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 5 file(s)
5 files changed
+361−47
rust/bridge/src/checks.rs+1−1@@ -119,7 +119,7 @@ pub fn call(cmd: &str, args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Val } } r["mutated"] = json!(false);- r["_hint"] = json!("A plan: send each call to fusion_route_net in turn, with expectedRevision set to revision for the first and to the previous reply's revision after that. Drop or shorten whatever conflicts lists first. fusion_route_net undoes a call that splits another net's pour.");+ r["_hint"] = json!("A plan: send each call to fusion_route_net in turn, with expectedRevision set to revision for the first and to the previous reply's revision after that. Drop or shorten whatever conflicts lists first. fusion_route_net undoes a call that splits another net's pour, or whose copper does not land as planned."); } if cmd == "silk_designators" && r["success"].as_bool() == Some(true) { r["mutated"] = json!(false);
rust/bridge/src/routing_script.py+137−44@@ -33,15 +33,22 @@ def number(v, positive=False): return round(v, 6) -def eagle_layer(v):- """A copper layer argument: Fusion's number (1, 16) or the standard's name (F.Cu, B.Cu), so a- caller can pass back what fusion_routing_state reported."""+def eagle_layer(v, board=None):+ """A copper layer argument: Fusion's number or the standard's name (F.Cu, In1.Cu, B.Cu), so a caller+ can pass back what fusion_routing_state reported. With a board, it must be one of that board's layers;+ an inner layer's name means a number only on a board, as In2.Cu is 15 on 4 layers and 3 on 6."""+ names = {'F.CU': 1, 'B.CU': 16}+ if board is not None:+ L = Layers(board)+ names = {nm.upper(): n for n, nm in L.names.items()} if isinstance(v, str):- v = {'F.CU': 1, 'B.CU': 16}.get(v.strip().upper(), v.strip())+ v = names.get(v.strip().upper(), v.strip()) if isinstance(v, str) and v.isdigit(): v = int(v) if isinstance(v, bool) or not isinstance(v, int):- fail('invalid_layer', 'A layer is 1 or F.Cu (top), 16 or B.Cu (bottom): got %r' % (v,))+ fail('invalid_layer', 'A layer is its number or its name (F.Cu, In1.Cu, B.Cu): got %r' % (v,))+ if board is not None and v not in L.order:+ fail('invalid_layer', 'Layer %s is not a copper layer of this board (%s)' % (v, ', '.join(L.name(n) for n in L.order))) return v @@ -236,7 +243,7 @@ def pours(board): filled+=a; islands+=1 out.append({'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',+ '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}) return out @@ -583,6 +590,59 @@ 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+ for a_,b_ in zip(ol,ol[1:]+ol[:1]):+ for t_ in (0.25,0.5,0.75):+ 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'])+ return 'LAYER %d; DELETE (S %.6fmm %.6fmm);'%(e['layer'],*best[1])+++def _pour_draw_cmd(net,layer,outline,o,pour='solid'):+ path=' '.join('(%.6fmm %.6fmm)'%(x,y) for x,y in list(outline)+list(outline[:1]))+ return ("CHANGE ISOLATE %.6fmm; CHANGE RANK %d; CHANGE POUR %s; CHANGE ORPHANS %s; CHANGE THERMALS %s; LAYER %d; POLYGON '%s' %.6fmm %s;"+ %(number(o['isolate']),int(o['rank']),pour.upper(),'ON' if o['orphans'] else 'OFF','ON' if o['thermals'] else 'OFF',layer,identifier(net),number(o['width'],True),path))+++def _cutout_rects(board,cutouts):+ """apply_pours' cutouts as rectangles on Fusion's restrict layers (tRestrict, bRestrict), which pours+ stay out of. Row by row, as POLYGON would ask for a signal name in a modal. Fusion has no restrict layer+ for an inner layer, so a cutout there is refused before anything is drawn."""+ if not cutouts: return []+ if not isinstance(cutouts,list): fail('invalid_args','cutouts must be a list of {layer, points}')+ out=[]+ for c_ in cutouts:+ layer=eagle_layer(c_.get('layer',1),board)+ if layer not in (1,16):+ fail('not_supported','A cutout on %s: Fusion has restrict layers for the top and bottom only. Shape that pour\'s outline around the area instead'%Layers(board).name(layer))+ pts=[(number(a),number(b)) for a,b in c_.get('points') or []]+ if len(pts)<3: fail('invalid_geometry','a cutout needs at least three points')+ rects=[r_ for r_ in svg_rects([pts],0.05) if r_[2]-r_[0]>=0.005 and r_[3]-r_[1]>=0.005]+ if len(rects)>1000: fail('too_detailed','A cutout needs %d rectangles: give it fewer sloped edges'%len(rects))+ out.append({'layer':Layers(board).name(layer),'restrictLayer':41 if layer==1 else 42,'rects':rects})+ return out+++def undo_route(app,args,commands,before_items):+ """Undo a route one step at a time until the copper is what it was. A command that landed nothing+ leaves no undo step, so it stops on the copper rather than on a count: that way it cannot take back+ an earlier edit. Returns (restored, board, meta)."""+ restored=False+ for _ in commands:+ app.executeTextCommand('Electron.run UNDO;')+ _,back,back_meta=snapshot(app,args); o=objects(back)+ restored=copper_signature(o[2]+o[3])==before_items+ if restored: break+ app.executeTextCommand('Electron.run RATSNEST;')+ _,back,back_meta=snapshot(app,args)+ return restored,back,back_meta++ def copper_signature(items): """Compare occupied centerline intervals, tolerating Fusion splitting/merging wires.""" groups=collections.defaultdict(list); vs=[]@@ -1174,7 +1234,8 @@ def _wire_key(x): def edit_tracks(app, root, board, meta, args): """The standard's edit_tracks: given tracks by their routing_state handle: width, layer, ends. Each is picked- with only its layer displayed, at the point farthest from other copper. An end moves with MOVE at that end.+ with only its layer displayed, at the point farthest from other copper. An end moves with MOVE along the track's+ line; at an angle the track is drawn again and the old one deleted. Read back: only the asked tracks may differ, and only as asked, or every change is undone.""" _guard(meta, args) edits = args.get('tracks') or []@@ -1199,12 +1260,23 @@ def edit_tracks(app, root, board, meta, args): step.append('CHANGE WIDTH %.6fmm (%.6fmm %.6fmm);' % (want['width'], pick[0], pick[1])) for key in ('start', 'end'): if ed.get(key) is not None:- to = [number(ed[key][0]), number(ed[key][1])]+ want[key] = [number(ed[key][0]), number(ed[key][1])]+ if want['start'] != x['start'] or want['end'] != x['end']:+ dx, dy = x['end'][0] - x['start'][0], x['end'][1] - x['start'][1]+ along = all(abs((q[0] - x['start'][0]) * dy - (q[1] - x['start'][1]) * dx) < 1e-6 * max(math.hypot(dx, dy), 1e-9) for q in (want['start'], want['end']))+ if along:+ for key in ('start', 'end'):+ if want[key] != x[key]:+ step.append('MOVE (%.6fmm %.6fmm) (%.6fmm %.6fmm);' % (x[key][0], x[key][1], want[key][0], want[key][1]))+ else: # MOVE keeps one segment only along the track's own line: at an angle Fusion bends it into two,- # whatever WIRE_BEND says. That is refused and undone below. Deleting and redrawing is no way- # round it: DELETE of a net's last wire removes the whole signal, pad connections too (live, 2706).- step.append('MOVE (%.6fmm %.6fmm) (%.6fmm %.6fmm);' % (x[key][0], x[key][1], to[0], to[1]))- want[key] = to+ # whatever WIRE_BEND says. So the new track goes down first and the old one comes off after:+ # that order, as DELETE of a net's last wire removes the whole signal, pad connections too.+ step.append("SET WIRE_BEND 2; LAYER %d; LINE '%s' %.6fmm (%.6fmm %.6fmm) (%.6fmm %.6fmm);" % (x['layer'], identifier(x['net']), want['width'], want['start'][0], want['start'][1], want['end'][0], want['end'][1]))+ away = _pick_points(x, others + [dict(x, start=want['start'], end=want['end'])], [p['at'] for p in pads.values() if x['layer'] in p['layers']])[0]+ step.append('DELETE (%.6fmm %.6fmm);' % (away[0], away[1]))+ # the width is on the new track, so nothing is changed on the old one+ step = [c for c in step if not c.startswith('CHANGE WIDTH')] if ed.get('layer') is not None: want['layer'] = _layer_number(board, ed['layer']) if ed.get('start') is None and ed.get('end') is None:@@ -1237,8 +1309,6 @@ def edit_tracks(app, root, board, meta, args): ok = ok and all(abs(width[_wire_key(p_['want'])] - p_['want']['width']) < 1e-6 for p_ in plan if _wire_key(p_['want']) in width) if not ok: _undo_until(app, args, meta['revision'], sum(1 for c in cmds if not c.startswith('DISPLAY')) + 2)- if any(ed.get('start') is not None or ed.get('end') is not None for ed in edits):- fail('end_move_bends', 'Fusion moves a track end only along the track\'s own line: at an angle it bends the track into two segments, so every change was undone. To re-aim a track, take it up with fusion_ripup_nets and route it again with fusion_route_net.') fail('readback_mismatch', 'The copper after the edit was not exactly the asked tracks; every change was undone') app.executeTextCommand('Electron.run RATSNEST;') return {'success': True, 'mutated': True, **meta2, 'results': results, 'verified': True,@@ -3027,10 +3097,17 @@ def stackup_for(count, total_mm, copper_mm, template): mat.update({'resin': '50%', 'dielectric_constant_1g': '4.5', 'dissipation_factor_1g': '0.02', 'glass_transition_temperature': '125degC', 'source': 'User'}) d = ET.SubElement(root, 'layerdef', {'type': k, 'id': gid(), 'layer': str(num), 'name': 'Dielectric-%d' % num, 'locally_modified_material': 'yes'}) ET.SubElement(d, 'material', mat)- ET.SubElement(root, 'viadef', {'name': 'Thru', 'layers': '1:16'})+ # the shape Fusion writes itself. Without startlayer and stoplayer, Fusion raises LayerId::IsSignalLayer+ # on load and a VIA with a layer range then lands nothing (a VIA without a range still lands)+ ET.SubElement(root, 'viadef', {'name': 'Thru 1:16', 'startlayer': '1', 'stoplayer': '16', 'complement': 'no'}) return root +def stack_has_broken_viadef(board):+ """True for a stack written by fusion_set_layer_count before 1.13.61: a via definition with no start or stop layer."""+ return any(not (v.get('startlayer') and v.get('stoplayer')) for s_ in board.findall('.//layerstackup') for v in s_.findall('viadef'))++ def set_layer_count(app, root, board, meta, args): _guard(meta, args) try:@@ -3045,8 +3122,11 @@ def set_layer_count(app, root, board, meta, args): stack = layer_stack(board) plan = {'from': [L.name(n) for n in now], 'to': ['F.Cu'] + ['In%d.Cu' % i for i in range(1, count - 1)] + ['B.Cu'], 'layerSetup': LAYER_SETUP[count], 'thicknessMm': round((stack.get('totalUm') or 1600) / 1000.0, 4)}- if now == want:+ repair = now == want and stack_has_broken_viadef(board)+ if now == want and not repair: return {'success': True, 'mutated': False, **meta, **plan, 'note': 'the board already has %d copper layers' % count}+ if repair:+ plan['repairedViaDefinition'] = True gone = [n for n in now if n not in want] used = layers_used(board, gone) if used:@@ -3081,7 +3161,7 @@ def set_layer_count(app, root, board, meta, args): got = list(Layers(after).order) st2 = layer_stack(after) same_parts = _elements_of(after) == _elements_of(board)- if got != want or not same_parts:+ if got != want or not same_parts or stack_has_broken_viadef(after): app.executeTextCommand("Electron.run SET CONFIRM NO; DRC LOAD '%s'; SET CONFIRM OFF;" % orig.as_posix()) fail('readback_mismatch', 'Fusion read back copper layers %s (asked %s); the board\'s own rules were loaded back' % ([Layers(after).name(n) for n in got], plan['to'])) return {'success': True, 'mutated': True, **meta2, **plan, 'layers': [Layers(after).name(n) for n in got], 'stackupRead': st2,@@ -4217,22 +4297,28 @@ def handle(app,args,action): if isinstance(q.get('thermals'),dict): q['thermals']=q['thermals'].get('on') if q.get('rank') is None and q.get('priority') is not None: q['rank']=q['priority'] if q.get('orphans') is None and q.get('keepIslands') is not None: q['orphans']=q['keepIslands']- if q.get('net') and q.get('newNet'): fail('not_supported',"A pour's net cannot be changed in place in Fusion (a polygon belongs to its signal): remove_pours it and apply_pours a new one on the other net")+ if q.get('newNet') is not None and identifier(q['newNet']) not in objects(board)[0]: fail('unknown_net','No exact net named '+q['newNet']) if q.get('rank') is not None and not (1<=int(q['rank'])<=6): fail('invalid_args','rank is 1 (poured first) to 6') have=pours(board); before_cu=copper_signature(objects(board)[2]+objects(board)[3]) targets=[] for q in want: 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))]+ same=[e for e in have if e['net']==q.get('net') and e['layer']==eagle_layer(q.get('layer',1),board)] if len(same)!=1: fail('unknown_pour' if not same else 'ambiguous_pour','%d %s pours on layer %s: give its points (pour_state lists each outline)'%(len(same),q.get('net'),q.get('layer'))) q['points']=same[0]['outline'] 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)) and same_outline(e['outline'],pts)),None)+ 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)+ 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')))+ 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) if q.get('isolate') is not None: cmd+=' CHANGE ISOLATE %.6fmm (%.6fmm %.6fmm);'%(number(q['isolate']),*at)@@ -4245,10 +4331,12 @@ def handle(app,args,action): _,after,after_meta=snapshot(app,args); now=pours(after) bad=[]; changes=[] for q,hit in targets:- n=next((e for e in now if e['net']==hit['net'] and e['layer']==hit['layer'] and same_outline(e['outline'],hit['outline'])),None)+ moved=q.get('newNet') is not None and q['newNet']!=hit['net']+ n=next((e for e in now if e['net']==(q['newNet'] if moved else hit['net']) and e['layer']==hit['layer'] and same_outline(e['outline'],hit['outline'])),None)+ if moved and any(e['net']==hit['net'] and e['layer']==hit['layer'] and same_outline(e['outline'],hit['outline']) for e in now): n=None ok=n is not None and (q.get('width') is None or abs(n['width']-q['width'])<1e-4) and (q.get('isolate') is None or abs(n['isolate']-q['isolate'])<1e-4) \ and (q.get('thermals') is None or n['thermals']==bool(q['thermals'])) and (q.get('rank') is None or n['rank']==int(q['rank'])) and (q.get('orphans') is None or n['orphans']==bool(q['orphans']))- changes.append({'net':hit['net'],'layer':hit['layer'],'width':[hit['width'],n and n['width']],'isolate':[hit['isolate'],n and n['isolate']],+ changes.append({'net':hit['net'],'newNet':q['newNet'] if moved else None,'layer':hit['layer'],'width':[hit['width'],n and n['width']],'isolate':[hit['isolate'],n and n['isolate']], '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@@ -4264,20 +4352,12 @@ 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)) and same_outline(e['outline'],[[float(a),float(b)] for a,b in q.get('points',[])]) for q in want)]+ 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)] 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()] for e in targets:- ol=e['outline']; best=None- for a_,b_ in zip(ol,ol[1:]+ol[:1]):- for t_ in (0.25,0.5,0.75):- 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'])- # Shift-click (S x y) deletes the WHOLE polygon; a plain click deletes one vertex of it- cmd='LAYER %d; DELETE (S %.6fmm %.6fmm);'%(e['layer'],*best[1])+ cmd=_pour_delete_cmd(e,pad_pts) attempted=True; app.executeTextCommand('Electron.run '+cmd); commands.append(cmd) app.executeTextCommand('Electron.run RATSNEST;') _,after,after_meta=snapshot(app,args)@@ -4333,9 +4413,18 @@ def handle(app,args,action): want=args.get('pours') if not isinstance(want,list) or not want: fail('invalid_args','pours must be a non-empty list of {net,layer,points,isolate,rank}') existing=pours(board); added=[]; kept=[]+ cutouts=_cutout_rects(board,args.get('cutouts'))+ for c_ in cutouts:+ for b_ in range(0,len(c_['rects']),60):+ cmd='LAYER %d; %s'%(c_['restrictLayer'],' '.join('RECT R0 (%.4fmm %.4fmm) (%.4fmm %.4fmm);'%r_ for r_ in c_['rects'][b_:b_+60]))+ attempted=True; app.executeTextCommand('Electron.run '+cmd); commands.append(cmd) for p in want:- net=identifier(p.get('net')); layer=eagle_layer(p.get('layer',1))- if layer not in (1,16): fail('invalid_geometry','pour layer must be 1 (top) or 16 (bottom)')+ # the standard's names: order is the pour order (1 first), connect is how pads join it+ if p.get('rank') is None and p.get('order') is not None: p['rank']=p['order']+ if p.get('thermals') is None and p.get('connect') is not None:+ if p['connect'] not in ('direct','relief'): fail('not_supported',"connect is direct or relief: Fusion's pour always joins pads of its own net")+ p['thermals']=p['connect']=='relief'+ net=identifier(p.get('net')); layer=eagle_layer(p.get('layer',1),board) pts=[[number(a),number(b)] for a,b in p.get('points') or []] if len(pts)<3: fail('invalid_geometry','a pour needs at least three points') if any(e['net']==net and e['layer']==layer and same_outline(e['outline'],pts) for e in existing): kept.append(net); continue@@ -4348,9 +4437,10 @@ def handle(app,args,action): app.executeTextCommand('Electron.run RATSNEST;') app.executeTextCommand('Electron.ImmediateRepaint /full') _,after,after_meta=snapshot(app,args); ps=pour_state(after)- missing=[p['net'] for p in want if not any(e['net']==p['net'] and e['layer']==eagle_layer(p.get('layer',1)) and same_outline(e['outline'],[[float(a),float(b)] for a,b in p['points']]) for e in ps['pours'])]- return {'success':not missing,'mutated':bool(added),'verified':not missing,'errorCode':None if not missing else 'readback_mismatch',- 'added':added,'kept':kept,'missing':missing,'commands':commands,**ps,**state(after,after_meta)}+ missing=[p['net'] for p in want if not any(e['net']==p['net'] and e['layer']==eagle_layer(p.get('layer',1),board) and same_outline(e['outline'],[[float(a),float(b)] for a,b in p['points']]) for e in ps['pours'])]+ return {'success':not missing,'mutated':bool(added or cutouts),'verified':not missing,'errorCode':None if not missing else 'readback_mismatch',+ 'added':added,'kept':kept,'missing':missing,'cutouts':[{'layer':c_['layer'],'restrictLayer':c_['restrictLayer'],'rects':len(c_['rects'])} for c_ in cutouts],+ 'commands':commands,**ps,**state(after,after_meta)} if action!='route_net': fail('unknown_action','Unknown routing action') candidate=plan(board,args) if 'dryRun' in args and not isinstance(args['dryRun'],bool): fail('invalid_args','dryRun must be boolean')@@ -4383,17 +4473,20 @@ def handle(app,args,action): # cuts another net's pour in two disconnects that net (BQ25792's VBAT, 2026-10-28). Undo it. split_new=sorted({x['net'] for x in after_state.get('splitNets') or []}-split_before-{candidate['net']}) if verified and split_new and not args.get('allowSplit'):- for _ in commands: app.executeTextCommand('Electron.run UNDO;')- app.executeTextCommand('Electron.run RATSNEST;')- _,back,back_meta=snapshot(app,args); back_objs=objects(back)- undone=copper_signature(back_objs[2]+back_objs[3])==before_items+ undone,back,back_meta=undo_route(app,args,commands,before_items) return {'success':False,'mutated':not undone,'verified':undone,'errorCode':'splits_net','splitNets':split_new, 'error':'The route cuts %s in two (a pour split by the new copper)%s'%(', '.join(split_new),'; it was undone' if undone else '; the undo did not restore the copper exactly: read fusion_routing_state'), '_hint':'Plan it on another layer (fusion_plan_route layers:[...]), or around the pour; allowSplit:true keeps a route that splits a net.', 'commands':commands,'plan':candidate,**state(back,back_meta)}- return {'success':verified,'mutated':actual!=before_items,'verified':verified,- 'errorCode':None if verified else 'readback_mismatch',- 'error':None if verified else 'Fusion copper differs from the exact requested delta; inspect current state before continuing',+ if not verified:+ # a route that did not land as planned comes off again, so a retry starts from the board it planned on+ vias_missing=[v for v in expected[1] if v not in actual[1]]; tracks_same=actual[0]==expected[0]+ undone,back,back_meta=undo_route(app,args,commands,before_items)+ return {'success':False,'mutated':not undone,'verified':False,'errorCode':'readback_mismatch','undone':undone,+ 'viasNotLanded':len(vias_missing),'tracksAsPlanned':tracks_same,+ 'error':'Fusion\'s copper was not the planned route%s'%('; it was undone, so the board is as it was' if undone else '; the undo did not restore the copper exactly: read fusion_routing_state before retrying'),+ 'commands':commands,'plan':candidate,**state(back,back_meta)}+ return {'success':True,'mutated':actual!=before_items,'verified':True,'errorCode':None,'error':None, 'commands':commands,'plan':candidate,**after_state} except Exception as exc: return {'success':False,'errorCode':getattr(exc,'code','routing_failed'),'error':str(exc),
tests/layer_drill.pyadded+78@@ -0,0 +1,78 @@+#!/usr/bin/env python3+"""Live drill of layer counts, vias, routes and pours on a new test board in Fusion (run on a shared laptop only when+it is free; never on anyone's design). It answers the questions unit tests can't: does a converted board take vias with a+layer range, does a stack in the old shape get repaired, does a route that doesn't land come off, do inner pours fill?+ python3 tests/layer_drill.py --target DESKTOP-BAR6S0F --dir C:/Users/olive/Documents/fusion-lane-tests/verbs --lib <a .lbr with RES>+"""+import argparse, json, subprocess, sys, tempfile, time+ap = argparse.ArgumentParser()+ap.add_argument('--target', default='DESKTOP-BAR6S0F'); ap.add_argument('--dir', required=True); ap.add_argument('--lib', required=True)+a = ap.parse_args()+A = ['adom-bridge', '--target', a.target, '--ai-thread', 'fusion-lane']+def call(v, args=None):+ o = subprocess.run(A + [v, json.dumps(args or {})], capture_output=True, text=True).stdout+ try: return json.loads(o)+ except Exception: return {'error': o[:300], 'success': False}+results = []+def check(name, ok, detail=''):+ results.append((name, bool(ok), detail)); print('%-46s %s %s' % (name, 'PASS' if ok else 'FAIL', '' if ok else str(detail)[:230]), flush=True)+def dialogs():+ d = call('fusion_check_dialogs'); check('no dialog', not d.get('count'), [x.get('title') for x in d.get('dialogs') or []])+ for x in d.get('dialogs') or []: call('fusion_dismiss_dialogs', {'hwnd': x.get('hwnd'), 'button': 'Cancel', 'reason': 'fusion-lane drill, my own test board'})+def seed(name):+ r = call('fusion_board_seed', {'libraryPath': a.lib, 'outDir': a.dir, 'name': name, 'title': 'drill', 'outlineMm': [40, 30], 'fixed': [+ {'ref': 'R1', 'deviceset': 'RES', 'value': '1k', 'x': 12, 'y': 20, 'nets': {'A': 'MID', 'B': 'NB'}},+ {'ref': 'R2', 'deviceset': 'RES', 'value': '1k', 'x': 28, 'y': 20, 'nets': {'A': 'MID', 'B': 'NC'}}]})+ check('seed ' + name, r.get('success'), r.get('error'))+ r = call('fusion_open_board', {'filePath': '%s/%s.brd' % (a.dir, name)}); check('open ' + name, r.get('success'), r.get('error')); time.sleep(4)+ return call('fusion_get_app_state').get('data', {}).get('activeDocument')+def st(doc): return call('fusion_routing_state', {'expectDocument': doc})+def via_lands(doc, cmd, label):+ b = len(st(doc).get('vias') or []); call('fusion_execute_text_command', {'command': 'Electron.run ' + cmd}); check(label, len(st(doc).get('vias') or []) > b, cmd)+def route(doc, y):+ s = st(doc)+ return call('fusion_route_net', {'expectDocument': doc, 'boardId': s.get('boardId'), 'expectedRevision': s.get('revision'), 'net': 'NB', 'layer': 'F.Cu', 'points': [[2, y], {'x': 4, 'y': y, 'layer': 'B.Cu'}, [6, y]]}), s++print('--- a board converted to 4 layers'); doc = seed('LDrill2')+r = call('fusion_set_layer_count', {'expectDocument': doc, 'count': 4}); check('set_layer_count 4, no assert', r.get('success') and not r.get('fusionAsserts'), {k: r.get(k) for k in ('errorCode', 'error', 'fusionAsserts')}); time.sleep(2); dialogs()+check('four copper layers', len(st(doc).get('layers') or []) == 4 or len(call('fusion_copper_layers', {'expectDocument': doc}).get('layers') or []) == 4)+via_lands(doc, "CHANGE DRILL 0.3mm; VIA 'MID' 0.6mm ROUND 1-16 (2mm 10mm);", 'via 1-16 lands')+via_lands(doc, "VIA 'MID' (4mm 10mm);", 'via with no range lands')+b = len(st(doc).get('vias') or []); call('fusion_execute_text_command', {'command': "Electron.run VIA 'MID' 0.6mm ROUND 1-2 (6mm 10mm);"}); print(' a blind via 1-2:', 'lands' if len(st(doc).get('vias') or []) > b else 'does not land (the layer setup has no blind stage)')+s = st(doc); r = call('fusion_place_vias', {'expectDocument': doc, 'boardId': s.get('boardId'), 'expectedRevision': s.get('revision'), 'vias': [{'net': 'MID', 'x': 8, 'y': 10}]}); check('fusion_place_vias', r.get('success'), r.get('error') or r.get('missing'))+print('--- pours on an inner layer')+s = st(doc); r = call('fusion_apply_pours', {'expectDocument': doc, 'boardId': s.get('boardId'), 'expectedRevision': s.get('revision'), 'pours': [{'net': 'NC', 'layer': 'In1.Cu', 'points': [[1, 1], [39, 1], [39, 29], [1, 29]], 'connect': 'direct', 'order': 2}]})+check('apply_pours on In1.Cu', r.get('success'), r.get('error') or r.get('errorCode')); inner = [p for p in r.get('pours') or [] if p.get('layer') == 2]+check('it has copper in it', inner and inner[0].get('filledMm2', 0) > 100, inner)+s = st(doc); r = call('fusion_apply_pours', {'expectDocument': doc, 'boardId': s.get('boardId'), 'expectedRevision': s.get('revision'), 'pours': [{'net': 'NC', 'layer': 'B.Cu', 'points': [[1, 1], [39, 1], [39, 29], [1, 29]]}]})+whole = next((p.get('filledMm2') for p in r.get('pours') or [] if p.get('layer') == 16), 0); check('pour on B.Cu', whole > 100, r.get('error'))+s = st(doc); r = call('fusion_remove_pours', {'expectDocument': doc, 'boardId': s.get('boardId'), 'expectedRevision': s.get('revision'), 'pours': [{'net': 'NC', 'layer': 'B.Cu', 'points': [[1, 1], [39, 1], [39, 29], [1, 29]]}]}); check('remove it', r.get('success'), r.get('error'))+s = st(doc); r = call('fusion_apply_pours', {'expectDocument': doc, 'boardId': s.get('boardId'), 'expectedRevision': s.get('revision'), 'pours': [{'net': 'NC', 'layer': 'B.Cu', 'points': [[1, 1], [39, 1], [39, 29], [1, 29]]}], 'cutouts': [{'layer': 'B.Cu', 'points': [[30, 3], [38, 3], [38, 11], [30, 11]]}]})+cut = next((p.get('filledMm2') for p in r.get('pours') or [] if p.get('layer') == 16), 0); check('a cutout takes about 64 mm2 out of the pour', 50 < whole - cut < 90, '%s -> %s, %s' % (whole, cut, r.get('error')))+s = st(doc); r = call('fusion_change_pours', {'expectDocument': doc, 'boardId': s.get('boardId'), 'expectedRevision': s.get('revision'), 'pours': [{'net': 'NC', 'layer': 'In1.Cu', 'newNet': 'NB'}]})+check("edit_pours moves a pour's net", r.get('success') and any(p.get('net') == 'NB' and p.get('layer') == 2 for p in r.get('pours') or []), r.get('error') or r.get('errorCode')); dialogs()+call('fusion_close_document', {'name': doc})++print('--- a board with the stack the old conversion wrote'); doc = seed('LDrillOld')+d = tempfile.gettempdir()+r = call('fusion_run_modeling_script', {'script': """+import tempfile, os, xml.etree.ElementTree as ET+p = os.path.join(tempfile.gettempdir(), 'ldrill.edru'); app.executeTextCommand("Electron.run DRC SAVE '%s';" % p.replace(chr(92), '/'))+t = ET.parse(p); r = t.getroot(); r = r.find('designrules') if r.tag != 'designrules' else r+for q in r.findall('param'):+ if q.get('name') == 'layerSetup': q.set('value', '(1+2*15+16)')+st = r.find('layerstackup'); vs = st.find('viadef'); vs.attrib.clear(); vs.set('name', 'Thru'); vs.set('layers', '1:16'); t.write(p, encoding='utf-8', xml_declaration=True)+app.executeTextCommand("Electron.run SET CONFIRM NO; DRC LOAD '%s'; SET CONFIRM OFF;" % p.replace(chr(92), '/')); result = 'ok'+"""}); print(' old stack loaded:', r.get('success'), (r.get('error') or '')[:120]); time.sleep(2); dialogs()+b = len(st(doc).get('tracks') or [])+r, s0 = route(doc, 25); print(' route on the old stack:', r.get('success'), r.get('errorCode'), 'undone', r.get('undone'), 'viasNotLanded', r.get('viasNotLanded'))+if not r.get('success'):+ check('a route that does not land comes off', r.get('undone') and len(st(doc).get('tracks') or []) == b, {k: r.get(k) for k in ('errorCode', 'undone', 'error')})+r = call('fusion_set_layer_count', {'expectDocument': doc, 'count': 4}); check('set_layer_count repairs it', r.get('success') and r.get('repairedViaDefinition'), {k: r.get(k) for k in ('errorCode', 'error', 'note', 'mutated')}); time.sleep(2)+via_lands(doc, "CHANGE DRILL 0.3mm; VIA 'MID' 0.6mm ROUND 1-16 (10mm 5mm);", 'via 1-16 lands after the repair')+r2 = call('fusion_set_layer_count', {'expectDocument': doc, 'count': 4}); check('a second call has nothing to repair', r2.get('success') and not r2.get('mutated'), r2.get('mutated'))+dialogs(); call('fusion_close_document', {'name': doc})++fails = [r for r in results if not r[1]]+print('%d PASS, %d FAIL' % (len(results) - len(fails), len(fails)))+sys.exit(1 if fails else 0)
tests/sch_drill.py+3−2@@ -308,9 +308,10 @@ step('edit_tracks width', 'fusion_edit_tracks', {'expectDocument': 'Untitled', ' step('edit_tracks end, along its line', 'fusion_edit_tracks', {'expectDocument': 'Untitled', 'tracks': [{'id': tid, 'end': [x0, y0 + 6] if pad else [0, 0]}]}, lambda r: expect(r.get('verified') and any(abs(t['end']['y'] - y0 - 6) < 1e-3 or abs(t['start']['y'] - y0 - 6) < 1e-3 for t in call('fusion_routing_state', {'expectDocument': 'Untitled'}).get('tracks') or [] if t.get('net') == 'NB'), 'end did not move')) now = next((t.get('id') for t in call('fusion_routing_state', {'expectDocument': 'Untitled'}).get('tracks') or [] if t.get('net') == 'NB'), None)-before_angle = sorted(track_widths())+before_angle = len(track_widths()) r = call('fusion_edit_tracks', {'expectDocument': 'Untitled', 'tracks': [{'id': now, 'end': [x0 + 3, y0 + 9]}]})-results.append(('edit_tracks angled end refused, undone', expect(r.get('errorCode') == 'end_move_bends' and sorted(track_widths()) == before_angle, '%s, tracks %s' % (r.get('errorCode'), sorted(track_widths())))))+tracks_now = [t for t in call('fusion_routing_state', {'expectDocument': 'Untitled'}).get('tracks') or [] if t.get('net') == 'NB']+results.append(('edit_tracks end, at an angle', expect(r.get('verified') and len(track_widths()) == before_angle and any(abs(t['end']['x'] - x0 - 3) < 1e-3 and abs(t['end']['y'] - y0 - 9) < 1e-3 or abs(t['start']['x'] - x0 - 3) < 1e-3 and abs(t['start']['y'] - y0 - 9) < 1e-3 for t in tracks_now), '%s, %d tracks, NB %s' % (r.get('errorCode') or r.get('error'), len(track_widths()), tracks_now[:2])))) print('%-34s %s' % (results[-1][0], 'PASS' if results[-1][1] is None else 'FAIL ' + str(results[-1][1]))) r = call('fusion_edit_tracks', {'expectDocument': 'Untitled', 'tracks': [{'id': 'track:NOPE:1:0,0,1,1', 'width': 0.3}]}) results.append(('edit_tracks unknown id', expect(r.get('errorCode') == 'nothing_to_do', str(r.get('errorCode')))))
tests/test_routing.py+142@@ -188,6 +188,108 @@ class RoutingTests(unittest.TestCase): self.assertEqual('board_mismatch',res['errorCode']);self.assertFalse(res['mutationAttempted']) finally:r.snapshot=saved +class InnerLayerPourTests(unittest.TestCase):+ """Pours on any copper layer of the board, by number or name, and cutouts on the restrict layers."""+ def setUp(self):+ self.root = ET.parse(ROOT/'demo/routing/fixture/fusion-routing.brd').getroot()+ self.b = self.root.find('drawing/board')+ for p_ in self.b.findall('designrules/param'):+ if p_.get('name') == 'layerSetup': p_.set('value', r.LAYER_SETUP[4])+ 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'})+ self.net = self.b.find('signals/signal').get('name')+ self.pts = [[1, 1], [20, 1], [20, 20], [1, 20]]+ def tearDown(self): r.snapshot = self.saved+ def call(self, action, **a): return r.handle(self.app, dict(a, boardId='b', expectedRevision='r'), action)+ def test_layer_names_follow_the_board(self):+ self.assertEqual([2, 15, 16], [r.eagle_layer(v, self.b) for v in ('In1.Cu', 'in2.cu', 'B.Cu')])+ self.assertEqual(15, r.eagle_layer(15, self.b))+ for bad in ('In3.Cu', 3, 'Top'):+ with self.assertRaises(r.RoutingError): r.eagle_layer(bad, self.b)+ def test_inner_pour_is_drawn_on_its_layer(self):+ out = self.call('apply_pours', pours=[{'net': self.net, 'layer': 'In1.Cu', 'points': self.pts}])+ self.assertTrue(any('LAYER 2; POLYGON' in c for c in out['commands']), out)+ def test_pour_on_a_layer_the_board_lacks_is_refused(self):+ out = self.call('apply_pours', pours=[{'net': self.net, 'layer': 3, 'points': self.pts}])+ self.assertEqual('invalid_layer', out['errorCode']); self.assertFalse(self.sent)+ def test_connect_and_order_take_the_standards_names(self):+ out = self.call('apply_pours', pours=[{'net': self.net, 'layer': 1, 'points': self.pts, 'connect': 'direct', 'order': 3}])+ c = [c for c in out['commands'] if 'POLYGON' in c][0]+ self.assertIn('CHANGE RANK 3', c); self.assertIn('CHANGE THERMALS OFF', c)+ def test_cutouts_go_on_the_restrict_layer_before_the_pours(self):+ out = self.call('apply_pours', pours=[{'net': self.net, 'layer': 'B.Cu', 'points': self.pts}], cutouts=[{'layer': 'B.Cu', 'points': [[5, 5], [8, 5], [8, 8], [5, 8]]}])+ self.assertTrue(out['commands'][0].startswith('LAYER 42; RECT'), out['commands'][0])+ self.assertEqual([{'layer': 'B.Cu', 'restrictLayer': 42, 'rects': 1}], out['cutouts'])+ def test_inner_cutout_is_refused_before_anything_is_drawn(self):+ out = self.call('apply_pours', pours=[{'net': self.net, 'layer': 'In1.Cu', 'points': self.pts}], cutouts=[{'layer': 'In1.Cu', 'points': [[5, 5], [8, 5], [8, 8]]}])+ self.assertEqual('not_supported', out['errorCode']); self.assertFalse(self.sent)+ def test_a_pours_net_moves_by_drawing_it_again_and_reads_back(self):+ nets = [s_.get('name') for s_ in self.b.findall('signals/signal')]+ import copy+ def with_pour(net):+ b = copy.deepcopy(self.b)+ sig = b.find("signals/signal[@name='%s']" % net)+ pg = ET.SubElement(sig, 'polygon', layer='15', width='0.3', isolate='0.2', rank='2', thermals='no', orphans='yes')+ for x, y in [(60, 60), (90, 60), (90, 90), (60, 90)]: ET.SubElement(pg, 'vertex', x=str(x), y=str(y))+ return b+ self.after = with_pour(nets[1]); self.b = with_pour(nets[0])+ r.snapshot = lambda app, args: (self.root, self.after if self.sent else self.b, {'boardId': 'b', 'revision': 'r'})+ out = self.call('change_pours', pours=[{'net': nets[0], 'layer': 'In2.Cu', 'newNet': nets[1]}])+ self.assertTrue(out['success'], out)+ c = out['commands'][0]+ self.assertIn('LAYER 15; DELETE (S ', c); self.assertIn("POLYGON '%s' 0.300000mm" % nets[1], c)+ self.assertIn('CHANGE RANK 2', c); self.assertIn('CHANGE THERMALS OFF', c); self.assertIn('CHANGE ORPHANS ON', c)+ self.assertEqual(nets[1], out['changes'][0]['newNet'])+ def test_a_move_where_the_old_pour_stays_is_a_mismatch(self):+ nets = [s_.get('name') for s_ in self.b.findall('signals/signal')]+ sig = self.b.find("signals/signal[@name='%s']" % nets[0])+ pg = ET.SubElement(sig, 'polygon', layer='15', width='0.3', isolate='0.2')+ for x, y in [(60, 60), (90, 60), (90, 90), (60, 90)]: ET.SubElement(pg, 'vertex', x=str(x), y=str(y))+ out = self.call('change_pours', pours=[{'net': nets[0], 'layer': 15, 'newNet': nets[1]}])+ self.assertEqual('readback_mismatch', out['errorCode'])+++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):+ import copy+ board = ET.parse(ROOT/'demo/routing/fixture/fusion-routing.brd').find('drawing/board')+ landed = copy.deepcopy(board) # the wire landed; nothing else asked for did+ ET.SubElement(landed.find("signals/signal[@name='NET_1']"), 'wire', x1='1', y1='1', x2='2', y2='1', width='0.2', layer='1')+ sent = []+ class App:+ def executeTextCommand(self, cmd): sent.append(cmd)+ saved = r.snapshot+ calls = {'n': 0}+ def snap(app, args):+ calls['n'] += 1+ undos = sum(c == 'Electron.run UNDO;' for c in sent)+ if calls['n'] == 1: return None, board, {'boardId': 'b', 'revision': 'r'}+ return None, (board if undos >= undo_steps_needed else landed), {'boardId': 'b', 'revision': 'r2'}+ r.snapshot = snap+ try:+ out = r.handle(App(), {'boardId': 'b', 'expectedRevision': 'r', 'net': 'NET_1', 'layer': 1, 'points': [{'pad': 'J1.1'}, [14, 7], [40, 7], {'pad': 'J2.1'}]}, 'route_net')+ finally:+ r.snapshot = saved+ return out, sum(c == 'Electron.run UNDO;' for c in sent), sum('LINE ' in c for c in sent)++ def test_mismatch_is_undone_and_stops_when_the_copper_is_back(self):+ out, undos, lines = self.run_route(1)+ self.assertEqual('readback_mismatch', out['errorCode'])+ self.assertTrue(out['undone']); self.assertFalse(out['mutated']); self.assertFalse(out['success'])+ self.assertGreater(lines, 1); self.assertEqual(1, undos) # not one per command: the rest would hit earlier edits++ def test_an_undo_that_cannot_restore_says_so(self):+ out, undos, lines = self.run_route(99)+ self.assertEqual('readback_mismatch', out['errorCode'])+ self.assertFalse(out['undone']); self.assertTrue(out['mutated']); self.assertEqual(lines, undos)+ self.assertIn('did not restore', out['error'])++ class NativeDrcTests(unittest.TestCase): def test_severity_follows_fusions_constants(self): class App:@@ -264,6 +366,30 @@ class EditVerbTests(unittest.TestCase): self.assertEqual(2, len(picks)); self.assertTrue(0 < picks[0][0] < 10 < picks[1][0] < 20, picks) self.assertTrue(all(abs(p[0] - 10) > 4 and p[1] == 5 for p in picks), picks) self.assertNotIn('DISPLAY 23;', runs); self.assertIn('DISPLAY 1;', runs); self.assertIn('DISPLAY 21;', runs)+ def test_track_end_along_its_line_moves_and_at_an_angle_is_drawn_again(self):+ wires = r.objects(self.board)[2]+ x = next(w for w in wires if w['start'] in ([0.0, 5.0], [10.0, 5.0]) and w['end'] in ([10.0, 5.0], [0.0, 5.0]))+ tid = 'track:%s:%d:%g,%g,%g,%g' % (x['net'], x['layer'], *x['start'], *x['end'])+ def moved(to):+ def go(b):+ for w in b.findall("signals/signal[@name='NET_1']/wire"):+ if {w.get('x1'), w.get('x2')} == {'0', '10'} and w.get('layer') == '1':+ w.set('x1', str(x['start'][0])); w.set('y1', str(x['start'][1])); w.set('x2', str(to[0])); w.set('y2', str(to[1]))+ return go+ far = [x['end'][0] + (2 if x['end'][0] > x['start'][0] else -2), 5.0]+ self.edited(moved(far))+ args = {'boardId': 'b', 'revision': 'r1'}+ out = r.edit_tracks(self.app, self.root, self.board, args, {'tracks': [{'id': tid, 'end': far}]})+ self.assertTrue(out['success'], out); self.assertTrue(any(c.startswith('MOVE') for c in self.runs()))+ self.sent.clear()+ angled = [x['end'][0], 9.0]+ self.edited(moved(angled))+ out = r.edit_tracks(self.app, self.root, self.board, args, {'tracks': [{'id': tid, 'end': angled, 'width': 0.2}]})+ self.assertTrue(out['success'], out)+ runs = self.runs()+ self.assertFalse(any(c.startswith('MOVE') for c in runs))+ ln = next(i for i, c in enumerate(runs) if 'LINE ' in c); de = next(i for i, c in enumerate(runs) if c.startswith('DELETE'))+ self.assertLess(ln, de) # the new track first: DELETE of a net's last wire takes the whole signal def test_track_width_mismatch_is_undone(self): def wrong(b): for w in b.findall("signals/signal[@name='NET_1']/wire"): w.set('width', '0.5')@@ -535,6 +661,22 @@ class LayerCountTests(unittest.TestCase): with self.assertRaises(r.RoutingError): r.stackup_for(8, 0.8, {}, None) + def test_stackup_has_the_shape_fusion_writes(self):+ # against a 4-layer stack Fusion wrote itself: same elements and attributes, including the via definition+ real = ET.parse(ROOT/'addin/AdomBridge/resources/dru/Adom-3rdPartyFab-4-Layer.edru').getroot().find('.//layerstackup')+ ours = r.stackup_for(4, 1.6, {}, real)+ shape = lambda s_: [(c.tag, c.get('type'), c.get('layer'), c.get('name'), sorted(c.attrib), [sorted(m.attrib) for m in c]) for c in s_]+ self.assertEqual(shape(real), shape(ours))+ self.assertEqual(dict(real.find('viadef').attrib), dict(ours.find('viadef').attrib))++ def test_a_stack_from_the_old_conversion_is_seen_as_broken(self):+ b = ET.fromstring('<board><designrules/></board>')+ self.assertFalse(r.stack_has_broken_viadef(b))+ st = r.stackup_for(4, 1.6, {}, None); b.find('designrules').append(st)+ self.assertFalse(r.stack_has_broken_viadef(b))+ v = st.find('viadef'); v.attrib.clear(); v.set('name', 'Thru'); v.set('layers', '1:16')+ self.assertTrue(r.stack_has_broken_viadef(b))+ class FootprintPrimitivesTests(unittest.TestCase):