app
Fusion - the Fusion 360 Bridge
Public Made by Adomby adom
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.
← Commit history
Update 13 file(s)
13 files changed
+210−22
BRIDGE_VERSION+1−1@@ -1 +1 @@-1.13.83+1.13.84
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" ]-}+}
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()