mmm​.s‑ol.nu

cool visuals with electronics

Automatically Placing Components in KiCAD using Centroid Files

While designing PCBs, I often find myself in a situation where I want to arrange footprints in a very regular and precise pattern.

KiCAD has a scripting API and a Python console, so it should be pretty easy to just come up with something, right? Well sadly as of right now the console UX is… not great. Arrow keys and mouse selection barely work, you can’t recall the last line (which is essential with typos or to tweak numbers) and the API overall just isn’t very concise or quick to work with in that REPL kind of fashion.

So after being in this situation a few times, I figured it might make sense to just write the boilerplate part once up front, and then be able to just generate the positioning info elsewhere in whatever way makes most sense in any case. While thinking about the format, I realized that KiCAD actually already knows a file format for exactly this: When preparing a job for pick-and-place production, KiCAD can export the footprint positioning information in a “.pos” text file (File > Fabrication Outputs > Component Placement (.pos)).

A .pos file looks something like this:

### Module positions - created on Sun Nov 28 13:56:09 2021 ###
### Printed by Pcbnew version kicad 5.1.10
## Unit = mm, Angle = deg.
## Side : All
# Ref     Val                      Package                                   PosX       PosY       Rot  Side
D6        1N4148                   D_SOD-323                              34.5500    31.5880   90.0000  bottom
D10       1N4148                   D_SOD-323                              49.6000    30.5000  180.0000  bottom
D12       1N4148                   D_SOD-323                              35.1500    -5.0120   90.0000  bottom
D15       1N4148                   D_SOD-323                              67.3500    13.5630   90.0000  bottom
D16       1N4148                   D_SOD-323                              49.4000    -6.7000  180.0000  bottom
C1        1uF                      C_0603_1608Metric                      47.9000    45.6000    0.0000  top
C2        20pF                     C_0603_1608Metric                      36.8000    44.8000    0.0000  top
C3        20pF                     C_0603_1608Metric                      36.8000    42.9000    0.0000  top
C4        1uF                      C_0603_1608Metric                      30.9500    50.7155    0.0000  top
C5        0.1uF                    C_0603_1608Metric                      37.8000    47.2000  270.0000  top
RGB1      SK6805                   SK6805-EC15                            21.5000    60.5755    0.0000  top
RGB2      SK6805                   SK6805-EC15                            10.7500    41.9255  180.0000  top
RGB3      SK6805                   SK6805-EC15                             0.0000    23.3255  270.0000  top
RGB5      SK6805                   SK6805-EC15                            43.0000    60.5755    0.0000  top
RGB6      SK6805                   SK6805-EC15                            32.2500    41.9255    0.0000  top
RGB7      SK6805                   SK6805-EC15                            21.5000    23.3255    0.0000  top
RGB8      SK6805                   SK6805-EC15                            10.7500     4.7255    0.0000  top
RGB10     SK6805                   SK6805-EC15                            53.7500    41.9255  270.0000  top
RGB11     SK6805                   SK6805-EC15                            43.0000    23.3255  270.0000  top
RGB12     SK6805                   SK6805-EC15                            32.2500     4.7255    0.0000  top
RGB15     SK6805                   SK6805-EC15                            64.5000    23.3255    0.0000  top
RGB16     SK6805                   SK6805-EC15                            53.7500     4.7255    0.0000  top
U1        ATmega32U2-MU            QFN-32-1EP_5x5mm_P0.5mm_EP3.1x3.1mm    45.2883    41.6267    0.0000  top
U3        USBLC6-2SC6              SOT-23-6                               32.8000    58.4400   90.0000  top
Y1        16MHz                    Crystal_SMD_2520-4Pin_2.5x2.0mm        40.2000    44.0000  270.0000  top
## End

It’s very simple to generate and read, and it contains all the information we need. There’s also a CSV version, but I started with this format for now.

Based on this, I put together a little Plugin that asks for a .pos file and then simply applies the placement info (x/y, rotation, top/bottom) by matching footprints by their Reference. It also compares the Value and Package fields, and shows a warning if they don’t match - this should minimize issues when accidentally loading the wrong placement file.

Here is the script source:

This is currently only compatible with Kicad 6.0+

from pcbnew import wxPointMM, GetBoard, Refresh, ActionPlugin
import wx
import re


