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.6.72: fire-and-return optimize verb + fusion_fetch_optimized_glb (avoids relay timeout on big-board tessellation)
5 files changed
+120−57
BRIDGE_VERSION+1−1@@ -1 +1 @@-1.6.71+1.6.72
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: