main
TylerK Wrist Health Tracker: KiCad project, fabrication files, 3D models, enclosure and dock CAD, design docs d433b2a 14h ago
#!/usr/bin/env python3
"""Interference check: every enclosure part against every 3D component of the board
(KiCad STEP), and the parts against each other. Prints any overlap > 0.001 mm^3 and the
minimum clearances that matter (pipes to MAX30101, post to TMP117, plate to module)."""
import itertools, os
import cadquery as cq
from enclosure import *

PARTS = ["body", "back_plate", "pipe_pd", "pipe_led", "thermal_post", "led_rod_1", "led_rod_2", "button_cap"]
parts = {n: cq.importers.importStep(os.path.join(OUT, f"{n}.step")).val() for n in PARTS}
board = cq.importers.importStep(BOARD_STEP).val().Solids()
# 302025 cell on its foam in the pocket (taped in before the board), and its two leads to BT1
parts["battery_cell"] = cell().val()
for i, w in enumerate(cell_leads()):
    parts[f"lead_{i + 1}"] = w
LEADS = ("lead_1", "lead_2")

bad = 0
pcb = max(board, key=lambda s: s.BoundingBox().xlen * s.BoundingBox().ylen)
for n, p in parts.items():
    for s in board:
        if n == "body" and s is pcb:
            continue                      # the crush ribs bite the board edge on purpose: checked below
        try:
            v = p.intersect(s).Volume()
        except Exception:
            continue
        if v > 1e-3:
            bb = s.BoundingBox()
            print(f"INTERFERENCE {n} x board part at ({bb.center.x:.1f},{-bb.center.y:.1f}) z{bb.zmin:.2f}-{bb.zmax:.2f}: {v:.4f} mm3")
            bad += 1
for (a, pa), (b, pb) in itertools.combinations(parts.items(), 2):
    if "battery_cell" in (a, b) and (a in LEADS or b in LEADS):
        continue                          # the leads come out of the cell
    v = pa.intersect(pb).Volume()
    if v > 1e-3:
        print(f"INTERFERENCE {a} x {b}: {v:.4f} mm3")
        bad += 1


def clearance(a, b):
    return a.distance(b)


u2 = [s for s in board if abs(s.BoundingBox().center.x - 16.0) < 1.0 and abs(-s.BoundingBox().center.y - 23.5) < 1.5
      and s.BoundingBox().zmax > 1.5]
u4 = [s for s in board if abs(s.BoundingBox().center.x - 11.6) < 0.8 and abs(-s.BoundingBox().center.y - 22.0) < 0.8
      and s.BoundingBox().zmax > 1.0]
mod = [s for s in board if s.BoundingBox().zmax > 3.5]
if u2:
    print(f"pipe_pd to MAX30101: {min(clearance(parts['pipe_pd'], s) for s in u2):.3f} mm (optical adhesive gap)")
    print(f"pipe_led to MAX30101: {min(clearance(parts['pipe_led'], s) for s in u2):.3f} mm")
if u4:
    print(f"thermal post to TMP117: {min(clearance(parts['thermal_post'], s) for s in u4):.3f} mm (thermal pad)")
if mod:
    print(f"back plate to module can: {min(clearance(parts['back_plate'], s) for s in mod):.3f} mm (air; the can is a backstop)")

# crush ribs: they must touch the board edge with the designed interference, nothing more
rv = parts["body"].intersect(pcb).Volume()
print(f"crush ribs into board edge: {rv:.3f} mm3 (designed: 4 ribs x {RIB_INTERF} mm)")
if rv > 0.2:
    print("INTERFERENCE body x PCB beyond the ribs"); bad += 1

# insertion: the cell is already taped into its pocket; the board, soldered to its leads, goes in from the
# skin side, straight down onto the ledge, before the back plate. Sweep every component up to the top of the case and look for anything
# it would pass through on the way (the button cap is already in its recess).
import numpy as np
top = Z_PLATE_IN + 1.0
blocked = 0
for s in board:
    if s is pcb:
        continue
    bb = s.BoundingBox()
    prism = cq.Workplane("XY").box(bb.xlen, bb.ylen, top - bb.zmin, centered=False).translate((bb.xmin, bb.ymin, bb.zmin)).val()
    for n in ("body", "button_cap", "battery_cell"):
        if prism.intersect(parts[n]).Volume() < 1e-3:
            continue
        sw = s
        for dz in np.arange(0.1, top - bb.zmin, 0.1):
            sw = sw.fuse(s.translate(cq.Vector(0, 0, dz)))
        v = sw.intersect(parts[n]).Volume()
        if v > 1e-3:
            print(f"INSERTION BLOCKED: part at ({bb.center.x:.1f},{-bb.center.y:.1f}) hits {n} on the way in: {v:.3f} mm3")
            blocked += 1
print("insertion path (board straight down onto the ledge):", "CLEAR" if blocked == 0 else f"{blocked} blocked")
bad += blocked

