muse/book-reading-clip-lamp

A Wi‑Fi/USB‑C ESP32‑S3 reading lamp with OV5640 camera, microphone, speaker amplifier, rechargeable Li‑Po power management, six-LED boost light, USB protection/charging detection, and a clip/gooseneck enclosure with external FlexPIFA antenna.

Version
0.4.3
License
unset
Stars
0

enclosure-viewer.js

import * as THREE from "three"
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"
const host = document.getElementById("viewport")
const scene = new THREE.Scene()
const camera = new THREE.PerspectiveCamera(34, 1, 0.1, 2000)
camera.up.set(0, 0, 1)
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, preserveDrawingBuffer: true })
renderer.setPixelRatio(Math.min(devicePixelRatio, 2))
renderer.setClearColor(0, 0)
renderer.outputColorSpace = THREE.SRGBColorSpace
renderer.toneMapping = THREE.ACESFilmicToneMapping
renderer.toneMappingExposure = 1.25
host.appendChild(renderer.domElement)
const controls = new OrbitControls(camera, renderer.domElement)
controls.enableDamping = true
controls.minDistance = 65
controls.maxDistance = 1500
controls.target.set(0, -90, -88)
scene.add(new THREE.HemisphereLight(0xffffff, 0x778b78, 2.5))
for (const [x, y, z, intensity] of [[-90,100,-110,3],[100,60,90,2],[-30,-100,40,1]]) {
  const light = new THREE.DirectionalLight(0xffffff, intensity); light.position.set(x,y,z); scene.add(light)
}
const loader = new GLTFLoader()
let mode = "assembled", view = "front", amount = 0
const objects = []
const pose = {
  front: [280, 310, 155], rear: [-270, -320, 210], side: [-430, 5, 80],
}
const applyView = () => {
  // Fit the narrow side of the viewport as well as its height.
  const factor = (1 + amount * 0.35) * Math.max(1, 1.05 / camera.aspect)
  const center = [0,-90,-88]
  camera.position.set(...pose[view].map((n,i) => center[i] + n * factor))
  controls.target.set(...center); controls.update()
  document.querySelectorAll("[data-view]").forEach(b => b.classList.toggle("active", b.dataset.view === view))
}
function update() {
  for (const { object, part } of objects) {
    const offset = part?.explode ?? [0,0,0]
    object.position.set(...offset.map(n => n * amount))
    object.traverse(child => {
      if (!child.isMesh) return
      for (const material of Array.isArray(child.material) ? child.material : [child.material]) {
        const shell = ["rear","front","base","neck"].includes(part?.group)
        material.transparent = mode === "inside" && shell
        material.opacity = mode === "inside" && shell ? 0.13 : 1
        material.depthWrite = !(mode === "inside" && shell)
        material.side = THREE.DoubleSide
        material.needsUpdate = true
      }
      child.renderOrder = ["rear","front","base","neck"].includes(part?.group) && mode === "inside" ? 2 : 0
    })
  }
  document.getElementById("explode").value = amount * 100
  document.getElementById("explodeValue").textContent = `${Math.round(amount * 100)}%`
  document.querySelectorAll("[data-mode]").forEach(b => b.classList.toggle("active", b.dataset.mode === mode))
  document.getElementById("viewLabel").textContent = `${view === "front" ? "COMPLETE LAMP" : view === "rear" ? "REAR VIEW" : "SIDE"} / ${mode.toUpperCase()}`
}
document.querySelectorAll("[data-mode]").forEach(b => b.onclick = () => {
  mode = b.dataset.mode; amount = mode === "exploded" ? 1 : 0; applyView(); update()
})
document.querySelectorAll("[data-view]").forEach(b => b.onclick = () => { view = b.dataset.view; applyView(); update() })
document.getElementById("explode").oninput = e => {
  amount = Number(e.target.value) / 100
  mode = amount ? "exploded" : "assembled"; update()
}
document.getElementById("save-view").onclick = async () => {
  renderer.render(scene, camera)
  const bounds = new THREE.Box3()
  for (const {object} of objects) bounds.union(new THREE.Box3().setFromObject(object))
  const points = []
  for(const x of [bounds.min.x,bounds.max.x]) for(const y of [bounds.min.y,bounds.max.y]) for(const z of [bounds.min.z,bounds.max.z]) {
    const p = new THREE.Vector3(x,y,z).project(camera)
    points.push([(p.x+1)*renderer.domElement.width/2,(1-p.y)*renderer.domElement.height/2])
  }
  const padding = 32 * renderer.getPixelRatio()
  const x0 = Math.max(0,Math.floor(Math.min(...points.map(p=>p[0]))-padding))
  const y0 = Math.max(0,Math.floor(Math.min(...points.map(p=>p[1]))-padding))
  const x1 = Math.min(renderer.domElement.width,Math.ceil(Math.max(...points.map(p=>p[0]))+padding))
  const y1 = Math.min(renderer.domElement.height,Math.ceil(Math.max(...points.map(p=>p[1]))+padding))
  const output = document.createElement("canvas")
  output.width = x1-x0; output.height = y1-y0
  const ctx = output.getContext("2d")
  ctx.fillStyle = "#eef3eb"; ctx.fillRect(0,0,output.width,output.height)
  ctx.drawImage(renderer.domElement,x0,y0,output.width,output.height,0,0,output.width,output.height)
  const response = await fetch("/save-preview", { method: "POST", headers: {"Content-Type":"application/json"},
    body: JSON.stringify({ image: output.toDataURL("image/png"), view, mode }) })
  document.getElementById("save-view").textContent = response.ok ? "View saved to project ✓" : "Save failed — try again"
}
new ResizeObserver(() => {
  const { width, height } = host.getBoundingClientRect()
  renderer.setSize(width, height); camera.aspect = width/height; camera.updateProjectionMatrix()
  applyView()
}).observe(host)
try {
  const parts = await (await fetch("./mechanical/parts.json")).json()
  document.getElementById("part-count").textContent = `${parts.length} mechanical models + routed PCB`
  await Promise.all([...parts.map(async part => {
    const result = await loader.loadAsync(`./mechanical/${part.glb}`)
    result.scene.name = part.id
    result.scene.traverse(o => { if(o.isMesh) { o.material.roughness = 0.48; o.material.metalness = 0.05 } })
    scene.add(result.scene); objects.push({ object: result.scene, part })
  }), (async () => {
    const result = await loader.loadAsync("./mechanical/pcb.gltf")
    // circuit-json-to-gltf emits (-PCB X, PCB Z, PCB Y); mechanical GLBs use PCB XYZ.
    result.scene.applyMatrix4(new THREE.Matrix4().set(-1,0,0,0, 0,0,1,0, 0,1,0,0, 0,0,0,1))
    scene.add(result.scene); objects.push({ object: result.scene })
  })()])
  document.getElementById("loading").classList.add("hidden")
  applyView(); update()
} catch (error) {
  document.getElementById("loading").textContent = `Preview could not load: ${error.message}`
  console.error(error)
}
renderer.setAnimationLoop(() => { controls.update(); renderer.render(scene, camera) })