"""Two-piece case for the ELEGOO ESP-32 dev board (30-pin, USB-C, B0D8T53CQ5). Board: the 30-pin DevKit V1 (ESP32-WROOM-32, USB-C), pins soldered pointing down. The header strips drop into a pocket milled into the rails and the pins drop into trenches below it, so the moulded header locates the board in both directions. Four posts in the lid press on the corner pads, two flexure tabs press the EN and BOOT buttons, and the lid's rim snaps over the base's rim. Run: .venv/bin/python case.py -> out/*.stl (render.py -> out/preview.png) Two bases are written: base_enclosed.stl pins hidden in trenches, a closed box base_pins_out.stl pins poke ~5 mm out the bottom for jumpers or a breadboard and a third piece, pin_cover.stl, snaps onto the bottom of the pins-out base to close it up when the pins are not plugged into anything. """ import os import numpy as np from manifold3d import Manifold, CrossSection, JoinType # ---- board (X along the length, USB at -X) # The cavity is deliberately cut for the largest of these boards (ELEGOO's # drawing says 51.74 x 29.0; a measured DOIT-pattern board is nearer 51.4 x # 28.3), because the header pocket, not the walls, is what holds the board. # Across the rows, that is. Along them the cavity ends are the closer stop, # 0.15 to 0.32 mm an end against the pocket's 0.8, which is what lets the # pocket be generous lengthwise without letting the board wander. BL, BW, BT = 51.74, 29.0, 1.6 # length, width, PCB thickness ROW = 25.40 / 2 # header rows are 1.00 in apart, not 25.64 HDR_PLASTIC = 2.5 # header plastic under the PCB PIN_BELOW = 6.0 # pin length below the plastic COMP_H = 3.4 # tallest part above PCB (module, USB-C) BTN_X, BTN_Y = 2.5, 7.5 # buttons: from USB end, +/- from centre BTN_TOP = 2.0 # button actuator height above PCB HOLE_IN = 2.3 # corner hole centres from each edge PL_L, PL_W = 15 * 2.54, 2.54 # header strip: 15 positions, moulded plastic USB_OVERHANG = 1.5 # USB-C shell sticks past the board edge # ---- case CLR = 0.15 # board-to-wall clearance (0.3 rattled end to end) WALL = 2.5 # was 2.0, which left the lid's skirt one bead thick behind # the groove. The wall is what pays for a deep snap: the # skirt is WALL - RIM_T - FIT, and the groove eats the # interference out of it TOP = 1.6 # lid plate FLOOR = 1.6 # enclosed base floor under the trenches PLATE = 1.0 # pins-out base: a thin bottom plate, the strips go through it SLOT_W = 1.8 # pin trench width, enclosed base only. It locates nothing # now, so it is wide enough to forgive a row spacing that is # not quite an inch as well as a pin that leans LEAD = 0.35 # lead-in step at the mouth of every trench and pocket PIN_AIR = 1.2 # air under the pin tips, so a long pin cannot hold a board up # Everything below came back loose off the printer at the last set of numbers, # in the same direction every time, which is a printer running under size on a # fit rather than three separate mistakes. So the clearances came down and the # interferences went up together. The honest way to set these is to run the # Tolerance Kit at /makerspace/tolerance/ and use its press/slip numbers. RIM = 2.0 # rim overlap height RIM_AIR = 0.4 # air over the rim, so a lid always lands on the split line FIT = 0.05 # rim clearance per side, was 0.15 and slid too easily RIM_T = 0.85 # rim thickness, no longer derived from FIT. It is what sets # the lid's wall over the groove (WALL - RIM_T - FIT), and that # wall is the ceiling on how deep the groove, and so how big # the interference, is allowed to get SNAP = 0.80 # lid bump, proud of the rim. 0.75 mm of interference into a SNAP_R = 0.95 # 0.75 groove, with 0.85 mm of lid skirt left behind it FOOT = 0.4 # break on the outer bottom edge, against elephant's foot R_OUT = 3.0 # outer corner radius PLUG_W, PLUG_H, PLUG_R = 10.6, 4.6, 1.5 # USB opening, sized to the socket FLARE = 1.2 # 45 degree mouth outside it, for the cable # moulding, which now has 1.15 mm of wall to cross HEAD = 0.9 # lid ceiling clearance above COMP_H CLAMP = 0.1 # lid posts press this far into the board (a 0.1 gap rattled) POCKET = 0.8 # depth of the header pocket in the rails PL_CLR = 0.45 # clearance from a strip's INNER face to the island between # the rows. That island is now the only thing locating the # board across the rows, and it is all that is needed: the # strips sit outside it, so either one stops the board moving # the other way SLOT_X = 23.0 # half the length of the channel each strip drops into. The # strips are NOT centred on this board: they sit 2.3 mm toward # the far end, measured off a photo against the 2.54 mm pad # pitch, which is what kept the board from dropping in. A # channel this long swallows +/- 3.9 mm of that, so the number # does not have to be right COVER_FLOOR = 1.6 PIN_CLR = 1.0 # air under the pin tips inside the cover STEPS = 6 # slices in a 45 degree bevel SHELL = 15.0 # a female Dupont jumper end, hanging off a pin, top to bottom EXIT_W = 34.0 # the side wire exit, about the length of a header strip EXIT_H = 5.0 EXIT_R = 2.0 PORT_D = 12.0 # the end wire exit: one round hole for a whole bundle GROM_FIT = 0.30 # grommet barrel, under the ribs, below the hole per side GROM_RIB = 0.70 # and the crush ribs on it, 0.40 oversize. They were 0.4 proud GROM_RR = 1.0 # for 0.1, and it fell straight back out: too little grip, and # a 0.4 mm rib is about one bead wide, which is the same reason # the old snap bumps never printed at all GROM_FLANGE = 1.8 GROM_LIP = 1.2 # flange margin over the point, where there is less room GROM_FR = 4.5 # corner radius of the flange, which is a rounded square like # everything else on the case rather than a plain washer GROM_FT = 1.2 # flange thickness GROM_IN = 3.0 # how far the tunnel carries on inside the wall GROM_CH = 0.6 # chamfer at each end of the bore, so wires do not chafe GROM_PILOT = 1.5 # bare barrel at the tip, so it finds the hole before the ribs # start biting and the push is progressive rather than a wall BORES = (4.0, 6.0, 8.0) IL, IW = BL + 2 * CLR, BW + 2 * CLR OL, OW = IL + 2 * WALL, IW + 2 * WALL SEG = 64 def rrect(l, w, r, seg=SEG): r = min(r, l / 2 - 0.01, w / 2 - 0.01) if r <= 0: return CrossSection.square((l, w), center=True) return CrossSection.square((l - 2 * r, w - 2 * r), center=True).offset( r, JoinType.Round, circular_segments=seg) def slab(l, w, r, z0, z1, x=0.0, y=0.0, seg=SEG): return rrect(l, w, r, seg).extrude(z1 - z0).translate((x, y, z0)) def bevel(l, w, r, z, h, up=True, n=STEPS): """A 45 degree break on an outer edge, in steps thinner than a layer. Manifold only scales a cross-section and a rounded rectangle needs a uniform offset, so there is no true cone to be had; sliced at 0.2 mm these steps are a ramp. Used on every face that goes down on the bed, because the first layers squash out past the model and on the pins-out base that lands exactly where the cover has to slide over it. """ m = Manifold() for i in range(n): o = h * i / n m += slab(l - 2 * (h - o), w - 2 * (h - o), r - (h - o), z + o if up else z - h, z + h if up else z - o, seg=32) return m def box(x0, x1, y0, y1, z0, z1): return Manifold.cube((x1 - x0, y1 - y0, z1 - z0)).translate((x0, y0, z0)) def cyl(r, z0, z1, x, y): return Manifold.cylinder(z1 - z0, r, circular_segments=SEG).translate((x, y, z0)) def usb_cut(zc): """Opening for the USB-C socket, with a 45 degree mouth on the outside. The socket's face sits 0.65 mm inside the wall, so a cable's moulding has to get that far in, and sizing the whole hole for the moulding is what left a gap all round the socket. Sizing the throat for the socket and flaring only the outer millimetre gives the moulding the same room it had and the port a collar instead of a slot. """ m = rrect(PLUG_H, PLUG_W, PLUG_R).extrude(WALL + 4).rotate((0, 90, 0)) m = m.translate((-OL / 2 - 2, 0, zc)) for i in range(STEPS): g = FLARE * (1 - i / STEPS) # how much wider, at this depth d = FLARE * (i + 1) / STEPS p = rrect(PLUG_H + 2 * g, PLUG_W + 2 * g, PLUG_R + g, 32) m += p.extrude(d + 2).rotate((0, 90, 0)).translate((-OL / 2 - 2, 0, zc)) return m def teardrop_profile(d): """A circle with a 45 degree roof on it, apex at +y.""" r = d / 2 a = np.linspace(np.radians(135), np.radians(405), 25) pts = [(r * np.cos(t), r * np.sin(t)) for t in a] + [(0.0, r * np.sqrt(2))] return CrossSection([pts]) def teardrop(d, depth): """A round hole with a 45 degree roof, centred on the origin, axis along Y. A 12 mm hole printed on its side droops at the crown and prints out of round; the roof is self-supporting and a cable does not care. """ m = teardrop_profile(d).extrude(depth).rotate((90, 0, 0)) return m.translate((0, depth / 2, 0)) def grommet(bore): """A press-fit tunnel for the end hole, to neck it down to the wires you have. The hole is cut for a whole bundle and most of the time you have three wires. Six crush ribs do the gripping rather than the whole barrel: a rib 0.1 mm oversize squashes where a barrel 0.1 mm oversize simply will not go in, and a printed hole is never quite the size it was drawn. The outside is the same teardrop as the hole, so it fills the point instead of leaving it open and it only goes in one way up. The flange is not a teardrop, though. The teardrop is only there because a 12 mm hole printed on its side droops at the crown, and the flange prints flat on the bed where nothing droops, so there is no reason for the part you can see to be shaped like the part you cannot. It is a rounded rectangle, to match every other outline on the case, sized and nudged up so it covers the hole's point outright rather than leaving it filled-but- visible: a teardrop is 1.24 mm taller above its centre than below, so the flange is offset by exactly that and made tall enough to swallow it. Modelled with the flange at z = 0 and the barrel going up, which is how it prints: bore vertical, ribs vertical, nothing over air but the 45 degree countersinks at each end of the bore. """ z0, z1 = GROM_FT, GROM_FT + WALL + GROM_IN prof = teardrop_profile(PORT_D - 2 * GROM_FIT).rotate(-90) # apex to +x r = PORT_D / 2 off = r * (np.sqrt(2) - 1) / 2 # the teardrop's own centre, up fh = r * (1 + np.sqrt(2)) + 2 * GROM_LIP # tall enough for the point fw = PORT_D + 2 * GROM_FLANGE m = slab(fh, fw, GROM_FR, 0, GROM_FT, x=off) m += prof.extrude(z1 - z0).translate((0, 0, z0)) rb = (PORT_D - 2 * GROM_FIT) / 2 rr = GROM_RR for deg in (60, 110, 160, 200, 250, 300): # clear of the apex t = np.radians(deg) d0 = rb + GROM_RIB - rr m += Manifold.cylinder(z1 - z0 - GROM_PILOT, rr, circular_segments=16).translate( (d0 * np.cos(t), d0 * np.sin(t), z0)) m -= Manifold.cylinder(z1 + 2, bore / 2, circular_segments=48).translate((0, 0, -1)) m -= Manifold.cylinder(GROM_CH, bore / 2 + GROM_CH, bore / 2, 48) m -= Manifold.cylinder(GROM_CH, bore / 2, bore / 2 + GROM_CH, 48).translate( (0, 0, z1 - GROM_CH)) return m, z1 def levels(pins_out): """Where everything sits above the outside of the floor. The two bases carry the board in different ways. The enclosed one rests the moulded header strips in a pocket, with the pins in blind trenches below. The pins-out one lets the strips drop clean through the floor, so its underside lands level with the bottom of the header and the whole length of every pin is outside the case for a jumper to grip. What holds the board up there is the PCB itself, sitting on the rails. """ if pins_out: seat = 0.0 # the strips pass right through F = HDR_PLASTIC # so the PCB lands on the rails else: F = FLOOR + PIN_BELOW + POCKET + PIN_AIR # rail top seat = F - POCKET # the header plastic rests here pcb_bot = seat + HDR_PLASTIC pcb_top = pcb_bot + BT return dict(F=F, seat=seat, pcb_top=pcb_top, split=pcb_top + 1.0, ceil=pcb_top + COMP_H + HEAD, usb_z=pcb_top + 1.6) def outer_shell(split, pins_out): """The outside of the base: a straight wall, bevelled where it meets the bed. It used to step inward over the bottom SEAM millimetres so the cover could wrap that step, which cost a 45 degree chamfer to climb back out of, left a reveal at the seam, thinned the wall to 0.95 mm exactly where the cover gripped it, and had to dodge the USB mouth. The cover carries a rim now and the base has a socket for it, so none of that is needed. """ return bevel(OL, OW, R_OUT, 0, FOOT) + slab(OL, OW, R_OUT, FOOT, split) def rim_ring(z0, z1, grow=0.0): """The rim's own outline, as a ring: inner face on the cavity, outer on IL.""" t = RIM_T + grow return (slab(IL + 2 * t, IW + 2 * t, R_OUT - WALL + t, z0, z1) - slab(IL, IW, 0.5, z0 - 1, z1 + 1)) def base(pins_out): L = levels(pins_out) F, split, usb_z = L["F"], L["split"], L["usb_z"] outer = outer_shell(split, pins_out) # rim: inner part of the wall rises RIM above the split rim_t = RIM_T rim = slab(IL + 2 * rim_t, IW + 2 * rim_t, R_OUT - WALL + rim_t, split, split + RIM) body = outer + rim if pins_out: # and the same joint again at the bottom, the other way up: a socket # for the cover's rim, so the two seams are the same thing mirrored body -= rim_ring(-1, RIM + RIM_AIR, FIT) body -= slab(IL, IW, 0.5, F, split + RIM + 1) # cavity above the rest level body -= slab(IL, IW - 2 * 4.6, 0.5, PLATE if pins_out else FLOOR, F + 1) # hollow the middle if not pins_out: for s in (-1, 1): # blind trenches for the pins body -= box(-SLOT_X, SLOT_X, s * ROW - SLOT_W / 2, s * ROW + SLOT_W / 2, FLOOR, F + 1) # A channel for each moulded header strip, running out to the wall and most # of the length of the board. It was a pocket cut to the size of the strip # and centred, which was wrong twice over: the strips are 2.3 mm off centre # on this board, and their outer face is only 0.68 mm from the wall, so # there was never room for a pocket wall out there anyway. What is left is # the island between the rows, and that alone holds the board straight. isl = ROW - PL_W / 2 - PL_CLR for s in (-1, 1): # out to the cavity wall and not a micron further: past that is the # wall itself, and a channel cut through it takes the snap bump with it y0, y1 = sorted((s * isl, s * IW / 2)) body -= box(-SLOT_X, SLOT_X, y0, y1, -1 if pins_out else L["seat"], F + 1) y0, y1 = sorted((s * (isl - LEAD), s * IW / 2)) # a mouth to find body -= box(-SLOT_X - LEAD, SLOT_X + LEAD, y0, y1, F - LEAD, F + 1) # snap bumps on the long sides of the rim, and at the bottom the groove # that the cover's bump sits in: the same numbers at both ends of the case yb = IW / 2 + rim_t for s in (-1, 1): bump = Manifold.cylinder(18, SNAP_R, circular_segments=32, center=True).rotate((0, 90, 0)) body += bump.translate((0, s * (yb + SNAP - SNAP_R), split + RIM / 2)) if pins_out: g = Manifold.cylinder(20, SNAP_R + 0.05, circular_segments=32, center=True).rotate((0, 90, 0)) body -= g.translate((0, s * (yb + SNAP - SNAP_R), RIM / 2)) body -= usb_cut(usb_z) return body, L def lid(L): split, ceil, pcb_top, usb_z = L["split"], L["ceil"], L["pcb_top"], L["usb_z"] top = ceil + TOP # the lid prints upside down, so its own top face is what goes on the bed body = slab(OL, OW, R_OUT, split, top - FOOT) + bevel(OL, OW, R_OUT, top, FOOT, up=False) rim_t = RIM_T # clear the inside, and the base's rim (plus fit) at the bottom. RIM_AIR # over the rim matters: with the relief stopping exactly at the rim's top # face, a lid could land on the rim instead of closing on the split line, # which holds it proud and takes the snap out of reach. body -= slab(IL, IW, 0.5, split - 1, ceil) body -= slab(IL + 2 * (rim_t + FIT), IW + 2 * (rim_t + FIT), R_OUT - WALL + rim_t + FIT, split - 1, split + RIM + RIM_AIR) # snap grooves yb = IW / 2 + rim_t for s in (-1, 1): g = Manifold.cylinder(20, SNAP_R + 0.05, circular_segments=32, center=True).rotate((0, 90, 0)) body -= g.translate((0, s * (yb + SNAP - SNAP_R), split + RIM / 2)) # corner posts clamp the board down around the mounting holes for sx in (-1, 1): for sy in (-1, 1): x = sx * (BL / 2 - HOLE_IN) y = sy * (BW / 2 - HOLE_IN) body += cyl(2.0, pcb_top - CLAMP, ceil + 0.01, x, y) # flexure buttons over EN and BOOT tab_w, tab_l, gap = 5.0, 9.5, 0.6 bx = -BL / 2 + BTN_X for s in (-1, 1): yc = s * BTN_Y xa = bx - 1.8 # free end xb = xa + tab_l # hinge u = (box(xa - gap, xb, yc - tab_w / 2 - gap, yc + tab_w / 2 + gap, ceil - 1, top + 1) - box(xa, xb + 1, yc - tab_w / 2, yc + tab_w / 2, ceil - 2, top + 2)) body -= u # thin the hinge from the underside (faces up when printing, no bridging) body -= box(xb - 2.5, xb, yc - tab_w / 2, yc + tab_w / 2, ceil - 1, ceil + 0.6) body += cyl(1.1, pcb_top + BTN_TOP + 0.4, ceil + 0.01, bx, yc) # nub # vents over the far half for i in range(5): x = 4 + i * 3.2 body -= box(x - 0.75, x + 0.75, -7, 7, ceil - 1, top + 1) body -= usb_cut(usb_z) return body, top def side_exits(z_top, z_bot): """One opening a side, the length of a header strip, for the wires to leave. A hole per pin would look tidier and be useless: 2.2 mm holes on the 2.54 mm pin pitch leave a third of a millimetre of wall between them, and a bundle of jumpers does not arrive one wire at a time anyway. This is 34 x 5 mm, about 165 mm2 a side, and it runs down to the floor so a wire can lie flat and slide straight out. The roof is a 30 mm bridge across 2 mm of wall, which prints without support. """ prof = rrect(EXIT_W, z_top - z_bot, EXIT_R) # a (x, z) profile m = prof.extrude(OW + 4).rotate((90, 0, 0)) # extrude across the case return m.translate((0, (OW + 4) / 2, (z_top + z_bot) / 2)) def end_exit(zc): """The other wired bottom: one round hole in the far end, for a bundle. Nothing hangs off the pins out at the ends of the box, so this one does not have to wait until it is below the jumper shells, and the part comes out 3 mm shorter than the side-slot version. Away from the USB end, so a bundle is not fighting a cable. """ return teardrop(PORT_D, WALL + 6).rotate((0, 0, 90)).translate( (OL / 2 - WALL + 1, 0, zc)) def pin_cover(L, wired=False): """Third piece: a floor that snaps onto the bottom of the pins-out base. The pins-out base is open underneath so jumpers can reach the header, which also means bare pins on the desk. This closes it, and the case is then the same box as the enclosed one, only with a floor you can pull off. It is the lid's joint mirrored: the cover carries a rim, the base has a socket for it, and the two butt flush at the seam exactly as the lid and base do at the split. Same RIM, RIM_T, FIT, SNAP and groove as the top, so both ends of the case feel the same. The rim is on the cover rather than the base only because of which way each part prints: it rises off the cover's top face, where nothing overhangs, while a rim hanging off the base's underside would have left the whole wall above it printing in air. Printed as it is modelled, open side up, there is nothing in it to overhang. `wired` is the same part made deep enough to swallow a row of jumper ends. "side" puts a wide slot down each long side, level with the header rows; "end" puts one round hole in the far end for a whole bundle. Either way the height is set by the jumper shells, not by anything on the board: SHELL millimetres of Dupont hang off pins that only stick out five. The side slots have to start below the shells, the end hole does not, so the end version is the shorter of the two. Change SHELL and rebuild for shorter jumpers. """ rim_t = RIM_T pin_z = L["seat"] - PIN_BELOW # how far the pins hang below zero if wired == "side": exit_top = -(SHELL + 0.8) # the slot starts below the shells inner = exit_top - EXIT_H # and runs right down to the floor elif wired == "end": inner = -(SHELL + 3.0) # room for the bundle to turn exit_top = inner + 1.5 + PORT_D / 2 # the hole's centre, low enough # that the flange over its point # still clears the wrap else: exit_top = None inner = pin_z - PIN_CLR # floor inside the cover bot = inner - COVER_FLOOR body = (bevel(OL, OW, R_OUT, bot, FOOT) + slab(OL, OW, R_OUT, bot + FOOT, 0) # flush to the seam + rim_ring(0, RIM)) # and the rim above it body -= slab(IL, IW, 0.5, inner, RIM + 1) # the inside, rim included yb = IW / 2 + rim_t for sgn in (-1, 1): # the bump, as on the base bump = Manifold.cylinder(18, SNAP_R, circular_segments=32, center=True).rotate((0, 90, 0)) body += bump.translate((0, sgn * (yb + SNAP - SNAP_R), RIM / 2)) if wired == "side": body -= side_exits(exit_top, inner) elif wired == "end": body -= end_exit(exit_top) return body, bot def fit_frame(L): """A short test print: the board seat and the rim, nothing else. Twenty minutes on the bed answers the three things that can be wrong before committing to the long print: does the board drop into the header pocket, do the pins clear the trenches, and does the lid snap on with the right grip. The lid itself is small enough to print whole for that last one, so there is no cut-down version of it; a ring sliced off the lid would be a 1 mm skirt and would flex nothing like the real thing. """ b, _ = base(False) z0 = L["seat"] - 2.0 frame = b.trim_by_plane((0, 0, 1), z0).trim_by_plane((0, 0, -1), -(L["split"] + RIM)) return frame.translate((0, 0, -z0)) def save(m, path): mesh = m.to_mesh() v = np.asarray(mesh.vert_properties)[:, :3] f = np.asarray(mesh.tri_verts) import trimesh trimesh.Trimesh(v, f, process=False).export(path) return v, f def main(): here = os.path.dirname(os.path.abspath(__file__)) out = os.path.join(here, "out") os.makedirs(out, exist_ok=True) for name, po in (("base_enclosed", False), ("base_pins_out", True)): b, L = base(po) save(b, os.path.join(out, name + ".stl")) lb, top = lid(L) # the lid is the same for both bases; print it upside down, top on the bed save(lb.rotate((180, 0, 0)).translate((0, 0, top)), os.path.join(out, "lid.stl")) print(f"{name}: {OL:.1f} x {OW:.1f} mm, closed height {top:.1f} mm, " f"volume base {b.volume()/1000:.1f} cm3 lid {lb.volume()/1000:.1f} cm3, " f"genus {b.genus()}/{lb.genus()}") if po: for tag, wired in (("pin_cover", None), ("pin_cover_wired", "side"), ("pin_cover_end", "end")): c, bot = pin_cover(L, wired) # printed sitting on its own floor, so shift the modelled part up save(c.translate((0, 0, -bot)), os.path.join(out, tag + ".stl")) print(f"{tag}: {RIM - bot:.1f} mm tall, hangs {-bot:.1f} mm below " f"the base, stack {top - bot:.1f} mm, volume {c.volume()/1000:.1f} cm3, " f"genus {c.genus()}") for bore in BORES: g, h = grommet(bore) save(g, os.path.join(out, "grommet_%g.stl" % bore)) print(f"grommet {bore:g} mm bore: {h:.1f} mm tall, " f"barrel {PORT_D - 2*GROM_FIT + 2*GROM_RIB:.2f} over ribs into a " f"{PORT_D:.1f} hole, volume {g.volume()/1000:.2f} cm3, genus {g.genus()}") frame = fit_frame(levels(False)) save(frame, os.path.join(out, "fit_frame.stl")) print(f"fit check: frame {frame.volume()/1000:.1f} cm3, " f"{frame.bounding_box()[5]-frame.bounding_box()[2]:.1f} mm tall") if __name__ == "__main__": main()