← Commit history
BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.9.283\ No newline at end of file+1.9.284\ No newline at end of file
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" }\ No newline at end of file
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]"-  }-}\ No newline at end of file+  ]+}
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()