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

export-enclosure.ts

/// <reference types="node" />
import jscad from "@jscad/modeling"
import { executeJscadOperations } from "jscad-planner"
import Module from "./mechanical-tools/node_modules/manifold-3d"
import { convertJscadPlanToGltf, convertJscadModelToGltf } from "jscad-to-gltf"
import { enclosureParts, mechanical, tint, cyl, box, type Plan } from "./enclosure-geometry"
import { baseAssemblyParts, baseMechanical } from "./base-assembly-geometry"
import { cameraMechanical, cameraFold, cameraConnectorReview, cameraParts, cameraFlexEnvelope, cameraHeatSinkEnvelope } from "./camera-mechanical"
import { antennaMechanical, antennaParts, antennaCableLength, antennaBodyEnvelope, antennaPlasticKeepout, antennaMetalKeepout } from "./antenna-mechanical"
import { mkdirSync, writeFileSync, readFileSync } from "node:fs"

const out = "mechanical"
mkdirSync(out, { recursive: true })
const kernel = await Module()
kernel.setup()
const M = kernel.Manifold
// Evaluate the same authoring plan with a robust solid kernel for fabrication.
const evaluate = (p: Plan): any => {
  switch (p.type) {
    case "cuboid": return M.cube(p.size, true)
    case "cylinder": return M.cylinder(p.height, p.radius, p.radius, p.segments ?? 96, true)
    case "extrudeLinear": {
      if (p.shape.type !== "polygon") throw new Error("Expected a polygon extrusion")
      return new kernel.CrossSection(p.shape.points).extrude(p.options.height)
    }
    case "translate": return evaluate(p.shape).translate(p.vector)
    case "rotate": return evaluate(p.shape).rotate(p.angles.map((n: number) => n * 180 / Math.PI))
    case "colorize": return evaluate(p.shape)
    case "union": return M.union(p.shapes.map(evaluate))
    case "subtract": return evaluate(p.shapes[0]).subtract(M.union(p.shapes.slice(1).map(evaluate)))
    default: throw new Error(`Unsupported geometry operation ${p.type}`)
  }
}
const allParts = [...enclosureParts, ...baseAssemblyParts]
const meshes = allParts.map(p => ({ ...p, geom: evaluate(p.plan) }))
// Preserve nested colors in dimensional reference assemblies such as the
// pouch's foil/tape and the speaker's frame/cone, including parent transforms.
function coloredGeometries(plan: Plan, fallback: number[], transforms: Plan[] = []): any[] {
  if (plan.type === "union") return plan.shapes.flatMap((p: Plan) => coloredGeometries(p, fallback, transforms))
  if (["translate", "rotate"].includes(plan.type)) return coloredGeometries(plan.shape, fallback, [...transforms, plan])
  if (plan.type === "colorize") return coloredGeometries(plan.shape, plan.color, transforms)
  const resolved = transforms.reduceRight((shape, transform) => ({ ...transform, shape }), plan)
  return [{ geom: executeJscadOperations(jscad as any, resolved as any), color: fallback }]
}
const reports: any[] = []
for (const p of meshes) {
  if (p.geom.status() !== "NoError") throw new Error(`${p.id}: ${p.geom.status()}`)
  const connectedSolids = p.geom.decompose().length
  if (p.printable && connectedSolids !== 1) throw new Error(`${p.id}: ${connectedSolids} separate solids`)
  const b = p.geom.boundingBox()
  const bbox = [b.min, b.max]
  const volume = p.geom.volume()
  if (!(volume > 0)) throw new Error(`${p.id}: no solid volume`)
  const mesh = p.geom.getMesh()
  const polygons: {vertices: number[][]}[] = []
  for (let i = 0; i < mesh.triVerts.length; i += 3) {
    polygons.push({vertices: [...mesh.triVerts.slice(i, i + 3)].map(index =>
      [...mesh.vertProperties.slice(index * mesh.numProp, index * mesh.numProp + 3)])})
  }
  // Binary STL with resolved transforms; each printed part is moved onto Z=0.
  const data = Buffer.alloc(84 + 50 * polygons.length)
  data.write(`Lamp Clip M7 ${p.id} / mm`, 0, 80, "ascii")
  data.writeUInt32LE(polygons.length, 80)
  const edges = new Map<string, number>()
  let count = 0
  for (const polygon of polygons) {
    const v = polygon.vertices
    if (v.length !== 3) throw new Error("Exporter requires triangles")
    const normal = jscad.maths.vec3.cross(jscad.maths.vec3.create(),
      jscad.maths.vec3.subtract(jscad.maths.vec3.create(), v[1] as [number, number, number], v[0] as [number, number, number]),
      jscad.maths.vec3.subtract(jscad.maths.vec3.create(), v[2] as [number, number, number], v[0] as [number, number, number]))
    jscad.maths.vec3.normalize(normal, normal)
    let offset = 84 + 50 * count++
    for (const n of normal) { data.writeFloatLE(n, offset); offset += 4 }
    for (const point of v) {
      const q = [point[0], point[1], point[2] - bbox[0][2]]
      for (const n of q) { data.writeFloatLE(n, offset); offset += 4 }
    }
    for (let i = 0; i < 3; i++) {
      const vertexKey = (q: number[]) => q.map(n => Math.fround(n)).join(",")
      const edge = [vertexKey(v[i]), vertexKey(v[(i + 1) % 3])].sort().join("|")
      edges.set(edge, (edges.get(edge) ?? 0) + 1)
    }
  }
  const badEdges = [...edges.values()].filter(n => n !== 2).length
  reports.push({ id: p.id, printable: p.printable, connectedSolids, volumeMm3: volume, bounds: bbox, triangles: polygons.length, nonManifoldEdges: badEdges })
  if (p.printable && badEdges) throw new Error(`${p.id}: ${badEdges} open/non-manifold edges`)
  if (process.argv.includes("--check-only")) continue
  if (p.printable) writeFileSync(`${out}/${p.id}.stl`, data)
  const glbOptions = { format: "glb", axisTransform: "none", meshName: p.id } as const
  const glb = p.id === "camera-reference" || p.multicolor
    ? await convertJscadModelToGltf({ geometries: coloredGeometries(p.plan, p.color) }, glbOptions)
    : await convertJscadPlanToGltf(tint(p.color, p.plan) as any, glbOptions)
  writeFileSync(`${out}/${p.id}.glb`, Buffer.from(glb.data as ArrayBuffer))
}
// Check the actual solids, not just the declared fastener dimensions: a clear
// rear access path, a supporting head seat and a closed front-face floor.
const rearHousing = meshes.find(p => p.id === "rear-housing")!.geom
const frontBezel = meshes.find(p => p.id === "front-bezel")!.geom
const fastener = mechanical.caseFastener
const filledBy = (part: any, probe: any) => Math.abs(part.intersect(probe).volume() - probe.volume()) < 1e-5
// Confirm the entire circular back has a continuous flat roof, except the
// intended screw, button and indicator apertures (not just its bounding box).
const roofProbe = evaluate({ type: "subtract", shapes: [
  cyl(35.9, mechanical.rearCeilingZ + 0.1, mechanical.rearZ - 0.1),
  ...mechanical.caseMounts.map(([x,y]) => cyl(fastener.counterboreDiameter / 2 + 0.01, mechanical.rearCeilingZ, mechanical.rearZ, x, y)),
  ...mechanical.buttonCenters.map(([x,y]) => cyl(3.16, mechanical.rearCeilingZ, mechanical.rearZ, x, y)),
  ...[[13,19],[-24,-10]].map(([x,y]) => cyl(1.16, mechanical.rearCeilingZ, mechanical.rearZ, x, y)),
] })
const rearRoofChecks = {
  outerPlaneZ: mechanical.rearZ, innerPlaneZ: mechanical.rearCeilingZ,
  thicknessMm: mechanical.rearZ - mechanical.rearCeilingZ,
  continuousRoof: filledBy(rearHousing, roofProbe),
  noRaisedPockets: Math.abs(rearHousing.boundingBox().max[2] - mechanical.rearZ) < 1e-5,
}
if (!rearRoofChecks.continuousRoof || !rearRoofChecks.noRaisedPockets)
  throw new Error(`Flat rear roof check failed: ${JSON.stringify(rearRoofChecks)}`)
const headFastenerChecks = mechanical.caseMounts.map(([x, y]) => {
  const shaftAccess = evaluate(cyl(fastener.clearanceDiameter * 0.49, -2.9, mechanical.rearZ + 0.1, x, y))
  const headAccess = evaluate(cyl(fastener.counterboreDiameter * 0.49, fastener.seatZ + 0.1, mechanical.rearZ + 0.1, x, y))
  const floor = evaluate(cyl(fastener.pilotDiameter * 0.49, mechanical.frontZ + 0.1, fastener.pilotBottomZ - 0.1, x, y))
  const pilot = evaluate(cyl(fastener.pilotDiameter * 0.49, fastener.pilotBottomZ + 0.1, fastener.bossTopZ + 0.1, x, y))
  const seat = evaluate({ type: "subtract", shapes: [
    cyl(1.95, fastener.seatZ - 0.2, fastener.seatZ - 0.1, x, y),
    cyl(1.2, fastener.seatZ - 0.3, fastener.seatZ, x, y),
  ] })
  return {
    center: [x, y], entry: "rear (+Z)",
    rearAccessClear: Math.abs(rearHousing.intersect(shaftAccess).volume()) < 1e-5,
    headCounterboreClear: Math.abs(rearHousing.intersect(headAccess).volume()) < 1e-5,
    headSeatSupported: filledBy(rearHousing, seat),
    frontFaceClosed: filledBy(frontBezel, floor),
    blindPilotClear: Math.abs(frontBezel.intersect(pilot).volume()) < 1e-5,
    frontFloorMm: fastener.pilotBottomZ - mechanical.frontZ,
    engagementMm: fastener.bossTopZ - (fastener.seatZ - fastener.nominalLength),
    tipClearanceMm: fastener.seatZ - fastener.nominalLength - fastener.pilotBottomZ,
    headRecessMm: mechanical.rearZ - fastener.seatZ - fastener.headHeight,
  }
})
if (headFastenerChecks.some(p => !p.rearAccessClear || !p.headCounterboreClear || !p.headSeatSupported || !p.frontFaceClosed || !p.blindPilotClear))
  throw new Error(`Rear-entry head fastener checks failed: ${JSON.stringify(headFastenerChecks)}`)
