gokul/cm4-media-carrier

A tscircuit package created by gokul.

Version
0.1.0
License
unset
Stars
0

tools/gerber-audit.py

"""Independent audit of delivered Gerbers/drills against the routed circuit JSON."""
import json, math, re, sys, warnings
from pathlib import Path
from collections import Counter
from dataclasses import replace
from gerbonara import GerberFile, ExcellonFile
from shapely.geometry import Polygon, LineString
root=Path(sys.argv[1]).resolve(); j=json.loads((root/'circuit.json').read_text()); folder=root/'gerbers'
errors=[]; counts={}
def read(name):
 text=(folder/name).read_text(); rotations=[]; angle=0
 for line in text.splitlines():
  m=re.match(r'%LR([-\d.]+)\*%',line)
  if m: angle=float(m.group(1))
  if 'D03*' in line: rotations.append(angle)
 with warnings.catch_warnings(record=True) as warns:
  f=GerberFile.from_string(text,filename=name)
  for w in warns:
   if 'Unknown statement' not in str(w.message) or '"LR' not in str(w.message): errors.append(str(w.message))
 flashes=[o for o in f.objects if type(o).__name__=='Flash']
 assert len(rotations)==len(flashes)
 for o,deg in zip(flashes,rotations):
  if deg:
   assert type(o.aperture).__name__ in ['RectangleAperture','CircleAperture'],type(o.aperture).__name__
   o.aperture=replace(o.aperture,rotation=math.radians(deg))._rotated()
 counts[name]=dict(Counter(type(o).__name__ for o in f.objects));return f
files={n:read(n) for n in ['F_Cu.gbr','B_Cu.gbr','In1_Cu.gbr','In2_Cu.gbr','F_Mask.gbr','B_Mask.gbr','F_Paste.gbr','B_Paste.gbr','Edge_Cuts.gbr']}
def geometry(e):
 if e['shape']=='polygon': return Polygon([(p['x'],p['y']) for p in e['points']]).bounds
 x=e['x'];y=e['y'];w=e.get('width',e.get('rect_pad_width',e.get('outer_width',e.get('outer_diameter',e.get('radius',0)*2))));h=e.get('height',e.get('rect_pad_height',e.get('outer_height',e.get('outer_diameter',e.get('radius',0)*2))))
 a=math.radians(e.get('ccw_rotation',e.get('rect_ccw_rotation',0)));w,h=abs(w*math.cos(a))+abs(h*math.sin(a)),abs(w*math.sin(a))+abs(h*math.cos(a))
 return (x-w/2,y-h/2,x+w/2,y+h/2)
def bbox(o):
 (x1,y1),(x2,y2)=o.bounding_box();return (x1,y1,x2,y2)
def close(a,b,tol=.00001):return max(abs(x-y) for x,y in zip(a,b))<=tol
pads=[e for e in j if e['type'] in ['pcb_smtpad','pcb_plated_hole']]; checked=0
cu=[o for o in files['F_Cu.gbr'].objects if type(o).__name__ in ['Flash','Region']]
for p in pads:
 expected=geometry(p)
 if not any(close(expected,bbox(o)) for o in cu): errors.append(dict(check='copper pad geometry',pad=p.get('pcb_smtpad_id',p.get('pcb_plated_hole_id')),expected=expected))
 else: checked+=1
# Every routed line, with its actual aperture width and copper layer.
def key(a,b,w): return tuple(sorted((tuple(round(v,5) for v in a),tuple(round(v,5) for v in b))))+(round(w,5),)
lines={layer:Counter(key((o.x1,o.y1),(o.x2,o.y2),o.aperture.diameter) for o in files[file].objects if type(o).__name__=='Line') for layer,file in [('top','F_Cu.gbr'),('bottom','B_Cu.gbr')]}
segments=0
for trace in [e for e in j if e['type']=='pcb_trace']:
 for a,b in zip(trace['route'],trace['route'][1:]):
  if a['route_type']!='wire' or b['route_type']!='wire' or a['layer']!=b['layer']:continue
  if math.hypot(a['x']-b['x'],a['y']-b['y'])<.000001:continue
  k=key((a['x'],a['y']),(b['x'],b['y']),a['width'])
  if k not in lines[a['layer']]:
   objects=files['F_Cu.gbr' if a['layer']=='top' else 'B_Cu.gbr'].objects
   aa=(a['x'],a['y']);bb=(b['x'],b['y'])
   found=any(type(o).__name__=='Line' and abs(o.aperture.diameter-a['width'])<.000001 and ((close(aa,(o.x1,o.y1),.000001) and close(bb,(o.x2,o.y2),.000001)) or (close(aa,(o.x2,o.y2),.000001) and close(bb,(o.x1,o.y1),.000001))) for o in objects)
   if not found:errors.append(dict(check='trace segment',trace=trace['pcb_trace_id'],key=k))
  segments+=1
