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-geometry.ts

/** Lamp Clip mechanical prototype M9. All coordinates are PCB coordinates in mm.
 * PCB mid-plane z=0; rear/components +Z; camera/page -Z.
 * Pure serializable JSCAD plans work with the pinned core and the CAD viewer.
 * The camera/heatsink and switch solids are dimensional references, not vendor CAD.
 */
import { rearHousingDimensions as rear } from "./rear-housing-dimensions"
import { pcbMounting } from "./pcb-mounting"
import { antennaParts, antennaTrayLands, antennaTrayPilots } from "./antenna-mechanical"
import { cameraParts } from "./camera-mechanical"
import { move, turn, union, subtract, tint, box, cyl, type Plan, type V3 } from "./mechanical-primitives"
export { move, turn, union, subtract, tint, box, cyl, type Plan, type V3 } from "./mechanical-primitives"
const tube = (ro: number, ri: number, z0: number, z1: number, x = 0, y = 0) =>
  subtract(cyl(ro, z0, z1, x, y), cyl(ri, z0 - 0.1, z1 + 0.1, x, y))
const pill = (w: number, h: number, depth: number): Plan => union(
  box([w - h, h, depth]),
  cyl(h / 2, -depth / 2, depth / 2, -(w - h) / 2),
  cyl(h / 2, -depth / 2, depth / 2, (w - h) / 2),
)
const atLed = (i: number, z: number, shape: Plan) => {
  const a = i * Math.PI / 3
  return move([25 * Math.cos(a), 25 * Math.sin(a), z], turn([0, 0, a + Math.PI / 2], shape))
}
const six = (f: (i: number) => Plan) => Array.from({ length: 6 }, (_, i) => f(i))
export const mechanical = {
  outerDiameter: 72, cavityDiameter: 62.4, frontZ: -17.2, rearZ: rear.outerZ, rearCeilingZ: rear.ceilingZ,
  harnessPocket: { center: [0, -20.3], outerSize: [26, 10.8], innerSize: [23.4, 8.2], roofZ: rear.outerZ, ceilingZ: rear.ceilingZ },
  pcbRadius: 30, pcbThickness: 1.6, lensCenter: [0, 3.5],
  boardMounts: pcbMounting.centers,
  boardFastener: pcbMounting,
  caseMounts: [90, 210, 330].map(a => [33 * Math.cos(a * Math.PI / 180), 33 * Math.sin(a * Math.PI / 180)]),
  caseFastener: {
    nominalDiameter: 2, nominalLength: 16, headDiameter: 4, headHeight: 1.6,
    clearanceDiameter: 2.3, counterboreDiameter: 4.4, seatZ: 8.2,
    pilotDiameter: 1.7, pilotBottomZ: -9.2, bossTopZ: -3, bossRadius: 2.5,
  },
  buttonCenters: [[24, -8]],
  microphonePcbPort: [20.7314605, 0], microphoneFacePort: [19, 0],
  neckBore: 6.5, neckCenterZ: 3, neckEndY: -43,
} as const
const caseFastener = mechanical.caseFastener

// The sloping, isolated acoustic tube aligns to the existing PCB's 0.5 mm drill.
const acousticAxis = (radius: number, extra = 0): Plan => {
  const a: V3 = [19, 0, mechanical.frontZ + 0.8], b: V3 = [20.7314605, 0, -1.1]
  const dx = b[0] - a[0], dz = b[2] - a[2]
  const length = Math.hypot(dx, dz)
  return move([(a[0] + b[0]) / 2, 0, (a[2] + b[2]) / 2],
    turn([0, Math.atan2(dx, dz), 0], cyl(radius, -length / 2 - extra, length / 2 + extra)))
}
const cameraSaddle = subtract(
  box([13.2, 13.2, 5], [0, 3.5, -13.8]),
  box([9.2, 9.2, 6], [0, 3.5, -13.8]),
  // Open the lower saddle wall for the camera flex.
  box([7, 4, 1.2], [0, -2.5, -11.55]),
)
const cameraPosts = [-8.5, 8.5].map(x => subtract(
  cyl(2, -16.3, -11.3, x, 3.5), cyl(0.85, -15.3, -11.1, x, 3.5),
))

const rearCup = subtract(
  union(
    // One flat 2 mm roof at the former tallest cable-pocket extent.
    subtract(cyl(36, -3, rear.outerZ), cyl(31.2, -3.1, rear.ceilingZ)),
    // Extend supports to the new ceiling; PCB and screw seating planes stay fixed.
    ...mechanical.boardMounts.map(([x, y]) => cyl(pcbMounting.bossRadius, 0.8, rear.ceilingZ + 0.1, x, y)),
    ...mechanical.caseMounts.map(([x, y]) => cyl(caseFastener.bossRadius, -3, rear.ceilingZ + 0.1, x, y)),
    // Unthreaded socket for a measured 6 mm gooseneck; adhesive retention.
    subtract(move([0, -36, 3], turn([Math.PI / 2, 0, 0], cyl(5.3, -7, 7))), cyl(31.2, -3.1, 10.1)),
  ),
  // Trim the socket's underside clear of the front cover's locating lip.
  cyl(31.2, -3.1, 0),
  ...mechanical.boardMounts.map(([x, y]) => cyl(pcbMounting.pilotDiameter / 2, 0.7, pcbMounting.pilotEndZ, x, y)),
  // Screws enter from +Z (rear); the roof counterbores recess their heads.
  ...mechanical.caseMounts.map(([x, y]) => cyl(caseFastener.clearanceDiameter / 2, -3.1, rear.outerZ + 0.2, x, y)),
  ...mechanical.caseMounts.map(([x, y]) => cyl(caseFastener.counterboreDiameter / 2, caseFastener.seatZ, rear.outerZ + 0.2, x, y)),
  ...mechanical.buttonCenters.map(([x, y]) => cyl(3.15, rear.ceilingZ - 0.2, rear.outerZ + 0.2, x, y)),
  // USB opening: left wall, 12 mm along Y and 7 mm high, includes cable overmold access.
  box([15, 12, 7], [-33, 0, 2.6]),
  // Stepped bore stops the metal neck at Y=-32, outside the 30 mm PCB radius.
  move([0, -38.5, 3], turn([Math.PI / 2, 0, 0], cyl(3.25, -6.5, 6.5))),
  move([0, -29.5, 3], turn([Math.PI / 2, 0, 0], cyl(2, -4, 4))),
  // Status and charging indicators: separate press-fit light pipes.
  cyl(1.15, rear.ceilingZ - 0.2, rear.outerZ + 0.2, 13, 19), cyl(1.15, rear.ceilingZ - 0.2, rear.outerZ + 0.2, -24, -10),
)

export const rearShell = subtract(union(rearCup, antennaTrayLands), antennaTrayPilots)

export const frontShell = subtract(
  union(
    subtract(cyl(36, mechanical.frontZ, -3.2), cyl(31.2, mechanical.frontZ + 1.8, -3.1)),
    // The step joins the smaller locating lip to the larger cup wall.
    tube(32, 29.45, -4.2, -3.25),
    tube(30.95, 29.45, -3.4, -1.4),
    // Bosses touch the rear cup at Z=-3 while preserving the 0.2 mm outer seam.
    // Blind pilots leave the entire page-facing surface closed at each screw.
    ...mechanical.caseMounts.map(([x, y]) => cyl(caseFastener.bossRadius, -16.3, caseFastener.bossTopZ, x, y)),
    cameraSaddle, ...cameraPosts, acousticAxis(1.4),
  ),
  cyl(4.9, mechanical.frontZ - 0.2, -15.1, 0, 3.5),
  // Rear relief clears square camera corners without moving the camera/flex.
  box([9.2, 9.2, 1.5], [0, 3.5, -15.55]),
  ...six(i => atLed(i, mechanical.frontZ + 0.9, pill(8.2, 5.2, 3))),
  acousticAxis(0.6, 2),
  // Internal pockets clear the M3 PCB screw heads without opening the front face.
  ...mechanical.boardMounts.map(([x,y]) => cyl(3.15, -2.7, -0.7, x, y)),
  // A flat end leaves a controlled 0.3 mm space for the PCB microphone gasket.
  box([5, 5, 2], [20.7314605, 0, -0.1]),
  ...mechanical.caseMounts.map(([x, y]) => cyl(caseFastener.pilotDiameter / 2, caseFastener.pilotBottomZ, caseFastener.bossTopZ + 0.1, x, y)),
)

