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

camera-mechanical.ts

/** Seeed 114993115 / MJY5OAF-F3M-V1, drawing 2023-06-20, page 3.
 * Dimensions from the module drawing and Seeed product part list. Fin detail,
 * adhesive thickness, FPC bend radius and connector insertion pose are assumptions.
 * This is a packaging model, not supplier CAD or a flex-fatigue simulation.
 */
import { box, cyl, move, turn, union, tint, type Plan, type V3 } from "./mechanical-primitives"
import type { MechanicalPart } from "./enclosure-geometry"

export const cameraMechanical = {
  sku: "114993115", revision: "MJY5OAF-F3M-V1 / 2023-06-20",
  source: "https://files.seeedstudio.com/wiki/OV5640_for_ESP32S3_sense/OV5640_Datasheet.pdf",
  productSource: "https://www.seeedstudio.com/OV5640-Camera-for-XIAO-ESP32S3-Sense-With-Heat-Sink-p-5739.html",
  overallFlatLength: 70.5, overallLengthTolerance: 0.2,
  bodyWidth: 8.5, bodyHeight: 5.4, bodyTolerance: 0.2,
  flexWidth: 6, flexThickness: 0.12, flexThicknessTolerance: 0.03,
  tailWidth: 12.5, tailLength: 4, tailThickness: 0.3,
  tailThicknessTolerance: 0.03, contactCount: 24, contactPitch: 0.5, contactLength: 2.5,
  heatSinkSize: [7, 6, 7] as V3, adhesiveAllowance: 0.15,
  lensCenter: [0, 3.5] as const,
  lensFrontZ: -16.7, moduleRearZ: -11.3, flexCenterZ: -11.56,
  connectorMouthY: -7.61101965, connectorTipY: -10.1, connectorFlexZ: -1.9,
  returnBendY: -4.6,
  // Two cylindrical bends preserve the contact-side normal. Static trial only;
  // Seeed does not specify a minimum bend radius or flex life in this drawing.
  minimumBendRadiusAssumed: 2.4,
} as const
const c = cameraMechanical
export const cameraFlexLength = c.overallFlatLength - c.bodyWidth
export const cameraBendRadius = (c.connectorFlexZ - c.flexCenterZ) / 4
const startY = c.lensCenter[1] - c.bodyWidth / 2
const turnY = (startY + 2 * c.returnBendY - c.connectorTipY + 2 * Math.PI * cameraBendRadius - cameraFlexLength) / 2
const midZ = c.flexCenterZ + 2 * cameraBendRadius

type Sample = { y: number; z: number; ty: number; tz: number; s: number }
const samples: Sample[] = []
const push = (y: number, z: number, ty: number, tz: number, s: number) => samples.push({ y, z, ty, tz, s })
let s = 0
push(startY, c.flexCenterZ, -1, 0, s)
s += startY - turnY
push(turnY, c.flexCenterZ, -1, 0, s)
for (let i = 1; i <= 96; i++) {
  const a = i * Math.PI / 96
  push(turnY - cameraBendRadius * Math.sin(a), c.flexCenterZ + cameraBendRadius * (1 - Math.cos(a)), -Math.cos(a), Math.sin(a), s + cameraBendRadius * a)
}
s += Math.PI * cameraBendRadius
s += c.returnBendY - turnY
push(c.returnBendY, midZ, 1, 0, s)
for (let i = 1; i <= 96; i++) {
  const a = i * Math.PI / 96
  push(c.returnBendY + cameraBendRadius * Math.sin(a), midZ + cameraBendRadius * (1 - Math.cos(a)), Math.cos(a), Math.sin(a), s + cameraBendRadius * a)
}
s += Math.PI * cameraBendRadius
// Last 4 mm is a stiffened, unbent tongue; do not bend at the exposed contacts.
s += c.returnBendY - (c.connectorTipY + c.tailLength)
push(c.connectorTipY + c.tailLength, c.connectorFlexZ, -1, 0, s)
export const cameraFlexSamples = samples
export const cameraFold = {
  freeFlexLength: cameraFlexLength, centerlineLength: s + c.tailLength,
  bendRadius: cameraBendRadius, innerRadiusAtMaxThickness: cameraBendRadius - (c.flexThickness + c.flexThicknessTolerance) / 2,
  foldCount: 2, tailStraightLength: c.tailLength,
  lowerTurnY: turnY, lowestY: turnY - cameraBendRadius,
  stiffenerStartY: c.connectorTipY + c.tailLength,
  nominalInsertionDepth: c.connectorMouthY - c.connectorTipY,
  endpoint: [0, c.connectorTipY, c.connectorFlexZ],
  contactFace: "+Z (toward PCB); pin-1 orientation requires sample confirmation",
}
export const cameraConnectorReview = {
  status: "DO NOT RELEASE: current J2 tail geometry is not a documented match",
  connector: "FH12-24S-0.5SH(55)",
  source: "https://www.hirose.com/en/product/series/FH12/documents?path=/api/v1/series/FH12/documents/product_series_catalog/content?lang%3DEN",
  sourcePage: 10,
  recommendedContactLengthMin: 3.5, seeedContactLength: 2.5,
  recommendedStiffenerLengthMin: 4.5, seeedStiffenerLength: 4,
  recommendedTailWidthTolerance: 0.07, seeedTailWidthTolerance: 0.1,
  action: "Select a connector documented for this shorter camera tail (or get explicit manufacturer approval), then recheck insertion height, contact side, pin 1 and folded route. The displayed J2 insertion is a packaging placeholder, not proof of mating.",
}