# Compare the actual dark/clear plane regions (not a viewer's composite).
planes={};plane_diffs={}
for e in [e for e in j if e['type']=='pcb_copper_pour']:
 shape=e['brep_shape']; expected=Polygon([(p['x'],p['y']) for p in shape['outer_ring']['vertices']], [[(p['x'],p['y']) for p in ring['vertices']] for ring in shape['inner_rings']])
 actual=Polygon();filename='In1_Cu.gbr' if e['layer']=='inner1' else 'In2_Cu.gbr'
 for o in files[filename].objects:
  if type(o).__name__=='Region':
   poly=Polygon(o.outline);actual=actual.union(poly) if o.polarity_dark else actual.difference(poly)
 delta=expected.symmetric_difference(actual).area;plane_diffs[e['layer']]=delta;planes[e['layer']]=actual
 if delta>.00001:errors.append(dict(check='plane regions',layer=e['layer'],delta=delta))
refs=[];sources={e['source_trace_id']:e for e in j if e['type']=='source_trace'}
for t in [e for e in j if e['type']=='pcb_trace']:
 name=sources.get(t.get('source_trace_id'),{}).get('name','')
 if not name.startswith(('HDMI_','USB_DATA_')):continue
 r=t['route'];copper=LineString([(p['x'],p['y']) for p in r]).buffer(r[0]['width']/2)
 uncovered=copper.difference(planes['inner1']).area
 refs.append(dict(name=name,unreferenced_area_mm2=uncovered,reference_boundary_distance_mm=copper.distance(planes['inner1'].boundary)))
 if uncovered>1e-8:errors.append(dict(check='reference plane',name=name,area=uncovered))
# Actual stencil must match the explicit source apertures, and be top-only.
paste=[e for e in j if e['type']=='pcb_solder_paste']; paste_objects=files['F_Paste.gbr'].objects
for p in paste:
 if p['layer']!='top' or not any(close(geometry(p),bbox(o)) for o in paste_objects):errors.append(dict(check='paste geometry',id=p['pcb_solder_paste_id']))
if len(paste)!=269 or len(paste_objects)!=269 or files['B_Paste.gbr'].objects: errors.append(dict(check='paste count/layers',source=len(paste),top=len(paste_objects),bottom=len(files['B_Paste.gbr'].objects)))
# Independently decode decimal metric drill hits and G85 routed slots.
drills={}
for filename,expected_count in [('drill-L1-L4.drl',sum(e['type'] in ['pcb_via','pcb_plated_hole'] for e in j)),('drill_npth.drl',sum(e['type']=='pcb_hole' for e in j))]:
 text=(folder/filename).read_text(); rows=text.splitlines(); tools={};diameter=None;actual=[]
 for i,line in enumerate(rows):
  tool=re.fullmatch(r'T(\d+)C([\d.]+)',line)
  select=re.fullmatch(r'T(\d+)',line)
  hit=re.fullmatch(r'X([-\d.]+)Y([-\d.]+)',line)
  if tool: tools[tool[1]]=float(tool[2])
  elif select: diameter=tools[select[1]]
  elif hit:
   x,y=map(float,hit.groups());x2,y2=x,y
   slot=re.fullmatch(r'G85X([-\d.]+)Y([-\d.]+)',rows[i+1]) if i+1<len(rows) else None
   if slot:x2,y2=map(float,slot.groups())
   actual.append((min(x,x2)-diameter/2,min(y,y2)-diameter/2,max(x,x2)+diameter/2,max(y,y2)+diameter/2))
 drills[filename]=len(actual)
 if len(actual)!=expected_count:errors.append(dict(check='drill count',file=filename,count=len(actual)))
 expected=[e for e in j if e['type'] in (['pcb_via','pcb_plated_hole'] if 'L1' in filename else ['pcb_hole'])]
 for e in expected:
  x=e['x'];y=e['y'];w=e.get('hole_width',e.get('hole_diameter'));h=e.get('hole_height',e.get('hole_diameter'))
  a=math.radians(e.get('ccw_rotation',0));w,h=abs(w*math.cos(a))+abs(h*math.sin(a)),abs(w*math.sin(a))+abs(h*math.cos(a))
  bb=(x-w/2,y-h/2,x+w/2,y+h/2)
  if not any(close(bb,o,.0006) for o in actual):errors.append(dict(check='drill geometry',expected=bb))
outline=files['Edge_Cuts.gbr'].objects
points=[(o.x1,o.y1) for o in outline]+[(o.x2,o.y2) for o in outline]
if len(outline)!=4 or any(points.count(p)!=2 for p in set(points)) or not close((max(p[0] for p in points)-min(p[0] for p in points),max(p[1] for p in points)-min(p[1] for p in points)),(70,76)):
 errors.append(dict(check='closed outline dimensions',points=points))
report=dict(passes=not errors,errors=errors,checked_copper_pads=checked,checked_trace_segments=segments,plane_symmetric_difference_mm2=plane_diffs,high_speed_reference=refs,top_stencil_apertures=len(paste_objects),bottom_stencil_apertures=len(files['B_Paste.gbr'].objects),drill_counts=drills,layer_object_counts=counts,parser_notes='Gerbonara 0.13; LR transforms applied explicitly to rectangle apertures; Excellon decimal metric hits/G85 slots decoded independently. Delivered files unchanged.')
(root/'gerber-audit.json').write_text(json.dumps(report,indent=2));print(json.dumps(report,indent=2));assert report['passes']
for n in ['In1_Cu.gbr','In2_Cu.gbr','F_Cu.gbr','B_Cu.gbr','F_Paste.gbr','F_Mask.gbr']:
 (root/(n+'.svg')).write_text(str(files[n].to_svg()))