Я давно собирался написать статью на эту тему. В этом году я рассказывал о ней на конференции DORS/CLUC: это ещё один вариант работы с многоплатными проектами в KiCad (и он лучше предыдущего :)). Видео доклада пока не появилось, но презентация уже доступна здесь.

Уже больше года я работаю над довольно сложным проектом, который состоит из множества печатных плат — в последней ревизии их целых 22! Это коммерческий проект для клиента, поэтому в подробности я вдаваться не могу, но могу рассказать о своём процессе работы. Разумеется, всё делается в KiCad.

Из-за особенностей проекта в относительно небольшом объёме устройства нужно разместить очень много функций. Поэтому используется буквально всё доступное пространство: платы получаются неправильной формы и располагаются под самыми разными углами. Кроме того, положение некоторых плат определяется внешним дизайном устройства и расположением интерфейсов и разъёмов.

В KiCad нет встроенной поддержки многоплатных проектов, но с помощью нескольких внешних инструментов организовать такую работу довольно просто: вносишь изменения, экспортируешь модели, импортируешь их в 3D-редактор, всё проверяешь и измеряешь, возвращаешься в KiCad — и повторяешь цикл.

Я уже писал о своём старом процессе работы с несколькими платами в KiCad. Но тогда это был мой собственный проект: я мог выбрать любой удобный 3D-редактор, а плат было всего четыре, причём расположены они были довольно просто. Я использовал макросы FreeCAD, чтобы разделить результат экспорта из KiCad на отдельные объекты плат и расположить их в пространстве нужным образом.

Но в коммерческом проекте, когда работаешь вместе с дизайнером, сложно попросить его просто перейти на FreeCAD. К тому же он может пользоваться программой, в которой автоматизация практически не предусмотрена, например Plasticity. Для некоторых задач это отличный инструмент, но автоматизация — точно не его сильная сторона.

Поэтому, чтобы максимально упростить процесс, я решил свести работу в 3D-редакторе к минимуму: все переносы и повороты должны выполняться заранее.

В итоге весь процесс выглядит так:

  • внести изменения в KiCad;

  • запустить команду, которая извлечёт плату, сгенерирует 3D-модель и применит нужные 3D-преобразования, чтобы поставить её в итоговое положение;

  • импортировать модель в 3D-редактор;

  • проверить, измерить и повторить цикл.

Основы многоплатных проектов в KiCad

Прежде чем переходить к 3D-преобразованиям и визуализации, разберём базовую часть работы в KiCad. На странице KiKit о работе с многоплатными проектами всё это уже подробно описано, и с неё удобно начинать.

Полноценной поддержки многоплатных проектов в KiCad нет, но можно создавать несколько контуров плат. Затем с помощью KiKit каждый такой контур извлекается в отдельный проект печатной платы KiCad, из которого уже можно генерировать 3D-модели.

Особое внимание нужно уделить разделению плат: каждая PCB должна быть полностью независимой. В KiCad 10 появилась поддержка локальных цепей питания (local power nets), и здесь она может пригодиться.

Платы соединяются между собой через разъёмы — обычно это межплатные штыревые или мезонинные разъёмы либо шлейфы и кабели, например FFC/FPC. Можно и напрямую припаять платы друг к другу через специально размещённые контактные площадки, но это вряд ли лучший вариант, особенно если устройство будет работать в условиях сильной вибрации.

К сожалению, насколько мне известно, в KiCad нельзя задать связь между двумя разъёмами. Поэтому распиновку приходится проверять и согласовывать вручную.

Работа со схемой

К проектированию схемы многоплатного устройства можно подойти двумя способами. В простых проектах разницы почти нет, но в сложных она уже становится существенной. Эти два подхода — построение схемы по функциональным блокам и построение схемы по платам.

При построении по функциональным блокам устройство проектируется обычным образом: отдельно источники питания, ввод-вывод, согласование и обработка сигналов, пользовательский интерфейс и так далее. Когда проект затем разбивается на несколько плат, один функциональный блок может оказаться распределён сразу между несколькими PCB. В результате контакты межплатных разъёмов оказываются разбросаны по всей схеме, а проследить прохождение сигналов между платами становится не так просто. И ошибиться здесь тоже легче. Зато человеку, который будет проверять схему целиком, обычно проще понять логику устройства.

