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.
Fusion bridge: implement the (documented-but-missing) expectDocument guard, classify API errors, geometry read-back, loud BOM under-collapse #38
Addresses the four items from Oliver's bridge feedback thread (2026-07-23 CAD session + BOM session). All changes are additive / low-risk; draft because they are untested against a live Fusion (no Windows+Fusion host in the authoring environment) — please run once before merge.
The headline: expectDocument was documented but never implemented
skills/fusion-driving/SKILL.md told callers to pass expectDocument to guard against a tab-switch retargeting their writes (issue #289, a real near-miss data loss). But no code path read the argument — neither the dispatcher nor the add-in — so passing it was a silent no-op. Users had a false data-loss guarantee.
This PR makes it real, in the bridge dispatcher (server.py), so it covers a verb the moment it proxies to the add-in — both mutating and read verbs (the read-verb case matters: the BOM session silently returned a different assembly, 68/268 vs 94/424, after a passive tab switch).
Changes
server.py— realexpectDocumentguard (#1 + #5). New_guard_expect_document()reads the live active-document name viaget_app_stateand fails closed witherrorCode:"wrong_document"(+expected/actual) before the verb runs. Wired intodispatch_commandafter the readiness/busy gates. No-op unless the caller passesexpectDocument. It is a name check (matches the documented contract); a stable lineage-id assertion is noted as a follow-up.addin/AdomBridge/commands/modeling.py— classify raw API errors (#3). New_classify_modeling_error()maps the three failures that cost the session real time into stable codes + actionable hints, in the bridge's usual shape:part_design_single_component(the expensive, undocumented Part-vs-Assembly one),root_component_rename,deleted_object_reference. Unrecognised errors keep the existing generic hint.addin/AdomBridge/commands/design_info.py— geometry read-back (#2, partial).fusion_get_design_infobodies now carrybbox_mm(mm, frombody.boundingBox— Fusion's internal cm ×10),faceCount, andcylindricalFaceCount. These are exactly the two signals that caught the session's invisible bugs: a body at the wrong Z, and holes that never got cut (a symmetric cut reaching nothing fails silently). Component-local only; the requestedworldSpaceoccurrence-transform resolution is deferred (needs live-Fusion validation).addin/AdomBridge/commands/assembly_bom.py— loud under-collapse (#4). Rather than widen the shippedtreatAsUnitdefault (which would silently change everyone's counts, and there is no purely structural signal separating a purchased vs organizational subassembly), the BOM now flags parts that look like the internals of a purchased unit (bearing ball/seal, idler, pulley, …) viapossibleUnderCollapse+ a_hintsuggesting atreatAsUnitlist. Non-behaviour-changing — counts are unchanged, only advisory fields are added. If you'd rather change the default, that's a one-liner and I'm happy to switch it.skills/fusion-driving/SKILL.md— updated to match: the guard now covers read verbs too, and is enforced in the dispatcher.
Not included (author flagged as separate, and one product decision left to you)
- Stronger #1: target a document by stable id without activating it (this PR does the name-check the skill promised).
- #2
worldSpace/occurrence resolution and a dedicatedfusion_inspect_bodiesverb. - #4: whether to widen the default
treatAsUnitvs keep the advisory-hint approach — your call. - Non-bridge:
sketch.modelToSketchSpace()does not project onto the sketch plane (belongs in a skill/doc).
All four Python files compile clean (py_compile).
🤖 Generated with Claude Code
Diff
--- a/server.py+++ b/server.py@@ -1,5941 +1,5998 @@⋯ 2217 unchanged lines ⋯ return {"success": False, "error": f"Add-in request failed: {e}"} -def _diagnose_addin_timeout(command: str, original_result: dict) -> dict:- """After a command timeout, probe /health to determine the cause.-- Returns a distinct error if the add-in is alive but its main thread- is blocked (e.g. by a modal dialog in Fusion). Always captures- auto-screenshots of Fusion windows on timeout so the AI can see- what dialog is blocking.- """- # Auto-screenshot on timeout — the most common cause is a blocking dialog- # that's invisible to the API. Capture Fusion windows so the AI can- # identify and dismiss the dialog.- timeout_screenshots = {}- try:- timeout_screenshots = _post_open_screenshot(command)- except Exception:- pass # Best effort — don't let screenshot failure mask the real error-- # Identify WHICH modal is blocking (titles enumerate over Win32 even while the- # add-in's main thread is stuck). Turns the opaque "add-in not responding" into- # an actionable cause + resolution — and stops the needless restart loop.- blocking_dialogs = classify_blocking_dialogs()-- health = _probe_addin(timeout=2.0)- if health and health.get("status") == "ok":- main_thread = health.get("main_thread", "unknown")- pending = health.get("pending_commands", 0)- if main_thread == "blocked" or pending > 0:- # Before assuming a modal dialog, check /status — if the add-in- # is busy with a known command, it's working (not dialog-blocked).- status = _check_addin_status(timeout=3.0)- if status and status.get("busy"):- busy_cmd = status.get("busyCommand", "unknown")- elapsed = status.get("elapsedSeconds", 0)- walk = status.get("walkProgress")- resp = {- "success": False,- "error": f"Fusion main thread busy — {busy_cmd} running for {elapsed}s.",- "errorCode": "main_thread_busy",- "busyCommand": busy_cmd,- "elapsedSeconds": elapsed,- "_hint": (- "Add-in is busy with a long-running command. Do NOT retry add-in commands. "- "Do NOT press Escape — the add-in is working, not stuck on a dialog. "- "Commands that still work: fusion_window_info, fusion_screenshot_fusion, "- "fusion_click_fusion, fusion_send_key, fusion_close_window."- ),- }- if walk:- resp["progress"] = walk- return resp-- # If we recognized the blocking modal, name it and give the precise fix- # instead of the generic "read the screenshots" guidance.- if blocking_dialogs:- titles = ", ".join(f"'{d['title']}' ({d['category']})" for d in blocking_dialogs)- resolutions = " ".join(dict.fromkeys(d["resolution"] for d in blocking_dialogs))- message = (f"The AdomBridge add-in is alive but its main thread is blocked by a "- f"modal dialog: {titles}. This is NOT an add-in crash — do not restart "- f"Fusion. {resolutions}")- else:- message = (f"The AdomBridge add-in is alive but its main thread is not "- f"responding (status: {main_thread}, pending: {pending}). "- f"Fusion 360 likely has a modal dialog open (Document Recovery, "- f"error, or update prompt) that is blocking execution. "- f"READ the screenshots to identify the dialog, then dismiss "- f"with fusion_send_key {{\"key\": \"escape\"}} or "- f"fusion_send_key {{\"key\": \"tab\"}} + {{\"key\": \"enter\"}}.")- return {- "success": False,- "error": "addin_main_thread_blocked",- "message": message,- "blockingDialogs": blocking_dialogs,- "data": {- "command": command,- "health": health,- "blockingDialogs": blocking_dialogs,- "postOpenScreenshot": timeout_screenshots,- },- }- # Health says responsive but command still timed out — unusual- return {- "success": False,- "error": "addin_command_timeout",- "message": f"Command '{command}' timed out but the add-in reports main thread "- f"is {main_thread}. The command may be long-running or stuck.",- "data": {- "command": command,- "health": health,- "postOpenScreenshot": timeout_screenshots,- },- }-- # Health probe failed — add-in HTTP server is down- return {- "success": False,- "error": "AdomBridge add-in not responding (it may have crashed).",- "errorCode": "fusion_addin_not_responding",- "_hint": "Fix it YOURSELF - never ask the user: restart Fusion via fusion_stop + "- "fusion_start (the bridge installs the add-in to ALL Fusion add-in dirs incl. "- "%APPDATA%\\Autodesk\\FusionAddins, and runOnStartup reloads it).",- }---def _orchestrate_open_lbr(args: dict) -> dict:- """Open an EAGLE .lbr library file in Fusion 360 Electronics.-- Uses Document.newDesignFromLocal (via the add-in's execute_text_command)- to open the .lbr file, which auto-switches to the Electronics Library- editor. Then optionally navigates to a symbol and verifies via export.-- This is orchestrated at bridge level to avoid add-in module caching- issues and to allow multi-step operations with waits.- """- import tempfile- import time-- file_path = args.get("filePath", "")- symbol_name = args.get("symbolName", "")- verify = args.get("verify", False)-- if not file_path:- return {"success": False, "error": "No filePath specified"}-- file_path = file_path.replace("\\", "/")- results = []-- # Step 1: Open the .lbr via Document.newDesignFromLocal.- # ⚠️ TIMEOUT + VERIFY-BY-STATE (fixed 2026-07-07, caught live on a GPU-less Azure- # VM): the open can take 60s+ on slow/software-rendered machines, so a fixed 30s- # read timeout expired mid-open and the canned proxy error claimed the ADD-IN was- # "not running" while the document was actually opening fine (fusion_build_library_3d- # then aborted all parts on a lie). Now: a generous timeout, AND on ANY failure we- # poll get_app_state for the expected document name - the doc actually being open- # outranks whatever the synchronous return claimed.- open_result = _proxy_to_addin("execute_text_command", {- "command": f"Document.newDesignFromLocal {file_path}",- }, timeout=120)-- if not open_result.get("success"):- # Verify by STATE before failing: did the doc open anyway?- expected = file_path.replace("\\", "/").rsplit("/", 1)[-1]- expected = expected.rsplit(".", 1)[0].lower()- opened_anyway = False- for _ in range(20): # up to ~60s of settling- time.sleep(3)- try:- st = _proxy_to_addin("get_app_state", {}, timeout=8)- active = str((st.get("data") or {}).get("activeDocument", "")).lower()- if expected and expected in active:- opened_anyway = True- break- except Exception:- pass- if not opened_anyway:- return {- "success": False,- "error": f"Document.newDesignFromLocal failed: {open_result.get('error', 'unknown')}",- "_hint": ("The open did not complete AND the document never became active. On slow/"- "software-rendered machines opens can take 60s+; this call already waited + "- "verified by state. Check fusion_check_dialogs for a blocking modal, then retry."),- "data": {"filePath": file_path},- }- results.append("Document.newDesignFromLocal: ok (verified by app state after slow open)")- else:- results.append("Document.newDesignFromLocal: ok")-- # Step 2: Navigate to symbol if requested- if symbol_name:- time.sleep(3) # Let Fusion finish opening and switching workspace-- sym_ref = symbol_name if symbol_name.endswith(".sym") else f"{symbol_name}.sym"- edit_result = _proxy_to_addin("electron_run", {- "command": f"EDIT {sym_ref}",- }, timeout=10)- results.append(f"EDIT {sym_ref}: success={edit_result.get('success')}")-- # Zoom to fit- _proxy_to_addin("electron_run", {"command": "WINDOW FIT"}, timeout=5)- results.append("WINDOW FIT: ok")-- # Step 3: Verify via EXPORT SCRIPT- verification = None- if verify:- import uuid- time.sleep(2) # Let Fusion settle before export- # Use a unique path to avoid "overwrite?" dialogs blocking the UI thread- export_path = str(Path(tempfile.gettempdir()) / f"_adom_verify_{uuid.uuid4().hex[:8]}.scr")- export_result = _proxy_to_addin("export_lbr", {"outputPath": export_path}, timeout=30)- if export_result.get("success"):- preview = export_result.get("data", {}).get("preview", "")- has_symbol = False- if symbol_name:- has_symbol = (- f"'{symbol_name.upper()}.sym'" in preview- or f"'{symbol_name}.sym'" in preview- )- verification = {- "exported": True,- "fileSize": export_result.get("data", {}).get("fileSize", 0),- "hasSymbol": has_symbol,- "preview": preview[:500],- }- else:- verification = {"exported": False, "error": export_result.get("error", "unknown")}- results.append(f"Verification: exported={verification.get('exported', False)}")-- import os- response = {- "success": True,- "output": f"Opened library: {os.path.basename(file_path)}",- "data": {"results": results, "filePath": file_path},- "_hint": (- "Library opened in the Electronics Library editor (Content Manager). "- "ALWAYS VERIFY VIA SCREENSHOT: a .lbr can FAIL to open ('<file>.lbr has errors and cannot be "- "opened') while this call still returns success - that error is an OWNED POPUP. Grab "- "desktop_screenshot_window on the Fusion main hwnd and CHECK ownedPopupCount + read the "- "_screenshots[] array (AD v1.8.177+ captures owned dialogs invisible to a plain capture); "- "ownedPopupCount>0 means an error/confirm dialog is up. "- "NOTE: an adom-lbr .lbr is 2D ONLY - symbol + footprint + a PLACEHOLDER 3D package. "- "To attach the real 3D chip, use fusion_attach_3d_package (it runs the Package3D generator + "- "FINISH, which binds the 3D onto the deviceset). See the 'fusion-libraries' skill / LIBRARY_FINDINGS.md."- ),- }- if symbol_name:- response["data"]["symbolName"] = symbol_name- if verification:- response["data"]["verification"] = verification-- # Auto-screenshot after opening — catches blocking dialogs, same as other open commands- return _post_open_screenshot(response)---def _orchestrate_attach_3d_package(args: dict) -> dict:- """Attach a real 3D model to a library package, end to end.-- Opens the .lbr (library active), runs Electron.Create3DPackage to enter the- Package3DEnvironment showing the footprint, imports the STEP model and- auto-orients it flat on the footprint, then executes Package3DStop (FINISH).- Fusion then shows a modal Save dialog (an OWNED popup) — that single click is- the only desktop-side step; this returns the exact instruction for it.-- args: {filePath: Windows path to the .lbr, modelPath: Windows path to the- STEP, packageName: optional str}- """- import time- file_path = (args.get("filePath") or "").replace("\\", "/")- model_path = (args.get("modelPath") or "").replace("\\", "/")- package = args.get("packageName", "")- orient_flag = args.get("orient", True) # 2026-07-07: expose orient (was hard-on)- if not file_path or not model_path:- return {"success": False,- "error": "filePath (.lbr) and modelPath (.step) are required (Windows paths, e.g. C:/...).",- "_hint": "Stage both onto Windows first (no container->Windows push verb). See the fusion-libraries skill."}- steps = []- open_res = _orchestrate_open_lbr({"filePath": file_path})- if not open_res.get("success"):- return {"success": False, "error": "open_lbr failed: " + str(open_res.get("error")), "data": {"steps": steps}}- steps.append("open_lbr: ok (library active)")- time.sleep(1)- cp = _proxy_to_addin("execute_text_command", {"command": f"Electron.Create3DPackage {file_path}"}, timeout=40)- if not cp.get("success"):- return {"success": False, "error": "Create3DPackage failed: " + str(cp.get("error")),- "_hint": "The library document must be ACTIVE, and the .lbr must be adom-lbr-generated "- "(a raw vendor EAGLE .lbr fails Fusion's XML parser).", "data": {"steps": steps}}- steps.append("Create3DPackage: ok (Package3DEnvironment)")- time.sleep(2)- import_script = (- "import adsk.core, adsk.fusion, math\n"- "res={}\n"- "d=adsk.fusion.Design.cast(app.activeProduct); root=d.rootComponent\n"- "im=app.importManager\n"- f"im.importToTarget(im.createSTEPImportOptions('{model_path}'), root)\n"- "oc=root.occurrences.item(root.occurrences.count-1); bb=oc.boundingBox\n"- "dx=bb.maxPoint.x-bb.minPoint.x; dy=bb.maxPoint.y-bb.minPoint.y; dz=bb.maxPoint.z-bb.minPoint.z\n"- # Tall-part guard (2026-07-07): keep a part vertical if its largest dim is already Z; only- # flatten a clearly-thin part whose thin axis isn't Z. Was: always tip smallest dim to Z,- # which laid tall through-hole pins on their side. Honors the orient flag (default True).- + ("axis=None\n" if not orient_flag else- "thin=min(dx,dy,dz); big=max(dx,dy,dz)\n"- "tall_z=(dz>=dx and dz>=dy)\n"- "axis=None\n"- "if (thin < 0.5*big) and not tall_z:\n"- " axis=(1,0,0) if (dy<=dx and dy<=dz) else ((0,1,0) if (dx<=dy and dx<=dz) else None)\n") +- "if axis:\n"- " mat=oc.transform2.copy(); rot=adsk.core.Matrix3D.create()\n"- " rot.setToRotation(math.pi/2, adsk.core.Vector3D.create(*axis), adsk.core.Point3D.create(0,0,0))\n"- " mat.transformBy(rot); oc.transform2=mat\n"- " d.snapshots.add() if d.snapshots.hasPendingSnapshot else None\n"- "res['dims_mm']=[round(dx*10,2),round(dy*10,2),round(dz*10,2)]\n"- "result=res\n"- )- imp = _proxy_to_addin("run_modeling_script", {"script": import_script}, timeout=120)- if not imp.get("success"):- return {"success": False, "error": "import/orient failed: " + str(imp.get("error")), "data": {"steps": steps}}- dims = (imp.get("data", {}) or {}).get("result", {})- steps.append(f"import+orient: ok ({dims.get('dims_mm') if isinstance(dims, dict) else dims})")- time.sleep(1)- _proxy_to_addin("run_modeling_script",- {"script": "import adsk.core\ncd=ui.commandDefinitions.itemById('Package3DStop')\nresult={'finished': bool(cd) and cd.execute()}\n"},- timeout=30)- steps.append("FINISH (Package3DStop): executed")- return {- "success": True,- "output": f"3D model placed + oriented on the footprint and FINISH executed for '{package or file_path}'. A Save dialog is now up.",- "data": {"steps": steps, "package": package},- "savePending": True,- "_hint": (- "FINAL STEP (desktop-side, one click): a Fusion 'Save' dialog is now up to save the 3D package. "- "Click it: desktop_find_window {titleContains:'Save'} -> desktop_ui_click "- "{hwnd, automationId:'QTApplication.QTFrameWindow.standardActions.SaveButton'}. "- "THEN VERIFY with desktop_screenshot_window on the Fusion hwnd: check ownedPopupCount (errors are owned "- "popups), and the deviceset's Package column should flip Placeholder->part-name + the 3D preview becomes "- "the real chip (the preview LAGS a beat - re-grab). Full flow: fusion-libraries skill / LIBRARY_FINDINGS.md sect 11."- ),- }---# ── Fusion cloud FOLDER HYGIENE (never write loose files to a shared project ROOT) ───────────-# Hard lesson (2026-06-29): defaulting uploads to a project's ROOT folder dumped 100+ loose f3d-# files into the shared Adom team root and other employees complained. RULE: the bridge NEVER-# writes a file to a project root. It writes into an AI-OWNED "Adom AI Workspace" folder, with a-# per-task SUBfolder, keeping the cloud tidy. See the fusion-cloud-hygiene skill.-_DEFAULT_UPLOAD_PROJECT = "a.YnVzaW5lc3M6YWRvbTMjMjAyMzExMjk3MDM5NjAzMzE" # the Adom business project-# The shared team ROOT folder of that project - OFF LIMITS for loose files (only the workspace-# folder itself may live here). Known roots we must refuse as a write target.-_KNOWN_ROOT_FOLDERS = {"urn:adsk.wipprod:fs.folder:co.jyO4vxQXR6S9zFpTQWnZAg"}-_AI_WORKSPACE_NAME = "Adom AI Workspace" # the AI-owned work area (BRAND: "Adom AI", not "Claude")-_ws_folder_cache = {}---def _safe_folder_name(name: str) -> str:- import re as _re- return _re.sub(r'[<>:"/\\|?*]', "", str(name or "")).strip()[:60] or "task"---def _find_child_folder(project_id: str, parent_id: str, name: str):- """folderId of a subfolder named `name` directly under parent_id, or None."""- try:- r = aps.handle_browse({"projectId": project_id, "folderId": parent_id})- items = (r.get("data") or {}).get("items", []) if isinstance(r, dict) else []- for it in items:- if it.get("type") == "folders" and (it.get("name") or "").strip() == name:- return it.get("id")- except Exception:- pass- return None---def _ensure_workspace_folder(project_id: str, task: str = None):- """Return a folderId inside the AI-owned 'Adom AI Workspace' (NEVER a project root). Creates the- workspace folder (one tidy folder under the project root) + an optional per-task subfolder if- missing. Cached per (project, task). Returns None if it cannot be resolved (caller must NOT then- fall back to root)."""- task = _safe_folder_name(task) if task else None- key = (project_id, task or "")- if key in _ws_folder_cache:- return _ws_folder_cache[key]- root = next(iter(_KNOWN_ROOT_FOLDERS)) # parent for the single workspace folder- ws = _find_child_folder(project_id, root, _AI_WORKSPACE_NAME)- if not ws:- cr = aps.handle_create_folder({"projectId": project_id, "parentFolderId": root, "name": _AI_WORKSPACE_NAME})- ws = (cr.get("data") or {}).get("folderId") if isinstance(cr, dict) and cr.get("success") else None- folder = ws- if task and ws:- sub = _find_child_folder(project_id, ws, task)- if not sub:- cr = aps.handle_create_folder({"projectId": project_id, "parentFolderId": ws, "name": task})- sub = (cr.get("data") or {}).get("folderId") if isinstance(cr, dict) and cr.get("success") else None- folder = sub or ws- if folder:- _ws_folder_cache[key] = folder- return folder---def _discover_upload_target(args: dict) -> tuple:- """(projectId, folderId) for f3d uploads - ALWAYS a non-root, AI-owned folder.-- Defaults to 'Adom AI Workspace/<task>' (auto-created). If the caller explicitly passes a- folderId that is a known project ROOT, it is REFUSED (we steer to the workspace instead) - the- bridge must never write loose files to a shared root. Returns (projectId, folderId|None);- folderId is None only if the workspace folder could not be created (caller must error, NOT- fall back to root)."""- project_id = args.get("projectId") or _DEFAULT_UPLOAD_PROJECT- fid = args.get("folderId")- if fid and fid in _KNOWN_ROOT_FOLDERS:- fid = None # explicit root -> refuse, steer to workspace- if not fid:- fid = _ensure_workspace_folder(project_id, args.get("task"))- return (project_id, fid)---def _capture_labeled(label) -> str | None:- """Background-capture the main Fusion window to a labeled PNG on the box- (C:/tmp/conduit-screenshots). Returns the saved path or None. Never fullscreen- (hwnd-targeted PrintWindow, so it captures Fusion in the background per fusion-driving)."""- if not label:- return None- try:- info = get_fusion_window_info()- hwnd = info.get("hwnd")- if not hwnd:- return None- import re as _re- safe = _re.sub(r'[<>:"/\\|?*]', "", str(label)).replace(" ", "_")[:40]- r = screenshot_hwnd(hwnd, label=safe)- return r.get("savedTo") if r.get("success") else None- except Exception:- return None---def _inject_package3d_bindings(lbr_text: str, bindings: list) -> str:- """Inject EAGLE <packages3d> + per-device <package3dinstances> into an .lbr - MERGE-AWARE- and IDEMPOTENT (safe to re-run with any subset of parts).-- bindings: [{package: <pkg name>, wip_urn: <urn>}]. The function reads any bindings ALREADY in- the .lbr, merges the new ones on top (new wins on conflict), strips all prior <packages3d> +- <package3dinstances>, then re-emits the full merged set: a library-level <package3d> per part- (between </packages> and <symbols>) and a <package3dinstances> inside every <device> that uses- that package (right after </connects>). So calling it again with just the parts that failed last- time ACCUMULATES instead of wiping the parts that already succeeded - the fix for the- 'a re-run dropped the earlier bindings' trap. Returns the new .lbr text."""- import re as _re- # 1. read existing bindings already in the file; new bindings override- merged = {}- em = _re.search(r"<packages3d>(.*?)</packages3d>", lbr_text, _re.S)- if em:- for pm in _re.finditer(r'<package3d name="([^"]+)"[^>]*wip_urn="([^"]+)"', em.group(1)):- merged[pm.group(1)] = pm.group(2)- for b in bindings:- merged[b["package"]] = b["wip_urn"]- # 2. strip ALL prior package3d markup so re-injection is clean (idempotent)- lbr_text = _re.sub(r"\s*<packages3d>.*?</packages3d>", "", lbr_text, flags=_re.S)- lbr_text = _re.sub(r"\s*<package3dinstances>.*?</package3dinstances>", "", lbr_text, flags=_re.S)- # 3. library-level <packages3d> block (all merged parts)- blocks = []- for pkg, urn in merged.items():- blocks.append(- f'<package3d name="{pkg}" urn="" wip_urn="{urn}" locally_modified="yes" type="model">'- f'<description>{pkg}</description>'- f'<packageinstances><packageinstance name="{pkg}"/></packageinstances>'- f'</package3d>'- )- pkgs3d = "<packages3d>\n" + "\n".join(blocks) + "\n</packages3d>\n"- lbr_text = lbr_text.replace("</packages>", "</packages>\n" + pkgs3d, 1)-- # 4. per-device <package3dinstances>- def _dev_repl(m):- dev = m.group(0)- pm = _re.search(r'package="([^"]+)"', dev)- if pm and pm.group(1) in merged and "</connects>" in dev:- inst = (f'<package3dinstances><package3dinstance package3d_urn="{merged[pm.group(1)]}"/>'- f'</package3dinstances>')- dev = dev.replace("</connects>", "</connects>\n" + inst, 1)- return dev-- return _re.sub(r'<device\b[^>]*>.*?</device>', _dev_repl, lbr_text, flags=_re.S)---def _orchestrate_make_3d_package(args: dict) -> dict:- """Create a RENDERING component 3D-PACKAGE urn (footprint + chip), fully programmatically - NO GUI- dialogs. A proper component 3D model contains the FOOTPRINT (pads + courtyard) AND the chip,- merged and aligned, so the 3D viewer can verify the chip's pads land on the footprint pads.-- So: open the library, run Electron.Create3DPackage to load the package's FOOTPRINT into a- generator doc, import the STEP onto it, orient it flat, then saveAs an .f3d (footprint + chip) -- which skips the FINISH Save dialog + the two unbeatable "Fusion360" CEF modals entirely -- aps_upload the .f3d, and return the fs.file:vf wip_urn to hand-write into the library's- <packages3d>.-- Two gotchas this avoids: (1) importing the STEP into an EMPTY design gives a chip with NO- footprint (an incomplete package); (2) a raw STEP upload does not render at all ("Thumbnail- download failed"). See the fusion-multipart-libraries skill.-- args: {lbrPath: Windows .lbr whose FIRST package is the footprint, modelPath: Windows .step,- projectId, folderId (both from fusion_aps_browse), fileName?: str, orient?: bool}- """- import os as _os, time as _time- lbr_path = (args.get("lbrPath") or args.get("filePath") or "").replace("\\", "/")- model_path = (args.get("modelPath") or "").replace("\\", "/")- project_id, folder_id = _discover_upload_target(args) # an AI-owned non-root folder (never project root)- if not lbr_path or not model_path:- return {"success": False,- "error": "lbrPath (.lbr with the footprint) and modelPath (.step) are required (Windows paths)."}- if not folder_id:- return {"success": False, "errorCode": "no_work_folder",- "error": "Could not resolve a non-root 'Adom AI Workspace' upload folder.",- "_hint": "The bridge refuses to write to a shared project ROOT. Sign in (fusion_aps_signin) "- "so it can create 'Adom AI Workspace', or pass a real (non-root) folderId. NEVER "- "pass a project root folderId. See the fusion-cloud-hygiene skill."}- cap_label = args.get("captureLabel") # when set, capture BEFORE (footprint) + AFTER (chip placed)- task = _safe_folder_name(args.get("task") or "AI 3D packages") # saveAs subfolder (never root)- orient = args.get("orient", True)- base = _os.path.basename(model_path).rsplit(".", 1)[0]- name = (args.get("fileName") or (base + "_3d")).replace("'", "").replace(".f3d", "")- f3d_name = name + ".f3d"- # 1. open the library so the footprint exists; 2. Create3DPackage -> generator WITH the footprint loaded- op = _orchestrate_open_lbr({"filePath": lbr_path})- if not op.get("success"):- return {"success": False, "error": "open_lbr failed: " + str(op.get("error"))}- _time.sleep(1)- cp = _proxy_to_addin("execute_text_command", {"command": f"Electron.Create3DPackage {lbr_path}"}, timeout=40)- if not cp.get("success"):- return {"success": False, "error": "Create3DPackage failed: " + str(cp.get("error")),- "_hint": "The .lbr must be adom-lbr-generated; its FIRST package's footprint is loaded into the generator."}- _time.sleep(2)- # BEFORE shot: the footprint (pads + courtyard) loaded in the 3D viewer, no chip yet.- before_shot = None- if cap_label:- try:- _proxy_to_addin("run_modeling_script",- {"script": "app.activeViewport.fit()\nresult={}"}, timeout=15)- except Exception:- pass- _time.sleep(0.4)- before_shot = _capture_labeled(f"{cap_label}_before")- # AUTO-ORIENT (fixed 2026-07-07): the old logic always rotated the SMALLEST bbox dim to Z,- # which is right for a flat SMD chip lying down but TIPS A TALL THROUGH-HOLE PART (machine pin,- # connector) onto its side - its long axis is already Z and must stay vertical. Now: keep the- # part vertical if its largest dim is already Z (tall_z), and only flatten a clearly THIN part- # whose thin axis isn't Z yet. A caller can still force orient:false to skip entirely.- orient_block = (- "thin=min(dx,dy,dz); big=max(dx,dy,dz)\n"- "tall_z=(dz>=dx and dz>=dy)\n"- "is_flat=(thin < 0.5*big)\n"- "axis=None\n"- "if is_flat and not tall_z:\n axis=(1,0,0) if (dy<=dx and dy<=dz) else ((0,1,0) if (dx<=dy and dx<=dz) else None)\n"- "if axis:\n mat=oc.transform2.copy(); r=adsk.core.Matrix3D.create()\n"- " r.setToRotation(math.pi/2, adsk.core.Vector3D.create(*axis), adsk.core.Point3D.create(0,0,0))\n"- " mat.transformBy(r); oc.transform2=mat\n"- ) if orient else ""- # 3. import the chip ONTO the footprint in the generator doc, orient, saveAs as f3d (footprint+chip)- script = (- "import adsk.core, adsk.fusion, math\n"- "app=adsk.core.Application.get(); doc=app.activeDocument\n"- "d=adsk.fusion.Design.cast(app.activeProduct); root=d.rootComponent\n"- "im=app.importManager\n"- f"im.importToTarget(im.createSTEPImportOptions('{model_path}'), root)\n"- "oc=root.occurrences.item(root.occurrences.count-1); bb=oc.boundingBox\n"- "dx=bb.maxPoint.x-bb.minPoint.x; dy=bb.maxPoint.y-bb.minPoint.y; dz=bb.maxPoint.z-bb.minPoint.z\n"- + orient_block +- "try:\n app.activeViewport.fit()\nexcept: pass\n"- # FOLDER HYGIENE: saveAs into an 'Adom AI Workspace'/<task> subfolder, NEVER the project root.- "proj=app.data.activeProject; rf=proj.rootFolder\n"- "def _sub(p,nm):\n"- " for i in range(p.dataFolders.count):\n"- " if p.dataFolders.item(i).name==nm: return p.dataFolders.item(i)\n"- " return p.dataFolders.add(nm)\n"- f"wsf=_sub(_sub(rf,'Adom AI Workspace'),'{task}')\n"- "res={}\n"- "try:\n"- f" doc.saveAs('{name}', wsf, '3d package (footprint+chip)', '')\n"- " res['path']=doc.dataFile.id if doc.dataFile else None\n"- " res['dims']=[round(dx*10,2),round(dy*10,2),round(dz*10,2)]\n"- "except Exception as e: res['err']=str(e)[:80]\n"- "result=res\n"- )- r = _proxy_to_addin("run_modeling_script", {"script": script}, timeout=120)- res = (r.get("data", {}) or {}).get("result", {}) if isinstance(r, dict) else {}- f3d_path = res.get("path") if isinstance(res, dict) else None- dims = res.get("dims") if isinstance(res, dict) else None- # AFTER shot: the chip placed flat on its footprint (pads landing on pads) in the 3D viewer.- after_shot = _capture_labeled(f"{cap_label}_after") if cap_label else None- if not f3d_path or not str(f3d_path).endswith(".f3d"):- return {"success": False, "error": "f3d saveAs did not produce a .f3d path: " + str(res),- "before": before_shot, "after": after_shot,- "_hint": "Confirm modelPath is a valid Windows .step path and Fusion is running."}- up = aps.handle_upload({"projectId": project_id, "folderId": folder_id,- "localPath": f3d_path, "fileName": f3d_name})- up_d = up.get("data", up) if isinstance(up, dict) else {}- item_urn = (up_d or {}).get("itemUrn", "") if isinstance(up_d, dict) else ""- try:- _proxy_to_addin("run_modeling_script", {"script": "app.activeDocument.close(False)\nresult={}"}, timeout=20)- except Exception:- pass- if "dm.lineage:" not in item_urn:- return {"success": False, "error": "f3d upload did not return a lineage urn: " + str(up_d)}- lid = item_urn.split("dm.lineage:")[-1]- wip_urn = f"urn:adsk.wipprod:fs.file:vf.{lid}?version=1"- return {- "success": True,- "wip_urn": wip_urn,- "dims_mm": dims,- "f3d": f3d_path,- "before": before_shot,- "after": after_shot,- "_hint": (- "DONE - a PROPER component 3D package (FOOTPRINT + chip, aligned) created with NO GUI dialogs. "- "NEXT: hand-write this wip_urn into the library's EAGLE XML - a <package3d name=\"PKG\" urn=\"\" "- "wip_urn=\"" + wip_urn + "\" locally_modified=\"yes\" type=\"model\"> (with <packageinstances>"- "<packageinstance name=\"PKG\"/></packageinstances>) inside <packages3d> (between </packages> and "- "<symbols>), PLUS <package3dinstances><package3dinstance package3d_urn=\"" + wip_urn + "\"/>"- "</package3dinstances> inside the device after </connects>. Then fusion_open_lbr the merged .lbr. "- "EXPECT: fusion_check_dialogs == 0 (no broken-ref), the device 3D preview shows footprint + chip "- "(NOT 'Thumbnail download failed'), and opening the f3d shows the chip's pads landing on the "- "footprint pads. "- "SPEEDUP: for a many-part library, call this verb once per part, collect the urns, hand-merge ALL "- "bindings in ONE pass, then fusion_open_lbr ONCE (don't open/save per part). "- "PITFALLS: needs Fusion running (after a bridge_install respawn it can transiently report "- "'not running' - fusion_start, then retry); a RAW STEP upload binds but renders NOTHING "- "('Thumbnail download failed') so always go through this verb (it makes an f3d); NEVER FINISH the "- "Package3D generator (Package3DStop) - its Save dialog + two opaque CEF modals are unbeatable, "- "this verb saveAs-es instead. Full recipe: the fusion-multipart-libraries skill."- ),- }---def _orchestrate_build_library_3d(args: dict) -> dict:- """Bind real 3D onto a multi-part library. Runs DETACHED by default (async=True) so it- SURVIVES AD's 60s relay cap.-- ⚠️ LEARNED THE HARD WAY (2026-07-08): the cloud Package3D generate + Hub upload takes MINUTES- on a real machine, but AD's relay hard-caps every request at ~60s and `timeoutSeconds` is NOT- honored for this verb - so the old synchronous build got its thread KILLED at 60s, wrote no- bound .lbr, and lost every wip_urn (3/4 pins on a demo board came back as flat pads because the- 4th never got a fresh urn). Fix: the build now runs in a BACKGROUND daemon thread and returns- immediately; the caller POLLS the bound `outLbrPath` file (read_file) until it has one- `<package3d ... wip_urn=urn:...>` per part. Pass `async:false` only on a fast machine where the- whole build fits under 60s. Then embed those package3d + a per-`<element>` `package3d_urn` in a- `.brd` and `fusion_open_board` + `fusion_show_3d_board` shows the REAL 3D bodies (background).-- args: {..., async?: bool (default TRUE - detached + pollable)}. See _build_library_3d_core.- """- combined = (args.get("lbrPath") or "").replace("\\", "/")- out_path = (args.get("outLbrPath") or combined).replace("\\", "/")- if args.get("async", True) and args.get("parts"):- import threading as _th-- def _run():- try:- _build_library_3d_core(args)- except Exception:- pass- _th.Thread(target=_run, daemon=True).start()- return {- "success": True, "status": "started", "async": True,- "boundLbr": out_path, "partsTotal": len(args.get("parts") or []),- "_hint": (- "⏳ 3D bind runs DETACHED (survives AD's 60s relay cap, which used to kill it + lose "- "every wip_urn). POLL the boundLbr via read_file every ~30s until it has one "- "<package3d name=... wip_urn=urn:...> PER PART (count == partsTotal). Do NOT re-fire "- "while running (check fusion_addin_status.busy). When done, embed those <packages3d> + "- "a per-<element> package3d_urn in your .brd, then fusion_open_board + fusion_show_3d_board "- "for real 3D bodies (all background). If a urn stays unresolved in the 3D view, re-run "- "for just that part - its f3d upload failed."),- }- return _build_library_3d_core(args)---def _build_library_3d_core(args: dict) -> dict:- """Build a RENDERING multi-part 3D library in ONE call - the whole programmatic pipeline.-- For each part: make its footprint+chip f3d package (no GUI dialogs, optional BEFORE/AFTER- screenshots), collect the wip_urn. Then inject ALL bindings into the combined .lbr in one- pass (no hand XML surgery) and open the finished library ONCE. This is the verb to call for- a basic-parts sampler / any many-part library - it replaces the per-part make_3d_package loop- + manual binding the AI used to do.-- args: {- lbrPath: combined .lbr to bind + open (Windows path),- parts: [{package, lbrPath (per-part .lbr whose FIRST package is this footprint),- modelPath (.step)}],- outLbrPath?: where to write the bound .lbr (default: overwrite lbrPath),- capture?: bool - capture before/after per part (default True),- projectId?, folderId?: APS upload target (default: the MAIN-project upload folder),- openWhenDone?: bool (default True)- }- """- combined = (args.get("lbrPath") or "").replace("\\", "/")- parts = args.get("parts") or []- if not combined or not parts:- return {"success": False,- "error": "lbrPath (combined .lbr) and parts[] are required.",- "_hint": "parts: [{package, lbrPath (per-part footprint .lbr), modelPath (.step)}]. "- "projectId/folderId default to the MAIN-project upload folder."}- out_path = (args.get("outLbrPath") or combined).replace("\\", "/")- capture = args.get("capture", True)- # Put this library's f3d in its OWN task subfolder under 'Adom AI Workspace' (never project root).- import os as _os2- task = args.get("task") or _os2.path.basename(combined).rsplit(".", 1)[0]- project_id, folder_id = _discover_upload_target({**args, "task": task})- if not folder_id:- return {"success": False, "errorCode": "no_work_folder",- "error": "Could not resolve a non-root 'Adom AI Workspace' upload folder.",- "_hint": "The bridge refuses to write to a shared project ROOT. Sign in "- "(fusion_aps_signin) so it can create the workspace folder, or pass a real "- "(non-root) folderId. See the fusion-cloud-hygiene skill."}- results = []; bindings = []; shots = []- for p in parts:- pkg = p.get("package")- per_lbr = (p.get("lbrPath") or "").replace("\\", "/")- step = (p.get("modelPath") or "").replace("\\", "/")- if not pkg or not per_lbr or not step:- results.append({"package": pkg, "success": False,- "error": "package, lbrPath (per-part .lbr) and modelPath (.step) all required"})- continue- mk_args = {- "lbrPath": per_lbr, "modelPath": step,- "projectId": project_id, "folderId": folder_id,- "fileName": f"{pkg}_3d", "task": task,- "captureLabel": (pkg if capture else None),- # Per-part orient passthrough (2026-07-07): a caller can set orient:false on a part- # whose STEP is already correctly oriented (e.g. a tall through-hole pin). Defaults- # to True, and make_3d_package's tall-part guard now keeps tall parts vertical anyway.- "orient": p.get("orient", True),- }- mk = _orchestrate_make_3d_package(mk_args)- # Retry once: the FIRST part of a run often fails transiently while Fusion settles from a- # prior view/library; a single retry recovers it (the dropped-first-part trap).- if not mk.get("success"):- import time as _t- _t.sleep(2)- mk = _orchestrate_make_3d_package(mk_args)- entry = {"package": pkg, "success": bool(mk.get("success"))}- if mk.get("success"):- entry["wip_urn"] = mk.get("wip_urn"); entry["dims_mm"] = mk.get("dims_mm")- bindings.append({"package": pkg, "wip_urn": mk["wip_urn"]})- else:- entry["error"] = mk.get("error")- if mk.get("before"):- shots.append({"package": pkg, "stage": "before", "path": mk["before"]})- if mk.get("after"):- shots.append({"package": pkg, "stage": "after", "path": mk["after"]})- results.append(entry)- # inject every binding in ONE pass, write the bound library. Read the ALREADY-BOUND output if it- # exists so a re-run ACCUMULATES (merge-aware) onto prior successes instead of starting clean.- bound = None- if bindings:- try:- import os as _os- src = out_path if _os.path.exists(out_path) else combined- with open(src, "r", encoding="utf-8") as fh:- txt = fh.read()- txt = _inject_package3d_bindings(txt, bindings)- with open(out_path, "w", encoding="utf-8") as fh:- fh.write(txt)- bound = out_path- except Exception as e:- return {"success": False, "error": f"binding injection failed: {e}",- "parts": results, "bindings": bindings, "screenshots": shots}- # open the finished library ONCE (so check_dialogs reflects the merged result)- opened = None- if bound and args.get("openWhenDone", True):- try:- opened = bool(_orchestrate_open_lbr({"filePath": bound}).get("success"))- except Exception:- opened = False- n_ok = sum(1 for r in results if r.get("success"))- return {- "success": n_ok > 0,- "boundLbr": bound,- "partsBound": n_ok,- "partsTotal": len(parts),- "parts": results,- "screenshots": shots,- "opened": opened,- "_hint": (- f"{n_ok}/{len(parts)} parts got a RENDERING footprint+chip 3D package; all bindings injected "- f"into {bound} and the library opened once. NEXT: fusion_check_dialogs should be 0 (no "- "broken-ref). For clean device previews + screenshots, save to the cloud and REOPEN from "- "the cloud (fusion_aps_open) - the in-session view often gets an empty 'Untitled' shoved in "- "front. Each device's lower-right 3D preview should show footprint + chip (NOT 'Thumbnail "- "download failed'). The BEFORE (footprint) / AFTER (chip placed) PNGs are in screenshots[] "- "on the Windows box (C:/tmp/conduit-screenshots) - pull them with desktop_pull_file (or "- "re-capture via desktop_screenshot_window) for the demo video. "- "AFTER (recommended): call fusion_capture_library_views per showcase part to grab the "- "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 "- "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 "- "missing parts pointing outLbrPath at the SAME file - binding injection is now MERGE-AWARE "- "and idempotent, so it accumulates onto the existing bindings (it no longer wipes the "- "parts that already succeeded). Each part also self-retries once on a transient first-part "- "failure. Full recipe: the fusion-multipart-libraries skill."- ),- }---def _dismiss_dialogs_bg() -> list:- """Dismiss any blocking Fusion dialog in the BACKGROUND, without stealing focus.-- Uses close_window (WM_CLOSE via PostMessage) = Cancel/No on the dialog - the background-safe- dismiss. ⛔ NEVER use send_key/Escape for this: send_key calls SetForegroundWindow and YANKS- Fusion to the foreground, disrupting the user (learned 2026-06-29). WM_CLOSE is also more reliable- than Escape on Qt dialogs. Returns the titles dismissed."""- try:- info = get_fusion_window_info()- dismissed = []- for d in info.get("dialogs", []) or []:- hwnd = d.get("hwnd")- if hwnd:- try:- close_window(hwnd) # background WM_CLOSE = Cancel/No (never creates a stray)- dismissed.append(d.get("title") or "")- except Exception:- pass- return dismissed- except Exception:- return []---def _orchestrate_capture_library_views(args: dict) -> dict:- """Capture the LIBRARY-EDITOR views that make EEs trust a part: the schematic SYMBOL, the- FOOTPRINT (pads + layer stack), and the COMPONENT/device view (Content Manager: the symbol +- the package table with the footprint<->package Mapped check + pin/pad counts). These are what- developers want to see - the 3D before/after shots alone don't show them.-- Requires the .lbr OPEN in the Electronics Library workspace (fusion_open_lbr first). For each- package it runs the EAGLE/Electron EDIT <pkg>.sym / .pac / .dev, WINDOW FIT to frame, and a- background hwnd screenshot (never fullscreen). Returns the saved PNG paths on the Windows box- (C:/tmp/conduit-screenshots - pull them with desktop_pull_file).-- args: {packages: [<deviceset name>, ...] (or a single 'package'),- views?: subset of ['component','symbol','footprint'] (default all three),- settle?: seconds to wait after each EDIT before framing/capturing (default 1.5 - raise it- if a shot shows the PREVIOUS part, lower it to go faster on a snappy machine)}- NOTE: ~2s per view * parts * views can exceed the ~60s relay request timeout - the captures still- complete server-side; verify the PNGs landed in C:/tmp/conduit-screenshots and pull them.- """- import time as _t- pkgs = args.get("packages") or ([args["package"]] if args.get("package") else [])- if not pkgs:- return {"success": False,- "error": "packages: [<deviceset name>] (or package: <name>) required.",- "_hint": "Open the .lbr first (fusion_open_lbr). Names are the deviceset names, e.g. ESP32-S3FN8."}- view_ext = {"component": "dev", "symbol": "sym", "footprint": "pac",- "dev": "dev", "sym": "sym", "pac": "pac"}- label_of = {"dev": "component", "sym": "symbol", "pac": "footprint"}- views = args.get("views") or ["component", "symbol", "footprint"]- # EDIT is async - the editor takes a beat to actually SWITCH the view. Capturing too soon grabs- # the PREVIOUS view (a mislabeled shot). Settle AFTER the EDIT (before fit/capture); tunable.- settle = float(args.get("settle", 1.5))- out = []- for pkg in pkgs:- for v in views:- ext = view_ext.get(v, v)- er = _proxy_to_addin("electron_run", {"command": f"EDIT {pkg}.{ext}"}, timeout=30)- _t.sleep(settle) # let the editor LOAD the new view before framing/capturing it- # ALWAYS catch + dismiss any dialog the EDIT raised - in the BACKGROUND (no focus steal).- # Common one: "Create new symbol/footprint '<name>'?" when <name> is a DEVICESET name but- # the symbol/package is named by the shared combo. Dismissing (Cancel/No) avoids a stray.- dismissed = _dismiss_dialogs_bg()- _proxy_to_addin("electron_run", {"command": "WINDOW FIT"}, timeout=20)- _t.sleep(0.6)- shot = _capture_labeled(f"{pkg}_{label_of.get(ext, ext)}")- out.append({"package": pkg, "view": label_of.get(ext, ext), "path": shot,- "dialogDismissed": dismissed or None,- "ok": bool(er.get("success", True)) and bool(shot) and not dismissed})- n_ok = sum(1 for o in out if o.get("ok"))- return {- "success": n_ok > 0,- "captured": out,- "_hint": (- f"Captured {n_ok}/{len(out)} library-editor views to C:/tmp/conduit-screenshots (pull with "- "desktop_pull_file). SYMBOL = full pinout; FOOTPRINT = pads + layer stack; COMPONENT = the "- "Content Manager device view with the footprint<->package Mapped check + pin/pad counts. "- "⚠️ NAMES: the COMPONENT (.dev) view opens by DEVICESET name (e.g. R-0603-10K). But SYMBOL "- "(.sym) and FOOTPRINT (.pac) open by the SYMBOL / PACKAGE name - in a SHARED-FOOTPRINT "- "library (one symbol/footprint per package, many value devicesets) that is the COMBO name "- "(e.g. 'R-0603'), NOT the deviceset name ('R-0603-10K'). Passing a deviceset name to .sym/"- ".pac makes Fusion pop 'Create new symbol/footprint?'; the bridge now auto-dismisses that in "- "the BACKGROUND (Cancel/No, no focus steal - see entries with dialogDismissed) and marks the "- "view not-ok, but you should pass the right name. For shared libraries the COMPONENT view "- "alone shows symbol + footprint + 3D previews + Mapped, so it is usually enough. "- "PITFALLS: the .lbr must be OPEN in the Electronics Library workspace (fusion_open_lbr first)."- ),- }---def _orchestrate_cleanup_cloud_files(args: dict) -> dict:- """Precisely delete a LIST of cloud files by lineage urn - SAFE cleanup of AI-created clutter.-- Deletes ONLY the exact `fileIds` (lineage urns) given - never name-guessing, so it cannot touch a- teammate's file in a shared folder. Loops server-side (one call deletes the whole list). Use this- to clean up f3d files the bridge created. (Find the ids with fusion_aps_browse on the folder.)-- args: {fileIds: [<lineage urn>, ...], projectName?: str (default active), folderPath?: str- (default root - where the file lives)}- """- file_ids = args.get("fileIds") or []- if not file_ids:- return {"success": False, "error": "fileIds [lineage urns] required.",- "_hint": "Get them from fusion_aps_browse {projectId, folderId} (item .id)."}- project_name = args.get("projectName", "")- folder_path = args.get("folderPath", "")- deleted = []; failed = []- for fid in file_ids:- try:- r = _proxy_to_addin("delete_cloud_file",- {"fileId": fid, "projectName": project_name, "folderPath": folder_path},- timeout=40)- if isinstance(r, dict) and r.get("success"):- deleted.append(fid)- else:- failed.append({"fileId": fid, "error": (r.get("error") if isinstance(r, dict) else str(r))[:90]})- except Exception as e:- failed.append({"fileId": fid, "error": str(e)[:90]})- return {- "success": len(deleted) > 0 or not failed,- "deletedCount": len(deleted),- "failedCount": len(failed),- "failed": failed[:25],- "_hint": (- f"Deleted {len(deleted)}/{len(file_ids)} cloud files by PRECISE lineage urn (no name-guessing, "- "so teammates' files are untouched). NOTE: a large list takes minutes and the relay request "- "may time out at ~60s while the deletes continue server-side - re-browse the folder to confirm "- "the count dropped. Anything in failed[] usually means the file was already gone or you lack "- "delete permission. See the fusion-cloud-hygiene skill."- ),- }---def _orchestrate_save_lbr(args: dict) -> dict:- """Save the currently open Electronics library as a .flbr file.-- Uses Document.CopyToDesktop to export the library in Fusion's native- .flbr format. The file can be re-opened later or uploaded to the cloud.- """- import os-- output_path = args.get("outputPath", "")- if not output_path:- return {"success": False, "error": "No outputPath specified"}-- output_path = output_path.replace("\\", "/")-- # Ensure path ends with .flbr- if not output_path.lower().endswith(".flbr"):- output_path += ".flbr"-- # Ensure parent directory exists- parent = os.path.dirname(output_path)- if parent and not os.path.exists(parent):- try:- os.makedirs(parent, exist_ok=True)- except Exception as e:- return {"success": False, "error": f"Cannot create directory {parent}: {e}"}-- # Execute Document.CopyToDesktop via the add-in- result = _proxy_to_addin("execute_text_command", {- "command": f"Document.CopyToDesktop {output_path}",- }, timeout=30)-- if not result.get("success"):- return {- "success": False,- "error": f"Document.CopyToDesktop failed: {result.get('error', 'unknown')}",- "data": {"outputPath": output_path},- }-- # Verify the file was created- if os.path.exists(output_path):- file_size = os.path.getsize(output_path)- result = {- "success": True,- "output": f"Saved library to {os.path.basename(output_path)} ({file_size} bytes)",- "data": {"outputPath": output_path, "fileSize": file_size},- }- else:- result = {- "success": True,- "output": f"Document.CopyToDesktop executed (file may still be writing)",- "data": {"outputPath": output_path},- }-- # Auto-screenshot after save — catches blocking dialogs- return _post_open_screenshot(result, settle_time=1.0)---# Package types = EPG's Scripts3d module names (each has runWithInput(params)).-# Kept in sync with Autodesk's ElectronicsPackageGenerator internal add-in.-_EPG_TYPES = [- "axial_diode", "axial_fuse", "axial_polarized_capacitor", "axial_resistor",- "bga", "chip", "chiparray2sideconvex", "chiparray2sideflat", "chiparray4sideflat",- "chip_led", "cornerconcave", "crystal", "dfn2", "dfn3", "dfn4", "dip",- "dip_socket", "dip_socket_dual_leaf", "dpak", "ecap", "female_standoff", "hc49",- "header_right_angle", "header_right_angle_socket", "header_straight",- "header_straight_socket", "male_female_standoff", "melf", "molded",- "oscillator_j", "oscillator_l", "plcc", "qfn", "qfp", "radial_dipped_rect",- "radial_ecap", "radial_inductor", "radial_round_led", "snap_lock", "sod",- "sodfl", "soic", "soj", "son", "sot143", "sot223", "sot23", "sotfl",- "surface_mount_header_female", "surface_mount_pin_header_right_angle",- "surface_mount_pin_header_straight",-]--# EPG param keys that are NOT lengths (never mm->cm converted).-_EPG_NON_DIMENSION_KEYS = {"DPins", "EPins", "pins", "thermal", "color_r", "color_g", "color_b"}---def _orchestrate_generate_package(args: dict) -> dict:- """Generate an IPC-compliant 3D package via Fusion's built-in- ElectronicsPackageGenerator (EPG), optionally laser-etch a marking into the- body top, and optionally export STEP - all in ONE headless add-in call.-- Proven live 2026-07-03 (0603 + '103' etch + 233KB STEP, 1.9s generation).- """- pkg_type = (args.get("type") or "").strip().lower()- if pkg_type not in _EPG_TYPES:- return {- "success": False,- "error": f"Unknown package type: {pkg_type!r}",- "errorCode": "unknown_package_type",- "supportedTypes": _EPG_TYPES,- "_hint": "Pass type as one of supportedTypes (EPG Scripts3d module names). "- "Common: chip (0402/0603/0805 passives), soic, qfn, qfp, bga, sot23, "- "dfn2, melf, ecap, crystal, header_straight.",- }-- raw_params = args.get("params") or {}- units_cm = bool(args.get("unitsCm"))- params = {}- for k, v in raw_params.items():- if (not units_cm) and isinstance(v, (int, float)) and k not in _EPG_NON_DIMENSION_KEYS:- params[k] = v / 10.0 # mm (datasheet-native) -> cm (Fusion/EPG-native)- else:- params[k] = v-- etch = args.get("etch") or ""- etch_style = (args.get("etchStyle") or "raised").strip().lower() # raised (white, default) | engraved- etch_depth_cm = float(args.get("etchDepthMm") or 0.03) / 10.0- etch_height_mm = args.get("etchHeightMm") # None = auto-fit- output_step = args.get("outputStep") or ""- bridge_version = BRIDGE_VERSION-- script = f"""-import sys, glob, os, importlib-import adsk.core, adsk.fusion--# Locate EPG across install conventions + versions (NEVER hardcode the webdeploy hash).-_bases = []-for env in ('ProgramFiles', 'ProgramFiles(x86)', 'ProgramW6432', 'LOCALAPPDATA'):- root = os.environ.get(env)- if root:- _bases += glob.glob(os.path.join(root, 'Autodesk', 'webdeploy', 'production', '*',- 'Api', 'InternalAddins', 'ElectronicsPackageGenerator'))-if not _bases:- raise RuntimeError('EPG_NOT_FOUND: ElectronicsPackageGenerator not present in this Fusion install')-epg_dir = max(_bases, key=os.path.getmtime)-parent = os.path.dirname(epg_dir)-if parent not in sys.path:- sys.path.insert(0, parent)--mod = importlib.import_module('ElectronicsPackageGenerator.Scripts3d.{pkg_type}')--doc = app.documents.add(adsk.core.DocumentTypes.FusionDesignDocumentType)-design = adsk.fusion.Design.cast(app.activeProduct)-mod.runWithInput({params!r}, design)-root = design.rootComponent--inv = [{{'name': b.name, 'vol': round(b.volume, 6)}} for b in root.bRepBodies]-etched = None-if {etch!r}:- # FIND THE TOP SURFACE the robust way (John's algorithm, 2026-07-04):- # 1. bounding box of the WHOLE chip (all bodies);- # 2. only faces whose centroid sits in the TOP 5% band of that bbox count;- # 3. among those upward planar faces take the LARGEST AREA (the molded body- # top beats terminal tops that tie it on height);- # 4. lay the text out with a 10% margin inside that face.- gmin, gmax = 1e9, -1e9- for b in root.bRepBodies:- bb = b.boundingBox- gmin = min(gmin, bb.minPoint.z)- gmax = max(gmax, bb.maxPoint.z)- band_z = gmax - 0.05 * (gmax - gmin)- top_face, top_area = None, -1.0- for b in root.bRepBodies:- for f in b.faces:- if isinstance(f.geometry, adsk.core.Plane) and f.geometry.normal.z > 0.9 \- and f.centroid.z >= band_z and f.area > top_area:- top_face, top_area = f, f.area- if top_face is None:- raise RuntimeError('ETCH_NO_TOP_FACE: no upward planar face in the top 5% of the chip bbox')- body = top_face.body- sk = root.sketches.add(top_face)- center = sk.modelToSketchSpace(top_face.centroid)- # Measure the face in SKETCH space (model-space bbox axes can be SWAPPED vs- # the sketch axes the text lays out in). Project the model bbox corners.- bb = top_face.boundingBox- p_min = sk.modelToSketchSpace(bb.minPoint)- p_max = sk.modelToSketchSpace(bb.maxPoint)- face_w = abs(p_max.x - p_min.x) # extent along sketch-x (the default text baseline)- face_h = abs(p_max.y - p_min.y) # extent along sketch-y- # ALWAYS run the text along the LONGEST axis of the top face (most room for- # the marking - John's rule, 2026-07-04). If the long axis is sketch-y,- # rotate the text 90deg rather than squeezing it onto the short axis.- import math as _math- rot_deg = 90 if face_h > face_w else 0- long_len = max(face_w, face_h)- short_len = min(face_w, face_h)- w = long_len * 0.80 # 10% margin each side along the long axis- box_h_cap = short_len * 0.80 # 10% margin each side across it- # multi-line support (e.g. MPN + variant on line 2): fit per-line- lines = {etch!r}.split(chr(10))- n_lines = max(1, len(lines))- max_chars = max(1, max(len(l) for l in lines))- # width-aware auto-fit: glyph ~0.75*h wide; line block ~1.35*h per line- h = ({etch_height_mm!r} / 10.0) if {etch_height_mm!r} else \- max(0.015, min(box_h_cap / (1.35 * n_lines), w / (0.75 * max_chars)))- if rot_deg:- c1 = adsk.core.Point3D.create(center.x - box_h_cap/2, center.y - w/2, 0)- c2 = adsk.core.Point3D.create(center.x + box_h_cap/2, center.y + w/2, 0)- else:- c1 = adsk.core.Point3D.create(center.x - w/2, center.y - box_h_cap/2, 0)- c2 = adsk.core.Point3D.create(center.x + w/2, center.y + box_h_cap/2, 0)- ti = sk.sketchTexts.createInput2({etch!r}, h)- ti.setAsMultiLine(c1, c2,- adsk.core.HorizontalAlignments.CenterHorizontalAlignment,- adsk.core.VerticalAlignments.MiddleVerticalAlignment, 0)- if rot_deg:- try:- ti.angle = _math.pi / 2.0- except Exception:- rot_deg = 0 # older API without angle - fall back to unrotated- txt = sk.sketchTexts.add(ti)- # MEASURE-AND-CORRECT (the margin is a CONTRACT): font metrics vary, so the- # 0.75h glyph estimate can under-shoot ('ADOM-A' rendered edge-to-edge). After- # placing, measure the text's real bbox; if it busts the 10%-margin box,- # recreate at the scaled-down height. Never trust an estimate you can measure.- fit_iterations = 0- measured_w = None- for _pass in range(2):- tb = txt.boundingBox- t_ext_x = abs(tb.maxPoint.x - tb.minPoint.x)- t_ext_y = abs(tb.maxPoint.y - tb.minPoint.y)- measured_w = max(t_ext_x, t_ext_y) # the baseline-axis extent- measured_c = min(t_ext_x, t_ext_y) # the cross-axis extent (line stack)- if measured_w <= w and measured_c <= box_h_cap:- break- scale = min(w / max(measured_w, 1e-6), box_h_cap / max(measured_c, 1e-6)) * 0.95- h = max(0.008, h * scale)- txt.deleteMe()- ti = sk.sketchTexts.createInput2({etch!r}, h)- ti.setAsMultiLine(c1, c2,- adsk.core.HorizontalAlignments.CenterHorizontalAlignment,- adsk.core.VerticalAlignments.MiddleVerticalAlignment, 0)- if rot_deg:- try:- ti.angle = _math.pi / 2.0- except Exception:- pass- txt = sk.sketchTexts.add(ti)- fit_iterations += 1- marking_geom = {{- 'chipBBox': {{'min': [round(gmin,5), 0, 0], 'maxZ': round(gmax,5)}},- 'chipBBoxZ': {{'min': round(gmin,5), 'max': round(gmax,5)}},- 'topBandZ': round(band_z,5),- 'topFace': {{'area': round(top_area,6), 'centroidZ': round(top_face.centroid.z,5),- 'sketchExtents': {{'x': round(face_w,5), 'y': round(face_h,5)}},- 'body': body.name}},- 'longAxis': 'sketch-y' if face_h > face_w else 'sketch-x',- 'rotatedDeg': rot_deg,- 'textBox': {{'c1': [round(c1.x,5), round(c1.y,5)], 'c2': [round(c2.x,5), round(c2.y,5)],- 'marginPct': 10}},- 'textHeightMm': round(h*10,4), 'lines': n_lines, 'maxLineChars': max_chars,- 'heightAuto': not bool({etch_height_mm!r}),- 'textWidthMeasuredMm': round(measured_w*10,4) if measured_w else None,- 'fitIterations': fit_iterations,- 'why': {{'band': 'top 5% of whole-chip bbox excludes terminal tops that tie the body',- 'largestArea': 'molded body top beats small terminal faces in the band',- 'longAxis': 'text runs along the longest face axis for maximum room',- 'fit': 'h = min(boxH/(1.35*lines), boxW/(0.75*maxChars)) - glyphs ~0.75h wide'}},- }}- if {etch_style!r} == 'engraved':- # engraved (sunken) cut - the pre-2026-07-04 default- ext_in = root.features.extrudeFeatures.createInput(txt, adsk.fusion.FeatureOperations.CutFeatureOperation)- ext_in.setDistanceExtent(False, adsk.core.ValueInput.createByReal(-{etch_depth_cm!r}))- ext_in.participantBodies = [body]- root.features.extrudeFeatures.add(ext_in)- else:- # RAISED WHITE marking (default): humans need CONTRAST - a thin positive- # extrude painted white on the dark body reads like real silkscreen ink,- # far better than a same-color emboss. Reuses EPG's own rgb-appearance- # utility so the white survives Fusion rendering (+ colored STEP).- ext_in = root.features.extrudeFeatures.createInput(txt, adsk.fusion.FeatureOperations.NewBodyFeatureOperation)- ext_in.setDistanceExtent(False, adsk.core.ValueInput.createByReal({etch_depth_cm!r}))- mark = root.features.extrudeFeatures.add(ext_in)- from ElectronicsPackageGenerator.Utilities import addin_utility as _au- for i in range(mark.bodies.count):- mb = mark.bodies.item(i)- mb.name = 'Marking' if i == 0 else 'Marking' + str(i + 1)- _au.apply_rgb_appearance(app, design, mb, 255, 255, 255, 'AdomMarkingWhite')- etched = {etch!r}--step_path = None-if {output_step!r}:- em = design.exportManager- opts = em.createSTEPExportOptions({output_step!r})- em.execute(opts)- step_path = {output_step!r}--# SIDECAR MANIFEST: every setting we picked + WHY + the bboxes we calculated,-# so a marking refresh (e.g. adding a variant name as a 2nd line) can rework the-# text without re-deriving anything. Written next to the STEP as <step>.manifest.json.-manifest = {{- 'schema': 'adom-fusion-generate-package/1',- 'bridgeVersion': {bridge_version!r},- 'type': {pkg_type!r},- 'paramsCm': {params!r},- 'paramsNote': 'paramsCm are EPG-native cm (mm inputs were /10)',- 'marking': ({{'text': {etch!r}, 'style': {etch_style!r},- 'depthMm': {etch_depth_cm!r} * 10, **marking_geom}} if etched else None),- 'bodies': inv,- 'outputs': {{'step': step_path}},- 'refresh': {{- 'howTo': 'To re-mark (e.g. add a variant on line 2): call fusion_generate_package again '- 'with the SAME type+paramsCm (unitsCm:true) and the new multi-line etch text '- '(join lines with a newline). The generator is deterministic, so geometry and '- 'placement reproduce exactly; this manifest carries the boxes/height the last '- 'run chose for comparison.',- 'example': {{'type': {pkg_type!r}, 'unitsCm': True, 'params': 'paramsCm from this file',- 'etch': ({etch!r} + chr(10) + 'VARIANT') if etched else 'MPN' + chr(10) + 'VARIANT'}},- }},-}}-manifest_path = None-if step_path:- manifest_path = step_path + '.manifest.json'- import json as _json- with open(manifest_path, 'w', encoding='utf-8') as _f:- _json.dump(manifest, _f, indent=2)--vp = app.activeViewport-cam = vp.camera-cam.isSmoothTransition = False-cam.target = adsk.core.Point3D.create(0, 0, 0)-cam.eye = adsk.core.Point3D.create(0.25, -0.35, 0.45)-cam.upVector = adsk.core.Vector3D.create(0, 0, 1)-cam.isFitView = True-vp.camera = cam-vp.refresh()--result = {{'type': {pkg_type!r}, 'bodies': inv, 'etched': etched, 'stepPath': step_path,- 'manifest': manifest, 'manifestPath': manifest_path,- 'epgDir': epg_dir, 'doc': app.activeDocument.name}}-"""-- res = _proxy_to_addin("run_modeling_script", {"script": script}, timeout=110)- if not res.get("success"):- err = (res.get("error") or "")- if "EPG_NOT_FOUND" in err:- res["errorCode"] = "epg_not_found"- res["_hint"] = ("Fusion's built-in ElectronicsPackageGenerator add-in was not found in "- "this install (ships with 2024+ Fusion under webdeploy .../Api/"- "InternalAddins). Update Fusion, or build the part with "- "fusion_make_3d_package from a STEP instead.")- return res-- data = res.get("data") or {}- inner = data.get("result") or {}- return {- "success": True,- "output": json.dumps(inner),- **inner,- "_hint": ("Generated an IPC-compliant parametric package via Fusion's built-in EPG"- + (f", marked '{etch}' on the chip top as {'an engraved cut' if etch_style == 'engraved' else 'RAISED WHITE text (silkscreen-style - high contrast on the dark body)'}" if etch else "")- + (f", exported STEP to {output_step}" if output_step else "")- + ". NEXT: fusion_screenshot_fusion for a visual check; pull_file the STEP for KiCad. "- "HOW THE MARKING WORKS: the top surface is found from the WHOLE-chip bounding box - "- "only upward planar faces in the top 5% height band count, largest area wins - and "- "the text is laid out with a 10% margin inside that face, auto-fit width-aware "- "(override with etchHeightMm). Default style is raised+white for CONTRAST (humans "- "can't read a dark-on-dark emboss); pass etchStyle:'engraved' for a sunken laser cut. "- "The text ALWAYS runs along the LONGEST axis of the top face (rotated 90deg when "- "needed) for maximum room; the 10% margin is ENFORCED by measuring the placed text bbox "- "and shrink-to-fitting (see fitIterations in the manifest). Multi-line markings work - join lines with a newline "- "(e.g. 'LM358' + newline + 'ADOM-A' for MPN + variant; per-line auto-fit). "- "SIDECAR MANIFEST: when outputStep is set, <step>.manifest.json records every "- "setting picked + WHY + the calculated bboxes (chip bbox, top band, face extents, "- "text box, height, rotation) - to refresh a marking (add a variant line), re-call "- "this verb with the manifest's type + paramsCm (unitsCm:true) + the new etch text; "- "generation is deterministic so placement reproduces exactly. The manifest is also "- "returned inline as 'manifest'. "- "PITFALLS: params are mm by default (unitsCm:true for EPG-native cm); dimension names "- "follow each EPG generator (chip: D/E/A/L/L1; soic: A/A1/b/D/E/E1/e/L/DPins); raised "- "markings are separate 'Marking' bodies (white in Fusion + colored STEP)."),- }---# ── Optimized-GLB pipeline (service-step2glb "molecule mode") ──────────────────-# A raw STEP->GLB tessellation (fusion_export_step + a plain step2glb convert) leaves-# EVERY solid/pad/via as its own draw call - a real board comes out ~16MB / ~25,000-# primitives that HALTS the viewer's GPU on rotation (John hit this live 2026-07-14).-# service-step2glb's molecule mode is the OCCT-side replacement for Colby's Blender-# molecule-converter: tessellate -> anchor to the MP machine pins -> (optional) bake-# silkscreen -> gold pins -> dedup/flatten/JOIN/weld/prune -> Draco. Same board comes-# out ~465KB / ~31 draw calls, auto-oriented. We call it straight from the bridge so-# a Fusion board becomes a wiki-grade GLB in ONE verb. Reachable from the box (it is a-# public *.adom.cloud host, same as the wiki the bridge already streams from).-STEP2GLB_URL = (os.environ.get("ADOM_STEP2GLB_URL")- or "https://step2glb-gmdoncpxdwx0.adom.cloud").rstrip("/")---def _multipart_body(fields: dict, files: list):- """Build a multipart/form-data body. files = [(field, filename, bytes, content_type)]."""- boundary = "----AdomBridgeGLB%d%d" % (int(_time.time() * 1000), os.getpid())- out = []- for k, v in (fields or {}).items():- out.append(("--" + boundary).encode())- out.append(('Content-Disposition: form-data; name="%s"' % k).encode())- out.append(b"")- out.append(str(v).encode())- for field, filename, data, ctype in (files or []):- out.append(("--" + boundary).encode())- out.append(('Content-Disposition: form-data; name="%s"; filename="%s"'- % (field, filename)).encode())- out.append(("Content-Type: %s" % (ctype or "application/octet-stream")).encode())- out.append(b"")- out.append(data)- out.append(("--" + boundary + "--").encode())- out.append(b"")- return b"\r\n".join(out), boundary---# A User-Agent is REQUIRED on every service call. The *.adom.cloud edge WAF 403s the-# default "Python-urllib/x.y" UA (confirmed live 2026-07-14) while a browser/curl UA-# gets 200. Set it on the POST AND both polls or the call fails with an opaque 403.-def _service_ua():- return "adom-fusion-bridge/%s" % BRIDGE_VERSION---def _service_glb_submit(step_path: str, silk_top: str = None, silk_bottom: str = None,- pin: str = "medium", job_name: str = "fusion-board") -> dict:- """POST a STEP (+ optional silk PNGs) to service-step2glb molecule mode. Returns- {ok, jobId} - does NOT wait. Pure urllib (no requests on the box)."""- try:- with open(step_path, "rb") as f:- step_bytes = f.read()- except Exception as e:- return {"ok": False, "error": "could not read STEP: %s" % e}- files = [("step", os.path.basename(step_path), step_bytes, "application/step")]- for field, p in (("silk_top", silk_top), ("silk_bottom", silk_bottom)):- if p and os.path.exists(p):- try:- with open(p, "rb") as f:- files.append((field, os.path.basename(p), f.read(), "image/png"))- except Exception:- pass- body, boundary = _multipart_body({}, files)- headers = {- "Content-Type": "multipart/form-data; boundary=" + boundary,- "X-Client": "fusion-bridge/adom",- "X-Job-Name": job_name,- "User-Agent": _service_ua(),- }- url = "%s/convert?molecule=true&pin=%s" % (STEP2GLB_URL, urllib.parse.quote(pin))- try:- req = urllib.request.Request(url, data=body, headers=headers, method="POST")- with urllib.request.urlopen(req, timeout=90) as resp:- queued = json.loads(resp.read().decode("utf-8", "replace"))- except Exception as e:- return {"ok": False, "error": "service POST failed: %s" % e,- "_hint": "Is %s reachable from this box? Override with ADOM_STEP2GLB_URL." % STEP2GLB_URL}- job_id = queued.get("job_id")- if not job_id:- return {"ok": False, "error": "service did not return a job_id", "raw": queued}- return {"ok": True, "jobId": job_id}---def _service_glb_fetch(job_id: str, out_path: str = None, poll_s: int = 0) -> dict:- """Poll a service job for up to poll_s seconds; if complete, optionally write the- GLB to out_path. Returns {ok, status, stats, glbBytes?, wrote?}. poll_s=0 = one check."""- ua = _service_ua()- stats, deadline, first = {}, _time.time() + max(0, poll_s), True- while first or _time.time() < deadline:- first = False- try:- preq = urllib.request.Request("%s/jobs/%s" % (STEP2GLB_URL, job_id), headers={"User-Agent": ua})- with urllib.request.urlopen(preq, timeout=20) as r:- stats = json.loads(r.read().decode("utf-8", "replace"))- except Exception:- _time.sleep(4); continue- st = stats.get("status")- if st == "complete":- break- if st == "error":- return {"ok": False, "status": "error", "error": stats.get("error") or stats, "stats": stats}- if _time.time() >= deadline:- return {"ok": True, "status": st or "processing", "stats": stats}- _time.sleep(6)- if stats.get("status") != "complete":- return {"ok": True, "status": stats.get("status") or "processing", "stats": stats}- try:- rreq = urllib.request.Request("%s/jobs/%s/result" % (STEP2GLB_URL, job_id), headers={"User-Agent": ua})- with urllib.request.urlopen(rreq, timeout=60) as r:- glb = r.read()- except Exception as e:- return {"ok": False, "status": "complete", "error": "download failed: %s" % e, "stats": stats}- wrote = None- if out_path:- try:- with open(out_path, "wb") as f:- f.write(glb)- wrote = out_path- except Exception as e:- return {"ok": False, "status": "complete", "error": "could not write GLB: %s" % e, "stats": stats}- return {"ok": True, "status": "complete", "stats": stats, "glbBytes": glb, "wrote": wrote}---def _orchestrate_export_optimized_glb(args: dict) -> dict:- """Fusion board -> wiki-grade optimized GLB in one call. Exports the active design's- STEP, (optionally) the top/bottom silkscreen, runs it through service-step2glb's- molecule pipeline (anchor + optional silk bake + gold pins + join/weld/prune + Draco),- and writes the small, fast, auto-anchored GLB to outputPath. See STEP2GLB_URL above."""- output_path = args.get("outputPath") or args.get("output_path")- if not output_path:- return {"success": False, "error": "No outputPath specified.",- "_hint": 'Usage: fusion_export_optimized_glb {"outputPath":"C:/tmp/board.glb", "silkscreen":true, "pin":"medium"}'}- if not output_path.lower().endswith(".glb"):- output_path = output_path + ".glb"- pin = args.get("pin", "medium")- want_silk = args.get("silkscreen", True)- # 1) Need a 3D Design product for STEP. Switch to the 3D board (no-op if already 3D).- _proxy_to_addin("show_3d_board", {}, timeout=60)- # 2) Export STEP next to the target GLB.- step_path = output_path[:-4] + ".step"- step_res = _proxy_to_addin("export_step", {"outputPath": step_path}, timeout=300)- if not step_res.get("success"):- return {"success": False, "error": "STEP export failed: %s" % (step_res.get("error") or step_res.get("message")),- "step": step_res, "_hint": "The active design must be a 3D Design product (fusion_show_3d_board first)."}- # 3) Optional silkscreen (best-effort; the GLB is great without it).- silk_top = silk_bottom = None- silk_note = None- if want_silk:- st = output_path[:-4] + "_silk_top.png"- sb = output_path[:-4] + "_silk_bottom.png"- rt = _proxy_to_addin("take_silkscreen_screenshot", {"outputPath": st, "layer": "top"}, timeout=90)- rb = _proxy_to_addin("take_silkscreen_screenshot", {"outputPath": sb, "layer": "bottom"}, timeout=90)- if rt.get("success"):- silk_top = st- if rb.get("success"):- silk_bottom = sb- if not (silk_top or silk_bottom):- silk_note = "silkscreen capture unavailable on this design; GLB built without a baked silk texture."- # 4) Submit to the molecule optimizer (fire-and-return: a big board's tessellation- # can run minutes, longer than the AD relay's request timeout, so we do NOT block- # the whole time here). Then bounded-wait up to `wait` seconds (default 90) so- # small boards still come back complete in one call.- job_name = os.path.splitext(os.path.basename(output_path))[0]- sub = _service_glb_submit(step_path, silk_top, silk_bottom, pin=pin, job_name=job_name)- if not sub.get("ok"):- return {"success": False, "error": sub.get("error"), "_hint": sub.get("_hint"),- "stepPath": step_path, "note": "STEP exported OK; optimize submit failed."}- job_id = sub["jobId"]- wait_s = int(args.get("wait", 15)) # keep total (STEP export + wait) under the ~60s relay timeout- fetched = _service_glb_fetch(job_id, out_path=output_path, poll_s=wait_s) if wait_s > 0 else {"ok": True, "status": "processing"}- status_url = "%s/jobs/%s" % (STEP2GLB_URL, job_id)- result_url = "%s/jobs/%s/result" % (STEP2GLB_URL, job_id)- if fetched.get("status") == "complete" and fetched.get("wrote"):- stats = fetched.get("stats") or {}- size = len(fetched.get("glbBytes") or b"")- return {- "success": True, "status": "complete",- "glbPath": output_path, "stepPath": step_path, "jobId": job_id,- "silkTop": silk_top, "silkBottom": silk_bottom,- "meshesBefore": stats.get("meshes_before"), "meshesAfter": stats.get("meshes_after"),- "sizeBytes": size, "moleculeAnchored": stats.get("molecule_anchored"),- "silkscreenApplied": stats.get("silkscreen_applied"), "note": silk_note,- "message": "Optimized GLB written (%d KB, meshes %s->%s, anchored=%s, silk=%s) to %s" % (- size // 1024, stats.get("meshes_before"), stats.get("meshes_after"),- stats.get("molecule_anchored"), stats.get("silkscreen_applied"), output_path),- "_hint": "Pull it with pull_file, then set it as component.parts.model_3d on a wiki component page. "- "Same optimizer as the molecule GLBs (Colby's pipeline).",- }- if not fetched.get("ok"):- return {"success": False, "error": fetched.get("error"), "jobId": job_id, "stepPath": step_path,- "statusUrl": status_url, "resultUrl": result_url}- # Still processing after the bounded wait - hand back the job so the caller finishes it.- return {- "success": True, "status": fetched.get("status") or "processing",- "pending": True, "jobId": job_id, "stepPath": step_path,- "glbPath": output_path, "silkTop": silk_top, "silkBottom": silk_bottom, "note": silk_note,- "statusUrl": status_url, "resultUrl": result_url,- "message": "Optimize job %s submitted; still processing after %ds. Finish it with "- "fusion_fetch_optimized_glb {\"jobId\":\"%s\",\"outputPath\":\"%s\"} (re-call until complete)." % (- job_id, wait_s, job_id, output_path),- "_hint": "Big boards tessellate for a few minutes. Either re-call fusion_fetch_optimized_glb "- "with this jobId (writes the GLB on the box when ready), or GET the resultUrl directly.",- }---def _orchestrate_fetch_optimized_glb(args: dict) -> dict:- """Fetch a previously-submitted optimize job's GLB (from fusion_export_optimized_glb's- jobId). Bounded poll (default 90s); writes to outputPath when complete."""- job_id = args.get("jobId")- if not job_id:- return {"success": False, "error": "No jobId.",- "_hint": 'Usage: fusion_fetch_optimized_glb {"jobId":"...","outputPath":"C:/tmp/board.glb"}'}- out_path = args.get("outputPath") or args.get("output_path")- if out_path and not out_path.lower().endswith(".glb"):- out_path = out_path + ".glb"- wait_s = int(args.get("wait", 15)) # keep total (STEP export + wait) under the ~60s relay timeout- res = _service_glb_fetch(job_id, out_path=out_path, poll_s=wait_s)- status_url = "%s/jobs/%s" % (STEP2GLB_URL, job_id)- result_url = "%s/jobs/%s/result" % (STEP2GLB_URL, job_id)- if res.get("status") == "complete" and res.get("wrote"):- stats = res.get("stats") or {}- size = len(res.get("glbBytes") or b"")- return {"success": True, "status": "complete", "glbPath": out_path, "jobId": job_id,- "sizeBytes": size, "meshesAfter": stats.get("meshes_after"),- "moleculeAnchored": stats.get("molecule_anchored"), "silkscreenApplied": stats.get("silkscreen_applied"),- "message": "Optimized GLB written (%d KB) to %s" % (size // 1024, out_path)}- if not res.get("ok"):- return {"success": False, "error": res.get("error"), "jobId": job_id, "statusUrl": status_url, "resultUrl": result_url}- return {"success": True, "status": res.get("status") or "processing", "pending": True, "jobId": job_id,- "statusUrl": status_url, "resultUrl": result_url,- "message": "Job %s still %s; re-call fusion_fetch_optimized_glb to finish." % (job_id, res.get("status") or "processing")}---def _describe_profile(p: dict) -> str:- """Human, UNAMBIGUOUS name for a browser profile - never just 'your browser'.-- A power user has several (personal / work / media), so a demo that says "it's in your- Chrome" is useless (John, 2026-07-20). Produce e.g.- "Chrome - John Personal ([email protected])" or "Edge - Default"."""- browser = (p.get("browser") or "browser").strip()- browser = {"chrome": "Chrome", "edge": "Edge", "brave": "Brave"}.get(browser.lower(), browser.title())- name = (p.get("displayName") or "").strip()- email = (p.get("email") or "").strip()- bits = browser- if name and email and name.lower() not in email.lower():- bits += " - %s (%s)" % (name, email)- elif email:- bits += " - %s" % email- elif name:- bits += " - %s" % name- elif p.get("profileDir"):- bits += " - %s" % p.get("profileDir")- return bits---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."""- why = []- try:- if ad_client.available():- r = ad_client.call(verb, args, timeout=timeout)- if isinstance(r, dict):- inner = r.get("output")- if isinstance(inner, str) and inner.strip().startswith("{"):- import json as _j0- try:- return _j0.loads(inner)- except Exception:- return r- return r- why.append("ad_client.call returned %r" % type(r).__name__)- else:- why.append("ad_client unavailable")- except Exception as e:- why.append("ad_client raised %s" % e)- try:- import subprocess as _sp, json as _j- exe = _find_adom_desktop_cli()- if not exe:- return {"_adCallError": "; ".join(why + ["adom-desktop 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("{"):- d = _j.loads(out)- inner = d.get("output")- if isinstance(inner, str) and inner.strip().startswith("{"):- try:- return _j.loads(inner)- except Exception:- return d- return d- why.append("cli stdout not json: %s" % (out[:120] or (p.stderr or "")[:120]))- except Exception as e:- why.append("cli raised %s" % e)- return {"_adCallError": "; ".join(why)}---# ── FUSION AUTO-UPDATE (John, 2026-07-22) ────────────────────────────────────────────────────-# "fusion ships updates non-stop and its annoying to click this. i just always want the latest-# fusion... just make this generally invisible to me and do that by default for all other adom-# users. but let them tell you they don't want you doing that and you make that a sticky setting."-#-# So: ON BY DEFAULT for everyone, applied silently in the BACKGROUND, opt-out is sticky per user.-# Fusion's own updater downloads in the background and swaps the build on the next launch, so all-# we have to do is stop making the human click "Update Now" on a nag panel.-def _bridge_prefs_path():- import pathlib- d = pathlib.Path(os.path.expanduser("~")) / ".adom" / "fusion-bridge"- d.mkdir(parents=True, exist_ok=True)- return d / "prefs.json"---def _get_bridge_pref(key: str, default=None):- try:- import json as _j- p = _bridge_prefs_path()- if p.exists():- v = (_j.loads(p.read_text()) or {})- return v.get(key, default)- except Exception:- pass- return default---def _set_bridge_pref(key: str, value) -> bool:- try:- import json as _j- p = _bridge_prefs_path()- cur = {}- if p.exists():- try: cur = _j.loads(p.read_text()) or {}- except Exception: cur = {}- cur[key] = value- p.write_text(_j.dumps(cur, indent=2))- return True- except Exception:- return False---def _auto_update_enabled() -> bool:- """Default TRUE. Only a user who explicitly opted out gets False, and that sticks."""- return bool(_get_bridge_pref("autoUpdateFusion", True))---# Buttons Fusion puts on its update nag / Job Status panel, best-first.-_UPDATE_BUTTONS = ("Update Now", "Update now", "Install Now", "Restart Now")---def _find_update_offer(hwnd=None) -> dict:- """Is Fusion offering an update right now? Detected via UIA (background, no foreground).- Returns {offered, button, hwnd, detail} - never raises."""- try:- hw = hwnd or _fusion_window_hwnd()- if not hw:- return {"offered": False}- # Scan the main window AND Fusion's owned popups - the update nag lives in the- # "View Job Status" panel, which is a CHILD window and is only in the UIA tree while it- # is open. Checking both is why this runs on a schedule rather than once.- candidates = [int(hw)]- try:- for w in (family_windows(int(hw)) or []):- h2 = w.get("hwnd") if isinstance(w, dict) else w- if h2 and int(h2) != int(hw):- candidates.append(int(h2))- except Exception:- pass- for h in candidates:- for label in _UPDATE_BUTTONS:- r = _unwrap(_ad_call("desktop_find_control", {"hwnd": h, "name": label}, timeout=20))- best = r.get("best") or {}- if best.get("name") and best.get("invokable"):- return {"offered": True, "button": best.get("name"), "hwnd": h,- "detail": "Fusion is offering an update ('%s' is on screen)" % best.get("name")}- return {"offered": False, "hwnd": hw}- except Exception:- return {"offered": False}---def _apply_fusion_update_silently(hwnd=None) -> dict:- """Click Fusion's update button IN THE BACKGROUND so the download starts and the new build- is applied on the next launch. No foreground, no caption - the user asked for this to be- invisible. Returns {applied, button, why}."""- if not _auto_update_enabled():- return {"applied": False, "why": "user opted out (sticky pref autoUpdateFusion=false)"}- offer = _find_update_offer(hwnd)- if not offer.get("offered"):- return {"applied": False, "why": "no update offered"}- r = _unwrap(_ad_call("desktop_ui_click",- {"hwnd": int(offer["hwnd"]), "name": offer["button"]}, timeout=30))- ok = bool(r.get("invoked") or r.get("success"))- if ok:- print("[Fusion Bridge] auto-update: clicked %r in the background" % offer["button"])- return {"applied": ok, "button": offer["button"],- "why": ("clicked %r in the background; Fusion downloads now and swaps on next launch"- % offer["button"]) if ok else "found the button but the UIA invoke did not take"}---def _handle_set_auto_update(fusion_info: dict, args: dict) -> dict:- """Turn Fusion auto-updating on/off. STICKY - remembered for this user forever."""- if "enabled" not in (args or {}):- cur = _auto_update_enabled()- return {"success": True, "autoUpdateFusion": cur,- "_hint": ("Fusion auto-update is currently %s. It is ON BY DEFAULT: the bridge "- "clicks Fusion's 'Update Now' for the user in the BACKGROUND so they "- "always run the latest build and never see the nag. To opt out: "- "fusion_set_auto_update {\"enabled\": false} - that choice is STICKY."- % ("ON" if cur else "OFF")),- "statusVerb": "fusion_readiness"}- enabled = bool(args.get("enabled"))- _set_bridge_pref("autoUpdateFusion", enabled)- return {"success": True, "autoUpdateFusion": enabled,- "narrate": ("I'll keep Fusion updated automatically in the background."- if enabled else- "I'll stop auto-updating Fusion. You'll see Autodesk's own update prompts."),- "_hint": ("Saved and sticky (~/.adom/fusion-bridge/prefs.json). %s"- % ("Auto-update ON: the bridge silently clicks 'Update Now' whenever Fusion "- "offers one, so the user always runs the newest build. Tell them it is "- "handled and they never need to click it."- if enabled else- "Auto-update OFF: the bridge will NOT touch update prompts; the user "- "handles Autodesk's nag themselves. Do not override this.")),- "statusVerb": "fusion_readiness"}---# Registered here, NOT in the COMMAND_HANDLERS literal: that dict is built at line ~1699,-# long before this function exists, so naming it there raised NameError at import and-# crash-looped the whole bridge (done exactly that, 2026-07-22).-COMMAND_HANDLERS["set_auto_update"] = _handle_set_auto_update---# ── AUTODESK FUSION MCP SERVER PROXY (John, 2026-07-23) ──────────────────────────────────────-# Autodesk + Anthropic shipped a Fusion MCP server: a LOCAL HTTP/JSON-RPC endpoint at-# http://127.0.0.1:27182/mcp that exposes Fusion's own text-to-CAD surface (read geometry,-# execute/update features, read Electronics design data).-#-# WHY THE BRIDGE PROXIES IT: that server binds LOOPBACK on the user's machine. It was designed-# for Claude Desktop running on the same box. An Adom AI runs in a CLOUD container and cannot-# reach the user's 127.0.0.1 at all. This bridge already runs on that machine, so it is exactly-# the right proxy: these verbs give the cloud AI Autodesk's MCP tools without reimplementing them.-#-# The server speaks MCP streamable-HTTP: you must `initialize`, capture the MCP-Session-Id-# response header, send notifications/initialized, and pass that header on every later call.-# Skipping it returns 400 "Missing MCP-Session-Id".-_MCP_URL = "http://127.0.0.1:27182/mcp"-_mcp_session_id = None-_mcp_lock = threading.Lock()---def _mcp_post(body: dict, sid: str = None, timeout: int = 30) -> dict:- import urllib.request, urllib.error- h = {"Content-Type": "application/json", "Accept": "application/json, text/event-stream"}- if sid:- h["MCP-Session-Id"] = sid- req = urllib.request.Request(_MCP_URL, method="POST",- data=json.dumps(body).encode(), headers=h)- try:- with urllib.request.urlopen(req, timeout=timeout) as r:- return {"status": r.status, "headers": dict(r.headers),- "body": r.read().decode("utf-8", "replace")}- except urllib.error.HTTPError as e:- return {"httpError": e.code, "headers": dict(e.headers),- "body": e.read().decode("utf-8", "replace")[:1000]}- except Exception as e:- return {"error": "%s: %s" % (type(e).__name__, str(e)[:200])}---def _mcp_new_session() -> dict:- """initialize + notifications/initialized. Returns {sessionId, serverInfo} or {error}."""- 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",- "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")}- sid = None- for k, v in (r.get("headers") or {}).items():- if k.lower() == "mcp-session-id":- sid = v- info = {}- try:- info = (json.loads(r.get("body") or "{}").get("result") or {}).get("serverInfo") or {}- except Exception:- pass- if sid:- _mcp_post({"jsonrpc": "2.0", "method": "notifications/initialized"}, sid)- _mcp_session_id = sid- return {"sessionId": sid, "serverInfo": info}---def _mcp_rpc(method: str, params: dict = None, timeout: int = 60) -> dict:- """One JSON-RPC call with automatic session (re)establishment."""- global _mcp_session_id- with _mcp_lock:- if not _mcp_session_id:- s = _mcp_new_session()- if s.get("error"):- return {"_mcpError": s["error"]}- sid = _mcp_session_id- body = {"jsonrpc": "2.0", "id": 7, "method": method}- if params is not None:- body["params"] = params- r = _mcp_post(body, sid, timeout=timeout)- # a dropped/rotated session shows up as 400 Missing/Invalid session; re-init once- if r.get("httpError") in (400, 404) and "ession" in str(r.get("body", "")):- with _mcp_lock:- _mcp_session_id = None- s = _mcp_new_session()- if s.get("error"):- return {"_mcpError": s["error"]}- sid = _mcp_session_id- r = _mcp_post(body, sid, timeout=timeout)- if r.get("error") or r.get("httpError"):- return {"_mcpError": r.get("error") or ("HTTP %s: %s" % (r.get("httpError"), r.get("body", "")[:200]))}- try:- return json.loads(r.get("body") or "{}")- except Exception:- return {"_mcpError": "unparseable response", "raw": (r.get("body") or "")[:400]}---_MCP_OFF_STATIC = (- "Fusion's MCP server is not reachable on 127.0.0.1:27182. It is OFF by default. The bridge "- "can turn it on FOR the user: call fusion_mcp_enable, which opens Preferences, expands "- "General, selects API, ticks 'Fusion MCP Server (runs locally on this device)' and clicks "- "Apply/OK. It needs Fusion running and briefly uses the foreground (announced with an "- "on-screen caption). Manual path if you prefer: profile icon (top right) -> Preferences -> "- "General -> API -> tick the box. NOTE the setting does NOT survive if Fusion is killed before "- "Apply is clicked."-)---def _mcp_off_hint() -> str:- """The MCP-unreachable hint, made state-aware: never tell the AI to go enable MCP when APS- is signed in and could serve the request RIGHT NOW with zero setup."""- r = _search_router()- lead = ""- if r.get("apsReady"):- lead = ("NOTE FIRST: APS is signed in, so for FILE SEARCH you do not need MCP at all - "- "fusion_aps_search works right now. Only enable MCP if you need its other surface "- "(script text-to-CAD, screenshots, electronics object-model reads). ")- elif r.get("apsConfigured"):- lead = ("NOTE: for FILE SEARCH, APS is configured and only needs fusion_aps_signin "- "(silent on a warm SSO session) - that may be less disruptive than enabling MCP. ")- return lead + _MCP_OFF_STATIC---def _handle_mcp_status(fusion_info: dict, args: dict) -> dict:- """Is Autodesk's Fusion MCP server up, and what does it expose?"""- s = _mcp_new_session()- if s.get("error"):- return {"success": True, "enabled": False, "url": _MCP_URL, "error": s.get("error"),- "_hint": _mcp_off_hint(), "statusVerb": "fusion_mcp_status"}- tools = []- tl = _mcp_rpc("tools/list")- if not tl.get("_mcpError"):- tools = [{"name": t.get("name"),- "description": (t.get("description") or "").split("\n")[0][:160]}- for t in ((tl.get("result") or {}).get("tools") or [])]- return {"success": True, "enabled": True, "url": _MCP_URL,- "sessionId": s.get("sessionId"), "serverInfo": s.get("serverInfo"),- "toolCount": len(tools), "tools": tools,- "_hint": ("Autodesk's Fusion MCP server is LIVE. Its tools are Autodesk's own, not this "- "bridge's: call them with fusion_mcp_call {tool, arguments}. Use "- "fusion_mcp_tools for full input schemas and fusion_mcp_resources for the "- "Electronics entity schemas. These COMPLEMENT the fusion_* verbs: prefer a "- "native verb when one exists (it is tested and returns richer hints), and "- "reach for MCP for Autodesk's text-to-CAD surface."),- "statusVerb": "fusion_mcp_status"}---def _handle_mcp_tools(fusion_info: dict, args: dict) -> dict:- """Full tool list WITH input schemas."""- tl = _mcp_rpc("tools/list")- if tl.get("_mcpError"):- return {"success": False, "enabled": False, "error": tl["_mcpError"], "_hint": _mcp_off_hint()}- tools = (tl.get("result") or {}).get("tools") or []- return {"success": True, "count": len(tools), "tools": tools,- "_hint": "Call one with fusion_mcp_call {\"tool\":\"<name>\",\"arguments\":{...}}.",- "statusVerb": "fusion_mcp_status"}---def _handle_mcp_call(fusion_info: dict, args: dict) -> dict:- """Call ANY tool on Autodesk's Fusion MCP server."""- tool = (args or {}).get("tool")- if not tool:- return {"success": False, "error": "Pass {tool, arguments}.",- "_hint": "fusion_mcp_tools lists the available tools and their input schemas."}- r = _mcp_rpc("tools/call", {"name": tool, "arguments": (args or {}).get("arguments") or {}},- timeout=int((args or {}).get("timeout") or 120))- if r.get("_mcpError"):- return {"success": False, "error": r["_mcpError"], "_hint": _mcp_off_hint()}- res = r.get("result") or {}- return {"success": not res.get("isError", False), "tool": tool, "result": res,- "_hint": ("Autodesk MCP tool result. Content is usually a list of {type,text} blocks. "- "If it complains about no active document, open one first (fusion_aps_open) "- "- MCP acts on the ACTIVE Fusion document."),- "statusVerb": "fusion_mcp_status"}---def _handle_mcp_resources(fusion_info: dict, args: dict) -> dict:- """List, or read, Autodesk's MCP resources (the Electronics entity schemas)."""- uri = (args or {}).get("uri")- if uri:- r = _mcp_rpc("resources/read", {"uri": uri})- if r.get("_mcpError"):- return {"success": False, "error": r["_mcpError"], "_hint": _mcp_off_hint()}- return {"success": True, "uri": uri, "result": r.get("result"),- "statusVerb": "fusion_mcp_status"}- r = _mcp_rpc("resources/list")- if r.get("_mcpError"):- return {"success": False, "error": r["_mcpError"], "_hint": _mcp_off_hint()}- res = (r.get("result") or {}).get("resources") or []- return {"success": True, "count": len(res),- "resources": [x.get("uri") for x in res],- "_hint": "Read one with fusion_mcp_resources {\"uri\":\"resource://...\"}.",- "statusVerb": "fusion_mcp_status"}---def _handle_mcp_enable(fusion_info: dict, args: dict) -> dict:- """Turn Autodesk's MCP server ON by driving Fusion's Preferences dialog.-- Autodesk exposes NO API for this toggle (apiPreferences has debuggingPort and- isDeveloperToolsEnabled but nothing for MCP), so the UI is the only route. Proven live- 2026-07-23. The tree children under General are rendered lazily by Qt and are NOT in the- UIA tree, so the section click is image-space and DOES take the foreground - announced.- """- import time as _t- if not _unwrap(_handle_fusion_readiness({}, {})).get("running"):- return {"success": False, "error": "Fusion is not running.",- "_hint": "fusion_start first, then fusion_mcp_enable."}- already = _mcp_new_session()- if not already.get("error"):- return {"success": True, "enabled": True, "alreadyOn": True,- "_hint": "Already enabled; nothing to do. fusion_mcp_status shows the tools."}-- _announce_foreground("turning on Fusion's MCP server in preferences")- steps = []- r = _unwrap(_ad_call("fusion_execute_text_command", {"command": "Commands.Start PreferencesCommand"}, timeout=45))- _t.sleep(3)- dlg = None- for w in (_unwrap(_ad_call("desktop_list_windows", {}, timeout=40)).get("windows") or []):- if str(w.get("title", "")).strip() == "Preferences":- dlg = w.get("hwnd")- if not dlg:- return {"success": False, "error": "Preferences dialog did not open.",- "steps": steps, "_hint": _mcp_off_hint()}- steps.append("opened Preferences")-- def shot():- s = _unwrap(_ad_call("desktop_screenshot_window", {"hwnd": int(dlg)}, timeout=45))- cm = s.get("coordMap") or {}- img = cm.get("image") or {}- return cm.get("shotId"), (img.get("w") or 1400), (img.get("h") or 836)-- sid_, W, H = shot()- if not sid_:- return {"success": False, "error": "could not capture Preferences", "steps": steps}- # fractional coords, measured on the real dialog (1400x836): expand arrow, then API row- _ad_call("desktop_click", {"space": "image", "shotId": sid_, "hwnd": int(dlg),- "x": int(W * 0.029), "y": int(H * 0.092)}, timeout=30) # expand General- steps.append("expanded General")- _t.sleep(2)- sid_, W, H = shot()- _ad_call("desktop_click", {"space": "image", "shotId": sid_, "hwnd": int(dlg),- "x": int(W * 0.071), "y": int(H * 0.135)}, timeout=30) # API row- steps.append("selected API")- _t.sleep(2)- sid_, W, H = shot()- _ad_call("desktop_click", {"space": "image", "shotId": sid_, "hwnd": int(dlg),- "x": int(W * 0.493), "y": int(H * 0.569)}, timeout=30) # the checkbox- steps.append("ticked 'Fusion MCP Server'")- _t.sleep(1)- _ad_call("desktop_click", {"space": "image", "shotId": sid_, "hwnd": int(dlg),- "x": int(W * 0.839), "y": int(H * 0.948)}, timeout=30) # Apply- _t.sleep(2)- _ad_call("desktop_click", {"space": "image", "shotId": sid_, "hwnd": int(dlg),- "x": int(W * 0.894), "y": int(H * 0.948)}, timeout=30) # OK- steps.append("clicked Apply then OK")- _t.sleep(4)-- s = _mcp_new_session()- ok = not s.get("error")- return {"success": ok, "enabled": ok, "steps": steps,- "sessionId": s.get("sessionId"), "serverInfo": s.get("serverInfo"),- "narrate": ("Fusion's MCP server is on. I enabled it for you in Preferences."- if ok else "I drove the Preferences dialog but the MCP port is still closed."),- "_hint": ("MCP server ENABLED - fusion_mcp_status lists its tools. Tell the user it is "- "handled; they do not need to touch Preferences."- if ok else- "The toggle did not take. Ask the user to set it manually: profile icon (top "- "right) -> Preferences -> General -> API -> tick 'Fusion MCP Server'. Do NOT "- "restart or kill Fusion before they click Apply, that discards the setting."),- "statusVerb": "fusion_mcp_status"}---COMMAND_HANDLERS["mcp_status"] = _handle_mcp_status-COMMAND_HANDLERS["mcp_tools"] = _handle_mcp_tools-COMMAND_HANDLERS["mcp_call"] = _handle_mcp_call-COMMAND_HANDLERS["mcp_resources"] = _handle_mcp_resources-COMMAND_HANDLERS["mcp_enable"] = _handle_mcp_enable---# ── FUSION PREFERENCES, DRIVEN THROUGH THE UI (John, 2026-07-23) ─────────────────────────────-# Fusion's Python API exposes only a THIN slice of preferences (generalPreferences: theme, orbit,-# units; apiPreferences: debuggingPort, isDeveloperToolsEnabled). Everything else - including the-# Fusion MCP Server toggle - is UI-only.-#-# But the Preferences dialog IS reachable programmatically:-# Commands.Start PreferencesCommand opens it (no menu hunting, no profile-icon click)-# and its LEFT-HAND SECTION TREE is exposed to UIA, so sections can be found by name.-#-# The catch, learned live: the CHILD sections under General (API, Design, Manufacture,-# Electronics, Render, Drawing, Simulation) are rendered LAZILY by Qt and never appear in the-# UIA tree, even after desktop_ui_expand reports success. So navigating into a child section-# needs an image-space click, which takes the foreground and is therefore announced with a-# caption. Everything up to that point is background.-#-# ⛔ The setting is DISCARDED unless Apply/OK is clicked. Killing or restarting Fusion with the-# dialog still open loses it (that is exactly why an earlier MCP enable silently reverted).-_PREF_SECTIONS = {- # section -> (parent-to-expand or None, fractional x, fractional y) on the 1400x836 dialog- "general": (None, 0.071, 0.092),- "api": ("general", 0.071, 0.135),- "design": ("general", 0.071, 0.179),- "manufacture": ("general", 0.071, 0.222),- "electronics": ("general", 0.071, 0.265),- "render": ("general", 0.071, 0.310),- "drawing": ("general", 0.071, 0.353),- "material": (None, 0.071, 0.483),- "graphics": (None, 0.071, 0.527),- "network": (None, 0.071, 0.570),- "preview features": (None, 0.071, 0.744),-}---def _prefs_dialog_hwnd():- for w in (_unwrap(_ad_call("desktop_list_windows", {}, timeout=40)).get("windows") or []):- if str(w.get("title", "")).strip() == "Preferences":- return w.get("hwnd")- return None---def _prefs_shot(hwnd):- s = _unwrap(_ad_call("desktop_screenshot_window", {"hwnd": int(hwnd)}, timeout=45))- cm = s.get("coordMap") or {}- img = cm.get("image") or {}- return (cm.get("shotId"), img.get("w") or 1400, img.get("h") or 836,- s.get("localSafePath"))---def _handle_prefs_open(fusion_info: dict, args: dict) -> dict:- """Open Fusion Preferences, optionally navigate to a section, and hand back a screenshot the- AI can click in. This is how you reach ANY preference, not just the API-exposed few."""- import time as _t- if not _unwrap(_handle_fusion_readiness({}, {})).get("running"):- return {"success": False, "error": "Fusion is not running.",- "_hint": "fusion_start first."}- section = str((args or {}).get("section") or "").strip().lower()- steps = []- hwnd = _prefs_dialog_hwnd()- if not hwnd:- _announce_foreground("opening Fusion preferences")- _ad_call("fusion_execute_text_command",- {"command": "Commands.Start PreferencesCommand"}, timeout=45)- _t.sleep(3)- hwnd = _prefs_dialog_hwnd()- steps.append("opened Preferences")- if not hwnd:- return {"success": False, "error": "Preferences dialog did not open.", "steps": steps}-- if section:- spec = _PREF_SECTIONS.get(section)- if not spec:- return {"success": False, "error": "Unknown section %r." % section,- "knownSections": sorted(_PREF_SECTIONS),- "_hint": ("Pass one of knownSections, or omit `section` and click the returned "- "shotId yourself with desktop_click {space:'image'}.")}- parent, fx, fy = spec- sid_, W, H, _p = _prefs_shot(hwnd)- if parent:- pspec = _PREF_SECTIONS[parent]- # click the expand arrow, left of the parent label- _ad_call("desktop_click", {"space": "image", "shotId": sid_, "hwnd": int(hwnd),- "x": int(W * 0.029), "y": int(H * pspec[2])}, timeout=30)- steps.append("expanded %s" % parent)- _t.sleep(2)- sid_, W, H, _p = _prefs_shot(hwnd)- _ad_call("desktop_click", {"space": "image", "shotId": sid_, "hwnd": int(hwnd),- "x": int(W * fx), "y": int(H * fy)}, timeout=30)- steps.append("selected %s" % section)- _t.sleep(2)-- sid_, W, H, path = _prefs_shot(hwnd)- return {"success": True, "hwnd": hwnd, "section": section or "general",- "shotId": sid_, "imageWidth": W, "imageHeight": H, "screenshot": path,- "steps": steps, "knownSections": sorted(_PREF_SECTIONS),- "_hint": ("Preferences is OPEN and showing this section. LOOK at the screenshot, then "- "click any control with desktop_click {space:'image', shotId, x, y, hwnd}. "- "⛔ Nothing is saved until you call fusion_prefs_close {save:true} (Apply+OK) "- "- killing or restarting Fusion first DISCARDS the change. Child sections "- "under General are lazily rendered and absent from the UIA tree, which is why "- "this verb hands you an image to click rather than control names."),- "statusVerb": "fusion_get_preferences"}---def _handle_prefs_close(fusion_info: dict, args: dict) -> dict:- """Apply+OK (save:true, default) or Cancel the Preferences dialog."""- import time as _t- save = (args or {}).get("save", True)- hwnd = _prefs_dialog_hwnd()- if not hwnd:- return {"success": True, "closed": False,- "_hint": "Preferences was not open; nothing to do."}- sid_, W, H, _p = _prefs_shot(hwnd)- if save:- _ad_call("desktop_click", {"space": "image", "shotId": sid_, "hwnd": int(hwnd),- "x": int(W * 0.839), "y": int(H * 0.948)}, timeout=30) # Apply- _t.sleep(2)- _ad_call("desktop_click", {"space": "image", "shotId": sid_, "hwnd": int(hwnd),- "x": int(W * 0.894), "y": int(H * 0.948)}, timeout=30) # OK- else:- _ad_call("desktop_click", {"space": "image", "shotId": sid_, "hwnd": int(hwnd),- "x": int(W * 0.951), "y": int(H * 0.948)}, timeout=30) # Cancel- _t.sleep(2)- return {"success": True, "closed": _prefs_dialog_hwnd() is None, "saved": bool(save),- "narrate": ("Saved your Fusion preferences." if save else "Closed preferences without saving."),- "_hint": ("Applied and closed. Some settings (the MCP server is one) only take effect "- "once saved this way." if save else "Cancelled; nothing was changed."),- "statusVerb": "fusion_get_preferences"}---COMMAND_HANDLERS["prefs_open"] = _handle_prefs_open-COMMAND_HANDLERS["prefs_close"] = _handle_prefs_close---def _fusion_signin_cfg_path():- import pathlib- d = pathlib.Path(os.path.expanduser("~")) / ".adom" / "fusion-signin"- d.mkdir(parents=True, exist_ok=True)- return d / "profile.json"---def _remembered_signin_profile():- try:- import json as _j- p = _fusion_signin_cfg_path()- if p.exists():- return (_j.loads(p.read_text()) or {}).get("profile") or None- except Exception:- pass- return None---def _remember_signin_profile(profile: str):- try:- import json as _j- _fusion_signin_cfg_path().write_text(_j.dumps({"profile": profile}))- except Exception:- pass---def _chrome_authorize_urls(max_age_min: int = 15):- """Scan Chrome + Edge profile History DBs for RECENT Autodesk-desktop OAuth authorize- URLs (the Fusion Identity SDK ones - marked by `idsdk` / redirect to idmgr/callback).-- This is how we fix Fusion opening the WRONG browser profile: Fusion fires its OAuth at- the OS-default browser, but the FULL authorize URL (client_id, PKCE challenge, state,- request_id) lands in that browser's History. We read it back and re-open it in the- profile the user actually authenticates Autodesk in. The URL is NOT tied to a browser- (its redirect is accounts.autodesk.com/idmgr/callback + an autodesk:// protocol handoff- matched by request_id), so completing it in ANY profile hands the token to the running- Fusion. Verified live (John, 2026-07-21).-- Returns a list of {url, sourceProfileDir, browser, ageSec} newest first.- """- import glob, sqlite3, shutil, tempfile, time as _t- la = os.environ.get("LOCALAPPDATA", "")- roots = []- if la:- roots.append(("chrome", os.path.join(la, "Google", "Chrome", "User Data")))- roots.append(("edge", os.path.join(la, "Microsoft", "Edge", "User Data")))- now = _t.time()- found = []- for browser, root in roots:- if not os.path.isdir(root):- continue- for hist in glob.glob(os.path.join(root, "*", "History")):- prof_dir = os.path.basename(os.path.dirname(hist))- tmp = os.path.join(tempfile.gettempdir(), "adom_hist_%s_%s.db" % (browser, prof_dir))- try:- shutil.copy2(hist, tmp) # copy first - Chrome keeps History locked- # Chrome buffers recent rows in the -wal; copy it too or a fresh authorize URL- # is invisible (this was the bug: a just-opened sign-in did not appear).- for ext in ("-wal", "-shm"):- if os.path.exists(hist + ext):- try: shutil.copy2(hist + ext, tmp + ext)- except Exception: pass- con = sqlite3.connect(tmp)- try: con.execute("PRAGMA journal_mode=WAL")- except Exception: pass- rows = con.execute(- "SELECT url, last_visit_time FROM urls "- "WHERE url LIKE '%developer.api.autodesk.com/authentication/v2/authorize%' "- "OR url LIKE '%idp.auth.autodesk.com/as/authorize%' "- "ORDER BY last_visit_time DESC LIMIT 6").fetchall()- con.close()- except Exception:- continue- for url, cts in rows:- if "idsdk" not in url and "idmgr" not in url:- continue # only the DESKTOP-app (Fusion) flow, not a web app- epoch = (cts / 1_000_000) - 11644473600- age = now - epoch- if age <= max_age_min * 60:- found.append({"url": url, "sourceProfileDir": prof_dir,- "browser": browser, "ageSec": int(age)})- found.sort(key=lambda x: x["ageSec"])- return found---def _fusion_window():- """Find the main Fusion window via desktop_list_windows. Returns the whole entry (it already- carries `rect`, so we get geometry in the SAME call - there is no desktop_window_info verb).- Returns {} when not found."""- wins = _unwrap(_ad_call("desktop_list_windows", {}, timeout=40)).get("windows") or []- cand = {}- for w in wins:- t = str(w.get("title", ""))- tl = t.lower()- if "autodesk fusion" in tl or tl.startswith("signing in"):- if "signing in" in tl or "welcome" in tl: # prefer the sign-in/welcome window- return w- cand = cand or w- return cand---def _fusion_window_hwnd():- """Just the hwnd of the main Fusion window (int|None)."""- return (_fusion_window() or {}).get("hwnd")---# ── NEVER STEAL THE USER'S FOREGROUND (John, 2026-07-22) ─────────────────────────────────────-# "try to NEVER bring windows to the foreground cuz its disruptive."-# desktop_click is SendInput and FOREGROUNDS the window. desktop_ui_click/desktop_ui_set are UIA-# Invoke/SetValue: programmatic, NO focus steal, NO cursor move, and Chromium exposes its a11y-# tree so page buttons/fields ARE reachable by accessible name. So: ALWAYS try background UIA-# first and only fall back to a foreground click for things with no UIA node (Fusion's Sign In-# button is a webview with none - that is the one documented exception).-# ── SIGN-IN WINDOW JANITOR (John, 2026-07-22) ────────────────────────────────────────────────-# "if you open a browser window for sign in, you must run a loop on a schedule to know when to-# close it so you never leave the user's desktop messy."-# We open browser windows to complete OAuth. Those windows are OURS and must not be left as-# litter once they have served their purpose. Every window we open is tracked here, and a-# background loop closes it as soon as the sign-in it belongs to is DONE (or it is clearly dead-# or too old). Never touches a window the user opened.-_signin_windows = [] # [{sessionId, profile, kind, openedAt}]-_signin_windows_lock = threading.Lock()-_JANITOR_MAX_AGE = 900-_last_update_sweep = 0.0 # 15 min: an OAuth window older than this is dead weight either way---def _track_signin_window(session_id, profile, kind="fusion"):- if not session_id:- return- with _signin_windows_lock:- _signin_windows.append({"sessionId": session_id, "profile": profile,- "kind": kind, "openedAt": _t2.time()})---def _close_tracked_window(w) -> bool:- """Close one window WE opened. nbrowser_close_window only closes our own sessions."""- r = _ad_call("nbrowser_close_window",- {"sessionId": w["sessionId"], "profile": w.get("profile")}, timeout=30)- return isinstance(r, dict) and not r.get("_adCallError")---def _signin_goal_met(kind) -> bool:- """Has the sign-in this window exists for actually completed?"""- try:- if kind == "aps":- return bool(_aps_quick_state().get("signedIn"))- return bool(_unwrap(_handle_fusion_readiness({}, {})).get("ready"))- except Exception:- return False---def _signin_janitor_loop():- """Poll until each tracked sign-in window can be closed, then close it."""- while True:- try:- _t2.sleep(20)- # Periodic AUTO-UPDATE sweep. Fusion's update nag only enters the UIA tree while its- # Job Status panel is open, so a single check at readiness time misses most of them.- # Sweeping on a schedule catches the nag whenever Fusion actually shows it, and the- # click is background so the user never sees any of it.- global _last_update_sweep- if _auto_update_enabled() and (_t2.time() - _last_update_sweep) > 300:- _last_update_sweep = _t2.time()- try:- if _unwrap(_handle_fusion_readiness({}, {})).get("running"):- _apply_fusion_update_silently()- except Exception:- pass- with _signin_windows_lock:- pending = list(_signin_windows)- if not pending:- continue- done_goals = {}- for w in pending:- kind = w.get("kind", "fusion")- if kind not in done_goals:- done_goals[kind] = _signin_goal_met(kind)- aged = (_t2.time() - w["openedAt"]) > _JANITOR_MAX_AGE- if done_goals[kind] or aged:- if _close_tracked_window(w):- print("[Fusion Bridge] janitor: closed %s sign-in window %s (%s)"- % (kind, w["sessionId"], "signed in" if done_goals[kind] else "expired"))- with _signin_windows_lock:- if w in _signin_windows:- _signin_windows.remove(w)- except Exception:- continue---def _start_signin_janitor():- t = threading.Thread(target=_signin_janitor_loop, daemon=True, name="signin-janitor")- t.start()- return t---def _announce_foreground(reason: str) -> bool:- """ALWAYS tell the user WHY, right before we steal their foreground.-- John, 2026-07-22: "anytime you bring something to the foreground, you should show an ad notify- for 2 seconds telling the user why... or an ad caption." A caption is the gentler of the two- (no toast stack, auto-clears), so that is what we use. Best-effort; never blocks the action.- """- try:- _ad_call("desktop_caption",- {"text": "Adom: %s" % reason, "id": "fusion-bridge-foreground",- "expiresInMs": 2500}, timeout=15)- return True- except Exception:- return False---def _click_background_first(hwnd, name=None, shot_id=None, x=None, y=None, reason=None) -> dict:- """Click preferring the BACKGROUND UIA path (no focus steal, no cursor move). Only falls back- to SendInput - which DOES foreground - when there is no UIA node, and in that case announces- WHY to the user first. Returns {clicked, via, why, announced}."""- if name:- r = _unwrap(_ad_call("desktop_ui_click", {"hwnd": int(hwnd), "name": name}, timeout=30))- if r.get("ok") or r.get("clicked") or r.get("invoked"):- return {"clicked": True, "via": "uia_background", "announced": False,- "why": "UIA Invoke on %r (background, no focus steal)" % name}- if shot_id is not None and x is not None and y is not None:- announced = _announce_foreground(reason or "clicking %s for you" % (name or "a button"))- _ad_call("desktop_click", {"space": "image", "shotId": shot_id,- "x": int(x), "y": int(y), "hwnd": int(hwnd)}, timeout=30)- return {"clicked": True, "via": "foreground_click", "announced": announced,- "why": "no UIA node; used SendInput (DOES foreground - user was told why)"}- return {"clicked": False, "via": None, "announced": False,- "why": "no UIA node and no shot coords given"}---# ── HUMAN WALLS: notify the user, and tell the AI it can drive it ────────────────────────────-# John, 2026-07-22: "you should be sending me ad notifies when you need me to do something" AND-# "suggest to the ai via hints that you can drive the thing on your own cuz the adom user wants-# you to do everything automatically if you can". So on every wall we do BOTH: toast the human,-# and hand the AI an exact, mechanical way to clear it without them.-def _autodesk_window_hwnds() -> set:- """hwnds of every Autodesk-ish BROWSER window right now (used to tell NEW from STALE)."""- wins = _unwrap(_ad_call("desktop_list_windows", {}, timeout=40)).get("windows") or []- out = set()- for w in wins:- tl = str(w.get("title", "")).lower()- if "autodesk" in tl and "fusion" not in tl:- out.add(w.get("hwnd"))- return out---def _detect_signin_wall(ignore_hwnds=None) -> dict:- """Classify what the Autodesk sign-in is waiting on, from BROWSER WINDOW TITLES.- Title-based on purpose: cheap, needs no OCR, and never touches/foregrounds the window.-- ignore_hwnds = windows that already existed BEFORE we opened this sign-in. Without it a dead- 'Sign-in request expired' / stale '2-step verification' tab from an earlier attempt gets- reported as the live wall and we toast the user about nothing (seen live 2026-07-22).- """- ignore = set(ignore_hwnds or ())- wins = _unwrap(_ad_call("desktop_list_windows", {}, timeout=40)).get("windows") or []- best = {}- for w in wins:- if w.get("hwnd") in ignore:- continue- t = str(w.get("title", "")); tl = t.lower()- if "2-step verification" in tl or "confirm sign-in" in tl:- return {"wall": "twofactor_email_code", "hwnd": w.get("hwnd"), "title": t}- if "identity manager" in tl:- best = {"wall": "protocol_handoff", "hwnd": w.get("hwnd"), "title": t}- elif "sign in - autodesk" in tl and not best:- best = {"wall": "credentials", "hwnd": w.get("hwnd"), "title": t}- return best---_WALL_COPY = {- "twofactor_email_code": ("Autodesk needs your 6-digit code",- "Autodesk emailed a verification code to finish signing Fusion in. "- "Enter it in the Chrome window, or tell your AI to fetch it for you."),- "credentials": ("Autodesk sign-in needs you",- "The Autodesk sign-in is asking for credentials. I never type your password "- "or 2FA - please complete it in the Chrome window."),- "protocol_handoff": ("Allow Fusion to finish signing in",- "Chrome is asking permission to open Autodesk Identity Manager. Click "- "Open to hand the login back to Fusion."),-}---def _notify_signin_wall(wall: str) -> dict:- """Toast the user's real desktop about a human wall. Best-effort, never raises."""- title, body = _WALL_COPY.get(wall, ("Fusion sign-in needs you", "Please check the sign-in window."))- return _cli_notify_all(title, body, "warning")---def _wall_hint(wall: str, notified: bool) -> str:- """The AI-facing instruction for a wall. Per John: do as much as possible IN CODE, and use- HINTS (not skills) to make the calling AI act correctly."""- common = ("I ALREADY TOASTED THE USER about this (notifyDelivered=%s) - you do not need to ask "- "me to, and do not nag them again. " % notified)- if wall == "twofactor_email_code":- return (common +- "WALL: Autodesk emailed a 6-DIGIT CODE. THE USER WANTS YOU TO DO THIS FOR THEM if "- "you can, so TRY THIS FIRST, do not just hand it back to them: if `adom-google` is "- "available in YOUR container, read the code out of their Gmail and submit it - "- " adom-google gmail search 'from:autodesk verification code' --limit 1 \n"- "then pull the 6 digits and call fusion_signin_2fa {\"code\":\"123456\"} which "- "types it in the BACKGROUND (no foreground steal) and submits. If `adom-google` is "- "NOT installed, TELL THE USER that installing adom-google would let you finish "- "logins like this automatically next time, then let them type the code. NEVER ask "- "them for the code if you can read it yourself.")- if wall == "protocol_handoff":- return (common +- "WALL: Chrome's native 'Open Autodesk Identity Manager?' dialog. Try to clear it "- "YOURSELF in the background: desktop_find_control on that dialog then "- "desktop_ui_click {name:'Open'} (UIA, no focus steal). Only if UIA finds no node, "- "fall back to a foregrounded desktop_click. Then poll fusion_readiness.")- return (common +- "WALL: the sign-in wants credentials. NEVER type their password or 2FA. If the browser "- "autofills, you may submit it. Otherwise let the user finish in the window I opened.")---def _unwrap(r):- """AD verb responses come back either flat or nested under `data` (the CLI puts the real- payload in output.data). Always look through both - forgetting this silently broke the- server-side Sign In click, because coordMap/shotId live one level down."""- if not isinstance(r, dict):- return {}- inner = r.get("data")- if isinstance(inner, dict) and inner:- return inner- return r---def _fusion_click_signin() -> dict:- """Click Fusion's 'Welcome to Fusion' -> Sign In button SERVER-SIDE, ALWAYS, on the user's- behalf (John, 2026-07-22: "you should ALWAYS just click sign in on behalf of the adom- users"). The button is a webview with no UIA node, so this is an image-space click.-- Runs on-box, so it costs no remote round-trip against Fusion's ~2-min OAuth expiry.- Returns {clicked: bool, why: str} - never raises.- """- import time as _t- win = _fusion_window()- hwnd = win.get("hwnd") or _unwrap(_handle_fusion_readiness({}, {})).get("hwnd")- if not hwnd:- return {"clicked": False, "why": "could not find the Fusion window"}-- # A MINIMIZED Fusion captures as a ~237x39 title-bar sliver, so the click lands on nothing.- # Restore it first (seen live: rect was at -32000,-32000, the Windows minimized position).- rect = win.get("rect") or {}- if (rect.get("left") is not None and rect["left"] <= -30000) or \- (0 < (rect.get("width") or 0) < 600) or (0 < (rect.get("height") or 0) < 400):- _ad_call("desktop_set_window_state", {"hwnd": int(hwnd), "state": "restore"}, timeout=30)- _t.sleep(1.5)-- shot = _unwrap(_ad_call("desktop_screenshot_window", {"hwnd": int(hwnd)}, timeout=45))- cm = shot.get("coordMap") or {}- sid = cm.get("shotId") or shot.get("shotId")- if not sid:- return {"clicked": False, "why": "screenshot returned no shotId"}- img = cm.get("image") or {}- w = shot.get("width") or img.get("w") or img.get("width") or cm.get("imageWidth") or 1400- h = shot.get("height") or img.get("h") or img.get("height") or cm.get("imageHeight") or 860- # Background UIA first (no focus steal). Fusion's Sign In is a WEBVIEW button with no UIA- # node, so this almost always falls through to the image-space click - that is the documented- # exception to the never-foreground rule, not a licence to foreground elsewhere.- r = _click_background_first(hwnd, name="Sign In", shot_id=sid,- x=int(w * 0.5), y=int(h * 0.57),- reason="signing Fusion in for you (clicking Sign In)")- return {"clicked": r.get("clicked"),- "why": "%s (image %dx%d)" % (r.get("why"), w, h),- "via": r.get("via")}---def _capture_fresh_authorize(wait_sec: int = 30, max_age: int = 120):- """Poll the browser History for a FRESH (< max_age s) Fusion authorize URL for up to- wait_sec, ON-BOX. Collapsing capture into one server-side wait (instead of the caller- re-polling over the flaky relay) is what lets the whole chain finish inside Fusion's- ~2-min request window. Returns the auth dict or None."""- import time as _t- deadline = _t.time() + max(1, wait_sec)- while True:- urls = _chrome_authorize_urls()- if urls and urls[0]["ageSec"] <= max_age:- return urls[0]- if _t.time() >= deadline:- return urls[0] if urls else None- _t.sleep(2)---_SSO_BUTTONS = {"google": "Continue with Google", "apple": "Continue with Apple",- "microsoft": "Continue with Microsoft", "facebook": "Continue with Facebook"}---def _drive_sso_signin(profile: str, tab_id, email: str, provider: str = "google") -> dict:- """Finish the Autodesk sign-in over CDP using SSO - NO password, NO 2FA, NO foreground.-- PROVEN LIVE (John, 2026-07-22) after password/2FA attempts kept stalling. If the browser- PROFILE is already signed into the identity provider (a work Google account is the common- case), "Continue with Google" -> pick the account -> "Open Product" completes the whole login- silently and hands the token back to Fusion via the autodesk:// callback.-- TWO THINGS MATTER:- 1. It must be a FRESH flow. Resuming a flowId that already advanced past the provider- choice lands you on the PASSWORD screen with no way back - that dead end cost us an hour.- 2. Click the provider BEFORE typing any email; entering an email commits to the password path.- """- steps = []-- def ev(expr):- r = _unwrap(_ad_call("nbrowser_eval", {"profile": profile, "tabId": tab_id,- "expression": expr}, timeout=45))- return r.get("result") or r.get("value")-- def click(text=None, selector=None):- a = {"profile": profile, "tabId": tab_id}- if text: a["text"] = text- if selector: a["selector"] = selector- r = _unwrap(_ad_call("nbrowser_click", a, timeout=45))- return bool(r.get("ok") or r.get("success") or r.get("changed"))-- btn = _SSO_BUTTONS.get((provider or "google").lower(), _SSO_BUTTONS["google"])- if not click(text=btn):- return {"done": False, "steps": steps,- "why": "%r not on the page - this is probably NOT a fresh flow (a resumed flowId "- "goes straight to the password screen)." % btn}- steps.append("clicked %r" % btn)- _t2.sleep(7)-- # provider account chooser - pick by the exact email- if email and click(text=email):- steps.append("picked the %s account" % email)- _t2.sleep(8)-- state = str(ev("JSON.stringify({u:location.href.slice(0,120),b:document.body.innerText.slice(0,200)})") or "")- if "You're signed in" in state or "signed in" in state.lower():- steps.append("provider returned 'You're signed in'")- # hand the token back to Fusion (autodesk:// protocol callback)- if click(text="Open Product"):- steps.append("clicked 'Open Product' to hand the token to Fusion")- return {"done": True, "steps": steps, "state": state[:200]}- return {"done": False, "steps": steps, "state": state[:200],- "why": "did not reach the signed-in page; a provider password/2FA may be required"}---def _orchestrate_signin_2fa(args: dict) -> dict:- """Submit Autodesk's emailed 6-digit verification code, IN THE BACKGROUND.-- Exists so the AI can finish a login end-to-end for the user (read the code from Gmail with- adom-google, then call this) instead of parking them on a 2FA screen. Uses UIA SetValue +- Invoke, so it never steals focus or moves the cursor.- """- code = str(args.get("code") or "").strip()- digits = "".join(ch for ch in code if ch.isdigit())- if len(digits) < 4:- return {"success": False, "errorCode": "bad_code",- "error": "Pass the emailed code, e.g. fusion_signin_2fa {\"code\":\"123456\"}.",- "_hint": ("Read it from the user's Gmail if you can: "- "adom-google gmail search 'from:autodesk verification code' --limit 1 "- "-> take the 6 digits -> call this verb again. If adom-google is not "- "installed, tell the user it would let you automate this."),- "statusVerb": "fusion_signin_2fa"}-- wall = _detect_signin_wall()- hwnd = args.get("hwnd") or wall.get("hwnd")- if not hwnd:- return {"success": False, "errorCode": "no_2fa_window",- "error": "No Autodesk 2-step-verification window found.",- "_hint": ("Nothing is waiting on a code right now. Check fusion_readiness - if "- "needsSignin is still true, run fusion_signin again to restart the flow."),- "statusVerb": "fusion_readiness"}-- # Find the code field WITHOUT touching the foreground. Autodesk renders either one input or a- # segmented set, so try the obvious accessible names, then fall back to the first edit control.- found = _unwrap(_ad_call("desktop_find_control",- {"hwnd": int(hwnd), "role": "edit"}, timeout=40))- ctrls = found.get("controls") or found.get("matches") or []- steps = []- filled = False- if len(ctrls) >= len(digits) and len(ctrls) >= 4:- # segmented: one box per digit- for i, ch in enumerate(digits[:len(ctrls)]):- _ad_call("desktop_ui_set", {"hwnd": int(hwnd),- "automationId": ctrls[i].get("automationId"),- "name": ctrls[i].get("name"), "value": ch}, timeout=20)- filled = True- steps.append("typed %d digits into %d segmented boxes (background UIA)" % (len(digits), len(ctrls)))- elif ctrls:- c = ctrls[0]- r = _unwrap(_ad_call("desktop_ui_set", {"hwnd": int(hwnd),- "automationId": c.get("automationId"),- "name": c.get("name"), "value": digits}, timeout=20))- filled = bool(r.get("ok") or r.get("set") or r is not None)- steps.append("typed the code into %r (background UIA)" % (c.get("name") or c.get("automationId")))-- if not filled:- return {"success": False, "errorCode": "code_field_not_found",- "error": "Could not find the code field via UIA.",- "controlsSeen": ctrls[:8],- "_hint": ("The code input was not in the UIA tree (shadow DOM). Run "- "desktop_find_control {hwnd:%s} to see what IS exposed. Last resort is a "- "FOREGROUNDED desktop_click on the field then desktop_type - avoid that "- "if you can, foregrounding is disruptive to the user." % hwnd),- "statusVerb": "fusion_signin_2fa"}-- sub = _click_background_first(hwnd, name="Next")- if not sub.get("clicked"):- sub = _click_background_first(hwnd, name="Verify")- steps.append("submitted via %s" % (sub.get("via") or "no submit control found"))-- return {"success": True, "submitted": True, "steps": steps,- "narrate": "I read the code in and submitted it for you, without touching your screen.",- "_hint": ("Code submitted in the BACKGROUND. Now poll fusion_readiness until ready:true. "- "If a Chrome 'Open Autodesk Identity Manager?' dialog appears, clear it with "- "desktop_ui_click {name:'Open'} (background) - fusion_signin reports that wall "- "too. If it says the code was wrong, the email may have a NEWER code."),- "statusVerb": "fusion_readiness"}---def _orchestrate_signin(args: dict) -> dict:- """Sign Fusion in through the CORRECT browser profile - the fix for Fusion firing its- OAuth at the OS-default browser (often the wrong Autodesk account). See the- fusion-multiprofile-signin skill.-- Staged: pick the target profile (remembered / arg / probed / ask) -> ensure Fusion has- emitted its OAuth URL (click Sign In if not) -> read that URL from the wrong browser's- History -> re-open it in the TARGET profile in the BACKGROUND -> report where it waits.-- auto=True runs the whole chain in ONE server-side call: click Fusion's Sign In, poll on-box- for the fresh authorize URL, then re-open it in the target profile - so the capture->reopen- round-trips happen on the box (fast) and fit inside Fusion's ~2-min request expiry even when- the remote relay is slow. This is the reliable path; the staged/manual path is the fallback.- """- steps = []- # ALWAYS click Sign In for the user unless they explicitly opt out (auto:false). Adom users- # should never be told "now go click Sign In yourself" - we do it for them and SAY so.- auto = args.get("auto")- auto = True if auto is None else bool(auto)- wait_sec = int(args.get("waitSec") or 32)- target = args.get("profile") # explicit override, e.g. "chrome:[email protected]"- if target:- _remember_signin_profile(target)- if not target:- target = _remembered_signin_profile()-- rd = _handle_fusion_readiness({}, {})- if not rd.get("running"):- return {"success": True, "stage": "not_running", "done": False, "steps": steps,- "_hint": "Fusion is not running. fusion_start, then fusion_signin.",- "statusVerb": "fusion_signin"}- if not rd.get("needsSignin"):- return {"success": True, "stage": "already", "done": True, "steps": steps,- "narrate": "Fusion is already signed in.",- "_hint": "Already signed in (ready:%s). Nothing to do." % rd.get("ready"),- "statusVerb": "fusion_signin"}-- # profiles known to ABE (for target selection + naming)- profs = _ad_call("nbrowser_profiles", {}, timeout=40)- pdata = profs.get("data", profs) if isinstance(profs, dict) else {}- choices = [p for p in (pdata.get("profiles") or []) if isinstance(p, dict) and p.get("profile")]-- # read Fusion's authorize URL from browser history (it fires the OS-default browser)- urls = _chrome_authorize_urls()- fresh = urls[0] if (urls and urls[0]["ageSec"] <= 150) else None- if auto and not fresh:- # ONE server-side pass: click Fusion's Sign In FOR THE USER, then poll on-box for the URL.- ck = _fusion_click_signin()- clicked = ck.get("clicked")- steps.append("clicked Fusion 'Sign In' for the user" if clicked- else "could not click Fusion 'Sign In' (%s)" % ck.get("why"))- got = _capture_fresh_authorize(wait_sec=wait_sec, max_age=140)- if got and got["ageSec"] <= 150:- urls = [got]- elif got:- urls = [got] # stale; fall through to the stale branch which tells us to reset- else:- urls = []- if not urls:- return {"success": True, "stage": "click_signin", "done": False, "steps": steps,- "narrate": ("I clicked Fusion's Sign In for you, but Fusion has not written its "- "sign-in URL to a browser yet. Give it a few seconds."),- "_hint": ("THIS VERB CLICKS FUSION'S 'Sign In' FOR THE USER - never tell them to go "- "click it themselves, and do not click it yourself. It just did (see "- "steps[]), but no Fusion OAuth URL has landed in any browser's history "- "yet. Simply CALL fusion_signin AGAIN in a few seconds; it will click if "- "needed, wait on-box for the URL, and re-open it in the right profile. "- "If steps[] says the click FAILED, that is a bridge bug worth reporting - "- "the usual causes are a minimized Fusion window (this verb restores it) "- "or the sign-in wall not being up yet (check fusion_readiness "- "needsSignin:true)."),- "statusVerb": "fusion_signin"}- auth = urls[0]- steps.append("captured Fusion OAuth URL from %s profile '%s' (%ss old)"- % (auth["browser"], auth["sourceProfileDir"], auth["ageSec"]))- # Fusion's sign-in request EXPIRES in ~2 min. A URL older than that completes the LOGIN but- # Fusion no longer waits on its request_id -> "Sign-in request expired", no handoff. Never- # silently reuse it; force a fresh request. (Learned live 2026-07-21.)- if auth["ageSec"] > 150:- return {"success": True, "stage": "stale", "done": False, "steps": steps,- "signinAuthUrl": auth["url"], "signinAgeSec": auth["ageSec"],- "_hint": ("The newest Fusion sign-in URL is %ss old - Fusion expires the request in "- "~2 min, so completing it yields 'Sign-in request expired' and NO handoff. "- "Get a FRESH request: fusion_stop then fusion_start (resets Fusion to a "- "clean 'Welcome to Fusion'), click Fusion's Sign In, and call fusion_signin "- "again PROMPTLY. Completion is near-instant when the target profile is "- "already signed into Autodesk (auto-consents). Skill: "- "fusion-multiprofile-signin." % auth["ageSec"]),- "statusVerb": "fusion_signin"}-- # choose the TARGET profile if not already fixed- reason = ""- if not target:- # probe each live profile for a real Autodesk session; prefer signed-in, then work over consumer- for c in choices:- if not c.get("live"):- continue- ls = _ad_call("nbrowser_login_state",- {"profile": c["profile"], "url": "https://accounts.autodesk.com/"}, timeout=40)- lsd = ls.get("data", ls) if isinstance(ls, dict) else {}- c["_ad"] = "signed-in" if lsd.get("loggedIn") else ("signed-out" if lsd.get("ok") is not None else "unprobed")- c["_conf"] = lsd.get("confidence"); c["_cookies"] = lsd.get("cookieCount")- signed = [c for c in choices if c.get("_ad") == "signed-in"]- if signed:- signed.sort(key=lambda c: ({"high": 3, "medium": 2, "low": 1}.get(c.get("_conf"), 0), c.get("_cookies") or 0), reverse=True)- target = signed[0]["profile"]; reason = "it holds a live Autodesk session"- else:- _CONSUMER = ("gmail.com", "outlook.com", "hotmail.com", "yahoo.com", "icloud.com", "live.com")- work = [c for c in choices if c.get("live") and c.get("email") and not any((c["email"] or "").lower().endswith("@" + d) for d in _CONSUMER)]- if work:- target = work[0]["profile"]; reason = "it is a work/corporate profile (no live Autodesk session detected - CONFIRM)"- if target:- _remember_signin_profile(target)- reason = reason or "you told me to use it"-- if not target:- return {"success": True, "stage": "ask_profile", "done": False, "steps": steps,- "signinAuthUrl": auth["url"], "profileChoices": choices,- "_hint": ("Captured Fusion's OAuth URL but cannot tell which profile is your Autodesk "- "account. ASK the user which profile their Autodesk login belongs to, then "- "call fusion_signin {profile:'chrome:<their-account>'} - I will remember it. "- "profileChoices lists every profile."),- "statusVerb": "fusion_signin"}-- tdesc = next((_describe_profile(c) for c in choices if c.get("profile") == target), target)- import time as _tt- sess = "fusion-signin-%d" % int(_tt.time()) # unique per attempt (a reused id can be refused)- # ── Is Fusion ALREADY using the right profile? (John, 2026-07-22: "they both opened in my- # work profile, so you're being lazy") ──────────────────────────────────────────────────- # Chrome profile DIRECTORIES are named Default / Profile 1 / Profile 2, and the user's work- # account is very often just "Default". We capture the authorize URL from a profile DIR, but- # the target is an EMAIL (chrome:[email protected]). Without mapping email -> dir we cannot tell- # they are the SAME profile, so we "fixed" a non-problem by re-opening the identical URL in- # the identical profile - a redundant second sign-in window on the user's screen.- target_dir = None- for c in choices:- if c.get("profile") == target:- target_dir = c.get("profileDir") or c.get("dir")- break- if target_dir and auth.get("sourceProfileDir") and \- str(target_dir).strip().lower() == str(auth["sourceProfileDir"]).strip().lower():- steps.append("Fusion already opened the sign-in in %s (profile dir %r) - no re-open needed"- % (target, target_dir))- wall0 = _detect_signin_wall()- notified0 = bool(_notify_signin_wall(wall0["wall"]).get("delivered")) if wall0.get("wall") else False- return {"success": True, "stage": "already_right_profile", "done": False, "steps": steps,- "signinProfile": target, "signinProfileDir": target_dir,- "signinWhere": tdesc if False else target,- "signinWall": wall0.get("wall"), "signinWallHwnd": wall0.get("hwnd"),- "notifyDelivered": notified0,- "signinWallHint": _wall_hint(wall0["wall"], notified0) if wall0.get("wall") else None,- "signinAuthUrl": auth["url"],- "narrate": ("Fusion opened its sign-in in the RIGHT profile already (%s), so I did "- "not open a second window." % target),- "_hint": ("NO re-open was needed - Fusion's OS-default browser IS the user's Autodesk "- "profile here (dir %r). Do NOT open another window; that just litters their "- "screen with a duplicate sign-in. The existing window is the one to finish. "- "%s" % (target_dir,- _wall_hint(wall0["wall"], notified0) if wall0.get("wall")- else "Poll fusion_readiness until ready:true.")),- "statusVerb": "fusion_readiness"}-- # snapshot the Autodesk windows that already exist, so a STALE expired tab from an earlier- # attempt is never mistaken for this attempt's wall- pre_hwnds = _autodesk_window_hwnds()- _open_args = {"sessionId": sess, "url": auth["url"], "background": True,- "profile": target, "thread": "fusion-signin",- "purpose": "Fusion Autodesk sign-in (correct profile)"}- r = _ad_call("nbrowser_open_window", _open_args, timeout=60)- rd_open = r.get("data", r) if isinstance(r, dict) else {}- opened = bool(rd_open.get("sessionId") or r.get("ok") or r.get("success")- or rd_open.get("opened") or ("opened in the BACKGROUND" in str(r.get("_hint", ""))))- open_via = "extension" if opened else None- if not opened:- # EXTENSION-FREE fallback: nbrowser_open_window needs the Adom extension installed in- # that profile. nbrowser_open_os_window does not - it opens the URL in the real profile- # at the OS level, which is all an OAuth consent needs. This keeps the multi-profile fix- # working on machines with no extension (same gap as APS issue #12).- r2 = _ad_call("nbrowser_open_os_window", _open_args, timeout=60)- if isinstance(r2, dict) and not r2.get("_adCallError"):- # ok:false just means the hwnd was not resolved in ~8s; the launch usually still fired- opened = bool(r2.get("ok") or r2.get("hwnd") or "Launch fired" in str(r2.get("_hint", "")))- if opened:- open_via = "extension_free"- r = r2- steps.append((("re-opened it in %s%s" % (tdesc, " (extension-free)" if open_via == "extension_free" else ""))- if opened else ("could not open %s (raw: %s)" % (tdesc, str(r)[:160]))))- if opened:- # hand it to the janitor so it is never left as litter on the user's desktop- _track_signin_window(sess, target, kind="fusion")-- # ── FINISH IT: drive the SSO path ourselves (no password, no 2FA, no foreground) ──────────- # John, 2026-07-22: the goal is a SIGNED-IN Fusion, not a handed-off checklist. When we own- # the window (nbrowser_open_window => agent-opened => CDP-drivable) and the profile is already- # signed into the identity provider, this completes the whole login silently.- sso = None- if opened and open_via == "extension" and args.get("driveSso", True):- tabs = _unwrap(_ad_call("nbrowser_list_tabs", {"profile": target}, timeout=45)).get("tabs") or []- my_tab = None- for t in tabs:- if "autodesk" in str(t.get("url", "")).lower() and t.get("owner") == "agent-opened":- my_tab = t.get("tabId") or t.get("id")- if my_tab:- email = None- for c in choices:- if c.get("profile") == target:- email = c.get("email")- sso = _drive_sso_signin(target, my_tab, email, args.get("ssoProvider", "google"))- steps.extend(sso.get("steps") or [])- if sso.get("done"):- for _ in range(6):- _t2.sleep(5)- if _unwrap(_handle_fusion_readiness({}, {})).get("ready"):- steps.append("Fusion reports ready:true - SIGNED IN")- # ── ONE LOGIN, BOTH SYSTEMS (John, 2026-07-22) ───────────────────────- # "why should the user have to login twice to autodesk to sign in to- # fusion and setup aps? why aren't those just driven from 1 login?"- # Right now the browser profile has a WARM Autodesk SSO session (we just- # used it). APS consent rides that session silently, so do it HERE while- # it is warm instead of making the user authenticate a second time.- aps_state = _aps_quick_state()- if aps_state.get("configured") and not aps_state.get("signedIn"):- try:- import aps as _apsmod- _apsmod.start_signin({"profile": target})- steps.append("APS not signed in - started its consent on the SAME "- "warm SSO session (no second login for the user)")- for _ in range(5):- _t2.sleep(4)- if _aps_quick_state().get("signedIn"):- steps.append("APS signed in too - one login covered both")- break- except Exception as e:- steps.append("APS auto-setup skipped (%s)" % str(e)[:80])- aps_state = _aps_quick_state()- return {"success": True, "stage": "signed_in", "done": True, "steps": steps,- "signinProfile": target, "signinWhere": tdesc,- "signinOpenedVia": open_via,- "aps": aps_state,- "narrate": ("Fusion is signed in as %s. No password or 2FA was needed - "- "I used the browser profile's existing SSO session.%s"- % (email or target,- " APS cloud search is set up too, from the same login."- if aps_state.get("signedIn") else "")),- "_hint": ("DONE - Fusion is signed in (ready:true). Nothing else to do; "- "do not tell the user to sign in. %s Drive Fusion normally now."- % ("APS is signed in as well, so fusion_aps_search is live."- if aps_state.get("signedIn") else- "APS is NOT signed in - run fusion_aps_signin NOW while the "- "browser SSO session is still warm so it consents silently.")),- "statusVerb": "fusion_readiness"}-- # Did the reopened sign-in immediately hit a HUMAN WALL (2FA, credentials, protocol dialog)?- # If so, toast the user AND tell the AI how to clear it for them.- wall = _detect_signin_wall(ignore_hwnds=pre_hwnds) if opened else {}- wall_kind = wall.get("wall")- notified = False- if wall_kind:- notified = bool(_notify_signin_wall(wall_kind).get("delivered"))- steps.append("hit %s wall; toasted the user (delivered=%s)" % (wall_kind, notified))-- return {"success": True, "stage": "opened" if opened else "open_failed",- "done": False, "steps": steps, "signinWhere": tdesc, "signinProfile": target,- "signinOpenedVia": open_via,- "signinWall": wall_kind, "signinWallHwnd": wall.get("hwnd"),- "notifyDelivered": notified,- "signinWallHint": _wall_hint(wall_kind, notified) if wall_kind else None,- "signinProfileReason": reason, "signinSession": sess if opened else None,- "signinWrongBrowser": "%s / %s" % (auth["browser"], auth["sourceProfileDir"]),- "signinAuthUrl": auth["url"],- "narrate": (("Fusion opened its sign-in in the WRONG browser (%s), so I moved the real "- "Autodesk sign-in URL into %s - I picked that because %s. Finish it there, "- "or I can drive it, and Fusion will pick up the login automatically."- % (auth["sourceProfileDir"], tdesc, reason)) if opened else- "I captured Fusion's sign-in URL but could not open the target profile."),- "_hint": (("The REAL Fusion OAuth URL is now open in %s (session 'fusion-signin'). It "- "completes back to the running Fusion via the idmgr/callback + autodesk:// "- "protocol handoff (request_id match), so the browser profile no longer has to "- "be the OS default - THIS is the fix for Fusion signing into the wrong "- "account. TELL the user the exact profile (never 'your browser'), say WHY it "- "was chosen (signinProfileReason), and offer: they finish it, you foreground "- "it, or (with their OK) you drive 'Continue with Google/Apple/Microsoft'. "- "NEVER type their password/2FA. Then poll fusion_readiness until ready:true. "- "The wrong-browser tab (%s) can be closed. If the wrong ACCOUNT completes "- "anyway, sign Fusion out and re-run. Skill: fusion-multiprofile-signin."- % (tdesc, auth["sourceProfileDir"])) if opened else- "Open failed: %s. Retry, or open auth url yourself with nbrowser_open_window "- "{profile:'%s', url:<signinAuthUrl>}." % (r.get("_adCallError"), target)),- "statusVerb": "fusion_signin"}---def _orchestrate_demo(args: dict) -> dict:- """FIRST-TIME-USER DEMO: take a brand-new user from nothing to "wow" in one verb.-- Written because HD's installer ran a "demo" that just opened Fusion and SAT on the- sign-in page doing nothing (John, 2026-07-20). A demo must actually finish the sign-in,- set up APS, and SHOW the user real work: an electronics PROJECT -> SCHEMATIC -> 2D BOARD- -> 3D BOARD, plus a live APS cloud search.-- STAGED + RESUMABLE. Each call advances as far as it safely can and returns:- stage - what it just did / is waiting on- done - whether the demo finished- narrate - a first-time-user-friendly line the CALLER should say + toast- screenshots- images to show the user for this stage- _hint - exactly what the AI must do next (including nbrowser_* for the browser leg)-- The bridge owns Fusion; it does NOT own the browser. So when the sign-in needs a browser- we hand the caller precise `nbrowser_*` (ABE) instructions - the user's NATIVE browser is- already signed into Autodesk, whereas pup is anonymous and would force a fresh login.- """- stage = (args.get("stage") or "auto").strip()- demo_query = args.get("query") or "" # optional: which design to demo- steps: list = []- shots: list = []-- def _shot(label):- try:- r = _handle_screenshot_fusion({}, {})- if r.get("success") and r.get("localSafePath"):- shots.append({"label": label, "path": r.get("localSafePath")})- except Exception:- pass-- def _out(stage_name, narrate, hint, done=False, **extra):- return {"success": True, "demo": True, "stage": stage_name, "done": done,- "steps": steps, "narrate": narrate, "screenshots": shots,- "statusVerb": "fusion_demo", "_hint": hint, **extra}-- # ── 1. Fusion present + running ─────────────────────────────────────────────- rd = _handle_fusion_readiness({}, {})- if not rd.get("installed"):- return _out("not_installed",- "Fusion 360 isn't installed yet - want me to install it? It's a free 30-day trial with "- "everything switched on, so the whole tour works: real boards, schematics, 3D, Gerber and "- "BOM exports, cloud search. After the trial, Fusion for Personal Use stays free for "- "non-commercial work (hobby PCBs up to 2 layers), and even an expired install can still "- "OPEN and VIEW your designs.",- "OFFER the install - do not just report it is missing. Say what they GET: the free 30-day "- "trial is FULL-FEATURED (every step of this demo works: electronics, schematic, 2D/3D "- "board, Gerbers/BOM/CPL, APS cloud search). Be honest about after: Fusion for Personal Use "- "is free for non-commercial use but LIMITS electronics (about 2 layers / 2 schematic "- "sheets / small board area) and some exports; an expired or read-only install can still "- "OPEN + VIEW + browse designs (it only blocks save/export/modify), so their work is never "- "locked away. Then call fusion_install_fusion (no shell approval needed; streams the "- "installer, 10-30 min), poll fusion_readiness until installed:true, and call fusion_demo "- "again to continue the tour. Full flow: the fusion-onboarding skill.")- if not rd.get("running"):- steps.append("launched Fusion")- _handle_launch({}, {})- rd = _handle_fusion_readiness({}, {})-- # ── 2. SIGN-IN: finish it, do not sit on it ─────────────────────────────────- if rd.get("needsSignin"):- # Delegate to the multi-profile sign-in fix (reads Fusion's OAuth URL out of the WRONG- # default browser and re-opens it in the RIGHT profile). Returns its own rich stage.- si = _orchestrate_signin({"profile": args.get("signinProfile")})- si["demo"] = True- if si.get("stage") in ("opened", "click_signin", "ask_profile", "open_failed"):- si.setdefault("done", False)- si["_hint"] = (si.get("_hint", "") + " (This is the fusion_demo sign-in stage - after "- "the user is signed in and fusion_readiness is ready:true, call fusion_demo "- "again to continue the tour: APS -> project -> schematic -> 2D -> 3D.)")- return si- # DO IT, don't just describe it: open the Autodesk sign-in in the user's OWN browser,- # in the BACKGROUND so we never yank them out of what they're doing. Then tell the AI- # exactly WHERE it is waiting and offer the three ways forward.- nb = _ad_call("nbrowser_readiness", {}, timeout=40)- nb_data = nb.get("data", nb) if isinstance(nb, dict) else {}- nb_state = nb_data.get("state")- opened, where, profile_used = False, "", ""- pick_reason, ambiguous = "", False- choices = []- if nb_state == "ready":- profs = _ad_call("nbrowser_profiles", {}, timeout=40)- pdata = profs.get("data", profs) if isinstance(profs, dict) else {}- # profiles are DICTS: {profile,label,email,browser,displayName,profileDir,active,live,...}- for p in (pdata.get("profiles") or []):- if not isinstance(p, dict):- continue- if p.get("blocked") or p.get("unresolved") or not p.get("profile"):- continue- choices.append({- "profile": p.get("profile"), "browser": (p.get("browser") or "").lower(),- "email": p.get("email") or "", "displayName": p.get("displayName") or "",- "profileDir": p.get("profileDir") or "",- "active": bool(p.get("active")), "live": bool(p.get("live")),- "asleep": bool(p.get("asleep")),- "extensionInstalled": p.get("extensionInstalled", True),- "describe": _describe_profile(p),- })- # PROBE each profile for a REAL Autodesk session - never guess by "active".- # (John, 2026-07-20: picking the active profile grabbed his PERSONAL Chrome with- # zero analysis. A power user's Autodesk login can live in any profile, so ASK THE- # BROWSER, don't assume.) nbrowser_login_state reports auth cookies per profile.- for c in choices:- if not c["live"]:- c["autodesk"] = "unprobed(asleep)"- continue- ls = _ad_call("nbrowser_login_state",- {"profile": c["profile"], "url": "https://accounts.autodesk.com/"},- timeout=45)- lsd = ls.get("data", ls) if isinstance(ls, dict) else {}- if lsd.get("ok") is None and lsd.get("loggedIn") is None:- c["autodesk"] = "unprobed"- else:- c["autodesk"] = "signed-in" if lsd.get("loggedIn") else "signed-out"- c["autodeskConfidence"] = lsd.get("confidence")- c["autodeskCookies"] = lsd.get("cookieCount")- _CONF = {"high": 3, "medium": 2, "low": 1}- signed = [c for c in choices if c.get("autodesk") == "signed-in"]- signed.sort(key=lambda c: (_CONF.get(c.get("autodeskConfidence"), 0),- c.get("autodeskCookies") or 0), reverse=True)- pick = signed[0] if signed else None- if pick:- pick_reason = ("it is the profile actually signed into Autodesk (%s auth cookies, %s "- "confidence)" % (pick.get("autodeskCookies"), pick.get("autodeskConfidence")))- else:- # NOTHING is signed in - do not silently guess. Prefer a work/corporate identity- # over a consumer mailbox (Autodesk seats are usually work accounts), but SAY SO- # and offer the alternatives.- _CONSUMER = ("gmail.com", "outlook.com", "hotmail.com", "yahoo.com", "icloud.com", "live.com")- def _work_first(c):- em = (c.get("email") or "").lower()- return (0 if (em and not any(em.endswith("@" + d) for d in _CONSUMER)) else 1,- 0 if c["live"] else 1)- ranked = sorted([c for c in choices if c["live"]] or choices, key=_work_first)- pick = ranked[0] if ranked else None- pick_reason = ("no profile has a live Autodesk session, so I picked the most likely "- "one (work/corporate identity first) - CONFIRM WITH THE USER")- ambiguous = not signed or len(signed) > 1- if pick:- profile_used = pick["profile"]- where = pick["describe"]- oa = {"sessionId": "fusion-signin", "url": "https://accounts.autodesk.com/",- "background": True, "thread": "fusion-demo", "purpose": "Autodesk sign-in for Fusion"}- if profile_used:- oa["profile"] = profile_used- r = _ad_call("nbrowser_open_window", oa, timeout=60)- opened = bool((r.get("data", r) if isinstance(r, dict) else {}).get("sessionId") or r.get("ok") or r.get("success"))- if not where:- where = "your browser"- if opened:- steps.append("opened Autodesk sign-in in %s (background)" % (where or "browser"))- else:- steps.append("could not auto-open the browser sign-in: %s" % (nb.get("_adCallError") or nb_state or "unknown"))- if opened:- narrate = ("I've opened the Autodesk sign-in in %s - I chose that profile because %s. "- "It's sitting in the background so it won't interrupt what you're doing. "- "Finish it whenever you like, tell me to bring it up front, or I can complete "- "it for you. If that's the wrong account, say which profile to use."- % (where, pick_reason))- else:- narrate = ("Fusion needs your Autodesk sign-in. I'd like to use your own browser - you're "- "probably already signed in there, so it should take one click.")- return _out("signin", narrate,- ("FINISH THE SIGN-IN - never leave a first-time user parked here. " +- ("A background sign-in tab is ALREADY OPEN in %s (session 'fusion-signin'). " % where- if opened else "Auto-open failed - open it yourself: nbrowser_open_window "- "{sessionId:'fusion-signin', url:'https://accounts.autodesk.com/', background:true, "- "profile:'<chrome:their-account>'}. ") +- "The profile was CHOSEN BY EVIDENCE, not guessed: each live profile was probed with "- "nbrowser_login_state against accounts.autodesk.com, and the one holding a real Autodesk "- "session wins (see signinProfileReason + per-profile autodesk/autodeskConfidence/"- "autodeskCookies in signinProfileChoices). RELAY THAT REASON to the user. If "- "signinProfileAmbiguous is true (nothing signed in, or several are), ASK them which "- "account their Autodesk login belongs to instead of assuming. "- "NAME THE EXACT PROFILE - never say just 'your browser' or 'your Chrome'. Power users "- "run several profiles (personal / work / media), so say the browser AND the identity "- "from `signinWhere` (e.g. 'Chrome - John Personal ([email protected])'). "- "`signinProfileChoices` lists every profile with describe/email/displayName/active - if "- "there is more than one, say which you used and OFFER TO SWITCH (re-run with a different "- "profile). If the one you want shows extensionInstalled:false it needs the one-time "- "extension install in THAT profile. "- "TELL THE USER WHERE IT IS WAITING and OFFER ALL THREE: (a) they finish it themselves "- "whenever they want, (b) you foreground that window for them "- "(nbrowser_switch_window / browser window state), or (c) THEY LET YOU DRIVE IT - ask "- "first (AskUserQuestion), then click through 'Continue with Google/Apple/Microsoft' "- "using their warm session. Use the NATIVE browser (ABE), NEVER pup: pup is anonymous so "- "Autodesk demands a full fresh login, while their real profile is usually already signed "- "in. NEVER type their password or 2FA - if a secret is demanded, fusion_notify_owner "- "toasts them to type it. Then click Fusion's own 'Sign In' (the webview exposes no UIA "- "control, so use an image-space desktop_click on the button, or foreground + click) to "- "fire the OAuth handoff; accept the 'Autodesk Identity Manager' overlay ('Always allow' "- "+ 'Open'). Codes expire in ~2 min - if it expires, click Sign In again. Poll "- "fusion_readiness until ready:true, then call fusion_demo again. Playbook: "- "fusion-autodesk-signin."),- needsSignin=True, signinOpened=opened, signinWhere=where,- signinProfile=profile_used, signinSession="fusion-signin" if opened else None,- signinProfileChoices=choices, signinProfileReason=pick_reason,- signinProfileAmbiguous=ambiguous, nbState=nb_state)-- if not rd.get("ready"):- return _out("waiting",- "Fusion is starting up - one moment.",- "Not ready yet (add-in still loading, or a licensing dialog). Poll fusion_readiness; it "- "auto-resolves seat dialogs. When ready:true, call fusion_demo again. Reason: " +- str(rd.get("_hint", ""))[:220])- steps.append("Fusion ready")-- # ── 3. APS: set it up if we can, otherwise SELL it (never skip silently) ─────- aps_ready = False- try:- ap = aps.handle_status({}).get("data", {})- aps_ready = bool(ap.get("configured") and ap.get("signedIn") and ap.get("tokenLive"))- except Exception:- ap = {}- if not aps_ready and stage != "skip_aps":- return _out("aps_setup",- "Next I'll turn on cloud search - it finds any design in your Autodesk cloud in about "- "two seconds instead of a 30-minute folder crawl. It's a one-time sign-in.",- "SET APS UP FOR THEM - do not merely mention it. (1) fusion_aps_status for exact state. "- "(2) If not configured, an admin registers a PKCE app once, then fusion_aps_set_client_id. "- "(3) fusion_aps_signin - drive it in their NATIVE browser (ABE), same reasoning as the "- "Fusion sign-in: they're already logged into Autodesk there. (4) Poll fusion_aps_status "- "until tokenLive:true, then call fusion_demo again - the demo then runs a LIVE sample "- "search so they SEE the speed. If they decline setup, call fusion_demo {stage:'skip_aps'} "- "and TELL them what they're missing (2s server-indexed search across the whole team hub; "- "the old in-app search took 30+ min and crashed Fusion, so it is disabled). "- "Skills: fusion-aps-search, fusion-aps-signin.",- apsConfigured=bool(ap.get("configured")), apsSignedIn=bool(ap.get("signedIn")))-- # ── 4. APS sample search - let them SEE the speed ───────────────────────────- aps_hits = []- if aps_ready:- try:- import time as _t- t0 = _t.time()- sr = aps.handle_search({"query": demo_query or "board", "limit": 5}).get("data", {})- aps_hits = [{"name": r.get("name"), "project": r.get("projectName")}- for r in (sr.get("results") or [])[:5]]- steps.append("APS sample search: %d hits in %.1fs" % (len(aps_hits), _t.time() - t0))- except Exception as e:- steps.append("APS sample search failed: %s" % e)-- # ── 5. Open an ELECTRONICS design, then walk schematic -> 2D -> 3D ──────────- st = _proxy_to_addin("get_app_state", {}, timeout=20) or {}- doc = (st.get("data") or st).get("activeDocument")- is_elec = bool((st.get("data") or st).get("isElectronics"))- if not is_elec:- target = demo_query or (aps_hits[0]["name"] if aps_hits else "")- if not target:- return _out("need_design",- "I need an electronics design to show off. Which board should I open?",- "No electronics design open and nothing to pick. If APS is live, "- "fusion_aps_search {query:'<board>'} then fusion_aps_open {query:'<name>'}; else ask "- "the user for a design name / open one via fusion_open_cloud_file. ALWAYS open the "- "PROJECT (EcadDesignProductType), never a .brd/.sch/3D child. Then call fusion_demo again.")- return _out("open_design",- "Opening %s so you can see a real board end to end." % target,- "OPEN THE PROJECT then re-call fusion_demo: fusion_aps_open {query:'%s'} (APS finds it at "- "any folder depth in seconds). CRITICAL: open the electronics PROJECT file, not the "- "schematic/.brd/3D child - a child opens an isolated, often empty view. Poll "- "fusion_get_app_state until isElectronics:true, then call fusion_demo again." % target,- target=target)-- steps.append("electronics project open: %s" % doc)- _shot("project")-- # schematic -> 2D board -> 3D board, screenshotting each so the AI can SHOW them- try:- _proxy_to_addin("show_schematic", {}, timeout=90); steps.append("showed schematic"); _shot("schematic")- except Exception as e:- steps.append("schematic failed: %s" % e)- try:- _proxy_to_addin("show_2d_board", {}, timeout=90); steps.append("showed 2D board"); _shot("board_2d")- except Exception as e:- steps.append("2D board failed: %s" % e)- try:- _proxy_to_addin("show_3d_board", {}, timeout=180); steps.append("showed 3D board"); _shot("board_3d")- except Exception as e:- steps.append("3D board failed: %s" % e)-- aps_line = ("Cloud search is live - I searched your whole Autodesk hub in about two seconds and "- "found: %s. " % ", ".join(h["name"] for h in aps_hits[:3])) if aps_hits else ""- return _out("done",- "That's the tour: your %s project, its schematic, the 2D board layout, and the real 3D board. "- "%sFrom here just ask - export Gerbers/BOM/CPL for the fab, generate a laser-etched IPC "- "package, load JLCPCB design rules, or open any design in your cloud." % (doc, aps_line),- "DEMO COMPLETE. SHOW the user the screenshots in order (project, schematic, board_2d, "- "board_3d) - they are in `screenshots` with labels. Narrate the `narrate` line. Then offer "- "concrete next steps in THEIR words: 'export the Gerbers', 'make me a SOIC-8 with my part "- "number etched on it' (fusion_generate_package), 'check this against JLCPCB rules' "- "(fusion_load_design_rules), 'find my other boards' (fusion_aps_search). Full catalog: "- "fusion_describe. Demo playbook: the fusion-demo skill.",- done=True, document=doc, apsSampleSearch=aps_hits)---def _orchestrate_board_stackup(args: dict) -> dict:- """Read a board's PHYSICAL fabrication stackup (see the pcb-stackup skill). A PCB is a- stack of copper + dielectric: Cu(L1) / prepreg / Cu(L2) / core / ... / Cu(bottom). This- reads the copper count + copper/dielectric thicknesses from the EAGLE design rules- (layerSetup / mtCopper / mtIsolate in the .brd) and the measured FR4 extent from the 3D- body, and returns the ordered layer list (name, thickness, z) so a caller can build the- stackup table + the real-thickness exploded 3D view."""- import re as _re- import tempfile as _tf- # 1) design rules from the EAGLE .brd (board editor active)- _proxy_to_addin("show_2d_board", {}, timeout=60)- brd = os.path.join(_tf.gettempdir(), "adom_stackup.brd")- r = _proxy_to_addin("export_eagle_source", {"outputPath": brd}, timeout=120)- if not r.get("success"):- return {"success": False, "error": "could not export .brd for stackup: %s" % (r.get("error") or r.get("message")),- "_hint": "Open the PROJECT and switch to the board (fusion_show_2d_board) first."}- try:- txt = open(brd, encoding="latin1", errors="replace").read()- except Exception as e:- return {"success": False, "error": "could not read .brd: %s" % e}- def _p(name):- m = _re.search(r'<param name="%s" value="([^"]*)"' % name, txt)- return m.group(1) if m else None- layer_setup = _p("layerSetup") or ""- mt_copper = [x for x in (_p("mtCopper") or "").split()]- mt_isolate = [x for x in (_p("mtIsolate") or "").split()]- copper_layers = _re.findall(r"\d+", layer_setup) # e.g. ['1','2','15','16']- ncu = len(copper_layers)- # bonds between copper layers, in order: '+' prepreg, '*' core- bonds = [("core" if c == "*" else "prepreg") for c in layer_setup if c in "+*"]- # 2) measure the FR4 Board body (3D)- _proxy_to_addin("show_3d_board", {}, timeout=60)- # Find the LARGEST-XY body named 'board' (the FR4 substrate) - there can be several- # 'board'-named bodies (small ones), so pick the substrate by area, not the first.- mscript = (- "import adsk.core, adsk.fusion\n"- "app=adsk.core.Application.get(); des=adsk.fusion.Design.cast(app.activeProduct)\n"- "best=None; ba=-1.0\n"- "for occ in des.rootComponent.allOccurrences:\n"- " for b in occ.component.bRepBodies:\n"- " if b.name.lower()=='board':\n"- " bb=b.boundingBox; mn=bb.minPoint; mx=bb.maxPoint\n"- " a=(mx.x-mn.x)*(mx.y-mn.y)\n"- " if a>ba: ba=a; best=((mx.x-mn.x)*10,(mx.y-mn.y)*10,(mx.z-mn.z)*10)\n"- "print('FR4 %.4f %.4f %.4f'%best if best else 'FR4 none')")- mm = _proxy_to_addin("run_modeling_script", {"script": mscript}, timeout=60)- fr4 = None- try:- msg = ""- if isinstance(mm, dict):- o = mm.get("output")- msg = (json.loads(o).get("message") if isinstance(o, str) and o.startswith("{") else (mm.get("message") or o)) or ""- m = _re.search(r"FR4\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)", str(msg))- if m:- fr4 = {"x_mm": float(m.group(1)), "y_mm": float(m.group(2)), "dielectric_mm": float(m.group(3))}- except Exception:- pass- def _num(s):- try: return float(str(s).replace("mm", ""))- except Exception: return None- return {- "success": True,- "layerSetup": layer_setup,- "copperLayers": copper_layers,- "copperCount": ncu,- "copperThickness_mm": [_num(t) for t in mt_copper[:ncu]] if mt_copper else None,- "dielectricBonds": bonds, # e.g. ['prepreg','core','prepreg']- "dielectricThickness_raw": mt_isolate, # design-rule list (may hold 2-layer defaults)- "fr4": fr4,- "brdPath": brd,- "_hint": ("Physical stack (see pcb-stackup skill): the FR4 is NOT one slab - it is "- "prepreg/core/prepreg with copper between. Build the table + exploded view per that skill. "- "If mtIsolate looks like 2-layer defaults, fit a symmetric split to fr4.dielectric_mm."),- }---def dispatch_command(command: str, args: dict) -> dict:- """Dispatch a command to the appropriate handler."""- # Direct handlers (don't need the add-in)- handler = COMMAND_HANDLERS.get(command)- if handler is not None:- return handler(fusion_info, args)-- # Check if Fusion is installed and running before any add-in-dependent command.- # We do NOT auto-launch — that causes 60s+ hangs when Fusion isn't running.- # The user should launch Fusion themselves; we just report the status.- # 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"):- 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():- return {- "success": False,- "error": "Fusion 360 is installed but not running.",- "errorCode": "fusion_not_running",- "_hint": "Call fusion_start to start Fusion 360, then wait for the add-in to become ready (fusion_start blocks until ready). Then retry this command.",- }- # Fusion is running — check if add-in is responsive (short timeout).- # But first, if the add-in is busy with a long command, skip the- # responsiveness check and fall through to the busy gate below.- addin_busy = _check_addin_status(timeout=3.0)- if addin_busy and addin_busy.get("busy"):- pass # Fall through to busy gate- elif not wait_for_addin(timeout=5):- return {- "success": False,- "error": "Fusion 360 is running but AdomBridge add-in not responding.",- "errorCode": "fusion_addin_not_responding",- "_hint": "The add-in isn't responding. Fix it YOURSELF - never ask the user: the bridge auto-installs the add-in to ALL Fusion add-in dirs (2025+ Fusion scans %APPDATA%/Autodesk/FusionAddins; the legacy API/AddIns dirs are silently ignored - issue #63), so restart Fusion via fusion_stop + fusion_start and it loads (runOnStartup). Verify with fusion_addin_status.",- }-- # ── Busy gate: reject add-in commands immediately if a long command is running ──- # Bridge-level commands (COMMAND_HANDLERS) already returned above — they use- # Win32 APIs and don't touch the add-in, so they always work during a walk.- # But add-in commands (and orchestrated commands that proxy to the add-in)- # would pile up behind _main_thread_lock for 300s and crash the host.- busy = _get_long_command()- if busy and command not in LONG_RUNNING_COMMANDS:- # This command would block behind the long-running one — reject immediately- progress = _get_busy_progress()- elapsed = round(_time.time() - busy["startedAt"], 1)- progress_pct = None- if progress and progress.get("foldersVisited") and progress.get("queueSize") is not None:- total = progress["foldersVisited"] + progress["queueSize"]- if total > 0:- progress_pct = round(100 * progress["foldersVisited"] / total)- return {- "success": False,- "error": (- f"Fusion main thread busy — {busy['command']} has been running for {elapsed}s. "- f"Your command '{command}' cannot execute until it finishes."- ),- "errorCode": "main_thread_busy",- "busyCommand": busy["command"],- "elapsedSeconds": elapsed,- "progress": progress,- "_hint": (- f"A cloud search ({busy['command']}) is in progress"- + (f" (~{progress_pct}% done)" if progress_pct is not None else "")- + f", running for {elapsed}s. "- "Do NOT retry add-in commands (get_app_state, document_info, etc.) — they will "- "all be rejected until the search finishes. Commands that still work right now: "- "fusion_window_info, fusion_screenshot_fusion, fusion_click_fusion, "- "fusion_send_key, fusion_close_window. Wait for the search to complete, then retry."- ),- }-- # Cross-bridge case: this bridge's _long_command is None but the add-in- # might be busy from another bridge/session. Quick non-blocking check.- if not busy and command not in LONG_RUNNING_COMMANDS:- addin_status = _check_addin_status(timeout=3.0)- if addin_status and addin_status.get("busy"):- elapsed = addin_status.get("elapsedSeconds", 0)- walk = addin_status.get("walkProgress")- busy_cmd = addin_status.get("busyCommand", "unknown")- resp = {- "success": False,- "error": (- f"Fusion main thread busy — {busy_cmd} running for {elapsed}s "- f"(from another bridge/session). Your command '{command}' cannot "- f"execute until it finishes."- ),- "errorCode": "main_thread_busy",- "busyCommand": busy_cmd,- "elapsedSeconds": elapsed,- "_hint": (- f"A long-running command ({busy_cmd}) is in progress from another session. "- "Do NOT retry add-in commands — they will all be rejected until it finishes. "- "Do NOT press Escape — the add-in is working, not stuck on a dialog. "- "Commands that still work: fusion_window_info, fusion_screenshot_fusion, "- "fusion_click_fusion, fusion_send_key, fusion_close_window."- ),- }- if walk:- resp["progress"] = walk- return resp-- # Orchestrated multi-step commands (handled at bridge level)- if command in _OPEN_WITH_SCREENSHOT:- return _handle_open_with_screenshot(fusion_info, args, command)- if command == "open_lbr":- return _orchestrate_open_lbr(args)- if command == "save_lbr":- return _merge_dialog_array(_orchestrate_save_lbr(args))- if command == "attach_3d_package":- return _merge_dialog_array(_orchestrate_attach_3d_package(args))- if command == "make_3d_package":- return _apply_failure_dialogs(_orchestrate_make_3d_package(args))- if command == "build_library_3d":- return _apply_failure_dialogs(_orchestrate_build_library_3d(args))- if command == "capture_library_views":- return _apply_failure_dialogs(_orchestrate_capture_library_views(args))- if command == "cleanup_cloud_files":- return _orchestrate_cleanup_cloud_files(args)- if command == "generate_package":- return _apply_failure_dialogs(_orchestrate_generate_package(args))- if command == "export_optimized_glb":- return _orchestrate_export_optimized_glb(args)- if command == "fetch_optimized_glb":- return _orchestrate_fetch_optimized_glb(args)- if command == "demo":- return _orchestrate_demo(args)- if command == "signin":- return _orchestrate_signin(args)- if command == "signin_2fa":- return _orchestrate_signin_2fa(args)- if command == "board_stackup":- return _orchestrate_board_stackup(args)-- # Add-in proxy commands- if command in ADDIN_COMMANDS:- addin_cmd = ADDIN_COMMAND_MAP.get(command, command)- proxy_timeout = ADDIN_COMMAND_TIMEOUTS.get(command, 30)- # Wrap long-running commands in set/clear so the gate knows they're active.- # Pre-dismiss blocking dialogs: modal dialogs steal Fusion's event loop,- # preventing fireCustomEvent from being processed. Without this, the- # walk appears "busy" but never actually starts — _walk_progress stays- # None indefinitely while the command sits in the event queue.- if command in LONG_RUNNING_COMMANDS:- try:- info = get_fusion_window_info()- if info.get("dialogs"):- for d in info["dialogs"]:- try:- # Use WM_CLOSE via PostMessage — doesn't steal foreground- # (unlike send_key which uses SendInput + SetForegroundWindow).- # WM_CLOSE is also more reliable than Escape for Qt dialogs.- close_window(d.get("hwnd"))- except Exception:- pass- import time as _time- _time.sleep(0.5) # give Fusion a moment to process the dismiss- except Exception:- pass- _set_long_command(command)- try:- return _proxy_to_addin(addin_cmd, args, timeout=proxy_timeout)- finally:- _clear_long_command()- result = _proxy_to_addin(addin_cmd, args, timeout=proxy_timeout)- # After a state-changing op, surface any dialog/owned-popup the AI must analyze- # (the Hub upload-close confirm, a save prompt, recovery, etc.) so it can't fly- # blind. Read-only verbs are excluded to avoid per-call screenshot latency.- if command in MUTATING_COMMANDS:- result = _merge_dialog_array(result)- return result-- # Unknown command — check installation/running status for helpful errors- if not fusion_info.get("installed"):- return {- "success": False,- "error": f"Fusion 360 is not installed on this machine. (command: {command})",- "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():- return {- "success": False,- "error": f"Fusion 360 is installed but not running. (command: {command})",- "errorCode": "fusion_not_running",- "_hint": "Call fusion_start to start Fusion 360 and wait for the add-in to be ready, then retry this command.",- }- 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.",- }---def _build_status() -> dict:- """Build the /status payload — the endpoint AD declares as healthEndpoint.-- MUST return HTTP 2xx whenever the server is up: AD's health check polls this- path and a 404 (the classic manifest-vs-server mismatch) makes AD wait the- full startup grace then report a generic "not reachable". We also self-report- the GUI chip fields {led, summary, tooltip} — AD renders them verbatim (the- bridge owns its color; AD owns only the unreachable→gray state).- """- import platform-- info = fusion_info or {}- installed = bool(info.get("installed"))- running = _is_fusion_running() if installed else False- addin = _probe_addin() if running else None- addin_ok = bool(addin)-- if platform.system() != "Windows":- led, summary = "yellow", "Unsupported OS"- tooltip = ("Bridge running, but this host is not Windows. Fusion 360 verbs "- "need a Windows host with Fusion 360 installed.")- elif not installed:- led, summary = "yellow", "Fusion not installed"- tooltip = ("Bridge running, but Fusion 360 is not installed. The AI can install it "- "for the user (fusion-onboarding), then fusion_start.")- elif not running:- led, summary = "yellow", "Fusion not running"- tooltip = "Fusion 360 is installed but not running. Call fusion_start to launch it."- elif not addin_ok:- led, summary = "yellow", "Add-in not connected"- tooltip = ("Fusion 360 is running but the AdomBridge add-in isn't responding yet "- "(still loading; if it persists the AI restarts Fusion via fusion_stop/start).")- else:- led, summary = "green", "Fusion ready"- tooltip = "Fusion 360 running, AdomBridge add-in connected. All fusion_* verbs available."-- return {- "status": "ok",- "led": led,- "summary": summary,- "tooltip": tooltip,- "bridgeVersion": BRIDGE_VERSION,- "fusion": {**info, "running": running},- "addin": addin,- }---class FusionBridgeHandler(BaseHTTPRequestHandler):- """HTTP request handler for the Fusion 360 bridge server."""-- def do_GET(self):- # /status is the manifest's healthEndpoint; /health is kept as a- # back-compat alias (older callers + the add-in-probe code path). Both- # return the same 2xx payload — new chip fields are purely additive.- if self.path in ("/status", "/health"):- self._respond(200, _build_status())- else:- self._respond(404, {"error": "Not found"})-- def do_POST(self):- if self.path != "/command":- self._respond(404, {"error": "Not found"})- return-- content_length = int(self.headers.get("Content-Length", 0))- body = self.rfile.read(content_length)-- try:- request = json.loads(body)- except json.JSONDecodeError as e:- self._respond(400, {"success": False, "error": f"Invalid JSON: {e}"})- return-- command = request.get("command", "")- args = request.get("args", {})-- print(f"[Fusion Bridge] Command: {command} | Args: {json.dumps(args)}")-- try:- result = dispatch_command(command, args)- self._respond(200, result)- except Exception as e:- print(f"[Fusion Bridge] ERROR: {e}")- traceback.print_exc()- self._respond(500, {- "success": False,- "error": f"Internal error: {e}",- })-- def _respond(self, status: int, data: dict):- self.send_response(status)- self.send_header("Content-Type", "application/json")- self.end_headers()- self.wfile.write(json.dumps(data).encode("utf-8"))-- def log_message(self, format, *args):- print(f"[Fusion Bridge] {args[0]} {args[1]} {args[2]}")---def _prune_bridge_logs(max_mb: int = 8, keep_tail_mb: int = 2) -> dict:- """LOG JANITOR (John, 2026-07-22: "do you have a janitor to clean up your log?").-- AD captures this bridge's stdout/stderr into ~/.adom/bridge-logs/fusion360.log. Nothing- rotated it, so it grew unbounded - fine today (it is small), a slow leak on a long-lived box.- On every bridge start we truncate our OWN logs to the most recent keep_tail_mb once they pass- max_mb, keeping the tail because that is where a crash traceback lives.-- Only touches files this bridge owns (fusion360*). Never raises.- """- import glob- out = {"checked": 0, "pruned": []}- try:- d = os.path.join(os.path.expanduser("~"), ".adom", "bridge-logs")- for path in glob.glob(os.path.join(d, "fusion360*.log")):- out["checked"] += 1- try:- sz = os.path.getsize(path)- if sz <= max_mb * 1024 * 1024:- continue- with open(path, "rb") as f:- f.seek(-keep_tail_mb * 1024 * 1024, os.SEEK_END)- tail = f.read()- with open(path, "wb") as f:- f.write(b"[adom-bridge log janitor] truncated %d MB -> tail %d MB\n"- % (sz // (1024 * 1024), keep_tail_mb))- f.write(tail)- out["pruned"].append({"file": os.path.basename(path), "wasMB": sz // (1024 * 1024)})- except Exception:- continue- if out["pruned"]:- print("[Fusion Bridge] log janitor: %s" % out["pruned"])- except Exception:- pass- return out---def main():- global fusion_info-- # Line-buffer stdout/stderr. AD spawns the bridge console-less and captures- # our stdout+stderr into ~/.adom/bridge-logs/fusion360.log. Python BLOCK-buffers- # stdout when it's not a TTY, so without this the buffer never flushes while- # serve_forever() runs → the log stays 0 bytes (and a spawn-crash traceback- # would be lost). Line buffering flushes every print()/traceback on newline.- try:- sys.stdout.reconfigure(line_buffering=True)- sys.stderr.reconfigure(line_buffering=True)- except Exception:- pass-- _prune_bridge_logs()- _start_signin_janitor()-- port = DEFAULT_PORT- if "--port" in sys.argv:- idx = sys.argv.index("--port")- if idx + 1 < len(sys.argv):- port = int(sys.argv[idx + 1])-- # Early banner BEFORE detection — so the log proves we started even if- # detect_fusion() is slow or wedges. (KiCad bridge prints an equivalent line.)- print(f"[Fusion Bridge] starting - version {BRIDGE_VERSION}, port {port}, "- f"healthEndpoint /status, pid {os.getpid()}", flush=True)-- fusion_info = detect_fusion()-- print(f"[Fusion Bridge] Fusion 360 detection result:")- print(f" Installed: {fusion_info.get('installed', False)}")- if fusion_info.get("installed"):- print(f" Exe path: {fusion_info.get('exe_path')}")- print(f" AddIns: {fusion_info.get('addins_dir')}")- print(f" Add-in: {'installed' if fusion_info.get('addin_installed') else 'not installed'}")- print(f" Running: {fusion_info.get('running')}")-- # ALWAYS sync the add-in on startup - it is idempotent (_sync_directory copies- # only CHANGED files). The old `if not addin_installed` guard meant a STALE- # add-in was never UPDATED: an add-in fix (e.g. get_parameters, caught live- # 2026-07-06) never reached users who already had ANY copy, because the bridge- # only deployed when it was entirely MISSING. Always-sync fixes that. It is safe- # while Fusion is up (locked add-in files simply skip via the per-target OSError- # catch); the update lands on the next bridge respawn with Fusion closed- # (fusion_stop -> bridge_install -> fusion_start).- print(f"[Fusion Bridge] Syncing AdomBridge add-in (idempotent; updates a stale copy)...")- try:- install_addin()- fusion_info = detect_fusion() # Re-detect after sync- print(f" Add-in: {'installed' if fusion_info.get('addin_installed') else 'FAILED'}")- except Exception as e:- print(f" Add-in sync failed: {e}")- else:- print(f" WARNING: Fusion 360 not found. Some commands will fail.")-- addin_health = _probe_addin()- if addin_health:- print(f" Add-in server: running on port {ADDIN_PORT}")- else:- print(f" Add-in server: not running (port {ADDIN_PORT})")-- all_commands = list(COMMAND_HANDLERS.keys()) + sorted(ADDIN_COMMANDS)- print(f"[Fusion Bridge] Available commands: {', '.join(all_commands)}")-- # AD (>=1.9.63) passes ADOM_BIND_HOST (always 127.0.0.1) to every bridge it- # spawns. Honor it and NEVER bind 0.0.0.0/'' by default - a public bind pops a- # Windows Firewall "allow access?" dialog, and AD's guarantee to users is no- # firewall prompts. Default to loopback if the var is absent (e.g. local dev).- bind_host = os.environ.get("ADOM_BIND_HOST", "127.0.0.1")- server = ThreadingHTTPServer((bind_host, port), FusionBridgeHandler)- server.daemon_threads = True- print(f"[Fusion Bridge] Listening on http://{bind_host}:{port}")- print(f"[Fusion Bridge] Health check: http://{bind_host}:{port}/status (alias /health)")- print(f"[Fusion Bridge] Press Ctrl+C to stop.")-- try:- server.serve_forever()- except KeyboardInterrupt:- print("\n[Fusion Bridge] Shutting down.")- server.server_close()---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- # re-raise for a non-zero exit.- try:- main()- except Exception:- print("[Fusion Bridge] FATAL: bridge failed to start", flush=True)- traceback.print_exc()- sys.stderr.flush()- raise+def _guard_expect_document(args: dict):+ """Enforce the caller's `expectDocument` assertion BEFORE a verb runs.++ Every fusion_* verb acts on Fusion's *active* document, and a user (or an+ auto-expanding panel) switching tabs silently retargets the next call. This+ is a documented near-miss: it deleted bodies out of an unrelated design+ (issue #289), and on a READ verb it silently returned a completely different+ BOM (68/268 vs the expected 94/424) with nothing signalling the switch.++ The `expectDocument` guard was DOCUMENTED in the fusion-driving skill but no+ code path actually read the argument, so passing it was a silent no-op — a+ false safety guarantee. This wires it up for real: when the caller passes+ {"expectDocument": "<name>"}, verify the active document IS that before we+ proxy the verb, and fail closed with a stable `wrong_document` code+ otherwise. Honoured on BOTH mutating and read verbs (issue #289 asked for+ mutating; the read-verb BOM repro asked for reads too).++ Returns an error dict to short-circuit on mismatch, or None to proceed.+ NOTE: this is a NAME check — if the document was renamed mid-session, pass+ the current name. A stable lineage-id assertion is tracked as a follow-up.+ """+ expected = (args or {}).get("expectDocument")+ if not expected:+ return None+ state = _proxy_to_addin("get_app_state", {}, timeout=8)+ actual = None+ if isinstance(state, dict):+ actual = state.get("activeDocument")+ if actual is None and isinstance(state.get("data"), dict):+ actual = state["data"].get("activeDocument")+ if actual != expected:+ return {+ "success": False,+ "errorCode": "wrong_document",+ "expected": expected,+ "actual": actual,+ "_hint": (+ "REFUSED before running: the active Fusion document is not the one you "+ "asserted with expectDocument, so the verb was not executed (it would "+ "otherwise have acted on the wrong file). Re-activate the intended "+ "document with fusion_activate_document, or re-issue with expectDocument "+ "set to the current active document. This is a NAME check: if the doc was "+ "renamed mid-session, pass the new name."+ ),+ }+ return None+++def _diagnose_addin_timeout(command: str, original_result: dict) -> dict:+ """After a command timeout, probe /health to determine the cause.++ Returns a distinct error if the add-in is alive but its main thread+ is blocked (e.g. by a modal dialog in Fusion). Always captures+ auto-screenshots of Fusion windows on timeout so the AI can see+ what dialog is blocking.+ """+ # Auto-screenshot on timeout — the most common cause is a blocking dialog+ # that's invisible to the API. Capture Fusion windows so the AI can+ # identify and dismiss the dialog.+ timeout_screenshots = {}+ try:+ timeout_screenshots = _post_open_screenshot(command)+ except Exception:+ pass # Best effort — don't let screenshot failure mask the real error++ # Identify WHICH modal is blocking (titles enumerate over Win32 even while the+ # add-in's main thread is stuck). Turns the opaque "add-in not responding" into+ # an actionable cause + resolution — and stops the needless restart loop.+ blocking_dialogs = classify_blocking_dialogs()++ health = _probe_addin(timeout=2.0)+ if health and health.get("status") == "ok":+ main_thread = health.get("main_thread", "unknown")+ pending = health.get("pending_commands", 0)+ if main_thread == "blocked" or pending > 0:+ # Before assuming a modal dialog, check /status — if the add-in+ # is busy with a known command, it's working (not dialog-blocked).+ status = _check_addin_status(timeout=3.0)+ if status and status.get("busy"):+ busy_cmd = status.get("busyCommand", "unknown")+ elapsed = status.get("elapsedSeconds", 0)+ walk = status.get("walkProgress")+ resp = {+ "success": False,+ "error": f"Fusion main thread busy — {busy_cmd} running for {elapsed}s.",+ "errorCode": "main_thread_busy",+ "busyCommand": busy_cmd,+ "elapsedSeconds": elapsed,+ "_hint": (+ "Add-in is busy with a long-running command. Do NOT retry add-in commands. "+ "Do NOT press Escape — the add-in is working, not stuck on a dialog. "+ "Commands that still work: fusion_window_info, fusion_screenshot_fusion, "+ "fusion_click_fusion, fusion_send_key, fusion_close_window."+ ),+ }+ if walk:+ resp["progress"] = walk+ return resp++ # If we recognized the blocking modal, name it and give the precise fix+ # instead of the generic "read the screenshots" guidance.+ if blocking_dialogs:+ titles = ", ".join(f"'{d['title']}' ({d['category']})" for d in blocking_dialogs)+ resolutions = " ".join(dict.fromkeys(d["resolution"] for d in blocking_dialogs))+ message = (f"The AdomBridge add-in is alive but its main thread is blocked by a "+ f"modal dialog: {titles}. This is NOT an add-in crash — do not restart "+ f"Fusion. {resolutions}")+ else:+ message = (f"The AdomBridge add-in is alive but its main thread is not "+ f"responding (status: {main_thread}, pending: {pending}). "+ f"Fusion 360 likely has a modal dialog open (Document Recovery, "+ f"error, or update prompt) that is blocking execution. "+ f"READ the screenshots to identify the dialog, then dismiss "+ f"with fusion_send_key {{\"key\": \"escape\"}} or "+ f"fusion_send_key {{\"key\": \"tab\"}} + {{\"key\": \"enter\"}}.")+ return {+ "success": False,+ "error": "addin_main_thread_blocked",+ "message": message,+ "blockingDialogs": blocking_dialogs,+ "data": {+ "command": command,+ "health": health,+ "blockingDialogs": blocking_dialogs,+ "postOpenScreenshot": timeout_screenshots,+ },+ }+ # Health says responsive but command still timed out — unusual+ return {+ "success": False,+ "error": "addin_command_timeout",+ "message": f"Command '{command}' timed out but the add-in reports main thread "+ f"is {main_thread}. The command may be long-running or stuck.",+ "data": {+ "command": command,+ "health": health,+ "postOpenScreenshot": timeout_screenshots,+ },+ }++ # Health probe failed — add-in HTTP server is down+ return {+ "success": False,+ "error": "AdomBridge add-in not responding (it may have crashed).",+ "errorCode": "fusion_addin_not_responding",+ "_hint": "Fix it YOURSELF - never ask the user: restart Fusion via fusion_stop + "+ "fusion_start (the bridge installs the add-in to ALL Fusion add-in dirs incl. "+ "%APPDATA%\\Autodesk\\FusionAddins, and runOnStartup reloads it).",+ }+++def _orchestrate_open_lbr(args: dict) -> dict:+ """Open an EAGLE .lbr library file in Fusion 360 Electronics.++ Uses Document.newDesignFromLocal (via the add-in's execute_text_command)+ to open the .lbr file, which auto-switches to the Electronics Library+ editor. Then optionally navigates to a symbol and verifies via export.++ This is orchestrated at bridge level to avoid add-in module caching+ issues and to allow multi-step operations with waits.+ """+ import tempfile+ import time++ file_path = args.get("filePath", "")+ symbol_name = args.get("symbolName", "")+ verify = args.get("verify", False)++ if not file_path:+ return {"success": False, "error": "No filePath specified"}++ file_path = file_path.replace("\\", "/")+ results = []++ # Step 1: Open the .lbr via Document.newDesignFromLocal.+ # ⚠️ TIMEOUT + VERIFY-BY-STATE (fixed 2026-07-07, caught live on a GPU-less Azure+ # VM): the open can take 60s+ on slow/software-rendered machines, so a fixed 30s+ # read timeout expired mid-open and the canned proxy error claimed the ADD-IN was+ # "not running" while the document was actually opening fine (fusion_build_library_3d+ # then aborted all parts on a lie). Now: a generous timeout, AND on ANY failure we+ # poll get_app_state for the expected document name - the doc actually being open+ # outranks whatever the synchronous return claimed.+ open_result = _proxy_to_addin("execute_text_command", {+ "command": f"Document.newDesignFromLocal {file_path}",+ }, timeout=120)++ if not open_result.get("success"):+ # Verify by STATE before failing: did the doc open anyway?+ expected = file_path.replace("\\", "/").rsplit("/", 1)[-1]+ expected = expected.rsplit(".", 1)[0].lower()+ opened_anyway = False+ for _ in range(20): # up to ~60s of settling+ time.sleep(3)+ try:+ st = _proxy_to_addin("get_app_state", {}, timeout=8)+ active = str((st.get("data") or {}).get("activeDocument", "")).lower()+ if expected and expected in active:+ opened_anyway = True+ break+ except Exception:+ pass+ if not opened_anyway:+ return {+ "success": False,+ "error": f"Document.newDesignFromLocal failed: {open_result.get('error', 'unknown')}",+ "_hint": ("The open did not complete AND the document never became active. On slow/"+ "software-rendered machines opens can take 60s+; this call already waited + "+ "verified by state. Check fusion_check_dialogs for a blocking modal, then retry."),+ "data": {"filePath": file_path},+ }+ results.append("Document.newDesignFromLocal: ok (verified by app state after slow open)")+ else:+ results.append("Document.newDesignFromLocal: ok")++ # Step 2: Navigate to symbol if requested+ if symbol_name:+ time.sleep(3) # Let Fusion finish opening and switching workspace++ sym_ref = symbol_name if symbol_name.endswith(".sym") else f"{symbol_name}.sym"+ edit_result = _proxy_to_addin("electron_run", {+ "command": f"EDIT {sym_ref}",+ }, timeout=10)+ results.append(f"EDIT {sym_ref}: success={edit_result.get('success')}")++ # Zoom to fit+ _proxy_to_addin("electron_run", {"command": "WINDOW FIT"}, timeout=5)+ results.append("WINDOW FIT: ok")++ # Step 3: Verify via EXPORT SCRIPT+ verification = None+ if verify:+ import uuid+ time.sleep(2) # Let Fusion settle before export+ # Use a unique path to avoid "overwrite?" dialogs blocking the UI thread+ export_path = str(Path(tempfile.gettempdir()) / f"_adom_verify_{uuid.uuid4().hex[:8]}.scr")+ export_result = _proxy_to_addin("export_lbr", {"outputPath": export_path}, timeout=30)+ if export_result.get("success"):+ preview = export_result.get("data", {}).get("preview", "")+ has_symbol = False+ if symbol_name:+ has_symbol = (+ f"'{symbol_name.upper()}.sym'" in preview+ or f"'{symbol_name}.sym'" in preview+ )+ verification = {+ "exported": True,+ "fileSize": export_result.get("data", {}).get("fileSize", 0),+ "hasSymbol": has_symbol,+ "preview": preview[:500],+ }+ else:+ verification = {"exported": False, "error": export_result.get("error", "unknown")}+ results.append(f"Verification: exported={verification.get('exported', False)}")++ import os+ response = {+ "success": True,+ "output": f"Opened library: {os.path.basename(file_path)}",+ "data": {"results": results, "filePath": file_path},+ "_hint": (+ "Library opened in the Electronics Library editor (Content Manager). "+ "ALWAYS VERIFY VIA SCREENSHOT: a .lbr can FAIL to open ('<file>.lbr has errors and cannot be "+ "opened') while this call still returns success - that error is an OWNED POPUP. Grab "+ "desktop_screenshot_window on the Fusion main hwnd and CHECK ownedPopupCount + read the "+ "_screenshots[] array (AD v1.8.177+ captures owned dialogs invisible to a plain capture); "+ "ownedPopupCount>0 means an error/confirm dialog is up. "+ "NOTE: an adom-lbr .lbr is 2D ONLY - symbol + footprint + a PLACEHOLDER 3D package. "+ "To attach the real 3D chip, use fusion_attach_3d_package (it runs the Package3D generator + "+ "FINISH, which binds the 3D onto the deviceset). See the 'fusion-libraries' skill / LIBRARY_FINDINGS.md."+ ),+ }+ if symbol_name:+ response["data"]["symbolName"] = symbol_name+ if verification:+ response["data"]["verification"] = verification++ # Auto-screenshot after opening — catches blocking dialogs, same as other open commands+ return _post_open_screenshot(response)+++def _orchestrate_attach_3d_package(args: dict) -> dict:+ """Attach a real 3D model to a library package, end to end.++ Opens the .lbr (library active), runs Electron.Create3DPackage to enter the+ Package3DEnvironment showing the footprint, imports the STEP model and+ auto-orients it flat on the footprint, then executes Package3DStop (FINISH).+ Fusion then shows a modal Save dialog (an OWNED popup) — that single click is+ the only desktop-side step; this returns the exact instruction for it.++ args: {filePath: Windows path to the .lbr, modelPath: Windows path to the+ STEP, packageName: optional str}+ """+ import time+ file_path = (args.get("filePath") or "").replace("\\", "/")+ model_path = (args.get("modelPath") or "").replace("\\", "/")+ package = args.get("packageName", "")+ orient_flag = args.get("orient", True) # 2026-07-07: expose orient (was hard-on)+ if not file_path or not model_path:+ return {"success": False,+ "error": "filePath (.lbr) and modelPath (.step) are required (Windows paths, e.g. C:/...).",+ "_hint": "Stage both onto Windows first (no container->Windows push verb). See the fusion-libraries skill."}+ steps = []+ open_res = _orchestrate_open_lbr({"filePath": file_path})+ if not open_res.get("success"):+ return {"success": False, "error": "open_lbr failed: " + str(open_res.get("error")), "data": {"steps": steps}}+ steps.append("open_lbr: ok (library active)")+ time.sleep(1)+ cp = _proxy_to_addin("execute_text_command", {"command": f"Electron.Create3DPackage {file_path}"}, timeout=40)+ if not cp.get("success"):+ return {"success": False, "error": "Create3DPackage failed: " + str(cp.get("error")),+ "_hint": "The library document must be ACTIVE, and the .lbr must be adom-lbr-generated "+ "(a raw vendor EAGLE .lbr fails Fusion's XML parser).", "data": {"steps": steps}}+ steps.append("Create3DPackage: ok (Package3DEnvironment)")+ time.sleep(2)+ import_script = (+ "import adsk.core, adsk.fusion, math\n"+ "res={}\n"+ "d=adsk.fusion.Design.cast(app.activeProduct); root=d.rootComponent\n"+ "im=app.importManager\n"+ f"im.importToTarget(im.createSTEPImportOptions('{model_path}'), root)\n"+ "oc=root.occurrences.item(root.occurrences.count-1); bb=oc.boundingBox\n"+ "dx=bb.maxPoint.x-bb.minPoint.x; dy=bb.maxPoint.y-bb.minPoint.y; dz=bb.maxPoint.z-bb.minPoint.z\n"+ # Tall-part guard (2026-07-07): keep a part vertical if its largest dim is already Z; only+ # flatten a clearly-thin part whose thin axis isn't Z. Was: always tip smallest dim to Z,+ # which laid tall through-hole pins on their side. Honors the orient flag (default True).+ + ("axis=None\n" if not orient_flag else+ "thin=min(dx,dy,dz); big=max(dx,dy,dz)\n"+ "tall_z=(dz>=dx and dz>=dy)\n"+ "axis=None\n"+ "if (thin < 0.5*big) and not tall_z:\n"+ " axis=(1,0,0) if (dy<=dx and dy<=dz) else ((0,1,0) if (dx<=dy and dx<=dz) else None)\n") ++ "if axis:\n"+ " mat=oc.transform2.copy(); rot=adsk.core.Matrix3D.create()\n"+ " rot.setToRotation(math.pi/2, adsk.core.Vector3D.create(*axis), adsk.core.Point3D.create(0,0,0))\n"+ " mat.transformBy(rot); oc.transform2=mat\n"+ " d.snapshots.add() if d.snapshots.hasPendingSnapshot else None\n"+ "res['dims_mm']=[round(dx*10,2),round(dy*10,2),round(dz*10,2)]\n"+ "result=res\n"+ )+ imp = _proxy_to_addin("run_modeling_script", {"script": import_script}, timeout=120)+ if not imp.get("success"):+ return {"success": False, "error": "import/orient failed: " + str(imp.get("error")), "data": {"steps": steps}}+ dims = (imp.get("data", {}) or {}).get("result", {})+ steps.append(f"import+orient: ok ({dims.get('dims_mm') if isinstance(dims, dict) else dims})")+ time.sleep(1)+ _proxy_to_addin("run_modeling_script",+ {"script": "import adsk.core\ncd=ui.commandDefinitions.itemById('Package3DStop')\nresult={'finished': bool(cd) and cd.execute()}\n"},+ timeout=30)+ steps.append("FINISH (Package3DStop): executed")+ return {+ "success": True,+ "output": f"3D model placed + oriented on the footprint and FINISH executed for '{package or file_path}'. A Save dialog is now up.",+ "data": {"steps": steps, "package": package},+ "savePending": True,+ "_hint": (+ "FINAL STEP (desktop-side, one click): a Fusion 'Save' dialog is now up to save the 3D package. "+ "Click it: desktop_find_window {titleContains:'Save'} -> desktop_ui_click "+ "{hwnd, automationId:'QTApplication.QTFrameWindow.standardActions.SaveButton'}. "+ "THEN VERIFY with desktop_screenshot_window on the Fusion hwnd: check ownedPopupCount (errors are owned "+ "popups), and the deviceset's Package column should flip Placeholder->part-name + the 3D preview becomes "+ "the real chip (the preview LAGS a beat - re-grab). Full flow: fusion-libraries skill / LIBRARY_FINDINGS.md sect 11."+ ),+ }+++# ── Fusion cloud FOLDER HYGIENE (never write loose files to a shared project ROOT) ───────────+# Hard lesson (2026-06-29): defaulting uploads to a project's ROOT folder dumped 100+ loose f3d+# files into the shared Adom team root and other employees complained. RULE: the bridge NEVER+# writes a file to a project root. It writes into an AI-OWNED "Adom AI Workspace" folder, with a+# per-task SUBfolder, keeping the cloud tidy. See the fusion-cloud-hygiene skill.+_DEFAULT_UPLOAD_PROJECT = "a.YnVzaW5lc3M6YWRvbTMjMjAyMzExMjk3MDM5NjAzMzE" # the Adom business project+# The shared team ROOT folder of that project - OFF LIMITS for loose files (only the workspace+# folder itself may live here). Known roots we must refuse as a write target.+_KNOWN_ROOT_FOLDERS = {"urn:adsk.wipprod:fs.folder:co.jyO4vxQXR6S9zFpTQWnZAg"}+_AI_WORKSPACE_NAME = "Adom AI Workspace" # the AI-owned work area (BRAND: "Adom AI", not "Claude")+_ws_folder_cache = {}+++def _safe_folder_name(name: str) -> str:+ import re as _re+ return _re.sub(r'[<>:"/\\|?*]', "", str(name or "")).strip()[:60] or "task"+++def _find_child_folder(project_id: str, parent_id: str, name: str):+ """folderId of a subfolder named `name` directly under parent_id, or None."""+ try:+ r = aps.handle_browse({"projectId": project_id, "folderId": parent_id})+ items = (r.get("data") or {}).get("items", []) if isinstance(r, dict) else []+ for it in items:+ if it.get("type") == "folders" and (it.get("name") or "").strip() == name:+ return it.get("id")+ except Exception:+ pass+ return None+++def _ensure_workspace_folder(project_id: str, task: str = None):+ """Return a folderId inside the AI-owned 'Adom AI Workspace' (NEVER a project root). Creates the+ workspace folder (one tidy folder under the project root) + an optional per-task subfolder if+ missing. Cached per (project, task). Returns None if it cannot be resolved (caller must NOT then+ fall back to root)."""+ task = _safe_folder_name(task) if task else None+ key = (project_id, task or "")+ if key in _ws_folder_cache:+ return _ws_folder_cache[key]+ root = next(iter(_KNOWN_ROOT_FOLDERS)) # parent for the single workspace folder+ ws = _find_child_folder(project_id, root, _AI_WORKSPACE_NAME)+ if not ws:+ cr = aps.handle_create_folder({"projectId": project_id, "parentFolderId": root, "name": _AI_WORKSPACE_NAME})+ ws = (cr.get("data") or {}).get("folderId") if isinstance(cr, dict) and cr.get("success") else None+ folder = ws+ if task and ws:+ sub = _find_child_folder(project_id, ws, task)+ if not sub:+ cr = aps.handle_create_folder({"projectId": project_id, "parentFolderId": ws, "name": task})+ sub = (cr.get("data") or {}).get("folderId") if isinstance(cr, dict) and cr.get("success") else None+ folder = sub or ws+ if folder:+ _ws_folder_cache[key] = folder+ return folder+++def _discover_upload_target(args: dict) -> tuple:+ """(projectId, folderId) for f3d uploads - ALWAYS a non-root, AI-owned folder.++ Defaults to 'Adom AI Workspace/<task>' (auto-created). If the caller explicitly passes a+ folderId that is a known project ROOT, it is REFUSED (we steer to the workspace instead) - the+ bridge must never write loose files to a shared root. Returns (projectId, folderId|None);+ folderId is None only if the workspace folder could not be created (caller must error, NOT+ fall back to root)."""+ project_id = args.get("projectId") or _DEFAULT_UPLOAD_PROJECT+ fid = args.get("folderId")+ if fid and fid in _KNOWN_ROOT_FOLDERS:+ fid = None # explicit root -> refuse, steer to workspace+ if not fid:+ fid = _ensure_workspace_folder(project_id, args.get("task"))+ return (project_id, fid)+++def _capture_labeled(label) -> str | None:+ """Background-capture the main Fusion window to a labeled PNG on the box+ (C:/tmp/conduit-screenshots). Returns the saved path or None. Never fullscreen+ (hwnd-targeted PrintWindow, so it captures Fusion in the background per fusion-driving)."""+ if not label:+ return None+ try:+ info = get_fusion_window_info()+ hwnd = info.get("hwnd")+ if not hwnd:+ return None+ import re as _re+ safe = _re.sub(r'[<>:"/\\|?*]', "", str(label)).replace(" ", "_")[:40]+ r = screenshot_hwnd(hwnd, label=safe)+ return r.get("savedTo") if r.get("success") else None+ except Exception:+ return None+++def _inject_package3d_bindings(lbr_text: str, bindings: list) -> str:+ """Inject EAGLE <packages3d> + per-device <package3dinstances> into an .lbr - MERGE-AWARE+ and IDEMPOTENT (safe to re-run with any subset of parts).++ bindings: [{package: <pkg name>, wip_urn: <urn>}]. The function reads any bindings ALREADY in+ the .lbr, merges the new ones on top (new wins on conflict), strips all prior <packages3d> ++ <package3dinstances>, then re-emits the full merged set: a library-level <package3d> per part+ (between </packages> and <symbols>) and a <package3dinstances> inside every <device> that uses+ that package (right after </connects>). So calling it again with just the parts that failed last+ time ACCUMULATES instead of wiping the parts that already succeeded - the fix for the+ 'a re-run dropped the earlier bindings' trap. Returns the new .lbr text."""+ import re as _re+ # 1. read existing bindings already in the file; new bindings override+ merged = {}+ em = _re.search(r"<packages3d>(.*?)</packages3d>", lbr_text, _re.S)+ if em:+ for pm in _re.finditer(r'<package3d name="([^"]+)"[^>]*wip_urn="([^"]+)"', em.group(1)):+ merged[pm.group(1)] = pm.group(2)+ for b in bindings:+ merged[b["package"]] = b["wip_urn"]+ # 2. strip ALL prior package3d markup so re-injection is clean (idempotent)+ lbr_text = _re.sub(r"\s*<packages3d>.*?</packages3d>", "", lbr_text, flags=_re.S)+ lbr_text = _re.sub(r"\s*<package3dinstances>.*?</package3dinstances>", "", lbr_text, flags=_re.S)+ # 3. library-level <packages3d> block (all merged parts)+ blocks = []+ for pkg, urn in merged.items():+ blocks.append(+ f'<package3d name="{pkg}" urn="" wip_urn="{urn}" locally_modified="yes" type="model">'+ f'<description>{pkg}</description>'+ f'<packageinstances><packageinstance name="{pkg}"/></packageinstances>'+ f'</package3d>'+ )+ pkgs3d = "<packages3d>\n" + "\n".join(blocks) + "\n</packages3d>\n"+ lbr_text = lbr_text.replace("</packages>", "</packages>\n" + pkgs3d, 1)++ # 4. per-device <package3dinstances>+ def _dev_repl(m):+ dev = m.group(0)+ pm = _re.search(r'package="([^"]+)"', dev)+ if pm and pm.group(1) in merged and "</connects>" in dev:+ inst = (f'<package3dinstances><package3dinstance package3d_urn="{merged[pm.group(1)]}"/>'+ f'</package3dinstances>')+ dev = dev.replace("</connects>", "</connects>\n" + inst, 1)+ return dev++ return _re.sub(r'<device\b[^>]*>.*?</device>', _dev_repl, lbr_text, flags=_re.S)+++def _orchestrate_make_3d_package(args: dict) -> dict:+ """Create a RENDERING component 3D-PACKAGE urn (footprint + chip), fully programmatically - NO GUI+ dialogs. A proper component 3D model contains the FOOTPRINT (pads + courtyard) AND the chip,+ merged and aligned, so the 3D viewer can verify the chip's pads land on the footprint pads.++ So: open the library, run Electron.Create3DPackage to load the package's FOOTPRINT into a+ generator doc, import the STEP onto it, orient it flat, then saveAs an .f3d (footprint + chip) -+ which skips the FINISH Save dialog + the two unbeatable "Fusion360" CEF modals entirely -+ aps_upload the .f3d, and return the fs.file:vf wip_urn to hand-write into the library's+ <packages3d>.++ Two gotchas this avoids: (1) importing the STEP into an EMPTY design gives a chip with NO+ footprint (an incomplete package); (2) a raw STEP upload does not render at all ("Thumbnail+ download failed"). See the fusion-multipart-libraries skill.++ args: {lbrPath: Windows .lbr whose FIRST package is the footprint, modelPath: Windows .step,+ projectId, folderId (both from fusion_aps_browse), fileName?: str, orient?: bool}+ """+ import os as _os, time as _time+ lbr_path = (args.get("lbrPath") or args.get("filePath") or "").replace("\\", "/")+ model_path = (args.get("modelPath") or "").replace("\\", "/")+ project_id, folder_id = _discover_upload_target(args) # an AI-owned non-root folder (never project root)+ if not lbr_path or not model_path:+ return {"success": False,+ "error": "lbrPath (.lbr with the footprint) and modelPath (.step) are required (Windows paths)."}+ if not folder_id:+ return {"success": False, "errorCode": "no_work_folder",+ "error": "Could not resolve a non-root 'Adom AI Workspace' upload folder.",+ "_hint": "The bridge refuses to write to a shared project ROOT. Sign in (fusion_aps_signin) "+ "so it can create 'Adom AI Workspace', or pass a real (non-root) folderId. NEVER "+ "pass a project root folderId. See the fusion-cloud-hygiene skill."}+ cap_label = args.get("captureLabel") # when set, capture BEFORE (footprint) + AFTER (chip placed)+ task = _safe_folder_name(args.get("task") or "AI 3D packages") # saveAs subfolder (never root)+ orient = args.get("orient", True)+ base = _os.path.basename(model_path).rsplit(".", 1)[0]+ name = (args.get("fileName") or (base + "_3d")).replace("'", "").replace(".f3d", "")+ f3d_name = name + ".f3d"+ # 1. open the library so the footprint exists; 2. Create3DPackage -> generator WITH the footprint loaded+ op = _orchestrate_open_lbr({"filePath": lbr_path})+ if not op.get("success"):+ return {"success": False, "error": "open_lbr failed: " + str(op.get("error"))}+ _time.sleep(1)+ cp = _proxy_to_addin("execute_text_command", {"command": f"Electron.Create3DPackage {lbr_path}"}, timeout=40)+ if not cp.get("success"):+ return {"success": False, "error": "Create3DPackage failed: " + str(cp.get("error")),+ "_hint": "The .lbr must be adom-lbr-generated; its FIRST package's footprint is loaded into the generator."}+ _time.sleep(2)+ # BEFORE shot: the footprint (pads + courtyard) loaded in the 3D viewer, no chip yet.+ before_shot = None+ if cap_label:+ try:+ _proxy_to_addin("run_modeling_script",+ {"script": "app.activeViewport.fit()\nresult={}"}, timeout=15)+ except Exception:+ pass+ _time.sleep(0.4)+ before_shot = _capture_labeled(f"{cap_label}_before")+ # AUTO-ORIENT (fixed 2026-07-07): the old logic always rotated the SMALLEST bbox dim to Z,+ # which is right for a flat SMD chip lying down but TIPS A TALL THROUGH-HOLE PART (machine pin,+ # connector) onto its side - its long axis is already Z and must stay vertical. Now: keep the+ # part vertical if its largest dim is already Z (tall_z), and only flatten a clearly THIN part+ # whose thin axis isn't Z yet. A caller can still force orient:false to skip entirely.+ orient_block = (+ "thin=min(dx,dy,dz); big=max(dx,dy,dz)\n"+ "tall_z=(dz>=dx and dz>=dy)\n"+ "is_flat=(thin < 0.5*big)\n"+ "axis=None\n"+ "if is_flat and not tall_z:\n axis=(1,0,0) if (dy<=dx and dy<=dz) else ((0,1,0) if (dx<=dy and dx<=dz) else None)\n"+ "if axis:\n mat=oc.transform2.copy(); r=adsk.core.Matrix3D.create()\n"+ " r.setToRotation(math.pi/2, adsk.core.Vector3D.create(*axis), adsk.core.Point3D.create(0,0,0))\n"+ " mat.transformBy(r); oc.transform2=mat\n"+ ) if orient else ""+ # 3. import the chip ONTO the footprint in the generator doc, orient, saveAs as f3d (footprint+chip)+ script = (+ "import adsk.core, adsk.fusion, math\n"+ "app=adsk.core.Application.get(); doc=app.activeDocument\n"+ "d=adsk.fusion.Design.cast(app.activeProduct); root=d.rootComponent\n"+ "im=app.importManager\n"+ f"im.importToTarget(im.createSTEPImportOptions('{model_path}'), root)\n"+ "oc=root.occurrences.item(root.occurrences.count-1); bb=oc.boundingBox\n"+ "dx=bb.maxPoint.x-bb.minPoint.x; dy=bb.maxPoint.y-bb.minPoint.y; dz=bb.maxPoint.z-bb.minPoint.z\n"+ + orient_block ++ "try:\n app.activeViewport.fit()\nexcept: pass\n"+ # FOLDER HYGIENE: saveAs into an 'Adom AI Workspace'/<task> subfolder, NEVER the project root.+ "proj=app.data.activeProject; rf=proj.rootFolder\n"+ "def _sub(p,nm):\n"+ " for i in range(p.dataFolders.count):\n"+ " if p.dataFolders.item(i).name==nm: return p.dataFolders.item(i)\n"+ " return p.dataFolders.add(nm)\n"+ f"wsf=_sub(_sub(rf,'Adom AI Workspace'),'{task}')\n"+ "res={}\n"+ "try:\n"+ f" doc.saveAs('{name}', wsf, '3d package (footprint+chip)', '')\n"+ " res['path']=doc.dataFile.id if doc.dataFile else None\n"+ " res['dims']=[round(dx*10,2),round(dy*10,2),round(dz*10,2)]\n"+ "except Exception as e: res['err']=str(e)[:80]\n"+ "result=res\n"+ )+ r = _proxy_to_addin("run_modeling_script", {"script": script}, timeout=120)+ res = (r.get("data", {}) or {}).get("result", {}) if isinstance(r, dict) else {}+ f3d_path = res.get("path") if isinstance(res, dict) else None+ dims = res.get("dims") if isinstance(res, dict) else None+ # AFTER shot: the chip placed flat on its footprint (pads landing on pads) in the 3D viewer.+ after_shot = _capture_labeled(f"{cap_label}_after") if cap_label else None+ if not f3d_path or not str(f3d_path).endswith(".f3d"):+ return {"success": False, "error": "f3d saveAs did not produce a .f3d path: " + str(res),+ "before": before_shot, "after": after_shot,+ "_hint": "Confirm modelPath is a valid Windows .step path and Fusion is running."}+ up = aps.handle_upload({"projectId": project_id, "folderId": folder_id,+ "localPath": f3d_path, "fileName": f3d_name})+ up_d = up.get("data", up) if isinstance(up, dict) else {}+ item_urn = (up_d or {}).get("itemUrn", "") if isinstance(up_d, dict) else ""+ try:+ _proxy_to_addin("run_modeling_script", {"script": "app.activeDocument.close(False)\nresult={}"}, timeout=20)+ except Exception:+ pass+ if "dm.lineage:" not in item_urn:+ return {"success": False, "error": "f3d upload did not return a lineage urn: " + str(up_d)}+ lid = item_urn.split("dm.lineage:")[-1]+ wip_urn = f"urn:adsk.wipprod:fs.file:vf.{lid}?version=1"+ return {+ "success": True,+ "wip_urn": wip_urn,+ "dims_mm": dims,+ "f3d": f3d_path,+ "before": before_shot,+ "after": after_shot,+ "_hint": (+ "DONE - a PROPER component 3D package (FOOTPRINT + chip, aligned) created with NO GUI dialogs. "+ "NEXT: hand-write this wip_urn into the library's EAGLE XML - a <package3d name=\"PKG\" urn=\"\" "+ "wip_urn=\"" + wip_urn + "\" locally_modified=\"yes\" type=\"model\"> (with <packageinstances>"+ "<packageinstance name=\"PKG\"/></packageinstances>) inside <packages3d> (between </packages> and "+ "<symbols>), PLUS <package3dinstances><package3dinstance package3d_urn=\"" + wip_urn + "\"/>"+ "</package3dinstances> inside the device after </connects>. Then fusion_open_lbr the merged .lbr. "+ "EXPECT: fusion_check_dialogs == 0 (no broken-ref), the device 3D preview shows footprint + chip "+ "(NOT 'Thumbnail download failed'), and opening the f3d shows the chip's pads landing on the "+ "footprint pads. "+ "SPEEDUP: for a many-part library, call this verb once per part, collect the urns, hand-merge ALL "+ "bindings in ONE pass, then fusion_open_lbr ONCE (don't open/save per part). "+ "PITFALLS: needs Fusion running (after a bridge_install respawn it can transiently report "+ "'not running' - fusion_start, then retry); a RAW STEP upload binds but renders NOTHING "+ "('Thumbnail download failed') so always go through this verb (it makes an f3d); NEVER FINISH the "+ "Package3D generator (Package3DStop) - its Save dialog + two opaque CEF modals are unbeatable, "+ "this verb saveAs-es instead. Full recipe: the fusion-multipart-libraries skill."+ ),+ }+++def _orchestrate_build_library_3d(args: dict) -> dict:+ """Bind real 3D onto a multi-part library. Runs DETACHED by default (async=True) so it+ SURVIVES AD's 60s relay cap.++ ⚠️ LEARNED THE HARD WAY (2026-07-08): the cloud Package3D generate + Hub upload takes MINUTES+ on a real machine, but AD's relay hard-caps every request at ~60s and `timeoutSeconds` is NOT+ honored for this verb - so the old synchronous build got its thread KILLED at 60s, wrote no+ bound .lbr, and lost every wip_urn (3/4 pins on a demo board came back as flat pads because the+ 4th never got a fresh urn). Fix: the build now runs in a BACKGROUND daemon thread and returns+ immediately; the caller POLLS the bound `outLbrPath` file (read_file) until it has one+ `<package3d ... wip_urn=urn:...>` per part. Pass `async:false` only on a fast machine where the+ whole build fits under 60s. Then embed those package3d + a per-`<element>` `package3d_urn` in a+ `.brd` and `fusion_open_board` + `fusion_show_3d_board` shows the REAL 3D bodies (background).++ args: {..., async?: bool (default TRUE - detached + pollable)}. See _build_library_3d_core.+ """+ combined = (args.get("lbrPath") or "").replace("\\", "/")+ out_path = (args.get("outLbrPath") or combined).replace("\\", "/")+ if args.get("async", True) and args.get("parts"):+ import threading as _th++ def _run():+ try:+ _build_library_3d_core(args)+ except Exception:+ pass+ _th.Thread(target=_run, daemon=True).start()+ return {+ "success": True, "status": "started", "async": True,+ "boundLbr": out_path, "partsTotal": len(args.get("parts") or []),+ "_hint": (+ "⏳ 3D bind runs DETACHED (survives AD's 60s relay cap, which used to kill it + lose "+ "every wip_urn). POLL the boundLbr via read_file every ~30s until it has one "+ "<package3d name=... wip_urn=urn:...> PER PART (count == partsTotal). Do NOT re-fire "+ "while running (check fusion_addin_status.busy). When done, embed those <packages3d> + "+ "a per-<element> package3d_urn in your .brd, then fusion_open_board + fusion_show_3d_board "+ "for real 3D bodies (all background). If a urn stays unresolved in the 3D view, re-run "+ "for just that part - its f3d upload failed."),+ }+ return _build_library_3d_core(args)+++def _build_library_3d_core(args: dict) -> dict:+ """Build a RENDERING multi-part 3D library in ONE call - the whole programmatic pipeline.++ For each part: make its footprint+chip f3d package (no GUI dialogs, optional BEFORE/AFTER+ screenshots), collect the wip_urn. Then inject ALL bindings into the combined .lbr in one+ pass (no hand XML surgery) and open the finished library ONCE. This is the verb to call for+ a basic-parts sampler / any many-part library - it replaces the per-part make_3d_package loop+ + manual binding the AI used to do.++ args: {+ lbrPath: combined .lbr to bind + open (Windows path),+ parts: [{package, lbrPath (per-part .lbr whose FIRST package is this footprint),+ modelPath (.step)}],+ outLbrPath?: where to write the bound .lbr (default: overwrite lbrPath),+ capture?: bool - capture before/after per part (default True),+ projectId?, folderId?: APS upload target (default: the MAIN-project upload folder),+ openWhenDone?: bool (default True)+ }+ """+ combined = (args.get("lbrPath") or "").replace("\\", "/")+ parts = args.get("parts") or []+ if not combined or not parts:+ return {"success": False,+ "error": "lbrPath (combined .lbr) and parts[] are required.",+ "_hint": "parts: [{package, lbrPath (per-part footprint .lbr), modelPath (.step)}]. "+ "projectId/folderId default to the MAIN-project upload folder."}+ out_path = (args.get("outLbrPath") or combined).replace("\\", "/")+ capture = args.get("capture", True)+ # Put this library's f3d in its OWN task subfolder under 'Adom AI Workspace' (never project root).+ import os as _os2+ task = args.get("task") or _os2.path.basename(combined).rsplit(".", 1)[0]+ project_id, folder_id = _discover_upload_target({**args, "task": task})+ if not folder_id:+ return {"success": False, "errorCode": "no_work_folder",+ "error": "Could not resolve a non-root 'Adom AI Workspace' upload folder.",+ "_hint": "The bridge refuses to write to a shared project ROOT. Sign in "+ "(fusion_aps_signin) so it can create the workspace folder, or pass a real "+ "(non-root) folderId. See the fusion-cloud-hygiene skill."}+ results = []; bindings = []; shots = []+ for p in parts:+ pkg = p.get("package")+ per_lbr = (p.get("lbrPath") or "").replace("\\", "/")+ step = (p.get("modelPath") or "").replace("\\", "/")+ if not pkg or not per_lbr or not step:+ results.append({"package": pkg, "success": False,+ "error": "package, lbrPath (per-part .lbr) and modelPath (.step) all required"})+ continue+ mk_args = {+ "lbrPath": per_lbr, "modelPath": step,+ "projectId": project_id, "folderId": folder_id,+ "fileName": f"{pkg}_3d", "task": task,+ "captureLabel": (pkg if capture else None),+ # Per-part orient passthrough (2026-07-07): a caller can set orient:false on a part+ # whose STEP is already correctly oriented (e.g. a tall through-hole pin). Defaults+ # to True, and make_3d_package's tall-part guard now keeps tall parts vertical anyway.+ "orient": p.get("orient", True),+ }+ mk = _orchestrate_make_3d_package(mk_args)+ # Retry once: the FIRST part of a run often fails transiently while Fusion settles from a+ # prior view/library; a single retry recovers it (the dropped-first-part trap).+ if not mk.get("success"):+ import time as _t+ _t.sleep(2)+ mk = _orchestrate_make_3d_package(mk_args)+ entry = {"package": pkg, "success": bool(mk.get("success"))}+ if mk.get("success"):+ entry["wip_urn"] = mk.get("wip_urn"); entry["dims_mm"] = mk.get("dims_mm")+ bindings.append({"package": pkg, "wip_urn": mk["wip_urn"]})+ else:+ entry["error"] = mk.get("error")+ if mk.get("before"):+ shots.append({"package": pkg, "stage": "before", "path": mk["before"]})+ if mk.get("after"):+ shots.append({"package": pkg, "stage": "after", "path": mk["after"]})+ results.append(entry)+ # inject every binding in ONE pass, write the bound library. Read the ALREADY-BOUND output if it+ # exists so a re-run ACCUMULATES (merge-aware) onto prior successes instead of starting clean.+ bound = None+ if bindings:+ try:+ import os as _os+ src = out_path if _os.path.exists(out_path) else combined+ with open(src, "r", encoding="utf-8") as fh:+ txt = fh.read()+ txt = _inject_package3d_bindings(txt, bindings)+ with open(out_path, "w", encoding="utf-8") as fh:+ fh.write(txt)+ bound = out_path+ except Exception as e:+ return {"success": False, "error": f"binding injection failed: {e}",+ "parts": results, "bindings": bindings, "screenshots": shots}+ # open the finished library ONCE (so check_dialogs reflects the merged result)+ opened = None+ if bound and args.get("openWhenDone", True):+ try:+ opened = bool(_orchestrate_open_lbr({"filePath": bound}).get("success"))+ except Exception:+ opened = False+ n_ok = sum(1 for r in results if r.get("success"))+ return {+ "success": n_ok > 0,+ "boundLbr": bound,+ "partsBound": n_ok,+ "partsTotal": len(parts),+ "parts": results,+ "screenshots": shots,+ "opened": opened,+ "_hint": (+ f"{n_ok}/{len(parts)} parts got a RENDERING footprint+chip 3D package; all bindings injected "+ f"into {bound} and the library opened once. NEXT: fusion_check_dialogs should be 0 (no "+ "broken-ref). For clean device previews + screenshots, save to the cloud and REOPEN from "+ "the cloud (fusion_aps_open) - the in-session view often gets an empty 'Untitled' shoved in "+ "front. Each device's lower-right 3D preview should show footprint + chip (NOT 'Thumbnail "+ "download failed'). The BEFORE (footprint) / AFTER (chip placed) PNGs are in screenshots[] "+ "on the Windows box (C:/tmp/conduit-screenshots) - pull them with desktop_pull_file (or "+ "re-capture via desktop_screenshot_window) for the demo video. "+ "AFTER (recommended): call fusion_capture_library_views per showcase part to grab the "+ "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 "+ "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 "+ "missing parts pointing outLbrPath at the SAME file - binding injection is now MERGE-AWARE "+ "and idempotent, so it accumulates onto the existing bindings (it no longer wipes the "+ "parts that already succeeded). Each part also self-retries once on a transient first-part "+ "failure. Full recipe: the fusion-multipart-libraries skill."+ ),+ }+++def _dismiss_dialogs_bg() -> list:+ """Dismiss any blocking Fusion dialog in the BACKGROUND, without stealing focus.++ Uses close_window (WM_CLOSE via PostMessage) = Cancel/No on the dialog - the background-safe+ dismiss. ⛔ NEVER use send_key/Escape for this: send_key calls SetForegroundWindow and YANKS+ Fusion to the foreground, disrupting the user (learned 2026-06-29). WM_CLOSE is also more reliable+ than Escape on Qt dialogs. Returns the titles dismissed."""+ try:+ info = get_fusion_window_info()+ dismissed = []+ for d in info.get("dialogs", []) or []:+ hwnd = d.get("hwnd")+ if hwnd:+ try:+ close_window(hwnd) # background WM_CLOSE = Cancel/No (never creates a stray)+ dismissed.append(d.get("title") or "")+ except Exception:+ pass+ return dismissed+ except Exception:+ return []+++def _orchestrate_capture_library_views(args: dict) -> dict:+ """Capture the LIBRARY-EDITOR views that make EEs trust a part: the schematic SYMBOL, the+ FOOTPRINT (pads + layer stack), and the COMPONENT/device view (Content Manager: the symbol ++ the package table with the footprint<->package Mapped check + pin/pad counts). These are what+ developers want to see - the 3D before/after shots alone don't show them.++ Requires the .lbr OPEN in the Electronics Library workspace (fusion_open_lbr first). For each+ package it runs the EAGLE/Electron EDIT <pkg>.sym / .pac / .dev, WINDOW FIT to frame, and a+ background hwnd screenshot (never fullscreen). Returns the saved PNG paths on the Windows box+ (C:/tmp/conduit-screenshots - pull them with desktop_pull_file).++ args: {packages: [<deviceset name>, ...] (or a single 'package'),+ views?: subset of ['component','symbol','footprint'] (default all three),+ settle?: seconds to wait after each EDIT before framing/capturing (default 1.5 - raise it+ if a shot shows the PREVIOUS part, lower it to go faster on a snappy machine)}+ NOTE: ~2s per view * parts * views can exceed the ~60s relay request timeout - the captures still+ complete server-side; verify the PNGs landed in C:/tmp/conduit-screenshots and pull them.+ """+ import time as _t+ pkgs = args.get("packages") or ([args["package"]] if args.get("package") else [])+ if not pkgs:+ return {"success": False,+ "error": "packages: [<deviceset name>] (or package: <name>) required.",+ "_hint": "Open the .lbr first (fusion_open_lbr). Names are the deviceset names, e.g. ESP32-S3FN8."}+ view_ext = {"component": "dev", "symbol": "sym", "footprint": "pac",+ "dev": "dev", "sym": "sym", "pac": "pac"}+ label_of = {"dev": "component", "sym": "symbol", "pac": "footprint"}+ views = args.get("views") or ["component", "symbol", "footprint"]+ # EDIT is async - the editor takes a beat to actually SWITCH the view. Capturing too soon grabs+ # the PREVIOUS view (a mislabeled shot). Settle AFTER the EDIT (before fit/capture); tunable.+ settle = float(args.get("settle", 1.5))+ out = []+ for pkg in pkgs:+ for v in views:+ ext = view_ext.get(v, v)+ er = _proxy_to_addin("electron_run", {"command": f"EDIT {pkg}.{ext}"}, timeout=30)+ _t.sleep(settle) # let the editor LOAD the new view before framing/capturing it+ # ALWAYS catch + dismiss any dialog the EDIT raised - in the BACKGROUND (no focus steal).+ # Common one: "Create new symbol/footprint '<name>'?" when <name> is a DEVICESET name but+ # the symbol/package is named by the shared combo. Dismissing (Cancel/No) avoids a stray.+ dismissed = _dismiss_dialogs_bg()+ _proxy_to_addin("electron_run", {"command": "WINDOW FIT"}, timeout=20)+ _t.sleep(0.6)+ shot = _capture_labeled(f"{pkg}_{label_of.get(ext, ext)}")+ out.append({"package": pkg, "view": label_of.get(ext, ext), "path": shot,+ "dialogDismissed": dismissed or None,+ "ok": bool(er.get("success", True)) and bool(shot) and not dismissed})+ n_ok = sum(1 for o in out if o.get("ok"))+ return {+ "success": n_ok > 0,+ "captured": out,+ "_hint": (+ f"Captured {n_ok}/{len(out)} library-editor views to C:/tmp/conduit-screenshots (pull with "+ "desktop_pull_file). SYMBOL = full pinout; FOOTPRINT = pads + layer stack; COMPONENT = the "+ "Content Manager device view with the footprint<->package Mapped check + pin/pad counts. "+ "⚠️ NAMES: the COMPONENT (.dev) view opens by DEVICESET name (e.g. R-0603-10K). But SYMBOL "+ "(.sym) and FOOTPRINT (.pac) open by the SYMBOL / PACKAGE name - in a SHARED-FOOTPRINT "+ "library (one symbol/footprint per package, many value devicesets) that is the COMBO name "+ "(e.g. 'R-0603'), NOT the deviceset name ('R-0603-10K'). Passing a deviceset name to .sym/"+ ".pac makes Fusion pop 'Create new symbol/footprint?'; the bridge now auto-dismisses that in "+ "the BACKGROUND (Cancel/No, no focus steal - see entries with dialogDismissed) and marks the "+ "view not-ok, but you should pass the right name. For shared libraries the COMPONENT view "+ "alone shows symbol + footprint + 3D previews + Mapped, so it is usually enough. "+ "PITFALLS: the .lbr must be OPEN in the Electronics Library workspace (fusion_open_lbr first)."+ ),+ }+++def _orchestrate_cleanup_cloud_files(args: dict) -> dict:+ """Precisely delete a LIST of cloud files by lineage urn - SAFE cleanup of AI-created clutter.++ Deletes ONLY the exact `fileIds` (lineage urns) given - never name-guessing, so it cannot touch a+ teammate's file in a shared folder. Loops server-side (one call deletes the whole list). Use this+ to clean up f3d files the bridge created. (Find the ids with fusion_aps_browse on the folder.)++ args: {fileIds: [<lineage urn>, ...], projectName?: str (default active), folderPath?: str+ (default root - where the file lives)}+ """+ file_ids = args.get("fileIds") or []+ if not file_ids:+ return {"success": False, "error": "fileIds [lineage urns] required.",+ "_hint": "Get them from fusion_aps_browse {projectId, folderId} (item .id)."}+ project_name = args.get("projectName", "")+ folder_path = args.get("folderPath", "")+ deleted = []; failed = []+ for fid in file_ids:+ try:+ r = _proxy_to_addin("delete_cloud_file",+ {"fileId": fid, "projectName": project_name, "folderPath": folder_path},+ timeout=40)+ if isinstance(r, dict) and r.get("success"):+ deleted.append(fid)+ else:+ failed.append({"fileId": fid, "error": (r.get("error") if isinstance(r, dict) else str(r))[:90]})+ except Exception as e:+ failed.append({"fileId": fid, "error": str(e)[:90]})+ return {+ "success": len(deleted) > 0 or not failed,+ "deletedCount": len(deleted),+ "failedCount": len(failed),+ "failed": failed[:25],+ "_hint": (+ f"Deleted {len(deleted)}/{len(file_ids)} cloud files by PRECISE lineage urn (no name-guessing, "+ "so teammates' files are untouched). NOTE: a large list takes minutes and the relay request "+ "may time out at ~60s while the deletes continue server-side - re-browse the folder to confirm "+ "the count dropped. Anything in failed[] usually means the file was already gone or you lack "+ "delete permission. See the fusion-cloud-hygiene skill."+ ),+ }+++def _orchestrate_save_lbr(args: dict) -> dict:+ """Save the currently open Electronics library as a .flbr file.++ Uses Document.CopyToDesktop to export the library in Fusion's native+ .flbr format. The file can be re-opened later or uploaded to the cloud.+ """+ import os++ output_path = args.get("outputPath", "")+ if not output_path:+ return {"success": False, "error": "No outputPath specified"}++ output_path = output_path.replace("\\", "/")++ # Ensure path ends with .flbr+ if not output_path.lower().endswith(".flbr"):+ output_path += ".flbr"++ # Ensure parent directory exists+ parent = os.path.dirname(output_path)+ if parent and not os.path.exists(parent):+ try:+ os.makedirs(parent, exist_ok=True)+ except Exception as e:+ return {"success": False, "error": f"Cannot create directory {parent}: {e}"}++ # Execute Document.CopyToDesktop via the add-in+ result = _proxy_to_addin("execute_text_command", {+ "command": f"Document.CopyToDesktop {output_path}",+ }, timeout=30)++ if not result.get("success"):+ return {+ "success": False,+ "error": f"Document.CopyToDesktop failed: {result.get('error', 'unknown')}",+ "data": {"outputPath": output_path},+ }++ # Verify the file was created+ if os.path.exists(output_path):+ file_size = os.path.getsize(output_path)+ result = {+ "success": True,+ "output": f"Saved library to {os.path.basename(output_path)} ({file_size} bytes)",+ "data": {"outputPath": output_path, "fileSize": file_size},+ }+ else:+ result = {+ "success": True,+ "output": f"Document.CopyToDesktop executed (file may still be writing)",+ "data": {"outputPath": output_path},+ }++ # Auto-screenshot after save — catches blocking dialogs+ return _post_open_screenshot(result, settle_time=1.0)+++# Package types = EPG's Scripts3d module names (each has runWithInput(params)).+# Kept in sync with Autodesk's ElectronicsPackageGenerator internal add-in.+_EPG_TYPES = [+ "axial_diode", "axial_fuse", "axial_polarized_capacitor", "axial_resistor",+ "bga", "chip", "chiparray2sideconvex", "chiparray2sideflat", "chiparray4sideflat",+ "chip_led", "cornerconcave", "crystal", "dfn2", "dfn3", "dfn4", "dip",+ "dip_socket", "dip_socket_dual_leaf", "dpak", "ecap", "female_standoff", "hc49",+ "header_right_angle", "header_right_angle_socket", "header_straight",+ "header_straight_socket", "male_female_standoff", "melf", "molded",+ "oscillator_j", "oscillator_l", "plcc", "qfn", "qfp", "radial_dipped_rect",+ "radial_ecap", "radial_inductor", "radial_round_led", "snap_lock", "sod",+ "sodfl", "soic", "soj", "son", "sot143", "sot223", "sot23", "sotfl",+ "surface_mount_header_female", "surface_mount_pin_header_right_angle",+ "surface_mount_pin_header_straight",+]++# EPG param keys that are NOT lengths (never mm->cm converted).+_EPG_NON_DIMENSION_KEYS = {"DPins", "EPins", "pins", "thermal", "color_r", "color_g", "color_b"}+++def _orchestrate_generate_package(args: dict) -> dict:+ """Generate an IPC-compliant 3D package via Fusion's built-in+ ElectronicsPackageGenerator (EPG), optionally laser-etch a marking into the+ body top, and optionally export STEP - all in ONE headless add-in call.++ Proven live 2026-07-03 (0603 + '103' etch + 233KB STEP, 1.9s generation).+ """+ pkg_type = (args.get("type") or "").strip().lower()+ if pkg_type not in _EPG_TYPES:+ return {+ "success": False,+ "error": f"Unknown package type: {pkg_type!r}",+ "errorCode": "unknown_package_type",+ "supportedTypes": _EPG_TYPES,+ "_hint": "Pass type as one of supportedTypes (EPG Scripts3d module names). "+ "Common: chip (0402/0603/0805 passives), soic, qfn, qfp, bga, sot23, "+ "dfn2, melf, ecap, crystal, header_straight.",+ }++ raw_params = args.get("params") or {}+ units_cm = bool(args.get("unitsCm"))+ params = {}+ for k, v in raw_params.items():+ if (not units_cm) and isinstance(v, (int, float)) and k not in _EPG_NON_DIMENSION_KEYS:+ params[k] = v / 10.0 # mm (datasheet-native) -> cm (Fusion/EPG-native)+ else:+ params[k] = v++ etch = args.get("etch") or ""+ etch_style = (args.get("etchStyle") or "raised").strip().lower() # raised (white, default) | engraved+ etch_depth_cm = float(args.get("etchDepthMm") or 0.03) / 10.0+ etch_height_mm = args.get("etchHeightMm") # None = auto-fit+ output_step = args.get("outputStep") or ""+ bridge_version = BRIDGE_VERSION++ script = f"""+import sys, glob, os, importlib+import adsk.core, adsk.fusion++# Locate EPG across install conventions + versions (NEVER hardcode the webdeploy hash).+_bases = []+for env in ('ProgramFiles', 'ProgramFiles(x86)', 'ProgramW6432', 'LOCALAPPDATA'):+ root = os.environ.get(env)+ if root:+ _bases += glob.glob(os.path.join(root, 'Autodesk', 'webdeploy', 'production', '*',+ 'Api', 'InternalAddins', 'ElectronicsPackageGenerator'))+if not _bases:+ raise RuntimeError('EPG_NOT_FOUND: ElectronicsPackageGenerator not present in this Fusion install')+epg_dir = max(_bases, key=os.path.getmtime)+parent = os.path.dirname(epg_dir)+if parent not in sys.path:+ sys.path.insert(0, parent)++mod = importlib.import_module('ElectronicsPackageGenerator.Scripts3d.{pkg_type}')++doc = app.documents.add(adsk.core.DocumentTypes.FusionDesignDocumentType)+design = adsk.fusion.Design.cast(app.activeProduct)+mod.runWithInput({params!r}, design)+root = design.rootComponent++inv = [{{'name': b.name, 'vol': round(b.volume, 6)}} for b in root.bRepBodies]+etched = None+if {etch!r}:+ # FIND THE TOP SURFACE the robust way (John's algorithm, 2026-07-04):+ # 1. bounding box of the WHOLE chip (all bodies);+ # 2. only faces whose centroid sits in the TOP 5% band of that bbox count;+ # 3. among those upward planar faces take the LARGEST AREA (the molded body+ # top beats terminal tops that tie it on height);+ # 4. lay the text out with a 10% margin inside that face.+ gmin, gmax = 1e9, -1e9+ for b in root.bRepBodies:+ bb = b.boundingBox+ gmin = min(gmin, bb.minPoint.z)+ gmax = max(gmax, bb.maxPoint.z)+ band_z = gmax - 0.05 * (gmax - gmin)+ top_face, top_area = None, -1.0+ for b in root.bRepBodies:+ for f in b.faces:+ if isinstance(f.geometry, adsk.core.Plane) and f.geometry.normal.z > 0.9 \+ and f.centroid.z >= band_z and f.area > top_area:+ top_face, top_area = f, f.area+ if top_face is None:+ raise RuntimeError('ETCH_NO_TOP_FACE: no upward planar face in the top 5% of the chip bbox')+ body = top_face.body+ sk = root.sketches.add(top_face)+ center = sk.modelToSketchSpace(top_face.centroid)+ # Measure the face in SKETCH space (model-space bbox axes can be SWAPPED vs+ # the sketch axes the text lays out in). Project the model bbox corners.+ bb = top_face.boundingBox+ p_min = sk.modelToSketchSpace(bb.minPoint)+ p_max = sk.modelToSketchSpace(bb.maxPoint)+ face_w = abs(p_max.x - p_min.x) # extent along sketch-x (the default text baseline)+ face_h = abs(p_max.y - p_min.y) # extent along sketch-y+ # ALWAYS run the text along the LONGEST axis of the top face (most room for+ # the marking - John's rule, 2026-07-04). If the long axis is sketch-y,+ # rotate the text 90deg rather than squeezing it onto the short axis.+ import math as _math+ rot_deg = 90 if face_h > face_w else 0+ long_len = max(face_w, face_h)+ short_len = min(face_w, face_h)+ w = long_len * 0.80 # 10% margin each side along the long axis+ box_h_cap = short_len * 0.80 # 10% margin each side across it+ # multi-line support (e.g. MPN + variant on line 2): fit per-line+ lines = {etch!r}.split(chr(10))+ n_lines = max(1, len(lines))+ max_chars = max(1, max(len(l) for l in lines))+ # width-aware auto-fit: glyph ~0.75*h wide; line block ~1.35*h per line+ h = ({etch_height_mm!r} / 10.0) if {etch_height_mm!r} else \+ max(0.015, min(box_h_cap / (1.35 * n_lines), w / (0.75 * max_chars)))+ if rot_deg:+ c1 = adsk.core.Point3D.create(center.x - box_h_cap/2, center.y - w/2, 0)+ c2 = adsk.core.Point3D.create(center.x + box_h_cap/2, center.y + w/2, 0)+ else:+ c1 = adsk.core.Point3D.create(center.x - w/2, center.y - box_h_cap/2, 0)+ c2 = adsk.core.Point3D.create(center.x + w/2, center.y + box_h_cap/2, 0)+ ti = sk.sketchTexts.createInput2({etch!r}, h)+ ti.setAsMultiLine(c1, c2,+ adsk.core.HorizontalAlignments.CenterHorizontalAlignment,+ adsk.core.VerticalAlignments.MiddleVerticalAlignment, 0)+ if rot_deg:+ try:+ ti.angle = _math.pi / 2.0+ except Exception:+ rot_deg = 0 # older API without angle - fall back to unrotated+ txt = sk.sketchTexts.add(ti)+ # MEASURE-AND-CORRECT (the margin is a CONTRACT): font metrics vary, so the+ # 0.75h glyph estimate can under-shoot ('ADOM-A' rendered edge-to-edge). After+ # placing, measure the text's real bbox; if it busts the 10%-margin box,+ # recreate at the scaled-down height. Never trust an estimate you can measure.+ fit_iterations = 0+ measured_w = None+ for _pass in range(2):+ tb = txt.boundingBox+ t_ext_x = abs(tb.maxPoint.x - tb.minPoint.x)+ t_ext_y = abs(tb.maxPoint.y - tb.minPoint.y)+ measured_w = max(t_ext_x, t_ext_y) # the baseline-axis extent+ measured_c = min(t_ext_x, t_ext_y) # the cross-axis extent (line stack)+ if measured_w <= w and measured_c <= box_h_cap:+ break+ scale = min(w / max(measured_w, 1e-6), box_h_cap / max(measured_c, 1e-6)) * 0.95+ h = max(0.008, h * scale)+ txt.deleteMe()+ ti = sk.sketchTexts.createInput2({etch!r}, h)+ ti.setAsMultiLine(c1, c2,+ adsk.core.HorizontalAlignments.CenterHorizontalAlignment,+ adsk.core.VerticalAlignments.MiddleVerticalAlignment, 0)+ if rot_deg:+ try:+ ti.angle = _math.pi / 2.0+ except Exception:+ pass+ txt = sk.sketchTexts.add(ti)+ fit_iterations += 1+ marking_geom = {{+ 'chipBBox': {{'min': [round(gmin,5), 0, 0], 'maxZ': round(gmax,5)}},+ 'chipBBoxZ': {{'min': round(gmin,5), 'max': round(gmax,5)}},+ 'topBandZ': round(band_z,5),+ 'topFace': {{'area': round(top_area,6), 'centroidZ': round(top_face.centroid.z,5),+ 'sketchExtents': {{'x': round(face_w,5), 'y': round(face_h,5)}},+ 'body': body.name}},+ 'longAxis': 'sketch-y' if face_h > face_w else 'sketch-x',+ 'rotatedDeg': rot_deg,+ 'textBox': {{'c1': [round(c1.x,5), round(c1.y,5)], 'c2': [round(c2.x,5), round(c2.y,5)],+ 'marginPct': 10}},+ 'textHeightMm': round(h*10,4), 'lines': n_lines, 'maxLineChars': max_chars,+ 'heightAuto': not bool({etch_height_mm!r}),+ 'textWidthMeasuredMm': round(measured_w*10,4) if measured_w else None,+ 'fitIterations': fit_iterations,+ 'why': {{'band': 'top 5% of whole-chip bbox excludes terminal tops that tie the body',+ 'largestArea': 'molded body top beats small terminal faces in the band',+ 'longAxis': 'text runs along the longest face axis for maximum room',+ 'fit': 'h = min(boxH/(1.35*lines), boxW/(0.75*maxChars)) - glyphs ~0.75h wide'}},+ }}+ if {etch_style!r} == 'engraved':+ # engraved (sunken) cut - the pre-2026-07-04 default+ ext_in = root.features.extrudeFeatures.createInput(txt, adsk.fusion.FeatureOperations.CutFeatureOperation)+ ext_in.setDistanceExtent(False, adsk.core.ValueInput.createByReal(-{etch_depth_cm!r}))+ ext_in.participantBodies = [body]+ root.features.extrudeFeatures.add(ext_in)+ else:+ # RAISED WHITE marking (default): humans need CONTRAST - a thin positive+ # extrude painted white on the dark body reads like real silkscreen ink,+ # far better than a same-color emboss. Reuses EPG's own rgb-appearance+ # utility so the white survives Fusion rendering (+ colored STEP).+ ext_in = root.features.extrudeFeatures.createInput(txt, adsk.fusion.FeatureOperations.NewBodyFeatureOperation)+ ext_in.setDistanceExtent(False, adsk.core.ValueInput.createByReal({etch_depth_cm!r}))+ mark = root.features.extrudeFeatures.add(ext_in)+ from ElectronicsPackageGenerator.Utilities import addin_utility as _au+ for i in range(mark.bodies.count):+ mb = mark.bodies.item(i)+ mb.name = 'Marking' if i == 0 else 'Marking' + str(i + 1)+ _au.apply_rgb_appearance(app, design, mb, 255, 255, 255, 'AdomMarkingWhite')+ etched = {etch!r}++step_path = None+if {output_step!r}:+ em = design.exportManager+ opts = em.createSTEPExportOptions({output_step!r})+ em.execute(opts)+ step_path = {output_step!r}++# SIDECAR MANIFEST: every setting we picked + WHY + the bboxes we calculated,+# so a marking refresh (e.g. adding a variant name as a 2nd line) can rework the+# text without re-deriving anything. Written next to the STEP as <step>.manifest.json.+manifest = {{+ 'schema': 'adom-fusion-generate-package/1',+ 'bridgeVersion': {bridge_version!r},+ 'type': {pkg_type!r},+ 'paramsCm': {params!r},+ 'paramsNote': 'paramsCm are EPG-native cm (mm inputs were /10)',+ 'marking': ({{'text': {etch!r}, 'style': {etch_style!r},+ 'depthMm': {etch_depth_cm!r} * 10, **marking_geom}} if etched else None),+ 'bodies': inv,+ 'outputs': {{'step': step_path}},+ 'refresh': {{+ 'howTo': 'To re-mark (e.g. add a variant on line 2): call fusion_generate_package again '+ 'with the SAME type+paramsCm (unitsCm:true) and the new multi-line etch text '+ '(join lines with a newline). The generator is deterministic, so geometry and '+ 'placement reproduce exactly; this manifest carries the boxes/height the last '+ 'run chose for comparison.',+ 'example': {{'type': {pkg_type!r}, 'unitsCm': True, 'params': 'paramsCm from this file',+ 'etch': ({etch!r} + chr(10) + 'VARIANT') if etched else 'MPN' + chr(10) + 'VARIANT'}},+ }},+}}+manifest_path = None+if step_path:+ manifest_path = step_path + '.manifest.json'+ import json as _json+ with open(manifest_path, 'w', encoding='utf-8') as _f:+ _json.dump(manifest, _f, indent=2)++vp = app.activeViewport+cam = vp.camera+cam.isSmoothTransition = False+cam.target = adsk.core.Point3D.create(0, 0, 0)+cam.eye = adsk.core.Point3D.create(0.25, -0.35, 0.45)+cam.upVector = adsk.core.Vector3D.create(0, 0, 1)+cam.isFitView = True+vp.camera = cam+vp.refresh()++result = {{'type': {pkg_type!r}, 'bodies': inv, 'etched': etched, 'stepPath': step_path,+ 'manifest': manifest, 'manifestPath': manifest_path,+ 'epgDir': epg_dir, 'doc': app.activeDocument.name}}+"""++ res = _proxy_to_addin("run_modeling_script", {"script": script}, timeout=110)+ if not res.get("success"):+ err = (res.get("error") or "")+ if "EPG_NOT_FOUND" in err:+ res["errorCode"] = "epg_not_found"+ res["_hint"] = ("Fusion's built-in ElectronicsPackageGenerator add-in was not found in "+ "this install (ships with 2024+ Fusion under webdeploy .../Api/"+ "InternalAddins). Update Fusion, or build the part with "+ "fusion_make_3d_package from a STEP instead.")+ return res++ data = res.get("data") or {}+ inner = data.get("result") or {}+ return {+ "success": True,+ "output": json.dumps(inner),+ **inner,+ "_hint": ("Generated an IPC-compliant parametric package via Fusion's built-in EPG"+ + (f", marked '{etch}' on the chip top as {'an engraved cut' if etch_style == 'engraved' else 'RAISED WHITE text (silkscreen-style - high contrast on the dark body)'}" if etch else "")+ + (f", exported STEP to {output_step}" if output_step else "")+ + ". NEXT: fusion_screenshot_fusion for a visual check; pull_file the STEP for KiCad. "+ "HOW THE MARKING WORKS: the top surface is found from the WHOLE-chip bounding box - "+ "only upward planar faces in the top 5% height band count, largest area wins - and "+ "the text is laid out with a 10% margin inside that face, auto-fit width-aware "+ "(override with etchHeightMm). Default style is raised+white for CONTRAST (humans "+ "can't read a dark-on-dark emboss); pass etchStyle:'engraved' for a sunken laser cut. "+ "The text ALWAYS runs along the LONGEST axis of the top face (rotated 90deg when "+ "needed) for maximum room; the 10% margin is ENFORCED by measuring the placed text bbox "+ "and shrink-to-fitting (see fitIterations in the manifest). Multi-line markings work - join lines with a newline "+ "(e.g. 'LM358' + newline + 'ADOM-A' for MPN + variant; per-line auto-fit). "+ "SIDECAR MANIFEST: when outputStep is set, <step>.manifest.json records every "+ "setting picked + WHY + the calculated bboxes (chip bbox, top band, face extents, "+ "text box, height, rotation) - to refresh a marking (add a variant line), re-call "+ "this verb with the manifest's type + paramsCm (unitsCm:true) + the new etch text; "+ "generation is deterministic so placement reproduces exactly. The manifest is also "+ "returned inline as 'manifest'. "+ "PITFALLS: params are mm by default (unitsCm:true for EPG-native cm); dimension names "+ "follow each EPG generator (chip: D/E/A/L/L1; soic: A/A1/b/D/E/E1/e/L/DPins); raised "+ "markings are separate 'Marking' bodies (white in Fusion + colored STEP)."),+ }+++# ── Optimized-GLB pipeline (service-step2glb "molecule mode") ──────────────────+# A raw STEP->GLB tessellation (fusion_export_step + a plain step2glb convert) leaves+# EVERY solid/pad/via as its own draw call - a real board comes out ~16MB / ~25,000+# primitives that HALTS the viewer's GPU on rotation (John hit this live 2026-07-14).+# service-step2glb's molecule mode is the OCCT-side replacement for Colby's Blender+# molecule-converter: tessellate -> anchor to the MP machine pins -> (optional) bake+# silkscreen -> gold pins -> dedup/flatten/JOIN/weld/prune -> Draco. Same board comes+# out ~465KB / ~31 draw calls, auto-oriented. We call it straight from the bridge so+# a Fusion board becomes a wiki-grade GLB in ONE verb. Reachable from the box (it is a+# public *.adom.cloud host, same as the wiki the bridge already streams from).+STEP2GLB_URL = (os.environ.get("ADOM_STEP2GLB_URL")+ or "https://step2glb-gmdoncpxdwx0.adom.cloud").rstrip("/")+++def _multipart_body(fields: dict, files: list):+ """Build a multipart/form-data body. files = [(field, filename, bytes, content_type)]."""+ boundary = "----AdomBridgeGLB%d%d" % (int(_time.time() * 1000), os.getpid())+ out = []+ for k, v in (fields or {}).items():+ out.append(("--" + boundary).encode())+ out.append(('Content-Disposition: form-data; name="%s"' % k).encode())+ out.append(b"")+ out.append(str(v).encode())+ for field, filename, data, ctype in (files or []):+ out.append(("--" + boundary).encode())+ out.append(('Content-Disposition: form-data; name="%s"; filename="%s"'+ % (field, filename)).encode())+ out.append(("Content-Type: %s" % (ctype or "application/octet-stream")).encode())+ out.append(b"")+ out.append(data)+ out.append(("--" + boundary + "--").encode())+ out.append(b"")+ return b"\r\n".join(out), boundary+++# A User-Agent is REQUIRED on every service call. The *.adom.cloud edge WAF 403s the+# default "Python-urllib/x.y" UA (confirmed live 2026-07-14) while a browser/curl UA+# gets 200. Set it on the POST AND both polls or the call fails with an opaque 403.+def _service_ua():+ return "adom-fusion-bridge/%s" % BRIDGE_VERSION+++def _service_glb_submit(step_path: str, silk_top: str = None, silk_bottom: str = None,+ pin: str = "medium", job_name: str = "fusion-board") -> dict:+ """POST a STEP (+ optional silk PNGs) to service-step2glb molecule mode. Returns+ {ok, jobId} - does NOT wait. Pure urllib (no requests on the box)."""+ try:+ with open(step_path, "rb") as f:+ step_bytes = f.read()+ except Exception as e:+ return {"ok": False, "error": "could not read STEP: %s" % e}+ files = [("step", os.path.basename(step_path), step_bytes, "application/step")]+ for field, p in (("silk_top", silk_top), ("silk_bottom", silk_bottom)):+ if p and os.path.exists(p):+ try:+ with open(p, "rb") as f:+ files.append((field, os.path.basename(p), f.read(), "image/png"))+ except Exception:+ pass+ body, boundary = _multipart_body({}, files)+ headers = {+ "Content-Type": "multipart/form-data; boundary=" + boundary,+ "X-Client": "fusion-bridge/adom",+ "X-Job-Name": job_name,+ "User-Agent": _service_ua(),+ }+ url = "%s/convert?molecule=true&pin=%s" % (STEP2GLB_URL, urllib.parse.quote(pin))+ try:+ req = urllib.request.Request(url, data=body, headers=headers, method="POST")+ with urllib.request.urlopen(req, timeout=90) as resp:+ queued = json.loads(resp.read().decode("utf-8", "replace"))+ except Exception as e:+ return {"ok": False, "error": "service POST failed: %s" % e,+ "_hint": "Is %s reachable from this box? Override with ADOM_STEP2GLB_URL." % STEP2GLB_URL}+ job_id = queued.get("job_id")+ if not job_id:+ return {"ok": False, "error": "service did not return a job_id", "raw": queued}+ return {"ok": True, "jobId": job_id}+++def _service_glb_fetch(job_id: str, out_path: str = None, poll_s: int = 0) -> dict:+ """Poll a service job for up to poll_s seconds; if complete, optionally write the+ GLB to out_path. Returns {ok, status, stats, glbBytes?, wrote?}. poll_s=0 = one check."""+ ua = _service_ua()+ stats, deadline, first = {}, _time.time() + max(0, poll_s), True+ while first or _time.time() < deadline:+ first = False+ try:+ preq = urllib.request.Request("%s/jobs/%s" % (STEP2GLB_URL, job_id), headers={"User-Agent": ua})+ with urllib.request.urlopen(preq, timeout=20) as r:+ stats = json.loads(r.read().decode("utf-8", "replace"))+ except Exception:+ _time.sleep(4); continue+ st = stats.get("status")+ if st == "complete":+ break+ if st == "error":+ return {"ok": False, "status": "error", "error": stats.get("error") or stats, "stats": stats}+ if _time.time() >= deadline:+ return {"ok": True, "status": st or "processing", "stats": stats}+ _time.sleep(6)+ if stats.get("status") != "complete":+ return {"ok": True, "status": stats.get("status") or "processing", "stats": stats}+ try:+ rreq = urllib.request.Request("%s/jobs/%s/result" % (STEP2GLB_URL, job_id), headers={"User-Agent": ua})+ with urllib.request.urlopen(rreq, timeout=60) as r:+ glb = r.read()+ except Exception as e:+ return {"ok": False, "status": "complete", "error": "download failed: %s" % e, "stats": stats}+ wrote = None+ if out_path:+ try:+ with open(out_path, "wb") as f:+ f.write(glb)+ wrote = out_path+ except Exception as e:+ return {"ok": False, "status": "complete", "error": "could not write GLB: %s" % e, "stats": stats}+ return {"ok": True, "status": "complete", "stats": stats, "glbBytes": glb, "wrote": wrote}+++def _orchestrate_export_optimized_glb(args: dict) -> dict:+ """Fusion board -> wiki-grade optimized GLB in one call. Exports the active design's+ STEP, (optionally) the top/bottom silkscreen, runs it through service-step2glb's+ molecule pipeline (anchor + optional silk bake + gold pins + join/weld/prune + Draco),+ and writes the small, fast, auto-anchored GLB to outputPath. See STEP2GLB_URL above."""+ output_path = args.get("outputPath") or args.get("output_path")+ if not output_path:+ return {"success": False, "error": "No outputPath specified.",+ "_hint": 'Usage: fusion_export_optimized_glb {"outputPath":"C:/tmp/board.glb", "silkscreen":true, "pin":"medium"}'}+ if not output_path.lower().endswith(".glb"):+ output_path = output_path + ".glb"+ pin = args.get("pin", "medium")+ want_silk = args.get("silkscreen", True)+ # 1) Need a 3D Design product for STEP. Switch to the 3D board (no-op if already 3D).+ _proxy_to_addin("show_3d_board", {}, timeout=60)+ # 2) Export STEP next to the target GLB.+ step_path = output_path[:-4] + ".step"+ step_res = _proxy_to_addin("export_step", {"outputPath": step_path}, timeout=300)+ if not step_res.get("success"):+ return {"success": False, "error": "STEP export failed: %s" % (step_res.get("error") or step_res.get("message")),+ "step": step_res, "_hint": "The active design must be a 3D Design product (fusion_show_3d_board first)."}+ # 3) Optional silkscreen (best-effort; the GLB is great without it).+ silk_top = silk_bottom = None+ silk_note = None+ if want_silk:+ st = output_path[:-4] + "_silk_top.png"+ sb = output_path[:-4] + "_silk_bottom.png"+ rt = _proxy_to_addin("take_silkscreen_screenshot", {"outputPath": st, "layer": "top"}, timeout=90)+ rb = _proxy_to_addin("take_silkscreen_screenshot", {"outputPath": sb, "layer": "bottom"}, timeout=90)+ if rt.get("success"):+ silk_top = st+ if rb.get("success"):+ silk_bottom = sb+ if not (silk_top or silk_bottom):+ silk_note = "silkscreen capture unavailable on this design; GLB built without a baked silk texture."+ # 4) Submit to the molecule optimizer (fire-and-return: a big board's tessellation+ # can run minutes, longer than the AD relay's request timeout, so we do NOT block+ # the whole time here). Then bounded-wait up to `wait` seconds (default 90) so+ # small boards still come back complete in one call.+ job_name = os.path.splitext(os.path.basename(output_path))[0]+ sub = _service_glb_submit(step_path, silk_top, silk_bottom, pin=pin, job_name=job_name)+ if not sub.get("ok"):+ return {"success": False, "error": sub.get("error"), "_hint": sub.get("_hint"),+ "stepPath": step_path, "note": "STEP exported OK; optimize submit failed."}+ job_id = sub["jobId"]+ wait_s = int(args.get("wait", 15)) # keep total (STEP export + wait) under the ~60s relay timeout+ fetched = _service_glb_fetch(job_id, out_path=output_path, poll_s=wait_s) if wait_s > 0 else {"ok": True, "status": "processing"}+ status_url = "%s/jobs/%s" % (STEP2GLB_URL, job_id)+ result_url = "%s/jobs/%s/result" % (STEP2GLB_URL, job_id)+ if fetched.get("status") == "complete" and fetched.get("wrote"):+ stats = fetched.get("stats") or {}+ size = len(fetched.get("glbBytes") or b"")+ return {+ "success": True, "status": "complete",+ "glbPath": output_path, "stepPath": step_path, "jobId": job_id,+ "silkTop": silk_top, "silkBottom": silk_bottom,+ "meshesBefore": stats.get("meshes_before"), "meshesAfter": stats.get("meshes_after"),+ "sizeBytes": size, "moleculeAnchored": stats.get("molecule_anchored"),+ "silkscreenApplied": stats.get("silkscreen_applied"), "note": silk_note,+ "message": "Optimized GLB written (%d KB, meshes %s->%s, anchored=%s, silk=%s) to %s" % (+ size // 1024, stats.get("meshes_before"), stats.get("meshes_after"),+ stats.get("molecule_anchored"), stats.get("silkscreen_applied"), output_path),+ "_hint": "Pull it with pull_file, then set it as component.parts.model_3d on a wiki component page. "+ "Same optimizer as the molecule GLBs (Colby's pipeline).",+ }+ if not fetched.get("ok"):+ return {"success": False, "error": fetched.get("error"), "jobId": job_id, "stepPath": step_path,+ "statusUrl": status_url, "resultUrl": result_url}+ # Still processing after the bounded wait - hand back the job so the caller finishes it.+ return {+ "success": True, "status": fetched.get("status") or "processing",+ "pending": True, "jobId": job_id, "stepPath": step_path,+ "glbPath": output_path, "silkTop": silk_top, "silkBottom": silk_bottom, "note": silk_note,+ "statusUrl": status_url, "resultUrl": result_url,+ "message": "Optimize job %s submitted; still processing after %ds. Finish it with "+ "fusion_fetch_optimized_glb {\"jobId\":\"%s\",\"outputPath\":\"%s\"} (re-call until complete)." % (+ job_id, wait_s, job_id, output_path),+ "_hint": "Big boards tessellate for a few minutes. Either re-call fusion_fetch_optimized_glb "+ "with this jobId (writes the GLB on the box when ready), or GET the resultUrl directly.",+ }+++def _orchestrate_fetch_optimized_glb(args: dict) -> dict:+ """Fetch a previously-submitted optimize job's GLB (from fusion_export_optimized_glb's+ jobId). Bounded poll (default 90s); writes to outputPath when complete."""+ job_id = args.get("jobId")+ if not job_id:+ return {"success": False, "error": "No jobId.",+ "_hint": 'Usage: fusion_fetch_optimized_glb {"jobId":"...","outputPath":"C:/tmp/board.glb"}'}+ out_path = args.get("outputPath") or args.get("output_path")+ if out_path and not out_path.lower().endswith(".glb"):+ out_path = out_path + ".glb"+ wait_s = int(args.get("wait", 15)) # keep total (STEP export + wait) under the ~60s relay timeout+ res = _service_glb_fetch(job_id, out_path=out_path, poll_s=wait_s)+ status_url = "%s/jobs/%s" % (STEP2GLB_URL, job_id)+ result_url = "%s/jobs/%s/result" % (STEP2GLB_URL, job_id)+ if res.get("status") == "complete" and res.get("wrote"):+ stats = res.get("stats") or {}+ size = len(res.get("glbBytes") or b"")+ return {"success": True, "status": "complete", "glbPath": out_path, "jobId": job_id,+ "sizeBytes": size, "meshesAfter": stats.get("meshes_after"),+ "moleculeAnchored": stats.get("molecule_anchored"), "silkscreenApplied": stats.get("silkscreen_applied"),+ "message": "Optimized GLB written (%d KB) to %s" % (size // 1024, out_path)}+ if not res.get("ok"):+ return {"success": False, "error": res.get("error"), "jobId": job_id, "statusUrl": status_url, "resultUrl": result_url}+ return {"success": True, "status": res.get("status") or "processing", "pending": True, "jobId": job_id,+ "statusUrl": status_url, "resultUrl": result_url,+ "message": "Job %s still %s; re-call fusion_fetch_optimized_glb to finish." % (job_id, res.get("status") or "processing")}+++def _describe_profile(p: dict) -> str:+ """Human, UNAMBIGUOUS name for a browser profile - never just 'your browser'.++ A power user has several (personal / work / media), so a demo that says "it's in your+ Chrome" is useless (John, 2026-07-20). Produce e.g.+ "Chrome - John Personal ([email protected])" or "Edge - Default"."""+ browser = (p.get("browser") or "browser").strip()+ browser = {"chrome": "Chrome", "edge": "Edge", "brave": "Brave"}.get(browser.lower(), browser.title())+ name = (p.get("displayName") or "").strip()+ email = (p.get("email") or "").strip()+ bits = browser+ if name and email and name.lower() not in email.lower():+ bits += " - %s (%s)" % (name, email)+ elif email:+ bits += " - %s" % email+ elif name:+ bits += " - %s" % name+ elif p.get("profileDir"):+ bits += " - %s" % p.get("profileDir")+ return bits+++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."""+ why = []+ try:+ if ad_client.available():+ r = ad_client.call(verb, args, timeout=timeout)+ if isinstance(r, dict):+ inner = r.get("output")+ if isinstance(inner, str) and inner.strip().startswith("{"):+ import json as _j0+ try:+ return _j0.loads(inner)+ except Exception:+ return r+ return r+ why.append("ad_client.call returned %r" % type(r).__name__)+ else:+ why.append("ad_client unavailable")+ except Exception as e:+ why.append("ad_client raised %s" % e)+ try:+ import subprocess as _sp, json as _j+ exe = _find_adom_desktop_cli()+ if not exe:+ return {"_adCallError": "; ".join(why + ["adom-desktop 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("{"):+ d = _j.loads(out)+ inner = d.get("output")+ if isinstance(inner, str) and inner.strip().startswith("{"):+ try:+ return _j.loads(inner)+ except Exception:+ return d+ return d+ why.append("cli stdout not json: %s" % (out[:120] or (p.stderr or "")[:120]))+ except Exception as e:+ why.append("cli raised %s" % e)+ return {"_adCallError": "; ".join(why)}+++# ── FUSION AUTO-UPDATE (John, 2026-07-22) ────────────────────────────────────────────────────+# "fusion ships updates non-stop and its annoying to click this. i just always want the latest+# fusion... just make this generally invisible to me and do that by default for all other adom+# users. but let them tell you they don't want you doing that and you make that a sticky setting."+#+# So: ON BY DEFAULT for everyone, applied silently in the BACKGROUND, opt-out is sticky per user.+# Fusion's own updater downloads in the background and swaps the build on the next launch, so all+# we have to do is stop making the human click "Update Now" on a nag panel.+def _bridge_prefs_path():+ import pathlib+ d = pathlib.Path(os.path.expanduser("~")) / ".adom" / "fusion-bridge"+ d.mkdir(parents=True, exist_ok=True)+ return d / "prefs.json"+++def _get_bridge_pref(key: str, default=None):+ try:+ import json as _j+ p = _bridge_prefs_path()+ if p.exists():+ v = (_j.loads(p.read_text()) or {})+ return v.get(key, default)+ except Exception:+ pass+ return default+++def _set_bridge_pref(key: str, value) -> bool:+ try:+ import json as _j+ p = _bridge_prefs_path()+ cur = {}+ if p.exists():+ try: cur = _j.loads(p.read_text()) or {}+ except Exception: cur = {}+ cur[key] = value+ p.write_text(_j.dumps(cur, indent=2))+ return True+ except Exception:+ return False+++def _auto_update_enabled() -> bool:+ """Default TRUE. Only a user who explicitly opted out gets False, and that sticks."""+ return bool(_get_bridge_pref("autoUpdateFusion", True))+++# Buttons Fusion puts on its update nag / Job Status panel, best-first.+_UPDATE_BUTTONS = ("Update Now", "Update now", "Install Now", "Restart Now")+++def _find_update_offer(hwnd=None) -> dict:+ """Is Fusion offering an update right now? Detected via UIA (background, no foreground).+ Returns {offered, button, hwnd, detail} - never raises."""+ try:+ hw = hwnd or _fusion_window_hwnd()+ if not hw:+ return {"offered": False}+ # Scan the main window AND Fusion's owned popups - the update nag lives in the+ # "View Job Status" panel, which is a CHILD window and is only in the UIA tree while it+ # is open. Checking both is why this runs on a schedule rather than once.+ candidates = [int(hw)]+ try:+ for w in (family_windows(int(hw)) or []):+ h2 = w.get("hwnd") if isinstance(w, dict) else w+ if h2 and int(h2) != int(hw):+ candidates.append(int(h2))+ except Exception:+ pass+ for h in candidates:+ for label in _UPDATE_BUTTONS:+ r = _unwrap(_ad_call("desktop_find_control", {"hwnd": h, "name": label}, timeout=20))+ best = r.get("best") or {}+ if best.get("name") and best.get("invokable"):+ return {"offered": True, "button": best.get("name"), "hwnd": h,+ "detail": "Fusion is offering an update ('%s' is on screen)" % best.get("name")}+ return {"offered": False, "hwnd": hw}+ except Exception:+ return {"offered": False}+++def _apply_fusion_update_silently(hwnd=None) -> dict:+ """Click Fusion's update button IN THE BACKGROUND so the download starts and the new build+ is applied on the next launch. No foreground, no caption - the user asked for this to be+ invisible. Returns {applied, button, why}."""+ if not _auto_update_enabled():+ return {"applied": False, "why": "user opted out (sticky pref autoUpdateFusion=false)"}+ offer = _find_update_offer(hwnd)+ if not offer.get("offered"):+ return {"applied": False, "why": "no update offered"}+ r = _unwrap(_ad_call("desktop_ui_click",+ {"hwnd": int(offer["hwnd"]), "name": offer["button"]}, timeout=30))+ ok = bool(r.get("invoked") or r.get("success"))+ if ok:+ print("[Fusion Bridge] auto-update: clicked %r in the background" % offer["button"])+ return {"applied": ok, "button": offer["button"],+ "why": ("clicked %r in the background; Fusion downloads now and swaps on next launch"+ % offer["button"]) if ok else "found the button but the UIA invoke did not take"}+++def _handle_set_auto_update(fusion_info: dict, args: dict) -> dict:+ """Turn Fusion auto-updating on/off. STICKY - remembered for this user forever."""+ if "enabled" not in (args or {}):+ cur = _auto_update_enabled()+ return {"success": True, "autoUpdateFusion": cur,+ "_hint": ("Fusion auto-update is currently %s. It is ON BY DEFAULT: the bridge "+ "clicks Fusion's 'Update Now' for the user in the BACKGROUND so they "+ "always run the latest build and never see the nag. To opt out: "+ "fusion_set_auto_update {\"enabled\": false} - that choice is STICKY."+ % ("ON" if cur else "OFF")),+ "statusVerb": "fusion_readiness"}+ enabled = bool(args.get("enabled"))+ _set_bridge_pref("autoUpdateFusion", enabled)+ return {"success": True, "autoUpdateFusion": enabled,+ "narrate": ("I'll keep Fusion updated automatically in the background."+ if enabled else+ "I'll stop auto-updating Fusion. You'll see Autodesk's own update prompts."),+ "_hint": ("Saved and sticky (~/.adom/fusion-bridge/prefs.json). %s"+ % ("Auto-update ON: the bridge silently clicks 'Update Now' whenever Fusion "+ "offers one, so the user always runs the newest build. Tell them it is "+ "handled and they never need to click it."+ if enabled else+ "Auto-update OFF: the bridge will NOT touch update prompts; the user "+ "handles Autodesk's nag themselves. Do not override this.")),+ "statusVerb": "fusion_readiness"}+++# Registered here, NOT in the COMMAND_HANDLERS literal: that dict is built at line ~1699,+# long before this function exists, so naming it there raised NameError at import and+# crash-looped the whole bridge (done exactly that, 2026-07-22).+COMMAND_HANDLERS["set_auto_update"] = _handle_set_auto_update+++# ── AUTODESK FUSION MCP SERVER PROXY (John, 2026-07-23) ──────────────────────────────────────+# Autodesk + Anthropic shipped a Fusion MCP server: a LOCAL HTTP/JSON-RPC endpoint at+# http://127.0.0.1:27182/mcp that exposes Fusion's own text-to-CAD surface (read geometry,+# execute/update features, read Electronics design data).+#+# WHY THE BRIDGE PROXIES IT: that server binds LOOPBACK on the user's machine. It was designed+# for Claude Desktop running on the same box. An Adom AI runs in a CLOUD container and cannot+# reach the user's 127.0.0.1 at all. This bridge already runs on that machine, so it is exactly+# the right proxy: these verbs give the cloud AI Autodesk's MCP tools without reimplementing them.+#+# The server speaks MCP streamable-HTTP: you must `initialize`, capture the MCP-Session-Id+# response header, send notifications/initialized, and pass that header on every later call.+# Skipping it returns 400 "Missing MCP-Session-Id".+_MCP_URL = "http://127.0.0.1:27182/mcp"+_mcp_session_id = None+_mcp_lock = threading.Lock()+++def _mcp_post(body: dict, sid: str = None, timeout: int = 30) -> dict:+ import urllib.request, urllib.error+ h = {"Content-Type": "application/json", "Accept": "application/json, text/event-stream"}+ if sid:+ h["MCP-Session-Id"] = sid+ req = urllib.request.Request(_MCP_URL, method="POST",+ data=json.dumps(body).encode(), headers=h)+ try:+ with urllib.request.urlopen(req, timeout=timeout) as r:+ return {"status": r.status, "headers": dict(r.headers),+ "body": r.read().decode("utf-8", "replace")}+ except urllib.error.HTTPError as e:+ return {"httpError": e.code, "headers": dict(e.headers),+ "body": e.read().decode("utf-8", "replace")[:1000]}+ except Exception as e:+ return {"error": "%s: %s" % (type(e).__name__, str(e)[:200])}+++def _mcp_new_session() -> dict:+ """initialize + notifications/initialized. Returns {sessionId, serverInfo} or {error}."""+ 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",+ "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")}+ sid = None+ for k, v in (r.get("headers") or {}).items():+ if k.lower() == "mcp-session-id":+ sid = v+ info = {}+ try:+ info = (json.loads(r.get("body") or "{}").get("result") or {}).get("serverInfo") or {}+ except Exception:+ pass+ if sid:+ _mcp_post({"jsonrpc": "2.0", "method": "notifications/initialized"}, sid)+ _mcp_session_id = sid+ return {"sessionId": sid, "serverInfo": info}+++def _mcp_rpc(method: str, params: dict = None, timeout: int = 60) -> dict:+ """One JSON-RPC call with automatic session (re)establishment."""+ global _mcp_session_id+ with _mcp_lock:+ if not _mcp_session_id:+ s = _mcp_new_session()+ if s.get("error"):+ return {"_mcpError": s["error"]}+ sid = _mcp_session_id+ body = {"jsonrpc": "2.0", "id": 7, "method": method}+ if params is not None:+ body["params"] = params+ r = _mcp_post(body, sid, timeout=timeout)+ # a dropped/rotated session shows up as 400 Missing/Invalid session; re-init once+ if r.get("httpError") in (400, 404) and "ession" in str(r.get("body", "")):+ with _mcp_lock:+ _mcp_session_id = None+ s = _mcp_new_session()+ if s.get("error"):+ return {"_mcpError": s["error"]}+ sid = _mcp_session_id+ r = _mcp_post(body, sid, timeout=timeout)+ if r.get("error") or r.get("httpError"):+ return {"_mcpError": r.get("error") or ("HTTP %s: %s" % (r.get("httpError"), r.get("body", "")[:200]))}+ try:+ return json.loads(r.get("body") or "{}")+ except Exception:+ return {"_mcpError": "unparseable response", "raw": (r.get("body") or "")[:400]}+++_MCP_OFF_STATIC = (+ "Fusion's MCP server is not reachable on 127.0.0.1:27182. It is OFF by default. The bridge "+ "can turn it on FOR the user: call fusion_mcp_enable, which opens Preferences, expands "+ "General, selects API, ticks 'Fusion MCP Server (runs locally on this device)' and clicks "+ "Apply/OK. It needs Fusion running and briefly uses the foreground (announced with an "+ "on-screen caption). Manual path if you prefer: profile icon (top right) -> Preferences -> "+ "General -> API -> tick the box. NOTE the setting does NOT survive if Fusion is killed before "+ "Apply is clicked."+)+++def _mcp_off_hint() -> str:+ """The MCP-unreachable hint, made state-aware: never tell the AI to go enable MCP when APS+ is signed in and could serve the request RIGHT NOW with zero setup."""+ r = _search_router()+ lead = ""+ if r.get("apsReady"):+ lead = ("NOTE FIRST: APS is signed in, so for FILE SEARCH you do not need MCP at all - "+ "fusion_aps_search works right now. Only enable MCP if you need its other surface "+ "(script text-to-CAD, screenshots, electronics object-model reads). ")+ elif r.get("apsConfigured"):+ lead = ("NOTE: for FILE SEARCH, APS is configured and only needs fusion_aps_signin "+ "(silent on a warm SSO session) - that may be less disruptive than enabling MCP. ")+ return lead + _MCP_OFF_STATIC+++def _handle_mcp_status(fusion_info: dict, args: dict) -> dict:+ """Is Autodesk's Fusion MCP server up, and what does it expose?"""+ s = _mcp_new_session()+ if s.get("error"):+ return {"success": True, "enabled": False, "url": _MCP_URL, "error": s.get("error"),+ "_hint": _mcp_off_hint(), "statusVerb": "fusion_mcp_status"}+ tools = []+ tl = _mcp_rpc("tools/list")+ if not tl.get("_mcpError"):+ tools = [{"name": t.get("name"),+ "description": (t.get("description") or "").split("\n")[0][:160]}+ for t in ((tl.get("result") or {}).get("tools") or [])]+ return {"success": True, "enabled": True, "url": _MCP_URL,+ "sessionId": s.get("sessionId"), "serverInfo": s.get("serverInfo"),+ "toolCount": len(tools), "tools": tools,+ "_hint": ("Autodesk's Fusion MCP server is LIVE. Its tools are Autodesk's own, not this "+ "bridge's: call them with fusion_mcp_call {tool, arguments}. Use "+ "fusion_mcp_tools for full input schemas and fusion_mcp_resources for the "+ "Electronics entity schemas. These COMPLEMENT the fusion_* verbs: prefer a "+ "native verb when one exists (it is tested and returns richer hints), and "+ "reach for MCP for Autodesk's text-to-CAD surface."),+ "statusVerb": "fusion_mcp_status"}+++def _handle_mcp_tools(fusion_info: dict, args: dict) -> dict:+ """Full tool list WITH input schemas."""+ tl = _mcp_rpc("tools/list")+ if tl.get("_mcpError"):+ return {"success": False, "enabled": False, "error": tl["_mcpError"], "_hint": _mcp_off_hint()}+ tools = (tl.get("result") or {}).get("tools") or []+ return {"success": True, "count": len(tools), "tools": tools,+ "_hint": "Call one with fusion_mcp_call {\"tool\":\"<name>\",\"arguments\":{...}}.",+ "statusVerb": "fusion_mcp_status"}+++def _handle_mcp_call(fusion_info: dict, args: dict) -> dict:+ """Call ANY tool on Autodesk's Fusion MCP server."""+ tool = (args or {}).get("tool")+ if not tool:+ return {"success": False, "error": "Pass {tool, arguments}.",+ "_hint": "fusion_mcp_tools lists the available tools and their input schemas."}+ r = _mcp_rpc("tools/call", {"name": tool, "arguments": (args or {}).get("arguments") or {}},+ timeout=int((args or {}).get("timeout") or 120))+ if r.get("_mcpError"):+ return {"success": False, "error": r["_mcpError"], "_hint": _mcp_off_hint()}+ res = r.get("result") or {}+ return {"success": not res.get("isError", False), "tool": tool, "result": res,+ "_hint": ("Autodesk MCP tool result. Content is usually a list of {type,text} blocks. "+ "If it complains about no active document, open one first (fusion_aps_open) "+ "- MCP acts on the ACTIVE Fusion document."),+ "statusVerb": "fusion_mcp_status"}+++def _handle_mcp_resources(fusion_info: dict, args: dict) -> dict:+ """List, or read, Autodesk's MCP resources (the Electronics entity schemas)."""+ uri = (args or {}).get("uri")+ if uri:+ r = _mcp_rpc("resources/read", {"uri": uri})+ if r.get("_mcpError"):+ return {"success": False, "error": r["_mcpError"], "_hint": _mcp_off_hint()}+ return {"success": True, "uri": uri, "result": r.get("result"),+ "statusVerb": "fusion_mcp_status"}+ r = _mcp_rpc("resources/list")+ if r.get("_mcpError"):+ return {"success": False, "error": r["_mcpError"], "_hint": _mcp_off_hint()}+ res = (r.get("result") or {}).get("resources") or []+ return {"success": True, "count": len(res),+ "resources": [x.get("uri") for x in res],+ "_hint": "Read one with fusion_mcp_resources {\"uri\":\"resource://...\"}.",+ "statusVerb": "fusion_mcp_status"}+++def _handle_mcp_enable(fusion_info: dict, args: dict) -> dict:+ """Turn Autodesk's MCP server ON by driving Fusion's Preferences dialog.++ Autodesk exposes NO API for this toggle (apiPreferences has debuggingPort and+ isDeveloperToolsEnabled but nothing for MCP), so the UI is the only route. Proven live+ 2026-07-23. The tree children under General are rendered lazily by Qt and are NOT in the+ UIA tree, so the section click is image-space and DOES take the foreground - announced.+ """+ import time as _t+ if not _unwrap(_handle_fusion_readiness({}, {})).get("running"):+ return {"success": False, "error": "Fusion is not running.",+ "_hint": "fusion_start first, then fusion_mcp_enable."}+ already = _mcp_new_session()+ if not already.get("error"):+ return {"success": True, "enabled": True, "alreadyOn": True,+ "_hint": "Already enabled; nothing to do. fusion_mcp_status shows the tools."}++ _announce_foreground("turning on Fusion's MCP server in preferences")+ steps = []+ r = _unwrap(_ad_call("fusion_execute_text_command", {"command": "Commands.Start PreferencesCommand"}, timeout=45))+ _t.sleep(3)+ dlg = None+ for w in (_unwrap(_ad_call("desktop_list_windows", {}, timeout=40)).get("windows") or []):+ if str(w.get("title", "")).strip() == "Preferences":+ dlg = w.get("hwnd")+ if not dlg:+ return {"success": False, "error": "Preferences dialog did not open.",+ "steps": steps, "_hint": _mcp_off_hint()}+ steps.append("opened Preferences")++ def shot():+ s = _unwrap(_ad_call("desktop_screenshot_window", {"hwnd": int(dlg)}, timeout=45))+ cm = s.get("coordMap") or {}+ img = cm.get("image") or {}+ return cm.get("shotId"), (img.get("w") or 1400), (img.get("h") or 836)++ sid_, W, H = shot()+ if not sid_:+ return {"success": False, "error": "could not capture Preferences", "steps": steps}+ # fractional coords, measured on the real dialog (1400x836): expand arrow, then API row+ _ad_call("desktop_click", {"space": "image", "shotId": sid_, "hwnd": int(dlg),+ "x": int(W * 0.029), "y": int(H * 0.092)}, timeout=30) # expand General+ steps.append("expanded General")+ _t.sleep(2)+ sid_, W, H = shot()+ _ad_call("desktop_click", {"space": "image", "shotId": sid_, "hwnd": int(dlg),+ "x": int(W * 0.071), "y": int(H * 0.135)}, timeout=30) # API row+ steps.append("selected API")+ _t.sleep(2)+ sid_, W, H = shot()+ _ad_call("desktop_click", {"space": "image", "shotId": sid_, "hwnd": int(dlg),+ "x": int(W * 0.493), "y": int(H * 0.569)}, timeout=30) # the checkbox+ steps.append("ticked 'Fusion MCP Server'")+ _t.sleep(1)+ _ad_call("desktop_click", {"space": "image", "shotId": sid_, "hwnd": int(dlg),+ "x": int(W * 0.839), "y": int(H * 0.948)}, timeout=30) # Apply+ _t.sleep(2)+ _ad_call("desktop_click", {"space": "image", "shotId": sid_, "hwnd": int(dlg),+ "x": int(W * 0.894), "y": int(H * 0.948)}, timeout=30) # OK+ steps.append("clicked Apply then OK")+ _t.sleep(4)++ s = _mcp_new_session()+ ok = not s.get("error")+ return {"success": ok, "enabled": ok, "steps": steps,+ "sessionId": s.get("sessionId"), "serverInfo": s.get("serverInfo"),+ "narrate": ("Fusion's MCP server is on. I enabled it for you in Preferences."+ if ok else "I drove the Preferences dialog but the MCP port is still closed."),+ "_hint": ("MCP server ENABLED - fusion_mcp_status lists its tools. Tell the user it is "+ "handled; they do not need to touch Preferences."+ if ok else+ "The toggle did not take. Ask the user to set it manually: profile icon (top "+ "right) -> Preferences -> General -> API -> tick 'Fusion MCP Server'. Do NOT "+ "restart or kill Fusion before they click Apply, that discards the setting."),+ "statusVerb": "fusion_mcp_status"}+++COMMAND_HANDLERS["mcp_status"] = _handle_mcp_status+COMMAND_HANDLERS["mcp_tools"] = _handle_mcp_tools+COMMAND_HANDLERS["mcp_call"] = _handle_mcp_call+COMMAND_HANDLERS["mcp_resources"] = _handle_mcp_resources+COMMAND_HANDLERS["mcp_enable"] = _handle_mcp_enable+++# ── FUSION PREFERENCES, DRIVEN THROUGH THE UI (John, 2026-07-23) ─────────────────────────────+# Fusion's Python API exposes only a THIN slice of preferences (generalPreferences: theme, orbit,+# units; apiPreferences: debuggingPort, isDeveloperToolsEnabled). Everything else - including the+# Fusion MCP Server toggle - is UI-only.+#+# But the Preferences dialog IS reachable programmatically:+# Commands.Start PreferencesCommand opens it (no menu hunting, no profile-icon click)+# and its LEFT-HAND SECTION TREE is exposed to UIA, so sections can be found by name.+#+# The catch, learned live: the CHILD sections under General (API, Design, Manufacture,+# Electronics, Render, Drawing, Simulation) are rendered LAZILY by Qt and never appear in the+# UIA tree, even after desktop_ui_expand reports success. So navigating into a child section+# needs an image-space click, which takes the foreground and is therefore announced with a+# caption. Everything up to that point is background.+#+# ⛔ The setting is DISCARDED unless Apply/OK is clicked. Killing or restarting Fusion with the+# dialog still open loses it (that is exactly why an earlier MCP enable silently reverted).+_PREF_SECTIONS = {+ # section -> (parent-to-expand or None, fractional x, fractional y) on the 1400x836 dialog+ "general": (None, 0.071, 0.092),+ "api": ("general", 0.071, 0.135),+ "design": ("general", 0.071, 0.179),+ "manufacture": ("general", 0.071, 0.222),+ "electronics": ("general", 0.071, 0.265),+ "render": ("general", 0.071, 0.310),+ "drawing": ("general", 0.071, 0.353),+ "material": (None, 0.071, 0.483),+ "graphics": (None, 0.071, 0.527),+ "network": (None, 0.071, 0.570),+ "preview features": (None, 0.071, 0.744),+}+++def _prefs_dialog_hwnd():+ for w in (_unwrap(_ad_call("desktop_list_windows", {}, timeout=40)).get("windows") or []):+ if str(w.get("title", "")).strip() == "Preferences":+ return w.get("hwnd")+ return None+++def _prefs_shot(hwnd):+ s = _unwrap(_ad_call("desktop_screenshot_window", {"hwnd": int(hwnd)}, timeout=45))+ cm = s.get("coordMap") or {}+ img = cm.get("image") or {}+ return (cm.get("shotId"), img.get("w") or 1400, img.get("h") or 836,+ s.get("localSafePath"))+++def _handle_prefs_open(fusion_info: dict, args: dict) -> dict:+ """Open Fusion Preferences, optionally navigate to a section, and hand back a screenshot the+ AI can click in. This is how you reach ANY preference, not just the API-exposed few."""+ import time as _t+ if not _unwrap(_handle_fusion_readiness({}, {})).get("running"):+ return {"success": False, "error": "Fusion is not running.",+ "_hint": "fusion_start first."}+ section = str((args or {}).get("section") or "").strip().lower()+ steps = []+ hwnd = _prefs_dialog_hwnd()+ if not hwnd:+ _announce_foreground("opening Fusion preferences")+ _ad_call("fusion_execute_text_command",+ {"command": "Commands.Start PreferencesCommand"}, timeout=45)+ _t.sleep(3)+ hwnd = _prefs_dialog_hwnd()+ steps.append("opened Preferences")+ if not hwnd:+ return {"success": False, "error": "Preferences dialog did not open.", "steps": steps}++ if section:+ spec = _PREF_SECTIONS.get(section)+ if not spec:+ return {"success": False, "error": "Unknown section %r." % section,+ "knownSections": sorted(_PREF_SECTIONS),+ "_hint": ("Pass one of knownSections, or omit `section` and click the returned "+ "shotId yourself with desktop_click {space:'image'}.")}+ parent, fx, fy = spec+ sid_, W, H, _p = _prefs_shot(hwnd)+ if parent:+ pspec = _PREF_SECTIONS[parent]+ # click the expand arrow, left of the parent label+ _ad_call("desktop_click", {"space": "image", "shotId": sid_, "hwnd": int(hwnd),+ "x": int(W * 0.029), "y": int(H * pspec[2])}, timeout=30)+ steps.append("expanded %s" % parent)+ _t.sleep(2)+ sid_, W, H, _p = _prefs_shot(hwnd)+ _ad_call("desktop_click", {"space": "image", "shotId": sid_, "hwnd": int(hwnd),+ "x": int(W * fx), "y": int(H * fy)}, timeout=30)+ steps.append("selected %s" % section)+ _t.sleep(2)++ sid_, W, H, path = _prefs_shot(hwnd)+ return {"success": True, "hwnd": hwnd, "section": section or "general",+ "shotId": sid_, "imageWidth": W, "imageHeight": H, "screenshot": path,+ "steps": steps, "knownSections": sorted(_PREF_SECTIONS),+ "_hint": ("Preferences is OPEN and showing this section. LOOK at the screenshot, then "+ "click any control with desktop_click {space:'image', shotId, x, y, hwnd}. "+ "⛔ Nothing is saved until you call fusion_prefs_close {save:true} (Apply+OK) "+ "- killing or restarting Fusion first DISCARDS the change. Child sections "+ "under General are lazily rendered and absent from the UIA tree, which is why "+ "this verb hands you an image to click rather than control names."),+ "statusVerb": "fusion_get_preferences"}+++def _handle_prefs_close(fusion_info: dict, args: dict) -> dict:+ """Apply+OK (save:true, default) or Cancel the Preferences dialog."""+ import time as _t+ save = (args or {}).get("save", True)+ hwnd = _prefs_dialog_hwnd()+ if not hwnd:+ return {"success": True, "closed": False,+ "_hint": "Preferences was not open; nothing to do."}+ sid_, W, H, _p = _prefs_shot(hwnd)+ if save:+ _ad_call("desktop_click", {"space": "image", "shotId": sid_, "hwnd": int(hwnd),+ "x": int(W * 0.839), "y": int(H * 0.948)}, timeout=30) # Apply+ _t.sleep(2)+ _ad_call("desktop_click", {"space": "image", "shotId": sid_, "hwnd": int(hwnd),+ "x": int(W * 0.894), "y": int(H * 0.948)}, timeout=30) # OK+ else:+ _ad_call("desktop_click", {"space": "image", "shotId": sid_, "hwnd": int(hwnd),+ "x": int(W * 0.951), "y": int(H * 0.948)}, timeout=30) # Cancel+ _t.sleep(2)+ return {"success": True, "closed": _prefs_dialog_hwnd() is None, "saved": bool(save),+ "narrate": ("Saved your Fusion preferences." if save else "Closed preferences without saving."),+ "_hint": ("Applied and closed. Some settings (the MCP server is one) only take effect "+ "once saved this way." if save else "Cancelled; nothing was changed."),+ "statusVerb": "fusion_get_preferences"}+++COMMAND_HANDLERS["prefs_open"] = _handle_prefs_open+COMMAND_HANDLERS["prefs_close"] = _handle_prefs_close+++def _fusion_signin_cfg_path():+ import pathlib+ d = pathlib.Path(os.path.expanduser("~")) / ".adom" / "fusion-signin"+ d.mkdir(parents=True, exist_ok=True)+ return d / "profile.json"+++def _remembered_signin_profile():+ try:+ import json as _j+ p = _fusion_signin_cfg_path()+ if p.exists():+ return (_j.loads(p.read_text()) or {}).get("profile") or None+ except Exception:+ pass+ return None+++def _remember_signin_profile(profile: str):+ try:+ import json as _j+ _fusion_signin_cfg_path().write_text(_j.dumps({"profile": profile}))+ except Exception:+ pass+++def _chrome_authorize_urls(max_age_min: int = 15):+ """Scan Chrome + Edge profile History DBs for RECENT Autodesk-desktop OAuth authorize+ URLs (the Fusion Identity SDK ones - marked by `idsdk` / redirect to idmgr/callback).++ This is how we fix Fusion opening the WRONG browser profile: Fusion fires its OAuth at+ the OS-default browser, but the FULL authorize URL (client_id, PKCE challenge, state,+ request_id) lands in that browser's History. We read it back and re-open it in the+ profile the user actually authenticates Autodesk in. The URL is NOT tied to a browser+ (its redirect is accounts.autodesk.com/idmgr/callback + an autodesk:// protocol handoff+ matched by request_id), so completing it in ANY profile hands the token to the running+ Fusion. Verified live (John, 2026-07-21).++ Returns a list of {url, sourceProfileDir, browser, ageSec} newest first.+ """+ import glob, sqlite3, shutil, tempfile, time as _t+ la = os.environ.get("LOCALAPPDATA", "")+ roots = []+ if la:+ roots.append(("chrome", os.path.join(la, "Google", "Chrome", "User Data")))+ roots.append(("edge", os.path.join(la, "Microsoft", "Edge", "User Data")))+ now = _t.time()+ found = []+ for browser, root in roots:+ if not os.path.isdir(root):+ continue+ for hist in glob.glob(os.path.join(root, "*", "History")):+ prof_dir = os.path.basename(os.path.dirname(hist))+ tmp = os.path.join(tempfile.gettempdir(), "adom_hist_%s_%s.db" % (browser, prof_dir))+ try:+ shutil.copy2(hist, tmp) # copy first - Chrome keeps History locked+ # Chrome buffers recent rows in the -wal; copy it too or a fresh authorize URL+ # is invisible (this was the bug: a just-opened sign-in did not appear).+ for ext in ("-wal", "-shm"):+ if os.path.exists(hist + ext):+ try: shutil.copy2(hist + ext, tmp + ext)+ except Exception: pass+ con = sqlite3.connect(tmp)+ try: con.execute("PRAGMA journal_mode=WAL")+ except Exception: pass+ rows = con.execute(+ "SELECT url, last_visit_time FROM urls "+ "WHERE url LIKE '%developer.api.autodesk.com/authentication/v2/authorize%' "+ "OR url LIKE '%idp.auth.autodesk.com/as/authorize%' "+ "ORDER BY last_visit_time DESC LIMIT 6").fetchall()+ con.close()+ except Exception:+ continue+ for url, cts in rows:+ if "idsdk" not in url and "idmgr" not in url:+ continue # only the DESKTOP-app (Fusion) flow, not a web app+ epoch = (cts / 1_000_000) - 11644473600+ age = now - epoch+ if age <= max_age_min * 60:+ found.append({"url": url, "sourceProfileDir": prof_dir,+ "browser": browser, "ageSec": int(age)})+ found.sort(key=lambda x: x["ageSec"])+ return found+++def _fusion_window():+ """Find the main Fusion window via desktop_list_windows. Returns the whole entry (it already+ carries `rect`, so we get geometry in the SAME call - there is no desktop_window_info verb).+ Returns {} when not found."""+ wins = _unwrap(_ad_call("desktop_list_windows", {}, timeout=40)).get("windows") or []+ cand = {}+ for w in wins:+ t = str(w.get("title", ""))+ tl = t.lower()+ if "autodesk fusion" in tl or tl.startswith("signing in"):+ if "signing in" in tl or "welcome" in tl: # prefer the sign-in/welcome window+ return w+ cand = cand or w+ return cand+++def _fusion_window_hwnd():+ """Just the hwnd of the main Fusion window (int|None)."""+ return (_fusion_window() or {}).get("hwnd")+++# ── NEVER STEAL THE USER'S FOREGROUND (John, 2026-07-22) ─────────────────────────────────────+# "try to NEVER bring windows to the foreground cuz its disruptive."+# desktop_click is SendInput and FOREGROUNDS the window. desktop_ui_click/desktop_ui_set are UIA+# Invoke/SetValue: programmatic, NO focus steal, NO cursor move, and Chromium exposes its a11y+# tree so page buttons/fields ARE reachable by accessible name. So: ALWAYS try background UIA+# first and only fall back to a foreground click for things with no UIA node (Fusion's Sign In+# button is a webview with none - that is the one documented exception).+# ── SIGN-IN WINDOW JANITOR (John, 2026-07-22) ────────────────────────────────────────────────+# "if you open a browser window for sign in, you must run a loop on a schedule to know when to+# close it so you never leave the user's desktop messy."+# We open browser windows to complete OAuth. Those windows are OURS and must not be left as+# litter once they have served their purpose. Every window we open is tracked here, and a+# background loop closes it as soon as the sign-in it belongs to is DONE (or it is clearly dead+# or too old). Never touches a window the user opened.+_signin_windows = [] # [{sessionId, profile, kind, openedAt}]+_signin_windows_lock = threading.Lock()+_JANITOR_MAX_AGE = 900+_last_update_sweep = 0.0 # 15 min: an OAuth window older than this is dead weight either way+++def _track_signin_window(session_id, profile, kind="fusion"):+ if not session_id:+ return+ with _signin_windows_lock:+ _signin_windows.append({"sessionId": session_id, "profile": profile,+ "kind": kind, "openedAt": _t2.time()})+++def _close_tracked_window(w) -> bool:+ """Close one window WE opened. nbrowser_close_window only closes our own sessions."""+ r = _ad_call("nbrowser_close_window",+ {"sessionId": w["sessionId"], "profile": w.get("profile")}, timeout=30)+ return isinstance(r, dict) and not r.get("_adCallError")+++def _signin_goal_met(kind) -> bool:+ """Has the sign-in this window exists for actually completed?"""+ try:+ if kind == "aps":+ return bool(_aps_quick_state().get("signedIn"))+ return bool(_unwrap(_handle_fusion_readiness({}, {})).get("ready"))+ except Exception:+ return False+++def _signin_janitor_loop():+ """Poll until each tracked sign-in window can be closed, then close it."""+ while True:+ try:+ _t2.sleep(20)+ # Periodic AUTO-UPDATE sweep. Fusion's update nag only enters the UIA tree while its+ # Job Status panel is open, so a single check at readiness time misses most of them.+ # Sweeping on a schedule catches the nag whenever Fusion actually shows it, and the+ # click is background so the user never sees any of it.+ global _last_update_sweep+ if _auto_update_enabled() and (_t2.time() - _last_update_sweep) > 300:+ _last_update_sweep = _t2.time()+ try:+ if _unwrap(_handle_fusion_readiness({}, {})).get("running"):+ _apply_fusion_update_silently()+ except Exception:+ pass+ with _signin_windows_lock:+ pending = list(_signin_windows)+ if not pending:+ continue+ done_goals = {}+ for w in pending:+ kind = w.get("kind", "fusion")+ if kind not in done_goals:+ done_goals[kind] = _signin_goal_met(kind)+ aged = (_t2.time() - w["openedAt"]) > _JANITOR_MAX_AGE+ if done_goals[kind] or aged:+ if _close_tracked_window(w):+ print("[Fusion Bridge] janitor: closed %s sign-in window %s (%s)"+ % (kind, w["sessionId"], "signed in" if done_goals[kind] else "expired"))+ with _signin_windows_lock:+ if w in _signin_windows:+ _signin_windows.remove(w)+ except Exception:+ continue+++def _start_signin_janitor():+ t = threading.Thread(target=_signin_janitor_loop, daemon=True, name="signin-janitor")+ t.start()+ return t+++def _announce_foreground(reason: str) -> bool:+ """ALWAYS tell the user WHY, right before we steal their foreground.++ John, 2026-07-22: "anytime you bring something to the foreground, you should show an ad notify+ for 2 seconds telling the user why... or an ad caption." A caption is the gentler of the two+ (no toast stack, auto-clears), so that is what we use. Best-effort; never blocks the action.+ """+ try:+ _ad_call("desktop_caption",+ {"text": "Adom: %s" % reason, "id": "fusion-bridge-foreground",+ "expiresInMs": 2500}, timeout=15)+ return True+ except Exception:+ return False+++def _click_background_first(hwnd, name=None, shot_id=None, x=None, y=None, reason=None) -> dict:+ """Click preferring the BACKGROUND UIA path (no focus steal, no cursor move). Only falls back+ to SendInput - which DOES foreground - when there is no UIA node, and in that case announces+ WHY to the user first. Returns {clicked, via, why, announced}."""+ if name:+ r = _unwrap(_ad_call("desktop_ui_click", {"hwnd": int(hwnd), "name": name}, timeout=30))+ if r.get("ok") or r.get("clicked") or r.get("invoked"):+ return {"clicked": True, "via": "uia_background", "announced": False,+ "why": "UIA Invoke on %r (background, no focus steal)" % name}+ if shot_id is not None and x is not None and y is not None:+ announced = _announce_foreground(reason or "clicking %s for you" % (name or "a button"))+ _ad_call("desktop_click", {"space": "image", "shotId": shot_id,+ "x": int(x), "y": int(y), "hwnd": int(hwnd)}, timeout=30)+ return {"clicked": True, "via": "foreground_click", "announced": announced,+ "why": "no UIA node; used SendInput (DOES foreground - user was told why)"}+ return {"clicked": False, "via": None, "announced": False,+ "why": "no UIA node and no shot coords given"}+++# ── HUMAN WALLS: notify the user, and tell the AI it can drive it ────────────────────────────+# John, 2026-07-22: "you should be sending me ad notifies when you need me to do something" AND+# "suggest to the ai via hints that you can drive the thing on your own cuz the adom user wants+# you to do everything automatically if you can". So on every wall we do BOTH: toast the human,+# and hand the AI an exact, mechanical way to clear it without them.+def _autodesk_window_hwnds() -> set:+ """hwnds of every Autodesk-ish BROWSER window right now (used to tell NEW from STALE)."""+ wins = _unwrap(_ad_call("desktop_list_windows", {}, timeout=40)).get("windows") or []+ out = set()+ for w in wins:+ tl = str(w.get("title", "")).lower()+ if "autodesk" in tl and "fusion" not in tl:+ out.add(w.get("hwnd"))+ return out+++def _detect_signin_wall(ignore_hwnds=None) -> dict:+ """Classify what the Autodesk sign-in is waiting on, from BROWSER WINDOW TITLES.+ Title-based on purpose: cheap, needs no OCR, and never touches/foregrounds the window.++ ignore_hwnds = windows that already existed BEFORE we opened this sign-in. Without it a dead+ 'Sign-in request expired' / stale '2-step verification' tab from an earlier attempt gets+ reported as the live wall and we toast the user about nothing (seen live 2026-07-22).+ """+ ignore = set(ignore_hwnds or ())+ wins = _unwrap(_ad_call("desktop_list_windows", {}, timeout=40)).get("windows") or []+ best = {}+ for w in wins:+ if w.get("hwnd") in ignore:+ continue+ t = str(w.get("title", "")); tl = t.lower()+ if "2-step verification" in tl or "confirm sign-in" in tl:+ return {"wall": "twofactor_email_code", "hwnd": w.get("hwnd"), "title": t}+ if "identity manager" in tl:+ best = {"wall": "protocol_handoff", "hwnd": w.get("hwnd"), "title": t}+ elif "sign in - autodesk" in tl and not best:+ best = {"wall": "credentials", "hwnd": w.get("hwnd"), "title": t}+ return best+++_WALL_COPY = {+ "twofactor_email_code": ("Autodesk needs your 6-digit code",+ "Autodesk emailed a verification code to finish signing Fusion in. "+ "Enter it in the Chrome window, or tell your AI to fetch it for you."),+ "credentials": ("Autodesk sign-in needs you",+ "The Autodesk sign-in is asking for credentials. I never type your password "+ "or 2FA - please complete it in the Chrome window."),+ "protocol_handoff": ("Allow Fusion to finish signing in",+ "Chrome is asking permission to open Autodesk Identity Manager. Click "+ "Open to hand the login back to Fusion."),+}+++def _notify_signin_wall(wall: str) -> dict:+ """Toast the user's real desktop about a human wall. Best-effort, never raises."""+ title, body = _WALL_COPY.get(wall, ("Fusion sign-in needs you", "Please check the sign-in window."))+ return _cli_notify_all(title, body, "warning")+++def _wall_hint(wall: str, notified: bool) -> str:+ """The AI-facing instruction for a wall. Per John: do as much as possible IN CODE, and use+ HINTS (not skills) to make the calling AI act correctly."""+ common = ("I ALREADY TOASTED THE USER about this (notifyDelivered=%s) - you do not need to ask "+ "me to, and do not nag them again. " % notified)+ if wall == "twofactor_email_code":+ return (common ++ "WALL: Autodesk emailed a 6-DIGIT CODE. THE USER WANTS YOU TO DO THIS FOR THEM if "+ "you can, so TRY THIS FIRST, do not just hand it back to them: if `adom-google` is "+ "available in YOUR container, read the code out of their Gmail and submit it - "+ " adom-google gmail search 'from:autodesk verification code' --limit 1 \n"+ "then pull the 6 digits and call fusion_signin_2fa {\"code\":\"123456\"} which "+ "types it in the BACKGROUND (no foreground steal) and submits. If `adom-google` is "+ "NOT installed, TELL THE USER that installing adom-google would let you finish "+ "logins like this automatically next time, then let them type the code. NEVER ask "+ "them for the code if you can read it yourself.")+ if wall == "protocol_handoff":+ return (common ++ "WALL: Chrome's native 'Open Autodesk Identity Manager?' dialog. Try to clear it "+ "YOURSELF in the background: desktop_find_control on that dialog then "+ "desktop_ui_click {name:'Open'} (UIA, no focus steal). Only if UIA finds no node, "+ "fall back to a foregrounded desktop_click. Then poll fusion_readiness.")+ return (common ++ "WALL: the sign-in wants credentials. NEVER type their password or 2FA. If the browser "+ "autofills, you may submit it. Otherwise let the user finish in the window I opened.")+++def _unwrap(r):+ """AD verb responses come back either flat or nested under `data` (the CLI puts the real+ payload in output.data). Always look through both - forgetting this silently broke the+ server-side Sign In click, because coordMap/shotId live one level down."""+ if not isinstance(r, dict):+ return {}+ inner = r.get("data")+ if isinstance(inner, dict) and inner:+ return inner+ return r+++def _fusion_click_signin() -> dict:+ """Click Fusion's 'Welcome to Fusion' -> Sign In button SERVER-SIDE, ALWAYS, on the user's+ behalf (John, 2026-07-22: "you should ALWAYS just click sign in on behalf of the adom+ users"). The button is a webview with no UIA node, so this is an image-space click.++ Runs on-box, so it costs no remote round-trip against Fusion's ~2-min OAuth expiry.+ Returns {clicked: bool, why: str} - never raises.+ """+ import time as _t+ win = _fusion_window()+ hwnd = win.get("hwnd") or _unwrap(_handle_fusion_readiness({}, {})).get("hwnd")+ if not hwnd:+ return {"clicked": False, "why": "could not find the Fusion window"}++ # A MINIMIZED Fusion captures as a ~237x39 title-bar sliver, so the click lands on nothing.+ # Restore it first (seen live: rect was at -32000,-32000, the Windows minimized position).+ rect = win.get("rect") or {}+ if (rect.get("left") is not None and rect["left"] <= -30000) or \+ (0 < (rect.get("width") or 0) < 600) or (0 < (rect.get("height") or 0) < 400):+ _ad_call("desktop_set_window_state", {"hwnd": int(hwnd), "state": "restore"}, timeout=30)+ _t.sleep(1.5)++ shot = _unwrap(_ad_call("desktop_screenshot_window", {"hwnd": int(hwnd)}, timeout=45))+ cm = shot.get("coordMap") or {}+ sid = cm.get("shotId") or shot.get("shotId")+ if not sid:+ return {"clicked": False, "why": "screenshot returned no shotId"}+ img = cm.get("image") or {}+ w = shot.get("width") or img.get("w") or img.get("width") or cm.get("imageWidth") or 1400+ h = shot.get("height") or img.get("h") or img.get("height") or cm.get("imageHeight") or 860+ # Background UIA first (no focus steal). Fusion's Sign In is a WEBVIEW button with no UIA+ # node, so this almost always falls through to the image-space click - that is the documented+ # exception to the never-foreground rule, not a licence to foreground elsewhere.+ r = _click_background_first(hwnd, name="Sign In", shot_id=sid,+ x=int(w * 0.5), y=int(h * 0.57),+ reason="signing Fusion in for you (clicking Sign In)")+ return {"clicked": r.get("clicked"),+ "why": "%s (image %dx%d)" % (r.get("why"), w, h),+ "via": r.get("via")}+++def _capture_fresh_authorize(wait_sec: int = 30, max_age: int = 120):+ """Poll the browser History for a FRESH (< max_age s) Fusion authorize URL for up to+ wait_sec, ON-BOX. Collapsing capture into one server-side wait (instead of the caller+ re-polling over the flaky relay) is what lets the whole chain finish inside Fusion's+ ~2-min request window. Returns the auth dict or None."""+ import time as _t+ deadline = _t.time() + max(1, wait_sec)+ while True:+ urls = _chrome_authorize_urls()+ if urls and urls[0]["ageSec"] <= max_age:+ return urls[0]+ if _t.time() >= deadline:+ return urls[0] if urls else None+ _t.sleep(2)+++_SSO_BUTTONS = {"google": "Continue with Google", "apple": "Continue with Apple",+ "microsoft": "Continue with Microsoft", "facebook": "Continue with Facebook"}+++def _drive_sso_signin(profile: str, tab_id, email: str, provider: str = "google") -> dict:+ """Finish the Autodesk sign-in over CDP using SSO - NO password, NO 2FA, NO foreground.++ PROVEN LIVE (John, 2026-07-22) after password/2FA attempts kept stalling. If the browser+ PROFILE is already signed into the identity provider (a work Google account is the common+ case), "Continue with Google" -> pick the account -> "Open Product" completes the whole login+ silently and hands the token back to Fusion via the autodesk:// callback.++ TWO THINGS MATTER:+ 1. It must be a FRESH flow. Resuming a flowId that already advanced past the provider+ choice lands you on the PASSWORD screen with no way back - that dead end cost us an hour.+ 2. Click the provider BEFORE typing any email; entering an email commits to the password path.+ """+ steps = []++ def ev(expr):+ r = _unwrap(_ad_call("nbrowser_eval", {"profile": profile, "tabId": tab_id,+ "expression": expr}, timeout=45))+ return r.get("result") or r.get("value")++ def click(text=None, selector=None):+ a = {"profile": profile, "tabId": tab_id}+ if text: a["text"] = text+ if selector: a["selector"] = selector+ r = _unwrap(_ad_call("nbrowser_click", a, timeout=45))+ return bool(r.get("ok") or r.get("success") or r.get("changed"))++ btn = _SSO_BUTTONS.get((provider or "google").lower(), _SSO_BUTTONS["google"])+ if not click(text=btn):+ return {"done": False, "steps": steps,+ "why": "%r not on the page - this is probably NOT a fresh flow (a resumed flowId "+ "goes straight to the password screen)." % btn}+ steps.append("clicked %r" % btn)+ _t2.sleep(7)++ # provider account chooser - pick by the exact email+ if email and click(text=email):+ steps.append("picked the %s account" % email)+ _t2.sleep(8)++ state = str(ev("JSON.stringify({u:location.href.slice(0,120),b:document.body.innerText.slice(0,200)})") or "")+ if "You're signed in" in state or "signed in" in state.lower():+ steps.append("provider returned 'You're signed in'")+ # hand the token back to Fusion (autodesk:// protocol callback)+ if click(text="Open Product"):+ steps.append("clicked 'Open Product' to hand the token to Fusion")+ return {"done": True, "steps": steps, "state": state[:200]}+ return {"done": False, "steps": steps, "state": state[:200],+ "why": "did not reach the signed-in page; a provider password/2FA may be required"}+++def _orchestrate_signin_2fa(args: dict) -> dict:+ """Submit Autodesk's emailed 6-digit verification code, IN THE BACKGROUND.++ Exists so the AI can finish a login end-to-end for the user (read the code from Gmail with+ adom-google, then call this) instead of parking them on a 2FA screen. Uses UIA SetValue ++ Invoke, so it never steals focus or moves the cursor.+ """+ code = str(args.get("code") or "").strip()+ digits = "".join(ch for ch in code if ch.isdigit())+ if len(digits) < 4:+ return {"success": False, "errorCode": "bad_code",+ "error": "Pass the emailed code, e.g. fusion_signin_2fa {\"code\":\"123456\"}.",+ "_hint": ("Read it from the user's Gmail if you can: "+ "adom-google gmail search 'from:autodesk verification code' --limit 1 "+ "-> take the 6 digits -> call this verb again. If adom-google is not "+ "installed, tell the user it would let you automate this."),+ "statusVerb": "fusion_signin_2fa"}++ wall = _detect_signin_wall()+ hwnd = args.get("hwnd") or wall.get("hwnd")+ if not hwnd:+ return {"success": False, "errorCode": "no_2fa_window",+ "error": "No Autodesk 2-step-verification window found.",+ "_hint": ("Nothing is waiting on a code right now. Check fusion_readiness - if "+ "needsSignin is still true, run fusion_signin again to restart the flow."),+ "statusVerb": "fusion_readiness"}++ # Find the code field WITHOUT touching the foreground. Autodesk renders either one input or a+ # segmented set, so try the obvious accessible names, then fall back to the first edit control.+ found = _unwrap(_ad_call("desktop_find_control",+ {"hwnd": int(hwnd), "role": "edit"}, timeout=40))+ ctrls = found.get("controls") or found.get("matches") or []+ steps = []+ filled = False+ if len(ctrls) >= len(digits) and len(ctrls) >= 4:+ # segmented: one box per digit+ for i, ch in enumerate(digits[:len(ctrls)]):+ _ad_call("desktop_ui_set", {"hwnd": int(hwnd),+ "automationId": ctrls[i].get("automationId"),+ "name": ctrls[i].get("name"), "value": ch}, timeout=20)+ filled = True+ steps.append("typed %d digits into %d segmented boxes (background UIA)" % (len(digits), len(ctrls)))+ elif ctrls:+ c = ctrls[0]+ r = _unwrap(_ad_call("desktop_ui_set", {"hwnd": int(hwnd),+ "automationId": c.get("automationId"),+ "name": c.get("name"), "value": digits}, timeout=20))+ filled = bool(r.get("ok") or r.get("set") or r is not None)+ steps.append("typed the code into %r (background UIA)" % (c.get("name") or c.get("automationId")))++ if not filled:+ return {"success": False, "errorCode": "code_field_not_found",+ "error": "Could not find the code field via UIA.",+ "controlsSeen": ctrls[:8],+ "_hint": ("The code input was not in the UIA tree (shadow DOM). Run "+ "desktop_find_control {hwnd:%s} to see what IS exposed. Last resort is a "+ "FOREGROUNDED desktop_click on the field then desktop_type - avoid that "+ "if you can, foregrounding is disruptive to the user." % hwnd),+ "statusVerb": "fusion_signin_2fa"}++ sub = _click_background_first(hwnd, name="Next")+ if not sub.get("clicked"):+ sub = _click_background_first(hwnd, name="Verify")+ steps.append("submitted via %s" % (sub.get("via") or "no submit control found"))++ return {"success": True, "submitted": True, "steps": steps,+ "narrate": "I read the code in and submitted it for you, without touching your screen.",+ "_hint": ("Code submitted in the BACKGROUND. Now poll fusion_readiness until ready:true. "+ "If a Chrome 'Open Autodesk Identity Manager?' dialog appears, clear it with "+ "desktop_ui_click {name:'Open'} (background) - fusion_signin reports that wall "+ "too. If it says the code was wrong, the email may have a NEWER code."),+ "statusVerb": "fusion_readiness"}+++def _orchestrate_signin(args: dict) -> dict:+ """Sign Fusion in through the CORRECT browser profile - the fix for Fusion firing its+ OAuth at the OS-default browser (often the wrong Autodesk account). See the+ fusion-multiprofile-signin skill.++ Staged: pick the target profile (remembered / arg / probed / ask) -> ensure Fusion has+ emitted its OAuth URL (click Sign In if not) -> read that URL from the wrong browser's+ History -> re-open it in the TARGET profile in the BACKGROUND -> report where it waits.++ auto=True runs the whole chain in ONE server-side call: click Fusion's Sign In, poll on-box+ for the fresh authorize URL, then re-open it in the target profile - so the capture->reopen+ round-trips happen on the box (fast) and fit inside Fusion's ~2-min request expiry even when+ the remote relay is slow. This is the reliable path; the staged/manual path is the fallback.+ """+ steps = []+ # ALWAYS click Sign In for the user unless they explicitly opt out (auto:false). Adom users+ # should never be told "now go click Sign In yourself" - we do it for them and SAY so.+ auto = args.get("auto")+ auto = True if auto is None else bool(auto)+ wait_sec = int(args.get("waitSec") or 32)+ target = args.get("profile") # explicit override, e.g. "chrome:[email protected]"+ if target:+ _remember_signin_profile(target)+ if not target:+ target = _remembered_signin_profile()++ rd = _handle_fusion_readiness({}, {})+ if not rd.get("running"):+ return {"success": True, "stage": "not_running", "done": False, "steps": steps,+ "_hint": "Fusion is not running. fusion_start, then fusion_signin.",+ "statusVerb": "fusion_signin"}+ if not rd.get("needsSignin"):+ return {"success": True, "stage": "already", "done": True, "steps": steps,+ "narrate": "Fusion is already signed in.",+ "_hint": "Already signed in (ready:%s). Nothing to do." % rd.get("ready"),+ "statusVerb": "fusion_signin"}++ # profiles known to ABE (for target selection + naming)+ profs = _ad_call("nbrowser_profiles", {}, timeout=40)+ pdata = profs.get("data", profs) if isinstance(profs, dict) else {}+ choices = [p for p in (pdata.get("profiles") or []) if isinstance(p, dict) and p.get("profile")]++ # read Fusion's authorize URL from browser history (it fires the OS-default browser)+ urls = _chrome_authorize_urls()+ fresh = urls[0] if (urls and urls[0]["ageSec"] <= 150) else None+ if auto and not fresh:+ # ONE server-side pass: click Fusion's Sign In FOR THE USER, then poll on-box for the URL.+ ck = _fusion_click_signin()+ clicked = ck.get("clicked")+ steps.append("clicked Fusion 'Sign In' for the user" if clicked+ else "could not click Fusion 'Sign In' (%s)" % ck.get("why"))+ got = _capture_fresh_authorize(wait_sec=wait_sec, max_age=140)+ if got and got["ageSec"] <= 150:+ urls = [got]+ elif got:+ urls = [got] # stale; fall through to the stale branch which tells us to reset+ else:+ urls = []+ if not urls:+ return {"success": True, "stage": "click_signin", "done": False, "steps": steps,+ "narrate": ("I clicked Fusion's Sign In for you, but Fusion has not written its "+ "sign-in URL to a browser yet. Give it a few seconds."),+ "_hint": ("THIS VERB CLICKS FUSION'S 'Sign In' FOR THE USER - never tell them to go "+ "click it themselves, and do not click it yourself. It just did (see "+ "steps[]), but no Fusion OAuth URL has landed in any browser's history "+ "yet. Simply CALL fusion_signin AGAIN in a few seconds; it will click if "+ "needed, wait on-box for the URL, and re-open it in the right profile. "+ "If steps[] says the click FAILED, that is a bridge bug worth reporting - "+ "the usual causes are a minimized Fusion window (this verb restores it) "+ "or the sign-in wall not being up yet (check fusion_readiness "+ "needsSignin:true)."),+ "statusVerb": "fusion_signin"}+ auth = urls[0]+ steps.append("captured Fusion OAuth URL from %s profile '%s' (%ss old)"+ % (auth["browser"], auth["sourceProfileDir"], auth["ageSec"]))+ # Fusion's sign-in request EXPIRES in ~2 min. A URL older than that completes the LOGIN but+ # Fusion no longer waits on its request_id -> "Sign-in request expired", no handoff. Never+ # silently reuse it; force a fresh request. (Learned live 2026-07-21.)+ if auth["ageSec"] > 150:+ return {"success": True, "stage": "stale", "done": False, "steps": steps,+ "signinAuthUrl": auth["url"], "signinAgeSec": auth["ageSec"],+ "_hint": ("The newest Fusion sign-in URL is %ss old - Fusion expires the request in "+ "~2 min, so completing it yields 'Sign-in request expired' and NO handoff. "+ "Get a FRESH request: fusion_stop then fusion_start (resets Fusion to a "+ "clean 'Welcome to Fusion'), click Fusion's Sign In, and call fusion_signin "+ "again PROMPTLY. Completion is near-instant when the target profile is "+ "already signed into Autodesk (auto-consents). Skill: "+ "fusion-multiprofile-signin." % auth["ageSec"]),+ "statusVerb": "fusion_signin"}++ # choose the TARGET profile if not already fixed+ reason = ""+ if not target:+ # probe each live profile for a real Autodesk session; prefer signed-in, then work over consumer+ for c in choices:+ if not c.get("live"):+ continue+ ls = _ad_call("nbrowser_login_state",+ {"profile": c["profile"], "url": "https://accounts.autodesk.com/"}, timeout=40)+ lsd = ls.get("data", ls) if isinstance(ls, dict) else {}+ c["_ad"] = "signed-in" if lsd.get("loggedIn") else ("signed-out" if lsd.get("ok") is not None else "unprobed")+ c["_conf"] = lsd.get("confidence"); c["_cookies"] = lsd.get("cookieCount")+ signed = [c for c in choices if c.get("_ad") == "signed-in"]+ if signed:+ signed.sort(key=lambda c: ({"high": 3, "medium": 2, "low": 1}.get(c.get("_conf"), 0), c.get("_cookies") or 0), reverse=True)+ target = signed[0]["profile"]; reason = "it holds a live Autodesk session"+ else:+ _CONSUMER = ("gmail.com", "outlook.com", "hotmail.com", "yahoo.com", "icloud.com", "live.com")+ work = [c for c in choices if c.get("live") and c.get("email") and not any((c["email"] or "").lower().endswith("@" + d) for d in _CONSUMER)]+ if work:+ target = work[0]["profile"]; reason = "it is a work/corporate profile (no live Autodesk session detected - CONFIRM)"+ if target:+ _remember_signin_profile(target)+ reason = reason or "you told me to use it"++ if not target:+ return {"success": True, "stage": "ask_profile", "done": False, "steps": steps,+ "signinAuthUrl": auth["url"], "profileChoices": choices,+ "_hint": ("Captured Fusion's OAuth URL but cannot tell which profile is your Autodesk "+ "account. ASK the user which profile their Autodesk login belongs to, then "+ "call fusion_signin {profile:'chrome:<their-account>'} - I will remember it. "+ "profileChoices lists every profile."),+ "statusVerb": "fusion_signin"}++ tdesc = next((_describe_profile(c) for c in choices if c.get("profile") == target), target)+ import time as _tt+ sess = "fusion-signin-%d" % int(_tt.time()) # unique per attempt (a reused id can be refused)+ # ── Is Fusion ALREADY using the right profile? (John, 2026-07-22: "they both opened in my+ # work profile, so you're being lazy") ──────────────────────────────────────────────────+ # Chrome profile DIRECTORIES are named Default / Profile 1 / Profile 2, and the user's work+ # account is very often just "Default". We capture the authorize URL from a profile DIR, but+ # the target is an EMAIL (chrome:[email protected]). Without mapping email -> dir we cannot tell+ # they are the SAME profile, so we "fixed" a non-problem by re-opening the identical URL in+ # the identical profile - a redundant second sign-in window on the user's screen.+ target_dir = None+ for c in choices:+ if c.get("profile") == target:+ target_dir = c.get("profileDir") or c.get("dir")+ break+ if target_dir and auth.get("sourceProfileDir") and \+ str(target_dir).strip().lower() == str(auth["sourceProfileDir"]).strip().lower():+ steps.append("Fusion already opened the sign-in in %s (profile dir %r) - no re-open needed"+ % (target, target_dir))+ wall0 = _detect_signin_wall()+ notified0 = bool(_notify_signin_wall(wall0["wall"]).get("delivered")) if wall0.get("wall") else False+ return {"success": True, "stage": "already_right_profile", "done": False, "steps": steps,+ "signinProfile": target, "signinProfileDir": target_dir,+ "signinWhere": tdesc if False else target,+ "signinWall": wall0.get("wall"), "signinWallHwnd": wall0.get("hwnd"),+ "notifyDelivered": notified0,+ "signinWallHint": _wall_hint(wall0["wall"], notified0) if wall0.get("wall") else None,+ "signinAuthUrl": auth["url"],+ "narrate": ("Fusion opened its sign-in in the RIGHT profile already (%s), so I did "+ "not open a second window." % target),+ "_hint": ("NO re-open was needed - Fusion's OS-default browser IS the user's Autodesk "+ "profile here (dir %r). Do NOT open another window; that just litters their "+ "screen with a duplicate sign-in. The existing window is the one to finish. "+ "%s" % (target_dir,+ _wall_hint(wall0["wall"], notified0) if wall0.get("wall")+ else "Poll fusion_readiness until ready:true.")),+ "statusVerb": "fusion_readiness"}++ # snapshot the Autodesk windows that already exist, so a STALE expired tab from an earlier+ # attempt is never mistaken for this attempt's wall+ pre_hwnds = _autodesk_window_hwnds()+ _open_args = {"sessionId": sess, "url": auth["url"], "background": True,+ "profile": target, "thread": "fusion-signin",+ "purpose": "Fusion Autodesk sign-in (correct profile)"}+ r = _ad_call("nbrowser_open_window", _open_args, timeout=60)+ rd_open = r.get("data", r) if isinstance(r, dict) else {}+ opened = bool(rd_open.get("sessionId") or r.get("ok") or r.get("success")+ or rd_open.get("opened") or ("opened in the BACKGROUND" in str(r.get("_hint", ""))))+ open_via = "extension" if opened else None+ if not opened:+ # EXTENSION-FREE fallback: nbrowser_open_window needs the Adom extension installed in+ # that profile. nbrowser_open_os_window does not - it opens the URL in the real profile+ # at the OS level, which is all an OAuth consent needs. This keeps the multi-profile fix+ # working on machines with no extension (same gap as APS issue #12).+ r2 = _ad_call("nbrowser_open_os_window", _open_args, timeout=60)+ if isinstance(r2, dict) and not r2.get("_adCallError"):+ # ok:false just means the hwnd was not resolved in ~8s; the launch usually still fired+ opened = bool(r2.get("ok") or r2.get("hwnd") or "Launch fired" in str(r2.get("_hint", "")))+ if opened:+ open_via = "extension_free"+ r = r2+ steps.append((("re-opened it in %s%s" % (tdesc, " (extension-free)" if open_via == "extension_free" else ""))+ if opened else ("could not open %s (raw: %s)" % (tdesc, str(r)[:160]))))+ if opened:+ # hand it to the janitor so it is never left as litter on the user's desktop+ _track_signin_window(sess, target, kind="fusion")++ # ── FINISH IT: drive the SSO path ourselves (no password, no 2FA, no foreground) ──────────+ # John, 2026-07-22: the goal is a SIGNED-IN Fusion, not a handed-off checklist. When we own+ # the window (nbrowser_open_window => agent-opened => CDP-drivable) and the profile is already+ # signed into the identity provider, this completes the whole login silently.+ sso = None+ if opened and open_via == "extension" and args.get("driveSso", True):+ tabs = _unwrap(_ad_call("nbrowser_list_tabs", {"profile": target}, timeout=45)).get("tabs") or []+ my_tab = None+ for t in tabs:+ if "autodesk" in str(t.get("url", "")).lower() and t.get("owner") == "agent-opened":+ my_tab = t.get("tabId") or t.get("id")+ if my_tab:+ email = None+ for c in choices:+ if c.get("profile") == target:+ email = c.get("email")+ sso = _drive_sso_signin(target, my_tab, email, args.get("ssoProvider", "google"))+ steps.extend(sso.get("steps") or [])+ if sso.get("done"):+ for _ in range(6):+ _t2.sleep(5)+ if _unwrap(_handle_fusion_readiness({}, {})).get("ready"):+ steps.append("Fusion reports ready:true - SIGNED IN")+ # ── ONE LOGIN, BOTH SYSTEMS (John, 2026-07-22) ───────────────────────+ # "why should the user have to login twice to autodesk to sign in to+ # fusion and setup aps? why aren't those just driven from 1 login?"+ # Right now the browser profile has a WARM Autodesk SSO session (we just+ # used it). APS consent rides that session silently, so do it HERE while+ # it is warm instead of making the user authenticate a second time.+ aps_state = _aps_quick_state()+ if aps_state.get("configured") and not aps_state.get("signedIn"):+ try:+ import aps as _apsmod+ _apsmod.start_signin({"profile": target})+ steps.append("APS not signed in - started its consent on the SAME "+ "warm SSO session (no second login for the user)")+ for _ in range(5):+ _t2.sleep(4)+ if _aps_quick_state().get("signedIn"):+ steps.append("APS signed in too - one login covered both")+ break+ except Exception as e:+ steps.append("APS auto-setup skipped (%s)" % str(e)[:80])+ aps_state = _aps_quick_state()+ return {"success": True, "stage": "signed_in", "done": True, "steps": steps,+ "signinProfile": target, "signinWhere": tdesc,+ "signinOpenedVia": open_via,+ "aps": aps_state,+ "narrate": ("Fusion is signed in as %s. No password or 2FA was needed - "+ "I used the browser profile's existing SSO session.%s"+ % (email or target,+ " APS cloud search is set up too, from the same login."+ if aps_state.get("signedIn") else "")),+ "_hint": ("DONE - Fusion is signed in (ready:true). Nothing else to do; "+ "do not tell the user to sign in. %s Drive Fusion normally now."+ % ("APS is signed in as well, so fusion_aps_search is live."+ if aps_state.get("signedIn") else+ "APS is NOT signed in - run fusion_aps_signin NOW while the "+ "browser SSO session is still warm so it consents silently.")),+ "statusVerb": "fusion_readiness"}++ # Did the reopened sign-in immediately hit a HUMAN WALL (2FA, credentials, protocol dialog)?+ # If so, toast the user AND tell the AI how to clear it for them.+ wall = _detect_signin_wall(ignore_hwnds=pre_hwnds) if opened else {}+ wall_kind = wall.get("wall")+ notified = False+ if wall_kind:+ notified = bool(_notify_signin_wall(wall_kind).get("delivered"))+ steps.append("hit %s wall; toasted the user (delivered=%s)" % (wall_kind, notified))++ return {"success": True, "stage": "opened" if opened else "open_failed",+ "done": False, "steps": steps, "signinWhere": tdesc, "signinProfile": target,+ "signinOpenedVia": open_via,+ "signinWall": wall_kind, "signinWallHwnd": wall.get("hwnd"),+ "notifyDelivered": notified,+ "signinWallHint": _wall_hint(wall_kind, notified) if wall_kind else None,+ "signinProfileReason": reason, "signinSession": sess if opened else None,+ "signinWrongBrowser": "%s / %s" % (auth["browser"], auth["sourceProfileDir"]),+ "signinAuthUrl": auth["url"],+ "narrate": (("Fusion opened its sign-in in the WRONG browser (%s), so I moved the real "+ "Autodesk sign-in URL into %s - I picked that because %s. Finish it there, "+ "or I can drive it, and Fusion will pick up the login automatically."+ % (auth["sourceProfileDir"], tdesc, reason)) if opened else+ "I captured Fusion's sign-in URL but could not open the target profile."),+ "_hint": (("The REAL Fusion OAuth URL is now open in %s (session 'fusion-signin'). It "+ "completes back to the running Fusion via the idmgr/callback + autodesk:// "+ "protocol handoff (request_id match), so the browser profile no longer has to "+ "be the OS default - THIS is the fix for Fusion signing into the wrong "+ "account. TELL the user the exact profile (never 'your browser'), say WHY it "+ "was chosen (signinProfileReason), and offer: they finish it, you foreground "+ "it, or (with their OK) you drive 'Continue with Google/Apple/Microsoft'. "+ "NEVER type their password/2FA. Then poll fusion_readiness until ready:true. "+ "The wrong-browser tab (%s) can be closed. If the wrong ACCOUNT completes "+ "anyway, sign Fusion out and re-run. Skill: fusion-multiprofile-signin."+ % (tdesc, auth["sourceProfileDir"])) if opened else+ "Open failed: %s. Retry, or open auth url yourself with nbrowser_open_window "+ "{profile:'%s', url:<signinAuthUrl>}." % (r.get("_adCallError"), target)),+ "statusVerb": "fusion_signin"}+++def _orchestrate_demo(args: dict) -> dict:+ """FIRST-TIME-USER DEMO: take a brand-new user from nothing to "wow" in one verb.++ Written because HD's installer ran a "demo" that just opened Fusion and SAT on the+ sign-in page doing nothing (John, 2026-07-20). A demo must actually finish the sign-in,+ set up APS, and SHOW the user real work: an electronics PROJECT -> SCHEMATIC -> 2D BOARD+ -> 3D BOARD, plus a live APS cloud search.++ STAGED + RESUMABLE. Each call advances as far as it safely can and returns:+ stage - what it just did / is waiting on+ done - whether the demo finished+ narrate - a first-time-user-friendly line the CALLER should say + toast+ screenshots- images to show the user for this stage+ _hint - exactly what the AI must do next (including nbrowser_* for the browser leg)++ The bridge owns Fusion; it does NOT own the browser. So when the sign-in needs a browser+ we hand the caller precise `nbrowser_*` (ABE) instructions - the user's NATIVE browser is+ already signed into Autodesk, whereas pup is anonymous and would force a fresh login.+ """+ stage = (args.get("stage") or "auto").strip()+ demo_query = args.get("query") or "" # optional: which design to demo+ steps: list = []+ shots: list = []++ def _shot(label):+ try:+ r = _handle_screenshot_fusion({}, {})+ if r.get("success") and r.get("localSafePath"):+ shots.append({"label": label, "path": r.get("localSafePath")})+ except Exception:+ pass++ def _out(stage_name, narrate, hint, done=False, **extra):+ return {"success": True, "demo": True, "stage": stage_name, "done": done,+ "steps": steps, "narrate": narrate, "screenshots": shots,+ "statusVerb": "fusion_demo", "_hint": hint, **extra}++ # ── 1. Fusion present + running ─────────────────────────────────────────────+ rd = _handle_fusion_readiness({}, {})+ if not rd.get("installed"):+ return _out("not_installed",+ "Fusion 360 isn't installed yet - want me to install it? It's a free 30-day trial with "+ "everything switched on, so the whole tour works: real boards, schematics, 3D, Gerber and "+ "BOM exports, cloud search. After the trial, Fusion for Personal Use stays free for "+ "non-commercial work (hobby PCBs up to 2 layers), and even an expired install can still "+ "OPEN and VIEW your designs.",+ "OFFER the install - do not just report it is missing. Say what they GET: the free 30-day "+ "trial is FULL-FEATURED (every step of this demo works: electronics, schematic, 2D/3D "+ "board, Gerbers/BOM/CPL, APS cloud search). Be honest about after: Fusion for Personal Use "+ "is free for non-commercial use but LIMITS electronics (about 2 layers / 2 schematic "+ "sheets / small board area) and some exports; an expired or read-only install can still "+ "OPEN + VIEW + browse designs (it only blocks save/export/modify), so their work is never "+ "locked away. Then call fusion_install_fusion (no shell approval needed; streams the "+ "installer, 10-30 min), poll fusion_readiness until installed:true, and call fusion_demo "+ "again to continue the tour. Full flow: the fusion-onboarding skill.")+ if not rd.get("running"):+ steps.append("launched Fusion")+ _handle_launch({}, {})+ rd = _handle_fusion_readiness({}, {})++ # ── 2. SIGN-IN: finish it, do not sit on it ─────────────────────────────────+ if rd.get("needsSignin"):+ # Delegate to the multi-profile sign-in fix (reads Fusion's OAuth URL out of the WRONG+ # default browser and re-opens it in the RIGHT profile). Returns its own rich stage.+ si = _orchestrate_signin({"profile": args.get("signinProfile")})+ si["demo"] = True+ if si.get("stage") in ("opened", "click_signin", "ask_profile", "open_failed"):+ si.setdefault("done", False)+ si["_hint"] = (si.get("_hint", "") + " (This is the fusion_demo sign-in stage - after "+ "the user is signed in and fusion_readiness is ready:true, call fusion_demo "+ "again to continue the tour: APS -> project -> schematic -> 2D -> 3D.)")+ return si+ # DO IT, don't just describe it: open the Autodesk sign-in in the user's OWN browser,+ # in the BACKGROUND so we never yank them out of what they're doing. Then tell the AI+ # exactly WHERE it is waiting and offer the three ways forward.+ nb = _ad_call("nbrowser_readiness", {}, timeout=40)+ nb_data = nb.get("data", nb) if isinstance(nb, dict) else {}+ nb_state = nb_data.get("state")+ opened, where, profile_used = False, "", ""+ pick_reason, ambiguous = "", False+ choices = []+ if nb_state == "ready":+ profs = _ad_call("nbrowser_profiles", {}, timeout=40)+ pdata = profs.get("data", profs) if isinstance(profs, dict) else {}+ # profiles are DICTS: {profile,label,email,browser,displayName,profileDir,active,live,...}+ for p in (pdata.get("profiles") or []):+ if not isinstance(p, dict):+ continue+ if p.get("blocked") or p.get("unresolved") or not p.get("profile"):+ continue+ choices.append({+ "profile": p.get("profile"), "browser": (p.get("browser") or "").lower(),+ "email": p.get("email") or "", "displayName": p.get("displayName") or "",+ "profileDir": p.get("profileDir") or "",+ "active": bool(p.get("active")), "live": bool(p.get("live")),+ "asleep": bool(p.get("asleep")),+ "extensionInstalled": p.get("extensionInstalled", True),+ "describe": _describe_profile(p),+ })+ # PROBE each profile for a REAL Autodesk session - never guess by "active".+ # (John, 2026-07-20: picking the active profile grabbed his PERSONAL Chrome with+ # zero analysis. A power user's Autodesk login can live in any profile, so ASK THE+ # BROWSER, don't assume.) nbrowser_login_state reports auth cookies per profile.+ for c in choices:+ if not c["live"]:+ c["autodesk"] = "unprobed(asleep)"+ continue+ ls = _ad_call("nbrowser_login_state",+ {"profile": c["profile"], "url": "https://accounts.autodesk.com/"},+ timeout=45)+ lsd = ls.get("data", ls) if isinstance(ls, dict) else {}+ if lsd.get("ok") is None and lsd.get("loggedIn") is None:+ c["autodesk"] = "unprobed"+ else:+ c["autodesk"] = "signed-in" if lsd.get("loggedIn") else "signed-out"+ c["autodeskConfidence"] = lsd.get("confidence")+ c["autodeskCookies"] = lsd.get("cookieCount")+ _CONF = {"high": 3, "medium": 2, "low": 1}+ signed = [c for c in choices if c.get("autodesk") == "signed-in"]+ signed.sort(key=lambda c: (_CONF.get(c.get("autodeskConfidence"), 0),+ c.get("autodeskCookies") or 0), reverse=True)+ pick = signed[0] if signed else None+ if pick:+ pick_reason = ("it is the profile actually signed into Autodesk (%s auth cookies, %s "+ "confidence)" % (pick.get("autodeskCookies"), pick.get("autodeskConfidence")))+ else:+ # NOTHING is signed in - do not silently guess. Prefer a work/corporate identity+ # over a consumer mailbox (Autodesk seats are usually work accounts), but SAY SO+ # and offer the alternatives.+ _CONSUMER = ("gmail.com", "outlook.com", "hotmail.com", "yahoo.com", "icloud.com", "live.com")+ def _work_first(c):+ em = (c.get("email") or "").lower()+ return (0 if (em and not any(em.endswith("@" + d) for d in _CONSUMER)) else 1,+ 0 if c["live"] else 1)+ ranked = sorted([c for c in choices if c["live"]] or choices, key=_work_first)+ pick = ranked[0] if ranked else None+ pick_reason = ("no profile has a live Autodesk session, so I picked the most likely "+ "one (work/corporate identity first) - CONFIRM WITH THE USER")+ ambiguous = not signed or len(signed) > 1+ if pick:+ profile_used = pick["profile"]+ where = pick["describe"]+ oa = {"sessionId": "fusion-signin", "url": "https://accounts.autodesk.com/",+ "background": True, "thread": "fusion-demo", "purpose": "Autodesk sign-in for Fusion"}+ if profile_used:+ oa["profile"] = profile_used+ r = _ad_call("nbrowser_open_window", oa, timeout=60)+ opened = bool((r.get("data", r) if isinstance(r, dict) else {}).get("sessionId") or r.get("ok") or r.get("success"))+ if not where:+ where = "your browser"+ if opened:+ steps.append("opened Autodesk sign-in in %s (background)" % (where or "browser"))+ else:+ steps.append("could not auto-open the browser sign-in: %s" % (nb.get("_adCallError") or nb_state or "unknown"))+ if opened:+ narrate = ("I've opened the Autodesk sign-in in %s - I chose that profile because %s. "+ "It's sitting in the background so it won't interrupt what you're doing. "+ "Finish it whenever you like, tell me to bring it up front, or I can complete "+ "it for you. If that's the wrong account, say which profile to use."+ % (where, pick_reason))+ else:+ narrate = ("Fusion needs your Autodesk sign-in. I'd like to use your own browser - you're "+ "probably already signed in there, so it should take one click.")+ return _out("signin", narrate,+ ("FINISH THE SIGN-IN - never leave a first-time user parked here. " ++ ("A background sign-in tab is ALREADY OPEN in %s (session 'fusion-signin'). " % where+ if opened else "Auto-open failed - open it yourself: nbrowser_open_window "+ "{sessionId:'fusion-signin', url:'https://accounts.autodesk.com/', background:true, "+ "profile:'<chrome:their-account>'}. ") ++ "The profile was CHOSEN BY EVIDENCE, not guessed: each live profile was probed with "+ "nbrowser_login_state against accounts.autodesk.com, and the one holding a real Autodesk "+ "session wins (see signinProfileReason + per-profile autodesk/autodeskConfidence/"+ "autodeskCookies in signinProfileChoices). RELAY THAT REASON to the user. If "+ "signinProfileAmbiguous is true (nothing signed in, or several are), ASK them which "+ "account their Autodesk login belongs to instead of assuming. "+ "NAME THE EXACT PROFILE - never say just 'your browser' or 'your Chrome'. Power users "+ "run several profiles (personal / work / media), so say the browser AND the identity "+ "from `signinWhere` (e.g. 'Chrome - John Personal ([email protected])'). "+ "`signinProfileChoices` lists every profile with describe/email/displayName/active - if "+ "there is more than one, say which you used and OFFER TO SWITCH (re-run with a different "+ "profile). If the one you want shows extensionInstalled:false it needs the one-time "+ "extension install in THAT profile. "+ "TELL THE USER WHERE IT IS WAITING and OFFER ALL THREE: (a) they finish it themselves "+ "whenever they want, (b) you foreground that window for them "+ "(nbrowser_switch_window / browser window state), or (c) THEY LET YOU DRIVE IT - ask "+ "first (AskUserQuestion), then click through 'Continue with Google/Apple/Microsoft' "+ "using their warm session. Use the NATIVE browser (ABE), NEVER pup: pup is anonymous so "+ "Autodesk demands a full fresh login, while their real profile is usually already signed "+ "in. NEVER type their password or 2FA - if a secret is demanded, fusion_notify_owner "+ "toasts them to type it. Then click Fusion's own 'Sign In' (the webview exposes no UIA "+ "control, so use an image-space desktop_click on the button, or foreground + click) to "+ "fire the OAuth handoff; accept the 'Autodesk Identity Manager' overlay ('Always allow' "+ "+ 'Open'). Codes expire in ~2 min - if it expires, click Sign In again. Poll "+ "fusion_readiness until ready:true, then call fusion_demo again. Playbook: "+ "fusion-autodesk-signin."),+ needsSignin=True, signinOpened=opened, signinWhere=where,+ signinProfile=profile_used, signinSession="fusion-signin" if opened else None,+ signinProfileChoices=choices, signinProfileReason=pick_reason,+ signinProfileAmbiguous=ambiguous, nbState=nb_state)++ if not rd.get("ready"):+ return _out("waiting",+ "Fusion is starting up - one moment.",+ "Not ready yet (add-in still loading, or a licensing dialog). Poll fusion_readiness; it "+ "auto-resolves seat dialogs. When ready:true, call fusion_demo again. Reason: " ++ str(rd.get("_hint", ""))[:220])+ steps.append("Fusion ready")++ # ── 3. APS: set it up if we can, otherwise SELL it (never skip silently) ─────+ aps_ready = False+ try:+ ap = aps.handle_status({}).get("data", {})+ aps_ready = bool(ap.get("configured") and ap.get("signedIn") and ap.get("tokenLive"))+ except Exception:+ ap = {}+ if not aps_ready and stage != "skip_aps":+ return _out("aps_setup",+ "Next I'll turn on cloud search - it finds any design in your Autodesk cloud in about "+ "two seconds instead of a 30-minute folder crawl. It's a one-time sign-in.",+ "SET APS UP FOR THEM - do not merely mention it. (1) fusion_aps_status for exact state. "+ "(2) If not configured, an admin registers a PKCE app once, then fusion_aps_set_client_id. "+ "(3) fusion_aps_signin - drive it in their NATIVE browser (ABE), same reasoning as the "+ "Fusion sign-in: they're already logged into Autodesk there. (4) Poll fusion_aps_status "+ "until tokenLive:true, then call fusion_demo again - the demo then runs a LIVE sample "+ "search so they SEE the speed. If they decline setup, call fusion_demo {stage:'skip_aps'} "+ "and TELL them what they're missing (2s server-indexed search across the whole team hub; "+ "the old in-app search took 30+ min and crashed Fusion, so it is disabled). "+ "Skills: fusion-aps-search, fusion-aps-signin.",+ apsConfigured=bool(ap.get("configured")), apsSignedIn=bool(ap.get("signedIn")))++ # ── 4. APS sample search - let them SEE the speed ───────────────────────────+ aps_hits = []+ if aps_ready:+ try:+ import time as _t+ t0 = _t.time()+ sr = aps.handle_search({"query": demo_query or "board", "limit": 5}).get("data", {})+ aps_hits = [{"name": r.get("name"), "project": r.get("projectName")}+ for r in (sr.get("results") or [])[:5]]+ steps.append("APS sample search: %d hits in %.1fs" % (len(aps_hits), _t.time() - t0))+ except Exception as e:+ steps.append("APS sample search failed: %s" % e)++ # ── 5. Open an ELECTRONICS design, then walk schematic -> 2D -> 3D ──────────+ st = _proxy_to_addin("get_app_state", {}, timeout=20) or {}+ doc = (st.get("data") or st).get("activeDocument")+ is_elec = bool((st.get("data") or st).get("isElectronics"))+ if not is_elec:+ target = demo_query or (aps_hits[0]["name"] if aps_hits else "")+ if not target:+ return _out("need_design",+ "I need an electronics design to show off. Which board should I open?",+ "No electronics design open and nothing to pick. If APS is live, "+ "fusion_aps_search {query:'<board>'} then fusion_aps_open {query:'<name>'}; else ask "+ "the user for a design name / open one via fusion_open_cloud_file. ALWAYS open the "+ "PROJECT (EcadDesignProductType), never a .brd/.sch/3D child. Then call fusion_demo again.")+ return _out("open_design",+ "Opening %s so you can see a real board end to end." % target,+ "OPEN THE PROJECT then re-call fusion_demo: fusion_aps_open {query:'%s'} (APS finds it at "+ "any folder depth in seconds). CRITICAL: open the electronics PROJECT file, not the "+ "schematic/.brd/3D child - a child opens an isolated, often empty view. Poll "+ "fusion_get_app_state until isElectronics:true, then call fusion_demo again." % target,+ target=target)++ steps.append("electronics project open: %s" % doc)+ _shot("project")++ # schematic -> 2D board -> 3D board, screenshotting each so the AI can SHOW them+ try:+ _proxy_to_addin("show_schematic", {}, timeout=90); steps.append("showed schematic"); _shot("schematic")+ except Exception as e:+ steps.append("schematic failed: %s" % e)+ try:+ _proxy_to_addin("show_2d_board", {}, timeout=90); steps.append("showed 2D board"); _shot("board_2d")+ except Exception as e:+ steps.append("2D board failed: %s" % e)+ try:+ _proxy_to_addin("show_3d_board", {}, timeout=180); steps.append("showed 3D board"); _shot("board_3d")+ except Exception as e:+ steps.append("3D board failed: %s" % e)++ aps_line = ("Cloud search is live - I searched your whole Autodesk hub in about two seconds and "+ "found: %s. " % ", ".join(h["name"] for h in aps_hits[:3])) if aps_hits else ""+ return _out("done",+ "That's the tour: your %s project, its schematic, the 2D board layout, and the real 3D board. "+ "%sFrom here just ask - export Gerbers/BOM/CPL for the fab, generate a laser-etched IPC "+ "package, load JLCPCB design rules, or open any design in your cloud." % (doc, aps_line),+ "DEMO COMPLETE. SHOW the user the screenshots in order (project, schematic, board_2d, "+ "board_3d) - they are in `screenshots` with labels. Narrate the `narrate` line. Then offer "+ "concrete next steps in THEIR words: 'export the Gerbers', 'make me a SOIC-8 with my part "+ "number etched on it' (fusion_generate_package), 'check this against JLCPCB rules' "+ "(fusion_load_design_rules), 'find my other boards' (fusion_aps_search). Full catalog: "+ "fusion_describe. Demo playbook: the fusion-demo skill.",+ done=True, document=doc, apsSampleSearch=aps_hits)+++def _orchestrate_board_stackup(args: dict) -> dict:+ """Read a board's PHYSICAL fabrication stackup (see the pcb-stackup skill). A PCB is a+ stack of copper + dielectric: Cu(L1) / prepreg / Cu(L2) / core / ... / Cu(bottom). This+ reads the copper count + copper/dielectric thicknesses from the EAGLE design rules+ (layerSetup / mtCopper / mtIsolate in the .brd) and the measured FR4 extent from the 3D+ body, and returns the ordered layer list (name, thickness, z) so a caller can build the+ stackup table + the real-thickness exploded 3D view."""+ import re as _re+ import tempfile as _tf+ # 1) design rules from the EAGLE .brd (board editor active)+ _proxy_to_addin("show_2d_board", {}, timeout=60)+ brd = os.path.join(_tf.gettempdir(), "adom_stackup.brd")+ r = _proxy_to_addin("export_eagle_source", {"outputPath": brd}, timeout=120)+ if not r.get("success"):+ return {"success": False, "error": "could not export .brd for stackup: %s" % (r.get("error") or r.get("message")),+ "_hint": "Open the PROJECT and switch to the board (fusion_show_2d_board) first."}+ try:+ txt = open(brd, encoding="latin1", errors="replace").read()+ except Exception as e:+ return {"success": False, "error": "could not read .brd: %s" % e}+ def _p(name):+ m = _re.search(r'<param name="%s" value="([^"]*)"' % name, txt)+ return m.group(1) if m else None+ layer_setup = _p("layerSetup") or ""+ mt_copper = [x for x in (_p("mtCopper") or "").split()]+ mt_isolate = [x for x in (_p("mtIsolate") or "").split()]+ copper_layers = _re.findall(r"\d+", layer_setup) # e.g. ['1','2','15','16']+ ncu = len(copper_layers)+ # bonds between copper layers, in order: '+' prepreg, '*' core+ bonds = [("core" if c == "*" else "prepreg") for c in layer_setup if c in "+*"]+ # 2) measure the FR4 Board body (3D)+ _proxy_to_addin("show_3d_board", {}, timeout=60)+ # Find the LARGEST-XY body named 'board' (the FR4 substrate) - there can be several+ # 'board'-named bodies (small ones), so pick the substrate by area, not the first.+ mscript = (+ "import adsk.core, adsk.fusion\n"+ "app=adsk.core.Application.get(); des=adsk.fusion.Design.cast(app.activeProduct)\n"+ "best=None; ba=-1.0\n"+ "for occ in des.rootComponent.allOccurrences:\n"+ " for b in occ.component.bRepBodies:\n"+ " if b.name.lower()=='board':\n"+ " bb=b.boundingBox; mn=bb.minPoint; mx=bb.maxPoint\n"+ " a=(mx.x-mn.x)*(mx.y-mn.y)\n"+ " if a>ba: ba=a; best=((mx.x-mn.x)*10,(mx.y-mn.y)*10,(mx.z-mn.z)*10)\n"+ "print('FR4 %.4f %.4f %.4f'%best if best else 'FR4 none')")+ mm = _proxy_to_addin("run_modeling_script", {"script": mscript}, timeout=60)+ fr4 = None+ try:+ msg = ""+ if isinstance(mm, dict):+ o = mm.get("output")+ msg = (json.loads(o).get("message") if isinstance(o, str) and o.startswith("{") else (mm.get("message") or o)) or ""+ m = _re.search(r"FR4\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)", str(msg))+ if m:+ fr4 = {"x_mm": float(m.group(1)), "y_mm": float(m.group(2)), "dielectric_mm": float(m.group(3))}+ except Exception:+ pass+ def _num(s):+ try: return float(str(s).replace("mm", ""))+ except Exception: return None+ return {+ "success": True,+ "layerSetup": layer_setup,+ "copperLayers": copper_layers,+ "copperCount": ncu,+ "copperThickness_mm": [_num(t) for t in mt_copper[:ncu]] if mt_copper else None,+ "dielectricBonds": bonds, # e.g. ['prepreg','core','prepreg']+ "dielectricThickness_raw": mt_isolate, # design-rule list (may hold 2-layer defaults)+ "fr4": fr4,+ "brdPath": brd,+ "_hint": ("Physical stack (see pcb-stackup skill): the FR4 is NOT one slab - it is "+ "prepreg/core/prepreg with copper between. Build the table + exploded view per that skill. "+ "If mtIsolate looks like 2-layer defaults, fit a symmetric split to fr4.dielectric_mm."),+ }+++def dispatch_command(command: str, args: dict) -> dict:+ """Dispatch a command to the appropriate handler."""+ # Direct handlers (don't need the add-in)+ handler = COMMAND_HANDLERS.get(command)+ if handler is not None:+ return handler(fusion_info, args)++ # Check if Fusion is installed and running before any add-in-dependent command.+ # We do NOT auto-launch — that causes 60s+ hangs when Fusion isn't running.+ # The user should launch Fusion themselves; we just report the status.+ # 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"):+ 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():+ return {+ "success": False,+ "error": "Fusion 360 is installed but not running.",+ "errorCode": "fusion_not_running",+ "_hint": "Call fusion_start to start Fusion 360, then wait for the add-in to become ready (fusion_start blocks until ready). Then retry this command.",+ }+ # Fusion is running — check if add-in is responsive (short timeout).+ # But first, if the add-in is busy with a long command, skip the+ # responsiveness check and fall through to the busy gate below.+ addin_busy = _check_addin_status(timeout=3.0)+ if addin_busy and addin_busy.get("busy"):+ pass # Fall through to busy gate+ elif not wait_for_addin(timeout=5):+ return {+ "success": False,+ "error": "Fusion 360 is running but AdomBridge add-in not responding.",+ "errorCode": "fusion_addin_not_responding",+ "_hint": "The add-in isn't responding. Fix it YOURSELF - never ask the user: the bridge auto-installs the add-in to ALL Fusion add-in dirs (2025+ Fusion scans %APPDATA%/Autodesk/FusionAddins; the legacy API/AddIns dirs are silently ignored - issue #63), so restart Fusion via fusion_stop + fusion_start and it loads (runOnStartup). Verify with fusion_addin_status.",+ }++ # ── Busy gate: reject add-in commands immediately if a long command is running ──+ # Bridge-level commands (COMMAND_HANDLERS) already returned above — they use+ # Win32 APIs and don't touch the add-in, so they always work during a walk.+ # But add-in commands (and orchestrated commands that proxy to the add-in)+ # would pile up behind _main_thread_lock for 300s and crash the host.+ busy = _get_long_command()+ if busy and command not in LONG_RUNNING_COMMANDS:+ # This command would block behind the long-running one — reject immediately+ progress = _get_busy_progress()+ elapsed = round(_time.time() - busy["startedAt"], 1)+ progress_pct = None+ if progress and progress.get("foldersVisited") and progress.get("queueSize") is not None:+ total = progress["foldersVisited"] + progress["queueSize"]+ if total > 0:+ progress_pct = round(100 * progress["foldersVisited"] / total)+ return {+ "success": False,+ "error": (+ f"Fusion main thread busy — {busy['command']} has been running for {elapsed}s. "+ f"Your command '{command}' cannot execute until it finishes."+ ),+ "errorCode": "main_thread_busy",+ "busyCommand": busy["command"],+ "elapsedSeconds": elapsed,+ "progress": progress,+ "_hint": (+ f"A cloud search ({busy['command']}) is in progress"+ + (f" (~{progress_pct}% done)" if progress_pct is not None else "")+ + f", running for {elapsed}s. "+ "Do NOT retry add-in commands (get_app_state, document_info, etc.) — they will "+ "all be rejected until the search finishes. Commands that still work right now: "+ "fusion_window_info, fusion_screenshot_fusion, fusion_click_fusion, "+ "fusion_send_key, fusion_close_window. Wait for the search to complete, then retry."+ ),+ }++ # Cross-bridge case: this bridge's _long_command is None but the add-in+ # might be busy from another bridge/session. Quick non-blocking check.+ if not busy and command not in LONG_RUNNING_COMMANDS:+ addin_status = _check_addin_status(timeout=3.0)+ if addin_status and addin_status.get("busy"):+ elapsed = addin_status.get("elapsedSeconds", 0)+ walk = addin_status.get("walkProgress")+ busy_cmd = addin_status.get("busyCommand", "unknown")+ resp = {+ "success": False,+ "error": (+ f"Fusion main thread busy — {busy_cmd} running for {elapsed}s "+ f"(from another bridge/session). Your command '{command}' cannot "+ f"execute until it finishes."+ ),+ "errorCode": "main_thread_busy",+ "busyCommand": busy_cmd,+ "elapsedSeconds": elapsed,+ "_hint": (+ f"A long-running command ({busy_cmd}) is in progress from another session. "+ "Do NOT retry add-in commands — they will all be rejected until it finishes. "+ "Do NOT press Escape — the add-in is working, not stuck on a dialog. "+ "Commands that still work: fusion_window_info, fusion_screenshot_fusion, "+ "fusion_click_fusion, fusion_send_key, fusion_close_window."+ ),+ }+ if walk:+ resp["progress"] = walk+ return resp++ # Honour the caller's expectDocument assertion on ANY add-in verb (mutating OR+ # read) before it can act on the wrong active document. A no-op unless the+ # caller passed expectDocument. Runs after the readiness/busy gates so a+ # not-running/blocked error surfaces first. (issue #289 + read-verb BOM repro.)+ _doc_guard = _guard_expect_document(args)+ if _doc_guard is not None:+ return _doc_guard++ # Orchestrated multi-step commands (handled at bridge level)+ if command in _OPEN_WITH_SCREENSHOT:+ return _handle_open_with_screenshot(fusion_info, args, command)+ if command == "open_lbr":+ return _orchestrate_open_lbr(args)+ if command == "save_lbr":+ return _merge_dialog_array(_orchestrate_save_lbr(args))+ if command == "attach_3d_package":+ return _merge_dialog_array(_orchestrate_attach_3d_package(args))+ if command == "make_3d_package":+ return _apply_failure_dialogs(_orchestrate_make_3d_package(args))+ if command == "build_library_3d":+ return _apply_failure_dialogs(_orchestrate_build_library_3d(args))+ if command == "capture_library_views":+ return _apply_failure_dialogs(_orchestrate_capture_library_views(args))+ if command == "cleanup_cloud_files":+ return _orchestrate_cleanup_cloud_files(args)+ if command == "generate_package":+ return _apply_failure_dialogs(_orchestrate_generate_package(args))+ if command == "export_optimized_glb":+ return _orchestrate_export_optimized_glb(args)+ if command == "fetch_optimized_glb":+ return _orchestrate_fetch_optimized_glb(args)+ if command == "demo":+ return _orchestrate_demo(args)+ if command == "signin":+ return _orchestrate_signin(args)+ if command == "signin_2fa":+ return _orchestrate_signin_2fa(args)+ if command == "board_stackup":+ return _orchestrate_board_stackup(args)++ # Add-in proxy commands+ if command in ADDIN_COMMANDS:+ addin_cmd = ADDIN_COMMAND_MAP.get(command, command)+ proxy_timeout = ADDIN_COMMAND_TIMEOUTS.get(command, 30)+ # Wrap long-running commands in set/clear so the gate knows they're active.+ # Pre-dismiss blocking dialogs: modal dialogs steal Fusion's event loop,+ # preventing fireCustomEvent from being processed. Without this, the+ # walk appears "busy" but never actually starts — _walk_progress stays+ # None indefinitely while the command sits in the event queue.+ if command in LONG_RUNNING_COMMANDS:+ try:+ info = get_fusion_window_info()+ if info.get("dialogs"):+ for d in info["dialogs"]:+ try:+ # Use WM_CLOSE via PostMessage — doesn't steal foreground+ # (unlike send_key which uses SendInput + SetForegroundWindow).+ # WM_CLOSE is also more reliable than Escape for Qt dialogs.+ close_window(d.get("hwnd"))+ except Exception:+ pass+ import time as _time+ _time.sleep(0.5) # give Fusion a moment to process the dismiss+ except Exception:+ pass+ _set_long_command(command)+ try:+ return _proxy_to_addin(addin_cmd, args, timeout=proxy_timeout)+ finally:+ _clear_long_command()+ result = _proxy_to_addin(addin_cmd, args, timeout=proxy_timeout)+ # After a state-changing op, surface any dialog/owned-popup the AI must analyze+ # (the Hub upload-close confirm, a save prompt, recovery, etc.) so it can't fly+ # blind. Read-only verbs are excluded to avoid per-call screenshot latency.+ if command in MUTATING_COMMANDS:+ result = _merge_dialog_array(result)+ return result++ # Unknown command — check installation/running status for helpful errors+ if not fusion_info.get("installed"):+ return {+ "success": False,+ "error": f"Fusion 360 is not installed on this machine. (command: {command})",+ "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():+ return {+ "success": False,+ "error": f"Fusion 360 is installed but not running. (command: {command})",+ "errorCode": "fusion_not_running",+ "_hint": "Call fusion_start to start Fusion 360 and wait for the add-in to be ready, then retry this command.",+ }+ 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.",+ }+++def _build_status() -> dict:+ """Build the /status payload — the endpoint AD declares as healthEndpoint.++ MUST return HTTP 2xx whenever the server is up: AD's health check polls this+ path and a 404 (the classic manifest-vs-server mismatch) makes AD wait the+ full startup grace then report a generic "not reachable". We also self-report+ the GUI chip fields {led, summary, tooltip} — AD renders them verbatim (the+ bridge owns its color; AD owns only the unreachable→gray state).+ """+ import platform++ info = fusion_info or {}+ installed = bool(info.get("installed"))+ running = _is_fusion_running() if installed else False+ addin = _probe_addin() if running else None+ addin_ok = bool(addin)++ if platform.system() != "Windows":+ led, summary = "yellow", "Unsupported OS"+ tooltip = ("Bridge running, but this host is not Windows. Fusion 360 verbs "+ "need a Windows host with Fusion 360 installed.")+ elif not installed:+ led, summary = "yellow", "Fusion not installed"+ tooltip = ("Bridge running, but Fusion 360 is not installed. The AI can install it "+ "for the user (fusion-onboarding), then fusion_start.")+ elif not running:+ led, summary = "yellow", "Fusion not running"+ tooltip = "Fusion 360 is installed but not running. Call fusion_start to launch it."+ elif not addin_ok:+ led, summary = "yellow", "Add-in not connected"+ tooltip = ("Fusion 360 is running but the AdomBridge add-in isn't responding yet "+ "(still loading; if it persists the AI restarts Fusion via fusion_stop/start).")+ else:+ led, summary = "green", "Fusion ready"+ tooltip = "Fusion 360 running, AdomBridge add-in connected. All fusion_* verbs available."++ return {+ "status": "ok",+ "led": led,+ "summary": summary,+ "tooltip": tooltip,+ "bridgeVersion": BRIDGE_VERSION,+ "fusion": {**info, "running": running},+ "addin": addin,+ }+++class FusionBridgeHandler(BaseHTTPRequestHandler):+ """HTTP request handler for the Fusion 360 bridge server."""++ def do_GET(self):+ # /status is the manifest's healthEndpoint; /health is kept as a+ # back-compat alias (older callers + the add-in-probe code path). Both+ # return the same 2xx payload — new chip fields are purely additive.+ if self.path in ("/status", "/health"):+ self._respond(200, _build_status())+ else:+ self._respond(404, {"error": "Not found"})++ def do_POST(self):+ if self.path != "/command":+ self._respond(404, {"error": "Not found"})+ return++ content_length = int(self.headers.get("Content-Length", 0))+ body = self.rfile.read(content_length)++ try:+ request = json.loads(body)+ except json.JSONDecodeError as e:+ self._respond(400, {"success": False, "error": f"Invalid JSON: {e}"})+ return++ command = request.get("command", "")+ args = request.get("args", {})++ print(f"[Fusion Bridge] Command: {command} | Args: {json.dumps(args)}")++ try:+ result = dispatch_command(command, args)+ self._respond(200, result)+ except Exception as e:+ print(f"[Fusion Bridge] ERROR: {e}")+ traceback.print_exc()+ self._respond(500, {+ "success": False,+ "error": f"Internal error: {e}",+ })++ def _respond(self, status: int, data: dict):+ self.send_response(status)+ self.send_header("Content-Type", "application/json")+ self.end_headers()+ self.wfile.write(json.dumps(data).encode("utf-8"))++ def log_message(self, format, *args):+ print(f"[Fusion Bridge] {args[0]} {args[1]} {args[2]}")+++def _prune_bridge_logs(max_mb: int = 8, keep_tail_mb: int = 2) -> dict:+ """LOG JANITOR (John, 2026-07-22: "do you have a janitor to clean up your log?").++ AD captures this bridge's stdout/stderr into ~/.adom/bridge-logs/fusion360.log. Nothing+ rotated it, so it grew unbounded - fine today (it is small), a slow leak on a long-lived box.+ On every bridge start we truncate our OWN logs to the most recent keep_tail_mb once they pass+ max_mb, keeping the tail because that is where a crash traceback lives.++ Only touches files this bridge owns (fusion360*). Never raises.+ """+ import glob+ out = {"checked": 0, "pruned": []}+ try:+ d = os.path.join(os.path.expanduser("~"), ".adom", "bridge-logs")+ for path in glob.glob(os.path.join(d, "fusion360*.log")):+ out["checked"] += 1+ try:+ sz = os.path.getsize(path)+ if sz <= max_mb * 1024 * 1024:+ continue+ with open(path, "rb") as f:+ f.seek(-keep_tail_mb * 1024 * 1024, os.SEEK_END)+ tail = f.read()+ with open(path, "wb") as f:+ f.write(b"[adom-bridge log janitor] truncated %d MB -> tail %d MB\n"+ % (sz // (1024 * 1024), keep_tail_mb))+ f.write(tail)+ out["pruned"].append({"file": os.path.basename(path), "wasMB": sz // (1024 * 1024)})+ except Exception:+ continue+ if out["pruned"]:+ print("[Fusion Bridge] log janitor: %s" % out["pruned"])+ except Exception:+ pass+ return out+++def main():+ global fusion_info++ # Line-buffer stdout/stderr. AD spawns the bridge console-less and captures+ # our stdout+stderr into ~/.adom/bridge-logs/fusion360.log. Python BLOCK-buffers+ # stdout when it's not a TTY, so without this the buffer never flushes while+ # serve_forever() runs → the log stays 0 bytes (and a spawn-crash traceback+ # would be lost). Line buffering flushes every print()/traceback on newline.+ try:+ sys.stdout.reconfigure(line_buffering=True)+ sys.stderr.reconfigure(line_buffering=True)+ except Exception:+ pass++ _prune_bridge_logs()+ _start_signin_janitor()++ port = DEFAULT_PORT+ if "--port" in sys.argv:+ idx = sys.argv.index("--port")+ if idx + 1 < len(sys.argv):+ port = int(sys.argv[idx + 1])++ # Early banner BEFORE detection — so the log proves we started even if+ # detect_fusion() is slow or wedges. (KiCad bridge prints an equivalent line.)+ print(f"[Fusion Bridge] starting - version {BRIDGE_VERSION}, port {port}, "+ f"healthEndpoint /status, pid {os.getpid()}", flush=True)++ fusion_info = detect_fusion()++ print(f"[Fusion Bridge] Fusion 360 detection result:")+ print(f" Installed: {fusion_info.get('installed', False)}")+ if fusion_info.get("installed"):+ print(f" Exe path: {fusion_info.get('exe_path')}")+ print(f" AddIns: {fusion_info.get('addins_dir')}")+ print(f" Add-in: {'installed' if fusion_info.get('addin_installed') else 'not installed'}")+ print(f" Running: {fusion_info.get('running')}")++ # ALWAYS sync the add-in on startup - it is idempotent (_sync_directory copies+ # only CHANGED files). The old `if not addin_installed` guard meant a STALE+ # add-in was never UPDATED: an add-in fix (e.g. get_parameters, caught live+ # 2026-07-06) never reached users who already had ANY copy, because the bridge+ # only deployed when it was entirely MISSING. Always-sync fixes that. It is safe+ # while Fusion is up (locked add-in files simply skip via the per-target OSError+ # catch); the update lands on the next bridge respawn with Fusion closed+ # (fusion_stop -> bridge_install -> fusion_start).+ print(f"[Fusion Bridge] Syncing AdomBridge add-in (idempotent; updates a stale copy)...")+ try:+ install_addin()+ fusion_info = detect_fusion() # Re-detect after sync+ print(f" Add-in: {'installed' if fusion_info.get('addin_installed') else 'FAILED'}")+ except Exception as e:+ print(f" Add-in sync failed: {e}")+ else:+ print(f" WARNING: Fusion 360 not found. Some commands will fail.")++ addin_health = _probe_addin()+ if addin_health:+ print(f" Add-in server: running on port {ADDIN_PORT}")+ else:+ print(f" Add-in server: not running (port {ADDIN_PORT})")++ all_commands = list(COMMAND_HANDLERS.keys()) + sorted(ADDIN_COMMANDS)+ print(f"[Fusion Bridge] Available commands: {', '.join(all_commands)}")++ # AD (>=1.9.63) passes ADOM_BIND_HOST (always 127.0.0.1) to every bridge it+ # spawns. Honor it and NEVER bind 0.0.0.0/'' by default - a public bind pops a+ # Windows Firewall "allow access?" dialog, and AD's guarantee to users is no+ # firewall prompts. Default to loopback if the var is absent (e.g. local dev).+ bind_host = os.environ.get("ADOM_BIND_HOST", "127.0.0.1")+ server = ThreadingHTTPServer((bind_host, port), FusionBridgeHandler)+ server.daemon_threads = True+ print(f"[Fusion Bridge] Listening on http://{bind_host}:{port}")+ print(f"[Fusion Bridge] Health check: http://{bind_host}:{port}/status (alias /health)")+ print(f"[Fusion Bridge] Press Ctrl+C to stop.")++ try:+ server.serve_forever()+ except KeyboardInterrupt:+ print("\n[Fusion Bridge] Shutting down.")+ server.server_close()+++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+ # re-raise for a non-zero exit.+ try:+ main()+ except Exception:+ print("[Fusion Bridge] FATAL: bridge failed to start", flush=True)+ traceback.print_exc()+ sys.stderr.flush()+ raise+--- a/addin/AdomBridge/commands/modeling.py+++ b/addin/AdomBridge/commands/modeling.py@@ -1,68 +1,111 @@⋯ 21 unchanged lines ⋯ import adsk.fusion +def _classify_modeling_error(msg: str):+ """Map common raw Fusion API exceptions to a stable errorCode + actionable hint.++ Raw adsk exceptions surface as bare 'RuntimeError: N : <text>' strings that+ honour neither the bridge's stable-errorCode convention nor its _hint one, so+ an AI driving the bridge has to infer the (non-obvious) fix. These three cost+ real session time to diagnose (bridge feedback, 2026-07-23); the Part-vs-+ Assembly one invalidates a whole modelling approach and is undocumented. This+ mirrors the dialog classifier (handlers/dialog_classify.py) one layer down.++ Returns (errorCode, hint) or (None, None) if unrecognised.+ """+ m = (msg or "").lower()+ if "can only contain one component" in m or "part design document" in m:+ return ("part_design_single_component",+ "This document was created from Fusion's PART template, which allows "+ "only ONE component (no assemblies) — that is why the operation was "+ "rejected. Create the document via the API with "+ "documents.add(adsk.core.DocumentTypes.FusionDesignDocumentType) for an "+ "assembly-capable doc, or model with bodies instead of components. Note "+ "the API and UI defaults for new-document type differ.")+ if "root component name cannot be changed" in m:+ return ("root_component_rename",+ "The ROOT component takes the DOCUMENT's name and cannot be renamed "+ "directly. Rename on save instead (fusion_save_to_cloud with name=...), "+ "or rename a child component rather than the root.")+ if "refers to a deleted object" in m:+ return ("deleted_object_reference",+ "A handle you held now points at an object that was deleted, or whose "+ "document was closed, since you fetched it. Re-fetch the object from the "+ "live design (re-query the component/body/sketch) before using it; do "+ "not cache API proxies across edits that delete/rebuild geometry.")+ return (None, None)++ def handle_run_modeling_script(app: adsk.core.Application, args: dict) -> dict: script = args.get("script") or args.get("code") or "" if not script:⋯ 25 unchanged lines ⋯ with contextlib.redirect_stdout(buf): exec(script, ns) except Exception as e:- return {"success": False, "error": str(e),- "data": {"traceback": traceback.format_exc(), "stdout": buf.getvalue()},- "_hint": "Modeling-script error (a code/geometry problem, NOT the Fusion license). "- "Read-only Fusion still creates geometry in memory; only cloud-save is blocked."}+ code, hint = _classify_modeling_error(str(e))+ resp = {"success": False, "error": str(e),+ "data": {"traceback": traceback.format_exc(), "stdout": buf.getvalue()}}+ if code:+ resp["errorCode"] = code+ resp["_hint"] = hint+ else:+ resp["_hint"] = ("Modeling-script error (a code/geometry problem, NOT the Fusion "+ "license). Read-only Fusion still creates geometry in memory; only "+ "cloud-save is blocked.")+ return resp res = ns.get("result") # Keep the return JSON-safe.⋯ 3 unchanged lines ⋯ return {"success": True, "output": out or "Modeling script ran successfully.", "data": {"result": res, "documentName": app.activeDocument.name if app.activeDocument else None}}+--- a/addin/AdomBridge/commands/design_info.py+++ b/addin/AdomBridge/commands/design_info.py@@ -1,98 +1,133 @@⋯ 52 unchanged lines ⋯ bodies = [] for i in range(comp.bRepBodies.count): body = comp.bRepBodies.item(i)- bodies.append({+ entry = { "name": body.name, "isVisible": body.isVisible, "isSolid": body.isSolid, "volume_cm3": body.volume if body.isSolid else None,- })+ }+ # Geometry read-back so an AI driving the bridge can verify what it BUILT+ # without eyeballing a matte-black part on a dark canvas, where pixels are a+ # weak verification channel (bridge feedback, 2026-07-23). Two real bugs in+ # that session were invisible in the render but obvious here:+ # - bbox_mm catches mis-placed/offset geometry (a platform built at Z-167.6+ # instead of -83.8 because an offset plane's offset got applied twice).+ # - cylindricalFaceCount is the cheapest "did the holes actually get cut?"+ # signal: a symmetric cut that reaches nothing fails SILENTLY (no+ # exception), and the cylindrical-face count is what surfaces it.+ # Units in mm (Fusion's internal unit is cm) to kill off-by-ten errors.+ # NOTE: this bbox is component-LOCAL; assembly placement verification through+ # occurrence transforms (the requested worldSpace flag) is a tracked follow-up.+ try:+ bb = body.boundingBox+ if bb:+ entry["bbox_mm"] = {+ "x": [round(bb.minPoint.x * 10.0, 3), round(bb.maxPoint.x * 10.0, 3)],+ "y": [round(bb.minPoint.y * 10.0, 3), round(bb.maxPoint.y * 10.0, 3)],+ "z": [round(bb.minPoint.z * 10.0, 3), round(bb.maxPoint.z * 10.0, 3)],+ }+ except Exception:+ pass+ try:+ faces = body.faces+ entry["faceCount"] = faces.count+ cyl = 0+ for fi in range(faces.count):+ geom = faces.item(fi).geometry+ if geom and geom.surfaceType == adsk.core.SurfaceTypes.CylinderSurfaceType:+ cyl += 1+ entry["cylindricalFaceCount"] = cyl+ except Exception:+ pass+ bodies.append(entry) # Also check mesh bodies (STL/3MF imports create these) mesh_bodies = []⋯ 31 unchanged lines ⋯ pass return result+--- a/addin/AdomBridge/commands/assembly_bom.py+++ b/addin/AdomBridge/commands/assembly_bom.py@@ -1,140 +1,168 @@⋯ 117 unchanged lines ⋯ "parts": rows, } + # The default treatAsUnit under-collapses relative to Fusion's own Manage->BOM:+ # a PURCHASED subassembly whose name doesn't contain "with fasteners" gets+ # descended into and emitted as its modeled internals (e.g. 240 "Bearing Ball"+ # rows that Fusion's BOM never shows), and nothing signalled it (bridge feedback,+ # 2026-07-23). There is no purely structural signal separating a purchased vs an+ # organizational subassembly, so rather than widen the default (which would silently+ # change counts for everyone), make the mismatch LOUD: flag parts that look like the+ # internals of a bought unit and suggest a treatAsUnit list. Non-behaviour-changing —+ # counts are unchanged; only advisory fields are added.+ _INTERNAL_HINTS = ("bearing ball", "bearing seal", "ball bearing", "idler",+ "pulley", "roller", "retaining ring", "circlip", "detent ball")+ flagged = sorted({r["componentName"] for r in rows+ if any(h in r["componentName"].lower() for h in _INTERNAL_HINTS)})+ if flagged:+ out["possibleUnderCollapse"] = flagged+ out["_hint"] = (+ "Some parts look like the INTERNALS of a purchased subassembly (e.g. "+ + ", ".join(flagged[:3])+ + ") that Fusion's own Manage->BOM would show as a single collapsed row. The "+ "default treatAsUnit only collapses names containing 'with fasteners', so a "+ "purchased unit named differently is exploded into its modeled parts and your "+ "counts run high. If these belong to a bought unit, re-run with treatAsUnit "+ "including that subassembly's name (e.g. "+ "treatAsUnit=[\"with fasteners\",\"idler pulley\",\"bearing\"]) to match Fusion's "+ "BOM. partNumber can't auto-detect this — Fusion auto-fills it from the "+ "component name, so the internals carry part numbers too.")+ outpath = args.get("outputPath") if outpath: try:⋯ 14 unchanged lines ⋯ out["csvError"] = str(e) return out+--- a/skills/fusion-driving/SKILL.md+++ b/skills/fusion-driving/SKILL.md@@ -1,219 +1,224 @@⋯ 62 unchanged lines ⋯ ## Guard the active document with `expectDocument` (a tab-switch retargets your writes) -Every mutating verb acts on Fusion's **active document**, and a user switching tabs in Fusion-silently retargets it. A `fusion_run_modeling_script` meant for one design can land in another and-delete bodies out of it (a real near-miss, issue #289). So across a multi-step mutation, pass-**`expectDocument`** with the name you expect to be active:+Every fusion verb acts on Fusion's **active document**, and a user (or an auto-expanding panel)+switching tabs in Fusion silently retargets it. A `fusion_run_modeling_script` meant for one design+can land in another and delete bodies out of it (a real near-miss, issue #289); a **read** verb like+`fusion_assembly_bom` can just as silently return a completely different assembly. So across a+multi-step session, pass **`expectDocument`** with the name you expect to be active: ```bash adom-desktop fusion_run_modeling_script '{"script":"...","expectDocument":"Monitor arms v1"}' ``` If the active document is not that, the bridge refuses **before executing** and returns-`errorCode: "wrong_document"` with `expected`/`actual`, instead of mutating the wrong file. It is a-**name** check, so if the doc was renamed mid-session, pass the new name. Honoured on every mutating-verb: `run_modeling_script`, `execute_text_command`, `electron_run`, `close_document`,-`close_all_documents`, `import_step`, `set_parameter`, `save_to_cloud`, `delete_cloud_file`.+`errorCode: "wrong_document"` with `expected`/`actual`, instead of acting on the wrong file. It is a+**name** check, so if the doc was renamed mid-session, pass the new name. Honoured on **every add-in+verb — both mutating and read** (e.g. `run_modeling_script`, `execute_text_command`, `electron_run`,+`close_document`, `close_all_documents`, `import_step`, `set_parameter`, `save_to_cloud`,+`delete_cloud_file`, and the read verbs `assembly_bom`, `physical_properties`, `board_info`,+`get_design_info`). The guard is enforced in the bridge dispatcher, so it covers a verb the moment it+proxies to the add-in. ## Rule 1 - after EVERY mutating op, READ the popup array. Then analyze. Then proceed. ⋯ 134 unchanged lines ⋯ ``` Slow is smooth, smooth is fast. The one time you skip the look is the time you lose the library.+
Comments
No comments yet.
Log in to comment.