mohan-bee/flight-controller-v2
A 64 mm STM32F405 flight-controller PCB with ICM-42688 IMU, SPI flash, USB-C/ESD, battery buck and 3.3 V regulation, four motor outputs, service I/O, and PETG standoff mounting hardware.
- Version
- 0.1.0
- License
- ISC
- Stars
- 0
tools/audit-manufacturing.py
import json, re, zipfile, runpy, math
from pathlib import Path
from shapely.geometry import Point, LineString, Polygon, box
from shapely.affinity import rotate, translate
from shapely.ops import unary_union
from shapely.validation import make_valid
from gerbonara import GerberFile, ExcellonFile
from gerbonara.utils import MM
env=runpy.run_path(str(Path(__file__).with_name('audit-copper.py')))
c=env['c'];root=env['root'];shapes=env['shapes'];errors=[]
release=root/'manufacturing/jlcpcb'
z=zipfile.ZipFile(release/'fabrication.zip')
def geometry(p):
t=type(p).__name__
if t=='Circle':return Point(p.x,p.y).buffer(p.r,resolution=96)
if t=='Rectangle':
return translate(rotate(box(-p.w/2,-p.h/2,p.w/2,p.h/2),math.degrees(p.rotation),origin=(0,0)),p.x,p.y)
if t=='Line':return LineString([(p.x1,p.y1),(p.x2,p.y2)]).buffer(p.width/2,resolution=96)
if t=='ArcPoly':
assert not any(p.arc_centers or []),'Unexpected curved Gerber region'
return make_valid(Polygon(p.outline))
raise AssertionError(t)
layers={}
for name in z.namelist():
if not name.endswith('.gbr'):continue
parsed=GerberFile.from_string(z.read(name).decode(),filename=name)
geom=Polygon();positives=[]
for o in parsed.objects:
for p in o.to_primitives():
g=geometry(p)
if p.polarity_dark:positives.append(g)
else:
geom=unary_union([geom,*positives]).difference(g);positives=[]
layers[name]=(parsed,unary_union([geom,*positives]))
board=next(x for x in c if x['type']=='pcb_board')
outline=Polygon([(p['x'],p['y']) for p in board['outline']])
edge_min=100
for name in ['F_Cu.gbr','In1_Cu.gbr','In2_Cu.gbr','B_Cu.gbr']:
geom=layers[name][1]
if not outline.covers(geom):errors.append([name,'Copper outside board'])
edge_min=min(edge_min,outline.boundary.distance(geom))
if outline.boundary.distance(geom)<.499:errors.append([name,'Project 0.5 mm copper-edge clearance'])
# Check the final Gerber copper against the source geometry, including plane holes.
agreement={}
for layer,name in [('top','F_Cu.gbr'),('inner1','In1_Cu.gbr'),('inner2','In2_Cu.gbr'),('bottom','B_Cu.gbr')]:
expected=[s['geom'] for s in shapes if layer in s['layers']]
for p in (x for x in c if x['type']=='pcb_copper_pour' and x['layer']==layer):
b=p['brep_shape'];expected.append(make_valid(Polygon([(v['x'],v['y']) for v in b['outer_ring']['vertices']], [[(v['x'],v['y']) for v in r['vertices']] for r in b['inner_rings']])))
expected=unary_union(expected);actual=layers[name][1]
missing=expected.difference(actual.buffer(.001)).area
added=actual.difference(expected.buffer(.001)).area
agreement[layer]=dict(missing_mm2=missing,added_mm2=added)
if missing>1e-6 or added>1e-6:errors.append([name,'Circuit JSON / Gerber mismatch',missing,added])
# G85 is canonical Excellon, but gerbonara does not implement it. Interpret each
# one-block G85 as a routed slot in memory for independent drill geometry parsing.
drills=[];counts={}
for name,plated in [('drill-L1-L4.drl',True),('drill_npth.drl',False)]:
text=z.read(name).decode();converted=[];slots=0
for line in text.splitlines():
if 'G85' in line:
match=re.fullmatch(r'(X[-\d.]+Y[-\d.]+)G85(X[-\d.]+Y[-\d.]+)',line)
assert match,line
converted.extend(['G00'+match[1],'M15','G01'+match[2],'M16','G05']);slots+=1
else:converted.append(line)
parsed=ExcellonFile.from_string('\n'.join(converted),filename=name,plated=plated)
counts[name]=dict(round_holes=len(list(parsed.drills())),slots=slots)
for o in parsed.objects:
drills.append(dict(geom=unary_union([geometry(p) for p in o.to_primitives(unit=MM)]),plated=plated,diameter=o.aperture.diameter))
assert counts['drill-L1-L4.drl']==dict(round_holes=sum(x['type']=='pcb_via' or (x['type']=='pcb_plated_hole' and x['shape'] in ['circle','circular_hole_with_rect_pad']) for x in c),slots=4),counts
assert counts['drill_npth.drl']==dict(round_holes=10,slots=0),counts
holegap=100
for i,a in enumerate(drills):
for b in drills[i+1:]:
gap=a['geom'].distance(b['geom']);holegap=min(holegap,gap)
minimum=.25 if a['diameter']==b['diameter']==.25 else .45
if gap+.001<minimum:errors.append(['Drill spacing',gap,minimum])
npthgap=min(d['geom'].distance(layers[name][1]) for d in drills if not d['plated'] for name in ['F_Cu.gbr','In1_Cu.gbr','In2_Cu.gbr','B_Cu.gbr'])
if npthgap<.199:errors.append(['NPTH to copper',npthgap])
via_ring=min((x['outer_diameter']-x['hole_diameter'])/2 for x in c if x['type']=='pcb_via')
pth_ring=min((x['outer_diameter']-x['hole_diameter'])/2 if x['shape']=='circle' else (min(x['rect_pad_width'],x['rect_pad_height'])-x['hole_diameter'])/2 if x['shape']=='circular_hole_with_rect_pad' else min(x['outer_width']-x['hole_width'],x['outer_height']-x['hole_height'])/2 for x in c if x['type']=='pcb_plated_hole')
if via_ring<.075 or pth_ring<.2-1e-9:errors.append(['Annular rings',via_ring,pth_ring])
mask=layers['F_Mask.gbr'][1];paste=layers['F_Paste.gbr'][1]
if paste.difference(mask.buffer(.001)).area>1e-6:
errors.append(['Paste outside mask openings'])
pad_shapes={s['id']:s['geom'] for s in shapes if s['kind']=='pad'}
for p in (x for x in c if x['type']=='pcb_smtpad' and x.get('pcb_port_id')):
if not mask.buffer(.001).covers(pad_shapes[p['pcb_smtpad_id']]):
errors.append(['Top solder land not exposed',p['pcb_smtpad_id']])
for x,y in [(-30,0),(30,-24),(30,0)]:
disk=Point(x,y).buffer(.999,resolution=96)
if not mask.covers(disk):errors.append(['Fiducial mask',x,y])
if paste.intersects(disk):errors.append(['Fiducial paste',x,y])
if not layers['B_Paste.gbr'][1].is_empty:errors.append(['Unexpected bottom paste'])
unpopulated={x['pcb_component_id'] for x in c if x['type']=='pcb_component' and x.get('do_not_place')}
for p in (x for x in c if x['type']=='pcb_smtpad' and x.get('pcb_component_id') in unpopulated):
if paste.intersects(Point(p['x'],p['y'])):errors.append(['Bare pad has paste',p['pcb_smtpad_id']])
silk=layers['F_SilkScreen.gbr']
silk_gap=silk[1].distance(mask)
silk_width=min(p.width for o in silk[0].objects for p in o.to_primitives())
if silk_gap<.149 or silk_width<.149:errors.append(['Silkscreen rules',silk_gap,silk_width])
(release/'gerber-top-copper.svg').write_text(str(layers['F_Cu.gbr'][0].to_svg(fg='#e8bc6c',bg='#102019',force_bounds=((-30,-30),(30,30)))))
report=dict(status='pass' if not errors else 'fail',errors=errors,copper_edge_min_mm=edge_min,drill_counts=counts,drill_edge_gap_min_mm=holegap,npth_copper_gap_min_mm=npthgap,via_annular_ring_mm=via_ring,usb_slot_annular_ring_min_mm=pth_ring,silkscreen_mask_gap_mm=silk_gap,silkscreen_line_width_mm=silk_width,gerber_circuit_geometry_agreement=agreement,fiducials=3,top_assembly_only=True,scope='Independent Gerbonara parser and Shapely geometry audit of final tscircuit CAM. G85 slots interpreted as equivalent routed slots in memory. Not a manufacturer CAM approval or electrical/thermal qualification.')
(root/'validation/release/manufacturing-audit.json').write_text(json.dumps(report,indent=2)+'\n')
print(json.dumps(report,indent=2))
assert not errors