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.0: fusion_build_library_3d one-call multi-part 3D library builder + make_3d_package auto-folder + before/after capture
6 files changed
+221−13
BRIDGE_VERSION+1−1@@ -1 +1 @@-1.5.13+1.6.0
CHANGELOG.md+16@@ -3,6 +3,22 @@ All notable changes to the Adom Desktop Fusion 360 bridge, newest first. Canonical: https://wiki.adom.inc/adom/adom-desktop-fusion-bridge ยท GitHub backup: `adom-inc/adom-desktop-fusion-bridge` +## 1.6.0 - 2026-06-29++- **NEW `fusion_build_library_3d` - build a whole RENDERING multi-part 3D library in ONE call.** Give+ it the combined `.lbr` + a `parts[]` list (`{package, lbrPath, modelPath}`); for each part it makes+ the footprint+chip f3d package (no GUI dialogs), collects the `wip_urn`, then **injects ALL bindings+ in one pass** (`_inject_package3d_bindings` - the EAGLE `<packages3d>` + per-device+ `<package3dinstances>` surgery the AI used to hand-write) and opens the finished library once.+ Returns per-part urns + a `screenshots[]` of every before/after. Each part is independent, so a+ Fusion reset mid-run just means re-running the failed parts.+- **`fusion_make_3d_package` got two upgrades:** (1) `projectId`/`folderId` are now **optional** -+ they default to the MAIN-project upload folder (`_discover_upload_target`), so the AI no longer has+ to `fusion_aps_browse` for them; (2) pass **`captureLabel`** to auto-capture a **BEFORE** (the+ footprint in the 3D viewer) and **AFTER** (the chip placed flat on its pads) hwnd-targeted+ background screenshot, returned as `before`/`after`. A viewport `fit()` frames each shot.+- Both verbs added to `fusion_describe` (the Verbs tab) and gated as Fusion-required.+ ## 1.5.13 - 2026-06-29 - **Rich teaching hints on `fusion_make_3d_package`:** its `_hint` now spells out NEXT step (the exact
adom-bridge-fusion-manifest.json+5−5@@ -1,13 +1,13 @@ { "manifest_version": 1, "name": "fusion360",- "version": "1.5.13",- "url": "https://wiki.adom.inc/download/adom/adom-desktop-fusion-bridge/1.5.13/adom-bridge-fusion-v1.5.13.zip",- "sha256": "00d3132c3081debd63031f442f84b56fc1b591477d8e3a44ef551ef3a0fee9f8",- "size": 272294,+ "version": "1.6.0",+ "url": "https://wiki.adom.inc/download/adom/adom-desktop-fusion-bridge/1.6.0/adom-bridge-fusion-v1.6.0.zip",+ "sha256": "c37957abf38dc8a723df2fbb0664a131772ab74bdd6d8b8ef7f5c5cf79c074e9",+ "size": 277562, "verbPrefixes": [ "fusion_" ], "healthEndpoint": "/status",- "released_at": "2026-06-29T14:58:39Z"+ "released_at": "2026-06-29T16:00:00Z" }
bridge.json+1−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "fusion360", "displayName": "Autodesk Fusion 360",- "version": "1.5.13",+ "version": "1.6.0", "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://github.com/adom-inc/adom-desktop-fusion-bridge", "author": "Adom Inc.",
describe.py+6@@ -77,6 +77,12 @@ _T = [ ("fusion_attach_3d_package", "Attach a real 3D chip model to a library package, end to end: opens the .lbr (library active), runs Electron.Create3DPackage to enter the Package3DEnvironment showing the footprint, imports the STEP + auto-orients it flat on the footprint, then executes Package3DStop (FINISH). This BINDS the 3D onto the deviceset (Content Manager shows the 3D nested under the package; the Packages 'Package' column flips Placeholder->part-name). ONE desktop-side step remains: Fusion pops a modal Save dialog (an OWNED popup) - the response _hint gives the exact desktop_ui_click to confirm it. AFTER: re-grab desktop_screenshot_window and check ownedPopupCount for errors; the 3D preview LAGS a beat before showing the real chip. Both paths (filePath/modelPath) are WINDOWS paths - stage from the container first.", {"filePath": "required str (.lbr WINDOWS path C:/...)", "modelPath": "required str (.step WINDOWS path C:/...)", "packageName": "optional str"}, {"success": "bool", "savePending": "bool", "data": "{steps}", "_hint": "the Save-dialog click + verify steps"}, 240, _STATUS, True, {"filePath": "C:/Users/<user>/adom-lib/ADS8588SIPM.fusion.lbr", "modelPath": "C:/Users/<user>/adom-lib/ADS8588SIPM.step", "packageName": "ADS8588SIPM"}),+ ("fusion_make_3d_package", "Create a RENDERING component 3D package (FOOTPRINT + chip, aligned) for ONE part, fully programmatically with ZERO GUI dialogs. Opens the .lbr, runs Electron.Create3DPackage to load the package's footprint into a generator doc, imports the STEP onto it, orients it flat, saveAs an .f3d (skips the FINISH Save dialog + the two unbeatable 'Fusion360' CEF modals), uploads the f3d via APS, and returns an fs.file:vf wip_urn to hand-write into the library's <packages3d>. projectId/folderId default to the MAIN-project upload folder. Pass captureLabel to also get a BEFORE (footprint) + AFTER (chip placed) screenshot. PITFALL: a RAW STEP upload binds but renders NOTHING ('Thumbnail download failed') - this verb makes an f3d, which renders. Prefer fusion_build_library_3d for many parts.",+ {"lbrPath": "required str (.lbr WINDOWS path; its FIRST package's footprint is loaded)", "modelPath": "required str (.step WINDOWS path)", "projectId": "optional str (default MAIN-project)", "folderId": "optional str (default upload folder)", "fileName": "optional str", "captureLabel": "optional str (capture before/after)", "orient": "optional bool (default true)"},+ {"success": "bool", "wip_urn": "str", "dims_mm": "[x,y,z]", "f3d": "str", "before": "str|null", "after": "str|null"}, 200, _STATUS, True, {"lbrPath": "C:/tmp/newlib/R_4k7.lbr", "modelPath": "C:/tmp/newlib/R_4k7.step", "captureLabel": "R_4k7"}),+ ("fusion_build_library_3d", "Build a RENDERING multi-part 3D library in ONE call - the whole programmatic pipeline. For each part it makes the footprint+chip f3d package (no GUI dialogs, optional before/after screenshots) and collects the wip_urn, then injects ALL bindings into the combined .lbr in one pass (no hand XML surgery) and opens the finished library once. This is the verb for a basic-parts sampler / any many-part library; it replaces the per-part make_3d_package loop + manual binding. projectId/folderId default to the MAIN-project upload folder. Each part is independent, so re-run just the failed parts if Fusion resets mid-run.",+ {"lbrPath": "required str (combined .lbr WINDOWS path to bind + open)", "parts": "required [{package, lbrPath (per-part footprint .lbr), modelPath (.step)}]", "outLbrPath": "optional str (default: overwrite lbrPath)", "capture": "optional bool (default true)", "projectId": "optional str", "folderId": "optional str", "openWhenDone": "optional bool (default true)"},+ {"success": "bool", "boundLbr": "str", "partsBound": "int", "partsTotal": "int", "parts": "[{package,success,wip_urn,dims_mm,error}]", "screenshots": "[{package,stage,path}]", "opened": "bool"}, 1800, _STATUS, True, {"lbrPath": "C:/tmp/newlib/AdomBasicParts2.lbr", "parts": [{"package": "R_4k7", "lbrPath": "C:/tmp/newlib/R_4k7.lbr", "modelPath": "C:/tmp/newlib/R_4k7.step"}]}), ("fusion_run_modeling_script", "Run an adsk.fusion modeling script in the live session (sketches/extrudes/params). Free programmatic CAD.", {"script": "required str (Python using adsk; globals: adsk, app, ui, design)"}, {"result": "obj", "documentName": "str"}, 180, None, True, {"script": "import adsk.fusion\n# ...build geometry..."}),
server.py+192−6@@ -1216,6 +1216,77 @@ def _orchestrate_attach_3d_package(args: dict) -> dict: } +# Default APS upload target for 3D-package f3d uploads (the MAIN Adom project + its+# bridge-uploads folder). Used when build_library_3d / make_3d_package are called WITHOUT+# an explicit projectId/folderId, so the AI never has to browse for them. Override by+# passing projectId/folderId.+_DEFAULT_UPLOAD_PROJECT = "a.YnVzaW5lc3M6YWRvbTMjMjAyMzExMjk3MDM5NjAzMzE"+_DEFAULT_UPLOAD_FOLDER = "urn:adsk.wipprod:fs.folder:co.jyO4vxQXR6S9zFpTQWnZAg"+++def _discover_upload_target(args: dict) -> tuple:+ """(projectId, folderId) for f3d uploads: explicit args win, else the known MAIN-project+ upload folder, so the AI never has to fusion_aps_browse for them."""+ return (args.get("projectId") or _DEFAULT_UPLOAD_PROJECT,+ args.get("folderId") or _DEFAULT_UPLOAD_FOLDER)+++def _capture_labeled(label) -> str | None:+ """Background-capture the main Fusion window to a labeled PNG on the box+ (C:/tmp/conduit-screenshots). Returns the saved path or None. Never fullscreen+ (hwnd-targeted PrintWindow, so it captures Fusion in the background per fusion-driving)."""+ if not label:+ return None+ try:+ info = get_fusion_window_info()+ hwnd = info.get("hwnd")+ if not hwnd:+ return None+ import re as _re+ safe = _re.sub(r'[<>:"/\\|?*]', "", str(label)).replace(" ", "_")[:40]+ r = screenshot_hwnd(hwnd, label=safe)+ return r.get("savedTo") if r.get("success") else None+ except Exception:+ return None+++def _inject_package3d_bindings(lbr_text: str, bindings: list) -> str:+ """Inject EAGLE <packages3d> + per-device <package3dinstances> into an .lbr in ONE pass.++ bindings: [{package: <pkg name>, wip_urn: <urn>}]. Adds a library-level <package3d> for+ each (between </packages> and <symbols>) and, into every <device> that uses that package,+ a <package3dinstances> right after </connects>. This is the exact XML surgery the AI used+ to hand-write per part - now one deterministic call. Returns the new .lbr text."""+ import re as _re+ blocks = []+ for b in bindings:+ pkg = b["package"]; urn = b["wip_urn"]+ blocks.append(+ f'<package3d name="{pkg}" urn="" wip_urn="{urn}" locally_modified="yes" type="model">'+ f'<description>{pkg}</description>'+ f'<packageinstances><packageinstance name="{pkg}"/></packageinstances>'+ f'</package3d>'+ )+ pkgs3d = "<packages3d>\n" + "\n".join(blocks) + "\n</packages3d>\n"+ if "<packages3d>" in lbr_text:+ lbr_text = _re.sub(r"<packages3d>.*?</packages3d>\s*", pkgs3d, lbr_text, flags=_re.S)+ else:+ lbr_text = lbr_text.replace("</packages>", "</packages>\n" + pkgs3d, 1)+ urn_by_pkg = {b["package"]: b["wip_urn"] for b in bindings}++ def _dev_repl(m):+ dev = m.group(0)+ pm = _re.search(r'package="([^"]+)"', dev)+ if pm and pm.group(1) in urn_by_pkg and "<package3dinstances>" not in dev and "</connects>" in dev:+ urn = urn_by_pkg[pm.group(1)]+ inst = (f'<package3dinstances><package3dinstance package3d_urn="{urn}"/>'+ f'</package3dinstances>')+ dev = dev.replace("</connects>", "</connects>\n" + inst, 1)+ return dev++ return _re.sub(r'<device\b[^>]*>.*?</device>', _dev_repl, lbr_text, flags=_re.S)++ def _orchestrate_make_3d_package(args: dict) -> dict: """Create a RENDERING component 3D-PACKAGE urn (footprint + chip), fully programmatically - NO GUI dialogs. A proper component 3D model contains the FOOTPRINT (pads + courtyard) AND the chip,@@ -1237,14 +1308,11 @@ def _orchestrate_make_3d_package(args: dict) -> dict: import os as _os, time as _time lbr_path = (args.get("lbrPath") or args.get("filePath") or "").replace("\\", "/") model_path = (args.get("modelPath") or "").replace("\\", "/")- project_id = args.get("projectId"); folder_id = args.get("folderId")+ project_id, folder_id = _discover_upload_target(args) # default to the MAIN-project upload folder if not lbr_path or not model_path: return {"success": False, "error": "lbrPath (.lbr with the footprint) and modelPath (.step) are required (Windows paths)."}- if not project_id or not folder_id:- return {"success": False,- "error": "projectId + folderId are required.",- "_hint": "Get them from fusion_aps_browse {} (hub->projects) then fusion_aps_browse {projectId} (folders)."}+ cap_label = args.get("captureLabel") # when set, capture BEFORE (footprint) + AFTER (chip placed) orient = args.get("orient", True) base = _os.path.basename(model_path).rsplit(".", 1)[0] name = (args.get("fileName") or (base + "_3d")).replace("'", "").replace(".f3d", "")@@ -1259,6 +1327,16 @@ def _orchestrate_make_3d_package(args: dict) -> dict: return {"success": False, "error": "Create3DPackage failed: " + str(cp.get("error")), "_hint": "The .lbr must be adom-lbr-generated; its FIRST package's footprint is loaded into the generator."} _time.sleep(2)+ # BEFORE shot: the footprint (pads + courtyard) loaded in the 3D viewer, no chip yet.+ before_shot = None+ if cap_label:+ try:+ _proxy_to_addin("run_modeling_script",+ {"script": "app.activeViewport.fit()\nresult={}"}, timeout=15)+ except Exception:+ pass+ _time.sleep(0.4)+ before_shot = _capture_labeled(f"{cap_label}_before") orient_block = ( "axis=(1,0,0) if (dy<=dx and dy<=dz) else ((0,1,0) if (dx<=dy and dx<=dz) else None)\n" "if axis:\n mat=oc.transform2.copy(); r=adsk.core.Matrix3D.create()\n"@@ -1275,6 +1353,7 @@ def _orchestrate_make_3d_package(args: dict) -> dict: "oc=root.occurrences.item(root.occurrences.count-1); bb=oc.boundingBox\n" "dx=bb.maxPoint.x-bb.minPoint.x; dy=bb.maxPoint.y-bb.minPoint.y; dz=bb.maxPoint.z-bb.minPoint.z\n" + orient_block ++ "try:\n app.activeViewport.fit()\nexcept: pass\n" "res={}\n" "try:\n" f" doc.saveAs('{name}', app.data.activeProject.rootFolder, '3d package (footprint+chip)', '')\n"@@ -1287,8 +1366,11 @@ def _orchestrate_make_3d_package(args: dict) -> dict: res = (r.get("data", {}) or {}).get("result", {}) if isinstance(r, dict) else {} f3d_path = res.get("path") if isinstance(res, dict) else None dims = res.get("dims") if isinstance(res, dict) else None+ # AFTER shot: the chip placed flat on its footprint (pads landing on pads) in the 3D viewer.+ after_shot = _capture_labeled(f"{cap_label}_after") if cap_label else None if not f3d_path or not str(f3d_path).endswith(".f3d"): return {"success": False, "error": "f3d saveAs did not produce a .f3d path: " + str(res),+ "before": before_shot, "after": after_shot, "_hint": "Confirm modelPath is a valid Windows .step path and Fusion is running."} up = aps.handle_upload({"projectId": project_id, "folderId": folder_id, "localPath": f3d_path, "fileName": f3d_name})@@ -1307,6 +1389,8 @@ def _orchestrate_make_3d_package(args: dict) -> dict: "wip_urn": wip_urn, "dims_mm": dims, "f3d": f3d_path,+ "before": before_shot,+ "after": after_shot, "_hint": ( "DONE - a PROPER component 3D package (FOOTPRINT + chip, aligned) created with NO GUI dialogs. " "NEXT: hand-write this wip_urn into the library's EAGLE XML - a <package3d name=\"PKG\" urn=\"\" "@@ -1328,6 +1412,106 @@ def _orchestrate_make_3d_package(args: dict) -> dict: } +def _orchestrate_build_library_3d(args: dict) -> dict:+ """Build a RENDERING multi-part 3D library in ONE call - the whole programmatic pipeline.++ For each part: make its footprint+chip f3d package (no GUI dialogs, optional BEFORE/AFTER+ screenshots), collect the wip_urn. Then inject ALL bindings into the combined .lbr in one+ pass (no hand XML surgery) and open the finished library ONCE. This is the verb to call for+ a basic-parts sampler / any many-part library - it replaces the per-part make_3d_package loop+ + manual binding the AI used to do.++ args: {+ lbrPath: combined .lbr to bind + open (Windows path),+ parts: [{package, lbrPath (per-part .lbr whose FIRST package is this footprint),+ modelPath (.step)}],+ outLbrPath?: where to write the bound .lbr (default: overwrite lbrPath),+ capture?: bool - capture before/after per part (default True),+ projectId?, folderId?: APS upload target (default: the MAIN-project upload folder),+ openWhenDone?: bool (default True)+ }+ """+ combined = (args.get("lbrPath") or "").replace("\\", "/")+ parts = args.get("parts") or []+ if not combined or not parts:+ return {"success": False,+ "error": "lbrPath (combined .lbr) and parts[] are required.",+ "_hint": "parts: [{package, lbrPath (per-part footprint .lbr), modelPath (.step)}]. "+ "projectId/folderId default to the MAIN-project upload folder."}+ out_path = (args.get("outLbrPath") or combined).replace("\\", "/")+ capture = args.get("capture", True)+ project_id, folder_id = _discover_upload_target(args)+ results = []; bindings = []; shots = []+ for p in parts:+ pkg = p.get("package")+ per_lbr = (p.get("lbrPath") or "").replace("\\", "/")+ step = (p.get("modelPath") or "").replace("\\", "/")+ if not pkg or not per_lbr or not step:+ results.append({"package": pkg, "success": False,+ "error": "package, lbrPath (per-part .lbr) and modelPath (.step) all required"})+ continue+ mk = _orchestrate_make_3d_package({+ "lbrPath": per_lbr, "modelPath": step,+ "projectId": project_id, "folderId": folder_id,+ "fileName": f"{pkg}_3d",+ "captureLabel": (pkg if capture else None),+ })+ entry = {"package": pkg, "success": bool(mk.get("success"))}+ if mk.get("success"):+ entry["wip_urn"] = mk.get("wip_urn"); entry["dims_mm"] = mk.get("dims_mm")+ bindings.append({"package": pkg, "wip_urn": mk["wip_urn"]})+ else:+ entry["error"] = mk.get("error")+ if mk.get("before"):+ shots.append({"package": pkg, "stage": "before", "path": mk["before"]})+ if mk.get("after"):+ shots.append({"package": pkg, "stage": "after", "path": mk["after"]})+ results.append(entry)+ # inject every binding in ONE pass, write the bound library+ bound = None+ if bindings:+ try:+ with open(combined, "r", encoding="utf-8") as fh:+ txt = fh.read()+ txt = _inject_package3d_bindings(txt, bindings)+ with open(out_path, "w", encoding="utf-8") as fh:+ fh.write(txt)+ bound = out_path+ except Exception as e:+ return {"success": False, "error": f"binding injection failed: {e}",+ "parts": results, "bindings": bindings, "screenshots": shots}+ # open the finished library ONCE (so check_dialogs reflects the merged result)+ opened = None+ if bound and args.get("openWhenDone", True):+ try:+ opened = bool(_orchestrate_open_lbr({"filePath": bound}).get("success"))+ except Exception:+ opened = False+ n_ok = sum(1 for r in results if r.get("success"))+ return {+ "success": n_ok > 0,+ "boundLbr": bound,+ "partsBound": n_ok,+ "partsTotal": len(parts),+ "parts": results,+ "screenshots": shots,+ "opened": opened,+ "_hint": (+ f"{n_ok}/{len(parts)} parts got a RENDERING footprint+chip 3D package; all bindings injected "+ f"into {bound} and the library opened once. NEXT: fusion_check_dialogs should be 0 (no "+ "broken-ref). For clean device previews + screenshots, save to the cloud and REOPEN from "+ "the cloud (fusion_aps_open) - the in-session view often gets an empty 'Untitled' shoved in "+ "front. Each device's lower-right 3D preview should show footprint + chip (NOT 'Thumbnail "+ "download failed'). The BEFORE (footprint) / AFTER (chip placed) PNGs are in screenshots[] "+ "on the Windows box (C:/tmp/conduit-screenshots) - pull them with desktop_pull_file (or "+ "re-capture via desktop_screenshot_window) for the demo video. PITFALLS: needs Fusion "+ "running; on long runs Fusion can reset between parts (fusion_start, then re-run just the "+ "failed parts - each is independent); any failed part has its error in parts[]. Full recipe: "+ "the fusion-multipart-libraries skill."+ ),+ }++ def _orchestrate_save_lbr(args: dict) -> dict: """Save the currently open Electronics library as a .flbr file. @@ -1397,7 +1581,7 @@ def dispatch_command(command: str, args: dict) -> dict: # The user should launch Fusion themselves; we just report the status. # Commands that need Fusion running (add-in commands + orchestrated commands) _OPEN_WITH_SCREENSHOT = {"open_schematic", "open_board", "show_3d_board", "show_2d_board", "show_schematic", "import_electronics"}- if command in ADDIN_COMMANDS or command in ("open_lbr", "save_lbr", "attach_3d_package", "make_3d_package") or command in _OPEN_WITH_SCREENSHOT:+ if command in ADDIN_COMMANDS or command in ("open_lbr", "save_lbr", "attach_3d_package", "make_3d_package", "build_library_3d") or command in _OPEN_WITH_SCREENSHOT: if not fusion_info.get("installed"): return { "success": False,@@ -1503,6 +1687,8 @@ def dispatch_command(command: str, args: dict) -> dict: return _merge_dialog_array(_orchestrate_attach_3d_package(args)) if command == "make_3d_package": return _orchestrate_make_3d_package(args)+ if command == "build_library_3d":+ return _orchestrate_build_library_3d(args) # Add-in proxy commands if command in ADDIN_COMMANDS: