Embedded HandbookApps
All sections › XXVI. From prototype to product
STM32F103 · level intermediate · 17 min read
Open in the interactive handbook

204а. Device enclosure: from board to enclosure, tools, checks before printing

The board from section 199 is assembled, flashed and tested, and it lies bare on the desk. It becomes a device inside an enclosure: the enclosure holds the board, lets connectors and light out, and protects against fingers, dust and water. This section shows how to get an enclosure from the same board model without redrawing dimensions by hand: KiCad exports the board, a script builds the enclosure from it and checks that everything fits, and only then does the enclosure go to the printer.

The whole path was run on the board from 199 (F103 in LQFP-48, a SOT-223 regulator, a crystal, two pin headers, an LED): kicad-cli 9.0.9 exported the board to STEP, a CadQuery 2.8.0 script read the board file, found places for the screws, built the base and the lid, checked intersections and exported STL. Part heights are a table in the script: the KiCad image has no solid models of the parts, and for all parts except the LQFP-48 these are assumptions.

What it is called in the documentation

Original Meaning
enclosure, housing the case around the device
standoff, boss a post that holds the board, a post for a screw
mounting hole a hole for fastening the board
courtyard (F.CrtYd) the outline occupied by a part on the board
STEP exchange format for solid models between CAD tools
STL a triangle mesh for the slicer and printing
slicer slicer: cuts the model into layers for the printer
clearance gap between parts
ingress protection (IP) protection rating of the enclosure
light pipe a light guide from an LED to a window in the enclosure

204а.1 Off-the-shelf, printed or custom

Option When What it costs you
off-the-shelf enclosure from a catalog the board is designed for it: dimensions and standoffs follow the enclosure drawing the board is drawn to fit the enclosure, not the other way round
FDM printing tens of units, prototypes, custom shapes print time, visible layers, plastic softens with heat
resin printing (SLA) small details, smooth surface brittleness, post-processing
milling, injection molding series production, metal, strength tooling and lead time; molding pays off at thousands of units

The order of decisions is the reverse of what people usually do. First you choose the enclosure, or at least its outer dimensions and screw positions, and only then route the board. An off-the-shelf enclosure from a catalog is almost always cheaper and stronger than a printed one; a custom one is needed when the shape is dictated by the product.


204а.2 What the enclosure takes from the board

The enclosure needs five things, and all of them are already in the board file:

What Where from On the board from 199
outline and thickness the Edge.Cuts layer, (thickness …) 45 × 32 mm, 1.6 mm
where each part sits the F.CrtYd outline of each part 15 parts
height above the board solid models of the parts a table of assumptions; LQFP-48 is 1.6 mm from the F103 datasheet
what goes outside connectors, LEDs, buttons J1, J2 are pin headers; D1 is an LED
how it is held mounting holes there are none: the script finds a place (204а.4)

Redrawing this by hand in CAD is a source of errors on every board revision. The right way is to read it from the file: the board changes, the script rebuilds the enclosure.


204а.3 Tools

Tool What it does in this chain License
KiCad, kicad-cli pcb export step exports the board (and the parts, if their models exist) to STEP GPL
CadQuery (OpenCascade kernel) the enclosure as a Python program: parameters, checks, export Apache 2.0
FreeCAD + KiCad StepUp the same with the mouse: open the board STEP, draw the enclosure around it LGPL
OpenSCAD the enclosure as text in its own language; convenient for simple boxes GPL
Fusion, Onshape cloud CAD; convenient to draw, inconvenient to repeat commercial
trimesh checking STL meshes: volume, watertightness MIT
slicer (PrusaSlicer, Cura, OrcaSlicer) STL → layers → G-code for the printer open source

CadQuery is chosen here: the enclosure is described by a program, like everything in this handbook, so it can be rebuilt for a new board and checked by the same run. FreeCAD is good when a complex enclosure shape is easier to draw than to describe; it opens the board STEP just as well.

Exporting the board is one command in the kicad/kicad:9.0 image:

kicad-cli pcb export step --subst-models -f -o board.step board.kicad_pcb

CadQuery read this STEP: the board is 45.00 × 32.00 × 1.51 mm. Not 1.6: only the board substrate went into the STEP, that is, the total thickness of 1.6 mm minus copper and solder mask on both sides, (0.035 + 0.01) × 2 = 0.09 mm. The enclosure is calculated from the full thickness in the board file, not from the STEP.


