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/build-assembly.tsx

import { Circuit, assembly } from "@tscircuit/core"
import type { AnyCircuitElement } from "circuit-json"
import { readFile, writeFile, mkdir, cp, rm } from "node:fs/promises"
import { createHash } from "node:crypto"
import { convertCircuitJsonTo3D, convertSceneToGLTF } from "circuit-json-to-gltf"
import { MechanicalEnvelopes } from "../MuseVoiceRemoteAssembly.circuit"
import { assemblyEnvelopes, stack, fromFront } from "../design/assembly-stack"
import { assembly as dimensions } from "../design/assembly"
import { writeAssetChunks } from "./chunk-assets"

const sha = (b: Uint8Array | string) => createHash("sha256").update(b).digest("hex")
const original = await readFile("circuit.json")
const board: AnyCircuitElement[] = JSON.parse(original.toString())
const imported = JSON.parse(await readFile("vendor/jlcpcb/import-manifest.json", "utf8"))
const downloads = JSON.parse(await readFile("docs/assembly-model-downloads.json", "utf8"))
const modelData = new Map<string, string>()
for (const e of imported.imports) {
  const record = downloads.models.find((m: {lcsc: string}) => m.lcsc === e.lcsc)
  if (!record) throw new Error(`No downloaded model evidence: ${e.lcsc}`)
  for (const extension of ["obj", "step"]) {
    const saved = record.models[extension], bytes = await readFile(saved.path)
    if (saved.status !== "downloaded" || sha(bytes) !== saved.sha256) throw new Error(`CAD integrity: ${e.lcsc}.${extension}`)
    if (extension === "obj") modelData.set(e.lcsc, `data:text/plain;base64,${bytes.toString("base64")}`)
  }
}

// Render only mechanical records, then namespace their IDs. The verified board
// records are copied byte-for-byte in meaning, without another placement pass.
const mechanical = new Circuit()
mechanical.schematicDisabled = true
mechanical.add(<assembly.device name="MUSE_MECHANICAL_ASSEMBLY"><MechanicalEnvelopes /></assembly.device>)
await mechanical.renderUntilSettled()
const mech = mechanical.getCircuitJson()
const ids = new Map<string, string>()
for (const e of mech) for (const [key, value] of Object.entries(e))
  if (key === `${e.type}_id` && typeof value === "string") ids.set(value, `assembly_${value}`)
function namespace(value: unknown): unknown {
  if (typeof value === "string") return ids.get(value) ?? value
  if (Array.isArray(value)) return value.map(namespace)
  if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([k,v]) => [k,namespace(v)]))
  return value
}
const json: AnyCircuitElement[] = [...board, ...namespace(mech) as AnyCircuitElement[]]
if (json.some(e => e.type.endsWith("_error"))) throw new Error("Assembly render reported errors")
await mkdir("dist/assembly", { recursive: true })
await writeFile("dist/assembly/assembly.circuit.json", JSON.stringify(json, null, 2) + "\n")
// Registry resolves previewComponentPath=dist/assembly/assembly.circuit.json
// to dist/assembly/assembly/circuit.json. Both are the same actual generated
// output; never rerender the source without its verified copper for a preview.
await mkdir("dist/assembly/assembly", { recursive: true })
await writeFile("dist/assembly/assembly/circuit.json", JSON.stringify(json, null, 2) + "\n")

// The published Circuit JSON keeps the original supplier OBJ/STEP URLs. Only
// this private conversion copy uses SHA-verified bytes, so export needs no CDN.
const local = structuredClone(json)
for (const e of local) if (e.type === "cad_component" && e.model_obj_url) {
  const lcsc = /\/(C\d+)\.obj/.exec(e.model_obj_url)?.[1]
  if (!lcsc || !modelData.has(lcsc)) throw new Error("Unresolved imported CAD")
  e.model_obj_url = modelData.get(lcsc)!
}
const scene = await convertCircuitJsonTo3D(local, { renderBoardTextures: true, textureResolution: 1536,
  boardDrillQuality: "fast", showBoundingBoxes: false, showPcbNotes: false })
const references = new Set(board.filter(e => e.type === "source_component").map(e => e.name))
const placed = board.filter(e => e.type === "pcb_component")
const actual = scene.boxes.filter(b => b.label && references.has(b.label))
if (actual.length !== placed.length || actual.some(b => !b.mesh?.triangles.length))
  throw new Error("A supplier model failed to load; refusing a bounding-box fallback")
const glb = Buffer.from(await convertSceneToGLTF(scene, {binary: true, embedImages: true}) as ArrayBuffer)
await writeFile("dist/assembly/MuseVoiceRemote-assembly.glb", glb)
const boardScene = {...scene, boxes: scene.boxes.filter(b => !b.label?.startsWith("ENV_"))}
const boardGlb = Buffer.from(await convertSceneToGLTF(boardScene, {binary: true, embedImages: true}) as ArrayBuffer)
await writeFile("dist/assembly/MuseVoiceRemote-populated-pcb.glb", boardGlb)
await writeAssetChunks(["dist/assembly/MuseVoiceRemote-assembly.glb","dist/assembly/MuseVoiceRemote-populated-pcb.glb"],"dist/assembly/assets.json")
await cp("scripts/reassemble-assets.py","dist/assembly/reassemble-assets.py")

// Conservative transformed OBJ bounds: dimensions/origins are real supplier
// mesh data, not dimensions inferred from an electronic footprint.
const bounds: {reference:string;lcsc:string;min:number[];max:number[]}[] = []
for (const cad of board.filter(e => e.type === "cad_component")) {
  const source = board.find(e => e.type === "source_component" && e.source_component_id === cad.source_component_id)
  const lcsc = /\/(C\d+)\.obj/.exec(cad.model_obj_url!)![1]
  const record = downloads.models.find((e: {lcsc:string}) => e.lcsc === lcsc)
  const b = record.obj_bounds, o = cad.model_origin_position ?? {x:0,y:0,z:0}
  if ((cad.rotation?.x ?? 0) || (cad.rotation?.y ?? 0) || cad.size) throw new Error("Bounds audit expects unscaled top Z rotation")
  const r = (cad.rotation?.z ?? 0) * Math.PI / 180
  const points = [b.min[0],b.max[0]].flatMap(x => [b.min[1],b.max[1]].flatMap(y => [b.min[2],b.max[2]].map(z => [
    cad.position.x + (x-o.x)*Math.cos(r) - (y-o.y)*Math.sin(r),
    cad.position.y + (x-o.x)*Math.sin(r) + (y-o.y)*Math.cos(r), cad.position.z + z-o.z])))
  bounds.push({reference:source?.type === "source_component" ? source.name : "unknown",lcsc,
    min:[0,1,2].map(i=>Math.min(...points.map(p=>p[i]))),max:[0,1,2].map(i=>Math.max(...points.map(p=>p[i])))})
}
const pack = assemblyEnvelopes.find(e=>e.kind === "battery")!
const packMin=[pack.x,pack.y,pack.z].map((v,i)=>v-pack.size[i]/2)
const packMax=[pack.x,pack.y,pack.z].map((v,i)=>v+pack.size[i]/2)
const packIntersections=bounds.filter(b=>[0,1,2].every(i=>Math.min(b.max[i],packMax[i])-Math.max(b.min[i],packMin[i])>0.00001))
const supplierIntersections=[]
for(let i=0;i<bounds.length;i++)for(let j=i+1;j<bounds.length;j++){
  const a=bounds[i],b=bounds[j]
  if([0,1,2].every(k=>Math.min(a.max[k],b.max[k])-Math.max(a.min[k],b.min[k])>0.00001))
    supplierIntersections.push([a.reference,b.reference])
}
const transducerIntersections=assemblyEnvelopes.filter(e=>e.kind==="speaker"||e.kind==="motor").flatMap(e=>{
  const lo=[e.x,e.y,e.z].map((v,i)=>v-e.size[i]/2),hi=[e.x,e.y,e.z].map((v,i)=>v+e.size[i]/2)
  return bounds.filter(b=>[0,1,2].every(i=>Math.min(b.max[i],hi[i])-Math.max(b.min[i],lo[i])>0.00001)).map(b=>[e.name,b.reference])
})
if(packIntersections.length||supplierIntersections.length||transducerIntersections.length)
  throw new Error(`Supplier geometry intersects the proposed assembly allocation: ${JSON.stringify({battery:packIntersections.map(p=>p.reference),supplier:supplierIntersections,transducers:transducerIntersections})}`)
