board
Wrist Health Tracker
Public Unreviewedby TylerK
A wrist-worn nRF52840 health tracker for sleep-quality scoring: heart rate, HRV, SpO2, steps and skin temperature, logged to flash and synced over BLE to a home server. Four-layer 32 x 30 mm KiCad board with single-sided assembly, a 3D-printed 35.7 x 33.9 x 9.95 mm case and a magnetic pogo-pin dock.
main
TylerK
Wrist Health Tracker: KiCad project, fabrication files, 3D models, enclosure and dock CAD, design docs
d433b2a
8h ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363
#!/usr/bin/env python3
"""Parametric enclosure for the wearable health tracker board (CadQuery).
Frame = KiCad's STEP frame of the board (exported with --user-origin 100x100mm):
X = board x, Y = -board y, board body z 0..0.8, F.Cu (all parts, SKIN side) faces +Z.
So +Z is toward the skin, -Z toward the top of the wrist.
Stack (skin -> top): sensor boss / back plate | board (parts face skin) | Li-Po | body top
Parts written to out/: body, back_plate, pipe_pd, pipe_led, thermal_post, led_pipe x2, button_cap,
dock_cradle (+ assembly STEP, STLs). Run check_fit.py afterwards for interference vs the board STEP.
"""
import os
import cadquery as cq
HERE = os.path.dirname(os.path.abspath(__file__))
OUT = os.path.join(HERE, "out")
BOARD_STEP = os.path.join(HERE, "..", "hardware", "health-tracker.step") # kicad-cli pcb export step --user-origin 100x100mm
PCB_FILE = os.path.join(HERE, "..", "kicad", "health-tracker.kicad_pcb")
# ---------------------------------------------------------------- parameters (mm)
BW, BH, BR, BT = 32.0, 30.0, 3.0, 0.8 # board outline, corner radius, thickness
GAP = 0.45 # board edge to wall: covers JLC outline +-0.2 and print +-0.2
# board location: the board is pushed by crush ribs (+x, +y walls) against datum pads (-x, -y walls),
# so it sits at its nominal position whatever its outline tolerance (SW1's plunger and the optics need that)
DATUM_PADS = [("x", 6.0), ("x", 26.0), ("y", 5.0), ("y", 27.0)] # on the -x wall at board y / -y wall at board x
CRUSH_RIBS = [("x", 19.0), ("x", 27.0), ("y", 3.0), ("y", 29.0)] # on the +x wall at board y / +y wall at board x
RIB_R, RIB_INTERF = 0.3, 0.10 # rib crest 0.10 mm inside the board outline: shaved on insertion
SW_Z = BT + 1.15 # SW1 plunger centre (TS24CA datasheet: 0.9 thick, centred on the 1.9 body)
SW_TIP = -0.27 # SW1 plunger tip, board x (2.15 from its pegs, overhangs the edge)
WALL_X = 1.4 # side walls (button / antenna sides)
WALL_Y = 1.5 # end walls (the screws are in the corners, not the walls)
CASE_R = 2.3 # case outer corner radius: tighter than the board's, so each corner holds a screw
# back plate: the module can (3.0 +-0.1 tall, top at 3.8) is 0.2 below it, a backstop with no tape
Z_PLATE_IN, PLATE_T = 4.0, 0.8
Z_PLATE_OUT = Z_PLATE_IN + PLATE_T
# Li-Po: 302025 (3.0 x 20 x 25 nominal, PCM inside the 25), turned 90 deg: 20 along x (the arm), 25 along y
# (around the wrist, lug to lug).
# It goes into its pocket FIRST, taped to the floor (the pocket locates it), leads at the +y end. Then the
# board is soldered to the leads and lowered in. The pocket allows +0.2 on width and thickness (pouch
# seal folds, swell) and a 2.6 mm lead zone at the +y end for the PCM fold and the wires' bend.
CELL = (20.0, 25.0, 3.0)
FOAM = 0.3 # double-sided foam, cell to pocket floor
LEAD_ZONE = 2.6
BATT = (CELL[0] + 0.4, CELL[1] + 0.2 + LEAD_ZONE, FOAM + CELL[2] + 0.2) # pocket 20.4 x 27.8 x 3.5
BATT_X0, BATT_Y0 = 4.0, 1.0 # clear of SW1's pegs (x<3.8) and >4.2 mm from the antenna (x>28.6);
# y 1.0..28.8 stays inside the 0.7 mm ledge (y 0.7..29.3)
Z_TOP_IN = -BATT[2] # pocket floor
TOP_T = 1.0 # MJF PA12 minimum wall is 0.8
# leads: a 1.6 mm deep channel from the pocket's lead zone to a well under BT1's pads (board x 28.4-30.8,
# y 20.05-25.15 on the battery side), big enough to tuck ~12 mm of service loop
LEAD_CH = (24.0, 25.8, 30.0, 28.8) # x0, y0, x1, y1 (board coords)
BT1_WELL = (26.0, 19.6, 30.6, 28.8)
LEAD_D = 1.6
# rework: two pry notches in the -y wall, each with a pocket under the board edge for a spudger tip
PRY_X = (11.0, 21.0) # clear of the -y datum pads (x 5, 27) and screws (x 6.5, 25.5)
Z_TOP_OUT = Z_TOP_IN - TOP_T
LEDGE = 0.7 # board rests on this ledge (B side)
# M1.2 x 4 countersunk self-tapping screws, one in each case corner, outside the board's 3 mm corner radius
SCREWS = [(-0.2, -0.2), (BW + 0.2, -0.2), (-0.2, BH + 0.2), (BW + 0.2, BH + 0.2)] # board coords
SCREW_PILOT, SCREW_CLEAR, SCREW_HEAD = 0.95, 1.25, 2.4 # body pilot, plate clearance, 90 deg countersink
LUG_W = 20.0 # strap width (quick-release 20 mm)
LUG_HORN = 4.0 # horn width in x
LUG_LEN = 3.7 # horn length beyond the end wall
BAR_D = 1.1 # spring-bar hole (fits 1.5 / 1.8 mm bars, 0.8-1.0 tips)
BAR_FROM_WALL = 2.3
BAR_Z = 1.5
# features (board coords) -- from the board placement
U2 = (16.0, 23.5); PD_OFF, LED_OFF = -0.80, +1.83 # MAX30101 aperture centres along board y
PD_SZ, LED_SZ = (2.15, 2.70), (2.15, 1.20) # light-pipe cross sections
U2_TOP = BT + 1.55
# light pipes are one 3.0 mm acrylic sheet thick, 0.10 above the sensor (clear at +0.1 part height, resin-filled);
# their tops set the boss surface, 0.65 proud of the 0.8 mm plate
PIPE_LEN, PIPE_GAP = 3.0, 0.10
BOSS_TOP = U2_TOP + PIPE_GAP + PIPE_LEN
BOSS_H = BOSS_TOP - Z_PLATE_OUT
U4 = (11.6, 22.0); U4_TOP = BT + 0.8; POST_D = 2.0; TPAD = 0.25
J1_PADS = [(5.79, 27.9), (8.33, 27.9), (10.87, 27.9), (13.41, 27.9)]
# Ø3 x 1.5 epoxy-coated N52, pressed into open pockets from the outside face and glued, 0.1 below the face:
# no thin skin to print, and only 0.2 mm between device and dock magnets. Opposite polarity = keyed.
MAGNETS = [(2.6, 28.2), (20.0, 28.2)]; MAG_D, MAG_H = 3.0, 1.5
MAG_RECESS, MAG_FLOOR = 0.1, 0.6
# dock connection: the plate has one well over the four J1 pads; the cradle's tongue enters it and carries the pogo
# pins, so they stand only 1.8 mm proud of plastic and compress 0.6 mm onto the pads
WELL = (4.85, 26.6, 14.35, 29.2) # x0, y0, x1, y1 (board coords) around J1's Ø1.8 pads
PAD_Z = BT + 0.035
TONGUE_TOP, POGO_FREE = PAD_Z + 1.2, 1.8
DOCK_RECESS = 1.0 # cradle recess depth around the case (and the strap channel)
LEDS = [(1.3, 23.4), (1.3, 9.2)]
SW1_Y = 18.6
BOSS = (9.4, 19.2, 19.2, 27.0) # x0, y0, x1, y1 (board coords)
def B(x, y):
"""board coords -> frame (x, -y)"""
return (x, -y)
def rrect(w, h, r, cx, cy):
return cq.Workplane("XY").center(cx, cy).rect(w, h).val()
def slab(x0, y0, x1, y1, z0, z1, r=0.0):
w, h = x1 - x0, y1 - y0
s = cq.Workplane("XY").workplane(offset=z0).center((x0 + x1) / 2, -(y0 + y1) / 2).rect(w, h).extrude(z1 - z0)
if r > 0:
s = s.edges("|Z").fillet(min(r, w / 2 - 0.01, h / 2 - 0.01))
return s
def cyl(x, y, d, z0, z1):
return cq.Workplane("XY").workplane(offset=z0).center(x, -y).circle(d / 2).extrude(z1 - z0)
def outline(z0, z1, grow=0.0, extra_y=0.0, extra_x=0.0, r=None):
"""rounded case/board outline grown by `grow` (+extra in x/y), extruded z0..z1"""
x0, y0 = -grow - extra_x, -grow - extra_y
x1, y1 = BW + grow + extra_x, BH + grow + extra_y
r = max(0.3, BR + grow) if r is None else r
return slab(x0, y0, x1, y1, z0, z1, r)
# ---------------------------------------------------------------- body (battery side + walls + lugs)
def body():
case = outline(Z_TOP_OUT, Z_PLATE_IN, grow=GAP, extra_x=WALL_X, extra_y=WALL_Y, r=CASE_R)
case = case.edges("<Z").fillet(1.0)
# board pocket (from the plate seat down to the board's B face)
case = case.cut(outline(0.0, Z_PLATE_IN + 1, grow=GAP))
# 0.3 mm air gap under the board inside the ledge, then the battery pocket
case = case.cut(outline(-0.3, 0.0, grow=-LEDGE))
case = case.cut(slab(BATT_X0, BATT_Y0, BATT_X0 + BATT[0], BATT_Y0 + BATT[1], Z_TOP_IN, 0.0, 0.4))
# relief for SW1's locating pegs, which poke 0.47 mm out of the board's battery side
case = case.cut(slab(0.8, 16.4, 3.8, 20.8, -0.8, 0.0, 0.4))
# battery leads: channel from the pocket's lead zone, and the well under BT1 (solder joints + service loop)
case = case.cut(slab(*LEAD_CH, -LEAD_D, 0.0, 0.6))
case = case.cut(slab(*BT1_WELL, -LEAD_D, 0.0, 0.6))
# pry notches (-y wall): a slot down the wall, then a pocket under the board edge for the spudger tip
for x in PRY_X:
case = case.cut(slab(x - 1.25, -GAP - 0.8, x + 1.25, -GAP + 0.01, -0.8, Z_PLATE_IN + 0.1, 0.3))
case = case.cut(slab(x - 1.25, -GAP - 0.8, x + 1.25, 1.2, -0.8, 0.0, 0.3))
# (no gasket groove: the 1.4 mm side walls can't hold one, and it cut through the screw pilots)
# screw pilots (M1.2 self-tapping, from the skin side), in the corners
for (x, y) in SCREWS:
case = case.cut(cyl(x, y, SCREW_PILOT, Z_PLATE_IN - 4.0, Z_PLATE_IN + 0.1))
# lug horns, 20 mm between inner faces, drilled through for spring bars
for xc in (BW / 2 - LUG_W / 2 - LUG_HORN / 2, BW / 2 + LUG_W / 2 + LUG_HORN / 2):
for sgn in (-1, 1):
if sgn < 0:
y0, y1 = -GAP - WALL_Y - LUG_LEN, -GAP - WALL_Y + 0.5
else:
y0, y1 = BH + GAP + WALL_Y - 0.5, BH + GAP + WALL_Y + LUG_LEN
horn = slab(xc - LUG_HORN / 2, y0, xc + LUG_HORN / 2, y1, -2.6, Z_PLATE_OUT - 0.2, 1.0)
horn = horn.edges(">Z or <Z").fillet(0.6)
horn = horn.cut(outline(Z_PLATE_IN, Z_PLATE_OUT + 1, grow=GAP, extra_x=WALL_X, extra_y=WALL_Y, r=CASE_R))
case = case.union(horn)
for sgn in (-1, 1):
yb = -GAP - WALL_Y - BAR_FROM_WALL if sgn < 0 else BH + GAP + WALL_Y + BAR_FROM_WALL
bar = cq.Workplane("YZ").workplane(offset=-5).center(-yb, BAR_Z).circle(BAR_D / 2).extrude(BW + 10)
case = case.cut(bar)
# side button: hole + inner recess for the cap's flange
case = case.cut(cq.Workplane("YZ").workplane(offset=-GAP - WALL_X - 1).center(-SW1_Y, SW_Z).circle(0.75).extrude(WALL_X + 2))
case = case.cut(cq.Workplane("YZ").workplane(offset=-GAP - 0.45).center(-SW1_Y, SW_Z).circle(1.2).extrude(0.46))
# board datum pads (flat, flush with the board edge) and crush ribs (0.1 mm interference), each
# with a lead-in at the top so the board self-centres as it is pressed down onto the ledge
for axis, pos in DATUM_PADS:
if axis == "x":
pad = slab(-GAP - 0.01, pos - 1.0, 0.0, pos + 1.0, 0.0, 1.6)
lead = cq.Workplane("XY").workplane(offset=1.6).center(-GAP / 2, -pos).rect(GAP, 2.0).workplane(offset=0.6).center(-GAP / 4, 0).rect(GAP / 2, 2.0).loft()
else:
pad = slab(pos - 1.0, -GAP - 0.01, pos + 1.0, 0.0, 0.0, 1.6)
lead = cq.Workplane("XY").workplane(offset=1.6).center(pos, GAP / 2).rect(2.0, GAP).workplane(offset=0.6).center(0, GAP / 4).rect(2.0, GAP / 2).loft()
case = case.union(pad).union(lead)
for axis, pos in CRUSH_RIBS:
if axis == "x":
cx, cy = BW - RIB_INTERF + RIB_R, pos
else:
cx, cy = pos, BH - RIB_INTERF + RIB_R
rib = cyl(cx, cy, 2 * RIB_R, 0.0, 1.2)
cone = cq.Workplane("XY").workplane(offset=1.2).center(cx, -cy).circle(RIB_R).workplane(offset=0.6).circle(0.05).loft()
case = case.union(rib).union(cone)
return case
# ---------------------------------------------------------------- back plate (skin side)
def back_plate(board_parts_bboxes):
plate = outline(Z_PLATE_IN, Z_PLATE_OUT, grow=GAP, extra_x=WALL_X, extra_y=WALL_Y, r=CASE_R)
plate = plate.edges(">Z").fillet(0.4)
boss = slab(*BOSS, Z_PLATE_OUT - 0.2, BOSS_TOP, 2.0).edges(">Z").fillet(0.35)
plate = plate.union(boss)
# rim that presses the board onto the body ledge, cut clear of every component
rim = outline(BT, Z_PLATE_IN, grow=-0.15).cut(outline(BT - 1, Z_PLATE_IN + 1, grow=-0.65))
for (x0, y0, x1, y1, z1) in board_parts_bboxes:
rim = rim.cut(cq.Workplane("XY").workplane(offset=BT - 0.1).center((x0 + x1) / 2, (y0 + y1) / 2)
.rect(x1 - x0 + 0.5, y1 - y0 + 0.5).extrude(Z_PLATE_IN - BT + 0.2))
plate = plate.union(rim)
# module: the plate sits 0.2 mm above the can (0.1 at the module's +0.1 tolerance); the can is a backstop
# optical holder: black (IR-opaque) skirt down to the MAX30101, two light-pipe pockets
cx, cy = U2
skirt = slab(cx - 1.95, cy - 3.1, cx + 1.95, cy + 3.1, U2_TOP + PIPE_GAP, Z_PLATE_IN + 0.01, 0.4)
plate = plate.union(skirt)
for off, (w, h) in ((PD_OFF, PD_SZ), (LED_OFF, LED_SZ)):
plate = plate.cut(slab(cx - w / 2, cy + off - h / 2, cx + w / 2, cy + off + h / 2,
U2_TOP, BOSS_TOP + 1))
# thermal post: guide tube + bore
tx, ty = U4
plate = plate.union(cyl(tx, ty, POST_D + 1.0, U4_TOP + 0.35, Z_PLATE_IN + 0.01))
plate = plate.cut(cyl(tx, ty, POST_D, U4_TOP, BOSS_TOP + 1))
# dock: a walled well down to the four J1 pads, for the cradle's pogo tongue (+ lead-in chamfer)
wx0, wy0, wx1, wy1 = WELL
cx, cy = U2
well_wall = slab(wx0 - 0.6, wy0 - 0.6, wx1 + 0.6, wy1 + 0.55, BT + 0.25, Z_PLATE_IN + 0.01, 0.6)
well_wall = well_wall.cut(slab(cx - 1.95, cy - 3.1, cx + 1.95, cy + 3.1, 0.0, U2_TOP + PIPE_GAP)) # clear of U2
plate = plate.union(well_wall)
plate = plate.cut(slab(wx0, wy0, wx1, wy1, BT + 0.1, Z_PLATE_OUT + 1, 0.3))
plate = plate.cut(cq.Workplane("XY").workplane(offset=Z_PLATE_OUT - 0.3).center((wx0 + wx1) / 2, -(wy0 + wy1) / 2)
.rect(wx1 - wx0, wy1 - wy0).workplane(offset=0.31).rect(wx1 - wx0 + 0.62, wy1 - wy0 + 0.62).loft())
# magnets: open pockets from the skin face (pressed in from outside, glued), on a 0.6 mm floor
for (x, y) in MAGNETS:
plate = plate.union(cyl(x, y, MAG_D + 1.0, Z_PLATE_OUT - MAG_RECESS - MAG_H - MAG_FLOOR, Z_PLATE_IN + 0.01))
plate = plate.cut(cyl(x, y, MAG_D + 0.1, Z_PLATE_OUT - MAG_RECESS - MAG_H - 0.05, Z_PLATE_OUT + 1))
# LED light-pipe holes (1.0 mm acrylic rod) with guide tubes
for (x, y) in LEDS:
plate = plate.union(cyl(x, y, 2.0, BT + 1.3, Z_PLATE_IN + 0.01)) # 0603 LED is 1.1 mm tall
plate = plate.cut(cyl(x, y, 1.0, BT + 1.2, Z_PLATE_OUT + 1))
# countersunk screw holes
for (x, y) in SCREWS:
plate = plate.cut(cyl(x, y, SCREW_CLEAR, Z_PLATE_IN - 1, Z_PLATE_OUT + 1))
h = (SCREW_HEAD - SCREW_CLEAR) / 2
plate = plate.cut(cq.Workplane("XY").workplane(offset=Z_PLATE_OUT - h).center(x, -y)
.circle(SCREW_CLEAR / 2).workplane(offset=h + 0.01).circle(SCREW_HEAD / 2 + 0.01).loft())
return plate
def pipes():
cx, cy = U2
z0, z1 = U2_TOP + PIPE_GAP, BOSS_TOP
pd = slab(cx - PD_SZ[0] / 2, cy + PD_OFF - PD_SZ[1] / 2, cx + PD_SZ[0] / 2, cy + PD_OFF + PD_SZ[1] / 2, z0, z1)
led = slab(cx - LED_SZ[0] / 2, cy + LED_OFF - LED_SZ[1] / 2, cx + LED_SZ[0] / 2, cy + LED_OFF + LED_SZ[1] / 2, z0, z1)
return pd, led, z1 - z0
def cell(size=CELL):
"""The Li-Po on its foam at the pocket floor, pushed to the pocket's -y end (lead zone at +y)."""
w, l, t = size
x0 = BATT_X0 + (BATT[0] - w) / 2
return slab(x0, BATT_Y0 + 0.1, x0 + w, BATT_Y0 + 0.1 + l, Z_TOP_IN + FOAM, Z_TOP_IN + FOAM + t, 0.8)
# the two leads (board coords + z), from the PCM at the cell's +y end: up into the lead zone, along the
# channel, down the BT1 well to the pads. Straight runs: the shortest path they can lie in.
_YE, _ZC, _ZE = BATT_Y0 + 0.1 + CELL[1], -0.9, Z_TOP_IN + FOAM + CELL[2] / 2
LEAD_PATHS = [[(14.8, _YE, _ZE), (14.8, 26.8, _ZC), (28.8, 26.8, _ZC), (28.8, 20.85, _ZC), (29.4, 20.85, -0.5)],
[(13.6, _YE, _ZE), (13.6, 28.0, _ZC), (30.0, 28.0, _ZC), (30.0, 24.35, -0.5)]]
def cell_leads(d=1.0):
"""The leads along LEAD_PATHS as Ø1.0 (28 AWG silicone) wires."""
out = []
for pts in LEAD_PATHS:
w = None
for a, b in zip(pts, pts[1:]):
va, vb = cq.Vector(a[0], -a[1], a[2]), cq.Vector(b[0], -b[1], b[2])
seg = cq.Solid.makeCylinder(d / 2, (vb - va).Length, va, vb - va)
ball = cq.Solid.makeSphere(d / 2, angleDegrees1=-90, angleDegrees2=90).translate(vb)
w = seg.fuse(ball) if w is None else w.fuse(seg).fuse(ball)
out.append(w)
return out
def thermal_post():
z0 = U4_TOP + TPAD
return cyl(U4[0], U4[1], POST_D, z0, BOSS_TOP), BOSS_TOP - z0
def led_rods():
return [cyl(x, y, 1.0, BT + 1.2, Z_PLATE_OUT) for (x, y) in LEDS]
def button_cap():
"""Pin through the wall + flange in the wall's inner recess. At rest the flange sits on the recess
floor, its inner face flush with the wall, 0.18 mm short of SW1's plunger tip (dead travel), so
the board drops in past it untouched; pressing the pin closes that gap and works the switch."""
flange = cq.Workplane("YZ").workplane(offset=-GAP - 0.45).center(-SW1_Y, SW_Z).circle(1.1).extrude(0.45)
pin = cq.Workplane("YZ").workplane(offset=-GAP - WALL_X - 0.7).center(-SW1_Y, SW_Z).circle(0.65).extrude(WALL_X + 0.7 - 0.45 + 0.01)
return flange.union(pin).edges("<X").fillet(0.3)
# ---------------------------------------------------------------- dock cradle
def dock():
"""Device sits skin-side down in the cradle. Frame: the device's skin face is at z = Z_PLATE_OUT; the cradle
is on the +z side of it, its top face DOCK_RECESS above (toward the device), so the case drops into a recess."""
zf = Z_PLATE_OUT # recess floor = the device's skin face
ztop = zf - DOCK_RECESS # cradle top face
xo, yo = GAP + WALL_X + 3.5, GAP + WALL_Y + LUG_LEN + 0.3 # 3.5 beside the case, ending just past the lugs
base = slab(-xo, -yo, BW + xo, BH + yo, ztop, zf + 9.0, 3.0).edges("<Z").fillet(0.8)
# recess: the case outline +0.25 all round, and a channel the width of the lugs, open at both ends, for the
# lug horns and the strap's loops round the spring bars
base = base.cut(outline(ztop - 0.1, zf, grow=GAP + 0.25, extra_x=WALL_X, extra_y=WALL_Y, r=CASE_R + 0.25))
cx0, cx1 = BW / 2 - LUG_W / 2 - LUG_HORN - 0.4, BW / 2 + LUG_W / 2 + LUG_HORN + 0.4
base = base.cut(slab(cx0, -yo - 1, cx1, BH + yo + 1, ztop - 0.1, zf))
base = base.cut(slab(BOSS[0] - 0.3, BOSS[1] - 0.3, BOSS[2] + 0.3, BOSS[3] + 0.3, zf - 0.1, BOSS_TOP + 0.3, 2.2))
# pogo tongue: rises from the recess floor into the device's well, 0.2 clear all round, top 1.2 above the pads
wx0, wy0, wx1, wy1 = WELL
base = base.union(slab(wx0 + 0.2, wy0 + 0.2, wx1 - 0.2, wy1 - 0.2, TONGUE_TOP, zf + 0.01, 0.3)
.edges("<Z").fillet(0.2))
# pogo pins: P75-series (1.02 mm barrel), press-fit through the tongue, tips POGO_FREE above it
for (x, y) in J1_PADS:
base = base.cut(cyl(x, y, 1.02, TONGUE_TOP - 0.1, zf + 9.1))
# magnets: open pockets in the recess floor, 0.1 below it, opposite polarity to the device's
for (x, y) in MAGNETS:
base = base.cut(cyl(x, y, MAG_D + 0.1, zf - 0.1, zf + MAG_RECESS + MAG_H + 0.05))
# LED windows: a groove under each LED rod, out under the case wall to the cradle's top face beside the device,
# so the charge (and status) light shows while the device sits on the dock
for (x, y) in LEDS:
base = base.cut(slab(-xo - 1, y - 0.7, x + 0.7, y + 0.7, ztop - 0.1, zf + 1.0))
# wiring cavity + USB-C breakout pocket (open at the far end)
base = base.cut(slab(3.0, 25.0, 17.0, 34.5, zf + 4.0, zf + 7.5, 1.0))
base = base.cut(slab(5.0, 34.0, 15.0, 45.0, zf + 3.0, zf + 7.5, 0.5))
# VBUS damping capacitor: 100 uF / 10 V aluminium electrolytic, 6.3 x 5.4 mm SMD can, wired
# straight across the VBUS and GND pogo pins. Its ~0.3-1 ohm ESR over-damps the ring that a
# hot plug (cable into dock, or device onto a live dock) would otherwise drive to ~9 V on the
# device's ceramic-only VBUS (fields: 9.3 V peak; nRF52840 VBUS abs max 5.8 V)
base = base.cut(slab(18.5, 25.5, 25.3, 32.3, zf + 2.5, zf + 9.1, 0.6))
base = base.cut(slab(16.0, 28.0, 19.0, 30.5, zf + 4.0, zf + 7.5, 0.3))
return base
def main():
os.makedirs(OUT, exist_ok=True)
# component bboxes on the skin side, for the rim cut-outs
bbs = []
if os.path.exists(BOARD_STEP):
brd = cq.importers.importStep(BOARD_STEP)
for s in brd.val().Solids():
bb = s.BoundingBox()
if bb.zmax > BT + 0.05 and (bb.xlen < 25 or bb.ylen < 25):
bbs.append((bb.xmin, bb.ymin, bb.xmax, bb.ymax, bb.zmax))
parts = {"body": body(), "back_plate": back_plate(bbs)}
pd, led, plen = pipes()
parts["pipe_pd"], parts["pipe_led"] = pd, led
post, postlen = thermal_post()
parts["thermal_post"] = post
for i, r in enumerate(led_rods()):
parts[f"led_rod_{i + 1}"] = r
parts["button_cap"] = button_cap()
parts["dock_cradle"] = dock()
for name, p in parts.items():
cq.exporters.export(p, os.path.join(OUT, f"{name}.step"))
cq.exporters.export(p, os.path.join(OUT, f"{name}.stl"), tolerance=0.02, angularTolerance=0.15)
assy = cq.Assembly()
colors = {"body": (0.15, 0.15, 0.17), "back_plate": (0.08, 0.08, 0.08), "pipe_pd": (0.75, 0.9, 1.0),
"pipe_led": (0.75, 0.9, 1.0), "thermal_post": (0.8, 0.8, 0.82), "button_cap": (0.3, 0.3, 0.32),
"dock_cradle": (0.9, 0.9, 0.9)}
for name, p in parts.items():
if name != "dock_cradle":
assy.add(p, name=name, color=cq.Color(*colors.get(name, (0.85, 0.95, 1.0))))
assy.save(os.path.join(OUT, "enclosure_assembly.step"))
print(f"light pipes: 2 pcs, {plen:.2f} mm long (cut from 3 mm clear acrylic); thermal post: d{POST_D} x {postlen:.2f} mm")
print("parts:", ", ".join(parts))
if __name__ == "__main__":
main()