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

base-assembly-geometry.ts

import baseHarnessPaths from "./harness-base-paths.json"
import groundHarnessPaths from "./harness-ground-paths.json"
import headHarnessPaths from "./harness-head-paths.json"
import { box, cyl, move, turn, union, subtract, tint, type Plan, type V3, type MechanicalPart } from "./enclosure-geometry"

/** M3 product pose in head-PCB coordinates, millimetres. The head and PCB stay
 * at their validated positions. Purchased parts are explicit reference models;
 * neither the flexible neck nor battery/speaker/spring are printable hardware.
 */
export const baseMechanical = {
  baseSize: [58, 92, 26] as V3,
  baseCenter: [0, -158, -176] as V3,
  batteryEnvelope: [40, 50, 8] as V3,
  batteryCenter: [0, -163, -181] as V3,
  speakerEnvelope: [35, 25, 7.7] as V3,
  speakerCenter: [0, -178, -169] as V3,
  neckOuterDiameter: 6, neckClearBore: 4, bendRadius: 60,
  neckCenterlineLength: 38 + Math.PI * 60 / 2 + 112,
  neckBaseAxis: [0, -130] as [number, number],
  hingeCenter: [0, -181, -197] as V3,
  harnessPinOrder: ["VBAT", "GND", "NTC", "NTC_RETURN", "SPK_P", "SPK_N", "ASK_REMOTE", "PWR_REMOTE"],
  chatSwitchCenter: [-15, -128] as [number, number],
  powerSwitchCenter: [16, -125] as [number, number],
}

// A local Z cylinder between arbitrary endpoints, with the native JSCAD plan
// rotation order. Modest tessellation keeps tscircuit's interactive view quick.
export const segment = (a: V3, b: V3, radius: number, overlap = 0.015, segments = 24): Plan => {
  const [dx, dy, dz] = b.map((v, i) => v - a[i])
  const length = Math.hypot(dx, dy, dz)
  return move(a.map((n, i) => (n + b[i]) / 2) as V3,
    turn([0, 0, Math.atan2(dy, dx)], turn([0, Math.acos(dz / length), 0],
      { type: "cylinder", radius, height: length + overlap * 2, segments })))
}
const pathSolid = (points: V3[], radius: number, overlap = 0.08, segments = 24): Plan =>
  union(...points.slice(1).map((p, i) => segment(points[i], p, radius, overlap, segments)))
const roundBox = (w: number, d: number, h: number, r: number, center: V3): Plan => move(center, union(
  box([w - 2 * r, d, h]), box([w, d - 2 * r, h]),
  ...[-1, 1].flatMap(x => [-1, 1].map(y => cyl(r, -h / 2, h / 2, x * (w / 2 - r), y * (d / 2 - r)))),
))

const straightLength = 38, curveLength = Math.PI * 60 / 2
export const neckPoint = (s: number): V3 => {
  if (s <= straightLength) return [0, -32 - s, 3]
  if (s <= straightLength + curveLength) {
    const a = (s - straightLength) / 60
    return [0, -70 - 60 * Math.sin(a), 3 - 60 * (1 - Math.cos(a))]
  }
  return [0, -130, -57 - (s - straightLength - curveLength)]
}
const neckSamples = [0, straightLength, ...Array.from({ length: 24 }, (_, i) => straightLength + curveLength * (i + 1) / 24), baseMechanical.neckCenterlineLength]
const neckPath = neckSamples.map(neckPoint)
const collars = Array.from({ length: 99 }, (_, i) => {
  const s = 12 + i * 2.25
  return segment(neckPoint(s - 0.32), neckPoint(s + 0.32), 3, 0, 24)
})
// Corrugated hollow purchase-part envelope. The wall is not a spring simulation.
const gooseneck = subtract(union(pathSolid(neckPath, 2.75), ...collars), pathSolid([
  [0, -31.5, 3], ...neckPath.slice(1, -1), [0, -130, -169.5],
], 2, 0.13), box([10, 10, 10], [0, -27.05, 3]))

const fasteners: [number, number][] = [[-24, -119], [24, -119], [-24, -197], [24, -197]]
const hingeBore = segment([-32, -181, -197], [32, -181, -197], 2.1)
const outerBase = roundBox(58, 92, 23.8, 7, [0, -158, -177.1]) // -189 .. -165.2
const baseTray = subtract(union(
  subtract(outerBase, roundBox(53.2, 87.2, 24, 5, [0, -158, -174.6])), // 2.4 mm floor/wall
  ...fasteners.map(([x, y]) => cyl(2.8, -186.8, -165.2, x, y)),
  ...[-24, 24].flatMap(x => [
    box([7, 8, 10], [x, -181, -191.8]),
    segment([x - 3.5, -181, -197], [x + 3.5, -181, -197], 5),
  ]),
  // Supports beside, never pressing into, the pouch. Speaker rests above it.
  ...[-21.8, 21.8].flatMap(x => [-169, -187].map(y => box([3, 5, 13.75], [x, y, -179.725]))),
  ...[-18.6, 18.6].flatMap(x => [-169, -187].map(y => box([9, 5, 2], [x, y, -173.85]))),
), ...fasteners.map(([x, y]) => cyl(0.85, -181, -165.1, x, y)), hingeBore)

const baseLid = subtract(union(
  roundBox(58, 92, 2, 7, [0, -158, -164]), // -165 .. -163; 0.2 mm seam to tray
  subtract(roundBox(52.7, 86.7, 2.4, 4.7, [0, -158, -166.1]), roundBox(49.5, 83.5, 3, 3.5, [0, -158, -166.1])),
  // Metal neck ends at -169; this prototype socket needs measured retention.
  cyl(5.4, -172, -153, 0, -130),
),
  cyl(3.25, -170, -152.8, 0, -130), cyl(2, -172.2, -169, 0, -130),
  // Panel-mounted controls: no daughterboard. Ream printed holes to the
  // supplier drawing; the 2 mm panel is captured between bezel and nut.
  cyl(6.1, -169, -162.8, -15, -128),
  cyl(3.25, -169, -162.8, 16, -125),
  // Shallow speech and I/O identification marks on the lid.
  subtract(cyl(2.7, -163.35, -162.8, -15, -141), cyl(2.1, -163.5, -162.7, -15, -141)),
  box([1.2, 2, 0.55], [-17, -143, -163.075]),
  box([0.7, 3, 0.55], [16, -116.5, -163.075]),
  subtract(cyl(1.6, -163.35, -162.8, 16, -135), cyl(1, -163.5, -162.7, 16, -135)),
  ...fasteners.map(([x, y]) => cyl(1.15, -168, -162.8, x, y)),
  ...fasteners.map(([x, y]) => cyl(3.05, -168, -165.05, x, y)),
  ...Array.from({ length: 7 }, (_, i) => roundBox(26, 1.3, 4, 0.6, [0, -185.5 + i * 2.5, -164])),
)

const lowerJaw = subtract(union(
  roundBox(52, 86, 3, 7, [0, -158, -205.5]),
  ...[-12, 12].flatMap(x => [
    box([8, 8, 8], [x, -181, -201]),
    segment([x - 4, -181, -197], [x + 4, -181, -197], 4.8),
  ]),
), hingeBore)
const upperPad = roundBox(40, 40, 1.2, 4, [0, -143, -189.6])
const lowerPad = roundBox(40, 40, 1.2, 4, [0, -143, -203.4])
const hingePin = segment([-28.2, -181, -197], [28.2, -181, -197], 1.8, 0)

// Reference torsion spring: its force, legs and final preload need selection.
const springPoints: V3[] = Array.from({ length: 61 }, (_, i) => {
  const t = i / 60, a = t * 6 * Math.PI
  return [-4 + 8 * t, -181 + 3.2 * Math.cos(a), -197 + 3.2 * Math.sin(a)]
})
const spring = pathSolid([[-5, -169, -190.7], ...springPoints, [5, -169, -202.5]], 0.45, 0.14, 12)
const battery = union(
  tint([0.67, 0.71, 0.74], roundBox(40, 50, 8, 1.4, [0, -163, -181])),
  tint([0.84, 0.61, 0.16], box([37, 3.4, 6], [0, -136.3, -181])),
  tint([0.12, 0.3, 0.22], box([25, 2.8, 1], [0, -135.7, -177.5])),
  tint([0.86, 0.9, 0.86], box([25, 29, 0.08], [0, -163, -176.96])),
  // Label markings: 1S pack reference, not a claimed production capacity.
  ...[-168, -164, -160].map(y => tint([0.12, 0.16, 0.17], box([17, 0.55, 0.08], [0, y, -176.88]))),
)
const speaker = union(
  tint([0.075, 0.085, 0.09], roundBox(35, 25, 7.7, 2, [0, -178, -169])),
  tint([0.24, 0.27, 0.28], cyl(8.6, -165.15, -165.1, 0, -178)),
  tint([0.09, 0.11, 0.12], cyl(6.5, -165.1, -165.08, 0, -178)),
)
const ntc = roundBox(2.4, 3, 0.9, 0.7, [-12, -155, -176.4])

// Dimensioned reference models from E-Switch PV5 and 100-series drawings.
// Cosmetic curvature/threads are simplified; actual hardware must be fit-tested.
const chatSwitch = move([-15, -128, 0], union(
  tint([0.1,0.12,0.13], cyl(6, -172.2, -163)),
  tint([0.12,0.14,0.15], cyl(8.75, -163, -157.9)),
  tint([0.07,0.08,0.09], cyl(6.05, -157.9, -156)),
  tint([0.55,0.58,0.6], subtract(cyl(8.5, -167, -165), cyl(6.05, -167.1, -164.9))),
  ...[-3.15,3.15].map(x => tint([0.75,0.65,0.27], box([0.5,2,3.6], [x,0,-174]))),
))
const powerSwitch = move([16, -125, 0], union(
  tint([0.22,0.24,0.26], box([6.86,12.7,8.89], [0,0,-171.445])),
  tint([0.65,0.68,0.7], cyl(3.175, -167, -158.11)),
  tint([0.65,0.68,0.7], subtract(cyl(4.7,-166.5,-165),cyl(3.2,-166.6,-164.9))),
  tint([0.65,0.68,0.7], subtract(cyl(4.7,-163,-161.5),cyl(3.2,-163.1,-161.4))),
  tint([0.7,0.73,0.75], segment([0,0,-158.2], [0,2.1,-148], 1.45, 0)),
  ...[-4.7,0,4.7].map(y => tint([0.75,0.65,0.27],box([2.03,0.76,3.96],[0,y,-177.87]))),
))
const wireColors: V3[] = [[0.85,0.1,0.08],[0.07,0.075,0.08],[0.96,0.69,0.13],[0.87,0.87,0.81],[0.15,0.46,0.93],[0.64,0.27,0.77],[0.12,0.72,0.4],[0.96,0.39,0.08]]
export const harnessPaths = Array.from({ length: 8 }, (_, i) => {
  const [x, radial] = [[-0.75,0],[0.75,0],[-1,-1.125],[-1,1.125],[0,-1.5],[0,1.5],[1,-1.125],[1,1.125]][i]
  const offsets = neckSamples.map(s => {
    const p = neckPoint(s), angle = Math.max(0, Math.min(Math.PI / 2, (s - 38) / 60))
    return [p[0] + x, p[1] + radial * Math.sin(angle), p[2] - radial * Math.cos(angle)] as V3
  })
  // Leads enter the XHP-8 cable housing from the rear; no soldered head ends.
  const head = headHarnessPaths[i] as V3[]
  return [...head, ...offsets.slice(1), ...(baseHarnessPaths[i] as V3[])]
})
// Insulated shared ground branches join the battery-negative termination.
const groundBranches = groundHarnessPaths as V3[][]

// XHP-8 mating housing envelope from JST eXH; detailed crimp beams are omitted.
// The lower body nests inside J3. Wire bores remain open through the housing.
const harnessPlug = subtract(union(
  box([20.7,4.3,4.5], [0,-20.0,5.35]),
  box([22.3,5.7,3], [0,-20.75,9.1]),
), ...Array.from({length:8},(_,i)=>cyl(i<2?0.62:0.52,3,10.7,(i-3.5)*2.5,-20.3)))

const part = (id: string, label: string, plan: Plan, group: MechanicalPart["group"], color: V3, note: string, printable = false, explode: V3 = [0,0,0], multicolor = false): MechanicalPart =>
  ({ id, label, plan, group, color, note, printable, explode, multicolor })
export const baseAssemblyParts: MechanicalPart[] = [
  part("head-harness-plug", "J3 mating plug · JST XHP-8", harnessPlug, "harness", [0.91,0.91,0.85], "Purchased polarized XHP-8 housing with SXH-001T-P0.6 contacts; dimensioned mating reference, verify sample fit", false, [0,0,20]),
  part("gooseneck", "Hollow flexible gooseneck · reference", gooseneck, "neck", [0.14,0.16,0.17], "244 mm centreline, 6 mm outside / 4 mm clear bore; purchase a load-rated flexible neck"),
  part("clip-base-tray", "Battery base / upper clip jaw", baseTray, "base", [0.12,0.17,0.18], "Protected pouch cavity, speaker supports and hinge ears; fit prototype", true, [0,0,-20]),
  part("clip-base-lid", "Base lid / neck mount / speaker grille", baseLid, "base", [0.2,0.27,0.27], "M2 case screws, Chat and power panel cutouts; no base PCB", true, [0,0,25]),
  part("clip-lower-jaw", "Lower spring clip jaw", lowerJaw, "base", [0.12,0.17,0.18], "Hinged jaw, shown open with 12.6 mm pad-to-pad gap", true, [0,0,-38]),
  part("clip-upper-pad", "Upper silicone grip pad", upperPad, "hardware", [0.08,0.1,0.09], "Cut or mold 1.2 mm elastomer, bonded to the upper jaw", false, [0,0,-22]),
  part("clip-lower-pad", "Lower silicone grip pad", lowerPad, "hardware", [0.08,0.1,0.09], "Cut or mold 1.2 mm elastomer, bonded to the lower jaw", false, [0,0,-35]),
  part("clip-hinge-pin", "Metal hinge pin · reference", hingePin, "hardware", [0.57,0.61,0.63], "3.6 mm diameter reference; retention hardware and production pin TBD", false, [40,0,0]),
  part("clip-torsion-spring", "Clip torsion spring · reference", spring, "hardware", [0.46,0.5,0.52], "Illustrative coil/legs; spring force, preload and cycle life unqualified"),
  part("base-battery-pack", "Protected 1S LiPo pack · 40 × 50 × 8 mm allowance", battery, "reference", [0.67,0.71,0.74], "Provisional pack envelope, including protection-tab allowance; exact qualified pack TBD", false, [0,18,-6], true),
  part("base-speaker", "8 Ω enclosed speaker · 35 × 25 × 7.7 mm reference", speaker, "reference", [0.08,0.1,0.11], "Existing BOM candidate envelope; verify actual supplier drawing and acoustic outlet", false, [0,-15,10], true),
  part("battery-temperature-sensor", "Battery temperature sensor · NTC", ntc, "reference", [0.83,0.51,0.12], "Insulated thermistor attached to the cell, J3 pins 3/4"),
  part("base-chat-switch", "SW1 · Chat pushbutton · panel mount", chatSwitch, "reference", [0.1,0.12,0.13], "E-Switch PV5S24011G, gold contacts, Off-(On), M12 thread; solder lugs and nut, no PCB", false, [-14,0,20], true),
  part("base-power-switch", "SW3 · Power toggle · panel mount", powerSwitch, "reference", [0.6,0.63,0.65], "E-Switch 100SP1T1B1M1REH, gold SPDT solder lugs; 2 common, 1 ground/OFF, 3 unused/ON", false, [14,0,20], true),
  ...harnessPaths.map((points, i) => part(`harness-wire-${i + 1}`, `${`J3.${i + 1}`} · ${baseMechanical.harnessPinOrder[i]}`, union(pathSolid(points, i < 2 ? 0.55 : 0.45, 0.015, 16), ...(i === 1 ? groundBranches.map(p => pathSolid(p,0.32,0.08,12)) : [])), "harness", wireColors[i], "Illustrative insulated lead routing; select flexible wire/current rating and strain relief. Speaker negative is not ground.")),
]