// Nominal M2 x 16 pan-head references, with a cross-drive recess. The buried
// threaded section shows only its 1.6 mm core; mating threads are not modeled.
// This keeps the fit check about solid interference, not intentional thread bite.
const caseScrew = subtract(
  union(
    cyl(caseFastener.headDiameter / 2, caseFastener.seatZ, caseFastener.seatZ + caseFastener.headHeight),
    cyl(caseFastener.nominalDiameter / 2, caseFastener.bossTopZ, caseFastener.seatZ + 0.05),
    cyl(0.8, caseFastener.seatZ - caseFastener.nominalLength, caseFastener.bossTopZ + 0.1),
  ),
  box([2.6, 0.5, 0.8], [0, 0, 9.7]),
  box([0.5, 2.6, 0.8], [0, 0, 9.7]),
)

// Internal M3 x 6 button-head PCB screws, inserted before fitting the front bezel.
// Pilot holes are tapped M3 after printing. Model the core in the threaded region;
// actual helical thread engagement is intentional, not solid interference.
const pcbScrew = subtract(union(
  cyl(pcbMounting.screwHeadDiameter / 2, -0.8 - pcbMounting.screwHeadHeight, -0.8),
  cyl(pcbMounting.screwDiameter / 2, -0.81, 0.8),
  cyl(1.2, 0.79, -0.8 + pcbMounting.screwLength),
), cyl(1, -2.55, -1.55))

export const cameraRetainer = subtract(
  union(box([11, 10.2, 0.8], [0, 3.5, -10.9]), box([21, 3.2, 0.8], [0, 3.5, -10.9])),
  // Window retains the camera perimeter without strapping over the cooling fins.
  box([7.8, 6.8, 1.2], [0, 3.5, -10.9]),
  // Extra underside relief above the flex exit; no sharp clamp on the ribbon.
  box([7, 1.4, 0.6], [0, -1.1, -11.2]),
  ...[-8.5, 8.5].map(x => cyl(1.1, -11.5, -10.3, x, 3.5)),
)

// Switch reference authored at its PCB mounting plane. TL3305 body 4.5 square,
// actuator top 3.8 mm. Four leads show the same row-paired contact arrangement.
export const tactileSwitchModel = union(
  tint([0.14, 0.16, 0.17], box([4.5, 4.5, 2], [0, 0, 1.1])),
  tint([0.7, 0.72, 0.73], box([4.4, 4.4, 0.3], [0, 0, 2.25])),
  tint([0.1, 0.12, 0.13], cyl(1.15, 2.35, 3.8)),
  ...[-3.25, 3.25].flatMap(x => [-1.4, 1.4].map(y =>
    tint([0.68, 0.7, 0.73], box([2, 0.8, 0.2], [x, y, 0.1])))),
)

const buttonBody = union(
  // Preserve switch contact at Z=4.8; lengthen only the plunger stem.
  cyl(1.05, 4.8, rear.ceilingZ - 0.6), cyl(3.8, rear.ceilingZ - 0.8, rear.ceilingZ - 0.2),
  cyl(2.9, rear.ceilingZ - 0.3, rear.outerZ + 0.5), cyl(3.8, rear.outerZ + 0.5, rear.outerZ + 1.5),
)
const sunIcon = union(tube(0.95, 0.55, rear.outerZ + 1.2, rear.outerZ + 1.7), ...Array.from({ length: 8 }, (_, i) =>
  turn([0, 0, i * Math.PI / 4], box([0.45, 0.35, 0.5], [1.65, 0, rear.outerZ + 1.45]))))
const lightCap = move([24, -8, 0], subtract(buttonBody, sunIcon))

