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 4 file(s)
4 files changed
+96−10
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` +## Next release++`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.+ ## 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.
describe.py+1−1@@ -41,7 +41,7 @@ _T = [ ("fusion_route_net", "Create explicit net copper in the live Fusion canvas, on any copper layer of the board (F.Cu, In1.Cu ... B.Cu, or EAGLE's numbers). Clearance preflight against pads by their real outline, tracks and arcs, vias, holes (footprint mounting holes too), footprint and board copper and keepouts (tRestrict/bRestrict), with board/revision guards and exact copper readback. Pours are repoured round the new copper; a route that cuts another net's pour in two is undone (splits_net) unless allowSplit. One or several paths per call; no general autorouting. Use routing_drc for native DRC.", {"expectDocument":"required exact board name","boardId":"required from routing_state","expectedRevision":"required from routing_state","net":"required exact name","points":"waypoints [x,y] or {pad} or {x,y,layer}; a waypoint's layer is the layer after it (a via where it changes)","paths":"optional list of paths instead of points","width":"mm, default .25","layer":"the first copper layer, by name or number; default F.Cu","viaDiameter":"mm, default .6 (viaSize also works: fusion_plan_route's routeNet)","viaDrill":"mm, default .3","clearance":"mm, can only raise the board's rules","allowSplit":"bool: keep a route that splits another net","dryRun":"optional bool"}, {"success":"bool","mutated":"bool","verified":"bool","revision":"str","plan":"obj","remainingNets":"list","splitNets":"[net] (splits_net)"},120,"fusion_routing_state",False,{}),- ("fusion_move_parts", "Place parts on the live board: one MOVE (absolute, mm) and ROTATE per part, each its own undo step, then a fresh export reads every element back. The layout half of the AI flow's Fusion lane (adom-aiflow place land). Coordinates are Fusion's (y up).",+ ("fusion_move_parts", "Place parts on the live board: one MOVE (absolute, mm) and ROTATE per part, each its own undo step, then a fresh export reads every element back; if any part didn't land, every move is undone. A rotation given as a number keeps the part's side, and is EAGLE's angle for that side. To change side, give side (top or bottom) or flip:true, or a string that names it (R90 top, MR90 bottom); parts that changed side are listed in sideChanged, and their tracks no longer reach their pads until they're routed again. The layout half of the AI flow's Fusion lane (adom-aiflow place land). Coordinates are Fusion's (y up).", {"expectDocument":"required exact board name","boardId":"required from routing_state","expectedRevision":"required from routing_state","parts":"required [{ref, x, y, rotation}] rotation in degrees or an EAGLE rotation like R90 / MR180"}, {"success":"bool","verified":"bool","moved":"[ref]","mismatched":"[{ref,want,got}]","revision":"str"},120,"fusion_routing_state",False,{}), ("fusion_place_vias", "Add vias at exact points (stitching, thermal, escape): VIA per via with its drill and diameter, then read back.",
rust/bridge/src/routing_script.py+29−9@@ -273,11 +273,14 @@ def element_rows(board): return {el.get('name'):{'x':float(el.get('x')),'y':float(el.get('y')),'rot':el.get('rot','R0')} for el in board.findall('elements/element')} -def rot_of(v):+def rot_of(v,current=None):+ """An element's rotation. A number is an angle and says nothing about the side, so it keeps the side the part is+ on (current, its rotation now): ROTATE =R90 on a mirrored part takes the mirror off, which moved bottom parts to+ the top (adom-aiflow#142). Saying R or MR in a string names the side outright, and that's the way to change it.""" if isinstance(v,(int,float)) and not isinstance(v,bool): d=float(v)%360.0- return 'R'+('%g'%d)- if isinstance(v,str) and re.fullmatch(r'M?R-?[\d.]+',v): return v+ return ('M' if current and 'M' in current else '')+'R'+('%g'%d)+ if isinstance(v,str) and re.fullmatch(r'S?M?R-?[\d.]+',v): return v.lstrip('S') fail('invalid_geometry','rotation must be degrees or an EAGLE rotation like R90 / MR180') @@ -4406,21 +4409,38 @@ def handle(app,args,action): # one MOVE (and ROTATE) per part, absolute; each its own undo step and its own moment on camera want=args.get('parts') if not isinstance(want,list) or not want: fail('invalid_args','parts must be a non-empty list of {ref,x,y,rotation}')- have=element_rows(board); done=[]+ have=element_rows(board); done=[]; rots={}; flipped=[] for p in want:- ref=identifier(p.get('ref')); x=number(p.get('x')); y=number(p.get('y'))+ ref=identifier(p.get('ref')); number(p.get('x')); number(p.get('y')) if ref not in have: fail('unknown_part','No element named %s on the board'%ref)- rot=rot_of(p.get('rotation',have[ref]['rot']))+ rots[ref]=rot_of(p.get('rotation',have[ref]['rot']),have[ref]['rot'])+ # the standard's side and flip: rotation and side are kept when left out+ if p.get('side') not in (None,'top','bottom'): fail('invalid_args','side is top or bottom')+ if p.get('side') is not None and p.get('flip'): fail('invalid_args','give side or flip, not both')+ if p.get('side') is not None or p.get('flip'):+ if isinstance(p.get('rotation'),str): fail('invalid_args','%s: give the rotation as a number when also giving side or flip: an R or MR string names the side itself'%ref)+ bottom=(p.get('side')=='bottom') if p.get('side') is not None else 'M' not in have[ref]['rot']+ rots[ref]=('M' if bottom else '')+rots[ref].lstrip('SM')+ if ('M' in rots[ref])!=('M' in have[ref]['rot']): flipped.append(ref)+ for p in want:+ ref=identifier(p.get('ref')); x=number(p.get('x')); y=number(p.get('y'))+ rot=rots[ref] cmd="MOVE '%s' (%.6fmm %.6fmm); ROTATE =%s '%s';"%(ref,x,y,rot,ref) attempted=True; app.executeTextCommand('Electron.run '+cmd); commands.append(cmd); done.append(ref) app.executeTextCommand('Electron.run RATSNEST;') app.executeTextCommand('Electron.ImmediateRepaint /full') _,after,after_meta=snapshot(app,args); got=element_rows(after); bad=[] for p in want:- g=got.get(p['ref']); r=rot_of(p.get('rotation',g['rot'] if g else 'R0'))+ g=got.get(p['ref']); r=rots[p['ref']] if not g or abs(g['x']-p['x'])>0.001 or abs(g['y']-p['y'])>0.001 or g['rot'].lstrip('S')!=r: bad.append({'ref':p['ref'],'want':[p['x'],p['y'],r],'got':g})- return {'success':not bad,'mutated':True,'verified':not bad,'errorCode':None if not bad else 'readback_mismatch',- 'mismatched':bad,'moved':done,'commands':commands,**state(after,after_meta)}+ if bad:+ # all or none: parts that did move would stay moved, or on the wrong side, with no way to tell which+ undone=_undo_until(app,args,meta['revision'],2*len(commands)+2)+ app.executeTextCommand('Electron.run RATSNEST;')+ _,back,back_meta=snapshot(app,args)+ return {'success':False,'mutated':not undone,'verified':False,'errorCode':'readback_mismatch','undone':undone,'mismatched':bad,'moved':[],'commands':commands,+ 'error':'%d part(s) did not land as asked; %s'%(len(bad),'every move was undone, so the board is as it was' if undone else 'the undo did not restore the board exactly: read fusion_routing_state'),**state(back,back_meta)}+ return {'success':True,'mutated':True,'verified':True,'errorCode':None,'mismatched':[],'moved':done,'sideChanged':flipped,'commands':commands,**state(after,after_meta)} if action=='change_pours': # change an existing pour's pen width and isolation in place (the refinement a field # solve asks for: a throat the pen was too wide to fill). Each CHANGE picks the pour on
tests/test_routing.py+62@@ -327,6 +327,68 @@ class PourIdTests(unittest.TestCase): self.assertTrue(any('CHANGE ISOLATE 0.400000mm' in c for c in out['commands']), out) +class MovePartsSideTests(unittest.TestCase):+ """adom-aiflow#142: a move keeps a part's side unless the caller names another."""+ def test_a_number_keeps_the_side_and_a_string_names_it(self):+ self.assertEqual('MR90', r.rot_of(90, 'MR180')); self.assertEqual('R90', r.rot_of(90, 'R180')); self.assertEqual('R90', r.rot_of(90))+ self.assertEqual('MR270', r.rot_of(-90, 'SMR0')); self.assertEqual('MR45.5', r.rot_of(45.5, 'MR0'))+ self.assertEqual('R90', r.rot_of('R90', 'MR180')); self.assertEqual('MR90', r.rot_of('MR90', 'R0')); self.assertEqual('MR90', r.rot_of('SMR90'))+ with self.assertRaises(r.RoutingError): r.rot_of('sideways')+ def setUp(self):+ import copy+ self.root = ET.parse(ROOT/'demo/routing/fixture/fusion-routing.brd').getroot(); self.b = self.root.find('drawing/board')+ self.els = self.b.findall('elements/element'); self.a, self.c = self.els[0], self.els[1]+ self.a.set('rot', 'MR0'); self.a.set('x', '10'); self.a.set('y', '10'); self.c.set('rot', 'R0'); self.c.set('x', '20'); self.c.set('y', '20')+ self.sent = []; test = self+ class App:+ def executeTextCommand(self, cmd): test.sent.append(cmd)+ self.app = App(); self.saved = r.snapshot; self.after = None; self.copy = copy+ def snap(app, args):+ if 'Electron.run UNDO;' in self.sent: return self.root, self.b, {'boardId': 'b', 'revision': 'r'}+ return self.root, (self.after if self.after is not None and self.sent else self.b), {'boardId': 'b', 'revision': 'r2' if self.sent else 'r'}+ r.snapshot = snap+ def tearDown(self): r.snapshot = self.saved+ def landed(self, **parts):+ b = self.copy.deepcopy(self.b)+ for ref, (x, y, rot) in parts.items():+ e = b.find("elements/element[@name='%s']" % ref); e.set('x', str(x)); e.set('y', str(y)); e.set('rot', rot)+ self.after = b+ def call(self, parts): return r.handle(self.app, {'boardId': 'b', 'expectedRevision': 'r', 'parts': parts}, 'move_parts')+ def test_a_bottom_part_given_a_number_stays_on_the_bottom(self):+ ref = self.a.get('name'); self.landed(**{ref: (11, 12, 'MR90')})+ out = self.call([{'ref': ref, 'x': 11, 'y': 12, 'rotation': 90}])+ self.assertTrue(out['success'], out.get('mismatched')); self.assertEqual([], out['sideChanged'])+ self.assertTrue(any("ROTATE =MR90 '%s'" % ref in c for c in self.sent))+ def test_naming_the_other_side_is_said_in_the_reply(self):+ ref = self.a.get('name'); self.landed(**{ref: (11, 12, 'R90')})+ out = self.call([{'ref': ref, 'x': 11, 'y': 12, 'rotation': 'R90'}])+ self.assertTrue(out['success'], out.get('mismatched')); self.assertEqual([ref], out['sideChanged'])+ def test_no_rotation_keeps_side_and_angle(self):+ ref = self.a.get('name'); self.landed(**{ref: (11, 12, 'MR0')})+ self.assertTrue(self.call([{'ref': ref, 'x': 11, 'y': 12}])['success'])+ def test_a_mismatch_undoes_the_parts_that_did_move(self):+ a, c = self.a.get('name'), self.c.get('name'); self.landed(**{a: (11, 12, 'MR90'), c: (20, 20, 'R0')}) # the second didn't land+ out = self.call([{'ref': a, 'x': 11, 'y': 12, 'rotation': 90}, {'ref': c, 'x': 25, 'y': 25, 'rotation': 0}])+ self.assertEqual('readback_mismatch', out['errorCode']); self.assertIn('Electron.run UNDO;', self.sent)+ self.assertTrue(out['undone']); self.assertFalse(out['mutated']); self.assertEqual([], out['moved'])+ def test_side_and_flip_put_a_part_where_asked(self):+ ref = self.a.get('name'); self.landed(**{ref: (11, 12, 'R90')})+ out = self.call([{'ref': ref, 'x': 11, 'y': 12, 'rotation': 90, 'side': 'top'}]); self.assertEqual([ref], out['sideChanged'], out.get('mismatched'))+ self.sent.clear(); self.landed(**{ref: (11, 12, 'R0')})+ out = self.call([{'ref': ref, 'x': 11, 'y': 12, 'flip': True}]); self.assertEqual([ref], out['sideChanged'], out.get('mismatched'))+ self.assertTrue(any("ROTATE =R0 '%s'" % ref in c for c in self.sent))+ c = self.c.get('name'); self.sent.clear(); self.landed(**{c: (20, 20, 'MR0')})+ out = self.call([{'ref': c, 'x': 20, 'y': 20, 'side': 'bottom'}]); self.assertEqual([c], out['sideChanged'], out.get('mismatched'))+ def test_side_with_a_string_or_with_flip_is_refused_before_anything_moves(self):+ ref = self.a.get('name')+ for bad in ({'rotation': 'MR90', 'side': 'top'}, {'side': 'top', 'flip': True}, {'side': 'left'}):+ out = self.call([dict({'ref': ref, 'x': 1, 'y': 1}, **bad)])+ self.assertEqual('invalid_args', out['errorCode']); self.assertFalse(self.sent)+ def test_a_bad_part_moves_nothing(self):+ out = self.call([{'ref': self.a.get('name'), 'x': 1, 'y': 1, 'rotation': 90}, {'ref': 'NOPE9', 'x': 1, 'y': 1}])+ self.assertEqual('unknown_part', out['errorCode']); self.assertFalse(self.sent)++ class TabNumberTests(unittest.TestCase): """#141: a document with no visible tab doesn't take a place in the tab bar.""" def test_a_hidden_document_shifts_nothing(self):