При построении схемы по платам сначала определяется, какие компоненты должны находиться на каждой конкретной PCB, и каждый сегмент схемного проекта соответствует отдельной плате. Такой подход удобен при поиске неисправностей: намного проще сопоставить участок схемы с физической областью конкретной платы. Но получить общее представление об устройстве в целом может быть сложнее. Именно этот подход рекомендуется на странице KiKit о многоплатных проектах.

Я пробовал оба варианта. У каждого есть свои плюсы и минусы, а выбор зависит от требований конкретного проекта и личных предпочтений. Было бы здорово иметь возможность задавать структуру проекта так, чтобы переключаться между этими двумя представлениями. Насколько я знаю, некоторые продвинутые EDA-системы такое умеют.

3D-преобразования

STEP — один из самых распространённых форматов обмена 3D-моделями в механическом проектировании. Как и в других 3D-форматах, геометрия задаётся относительно системы координат — вместе с положением и ориентацией объекта. Поэтому все необходимые преобразования можно выполнить заранее: они сохранятся в файле. После этого готовый объект достаточно импортировать в 3D-редактор, и он сразу окажется в нужном положении и с нужной ориентацией.

Значит, первым делом нужно определить это положение:

  • экспортировать 3D-модель платы из KiCad в STEP-файл;

  • импортировать её в 3D-редактор;

  • с помощью последовательности преобразований — поворотов и переносов — установить плату в нужное положение. Последовательность преобразований нужно записать.

Для первоначального ручного позиционирования в FreeCAD удобнее всего выбрать объект, открыть Edit → Placement, включить Apply incremental changes, а затем выполнить преобразования по одному.

Чтобы не усложнять задачу, лучше всегда сначала выполнять повороты, а уже затем переносы. При этом порядок переносов значения не имеет, а вот для поворотов порядок важен.

Когда последовательность преобразований записана, остаётся найти инструмент, который сможет их выполнить. Подходящего варианта с интерфейсом командной строки я не нашёл, поэтому написал свой на основе Python-интерфейса FreeCAD.

step_prepare.py

step_prepare.py — Python-скрипт, который экспортирует STEP-файл из проекта печатной платы KiCad, а затем через интерфейс командной строки FreeCAD обрабатывает его и применяет нужные 3D-преобразования.

❯ ./step_prepare.py --help
usage: step_prepare.py [-h] [-o OUTFILE] [-t TRANSFORM] [--origin ORIGIN] [--skip-kicad-export] [-d] infile

Prepare STEP file from Kicad PCB for mechanical CAD import

positional arguments:
  infile                Input Kicad PCB file

options:
  -h, --help            show this help message and exit
  -o, --outfile OUTFILE
                        Output STEP file
  -t, --transform TRANSFORM
                        Transformations to apply to the STEP file (e.g. "tx100 ty-50 rz90")
  --origin ORIGIN       Origin as "x,y" coordinates, a footprint name (e.g. "pcb_origin", errors out if multiple instances exist) or a reference
                        ("ref:H1"). Defaults to the lowest left corner of the board outline.
  --skip-kicad-export   Skip Kicad STEP export step (assume STEP file already exists)
  -d, --detailed-board  Use detailed board export options (longer export time)

Последовательность преобразований задаётся просто: строкой из rx|ry|rz|tx|ty|tz с нужными значениями. Например, rx90 ry-90 tx23.4 сначала повернёт объект на 90° вокруг оси X, затем на −90° вокруг оси Y и в конце перенесёт его на 23,4 мм вдоль оси X.

Начало координат объекта можно задать несколькими способами: автоматически — по нижней левой точке контура платы — либо указав позиционное обозначение посадочного места или его имя в библиотеке через аргумент командной строки --origin. Обычно я создаю специальное посадочное место с графикой на одном из пользовательских слоёв и размещаю его в удобной точке внутри контура платы. Тогда в многоплатном проекте оно извлекается вместе с платой в отдельный PCB-файл, который затем можно экспортировать и обработать.

Единственная проблема — цвета. При обработке они полностью теряются, и итоговая модель получает цвет по умолчанию. Я пробовал несколько способов сохранить цвета, но ничего не получилось. Пока меня это устраивает. Для финальных рендеров, когда всё уже готово, при необходимости я вручную импортирую модели в файл и расставляю их по местам.

origin footprint
Посадочное место, задающее начало координат

Совмещение граней

При некоторых преобразованиях плата оказывается под сложными углами сразу относительно нескольких осей, и вручную подобрать исходное положение бывает непросто. Поэтому я написал макрос для FreeCAD, который автоматизирует эту задачу. Достаточно выбрать исходную грань, исходное ребро, затем целевую грань и целевое ребро и запустить макрос. Он выведет последовательность поворотов, необходимую для того, чтобы привести исходный объект к нужной ориентации. Переносы макрос не выполняет, но после того как угол выставлен правильно, их уже довольно легко сделать вручную.

В FreeCAD:

  • откройте в меню Macro -> Macros;

  • нажмите Create, задайте имя и вставьте приведённый ниже Python-код;

  • выберите по порядку: исходную грань, исходное ребро, целевую грань, целевое ребро;

  • запустите макрос: выберите его в окне Macro -> Macros и нажмите Execute либо, если вы уже запускали его хотя бы один раз, воспользуйтесь Macro -> Recent macros.

Если объект оказался перевёрнут или повернулся не в ту сторону, попробуйте изменить значения констант FLIP_EDGE и FLIP_NORMAL в макросе либо выбрать другую сторону целевой грани или ребра.

Код макроса FreeCAD также доступен по адресу https://gitlab.com/-/snippets/6047539

# -*- coding: utf-8 -*-
# AlignRotation.FCMacro
#
# Computes the rotation that makes a PCB face parallel to (and facing)
# an enclosure face, with a chosen PCB edge parallel to a chosen
# enclosure edge. Prints the result as axis/angle, quaternion and
# yaw/pitch/roll, plus the two-step decomposition (swing + twist).
#
# USAGE
#   1. Click the PCB *mounting* face (the face that must touch the enclosure)
#   2. Ctrl-click a straight edge of the PCB (in-plane direction reference)
#   3. Ctrl-click the enclosure face the PCB mounts onto
#   4. Ctrl-click a straight edge on the enclosure that edge (2) must match
#   5. Run the macro; output appears in the Report view
#      (View > Panels > Report view)
#
# FLAGS
#   APPLY_TO_PCB  True -> actually rotate the PCB object (about the centre of
#                 its mounting face, so it stays put) as a visual preview.
#                 Note: assumes the PCB object sits at document root; if it
#                 lives inside an App::Part / Link, apply the printed rotation
#                 in the appropriate local frame yourself.
#   FLIP_EDGE     True if the result is twisted 180 deg about the face normal
#                 (edge parametrisation direction in STEP is arbitrary).
#   FLIP_NORMAL   True if the PCB ends up facing the wrong way (e.g. you
#                 selected the PCB's top face instead of the mounting face).

import math
import FreeCAD as App
import FreeCADGui as Gui
import Part

APPLY_TO_PCB = True
FLIP_EDGE    = False
FLIP_NORMAL  = True

# --------------------------------------------------------------- helpers

def _picks():
    """(object, subname, sub-shape in GLOBAL coordinates), in selection order.

    Part.getShape with needSubElement=True applies all placements along the
    path, including App::Link / App::Part containers.
    """
    out = []
    for sel in Gui.Selection.getSelectionEx('', 0):
        for sub in sel.SubElementNames:
            shp = Part.getShape(sel.Object, sub, needSubElement=True)
            out.append((sel.Object, sub, shp))
    return out

def _parent_shape(obj, sub):
    """Whole shape (global coords) of the object owning the sub-element."""
    parts = sub.split('.')
    if parts and parts[-1][:4] in ('Face', 'Edge', 'Vert'):
        sub = '.'.join(parts[:-1])
        if sub:
            sub += '.'
    try:
        return Part.getShape(obj, sub)
    except Exception:
        return None

def _valid_uv(face):
    """A (u, v) pair that actually lies on the face (avoids holes)."""
    u0, u1, v0, v1 = face.ParameterRange
    for i in range(1, 8):
        for j in range(1, 8):
            u = u0 + (u1 - u0) * i / 8.0
            v = v0 + (v1 - v0) * j / 8.0
            try:
                if face.isPartOfDomain(u, v):
                    return u, v
            except Exception:
                break
    return 0.5 * (u0 + u1), 0.5 * (v0 + v1)

