mohan-bee/mini-mp3-player
Portable, battery-powered MP3 player hardware with USB charging, microSD storage, an MCU, VS10xx audio decoder, headphone output, status controls, and regulated power rails.
- Version
- 1.2.0
- License
- unset
- Stars
- 0
scripts/generate-assembly.mjs
import { writeModelUrls } from './write-model-urls.mjs'
// Reproducible CAD-only models. All coordinates in PCB-centered millimetres.
import fs from 'node:fs'
import * as THREE from 'three'
import { GLTFExporter } from 'three/examples/jsm/exporters/GLTFExporter.js'
import { FontLoader } from 'three/examples/jsm/loaders/FontLoader.js'
import jscad from '@jscad/modeling'
const {primitives:p, transforms:t, booleans:b, geometries:g}=jscad
globalThis.FileReader=class {
readAsArrayBuffer(blob){blob.arrayBuffer().then(v=>{this.result=v;this.onloadend?.()})}
readAsDataURL(blob){blob.arrayBuffer().then(v=>{this.result=`data:${blob.type};base64,${Buffer.from(v).toString('base64')}`;this.onloadend?.()})}
}
const font=new FontLoader().parse(JSON.parse(fs.readFileSync('node_modules/three/examples/fonts/helvetiker_bold.typeface.json','utf8')))
const models={}, report=[]
const navy='#20334d',mint='#67cbb0',coral='#f18470',slate='#6486aa',white='#f3f5ee'
const box=(size,center)=>p.cuboid({size,center})
const round=(size,center,r=1)=>p.roundedCuboid({size,center,roundRadius:r,segments:24})
const cyl=(r,h,center)=>p.cylinder({radius:r,height:h,center,segments:32})
function solid(group,shape,color,metalness=0){
const vertices=[]
for(const polygon of g.geom3.toPolygons(shape)) for(let i=1;i<polygon.vertices.length-1;i++) vertices.push(...polygon.vertices[0],...polygon.vertices[i],...polygon.vertices[i+1])
if(!vertices.length||vertices.some(v=>!Number.isFinite(v)))throw Error('Invalid CAD mesh')
const geo=new THREE.BufferGeometry();geo.setAttribute('position',new THREE.Float32BufferAttribute(vertices,3));geo.computeVertexNormals()
group.add(new THREE.Mesh(geo,new THREE.MeshStandardMaterial({color,metalness,roughness:metalness?.35:.65})))
}
function text(group,label,x,y,z,size,color=white){
const geo=new THREE.ExtrudeGeometry(font.generateShapes(label,size),{depth:.12,bevelEnabled:false});geo.computeBoundingBox();const bb=geo.boundingBox
geo.translate(x-(bb.max.x+bb.min.x)/2,y-(bb.max.y+bb.min.y)/2,z)
group.add(new THREE.Mesh(geo,new THREE.MeshStandardMaterial({color,roughness:.6})))
}
function wire(group,points,color){
const v=points.map(p=>new THREE.Vector3(...p)),curve=new THREE.CurvePath()
let last=v[0]
for(let i=1;i<v.length-1;i++){
const radius=Math.min(1.0,v[i].distanceTo(v[i-1])*.3,v[i].distanceTo(v[i+1])*.3)
const before=v[i].clone().add(v[i-1].clone().sub(v[i]).normalize().multiplyScalar(radius))
const after=v[i].clone().add(v[i+1].clone().sub(v[i]).normalize().multiplyScalar(radius))
curve.add(new THREE.LineCurve3(last,before));curve.add(new THREE.QuadraticBezierCurve3(before,v[i],after));last=after
}
curve.add(new THREE.LineCurve3(last,v.at(-1)))
group.add(new THREE.Mesh(new THREE.TubeGeometry(curve,100,.30,8,false),new THREE.MeshStandardMaterial({color,roughness:.7})))
}
async function save(name,group){
group.name=name;group.updateMatrixWorld(true)
const glb=await new GLTFExporter().parseAsync(group,{binary:true})
fs.writeFileSync(`mechanical/models/${name}.glb`,Buffer.from(glb))
models[name]=`data:model/gltf-binary;base64,${Buffer.from(glb).toString('base64')}`
const bounds=new THREE.Box3().setFromObject(group)
report.push({name,bytes:glb.byteLength,min:bounds.min.toArray(),max:bounds.max.toArray()})
}
fs.mkdirSync('mechanical/models',{recursive:true})
// Board is centered at Z=0, surfaces at +/-0.8. Enclosure bottom at -9.8.
const circuit=JSON.parse(fs.readFileSync('dist/index/circuit.json','utf8'))
const holes=circuit.filter(x=>x.type==='pcb_hole'&&!x.pcb_component_id&&Math.abs(x.hole_diameter-2.2)<.001).map(x=>[x.x,x.y])
if(holes.length!==3)throw Error('Expected three PCB mounting holes')
const screws=holes
const jackVertices=JSON.parse(fs.readFileSync('mechanical/J4-world-vertices.json','utf8'))
const jackNoseX=Math.max(...jackVertices.map(p=>p[0]))
const nose=jackVertices.filter(p=>Math.abs(p[0]-jackNoseX)<.001)
const jackY=(Math.min(...nose.map(p=>p[1]))+Math.max(...nose.map(p=>p[1])))/2
const jackZ=(Math.min(...nose.map(p=>p[2]))+Math.max(...nose.map(p=>p[2])))/2
const cuts=[box([12,15,6],[-34,-37,2.8]),box([6.8,7,2.9],[-22.95,-34,1.85]),
box([9,10.6,8],[44,jackY,4.85]),box([19,19,4.2],[-44,18,2.3])]
let base=new THREE.Group()
let shell=b.subtract(round([88,68,18],[0,0,-.8],1.5),round([84,64,22],[0,0,3.2],1),...cuts)
solid(base,shell,navy)
for(const [x,y] of holes)solid(base,b.subtract(cyl(2.25,7,[x,y,-4.3]),cyl(.9,8,[x,y,-4.3])),navy)
// Recessed connector-facing bezel: the jack nose sits flush with its inner panel.
const panelX=jackNoseX-.225
let bezel=b.union(box([.6,12,6.8],[panelX,jackY,4.5]),
box([4.5,.7,6.8],[41.75,jackY-5.65,4.5]),box([4.5,.7,6.8],[41.75,jackY+5.65,4.5]),
box([4.5,12,.7],[41.75,jackY,7.55]))
bezel=b.subtract(bezel,t.translate([panelX,jackY,jackZ],t.rotateY(Math.PI/2,p.cylinder({radius:2.6,height:2,segments:48}))))
solid(base,bezel,navy)
for(const x of [-29,29])solid(base,box([2,44,5.8],[x,0,-4.9]),navy)
// Non-compressing battery retaining rails and a small soft adhesive pad.
for(const x of [-27.5,27.5])solid(base,box([1.5,39,5.5],[x,0,-5.05]),navy)
solid(base,box([46,30,.5],[0,0,-7.55]),'#424e5b')
const powerLabel=new THREE.Group()
text(powerLabel,'POWER',0,0,0,1.05)
powerLabel.rotation.x=Math.PI/2
powerLabel.position.set(-22.1499746,-34.02,5)
base.add(powerLabel)
await save('base',base)
// External thumb slider coupled to the existing SW7 actuator; no new electrical switch.
// Supplier CAD actuator centre: X=-22.15, Z=1.85; datasheet travel 1.6 +/-0.1mm.
const slider=new THREE.Group(),sx=-22.1499746
let thumb=round([4.8,1.6,2.5],[sx,-34.65,1.85],.3)
for(const x of [-1.4,0,1.4])thumb=b.subtract(thumb,box([.32,.45,1.7],[sx+x,-35.4,1.85]))
solid(slider,thumb,mint)
solid(slider,box([2.7,3.75,1.7],[sx,-32.825,1.85]),mint)
solid(slider,b.subtract(box([2.7,1.65,1.7],[sx,-30.175,1.85]),box([1.7,2,1.1],[sx,-29.9,1.85])),mint)
// Captive inside flange prevents the cap being pulled out through the side slot.
solid(slider,box([5.6,.6,3.4],[sx,-31.65,1.85]),mint)
await save('power-slider',slider)
fs.writeFileSync('validation/power-slider.json',JSON.stringify({switch:'SW7',part:'MSK12C02',
supplier:'C431540',actuatorCentre:[sx,-30.50608775,1.85],travelMm:1.6,travelToleranceMm:.1,
externalCapMm:[4.8,1.6,2.5],slotMm:[6.8,2.9],couplingClearancePerSideMm:.2,
datasheet:'https://datasheet.lcsc.com/lcsc/2304140030_SHOU-HAN-MSK12C02_C431540.pdf',prototype:true},null,2)+'\n')
let seam=new THREE.Group();solid(seam,b.subtract(round([88,68,.5],[0,0,8.45],.2),box([83.5,63.5,2],[0,0,8.45])),mint);await save('seam',seam)
let lid=new THREE.Group()
let top=b.subtract(round([88,68,3],[0,0,10.2],1),box([84,64,3],[0,0,8.7]))
for(const x of [-12,0,12,24])top=b.subtract(top,round([9.4,6.4,8],[x,-28,11],.6))
for(const [x,y] of screws)top=b.subtract(top,cyl(1.15,14,[x,y,7]),cyl(2,1.2,[x,y,11.65]))
for(const [x,y] of [[-34,-11],[-3,23]])top=b.subtract(top,cyl(.65,8,[x,y,11]))
solid(lid,top,navy)
for(const x of [-12,0,12,24])solid(lid,b.subtract(round([12.2,9.2,3.8],[x,-28,8.4],.5),box([10.6,7.6,5],[x,-28,8.4])),navy)
// Lid spacers share the PCB and lower-boss axes; PCB is captured at +/-0.8.
for(const [x,y] of screws){
let spacer=b.subtract(cyl(2.1,9.4,[x,y,5.5]),cyl(1.15,10,[x,y,5.5]))
// Relieve the lower spacer around nearby SMD parts, especially R10/C42 at H2.
for(const c of circuit.filter(e=>e.type==='pcb_component'&&e.width&&e.height)){
if(Math.hypot(c.center.x-x,c.center.y-y)>8)continue
spacer=b.subtract(spacer,box([c.width+.6,c.height+.6,3],[c.center.x,c.center.y,2.25]))
}
solid(lid,spacer,navy)
}
text(lid,'MINI / MP3',0,12,11.71,4)
text(lid,'CHARGE',-34,-20,11.71,1.3)
text(lid,'MICRO SD',-35,22,11.71,1.2)
text(lid,'POWER',sx,-29,11.71,1.05)
// Point over the front edge to SW7's external thumb cap, below the lid.
solid(lid,box([.24,1.5,.12],[sx,-31.15,11.77]),white)
solid(lid,t.translate([sx,-32.1,11.71],jscad.extrusions.extrudeLinear({height:.12},
p.polygon({points:[[-.7,.5],[.7,.5],[0,-.5]]}))),white)
// Clear inserts belong to the lid, so hiding or exploding the lid carries them.
// The 2.4 mm flange seats against the roof underside at Z=10.2 mm;
// bond its annular face to the lid. The 1.2 mm stem ends flush at Z=11.7.
for(const [x,y] of [[-34,-11],[-3,23]]) {
const insert=b.union(cyl(.6,1.5,[x,y,10.95]),cyl(1.2,.4,[x,y,10]))
solid(lid,insert,'#dce9e7')
const material=lid.children.at(-1).material
material.transparent=true;material.opacity=.4
}
await save('lid',lid)
let divider=new THREE.Group();solid(divider,round([61,44,.5],[0,0,-1.75],.2),white);await save('divider',divider)
let battery=new THREE.Group();solid(battery,round([52,36,5],[0,0,-4.8],.7),'#bbc2ca',.75)
solid(battery,box([5,36.1,5.05],[-23.5,0,-4.8]),'#d9a135')
text(battery,'LiPo 1S / 3.7V',1,2,-2.27,2.4,'#293747')
text(battery,'PROTECTED + 10k NTC',1,-4,-2.27,1.4,'#293747')
await save('battery',battery)
let harness=new THREE.Group()
// Actual header pin row is at Y=-6.340943, not its footprint center Y=-4.
const jstY=-6.340943,plugY=jstY+.4
for(let i=0;i<3;i++)wire(harness,[[-26,-3+i*.7,-4.8],[-41.5,-3+i*.7,-4.8],[-41.5,-3+i*.7,8.2],
[-37,-3+i*.7,9.7],[-30-i*2,jstY,9.7],[-30-i*2,jstY,8.8]],['#c83f43','#e2e4de','#20252c'][i])
// PHR-3: 7.8 x 4.5 x 6.85 mm housing, mated SMT envelope 8.6mm above PCB.
// Hollow contact cavities and wire entries replace the former solid block.
let plug=round([7.8,4.5,6.85],[-32,plugY,5.975],.12)
for(const x of [-30,-32,-34]){
plug=b.subtract(plug,box([.8,.8,4.4],[x,jstY,4.5]),cyl(.48,2,[x,jstY,9]))
}
solid(harness,plug,white)
for(const x of [-33,-31])solid(harness,box([.35,.35,2.2],[x,plugY+2.35,7.8]),white)
await save('jst-harness',harness)
const cableBounds=new THREE.Box3().setFromObject(harness)
if(cableBounds.min.x < -41.9 || cableBounds.max.z > 10) throw new Error('Harness must clear compact case wall and lid')
for(const [i,x] of [-12,0,12,24].entries()){
const cap=new THREE.Group(),color=[mint,slate,slate,coral][i]
solid(cap,round([10.2,7.2,1],[x,-28,9.7],.35),color)
solid(cap,round([9,6,2.9],[x,-28,11.15],.65),color)
// Supplier CAD actuator top 1.95 + PCB 0.8; nominal gap 0.2 => stem bottom2.95.
solid(cap,cyl(1,6.3,[x,-28,6.1]),color)
text(cap,['PLAY','PREV','NEXT','VOL'][i],x,-28,12.62,1.45)
await save(`cap-${i+1}`,cap)
}
const fasteners=new THREE.Group()
for(const [x,y] of holes){
solid(fasteners,cyl(.95,15,[x,y,3.75]),'#929da6',.8)
solid(fasteners,b.subtract(cyl(1.9,.8,[x,y,11.5]),box([.5,3,.5],[x,y,11.9]),box([3,.5,.5],[x,y,11.9])),'#929da6',.8)
}
await save('mounting-screws',fasteners)
fs.writeFileSync('validation/mechanical-alignment.json',JSON.stringify({
pcbHoleCentres:holes,baseBossCentres:holes,lidHoleCentres:holes,coaxialErrorMm:0,
pcbBottomZ:-.8,baseBossTopZ:-.8,pcbTopZ:.8,lidSpacerBottomZ:.8,
jackNoseX,jackY,jackZ,bezelFrontX:panelX+.3,jackRadialClearanceMm:.2,
jstPinRowY:jstY,jstMatedTopZ:9.4,ledWindowDiameterMm:1.2,ledWindowLengthMm:1.5,ledWindowFlangeDiameterMm:2.4,ledWindowFlangeThicknessMm:.4,ledWindowRetention:'flange bonded to lid underside',
housingReference:'https://www.jst-mfg.com/product/pdf/eng/ePH.pdf',prototype:true},null,2)+'\n')
fs.writeFileSync('mechanical/models.json',JSON.stringify(models))
writeModelUrls(models)
fs.writeFileSync('validation/mechanical-models.json',JSON.stringify({units:'mm',boardZ:0,case:[88,68,21.5],batteryEnvelope:[52,36,5],switchTopFromSupplierCad:1.95,prototype:true,models:report},null,2)+'\n')
console.log(`Generated ${report.length} colored CAD models (${report.reduce((s,r)=>s+r.bytes,0)} bytes)`)