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 73 file(s)
12 files changed
+129−23
BRIDGE_VERSION+1−1@@ -1 +1 @@-1.9.237+1.9.238
addin/AdomBridge/AdomBridge.py+32−4@@ -12,6 +12,8 @@ import traceback # Module-level references kept alive for the add-in lifetime _app = None _ui = None+# One start-failure modal per Fusion session; repeats go to the log (#69).+_start_error_shown = False _custom_event = None _event_handler = None _http_thread = None@@ -76,15 +78,36 @@ def run(context): # Start the HTTP server on a background daemon thread from . import http_server- _http_thread = http_server.start(CUSTOM_EVENT_ID, _app)+ try:+ _http_thread = http_server.start(CUSTOM_EVENT_ID, _app)+ except OSError:+ # Port 8774 already bound: a SIBLING copy of this add-in is serving. install()+ # writes every known AddIns location and newer Fusion builds scan more than one+ # (seen live on #69: FusionAddins AND API/AddIns both loaded), so a second copy+ # binding the port is normal operation, not an error the user can act on.+ # Stand down quietly; the serving copy has the job. (Colby, PR #62)+ adsk.core.Application.log(+ "Adom Bridge add-in: port 8774 already served by a sibling copy; this copy is standing down")+ return # Log to the Text Commands palette (non-blocking) adsk.core.Application.log(f"Adom Bridge add-in started (port 8774)") except Exception: # Only use messageBox for errors — worth blocking to show the user- if _ui:+ # ONE modal, total. The first failure is worth blocking to show the user; repeats+ # (Fusion can retry the lifecycle, and several installed copies can each fail the+ # same way) turned one broken deploy into a dialog storm - twice per launch, then+ # once per user action (issue #69, Colby, 2026-08-28). After the first box, log.+ global _start_error_shown+ if _ui and not _start_error_shown:+ _start_error_shown = True _ui.messageBox(f"Adom Bridge failed to start:\n{traceback.format_exc()}")+ else:+ try:+ adsk.core.Application.log(f"Adom Bridge failed to start:\n{traceback.format_exc()}")+ except Exception:+ pass def stop(context):@@ -110,5 +133,10 @@ def stop(context): _http_thread = None except Exception:- if _ui:- _ui.messageBox(f"Adom Bridge stop error:\n{traceback.format_exc()}")+ # NEVER a modal from stop. Fusion re-fires the lifecycle on ordinary events, so+ # when the add-in is broken a stop-time messageBox costs the user one dialog PER+ # ACTION they take (seen live 2026-08-28, #69). run() already told them once.+ try:+ adsk.core.Application.log(f"Adom Bridge stop error:\n{traceback.format_exc()}")+ except Exception:+ pass
adom-bridge-fusion-manifest.json+4−4@@ -1,10 +1,10 @@ { "manifest_version": 1, "name": "fusion360",- "version": "1.9.237",- "url": "https://wiki.adom.inc/download/adom/fusion-bridge/1.9.237/adom-bridge-fusion-v1.9.237.zip",- "sha256": "8180d60b5add20106e2fbb11afddbc8f583159c6ff50dc6526a8ed960f2c1723",- "size": 528977,+ "version": "1.9.238",+ "url": "https://wiki.adom.inc/download/adom/fusion-bridge/1.9.238/adom-bridge-fusion-v1.9.238.zip",+ "sha256": "eabd567eadb9e17c29fa16400539b4b7697ff5484b530ab7237ce305f124be5e",+ "size": 534015, "verbPrefixes": [ "fusion_" ],
bridge.json+1−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "fusion360", "displayName": "Autodesk Fusion 360",- "version": "1.9.237",+ "version": "1.9.238", "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.237"+version = "1.9.238" dependencies = [ "serde_json", ]
dashboard/Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.9.237"+version = "1.9.238" edition = "2021" [dependencies]
describe.py+1−1@@ -186,7 +186,7 @@ _T = [ ("fusion_open_cloud_file", "Open a CLOUD file by name (the front door to every design flow). PCB/ELECTRONICS NAV RULE: open the electronics DESIGN document (EcadDesignProductType), never a child .brd/.sch/3D directly - then switch views with fusion_show_schematic / fusion_show_2d_board / fusion_show_3d_board. LONG-RUNNING: fire it, then poll fusion_get_app_state until activeDocument matches (cloud opens on electronics designs take 1-3 min). A crash-recovery modal can block the open - fusion_dismiss_blocking_dialogs.", {"fileName": "required str", "projectName": "optional str (defaults to active project)", "folderPath": "optional str"}, {"success": "bool", "documentName": "str"}, 240, "fusion_get_app_state", True, {"fileName": "BQ25792", "projectName": "Main"}), ("fusion_open_design", "Open a LOCAL design file (routes through the add-in import). Prefer cloud flows (fusion_open_cloud_file) for team designs; this is for local .f3d/.step style files.", {"filePath": "required str (WINDOWS path)"}, {"success": "bool"}, 240, None, True, {"filePath": "C:/Temp/part.f3d"}), ("fusion_close_document", "Close ONE document WITHOUT the modal save dialog (save:false discards silently - the automation-safe close). Omit name to close the active document.", {"name": "optional str (default: active document)", "save": "optional bool default false"}, {"success": "bool", "closed": "str"}, 60, None, False, {"save": False}),- ("fusion_close_all_documents", "Close ALL open documents without save dialogs. DESTRUCTIVE to unsaved user work - confirm with the human before calling on a box you do not own. By default it SKIPS scratch 'Untitled' docs; pass closeUntitled:true to close the UNMODIFIED ones too (page issue #59: the library/bind flow left ~5 per import, 16 after three, and no caller could clear them), and force:true to also discard MODIFIED Untitled docs, which may hold real unsaved work.", {"save": "optional bool default false", "closeUntitled": "optional bool default false - also close UNMODIFIED Untitled scratch docs", "force": "optional bool default false - with closeUntitled, also discard MODIFIED Untitled docs"}, 60, None, False, {"closeUntitled": True}),+ ("fusion_close_all_documents", "Close ALL open documents without save dialogs. DESTRUCTIVE to unsaved user work - confirm with the human before calling on a box you do not own. By default it SKIPS scratch 'Untitled' docs; pass closeUntitled:true to close the UNMODIFIED ones too (page issue #59: the library/bind flow left ~5 per import, 16 after three, and no caller could clear them), and force:true to also discard MODIFIED Untitled docs, which may hold real unsaved work.", {"save": "optional bool default false", "closeUntitled": "optional bool default false - also close UNMODIFIED Untitled scratch docs", "force": "optional bool default false - with closeUntitled, also discard MODIFIED Untitled docs"}, {"success": "bool", "closed": "[str]", "wouldPrompt": "[str]|null"}, 60, None, False, {"closeUntitled": True}), ("fusion_reset_workspace", "Bring Fusion back to a clean slate between runs: closes named documents AND the Untitled scratch the library/bind flow leaves behind (page issue #59). Fusion always keeps ONE blank document alive, so a single remaining Untitled is correct rather than a failure. MODIFIED Untitled docs are left untouched unless force:true, because those can hold real unsaved work.", {"force": "optional bool default false - also discard MODIFIED Untitled docs"}, {"success": "bool", "closedByAddin": "list", "untitledScratchClosed": "int", "remaining": "list"}, 90, None, False, {}), ("fusion_document_info", "Metadata for the ACTIVE document: name, product type, save state, version, parent project.", {}, {"success": "bool", "name": "str", "productType": "str", "isSaved": "bool"}, 30, None, False, {}), ("fusion_execute_text_command", "Run a raw Fusion TEXT COMMAND (app.executeTextCommand) - the escape hatch to Fusion internals (Electron.* electronics commands included). Electron.* commands need an ELECTRONICS document context (schematic/board view active). Guard risky calls with expectDocument.", {"command": "required str", "expectDocument": "optional str (refuses wrong_document if the active doc does not match)"}, {"success": "bool", "output": "str"}, 60, None, False, {"command": "Electron.ImmediateRepaint /full"}),
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.237"+DASH_VERSION="1.9.238" 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
install_addin.py+46@@ -1,3 +1,4 @@+import os #!/usr/bin/env python3 """Install the AdomBridge add-in into EVERY Fusion add-in location. @@ -87,7 +88,52 @@ def find_addins_dir() -> Path | None: return TARGET_CANDIDATES[0] +def _check_relative_imports(src_root):+ """Refuse a payload whose relative imports do not all resolve INSIDE the payload.++ Issue #69 (Colby, 2026-08-28): the sync delivered a new commands/__init__.py that imports+ .activity_palette, without activity_palette.py, and Fusion's add-in bricked with a+ ModuleNotFoundError dialog storm. The half that landed referenced the half that did not.+ This is the transactional guarantee made cheap: check the payload before touching the+ installed tree, and if it is incomplete leave the installed add-in exactly as it was -+ which also means the mirror step can never delete a hand-restored file to re-brick a box.+ Returns a list of "file: imports X" problems; empty means clean.+ """+ import os, re+ problems = []+ py = []+ for d, _, fs in os.walk(src_root):+ for f in fs:+ if f.endswith(".py"):+ py.append(os.path.join(d, f))+ have = set(os.path.normpath(p) for p in py)+ have_dirs = set(os.path.normpath(os.path.dirname(p)) for p in py)+ pat = re.compile(r"^\s*from\s+(\.+)([\w.]*)\s+import", re.M)+ for f in py:+ try:+ txt = open(f, encoding="utf-8", errors="ignore").read()+ except Exception:+ continue+ for m in pat.finditer(txt):+ dots, mod = m.group(1), m.group(2)+ base = os.path.dirname(f)+ for _ in range(len(dots) - 1):+ base = os.path.dirname(base)+ if not mod:+ continue+ cand = os.path.normpath(os.path.join(base, *mod.split(".")))+ if not (cand + ".py" in have or os.path.join(cand, "__init__.py") in have or cand in have_dirs):+ problems.append("%s imports %s%s" % (os.path.relpath(f, src_root), dots, mod))+ return problems++ def install():+ _src = os.path.join(os.path.dirname(os.path.abspath(__file__)), "addin", "AdomBridge")+ _bad = _check_relative_imports(_src)+ if _bad:+ print("[install_addin] REFUSING: payload has unresolved relative imports - installing it "+ "would brick the add-in (issue #69): " + "; ".join(_bad))+ return None """Install the AdomBridge add-in into EVERY known Fusion add-in location. ALL of TARGET_CANDIDATES get the add-in (see module docstring - Fusion moved
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "fusion-bridge",- "version": "1.9.237",+ "version": "1.9.238", "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−8@@ -1,6 +1,6 @@ { "slug": "fusion-bridge",- "version": "1.9.237",+ "version": "1.9.238", "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,9 +111,5 @@ "label": "Part volume", "prompt": "How much material does each printed part in this assembly use?" }- ],- "author": {- "name": "John Lauer",- "email": "[email protected]"- }-}+ ]+}
tools/release.sh+29@@ -192,6 +192,35 @@ zp, listf = sys.argv[1], sys.argv[2] files = [l.strip() for l in open(listf) if l.strip()] missing = [f for f in files if not os.path.exists(f)] if missing: raise SystemExit("missing release files: %s" % missing)+# THE ADD-IN PAYLOAD COMES FROM THE TREE, NOT THE HAND LIST (issue #69, Colby, 2026-08-28).+# activity_palette.py was added to the tree and imported from commands/__init__.py, but never+# added to release-files.txt, so 1.9.233-1.9.237 shipped the importer without the imported+# module. Every fresh install bricked Fusion's add-in with a ModuleNotFoundError dialog storm+# (twice per launch, then once per user action). A hand list is a second source of truth+# that nothing keeps honest; the tree is the only one. Same family as kicad-bridge#63.+import glob+tree = sorted(set(glob.glob("addin/AdomBridge/**/*.py", recursive=True)+ + glob.glob("addin/AdomBridge/**/*.manifest", recursive=True)))+extra = [f for f in tree if f not in files]+if extra:+ print(" +%d add-in file(s) from the tree the hand list lacked: %s" % (len(extra), extra))+files = files + extra+# And REFUSE to ship a payload whose relative imports do not all resolve inside it (the+# transactional guarantee Colby asked for, done at the cheapest possible point: before+# the zip exists at all).+import re+payload = set(files)+for f in [x for x in files if x.startswith("addin/AdomBridge/") and x.endswith(".py")]:+ d = os.path.dirname(f)+ for m in re.finditer(r"^\s*from\s+(\.+)([\w.]*)\s+import", open(f, encoding="utf-8").read(), re.M):+ dots, mod = m.group(1), m.group(2)+ base = d+ for _ in range(len(dots) - 1): base = os.path.dirname(base)+ if not mod: continue+ cand = os.path.join(base, *mod.split(".")) + if not (cand + ".py" in payload or os.path.join(cand, "__init__.py") in payload):+ raise SystemExit("REFUSING to build: %s imports %s%s which is not in the payload" % (f, dots, mod))+print(" add-in payload: %d files, all relative imports resolve" % len([x for x in files if x.startswith("addin/")])) with zipfile.ZipFile(zp, "w", zipfile.ZIP_DEFLATED) as z: for f in files: z.write(f, f) print(" zip", os.path.getsize(zp), "bytes,", len(files), "files")