def outward_normal(face, whole):
    """Unit normal of a planar face, verified to point OUT of the material.

    STEP faces are frequently 'Reversed', and normalAt()'s handling of that
    has varied between versions -- so instead of trusting flags we probe a
    point along the normal and check it is outside the parent solid.
    """
    if face.findPlane() is None:
        raise ValueError("selected face is not planar")
    u, v = _valid_uv(face)
    n = face.normalAt(u, v)
    n.normalize()
    if whole is not None and whole.Volume > 1e-9:
        p = face.valueAt(u, v)
        d = max(whole.BoundBox.DiagonalLength * 1e-4, 1e-2)
        try:
            if whole.isInside(p + n * d, 1e-7, False):
                n = n.negative()
        except Exception:
            pass
    return n

def in_plane_dir(edge, n):
    """Unit direction of the reference edge, projected into the face plane."""
    t = edge.tangentAt(0.5 * (edge.FirstParameter + edge.LastParameter))
    t.normalize()
    t = t - n * t.dot(n)
    if t.Length < 1e-6:
        raise ValueError("reference edge is (nearly) perpendicular to the face plane")
    t.normalize()
    return t

def decompose_xyz(R):
    """Angles (ax, ay, az) in degrees such that R = Rz(az) * Ry(ay) * Rx(ax),
    i.e. applied to the model as: rotate about GLOBAL X by ax, then GLOBAL Y
    by ay, then GLOBAL Z by az (extrinsic X-Y-Z order).

    Extracted from the rotation matrix columns; handles the gimbal case
    ay = +/-90 deg (there ax/az are not unique -- az is set to 0).
    """
    cx = R.multVec(App.Vector(1, 0, 0))   # matrix column X
    cy = R.multVec(App.Vector(0, 1, 0))   # matrix column Y
    cz = R.multVec(App.Vector(0, 0, 1))   # matrix column Z
    m20 = cx.z                            # = -sin(ay)
    if m20 < -0.999999999:                # ay = +90 deg, gimbal
        ay, az = 90.0, 0.0
        ax = math.degrees(math.atan2(cy.x, cz.x))
    elif m20 > 0.999999999:               # ay = -90 deg, gimbal
        ay, az = -90.0, 0.0
        ax = math.degrees(math.atan2(-cy.x, -cz.x))
    else:
        ay = math.degrees(math.asin(-m20))
        ax = math.degrees(math.atan2(cy.z, cz.z))
        az = math.degrees(math.atan2(cx.y, cx.x))
    return ax, ay, az

def fmt(v):
    return "({:.6f}, {:.6f}, {:.6f})".format(v.x, v.y, v.z)

def say(msg=""):
    App.Console.PrintMessage(msg + "\n")

# ------------------------------------------------------------------ main

picks = _picks()
ok = (len(picks) == 4
      and picks[0][2].ShapeType == 'Face' and picks[1][2].ShapeType == 'Edge'
      and picks[2][2].ShapeType == 'Face' and picks[3][2].ShapeType == 'Edge')
if not ok:
    raise RuntimeError(
        "Select exactly 4 things, in this order (Ctrl-click):\n"
        "  1) PCB mounting face   2) PCB reference edge\n"
        "  3) enclosure face      4) enclosure reference edge")

pcb_obj, pcb_sub, f_src = picks[0]
e_src = picks[1][2]
enc_obj, enc_sub, f_tgt = picks[2]
e_tgt = picks[3][2]

if not isinstance(e_src.Curve, Part.Line):
    say("warning : PCB reference edge is not straight; using mid-point tangent")
if not isinstance(e_tgt.Curve, Part.Line):
    say("warning : enclosure reference edge is not straight; using mid-point tangent")

n_src = outward_normal(f_src, _parent_shape(pcb_obj, pcb_sub))
n_tgt = outward_normal(f_tgt, _parent_shape(enc_obj, enc_sub))

n_dst = n_tgt.negative()            # mounting face must LOOK AT the enclosure face
if FLIP_NORMAL:
    n_dst = n_dst.negative()

t_src = in_plane_dir(e_src, n_src)
t_dst = in_plane_dir(e_tgt, n_tgt)
if FLIP_EDGE:
    t_dst = t_dst.negative()

# Step 1 -- "swing": minimal rotation taking the PCB normal onto the target
R1 = App.Rotation(n_src, n_dst)

# Step 2 -- "twist": rotate about the target normal to line the edges up
t_mid = R1.multVec(t_src)
twist = math.degrees(math.atan2(n_dst.dot(t_mid.cross(t_dst)), t_mid.dot(t_dst)))
R2 = App.Rotation(n_dst, twist)

R = R2.multiply(R1)                 # a.multiply(b) applies b FIRST, then a

# --------------------------------------------------------------- report

