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 16 file(s)
16 files changed
+184−54
BRIDGE_VERSION+1−1CHANGELOG.md+18bridge.json+1−1contracts/describe.json+3−2dashboard/Cargo.lock+1−1dashboard/Cargo.toml+1−1describe.py+2−2install.sh+1−1package.json+1−1page.json+2−2rust/Cargo.lock+5−5rust/Cargo.toml+1−1rust/bridge/src/lbr_use.py+30−20rust/bridge/src/orchestrations.rs+86−6rust/bridge/src/routing_script.py+9−6tests/test_lbr_use.py+22−4BRIDGE_VERSION+1−1@@ -1 +1 @@-1.13.47+1.13.48
CHANGELOG.md+18@@ -3,6 +3,24 @@ 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.48 (2026-11-01)++Issue #136 (Andy Engevold, AI Flow on 1.13.47):+- Fusion 2706's ADD also raises its `assetAdded` error on a .lbr with a UTF-8 byte-order mark or single-quoted+ attributes (bisected live). `fusion_schematic_run`, `fusion_sch_place_part` and `fusion_sch_swap_part` now rewrite+ those in place too, keeping the element tree (`libraryReformatted`). Found harmless, and left alone: CRLF, indentation,+ no XML declaration, a DOCTYPE, comments, non-ASCII text, apostrophes in text, an unresolvable 3D package URN.+- `fusion_new_electronics_from_eagle`: the Open dialog that "never appeared" was on screen. The command is queued, and a+ busy Fusion opened the dialog after the 90 s wait, with nothing left to fill or close it. The import now waits up to+ 5 minutes (stage `waiting_for_open_dialog`); if it still gives up (`open_dialog_not_seen`), it closes a late dialog and+ the .brd one it chains to for the next 10 minutes, until another import starts. It finds the dialog without depending+ on Windows' language (the File name control, not the title).+- The import no longer takes an unreadable Adom Bridge status as approval: it refuses `ab_unreachable` before any+ dialog opens. If the approval wait itself breaks (a 504), it keeps reading the grant for the rest of the 5 minutes.+- `fusion_board_seed` with no fixed parts writes no library, rather than an empty one under the project library's name.+- `fusion_schematic_run`: the replies name `libraryLoaded`, the .lbr the bridge loads with USE itself (USE is not in the+ command list). A part name in lower case no longer reads as a failed ADD: EAGLE keeps names in upper case.+ ## 1.13.47 (2026-10-31) Issue #135, the actual cause, bisected live on Fusion 2706.1.14 with the AI Flow lane (adom-aiflow#114): Fusion
bridge.json+1−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "fusion360", "displayName": "Autodesk Fusion 360",- "version": "1.13.47",+ "version": "1.13.48", "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+3−2@@ -1,6 +1,6 @@ { "contract": "fusion-bridge verb catalog",- "frozen": "2026-10-31",+ "frozen": "2026-11-01", "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": [@@ -22204,6 +22204,7 @@ "output": { "added": "[name]", "commands": "int",+ "libraryLoaded": "path", "libraryReformatted": "bool", "nets": "{net:[part.pin]}", "parts": "[name]",@@ -22211,7 +22212,7 @@ "verified": "bool" }, "statusVerb": "fusion_get_app_state",- "summary": "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. 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 with no line breaks between its elements is rewritten in place first, whitespace only (libraryReformatted): Fusion 2706 cannot ADD from one. Readback: the parts and nets on the sheet from a fresh export.",+ "summary": "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. 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) is rewritten in place first, keeping its element tree (libraryReformatted). The bridge sends USE itself, for the file named in libraryLoaded. Readback: the parts and nets on the sheet from a fresh export.", "timeoutSeconds": 120 }, {
dashboard/Cargo.lock+1−1@@ -4,7 +4,7 @@ version = 4 [[package]] name = "fusion-dashboard"-version = "1.13.47"+version = "1.13.48" dependencies = [ "serde_json", ]
dashboard/Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.13.47"+version = "1.13.48" edition = "2021" [dependencies]
describe.py+2−2@@ -662,9 +662,9 @@ _T = [ ("fusion_ripup_nets", "Take whole nets' tracks and vias back up (RIPUP per net, one undo step each) so they can be routed again, wider or another way. Their pours stay. Readback proves no track or via is left on those nets and no pour was lost.", {"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. 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 with no line breaks between its elements is rewritten in place first, whitespace only (libraryReformatted): Fusion 2706 cannot ADD from one. Readback: the parts and nets on the sheet from a fresh export.",+ ("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. 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) is rewritten in place first, keeping its element tree (libraryReformatted). 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"},- {"success":"bool","verified":"bool","parts":"[name]","added":"[name]","nets":"{net:[part.pin]}","commands":"int","libraryReformatted":"bool"},120,"fusion_get_app_state",False,{}),+ {"success":"bool","verified":"bool","parts":"[name]","added":"[name]","nets":"{net:[part.pin]}","commands":"int","libraryLoaded":"path","libraryReformatted":"bool"},120,"fusion_get_app_state",False,{}), ("fusion_board_bom", "The bill of materials from the live board's own part attributes: one line per MPN with designators, quantity, value, package, manufacturer and the Mouser, DigiKey and LCSC numbers, plus the JLCPCB form (Comment, Designator, Footprint, LCSC). Board-only parts (test pads) are listed as such, and any part missing a distributor number is named. Read-only; with outputDir it also writes bom.csv and bom-jlcpcb.csv there.", {"expectDocument":"required exact board name","outputDir":"optional folder on this machine for bom.csv + bom-jlcpcb.csv"}, {"lines":"[{designators,quantity,value,package,mpn,manufacturer,mouser,digikey,lcsc}]","csv":"str","jlcpcbCsv":"str","files":"{name:path}","boardOnly":"[str]","missingPartNumbers":"{mouser,digikey,lcsc:[str]}"},60,"fusion_routing_state",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.47"+DASH_VERSION="1.13.48" 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.47",+ "version": "1.13.48", "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.47",+ "version": "1.13.48", "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.47"+version = "1.13.48" dependencies = [ "adom-eda-checks", "base64",@@ -299,7 +299,7 @@ dependencies = [ [[package]] name = "fusion-core"-version = "1.13.47"+version = "1.13.48" dependencies = [ "serde", "serde_json",@@ -310,7 +310,7 @@ dependencies = [ [[package]] name = "fusion-lbr"-version = "1.13.47"+version = "1.13.48" dependencies = [ "regex", "roxmltree",@@ -322,14 +322,14 @@ dependencies = [ [[package]] name = "fusion-os-mac"-version = "1.13.47"+version = "1.13.48" dependencies = [ "fusion-core", ] [[package]] name = "fusion-os-win"-version = "1.13.47"+version = "1.13.48" 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.47"+version = "1.13.48" edition = "2021" license = "MIT"
rust/bridge/src/lbr_use.py+30−20@@ -1,39 +1,49 @@ -# ── loading a .lbr for ADD / REPLACE (issue #135) ───────────────────────────────────────────────-# Fusion 2706 raises "PlacePartWorker.cpp, Line 23. Condition: assetAdded" on every ADD from a-# library file whose elements are not separated by line breaks (<packages><package ...> on one-# line), whatever the parts are; the same library with a line break between elements adds cleanly,-# with or without 3D packages (bisected live on 2706.1.14, 2026-10-31: one compact boundary in-# <devicesets> was enough). So a compact .lbr is rewritten in place with a line break between-# elements before USE. In place, because a copy at another path with the same library name makes-# Fusion ask which one to take (its Select Library dialog) once both have been USEd.+# ── loading a .lbr for ADD / REPLACE (issues #135, #136) ────────────────────────────────────────+# Fusion 2706's ADD reads the library file as text. It raises "PlacePartWorker.cpp, Line 23.+# Condition: assetAdded" on every ADD from a file that has (each bisected live on 2706.1.14,+# 2026-10-31 and 2026-11-01, on a library that adds cleanly otherwise):+# - elements inside <library> with no line break between them (one is enough);+# - a UTF-8 byte-order mark;+# - attributes in single quotes.+# Fine as they are: indentation, tabs, CRLF, no XML declaration, a DOCTYPE, comments, non-ASCII+# text, apostrophes and escaped HTML in text, the EAGLE version, an unresolvable 3D package URN.+# So such a file is rewritten in place before USE, keeping its element tree exactly. In place,+# because a copy at another path with the same library name makes Fusion ask which one to take+# (its Select Library dialog) once both have been USEd. def _lbr_shape(root): return [(e.tag if isinstance(e.tag, str) else 'comment', sorted(e.attrib.items()), (e.text or '').strip(), (e.tail or '').strip()) for e in root.iter()] -def lbr_needs_line_breaks(data):- """True when two elements inside the file's <library> (bytes or text) touch with no line break- between them. The header outside it may be compact: Fusion's own exports are, and add fine."""+def lbr_needs_rewrite(data):+ """True when the file (bytes or text) has anything Fusion 2706's ADD cannot read: a byte-order+ mark, single-quoted attributes, or two elements inside <library> with no line break between+ them. A compact header outside <library> and a single-quoted XML declaration are fine: Fusion's+ own exports have them.""" import re as _re if isinstance(data, str): data = data.encode('utf-8')+ if data[:3] == b'\xef\xbb\xbf' or data[:2] in (b'\xff\xfe', b'\xfe\xff'): return True+ body = _re.sub(rb'^\s*<\?xml[^>]*\?>', b'', data, count=1)+ if _re.search(rb"""<[A-Za-z][^<>"']*\s[\w:.-]+\s*=\s*'""", body): return True i = data.find(b'<library') return i >= 0 and _re.search(rb'>[ \t]*<', data[i:]) is not None def normalize_lbr(path):- """Rewrite a compact .lbr in place with a line break between elements. Returns True when it- rewrote the file. Only whitespace between elements changes: the element tree (tags, attributes,- text, comments) is compared before and after, and nothing is written when it differs."""+ """Rewrite a .lbr Fusion 2706 cannot ADD from in place: no byte-order mark, double-quoted+ attributes, a line break between elements. Returns True when it rewrote the file. Only the file's+ formatting changes: the element tree (tags, attributes, text, comments) is compared before and+ after, and nothing is written when it differs.""" import os as _os import re as _re import xml.etree.ElementTree as _ET from pathlib import Path as _P p = _P(path) raw = p.read_bytes()- if not lbr_needs_line_breaks(raw):+ if not lbr_needs_rewrite(raw): return False # parsed from bytes, so the file's own declared encoding is honoured; written back as UTF-8 root = _ET.fromstring(raw, parser=_ET.XMLParser(target=_ET.TreeBuilder(insert_comments=True)))@@ -42,7 +52,7 @@ def normalize_lbr(path): body = _ET.tostring(root, encoding='unicode') again = _ET.fromstring(body, parser=_ET.XMLParser(target=_ET.TreeBuilder(insert_comments=True))) if _lbr_shape(again) != before:- raise RuntimeError('rewriting %s with line breaks would change more than whitespace' % p.name)+ raise RuntimeError('rewriting %s for Fusion would change more than its formatting' % p.name) m = _re.search(r'<!DOCTYPE[^>]*>', raw[:2000].decode('latin-1')) out = '<?xml version="1.0" encoding="utf-8"?>\n' + (m.group(0) + '\n' if m else '') + body + '\n' tmp = p.with_name(p.name + '.adom-tmp')@@ -53,13 +63,13 @@ def normalize_lbr(path): def use_library(app, path): """USE a .lbr on this machine so ADD/REPLACE can take its devices, rewriting it first when- Fusion 2706 would refuse it (see above). Returns True when the file was rewritten."""+ Fusion 2706 could not read it (see above). Returns True when the file was rewritten.""" from pathlib import Path as _P try: changed = normalize_lbr(path) except PermissionError:- raise RuntimeError('library_not_writable: %s has no line breaks between its elements, which Fusion 2706 cannot ADD from '- '(PlacePartWorker assetAdded), and this machine will not let the bridge rewrite it. Save it somewhere writable '- 'and pass that path' % path)+ raise RuntimeError('library_not_writable: %s is formatted in a way Fusion 2706 cannot ADD from (PlacePartWorker assetAdded: '+ 'no line breaks between elements, a byte-order mark or single-quoted attributes), and this machine will not let '+ 'the bridge rewrite it. Save it somewhere writable and pass that path' % path) app.executeTextCommand("Electron.run USE '%s';" % _P(path).as_posix()) return changed
rust/bridge/src/orchestrations.rs+86−6@@ -1258,6 +1258,50 @@ fn close_new_untitled(before: &[String], pass: &dyn Fn(&str, Value) -> Value) -> closed } +/// Bumped by each import: a late-dialog watcher left by an earlier import stops when a new one starts,+/// so it can never close the new import's dialog.+static EAGLE_IMPORT_GEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);++/// The importer's Open dialog on this machine: by title in ab's window list, else any native file+/// dialog on Fusion's processes (a Windows that is not in English titles it differently: the common+/// dialog's File name box, control 1148, is what proves it). An ab failure is noted in `ab_error`.+fn eagle_open_dialog(ab: &dyn fusion_core::ab::AbClient, caller: &fusion_core::ab::Caller, pids: &[u32], ab_error: &std::cell::RefCell<Option<String>>) -> Option<i64> {+ match ab.call("desktop_list_windows", json!({}), caller, 10) {+ Ok(v) => {+ let found = 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());+ if found.is_some() { return found; }+ }+ Err(e) => { *ab_error.borrow_mut() = Some(format!("desktop_list_windows: {e}")); }+ }+ crate::small_verbs::native_file_dialogs(ab, caller, pids).iter().find_map(|w| {+ let h = w["hwnd"].as_i64()?;+ if w["title"].as_str().unwrap_or("").trim().eq_ignore_ascii_case("open") { return Some(h); }+ ab.call("desktop_find_controls", json!({"hwnd": h}), caller, 10).ok().filter(|c| c.to_string().contains("1148")).map(|_| h)+ })+}++/// The import's command is queued and cannot be taken back, so when the bridge stops waiting Fusion may+/// still open the dialog later, with nobody left to fill it, blocking Fusion on the user's screen (#136).+/// For ten minutes this closes what that import opens (the .sch dialog, then the .brd one it chains to),+/// and stops at once if another import starts.+fn watch_for_late_open_dialog(state_dir: std::path::PathBuf, caller: fusion_core::ab::Caller, pids: Vec<u32>, wanted: String, gen: u64) {+ std::thread::spawn(move || {+ use std::sync::atomic::Ordering;+ let Some(ab) = fusion_core::ab::Loopback::from_env() else { return };+ let noted = std::cell::RefCell::new(None);+ let (mut closed, mut last_close) = (0, None::<std::time::Instant>);+ for _ in 0..200 {+ std::thread::sleep(Duration::from_secs(3));+ if EAGLE_IMPORT_GEN.load(Ordering::SeqCst) != gen { return; }+ if let Some(h) = eagle_open_dialog(&ab, &caller, &pids, ¬ed) {+ if crate::small_verbs::close_native_dialog(&ab, &caller, h).0 { closed += 1; last_close = Some(std::time::Instant::now()); }+ } else if last_close.map(|t| t.elapsed() > Duration::from_secs(30)).unwrap_or(false) { break; }+ }+ if closed > 0 { crate::fusion_prefs::action_ledger(&state_dir, "eagle_import_late_dialog_closed", json!({"wanted": wanted, "closed": closed})); }+ });+}+ pub fn new_electronics_from_eagle(ctx: &Ctx, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value { // Async by contract (issue #90): the dialog-driven import outlives ab's relay budget, so the // default returns a runId at once and the work runs on this exe's own thread; the caller@@ -1327,22 +1371,47 @@ pub fn new_electronics_from_eagle(ctx: &Ctx, args: &Value, pass: &dyn Fn(&str, V "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 import_gen = EAGLE_IMPORT_GEN.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + 1;+ let ab_error: std::cell::RefCell<Option<String>> = std::cell::RefCell::new(None);+ let find_open_dialog = || -> Option<i64> { eagle_open_dialog(&ab, &ctx.caller, &ctx.pids, &ab_error) }; let (sch_base, brd_base) = (sch.rsplit('/').next().unwrap_or("").to_string(), brd.rsplit('/').next().unwrap_or("").to_string()); // The Open dialogs are filled with UI Automation and keystrokes, which ab gates behind its shell // approval even for a bridge (#133: on a PC with no grant every fill was refused, needs_approval). // Ask BEFORE any dialog opens: the prompt shows in Adom Bridge (or Hydrogen), and the run waits up to // five minutes for the click; no grant, no dialog.- let approved = |st: &Value| { let s = &st["permissions"]["shell"]; let s = if s.is_object() { s } else { &st["data"]["permissions"]["shell"] }; s["autoApproved"].as_bool() == Some(true) };- if !ab.call("status", json!({}), &ctx.caller, 10).map(|s| approved(&s)).unwrap_or(true) {+ let approved = |st: &Value| { let s = &st["permissions"]["shell"]; let s = if s.is_object() { s } else { &st["data"]["permissions"]["shell"] }; s["autoApproved"].as_bool() == Some(true) || s["willPrompt"].as_bool() == Some(false) };+ // a status that cannot be read is not an approval: every step of the import needs ab, so opening a+ // dialog now would leave it on the user's screen with nothing able to fill or close it (#136)+ let mut status_now = Err(String::new());+ for attempt in 0..3 {+ if attempt > 0 { std::thread::sleep(Duration::from_secs(3)); }+ status_now = ab.call("status", json!({}), &ctx.caller, 10).map_err(|e| format!("{e}"));+ if status_now.is_ok() { break; }+ }+ let status_now = match status_now {+ Ok(v) => v,+ Err(e) => return json!({"success": false, "errorCode": "ab_unreachable", "imported": [], "dialogOpened": false, "abError": e,+ "error": "Not started: the bridge could not reach Adom Bridge, which it needs to fill in and close Fusion's Open dialog. No dialog was opened.",+ "_hint": "Retry in a moment; if it keeps failing, check Adom Bridge is running (bridge_list) and let it respawn this bridge."}),+ };+ if !approved(&status_now) { let tell = "Adom Bridge needs your OK to fill in Fusion's Open dialog for the EAGLE import: click Approve (1 hr, 24 hr or Auto) in the Adom Bridge window, on its notification, or in Hydrogen."; if let Some(rid) = args["_runId"].as_str() { let mut v = std::fs::read_to_string(eagle_run_path(&ctx.state_dir, rid)).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok()).unwrap_or(json!({})); v["stage"] = json!("waiting_for_approval"); v["tellUser"] = json!(tell); eagle_run_write(&ctx.state_dir, rid, &v); }+ let asked_at = std::time::Instant::now(); let g = ab.call("request_shell_approval", json!({"wait": true, "timeoutSec": 300, "reason": "Fill in Fusion's Open dialog with the EAGLE .sch and .brd paths for the design import"}), &ctx.caller, 320);- let granted = g.as_ref().map(|v| v["granted"].as_bool() == Some(true) || v["data"]["granted"].as_bool() == Some(true)).unwrap_or(false);+ let mut granted = g.as_ref().map(|v| v["granted"].as_bool() == Some(true) || v["data"]["granted"].as_bool() == Some(true)).unwrap_or(false);+ // the wait itself can break (a 504 from ab, #136) while the prompt still stands: keep reading+ // the grant for the rest of the five minutes instead of giving up on a click still to come+ if !granted && g.is_err() {+ while asked_at.elapsed() < Duration::from_secs(300) {+ std::thread::sleep(Duration::from_secs(3));+ if ab.call("status", json!({}), &ctx.caller, 10).map(|s| approved(&s)).unwrap_or(false) { granted = true; break; }+ }+ } if let Some(rid) = args["_runId"].as_str() { let mut v = std::fs::read_to_string(eagle_run_path(&ctx.state_dir, rid)).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok()).unwrap_or(json!({})); v["stage"] = json!("started"); v["approval"] = json!({"granted": granted});@@ -1380,14 +1449,25 @@ 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 i in 0..(if want == "sch" { 60 } else { 20 }) {+ // up to five minutes: a busy Fusion opened it after the old 90 s, with nobody left to fill it (#136)+ let first_wait = args["_openDialogWaitSec"].as_u64().map(|s| (s * 2 / 3).max(1)).unwrap_or(200);+ if want == "sch" { if let Some(rid) = args["_runId"].as_str() {+ let mut v = std::fs::read_to_string(eagle_run_path(&ctx.state_dir, rid)).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok()).unwrap_or(json!({}));+ v["stage"] = json!("waiting_for_open_dialog");+ eagle_run_write(&ctx.state_dir, rid, &v);+ } }+ for i in 0..(if want == "sch" { first_wait } else { 40 }) { 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; }- return json!({"success": false, "error": format!("The '{want}' Open dialog never appeared."), "_hint": "Screenshot the Fusion hwnd + READ screenshots[] for a blocking modal; the importer may have errored on the EAGLE source. Verify the .sch/.brd are valid EAGLE.", "data": {"picked": picked}});+ watch_for_late_open_dialog(ctx.state_dir.clone(), ctx.caller.clone(), ctx.pids.clone(), base.to_string(), import_gen);+ let ab_note = ab_error.borrow().clone();+ return json!({"success": false, "errorCode": "open_dialog_not_seen", "imported": picked, "abError": ab_note, "lateDialogWatch": true,+ "error": format!("Fusion's '{want}' Open dialog did not appear in the time the bridge waits{}. Nothing was imported. Fusion may still open it: for the next ten minutes the bridge closes a late one, so it cannot stay on your screen.", if ab_note.is_some() { ", and Adom Bridge could not list the windows" } else { "" }),+ "_hint": "Fusion was most likely busy. Let it settle (fusion_get_app_state answers again), close any empty Untitled documents, and run the import again; starting a new import stops the watch. Autodesk errors on the EAGLE source would show as a dialog in fusion_check_dialogs."}); }; // 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)
rust/bridge/src/routing_script.py+9−6@@ -668,7 +668,7 @@ def sch_run(app, args): # command, so two in-use libraries with the same name at different paths make ADD ask which one.) prod = app.activeProduct def part_names():- try: return {prod.parts.item(i).name for i in range(prod.parts.count)}+ try: return {prod.parts.item(i).name.upper() for i in range(prod.parts.count)} except Exception: return None held = set() try:@@ -700,12 +700,12 @@ def sch_run(app, args): # instead of running on (#135 retried for minutes past the caller's timeout) want = SCH_FORMS[0][1].match(c.replace('SET CONFIRM YES; ', '').replace(' SET CONFIRM OFF;', '').strip()).group(3) names = part_names()- if names is not None and want not in names:+ if names is not None and want.upper() not in names: # EAGLE keeps part names in upper case return {'success': False, 'mutated': len(done) > 1, 'errorCode': 'add_failed', 'failedAt': len(done) - 1, 'failedCommand': c, 'part': want,- 'libraryFrom': used, 'libraryReformatted': reformatted, 'librariesHeld': sorted(held), 'commands': len(done), 'documentName': name,+ 'libraryFrom': used, 'libraryLoaded': lbr, 'libraryReformatted': reformatted, 'librariesHeld': sorted(held), 'commands': len(done), 'documentName': name, 'error': 'ADD of %s did not land: Fusion found no such device in %s (%s). The commands before it ran.' % (want, libname, 'the design holds that library, so the device is missing from it' if used == 'design' else 'Fusion did not take it from the .lbr loaded with USE'),- '_hint': 'Seed the design with fusion_board_seed (it embeds the whole library), or add the part to the design\'s library; then run the rest of the commands.'}+ '_hint': 'The bridge loads libraryLoaded with USE itself, so USE is not in the command list. Check the deviceset is in that file and that the schematic in front is the design you meant (documentName); then run the rest of the commands.'} if pause: app.executeTextCommand('Electron.ImmediateRepaint /full') import adsk; adsk.doEvents(); time.sleep(pause)@@ -713,11 +713,11 @@ def sch_run(app, args): app.executeTextCommand('Electron.ImmediateRepaint /full') _, after, nets = sch_snapshot(app) want = sorted({SCH_FORMS[0][1].match(c.strip()).group(3) for c in cmds if c.strip().startswith('ADD ')})- missing = [p for p in want if p not in after]+ missing = [p for p in want if p.upper() not in {a.upper() for a in after}] ok = not missing return {'success': ok, 'mutated': True, 'verified': ok, 'errorCode': None if ok else 'readback_mismatch', 'error': None if ok else 'parts not on the sheet after ADD: %s' % missing, 'documentName': name,- 'parts': after, 'added': [p for p in after if p not in before], 'nets': nets, 'commands': len(done), 'libraryFrom': used, 'libraryReformatted': reformatted}+ 'parts': after, 'added': [p for p in after if p not in before], 'nets': nets, 'commands': len(done), 'libraryFrom': used, 'libraryLoaded': lbr, 'libraryReformatted': reformatted} def board_bom(board, args):@@ -874,6 +874,9 @@ def board_seed(args): n = lib_b.find(tag) if n is not None: lib_b.remove(n) lib_brd = ET.tostring(lib_b, encoding='unicode')+ # no parts in the seed: no library either, rather than an empty one under the project library's+ # name (#136). schematic_run loads the .lbr itself for its ADDs.+ if not any(True for _ in lib.iter('deviceset')): lib_sch = lib_brd = '' # the rules: Fusion's own block, with the values the caller gives rules = FUSION_DEFAULT_RULES; r = args.get('rules') or {} def setp(k, v):
tests/test_lbr_use.py+22−4@@ -1,5 +1,5 @@-"""lbr_use.py: a compact .lbr is rewritten with line breaks (Fusion 2706 cannot ADD from one, #135),-only whitespace changes, and a library already broken into lines is left alone."""+"""lbr_use.py: a .lbr Fusion 2706 cannot ADD from (no line breaks, a byte-order mark, single-quoted+attributes: #135, #136) is rewritten keeping its element tree, and a readable one is left alone.""" import os import tempfile import unittest@@ -38,7 +38,7 @@ class LbrUse(unittest.TestCase): self.assertIn('<!DOCTYPE eagle SYSTEM "eagle.dtd">', out) self.assertIn('>NAME', out) self.assertIn('<devicesets>\n<deviceset name="RES">', out)- self.assertFalse(NS['lbr_needs_line_breaks'](open(p, 'rb').read()))+ self.assertFalse(NS['lbr_needs_rewrite'](open(p, 'rb').read())) self.assertFalse(os.path.exists(p + '.adom-tmp')) def test_second_pass_and_broken_files_are_left_alone(self):@@ -52,6 +52,24 @@ class LbrUse(unittest.TestCase): self.assertFalse(NS['normalize_lbr'](q)) self.assertEqual(open(q, encoding='utf-8').read(), text) + def test_bom_and_single_quotes_are_rewritten(self):+ for text in ('\ufeff' + BROKEN, BROKEN.replace('name="RES"', "name='RES'")):+ p = self.write(text)+ before = shape(p)+ self.assertTrue(NS['normalize_lbr'](p))+ self.assertEqual(shape(p), before)+ raw = open(p, 'rb').read()+ self.assertFalse(raw.startswith(b'\xef\xbb\xbf'))+ self.assertIn(b'name="RES"', raw)+ self.assertFalse(NS['lbr_needs_rewrite'](raw))++ def test_readable_shapes_are_left_alone(self):+ keep = ["<?xml version='1.0' encoding='utf-8'?>" + BROKEN[BROKEN.index('\n'):], # Fusion's own exports+ BROKEN.replace('made by a', "someone's part, made by a"), # an apostrophe in text+ BROKEN.replace('\n', '\r\n')]+ for text in keep:+ self.assertFalse(NS['lbr_needs_rewrite'](text), text[:60])+ def test_use_library_runs_use_after_the_rewrite(self): p = self.write(COMPACT) seen = []@@ -60,7 +78,7 @@ class LbrUse(unittest.TestCase): self.assertTrue(NS['use_library'](App(), p)) self.assertEqual(len(seen), 1) self.assertTrue(seen[0][0].startswith("Electron.run USE '"))- self.assertFalse(NS['lbr_needs_line_breaks'](seen[0][1]))+ self.assertFalse(NS['lbr_needs_rewrite'](seen[0][1])) if __name__ == '__main__':