main
John Lauer Publish 0.1.26 0b71841
#!/usr/bin/env python3
"""KiCad mask obstacles, including complete custom-pad primitives, in board mm.
Does not substitute a custom pad's anchor for its copper/mask polygon.
Unsupported custom primitives fail closed. Caller supplies body/via obstacles.
"""
import json,re,math,argparse
from pathlib import Path
from shapely.geometry import Polygon,Point,LineString,box,mapping
from shapely.affinity import rotate,translate
from shapely.ops import unary_union

def parse(text):
 ts=iter(re.findall(r'"(?:\\.|[^"\\])*"|[^\s()]+|[()]',text))
 def rec():
  a=[]
  for t in ts:
   if t==')':return a
   a.append(rec() if t=='(' else json.loads(t) if t.startswith('"') else t)
  return a
 return rec()[0]
def sub(n,k):return [x for x in n if isinstance(x,list) and x and x[0]==k]
def one(n,k):return next(iter(sub(n,k)),[k])[1:]
def xy(n,k):return tuple(map(float,one(n,k)[:2]))
def primitive(n):
 kind=n[0];width=float((one(n,'width') or [0])[0]);fill=one(n,'fill')==['yes']
 if kind=='gr_poly':
  p=Polygon([tuple(map(float,v[1:3])) for v in one(n,'pts') if v[0]=='xy']);g=p if fill else p.exterior
 elif kind=='gr_rect':
  a,b=xy(n,'start'),xy(n,'end');p=box(min(a[0],b[0]),min(a[1],b[1]),max(a[0],b[0]),max(a[1],b[1]));g=p if fill else p.exterior
 elif kind=='gr_line':g=LineString([xy(n,'start'),xy(n,'end')])
 elif kind=='gr_circle':
  center=xy(n,'center');p=Point(center).buffer(math.dist(center,xy(n,'end')),quad_segs=24);g=p if fill else p.exterior
 else:raise ValueError('Unsupported custom-pad primitive: '+kind)
 return g.buffer(width/2) if width else g

def pad_shape(p):
 w,h=map(float,one(p,'size'));kind=p[3];rect=box(-w/2,-h/2,w/2,h/2)
 if kind=='custom':
  primitives=one(p,'primitives')
  if not primitives:raise ValueError('Custom pad without primitives')
  anchor=one(one(p,'options'),'anchor');base=Point(0,0).buffer(min(w,h)/2) if anchor==['circle'] else rect
  return unary_union([base]+[primitive(n) for n in primitives])
 if kind=='rect':return rect
 if kind=='circle':return Point(0,0).buffer(w/2,quad_segs=24)
 if kind=='oval':return LineString([(-(w-h)/2,0),((w-h)/2,0)]).buffer(h/2) if w>=h else LineString([(0,-(h-w)/2),(0,(h-w)/2)]).buffer(w/2)
 if kind=='roundrect':
  r=min(w,h)*float((one(p,'roundrect_rratio') or [0])[0]);return box(-w/2+r,-h/2+r,w/2-r,h/2-r).buffer(r) if r else rect
 raise ValueError('Unsupported mask pad shape: '+kind)
def obstacles(root,clearance=0.0):
 result=[];setup=one(root,'setup');globalmargin=float((one(setup,'pad_to_mask_clearance') or [0])[0])
 for f in sub(root,'footprint'):
  ref=next((x[2] for x in sub(f,'property') if x[1]=='Reference'),'?');at=list(map(float,one(f,'at')));fa=at[2] if len(at)>2 else 0;fm=float((one(f,'solder_mask_margin') or [globalmargin])[0])
  for idx,p in enumerate(sub(f,'pad')):
   layers=one(p,'layers');sides=[s for s in ['F','B'] if s+'.Mask' in layers or '*.Mask' in layers]
   if not sides:continue
   pa=list(map(float,one(p,'at')));angle=pa[2] if len(pa)>2 else fa;center=rotate(Point(pa[:2]),-fa,origin=(0,0));margin=float((one(p,'solder_mask_margin') or [fm])[0]);g=pad_shape(p).buffer(margin+clearance)
   if g.is_empty:continue
   g=translate(rotate(g,-angle,origin=(0,0)),at[0]+center.x,at[1]+center.y)
   for side in sides:result.append(dict(id=f'{ref}:pad:{p[1]}:{idx}',reference=ref,pad=p[1],kind='pad-mask-or-hole',layer=side+'.SilkS',polygon=mapping(g),box=list(g.bounds),sourceShape=p[3],maskMargin=margin,silkClearance=clearance))
 return result
if __name__=='__main__':
 p=argparse.ArgumentParser();p.add_argument('--board',required=True);p.add_argument('--out',required=True);a=p.parse_args();r=obstacles(parse(Path(a.board).read_text()));Path(a.out).write_text(json.dumps(r));print(len(r),'native mask obstacles; custom pads',sum(o['sourceShape']=='custom' for o in r))