// Developable strip: extrusion of a constant-thickness YZ profile across X.
// Cylindrical bends do not stretch the ribbon sideways or invent extra length.
function strip(thickness: number = c.flexThickness, width: number = c.flexWidth): Plan {
  const edge = (sign: number) => samples.map(p => {
    const y = p.y - sign * p.tz * thickness / 2
    const z = p.z + sign * p.ty * thickness / 2
    return [-z, y]
  })
  const points = [...edge(1), ...edge(-1).reverse()]
  const signedArea = points.reduce((sum, p, i) => {
    const q = points[(i + 1) % points.length]
    return sum + p[0] * q[1] - q[0] * p[1]
  }, 0)
  if (signedArea < 0) points.reverse()
  return move([-width / 2, 0, 0], turn([0, Math.PI / 2, 0], {
    type: "extrudeLinear", options: { height: width }, shape: { type: "polygon", points },
  }))
}
export const cameraFlexEnvelope = union(
  strip(c.flexThickness + c.flexThicknessTolerance, c.flexWidth + 0.1),
  box([c.tailWidth + 0.1, c.tailLength, c.tailThickness + c.tailThicknessTolerance], [0, c.connectorTipY + c.tailLength / 2, c.connectorFlexZ]),
)
export const cameraHeatSinkEnvelope = box(c.heatSinkSize, [0, 3.5, c.moduleRearZ + c.adhesiveAllowance + c.heatSinkSize[2] / 2])
const modulePlan = union(
  // Body + barrel share a 5.4 mm envelope; visual barrel subdivision is nominal.
  tint([0.66, 0.68, 0.7], box([8.5, 8.5, 3.7], [0, 3.5, -13.15])),
  tint([0.1, 0.11, 0.12], box([8.1, 8.1, 0.5], [0, 3.5, -15.25])),
  tint([0.11, 0.12, 0.13], cyl(3.55, c.lensFrontZ, -15.3, 0, 3.5)),
  tint([0.11, 0.25, 0.32], cyl(2.55, c.lensFrontZ - 0.005, c.lensFrontZ + 0.03, 0, 3.5)),
)
const heatSinkZ = c.moduleRearZ + c.adhesiveAllowance
const heatSink = union(
  box([7, 6, 1], [0, 3.5, heatSinkZ + 0.5]),
  ...[-3.1, -1.0333, 1.0333, 3.1].map(x => box([0.8, 6, 6.05], [x, 3.5, heatSinkZ + 3.975])),
)
const flex = union(
  tint([0.86, 0.52, 0.12], strip()),
  tint([0.86, 0.52, 0.12], box([12.5, 4, 0.12], [0, -8.1, c.connectorFlexZ + 0.09])),
  tint([0.13, 0.14, 0.17], box([12.5, 4, 0.18], [0, -8.1, c.connectorFlexZ - 0.06])),
  ...Array.from({ length: 24 }, (_, i) => tint([0.94, 0.73, 0.24], box([0.3, 2.5, 0.015], [-5.75 + i * 0.5, c.connectorTipY + 1.25, c.connectorFlexZ + 0.15]))),
)
export const cameraParts: MechanicalPart[] = [
  { id: "camera-reference", label: "Seeed OV5640 · 8.5 mm autofocus module", plan: modulePlan, color: [0.15,0.15,0.16], printable: false, multicolor: true, group: "reference", explode: [0,0,-15], note: "Seeed 114993115 / 8.5 x 8.5 x 5.4 mm published envelope; optical/barrel details approximate" },
  { id: "camera-heatsink", label: "Seeed camera heatsink · 7 × 6 × 7 mm", plan: heatSink, color: [0.14,0.15,0.17], printable: false, group: "reference", explode: [0,0,-8], note: "Published overall size; four fins/base thickness are photo-based nominal details, not a heatsink drawing" },
  { id: "camera-thermal-pad", label: "Camera thermal adhesive allowance", plan: box([7,6,c.adhesiveAllowance], [0,3.5,c.moduleRearZ+c.adhesiveAllowance/2]), color:[0.52,0.72,0.82], printable:false, group:"reference", explode:[0,0,-12], note:"0.15 mm thickness allowance; measure supplied adhesive" },
  { id: "camera-flex", label: "Seeed camera ribbon · two rounded folds", plan: flex, color: [0.86,0.52,0.12], printable: false, multicolor: true, group: "reference", explode: [0,0,-5], note: "62 mm edge-to-tip developed length (70.5 mm overall minus 8.5 mm camera); 6 mm flex, 12.5 mm 24-contact tongue; 2.415 mm nominal bend radius is unqualified" },
]