main
TylerK Wrist Health Tracker: KiCad project, fabrication files, 3D models, enclosure and dock CAD, design docs d433b2a 14h ago
#!/usr/bin/env python3
"""Full assembly for viewing: the device as worn (enclosure parts, the routed board with every
component, the cell, magnets, screws, thermal pad, spring bars and a 20 mm strap), and the dock
beside it with its pogo pins, magnets and VBUS damping capacitor, and a second dock with the device seated in it.
Run enclosure.py first.

The exploded copy (50 mm to the -x side) shows the build order: the cell is taped into its pocket, then the
board (soldered to the cell's leads) drops in from the skin side.
Writes out/full_assembly.step (open in Fusion: File > Open, or Insert > Insert into current design)
and out/full_assembly.glb (any glTF viewer). Frame: X = board x, Y = -board y, +Z = skin side."""
import os
import cadquery as cq
from enclosure import (OUT, BOARD_STEP, BW, BH, BT, GAP, WALL_Y, Z_PLATE_IN, Z_PLATE_OUT, SCREWS, LUG_W,
                       BAR_FROM_WALL, BAR_Z, MAGNETS, MAG_D, MAG_H, MAG_RECESS, J1_PADS, U4, U4_TOP, POST_D, TPAD,
                       SCREW_PILOT, SCREW_HEAD, PAD_Z, TONGUE_TOP, cyl, slab, cell, cell_leads)

DOCK_DX = 50.0                       # dock shown beside the device, along +x

C = {
    "body": (0.17, 0.18, 0.21), "back_plate": (0.06, 0.06, 0.07), "pipe_pd": (0.72, 0.88, 1.0),
    "pipe_led": (0.72, 0.88, 1.0), "thermal_post": (0.80, 0.81, 0.84), "led_rod_1": (0.85, 0.95, 1.0),
    "led_rod_2": (0.85, 0.95, 1.0), "button_cap": (0.33, 0.34, 0.38), "dock_cradle": (0.86, 0.87, 0.89),
    "pcb": (0.06, 0.36, 0.18), "module": (0.82, 0.80, 0.74), "part": (0.20, 0.20, 0.22),
    "cell": (0.66, 0.70, 0.78), "magnet": (0.62, 0.62, 0.66), "screw": (0.72, 0.72, 0.75),
    "tpad": (0.95, 0.55, 0.20), "bar": (0.75, 0.75, 0.78), "strap": (0.12, 0.13, 0.16),
    "pogo": (0.85, 0.70, 0.25), "cap": (0.15, 0.25, 0.55), "lead_red": (0.80, 0.12, 0.10),
    "lead_black": (0.10, 0.10, 0.10),
}


def color(k, a=1.0):
    return cq.Color(*C[k], a)


def load(name):
    return cq.importers.importStep(os.path.join(OUT, f"{name}.step"))


def screw(x, y):
    """M1.2 x 4 countersunk: 90 deg head flush with the skin face, shank into the body pilot."""
    h = SCREW_HEAD / 2 - 0.6
    head = (cq.Workplane("XY").workplane(offset=Z_PLATE_OUT - h).center(x, -y)
            .circle(0.6).workplane(offset=h).circle(SCREW_HEAD / 2 - 0.05).loft())
    shank = cyl(x, y, SCREW_PILOT, Z_PLATE_OUT - 4.0, Z_PLATE_OUT - h + 0.01)   # drawn at the thread root (= pilot)
    return head.union(shank).cut(slab(x - 0.1, y - 0.9, x + 0.1, y + 0.9, Z_PLATE_OUT - 0.3, Z_PLATE_OUT + 0.1))


def magnet(x, y):
    """Ø3 x 1.5 epoxy-coated magnet in its open pocket, 0.1 below the skin face."""
    return cyl(x, y, MAG_D, Z_PLATE_OUT - MAG_RECESS - MAG_H, Z_PLATE_OUT - MAG_RECESS)


def spring_bar(yb, out_sign=0):
    """1.5 mm quick-release spring bar between the lug inner faces, 0.9 mm tips in the 1.1 mm lug holes,
    drawn where the strap's pull puts it: tips bearing on the outer side of the holes."""
    yb = yb + out_sign * (0.55 - 0.45)
    x0, x1 = BW / 2 - LUG_W / 2, BW / 2 + LUG_W / 2
    bar = cq.Workplane("YZ").workplane(offset=x0 + 0.05).center(-yb, BAR_Z).circle(0.75).extrude(x1 - x0 - 0.1)
    tips = cq.Workplane("YZ").workplane(offset=x0 - 1.6).center(-yb, BAR_Z).circle(0.45).extrude(x1 - x0 + 3.2)
    return bar.union(tips)


