app
Fusion - the Fusion 360 Bridge
Public Made by Adomby adom
Drive Autodesk Fusion 360 from the cloud via Adom Bridge: component libraries, IPC package generation, board layout, exports (STEP/Gerbers/BOM/CPL), fast APS cloud search, and parametric modeling.
← Commit history
Update 102 file(s)
12 files changed
+314−73
BRIDGE_VERSION+1−1addin/AdomBridge/AdomBridge.manifest+1−1addin/AdomBridge/commands/activity_palette.py+31−13addin/AdomBridge/commands/close_document.py+36adom-bridge-fusion-manifest.json+4−4bridge.json+1−1dashboard/Cargo.lock+1−1dashboard/Cargo.toml+1−1install.sh+1−1package.json+1−1page.json+11−8server.py+225−41BRIDGE_VERSION+1−1@@ -1 +1 @@-1.9.283+1.9.284
addin/AdomBridge/AdomBridge.manifest+1−1@@ -6,7 +6,7 @@ "description": { "": "Adom Desktop Conduit bridge add-in. Exposes a localhost HTTP API for external automation of Fusion 360." },- "version": "1.0.8",+ "version": "1.0.11", "runOnStartup": true, "supportedOS": "windows" }
addin/AdomBridge/commands/activity_palette.py+31−13@@ -31,6 +31,7 @@ PALETTE_ID = "AdomActivityPalette" # recognisably ours, not a teal dot. Embedded so it renders with no network and no file. ADOM_LOGO_SVG = '<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1072.9 236"><defs><style>.cls-1{fill:#00b6af;stroke-width:0px;}</style></defs><path class="cls-1" d="M212.94,23.05c-25.84-25.84-62.87-30.69-82.73-10.84-8.06,8.06-12.04,18.94-12.21,30.76h0s0,.02,0,.02c0,.04,0,.08,0,.12v73.29c0,.89.72,1.61,1.61,1.61h72.5l-.02-.02c12.17.03,23.42-3.94,31.69-12.2,19.85-19.85,15-56.89-10.84-82.73Z"/><path class="cls-1" d="M23.05,212.95c25.84,25.84,62.87,30.69,82.73,10.84,8.06-8.06,12.04-18.94,12.21-30.76h0s0-.02,0-.02c0-.04,0-.08,0-.12v-73.29c0-.89-.72-1.61-1.61-1.61H43.89l.02.02c-12.17-.03-23.42,3.94-31.69,12.2-19.85,19.85-15,56.89,10.84,82.73Z"/><path class="cls-1" d="M1065.5,25.22h-58.49c-2.98,0-5.68,1.87-6.73,4.65l-44.4,117.54c-.06.17-.31.17-.37,0l-45.44-117.59c-1.07-2.75-3.76-4.6-6.72-4.6h-58.79c-4.09,0-7.4,3.31-7.4,7.4v35.06l-.03-.12c-1.24,4.99-5.89,6-5.89,6,0,0-4.51,1.02-7.61-2.53v.03c-17.82-29.81-50.31-48.93-89.04-48.93-41.5,0-75.86,21.85-92.76,55.23v-.07c-2.86,5.19-8.74,5.13-8.74,5.13,0,0-5.63-.01-8.41-4.61-16.49-32.68-50.56-52.59-94.79-52.59h-73.46c-4.09,0-7.4,3.31-7.4,7.4v121.27s0,.03,0,.04c-.95,5.15-4.75,5.94-4.75,5.94,0,0-3.88.85-6.77-3.32l.05.23-46.05-126.83c-1.03-2.84-3.75-4.74-6.77-4.74h-57.14c-3.01,0-5.73,1.9-6.76,4.73l-68.67,188.38c-1.05,2.88-.18,6.21,2.4,8.19,1.31,1.01,2.98,1.47,4.64,1.47h34.96s6.43,0,6.43,0c2.5,0,4.83-1.39,5.9-3.65,8.49-17.86,26.5-29.64,48.37-29.64s39.71,11.73,48.19,29.53c1.1,2.32,3.49,3.76,6.06,3.76h120.78c31.22,0,57.85-9.95,77.02-28.77,7.38-7.25,13.43-15.72,18.05-25.1v.09c2.74-5.19,8.37-5.13,8.37-5.13,0,0,5.43,0,8.08,4.64,4.88,9.88,11.26,18.78,18.91,26.42,18.6,18.6,44.61,29.78,74.26,29.78,38.74,0,71.23-19.18,89.05-49.07l-.02.07c3.1-3.55,7.61-2.53,7.61-2.53,0,0,4.65,1,5.89,6l.03-.12v36.32c0,4.09,3.31,7.4,7.4,7.4h45.79c4.09,0,7.4-3.31,7.4-7.4v-86.26c0-.23.32-.28.39-.06l30.37,88.86c1,2.91,3.74,4.87,6.81,4.87h39.69c3.08,0,5.82-1.96,6.81-4.87l30.1-88.02c.07-.21.39-.16.39.06v85.43c0,4.09,3.31,7.4,7.4,7.4h45.79c4.09,0,7.4-3.31,7.4-7.4V32.62c0-4.09-3.31-7.4-7.4-7.4ZM372.31,147.57c-.15.21-.5.57-1.1.57h-30.33c-.61,0-.95-.35-1.1-.56-.15-.21-.38-.65-.19-1.23l15.06-45.79c.27-.81.92-.94,1.29-.94h0c.37,0,1.02.12,1.29.93l15.26,45.8c.19.58-.04,1.01-.19,1.23ZM525.72,173.84h-14.52c-.86,0-1.56-.7-1.56-1.56v-92.16c0-.86.7-1.56,1.56-1.56h14.52c30.81,0,48.48,17.47,48.48,47.92s-17.67,47.36-48.48,47.36ZM734.6,173.26c-26.51,0-42.33-17.71-42.34-47.36,0-14.59,3.88-26.68,11.21-34.98,7.33-8.29,18.09-12.67,31.13-12.67,12.11,0,22.28,3.84,29.58,11.13.5.5.98,1.01,1.45,1.54,7.4,8.33,11.31,20.42,11.31,34.97,0,29.21-16.22,47.36-42.34,47.36Z"/></svg>' _palette = None+_last_reload_ts = 0.0 def _html(title, detail, percent, eta_sec, tone):@@ -69,7 +70,7 @@ def _html(title, detail, percent, eta_sec, tone): } -def _shell_html():+def _shell_html(initial_payload=None): """The banner page, written ONCE. Every later update arrives through palette.sendInfoToHTML("update", json) and is applied by the page's own script. @@ -120,7 +121,8 @@ def _shell_html(): try{ if(action==='update'){ apply(JSON.parse(data||'{}')); } }catch(e){ return 'ERR '+e; } return 'OK'; }};- </script></body></html>""" % {"logo": ADOM_LOGO_SVG}+ try{ apply(%(initial)s); }catch(e){}+ </script></body></html>""" % {"logo": ADOM_LOGO_SVG, "initial": (initial_payload or "{}").replace("</", "<\\/")} def _payload(title, detail, percent, eta_sec, tone):@@ -178,7 +180,9 @@ def handle_activity_banner(app: adsk.core.Application, args: dict) -> dict: _palette = ui.palettes.itemById(PALETTE_ID) created = False if _palette is None:- path = _write_html(_shell_html())+ # The first paint carries the real state (John, 2026-08-30: a palette reading+ # "Working / 0%" for its first seconds is worse than no palette).+ path = _write_html(_shell_html(payload)) created = True _palette = ui.palettes.add( PALETTE_ID, "Adom", path,@@ -200,26 +204,40 @@ def handle_activity_banner(app: adsk.core.Application, args: dict) -> dict: # the page may not have run its script yet; retry briefly, and only if the message # never lands fall back to one reload of the shell (never per update). delivered = False- for _try in range(6 if created else 2):+ last_err = ""+ import time as _t+ for _try in range(12 if created else 3): try: r = _palette.sendInfoToHTML("update", payload) if str(r or "").startswith("OK"): delivered = True break- except Exception:- pass- import time as _t+ last_err = "handler returned %r" % (str(r)[:60],)+ except Exception as _e:+ last_err = str(_e)[:100] _t.sleep(0.25)+ # FAIL-SAFE (John, 2026-08-30, "still seeing this awful palette window that gives me+ # zero information"): if the in-place message does not land, re-render the page WITH+ # the state baked in, but never more than once per 5 s (each re-render costs Fusion's+ # embedded browser a few threads; per-update re-rendering was the 21,900-thread leak).+ # Wrong-but-cheap is not an option; stale is not an option either.+ global _last_reload_ts if not delivered and not created:- try:- _palette.htmlFileURL = _write_html(_shell_html())- _palette.sendInfoToHTML("update", payload)- except Exception:- pass+ now = _t.time()+ if now - _last_reload_ts >= 5.0:+ try:+ _palette.htmlFileURL = _write_html(_shell_html(payload))+ _last_reload_ts = now+ delivered = True+ last_err = "re-rendered: " + last_err+ except Exception as _e:+ last_err = "re-render failed: %s" % str(_e)[:80] # Report what FUSION says, not what we intended. q = ui.palettes.itemById(PALETTE_ID) return {"success": q is not None, "visible": bool(q.isVisible) if q else False,- "dockingState": int(q.dockingState) if q else None}+ "dockingState": int(q.dockingState) if q else None,+ "delivered": delivered, "created": created,+ "deliveryError": (last_err or None)} except Exception as e: # Advisory only: a banner that cannot be drawn must never fail the import. return {"success": False, "error": "activity banner unavailable: %s" % str(e)[:160],
addin/AdomBridge/commands/close_document.py+36@@ -3,6 +3,39 @@ import adsk.core ++def _stop_running_command(app, doc_name: str) -> dict:+ """PRE-EMPT Fusion's "Can't close window while command is running! Running command:+ Edit. Stop now?" modal (John, 2026-08-30: "u should never allow dialogs like this to+ show up. you need to pre-empt any of these with your logic"). Closing a document while+ an editor command (EDIT, ROUTE, MOVE, ...) is active makes Fusion ASK; the answer is+ always "yes, stop it" for a document the bridge is closing, so decide it here, before+ the close, by terminating the active command first. Returns what was stopped."""+ info = {"stopped": None}+ try:+ ui = app.userInterface+ cmd = ""+ try:+ cmd = str(ui.activeCommand or "")+ except Exception:+ cmd = ""+ if cmd and cmd.lower() not in ("selectcommand", "select", "none"):+ try:+ ui.terminateActiveCommand()+ info["stopped"] = cmd+ except Exception as e:+ info["stopError"] = str(e)[:120]+ try:+ # Belt and braces for the electronics editors: an ESC-equivalent that ends+ # a half-entered command without drawing anything.+ app.executeTextCommand("Commands.Abort")+ except Exception:+ pass+ except Exception as e:+ info["stopError"] = str(e)[:120]+ return info++ def handle_close_document(app: adsk.core.Application, args: dict) -> dict: """Close a document in Fusion 360 without triggering the save dialog. @@ -53,6 +86,7 @@ def handle_close_document(app: adsk.core.Application, args: dict) -> dict: pass # Close the document. Passing False skips the save dialog entirely.+ _stopped = _stop_running_command(app, doc_name) doc.close(save_changes) remaining = app.documents.count@@ -68,6 +102,7 @@ def handle_close_document(app: adsk.core.Application, args: dict) -> dict: "output": f"Closed '{doc_name}' (save={save_changes}, had_unsaved={'yes' if not is_saved else 'no'}). {remaining} document(s) remaining.", "data": { "closedDocument": doc_name,+ "commandStopped": _stopped.get("stopped"), "savedBeforeClose": save_changes, "hadUnsavedChanges": not is_saved, "remainingDocuments": remaining,@@ -181,6 +216,7 @@ def handle_close_all_documents(app: adsk.core.Application, args: dict) -> dict: if is_mod and not save_changes: would_prompt.append(doc_name) continue+ _stop_running_command(app, doc_name) doc.close(save_changes) closed.append(doc_name) except Exception as e:
adom-bridge-fusion-manifest.json+4−4@@ -1,10 +1,10 @@ { "manifest_version": 1, "name": "fusion360",- "version": "1.9.283",- "url": "https://wiki.adom.inc/download/adom/fusion-bridge/1.9.283/adom-bridge-fusion-v1.9.283.zip",- "sha256": "f87344fbabb4f6d1427c496f4403852d1919e114e9fb63967815ef81a67db3f6",- "size": 573585,+ "version": "1.9.284",+ "url": "https://wiki.adom.inc/download/adom/fusion-bridge/1.9.284/adom-bridge-fusion-v1.9.284.zip",+ "sha256": "beaceda7864e3628e419faf44649fc315d9900c7d9e0560ca6b6cee5df9d9286",+ "size": 577266, "verbPrefixes": [ "fusion_" ],
bridge.json+1−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "fusion360", "displayName": "Autodesk Fusion 360",- "version": "1.9.283",+ "version": "1.9.284", "description": "Drive Autodesk Fusion 360 from the cloud: launch Fusion, electronics board layout, design rules, exports (STEP/IGES/STL/3MF/USDZ/OBJ/DXF/DWG/Gerbers/BOM/CPL), fast APS server-indexed cloud search plus browse/recent/file-info/versions, cloud file download/upload and folder creation, and in-app parametric modeling (fusion_run_modeling_script). Never-charge: APS calls are capped to the free tier.", "homepage": "https://wiki.adom.inc/adom/fusion-bridge", "author": "Adom Inc.",
dashboard/Cargo.lock+1−1@@ -4,7 +4,7 @@ version = 4 [[package]] name = "fusion-dashboard"-version = "1.9.283"+version = "1.9.284" dependencies = [ "serde_json", ]
dashboard/Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.9.283"+version = "1.9.284" edition = "2021" [dependencies]
install.sh+1−1@@ -21,7 +21,7 @@ echo "Installed fusion-bridge skills: $(ls "$PKG_DIR/skills" 2>/dev/null | tr '\ # binary (the three-artifact pattern: bridge zip -> ab, THIS pkg -> container skills + # this download, page media -> wiki). Per the wiki's PKG_VS_RELEASE policy, binaries are # platform-tagged RELEASE assets, never tarball content - the installer fetches them.-DASH_VERSION="1.9.283"+DASH_VERSION="1.9.284" DASH_URL="https://wiki.adom.inc/download/adom/fusion-bridge/${DASH_VERSION}/fusion-dashboard" DEST="" if [ -w /usr/local/bin ]; then DEST=/usr/local/bin
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "fusion-bridge",- "version": "1.9.283",+ "version": "1.9.284", "type": "app", "description": "This package installs the bridge's SKILLS into your container so your AI knows how to drive it; Adom Bridge loads the bridge runtime itself from the release zip.", "dependencies": {
page.json+11−8@@ -1,6 +1,6 @@ { "slug": "fusion-bridge",- "version": "1.9.283",+ "version": "1.9.284", "type": "app", "description": "This package installs the bridge's SKILLS into your container so your AI knows how to drive it; Adom Bridge loads the bridge runtime itself from the release zip.", "dependencies": {@@ -11,6 +11,10 @@ "uninstall": "./uninstall.sh" }, "tags": [+ "ab",+ "bridge",+ "fusion",+ "desktop-app", "fusion360", "autodesk", "cad",@@ -75,7 +79,10 @@ "build a basic parts library", "add a 3d model to a component", "make a fusion 360 library with 3d",- "set up the fusion bridge"+ "set up the fusion bridge",+ "fusion dashboard",+ "fusion status page",+ "open the fusion launch page" ], "discovery_pitch": "Use the Adom Bridge Fusion 360 bridge when the user wants to DRIVE Autodesk Fusion 360 from the cloud: open/search their team's Fusion cloud designs, lay out a PCB or do electronics, export STEP/IGES/STL/Gerbers/BOM, or build a component library with real rendering 3D. It runs Fusion on the user's own machine via Adom Bridge. NOT for web/browser/login/form-filling (that's the native-browser extension) and NOT for KiCad (that's the kicad bridge).", "brief": "Drive Autodesk Fusion 360 from the cloud via Adom Bridge: component libraries, IPC package generation, board layout, exports (STEP/Gerbers/BOM/CPL), fast APS cloud search, and parametric modeling.",@@ -104,9 +111,5 @@ "label": "Part volume", "prompt": "How much material does each printed part in this assembly use?" }- ],- "author": {- "name": "John Lauer",- "email": "[email protected]"- }-}+ ]+}
server.py+225−41@@ -3136,6 +3136,27 @@ _ASSEMBLY_BOM_UNIT_DEFAULTS = ["with fasteners", "with fastener", "with bearings "bearing", "idler pulley", "pulley", "idler"] +_SESSION_SNAPSHOT_DONE = [False]+++def _snapshot_session_docs(st: dict):+ """Record what was open the FIRST time this bridge process heard from the add-in: those+ documents are the user's; everything the bridge opens later is registered as its own."""+ if _SESSION_SNAPSHOT_DONE[0]:+ return+ try:+ docs = (st.get("data") or {}).get("documents")+ if not isinstance(docs, list):+ return+ for d in docs:+ n = str((d or {}).get("name") or "").lower()+ if n and not _is_bridge_doc_text(n):+ _SESSION_DOCS_AT_START.add(n)+ _SESSION_SNAPSHOT_DONE[0] = True+ except Exception:+ pass++ def _proxy_to_addin(command: str, args: dict, timeout: int = 30) -> dict: """Proxy a command to the Fusion add-in HTTP server. @@ -3168,6 +3189,8 @@ def _proxy_to_addin(command: str, args: dict, timeout: int = 30) -> dict: with urllib.request.urlopen(req, timeout=timeout) as resp: result = json.loads(resp.read()) + if command == "get_app_state" and isinstance(result, dict) and result.get("success"):+ _snapshot_session_docs(result) # Check if the add-in itself returned a timeout (main thread didn't respond) if (not result.get("success") and "timed out" in result.get("error", "").lower()):@@ -3640,6 +3663,25 @@ def _orchestrate_open_lbr(args: dict) -> dict: +def _fusion_front_doc_title():+ """Lower-cased title of Fusion's main window (the document IN FRONT), or None when it+ cannot be read. Fusion titles the main window '<document> - Autodesk Fusion'."""+ try:+ best = None+ for w in family_windows() or []:+ t = str(w.get("title") or "")+ if "autodesk fusion" not in t.lower():+ continue+ area = int(w.get("width") or 0) * int(w.get("height") or 0)+ if best is None or area > best[0]:+ best = (area, t)+ if not best:+ return None+ return best[1].lower().replace(" - autodesk fusion", "").strip()+ except Exception:+ return None++ def _ensure_library_active(lbr_path: str, timeout_s: int = 60) -> dict: """Make the .lbr the ACTIVE document - verified, not assumed (caught live 2026-08-21). @@ -3653,6 +3695,7 @@ def _ensure_library_active(lbr_path: str, timeout_s: int = 60) -> dict: stem = lbr_path.replace("\\", "/").rsplit("/", 1)[-1].rsplit(".", 1)[0].lower() deadline = _t.time() + timeout_s last_ws = None+ _reopened = {"done": False} while _t.time() < deadline: try: st = _proxy_to_addin("get_app_state", {}, timeout=6)@@ -3669,7 +3712,28 @@ def _ensure_library_active(lbr_path: str, timeout_s: int = 60) -> dict: ws_ok = ("librar" in _wl or "schematic" in _wl or "board" in _wl or "pcb" in _wl) and "electronics design" not in _wl if st.get("success") and stem in active.lower() and ws_ok:- return {"ok": True, "activeDocument": active, "activeWorkspace": ws}+ # THE WINDOW IN FRONT, not the API's activeDocument (2026-08-30): with two+ # libraries open, activeDocument said ch340g-symbols while lan7800's window+ # was in front, and EDIT (which acts on the window in front) raised "Create+ # new device" in the WRONG library. Fusion titles its main window after the+ # document in front, so the title is the truth; when it disagrees, close our+ # own copy and reopen it so it lands in front (it is a bridge document).+ _front = _fusion_front_doc_title()+ if _front is None or stem in _front:+ return {"ok": True, "activeDocument": active, "activeWorkspace": ws,+ "frontWindow": _front}+ if not _reopened.get("done"):+ _reopened["done"] = True+ try:+ _close_bridge_doc_verified(stem)+ except Exception:+ pass+ try:+ _orchestrate_open_lbr({"lbrPath": lbr_path})+ except Exception:+ pass+ _t.sleep(3)+ continue # Re-activate EVERY iteration until the workspace actually switches - a single # activate can lose the race with the 3D step's workspace change (was tried once). if st.get("success"):@@ -3761,6 +3825,28 @@ def _orchestrate_attach_3d_package(args: dict) -> dict: " mat.transformBy(rot); oc.transform2=mat\n" " d.snapshots.add() if d.snapshots.hasPendingSnapshot else None\n" "res['dims_mm']=[round(dx*10,2),round(dy*10,2),round(dz*10,2)]\n"+ # OPTIONAL LAYERS, OFF BY DEFAULT (John, 2026-08-30): a STEP may carry named solids+ # beside the chip (Adom.HeatsinkVias, Adom.SolderBalls, Adom.LaserEtch). Fusion keeps+ # them as bodies with their own eye toggles; STEP itself has no hidden flag, so the+ # bridge switches every 'Adom.*' body except Adom.Chip off right after the import.+ # The user turns a layer on in the Browser when they want it.+ "names=[]; hidden=[]\n"+ "def _walk(comp):\n"+ " for i in range(comp.bRepBodies.count):\n"+ " b=comp.bRepBodies.item(i); names.append(b.name)\n"+ " n=(b.name or '')\n"+ " if n.startswith('Adom.') and n!='Adom.Chip' and n not in LAYERS_ON:\n"+ " try:\n"+ " b.isLightBulbOn=False; hidden.append(n)\n"+ " except Exception: pass\n"+ " for j in range(comp.occurrences.count):\n"+ " _walk(comp.occurrences.item(j).component)\n"+ f"LAYERS_ON=set({sorted(set(str(x) for x in (args.get('layersOn') or [])))!r})\n"+ "try:\n"+ " _walk(oc.component)\n"+ "except Exception as _e:\n"+ " res['layersError']=str(_e)[:120]\n"+ "res['bodies']=names; res['layersHidden']=hidden\n" "result=res\n" ) imp = _proxy_to_addin("run_modeling_script", {"script": import_script}, timeout=120)@@ -3768,6 +3854,8 @@ def _orchestrate_attach_3d_package(args: dict) -> dict: return {"success": False, "error": "import/orient failed: " + str(imp.get("error")), "data": {"steps": steps}} dims = (imp.get("data", {}) or {}).get("result", {}) steps.append(f"import+orient: ok ({dims.get('dims_mm') if isinstance(dims, dict) else dims})")+ if isinstance(dims, dict) and dims.get("bodies") is not None:+ steps.append("bodies: %s; layers hidden: %s" % (dims.get("bodies"), dims.get("layersHidden"))) time.sleep(1) _proxy_to_addin("run_modeling_script", {"script": "import adsk.core\ncd=ui.commandDefinitions.itemById('Package3DStop')\nresult={'finished': bool(cd) and cd.execute()}\n"},@@ -3969,6 +4057,7 @@ def _orchestrate_make_3d_package(args: dict) -> dict: orient = args.get("orient", True) base = _os.path.basename(model_path).rsplit(".", 1)[0] name = (args.get("fileName") or (base + "_3d")).replace("'", "").replace(".f3d", "")+ _note_bridge_doc(name) # the generator/3d document is the bridge's, never the user's f3d_name = name + ".f3d" # 1. open the library so the footprint exists; 2. Create3DPackage -> generator WITH the footprint loaded op = _orchestrate_open_lbr({"filePath": lbr_path})@@ -4050,6 +4139,25 @@ def _orchestrate_make_3d_package(args: dict) -> dict: " if p.dataFolders.item(i).name==nm: return p.dataFolders.item(i)\n" " return p.dataFolders.add(nm)\n" "res={}\n"+ # Optional layer bodies (Adom.HeatsinkVias, Adom.SolderBalls, Adom.LaserEtch) arrive+ # visible; switch them off so the user opts in from Fusion's Browser (John, 2026-08-30).+ "names=[]; hidden=[]\n"+ "def _walk(comp):\n"+ " for i in range(comp.bRepBodies.count):\n"+ " b=comp.bRepBodies.item(i); names.append(b.name)\n"+ " n=(b.name or '')\n"+ " if n.startswith('Adom.') and n!='Adom.Chip' and n not in LAYERS_ON:\n"+ " try:\n"+ " b.isLightBulbOn=False; hidden.append(n)\n"+ " except Exception: pass\n"+ " for j in range(comp.occurrences.count):\n"+ " _walk(comp.occurrences.item(j).component)\n"+ f"LAYERS_ON=set({sorted(set(str(x) for x in (args.get('layersOn') or [])))!r})\n"+ "try:\n"+ " _walk(oc.component)\n"+ "except Exception as _e:\n"+ " res['layersError']=str(_e)[:120]\n"+ "res['bodies']=names; res['layersHidden']=hidden\n" "proj=None\n" "try: proj=app.data.activeProject\n" "except Exception as e: res['projErr']=str(e)[:60]\n"@@ -6145,6 +6253,29 @@ _BRIDGE_DOC_BUILTINS = ("adom-test-", "adomdemosample", "adom-fusion-wrap", "ads8588sipm", "0603waf") +_SESSION_DOCS_AT_START: set = set() # documents open when this bridge process started (the user's)+++def _user_unsaved_docs(unsaved_names) -> list:+ """The unsaved documents that are the USER'S: not registered as bridge-opened, not a+ generator/scratch design that appeared after the bridge started. These are the only+ documents a restart may legitimately ask about."""+ out = []+ for n in (unsaved_names or []):+ raw = str(n or "")+ bare = raw.replace("*", "").split(" (")[0].strip().lower()+ if not bare:+ continue+ if _is_bridge_doc_text(bare):+ continue+ if bare.startswith("untitled") and bare not in _SESSION_DOCS_AT_START:+ continue+ if bare.endswith("_3d") and bare not in _SESSION_DOCS_AT_START:+ continue+ out.append(raw)+ return out++ 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."""@@ -12773,6 +12904,16 @@ def _handle_create_library(fusion_info: dict, args: dict) -> dict: _banner_watchdog_start(run_id) # keep the in-Fusion palette present for the whole run _LONG_OP["since"] = __import__("time").time() _docs_before = _open_doc_names() # chain hygiene: everything opened after this is ours+ # OWNERSHIP (John, 2026-08-30): every document this chain opens is the BRIDGE's, so a+ # later restart never asks the user about it. The library itself, plus the per-part+ # generator/3d documents, are registered here; the snapshot above is what the user had.+ try:+ if lbr_p:+ _note_bridge_doc(lbr_p.replace("\\", "/").rsplit("/", 1)[-1].rsplit(".", 1)[0])+ for _n in (_docs_before or []):+ _SESSION_DOCS_AT_START.add(str(_n).lower())+ except Exception:+ pass 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@@ -12837,10 +12978,24 @@ def _handle_create_library(fusion_info: dict, args: dict) -> dict: _pct = 100.0 * _est_done[0] / _est_total _taskbar_progress(_pct) # ...and the SAME story inside Fusion's own window (2026-08-28)- _banner("update", title=stage_name.split(" (")[0].capitalize(),- detail=("%d part%s" % (state.get("partsTotal") or 1,- "" if (state.get("partsTotal") or 1) == 1 else "s")),- percent=_pct, eta_sec=max(0, _est_total - _est_done[0]))+ # THE PALETTE MUST SAY WHAT IS HAPPENING (John, 2026-08-30: "whenever you're driving+ # fusion, you MUST show better info than this"): which library, which stage, how many+ # parts, and what the stage does. "Working / 0%" is not information.+ _n = int(state.get("partsTotal") or 1)+ _lib = _lib_label(state.get("libraryPath") or lbr_p)+ _stage_key = stage_name.split(" (")[0].lower()+ _what = {+ "starting": "Unpacking the bundle and pairing every deviceset with its STEP",+ "launch": "Starting Fusion and waiting for the Adom add-in",+ "convert": "Converting KiCad sources into an EAGLE library",+ "binding 3d": ("Minting one 3D package per footprint (%d STEP import%s), then binding the "+ "urns into the library" % (_n, "" if _n == 1 else "s")),+ "proving": ("Opening every deviceset and capturing its symbol, footprint, 3D and "+ "component views (%d part%s)" % (_n, "" if _n == 1 else "s")),+ }+ _desc = next((v for k, v in _what.items() if _stage_key.startswith(k)), stage_name)+ _banner("update", title="%s: %s" % (_lib, stage_name.split(" (")[0]),+ detail=_desc, percent=_pct, eta_sec=max(0, _est_total - _est_done[0])) def _leave(stage_key): took = _tc.time() - float(state.get("stageStartedAt") or _tc.time()) _tmc.log_run("stage:" + stage_key, took, ok=True)@@ -12862,42 +13017,68 @@ def _handle_create_library(fusion_info: dict, args: dict) -> dict: # stage fired no toast at all and _handle_launch killed Fusion after a # 30s gate). The two-button notice is the user's decision point; this # chain records the payload for the page, fires the toast, and WAITS.- _enter("Fusion is open but its Adom add-in is not answering - "- "asking you whether to save and restart it")- state["stageReason"] = "addin_not_answering"- _ri = _addin_restart_info(fire_toast=True)- state.update(errorCode="addin_restart_required",- userMessage=_ri.get("userMessage"),- documentsKnown=_ri.get("documentsKnown"),- openDocuments=_ri.get("openDocuments"),- unsavedDocuments=_ri.get("unsavedDocuments"),- remedyVerb="fusion_restart_for_addin",- notificationId=(_ri.get("toast") or {}).get("id"))- _save()- _nid = (_ri.get("toast") or {}).get("id")- if _nid:- _watch_restart_choice(_nid)- _ask_deadline = _tc.time() + 1800- _chosen = None- while _tc.time() < _ask_deadline:- if _addin_round_trip_ready(tries=1):- _chosen = "restarted"; break # restarted (by them or by the remedy)- if _user_restart_hold_active():- _chosen = "hold"; break- _tc.sleep(4)- if _chosen == "hold":- state.update(stage="failed", success=False, errorCode="user_owns_restart",- error="You chose 'I'll handle it' on the restart notice, so "- "this import stopped without touching Fusion. Restart "- "Fusion yourself, then start the import again.")- _save(); return+ # ASK ONLY WHEN THERE IS SOMETHING OF THE USER'S TO LOSE (John, 2026-08-30:+ # "i NEVER opened any docs in fusion, ONLY YOU DID so asking me is just+ # slowing you down"). The bridge registers every document it opens+ # (_BRIDGE_DOCS) and snapshots what was open when it started; an unsaved+ # document that the bridge did not open is the only reason to ask. With+ # none, the bridge restarts Fusion itself, saving what can be saved.+ _ri0 = _addin_restart_info(fire_toast=False)+ _mine = _user_unsaved_docs(_ri0.get("unsavedDocuments") or [])+ if not _mine:+ _enter("Fusion is open but its Adom add-in is not answering - "+ "restarting Fusion (only bridge documents are open)")+ state["stageReason"] = "addin_not_answering"+ state["restartChoice"] = "auto"+ state["restartReason"] = ("no unsaved user documents; open: %s"+ % (_ri0.get("openDocuments") or []))+ _save()+ try:+ _rr = _handle_restart_for_addin(fusion_info, {"save": True, "auto": True})+ state["restartResult"] = {k: _rr.get(k) for k in ("success", "error", "errorCode")}+ except Exception as _e:+ state["restartResult"] = {"success": False, "error": str(_e)[:160]}+ _save()+ _chosen = "restarted"+ else:+ _chosen = None if _chosen != "restarted":- state.update(stage="failed", success=False, errorCode="awaiting_user_restart",- error="Fusion's add-in stayed silent for 30 minutes and nobody "- "answered the restart notice. Nothing was restarted. "- "Save and restart Fusion, then start the import again.")- _save(); return- state.pop("errorCode", None); state["restartChoice"] = "restart"+ _enter("Fusion is open but its Adom add-in is not answering - "+ "asking you whether to save and restart it (unsaved: %s)" % ", ".join(_mine)[:80])+ state["stageReason"] = "addin_not_answering"+ _ri = _addin_restart_info(fire_toast=True)+ state.update(errorCode="addin_restart_required",+ userMessage=_ri.get("userMessage"),+ documentsKnown=_ri.get("documentsKnown"),+ openDocuments=_ri.get("openDocuments"),+ unsavedDocuments=_ri.get("unsavedDocuments"),+ remedyVerb="fusion_restart_for_addin",+ notificationId=(_ri.get("toast") or {}).get("id"))+ _save()+ _nid = (_ri.get("toast") or {}).get("id")+ if _nid:+ _watch_restart_choice(_nid)+ _ask_deadline = _tc.time() + 1800+ _chosen = None+ while _tc.time() < _ask_deadline:+ if _addin_round_trip_ready(tries=1):+ _chosen = "restarted"; break # restarted (by them or by the remedy)+ if _user_restart_hold_active():+ _chosen = "hold"; break+ _tc.sleep(4)+ if _chosen == "hold":+ state.update(stage="failed", success=False, errorCode="user_owns_restart",+ error="You chose 'I'll handle it' on the restart notice, so "+ "this import stopped without touching Fusion. Restart "+ "Fusion yourself, then start the import again.")+ _save(); return+ if _chosen != "restarted":+ state.update(stage="failed", success=False, errorCode="awaiting_user_restart",+ error="Fusion's add-in stayed silent for 30 minutes and nobody "+ "answered the restart notice. Nothing was restarted. "+ "Save and restart Fusion, then start the import again.")+ _save(); return+ state.pop("errorCode", None); state.setdefault("restartChoice", "restart") _save() else: _enter("launching Fusion (add-in loads at start)")@@ -12955,7 +13136,10 @@ def _handle_create_library(fusion_info: dict, args: dict) -> dict: # run failed at 0%. Wait up to two minutes for the add-in to report idle first. for _idle_try in range(60): _st_idle = _check_addin_status(timeout=3.0) or {}- if not _st_idle.get("busy"):+ # Our own palette update is not "a previous command" (run 16c2e13149f4,+ # 2026-08-30: the watchdog beat every 10 s kept this loop waiting on+ # busyCommand=activity_banner for the full two minutes).+ if not _st_idle.get("busy") or _st_idle.get("busyCommand") in ("activity_banner", "get_app_state"): break state["stageLabel"] = "waiting for Fusion to finish a previous command before binding" _save()