← Commit history
BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.9.6\ No newline at end of file+1.9.7\ No newline at end of file
addin/AdomBridge/AdomBridge.manifest+1−1
@@ -6,7 +6,7 @@     "description": {         "": "Adom Desktop Conduit bridge add-in. Exposes a localhost HTTP API for external automation of Fusion 360."     },-    "version": "1.0.5",+    "version": "1.0.6",     "runOnStartup": true,     "supportedOS": "windows" }\ No newline at end of file
addin/AdomBridge/commands/assembly_bom.py+33−1
@@ -9,6 +9,7 @@ double-counted the way a flat allOccurrences walk would count it. """  import csv+import time  import adsk.core import adsk.fusion@@ -52,6 +53,24 @@ def handle_assembly_bom(app: adsk.core.Application, args: dict) -> dict:                 "_hint": "Open a mechanical design in the Design workspace, then retry."}      args = args or {}+    # Walk budget (issue #659, found by the daily verb suite): an electronics 3D PCB carries an+    # occurrence per pad, so the walk that is instant on mechanical assemblies grinds for minutes,+    # blows the relay window, and blocks the add-in main thread for every later verb. Refuse FAST+    # with a redirect instead: the mechanical walk is the wrong tool for a board anyway.+    budget = float(args.get("maxSeconds", 20))+    deadline = time.time() + budget+    try:+        occ_total = design.rootComponent.allOccurrences.count+    except Exception:+        occ_total = -1+    if occ_total > 3000:+        return {"success": False, "errorCode": "bom_walk_budget_exceeded",+                "occurrences": occ_total,+                "error": "This design has %d occurrences - walking it would block Fusion for minutes." % occ_total,+                "_hint": "This looks like an electronics 3D PCB (an occurrence per pad), and the mechanical "+                         "BOM walk is the wrong tool for a board: use fusion_export_bom for the electronics "+                         "BOM. For a genuinely huge MECHANICAL assembly, re-run with maxSeconds raised "+                         "(e.g. {\"maxSeconds\": 120}) and expect a long wait."}     kit = [k.lower() for k in (args.get("treatAsUnit") or ["with fasteners", "with fastener"])]     exclude = tuple(args.get("exclude") or [])     include_pp = bool(args.get("includePhysicalProperties", False))@@ -78,8 +97,13 @@ def handle_assembly_bom(app: adsk.core.Application, args: dict) -> dict:     sub_stats = {}   # subassembly name -> {part name -> instances}     sub_stack = [] +    class _BudgetExceeded(Exception):+        pass+     def walk(occs):         for i in range(occs.count):+            if time.time() > deadline:+                raise _BudgetExceeded()             occ = occs.item(i)             comp = occ.component             name = comp.name@@ -122,7 +146,15 @@ def handle_assembly_bom(app: adsk.core.Application, args: dict) -> dict:                 walk(children)                 sub_stack.pop() -    walk(design.rootComponent.occurrences)+    try:+        walk(design.rootComponent.occurrences)+    except _BudgetExceeded:+        return {"success": False, "errorCode": "bom_walk_budget_exceeded",+                "occurrences": occ_total, "budgetSeconds": budget,+                "partialParts": len(parts),+                "error": "BOM walk exceeded its %ds budget (%d part types found so far)." % (budget, len(parts)),+                "_hint": "On an electronics board use fusion_export_bom instead; on a huge mechanical "+                         "assembly re-run with a larger maxSeconds."}     rows = list(parts.values())      doc_name = design.rootComponent.name
adom-bridge-fusion-manifest.json+4−4
@@ -1,10 +1,10 @@ {     "manifest_version": 1,     "name": "fusion360",-    "version": "1.9.6",-    "url": "https://wiki.adom.inc/download/adom/fusion-bridge/1.9.6/adom-bridge-fusion-v1.9.6.zip",-    "sha256": "b0a03bf579dd46e26267e248285fe0cd381771a6793bcf58a80b9ed4209b36e3",-    "size": 304432,+    "version": "1.9.7",+    "url": "https://wiki.adom.inc/download/adom/fusion-bridge/1.9.7/adom-bridge-fusion-v1.9.7.zip",+    "sha256": "966131c4d4fdf838be5a9055907f5cab846c4956b35e017d9235be2f5f2c15e3",+    "size": 305121,     "verbPrefixes": [         "fusion_"     ],
bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "fusion360",   "displayName": "Autodesk Fusion 360",-  "version": "1.9.6",+  "version": "1.9.7",   "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.",
describe.py+1−1
@@ -157,7 +157,7 @@ _T = [      {"result": "obj", "documentName": "str"}, 180, None, True, {"script": "import adsk.fusion\n# ...build geometry..."}),      ("fusion_assembly_bom", "Structured, kit-aware MECHANICAL bill of materials for the active Design assembly (the counterpart to the electronics-only fusion_export_bom). Recurses organizational subassemblies but counts a physical part or a purchased kit/unit ONCE, so hardware modeled inside a kit (a '... with fasteners' bracket, a bearing/pulley) is NOT double-counted the way a flat allOccurrences walk counts it. Matches Fusion's Manage -> BOM quantities. Optionally writes a CSV.",-     {"treatAsUnit": "optional [str] name substrings counted as one unit (default ['with fasteners'])", "exclude": "optional [str] top-level name prefixes to skip", "includePhysicalProperties": "optional bool (adds volume_cm3/mass_kg per line)", "outputPath": "optional str (Windows CSV path)"},+     {"maxSeconds": "optional float (walk budget, default 20; the verb REFUSES fast with bom_walk_budget_exceeded + a fusion_export_bom redirect on electronics 3D PCBs instead of blocking Fusion for minutes - issue #659)", "treatAsUnit": "optional [str] name substrings counted as one unit (default ['with fasteners'])", "exclude": "optional [str] top-level name prefixes to skip", "includePhysicalProperties": "optional bool (adds volume_cm3/mass_kg per line)", "outputPath": "optional str (Windows CSV path)"},      {"success": "bool", "design": "str", "partCount": "int", "totalInstances": "int", "parts": "[{componentName, partNumber, description, material, quantity, bodies}]"},      120, None, False, {"treatAsUnit": ["with fasteners"], "includePhysicalProperties": True}), 
tests/run_verb_tests.py+16−3
@@ -21,7 +21,7 @@ import argparse, datetime, json, os, pathlib, subprocess, sys, time REPO = pathlib.Path(__file__).resolve().parent.parent sys.path.insert(0, str(REPO)) import describe  # noqa: E402-CATALOG_TIMEOUTS = {v[0]: v[3] for v in describe._T}+CATALOG_TIMEOUTS = {v[0]: v[4] for v in describe._T}  THREAD = "fusion-verb-tests" SCRATCH = "C:/Users/john/AppData/Local/Temp/fusion-verb-tests"@@ -70,6 +70,11 @@ def expect(d, spec):         if got and (got - planned): errs.append("verbs described but UNTRIAGED in test plan: %s" % sorted(got - planned)[:6])     if spec.get("pred") == "must_refuse" and not (d.get("success") is False or d.get("error")):         errs.append("expected a refusal, got success")+    if spec.get("pred") == "ok_or_budget":+        ok = (d.get("success") is True and all(f in d for f in ("parts", "collapsedUnits")))+        budget = d.get("errorCode") == "bom_walk_budget_exceeded"+        if not (ok or budget):+            errs.append("expected a BOM or the budget refusal, got error=%s code=%s" % (str(d.get("error"))[:60], d.get("errorCode")))     if d.get("_testTimeout"): errs.append("timed out")     return errs @@ -131,6 +136,8 @@ t("fusion_mcp_enable", "manual", note="drives the prefs UI, takes foreground") t("fusion_prefs_open", "manual", note="opens a dialog on the user's screen") t("fusion_prefs_close", "manual") t("fusion_set_auto_update", "daily", {}, {"ok": True}, "no args = read-only")+t("fusion_assembly_bom", "daily", {}, {"pred": "ok_or_budget"},+  "either a BOM (small doc) or the fast bom_walk_budget_exceeded refusal (3D PCB) - both are the verb working as designed") t("fusion_dismiss_blocking_dialogs", "daily", {}, {"ok": True}, "no-op when nothing is blocking") t("fusion_detect_layers", "weekly", {}, {"ok": True}, "board view active") t("fusion_fetch_optimized_glb", "weekly", {}, {"ok": True}, "runs the stackup GLB fetch")@@ -142,9 +149,12 @@ t("fusion_open_cloud_file", "daily", dict(OPEN, **{"_pollDoc": "BQ25792", "_poll t("fusion_show_schematic", "daily", {}, {"ok": True}) t("fusion_show_2d_board", "daily", {}, {"ok": True}) t("fusion_board_info", "daily", {}, {"ok": True, "fields": ["rawXml"]}, "needs the 2D board view ACTIVE - ordering matters")+t("fusion_execute_text_command", "daily", {"command": "Electron.ImmediateRepaint /full", "expectDocument": "BQ25792"}, {"ok": True},+  "Electron.* needs the ELECTRONICS editor context - must run in the 2D stage") t("fusion_show_3d_board", "daily", {}, {"ok": True}) t("fusion_inspect_bodies", "daily", {}, {"ok": True, "fields": ["bodies", "units"]})-t("fusion_assembly_bom", "daily", {}, {"ok": True, "fields": ["parts", "collapsedUnits", "possibleUnderCollapse"]})+t("fusion_assembly_bom#realboard", "weekly", {}, {"pred": "ok_or_budget"},+  "on the open BQ25792 3D PCB the budget guard must refuse FAST - the grind was issue #659") t("fusion_physical_properties", "daily", {}, {"ok": True}) t("fusion_get_design_info", "daily", {}, {"ok": True}) t("fusion_get_parameters", "daily", {}, {"ok": True})@@ -158,7 +168,6 @@ t("fusion_assembly_bom#guard", "negative",   {"expectDocument": "ZZZ_NoSuchDoc_TEST"}, {"errorCode": "wrong_document"}) t("fusion_run_modeling_script", "daily",   {"script": "result = {'ok': True}", "expectDocument": "BQ25792"}, {"ok": True})-t("fusion_execute_text_command", "daily", {"command": "Electron.ImmediateRepaint /full", "expectDocument": "BQ25792"}, {"ok": True}, "benign repaint used by electron_view.py itself") t("fusion_electron_run", "weekly", {"command": "WINDOW FIT;"}, {"ok": True}, "board view must be active") t("fusion_electron_zoom", "weekly", {"factor": 1.0}, {"ok": True}) t("fusion_electron_pan", "weekly", {"dx": 0, "dy": 0}, {"ok": True})@@ -212,6 +221,10 @@ def main():              "all": {"daily", "weekly", "negative"}}[a.tier]     only = set(x.strip() for x in a.only.split(",") if x.strip()) +    print("warmup: waking the bridge (unscored)...")+    call(a.target, "fusion_get_app_state", {}, timeout=45)+    call(a.target, "fusion_get_app_state", {}, timeout=45)+     described = {v[0] for v in describe._T}     untriaged = described - {k.split("#")[0] for k in PLAN}     results, t0 = [], time.time()