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"
const viewport = document.getElementById("viewport"), status = document.getElementById("loading")
const renderer = new THREE.WebGLRenderer({antialias:true,alpha:false,preserveDrawingBuffer:true})
renderer.setPixelRatio(Math.min(devicePixelRatio,2))
renderer.setClearColor(0x0c1722)
renderer.outputColorSpace=THREE.SRGBColorSpace
viewport.append(renderer.domElement)
const scene=new THREE.Scene(),camera=new THREE.PerspectiveCamera(36,1,0.1,2000)
const controls=new OrbitControls(camera,renderer.domElement)
controls.enableDamping=true
scene.add(new THREE.HemisphereLight(0xdfefff,0x4a535c,2.5))
for(const [position,intensity] of [[[50,90,70],2.5],[[-70,20,-40],1.4]]){
const light=new THREE.DirectionalLight(0xffffff,intensity);light.position.set(...position);scene.add(light)
}
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.map(e=>[e.name,e]))
let model,mode="assembled",meshes=[],view="iso"
const ray=new THREE.Raycaster(),pointer=new THREE.Vector2()
const originalPositions=new Map(), selected=document.getElementById("selection")
async function loadAsset(name,manifestUrl="assets.json"){
const url=new URL(manifestUrl,location.href)
const response=await fetch(url)
if(!response.ok)throw Error(`Asset manifest failed: ${response.status}`)
const asset=(await response.json()).assets[name]
if(!asset?.parts?.length)throw Error(`Asset is missing: ${name}`)
const hash=async bytes=>Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256",bytes))).map(x=>x.toString(16).padStart(2,"0")).join("")
const result=new Uint8Array(asset.bytes)
let offset=0
for(const part of asset.parts){
const r=await fetch(new URL(part.path,url))
if(!r.ok)throw Error(`Asset part failed: ${r.status}`)
const bytes=new Uint8Array(await r.arrayBuffer())
if(bytes.length!==part.bytes||await hash(bytes)!==part.sha256)throw Error("Asset part checksum failed")
result.set(bytes,offset);offset+=bytes.length
}
if(offset!==asset.bytes||await hash(result)!==asset.sha256)throw Error("Complete asset checksum failed")
return result
}
function focusCamera(which="iso"){
view=which
const target=new THREE.Vector3(0,mode==="exploded"?4:2,0)
controls.target.copy(target)
const direction=which==="top"?new THREE.Vector3(0,1,0.001):which==="front"?new THREE.Vector3(0,-1,0.001):new THREE.Vector3(-0.75,1,-0.85).normalize()
camera.up.set(0,1,0)
if(which!=="iso")camera.up.set(0,0,1)
camera.position.copy(target).addScaledVector(direction,mode==="exploded"?180:155)
controls.update()
}
function applyView(){
for(const object of model.children){
const env=definitions.get(object.name),base=originalPositions.get(object)
object.position.copy(base)
if(env){
object.visible=mode!=="pcb"&&document.getElementById("envelopes").checked&&(env.kind!=="case"||document.getElementById("case").checked)
if(mode==="exploded")object.position.y+=env.explode
}else object.visible=document.getElementById("pcb").checked
}
for(const button of document.querySelectorAll("[data-mode]"))button.setAttribute("aria-pressed",String(button.dataset.mode===mode))
window.museAssembly.mode=mode
window.museAssembly.parts=model.children.map(o=>({name:o.name,visible:o.visible,position:o.position.toArray()}))
}
function inspect(name){
const env=definitions.get(name)
selected.textContent=env?env.label:`${name} · genuine imported supplier CAD`
if(name==="PCB"||name?.startsWith("Board"))selected.textContent="Four-layer populated PCB · 66 × 48 × 1 mm"
}
try{
const binary=await loadAsset("MuseVoiceRemote-assembly.glb")
const loaded=await new GLTFLoader().parseAsync(binary.buffer,"./")
model=loaded.scene;scene.add(model)
model.traverse(o=>{if(o.isMesh){meshes.push(o);o.geometry.computeBoundingBox()}})
for(const o of model.children){
originalPositions.set(o,o.position.clone())
if(definitions.get(o.name)?.kind==="case")o.traverse(c=>{if(c.isMesh){
c.material=c.material.clone();c.material.wireframe=true;c.material.transparent=true;c.material.opacity=0.2;c.material.depthWrite=false
}})
}
for(const e of manifest.envelopes){
const button=document.createElement("button");button.textContent=e.label;button.onclick=()=>inspect(e.name);document.getElementById("parts").append(button)
}
window.museAssembly={ready:true,mode,assetTransport:"sha256-verified-byte-parts",meshCount:meshes.length,supplierMeshes:manifest.supplier_meshes_loaded,
envelopes:manifest.envelopes.length,geometryBounds:new THREE.Box3().setFromObject(model).getSize(new THREE.Vector3()).toArray()}
applyView();focusCamera()
status.textContent=`${manifest.supplier_meshes_loaded} supplier models loaded · ${manifest.supplier_model_types} part types`
}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);camera.aspect=w/h;camera.updateProjectionMatrix()}).observe(viewport)
for(const button of document.querySelectorAll("[data-mode]"))button.onclick=()=>{mode=button.dataset.mode;applyView();focusCamera(view)}
for(const button of document.querySelectorAll("[data-camera]"))button.onclick=()=>focusCamera(button.dataset.camera)
for(const id of ["envelopes","case","pcb"])document.getElementById(id).onchange=applyView
document.getElementById("reset").onclick=()=>focusCamera("iso")
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(meshes).find(h=>{let p=h.object;while(p){if(!p.visible)return false;p=p.parent}return !h.object.material.wireframe})
if(hit){let object=hit.object;while(object.parent&&object.parent!==model)object=object.parent;inspect(object.name)}
})
renderer.setAnimationLoop(()=>{controls.update();renderer.render(scene,camera)})
const download=document.createElement("button")
download.textContent="Download assembly ZIP"
download.onclick=async()=>{
download.disabled=true
try{
const bytes=await loadAsset("MuseVoiceRemote-3d-assembly.zip","../assembly-bundle-assets.json")
const url=URL.createObjectURL(new Blob([bytes],{type:"application/zip"}))
const a=document.createElement("a");a.href=url;a.download="MuseVoiceRemote-3d-assembly.zip";a.click()
setTimeout(()=>URL.revokeObjectURL(url),60000)
}catch(error){status.textContent=`Download failed: ${error.message}`;throw error}
finally{download.disabled=false}
}
document.getElementById("parts").append(download)