export interface MechanicalPart {
  id: string; label: string; plan: Plan; color: V3; printable: boolean;
  group: "rear" | "front" | "optics" | "controls" | "reference" | "base" | "neck" | "harness" | "hardware";
  multicolor?: boolean;
  explode: V3; note: string;
}
export const enclosureParts: MechanicalPart[] = [
  { id: "rear-housing", label: "Rear housing · flat back", plan: rearShell, color: [0.12, 0.17, 0.18], printable: true, group: "rear", explode: [0, 0, 26], note: "Uniform back at Z=15.5 mm with a 2 mm roof; internal antenna and JST clearance. Three M2 x 16 screws enter through recessed rear wells; extended PCB/tray posts, Light plunger and indicator pipes." },
  { id: "front-bezel", label: "Front bezel · no external screw holes", plan: frontShell, color: [0.2, 0.27, 0.27], printable: true, group: "front", explode: [0, 0, -25], note: "Blind internal case-screw bosses; 0.8 mm shallower face; unchanged camera saddle, six LED windows and isolated microphone duct" },
  ...mechanical.caseMounts.map(([x, y], i): MechanicalPart => ({
    id: `head-case-screw-${i + 1}`, label: `Rear case screw ${i + 1} · M2 x 16`,
    plan: move([x, y, 0], caseScrew), color: [0.65, 0.68, 0.7], printable: false,
    group: "hardware", explode: [0, 0, 46],
    note: "Purchased screw reference; 4 mm head, 1.6 mm height; threaded core only inside blind pilot. Verify actual fastener dimensions and printed fit.",
  })),
  ...mechanical.boardMounts.map(([x,y],i): MechanicalPart => ({
    id: `pcb-mount-screw-${i+1}`, label: `PCB screw ${i+1} · M3 x 6`,
    plan: move([x,y,0], pcbScrew), color: [0.65,0.68,0.7], printable: false,
    group: "hardware", explode: [0,0,-12],
    note: "Internal M3 x 6 button-head screw, maximum head 5.7 x 1.65 mm. Tap the 2.5 mm blind printed pilot M3; install before front bezel. Sample-fit and torque qualification required.",
  })),
  { id: "camera-retainer", label: "Camera retaining frame · open heatsink window", plan: cameraRetainer, color: [0.27, 0.35, 0.34], printable: true, group: "optics", explode: [0, 0, -8], note: "M2 x 4 screws; window clears the 7 x 6 mm heatsink base, frame retains the 8.5 mm module edge" },
  { id: "light-cap", label: "Light button cap", plan: lightCap, color: [0.91, 0.79, 0.5], printable: true, group: "controls", explode: [0, 0, 38], note: "Captured cap with sun icon; verify switch actuation after printing" },
  ...sixParts(),
  ...([[13, 19], [-24, -10]] as const).map(([x, y], i): MechanicalPart => ({
    id: `indicator-pipe-${i + 1}`, label: i ? "Charging light pipe" : "Status light pipe",
    plan: union(cyl(1, 2.2, rear.outerZ + 0.1, x, y), cyl(1.65, rear.ceilingZ - 0.6, rear.ceilingZ - 0.15, x, y)),
    color: i ? [1, 0.58, 0.2] : [0.35, 0.83, 0.59], printable: true,
    group: "controls", explode: [0, 0, 28], note: "Clear resin/light-pipe stock; flange seats under rear cover",
  })),
  ...cameraParts, ...antennaParts,
]
function sixParts(): MechanicalPart[] {
  return Array.from({ length: 6 }, (_, i) => ({
    id: `diffuser-${i + 1}`, label: `Light diffuser ${i + 1}`, color: [0.97, 0.91, 0.72] as V3,
    plan: atLed(i, mechanical.frontZ + 1, union(pill(7.8, 4.8, 2), move([0, 0, 1.1], pill(9.4, 6.4, 0.6)))),
    printable: true, group: "optics" as const, explode: [0, 0, -32] as V3,
    note: "Translucent insert retained by an internal flange; optical performance needs a print test",
  }))
}