204а.4 Mounting holes go in before routing, not after

The board from 199 has no mounting holes. The script looks for a place for an M2 screw (a ⌀ 2.2 mm hole plus another 0.5 mm of margin to any part) near each corner: the point closest to the corner that is no closer than 3 mm to the board edge and does not touch the outline of any part.

A 45 by 32 mm board with part outlines: the right corners are free, so the holes sit 3 mm from the edges; the top left corner is taken by crystal Y1, the bottom left by pin header J2 and regulator U2, so those holes are moved into the board by 9.1 and 7.7 mm; pin headers J1 and J2 pass through slots in the lid, and there is a window above LED D1

Corner Hole Margin beyond 0.5 mm to a part
top left x = 7.75, y = 10.75 0.30 mm
top right x = 42.00, y = 3.00 2.63 mm
bottom left x = 6.75, y = 22.25 0.38 mm
bottom right x = 42.00, y = 29.00 0.72 mm

The right corners are free: the screws sit exactly 3 mm from the edges. The left ones are taken: the crystal runs along the top edge for almost 14 mm, and at the bottom there are pin header J2 and the regulator. The screws moved into the board, so the board will be held asymmetrically; the standoffs inside the enclosure will not be where you expect them.

The conclusion is simple: mounting holes are placed first, before placing the parts, and covered with a keepout zone, just like the board outline. Most often these are four M2.5 or M3 holes in the corners; their coordinates are the contract between the board and the enclosure.


204а.5 The enclosure as a program

The parameters are at the top of the script; everything else is calculated from them and from the board file:

WALL = 2.0          # стенка и дно, мм — для печати соплом 0,4 это 5 проходов
GAP = 0.5           # зазор плата — стенка с каждой стороны
STANDOFF = 4.0      # стойка под платой: хвосты выводов гребёнок снизу — до 3 мм
LID_CLEAR = 1.5     # над самой высокой деталью под крышкой
HOLE_D = 2.2        # отверстие в плате под винт M2

The base is a box with a hollowed-out cavity and standoffs for self-tapping screws at the found positions:

    split = z_board + thick                         # дно по верх платы, выше — крышка
    base = (cq.Workplane("XY").box(out_w, out_h, split, centered=False).translate((ox, fy(by2) - GAP - WALL, 0))
            .cut(cq.Workplane("XY").box(in_w, in_h, split, centered=False).translate((bx1 - GAP, fy(by2) - GAP, WALL))))
    for cx, cy, _ in hs:
        boss = (cq.Workplane("XY").circle(BOSS_D / 2).extrude(STANDOFF).translate((cx, fy(cy), WALL))
                .cut(cq.Workplane("XY").circle(PILOT_D / 2).extrude(STANDOFF).translate((cx, fy(cy), WALL))))
        base = base.union(boss)

The lid is the same kind of box on top; it has slots following the pin header outlines with a 0.3 mm margin and a ⌀ 2 mm window above the LED. One subtlety: in KiCad the Y axis points down, in CAD it points up, so all board coordinates go through fy(y); forget it and the slots end up mirrored.

The result:

Dimension How it is calculated Value
outside, length × width 45 + 2 · 0.5 + 2 · 2 and 32 + 2 · 0.5 + 2 · 2 50.0 × 37.0 mm
outside, height 2 + 4 + 1.6 + 4.3 + 1.5 + 2 15.4 mm
slot J1 / J2 pin header outline + 2 · 0.3 4.14 × 11.76 / 4.14 × 6.69 mm
plastic volume: base / lid from the model 5841 / 5467 mm³

The 4.3 mm height under the lid is the HC49-SD crystal from the table of assumptions; for your own board, take it from the drawing of the tallest part.


204а.6 Checks before printing

Printing an enclosure takes hours. An error that is visible in the model within a second is found before printing:

The script exports case_base.stl and case_lid.stl for the slicer and case.step for anyone who will refine the enclosure in another CAD tool.


204а.7 Printing, material and protection

Printing tolerances. An FDM printer lays a line wider than the calculated one, holes come out smaller, and fits are tighter than in the model. So holes for self-tapping screws are adjusted on the real part: a test print of one standoff is cheaper than an enclosure. Set the wall to a multiple of the line width: 2.0 mm with a 0.4 nozzle is five passes.