def strap(yb, out_sign, R=24.0, t=2.2, sweep=100):
    """One half of a 20 mm band: loops the spring bar, then curves round the wrist (toward +Z)."""
    yg = -yb
    w = LUG_W - 0.4
    x0 = BW / 2 - w / 2
    loop = (cq.Workplane("YZ").workplane(offset=x0).center(yg, BAR_Z).circle(0.75 + t).circle(0.80)
            .extrude(w))
    zc = BAR_Z + 0.75 + R + t / 2                     # band centre-line starts tangent under the bar
    ring = (cq.Workplane("YZ").workplane(offset=x0).center(yg, zc).circle(R + t / 2).circle(R - t / 2)
            .extrude(w))
    # keep the arc that leaves the bar outward (away from the case) and climbs toward the wrist
    big = 200
    keep = cq.Workplane("XY").box(big, big, big).translate((BW / 2, yg + out_sign * big / 2, zc - big / 2 + 0.01))
    band = ring.intersect(keep)
    if sweep > 90:                                     # carry on past the quarter, up the wrist
        extra = (cq.Workplane("YZ").workplane(offset=x0)
                 .center(yg + out_sign * (R), zc).rect(t, 2 * R * 0.18).extrude(w)
                 .translate((0, 0, R * 0.18)))
        band = band.union(extra)
    half_loop = loop.cut(cq.Workplane("XY").box(big, big, big).translate((BW / 2, yg - out_sign * big / 2, 0)))
    return band.union(half_loop)


def board_groups():
    """The KiCad STEP (routed board + 3D models), split into PCB / module / other parts for colour."""
    groups = {"pcb": [], "module": [], "part": []}
    for s in cq.importers.importStep(BOARD_STEP).val().Solids():
        bb = s.BoundingBox()
        if bb.xlen > 30 and bb.ylen > 28:
            groups["pcb"].append(s)
        elif bb.zmax > 3.5:
            groups["module"].append(s)
        else:
            groups["part"].append(s)
    return {k: cq.Compound.makeCompound(v) for k, v in groups.items() if v}


def exploded(board):
    """Build order, pulled apart along Z (the skin side is up): 1 body (button cap pushed into its
    recess from inside), 2 the cell, taped into its pocket, leads laid in the channel, 3 the board,
    soldered to the leads, then dropped straight down onto the ledge, 4 the back plate with pipes, post,
    rods and magnets, 5 screws."""
    ex = cq.Assembly(name="device_exploded_build_order", loc=cq.Location(cq.Vector(-DOCK_DX, 0, 0)))
    def put(shape, name, col, dz, dx=0.0, alpha=1.0):
        ex.add(shape, name=name, color=color(col, alpha), loc=cq.Location(cq.Vector(dx, 0, dz)))
    put(load("body"), "1_body", "body", 0)
    put(load("button_cap"), "1_button_cap", "button_cap", 0, dx=-6)
    put(cell(), "2_lipo_cell", "cell", 12)
    for k, comp in board.items():
        put(comp, f"3_board_{k}", k, 20)
    put(slab(U4[0] - 1.0, U4[1] - 1.0, U4[0] + 1.0, U4[1] + 1.0, U4_TOP, U4_TOP + TPAD), "3_thermal_pad", "tpad", 26)
    put(load("back_plate"), "4_back_plate", "back_plate", 32)
    for n in ("pipe_pd", "pipe_led", "thermal_post", "led_rod_1", "led_rod_2"):
        put(load(n), f"4_{n}", n, 40)
    for i, (x, y) in enumerate(MAGNETS):
        put(magnet(x, y), f"4_magnet_{i + 1}", "magnet", 40)
    for i, (x, y) in enumerate(SCREWS):
        put(screw(x, y), f"5_screw_{i + 1}", "screw", 46)
    return ex


