← Commit history
BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.9.287\ No newline at end of file+1.9.288\ No newline at end of file
adom-bridge-fusion-manifest.json+4−4
@@ -1,10 +1,10 @@ {   "manifest_version": 1,   "name": "fusion360",-  "version": "1.9.287",-  "url": "https://wiki.adom.inc/download/adom/fusion-bridge/1.9.287/adom-bridge-fusion-v1.9.287.zip",-  "sha256": "5d1a0c8c8a168f5c766fe30d4a20b1cbd3b95fac28f027d5cae7af5bad8a4b06",-  "size": 578906,+  "version": "1.9.288",+  "url": "https://wiki.adom.inc/download/adom/fusion-bridge/1.9.288/adom-bridge-fusion-v1.9.288.zip",+  "sha256": "c603e074b5f0ac8276ea4235ec0f7262a3964779b553d87802fad08e79c65cc5",+  "size": 581068,   "verbPrefixes": [     "fusion_"   ],
bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "fusion360",   "displayName": "Autodesk Fusion 360",-  "version": "1.9.287",+  "version": "1.9.288",   "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/fusion-bridge",   "author": "Adom Inc.",
dashboard/Cargo.lock+1−1
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "fusion-dashboard"-version = "1.9.287"+version = "1.9.288" dependencies = [  "serde_json", ]
dashboard/Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.9.287"+version = "1.9.288" edition = "2021"  [dependencies]
install.sh+1−1
@@ -21,7 +21,7 @@ echo "Installed fusion-bridge skills: $(ls "$PKG_DIR/skills" 2>/dev/null | tr '\ # binary (the three-artifact pattern: bridge zip -> ab, THIS pkg -> container skills + # this download, page media -> wiki). Per the wiki's PKG_VS_RELEASE policy, binaries are # platform-tagged RELEASE assets, never tarball content - the installer fetches them.-DASH_VERSION="1.9.287"+DASH_VERSION="1.9.288" DASH_URL="https://wiki.adom.inc/download/adom/fusion-bridge/${DASH_VERSION}/fusion-dashboard" DEST="" if [ -w /usr/local/bin ]; then DEST=/usr/local/bin
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "fusion-bridge",-  "version": "1.9.287",+  "version": "1.9.288",   "type": "app",   "description": "This package installs the bridge's SKILLS into your container so your AI knows how to drive it; Adom Bridge loads the bridge runtime itself from the release zip.",   "dependencies": {
page.json+11−8
@@ -1,6 +1,6 @@ {   "slug": "fusion-bridge",-  "version": "1.9.287",+  "version": "1.9.288",   "type": "app",   "description": "This package installs the bridge's SKILLS into your container so your AI knows how to drive it; Adom Bridge loads the bridge runtime itself from the release zip.",   "dependencies": {@@ -11,6 +11,10 @@     "uninstall": "./uninstall.sh"   },   "tags": [+    "ab",+    "bridge",+    "fusion",+    "desktop-app",     "fusion360",     "autodesk",     "cad",@@ -75,7 +79,10 @@     "build a basic parts library",     "add a 3d model to a component",     "make a fusion 360 library with 3d",-    "set up the fusion bridge"+    "set up the fusion bridge",+    "fusion dashboard",+    "fusion status page",+    "open the fusion launch page"   ],   "discovery_pitch": "Use the Adom Bridge Fusion 360 bridge when the user wants to DRIVE Autodesk Fusion 360 from the cloud: open/search their team's Fusion cloud designs, lay out a PCB or do electronics, export STEP/IGES/STL/Gerbers/BOM, or build a component library with real rendering 3D. It runs Fusion on the user's own machine via Adom Bridge. NOT for web/browser/login/form-filling (that's the native-browser extension) and NOT for KiCad (that's the kicad bridge).",   "brief": "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.",@@ -104,9 +111,5 @@       "label": "Part volume",       "prompt": "How much material does each printed part in this assembly use?"     }-  ],-  "author": {-    "name": "John Lauer",-    "email": "[email protected]"-  }-}\ No newline at end of file+  ]+}
server.py+123−6
@@ -3835,9 +3835,12 @@ def _orchestrate_attach_3d_package(args: dict) -> dict:         # body name hid nothing and 16 via rings showed on the 3D PCB). Match the occurrence         # AND the body names, and switch off both the occurrence and each body.         "names=[]; hidden=[]\n"+        # ALL LAYERS ON BY DEFAULT (John, 2026-08-30): with package VARIANTS, a STEP bound to a+        # package is exactly what that package should show (the -HV model IS the vias), so+        # nothing is hidden unless the caller names a group in layersOff.         "def _layer(n):\n"         "    n=(n or '').split(':')[0]\n"-        "    return n.startswith('Adom.') and n!='Adom.Chip' and n not in LAYERS_ON\n"+        "    return n.startswith('Adom.') and n in LAYERS_OFF\n"         "def _walk(comp, under):\n"         "    for i in range(comp.bRepBodies.count):\n"         "        b=comp.bRepBodies.item(i); names.append((under or '')+'/'+b.name)\n"@@ -3852,7 +3855,7 @@ def _orchestrate_attach_3d_package(args: dict) -> dict:         "            try: o.isLightBulbOn=False\n"         "            except Exception: pass\n"         "        _walk(o.component, lay)\n"-        f"LAYERS_ON=set({sorted(set(str(x) for x in (args.get('layersOn') or [])))!r})\n"+        f"LAYERS_OFF=set({sorted(set(str(x) for x in (args.get('layersOff') or [])))!r})\n"         "try:\n"         "    _walk(oc.component, '')\n"         "except Exception as _e:\n"@@ -4029,6 +4032,41 @@ def _inject_package3d_bindings(lbr_text: str, bindings: list) -> str:     return _re.sub(r'<device\b[^>]*>.*?</device>', _dev_repl, lbr_text, flags=_re.S)  +def _pad1_xy_mm(lbr_path: str):+    """(x, y) in mm of pad/smd named "1" in the FIRST package of the .lbr, or None."""+    try:+        t = open(lbr_path, "r", encoding="utf-8", errors="replace").read()+        m = re.search(r"<package name=\"[^\"]*\">(.*?)</package>", t, re.S)+        body = m.group(1) if m else t+        pm = re.search(r'<(?:smd|pad) name="1" x="([-0-9.]+)" y="([-0-9.]+)"', body)+        if not pm:+            return None+        return (float(pm.group(1)), float(pm.group(2)))+    except Exception:+        return None+++def _step_layer_story(bodies) -> str:+    """What makes this STEP different from a chip-only model, for the palette (John,+    2026-08-30: "tell them in the palette the nuances of what makes that step unique")."""+    try:+        groups = {}+        for b in bodies or []:+            g = str(b).split("/")[0]+            if g.startswith("Adom.") and g != "Adom.Chip":+                groups[g] = groups.get(g, 0) + 1+        if not groups:+            return "chip body only (the baseline model)"+        words = {"Adom.HeatsinkVias": "heatsink via barrels under the exposed pad",+                 "Adom.SolderBalls": "solder-ball hemispheres sitting on the pads",+                 "Adom.LaserEtch": "the laser-etched part number",+                 "Adom.Board": "board stock", "Adom.Pads": "rendered pads"}+        parts = ["%d %s" % (n, words.get(g, g[5:])) for g, n in sorted(groups.items())]+        return "unlike the chip-only model, this one carries " + " and ".join(parts) + ", all left visible"+    except Exception:+        return ""++ 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,@@ -4130,6 +4168,56 @@ def _orchestrate_make_3d_package(args: dict) -> dict:         " 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"     ) if orient else ""+    # PIN 1 (John, 2026-08-30: "the NUMBER ONE PROBLEM ee's expect us to solve"): the LAN7800+    # footprint had pad 1 bottom-left while the chip body's pin-1 dimple sat top-left. Neither+    # file is trusted: pad 1's position is read from the .lbr, the chip's pin-1 mark is found on+    # the imported body (the small circle on the top face nearest a corner), and when the two+    # sit in different corners the chip is rotated about Z by the multiple of 90 degrees that+    # puts its mark over pad 1. The result records what was found and what was done, and the+    # palette says it, so a rotation is never silent.+    _pin1 = _pad1_xy_mm(lbr_path)+    pin1_block = (+        f"PIN1={(_pin1[0] / 10.0, _pin1[1] / 10.0) if _pin1 else None!r}\n"+        "res_pin1={'pad1_mm': " + (f"[{_pin1[0]},{_pin1[1]}]" if _pin1 else "None") + ", 'mark_mm': None, 'rotatedDeg': 0, 'status': 'unchecked'}\n"+        "def _mark(occ):\n"+        "    bb2=occ.boundingBox; zmax=bb2.maxPoint.z; best=None\n"+        "    def _w(comp, depth):\n"+        "        nonlocal best\n"+        "        if depth>6: return\n"+        "        for i in range(comp.bRepBodies.count):\n"+        "            b=comp.bRepBodies.item(i)\n"+        "            try: vol=b.volume\n"+        "            except Exception: vol=0\n"+        "            if vol*1000 < 5: continue\n"   # the chip body, not a via or a ball+        "            for e in b.edges:\n"+        "                try:\n"+        "                    g=e.geometry\n"+        "                    if g.objectType not in (adsk.core.Circle3D.classType(), adsk.core.Arc3D.classType()): continue\n"+        "                    c=g.center\n"+        "                    if abs(c.z-zmax)>0.03 or g.radius>0.1: continue\n"+        "                    score=abs(c.x)+abs(c.y)\n"+        "                    if best is None or score>best[0]: best=(score,c.x,c.y)\n"+        "                except Exception: pass\n"+        "        for j in range(comp.occurrences.count): _w(comp.occurrences.item(j).component, depth+1)\n"+        "    _w(occ.component, 0)\n"+        "    return None if best is None else (best[1],best[2])\n"+        "try:\n"+        "    mk=_mark(oc)\n"+        "    if mk: res_pin1['mark_mm']=[round(mk[0]*10,2), round(mk[1]*10,2)]\n"+        "    if mk and PIN1:\n"+        "        import math as _m\n"+        "        want=_m.atan2(PIN1[1],PIN1[0]); have=_m.atan2(mk[1],mk[0])\n"+        "        k=int(round(((want-have)/(_m.pi/2)))) % 4\n"+        "        if k:\n"+        "            mat=oc.transform2.copy(); r=adsk.core.Matrix3D.create()\n"+        "            r.setToRotation(k*_m.pi/2, adsk.core.Vector3D.create(0,0,1), adsk.core.Point3D.create(0,0,0))\n"+        "            mat.transformBy(r); oc.transform2=mat\n"+        "            res_pin1['rotatedDeg']=k*90\n"+        "        res_pin1['status']='rotated' if k else 'aligned'\n"+        "    elif PIN1: res_pin1['status']='no_mark_on_chip'\n"+        "    else: res_pin1['status']='no_pad1_in_lbr'\n"+        "except Exception as _e: res_pin1['status']='error: '+str(_e)[:80]\n"+    )     # 3. import the chip ONTO the footprint in the generator doc, orient, saveAs as f3d (footprint+chip)     script = (         "import adsk.core, adsk.fusion, math\n"@@ -4139,7 +4227,7 @@ def _orchestrate_make_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"-        + orient_block ++        + orient_block + pin1_block +         "try:\n app.activeViewport.fit()\nexcept: pass\n"         # FOLDER HYGIENE: saveAs into an 'Adom AI Workspace'/<task> subfolder, NEVER the project root.         # activeProject is None on a fresh box until the user touches the data panel, so fall@@ -4157,9 +4245,12 @@ def _orchestrate_make_3d_package(args: dict) -> dict:         # body name hid nothing and 16 via rings showed on the 3D PCB). Match the occurrence         # AND the body names, and switch off both the occurrence and each body.         "names=[]; hidden=[]\n"+        # ALL LAYERS ON BY DEFAULT (John, 2026-08-30): with package VARIANTS, a STEP bound to a+        # package is exactly what that package should show (the -HV model IS the vias), so+        # nothing is hidden unless the caller names a group in layersOff.         "def _layer(n):\n"         "    n=(n or '').split(':')[0]\n"-        "    return n.startswith('Adom.') and n!='Adom.Chip' and n not in LAYERS_ON\n"+        "    return n.startswith('Adom.') and n in LAYERS_OFF\n"         "def _walk(comp, under):\n"         "    for i in range(comp.bRepBodies.count):\n"         "        b=comp.bRepBodies.item(i); names.append((under or '')+'/'+b.name)\n"@@ -4174,7 +4265,7 @@ def _orchestrate_make_3d_package(args: dict) -> dict:         "            try: o.isLightBulbOn=False\n"         "            except Exception: pass\n"         "        _walk(o.component, lay)\n"-        f"LAYERS_ON=set({sorted(set(str(x) for x in (args.get('layersOn') or [])))!r})\n"+        f"LAYERS_OFF=set({sorted(set(str(x) for x in (args.get('layersOff') or [])))!r})\n"         "try:\n"         "    _walk(oc.component, '')\n"         "except Exception as _e:\n"@@ -4232,7 +4323,17 @@ def _orchestrate_make_3d_package(args: dict) -> dict:         " if proj is None: raise RuntimeError('no usable cloud project on this account')\n"         " rf=proj.rootFolder\n"         f" wsf=_sub(_sub(rf,'Adom AI Workspace'),'{task}')\n"-        f" doc.saveAs('{name}', wsf, '3d package (footprint+chip)', '')\n"+        "res['pin1']=res_pin1\n"+        # A same-named f3d already in the folder (a re-run of the same package) makes saveAs+        # fail with CB_NAE ("another object with the same name already exists"); seen on the+        # LAN7800 variant run 2026-08-30. Retry once under a time-stamped name.+        " try:\n"+        f"  doc.saveAs('{name}', wsf, '3d package (footprint+chip)', '')\n"+        " except Exception as _se:\n"+        "  if 'CB_NAE' in str(_se) or 'same name' in str(_se):\n"+        f"   import time as _t0; _nm='{name}-'+_t0.strftime('%m%d%H%M%S')\n"+        "   doc.saveAs(_nm, wsf, '3d package (footprint+chip)', ''); res['renamedTo']=_nm\n"+        "  else: raise\n"         # A first-ever cloud save on a fresh install syncs ASYNCHRONOUSLY: right after         # saveAs, dataFile.id can still be the local cache path; the lineage urn appears         # once the upload lands. Wait for it (bounded well under the 120s script timeout).@@ -4252,6 +4353,8 @@ 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+    pin1 = res.get("pin1") if isinstance(res, dict) else None+    bodies = res.get("bodies") 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     # doc.saveAs into a DataFolder IS a cloud save: dataFile.id returns the LINEAGE urn@@ -4267,6 +4370,7 @@ def _orchestrate_make_3d_package(args: dict) -> dict:             except Exception:                 pass         return {"success": True, "wip_urn": wip, "lineageUrn": f3d_path, "dims": dims,+                "pin1": pin1, "bodies": bodies, "renamedTo": res.get("renamedTo"),                 "fileName": f3d_name, "savedTo": "cloud (Adom AI Workspace)",                 "before": before_shot, "after": after_shot,                 "_hint": "Saved DIRECTLY to the cloud workspace - no upload step needed. "@@ -4715,7 +4819,20 @@ def _build_library_3d_core(args: dict) -> dict:         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")+            entry["pin1"] = mk.get("pin1"); entry["stepStory"] = _step_layer_story(mk.get("bodies"))             bindings.append({"package": pkg, "wip_urn": mk["wip_urn"]})+            try:+                _p1 = mk.get("pin1") or {}+                _p1s = {"aligned": "pin 1 verified: the chip's mark sits over pad 1",+                        "rotated": "pin 1: rotated the chip %s deg so its mark sits over pad 1" % _p1.get("rotatedDeg"),+                        "no_mark_on_chip": "pin 1: no mark found on the chip body, alignment not checked",+                        "no_pad1_in_lbr": "pin 1: no pad 1 in the footprint, alignment not checked"}.get(+                            _p1.get("status") or "", "")+                _banner("update", title="%s: bound 3D, part %d of %d" % (_lib_label(combined), i, total),+                        detail="%s. %s. %s" % (_named(pkg, _descs_sync), entry["stepStory"], _p1s),+                        percent=100.0 * i / max(1, total) * 0.4, eta_sec=None)+            except Exception:+                pass         else:             entry["error"] = mk.get("error")         if mk.get("before"):