Snap-Fit PCB Case
A screwless enclosure for a small circuit board, sized by default for a 50 x 70 mm board with 25 mm of inside height. The lid has four cantilever snap arms whose ramped hooks spring into windows in the base walls, so it clicks shut by hand; the arms are sized for about 1.2 % strain at full deflection, which is gentle enough for repeated opening in PLA or PETG. To open it, press the hooks in through the windows with a small screwdriver or lever the lid up at the pry slot in the rim. The board mounts on four standoffs with M2.5 or M3 self-tapping screws, and a rounded opening in one end wall gives access to a USB connector. Closed, the case measures 60 x 80 x 29 mm; the base (60 x 80 x 27 mm) and the lid (60 x 80 x 19.1 mm) print side by side, base open side up and lid inside face up, with no supports. You can change the inner width, length and height, wall thickness, screw size, standoff spacing and height, the USB opening size and position (or turn it off), and the snap arm length, thickness, width and hook depth.
# Snap-Fit Case - screwless PCB enclosure with cantilever snaps
#
# A two-part box for a small circuit board. The lid carries four cantilever
# snap arms that hang down inside the long walls of the base. Pressing the lid
# on, the ramped hooks push the arms inward; when the lid seats on the rim the
# hooks spring out into windows in the base walls and lock it - no screws.
# To open: push the hooks in through the windows with a small screwdriver tip,
# or lever the lid up at the pry slot in the rim opposite the USB end.
#
# The board screws to four standoffs with self-tapping M2.5 or M3 screws.
# A rounded cutout in the -Y end wall gives access to a USB connector.
#
# Snap arm strain (rectangular cantilever, fixed at the lid):
# strain = 1.5 * t * y / L^2
# t = arm thickness, L = arm length (lid to hook catch), y = deflection = hook depth
# 1.5 * 1.6 * 1.0 / 14^2 = 1.2 %, under the 1.5 % kept for repeated use in PLA / PETG.
#
# Print layout: base open side up on the left, lid inside face up on the right
# (arms pointing up). No supports. The only flat overhang is the 1.2 mm hook
# ledge at the top of each arm; window and USB tops are short bridges (< 10 mm).
#
# Axes: X = case width, Y = case length (USB on -Y), Z = up, bed at z = 0.
# --- Case ---
@param 30..150 group:"Case" desc:"Inner width X (mm)"
inner_w = 56
@param 30..200 group:"Case" desc:"Inner length Y (mm)"
inner_d = 76
@param 15..60 group:"Case" desc:"Inner height, floor to lid (mm)"
inner_h = 25
@param 1.6..4 step:0.1 group:"Case" desc:"Wall thickness (mm)"
wall = 2.0
@param 1.6..4 step:0.1 group:"Case" desc:"Floor and lid thickness (mm)"
floor_t = 2.0
corner_r = 3 # outer corner radius
fit = 0.2 # sliding fit clearance per side (lid lip, arms)
# --- Board standoffs ---
@param choices:["M2.5", "M3"] group:"Board" desc:"Self-tapping screw size"
screw = "M2.5"
@param 10..140 group:"Board" desc:"Mounting hole spacing X, center to center (mm)"
hole_dx = 43
@param 10..190 group:"Board" desc:"Mounting hole spacing Y, center to center (mm)"
hole_dy = 63
@param -30..30 group:"Board" desc:"Hole pattern offset along Y (mm)"
board_y = 0
@param 3..15 group:"Board" desc:"Standoff height above the floor (mm)"
standoff_h = 5
board_t = 1.6 # PCB thickness (for the USB height)
# Self-tapping pilot = about 0.8 x screw size, +0.1 for print shrinkage
pilot_d = if screw == "M3" then 2.5 else 2.1
boss_d = if screw == "M3" then 7 else 6
# --- USB cutout (-Y end wall) ---
@param group:"USB" desc:"Cut a USB opening in the -Y end wall"
usb = true
@param 6..20 step:0.5 group:"USB" desc:"USB opening width (mm)"
usb_w = 12
@param 3..12 step:0.5 group:"USB" desc:"USB opening height (mm)"
usb_h = 7
@param -25..25 group:"USB" desc:"USB opening offset along X (mm)"
usb_x = 0
# --- Snap arms (on the lid) ---
@param 8..25 group:"Snap" desc:"Arm length, lid to hook catch (mm)"
arm_l = 14
@param 1.2..2.4 step:0.1 group:"Snap" desc:"Arm thickness (mm)"
arm_t = 1.6
@param 5..15 group:"Snap" desc:"Arm width (mm)"
arm_w = 8
@param 0.6..1.6 step:0.1 group:"Snap" desc:"Hook depth into the wall window (mm)"
hook_d = 1.0
hook_land = 0.6 # vertical flat on the hook face
hook_ramp = 2.5 # lead-in ramp height (about 26 deg from vertical)
win_gap = 0.3 # window clearance around the hook
lip_h = 3 # locating lip depth under the lid
lip_t = 1.2
# --- Derived ---
ow = inner_w + 2 * wall
od = inner_d + 2 * wall
base_h = floor_t + inner_h
xi = inner_w / 2 # inner wall face (+X side)
xa = xi - fit # outer face of the arm
hook_p = hook_d + fit # hook protrusion from the arm face
zc = base_h - arm_l # hook catch height (closed)
z_tip = zc - hook_land - hook_ramp # bottom of the arm
arm_y = [-inner_d / 4, inner_d / 4] # two arms on each long side
# corner index i in 0..3 -> X sign and Y sign
def sx(i) = if i % 2 == 0 then 1 else -1
def sy(i) = if i < 2 then 1 else -1
strain = 1.5 * arm_t * hook_d / arm_l ** 2
# --- Base ---
tub = box ow od base_h
| floor
| edges =Z | fillet corner_r
| faces >Z | shell wall
# Standoffs stand on the floor top (z = floor_t); 1 mm sunk into the floor to fuse
hx = hole_dx / 2
hy = hole_dy / 2
bosses = union [cylinder (boss_d / 2) (standoff_h + 1) | floor | translate (sx(i) * hx) (sy(i) * hy + board_y) (floor_t - 1) for i in range(4)]
pilots = union [cylinder (pilot_d / 2) standoff_h | floor | translate (sx(i) * hx) (sy(i) * hy + board_y) floor_t for i in range(4)]
# Windows for the hooks: hook catch sits 0.15 mm below the window top
win_h = hook_land + hook_ramp + 0.15
win_one = box (wall + 2) (arm_w + 2 * win_gap) win_h | floor
windows = union [win_one | translate (sx(i) * (xi + wall / 2)) (sy(i) * inner_d / 4) (zc + 0.15 - win_h) for i in range(4)]
# USB: rounded slot centred on the connector height (connector top on the board)
usb_z = floor_t + standoff_h + board_t + usb_h / 2 - 0.5
usb_cut = box usb_w (wall + 2) usb_h | edges =Y | fillet 2 | translate usb_x (-od / 2) usb_z
# Pry slot in the +Y rim: a screwdriver tip fits under the lid edge
pry = box 10 (wall + 2) 1.5 | floor | translate 0 (od / 2) (base_h - 1.5)
base_solid = tub + bosses - pilots - pry
base_cut = base_solid - windows
base = if usb then base_cut - usb_cut else base_cut
# --- Lid (built in the closed position) ---
plate = box ow od floor_t | floor | edges =Z | fillet corner_r | translate 0 0 base_h
# Locating lip, notched where the arms pass
lip_out = box (inner_w - 2 * fit) (inner_d - 2 * fit) lip_h | floor | edges =Z | fillet 1.5 # tub inner corners are R1
lip_in = box (inner_w - 2 * fit - 2 * lip_t) (inner_d - 2 * fit - 2 * lip_t) (lip_h + 2) | floor | translate 0 0 -1
notch = union [box 6 (arm_w + 2) (lip_h + 2) | floor | translate (sx(i) * xi) (sy(i) * inner_d / 4) -1 for i in range(4)]
lip = (lip_out - lip_in - notch) | translate 0 0 (base_h - lip_h)
# One arm on the +X side: slab plus hook (XZ profile, extruded along Y)
slab = box arm_t arm_w (base_h + 0.5 - z_tip) | floor | translate (xa - arm_t / 2) 0 z_tip
hook = workplane XZ
| polyline [(xa - 0.5, zc), (xa + hook_p, zc), (xa + hook_p, zc - hook_land), (xa, z_tip), (xa - 0.5, z_tip)]
| extrude arm_w
| translate 0 (-arm_w / 2) 0
arm_right = slab + hook
arm_left = arm_right | mirror "X"
arms_r = union [(arm_right | translate 0 y 0) for y in arm_y]
arms_l = union [(arm_left | translate 0 y 0) for y in arm_y]
arms = arms_r + arms_l
lid_closed = plate + lip + arms
# --- Fit checks (closed position) ---
assert strain <= 0.015 # snap arm strain at full deflection
assert base.solids == 1
assert lid_closed.solids == 1
assert clear(lid_closed, base) # nothing collides: hooks sit in the windows
engage = lid_closed * base_solid # hooks vs the wall before the windows are cut
assert engage.solids == 4
engage_r = engage * (box 20 od 100 | translate (xi + 10) 0 0) # +X side only
print "hook engagement into wall (mm)" engage_r.bbox.x
assert engage_r.bbox.x ~ hook_d tol:0.01
assert z_tip > floor_t + standoff_h + board_t + 1 # arms end above the board
closed = base + lid_closed
assert closed.bbox.x ~ ow
assert closed.bbox.y ~ od
assert closed.bbox.z ~ base_h + floor_t
print "closed size" closed.bbox.x closed.bbox.y closed.bbox.z
print "arm strain (%)" strain * 100
# --- Print layout: lid flipped inside face up, beside the base ---
lid_print = lid_closed | rotate 0 180 0 | floor | translate (ow + 10) 0 0
assert clear(base, lid_print)
print "base size" base.bbox.x base.bbox.y base.bbox.z
print "lid print size" lid_print.bbox.x lid_print.bbox.y lid_print.bbox.z
print "hook engagement volume (mm3, 4 hooks)" engage.volume
out = base + lid_print
assert out.solids == 2
out