← Commit history

1.6.54: 3D auto-orient tall-part guard (keep through-hole pins vertical) + orient passthrough

John Lauer ·160853046f ·3mo ago ·parent bbe3b44
5 files changed +156−9
BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.6.53\ No newline at end of file+1.6.54\ No newline at end of file
CHANGELOG.md+11
@@ -3,6 +3,17 @@ 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.54 - 2026-07-07++- **Fixed 3D auto-orient laying TALL through-hole parts on their side** (John caught it: a machine+  pin's 3D came in horizontal). `make_3d_package` / `attach_3d_package` / `build_library_3d` always+  rotated the SMALLEST bbox dim to Z (correct for a flat SMD chip, wrong for a tall pin whose long+  axis is already Z). Now a tall-part guard keeps a part vertical when its largest dimension is+  already Z, and only flattens a clearly-thin part whose thin axis isn't Z yet. Verified: machine+  pins stay vertical, a flat 0603 stays flat, a chip imported on its edge still gets flattened.+- `attach_3d_package` and `build_library_3d` parts[] now honor an `orient` flag (default true) so a+  caller can skip auto-orient for an already-correctly-oriented STEP.+ ## 1.6.53 - 2026-07-07  - **Fixed slow-machine opens being mislabeled "add-in not running" (caught live on a GPU-less Azure
adom-bridge-fusion-manifest.json+5−5
@@ -1,13 +1,13 @@ {   "manifest_version": 1,   "name": "fusion360",-  "version": "1.6.53",-  "url": "https://wiki.adom.inc/download/adom/adom-desktop-fusion-bridge/1.6.53/adom-bridge-fusion-v1.6.53.zip",-  "sha256": "93e1b055a799133d452813822f1979dd67a79d2af52bd586c5bf147a29e5758d",-  "size": 334089,+  "version": "1.6.54",+  "url": "https://wiki.adom.inc/download/adom/adom-desktop-fusion-bridge/1.6.54/adom-bridge-fusion-v1.6.54.zip",+  "sha256": "e2920d0d79a89fcc53c808dc2f8456c068c8be3929952f2bb8fd4b096a85e5fe",+  "size": 337157,   "verbPrefixes": [     "fusion_"   ],   "healthEndpoint": "/status",-  "released_at": "2026-07-07T12:55:00Z"+  "released_at": "2026-07-07T16:20:00Z" }
bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "fusion360",   "displayName": "Autodesk Fusion 360",-  "version": "1.6.53",+  "version": "1.6.54",   "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.",
server.py+138−2
@@ -235,6 +235,115 @@ def _handle_notify_owner(args: dict) -> dict:     }  +def _handle_new_electronics_from_eagle(args: dict) -> dict:+    """Import a legacy EAGLE .sch (+ paired .brd) into a NEW Fusion electronics design+    so the parts actually INSTANTIATE (schematic + populated board), then land in the PCB+    editor. Proven live 2026-07-07 on winvm.++    THE KEY FINDING: `Document.newDesignFromLocal <file.sch>` opens the schematic EDITOR+    but does NOT instantiate the parts (board_info == 0). Fusion's REAL EAGLE importer is+    the internal command `ImportSCHAndBRDCmd` - it pops TWO native "Open" file dialogs (first+    the .sch, then the .brd) and builds a proper board-linked electronics design. This verb+    fires that command and drives BOTH dialogs entirely in the BACKGROUND via the AD direct+    API (desktop_ui_click by accessible name - no foreground, no coordinates). The Fusion-+    native .fsch/.fbrd container is opaque BINARY (Manifest.dat + BulkStream segments), so+    you CANNOT author it offline - going through Fusion's importer is the only way.++    args: {schPath: Windows path to the .sch (required), brdPath: Windows path to the .brd+           (optional; defaults to the .sch's sibling .brd)}+    """+    import time as _t+    sch = (args.get("schPath") or args.get("filePath") or "").replace("\\", "/")+    if not sch or not sch.lower().endswith(".sch"):+        return {"success": False, "error": "schPath (a .sch file on the Windows host) is required.",+                "_hint": "Author the EAGLE .sch + paired .brd (same basename, same folder), stage both "+                         "to Windows (send_files), then call with {schPath, brdPath}."}+    brd = (args.get("brdPath") or (sch[:-4] + ".brd")).replace("\\", "/")+    if not ad_client.available():+        return {"success": False, "error": "AD direct API unavailable - cannot drive the import dialogs.",+                "_hint": "This verb needs AD >=1.9.84 (the bridge drives the two Open dialogs via desktop_ui_click). "+                         "Fallback: fire 'Commands.Start ImportSCHAndBRDCmd' via fusion_execute_text_command and "+                         "drive the two Open dialogs yourself (select the .sch then .brd by accessible name)."}++    def _find_open_dialog():+        res = ad_client.call("desktop_list_windows", {}) or {}+        out = res.get("output") if isinstance(res, dict) else None+        if isinstance(out, str):+            try: out = json.loads(out)+            except Exception: out = None+        wins = ((out or res).get("data", out or res) or {}).get("windows", []) if isinstance(out or res, dict) else []+        for w in wins:+            if str(w.get("title", "")).strip().lower() == "open":+                return w.get("hwnd")+        return None++    def _pick(hwnd, basename):+        # select the file by accessible name, then click Open - both background UIA+        ad_client.call("desktop_ui_click", {"hwnd": hwnd, "name": basename})+        _t.sleep(1.0)+        ad_client.call("desktop_ui_click", {"hwnd": hwnd, "name": "Open"})++    import os as _os+    sch_base, brd_base = _os.path.basename(sch), _os.path.basename(brd)++    # 1) Fire Fusion's EAGLE importer (opens the first Open dialog).+    _proxy_to_addin("execute_text_command", {"command": "Commands.Start ImportSCHAndBRDCmd"}, timeout=15)++    # 2) Drive the two Open dialogs (sch, then brd). Each appears after a beat; retry.+    picked = []+    for want, base in (("sch", sch_base), ("brd", brd_base)):+        dlg = None+        for _ in range(20):+            _t.sleep(1.5)+            dlg = _find_open_dialog()+            if dlg:+                break+        if not dlg:+            # brd dialog may not appear if Fusion inferred the sibling automatically+            if want == "brd" and picked:+                break+            return {"success": False, "error": f"The '{want}' Open dialog never appeared.",+                    "_hint": "Screenshot the Fusion hwnd + READ screenshots[] for a blocking modal; the importer "+                             "may have errored on the EAGLE source. Verify the .sch/.brd are valid EAGLE.",+                    "data": {"picked": picked}}+        _pick(dlg, base)+        picked.append(base)+        _t.sleep(2.0)++    # 3) Verify by STATE - poll until the imported design is an electronics design.+    ready = False+    for _ in range(40):  # up to ~2 min; import is slow, esp. software-rendered+        _t.sleep(3)+        try:+            st = _proxy_to_addin("get_app_state", {}, timeout=8)+            d = st.get("data") or {}+            if d.get("isElectronics") and str(d.get("activeWorkspace", "")).lower() in ("pcb editor", "schematic editor", "board layout", "3d pcb"):+                ready = True+                break+        except Exception:+            pass  # app_state is None while a modal import dialog blocks - keep polling++    return {+        "success": ready,+        "imported": picked,+        "statusVerb": "fusion_board_info",+        "_hint": (+            "EAGLE design imported via Fusion's own ImportSCHAndBRDCmd (both Open dialogs driven in the "+            "background). It is now an electronics design in the PCB editor. NEXT: fusion_show_2d_board, "+            "then RATSNEST + 'AUTO ;' (fusion_electron_run) to autoroute, fusion_show_3d_board for the 3D. "+            "NOTE: board_info can read 0 right after import (wrong-view query) even though the board is "+            "populated - verify by screenshotting the Fusion hwnd (read screenshots[]) or fusion_show_2d_board "+            "+ WINDOW FIT. If 3D bodies are missing, the placed parts' package3d urns must resolve in THIS "+            "project - build the library's 3D into this project with fusion_build_library_3d (do NOT reuse "+            "another library's urns; they don't resolve cross-project)."+            if ready else+            "Import fired + both Open dialogs driven, but the design did not confirm as electronics within the "+            "budget (a slow VM import can run longer). Poll fusion_get_app_state, and screenshot the Fusion hwnd "+            "reading screenshots[] for a blocking import dialog."+        ),+    }++ def _handle_launch(fusion_info: dict, args: dict) -> dict:     """Launch Fusion 360 and optionally wait for the AdomBridge add-in."""     # LIVE detect (not the stale bridge-start fusion_info snapshot) so a launch right@@ -1253,6 +1362,11 @@ COMMAND_HANDLERS = {     "dismiss_recovery": _handle_dismiss_recovery,     "relocate_recovery": _handle_relocate_recovery,     "open_cloud_file": _handle_open_cloud_file,+    # Import a legacy EAGLE .sch (+ .brd) into a NEW populated Fusion electronics design via+    # Fusion's own ImportSCHAndBRDCmd (drives both Open dialogs in the background). This is the+    # ONLY way to author a board from EAGLE source - newDesignFromLocal opens the editor but does+    # NOT instantiate parts, and the .fsch/.fbrd binary container can't be built offline.+    "new_electronics_from_eagle": lambda fi, args: _handle_new_electronics_from_eagle(args),     "screenshot_fusion": _handle_screenshot_fusion,     "click_fusion": _handle_click_fusion,     "send_key": _handle_send_key,@@ -1891,6 +2005,7 @@ def _orchestrate_attach_3d_package(args: dict) -> dict:     file_path = (args.get("filePath") or "").replace("\\", "/")     model_path = (args.get("modelPath") or "").replace("\\", "/")     package = args.get("packageName", "")+    orient_flag = args.get("orient", True)  # 2026-07-07: expose orient (was hard-on)     if not file_path or not model_path:         return {"success": False,                 "error": "filePath (.lbr) and modelPath (.step) are required (Windows paths, e.g. C:/...).",@@ -1916,7 +2031,15 @@ def _orchestrate_attach_3d_package(args: dict) -> dict:         f"im.importToTarget(im.createSTEPImportOptions('{model_path}'), root)\n"         "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"-        "axis=(1,0,0) if (dy<=dx and dy<=dz) else ((0,1,0) if (dx<=dy and dx<=dz) else None)\n"+        # Tall-part guard (2026-07-07): keep a part vertical if its largest dim is already Z; only+        # flatten a clearly-thin part whose thin axis isn't Z. Was: always tip smallest dim to Z,+        # which laid tall through-hole pins on their side. Honors the orient flag (default True).+        + ("axis=None\n" if not orient_flag else+           "thin=min(dx,dy,dz); big=max(dx,dy,dz)\n"+           "tall_z=(dz>=dx and dz>=dy)\n"+           "axis=None\n"+           "if (thin < 0.5*big) and not tall_z:\n"+           "    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(); rot=adsk.core.Matrix3D.create()\n"         "    rot.setToRotation(math.pi/2, adsk.core.Vector3D.create(*axis), adsk.core.Point3D.create(0,0,0))\n"@@ -2149,8 +2272,17 @@ def _orchestrate_make_3d_package(args: dict) -> dict:             pass         _time.sleep(0.4)         before_shot = _capture_labeled(f"{cap_label}_before")+    # AUTO-ORIENT (fixed 2026-07-07): the old logic always rotated the SMALLEST bbox dim to Z,+    # which is right for a flat SMD chip lying down but TIPS A TALL THROUGH-HOLE PART (machine pin,+    # connector) onto its side - its long axis is already Z and must stay vertical. Now: keep the+    # part vertical if its largest dim is already Z (tall_z), and only flatten a clearly THIN part+    # whose thin axis isn't Z yet. A caller can still force orient:false to skip entirely.     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"+        "thin=min(dx,dy,dz); big=max(dx,dy,dz)\n"+        "tall_z=(dz>=dx and dz>=dy)\n"+        "is_flat=(thin < 0.5*big)\n"+        "axis=None\n"+        "if is_flat and not tall_z:\n 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"         " r.setToRotation(math.pi/2, adsk.core.Vector3D.create(*axis), adsk.core.Point3D.create(0,0,0))\n"         " mat.transformBy(r); oc.transform2=mat\n"@@ -2283,6 +2415,10 @@ def _orchestrate_build_library_3d(args: dict) -> dict:             "projectId": project_id, "folderId": folder_id,             "fileName": f"{pkg}_3d", "task": task,             "captureLabel": (pkg if capture else None),+            # Per-part orient passthrough (2026-07-07): a caller can set orient:false on a part+            # whose STEP is already correctly oriented (e.g. a tall through-hole pin). Defaults+            # to True, and make_3d_package's tall-part guard now keeps tall parts vertical anyway.+            "orient": p.get("orient", True),         }         mk = _orchestrate_make_3d_package(mk_args)         # Retry once: the FIRST part of a run often fails transiently while Fusion settles from a