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 14 file(s)
14 files changed
+61−23
BRIDGE_VERSION+1−1@@ -1 +1 @@-1.13.71+1.13.72
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.72 (2026-11-04)++`fusion_apply_silkscreen` moves and hides a part's name or value when the part is marked smashed but has no text of its own (#144, Glenn Jones). It trusted the flag, skipped SMASH, and MOVE and CHANGE then picked nothing.+ ## 1.13.71 (2026-11-04) From Glenn Jones's reports on a 4-layer board:
bridge.json+1−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "fusion360", "displayName": "Autodesk Fusion 360",- "version": "1.13.71",+ "version": "1.13.72", "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.71"+version = "1.13.72" dependencies = [ "serde_json", ]
dashboard/Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.13.71"+version = "1.13.72" 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.71"+DASH_VERSION="1.13.72" 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.71",+ "version": "1.13.72", "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.71",+ "version": "1.13.72", "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.71"+version = "1.13.72" dependencies = [ "adom-eda-checks", "base64",@@ -299,7 +299,7 @@ dependencies = [ [[package]] name = "fusion-core"-version = "1.13.71"+version = "1.13.72" dependencies = [ "serde", "serde_json",@@ -310,7 +310,7 @@ dependencies = [ [[package]] name = "fusion-lbr"-version = "1.13.71"+version = "1.13.72" dependencies = [ "regex", "roxmltree",@@ -322,14 +322,14 @@ dependencies = [ [[package]] name = "fusion-os-mac"-version = "1.13.71"+version = "1.13.72" dependencies = [ "fusion-core", ] [[package]] name = "fusion-os-win"-version = "1.13.71"+version = "1.13.72" 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.71"+version = "1.13.72" edition = "2021" license = "MIT"
rust/bridge/src/palette.rs+3−1@@ -58,7 +58,9 @@ fn worker() { let finished = FINISHED_SEQ.load(Ordering::Relaxed) == painted_seq; let idle_for = if finished { Duration::from_secs(3) } else { Duration::from_secs(25) }; if shown && !HOLD.load(Ordering::Relaxed) && painted_at.elapsed() > idle_for && SEQ.load(Ordering::Relaxed) == painted_seq && !run_in_flight() {- if send("hide", "", "", None, None)["success"].as_bool() == Some(true) { shown = false; *LAST.lock().unwrap() = None; }+ let r = send("hide", "", "", None, None);+ *LAST_RESULT.lock().unwrap() = Some(("hide".into(), String::new(), r["success"].as_bool() == Some(true), r["error"].as_str().unwrap_or("").chars().take(120).collect(), Instant::now()));+ if r["success"].as_bool() == Some(true) { shown = false; *LAST.lock().unwrap() = None; } else { painted_at = Instant::now(); } } }
rust/bridge/src/routing_script.py+4−1@@ -1050,7 +1050,10 @@ def apply_silkscreen(app, root, board, meta, args): for _, item in want: ref = identifier(item.get('ref')) if ref not in els: fail('designator_not_found', 'No part %s on the board' % ref)- to_smash = sorted({it['ref'] for kind, it in want if els[it['ref']].get('smashed') != 'yes'})+ # by the text itself: a part can say smashed="yes" and still have no NAME attribute of its own (a board from a+ # file, or part of one), and then MOVE and CHANGE pick nothing and the text stays where it is (#144)+ def has_own_text(el, kind): return any(a.get('name') == kind and a.get('x') is not None for a in el.findall('attribute'))+ to_smash = sorted({it['ref'] for kind, it in want if not has_own_text(els[it['ref']], kind)}) for ref in to_smash: run("SMASH '%s';" % ref) if to_smash: root, board, _ = snapshot(app, {**args, 'expectedRevision': None})
tests/glenn_drill.py+17−7@@ -60,19 +60,30 @@ if want('142') or want('143'): check('the zip has the inner layers', any(n.upper().endswith(('.G1', '.G2', '.GP1', '.GP2')) for n in names), names) if want('143'): print('--- #143 the panel')- def panels():- w = call('desktop_list_windows'); ws = (w.get('data') or w).get('windows') or []- return [x.get('title') for x in ws if 'driving Fusion' in (x.get('title') or '') or 'ADOM' in (x.get('title') or '')]- print(' right after the export:', panels()); time.sleep(10); left = panels(); check('no panel 10 s after the verb answered', not left, left)- call('fusion_export_gerbers', {'outputDir': out + 'b'}, 300); call('fusion_export_gerbers', {'outputDir': out + 'c'}, 300); time.sleep(10); left = panels(); check('nor after two exports in a row', not left, left)+ PV = "import adsk.core\\nui=adsk.core.Application.get().userInterface\\np=ui.palettes.itemById('AdomActivityPalette')\\nresult=bool(p and p.isVisible)"+ call('fusion_export_gerbers', {'outputDir': out + 'b'}, 300); call('fusion_export_gerbers', {'outputDir': out + 'c'}, 300)+ print(' right after two exports in a row:', script(PV)[0]); time.sleep(8); check('panel gone 8 s after the verb answered', script(PV)[0] is False, script(PV)) closeall() doc = board('GGerb2'); r = call('fusion_export_gerbers', {'outputDir': D + '/g2'}, 300); d = r.get('data') or r; check('a 2-layer board still exports 2', r.get('success') and d.get('layerCount') == 2, {k: d.get(k) for k in ('layerCount', 'error')}); closeall() if want('141'): print('--- #141 a document with no tab')- print(' ', script("import adsk.core\nd=app.documents.add(adsk.core.DocumentTypes.FusionDesignDocumentType,False)\nresult={'visible':d.isVisible,'n':app.documents.count}"))+ print(' ', script("""+import adsk.core+hit=[]+def walk(fol,depth):+ for k in range(fol.dataFiles.count):+ q=fol.dataFiles.item(k)+ if q.fileExtension=='f3d' and not hit: hit.append(q)+ if depth<2 and not hit:+ for j in range(min(fol.dataFolders.count,6)): walk(fol.dataFolders.item(j),depth+1)+walk(app.data.activeProject.rootFolder,0)+z=app.documents.open(hit[0],False) if hit else None+result={'hidden':z.name if z else None,'visible':z.isVisible if z else None}+""")) doc = board('GTab'); time.sleep(2) print(' ', script("result=[(app.documents.item(i).name,app.documents.item(i).isVisible) for i in range(app.documents.count)]"))+ print(' ', script("i=[k for k in range(app.documents.count) if app.documents.item(k).name=='%s'][0]\nvis=sum(1 for k in range(i) if app.documents.item(k).isVisible)\napp.executeTextCommand('UI.SwitchToTab %%d'%%i)\nold=app.activeDocument.name\napp.executeTextCommand('UI.SwitchToTab %%d'%%vis)\nresult={'oldIndexLandsOn':old,'fixLandsOn':app.activeDocument.name}" % doc)) r = st(doc); check('routing_state reads the right board', r.get('success') is not False and r.get('documentName') == doc and len(r.get('elements') or []) == 2, {k: r.get(k) for k in ('errorCode', 'error', 'documentName')}) r = call('fusion_board_source', {'expectDocument': doc}); check('board_source too', r.get('success') is not False, r.get('error')) print(' ', script("import adsk.core\nfor i in range(app.documents.count):\n d=app.documents.item(i)\n if not d.isVisible:\n d.close(False)\n break\nresult=app.documents.count")); closeall()@@ -89,7 +100,6 @@ if want('145'): v = vz(); check('filter changes both, leaves the third', r.get('verified') and v[(5.0, 5.0)][0] == 0.35 and v[(10.0, 5.0)][0] == 0.35 and v[(15.0, 5.0)][0] == 0.4, (r.get('errorCode'), r.get('error'), v)) s = st(doc); r = call('fusion_change_vias', {'expectDocument': doc, 'boardId': s.get('boardId'), 'expectedRevision': s.get('revision'), 'vias': [{'x': 5, 'y': 5, 'diameter': 0.9}]}) v = vz(); check('by position, on a track end, changes the via', r.get('verified') and v[(5.0, 5.0)][1] == 0.9 and v[(10.0, 5.0)][1] == 0.7, (r.get('errorCode'), r.get('error'), v)); check('tracks untouched', len(st(doc).get('tracks') or []) == tr0, tr0)- vis = script("import xml\nresult=1") s = st(doc); r = call('fusion_change_vias', {'expectDocument': doc, 'boardId': s.get('boardId'), 'expectedRevision': s.get('revision'), 'vias': [{'x': 40, 'y': 40, 'drill': 0.3}]}); check('no via there: nothing changes', r.get('errorCode') == 'nothing_to_do', r.get('errorCode')); nodialog('after the vias'); closeall() if want('144'):
tests/test_routing.py+19@@ -389,6 +389,25 @@ class ChangeViasTests(unittest.TestCase): self.assertEqual('bad_args', self.call(filter={'drill': 0.2})['errorCode']) +class SilkSmashTests(unittest.TestCase):+ """#144: a part marked smashed with no text of its own still needs SMASH, or MOVE picks nothing."""+ def test_smashes_by_the_text_not_the_flag(self):+ root = ET.parse(ROOT/'demo/routing/fixture/fusion-routing.brd').getroot(); b = root.find('drawing/board')+ a, c = b.findall('elements/element')[:2]+ a.set('smashed', 'yes') # the flag, no text+ c.set('smashed', 'yes'); ET.SubElement(c, 'attribute', name='NAME', x='1', y='1', size='1', layer='25') # both+ sent = []+ class App:+ def executeTextCommand(self, cmd): sent.append(cmd)+ saved = r.snapshot; r.snapshot = lambda app, args: (root, b, {'boardId': 'b', 'revision': 'r'})+ try:+ r.apply_silkscreen(App(), root, b, {'boardId': 'b', 'revision': 'r'}, {'keepPartial': True, 'names': [{'ref': a.get('name'), 'x': 1, 'y': 2}, {'ref': c.get('name'), 'x': 1, 'y': 2}]})+ finally:+ r.snapshot = saved+ self.assertIn("Electron.run SMASH '%s';" % a.get('name'), sent)+ self.assertNotIn("Electron.run SMASH '%s';" % c.get('name'), sent)++ class SilkUndoTests(unittest.TestCase): """#144: a failed batch is really undone, new free texts included, or says what's left.""" def setUp(self):