master
John Lauer Preserve BQ independent routing, narration and native copper-retention evidence a4b7711 1mo ago
"""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)