Embedded HandbookПриложения
Все разделы › XXVI. От макета к изделию
STM32F103 · уровень средний · 17 мин чтения
Открыть в интерактивном справочнике

204а. Корпус прибора: от платы к корпусу, инструменты, проверка до печати

Плата из раздела 199 собрана, прошита, испытана — и лежит на столе голой. Прибором она станет в корпусе: он держит плату, выпускает наружу разъёмы и свет, защищает от пальцев, пыли и воды. Этот раздел — как корпус получают из той же модели платы, без ручного перерисовывания размеров: KiCad выгружает плату, скрипт строит по ней корпус и проверяет, что всё встало, и только потом корпус идёт на печать.

Весь путь прогнан на плате из 199 (F103 в LQFP-48, стабилизатор SOT-223, кварц, две гребёнки, светодиод): kicad-cli 9.0.9 выгрузил плату в STEP, скрипт на CadQuery 2.8.0 прочитал файл платы, нашёл места под винты, построил дно и крышку, проверил пересечения и выгрузил STL. Высоты деталей — таблица в скрипте: объёмных моделей в образе KiCad нет, и для всех деталей, кроме LQFP-48, это допущения.

Как это называется в документации

Оригинал Перевод
enclosure, housing корпус
standoff, boss стойка под плату, бобышка под винт
mounting hole крепёжное отверстие
courtyard (F.CrtYd) контур, занятый деталью на плате
STEP обменный формат твёрдых тел между CAD
STL сетка треугольников для слайсера и печати
slicer слайсер: режет модель на слои для принтера
clearance зазор
ingress protection (IP) степень защиты оболочки
light pipe световод от светодиода к окну корпуса

204а.1 Готовый, напечатанный или свой

Путь Когда Чем платят
готовый корпус из каталога плата сделана под него: размеры и стойки по чертежу корпуса плату рисуют под корпус, а не наоборот
печать FDM десятки штук, макеты, свои формы время печати, слоистость, пластик мягчеет от тепла
печать смолой (SLA) мелкие детали, гладкая поверхность хрупкость, постобработка
фрезеровка, литьё под давлением серия, металл, прочность оснастка и срок; литьё окупается на тысячах

Порядок решений обратный тому, как обычно действуют. Сначала выбирают корпус или хотя бы его наружные размеры и места под винты — потом разводят плату. Готовый корпус из каталога почти всегда дешевле и крепче напечатанного; свой нужен, когда форма диктуется изделием.


204а.2 Что корпус берёт у платы

Корпусу нужны пять вещей, и все они уже лежат в файле платы:

Что Откуда На плате из 199
контур и толщина слой Edge.Cuts, (thickness …) 45 × 32 мм, 1,6 мм
где что стоит контуры F.CrtYd каждой детали 15 деталей
высота над платой объёмные модели деталей таблица допущений; LQFP-48 — 1,6 мм по даташиту F103
что выходит наружу разъёмы, светодиоды, кнопки J1, J2 — гребёнки; D1 — светодиод
чем держится крепёжные отверстия их нет — место ищет скрипт (204а.4)

Перерисовывать это руками в CAD — источник ошибок на каждой ревизии платы. Правильно — читать из файла: плата поменялась, скрипт перестроил корпус.


204а.3 Инструменты

Инструмент Что делает в этой цепочке Лицензия
KiCad, kicad-cli pcb export step выгружает плату (и детали, если есть их модели) в STEP GPL
CadQuery (ядро OpenCascade) корпус как программа на Python: параметры, проверки, выгрузка Apache 2.0
FreeCAD + KiCad StepUp то же мышью: открыть STEP платы, нарисовать корпус вокруг LGPL
OpenSCAD корпус как текст на своём языке; удобно для простых коробок GPL
Fusion, Onshape облачные CAD; удобно рисовать, неудобно повторять коммерческие
trimesh проверка сеток STL: объём, замкнутость MIT
слайсер (PrusaSlicer, Cura, OrcaSlicer) STL → слои → G-код для принтера открытые

Здесь выбран CadQuery: корпус описан программой, как и всё в справочнике, поэтому его можно перестроить по новой плате и проверить тем же прогоном. FreeCAD хорош, когда корпус сложной формы проще нарисовать, чем описать; STEP платы он открывает так же.

Выгрузка платы — одна команда в образе kicad/kicad:9.0:

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

CadQuery прочитал этот STEP: плата 45,00 × 32,00 × 1,51 мм. Не 1,6 — в STEP ушла только основа платы: общая толщина 1,6 мм минус медь и маска с двух сторон, (0,035 + 0,01) × 2 = 0,09 мм. Корпус считают по полной толщине из файла платы, а не по STEP.


204а.4 Крепёж — до разводки, а не после