Material. PLA is the easiest to print, but it softens with heat: an enclosure in the sun or in a car warps. For warm places and outdoors use PETG or ASA, for loaded parts use the same with higher infill. Softening temperatures are in the datasheet of the specific plastic.

IP protection rating (GOST 14254, also known as IEC 60529). The first digit is protection against solid objects and dust, the second against water: IP54 is dust-protected and protected against splashes from all sides; IP67 is dust-tight and withstands brief immersion. A printed enclosure with slots for pin headers is IP20 at best: protected against fingers, not water. Seals, sealed connectors and testing for the declared rating are a job for the design, not for the printer (section 206).

Radio and the enclosure. Metal and filled plastic shield an antenna. A module with an on-board antenna is placed against a plastic wall and not covered with metal; more on this in 206.3.


204а.8 Pitfalls


204а.9 Summary

Full source code

case204.py: an enclosure from a KiCad board, parametric, in CadQuery.

# -*- coding: utf-8 -*-
"""204а: корпус по плате из KiCad — параметрически, в CadQuery.

Запуск в образе с CadQuery (cadquery-cad): python case204.py board.kicad_pcb
Читает из файла платы контур (Edge.Cuts), толщину и контуры деталей (F.CrtYd), подбирает
места под винты M2 в углах так, чтобы отверстие с запасом не задевало ни одной детали,
строит дно со стойками и крышку с прорезями под разъёмы и окном над светодиодом,
проверяет пересечения и зазоры и выгружает STL, STEP и проекции SVG."""
import math, re, sys

import cadquery as cq

# ── параметры корпуса (всё, что меняют под свой прибор) ─────────────────
WALL = 2.0          # стенка и дно, мм — для печати соплом 0,4 это 5 проходов
GAP = 0.5           # зазор плата — стенка с каждой стороны
STANDOFF = 4.0      # стойка под платой: хвосты выводов гребёнок снизу — до 3 мм
LID_CLEAR = 1.5     # над самой высокой деталью под крышкой
HOLE_D = 2.2        # отверстие в плате под винт M2
KEEP = 0.5          # запас от края отверстия до контура детали
BOSS_D = 5.0        # стойка под саморез M2
PILOT_D = 1.6       # отверстие в стойке под саморез M2 в пластик
CORNER_INSET = 3.0  # центр отверстия — не ближе 3 мм к краю платы
MIN_CLEAR = 0.3     # и не ближе 0,3 мм (сверх KEEP) к контуру детали

# высота деталей над платой, мм: LQFP-48 — «A max» из даташита F103, остальное — допущения
# (объёмных моделей в образе KiCad нет; сверять с даташитом своей детали)
HEIGHT = {"LQFP-48": 1.6, "SOT-223": 1.8, "Crystal_SMD_HC49-SD": 4.3, "C_0603": 0.9, "C_0805": 1.3,
          "R_0603": 0.55, "LED_0805": 0.8, "PinHeader": 8.5}
THROUGH_LID = ("PinHeader",)   # эти выходят наружу через прорези крышки


def num(s):
    return float(s)


def parse(path):
    s = open(path, encoding="utf-8").read()
    thick = num(re.search(r"\(thickness ([\d.]+)\)", s).group(1))
    xs, ys = [], []
    for m in re.finditer(r'\(gr_line\s*\(start ([\d.\-]+) ([\d.\-]+)\)\s*\(end ([\d.\-]+) ([\d.\-]+)\).*?\(layer "Edge\.Cuts"\)', s, re.S):
        a, b, c, d = map(num, m.groups())
        xs += [a, c]
        ys += [b, d]
    board = (min(xs), min(ys), max(xs), max(ys))
    parts = []
    starts = [m.start() for m in re.finditer(r'\n\t\(footprint "', s)] + [len(s)]
    for i in range(len(starts) - 1):
        blk = s[starts[i]:starts[i + 1]]
        name = re.search(r'\(footprint "([^"]+)"', blk).group(1)
        at = re.search(r'\n\t\t\(at ([\d.\-]+) ([\d.\-]+)(?: ([\d.\-]+))?\)', blk)
        x0, y0 = num(at.group(1)), num(at.group(2))
        rot = math.radians(num(at.group(3))) if at.group(3) else 0.0
        ref = re.search(r'\(property "Reference" "([^"]+)"', blk).group(1)
        pts = []
        for m in re.finditer(r'\(fp_(?:line|rect)\s*\(start ([\d.\-]+) ([\d.\-]+)\)\s*\(end ([\d.\-]+) ([\d.\-]+)\)(?:(?!\(fp_).)*?\(layer "F\.CrtYd"\)', blk, re.S):
            a, b, c, d = map(num, m.groups())
            pts += [(a, b), (c, d), (a, d), (c, b)]
        # KiCad: поворот по часовой при оси Y вниз
        tp = [(x0 + px * math.cos(rot) + py * math.sin(rot), y0 - px * math.sin(rot) + py * math.cos(rot)) for px, py in pts]
        bx = (min(p[0] for p in tp), min(p[1] for p in tp), max(p[0] for p in tp), max(p[1] for p in tp))
        h = next(v for k, v in HEIGHT.items() if name.startswith(k))
        parts.append((ref, name, bx, h))
    return thick, board, parts


