mohan-bee/rc-link-breakaway-panel
A four-layer RC link panel combining an STM32/E28 2.4‑GHz transmitter with USB-RP2040 socket and an STM32/E28 receiver providing six buffered PWM servo outputs, regulated 3.3‑V power, headers, controls, and protection/decoupling.
- Version
- 0.1.5
- License
- unset
- Stars
- 0
tools/audit-cam.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 shapely import get_parts
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'
z=zipfile.ZipFile(release/'raw-gerbers.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
# Gerbonara does not implement valid RS-274X aperture rotation. Preserve it
# explicitly for flashes while parsing an in-memory copy; CAM is untouched.
raw=z.read(name).decode();lines=[];flash_angles=[];angle=0
for line in raw.splitlines():
match=re.fullmatch(r'%LR([-\d.]+)\*%',line)
if match:
angle=float(match[1]);continue
lines.append(line)
if line.endswith('D03*'):flash_angles.append(angle)
elif angle and line.endswith('D01*'):raise AssertionError('Rotated draw not implemented')
parsed=GerberFile.from_string('\n'.join(lines),filename=name)
flash_index=0
geom=Polygon();positives=[]
for o in parsed.objects:
aperture_angle=0
if type(o).__name__=='Flash':
aperture_angle=flash_angles[flash_index];flash_index+=1
for p in o.to_primitives():
g=geometry(p)
if aperture_angle:g=rotate(g,aperture_angle,origin=(o.x,o.y))
if p.polarity_dark:positives.append(g)
else:
geom=unary_union([geom,*positives]).difference(g);positives=[]
assert flash_index==len(flash_angles),(name,flash_index,len(flash_angles))
layers[name]=(parsed,unary_union([geom,*positives]))
from shapely.ops import polygonize
edges=[]
for o in layers['Edge_Cuts.gbr'][0].objects:
for p in o.to_primitives():
assert type(p).__name__=='Line'
edges.append(LineString([(p.x1,p.y1),(p.x2,p.y2)]))
rings=list(polygonize(unary_union(edges)))
material=max(rings,key=lambda p:p.area)
expected=json.loads((root/'panel-geometry.json').read_text())
expected_slots=unary_union([Polygon([(v['x'],v['y']) for v in pts]) for pts in expected['slots']])
assert len(material.interiors)==9, len(material.interiors)
assert material.symmetric_difference(box(-66.5,-38,66.5,38).difference(expected_slots)).area<.001
counts={}
for name in ['drill-L1-L4.drl','drill_npth.drl']:
f=ExcellonFile.from_string(z.read(name).decode())
counts[name]=len(f.objects)
assert counts=={'drill-L1-L4.drl':322,'drill_npth.drl':96},counts
for name in ['F_Cu.gbr','In1_Cu.gbr','In2_Cu.gbr','B_Cu.gbr']:
assert material.covers(layers[name][1]), name
assert material.boundary.distance(layers[name][1])>.499, name
for h in expected['mouseBites']:
assert layers[name][1].distance(Point(h['x'],h['y']))-.3>.249, name
for f in [*expected['fiducials'], {'x':-53.5,'y':18},{'x':5.5,'y':16},{'x':-50.5,'y':-23}, {'x':26,'y':21},{'x':56.5,'y':17},{'x':16.5,'y':-3}]:
p=Point(f['x'],f['y'])
assert layers['F_Cu.gbr'][1].buffer(.001).covers(p.buffer(.499)), f
assert layers['F_Mask.gbr'][1].buffer(.001).covers(p.buffer(.999)), f
assert layers['F_Paste.gbr'][1].intersection(p.buffer(1)).area<.00001, f
agreement={}
for layer,name in [('top','F_Cu.gbr'),('inner1','In1_Cu.gbr'),('inner2','In2_Cu.gbr'),('bottom','B_Cu.gbr')]:
native=[s['geom'] for s in shapes if layer in s['layers']]
for e in c:
if e['type']!='pcb_copper_pour' or e['layer']!=layer:continue
b=e['brep_shape']
native.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']])))
native=unary_union(native);actual=layers[name][1]
missing=native.difference(actual.buffer(.001)).area
added=actual.difference(native.buffer(.001)).area
agreement[layer]=dict(missing_mm2=missing,added_mm2=added)
assert missing<1e-6 and added<1e-6,(layer,missing,added)
drill_geometry=[]
for filename,plated in [('drill-L1-L4.drl',True),('drill_npth.drl',False)]:
parsed=ExcellonFile.from_string(z.read(filename).decode())
actual=[]
for o in parsed.objects:
assert type(o).__name__=='Flash'
actual.append((o.x,o.y,o.aperture.diameter))
drill_geometry.append((Point(o.x,o.y).buffer(o.aperture.diameter/2,resolution=64),plated))
native=[(e['x'],e['y'],e['hole_diameter']) for e in c if (e['type'] in ['pcb_via','pcb_plated_hole'] if plated else e['type']=='pcb_hole')]
assert len(native)==len(actual),filename
# Excellon output rounds coordinates to 0.0001 mm: <=0.000071 mm radial error.
for x,y,diameter in native:
candidates=[i for i,(ax,ay,ad) in enumerate(actual) if abs(ad-diameter)<1e-6 and math.hypot(x-ax,y-ay)<.000071]
assert len(candidates)==1,(filename,x,y,diameter)
actual.pop(candidates[0])
assert not actual,filename
npth_gap=min(g.distance(layers[name][1]) for g,plated in drill_geometry if not plated for name in ['F_Cu.gbr','In1_Cu.gbr','In2_Cu.gbr','B_Cu.gbr'])
assert npth_gap>.199,npth_gap
smt_ids={e['pcb_smtpad_id'] for e in c if e['type']=='pcb_smtpad'}
smt_shapes=[s['geom'] for s in shapes if s['id'] in smt_ids]
for g,_ in drill_geometry:
assert all(g.intersection(pad).area<1e-8 for pad in smt_shapes), 'Drill crosses SMT land'
drill_gap=min(a.distance(b) for i,(a,_) in enumerate(drill_geometry) for b,_ in drill_geometry[i+1:])
assert drill_gap>=.249,drill_gap
via_ring=min((e['outer_diameter']-e['hole_diameter'])/2 for e in c if e['type']=='pcb_via')
assert via_ring>=.099,via_ring
mask=layers['F_Mask.gbr'][1];paste=layers['F_Paste.gbr'][1]
mask_parts=list(get_parts(mask))
mask_web=min(a.distance(b) for i,a in enumerate(mask_parts) for b in mask_parts[i+1:])
assert mask_web>=.099,mask_web
assert paste.difference(mask.buffer(.001)).area<1e-6,'Paste outside mask'
minimum_area_ratio=min(p.area/(p.length*.1) for p in get_parts(paste))
assert minimum_area_ratio>=.66,minimum_area_ratio
for e in (e for e in c if e['type']=='pcb_smtpad' and e.get('pcb_port_id')):
s=next(s for s in shapes if s['id']==e['pcb_smtpad_id'])
assert mask.buffer(.001).covers(s['geom']),e['pcb_smtpad_id']
assert layers['B_Paste.gbr'][1].is_empty,'Bottom assembly paste'
assert all(e['layer']=='top' for e in c if e['type']=='pcb_smtpad')
silk_gap=layers['F_SilkScreen.gbr'][1].distance(mask)
silk_width=min(p.width for o in layers['F_SilkScreen.gbr'][0].objects for p in o.to_primitives())
if silk_gap<.149 or silk_width<.149:
offenders=[dict(bounds=geometry(p).bounds,width=p.width,gap=geometry(p).distance(mask)) for o in layers["F_SilkScreen.gbr"][0].objects for p in o.to_primitives() if p.width<.149 or geometry(p).distance(mask)<.149]
(root/".tmp/silk-offenders.json").write_text(json.dumps(offenders,indent=2))
assert silk_gap>=.149 and silk_width>=.149,(silk_gap,silk_width)
ep=box(36.5-1.8499963,-8-1.8499963,36.5+1.8499963,-8+1.8499963)
thermal_paste=paste.intersection(ep)
assert len(list(get_parts(thermal_paste)))==4,'QFN thermal pad needs four stencil windows'
coverage=thermal_paste.area/ep.area
assert .60<coverage<.75,coverage
report=dict(status='pass',routed_slots=9,drill_counts=counts,exposed_no_paste_fiducials=9,gerber_circuit_geometry_agreement=agreement,
drill_edge_gap_min_mm=drill_gap,npth_to_copper_min_mm=npth_gap,minimum_via_annular_ring_mm=via_ring,mask_web_min_mm=mask_web,minimum_stencil_area_ratio_at_0_1mm=minimum_area_ratio,
qfn_thermal_paste_coverage=coverage,silkscreen_to_mask_min_mm=silk_gap,silkscreen_line_min_mm=silk_width,top_assembly_only=True,
scope='Independent actual CAM: four-layer copper agreement, outline, exact drills, drill-to-SMT, NPTH clearance, mask, paste, stencil release, silkscreen and fiducials. Not factory CAM approval or physical qualification.')
(root/'validation/cam-audit.json').write_text(json.dumps(report,indent=2)+'\n')
print(json.dumps(report,indent=2))