У платы из 199 крепёжных отверстий нет. Скрипт ищет место под винт M2 (отверстие ⌀ 2,2 мм и ещё 0,5 мм запаса до детали) у каждого угла: ближайшую к углу точку, которая не ближе 3 мм к краю платы и не задевает контур ни одной детали.

Плата 45 на 32 мм с контурами деталей: правые углы свободны — отверстия встали в 3 мм от краёв; левый верхний занят кварцем Y1, левый нижний — гребёнкой J2 и стабилизатором U2, эти отверстия сдвинуты внутрь платы на 9,1 и 7,7 мм; гребёнки J1 и J2 выходят сквозь прорези крышки, над светодиодом D1 окно

Угол Отверстие Запас сверх 0,5 мм до детали
левый верхний x = 7,75, y = 10,75 0,30 мм
правый верхний x = 42,00, y = 3,00 2,63 мм
левый нижний x = 6,75, y = 22,25 0,38 мм
правый нижний x = 42,00, y = 29,00 0,72 мм

Правые углы свободны — винты встали ровно в 3 мм от краёв. Левые заняты: кварц тянется вдоль верхнего края почти на 14 мм, внизу стоят гребёнка J2 и стабилизатор. Винты уехали внутрь платы, и плата будет держаться несимметрично; стойки внутри корпуса встанут не там, где их ждёшь.

Вывод простой: места под крепёж ставят первыми, до расстановки деталей, и закрывают запретной зоной — так же, как контур платы. Чаще всего это четыре отверстия M2,5 или M3 в углах; их координаты и есть договор между платой и корпусом.


204а.5 Корпус программой

Параметры — в начале скрипта; всё остальное вычисляется из них и из файла платы:

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

Дно — коробка с вырезанной полостью и стойками под саморезы в найденных местах:

    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)

Крышка — такая же коробка сверху; в ней прорези по контурам гребёнок с запасом 0,3 мм и окно ⌀ 2 мм над светодиодом. Одна тонкость: в KiCad ось Y направлена вниз, в CAD — вверх, поэтому все координаты с платы проходят через fy(y); забудешь — прорези окажутся зеркально.

Что получилось:

Размер Как считается Значение
снаружи, длина × ширина 45 + 2 · 0,5 + 2 · 2 и 32 + 2 · 0,5 + 2 · 2 50,0 × 37,0 мм
снаружи, высота 2 + 4 + 1,6 + 4,3 + 1,5 + 2 15,4 мм
прорезь J1 / J2 контур гребёнки + 2 · 0,3 4,14 × 11,76 / 4,14 × 6,69 мм
объём пластика: дно / крышка из модели 5841 / 5467 мм³

Высота 4,3 мм под крышкой — это кварц HC49-SD из таблицы допущений; для своей платы её берут из чертежа самой высокой детали.


204а.6 Проверка до печати

Печать корпуса — часы. Ошибку, которая видна в модели за секунду, находят до неё:

Скрипт выгружает case_base.stl и case_lid.stl для слайсера и case.step для тех, кто будет дорабатывать корпус в другом CAD.


204а.7 Печать, материал и защита

Зазоры печати. FDM-принтер кладёт линию шире расчётной, отверстия выходят меньше, а посадки туже, чем в модели. Поэтому отверстия под саморезы делают по месту: пробная печать одной стойки дешевле корпуса. Стенку задают кратной ширине линии: 2,0 мм при сопле 0,4 — пять проходов.

Материал. PLA печатается легче всех, но мягчеет от тепла: корпус на солнце или в машине ведёт. Для тёплых мест и улицы берут PETG или ASA, для деталей под нагрузкой — их же с большим заполнением. Температуры размягчения — в паспорте конкретного пластика.

Степень защиты IP (ГОСТ 14254, он же IEC 60529). Первая цифра — от твёрдых тел и пыли, вторая — от воды: IP54 — пылезащищённый, от брызг со всех сторон; IP67 — пыленепроницаемый, выдерживает кратковременное погружение. Напечатанный корпус с прорезями под гребёнки — в лучшем случае IP20: от пальцев, не от воды. Уплотнение, герметичные разъёмы и испытание для заявленной цифры — задача не принтера, а конструкции (раздел 206).

Радио и корпус. Металл и пластик с наполнителем экранируют антенну. Модуль с антенной на плате ставят к пластиковой стенке и не закрывают металлом; подробнее — в 206.3.


204а.8 Грабли


204а.9 Итоги

Полные исходники

case204.py — корпус по плате из KiCad — параметрически, в 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")

Дальше: раздел 205 «Документация изделия: комплект, обозначения, ревизии» — прибор живёт годами; теперь нужно, чтобы его можно было повторить, чинить и менять, не раскапывая память разработчика.

Открыть в интерактивном справочнике