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
Source sync: the page repo was 10 releases stale (1.8.4..1.9.2 shipped only in zips). Full runtime source now browsable: expectDocument guard (mutating + read), API error classifier, inspect_bodies, caller identity, dark-mode + hero skills era. Fixes the docs-vs-code gap Oliver audited in #25.
8 files changed
+447−39
BRIDGE_VERSION+1−1@@ -1 +1 @@-1.9.1+1.9.2
adom-bridge-fusion-manifest.json+4−4@@ -1,10 +1,10 @@ { "manifest_version": 1, "name": "fusion360",- "version": "1.9.1",- "url": "https://wiki.adom.inc/download/adom/fusion-bridge/1.9.1/adom-bridge-fusion-v1.9.1.zip",- "sha256": "17a5ebb00206d4a31531930559e99b2538f99137263dcc275230e377fb25381f",- "size": 300071,+ "version": "1.9.2",+ "url": "https://wiki.adom.inc/download/adom/fusion-bridge/1.9.2/adom-bridge-fusion-v1.9.2.zip",+ "sha256": "6c4b942a9856ccd9aa5be4c63c3a144b551582014d17b952e36844b9f3688813",+ "size": 300217, "verbPrefixes": [ "fusion_" ],
aps.py+11−3@@ -333,7 +333,7 @@ def _signin_setup_chain(opened: bool, abe: dict) -> list: "purpose": "Autodesk APS consent", "profile": "chrome:<their-autodesk-email>", "url": "<authUrl from this response>"}, "note": "EXTENSION-FREE - works with no Adom extension installed. If this verb is "- "unknown, the adom-desktop CLI on this box is too old: update it (or have "+ "unknown, adom-bridge-cli on this box is too old: update it (or have " "the user paste authUrl into the right profile by hand)."}, {"step": "user_approves", "do": "Ask the user to approve in that profile; without the extension we cannot click for them."},@@ -659,8 +659,16 @@ def start_signin(args: dict | None = None) -> dict: # A browser passed explicitly is now the remembered one (it's what the caller chose to # drive). A previously-remembered one is refreshed to keep its timestamp warm. save_signin_browser(pref)- elif args.get("allowDefaultBrowser"):- # Legacy fallback — only when the caller opts in (e.g. no extension available).+ elif args.get("allowDefaultBrowser") and not pref:+ # Legacy fallback — only when the caller opts in AND we have NOTHING remembered.+ #+ # Never when a browser IS remembered. The OS default is usually a different, personal+ # profile, so honouring the flag there silently dumps an Autodesk login into the wrong+ # browser. That happened live (2026-07-25): Edge was remembered in EXTENSION mode, which+ # carries no exe, so _launch_url_in_browser could not spawn it, control fell through to+ # this branch, and the consent opened in the user's personal Chrome. A remembered pref+ # that cannot be launched directly belongs on the extension path below, not here.+ # To genuinely prefer the OS default, call fusion_aps_forget_browser first. try: webbrowser.open(auth_url) opened_via = "os_default_browser"
bridge.json+1−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "fusion360", "displayName": "Autodesk Fusion 360",- "version": "1.9.1",+ "version": "1.9.2", "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.",
describe.py+6@@ -214,6 +214,12 @@ _T = [ {"name": "str (refdes, e.g. R1)", "x": "optional float", "y": "optional float", "properties": "optional bool (default true)"}, {"success": "bool", "output": "str", "deviceInfo": "str"}, 30, None, True, {"name": "U1"}), + ("fusion_inspect_bodies", "GEOMETRY READ-BACK: what actually got built, in MILLIMETRES. Per body: bbox, sizeMm, volumeMm3, areaMm2, faceCount, cylindricalFaceCount, appearance, material, isVisible. Use this to VERIFY your own modelling - a body at the wrong Z (a double-applied offset, a part buried inside another) renders perfectly and is INVISIBLE in a screenshot, and this work is often matte-black parts on a dark canvas where pixels are a weak check. cylindricalFaceCount is the cheap way to confirm holes were actually cut: a symmetric cut that reaches nothing fails SILENTLY with no exception. Pass worldSpace:true to resolve bodies through their occurrence transforms (assembly placement); optionally filter with occurrence. Fusion's API is centimetres - this converts for you.",+ {"worldSpace": "optional bool (resolve through occurrence transforms; default false = root component only)", "occurrence": "optional str (substring filter on occurrence name, worldSpace only)"},+ {"success": "bool", "units": "str (mm)", "bodyCount": "int", "documentName": "str",+ "bodies": "[{name,owner,bbox:{x,y,z},sizeMm,volumeMm3,areaMm2,faceCount,cylindricalFaceCount,appearance,material,isVisible}]"},+ 130, _STATUS, False, {"worldSpace": True}),+ # ── Self-describe ────────────────────────────────────────────────────── ("fusion_describe", "Self-describe every verb the bridge exposes (this list) for AD's Verbs tab + runner.", {}, {"success": "bool", "verbs": "[{name,summary,input,output,timeoutSeconds,statusVerb,longRunning,example}]"}, 20, None, False, {}),
fusion_detect.py+23@@ -21,7 +21,30 @@ ADDIN_PORT = 8774 # which is exactly what the fusion-onboarding skill runs. We MUST check both, or # an AI-installed (globalinstall) Fusion is invisible and onboarding "succeeds" # yet the bridge still reports not-installed. (Found on the ADOMBASELINE test.)+def _scan_all_user_webdeploy() -> list:+ r"""Scan ALL user profiles for Fusion webdeploy dirs (issue #326).++ Fusion may be installed under a different Windows user profile than the+ current process. Instead of just checking %LOCALAPPDATA%, enumerate all+ user directories under C:\Users and check each one's AppData\Local\Autodesk\webdeploy.+ """+ bases = []+ try:+ users_dir = Path("C:\\Users")+ if users_dir.exists():+ for user_path in users_dir.iterdir():+ if user_path.is_dir():+ webdeploy = user_path / "AppData" / "Local" / "Autodesk" / "webdeploy" / "production"+ if webdeploy not in bases:+ bases.append(webdeploy)+ except Exception:+ pass # If enumeration fails, fall through to the normal paths below+ return bases+ _WEBDEPLOY_BASES = []+# First, scan ALL user profiles for Fusion (issue #326: cross-user detection)+_WEBDEPLOY_BASES.extend(_scan_all_user_webdeploy())+# Then add the standard environment-based paths (current user + system-wide) for _var in ("LOCALAPPDATA", "ProgramFiles", "ProgramFiles(x86)", "ProgramW6432"): _root = os.environ.get(_var) if _root:
handlers/fusion_ui.py+9@@ -205,6 +205,15 @@ def _find_qt_dialog_windows() -> list: h = rect.bottom - rect.top if w < 400 or h < 300: return True # small tool panel, skip+ # ASPECT GUARD (2026-07-23): the size test alone is not enough. Fusion's+ # docked side panels (BROWSER, Timeline, Comments) are TALL and NARROW and+ # sail past 400x300 - the BROWSER measured 450x1194 and got reported as a+ # blocking dialog on an idle Fusion, which makes a driving AI believe it is+ # blocked when nothing is wrong. Real blocking dialogs (the startup picker,+ # licensing, recovery) are landscape or roughly square. So: anything markedly+ # taller than it is wide is a docked panel, not a dialog.+ if h > w * 1.5:+ return True # tall narrow docked panel, skip rect = ctypes.wintypes.RECT() user32.GetWindowRect(hwnd, ctypes.byref(rect))
server.py+392−30@@ -94,6 +94,49 @@ def _addin_staleness(reported_version) -> dict: # Populated at startup fusion_info = None +# Caller identity: WHO we are currently acting for (issue #348). AD stamps X-Adom-Caller-* on+# every request it dispatches to us; we echo those back on our own AD callbacks so the user sees+# "chip-fetcher tab 3 (via fusion)" instead of a nameless bridge.+#+# THREAD-LOCAL, deliberately. This is a ThreadingHTTPServer, so a module global would let+# concurrent requests clobber each other and attribute tab 3's work to tab 11 - precisely the+# failure this feature exists to prevent. Thread-local also fixes the stale-identity trap the+# contract calls out: a background thread (health poll, timer, crash cleanup) has no value bound,+# so it can never inherit some earlier request's name.+_caller_tls = threading.local()+++def _set_caller_identity(ident: dict | None) -> None:+ """Bind the calling AI thread's identity to THIS request thread. Empty values are dropped."""+ _caller_tls.value = {k: v for k, v in (ident or {}).items() if v}+++def _get_caller_identity() -> dict:+ """Identity bound to this thread, or {} when we are acting on our own behalf."""+ return getattr(_caller_tls, "value", None) or {}+++def _stamp_caller(args: dict) -> dict:+ """Attribute an OUTGOING AD call (issue #348).++ Forwards the identity of the AI thread we are carrying out work for. When we act on our OWN+ behalf (health poll, timer, cleanup sweep) nothing is bound to this thread, so we name+ ourselves rather than forwarding a stale identity. An explicit caller in args always wins.++ Returns a COPY; the caller's dict is never mutated."""+ out = dict(args or {})+ if out.get("caller") or out.get("aiThread"):+ return out+ ident = _get_caller_identity()+ if ident.get("thread"):+ caller = {"aiThread": ident["thread"]}+ if ident.get("container"):+ caller["containerName"] = ident["container"]+ else:+ caller = {"aiThread": "fusion bridge (self)", "containerName": "local"}+ out["caller"] = caller+ return out+ def _reclaim_seat_from_peers() -> int: """Free the Autodesk seat held by another machine by stopping Fusion on peer ADs.@@ -205,23 +248,39 @@ def _resolve_seat_dialog(max_clicks: int = 3) -> dict: def _find_adom_desktop_cli() -> str | None:- """Locate the adom-desktop CLI/exe so the bridge can drive AD's relay directly (used when the- in-process ad_client is unavailable, e.g. on a headless VM). Checks the per-user Windows install- dir, then PATH, then the common Linux/dev locations. Returns a path or None."""+ """Locate the host app's CLI so the bridge can drive it when the in-process ad_client is down.++ Accepts BOTH names. The host app was renamed Adom Desktop -> Adom Bridge in v2.0.0 (issue #537),+ so a box may carry either layout: a renamed install has "Adom Bridge"/adom-bridge-cli.exe, an+ older one still has "Adom Desktop"/adom-desktop-cli.exe. Looking for only one silently kills the+ CLI fallback on the other, so we try new-first then old and let whichever exists win.++ The hard rule survives the rename: shell the **-cli** exe only. Never the GUI exe (adom-bridge.exe+ / adom-desktop.exe) - launching that with verb args foregrounds the app on the user's screen every+ single call (issue #295). Prefer the loopback direct API over shelling at all (_ad_direct_api_call).+ """ import shutil la = os.environ.get("LOCALAPPDATA", "")- candidates = [- os.path.join(la, "Adom Desktop", "adom-desktop.exe") if la else None,- os.path.join(la, "Programs", "Adom Desktop", "adom-desktop.exe") if la else None,- ]- for c in candidates:- if c and os.path.exists(c):- return c- return shutil.which("adom-desktop") or shutil.which("adom-desktop.exe")+ apps = (("Adom Bridge", "adom-bridge-cli.exe"), ("Adom Desktop", "adom-desktop-cli.exe"))+ if la:+ for folder, exe in apps:+ for parent in (la, os.path.join(la, "Programs")):+ c = os.path.join(parent, folder, exe)+ if os.path.exists(c):+ return c+ # PATH: the CLI shim only. On Windows NEVER resolve the bare name - that is the GUI exe.+ for shim in ("adom-bridge-cli", "adom-desktop-cli"):+ hit = shutil.which(shim)+ if hit:+ return hit+ if os.name != "nt":+ # On Linux/dev the bare name IS the CLI.+ return shutil.which("adom-bridge") or shutil.which("adom-desktop")+ return None def _cli_notify_all(title: str, body: str, level: str) -> dict:- """Deliver a toast to EVERY connected desktop via `adom-desktop --target all notify_user`. This is+ """Deliver a toast to EVERY connected desktop via `adom-bridge-cli --target all notify_user`. This is the reliable cross-AD path when the bridge's in-process ad_client is down (headless VM): the CLI joins the relay itself and `--target all` reaches the user's real machine (not just this VM). Best-effort + never raises. Returns {delivered, targets, error}. (John 2026-07-14: the notify MUST@@ -229,7 +288,7 @@ def _cli_notify_all(title: str, body: str, level: str) -> dict: import subprocess as _sp exe = _find_adom_desktop_cli() if not exe:- return {"delivered": False, "error": "adom-desktop CLI not found"}+ return {"delivered": False, "error": "adom-bridge-cli not found"} # STICKY by default: a human-wall alert must NOT vanish in a few seconds (John 2026-07-14 - the # first toasts disappeared before he noticed them). scenario:reminder keeps it on screen until the # user acts; it REQUIRES >=1 button, so include one. durationLong is a belt-and-suspenders ~25s.@@ -265,24 +324,24 @@ def _handle_notify_owner(args: dict) -> dict: # ad_client is the bridge's IN-PROCESS AD API - it is routinely UNAVAILABLE when this bridge # runs on an unattended Hyper-V VM (found live 2026-07-14: the toast silently never reached the # user, and the AI "forgot" to relay it - exactly the failure John demanded we engineer out).- # SELF-DELIVER via the adom-desktop CLI, which connects to the relay independently: `--target+ # SELF-DELIVER via adom-bridge-cli, which connects to the relay independently: `--target # all` fans the toast out to EVERY connected desktop (the VM + the user's real machines), so it # reliably lands on the computer the user is actually AT. No AI relay step to forget. cli = _cli_notify_all(title, body, level) if cli.get("delivered"): return {"success": True, "via": "cli:--target all", "targets": cli.get("targets"), "_hint": ("Toast fanned out to ALL connected desktops (the user's main machine "- "included) via the adom-desktop CLI, because the bridge's in-process AD "+ "included) via adom-bridge-cli, because the bridge's in-process AD " "API was unavailable (this VM). WAIT and poll fusion_readiness; do not " "re-toast within a few minutes. Only notify when the box is ACTUALLY " "ready for the user to act - do not toast for a step you can do yourself.")} # CLI fallback also failed - return the payload so the AI can relay as the true last resort. return { "success": False,- "error": "AD in-process API unavailable AND the adom-desktop CLI fallback failed: "+ "error": "AD in-process API unavailable AND the adom-bridge-cli fallback failed: " + str(cli.get("error"))[:200], "notifyUser": {"title": title, "body": body, "level": level},- "_hint": ("Relay the notifyUser payload yourself: `adom-desktop --target all notify_user "+ "_hint": ("Relay the notifyUser payload yourself: `adom-bridge-cli --target all notify_user " "{title, body, level}` (or `--target <the user's host>`)."), } res = ad_client.notify(title, body, level=level, reach_user=True) or {}@@ -2162,6 +2221,52 @@ def _handle_addin_status() -> dict: return result +# Raw Fusion API failures (issue #289 item 3, Oliver). Fusion raises RuntimeError with text like+# "3 : Part Design documents can only contain one component" - no code, no remedy - so an AI has to+# infer the fix, and the first of these invalidates a whole modelling approach. Same idea as the+# dialog classifier, applied one layer down: match the text, attach a STABLE code and the real fix.+_API_ERROR_PATTERNS = (+ ("part design documents can only contain one component", "part_template_single_component",+ "This document came from Fusion's PART template, which permits exactly ONE component, so "+ "occurrences cannot be added. Documents created via documents.add(FusionDesignDocumentType) "+ "ARE assembly-capable. Either model with BODIES inside the single component, or create a new "+ "document through the API and build there. Fusion's API and UI defaults differ here, which is "+ "why this is surprising."),+ ("root component name cannot be changed", "root_rename_unsupported",+ "The root component always takes the DOCUMENT's name and cannot be renamed directly. Rename "+ "the document on save instead, or rename a sub-occurrence."),+ ("refers to a deleted object", "stale_api_handle",+ "A handle you are holding points at an object that was closed or deleted. Re-fetch it from "+ "the CURRENT app.activeDocument / activeProduct instead of reusing one from an earlier call."),+)+++def _classify_api_error(result: dict) -> dict:+ """Give a raw Fusion API exception a stable errorCode + an actionable remedy (issue #289)."""+ if not isinstance(result, dict) or result.get("success") or result.get("errorCode"):+ return result+ data = result.get("data") if isinstance(result.get("data"), dict) else {}+ blob = " ".join(str(x) for x in (result.get("error", ""), result.get("output", ""),+ data.get("traceback", ""))).lower()+ for needle, code, hint in _API_ERROR_PATTERNS:+ if needle in blob:+ result["errorCode"] = code+ result["_hint"] = hint+ break+ return result+++# Purchased-subassembly defaults for assembly_bom (issue #289 item 4, Oliver). Fusion's own+# Manage -> BOM renders EVERY subassembly as one collapsible row; the add-in shipped with only+# "with fasteners", so a purchased bearing or pulley unit was descended into and emitted as its+# modeled internals (240 Bearing Ball rows on Wirebening desk, which Fusion never shows at that+# level). Widened HERE at the bridge so no add-in redeploy is needed. An explicit caller-supplied+# treatAsUnit always wins. Note partNumber is NOT a usable signal: Fusion auto-fills it from the+# component name, so the bearing balls carry one too.+_ASSEMBLY_BOM_UNIT_DEFAULTS = ["with fasteners", "with fastener", "with bearings",+ "bearing", "idler pulley", "pulley", "idler"]++ def _proxy_to_addin(command: str, args: dict, timeout: int = 30) -> dict: """Proxy a command to the Fusion add-in HTTP server. @@ -2169,6 +2274,10 @@ def _proxy_to_addin(command: str, args: dict, timeout: int = 30) -> dict: - Add-in alive but main thread blocked (modal dialog) → distinct error - Add-in HTTP server crashed → connection error """+ if command == "assembly_bom" and not args.get("treatAsUnit"):+ args = dict(args)+ args["treatAsUnit"] = list(_ASSEMBLY_BOM_UNIT_DEFAULTS)+ body = json.dumps({"command": command, "args": args}).encode("utf-8") req = urllib.request.Request( f"http://127.0.0.1:{ADDIN_PORT}/command",@@ -2198,6 +2307,11 @@ def _proxy_to_addin(command: str, args: dict, timeout: int = 30) -> dict: result["_hint"] = stale["_staleHint"] result.update({k: stale[k] for k in ("addinVersion", "expectedAddinVersion", "addinStale")}) + # Raw Fusion API exceptions arrive as bare "3 : <text>" RuntimeError strings with no+ # errorCode and no remedy (issue #289 item 3). Classify them here, one layer below the+ # dialog classifier and in the same shape, so a caller can branch on a stable code.+ result = _classify_api_error(result)+ return result except urllib.error.URLError as e:@@ -2986,7 +3100,7 @@ def _build_library_3d_core(args: dict) -> dict: "symbol / footprint / component (pin<->pad mapped) views - those are what make EEs trust " "the library, and the 3D-only shots miss them. " "PITFALLS: needs Fusion running. ⚠️ RELAY TIMEOUT: a many-part run takes minutes but the "- "adom-desktop relay times out the REQUEST at ~60s - you may get 'Request timed out' even "+ "Adom Bridge relay times out the REQUEST at ~60s - you may get 'Request timed out' even " "though the build KEEPS RUNNING server-side and finishes. Do NOT assume it failed: wait, " "then verify by reading the boundLbr (count <package3d name=) + the before/after PNGs in " "C:/tmp/conduit-screenshots. If a part is missing, just re-run build_library_3d for the "@@ -3749,12 +3863,71 @@ def _describe_profile(p: dict) -> str: return bits +def _ad_direct_api_call(verb: str, args: dict, timeout: float = 60) -> dict | None:+ """Call an AD verb over the loopback DIRECT API - the correct, non-foregrounding way for a+ bridge to reach AD when the in-process ad_client is down (issue #295). AD spawns the bridge+ with ADOM_DIRECT_API_URL in its env (fallback: ~/.adom/direct-api-port); POST+ {"command", "args"} to <base>/command. Returns the parsed result dict, or None if the direct+ API is unavailable or errors (so _ad_call can fall through). Never shells the GUI exe."""+ base = (os.environ.get("ADOM_DIRECT_API_URL") or "").strip()+ if not base:+ try:+ with open(os.path.join(os.path.expanduser("~"), ".adom", "direct-api-port")) as f:+ port = f.read().strip()+ if port:+ base = "http://127.0.0.1:%s" % port+ except Exception:+ return None+ if not base:+ return None+ try:+ body = json.dumps({"command": verb, "args": args}).encode("utf-8")+ headers = {"Content-Type": "application/json"}+ # The bridge token is what keeps a bridge's own callbacks exempt from AD's identity gate+ # (ad_client already sends it; this path used to omit it).+ tok = os.environ.get("ADOM_BRIDGE_TOKEN") or ""+ if tok:+ headers["X-Adom-Bridge-Token"] = tok+ # Echo the identity AD handed us, then name OURSELVES as the delegate (issue #348). The+ # thread alone would hide who ran it; the bridge alone would hide who asked. AD needs both+ # to render "chip-fetcher tab 3 (via fusion)". Delegate is ADDED, never forwarded.+ ident = _get_caller_identity()+ for key, hdr in (("thread", "X-Adom-Caller-Thread"),+ ("container", "X-Adom-Caller-Container"),+ ("reason", "X-Adom-Caller-Reason")):+ if ident.get(key):+ headers[hdr] = ident[key]+ headers["X-Adom-Caller-Delegate"] = "fusion"+ req = urllib.request.Request(base.rstrip("/") + "/command", data=body,+ headers=headers, method="POST")+ with urllib.request.urlopen(req, timeout=timeout) as resp:+ r = json.loads(resp.read())+ if isinstance(r, dict):+ inner = r.get("output")+ if isinstance(inner, str) and inner.strip().startswith("{"):+ try:+ return json.loads(inner)+ except Exception:+ return r+ return r+ except Exception:+ return None+ return None++ def _ad_call(verb: str, args: dict, timeout: int = 60) -> dict: """Call another AD verb (nbrowser_*, desktop_*) from inside this bridge. - Prefers the in-process ad_client; falls back to the adom-desktop CLI (which joins the- relay itself) so this still works on an unattended VM where ad_client is down. Never- raises - returns {} on failure so callers can degrade to instructing the AI instead."""+ Prefers the in-process ad_client; then the loopback DIRECT API (issue #295); only as a last+ resort shells the host app's **-cli** exe (adom-bridge-cli.exe, NEVER the GUI adom-bridge.exe,+ which would foreground AD). Works on an unattended VM where ad_client is down. Never raises -+ returns {} on failure so callers can degrade to instructing the AI instead.++ Attribution (issue #348) is stamped into args HERE, once, so it survives on ALL THREE paths -+ the in-process client and the CLI subprocess cannot carry HTTP headers, and an explicit caller+ block in args is the highest-precedence form AD accepts. The direct-API path additionally sends+ the X-Adom-Caller-* headers so AD can record the delegation chain."""+ args = _stamp_caller(args) why = [] try: if ad_client.available():@@ -3773,11 +3946,16 @@ def _ad_call(verb: str, args: dict, timeout: int = 60) -> dict: why.append("ad_client unavailable") except Exception as e: why.append("ad_client raised %s" % e)+ # #295: prefer the loopback DIRECT API - it reaches AD without foregrounding anything.+ direct = _ad_direct_api_call(verb, args, timeout=timeout)+ if isinstance(direct, dict):+ return direct+ why.append("direct-api unavailable") try: import subprocess as _sp, json as _j- exe = _find_adom_desktop_cli()+ exe = _find_adom_desktop_cli() # the -cli exe only; never the GUI exe (#295) if not exe:- return {"_adCallError": "; ".join(why + ["adom-desktop CLI not found"])}+ return {"_adCallError": "; ".join(why + ["adom-bridge-cli not found"])} p = _sp.run([exe, verb, _j.dumps(args)], capture_output=True, text=True, timeout=timeout) out = (p.stdout or "").strip() if out.startswith("{"):@@ -3969,7 +4147,7 @@ def _mcp_new_session() -> dict: global _mcp_session_id r = _mcp_post({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2024-11-05", "capabilities": {},- "clientInfo": {"name": "adom-desktop-fusion-bridge",+ "clientInfo": {"name": "fusion-bridge", "version": BRIDGE_VERSION}}}) if r.get("error") or r.get("httpError"): return {"error": r.get("error") or ("HTTP %s" % r.get("httpError")), "raw": r.get("body")}@@ -5510,8 +5688,62 @@ def _orchestrate_board_stackup(args: dict) -> dict: } -def dispatch_command(command: str, args: dict) -> dict:- """Dispatch a command to the appropriate handler."""+def _assert_active_document(expect: str) -> dict | None:+ """Document guard (issue #289): return a `wrong_document` error dict if Fusion's active document+ is not `expect`, else None. A Fusion tab-switch silently retargets `app.activeDocument`, so a+ mutating verb can hit the wrong file and destroy work. Callers pass `expectDocument` to assert+ intent BEFORE any write. Fails OPEN (returns None) when the active doc cannot be read, so the+ guard never wedges a legitimate call - it only blocks a CONFIRMED mismatch."""+ actual = None+ try:+ st = _proxy_to_addin("get_app_state", {}, timeout=8)+ data = st.get("data") if isinstance(st, dict) else None+ if not isinstance(data, dict):+ out = st.get("output") if isinstance(st, dict) else None+ if isinstance(out, str) and out.strip().startswith("{"):+ data = json.loads(out).get("data", {})+ actual = (data or {}).get("activeDocument") or (st.get("activeDocument") if isinstance(st, dict) else None)+ except Exception:+ actual = None+ if actual and actual != expect:+ return {+ "success": False,+ "errorCode": "wrong_document",+ "expected": expect,+ "actual": actual,+ "error": "Active document is '%s', expected '%s'." % (actual, expect),+ "_hint": ("The active document changed - a Fusion tab-switch retargets the active "+ "document. Re-activate the intended document, or re-issue with expectDocument "+ "set to the current name (a rename mid-session also changes it)."),+ }+ return None+++def dispatch_command(command: str, args: dict, caller_identity: dict = None) -> dict:+ """Dispatch a command to the appropriate handler.++ caller_identity: {'thread','container','reason'} from the X-Adom-Caller-* headers AD stamped on+ this request. Bound to THIS thread (issue #348) so any AD callback we make while carrying out+ the verb is attributed to the AI thread that asked for it. Pass None to leave the binding alone.+ """+ if caller_identity is not None:+ _set_caller_identity(caller_identity)+ # Strip the "fusion_" prefix if present — handlers are registered without it (e.g. "aps_signin"+ # not "fusion_aps_signin"), but the relay passes them with the prefix for CLI clarity. Issue #327.+ if command.startswith("fusion_"):+ command = command[7:]++ # Document guard (issue #289 items 1 AND 5, Oliver): a verb carrying `expectDocument` must not+ # run against a document the caller didn't intend. Originally only mutating verbs were guarded;+ # Oliver's BOM session proved READS need it too - a passive tab switch silently returned a+ # different assembly (68/268 vs 94/424 instances) with nothing signalling the retarget. So the+ # assertion now fires for ANY verb that passes the arg: a caller who states an expectation wants+ # it checked. Asserted BEFORE dispatch, then stripped so handlers never see the key.+ if isinstance(args, dict) and args.get("expectDocument"):+ _guard = _assert_active_document(str(args["expectDocument"]))+ if _guard is not None:+ return _guard+ args = {k: v for k, v in args.items() if k != "expectDocument"} # Direct handlers (don't need the add-in) handler = COMMAND_HANDLERS.get(command) if handler is not None:@@ -5523,14 +5755,18 @@ def dispatch_command(command: str, args: dict) -> dict: # Commands that need Fusion running (add-in commands + orchestrated commands) _OPEN_WITH_SCREENSHOT = {"open_schematic", "open_board", "show_3d_board", "show_2d_board", "show_schematic", "import_electronics"} if command in ADDIN_COMMANDS or command in ("open_lbr", "save_lbr", "attach_3d_package", "make_3d_package", "build_library_3d", "capture_library_views", "cleanup_cloud_files", "generate_package", "export_optimized_glb", "board_stackup") or command in _OPEN_WITH_SCREENSHOT:- if not fusion_info.get("installed"):+ # Issue #326: Fusion may be installed under a different Windows user profile than the current+ # one, so file-path detection fails even though Fusion is running. Check if it's actually+ # running (via tasklist) as a reliable fallback. If running, it's installed somewhere.+ fusion_running = _is_fusion_running()+ if not fusion_info.get("installed") and not fusion_running: return { "success": False, "error": "Fusion 360 is not installed on this machine.", "errorCode": "fusion_not_installed", "_hint": "Fusion 360 isn't installed. Do NOT tell the user to install it themselves - OFFER to install it FOR them and do it on a yes: the fusion-onboarding skill silent-installs Fusion + drives the Autodesk sign-in.", }- if not _is_fusion_running():+ if not fusion_running: return { "success": False, "error": "Fusion 360 is installed but not running.",@@ -5705,7 +5941,7 @@ def dispatch_command(command: str, args: dict) -> dict: return { "success": False, "error": f"Unknown command: {command}",- "_hint": "Run `adom-desktop help` or check cli/src/commands.rs to see the list of available fusion_* commands. This command name may be misspelled or not yet implemented.",+ "_hint": "Run `adom-bridge-cli help` or check cli/src/commands.rs to see the list of available fusion_* commands. This command name may be misspelled or not yet implemented.", } @@ -5785,10 +6021,17 @@ class FusionBridgeHandler(BaseHTTPRequestHandler): command = request.get("command", "") args = request.get("args", {}) + # Who is asking (issue #348). AD stamps these on every request it dispatches to a bridge.+ caller_identity = {+ "thread": self.headers.get("X-Adom-Caller-Thread", ""),+ "container": self.headers.get("X-Adom-Caller-Container", ""),+ "reason": self.headers.get("X-Adom-Caller-Reason", ""),+ }+ print(f"[Fusion Bridge] Command: {command} | Args: {json.dumps(args)}") try:- result = dispatch_command(command, args)+ result = dispatch_command(command, args, caller_identity=caller_identity) self._respond(200, result) except Exception as e: print(f"[Fusion Bridge] ERROR: {e}")@@ -5797,6 +6040,10 @@ class FusionBridgeHandler(BaseHTTPRequestHandler): "success": False, "error": f"Internal error: {e}", })+ finally:+ # Unbind before this thread is reused or retired. Without this, a later call made on+ # our OWN behalf could inherit this AI thread's name and misattribute it.+ _set_caller_identity(None) def _respond(self, status: int, data: dict): self.send_response(status)@@ -5928,6 +6175,121 @@ def main(): server.server_close() +# ── Geometry read-back (issue #289 item 2, Oliver) ───────────────────────────────────────────+# There was no way to ask what actually got BUILT, so every caller hand-wrote body enumeration+# inside a modeling script and parsed it back out. That read-back caught both real modelling bugs+# in Oliver's session and NEITHER was visible in a screenshot (a body at Z -167.6 instead of -83.8+# from a double-applied offset; a handle buried inside the plate it should hang below). This work+# is often matte-black parts on a dark canvas, so pixels are a weak verification channel and an AI+# driving the bridge has no other way to check its own output.+#+# Implemented BRIDGE-side on the existing run_modeling_script surface (the add-in already captures+# a script's `result`), so it needs no add-in redeploy and no Fusion restart.+#+# Units are MILLIMETRES, deliberately. Fusion's internals are centimetres, which is a standing+# source of off-by-ten errors when an AI reads these numbers back.+_INSPECT_BODIES_SCRIPT = r'''+import adsk.core, adsk.fusion++_des = adsk.fusion.Design.cast(app.activeProduct)+_WORLD = __WORLD__+_OCC = __OCC__++def _mm(v): return round(v * 10.0, 4) # cm -> mm+def _mm3(v): return round(v * 1000.0, 4) # cm3 -> mm3+def _mm2(v): return round(v * 100.0, 4) # cm2 -> mm2++def _describe(body, owner):+ bb = body.boundingBox+ mn, mx = bb.minPoint, bb.maxPoint+ cyl = 0+ try:+ for f in body.faces:+ g = f.geometry+ if g and g.surfaceType == adsk.core.SurfaceTypes.CylinderSurfaceType:+ cyl += 1+ except Exception:+ cyl = -1+ def _nm(o):+ try: return o.name+ except Exception: return None+ return {+ "name": body.name,+ "owner": owner,+ "bbox": {"x": [_mm(mn.x), _mm(mx.x)],+ "y": [_mm(mn.y), _mm(mx.y)],+ "z": [_mm(mn.z), _mm(mx.z)]},+ "sizeMm": [_mm(mx.x - mn.x), _mm(mx.y - mn.y), _mm(mx.z - mn.z)],+ "volumeMm3": _mm3(body.volume),+ "areaMm2": _mm2(body.area),+ "faceCount": body.faces.count,+ "cylindricalFaceCount": cyl,+ "appearance": _nm(body.appearance),+ "material": _nm(body.material),+ "isVisible": body.isVisible,+ }++rows = []+root = _des.rootComponent+for b in root.bRepBodies:+ rows.append(_describe(b, root.name))++if _WORLD:+ # Bodies fetched THROUGH an occurrence are proxies whose boundingBox is already resolved+ # into assembly space, so occurrence placement is verified without any matrix maths here.+ for occ in root.allOccurrences:+ if _OCC and _OCC.lower() not in occ.name.lower():+ continue+ try:+ for b in occ.bRepBodies:+ rows.append(_describe(b, occ.name))+ except Exception:+ pass++result = {"units": "mm", "worldSpace": bool(_WORLD),+ "documentName": app.activeDocument.name if app.activeDocument else None,+ "bodyCount": len(rows), "bodies": rows}+'''+++def _handle_inspect_bodies(fusion_info: dict, args: dict) -> dict:+ """fusion_inspect_bodies - what actually got built, in millimetres."""+ world = bool(args.get("worldSpace"))+ occ = args.get("occurrence") or ""+ script = (_INSPECT_BODIES_SCRIPT+ .replace("__WORLD__", "True" if world else "False")+ .replace("__OCC__", repr(str(occ)) if occ else "None"))+ res = _proxy_to_addin("run_modeling_script", {"script": script}, timeout=120)+ if not res.get("success"):+ return res+ data = res.get("data") or {}+ payload = data.get("result")+ if not isinstance(payload, dict):+ return {"success": False, "error": "inspect_bodies returned no structured result",+ "errorCode": "inspect_failed", "data": data,+ "_hint": "The read-back script ran but produced no result dict. Usually means no "+ "active Design; open a document and retry."}+ n = payload.get("bodyCount", 0)+ payload["success"] = True+ payload["_hint"] = (+ "Bounding boxes, volumes and areas are in MILLIMETRES (Fusion's API is centimetres; "+ "converted here so you do not have to). Check bbox Z against where the part SHOULD sit - "+ "a wrong offset renders perfectly and is invisible in a screenshot. cylindricalFaceCount "+ "is the cheap way to confirm holes were actually cut: a symmetric cut that reaches nothing "+ "fails SILENTLY with no exception, and the count is what surfaces it. "+ + ("Pass worldSpace:true to resolve bodies through their occurrence transforms and verify "+ "assembly placement." if not world else+ "worldSpace:true - occurrence bodies are proxies already resolved into assembly space.")+ ) if n else (+ "No bodies found. If you expected some, the active document may not be the one you built "+ "into (pass expectDocument on mutating verbs to catch that), or the design is empty."+ )+ return payload+++COMMAND_HANDLERS["inspect_bodies"] = _handle_inspect_bodies++ if __name__ == "__main__": # Surface any fatal startup error to stderr (which AD captures into # ~/.adom/bridge-logs/fusion360.log) so a spawn-crash is debuggable, then