const boardFastenerChecks = mechanical.boardMounts.map(([x,y]) => {
  const f = mechanical.boardFastener
  const support = evaluate({type:"subtract",shapes:[cyl(f.bossRadius - 0.1,0.81,1,x,y),cyl(1.7,0.8,1.1,x,y)]})
  const pilot = evaluate(cyl(f.pilotDiameter * 0.49,0.81,f.pilotEndZ-0.01,x,y))
  const headAccess = evaluate(cyl(f.screwHeadDiameter/2+0.1,-2.55,-0.81,x,y))
  const checks = {
    center:[x,y], clearanceDiameterMm:f.clearanceDiameter, screw:"M3 x 6 button head",
    supportIntact:filledBy(rearHousing,support),
    pilotClear:Math.abs(rearHousing.intersect(pilot).volume())<1e-5,
    bezelHeadClear:Math.abs(frontBezel.intersect(headAccess).volume())<1e-5,
    engagementMm:f.screwLength-mechanical.pcbThickness,
    tipClearanceMm:f.pilotEndZ-(-0.8+f.screwLength),
    note:"Tap printed pilots M3; install internally before bezel. Printed threads and torque need sample qualification.",
  }
  if(!checks.supportIntact||!checks.pilotClear||!checks.bezelHeadClear)throw new Error(`PCB mount failed: ${JSON.stringify(checks)}`)
  return checks
})
// Solid-vs-solid checks catch misplaced inserts, bosses and camera allowance.
const intersections: any[] = []
const boundsOverlap = (a: any, b: any) => {
  const aa = a.boundingBox(), bb = b.boundingBox()
  return aa.min.every((n: number, i: number) => n < bb.max[i] && aa.max[i] > bb.min[i])
}
for (let i = 0; i < meshes.length; i++) for (let j = i + 1; j < meshes.length; j++) {
  const a = meshes[i], b = meshes[j]
  if (!boundsOverlap(a.geom, b.geom)) continue
  const intersection = a.geom.intersect(b.geom)
  const volume = Math.abs(intersection.volume())
  if (volume > 0.02) intersections.push({ a: a.id, b: b.id, volumeMm3: volume, bounds: intersection.boundingBox() })
}
// Model the actual PCB mounting drills when checking the screw shanks.
const pcb = evaluate(cyl(30, -0.8, 0.8)).subtract(M.union(
  mechanical.boardMounts.map(([x,y]) => evaluate(cyl(mechanical.boardFastener.clearanceDiameter / 2, -0.9, 0.9, x, y))),
))
const boardIntersections: any[] = []
for (const p of meshes) {
  if (!boundsOverlap(p.geom, pcb)) continue
  const v = Math.abs(p.geom.intersect(pcb).volume())
  if (v > 0.02) boardIntersections.push({ part: p.id, component: "PCB substrate", volumeMm3: v })
}
const bodyBounds = JSON.parse(readFileSync(`${out}/pcb-component-bounds.json`, "utf8"))
// A bounding box fills J2's real FPC slot. Permit only the short inserted tongue
// inside that envelope; keep every other ribbon/component collision actionable.
const matingZone = evaluate(box([12.61, 2.5, 0.36], [0, -8.85, cameraMechanical.connectorFlexZ]))
// The header CAD bounding box fills its open socket. Exclude only the
// inserted XHP-8 housing region (z=3.1..7.9) plus 0.01 mm mesh tolerance;
// all other overlaps remain errors.
const harnessMatingZone = evaluate(box([22.32, 5.72, 4.82], [0, -20.75, 5.5]))
const antennaMatingZone = evaluate(box([3.2, 4.1, 2.6], [6, 20.3, 2.8]))
const matingOverlaps: any[] = []
for (const body of bodyBounds) {
  const size = body.min.map((n: number, i: number) => body.max[i] - n)
  if (size.some((n: number) => n <= 0)) continue
  const center = body.min.map((n: number, i: number) => (body.max[i] + n) / 2)
  const envelope = evaluate(box(size as [number,number,number], center as [number,number,number]))
  for (const p of meshes) {
    if (!boundsOverlap(p.geom, envelope)) continue
    let overlap = p.geom.intersect(envelope)
    if (body.name === "J2" && p.id === "camera-flex") {
      matingOverlaps.push({ part: p.id, component: body.name, volumeMm3: Math.abs(overlap.volume()), note: "Inserted tongue inside connector bounding envelope; physical slot and pin-1 need sample confirmation" })
      overlap = overlap.subtract(matingZone)
    }
    if (body.name === "J3" && p.id === "head-harness-plug") {
      matingOverlaps.push({ part: p.id, component: body.name, volumeMm3: Math.abs(overlap.volume()), note: "XHP-8 inserted housing in B8B-XH socket envelope; dimensioned reference, sample fit required" })
      overlap = overlap.subtract(harnessMatingZone)
    }
    if (body.name === "U1" && p.id === "wifi-antenna-coax") {
      matingOverlaps.push({part:p.id,component:body.name,volumeMm3:Math.abs(overlap.volume()),note:"U.FL plug and feed within the upper-right socket recess only; sample engagement required"})
      overlap = overlap.subtract(antennaMatingZone)
    }
    const v = Math.abs(overlap.volume())
    if (v > 0.02) boardIntersections.push({ part: p.id, component: body.name, volumeMm3: v })
  }
}
// Clearance volumes are mechanical/RF reservations, not host-PCB copper
// keepouts: the WROOM-1U has its own socket and ANT1 lives above the PCB.
const antennaChecks: any[] = []
for (const [kind, plan] of [["plastic", antennaPlasticKeepout], ["metal", antennaMetalKeepout], ["body", antennaBodyEnvelope]] as const) {
  const region = evaluate(plan)
  for (const p of meshes.filter(p => !p.id.startsWith("wifi-antenna"))) {
    if (kind === "plastic" && !p.printable) continue
    if (kind === "metal" && p.printable) continue
    const volume = Math.abs(region.intersect(p.geom).volume())
    if (volume > 0.001) antennaChecks.push({kind, target:p.id, overlapMm3:volume})
  }
  for (const b of [{name:"PCB substrate",min:[-30,-30,-0.8],max:[30,30,0.8]},...bodyBounds]) {
    if (kind === "plastic") continue
    const solid = b.name === "PCB substrate" ? pcb : evaluate(box(b.min.map((n:number,i:number)=>b.max[i]-n),b.min.map((n:number,i:number)=>(b.max[i]+n)/2)))
    const volume = Math.abs(region.intersect(solid).volume())
    if (volume > 0.001) antennaChecks.push({kind,target:b.name,overlapMm3:volume})
  }
}
// The supplied coax exits the ground-wall end outside the radiator reservation.
const coaxMetalOverlap = Math.abs(evaluate(antennaMetalKeepout).intersect(meshes.find(p=>p.id==="wifi-antenna-coax")!.geom).volume())
if(coaxMetalOverlap > 0.001) antennaChecks.push({kind:"metal",target:"coax",overlapMm3:coaxMetalOverlap})
if (Math.abs(antennaCableLength-antennaMechanical.nominalExposedCablePath)>0.01) throw new Error("Antenna cable length mismatch")
const antennaFit = {dimensions:antennaMechanical,cableModeledLengthMm:antennaCableLength,clearanceViolations:antennaChecks,
  plasticRoofGapMm:antennaMechanical.pocketCeilingZ-antennaMechanical.mainElementTopZ,
  pcbCopperChangeRequired:false,rfPerformanceValidated:false,
  note:"Exact supplier antenna and mounting clearance modeled. Cable OD/bend and connector seating are packaging assumptions; Wi-Fi range, final gain and tuning require a physical assembly."}
