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
design/enclosure.ts
import { assembly as a } from "./assembly"
import { stack } from "./assembly-stack"
export type V3 = [number,number,number]
export type Solid =
| {type:"cuboid";size:V3}
| {type:"cylinder";radius:number;height:number;segments:number}
| {type:"extrudeLinear";options:{height:number};shape:{type:"polygon";points:[number,number][]}}
| {type:"translate";vector:V3;shape:Solid}
| {type:"rotate";angles:V3;shape:Solid}
| {type:"union"|"subtract"|"hull";shapes:Solid[]}
export const move=(v:V3,s:Solid):Solid=>({type:"translate",vector:v,shape:s})
export const rotate=(angles:V3,s:Solid):Solid=>({type:"rotate",angles,shape:s})
export const box=(size:V3,center:V3):Solid=>move(center,{type:"cuboid",size})
export const cylinder=(radius:number,z0:number,z1:number,x=0,y=0):Solid=>
move([x,y,(z0+z1)/2],{type:"cylinder",radius,height:z1-z0,segments:48})
export const union=(...shapes:Solid[]):Solid=>({type:"union",shapes})
export const subtract=(base:Solid,...cuts:Solid[]):Solid=>({type:"subtract",shapes:[base,...cuts]})
export function rounded(w:number,h:number,r:number,z0:number,z1:number):Solid {
const points:[number,number][]=[]
for(let k=0;k<4;k++){
const cx=(k===0||k===3?1:-1)*(w/2-r),cy=(k<2?1:-1)*(h/2-r)
for(let i=0;i<=12;i++){
const t=(k*90+i*90/12)*Math.PI/180
points.push([cx+r*Math.cos(t),cy+r*Math.sin(t)])
}
}
return move([0,0,z0],{type:"extrudeLinear",options:{height:z1-z0},shape:{type:"polygon",points}})
}
// Quarter-circle sections form genuine edge roundovers in both native JSCAD
// and printable OpenSCAD. The faceted chord error at R1.2 is <0.006 mm.
export function softened(w:number,h:number,r:number,z0:number,z1:number,bottom=0,top=0):Solid {
const sections:{z:number;inset:number}[]=[]
if(bottom)for(let i=0;i<=8;i++){
const t=i*Math.PI/16
sections.push({z:z0+bottom*(1-Math.cos(t)),inset:bottom*(1-Math.sin(t))})
}
else sections.push({z:z0,inset:0})
sections.push({z:z1-top,inset:0})
if(top)for(let i=1;i<=8;i++){
const t=i*Math.PI/16
sections.push({z:z1-top+top*Math.sin(t),inset:top*(1-Math.cos(t))})
}
const slice=(s:typeof sections[number])=>rounded(w-2*s.inset,h-2*s.inset,r-s.inset,Math.min(s.z,z1-0.001),Math.min(s.z+0.001,z1))
return union(...sections.slice(1).map((s,i)=>({type:"hull",shapes:[slice(sections[i]),slice(s)]} as Solid)))
}
// A rounded slot in the X/Z plane through the upper/lower sidewall.
const sideSlot=(width:number,height:number,r:number,x:number,y:number,z:number)=>
move([x,y,z],rotate([Math.PI/2,0,0],rounded(width,height,r,-2,2)))
const sideGrip=(x:number,y:number,z:number,height:number)=>
move([x,y,z],rotate([0,Math.PI/2,0],softened(height,6.4,0.65,-0.4,0.4,0.22,0.22)))
export const enclosure = {
revision:"rounded-concept-16mm-r4-side-talk",width:a.case.width,height:a.case.height,depth:a.case.depth,
referenceBody:{width:72,height:53.5,depth:16},
footprintChange:"2 mm outward on every edge to clear the real LCD beneath R6 corners",
sideWall:0.8,frontLip:0.6,rearWall:0.8,cornerRadius:6,edgeRoundover:1.2,
splitZ:10.8,lapHeight:0.9,lapClearance:0.15,
screwCenters:[[-34.3,-24.8],[34.3,-24.8],[-34.3,24.8],[34.3,24.8]] as [number,number][],
pcbHoles:a.mountingHoles,
displayWindow:{x:a.display.activeX,y:0,width:58,height:43.6},
micPorts:[{x:-27.2349266,y:28.75,z:3.55},{x:28.7650734,y:28.75,z:3.55}],
micGrille:{holesPerPort:7,holeDiameter:0.6,pitchRadius:0.72},
usb:{x:0,y:-28.75,z:6.35,width:10.6,height:4.7,cornerRadius:1.1,receptacleSetback:3.225},
screws:{count:4,designation:"M1.6 × 12 mm countersunk; thread/pilot fit must be prototyped",pilotDiameter:1.25,
clearanceDiameter:1.8,headDiameter:3,pilotBottom:3.8,tipZ:4},
battery:{pad:stack.batteryPad,swelling:stack.batterySwelling,swellingDirection:"toward PCB",cellBottom:a.battery.zBottom,
expandedBottom:a.battery.zBottom-stack.batterySwelling,cellTop:a.battery.zBottom+a.battery.depth},
wireChannel:{innerX:[25.2,28.6],y:[-10,14],bottom:8.1,top:15.2},
controls:{external:["hold-to-talk","power on/off"],
talk:{faceNormal:[1,0,0],pressDirection:[-1,0,0],proposedTravel:0.30,camRestGap:0.05,
status:"Sidewall face; cam and spring return/travel require physical qualification."},
power:{type:"latching side slider",faceNormal:[1,0,0]},
privacy:{external:false,pcbSwitch:"SW_PRIVACY",assemblySetting:"ON",recordingPolicy:"Firmware records only while TALK is held; no independent external mic disconnect."}},
print:{front:"Front face on bed; local removable supports under screw bosses and PCB ledges.",
rear:"Outside rear face on bed; posts and wire guides point upward.",
process:"Fit prototype: fine-layer PETG or SLA. Retest small pilots and 0.15 mm lap clearance for your printer."},
} as const
const frontOuter=softened(a.case.width,a.case.height,6,0,enclosure.splitZ,1.2,0)
const frontHollow=softened(74.4,55.9,5.2,0.6,12.5,0.6,0)
const rearOuter=softened(a.case.width,a.case.height,6,enclosure.splitZ,16,0,1.2)
const rearHollow=softened(74.4,55.9,5.2,9.5,15.2,0,0.4)
const w=enclosure.displayWindow
// The 0.18 mm inner corner fits completely outside the rectangular pixels.
// A larger rounded outer mouth gives the inset screen a visible bevel.
const screenCut=move([w.x,w.y,0],union(rounded(w.width,w.height,0.18,-1,1),
{type:"hull",shapes:[rounded(w.width+0.8,w.height+0.8,0.65,-0.1,0.001),rounded(w.width,w.height,0.18,0.4,0.401)]}))
const holes:Solid[]=[
sideSlot(10.6,4.7,1.1,0,-28.25,6.35),
move([37.7,17,10.4],rotate([0,Math.PI/2,0],rounded(3.6,9.6,1.6,-2,2))), // side-facing TALK guide
box([4,4.6,2.1],[37.5,-5.11,5.8]),
sideSlot(3.2,1.5,0.7,12,28.25,6.5), // provision for status light guide
...enclosure.micPorts.flatMap(p=>Array.from({length:7},(_,i)=>{
const t=(i-1)*Math.PI/3,dx=i?0.72*Math.cos(t):0,dz=i?0.72*Math.sin(t):0
return move([p.x+dx,28.25,p.z+dz],rotate([Math.PI/2,0,0],
{type:"cylinder",radius:0.3,height:4,segments:24}))
})),
]
const anchoredBoss=(x:number,y:number,z0:number,z1:number):Solid=>({type:"hull",shapes:[
cylinder(1.2,z0,z1,x,y),cylinder(0.9,z0,z1,Math.sign(x)*35.5,Math.sign(y)*26.1)]})
const frontBosses=enclosure.screwCenters.map(([x,y])=>subtract(anchoredBoss(x,y,3.55,10.8),cylinder(0.625,3.8,11.1,x,y)))
const ledges=[-1,1].flatMap(s=>[-12,12].map(y=>box([4.6,7,0.2],[s*35.1,y,3.6])))
const guides=[-1,1].flatMap(s=>[-12,12].map(y=>box([0.5,7,0.7],[s*33.3,y,4.05])))
// Separate screws close the case outside the PCB and LCD projection at Z>3.4.
// The four reference PCB mounting bores remain on their own support posts.
const frontRim=subtract(rounded(74.1,55.6,5.05,10.6,11.7),rounded(73.3,54.8,4.65,10.5,11.9),
...enclosure.screwCenters.map(([x,y])=>({type:"hull",shapes:[cylinder(1.35,10.8,12,x,y),
cylinder(1.05,10.8,12,Math.sign(x)*35.5,Math.sign(y)*26.1)]} as Solid)))
export const frontShell=subtract(union(subtract(frontOuter,frontHollow),frontRim,...frontBosses,...ledges,...guides),screenCut,...holes)
const mountPosts=a.mountingHoles.map(p=>{
let post=subtract(cylinder(1.6,4.7,15.4,p.x,p.y),cylinder(0.9,4.5,16.2,p.x,p.y))
if(p.y>0){ // relieve inner side beside microphones; retain reference Ø3.2 stock above it
post=subtract(post,box([3,5,1.6],[p.x+(p.x<0?2.75:-2.75),p.y,5.3]))
}
return post
})
// Side-exit relief for the three proposed battery leads; wires must not pass
// through the solid left channel wall or bear against the pouch edge.
const channelWalls=[24.9,28.9].map(x=>x===24.9?
subtract(box([0.6,24,7.3],[x,2,11.55]),box([1.2,7.4,2.8],[x,10.5,13.4])):
box([0.6,24,7.3],[x,2,11.55]))
const rearBosses=enclosure.screwCenters.map(([x,y])=>anchoredBoss(x,y,10.95,15.4))
const counterbores=enclosure.screwCenters.flatMap(([x,y])=>[
cylinder(0.9,10.5,16.2,x,y),
{type:"hull",shapes:[cylinder(0.9,15.35,15.45,x,y),cylinder(1.5,15.95,16.15,x,y)]} as Solid,
])
const speakerGrille:Solid[]=[]
for(let row=-1;row<=1;row++)for(let col=-5;col<=5;col++)
speakerGrille.push(cylinder(0.65,14.95,16.2,-7.5+col*2.3,-19.8+row*2))
export const rearShell=subtract(union(subtract(rearOuter,rearHollow),...mountPosts,...rearBosses,...channelWalls),...holes,...counterbores,...speakerGrille)
// External sliders engage the real supplier stem through relieved cups.
const powerGlyph=move([38.85,-5.11,5.8],rotate([0,Math.PI/2,0],union(
subtract(cylinder(0.65,-0.18,0.18),cylinder(0.44,-0.25,0.25),box([0.8,0.52,0.8],[-0.65,0,0])),
box([0.75,0.17,0.36],[-0.65,0,0]))))
export const powerCap=subtract(union(
subtract(box([2.1,2,1.6],[32.65,-5.11,5.8]),box([2.1,1.15,0.85],[32.1,-5.11,5.8])),
box([5.2,1.2,1],[35.8,-5.11,5.8]),sideGrip(38.45,-5.11,5.8,3)),powerGlyph)
// Side-facing TALK key (+X): its printed guide and retained flange move
// inward along -X. A perimeter link stays outside the cell footprint and
// converts that stroke into the existing spring's -Z deflection. Qualify the
// printed link, cam, spring anchoring, force, return and switch travel on a prototype.
const talkFace=move([38.5,17,10.4],rotate([0,Math.PI/2,0],softened(3.2,9.2,1.4,-0.5,0.5,0.18,0.18)))
const talkCam=move([0,25.7,0],rotate([Math.PI/2,0,0],{
type:"extrudeLinear",options:{height:1.2},shape:{type:"polygon",points:[[-0.2,9.9],[1.8,7.9],[1.8,11.4],[-0.2,11.4]]}
}))
const micMark=union(move([0,0.35,0],rounded(0.6,1.05,0.3,-0.15,0.15)),
subtract(rounded(1.35,1.5,0.6,-0.15,0.15),move([0,0.3,0],rounded(1,1.5,0.48,-0.25,0.25))),
box([0.18,0.35,0.3],[0,-0.8,0]),box([0.9,0.16,0.3],[0,-0.98,0]))
const micGlyph=move([39,17,10.4],rotate([0,Math.PI/2,0],rotate([0,0,Math.PI/2],micMark)))
export const talkCap=subtract(union(talkFace,
move([37.1,17,10.4],rotate([0,Math.PI/2,0],rounded(2.8,8.8,1.2,-1.2,1.2))), // rounded guide through the sidewall slot
box([0.5,10.6,4.6],[36.3,17,10.4]), // retained flange inside the front lap
box([7.8,1.2,2],[32.75,17,10.4]), // route behind the side face
box([0.9,9,2],[29.2,21,10.4]), // clear the upper PCB support post
box([29.8,1.2,2],[14.65,25.1,10.4]),talkCam),micGlyph)
// A thin spring interface is necessary because the reference cell overlaps the
// TALK switch in XY. This is not represented as a printable plastic plunger.
export const talkLeaf=box([2,5,0.1],[0,22.8,8.6])
export const mechanicalParts=[
{name:"CASE_FRONT",label:"Front tray · rounded 76 × 57.5 mm",kind:"case",plan:frontShell,color:[0.10,0.12,0.14],explode:-24,printable:true,opensWithRear:false},
{name:"CASE_REAR",label:"Rear cover · four screws + speaker grille",kind:"case",plan:rearShell,color:[0.13,0.15,0.17],explode:34,printable:true,opensWithRear:true},
{name:"CONTROL_TALK",label:"Side-facing TALK cap · cam/spring travel needs validation",kind:"control",plan:talkCap,color:[0.12,0.50,0.49],explode:5,printable:true,opensWithRear:false},
{name:"CONTROL_POWER",label:"Power ON/OFF · single side slider",kind:"control",plan:powerCap,color:[0.22,0.24,0.25],explode:4,printable:true,opensWithRear:false},
{name:"TALK_SPRING_INTERFACE",label:"TALK · 0.10 mm spring strip, not a printed part",kind:"leaf",plan:talkLeaf,color:[0.65,0.68,0.70],explode:3,printable:false,opensWithRear:false},
...enclosure.screwCenters.map(([x,y],i)=>({name:`CASE_SCREW_${i+1}`,label:`M1.6 × 12 screw ${i+1} · dimensional envelope`,kind:"fastener",
plan:subtract(union(cylinder(0.8,4,15.4,x,y),{type:"hull",shapes:[cylinder(0.8,15.3,15.4,x,y),cylinder(1.5,15.95,16,x,y)]} as Solid),
box([1.8,0.35,0.4],[x,y,16]),box([0.35,1.8,0.4],[x,y,16])),
color:[0.35,0.37,0.4],explode:40,printable:false,opensWithRear:true})),
]