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:
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()
line
to line.strip()
in line 54.