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.
Import honesty + pollable detached 3D build (#65) #4
Closes the two bridge-side halves of adom/fusion-bridge#65 and adds the piece the Cloud Libraries fusion lane needs for 3D. Three focused changes in server.py, changelog under Unreleased, no version bump (yours at release).
open_lbr joins the dialog-guarded dispatches. It returned _orchestrate_open_lbr raw while its neighbors ride _merge_dialog_array, so a malformed .lbr reported success (Fusion opens an empty design named like the file, behind an owned Error popup). One-line wrap plus a comment naming the incident.
The create chain fails hard when the library never presented. Previously: stage done, verdict not_proven, zero frames, while the user looked at the error dialog. Now zero frames plus not-proven classifies blocking dialogs (handlers/dialog_classify, same categories as _apply_failure_dialogs), carries the dialog text, and lands stage failed with errorCode library_never_presented.
The detached fusion_build_library_3d keeps its report and is pollable. The background thread swallowed the core result; callers had to grep the bound .lbr for urns. The run now returns a runId and statusVerb fusion_prove_result, writing per-part progress (minting X (3/7)) and the final per-part report into the same per-run store as the create chain (fusion-prove-runs.json, capped 20, same shape). No new verb, no new poll surface. Sync mode (async:false) unchanged.
Why now: the wiki's Cloud Libraries Export (Fusion) landed today on the fusion_open_lbr path (multi-deviceset import is proven; the earlier failure was our own illegal pin name, fixed in adom-lbr 2.17.1). Colby wants the 3D bodies attached through the bridge with no AI in the loop; the bundle already ships one STEP per part plus per-part .lbr inputs are derivable. With this PR merged, the page recipe is: open_lbr, then build_library_3d {parts}, then poll fusion_prove_result {runId}, all within web-control's 135s envelopes.
Testing: py_compile clean; the guard and store patterns are lifts of your existing ones (merge wrap, create-chain _save). I did NOT run a live Fusion round against a modified build on Colby's box; the three paths are exercised by your existing flows the moment they run (open_lbr on any bad file, any shape-A create with a bad file, any async build). Happy to do a live pass together if you want it before merge.
Diff Skip to comments (3)
@@ -1,12677 +1,12712 @@⋯ 3965 unchanged lines ⋯ import threading as _th import time as _tt- _BUILD_IN_FLIGHT[out_path] = _tt.time()-- def _run():- try:- _build_library_3d_core(args)- except Exception:- pass- finally:- # ALWAYS clear, even on failure: a marker that outlives its build would- # refuse every later capture (page issue #62).- _BUILD_IN_FLIGHT.pop(out_path, None)- _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})- # A missing APS workspace folder no longer gates the build: each part's mint saves- # straight to the cloud through the add-in and only needs APS as its upload fallback- # (adom/adom-bridge#93). folder_id=None flows down and the mint gates itself.- 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 Bridge relay times out the REQUEST at ~60s - you may get 'Request timed out' even "- "though the build KEEPS RUNNING server-side and finishes. Do NOT assume it failed: wait, "- "then verify by reading the boundLbr (count <package3d name=) + the before/after PNGs in "- "C:/tmp/conduit-screenshots. If a part is missing, just re-run build_library_3d for the "- "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 _taskbar_progress(pct=None):- """Show REAL progress on Fusion's Windows-taskbar button instead of orange flashes- (John, 2026-08-23: he keeps seeing attention-flash highlights and no progress bar).-- Windows flashes a BACKGROUND window's button orange whenever the app requests- attention (every dialog Fusion raises while the bridge drives it does this). We- cannot stop Fusion REQUESTING it - but we can (a) clear the flash the moment we- have handled whatever raised it, and (b) replace the signal with an honest- progress bar during chain stages, paced by this machine's measured estimates.- pct=None clears the bar. Best-effort: never raises, silently no-ops without AD."""- try:- if not ad_client.available():- return- info = get_fusion_window_info() or {}- hwnd = info.get("hwnd") or (info.get("mainWindow") or {}).get("hwnd")- if not hwnd:- return- args = {"hwnd": int(hwnd), "flash": "stop"}- if pct is None:- args["progress"] = {"state": "none"}- else:- args["progress"] = {"state": "normal",- "value": max(1, min(100, int(pct))), "max": 100}- ad_client.call("desktop_taskbar", args, timeout=4)- except Exception:- pass---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 _handle_dismiss_blocking_dialogs(fusion_info: dict, args: dict) -> dict:- """NATIVE fusion_dismiss_blocking_dialogs (John, 2026-08-23: he hates seeing Fusion- dialogs while the bridge drives, and the verb was falling to ab's BLIND WM_CLOSE- fallback - adom-bridge#86 - which bypasses this bridge's classifier and cannot tell a- harmless nag from a DATA-LOSS 'packages are uploading, sure you want to close?' prompt).-- This routes every open dialog through classify_blocking_dialogs() and answers each SAFELY- in the BACKGROUND (WM_CLOSE = Cancel/No, never SetForegroundWindow, never Yes/close):- - upload_in_progress: WM_CLOSE == 'No' == do NOT close == the upload keeps draining. We- flag it so the caller polls hasActiveJobs before any retry (closing now still loses it).- - everything else: WM_CLOSE == Cancel/No, the safe answer for every Fusion confirm.- Returns what each dialog WAS (category) and how it was answered - so the caller never has to- guess, and never gets a stray Yes.- """- classified = classify_blocking_dialogs()- if not classified:- return {"success": True, "dismissed": [], "count": 0,- "_hint": "No blocking Fusion dialog is open."}- answered, upload_active = [], False- for d in classified:- hwnd, cat = d.get("hwnd"), d.get("category")- safe = "No/Cancel (WM_CLOSE, background)"- if cat == "upload_in_progress":- upload_active = True- safe = "No - declined close, Hub upload left to drain (NEVER Yes)"- ok = False- try:- ok = bool(close_dialog_bg(hwnd))- except Exception:- ok = False- answered.append({"title": d.get("title"), "category": cat,- "answeredWith": safe, "closed": ok,- "resolution": d.get("resolution")})- hint = ("Answered %d blocking dialog(s) in the background (No/Cancel - never Yes, never "- "focus-stolen)." % len(answered))- if upload_active:- hint += (" One was a Hub-UPLOAD 'lose these changes?' prompt: it was declined (No), but "- "the upload is STILL in flight - poll app.hasActiveJobs (fusion_state) until it "- "drains before any close/saveAs, or you WILL lose the 3D package.")- _taskbar_progress(None) if not upload_active else _taskbar_progress(1)- return {"success": True, "count": len(answered), "dismissed": answered,- "uploadStillActive": upload_active, "_hint": hint}---COMMAND_HANDLERS["dismiss_blocking_dialogs"] = _handle_dismiss_blocking_dialogs---def _capture_3d_view(pkg: str, lbr_path: str, settle: float) -> dict:- """Capture the DEDICATED 3D-package render as its own artifact (page issue #63).-- The 3D chip previously existed only as a thumbnail inside the ganged component view.- Getting it standalone meant a separate fusion_show_3d_package call that re-attaches the- STEP and re-renders - measured at a 180s timeout during the import campaign, and it- took Fusion down with it under concurrent load. The 3D body is already built and saved- by the time captures run, so entering the Package3D environment and photographing it is- cheap: no re-attach, no re-render.- """- import time as _t- entry = {"package": pkg, "view": "3d", "path": None, "ok": False}- # A PLACEHOLDER IS NOT A RESULT (page issue #42). With no <package3d> bound, Fusion- # renders a grey slab that photographs exactly like a real chip. Knowable offline.- if lbr_path:- try:- if not _library_artifact_names(lbr_path).get("p3d"):- entry.update({- "errorCode": "no_3d_package_bound",- "error": "No <package3d> is bound in that library, so the only thing to "- "photograph is Fusion's grey Placeholder - refusing to return "- "that as the 3D artifact.",- "_hint": "Run fusion_build_library_3d (or fusion_bind_library_3d) first, "- "then re-capture. A Placeholder looks like a successful 3D view "- "in a screenshot, which is why this refuses instead."})- return entry- except Exception:- pass- cp = _create_3d_package(lbr_path, timeout=60) if lbr_path else {"success": False,- "error": "no libraryPath"}- if not cp.get("success"):- entry.update({"errorCode": "create_3d_package_failed",- "error": "Could not enter the Package3D environment: %s"- % str(cp.get("error"))[:200]})- return entry- try:- if settle:- _t.sleep(settle)- entry["path"] = _capture_labeled("%s_3d" % pkg)- entry["ok"] = bool(entry["path"])- finally:- # ALWAYS leave the Package3D environment, even if the capture threw. Staying in it- # would make every LATER view capture the 3D editor - exactly the mislabelled-frame- # failure reported in page issue #62.- try:- _proxy_to_addin("execute_text_command", {"command": "Package3DStop"}, timeout=20)- _t.sleep(0.6)- _d = _dismiss_dialogs_bg()- if _d:- print("[Fusion Bridge] Package3DStop raised a dialog (%s) - answered in the "- "background; see skills/fusion-no-modal-dialogs" % _d)- except Exception as _e:- print("[Fusion Bridge] Package3DStop failed: %s" % _e)- return entry--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."}- # FOUR artifacts, in ASSEMBLY ORDER (page issues #60 and #63): symbol, footprint,- # the dedicated 3D chip, then the ganged component view LAST as the closing proof.- # The 3D chip used to exist only as a thumbnail inside the ganged view, so a caller- # could not show it as its own artifact without a separate fusion_show_3d_package call- # that re-attaches the STEP - slow (180s timeout observed) and crash-prone under load.- view_ext = {"component": "dev", "symbol": "sym", "footprint": "pac", "3d": "3d",- "dev": "dev", "sym": "sym", "pac": "pac", "p3d": "3d", "package3d": "3d"}- label_of = {"dev": "component", "sym": "symbol", "pac": "footprint", "3d": "3d"}- views = args.get("views") or ["symbol", "footprint", "3d", "component"]- # 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))- # ⛔ PRE-CHECK EVERY NAME OFFLINE, BEFORE ANY EDIT REACHES FUSION.- # This verb used to fire EDIT on whatever it was handed and then call- # _dismiss_dialogs_bg() to clear the "Create new footprint 'X'?" modal it had just- # caused. That is backwards three ways: the modal freezes the add-in's main thread- # (so every later verb reads as a crash), it dings the machine's alert sound at a user- # who did not do anything, and dismissal is a RACE we lose whenever Fusion is slow.- # The .lbr on disk already knows every symbol/package/deviceset name, so a name that- # would prompt is knowable in milliseconds without Fusion. Resolve here; never ask.- # REFUSE TO MANUFACTURE EVIDENCE DURING A BUILD (page issue #62, 2026-08-26).- # fusion_build_library_3d is async; while it runs it holds the 3D-package editor on- # screen, so EDIT <name>.sym/.pac land on that editor and every capture shows the part- # being built rather than the view it is labelled with. One came back ok:true with no- # chip in the frame. A false pass is worse than a refusal: it is evidence that lies.- _flight = _build_in_flight()- if _flight and not args.get("ignoreBuildInProgress"):- import time as _ft- return {"success": False, "errorCode": "build_in_progress",- "error": "A 3D build is still running for %s - refusing to capture, because "- "the build holds the 3D-package editor on screen and every view "- "would show that editor instead of the view it is labelled with."- % ", ".join(sorted(_flight)),- "buildsInFlight": [{"lbrPath": k, "runningSec": int(_ft.time() - v)}- for k, v in sorted(_flight.items())],- "_hint": "Wait for the build to finish, then re-capture - the same call "- "returns correct content once it is done. Detect completion by "- "polling fusion_get_app_state until the <part>_3d document reports "- "isSaved:true, or read the bound .lbr until it has one <package3d "- "wip_urn=...> per part. Pass ignoreBuildInProgress:true only if you "- "genuinely want a mid-build frame."}- lbr_path = (args.get("libraryPath") or "").replace("\\", "/") or _remembered_open_library()- resolved_names = {}- name_errors = []- if lbr_path:- for pkg in pkgs:- for v in views:- ext = view_ext.get(v, v)- if ext == "3d":- continue # not an EDIT target; guarded by its own p3d check- r, err = _resolve_artifact_name(pkg, ext, lbr_path)- if err:- name_errors.append({"package": pkg, "view": label_of.get(ext, ext),- "error": err.get("error"),- "available": err.get("available"),- "closestMatches": err.get("closestMatches")})- else:- resolved_names[(pkg, ext)] = r- out = []- for pkg in pkgs:- for v in views:- ext = view_ext.get(v, v)- if ext == "3d":- out.append(_capture_3d_view(pkg, lbr_path, settle))- continue- if lbr_path and (pkg, ext) not in resolved_names:- # Would have prompted. Report it as a skip; do NOT hand it to Fusion.- _bad = next((e for e in name_errors- if e["package"] == pkg and e["view"] == label_of.get(ext, ext)), {})- out.append({"package": pkg, "view": label_of.get(ext, ext), "path": None,- "ok": False, "skipped": "name_not_in_library",- "error": _bad.get("error"),- "available": (_bad.get("available") or [])[:20],- "closestMatches": _bad.get("closestMatches"),- "_hint": "No EDIT was issued and no dialog was raised - this name "- "is not in the library, so opening it would have popped "- "Fusion's blocking 'Create new ...?' modal. Pick a name "- "from `available`."})- continue- name = resolved_names.get((pkg, ext), pkg)- er = _proxy_to_addin("electron_run", {"command": f"EDIT {name}.{ext}", "confirm": "no"}, 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.- # BACKSTOP ONLY. With the pre-check above this should never fire; if it does,- # the pre-check has a hole (a library opened by a route that did not record its- # path, or a name EAGLE renamed on load) and that is a BUG to fix at the source,- # not a dialog to keep sweeping. Log it loudly so it cannot stay invisible.- dismissed = _dismiss_dialogs_bg()- if dismissed:- print("[Fusion Bridge] ⚠ PRE-CHECK HOLE: EDIT %s.%s still raised a dialog "- "(lbr_path=%r). Prevention failed; dismissal should not be load-bearing."- % (name, ext, lbr_path))- _bridge_action_ledger("dialog_precheck_hole",- {"name": name, "ext": ext, "lbrPath": lbr_path,- "dismissed": dismissed})- _proxy_to_addin("electron_run", {"command": "WINDOW FIT"}, timeout=20)- _t.sleep(0.6)- shot = _capture_labeled(f"{pkg}_{label_of.get(ext, ext)}")- # ok MUST MEAN "the frame shows the view it is labelled with", not "a file was- # written" (page issue #62). The reported failure captured the 3D-package- # editor for every view and still marked one ok:true. We cannot read the pixels- # from here, but we CAN ask Fusion which editor was actually on screen at- # capture time, which is exactly what went wrong - so a frame taken outside the- # Electronics Library editor is never reported as a pass.- editor_ws, editor_ok = None, True- try:- _st = _proxy_to_addin("get_app_state", {}, timeout=15) or {}- editor_ws = _st.get("activeWorkspace")- if editor_ws and "electronics library" not in str(editor_ws).lower():- editor_ok = False- except Exception:- editor_ws = None # unknown, not a pass and not a failure- entry = {"package": pkg, "view": label_of.get(ext, ext), "path": shot,- "dialogDismissed": dismissed or None,- "capturedInWorkspace": editor_ws,- "ok": (bool(er.get("success", True)) and bool(shot)- and not dismissed and editor_ok)}- if not editor_ok:- entry["errorCode"] = "wrong_editor_captured"- entry["error"] = ("Captured while %r was active, not the Electronics Library "- "editor - the frame does not show the %s view it is "- "labelled with." % (editor_ws, label_of.get(ext, ext)))- out.append(entry)- 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_direct_api_call(verb: str, args: dict, timeout: float = 60) -> dict | None:- """Call an AD verb over the loopback DIRECT API - the correct, non-foregrounding way for a- bridge to reach AD when the in-process ad_client is down (issue #295). AD spawns the bridge- with ADOM_DIRECT_API_URL in its env (fallback: ~/.adom/direct-api-port); POST- {"command", "args"} to <base>/command. Returns the parsed result dict, or None if the direct- API is unavailable or errors (so _ad_call can fall through). Never shells the GUI exe."""- base = (os.environ.get("ADOM_DIRECT_API_URL") or "").strip()- if not base:- try:- with open(os.path.join(os.path.expanduser("~"), ".adom", "direct-api-port")) as f:- port = f.read().strip()- if port:- base = "http://127.0.0.1:%s" % port- except Exception:- return None- if not base:- return None- try:- body = json.dumps({"command": verb, "args": args}).encode("utf-8")- headers = {"Content-Type": "application/json"}- # The bridge token is what keeps a bridge's own callbacks exempt from AD's identity gate- # (ad_client already sends it; this path used to omit it).- tok = os.environ.get("ADOM_BRIDGE_TOKEN") or ""- if tok:- headers["X-Adom-Bridge-Token"] = tok- # Echo the identity AD handed us, then name OURSELVES as the delegate (issue #348). The- # thread alone would hide who ran it; the bridge alone would hide who asked. AD needs both- # to render "chip-fetcher tab 3 (via fusion)". Delegate is ADDED, never forwarded.- ident = _get_caller_identity()- for key, hdr in (("thread", "X-Adom-Caller-Thread"),- ("container", "X-Adom-Caller-Container"),- ("reason", "X-Adom-Caller-Reason")):- if ident.get(key):- headers[hdr] = ident[key]- headers["X-Adom-Caller-Delegate"] = "fusion"- req = urllib.request.Request(base.rstrip("/") + "/command", data=body,- headers=headers, method="POST")- with urllib.request.urlopen(req, timeout=timeout) as resp:- r = json.loads(resp.read())- if isinstance(r, dict):- inner = r.get("output")- if isinstance(inner, str) and inner.strip().startswith("{"):- try:- return json.loads(inner)- except Exception:- return r- return r- except Exception:- return None- return None---def _ad_call(verb: str, args: dict, timeout: int = 60) -> dict:- """Call another AD verb (nbrowser_*, desktop_*) from inside this bridge.-- Prefers the in-process ad_client; then the loopback DIRECT API (issue #295); only as a last- resort shells the host app's **-cli** exe (adom-bridge-cli.exe, NEVER the GUI adom-bridge.exe,- which would foreground AD). Works on an unattended VM where ad_client is down. Never raises -- returns {} on failure so callers can degrade to instructing the AI instead.-- Attribution (issue #348) is stamped into args HERE, once, so it survives on ALL THREE paths -- the in-process client and the CLI subprocess cannot carry HTTP headers, and an explicit caller- block in args is the highest-precedence form AD accepts. The direct-API path additionally sends- the X-Adom-Caller-* headers so AD can record the delegation chain."""- args = _stamp_caller(args)- why = []- try:- if ad_client.available():- 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)- # #295: prefer the loopback DIRECT API - it reaches AD without foregrounding anything.- direct = _ad_direct_api_call(verb, args, timeout=timeout)- if isinstance(direct, dict):- return direct- why.append("direct-api unavailable")- try:- import subprocess as _sp, json as _j- exe = _find_adom_desktop_cli() # the -cli exe only; never the GUI exe (#295)- if not exe:- return {"_adCallError": "; ".join(why + ["adom-bridge CLI not found"])}- p = _sp.run([exe, verb, _j.dumps(args)], capture_output=True, text=True, timeout=timeout)- out = (p.stdout or "").strip()- if out.startswith("{"):- 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.-# ⚠ TWO CLASSES (#50, 2026-08-23 - 9/9 chains lost Fusion mid-PROVE): "Update Now" only-# STARTS a background download (safe anytime), but "Install Now"/"Restart Now" EXIT Fusion-# to apply the update. The sweep used to click all four blindly on its 300s cadence, which-# lines up exactly with the observed clean exit at +344s into a chain. Exit-class buttons-# may now only be clicked when Fusion is verifiably IDLE (no long op, no recent driving,-# no open documents).-_UPDATE_BUTTONS = ("Update Now", "Update now", "Install Now", "Restart Now")-_UPDATE_BUTTONS_EXIT_CLASS = ("Install Now", "Restart Now")---def _bridge_action_ledger(action: str, detail: dict):- """Append one background-action record to ~/.adom/fusion-bridge/actions.jsonl.-- The bridge log truncates on every respawn (found investigating #50: a morning of- evidence was gone by the time the report landed), so anything the bridge does to- Fusion BEHIND the user's back - update clicks, seat takeovers, dialog dismissals,- doc closes, hang-gate kills - is also written here, append-only, survives respawns.- One grep answers 'did the bridge touch Fusion at HH:MM?'. Never raises."""- try:- import json as _j, time as _t, pathlib as _pl- p = _pl.Path(os.path.expanduser("~")) / ".adom" / "fusion-bridge" / "actions.jsonl"- p.parent.mkdir(parents=True, exist_ok=True)- rec = {"at": _t.strftime("%Y-%m-%d %H:%M:%S"), "epoch": round(_t.time(), 1),- "action": action, "pid": os.getpid()}- rec.update(detail or {})- with open(p, "a", encoding="utf-8") as f:- f.write(_j.dumps(rec) + "\n")- except Exception:- pass---try:- import handlers.dismiss_recovery as _dr_mod- _dr_mod.LEDGER = _bridge_action_ledger # every recovery WM_CLOSE / Escape is recorded-except Exception:- pass---def _host_op_active() -> bool:- """THE shared invariant (#52 item 3): is a long op or recent driving in flight? Every- BACKGROUND subsystem that would act ON the host app (synthetic input, window closes,- update clicks) must stand down while this is true - so subsystem number four is born- safe instead of being diagnosed in a six-hour thread. Seat takeover is the one- documented exemption: the seat dialog BLOCKS the very op we are protecting."""- import time as _t- if _LONG_OP.get("name"):- return True- return bool(_LAST_DRIVE_AT[0] and (_t.time() - _LAST_DRIVE_AT[0]) < 180)---def _fusion_verifiably_idle() -> dict:- """May the bridge do something that EXITS Fusion right now? Only when ALL of:- no registered long op, no driving verb for 3+ minutes, and no open documents- (best-effort; an unreachable add-in counts as NOT idle - safe default)."""- import time as _t- if _LONG_OP.get("name"):- return {"idle": False, "why": "long op in flight: %s" % _LONG_OP["name"]}- drove_ago = _t.time() - float(_LAST_DRIVE_AT[0] or 0)- if _LAST_DRIVE_AT[0] and drove_ago < 180:- return {"idle": False, "why": "driving verb %ds ago" % int(drove_ago)}- try:- st = _proxy_to_addin("document_info", {}, timeout=6)- docs = (st.get("data") or {}).get("openDocuments") or []- if not st.get("success"):- return {"idle": False, "why": "add-in unreachable - cannot prove no open docs"}- if docs:- return {"idle": False, "why": "%d document(s) open" % len(docs)}- except Exception:- return {"idle": False, "why": "doc check failed - cannot prove idle"}- return {"idle": True, "why": "no long op, no recent driving, no open documents"}---def _find_controls_any(obj, depth=0):- """Envelope-proof extraction of UIA find_control results: ab has moved best/controls- across response levels between versions (same drift family as coordMap). Returns- {best, controls} from anywhere in the response, parsing stringified output."""- if depth > 5:- return {}- if isinstance(obj, str) and obj[:1] in "[{":- try:- import json as _j- return _find_controls_any(_j.loads(obj), depth + 1)- except Exception:- return {}- if isinstance(obj, dict):- if isinstance(obj.get("best"), dict) or isinstance(obj.get("controls"), list):- return {"best": obj.get("best") or {}, "controls": obj.get("controls") or []}- for k in ("data", "output", "result", "response"):- got = _find_controls_any(obj.get(k), depth + 1)- if got:- return got- return {}---_LAST_UPDATE_OFFER = {"offered": False, "at": 0}---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 = _find_controls_any(_ad_call("desktop_find_control", {"hwnd": h, "name": label}, timeout=20))- best = r.get("best") or {}- if best.get("name") and best.get("invokable"):- import time as _ut- _LAST_UPDATE_OFFER.update(offered=True, at=_ut.time(), button=best.get("name"))- 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"}- # ⚠ #50 gate: "Install Now"/"Restart Now" EXIT Fusion to apply the update. Clicking one- # while a chain is running killed Fusion mid-PROVE 9/9 times (clean exit, no crash- # artifact, matching this sweep's 300s cadence). Exit-class buttons only fire when- # Fusion is verifiably idle; otherwise defer - the janitor retries every 300s and will- # land it the moment the work is done. "Update Now" (download only) stays safe anytime.- if offer["button"] in _UPDATE_BUTTONS_EXIT_CLASS:- idle = _fusion_verifiably_idle()- if not idle.get("idle"):- _bridge_action_ledger("auto_update_deferred",- {"button": offer["button"], "notIdle": idle.get("why")})- print("[Fusion Bridge] auto-update: %r on screen but Fusion is NOT idle (%s) - "- "DEFERRED, will retry when idle" % (offer["button"], idle.get("why")))- return {"applied": False, "deferred": True, "button": offer["button"],- "why": "exit-class update button deferred: %s" % idle.get("why")}- 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"])- _bridge_action_ledger("auto_update_click",- {"button": offer["button"], "invoked": ok,- "exitClass": offer["button"] in _UPDATE_BUTTONS_EXIT_CLASS})- 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---# ── SAVE-PROMPT SENTINEL (issue #30, 2026-08-16) ─────────────────────────────────────────────-# ab's live demo run proved that Fusion's unsaved-doc "Save / Don't Save / Cancel" modal,-# popped mid document-switch by our own show_* verbs, orphans the in-flight add-in call and-# can wedge the add-in main thread PERMANENTLY (only fusion_close {force} + fusion_start-# recovers). The show verbs themselves create the modified-doc state, so for BRIDGE-CREATED-# documents discarding is always correct: while a doc-switching command runs, a watcher-# scans for the prompt and clicks "Don't Save" IN THE BACKGROUND (UIA invoke, no focus).-#-# ⚠ DATA-SAFETY RULE: we only auto-discard when the dialog text names a document the bridge-# itself created this session (_BRIDGE_DOCS). A user's own unsaved design NEVER gets its-# Save prompt auto-answered - that would destroy their work. Unrecognized prompts are left-# standing and classified as before (classify_blocking_dialogs -> save_changes hint).--_BRIDGE_DOCS: set = set() # lowercase doc-name fragments the bridge created-_UNATTRIBUTED_PROMPT_STRIKES: dict = {} # hwnd -> consecutive sightings without attribution-_SIZE_ATTEMPTS: dict = {} # hwnd -> sizing attempts (Fusion restores its OWN saved- # geometry LATE in startup and can override an early- # resize, so we retry until the window is confirmed- # large, then stop touching it - John's live trace- # 2026-08-18: sized to 92% at t+75s, Fusion re-restored- # itself at t+90s.)-_NO_DONTSAVE_HWNDS: set = set() # hwnds probed once with NO Don't-Save button (UIA-less)-_DONT_SAVE_LABELS = ("Don't Save", "Don’t Save")-# Names the bridge generates by CONVENTION - always ours, even after a respawn wiped the-# session registry (caught live 2026-08-16: respawn emptied _BRIDGE_DOCS and the sentinel-# refused its own ADOM-TEST doc's prompt, wedging two tours in a row).-_BRIDGE_DOC_BUILTINS = ("adom-test-", "adomdemosample", "adom-fusion-wrap",- # the tour's own bundled sample parts - bridge docs by definition- # (their Save prompt failed a third tour, 2026-08-16)- "ads8588sipm", "0603waf")---def _note_bridge_doc(name: str):- """Register a document/library name as BRIDGE-CREATED (safe to auto-discard).- Write-through to prefs.json so the registry SURVIVES bridge respawns."""- n = str(name or "").rsplit("/", 1)[-1].rsplit("\\", 1)[-1]- n = n.rsplit(".", 1)[0].strip().lower()- if len(n) >= 3:- _BRIDGE_DOCS.add(n)- while len(_BRIDGE_DOCS) > 24:- _BRIDGE_DOCS.pop()- try:- _set_bridge_pref("bridgeDocs", sorted(_BRIDGE_DOCS))- except Exception:- pass---try: # reload the persisted registry at import (respawn survival)- _BRIDGE_DOCS.update(x for x in (_get_bridge_pref("bridgeDocs", []) or [])- if isinstance(x, str) and len(x) >= 3)-except Exception:- pass---def _is_bridge_doc_text(text: str):- t = (text or "").lower()- return (next((n for n in _BRIDGE_DOCS if n in t), None)- or next((p for p in _BRIDGE_DOC_BUILTINS if p in t), None))---def _find_save_prompt() -> dict:- """Is Fusion's unsaved-doc Save prompt up? Two shapes seen live: a CEF modal with a- UIA-reachable "Don't Save" button, and a bare Qt dialog titled just "Save" with NO- UIA tree at all (ConfRoomROG, 2026-08-16 - it blocked the main thread invisibly).- Returns {found, hwnd, button|None, text} - never raises."""- try:- info = get_fusion_window_info() or {}- for d in info.get("dialogs") or []:- h = d.get("hwnd")- if not h:- continue- title = str(d.get("title") or "").strip().lower()- for label in (() if h in _NO_DONTSAVE_HWNDS else _DONT_SAVE_LABELS):- r = _find_controls_any(_ad_call("desktop_find_control",- {"hwnd": int(h), "name": label}, timeout=10))- best = r.get("best") or {}- if best.get("name") and best.get("invokable"):- text = ""- try:- t = _find_controls_any(_ad_call("desktop_find_control",- {"hwnd": int(h), "name": "save"}, timeout=10))- for c in ([t.get("best") or {}] + list(t.get("controls") or [])):- nm = str(c.get("name") or "")- if len(nm) > len(text):- text = nm- except Exception:- pass- return {"found": True, "hwnd": int(h), "button": best.get("name"), "text": text}- if title in ("save", "save changes", "unsaved changes"):- # UIA-less Qt variant: identified by title alone; no clickable button handle.- _NO_DONTSAVE_HWNDS.add(h) # never pay the 2x10s UIA probe for this hwnd again- return {"found": True, "hwnd": int(h), "button": None, "text": ""}- except Exception:- pass- return {"found": False}----def _close_idle_bridge_docs() -> dict:- """Close BRIDGE-created documents that are sitting DIRTY while nothing is driving.-- John, 2026-08-20: "why are you always leaving annoying dialog boxes up like this?"- Dismissing the Save prompt was only ever treating the symptom. The prompt exists- because our surfaces/tour runs leave the staged document MODIFIED and open, so the- next thing that tries to close it asks the user to decide about OUR throwaway edits.- A prompt we have to dismiss is a mess we should not have made.-- Only touches documents in the bridge-doc registry, only while no driving verb has run- for a few minutes (so a demo in progress is never yanked out from under itself), and- always with save:false - these are our scratch edits, never the user's work.- """- import time as _t- try:- idle_for = _t.time() - float(_LAST_DRIVE_AT[0] or 0)- except Exception:- idle_for = 1e9- if idle_for < 180:- return {"closed": [], "why": "driving activity within 3 minutes - leaving documents alone"}- try:- st = _proxy_to_addin("document_info", {}, timeout=6)- except Exception:- return {"closed": [], "why": "add-in unreachable"}- if not st.get("success"):- return {"closed": [], "why": "no document info"}- docs = (st.get("data") or {}).get("openDocuments") or []- closed = []- for d in docs:- name = str(d.get("name") or "")- dirty = bool(d.get("isModified") or d.get("modified") or name.endswith("*"))- if not name or not dirty:- continue- if not _is_bridge_doc_text(name):- continue # the user's document - never ours to close- try:- r = _close_bridge_doc_verified(name)- if r.get("closed"):- closed.append(name)- print("[Fusion Bridge] janitor: closed dirty bridge doc %r (verified gone, "- "any Save-As answered in the same call)" % name)- _bridge_action_ledger("idle_doc_close", {"doc": name})- except Exception:- pass- return {"closed": closed, "why": "closed %d dirty bridge doc(s)" % len(closed)}---def _dismiss_save_prompt_if_bridge_doc() -> dict:- """Auto-dismiss the Save prompt ONLY for bridge-created docs. Ladder:- 1. Prompt text names a bridge doc -> UIA-click "Don't Save" (background, discards OUR- throwaway edits). 2. Text unreadable (UIA-less Qt variant) but the MAIN WINDOW- title names a bridge doc -> post ESCAPE (= Cancel: non-destructive for ANY doc,- just unblocks the main thread; the doc stays open unsaved). UIA click falling- back to Escape also covers ab's per-command permission gate blocking desktop_ui_click.- A prompt for a USER's document is never touched. Returns {handled, why} - never raises."""- prompt = _find_save_prompt()- if not prompt.get("found"):- return {"handled": False, "why": "no save prompt"}- text = prompt.get("text") or ""- matched = _is_bridge_doc_text(text)- main_title = ""- if not matched:- try:- main_title = str((get_fusion_window_info() or {}).get("title") or "")- except Exception:- pass- matched = _is_bridge_doc_text(main_title)- if not matched:- # UNATTRIBUTABLE prompt (the spawning doc already closed, so no title names it -- # exactly how a standing prompt looks after a close-flow; caught live 2026-08-17).- # Escape = CANCEL: non-destructive for ANYONE's document (the close is cancelled,- # the doc stays open, nothing saved or discarded). A modal that blocks the add-in- # main thread for 2+ sweeps gets escaped rather than sat on; Don't Save is still- # NEVER clicked without attribution.- # ZOMBIE GATE (2026-08-17, found live on ConfRoomROG): "Save"-titled windows can be- # non-modal GHOSTS that block nothing (main thread fully responsive, PrintWindow- # black, no UIA). Escaping those every other tick is pointless input spam - only- # escalate on an unattributed prompt when the add-in main thread is ACTUALLY- # blocked (status probe busy or unanswerable).- try:- _st = _check_addin_status(timeout=2.0)- _blocked = (_st is None) or bool(_st.get("busy"))- except Exception:- _blocked = True- if not _blocked:- return {"handled": False,- "why": "unattributed dialog but the main thread is responsive - zombie window, leaving it alone",- "hwnd": prompt["hwnd"]}- n = _UNATTRIBUTED_PROMPT_STRIKES.get(prompt["hwnd"], 0) + 1- _UNATTRIBUTED_PROMPT_STRIKES[prompt["hwnd"]] = n- if n >= 2:- try:- esc = send_key_background("escape", hwnd=prompt["hwnd"])- if esc.get("success"):- print("[Fusion Bridge] save-prompt sentinel: ESCAPED an unattributed prompt "- "after %d sightings (Cancel - non-destructive, main thread freed)" % n)- _UNATTRIBUTED_PROMPT_STRIKES.pop(prompt["hwnd"], None)- return {"handled": True, "why": "escaped unattributed prompt (Cancel) after %d sightings" % n}- except Exception:- pass- return {"handled": False, "why": "unattributed save prompt - strike %d of 2 before Escape" % n,- "hwnd": prompt["hwnd"]}- if prompt.get("button"):- r = _unwrap(_ad_call("desktop_ui_click",- {"hwnd": prompt["hwnd"], "name": prompt["button"]}, timeout=15))- if r.get("invoked") or r.get("success"):- print("[Fusion Bridge] save-prompt sentinel: clicked %r for bridge doc %r (background)"- % (prompt["button"], matched))- return {"handled": True, "why": "clicked '%s' for bridge doc '%s'" % (prompt["button"], matched)}- # ATTRIBUTED to a bridge doc, so "Don't Save" is the RIGHT answer, not Escape. Escape- # cancels the close and leaves the doc dirty, which guarantees the same prompt returns -- # that loop is exactly what John saw twice in a row (2026-08-20). Try the decisive- # button first; Escape stays as the fallback when UIA is unavailable.- try:- r2 = _unwrap(_ad_call("desktop_ui_click",- {"hwnd": prompt["hwnd"], "name": "Don't Save"}, timeout=15))- if r2.get("invoked") or r2.get("success"):- print("[Fusion Bridge] save-prompt sentinel: clicked \'Don\'t Save\' for bridge doc %r "- "(attributed by window title)" % matched)- _bridge_action_ledger("save_prompt_dismiss", {"doc": matched, "how": "dont_save"})- return {"handled": True, "why": "clicked Don't Save for bridge doc '%s'" % matched}- except Exception:- pass- # Escape = Cancel: always non-destructive, needs no UIA and no ab input grant.- try:- esc = send_key_background("escape", hwnd=prompt["hwnd"])- if esc.get("success"):- print("[Fusion Bridge] save-prompt sentinel: ESCAPED the prompt for bridge doc %r "- "(Cancel - doc stays open unsaved, main thread freed)" % matched)- _bridge_action_ledger("save_prompt_dismiss", {"doc": matched, "how": "escape"})- return {"handled": True, "why": "escaped (Cancel) for bridge doc '%s'" % matched}- except Exception:- pass- return {"handled": False, "why": "dismiss attempts did not land", "hwnd": prompt["hwnd"]}---class _SavePromptSentinel:- """Context manager: while a doc-switching add-in call runs, poll for the Save prompt- and auto-dismiss it (bridge docs only) so the call never wedges behind the modal."""-- def __init__(self, max_seconds: float):- import time as _t- self.max_seconds = max(5.0, float(max_seconds))- self._deadline = _t.time() + self.max_seconds- self._stop = threading.Event()- self._hits = []-- def soft_stop(self, grace_seconds: float = 12.0):- """Shrink the watch window instead of killing it: keeps covering the just-returned- call for a short grace period (the post-timeout prompt is the wedge case)."""- import time as _t- self._deadline = min(self._deadline, _t.time() + grace_seconds)-- def __enter__(self):- def _watch():- import time as _t- while not self._stop.is_set() and _t.time() < self._deadline:- try:- info = get_fusion_window_info() or {}- if info.get("dialogs"):- got = _dismiss_save_prompt_if_bridge_doc()- if got.get("handled"):- self._hits.append(got)- except Exception:- pass- self._stop.wait(1.0)- self._thread = threading.Thread(target=_watch, daemon=True)- self._thread.start()- return self-- def __exit__(self, *exc):- self._stop.set()- return False---# Add-in commands that switch/open documents and can pop the Save prompt mid-flight.-_DOC_SWITCHING_ADDIN = {- "open_lbr", "open_cloud_file", "open_by_urn", "open_schematic", "open_board",- "show_schematic", "show_2d_board", "show_3d_board", "import_electronics",- "execute_text_command", "electron_run", "new_electronics_from_eagle",-}---def _license_classify_extend(dialogs: list):- """ab's #30 ask: the Autodesk seat modal ('Active Sessions Exceeded' / 'Suspend Remote- Session') often carries a bare Win32 title, so the title-substring classifier files it- as generic_modal - undecidable for a caller. find_licensing_dialog() detects it by- OWNERSHIP + SHAPE instead, so when it fires, append the decidable category (mutates- the passed list in place; never raises)."""- try:- if any(d.get("category") == "license_sessions_exceeded" for d in dialogs):- return- dlg = find_licensing_dialog()- if not dlg:- return- auto = _seat_takeover_enabled()- dialogs.append({- "category": "license_sessions_exceeded",- "title": dlg.get("title") or "",- "hwnd": dlg.get("hwnd"),- "resolution": (- "Autodesk seat modal: this account's Fusion is active on another machine. "- + ("Auto-takeover is ON - the bridge clicks Continue through both dialogs in the "- "background (~20s sweep; a fusion_start resolves it immediately). The other "- "machine's Fusion suspends RESUMABLY."- if auto else- "Auto-takeover is OPTED OUT (fusion_set_seat_takeover) - resolve the dialog "- "manually or free the seat with fusion_stop on the other machine.")),- "autoHandled": auto,- })- except Exception:- pass---def _handle_fusion_state(fusion_info: dict, args: dict) -> dict:- """fusion_state - the render-me verb (#28; kicad_state is the reference shape).-- SERVER-SIDE ONLY on purpose: it must answer even when the add-in main thread is busy- or wedged (ab's demo falls back to window_info + desktop screenshots today exactly- because a state verb has to work in those moments). Returns running/window/dialog- state + a fresh background screenshot of the Fusion window; activeDocument is- best-effort via the add-in with a short budget.-- args: screenshot (default true) | imageAs: "path" (default) | "base64" (inline PNG,- auto-refused over ~4MB - pull the path via read_file instead)."""- running = _is_fusion_running()- result = {"success": True, "running": running}- # THESE TWO SURVIVE A CLOSED FUSION, ON PURPOSE (caught re-testing #82, 2026-08-20).- # focusEvents is the QUIETNESS AUDIT LOG and timings is on-disk measured history -- # neither needs the app to be up, and both are most likely to be asked for exactly when- # it is NOT (after a run, reviewing what the bridge did). They used to sit below the- # not-running early return, so an auditor closing Fusion lost the evidence.- try:- with _FOCUS_LOCK:- result["focusEvents"] = list(_FOCUS_EVENTS[-20:])- except Exception:- result["focusEvents"] = []- try:- import timings as _tm0- result["timings"] = _tm0.summary()- _s0 = _tm0.sentence()- if _s0:- result["timingsSentence"] = _s0- except Exception:- pass- if not running:- result["_hint"] = ("Fusion is not running - nothing to render. fusion_start launches it "- "in the background; fusion_demo {mode:'tour'} cold-starts it itself. "- "focusEvents and timings ARE still reported - they do not need Fusion.")- result["output"] = json.dumps({"running": False})- return result- info = get_fusion_window_info() or {}- result["hwnd"] = info.get("hwnd")- result["title"] = info.get("title")- result["rect"] = info.get("rect")- result["dialogs"] = info.get("dialogs") or []- try:- result["blockingDialogs"] = classify_blocking_dialogs() or []- except Exception:- result["blockingDialogs"] = []- _license_classify_extend(result["blockingDialogs"])- # Busy state (the add-in's own report; 3s budget, never blocks the state read).- busy = None- try:- st = _check_addin_status(timeout=3.0)- if st:- result["addinResponding"] = True- if st.get("busy"):- busy = {"command": st.get("busyCommand"), "elapsedSeconds": st.get("elapsedSeconds")}- else:- result["addinResponding"] = False- except Exception:- result["addinResponding"] = False- result["mainThreadBusy"] = busy- try:- with _FOCUS_LOCK:- result["focusEvents"] = list(_FOCUS_EVENTS[-20:])- except Exception:- result["focusEvents"] = []- # WHAT TO EXPECT, BEFORE THE CLICK (issue #32 section 2): this machine's own measured- # launch history, so a UI can say "typically 45s cold, 3s warm here" instead of making- # the user discover it by waiting.- try:- import time as _tnow- import timings as _tm- result["timings"] = _tm.summary()- _s = _tm.sentence()- if _s:- result["timingsSentence"] = _s- _lsa = _get_bridge_pref("launchStartedAt", None)- if _lsa and not result.get("addinResponding") and (_tnow.time() - float(_lsa)) < 900:- result["progress"] = _tm.progress_block(- "launch", float(_lsa),- "Fusion is starting - waiting for the Adom add-in to serve",- start_kind="cold", fallback=150)- except Exception:- pass- try:- from handlers.taskbar_overlay import badge_active, last_error as _badge_err- result["drivingBadge"] = {"active": badge_active(),- "lastDrivingVerbAt": _LAST_DRIVE_AT[0] or None,- "idleShedSeconds": _BADGE_IDLE_SECONDS,- "enabled": _driving_badge_enabled(),- "lastError": _badge_err()}- except Exception:- pass- # Active document, best-effort (skip when busy - it would just be rejected).- if result.get("addinResponding") and not busy:- try:- st = _proxy_to_addin("get_app_state", {}, timeout=5)- result["activeDocument"] = (st.get("data") or {}).get("activeDocument")- except Exception:- pass- # The screenshot: PrintWindow-based, background, no focus taken.- if args.get("screenshot", True):- try:- shot = screenshot_fusion_window()- if shot.get("success"):- result["screenshot"] = shot.get("savedTo")- if args.get("imageAs") == "base64":- import base64 as _b64- raw = open(shot["savedTo"], "rb").read()- if len(raw) <= 4 * 1024 * 1024:- result["screenshotBase64"] = _b64.b64encode(raw).decode("ascii")- else:- # DOWNSCALE instead of refusing (ralph, 2026-08-18: a PIL-less bridge- # saves 24MB BMPs, so every Dashboard surface card came back with no- # image at all). Same path the tour gallery uses.- small = _shot_b64_small(shot["savedTo"], max_w=1280)- if small:- result["screenshotBase64"] = small- result["screenshotNote"] = ("downscaled to <=1280px (raw capture is "- "%dMB) - pull result.screenshot for full size"- % (len(raw) // (1024 * 1024)))- else:- result["screenshotNote"] = ("capture is %dKB (>4MB inline cap) and could "- "not be downscaled - pull result.screenshot "- "via read_file." % (len(raw) // 1024))- except Exception as e:- result["screenshotError"] = str(e)- hints = []- if busy:- hints.append("Main thread busy (%s, %ss) - add-in verbs are rejected right now; "- "window/screenshot verbs still work." % (busy["command"], busy["elapsedSeconds"]))- if result["blockingDialogs"]:- hints.append("A modal is up - see blockingDialogs for the classified cause + resolution.")- if not hints:- hints.append("Fusion is clear. The screenshot shows the current surface - this is the verb "- "to render any fusion_show_* result (they open in the background by design).")- result["_hint"] = " ".join(hints)- result["output"] = json.dumps({"running": True, "activeDocument": result.get("activeDocument"),- "mainThreadBusy": busy, "blockingDialogs": result["blockingDialogs"],- "screenshot": result.get("screenshot")})- return result---COMMAND_HANDLERS["state"] = _handle_fusion_state---# ── 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": "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"):- # Enabled-ness is a persistent Fusion PREFERENCE, but it is only observable while- # Fusion runs. Report the last GOOD observation so a closed Fusion reads as- # "configured, dormant" instead of broken (John, 2026-08-17).- return {"success": True, "enabled": False, "url": _MCP_URL, "error": s.get("error"),- "lastEnabledAt": _get_bridge_pref("mcpLastEnabledAt", None),- "fusionRunning": _is_fusion_running(),- "_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 [])]- try:- import datetime as _dt- _set_bridge_pref("mcpLastEnabledAt", _dt.datetime.now().isoformat(timespec="seconds"))- except Exception:- pass- 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. Re-call fusion_mcp_enable - driving Preferences is "- "racy on a busy main thread and a second pass usually lands it. Do NOT hand the "- "user a Preferences walkthrough (describe.py says NEVER ask them to do this by "- "hand); if a second pass also fails, report what the steps[] show and that the "- "setting is discarded when Fusion restarts before Apply."),- "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---_last_seat_takeover_notice = [0.0]--# ── Driving badge lifecycle (John, 2026-08-17: "after we're done controlling fusion, shed-# the badge like nb does - after like 5 or 10 minutes of inactivity") ────────────────────-# Badge ON: any DRIVING verb dispatch while Fusion runs (set here, throttled in the helper).-# Badge OFF: the janitor sheds it once no driving verb has run for _BADGE_IDLE_SECONDS.-# PASSIVE reads are excluded from "driving" ON PURPOSE: the Dashboard polls readiness every-# 15s and pollers poll demo_progress every ~2s - counting those would pin the badge forever.-_BADGE_IDLE_SECONDS = 300 # John said 5-10 min; 5 keeps the taskbar honest sooner-_LAST_DRIVE_AT = [0.0]-_BADGE_PASSIVE = {- "readiness", "state", "window_info", "describe", "demo_progress", "addin_status",- "aps_status", "mcp_status", "get_preferences", "targets", "screenshot_fusion",- "screenshot_all", "check_dialogs", "set_auto_update", "set_seat_takeover",- "uninstall", "get_app_state", "board_info", "document_info",-}---def _note_driving_activity(command: str):- """Called from dispatch for every verb: driving verbs stamp the activity clock and- ensure the badge is on (async - a 6s desktop_taskbar call must never slow a verb)."""- if command in _BADGE_PASSIVE:- return- import time as _t- _LAST_DRIVE_AT[0] = _t.time()-- def _ensure():- # RETRY, do not fire-and-forget: `start` is a driving verb dispatched BEFORE Fusion's- # window exists, so a single attempt always found no hwnd and the badge never landed- # (John caught exactly this after auto-green launched Fusion, 2026-08-18).- try:- from handlers.taskbar_overlay import set_driving_badge- from fusion_detect import _fusion_main_hwnd_quick- import time as _tt- if not _driving_badge_enabled():- return- for _ in range(30): # ~90s, covers a cold Fusion launch- if _is_fusion_running():- hw = _fusion_main_hwnd_quick()- if hw and set_driving_badge(hw):- return- _tt.sleep(3)- except Exception:- pass- threading.Thread(target=_ensure, daemon=True).start()----def _window_sizing_enabled() -> bool:- """Default TRUE: a Fusion window the user has to squint at is a bug, not a preference."""- return bool(_get_bridge_pref("sizeFusionWindow", True))---def _handle_set_window_sizing(fusion_info: dict, args: dict) -> dict:- """Read/toggle auto-sizing Fusion's window to ~92% of the screen after launch. STICKY."""- if "enabled" not in (args or {}):- cur = _window_sizing_enabled()- return {"success": True, "sizeFusionWindow": cur,- "_hint": ("Fusion window auto-sizing is %s. ON BY DEFAULT: after a launch the "- "bridge grows a small Fusion window to ~92%% of the monitor work area, "- "centered, WITHOUT activating it (SetWindowPos NOACTIVATE, never "- "ShowWindow/maximize, so your focus is never stolen). A window you "- "maximized or already sized large is left alone. Opt out: "- "fusion_set_window_sizing {\"enabled\": false} - STICKY."- % ("ON" if cur else "OFF")),- "statusVerb": "fusion_state"}- enabled = bool(args.get("enabled"))- _set_bridge_pref("sizeFusionWindow", enabled)- return {"success": True, "sizeFusionWindow": enabled,- "narrate": ("I'll size Fusion's window to fill most of your screen when it launches."- if enabled else "I'll leave Fusion's window size exactly as Fusion opens it."),- "statusVerb": "fusion_state"}---def _driving_badge_enabled() -> bool:- """Default TRUE. The taskbar overlay is identity, not noise - but a user who dislikes it- can switch it off permanently (John, 2026-08-18)."""- return bool(_get_bridge_pref("showDrivingBadge", True))---def _handle_set_driving_badge(fusion_info: dict, args: dict) -> dict:- """Read/toggle the 'Adom is driving Fusion' taskbar overlay badge. STICKY."""- if "enabled" not in (args or {}):- cur = _driving_badge_enabled()- return {"success": True, "showDrivingBadge": cur,- "_hint": ("The driving badge is currently %s. ON BY DEFAULT: while Adom drives "- "Fusion, its taskbar button wears a small Adom mark so the user knows "- "at a glance who is in control; it sheds after 5 minutes idle. Opt out: "- "fusion_set_driving_badge {\"enabled\": false} - STICKY."- % ("ON" if cur else "OFF")),- "statusVerb": "fusion_state"}- enabled = bool(args.get("enabled"))- _set_bridge_pref("showDrivingBadge", enabled)- if not enabled:- try:- from handlers.taskbar_overlay import clear_driving_badge- clear_driving_badge()- except Exception:- pass- return {"success": True, "showDrivingBadge": enabled,- "narrate": ("I'll badge Fusion's taskbar button while I'm driving it."- if enabled else "I'll stop badging Fusion's taskbar button."),- "_hint": "Sticky (~/.adom/fusion-bridge/prefs.json). Cleared immediately when turned off.",- "statusVerb": "fusion_state"}---def _seat_takeover_enabled() -> bool:- """Default TRUE (John: "always just take over the session"). Sticky opt-out for users- who'd rather resolve seat conflicts themselves - mirrors autoUpdateFusion."""- return bool(_get_bridge_pref("autoTakeOverSeat", True))---def _handle_set_seat_takeover(fusion_info: dict, args: dict) -> dict:- """Read/toggle the mid-session seat-takeover sweep. STICKY per user. Scope note: this- gates only the PASSIVE 20s sweep; a verb-driven fusion_start still grabs the seat- (the user asked for Fusion, so taking the seat is implied - John's July rule)."""- if "enabled" not in (args or {}):- cur = _seat_takeover_enabled()- return {"success": True, "autoTakeOverSeat": cur,- "_hint": ("Seat takeover is currently %s. ON BY DEFAULT: when Autodesk pops the "- "'Active Sessions Exceeded' dialogs mid-session, the bridge clicks "- "Continue through BOTH in the background - this machine takes the seat, "- "the other machine's Fusion suspends RESUMABLY, one toast tells the user. "- "Opt out: fusion_set_seat_takeover {\"enabled\": false} - STICKY."- % ("ON" if cur else "OFF")),- "statusVerb": "fusion_readiness"}- enabled = bool(args.get("enabled"))- _set_bridge_pref("autoTakeOverSeat", enabled)- return {"success": True, "autoTakeOverSeat": enabled,- "narrate": ("I'll automatically take the Fusion seat on this machine whenever "- "Autodesk flags the session limit." if enabled else- "I'll leave Autodesk seat-conflict dialogs for you to resolve yourself."),- "_hint": ("Sticky (~/.adom/fusion-bridge/prefs.json). %s"- % ("Takeover ON: mid-session seat dialogs are clicked through silently."- if enabled else- "Takeover OFF: the sweep will NOT touch seat dialogs; fusion_start "- "still grabs the seat when the user asks for Fusion.")),- "statusVerb": "fusion_readiness"}---# Registered HERE, next to the def - naming it in the COMMAND_HANDLERS literal (built ~3000-# lines earlier) is the import-time NameError that crash-looped the bridge on 2026-07-22.-COMMAND_HANDLERS["set_seat_takeover"] = _handle_set_seat_takeover-COMMAND_HANDLERS["set_driving_badge"] = _handle_set_driving_badge-COMMAND_HANDLERS["set_window_sizing"] = _handle_set_window_sizing---def _seat_sweep_tick():- """ALWAYS take over the Autodesk seat (John, 2026-08-16: "always just take over the- session"). The launch/readiness paths already auto-resolve the 'Active Sessions- Exceeded' -> 'Suspend Remote Session' dialog pair, but a MID-SESSION seat conflict- (Autodesk pops it whenever another machine signs in) sat unhandled until someone- happened to poll readiness. This tick runs from the janitor loop every ~20s: cheap- no-op when no dialog (local Win32 window walk), and when one IS up it clicks- Continue on BOTH dialogs in the background (Suspend pre-selected = this machine- takes the seat; the other machine's Fusion suspends resumably, nothing is lost).- Toasts the user AFTER a real takeover so they know the other box got suspended."""- # ⚠ SECTION SCOPING (v1.9.59, found by the badge-shed watcher): this tick has THREE- # independent jobs - seat takeover, badge shed, save-prompt sweep. The seat section's- # old early `return`s exited the WHOLE function on the no-dialog (normal!) path, so- # the badge NEVER shed and the save sweep NEVER ran unless a seat dialog was up.- # Each section is now self-contained; none may `return`.- try:- if _seat_takeover_enabled() and find_licensing_dialog():- _seat_resolve_section()- except Exception:- pass-- # Keep Fusion's window a usable size (once per hwnd; never touches a maximized or- # already-large window, never activates).- try:- if _window_sizing_enabled() and _is_fusion_running():- from fusion_detect import _fusion_main_hwnd_quick as _hwq- _h = _hwq()- _n = _SIZE_ATTEMPTS.get(_h, 0) if _h else 99- if _h and _n < 6: # ~2 minutes of sweeps, then leave it alone forever- _r = ensure_reasonable_window(hwnd=_h)- _why = str(_r.get("why") or "")- if "already" in _why: # large enough or user-maximized -> converged, stop- _SIZE_ATTEMPTS[_h] = 99- else:- _SIZE_ATTEMPTS[_h] = _n + 1- if _r.get("sized"):- print("[Fusion Bridge] window sized on sweep (attempt %d): %s" % (_n + 1, _why))- except Exception:- pass- # Self-heal the badge: if driving is RECENT and Fusion is up but the badge is missing- # (launch race, respawn, a manual clear), put it back on the next tick.- try:- from handlers.taskbar_overlay import badge_active as _ba, set_driving_badge as _sb- from fusion_detect import _fusion_main_hwnd_quick as _hw- if (_driving_badge_enabled() and not _ba() and _LAST_DRIVE_AT[0]- and (_t2.time() - _LAST_DRIVE_AT[0]) <= _BADGE_IDLE_SECONDS and _is_fusion_running()):- h = _hw()- if h:- _sb(h)- except Exception:- pass- # Shed the driving badge after idle (John: "so the user knows we are DONE controlling").- try:- from handlers.taskbar_overlay import badge_active, clear_driving_badge- if badge_active() and _LAST_DRIVE_AT[0] and (_t2.time() - _LAST_DRIVE_AT[0]) > _BADGE_IDLE_SECONDS:- if clear_driving_badge():- print("[Fusion Bridge] driving badge shed after %ds idle" % _BADGE_IDLE_SECONDS)- except Exception:- pass- # DIRECT "Unsaved changes" catcher (John's blocked-screen screenshot, 2026-08-24:- # a graceful stop wedged TEN MINUTES behind this dialog; the sentinel's Escape- # fallback CANCELS the close, so the stop fought its own dialog). Find the dialog by- # its title at the desktop level, prove the doc it names is OURS via UIA text, and- # click Don't Save. Never touches a dialog naming a user's document.- try:- from handlers.dismiss_recovery import _find_window_by_title as _fwt2- _uh = _fwt2("Unsaved changes")- if _uh:- _ours = None- for _probe in tuple(_BRIDGE_DOCS)[:8] + _BRIDGE_DOC_BUILTINS:- _rr = _find_controls_any(_ad_call("desktop_find_control",- {"hwnd": int(_uh), "contains": _probe}, timeout=8))- if (_rr.get("best") or {}).get("name"):- _ours = _probe- break- if _ours:- _ck2 = _unwrap(_ad_call("desktop_ui_click",- {"hwnd": int(_uh), "name": "Don't Save"}, timeout=10))- _bridge_action_ledger("unsaved_changes_dont_save",- {"hwnd": int(_uh), "docMatch": _ours,- "clicked": bool(_ck2.get("invoked") or _ck2.get("success"))})- print("[Fusion Bridge] janitor: answered the Unsaved-changes dialog with "- "Don't Save (bridge doc %r)" % _ours)- except Exception:- pass- # Same tick also sweeps a standing Save prompt for BRIDGE-CREATED docs (2026-08-16:- # a bare "Save" Qt modal from a killed session blocked the main thread invisibly and- # failed two tours in a row - the sentinel only ran during doc-switch calls).- try:- info = get_fusion_window_info() or {}- if info.get("dialogs"):- _dismiss_save_prompt_if_bridge_doc()- except Exception:- pass- # PREVENTION beats dismissal: close our own dirty scratch docs while nothing is- # driving, so the Save prompt never gets raised at the user in the first place.- # Self-contained like every other section here - it must never be able to starve- # the sections after it (the early-return bug that broke this sweep before).- try:- _close_idle_bridge_docs()- except Exception:- pass- # A Create-new prompt from ANY route (not just our own EDIT) blocks the main thread.- # Answer No: it means a name did not exist, and inventing an empty artifact would hide- # that. Self-contained, like every other section in this sweep.- try:- for _d in (classify_blocking_dialogs() or []):- if _d.get("category") == "create_confirm":- _unwrap(_ad_call("desktop_ui_click", {"hwnd": _d.get("hwnd"), "name": "No"}, timeout=12))- print("[Fusion Bridge] janitor: declined a stray Create-new prompt (%s)"- % _d.get("matchedOn", "title"))- _bridge_action_ledger("create_confirm_declined", {"matchedOn": _d.get("matchedOn")})- except Exception:- pass- # Autodesk's CRASH REPORTER ("Fusion Error Report", its own process - it outlives- # Fusion, so it is NOT in the Fusion window family and no other sweep sees it).- # John hit one on screen after the #50 seat-suspension day (2026-08-23). Two jobs:- # (1) LEDGER it - a crash reporter is a timestamped "Fusion really crashed" marker,- # exactly the forensic breadcrumb #50 lacked; (2) CLOSE it quietly so the user never- # babysits it. Always "Close", never "Send Report": submitting crash data to Autodesk- # is the user's call, not the janitor's. Self-contained like every section here.- try:- # LOCAL Win32 walk, not a relay verb: the relay envelope nests results under a- # stringified `output` and the first version of this section parsed it wrong, so- # a wedged black cer_dialog sat on John's taskbar for two hours (2026-08-23).- # CER is its own process (cer_dialog.exe) that outlives Fusion; when its parent- # died abruptly it renders BLACK and answers no UIA - escalate: WM_CLOSE, then- # (only for a confirmed cer_dialog pid) a single-pid terminate. Never Send Report.- import ctypes as _ct- from handlers.dismiss_recovery import _find_window_by_title as _fwt- _ch = _fwt("Fusion Error Report")- if _ch and not _host_op_active():- _u32 = _ct.windll.user32- _pid = _ct.c_ulong(0)- _u32.GetWindowThreadProcessId(_ct.c_void_p(int(_ch)), _ct.byref(_pid))- _bridge_action_ledger("crash_reporter_seen", {"hwnd": int(_ch), "pid": _pid.value})- _u32.PostMessageW(_ct.c_void_p(int(_ch)), 0x0010, 0, 0) # WM_CLOSE- _t2.sleep(2)- if _u32.IsWindow(_ct.c_void_p(int(_ch))):- # still standing: confirm the pid really is cer_dialog before terminating- try:- import subprocess as _sp- _nm = _sp.run(["tasklist", "/FI", "PID eq %d" % _pid.value, "/FO", "CSV", "/NH"],- capture_output=True, text=True, timeout=10,- creationflags=getattr(_sp, "CREATE_NO_WINDOW", 0)).stdout- if "cer_dialog" in _nm.lower():- _sp.run(["taskkill", "/PID", str(_pid.value), "/F"],- capture_output=True, timeout=10,- creationflags=getattr(_sp, "CREATE_NO_WINDOW", 0))- _bridge_action_ledger("crash_reporter_killed",- {"pid": _pid.value, "why": "wedged (WM_CLOSE ignored)"})- except Exception:- pass- else:- _bridge_action_ledger("crash_reporter_dismissed", {"hwnd": int(_ch), "closed": True})- print("[Fusion Bridge] janitor: Fusion crash reporter handled (ledgered; never "- "Send Report on the user's behalf)")- except Exception:- pass---def _seat_resolve_section():- try:- r = _resolve_seat_dialog(max_clicks=3)- if r.get("clicks"):- now = _t2.time()- if now - _last_seat_takeover_notice[0] > 120:- _last_seat_takeover_notice[0] = now- try:- ad_client.notify(- "Fusion seat taken over on this machine",- "Autodesk flagged your account's session limit, so I clicked through the "- "'Active Sessions Exceeded' dialogs in the background and took the seat here. "- "Fusion on your other machine is SUSPENDED (resumable, nothing lost) - "- "using Fusion there will simply grab the seat back the same way.",- level="info", reach_user=True)- except Exception:- pass- print("[Fusion Bridge] seat sweep: took over the session (clicks=%s, verified=%s)"- % (r.get("clicks"), r.get("verified")))- _bridge_action_ledger("seat_takeover",- {"clicks": r.get("clicks"), "verified": r.get("verified")})- except Exception:- pass---def _signin_janitor_loop():- """Poll until each tracked sign-in window can be closed, then close it."""- while True:- try:- _t2.sleep(20)- _seat_sweep_tick()- # 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()- # Same sweep also clears the Recovered Documents dialog whenever it- # shows up MID-SESSION or on a user-launched Fusion (launch-time- # relocation only covers bridge-driven starts). Info-only dialog:- # closing loses nothing (File > Recover Documents keeps the list),- # so it is ALWAYS safe to close in the background. (John, 2026-08-16:- # "anytime it comes up, just ignore it and close it".)- try:- if not _host_op_active():- dismiss_recovery_dialog(background=True)- except Exception:- pass- 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 _advance_credentials_page(hwnd, email) -> dict:- """Self-drive the Autodesk credentials page as far as possible WITHOUT secrets- (John, 2026-08-16: 'do as much self driving automation as you can... only let the- AI know when you need it to know'). Background UIA only - no foreground, no focus- steal: set the email/username field, invoke Next/Sign in, then classify what the- page became. Returns {advanced, wall, detail}. NEVER touches password fields."""- import time as _t- out = {"advanced": False, "wall": "credentials", "detail": ""}- try:- found = _find_controls_any(_ad_call("desktop_find_control", {"hwnd": int(hwnd), "role": "edit"}, timeout=40))- ctrls = found.get("controls") or []- if not ctrls and found.get("best"):- ctrls = [found["best"]]- pwd = [c for c in ctrls if "password" in str(c.get("name", "")).lower()- or "password" in str(c.get("automationId", "")).lower()]- if pwd:- out["wall"] = "password"- out["detail"] = "password field present - the human step"- return out- emailish = [c for c in ctrls if any(k in (str(c.get("name", "")) + str(c.get("automationId", ""))).lower()- for k in ("email", "username", "user id", "userid"))] or ctrls[:1]- if email and emailish:- c = emailish[0]- r = _unwrap(_ad_call("desktop_ui_set", {"hwnd": int(hwnd),- "automationId": c.get("automationId"),- "name": c.get("name"), "value": email}, timeout=30))- if r.get("success") or r.get("ok"):- out["detail"] = "typed email %r into %r (background UIA)" % (email, c.get("name") or c.get("automationId"))- for btn in ("Next", "NEXT", "Sign in", "Sign In", "Continue"):- rb = _unwrap(_ad_call("desktop_ui_click", {"hwnd": int(hwnd), "name": btn}, timeout=25))- if rb.get("success") or rb.get("ok") or rb.get("status") == "ok":- out["advanced"] = True- out["detail"] += "; invoked %r" % btn- break- if out["advanced"]:- _t.sleep(4)- found2 = _unwrap(_ad_call("desktop_find_control", {"hwnd": int(hwnd), "role": "edit"}, timeout=40))- ctrls2 = found2.get("controls") or found2.get("matches") or []- if not ctrls2 and found2.get("best"):- ctrls2 = [found2["best"]]- if any("password" in (str(c.get("name", "")) + str(c.get("automationId", ""))).lower() for c in ctrls2):- out["wall"] = "password"- out["detail"] += "; page advanced to the PASSWORD step"- except Exception as e:- out["detail"] = "advance error: " + str(e)[:120]- 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."),- "password": ("Just your password, everything else is done",- "Adom drove the Fusion sign-in all the way: email entered, page advanced. "- "The ONLY step left is your password (and I never type secrets). Type it in "- "the browser window and everything continues automatically."),- "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 _resolve_and_advance_wall(wall_info, email) -> dict:- """Given a detected wall, push it as far as code can go (email typing on the- credentials page), refining 'credentials' into 'password' when that is what- remains. Returns possibly-updated wall_info with 'advance' detail attached."""- if wall_info.get("wall") == "credentials" and wall_info.get("hwnd"):- adv = _advance_credentials_page(wall_info["hwnd"], email)- wall_info = dict(wall_info)- wall_info["wall"] = adv.get("wall") or wall_info["wall"]- wall_info["advance"] = adv.get("detail")- wall_info["advanced"] = adv.get("advanced")- return wall_info---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 == "password":- return (common +- "WALL: PASSWORD ONLY. The bridge already typed the user's email and advanced the "- "page (see steps[]) - the single remaining human act is typing their password in "- "the browser window. Do NOT ask them for the password, do NOT try to type it, and "- "do NOT re-drive the page. After they type it, the flow continues automatically - "- "poll fusion_readiness until ready:true (a 2FA wall may follow; if it does you may "- "be able to finish it yourself via fusion_signin_2fa + adom-google Gmail).")- 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 _find_coordmap(obj, depth=0):- """Recursively find a coordMap (with shotId) anywhere in a verb response - ab has- moved it across envelope levels between versions (top-level sibling of data in some,- nested under data in others, stringified inside output in 2.0.46's direct API).- Returns the coordMap dict or {}."""- if depth > 5:- return {}- if isinstance(obj, str) and obj[:1] in "[{":- try:- import json as _j- return _find_coordmap(_j.loads(obj), depth + 1)- except Exception:- return {}- if isinstance(obj, dict):- cm = obj.get("coordMap")- if isinstance(cm, dict) and cm.get("shotId"):- return cm- if obj.get("shotId"):- return obj- for k in ("data", "output", "result", "response"):- got = _find_coordmap(obj.get(k), depth + 1)- if got:- return got- return {}---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)-- _raw = _ad_call("desktop_screenshot_window", {"hwnd": int(hwnd)}, timeout=45)- shot = _unwrap(_raw)- # ab has moved coordMap across envelope levels between versions (sibling of data /- # nested / stringified in output on 2.0.46) - search recursively, never guess a level.- cm = _find_coordmap(_raw)- sid = cm.get("shotId")- if not sid:- # VERSION-PROOF fallback (ab 2.0.46's direct API returns no coordMap at all):- # compute the click in SCREEN space from the window rect - no shotId needed.- w_rect = (win.get("rect") or {})- L = w_rect.get("left"); T = w_rect.get("top")- W = w_rect.get("width") or 0; H = w_rect.get("height") or 0- if L is not None and W > 400 and H > 300:- sx = int(L + W * 0.5); sy = int(T + H * 0.57)- rr = _unwrap(_ad_call("desktop_click", {"x": sx, "y": sy, "hwnd": int(hwnd),- "reason": "signing Fusion in for you (clicking Sign In, screen-space fallback)"},- timeout=30))- okc = rr.get("success") or rr.get("ok") or rr.get("status") == "ok"- return {"clicked": bool(okc),- "why": "screen-space fallback click at (%d,%d) rect %sx%s: %s" % (sx, sy, W, H, "ok" if okc else str(rr.get("error"))[:80]),- "via": "screen-fallback"}- if not sid:- return {"clicked": False,- "why": "screenshot returned no shotId (hwnd=%s raw_keys=%s err=%s)" % (- hwnd,- sorted(list(_raw.keys()))[:8] if isinstance(_raw, dict) else type(_raw).__name__,- str((_raw or {}).get("error"))[:100] if isinstance(_raw, dict) else "")}- 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"):- # Self-drive (John, 2026-08-16): never hand "go start Fusion" back to the caller.- steps.append("Fusion was not running - launching it (background) as part of sign-in")- try:- dispatch_command("start", {})- except Exception as e:- steps.append("launch failed: " + str(e)[:80])- import time as _lt- deadline = _lt.time() + 120- while _lt.time() < deadline:- _lt.sleep(8)- rd = _handle_fusion_readiness({}, {})- if rd.get("running"):- break- if not rd.get("running"):- return {"success": False, "stage": "launch_failed", "done": False, "steps": steps,- "error": "Fusion did not come up within 120s of launch.",- "_hint": "Check fusion_readiness; the box may be mid-update. Retry fusion_signin.",- "statusVerb": "fusion_readiness"}- _lt.sleep(5)- 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()- _email0 = args.get("email") or next((c.get("email") for c in choices if c.get("profile") == target and c.get("email")), None)- if wall0.get("wall"):- wall0 = _resolve_and_advance_wall(wall0, _email0)- 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 {}- _email1 = args.get("email") or next((c.get("email") for c in choices if c.get("profile") == target and c.get("email")), None)- if wall.get("wall"):- wall = _resolve_and_advance_wall(wall, _email1)- 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))-- if not opened:- # ABE-less box fallback (John, 2026-08-16: "push through as far as you can go from- # your code"): the OAuth page is ALREADY OPEN in the browser Fusion fired (that is- # where we captured the URL). No reopen is possible without the extension - so- # DRIVE THE EXISTING WINDOW instead: detect the wall, type the email, advance to- # the password step, toast the human there.- wall_live = _detect_signin_wall()- if wall_live.get("wall"):- _email_fb = args.get("email")- wall_live = _resolve_and_advance_wall(wall_live, _email_fb)- wk = wall_live.get("wall")- notified_fb = bool(_notify_signin_wall(wk).get("delivered")) if wk else False- steps.append("no extension on this box - drove the EXISTING browser window instead: %s"- % str(wall_live.get("advance") or "wall %r" % wk))- return {"success": True, "stage": "driving_existing_window", "done": False,- "steps": steps, "signinWall": wk, "signinWallHwnd": wall_live.get("hwnd"),- "advanced": wall_live.get("advanced"), "notifyDelivered": notified_fb,- "signinWallHint": _wall_hint(wk, notified_fb),- "signinAuthUrl": auth["url"],- "narrate": ("This machine has no Adom browser extension, so I drove the sign-in "- "window Fusion already opened: %s." % str(wall_live.get("advance") or wk)),- "_hint": ("No ABE here, so the EXISTING browser window was driven directly "- "(email typed via background UIA where possible). " +- _wall_hint(wk, notified_fb)),- "statusVerb": "fusion_readiness"}- 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"}-----# ═══════════════════════════════════════════════════════════════════════════════-# Library SHOW verbs + bare-artifact wrapping (issue #29, John 2026-08-14):-# first-class opens for each library content type (symbol / footprint / 3D chip),-# accepting EITHER an existing .lbr + name OR a bare Fusion/EAGLE-formatted XML-# fragment which gets WRAPPED in a generated minimal library so it shows in-# Fusion's Library Manager. 3D chips ride the attach-after-open path because-# embedded <packages3d> in a hand-built .lbr is exactly the Fusion quirk that-# does not work reliably - attach post-open DOES.-# ═══════════════════════════════════════════════════════════════════════════════--_LBR_WRAP_SETTINGS = '<settings><setting alwaysvectorfont="no"/></settings>'-_LBR_WRAP_GRID = '<grid distance="0.1" unitdist="inch" unit="inch" style="lines" multiple="1" display="no" altdistance="0.01" altunitdist="inch" altunit="inch"/>'-_LBR_WRAP_LAYERS = '<layers>\n<layer number="1" name="Top" color="4" fill="1" visible="yes" active="yes"/>\n<layer number="16" name="Bottom" color="1" fill="1" visible="yes" active="yes"/>\n<layer number="17" name="Pads" color="2" fill="1" visible="yes" active="yes"/>\n<layer number="18" name="Vias" color="2" fill="1" visible="yes" active="yes"/>\n<layer number="19" name="Unrouted" color="6" fill="1" visible="yes" active="yes"/>\n<layer number="20" name="Dimension" color="15" fill="1" visible="yes" active="yes"/>\n<layer number="21" name="tPlace" color="7" fill="1" visible="yes" active="yes"/>\n<layer number="22" name="bPlace" color="7" fill="1" visible="yes" active="yes"/>\n<layer number="23" name="tOrigins" color="15" fill="1" visible="yes" active="yes"/>\n<layer number="24" name="bOrigins" color="15" fill="1" visible="yes" active="yes"/>\n<layer number="25" name="tNames" color="7" fill="1" visible="yes" active="yes"/>\n<layer number="26" name="bNames" color="7" fill="1" visible="yes" active="yes"/>\n<layer number="27" name="tValues" color="7" fill="1" visible="yes" active="yes"/>\n<layer number="28" name="bValues" color="7" fill="1" visible="yes" active="yes"/>\n<layer number="29" name="tStop" color="7" fill="3" visible="no" active="yes"/>\n<layer number="30" name="bStop" color="7" fill="6" visible="no" active="yes"/>\n<layer number="31" name="tCream" color="7" fill="4" visible="no" active="yes"/>\n<layer number="32" name="bCream" color="7" fill="5" visible="no" active="yes"/>\n<layer number="33" name="tFinish" color="6" fill="3" visible="no" active="yes"/>\n<layer number="34" name="bFinish" color="6" fill="6" visible="no" active="yes"/>\n<layer number="35" name="tGlue" color="7" fill="4" visible="no" active="yes"/>\n<layer number="36" name="bGlue" color="7" fill="5" visible="no" active="yes"/>\n<layer number="37" name="tTest" color="7" fill="1" visible="no" active="yes"/>\n<layer number="38" name="bTest" color="7" fill="1" visible="no" active="yes"/>\n<layer number="39" name="tKeepout" color="4" fill="11" visible="no" active="yes"/>\n<layer number="40" name="bKeepout" color="1" fill="11" visible="no" active="yes"/>\n<layer number="41" name="tRestrict" color="4" fill="10" visible="no" active="yes"/>\n<layer number="42" name="bRestrict" color="1" fill="10" visible="no" active="yes"/>\n<layer number="43" name="vRestrict" color="2" fill="10" visible="no" active="yes"/>\n<layer number="44" name="Drills" color="7" fill="1" visible="no" active="yes"/>\n<layer number="45" name="Holes" color="7" fill="1" visible="no" active="yes"/>\n<layer number="46" name="Milling" color="3" fill="1" visible="no" active="yes"/>\n<layer number="47" name="Measures" color="7" fill="1" visible="no" active="yes"/>\n<layer number="48" name="Document" color="7" fill="1" visible="yes" active="yes"/>\n<layer number="49" name="Reference" color="7" fill="1" visible="yes" active="yes"/>\n<layer number="91" name="Nets" color="2" fill="1" visible="yes" active="yes"/>\n<layer number="92" name="Busses" color="1" fill="1" visible="yes" active="yes"/>\n<layer number="93" name="Pins" color="2" fill="1" visible="no" active="yes"/>\n<layer number="94" name="Symbols" color="4" fill="1" visible="yes" active="yes"/>\n<layer number="95" name="Names" color="7" fill="1" visible="yes" active="yes"/>\n<layer number="96" name="Values" color="7" fill="1" visible="yes" active="yes"/>\n<layer number="97" name="Info" color="7" fill="1" visible="yes" active="yes"/>\n<layer number="98" name="Guide" color="6" fill="1" visible="yes" active="yes"/>\n</layers>'---def _lbr_wrap_build(name, symbol_xml=None, package_xml=None):- """Build a minimal, Fusion-parseable EAGLE .lbr wrapping bare artifacts.-- A deviceset is generated only when a symbol exists (a gate needs a symbol);- a bare footprint still lists under Packages. 3D packages are NEVER embedded- here (the known-flaky path); use stepPath on show_3d_package to attach after- the open instead.- """- symbols = symbol_xml or ""- packages = package_xml or ""- devicesets = ""- if symbol_xml:- connects = ""- variant = '<device name=""/>'- if package_xml:- pkg_m = re.search(r'<package\s+name="([^"]+)"', package_xml)- pkg_name = pkg_m.group(1) if pkg_m else name- variant = '<device name="" package="%s"><technologies><technology name=""/></technologies></device>' % pkg_name- devicesets = (- '<deviceset name="%s" prefix="U"><description>Adom wrapper</description>'- '<gates><gate name="G$1" symbol="%s" x="0" y="0"/></gates>'- '<devices>%s</devices></deviceset>' % (name, name, variant)- )- return (- '<?xml version="1.0" encoding="utf-8"?>\n'- '<!DOCTYPE eagle SYSTEM "eagle.dtd">\n'- '<eagle version="9.6.2">\n<drawing>\n'- + _LBR_WRAP_SETTINGS + '\n' + _LBR_WRAP_GRID + '\n' + _LBR_WRAP_LAYERS + '\n'- + '<library>\n<description>Adom wrapper library: ' + name + '</description>\n'- + '<packages>' + packages + '</packages>\n'- + '<symbols>' + symbols + '</symbols>\n'- + '<devicesets>' + devicesets + '</devicesets>\n'- + '</library>\n</drawing>\n</eagle>\n'- )---def _lbr_wrap_write(name, symbol_xml=None, package_xml=None):- """Write a wrapper .lbr to the wrap dir; returns (path, None) or (None, err)."""- import os- import tempfile- for frag, kind, tag in ((symbol_xml, "symbolXml", "symbol"), (package_xml, "packageXml", "package")):- if frag and not re.match(r"\s*<" + tag + r"[\s>]", frag):- return None, ("%s must be a bare <%s ...>...</%s> XML fragment "- "(got something else). To open a whole library, pass libraryPath instead." % (kind, tag, tag))- safe = re.sub(r"[^A-Za-z0-9_-]", "_", name)[:60] or "wrapped"- d = os.path.join(tempfile.gettempdir(), "adom-fusion-wrap")- os.makedirs(d, exist_ok=True)- path = os.path.join(d, safe + ".lbr").replace("\\", "/")- try:- _note_bridge_doc(safe) # generated wrapper = bridge doc (sentinel may discard its edits)- except Exception:- pass- try:- with open(path, "w", encoding="utf-8") as f:- f.write(_lbr_wrap_build(name, symbol_xml, package_xml))- except Exception as e:- return None, "could not write wrapper library: " + str(e)- return path, None---def _artifact_name_from(args, *frag_keys):- """Resolve the artifact name: explicit arg wins, else parsed from the fragment."""- name = (args.get("name") or "").strip()- if name:- return name- for k in frag_keys:- frag = args.get(k) or ""- m = re.search(r'name="([^"]+)"', frag)- if m:- return m.group(1)- return ""----def _remember_open_library(lbr_path: str):- """Record the .lbr we just opened so LATER verbs can pre-check names without a path.-- ⛔ THIS EXISTS TO PREVENT A MODAL, NOT TO BE TIDY (John, 2026-08-26, third report of the- same dialog). `_resolve_artifact_name` could always stop a Create-new prompt, but it- needs the .lbr on disk, and `fusion_capture_library_views` never took a libraryPath - so- the ONE verb that EDITs the most names was the one verb structurally unable to run the- guard, and it "handled" the prompt by raising it and dismissing it afterwards. Persisted- (not just module state) because AD reaps and respawns this bridge between calls, which- would drop in-memory state exactly when a capture follows an open.- """- # LOCAL imports + a LOUD except. Both matter: this module imports os/time inside- # functions, so a module-level `time.time()` here raised NameError - and the bare- # `except: pass` I first wrote ATE it, leaving the guard silently disarmed while the- # code read as correct. A prevention guard that fails quietly is worse than none,- # because it removes the pressure to notice.- try:- import pathlib as _pl, json as _js, os as _os, time as _tm- d = _pl.Path(_os.path.expanduser("~")) / ".adom" / "fusion-bridge"- d.mkdir(parents=True, exist_ok=True)- (d / "open_library.json").write_text(_js.dumps(- {"libraryPath": lbr_path.replace("\\", "/"), "at": _tm.time()}))- except Exception as e:- print("[Fusion Bridge] ⚠ could not remember the open library (%s: %s) - the "- "Create-new-dialog pre-check will fall back to SET CONFIRM only"- % (type(e).__name__, e))---def _remembered_open_library():- """The last .lbr opened, if it is still on disk. None when we genuinely do not know."""- try:- import pathlib as _pl, json as _js, os as _os- f = _pl.Path(_os.path.expanduser("~")) / ".adom" / "fusion-bridge" / "open_library.json"- if not f.exists():- return None- lp = (_js.loads(f.read_text()) or {}).get("libraryPath") or ""- return lp if lp and _os.path.exists(lp) else None- except Exception as e:- print("[Fusion Bridge] ⚠ could not read the remembered library (%s: %s)"- % (type(e).__name__, e))- return None---def _library_artifact_names(lbr_path: str) -> dict:- """The names that actually EXIST in a .lbr: {"sym": [...], "pac": [...], "dev": [...]}.-- EAGLE/Fusion libraries are XML, so the <symbol>/<package>/<deviceset> name attributes- are the source of truth. Empty dict when the file is unreadable - callers must treat- "I could not check" as "do not guess", never as "it is missing".- """- try:- import xml.etree.ElementTree as _ET- root = _ET.parse(lbr_path).getroot()- except Exception:- return {}- def names(tag):- return [e.get("name") for e in root.iter(tag) if e.get("name")]- return {"sym": names("symbol"), "pac": names("package"), "dev": names("deviceset"),- "p3d": [e.get("name") for e in root.iter("package3d") if e.get("name")]}---def _resolve_artifact_name(name: str, suffix: str, lbr_path: str):- """Resolve `name` to something that EXISTS, or say it does not - WITHOUT asking Fusion.-- ⚠️ PREVENTION, NOT CLEANUP (John, 2026-08-21: "you should never allow dialogs like that- to ever even show cuz they screw you up"). `EDIT <name>.<suffix>` on a name that is not- in the library pops Fusion's "Create new footprint 'X'?" modal, which blocks the add-in's- main thread until somebody answers it - so a typo became a hang, and every later verb- read as "add-in not responding". This bridge KNEW that: the old hint here told callers to- run fusion_dismiss_blocking_dialogs afterwards. Telling the caller to sweep up a mess we- chose to make is exactly backwards.-- Returns (resolved_name, None) or (None, error_dict). Exact match wins; then a unique- case-insensitive match, because EAGLE names round-trip through tools that change case.- """- have = _library_artifact_names(lbr_path).get(suffix if suffix in ("sym", "pac", "dev") else "", [])- if not have:- # REFUSE, do not wave it through (reversed 2026-08-21, John caught the prompt- # live): "cannot read the library -> do not block" meant a corrupt .lbr let the- # EDIT reach Fusion, which popped "Create new footprint 'X'?" - the exact modal- # this resolver exists to prevent. An unreadable or artifact-less library makes- # the prompt CERTAIN, so certainty of a prompt is exactly when to block.- return None, {- "success": False, "errorCode": "library_unreadable",- "error": "Cannot read any %s names from %s - the library is unparseable or "- "empty, so EDIT was NOT issued (it would certainly raise Fusion's "- "Create-new prompt)." % (suffix, lbr_path),- "_hint": "Validate the .lbr first (fusion_open_lbr now refuses garbage with "- "lbr_invalid + the file size/head). Re-stage the file from its source "- "and compare hashes."}- if name in have:- return name, None- ci = [h for h in have if h.lower() == str(name).lower()]- if len(ci) == 1:- return ci[0], None- # A CALLER SHOULD NEVER PARSE OUR FILE FORMAT TO TALK TO US (page issue #42). ab ended up- # regexing <symbol name="..."> out of the .lbr because the trailing character differed- # (file CL21B104KBCNFNC.lbr vs deviceset CL21B104KBCNFN). We have the library open and we- # know the names, so resolve the near-miss ourselves and, when we cannot, put the names in- # the error so the retry is obvious without reading XML.- import difflib as _dl- close = _dl.get_close_matches(str(name), have, n=5, cutoff=0.6)- stem = str(name).rsplit("/", 1)[-1].rsplit(".", 1)[0]- pref = [h for h in have if h.lower().startswith(stem.lower()[:max(4, len(stem) - 2)])]- if len(close) == 1 and close[0].lower() != str(name).lower():- print("[Fusion Bridge] resolved near-miss %r -> %r (issue #42)" % (name, close[0]))- return close[0], None- if len(pref) == 1:- print("[Fusion Bridge] resolved by stem %r -> %r (issue #42)" % (name, pref[0]))- return pref[0], None- return None, {- "success": False,- "error": "No %s named %r in that library, so the EDIT was NOT issued." % (- {"sym": "symbol", "pac": "footprint", "dev": "device"}.get(suffix, suffix), name),- "errorCode": "artifact_not_in_library",- "available": have[:40],- "closestMatches": close,- "_hint": ("Deliberately refused BEFORE touching Fusion: EDIT on a missing name pops a "- "'Create new ...?' modal that blocks the add-in's main thread and makes every "- "later verb look like a crash. Pick a name from `available` (they are the "- "<symbol>/<package>/<deviceset> name attributes in the .lbr, the source of "- "truth) and re-call. Nothing was opened and no dialog was raised."),- }---def _show_library_view(name, suffix, lbr_path=None):- """EDIT to a library view + fit + background screenshot. Library must be open.-- Verifies the name EXISTS first when we know the .lbr, so Fusion is never asked to open- something that would make it prompt (see _resolve_artifact_name).- """- import time- if lbr_path:- resolved, err = _resolve_artifact_name(name, suffix, lbr_path)- if err:- return err- if resolved != name:- print("[Fusion Bridge] resolved %r -> %r from the library (avoids a Create-new prompt)"- % (name, resolved))- name = resolved- # ⚠️ EDIT DRIVES THE ACTIVE DOCUMENT, NOT A PATH (page issue #47, root cause of- # failure modes 1 AND 2): in a batch, several libraries (and f3ds) are open at- # once, and EDIT lands in whichever is active. The artifact scan read the right- # FILE while EDIT drove the WRONG document - so Fusion offered to CREATE a part- # that plainly existed, or refused with "Not in Electronics editor". Make the- # target library active, verified, before every EDIT.- act = _ensure_library_active(lbr_path, timeout_s=30)- if not act.get("ok"):- return {"success": False, "errorCode": "library_not_active",- "error": "Could not make %s the ACTIVE document (active: %r), so EDIT "- "was not issued - it would have driven the wrong library." % (- lbr_path.rsplit("/", 1)[-1], act.get("activeDocument")),- "_hint": "fusion_open_lbr the library, then re-call. In a batch, close "- "the previous part's documents first (the create_library chain "- "now does this itself)."}- # BUSY-RETRY (matrix round 2026-08-21, ads1220irvar lost 2 frames to "add-in is BUSY"):- # on a shared box another caller's long command can hold the main thread exactly when- # a capture's EDIT lands. Busy is transient by definition - wait it out (up to 90s)- # instead of failing the frame.- edit = None- for _busy_try in range(7):- edit = _proxy_to_addin("electron_run", {"command": "EDIT %s.%s" % (name, suffix),- "confirm": "no"}, timeout=15)- _etxt = str(edit.get("error") or "")- if edit.get("success"):- break- # NOT-IN-ELECTRONICS-EDITOR RETRY (#49 parts 6/7, 2026-08-23): the 3D step can leave- # Fusion on the Design workspace, and even after _ensure_library_active a stray EDIT- # can land a beat before the switch completes. Re-ensure the library editor and retry- # rather than losing the frame - the same "transient, wait it out" law as BUSY.- if lbr_path and ("not in electronics editor" in _etxt.lower()- or "current: design" in _etxt.lower()):- print("[Fusion Bridge] EDIT %s.%s hit Design workspace (attempt %d) - re-entering "- "the library editor and retrying" % (name, suffix, _busy_try + 1))- _ensure_library_active(lbr_path, timeout_s=20)- time.sleep(1.0)- continue- if "BUSY" not in _etxt and "busy" not in _etxt:- break- print("[Fusion Bridge] EDIT %s.%s deferred - main thread busy (attempt %d), waiting 15s"- % (name, suffix, _busy_try + 1))- time.sleep(15)- # WE MAY NOT HAVE BEEN ABLE TO PRE-CHECK (no libraryPath, nothing remembered, or the- # library was opened by another route). If Fusion prompted anyway, answer it HERE - a- # modal we caused must never outlive the call that caused it, and it is holding the main- # thread hostage while it sits there (John saw this dialog twice, 2026-08-21).- try:- _pending = [d for d in (classify_blocking_dialogs() or [])- if d.get("category") == "create_confirm"]- if _pending:- # A create_confirm on screen is not automatically OURS (run 7d4427d443f5: a- # footprint EDIT whose name WAS in the library got failed because a stale- # Create-new prompt from an earlier interaction was still standing). Read the- # dialog's own text: only a prompt that names THIS artifact fails THIS call.- # Foreign/stale prompts are still declined (never left blocking the main- # thread), but the EDIT's own outcome decides the result.- _about_this = False- for _d in _pending:- try:- from handlers.dialog_classify import _dialog_text as _dtxt- _body = "%s %s" % (_dtxt(_d.get("hwnd")) or "", _d.get("title") or "")- except Exception:- _body = _d.get("title") or ""- if str(name).lower() in _body.lower():- _about_this = True- _unwrap(_ad_call("desktop_ui_click", {"hwnd": _d.get("hwnd"), "name": "No"}, timeout=12))- if not _about_this:- print("[Fusion Bridge] declined a STALE Create-new prompt that does not mention "- "%r - clearing it and trusting the EDIT's own result" % name)- if _pending and _about_this:- print("[Fusion Bridge] declined a Create-new prompt for %r - the name is not in the "- "library (answering Yes would fabricate an empty artifact)" % name)- return {- "success": False,- "error": "No %s named %r in the open library. Fusion offered to CREATE it; the "- "bridge declined." % ({"sym": "symbol", "pac": "footprint",- "dev": "device"}.get(suffix, suffix), name),- "errorCode": "artifact_not_in_library",- "promptDeclined": True,- "_hint": ("The prompt was answered (No) inside this call, so nothing is left "- "blocking Fusion's main thread. Answering Yes would have created an "- "EMPTY artifact and made this look like it worked. Pass libraryPath so "- "the bridge can verify names up front and never raise the prompt at all."),- }- except Exception:- pass- if not edit.get("success"):- return {"success": False, "error": "EDIT %s.%s failed: %s" % (name, suffix, edit.get("error")),- "_hint": ("The name must match the deviceset/artifact inside the OPEN library exactly "- "(EDIT on a missing name pops a 'Create new?' dialog - run "- "fusion_dismiss_blocking_dialogs if one appeared). Not sure of the name? "- "fusion_get_app_state shows the active document; the .lbr's <symbol>/<package> "- "name attributes are the source of truth.")}- _proxy_to_addin("electron_run", {"command": "WINDOW FIT"}, timeout=8)- time.sleep(1.0)- # REPORT WHAT WE ACTUALLY OPENED (page issue #40/#42, and kicad's shape: it returns- # symbolNames / footprintName). ab's client was reading our .lbr to learn what to ask- # for; the cure is that the verb SAYS what it opened and what else is in there, so the- # next call needs no discovery step and no file parsing.- out = {"success": True, "view": suffix, "name": name, "resolvedName": name}- if lbr_path:- _n = _library_artifact_names(lbr_path)- if _n:- out["libraryPath"] = lbr_path- out["symbolNames"] = _n.get("sym", [])- out["packageNames"] = _n.get("pac", [])- out["devicesetNames"] = _n.get("dev", [])- out["package3dNames"] = _n.get("p3d", [])- out["has3d"] = bool(_n.get("p3d"))- try:- shot = _handle_screenshot_fusion({}, {})- p = shot.get("savedTo") or shot.get("localSafePath")- if shot.get("success") and p:- out["screenshot"] = p- except Exception:- pass- return out---def _last_library() -> str:- """The .lbr we most recently opened, across respawns."""- if _LAST_LIBRARY[0]:- return _LAST_LIBRARY[0]- try:- return _get_bridge_pref("lastLibraryPath", "") or ""- except Exception:- return ""---_LAST_LIBRARY = [""] # the .lbr most recently opened by this bridge---def _show_prepare_library(args, symbol_xml=None, package_xml=None, name=""):- """Open the target library: explicit path, generated wrapper, or already-open.-- Returns (errdict|None, wrapped_path|None)."""- library_path = (args.get("libraryPath") or "").replace("\\", "/")- wrapped = None- if symbol_xml or package_xml:- if library_path:- return {"success": False,- "error": "Pass EITHER libraryPath OR a bare XML fragment, not both.",- "_hint": "libraryPath opens an existing library; a fragment gets wrapped in a generated one."}, None- wrapped, err = _lbr_wrap_write(name, symbol_xml, package_xml)- if err:- return {"success": False, "error": err}, None- library_path = wrapped- if library_path:- opened = _orchestrate_open_lbr({"filePath": library_path})- if not opened.get("success"):- return {"success": False, "error": "open_lbr failed: " + str(opened.get("error")),- "data": {"libraryPath": library_path, "wrapped": bool(wrapped)}}, None- # REMEMBER IT. The name-only call path ("the library is already open") had no .lbr to- # check against, so the Create-new guard silently did nothing and the blind EDIT went- # out anyway - which is why John saw the dialog AGAIN after the first fix. Whatever we- # last opened IS the library those calls mean.- _LAST_LIBRARY[0] = library_path- try:- _set_bridge_pref("lastLibraryPath", library_path) # survives a bridge respawn- except Exception:- pass- return None, wrapped---def _orchestrate_show_symbol(args: dict) -> dict:- """Open a schematic SYMBOL in Fusion's library editor.-- args: name (deviceset/symbol name), plus ONE of:- libraryPath - existing .lbr to open first (omit if the library is already open)- symbolXml - a bare <symbol ...>...</symbol> fragment; a wrapper library is- generated around it so it shows in the Library Manager.- """- symbol_xml = args.get("symbolXml")- name = _artifact_name_from(args, "symbolXml")- if not name:- return {"success": False, "error": "name required (or a symbolXml fragment with a name attribute)."}- err, wrapped = _show_prepare_library(args, symbol_xml=symbol_xml, name=name)- if err:- return err- out = _show_library_view(name, "sym", wrapped or (args.get("libraryPath") or "").replace("\\", "/") or _last_library())- if wrapped and out.get("success"):- out["wrappedLibrary"] = wrapped- out["_hint"] = ("Bare symbol was wrapped in a generated library at %s (a deviceset with one "- "gate was synthesized so the Library Manager lists it). To pair it with a "- "footprint, re-call with both symbolXml and packageXml." % wrapped)- return out---def _orchestrate_show_footprint(args: dict) -> dict:- """Open a FOOTPRINT (package) in Fusion's library editor.-- args: name, plus ONE of libraryPath | packageXml (bare <package> fragment,- wrapped in a generated library)."""- package_xml = args.get("packageXml")- name = _artifact_name_from(args, "packageXml")- if not name:- return {"success": False, "error": "name required (or a packageXml fragment with a name attribute)."}- err, wrapped = _show_prepare_library(args, package_xml=package_xml, name=name)- if err:- return err- out = _show_library_view(name, "pac", wrapped or (args.get("libraryPath") or "").replace("\\", "/") or _last_library())- if wrapped and out.get("success"):- out["wrappedLibrary"] = wrapped- out["_hint"] = "Bare footprint was wrapped in a generated library at " + wrapped + "."- return out---_SHOW_3D_FALLBACK_PACKAGE = (- '<package name="%s"><description>Adom auto footprint (3D carrier)</description>'- '<smd name="1" x="-1.27" y="0" dx="1.2" dy="1.2" layer="1"/>'- '<smd name="2" x="1.27" y="0" dx="1.2" dy="1.2" layer="1"/>'- '<wire x1="-2.2" y1="1.6" x2="2.2" y2="1.6" width="0.127" layer="21"/>'- '<wire x1="-2.2" y1="-1.6" x2="2.2" y2="-1.6" width="0.127" layer="21"/>'- '<text x="-2.2" y="2" size="1" layer="25">>NAME</text></package>'-)---def _orchestrate_show_3d_package(args: dict) -> dict:- """Open the 3D CHIP (Package3DEnvironment) for a library package; optionally- attach a STEP model first (the reliable add-3D-AFTER-the-open path - embedded- <packages3d> in hand-built libraries is the Fusion quirk that fails).-- args: name, then:- libraryPath - existing .lbr (with packageXml/stepPath absent, just shows it)- packageXml - bare <package> fragment to wrap (footprint carrier)- stepPath - Windows path to a STEP; attached onto the footprint via the- attach machinery. With NO packageXml/libraryPath, a minimal- 2-pad carrier footprint is generated so the STEP has a home.- """- package_xml = args.get("packageXml")- step_path = (args.get("stepPath") or "").replace("\\", "/")- name = _artifact_name_from(args, "packageXml") or (- re.sub(r"[^A-Za-z0-9_-]", "_", step_path.rsplit("/", 1)[-1].rsplit(".", 1)[0])[:60] if step_path else "")- if not name:- return {"success": False, "error": "name required (or derivable from packageXml/stepPath)."}- library_path = (args.get("libraryPath") or "").replace("\\", "/")- wrapped = None- if not library_path:- if not package_xml and step_path:- package_xml = _SHOW_3D_FALLBACK_PACKAGE % name- if not package_xml:- return {"success": False,- "error": "Need a footprint to host the 3D: pass libraryPath, packageXml, or stepPath.",- "_hint": "A 3D package lives ON a footprint. stepPath alone works: a minimal 2-pad carrier footprint is generated."}- wrapped, werr = _lbr_wrap_write(name, package_xml=package_xml)- if werr:- return {"success": False, "error": werr}- library_path = wrapped- if step_path:- res = _merge_dialog_array(_orchestrate_attach_3d_package(- {"filePath": library_path, "modelPath": step_path, "packageName": name}))- # THE ATTACH-FAILED BRANCH OF THE PLACEHOLDER GUARD (page issue #44): when the- # attach we just attempted did not actually land a <package3d> in the library,- # what renders next is Fusion's grey Placeholder - and this verb used to return- # the attach result's success over it. Same offline check as no_3d_package_bound,- # run on the branch where the bridge TRIED and failed rather than was never asked.- _p3d_after = _library_artifact_names(library_path).get("p3d", [])- if not _p3d_after:- _up_err = str(res.get("error") or res.get("attachError") or "attach reported no error but bound nothing")- return {"success": False,- "errorCode": "3d_attach_failed",- "error": "Attaching the STEP failed, so the library still has no 3D "- "package - refusing to show Fusion's Placeholder as if it "- "were the part.",- "attachError": _up_err[:400],- "libraryPath": library_path,- "_hint": ("The upstream failure is in attachError (a 409 here usually "- "means the f3d already exists in the cloud - the bind path "- "reuses it as of 1.9.11x, so retry fusion_bind_library_3d "- "and read ITS message). Nothing was rendered because a "- "Placeholder screenshot photographs exactly like a real "- "chip and must never count as one.")}- if wrapped:- res["wrappedLibrary"] = wrapped- res["view"] = "3d-package"- return res- opened = _orchestrate_open_lbr({"filePath": library_path})- if not opened.get("success"):- return {"success": False, "error": "open_lbr failed: " + str(opened.get("error"))}- # ⚠️ A PLACEHOLDER IS NOT A RESULT (page issue #42). With no <package3d> bound, Fusion- # renders its grey Placeholder slab and this verb used to return success - ab's card- # captured that, counted three artifacts, and reported the library PROVEN while a human- # looking at the screenshot saw a featureless block where their capacitor should be.- # Technically truthful, practically a false pass. Same class as a 237x39 "screenshot":- # a non-result reported as success. The .lbr answers this offline, before we render.- _p3d = _library_artifact_names(library_path).get("p3d", [])- if not _p3d:- return {"success": False,- "error": "That library has no 3D package bound, so the only thing to show is "- "Fusion's grey Placeholder - refusing to report that as a 3D package.",- "errorCode": "no_3d_package_bound",- "libraryPath": library_path,- "_hint": ("Bind a real model first: re-call fusion_show_3d_package with "- "stepPath pointing at the STEP file and the bridge attaches it onto "- "the footprint, then shows it. A Placeholder rendering LOOKS like a "- "successful 3D view in a screenshot, which is why this refuses "- "rather than letting a caller count it as proof.")}- cp = _create_3d_package(library_path, timeout=60)- if not cp.get("success"):- return {"success": False, "error": "Create3DPackage failed: " + str(cp.get("error")),- "_hint": "The library must be Fusion-parseable (adom-lbr style); raw vendor .lbr files fail Fusion's parser."}- out = {"success": True, "view": "3d-package", "name": name, "libraryPath": library_path}- try:- shot = _handle_screenshot_fusion({}, {})- p = shot.get("savedTo") or shot.get("localSafePath")- if shot.get("success") and p:- out["screenshot"] = p- except Exception:- pass- out["_hint"] = ("You are in the Package3DEnvironment. To leave WITHOUT saving, run "- "fusion_execute_text_command with Package3DStop; the bridge answers the FINISH Save dialog itself (never you) "- "(fusion_dismiss_blocking_dialogs). To BIND a real 3D model here, re-call with stepPath.")- return out---# ── fusion_demo tour mode: the six-content-type showcase with structured progress ──--_DEMO_TOURS: dict = {}-_DEMO_TOURS_LOCK = threading.Lock()---def _demo_tour_dir():- import pathlib- d = pathlib.Path(os.path.expanduser("~")) / ".adom" / "fusion-bridge" / "tours"- d.mkdir(parents=True, exist_ok=True)- return d---def _demo_tour_persist(state: dict):- """Snapshot a FINISHED tour to disk (ab's #30 grace-window ask, 2026-08-16): their- 2.5s poller got 'unknown demoId' for the final frame because a bridge_install- respawned this process between polls, wiping the in-memory dict - tours were never- reaped, the PROCESS was. The disk snapshot makes the done frame (with its shots)- pollable across respawns; keep the last 8, best-effort."""- try:- d = _demo_tour_dir()- snap = {k: v for k, v in state.items() if k != "started"}- (d / (str(state.get("demoId")) + ".json")).write_text(json.dumps(snap))- old = sorted(d.glob("tour-*.json"), key=lambda p: p.stat().st_mtime)- for p in old[:-8]:- p.unlink()- except Exception:- pass---def _demo_tour_load(demo_id: str):- try:- p = _demo_tour_dir() / (demo_id + ".json")- if p.is_file():- return json.loads(p.read_text())- except Exception:- pass- return None---def _bmp_downscale_png(path: str, max_w: int = 640):- """Pure-STDLIB BMP -> small PNG (nearest-neighbor). Exists because AD's provisioned- python has NO PIL, so screenshot captures fall back to raw 24MB BMPs - and a 24MB BMP- is 31MB as base64, which no relay frame should ever carry (caught live 2026-08-16:- every tour stepShot silently skipped, and the done gallery would have been ~280MB).- BMP is uncompressed BGR rows and PNG needs only zlib+struct, so no dependency."""- import struct- import zlib- raw = open(path, "rb").read()- if raw[:2] != b"BM":- raise ValueError("not a BMP")- pix_off = struct.unpack_from("<I", raw, 10)[0]- w = struct.unpack_from("<i", raw, 18)[0]- h = struct.unpack_from("<i", raw, 22)[0]- bpp = struct.unpack_from("<H", raw, 28)[0]- comp = struct.unpack_from("<I", raw, 30)[0]- if comp != 0 or bpp not in (24, 32) or w <= 0 or h == 0:- raise ValueError("unsupported BMP (bpp=%s comp=%s)" % (bpp, comp))- bottom_up = h > 0- h = abs(h)- bypp = bpp // 8- stride = ((w * bypp) + 3) & ~3- ow = min(max_w, w)- oh = max(1, int(h * ow / w))- xs = [int(x * w / ow) * bypp for x in range(ow)]- rows = []- for y in range(oh):- sy = int(y * h / oh)- row_off = pix_off + ((h - 1 - sy) if bottom_up else sy) * stride- line = bytearray(1 + ow * 3) # PNG filter byte 0 + RGB- i = 1- for xo in xs:- o = row_off + xo- line[i] = raw[o + 2] # R (BMP stores BGR)- line[i + 1] = raw[o + 1] # G- line[i + 2] = raw[o] # B- i += 3- rows.append(bytes(line))- def chunk(tag, data):- return (struct.pack(">I", len(data)) + tag + data- + struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF))- ihdr = struct.pack(">IIBBBBB", ow, oh, 8, 2, 0, 0, 0)- idat = zlib.compress(b"".join(rows), 6)- return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", ihdr) + chunk(b"IDAT", idat)- + chunk(b"IEND", b""))---def _shot_b64_small(path: str, max_w: int = 640):- """Base64 evidence frame for ab's stepShot contract (~640px wide). PIL downscale when- available; the stdlib BMP downscaler when not (AD's python ships without PIL); else- the raw file if it is small enough for the relay envelope."""- import base64- try:- try:- from PIL import Image- import io- im = Image.open(path)- if im.width > max_w:- im = im.resize((max_w, max(1, int(im.height * max_w / im.width))))- buf = io.BytesIO()- im.convert("RGB").save(buf, format="PNG")- return base64.b64encode(buf.getvalue()).decode()- except ImportError:- pass- if path.lower().endswith(".bmp"):- return base64.b64encode(_bmp_downscale_png(path, max_w)).decode()- raw = open(path, "rb").read()- if len(raw) <= 1_500_000:- return base64.b64encode(raw).decode()- except Exception:- pass- return None---def _demo_tour_sample_lbr():- """The bundled sample library staged into the wrap dir (self-sufficient demo data)."""- import os- import shutil- import tempfile- src = os.path.join(os.path.dirname(os.path.abspath(__file__)), "resources", "samples", "ADS8588SIPM.fusion.lbr")- if not os.path.isfile(src):- return None- d = os.path.join(tempfile.gettempdir(), "adom-fusion-wrap")- os.makedirs(d, exist_ok=True)- dst = os.path.join(d, "AdomDemoSample.lbr")- shutil.copyfile(src, dst)- return dst.replace("\\", "/")---# Per-step narration (John, 2026-08-24: "show ab captions for each step ... explaining-# what each step is doing ... and all the cool stuff you needed to have in place to make-# that work"). One sentence of WHAT + one of the MACHINERY. Written to be READ ALOUD-# (adom-tts renders these on the dashboard when audio narration is on) and shown as a-# desktop caption over the live demo, so a screenshot appearing always has its why.-_TOUR_EXPLAIN = {- "launch": "Starting Fusion in the background. The bridge launches it detached and minimized, takes the one Autodesk seat if another machine holds it, and never steals your focus - and the AdomBridge add-in loads itself inside Fusion and opens a local command port the bridge drives everything through.",- "stage": "Staging the sample part. This library was built from open KiCad source by adom dash l b r and lands on disk as a real EAGLE library file Fusion can open natively.",- "library": "Opening the electronics library. The bridge drives Fusion's own Electronics Library editor through the add-in - no clicks, no foreground, the same path a human would take.",- "symbol": "The symbol editor: the part's full pinout, converted pin for pin from KiCad source. The add-in switches views with Fusion's own edit commands.",- "footprint": "The footprint editor: real pads and layers, exactly the copper a board would get - never a placeholder.",- "device": "The component view: every symbol pin mapped to its footprint pad. The bridge verifies this mapping offline before Fusion ever opens the file, so dead wiring cannot photograph well.",- "3d-package": "The 3D chip - the showpiece. The bridge imports the part's STEP model onto the footprint in Fusion's 3D package generator, orients it flat automatically, saves it into your Autodesk cloud hub, and binds the resulting cloud model into the library, so every future board carries the real body.",- "datahub": "Your Fusion Data Hub, through the add-in: hubs, projects and folders listed over Fusion's own cloud API - the same storage the 3D chip was just saved into.",- "aps": "APS cloud search: the bridge's own server-indexed search over your Fusion cloud files, about two seconds per query after a single sign-in, and never charged.",- "mcp": "Autodesk's official MCP server, answering over the bridge. Useful - but its toolset is small and read-mostly. The bridge's one hundred plus fusion verbs go far beyond it: electronics editors, text commands, cloud files, evidence captures and a dozen export formats.",- "make-project": "Generating a complete test project around the part: schematic and board authored from EAGLE source and instantiated by Fusion, entirely in the background.",- "open-design": "Opening the cloud electronics design from your hub - the bridge resolves it by name and opens the parent design, never a child view.",- "schematic": "The schematic view of the generated project - the part wired into a real sheet.",- "2d-board": "The 2D board layout - the same footprint you saw in the library, now placed on copper.",- "3d-board": "The 3D board - and the chip body on it is the exact cloud model the bridge minted and bound earlier in this tour.",- "exports": "Exports: STEP and STL written straight from the add-in. The same pipeline covers F 3 D, G L B, 3 M F, IGES, SAT, D X F, gerbers and BOM - every format a downstream tool could ask for.",-}---def _tour_caption(text: str, seconds: int = 45):- """One desktop caption per tour step (id-replace: each step's caption replaces the- last, so the screen always narrates the CURRENT step). Best-effort, never raises."""- try:- # dash-demo fleet ruling: ALWAYS the bridge's DEFAULT caption size and position -- # seven apps, one caption look. If the default is wrong, fix it in ab for everyone.- _ad_call("desktop_caption", {"text": text, "id": "fusion-tour",- "duration": max(15000, min(int(seconds) * 2000, 240000))},- timeout=10)- except Exception:- pass---def _orchestrate_demo_tour(args: dict) -> dict:- """Start the async six-content-type tour; poll fusion_demo_progress {demoId}."""- import time- import uuid- demo_id = "tour-" + uuid.uuid4().hex[:8]- include_design = bool(args.get("includeDesign", True))- query = args.get("query") or ""- # Cold-start (issue #30, verified live: a cold box gave a 100s bar ending in- # nothing): when Fusion/add-in is not responding, the tour launches it itself,- # as a REPORTED step so the front-end's bar covers the wait.- cold = True- try:- probe = _proxy_to_addin("get_app_state", {}, timeout=8)- cold = not probe.get("success")- except Exception:- pass- import timings as _tm- def _est(phase, seeded):- """Measured p50 for this phase on THIS machine, else the seeded literal."""- return int(round(_tm.estimate(phase, seeded, "cold" if phase == "launch" else None)[0]))- steps = ([("launch", "Starting Fusion (cold start)", _est("launch", 120))] if cold else []) + [- ("stage", "Staging the sample library", _est("stage", 3)),- ("library", "Opening the electronics library", _est("library", 35)),- ("symbol", "Symbol: full pinout in the symbol editor", _est("symbol", 8)),- ("footprint", "Footprint: pads + layers in the footprint editor", _est("footprint", 8)),- ("device", "Component view: symbol + package mapping", _est("device", 8)),- ("3d-package", "3D chip: the Package3D environment", _est("3d-package", 30)),- # The platform side (John, 2026-08-24: "show off all aspects of your bridge- # including aps and data hub and the add-in and the mcp server chatting and the- # limits of the mcp server that you go way beyond"). Each degrades gracefully- # (signed-out APS, disabled MCP) - the caption then narrates the honest state.- ("datahub", "Data Hub: your cloud projects through the add-in", _est("datahub", 10)),- ("aps", "APS cloud search: ~2s indexed lookups", _est("aps", 8)),- ("mcp", "MCP: Autodesk's own AI server (and its limits)", _est("mcp", 10)),- ]- if include_design:- # Design side is SELF-SUFFICIENT by default (John, 2026-08-15: customers have- # only a component page, never a canonical cloud board): generate + import a- # test project around the sample part. A query switches to the cloud-open path.- steps += ([("open-design", "Opening the cloud electronics design", _est("open-design", 150))] if query- else [("make-project", "Generating + importing a test project around the sample part", _est("make-project", 60))])- steps += [- ("schematic", "Schematic view", _est("schematic", 12)),- ("2d-board", "2D board layout", _est("2d-board", 12)),- ("3d-board", "3D board", _est("3d-board", 20)),- ("exports", "Exporting: STEP + STL (of a dozen formats)", _est("exports", 25)),- ]- est = sum(s[2] for s in steps)- state = {"demoId": demo_id, "percent": 0, "elapsedSec": 0, "estimatedSec": est,- "step": steps[0][0], "stepLabel": steps[0][1], "done": False, "failed": False,- "steps": [s[0] for s in steps], "shots": [], "started": time.time()}- with _DEMO_TOURS_LOCK:- _DEMO_TOURS[demo_id] = state-- def _wait_idle(max_s=180):- """Fusion's main thread keeps grinding long ops after our proxy timeout returns- (Create3DPackage on a cold session ran 120s+ and broke the next step's open,- issue #30 cold-start validation). Gate on app-state responding again."""- deadline = time.time() + max_s- while time.time() < deadline:- try:- if _proxy_to_addin("get_app_state", {}, timeout=8).get("success"):- return True- except Exception:- pass- time.sleep(5)- return False-- def _snap(label):- # {title, image} is the ab web-control harness's gallery item contract (issue #29- # reply, harness live in ab 2.0.39) - label kept as an alias.- try:- r = _handle_screenshot_fusion({}, {})- # server-side helper returns savedTo (localSafePath is the CLI layer's name -- # checking only that dropped EVERY tour shot, caught live 2026-08-15)- p = r.get("savedTo") or r.get("localSafePath")- if r.get("success") and p:- state["shots"].append({"title": label, "label": label, "path": p})- except Exception:- pass-- def _run():- fg = bool(args.get("foreground"))- if not fg:- begin_background_envelope(float(est + 60))- done_est = 0- _docs_before_tour = _open_doc_names() # tour hygiene: close only what WE open- try:- _VIEW_STEPS = {"symbol", "footprint", "device", "schematic", "2d-board", "3d-board", "library"}- _last_view_step = None- _step_i = 0- while _step_i < len(steps):- key, label, dur = steps[_step_i]- _step_i += 1- # ── TRANSPORT (fusion_demo_control): boundary checks, never mid-verb ──- if state.pop("abort", None):- raise RuntimeError("tour exited by the user (transport control)")- while state.get("paused") and not state.get("abort"):- state.update(stepLabel="Paused - press play to continue", stepCaption="Paused.")- time.sleep(1.5)- if state.pop("abort", None):- raise RuntimeError("tour exited by the user (transport control)")- if state.pop("skipNext", None):- state.setdefault("skippedSteps", []).append(key)- done_est += dur- continue- if state.pop("replayPrev", None) and _last_view_step:- _step_i -= 1 # come back to the current step after- key, label, dur = _last_view_step # re-run the last view step once- if key in _VIEW_STEPS:- _last_view_step = (key, label, dur)- if not fg:- # RE-ARM per step: one est-sized envelope EXPIRED mid-tour on real runs- # (485-890s vs ~376s armed) and later steps stole the user's foreground- # while they typed in other threads (John, live during ab's demo,- # 2026-08-16). Nesting is refcounted, so re-arming is free.- begin_background_envelope(float(dur * 3 + 240))- _step_started = time.time()- # NARRATION (John, 2026-08-24): the caption teaches what this step is doing- # and the machinery underneath, on-screen (desktop caption, id-replaced per- # step) and in the progress stream (stepCaption - the dashboard shows it and- # renders it as audio when narration is on).- _explain = _TOUR_EXPLAIN.get(key) or label- if args.get("captions", True):- _tour_caption(_explain, dur)- # LIVE, per-step ETA (issue #32): the tour reports the same contract block- # every other slow verb does, so ONE renderer draws every bar in Adom.- state.update(step=key, stepLabel=label, stepCaption=_explain,- percent=int(100 * done_est / est),- elapsedSec=int(time.time() - state["started"]),- estimatedSec=est,- etaSec=max(0, int(est - (time.time() - state["started"]))),- confidence=_tm.estimate(key, dur)[1],- startKind=("cold" if cold else "warm"),- progress={"phase": key, "stepLabel": label,- "percent": int(100 * done_est / est),- "elapsedSec": int(time.time() - state["started"]),- "estimatedSec": est,- "etaSec": max(0, int(est - (time.time() - state["started"]))),- "confidence": _tm.estimate(key, dur)[1],- "startKind": ("cold" if cold else "warm")})- if key == "stage" and args.get("sampleLbr"):- pass # handled in the stage branch below- if key == "launch":- dispatch_command("start", {})- ready = False- seat_attempts = 0- for _ in range(30):- time.sleep(5)- try:- if _proxy_to_addin("get_app_state", {}, timeout=8).get("success"):- ready = True- break- except Exception:- pass- # ab's #30 fleet finding (2026-08-16): a single-seat account with Fusion- # signed in elsewhere wedges the cold start at 0% behind 'Active Sessions- # Exceeded'. Self-heal (take the seat, John's standing call) and REPORT it- # as its own state; if takeover cannot land (opt-out, or the UIA path is- # unavailable), fail FAST with the decidable classification instead of- # burning the whole 150s budget.- try:- _dismiss_save_prompt_if_bridge_doc()- except Exception:- pass- try:- if find_licensing_dialog():- if _seat_takeover_enabled():- state.update(stepLabel="Taking over the Autodesk seat (another machine holds it)")- r = _resolve_seat_dialog(max_clicks=3)- seat_attempts += 1- if r.get("clicks"):- state["seatTakeover"] = True- state.setdefault("warnings", []).append(- "license_sessions_exceeded: took the seat over in the background "- "(the other machine's Fusion suspended resumably)")- if seat_attempts >= 3 and find_licensing_dialog():- raise RuntimeError(- "license_sessions_exceeded: the Autodesk seat is held on another "- "machine and background takeover did not land. Free it there "- "(fusion_stop on that box) and re-run the tour.")- else:- raise RuntimeError(- "license_sessions_exceeded: seat held on another machine and "- "auto-takeover is opted out (fusion_set_seat_takeover). Resolve the "- "'Active Sessions Exceeded' dialog or re-enable takeover, then re-run.")- except RuntimeError:- raise- except Exception:- pass- if not ready:- # Not ready usually means the SIGN-IN wall on a fresh box. Drive it as far- # as code can (email typed, page advanced), toast the human at the exact- # remaining step, and fail the tour with instructions instead of a shrug.- rd = _handle_fusion_readiness({}, {})- if rd.get("needsSignin"):- state.update(step="signin", stepLabel="Signing in (one-time, driving it for you)")- si = _orchestrate_signin({"auto": True})- wallname = si.get("signinWall") or "credentials"- state.setdefault("warnings", []).append(- "sign-in required: drove to wall %r; user toasted; %s" % (wallname, str(si.get("stage"))))- raise RuntimeError(- "One-time Autodesk sign-in needed on this machine. I drove it as far "- "as possible and toasted the user at the %r step - after they finish, "- "re-run the tour and it completes." % wallname)- raise RuntimeError("Fusion launched but its add-in never became ready (150s)")- elif key == "stage":- custom = (args.get("sampleLbr") or "").replace("\\", "/")- if custom:- # Tour ANY wiki part (John, 2026-08-16: "do the tour using an adom basic- # parts led"): caller stages the part's .lbr (send_files) and passes its- # Windows path; the deviceset is read from the file.- import os as _os- if not _os.path.isfile(custom):- raise RuntimeError("sampleLbr not found on this machine: %s" % custom)- state["sampleLbr"] = custom- m = re.search(r'<deviceset name="([^"]+)"', open(custom, encoding="utf-8", errors="replace").read())- state["deviceset"] = m.group(1) if m else "UNKNOWN"- else:- lbr = _demo_tour_sample_lbr()- if not lbr:- raise RuntimeError("bundled sample library missing from the bridge install")- state["sampleLbr"] = lbr- state["deviceset"] = "ADS8588SIPM"- elif key == "library":- r = _orchestrate_open_lbr({"filePath": state["sampleLbr"]})- if not r.get("success"):- raise RuntimeError("open_lbr: " + str(r.get("error")))- _snap(label)- elif key in ("symbol", "footprint", "device"):- suffix = {"symbol": "sym", "footprint": "pac", "device": "dev"}[key]- r = _show_library_view(state.get("deviceset") or "ADS8588SIPM", suffix)- if not r.get("success"):- raise RuntimeError(str(r.get("error")))- if r.get("screenshot"):- state["shots"].append({"title": label, "label": label, "path": r["screenshot"]})- elif key == "3d-package":- cp = _create_3d_package(state["sampleLbr"], timeout=60)- if cp.get("success"):- time.sleep(3)- _snap(label)- _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)- time.sleep(2)- try:- _dismiss_dialogs_bg()- except Exception:- pass- # Close the Package3D generator doc so it cannot linger as the- # active view and pollute the design-side screenshots (2026-08-15:- # shots 6-9 were four copies of this screen).- _proxy_to_addin("close_document", {"save": False}, timeout=30)- time.sleep(1)- else:- state.setdefault("warnings", []).append("3d-package step skipped: " + str(cp.get("error")))- state.setdefault("failedSteps", []).append(key)- elif key == "make-project":- _wait_idle()- r = _orchestrate_make_test_project({"libraryPath": state["sampleLbr"]})- if r.get("success"):- time.sleep(3)- _snap(label)- else:- state.setdefault("failedSteps", []).append(key)- state.setdefault("warnings", []).append("make-project: " + str(r.get("error"))[:120])- state["designOpenFailed"] = True- elif key == "open-design":- _wait_idle()- dispatch_command("open_cloud_file", {"fileName": query, "wait": False})- deadline = time.time() + 170- design_open = False- while time.time() < deadline:- st = _proxy_to_addin("get_app_state", {}, timeout=10)- if query.lower() in str((st.get("data") or {}).get("activeDocument", "")).lower():- design_open = True- break- time.sleep(5)- if design_open:- time.sleep(3)- _snap(label)- else:- # A screenshot of NOT-the-design is worse than an honest failure:- # record it and let the dependent view steps skip themselves.- state.setdefault("failedSteps", []).append(key)- state.setdefault("warnings", []).append(- "open-design: %r never became the active document within 170s" % query)- state["designOpenFailed"] = True- elif key == "datahub":- _wait_idle() # ralph run 649s: the 3D save was still grinding; busy != broken- r = _proxy_to_addin("list_cloud_projects", {}, timeout=45)- if r.get("success"):- _d = r.get("data") or {}- _projs = _d.get("projects") or _d.get("dataProjects") or []- state.setdefault("highlights", {})["datahub"] = {- "projects": len(_projs),- "names": [str(p.get("name") or p)[:40] for p in _projs[:6]]}- else:- state.setdefault("warnings", []).append(- "datahub: " + str(r.get("error"))[:100])- elif key == "aps":- _q = (state.get("deviceset") or "ADS8588").split("/")[-1][:20]- _t0 = time.time()- r = dispatch_command("aps_search", {"query": _q, "limit": 5})- if r.get("success"):- _hits = r.get("results") or r.get("items") or (r.get("data") or {}).get("results") or []- state.setdefault("highlights", {})["aps"] = {- "query": _q, "hits": len(_hits), "seconds": round(time.time() - _t0, 1)}- else:- state.setdefault("warnings", []).append(- "aps: %s (sign in once with fusion_aps_signin and this step lights up)"- % str(r.get("error"))[:80])- elif key == "mcp":- r = dispatch_command("mcp_tools", {})- if r.get("success"):- _tools = r.get("tools") or (r.get("data") or {}).get("tools") or []- _names = [str(t.get("name") or t)[:32] for t in _tools][:20]- state.setdefault("highlights", {})["mcp"] = {- "toolCount": len(_tools), "tools": _names,- "beyond": ("The bridge's fusion_* surface is ~120 verbs: electronics "- "editors, EAGLE text commands, cloud files, APS search, "- "evidence captures, and 14 export formats - none of which "- "the MCP toolset offers.")}- else:- state.setdefault("warnings", []).append(- "mcp: %s (fusion_mcp_enable turns it on in ~10s)" % str(r.get("error"))[:80])- elif key == "exports":- _wait_idle()- _exp_dir = "C:/tmp/fusion-tour-exports"- _got = []- for _fmt, _verb in (("step", "export_step"), ("stl", "export_stl")):- _p = "%s/%s.%s" % (_exp_dir, (state.get("deviceset") or "tour"), _fmt)- r = dispatch_command(_verb, {"outputPath": _p})- if r.get("success"):- _got.append(_fmt.upper())- else:- state.setdefault("warnings", []).append(- "%s: %s" % (_verb, str(r.get("error"))[:80]))- if _got:- state.setdefault("highlights", {})["exports"] = {- "formats": _got, "dir": _exp_dir,- "also": ["F3D", "GLB", "3MF", "IGES", "SAT", "DXF", "DWG", "OBJ",- "FBX", "SKP", "USDZ", "gerbers", "BOM", "CPL"]}- _snap(label)- elif key in ("schematic", "2d-board", "3d-board"):- if state.get("designOpenFailed"):- state.setdefault("failedSteps", []).append(key)- continue- cmd = {"schematic": "show_schematic", "2d-board": "show_2d_board", "3d-board": "show_3d_board"}[key]- r = dispatch_command(cmd, {})- if r.get("success"):- time.sleep(2)- _snap(label)- else:- state.setdefault("failedSteps", []).append(key)- state.setdefault("warnings", []).append("%s: %s" % (key, str(r.get("error"))[:120]))- # Every finished step is a MEASURED sample - this is what turns the next run's- # estimate from a literal into this machine's own p50 (issue #32).- try:- _tm.record(key, time.time() - _step_started,- "cold" if key == "launch" and cold else None)- except Exception:- pass- done_est += dur- state.update(percent=int(100 * done_est / est),- elapsedSec=int(time.time() - state["started"]))- n_shots = len(state.get("shots") or [])- if args.get("captions", True):- _tour_caption("Tour complete: library, symbol, footprint, 3D chip, Data Hub, "- "APS search, MCP, a generated project in schematic, 2D and 3D, "- "and real exports - all driven in the background by the bridge.", 20)- state.update(done=True, percent=100, step="done", stepLabel="Tour complete",- narrate="Fusion tour complete: %d views captured across the electronics library%s." % (- n_shots, " and the board design" if include_design and query else ""),- elapsedSec=int(time.time() - state["started"]))- except Exception as e:- state.setdefault("failedSteps", []).append(state.get("step"))- state.update(failed=True, done=True, error=str(e)[:300],- narrate="Fusion tour stopped at '%s': %s" % (state.get("stepLabel"), str(e)[:120]),- elapsedSec=int(time.time() - state["started"]))- finally:- # TOUR HYGIENE (John's 9-open-docs screenshot, 2026-08-24: SIX copies of- # ADOM-TEST-ADS8588SIPM left behind): same guarantee the create_library chain- # got in 1.9.182/188 - close every doc the tour opened, plus unmodified- # Untitleds, success or failure. Pre-existing docs are never touched.- try:- state["tourDocsClosed"] = _close_docs_opened_since(_docs_before_tour)- except Exception:- pass- if args.get("captions", True):- try:- _ad_call("desktop_caption", {"action": "hide", "id": "fusion-tour"}, timeout=8)- except Exception:- pass- _demo_tour_persist(state)-- threading.Thread(target=_run, daemon=True, name="fusion-demo-tour").start()- return {"success": True, "demoId": demo_id, "estimatedSec": est,- "steps": [{"step": s[0], "label": s[1], "estimatedSec": s[2],- "caption": _TOUR_EXPLAIN.get(s[0]) or s[1]} for s in steps],- "statusVerb": "fusion_demo_progress",- "_hint": ("Tour started ASYNC (background by default - pass foreground:true only if the "- "user wants to WATCH). Poll fusion_demo_progress {\"demoId\": \"%s\"} about "- "once a second and render percent/stepLabel as a real progress bar. The final "- "poll (done:true) carries the screenshot array.") % demo_id}---def _handle_demo_control(args: dict) -> dict:- """Transport control for a running tour (John, 2026-08-24: "play/pause/next/prev/exit- ... an easy way to stop the demo"). Granularity is BETWEEN steps: a running Fusion verb- cannot be preempted, so pause/skip/exit take effect at the next step boundary (a few- seconds to a minute). actions: pause | resume | next (skip the upcoming step) |- prev (re-run the last completed VIEW step) | exit (abort + close the tour's docs)."""- demo_id = args.get("demoId") or ""- action = (args.get("action") or "").lower()- with _DEMO_TOURS_LOCK:- state = _DEMO_TOURS.get(demo_id)- if state is None and not demo_id and len(_DEMO_TOURS) == 1:- state = list(_DEMO_TOURS.values())[0]- if state is None or state.get("done"):- return {"success": False, "error": "no running tour" + (" with id " + demo_id if demo_id else ""),- "_hint": "Controls only apply while a tour is running."}- if action == "pause":- state["paused"] = True- elif action == "resume":- state["paused"] = False- elif action == "next":- state["skipNext"] = True- state["paused"] = False- elif action == "prev":- state["replayPrev"] = True- state["paused"] = False- elif action == "exit":- state["abort"] = True- state["paused"] = False- else:- return {"success": False, "error": "action must be pause|resume|next|prev|exit"}- return {"success": True, "action": action, "paused": bool(state.get("paused")),- "_hint": "Takes effect at the next step boundary - a running Fusion verb finishes first."}---def _handle_demo_progress(args: dict) -> dict:- demo_id = args.get("demoId") or ""- with _DEMO_TOURS_LOCK:- state = _DEMO_TOURS.get(demo_id)- # Fall back to "the single running tour" ONLY when no id was given at all -- # a WRONG id must refuse (the suite's #unknown negative test enforces this).- if state is None and not demo_id and len(_DEMO_TOURS) == 1:- state = list(_DEMO_TOURS.values())[0]- persisted = False- if state is None and demo_id:- # Respawn-survival (ab #30): finished tours are snapshotted to disk, so the final- # frame stays pollable even after bridge_install replaced this process mid-poll.- state = _demo_tour_load(demo_id)- persisted = state is not None- if state is None:- return {"success": False, "error": "unknown demoId " + demo_id,- "_hint": "Start a tour first: fusion_demo {\"mode\": \"tour\"}. Its response carries the demoId. Finished tours stay pollable (disk snapshot) even across bridge respawns - an unknown id here means it never ran on this box."}- out = {"success": True}- out.update({k: v for k, v in state.items() if k != "started"})- if persisted:- out["fromSnapshot"] = True- # ── stepShot: the DURING evidence (ab's exact contract, #30 2026-08-16): one shot per- # completed step, riding the FIRST progress frame after that step completes, ~640px- # wide base64 - the bridge knows the exact completion moment and surface, the page- # does not. Delivery cursor lives in the tour state; encode failure still advances so- # one unreadable file can never stall the stream. The final shots gallery (below and- # at done:true) is unchanged - that is the AFTER evidence.- if not persisted and args.get("sinceShot") is None:- # stepShot only for callers that do NOT window with sinceShot: its delivery cursor- # is per-TOUR, so two windowing viewers would steal frames from each other, and- # windowing callers already get every shot exactly once (2026-08-24 gallery fix).- _sent = int(state.get("stepShotSent") or 0)- _all = list(state.get("shots") or [])- if _sent < len(_all):- s = _all[_sent]- img = _shot_b64_small(s.get("path") or "")- state["stepShotSent"] = _sent + 1- if img:- out["stepShot"] = {"step": s.get("label"), "title": s.get("title") or s.get("label"),- "image": img}- # ── Progressive per-step EVIDENCE (John, 2026-08-16): shots stream to the caller AS- # each step completes, not only at done:true. Contract for a live evidence gallery:- # poll fusion_demo_progress {demoId, imagesAs:"base64", sinceShot:<count you have>}- # -> shots = ONLY the new ones (each with .image base64), shotCount = total so far.- # sinceShot keeps 1/sec polls cheap: unchanged evidence is never re-encoded/re-sent.- # Shot dicts are COPIED before encoding - mutating state's own dicts embedded base64- # into the shared tour state (memory bloat + every later poll re-shipping it all).- shots = list(out.get("shots") or [])- out["shotCount"] = len(shots)- try:- since = max(0, int(args.get("sinceShot") or 0))- except Exception:- since = 0- if since:- shots = shots[since:]- imgs = args.get("imagesAs") or ("base64" if state.get("done") else "paths")- if imgs in ("base64", "both"):- import base64- encoded = []- for s in shots:- s = dict(s)- try:- raw = open(s["path"], "rb").read()- if len(raw) <= 4 * 1024 * 1024:- s["image"] = base64.b64encode(raw).decode()- else:- # PIL-less bridge python saves raw 24MB BMPs - NEVER ship those inline- # (9 x 31MB base64 = a ~280MB frame). Downscaled evidence instead.- img = _shot_b64_small(s["path"], max_w=1280)- if img:- s["image"] = img- s["imageNote"] = "downscaled to <=1280px (original is a %dMB capture; pull_file the path for full size)" % (len(raw) // (1024 * 1024))- except Exception:- pass- encoded.append(s)- shots = encoded- out["shots"] = shots- if not state.get("done"):- out["_hint"] = ("Tour running (%s). shots[] carries evidence for every COMPLETED step "- "already - render it live; pass sinceShot=%d on the next poll to receive "- "only new shots." % (out.get("stepLabel"), out["shotCount"]))- return out-----# ── Async wrapper for export_optimized_glb (2026-08-14): the verb's STEP-export phase-# alone can outlive ab's 60s relay window (fresh-session Fusion), so the sync form dies-# at the relay even though the work completes. async:true fires the whole pipeline in a-# thread and returns an exportId instantly; fusion_fetch_optimized_glb {exportId} reports-# phase -> jobId -> complete. Same pattern as the demo tour.-_GLB_ASYNC: dict = {}-_GLB_ASYNC_LOCK = threading.Lock()---def _start_glb_export_async(args: dict) -> dict:- import time- import uuid- export_id = "glbx-" + uuid.uuid4().hex[:8]- state = {"exportId": export_id, "phase": "exporting-step", "done": False,- "failed": False, "started": time.time()}- with _GLB_ASYNC_LOCK:- _GLB_ASYNC[export_id] = state-- inner = {k: v for k, v in args.items() if k != "async"}- inner.setdefault("wait", 20)-- def _run():- begin_background_envelope(400.0)- try:- res = _orchestrate_export_optimized_glb(inner)- state["result"] = res- if res.get("glbPath") and res.get("success"):- state.update(phase="complete", done=True)- return- job = res.get("jobId")- if not (res.get("success") and job):- state.update(phase="failed", failed=True, done=True,- error=str(res.get("error"))[:200])- return- state.update(phase="optimizing", jobId=job)- deadline = time.time() + 600- while time.time() < deadline:- f = _orchestrate_fetch_optimized_glb(- {"jobId": job, "outputPath": inner.get("outputPath"), "wait": 20})- state["result"] = f- if f.get("glbPath") and not f.get("pending"):- state.update(phase="complete", done=True)- return- if not f.get("success") and not f.get("pending"):- state.update(phase="failed", failed=True, done=True,- error=str(f.get("error"))[:200])- return- time.sleep(5)- state.update(phase="failed", failed=True, done=True, error="optimizer did not finish within 10 min")- except Exception as e:- state.update(phase="failed", failed=True, done=True, error=str(e)[:200])-- threading.Thread(target=_run, daemon=True, name="glb-export-async").start()- return {"success": True, "pending": True, "exportId": export_id,- "statusVerb": "fusion_fetch_optimized_glb",- "_hint": ("Export running ASYNC (the sync form can outlive the relay window on a cold "- "Fusion session). Poll fusion_fetch_optimized_glb {\"exportId\": \"%s\"} "- "every few seconds: phase walks exporting-step -> optimizing -> complete, and "- "the final poll carries glbPath/sizeBytes.") % export_id}---def _glb_export_async_status(args: dict) -> dict:- import time- export_id = args.get("exportId") or ""- with _GLB_ASYNC_LOCK:- state = _GLB_ASYNC.get(export_id)- if state is None:- return {"success": False, "error": "unknown exportId " + export_id,- "_hint": "Start one with fusion_export_optimized_glb {async: true}; its response carries the exportId."}- out = {"success": not state.get("failed"), "exportId": export_id,- "phase": state.get("phase"), "done": state.get("done"),- "elapsedSec": int(time.time() - state["started"])}- if state.get("jobId"):- out["jobId"] = state["jobId"]- if state.get("error"):- out["error"] = state["error"]- if state.get("done") and isinstance(state.get("result"), dict):- for k in ("glbPath", "sizeBytes", "meshesAfter", "moleculeAnchored", "silkscreenApplied"):- if k in state["result"]:- out[k] = state["result"][k]- return out-----# ── fusion_make_test_project (John, 2026-08-15): the Web Control demo's PROJECT button.-# "None of our other adom user customers will even have a BQ25792" - a customer's session-# has exactly ONE thing: a component page's .lbr. This verb closes the circle from it:-# it GENERATES a minimal test circuit (the part + a generated series resistor on a small-# board) as real EAGLE .sch/.brd server-side, then imports the pair as a NEW electronics-# design. No cloud, no sign-in, no pre-existing content. The schematic/2D/3D buttons then-# work on the result.--def _mtp_extract(lbr_text, deviceset):- """Pull the pieces the generator needs out of the .lbr."""- def block_of(tag, name):- m = re.search(r'<%s name="%s"[\s>].*?</%s>' % (tag, re.escape(name), tag), lbr_text, re.S)- return m.group(0) if m else None- ds = block_of("deviceset", deviceset)- if not ds:- return None, "deviceset %r not found in the library" % deviceset- gate = re.search(r'<gate name="([^"]+)" symbol="([^"]+)"', ds)- prefix_m = re.search(r'<deviceset name="[^"]+"[^>]*prefix="([^"]+)"', ds)- dev = re.search(r'<device name="[^"]*" package="([^"]+)"', ds)- if not (gate and dev):- return None, "deviceset %r has no gate/package device mapping" % deviceset- connects = re.findall(r'<connect gate="[^"]+" pin="([^"]+)" pad="([^"]+)"', ds)- # Strip 3D binding refs from the embedded deviceset: a package3dinstance whose- # packages3d section is not ALSO embedded is a dangling ref, and Fusion then- # renders the schematic EMPTY (caught 2026-08-15: bound-library test projects- # imported blank sheets). The test project needs no 3D binding.- ds = re.sub(r'<package3dinstances>.*?</package3dinstances>', '', ds, flags=re.S)- sym = block_of("symbol", gate.group(2))- pkg = block_of("package", dev.group(1))- if not (sym and pkg and connects):- return None, "library is missing the symbol/package/connects for %r" % deviceset- layers = re.search(r'<layers>.*?</layers>', lbr_text, re.S)- return {"deviceset": ds, "symbol": sym, "package": pkg, "connects": connects,- "symName": gate.group(2), "gateName": gate.group(1), "pkgName": dev.group(1),- "prefix": (prefix_m.group(1) if prefix_m else "U"),- "layers": layers.group(0) if layers else ""}, None---_MTP_R_SYM = ('<symbol name="ADOMR"><wire x1="-2.54" y1="1.016" x2="2.54" y2="1.016" width="0.254" layer="94"/>'- '<wire x1="2.54" y1="1.016" x2="2.54" y2="-1.016" width="0.254" layer="94"/>'- '<wire x1="2.54" y1="-1.016" x2="-2.54" y2="-1.016" width="0.254" layer="94"/>'- '<wire x1="-2.54" y1="-1.016" x2="-2.54" y2="1.016" width="0.254" layer="94"/>'- '<pin name="1" x="-5.08" y="0" visible="off" length="short"/>'- '<pin name="2" x="5.08" y="0" visible="off" length="short" rot="R180"/>'- '<text x="-2.54" y="1.524" size="1.778" layer="95">>NAME</text>'- '<text x="-2.54" y="-3.302" size="1.778" layer="96">>VALUE</text></symbol>')-_MTP_R_PKG = ('<package name="ADOMR0603"><description>Resistor 0603</description>'- '<smd name="1" x="-0.85" y="0" dx="1" dy="1" layer="1"/>'- '<smd name="2" x="0.85" y="0" dx="1" dy="1" layer="1"/>'- '<wire x1="-0.4" y1="0.5" x2="0.4" y2="0.5" width="0.127" layer="21"/>'- '<wire x1="-0.4" y1="-0.5" x2="0.4" y2="-0.5" width="0.127" layer="21"/>'- '<text x="-1" y="0.8" size="0.8" layer="25">>NAME</text></package>')-_MTP_R_DS = ('<deviceset name="ADOMR" prefix="R"><description>generated test resistor</description>'- '<gates><gate name="G$1" symbol="ADOMR" x="0" y="0"/></gates>'- '<devices><device name="" package="ADOMR0603">'- '<connects><connect gate="G$1" pin="1" pad="1"/><connect gate="G$1" pin="2" pad="2"/></connects>'- '<technologies><technology name=""/></technologies></device></devices></deviceset>')-_MTP_SCH_LAYERS = ('<layers>'- '<layer number="91" name="Nets" color="2" fill="1" visible="yes" active="yes"/>'- '<layer number="92" name="Busses" color="1" fill="1" visible="yes" active="yes"/>'- '<layer number="93" name="Pins" color="2" fill="1" visible="no" active="yes"/>'- '<layer number="94" name="Symbols" color="4" fill="1" visible="yes" active="yes"/>'- '<layer number="95" name="Names" color="7" fill="1" visible="yes" active="yes"/>'- '<layer number="96" name="Values" color="7" fill="1" visible="yes" active="yes"/>'- '<layer number="97" name="Info" color="7" fill="1" visible="yes" active="yes"/>'- '<layer number="98" name="Guide" color="6" fill="1" visible="yes" active="yes"/></layers>')-_MTP_HEAD = ('<?xml version="1.0" encoding="utf-8"?>\n<!DOCTYPE eagle SYSTEM "eagle.dtd">\n'- '<eagle version="9.6.2">\n<drawing>\n<settings><setting alwaysvectorfont="no"/></settings>'- '<grid distance="0.1" unitdist="inch" unit="inch" style="lines" multiple="1" display="no" '- 'altdistance="0.01" altunitdist="inch" altunit="inch"/>')---_R3D_CACHE = None # loaded once per bridge process---def _mtp_resistor():- """The REAL series resistor for test projects: the wiki basic part Royalohm- 0603WAF3300T5E (330R 0603), bundled in resources/samples (John, 2026-08-15: a- synthetic 3D-less resistor 'proves' nothing - real parts only). Returns- (extract, lbr_text) or (None, None) when the bundle is missing.- Its 3D urn is hub-scoped, so it is minted ONCE per box (make_3d_package on the- bundled lbr auto-caches it in ~/.adom/fusion-bridge/r3d-urn.json) and reused."""- import os- p = os.path.join(os.path.dirname(os.path.abspath(__file__)),- "resources", "samples", "0603WAF3300T5E.lbr")- if not os.path.isfile(p):- return None, None- txt = open(p, encoding="utf-8", errors="replace").read()- ex, err = _mtp_extract(txt, "0603WAF3300T5E")- return (None, None) if err else (ex, txt)---def _r3d_cached_urn():- global _R3D_CACHE- if _R3D_CACHE is not None:- return _R3D_CACHE- import json as _json- import os- p = os.path.expanduser("~/.adom/fusion-bridge/r3d-urn.json")- try:- _R3D_CACHE = _json.load(open(p)).get("0603WAF3300T5E", "")- except Exception:- _R3D_CACHE = ""- return _R3D_CACHE---def _r3d_cache_write(urn):- import json as _json- import os- d = os.path.expanduser("~/.adom/fusion-bridge")- os.makedirs(d, exist_ok=True)- _json.dump({"0603WAF3300T5E": urn}, open(os.path.join(d, "r3d-urn.json"), "w"))---def _mtp_generate(lib_name, ex, part_name, urn="", rex=None, r_urn="", solo=True):- """Build the .sch/.brd pair. R1 is the REAL bundled resistor part (rex) when- available, falling back to the legacy synthetic ADOMR. Wiring: R1.pad2-side pin ->- the part's first connected pin; GND -> its second; VCC -> R1's first pin."""- pins = [p for p, _ in ex["connects"]]- pads = {p: pad for p, pad in ex["connects"]}- p1, p2 = pins[0], (pins[1] if len(pins) > 1 else pins[0])- lib = ('<library name="%s"><packages>%s</packages>' % (lib_name, ex["package"])- + (('<packages3d><package3d name="' + part_name + '" urn="" wip_urn="' + urn + '" locally_modified="yes" type="model">'- '<packageinstances><packageinstance name="' + ex["pkgName"] + '"/></packageinstances>'- '</package3d></packages3d>') if urn else '')- + '<symbols>%s</symbols><devicesets>%s</devicesets></library>' % (ex["symbol"], ex["deviceset"]))- if rex:- r_lib_name, r_ds, r_gate = "adom-resistor", "0603WAF3300T5E", rex["gateName"]- r_pins = [p for p, _ in rex["connects"]]- r_pin1, r_pin2 = r_pins[0], (r_pins[1] if len(r_pins) > 1 else r_pins[0])- r_pads = {p: pad for p, pad in rex["connects"]}- rlib = ('<library name="adom-resistor"><packages>%s</packages>' % rex["package"]- + (('<packages3d><package3d name="' + r_ds + '" urn="" wip_urn="' + r_urn + '" locally_modified="yes" type="model">'- '<packageinstances><packageinstance name="' + rex["pkgName"] + '"/></packageinstances>'- '</package3d></packages3d>') if r_urn else '')- + '<symbols>%s</symbols><devicesets>%s</devicesets></library>' % (rex["symbol"], rex["deviceset"]))- r_part = '<part name="R1" library="adom-resistor" deviceset="%s" device="" value="330"/>' % r_ds- r_pkg_name = rex["pkgName"]- else:- r_lib_name, r_ds, r_gate = "adomdemo", "ADOMR", "G$1"- r_pin1, r_pin2 = "1", "2"- r_pads = {"1": "1", "2": "2"}- rlib = ('<library name="adomdemo"><packages>%s</packages><symbols>%s</symbols><devicesets>%s</devicesets></library>'- % (_MTP_R_PKG, _MTP_R_SYM, _MTP_R_DS))- r_part = '<part name="R1" library="adomdemo" deviceset="ADOMR" device="" value="330"/>'- r_pkg_name = "ADOMR0603"- g = ex["gateName"]- # Correct EE designator from the deviceset's own prefix (John, 2026-08-16: "why is- # there a D1 and R1 in a capacitor demo?"): C->C1, D->D1, U->U1; the series resistor- # is R1, so a resistor-under-test becomes R2.- pfx = (ex.get("prefix") or "U").strip() or "U"- part_ref = (pfx + "2") if (pfx.upper() == "R" and not solo) else (pfx + "1")- if solo:- # SOLO layout (John, 2026-08-16: "when demoing a single component, you don't need- # extra components... it's just confusing"): JUST the part - one symbol on the- # sheet, one element centered on a small board. No resistor, no nets.- lib_solo = ('<library name="%s"><packages>%s</packages>' % (lib_name, ex["package"])- + (('<packages3d><package3d name="' + part_name + '" urn="" wip_urn="' + urn + '" locally_modified="yes" type="model">'- '<packageinstances><packageinstance name="' + ex["pkgName"] + '"/></packageinstances>'- '</package3d></packages3d>') if urn else '')- + '<symbols>%s</symbols><devicesets>%s</devicesets></library>' % (ex["symbol"], ex["deviceset"]))- sch = (_MTP_HEAD + _MTP_SCH_LAYERS +- '<schematic xreflabel="%F%N/%S.%C%R" xrefpart="/%S.%C%R">'- '<libraries>' + lib_solo + '</libraries>'- '<attributes/><variantdefs/><classes><class number="0" name="default" width="0" drill="0"/></classes>'- '<parts><part name="' + part_ref + '" library="' + lib_name + '" deviceset="' + part_name + '" device=""/></parts>'- '<sheets><sheet><plain>'- '<text x="0" y="25.4" size="2.54" layer="97">' + part_name + ' - Adom component demo</text>'- '</plain><instances>'- '<instance part="' + part_ref + '" gate="' + ex["gateName"] + '" x="25.4" y="12.7"/>'- '</instances><busses/><nets/></sheet></sheets></schematic></drawing></eagle>\n')- brd = (_MTP_HEAD + ex["layers"] +- '<board><plain>'- '<wire x1="0" y1="0" x2="8" y2="0" width="0.2" layer="20"/>'- '<wire x1="8" y1="0" x2="8" y2="6" width="0.2" layer="20"/>'- '<wire x1="8" y1="6" x2="0" y2="6" width="0.2" layer="20"/>'- '<wire x1="0" y1="6" x2="0" y2="0" width="0.2" layer="20"/></plain>'- '<libraries><library name="' + lib_name + '"><packages>' + ex["package"] + '</packages>'- + (('<packages3d><package3d name="' + part_name + '" urn="" wip_urn="' + urn + '" locally_modified="yes" type="model">'- '<packageinstances><packageinstance name="' + ex["pkgName"] + '"/></packageinstances>'- '</package3d></packages3d>') if urn else '')- + '</library></libraries>'- '<attributes/><variantdefs/><classes><class number="0" name="default" width="0" drill="0"/></classes>'- '<designrules name="default"/><autorouter/>'- '<elements><element name="' + part_ref + '" library="' + lib_name + '" package="' + ex["pkgName"] + '" value="" x="4" y="3"'- + ((' package3d_urn="' + urn + '"') if urn else '') + '/></elements>'- '<signals/></board></drawing></eagle>\n')- return sch, brd- sch = (_MTP_HEAD + _MTP_SCH_LAYERS +- '<schematic xreflabel="%F%N/%S.%C%R" xrefpart="/%S.%C%R">'- '<libraries>' + lib + rlib + '</libraries>'- '<attributes/><variantdefs/><classes><class number="0" name="default" width="0" drill="0"/></classes>'- '<parts>'- '<part name="' + part_ref + '" library="' + lib_name + '" deviceset="' + part_name + '" device=""/>'- + r_part +- '</parts><sheets><sheet><plain>'- '<text x="0" y="30.48" size="2.54" layer="97">Adom generated test circuit: ' + part_name + '</text>'- '</plain><instances>'- '<instance part="R1" gate="' + r_gate + '" x="12.7" y="12.7"/>'- '<instance part="' + part_ref + '" gate="' + g + '" x="38.1" y="12.7"/>'- '</instances><busses/><nets>'- '<net name="N1" class="0"><segment>'- '<wire x1="17.78" y1="12.7" x2="25.4" y2="12.7" width="0.1524" layer="91"/>'- '<pinref part="R1" gate="' + r_gate + '" pin="' + r_pin2 + '"/><pinref part="' + part_ref + '" gate="' + g + '" pin="' + p1 + '"/>'- '</segment></net>'- '<net name="VCC" class="0"><segment>'- '<wire x1="7.62" y1="12.7" x2="2.54" y2="12.7" width="0.1524" layer="91"/>'- '<pinref part="R1" gate="' + r_gate + '" pin="' + r_pin1 + '"/></segment></net>'- '<net name="GND" class="0"><segment>'- '<pinref part="' + part_ref + '" gate="' + g + '" pin="' + p2 + '"/></segment></net>'- '</nets></sheet></sheets></schematic></drawing></eagle>\n')- brd = (_MTP_HEAD + ex["layers"] +- '<board><plain>'- '<wire x1="0" y1="0" x2="14" y2="0" width="0.2" layer="20"/>'- '<wire x1="14" y1="0" x2="14" y2="9" width="0.2" layer="20"/>'- '<wire x1="14" y1="9" x2="0" y2="9" width="0.2" layer="20"/>'- '<wire x1="0" y1="9" x2="0" y2="0" width="0.2" layer="20"/></plain>'- '<libraries>' + lib.replace('<symbols>' + ex["symbol"] + '</symbols><devicesets>' + ex["deviceset"] + '</devicesets>', '')- + rlib.replace('<symbols>' + (rex["symbol"] if rex else _MTP_R_SYM) + '</symbols><devicesets>' + (rex["deviceset"] if rex else _MTP_R_DS) + '</devicesets>', '')- + '</libraries>'- '<attributes/><variantdefs/><classes><class number="0" name="default" width="0" drill="0"/></classes>'- '<designrules name="default"/><autorouter/>'- '<elements>'- '<element name="R1" library="' + r_lib_name + '" package="' + r_pkg_name + '" value="330" x="3.5" y="4.5"'- + ((' package3d_urn="' + r_urn + '"') if (rex and r_urn) else '') + '/>'- '<element name="' + part_ref + '" library="' + lib_name + '" package="' + ex["pkgName"] + '" value="" x="9.5" y="4.5"'- + ((' package3d_urn="' + urn + '"') if urn else '') + '/>'- '</elements><signals>'- '<signal name="N1"><contactref element="R1" pad="' + r_pads[r_pin2] + '"/><contactref element="' + part_ref + '" pad="' + pads[p1] + '"/>'- '<wire x1="4.35" y1="4.5" x2="8.5" y2="4.5" width="0.3" layer="1"/></signal>'- '<signal name="VCC"><contactref element="R1" pad="' + r_pads[r_pin1] + '"/></signal>'- '<signal name="GND"><contactref element="' + part_ref + '" pad="' + pads[p2] + '"/></signal>'- '</signals></board></drawing></eagle>\n')- return sch, brd---_TEST_PROJECT: dict = {}-try: # restore the last generated test project so the DESIGN surfaces (project/schematic/- # 2d/3d) survive a bridge respawn - an install used to wipe this and those four verbs- # failed with "query or urn required" until something re-staged (ralph, 2026-08-18).- _tp = _get_bridge_pref("testProject", None)- if isinstance(_tp, dict) and _tp.get("sch") and _tp.get("brd"):- if os.path.isfile(_tp["sch"]) and os.path.isfile(_tp["brd"]):- _TEST_PROJECT = _tp-except Exception:- pass---def _test_project_activate(kind):- """Activate the test project's sch or brd DOCUMENT (newDesignFromLocal re-open =- activate). Returns True when the doc is confirmed active."""- import time as _t- if not _TEST_PROJECT:- return False- path = _TEST_PROJECT.get(kind)- if not path:- return False- _proxy_to_addin("execute_text_command", {"command": "Document.newDesignFromLocal %s" % path}, timeout=60)- for _ in range(6):- _t.sleep(2)- st = _proxy_to_addin("get_app_state", {}, timeout=10)- if _TEST_PROJECT["docBase"] in str((st.get("data") or {}).get("activeDocument", "")).lower():- return True- return False---def _orchestrate_make_test_project(args: dict) -> dict:- """Generate + import a minimal test project around a library part.-- args: libraryPath (required, Windows path .lbr), deviceset (optional: defaults to- the library's first deviceset).- """- import os- import tempfile- lbr_path = (args.get("libraryPath") or "").replace("\\", "/")- if not lbr_path:- return {"success": False, "error": "libraryPath (.lbr, Windows path) required.",- "_hint": "Stage the component page's .lbr onto the box first (send_files)."}- try:- lbr_text = open(lbr_path, encoding="utf-8", errors="replace").read()- except Exception as e:- return {"success": False, "error": "could not read %s: %s" % (lbr_path, e)}- deviceset = args.get("deviceset")- if not deviceset:- m = re.search(r'<deviceset name="([^"]+)"', lbr_text)- if not m:- return {"success": False, "error": "no deviceset found in the library"}- deviceset = m.group(1)- ex, err = _mtp_extract(lbr_text, deviceset)- if err:- return {"success": False, "error": err}- lib_name = re.sub(r"[^A-Za-z0-9_-]", "_", os.path.basename(lbr_path).rsplit(".", 1)[0])- # The source library's 3D binding (if any) rides into the generated BOARD - each- # element gets package3d_urn so the 3D PCB renders the REAL component model, not a- # generic extrusion (John caught every test board showing black boxes, 2026-08-15).- urn_m = re.search(r'wip_urn="([^"]+)"', lbr_text)- solo = not bool(args.get("withResistor"))- rex, _rtxt = (None, None) if solo else _mtp_resistor()- r_urn = _r3d_cached_urn() if rex else ""- sch_text, brd_text = _mtp_generate(lib_name, ex, deviceset,- urn_m.group(1) if urn_m else "", rex=rex, r_urn=r_urn, solo=solo)- d = os.path.join(tempfile.gettempdir(), "adom-fusion-wrap")- os.makedirs(d, exist_ok=True)- base = "ADOM-TEST-" + re.sub(r"[^A-Za-z0-9_-]", "_", deviceset)[:40]- sch_path = os.path.join(d, base + ".sch").replace("\\", "/")- brd_path = os.path.join(d, base + ".brd").replace("\\", "/")- open(sch_path, "w", encoding="utf-8").write(sch_text)- open(brd_path, "w", encoding="utf-8").write(brd_text)- # ⛔ DIALOG-FREE ONLY (John, 2026-08-15, after ImportSCHAndBRDCmd's native Open dialogs- # wedged his Fusion): Document.newDesignFromLocal opens each file with ZERO dialogs and- # ZERO foreground - the same primitive open_lbr/open_board use. The .brd (elements- # placed + embedded libraries) INSTANTIATES the board; the .sch opens as its own- # drawn schematic document. Never route this through new_electronics_from_eagle.- import time as _t- steps = []- for label, fpath in (("board", brd_path), ("schematic", sch_path)):- r = _proxy_to_addin("execute_text_command",- {"command": "Document.newDesignFromLocal %s" % fpath}, timeout=120)- okd = bool(r.get("success"))- if not okd:- expected = fpath.rsplit("/", 1)[-1].rsplit(".", 1)[0].lower()- for _ in range(10):- _t.sleep(3)- st = _proxy_to_addin("get_app_state", {}, timeout=10)- if expected in str((st.get("data") or {}).get("activeDocument", "")).lower():- okd = True- break- steps.append("%s open: %s" % (label, "ok" if okd else "FAILED: " + str(r.get("error"))[:80]))- if not okd:- return {"success": False, "schPath": sch_path, "brdPath": brd_path,- "error": "%s open failed: %s" % (label, str(r.get("error"))[:150]),- "data": {"steps": steps}}- _t.sleep(2)- global _TEST_PROJECT- _TEST_PROJECT = {"sch": sch_path, "brd": brd_path,- "docBase": base.lower()}- # PERSIST: the design surfaces (show_project/schematic/2d/3d) resolve through this handle,- # and a bridge respawn - which happens on every install - used to wipe it, so those four- # surfaces failed with "query or urn required" until the caller re-staged (ralph, 2026-08-18).- # The paths live on the box, so they stay valid across respawns.- try:- _set_bridge_pref("testProject", _TEST_PROJECT)- except Exception:- pass- try:- _note_bridge_doc(base) # generated test docs = bridge docs (sentinel may discard)- except Exception:- pass- out = {"success": True, "schPath": sch_path, "brdPath": brd_path,- "deviceset": deviceset, "steps": steps,- "circuit": ("JUST %s on a small board (solo demo layout)" % deviceset) if solo- else "R1 (330) in series with %s; VCC/GND stubs" % deviceset,- "_hint": ("Test project opened as TWO documents (board instantiated with placed parts, "- "schematic as its drawn sheet) via the dialog-free Document.newDesignFromLocal "- "path. The BOARD document is active: fusion_show_2d_board / fusion_show_3d_board "- "work now; for the schematic view activate its document (it opened last... the "- "SCHEMATIC document is actually active) - board legs may need the board doc "- "re-activated via Document.newDesignFromLocal or open_board. Generated from "- "NOTHING but the component's .lbr - no cloud, no sign-in.")}- return out---def _sweep_untitled_scratch(max_close: int = 24) -> list:- """Close UNMODIFIED 'Untitled' scratch documents (page issue #59, 2026-08-26).-- The library/bind flow left ~5 Untitled docs per import - 16 after three - and no- caller could clear them, because close_all_documents skipped every Untitled. They are- scratch by construction: an UNMODIFIED Untitled has no data file and no edits, so- closing one cannot lose anything. Modified ones are never touched here; those may hold- real unsaved work and need an explicit force. Fusion keeps a single blank doc alive on- its own, which is the 'keep exactly one' the reporter asked for.- """- closed = []- try:- prev_total = None- for _ in range(max_close):- st = _proxy_to_addin("document_info", {}, timeout=8)- docs = (st.get("data") or {}).get("openDocuments") or []- untitled = [d for d in docs- if str(d.get("name") or "").lower().startswith("untitled")- and not (d.get("isModified") or d.get("modified"))]- # ⚠ FUSION RE-CREATES A BLANK DOC whenever the last one closes. The add-in's own- # close_all_documents carries this warning ("do NOT loop on documents.count ...- # turns while count > 0 into an infinite loop") and I reintroduced it here: on a- # workspace holding ONE Untitled this closed it 24 times, each time racing a- # fresh blank, and terminated only because it hit the cap. Leaving the last- # Untitled alone is also the correct end state - Fusion keeps exactly one.- if len(untitled) <= 1 and len(docs) == len(untitled):- break- if not untitled:- break- # NO-PROGRESS GUARD: if the total did not fall after a close, we are fighting the- # re-create rather than draining a backlog. Stop instead of spinning to the cap.- if prev_total is not None and len(docs) >= prev_total:- break- prev_total = len(docs)- victim = untitled[0]- r = _proxy_to_addin("close_document",- {"name": victim.get("name"), "save": False}, timeout=20)- if not r.get("success"):- break # stop on the first refusal rather than spin- closed.append(str(victim.get("name")))- if closed:- print("[Fusion Bridge] swept %d Untitled scratch doc(s): %s"- % (len(closed), ", ".join(closed[:6])))- _bridge_action_ledger("untitled_scratch_swept", {"count": len(closed)})- except Exception:- pass- return closed---def _orchestrate_bind_library_3d(args: dict) -> dict:- """ONE CALL for the correct library (John's bar: the 3D renders in the Content- Manager): mint the part's 3D as a cloud f3d, inject the wip_urn binding into the- .lbr ON DISK, and reopen the library. Needs a signed-in Fusion session (the one- cloud-dependent step in the circle).-- args: {libraryPath: Windows .lbr (required), stepPath: Windows .step (required),- deviceset: optional (defaults to the library's first)}- """- import time as _t- lbr_path = (args.get("libraryPath") or "").replace("\\", "/")- step_path = (args.get("stepPath") or "").replace("\\", "/")- if not (lbr_path and step_path):- return {"success": False, "error": "libraryPath (.lbr) and stepPath (.step) required (Windows paths)."}- try:- txt = open(lbr_path, encoding="utf-8", errors="replace").read()- except Exception as e:- return {"success": False, "error": "cannot read %s: %s" % (lbr_path, e)}- deviceset = args.get("deviceset")- if not deviceset:- m = re.search(r'<deviceset name="([^"]+)"', txt)- if not m:- return {"success": False, "error": "no deviceset in the library"}- deviceset = m.group(1)- pkg_m = re.search(r'<package name="([^"]+)"', txt)- pkg_name = pkg_m.group(1) if pkg_m else deviceset- # IDEMPOTENT (caught live 2026-08-21): re-binding an already-bound library re-uploaded- # the f3d and hit a 409 name collision in the cloud folder - failing a chain that had- # NOTHING left to do. A .lbr whose package3d already carries a urn is bound; verify and- # return success instead of re-minting.- _existing = re.search(r'<package3d[^>]*urn="([^"]+)"', txt)- if _existing and not args.get("force"):- return {"success": True, "alreadyBound": True, "wip_urn": _existing.group(1),- "deviceset": deviceset, "libraryPath": lbr_path,- "_hint": ("This library already carries a package3d urn - nothing to mint. "- "Pass force:true to re-bind (e.g. after replacing the STEP). "- "fusion_check_3d_thumbnail verifies the cloud thumbnail rendered.")}- mint = _apply_failure_dialogs(_orchestrate_make_3d_package(- {"lbrPath": lbr_path, "modelPath": step_path,- "fileName": "ADOM-%s-3D" % re.sub(r"[^A-Za-z0-9_-]", "_", deviceset)[:40]}))- urn = mint.get("wip_urn") or ""- if not urn:- return {"success": False, "error": "mint failed: " + str(mint.get("error"))[:150],- "_hint": str(mint.get("_hint") or "")[:150]}- # WRITE-BACK NOTICE (#52 item 8): the minted binding is written INTO the caller's- # .lbr on purpose (the file on disk is what Fusion opens, and it must open BOUND) -- # but silently mutating an input cost the matrix runner a pristine-restore ritual.- # Keep a one-time .prebind backup beside it and SAY the mutation out loud.- try:- import shutil as _sh- _bak = lbr_path + ".prebind"- if not os.path.exists(_bak):- _sh.copyfile(lbr_path, _bak)- except Exception:- pass- txt = re.sub(r'<packages3d>.*?</packages3d>\n?', '', txt, flags=re.S)- binding = ('<packages3d><package3d name="%s" urn="" wip_urn="%s" locally_modified="yes" type="model">'- '<packageinstances><packageinstance name="%s"/></packageinstances>'- '</package3d></packages3d>' % (deviceset, urn, pkg_name))- txt = txt.replace('</packages>', '</packages>\n' + binding, 1)- txt = re.sub(r'<package3dinstances>.*?</package3dinstances>', '', txt, flags=re.S)- txt = txt.replace('<technologies>',- '<package3dinstances><package3dinstance package3d_urn="%s"/></package3dinstances><technologies>' % urn, 1)- try:- open(lbr_path, "w", encoding="utf-8").write(txt)- except Exception as e:- return {"success": False, "error": "binding write failed: %s" % e, "wip_urn": urn}- _t.sleep(1)- reopened = _orchestrate_open_lbr({"filePath": lbr_path})- _reopen_ok = bool(reopened.get("success"))- # THE BIND ITSELF IS DONE once the urn is minted AND written to the .lbr on disk- # (#49 / #47 failure-mode-3, John 2026-08-23). The re-open here is only a refresh so the- # prove stage can screenshot the 3D - its failure must NEVER (a) present as a bind failure- # with error:null, nor (b) discard a real, minted, PERSISTED urn and abort the chain. Two- # rules from that report: never return error:null on a failure, and a good urn lets the- # chain continue. So: success rides on the urn+write (the actual binding work), and the- # re-open is reported separately with its reason surfaced.- if not _reopen_ok:- _why = str(reopened.get("error") or reopened.get("_hint")- or "library re-open returned success:false with no detail (open_lbr)")[:220]- return {"success": True, "wip_urn": urn, "libraryPath": lbr_path,- "deviceset": deviceset, "bound": True, "reopened": False, "mutatedInput": True,- "prebindBackup": lbr_path + ".prebind",- "reopenError": _why,- "_hint": ("Binding minted (%s) and WRITTEN to the .lbr - the bind is real and "- "persisted. Re-opening the library into Fusion failed (reopenError "- "above); the prove stage opens the library itself, so the chain "- "continues. If prove's 3D frame is missing, the library just needs a "- "clean re-open once Fusion is idle." % urn)}- # #59: the mint spawns scratch Untitled docs. Clear them before returning so a batch- # caller's workspace stays bounded instead of growing ~5 docs per import.- _swept = _sweep_untitled_scratch()- return {"success": True, "wip_urn": urn, "mutatedInput": True,- "untitledScratchClosed": len(_swept),- "prebindBackup": lbr_path + ".prebind",- "libraryPath": lbr_path, "deviceset": deviceset,- "bound": True, "reopened": True,- "_hint": ("NOTE: the input .lbr was REWRITTEN with the minted package3d binding "- "(account-scoped; pristine copy kept at <libraryPath>.prebind). "- "Library re-opened WITH the 3D binding. The Content Manager's 3D preview "- "resolves after Autodesk's thumbnail service catches up (~2-3 min for a "- "fresh mint) - EDIT %s.dev then screenshot to verify the render." % deviceset)}---def _lbr_has_package3d(lbr_path: str):- """Does this .lbr carry a bound package3d? Returns True/False, or None when the file- cannot be read (unknown is NOT False - a caller must be able to tell them apart)."""- try:- txt = open(lbr_path, encoding="utf-8", errors="replace").read()- except Exception:- return None- m = re.search(r'<package3d[^>]*(?:\burn|\bwip_urn)="([^"]+)"', txt)- return bool(m and m.group(1).strip())---def _orchestrate_show_library(args: dict) -> dict:- """Eight-surfaces contract (ab SDK, issue #30): the LIBRARY surface by its smallest- name. libraryPath opens it (open_lbr); name alone handles the already-open case."""- import time as _t- lbr = (args.get("libraryPath") or "").replace("\\", "/")- name = (args.get("name") or "").strip()- if lbr:- opened = _orchestrate_open_lbr({"filePath": lbr})- if not opened.get("success"):- return {"success": False, "error": "open_lbr failed: " + str(opened.get("error"))[:150]}- _t.sleep(2)- elif name:- st = _proxy_to_addin("get_app_state", {}, timeout=10)- active = str((st.get("data") or {}).get("activeDocument", "")).lower()- if name.lower() not in active:- return {"success": False,- "error": "library %r is not the active document and no libraryPath was given" % name,- "_hint": "Pass libraryPath (WINDOWS .lbr path) to open it; name alone only handles the already-open case."}- else:- return {"success": False, "error": "libraryPath or name required."}- # ── FINDING 2 (#58): ASSERT THE LIBRARY IS ACTUALLY THE ACTIVE DOCUMENT ────────────- # One call returned success:true with Fusion sitting on an empty Untitled(1) DESIGN- # tab, no Electronics panels at all, and nothing in the response distinguished it from- # the nine that worked. Worse, the reporter's blank-frame smoke alarm RATED IT BEST:- # 99.5% ink coverage vs 94.5-94.7% for the real ones, because a uniform dark canvas is- # "inked" everywhere. A screenshot cannot be the proof here; the document name can.- want = (name or (lbr.rsplit("/", 1)[-1].rsplit(".", 1)[0] if lbr else "")).strip()- active = ""- try:- st = _proxy_to_addin("get_app_state", {}, timeout=12)- active = str((st.get("data") or {}).get("activeDocument", "") or "")- except Exception:- pass- if want and active and want.lower() not in active.lower():- return {"success": False, "errorCode": "library_not_active",- "error": "opened %r but the ACTIVE document is %r - the view you would "- "screenshot is not the library." % (want, active),- "activeDocument": active, "expected": want,- "_hint": ("Fusion switched or left another tab in front. Re-call "- "fusion_show_library {libraryPath} once; if it repeats, close the "- "stray document (fusion_close_document) and retry.")}-- out = {"success": True, "view": "library", "activeDocument": active or None}-- # ── THE HINT REQUEST (#58): say when there is no 3D to show ────────────────────────- # The reporter called this verb alone, got a grey Placeholder in the 3D pane on 10/10- # parts, and filed it as a bridge bug before finding their own documentation saying- # fusion_bind_library_3d must run first. They are right that the bridge KNOWS this and- # said nothing. threeDBound is null when unknowable (name-only call), never False.- bound = _lbr_has_package3d(lbr) if lbr else None- out["threeDBound"] = bound- if bound is False:- out["_hint"] = ("This library has NO package3d, so Fusion renders a grey Placeholder "- "slab in the 3D pane - that is not a captured 3D artifact. Run "- "fusion_bind_library_3d {libraryPath, stepPath} FIRST, then re-open. "- "(fusion_create_library does the whole chain for you.)")- elif bound is None and lbr:- out["_hint"] = ("Could not read the .lbr to tell whether a package3d is bound; if the "- "3D pane shows a grey Placeholder, run fusion_bind_library_3d first.")-- # ── FINDING 3 (#58): ten calls left ten tabs open ──────────────────────────────────- # Not closed automatically: showing a library is a "leave it on screen" verb, so- # closing it would defeat the call. Reported instead, so a batch caller can see the- # accumulation it is causing rather than discovering it ten tabs later.- try:- _docs = _open_doc_names()- if len(_docs) > 3:- out["openDocuments"] = len(_docs)- out["_hintDocs"] = ("%d documents are open in Fusion. show_library deliberately "- "leaves the library on screen, so a batch of calls accumulates "- "tabs - close them with fusion_close_all_documents between "- "runs." % len(_docs))- except Exception:- pass-- try:- shot = _handle_screenshot_fusion({}, {})- p = shot.get("savedTo") or shot.get("localSafePath")- if shot.get("success") and p:- out["screenshot"] = p- except Exception:- pass- return out---def _orchestrate_show_project(args: dict) -> dict:- """Eight-surfaces contract: bring the PROJECT/design to its top level.- - No args + a generated test project in this session: activates its board doc.- - query: already-active match wins; else fire open_cloud_file + poll.- - urn: open_by_urn passthrough."""- import time as _t- query = (args.get("query") or args.get("fileName") or "").strip()- urn = (args.get("urn") or "").strip()- if not query and not urn:- if _TEST_PROJECT:- ok = _test_project_activate("brd")- out = {"success": bool(ok), "view": "project", "document": _TEST_PROJECT["docBase"],- "source": "generated test project"}- if not ok:- out["error"] = "could not activate the test project's board document"- else:- try:- shot = _handle_screenshot_fusion({}, {})- p = shot.get("savedTo") or shot.get("localSafePath")- if shot.get("success") and p:- out["screenshot"] = p- except Exception:- pass- return out- return {"success": False, "error": "query (cloud design name) or urn required - or run fusion_make_test_project first."}- if urn:- return dispatch_command("open_by_urn", {"urn": urn})- st = _proxy_to_addin("get_app_state", {}, timeout=10)- active = str((st.get("data") or {}).get("activeDocument", "")).lower()- if query.lower() in active:- out = {"success": True, "view": "project", "document": active, "source": "already open"}- else:- dispatch_command("open_cloud_file", {"fileName": query, "wait": False})- deadline = _t.time() + 170- opened = False- while _t.time() < deadline:- st = _proxy_to_addin("get_app_state", {}, timeout=10)- if query.lower() in str((st.get("data") or {}).get("activeDocument", "")).lower():- opened = True- break- _t.sleep(5)- if not opened:- return {"success": False, "error": "%r never became the active document within 170s" % query,- "_hint": "Check the name with fusion_aps_search; cloud opens on cold sessions take minutes."}- out = {"success": True, "view": "project", "document": query, "source": "cloud open"}- try:- shot = _handle_screenshot_fusion({}, {})- p = shot.get("savedTo") or shot.get("localSafePath")- if shot.get("success") and p:- out["screenshot"] = p- except Exception:- pass- return out---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({}, {})- p = r.get("savedTo") or r.get("localSafePath")- if r.get("success") and p:- shots.append({"title": label, "label": label, "path": p})- 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 _assert_active_document(expect: str) -> dict | None:- """Document guard (issue #289): return a `wrong_document` error dict if Fusion's active document- is not `expect`, else None. A Fusion tab-switch silently retargets `app.activeDocument`, so a- mutating verb can hit the wrong file and destroy work. Callers pass `expectDocument` to assert- intent BEFORE any write. Fails OPEN (returns None) when the active doc cannot be read, so the- guard never wedges a legitimate call - it only blocks a CONFIRMED mismatch."""- actual = None- try:- st = _proxy_to_addin("get_app_state", {}, timeout=8)- data = st.get("data") if isinstance(st, dict) else None- if not isinstance(data, dict):- out = st.get("output") if isinstance(st, dict) else None- if isinstance(out, str) and out.strip().startswith("{"):- data = json.loads(out).get("data", {})- actual = (data or {}).get("activeDocument") or (st.get("activeDocument") if isinstance(st, dict) else None)- except Exception:- actual = None- if actual and actual != expect:- return {- "success": False,- "errorCode": "wrong_document",- "expected": expect,- "actual": actual,- "error": "Active document is '%s', expected '%s'." % (actual, expect),- "_hint": ("The active document changed - a Fusion tab-switch retargets the active "- "document. Re-activate the intended document, or re-issue with expectDocument "- "set to the current name (a rename mid-session also changes it)."),- }- return None---# The EIGHT SURFACES show verbs. `capture:true` is honoured on exactly these: the caller-# asked to be SHOWN something, so proof belongs on the same response (page issue #30).-_SHOW_SURFACE_VERBS = {- "show_symbol", "show_footprint", "show_3d_package", "show_library",- "show_project", "show_schematic", "show_2d_board", "show_3d_board",-}--_WINDOW_RAISING = {- # close verbs raise windows too (the Hub upload-close confirm, Save-As) - John caught- # the modal standing TWICE on 2026-08-21 because the post-verb sweep never ran for a- # raw close_document from web control.- "close_document", "close_window",- "open_cloud_file", "open_by_urn", "aps_open", "open_design", "open_lbr",- "open_schematic", "open_board", "import_electronics", "import_step",- "new_electronics_from_eagle", "show_schematic", "show_2d_board", "show_3d_board",- "run_modeling_script", "execute_text_command", "electron_run",- "attach_3d_package", "make_3d_package", "build_library_3d",- "capture_library_views", "generate_package", "export_optimized_glb",- "board_stackup", "save_to_cloud", "start",- "show_symbol", "show_footprint", "show_3d_package", "make_test_project",- "bind_library_3d", "show_library", "show_project",-}----def _capture_surface_shot(verb: str, args: dict) -> dict:- """Settle, then capture the surface - the bridge's half of issue #30's capture:true.-- SETTLE IS OURS, NOT THE PAGE'S. We opened the window, so we know what to wait for:- the add-in main thread going idle (the surface is still drawing while it is busy),- then a short paint pause. A page cannot see either, which is why it ends up polling.- Returns ab's gallery item shape: {title, image}, image being a ~640px base64 frame.- NOTE the encoding is not guaranteed PNG end to end: we encode PNG, but the relay may- transcode to WebP in transit (observed live 2026-08-19, RIFF header on frames we were- labelling PNG). Consumers should sniff the magic bytes rather than assume.- """- import time as _t- label = {- "show_symbol": "Symbol", "show_footprint": "Footprint",- "show_3d_package": "3D package", "show_library": "Library",- "show_project": "Project", "show_schematic": "Schematic",- "show_2d_board": "2D board", "show_3d_board": "3D board",- }.get(verb, verb)- deadline = _t.time() + float(args.get("captureTimeoutSeconds", 120))- while _t.time() < deadline:- try:- st = _proxy_to_addin("get_app_state", {}, timeout=6)- if st.get("success"):- break- except Exception:- pass- _t.sleep(3)- # The canvas paints a beat after the main thread frees up; capturing on the same tick- # produced blank-canvas evidence on ab's regression thread.- _t.sleep(float(args.get("capturePaintDelay", 2.5)))- try:- shot = screenshot_fusion_window()- except Exception as e:- return {"title": label, "image": None, "error": str(e)[:160]}- path = shot.get("savedTo") or shot.get("localSafePath")- if not shot.get("success") or not path:- return {"title": label, "image": None,- "error": shot.get("error") or "capture failed"}- # WIDTH IS THE CALLER'S CALL (adom-bridge#91): a container caller can take the 640px- # frame, but a BROWSER caller is paying for those bytes in the /command response body,- # and 200-400px is plenty for a "here is your part, open on screen" thumbnail.- try:- max_w = int(args.get("captureMaxWidth") or 640)- except Exception:- max_w = 640- max_w = max(120, min(1600, max_w))- img = _shot_b64_small(path, max_w=max_w)- return {"title": label, "image": img, "path": path, "maxWidth": max_w,- # Same bytes, prefixed - a browser can drop this straight into an <img src>.- "thumbnailDataUrl": ("data:image/png;base64," + img) if img else None}----# ── focusEvents: the quietness audit log (SDK canonicalization, page issue #34) ─────────-# Every time this bridge took the user's foreground - or refused to - it lands here, so a-# human (or a reviewer) can audit how quiet we actually were rather than trusting that we-# were. Ring buffer in memory; the point is the recent past, not forever.-_FOCUS_EVENTS = []-_FOCUS_EVENTS_MAX = 50-_FOCUS_LOCK = threading.Lock()---def _note_focus_event(verb: str, outcome: str, reason: str = "") -> None:- """outcome: 'foreground' (we took it, with a reason) | 'refused' (no reason given)."""- try:- import datetime as _dt- with _FOCUS_LOCK:- _FOCUS_EVENTS.append({- "at": _dt.datetime.now().isoformat(timespec="seconds"),- "verb": verb if verb.startswith("fusion_") else "fusion_" + verb,- "outcome": outcome,- "reason": (reason or "")[:200],- })- del _FOCUS_EVENTS[:-_FOCUS_EVENTS_MAX]- except Exception:- pass----def _snapshot_open_docs() -> set:- """Names of documents open in Fusion right now (empty on any failure)."""- try:- r = _proxy_to_addin("document_info", {}, timeout=5)- if not r.get("success"):- return set()- return {str(d.get("name") or "").rstrip("*").strip().lower()- for d in ((r.get("data") or {}).get("openDocuments") or []) if d.get("name")}- except Exception:- return set()---def _register_docs_we_opened(before: set) -> None:- """Any document that APPEARED while the bridge was driving is the bridge's.-- Third attempt at this, because the first two kept leaving Save prompts on John's- screen (2026-08-20, twice). Registering the ARGUMENTS of show verbs was opt-in and- missed every path that names a document some other way - IN-S42ATR slipped straight- through it. Registering the ACTIVE document unconditionally would be worse: it would- claim the user's own open design and let the janitor discard their work.-- The diff is both safe and complete: a document that was not open before our verb and- is open after it exists BECAUSE of us. The user's documents were already open, so they- are never in the delta, and nothing they do concurrently gets adopted.- """- if not before and before != set():- return- try:- for name in (_snapshot_open_docs() - before):- if name and name != "untitled":- _note_bridge_doc(name)- print("[Fusion Bridge] registered %r as a bridge doc (it appeared while we drove)" % name)- except Exception:- pass----_ACTIVITY_MODES = ("flash", "progress", "both", "none")---def _activity_mode() -> str:- """How to signal 'Adom is driving Fusion' on the taskbar. Sticky, user-settable."""- try:- m = str(_get_bridge_pref("activityIndicator", "progress") or "progress").lower()- except Exception:- m = "progress"- return m if m in _ACTIVITY_MODES else "progress"---def _time_and_indicate(command: str, args: dict, run):- """Time EVERY verb, log it, and paint the chosen taskbar activity indicator.-- John's experiment (2026-08-21): a flash says "something is happening" and nothing more.- A progress bar paced by this machine's OWN measured history for THIS verb says how long- it will take, and disappears the moment the verb finishes or fails. Modes: flash |- progress | both | none.-- The estimate comes from timings.verb_estimate; a verb with no history runs the bar- INDETERMINATE rather than inventing a percentage.- """- import time as _t- import timings as _tm- mode = _activity_mode()- slow = command in LONG_RUNNING_COMMANDS or command in _WINDOW_RAISING- if command == "start":- # John, 2026-08-21: show the taskbar bar DURING the launch, paced by how long a- # launch actually takes here. The launch history is richer than the verb log - it is- # measured per startKind, and cold vs warm differ 100x (measured: cold ~45-53s,- # warm ~0.3s). A warm start needs no bar at all.- if _is_fusion_running():- est, conf = 0.0, "measured"- slow = False- else:- est, conf = _tm.estimate("launch", 150, "cold")- else:- est, conf = _tm.verb_estimate(command)- stop = None- if slow and mode in ("progress", "both"):- try:- from handlers.taskbar_overlay import start_activity_progress- stop = start_activity_progress(command, est, conf)- except Exception:- stop = None- if slow and mode in ("flash", "both"):- try:- _ad_call("desktop_taskbar", {"titleContains": "Autodesk Fusion",- "flash": "until_focused"}, timeout=10)- except Exception:- pass- t0 = _t.time()- failed = False- try:- res = run()- failed = isinstance(res, dict) and res.get("success") is False- return res- except Exception:- failed = True- raise- finally:- took = _t.time() - t0- try:- _tm.log_run(command, took, ok=not failed)- except Exception:- pass- if stop is not None or (slow and mode in ("flash", "both")):- try:- from handlers.taskbar_overlay import end_activity_progress- end_activity_progress(command, failed=failed)- if mode in ("flash", "both"):- _ad_call("desktop_taskbar", {"titleContains": "Autodesk Fusion",- "flash": "stop"}, timeout=8)- except Exception:- pass----def _handle_set_activity_indicator(fusion_info: dict, args: dict) -> dict:- """Read or set how the taskbar signals that Adom is driving Fusion."""- if "mode" in args:- mode = str(args.get("mode") or "").lower()- if mode not in _ACTIVITY_MODES:- return {"success": False,- "error": "mode must be one of %s" % ", ".join(_ACTIVITY_MODES),- "errorCode": "bad_activity_mode"}- _set_bridge_pref("activityIndicator", mode)- if mode in ("none", "flash"):- try:- from handlers.taskbar_overlay import _taskbar_progress- _taskbar_progress("none") # clear immediately, never leave a stale bar- except Exception:- pass- if mode in ("none", "progress"):- try:- _ad_call("desktop_taskbar", {"titleContains": "Autodesk Fusion",- "flash": "stop"}, timeout=8)- except Exception:- pass- return {"success": True, "activityIndicator": _activity_mode(),- "modes": list(_ACTIVITY_MODES),- "_hint": ("Sticky (~/.adom/fusion-bridge/prefs.json). progress = a taskbar bar paced "- "by THIS machine's measured time for that verb; flash = the old "- "attention flash; both; none. A verb with no measured history yet runs "- "the bar indeterminate rather than inventing a percentage.")}---def _handle_verb_times(fusion_info: dict, args: dict) -> dict:- """The measured run-time report, aggregated from the append-only verb log."""- import timings as _tm- rep = _tm.verb_report(int(args.get("limit") or 40))- return {"success": True, "verbs": rep, "count": len(rep),- "_hint": ("Aggregated from ~/.adom/fusion-bridge/verb-times.jsonl (every run, with "- "its outcome). p90 is the useful column when hunting a slow surface; "- "`failures` next to a healthy p50 usually means a verb that fails FAST, "- "which is a different bug from one that is slow.")}---COMMAND_HANDLERS["set_activity_indicator"] = _handle_set_activity_indicator-COMMAND_HANDLERS["verb_times"] = _handle_verb_times----def _handle_list_library(fusion_info: dict, args: dict) -> dict:- """What is actually IN a .lbr: symbols, packages, devicesets, package3d.-- ab asked for this (page issue #42) after resorting to regexing our XML from their client.- A caller reading the bridge's own file format to work out what to ask the bridge for is a- smell, and this is the cheap end of fixing it.- """- path = (args.get("libraryPath") or args.get("filePath") or "").replace("\\", "/")- if not path:- return {"success": False, "error": "libraryPath required.",- "errorCode": "library_path_required"}- names = _library_artifact_names(path)- if not names:- return {"success": False, "error": "Could not read or parse that .lbr: %s" % path,- "errorCode": "library_unreadable",- "_hint": "Path must be the WINDOWS path to an existing .lbr on the box."}- return {"success": True,- "libraryPath": path,- "symbols": names.get("sym", []),- "packages": names.get("pac", []),- "devicesets": names.get("dev", []),- "package3d": names.get("p3d", []),- "has3d": bool(names.get("p3d")),- "_hint": ("Use these EXACT names with fusion_show_symbol / show_footprint / "- "show_3d_package - no need to parse the .lbr yourself. has3d:false means "- "show_3d_package would only have a grey Placeholder to render, so pass "- "stepPath to bind a real model first.")}---COMMAND_HANDLERS["list_library"] = _handle_list_library----def _prove_capture(label: str, view: str) -> dict:- """Settle, capture full-screen, and MEASURE the frame. A capture is evidence only if it- is app-sized: a 237x39 title-bar sliver once passed a byte-size check and got reported as- a screenshot, so every evidence frame is decoded and measured here."""- import time as _t- for _ in range(40): # let the editor finish drawing before capturing- try:- if _proxy_to_addin("get_app_state", {}, timeout=6).get("success"):- break- except Exception:- pass- _t.sleep(3)- _t.sleep(2.0) # paint beat: the canvas lands after the thread frees- out = {"step": label, "view": view, "ok": False, "screenshot": None}- # WORKSPACE STAMP on EVERY frame (#52 item 2: gates must assert content, not count- # artifacts). package3d already hard-fails outside Design; the other views get their- # expected-workspace names captured from green runs via this stamp first, THEN their- # own hard gates - a gate that can false-fail is as useless as one that cannot fail.- try:- _wsr = _proxy_to_addin("execute_text_command",- {"command": "DebugCommands.ActiveWorkspace"}, timeout=10)- _wsv = ((_wsr.get("data") or {}).get("output") or (_wsr.get("data") or {}).get("result") or "")- if _wsv:- out["activeWorkspace"] = str(_wsv).strip()[:60]- except Exception:- pass- try:- shot = _handle_screenshot_fusion({}, {})- p = shot.get("savedTo") or shot.get("localSafePath")- if shot.get("success") and p:- out["screenshot"] = p- w, h = shot.get("width"), shot.get("height")- out["width"], out["height"] = w, h- out["ok"] = not (w and h) or (int(w) >= 400 and int(h) >= 300)- # INLINE COPY FOR BROWSER CALLERS (issue #47): the path is unreadable from- # wiki.adom.inc web control - it cannot open C:/tmp. A ~500px data URI per- # frame keeps the whole four-frame set renderable straight from the poll- # response while the full-res file stays on disk for same-box callers.- try:- out["thumbnailDataUrl"] = _frame_data_url(p, max_width=500)- except Exception:- pass- except Exception as e:- out["error"] = str(e)[:400]- return out---def _frame_data_url(path: str, max_width: int = 500) -> str:- """Downscale an on-disk frame to a data: URI (best effort, PIL-free: Fusion boxes have- no PIL, so serve the file bytes as-is when small enough, else skip the inline copy)."""- import base64 as _b64- try:- raw = open(path, "rb").read()- except Exception:- return ""- # The bridge's captures are relay-sized webp (~150-250KB). Cap the inline copy so a- # four-frame poll response stays comfortably under relay body limits.- if len(raw) > 300_000:- return ""- mime = "image/webp" if path.lower().endswith(".webp") else "image/png"- return "data:%s;base64,%s" % (mime, _b64.b64encode(raw).decode())----def _open_doc_names() -> set:- """Names of every document open in Fusion right now (best-effort, empty on failure)."""- try:- st = _proxy_to_addin("document_info", {}, timeout=8)- return {str(d.get("name") or "") for d in ((st.get("data") or {}).get("openDocuments") or [])- if d.get("name")}- except Exception:- return set()---def _close_docs_opened_since(before: set) -> list:- """Close every document that was NOT open when `before` was snapshotted. Used by the- create_library chain on BOTH success and failure. Everything a chain opens is bridge- scratch by construction: Create3DPackage's generator design (an 'Untitled(N)' - one in- bind, another in prove's 3D frame), the mint's saved ADOM-*-3D (already in the cloud),- the .lbr editor (the file on disk is the truth). John spotted five Untitleds piling up- by part 3 of a 20-part matrix (2026-08-23): ~2 leaked per chain, and Fusion slows with- every open doc. Never touches a doc that pre-dates the chain (the user's own work)."""- closed = []- try:- for n in sorted(_open_doc_names() - set(before or ())):- _note_bridge_doc(n) # so the save-prompt sentinel owns any prompt it raises- r = _close_bridge_doc_verified(n)- if r.get("closed"):- closed.append(n)- # UNTITLED SWEEP (John's 5-tab screenshot + the []-swept rerun, 2026-08-23): the- # add-in names every unsaved default doc literally "Untitled" - a SET collapses- # them, so the before/after diff sees zero change however many pile up. Close- # UNMODIFIED Untitleds directly, one per pass, until none remain: an unmodified- # Untitled is an empty tab by definition (any real content flips isModified), so- # this can never eat user work. Bounded, and only ever runs at chain end.- for _ in range(12):- st = _proxy_to_addin("document_info", {}, timeout=8)- docs = (st.get("data") or {}).get("openDocuments") or []- u = next((dd for dd in docs- if str(dd.get("name") or "").startswith("Untitled")- and not (dd.get("isModified") or dd.get("modified"))), None)- if not u:- break- _proxy_to_addin("close_document", {"name": u.get("name"), "save": False}, timeout=20)- closed.append(str(u.get("name")))- if closed:- print("[Fusion Bridge] chain hygiene: closed %d scratch doc(s) the chain opened: %s"- % (len(closed), ", ".join(closed)))- _bridge_action_ledger("chain_scratch_docs_closed", {"docs": closed})- except Exception:- pass- return closed---def _close_bridge_doc_verified(name: str, timeout_s: int = 30) -> dict:- """Close a BRIDGE-owned scratch document deterministically - no dialog survives this call.-- John, 2026-08-21, third Save dialog in two days: "save programmatically before a dialog- pops up asking you to save. cuz that will make all your code more deterministic where you- don't have to use a screenshot and analysis of it." The rule this implements: the- operation that dirties a document RESOLVES that document's state before returning. Not a- janitor three minutes later, not the next open tripping over it - inside the same verb.-- Sequence: close_document {save:false} -> verify via document_info that it is GONE. If- Fusion raised its Save-As anyway (a never-saved doc can), answer Don't Save HERE, in the- same call, then re-verify. Deterministic by construction: state checks, no screenshots.- """- import time as _t- n = str(name or "").rstrip("*").strip()- if not n:- return {"closed": False, "why": "no name"}- # ⛔ NEVER CLOSE OVER AN IN-FLIGHT UPLOAD (John caught the dialog live, 2026-08-21:- # "Packages are being uploaded to your Fusion Hub. If you close the document you will- # lose these changes."). Create3DPackage/saveAs uploads continue AFTER the API call- # returns, and a close issued during them raises exactly that modal - blocking the- # main thread AND offering to destroy the upload. app.hasActiveJobs is the- # deterministic signal; wait for it to drain (cap 120s) before touching the close.- _jobs_deadline = _t.time() + 120- while _t.time() < _jobs_deadline:- try:- jr = _proxy_to_addin("run_modeling_script",- {"script": "import adsk.core\n"- "result={'jobs': adsk.core.Application.get().hasActiveJobs}"},- timeout=10)- jd = (jr.get("data") or {}).get("result") or {}- if not jd.get("jobs"):- break- print("[Fusion Bridge] close(%r): Fusion Hub upload in flight - waiting, not "- "closing (a close now would raise the lose-changes modal)" % n)- except Exception:- break # cannot ask -> proceed; the dialog answer below is the backstop- _t.sleep(5)- else:- return {"closed": False, "deferred": True,- "why": "Fusion Hub uploads still active after 120s - close deferred so the "- "upload is not destroyed; retry after the jobs drain"}- try:- _proxy_to_addin("close_document", {"name": n, "save": False}, timeout=20)- except Exception:- pass- deadline = _t.time() + timeout_s- while _t.time() < deadline:- try:- di = _proxy_to_addin("document_info", {}, timeout=6)- docs = [str(d.get("name") or "").rstrip("*").strip().lower()- for d in ((di.get("data") or {}).get("openDocuments") or [])]- if di.get("success") and n.lower() not in docs:- return {"closed": True}- except Exception:- pass- # A never-saved doc can raise Save-As despite save:false - answer it NOW, same call.- try:- got = _dismiss_save_prompt_if_bridge_doc()- if got.get("handled"):- print("[Fusion Bridge] close(%r): answered the Save prompt inside the same "- "call (deterministic close, never left on screen)" % n)- except Exception:- pass- _t.sleep(2)- return {"closed": False, "why": "document still open after %ds" % timeout_s}-----def _check_connects(lbr_path: str) -> dict:- """Referential integrity of the pin->pad mapping - the FIFTH artifact (John, 2026-08-21:- "for your source files, don't you need a pin mapping too?").-- The mapping is embedded by KiCad convention (pin NUMBER == pad name), but the convention- breaks - and when it does, a converter can emit connects referencing pads that do not- exist (verified live: adom-lbr warned connect-pad-missing and still wrote the file, "0- errors"). Dead wiring photographs perfectly in all four evidence frames, so the frames- cannot catch it; only this state check can. Offline, deterministic, no Fusion needed.- """- try:- import xml.etree.ElementTree as _ET- root = _ET.parse(lbr_path).getroot()- except Exception:- return {"ok": False, "why": "library unreadable"}- pins = {p.get("name") for p in root.iter("pin") if p.get("name")}- pads = {p.get("name") for p in root.iter("smd") if p.get("name")}- pads |= {p.get("name") for p in root.iter("pad") if p.get("name")}- connects = [(c.get("pin"), c.get("pad")) for c in root.iter("connect")]- bad_pin = [c for c in connects if c[0] not in pins]- # EAGLE's connect `pad` attribute is a SPACE-SEPARATED LIST (a same-net pad array: QFN- # thermal vias, BGA ground balls - pad="81 81@2 ... 81@11" binds one pin to all of them).- # Treating it as one name false-failed valid libraries Fusion opens fine (#49, adom-lbr- # 2.10 multi-pad connects). Split and check each name.- bad_pad = [c for c in connects- if not (c[1] or "").split()- or any(p not in pads for p in (c[1] or "").split())]- connected_pads = {p for c in connects for p in (c[1] or "").split()}- unconnected_pads = sorted(pads - connected_pads)- ok = bool(connects) and not bad_pin and not bad_pad- return {"ok": ok, "connects": len(connects),- "badPinRefs": bad_pin[:8], "badPadRefs": bad_pad[:8],- "unconnectedPads": unconnected_pads[:12],- "why": None if ok else (- "no connects at all" if not connects else- "connects reference missing %s" % ("pins" if bad_pin else "pads"))}---def _count_3d_bodies() -> dict:- """How many REAL bodies are in the active 3D design - a STATE check, not a screenshot.-- John, 2026-08-21: "giving back a screenshot of the 3d viewer ... is not a success if you- don't have a 3d file in the viewer." The .lbr declaring a <package3d> is necessary but- not sufficient: the bound urn can fail to download and the viewer renders an EMPTY- viewport that still photographs as a plausible frame. Fusion itself knows the truth -- ask it. Returns {bodies, bboxMm3} or {-1} when unanswerable.- """- script = (- "import adsk.core, adsk.fusion\n"- "d = app.activeProduct\n"- "n = 0; vol = 0.0\n"- "try:\n"- " root = d.rootComponent\n"- " for occ in [None] + list(root.allOccurrences):\n"- " comp = root if occ is None else occ.component\n"- " for b in comp.bRepBodies:\n"- " n += 1\n"- " bb = b.boundingBox\n"- " dx = (bb.maxPoint.x - bb.minPoint.x) * 10\n"- " dy = (bb.maxPoint.y - bb.minPoint.y) * 10\n"- " dz = (bb.maxPoint.z - bb.minPoint.z) * 10\n"- " vol += abs(dx * dy * dz)\n"- "except Exception:\n"- " n = -1\n"- "result = {'bodies': n, 'bboxMm3': round(vol, 2)}\n"- )- try:- r = _proxy_to_addin("run_modeling_script", {"script": script}, timeout=30)- data = (r.get("data") or {}).get("result") or (r.get("data") or {})- if r.get("success") and isinstance(data, dict) and "bodies" in data:- return {"bodies": int(data.get("bodies", -1)),- "bboxMm3": float(data.get("bboxMm3") or 0)}- except Exception:- pass- return {"bodies": -1, "bboxMm3": 0}---def _handle_prove_library(fusion_info: dict, args: dict) -> dict:- # INCREMENTAL EMISSION (page issue #60, 2026-08-26): the four frames are captured in- # the order a library is actually assembled - symbol, footprint, 3D, then the ganged- # view as the closing proof - but they used to be published only when this function- # RETURNED, so a caller watched nothing for ~370s and then got all four at once. An- # optional _onFrame callback (passed by the create_library chain, never over the relay)- # publishes each artifact the instant it lands, so a poller assembles the bundle- # progressively and sees the ganged view arrive last.- _on_frame = args.get("_onFrame") if callable(args.get("_onFrame")) else None-- def _emit(entry):- if _on_frame:- try:- _on_frame(entry)- except Exception:- pass- return entry-- """PROVE a .lbr with the FOUR evidence images John specified (2026-08-21):-- 1. the symbol, full screen in the Symbol editor- 2. the footprint, full screen in the Footprint editor- 3. the 3D chip, full screen in the 3D editor- 4. the ganged Library Manager view: symbol + footprint + 3D chip + pin connections-- The artifacts are verified from the .lbr FIRST, offline, because the view can lie: Fusion- ticks Mapped even when Package is "Placeholder", and a grey Placeholder slab photographs- exactly like a bound model (page issue #42). So `proven` is decided by what EXISTS, and- the four frames are the evidence attached to that verdict - never the verdict itself.- """- lbr = (args.get("libraryPath") or args.get("filePath") or "").replace("\\", "/") or _last_library()- if not lbr:- return {"success": False, "error": "libraryPath required (or open a library first).",- "errorCode": "library_path_required"}- names = _library_artifact_names(lbr)- if not names:- return {"success": False, "error": "Could not read or parse that .lbr: %s" % lbr,- "errorCode": "library_unreadable"}- devs = names.get("dev", [])- want = (args.get("name") or "").strip() or (devs[0] if devs else "")- artifacts = {- "symbol": {"present": bool(names.get("sym")), "names": names.get("sym", [])[:10]},- "footprint": {"present": bool(names.get("pac")), "names": names.get("pac", [])[:10]},- "package3d": {"present": bool(names.get("p3d")), "names": names.get("p3d", [])[:10]},- "deviceset": {"present": bool(devs), "names": devs[:10]},- }- missing = [k for k in ("symbol", "footprint", "package3d") if not artifacts[k]["present"]]- # THE PIN MAPPING IS AN ARTIFACT TOO. Dead or missing connects render fine in every- # frame - the ganged view just quietly shows no wiring - so the verdict checks the- # mapping's referential integrity, not its photogenicity.- conn = _check_connects(lbr)- artifacts["connects"] = conn- if artifacts["symbol"]["present"] and artifacts["footprint"]["present"] and not conn.get("ok"):- missing.append("connects(%s)" % (conn.get("why") or "invalid"))-- opened = _orchestrate_open_lbr({"filePath": lbr})- if not opened.get("success"):- return {"success": False, "error": "open_lbr failed: " + str(opened.get("error")),- "errorCode": "library_open_failed", "artifacts": artifacts}- _LAST_LIBRARY[0] = lbr-- evidence = []- # 1 + 2: the two editors, each full screen.- for label, suffix, view in (("symbol", "sym", "symbol-editor"),- ("footprint", "pac", "footprint-editor")):- r = _show_library_view(want, suffix, lbr)- if not r.get("success"):- evidence.append(_emit({"step": label, "view": view, "ok": False,- "error": str(r.get("error"))[:160]}))- continue- evidence.append(_emit(_prove_capture(label, view)))- # 3: the 3D editor. Only reachable when a real package3d is bound - with none, Fusion- # shows its Placeholder, which is exactly the false pass this verb exists to prevent.- if artifacts["package3d"]["present"]:- # THE BOUND MODEL, NOT THE GENERATOR (caught by the bodies gate, run be27c38d034e:- # Create3DPackage opens the empty Package3D GENERATOR for an already-bound library -- # bodies=0, a frame of nothing). The evidence is the f3d the package3d urn points- # at: open THAT document, which loads the real geometry the Library Manager shows.- # THE URN LIVES IN wip_urn, NOT urn (run 7d4427d443f5): this bridge's own bind- # writes `<package3d urn="" wip_urn="urn:adsk.wipprod:fs.file:vf...">` - urn stays- # empty by the EAGLE-XML convention used everywhere else in this file. Reading only- # `urn` made a bound library look unopenable ("no urn"). A caller (the create_library- # chain) can also hand the bind result's urn straight in as f3dUrn.- _p3d_urn = ""- try:- import xml.etree.ElementTree as _ET3- for _p3 in _ET3.parse(lbr).getroot().iter("package3d"):- _p3d_urn = (_p3.get("urn") or _p3.get("wip_urn") or "").strip() or _p3d_urn- except Exception:- pass- _p3d_urn = _p3d_urn or (args.get("f3dUrn") or "").strip()- opened3d = None- if _p3d_urn:- # A JUST-MINTED f3d may not be durably openable yet (page issue #47 failure- # mode 3: bind success + thumbnail ready, open refused moments later). The- # lineage commits asynchronously - retry over ~45s before failing the frame.- import time as _t3r- for _attempt in range(3):- opened3d = dispatch_command("open_by_urn", {"urn": _p3d_urn})- if opened3d.get("success"):- break- if _attempt < 2:- print("[Fusion Bridge] bound f3d not openable yet (attempt %d) - "- "waiting 15s for the lineage to commit" % (_attempt + 1))- _t3r.sleep(15)- if opened3d and opened3d.get("success"):- # WORKSPACE GUARANTEE (#51, 2026-08-23): the minted f3d sometimes reopens into- # the electronics PACKAGE workspace showing its 2D SHEET (s1 tPlace, layer- # sidebar) instead of the 3D canvas - 7 of the matrix's 20 frames photographed- # that sheet while prove stamped them 4/4. The active view is STICKY per-user- # state, so never trust it: read DebugCommands.ActiveWorkspace, force the- # Design (modeling) workspace when it is anything else, and normalize the- # camera (goHome+fit - also fixes the two chip-on-edge captures). The frame- # then records the workspace it was actually shot in, and cannot be ok unless- # that workspace shows the 3D canvas.- _ws_after = ""- try:- _wsfix = _proxy_to_addin("run_modeling_script", {"script": (- "import adsk.core, time\n"- "app=adsk.core.Application.get()\n"- "def _ws():\n"- " try: return app.executeTextCommand('DebugCommands.ActiveWorkspace').strip()\n"- " except Exception: return ''\n"- "before=_ws()\n"- "if 'design' not in before.lower():\n"- " try:\n"- " w=app.userInterface.workspaces.itemById('FusionSolidEnvironment')\n"- " if w: w.activate()\n"- " adsk.doEvents(); time.sleep(2)\n"- " except Exception: pass\n"- "try:\n"- " app.activeViewport.goHome(False)\n"- " app.activeViewport.fit()\n"- "except Exception: pass\n"- "result={'before': before, 'after': _ws()}\n")}, timeout=60)- _ws_after = str(((_wsfix.get("data") or {}).get("result") or {}).get("after") or "")- except Exception:- pass- e3 = _prove_capture("package3d", "3d-editor")- e3["activeWorkspace"] = _ws_after- if _ws_after and "design" not in _ws_after.lower():- e3["ok"] = False- e3["error"] = ("package3d frame captured in workspace %r, not the Design 3D "- "canvas - a 2D package sheet is not evidence of a 3D chip "- "(the workspace switch did not take)." % _ws_after)- chk = _count_3d_bodies()- e3["bodies"] = chk["bodies"]- e3["bboxMm3"] = chk["bboxMm3"]- if chk["bodies"] == 0:- e3["ok"] = False- e3["error"] = ("The bound f3d opened but contains NO bodies - an empty "- "viewport is not evidence of a 3D chip.")- elif chk["bodies"] < 0:- e3["bodiesUnverified"] = True- evidence.append(_emit(e3))- # HAND THE APP BACK (deterministic-close rule): the f3d doc is render scratch- # here; close it verified so stage 4 is not stranded in the wrong workspace.- # Same upload gate as _close_bridge_doc_verified: closing over an in-flight- # Hub upload raises the lose-changes modal (John saw it live, 2026-08-21).- try:- _proxy_to_addin("run_modeling_script",- {"script": "import adsk.core, time\n"- "app=adsk.core.Application.get()\n"- "for _ in range(24):\n"- " if not app.hasActiveJobs: break\n"- " time.sleep(5)\n"- "app.activeDocument.close(False)\nresult={}"},- timeout=140)- except Exception:- pass- else:- evidence.append(_emit({"step": "package3d", "view": "3d-editor", "ok": False,- "error": "Could not open the bound f3d (%s): %s" % (- _p3d_urn[-30:] if _p3d_urn else "no urn",- str((opened3d or {}).get("error"))[:100])}))- else:- evidence.append(_emit({"step": "package3d", "view": "3d-editor", "ok": False,- "error": "No package3d bound - the 3D editor would only show Fusion's "- "grey Placeholder, which is not evidence."}))- # 4: the ganged Library Manager view - symbol, footprint, 3D and pin connections at once.- # Stage 3 left the app in a DIFFERENT document/workspace, so re-enter the library first,- # verified - EDIT fails with "Not in Electronics editor" otherwise (run be27c38d034e).- _reopen = _orchestrate_open_lbr({"filePath": lbr})- if _reopen.get("success"):- _ensure_library_active(lbr, timeout_s=45)- r = _show_library_view(want, "dev", lbr)- if r.get("success"):- evidence.append(_emit(_prove_capture("ganged", "library-manager")))- else:- evidence.append({"step": "ganged", "view": "library-manager", "ok": False,- "error": str(r.get("error"))[:160]})-- # THE THUMBNAIL IS PART OF THE VERDICT (John: "you must ensure its there"). A bound urn- # whose cloud thumbnail never rendered shows the user a grey slab in the Library Manager- # while every local check passes. Cloud state, not pixels; waits up to 3 min because- # Fusion renders it asynchronously after a bind.- thumb = None- thumb_unverified = None- if artifacts["package3d"]["present"]:- try:- thumb = _handle_check_3d_thumbnail(fusion_info,- {"libraryPath": lbr, "waitSeconds": 180})- artifacts["package3d"]["thumbnail"] = (thumb.get("thumbnail") or {}).get("state")- if not thumb.get("success"):- if thumb.get("errorCode") == "no_work_folder":- # APS is not signed in on this box, so the cloud thumbnail state CANNOT- # be observed - that is "unverified", not "checked and absent". Autodesk- # renders the thumbnail from Fusion's own upload regardless of our APS- # app, so do not fail the whole verdict over an unobservable check- # (adom/adom-bridge#93); surface it loudly instead.- thumb_unverified = "aps_unavailable"- artifacts["package3d"]["thumbnail"] = "unverified(aps_unavailable)"- else:- missing.append("thumbnail(%s)" % ((thumb.get("thumbnail") or {}).get("state")- or thumb.get("errorCode") or "unknown"))- except Exception as _e:- missing.append("thumbnail(check-failed)")- captured = [e for e in evidence if e.get("ok") and e.get("screenshot")]- # A failed FRAME belongs in `missing` too (run 7d4427d443f5 read "missing=[]" while- # 2 of 4 frames had failed - the reader should never have to diff the evidence list- # to learn what is absent).- # FULL error text (issue #47: the [:60] cut landed exactly where the actionable half- # of every message began - "Fusion offere[d ...]" - and cost the reporter a diagnosis)- missing += ["frame:%s(%s)" % (e.get("step"), str(e.get("error")))- for e in evidence if not (e.get("ok") and e.get("screenshot"))]- proven = (not missing) and len(captured) == 4- out = {"success": proven, "proven": proven, "libraryPath": lbr, "deviceset": want,- "artifacts": artifacts, "missing": missing,- "evidence": evidence, "imagesCaptured": len(captured), "imagesRequired": 4}- if thumb_unverified:- out["thumbnailUnverified"] = thumb_unverified- out["_thumbnailNote"] = ("The cloud thumbnail could not be observed (APS not signed in "- "on this box) - the 4-frame local evidence stands, but verify "- "the Library Manager preview by eye or sign in APS and run "- "fusion_check_3d_thumbnail.")- # RESOLVE OUR OWN DOCUMENT STATE BEFORE RETURNING (deterministic-close rule): the four- # views dirtied the library document; closing it here, verified, means no Save dialog- # can ever be raised at the user for this run's scratch work. The .lbr ON DISK is the- # artifact; the Fusion document was only ever a rendering surface.- try:- _stem = lbr.rsplit("/", 1)[-1].rsplit(".", 1)[0]- _note_bridge_doc(_stem)- out["scratchDocClosed"] = _close_bridge_doc_verified(_stem).get("closed", False)- except Exception:- pass- try:- _PROVE_LAST[0] = dict(out)- import tempfile as _tf- open(os.path.join(_tf.gettempdir(), "fusion-prove-last.json"), "w",- encoding="utf-8").write(json.dumps(out))- except Exception:- pass- if proven:- out["_hint"] = ("PROVEN: all three artifacts exist and all FOUR evidence frames were "- "captured app-sized - symbol editor, footprint editor, 3D editor, and "- "the ganged Library Manager view with pin connections. Attach "- "evidence[].screenshot in that order.")- else:- why = []- if missing:- why.append("missing artifacts: " + ", ".join(missing))- if len(captured) != 4:- why.append("only %d of 4 evidence frames captured" % len(captured))- out["error"] = "NOT proven - " + "; ".join(why)- out["errorCode"] = "library_not_proven"- out["_hint"] = ("Do NOT present these frames as proof. A missing package3d renders as a "- "grey Placeholder that photographs like a real chip, and a frame that "- "failed its size check is not evidence of anything. Bind the 3D with "- "fusion_show_3d_package {stepPath} and re-run.")- return out---COMMAND_HANDLERS["prove_library"] = _handle_prove_library----def _preflight_lbr(path: str) -> dict:- """PRE-ANALYZE a .lbr: will Fusion open it? Every check is offline and named.-- John, 2026-08-21: "what if you just pre-analyze a library to make sure its going to- open ok?" - this is that, as one reusable gate. It encodes every rejection Fusion has- actually produced on this bridge: a truncated binary file (77-byte ads1220irvar, the- blocking Error modal), empty <packages> (Fusion's silent "No Components"), dead- <connects> (renders fine, wires nothing), and absent devicesets (EDIT prompts).- Returns {ok, checks: [{check, ok, detail}], errors: [...]} - callers show the list.- """- import xml.etree.ElementTree as _ET- checks = []- def _c(name, ok, detail=""):- checks.append({"check": name, "ok": bool(ok), "detail": detail})- return ok- p = (path or "").replace("\\", "/")- try:- sz = os.path.getsize(p)- except Exception as e:- _c("file-readable", False, str(e)[:120])- return {"ok": False, "checks": checks,- "errors": [c["check"] + ": " + c["detail"] for c in checks if not c["ok"]]}- _c("file-readable", True, "%d bytes" % sz)- if not _c("size-sane", sz >= 200,- "%d bytes - a real EAGLE library is never this small (truncated transfer?)" % sz):- pass- root = None- if sz >= 200:- try:- root = _ET.parse(p).getroot()- _c("xml-parses", True)- except Exception as e:- _c("xml-parses", False, str(e)[:160])- if root is not None:- _c("root-is-eagle", root.tag == "eagle", "root is <%s>" % root.tag)- pkgs = [x.get("name") for x in root.iter("package")]- syms = [x.get("name") for x in root.iter("symbol")]- devs = [x.get("name") for x in root.iter("deviceset")]- _c("packages-nonempty", bool(pkgs),- "%d package(s)" % len(pkgs) if pkgs else- "EMPTY <packages> - Fusion opens this to a silent 'No Components' library")- _c("symbols-present", bool(syms), "%d symbol(s)" % len(syms))- _c("devicesets-present", bool(devs),- "%d deviceset(s)" % len(devs) if devs else- "no <deviceset> - EDIT <name>.dev will raise Fusion's Create-new prompt")- if pkgs and syms:- conn = _check_connects(p)- _c("connects-resolve", bool(conn.get("ok")),- ("%d connect(s), all resolving" % (conn.get("connects") or 0))- if conn.get("ok") else str(conn.get("why") or "invalid")[:160])- # DUPLICATE PAD NAMES sink the WHOLE library in Fusion's EAGLE importer while- # passing every XML-level check (adom-bridge#93: ads1220irvar shipped SIX pads- # named "17" - thermal pad + stitching vias - and Fusion refused the file with- # its generic 'has errors' modal). Mirror of the wiki's LBR_DUPLICATE_PADS lint.- _dups = []- for pkg_el in root.iter("package"):- seen_pads = {}- for pad_el in list(pkg_el.iter("smd")) + list(pkg_el.iter("pad")):- nm = pad_el.get("name")- if nm:- seen_pads[nm] = seen_pads.get(nm, 0) + 1- _dups += ["%s/%s x%d" % (pkg_el.get("name"), n, c)- for n, c in seen_pads.items() if c > 1]- _c("pads-unique", not _dups,- ("duplicate pad names - Fusion's importer refuses the ENTIRE library over "- "these: %s. Rename to e.g. 17, 17@2, 17@3..." % ", ".join(_dups[:6]))- if _dups else "all pad names unique per package")- ok = all(c["ok"] for c in checks)- return {"ok": ok, "checks": checks,- "errors": [c["check"] + ": " + c["detail"] for c in checks if not c["ok"]]}---def _handle_preflight_lbr(fusion_info: dict, args: dict) -> dict:- """Verb wrapper: will this .lbr open cleanly in Fusion? Offline, instant, no Fusion."""- p = (args.get("libraryPath") or args.get("filePath") or "").replace("\\", "/")- if not p:- return {"success": False, "error": "libraryPath required."}- pf = _preflight_lbr(p)- return {"success": pf["ok"], "preflight": pf, "libraryPath": p,- "errorCode": None if pf["ok"] else "lbr_preflight_failed",- "_hint": ("Every check green - Fusion will open this library cleanly."- if pf["ok"] else- "Fix the named check(s) BEFORE any Fusion verb touches this file - "- "each one maps to a real Fusion failure (blocking Error modal, silent "- "No Components, Create-new prompt, or dead wiring). Re-stage and "- "hash-compare if size-sane or xml-parses failed: transfer corruption "- "is the usual cause.")}---COMMAND_HANDLERS["preflight_lbr"] = _handle_preflight_lbr---def _kicad_ingest_unavailable_error() -> dict:- """The shape-B refusal, shared by import_kicad and the create_library fast-fail.-- Platform rule (John, 2026-08-21): a bridge runs on Windows or Mac; ALL Adom binaries- are Linux-container-only. So KiCad conversion can never happen on this box, and the- contract is authoring-time: convert in a container, publish the validated .lbr on the- part page, hand the bridge shape A (.lbr + .step)."""- return {"success": False, "errorCode": "adom_lbr_unavailable",- "kicadIngest": False,- "error": "adom-lbr (the canonical KiCad->EAGLE converter) does not run on this "- "platform - Adom binaries are Linux-container-only by design - and this "- "bridge deliberately does NOT convert KiCad formats itself.",- "_hint": ("Convert at AUTHORING time, where adom-lbr genuinely runs: in a "- "container, `adom-lbr generate --sym <.kicad_sym> --fp <.kicad_mod> "- "-o <out.lbr>` (auto-linted), publish the .lbr on the part page, then "- "call fusion_create_library shape A {libraryPath, stepPath}. "- "fusion_readiness reports kicadIngest so this is knowable before "- "staging files or launching Fusion.")}---def _find_adom_lbr() -> str:- """Locate adom-lbr on THIS box (the canonical KiCad->EAGLE converter, adom/adom-lbr)."""- import shutil as _sh- hit = _sh.which("adom-lbr") or _sh.which("adom-lbr.exe")- if hit:- return hit- la = os.environ.get("LOCALAPPDATA", "")- for c in ([os.path.join(la, "Programs", "adom-lbr", "adom-lbr.exe"),- os.path.join(la, "adom-lbr", "adom-lbr.exe")] if la else []):- if os.path.exists(c):- return c- return ""----# _install_adom_lbr_windows was DELETED (2026-08-21). The platform rule it violated:-# a bridge runs on Windows or Mac, and ALL Adom binaries are Linux-container-only by-# deliberate decision - there will never be a windows adom-lbr asset for it to fetch-# (adom-lbr#6 withdrawn, adom-lbr#8 retracted by its own filer). Conversion happens at-# AUTHORING time in a container; the published .lbr is the runtime contract (page-# issue #43, adom-bridge#92).---def _apply_pin_map(lbr_path: str, pin_map: dict) -> dict:- """Rewrite <connects> from an EXPLICIT {pinName: padName} map, then re-verify.-- The identity join (pin number == pad name) is convention, and when it breaks no- converter can know the truth - it is datasheet knowledge. The caller supplies it here;- a deterministic XML rewrite applies it; _check_connects validates the result. Refuses- (and changes nothing) if the map references pins or pads that do not exist.- """- try:- text = open(lbr_path, "r", encoding="utf-8", errors="replace").read()- except Exception as e:- return {"ok": False, "why": "unreadable: %s" % str(e)[:80]}- import re as _re- import xml.etree.ElementTree as _ET- try:- root = _ET.fromstring(text)- except Exception:- return {"ok": False, "why": "library is not valid XML"}- pins = {p.get("name") for p in root.iter("pin") if p.get("name")}- pads = {p.get("name") for p in root.iter("smd") if p.get("name")}- pads |= {p.get("name") for p in root.iter("pad") if p.get("name")}- bad = [(k, v) for k, v in pin_map.items() if k not in pins or v not in pads]- if bad:- return {"ok": False, "why": "map references missing pins/pads",- "bad": bad[:8], "pins": sorted(pins)[:20], "pads": sorted(pads)[:20]}- gate = ( _re.findall(r'<gate name="([^"]+)"', text) or ["G$1"])[0]- conns = "\n".join('<connect gate="%s" pin="%s" pad="%s"/>' % (gate, k, v)- for k, v in pin_map.items())- joined = "<connects>\n%s\n</connects>" % conns- if _re.search(r"<connects>.*?</connects>", text, _re.S):- text = _re.sub(r"<connects>.*?</connects>", joined, text, count=1, flags=_re.S)- elif "<connects/>" in text:- text = text.replace("<connects/>", joined, 1)- else:- return {"ok": False, "why": "no <connects> element to rewrite"}- open(lbr_path, "w", encoding="utf-8").write(text)- chk = _check_connects(lbr_path)- return {"ok": bool(chk.get("ok")), "applied": len(pin_map), "verify": chk}---def _handle_import_kicad(fusion_info: dict, args: dict) -> dict:- """Ingest KiCad symbol/footprint files and produce a valid .lbr (issue #43) - by- DELEGATING to adom-lbr, the ecosystem's canonical converter.-- John, 2026-08-21: "just use adom-lbr as your converter" - instead of this bridge- growing its own KiCad parser. adom-lbr owns the conversion, ships a linter whose- checks name the exact failure this issue was filed about (empty-packages /- empty-connects -> Fusion "No Components"), and round-trips KiCad, EAGLE and Altium.- One converter, one set of bugs, one place to fix them.-- args: kicadSymbolPath, kicadFootprintPath (Windows paths), libraryPath (output,- optional), name (optional override).- """- import subprocess as _sp- sym_p = (args.get("kicadSymbolPath") or "").replace("\\", "/")- mod_p = (args.get("kicadFootprintPath") or "").replace("\\", "/")- lbr_p = (args.get("libraryPath") or "").replace("\\", "/")- if not sym_p and not mod_p:- return {"success": False, "errorCode": "kicad_source_required",- "error": "Pass kicadSymbolPath and/or kicadFootprintPath."}- tool = _find_adom_lbr()- if not tool:- return _kicad_ingest_unavailable_error()- if not lbr_p:- base = (sym_p or mod_p).rsplit("/", 1)- stem = base[-1].rsplit(".", 1)[0]- lbr_p = (base[0] + "/" if len(base) > 1 else "") + stem + ".lbr"- cmd = [tool, "generate"]- if sym_p:- cmd += ["--sym", sym_p]- if mod_p:- cmd += ["--fp", mod_p]- if args.get("name"):- cmd += ["--name", str(args["name"])]- cmd += ["-o", lbr_p]- try:- r = _sp.run(cmd, capture_output=True, text=True, timeout=120)- except Exception as e:- return {"success": False, "errorCode": "adom_lbr_failed",- "error": "adom-lbr did not run: %s" % str(e)[:150]}- if r.returncode != 0:- return {"success": False, "errorCode": "adom_lbr_failed",- "error": "adom-lbr generate failed (exit %d): %s"- % (r.returncode, (r.stderr or r.stdout)[-300:]),- "_hint": "adom-lbr auto-lints its output and exits 2 on lint failure - the "- "message above names the exact check that failed."}- names = _library_artifact_names(lbr_p)- pin_map = args.get("pinMap")- pm_result = None- if isinstance(pin_map, dict) and pin_map:- pm_result = _apply_pin_map(lbr_p, {str(k): str(v) for k, v in pin_map.items()})- if not pm_result.get("ok"):- return {"success": False, "errorCode": "pin_map_invalid",- "error": "The explicit pinMap could not be applied: %s" % pm_result.get("why"),- "pinMapDetail": pm_result,- "_hint": "pinMap must be {pinName: padName} using names that exist - the "- "detail lists the symbol's pins and the package's pads."}- return {"success": True, "libraryPath": lbr_p, "converter": "adom-lbr",- "pinMapApplied": (pm_result or {}).get("applied"),- "symbols": names.get("sym", []), "packages": names.get("pac", []),- "devicesets": names.get("dev", []), "has3d": bool(names.get("p3d")),- "toolOutput": (r.stdout or "").strip()[-300:],- "_hint": ("Generated by adom-lbr (lint-clean by construction). fusion_show_symbol / "- "show_footprint consume it directly; bind the 3D with "- "fusion_bind_library_3d {libraryPath, stepPath}, then "- "fusion_prove_library captures the four evidence frames.")}---COMMAND_HANDLERS["import_kicad"] = _handle_import_kicad----_PROVE_LAST = [None]---def _handle_prove_result(fusion_info: dict, args: dict) -> dict:- """Fetch the LAST completed fusion_prove_library run.-- prove_library drives four editor views with settle loops and routinely outlives ab's- relay window - and since it returned its evidence IN the response, a timeout LOST the- result while the work had succeeded (caught live on the CR0603 chain, 2026-08-21). The- run now persists its outcome, and this verb hands it back: fire prove_library, and on a- relay timeout poll THIS until it reports the run you started.- """- # RESULT IDENTITY (#52 item 6, 2026-08-24): with several threads driving one Fusion,- # "the last run globally" is the WRONG run for anyone who passed a runId - the matrix- # runner picked up another thread's TLV9001 chain as its own result. A given runId is- # now authoritative: looked up in the per-run store, and an unknown id is an ERROR,- # never silently the latest.- _rid = str(args.get("runId") or "").strip()- res = None- if _rid:- try:- import tempfile as _tf- _runs = json.loads(open(os.path.join(_tf.gettempdir(), "fusion-prove-runs.json"),- encoding="utf-8").read())- res = _runs.get(_rid)- except Exception:- res = None- if res is None:- _pl = _PROVE_LAST[0]- if _pl and _pl.get("runId") == _rid:- res = _pl- if res is None:- return {"success": False, "errorCode": "unknown_run_id",- "error": "no prove run with runId %r on this box" % _rid,- "_hint": "runId comes from fusion_create_library's response. Omit runId "- "for the latest completed run (single-caller convenience only)."}- if res is None:- res = _PROVE_LAST[0]- if res is None:- try:- import tempfile as _tf- res = json.loads(open(os.path.join(_tf.gettempdir(), "fusion-prove-last.json"),- encoding="utf-8").read())- except Exception:- res = None- if res is None:- return {"success": False, "errorCode": "no_prove_run",- "error": "No fusion_prove_library run has completed on this box yet.",- "_hint": "Run fusion_prove_library {libraryPath}; if the relay times out, the "- "bridge keeps working - poll this verb until the result appears."}- # INLINE FRAMES ARE OPT-IN (issue #47's web-control ask): each evidence entry carries- # thumbnailDataUrl, but four base64 frames on EVERY 15s poll would bloat the relay- # body. Pass {inlineFrames:true} on the FINAL fetch to get them; plain polls return- # paths + metadata only.- if not args.get("inlineFrames"):- try:- res = json.loads(json.dumps(res))- for e in ((res.get("prove") or {}).get("evidence") or []) + (res.get("evidence") or []):- e.pop("thumbnailDataUrl", None)- except Exception:- pass- # LIVE PROGRESS FOR WEB-CONTROL (John, 2026-08-21: "as accurate of progress bars as we- # can ... you should know how long stuff takes by measuring it"). Computed AT READ TIME- # from the persisted stage plan + this machine's measured stage history, so every poll- # gets fresh numbers with no ticker thread: percent = (sum of completed stages'- # estimates + elapsed of the current stage, capped at 95% of its estimate) / total.- # The estimates are the rolling per-machine p50s that update after EVERY run, so the- # bar gets more accurate the more this user uses it - per box, automatically.- try:- if res.get("stage") not in (None, "done", "failed") and res.get("plan"):- import time as _tp- plan = res.get("plan") or []- total = sum(float(p.get("estimatedSec") or 0) for p in plan) or 1.0- done_names = {d.get("stage") for d in (res.get("stagesDone") or [])}- done_est = sum(float(p.get("estimatedSec") or 0) for p in plan- if p.get("stage") in done_names)- cur = next((p for p in plan if p.get("stage") not in done_names), None)- cur_elapsed = max(0.0, _tp.time() - float(res.get("stageStartedAt") or _tp.time()))- cur_est = float((cur or {}).get("estimatedSec") or 1)- cur_credit = min(cur_elapsed, cur_est * 0.95)- pct = int(100 * (done_est + cur_credit) / total)- eta = max(0, int(total - done_est - cur_credit))- confs = [p.get("confidence") for p in plan]- res["progress"] = {- "percent": min(95, pct), "etaSec": eta,- "elapsedSec": int(cur_elapsed), "stepLabel": res.get("stage"),- "estimatedSec": int(total),- "confidence": ("measured" if all(c == "measured" for c in confs)- else "unknown" if all(c == "unknown" for c in confs)- else "typical"),- "overdue": cur_elapsed > cur_est,- }- except Exception:- pass- want = str(args.get("runId") or "")- if want and str(res.get("runId") or "") != want:- # PER-RUN store first (issue #47: concurrent chains used to overwrite the single- # latest slot, so a valid runId could match NOTHING and the caller's poll hung).- try:- import tempfile as _tfr- _runs = json.loads(open(os.path.join(_tfr.gettempdir(),- "fusion-prove-runs.json"),- encoding="utf-8").read())- except Exception:- _runs = {}- if want in _runs:- _r2 = _runs[want]- if not args.get("inlineFrames"):- try:- _r2 = json.loads(json.dumps(_r2))- for e in ((_r2.get("prove") or {}).get("evidence") or []) + (_r2.get("evidence") or []):- e.pop("thumbnailDataUrl", None)- except Exception:- pass- return {"success": True, "result": _r2,- "_hint": "Your run, from the per-run store (concurrent chains no "- "longer overwrite each other)."}- return {"success": False, "errorCode": "run_not_found",- "error": "No stored result for run %s (latest is %s; %d run(s) in the "- "per-run store)." % (want, res.get("runId"), len(_runs)),- "knownRunIds": list(_runs)[-8:],- "_hint": "If your chain JUST started, keep polling - its first state lands "- "within seconds. A runId absent after ~30s means the chain never "- "started (check the create_library response for an immediate "- "refusal like lbr_preflight_failed) - treat that as TERMINAL "- "rather than polling out your budget."}- return {"success": True, "result": res,- "_hint": "The last completed prove run (survives relay timeouts and respawns)."}---COMMAND_HANDLERS["prove_result"] = _handle_prove_result----def _handle_create_library(fusion_info: dict, args: dict) -> dict:- """ONE verb for the whole "create a Fusion library" task - one call, one consent.-- John, 2026-08-21: web-control needs to hand the bridge EITHER a ready .lbr + .step, OR- KiCad sources (+ optional pinMap) - two entry shapes, one contract. And ab measured what- multi-verb flows cost a browser caller: every cross-origin call is a human consent- (#30's nine-prompt incident). So this verb accepts both shapes and runs the whole chain- itself: [convert via adom-lbr] -> [bind the STEP] -> [prove: four frames + verdict].-- Entry shapes:- A: {libraryPath, stepPath?} - .lbr given, bind + prove- B: {kicadSymbolPath?, kicadFootprintPath?, stepPath?,- pinMap?, libraryPath?} - convert, then bind + prove-- The chain outlives ab's relay window by design, so it returns IMMEDIATELY with- statusVerb fusion_prove_result (the #32 pollable pattern): fire once, poll one verb,- both results (progress and final) come from state the bridge persists.- """- import threading as _th- import tempfile as _tf- lbr_p = (args.get("libraryPath") or "").replace("\\", "/")- sym_p = (args.get("kicadSymbolPath") or "").replace("\\", "/")- mod_p = (args.get("kicadFootprintPath") or "").replace("\\", "/")- step_p = (args.get("stepPath") or "").replace("\\", "/")- if not lbr_p and not (sym_p or mod_p):- return {"success": False, "errorCode": "no_input",- "error": "Give me EITHER libraryPath (a ready .lbr) OR kicadSymbolPath / "- "kicadFootprintPath (KiCad sources).",- "_hint": "Shape A: {libraryPath, stepPath}. Shape B: {kicadSymbolPath, "- "kicadFootprintPath, stepPath, pinMap?}. stepPath is required for "- "a PROVEN library - without it there is no real 3D body."}- # SHAPE B FAILS AT THE FRONT DOOR, never three stages in (#43 follow-up, 2026-08-21):- # the old flow staged files, launched Fusion (a minute of the user's time), and THEN- # discovered adom-lbr is absent - which on Windows/Mac it always is, by platform rule.- # Knowable up front, so refused up front; fusion_readiness carries the same flag.- if (sym_p or mod_p) and not _find_adom_lbr():- return _kicad_ingest_unavailable_error()- # SHAPE A PRE-ANALYSIS at the same front door (John, 2026-08-21: "pre-analyze a- # library to make sure its going to open ok"): a bad .lbr must never cost the caller- # a Fusion LAUNCH to discover. Same named checks as fusion_preflight_lbr.- if lbr_p and not (sym_p or mod_p):- # MID-WRITE TOLERANCE (#49 "start-failed / no runId", 2026-08-23): a caller that- # STAGES the .lbr and calls this in the same breath can race the file's final flush -- # preflight's own size-sane / xml-parses checks (built to catch a TRUNCATED file)- # then fire on a file that is merely half-written, the call returns lbr_preflight_failed- # with NO runId, and the very next attempt (file now complete) succeeds. That is a- # transient the code can absorb: re-run preflight a few times with a short settle so a- # still-flushing file gets its beat. A GENUINELY bad .lbr fails every try and still- # returns fast. Only the truncation-class checks are worth retrying; a structural error- # (root-not-eagle, no devicesets) will not fix itself, but re-checking it is cheap.- import time as _pf_t- _pf = _preflight_lbr(lbr_p)- for _pf_try in range(4):- if _pf["ok"]:- break- _pf_t.sleep(0.6)- _pf = _preflight_lbr(lbr_p)- if not _pf["ok"]:- return {"success": False, "errorCode": "lbr_preflight_failed",- "preflight": _pf, "runId": None, "preflightRetries": _pf_try,- "error": "Pre-analysis says Fusion will NOT open this library (re-checked "- "%d times over ~%.1fs in case it was mid-write): "- % (_pf_try + 1, _pf_try * 0.6) +- "; ".join(_pf["errors"])[:360],- "_hint": "This is NOT 'Fusion failed to start' - there is no runId because "- "the .lbr did not pass pre-analysis (errorCode lbr_preflight_failed; "- "see preflight.errors). If your harness stages the file, ensure the "- "write is FLUSHED/closed before calling. fusion_preflight_lbr re-runs "- "these checks in isolation."}-- import uuid as _uuid- run_id = _uuid.uuid4().hex[:12]-- def _chain():- state = {"stage": "starting", "success": None, "libraryPath": lbr_p or None,- "runId": run_id}- # Register with the hang gate: while this chain runs (and is under the 900s- # ceiling) a non-pumping main thread is WORK, not a hang (#49 mid-mint kills).- _LONG_OP["name"] = "create_library:" + run_id- _LONG_OP["since"] = __import__("time").time()- _docs_before = _open_doc_names() # chain hygiene: everything opened after this is ours- def _save():- # PER-RUN persistence (issue #47 follow-up, 2026-08-21): a single latest-run- # slot meant two concurrent chains OVERWROTE each other - the matrix's poll- # hung 18 minutes on a runId whose state my verification chain had clobbered,- # and a no-runId poll returned someone else's run. Every run keeps its own- # entry (capped at 20, insertion order); latest stays for no-runId polls.- try:- _PROVE_LAST[0] = dict(state)- open(os.path.join(_tf.gettempdir(), "fusion-prove-last.json"), "w",- encoding="utf-8").write(json.dumps(state))- _runs_p = os.path.join(_tf.gettempdir(), "fusion-prove-runs.json")- try:- _runs = json.loads(open(_runs_p, encoding="utf-8").read())- except Exception:- _runs = {}- _runs[run_id] = dict(state)- while len(_runs) > 20:- _runs.pop(next(iter(_runs)))- open(_runs_p, "w", encoding="utf-8").write(json.dumps(_runs))- except Exception:- pass- import timings as _tmc- import time as _tc- # Stage estimates are MEASURED per machine and self-improving: each completed stage- # records its actual into the rolling store, so the very next run's bar is paced by- # what THIS box just did. Seeds (used only until real samples exist): launch 60s,- # convert 5s, bind 60s (measured 45s on AdomLapper), prove 300s (four frames +- # thumbnail wait).- #- # DOCTRINE (run 8bb786b174ee, 2026-08-21): the chain once failed with "Fusion 360 is- # installed but not running" - an error the code can fix. Fusion not being up is a- # STAGE of this chain, never a refusal: probe once, and when the add-in is not- # answering, launch Fusion ourselves and wait for the round trip.- _needs_launch = not _addin_round_trip_ready(tries=1)- _STAGES = [("launch", 60.0, _needs_launch),- ("convert", 5.0, bool(sym_p or mod_p)),- ("bind", 60.0, bool(step_p)),- ("prove", 300.0, True)]- state["plan"] = [{"stage": n, "estimatedSec": round(_tmc.estimate("stage:" + n, f)[0] or f, 1),- "confidence": _tmc.estimate("stage:" + n, f)[1]}- for n, f, active in _STAGES if active]- state["stagesDone"] = []- _est_by_stage = {p["stage"]: float(p.get("estimatedSec") or 1.0)- for p in state["plan"]}- _est_total = sum(_est_by_stage.values()) or 1.0- _est_done = [0.0]- def _enter(stage_name):- state["stage"] = stage_name- state["stageStartedAt"] = _tc.time()- _save()- # honest taskbar progress instead of orange attention-flashes (2026-08-23)- _taskbar_progress(100.0 * _est_done[0] / _est_total)- def _leave(stage_key):- took = _tc.time() - float(state.get("stageStartedAt") or _tc.time())- _tmc.log_run("stage:" + stage_key, took, ok=True)- state["stagesDone"] = state.get("stagesDone", []) + [- {"stage": stage_key, "seconds": round(took, 1)}]- _est_done[0] += _est_by_stage.get(stage_key, 0.0)- _taskbar_progress(100.0 * _est_done[0] / _est_total)- try:- if _needs_launch:- _enter("launching Fusion (add-in loads at start)")- dispatch_command("start", {})- _deadline = _tc.time() + 240- while _tc.time() < _deadline and not _addin_round_trip_ready(tries=1):- _tc.sleep(5)- if not _addin_round_trip_ready(tries=1):- state["launch"] = {"success": False,- "error": "Fusion did not become ready within 240s "- "of the chain launching it."}- state.update(stage="failed", success=False); _save(); return- _leave("launch")- target_lbr = lbr_p- if sym_p or mod_p:- _enter("converting (adom-lbr)")- conv = _handle_import_kicad(fusion_info, {- "kicadSymbolPath": sym_p, "kicadFootprintPath": mod_p,- "libraryPath": lbr_p, "pinMap": args.get("pinMap"),- "name": args.get("name")})- state["convert"] = {k: conv.get(k) for k in- ("success", "errorCode", "error", "libraryPath",- "packages", "symbols", "pinMapApplied")}- if not conv.get("success"):- state.update(stage="failed", success=False); _save(); return- _leave("convert")- target_lbr = conv.get("libraryPath")- state["libraryPath"] = target_lbr- if step_p:- _enter("binding 3D (STEP -> f3d -> package3d)")- bind = dispatch_command("bind_library_3d",- {"libraryPath": target_lbr, "stepPath": step_p})- state["bind"] = {k: bind.get(k) for k in- ("success", "error", "wip_urn", "reopened",- "reopenError")}- if not bind.get("success"):- # NEVER surface a failure with error:null (#49, John 2026-08-23). If the- # bind handler somehow returns no reason, synthesize one from whatever it- # did carry so the caller always sees WHY, never a silent rejection next- # to a real urn.- if not state["bind"].get("error"):- _u = state["bind"].get("wip_urn")- state["bind"]["error"] = (- ("bind reported failure but gave no reason; a urn WAS minted (%s) "- "- likely a post-mint step (library re-open / verify) failed "- "silently" % _u) if _u else- "bind failed and returned no reason and no urn")- state.update(stage="failed", success=False); _save(); return- _leave("bind")- _enter("proving (four evidence frames)")- # #60: publish each artifact THE MOMENT it lands, in assembly order- # (symbol -> footprint -> 3d -> ganged), so a poller watches the bundle build- # instead of waiting ~370s for all four. The frame carries its own label, so a- # renderer can show what just arrived and which phase is running.- def _publish_frame(entry):- ev = state.setdefault("evidence", [])- ev.append(entry)- state["evidenceCount"] = len(ev)- state["lastArtifact"] = entry.get("step")- state["stageLabel"] = "captured %s (%d of 4)" % (entry.get("step"), len(ev))- _save()-- prove = _handle_prove_library(fusion_info, {- "libraryPath": target_lbr, "name": args.get("name"),- # the bind just told us exactly which f3d holds the geometry - hand it on so- # the 3D frame never depends on re-parsing the lbr for it- "f3dUrn": ((state.get("bind") or {}).get("wip_urn") or ""),- "_onFrame": _publish_frame})- _leave("prove")- # ⛔ "done" WITH ZERO FRAMES IS A LIE (page issue #65, Colby, 2026-08-27).- # A caller polling the documented contract watched this walk launching ->- # proving -> done with NO evidence frames and NO errorCode, while Fusion's- # "<file> has errors and cannot be opened" dialog sat on the user's screen.- # stage is what a poller branches on, so a chain that captured nothing must- # never reach the terminal stage that means it worked. Same false-pass class- # as #58 and #62: reporting a non-result as success.- _frames = len(state.get("evidence") or [])- _dlg_text = ""- if not _frames:- # An open-error dialog is the usual cause, and its text is the single most- # useful thing we can hand back - so read it rather than guessing.- try:- from handlers.dialog_classify import _dialog_text as _dtxt- for _d in (classify_blocking_dialogs() or []):- _t = (_dtxt(_d.get("hwnd")) or _d.get("title") or "")- if _t:- _dlg_text = str(_t)[:400]- break- except Exception:- pass- if not _frames:- state.update(- stage="failed", success=False, verdict="not_proven",- errorCode=("blocked_by_dialog" if _dlg_text else "no_evidence_captured"),- error=(("Fusion is showing a blocking dialog and the chain captured no "- "evidence: %s" % _dlg_text) if _dlg_text else- "The prove step completed but captured ZERO evidence frames, so "- "there is nothing proving this library imported. Reporting failed "- "rather than done."),- dialogText=(_dlg_text or None),- evidenceCount=0, prove=prove,- _hint=("stage is 'failed', not 'done', because zero of the four evidence "- "frames were captured. %s Re-run after clearing the condition; if "- "the library genuinely imported, this is a bridge defect worth "- "filing." % ("The dialog text above is what Fusion is showing the "- "user right now." if _dlg_text else- "No blocking dialog was detected, so the import "- "likely never produced a document to photograph.")))- else:- state.update(stage="done", success=bool(prove.get("proven")),- verdict=("proven" if prove.get("proven") else "not_proven"),- evidenceCount=_frames, prove=prove)- _save()- except Exception as e:- state.update(stage="failed", success=False, verdict="not_proven",- error=str(e)[:400]); _save()- finally:- if _LONG_OP.get("name") == "create_library:" + run_id:- _LONG_OP["name"] = None- _LONG_OP["since"] = 0.0- _taskbar_progress(None) # drop the bar + kill any lingering orange flash- # A BATCH MUST NOT NEED THE CALLER TO SWEEP BETWEEN PARTS (page issue #47,- # failure mode 5: unsaved library docs accumulated until the add-in stopped- # responding and one part's mess failed every part after it). Close THIS- # chain's library document on success AND failure - prove's own close is one- # path here, this is the guarantee. Idempotent when already closed.- try:- _lbr_done = (state.get("libraryPath") or lbr_p or "")- if _lbr_done:- _stem_done = _lbr_done.replace("\\", "/").rsplit("/", 1)[-1].rsplit(".", 1)[0]- _closed = _close_bridge_doc_verified(_stem_done)- state["libraryDocClosed"] = bool(_closed.get("closed"))- _save()- except Exception:- pass- # CHAIN HYGIENE: close EVERY doc this chain opened (the Create3DPackage- # 'Untitled' generators, the saved 3D, anything a frame left behind), not just- # the library. Pre-existing docs are untouched.- try:- state["scratchDocsClosed"] = _close_docs_opened_since(_docs_before)- _save()- except Exception:- pass-- _th.Thread(target=_chain, daemon=True).start()- return {"success": True, "started": True, "runId": run_id,- "statusVerb": "fusion_prove_result",- "shape": "A (.lbr direct)" if (lbr_p and not (sym_p or mod_p)) else "B (KiCad sources)",- "_hint": ("The chain runs in the background (it outlives the relay window by "- "design). Poll fusion_prove_result every ~15s: `stage` narrates "- "(converting -> binding 3D -> proving), and on stage:'done' the "- "`prove` block carries the four evidence frames and the verdict. "- "ONE call, ONE consent, ONE poll verb.")}---COMMAND_HANDLERS["create_library"] = _handle_create_library-COMMAND_HANDLERS["demo_control"] = lambda info, args: _handle_demo_control(args)---def _handle_action_ledger(args: dict) -> dict:- """READ-ONLY view of the background-action ledger (#52 item 1: 'a caller on a relay or- in a container is blind to it'). Answers "who touched my host app" without filesystem- access. args: {limit (default 50, max 500), sinceSeconds, action (substring filter)}."""- import pathlib as _pl- p = _pl.Path(os.path.expanduser("~")) / ".adom" / "fusion-bridge" / "actions.jsonl"- if not p.exists():- return {"success": True, "entries": [], "count": 0,- "_hint": "Ledger empty: the bridge has taken no background actions on this "- "box since 1.9.177 (or none since the file was last cleared)."}- limit = max(1, min(int(args.get("limit") or 50), 500))- since = args.get("sinceSeconds")- want = str(args.get("action") or "").lower()- import time as _t- cutoff = (_t.time() - float(since)) if since else None- out = []- try:- for line in p.read_text(encoding="utf-8", errors="replace").splitlines():- try:- e = json.loads(line)- except Exception:- continue- if cutoff and float(e.get("epoch") or 0) < cutoff:- continue- if want and want not in str(e.get("action") or "").lower():- continue- out.append(e)- except Exception as e:- return {"success": False, "error": "ledger unreadable: %s" % e}- return {"success": True, "entries": out[-limit:], "count": len(out),- "_hint": ("Every background action the bridge takes on the host app - update "- "clicks and deferrals, seat takeovers, dialog dismissals, doc closes, "- "hang-gate kills, WM_CLOSEs (with window class+size) - appended live, "- "survives respawns. Filter: {action:'wm_close', sinceSeconds:3600}.")}---COMMAND_HANDLERS["action_ledger"] = lambda info, args: _handle_action_ledger(args or {})---def _handle_reset_workspace(args: dict) -> dict:- """fusion_reset_workspace - bring Fusion back to a clean slate between runs (#59).-- Closes named documents AND the Untitled scratch the library/bind flow leaves behind,- which close_all_documents alone could not do. Unmodified scratch always; MODIFIED- Untitled docs only with force:true, because those can hold real unsaved work.- """- force = bool(args.get("force", False))- r = dispatch_command("close_all_documents",- {"save": False, "closeUntitled": True, "force": force})- swept = _sweep_untitled_scratch()- left = []- try:- st = _proxy_to_addin("document_info", {}, timeout=8)- left = [d.get("name") for d in ((st.get("data") or {}).get("openDocuments") or [])]- except Exception:- pass- return {"success": True, "closedByAddin": (r.get("data") or {}).get("closed"),- "skipped": (r.get("data") or {}).get("skipped"),- "untitledScratchClosed": len(swept), "remaining": left,- "_hint": ("Fusion always keeps ONE blank document alive, so a single Untitled "- "remaining is correct, not a failure. A modified Untitled is left "- "untouched unless you pass force:true - it may hold unsaved work.")}---COMMAND_HANDLERS["reset_workspace"] = lambda info, args: _handle_reset_workspace(args or {})----def _handle_check_3d_thumbnail(fusion_info: dict, args: dict) -> dict:- """Is the Library Manager's 3D THUMBNAIL actually rendered? Deterministic, no pixels.-- The thumbnail is rendered by Autodesk's cloud against the bound f3d and exposed as a- relationship on the file's version - a free /data/v1 read. Fusion takes its time after a- bind, so `waitSeconds` polls (default 0 = single check). This is the check John named as- "an area you always screw up": a bound urn with NO rendered thumbnail shows the user a- grey slab in the Library Manager while everything else reports success.-- args: {libraryPath | urn, fileName?, projectId?, folderId?, waitSeconds?}- """- import time as _t- import aps as _aps- urn = (args.get("urn") or "").strip()- lbr = (args.get("libraryPath") or "").replace("\\", "/")- file_name = args.get("fileName") or ""- if not urn and lbr:- try:- import xml.etree.ElementTree as _ET- root = _ET.parse(lbr).getroot()- for p3 in root.iter("package3d"):- # wip_urn, not urn: this bridge writes <package3d urn="" wip_urn="...">- # (the same one-attribute miss that broke prove's 3D frame, issue #46 -- # and unfixed HERE it made tja1051t/type-c report thumbnail not_found- # with the file sitting right there, issue #47 failure mode 4)- urn = (p3.get("urn") or p3.get("wip_urn") or "").strip() or urn- file_name = file_name or (p3.get("name") or "")- except Exception:- pass- if not urn and not file_name:- return {"success": False, "errorCode": "no_3d_reference",- "error": "No urn/fileName given and the .lbr carries no package3d urn.",- "_hint": "Bind first (fusion_bind_library_3d), then check the thumbnail."}- project_id, folder_id = _discover_upload_target(args)- if not folder_id:- return {"success": False, "errorCode": "no_work_folder",- "error": "Cannot resolve the upload folder to look in (APS signed in?)."}- deadline = _t.time() + float(args.get("waitSeconds") or 0)- while True:- st = _aps.thumbnail_state(project_id, folder_id, wip_urn=urn, file_name=file_name)- if st.get("state") == "ready" or _t.time() >= deadline:- break- _t.sleep(10)- ready = st.get("state") == "ready"- return {"success": ready, "thumbnail": st, "urn": urn or None,- "_hint": ("Thumbnail is RENDERED in the cloud - the Library Manager will show the "- "real chip." if ready else- "Not rendered yet (state=%s). Fusion uploads + renders asynchronously "- "after a bind - re-call with waitSeconds:180 to poll, and treat a "- "still-pending thumbnail as NOT proven." % st.get("state"))}---COMMAND_HANDLERS["check_3d_thumbnail"] = _handle_check_3d_thumbnail---def dispatch_command(command: str, args: dict, caller_identity: dict = None) -> dict:- """Thin wrapper: run the verb, then guarantee the windowEtiquette annotation lands on- EVERY backgrounded window-raising result - the inner dispatcher's closure only covered- direct-handler and add-in paths, so orchestrated verbs (show_*, make_test_project, the- tour, ...) returned unannotated (the calling AI could not know no focus was taken).- Idempotent with the inner annotation (the note is only appended once)."""- _fg = bool((args or {}).get("foreground"))- # WHAT WE OPEN IS OURS TO CLEAN UP (see _register_docs_we_opened). Snapshot before,- # diff after, so every document our verbs create is attributable to us - and only ours.- _cmd_short = command[7:] if command.startswith("fusion_") else command- _docs_before = _snapshot_open_docs() if _cmd_short in _WINDOW_RAISING else None- res = _time_and_indicate(_cmd_short, args,- lambda: _dispatch_command_inner(command, args, caller_identity))- if _docs_before is not None:- _register_docs_we_opened(_docs_before)- # ── capture:true - ONE call = ONE consent = surface opened AND proven (issue #30) ──- # ab measured NINE human consent prompts for a single Symbol button under an- # "Allow ONCE" web-control grant: the show verb, then eight state-verb polls while the- # page guessed at when the surface had settled. Page-side settle polling is- # structurally wrong when every cross-origin call costs a consent, and the bridge is- # the only party that actually KNOWS when the surface is ready - it opened it. So the- # bridge owns settle + proof, and returns the frame inline.- try:- _cmd = command[7:] if command.startswith("fusion_") else command- # REGISTER WHAT WE OPEN, OR WE LEAVE OUR MESS ON THE USER'S SCREEN (John, live- # 2026-08-20: a Save prompt for our own staged demo part sat on his desktop). The- # save-prompt sentinel only auto-discards documents it knows the BRIDGE created,- # which is the right instinct - we must never discard the user's work - but the- # show verbs stage a part and never registered it, so the sentinel correctly- # refused to touch a dialog that was 100% our doing. Registering the target of- # every show verb closes that gap without widening what we are willing to discard.- if _cmd in _SHOW_SURFACE_VERBS and isinstance(args, dict):- for _k in ("name", "deviceset", "libraryPath", "fileName", "query"):- if args.get(_k):- _note_bridge_doc(str(args[_k]))- if (_cmd in _SHOW_SURFACE_VERBS and bool((args or {}).get("capture"))- and isinstance(res, dict) and res.get("success") is not False):- res["shot"] = _capture_surface_shot(_cmd, args or {})- except Exception as _e:- try:- res["shot"] = {"title": command, "image": None, "error": str(_e)[:160]}- except Exception:- pass- try:- # FULL-PASS GUARANTEE (John, 2026-08-21: a Save-As sat on screen again and blocked- # every web-control call behind it). Instead of auditing each of ~20 paths and- # missing the 21st, ONE structural rule at the dispatch chokepoint - the same move- # that fixed Create3DPackage: after ANY window-raising verb, if a Save/Save-As- # prompt is standing and attributable to a bridge document, it is answered HERE,- # before the verb returns. A modal we caused must never outlive the call.- _cmd_sw = command[7:] if command.startswith("fusion_") else command- if _cmd_sw in _WINDOW_RAISING:- try:- _sw = _dismiss_save_prompt_if_bridge_doc()- if _sw.get("handled") and isinstance(res, dict):- res["savePromptCleared"] = _sw.get("why")- except Exception:- pass- # Same chokepoint, the other standing modals (John caught each on screen,- # 2026-08-21). All are answered with their work-preserving choice - a raw- # electron_run EDIT from ANY caller can raise the Create prompt, and no- # verb-level pre-check reaches those, so the answer lives HERE:- # upload_in_progress -> No (cancels the close; the upload survives)- # create_confirm -> No (never fabricate an empty artifact)- # lbr open Error -> OK (dismiss; its orphan Untitled design gets- # closed by the doc janitor)- try:- for _d in (classify_blocking_dialogs() or []):- _cat = _d.get("category")- if _cat == "upload_in_progress":- _unwrap(_ad_call("desktop_ui_click",- {"hwnd": _d.get("hwnd"), "name": "No"}, timeout=12))- if isinstance(res, dict):- res["uploadCloseDeclined"] = True- print("[Fusion Bridge] answered the Hub upload-close confirm with "- "No (work-preserving; close retried after jobs drain)")- elif _cat == "create_confirm":- _unwrap(_ad_call("desktop_ui_click",- {"hwnd": _d.get("hwnd"), "name": "No"}, timeout=12))- if isinstance(res, dict):- res["createPromptDeclined"] = True- print("[Fusion Bridge] declined a standing Create-new prompt at the "- "chokepoint (raised by a raw EDIT; Yes would fabricate an "- "empty artifact)")- elif _cat in ("launch_error", "lbr_open_error"):- _unwrap(_ad_call("desktop_ui_click",- {"hwnd": _d.get("hwnd"), "name": "OK"}, timeout=12))- print("[Fusion Bridge] dismissed a standing %s dialog at the "- "chokepoint" % _cat)- except Exception:- pass- except Exception:- pass- try:- cmd = command[7:] if command.startswith("fusion_") else command- # windowEtiquette is an ENUM, not a boolean in disguise (SDK canonicalization,- # page issue #34): a caller branches on it, so BOTH outcomes must be stated. We- # only ever annotated the background case, which left a runner unable to tell- # "came forward, with a reason" from "this verb does not report etiquette".- if cmd in _WINDOW_RAISING and _fg and isinstance(res, dict):- res.setdefault("windowEtiquette", "foreground")- if cmd in _WINDOW_RAISING and not _fg and isinstance(res, dict):- res.setdefault("windowEtiquette", "background")- note = ("Window opened/changed in the BACKGROUND - the user's focus was NOT touched; "- "do NOT foreground anything to compensate. Render results via fusion_state. "- "If the USER asks to watch, re-call with {\"foreground\": true, "- "\"foregroundReason\": \"<why, shown to the user as a 3s caption>\"}.")- h = res.get("_hint")- if not (isinstance(h, str) and "focus was NOT touched" in h):- res["_hint"] = (h + " " + note) if isinstance(h, str) else note- except Exception:- pass- return res---def _dispatch_command_inner(command: str, args: dict, caller_identity: dict = None) -> dict:- import time as _time # MUST be first: a later local `import time as _time` makes the- # name local to this whole scope, so three earlier uses raised UnboundLocalError -- # found by tools/shadow_check.py minutes after the same class shipped as the 1.9.128- # open_lbr regression (issue #47). The gate now runs at every release.- """Dispatch a command to the appropriate handler.-- caller_identity: {'thread','container','reason'} from the X-Adom-Caller-* headers AD stamped on- this request. Bound to THIS thread (issue #348) so any AD callback we make while carrying out- the verb is attributed to the AI thread that asked for it. Pass None to leave the binding alone.- """- if caller_identity is not None:- _set_caller_identity(caller_identity)- # Strip the "fusion_" prefix if present — handlers are registered without it (e.g. "aps_signin"- # not "fusion_aps_signin"), but the relay passes them with the prefix for CLI clarity. Issue #327.- if command.startswith("fusion_"):- command = command[7:]-- # Driving-badge activity clock (John, 2026-08-17): every non-passive verb marks Fusion- # as actively driven; the janitor sheds the badge after _BADGE_IDLE_SECONDS of quiet.- try:- _note_driving_activity(command)- except Exception:- pass-- # Document guard (issue #289 items 1 AND 5, Oliver): a verb carrying `expectDocument` must not- # run against a document the caller didn't intend. Originally only mutating verbs were guarded;- # Oliver's BOM session proved READS need it too - a passive tab switch silently returned a- # different assembly (68/268 vs 94/424 instances) with nothing signalling the retarget. So the- # assertion now fires for ANY verb that passes the arg: a caller who states an expectation wants- # it checked. Asserted BEFORE dispatch, then stripped so handlers never see the key.- if isinstance(args, dict) and args.get("expectDocument"):- _guard = _assert_active_document(str(args["expectDocument"]))- if _guard is not None:- return _guard- args = {k: v for k, v in args.items() if k != "expectDocument"}-- # Background envelope (John 2026-08-14): ANY verb that can raise a Fusion window runs guarded,- # so a cloud open / view switch / import NEVER steals the user's foreground mid-typing. The- # deliberate-foreground verbs (prefs_open, mcp_enable, demo - which announce themselves with- # captions) are excluded on purpose. Refcounted, so overlapping verbs nest cleanly.- _ran_backgrounded = False- if command in _WINDOW_RAISING:- if (args or {}).get("foreground"):- # Foreground is allowed but never free (John, 2026-08-16): the CALLER must say- # WHY, and the user gets that reason as an ab caption for ~3s BEFORE the steal,- # so they know what is happening and can yield (or object). No reason = refuse- # with a teaching hint - same pattern as every reason-gated Adom verb.- fg_reason = str(args.get("foregroundReason") or "").strip()- if not fg_reason:- _note_focus_event(command, "refused")- return {"success": False, "errorCode": "foreground_reason_required",- "error": "foreground:true needs foregroundReason (a short user-facing sentence).",- "_hint": ("Fusion verbs ALWAYS run in the background so the user is never "- "interrupted mid-typing. If the user genuinely wants to WATCH, re-call "- "with foreground:true AND foregroundReason:\"<why, in words the user "- "will read>\" - it is shown as an ab caption for 3 seconds before the "- "window comes forward, so they can yield.")}- _note_focus_event(command, "foreground", fg_reason)- try:- ad_client.notify("Fusion is coming to the foreground",- fg_reason + " (Requested by your AI - Fusion verbs otherwise always "- "run in the background.)", level="info", reach_user=True)- _time.sleep(3.0) # the user's 3-second heads-up BEFORE any focus change- except Exception:- pass- args = {k: v for k, v in args.items() if k not in ("foreground", "foregroundReason")}- else:- _sec = ADDIN_COMMAND_TIMEOUTS.get(command, 60) + 20- begin_background_envelope(float(_sec), minimize=(command == "start"))- _ran_backgrounded = True-- def _annotate_background(res):- # Tell the CALLING AI what just happened window-wise (John, 2026-08-16): the verb- # ran under the background envelope - no focus was taken, nothing to apologize for,- # and the AI should not "helpfully" foreground anything afterward.- if _ran_backgrounded and isinstance(res, dict):- res.setdefault("windowEtiquette", "background")- note = ("Window opened/changed in the BACKGROUND - the user's focus was NOT touched; "- "do NOT foreground anything to compensate. Render results via fusion_state. "- "If the USER asks to watch, re-call with {\"foreground\": true, "- "\"foregroundReason\": \"<why, shown to the user as a 3s caption>\"}.")- h = res.get("_hint")- res["_hint"] = (h + " " + note) if isinstance(h, str) and note not in h else (h or note)- return res-- # Direct handlers (don't need the add-in)- handler = COMMAND_HANDLERS.get(command)- if handler is not None:- return _annotate_background(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", "show_symbol", "show_footprint", "show_3d_package", "make_test_project", "bind_library_3d", "show_library", "show_project") or command in _OPEN_WITH_SCREENSHOT:- # Issue #326: Fusion may be installed under a different Windows user profile than the current- # one, so file-path detection fails even though Fusion is running. Check if it's actually- # running (via tasklist) as a reliable fallback. If running, it's installed somewhere.- fusion_running = _is_fusion_running()- if not fusion_info.get("installed") and not fusion_running:- return {- "success": False,- "error": "Fusion 360 is not installed on this machine.",- "errorCode": "fusion_not_installed",- "_hint": "Fusion 360 isn't installed. Do NOT tell the user to install it themselves - OFFER to install it FOR them and do it on a yes: the fusion-onboarding skill silent-installs Fusion + drives the Autodesk sign-in.",- }- if not fusion_running:- # AUTO-OPEN, DO NOT REFUSE (page issue #64, 2026-08-26).- # CLAUDE.md's automation doctrine: "If Fusion is closed, launch it ... Never- # return an error the code could have fixed." fusion_create_library already- # treats launch as a STAGE of the chain, but every INDIVIDUAL verb still- # refused - so a user who ran a library import with Fusion merely closed- # watched four steps fail in a row ("3D bind did NOT report a binding: Fusion- # 360 is installed but not running", then symbol, footprint and 3D the same).- # They cannot tell "the app was shut" from "this product is broken". Fusion- # not being up is a STEP, never a refusal.- _auto = None- if not args.get("noAutoStart"):- import time as _as- _t0 = _as.time()- print("[Fusion Bridge] %s needs Fusion; it is closed - launching it "- "(page issue #64)" % command)- try:- # Same handler fusion_start uses (server.py:676), with waitForAddin so- # we do not hand back a Fusion whose add-in is not answering yet.- _auto = _handle_launch(fusion_info, {"waitForAddin": True}) or {}- except Exception as _e:- _auto = {"success": False, "error": "%s: %s" % (type(_e).__name__, _e)}- _took = round(_as.time() - _t0, 1)- _bridge_action_ledger("fusion_auto_started",- {"forCommand": command, "ok": bool(_auto.get("success")),- "tookSec": _took})- if _auto.get("success") and _is_fusion_running():- fusion_running = True- print("[Fusion Bridge] Fusion up in %ss - continuing %s" % (_took, command))- else:- return {- "success": False,- "errorCode": "fusion_autostart_failed",- "error": "Fusion was closed so the bridge tried to open it for you, "- "and the launch did not come up: %s"- % str(_auto.get("error") or "no error reported")[:300],- "autoStartAttempted": True, "autoStartSec": _took,- "_hint": "This is a launch failure, NOT a bad request - the command "- "itself was never run. Check fusion_readiness (needsSignin "- "true means Autodesk sign-in is blocking the launch, which "- "is a human step). Pass noAutoStart:true to get the old "- "refuse-immediately behaviour.",- }- if not 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):- # Hung HOST vs down ADD-IN (page issue #38) - opposite remedies, so say which.- # KILL-GRADE evidence only (_confirmed_hung): a busy main thread starves these- # very probes and stops pumping messages, so one failed snapshot looks exactly- # like a hang. Three healthy Fusions were taskkilled mid-work by this gate- # before the persistence rule (2026-08-21).- _h = _confirmed_hung()- if _h.get("hung"):- _ok = False- _bridge_action_ledger("hang_gate_kill",- {"heldSeconds": _h.get("heldSeconds"),- "signals": _h.get("signals")})- try:- dispatch_command("kill", {})- _time.sleep(6)- dispatch_command("start", {})- _ok = True- except Exception:- pass- return {- "success": False,- "error": "Fusion 360 itself is NOT RESPONDING (hung host process, held "- "%ss with the add-in refusing a 20s probe); the add-in cannot "- "answer while it is." % _h.get("heldSeconds"),- "errorCode": "fusion_host_hung",- "hungSignals": _h.get("signals"),- "restartAttempted": _ok,- "_hint": ("Do NOT hunt for a blocking dialog - there is not one. Nothing "- "reaches Fusion over IPC while the host is hung. "- + ("The bridge force-restarted it; poll fusion_readiness."- if _ok else "Use fusion_kill then fusion_start.")),- }- if _h.get("phase") in ("suspect", "busy-not-hung"):- return {- "success": False,- "error": "Fusion's main thread is not answering right now - most likely "- "WORKING on something heavy (a STEP import or save starves the "- "add-in's probes), not hung.",- "errorCode": "fusion_addin_busy",- "hostResponding": _h.get("phase") == "busy-not-hung",- "hungHeldSeconds": _h.get("heldSeconds"),- "_hint": ("WAIT and re-poll; do not restart anything. The bridge only "- "declares a real hang after 180s of continuous silence "- "including a 20s probe - if that happens it restarts Fusion "- "itself and says so."),- }- return {- "success": False,- "error": "Fusion 360 is running but AdomBridge add-in not responding.",- "errorCode": "fusion_addin_not_responding",- "hostResponding": True,- "_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:- # Test-project layout (make_test_project): the "views" are two DOCUMENTS, not- # one design's internal views. Activate the right document first, then the- # normal flow proceeds (for 2D the board editor already IS the 2D view).- if _TEST_PROJECT and command in ("show_schematic", "show_2d_board", "show_3d_board"):- try:- st = _proxy_to_addin("get_app_state", {}, timeout=10)- active = str((st.get("data") or {}).get("activeDocument", "")).lower()- except Exception:- active = ""- if _TEST_PROJECT:- kind = "sch" if command == "show_schematic" else "brd"- _test_project_activate(kind)- if command in ("show_schematic", "show_2d_board"):- shot = _handle_screenshot_fusion({}, {})- return {"success": True, "view": command.replace("show_", ""),- "document": _TEST_PROJECT["docBase"],- "screenshot": shot.get("savedTo"),- "_hint": "Test-project layout: the %s DOCUMENT is now active (its editor IS this view)." % kind}- # 3D board: with the board doc active, fall through to the normal- # show_3d_board machinery which switches the board editor to 3D.- return _handle_open_with_screenshot(fusion_info, args, command)- if command == "open_lbr":- return _orchestrate_open_lbr(args)- if command == "show_symbol":- return _merge_dialog_array(_orchestrate_show_symbol(args))- if command == "show_footprint":- return _merge_dialog_array(_orchestrate_show_footprint(args))- if command == "show_3d_package":- return _merge_dialog_array(_orchestrate_show_3d_package(args))- if command == "make_test_project":- return _merge_dialog_array(_orchestrate_make_test_project(args))- if command == "bind_library_3d":- return _merge_dialog_array(_orchestrate_bind_library_3d(args))- if command == "show_library":- return _merge_dialog_array(_orchestrate_show_library(args))- if command == "show_project":- return _merge_dialog_array(_orchestrate_show_project(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":- if (args or {}).get("async"):- return _start_glb_export_async(args)- return _orchestrate_export_optimized_glb(args)- if command == "fetch_optimized_glb":- if (args or {}).get("exportId"):- return _glb_export_async_status(args)- return _orchestrate_fetch_optimized_glb(args)- if command == "demo":- if (args or {}).get("mode") == "tour":- return _orchestrate_demo_tour(args)- 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:- # ⛔ RAW close_document GATE (John caught the modal standing twice, 2026-08-21):- # web control calls this verb directly, bypassing _close_bridge_doc_verified's- # upload gate - and a close over an in-flight Hub upload raises the lose-changes- # confirm. Same rule at this door: wait for app.hasActiveJobs (cap 45s inside- # the relay window), then refuse honestly rather than raise a data-loss modal.- if command == "close_document":- import time as _tcg- _cg_deadline = _tcg.time() + 45- while _tcg.time() < _cg_deadline:- try:- _jr = _proxy_to_addin("run_modeling_script",- {"script": "import adsk.core\n"- "result={'jobs': adsk.core.Application.get().hasActiveJobs}"},- timeout=10)- if not ((_jr.get("data") or {}).get("result") or {}).get("jobs"):- break- except Exception:- break- _tcg.sleep(5)- else:- return {"success": False, "errorCode": "upload_in_flight_close_deferred",- "error": "Fusion is still uploading packages to the Hub - closing now "- "would raise a lose-changes confirm and risk destroying the "- "upload. The close was NOT issued.",- "_hint": "Re-call fusion_close_document in ~30s; the bridge closes the "- "moment app.hasActiveJobs drains. Nothing was lost and no "- "dialog was raised."}- 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 _annotate_background(_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 _annotate_background(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-bridge 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", {})-- # Who is asking (issue #348). AD stamps these on every request it dispatches to a bridge.- caller_identity = {- "thread": self.headers.get("X-Adom-Caller-Thread", ""),- "container": self.headers.get("X-Adom-Caller-Container", ""),- "reason": self.headers.get("X-Adom-Caller-Reason", ""),- }-- print(f"[Fusion Bridge] Command: {command} | Args: {json.dumps(args)}")-- try:- result = dispatch_command(command, args, caller_identity=caller_identity)- 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}",- })- finally:- # Unbind before this thread is reused or retired. Without this, a later call made on- # our OWN behalf could inherit this AI thread's name and misattribute it.- _set_caller_identity(None)-- def _respond(self, status: int, data: dict):- self.send_response(status)- 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()---# ── Geometry read-back (issue #289 item 2, Oliver) ───────────────────────────────────────────-# There was no way to ask what actually got BUILT, so every caller hand-wrote body enumeration-# inside a modeling script and parsed it back out. That read-back caught both real modelling bugs-# in Oliver's session and NEITHER was visible in a screenshot (a body at Z -167.6 instead of -83.8-# from a double-applied offset; a handle buried inside the plate it should hang below). This work-# is often matte-black parts on a dark canvas, so pixels are a weak verification channel and an AI-# driving the bridge has no other way to check its own output.-#-# Implemented BRIDGE-side on the existing run_modeling_script surface (the add-in already captures-# a script's `result`), so it needs no add-in redeploy and no Fusion restart.-#-# Units are MILLIMETRES, deliberately. Fusion's internals are centimetres, which is a standing-# source of off-by-ten errors when an AI reads these numbers back.-_INSPECT_BODIES_SCRIPT = r'''-import adsk.core, adsk.fusion--_des = adsk.fusion.Design.cast(app.activeProduct)-_WORLD = __WORLD__-_OCC = __OCC__--def _mm(v): return round(v * 10.0, 4) # cm -> mm-def _mm3(v): return round(v * 1000.0, 4) # cm3 -> mm3-def _mm2(v): return round(v * 100.0, 4) # cm2 -> mm2--def _describe(body, owner):- bb = body.boundingBox- mn, mx = bb.minPoint, bb.maxPoint- cyl = 0- try:- for f in body.faces:- g = f.geometry- if g and g.surfaceType == adsk.core.SurfaceTypes.CylinderSurfaceType:- cyl += 1- except Exception:- cyl = -1- def _nm(o):- try: return o.name- except Exception: return None- return {- "name": body.name,- "owner": owner,- "bbox": {"x": [_mm(mn.x), _mm(mx.x)],- "y": [_mm(mn.y), _mm(mx.y)],- "z": [_mm(mn.z), _mm(mx.z)]},- "sizeMm": [_mm(mx.x - mn.x), _mm(mx.y - mn.y), _mm(mx.z - mn.z)],- "volumeMm3": _mm3(body.volume),- "areaMm2": _mm2(body.area),- "faceCount": body.faces.count,- "cylindricalFaceCount": cyl,- "appearance": _nm(body.appearance),- "material": _nm(body.material),- "isVisible": body.isVisible,- }--rows = []-root = _des.rootComponent-for b in root.bRepBodies:- rows.append(_describe(b, root.name))--if _WORLD:- # Bodies fetched THROUGH an occurrence are proxies whose boundingBox is already resolved- # into assembly space, so occurrence placement is verified without any matrix maths here.- for occ in root.allOccurrences:- if _OCC and _OCC.lower() not in occ.name.lower():- continue- try:- for b in occ.bRepBodies:- rows.append(_describe(b, occ.name))- except Exception:- pass--result = {"units": "mm", "worldSpace": bool(_WORLD),- "documentName": app.activeDocument.name if app.activeDocument else None,- "bodyCount": len(rows), "bodies": rows}-'''---def _handle_inspect_bodies(fusion_info: dict, args: dict) -> dict:- """fusion_inspect_bodies - what actually got built, in millimetres."""- world = bool(args.get("worldSpace"))- occ = args.get("occurrence") or ""- script = (_INSPECT_BODIES_SCRIPT- .replace("__WORLD__", "True" if world else "False")- .replace("__OCC__", repr(str(occ)) if occ else "None"))- res = _proxy_to_addin("run_modeling_script", {"script": script}, timeout=120)- if not res.get("success"):- return res- data = res.get("data") or {}- payload = data.get("result")- if not isinstance(payload, dict):- return {"success": False, "error": "inspect_bodies returned no structured result",- "errorCode": "inspect_failed", "data": data,- "_hint": "The read-back script ran but produced no result dict. Usually means no "- "active Design; open a document and retry."}- n = payload.get("bodyCount", 0)- payload["success"] = True- payload["_hint"] = (- "Bounding boxes, volumes and areas are in MILLIMETRES (Fusion's API is centimetres; "- "converted here so you do not have to). Check bbox Z against where the part SHOULD sit - "- "a wrong offset renders perfectly and is invisible in a screenshot. cylindricalFaceCount "- "is the cheap way to confirm holes were actually cut: a symmetric cut that reaches nothing "- "fails SILENTLY with no exception, and the count is what surfaces it. "- + ("Pass worldSpace:true to resolve bodies through their occurrence transforms and verify "- "assembly placement." if not world else- "worldSpace:true - occurrence bodies are proxies already resolved into assembly space.")- ) if n else (- "No bodies found. If you expected some, the active document may not be the one you built "- "into (pass expectDocument on mutating verbs to catch that), or the design is empty."- )- return payload---COMMAND_HANDLERS["inspect_bodies"] = _handle_inspect_bodies----# ── Uninstall cascade (ab 2.0.35) ────────────────────────────────────────────────────────────-# ab's bridge_uninstall calls this verb FIRST, while our files still exist, so we can remove the-# artifacts we planted OUTSIDE our own cache dir. That matters more for this bridge than for most:-# we install a Fusion ADD-IN into Autodesk's own directories, and a stale AdomBridge add-in keeps-# loading on every Fusion launch, binding port 8774, and reaching for a bridge that no longer-# exists. We also hold two user-data stores (~/.adom/fusion-bridge prefs, ~/.adom/fusion-aps-# OAuth tokens). Uninstall means all of it goes.-#-# Pass {"dryRun": true} to see exactly what WOULD be removed without touching anything.-def _handle_uninstall(fusion_info: dict, args: dict) -> dict:- """fusion_uninstall - remove this bridge's out-of-cache artifacts (add-in, prefs, APS tokens)."""- import shutil as _sh- import pathlib as _pl- args = args or {}- dry = bool(args.get("dryRun"))- home = _pl.Path(os.path.expanduser("~"))-- targets = []- try:- from install_addin import TARGET_CANDIDATES as _TC- targets += [_pl.Path(str(t)) / "AdomBridge" for t in _TC]- except Exception:- targets += [home / "AppData" / "Roaming" / "Autodesk" / "FusionAddins" / "AdomBridge",- home / "AppData" / "Roaming" / "Autodesk" / "Autodesk Fusion" / "API" / "AddIns" / "AdomBridge",- home / "AppData" / "Roaming" / "Autodesk" / "Autodesk Fusion 360" / "API" / "AddIns" / "AdomBridge"]- addin_targets = list(targets)- data_targets = [home / ".adom" / "fusion-bridge", home / ".adom" / "fusion-aps"]-- removed, failed, skipped = [], [], []- for p in addin_targets + data_targets:- kind = "addin" if p in addin_targets else "userdata"- if not p.exists():- skipped.append({"path": str(p), "kind": kind, "why": "not present"})- continue- if dry:- removed.append({"path": str(p), "kind": kind, "dryRun": True})- continue- try:- _sh.rmtree(p)- removed.append({"path": str(p), "kind": kind})- except Exception as e:- failed.append({"path": str(p), "kind": kind, "error": str(e)})-- running = False- try:- running = bool(_is_fusion_running())- except Exception:- pass-- out = {"success": not failed, "dryRun": dry, "cascade": bool(args.get("cascade")),- "fusionRunning": running, "removed": removed, "failed": failed, "skipped": skipped}- if failed and running:- out["_hint"] = ("Fusion is RUNNING and holds the add-in files open, so %d path(s) could not be "- "removed. Close Fusion (fusion_stop) and re-run fusion_uninstall, or delete "- "those paths by hand - otherwise the stale add-in keeps loading inside Fusion "- "on every launch and binding its port." % len(failed))- elif failed:- out["_hint"] = ("%d path(s) could not be removed (permissions?). They are listed in `failed`; "- "delete them by hand so no stale add-in is left inside Fusion." % len(failed))- elif dry:- out["_hint"] = ("DRY RUN - nothing was touched. These are the paths a real uninstall removes: "- "the Fusion add-in in Autodesk's AddIns dirs, plus this bridge's prefs and "- "APS OAuth tokens under ~/.adom.")- else:- out["_hint"] = ("Cleaned up: the Fusion add-in is gone from Autodesk's directories, and this "- "bridge's prefs + APS OAuth tokens are removed. Fusion itself is untouched. "- "Re-installing the bridge redeploys the add-in on its next start.")- return out---COMMAND_HANDLERS["uninstall"] = _handle_uninstall-COMMAND_HANDLERS["demo_progress"] = lambda _info, args: _handle_demo_progress(args or {})---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+ import uuid as _uuid+ import tempfile as _tf+ _BUILD_IN_FLIGHT[out_path] = _tt.time()+ # Pollable run state (adom/fusion-bridge#65): the detached build used to+ # swallow its result, leaving callers to grep the bound .lbr for urns.+ # The run now writes per-part progress and the FINAL per-part report into+ # the same per-run store the create chain uses, so fusion_prove_result+ # {runId} answers for this build too (web-control's 135s envelope polls+ # one verb either way).+ _run_id = _uuid.uuid4().hex[:12]+ _bstate = {"stage": "starting", "kind": "build_library_3d", "runId": _run_id,+ "boundLbr": out_path, "partsTotal": len(args.get("parts") or []),+ "partsDone": 0, "success": None}++ def _bsave():+ try:+ _PROVE_LAST[0] = dict(_bstate)+ open(os.path.join(_tf.gettempdir(), "fusion-prove-last.json"), "w",+ encoding="utf-8").write(json.dumps(_bstate))+ _runs_p = os.path.join(_tf.gettempdir(), "fusion-prove-runs.json")+ try:+ _runs = json.loads(open(_runs_p, encoding="utf-8").read())+ except Exception:+ _runs = {}+ _runs[_run_id] = dict(_bstate)+ while len(_runs) > 20:+ _runs.pop(next(iter(_runs)))+ open(_runs_p, "w", encoding="utf-8").write(json.dumps(_runs))+ except Exception:+ pass++ def _on_part(i, total, entry):+ _bstate["partsDone"] = i+ _bstate["stage"] = "minting %s (%d/%d)" % (entry.get("package") or "part", i, total)+ _bstate.setdefault("parts", []).append(entry)+ _bsave()++ _bsave()++ def _run():+ try:+ args["_onPart"] = _on_part+ _res = _build_library_3d_core(args)+ _bstate.update(stage="done", success=bool(_res.get("success")),+ result={k: _res.get(k) for k in+ ("success", "boundLbr", "partsBound", "partsTotal",+ "parts", "opened", "error")})+ except Exception as _e:+ _bstate.update(stage="failed", success=False, error=str(_e)[:300])+ finally:+ _bsave()+ # ALWAYS clear, even on failure: a marker that outlives its build would+ # refuse every later capture (page issue #62).+ _BUILD_IN_FLIGHT.pop(out_path, None)+ _th.Thread(target=_run, daemon=True).start()+ return {+ "success": True, "status": "started", "async": True, "runId": _run_id,+ "statusVerb": "fusion_prove_result",+ "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})+ # A missing APS workspace folder no longer gates the build: each part's mint saves+ # straight to the cloud through the add-in and only needs APS as its upload fallback+ # (adom/adom-bridge#93). folder_id=None flows down and the mint gates itself.+ 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)+ _cb = args.get("_onPart")+ if callable(_cb):+ try: _cb(len(results), len(parts), entry)+ except Exception: pass+ # 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 Bridge relay times out the REQUEST at ~60s - you may get 'Request timed out' even "+ "though the build KEEPS RUNNING server-side and finishes. Do NOT assume it failed: wait, "+ "then verify by reading the boundLbr (count <package3d name=) + the before/after PNGs in "+ "C:/tmp/conduit-screenshots. If a part is missing, just re-run build_library_3d for the "+ "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 _taskbar_progress(pct=None):+ """Show REAL progress on Fusion's Windows-taskbar button instead of orange flashes+ (John, 2026-08-23: he keeps seeing attention-flash highlights and no progress bar).++ Windows flashes a BACKGROUND window's button orange whenever the app requests+ attention (every dialog Fusion raises while the bridge drives it does this). We+ cannot stop Fusion REQUESTING it - but we can (a) clear the flash the moment we+ have handled whatever raised it, and (b) replace the signal with an honest+ progress bar during chain stages, paced by this machine's measured estimates.+ pct=None clears the bar. Best-effort: never raises, silently no-ops without AD."""+ try:+ if not ad_client.available():+ return+ info = get_fusion_window_info() or {}+ hwnd = info.get("hwnd") or (info.get("mainWindow") or {}).get("hwnd")+ if not hwnd:+ return+ args = {"hwnd": int(hwnd), "flash": "stop"}+ if pct is None:+ args["progress"] = {"state": "none"}+ else:+ args["progress"] = {"state": "normal",+ "value": max(1, min(100, int(pct))), "max": 100}+ ad_client.call("desktop_taskbar", args, timeout=4)+ except Exception:+ pass+++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 _handle_dismiss_blocking_dialogs(fusion_info: dict, args: dict) -> dict:+ """NATIVE fusion_dismiss_blocking_dialogs (John, 2026-08-23: he hates seeing Fusion+ dialogs while the bridge drives, and the verb was falling to ab's BLIND WM_CLOSE+ fallback - adom-bridge#86 - which bypasses this bridge's classifier and cannot tell a+ harmless nag from a DATA-LOSS 'packages are uploading, sure you want to close?' prompt).++ This routes every open dialog through classify_blocking_dialogs() and answers each SAFELY+ in the BACKGROUND (WM_CLOSE = Cancel/No, never SetForegroundWindow, never Yes/close):+ - upload_in_progress: WM_CLOSE == 'No' == do NOT close == the upload keeps draining. We+ flag it so the caller polls hasActiveJobs before any retry (closing now still loses it).+ - everything else: WM_CLOSE == Cancel/No, the safe answer for every Fusion confirm.+ Returns what each dialog WAS (category) and how it was answered - so the caller never has to+ guess, and never gets a stray Yes.+ """+ classified = classify_blocking_dialogs()+ if not classified:+ return {"success": True, "dismissed": [], "count": 0,+ "_hint": "No blocking Fusion dialog is open."}+ answered, upload_active = [], False+ for d in classified:+ hwnd, cat = d.get("hwnd"), d.get("category")+ safe = "No/Cancel (WM_CLOSE, background)"+ if cat == "upload_in_progress":+ upload_active = True+ safe = "No - declined close, Hub upload left to drain (NEVER Yes)"+ ok = False+ try:+ ok = bool(close_dialog_bg(hwnd))+ except Exception:+ ok = False+ answered.append({"title": d.get("title"), "category": cat,+ "answeredWith": safe, "closed": ok,+ "resolution": d.get("resolution")})+ hint = ("Answered %d blocking dialog(s) in the background (No/Cancel - never Yes, never "+ "focus-stolen)." % len(answered))+ if upload_active:+ hint += (" One was a Hub-UPLOAD 'lose these changes?' prompt: it was declined (No), but "+ "the upload is STILL in flight - poll app.hasActiveJobs (fusion_state) until it "+ "drains before any close/saveAs, or you WILL lose the 3D package.")+ _taskbar_progress(None) if not upload_active else _taskbar_progress(1)+ return {"success": True, "count": len(answered), "dismissed": answered,+ "uploadStillActive": upload_active, "_hint": hint}+++COMMAND_HANDLERS["dismiss_blocking_dialogs"] = _handle_dismiss_blocking_dialogs+++def _capture_3d_view(pkg: str, lbr_path: str, settle: float) -> dict:+ """Capture the DEDICATED 3D-package render as its own artifact (page issue #63).++ The 3D chip previously existed only as a thumbnail inside the ganged component view.+ Getting it standalone meant a separate fusion_show_3d_package call that re-attaches the+ STEP and re-renders - measured at a 180s timeout during the import campaign, and it+ took Fusion down with it under concurrent load. The 3D body is already built and saved+ by the time captures run, so entering the Package3D environment and photographing it is+ cheap: no re-attach, no re-render.+ """+ import time as _t+ entry = {"package": pkg, "view": "3d", "path": None, "ok": False}+ # A PLACEHOLDER IS NOT A RESULT (page issue #42). With no <package3d> bound, Fusion+ # renders a grey slab that photographs exactly like a real chip. Knowable offline.+ if lbr_path:+ try:+ if not _library_artifact_names(lbr_path).get("p3d"):+ entry.update({+ "errorCode": "no_3d_package_bound",+ "error": "No <package3d> is bound in that library, so the only thing to "+ "photograph is Fusion's grey Placeholder - refusing to return "+ "that as the 3D artifact.",+ "_hint": "Run fusion_build_library_3d (or fusion_bind_library_3d) first, "+ "then re-capture. A Placeholder looks like a successful 3D view "+ "in a screenshot, which is why this refuses instead."})+ return entry+ except Exception:+ pass+ cp = _create_3d_package(lbr_path, timeout=60) if lbr_path else {"success": False,+ "error": "no libraryPath"}+ if not cp.get("success"):+ entry.update({"errorCode": "create_3d_package_failed",+ "error": "Could not enter the Package3D environment: %s"+ % str(cp.get("error"))[:200]})+ return entry+ try:+ if settle:+ _t.sleep(settle)+ entry["path"] = _capture_labeled("%s_3d" % pkg)+ entry["ok"] = bool(entry["path"])+ finally:+ # ALWAYS leave the Package3D environment, even if the capture threw. Staying in it+ # would make every LATER view capture the 3D editor - exactly the mislabelled-frame+ # failure reported in page issue #62.+ try:+ _proxy_to_addin("execute_text_command", {"command": "Package3DStop"}, timeout=20)+ _t.sleep(0.6)+ _d = _dismiss_dialogs_bg()+ if _d:+ print("[Fusion Bridge] Package3DStop raised a dialog (%s) - answered in the "+ "background; see skills/fusion-no-modal-dialogs" % _d)+ except Exception as _e:+ print("[Fusion Bridge] Package3DStop failed: %s" % _e)+ return entry++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."}+ # FOUR artifacts, in ASSEMBLY ORDER (page issues #60 and #63): symbol, footprint,+ # the dedicated 3D chip, then the ganged component view LAST as the closing proof.+ # The 3D chip used to exist only as a thumbnail inside the ganged view, so a caller+ # could not show it as its own artifact without a separate fusion_show_3d_package call+ # that re-attaches the STEP - slow (180s timeout observed) and crash-prone under load.+ view_ext = {"component": "dev", "symbol": "sym", "footprint": "pac", "3d": "3d",+ "dev": "dev", "sym": "sym", "pac": "pac", "p3d": "3d", "package3d": "3d"}+ label_of = {"dev": "component", "sym": "symbol", "pac": "footprint", "3d": "3d"}+ views = args.get("views") or ["symbol", "footprint", "3d", "component"]+ # 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))+ # ⛔ PRE-CHECK EVERY NAME OFFLINE, BEFORE ANY EDIT REACHES FUSION.+ # This verb used to fire EDIT on whatever it was handed and then call+ # _dismiss_dialogs_bg() to clear the "Create new footprint 'X'?" modal it had just+ # caused. That is backwards three ways: the modal freezes the add-in's main thread+ # (so every later verb reads as a crash), it dings the machine's alert sound at a user+ # who did not do anything, and dismissal is a RACE we lose whenever Fusion is slow.+ # The .lbr on disk already knows every symbol/package/deviceset name, so a name that+ # would prompt is knowable in milliseconds without Fusion. Resolve here; never ask.+ # REFUSE TO MANUFACTURE EVIDENCE DURING A BUILD (page issue #62, 2026-08-26).+ # fusion_build_library_3d is async; while it runs it holds the 3D-package editor on+ # screen, so EDIT <name>.sym/.pac land on that editor and every capture shows the part+ # being built rather than the view it is labelled with. One came back ok:true with no+ # chip in the frame. A false pass is worse than a refusal: it is evidence that lies.+ _flight = _build_in_flight()+ if _flight and not args.get("ignoreBuildInProgress"):+ import time as _ft+ return {"success": False, "errorCode": "build_in_progress",+ "error": "A 3D build is still running for %s - refusing to capture, because "+ "the build holds the 3D-package editor on screen and every view "+ "would show that editor instead of the view it is labelled with."+ % ", ".join(sorted(_flight)),+ "buildsInFlight": [{"lbrPath": k, "runningSec": int(_ft.time() - v)}+ for k, v in sorted(_flight.items())],+ "_hint": "Wait for the build to finish, then re-capture - the same call "+ "returns correct content once it is done. Detect completion by "+ "polling fusion_get_app_state until the <part>_3d document reports "+ "isSaved:true, or read the bound .lbr until it has one <package3d "+ "wip_urn=...> per part. Pass ignoreBuildInProgress:true only if you "+ "genuinely want a mid-build frame."}+ lbr_path = (args.get("libraryPath") or "").replace("\\", "/") or _remembered_open_library()+ resolved_names = {}+ name_errors = []+ if lbr_path:+ for pkg in pkgs:+ for v in views:+ ext = view_ext.get(v, v)+ if ext == "3d":+ continue # not an EDIT target; guarded by its own p3d check+ r, err = _resolve_artifact_name(pkg, ext, lbr_path)+ if err:+ name_errors.append({"package": pkg, "view": label_of.get(ext, ext),+ "error": err.get("error"),+ "available": err.get("available"),+ "closestMatches": err.get("closestMatches")})+ else:+ resolved_names[(pkg, ext)] = r+ out = []+ for pkg in pkgs:+ for v in views:+ ext = view_ext.get(v, v)+ if ext == "3d":+ out.append(_capture_3d_view(pkg, lbr_path, settle))+ continue+ if lbr_path and (pkg, ext) not in resolved_names:+ # Would have prompted. Report it as a skip; do NOT hand it to Fusion.+ _bad = next((e for e in name_errors+ if e["package"] == pkg and e["view"] == label_of.get(ext, ext)), {})+ out.append({"package": pkg, "view": label_of.get(ext, ext), "path": None,+ "ok": False, "skipped": "name_not_in_library",+ "error": _bad.get("error"),+ "available": (_bad.get("available") or [])[:20],+ "closestMatches": _bad.get("closestMatches"),+ "_hint": "No EDIT was issued and no dialog was raised - this name "+ "is not in the library, so opening it would have popped "+ "Fusion's blocking 'Create new ...?' modal. Pick a name "+ "from `available`."})+ continue+ name = resolved_names.get((pkg, ext), pkg)+ er = _proxy_to_addin("electron_run", {"command": f"EDIT {name}.{ext}", "confirm": "no"}, 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.+ # BACKSTOP ONLY. With the pre-check above this should never fire; if it does,+ # the pre-check has a hole (a library opened by a route that did not record its+ # path, or a name EAGLE renamed on load) and that is a BUG to fix at the source,+ # not a dialog to keep sweeping. Log it loudly so it cannot stay invisible.+ dismissed = _dismiss_dialogs_bg()+ if dismissed:+ print("[Fusion Bridge] ⚠ PRE-CHECK HOLE: EDIT %s.%s still raised a dialog "+ "(lbr_path=%r). Prevention failed; dismissal should not be load-bearing."+ % (name, ext, lbr_path))+ _bridge_action_ledger("dialog_precheck_hole",+ {"name": name, "ext": ext, "lbrPath": lbr_path,+ "dismissed": dismissed})+ _proxy_to_addin("electron_run", {"command": "WINDOW FIT"}, timeout=20)+ _t.sleep(0.6)+ shot = _capture_labeled(f"{pkg}_{label_of.get(ext, ext)}")+ # ok MUST MEAN "the frame shows the view it is labelled with", not "a file was+ # written" (page issue #62). The reported failure captured the 3D-package+ # editor for every view and still marked one ok:true. We cannot read the pixels+ # from here, but we CAN ask Fusion which editor was actually on screen at+ # capture time, which is exactly what went wrong - so a frame taken outside the+ # Electronics Library editor is never reported as a pass.+ editor_ws, editor_ok = None, True+ try:+ _st = _proxy_to_addin("get_app_state", {}, timeout=15) or {}+ editor_ws = _st.get("activeWorkspace")+ if editor_ws and "electronics library" not in str(editor_ws).lower():+ editor_ok = False+ except Exception:+ editor_ws = None # unknown, not a pass and not a failure+ entry = {"package": pkg, "view": label_of.get(ext, ext), "path": shot,+ "dialogDismissed": dismissed or None,+ "capturedInWorkspace": editor_ws,+ "ok": (bool(er.get("success", True)) and bool(shot)+ and not dismissed and editor_ok)}+ if not editor_ok:+ entry["errorCode"] = "wrong_editor_captured"+ entry["error"] = ("Captured while %r was active, not the Electronics Library "+ "editor - the frame does not show the %s view it is "+ "labelled with." % (editor_ws, label_of.get(ext, ext)))+ out.append(entry)+ 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_direct_api_call(verb: str, args: dict, timeout: float = 60) -> dict | None:+ """Call an AD verb over the loopback DIRECT API - the correct, non-foregrounding way for a+ bridge to reach AD when the in-process ad_client is down (issue #295). AD spawns the bridge+ with ADOM_DIRECT_API_URL in its env (fallback: ~/.adom/direct-api-port); POST+ {"command", "args"} to <base>/command. Returns the parsed result dict, or None if the direct+ API is unavailable or errors (so _ad_call can fall through). Never shells the GUI exe."""+ base = (os.environ.get("ADOM_DIRECT_API_URL") or "").strip()+ if not base:+ try:+ with open(os.path.join(os.path.expanduser("~"), ".adom", "direct-api-port")) as f:+ port = f.read().strip()+ if port:+ base = "http://127.0.0.1:%s" % port+ except Exception:+ return None+ if not base:+ return None+ try:+ body = json.dumps({"command": verb, "args": args}).encode("utf-8")+ headers = {"Content-Type": "application/json"}+ # The bridge token is what keeps a bridge's own callbacks exempt from AD's identity gate+ # (ad_client already sends it; this path used to omit it).+ tok = os.environ.get("ADOM_BRIDGE_TOKEN") or ""+ if tok:+ headers["X-Adom-Bridge-Token"] = tok+ # Echo the identity AD handed us, then name OURSELVES as the delegate (issue #348). The+ # thread alone would hide who ran it; the bridge alone would hide who asked. AD needs both+ # to render "chip-fetcher tab 3 (via fusion)". Delegate is ADDED, never forwarded.+ ident = _get_caller_identity()+ for key, hdr in (("thread", "X-Adom-Caller-Thread"),+ ("container", "X-Adom-Caller-Container"),+ ("reason", "X-Adom-Caller-Reason")):+ if ident.get(key):+ headers[hdr] = ident[key]+ headers["X-Adom-Caller-Delegate"] = "fusion"+ req = urllib.request.Request(base.rstrip("/") + "/command", data=body,+ headers=headers, method="POST")+ with urllib.request.urlopen(req, timeout=timeout) as resp:+ r = json.loads(resp.read())+ if isinstance(r, dict):+ inner = r.get("output")+ if isinstance(inner, str) and inner.strip().startswith("{"):+ try:+ return json.loads(inner)+ except Exception:+ return r+ return r+ except Exception:+ return None+ return None+++def _ad_call(verb: str, args: dict, timeout: int = 60) -> dict:+ """Call another AD verb (nbrowser_*, desktop_*) from inside this bridge.++ Prefers the in-process ad_client; then the loopback DIRECT API (issue #295); only as a last+ resort shells the host app's **-cli** exe (adom-bridge-cli.exe, NEVER the GUI adom-bridge.exe,+ which would foreground AD). Works on an unattended VM where ad_client is down. Never raises -+ returns {} on failure so callers can degrade to instructing the AI instead.++ Attribution (issue #348) is stamped into args HERE, once, so it survives on ALL THREE paths -+ the in-process client and the CLI subprocess cannot carry HTTP headers, and an explicit caller+ block in args is the highest-precedence form AD accepts. The direct-API path additionally sends+ the X-Adom-Caller-* headers so AD can record the delegation chain."""+ args = _stamp_caller(args)+ why = []+ try:+ if ad_client.available():+ 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)+ # #295: prefer the loopback DIRECT API - it reaches AD without foregrounding anything.+ direct = _ad_direct_api_call(verb, args, timeout=timeout)+ if isinstance(direct, dict):+ return direct+ why.append("direct-api unavailable")+ try:+ import subprocess as _sp, json as _j+ exe = _find_adom_desktop_cli() # the -cli exe only; never the GUI exe (#295)+ if not exe:+ return {"_adCallError": "; ".join(why + ["adom-bridge CLI not found"])}+ p = _sp.run([exe, verb, _j.dumps(args)], capture_output=True, text=True, timeout=timeout)+ out = (p.stdout or "").strip()+ if out.startswith("{"):+ 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.+# ⚠ TWO CLASSES (#50, 2026-08-23 - 9/9 chains lost Fusion mid-PROVE): "Update Now" only+# STARTS a background download (safe anytime), but "Install Now"/"Restart Now" EXIT Fusion+# to apply the update. The sweep used to click all four blindly on its 300s cadence, which+# lines up exactly with the observed clean exit at +344s into a chain. Exit-class buttons+# may now only be clicked when Fusion is verifiably IDLE (no long op, no recent driving,+# no open documents).+_UPDATE_BUTTONS = ("Update Now", "Update now", "Install Now", "Restart Now")+_UPDATE_BUTTONS_EXIT_CLASS = ("Install Now", "Restart Now")+++def _bridge_action_ledger(action: str, detail: dict):+ """Append one background-action record to ~/.adom/fusion-bridge/actions.jsonl.++ The bridge log truncates on every respawn (found investigating #50: a morning of+ evidence was gone by the time the report landed), so anything the bridge does to+ Fusion BEHIND the user's back - update clicks, seat takeovers, dialog dismissals,+ doc closes, hang-gate kills - is also written here, append-only, survives respawns.+ One grep answers 'did the bridge touch Fusion at HH:MM?'. Never raises."""+ try:+ import json as _j, time as _t, pathlib as _pl+ p = _pl.Path(os.path.expanduser("~")) / ".adom" / "fusion-bridge" / "actions.jsonl"+ p.parent.mkdir(parents=True, exist_ok=True)+ rec = {"at": _t.strftime("%Y-%m-%d %H:%M:%S"), "epoch": round(_t.time(), 1),+ "action": action, "pid": os.getpid()}+ rec.update(detail or {})+ with open(p, "a", encoding="utf-8") as f:+ f.write(_j.dumps(rec) + "\n")+ except Exception:+ pass+++try:+ import handlers.dismiss_recovery as _dr_mod+ _dr_mod.LEDGER = _bridge_action_ledger # every recovery WM_CLOSE / Escape is recorded+except Exception:+ pass+++def _host_op_active() -> bool:+ """THE shared invariant (#52 item 3): is a long op or recent driving in flight? Every+ BACKGROUND subsystem that would act ON the host app (synthetic input, window closes,+ update clicks) must stand down while this is true - so subsystem number four is born+ safe instead of being diagnosed in a six-hour thread. Seat takeover is the one+ documented exemption: the seat dialog BLOCKS the very op we are protecting."""+ import time as _t+ if _LONG_OP.get("name"):+ return True+ return bool(_LAST_DRIVE_AT[0] and (_t.time() - _LAST_DRIVE_AT[0]) < 180)+++def _fusion_verifiably_idle() -> dict:+ """May the bridge do something that EXITS Fusion right now? Only when ALL of:+ no registered long op, no driving verb for 3+ minutes, and no open documents+ (best-effort; an unreachable add-in counts as NOT idle - safe default)."""+ import time as _t+ if _LONG_OP.get("name"):+ return {"idle": False, "why": "long op in flight: %s" % _LONG_OP["name"]}+ drove_ago = _t.time() - float(_LAST_DRIVE_AT[0] or 0)+ if _LAST_DRIVE_AT[0] and drove_ago < 180:+ return {"idle": False, "why": "driving verb %ds ago" % int(drove_ago)}+ try:+ st = _proxy_to_addin("document_info", {}, timeout=6)+ docs = (st.get("data") or {}).get("openDocuments") or []+ if not st.get("success"):+ return {"idle": False, "why": "add-in unreachable - cannot prove no open docs"}+ if docs:+ return {"idle": False, "why": "%d document(s) open" % len(docs)}+ except Exception:+ return {"idle": False, "why": "doc check failed - cannot prove idle"}+ return {"idle": True, "why": "no long op, no recent driving, no open documents"}+++def _find_controls_any(obj, depth=0):+ """Envelope-proof extraction of UIA find_control results: ab has moved best/controls+ across response levels between versions (same drift family as coordMap). Returns+ {best, controls} from anywhere in the response, parsing stringified output."""+ if depth > 5:+ return {}+ if isinstance(obj, str) and obj[:1] in "[{":+ try:+ import json as _j+ return _find_controls_any(_j.loads(obj), depth + 1)+ except Exception:+ return {}+ if isinstance(obj, dict):+ if isinstance(obj.get("best"), dict) or isinstance(obj.get("controls"), list):+ return {"best": obj.get("best") or {}, "controls": obj.get("controls") or []}+ for k in ("data", "output", "result", "response"):+ got = _find_controls_any(obj.get(k), depth + 1)+ if got:+ return got+ return {}+++_LAST_UPDATE_OFFER = {"offered": False, "at": 0}+++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 = _find_controls_any(_ad_call("desktop_find_control", {"hwnd": h, "name": label}, timeout=20))+ best = r.get("best") or {}+ if best.get("name") and best.get("invokable"):+ import time as _ut+ _LAST_UPDATE_OFFER.update(offered=True, at=_ut.time(), button=best.get("name"))+ 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"}+ # ⚠ #50 gate: "Install Now"/"Restart Now" EXIT Fusion to apply the update. Clicking one+ # while a chain is running killed Fusion mid-PROVE 9/9 times (clean exit, no crash+ # artifact, matching this sweep's 300s cadence). Exit-class buttons only fire when+ # Fusion is verifiably idle; otherwise defer - the janitor retries every 300s and will+ # land it the moment the work is done. "Update Now" (download only) stays safe anytime.+ if offer["button"] in _UPDATE_BUTTONS_EXIT_CLASS:+ idle = _fusion_verifiably_idle()+ if not idle.get("idle"):+ _bridge_action_ledger("auto_update_deferred",+ {"button": offer["button"], "notIdle": idle.get("why")})+ print("[Fusion Bridge] auto-update: %r on screen but Fusion is NOT idle (%s) - "+ "DEFERRED, will retry when idle" % (offer["button"], idle.get("why")))+ return {"applied": False, "deferred": True, "button": offer["button"],+ "why": "exit-class update button deferred: %s" % idle.get("why")}+ 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"])+ _bridge_action_ledger("auto_update_click",+ {"button": offer["button"], "invoked": ok,+ "exitClass": offer["button"] in _UPDATE_BUTTONS_EXIT_CLASS})+ 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+++# ── SAVE-PROMPT SENTINEL (issue #30, 2026-08-16) ─────────────────────────────────────────────+# ab's live demo run proved that Fusion's unsaved-doc "Save / Don't Save / Cancel" modal,+# popped mid document-switch by our own show_* verbs, orphans the in-flight add-in call and+# can wedge the add-in main thread PERMANENTLY (only fusion_close {force} + fusion_start+# recovers). The show verbs themselves create the modified-doc state, so for BRIDGE-CREATED+# documents discarding is always correct: while a doc-switching command runs, a watcher+# scans for the prompt and clicks "Don't Save" IN THE BACKGROUND (UIA invoke, no focus).+#+# ⚠ DATA-SAFETY RULE: we only auto-discard when the dialog text names a document the bridge+# itself created this session (_BRIDGE_DOCS). A user's own unsaved design NEVER gets its+# Save prompt auto-answered - that would destroy their work. Unrecognized prompts are left+# standing and classified as before (classify_blocking_dialogs -> save_changes hint).++_BRIDGE_DOCS: set = set() # lowercase doc-name fragments the bridge created+_UNATTRIBUTED_PROMPT_STRIKES: dict = {} # hwnd -> consecutive sightings without attribution+_SIZE_ATTEMPTS: dict = {} # hwnd -> sizing attempts (Fusion restores its OWN saved+ # geometry LATE in startup and can override an early+ # resize, so we retry until the window is confirmed+ # large, then stop touching it - John's live trace+ # 2026-08-18: sized to 92% at t+75s, Fusion re-restored+ # itself at t+90s.)+_NO_DONTSAVE_HWNDS: set = set() # hwnds probed once with NO Don't-Save button (UIA-less)+_DONT_SAVE_LABELS = ("Don't Save", "Don’t Save")+# Names the bridge generates by CONVENTION - always ours, even after a respawn wiped the+# session registry (caught live 2026-08-16: respawn emptied _BRIDGE_DOCS and the sentinel+# refused its own ADOM-TEST doc's prompt, wedging two tours in a row).+_BRIDGE_DOC_BUILTINS = ("adom-test-", "adomdemosample", "adom-fusion-wrap",+ # the tour's own bundled sample parts - bridge docs by definition+ # (their Save prompt failed a third tour, 2026-08-16)+ "ads8588sipm", "0603waf")+++def _note_bridge_doc(name: str):+ """Register a document/library name as BRIDGE-CREATED (safe to auto-discard).+ Write-through to prefs.json so the registry SURVIVES bridge respawns."""+ n = str(name or "").rsplit("/", 1)[-1].rsplit("\\", 1)[-1]+ n = n.rsplit(".", 1)[0].strip().lower()+ if len(n) >= 3:+ _BRIDGE_DOCS.add(n)+ while len(_BRIDGE_DOCS) > 24:+ _BRIDGE_DOCS.pop()+ try:+ _set_bridge_pref("bridgeDocs", sorted(_BRIDGE_DOCS))+ except Exception:+ pass+++try: # reload the persisted registry at import (respawn survival)+ _BRIDGE_DOCS.update(x for x in (_get_bridge_pref("bridgeDocs", []) or [])+ if isinstance(x, str) and len(x) >= 3)+except Exception:+ pass+++def _is_bridge_doc_text(text: str):+ t = (text or "").lower()+ return (next((n for n in _BRIDGE_DOCS if n in t), None)+ or next((p for p in _BRIDGE_DOC_BUILTINS if p in t), None))+++def _find_save_prompt() -> dict:+ """Is Fusion's unsaved-doc Save prompt up? Two shapes seen live: a CEF modal with a+ UIA-reachable "Don't Save" button, and a bare Qt dialog titled just "Save" with NO+ UIA tree at all (ConfRoomROG, 2026-08-16 - it blocked the main thread invisibly).+ Returns {found, hwnd, button|None, text} - never raises."""+ try:+ info = get_fusion_window_info() or {}+ for d in info.get("dialogs") or []:+ h = d.get("hwnd")+ if not h:+ continue+ title = str(d.get("title") or "").strip().lower()+ for label in (() if h in _NO_DONTSAVE_HWNDS else _DONT_SAVE_LABELS):+ r = _find_controls_any(_ad_call("desktop_find_control",+ {"hwnd": int(h), "name": label}, timeout=10))+ best = r.get("best") or {}+ if best.get("name") and best.get("invokable"):+ text = ""+ try:+ t = _find_controls_any(_ad_call("desktop_find_control",+ {"hwnd": int(h), "name": "save"}, timeout=10))+ for c in ([t.get("best") or {}] + list(t.get("controls") or [])):+ nm = str(c.get("name") or "")+ if len(nm) > len(text):+ text = nm+ except Exception:+ pass+ return {"found": True, "hwnd": int(h), "button": best.get("name"), "text": text}+ if title in ("save", "save changes", "unsaved changes"):+ # UIA-less Qt variant: identified by title alone; no clickable button handle.+ _NO_DONTSAVE_HWNDS.add(h) # never pay the 2x10s UIA probe for this hwnd again+ return {"found": True, "hwnd": int(h), "button": None, "text": ""}+ except Exception:+ pass+ return {"found": False}++++def _close_idle_bridge_docs() -> dict:+ """Close BRIDGE-created documents that are sitting DIRTY while nothing is driving.++ John, 2026-08-20: "why are you always leaving annoying dialog boxes up like this?"+ Dismissing the Save prompt was only ever treating the symptom. The prompt exists+ because our surfaces/tour runs leave the staged document MODIFIED and open, so the+ next thing that tries to close it asks the user to decide about OUR throwaway edits.+ A prompt we have to dismiss is a mess we should not have made.++ Only touches documents in the bridge-doc registry, only while no driving verb has run+ for a few minutes (so a demo in progress is never yanked out from under itself), and+ always with save:false - these are our scratch edits, never the user's work.+ """+ import time as _t+ try:+ idle_for = _t.time() - float(_LAST_DRIVE_AT[0] or 0)+ except Exception:+ idle_for = 1e9+ if idle_for < 180:+ return {"closed": [], "why": "driving activity within 3 minutes - leaving documents alone"}+ try:+ st = _proxy_to_addin("document_info", {}, timeout=6)+ except Exception:+ return {"closed": [], "why": "add-in unreachable"}+ if not st.get("success"):+ return {"closed": [], "why": "no document info"}+ docs = (st.get("data") or {}).get("openDocuments") or []+ closed = []+ for d in docs:+ name = str(d.get("name") or "")+ dirty = bool(d.get("isModified") or d.get("modified") or name.endswith("*"))+ if not name or not dirty:+ continue+ if not _is_bridge_doc_text(name):+ continue # the user's document - never ours to close+ try:+ r = _close_bridge_doc_verified(name)+ if r.get("closed"):+ closed.append(name)+ print("[Fusion Bridge] janitor: closed dirty bridge doc %r (verified gone, "+ "any Save-As answered in the same call)" % name)+ _bridge_action_ledger("idle_doc_close", {"doc": name})+ except Exception:+ pass+ return {"closed": closed, "why": "closed %d dirty bridge doc(s)" % len(closed)}+++def _dismiss_save_prompt_if_bridge_doc() -> dict:+ """Auto-dismiss the Save prompt ONLY for bridge-created docs. Ladder:+ 1. Prompt text names a bridge doc -> UIA-click "Don't Save" (background, discards OUR+ throwaway edits). 2. Text unreadable (UIA-less Qt variant) but the MAIN WINDOW+ title names a bridge doc -> post ESCAPE (= Cancel: non-destructive for ANY doc,+ just unblocks the main thread; the doc stays open unsaved). UIA click falling+ back to Escape also covers ab's per-command permission gate blocking desktop_ui_click.+ A prompt for a USER's document is never touched. Returns {handled, why} - never raises."""+ prompt = _find_save_prompt()+ if not prompt.get("found"):+ return {"handled": False, "why": "no save prompt"}+ text = prompt.get("text") or ""+ matched = _is_bridge_doc_text(text)+ main_title = ""+ if not matched:+ try:+ main_title = str((get_fusion_window_info() or {}).get("title") or "")+ except Exception:+ pass+ matched = _is_bridge_doc_text(main_title)+ if not matched:+ # UNATTRIBUTABLE prompt (the spawning doc already closed, so no title names it -+ # exactly how a standing prompt looks after a close-flow; caught live 2026-08-17).+ # Escape = CANCEL: non-destructive for ANYONE's document (the close is cancelled,+ # the doc stays open, nothing saved or discarded). A modal that blocks the add-in+ # main thread for 2+ sweeps gets escaped rather than sat on; Don't Save is still+ # NEVER clicked without attribution.+ # ZOMBIE GATE (2026-08-17, found live on ConfRoomROG): "Save"-titled windows can be+ # non-modal GHOSTS that block nothing (main thread fully responsive, PrintWindow+ # black, no UIA). Escaping those every other tick is pointless input spam - only+ # escalate on an unattributed prompt when the add-in main thread is ACTUALLY+ # blocked (status probe busy or unanswerable).+ try:+ _st = _check_addin_status(timeout=2.0)+ _blocked = (_st is None) or bool(_st.get("busy"))+ except Exception:+ _blocked = True+ if not _blocked:+ return {"handled": False,+ "why": "unattributed dialog but the main thread is responsive - zombie window, leaving it alone",+ "hwnd": prompt["hwnd"]}+ n = _UNATTRIBUTED_PROMPT_STRIKES.get(prompt["hwnd"], 0) + 1+ _UNATTRIBUTED_PROMPT_STRIKES[prompt["hwnd"]] = n+ if n >= 2:+ try:+ esc = send_key_background("escape", hwnd=prompt["hwnd"])+ if esc.get("success"):+ print("[Fusion Bridge] save-prompt sentinel: ESCAPED an unattributed prompt "+ "after %d sightings (Cancel - non-destructive, main thread freed)" % n)+ _UNATTRIBUTED_PROMPT_STRIKES.pop(prompt["hwnd"], None)+ return {"handled": True, "why": "escaped unattributed prompt (Cancel) after %d sightings" % n}+ except Exception:+ pass+ return {"handled": False, "why": "unattributed save prompt - strike %d of 2 before Escape" % n,+ "hwnd": prompt["hwnd"]}+ if prompt.get("button"):+ r = _unwrap(_ad_call("desktop_ui_click",+ {"hwnd": prompt["hwnd"], "name": prompt["button"]}, timeout=15))+ if r.get("invoked") or r.get("success"):+ print("[Fusion Bridge] save-prompt sentinel: clicked %r for bridge doc %r (background)"+ % (prompt["button"], matched))+ return {"handled": True, "why": "clicked '%s' for bridge doc '%s'" % (prompt["button"], matched)}+ # ATTRIBUTED to a bridge doc, so "Don't Save" is the RIGHT answer, not Escape. Escape+ # cancels the close and leaves the doc dirty, which guarantees the same prompt returns -+ # that loop is exactly what John saw twice in a row (2026-08-20). Try the decisive+ # button first; Escape stays as the fallback when UIA is unavailable.+ try:+ r2 = _unwrap(_ad_call("desktop_ui_click",+ {"hwnd": prompt["hwnd"], "name": "Don't Save"}, timeout=15))+ if r2.get("invoked") or r2.get("success"):+ print("[Fusion Bridge] save-prompt sentinel: clicked \'Don\'t Save\' for bridge doc %r "+ "(attributed by window title)" % matched)+ _bridge_action_ledger("save_prompt_dismiss", {"doc": matched, "how": "dont_save"})+ return {"handled": True, "why": "clicked Don't Save for bridge doc '%s'" % matched}+ except Exception:+ pass+ # Escape = Cancel: always non-destructive, needs no UIA and no ab input grant.+ try:+ esc = send_key_background("escape", hwnd=prompt["hwnd"])+ if esc.get("success"):+ print("[Fusion Bridge] save-prompt sentinel: ESCAPED the prompt for bridge doc %r "+ "(Cancel - doc stays open unsaved, main thread freed)" % matched)+ _bridge_action_ledger("save_prompt_dismiss", {"doc": matched, "how": "escape"})+ return {"handled": True, "why": "escaped (Cancel) for bridge doc '%s'" % matched}+ except Exception:+ pass+ return {"handled": False, "why": "dismiss attempts did not land", "hwnd": prompt["hwnd"]}+++class _SavePromptSentinel:+ """Context manager: while a doc-switching add-in call runs, poll for the Save prompt+ and auto-dismiss it (bridge docs only) so the call never wedges behind the modal."""++ def __init__(self, max_seconds: float):+ import time as _t+ self.max_seconds = max(5.0, float(max_seconds))+ self._deadline = _t.time() + self.max_seconds+ self._stop = threading.Event()+ self._hits = []++ def soft_stop(self, grace_seconds: float = 12.0):+ """Shrink the watch window instead of killing it: keeps covering the just-returned+ call for a short grace period (the post-timeout prompt is the wedge case)."""+ import time as _t+ self._deadline = min(self._deadline, _t.time() + grace_seconds)++ def __enter__(self):+ def _watch():+ import time as _t+ while not self._stop.is_set() and _t.time() < self._deadline:+ try:+ info = get_fusion_window_info() or {}+ if info.get("dialogs"):+ got = _dismiss_save_prompt_if_bridge_doc()+ if got.get("handled"):+ self._hits.append(got)+ except Exception:+ pass+ self._stop.wait(1.0)+ self._thread = threading.Thread(target=_watch, daemon=True)+ self._thread.start()+ return self++ def __exit__(self, *exc):+ self._stop.set()+ return False+++# Add-in commands that switch/open documents and can pop the Save prompt mid-flight.+_DOC_SWITCHING_ADDIN = {+ "open_lbr", "open_cloud_file", "open_by_urn", "open_schematic", "open_board",+ "show_schematic", "show_2d_board", "show_3d_board", "import_electronics",+ "execute_text_command", "electron_run", "new_electronics_from_eagle",+}+++def _license_classify_extend(dialogs: list):+ """ab's #30 ask: the Autodesk seat modal ('Active Sessions Exceeded' / 'Suspend Remote+ Session') often carries a bare Win32 title, so the title-substring classifier files it+ as generic_modal - undecidable for a caller. find_licensing_dialog() detects it by+ OWNERSHIP + SHAPE instead, so when it fires, append the decidable category (mutates+ the passed list in place; never raises)."""+ try:+ if any(d.get("category") == "license_sessions_exceeded" for d in dialogs):+ return+ dlg = find_licensing_dialog()+ if not dlg:+ return+ auto = _seat_takeover_enabled()+ dialogs.append({+ "category": "license_sessions_exceeded",+ "title": dlg.get("title") or "",+ "hwnd": dlg.get("hwnd"),+ "resolution": (+ "Autodesk seat modal: this account's Fusion is active on another machine. "+ + ("Auto-takeover is ON - the bridge clicks Continue through both dialogs in the "+ "background (~20s sweep; a fusion_start resolves it immediately). The other "+ "machine's Fusion suspends RESUMABLY."+ if auto else+ "Auto-takeover is OPTED OUT (fusion_set_seat_takeover) - resolve the dialog "+ "manually or free the seat with fusion_stop on the other machine.")),+ "autoHandled": auto,+ })+ except Exception:+ pass+++def _handle_fusion_state(fusion_info: dict, args: dict) -> dict:+ """fusion_state - the render-me verb (#28; kicad_state is the reference shape).++ SERVER-SIDE ONLY on purpose: it must answer even when the add-in main thread is busy+ or wedged (ab's demo falls back to window_info + desktop screenshots today exactly+ because a state verb has to work in those moments). Returns running/window/dialog+ state + a fresh background screenshot of the Fusion window; activeDocument is+ best-effort via the add-in with a short budget.++ args: screenshot (default true) | imageAs: "path" (default) | "base64" (inline PNG,+ auto-refused over ~4MB - pull the path via read_file instead)."""+ running = _is_fusion_running()+ result = {"success": True, "running": running}+ # THESE TWO SURVIVE A CLOSED FUSION, ON PURPOSE (caught re-testing #82, 2026-08-20).+ # focusEvents is the QUIETNESS AUDIT LOG and timings is on-disk measured history -+ # neither needs the app to be up, and both are most likely to be asked for exactly when+ # it is NOT (after a run, reviewing what the bridge did). They used to sit below the+ # not-running early return, so an auditor closing Fusion lost the evidence.+ try:+ with _FOCUS_LOCK:+ result["focusEvents"] = list(_FOCUS_EVENTS[-20:])+ except Exception:+ result["focusEvents"] = []+ try:+ import timings as _tm0+ result["timings"] = _tm0.summary()+ _s0 = _tm0.sentence()+ if _s0:+ result["timingsSentence"] = _s0+ except Exception:+ pass+ if not running:+ result["_hint"] = ("Fusion is not running - nothing to render. fusion_start launches it "+ "in the background; fusion_demo {mode:'tour'} cold-starts it itself. "+ "focusEvents and timings ARE still reported - they do not need Fusion.")+ result["output"] = json.dumps({"running": False})+ return result+ info = get_fusion_window_info() or {}+ result["hwnd"] = info.get("hwnd")+ result["title"] = info.get("title")+ result["rect"] = info.get("rect")+ result["dialogs"] = info.get("dialogs") or []+ try:+ result["blockingDialogs"] = classify_blocking_dialogs() or []+ except Exception:+ result["blockingDialogs"] = []+ _license_classify_extend(result["blockingDialogs"])+ # Busy state (the add-in's own report; 3s budget, never blocks the state read).+ busy = None+ try:+ st = _check_addin_status(timeout=3.0)+ if st:+ result["addinResponding"] = True+ if st.get("busy"):+ busy = {"command": st.get("busyCommand"), "elapsedSeconds": st.get("elapsedSeconds")}+ else:+ result["addinResponding"] = False+ except Exception:+ result["addinResponding"] = False+ result["mainThreadBusy"] = busy+ try:+ with _FOCUS_LOCK:+ result["focusEvents"] = list(_FOCUS_EVENTS[-20:])+ except Exception:+ result["focusEvents"] = []+ # WHAT TO EXPECT, BEFORE THE CLICK (issue #32 section 2): this machine's own measured+ # launch history, so a UI can say "typically 45s cold, 3s warm here" instead of making+ # the user discover it by waiting.+ try:+ import time as _tnow+ import timings as _tm+ result["timings"] = _tm.summary()+ _s = _tm.sentence()+ if _s:+ result["timingsSentence"] = _s+ _lsa = _get_bridge_pref("launchStartedAt", None)+ if _lsa and not result.get("addinResponding") and (_tnow.time() - float(_lsa)) < 900:+ result["progress"] = _tm.progress_block(+ "launch", float(_lsa),+ "Fusion is starting - waiting for the Adom add-in to serve",+ start_kind="cold", fallback=150)+ except Exception:+ pass+ try:+ from handlers.taskbar_overlay import badge_active, last_error as _badge_err+ result["drivingBadge"] = {"active": badge_active(),+ "lastDrivingVerbAt": _LAST_DRIVE_AT[0] or None,+ "idleShedSeconds": _BADGE_IDLE_SECONDS,+ "enabled": _driving_badge_enabled(),+ "lastError": _badge_err()}+ except Exception:+ pass+ # Active document, best-effort (skip when busy - it would just be rejected).+ if result.get("addinResponding") and not busy:+ try:+ st = _proxy_to_addin("get_app_state", {}, timeout=5)+ result["activeDocument"] = (st.get("data") or {}).get("activeDocument")+ except Exception:+ pass+ # The screenshot: PrintWindow-based, background, no focus taken.+ if args.get("screenshot", True):+ try:+ shot = screenshot_fusion_window()+ if shot.get("success"):+ result["screenshot"] = shot.get("savedTo")+ if args.get("imageAs") == "base64":+ import base64 as _b64+ raw = open(shot["savedTo"], "rb").read()+ if len(raw) <= 4 * 1024 * 1024:+ result["screenshotBase64"] = _b64.b64encode(raw).decode("ascii")+ else:+ # DOWNSCALE instead of refusing (ralph, 2026-08-18: a PIL-less bridge+ # saves 24MB BMPs, so every Dashboard surface card came back with no+ # image at all). Same path the tour gallery uses.+ small = _shot_b64_small(shot["savedTo"], max_w=1280)+ if small:+ result["screenshotBase64"] = small+ result["screenshotNote"] = ("downscaled to <=1280px (raw capture is "+ "%dMB) - pull result.screenshot for full size"+ % (len(raw) // (1024 * 1024)))+ else:+ result["screenshotNote"] = ("capture is %dKB (>4MB inline cap) and could "+ "not be downscaled - pull result.screenshot "+ "via read_file." % (len(raw) // 1024))+ except Exception as e:+ result["screenshotError"] = str(e)+ hints = []+ if busy:+ hints.append("Main thread busy (%s, %ss) - add-in verbs are rejected right now; "+ "window/screenshot verbs still work." % (busy["command"], busy["elapsedSeconds"]))+ if result["blockingDialogs"]:+ hints.append("A modal is up - see blockingDialogs for the classified cause + resolution.")+ if not hints:+ hints.append("Fusion is clear. The screenshot shows the current surface - this is the verb "+ "to render any fusion_show_* result (they open in the background by design).")+ result["_hint"] = " ".join(hints)+ result["output"] = json.dumps({"running": True, "activeDocument": result.get("activeDocument"),+ "mainThreadBusy": busy, "blockingDialogs": result["blockingDialogs"],+ "screenshot": result.get("screenshot")})+ return result+++COMMAND_HANDLERS["state"] = _handle_fusion_state+++# ── 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": "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"):+ # Enabled-ness is a persistent Fusion PREFERENCE, but it is only observable while+ # Fusion runs. Report the last GOOD observation so a closed Fusion reads as+ # "configured, dormant" instead of broken (John, 2026-08-17).+ return {"success": True, "enabled": False, "url": _MCP_URL, "error": s.get("error"),+ "lastEnabledAt": _get_bridge_pref("mcpLastEnabledAt", None),+ "fusionRunning": _is_fusion_running(),+ "_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 [])]+ try:+ import datetime as _dt+ _set_bridge_pref("mcpLastEnabledAt", _dt.datetime.now().isoformat(timespec="seconds"))+ except Exception:+ pass+ 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. Re-call fusion_mcp_enable - driving Preferences is "+ "racy on a busy main thread and a second pass usually lands it. Do NOT hand the "+ "user a Preferences walkthrough (describe.py says NEVER ask them to do this by "+ "hand); if a second pass also fails, report what the steps[] show and that the "+ "setting is discarded when Fusion restarts before Apply."),+ "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+++_last_seat_takeover_notice = [0.0]++# ── Driving badge lifecycle (John, 2026-08-17: "after we're done controlling fusion, shed+# the badge like nb does - after like 5 or 10 minutes of inactivity") ────────────────────+# Badge ON: any DRIVING verb dispatch while Fusion runs (set here, throttled in the helper).+# Badge OFF: the janitor sheds it once no driving verb has run for _BADGE_IDLE_SECONDS.+# PASSIVE reads are excluded from "driving" ON PURPOSE: the Dashboard polls readiness every+# 15s and pollers poll demo_progress every ~2s - counting those would pin the badge forever.+_BADGE_IDLE_SECONDS = 300 # John said 5-10 min; 5 keeps the taskbar honest sooner+_LAST_DRIVE_AT = [0.0]+_BADGE_PASSIVE = {+ "readiness", "state", "window_info", "describe", "demo_progress", "addin_status",+ "aps_status", "mcp_status", "get_preferences", "targets", "screenshot_fusion",+ "screenshot_all", "check_dialogs", "set_auto_update", "set_seat_takeover",+ "uninstall", "get_app_state", "board_info", "document_info",+}+++def _note_driving_activity(command: str):+ """Called from dispatch for every verb: driving verbs stamp the activity clock and+ ensure the badge is on (async - a 6s desktop_taskbar call must never slow a verb)."""+ if command in _BADGE_PASSIVE:+ return+ import time as _t+ _LAST_DRIVE_AT[0] = _t.time()++ def _ensure():+ # RETRY, do not fire-and-forget: `start` is a driving verb dispatched BEFORE Fusion's+ # window exists, so a single attempt always found no hwnd and the badge never landed+ # (John caught exactly this after auto-green launched Fusion, 2026-08-18).+ try:+ from handlers.taskbar_overlay import set_driving_badge+ from fusion_detect import _fusion_main_hwnd_quick+ import time as _tt+ if not _driving_badge_enabled():+ return+ for _ in range(30): # ~90s, covers a cold Fusion launch+ if _is_fusion_running():+ hw = _fusion_main_hwnd_quick()+ if hw and set_driving_badge(hw):+ return+ _tt.sleep(3)+ except Exception:+ pass+ threading.Thread(target=_ensure, daemon=True).start()++++def _window_sizing_enabled() -> bool:+ """Default TRUE: a Fusion window the user has to squint at is a bug, not a preference."""+ return bool(_get_bridge_pref("sizeFusionWindow", True))+++def _handle_set_window_sizing(fusion_info: dict, args: dict) -> dict:+ """Read/toggle auto-sizing Fusion's window to ~92% of the screen after launch. STICKY."""+ if "enabled" not in (args or {}):+ cur = _window_sizing_enabled()+ return {"success": True, "sizeFusionWindow": cur,+ "_hint": ("Fusion window auto-sizing is %s. ON BY DEFAULT: after a launch the "+ "bridge grows a small Fusion window to ~92%% of the monitor work area, "+ "centered, WITHOUT activating it (SetWindowPos NOACTIVATE, never "+ "ShowWindow/maximize, so your focus is never stolen). A window you "+ "maximized or already sized large is left alone. Opt out: "+ "fusion_set_window_sizing {\"enabled\": false} - STICKY."+ % ("ON" if cur else "OFF")),+ "statusVerb": "fusion_state"}+ enabled = bool(args.get("enabled"))+ _set_bridge_pref("sizeFusionWindow", enabled)+ return {"success": True, "sizeFusionWindow": enabled,+ "narrate": ("I'll size Fusion's window to fill most of your screen when it launches."+ if enabled else "I'll leave Fusion's window size exactly as Fusion opens it."),+ "statusVerb": "fusion_state"}+++def _driving_badge_enabled() -> bool:+ """Default TRUE. The taskbar overlay is identity, not noise - but a user who dislikes it+ can switch it off permanently (John, 2026-08-18)."""+ return bool(_get_bridge_pref("showDrivingBadge", True))+++def _handle_set_driving_badge(fusion_info: dict, args: dict) -> dict:+ """Read/toggle the 'Adom is driving Fusion' taskbar overlay badge. STICKY."""+ if "enabled" not in (args or {}):+ cur = _driving_badge_enabled()+ return {"success": True, "showDrivingBadge": cur,+ "_hint": ("The driving badge is currently %s. ON BY DEFAULT: while Adom drives "+ "Fusion, its taskbar button wears a small Adom mark so the user knows "+ "at a glance who is in control; it sheds after 5 minutes idle. Opt out: "+ "fusion_set_driving_badge {\"enabled\": false} - STICKY."+ % ("ON" if cur else "OFF")),+ "statusVerb": "fusion_state"}+ enabled = bool(args.get("enabled"))+ _set_bridge_pref("showDrivingBadge", enabled)+ if not enabled:+ try:+ from handlers.taskbar_overlay import clear_driving_badge+ clear_driving_badge()+ except Exception:+ pass+ return {"success": True, "showDrivingBadge": enabled,+ "narrate": ("I'll badge Fusion's taskbar button while I'm driving it."+ if enabled else "I'll stop badging Fusion's taskbar button."),+ "_hint": "Sticky (~/.adom/fusion-bridge/prefs.json). Cleared immediately when turned off.",+ "statusVerb": "fusion_state"}+++def _seat_takeover_enabled() -> bool:+ """Default TRUE (John: "always just take over the session"). Sticky opt-out for users+ who'd rather resolve seat conflicts themselves - mirrors autoUpdateFusion."""+ return bool(_get_bridge_pref("autoTakeOverSeat", True))+++def _handle_set_seat_takeover(fusion_info: dict, args: dict) -> dict:+ """Read/toggle the mid-session seat-takeover sweep. STICKY per user. Scope note: this+ gates only the PASSIVE 20s sweep; a verb-driven fusion_start still grabs the seat+ (the user asked for Fusion, so taking the seat is implied - John's July rule)."""+ if "enabled" not in (args or {}):+ cur = _seat_takeover_enabled()+ return {"success": True, "autoTakeOverSeat": cur,+ "_hint": ("Seat takeover is currently %s. ON BY DEFAULT: when Autodesk pops the "+ "'Active Sessions Exceeded' dialogs mid-session, the bridge clicks "+ "Continue through BOTH in the background - this machine takes the seat, "+ "the other machine's Fusion suspends RESUMABLY, one toast tells the user. "+ "Opt out: fusion_set_seat_takeover {\"enabled\": false} - STICKY."+ % ("ON" if cur else "OFF")),+ "statusVerb": "fusion_readiness"}+ enabled = bool(args.get("enabled"))+ _set_bridge_pref("autoTakeOverSeat", enabled)+ return {"success": True, "autoTakeOverSeat": enabled,+ "narrate": ("I'll automatically take the Fusion seat on this machine whenever "+ "Autodesk flags the session limit." if enabled else+ "I'll leave Autodesk seat-conflict dialogs for you to resolve yourself."),+ "_hint": ("Sticky (~/.adom/fusion-bridge/prefs.json). %s"+ % ("Takeover ON: mid-session seat dialogs are clicked through silently."+ if enabled else+ "Takeover OFF: the sweep will NOT touch seat dialogs; fusion_start "+ "still grabs the seat when the user asks for Fusion.")),+ "statusVerb": "fusion_readiness"}+++# Registered HERE, next to the def - naming it in the COMMAND_HANDLERS literal (built ~3000+# lines earlier) is the import-time NameError that crash-looped the bridge on 2026-07-22.+COMMAND_HANDLERS["set_seat_takeover"] = _handle_set_seat_takeover+COMMAND_HANDLERS["set_driving_badge"] = _handle_set_driving_badge+COMMAND_HANDLERS["set_window_sizing"] = _handle_set_window_sizing+++def _seat_sweep_tick():+ """ALWAYS take over the Autodesk seat (John, 2026-08-16: "always just take over the+ session"). The launch/readiness paths already auto-resolve the 'Active Sessions+ Exceeded' -> 'Suspend Remote Session' dialog pair, but a MID-SESSION seat conflict+ (Autodesk pops it whenever another machine signs in) sat unhandled until someone+ happened to poll readiness. This tick runs from the janitor loop every ~20s: cheap+ no-op when no dialog (local Win32 window walk), and when one IS up it clicks+ Continue on BOTH dialogs in the background (Suspend pre-selected = this machine+ takes the seat; the other machine's Fusion suspends resumably, nothing is lost).+ Toasts the user AFTER a real takeover so they know the other box got suspended."""+ # ⚠ SECTION SCOPING (v1.9.59, found by the badge-shed watcher): this tick has THREE+ # independent jobs - seat takeover, badge shed, save-prompt sweep. The seat section's+ # old early `return`s exited the WHOLE function on the no-dialog (normal!) path, so+ # the badge NEVER shed and the save sweep NEVER ran unless a seat dialog was up.+ # Each section is now self-contained; none may `return`.+ try:+ if _seat_takeover_enabled() and find_licensing_dialog():+ _seat_resolve_section()+ except Exception:+ pass++ # Keep Fusion's window a usable size (once per hwnd; never touches a maximized or+ # already-large window, never activates).+ try:+ if _window_sizing_enabled() and _is_fusion_running():+ from fusion_detect import _fusion_main_hwnd_quick as _hwq+ _h = _hwq()+ _n = _SIZE_ATTEMPTS.get(_h, 0) if _h else 99+ if _h and _n < 6: # ~2 minutes of sweeps, then leave it alone forever+ _r = ensure_reasonable_window(hwnd=_h)+ _why = str(_r.get("why") or "")+ if "already" in _why: # large enough or user-maximized -> converged, stop+ _SIZE_ATTEMPTS[_h] = 99+ else:+ _SIZE_ATTEMPTS[_h] = _n + 1+ if _r.get("sized"):+ print("[Fusion Bridge] window sized on sweep (attempt %d): %s" % (_n + 1, _why))+ except Exception:+ pass+ # Self-heal the badge: if driving is RECENT and Fusion is up but the badge is missing+ # (launch race, respawn, a manual clear), put it back on the next tick.+ try:+ from handlers.taskbar_overlay import badge_active as _ba, set_driving_badge as _sb+ from fusion_detect import _fusion_main_hwnd_quick as _hw+ if (_driving_badge_enabled() and not _ba() and _LAST_DRIVE_AT[0]+ and (_t2.time() - _LAST_DRIVE_AT[0]) <= _BADGE_IDLE_SECONDS and _is_fusion_running()):+ h = _hw()+ if h:+ _sb(h)+ except Exception:+ pass+ # Shed the driving badge after idle (John: "so the user knows we are DONE controlling").+ try:+ from handlers.taskbar_overlay import badge_active, clear_driving_badge+ if badge_active() and _LAST_DRIVE_AT[0] and (_t2.time() - _LAST_DRIVE_AT[0]) > _BADGE_IDLE_SECONDS:+ if clear_driving_badge():+ print("[Fusion Bridge] driving badge shed after %ds idle" % _BADGE_IDLE_SECONDS)+ except Exception:+ pass+ # DIRECT "Unsaved changes" catcher (John's blocked-screen screenshot, 2026-08-24:+ # a graceful stop wedged TEN MINUTES behind this dialog; the sentinel's Escape+ # fallback CANCELS the close, so the stop fought its own dialog). Find the dialog by+ # its title at the desktop level, prove the doc it names is OURS via UIA text, and+ # click Don't Save. Never touches a dialog naming a user's document.+ try:+ from handlers.dismiss_recovery import _find_window_by_title as _fwt2+ _uh = _fwt2("Unsaved changes")+ if _uh:+ _ours = None+ for _probe in tuple(_BRIDGE_DOCS)[:8] + _BRIDGE_DOC_BUILTINS:+ _rr = _find_controls_any(_ad_call("desktop_find_control",+ {"hwnd": int(_uh), "contains": _probe}, timeout=8))+ if (_rr.get("best") or {}).get("name"):+ _ours = _probe+ break+ if _ours:+ _ck2 = _unwrap(_ad_call("desktop_ui_click",+ {"hwnd": int(_uh), "name": "Don't Save"}, timeout=10))+ _bridge_action_ledger("unsaved_changes_dont_save",+ {"hwnd": int(_uh), "docMatch": _ours,+ "clicked": bool(_ck2.get("invoked") or _ck2.get("success"))})+ print("[Fusion Bridge] janitor: answered the Unsaved-changes dialog with "+ "Don't Save (bridge doc %r)" % _ours)+ except Exception:+ pass+ # Same tick also sweeps a standing Save prompt for BRIDGE-CREATED docs (2026-08-16:+ # a bare "Save" Qt modal from a killed session blocked the main thread invisibly and+ # failed two tours in a row - the sentinel only ran during doc-switch calls).+ try:+ info = get_fusion_window_info() or {}+ if info.get("dialogs"):+ _dismiss_save_prompt_if_bridge_doc()+ except Exception:+ pass+ # PREVENTION beats dismissal: close our own dirty scratch docs while nothing is+ # driving, so the Save prompt never gets raised at the user in the first place.+ # Self-contained like every other section here - it must never be able to starve+ # the sections after it (the early-return bug that broke this sweep before).+ try:+ _close_idle_bridge_docs()+ except Exception:+ pass+ # A Create-new prompt from ANY route (not just our own EDIT) blocks the main thread.+ # Answer No: it means a name did not exist, and inventing an empty artifact would hide+ # that. Self-contained, like every other section in this sweep.+ try:+ for _d in (classify_blocking_dialogs() or []):+ if _d.get("category") == "create_confirm":+ _unwrap(_ad_call("desktop_ui_click", {"hwnd": _d.get("hwnd"), "name": "No"}, timeout=12))+ print("[Fusion Bridge] janitor: declined a stray Create-new prompt (%s)"+ % _d.get("matchedOn", "title"))+ _bridge_action_ledger("create_confirm_declined", {"matchedOn": _d.get("matchedOn")})+ except Exception:+ pass+ # Autodesk's CRASH REPORTER ("Fusion Error Report", its own process - it outlives+ # Fusion, so it is NOT in the Fusion window family and no other sweep sees it).+ # John hit one on screen after the #50 seat-suspension day (2026-08-23). Two jobs:+ # (1) LEDGER it - a crash reporter is a timestamped "Fusion really crashed" marker,+ # exactly the forensic breadcrumb #50 lacked; (2) CLOSE it quietly so the user never+ # babysits it. Always "Close", never "Send Report": submitting crash data to Autodesk+ # is the user's call, not the janitor's. Self-contained like every section here.+ try:+ # LOCAL Win32 walk, not a relay verb: the relay envelope nests results under a+ # stringified `output` and the first version of this section parsed it wrong, so+ # a wedged black cer_dialog sat on John's taskbar for two hours (2026-08-23).+ # CER is its own process (cer_dialog.exe) that outlives Fusion; when its parent+ # died abruptly it renders BLACK and answers no UIA - escalate: WM_CLOSE, then+ # (only for a confirmed cer_dialog pid) a single-pid terminate. Never Send Report.+ import ctypes as _ct+ from handlers.dismiss_recovery import _find_window_by_title as _fwt+ _ch = _fwt("Fusion Error Report")+ if _ch and not _host_op_active():+ _u32 = _ct.windll.user32+ _pid = _ct.c_ulong(0)+ _u32.GetWindowThreadProcessId(_ct.c_void_p(int(_ch)), _ct.byref(_pid))+ _bridge_action_ledger("crash_reporter_seen", {"hwnd": int(_ch), "pid": _pid.value})+ _u32.PostMessageW(_ct.c_void_p(int(_ch)), 0x0010, 0, 0) # WM_CLOSE+ _t2.sleep(2)+ if _u32.IsWindow(_ct.c_void_p(int(_ch))):+ # still standing: confirm the pid really is cer_dialog before terminating+ try:+ import subprocess as _sp+ _nm = _sp.run(["tasklist", "/FI", "PID eq %d" % _pid.value, "/FO", "CSV", "/NH"],+ capture_output=True, text=True, timeout=10,+ creationflags=getattr(_sp, "CREATE_NO_WINDOW", 0)).stdout+ if "cer_dialog" in _nm.lower():+ _sp.run(["taskkill", "/PID", str(_pid.value), "/F"],+ capture_output=True, timeout=10,+ creationflags=getattr(_sp, "CREATE_NO_WINDOW", 0))+ _bridge_action_ledger("crash_reporter_killed",+ {"pid": _pid.value, "why": "wedged (WM_CLOSE ignored)"})+ except Exception:+ pass+ else:+ _bridge_action_ledger("crash_reporter_dismissed", {"hwnd": int(_ch), "closed": True})+ print("[Fusion Bridge] janitor: Fusion crash reporter handled (ledgered; never "+ "Send Report on the user's behalf)")+ except Exception:+ pass+++def _seat_resolve_section():+ try:+ r = _resolve_seat_dialog(max_clicks=3)+ if r.get("clicks"):+ now = _t2.time()+ if now - _last_seat_takeover_notice[0] > 120:+ _last_seat_takeover_notice[0] = now+ try:+ ad_client.notify(+ "Fusion seat taken over on this machine",+ "Autodesk flagged your account's session limit, so I clicked through the "+ "'Active Sessions Exceeded' dialogs in the background and took the seat here. "+ "Fusion on your other machine is SUSPENDED (resumable, nothing lost) - "+ "using Fusion there will simply grab the seat back the same way.",+ level="info", reach_user=True)+ except Exception:+ pass+ print("[Fusion Bridge] seat sweep: took over the session (clicks=%s, verified=%s)"+ % (r.get("clicks"), r.get("verified")))+ _bridge_action_ledger("seat_takeover",+ {"clicks": r.get("clicks"), "verified": r.get("verified")})+ except Exception:+ pass+++def _signin_janitor_loop():+ """Poll until each tracked sign-in window can be closed, then close it."""+ while True:+ try:+ _t2.sleep(20)+ _seat_sweep_tick()+ # 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()+ # Same sweep also clears the Recovered Documents dialog whenever it+ # shows up MID-SESSION or on a user-launched Fusion (launch-time+ # relocation only covers bridge-driven starts). Info-only dialog:+ # closing loses nothing (File > Recover Documents keeps the list),+ # so it is ALWAYS safe to close in the background. (John, 2026-08-16:+ # "anytime it comes up, just ignore it and close it".)+ try:+ if not _host_op_active():+ dismiss_recovery_dialog(background=True)+ except Exception:+ pass+ 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 _advance_credentials_page(hwnd, email) -> dict:+ """Self-drive the Autodesk credentials page as far as possible WITHOUT secrets+ (John, 2026-08-16: 'do as much self driving automation as you can... only let the+ AI know when you need it to know'). Background UIA only - no foreground, no focus+ steal: set the email/username field, invoke Next/Sign in, then classify what the+ page became. Returns {advanced, wall, detail}. NEVER touches password fields."""+ import time as _t+ out = {"advanced": False, "wall": "credentials", "detail": ""}+ try:+ found = _find_controls_any(_ad_call("desktop_find_control", {"hwnd": int(hwnd), "role": "edit"}, timeout=40))+ ctrls = found.get("controls") or []+ if not ctrls and found.get("best"):+ ctrls = [found["best"]]+ pwd = [c for c in ctrls if "password" in str(c.get("name", "")).lower()+ or "password" in str(c.get("automationId", "")).lower()]+ if pwd:+ out["wall"] = "password"+ out["detail"] = "password field present - the human step"+ return out+ emailish = [c for c in ctrls if any(k in (str(c.get("name", "")) + str(c.get("automationId", ""))).lower()+ for k in ("email", "username", "user id", "userid"))] or ctrls[:1]+ if email and emailish:+ c = emailish[0]+ r = _unwrap(_ad_call("desktop_ui_set", {"hwnd": int(hwnd),+ "automationId": c.get("automationId"),+ "name": c.get("name"), "value": email}, timeout=30))+ if r.get("success") or r.get("ok"):+ out["detail"] = "typed email %r into %r (background UIA)" % (email, c.get("name") or c.get("automationId"))+ for btn in ("Next", "NEXT", "Sign in", "Sign In", "Continue"):+ rb = _unwrap(_ad_call("desktop_ui_click", {"hwnd": int(hwnd), "name": btn}, timeout=25))+ if rb.get("success") or rb.get("ok") or rb.get("status") == "ok":+ out["advanced"] = True+ out["detail"] += "; invoked %r" % btn+ break+ if out["advanced"]:+ _t.sleep(4)+ found2 = _unwrap(_ad_call("desktop_find_control", {"hwnd": int(hwnd), "role": "edit"}, timeout=40))+ ctrls2 = found2.get("controls") or found2.get("matches") or []+ if not ctrls2 and found2.get("best"):+ ctrls2 = [found2["best"]]+ if any("password" in (str(c.get("name", "")) + str(c.get("automationId", ""))).lower() for c in ctrls2):+ out["wall"] = "password"+ out["detail"] += "; page advanced to the PASSWORD step"+ except Exception as e:+ out["detail"] = "advance error: " + str(e)[:120]+ 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."),+ "password": ("Just your password, everything else is done",+ "Adom drove the Fusion sign-in all the way: email entered, page advanced. "+ "The ONLY step left is your password (and I never type secrets). Type it in "+ "the browser window and everything continues automatically."),+ "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 _resolve_and_advance_wall(wall_info, email) -> dict:+ """Given a detected wall, push it as far as code can go (email typing on the+ credentials page), refining 'credentials' into 'password' when that is what+ remains. Returns possibly-updated wall_info with 'advance' detail attached."""+ if wall_info.get("wall") == "credentials" and wall_info.get("hwnd"):+ adv = _advance_credentials_page(wall_info["hwnd"], email)+ wall_info = dict(wall_info)+ wall_info["wall"] = adv.get("wall") or wall_info["wall"]+ wall_info["advance"] = adv.get("detail")+ wall_info["advanced"] = adv.get("advanced")+ return wall_info+++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 == "password":+ return (common ++ "WALL: PASSWORD ONLY. The bridge already typed the user's email and advanced the "+ "page (see steps[]) - the single remaining human act is typing their password in "+ "the browser window. Do NOT ask them for the password, do NOT try to type it, and "+ "do NOT re-drive the page. After they type it, the flow continues automatically - "+ "poll fusion_readiness until ready:true (a 2FA wall may follow; if it does you may "+ "be able to finish it yourself via fusion_signin_2fa + adom-google Gmail).")+ 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 _find_coordmap(obj, depth=0):+ """Recursively find a coordMap (with shotId) anywhere in a verb response - ab has+ moved it across envelope levels between versions (top-level sibling of data in some,+ nested under data in others, stringified inside output in 2.0.46's direct API).+ Returns the coordMap dict or {}."""+ if depth > 5:+ return {}+ if isinstance(obj, str) and obj[:1] in "[{":+ try:+ import json as _j+ return _find_coordmap(_j.loads(obj), depth + 1)+ except Exception:+ return {}+ if isinstance(obj, dict):+ cm = obj.get("coordMap")+ if isinstance(cm, dict) and cm.get("shotId"):+ return cm+ if obj.get("shotId"):+ return obj+ for k in ("data", "output", "result", "response"):+ got = _find_coordmap(obj.get(k), depth + 1)+ if got:+ return got+ return {}+++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)++ _raw = _ad_call("desktop_screenshot_window", {"hwnd": int(hwnd)}, timeout=45)+ shot = _unwrap(_raw)+ # ab has moved coordMap across envelope levels between versions (sibling of data /+ # nested / stringified in output on 2.0.46) - search recursively, never guess a level.+ cm = _find_coordmap(_raw)+ sid = cm.get("shotId")+ if not sid:+ # VERSION-PROOF fallback (ab 2.0.46's direct API returns no coordMap at all):+ # compute the click in SCREEN space from the window rect - no shotId needed.+ w_rect = (win.get("rect") or {})+ L = w_rect.get("left"); T = w_rect.get("top")+ W = w_rect.get("width") or 0; H = w_rect.get("height") or 0+ if L is not None and W > 400 and H > 300:+ sx = int(L + W * 0.5); sy = int(T + H * 0.57)+ rr = _unwrap(_ad_call("desktop_click", {"x": sx, "y": sy, "hwnd": int(hwnd),+ "reason": "signing Fusion in for you (clicking Sign In, screen-space fallback)"},+ timeout=30))+ okc = rr.get("success") or rr.get("ok") or rr.get("status") == "ok"+ return {"clicked": bool(okc),+ "why": "screen-space fallback click at (%d,%d) rect %sx%s: %s" % (sx, sy, W, H, "ok" if okc else str(rr.get("error"))[:80]),+ "via": "screen-fallback"}+ if not sid:+ return {"clicked": False,+ "why": "screenshot returned no shotId (hwnd=%s raw_keys=%s err=%s)" % (+ hwnd,+ sorted(list(_raw.keys()))[:8] if isinstance(_raw, dict) else type(_raw).__name__,+ str((_raw or {}).get("error"))[:100] if isinstance(_raw, dict) else "")}+ 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"):+ # Self-drive (John, 2026-08-16): never hand "go start Fusion" back to the caller.+ steps.append("Fusion was not running - launching it (background) as part of sign-in")+ try:+ dispatch_command("start", {})+ except Exception as e:+ steps.append("launch failed: " + str(e)[:80])+ import time as _lt+ deadline = _lt.time() + 120+ while _lt.time() < deadline:+ _lt.sleep(8)+ rd = _handle_fusion_readiness({}, {})+ if rd.get("running"):+ break+ if not rd.get("running"):+ return {"success": False, "stage": "launch_failed", "done": False, "steps": steps,+ "error": "Fusion did not come up within 120s of launch.",+ "_hint": "Check fusion_readiness; the box may be mid-update. Retry fusion_signin.",+ "statusVerb": "fusion_readiness"}+ _lt.sleep(5)+ 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()+ _email0 = args.get("email") or next((c.get("email") for c in choices if c.get("profile") == target and c.get("email")), None)+ if wall0.get("wall"):+ wall0 = _resolve_and_advance_wall(wall0, _email0)+ 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 {}+ _email1 = args.get("email") or next((c.get("email") for c in choices if c.get("profile") == target and c.get("email")), None)+ if wall.get("wall"):+ wall = _resolve_and_advance_wall(wall, _email1)+ 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))++ if not opened:+ # ABE-less box fallback (John, 2026-08-16: "push through as far as you can go from+ # your code"): the OAuth page is ALREADY OPEN in the browser Fusion fired (that is+ # where we captured the URL). No reopen is possible without the extension - so+ # DRIVE THE EXISTING WINDOW instead: detect the wall, type the email, advance to+ # the password step, toast the human there.+ wall_live = _detect_signin_wall()+ if wall_live.get("wall"):+ _email_fb = args.get("email")+ wall_live = _resolve_and_advance_wall(wall_live, _email_fb)+ wk = wall_live.get("wall")+ notified_fb = bool(_notify_signin_wall(wk).get("delivered")) if wk else False+ steps.append("no extension on this box - drove the EXISTING browser window instead: %s"+ % str(wall_live.get("advance") or "wall %r" % wk))+ return {"success": True, "stage": "driving_existing_window", "done": False,+ "steps": steps, "signinWall": wk, "signinWallHwnd": wall_live.get("hwnd"),+ "advanced": wall_live.get("advanced"), "notifyDelivered": notified_fb,+ "signinWallHint": _wall_hint(wk, notified_fb),+ "signinAuthUrl": auth["url"],+ "narrate": ("This machine has no Adom browser extension, so I drove the sign-in "+ "window Fusion already opened: %s." % str(wall_live.get("advance") or wk)),+ "_hint": ("No ABE here, so the EXISTING browser window was driven directly "+ "(email typed via background UIA where possible). " ++ _wall_hint(wk, notified_fb)),+ "statusVerb": "fusion_readiness"}+ 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"}+++++# ═══════════════════════════════════════════════════════════════════════════════+# Library SHOW verbs + bare-artifact wrapping (issue #29, John 2026-08-14):+# first-class opens for each library content type (symbol / footprint / 3D chip),+# accepting EITHER an existing .lbr + name OR a bare Fusion/EAGLE-formatted XML+# fragment which gets WRAPPED in a generated minimal library so it shows in+# Fusion's Library Manager. 3D chips ride the attach-after-open path because+# embedded <packages3d> in a hand-built .lbr is exactly the Fusion quirk that+# does not work reliably - attach post-open DOES.+# ═══════════════════════════════════════════════════════════════════════════════++_LBR_WRAP_SETTINGS = '<settings><setting alwaysvectorfont="no"/></settings>'+_LBR_WRAP_GRID = '<grid distance="0.1" unitdist="inch" unit="inch" style="lines" multiple="1" display="no" altdistance="0.01" altunitdist="inch" altunit="inch"/>'+_LBR_WRAP_LAYERS = '<layers>\n<layer number="1" name="Top" color="4" fill="1" visible="yes" active="yes"/>\n<layer number="16" name="Bottom" color="1" fill="1" visible="yes" active="yes"/>\n<layer number="17" name="Pads" color="2" fill="1" visible="yes" active="yes"/>\n<layer number="18" name="Vias" color="2" fill="1" visible="yes" active="yes"/>\n<layer number="19" name="Unrouted" color="6" fill="1" visible="yes" active="yes"/>\n<layer number="20" name="Dimension" color="15" fill="1" visible="yes" active="yes"/>\n<layer number="21" name="tPlace" color="7" fill="1" visible="yes" active="yes"/>\n<layer number="22" name="bPlace" color="7" fill="1" visible="yes" active="yes"/>\n<layer number="23" name="tOrigins" color="15" fill="1" visible="yes" active="yes"/>\n<layer number="24" name="bOrigins" color="15" fill="1" visible="yes" active="yes"/>\n<layer number="25" name="tNames" color="7" fill="1" visible="yes" active="yes"/>\n<layer number="26" name="bNames" color="7" fill="1" visible="yes" active="yes"/>\n<layer number="27" name="tValues" color="7" fill="1" visible="yes" active="yes"/>\n<layer number="28" name="bValues" color="7" fill="1" visible="yes" active="yes"/>\n<layer number="29" name="tStop" color="7" fill="3" visible="no" active="yes"/>\n<layer number="30" name="bStop" color="7" fill="6" visible="no" active="yes"/>\n<layer number="31" name="tCream" color="7" fill="4" visible="no" active="yes"/>\n<layer number="32" name="bCream" color="7" fill="5" visible="no" active="yes"/>\n<layer number="33" name="tFinish" color="6" fill="3" visible="no" active="yes"/>\n<layer number="34" name="bFinish" color="6" fill="6" visible="no" active="yes"/>\n<layer number="35" name="tGlue" color="7" fill="4" visible="no" active="yes"/>\n<layer number="36" name="bGlue" color="7" fill="5" visible="no" active="yes"/>\n<layer number="37" name="tTest" color="7" fill="1" visible="no" active="yes"/>\n<layer number="38" name="bTest" color="7" fill="1" visible="no" active="yes"/>\n<layer number="39" name="tKeepout" color="4" fill="11" visible="no" active="yes"/>\n<layer number="40" name="bKeepout" color="1" fill="11" visible="no" active="yes"/>\n<layer number="41" name="tRestrict" color="4" fill="10" visible="no" active="yes"/>\n<layer number="42" name="bRestrict" color="1" fill="10" visible="no" active="yes"/>\n<layer number="43" name="vRestrict" color="2" fill="10" visible="no" active="yes"/>\n<layer number="44" name="Drills" color="7" fill="1" visible="no" active="yes"/>\n<layer number="45" name="Holes" color="7" fill="1" visible="no" active="yes"/>\n<layer number="46" name="Milling" color="3" fill="1" visible="no" active="yes"/>\n<layer number="47" name="Measures" color="7" fill="1" visible="no" active="yes"/>\n<layer number="48" name="Document" color="7" fill="1" visible="yes" active="yes"/>\n<layer number="49" name="Reference" color="7" fill="1" visible="yes" active="yes"/>\n<layer number="91" name="Nets" color="2" fill="1" visible="yes" active="yes"/>\n<layer number="92" name="Busses" color="1" fill="1" visible="yes" active="yes"/>\n<layer number="93" name="Pins" color="2" fill="1" visible="no" active="yes"/>\n<layer number="94" name="Symbols" color="4" fill="1" visible="yes" active="yes"/>\n<layer number="95" name="Names" color="7" fill="1" visible="yes" active="yes"/>\n<layer number="96" name="Values" color="7" fill="1" visible="yes" active="yes"/>\n<layer number="97" name="Info" color="7" fill="1" visible="yes" active="yes"/>\n<layer number="98" name="Guide" color="6" fill="1" visible="yes" active="yes"/>\n</layers>'+++def _lbr_wrap_build(name, symbol_xml=None, package_xml=None):+ """Build a minimal, Fusion-parseable EAGLE .lbr wrapping bare artifacts.++ A deviceset is generated only when a symbol exists (a gate needs a symbol);+ a bare footprint still lists under Packages. 3D packages are NEVER embedded+ here (the known-flaky path); use stepPath on show_3d_package to attach after+ the open instead.+ """+ symbols = symbol_xml or ""+ packages = package_xml or ""+ devicesets = ""+ if symbol_xml:+ connects = ""+ variant = '<device name=""/>'+ if package_xml:+ pkg_m = re.search(r'<package\s+name="([^"]+)"', package_xml)+ pkg_name = pkg_m.group(1) if pkg_m else name+ variant = '<device name="" package="%s"><technologies><technology name=""/></technologies></device>' % pkg_name+ devicesets = (+ '<deviceset name="%s" prefix="U"><description>Adom wrapper</description>'+ '<gates><gate name="G$1" symbol="%s" x="0" y="0"/></gates>'+ '<devices>%s</devices></deviceset>' % (name, name, variant)+ )+ return (+ '<?xml version="1.0" encoding="utf-8"?>\n'+ '<!DOCTYPE eagle SYSTEM "eagle.dtd">\n'+ '<eagle version="9.6.2">\n<drawing>\n'+ + _LBR_WRAP_SETTINGS + '\n' + _LBR_WRAP_GRID + '\n' + _LBR_WRAP_LAYERS + '\n'+ + '<library>\n<description>Adom wrapper library: ' + name + '</description>\n'+ + '<packages>' + packages + '</packages>\n'+ + '<symbols>' + symbols + '</symbols>\n'+ + '<devicesets>' + devicesets + '</devicesets>\n'+ + '</library>\n</drawing>\n</eagle>\n'+ )+++def _lbr_wrap_write(name, symbol_xml=None, package_xml=None):+ """Write a wrapper .lbr to the wrap dir; returns (path, None) or (None, err)."""+ import os+ import tempfile+ for frag, kind, tag in ((symbol_xml, "symbolXml", "symbol"), (package_xml, "packageXml", "package")):+ if frag and not re.match(r"\s*<" + tag + r"[\s>]", frag):+ return None, ("%s must be a bare <%s ...>...</%s> XML fragment "+ "(got something else). To open a whole library, pass libraryPath instead." % (kind, tag, tag))+ safe = re.sub(r"[^A-Za-z0-9_-]", "_", name)[:60] or "wrapped"+ d = os.path.join(tempfile.gettempdir(), "adom-fusion-wrap")+ os.makedirs(d, exist_ok=True)+ path = os.path.join(d, safe + ".lbr").replace("\\", "/")+ try:+ _note_bridge_doc(safe) # generated wrapper = bridge doc (sentinel may discard its edits)+ except Exception:+ pass+ try:+ with open(path, "w", encoding="utf-8") as f:+ f.write(_lbr_wrap_build(name, symbol_xml, package_xml))+ except Exception as e:+ return None, "could not write wrapper library: " + str(e)+ return path, None+++def _artifact_name_from(args, *frag_keys):+ """Resolve the artifact name: explicit arg wins, else parsed from the fragment."""+ name = (args.get("name") or "").strip()+ if name:+ return name+ for k in frag_keys:+ frag = args.get(k) or ""+ m = re.search(r'name="([^"]+)"', frag)+ if m:+ return m.group(1)+ return ""++++def _remember_open_library(lbr_path: str):+ """Record the .lbr we just opened so LATER verbs can pre-check names without a path.++ ⛔ THIS EXISTS TO PREVENT A MODAL, NOT TO BE TIDY (John, 2026-08-26, third report of the+ same dialog). `_resolve_artifact_name` could always stop a Create-new prompt, but it+ needs the .lbr on disk, and `fusion_capture_library_views` never took a libraryPath - so+ the ONE verb that EDITs the most names was the one verb structurally unable to run the+ guard, and it "handled" the prompt by raising it and dismissing it afterwards. Persisted+ (not just module state) because AD reaps and respawns this bridge between calls, which+ would drop in-memory state exactly when a capture follows an open.+ """+ # LOCAL imports + a LOUD except. Both matter: this module imports os/time inside+ # functions, so a module-level `time.time()` here raised NameError - and the bare+ # `except: pass` I first wrote ATE it, leaving the guard silently disarmed while the+ # code read as correct. A prevention guard that fails quietly is worse than none,+ # because it removes the pressure to notice.+ try:+ import pathlib as _pl, json as _js, os as _os, time as _tm+ d = _pl.Path(_os.path.expanduser("~")) / ".adom" / "fusion-bridge"+ d.mkdir(parents=True, exist_ok=True)+ (d / "open_library.json").write_text(_js.dumps(+ {"libraryPath": lbr_path.replace("\\", "/"), "at": _tm.time()}))+ except Exception as e:+ print("[Fusion Bridge] ⚠ could not remember the open library (%s: %s) - the "+ "Create-new-dialog pre-check will fall back to SET CONFIRM only"+ % (type(e).__name__, e))+++def _remembered_open_library():+ """The last .lbr opened, if it is still on disk. None when we genuinely do not know."""+ try:+ import pathlib as _pl, json as _js, os as _os+ f = _pl.Path(_os.path.expanduser("~")) / ".adom" / "fusion-bridge" / "open_library.json"+ if not f.exists():+ return None+ lp = (_js.loads(f.read_text()) or {}).get("libraryPath") or ""+ return lp if lp and _os.path.exists(lp) else None+ except Exception as e:+ print("[Fusion Bridge] ⚠ could not read the remembered library (%s: %s)"+ % (type(e).__name__, e))+ return None+++def _library_artifact_names(lbr_path: str) -> dict:+ """The names that actually EXIST in a .lbr: {"sym": [...], "pac": [...], "dev": [...]}.++ EAGLE/Fusion libraries are XML, so the <symbol>/<package>/<deviceset> name attributes+ are the source of truth. Empty dict when the file is unreadable - callers must treat+ "I could not check" as "do not guess", never as "it is missing".+ """+ try:+ import xml.etree.ElementTree as _ET+ root = _ET.parse(lbr_path).getroot()+ except Exception:+ return {}+ def names(tag):+ return [e.get("name") for e in root.iter(tag) if e.get("name")]+ return {"sym": names("symbol"), "pac": names("package"), "dev": names("deviceset"),+ "p3d": [e.get("name") for e in root.iter("package3d") if e.get("name")]}+++def _resolve_artifact_name(name: str, suffix: str, lbr_path: str):+ """Resolve `name` to something that EXISTS, or say it does not - WITHOUT asking Fusion.++ ⚠️ PREVENTION, NOT CLEANUP (John, 2026-08-21: "you should never allow dialogs like that+ to ever even show cuz they screw you up"). `EDIT <name>.<suffix>` on a name that is not+ in the library pops Fusion's "Create new footprint 'X'?" modal, which blocks the add-in's+ main thread until somebody answers it - so a typo became a hang, and every later verb+ read as "add-in not responding". This bridge KNEW that: the old hint here told callers to+ run fusion_dismiss_blocking_dialogs afterwards. Telling the caller to sweep up a mess we+ chose to make is exactly backwards.++ Returns (resolved_name, None) or (None, error_dict). Exact match wins; then a unique+ case-insensitive match, because EAGLE names round-trip through tools that change case.+ """+ have = _library_artifact_names(lbr_path).get(suffix if suffix in ("sym", "pac", "dev") else "", [])+ if not have:+ # REFUSE, do not wave it through (reversed 2026-08-21, John caught the prompt+ # live): "cannot read the library -> do not block" meant a corrupt .lbr let the+ # EDIT reach Fusion, which popped "Create new footprint 'X'?" - the exact modal+ # this resolver exists to prevent. An unreadable or artifact-less library makes+ # the prompt CERTAIN, so certainty of a prompt is exactly when to block.+ return None, {+ "success": False, "errorCode": "library_unreadable",+ "error": "Cannot read any %s names from %s - the library is unparseable or "+ "empty, so EDIT was NOT issued (it would certainly raise Fusion's "+ "Create-new prompt)." % (suffix, lbr_path),+ "_hint": "Validate the .lbr first (fusion_open_lbr now refuses garbage with "+ "lbr_invalid + the file size/head). Re-stage the file from its source "+ "and compare hashes."}+ if name in have:+ return name, None+ ci = [h for h in have if h.lower() == str(name).lower()]+ if len(ci) == 1:+ return ci[0], None+ # A CALLER SHOULD NEVER PARSE OUR FILE FORMAT TO TALK TO US (page issue #42). ab ended up+ # regexing <symbol name="..."> out of the .lbr because the trailing character differed+ # (file CL21B104KBCNFNC.lbr vs deviceset CL21B104KBCNFN). We have the library open and we+ # know the names, so resolve the near-miss ourselves and, when we cannot, put the names in+ # the error so the retry is obvious without reading XML.+ import difflib as _dl+ close = _dl.get_close_matches(str(name), have, n=5, cutoff=0.6)+ stem = str(name).rsplit("/", 1)[-1].rsplit(".", 1)[0]+ pref = [h for h in have if h.lower().startswith(stem.lower()[:max(4, len(stem) - 2)])]+ if len(close) == 1 and close[0].lower() != str(name).lower():+ print("[Fusion Bridge] resolved near-miss %r -> %r (issue #42)" % (name, close[0]))+ return close[0], None+ if len(pref) == 1:+ print("[Fusion Bridge] resolved by stem %r -> %r (issue #42)" % (name, pref[0]))+ return pref[0], None+ return None, {+ "success": False,+ "error": "No %s named %r in that library, so the EDIT was NOT issued." % (+ {"sym": "symbol", "pac": "footprint", "dev": "device"}.get(suffix, suffix), name),+ "errorCode": "artifact_not_in_library",+ "available": have[:40],+ "closestMatches": close,+ "_hint": ("Deliberately refused BEFORE touching Fusion: EDIT on a missing name pops a "+ "'Create new ...?' modal that blocks the add-in's main thread and makes every "+ "later verb look like a crash. Pick a name from `available` (they are the "+ "<symbol>/<package>/<deviceset> name attributes in the .lbr, the source of "+ "truth) and re-call. Nothing was opened and no dialog was raised."),+ }+++def _show_library_view(name, suffix, lbr_path=None):+ """EDIT to a library view + fit + background screenshot. Library must be open.++ Verifies the name EXISTS first when we know the .lbr, so Fusion is never asked to open+ something that would make it prompt (see _resolve_artifact_name).+ """+ import time+ if lbr_path:+ resolved, err = _resolve_artifact_name(name, suffix, lbr_path)+ if err:+ return err+ if resolved != name:+ print("[Fusion Bridge] resolved %r -> %r from the library (avoids a Create-new prompt)"+ % (name, resolved))+ name = resolved+ # ⚠️ EDIT DRIVES THE ACTIVE DOCUMENT, NOT A PATH (page issue #47, root cause of+ # failure modes 1 AND 2): in a batch, several libraries (and f3ds) are open at+ # once, and EDIT lands in whichever is active. The artifact scan read the right+ # FILE while EDIT drove the WRONG document - so Fusion offered to CREATE a part+ # that plainly existed, or refused with "Not in Electronics editor". Make the+ # target library active, verified, before every EDIT.+ act = _ensure_library_active(lbr_path, timeout_s=30)+ if not act.get("ok"):+ return {"success": False, "errorCode": "library_not_active",+ "error": "Could not make %s the ACTIVE document (active: %r), so EDIT "+ "was not issued - it would have driven the wrong library." % (+ lbr_path.rsplit("/", 1)[-1], act.get("activeDocument")),+ "_hint": "fusion_open_lbr the library, then re-call. In a batch, close "+ "the previous part's documents first (the create_library chain "+ "now does this itself)."}+ # BUSY-RETRY (matrix round 2026-08-21, ads1220irvar lost 2 frames to "add-in is BUSY"):+ # on a shared box another caller's long command can hold the main thread exactly when+ # a capture's EDIT lands. Busy is transient by definition - wait it out (up to 90s)+ # instead of failing the frame.+ edit = None+ for _busy_try in range(7):+ edit = _proxy_to_addin("electron_run", {"command": "EDIT %s.%s" % (name, suffix),+ "confirm": "no"}, timeout=15)+ _etxt = str(edit.get("error") or "")+ if edit.get("success"):+ break+ # NOT-IN-ELECTRONICS-EDITOR RETRY (#49 parts 6/7, 2026-08-23): the 3D step can leave+ # Fusion on the Design workspace, and even after _ensure_library_active a stray EDIT+ # can land a beat before the switch completes. Re-ensure the library editor and retry+ # rather than losing the frame - the same "transient, wait it out" law as BUSY.+ if lbr_path and ("not in electronics editor" in _etxt.lower()+ or "current: design" in _etxt.lower()):+ print("[Fusion Bridge] EDIT %s.%s hit Design workspace (attempt %d) - re-entering "+ "the library editor and retrying" % (name, suffix, _busy_try + 1))+ _ensure_library_active(lbr_path, timeout_s=20)+ time.sleep(1.0)+ continue+ if "BUSY" not in _etxt and "busy" not in _etxt:+ break+ print("[Fusion Bridge] EDIT %s.%s deferred - main thread busy (attempt %d), waiting 15s"+ % (name, suffix, _busy_try + 1))+ time.sleep(15)+ # WE MAY NOT HAVE BEEN ABLE TO PRE-CHECK (no libraryPath, nothing remembered, or the+ # library was opened by another route). If Fusion prompted anyway, answer it HERE - a+ # modal we caused must never outlive the call that caused it, and it is holding the main+ # thread hostage while it sits there (John saw this dialog twice, 2026-08-21).+ try:+ _pending = [d for d in (classify_blocking_dialogs() or [])+ if d.get("category") == "create_confirm"]+ if _pending:+ # A create_confirm on screen is not automatically OURS (run 7d4427d443f5: a+ # footprint EDIT whose name WAS in the library got failed because a stale+ # Create-new prompt from an earlier interaction was still standing). Read the+ # dialog's own text: only a prompt that names THIS artifact fails THIS call.+ # Foreign/stale prompts are still declined (never left blocking the main+ # thread), but the EDIT's own outcome decides the result.+ _about_this = False+ for _d in _pending:+ try:+ from handlers.dialog_classify import _dialog_text as _dtxt+ _body = "%s %s" % (_dtxt(_d.get("hwnd")) or "", _d.get("title") or "")+ except Exception:+ _body = _d.get("title") or ""+ if str(name).lower() in _body.lower():+ _about_this = True+ _unwrap(_ad_call("desktop_ui_click", {"hwnd": _d.get("hwnd"), "name": "No"}, timeout=12))+ if not _about_this:+ print("[Fusion Bridge] declined a STALE Create-new prompt that does not mention "+ "%r - clearing it and trusting the EDIT's own result" % name)+ if _pending and _about_this:+ print("[Fusion Bridge] declined a Create-new prompt for %r - the name is not in the "+ "library (answering Yes would fabricate an empty artifact)" % name)+ return {+ "success": False,+ "error": "No %s named %r in the open library. Fusion offered to CREATE it; the "+ "bridge declined." % ({"sym": "symbol", "pac": "footprint",+ "dev": "device"}.get(suffix, suffix), name),+ "errorCode": "artifact_not_in_library",+ "promptDeclined": True,+ "_hint": ("The prompt was answered (No) inside this call, so nothing is left "+ "blocking Fusion's main thread. Answering Yes would have created an "+ "EMPTY artifact and made this look like it worked. Pass libraryPath so "+ "the bridge can verify names up front and never raise the prompt at all."),+ }+ except Exception:+ pass+ if not edit.get("success"):+ return {"success": False, "error": "EDIT %s.%s failed: %s" % (name, suffix, edit.get("error")),+ "_hint": ("The name must match the deviceset/artifact inside the OPEN library exactly "+ "(EDIT on a missing name pops a 'Create new?' dialog - run "+ "fusion_dismiss_blocking_dialogs if one appeared). Not sure of the name? "+ "fusion_get_app_state shows the active document; the .lbr's <symbol>/<package> "+ "name attributes are the source of truth.")}+ _proxy_to_addin("electron_run", {"command": "WINDOW FIT"}, timeout=8)+ time.sleep(1.0)+ # REPORT WHAT WE ACTUALLY OPENED (page issue #40/#42, and kicad's shape: it returns+ # symbolNames / footprintName). ab's client was reading our .lbr to learn what to ask+ # for; the cure is that the verb SAYS what it opened and what else is in there, so the+ # next call needs no discovery step and no file parsing.+ out = {"success": True, "view": suffix, "name": name, "resolvedName": name}+ if lbr_path:+ _n = _library_artifact_names(lbr_path)+ if _n:+ out["libraryPath"] = lbr_path+ out["symbolNames"] = _n.get("sym", [])+ out["packageNames"] = _n.get("pac", [])+ out["devicesetNames"] = _n.get("dev", [])+ out["package3dNames"] = _n.get("p3d", [])+ out["has3d"] = bool(_n.get("p3d"))+ try:+ shot = _handle_screenshot_fusion({}, {})+ p = shot.get("savedTo") or shot.get("localSafePath")+ if shot.get("success") and p:+ out["screenshot"] = p+ except Exception:+ pass+ return out+++def _last_library() -> str:+ """The .lbr we most recently opened, across respawns."""+ if _LAST_LIBRARY[0]:+ return _LAST_LIBRARY[0]+ try:+ return _get_bridge_pref("lastLibraryPath", "") or ""+ except Exception:+ return ""+++_LAST_LIBRARY = [""] # the .lbr most recently opened by this bridge+++def _show_prepare_library(args, symbol_xml=None, package_xml=None, name=""):+ """Open the target library: explicit path, generated wrapper, or already-open.++ Returns (errdict|None, wrapped_path|None)."""+ library_path = (args.get("libraryPath") or "").replace("\\", "/")+ wrapped = None+ if symbol_xml or package_xml:+ if library_path:+ return {"success": False,+ "error": "Pass EITHER libraryPath OR a bare XML fragment, not both.",+ "_hint": "libraryPath opens an existing library; a fragment gets wrapped in a generated one."}, None+ wrapped, err = _lbr_wrap_write(name, symbol_xml, package_xml)+ if err:+ return {"success": False, "error": err}, None+ library_path = wrapped+ if library_path:+ opened = _orchestrate_open_lbr({"filePath": library_path})+ if not opened.get("success"):+ return {"success": False, "error": "open_lbr failed: " + str(opened.get("error")),+ "data": {"libraryPath": library_path, "wrapped": bool(wrapped)}}, None+ # REMEMBER IT. The name-only call path ("the library is already open") had no .lbr to+ # check against, so the Create-new guard silently did nothing and the blind EDIT went+ # out anyway - which is why John saw the dialog AGAIN after the first fix. Whatever we+ # last opened IS the library those calls mean.+ _LAST_LIBRARY[0] = library_path+ try:+ _set_bridge_pref("lastLibraryPath", library_path) # survives a bridge respawn+ except Exception:+ pass+ return None, wrapped+++def _orchestrate_show_symbol(args: dict) -> dict:+ """Open a schematic SYMBOL in Fusion's library editor.++ args: name (deviceset/symbol name), plus ONE of:+ libraryPath - existing .lbr to open first (omit if the library is already open)+ symbolXml - a bare <symbol ...>...</symbol> fragment; a wrapper library is+ generated around it so it shows in the Library Manager.+ """+ symbol_xml = args.get("symbolXml")+ name = _artifact_name_from(args, "symbolXml")+ if not name:+ return {"success": False, "error": "name required (or a symbolXml fragment with a name attribute)."}+ err, wrapped = _show_prepare_library(args, symbol_xml=symbol_xml, name=name)+ if err:+ return err+ out = _show_library_view(name, "sym", wrapped or (args.get("libraryPath") or "").replace("\\", "/") or _last_library())+ if wrapped and out.get("success"):+ out["wrappedLibrary"] = wrapped+ out["_hint"] = ("Bare symbol was wrapped in a generated library at %s (a deviceset with one "+ "gate was synthesized so the Library Manager lists it). To pair it with a "+ "footprint, re-call with both symbolXml and packageXml." % wrapped)+ return out+++def _orchestrate_show_footprint(args: dict) -> dict:+ """Open a FOOTPRINT (package) in Fusion's library editor.++ args: name, plus ONE of libraryPath | packageXml (bare <package> fragment,+ wrapped in a generated library)."""+ package_xml = args.get("packageXml")+ name = _artifact_name_from(args, "packageXml")+ if not name:+ return {"success": False, "error": "name required (or a packageXml fragment with a name attribute)."}+ err, wrapped = _show_prepare_library(args, package_xml=package_xml, name=name)+ if err:+ return err+ out = _show_library_view(name, "pac", wrapped or (args.get("libraryPath") or "").replace("\\", "/") or _last_library())+ if wrapped and out.get("success"):+ out["wrappedLibrary"] = wrapped+ out["_hint"] = "Bare footprint was wrapped in a generated library at " + wrapped + "."+ return out+++_SHOW_3D_FALLBACK_PACKAGE = (+ '<package name="%s"><description>Adom auto footprint (3D carrier)</description>'+ '<smd name="1" x="-1.27" y="0" dx="1.2" dy="1.2" layer="1"/>'+ '<smd name="2" x="1.27" y="0" dx="1.2" dy="1.2" layer="1"/>'+ '<wire x1="-2.2" y1="1.6" x2="2.2" y2="1.6" width="0.127" layer="21"/>'+ '<wire x1="-2.2" y1="-1.6" x2="2.2" y2="-1.6" width="0.127" layer="21"/>'+ '<text x="-2.2" y="2" size="1" layer="25">>NAME</text></package>'+)+++def _orchestrate_show_3d_package(args: dict) -> dict:+ """Open the 3D CHIP (Package3DEnvironment) for a library package; optionally+ attach a STEP model first (the reliable add-3D-AFTER-the-open path - embedded+ <packages3d> in hand-built libraries is the Fusion quirk that fails).++ args: name, then:+ libraryPath - existing .lbr (with packageXml/stepPath absent, just shows it)+ packageXml - bare <package> fragment to wrap (footprint carrier)+ stepPath - Windows path to a STEP; attached onto the footprint via the+ attach machinery. With NO packageXml/libraryPath, a minimal+ 2-pad carrier footprint is generated so the STEP has a home.+ """+ package_xml = args.get("packageXml")+ step_path = (args.get("stepPath") or "").replace("\\", "/")+ name = _artifact_name_from(args, "packageXml") or (+ re.sub(r"[^A-Za-z0-9_-]", "_", step_path.rsplit("/", 1)[-1].rsplit(".", 1)[0])[:60] if step_path else "")+ if not name:+ return {"success": False, "error": "name required (or derivable from packageXml/stepPath)."}+ library_path = (args.get("libraryPath") or "").replace("\\", "/")+ wrapped = None+ if not library_path:+ if not package_xml and step_path:+ package_xml = _SHOW_3D_FALLBACK_PACKAGE % name+ if not package_xml:+ return {"success": False,+ "error": "Need a footprint to host the 3D: pass libraryPath, packageXml, or stepPath.",+ "_hint": "A 3D package lives ON a footprint. stepPath alone works: a minimal 2-pad carrier footprint is generated."}+ wrapped, werr = _lbr_wrap_write(name, package_xml=package_xml)+ if werr:+ return {"success": False, "error": werr}+ library_path = wrapped+ if step_path:+ res = _merge_dialog_array(_orchestrate_attach_3d_package(+ {"filePath": library_path, "modelPath": step_path, "packageName": name}))+ # THE ATTACH-FAILED BRANCH OF THE PLACEHOLDER GUARD (page issue #44): when the+ # attach we just attempted did not actually land a <package3d> in the library,+ # what renders next is Fusion's grey Placeholder - and this verb used to return+ # the attach result's success over it. Same offline check as no_3d_package_bound,+ # run on the branch where the bridge TRIED and failed rather than was never asked.+ _p3d_after = _library_artifact_names(library_path).get("p3d", [])+ if not _p3d_after:+ _up_err = str(res.get("error") or res.get("attachError") or "attach reported no error but bound nothing")+ return {"success": False,+ "errorCode": "3d_attach_failed",+ "error": "Attaching the STEP failed, so the library still has no 3D "+ "package - refusing to show Fusion's Placeholder as if it "+ "were the part.",+ "attachError": _up_err[:400],+ "libraryPath": library_path,+ "_hint": ("The upstream failure is in attachError (a 409 here usually "+ "means the f3d already exists in the cloud - the bind path "+ "reuses it as of 1.9.11x, so retry fusion_bind_library_3d "+ "and read ITS message). Nothing was rendered because a "+ "Placeholder screenshot photographs exactly like a real "+ "chip and must never count as one.")}+ if wrapped:+ res["wrappedLibrary"] = wrapped+ res["view"] = "3d-package"+ return res+ opened = _orchestrate_open_lbr({"filePath": library_path})+ if not opened.get("success"):+ return {"success": False, "error": "open_lbr failed: " + str(opened.get("error"))}+ # ⚠️ A PLACEHOLDER IS NOT A RESULT (page issue #42). With no <package3d> bound, Fusion+ # renders its grey Placeholder slab and this verb used to return success - ab's card+ # captured that, counted three artifacts, and reported the library PROVEN while a human+ # looking at the screenshot saw a featureless block where their capacitor should be.+ # Technically truthful, practically a false pass. Same class as a 237x39 "screenshot":+ # a non-result reported as success. The .lbr answers this offline, before we render.+ _p3d = _library_artifact_names(library_path).get("p3d", [])+ if not _p3d:+ return {"success": False,+ "error": "That library has no 3D package bound, so the only thing to show is "+ "Fusion's grey Placeholder - refusing to report that as a 3D package.",+ "errorCode": "no_3d_package_bound",+ "libraryPath": library_path,+ "_hint": ("Bind a real model first: re-call fusion_show_3d_package with "+ "stepPath pointing at the STEP file and the bridge attaches it onto "+ "the footprint, then shows it. A Placeholder rendering LOOKS like a "+ "successful 3D view in a screenshot, which is why this refuses "+ "rather than letting a caller count it as proof.")}+ cp = _create_3d_package(library_path, timeout=60)+ if not cp.get("success"):+ return {"success": False, "error": "Create3DPackage failed: " + str(cp.get("error")),+ "_hint": "The library must be Fusion-parseable (adom-lbr style); raw vendor .lbr files fail Fusion's parser."}+ out = {"success": True, "view": "3d-package", "name": name, "libraryPath": library_path}+ try:+ shot = _handle_screenshot_fusion({}, {})+ p = shot.get("savedTo") or shot.get("localSafePath")+ if shot.get("success") and p:+ out["screenshot"] = p+ except Exception:+ pass+ out["_hint"] = ("You are in the Package3DEnvironment. To leave WITHOUT saving, run "+ "fusion_execute_text_command with Package3DStop; the bridge answers the FINISH Save dialog itself (never you) "+ "(fusion_dismiss_blocking_dialogs). To BIND a real 3D model here, re-call with stepPath.")+ return out+++# ── fusion_demo tour mode: the six-content-type showcase with structured progress ──++_DEMO_TOURS: dict = {}+_DEMO_TOURS_LOCK = threading.Lock()+++def _demo_tour_dir():+ import pathlib+ d = pathlib.Path(os.path.expanduser("~")) / ".adom" / "fusion-bridge" / "tours"+ d.mkdir(parents=True, exist_ok=True)+ return d+++def _demo_tour_persist(state: dict):+ """Snapshot a FINISHED tour to disk (ab's #30 grace-window ask, 2026-08-16): their+ 2.5s poller got 'unknown demoId' for the final frame because a bridge_install+ respawned this process between polls, wiping the in-memory dict - tours were never+ reaped, the PROCESS was. The disk snapshot makes the done frame (with its shots)+ pollable across respawns; keep the last 8, best-effort."""+ try:+ d = _demo_tour_dir()+ snap = {k: v for k, v in state.items() if k != "started"}+ (d / (str(state.get("demoId")) + ".json")).write_text(json.dumps(snap))+ old = sorted(d.glob("tour-*.json"), key=lambda p: p.stat().st_mtime)+ for p in old[:-8]:+ p.unlink()+ except Exception:+ pass+++def _demo_tour_load(demo_id: str):+ try:+ p = _demo_tour_dir() / (demo_id + ".json")+ if p.is_file():+ return json.loads(p.read_text())+ except Exception:+ pass+ return None+++def _bmp_downscale_png(path: str, max_w: int = 640):+ """Pure-STDLIB BMP -> small PNG (nearest-neighbor). Exists because AD's provisioned+ python has NO PIL, so screenshot captures fall back to raw 24MB BMPs - and a 24MB BMP+ is 31MB as base64, which no relay frame should ever carry (caught live 2026-08-16:+ every tour stepShot silently skipped, and the done gallery would have been ~280MB).+ BMP is uncompressed BGR rows and PNG needs only zlib+struct, so no dependency."""+ import struct+ import zlib+ raw = open(path, "rb").read()+ if raw[:2] != b"BM":+ raise ValueError("not a BMP")+ pix_off = struct.unpack_from("<I", raw, 10)[0]+ w = struct.unpack_from("<i", raw, 18)[0]+ h = struct.unpack_from("<i", raw, 22)[0]+ bpp = struct.unpack_from("<H", raw, 28)[0]+ comp = struct.unpack_from("<I", raw, 30)[0]+ if comp != 0 or bpp not in (24, 32) or w <= 0 or h == 0:+ raise ValueError("unsupported BMP (bpp=%s comp=%s)" % (bpp, comp))+ bottom_up = h > 0+ h = abs(h)+ bypp = bpp // 8+ stride = ((w * bypp) + 3) & ~3+ ow = min(max_w, w)+ oh = max(1, int(h * ow / w))+ xs = [int(x * w / ow) * bypp for x in range(ow)]+ rows = []+ for y in range(oh):+ sy = int(y * h / oh)+ row_off = pix_off + ((h - 1 - sy) if bottom_up else sy) * stride+ line = bytearray(1 + ow * 3) # PNG filter byte 0 + RGB+ i = 1+ for xo in xs:+ o = row_off + xo+ line[i] = raw[o + 2] # R (BMP stores BGR)+ line[i + 1] = raw[o + 1] # G+ line[i + 2] = raw[o] # B+ i += 3+ rows.append(bytes(line))+ def chunk(tag, data):+ return (struct.pack(">I", len(data)) + tag + data+ + struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF))+ ihdr = struct.pack(">IIBBBBB", ow, oh, 8, 2, 0, 0, 0)+ idat = zlib.compress(b"".join(rows), 6)+ return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", ihdr) + chunk(b"IDAT", idat)+ + chunk(b"IEND", b""))+++def _shot_b64_small(path: str, max_w: int = 640):+ """Base64 evidence frame for ab's stepShot contract (~640px wide). PIL downscale when+ available; the stdlib BMP downscaler when not (AD's python ships without PIL); else+ the raw file if it is small enough for the relay envelope."""+ import base64+ try:+ try:+ from PIL import Image+ import io+ im = Image.open(path)+ if im.width > max_w:+ im = im.resize((max_w, max(1, int(im.height * max_w / im.width))))+ buf = io.BytesIO()+ im.convert("RGB").save(buf, format="PNG")+ return base64.b64encode(buf.getvalue()).decode()+ except ImportError:+ pass+ if path.lower().endswith(".bmp"):+ return base64.b64encode(_bmp_downscale_png(path, max_w)).decode()+ raw = open(path, "rb").read()+ if len(raw) <= 1_500_000:+ return base64.b64encode(raw).decode()+ except Exception:+ pass+ return None+++def _demo_tour_sample_lbr():+ """The bundled sample library staged into the wrap dir (self-sufficient demo data)."""+ import os+ import shutil+ import tempfile+ src = os.path.join(os.path.dirname(os.path.abspath(__file__)), "resources", "samples", "ADS8588SIPM.fusion.lbr")+ if not os.path.isfile(src):+ return None+ d = os.path.join(tempfile.gettempdir(), "adom-fusion-wrap")+ os.makedirs(d, exist_ok=True)+ dst = os.path.join(d, "AdomDemoSample.lbr")+ shutil.copyfile(src, dst)+ return dst.replace("\\", "/")+++# Per-step narration (John, 2026-08-24: "show ab captions for each step ... explaining+# what each step is doing ... and all the cool stuff you needed to have in place to make+# that work"). One sentence of WHAT + one of the MACHINERY. Written to be READ ALOUD+# (adom-tts renders these on the dashboard when audio narration is on) and shown as a+# desktop caption over the live demo, so a screenshot appearing always has its why.+_TOUR_EXPLAIN = {+ "launch": "Starting Fusion in the background. The bridge launches it detached and minimized, takes the one Autodesk seat if another machine holds it, and never steals your focus - and the AdomBridge add-in loads itself inside Fusion and opens a local command port the bridge drives everything through.",+ "stage": "Staging the sample part. This library was built from open KiCad source by adom dash l b r and lands on disk as a real EAGLE library file Fusion can open natively.",+ "library": "Opening the electronics library. The bridge drives Fusion's own Electronics Library editor through the add-in - no clicks, no foreground, the same path a human would take.",+ "symbol": "The symbol editor: the part's full pinout, converted pin for pin from KiCad source. The add-in switches views with Fusion's own edit commands.",+ "footprint": "The footprint editor: real pads and layers, exactly the copper a board would get - never a placeholder.",+ "device": "The component view: every symbol pin mapped to its footprint pad. The bridge verifies this mapping offline before Fusion ever opens the file, so dead wiring cannot photograph well.",+ "3d-package": "The 3D chip - the showpiece. The bridge imports the part's STEP model onto the footprint in Fusion's 3D package generator, orients it flat automatically, saves it into your Autodesk cloud hub, and binds the resulting cloud model into the library, so every future board carries the real body.",+ "datahub": "Your Fusion Data Hub, through the add-in: hubs, projects and folders listed over Fusion's own cloud API - the same storage the 3D chip was just saved into.",+ "aps": "APS cloud search: the bridge's own server-indexed search over your Fusion cloud files, about two seconds per query after a single sign-in, and never charged.",+ "mcp": "Autodesk's official MCP server, answering over the bridge. Useful - but its toolset is small and read-mostly. The bridge's one hundred plus fusion verbs go far beyond it: electronics editors, text commands, cloud files, evidence captures and a dozen export formats.",+ "make-project": "Generating a complete test project around the part: schematic and board authored from EAGLE source and instantiated by Fusion, entirely in the background.",+ "open-design": "Opening the cloud electronics design from your hub - the bridge resolves it by name and opens the parent design, never a child view.",+ "schematic": "The schematic view of the generated project - the part wired into a real sheet.",+ "2d-board": "The 2D board layout - the same footprint you saw in the library, now placed on copper.",+ "3d-board": "The 3D board - and the chip body on it is the exact cloud model the bridge minted and bound earlier in this tour.",+ "exports": "Exports: STEP and STL written straight from the add-in. The same pipeline covers F 3 D, G L B, 3 M F, IGES, SAT, D X F, gerbers and BOM - every format a downstream tool could ask for.",+}+++def _tour_caption(text: str, seconds: int = 45):+ """One desktop caption per tour step (id-replace: each step's caption replaces the+ last, so the screen always narrates the CURRENT step). Best-effort, never raises."""+ try:+ # dash-demo fleet ruling: ALWAYS the bridge's DEFAULT caption size and position -+ # seven apps, one caption look. If the default is wrong, fix it in ab for everyone.+ _ad_call("desktop_caption", {"text": text, "id": "fusion-tour",+ "duration": max(15000, min(int(seconds) * 2000, 240000))},+ timeout=10)+ except Exception:+ pass+++def _orchestrate_demo_tour(args: dict) -> dict:+ """Start the async six-content-type tour; poll fusion_demo_progress {demoId}."""+ import time+ import uuid+ demo_id = "tour-" + uuid.uuid4().hex[:8]+ include_design = bool(args.get("includeDesign", True))+ query = args.get("query") or ""+ # Cold-start (issue #30, verified live: a cold box gave a 100s bar ending in+ # nothing): when Fusion/add-in is not responding, the tour launches it itself,+ # as a REPORTED step so the front-end's bar covers the wait.+ cold = True+ try:+ probe = _proxy_to_addin("get_app_state", {}, timeout=8)+ cold = not probe.get("success")+ except Exception:+ pass+ import timings as _tm+ def _est(phase, seeded):+ """Measured p50 for this phase on THIS machine, else the seeded literal."""+ return int(round(_tm.estimate(phase, seeded, "cold" if phase == "launch" else None)[0]))+ steps = ([("launch", "Starting Fusion (cold start)", _est("launch", 120))] if cold else []) + [+ ("stage", "Staging the sample library", _est("stage", 3)),+ ("library", "Opening the electronics library", _est("library", 35)),+ ("symbol", "Symbol: full pinout in the symbol editor", _est("symbol", 8)),+ ("footprint", "Footprint: pads + layers in the footprint editor", _est("footprint", 8)),+ ("device", "Component view: symbol + package mapping", _est("device", 8)),+ ("3d-package", "3D chip: the Package3D environment", _est("3d-package", 30)),+ # The platform side (John, 2026-08-24: "show off all aspects of your bridge+ # including aps and data hub and the add-in and the mcp server chatting and the+ # limits of the mcp server that you go way beyond"). Each degrades gracefully+ # (signed-out APS, disabled MCP) - the caption then narrates the honest state.+ ("datahub", "Data Hub: your cloud projects through the add-in", _est("datahub", 10)),+ ("aps", "APS cloud search: ~2s indexed lookups", _est("aps", 8)),+ ("mcp", "MCP: Autodesk's own AI server (and its limits)", _est("mcp", 10)),+ ]+ if include_design:+ # Design side is SELF-SUFFICIENT by default (John, 2026-08-15: customers have+ # only a component page, never a canonical cloud board): generate + import a+ # test project around the sample part. A query switches to the cloud-open path.+ steps += ([("open-design", "Opening the cloud electronics design", _est("open-design", 150))] if query+ else [("make-project", "Generating + importing a test project around the sample part", _est("make-project", 60))])+ steps += [+ ("schematic", "Schematic view", _est("schematic", 12)),+ ("2d-board", "2D board layout", _est("2d-board", 12)),+ ("3d-board", "3D board", _est("3d-board", 20)),+ ("exports", "Exporting: STEP + STL (of a dozen formats)", _est("exports", 25)),+ ]+ est = sum(s[2] for s in steps)+ state = {"demoId": demo_id, "percent": 0, "elapsedSec": 0, "estimatedSec": est,+ "step": steps[0][0], "stepLabel": steps[0][1], "done": False, "failed": False,+ "steps": [s[0] for s in steps], "shots": [], "started": time.time()}+ with _DEMO_TOURS_LOCK:+ _DEMO_TOURS[demo_id] = state++ def _wait_idle(max_s=180):+ """Fusion's main thread keeps grinding long ops after our proxy timeout returns+ (Create3DPackage on a cold session ran 120s+ and broke the next step's open,+ issue #30 cold-start validation). Gate on app-state responding again."""+ deadline = time.time() + max_s+ while time.time() < deadline:+ try:+ if _proxy_to_addin("get_app_state", {}, timeout=8).get("success"):+ return True+ except Exception:+ pass+ time.sleep(5)+ return False++ def _snap(label):+ # {title, image} is the ab web-control harness's gallery item contract (issue #29+ # reply, harness live in ab 2.0.39) - label kept as an alias.+ try:+ r = _handle_screenshot_fusion({}, {})+ # server-side helper returns savedTo (localSafePath is the CLI layer's name -+ # checking only that dropped EVERY tour shot, caught live 2026-08-15)+ p = r.get("savedTo") or r.get("localSafePath")+ if r.get("success") and p:+ state["shots"].append({"title": label, "label": label, "path": p})+ except Exception:+ pass++ def _run():+ fg = bool(args.get("foreground"))+ if not fg:+ begin_background_envelope(float(est + 60))+ done_est = 0+ _docs_before_tour = _open_doc_names() # tour hygiene: close only what WE open+ try:+ _VIEW_STEPS = {"symbol", "footprint", "device", "schematic", "2d-board", "3d-board", "library"}+ _last_view_step = None+ _step_i = 0+ while _step_i < len(steps):+ key, label, dur = steps[_step_i]+ _step_i += 1+ # ── TRANSPORT (fusion_demo_control): boundary checks, never mid-verb ──+ if state.pop("abort", None):+ raise RuntimeError("tour exited by the user (transport control)")+ while state.get("paused") and not state.get("abort"):+ state.update(stepLabel="Paused - press play to continue", stepCaption="Paused.")+ time.sleep(1.5)+ if state.pop("abort", None):+ raise RuntimeError("tour exited by the user (transport control)")+ if state.pop("skipNext", None):+ state.setdefault("skippedSteps", []).append(key)+ done_est += dur+ continue+ if state.pop("replayPrev", None) and _last_view_step:+ _step_i -= 1 # come back to the current step after+ key, label, dur = _last_view_step # re-run the last view step once+ if key in _VIEW_STEPS:+ _last_view_step = (key, label, dur)+ if not fg:+ # RE-ARM per step: one est-sized envelope EXPIRED mid-tour on real runs+ # (485-890s vs ~376s armed) and later steps stole the user's foreground+ # while they typed in other threads (John, live during ab's demo,+ # 2026-08-16). Nesting is refcounted, so re-arming is free.+ begin_background_envelope(float(dur * 3 + 240))+ _step_started = time.time()+ # NARRATION (John, 2026-08-24): the caption teaches what this step is doing+ # and the machinery underneath, on-screen (desktop caption, id-replaced per+ # step) and in the progress stream (stepCaption - the dashboard shows it and+ # renders it as audio when narration is on).+ _explain = _TOUR_EXPLAIN.get(key) or label+ if args.get("captions", True):+ _tour_caption(_explain, dur)+ # LIVE, per-step ETA (issue #32): the tour reports the same contract block+ # every other slow verb does, so ONE renderer draws every bar in Adom.+ state.update(step=key, stepLabel=label, stepCaption=_explain,+ percent=int(100 * done_est / est),+ elapsedSec=int(time.time() - state["started"]),+ estimatedSec=est,+ etaSec=max(0, int(est - (time.time() - state["started"]))),+ confidence=_tm.estimate(key, dur)[1],+ startKind=("cold" if cold else "warm"),+ progress={"phase": key, "stepLabel": label,+ "percent": int(100 * done_est / est),+ "elapsedSec": int(time.time() - state["started"]),+ "estimatedSec": est,+ "etaSec": max(0, int(est - (time.time() - state["started"]))),+ "confidence": _tm.estimate(key, dur)[1],+ "startKind": ("cold" if cold else "warm")})+ if key == "stage" and args.get("sampleLbr"):+ pass # handled in the stage branch below+ if key == "launch":+ dispatch_command("start", {})+ ready = False+ seat_attempts = 0+ for _ in range(30):+ time.sleep(5)+ try:+ if _proxy_to_addin("get_app_state", {}, timeout=8).get("success"):+ ready = True+ break+ except Exception:+ pass+ # ab's #30 fleet finding (2026-08-16): a single-seat account with Fusion+ # signed in elsewhere wedges the cold start at 0% behind 'Active Sessions+ # Exceeded'. Self-heal (take the seat, John's standing call) and REPORT it+ # as its own state; if takeover cannot land (opt-out, or the UIA path is+ # unavailable), fail FAST with the decidable classification instead of+ # burning the whole 150s budget.+ try:+ _dismiss_save_prompt_if_bridge_doc()+ except Exception:+ pass+ try:+ if find_licensing_dialog():+ if _seat_takeover_enabled():+ state.update(stepLabel="Taking over the Autodesk seat (another machine holds it)")+ r = _resolve_seat_dialog(max_clicks=3)+ seat_attempts += 1+ if r.get("clicks"):+ state["seatTakeover"] = True+ state.setdefault("warnings", []).append(+ "license_sessions_exceeded: took the seat over in the background "+ "(the other machine's Fusion suspended resumably)")+ if seat_attempts >= 3 and find_licensing_dialog():+ raise RuntimeError(+ "license_sessions_exceeded: the Autodesk seat is held on another "+ "machine and background takeover did not land. Free it there "+ "(fusion_stop on that box) and re-run the tour.")+ else:+ raise RuntimeError(+ "license_sessions_exceeded: seat held on another machine and "+ "auto-takeover is opted out (fusion_set_seat_takeover). Resolve the "+ "'Active Sessions Exceeded' dialog or re-enable takeover, then re-run.")+ except RuntimeError:+ raise+ except Exception:+ pass+ if not ready:+ # Not ready usually means the SIGN-IN wall on a fresh box. Drive it as far+ # as code can (email typed, page advanced), toast the human at the exact+ # remaining step, and fail the tour with instructions instead of a shrug.+ rd = _handle_fusion_readiness({}, {})+ if rd.get("needsSignin"):+ state.update(step="signin", stepLabel="Signing in (one-time, driving it for you)")+ si = _orchestrate_signin({"auto": True})+ wallname = si.get("signinWall") or "credentials"+ state.setdefault("warnings", []).append(+ "sign-in required: drove to wall %r; user toasted; %s" % (wallname, str(si.get("stage"))))+ raise RuntimeError(+ "One-time Autodesk sign-in needed on this machine. I drove it as far "+ "as possible and toasted the user at the %r step - after they finish, "+ "re-run the tour and it completes." % wallname)+ raise RuntimeError("Fusion launched but its add-in never became ready (150s)")+ elif key == "stage":+ custom = (args.get("sampleLbr") or "").replace("\\", "/")+ if custom:+ # Tour ANY wiki part (John, 2026-08-16: "do the tour using an adom basic+ # parts led"): caller stages the part's .lbr (send_files) and passes its+ # Windows path; the deviceset is read from the file.+ import os as _os+ if not _os.path.isfile(custom):+ raise RuntimeError("sampleLbr not found on this machine: %s" % custom)+ state["sampleLbr"] = custom+ m = re.search(r'<deviceset name="([^"]+)"', open(custom, encoding="utf-8", errors="replace").read())+ state["deviceset"] = m.group(1) if m else "UNKNOWN"+ else:+ lbr = _demo_tour_sample_lbr()+ if not lbr:+ raise RuntimeError("bundled sample library missing from the bridge install")+ state["sampleLbr"] = lbr+ state["deviceset"] = "ADS8588SIPM"+ elif key == "library":+ r = _orchestrate_open_lbr({"filePath": state["sampleLbr"]})+ if not r.get("success"):+ raise RuntimeError("open_lbr: " + str(r.get("error")))+ _snap(label)+ elif key in ("symbol", "footprint", "device"):+ suffix = {"symbol": "sym", "footprint": "pac", "device": "dev"}[key]+ r = _show_library_view(state.get("deviceset") or "ADS8588SIPM", suffix)+ if not r.get("success"):+ raise RuntimeError(str(r.get("error")))+ if r.get("screenshot"):+ state["shots"].append({"title": label, "label": label, "path": r["screenshot"]})+ elif key == "3d-package":+ cp = _create_3d_package(state["sampleLbr"], timeout=60)+ if cp.get("success"):+ time.sleep(3)+ _snap(label)+ _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)+ time.sleep(2)+ try:+ _dismiss_dialogs_bg()+ except Exception:+ pass+ # Close the Package3D generator doc so it cannot linger as the+ # active view and pollute the design-side screenshots (2026-08-15:+ # shots 6-9 were four copies of this screen).+ _proxy_to_addin("close_document", {"save": False}, timeout=30)+ time.sleep(1)+ else:+ state.setdefault("warnings", []).append("3d-package step skipped: " + str(cp.get("error")))+ state.setdefault("failedSteps", []).append(key)+ elif key == "make-project":+ _wait_idle()+ r = _orchestrate_make_test_project({"libraryPath": state["sampleLbr"]})+ if r.get("success"):+ time.sleep(3)+ _snap(label)+ else:+ state.setdefault("failedSteps", []).append(key)+ state.setdefault("warnings", []).append("make-project: " + str(r.get("error"))[:120])+ state["designOpenFailed"] = True+ elif key == "open-design":+ _wait_idle()+ dispatch_command("open_cloud_file", {"fileName": query, "wait": False})+ deadline = time.time() + 170+ design_open = False+ while time.time() < deadline:+ st = _proxy_to_addin("get_app_state", {}, timeout=10)+ if query.lower() in str((st.get("data") or {}).get("activeDocument", "")).lower():+ design_open = True+ break+ time.sleep(5)+ if design_open:+ time.sleep(3)+ _snap(label)+ else:+ # A screenshot of NOT-the-design is worse than an honest failure:+ # record it and let the dependent view steps skip themselves.+ state.setdefault("failedSteps", []).append(key)+ state.setdefault("warnings", []).append(+ "open-design: %r never became the active document within 170s" % query)+ state["designOpenFailed"] = True+ elif key == "datahub":+ _wait_idle() # ralph run 649s: the 3D save was still grinding; busy != broken+ r = _proxy_to_addin("list_cloud_projects", {}, timeout=45)+ if r.get("success"):+ _d = r.get("data") or {}+ _projs = _d.get("projects") or _d.get("dataProjects") or []+ state.setdefault("highlights", {})["datahub"] = {+ "projects": len(_projs),+ "names": [str(p.get("name") or p)[:40] for p in _projs[:6]]}+ else:+ state.setdefault("warnings", []).append(+ "datahub: " + str(r.get("error"))[:100])+ elif key == "aps":+ _q = (state.get("deviceset") or "ADS8588").split("/")[-1][:20]+ _t0 = time.time()+ r = dispatch_command("aps_search", {"query": _q, "limit": 5})+ if r.get("success"):+ _hits = r.get("results") or r.get("items") or (r.get("data") or {}).get("results") or []+ state.setdefault("highlights", {})["aps"] = {+ "query": _q, "hits": len(_hits), "seconds": round(time.time() - _t0, 1)}+ else:+ state.setdefault("warnings", []).append(+ "aps: %s (sign in once with fusion_aps_signin and this step lights up)"+ % str(r.get("error"))[:80])+ elif key == "mcp":+ r = dispatch_command("mcp_tools", {})+ if r.get("success"):+ _tools = r.get("tools") or (r.get("data") or {}).get("tools") or []+ _names = [str(t.get("name") or t)[:32] for t in _tools][:20]+ state.setdefault("highlights", {})["mcp"] = {+ "toolCount": len(_tools), "tools": _names,+ "beyond": ("The bridge's fusion_* surface is ~120 verbs: electronics "+ "editors, EAGLE text commands, cloud files, APS search, "+ "evidence captures, and 14 export formats - none of which "+ "the MCP toolset offers.")}+ else:+ state.setdefault("warnings", []).append(+ "mcp: %s (fusion_mcp_enable turns it on in ~10s)" % str(r.get("error"))[:80])+ elif key == "exports":+ _wait_idle()+ _exp_dir = "C:/tmp/fusion-tour-exports"+ _got = []+ for _fmt, _verb in (("step", "export_step"), ("stl", "export_stl")):+ _p = "%s/%s.%s" % (_exp_dir, (state.get("deviceset") or "tour"), _fmt)+ r = dispatch_command(_verb, {"outputPath": _p})+ if r.get("success"):+ _got.append(_fmt.upper())+ else:+ state.setdefault("warnings", []).append(+ "%s: %s" % (_verb, str(r.get("error"))[:80]))+ if _got:+ state.setdefault("highlights", {})["exports"] = {+ "formats": _got, "dir": _exp_dir,+ "also": ["F3D", "GLB", "3MF", "IGES", "SAT", "DXF", "DWG", "OBJ",+ "FBX", "SKP", "USDZ", "gerbers", "BOM", "CPL"]}+ _snap(label)+ elif key in ("schematic", "2d-board", "3d-board"):+ if state.get("designOpenFailed"):+ state.setdefault("failedSteps", []).append(key)+ continue+ cmd = {"schematic": "show_schematic", "2d-board": "show_2d_board", "3d-board": "show_3d_board"}[key]+ r = dispatch_command(cmd, {})+ if r.get("success"):+ time.sleep(2)+ _snap(label)+ else:+ state.setdefault("failedSteps", []).append(key)+ state.setdefault("warnings", []).append("%s: %s" % (key, str(r.get("error"))[:120]))+ # Every finished step is a MEASURED sample - this is what turns the next run's+ # estimate from a literal into this machine's own p50 (issue #32).+ try:+ _tm.record(key, time.time() - _step_started,+ "cold" if key == "launch" and cold else None)+ except Exception:+ pass+ done_est += dur+ state.update(percent=int(100 * done_est / est),+ elapsedSec=int(time.time() - state["started"]))+ n_shots = len(state.get("shots") or [])+ if args.get("captions", True):+ _tour_caption("Tour complete: library, symbol, footprint, 3D chip, Data Hub, "+ "APS search, MCP, a generated project in schematic, 2D and 3D, "+ "and real exports - all driven in the background by the bridge.", 20)+ state.update(done=True, percent=100, step="done", stepLabel="Tour complete",+ narrate="Fusion tour complete: %d views captured across the electronics library%s." % (+ n_shots, " and the board design" if include_design and query else ""),+ elapsedSec=int(time.time() - state["started"]))+ except Exception as e:+ state.setdefault("failedSteps", []).append(state.get("step"))+ state.update(failed=True, done=True, error=str(e)[:300],+ narrate="Fusion tour stopped at '%s': %s" % (state.get("stepLabel"), str(e)[:120]),+ elapsedSec=int(time.time() - state["started"]))+ finally:+ # TOUR HYGIENE (John's 9-open-docs screenshot, 2026-08-24: SIX copies of+ # ADOM-TEST-ADS8588SIPM left behind): same guarantee the create_library chain+ # got in 1.9.182/188 - close every doc the tour opened, plus unmodified+ # Untitleds, success or failure. Pre-existing docs are never touched.+ try:+ state["tourDocsClosed"] = _close_docs_opened_since(_docs_before_tour)+ except Exception:+ pass+ if args.get("captions", True):+ try:+ _ad_call("desktop_caption", {"action": "hide", "id": "fusion-tour"}, timeout=8)+ except Exception:+ pass+ _demo_tour_persist(state)++ threading.Thread(target=_run, daemon=True, name="fusion-demo-tour").start()+ return {"success": True, "demoId": demo_id, "estimatedSec": est,+ "steps": [{"step": s[0], "label": s[1], "estimatedSec": s[2],+ "caption": _TOUR_EXPLAIN.get(s[0]) or s[1]} for s in steps],+ "statusVerb": "fusion_demo_progress",+ "_hint": ("Tour started ASYNC (background by default - pass foreground:true only if the "+ "user wants to WATCH). Poll fusion_demo_progress {\"demoId\": \"%s\"} about "+ "once a second and render percent/stepLabel as a real progress bar. The final "+ "poll (done:true) carries the screenshot array.") % demo_id}+++def _handle_demo_control(args: dict) -> dict:+ """Transport control for a running tour (John, 2026-08-24: "play/pause/next/prev/exit+ ... an easy way to stop the demo"). Granularity is BETWEEN steps: a running Fusion verb+ cannot be preempted, so pause/skip/exit take effect at the next step boundary (a few+ seconds to a minute). actions: pause | resume | next (skip the upcoming step) |+ prev (re-run the last completed VIEW step) | exit (abort + close the tour's docs)."""+ demo_id = args.get("demoId") or ""+ action = (args.get("action") or "").lower()+ with _DEMO_TOURS_LOCK:+ state = _DEMO_TOURS.get(demo_id)+ if state is None and not demo_id and len(_DEMO_TOURS) == 1:+ state = list(_DEMO_TOURS.values())[0]+ if state is None or state.get("done"):+ return {"success": False, "error": "no running tour" + (" with id " + demo_id if demo_id else ""),+ "_hint": "Controls only apply while a tour is running."}+ if action == "pause":+ state["paused"] = True+ elif action == "resume":+ state["paused"] = False+ elif action == "next":+ state["skipNext"] = True+ state["paused"] = False+ elif action == "prev":+ state["replayPrev"] = True+ state["paused"] = False+ elif action == "exit":+ state["abort"] = True+ state["paused"] = False+ else:+ return {"success": False, "error": "action must be pause|resume|next|prev|exit"}+ return {"success": True, "action": action, "paused": bool(state.get("paused")),+ "_hint": "Takes effect at the next step boundary - a running Fusion verb finishes first."}+++def _handle_demo_progress(args: dict) -> dict:+ demo_id = args.get("demoId") or ""+ with _DEMO_TOURS_LOCK:+ state = _DEMO_TOURS.get(demo_id)+ # Fall back to "the single running tour" ONLY when no id was given at all -+ # a WRONG id must refuse (the suite's #unknown negative test enforces this).+ if state is None and not demo_id and len(_DEMO_TOURS) == 1:+ state = list(_DEMO_TOURS.values())[0]+ persisted = False+ if state is None and demo_id:+ # Respawn-survival (ab #30): finished tours are snapshotted to disk, so the final+ # frame stays pollable even after bridge_install replaced this process mid-poll.+ state = _demo_tour_load(demo_id)+ persisted = state is not None+ if state is None:+ return {"success": False, "error": "unknown demoId " + demo_id,+ "_hint": "Start a tour first: fusion_demo {\"mode\": \"tour\"}. Its response carries the demoId. Finished tours stay pollable (disk snapshot) even across bridge respawns - an unknown id here means it never ran on this box."}+ out = {"success": True}+ out.update({k: v for k, v in state.items() if k != "started"})+ if persisted:+ out["fromSnapshot"] = True+ # ── stepShot: the DURING evidence (ab's exact contract, #30 2026-08-16): one shot per+ # completed step, riding the FIRST progress frame after that step completes, ~640px+ # wide base64 - the bridge knows the exact completion moment and surface, the page+ # does not. Delivery cursor lives in the tour state; encode failure still advances so+ # one unreadable file can never stall the stream. The final shots gallery (below and+ # at done:true) is unchanged - that is the AFTER evidence.+ if not persisted and args.get("sinceShot") is None:+ # stepShot only for callers that do NOT window with sinceShot: its delivery cursor+ # is per-TOUR, so two windowing viewers would steal frames from each other, and+ # windowing callers already get every shot exactly once (2026-08-24 gallery fix).+ _sent = int(state.get("stepShotSent") or 0)+ _all = list(state.get("shots") or [])+ if _sent < len(_all):+ s = _all[_sent]+ img = _shot_b64_small(s.get("path") or "")+ state["stepShotSent"] = _sent + 1+ if img:+ out["stepShot"] = {"step": s.get("label"), "title": s.get("title") or s.get("label"),+ "image": img}+ # ── Progressive per-step EVIDENCE (John, 2026-08-16): shots stream to the caller AS+ # each step completes, not only at done:true. Contract for a live evidence gallery:+ # poll fusion_demo_progress {demoId, imagesAs:"base64", sinceShot:<count you have>}+ # -> shots = ONLY the new ones (each with .image base64), shotCount = total so far.+ # sinceShot keeps 1/sec polls cheap: unchanged evidence is never re-encoded/re-sent.+ # Shot dicts are COPIED before encoding - mutating state's own dicts embedded base64+ # into the shared tour state (memory bloat + every later poll re-shipping it all).+ shots = list(out.get("shots") or [])+ out["shotCount"] = len(shots)+ try:+ since = max(0, int(args.get("sinceShot") or 0))+ except Exception:+ since = 0+ if since:+ shots = shots[since:]+ imgs = args.get("imagesAs") or ("base64" if state.get("done") else "paths")+ if imgs in ("base64", "both"):+ import base64+ encoded = []+ for s in shots:+ s = dict(s)+ try:+ raw = open(s["path"], "rb").read()+ if len(raw) <= 4 * 1024 * 1024:+ s["image"] = base64.b64encode(raw).decode()+ else:+ # PIL-less bridge python saves raw 24MB BMPs - NEVER ship those inline+ # (9 x 31MB base64 = a ~280MB frame). Downscaled evidence instead.+ img = _shot_b64_small(s["path"], max_w=1280)+ if img:+ s["image"] = img+ s["imageNote"] = "downscaled to <=1280px (original is a %dMB capture; pull_file the path for full size)" % (len(raw) // (1024 * 1024))+ except Exception:+ pass+ encoded.append(s)+ shots = encoded+ out["shots"] = shots+ if not state.get("done"):+ out["_hint"] = ("Tour running (%s). shots[] carries evidence for every COMPLETED step "+ "already - render it live; pass sinceShot=%d on the next poll to receive "+ "only new shots." % (out.get("stepLabel"), out["shotCount"]))+ return out+++++# ── Async wrapper for export_optimized_glb (2026-08-14): the verb's STEP-export phase+# alone can outlive ab's 60s relay window (fresh-session Fusion), so the sync form dies+# at the relay even though the work completes. async:true fires the whole pipeline in a+# thread and returns an exportId instantly; fusion_fetch_optimized_glb {exportId} reports+# phase -> jobId -> complete. Same pattern as the demo tour.+_GLB_ASYNC: dict = {}+_GLB_ASYNC_LOCK = threading.Lock()+++def _start_glb_export_async(args: dict) -> dict:+ import time+ import uuid+ export_id = "glbx-" + uuid.uuid4().hex[:8]+ state = {"exportId": export_id, "phase": "exporting-step", "done": False,+ "failed": False, "started": time.time()}+ with _GLB_ASYNC_LOCK:+ _GLB_ASYNC[export_id] = state++ inner = {k: v for k, v in args.items() if k != "async"}+ inner.setdefault("wait", 20)++ def _run():+ begin_background_envelope(400.0)+ try:+ res = _orchestrate_export_optimized_glb(inner)+ state["result"] = res+ if res.get("glbPath") and res.get("success"):+ state.update(phase="complete", done=True)+ return+ job = res.get("jobId")+ if not (res.get("success") and job):+ state.update(phase="failed", failed=True, done=True,+ error=str(res.get("error"))[:200])+ return+ state.update(phase="optimizing", jobId=job)+ deadline = time.time() + 600+ while time.time() < deadline:+ f = _orchestrate_fetch_optimized_glb(+ {"jobId": job, "outputPath": inner.get("outputPath"), "wait": 20})+ state["result"] = f+ if f.get("glbPath") and not f.get("pending"):+ state.update(phase="complete", done=True)+ return+ if not f.get("success") and not f.get("pending"):+ state.update(phase="failed", failed=True, done=True,+ error=str(f.get("error"))[:200])+ return+ time.sleep(5)+ state.update(phase="failed", failed=True, done=True, error="optimizer did not finish within 10 min")+ except Exception as e:+ state.update(phase="failed", failed=True, done=True, error=str(e)[:200])++ threading.Thread(target=_run, daemon=True, name="glb-export-async").start()+ return {"success": True, "pending": True, "exportId": export_id,+ "statusVerb": "fusion_fetch_optimized_glb",+ "_hint": ("Export running ASYNC (the sync form can outlive the relay window on a cold "+ "Fusion session). Poll fusion_fetch_optimized_glb {\"exportId\": \"%s\"} "+ "every few seconds: phase walks exporting-step -> optimizing -> complete, and "+ "the final poll carries glbPath/sizeBytes.") % export_id}+++def _glb_export_async_status(args: dict) -> dict:+ import time+ export_id = args.get("exportId") or ""+ with _GLB_ASYNC_LOCK:+ state = _GLB_ASYNC.get(export_id)+ if state is None:+ return {"success": False, "error": "unknown exportId " + export_id,+ "_hint": "Start one with fusion_export_optimized_glb {async: true}; its response carries the exportId."}+ out = {"success": not state.get("failed"), "exportId": export_id,+ "phase": state.get("phase"), "done": state.get("done"),+ "elapsedSec": int(time.time() - state["started"])}+ if state.get("jobId"):+ out["jobId"] = state["jobId"]+ if state.get("error"):+ out["error"] = state["error"]+ if state.get("done") and isinstance(state.get("result"), dict):+ for k in ("glbPath", "sizeBytes", "meshesAfter", "moleculeAnchored", "silkscreenApplied"):+ if k in state["result"]:+ out[k] = state["result"][k]+ return out+++++# ── fusion_make_test_project (John, 2026-08-15): the Web Control demo's PROJECT button.+# "None of our other adom user customers will even have a BQ25792" - a customer's session+# has exactly ONE thing: a component page's .lbr. This verb closes the circle from it:+# it GENERATES a minimal test circuit (the part + a generated series resistor on a small+# board) as real EAGLE .sch/.brd server-side, then imports the pair as a NEW electronics+# design. No cloud, no sign-in, no pre-existing content. The schematic/2D/3D buttons then+# work on the result.++def _mtp_extract(lbr_text, deviceset):+ """Pull the pieces the generator needs out of the .lbr."""+ def block_of(tag, name):+ m = re.search(r'<%s name="%s"[\s>].*?</%s>' % (tag, re.escape(name), tag), lbr_text, re.S)+ return m.group(0) if m else None+ ds = block_of("deviceset", deviceset)+ if not ds:+ return None, "deviceset %r not found in the library" % deviceset+ gate = re.search(r'<gate name="([^"]+)" symbol="([^"]+)"', ds)+ prefix_m = re.search(r'<deviceset name="[^"]+"[^>]*prefix="([^"]+)"', ds)+ dev = re.search(r'<device name="[^"]*" package="([^"]+)"', ds)+ if not (gate and dev):+ return None, "deviceset %r has no gate/package device mapping" % deviceset+ connects = re.findall(r'<connect gate="[^"]+" pin="([^"]+)" pad="([^"]+)"', ds)+ # Strip 3D binding refs from the embedded deviceset: a package3dinstance whose+ # packages3d section is not ALSO embedded is a dangling ref, and Fusion then+ # renders the schematic EMPTY (caught 2026-08-15: bound-library test projects+ # imported blank sheets). The test project needs no 3D binding.+ ds = re.sub(r'<package3dinstances>.*?</package3dinstances>', '', ds, flags=re.S)+ sym = block_of("symbol", gate.group(2))+ pkg = block_of("package", dev.group(1))+ if not (sym and pkg and connects):+ return None, "library is missing the symbol/package/connects for %r" % deviceset+ layers = re.search(r'<layers>.*?</layers>', lbr_text, re.S)+ return {"deviceset": ds, "symbol": sym, "package": pkg, "connects": connects,+ "symName": gate.group(2), "gateName": gate.group(1), "pkgName": dev.group(1),+ "prefix": (prefix_m.group(1) if prefix_m else "U"),+ "layers": layers.group(0) if layers else ""}, None+++_MTP_R_SYM = ('<symbol name="ADOMR"><wire x1="-2.54" y1="1.016" x2="2.54" y2="1.016" width="0.254" layer="94"/>'+ '<wire x1="2.54" y1="1.016" x2="2.54" y2="-1.016" width="0.254" layer="94"/>'+ '<wire x1="2.54" y1="-1.016" x2="-2.54" y2="-1.016" width="0.254" layer="94"/>'+ '<wire x1="-2.54" y1="-1.016" x2="-2.54" y2="1.016" width="0.254" layer="94"/>'+ '<pin name="1" x="-5.08" y="0" visible="off" length="short"/>'+ '<pin name="2" x="5.08" y="0" visible="off" length="short" rot="R180"/>'+ '<text x="-2.54" y="1.524" size="1.778" layer="95">>NAME</text>'+ '<text x="-2.54" y="-3.302" size="1.778" layer="96">>VALUE</text></symbol>')+_MTP_R_PKG = ('<package name="ADOMR0603"><description>Resistor 0603</description>'+ '<smd name="1" x="-0.85" y="0" dx="1" dy="1" layer="1"/>'+ '<smd name="2" x="0.85" y="0" dx="1" dy="1" layer="1"/>'+ '<wire x1="-0.4" y1="0.5" x2="0.4" y2="0.5" width="0.127" layer="21"/>'+ '<wire x1="-0.4" y1="-0.5" x2="0.4" y2="-0.5" width="0.127" layer="21"/>'+ '<text x="-1" y="0.8" size="0.8" layer="25">>NAME</text></package>')+_MTP_R_DS = ('<deviceset name="ADOMR" prefix="R"><description>generated test resistor</description>'+ '<gates><gate name="G$1" symbol="ADOMR" x="0" y="0"/></gates>'+ '<devices><device name="" package="ADOMR0603">'+ '<connects><connect gate="G$1" pin="1" pad="1"/><connect gate="G$1" pin="2" pad="2"/></connects>'+ '<technologies><technology name=""/></technologies></device></devices></deviceset>')+_MTP_SCH_LAYERS = ('<layers>'+ '<layer number="91" name="Nets" color="2" fill="1" visible="yes" active="yes"/>'+ '<layer number="92" name="Busses" color="1" fill="1" visible="yes" active="yes"/>'+ '<layer number="93" name="Pins" color="2" fill="1" visible="no" active="yes"/>'+ '<layer number="94" name="Symbols" color="4" fill="1" visible="yes" active="yes"/>'+ '<layer number="95" name="Names" color="7" fill="1" visible="yes" active="yes"/>'+ '<layer number="96" name="Values" color="7" fill="1" visible="yes" active="yes"/>'+ '<layer number="97" name="Info" color="7" fill="1" visible="yes" active="yes"/>'+ '<layer number="98" name="Guide" color="6" fill="1" visible="yes" active="yes"/></layers>')+_MTP_HEAD = ('<?xml version="1.0" encoding="utf-8"?>\n<!DOCTYPE eagle SYSTEM "eagle.dtd">\n'+ '<eagle version="9.6.2">\n<drawing>\n<settings><setting alwaysvectorfont="no"/></settings>'+ '<grid distance="0.1" unitdist="inch" unit="inch" style="lines" multiple="1" display="no" '+ 'altdistance="0.01" altunitdist="inch" altunit="inch"/>')+++_R3D_CACHE = None # loaded once per bridge process+++def _mtp_resistor():+ """The REAL series resistor for test projects: the wiki basic part Royalohm+ 0603WAF3300T5E (330R 0603), bundled in resources/samples (John, 2026-08-15: a+ synthetic 3D-less resistor 'proves' nothing - real parts only). Returns+ (extract, lbr_text) or (None, None) when the bundle is missing.+ Its 3D urn is hub-scoped, so it is minted ONCE per box (make_3d_package on the+ bundled lbr auto-caches it in ~/.adom/fusion-bridge/r3d-urn.json) and reused."""+ import os+ p = os.path.join(os.path.dirname(os.path.abspath(__file__)),+ "resources", "samples", "0603WAF3300T5E.lbr")+ if not os.path.isfile(p):+ return None, None+ txt = open(p, encoding="utf-8", errors="replace").read()+ ex, err = _mtp_extract(txt, "0603WAF3300T5E")+ return (None, None) if err else (ex, txt)+++def _r3d_cached_urn():+ global _R3D_CACHE+ if _R3D_CACHE is not None:+ return _R3D_CACHE+ import json as _json+ import os+ p = os.path.expanduser("~/.adom/fusion-bridge/r3d-urn.json")+ try:+ _R3D_CACHE = _json.load(open(p)).get("0603WAF3300T5E", "")+ except Exception:+ _R3D_CACHE = ""+ return _R3D_CACHE+++def _r3d_cache_write(urn):+ import json as _json+ import os+ d = os.path.expanduser("~/.adom/fusion-bridge")+ os.makedirs(d, exist_ok=True)+ _json.dump({"0603WAF3300T5E": urn}, open(os.path.join(d, "r3d-urn.json"), "w"))+++def _mtp_generate(lib_name, ex, part_name, urn="", rex=None, r_urn="", solo=True):+ """Build the .sch/.brd pair. R1 is the REAL bundled resistor part (rex) when+ available, falling back to the legacy synthetic ADOMR. Wiring: R1.pad2-side pin ->+ the part's first connected pin; GND -> its second; VCC -> R1's first pin."""+ pins = [p for p, _ in ex["connects"]]+ pads = {p: pad for p, pad in ex["connects"]}+ p1, p2 = pins[0], (pins[1] if len(pins) > 1 else pins[0])+ lib = ('<library name="%s"><packages>%s</packages>' % (lib_name, ex["package"])+ + (('<packages3d><package3d name="' + part_name + '" urn="" wip_urn="' + urn + '" locally_modified="yes" type="model">'+ '<packageinstances><packageinstance name="' + ex["pkgName"] + '"/></packageinstances>'+ '</package3d></packages3d>') if urn else '')+ + '<symbols>%s</symbols><devicesets>%s</devicesets></library>' % (ex["symbol"], ex["deviceset"]))+ if rex:+ r_lib_name, r_ds, r_gate = "adom-resistor", "0603WAF3300T5E", rex["gateName"]+ r_pins = [p for p, _ in rex["connects"]]+ r_pin1, r_pin2 = r_pins[0], (r_pins[1] if len(r_pins) > 1 else r_pins[0])+ r_pads = {p: pad for p, pad in rex["connects"]}+ rlib = ('<library name="adom-resistor"><packages>%s</packages>' % rex["package"]+ + (('<packages3d><package3d name="' + r_ds + '" urn="" wip_urn="' + r_urn + '" locally_modified="yes" type="model">'+ '<packageinstances><packageinstance name="' + rex["pkgName"] + '"/></packageinstances>'+ '</package3d></packages3d>') if r_urn else '')+ + '<symbols>%s</symbols><devicesets>%s</devicesets></library>' % (rex["symbol"], rex["deviceset"]))+ r_part = '<part name="R1" library="adom-resistor" deviceset="%s" device="" value="330"/>' % r_ds+ r_pkg_name = rex["pkgName"]+ else:+ r_lib_name, r_ds, r_gate = "adomdemo", "ADOMR", "G$1"+ r_pin1, r_pin2 = "1", "2"+ r_pads = {"1": "1", "2": "2"}+ rlib = ('<library name="adomdemo"><packages>%s</packages><symbols>%s</symbols><devicesets>%s</devicesets></library>'+ % (_MTP_R_PKG, _MTP_R_SYM, _MTP_R_DS))+ r_part = '<part name="R1" library="adomdemo" deviceset="ADOMR" device="" value="330"/>'+ r_pkg_name = "ADOMR0603"+ g = ex["gateName"]+ # Correct EE designator from the deviceset's own prefix (John, 2026-08-16: "why is+ # there a D1 and R1 in a capacitor demo?"): C->C1, D->D1, U->U1; the series resistor+ # is R1, so a resistor-under-test becomes R2.+ pfx = (ex.get("prefix") or "U").strip() or "U"+ part_ref = (pfx + "2") if (pfx.upper() == "R" and not solo) else (pfx + "1")+ if solo:+ # SOLO layout (John, 2026-08-16: "when demoing a single component, you don't need+ # extra components... it's just confusing"): JUST the part - one symbol on the+ # sheet, one element centered on a small board. No resistor, no nets.+ lib_solo = ('<library name="%s"><packages>%s</packages>' % (lib_name, ex["package"])+ + (('<packages3d><package3d name="' + part_name + '" urn="" wip_urn="' + urn + '" locally_modified="yes" type="model">'+ '<packageinstances><packageinstance name="' + ex["pkgName"] + '"/></packageinstances>'+ '</package3d></packages3d>') if urn else '')+ + '<symbols>%s</symbols><devicesets>%s</devicesets></library>' % (ex["symbol"], ex["deviceset"]))+ sch = (_MTP_HEAD + _MTP_SCH_LAYERS ++ '<schematic xreflabel="%F%N/%S.%C%R" xrefpart="/%S.%C%R">'+ '<libraries>' + lib_solo + '</libraries>'+ '<attributes/><variantdefs/><classes><class number="0" name="default" width="0" drill="0"/></classes>'+ '<parts><part name="' + part_ref + '" library="' + lib_name + '" deviceset="' + part_name + '" device=""/></parts>'+ '<sheets><sheet><plain>'+ '<text x="0" y="25.4" size="2.54" layer="97">' + part_name + ' - Adom component demo</text>'+ '</plain><instances>'+ '<instance part="' + part_ref + '" gate="' + ex["gateName"] + '" x="25.4" y="12.7"/>'+ '</instances><busses/><nets/></sheet></sheets></schematic></drawing></eagle>\n')+ brd = (_MTP_HEAD + ex["layers"] ++ '<board><plain>'+ '<wire x1="0" y1="0" x2="8" y2="0" width="0.2" layer="20"/>'+ '<wire x1="8" y1="0" x2="8" y2="6" width="0.2" layer="20"/>'+ '<wire x1="8" y1="6" x2="0" y2="6" width="0.2" layer="20"/>'+ '<wire x1="0" y1="6" x2="0" y2="0" width="0.2" layer="20"/></plain>'+ '<libraries><library name="' + lib_name + '"><packages>' + ex["package"] + '</packages>'+ + (('<packages3d><package3d name="' + part_name + '" urn="" wip_urn="' + urn + '" locally_modified="yes" type="model">'+ '<packageinstances><packageinstance name="' + ex["pkgName"] + '"/></packageinstances>'+ '</package3d></packages3d>') if urn else '')+ + '</library></libraries>'+ '<attributes/><variantdefs/><classes><class number="0" name="default" width="0" drill="0"/></classes>'+ '<designrules name="default"/><autorouter/>'+ '<elements><element name="' + part_ref + '" library="' + lib_name + '" package="' + ex["pkgName"] + '" value="" x="4" y="3"'+ + ((' package3d_urn="' + urn + '"') if urn else '') + '/></elements>'+ '<signals/></board></drawing></eagle>\n')+ return sch, brd+ sch = (_MTP_HEAD + _MTP_SCH_LAYERS ++ '<schematic xreflabel="%F%N/%S.%C%R" xrefpart="/%S.%C%R">'+ '<libraries>' + lib + rlib + '</libraries>'+ '<attributes/><variantdefs/><classes><class number="0" name="default" width="0" drill="0"/></classes>'+ '<parts>'+ '<part name="' + part_ref + '" library="' + lib_name + '" deviceset="' + part_name + '" device=""/>'+ + r_part ++ '</parts><sheets><sheet><plain>'+ '<text x="0" y="30.48" size="2.54" layer="97">Adom generated test circuit: ' + part_name + '</text>'+ '</plain><instances>'+ '<instance part="R1" gate="' + r_gate + '" x="12.7" y="12.7"/>'+ '<instance part="' + part_ref + '" gate="' + g + '" x="38.1" y="12.7"/>'+ '</instances><busses/><nets>'+ '<net name="N1" class="0"><segment>'+ '<wire x1="17.78" y1="12.7" x2="25.4" y2="12.7" width="0.1524" layer="91"/>'+ '<pinref part="R1" gate="' + r_gate + '" pin="' + r_pin2 + '"/><pinref part="' + part_ref + '" gate="' + g + '" pin="' + p1 + '"/>'+ '</segment></net>'+ '<net name="VCC" class="0"><segment>'+ '<wire x1="7.62" y1="12.7" x2="2.54" y2="12.7" width="0.1524" layer="91"/>'+ '<pinref part="R1" gate="' + r_gate + '" pin="' + r_pin1 + '"/></segment></net>'+ '<net name="GND" class="0"><segment>'+ '<pinref part="' + part_ref + '" gate="' + g + '" pin="' + p2 + '"/></segment></net>'+ '</nets></sheet></sheets></schematic></drawing></eagle>\n')+ brd = (_MTP_HEAD + ex["layers"] ++ '<board><plain>'+ '<wire x1="0" y1="0" x2="14" y2="0" width="0.2" layer="20"/>'+ '<wire x1="14" y1="0" x2="14" y2="9" width="0.2" layer="20"/>'+ '<wire x1="14" y1="9" x2="0" y2="9" width="0.2" layer="20"/>'+ '<wire x1="0" y1="9" x2="0" y2="0" width="0.2" layer="20"/></plain>'+ '<libraries>' + lib.replace('<symbols>' + ex["symbol"] + '</symbols><devicesets>' + ex["deviceset"] + '</devicesets>', '')+ + rlib.replace('<symbols>' + (rex["symbol"] if rex else _MTP_R_SYM) + '</symbols><devicesets>' + (rex["deviceset"] if rex else _MTP_R_DS) + '</devicesets>', '')+ + '</libraries>'+ '<attributes/><variantdefs/><classes><class number="0" name="default" width="0" drill="0"/></classes>'+ '<designrules name="default"/><autorouter/>'+ '<elements>'+ '<element name="R1" library="' + r_lib_name + '" package="' + r_pkg_name + '" value="330" x="3.5" y="4.5"'+ + ((' package3d_urn="' + r_urn + '"') if (rex and r_urn) else '') + '/>'+ '<element name="' + part_ref + '" library="' + lib_name + '" package="' + ex["pkgName"] + '" value="" x="9.5" y="4.5"'+ + ((' package3d_urn="' + urn + '"') if urn else '') + '/>'+ '</elements><signals>'+ '<signal name="N1"><contactref element="R1" pad="' + r_pads[r_pin2] + '"/><contactref element="' + part_ref + '" pad="' + pads[p1] + '"/>'+ '<wire x1="4.35" y1="4.5" x2="8.5" y2="4.5" width="0.3" layer="1"/></signal>'+ '<signal name="VCC"><contactref element="R1" pad="' + r_pads[r_pin1] + '"/></signal>'+ '<signal name="GND"><contactref element="' + part_ref + '" pad="' + pads[p2] + '"/></signal>'+ '</signals></board></drawing></eagle>\n')+ return sch, brd+++_TEST_PROJECT: dict = {}+try: # restore the last generated test project so the DESIGN surfaces (project/schematic/+ # 2d/3d) survive a bridge respawn - an install used to wipe this and those four verbs+ # failed with "query or urn required" until something re-staged (ralph, 2026-08-18).+ _tp = _get_bridge_pref("testProject", None)+ if isinstance(_tp, dict) and _tp.get("sch") and _tp.get("brd"):+ if os.path.isfile(_tp["sch"]) and os.path.isfile(_tp["brd"]):+ _TEST_PROJECT = _tp+except Exception:+ pass+++def _test_project_activate(kind):+ """Activate the test project's sch or brd DOCUMENT (newDesignFromLocal re-open =+ activate). Returns True when the doc is confirmed active."""+ import time as _t+ if not _TEST_PROJECT:+ return False+ path = _TEST_PROJECT.get(kind)+ if not path:+ return False+ _proxy_to_addin("execute_text_command", {"command": "Document.newDesignFromLocal %s" % path}, timeout=60)+ for _ in range(6):+ _t.sleep(2)+ st = _proxy_to_addin("get_app_state", {}, timeout=10)+ if _TEST_PROJECT["docBase"] in str((st.get("data") or {}).get("activeDocument", "")).lower():+ return True+ return False+++def _orchestrate_make_test_project(args: dict) -> dict:+ """Generate + import a minimal test project around a library part.++ args: libraryPath (required, Windows path .lbr), deviceset (optional: defaults to+ the library's first deviceset).+ """+ import os+ import tempfile+ lbr_path = (args.get("libraryPath") or "").replace("\\", "/")+ if not lbr_path:+ return {"success": False, "error": "libraryPath (.lbr, Windows path) required.",+ "_hint": "Stage the component page's .lbr onto the box first (send_files)."}+ try:+ lbr_text = open(lbr_path, encoding="utf-8", errors="replace").read()+ except Exception as e:+ return {"success": False, "error": "could not read %s: %s" % (lbr_path, e)}+ deviceset = args.get("deviceset")+ if not deviceset:+ m = re.search(r'<deviceset name="([^"]+)"', lbr_text)+ if not m:+ return {"success": False, "error": "no deviceset found in the library"}+ deviceset = m.group(1)+ ex, err = _mtp_extract(lbr_text, deviceset)+ if err:+ return {"success": False, "error": err}+ lib_name = re.sub(r"[^A-Za-z0-9_-]", "_", os.path.basename(lbr_path).rsplit(".", 1)[0])+ # The source library's 3D binding (if any) rides into the generated BOARD - each+ # element gets package3d_urn so the 3D PCB renders the REAL component model, not a+ # generic extrusion (John caught every test board showing black boxes, 2026-08-15).+ urn_m = re.search(r'wip_urn="([^"]+)"', lbr_text)+ solo = not bool(args.get("withResistor"))+ rex, _rtxt = (None, None) if solo else _mtp_resistor()+ r_urn = _r3d_cached_urn() if rex else ""+ sch_text, brd_text = _mtp_generate(lib_name, ex, deviceset,+ urn_m.group(1) if urn_m else "", rex=rex, r_urn=r_urn, solo=solo)+ d = os.path.join(tempfile.gettempdir(), "adom-fusion-wrap")+ os.makedirs(d, exist_ok=True)+ base = "ADOM-TEST-" + re.sub(r"[^A-Za-z0-9_-]", "_", deviceset)[:40]+ sch_path = os.path.join(d, base + ".sch").replace("\\", "/")+ brd_path = os.path.join(d, base + ".brd").replace("\\", "/")+ open(sch_path, "w", encoding="utf-8").write(sch_text)+ open(brd_path, "w", encoding="utf-8").write(brd_text)+ # ⛔ DIALOG-FREE ONLY (John, 2026-08-15, after ImportSCHAndBRDCmd's native Open dialogs+ # wedged his Fusion): Document.newDesignFromLocal opens each file with ZERO dialogs and+ # ZERO foreground - the same primitive open_lbr/open_board use. The .brd (elements+ # placed + embedded libraries) INSTANTIATES the board; the .sch opens as its own+ # drawn schematic document. Never route this through new_electronics_from_eagle.+ import time as _t+ steps = []+ for label, fpath in (("board", brd_path), ("schematic", sch_path)):+ r = _proxy_to_addin("execute_text_command",+ {"command": "Document.newDesignFromLocal %s" % fpath}, timeout=120)+ okd = bool(r.get("success"))+ if not okd:+ expected = fpath.rsplit("/", 1)[-1].rsplit(".", 1)[0].lower()+ for _ in range(10):+ _t.sleep(3)+ st = _proxy_to_addin("get_app_state", {}, timeout=10)+ if expected in str((st.get("data") or {}).get("activeDocument", "")).lower():+ okd = True+ break+ steps.append("%s open: %s" % (label, "ok" if okd else "FAILED: " + str(r.get("error"))[:80]))+ if not okd:+ return {"success": False, "schPath": sch_path, "brdPath": brd_path,+ "error": "%s open failed: %s" % (label, str(r.get("error"))[:150]),+ "data": {"steps": steps}}+ _t.sleep(2)+ global _TEST_PROJECT+ _TEST_PROJECT = {"sch": sch_path, "brd": brd_path,+ "docBase": base.lower()}+ # PERSIST: the design surfaces (show_project/schematic/2d/3d) resolve through this handle,+ # and a bridge respawn - which happens on every install - used to wipe it, so those four+ # surfaces failed with "query or urn required" until the caller re-staged (ralph, 2026-08-18).+ # The paths live on the box, so they stay valid across respawns.+ try:+ _set_bridge_pref("testProject", _TEST_PROJECT)+ except Exception:+ pass+ try:+ _note_bridge_doc(base) # generated test docs = bridge docs (sentinel may discard)+ except Exception:+ pass+ out = {"success": True, "schPath": sch_path, "brdPath": brd_path,+ "deviceset": deviceset, "steps": steps,+ "circuit": ("JUST %s on a small board (solo demo layout)" % deviceset) if solo+ else "R1 (330) in series with %s; VCC/GND stubs" % deviceset,+ "_hint": ("Test project opened as TWO documents (board instantiated with placed parts, "+ "schematic as its drawn sheet) via the dialog-free Document.newDesignFromLocal "+ "path. The BOARD document is active: fusion_show_2d_board / fusion_show_3d_board "+ "work now; for the schematic view activate its document (it opened last... the "+ "SCHEMATIC document is actually active) - board legs may need the board doc "+ "re-activated via Document.newDesignFromLocal or open_board. Generated from "+ "NOTHING but the component's .lbr - no cloud, no sign-in.")}+ return out+++def _sweep_untitled_scratch(max_close: int = 24) -> list:+ """Close UNMODIFIED 'Untitled' scratch documents (page issue #59, 2026-08-26).++ The library/bind flow left ~5 Untitled docs per import - 16 after three - and no+ caller could clear them, because close_all_documents skipped every Untitled. They are+ scratch by construction: an UNMODIFIED Untitled has no data file and no edits, so+ closing one cannot lose anything. Modified ones are never touched here; those may hold+ real unsaved work and need an explicit force. Fusion keeps a single blank doc alive on+ its own, which is the 'keep exactly one' the reporter asked for.+ """+ closed = []+ try:+ prev_total = None+ for _ in range(max_close):+ st = _proxy_to_addin("document_info", {}, timeout=8)+ docs = (st.get("data") or {}).get("openDocuments") or []+ untitled = [d for d in docs+ if str(d.get("name") or "").lower().startswith("untitled")+ and not (d.get("isModified") or d.get("modified"))]+ # ⚠ FUSION RE-CREATES A BLANK DOC whenever the last one closes. The add-in's own+ # close_all_documents carries this warning ("do NOT loop on documents.count ...+ # turns while count > 0 into an infinite loop") and I reintroduced it here: on a+ # workspace holding ONE Untitled this closed it 24 times, each time racing a+ # fresh blank, and terminated only because it hit the cap. Leaving the last+ # Untitled alone is also the correct end state - Fusion keeps exactly one.+ if len(untitled) <= 1 and len(docs) == len(untitled):+ break+ if not untitled:+ break+ # NO-PROGRESS GUARD: if the total did not fall after a close, we are fighting the+ # re-create rather than draining a backlog. Stop instead of spinning to the cap.+ if prev_total is not None and len(docs) >= prev_total:+ break+ prev_total = len(docs)+ victim = untitled[0]+ r = _proxy_to_addin("close_document",+ {"name": victim.get("name"), "save": False}, timeout=20)+ if not r.get("success"):+ break # stop on the first refusal rather than spin+ closed.append(str(victim.get("name")))+ if closed:+ print("[Fusion Bridge] swept %d Untitled scratch doc(s): %s"+ % (len(closed), ", ".join(closed[:6])))+ _bridge_action_ledger("untitled_scratch_swept", {"count": len(closed)})+ except Exception:+ pass+ return closed+++def _orchestrate_bind_library_3d(args: dict) -> dict:+ """ONE CALL for the correct library (John's bar: the 3D renders in the Content+ Manager): mint the part's 3D as a cloud f3d, inject the wip_urn binding into the+ .lbr ON DISK, and reopen the library. Needs a signed-in Fusion session (the one+ cloud-dependent step in the circle).++ args: {libraryPath: Windows .lbr (required), stepPath: Windows .step (required),+ deviceset: optional (defaults to the library's first)}+ """+ import time as _t+ lbr_path = (args.get("libraryPath") or "").replace("\\", "/")+ step_path = (args.get("stepPath") or "").replace("\\", "/")+ if not (lbr_path and step_path):+ return {"success": False, "error": "libraryPath (.lbr) and stepPath (.step) required (Windows paths)."}+ try:+ txt = open(lbr_path, encoding="utf-8", errors="replace").read()+ except Exception as e:+ return {"success": False, "error": "cannot read %s: %s" % (lbr_path, e)}+ deviceset = args.get("deviceset")+ if not deviceset:+ m = re.search(r'<deviceset name="([^"]+)"', txt)+ if not m:+ return {"success": False, "error": "no deviceset in the library"}+ deviceset = m.group(1)+ pkg_m = re.search(r'<package name="([^"]+)"', txt)+ pkg_name = pkg_m.group(1) if pkg_m else deviceset+ # IDEMPOTENT (caught live 2026-08-21): re-binding an already-bound library re-uploaded+ # the f3d and hit a 409 name collision in the cloud folder - failing a chain that had+ # NOTHING left to do. A .lbr whose package3d already carries a urn is bound; verify and+ # return success instead of re-minting.+ _existing = re.search(r'<package3d[^>]*urn="([^"]+)"', txt)+ if _existing and not args.get("force"):+ return {"success": True, "alreadyBound": True, "wip_urn": _existing.group(1),+ "deviceset": deviceset, "libraryPath": lbr_path,+ "_hint": ("This library already carries a package3d urn - nothing to mint. "+ "Pass force:true to re-bind (e.g. after replacing the STEP). "+ "fusion_check_3d_thumbnail verifies the cloud thumbnail rendered.")}+ mint = _apply_failure_dialogs(_orchestrate_make_3d_package(+ {"lbrPath": lbr_path, "modelPath": step_path,+ "fileName": "ADOM-%s-3D" % re.sub(r"[^A-Za-z0-9_-]", "_", deviceset)[:40]}))+ urn = mint.get("wip_urn") or ""+ if not urn:+ return {"success": False, "error": "mint failed: " + str(mint.get("error"))[:150],+ "_hint": str(mint.get("_hint") or "")[:150]}+ # WRITE-BACK NOTICE (#52 item 8): the minted binding is written INTO the caller's+ # .lbr on purpose (the file on disk is what Fusion opens, and it must open BOUND) -+ # but silently mutating an input cost the matrix runner a pristine-restore ritual.+ # Keep a one-time .prebind backup beside it and SAY the mutation out loud.+ try:+ import shutil as _sh+ _bak = lbr_path + ".prebind"+ if not os.path.exists(_bak):+ _sh.copyfile(lbr_path, _bak)+ except Exception:+ pass+ txt = re.sub(r'<packages3d>.*?</packages3d>\n?', '', txt, flags=re.S)+ binding = ('<packages3d><package3d name="%s" urn="" wip_urn="%s" locally_modified="yes" type="model">'+ '<packageinstances><packageinstance name="%s"/></packageinstances>'+ '</package3d></packages3d>' % (deviceset, urn, pkg_name))+ txt = txt.replace('</packages>', '</packages>\n' + binding, 1)+ txt = re.sub(r'<package3dinstances>.*?</package3dinstances>', '', txt, flags=re.S)+ txt = txt.replace('<technologies>',+ '<package3dinstances><package3dinstance package3d_urn="%s"/></package3dinstances><technologies>' % urn, 1)+ try:+ open(lbr_path, "w", encoding="utf-8").write(txt)+ except Exception as e:+ return {"success": False, "error": "binding write failed: %s" % e, "wip_urn": urn}+ _t.sleep(1)+ reopened = _orchestrate_open_lbr({"filePath": lbr_path})+ _reopen_ok = bool(reopened.get("success"))+ # THE BIND ITSELF IS DONE once the urn is minted AND written to the .lbr on disk+ # (#49 / #47 failure-mode-3, John 2026-08-23). The re-open here is only a refresh so the+ # prove stage can screenshot the 3D - its failure must NEVER (a) present as a bind failure+ # with error:null, nor (b) discard a real, minted, PERSISTED urn and abort the chain. Two+ # rules from that report: never return error:null on a failure, and a good urn lets the+ # chain continue. So: success rides on the urn+write (the actual binding work), and the+ # re-open is reported separately with its reason surfaced.+ if not _reopen_ok:+ _why = str(reopened.get("error") or reopened.get("_hint")+ or "library re-open returned success:false with no detail (open_lbr)")[:220]+ return {"success": True, "wip_urn": urn, "libraryPath": lbr_path,+ "deviceset": deviceset, "bound": True, "reopened": False, "mutatedInput": True,+ "prebindBackup": lbr_path + ".prebind",+ "reopenError": _why,+ "_hint": ("Binding minted (%s) and WRITTEN to the .lbr - the bind is real and "+ "persisted. Re-opening the library into Fusion failed (reopenError "+ "above); the prove stage opens the library itself, so the chain "+ "continues. If prove's 3D frame is missing, the library just needs a "+ "clean re-open once Fusion is idle." % urn)}+ # #59: the mint spawns scratch Untitled docs. Clear them before returning so a batch+ # caller's workspace stays bounded instead of growing ~5 docs per import.+ _swept = _sweep_untitled_scratch()+ return {"success": True, "wip_urn": urn, "mutatedInput": True,+ "untitledScratchClosed": len(_swept),+ "prebindBackup": lbr_path + ".prebind",+ "libraryPath": lbr_path, "deviceset": deviceset,+ "bound": True, "reopened": True,+ "_hint": ("NOTE: the input .lbr was REWRITTEN with the minted package3d binding "+ "(account-scoped; pristine copy kept at <libraryPath>.prebind). "+ "Library re-opened WITH the 3D binding. The Content Manager's 3D preview "+ "resolves after Autodesk's thumbnail service catches up (~2-3 min for a "+ "fresh mint) - EDIT %s.dev then screenshot to verify the render." % deviceset)}+++def _lbr_has_package3d(lbr_path: str):+ """Does this .lbr carry a bound package3d? Returns True/False, or None when the file+ cannot be read (unknown is NOT False - a caller must be able to tell them apart)."""+ try:+ txt = open(lbr_path, encoding="utf-8", errors="replace").read()+ except Exception:+ return None+ m = re.search(r'<package3d[^>]*(?:\burn|\bwip_urn)="([^"]+)"', txt)+ return bool(m and m.group(1).strip())+++def _orchestrate_show_library(args: dict) -> dict:+ """Eight-surfaces contract (ab SDK, issue #30): the LIBRARY surface by its smallest+ name. libraryPath opens it (open_lbr); name alone handles the already-open case."""+ import time as _t+ lbr = (args.get("libraryPath") or "").replace("\\", "/")+ name = (args.get("name") or "").strip()+ if lbr:+ opened = _orchestrate_open_lbr({"filePath": lbr})+ if not opened.get("success"):+ return {"success": False, "error": "open_lbr failed: " + str(opened.get("error"))[:150]}+ _t.sleep(2)+ elif name:+ st = _proxy_to_addin("get_app_state", {}, timeout=10)+ active = str((st.get("data") or {}).get("activeDocument", "")).lower()+ if name.lower() not in active:+ return {"success": False,+ "error": "library %r is not the active document and no libraryPath was given" % name,+ "_hint": "Pass libraryPath (WINDOWS .lbr path) to open it; name alone only handles the already-open case."}+ else:+ return {"success": False, "error": "libraryPath or name required."}+ # ── FINDING 2 (#58): ASSERT THE LIBRARY IS ACTUALLY THE ACTIVE DOCUMENT ────────────+ # One call returned success:true with Fusion sitting on an empty Untitled(1) DESIGN+ # tab, no Electronics panels at all, and nothing in the response distinguished it from+ # the nine that worked. Worse, the reporter's blank-frame smoke alarm RATED IT BEST:+ # 99.5% ink coverage vs 94.5-94.7% for the real ones, because a uniform dark canvas is+ # "inked" everywhere. A screenshot cannot be the proof here; the document name can.+ want = (name or (lbr.rsplit("/", 1)[-1].rsplit(".", 1)[0] if lbr else "")).strip()+ active = ""+ try:+ st = _proxy_to_addin("get_app_state", {}, timeout=12)+ active = str((st.get("data") or {}).get("activeDocument", "") or "")+ except Exception:+ pass+ if want and active and want.lower() not in active.lower():+ return {"success": False, "errorCode": "library_not_active",+ "error": "opened %r but the ACTIVE document is %r - the view you would "+ "screenshot is not the library." % (want, active),+ "activeDocument": active, "expected": want,+ "_hint": ("Fusion switched or left another tab in front. Re-call "+ "fusion_show_library {libraryPath} once; if it repeats, close the "+ "stray document (fusion_close_document) and retry.")}++ out = {"success": True, "view": "library", "activeDocument": active or None}++ # ── THE HINT REQUEST (#58): say when there is no 3D to show ────────────────────────+ # The reporter called this verb alone, got a grey Placeholder in the 3D pane on 10/10+ # parts, and filed it as a bridge bug before finding their own documentation saying+ # fusion_bind_library_3d must run first. They are right that the bridge KNOWS this and+ # said nothing. threeDBound is null when unknowable (name-only call), never False.+ bound = _lbr_has_package3d(lbr) if lbr else None+ out["threeDBound"] = bound+ if bound is False:+ out["_hint"] = ("This library has NO package3d, so Fusion renders a grey Placeholder "+ "slab in the 3D pane - that is not a captured 3D artifact. Run "+ "fusion_bind_library_3d {libraryPath, stepPath} FIRST, then re-open. "+ "(fusion_create_library does the whole chain for you.)")+ elif bound is None and lbr:+ out["_hint"] = ("Could not read the .lbr to tell whether a package3d is bound; if the "+ "3D pane shows a grey Placeholder, run fusion_bind_library_3d first.")++ # ── FINDING 3 (#58): ten calls left ten tabs open ──────────────────────────────────+ # Not closed automatically: showing a library is a "leave it on screen" verb, so+ # closing it would defeat the call. Reported instead, so a batch caller can see the+ # accumulation it is causing rather than discovering it ten tabs later.+ try:+ _docs = _open_doc_names()+ if len(_docs) > 3:+ out["openDocuments"] = len(_docs)+ out["_hintDocs"] = ("%d documents are open in Fusion. show_library deliberately "+ "leaves the library on screen, so a batch of calls accumulates "+ "tabs - close them with fusion_close_all_documents between "+ "runs." % len(_docs))+ except Exception:+ pass++ try:+ shot = _handle_screenshot_fusion({}, {})+ p = shot.get("savedTo") or shot.get("localSafePath")+ if shot.get("success") and p:+ out["screenshot"] = p+ except Exception:+ pass+ return out+++def _orchestrate_show_project(args: dict) -> dict:+ """Eight-surfaces contract: bring the PROJECT/design to its top level.+ - No args + a generated test project in this session: activates its board doc.+ - query: already-active match wins; else fire open_cloud_file + poll.+ - urn: open_by_urn passthrough."""+ import time as _t+ query = (args.get("query") or args.get("fileName") or "").strip()+ urn = (args.get("urn") or "").strip()+ if not query and not urn:+ if _TEST_PROJECT:+ ok = _test_project_activate("brd")+ out = {"success": bool(ok), "view": "project", "document": _TEST_PROJECT["docBase"],+ "source": "generated test project"}+ if not ok:+ out["error"] = "could not activate the test project's board document"+ else:+ try:+ shot = _handle_screenshot_fusion({}, {})+ p = shot.get("savedTo") or shot.get("localSafePath")+ if shot.get("success") and p:+ out["screenshot"] = p+ except Exception:+ pass+ return out+ return {"success": False, "error": "query (cloud design name) or urn required - or run fusion_make_test_project first."}+ if urn:+ return dispatch_command("open_by_urn", {"urn": urn})+ st = _proxy_to_addin("get_app_state", {}, timeout=10)+ active = str((st.get("data") or {}).get("activeDocument", "")).lower()+ if query.lower() in active:+ out = {"success": True, "view": "project", "document": active, "source": "already open"}+ else:+ dispatch_command("open_cloud_file", {"fileName": query, "wait": False})+ deadline = _t.time() + 170+ opened = False+ while _t.time() < deadline:+ st = _proxy_to_addin("get_app_state", {}, timeout=10)+ if query.lower() in str((st.get("data") or {}).get("activeDocument", "")).lower():+ opened = True+ break+ _t.sleep(5)+ if not opened:+ return {"success": False, "error": "%r never became the active document within 170s" % query,+ "_hint": "Check the name with fusion_aps_search; cloud opens on cold sessions take minutes."}+ out = {"success": True, "view": "project", "document": query, "source": "cloud open"}+ try:+ shot = _handle_screenshot_fusion({}, {})+ p = shot.get("savedTo") or shot.get("localSafePath")+ if shot.get("success") and p:+ out["screenshot"] = p+ except Exception:+ pass+ return out+++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({}, {})+ p = r.get("savedTo") or r.get("localSafePath")+ if r.get("success") and p:+ shots.append({"title": label, "label": label, "path": p})+ 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 _assert_active_document(expect: str) -> dict | None:+ """Document guard (issue #289): return a `wrong_document` error dict if Fusion's active document+ is not `expect`, else None. A Fusion tab-switch silently retargets `app.activeDocument`, so a+ mutating verb can hit the wrong file and destroy work. Callers pass `expectDocument` to assert+ intent BEFORE any write. Fails OPEN (returns None) when the active doc cannot be read, so the+ guard never wedges a legitimate call - it only blocks a CONFIRMED mismatch."""+ actual = None+ try:+ st = _proxy_to_addin("get_app_state", {}, timeout=8)+ data = st.get("data") if isinstance(st, dict) else None+ if not isinstance(data, dict):+ out = st.get("output") if isinstance(st, dict) else None+ if isinstance(out, str) and out.strip().startswith("{"):+ data = json.loads(out).get("data", {})+ actual = (data or {}).get("activeDocument") or (st.get("activeDocument") if isinstance(st, dict) else None)+ except Exception:+ actual = None+ if actual and actual != expect:+ return {+ "success": False,+ "errorCode": "wrong_document",+ "expected": expect,+ "actual": actual,+ "error": "Active document is '%s', expected '%s'." % (actual, expect),+ "_hint": ("The active document changed - a Fusion tab-switch retargets the active "+ "document. Re-activate the intended document, or re-issue with expectDocument "+ "set to the current name (a rename mid-session also changes it)."),+ }+ return None+++# The EIGHT SURFACES show verbs. `capture:true` is honoured on exactly these: the caller+# asked to be SHOWN something, so proof belongs on the same response (page issue #30).+_SHOW_SURFACE_VERBS = {+ "show_symbol", "show_footprint", "show_3d_package", "show_library",+ "show_project", "show_schematic", "show_2d_board", "show_3d_board",+}++_WINDOW_RAISING = {+ # close verbs raise windows too (the Hub upload-close confirm, Save-As) - John caught+ # the modal standing TWICE on 2026-08-21 because the post-verb sweep never ran for a+ # raw close_document from web control.+ "close_document", "close_window",+ "open_cloud_file", "open_by_urn", "aps_open", "open_design", "open_lbr",+ "open_schematic", "open_board", "import_electronics", "import_step",+ "new_electronics_from_eagle", "show_schematic", "show_2d_board", "show_3d_board",+ "run_modeling_script", "execute_text_command", "electron_run",+ "attach_3d_package", "make_3d_package", "build_library_3d",+ "capture_library_views", "generate_package", "export_optimized_glb",+ "board_stackup", "save_to_cloud", "start",+ "show_symbol", "show_footprint", "show_3d_package", "make_test_project",+ "bind_library_3d", "show_library", "show_project",+}++++def _capture_surface_shot(verb: str, args: dict) -> dict:+ """Settle, then capture the surface - the bridge's half of issue #30's capture:true.++ SETTLE IS OURS, NOT THE PAGE'S. We opened the window, so we know what to wait for:+ the add-in main thread going idle (the surface is still drawing while it is busy),+ then a short paint pause. A page cannot see either, which is why it ends up polling.+ Returns ab's gallery item shape: {title, image}, image being a ~640px base64 frame.+ NOTE the encoding is not guaranteed PNG end to end: we encode PNG, but the relay may+ transcode to WebP in transit (observed live 2026-08-19, RIFF header on frames we were+ labelling PNG). Consumers should sniff the magic bytes rather than assume.+ """+ import time as _t+ label = {+ "show_symbol": "Symbol", "show_footprint": "Footprint",+ "show_3d_package": "3D package", "show_library": "Library",+ "show_project": "Project", "show_schematic": "Schematic",+ "show_2d_board": "2D board", "show_3d_board": "3D board",+ }.get(verb, verb)+ deadline = _t.time() + float(args.get("captureTimeoutSeconds", 120))+ while _t.time() < deadline:+ try:+ st = _proxy_to_addin("get_app_state", {}, timeout=6)+ if st.get("success"):+ break+ except Exception:+ pass+ _t.sleep(3)+ # The canvas paints a beat after the main thread frees up; capturing on the same tick+ # produced blank-canvas evidence on ab's regression thread.+ _t.sleep(float(args.get("capturePaintDelay", 2.5)))+ try:+ shot = screenshot_fusion_window()+ except Exception as e:+ return {"title": label, "image": None, "error": str(e)[:160]}+ path = shot.get("savedTo") or shot.get("localSafePath")+ if not shot.get("success") or not path:+ return {"title": label, "image": None,+ "error": shot.get("error") or "capture failed"}+ # WIDTH IS THE CALLER'S CALL (adom-bridge#91): a container caller can take the 640px+ # frame, but a BROWSER caller is paying for those bytes in the /command response body,+ # and 200-400px is plenty for a "here is your part, open on screen" thumbnail.+ try:+ max_w = int(args.get("captureMaxWidth") or 640)+ except Exception:+ max_w = 640+ max_w = max(120, min(1600, max_w))+ img = _shot_b64_small(path, max_w=max_w)+ return {"title": label, "image": img, "path": path, "maxWidth": max_w,+ # Same bytes, prefixed - a browser can drop this straight into an <img src>.+ "thumbnailDataUrl": ("data:image/png;base64," + img) if img else None}++++# ── focusEvents: the quietness audit log (SDK canonicalization, page issue #34) ─────────+# Every time this bridge took the user's foreground - or refused to - it lands here, so a+# human (or a reviewer) can audit how quiet we actually were rather than trusting that we+# were. Ring buffer in memory; the point is the recent past, not forever.+_FOCUS_EVENTS = []+_FOCUS_EVENTS_MAX = 50+_FOCUS_LOCK = threading.Lock()+++def _note_focus_event(verb: str, outcome: str, reason: str = "") -> None:+ """outcome: 'foreground' (we took it, with a reason) | 'refused' (no reason given)."""+ try:+ import datetime as _dt+ with _FOCUS_LOCK:+ _FOCUS_EVENTS.append({+ "at": _dt.datetime.now().isoformat(timespec="seconds"),+ "verb": verb if verb.startswith("fusion_") else "fusion_" + verb,+ "outcome": outcome,+ "reason": (reason or "")[:200],+ })+ del _FOCUS_EVENTS[:-_FOCUS_EVENTS_MAX]+ except Exception:+ pass++++def _snapshot_open_docs() -> set:+ """Names of documents open in Fusion right now (empty on any failure)."""+ try:+ r = _proxy_to_addin("document_info", {}, timeout=5)+ if not r.get("success"):+ return set()+ return {str(d.get("name") or "").rstrip("*").strip().lower()+ for d in ((r.get("data") or {}).get("openDocuments") or []) if d.get("name")}+ except Exception:+ return set()+++def _register_docs_we_opened(before: set) -> None:+ """Any document that APPEARED while the bridge was driving is the bridge's.++ Third attempt at this, because the first two kept leaving Save prompts on John's+ screen (2026-08-20, twice). Registering the ARGUMENTS of show verbs was opt-in and+ missed every path that names a document some other way - IN-S42ATR slipped straight+ through it. Registering the ACTIVE document unconditionally would be worse: it would+ claim the user's own open design and let the janitor discard their work.++ The diff is both safe and complete: a document that was not open before our verb and+ is open after it exists BECAUSE of us. The user's documents were already open, so they+ are never in the delta, and nothing they do concurrently gets adopted.+ """+ if not before and before != set():+ return+ try:+ for name in (_snapshot_open_docs() - before):+ if name and name != "untitled":+ _note_bridge_doc(name)+ print("[Fusion Bridge] registered %r as a bridge doc (it appeared while we drove)" % name)+ except Exception:+ pass++++_ACTIVITY_MODES = ("flash", "progress", "both", "none")+++def _activity_mode() -> str:+ """How to signal 'Adom is driving Fusion' on the taskbar. Sticky, user-settable."""+ try:+ m = str(_get_bridge_pref("activityIndicator", "progress") or "progress").lower()+ except Exception:+ m = "progress"+ return m if m in _ACTIVITY_MODES else "progress"+++def _time_and_indicate(command: str, args: dict, run):+ """Time EVERY verb, log it, and paint the chosen taskbar activity indicator.++ John's experiment (2026-08-21): a flash says "something is happening" and nothing more.+ A progress bar paced by this machine's OWN measured history for THIS verb says how long+ it will take, and disappears the moment the verb finishes or fails. Modes: flash |+ progress | both | none.++ The estimate comes from timings.verb_estimate; a verb with no history runs the bar+ INDETERMINATE rather than inventing a percentage.+ """+ import time as _t+ import timings as _tm+ mode = _activity_mode()+ slow = command in LONG_RUNNING_COMMANDS or command in _WINDOW_RAISING+ if command == "start":+ # John, 2026-08-21: show the taskbar bar DURING the launch, paced by how long a+ # launch actually takes here. The launch history is richer than the verb log - it is+ # measured per startKind, and cold vs warm differ 100x (measured: cold ~45-53s,+ # warm ~0.3s). A warm start needs no bar at all.+ if _is_fusion_running():+ est, conf = 0.0, "measured"+ slow = False+ else:+ est, conf = _tm.estimate("launch", 150, "cold")+ else:+ est, conf = _tm.verb_estimate(command)+ stop = None+ if slow and mode in ("progress", "both"):+ try:+ from handlers.taskbar_overlay import start_activity_progress+ stop = start_activity_progress(command, est, conf)+ except Exception:+ stop = None+ if slow and mode in ("flash", "both"):+ try:+ _ad_call("desktop_taskbar", {"titleContains": "Autodesk Fusion",+ "flash": "until_focused"}, timeout=10)+ except Exception:+ pass+ t0 = _t.time()+ failed = False+ try:+ res = run()+ failed = isinstance(res, dict) and res.get("success") is False+ return res+ except Exception:+ failed = True+ raise+ finally:+ took = _t.time() - t0+ try:+ _tm.log_run(command, took, ok=not failed)+ except Exception:+ pass+ if stop is not None or (slow and mode in ("flash", "both")):+ try:+ from handlers.taskbar_overlay import end_activity_progress+ end_activity_progress(command, failed=failed)+ if mode in ("flash", "both"):+ _ad_call("desktop_taskbar", {"titleContains": "Autodesk Fusion",+ "flash": "stop"}, timeout=8)+ except Exception:+ pass++++def _handle_set_activity_indicator(fusion_info: dict, args: dict) -> dict:+ """Read or set how the taskbar signals that Adom is driving Fusion."""+ if "mode" in args:+ mode = str(args.get("mode") or "").lower()+ if mode not in _ACTIVITY_MODES:+ return {"success": False,+ "error": "mode must be one of %s" % ", ".join(_ACTIVITY_MODES),+ "errorCode": "bad_activity_mode"}+ _set_bridge_pref("activityIndicator", mode)+ if mode in ("none", "flash"):+ try:+ from handlers.taskbar_overlay import _taskbar_progress+ _taskbar_progress("none") # clear immediately, never leave a stale bar+ except Exception:+ pass+ if mode in ("none", "progress"):+ try:+ _ad_call("desktop_taskbar", {"titleContains": "Autodesk Fusion",+ "flash": "stop"}, timeout=8)+ except Exception:+ pass+ return {"success": True, "activityIndicator": _activity_mode(),+ "modes": list(_ACTIVITY_MODES),+ "_hint": ("Sticky (~/.adom/fusion-bridge/prefs.json). progress = a taskbar bar paced "+ "by THIS machine's measured time for that verb; flash = the old "+ "attention flash; both; none. A verb with no measured history yet runs "+ "the bar indeterminate rather than inventing a percentage.")}+++def _handle_verb_times(fusion_info: dict, args: dict) -> dict:+ """The measured run-time report, aggregated from the append-only verb log."""+ import timings as _tm+ rep = _tm.verb_report(int(args.get("limit") or 40))+ return {"success": True, "verbs": rep, "count": len(rep),+ "_hint": ("Aggregated from ~/.adom/fusion-bridge/verb-times.jsonl (every run, with "+ "its outcome). p90 is the useful column when hunting a slow surface; "+ "`failures` next to a healthy p50 usually means a verb that fails FAST, "+ "which is a different bug from one that is slow.")}+++COMMAND_HANDLERS["set_activity_indicator"] = _handle_set_activity_indicator+COMMAND_HANDLERS["verb_times"] = _handle_verb_times++++def _handle_list_library(fusion_info: dict, args: dict) -> dict:+ """What is actually IN a .lbr: symbols, packages, devicesets, package3d.++ ab asked for this (page issue #42) after resorting to regexing our XML from their client.+ A caller reading the bridge's own file format to work out what to ask the bridge for is a+ smell, and this is the cheap end of fixing it.+ """+ path = (args.get("libraryPath") or args.get("filePath") or "").replace("\\", "/")+ if not path:+ return {"success": False, "error": "libraryPath required.",+ "errorCode": "library_path_required"}+ names = _library_artifact_names(path)+ if not names:+ return {"success": False, "error": "Could not read or parse that .lbr: %s" % path,+ "errorCode": "library_unreadable",+ "_hint": "Path must be the WINDOWS path to an existing .lbr on the box."}+ return {"success": True,+ "libraryPath": path,+ "symbols": names.get("sym", []),+ "packages": names.get("pac", []),+ "devicesets": names.get("dev", []),+ "package3d": names.get("p3d", []),+ "has3d": bool(names.get("p3d")),+ "_hint": ("Use these EXACT names with fusion_show_symbol / show_footprint / "+ "show_3d_package - no need to parse the .lbr yourself. has3d:false means "+ "show_3d_package would only have a grey Placeholder to render, so pass "+ "stepPath to bind a real model first.")}+++COMMAND_HANDLERS["list_library"] = _handle_list_library++++def _prove_capture(label: str, view: str) -> dict:+ """Settle, capture full-screen, and MEASURE the frame. A capture is evidence only if it+ is app-sized: a 237x39 title-bar sliver once passed a byte-size check and got reported as+ a screenshot, so every evidence frame is decoded and measured here."""+ import time as _t+ for _ in range(40): # let the editor finish drawing before capturing+ try:+ if _proxy_to_addin("get_app_state", {}, timeout=6).get("success"):+ break+ except Exception:+ pass+ _t.sleep(3)+ _t.sleep(2.0) # paint beat: the canvas lands after the thread frees+ out = {"step": label, "view": view, "ok": False, "screenshot": None}+ # WORKSPACE STAMP on EVERY frame (#52 item 2: gates must assert content, not count+ # artifacts). package3d already hard-fails outside Design; the other views get their+ # expected-workspace names captured from green runs via this stamp first, THEN their+ # own hard gates - a gate that can false-fail is as useless as one that cannot fail.+ try:+ _wsr = _proxy_to_addin("execute_text_command",+ {"command": "DebugCommands.ActiveWorkspace"}, timeout=10)+ _wsv = ((_wsr.get("data") or {}).get("output") or (_wsr.get("data") or {}).get("result") or "")+ if _wsv:+ out["activeWorkspace"] = str(_wsv).strip()[:60]+ except Exception:+ pass+ try:+ shot = _handle_screenshot_fusion({}, {})+ p = shot.get("savedTo") or shot.get("localSafePath")+ if shot.get("success") and p:+ out["screenshot"] = p+ w, h = shot.get("width"), shot.get("height")+ out["width"], out["height"] = w, h+ out["ok"] = not (w and h) or (int(w) >= 400 and int(h) >= 300)+ # INLINE COPY FOR BROWSER CALLERS (issue #47): the path is unreadable from+ # wiki.adom.inc web control - it cannot open C:/tmp. A ~500px data URI per+ # frame keeps the whole four-frame set renderable straight from the poll+ # response while the full-res file stays on disk for same-box callers.+ try:+ out["thumbnailDataUrl"] = _frame_data_url(p, max_width=500)+ except Exception:+ pass+ except Exception as e:+ out["error"] = str(e)[:400]+ return out+++def _frame_data_url(path: str, max_width: int = 500) -> str:+ """Downscale an on-disk frame to a data: URI (best effort, PIL-free: Fusion boxes have+ no PIL, so serve the file bytes as-is when small enough, else skip the inline copy)."""+ import base64 as _b64+ try:+ raw = open(path, "rb").read()+ except Exception:+ return ""+ # The bridge's captures are relay-sized webp (~150-250KB). Cap the inline copy so a+ # four-frame poll response stays comfortably under relay body limits.+ if len(raw) > 300_000:+ return ""+ mime = "image/webp" if path.lower().endswith(".webp") else "image/png"+ return "data:%s;base64,%s" % (mime, _b64.b64encode(raw).decode())++++def _open_doc_names() -> set:+ """Names of every document open in Fusion right now (best-effort, empty on failure)."""+ try:+ st = _proxy_to_addin("document_info", {}, timeout=8)+ return {str(d.get("name") or "") for d in ((st.get("data") or {}).get("openDocuments") or [])+ if d.get("name")}+ except Exception:+ return set()+++def _close_docs_opened_since(before: set) -> list:+ """Close every document that was NOT open when `before` was snapshotted. Used by the+ create_library chain on BOTH success and failure. Everything a chain opens is bridge+ scratch by construction: Create3DPackage's generator design (an 'Untitled(N)' - one in+ bind, another in prove's 3D frame), the mint's saved ADOM-*-3D (already in the cloud),+ the .lbr editor (the file on disk is the truth). John spotted five Untitleds piling up+ by part 3 of a 20-part matrix (2026-08-23): ~2 leaked per chain, and Fusion slows with+ every open doc. Never touches a doc that pre-dates the chain (the user's own work)."""+ closed = []+ try:+ for n in sorted(_open_doc_names() - set(before or ())):+ _note_bridge_doc(n) # so the save-prompt sentinel owns any prompt it raises+ r = _close_bridge_doc_verified(n)+ if r.get("closed"):+ closed.append(n)+ # UNTITLED SWEEP (John's 5-tab screenshot + the []-swept rerun, 2026-08-23): the+ # add-in names every unsaved default doc literally "Untitled" - a SET collapses+ # them, so the before/after diff sees zero change however many pile up. Close+ # UNMODIFIED Untitleds directly, one per pass, until none remain: an unmodified+ # Untitled is an empty tab by definition (any real content flips isModified), so+ # this can never eat user work. Bounded, and only ever runs at chain end.+ for _ in range(12):+ st = _proxy_to_addin("document_info", {}, timeout=8)+ docs = (st.get("data") or {}).get("openDocuments") or []+ u = next((dd for dd in docs+ if str(dd.get("name") or "").startswith("Untitled")+ and not (dd.get("isModified") or dd.get("modified"))), None)+ if not u:+ break+ _proxy_to_addin("close_document", {"name": u.get("name"), "save": False}, timeout=20)+ closed.append(str(u.get("name")))+ if closed:+ print("[Fusion Bridge] chain hygiene: closed %d scratch doc(s) the chain opened: %s"+ % (len(closed), ", ".join(closed)))+ _bridge_action_ledger("chain_scratch_docs_closed", {"docs": closed})+ except Exception:+ pass+ return closed+++def _close_bridge_doc_verified(name: str, timeout_s: int = 30) -> dict:+ """Close a BRIDGE-owned scratch document deterministically - no dialog survives this call.++ John, 2026-08-21, third Save dialog in two days: "save programmatically before a dialog+ pops up asking you to save. cuz that will make all your code more deterministic where you+ don't have to use a screenshot and analysis of it." The rule this implements: the+ operation that dirties a document RESOLVES that document's state before returning. Not a+ janitor three minutes later, not the next open tripping over it - inside the same verb.++ Sequence: close_document {save:false} -> verify via document_info that it is GONE. If+ Fusion raised its Save-As anyway (a never-saved doc can), answer Don't Save HERE, in the+ same call, then re-verify. Deterministic by construction: state checks, no screenshots.+ """+ import time as _t+ n = str(name or "").rstrip("*").strip()+ if not n:+ return {"closed": False, "why": "no name"}+ # ⛔ NEVER CLOSE OVER AN IN-FLIGHT UPLOAD (John caught the dialog live, 2026-08-21:+ # "Packages are being uploaded to your Fusion Hub. If you close the document you will+ # lose these changes."). Create3DPackage/saveAs uploads continue AFTER the API call+ # returns, and a close issued during them raises exactly that modal - blocking the+ # main thread AND offering to destroy the upload. app.hasActiveJobs is the+ # deterministic signal; wait for it to drain (cap 120s) before touching the close.+ _jobs_deadline = _t.time() + 120+ while _t.time() < _jobs_deadline:+ try:+ jr = _proxy_to_addin("run_modeling_script",+ {"script": "import adsk.core\n"+ "result={'jobs': adsk.core.Application.get().hasActiveJobs}"},+ timeout=10)+ jd = (jr.get("data") or {}).get("result") or {}+ if not jd.get("jobs"):+ break+ print("[Fusion Bridge] close(%r): Fusion Hub upload in flight - waiting, not "+ "closing (a close now would raise the lose-changes modal)" % n)+ except Exception:+ break # cannot ask -> proceed; the dialog answer below is the backstop+ _t.sleep(5)+ else:+ return {"closed": False, "deferred": True,+ "why": "Fusion Hub uploads still active after 120s - close deferred so the "+ "upload is not destroyed; retry after the jobs drain"}+ try:+ _proxy_to_addin("close_document", {"name": n, "save": False}, timeout=20)+ except Exception:+ pass+ deadline = _t.time() + timeout_s+ while _t.time() < deadline:+ try:+ di = _proxy_to_addin("document_info", {}, timeout=6)+ docs = [str(d.get("name") or "").rstrip("*").strip().lower()+ for d in ((di.get("data") or {}).get("openDocuments") or [])]+ if di.get("success") and n.lower() not in docs:+ return {"closed": True}+ except Exception:+ pass+ # A never-saved doc can raise Save-As despite save:false - answer it NOW, same call.+ try:+ got = _dismiss_save_prompt_if_bridge_doc()+ if got.get("handled"):+ print("[Fusion Bridge] close(%r): answered the Save prompt inside the same "+ "call (deterministic close, never left on screen)" % n)+ except Exception:+ pass+ _t.sleep(2)+ return {"closed": False, "why": "document still open after %ds" % timeout_s}+++++def _check_connects(lbr_path: str) -> dict:+ """Referential integrity of the pin->pad mapping - the FIFTH artifact (John, 2026-08-21:+ "for your source files, don't you need a pin mapping too?").++ The mapping is embedded by KiCad convention (pin NUMBER == pad name), but the convention+ breaks - and when it does, a converter can emit connects referencing pads that do not+ exist (verified live: adom-lbr warned connect-pad-missing and still wrote the file, "0+ errors"). Dead wiring photographs perfectly in all four evidence frames, so the frames+ cannot catch it; only this state check can. Offline, deterministic, no Fusion needed.+ """+ try:+ import xml.etree.ElementTree as _ET+ root = _ET.parse(lbr_path).getroot()+ except Exception:+ return {"ok": False, "why": "library unreadable"}+ pins = {p.get("name") for p in root.iter("pin") if p.get("name")}+ pads = {p.get("name") for p in root.iter("smd") if p.get("name")}+ pads |= {p.get("name") for p in root.iter("pad") if p.get("name")}+ connects = [(c.get("pin"), c.get("pad")) for c in root.iter("connect")]+ bad_pin = [c for c in connects if c[0] not in pins]+ # EAGLE's connect `pad` attribute is a SPACE-SEPARATED LIST (a same-net pad array: QFN+ # thermal vias, BGA ground balls - pad="81 81@2 ... 81@11" binds one pin to all of them).+ # Treating it as one name false-failed valid libraries Fusion opens fine (#49, adom-lbr+ # 2.10 multi-pad connects). Split and check each name.+ bad_pad = [c for c in connects+ if not (c[1] or "").split()+ or any(p not in pads for p in (c[1] or "").split())]+ connected_pads = {p for c in connects for p in (c[1] or "").split()}+ unconnected_pads = sorted(pads - connected_pads)+ ok = bool(connects) and not bad_pin and not bad_pad+ return {"ok": ok, "connects": len(connects),+ "badPinRefs": bad_pin[:8], "badPadRefs": bad_pad[:8],+ "unconnectedPads": unconnected_pads[:12],+ "why": None if ok else (+ "no connects at all" if not connects else+ "connects reference missing %s" % ("pins" if bad_pin else "pads"))}+++def _count_3d_bodies() -> dict:+ """How many REAL bodies are in the active 3D design - a STATE check, not a screenshot.++ John, 2026-08-21: "giving back a screenshot of the 3d viewer ... is not a success if you+ don't have a 3d file in the viewer." The .lbr declaring a <package3d> is necessary but+ not sufficient: the bound urn can fail to download and the viewer renders an EMPTY+ viewport that still photographs as a plausible frame. Fusion itself knows the truth -+ ask it. Returns {bodies, bboxMm3} or {-1} when unanswerable.+ """+ script = (+ "import adsk.core, adsk.fusion\n"+ "d = app.activeProduct\n"+ "n = 0; vol = 0.0\n"+ "try:\n"+ " root = d.rootComponent\n"+ " for occ in [None] + list(root.allOccurrences):\n"+ " comp = root if occ is None else occ.component\n"+ " for b in comp.bRepBodies:\n"+ " n += 1\n"+ " bb = b.boundingBox\n"+ " dx = (bb.maxPoint.x - bb.minPoint.x) * 10\n"+ " dy = (bb.maxPoint.y - bb.minPoint.y) * 10\n"+ " dz = (bb.maxPoint.z - bb.minPoint.z) * 10\n"+ " vol += abs(dx * dy * dz)\n"+ "except Exception:\n"+ " n = -1\n"+ "result = {'bodies': n, 'bboxMm3': round(vol, 2)}\n"+ )+ try:+ r = _proxy_to_addin("run_modeling_script", {"script": script}, timeout=30)+ data = (r.get("data") or {}).get("result") or (r.get("data") or {})+ if r.get("success") and isinstance(data, dict) and "bodies" in data:+ return {"bodies": int(data.get("bodies", -1)),+ "bboxMm3": float(data.get("bboxMm3") or 0)}+ except Exception:+ pass+ return {"bodies": -1, "bboxMm3": 0}+++def _handle_prove_library(fusion_info: dict, args: dict) -> dict:+ # INCREMENTAL EMISSION (page issue #60, 2026-08-26): the four frames are captured in+ # the order a library is actually assembled - symbol, footprint, 3D, then the ganged+ # view as the closing proof - but they used to be published only when this function+ # RETURNED, so a caller watched nothing for ~370s and then got all four at once. An+ # optional _onFrame callback (passed by the create_library chain, never over the relay)+ # publishes each artifact the instant it lands, so a poller assembles the bundle+ # progressively and sees the ganged view arrive last.+ _on_frame = args.get("_onFrame") if callable(args.get("_onFrame")) else None++ def _emit(entry):+ if _on_frame:+ try:+ _on_frame(entry)+ except Exception:+ pass+ return entry++ """PROVE a .lbr with the FOUR evidence images John specified (2026-08-21):++ 1. the symbol, full screen in the Symbol editor+ 2. the footprint, full screen in the Footprint editor+ 3. the 3D chip, full screen in the 3D editor+ 4. the ganged Library Manager view: symbol + footprint + 3D chip + pin connections++ The artifacts are verified from the .lbr FIRST, offline, because the view can lie: Fusion+ ticks Mapped even when Package is "Placeholder", and a grey Placeholder slab photographs+ exactly like a bound model (page issue #42). So `proven` is decided by what EXISTS, and+ the four frames are the evidence attached to that verdict - never the verdict itself.+ """+ lbr = (args.get("libraryPath") or args.get("filePath") or "").replace("\\", "/") or _last_library()+ if not lbr:+ return {"success": False, "error": "libraryPath required (or open a library first).",+ "errorCode": "library_path_required"}+ names = _library_artifact_names(lbr)+ if not names:+ return {"success": False, "error": "Could not read or parse that .lbr: %s" % lbr,+ "errorCode": "library_unreadable"}+ devs = names.get("dev", [])+ want = (args.get("name") or "").strip() or (devs[0] if devs else "")+ artifacts = {+ "symbol": {"present": bool(names.get("sym")), "names": names.get("sym", [])[:10]},+ "footprint": {"present": bool(names.get("pac")), "names": names.get("pac", [])[:10]},+ "package3d": {"present": bool(names.get("p3d")), "names": names.get("p3d", [])[:10]},+ "deviceset": {"present": bool(devs), "names": devs[:10]},+ }+ missing = [k for k in ("symbol", "footprint", "package3d") if not artifacts[k]["present"]]+ # THE PIN MAPPING IS AN ARTIFACT TOO. Dead or missing connects render fine in every+ # frame - the ganged view just quietly shows no wiring - so the verdict checks the+ # mapping's referential integrity, not its photogenicity.+ conn = _check_connects(lbr)+ artifacts["connects"] = conn+ if artifacts["symbol"]["present"] and artifacts["footprint"]["present"] and not conn.get("ok"):+ missing.append("connects(%s)" % (conn.get("why") or "invalid"))++ opened = _orchestrate_open_lbr({"filePath": lbr})+ if not opened.get("success"):+ return {"success": False, "error": "open_lbr failed: " + str(opened.get("error")),+ "errorCode": "library_open_failed", "artifacts": artifacts}+ _LAST_LIBRARY[0] = lbr++ evidence = []+ # 1 + 2: the two editors, each full screen.+ for label, suffix, view in (("symbol", "sym", "symbol-editor"),+ ("footprint", "pac", "footprint-editor")):+ r = _show_library_view(want, suffix, lbr)+ if not r.get("success"):+ evidence.append(_emit({"step": label, "view": view, "ok": False,+ "error": str(r.get("error"))[:160]}))+ continue+ evidence.append(_emit(_prove_capture(label, view)))+ # 3: the 3D editor. Only reachable when a real package3d is bound - with none, Fusion+ # shows its Placeholder, which is exactly the false pass this verb exists to prevent.+ if artifacts["package3d"]["present"]:+ # THE BOUND MODEL, NOT THE GENERATOR (caught by the bodies gate, run be27c38d034e:+ # Create3DPackage opens the empty Package3D GENERATOR for an already-bound library -+ # bodies=0, a frame of nothing). The evidence is the f3d the package3d urn points+ # at: open THAT document, which loads the real geometry the Library Manager shows.+ # THE URN LIVES IN wip_urn, NOT urn (run 7d4427d443f5): this bridge's own bind+ # writes `<package3d urn="" wip_urn="urn:adsk.wipprod:fs.file:vf...">` - urn stays+ # empty by the EAGLE-XML convention used everywhere else in this file. Reading only+ # `urn` made a bound library look unopenable ("no urn"). A caller (the create_library+ # chain) can also hand the bind result's urn straight in as f3dUrn.+ _p3d_urn = ""+ try:+ import xml.etree.ElementTree as _ET3+ for _p3 in _ET3.parse(lbr).getroot().iter("package3d"):+ _p3d_urn = (_p3.get("urn") or _p3.get("wip_urn") or "").strip() or _p3d_urn+ except Exception:+ pass+ _p3d_urn = _p3d_urn or (args.get("f3dUrn") or "").strip()+ opened3d = None+ if _p3d_urn:+ # A JUST-MINTED f3d may not be durably openable yet (page issue #47 failure+ # mode 3: bind success + thumbnail ready, open refused moments later). The+ # lineage commits asynchronously - retry over ~45s before failing the frame.+ import time as _t3r+ for _attempt in range(3):+ opened3d = dispatch_command("open_by_urn", {"urn": _p3d_urn})+ if opened3d.get("success"):+ break+ if _attempt < 2:+ print("[Fusion Bridge] bound f3d not openable yet (attempt %d) - "+ "waiting 15s for the lineage to commit" % (_attempt + 1))+ _t3r.sleep(15)+ if opened3d and opened3d.get("success"):+ # WORKSPACE GUARANTEE (#51, 2026-08-23): the minted f3d sometimes reopens into+ # the electronics PACKAGE workspace showing its 2D SHEET (s1 tPlace, layer+ # sidebar) instead of the 3D canvas - 7 of the matrix's 20 frames photographed+ # that sheet while prove stamped them 4/4. The active view is STICKY per-user+ # state, so never trust it: read DebugCommands.ActiveWorkspace, force the+ # Design (modeling) workspace when it is anything else, and normalize the+ # camera (goHome+fit - also fixes the two chip-on-edge captures). The frame+ # then records the workspace it was actually shot in, and cannot be ok unless+ # that workspace shows the 3D canvas.+ _ws_after = ""+ try:+ _wsfix = _proxy_to_addin("run_modeling_script", {"script": (+ "import adsk.core, time\n"+ "app=adsk.core.Application.get()\n"+ "def _ws():\n"+ " try: return app.executeTextCommand('DebugCommands.ActiveWorkspace').strip()\n"+ " except Exception: return ''\n"+ "before=_ws()\n"+ "if 'design' not in before.lower():\n"+ " try:\n"+ " w=app.userInterface.workspaces.itemById('FusionSolidEnvironment')\n"+ " if w: w.activate()\n"+ " adsk.doEvents(); time.sleep(2)\n"+ " except Exception: pass\n"+ "try:\n"+ " app.activeViewport.goHome(False)\n"+ " app.activeViewport.fit()\n"+ "except Exception: pass\n"+ "result={'before': before, 'after': _ws()}\n")}, timeout=60)+ _ws_after = str(((_wsfix.get("data") or {}).get("result") or {}).get("after") or "")+ except Exception:+ pass+ e3 = _prove_capture("package3d", "3d-editor")+ e3["activeWorkspace"] = _ws_after+ if _ws_after and "design" not in _ws_after.lower():+ e3["ok"] = False+ e3["error"] = ("package3d frame captured in workspace %r, not the Design 3D "+ "canvas - a 2D package sheet is not evidence of a 3D chip "+ "(the workspace switch did not take)." % _ws_after)+ chk = _count_3d_bodies()+ e3["bodies"] = chk["bodies"]+ e3["bboxMm3"] = chk["bboxMm3"]+ if chk["bodies"] == 0:+ e3["ok"] = False+ e3["error"] = ("The bound f3d opened but contains NO bodies - an empty "+ "viewport is not evidence of a 3D chip.")+ elif chk["bodies"] < 0:+ e3["bodiesUnverified"] = True+ evidence.append(_emit(e3))+ # HAND THE APP BACK (deterministic-close rule): the f3d doc is render scratch+ # here; close it verified so stage 4 is not stranded in the wrong workspace.+ # Same upload gate as _close_bridge_doc_verified: closing over an in-flight+ # Hub upload raises the lose-changes modal (John saw it live, 2026-08-21).+ try:+ _proxy_to_addin("run_modeling_script",+ {"script": "import adsk.core, time\n"+ "app=adsk.core.Application.get()\n"+ "for _ in range(24):\n"+ " if not app.hasActiveJobs: break\n"+ " time.sleep(5)\n"+ "app.activeDocument.close(False)\nresult={}"},+ timeout=140)+ except Exception:+ pass+ else:+ evidence.append(_emit({"step": "package3d", "view": "3d-editor", "ok": False,+ "error": "Could not open the bound f3d (%s): %s" % (+ _p3d_urn[-30:] if _p3d_urn else "no urn",+ str((opened3d or {}).get("error"))[:100])}))+ else:+ evidence.append(_emit({"step": "package3d", "view": "3d-editor", "ok": False,+ "error": "No package3d bound - the 3D editor would only show Fusion's "+ "grey Placeholder, which is not evidence."}))+ # 4: the ganged Library Manager view - symbol, footprint, 3D and pin connections at once.+ # Stage 3 left the app in a DIFFERENT document/workspace, so re-enter the library first,+ # verified - EDIT fails with "Not in Electronics editor" otherwise (run be27c38d034e).+ _reopen = _orchestrate_open_lbr({"filePath": lbr})+ if _reopen.get("success"):+ _ensure_library_active(lbr, timeout_s=45)+ r = _show_library_view(want, "dev", lbr)+ if r.get("success"):+ evidence.append(_emit(_prove_capture("ganged", "library-manager")))+ else:+ evidence.append({"step": "ganged", "view": "library-manager", "ok": False,+ "error": str(r.get("error"))[:160]})++ # THE THUMBNAIL IS PART OF THE VERDICT (John: "you must ensure its there"). A bound urn+ # whose cloud thumbnail never rendered shows the user a grey slab in the Library Manager+ # while every local check passes. Cloud state, not pixels; waits up to 3 min because+ # Fusion renders it asynchronously after a bind.+ thumb = None+ thumb_unverified = None+ if artifacts["package3d"]["present"]:+ try:+ thumb = _handle_check_3d_thumbnail(fusion_info,+ {"libraryPath": lbr, "waitSeconds": 180})+ artifacts["package3d"]["thumbnail"] = (thumb.get("thumbnail") or {}).get("state")+ if not thumb.get("success"):+ if thumb.get("errorCode") == "no_work_folder":+ # APS is not signed in on this box, so the cloud thumbnail state CANNOT+ # be observed - that is "unverified", not "checked and absent". Autodesk+ # renders the thumbnail from Fusion's own upload regardless of our APS+ # app, so do not fail the whole verdict over an unobservable check+ # (adom/adom-bridge#93); surface it loudly instead.+ thumb_unverified = "aps_unavailable"+ artifacts["package3d"]["thumbnail"] = "unverified(aps_unavailable)"+ else:+ missing.append("thumbnail(%s)" % ((thumb.get("thumbnail") or {}).get("state")+ or thumb.get("errorCode") or "unknown"))+ except Exception as _e:+ missing.append("thumbnail(check-failed)")+ captured = [e for e in evidence if e.get("ok") and e.get("screenshot")]+ # A failed FRAME belongs in `missing` too (run 7d4427d443f5 read "missing=[]" while+ # 2 of 4 frames had failed - the reader should never have to diff the evidence list+ # to learn what is absent).+ # FULL error text (issue #47: the [:60] cut landed exactly where the actionable half+ # of every message began - "Fusion offere[d ...]" - and cost the reporter a diagnosis)+ missing += ["frame:%s(%s)" % (e.get("step"), str(e.get("error")))+ for e in evidence if not (e.get("ok") and e.get("screenshot"))]+ proven = (not missing) and len(captured) == 4+ out = {"success": proven, "proven": proven, "libraryPath": lbr, "deviceset": want,+ "artifacts": artifacts, "missing": missing,+ "evidence": evidence, "imagesCaptured": len(captured), "imagesRequired": 4}+ if thumb_unverified:+ out["thumbnailUnverified"] = thumb_unverified+ out["_thumbnailNote"] = ("The cloud thumbnail could not be observed (APS not signed in "+ "on this box) - the 4-frame local evidence stands, but verify "+ "the Library Manager preview by eye or sign in APS and run "+ "fusion_check_3d_thumbnail.")+ # RESOLVE OUR OWN DOCUMENT STATE BEFORE RETURNING (deterministic-close rule): the four+ # views dirtied the library document; closing it here, verified, means no Save dialog+ # can ever be raised at the user for this run's scratch work. The .lbr ON DISK is the+ # artifact; the Fusion document was only ever a rendering surface.+ try:+ _stem = lbr.rsplit("/", 1)[-1].rsplit(".", 1)[0]+ _note_bridge_doc(_stem)+ out["scratchDocClosed"] = _close_bridge_doc_verified(_stem).get("closed", False)+ except Exception:+ pass+ try:+ _PROVE_LAST[0] = dict(out)+ import tempfile as _tf+ open(os.path.join(_tf.gettempdir(), "fusion-prove-last.json"), "w",+ encoding="utf-8").write(json.dumps(out))+ except Exception:+ pass+ if proven:+ out["_hint"] = ("PROVEN: all three artifacts exist and all FOUR evidence frames were "+ "captured app-sized - symbol editor, footprint editor, 3D editor, and "+ "the ganged Library Manager view with pin connections. Attach "+ "evidence[].screenshot in that order.")+ else:+ why = []+ if missing:+ why.append("missing artifacts: " + ", ".join(missing))+ if len(captured) != 4:+ why.append("only %d of 4 evidence frames captured" % len(captured))+ out["error"] = "NOT proven - " + "; ".join(why)+ out["errorCode"] = "library_not_proven"+ out["_hint"] = ("Do NOT present these frames as proof. A missing package3d renders as a "+ "grey Placeholder that photographs like a real chip, and a frame that "+ "failed its size check is not evidence of anything. Bind the 3D with "+ "fusion_show_3d_package {stepPath} and re-run.")+ return out+++COMMAND_HANDLERS["prove_library"] = _handle_prove_library++++def _preflight_lbr(path: str) -> dict:+ """PRE-ANALYZE a .lbr: will Fusion open it? Every check is offline and named.++ John, 2026-08-21: "what if you just pre-analyze a library to make sure its going to+ open ok?" - this is that, as one reusable gate. It encodes every rejection Fusion has+ actually produced on this bridge: a truncated binary file (77-byte ads1220irvar, the+ blocking Error modal), empty <packages> (Fusion's silent "No Components"), dead+ <connects> (renders fine, wires nothing), and absent devicesets (EDIT prompts).+ Returns {ok, checks: [{check, ok, detail}], errors: [...]} - callers show the list.+ """+ import xml.etree.ElementTree as _ET+ checks = []+ def _c(name, ok, detail=""):+ checks.append({"check": name, "ok": bool(ok), "detail": detail})+ return ok+ p = (path or "").replace("\\", "/")+ try:+ sz = os.path.getsize(p)+ except Exception as e:+ _c("file-readable", False, str(e)[:120])+ return {"ok": False, "checks": checks,+ "errors": [c["check"] + ": " + c["detail"] for c in checks if not c["ok"]]}+ _c("file-readable", True, "%d bytes" % sz)+ if not _c("size-sane", sz >= 200,+ "%d bytes - a real EAGLE library is never this small (truncated transfer?)" % sz):+ pass+ root = None+ if sz >= 200:+ try:+ root = _ET.parse(p).getroot()+ _c("xml-parses", True)+ except Exception as e:+ _c("xml-parses", False, str(e)[:160])+ if root is not None:+ _c("root-is-eagle", root.tag == "eagle", "root is <%s>" % root.tag)+ pkgs = [x.get("name") for x in root.iter("package")]+ syms = [x.get("name") for x in root.iter("symbol")]+ devs = [x.get("name") for x in root.iter("deviceset")]+ _c("packages-nonempty", bool(pkgs),+ "%d package(s)" % len(pkgs) if pkgs else+ "EMPTY <packages> - Fusion opens this to a silent 'No Components' library")+ _c("symbols-present", bool(syms), "%d symbol(s)" % len(syms))+ _c("devicesets-present", bool(devs),+ "%d deviceset(s)" % len(devs) if devs else+ "no <deviceset> - EDIT <name>.dev will raise Fusion's Create-new prompt")+ if pkgs and syms:+ conn = _check_connects(p)+ _c("connects-resolve", bool(conn.get("ok")),+ ("%d connect(s), all resolving" % (conn.get("connects") or 0))+ if conn.get("ok") else str(conn.get("why") or "invalid")[:160])+ # DUPLICATE PAD NAMES sink the WHOLE library in Fusion's EAGLE importer while+ # passing every XML-level check (adom-bridge#93: ads1220irvar shipped SIX pads+ # named "17" - thermal pad + stitching vias - and Fusion refused the file with+ # its generic 'has errors' modal). Mirror of the wiki's LBR_DUPLICATE_PADS lint.+ _dups = []+ for pkg_el in root.iter("package"):+ seen_pads = {}+ for pad_el in list(pkg_el.iter("smd")) + list(pkg_el.iter("pad")):+ nm = pad_el.get("name")+ if nm:+ seen_pads[nm] = seen_pads.get(nm, 0) + 1+ _dups += ["%s/%s x%d" % (pkg_el.get("name"), n, c)+ for n, c in seen_pads.items() if c > 1]+ _c("pads-unique", not _dups,+ ("duplicate pad names - Fusion's importer refuses the ENTIRE library over "+ "these: %s. Rename to e.g. 17, 17@2, 17@3..." % ", ".join(_dups[:6]))+ if _dups else "all pad names unique per package")+ ok = all(c["ok"] for c in checks)+ return {"ok": ok, "checks": checks,+ "errors": [c["check"] + ": " + c["detail"] for c in checks if not c["ok"]]}+++def _handle_preflight_lbr(fusion_info: dict, args: dict) -> dict:+ """Verb wrapper: will this .lbr open cleanly in Fusion? Offline, instant, no Fusion."""+ p = (args.get("libraryPath") or args.get("filePath") or "").replace("\\", "/")+ if not p:+ return {"success": False, "error": "libraryPath required."}+ pf = _preflight_lbr(p)+ return {"success": pf["ok"], "preflight": pf, "libraryPath": p,+ "errorCode": None if pf["ok"] else "lbr_preflight_failed",+ "_hint": ("Every check green - Fusion will open this library cleanly."+ if pf["ok"] else+ "Fix the named check(s) BEFORE any Fusion verb touches this file - "+ "each one maps to a real Fusion failure (blocking Error modal, silent "+ "No Components, Create-new prompt, or dead wiring). Re-stage and "+ "hash-compare if size-sane or xml-parses failed: transfer corruption "+ "is the usual cause.")}+++COMMAND_HANDLERS["preflight_lbr"] = _handle_preflight_lbr+++def _kicad_ingest_unavailable_error() -> dict:+ """The shape-B refusal, shared by import_kicad and the create_library fast-fail.++ Platform rule (John, 2026-08-21): a bridge runs on Windows or Mac; ALL Adom binaries+ are Linux-container-only. So KiCad conversion can never happen on this box, and the+ contract is authoring-time: convert in a container, publish the validated .lbr on the+ part page, hand the bridge shape A (.lbr + .step)."""+ return {"success": False, "errorCode": "adom_lbr_unavailable",+ "kicadIngest": False,+ "error": "adom-lbr (the canonical KiCad->EAGLE converter) does not run on this "+ "platform - Adom binaries are Linux-container-only by design - and this "+ "bridge deliberately does NOT convert KiCad formats itself.",+ "_hint": ("Convert at AUTHORING time, where adom-lbr genuinely runs: in a "+ "container, `adom-lbr generate --sym <.kicad_sym> --fp <.kicad_mod> "+ "-o <out.lbr>` (auto-linted), publish the .lbr on the part page, then "+ "call fusion_create_library shape A {libraryPath, stepPath}. "+ "fusion_readiness reports kicadIngest so this is knowable before "+ "staging files or launching Fusion.")}+++def _find_adom_lbr() -> str:+ """Locate adom-lbr on THIS box (the canonical KiCad->EAGLE converter, adom/adom-lbr)."""+ import shutil as _sh+ hit = _sh.which("adom-lbr") or _sh.which("adom-lbr.exe")+ if hit:+ return hit+ la = os.environ.get("LOCALAPPDATA", "")+ for c in ([os.path.join(la, "Programs", "adom-lbr", "adom-lbr.exe"),+ os.path.join(la, "adom-lbr", "adom-lbr.exe")] if la else []):+ if os.path.exists(c):+ return c+ return ""++++# _install_adom_lbr_windows was DELETED (2026-08-21). The platform rule it violated:+# a bridge runs on Windows or Mac, and ALL Adom binaries are Linux-container-only by+# deliberate decision - there will never be a windows adom-lbr asset for it to fetch+# (adom-lbr#6 withdrawn, adom-lbr#8 retracted by its own filer). Conversion happens at+# AUTHORING time in a container; the published .lbr is the runtime contract (page+# issue #43, adom-bridge#92).+++def _apply_pin_map(lbr_path: str, pin_map: dict) -> dict:+ """Rewrite <connects> from an EXPLICIT {pinName: padName} map, then re-verify.++ The identity join (pin number == pad name) is convention, and when it breaks no+ converter can know the truth - it is datasheet knowledge. The caller supplies it here;+ a deterministic XML rewrite applies it; _check_connects validates the result. Refuses+ (and changes nothing) if the map references pins or pads that do not exist.+ """+ try:+ text = open(lbr_path, "r", encoding="utf-8", errors="replace").read()+ except Exception as e:+ return {"ok": False, "why": "unreadable: %s" % str(e)[:80]}+ import re as _re+ import xml.etree.ElementTree as _ET+ try:+ root = _ET.fromstring(text)+ except Exception:+ return {"ok": False, "why": "library is not valid XML"}+ pins = {p.get("name") for p in root.iter("pin") if p.get("name")}+ pads = {p.get("name") for p in root.iter("smd") if p.get("name")}+ pads |= {p.get("name") for p in root.iter("pad") if p.get("name")}+ bad = [(k, v) for k, v in pin_map.items() if k not in pins or v not in pads]+ if bad:+ return {"ok": False, "why": "map references missing pins/pads",+ "bad": bad[:8], "pins": sorted(pins)[:20], "pads": sorted(pads)[:20]}+ gate = ( _re.findall(r'<gate name="([^"]+)"', text) or ["G$1"])[0]+ conns = "\n".join('<connect gate="%s" pin="%s" pad="%s"/>' % (gate, k, v)+ for k, v in pin_map.items())+ joined = "<connects>\n%s\n</connects>" % conns+ if _re.search(r"<connects>.*?</connects>", text, _re.S):+ text = _re.sub(r"<connects>.*?</connects>", joined, text, count=1, flags=_re.S)+ elif "<connects/>" in text:+ text = text.replace("<connects/>", joined, 1)+ else:+ return {"ok": False, "why": "no <connects> element to rewrite"}+ open(lbr_path, "w", encoding="utf-8").write(text)+ chk = _check_connects(lbr_path)+ return {"ok": bool(chk.get("ok")), "applied": len(pin_map), "verify": chk}+++def _handle_import_kicad(fusion_info: dict, args: dict) -> dict:+ """Ingest KiCad symbol/footprint files and produce a valid .lbr (issue #43) - by+ DELEGATING to adom-lbr, the ecosystem's canonical converter.++ John, 2026-08-21: "just use adom-lbr as your converter" - instead of this bridge+ growing its own KiCad parser. adom-lbr owns the conversion, ships a linter whose+ checks name the exact failure this issue was filed about (empty-packages /+ empty-connects -> Fusion "No Components"), and round-trips KiCad, EAGLE and Altium.+ One converter, one set of bugs, one place to fix them.++ args: kicadSymbolPath, kicadFootprintPath (Windows paths), libraryPath (output,+ optional), name (optional override).+ """+ import subprocess as _sp+ sym_p = (args.get("kicadSymbolPath") or "").replace("\\", "/")+ mod_p = (args.get("kicadFootprintPath") or "").replace("\\", "/")+ lbr_p = (args.get("libraryPath") or "").replace("\\", "/")+ if not sym_p and not mod_p:+ return {"success": False, "errorCode": "kicad_source_required",+ "error": "Pass kicadSymbolPath and/or kicadFootprintPath."}+ tool = _find_adom_lbr()+ if not tool:+ return _kicad_ingest_unavailable_error()+ if not lbr_p:+ base = (sym_p or mod_p).rsplit("/", 1)+ stem = base[-1].rsplit(".", 1)[0]+ lbr_p = (base[0] + "/" if len(base) > 1 else "") + stem + ".lbr"+ cmd = [tool, "generate"]+ if sym_p:+ cmd += ["--sym", sym_p]+ if mod_p:+ cmd += ["--fp", mod_p]+ if args.get("name"):+ cmd += ["--name", str(args["name"])]+ cmd += ["-o", lbr_p]+ try:+ r = _sp.run(cmd, capture_output=True, text=True, timeout=120)+ except Exception as e:+ return {"success": False, "errorCode": "adom_lbr_failed",+ "error": "adom-lbr did not run: %s" % str(e)[:150]}+ if r.returncode != 0:+ return {"success": False, "errorCode": "adom_lbr_failed",+ "error": "adom-lbr generate failed (exit %d): %s"+ % (r.returncode, (r.stderr or r.stdout)[-300:]),+ "_hint": "adom-lbr auto-lints its output and exits 2 on lint failure - the "+ "message above names the exact check that failed."}+ names = _library_artifact_names(lbr_p)+ pin_map = args.get("pinMap")+ pm_result = None+ if isinstance(pin_map, dict) and pin_map:+ pm_result = _apply_pin_map(lbr_p, {str(k): str(v) for k, v in pin_map.items()})+ if not pm_result.get("ok"):+ return {"success": False, "errorCode": "pin_map_invalid",+ "error": "The explicit pinMap could not be applied: %s" % pm_result.get("why"),+ "pinMapDetail": pm_result,+ "_hint": "pinMap must be {pinName: padName} using names that exist - the "+ "detail lists the symbol's pins and the package's pads."}+ return {"success": True, "libraryPath": lbr_p, "converter": "adom-lbr",+ "pinMapApplied": (pm_result or {}).get("applied"),+ "symbols": names.get("sym", []), "packages": names.get("pac", []),+ "devicesets": names.get("dev", []), "has3d": bool(names.get("p3d")),+ "toolOutput": (r.stdout or "").strip()[-300:],+ "_hint": ("Generated by adom-lbr (lint-clean by construction). fusion_show_symbol / "+ "show_footprint consume it directly; bind the 3D with "+ "fusion_bind_library_3d {libraryPath, stepPath}, then "+ "fusion_prove_library captures the four evidence frames.")}+++COMMAND_HANDLERS["import_kicad"] = _handle_import_kicad++++_PROVE_LAST = [None]+++def _handle_prove_result(fusion_info: dict, args: dict) -> dict:+ """Fetch the LAST completed fusion_prove_library run.++ prove_library drives four editor views with settle loops and routinely outlives ab's+ relay window - and since it returned its evidence IN the response, a timeout LOST the+ result while the work had succeeded (caught live on the CR0603 chain, 2026-08-21). The+ run now persists its outcome, and this verb hands it back: fire prove_library, and on a+ relay timeout poll THIS until it reports the run you started.+ """+ # RESULT IDENTITY (#52 item 6, 2026-08-24): with several threads driving one Fusion,+ # "the last run globally" is the WRONG run for anyone who passed a runId - the matrix+ # runner picked up another thread's TLV9001 chain as its own result. A given runId is+ # now authoritative: looked up in the per-run store, and an unknown id is an ERROR,+ # never silently the latest.+ _rid = str(args.get("runId") or "").strip()+ res = None+ if _rid:+ try:+ import tempfile as _tf+ _runs = json.loads(open(os.path.join(_tf.gettempdir(), "fusion-prove-runs.json"),+ encoding="utf-8").read())+ res = _runs.get(_rid)+ except Exception:+ res = None+ if res is None:+ _pl = _PROVE_LAST[0]+ if _pl and _pl.get("runId") == _rid:+ res = _pl+ if res is None:+ return {"success": False, "errorCode": "unknown_run_id",+ "error": "no prove run with runId %r on this box" % _rid,+ "_hint": "runId comes from fusion_create_library's response. Omit runId "+ "for the latest completed run (single-caller convenience only)."}+ if res is None:+ res = _PROVE_LAST[0]+ if res is None:+ try:+ import tempfile as _tf+ res = json.loads(open(os.path.join(_tf.gettempdir(), "fusion-prove-last.json"),+ encoding="utf-8").read())+ except Exception:+ res = None+ if res is None:+ return {"success": False, "errorCode": "no_prove_run",+ "error": "No fusion_prove_library run has completed on this box yet.",+ "_hint": "Run fusion_prove_library {libraryPath}; if the relay times out, the "+ "bridge keeps working - poll this verb until the result appears."}+ # INLINE FRAMES ARE OPT-IN (issue #47's web-control ask): each evidence entry carries+ # thumbnailDataUrl, but four base64 frames on EVERY 15s poll would bloat the relay+ # body. Pass {inlineFrames:true} on the FINAL fetch to get them; plain polls return+ # paths + metadata only.+ if not args.get("inlineFrames"):+ try:+ res = json.loads(json.dumps(res))+ for e in ((res.get("prove") or {}).get("evidence") or []) + (res.get("evidence") or []):+ e.pop("thumbnailDataUrl", None)+ except Exception:+ pass+ # LIVE PROGRESS FOR WEB-CONTROL (John, 2026-08-21: "as accurate of progress bars as we+ # can ... you should know how long stuff takes by measuring it"). Computed AT READ TIME+ # from the persisted stage plan + this machine's measured stage history, so every poll+ # gets fresh numbers with no ticker thread: percent = (sum of completed stages'+ # estimates + elapsed of the current stage, capped at 95% of its estimate) / total.+ # The estimates are the rolling per-machine p50s that update after EVERY run, so the+ # bar gets more accurate the more this user uses it - per box, automatically.+ try:+ if res.get("stage") not in (None, "done", "failed") and res.get("plan"):+ import time as _tp+ plan = res.get("plan") or []+ total = sum(float(p.get("estimatedSec") or 0) for p in plan) or 1.0+ done_names = {d.get("stage") for d in (res.get("stagesDone") or [])}+ done_est = sum(float(p.get("estimatedSec") or 0) for p in plan+ if p.get("stage") in done_names)+ cur = next((p for p in plan if p.get("stage") not in done_names), None)+ cur_elapsed = max(0.0, _tp.time() - float(res.get("stageStartedAt") or _tp.time()))+ cur_est = float((cur or {}).get("estimatedSec") or 1)+ cur_credit = min(cur_elapsed, cur_est * 0.95)+ pct = int(100 * (done_est + cur_credit) / total)+ eta = max(0, int(total - done_est - cur_credit))+ confs = [p.get("confidence") for p in plan]+ res["progress"] = {+ "percent": min(95, pct), "etaSec": eta,+ "elapsedSec": int(cur_elapsed), "stepLabel": res.get("stage"),+ "estimatedSec": int(total),+ "confidence": ("measured" if all(c == "measured" for c in confs)+ else "unknown" if all(c == "unknown" for c in confs)+ else "typical"),+ "overdue": cur_elapsed > cur_est,+ }+ except Exception:+ pass+ want = str(args.get("runId") or "")+ if want and str(res.get("runId") or "") != want:+ # PER-RUN store first (issue #47: concurrent chains used to overwrite the single+ # latest slot, so a valid runId could match NOTHING and the caller's poll hung).+ try:+ import tempfile as _tfr+ _runs = json.loads(open(os.path.join(_tfr.gettempdir(),+ "fusion-prove-runs.json"),+ encoding="utf-8").read())+ except Exception:+ _runs = {}+ if want in _runs:+ _r2 = _runs[want]+ if not args.get("inlineFrames"):+ try:+ _r2 = json.loads(json.dumps(_r2))+ for e in ((_r2.get("prove") or {}).get("evidence") or []) + (_r2.get("evidence") or []):+ e.pop("thumbnailDataUrl", None)+ except Exception:+ pass+ return {"success": True, "result": _r2,+ "_hint": "Your run, from the per-run store (concurrent chains no "+ "longer overwrite each other)."}+ return {"success": False, "errorCode": "run_not_found",+ "error": "No stored result for run %s (latest is %s; %d run(s) in the "+ "per-run store)." % (want, res.get("runId"), len(_runs)),+ "knownRunIds": list(_runs)[-8:],+ "_hint": "If your chain JUST started, keep polling - its first state lands "+ "within seconds. A runId absent after ~30s means the chain never "+ "started (check the create_library response for an immediate "+ "refusal like lbr_preflight_failed) - treat that as TERMINAL "+ "rather than polling out your budget."}+ return {"success": True, "result": res,+ "_hint": "The last completed prove run (survives relay timeouts and respawns)."}+++COMMAND_HANDLERS["prove_result"] = _handle_prove_result++++def _handle_create_library(fusion_info: dict, args: dict) -> dict:+ """ONE verb for the whole "create a Fusion library" task - one call, one consent.++ John, 2026-08-21: web-control needs to hand the bridge EITHER a ready .lbr + .step, OR+ KiCad sources (+ optional pinMap) - two entry shapes, one contract. And ab measured what+ multi-verb flows cost a browser caller: every cross-origin call is a human consent+ (#30's nine-prompt incident). So this verb accepts both shapes and runs the whole chain+ itself: [convert via adom-lbr] -> [bind the STEP] -> [prove: four frames + verdict].++ Entry shapes:+ A: {libraryPath, stepPath?} - .lbr given, bind + prove+ B: {kicadSymbolPath?, kicadFootprintPath?, stepPath?,+ pinMap?, libraryPath?} - convert, then bind + prove++ The chain outlives ab's relay window by design, so it returns IMMEDIATELY with+ statusVerb fusion_prove_result (the #32 pollable pattern): fire once, poll one verb,+ both results (progress and final) come from state the bridge persists.+ """+ import threading as _th+ import tempfile as _tf+ lbr_p = (args.get("libraryPath") or "").replace("\\", "/")+ sym_p = (args.get("kicadSymbolPath") or "").replace("\\", "/")+ mod_p = (args.get("kicadFootprintPath") or "").replace("\\", "/")+ step_p = (args.get("stepPath") or "").replace("\\", "/")+ if not lbr_p and not (sym_p or mod_p):+ return {"success": False, "errorCode": "no_input",+ "error": "Give me EITHER libraryPath (a ready .lbr) OR kicadSymbolPath / "+ "kicadFootprintPath (KiCad sources).",+ "_hint": "Shape A: {libraryPath, stepPath}. Shape B: {kicadSymbolPath, "+ "kicadFootprintPath, stepPath, pinMap?}. stepPath is required for "+ "a PROVEN library - without it there is no real 3D body."}+ # SHAPE B FAILS AT THE FRONT DOOR, never three stages in (#43 follow-up, 2026-08-21):+ # the old flow staged files, launched Fusion (a minute of the user's time), and THEN+ # discovered adom-lbr is absent - which on Windows/Mac it always is, by platform rule.+ # Knowable up front, so refused up front; fusion_readiness carries the same flag.+ if (sym_p or mod_p) and not _find_adom_lbr():+ return _kicad_ingest_unavailable_error()+ # SHAPE A PRE-ANALYSIS at the same front door (John, 2026-08-21: "pre-analyze a+ # library to make sure its going to open ok"): a bad .lbr must never cost the caller+ # a Fusion LAUNCH to discover. Same named checks as fusion_preflight_lbr.+ if lbr_p and not (sym_p or mod_p):+ # MID-WRITE TOLERANCE (#49 "start-failed / no runId", 2026-08-23): a caller that+ # STAGES the .lbr and calls this in the same breath can race the file's final flush -+ # preflight's own size-sane / xml-parses checks (built to catch a TRUNCATED file)+ # then fire on a file that is merely half-written, the call returns lbr_preflight_failed+ # with NO runId, and the very next attempt (file now complete) succeeds. That is a+ # transient the code can absorb: re-run preflight a few times with a short settle so a+ # still-flushing file gets its beat. A GENUINELY bad .lbr fails every try and still+ # returns fast. Only the truncation-class checks are worth retrying; a structural error+ # (root-not-eagle, no devicesets) will not fix itself, but re-checking it is cheap.+ import time as _pf_t+ _pf = _preflight_lbr(lbr_p)+ for _pf_try in range(4):+ if _pf["ok"]:+ break+ _pf_t.sleep(0.6)+ _pf = _preflight_lbr(lbr_p)+ if not _pf["ok"]:+ return {"success": False, "errorCode": "lbr_preflight_failed",+ "preflight": _pf, "runId": None, "preflightRetries": _pf_try,+ "error": "Pre-analysis says Fusion will NOT open this library (re-checked "+ "%d times over ~%.1fs in case it was mid-write): "+ % (_pf_try + 1, _pf_try * 0.6) ++ "; ".join(_pf["errors"])[:360],+ "_hint": "This is NOT 'Fusion failed to start' - there is no runId because "+ "the .lbr did not pass pre-analysis (errorCode lbr_preflight_failed; "+ "see preflight.errors). If your harness stages the file, ensure the "+ "write is FLUSHED/closed before calling. fusion_preflight_lbr re-runs "+ "these checks in isolation."}++ import uuid as _uuid+ run_id = _uuid.uuid4().hex[:12]++ def _chain():+ state = {"stage": "starting", "success": None, "libraryPath": lbr_p or None,+ "runId": run_id}+ # Register with the hang gate: while this chain runs (and is under the 900s+ # ceiling) a non-pumping main thread is WORK, not a hang (#49 mid-mint kills).+ _LONG_OP["name"] = "create_library:" + run_id+ _LONG_OP["since"] = __import__("time").time()+ _docs_before = _open_doc_names() # chain hygiene: everything opened after this is ours+ def _save():+ # PER-RUN persistence (issue #47 follow-up, 2026-08-21): a single latest-run+ # slot meant two concurrent chains OVERWROTE each other - the matrix's poll+ # hung 18 minutes on a runId whose state my verification chain had clobbered,+ # and a no-runId poll returned someone else's run. Every run keeps its own+ # entry (capped at 20, insertion order); latest stays for no-runId polls.+ try:+ _PROVE_LAST[0] = dict(state)+ open(os.path.join(_tf.gettempdir(), "fusion-prove-last.json"), "w",+ encoding="utf-8").write(json.dumps(state))+ _runs_p = os.path.join(_tf.gettempdir(), "fusion-prove-runs.json")+ try:+ _runs = json.loads(open(_runs_p, encoding="utf-8").read())+ except Exception:+ _runs = {}+ _runs[run_id] = dict(state)+ while len(_runs) > 20:+ _runs.pop(next(iter(_runs)))+ open(_runs_p, "w", encoding="utf-8").write(json.dumps(_runs))+ except Exception:+ pass+ import timings as _tmc+ import time as _tc+ # Stage estimates are MEASURED per machine and self-improving: each completed stage+ # records its actual into the rolling store, so the very next run's bar is paced by+ # what THIS box just did. Seeds (used only until real samples exist): launch 60s,+ # convert 5s, bind 60s (measured 45s on AdomLapper), prove 300s (four frames ++ # thumbnail wait).+ #+ # DOCTRINE (run 8bb786b174ee, 2026-08-21): the chain once failed with "Fusion 360 is+ # installed but not running" - an error the code can fix. Fusion not being up is a+ # STAGE of this chain, never a refusal: probe once, and when the add-in is not+ # answering, launch Fusion ourselves and wait for the round trip.+ _needs_launch = not _addin_round_trip_ready(tries=1)+ _STAGES = [("launch", 60.0, _needs_launch),+ ("convert", 5.0, bool(sym_p or mod_p)),+ ("bind", 60.0, bool(step_p)),+ ("prove", 300.0, True)]+ state["plan"] = [{"stage": n, "estimatedSec": round(_tmc.estimate("stage:" + n, f)[0] or f, 1),+ "confidence": _tmc.estimate("stage:" + n, f)[1]}+ for n, f, active in _STAGES if active]+ state["stagesDone"] = []+ _est_by_stage = {p["stage"]: float(p.get("estimatedSec") or 1.0)+ for p in state["plan"]}+ _est_total = sum(_est_by_stage.values()) or 1.0+ _est_done = [0.0]+ def _enter(stage_name):+ state["stage"] = stage_name+ state["stageStartedAt"] = _tc.time()+ _save()+ # honest taskbar progress instead of orange attention-flashes (2026-08-23)+ _taskbar_progress(100.0 * _est_done[0] / _est_total)+ def _leave(stage_key):+ took = _tc.time() - float(state.get("stageStartedAt") or _tc.time())+ _tmc.log_run("stage:" + stage_key, took, ok=True)+ state["stagesDone"] = state.get("stagesDone", []) + [+ {"stage": stage_key, "seconds": round(took, 1)}]+ _est_done[0] += _est_by_stage.get(stage_key, 0.0)+ _taskbar_progress(100.0 * _est_done[0] / _est_total)+ try:+ if _needs_launch:+ _enter("launching Fusion (add-in loads at start)")+ dispatch_command("start", {})+ _deadline = _tc.time() + 240+ while _tc.time() < _deadline and not _addin_round_trip_ready(tries=1):+ _tc.sleep(5)+ if not _addin_round_trip_ready(tries=1):+ state["launch"] = {"success": False,+ "error": "Fusion did not become ready within 240s "+ "of the chain launching it."}+ state.update(stage="failed", success=False); _save(); return+ _leave("launch")+ target_lbr = lbr_p+ if sym_p or mod_p:+ _enter("converting (adom-lbr)")+ conv = _handle_import_kicad(fusion_info, {+ "kicadSymbolPath": sym_p, "kicadFootprintPath": mod_p,+ "libraryPath": lbr_p, "pinMap": args.get("pinMap"),+ "name": args.get("name")})+ state["convert"] = {k: conv.get(k) for k in+ ("success", "errorCode", "error", "libraryPath",+ "packages", "symbols", "pinMapApplied")}+ if not conv.get("success"):+ state.update(stage="failed", success=False); _save(); return+ _leave("convert")+ target_lbr = conv.get("libraryPath")+ state["libraryPath"] = target_lbr+ if step_p:+ _enter("binding 3D (STEP -> f3d -> package3d)")+ bind = dispatch_command("bind_library_3d",+ {"libraryPath": target_lbr, "stepPath": step_p})+ state["bind"] = {k: bind.get(k) for k in+ ("success", "error", "wip_urn", "reopened",+ "reopenError")}+ if not bind.get("success"):+ # NEVER surface a failure with error:null (#49, John 2026-08-23). If the+ # bind handler somehow returns no reason, synthesize one from whatever it+ # did carry so the caller always sees WHY, never a silent rejection next+ # to a real urn.+ if not state["bind"].get("error"):+ _u = state["bind"].get("wip_urn")+ state["bind"]["error"] = (+ ("bind reported failure but gave no reason; a urn WAS minted (%s) "+ "- likely a post-mint step (library re-open / verify) failed "+ "silently" % _u) if _u else+ "bind failed and returned no reason and no urn")+ state.update(stage="failed", success=False); _save(); return+ _leave("bind")+ _enter("proving (four evidence frames)")+ # #60: publish each artifact THE MOMENT it lands, in assembly order+ # (symbol -> footprint -> 3d -> ganged), so a poller watches the bundle build+ # instead of waiting ~370s for all four. The frame carries its own label, so a+ # renderer can show what just arrived and which phase is running.+ def _publish_frame(entry):+ ev = state.setdefault("evidence", [])+ ev.append(entry)+ state["evidenceCount"] = len(ev)+ state["lastArtifact"] = entry.get("step")+ state["stageLabel"] = "captured %s (%d of 4)" % (entry.get("step"), len(ev))+ _save()++ prove = _handle_prove_library(fusion_info, {+ "libraryPath": target_lbr, "name": args.get("name"),+ # the bind just told us exactly which f3d holds the geometry - hand it on so+ # the 3D frame never depends on re-parsing the lbr for it+ "f3dUrn": ((state.get("bind") or {}).get("wip_urn") or ""),+ "_onFrame": _publish_frame})+ _leave("prove")+ _frames = len((prove or {}).get("evidence") or []) or int(state.get("evidenceCount") or 0)+ if not prove.get("proven") and _frames == 0:+ # Zero frames means the library never presented at all - "done,+ # not_proven" read as a soft verdict while the USER was looking at+ # Fusion's error dialog (adom/fusion-bridge#65). Name the dialog+ # when one is up and land a hard failed stage.+ _dlg_txt = None+ try:+ from handlers.dialog_classify import classify_blocking_dialogs, FAILURE_CATEGORIES+ for _d in (classify_blocking_dialogs() or []):+ if _d.get("category") in FAILURE_CATEGORIES:+ _dlg_txt = (_d.get("body") or _d.get("title") or "Error dialog")[:300]+ break+ except Exception:+ pass+ state.update(stage="failed", success=False, verdict="not_proven",+ errorCode="library_never_presented",+ error=(_dlg_txt or "prove produced no evidence frames: the "+ "library document never presented (bad .lbr, or Fusion "+ "showed an error dialog)"),+ prove=prove)+ _save(); return+ state.update(stage="done", success=bool(prove.get("proven")),+ verdict=("proven" if prove.get("proven") else "not_proven"),+ prove=prove)+ _save()+ except Exception as e:+ state.update(stage="failed", success=False, verdict="not_proven",+ error=str(e)[:400]); _save()+ finally:+ if _LONG_OP.get("name") == "create_library:" + run_id:+ _LONG_OP["name"] = None+ _LONG_OP["since"] = 0.0+ _taskbar_progress(None) # drop the bar + kill any lingering orange flash+ # A BATCH MUST NOT NEED THE CALLER TO SWEEP BETWEEN PARTS (page issue #47,+ # failure mode 5: unsaved library docs accumulated until the add-in stopped+ # responding and one part's mess failed every part after it). Close THIS+ # chain's library document on success AND failure - prove's own close is one+ # path here, this is the guarantee. Idempotent when already closed.+ try:+ _lbr_done = (state.get("libraryPath") or lbr_p or "")+ if _lbr_done:+ _stem_done = _lbr_done.replace("\\", "/").rsplit("/", 1)[-1].rsplit(".", 1)[0]+ _closed = _close_bridge_doc_verified(_stem_done)+ state["libraryDocClosed"] = bool(_closed.get("closed"))+ _save()+ except Exception:+ pass+ # CHAIN HYGIENE: close EVERY doc this chain opened (the Create3DPackage+ # 'Untitled' generators, the saved 3D, anything a frame left behind), not just+ # the library. Pre-existing docs are untouched.+ try:+ state["scratchDocsClosed"] = _close_docs_opened_since(_docs_before)+ _save()+ except Exception:+ pass++ _th.Thread(target=_chain, daemon=True).start()+ return {"success": True, "started": True, "runId": run_id,+ "statusVerb": "fusion_prove_result",+ "shape": "A (.lbr direct)" if (lbr_p and not (sym_p or mod_p)) else "B (KiCad sources)",+ "_hint": ("The chain runs in the background (it outlives the relay window by "+ "design). Poll fusion_prove_result every ~15s: `stage` narrates "+ "(converting -> binding 3D -> proving), and on stage:'done' the "+ "`prove` block carries the four evidence frames and the verdict. "+ "ONE call, ONE consent, ONE poll verb.")}+++COMMAND_HANDLERS["create_library"] = _handle_create_library+COMMAND_HANDLERS["demo_control"] = lambda info, args: _handle_demo_control(args)+++def _handle_action_ledger(args: dict) -> dict:+ """READ-ONLY view of the background-action ledger (#52 item 1: 'a caller on a relay or+ in a container is blind to it'). Answers "who touched my host app" without filesystem+ access. args: {limit (default 50, max 500), sinceSeconds, action (substring filter)}."""+ import pathlib as _pl+ p = _pl.Path(os.path.expanduser("~")) / ".adom" / "fusion-bridge" / "actions.jsonl"+ if not p.exists():+ return {"success": True, "entries": [], "count": 0,+ "_hint": "Ledger empty: the bridge has taken no background actions on this "+ "box since 1.9.177 (or none since the file was last cleared)."}+ limit = max(1, min(int(args.get("limit") or 50), 500))+ since = args.get("sinceSeconds")+ want = str(args.get("action") or "").lower()+ import time as _t+ cutoff = (_t.time() - float(since)) if since else None+ out = []+ try:+ for line in p.read_text(encoding="utf-8", errors="replace").splitlines():+ try:+ e = json.loads(line)+ except Exception:+ continue+ if cutoff and float(e.get("epoch") or 0) < cutoff:+ continue+ if want and want not in str(e.get("action") or "").lower():+ continue+ out.append(e)+ except Exception as e:+ return {"success": False, "error": "ledger unreadable: %s" % e}+ return {"success": True, "entries": out[-limit:], "count": len(out),+ "_hint": ("Every background action the bridge takes on the host app - update "+ "clicks and deferrals, seat takeovers, dialog dismissals, doc closes, "+ "hang-gate kills, WM_CLOSEs (with window class+size) - appended live, "+ "survives respawns. Filter: {action:'wm_close', sinceSeconds:3600}.")}+++COMMAND_HANDLERS["action_ledger"] = lambda info, args: _handle_action_ledger(args or {})+++def _handle_reset_workspace(args: dict) -> dict:+ """fusion_reset_workspace - bring Fusion back to a clean slate between runs (#59).++ Closes named documents AND the Untitled scratch the library/bind flow leaves behind,+ which close_all_documents alone could not do. Unmodified scratch always; MODIFIED+ Untitled docs only with force:true, because those can hold real unsaved work.+ """+ force = bool(args.get("force", False))+ r = dispatch_command("close_all_documents",+ {"save": False, "closeUntitled": True, "force": force})+ swept = _sweep_untitled_scratch()+ left = []+ try:+ st = _proxy_to_addin("document_info", {}, timeout=8)+ left = [d.get("name") for d in ((st.get("data") or {}).get("openDocuments") or [])]+ except Exception:+ pass+ return {"success": True, "closedByAddin": (r.get("data") or {}).get("closed"),+ "skipped": (r.get("data") or {}).get("skipped"),+ "untitledScratchClosed": len(swept), "remaining": left,+ "_hint": ("Fusion always keeps ONE blank document alive, so a single Untitled "+ "remaining is correct, not a failure. A modified Untitled is left "+ "untouched unless you pass force:true - it may hold unsaved work.")}+++COMMAND_HANDLERS["reset_workspace"] = lambda info, args: _handle_reset_workspace(args or {})++++def _handle_check_3d_thumbnail(fusion_info: dict, args: dict) -> dict:+ """Is the Library Manager's 3D THUMBNAIL actually rendered? Deterministic, no pixels.++ The thumbnail is rendered by Autodesk's cloud against the bound f3d and exposed as a+ relationship on the file's version - a free /data/v1 read. Fusion takes its time after a+ bind, so `waitSeconds` polls (default 0 = single check). This is the check John named as+ "an area you always screw up": a bound urn with NO rendered thumbnail shows the user a+ grey slab in the Library Manager while everything else reports success.++ args: {libraryPath | urn, fileName?, projectId?, folderId?, waitSeconds?}+ """+ import time as _t+ import aps as _aps+ urn = (args.get("urn") or "").strip()+ lbr = (args.get("libraryPath") or "").replace("\\", "/")+ file_name = args.get("fileName") or ""+ if not urn and lbr:+ try:+ import xml.etree.ElementTree as _ET+ root = _ET.parse(lbr).getroot()+ for p3 in root.iter("package3d"):+ # wip_urn, not urn: this bridge writes <package3d urn="" wip_urn="...">+ # (the same one-attribute miss that broke prove's 3D frame, issue #46 -+ # and unfixed HERE it made tja1051t/type-c report thumbnail not_found+ # with the file sitting right there, issue #47 failure mode 4)+ urn = (p3.get("urn") or p3.get("wip_urn") or "").strip() or urn+ file_name = file_name or (p3.get("name") or "")+ except Exception:+ pass+ if not urn and not file_name:+ return {"success": False, "errorCode": "no_3d_reference",+ "error": "No urn/fileName given and the .lbr carries no package3d urn.",+ "_hint": "Bind first (fusion_bind_library_3d), then check the thumbnail."}+ project_id, folder_id = _discover_upload_target(args)+ if not folder_id:+ return {"success": False, "errorCode": "no_work_folder",+ "error": "Cannot resolve the upload folder to look in (APS signed in?)."}+ deadline = _t.time() + float(args.get("waitSeconds") or 0)+ while True:+ st = _aps.thumbnail_state(project_id, folder_id, wip_urn=urn, file_name=file_name)+ if st.get("state") == "ready" or _t.time() >= deadline:+ break+ _t.sleep(10)+ ready = st.get("state") == "ready"+ return {"success": ready, "thumbnail": st, "urn": urn or None,+ "_hint": ("Thumbnail is RENDERED in the cloud - the Library Manager will show the "+ "real chip." if ready else+ "Not rendered yet (state=%s). Fusion uploads + renders asynchronously "+ "after a bind - re-call with waitSeconds:180 to poll, and treat a "+ "still-pending thumbnail as NOT proven." % st.get("state"))}+++COMMAND_HANDLERS["check_3d_thumbnail"] = _handle_check_3d_thumbnail+++def dispatch_command(command: str, args: dict, caller_identity: dict = None) -> dict:+ """Thin wrapper: run the verb, then guarantee the windowEtiquette annotation lands on+ EVERY backgrounded window-raising result - the inner dispatcher's closure only covered+ direct-handler and add-in paths, so orchestrated verbs (show_*, make_test_project, the+ tour, ...) returned unannotated (the calling AI could not know no focus was taken).+ Idempotent with the inner annotation (the note is only appended once)."""+ _fg = bool((args or {}).get("foreground"))+ # WHAT WE OPEN IS OURS TO CLEAN UP (see _register_docs_we_opened). Snapshot before,+ # diff after, so every document our verbs create is attributable to us - and only ours.+ _cmd_short = command[7:] if command.startswith("fusion_") else command+ _docs_before = _snapshot_open_docs() if _cmd_short in _WINDOW_RAISING else None+ res = _time_and_indicate(_cmd_short, args,+ lambda: _dispatch_command_inner(command, args, caller_identity))+ if _docs_before is not None:+ _register_docs_we_opened(_docs_before)+ # ── capture:true - ONE call = ONE consent = surface opened AND proven (issue #30) ──+ # ab measured NINE human consent prompts for a single Symbol button under an+ # "Allow ONCE" web-control grant: the show verb, then eight state-verb polls while the+ # page guessed at when the surface had settled. Page-side settle polling is+ # structurally wrong when every cross-origin call costs a consent, and the bridge is+ # the only party that actually KNOWS when the surface is ready - it opened it. So the+ # bridge owns settle + proof, and returns the frame inline.+ try:+ _cmd = command[7:] if command.startswith("fusion_") else command+ # REGISTER WHAT WE OPEN, OR WE LEAVE OUR MESS ON THE USER'S SCREEN (John, live+ # 2026-08-20: a Save prompt for our own staged demo part sat on his desktop). The+ # save-prompt sentinel only auto-discards documents it knows the BRIDGE created,+ # which is the right instinct - we must never discard the user's work - but the+ # show verbs stage a part and never registered it, so the sentinel correctly+ # refused to touch a dialog that was 100% our doing. Registering the target of+ # every show verb closes that gap without widening what we are willing to discard.+ if _cmd in _SHOW_SURFACE_VERBS and isinstance(args, dict):+ for _k in ("name", "deviceset", "libraryPath", "fileName", "query"):+ if args.get(_k):+ _note_bridge_doc(str(args[_k]))+ if (_cmd in _SHOW_SURFACE_VERBS and bool((args or {}).get("capture"))+ and isinstance(res, dict) and res.get("success") is not False):+ res["shot"] = _capture_surface_shot(_cmd, args or {})+ except Exception as _e:+ try:+ res["shot"] = {"title": command, "image": None, "error": str(_e)[:160]}+ except Exception:+ pass+ try:+ # FULL-PASS GUARANTEE (John, 2026-08-21: a Save-As sat on screen again and blocked+ # every web-control call behind it). Instead of auditing each of ~20 paths and+ # missing the 21st, ONE structural rule at the dispatch chokepoint - the same move+ # that fixed Create3DPackage: after ANY window-raising verb, if a Save/Save-As+ # prompt is standing and attributable to a bridge document, it is answered HERE,+ # before the verb returns. A modal we caused must never outlive the call.+ _cmd_sw = command[7:] if command.startswith("fusion_") else command+ if _cmd_sw in _WINDOW_RAISING:+ try:+ _sw = _dismiss_save_prompt_if_bridge_doc()+ if _sw.get("handled") and isinstance(res, dict):+ res["savePromptCleared"] = _sw.get("why")+ except Exception:+ pass+ # Same chokepoint, the other standing modals (John caught each on screen,+ # 2026-08-21). All are answered with their work-preserving choice - a raw+ # electron_run EDIT from ANY caller can raise the Create prompt, and no+ # verb-level pre-check reaches those, so the answer lives HERE:+ # upload_in_progress -> No (cancels the close; the upload survives)+ # create_confirm -> No (never fabricate an empty artifact)+ # lbr open Error -> OK (dismiss; its orphan Untitled design gets+ # closed by the doc janitor)+ try:+ for _d in (classify_blocking_dialogs() or []):+ _cat = _d.get("category")+ if _cat == "upload_in_progress":+ _unwrap(_ad_call("desktop_ui_click",+ {"hwnd": _d.get("hwnd"), "name": "No"}, timeout=12))+ if isinstance(res, dict):+ res["uploadCloseDeclined"] = True+ print("[Fusion Bridge] answered the Hub upload-close confirm with "+ "No (work-preserving; close retried after jobs drain)")+ elif _cat == "create_confirm":+ _unwrap(_ad_call("desktop_ui_click",+ {"hwnd": _d.get("hwnd"), "name": "No"}, timeout=12))+ if isinstance(res, dict):+ res["createPromptDeclined"] = True+ print("[Fusion Bridge] declined a standing Create-new prompt at the "+ "chokepoint (raised by a raw EDIT; Yes would fabricate an "+ "empty artifact)")+ elif _cat in ("launch_error", "lbr_open_error"):+ _unwrap(_ad_call("desktop_ui_click",+ {"hwnd": _d.get("hwnd"), "name": "OK"}, timeout=12))+ print("[Fusion Bridge] dismissed a standing %s dialog at the "+ "chokepoint" % _cat)+ except Exception:+ pass+ except Exception:+ pass+ try:+ cmd = command[7:] if command.startswith("fusion_") else command+ # windowEtiquette is an ENUM, not a boolean in disguise (SDK canonicalization,+ # page issue #34): a caller branches on it, so BOTH outcomes must be stated. We+ # only ever annotated the background case, which left a runner unable to tell+ # "came forward, with a reason" from "this verb does not report etiquette".+ if cmd in _WINDOW_RAISING and _fg and isinstance(res, dict):+ res.setdefault("windowEtiquette", "foreground")+ if cmd in _WINDOW_RAISING and not _fg and isinstance(res, dict):+ res.setdefault("windowEtiquette", "background")+ note = ("Window opened/changed in the BACKGROUND - the user's focus was NOT touched; "+ "do NOT foreground anything to compensate. Render results via fusion_state. "+ "If the USER asks to watch, re-call with {\"foreground\": true, "+ "\"foregroundReason\": \"<why, shown to the user as a 3s caption>\"}.")+ h = res.get("_hint")+ if not (isinstance(h, str) and "focus was NOT touched" in h):+ res["_hint"] = (h + " " + note) if isinstance(h, str) else note+ except Exception:+ pass+ return res+++def _dispatch_command_inner(command: str, args: dict, caller_identity: dict = None) -> dict:+ import time as _time # MUST be first: a later local `import time as _time` makes the+ # name local to this whole scope, so three earlier uses raised UnboundLocalError -+ # found by tools/shadow_check.py minutes after the same class shipped as the 1.9.128+ # open_lbr regression (issue #47). The gate now runs at every release.+ """Dispatch a command to the appropriate handler.++ caller_identity: {'thread','container','reason'} from the X-Adom-Caller-* headers AD stamped on+ this request. Bound to THIS thread (issue #348) so any AD callback we make while carrying out+ the verb is attributed to the AI thread that asked for it. Pass None to leave the binding alone.+ """+ if caller_identity is not None:+ _set_caller_identity(caller_identity)+ # Strip the "fusion_" prefix if present — handlers are registered without it (e.g. "aps_signin"+ # not "fusion_aps_signin"), but the relay passes them with the prefix for CLI clarity. Issue #327.+ if command.startswith("fusion_"):+ command = command[7:]++ # Driving-badge activity clock (John, 2026-08-17): every non-passive verb marks Fusion+ # as actively driven; the janitor sheds the badge after _BADGE_IDLE_SECONDS of quiet.+ try:+ _note_driving_activity(command)+ except Exception:+ pass++ # Document guard (issue #289 items 1 AND 5, Oliver): a verb carrying `expectDocument` must not+ # run against a document the caller didn't intend. Originally only mutating verbs were guarded;+ # Oliver's BOM session proved READS need it too - a passive tab switch silently returned a+ # different assembly (68/268 vs 94/424 instances) with nothing signalling the retarget. So the+ # assertion now fires for ANY verb that passes the arg: a caller who states an expectation wants+ # it checked. Asserted BEFORE dispatch, then stripped so handlers never see the key.+ if isinstance(args, dict) and args.get("expectDocument"):+ _guard = _assert_active_document(str(args["expectDocument"]))+ if _guard is not None:+ return _guard+ args = {k: v for k, v in args.items() if k != "expectDocument"}++ # Background envelope (John 2026-08-14): ANY verb that can raise a Fusion window runs guarded,+ # so a cloud open / view switch / import NEVER steals the user's foreground mid-typing. The+ # deliberate-foreground verbs (prefs_open, mcp_enable, demo - which announce themselves with+ # captions) are excluded on purpose. Refcounted, so overlapping verbs nest cleanly.+ _ran_backgrounded = False+ if command in _WINDOW_RAISING:+ if (args or {}).get("foreground"):+ # Foreground is allowed but never free (John, 2026-08-16): the CALLER must say+ # WHY, and the user gets that reason as an ab caption for ~3s BEFORE the steal,+ # so they know what is happening and can yield (or object). No reason = refuse+ # with a teaching hint - same pattern as every reason-gated Adom verb.+ fg_reason = str(args.get("foregroundReason") or "").strip()+ if not fg_reason:+ _note_focus_event(command, "refused")+ return {"success": False, "errorCode": "foreground_reason_required",+ "error": "foreground:true needs foregroundReason (a short user-facing sentence).",+ "_hint": ("Fusion verbs ALWAYS run in the background so the user is never "+ "interrupted mid-typing. If the user genuinely wants to WATCH, re-call "+ "with foreground:true AND foregroundReason:\"<why, in words the user "+ "will read>\" - it is shown as an ab caption for 3 seconds before the "+ "window comes forward, so they can yield.")}+ _note_focus_event(command, "foreground", fg_reason)+ try:+ ad_client.notify("Fusion is coming to the foreground",+ fg_reason + " (Requested by your AI - Fusion verbs otherwise always "+ "run in the background.)", level="info", reach_user=True)+ _time.sleep(3.0) # the user's 3-second heads-up BEFORE any focus change+ except Exception:+ pass+ args = {k: v for k, v in args.items() if k not in ("foreground", "foregroundReason")}+ else:+ _sec = ADDIN_COMMAND_TIMEOUTS.get(command, 60) + 20+ begin_background_envelope(float(_sec), minimize=(command == "start"))+ _ran_backgrounded = True++ def _annotate_background(res):+ # Tell the CALLING AI what just happened window-wise (John, 2026-08-16): the verb+ # ran under the background envelope - no focus was taken, nothing to apologize for,+ # and the AI should not "helpfully" foreground anything afterward.+ if _ran_backgrounded and isinstance(res, dict):+ res.setdefault("windowEtiquette", "background")+ note = ("Window opened/changed in the BACKGROUND - the user's focus was NOT touched; "+ "do NOT foreground anything to compensate. Render results via fusion_state. "+ "If the USER asks to watch, re-call with {\"foreground\": true, "+ "\"foregroundReason\": \"<why, shown to the user as a 3s caption>\"}.")+ h = res.get("_hint")+ res["_hint"] = (h + " " + note) if isinstance(h, str) and note not in h else (h or note)+ return res++ # Direct handlers (don't need the add-in)+ handler = COMMAND_HANDLERS.get(command)+ if handler is not None:+ return _annotate_background(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", "show_symbol", "show_footprint", "show_3d_package", "make_test_project", "bind_library_3d", "show_library", "show_project") or command in _OPEN_WITH_SCREENSHOT:+ # Issue #326: Fusion may be installed under a different Windows user profile than the current+ # one, so file-path detection fails even though Fusion is running. Check if it's actually+ # running (via tasklist) as a reliable fallback. If running, it's installed somewhere.+ fusion_running = _is_fusion_running()+ if not fusion_info.get("installed") and not fusion_running:+ return {+ "success": False,+ "error": "Fusion 360 is not installed on this machine.",+ "errorCode": "fusion_not_installed",+ "_hint": "Fusion 360 isn't installed. Do NOT tell the user to install it themselves - OFFER to install it FOR them and do it on a yes: the fusion-onboarding skill silent-installs Fusion + drives the Autodesk sign-in.",+ }+ if not fusion_running:+ # AUTO-OPEN, DO NOT REFUSE (page issue #64, 2026-08-26).+ # CLAUDE.md's automation doctrine: "If Fusion is closed, launch it ... Never+ # return an error the code could have fixed." fusion_create_library already+ # treats launch as a STAGE of the chain, but every INDIVIDUAL verb still+ # refused - so a user who ran a library import with Fusion merely closed+ # watched four steps fail in a row ("3D bind did NOT report a binding: Fusion+ # 360 is installed but not running", then symbol, footprint and 3D the same).+ # They cannot tell "the app was shut" from "this product is broken". Fusion+ # not being up is a STEP, never a refusal.+ _auto = None+ if not args.get("noAutoStart"):+ import time as _as+ _t0 = _as.time()+ print("[Fusion Bridge] %s needs Fusion; it is closed - launching it "+ "(page issue #64)" % command)+ try:+ # Same handler fusion_start uses (server.py:676), with waitForAddin so+ # we do not hand back a Fusion whose add-in is not answering yet.+ _auto = _handle_launch(fusion_info, {"waitForAddin": True}) or {}+ except Exception as _e:+ _auto = {"success": False, "error": "%s: %s" % (type(_e).__name__, _e)}+ _took = round(_as.time() - _t0, 1)+ _bridge_action_ledger("fusion_auto_started",+ {"forCommand": command, "ok": bool(_auto.get("success")),+ "tookSec": _took})+ if _auto.get("success") and _is_fusion_running():+ fusion_running = True+ print("[Fusion Bridge] Fusion up in %ss - continuing %s" % (_took, command))+ else:+ return {+ "success": False,+ "errorCode": "fusion_autostart_failed",+ "error": "Fusion was closed so the bridge tried to open it for you, "+ "and the launch did not come up: %s"+ % str(_auto.get("error") or "no error reported")[:300],+ "autoStartAttempted": True, "autoStartSec": _took,+ "_hint": "This is a launch failure, NOT a bad request - the command "+ "itself was never run. Check fusion_readiness (needsSignin "+ "true means Autodesk sign-in is blocking the launch, which "+ "is a human step). Pass noAutoStart:true to get the old "+ "refuse-immediately behaviour.",+ }+ if not 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):+ # Hung HOST vs down ADD-IN (page issue #38) - opposite remedies, so say which.+ # KILL-GRADE evidence only (_confirmed_hung): a busy main thread starves these+ # very probes and stops pumping messages, so one failed snapshot looks exactly+ # like a hang. Three healthy Fusions were taskkilled mid-work by this gate+ # before the persistence rule (2026-08-21).+ _h = _confirmed_hung()+ if _h.get("hung"):+ _ok = False+ _bridge_action_ledger("hang_gate_kill",+ {"heldSeconds": _h.get("heldSeconds"),+ "signals": _h.get("signals")})+ try:+ dispatch_command("kill", {})+ _time.sleep(6)+ dispatch_command("start", {})+ _ok = True+ except Exception:+ pass+ return {+ "success": False,+ "error": "Fusion 360 itself is NOT RESPONDING (hung host process, held "+ "%ss with the add-in refusing a 20s probe); the add-in cannot "+ "answer while it is." % _h.get("heldSeconds"),+ "errorCode": "fusion_host_hung",+ "hungSignals": _h.get("signals"),+ "restartAttempted": _ok,+ "_hint": ("Do NOT hunt for a blocking dialog - there is not one. Nothing "+ "reaches Fusion over IPC while the host is hung. "+ + ("The bridge force-restarted it; poll fusion_readiness."+ if _ok else "Use fusion_kill then fusion_start.")),+ }+ if _h.get("phase") in ("suspect", "busy-not-hung"):+ return {+ "success": False,+ "error": "Fusion's main thread is not answering right now - most likely "+ "WORKING on something heavy (a STEP import or save starves the "+ "add-in's probes), not hung.",+ "errorCode": "fusion_addin_busy",+ "hostResponding": _h.get("phase") == "busy-not-hung",+ "hungHeldSeconds": _h.get("heldSeconds"),+ "_hint": ("WAIT and re-poll; do not restart anything. The bridge only "+ "declares a real hang after 180s of continuous silence "+ "including a 20s probe - if that happens it restarts Fusion "+ "itself and says so."),+ }+ return {+ "success": False,+ "error": "Fusion 360 is running but AdomBridge add-in not responding.",+ "errorCode": "fusion_addin_not_responding",+ "hostResponding": True,+ "_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:+ # Test-project layout (make_test_project): the "views" are two DOCUMENTS, not+ # one design's internal views. Activate the right document first, then the+ # normal flow proceeds (for 2D the board editor already IS the 2D view).+ if _TEST_PROJECT and command in ("show_schematic", "show_2d_board", "show_3d_board"):+ try:+ st = _proxy_to_addin("get_app_state", {}, timeout=10)+ active = str((st.get("data") or {}).get("activeDocument", "")).lower()+ except Exception:+ active = ""+ if _TEST_PROJECT:+ kind = "sch" if command == "show_schematic" else "brd"+ _test_project_activate(kind)+ if command in ("show_schematic", "show_2d_board"):+ shot = _handle_screenshot_fusion({}, {})+ return {"success": True, "view": command.replace("show_", ""),+ "document": _TEST_PROJECT["docBase"],+ "screenshot": shot.get("savedTo"),+ "_hint": "Test-project layout: the %s DOCUMENT is now active (its editor IS this view)." % kind}+ # 3D board: with the board doc active, fall through to the normal+ # show_3d_board machinery which switches the board editor to 3D.+ return _handle_open_with_screenshot(fusion_info, args, command)+ if command == "open_lbr":+ # Same guard as every other document-opening verb: a malformed .lbr makes+ # Fusion open an EMPTY design behind an owned "Error" popup while the API+ # call returns ok. Unwrapped, that reported success under a visible error+ # dialog (adom/fusion-bridge#65, caught live on a Cloud Library import).+ return _merge_dialog_array(_orchestrate_open_lbr(args))+ if command == "show_symbol":+ return _merge_dialog_array(_orchestrate_show_symbol(args))+ if command == "show_footprint":+ return _merge_dialog_array(_orchestrate_show_footprint(args))+ if command == "show_3d_package":+ return _merge_dialog_array(_orchestrate_show_3d_package(args))+ if command == "make_test_project":+ return _merge_dialog_array(_orchestrate_make_test_project(args))+ if command == "bind_library_3d":+ return _merge_dialog_array(_orchestrate_bind_library_3d(args))+ if command == "show_library":+ return _merge_dialog_array(_orchestrate_show_library(args))+ if command == "show_project":+ return _merge_dialog_array(_orchestrate_show_project(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":+ if (args or {}).get("async"):+ return _start_glb_export_async(args)+ return _orchestrate_export_optimized_glb(args)+ if command == "fetch_optimized_glb":+ if (args or {}).get("exportId"):+ return _glb_export_async_status(args)+ return _orchestrate_fetch_optimized_glb(args)+ if command == "demo":+ if (args or {}).get("mode") == "tour":+ return _orchestrate_demo_tour(args)+ 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:+ # ⛔ RAW close_document GATE (John caught the modal standing twice, 2026-08-21):+ # web control calls this verb directly, bypassing _close_bridge_doc_verified's+ # upload gate - and a close over an in-flight Hub upload raises the lose-changes+ # confirm. Same rule at this door: wait for app.hasActiveJobs (cap 45s inside+ # the relay window), then refuse honestly rather than raise a data-loss modal.+ if command == "close_document":+ import time as _tcg+ _cg_deadline = _tcg.time() + 45+ while _tcg.time() < _cg_deadline:+ try:+ _jr = _proxy_to_addin("run_modeling_script",+ {"script": "import adsk.core\n"+ "result={'jobs': adsk.core.Application.get().hasActiveJobs}"},+ timeout=10)+ if not ((_jr.get("data") or {}).get("result") or {}).get("jobs"):+ break+ except Exception:+ break+ _tcg.sleep(5)+ else:+ return {"success": False, "errorCode": "upload_in_flight_close_deferred",+ "error": "Fusion is still uploading packages to the Hub - closing now "+ "would raise a lose-changes confirm and risk destroying the "+ "upload. The close was NOT issued.",+ "_hint": "Re-call fusion_close_document in ~30s; the bridge closes the "+ "moment app.hasActiveJobs drains. Nothing was lost and no "+ "dialog was raised."}+ 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 _annotate_background(_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 _annotate_background(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-bridge 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", {})++ # Who is asking (issue #348). AD stamps these on every request it dispatches to a bridge.+ caller_identity = {+ "thread": self.headers.get("X-Adom-Caller-Thread", ""),+ "container": self.headers.get("X-Adom-Caller-Container", ""),+ "reason": self.headers.get("X-Adom-Caller-Reason", ""),+ }++ print(f"[Fusion Bridge] Command: {command} | Args: {json.dumps(args)}")++ try:+ result = dispatch_command(command, args, caller_identity=caller_identity)+ 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}",+ })+ finally:+ # Unbind before this thread is reused or retired. Without this, a later call made on+ # our OWN behalf could inherit this AI thread's name and misattribute it.+ _set_caller_identity(None)++ def _respond(self, status: int, data: dict):+ self.send_response(status)+ 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()+++# ── Geometry read-back (issue #289 item 2, Oliver) ───────────────────────────────────────────+# There was no way to ask what actually got BUILT, so every caller hand-wrote body enumeration+# inside a modeling script and parsed it back out. That read-back caught both real modelling bugs+# in Oliver's session and NEITHER was visible in a screenshot (a body at Z -167.6 instead of -83.8+# from a double-applied offset; a handle buried inside the plate it should hang below). This work+# is often matte-black parts on a dark canvas, so pixels are a weak verification channel and an AI+# driving the bridge has no other way to check its own output.+#+# Implemented BRIDGE-side on the existing run_modeling_script surface (the add-in already captures+# a script's `result`), so it needs no add-in redeploy and no Fusion restart.+#+# Units are MILLIMETRES, deliberately. Fusion's internals are centimetres, which is a standing+# source of off-by-ten errors when an AI reads these numbers back.+_INSPECT_BODIES_SCRIPT = r'''+import adsk.core, adsk.fusion++_des = adsk.fusion.Design.cast(app.activeProduct)+_WORLD = __WORLD__+_OCC = __OCC__++def _mm(v): return round(v * 10.0, 4) # cm -> mm+def _mm3(v): return round(v * 1000.0, 4) # cm3 -> mm3+def _mm2(v): return round(v * 100.0, 4) # cm2 -> mm2++def _describe(body, owner):+ bb = body.boundingBox+ mn, mx = bb.minPoint, bb.maxPoint+ cyl = 0+ try:+ for f in body.faces:+ g = f.geometry+ if g and g.surfaceType == adsk.core.SurfaceTypes.CylinderSurfaceType:+ cyl += 1+ except Exception:+ cyl = -1+ def _nm(o):+ try: return o.name+ except Exception: return None+ return {+ "name": body.name,+ "owner": owner,+ "bbox": {"x": [_mm(mn.x), _mm(mx.x)],+ "y": [_mm(mn.y), _mm(mx.y)],+ "z": [_mm(mn.z), _mm(mx.z)]},+ "sizeMm": [_mm(mx.x - mn.x), _mm(mx.y - mn.y), _mm(mx.z - mn.z)],+ "volumeMm3": _mm3(body.volume),+ "areaMm2": _mm2(body.area),+ "faceCount": body.faces.count,+ "cylindricalFaceCount": cyl,+ "appearance": _nm(body.appearance),+ "material": _nm(body.material),+ "isVisible": body.isVisible,+ }++rows = []+root = _des.rootComponent+for b in root.bRepBodies:+ rows.append(_describe(b, root.name))++if _WORLD:+ # Bodies fetched THROUGH an occurrence are proxies whose boundingBox is already resolved+ # into assembly space, so occurrence placement is verified without any matrix maths here.+ for occ in root.allOccurrences:+ if _OCC and _OCC.lower() not in occ.name.lower():+ continue+ try:+ for b in occ.bRepBodies:+ rows.append(_describe(b, occ.name))+ except Exception:+ pass++result = {"units": "mm", "worldSpace": bool(_WORLD),+ "documentName": app.activeDocument.name if app.activeDocument else None,+ "bodyCount": len(rows), "bodies": rows}+'''+++def _handle_inspect_bodies(fusion_info: dict, args: dict) -> dict:+ """fusion_inspect_bodies - what actually got built, in millimetres."""+ world = bool(args.get("worldSpace"))+ occ = args.get("occurrence") or ""+ script = (_INSPECT_BODIES_SCRIPT+ .replace("__WORLD__", "True" if world else "False")+ .replace("__OCC__", repr(str(occ)) if occ else "None"))+ res = _proxy_to_addin("run_modeling_script", {"script": script}, timeout=120)+ if not res.get("success"):+ return res+ data = res.get("data") or {}+ payload = data.get("result")+ if not isinstance(payload, dict):+ return {"success": False, "error": "inspect_bodies returned no structured result",+ "errorCode": "inspect_failed", "data": data,+ "_hint": "The read-back script ran but produced no result dict. Usually means no "+ "active Design; open a document and retry."}+ n = payload.get("bodyCount", 0)+ payload["success"] = True+ payload["_hint"] = (+ "Bounding boxes, volumes and areas are in MILLIMETRES (Fusion's API is centimetres; "+ "converted here so you do not have to). Check bbox Z against where the part SHOULD sit - "+ "a wrong offset renders perfectly and is invisible in a screenshot. cylindricalFaceCount "+ "is the cheap way to confirm holes were actually cut: a symmetric cut that reaches nothing "+ "fails SILENTLY with no exception, and the count is what surfaces it. "+ + ("Pass worldSpace:true to resolve bodies through their occurrence transforms and verify "+ "assembly placement." if not world else+ "worldSpace:true - occurrence bodies are proxies already resolved into assembly space.")+ ) if n else (+ "No bodies found. If you expected some, the active document may not be the one you built "+ "into (pass expectDocument on mutating verbs to catch that), or the design is empty."+ )+ return payload+++COMMAND_HANDLERS["inspect_bodies"] = _handle_inspect_bodies++++# ── Uninstall cascade (ab 2.0.35) ────────────────────────────────────────────────────────────+# ab's bridge_uninstall calls this verb FIRST, while our files still exist, so we can remove the+# artifacts we planted OUTSIDE our own cache dir. That matters more for this bridge than for most:+# we install a Fusion ADD-IN into Autodesk's own directories, and a stale AdomBridge add-in keeps+# loading on every Fusion launch, binding port 8774, and reaching for a bridge that no longer+# exists. We also hold two user-data stores (~/.adom/fusion-bridge prefs, ~/.adom/fusion-aps+# OAuth tokens). Uninstall means all of it goes.+#+# Pass {"dryRun": true} to see exactly what WOULD be removed without touching anything.+def _handle_uninstall(fusion_info: dict, args: dict) -> dict:+ """fusion_uninstall - remove this bridge's out-of-cache artifacts (add-in, prefs, APS tokens)."""+ import shutil as _sh+ import pathlib as _pl+ args = args or {}+ dry = bool(args.get("dryRun"))+ home = _pl.Path(os.path.expanduser("~"))++ targets = []+ try:+ from install_addin import TARGET_CANDIDATES as _TC+ targets += [_pl.Path(str(t)) / "AdomBridge" for t in _TC]+ except Exception:+ targets += [home / "AppData" / "Roaming" / "Autodesk" / "FusionAddins" / "AdomBridge",+ home / "AppData" / "Roaming" / "Autodesk" / "Autodesk Fusion" / "API" / "AddIns" / "AdomBridge",+ home / "AppData" / "Roaming" / "Autodesk" / "Autodesk Fusion 360" / "API" / "AddIns" / "AdomBridge"]+ addin_targets = list(targets)+ data_targets = [home / ".adom" / "fusion-bridge", home / ".adom" / "fusion-aps"]++ removed, failed, skipped = [], [], []+ for p in addin_targets + data_targets:+ kind = "addin" if p in addin_targets else "userdata"+ if not p.exists():+ skipped.append({"path": str(p), "kind": kind, "why": "not present"})+ continue+ if dry:+ removed.append({"path": str(p), "kind": kind, "dryRun": True})+ continue+ try:+ _sh.rmtree(p)+ removed.append({"path": str(p), "kind": kind})+ except Exception as e:+ failed.append({"path": str(p), "kind": kind, "error": str(e)})++ running = False+ try:+ running = bool(_is_fusion_running())+ except Exception:+ pass++ out = {"success": not failed, "dryRun": dry, "cascade": bool(args.get("cascade")),+ "fusionRunning": running, "removed": removed, "failed": failed, "skipped": skipped}+ if failed and running:+ out["_hint"] = ("Fusion is RUNNING and holds the add-in files open, so %d path(s) could not be "+ "removed. Close Fusion (fusion_stop) and re-run fusion_uninstall, or delete "+ "those paths by hand - otherwise the stale add-in keeps loading inside Fusion "+ "on every launch and binding its port." % len(failed))+ elif failed:+ out["_hint"] = ("%d path(s) could not be removed (permissions?). They are listed in `failed`; "+ "delete them by hand so no stale add-in is left inside Fusion." % len(failed))+ elif dry:+ out["_hint"] = ("DRY RUN - nothing was touched. These are the paths a real uninstall removes: "+ "the Fusion add-in in Autodesk's AddIns dirs, plus this bridge's prefs and "+ "APS OAuth tokens under ~/.adom.")+ else:+ out["_hint"] = ("Cleaned up: the Fusion add-in is gone from Autodesk's directories, and this "+ "bridge's prefs + APS OAuth tokens are removed. Fusion itself is untouched. "+ "Re-installing the bridge redeploys the add-in on its next start.")+ return out+++COMMAND_HANDLERS["uninstall"] = _handle_uninstall+COMMAND_HANDLERS["demo_progress"] = lambda _info, args: _handle_demo_progress(args or {})+++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+@@ -1,596 +1,616 @@⋯ 2 unchanged lines ⋯ All notable changes to the Adom Bridge Fusion 360 bridge, newest first. Canonical: https://wiki.adom.inc/adom/fusion-bridge · GitHub backup: `adom-inc/fusion-bridge` +## Unreleased (PR: import honesty + pollable 3D build, adom/fusion-bridge#65)++- **fusion_open_lbr can no longer report success under an error dialog.** A malformed .lbr makes+ Fusion open an EMPTY design behind an owned "Error" popup while the API call returns ok; every+ other document-opening verb already rode the dialog guard, this one returned raw. Caught live on+ a Cloud Library import: the caller was told active-document ok while the user looked at+ "RayFive.lbr has errors and cannot be opened".+- **The create-library chain fails HARD when the library never presented.** stage done with+ verdict not_proven and ZERO evidence frames used to be the chain's whole answer while the error+ dialog stood; zero frames now classifies any blocking dialog, carries its text, and lands+ stage failed with errorCode library_never_presented.+- **The detached fusion_build_library_3d run is pollable and keeps its report.** The background+ build swallowed its per-part result, leaving callers to grep the bound .lbr for urns. It now+ returns a runId and writes per-part progress ("minting TPS73733DCQR (3/7)") plus the final+ per-part report into the same per-run store as the create chain, so fusion_prove_result {runId}+ answers for builds too. This is the last piece the wiki Cloud Libraries fusion lane needs to+ attach 3D bodies through the bridge with no AI in the loop (the bundle already ships one STEP+ per part).+ ## 1.9.76 - 2026-08-18 (ralph, dashboard) - **"Fusion is opening right now" LED (John's ask).** A launch takes 30-90s and the board used to⋯ 585 unchanged lines ⋯ Entries for **1.6.37 and earlier** live in [`CHANGELOG_ARCHIVE.md`](https://wiki.adom.inc/adom/fusion-bridge) (full history, kept on the wiki page's Files tab, not shipped in the install tarball). The per-release changelog for every version is also on the page's **Releases** tab.+
Comments
Log in to comment.