This post describes pcbpack, a Python script that automates the process of laying out multiple PCBs on a single panel for CNC milling. The tool is part of my gctools package, a Python framework and set of tools for manipulating gcode available on GitHub.

When I started learning how to mill PCBs with a CNC I was making a single board at a time which turned out to be time consuming and prone to error. Each board needed to be positioned and aligned, the blank PCB needed to be trimmed to size and every milling operation presented it's own unique set of issues. Given that blank boards come in standard sizes (I get mine from the local Jaycar store which stock single and double sided 150x75mm and 150x150mm blanks) it made sense to try and automate the process to fit as many boards on to a single blank as I could.

The goal was to maintain a repository of PCBs and be able to pick and choose the ones I wanted to fill a single panel as much as possible. Using my gctools Python library I came up with a script that would do exactly that for me. A sample run looks like this:

C:\Sandbox\cnc-router\gctools>python --panel small --output scratch\20150823a tgl-011 wylass/phone_adapter wylass/phone_adapter benchtool/v4ch benchtool/icp tgl-009  
INFO: Selected layout ...  
INFO:   Board 56.30 x 46.14 @ 0.00, 0.00 (Rot = True)  
INFO:   Board 32.80 x 38.52 @ 57.00, 0.00 (Rot = True)  
INFO:   Board 27.98 x 38.26 @ 90.00, 0.00 (Rot = True)  
INFO:   Board 24.55 x 24.55 @ 57.00, 39.00 (Rot = True)  
INFO:   Board 24.55 x 24.55 @ 82.00, 39.00 (Rot = True)  
INFO:   Board 23.53 x 22.01 @ 107.00, 39.00 (Rot = True)  
INFO: Optimising ...  
INFO:   Top copper  
INFO:     Original - 0 operations, 0mm air travel  
INFO:     No optimisation can be performed.  
INFO:   Bottom copper  
INFO:     Original - 5149 operations, 2734mm air travel  
INFO:     Optimised - 980mm air travel, 35 % of original.  
INFO:   Board outline  
INFO:     Original - 72 operations, 280mm air travel  
INFO:     Optimised - 256mm air travel, 91 % of original.  
INFO:   Drill (1.5mm)  
INFO:     Original - 10 operations, 146mm air travel  
INFO:     Optimised - 142mm air travel, 97 % of original.  
INFO:   Drill (1.0mm)  
INFO:     Original - 143 operations, 1422mm air travel  
INFO:     Optimised - 768mm air travel, 54 % of original.  
INFO: Generating scratch\20150823a_02_bottom.ngc  
INFO:   X: 2.9906, 127.6944 Y: 2.2032, 60.5580 Z: -0.0508, 3.0000  
INFO: Generating scratch\20150823a_99_outline.ngc  
INFO:   X: 2.0000, 128.5326 Y: 2.0000, 61.5486 Z: -2.0000, 3.0000  
INFO: Generating scratch\20150823a_03_drill_1.0.ngc  
INFO:   X: 4.9718, 125.3068 Y: 4.9718, 54.5128 Z: -2.0000, 3.0000  
INFO: Generating scratch\20150823a_04_drill_1.5.ngc  
INFO:   X: 70.2522, 100.2560 Y: 47.3754, 58.3736 Z: -2.0000, 3.0000  

In this case there are 6 boards being placed on a 150x75mm panel - an ATtiny84 based sensor module and a collection of breakout and adapter PCBs to fill out the space. The layout it came up with looks like this:

TODO: PCB Layout Image

The output of the script is a set of gcode files, one for each tool head required and the isolation routing files split between the top and bottom copper layers.

There are a few pre-requisites for using the script; firstly it expects a repository of gcode files for each PCB with each PCB having a child directory all to itself. Secondly it uses a JSON formatted configuration file called pcbpack.json in the same directory as the script to define the available panel layouts as well as some default values for various command line parameters. My local configuration, which is also in the GitHub repository, looks like this:

# Configuration file for pcbpack.
  # Location of repository
  "boards": "boards",
  # Panel definitions
  "panels": {
    "small_full": {
      "description": "Single sided, screw lock",
      "width": 150,
      "height": 75,
      # Locked regions (can't place PCB here)
      "locked": [
        { "x": 0, "y": 0, "w": 10, "h": 10 },
        { "x": 0, "y": 65, "w": 10, "h": 10 },
        { "x": 140, "y": 0, "w": 10, "h": 10 },
        { "x": 140, "y": 65, "w": 10, "h": 10 }
    "small": {
      "description": "Single sided, avoid screws",
      "width": 135,
      "height": 70
    "large": {
      "description": "Single sided, large, avoid screws",
      "width": 135,
      "height": 145

The boards entry specifies the base directory for the boards and can be a path relative to the directory containing the script or an absolute path. When you specify a board name it is treated as a directory relative to the specified repository location. I keep an archive of boards in a local GIT repository cloned at this location so I can easily include older designs in a milling session.

The panels entry defines the available panel sizes and can defined excluded regions where boards cannot be placed. The first entry in the example above (the small_full panel) uses the full 150x75mm panel and defines exclusions where the mounting screws are located. The other two entries (small and large) are for 150x75mm and 150x150mm panels respectively but assume the working origin is on the inside of the mounting screws.

When milling the generated gcode assumes that the WCS origin is set to the bottom left corner of the panel and that all operations occur in the positive X and Y axis.

TODO: OpenSCAM Screenshot

As a bonus the script also generates a set of PNG images to show the cutting operations for each of the generated files as well as an OpenSCAM project file so you can visualise the process.

Input Files

The script expects isolation routing files generated by Line Grinder from gerber files created by your PCB layout application. I used DesignSpark rather than Eagle for schematic capture and PCB layout so pcb-gcode wasn't an option for me. The LineGrinder tool can be used on any gerber file which makes it a more portable solution.

The pcbpack tool requires the bottom copper isolation routing file, the board edge milling file and the original Excellon drill file generated by your PCB layout software. It can also use the top copper isolation file but this is an incomplete feature at this stage. A typical board directory will contain files like this:

frontpanel - Board Outline_EDGEMILL_GCODE.ngc  
frontpanel - Bottom Copper_ISOLATION_GCODE.ngc  
frontpanel - Drill Data - [Through Hole].drl  
frontpanel - Top Copper_ISOLATION_GCODE.ngc  

When generating the files with LineGrinder please keep the default file names that it assigns - pcbpack uses these to determine which file is which. The Excellon drill file is identified by its file extension, you can call it whatever you like as long as there is only one in the directory.

TODO: Isolation Routing Files

The script expects all the files to be from the point of view of the top of the board - it will apply all necessary transformations itself to ensure the generated gcode is correct for the side of the board being operated on. This means you will have to change the default settings of LineGrinder to disable any X or Y flip operations performed on the gcode.

Laying Out The Panel

When the script loads the files for each PCB it makes some modifications to them to make it easier to lay out the final result. The board dimensions are determined from the edge milling gcode (only rectangular boards are supported) and the files are translated to ensure they have a common zero origin. The Excellon drill file is used to generate gcode for drilling, one file for each drill diameter found in the file.

TODO: Copper Tearing

I add some additional cutting operations to the bottom copper layer at this stage as well - an outline of the PCB on the inner edge and a circle at the correct diameter for each drill point. This stops the copper from tearing during the edge milling and drilling operations and make a big difference to the quality of the final board. The image above shows a board milled without these lines, you can see how the edges of the board and drill holes lift the surrounding copper.

I then use a simple bin packing algorithm to find the best fit for the boards on the specified panel. Unfortunately the current implementation is not very efficient - the time taken is about 2^N where N is the number of boards being placed; laying out 6 boards will take 16 times longer than laying out 2.


As a final step before writing out the generated gcode files I perform a simple optimisation process on them to minimise the number of non-cutting moves.

To do this I convert the file into a sequence of cutting moves - points (drilling), lines and arcs. I then look for the cutting move with the closest end point to the current location, reversing the direction if neccessary. If the end point is at the current position the cutting movement is continued, if not the tool tip is retracted, moved and a penetration cycle performed before the cutting operation is executed. The algorithm is simple but the result can cut down the total milling time dramatically - reducing the number of movements to a third or less of the original.

This optimiser is not limited to PCB files, the optimise() function is part of the general gctools library and you can use it in your own scripts. There are some caveats though:

  • The optimiser expects the source gcode to only contain two Z levels - one for cutting and one for safe movement. It determines these by looking at the bounding box for the source file, if it uses a range of Z levels you will not get the results you expect.
  • The penetration and cutting feed rates are determined using a best guess analysis of the source file by looking at the G1 commands. Once again, if you use a range of values you will not get the results you expect.

As with the rest of the gctools code you should inspect the generated code carefully before running it on your CNC.

Output Files

The final result is a set of gcode files for all the steps needed to mill the PCB - top and bottom copper isolation milling, a drill file for each diameter drill hole needed and an edge milling file to cut the PCBs out of the panel.

The files generated for the example at the start of post are:


As you can see the files are numbered to indicate the order in which they should be run and named so it is clear what their purpose is.

Next Steps

Automating the milling workflow has made a big difference to how I produce PCBs - I can now go from selecting which boards to mill to a complete milled panel in about an hour and a half. The boards shown below are the finished product of the example I have been using here - milled and ready to solder in less time than it took to write this post.

Finished Boards

Having a consistantly repeatable procedure with minimal setup required means that board production is a minor part of my project - I now find myself in the situation where I can produce boards far faster than I can actually use them.

There is still some work to do on the tool of course - the layout and optimisation algorithms can be tuned for speed and the support for double sided boards is incomplete. For the later I simply need to find a way to ensure the blank PCB is oriented correctly so the top and bottom layers line up accurately - at the moment I'm happy with the single side boards I'm producing so the priority on that task is not high.

If you would like to use the pcbpack tool yourself you can simply clone or download the gctools repository to get it. If you make any modifications or enhancements please send me a pull request.