← Commit history

v1.6.72: fire-and-return optimize verb + fusion_fetch_optimized_glb (avoids relay timeout on big-board tessellation)

John Lauer ·5a19638701 ·3mo ago ·parent fa56c0e
5 files changed +120−57
BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.6.71\ No newline at end of file+1.6.72\ No newline at end of file
adom-bridge-fusion-manifest.json+4−4
@@ -1,10 +1,10 @@ {   "manifest_version": 1,   "name": "fusion360",-  "version": "1.6.71",-  "url": "https://wiki.adom.inc/download/adom/adom-desktop-fusion-bridge/1.6.71/adom-bridge-fusion-v1.6.71.zip",-  "sha256": "9682846f5acca6eece70813fd11615cfea597561cc0e71d9cbb32f3f445e575d",-  "size": 370945,+  "version": "1.6.72",+  "url": "https://wiki.adom.inc/download/adom/adom-desktop-fusion-bridge/1.6.72/adom-bridge-fusion-v1.6.72.zip",+  "sha256": "617db8fc2cc62b99d9d9d03e14f4adb21bc28d1d68d66d7a40ad7d25ca13459b",+  "size": 371999,   "verbPrefixes": [     "fusion_"   ],
bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "fusion360",   "displayName": "Autodesk Fusion 360",-  "version": "1.6.71",+  "version": "1.6.72",   "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/adom-desktop-fusion-bridge",   "author": "Adom Inc.",
describe.py+2−1
@@ -132,7 +132,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 in ONE call. Exports STEP (+ optional silkscreen) and runs it through 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. Use for a component page's 3D viewer (component.parts.model_3d).", {"outputPath": "required str (.glb)", "silkscreen": "bool default true", "pin": "str medium|large default medium"}, {"glbPath": "str", "meshesAfter": "int", "sizeBytes": "int", "moleculeAnchored": "bool", "silkscreenApplied": "bool"}, 300, None, True, {"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.", {"outputPath": "required str (.glb)", "silkscreen": "bool default true", "pin": "str medium|large default medium", "wait": "int seconds default 90"}, {"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 90"}, {"glbPath": "str", "status": "str", "pending": "bool", "sizeBytes": "int"}, 150, None, False, {"jobId": "abc123", "outputPath": "C:/out/board.glb"}),     ("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"}),     ("fusion_export_dwg", "Export 2D DWG.", {"outputPath": "required str"}, {"format": "str"}, 120, None, True, {"outputPath": "C:/out/sketch.dwg"}),
server.py+112−50
@@ -3287,11 +3287,17 @@ def _multipart_body(fields: dict, files: list):     return b"\r\n".join(out), boundary  -def _service_glb_optimize(step_path: str, silk_top: str = None, silk_bottom: str = None,-                          pin: str = "medium", job_name: str = "fusion-board",-                          poll_max_s: int = 240) -> dict:-    """POST a STEP (+ optional silk PNGs) to service-step2glb molecule mode, poll to-    completion, return {ok, glbBytes, stats}. Pure urllib (no requests on the box)."""+# A User-Agent is REQUIRED on every service call. The *.adom.cloud edge WAF 403s the+# default "Python-urllib/x.y" UA (confirmed live 2026-07-14) while a browser/curl UA+# gets 200. Set it on the POST AND both polls or the call fails with an opaque 403.+def _service_ua():+    return "adom-fusion-bridge/%s" % BRIDGE_VERSION+++def _service_glb_submit(step_path: str, silk_top: str = None, silk_bottom: str = None,+                        pin: str = "medium", job_name: str = "fusion-board") -> dict:+    """POST a STEP (+ optional silk PNGs) to service-step2glb molecule mode. Returns+    {ok, jobId} - does NOT wait. Pure urllib (no requests on the box)."""     try:         with open(step_path, "rb") as f:             step_bytes = f.read()@@ -3306,16 +3312,11 @@ def _service_glb_optimize(step_path: str, silk_top: str = None, silk_bottom: str             except Exception:                 pass     body, boundary = _multipart_body({}, files)-    # NOTE: a User-Agent is REQUIRED. The *.adom.cloud edge WAF 403s the default-    # "Python-urllib/x.y" UA (confirmed live 2026-07-14) while a browser/curl UA gets-    # 200. urllib sends the default on EVERY request (POST + both polls), so set it-    # on all three or the whole call fails with an opaque 403.-    _UA = "adom-fusion-bridge/%s" % BRIDGE_VERSION     headers = {         "Content-Type": "multipart/form-data; boundary=" + boundary,         "X-Client": "fusion-bridge/adom",         "X-Job-Name": job_name,-        "User-Agent": _UA,+        "User-Agent": _service_ua(),     }     url = "%s/convert?molecule=true&pin=%s" % (STEP2GLB_URL, urllib.parse.quote(pin))     try:@@ -3328,32 +3329,47 @@ def _service_glb_optimize(step_path: str, silk_top: str = None, silk_bottom: str     job_id = queued.get("job_id")     if not job_id:         return {"ok": False, "error": "service did not return a job_id", "raw": queued}-    # Poll until complete (STEP tessellation of a big board can take a few minutes).-    stats, deadline = {}, _time.time() + poll_max_s-    while _time.time() < deadline:+    return {"ok": True, "jobId": job_id}+++def _service_glb_fetch(job_id: str, out_path: str = None, poll_s: int = 0) -> dict:+    """Poll a service job for up to poll_s seconds; if complete, optionally write the+    GLB to out_path. Returns {ok, status, stats, glbBytes?, wrote?}. poll_s=0 = one check."""+    ua = _service_ua()+    stats, deadline, first = {}, _time.time() + max(0, poll_s), True+    while first or _time.time() < deadline:+        first = False         try:-            preq = urllib.request.Request("%s/jobs/%s" % (STEP2GLB_URL, job_id),-                                          headers={"User-Agent": _UA})+            preq = urllib.request.Request("%s/jobs/%s" % (STEP2GLB_URL, job_id), headers={"User-Agent": ua})             with urllib.request.urlopen(preq, timeout=20) as r:                 stats = json.loads(r.read().decode("utf-8", "replace"))         except Exception:-            _time.sleep(5); continue+            _time.sleep(4); continue         st = stats.get("status")         if st == "complete":             break         if st == "error":-            return {"ok": False, "error": "service job failed: %s" % (stats.get("error") or stats), "jobId": job_id}+            return {"ok": False, "status": "error", "error": stats.get("error") or stats, "stats": stats}+        if _time.time() >= deadline:+            return {"ok": True, "status": st or "processing", "stats": stats}         _time.sleep(6)-    else:-        return {"ok": False, "error": "service job did not finish within %ss" % poll_max_s, "jobId": job_id}+    if stats.get("status") != "complete":+        return {"ok": True, "status": stats.get("status") or "processing", "stats": stats}     try:-        rreq = urllib.request.Request("%s/jobs/%s/result" % (STEP2GLB_URL, job_id),-                                      headers={"User-Agent": _UA})+        rreq = urllib.request.Request("%s/jobs/%s/result" % (STEP2GLB_URL, job_id), headers={"User-Agent": ua})         with urllib.request.urlopen(rreq, timeout=60) as r:             glb = r.read()     except Exception as e:-        return {"ok": False, "error": "could not download result GLB: %s" % e, "jobId": job_id}-    return {"ok": True, "glbBytes": glb, "stats": stats, "jobId": job_id}+        return {"ok": False, "status": "complete", "error": "download failed: %s" % e, "stats": stats}+    wrote = None+    if out_path:+        try:+            with open(out_path, "wb") as f:+                f.write(glb)+            wrote = out_path+        except Exception as e:+            return {"ok": False, "status": "complete", "error": "could not write GLB: %s" % e, "stats": stats}+    return {"ok": True, "status": "complete", "stats": stats, "glbBytes": glb, "wrote": wrote}   def _orchestrate_export_optimized_glb(args: dict) -> dict:@@ -3391,37 +3407,81 @@ def _orchestrate_export_optimized_glb(args: dict) -> dict:             silk_bottom = sb         if not (silk_top or silk_bottom):             silk_note = "silkscreen capture unavailable on this design; GLB built without a baked silk texture."-    # 4) Run the molecule optimizer and write the GLB.+    # 4) Submit to the molecule optimizer (fire-and-return: a big board's tessellation+    #    can run minutes, longer than the AD relay's request timeout, so we do NOT block+    #    the whole time here). Then bounded-wait up to `wait` seconds (default 90) so+    #    small boards still come back complete in one call.     job_name = os.path.splitext(os.path.basename(output_path))[0]-    opt = _service_glb_optimize(step_path, silk_top, silk_bottom, pin=pin, job_name=job_name)-    if not opt.get("ok"):-        return {"success": False, "error": opt.get("error"), "_hint": opt.get("_hint"),-                "stepPath": step_path, "note": "STEP exported OK; optimization failed - you can still convert the STEP another way."}-    try:-        with open(output_path, "wb") as f:-            f.write(opt["glbBytes"])-    except Exception as e:-        return {"success": False, "error": "could not write GLB to %s: %s" % (output_path, e)}-    stats = opt.get("stats") or {}+    sub = _service_glb_submit(step_path, silk_top, silk_bottom, pin=pin, job_name=job_name)+    if not sub.get("ok"):+        return {"success": False, "error": sub.get("error"), "_hint": sub.get("_hint"),+                "stepPath": step_path, "note": "STEP exported OK; optimize submit failed."}+    job_id = sub["jobId"]+    wait_s = int(args.get("wait", 90))+    fetched = _service_glb_fetch(job_id, out_path=output_path, poll_s=wait_s) if wait_s > 0 else {"ok": True, "status": "processing"}+    status_url = "%s/jobs/%s" % (STEP2GLB_URL, job_id)+    result_url = "%s/jobs/%s/result" % (STEP2GLB_URL, job_id)+    if fetched.get("status") == "complete" and fetched.get("wrote"):+        stats = fetched.get("stats") or {}+        size = len(fetched.get("glbBytes") or b"")+        return {+            "success": True, "status": "complete",+            "glbPath": output_path, "stepPath": step_path, "jobId": job_id,+            "silkTop": silk_top, "silkBottom": silk_bottom,+            "meshesBefore": stats.get("meshes_before"), "meshesAfter": stats.get("meshes_after"),+            "sizeBytes": size, "moleculeAnchored": stats.get("molecule_anchored"),+            "silkscreenApplied": stats.get("silkscreen_applied"), "note": silk_note,+            "message": "Optimized GLB written (%d KB, meshes %s->%s, anchored=%s, silk=%s) to %s" % (+                size // 1024, stats.get("meshes_before"), stats.get("meshes_after"),+                stats.get("molecule_anchored"), stats.get("silkscreen_applied"), output_path),+            "_hint": "Pull it with pull_file, then set it as component.parts.model_3d on a wiki component page. "+                     "Same optimizer as the molecule GLBs (Colby's pipeline).",+        }+    if not fetched.get("ok"):+        return {"success": False, "error": fetched.get("error"), "jobId": job_id, "stepPath": step_path,+                "statusUrl": status_url, "resultUrl": result_url}+    # Still processing after the bounded wait - hand back the job so the caller finishes it.     return {-        "success": True,-        "glbPath": output_path,-        "stepPath": step_path,-        "silkTop": silk_top, "silkBottom": silk_bottom,-        "meshesBefore": stats.get("meshes_before"), "meshesAfter": stats.get("meshes_after"),-        "sizeBytes": len(opt["glbBytes"]),-        "sizeBeforeCompress": stats.get("size_before_compress"),-        "moleculeAnchored": stats.get("molecule_anchored"),-        "silkscreenApplied": stats.get("silkscreen_applied"),-        "note": silk_note,-        "message": "Optimized GLB written (%d KB, meshes %s->%s, anchored=%s, silk=%s) to %s" % (-            len(opt["glbBytes"]) // 1024, stats.get("meshes_before"), stats.get("meshes_after"),-            stats.get("molecule_anchored"), stats.get("silkscreen_applied"), output_path),-        "_hint": "Pull it with pull_file, then put it on a wiki component page as component.parts.model_3d. "-                 "This is the same optimizer that produces the gorgeous molecule GLBs (Colby's pipeline).",+        "success": True, "status": fetched.get("status") or "processing",+        "pending": True, "jobId": job_id, "stepPath": step_path,+        "glbPath": output_path, "silkTop": silk_top, "silkBottom": silk_bottom, "note": silk_note,+        "statusUrl": status_url, "resultUrl": result_url,+        "message": "Optimize job %s submitted; still processing after %ds. Finish it with "+                   "fusion_fetch_optimized_glb {\"jobId\":\"%s\",\"outputPath\":\"%s\"} (re-call until complete)." % (+                       job_id, wait_s, job_id, output_path),+        "_hint": "Big boards tessellate for a few minutes. Either re-call fusion_fetch_optimized_glb "+                 "with this jobId (writes the GLB on the box when ready), or GET the resultUrl directly.",     }  +def _orchestrate_fetch_optimized_glb(args: dict) -> dict:+    """Fetch a previously-submitted optimize job's GLB (from fusion_export_optimized_glb's+    jobId). Bounded poll (default 90s); writes to outputPath when complete."""+    job_id = args.get("jobId")+    if not job_id:+        return {"success": False, "error": "No jobId.",+                "_hint": 'Usage: fusion_fetch_optimized_glb {"jobId":"...","outputPath":"C:/tmp/board.glb"}'}+    out_path = args.get("outputPath") or args.get("output_path")+    if out_path and not out_path.lower().endswith(".glb"):+        out_path = out_path + ".glb"+    wait_s = int(args.get("wait", 90))+    res = _service_glb_fetch(job_id, out_path=out_path, poll_s=wait_s)+    status_url = "%s/jobs/%s" % (STEP2GLB_URL, job_id)+    result_url = "%s/jobs/%s/result" % (STEP2GLB_URL, job_id)+    if res.get("status") == "complete" and res.get("wrote"):+        stats = res.get("stats") or {}+        size = len(res.get("glbBytes") or b"")+        return {"success": True, "status": "complete", "glbPath": out_path, "jobId": job_id,+                "sizeBytes": size, "meshesAfter": stats.get("meshes_after"),+                "moleculeAnchored": stats.get("molecule_anchored"), "silkscreenApplied": stats.get("silkscreen_applied"),+                "message": "Optimized GLB written (%d KB) to %s" % (size // 1024, out_path)}+    if not res.get("ok"):+        return {"success": False, "error": res.get("error"), "jobId": job_id, "statusUrl": status_url, "resultUrl": result_url}+    return {"success": True, "status": res.get("status") or "processing", "pending": True, "jobId": job_id,+            "statusUrl": status_url, "resultUrl": result_url,+            "message": "Job %s still %s; re-call fusion_fetch_optimized_glb to finish." % (job_id, res.get("status") or "processing")}++ def dispatch_command(command: str, args: dict) -> dict:     """Dispatch a command to the appropriate handler."""     # Direct handlers (don't need the add-in)@@ -3550,6 +3610,8 @@ def dispatch_command(command: str, args: dict) -> dict:         return _apply_failure_dialogs(_orchestrate_generate_package(args))     if command == "export_optimized_glb":         return _orchestrate_export_optimized_glb(args)+    if command == "fetch_optimized_glb":+        return _orchestrate_fetch_optimized_glb(args)      # Add-in proxy commands     if command in ADDIN_COMMANDS: