← Commit history

v1.6.0: fusion_build_library_3d one-call multi-part 3D library builder + make_3d_package auto-folder + before/after capture

John Lauer ·4562e269bc ·3mo ago ·parent c3d5cb5
6 files changed +221−13
BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.5.13\ No newline at end of file+1.6.0\ No newline at end of file
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" }\ No newline at end of file
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: