main
John Lauer Add KiCad symbol and footprint, viewers, provenance, README, models.json and verification log 354b2dd 8h ago
#!/usr/bin/env python3
"""Build the MLX90614xCI (5 degree FOV, long collimator cap) TO-39 4-lead model from the Melexis datasheet.

Source: Melexis MLX90614 datasheet, Datasheet Rev 021 (02-Jun-2026), DOC#3901090614,
p.49 Figure 45 (Mechanical drawing of MLX90614xCI) and p.6 Figure 1 (pin description, bottom view).

Frame (Adom / KiCad 3D): Z up, millimetres, origin = KiCad TO-39-4 footprint pad 1,
x = KiCad x, y = -KiCad y. Can axis at (2.54, 0). Seating plane z = 0 is the bottom of the
Ø1.20 lead beads (they stand 0.38 below the header); the header bottom is at z = 0.38.

Printed dimensions (p.49): cap Ø9.00 ±0.1, 0.50x45 top chamfer, Ø1.50 groove whose centre lies 0.20 outside
the cap wall and 1.58 below the top, overall 20.20 ±0.15 (top to header bottom), 19.50 ±0.1 (top to
header top line), header Ø9.15 +0.20/-0.10, tab 0.80 ±0.1 wide at 45 deg between pins 1 and 4,
M5 thread in the top, leads Ø0.45 ±0.04 on a Ø5.08 circle, 13.00 ±0.5 long below the header,
Ø1.20 lead beads 0.38 below the header.
Measured off the drawing (400 dpi render, 44.5 px/mm calibrated on Ø9.00 and 20.20; both read true):
header flange thickness 0.40, upper header step 0.29 at Ø9.00, tab tip 5.46 from the can axis,
M5 minor-diameter bore Ø4.13 (thread convention: full circle = minor diameter).
Not given anywhere in the datasheet: bore depth (modelled 2.00, assumption) and where the laser marking sits.

usage: build_to39_bci.py out_dir
writes: mlx90614esf-bci-000-tu.step (plain), mlx90614esf-bci-000-tu-etched.step (with the BCI device code),
        dims.json
"""
import json, math, sys, os
import cadquery as cq

OUT = sys.argv[1]
os.makedirs(OUT, exist_ok=True)
CX, CY = 2.54, 0.0

D = dict(
    cap_d=9.00, chamfer=0.50, groove_d=1.50, groove_off=0.20, groove_from_top=1.58,
    overall=20.20, top_to_header_top=19.50, header_d=9.15, header_t=0.40, header_step_t=0.29,
    tab_w=0.80, tab_tip_r=5.46, tab_angle_deg=135.0, bore_d=4.13, bore_depth=2.00,
    lead_d=0.45, lead_circle_d=5.08, lead_len_below_header=13.00, bead_d=1.20, bead_h=0.38,
)
Z_HDR_BOT = D['bead_h']                                   # 0.38
Z_TOP = Z_HDR_BOT + D['overall']                          # 20.58
Z_HDR_TOP = Z_HDR_BOT + D['header_t']                     # 0.78 (flange)
Z_CAP_BOT = Z_TOP - D['top_to_header_top']                # 1.08
assert abs(Z_CAP_BOT - (Z_HDR_TOP + D['header_step_t'])) < 0.02, (Z_CAP_BOT, Z_HDR_TOP)

PINS = {  # pad number -> (x, y) in the model frame (KiCad y flipped)
    '1': (0.0, 0.0), '2': (2.54, -2.54), '3': (5.08, 0.0), '4': (2.54, 2.54)}


def cyl(d, z0, z1, x=CX, y=CY):
    return cq.Workplane('XY').workplane(offset=z0).center(x, y).circle(d / 2).extrude(z1 - z0)


# ---- can (header flange + tab + header step + cap with chamfer, groove and M5 bore)
flange = cyl(D['header_d'], Z_HDR_BOT, Z_HDR_TOP)
a = math.radians(D['tab_angle_deg'])
tab_len = D['tab_tip_r']
tab = (cq.Workplane('XY').workplane(offset=Z_HDR_BOT)
       .center(CX + math.cos(a) * tab_len / 2, CY + math.sin(a) * tab_len / 2)
       .rect(tab_len, D['tab_w']).extrude(D['header_t'])
       .rotate((CX + math.cos(a) * tab_len / 2, CY + math.sin(a) * tab_len / 2, 0),
               (CX + math.cos(a) * tab_len / 2, CY + math.sin(a) * tab_len / 2, 1), D['tab_angle_deg']))
step = cyl(D['cap_d'], Z_HDR_TOP, Z_CAP_BOT)
cap = cyl(D['cap_d'], Z_CAP_BOT, Z_TOP).faces('>Z').edges().chamfer(D['chamfer'])
groove_r = D['cap_d'] / 2 + D['groove_off']
groove_z = Z_TOP - D['groove_from_top']
torus = cq.Workplane('XY').add(cq.Solid.makeTorus(groove_r, D['groove_d'] / 2,
                                                   pnt=cq.Vector(CX, CY, groove_z), dir=cq.Vector(0, 0, 1)))
cap = cap.cut(torus)
bore = cyl(D['bore_d'], Z_TOP - D['bore_depth'], Z_TOP + 0.1)
cap = cap.cut(bore)
can = flange.union(tab).union(step).union(cap)

# dark optics floor at the bottom of the bore (the window/lens seen from the top; its detail is not drawn)
window = cyl(D['bore_d'] - 0.02, Z_TOP - D['bore_depth'], Z_TOP - D['bore_depth'] + 0.05)

# ---- leads and glass beads
lead_bot = Z_HDR_BOT - D['lead_len_below_header']
leads = None
beads = None
for n, (x, y) in PINS.items():
    l = cyl(D['lead_d'], lead_bot, Z_HDR_TOP - 0.05, x, y)
    b = cyl(D['bead_d'], 0.0, Z_HDR_BOT + 0.01, x, y).cut(cyl(D['lead_d'], -0.1, 0.5, x, y))
    leads = l if leads is None else leads.union(l)
    beads = b if beads is None else beads.union(b)

NICKEL = cq.Color(0.80, 0.80, 0.78)
TIN = cq.Color(0.86, 0.86, 0.85)
GLASS = cq.Color(0.42, 0.52, 0.48)
OPTIC = cq.Color(0.06, 0.06, 0.07)
MARK = cq.Color(0.30, 0.30, 0.32)


def assembly(with_mark):
    asm = cq.Assembly(name='MLX90614ESF-BCI-000-TU')
    asm.add(can, name='body', color=NICKEL)
    asm.add(window, name='window', color=OPTIC)
    asm.add(beads, name='beads', color=GLASS)
    asm.add(leads, name='pins', color=TIN)
    if with_mark:
        asm.add(mark_solid(), name='Adom.LaserEtch', color=MARK)
    return asm


def mark_solid(text='BCI', size=1.6, zc=None):
    """The Melexis device code (Table 20, p.50) on the +Y wall, conforming to the cylinder (30 um proud)."""
    zc = zc if zc is not None else (Z_CAP_BOT + groove_z) / 2 + 2.0
    r0 = D['cap_d'] / 2
    # text lying in an XZ plane, extruded through the wall along +Y
    wp = cq.Workplane(cq.Plane(origin=(CX, CY + r0 - 1.0, zc), xDir=(-1, 0, 0), normal=(0, 1, 0)))
    txt = wp.text(text, size, 2.0, combine=True, halign='center', valign='center', kind='bold')
    shell = cyl(2 * (r0 + 0.03), zc - 3, zc + 3).cut(cyl(2 * r0 - 0.002, zc - 4, zc + 4))
    return txt.intersect(shell)


assembly(False).save(os.path.join(OUT, 'mlx90614esf-bci-000-tu.step'), exportType='STEP')
assembly(True).save(os.path.join(OUT, 'mlx90614esf-bci-000-tu-etched.step'), exportType='STEP')

bb = can.val().BoundingBox()
summary = dict(D, z_header_bottom=Z_HDR_BOT, z_header_top=Z_HDR_TOP, z_cap_bottom=Z_CAP_BOT, z_top=Z_TOP,
               groove_centre_z=groove_z, lead_bottom_z=lead_bot,
               can_bbox_mm=[bb.xmin, bb.ymin, bb.zmin, bb.xmax, bb.ymax, bb.zmax], pins=PINS,
               mark=dict(text='BCI', size_mm=1.6, wall='+Y', proud_mm=0.03))
json.dump(summary, open(os.path.join(OUT, 'dims.json'), 'w'), indent=1)
print('OK', json.dumps(summary['can_bbox_mm']), 'top', Z_TOP, 'leads to', lead_bot)

if os.environ.get('BBOX'):
    for nm, o in (('can', can), ('window', window), ('beads', beads), ('leads', leads), ('mark', mark_solid())):
        b = o.val().BoundingBox(); print(nm, round(b.xmin, 3), round(b.ymin, 3), round(b.zmin, 3), round(b.xmax, 3), round(b.ymax, 3), round(b.zmax, 3))