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/assembly-viewer.js
import * as THREE from "three"
import { OrbitControls } from "./lib/controls/OrbitControls.js"
import { GLTFLoader } from "./lib/loaders/GLTFLoader.js"
import { RoomEnvironment } from "./lib/environments/RoomEnvironment.js"
import { mergeGeometries, toCreasedNormals } from "./lib/utils/BufferGeometryUtils.js"
const viewport=document.getElementById("viewport"),status=document.getElementById("loading")
const renderer=new THREE.WebGLRenderer({antialias:true,alpha:false,preserveDrawingBuffer:false})
renderer.setPixelRatio(Math.min(devicePixelRatio,1.5));renderer.setClearColor(0xe5e2dc)
renderer.outputColorSpace=THREE.SRGBColorSpace;renderer.toneMapping=THREE.ACESFilmicToneMapping;renderer.toneMappingExposure=1.05
renderer.shadowMap.enabled=true;renderer.shadowMap.type=THREE.PCFSoftShadowMap;renderer.shadowMap.autoUpdate=false
viewport.append(renderer.domElement)
const scene=new THREE.Scene(),camera=new THREE.PerspectiveCamera(36,1,.1,2000)
const controls=new OrbitControls(camera,renderer.domElement);controls.enableDamping=true
controls.minDistance=25;controls.maxDistance=800
const pmrem=new THREE.PMREMGenerator(renderer),studio=new RoomEnvironment()
scene.environment=pmrem.fromScene(studio,.04).texture;scene.environmentIntensity=.45
studio.dispose();pmrem.dispose()
scene.add(new THREE.HemisphereLight(0xffffff,0x6a625a,.65))
for(const [i,[position,intensity]]of [[[20,-70,100],2],[[-70,-20,30],.8],[[40,70,60],1.6]].entries()){
const light=new THREE.DirectionalLight(0xffffff,intensity);light.position.set(...position);scene.add(light)
if(i===0){light.castShadow=true;light.shadow.mapSize.set(1024,1024);Object.assign(light.shadow.camera,{left:-100,right:100,top:100,bottom:-100,near:1,far:300});light.shadow.bias=-.0002;light.shadow.normalBias=.035}
}
const floor=new THREE.Mesh(new THREE.PlaneGeometry(1500,1500),new THREE.MeshStandardMaterial({color:0xdcd8d1,roughness:.95}))
floor.position.z=-28.76;floor.receiveShadow=true;scene.add(floor)
const manifest=await fetch("manifest.json").then(r=>{if(!r.ok)throw Error("Manifest download failed");return r.json()})
const definitions=new Map([...manifest.envelopes,...manifest.mechanical_parts,...manifest.cable_parts].map(e=>[e.name,e]))
const suppliers=new Map(manifest.transformed_supplier_bounds.map(p=>[p.reference,p]))
let model,mode="assembled",view="iso",meshes=[],pickMeshes=[],pending=false,frameCount=0,ready=false
const originalPositions=new Map(),partBounds=new Map(),selected=document.getElementById("selection")
const ray=new THREE.Raycaster(),pointer=new THREE.Vector2(),boardBatch=new THREE.Group()
const highlight=new THREE.Box3Helper(new THREE.Box3(),0x18a795);highlight.visible=false;highlight.material.depthTest=false;highlight.renderOrder=100
scene.add(boardBatch,highlight)
let selectedName=null
// No animation loop: orbit damping schedules only the frames it needs. Static
// shadow maps are refreshed only when assembly geometry or visibility changes.
function invalidate(shadows=false){
if(shadows)renderer.shadowMap.needsUpdate=true
if(pending)return;pending=true
requestAnimationFrame(()=>{
pending=false;const changing=controls.update();renderer.render(scene,camera);frameCount++
window.musePerformance={frames:frameCount,calls:renderer.info.render.calls,triangles:renderer.info.render.triangles,pending,rendering:"on demand"}
if(changing)invalidate()
})
}
controls.addEventListener("change",()=>invalidate())
function grain(){
const data=new Uint8Array(128*128);let seed=23
for(let i=0;i<data.length;i++){seed=(1664525*seed+1013904223)>>>0;data[i]=96+(seed>>>26)}
const tex=new THREE.DataTexture(data,128,128,THREE.RedFormat);tex.wrapS=tex.wrapT=THREE.RepeatWrapping;tex.repeat.set(22,22);tex.needsUpdate=true;return tex
}
const printGrain=grain()
// Illustration on the existing LCD surface; no added glass or physical part.
function screenArtwork(){
const canvas=document.createElement("canvas");canvas.width=320;canvas.height=240
const c=canvas.getContext("2d"),g=c.createLinearGradient(0,0,320,240)
g.addColorStop(0,"#292b2e");g.addColorStop(1,"#121315");c.fillStyle=g;c.fillRect(0,0,320,240)
c.fillStyle="#d5d8da";c.font="12px sans-serif";c.fillText("M U S E",22,28)
c.fillStyle="#8ee9e1";c.beginPath();c.arc(292,24,3,0,Math.PI*2);c.fill()
for(let i=-12;i<=12;i++){
const h=4+Math.exp(-Math.abs(i)/4.5)*(24+40*Math.abs(Math.sin(i*1.8+1)))
c.strokeStyle="#70d6d0";c.lineWidth=3;c.lineCap="round";c.beginPath();c.moveTo(160+i*9,115-h/2);c.lineTo(160+i*9,115+h/2);c.stroke()
}
c.fillStyle="#d5d8da";c.font="16px sans-serif";c.textAlign="center";c.fillText("Hold TALK",160,196)
const texture=new THREE.CanvasTexture(canvas);texture.colorSpace=THREE.SRGBColorSpace;texture.anisotropy=4
const screen=new THREE.Mesh(new THREE.PlaneGeometry(57.6,43.2),new THREE.MeshPhysicalMaterial({map:texture,emissiveMap:texture,emissive:0xffffff,emissiveIntensity:.12,roughness:.12,metalness:0,clearcoat:1,clearcoatRoughness:.04,polygonOffset:true,polygonOffsetFactor:-1,polygonOffsetUnits:-1}))
screen.rotation.x=Math.PI/2;screen.position.y=-.011;screen.renderOrder=-9;return screen
}
function batteryMarking(){
const canvas=document.createElement("canvas");canvas.width=512;canvas.height=256
const c=canvas.getContext("2d");c.fillStyle="#deded9";c.fillRect(0,0,512,256)
c.strokeStyle="#929690";c.lineWidth=2;c.strokeRect(12,12,488,232)
c.fillStyle="#323c3d";c.font="bold 29px sans-serif";c.fillText("BATTERY ALLOCATION",30,65)
c.font="25px sans-serif";c.fillText("52.5 × 35 × 5.7 mm max",30,115)
c.font="bold 23px sans-serif";c.fillText("PACK NOT SELECTED",30,177)
c.font="19px sans-serif";c.fillText("Fit study · verify swelling + leads",30,215)
const texture=new THREE.CanvasTexture(canvas);texture.colorSpace=THREE.SRGBColorSpace
const marking=new THREE.Mesh(new THREE.PlaneGeometry(36,18),new THREE.MeshStandardMaterial({map:texture,roughness:.75,metalness:.05}))
marking.rotation.x=-Math.PI/2;marking.position.y=2.861;return marking
}
function visibleBounds(){
const bounds=new THREE.Box3()
for(const object of model.children)if(object.visible){
const box=partBounds.get(object).clone().translate(object.position.clone().sub(originalPositions.get(object)));bounds.union(box)
}
return bounds.isEmpty()?new THREE.Box3(new THREE.Vector3(-38,-16,-29),new THREE.Vector3(38,0,29)):bounds
}
function focusCamera(which="iso"){
view=which
const target=visibleBounds().getCenter(new THREE.Vector3()),service=mode==="exploded"||mode==="open"
const direction=which==="controls"?new THREE.Vector3(-1.5,-.8,.35):which==="top"?new THREE.Vector3(0,1,.001):which==="front"?new THREE.Vector3(0,-1,.001):
service?new THREE.Vector3(.9,.75,-1.25):new THREE.Vector3(-.65,-1.4,.55)
direction.normalize();camera.up.set(0,service&&which==="iso"?1:0,service&&which==="iso"?0:1)
controls.target.copy(target);camera.position.copy(target).add(direction);camera.lookAt(target);camera.updateMatrixWorld()
const right=new THREE.Vector3().setFromMatrixColumn(camera.matrixWorld,0),up=new THREE.Vector3().setFromMatrixColumn(camera.matrixWorld,1)
const b=visibleBounds(),tan=Math.tan(THREE.MathUtils.degToRad(camera.fov/2));let distance=0
for(const x of [b.min.x,b.max.x])for(const y of [b.min.y,b.max.y])for(const z of [b.min.z,b.max.z]){
const p=new THREE.Vector3(x,y,z).sub(target),depth=p.dot(direction)
distance=Math.max(distance,depth+Math.abs(p.dot(up))/tan,depth+Math.abs(p.dot(right))/(tan*camera.aspect))
}
camera.position.copy(target).addScaledVector(direction,distance*1.12);controls.update();invalidate()
}
function selectionBounds(){
const object=model.children.find(o=>o.name===selectedName)
highlight.visible=Boolean(object?.visible)
if(highlight.visible)highlight.box.copy(partBounds.get(object)).translate(object.position.clone().sub(originalPositions.get(object)))
}
function applyView(){
floor.visible=mode==="assembled";boardBatch.visible=document.getElementById("pcb").checked
const spacing=Number(document.getElementById("spacing").value)
for(const object of model.children){
const env=definitions.get(object.name),base=originalPositions.get(object);object.position.copy(base)
if(env){
const cable=env.name.startsWith("CABLE_"),mechanical=!env.name.startsWith("ENV_")
object.visible=mode!=="pcb"&&(cable?document.getElementById("cables").checked:mechanical?document.getElementById("case").checked:
env.kind==="reserve"?document.getElementById("reserve").checked:document.getElementById("envelopes").checked)
if(mode==="exploded")object.position.y+=env.explode*spacing
if(mode==="open"&&(env.opensWithRear||["battery","pad","reserve"].includes(env.kind)))object.position.y+=32
}else object.visible=document.getElementById("pcb").checked
if(mode==="wiring"&&env){
if(["case","control","leaf","fastener"].includes(env.kind))object.visible=object.name==="CONTROL_POWER"
if(env.kind==="pad"||env.kind==="reserve")object.visible=false
}
if(["battery","speaker","motor"].includes(env?.kind))object.traverse(c=>{if(c.isMesh){
const transparent=mode==="wiring";if(c.material.transparent!==transparent)c.material.needsUpdate=true
c.material.transparent=transparent;c.material.opacity=transparent?.15:1;c.material.depthWrite=!transparent
}})
}
for(const button of document.querySelectorAll("[data-mode]"))button.setAttribute("aria-pressed",String(button.dataset.mode===mode))
Object.assign(window.museAssembly,{mode,spacing,parts:model.children.map(o=>({name:o.name,visible:o.visible,position:o.position.toArray()}))})
document.getElementById("service-note").hidden=mode!=="open"&&mode!=="exploded"
document.getElementById("spacing-control").hidden=mode!=="exploded"
selectionBounds();invalidate(true)
}
function inspect(name){
const env=definitions.get(name),supplier=suppliers.get(name)
selectedName=name;selected.dataset.part=name;selected.textContent=env?env.label:supplier?`${name} · imported JLCPCB ${supplier.lcsc??supplier.lcsc_id??"supplier CAD"}`:"Four-layer populated PCB · 66 × 48 × 1 mm"
selectionBounds();invalidate()
}
// Preserve every original supplier mesh and material. Repeated primitives share
// GPU instances; remaining static primitives are merged only by identical material.
// Each triangle/instance retains its original reference for picking.
function batchBoard(){
const groups=new Map();let original=0
model.updateMatrixWorld(true)
for(const root of model.children)if(!definitions.has(root.name))root.traverse(mesh=>{
if(!mesh.isMesh||Array.isArray(mesh.material))return
const key=`${mesh.geometry.uuid}:${mesh.material.uuid}`
if(!groups.has(key))groups.set(key,[]);groups.get(key).push({mesh,name:root.name});original++
})
const leftovers=new Map()
for(const items of groups.values()){
if(items.length>=3){
const batch=new THREE.InstancedMesh(items[0].mesh.geometry,items[0].mesh.material,items.length)
batch.userData.references=items.map(p=>p.name)
items.forEach((p,i)=>{batch.setMatrixAt(i,p.mesh.matrixWorld);p.mesh.layers.set(1)})
batch.computeBoundingBox();batch.computeBoundingSphere();boardBatch.add(batch);pickMeshes.push(batch)
}else for(const p of items){
const signature=Object.entries(p.mesh.geometry.attributes).map(([k,a])=>`${k}:${a.itemSize}:${a.normalized}`).sort().join(";")
const key=`${p.mesh.material.uuid}:${signature}`
if(!leftovers.has(key))leftovers.set(key,[]);leftovers.get(key).push(p)
}
}
for(const items of leftovers.values()){
let faces=0;const ranges=[],geometries=[]
for(const p of items){
const geometry=p.mesh.geometry.index?p.mesh.geometry.toNonIndexed():p.mesh.geometry.clone()
geometry.applyMatrix4(p.mesh.matrixWorld);faces+=geometry.getAttribute("position").count/3
ranges.push({end:faces,name:p.name});geometries.push(geometry);p.mesh.layers.set(1)
}
const merged=mergeGeometries(geometries,false)
if(!merged)throw Error("Unable to batch supplier geometry without losing attributes")
const batch=new THREE.Mesh(merged,items[0].mesh.material);batch.userData.faceReferences=ranges
boardBatch.add(batch);pickMeshes.push(batch);geometries.forEach(g=>g.dispose())
}
return {original_primitives:original,draw_groups:boardBatch.children.length,all_supplier_models_retained:155}
}
async function loadModel(){
const compressed=manifest.viewer_model&&typeof DecompressionStream!=="undefined"
const response=await fetch(compressed?manifest.viewer_model.path:"MuseVoiceRemote-assembly.glb")
if(!response.ok)throw Error(`Model download failed (${response.status})`)
const download=await response.arrayBuffer()
const bytes=compressed?await new Response(new Blob([download]).stream().pipeThrough(new DecompressionStream("gzip"))).arrayBuffer():download
let verified=false
if(crypto.subtle){
const hash=Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256",bytes)),b=>b.toString(16).padStart(2,"0")).join("")
if(hash!==manifest.outputs.assembly_glb.sha256)throw Error("Model integrity check failed")
verified=true
}
const loaded=await new GLTFLoader().parseAsync(bytes,new URL(".",location.href).href)
return {loaded,transfer:{bytes:download.byteLength,uncompressed_bytes:bytes.byteLength,lossless:true,sha256_verified:verified}}
}
try{
const {loaded,transfer}=await loadModel();model=loaded.scene;scene.add(model)
model.traverse(o=>{if(o.isMesh){meshes.push(o);o.geometry.computeBoundingBox()}})
const geometryBounds=new THREE.Box3().setFromObject(model).getSize(new THREE.Vector3()).toArray()
for(const o of model.children){
originalPositions.set(o,o.position.clone());partBounds.set(o,new THREE.Box3().setFromObject(o))
const definition=definitions.get(o.name)
if(o.name==="ENV_LCD_ACTIVE_PIXELS"){
o.traverse(c=>{if(c.isMesh){c.material=c.material.clone();c.material.visible=false}});o.add(screenArtwork())
}
if(definition?.kind==="case"||definition?.kind==="control")o.traverse(c=>{if(c.isMesh){
c.geometry=toCreasedNormals(c.geometry,Math.PI/4)
c.material=new THREE.MeshPhysicalMaterial({color:definition.kind==="case"?"#242b30":o.name==="CONTROL_TALK"?"#248c87":"#353d42",
roughness:definition.kind==="case"?.62:.48,metalness:0,clearcoat:.08,clearcoatRoughness:.65,bumpMap:printGrain,bumpScale:.012})
c.castShadow=true;c.receiveShadow=true;c.renderOrder=-10
}})
if(definition?.kind==="cable"||definition?.kind==="flex")o.traverse(c=>{if(c.isMesh){
c.material=new THREE.MeshPhysicalMaterial({color:c.material.color,roughness:.55,metalness:0,clearcoat:.1})
}})
if(definition?.kind==="battery"){
o.traverse(c=>{if(c.isMesh)c.material=new THREE.MeshStandardMaterial({color:0xadafb1,roughness:.38,metalness:.65})})
o.add(batteryMarking())
}
if(definition)o.traverse(c=>{if(c.isMesh){c.userData.part=o.name;pickMeshes.push(c)}})
}
const batching=batchBoard()
for(const o of model.children){
const e=definitions.get(o.name),button=document.createElement("button")
button.textContent=e?e.label:o.name;button.dataset.search=button.textContent.toLowerCase();button.onclick=()=>inspect(o.name);document.getElementById("parts").append(button)
}
window.museAssembly={ready:true,mode,meshCount:meshes.length,supplierMeshes:manifest.supplier_meshes_loaded,
envelopes:manifest.envelopes.length,mechanicalParts:manifest.mechanical_parts.length,cables:manifest.cable_parts.length,
geometryBounds,batching,transfer,selectPart:inspect,quality:"balanced"}
ready=true;applyView();focusCamera()
status.textContent=`${manifest.supplier_meshes_loaded} supplier models · ${(transfer.bytes/1e6).toFixed(1)} MB download · full geometry`
}catch(error){status.textContent=`3D load failed: ${error.message}`;window.museAssembly={ready:false,error:error.message};throw error}
new ResizeObserver(()=>{
const w=viewport.clientWidth,h=viewport.clientHeight;renderer.setSize(w,h,false);camera.aspect=w/h;camera.updateProjectionMatrix()
if(ready)focusCamera(view);else invalidate()
}).observe(viewport)
for(const button of document.querySelectorAll("[data-mode]"))button.onclick=()=>{mode=button.dataset.mode;selectedName=null;applyView();focusCamera(view)}
for(const button of document.querySelectorAll("[data-camera]"))button.onclick=()=>focusCamera(button.dataset.camera)
for(const id of ["envelopes","case","pcb","reserve","cables"])document.getElementById(id).onchange=()=>{applyView();focusCamera(view)}
document.getElementById("reset").onclick=()=>{selectedName=null;selectionBounds();focusCamera("iso")}
document.getElementById("spacing").oninput=()=>{applyView();focusCamera(view)}
document.getElementById("part-search").oninput=e=>{for(const b of document.querySelectorAll("#parts button"))b.hidden=!b.dataset.search.includes(e.target.value.toLowerCase())}
document.getElementById("quality").onchange=e=>{
const detailed=e.target.value==="detailed";renderer.setPixelRatio(Math.min(devicePixelRatio,detailed?2:1.5))
window.museAssembly.quality=e.target.value;renderer.setSize(viewport.clientWidth,viewport.clientHeight,false);invalidate()
}
document.getElementById("save-render").onclick=()=>{
renderer.render(scene,camera);const a=document.createElement("a");a.download=`MuseVoiceRemote-${mode}.png`;a.href=renderer.domElement.toDataURL("image/png");a.click()
}
let start
renderer.domElement.addEventListener("pointerdown",e=>start=[e.clientX,e.clientY])
renderer.domElement.addEventListener("pointerup",e=>{
if(!start||Math.hypot(e.clientX-start[0],e.clientY-start[1])>5)return
const rect=renderer.domElement.getBoundingClientRect();pointer.set((e.clientX-rect.left)/rect.width*2-1,-(e.clientY-rect.top)/rect.height*2+1)
ray.setFromCamera(pointer,camera)
const hit=ray.intersectObjects(pickMeshes,false).find(h=>{let p=h.object;while(p){if(!p.visible)return false;p=p.parent}return h.object.material.visible!==false&&!h.object.material.wireframe})
if(hit){const data=hit.object.userData;inspect(data.references?data.references[hit.instanceId]:data.faceReferences?data.faceReferences.find(p=>hit.faceIndex<p.end)?.name:data.part)}
})