err_n = math.degrees(R.multVec(n_src).getAngle(n_dst))
err_t = math.degrees(R.multVec(t_src).getAngle(t_dst))
yaw, pitch, roll = R.toEuler()

say("=== AlignRotation ===")
say("PCB     : {}  [{}]".format(pcb_obj.Label, pcb_sub))
say("target  : {}  [{}]".format(enc_obj.Label, enc_sub))
say("normal  : pcb {}  ->  goal {}".format(fmt(n_src), fmt(n_dst)))
say("edge    : pcb {}  ->  goal {}".format(fmt(t_src), fmt(t_dst)))
say()
say("step 1  : swing {:.6f} deg about {}".format(math.degrees(R1.Angle), fmt(R1.Axis)))
say("step 2  : twist {:.6f} deg about {} (target normal)".format(twist, fmt(R2.Axis)))
say()
say("combined rotation:")
say("  axis/angle : {:.6f} deg about {}".format(math.degrees(R.Angle), fmt(R.Axis)))
say("  quaternion : ({:.9f}, {:.9f}, {:.9f}, {:.9f})".format(*R.Q))
say("  Placement dialog (yaw/pitch/roll): Z={:.6f}  Y={:.6f}  X={:.6f}"
    .format(yaw, pitch, roll))
say()

# ---- rotation sequence for export into another 3D tool -----------------
ax, ay, az = decompose_xyz(R)
Rx = App.Rotation(App.Vector(1, 0, 0), ax)
Ry = App.Rotation(App.Vector(0, 1, 0), ay)
Rz = App.Rotation(App.Vector(0, 0, 1), az)
Rseq = Rz.multiply(Ry).multiply(Rx)          # Rx applied first, Rz last
resid = math.degrees(Rseq.inverted().multiply(R).Angle)

say("EXPORT SEQUENCE  (about the FIXED GLOBAL axes, in this order):")
say("  1) rotX  {:+.6f} deg".format(ax))
say("  2) rotY  {:+.6f} deg".format(ay))
say("  3) rotZ  {:+.6f} deg".format(az))
say("  reconstruction residual: {:.2e} deg (should be ~0)".format(resid))
say("  note: if the target tool rotates about LOCAL axes that follow the")
say("        object, apply the SAME angles in reverse order: rotZ -> rotY -> rotX")
say()
say("  python : obj.Placement.Rotation = App.Rotation({:.9f}, {:.9f}, {:.9f})"
    .format(yaw, pitch, roll))
say()
say("check   : normal error {:.2e} deg, edge error {:.2e} deg".format(err_n, err_t))
say("hint    : twisted 180deg? set FLIP_EDGE=True."
    "  Board flipped/inside the wall? set FLIP_NORMAL=True.")

if APPLY_TO_PCB:
    c = f_src.CenterOfMass                      # global centre of mounting face
    delta = App.Placement(App.Vector(0, 0, 0), R, c)   # rotate about c
    pcb_obj.Placement = delta.multiply(pcb_obj.Placement)
    App.ActiveDocument.recompute()
    say("applied : PCB rotated about its mounting-face centre {}".format(fmt(c)))

Если вы проектируете платы для автономных устройств, одной компоновкой дело не ограничивается: нужно выбрать источник питания, оценить время работы и учесть режимы энергопотребления микроконтроллера.

28 сентября в 20:00 на бесплатном уроке Otus «Автономное питание устройств на микроконтроллерах STM32» Артём Бражников разберёт эти вопросы на примере схемотехники устройства с батарейным питанием. Также можно будет задать вопросы и познакомиться с форматом обучения на курсе «Электроника и электротехника».

Комментарии (1)


  1. Brazil
    21.09.2026 20:13

    Всё это уже не актуально.
    Нет никакого смысла делится макросами на питоне.
    Сейчас Claude создает схему с нуля в KiCad сам!
    Сам находит все микросхемы в стоках, сам подключает цепи, сам генерит посадочные места. Размещает и позиционирует тоже по запросу неплохо. Остается слабым местом трассировка и правила.
    Да, многое делает скриптами, ну так этих скриптов он десятками создает.
    Опоздал немного автор со своей инновацией.

    Но сама мысль правильная. Коммерческие тулсы с закрытыми форматами типа Altium внезапно стали неюзабельными. Потому что в них Claude ничего не может толком поделать из-за закрытости. А открытые тулсы с текстовыми форматами исходников для Claude как родные.