← Commit history
BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.13.83\ No newline at end of file+1.13.84\ No newline at end of file
CHANGELOG.md+4
@@ -3,6 +3,10 @@ All notable changes to the Adom Bridge Fusion 360 bridge, newest first. Canonical: https://wiki.adom.inc/adom/fusion-bridge ยท GitHub backup: `adom-inc/fusion-bridge` +## 1.13.84 (2026-11-06)++`render_board_3d` with `quality:"high"` really stops a render that runs past `renderSeconds` (found by Drew Owens on PR #40). It called `RenderFuture.cancel()`, which does not exist; the error was swallowed and the reply said "it was cancelled" while the render kept going and could write `outputPath` later. Now it ends that render's own RenderProcess.exe through the same `render_process.cancel` as `render_image` (embedded in the electronics script), renders into a staging folder and moves the image to `outputPath` only on success, so an existing image is no longer deleted first and a late image can never land there. The reply carries `cancel: {cancelled, renderState, reason}` and says plainly when a render could not be stopped. Exe only: no Fusion restart.+ ## 1.13.83 (2026-11-05)  `fusion_save_to_cloud` makes the missing folders on `folderPath` for a mechanical design too, as its description says: a save to `fusion-lane-tests/drawings` answered "Folder 'drawings' not found" when `drawings` did not exist yet. Add-in 1.0.41: Fusion needs one restart.
bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "fusion360",   "displayName": "Autodesk Fusion 360",-  "version": "1.13.83",+  "version": "1.13.84",   "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.13.83"+version = "1.13.84" dependencies = [  "serde_json", ]
dashboard/Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.13.83"+version = "1.13.84" 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.13.83"+DASH_VERSION="1.13.84" 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.13.83",+  "version": "1.13.84",   "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+2−2
@@ -1,6 +1,6 @@ {   "slug": "fusion-bridge",-  "version": "1.13.83",+  "version": "1.13.84",   "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": {@@ -152,4 +152,4 @@   "confirmed_user_skills": [     "fusion-design-efficiency"   ]-}\ No newline at end of file+}
rust/Cargo.lock+5−5
@@ -19,7 +19,7 @@ dependencies = [  [[package]] name = "adom-fusion-bridge"-version = "1.13.83"+version = "1.13.84" dependencies = [  "adom-eda-checks",  "base64",@@ -299,7 +299,7 @@ dependencies = [  [[package]] name = "fusion-core"-version = "1.13.83"+version = "1.13.84" dependencies = [  "serde",  "serde_json",@@ -310,7 +310,7 @@ dependencies = [  [[package]] name = "fusion-lbr"-version = "1.13.83"+version = "1.13.84" dependencies = [  "regex",  "roxmltree",@@ -322,14 +322,14 @@ dependencies = [  [[package]] name = "fusion-os-mac"-version = "1.13.83"+version = "1.13.84" dependencies = [  "fusion-core", ]  [[package]] name = "fusion-os-win"-version = "1.13.83"+version = "1.13.84" dependencies = [  "fusion-core",  "windows-sys 0.59.0",
rust/Cargo.toml+1−1
@@ -4,7 +4,7 @@ members = ["core", "lbr", "os-win", "os-mac", "bridge"]  # One version for the whole bridge, in lockstep with BRIDGE_VERSION (tools/release.sh bumps both). [workspace.package]-version = "1.13.83"+version = "1.13.84" edition = "2021" license = "MIT" 
rust/bridge/src/docs.rs+4−1
@@ -1,7 +1,10 @@ //! document_info and close_all_documents in the EDA Bridge Standard's shape (docs_script.py on //! Fusion's main thread). document_info keeps the add-in's reply and adds the standard's fields. use serde_json::{json, Value};-const SOURCE: &str = include_str!("docs_script.py");+// render_process.py (the mechanical layer's render cancel) is embedded as text and loaded as its own module+// inside the script, so render_board_3d can end a timed-out render the same way render_image does+const SOURCE: &str = concat!(include_str!("docs_script.py"), "\nRENDER_PROCESS_SOURCE = r'''",+    include_str!("../../../addin/AdomBridge/commands/mechanical/render_process.py"), "'''\n"); pub fn owns(cmd: &str) -> bool { matches!(cmd, "document_info" | "close_all_documents" | "render_board_3d" | "read_footprint" | "project_info" | "project_snapshot" | "project_rename" | "export_pdf") } fn script(cmd: &str, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {     let s = format!("import json\nexec({})\nresult=handle(app,json.loads({}),{})\n",
rust/bridge/src/docs_script.py+40−7
@@ -108,6 +108,20 @@ SIDES = {'top': 'TopViewOrientation', 'bottom': 'BottomViewOrientation', 'left':          'front': 'FrontViewOrientation', 'back': 'BackViewOrientation', 'iso': 'IsoTopRightViewOrientation'}  +def tempfile_dir():+    import tempfile+    return tempfile.gettempdir()+++def _render_process():+    """render_process.py, embedded by docs.rs as RENDER_PROCESS_SOURCE, run as its own module each call (never+    cached in sys.modules, so a bridge update reaches it without a Fusion restart)."""+    import types+    mod = types.ModuleType('adom_render_process')+    exec(RENDER_PROCESS_SOURCE, mod.__dict__)+    return mod++ def render_board_3d(app, args):     """A 3D picture of the board from one side, through Fusion's own viewport, with the tab the     user had put back afterwards."""@@ -211,26 +225,45 @@ def render_board_3d(app, args):             d = adsk.fusion.Design.cast(app.activeProduct)             rendering = d.renderManager.rendering             keep = (rendering.aspectRatio, rendering.resolutionWidth, rendering.resolutionHeight, rendering.renderQuality, rendering.isBackgroundTransparent)-            if os.path.exists(out):-                os.remove(out)+            # Fusion writes into a staging folder of our own and the image is moved to `out` only on success,+            # so a render that outlives renderSeconds can never write `out` later (Drew Owens, PR #40 note)+            import uuid, shutil+            stage = os.path.join(tempfile_dir(), 'adom-fusion-render', uuid.uuid4().hex)+            os.makedirs(stage, exist_ok=True)+            staged = os.path.join(stage, os.path.basename(out))+            cancel = None             try:                 rendering.aspectRatio = adsk.fusion.RenderAspectRatios.CustomRenderAspectRatio                 rendering.resolutionWidth, rendering.resolutionHeight = w, h                 rendering.renderQuality = max(25, int(args.get('renderQuality', 25)))                 rendering.isBackgroundTransparent = transparent-                future = rendering.startLocalRender(out)+                started = time.time()+                future = rendering.startLocalRender(staged)                 deadline = time.time() + float(args.get('renderSeconds', 45))                 while future is not None and future.renderState != adsk.fusion.LocalRenderStates.FinishedLocalRenderState and time.time() < deadline:                     adsk.doEvents(); time.sleep(0.25)                 done = future is not None and future.renderState == adsk.fusion.LocalRenderStates.FinishedLocalRenderState                 if not done and future is not None:-                    try: future.cancel()-                    except Exception: pass+                    # RenderFuture has no cancel(): end this render's own RenderProcess.exe, as render_image does+                    cancel = _render_process().cancel(future, started, adsk.fusion.LocalRenderStates, pump=adsk.doEvents)             finally:                 rendering.aspectRatio, rendering.resolutionWidth, rendering.resolutionHeight, rendering.renderQuality, rendering.isBackgroundTransparent = keep             if not done:-                return {'success': False, 'errorCode': 'render_timeout', 'error': 'the ray-traced render did not finish in renderSeconds; it was cancelled (a smaller width/height or a larger renderSeconds helps)'}-            ok = os.path.exists(out)+                if cancel and cancel.get('cancelled'):+                    shutil.rmtree(stage, ignore_errors=True)+                    why = 'it was cancelled and wrote nothing'+                else:+                    why = ('it could not be cancelled (%s); if it finishes later its image stays in the staging folder %s, never at outputPath'+                           % ((cancel or {}).get('reason', 'no render job'), stage))+                return {'success': False, 'errorCode': 'render_timeout', 'cancel': cancel,+                        'error': 'the ray-traced render did not finish in renderSeconds; %s (a smaller width/height or a larger renderSeconds helps)' % why}+            ok = os.path.exists(staged)+            if ok:+                try:+                    os.replace(staged, out)+                except OSError:+                    shutil.move(staged, out)  # the staging folder is on another volume+            shutil.rmtree(stage, ignore_errors=True)         else:             opts = adsk.core.SaveImageFileOptions.create(out)             opts.width, opts.height = w, h
tests/test_render_board_3d.pyadded+148
@@ -0,0 +1,148 @@+"""render_board_3d's high-quality render (rust/bridge/src/docs_script.py), built the way docs.rs builds it: the+script plus render_process.py embedded as RENDER_PROCESS_SOURCE. A timed-out render used to call+RenderFuture.cancel(), which does not exist, and still answer "it was cancelled" while the render went on and+could write outputPath later (found by Drew Owens, PR #40). Now it renders into a staging folder, moves the image+only on success, and ends a timed-out render through render_process.cancel."""+import os+from pathlib import Path+import re+import sys+import tempfile+from types import ModuleType, SimpleNamespace as NS+import unittest+from unittest import mock++ROOT = Path(__file__).resolve().parents[1]+FINISHED, PROCESSING, FAILED, QUEUED = "finished", "processing", "failed", "queued"+++def build_source():+    """docs.rs's SOURCE: the script, then render_process.py as RENDER_PROCESS_SOURCE."""+    rs = (ROOT / "rust/bridge/src/docs.rs").read_text()+    assert 'include_str!("../../../addin/AdomBridge/commands/mechanical/render_process.py")' in rs+    assert re.search(r'RENDER_PROCESS_SOURCE = r\'\'\'"', rs)+    script = (ROOT / "rust/bridge/src/docs_script.py").read_text()+    embedded = (ROOT / "addin/AdomBridge/commands/mechanical/render_process.py").read_text()+    return script + "\nRENDER_PROCESS_SOURCE = r'''" + embedded + "'''\n"+++def fake_adsk():+    adsk = ModuleType("adsk")+    adsk.core = ModuleType("adsk.core")+    adsk.fusion = ModuleType("adsk.fusion")+    adsk.doEvents = lambda: None+    adsk.core.ViewOrientations = NS(TopViewOrientation=1)+    adsk.core.CameraTypes = NS(PerspectiveCameraType=1, OrthographicCameraType=2)+    adsk.fusion.LocalRenderStates = NS(FinishedLocalRenderState=FINISHED, ProcessingLocalRenderState=PROCESSING,+                                       FailedLocalRenderState=FAILED, QueuedLocalRenderState=QUEUED)+    adsk.fusion.RenderAspectRatios = NS(CustomRenderAspectRatio=9)+    return adsk+++class Future:+    def __init__(self, path, finish):+        self.path, self.finish, self.state = path, finish, PROCESSING+        if finish:+            Path(path).write_bytes(b"png")+            self.state = FINISHED++    @property+    def renderState(self):+        if self.state is None:+            raise RuntimeError("3 : render job doesn't exist")+        return self.state+++class RenderBoard3D(unittest.TestCase):+    def setUp(self):+        self.adsk = fake_adsk()+        patcher = mock.patch.dict(sys.modules, {"adsk": self.adsk, "adsk.core": self.adsk.core, "adsk.fusion": self.adsk.fusion})+        patcher.start()+        self.addCleanup(patcher.stop)+        self.ns = {}+        exec(compile(build_source(), "docs_script.py", "exec"), self.ns)+        folder = tempfile.TemporaryDirectory()+        self.addCleanup(folder.cleanup)+        self.dir = Path(folder.name)+        self.temp = self.dir / "temp"+        self.temp.mkdir()+        self.ns["tempfile_dir"] = lambda: str(self.temp)+        self.finish = True+        self.futures = []+        rendering = NS(aspectRatio=1, resolutionWidth=800, resolutionHeight=600, renderQuality=50, isBackgroundTransparent=False)++        def start(path):+            future = Future(path, self.finish)+            self.futures.append(future)+            return future+        rendering.startLocalRender = start+        self.rendering = rendering+        camera = NS(viewOrientation=None, cameraType=None, isFitView=False, isSmoothTransition=True, viewExtents=1.0)+        design_doc = NS(name="Board", isVisible=True)+        self.app = NS(activeDocument=design_doc, activeViewport=NS(camera=camera), activeProduct=NS(renderManager=NS(rendering=rendering)),+                      documents=NS(count=1, item=lambda i: design_doc), executeTextCommand=lambda text: None)+        self.ns["_design_3d"] = lambda app, args: (0, design_doc, None)+        self.adsk.fusion.Design = NS(cast=lambda product: product)++    def render(self, **args):+        return self.ns["render_board_3d"](self.app, {"quality": "high", "outputPath": str(self.dir / "board.png"), **args})++    def test_a_finished_render_is_moved_to_outputpath_and_the_staging_folder_goes(self):+        out = self.render()+        self.assertTrue(out["success"], out)+        self.assertEqual((self.dir / "board.png").read_bytes(), b"png")+        # Fusion was given a staging path, never outputPath itself+        self.assertNotEqual(self.futures[0].path, str(self.dir / "board.png"))+        self.assertTrue(self.futures[0].path.startswith(str(self.temp / "adom-fusion-render")))+        self.assertEqual(list((self.temp / "adom-fusion-render").iterdir()), [])+        # every render setting it touched is put back+        self.assertEqual((self.rendering.resolutionWidth, self.rendering.renderQuality), (800, 50))++    def test_an_existing_outputpath_is_replaced_only_on_success(self):+        (self.dir / "board.png").write_bytes(b"old")+        self.finish = False+        cancel = mock.Mock(return_value={"cancelled": True, "renderState": "removed", "reason": "process_ended"})+        with mock.patch.dict(self.ns, {"_render_process": lambda: NS(cancel=cancel)}):+            out = self.render(renderSeconds=0)+        self.assertEqual(out["errorCode"], "render_timeout")+        # the old image is still there: a timed-out render no longer deletes it first+        self.assertEqual((self.dir / "board.png").read_bytes(), b"old")++    def test_a_timed_out_render_is_cancelled_through_its_render_process(self):+        self.finish = False+        cancel = mock.Mock(return_value={"cancelled": True, "renderState": "removed", "reason": "process_ended"})+        with mock.patch.dict(self.ns, {"_render_process": lambda: NS(cancel=cancel)}):+            out = self.render(renderSeconds=0)+        self.assertFalse(out["success"])+        self.assertEqual(out["errorCode"], "render_timeout")+        self.assertIn("cancelled and wrote nothing", out["error"])+        self.assertEqual(out["cancel"]["reason"], "process_ended")+        future, started, states = cancel.call_args.args+        self.assertIs(future, self.futures[0])+        self.assertIs(states, self.adsk.fusion.LocalRenderStates)+        self.assertFalse((self.dir / "board.png").exists())+        self.assertEqual(list((self.temp / "adom-fusion-render").iterdir()), [])++    def test_a_render_that_cannot_be_cancelled_says_so_and_cannot_reach_outputpath(self):+        self.finish = False+        cancel = mock.Mock(return_value={"cancelled": False, "renderState": "queued", "reason": "queued_behind_another_render"})+        with mock.patch.dict(self.ns, {"_render_process": lambda: NS(cancel=cancel)}):+            out = self.render(renderSeconds=0)+        self.assertEqual(out["errorCode"], "render_timeout")+        self.assertNotIn("was cancelled", out["error"])+        self.assertIn("queued_behind_another_render", out["error"])+        # its late image would land in the staging folder named in the reply, never at outputPath+        late = Path(self.futures[0].path)+        self.assertIn(str(late.parent), out["error"])+        self.assertNotEqual(late, self.dir / "board.png")++    def test_the_embedded_render_process_loads_as_its_own_module(self):+        mod = self.ns["_render_process"]()+        self.assertTrue(callable(mod.cancel) and callable(mod.terminate))+        # its names stay in its own module, not the script's+        self.assertNotIn("terminate", self.ns)+        self.assertEqual(mod.children(), [] if os.name != "nt" else mod.children())+++if __name__ == "__main__":+    unittest.main()