def main():
    dev = cq.Assembly(name="device")
    for n in ("body", "back_plate", "pipe_pd", "pipe_led", "thermal_post", "led_rod_1", "led_rod_2", "button_cap"):
        dev.add(load(n), name=n, color=color(n, 0.55 if n == "body" else 1.0))
    brd = cq.Assembly(name="board")
    groups = board_groups()
    for k, comp in groups.items():
        brd.add(comp, name={"pcb": "pcb_routed", "module": "nrf52840_module", "part": "components"}[k], color=color(k))
    dev.add(brd)
    dev.add(cell(), name="lipo_302025_110mAh", color=color("cell"))
    for w, nm in zip(cell_leads(), ("lead_minus", "lead_plus")):
        dev.add(w, name=nm, color=color("lead_black" if nm == "lead_minus" else "lead_red"))
    dev.add(slab(U4[0] - 1.0, U4[1] - 1.0, U4[0] + 1.0, U4[1] + 1.0, U4_TOP, U4_TOP + TPAD), name="thermal_pad",
            color=color("tpad"))
    for i, (x, y) in enumerate(MAGNETS):
        dev.add(magnet(x, y), name=f"magnet_{i + 1}", color=color("magnet"))
    for i, (x, y) in enumerate(SCREWS):
        dev.add(screw(x, y), name=f"screw_M1.2x4_{i + 1}", color=color("screw"))
    band = cq.Assembly(name="strap_20mm")
    for sgn, nm in ((-1, "6_oclock"), (1, "12_oclock")):
        yb = -GAP - WALL_Y - BAR_FROM_WALL if sgn < 0 else BH + GAP + WALL_Y + BAR_FROM_WALL
        band.add(spring_bar(yb, sgn), name=f"spring_bar_{nm}", color=color("bar"))
        band.add(strap(yb + sgn * 0.1, out_sign=-sgn), name=f"band_{nm}", color=color("strap"))
    dev.add(band)

    dock = cq.Assembly(name="dock", loc=cq.Location(cq.Vector(DOCK_DX, 0, 0)))
    dock.add(load("dock_cradle"), name="dock_cradle", color=color("dock_cradle"))
    zf = Z_PLATE_OUT
    for i, (x, y) in enumerate(J1_PADS):
        # P75 pin drawn free (not docked): plunger tip 1.8 mm above the tongue, barrel pressed into the tongue
        pin = cyl(x, y, 1.02, TONGUE_TOP + 0.4, zf + 8.5).union(cyl(x, y, 0.74, TONGUE_TOP - 1.8, TONGUE_TOP + 0.4))
        dock.add(pin, name=f"pogo_{['VBUS', 'D-', 'D+', 'GND'][i]}", color=color("pogo"))
    for i, (x, y) in enumerate(MAGNETS):
        dock.add(cyl(x, y, MAG_D, zf + MAG_RECESS, zf + MAG_RECESS + MAG_H), name=f"dock_magnet_{i + 1}",
                 color=color("magnet"))
    can = cyl(21.9, 28.9, 6.3, zf + 2.5, zf + 7.9)          # glued against the pocket ceiling, leads out the bottom
    dock.add(can, name="C_damp_100uF_10V_electrolytic", color=color("cap"))

    # a second dock, with the device seated in it (pins compressed onto the J1 pads), beside the first
    docked = cq.Assembly(name="device_on_dock", loc=cq.Location(cq.Vector(2 * DOCK_DX, 0, 0)))
    docked.add(load("dock_cradle"), name="dock_cradle", color=color("dock_cradle"))
    for n in ("body", "back_plate", "pipe_pd", "pipe_led", "thermal_post", "led_rod_1", "led_rod_2", "button_cap"):
        docked.add(load(n), name=n, color=color(n))
    for k, comp in groups.items():
        docked.add(comp, name=f"board_{k}", color=color(k))
    docked.add(cell(), name="lipo_302025_110mAh", color=color("cell"))
    for i, (x, y) in enumerate(J1_PADS):
        pin = cyl(x, y, 1.02, TONGUE_TOP + 0.4, zf + 8.5).union(cyl(x, y, 0.74, PAD_Z, TONGUE_TOP + 0.4))
        docked.add(pin, name=f"pogo_{i + 1}_on_pad", color=color("pogo"))
    for i, (x, y) in enumerate(MAGNETS):
        docked.add(magnet(x, y), name=f"magnet_{i + 1}", color=color("magnet"))
        docked.add(cyl(x, y, MAG_D, zf + MAG_RECESS, zf + MAG_RECESS + MAG_H), name=f"dock_magnet_{i + 1}",
                   color=color("magnet"))

    top = cq.Assembly(name="health_tracker")
    top.add(dev)
    top.add(dock)
    top.add(docked)
    top.add(exploded(groups))
    step = os.path.join(OUT, "full_assembly.step")
    top.save(step)
    try:
        top.save(os.path.join(OUT, "full_assembly.glb"))
    except Exception as e:                            # older CadQuery: GLB export not available
        print("glb skipped:", e)
    print("wrote", step)


if __name__ == "__main__":
    main()