writeFileSync(`${out}/antenna-fit-checks.json`,JSON.stringify(antennaFit,null,2))
if(antennaChecks.length) boardIntersections.push(...antennaChecks.map(c=>({part:"antenna-reservation",component:c.target,volumeMm3:c.overlapMm3})))
const bezelStudy = {previousFrontZ:-18,frontZ:mechanical.frontZ,reductionMm:mechanical.frontZ+18,
  cupDepthMm:-3.2-mechanical.frontZ,previousCupDepthMm:14.8,
  printedPartHeightMm:frontBezel.boundingBox().max[2]-frontBezel.boundingBox().min[2],previousPrintedPartHeightMm:16.9,
  bodyShellDepthMm:mechanical.rearZ-mechanical.frontZ,maximumHeadDepthMm:mechanical.harnessPocket.roofZ-mechanical.frontZ,
  lensRecessNominalMm:cameraMechanical.lensFrontZ-mechanical.frontZ,
  lensRecessWorstBodyToleranceMm:cameraMechanical.lensFrontZ-cameraMechanical.bodyTolerance-mechanical.frontZ,
  cameraRearZ:cameraMechanical.moduleRearZ,cameraFoldRadiusMm:cameraFold.bendRadius,
  note:"Camera, supplied heatsink and flex stay fixed. More than 0.8 mm reduction needs a revised camera/flex/thermal stack or an exposed/protruding lens."}
