← Commit history
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):