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
v1.9.47: live per-step tour evidence (stepShot + sinceShot), respawn-proof done frames, license_sessions_exceeded
6 files changed
+201−10
BRIDGE_VERSION+1−1@@ -1 +1 @@-1.9.46+1.9.47
CHANGELOG.md+18@@ -3,6 +3,24 @@ 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` +## 1.9.47 - 2026-08-16++- **Live per-step tour evidence (ab's #30 contract, John's call).** fusion_demo_progress frames+ now carry `stepShot {step, title, image}` - a ~640px base64 capture the bridge took at each+ step's completion, one per step, riding the first frame after it completes. shots[] also+ streams mid-run (paths always; `imagesAs:base64` + `sinceShot:<n>` windows to only the new+ ones, so 1s polls stay cheap). The done:true gallery is unchanged.+- **Finished tours survive bridge respawns.** ab's poller lost the final frame because+ bridge_install replaced the process between polls (tours were never reaped - the process was).+ Done tours snapshot to ~/.adom/fusion-bridge/tours/ (last 8 kept) and fusion_demo_progress+ falls back to the snapshot for unknown in-memory ids.+- **license_sessions_exceeded is now a first-class classification.** The seat modal often has a+ bare Win32 title, so the substring classifier filed it generic; fusion_state and+ fusion_window_info now also run the ownership+shape detector and append the decidable category+ with the auto-takeover status. The tour cold-start self-heals it (takes the seat, reports it as+ its own progress state) and FAILS FAST with the classification when takeover cannot land or is+ opted out - no more 150s wedge at 0%.+ ## 1.9.46 - 2026-08-16 - **fusion_set_seat_takeover: the seat-takeover sweep is now a STICKY setting** (default ON,
adom-bridge-fusion-manifest.json+4−4@@ -1,10 +1,10 @@ { "manifest_version": 1, "name": "fusion360",- "version": "1.9.46",- "url": "https://wiki.adom.inc/download/adom/fusion-bridge/1.9.46/adom-bridge-fusion-v1.9.46.zip",- "sha256": "3f37b7c7b4ed81065f6a44fbf40131c5d736639f334d502ce6ffa2aa8c48d5af",- "size": 392587,+ "version": "1.9.47",+ "url": "https://wiki.adom.inc/download/adom/fusion-bridge/1.9.47/adom-bridge-fusion-v1.9.47.zip",+ "sha256": "dd86103860fcbb45e3345f8fd9171bcf96184a6a49bf1a57685f85f9ffef94de",+ "size": 395774, "verbPrefixes": [ "fusion_" ],
bridge.json+1−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "fusion360", "displayName": "Autodesk Fusion 360",- "version": "1.9.46",+ "version": "1.9.47", "description": "Drive Autodesk Fusion 360 from the cloud: launch Fusion, electronics board layout, design rules, exports (STEP/IGES/STL/3MF/USDZ/OBJ/DXF/DWG/Gerbers/BOM/CPL), fast APS server-indexed cloud search plus browse/recent/file-info/versions, cloud file download/upload and folder creation, and in-app parametric modeling (fusion_run_modeling_script). Never-charge: APS calls are capped to the free tier.", "homepage": "https://wiki.adom.inc/adom/fusion-bridge", "author": "Adom Inc.",
describe.py+1−1@@ -200,7 +200,7 @@ _T = [ ("fusion_show_symbol", "Open a schematic SYMBOL in Fusion's library editor - first-class, no hand-built EDIT strings. Pass name + libraryPath (.lbr, opened first; omit libraryPath if a library is already open), OR pass a BARE <symbol> XML fragment as symbolXml and the bridge generates a minimal wrapper library around it (deviceset + gate synthesized) so it shows in the Library Manager. Background-safe: never steals the foreground.", {"name": "str (deviceset/symbol name; optional if symbolXml carries name=)", "libraryPath": "optional str (WINDOWS path .lbr)", "symbolXml": "optional str (bare <symbol ...>...</symbol>)", "foreground": "optional bool - user's choice to WATCH; default background"}, {"success": "bool", "view": "str", "screenshot": "str path", "wrappedLibrary": "str (when wrapped)"}, 180, None, True, {"name": "ADS8588SIPM", "libraryPath": "C:/Temp/lib.lbr"}), ("fusion_show_footprint", "Open a FOOTPRINT (package) in Fusion's library editor. Same contract as fusion_show_symbol: name + libraryPath, or a bare <package> fragment as packageXml (wrapper library generated).", {"name": "str", "libraryPath": "optional str", "packageXml": "optional str (bare <package ...>...</package>)", "foreground": "optional bool - user's choice to WATCH; default background"}, {"success": "bool", "view": "str", "screenshot": "str path", "wrappedLibrary": "str (when wrapped)"}, 180, None, True, {"name": "ADS8588SIPM", "libraryPath": "C:/Temp/lib.lbr"}), ("fusion_show_3d_package", "Open the 3D CHIP view (Package3DEnvironment) for a library package - and the RELIABLE way to get a 3D model into a library: pass stepPath and it is attached AFTER the library opens (embedded <packages3d> in hand-built .lbr files is the Fusion quirk that fails; attach-after-open works). stepPath with no library/packageXml auto-generates a minimal 2-pad carrier footprint. Without stepPath it just enters the 3D view of the named package.", {"name": "str", "libraryPath": "optional str (.lbr)", "packageXml": "optional str (bare <package> fragment)", "stepPath": "optional str (WINDOWS path .step to attach)", "foreground": "optional bool - user's choice to WATCH; default background"}, {"success": "bool", "view": "str", "screenshot": "str path", "wrappedLibrary": "str"}, 300, None, True, {"name": "MYCHIP", "stepPath": "C:/Temp/chip.step"}),- ("fusion_demo_progress", "Poll a running fusion_demo tour: {percent, elapsedSec, estimatedSec, step, stepLabel, done, failed, shots}. Poll about once a second and render a REAL progress bar. When done:true the shots array carries base64 images (pass imagesAs:paths to skip the encoding).", {"demoId": "required str (from fusion_demo mode:tour; omitted = the single running tour)", "imagesAs": "optional str paths|base64|both"}, {"percent": "int", "step": "str", "stepLabel": "str", "done": "bool", "shots": "list"}, 15, None, False, {"demoId": "tour-abc123"}),+ ("fusion_demo_progress", "Poll a running fusion_demo tour: {percent, elapsedSec, estimatedSec, step, stepLabel, done, failed, shots, shotCount, stepShot}. Poll ~1-2.5s and render a REAL progress bar. LIVE EVIDENCE (issue #30): each frame MAY carry stepShot {step, title, image} - a ~640px base64 capture the BRIDGE took at that step's completion, one per step, riding the first frame after it completes (render these during the run; the page should not guess-snap). shots[] streams too: mid-run it carries paths for every completed step; pass imagesAs:base64 + sinceShot:<count you have> to receive only NEW shots fully encoded. done:true = the full gallery (base64 by default). Finished tours are DISK-SNAPSHOTTED, so the final frame stays pollable even across a bridge respawn.", {"demoId": "required str (from fusion_demo mode:tour; omitted = the single running tour)", "imagesAs": "optional str paths|base64|both", "sinceShot": "optional int - shots you already received; response windows to the new ones"}, {"percent": "int", "step": "str", "stepLabel": "str", "done": "bool", "shots": "list", "shotCount": "int", "stepShot": "dict|absent"}, 15, None, False, {"demoId": "tour-abc123", "imagesAs": "base64", "sinceShot": 3}), ("fusion_make_test_project", "Create a NEW electronics test project FROM SCRATCH around a library part - the Web Control demo's PROJECT button, and the answer to 'a customer only has a component page'. Generates a minimal test circuit server-side (the part + a generated 330R series resistor with VCC/GND stubs on a small board) as real EAGLE .sch/.brd, then imports the pair as a new electronics design. NO cloud, NO sign-in, no pre-existing content. After it, fusion_show_schematic / fusion_show_2d_board / fusion_show_3d_board all work on the result. Wiring uses the deviceset's own connects (first two pins), so 2-terminal parts get a real circuit and big ICs get a structurally-valid integration proof.", {"libraryPath": "required str (WINDOWS path .lbr; stage from container first)", "deviceset": "optional str (defaults to the library's first deviceset)"}, {"success": "bool", "schPath": "str", "brdPath": "str", "circuit": "str"}, 300, None, True, {"libraryPath": "C:/Temp/IN-S42ATR.lbr"}), ("fusion_screenshot_fusion", "Background PrintWindow capture of Fusion's MAIN window (largest same-title window; works occluded/bottom-of-z-order, never raises anything) or a specific dialog by hwnd. Returns savedTo (PNG/WebP on the box; pull with pull_file). (Live since early versions but undescribed until 2026-08-15 - it was invisible to callers AND to the drift check.)", {"hwnd": "optional int (a dialog; default the main window)"}, {"success": "bool", "savedTo": "str", "sizeKB": "int"}, 60, None, False, {}),
server.py+176−3@@ -1107,6 +1107,7 @@ def _handle_window_info(fusion_info: dict, args: dict) -> dict: result["blockingDialogs"] = classify_blocking_dialogs() or [] except Exception: result["blockingDialogs"] = []+ _license_classify_extend(result["blockingDialogs"]) dialog_info = [f" hwnd={d['hwnd']}: {d['title']}" for d in dialogs] output_parts = [ f"Main: hwnd={result['hwnd']}, title='{result['title']}'",@@ -4345,6 +4346,37 @@ _DOC_SWITCHING_ADDIN = { } +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). @@ -4372,6 +4404,7 @@ def _handle_fusion_state(fusion_info: dict, args: dict) -> dict: 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:@@ -6149,6 +6182,64 @@ _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 _shot_b64_small(path: str, max_w: int = 640):+ """Base64 evidence frame for ab's stepShot contract (~640px wide). PIL downscale when+ available; else the raw file if it is small enough for the relay envelope."""+ try:+ try:+ from PIL import Image+ import io+ import base64+ 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:+ import base64+ 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@@ -6247,6 +6338,7 @@ def _orchestrate_demo_tour(args: dict) -> dict: if key == "launch": dispatch_command("start", {}) ready = False+ seat_attempts = 0 for _ in range(30): time.sleep(5) try:@@ -6255,6 +6347,37 @@ def _orchestrate_demo_tour(args: dict) -> dict: 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:+ 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@@ -6379,6 +6502,8 @@ def _orchestrate_demo_tour(args: dict) -> dict: 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:+ _demo_tour_persist(state) threading.Thread(target=_run, daemon=True, name="fusion-demo-tour").start() return {"success": True, "demoId": demo_id, "estimatedSec": est,@@ -6398,20 +6523,68 @@ def _handle_demo_progress(args: dict) -> dict: # 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."}+ "_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:+ _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") and state.get("done"):+ if imgs in ("base64", "both"): import base64- for s in out.get("shots", []):+ encoded = []+ for s in shots:+ s = dict(s) try: with open(s["path"], "rb") as f: s["image"] = base64.b64encode(f.read()).decode() 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