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)
10 files changed
+215−21
BRIDGE_VERSION+1−1@@ -1 +1 @@-1.9.238+1.9.239
adom-bridge-fusion-manifest.json+4−4@@ -1,10 +1,10 @@ { "manifest_version": 1, "name": "fusion360",- "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,+ "version": "1.9.239",+ "url": "https://wiki.adom.inc/download/adom/fusion-bridge/1.9.239/adom-bridge-fusion-v1.9.239.zip",+ "sha256": "bbaf8737741b2f792bc8e0a614af7557c3f4cdaf03abd01ca9e84bc637f1969b",+ "size": 537767, "verbPrefixes": [ "fusion_" ],
bridge.json+1−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "fusion360", "displayName": "Autodesk Fusion 360",- "version": "1.9.238",+ "version": "1.9.239", "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.238"+version = "1.9.239" dependencies = [ "serde_json", ]
dashboard/Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.9.238"+version = "1.9.239" edition = "2021" [dependencies]
describe.py+3@@ -276,6 +276,9 @@ _T = [ {"parts": "optional [{package, lbrPath, modelPath}] - the parts you intend to import (drives the estimate)", "stepPath": "optional single .step when not passing parts[]", "libraryPath": "optional .lbr for context"}, {"success": "bool", "fusion": "{installed, running, addinReady, status, message}", "plan": "[{stage, label, estimatedSec, confidence, needsUserConsent?}]", "estimatedSec": "float", "estimatedSecIfAlreadyOpen": "float", "summary": "str"}, 20, None, True, {"parts": [{"package": "AL8843SP-13", "lbrPath": "C:/tmp/al8843.lbr", "modelPath": "C:/tmp/al8843.step"}]}),+ ("fusion_live_feed", "A ~1 fps look INTO Fusion while the bridge drives it, published to a shotlog channel as tagged kinds: [main #n] = the Fusion window each tick (the video), [popup #n.m] = every owned dialog/panel over it tied to its tick (Fusion draws these as separate windows a main capture omits). Proof frames from the import chain are the third kind (kind:proof, with step labels) and are NOT produced here - this feed shows what happens BETWEEN proofs, e.g. during boot or a restart, so a human can see open tabs and dialogs instead of guessing. Runs in the background, never foregrounds. The wiki may choose not to render 1 fps; the bridge owns the feed regardless.",+ {"action": "start|stop|status", "channel": "optional str (default fusion-live)", "intervalSec": "optional float (default 1.0)", "maxSec": "optional float (default 600)"},+ {"success": "bool", "started": "bool", "channel": "str", "frames": "int"}, 15, None, True, {"action": "start", "channel": "fusion-live"}), ("fusion_restart_for_addin", "Finish add-in setup by restarting Fusion, SAVING the user's open documents first. Call this when any verb returns errorCode addin_restart_required (the add-in was installed/updated while Fusion was already open, and Fusion only loads add-ins at launch). ASK THE USER FIRST when a human is present - the failing verb's payload carries userMessage plus openDocuments/unsavedDocuments so you can show them what is open. Refuses with unsaved_named_document rather than discarding a never-saved document.", {"save": "optional bool (default true) - save open documents before restarting"}, {"success": "bool", "addinReady": "bool", "tookSec": "float", "steps": "[{step, ok}]"},
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.238"+DASH_VERSION="1.9.239" 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.238",+ "version": "1.9.239", "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.238",+ "version": "1.9.239", "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]"- }-}+ ]+}
server.py+191−3@@ -348,6 +348,43 @@ def _cli_notify_all(title: str, body: str, level: str, buttons=None) -> dict: return {"delivered": False, "error": str(e)[:200]} +def _set_user_restart_hold(nid):+ """The user chose to restart Fusion themselves; no automation may do it for them."""+ try:+ import pathlib as _pl, json as _js, time as _t, os as _os+ d = _pl.Path(_os.path.expanduser("~")) / ".adom" / "fusion-bridge"+ d.mkdir(parents=True, exist_ok=True)+ (d / "user_restart_hold.json").write_text(_js.dumps({"at": _t.time(), "notification": nid}))+ except Exception:+ pass+++def _user_restart_hold_active():+ """True while the user has said 'I'll handle it' and the add-in has not come back.++ John, 2026-08-28: "i just clicked 'i'll handle it' when asked to restart fusion and+ then you quit fusion on me? i would have expected that i would restart fusion." Two+ racing chains each entered their launch stage, and the launch handler's running-but-+ silent branch restarted Fusion because that is its doctrine (#39). Nothing consulted+ the answer he had just given. This hold is that answer, made visible to every path+ that could restart Fusion. It clears itself the moment the add-in answers (he did+ the restart) or after 4 hours (he forgot; do not brick automation forever).+ """+ try:+ import pathlib as _pl, json as _js, time as _t, os as _os+ f = _pl.Path(_os.path.expanduser("~")) / ".adom" / "fusion-bridge" / "user_restart_hold.json"+ if not f.exists():+ return False+ at = float((_js.loads(f.read_text()) or {}).get("at") or 0)+ if _t.time() - at > 4 * 3600:+ f.unlink(missing_ok=True); return False+ if _addin_round_trip_ready(tries=1):+ f.unlink(missing_ok=True); return False # they restarted; hold released+ return True+ except Exception:+ return False++ def _watch_restart_choice(notification_id: str, timeout_s: float = 1800.0): """Wait (in the background) for the user's answer to the restart notice and honour it. @@ -373,8 +410,18 @@ def _watch_restart_choice(notification_id: str, timeout_s: float = 1800.0): i = out.find("{") x = _js.loads(out[i:]) if i >= 0 else {} x = x.get("data") or x- btn = x.get("buttonId") or x.get("button") or x.get("response")+ # ab reports the press as response: {"action": "<label>"} - a DICT. The+ # first version read `response` as a string, got a dict, treated it as falsy,+ # and fell into the decline branch WITHOUT recording anything. John pressed+ # "I'll handle it" (2026-08-28 21:55) and the ledger has no trace of it.+ # A choice the bridge cannot even see is a choice it cannot honour.+ resp = x.get("response")+ if isinstance(resp, dict):+ resp = resp.get("action") or resp.get("button") or resp.get("id")+ btn = x.get("buttonId") or x.get("button") or resp if x.get("pending") is False or btn or x.get("dismissed"):+ _bridge_action_ledger("restart_choice_seen",+ {"raw": str(btn)[:40], "notification": notification_id}) if btn in ("restart", "Save and restart"): _bridge_action_ledger("restart_choice", {"choice": "restart", "notification": notification_id})@@ -390,6 +437,11 @@ def _watch_restart_choice(notification_id: str, timeout_s: float = 1800.0): _bridge_action_ledger("restart_choice", {"choice": btn or "dismissed", "notification": notification_id})+ # "I'll handle it" means: THE USER OWNS FUSION'S LIFECYCLE NOW.+ # Record a hold so no automatic path restarts Fusion on their+ # behalf until they do it themselves (cleared when the add-in+ # next answers, i.e. they restarted).+ _set_user_restart_hold(notification_id) return except Exception: pass@@ -890,6 +942,19 @@ def _handle_launch(fusion_info: dict, args: dict) -> dict: "is busy) and compare elapsedSeconds against fusion_state.timings for " "this machine; only past roughly 2x p90 is a restart justified."), }+ if not addin_ok and _user_restart_hold_active():+ return {"success": False, "errorCode": "user_owns_restart",+ "alreadyRunning": True, "startKind": "warm",+ "error": "Fusion is running without its add-in, and the user chose "+ "'I'll handle it' on the restart notice - so the bridge will NOT "+ "restart Fusion. The user owns that restart now.",+ "_hint": "Do not call fusion_start or fusion_restart_for_addin. Wait for "+ "the user to restart Fusion themselves (the hold releases the "+ "moment the add-in answers), or ask them again with "+ "fusion_restart_for_addin only if THEY change their mind."}+ if not addin_ok:+ _bridge_action_ledger("fusion_restart_for_silent_addin_considered",+ {"reason": "running but add-in not answering; entering persistence gate"}) if not addin_ok: # ⛔ PERSISTENCE GATE (John, 2026-08-22, live during the ab library-matrix run): # "you better not be exiting fusion between library imports." A single missed 5s@@ -11860,6 +11925,30 @@ def _handle_create_library(fusion_info: dict, args: dict) -> dict: # is now the canonical input and `stepPath` is sugar that normalises into it, so both # shapes run the identical code path and a 1-part library is not a special case. parts_in = args.get("parts") or []+ # ⛔ SINGLE-FLIGHT (John, 2026-08-28). Two Create Fusion Library presses seconds apart+ # started TWO chains (cea6c079 and ba243a1c); both entered their launch stage, both+ # restarted Fusion, both then bound into one Fusion at once. There is exactly one+ # Fusion and one main thread; a second import must wait for or attach to the first,+ # never race it. Return the running run's id so the caller can poll it instead.+ try:+ import tempfile as _tfs, json as _jss, time as _tts+ _rp = os.path.join(_tfs.gettempdir(), "fusion-prove-runs.json")+ _runs = _jss.loads(open(_rp, encoding="utf-8").read()) if os.path.exists(_rp) else {}+ _live = [(k, v) for k, v in _runs.items()+ if v.get("verb", "create_library") in ("create_library", None)+ and v.get("stage") not in ("done", "failed", None)+ and v.get("startedAt")+ and _tts.time() - float(v.get("startedAt")) < 1800]+ if _live:+ _rid, _rv = _live[-1]+ return {"success": True, "started": False, "attached": True,+ "runId": _rid, "statusVerb": "fusion_prove_result",+ "stage": _rv.get("stage"),+ "_hint": "An import is ALREADY running (%s, stage: %s). Not starting a "+ "second one - two chains racing one Fusion is how it got "+ "restarted under a user today. Poll this runId." % (_rid, _rv.get("stage"))}+ except Exception:+ pass if not lbr_p and not (sym_p or mod_p): return {"success": False, "errorCode": "no_input", "error": "Give me EITHER libraryPath (a ready .lbr) OR kicadSymbolPath / "@@ -11910,8 +11999,10 @@ def _handle_create_library(fusion_info: dict, args: dict) -> dict: run_id = _uuid.uuid4().hex[:12] def _chain():+ import time as _tst state = {"stage": "starting", "success": None, "libraryPath": lbr_p or None,- "runId": run_id}+ "runId": run_id, "startedAt": _tst.time(),+ "verb": "create_library"} # Register with the hang gate: while this chain runs (and is under the 900s # ceiling) a non-pumping main thread is WORK, not a hang (#49 mid-mint kills). _LONG_OP["name"] = "create_library:" + run_id@@ -11994,7 +12085,20 @@ def _handle_create_library(fusion_info: dict, args: dict) -> dict: _taskbar_progress(100.0 * _est_done[0] / _est_total) try: if _needs_launch:- _enter("launching Fusion (add-in loads at start)")+ # SAY WHAT IS ACTUALLY HAPPENING (John, 2026-08-28: "i don't get why colby's+ # interface is saying 'launching fusion' when its already launched"). This+ # stage name flowed straight up the chain to the wiki's progress line. When+ # Fusion is already running and only the add-in is silent, "launching" is a+ # lie about the reason, and the user cannot reconcile it with the Fusion+ # window in front of them. Name the real condition; the wiki renders `stage`.+ if _is_fusion_running():+ _enter("Fusion is open but its Adom add-in is not answering - "+ "restarting Fusion so the add-in loads")+ state["stageReason"] = "addin_not_answering"+ else:+ _enter("launching Fusion (add-in loads at start)")+ state["stageReason"] = "fusion_not_running"+ _save() dispatch_command("start", {}) _deadline = _tc.time() + 240 while _tc.time() < _deadline and not _addin_round_trip_ready(tries=1):@@ -12104,6 +12208,7 @@ def _handle_create_library(fusion_info: dict, args: dict) -> dict: def _frame_for(entry, _d=_dev): entry = dict(entry) entry["part"] = _d # tag it so 40 frames stay groupable+ entry["kind"] = "proof" # vs "main"/"popup" from fusion_live_feed return _publish_frame(entry) _r = _handle_prove_library(fusion_info, {@@ -13378,6 +13483,88 @@ def _human_secs(v: float) -> str: return "%dm %02ds" % (m, sec) if sec else "%dm" % m +_LIVE_FEED = {"thread": None, "stop": False, "frames": 0, "channel": None}+++def _handle_live_feed(fusion_info: dict, args: dict) -> dict:+ """A ~1 fps look INTO Fusion while the bridge drives it, published as three KINDS.++ John, 2026-08-28, after a chain restarted Fusion under him: "had you shown me a+ screenshot of fusion's interface i could have seen if there were any open tabs or not+ ... i literally should be getting screenshots like every 5 seconds during boot up ...+ or maybe each second, so like a 1 fps video." And: the frames need telling apart -+ a Browser-panel popup, the main window, and a proof shot are three different things+ and a viewer must never have to guess which it is looking at.++ kind=main the Fusion main window, the 1 fps video. One card per tick.+ kind=popup every OWNED popup over it (dialogs, Browser/tool panels, the Adom banner),+ each tagged with the tick it belongs to. Fusion draws these as separate+ windows that a main-frame capture omits, so they ride alongside, never+ instead of, the main frame.+ kind=proof is NOT produced here. Proof frames are the evidence the import chain+ captures (symbol / footprint / 3d / ganged) and they carry their own labels;+ the feed exists so the human can see what happened BETWEEN proofs.++ The wiki may choose not to render 1 fps; that is fine. The bridge owns the feed and+ publishes it to shotlog under `channel`, so a human always has somewhere to look.+ args: {action: start|stop|status, channel?, intervalSec? (default 1.0), maxSec? (600)}+ """+ import threading as _th, time as _t, subprocess as _sp+ action = str(args.get("action") or "status").lower()+ if action == "status":+ return {"success": True, "running": bool(_LIVE_FEED["thread"] and _LIVE_FEED["thread"].is_alive()),+ "frames": _LIVE_FEED["frames"], "channel": _LIVE_FEED["channel"]}+ if action == "stop":+ _LIVE_FEED["stop"] = True+ return {"success": True, "stopped": True, "frames": _LIVE_FEED["frames"]}+ if _LIVE_FEED["thread"] and _LIVE_FEED["thread"].is_alive():+ return {"success": True, "alreadyRunning": True, "channel": _LIVE_FEED["channel"],+ "frames": _LIVE_FEED["frames"]}+ channel = str(args.get("channel") or "fusion-live")+ interval = max(0.5, float(args.get("intervalSec") or 1.0))+ max_sec = min(3600.0, float(args.get("maxSec") or 600.0))+ _LIVE_FEED.update(stop=False, frames=0, channel=channel)++ def _inject(path, desc):+ try:+ _sp.run(["adom-shotlog", "inject", "-c", channel, "-d", desc, "-s",+ "desktop_screenshot_window", "--native", path],+ capture_output=True, text=True, timeout=20)+ except Exception:+ pass++ def _run():+ t0 = _t.time(); tick = 0+ while not _LIVE_FEED["stop"] and _t.time() - t0 < max_sec:+ tick += 1+ try:+ info = get_fusion_window_info() or {}+ hwnd = info.get("hwnd") or (info.get("mainWindow") or {}).get("hwnd")+ if hwnd:+ shot = _ad_call("desktop_screenshot_window", {"hwnd": int(hwnd)}, timeout=20) or {}+ main = shot.get("localSafePath")+ pops = shot.get("screenshots") or []+ title = str(shot.get("title") or info.get("title") or "Fusion")[:60]+ if main:+ _inject(main, "[main #%d] %s | %d popup(s)" % (tick, title, len(pops)))+ _LIVE_FEED["frames"] += 1+ for j, sp_ in enumerate(pops, 1):+ if sp_.get("localSafePath"):+ _inject(sp_["localSafePath"],+ "[popup #%d.%d] %s" % (tick, j, str(sp_.get("title") or "dialog")[:50]))+ except Exception:+ pass+ _t.sleep(interval)+ _LIVE_FEED["thread"] = None+ _LIVE_FEED["thread"] = _th.Thread(target=_run, daemon=True); _LIVE_FEED["thread"].start()+ return {"success": True, "started": True, "channel": channel, "intervalSec": interval,+ "maxSec": max_sec,+ "_hint": "Frames land in shotlog channel %r tagged [main #n] (the 1 fps video) and "+ "[popup #n.m] (dialogs/panels over it, tied to their tick). Proof frames "+ "from the import chain are a third kind and carry their own step labels. "+ "Open with: adom-shotlog open -c %s --skinny. Stop with action:stop." % (channel, channel)}++ def _handle_restart_for_addin(fusion_info: dict, args: dict) -> dict: """Save open documents, restart Fusion, wait for the add-in, report back. @@ -13486,6 +13673,7 @@ def _handle_inspect_bodies(fusion_info: dict, args: dict) -> dict: COMMAND_HANDLERS["inspect_bodies"] = _handle_inspect_bodies COMMAND_HANDLERS["restart_for_addin"] = _handle_restart_for_addin+COMMAND_HANDLERS["live_feed"] = _handle_live_feed COMMAND_HANDLERS["plan_library_import"] = _handle_plan_library_import COMMAND_HANDLERS["library_parts"] = _handle_library_parts