seveibar/am3352-dev-board
A 324-ball TI AM3352 ARM processor with DDR3, LCD, USB, Ethernet, storage, serial, analog, clock, reset, power, and JTAG interfaces, paired with configurable JST-GH board connectors and constrained PCB routing.
- Version
- 1.0.2
- License
- unset
- Stars
- 0
scripts/clean-pad-escapes.py
"""Remove unused through vias and regularize BGA dogbones in saved board routing.
All coordinates are board-world points in mm, +X right, +Y up, +Z above.
Uses the built circuit for pad positions and clearance obstacles. Writes an audit
trail; run a full rebuild, physical copper audit, and DDR validation afterwards.
"""
import copy,json,math,runpy,itertools,collections
from pathlib import Path
from shapely.geometry import Point,LineString
from shapely.strtree import STRtree
x=runpy.run_path('scripts/audit-copper.py');c=x['c'];d=json.loads(Path('design/routed-copper.json').read_text());layers=x['layers']
names={e['source_net_id']:e['name'] for e in c if e['type']=='source_net'}
components={e['source_component_id']:e['name'] for e in c if e['type']=='source_component'}
sourceports={e['source_port_id']:e for e in c if e['type']=='source_port'}
bgaports=[p for p in c if p['type']=='pcb_port' and components[sourceports[p['source_port_id']]['source_component_id']] in ('U1','U3')]
pos=lambda p:(p['x'],p['y'])
def planar(rs):
return sum(math.dist(pos(a),pos(b)) for r in rs for a,b in zip(r,r[1:]) if a['route_type']==b['route_type']=='wire' and a['layer']==b['layer'])
def geometry(r):
for p in r:
if p['route_type']=='via':
for layer in layers:yield layer,Point(pos(p)).buffer(p.get('via_diameter',.3)/2)
for a,b in zip(r,r[1:]):
if a['route_type']==b['route_type']=='wire' and a['layer']==b['layer'] and pos(a)!=pos(b):yield a['layer'],LineString([pos(a),pos(b)]).buffer(a.get('width',.1)/2)
# Original obstacles remain conservative after a move; new geometry is added too.
fixed=[(g,m) for g,m in zip(x['shapes'],x['meta']) if not m[2].startswith('pcb_copper_pour')]
trees={l:STRtree([g for g,m in fixed if m[0]==l]) for l in layers};metadata={l:[m for g,m in fixed if m[0]==l] for l in layers};added={l:[] for l in layers}
def clear(g,layer,net):
tree=trees[layer]
return all(metadata[layer][int(i)][1]==net or g.distance(tree.geometries[int(i)])>=.09999 for i in tree.query(g.buffer(.10001))) and all(n==net or g.distance(h)>=.09999 for h,n in added[layer])
# Identify vias whose annulus only contacts copper on a single layer. They do
# not provide an inter-layer connection. Require a standalone via record.
nonvias=[(g,m) for g,m in zip(x['shapes'],x['meta']) if not m[2].endswith('_via')];contacttree=STRtree([g for g,m in nonvias]);removed=[]
for sid,rs in d['nets'].items():
keep=[]
for ri,r in enumerate(rs):
vias=[p for p in r if p['route_type']=='via']
if len(vias)!=1 or len(set(pos(p) for p in r))!=1:keep.append(r);continue
v=vias[0];g=Point(pos(v)).buffer(v.get('via_diameter',.3)/2);net=x['find'](sid)
touched=[nonvias[int(i)][1] for i in contacttree.query(g) if nonvias[int(i)][1][1]==net and nonvias[int(i)][1][2]!=f'fr_{sid}_{ri}' and g.distance(nonvias[int(i)][0])<.00001]
if len(set(m[0] for m in touched))>1:keep.append(r);continue
# Leave any actual pad/thermal via alone even if its other connection is absent.
if any(m[3] for m in touched):keep.append(r);continue
removed.append(dict(net=names.get(sid,sid),position=pos(v),contactLayers=sorted(set(m[0] for m in touched))))
d['nets'][sid]=keep
def cleanpoints(coords):
out=[]
for p in coords:
if not out or math.dist(out[-1],p)>1e-7:out.append(p)
return out
def elbows(a,b):
dx,dy=b[0]-a[0],b[1]-a[1];sx=1 if dx>=0 else -1;sy=1 if dy>=0 else -1;t=min(abs(dx),abs(dy))
return [cleanpoints([a,(a[0]+sx*t,a[1]+sy*t),b]),cleanpoints([a,(b[0]-sx*t,b[1]-sy*t),b])]
def moved_routes(rs,old,new,pad,net):
result=copy.deepcopy(rs)
for ri,r in enumerate(rs):
if not any(math.dist(pos(p),old)<1e-7 for p in r):continue
if any(p['route_type']=='via' for p in r):
if any(math.dist(pos(p),old)>1e-7 for p in r):return None
for p in result[ri]:p['x'],p['y']=new
continue
coords=[pos(p) for p in r];at=[i for i,p in enumerate(coords) if math.dist(p,old)<1e-7]
if len(at)!=1 or at[0] not in (0,len(r)-1):return None
reverse=at[0]!=0
if reverse:coords=coords[::-1]
# A direct pad escape is replaced with a single 45-degree dogbone.
if r[0]['layer']=='top' and math.dist(coords[-1],pad)<.002 and LineString(coords).length<1.1:
candidates=[[new,pad]]
else:
candidates=[e+coords[2:] for e in elbows(new,coords[1])]
options=[]
for cc in candidates:
line=LineString(cc)
if not line.is_simple:continue
g=line.buffer(r[0].get('width',.1)/2)
if clear(g,r[0]['layer'],net):options.append(cc)
if not options:return None
cc=min(options,key=lambda cc:(LineString(cc).length,len(cc)))
if reverse:cc=cc[::-1]
result[ri]=[dict(route_type='wire',x=round(a,6),y=round(b,6),width=r[0].get('width',.1),layer=r[0]['layer']) for a,b in cc]
return result
moves=[];unchanged=[]
for sid,rs in list(d['nets'].items()):
net=x['find'](sid);originalLength=planar(rs);timed=names.get(sid,'').startswith(('DDR_','USB0_'))
for v in [copy.deepcopy(p) for r in rs for p in r if p['route_type']=='via']:
old=pos(v);pads=[p for p in bgaports if x['find'](p['source_port_id'])==net]
if not pads:continue
p=min(pads,key=lambda p:math.dist(pos(p),old));pad=pos(p);dx,dy=old[0]-pad[0],old[1]-pad[1]
if not .25<math.dist(old,pad)<.85 or abs(abs(dx)-abs(dy))<.005:continue
# Only vias with an actual short top-layer route from this pad are dogbones.
if not any(all(q['route_type']=='wire' and q['layer']=='top' for q in r) and min(math.dist(pos(r[0]),old)+math.dist(pos(r[-1]),pad),math.dist(pos(r[-1]),old)+math.dist(pos(r[0]),pad))<.002 and LineString([pos(q) for q in r]).length<1.1 for r in rs):continue
sx=1 if dx>=0 else -1;sy=1 if dy>=0 else -1;found=None
offsets=sorted([.4,.375,.35,.325],key=lambda off:math.dist(old,(pad[0]+sx*off,pad[1]+sy*off)))
for off in offsets:
new=(round(pad[0]+sx*off,6),round(pad[1]+sy*off,6));g=Point(new).buffer(v.get('via_diameter',.3)/2)
if not all(clear(g,l,net) for l in layers):continue
trial=moved_routes(rs,old,new,pad,net)
if trial is None:continue
delta=planar(trial)-originalLength
# Keep DDR/USB net planar lengths within 40um of their original value.
if timed and abs(delta)>.04:continue
found=(trial,new,delta);break
if found is None:unchanged.append(dict(net=names[sid],position=old));continue
trial,new,delta=found
for r in trial:
for layer,g in geometry(r):added[layer].append((g,net))
rs=trial;d['nets'][sid]=rs
moves.append(dict(net=names[sid],ref=components[sourceports[p['source_port_id']]['source_component_id']],pin=sourceports[p['source_port_id']].get('name'),before=old,after=new,pad=pad,netLengthDeltaMm=delta))
print('Dogbone',names[sid],old,'->',new,flush=True)
Path('design/routed-copper.json').write_text(json.dumps(d,separators=(',',':'))+'\n')
Path('design/pad-escape-cleanup.json').write_text(json.dumps(dict(removedVias=removed,movedDogbones=moves,unchangedDogbones=unchanged),indent=2)+'\n')
print('Removed',len(removed),'unused vias; regularized',len(moves),'dogbones;',len(unchanged),'constrained escapes retained')