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
+123−32
BRIDGE_VERSION+1−1@@ -1 +1 @@-1.13.73+1.13.74
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.74 (2026-11-05)++`fusion_remove_pours` could delete the wrong pour and answer `verified: true` when two pours on a layer share an edge (found by AI Flow): the pick landed on the shared edge, and the read back only counted pours per net and layer. It now picks a point on no other pour's outline, and reads back by id: if the wrong one went, it's undone and answered (`readback_mismatch`, `removedByMistake`). If every edge is shared, it refuses before deleting anything (`unsafe_pick`). `fusion_edit_pours` picks the same way, and undoes a change that touched another pour.+ ## 1.13.73 (2026-11-05) `fusion_move_parts` keeps a part's side (adom-aiflow#142, Andy Engevold). A rotation given as a number took the mirror off, so a bottom part moved to the top. A number now keeps the side. To change it, give `side` (top or bottom) or `flip: true`, the standard's arguments, or a string that names it (`R90`, `MR90`); parts that change side are listed in `sideChanged`. If any part doesn't land, every move is undone (`undone`), where the parts already moved used to stay moved.
bridge.json+1−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "fusion360", "displayName": "Autodesk Fusion 360",- "version": "1.13.73",+ "version": "1.13.74", "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.73"+version = "1.13.74" dependencies = [ "serde_json", ]
dashboard/Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "fusion-dashboard"-version = "1.13.73"+version = "1.13.74" 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.73"+DASH_VERSION="1.13.74" 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.73",+ "version": "1.13.74", "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.73",+ "version": "1.13.74", "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.73"+version = "1.13.74" dependencies = [ "adom-eda-checks", "base64",@@ -299,7 +299,7 @@ dependencies = [ [[package]] name = "fusion-core"-version = "1.13.73"+version = "1.13.74" dependencies = [ "serde", "serde_json",@@ -310,7 +310,7 @@ dependencies = [ [[package]] name = "fusion-lbr"-version = "1.13.73"+version = "1.13.74" dependencies = [ "regex", "roxmltree",@@ -322,14 +322,14 @@ dependencies = [ [[package]] name = "fusion-os-mac"-version = "1.13.73"+version = "1.13.74" dependencies = [ "fusion-core", ] [[package]] name = "fusion-os-win"-version = "1.13.73"+version = "1.13.74" 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.73"+version = "1.13.74" edition = "2021" license = "MIT"
rust/bridge/src/routing_script.py+53−17@@ -600,16 +600,30 @@ def item_key(item): return ('via',item['net'],tuple(grid(c) for c in item['at']),round(item['drill'],6),round(item['diameter'],6),item['extent']) -def _pour_delete_cmd(e,pad_pts):- """DELETE of one whole pour, picked on its outline point farthest from every pad, so it can never take- a part with it. Shift-click (S x y) deletes the WHOLE polygon; a plain click deletes one vertex of it."""- ol=e['outline']; best=None+def _pour_pick(e,others):+ """Points on this pour's outline and on no other pour's on its layer, best first: farthest from them. A pick is+ taken by whichever outline runs through it, so one on a shared edge can take the other pour (two pours sharing+ three edges lost the wrong one and answered verified, 2026-11-05). A copy with the same id is the same pour."""+ rings=[list(zip(o['outline'],o['outline'][1:]+o['outline'][:1])) for o in others if o['layer']==e['layer'] and o.get('id')!=e.get('id')]+ ol=e['outline']; out=[] for a_,b_ in zip(ol,ol[1:]+ol[:1]):- for t_ in (0.25,0.5,0.75):+ for t_ in (0.5,0.25,0.75,0.1,0.9): q=(a_[0]+(b_[0]-a_[0])*t_,a_[1]+(b_[1]-a_[1])*t_)- dmin=min((math.hypot(q[0]-pp[0],q[1]-pp[1]) for pp in pad_pts),default=99)- if best is None or dmin>best[0]: best=(dmin,q)- if best[0]<1.0: fail('unsafe_pick','No outline point of the %s pour is 1 mm clear of a pad; remove it in Fusion by hand'%e['net'])+ near=min((point_distance(q,c,d) for ring in rings for c,d in ring),default=99)+ if near>0.2: out.append((near,q))+ return [q for _,q in sorted(out,key=lambda x:-x[0])]+++def _pour_delete_cmd(e,pad_pts,others=()):+ """DELETE of one whole pour, picked where only its own outline runs, and far from every pad, so it can never take+ a part with it. Shift-click (S x y) deletes the WHOLE polygon; a plain click deletes one vertex of it."""+ picks=_pour_pick(e,others)+ if not picks: fail('unsafe_pick','Every edge of the %s pour on layer %d lies along another pour, so a pick could take the wrong one: remove the other pour first, or remove it in Fusion by hand'%(e['net'],e['layer']))+ best=None+ for q in picks:+ dmin=min((math.hypot(q[0]-pp[0],q[1]-pp[1]) for pp in pad_pts),default=99)+ if best is None or dmin>best[0]: best=(dmin,q)+ if best[0]<1.0: fail('unsafe_pick','No outline point of the %s pour is 1 mm clear of a pad and of other pours; remove it in Fusion by hand'%e['net']) return 'LAYER %d; DELETE (S %.6fmm %.6fmm);'%(e['layer'],*best[1]) @@ -4476,14 +4490,14 @@ def handle(app,args,action): pts=[[float(a),float(b)] for a,b in q.get('points',[])] hit=next((e for e in have if e['net']==q.get('net') and e['layer']==eagle_layer(q.get('layer',1),board) and same_outline(e['outline'],pts)),None) if not hit: fail('unknown_pour','No %s pour on layer %s with that outline'%(q.get('net'),q.get('layer')))- ol=hit['outline']; edges=list(zip(ol,ol[1:]+ol[:1]))- a,b=max(edges,key=lambda e:math.hypot(e[1][0]-e[0][0],e[1][1]-e[0][1]))- at=((a[0]+b[0])/2,(a[1]+b[1])/2)+ own=_pour_pick(hit,have)+ if not own: fail('unsafe_pick','Every edge of the %s pour on layer %d lies along another pour, so a change could land on the wrong one'%(hit['net'],hit['layer']))+ at=own[0] if q.get('newNet') is not None and q['newNet']!=hit['net']: # a polygon belongs to its signal, so a pour moves net by being drawn again: same outline, same settings keep={'width':q.get('width',hit['width']),'isolate':q.get('isolate',hit['isolate']),'rank':q.get('rank',hit['rank']), 'thermals':q.get('thermals',hit['thermals']),'orphans':q.get('orphans',hit['orphans'])}- cmd=(_pour_delete_cmd(hit,[p_['at'] for p_ in objects(board)[1].values()])+' '+_pour_draw_cmd(q['newNet'],hit['layer'],hit['outline'],keep,hit.get('pour','solid')))+ cmd=(_pour_delete_cmd(hit,[p_['at'] for p_ in objects(board)[1].values()],have)+' '+_pour_draw_cmd(q['newNet'],hit['layer'],hit['outline'],keep,hit.get('pour','solid'))) attempted=True; app.executeTextCommand('Electron.run '+cmd); commands.append(cmd); targets.append((q,hit)); continue cmd='LAYER %d;'%hit['layer'] if q.get('width') is not None: cmd+=' CHANGE WIDTH %.6fmm (%.6fmm %.6fmm);'%(number(q['width'],True),*at)@@ -4506,7 +4520,19 @@ def handle(app,args,action): 'thermals':[hit['thermals'],n and n['thermals']],'rank':[hit['rank'],n and n['rank']],'orphans':[hit['orphans'],n and n['orphans']],'filledMm2':[hit['filledMm2'],n and n['filledMm2']]}) if not ok: bad.append(hit['net']) other=copper_signature(objects(after)[2]+objects(after)[3])!=before_cu+ # the pours nobody asked about are as they were, so a pick that took a neighbour shows+ asked={id(h) for _,h in targets}+ sett=lambda e:(e['id'],round(e['isolate'],4),round(e['width'],4),e['rank'],e['thermals'],e['orphans'])+ rest_before=sorted(sett(e) for e in have if id(e) not in asked)+ rest_now=sorted(sett(e) for e in now if not any(e['layer']==h['layer'] and same_outline(e['outline'],h['outline']) for _,h in targets))+ if rest_before!=rest_now: bad.append('another pour changed') okall=not bad and not other+ if not okall:+ undone=_undo_until(app,args,meta['revision'],len(commands)+2)+ _,back,back_meta=snapshot(app,args)+ return {'success':False,'mutated':not undone,'verified':False,'errorCode':'readback_mismatch','undone':undone,+ 'error':('tracks or vias changed' if other else 'pours not as asked: %s'%bad)+('; it was undone' if undone else '; the undo did not restore them: read fusion_pour_state'),+ 'changes':changes,'commands':commands,**pour_state(back),**state(back,back_meta)} return {'success':okall,'mutated':True,'verified':okall,'errorCode':None if okall else 'readback_mismatch', 'error':None if okall else ('tracks or vias changed' if other else 'pours not changed: %s'%bad), 'changes':changes,'commands':commands,**pour_state(after),**state(after,after_meta)}@@ -4525,8 +4551,12 @@ def handle(app,args,action): if not targets: return {'success':True,'mutated':False,'removed':0,**meta,'note':'no pour to remove'} elements_before=len(board.findall('elements/element')); cu_before=copper_signature(ws_+vs_) pad_pts=[p_['at'] for p_ in pads_.values()]+ still=list(have)+ cmds_=[] for e in targets:- cmd=_pour_delete_cmd(e,pad_pts)+ cmds_.append(_pour_delete_cmd(e,pad_pts,still)) # all checked before anything is deleted+ still.remove(e)+ for cmd in cmds_: attempted=True; app.executeTextCommand('Electron.run '+cmd); commands.append(cmd) app.executeTextCommand('Electron.run RATSNEST;') _,after,after_meta=snapshot(app,args)@@ -4534,11 +4564,17 @@ def handle(app,args,action): if len(after.findall('elements/element'))!=elements_before or copper_signature(ws2+vs2)!=cu_before: for _ in commands: app.executeTextCommand('Electron.run UNDO;') fail('readback_mismatch','Removing pours changed parts or tracks; every DELETE was undone')- # by count per net and layer: a pour that lost a vertex instead of going would still be here+ # by id: a count per net and layer can't see that the other pour went instead of the one asked for from collections import Counter- want_gone=Counter((t['net'],t['layer']) for t in targets); before_n=Counter((e['net'],e['layer']) for e in have); after_n=Counter((e['net'],e['layer']) for e in pours(after))- left=[{'net':k[0],'layer':k[1],'count':after_n[k]-(before_n[k]-n)} for k,n in want_gone.items() if after_n[k]>before_n[k]-n]- okp=not left+ expect=Counter(e['id'] for e in have)-Counter(t['id'] for t in targets); now_=Counter(e['id'] for e in pours(after))+ if expect!=now_:+ undone=_undo_until(app,args,meta['revision'],len(commands)+2)+ r_,back,back_meta=snapshot(app,args)+ return {'success':False,'mutated':not undone,'verified':False,'errorCode':'readback_mismatch','undone':undone,'removed':0,'commands':commands,+ 'removedByMistake':sorted((expect-now_).elements()),'stillThere':sorted((now_-expect).elements()),+ 'error':'Fusion did not remove exactly the pours asked for; %s'%('it was undone, so the board is as it was' if undone else 'the undo did not restore them: read fusion_pour_state'),**pour_state(back),**state(back,back_meta)}+ left=[]+ okp=True return {'success':okp,'mutated':True,'verified':okp,'errorCode':None if okp else 'readback_mismatch','removed':len(targets)-sum(x['count'] for x in left), 'left':left,'commands':commands,**pour_state(after),**state(after,after_meta)} if action=='ripup_nets':
tests/glenn_drill.py+15−1@@ -99,7 +99,21 @@ if want('138'): check('edit by id', r.get('success') and abs(next(p for p in ps if p.get('id') == one)['isolate'] - 0.35) < 1e-3, (r.get('errorCode'), r.get('error'))) s = st(doc); r = call('fusion_remove_pours', {'expectDocument': doc, 'boardId': s.get('boardId'), 'expectedRevision': s.get('revision'), 'pours': [{'id': one}]}); ps = call('fusion_pour_state', {'expectDocument': doc}).get('pours') or [] check('remove by id takes only that one', r.get('removed') == 1 and [p.get('net') for p in ps] == ['NC'], (r.get('errorCode'), r.get('error'), [p.get('net') for p in ps]))- s = st(doc); r = call('fusion_remove_pours', {'expectDocument': doc, 'boardId': s.get('boardId'), 'expectedRevision': s.get('revision'), 'pours': [{'id': 'pour:x:1:nope'}]}); check('an unknown id is refused', r.get('errorCode') == 'unknown_pour', r.get('errorCode')); nodialog('after the pours'); closeall()+ s = st(doc); r = call('fusion_remove_pours', {'expectDocument': doc, 'boardId': s.get('boardId'), 'expectedRevision': s.get('revision'), 'pours': [{'id': 'pour:x:1:nope'}]}); check('an unknown id is refused', r.get('errorCode') == 'unknown_pour', r.get('errorCode'))+ print('--- two pours sharing three edges, as AI Flow reported')+ SMALL = [[1, 1], [19, 1], [19, 5], [1, 5]]; BIG = [[1, 1], [19, 1], [19, 14], [1, 14]]+ for order, outline in (('the smaller', SMALL), ('the larger', BIG)):+ s = st(doc); r = call('fusion_apply_pours', {'expectDocument': doc, 'boardId': s.get('boardId'), 'expectedRevision': s.get('revision'), 'pours': [{'net': 'MID', 'layer': 'B.Cu', 'points': SMALL}, {'net': 'MID', 'layer': 'B.Cu', 'points': BIG}]}); check('both pours', r.get('success'), r.get('error') or r.get('missing'))+ ps = [p for p in call('fusion_pour_state', {'expectDocument': doc}).get('pours') or [] if p.get('net') == 'MID']+ rows = {tuple(map(tuple, p['outline'])): p['id'] for p in ps}; want_id = rows[tuple(map(tuple, [[float(a), float(b)] for a, b in outline]))]; other = [v for v in rows.values() if v != want_id]+ s = st(doc); r = call('fusion_edit_pours', {'expectDocument': doc, 'boardId': s.get('boardId'), 'expectedRevision': s.get('revision'), 'pours': [{'id': want_id, 'isolate': 0.33}]})+ ps = [p for p in call('fusion_pour_state', {'expectDocument': doc}).get('pours') or [] if p.get('net') == 'MID']; iso = {p['id']: round(p['isolate'], 3) for p in ps}+ check('edit %s changes only it' % order, r.get('success') and iso.get(want_id) == 0.33 and all(iso.get(o) != 0.33 for o in other), (r.get('errorCode'), r.get('error'), iso))+ s = st(doc); r = call('fusion_remove_pours', {'expectDocument': doc, 'boardId': s.get('boardId'), 'expectedRevision': s.get('revision'), 'pours': [{'id': want_id}]})+ left = [p['id'] for p in call('fusion_pour_state', {'expectDocument': doc}).get('pours') or [] if p.get('net') == 'MID']; print(' ', r.get('commands'))+ check('remove %s takes that one' % order, r.get('success') and r.get('removed') == 1 and left == other, (r.get('errorCode'), r.get('error'), left, want_id))+ s = st(doc); call('fusion_remove_pours', {'expectDocument': doc, 'boardId': s.get('boardId'), 'expectedRevision': s.get('revision'), 'pours': [{'id': i} for i in left]})+ nodialog('after the pours'); closeall() if want('move'): print('--- adom-aiflow#142 a move keeps the side')
tests/test_routing.py+37@@ -321,6 +321,43 @@ class PourIdTests(unittest.TestCase): for action in ('remove_pours', 'change_pours'): out = self.call(action, pours=[{'id': 'pour:x:1:nope', 'isolate': 0.3}]) self.assertEqual('unknown_pour', out['errorCode']); self.assertFalse(self.sent)+ def overlap(self):+ small = [[1, 1], [19, 1], [19, 5], [1, 5]]; big = [[1, 1], [19, 1], [19, 14], [1, 14]]+ return small, big+ def test_a_pick_is_never_on_a_neighbours_edge(self):+ small, big = self.overlap()+ e = {'id': 's', 'net': 'G', 'layer': 16, 'outline': small}; o = {'id': 'b', 'net': 'G', 'layer': 16, 'outline': big}+ picks = r._pour_pick(e, [e, o])+ self.assertTrue(picks); self.assertTrue(all(abs(q[1] - 5) < 1e-6 for q in picks), picks) # only its top edge is its own+ self.assertTrue(all(abs(q[1] - 14) < 1e-6 or q[1] > 5.2 for q in r._pour_pick(o, [e, o])))+ self.assertTrue(r._pour_pick(e, [dict(o, layer=1)])) # another layer is no neighbour+ self.assertEqual([], r._pour_pick(dict(e, id='x'), [dict(e, id='y')])) # every edge shared+ def test_all_edges_shared_is_refused_before_anything_runs(self):+ small, _ = self.overlap(); self.add(self.b, self.nets[0], small); self.add(self.b, self.nets[1], small)+ rows = r.pours(self.b)+ out = self.call('remove_pours', pours=[{'id': rows[0]['id']}])+ self.assertEqual('unsafe_pick', out['errorCode']); self.assertFalse(self.sent)+ def test_the_wrong_pour_going_is_caught_by_id_and_undone(self):+ import copy+ small, big = self.overlap()+ # far from the fixture's pads, so the only question is which pour went+ small = [[x + 100, y + 100] for x, y in small]; big = [[x + 100, y + 100] for x, y in big]+ self.add(self.b, self.nets[0], small); self.add(self.b, self.nets[0], big)+ rows = r.pours(self.b); want = next(p for p in rows if p['outline'] == [[float(a), float(b)] for a, b in small])+ wrong = copy.deepcopy(self.b); s_ = wrong.find("signals/signal[@name='%s']" % self.nets[0]); s_.remove(s_.findall('polygon')[1]) # the big one went+ r.snapshot = lambda app, args: (self.root, (self.b if not self.sent or 'Electron.run UNDO;' in self.sent else wrong), {'boardId': 'b', 'revision': 'r'})+ out = self.call('remove_pours', pours=[{'id': want['id']}])+ self.assertEqual('readback_mismatch', out['errorCode']); self.assertFalse(out['verified'], out); self.assertIn('Electron.run UNDO;', self.sent)+ self.assertTrue(out['undone']); self.assertEqual(1, len(out['removedByMistake'])); self.assertEqual([want['id']], out['stillThere'])+ def test_edit_that_changes_a_neighbour_is_undone(self):+ import copy+ small, big = self.overlap(); small = [[x + 100, y + 100] for x, y in small]; big = [[x + 100, y + 100] for x, y in big]+ self.add(self.b, self.nets[0], small); self.add(self.b, self.nets[0], big)+ rows = r.pours(self.b); want = next(p for p in rows if p['outline'] == [[float(a), float(b)] for a, b in small])+ got = copy.deepcopy(self.b); pgs = got.find("signals/signal[@name='%s']" % self.nets[0]).findall('polygon'); pgs[0].set('isolate', '0.4'); pgs[1].set('isolate', '0.4')+ r.snapshot = lambda app, args: (self.root, (self.b if not self.sent or 'Electron.run UNDO;' in self.sent else got), {'boardId': 'b', 'revision': 'r'})+ out = self.call('change_pours', pours=[{'id': want['id'], 'isolate': 0.4}])+ self.assertEqual('readback_mismatch', out['errorCode']); self.assertIn('another pour changed', out['error']); self.assertIn('Electron.run UNDO;', self.sent) def test_edit_by_id_takes_the_pour_from_its_handle(self): self.add(self.b, self.nets[0], self.SQ); pid = r.pours(self.b)[0]['id'] out = self.call('change_pours', pours=[{'id': pid, 'isolate': 0.4}])