← Commit history
BRIDGE_VERSION+1−1
@@ -1 +1 @@-1.13.73\ No newline at end of file+1.13.74\ 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.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"   ]-}\ No newline at end of file+}
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}])