AnasSarkiz/muse-voice-remote
Muse Voice Remote — bare ESP32-S3R8, 155 TOP JLCPCB parts with real supplier CAD, native 3D assembly and GLB exports. Revised 17 mm case allocation; 0.30 mm via drill /0.45 mm pad. Checked prototype PCB; mechanical, RF and MUSE qualification pending.
- Version
- 1.3.2
- License
- unset
- Stars
- 0
scripts/export-fabrication.py
#!/usr/bin/env python3
"""Export a checked prototype manufacturing handoff from native imported CAD.
Supplier electronic copper is immutable. A separate factory copy receives
standard reference designators and thermal-pad stencil windows at 60% (TI DRC regulator: 80%) coverage.
"""
import csv,datetime,hashlib,json,math,os,pathlib,re,shutil,subprocess,sys,zipfile
import pcbnew
sys.path.insert(0,"/workspace/.cache/native-python")
from shapely.geometry import Polygon,box
from shapely.ops import unary_union
root=pathlib.Path(__file__).resolve().parents[1]
version=json.loads((root/'package.json').read_text())['version']
out=root/'manufacturing';out.mkdir(exist_ok=True)
source=root/'hardware/MuseVoiceRemote.kicad_pcb'
copper=json.loads((root/'design/routed-copper.json').read_text())
if hashlib.sha256(source.read_bytes()).hexdigest()!=copper['cad_sha256']:raise RuntimeError('Stale checked native CAD')
if hashlib.sha256((root/'docs/routing-input.json').read_bytes()).hexdigest()!=copper['input_sha256']:raise RuntimeError('Stale layout/net map')
env={**os.environ,'XDG_CONFIG_HOME':'/workspace/.config','XDG_DATA_HOME':'/workspace/.local/share','XDG_CACHE_HOME':'/workspace/.cache'}
def run(args):subprocess.run(args,cwd=root,env=env,check=True)
run(['kicad-cli','pcb','drc',str(source),'--output',str(out/'source-drc.txt'),'--exit-code-violations'])
run(['/usr/bin/python3',str(root/'scripts/audit-via-in-pad.py'),str(source),'--output',str(out/'via-in-pad-audit.json')])
run(['/usr/bin/python3',str(root/'scripts/audit-via-sizes.py'),str(source),'--output',str(out/'source-via-size-audit.json')])
catalog=json.loads((root/'docs/component-catalog.json').read_text());selection=json.loads((root/'docs/jlcpcb-import-selection.json').read_text())
references={p['reference']:p for p in catalog}
rendered=json.loads((root/'circuit.json').read_text())
if sum(e['type']=='pcb_component' for e in rendered)!=len(catalog) or sum(e['type']=='cad_component' for e in rendered)!=len(catalog):raise RuntimeError('Incomplete published components/CAD')
if sum(e['type']=='pcb_copper_pour' for e in rendered)<3:raise RuntimeError('Native power/ground planes missing from circuit.json')
board=pcbnew.LoadBoard(str(source));footprints=list(board.GetFootprints())
if len([f for f in footprints if f.GetReference() in references])!=len(catalog):raise RuntimeError('Native assembly count mismatch')
if any(f.GetLayer()!=pcbnew.F_Cu for f in footprints if f.GetReference() in references):raise RuntimeError('Assembly must be top-only')
if any(not isinstance(t,pcbnew.PCB_VIA) and t.GetLayer()==pcbnew.In1_Cu and t.GetNetname()!='GND' for t in board.GetTracks()):raise RuntimeError('Signal track on Layer 2 ground')
def copper_geometry_signature():
records=[]
for f in footprints:
pads=list(f.Pads())
for p in pads:
if p.GetAttribute()==pcbnew.PAD_ATTRIB_NPTH:continue # Separate acoustic-port adaptation audit below.
polygon=p.GetEffectivePolygon(pcbnew.F_Cu)
outlines=[]
for i in range(polygon.OutlineCount()):
chain=polygon.COutline(i)
outer=[[chain.CPoint(j).x,chain.CPoint(j).y] for j in range(chain.PointCount())]
holes=[]
for h in range(polygon.HoleCount(i)):
inner=polygon.CHole(i,h)
holes.append([[inner.CPoint(j).x,inner.CPoint(j).y] for j in range(inner.PointCount())])
outlines.append([outer,holes])
position=p.GetPosition();drill=p.GetDrillSize()
records.append([p.m_Uuid.AsString(),p.GetNumber(),p.GetNetname(),[position.x,position.y],
[drill.x,drill.y],p.GetDrillShape(),
[p.GetLayerSet().Contains(layer) for layer in [pcbnew.F_Cu,pcbnew.In1_Cu,pcbnew.In2_Cu,pcbnew.B_Cu,pcbnew.F_Mask,pcbnew.B_Mask]],outlines])
return hashlib.sha256(json.dumps(sorted(records),separators=(',',':')).encode()).hexdigest()
supplier_copper_signature=copper_geometry_signature()
# Preserve every electronic copper land and plated hole. Factory-only enlargement
# of the imported microphone acoustic NPTH ports meets the published 0.50 mm
# drilling minimum; the original supplier library/source footprints stay intact.
npth_adaptations=[]
for f in footprints:
for p in list(f.Pads()):
if p.GetAttribute()!=pcbnew.PAD_ATTRIB_NPTH:continue
old=[pcbnew.ToMM(p.GetDrillSize().x),pcbnew.ToMM(p.GetDrillSize().y)]
if min(old)>=.5:continue
if f.GetReference() not in ['MIC1','MIC2'] or max(abs(x-.4) for x in old)>1e-6:raise RuntimeError('Unexpected undersized imported NPTH')
at=p.GetPosition();npth_adaptations.append(dict(logical=f.GetReference(),at_native_mm=[pcbnew.ToMM(at.x),pcbnew.ToMM(at.y)],original_drill_mm=old,factory_drill_mm=[.5,.5],reason='JLCPCB published minimum non-plated hole diameter 0.50 mm'))
p.SetDrillSize(pcbnew.VECTOR2I(pcbnew.FromMM(.5),pcbnew.FromMM(.5)));p.SetSize(pcbnew.VECTOR2I(pcbnew.FromMM(.5),pcbnew.FromMM(.5)))
if len(npth_adaptations)!=2:raise RuntimeError('Microphone acoustic-port adaptation count changed')
board.GetTitleBlock().SetTitle('Muse Voice Remote')
board.GetTitleBlock().SetRevision(version)
for via in list(board.GetTracks()):
if isinstance(via,pcbnew.PCB_VIA):
via.SetFrontTentingMode(pcbnew.TENTING_MODE_TENTED)
via.SetBackTentingMode(pcbnew.TENTING_MODE_TENTED)
# Preserve U1 for the bare MCU; normalize the remaining factory designators.
refmap={'U1':'U1'};counts={'U':1}
for ref in sorted(references):
if ref=='U1':continue
prefix=ref.split('_')[0]
if prefix=='LED':prefix='D'
if prefix=='MIC':prefix='MIC'
if re.fullmatch(r'MIC\d+',ref):prefix='MIC'
if re.fullmatch(r'Y\d+',ref):prefix='Y'
counts[prefix]=counts.get(prefix,0)+1;refmap[ref]=prefix+str(counts[prefix])
if len(set(refmap.values()))!=len(catalog):raise RuntimeError('Duplicate factory designator')
# Input and plot origin at the lower-left PCB corner, matching the JLC CPL.
board.GetDesignSettings().SetAuxOrigin(pcbnew.VECTOR2I(pcbnew.FromMM(-33),pcbnew.FromMM(24)))
positions=[];windows=[];thermal_holes=[]
for f in footprints:
if f.GetReference()=='U1':
for p in f.Pads():
if p.GetAttribute()==pcbnew.PAD_ATTRIB_PTH and p.GetNumber()=='57':thermal_holes.append(dict(reference='U1',x_mm=pcbnew.ToMM(p.GetPosition().x)+33,y_mm=24-pcbnew.ToMM(p.GetPosition().y),drill_mm=pcbnew.ToMM(p.GetDrillSize().x)))
seeding=json.loads((root/'docs/power-plane-seeding.json').read_text())
for p in seeding['thermal_vias']:
x,y=p['at_native_mm'];thermal_holes.append(dict(reference=refmap[p['reference']],x_mm=x+33,y_mm=24-y,drill_mm=p['drill_mm']))
if len(thermal_holes)!=19:raise RuntimeError('Exposed-pad filled-via map is incomplete')
for f in footprints:
logical=f.GetReference()
if logical not in references:continue
positions.append(dict(logical=logical,reference=refmap[logical],x_mm=pcbnew.ToMM(f.GetPosition().x)+33,
y_mm=24-pcbnew.ToMM(f.GetPosition().y),rotation_deg=f.GetOrientationDegrees()%360,layer='Top'))
# Keep electrical pads exactly imported; adjust only the factory paste artwork.
for p in f.Pads():
if p.GetAttribute()!=pcbnew.PAD_ATTRIB_SMD or logical not in ['U1','U_CHARGE','U_AMP','U_REG'] or p.GetNumber()!={'U1':'57','U_CHARGE':'17','U_AMP':'17','U_REG':'11'}[logical]:continue
if not p.GetLayerSet().Contains(pcbnew.F_Paste):continue
# Imported custom lands can have a tiny anchor and large polygon lobes.
# Window the actual copper outline rather than the anchor's GetSize().
effective=p.GetEffectivePolygon(pcbnew.F_Cu);polygons=[]
for i in range(effective.OutlineCount()):
chain=effective.COutline(i)
outer=[(pcbnew.ToMM(chain.CPoint(j).x),pcbnew.ToMM(chain.CPoint(j).y)) for j in range(chain.PointCount())]
holes=[]
for h in range(effective.HoleCount(i)):
inner=effective.CHole(i,h)
holes.append([(pcbnew.ToMM(inner.CPoint(j).x),pcbnew.ToMM(inner.CPoint(j).y)) for j in range(inner.PointCount())])
polygons.append(Polygon(outer,holes))
land=unary_union(polygons);left,bottom,right,top=land.bounds
width=right-left;height=top-bottom
columns=3 if min(width,height)>=3.2 else 2;rows=columns;gap=.35 if columns==3 else .18
target=.80 if logical=='U_REG' else .60 # TI DRC0010J calls for 80%.
centre_x=(left+right)/2;centre_y=(bottom+top)/2
cell_w=(width-(columns-1)*gap)/columns;cell_h=(height-(rows-1)*gap)/rows
if cell_w<=0 or cell_h<=0:raise RuntimeError('Stencil grid does not fit imported land')
def apertures(scale):
result=[]
for column in range(columns):
for row in range(rows):
x=centre_x+(column-(columns-1)/2)*(cell_w+gap)
y=centre_y+(row-(rows-1)/2)*(cell_h+gap)
clipped=land.intersection(box(x-cell_w*scale/2,y-cell_h*scale/2,x+cell_w*scale/2,y+cell_h*scale/2))
if clipped.is_empty:continue
result.extend([clipped] if clipped.geom_type=='Polygon' else list(clipped.geoms))
return result
if sum(g.area for g in apertures(1))/land.area<target:raise RuntimeError('Target paste coverage cannot fit imported land')
lo,hi=0.,1.
for iteration in range(50):
mid=(lo+hi)/2
if sum(g.area for g in apertures(mid))/land.area<target:lo=mid
else:hi=mid
artwork=apertures(hi)
layers=p.GetLayerSet();layers.RemoveLayer(pcbnew.F_Paste);p.SetLayerSet(layers)
measured_area=0
for aperture in artwork:
if aperture.interiors:raise RuntimeError('Stencil aperture contains unsupported holes')
points=list(aperture.exterior.coords)[:-1]
native_polygon=pcbnew.SHAPE_POLY_SET();native_polygon.NewOutline()
for x,y in points:native_polygon.Append(pcbnew.FromMM(x),pcbnew.FromMM(y))
shape=pcbnew.PCB_SHAPE(board);shape.SetShape(pcbnew.SHAPE_T_POLY);shape.SetPolyShape(native_polygon)
shape.SetLayer(pcbnew.F_Paste);shape.SetFilled(True);shape.SetWidth(0);board.Add(shape)
measured_area+=aperture.area
coverage=measured_area/land.area
if abs(coverage-target)>1e-6:raise RuntimeError('Stencil coverage mismatch')
windows.append(dict(logical=logical,reference=refmap[logical],pad=p.GetNumber(),coverage=coverage,
target_coverage=target,columns=columns,rows=rows,apertures=len(artwork),
land_area_mm2=land.area,paste_area_mm2=measured_area,gap_mm=gap,
actual_imported_copper_outline_used=True))
f.SetReference(refmap[logical])
if copper_geometry_signature()!=supplier_copper_signature:raise RuntimeError('Factory preparation changed imported electronic copper, drills or mask layers')
factory=out/'MuseVoiceRemote-factory.kicad_pcb';pcbnew.SaveBoard(str(factory),board)
# Restore checked rules after pcbnew serialization, which can otherwise create defaults.
shutil.copyfile(root/'hardware/MuseVoiceRemote.kicad_pro',out/'MuseVoiceRemote-factory.kicad_pro')
run(['/usr/bin/python3',str(root/'scripts/fill-ground-plane.py'),str(factory)])
run(['/usr/bin/python3',str(root/'scripts/audit-via-sizes.py'),str(factory),'--output',str(out/'factory-via-size-audit.json')])
run(['kicad-cli','pcb','drc',str(factory),'--output',str(out/'factory-drc.txt'),'--exit-code-violations'])
run(['kicad-cli','pcb','export','gerbers',str(factory),'--layers','F.Cu,In1.Cu,In2.Cu,B.Cu,F.Mask,B.Mask,F.SilkS,B.SilkS,Edge.Cuts,F.Paste,B.Paste','--use-drill-file-origin','--subtract-soldermask','--output',str(out/'gerbers')+'/'])
run(['kicad-cli','pcb','export','drill',str(factory),'--output',str(out/'drill')+'/','--drill-origin','plot','--excellon-units','mm','--excellon-separate-th','--excellon-oval-format','route','--generate-map','--map-format','svg'])
run(['kicad-cli','pcb','export','pos',str(factory),'--output',str(out/'KiCad-top-positions.csv'),'--format','csv','--units','mm','--side','front','--smd-only','--use-drill-file-origin'])
with (out/'BOM-JLCPCB.csv').open('w',newline='') as fd:
w=csv.writer(fd);w.writerow(['Comment','Designator','Footprint','LCSC Part #'])
for s in selection['parts']:
raw=json.loads((root/s['raw_response']).read_text())
supplier=next(p for p in raw['data']['componentPageInfo']['list'] if p['componentCode']==s['lcsc'])
w.writerow([s['mpn'],','.join(refmap[r] for r in s['references']),supplier.get('componentSpecificationEn') or s.get('package') or s['lcsc'],s['lcsc']])
with (out/'CPL-JLCPCB.csv').open('w',newline='') as fd:
w=csv.writer(fd);w.writerow(['Designator','Mid X','Mid Y','Rotation','Layer'])
for p in sorted(positions,key=lambda p:p['reference']):w.writerow([p['reference'],f"{p['x_mm']:.6f}mm",f"{p['y_mm']:.6f}mm",f"{p['rotation_deg']:.6f}",'Top'])
with (out/'thermal-via-fill.csv').open('w',newline='') as fd:
w=csv.DictWriter(fd,fieldnames=['reference','x_mm','y_mm','drill_mm']);w.writeheader();w.writerows(thermal_holes)
(out/'reference-map.json').write_text(json.dumps(dict(logical_to_factory=refmap,placements=positions,stencil_windows=windows),indent=2)+'\n')
if len(positions)!=len(catalog):raise RuntimeError('CPL count mismatch')
with (out/'KiCad-top-positions.csv').open() as fd:actual=list(csv.DictReader(fd))
if len(actual)!=len(catalog):raise RuntimeError('Native positions export dropped populated components')
expected={p['reference']:p for p in positions}
for p in actual:
item=expected[p['Ref']]
if abs(float(p['PosX'])-item['x_mm'])>1e-5 or abs(float(p['PosY'])-item['y_mm'])>1e-5:raise RuntimeError('Origin or placement mismatch')
if abs((float(p['Rot'])-item['rotation_deg']+180)%360-180)>1e-5:raise RuntimeError('Rotation mismatch')
if p['Side']!='top':raise RuntimeError('Incorrect assembly side')
for suffix in ['gtl','g1','g2','gbl','gts','gbs','gto','gbo','gm1','gtp','gbp']:
candidates=list((out/'gerbers').glob('*.'+suffix))
if len(candidates)!=1 or 'M02*' not in candidates[0].read_text():raise RuntimeError('Missing or incomplete Gerber '+suffix)
drills=list((out/'drill').glob('*.drl'))
if len(drills)!=2 or any('METRIC' not in f.read_text() or 'M30' not in f.read_text() for f in drills):raise RuntimeError('Incomplete metric PTH/NPTH drilling')
notes=root/'docs/fabrication-notes.md'
if not notes.exists():raise RuntimeError('Fabrication profile is missing')
shutil.copyfile(notes,out/'FABRICATION-NOTES.md')
report=dict(version=version,checked_at=datetime.datetime.now(datetime.timezone.utc).isoformat(),release_scope='prototype PCB/PCBA artwork',populated_top_components=len(catalog),unique_supplier_imports=len(selection['parts']),native_drc='0 violations / 0 unconnected / 0 footprint errors',supplier_copper_geometry_sha256=supplier_copper_signature,imported_electronic_copper_plated_drills_and_mask_layers_preserved=True,factory_npth_acoustic_port_adaptations=npth_adaptations,minimum_via_drill_mm=.3,minimum_via_pad_mm=.45,minimum_radial_annulus_mm=.075,circuit_json_has_native_filled_planes=True,stencil_windows=windows,device_bench_tested=False,muse_service_verified=False,impedance_coupon_measured=False,filled_capped_thermal_holes=len(thermal_holes),source_native_sha256=hashlib.sha256(source.read_bytes()).hexdigest(),factory_native_sha256=hashlib.sha256(factory.read_bytes()).hexdigest())
(out/'verification.json').write_text(json.dumps(report,indent=2)+'\n')
gerber_archive=out/f'MuseVoiceRemote-Gerbers-JLCPCB-v{version}.zip'
with zipfile.ZipFile(gerber_archive,'w',compression=zipfile.ZIP_DEFLATED,compresslevel=9) as z:
for p in sorted(list((out/'gerbers').glob('*'))+drills):
if p.is_file():z.write(p,p.name)
with zipfile.ZipFile(gerber_archive) as z:
if z.testzip() is not None:raise RuntimeError('Gerber ZIP integrity failure')
files=[p for p in out.rglob('*') if p.is_file() and p.suffix not in ['.lck','.kicad_prl'] and (p.suffix!='.zip' or p==gerber_archive) and p.name!='SHA256SUMS']
(out/'SHA256SUMS').write_text(''.join(hashlib.sha256(p.read_bytes()).hexdigest()+' '+str(p.relative_to(out))+'\n' for p in sorted(files)))
archive=out/f'MuseVoiceRemote-fabrication-v{version}.zip'
with zipfile.ZipFile(archive,'w',compression=zipfile.ZIP_DEFLATED,compresslevel=9) as z:
for p in sorted(out.rglob('*')):
if p.is_file() and p.suffix not in ['.lck','.kicad_prl'] and (p.suffix!='.zip' or p==gerber_archive):z.write(p,str(p.relative_to(out)))
with zipfile.ZipFile(archive) as z:
if z.testzip() is not None:raise RuntimeError('ZIP integrity failure')
print(f'Checked prototype handoff: {len(catalog)} top parts; {len(selection["parts"])} imports; {archive.name}',flush=True)
os._exit(0)