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)}
})