# button: the cap must sit just short of SW1's plunger and reach it within the switch travel
sw1 = [s for s in board if s.BoundingBox().xmin < 0 and 15 < -s.BoundingBox().ymin < 22]
if sw1:
    g = parts["button_cap"].distance(sw1[0])
    print(f"button cap to SW1 plunger: {g:.3f} mm dead travel (pin stands {GAP + WALL_X + 0.7 - GAP - WALL_X:.1f} mm proud)")
    if not 0.0 <= g <= 0.4:
        print("BUTTON cannot work"); bad += 1

# battery: a cell at the top of its tolerance (+0.2 wide, +0.2 thick) must still drop into the pocket
big = cell((CELL[0] + 0.2, CELL[1], CELL[2] + 0.2)).val()
v = big.intersect(parts["body"]).Volume() + big.intersect(pcb).Volume()
print(f"max-tolerance cell ({CELL[0] + 0.2} x {CELL[1]} x {CELL[2] + 0.2}) in its pocket: {'fits' if v < 1e-3 else f'{v:.3f} mm3 over'}")
if v >= 1e-3:
    bad += 1
lead_len = [sum((cq.Vector(*b) - cq.Vector(*a)).Length for a, b in zip(p, p[1:])) for p in LEAD_PATHS]
print(f"battery leads: lie in the channel and BT1 well, {lead_len[0]:.0f} / {lead_len[1]:.0f} mm cell-to-pad "
      f"(cut them ~12 mm longer: the slack is what lets you solder with the board flipped out beside the case)")

# rework: a 0.4 x 2.0 mm spudger blade must reach down each pry slot and under the board edge
for x in PRY_X:
    down = slab(x - 1.0, -GAP - 0.7, x + 1.0, -GAP - 0.3, -0.6, Z_PLATE_IN + 1).val()
    under = slab(x - 1.0, -GAP - 0.7, x + 1.0, 1.0, -0.6, -0.2).val()
    blade = down.fuse(under)
    v = blade.intersect(parts["body"]).Volume() + blade.intersect(pcb).Volume()
    print(f"pry slot at board x {x}: {'spudger reaches under the board edge' if v < 1e-3 else f'BLOCKED {v:.3f} mm3'}")
    if v >= 1e-3:
        bad += 1

# tolerance: JLC outline +-0.2. A big board just makes the ribs bite harder (its -x/-y edges stay on the datum
# pads); a small one is no longer held by the ribs and can drift +x, away from the button cap.
for d in (-0.2, 0.0, 0.2):
    bite = RIB_INTERF + d
    dead = 0.180 + max(0.0, -bite)        # a loose board can drift +x by the lost bite, away from the cap
    print(f"board {BW + d:.1f} x {BH + d:.1f}: rib bite {bite:+.2f} mm; button dead travel up to {dead:.2f} mm "
          f"(+0.25 switch travel = {dead + 0.25:.2f} of the 0.7 mm pin)")

# ---------------------------------------------------------------- corner screws: plastic round each pilot and head
def rr_sdf(px, py, x0, y0, x1, y1, r):
    """signed distance to a rounded rectangle (board coords), negative inside"""
    cx, cy, hx, hy = (x0 + x1) / 2, (y0 + y1) / 2, (x1 - x0) / 2 - r, (y1 - y0) / 2 - r
    qx, qy = abs(px - cx) - hx, abs(py - cy) - hy
    return (max(qx, 0) ** 2 + max(qy, 0) ** 2) ** 0.5 + min(max(qx, qy), 0) - r


for (x, y) in SCREWS:
    to_cavity = rr_sdf(x, y, -GAP, -GAP, BW + GAP, BH + GAP, BR + GAP) - SCREW_PILOT / 2
    outer = -rr_sdf(x, y, -GAP - WALL_X, -GAP - WALL_Y, BW + GAP + WALL_X, BH + GAP + WALL_Y, CASE_R)
    ok = to_cavity >= 0.5 and outer - SCREW_PILOT / 2 >= 0.5 and outer - SCREW_HEAD / 2 >= 0.2
    print(f"screw at board ({x:.1f},{y:.1f}): {to_cavity:.2f} mm to the board cavity, {outer - SCREW_PILOT / 2:.2f} mm "
          f"to the outside, head {outer - SCREW_HEAD / 2:.2f} mm from the edge" + ("" if ok else "  TOO THIN"))
    if not ok:
        bad += 1

# ---------------------------------------------------------------- dock: the device, seated, against the cradle
dock = cq.importers.importStep(os.path.join(OUT, "dock_cradle.step")).val()
dev = {n: parts[n] for n in ("body", "back_plate", "pipe_pd", "pipe_led", "thermal_post", "led_rod_1", "led_rod_2",
                              "button_cap")}
