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" },
]