← Commit history

v1.9.10: async GLB export + demo_progress id fix

John Lauer ·393a16f00c ·1mo ago ·parent 3d618d6
6 files changed +116−19
BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.9.9\ No newline at end of file+1.9.10\ No newline at end of file
adom-bridge-fusion-manifest.json+4−4
@@ -1,10 +1,10 @@ {     "manifest_version": 1,     "name": "fusion360",-    "version": "1.9.9",-    "url": "https://wiki.adom.inc/download/adom/fusion-bridge/1.9.9/adom-bridge-fusion-v1.9.9.zip",-    "sha256": "3748091b9d011afbfc3bef9d595304d2245c1b0c4babf942e3bd1c3430f19100",-    "size": 357270,+    "version": "1.9.10",+    "url": "https://wiki.adom.inc/download/adom/fusion-bridge/1.9.10/adom-bridge-fusion-v1.9.10.zip",+    "sha256": "b3bebb34d99940a281d317253dec76ce6e4891123e10a490624093f90d0a787e",+    "size": 358515,     "verbPrefixes": [         "fusion_"     ],
bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "fusion360",   "displayName": "Autodesk Fusion 360",-  "version": "1.9.9",+  "version": "1.9.10",   "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+2−2
@@ -206,8 +206,8 @@ _T = [     ("fusion_export_stl", "Export to STL mesh.", {"outputPath": "required str"}, {"format": "str"}, 300, None, True, {"outputPath": "C:/out/part.stl"}),     ("fusion_export_3mf", "Export to 3MF mesh.", {"outputPath": "required str"}, {"format": "str"}, 300, None, True, {"outputPath": "C:/out/part.3mf"}),     ("fusion_export_usdz", "Export to USDZ (AR / Hydrogen 3D).", {"outputPath": "required str"}, {"format": "str"}, 300, None, True, {"outputPath": "C:/out/part.usdz"}),-    ("fusion_export_optimized_glb", "Fusion board -> wiki-grade optimized GLB. Exports STEP (+ optional silkscreen) and submits it to service-step2glb molecule mode (anchor to MP pins + silk bake + gold pins + join/weld/prune + Draco): ~465KB/~31 draw calls vs a raw ~16MB/~25000 that halts the viewer. Bounded-waits `wait`s (default 90); small boards return complete, big boards return {pending, jobId} - finish with fusion_fetch_optimized_glb. Use for a component page's component.parts.model_3d. STACKUP RULE (John 2026-07-15, see pcb-stackup skill): board bbox = the FR4 body, NEVER the whole assembly (components overhang edges + tower above); each overlay goes at ITS layer's z (copper at FR4-top, silk above mask); copper traces are REAL 35um 3D bodies colored like the FR4 - recolor them + translucent mask in the RAW GLB before optimizing to make routing visible; put the measured stackup table on the board's wiki page. PUBLISH EVERYTHING: a board's wiki page repo must carry EVERY exportable design file (.f3d/.step/.usdz/EAGLE .brd+.sch/Fusion .fbrd+.fsch/gerbers/BOM/CPL/GLBs/renders), not just the 3D - the page exists to share the design.", {"outputPath": "required str (.glb)", "silkscreen": "bool default true", "pin": "str medium|large default medium", "wait": "int seconds default 15 (keep under the ~60s relay timeout; re-call fetch until complete)"}, {"glbPath": "str", "jobId": "str", "pending": "bool", "meshesAfter": "int", "sizeBytes": "int", "moleculeAnchored": "bool", "silkscreenApplied": "bool"}, 200, None, True, {"outputPath": "C:/out/board.glb"}),-    ("fusion_fetch_optimized_glb", "Finish a fusion_export_optimized_glb job: bounded-poll the optimizer by jobId and write the GLB to outputPath when complete. Re-call until status=complete (big boards tessellate a few minutes). No Fusion needed.", {"jobId": "required str", "outputPath": "str (.glb)", "wait": "int seconds default 15 (keep under the ~60s relay timeout; re-call fetch until complete)"}, {"glbPath": "str", "status": "str", "pending": "bool", "sizeBytes": "int"}, 150, None, False, {"jobId": "abc123", "outputPath": "C:/out/board.glb"}),+    ("fusion_export_optimized_glb", "Fusion board -> wiki-grade optimized GLB. Exports STEP (+ optional silkscreen) and submits it to service-step2glb molecule mode (anchor to MP pins + silk bake + gold pins + join/weld/prune + Draco): ~465KB/~31 draw calls vs a raw ~16MB/~25000 that halts the viewer. Bounded-waits `wait`s (default 90); small boards return complete, big boards return {pending, jobId} - finish with fusion_fetch_optimized_glb. Use for a component page's component.parts.model_3d. STACKUP RULE (John 2026-07-15, see pcb-stackup skill): board bbox = the FR4 body, NEVER the whole assembly (components overhang edges + tower above); each overlay goes at ITS layer's z (copper at FR4-top, silk above mask); copper traces are REAL 35um 3D bodies colored like the FR4 - recolor them + translucent mask in the RAW GLB before optimizing to make routing visible; put the measured stackup table on the board's wiki page. PUBLISH EVERYTHING: a board's wiki page repo must carry EVERY exportable design file (.f3d/.step/.usdz/EAGLE .brd+.sch/Fusion .fbrd+.fsch/gerbers/BOM/CPL/GLBs/renders), not just the 3D - the page exists to share the design.", {"outputPath": "required str (.glb)", "silkscreen": "bool default true", "pin": "str medium|large default medium", "wait": "int seconds default 15 (keep under the ~60s relay timeout; re-call fetch until complete)", "async": "bool - RECOMMENDED true: fires the whole export+optimize pipeline in the background and returns {exportId} instantly (the sync form can outlive the relay window on a cold Fusion session); poll fusion_fetch_optimized_glb {exportId}"}, {"glbPath": "str", "jobId": "str", "pending": "bool", "meshesAfter": "int", "sizeBytes": "int", "moleculeAnchored": "bool", "silkscreenApplied": "bool"}, 200, None, True, {"outputPath": "C:/out/board.glb"}),+    ("fusion_fetch_optimized_glb", "Finish a fusion_export_optimized_glb job: bounded-poll the optimizer by jobId and write the GLB to outputPath when complete. Re-call until status=complete (big boards tessellate a few minutes). No Fusion needed.", {"jobId": "str (service job form)", "exportId": "str (async form from export_optimized_glb async:true - returns {phase, done, glbPath} instantly per poll)", "outputPath": "str (.glb)", "wait": "int seconds default 15 (keep under the ~60s relay timeout; re-call fetch until complete)"}, {"glbPath": "str", "status": "str", "pending": "bool", "sizeBytes": "int"}, 150, None, False, {"jobId": "abc123", "outputPath": "C:/out/board.glb"}),     ("fusion_board_stackup", "Read a board's PHYSICAL fabrication stackup (see the pcb-stackup skill): copper layer count + copper/dielectric thicknesses from the EAGLE design rules (layerSetup/mtCopper/mtIsolate) plus the measured FR4 extent. A PCB is Cu/prepreg/Cu/core/.../Cu - the FR4 is NOT one slab. Use to build the stackup table + real-thickness exploded 3D view, and put a stackup table on every exported board's wiki page.", {}, {"layerSetup": "str", "copperLayers": "list", "copperCount": "int", "copperThickness_mm": "list", "dielectricBonds": "list (prepreg/core order)", "fr4": "{x_mm,y_mm,dielectric_mm}"}, 200, None, True, {}),     ("fusion_export_obj", "Export to OBJ mesh.", {"outputPath": "required str"}, {"format": "str"}, 300, None, True, {"outputPath": "C:/out/part.obj"}),     ("fusion_export_dxf", "Export 2D DXF.", {"outputPath": "required str"}, {"format": "str"}, 120, None, True, {"outputPath": "C:/out/sketch.dxf"}),
server.py+92−1
@@ -5705,7 +5705,9 @@ def _handle_demo_progress(args: dict) -> dict:     demo_id = args.get("demoId") or ""     with _DEMO_TOURS_LOCK:         state = _DEMO_TOURS.get(demo_id)-        if state is None and len(_DEMO_TOURS) == 1:+        # 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]     if state is None:         return {"success": False, "error": "unknown demoId " + demo_id,@@ -5724,6 +5726,91 @@ def _handle_demo_progress(args: dict) -> dict:     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++ def _orchestrate_demo(args: dict) -> dict:     """FIRST-TIME-USER DEMO: take a brand-new user from nothing to "wow" in one verb. @@ -6306,8 +6393,12 @@ def dispatch_command(command: str, args: dict, caller_identity: dict = None) ->     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":
tests/run_verb_tests.py+16−10
@@ -131,7 +131,8 @@ t("fusion_get_preferences", "daily", {}, {"ok": True, "fields": ["current"]}) t("fusion_set_preference", "daily", {"theme": "darkblue"}, {"ok": True}, "sets the value it already has") t("fusion_mcp_status", "daily", {}, {"ok": True, "fields": ["enabled"]}) t("fusion_mcp_tools", "weekly", {}, {"ok": True}, "only meaningful when MCP enabled")-t("fusion_mcp_call", "weekly", {"tool": "fusion_mcp_read", "arguments": {"queryType": "summary"}}, {"ok": True})+t("fusion_mcp_call", "weekly", {"tool": "fusion_mcp_read", "arguments": {"queryType": "document"}}, {"ok": True},+  "queryType must be a schema enum value (apiDocumentation|screenshot|document|projects|activeCommand) - 'summary' was a guess that failed validation") t("fusion_mcp_resources", "weekly", {}, {"ok": True}) t("fusion_mcp_enable", "manual", note="drives the prefs UI, takes foreground") t("fusion_prefs_open", "manual", note="opens a dialog on the user's screen")@@ -194,9 +195,10 @@ t("fusion_export_iges", "weekly", {"outputPath": f"{SCRATCH}/verbtest.iges"}, {" t("fusion_export_sat", "weekly", {"outputPath": f"{SCRATCH}/verbtest.sat"}, {"ok": True}) for fmt in ("obj", "fbx", "usdz", "dxf", "dwg", "skp"):     t(f"fusion_export_{fmt}", "manual", note="build/doc-type dependent (OBJ refused on the 2026-08-14 box); verify quarterly")-t("fusion_export_optimized_glb", "weekly", {"outputPath": f"{SCRATCH}/board.glb", "wait": 10}, {"ok": True})-t("fusion_fetch_optimized_glb", "weekly", {"_dynamic": "glb_job"}, {"ok": True},-  "jobId chained from export_optimized_glb; SKIPs if the export completed inline")+t("fusion_export_optimized_glb", "weekly", {"outputPath": f"{SCRATCH}/board.glb", "async": True}, {"ok": True, "fields": ["exportId"]},+  "async form: sync outlived ab's 60s relay window on a cold session (2026-08-14)")+t("fusion_fetch_optimized_glb", "weekly", {"_dynamic": "glb_job"}, {"ok": True, "fields": ["glbPath"]},+  "polls the async exportId to completion - the final status must carry the GLB") t("fusion_set_design_rules", "manual", note="mutates the board's DRC rules")  # ── stage 5: libraries (weekly) + the show verbs (issue #29) ────────────────@@ -332,12 +334,16 @@ def stage_dynamic(target, kind):             return {"filePath": SCRATCH + "/" + fname}, None         if kind == "glb_job":             prev = LAST_RESULTS.get("fusion_export_optimized_glb") or {}-            job = prev.get("jobId")-            if not job:-                if prev.get("glbPath") or prev.get("success"):-                    return None, "SKIP: export completed inline (no jobId to fetch)"-                return None, "export_optimized_glb produced neither jobId nor glbPath"-            return {"jobId": job, "outputPath": SCRATCH + "/board.glb", "wait": 30}, None+            eid = prev.get("exportId")+            if not eid:+                return None, "export_optimized_glb returned no exportId (async start failed)"+            deadline = time.time() + 420+            while time.time() < deadline:+                st, _ = call(target, "fusion_fetch_optimized_glb", {"exportId": eid}, timeout=45)+                if st.get("done"):+                    break+                time.sleep(10)+            return {"exportId": eid}, None         if kind == "urn_from_search":             r, _ = call(target, "fusion_aps_search", {"query": "BQ25792", "limit": 5}, timeout=90)             for item in (r.get("results") or r.get("items") or []):