# the band: spring bars (Ø1.8 max) and the strap's loop round each bar (2.2 mm strap: Ø6.2 envelope)
for sgn in (-1, 1):
    yb = -GAP - WALL_Y - BAR_FROM_WALL if sgn < 0 else BH + GAP + WALL_Y + BAR_FROM_WALL
    x0 = BW / 2 - LUG_W / 2
    dev[f"strap_loop_{sgn:+d}"] = (cq.Workplane("YZ").workplane(offset=x0 + 0.2).center(-yb, BAR_Z)
                                   .circle(0.75 + 2.2).extrude(LUG_W - 0.4).val())
for s in board:
    if s is not pcb:
        dev.setdefault("board_parts", []).append(s)
seat_bad = 0
for n, p in dev.items():
    for q in (p if isinstance(p, list) else [p]):
        v = q.intersect(dock).Volume()
        if v > 1e-3:
            print(f"DOCK INTERFERENCE: {n} x cradle: {v:.4f} mm3"); seat_bad += 1
print("device seated in the dock recess:", "CLEAR" if seat_bad == 0 else f"{seat_bad} clashes")
bad += seat_bad

# pogo pins: J1's pad centres read from the routed board, tips compressed onto them
import re
pcbtxt = open(PCB_FILE).read()
i = pcbtxt.index('(property "Reference" "J1"')
fp = pcbtxt[pcbtxt.rindex('(footprint "', 0, i):]
fp = fp[:fp.index('\n\t(footprint "') if '\n\t(footprint "' in fp else len(fp)]
fx, fy = (float(v) - 100.0 for v in re.search(r'\(at ([-\d.]+) ([-\d.]+)', fp).groups())
pads = [(fx + float(a), fy + float(b)) for a, b in re.findall(r'\(pad "\d" smd circle\s*\(at ([-\d.]+) ([-\d.]+)', fp)]
for (x, y), (px, py) in zip(J1_PADS, sorted(pads)):
    off = ((x - px) ** 2 + (y - py) ** 2) ** 0.5
    plunger = cyl(x, y, 0.74, PAD_Z + 0.01, TONGUE_TOP).val()
    v = sum(plunger.intersect(q).Volume() for q in [parts["back_plate"]] + dev["board_parts"])
    comp = PAD_Z - (TONGUE_TOP - POGO_FREE)
    ok = off < 0.3 and v < 1e-3 and 0.3 <= comp <= 1.0
    print(f"pogo at ({x:.2f},{y:.2f}): {off:.2f} mm off pad centre, compressed {comp:.2f} mm, path "
          + ("clear" if v < 1e-3 else f"BLOCKED {v:.3f} mm3") + ("" if ok else "  FAIL"))
    if not ok:
        bad += 1
if len(pads) != 4:
    print("J1 pads not found on the board"); bad += 1

# charge / status LEDs: each rod end must see open air through the cradle's groove, out beside the device
zf = Z_PLATE_OUT
xo = GAP + WALL_X + 3.5
for i, (x, y) in enumerate(LEDS):
    under = slab(x - 0.45, y - 0.45, x + 0.45, y + 0.45, zf + 0.01, zf + 0.9).val()          # below the rod end
    run = slab(-xo - 0.5, y - 0.45, x + 0.45, y + 0.45, zf + 0.3, zf + 0.9).val()             # along the groove
    sky = slab(-xo - 0.5, y - 0.45, -GAP - WALL_X - 0.6, y + 0.45, zf - DOCK_RECESS - 3, zf + 0.9).val()   # open above
    v = sum(b.intersect(dock).Volume() for b in (under, run, sky)) + sky.intersect(parts["body"]).Volume()
    print(f"LED rod {i + 1} at board y {y}: " + ("light reaches the cradle's top face beside the device" if v < 1e-3
                                                  else f"BLOCKED {v:.3f} mm3"))
    if v >= 1e-3:
        bad += 1

# magnets: open pockets (nothing printed over the face), magnets 0.1 below each face
for i, (x, y) in enumerate(MAGNETS):
    mag = cyl(x, y, MAG_D, zf - MAG_RECESS - MAG_H, zf - MAG_RECESS).val()
    mouth = cyl(x, y, MAG_D, zf - MAG_RECESS, zf + 0.5).val()
    dmag = cyl(x, y, MAG_D, zf + MAG_RECESS, zf + MAG_RECESS + MAG_H).val()
    dmouth = cyl(x, y, MAG_D, zf - 0.5, zf + MAG_RECESS).val()
    v = sum(a.intersect(b).Volume() for a, b in ((mag, parts["back_plate"]), (mouth, parts["back_plate"]),
                                                  (dmag, dock), (dmouth, dock)))
    print(f"magnet pair {i + 1}: open pockets, {2 * MAG_RECESS:.1f} mm apart, {MAG_FLOOR} mm floor behind the "
          f"device magnet: " + ("ok" if v < 1e-3 else f"BLOCKED {v:.3f} mm3"))
    if v >= 1e-3:
        bad += 1

print("RESULT:", "CLEAN" if bad == 0 else f"{bad} interferences")