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.
master
John Lauer
Preserve BQ independent routing, narration and native copper-retention evidence
a4b7711
1mo ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899
"""Astra's independent geometry model. Reads only the stripped board, never reference traces."""
import math,re,xml.etree.ElementTree as ET
from collections import defaultdict
from pathlib import Path
from shapely.geometry import Point,LineString,Polygon,box
from shapely.affinity import rotate,translate,scale
from shapely.strtree import STRtree
from shapely.ops import unary_union
LAYERS=[1,2,15,16]
ROOT=Path(__file__).resolve().parent
class Geometry:
def __init__(self,path=ROOT/'bq25792-astra-unrouted.brd'):
self.tree=ET.parse(path);self.board=self.tree.getroot().find('drawing/board')
assert not any(self.board.findall('signals/signal/'+t) for t in ['wire','via','polygonpour','polygon'])
self.pads={};self.groups=defaultdict(list);self.obstacles=[];self.meta=[];self.holes=[];self.copper=[];self.vias=[];self.routes=[]
contacts={(c.get('element'),c.get('pad')):s.get('name') for s in self.board.findall('signals/signal') for c in s.findall('contactref')}
packages={(l.get('name'),p.get('name')):p for l in self.board.findall('libraries/library') for p in l.findall('packages/package')}
for el in self.board.findall('elements/element'):
pkg=packages[(el.get('library'),el.get('package'))];rot=el.get('rot','R0');a=float(re.search(r'R(-?[\d.]+)',rot)[1]);mir='M' in rot
def transform(g):
g=rotate(g,a,origin=(0,0))
if mir:g=scale(g,xfact=-1,yfact=1,origin=(0,0))
return translate(g,float(el.get('x')),float(el.get('y')))
local=[]
for p in list(pkg.findall('smd'))+list(pkg.findall('pad')):
x,y=float(p.get('x')),float(p.get('y'));net=contacts.get((el.get('name'),p.get('name')));name=el.get('name')+'.'+p.get('name')
if p.tag=='smd':
dx,dy=float(p.get('dx')),float(p.get('dy'));radius=min(dx,dy)*float(p.get('roundness','0'))/200
g=box(-dx/2+radius,-dy/2+radius,dx/2-radius,dy/2-radius).buffer(radius,quad_segs=24) if radius and radius<min(dx,dy)/2 else Point(0,0).buffer(radius,quad_segs=24) if radius and dx==dy else LineString([(-dx/2+radius,-dy/2+radius),(dx/2-radius,dy/2-radius)]).buffer(radius,quad_segs=24) if radius else box(-dx/2,-dy/2,dx/2,dy/2)
g=translate(rotate(g,float(p.get('rot','R0').replace('R','')),origin=(0,0)),x,y);ls=[17-int(p.get('layer')) if mir else int(p.get('layer'))];drill=None
else:
drill=float(p.get('drill'));dia=max(float(p.get('diameter','0')),drill+.36)
assert p.get('shape','round')=='round',p.attrib
g=Point(x,y).buffer(dia/2,quad_segs=24);ls=LAYERS[:]
at=transform(Point(x,y));g=transform(g)
pad={'name':name,'net':net,'at':(at.x,at.y),'layers':ls,'shape':g,'drill':drill}
self.pads[name]=pad;local.append(pad)
if net:self.groups[net].append(pad)
self.add_obstacle(g,net,ls,'pad')
if drill:self.holes.append((pad['at'],drill/2))
for shape in pkg.findall('polygonshape'):
layer=int(shape.get('layer'));layer=17-layer if mir and layer in LAYERS else layer
if layer not in LAYERS+[41,42,43]:continue
vertices=shape.findall('polygonoutlineobjects/vertex')
assert vertices and not any(v.get('curve') for v in vertices)
g=transform(Polygon([(float(v.get('x')),float(v.get('y'))) for v in vertices]))
if layer in LAYERS:
nets={p['net'] for p in local if layer in p['layers'] and g.covers(Point(p['at']))}
assert len(nets)<=1,('Custom copper touches different pad nets',el.get('name'),nets)
net=next(iter(nets),None);ls=[layer]
else:net='__keepout__';ls=LAYERS if layer==43 else [1 if layer==41 else 16]
self.add_obstacle(g,net,ls,'keepout' if layer>=41 else 'custom-pad')
if layer in LAYERS:
for p in local:
if p['net']==net and layer in p['layers'] and g.intersects(p['shape']):p['shape']=p['shape'].union(g)
for c in pkg.findall('circle'):
if int(c.get('layer')) not in LAYERS+[41,42,43]:continue
layer=int(c.get('layer'));g=transform(Point(float(c.get('x')),float(c.get('y'))).buffer(float(c.get('radius'))+float(c.get('width','0'))/2,quad_segs=24))
self.add_obstacle(g,'__keepout__',[layer] if layer in LAYERS else LAYERS if layer==43 else [1 if layer==41 else 16],'keepout')
self.index=STRtree(self.obstacles);self._copper_index=None;self._size=-1
def add_obstacle(self,g,n,ls,kind):self.obstacles.append(g);self.meta.append((n,ls,kind))
def clear(self,points,net,layer,width):
line=LineString(points)
if not box(.2+width/2,.2+width/2,50.4-.2-width/2,26.4-.2-width/2).covers(line):return False
area=line.buffer(width/2+.105,quad_segs=8)
for i in self.index.query(area):
n,ls,kind=self.meta[i]
if n!=net and layer in ls and area.intersects(self.obstacles[i]):return False
if len(self.copper)!=self._size:
self._copper_index=STRtree([c[2] for c in self.copper]);self._size=len(self.copper)
for i in self._copper_index.query(area):
n,l,g=self.copper[i]
if n!=net and l==layer and area.intersects(g):return False
return True
def via_clear(self,at,net,dia=.45,drill=.2):
# Through vias occupy all four layers; drill spacing also applies on same-net copper.
radius=dia/2
if not all(self.clear([at,at],net,l,dia) for l in LAYERS):return False
if any(math.dist(at,p)<drill/2+r+.205 for p,r in self.holes):return False
if any(Point(at).distance(p['shape'])<radius+.105 for p in self.pads.values()):return False
return True
def add_route(self,net,parts,vias):
for layer,pts,width in parts:self.copper.append((net,layer,LineString(pts).buffer(width/2,quad_segs=12)))
for at in vias:
self.holes.append((at,.1));self.vias.append({'net':net,'at':at,'diameter':.45,'drill':.2})
for l in LAYERS:self.copper.append((net,l,Point(at).buffer(.225,quad_segs=24)))
self.routes.append({'net':net,'parts':[{'layer':l,'points':p,'width':w} for l,p,w in parts],'vias':vias})
def export(self,path):
import copy
tree=copy.deepcopy(self.tree);b=tree.getroot().find('drawing/board');signals={s.get('name'):s for s in b.findall('signals/signal')}
for route in self.routes:
s=signals[route['net']]
for p in route['parts']:
for a,z in zip(p['points'],p['points'][1:]):
if math.dist(a,z)<1e-7:continue
ET.SubElement(s,'wire',{'x1':f'{a[0]:.6f}','y1':f'{a[1]:.6f}','x2':f'{z[0]:.6f}','y2':f'{z[1]:.6f}','width':str(p['width']),'layer':str(p['layer'])})
for v in self.vias:ET.SubElement(signals[v['net']],'via',{'x':f"{v['at'][0]:.6f}",'y':f"{v['at'][1]:.6f}",'extent':'1-16','drill':str(v['drill']),'diameter':str(v['diameter'])})
tree.write(path,encoding='utf-8',xml_declaration=True)