if(bezelStudy.lensRecessWorstBodyToleranceMm < 0.29) throw new Error("Insufficient lens recess")
const manifest = allParts.map(({ plan, ...p }) => ({ ...p, glb: `${p.id}.glb`, ...(p.printable ? { stl: `${p.id}.stl` } : {}) }))
writeFileSync(`${out}/parts.json`, JSON.stringify(manifest, null, 2))
if (!process.argv.includes("--check-only")) {
  const cachedParts = [...baseAssemblyParts, ...cameraParts, ...antennaParts]
  const imports = cachedParts.map((p, i) => `import model${i} from "./mechanical/${p.id}.glb"`)
  const entries = cachedParts.map((p, i) => `  "${p.id}": model${i},`)
  writeFileSync("base-cad-models.ts", [
    '/// <reference path="./node_modules/@tscircuit/cli/types/static-assets/index.d.ts" />',
    '// Cached base/camera/antenna GLBs; regenerate with enclosure:export.',
    ...imports, 'export const baseCadModels: Record<string, string> = {', ...entries, '}\n',
  ].join("\n"))
}
// Check conservative complete heatsink envelope and worst published FPC thickness,
// not only decorative fins or the nominal thin ribbon. Intended camera contact and
// connector mating zones are excluded explicitly; no whole component is hidden.
const cameraClearanceChecks: any[] = []
for (const [id, plan] of [["flex-max-tolerance", cameraFlexEnvelope], ["heatsink-full-envelope", cameraHeatSinkEnvelope]] as const) {
  const solid = evaluate(plan)
  for (const part of meshes.filter(p => !["camera-flex", "camera-heatsink", "camera-thermal-pad", "camera-reference"].includes(p.id))) {
    const overlap = Math.abs(solid.intersect(part.geom).volume())
    const gap = solid.minGap(part.geom, 3)
    if (gap < 3) cameraClearanceChecks.push({ candidate: id, target: part.id, gapMm: gap, overlapMm3: overlap })
    if (overlap > 0.001) boardIntersections.push({ part: id, component: part.id, volumeMm3: overlap })
  }
  for (const body of [{name:"PCB substrate", min:[-30,-30,-0.8], max:[30,30,0.8]}, ...bodyBounds]) {
    const envelope = body.name === "PCB substrate" ? pcb : evaluate(box(body.min.map((n:number,i:number) => body.max[i]-n), body.min.map((n:number,i:number) => (body.max[i]+n)/2)))
    const checked = body.name === "J2" && id === "flex-max-tolerance" ? solid.subtract(matingZone) : solid
    const overlap = Math.abs(checked.intersect(envelope).volume())
    const gap = checked.minGap(envelope, 3)
    if (gap < 3) cameraClearanceChecks.push({ candidate: id, target: body.name, gapMm: gap, overlapMm3: overlap, ...(body.name === "J2" ? { note:"Expected inserted contact tongue excluded" } : {}) })
    if (overlap > 0.001) boardIntersections.push({ part: id, component: body.name, volumeMm3: overlap })
  }
}
if (Math.abs(cameraFold.centerlineLength - cameraFold.freeFlexLength) > 1e-8 || cameraFold.bendRadius < cameraMechanical.minimumBendRadiusAssumed)
  throw new Error("Ribbon length or assumed bend-radius violation")