def clear(cx, cy, parts):
    """Расстояние от края отверстия (с запасом) до ближайшего контура детали; > 0 — свободно."""
    r = HOLE_D / 2 + KEEP
    best = 1e9
    for _, _, (x1, y1, x2, y2), _ in parts:
        dx = max(x1 - cx, 0, cx - x2)
        dy = max(y1 - cy, 0, cy - y2)
        best = min(best, math.hypot(dx, dy) - r)
    return best


def holes(board, parts):
    """У каждого угла — ближайшая к нему точка на сетке 0,25 мм, где отверстие не ближе
    CORNER_INSET к краям платы и не ближе MIN_CLEAR к контуру любой детали."""
    bx1, by1, bx2, by2 = board
    out = []
    for sx, sy in ((0, 0), (1, 0), (0, 1), (1, 1)):
        best = None
        for i in range(0, 81):
            for j in range(0, 61):
                dx, dy = CORNER_INSET + i * 0.25, CORNER_INSET + j * 0.25
                cx = bx1 + dx if sx == 0 else bx2 - dx
                cy = by1 + dy if sy == 0 else by2 - dy
                c = clear(cx, cy, parts)
                if c >= MIN_CLEAR:
                    d = math.hypot(dx, dy)
                    if best is None or d < best[0]:
                        best = (d, (round(cx, 2), round(cy, 2), round(c, 2)))
        out.append(best[1] if best else None)
    return out


def build(path):
    thick, board, parts = parse(path)
    bx1, by1, bx2, by2 = board
    bw, bh = bx2 - bx1, by2 - by1
    hs = holes(board, parts)
    top_parts = [p for p in parts if not p[1].startswith(THROUGH_LID)]
    tallest = max(p[3] for p in top_parts)
    in_w, in_h = bw + 2 * GAP, bh + 2 * GAP
    z_board = WALL + STANDOFF                       # низ платы
    z_top_in = z_board + thick + tallest + LID_CLEAR  # потолок изнутри
    ox, oy = bx1 - GAP - WALL, by1 - GAP - WALL     # угол корпуса в координатах платы
    out_w, out_h = in_w + 2 * WALL, in_h + 2 * WALL
    # координаты платы (Y вниз) → корпус (Y вверх): y' = by2 - y + by1
    fy = lambda y: by2 - y + by1

    split = z_board + thick                         # дно по верх платы, выше — крышка
    base = (cq.Workplane("XY").box(out_w, out_h, split, centered=False).translate((ox, fy(by2) - GAP - WALL, 0))
            .cut(cq.Workplane("XY").box(in_w, in_h, split, centered=False).translate((bx1 - GAP, fy(by2) - GAP, WALL))))
    for cx, cy, _ in hs:
        boss = (cq.Workplane("XY").circle(BOSS_D / 2).extrude(STANDOFF).translate((cx, fy(cy), WALL))
                .cut(cq.Workplane("XY").circle(PILOT_D / 2).extrude(STANDOFF).translate((cx, fy(cy), WALL))))
        base = base.union(boss)
    lid_h = z_top_in + WALL - split
    lid = (cq.Workplane("XY").box(out_w, out_h, lid_h, centered=False).translate((ox, fy(by2) - GAP - WALL, split))
           .cut(cq.Workplane("XY").box(in_w, in_h, lid_h - WALL, centered=False).translate((bx1 - GAP, fy(by2) - GAP, split))))
    cut_list = []
    for ref, name, (x1, y1, x2, y2), h in parts:
        if name.startswith(THROUGH_LID):            # прорезь по контуру гребёнки + 0,3 мм
            w, d = x2 - x1 + 0.6, y2 - y1 + 0.6
            lid = lid.cut(cq.Workplane("XY").box(w, d, lid_h + 1, centered=False)
                          .translate((x1 - 0.3, fy(y2) - 0.3, split - 0.5)))
            cut_list.append((ref, round(w, 2), round(d, 2)))
        if name.startswith("LED"):                  # окно над светодиодом, ⌀ 2 мм
            lid = lid.cut(cq.Workplane("XY").circle(1.0).extrude(WALL + 1).translate(((x1 + x2) / 2, fy((y1 + y2) / 2), z_top_in - 0.5)))
            cut_list.append((ref, "окно 2", ""))

    pcb = cq.Workplane("XY").box(bw, bh, thick, centered=False).translate((bx1, fy(by2), z_board))
    for cx, cy, _ in hs:
        pcb = pcb.cut(cq.Workplane("XY").circle(HOLE_D / 2).extrude(thick).translate((cx, fy(cy), z_board)))
    comps = None
    for ref, name, (x1, y1, x2, y2), h in parts:
        body = cq.Workplane("XY").box(x2 - x1, y2 - y1, h, centered=False).translate((x1, fy(y2), z_board + thick))
        comps = body if comps is None else comps.union(body)
    return dict(thick=thick, board=board, parts=parts, holes=hs, tallest=tallest, out=(out_w, out_h, z_top_in + WALL),
                base=base, lid=lid, pcb=pcb, comps=comps, cuts=cut_list, split=split, z_board=z_board)