class MainDialog(wx.Dialog):
    def __init__(self, *args, **kwargs):
        super(MainDialog, self).__init__(*args, **kwargs)

        self.SetTitle("Load Component Placement")

        vbox = wx.BoxSizer(wx.VERTICAL)

        static_text = wx.StaticText(
            self,
            label="Select a .pos ASCII file generated by KiCAD or an external Tool.",
        )
        vbox.Add(static_text, wx.SizerFlags().Center().Border(wx.ALL, 10))

        self.file = wx.FilePickerCtrl(self, style=wx.FD_OPEN | wx.FD_FILE_MUST_EXIST)
        self.file.Bind(wx.EVT_FILEPICKER_CHANGED, self.onChangePath)
        vbox.Add(self.file, wx.SizerFlags().Expand().Border(wx.ALL, 5))

        cancel = wx.Button(self, label="Cancel")
        cancel.Bind(wx.EVT_BUTTON, self.onCancel)

        self.ok = wx.Button(self, label="Ok")
        self.ok.Disable()
        self.ok.Bind(wx.EVT_BUTTON, self.onOk)

        hbox = wx.BoxSizer(wx.HORIZONTAL)
        hbox.Add(cancel, wx.SizerFlags().Border(wx.ALL, 5))
        hbox.AddStretchSpacer()
        hbox.Add(self.ok, wx.SizerFlags().Border(wx.ALL, 5))

        vbox.Add(hbox, wx.SizerFlags().Expand())

        self.SetSizerAndFit(vbox)

    def onChangePath(self, e):
        self.ok.Enable()

    def onOk(self, e):
        file = self.file.GetPath()

        board = GetBoard()
        changes = []

        with open(file, "r") as f:
            for i, line in enumerate(f):
                if line[0] == "#":
                    continue

                parts = re.split(" +", line.strip())

                if len(parts) != 7:
                    wx.MessageBox(
                        "Invalid line {}:\n{}".format(i, line),
                        "Error",
                        wx.OK | wx.ICON_ERROR,
                    )
                    break

                ref, val, pkg, x, y, rot, side = parts
                fp = board.FindFootprintByReference(ref)

                if fp is None:
                    res = wx.MessageBox(
                        "Couldn't find Footprint with reference '{}'. Skip footprint?".format(
                            ref
                        ),
                        "Warning",
                        wx.OK | wx.CANCEL | wx.ICON_WARNING,
                    )
                    if res == wx.CANCEL:
                        return
                    else:
                        continue

                fp_val = fp.GetValue().replace(" ", "_")
                if fp_val != val and val != "*":
                    res = wx.MessageBox(
                        "Footprint '{}' has unexpected value '{}' (expected {}). Skip footprint?".format(
                            ref, fp_val, val
                        ),
                        "Warning",
                        wx.YES_NO | wx.CANCEL | wx.ICON_WARNING,
                    )
                    if res == wx.CANCEL:
                        return
                    elif res == wx.YES:
                        continue

                fp_pkg = fp.GetFPID().GetLibItemName().c_str().replace(" ", "_")
                if fp_pkg != pkg and pkg != "*":
                    res = wx.MessageBox(
                        "Footprint '{}' has unexpected package '{}' (expected {}). Skip footprint?".format(
                            ref, fp_pkg, pkg
                        ),
                        "Warning",
                        wx.YES_NO | wx.CANCEL | wx.ICON_WARNING,
                    )
                    if res == wx.CANCEL:
                        return
                    elif res == wx.YES:
                        continue

                x, y = float(x), -float(y)
                changes.append((fp, wxPointMM(x, y), float(rot), side))

        res = wx.MessageBox(
            "Apply changes to {} footprints?".format(len(changes)),
            "Confirmation",
            wx.YES_NO,
        )
        if res == wx.NO:
            return

        for fp, center, rot, side in changes:
            if fp.IsFlipped() != (side == "bottom"):
                fp.Flip(center, True)
            fp.SetPosition(center)
            fp.SetOrientationDegrees(rot)
            fp.SetModified()
        Refresh()

        self.Destroy()

    def onCancel(self, e):
        self.Destroy()


class LoadComponentPlacementPlugin(ActionPlugin):
    def defaults(self):
        self.name = "Load Component Placement"
        self.category = "Automatization"
        self.description = (
            "Load a Component Placement (.pos) file and apply it to matching footprints"
        )
        self.show_toolbar_button = True

    def Run(self):
        dialog = MainDialog(None)
        dialog.ShowModal()
        dialog.Destroy()


LoadComponentPlacementPlugin().register()

EDIT: an earlier version of the script contained a bug; it would always place components on the top side. This has been fixed by changing line to line.strip() in line 54.