const report = { status:"3D assembly study; physical mechanical qualification pending", board_source_sha256:sha(original),
  pcb_records_preserved:true, populated_top_components:placed.length, supplier_model_types:modelData.size,
  supplier_obj_and_step_sha256_verified:modelData.size*2, supplier_meshes_loaded:actual.length,
  cad_fallback_boxes:0, via_minima_mm:{hole:0.3,pad:0.45},
  coordinate_system:{units:"mm",pcb_midplane_z:0,pcb_top_z:0.5,front_outer_z:fromFront(0),
    pdf_front_to_pcb_centre:stack.pcbCentreFromFront,display_faces:"−Z",all_population:"TOP / +Z"},
  envelopes:assemblyEnvelopes, transformed_supplier_bounds:bounds,
  conservative_battery_intersections:packIntersections,
  conservative_supplier_pair_intersections:supplierIntersections,
  conservative_transducer_intersections:transducerIntersections,
  mechanical_revision:{pdf_baseline_case_depth:16,proposed_case_depth:dimensions.case.depth,
    battery_shift_x_mm:1,battery_raise_mm:0.5,
    transducer_raise_mm:0.5,
    reason:"Original battery allocation overlaps privacy, TALK and RESET switch bounds; speaker allocation overlaps BOOT. A 17 mm case retains swelling clearance after raising allocations."},
  rear_clearance_after_battery_swelling_mm:stack.rearInner-(dimensions.battery.zBottom+dimensions.battery.depth+stack.batterySwelling),
  limitations:["Envelopes are not manufacturer CAD or selected off-board parts.",
    "Case is the external volume only, without wall/aperture/post engineering.",
    "No flex fold, harness, acoustic ducts, antenna or actuator linkage geometry is claimed.",
    "OBJ AABB screening does not certify collision-free solids, tolerances or assembly access.",
    "PDF MCU module geometry is obsolete; real bare ESP32-S3R8 supplier model is used."],
  outputs:{assembly_glb:{bytes:glb.length,sha256:sha(glb)},pcb_glb:{bytes:boardGlb.length,sha256:sha(boardGlb)}} }
await writeFile("docs/assembly-verification.json",JSON.stringify(report,null,2)+"\n")
await writeFile("dist/assembly/manifest.json",JSON.stringify(report,null,2)+"\n")
await cp("scripts/assembly-viewer.html","dist/assembly/index.html")
await cp("scripts/assembly-viewer.js","dist/assembly/viewer.js")
await mkdir("dist/assembly/lib",{recursive:true})
for(const name of ["OrbitControls.js","GLTFLoader.js","BufferGeometryUtils.js"])
  await rm(`dist/assembly/lib/${name}`,{force:true})
for (const file of ["build/three.module.js","build/three.core.js","examples/jsm/controls/OrbitControls.js",
  "examples/jsm/loaders/GLTFLoader.js","examples/jsm/utils/BufferGeometryUtils.js"]) {
  const name=file.replace("build/","").replace("examples/jsm/","")
  const parent=name.includes("/")?name.slice(0,name.lastIndexOf("/")):""
  await mkdir(`dist/assembly/lib/${parent}`,{recursive:true})
  await cp(`node_modules/three/${file}`,`dist/assembly/lib/${name}`)
}
// Retained images come from the actual checked browser, not generated artwork.
await cp("docs/assembly-previews","dist/assembly/previews",{recursive:true})
console.log(JSON.stringify({supplier_types:modelData.size,actual_meshes:actual.length,
  board_glb_bytes:boardGlb.length,assembly_glb_bytes:glb.length,battery_aabb_intersections:packIntersections.length},null,2))