def vol(wp):
    return sum(s.Volume() for s in wp.vals()) if wp is not None else 0.0


if __name__ == "__main__":
    r = build(sys.argv[1] if len(sys.argv) > 1 else "board.kicad_pcb")
    bx1, by1, bx2, by2 = r["board"]
    print("плата: %.1f × %.1f мм, толщина %.1f" % (bx2 - bx1, by2 - by1, r["thick"]))
    for ref, name, bx, h in r["parts"]:
        print("деталь %s %s: контур %.2f…%.2f × %.2f…%.2f, высота %.1f" % (ref, name, bx[0], bx[2], bx[1], bx[3], h))
    for k, hcl in enumerate(r["holes"]):
        print("отверстие %d: x=%.2f y=%.2f, запас сверх 0,5 мм до детали %.2f мм" % ((k + 1,) + hcl))
    print("самая высокая деталь под крышкой: %.1f мм" % r["tallest"])
    print("корпус снаружи: %.1f × %.1f × %.1f мм" % r["out"])
    for c in r["cuts"]:
        print("вырез %s: %s %s" % c)
    solid = r["base"].union(r["lid"])
    for what, other in (("плата", r["pcb"]), ("детали", r["comps"])):
        inter = vol(solid.intersect(other))
        print("пересечение корпуса и %s: %.4f мм³" % ("платы" if what == "плата" else "деталей", inter))
    print("объём дна %.0f мм³, крышки %.0f мм³" % (vol(r["base"]), vol(r["lid"])))
    import os
    if os.path.exists("board.step"):               # сверка с выгрузкой kicad-cli pcb export step
        bb = cq.importers.importStep("board.step").val().BoundingBox()
        print("плата из STEP: %.2f × %.2f × %.2f мм" % (bb.xlen, bb.ylen, bb.zlen))
    cq.exporters.export(r["base"], "case_base.stl")
    cq.exporters.export(r["lid"], "case_lid.stl")
    cq.exporters.export(r["base"].union(r["lid"]), "case.step")
    asm = r["base"].union(r["pcb"]).union(r["comps"])
    cq.exporters.export(asm, "view_base.svg", opt={"projectionDir": (1, -1.2, 1.1), "showHidden": False,
                                                   "width": 520, "height": 360, "strokeWidth": 0.35})
    cq.exporters.export(r["lid"], "view_lid.svg", opt={"projectionDir": (1, -1.2, 1.1), "showHidden": False,
                                                      "width": 520, "height": 360, "strokeWidth": 0.35})
    print("выгружено: case_base.stl, case_lid.stl, case.step, view_base.svg, view_lid.svg")

Next: section 205 "Product documentation: the set, designations, revisions": the device lives for years; now it must be possible to reproduce, repair and change it without digging through the developer's memory.

Open in the interactive handbook