// M4 placed the lens 6 mm nearer the PCB. Substituting the supplied heatsink
// at that pose proves the old enclosure cannot contain this complete stack.
const oldPoseHeatSink = meshes.find(p => p.id === "camera-heatsink")!.geom.translate([0,0,6])
const previousFit = {
  shellDepthMm: 22,
  suppliedHeatSinkPcbIntersectionMm3: Math.abs(oldPoseHeatSink.intersect(pcb).volume()),
  suppliedHeatSinkRetainerIntersectionMm3: Math.abs(oldPoseHeatSink.intersect(evaluate(box([21,3.2,1.2], [0,3.5,-2.65]))).volume()),
  result: "Does not fit: supplied 7 mm heatsink crosses PCB and original retaining strap",
}
if (previousFit.suppliedHeatSinkPcbIntersectionMm3 < 1) throw new Error("Expected baseline interference missing")
const cameraFit = { dimensions: cameraMechanical, fold: cameraFold, connectorReview: cameraConnectorReview, readyForManufacture: false, shellDepthMm: mechanical.rearZ-mechanical.frontZ, previousFit, matingOverlaps, clearances:cameraClearanceChecks, conclusion:"Enclosure space passes, but current J2 mating geometry is not a documented match. Bend radius, adhesive, shipped revision, insertion/pin-1 and thermal performance require physical qualification." }
writeFileSync(`${out}/camera-fit-checks.json`, JSON.stringify(cameraFit, null, 2))
writeFileSync(`${out}/geometry-checks.json`, JSON.stringify({ dimensions: mechanical, baseDimensions: baseMechanical, cameraFit, antennaFit, bezelStudy, parts: reports, rearRoofChecks, headFastenerChecks, boardFastenerChecks, intersections, boardIntersections }, null, 2))
console.log(JSON.stringify({ parts: reports.length, printable: reports.filter(p => p.printable).length, nonManifoldEdges: reports.reduce((n, p) => n + (p.printable ? p.nonManifoldEdges : 0), 0), rearRoofChecks, headFastenerChecks, boardFastenerChecks, antennaFit, bezelStudy, intersections, checkedComponentEnvelopes: bodyBounds.length, boardIntersections }, null, 2))
if (intersections.length || boardIntersections.length) process.exitCode = 1