MustafaMulla29/rp2040-bldc-motor-controller-new

A USB‑C PD–powered RP2040 BLDC motor controller PCB with 3.3 V logic, MCT8329A sensorless commutation, three MOSFET half-bridges, protected power switching, USB data, controls, and motor connectors.

Version
0.3.82
License
unset
Stars
0

scripts/prepare-assembly.mjs

/** Assembly-coordinate preparation from numbered supplier pads.
 * Does not modify circuit JSON, copper, footprints, DRC rules or error records.
 * Factory placement preview remains the final authority for its library convention.
 */
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { createHash } from 'node:crypto'
import { runAllChecks } from '@tscircuit/checks'
const project=resolve(dirname(fileURLToPath(import.meta.url)), '..')
const input=resolve(project,process.argv[2]??'dist/index/circuit.json')
const output=resolve(project,process.argv[3]??'dist/assembly-review')
const raw=readFileSync(input), rows=JSON.parse(raw)
const refRaw=readFileSync(resolve(project,'manufacturing/supplier-pad-references.json'))
const refs=JSON.parse(refRaw).parts
const normalizeMpn=s=>s.replace(/_C\d+$/, '').replace(/[^a-zA-Z0-9]/g,'').toUpperCase()
// Catalog naming differences, not substitutes: TR is Winbond reel packing;
// Phoenix lists the same header by product number and product type.
const catalogAliases={C131025:'W25Q16JVSSIQ',C92141:'1757255'}
const of=t=>rows.filter(r=>r.type===t)
const sources=new Map(of('source_component').map(r=>[r.source_component_id,r]))
const pp=new Map(of('pcb_port').map(r=>[r.pcb_port_id,r]))
const sp=new Map(of('source_port').map(r=>[r.source_port_id,r]))
const failures=rows.filter(r=>r.type.endsWith('_error'))
const checks=await runAllChecks(rows)
failures.push(...checks.filter(r=>r.type.endsWith('_error')))
for(const v of of('pcb_via')) if(Math.abs(v.hole_diameter-.3)>1e-9||Math.abs(v.outer_diameter-.45)>1e-9)failures.push({message:'Via dimension changed',id:v.pcb_via_id})
const pads=of('pcb_smtpad').concat(of('pcb_plated_hole'))
const stencil=JSON.parse(readFileSync(resolve(project,'manufacturing/stencil-u7.json')))
const u7=of('pcb_component').find(c=>sources.get(c.source_component_id)?.name==='U7')
const ep=pads.find(p=>p.pcb_component_id===u7?.pcb_component_id&&sp.get(pp.get(p.pcb_port_id)?.source_port_id)?.pin_number===37)
if(!ep||u7.rotation!==0||u7.layer!=='top'||Math.abs(ep.x-stencil.padCenterMm[0])>1e-6||Math.abs(ep.y-stencil.padCenterMm[1])>1e-6||Math.abs(ep.width-stencil.copperSizeMm[0])>1e-6||Math.abs(ep.height-stencil.copperSizeMm[1])>1e-6)throw Error('U7 stencil drawing is stale')
for(const a of stencil.apertures)if(Math.abs(a.centerX)+a.width/2>ep.width/2||Math.abs(a.centerY)+a.height/2>ep.height/2)throw Error('Stencil aperture extends outside exposed pad')
const stack=JSON.parse(readFileSync(resolve(project,'manufacturing/stackup.json'))),board=of('pcb_board')[0]
if(board.width!==stack.finalOutlineMm[0]||board.height!==stack.finalOutlineMm[1]||board.num_layers!==4||board.thickness!==stack.finishedThicknessMm)throw Error('Stackup instruction is stale')
const mean=vs=>[vs.reduce((s,p)=>s+p[0],0)/vs.length,vs.reduce((s,p)=>s+p[1],0)/vs.length]
function padMap(cid){
 const groups={}
 for(const p of pads.filter(p=>p.pcb_component_id===cid)){
  const pin=sp.get(pp.get(p.pcb_port_id)?.source_port_id)?.pin_number
  if(pin===undefined)throw Error(`Unnumbered pad in ${cid}`)
  const point=p.points?mean(p.points.map(p=>[p.x,p.y])):[p.x,p.y]
  ;(groups[pin]??=[]).push(point)
 }
 return Object.fromEntries(Object.entries(groups).map(([k,v])=>[k,mean(v)]))
}
const reviewed=[]
for(const c of of('pcb_component')){
 const s=sources.get(c.source_component_id)
 if(!s||s.do_not_place||c.do_not_place)continue
 // A native pad test point is fabricated copper, not a purchased assembly part.
 if(s.ftype==='simple_test_point'&&s.footprint_variant==='pad')continue
 const id=s.supplier_part_numbers?.jlcpcb?.[0],r=refs[id]
 if(!r)throw Error(`No supplier pad reference for ${s.name} (${id})`)
 if(normalizeMpn(s.manufacturer_part_number)!==normalizeMpn(r.manufacturerPartNumber)&&catalogAliases[id]!==s.manufacturer_part_number)throw Error(`MPN/catalog mismatch ${s.name}`)
 const a=padMap(c.pcb_component_id),pins=Object.keys(r.pins)
 if(pins.length!==Object.keys(a).length||pins.some(p=>!a[p]))throw Error(`Numbered pad mismatch ${s.name}`)
 const fits=[0,90,180,270].map(angle=>{
  const rad=angle*Math.PI/180,co=Math.cos(rad),si=Math.sin(rad)
  const points=Object.fromEntries(pins.map(p=>{
   const [rx,y]=r.pins[p],x=c.layer==='bottom'?-rx:rx
   return[p,[x*co-y*si,x*si+y*co]]
  }))
  const origin=mean(pins.map(p=>[a[p][0]-points[p][0],a[p][1]-points[p][1]]))
  const errors=pins.map(p=>Math.hypot(a[p][0]-points[p][0]-origin[0],a[p][1]-points[p][1]-origin[1]))
  return {angle,origin,points,rms:Math.sqrt(errors.reduce((s,x)=>s+x*x,0)/errors.length),maxResidual:Math.max(...errors)}
 }).sort((a,b)=>a.rms-b.rms)
 const best=fits[0]
 // This threshold classifies orientation only; it is NOT a pad-fit/DFM allowance.
 // Generic R/C lands intentionally differ from catalog lands by up to 0.13 mm.
 // AOD2610E uses the previously reviewed, larger TO-252 lands. Check that
 // these contain every catalog pad at the authored body datum; the 0.258 mm
 // pad-centroid difference is land length, not a pin/body displacement.
 if(id==='C282428'){
  const catalogPads=[{pin:'1',x:4.3580177,y:-2.290064,w:2.3360126,h:1.064006},
    {pin:'3',x:4.3580177,y:2.290064,w:2.3360126,h:1.064006},
    {pin:'2',x:-2.6130123,y:0,w:5.8260234,h:5.4912006}]
  const rad=best.angle*Math.PI/180,co=Math.cos(rad),si=Math.sin(rad)
  for(const r of catalogPads){
   const p=pads.find(p=>p.pcb_component_id===c.pcb_component_id&&String(sp.get(pp.get(p.pcb_port_id)?.source_port_id)?.pin_number)===r.pin)
   const x=c.center.x+r.x*co-r.y*si,y=c.center.y+r.x*si+r.y*co
   const w=Math.abs(co)*r.w+Math.abs(si)*r.h,h=Math.abs(si)*r.w+Math.abs(co)*r.h
   if(!p||p.shape!=='rect'||Math.abs(x-p.x)+w/2>p.width/2||Math.abs(y-p.y)+h/2>p.height/2)throw Error(`TO-252 land does not contain catalog pad ${s.name}.${r.pin}`)
  }
 }
 const orientationResidualLimit=id==='C282428'?.26:.16
 if(best.maxResidual>orientationResidualLimit||fits[1].rms-best.rms<.2)throw Error(`Ambiguous/unreviewed orientation ${s.name}`)
 let origin=[c.center.x,c.center.y],originMethod='Authored component center; checked against numbered supplier pads'
 if(['J1','J19'].includes(s.name)){
  if(s.manufacturer_part_number!=='TYPE-C-31-M-12'||id!=='C165948'||c.layer!=='top'||best.angle!==270)throw Error('USB connector changed; review mechanical datum again')
  // The four shell-slot centers retained the manufacturer's datum when the
  // signal-pad lengths were corrected. Copper-bounds center is not this origin.
  origin=mean(['1','2','3','4'].map(p=>[a[p][0]-best.points[p][0],a[p][1]-best.points[p][1]]))
  originMethod='Four mechanical shell-slot centers registered to C165948 supplier zero; excludes corrected signal lands from centroid fit'
 } else if(id==='C470294'){
  origin=best.origin;originMethod='Alps Drawing 2 numbered terminal/tab datum; corrected mounting slots make copper-bounds center asymmetric. Factory library placement preview is still required.';
 } else if(id==='C2802568'){
  origin=best.origin;originMethod='PowerDI3333 numbered catalog-pad datum; polygon drain and stencil produce an asymmetric component-bounds center. Factory placement preview required.';
 } else if(['C98732','C2894945'].includes(id)){
  origin=best.origin;originMethod='Numbered supplier-pad datum; catalog footprint origin is offset from copper bounds (XT60 mounting tabs / 3-pin right-angle header)';
 } else if(Math.hypot(best.origin[0]-origin[0],best.origin[1]-origin[1])>.05)throw Error(`Unreviewed placement-center offset ${s.name}`)
 const kinds=new Set(pads.filter(p=>p.pcb_component_id===c.pcb_component_id).map(p=>p.type))
 const mount=kinds.size===2?'SMT with soldered shell tabs':kinds.has('pcb_plated_hole')?'Through-hole':'SMT'
 reviewed.push({designator:s.name,mpn:s.manufacturer_part_number,supplier:id,package:r.package,layer:c.layer,rotation:best.angle,x:origin[0],y:origin[1],mount,matchedNumberedPads:pins.length,maxPadResidualMm:best.maxResidual,alternativeRmsMm:fits[1].rms,authoredCenter:c.center,originMethod,reference:r.source})
}
if(failures.length)throw Error(`Release input contains ${failures.length} DRC/via failures`)
if(reviewed.some(r=>r.layer!=='top'))throw Error('Top-only assembly contains a bottom component')
if(reviewed.length!==155)throw Error('Assembly component count changed; review scope')
reviewed.sort((a,b)=>a.designator.localeCompare(b.designator,undefined,{numeric:true}))
mkdirSync(output,{recursive:true})
const csv=arr=>arr.map(row=>row.map(x=>'"'+String(x??'').replaceAll('"','""')+'"').join(',')).join('\n')+'\n'
writeFileSync(resolve(output,'cpl-reviewed.csv'),csv([['Designator','Mid X','Mid Y','Layer','Rotation'],...reviewed.map(r=>[r.designator,r.x.toFixed(6)+'mm',r.y.toFixed(6)+'mm',r.layer,r.rotation])]))
writeFileSync(resolve(output,'bom.csv'),csv([['Comment','Designator','Footprint','LCSC Part #'],...reviewed.map(r=>[r.mpn,r.designator,r.package,r.supplier])]))
writeFileSync(resolve(output,'assembly-scope.csv'),csv([['Designator','MPN','LCSC','Side','Process','Rotation','X mm','Y mm'],...reviewed.map(r=>[r.designator,r.mpn,r.supplier,r.layer,r.mount,r.rotation,r.x.toFixed(6),r.y.toFixed(6)])]))
const report={status:'OFFLINE_GEOMETRY_REVIEW_PASS_FACTORY_PREVIEW_REQUIRED',inputSha256:createHash('sha256').update(raw).digest('hex'),referenceSha256:createHash('sha256').update(refRaw).digest('hex'),nativeDrcErrors:0,nativeWarnings:checks.filter(r=>r.type.endsWith('_warning')).length,componentCount:reviewed.length,bottom:reviewed.filter(r=>r.layer==='bottom').length,throughHole:reviewed.filter(r=>r.mount==='Through-hole').map(r=>r.designator),reviewed}
writeFileSync(resolve(output,'orientation-review.json'),JSON.stringify(report,null,2)+'\n')
console.log(JSON.stringify({components:reviewed.length,top:reviewed.length-report.bottom,bottom:report.bottom,throughHole:report.throughHole,usbOrigins:reviewed.filter(r=>['J1','J19'].includes(r.designator)).map(r=>({name:r.designator,x:r.x,y:r.y,rotation:r.rotation})),nativeDrcErrors:0,output},null,2))