← Commit history
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++Pours have a stable `id` (#138, Andy Engevold): `fusion_pour_state` and the board model give each one, made from its net, layer and outline, as Fusion stores none. It stays the same across saves and when other pours come and go. `fusion_remove_pours` and `fusion_edit_pours` take it, and an unknown one is refused before anything runs.+ ## 1.13.68 (2026-11-03)  `fusion_project_create` makes a design with its sheet and board linked (`linked: true`), through Fusion's own EAGLE import, so a part placed on the sheet goes on the board. It used to open two unlinked documents, which is now only the fallback when the import can't run (`linked: false`, `whyNotLinked`). If Adom Bridge is waiting for an OK, it answers `stillRunning` with a `runId`.
describe.py+3−3
@@ -50,7 +50,7 @@ _T = [     ("fusion_apply_pours", "Draw copper pours from a plan: POLYGON per pour (net, layer 1 or 16, outline points, isolate, rank 1..6, width, thermals, orphans), skipping any already on the board with the same net, layer and outline, then RATSNEST fills them. The reply carries Fusion's own fill per pour (filledMm2, islands) and per layer. Removing a pour is not done here.",      {"expectDocument":"required exact board name","boardId":"required","expectedRevision":"required","pours":"required [{net, layer, points:[[x,y]...], isolate (mm, default 0.25), rank (default 1), width (mm, default 0.25), thermals (default true), orphans (default false)}]"},      {"success":"bool","verified":"bool","added":"[net]","kept":"[net]","pours":"[{net,layer,outline,filledMm2,islands,filled}]","filledAreaByLayerMm2":"{layer:mm2}","revision":"str"},120,"fusion_routing_state",False,{}),-    ("fusion_pour_state", "Read every pour on the live board with Fusion's own computed fill: filled area per pour, per layer and per net, and which pours were never filled (no RATSNEST since they were drawn). The measure step of the AI flow's Fusion lane.",+    ("fusion_pour_state", "Read every pour on the live board with Fusion's own computed fill, each with a stable id made from its net, layer and outline (Fusion stores none): filled area per pour, per layer and per net, and which pours were never filled (no RATSNEST since they were drawn). The measure step of the AI flow's Fusion lane.",      {"expectDocument":"required exact board name","boardId":"optional"},      {"pours":"list","zones":"[{net,layer,outline,filledMm2,filled,fills:[{outline,holes}]}] (the standard's pours model)","filledAreaByLayerMm2":"{layer:mm2}","filledAreaByNetMm2":"{net:mm2}","unfilled":"[net]","revision":"str"},60,"fusion_routing_state",False,{}),     ("fusion_layer_stack", "The board's stack-up in the background, from Fusion's own Layer Stack Manager data in the board: copper layers top to bottom (F.Cu, In1.Cu, ..., B.Cu) with copper thickness in micrometres, the dielectric below each (thickness, Dk x 1000, prepreg or core, material) and the total board thickness. What impedance, current-capacity and copper-balance checks read. A board with no Layer Stack Manager data falls back to its design rules and says the dielectric constant is unknown. No view switching (fusion_board_stackup is the older 2D/3D read).",@@ -695,7 +695,7 @@ _T = [     ("fusion_change_pours", "Change existing pours in place: the pen width (the narrowest copper the fill can reach; a pen wider than a throat leaves it empty) and the isolation. Each pour is named by net, layer and outline, picked on the midpoint of its longest edge, then RATSNEST refills. The readback proves each pour took its new width/isolate and that no track or via changed, and reports the filled area before and after.",      {"expectDocument":"required exact board name","boardId":"required","expectedRevision":"required","pours":"required [{net, layer, points?:[[x,y]...] (omit when the net has one pour on that layer), width? or minWidthMm? (mm), isolate? or clearanceMm? (mm), thermals? (bool, or {on}), rank? or priority? (1-6), orphans? or keepIslands? (bool)}]"},      {"success":"bool","verified":"bool","changes":"[{net,layer,width,isolate,thermals,rank,orphans:[before,after],filledMm2:[before,after]}]","revision":"str"},120,"fusion_routing_state",False,{}),-    ("fusion_edit_pours", "The EDA Bridge Standard's name for fusion_change_pours (marked aliasOf): change existing pours in place, minimum width, clearance, thermals, rank and orphans, each read back after the refill. A pour's net cannot be moved in place in Fusion (a polygon belongs to its signal): that is refused with what to do instead.",+    ("fusion_edit_pours", "The EDA Bridge Standard's name for fusion_change_pours (marked aliasOf): change existing pours in place, named by id from fusion_pour_state or by net and layer, minimum width, clearance, thermals, rank and orphans, each read back after the refill. A pour's net cannot be moved in place in Fusion (a polygon belongs to its signal): that is refused with what to do instead.",      {"expectDocument":"required exact board name","boardId":"required","expectedRevision":"required","pours":"required [{net, layer, points?, minWidthMm?, clearanceMm?, thermals?, priority?, keepIslands?}]"},      {"success":"bool","verified":"bool","changes":"[{net,layer,width,isolate,thermals,rank,orphans:[before,after],filledMm2:[before,after]}]","revision":"str"},120,"fusion_routing_state",False,{"pours":[{"net":"GND","layer":16,"clearanceMm":0.3,"thermals":True}]}),     ("fusion_board_seed", "Write the design a new board starts from, as EAGLE source on this machine: the board outline, the fab's design rules (Fusion's own rules block with your clearance, edge distance, minimum width and drill, hole-to-hole, pad and via restring and copper weight; Fusion's defaults are 0.35 mm drill, 1 mm to the edge and a 25 % pad restring, wrong for most fabs), the fixed parts at their places with their nets, and the library they come from. Then fusion_new_electronics_from_eagle imports it as a linked schematic and board.",@@ -704,7 +704,7 @@ _T = [     ("fusion_schematic_plan", "Plan a grouped schematic from a netlist: one labelled frame per block, the block's parts in a row, every connected pin given a short stub named for its net and a label. Returns the command groups for fusion_schematic_run (one call per group, so the sheet grows block by block on camera). Pins may be named by pad (U1.3) or pin (U1.VIN). Pure computation from the .lbr; nothing in Fusion changes.",      {"libraryPath":"required .lbr on this machine","parts":"required [{ref, deviceset, value?, block?}]","nets":"required {net:[ref.pin or ref.pad]}","columns":"optional frames per row (default 3)"},      {"groups":"[{group, parts, commands}]","commandCount":"int","notInParts":"[str]"},60,"fusion_get_app_state",False,{}),-    ("fusion_remove_pours", "Delete pours (all of them, or the ones named by net, layer and outline) so a board can be poured again after a refine. Each DELETE picks the outline point farthest from every pad (refused if none is 1 mm clear), and the readback undoes everything if a part or a track changed.",+    ("fusion_remove_pours", "Delete pours (all of them, or the ones named by id from fusion_pour_state, or by net, layer and outline) so a board can be poured again after a refine. Each DELETE picks the outline point farthest from every pad (refused if none is 1 mm clear), and the readback undoes everything if a part or a track changed.",      {"expectDocument":"required exact board name","boardId":"required","expectedRevision":"required","pours":"optional 'all' (default) or [{net, layer, points}]"},      {"success":"bool","removed":"int","left":"list","revision":"str"},120,"fusion_routing_state",False,{}),     ("fusion_board_source", "Read the live board as EAGLE XML from a fresh export: parts, copper, vias, pours with Fusion's own computed fill, and the outline. Read-only. Feed it to adom-fields (it reads .brd) for current and heat, or to adom-aiflow, which converts it for its offline checks.",
rust/bridge/src/board_model.py+9−1
@@ -802,6 +802,14 @@ def _len(v):     return float(m[1]) * {'mm': 1, None: 1, 'mil': 0.0254, 'mic': 0.001, 'um': 0.001, 'in': 25.4}[m[2]]  +def pour_id(net, layer, outline):+    """A pour's handle. Fusion stores no id on a polygon, so it's made from what the pour is: its net, layer and+    outline, whichever vertex the outline starts at. The same pour keeps it across saves and when other pours come+    and go; a new outline is a new pour."""+    pts = sorted((round(float(a), 3), round(float(b), 3)) for a, b in outline)+    return 'pour:%s:%d:%s' % (net, int(layer), hashlib.sha1(repr(pts).encode()).hexdigest()[:12])++ def zones(board):     """The pours model (companions schema `pours`): each pour as drawn and the copper Fusion     actually poured, island by island with its holes."""@@ -820,7 +828,7 @@ def zones(board):                     fills.append({'outline': [[_r(a), _r(b)] for a, b in vertex_ring(seg.findall('vertex'))],                                   'holes': [[[_r(a), _r(b)] for a, b in vertex_ring(h.findall('vertex'))] for h in sh.iter('polygonholesegments')]})             filled = sum(area(f['outline']) - sum(area(h) for h in f['holes']) for f in fills)-            out.append({'net': s.get('name'), 'layer': layers.name(layer), 'layerId': layer,+            out.append({'id': pour_id(s.get('name'), layer, vertex_ring(shape_vertices(p))), 'net': s.get('name'), 'layer': layers.name(layer), 'layerId': layer,                         'outline': [[_r(a), _r(b)] for a, b in vertex_ring(shape_vertices(p))],                         'filledMm2': round(filled, 3), 'filled': fd is not None, 'fills': fills,                         'pour': p.get('pour', 'solid'), 'isolate': _f(p, 'isolate'), 'rank': int(p.get('rank', '1'))})
rust/bridge/src/routing_script.py+13−2
@@ -242,7 +242,7 @@ def pours(board):                     for h in sh.iter('polygonholesegments'):                         a-=shoelace([(float(v.get('x')),float(v.get('y'))) for v in h.findall('vertex')])                     filled+=a; islands+=1-            out.append({'net':s.get('name'),'layer':int(p.get('layer','1')),'outline':outline,+            out.append({'id':pour_id(s.get('name'),int(p.get('layer','1')),outline),'net':s.get('name'),'layer':int(p.get('layer','1')),'outline':outline,                         'isolate':float(p.get('isolate','0')),'rank':int(p.get('rank','1')),'width':float(p.get('width','0')),                         'thermals':p.get('thermals','yes')!='no','orphans':p.get('orphans','no')=='yes','pour':p.get('pour','solid'),                         'filledMm2':round(filled,3),'islands':islands,'filled':fd is not None})@@ -4366,6 +4366,14 @@ def handle(app,args,action):             have=pours(board); before_cu=copper_signature(objects(board)[2]+objects(board)[3])             targets=[]             for q in want:+                if q.get('id'):+                    # the standard's handle: it says which pour, so net, layer and outline come from the pour itself+                    byid=next((e for e in have if e['id']==q['id']),None)+                    if byid is None: fail('unknown_pour','No pour with id %s: fusion_pour_state lists them'%q['id'])+                    if q.get('net') and q['net']!=byid['net'] and q.get('newNet') is None:+                        if identifier(q['net']) not in objects(board)[0]: fail('unknown_net','No exact net named '+q['net'])+                        q['newNet']=q['net']+                    q.update(net=byid['net'],layer=byid['layer'],points=byid['outline'])                 if q.get('points') is None:                     # no outline: net and layer name the pour when that net has one pour on that layer                     same=[e for e in have if e['net']==q.get('net') and e['layer']==eagle_layer(q.get('layer',1),board)]@@ -4416,7 +4424,10 @@ def handle(app,args,action):             want=args.get('pours')             if want in (None,'all'): targets=have             else:-                targets=[e for e in have if any(e['net']==q.get('net') and e['layer']==eagle_layer(q.get('layer',1),board) and same_outline(e['outline'],[[float(a),float(b)] for a,b in q.get('points',[])]) for q in want)]+                ids=[q['id'] for q in want if q.get('id')]+                unknown=[i for i in ids if not any(e['id']==i for e in have)]+                if unknown: fail('unknown_pour','No pour with id %s: fusion_pour_state lists them'%', '.join(unknown))+                targets=[e for e in have if e['id'] in ids or any(not q.get('id') and e['net']==q.get('net') and e['layer']==eagle_layer(q.get('layer',1),board) and same_outline(e['outline'],[[float(a),float(b)] for a,b in q.get('points',[])]) for q in want)]             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()]
tests/test_routing.py+51
@@ -276,6 +276,57 @@ class SchematicRunListTests(unittest.TestCase):         self.assertEqual('invalid_args', e.exception.code)  +class PourIdTests(unittest.TestCase):+    """#138: a pour's id is what it is, so it survives saves and other pours coming and going."""+    SQ = [[60, 60], [90, 60], [90, 90], [60, 90]]+    def setUp(self):+        self.root = ET.parse(ROOT/'demo/routing/fixture/fusion-routing.brd').getroot(); self.b = self.root.find('drawing/board')+        self.nets = [s_.get('name') for s_ in self.b.findall('signals/signal')]+        self.sent = []; test = self+        class App:+            def executeTextCommand(self, cmd): test.sent.append(cmd)+        self.app = App(); self.saved = r.snapshot; self.after = self.b+        r.snapshot = lambda app, args: (self.root, self.after, {'boardId': 'b', 'revision': 'r'})+    def tearDown(self): r.snapshot = self.saved+    def add(self, b, net, pts, layer='16', tag='polygon'):+        pg = ET.SubElement(b.find("signals/signal[@name='%s']" % net), tag, layer=layer, width='0.3', isolate='0.2')+        holder = pg if tag == 'polygon' else ET.SubElement(pg, 'polygonoutlineobjects')+        for x, y in pts: ET.SubElement(holder, 'vertex', x=str(x), y=str(y))+    def call(self, action, **a): return r.handle(self.app, dict(a, boardId='b', expectedRevision='r'), action)+    def test_the_id_ignores_where_the_outline_starts_and_changes_with_the_pour(self):+        a = r.pour_id('N', 16, self.SQ)+        self.assertEqual(a, r.pour_id('N', 16, self.SQ[2:] + self.SQ[:2]))+        self.assertNotEqual(a, r.pour_id('M', 16, self.SQ)); self.assertNotEqual(a, r.pour_id('N', 1, self.SQ))+        self.assertNotEqual(a, r.pour_id('N', 16, [[60, 60], [91, 60], [90, 90], [60, 90]]))+    def test_saved_and_drawn_pours_read_the_same_id_and_other_pours_dont_shift_it(self):+        self.add(self.b, self.nets[0], self.SQ); one = r.pours(self.b)[0]['id']+        self.add(self.b, self.nets[1], [[1, 1], [9, 1], [9, 9]])+        ids = {p['net']: p['id'] for p in r.pours(self.b)}+        self.assertEqual(one, ids[self.nets[0]])+        self.assertEqual(ids[self.nets[0]], next(z['id'] for z in r.zones(self.b) if z['net'] == self.nets[0]))+        import copy+        fus = copy.deepcopy(self.root).find('drawing/board'); [sg.remove(pg) for sg in fus.findall('signals/signal') for pg in sg.findall('polygon')]+        self.add(fus, self.nets[0], self.SQ, tag='polygonpour')+        self.assertEqual(one, r.pours(fus)[0]['id'])+    def test_remove_by_id_takes_only_that_pour(self):+        self.add(self.b, self.nets[0], self.SQ); self.add(self.b, self.nets[0], [[100, 60], [130, 60], [130, 90]])+        rows = r.pours(self.b)+        import copy+        self.after = copy.deepcopy(self.b); s_ = self.after.find("signals/signal[@name='%s']" % self.nets[0]); s_.remove(s_.findall('polygon')[0])+        after = self.after; r.snapshot = lambda app, args: (self.root, after if self.sent else self.b, {'boardId': 'b', 'revision': 'r'})+        out = self.call('remove_pours', pours=[{'id': rows[0]['id']}])+        self.assertEqual(1, out.get('removed'), {k: out.get(k) for k in ('errorCode', 'error', 'left', 'note')}); self.assertEqual(1, len(out['commands']))+    def test_an_unknown_id_is_refused_before_anything_runs(self):+        self.add(self.b, self.nets[0], self.SQ)+        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 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}])+        self.assertTrue(any('CHANGE ISOLATE 0.400000mm' in c for c in out['commands']), out)++ class RouteMismatchTests(unittest.TestCase):     """A route whose copper does not land as planned comes off again, without taking an earlier edit with it."""     def run_route(self, undo_steps_needed):