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 10 file(s)
10 files changed
+243−21
BRIDGE_VERSION+1−1@@ -1 +1 @@-1.9.95+1.9.96
adom-bridge-fusion-manifest.json+4−4@@ -1,10 +1,10 @@ { "manifest_version": 1, "name": "fusion360",- "version": "1.9.95",- "url": "https://wiki.adom.inc/download/adom/fusion-bridge/1.9.110/adom-bridge-fusion-v1.9.95.zip",- "sha256": "61f5e21e3d37d497a074ef75eb46cbdb0c6de6bbfa07921571d4f7b8e6eca61f",- "size": 429881,+ "version": "1.9.96",+ "url": "https://wiki.adom.inc/download/adom/fusion-bridge/1.9.111/adom-bridge-fusion-v1.9.96.zip",+ "sha256": "1c6c1897c34fd53b4d0b5a1454d3208bd40f5f15e8691b58d59e7aca8b1defe3",+ "size": 432038, "verbPrefixes": [ "fusion_" ],
bridge.json+1−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "fusion360", "displayName": "Autodesk Fusion 360",- "version": "1.9.95",+ "version": "1.9.96", "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.toml+1−1@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.9.110"+version = "1.9.111" edition = "2021" [dependencies]
dev-skills/fusion-dialogs/SKILL.mdadded+107@@ -0,0 +1,107 @@+---+name: fusion-dialogs+description: DEVELOPER-only. How the Fusion bridge deals with modal dialogs - and, far more importantly, how it avoids CAUSING them. Read before writing any verb that drives Fusion's UI, opens a library artifact, closes a document, or reports "add-in not responding". Trigger words - fusion dialog, modal, Create new footprint, save prompt, blocking dialog, add-in not responding, main thread blocked, dismiss dialog.+user-invocable: false+---++# Fusion dialogs: prevent, then detect, then answer++**The rule, in John's words (2026-08-21): "you should never allow dialogs like that to ever+even show cuz they screw you up, so you should pre-emptively make sure they never show up."**++A modal in Fusion is not a cosmetic annoyance. Fusion runs one command at a time on its main+thread, and a modal **holds that thread until somebody answers it**. So every dialog we cause+turns into:++- the verb that raised it hanging,+- every later verb timing out,+- `fusion_addin_not_responding` reported to the caller,+- a human concluding the add-in crashed and restarting Fusion,++which is a spectacular amount of damage from a Yes/No box. Ranked responses:++| rank | approach | when |+|---|---|---|+| 1 | **Never raise it** - check preconditions before asking Fusion | always possible for anything we control |+| 2 | **Detect + answer** deterministically | dialogs raised by Fusion itself (licensing, crash, update) |+| 3 | Report it with a screenshot | genuinely user-decidable only (seat conflict) |++Rank 3 is a last resort, not a default. "Run `fusion_dismiss_blocking_dialogs` afterwards" is+NOT an acceptable design - it hands the caller a mess we chose to make.++## The case that taught this: "Create new footprint 'X'?"++`EDIT <name>.pac` on a name that is **not in the open library** makes Fusion offer to create+it. That modal then blocks the main thread. The bridge shipped for months with this hint at+the call site:++> "EDIT on a missing name pops a 'Create new?' dialog - run fusion_dismiss_blocking_dialogs+> if one appeared."++We *knew*, documented it, and still caused it. That is the exact inversion this skill exists+to prevent.++**The fix is a precondition check, not a sweeper.** `_resolve_artifact_name()` parses the .lbr+(EAGLE libraries are XML, so `<symbol>/<package>/<deviceset>` name attributes are the source+of truth), resolves exact then unique case-insensitive matches, and if the name is absent+**refuses before touching Fusion**:++```+errorCode: "artifact_not_in_library"+available: [... what the library actually holds ...]+_hint: "Deliberately refused BEFORE touching Fusion ... Nothing was opened and no+ dialog was raised."+```++A caller gets the real answer (wrong name, here are the right ones) in milliseconds instead+of a hang. **Generalise it: before any command that can prompt, verify the thing you are+about to name actually exists.**++## Detection: the title is often useless++Fusion titles several unrelated modals just `Fusion`. A title-only classifier cannot tell+"Create new footprint" from anything else, which is why ours missed it completely.++**The identity lives in the BODY TEXT.** Two ways to read it, both background and read-only:++- `desktop_find_controls {hwnd}` - Qt publishes the prompt as Text controls in the a11y tree.+ This is what `_dialog_text()` uses.+- `desktop_screenshot_window {hwnd}` - ab returns the window **plus its owned child windows**+ as separate images. Use this when UIA is unavailable (some Qt modals expose nothing), and+ when you need to SHOW a human what is blocking.++`classify_blocking_dialogs()` therefore matches on title first, then falls back to body text+whenever the title is generic, and reports `matchedOn: "title" | "bodyText"`.++## Answering: pick the answer that does not hide the bug++For `create_confirm` the bridge answers **No**, deliberately. "Yes" would invent an empty+footprint, the surface would "succeed", and the real fault - a wrong name - would surface+days later as a mysteriously blank part. **Prefer the answer that fails loudly over the one+that produces a plausible artifact.**++Same reasoning as the transfer-integrity rule ab landed on: a zero-filled gap makes a file of+exactly the right length that is silently wrong.++## Never report a blocked main thread as a crash++Three distinct states that look identical to a naive caller, all now distinguished:++| state | errorCode | remedy |+|---|---|---|+| a modal is holding the main thread | classified dialog + `mainThreadBusy` | answer or clear it |+| a long command is still running | `fusion_addin_busy` (+ `busyCommand`, `elapsedSeconds`) | WAIT - a staged surface measured 546s |+| the host stopped pumping messages | `fusion_host_hung` (+ `hungSignals`) | force-restart; nothing reaches it over IPC |+| the add-in genuinely is not serving | `fusion_addin_not_responding` (+ `hostResponding: true`) | restart Fusion; it only loads at launch |++Collapsing these into one error is what sent ab hunting a phantom dialog (#38) and made a+9-minute surface look like a dead add-in (#40).++## Checklist for a new verb that drives the UI++1. Can it prompt? List every dialog it could raise, then remove each cause.+2. Verify names/paths/state BEFORE issuing the command.+3. If a dialog is still possible, classify it by BODY TEXT and choose the answer that does+ not fabricate an artifact.+4. Never return "not responding" without checking busy vs hung vs modal first.+5. Never tell the caller to run a dismisser as part of the happy path.
handlers/dialog_classify.py+47−1@@ -72,6 +72,21 @@ _RULES = [ # FusionLauncher, so classify_launch_dialogs enumerates ALL top-level # windows to catch them - get_fusion_window_info would miss them). ── + # "Create new footprint 'X'?" - Fusion offering to CREATE something we asked to open.+ # The bridge should never see this (we verify the name exists first, see+ # _resolve_artifact_name), but a library opened by another route can still raise it, and+ # it BLOCKS THE MAIN THREAD until answered - which reads downstream as "add-in not+ # responding" rather than "a modal is waiting". NOTE the window TITLE is just "Fusion":+ # this is only identifiable from its BODY TEXT, which is why title-only classification+ # missed it entirely (John, 2026-08-21).+ (("create new footprint", "create new symbol", "create new device",+ "create new package", "create new library"),+ "create_confirm",+ "Fusion is asking whether to CREATE an artifact we asked to open, which means the name "+ "does not exist in the library. Answering 'Yes' invents an empty artifact and hides the "+ "real problem, so the bridge answers NO and reports the missing name. Fix the NAME "+ "(fusion_show_* refuses up front with `available` listing what the .lbr actually holds)."),+ # Fusion CRASHED and is offering to send a report. Benign to close (the X == Close, # the report still uploads its basic data), and CRITICALLY it is the tell that Fusion # is gone even though a Fusion360.exe still shows in tasklist - which is exactly how@@ -155,7 +170,7 @@ FAILURE_CATEGORIES = {"operation_error"} DECISION_CATEGORIES = {"session_conflict_decision", "session_suspend_confirm"} # Categories the launch code MAY auto-dismiss (a benign ack; WM_CLOSE == Cancel/OK). # Everything else (decisions, sign-in) is SURFACED, never auto-closed.-AUTO_DISMISS_CATEGORIES = {"launch_error", "already_open", "crash_report"}+AUTO_DISMISS_CATEGORIES = {"launch_error", "already_open", "crash_report", "create_confirm"} # A crash report means the app is GONE, not merely dialog-blocked: clearing it is not # enough, the bridge must relaunch. Kept separate from AUTO_DISMISS so a caller can tell # "I closed a nuisance" from "I closed a nuisance and must now restart the app".@@ -250,6 +265,7 @@ def classify_launch_dialogs() -> list: "title": title, "category": category, "resolution": resolution,+ "matchedOn": dlg.get("matchedOn", "title"), "decision": category in DECISION_CATEGORIES, }) return out@@ -268,6 +284,27 @@ def close_dialog_bg(hwnd) -> bool: return False +def _dialog_text(hwnd) -> str:+ """The dialog's BODY TEXT, read via UIA.++ Fusion titles several distinct modals just "Fusion", so a title-only classifier cannot+ tell "Create new footprint 'X'?" from any other one (John, 2026-08-21). ab exposes the+ a11y tree through desktop_find_controls, and Qt publishes the prompt as Text controls,+ so the identity we need is right there. Read-only and background: no focus is taken.+ Returns "" on any failure - text we could not read must never become a misclassification.+ """+ if not hwnd:+ return ""+ try:+ import ad_client+ r = ad_client.call("desktop_find_controls", {"hwnd": int(hwnd)}, timeout=10) or {}+ data = r.get("data") if isinstance(r.get("data"), dict) else r+ ctrls = (data or {}).get("controls") or (data or {}).get("matches") or []+ return " ".join(str(c.get("name") or "") for c in ctrls if c.get("name"))[:400]+ except Exception:+ return ""++ def classify_blocking_dialogs() -> list: """Return classified blocking dialogs currently open in Fusion. @@ -282,6 +319,15 @@ def classify_blocking_dialogs() -> list: for dlg in info.get("dialogs", []) or []: title = dlg.get("title", "") classified = _category_for_title(title)+ # A title-only match of "Fusion" tells us nothing. Fall back to the BODY TEXT, which+ # is where the identity actually lives for Fusion's generic-titled modals.+ if (not classified) or classified[0] in ("generic_modal_read_screenshot", "unknown"):+ body = _dialog_text(dlg.get("hwnd"))+ if body:+ by_text = _category_for_title(body)+ if by_text:+ classified = by_text+ dlg["matchedOn"] = "bodyText" if not classified: continue category, resolution = classified
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.110"+DASH_VERSION="1.9.111" 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.110",+ "version": "1.9.111", "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−7@@ -1,6 +1,6 @@ { "slug": "fusion-bridge",- "version": "1.9.110",+ "version": "1.9.111", "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",@@ -70,7 +74,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.",@@ -99,9 +106,5 @@ "label": "Part volume", "prompt": "How much material does each printed part in this assembly use?" }- ],- "author": {- "name": "John Lauer",- "email": "[email protected]"- }+ ] }
server.py+70−4@@ -6746,9 +6746,75 @@ def _artifact_name_from(args, *frag_keys): return "" -def _show_library_view(name, suffix):- """EDIT to a library view + fit + background screenshot. Library must be open."""++def _library_artifact_names(lbr_path: str) -> dict:+ """The names that actually EXIST in a .lbr: {"sym": [...], "pac": [...], "dev": [...]}.++ EAGLE/Fusion libraries are XML, so the <symbol>/<package>/<deviceset> name attributes+ are the source of truth. Empty dict when the file is unreadable - callers must treat+ "I could not check" as "do not guess", never as "it is missing".+ """+ try:+ import xml.etree.ElementTree as _ET+ root = _ET.parse(lbr_path).getroot()+ except Exception:+ return {}+ def names(tag):+ return [e.get("name") for e in root.iter(tag) if e.get("name")]+ return {"sym": names("symbol"), "pac": names("package"), "dev": names("deviceset")}+++def _resolve_artifact_name(name: str, suffix: str, lbr_path: str):+ """Resolve `name` to something that EXISTS, or say it does not - WITHOUT asking Fusion.++ ⚠️ PREVENTION, NOT CLEANUP (John, 2026-08-21: "you should never allow dialogs like that+ to ever even show cuz they screw you up"). `EDIT <name>.<suffix>` on a name that is not+ in the library pops Fusion's "Create new footprint 'X'?" modal, which blocks the add-in's+ main thread until somebody answers it - so a typo became a hang, and every later verb+ read as "add-in not responding". This bridge KNEW that: the old hint here told callers to+ run fusion_dismiss_blocking_dialogs afterwards. Telling the caller to sweep up a mess we+ chose to make is exactly backwards.++ Returns (resolved_name, None) or (None, error_dict). Exact match wins; then a unique+ case-insensitive match, because EAGLE names round-trip through tools that change case.+ """+ have = _library_artifact_names(lbr_path).get(suffix if suffix in ("sym", "pac", "dev") else "", [])+ if not have:+ return name, None # cannot read the library -> do not block the call+ if name in have:+ return name, None+ ci = [h for h in have if h.lower() == str(name).lower()]+ if len(ci) == 1:+ return ci[0], None+ return None, {+ "success": False,+ "error": "No %s named %r in that library, so the EDIT was NOT issued." % (+ {"sym": "symbol", "pac": "footprint", "dev": "device"}.get(suffix, suffix), name),+ "errorCode": "artifact_not_in_library",+ "available": have[:40],+ "_hint": ("Deliberately refused BEFORE touching Fusion: EDIT on a missing name pops a "+ "'Create new ...?' modal that blocks the add-in's main thread and makes every "+ "later verb look like a crash. Pick a name from `available` (they are the "+ "<symbol>/<package>/<deviceset> name attributes in the .lbr, the source of "+ "truth) and re-call. Nothing was opened and no dialog was raised."),+ }+++def _show_library_view(name, suffix, lbr_path=None):+ """EDIT to a library view + fit + background screenshot. Library must be open.++ Verifies the name EXISTS first when we know the .lbr, so Fusion is never asked to open+ something that would make it prompt (see _resolve_artifact_name).+ """ import time+ if lbr_path:+ resolved, err = _resolve_artifact_name(name, suffix, lbr_path)+ if err:+ return err+ if resolved != name:+ print("[Fusion Bridge] resolved %r -> %r from the library (avoids a Create-new prompt)"+ % (name, resolved))+ name = resolved edit = _proxy_to_addin("electron_run", {"command": "EDIT %s.%s" % (name, suffix)}, timeout=15) if not edit.get("success"): return {"success": False, "error": "EDIT %s.%s failed: %s" % (name, suffix, edit.get("error")),@@ -6808,7 +6874,7 @@ def _orchestrate_show_symbol(args: dict) -> dict: err, wrapped = _show_prepare_library(args, symbol_xml=symbol_xml, name=name) if err: return err- out = _show_library_view(name, "sym")+ out = _show_library_view(name, "sym", wrapped or (args.get("libraryPath") or "").replace("\\", "/")) if wrapped and out.get("success"): out["wrappedLibrary"] = wrapped out["_hint"] = ("Bare symbol was wrapped in a generated library at %s (a deviceset with one "@@ -6829,7 +6895,7 @@ def _orchestrate_show_footprint(args: dict) -> dict: err, wrapped = _show_prepare_library(args, package_xml=package_xml, name=name) if err: return err- out = _show_library_view(name, "pac")+ out = _show_library_view(name, "pac", wrapped or (args.get("libraryPath") or "").replace("\\", "/")) if wrapped and out.get("success"): out["wrappedLibrary"] = wrapped out["_hint"] = "Bare footprint was wrapped in a generated library at " + wrapped + "."