Skip to main content

Overview

Action plugins allow you to extend KiCad’s PCB editor with custom interactive tools. They appear as menu items and optional toolbar buttons, providing a way to automate repetitive tasks or add new functionality.

Plugin Architecture

Action plugins are implemented through the ACTION_PLUGIN base class defined in /pcbnew/action_plugin.h:
class ACTION_PLUGIN
{
public:
    virtual ~ACTION_PLUGIN();
    
    // Required methods to implement
    virtual wxString GetCategoryName() = 0;
    virtual wxString GetName() = 0;
    virtual wxString GetClassName() = 0;
    virtual wxString GetDescription() = 0;
    virtual bool GetShowToolbarButton() = 0;
    virtual wxString GetIconFileName( bool aDark ) = 0;
    virtual wxString GetPluginPath() = 0;
    virtual void* GetObject() = 0;
    virtual void Run() = 0;
    
    // Registration
    void register_action();
    
    // UI elements
    int m_actionMenuId;
    int m_actionButtonId;
    wxBitmap iconBitmap;
    bool show_on_toolbar;
};

Creating an Action Plugin

Basic Structure

Create a Python file that inherits from ActionPlugin:
import pcbnew
import os

class MyActionPlugin(pcbnew.ActionPlugin):
    def defaults(self):
        """Define plugin metadata"""
        self.name = "My Custom Tool"
        self.category = "Modify PCB"
        self.description = "Does something useful with the PCB"
        self.show_toolbar_button = True
        self.icon_file_name = os.path.join(
            os.path.dirname(__file__), 'icon.png'
        )
        self.dark_icon_file_name = os.path.join(
            os.path.dirname(__file__), 'icon_dark.png'
        )
    
    def Run(self):
        """Execute when the plugin is invoked"""
        board = pcbnew.GetBoard()
        # Your plugin logic here
        pcbnew.Refresh()  # Update display

# Register the plugin
MyActionPlugin().register()

Plugin Metadata

The defaults() method sets plugin properties:
  • name: Display name in menu (required)
  • category: Submenu grouping (required)
  • description: Tooltip and help text (required)
  • show_toolbar_button: Add to toolbar (default: False)
  • icon_file_name: Path to icon (24x24 PNG recommended)
  • dark_icon_file_name: Icon for dark theme (optional)

Real-World Examples

Example 1: Automatic Border Creator

Based on /demos/python_scripts_examples/action_menu_add_automatic_border.py:
from pcbnew import *

class AddAutomaticBorder(ActionPlugin):
    """
    Automatically creates or updates PCB edges to include all elements
    """
    
    def defaults(self):
        self.name = "Add or update automatic PCB edges"
        self.category = "Modify PCB"
        self.description = "Automatically add or update edges on an existing PCB"
        # Offset between elements and edge (2.54mm)
        self.offset = FromMM(2.54)
        # Snap to grid (2.54mm)
        self.grid = FromMM(2.54)
    
    def min(self, a, b):
        """Helper to find minimum, handling None values"""
        if a is None:
            return b
        if b is None:
            return a
        return a if a < b else b
    
    def max(self, a, b):
        """Helper to find maximum, handling None values"""
        if a is None:
            return b
        if b is None:
            return a
        return a if a > b else b
    
    def Run(self):
        pcb = GetBoard()
        min_x = min_y = max_x = max_y = None
        
        # Find bounding box of all zones
        for i in range(pcb.GetAreaCount()):
            bbox = pcb.GetArea(i).GetBoundingBox()
            min_x = self.min(min_x, bbox.GetX())
            min_y = self.min(min_y, bbox.GetY())
            max_x = self.max(max_x, bbox.GetX() + bbox.GetWidth())
            max_y = self.max(max_y, bbox.GetY() + bbox.GetHeight())
        
        # Include all tracks
        for track in pcb.GetTracks():
            min_x = self.min(min_x, track.GetStart().x)
            min_y = self.min(min_y, track.GetStart().y)
            max_x = self.max(max_x, track.GetEnd().x)
            max_y = self.max(max_y, track.GetEnd().y)
        
        # Include all footprints
        for footprint in pcb.GetFootprints():
            bbox = footprint.GetBoundingBox()
            min_x = self.min(min_x, bbox.GetX())
            min_y = self.min(min_y, bbox.GetY())
            max_x = self.max(max_x, bbox.GetX() + bbox.GetWidth())
            max_y = self.max(max_y, bbox.GetY() + bbox.GetHeight())
        
        # Add offset
        min_x -= self.offset
        min_y -= self.offset
        max_x += self.offset
        max_y += self.offset
        
        # Snap to grid
        min_x = min_x - (min_x % self.grid)
        min_y = min_y - (min_y % self.grid)
        if (max_x % self.grid) != 0:
            max_x = max_x - (max_x % self.grid) + self.grid
        if (max_y % self.grid) != 0:
            max_y = max_y - (max_y % self.grid) + self.grid
        
        # Create edge shapes
        self._create_edge(pcb, min_x, min_y, min_x, max_y)  # West
        self._create_edge(pcb, min_x, min_y, max_x, min_y)  # North
        self._create_edge(pcb, max_x, min_y, max_x, max_y)  # East
        self._create_edge(pcb, min_x, max_y, max_x, max_y)  # South
    
    def _create_edge(self, pcb, x1, y1, x2, y2):
        """Create or update an edge line"""
        edge = PCB_SHAPE()
        edge.SetLayer(Edge_Cuts)
        edge.SetStart(wxPoint(x1, y1))
        edge.SetEnd(wxPoint(x2, y2))
        pcb.Add(edge)

AddAutomaticBorder().register()

Example 2: Footprint Generator

Based on /demos/python_scripts_examples/action_plugin_test_undoredo.py:
import pcbnew
import random

class GenerateRandomContent(pcbnew.ActionPlugin):
    
    def defaults(self):
        self.name = "Generate Random Test Content"
        self.category = "Test Tools"
        self.description = "Generate random footprints and tracks for testing"
    
    def Run(self):
        self.pcb = pcbnew.GetBoard()
        random.seed()
        
        for i in range(10):
            # Create random track segments
            seg = pcbnew.PCB_SHAPE()
            seg.SetLayer(random.choice([
                pcbnew.Edge_Cuts,
                pcbnew.Cmts_User,
                pcbnew.Eco1_User
            ]))
            seg.SetStart(pcbnew.VECTOR2I_MM(
                random.randint(10, 100),
                random.randint(10, 100)
            ))
            seg.SetEnd(pcbnew.VECTOR2I_MM(
                random.randint(10, 100),
                random.randint(10, 100)
            ))
            self.pcb.Add(seg)
            
            # Create random tracks and vias
            if i % 2 == 0:
                t = pcbnew.PCB_TRACK(None)
            else:
                t = pcbnew.PCB_VIA(None)
                t.SetViaType(pcbnew.VIATYPE_THROUGH)
                t.SetDrill(pcbnew.FromMM(random.randint(1, 20) / 10.0))
            
            t.SetStart(pcbnew.VECTOR2I_MM(
                random.randint(100, 150),
                random.randint(100, 150)
            ))
            t.SetEnd(pcbnew.VECTOR2I_MM(
                random.randint(100, 150),
                random.randint(100, 150)
            ))
            t.SetWidth(pcbnew.FromMM(random.randint(1, 15) / 10.0))
            t.SetLayer(random.choice([pcbnew.F_Cu, pcbnew.B_Cu]))
            self.pcb.Add(t)
            
            # Create random connector footprints
            self.create_fpc_footprint(random.randint(2, 40))
    
    def create_fpc_footprint(self, pads):
        """Create an FPC connector footprint"""
        footprint = pcbnew.FOOTPRINT(self.pcb)
        footprint.SetReference(f"FPC{pads}")
        footprint.Reference().SetPosition(pcbnew.VECTOR2I_MM(-1, -1))
        self.pcb.Add(footprint)
        
        # Create signal pads
        pad_size = pcbnew.VECTOR2I_MM(0.25, 1.6)
        for n in range(pads):
            pad = pcbnew.PAD(footprint)
            pad.SetSize(pad_size)
            pad.SetShape(pcbnew.PAD_SHAPE_RECT)
            pad.SetAttribute(pcbnew.PAD_ATTRIB_SMD)
            pad.SetLayerSet(pad.SMDMask())
            pad.SetPosition(pcbnew.VECTOR2I_MM(0.5 * n, 0))
            pad.SetPadName(str(n + 1))
            footprint.Add(pad)
        
        # Create mounting pads
        mount_size = pcbnew.VECTOR2I_MM(1.50, 2.0)
        for i, x_offset in enumerate([-1.6, (pads-1)*0.5 + 1.6]):
            pad = pcbnew.PAD(footprint)
            pad.SetSize(mount_size)
            pad.SetShape(pcbnew.PAD_SHAPE_RECT)
            pad.SetAttribute(pcbnew.PAD_ATTRIB_SMD)
            pad.SetLayerSet(pad.SMDMask())
            pad.SetPosition(pcbnew.VECTOR2I_MM(x_offset, 1.3))
            pad.SetPadName("0")
            footprint.Add(pad)
        
        # Add silkscreen
        silk = pcbnew.PCB_SHAPE(footprint)
        silk.SetStart(pcbnew.VECTOR2I_MM(-1, 0))
        silk.SetEnd(pcbnew.VECTOR2I_MM(0, 0))
        silk.SetWidth(pcbnew.FromMM(0.2))
        silk.SetLayer(pcbnew.F_SilkS)
        silk.SetShape(pcbnew.S_SEGMENT)
        footprint.Add(silk)
        
        # Random placement
        footprint.SetPosition(pcbnew.VECTOR2I_MM(
            random.randint(20, 200),
            random.randint(20, 150)
        ))

GenerateRandomContent().register()

Example 3: Move Items Randomly

import pcbnew
import random

class MoveItemsRandomly(pcbnew.ActionPlugin):
    
    def defaults(self):
        self.name = "Move Elements Randomly"
        self.category = "Test Undo/Redo"
        self.description = "Move all board elements by random offsets"
    
    def Run(self):
        pcb = pcbnew.GetBoard()
        
        # Move zones
        for i in range(pcb.GetAreaCount()):
            area = pcb.GetArea(i)
            area.Move(pcbnew.VECTOR2I_MM(
                random.randint(-20, 20),
                random.randint(-20, 20)
            ))
        
        # Move and randomly flip footprints
        for footprint in pcb.GetFootprints():
            footprint.Move(pcbnew.VECTOR2I_MM(
                random.randint(-20, 20),
                random.randint(-20, 20)
            ))
            if random.randint(0, 10) > 5:
                footprint.Flip(footprint.GetPosition(), True)
        
        # Move tracks
        for track in pcb.GetTracks():
            track.Move(pcbnew.VECTOR2I_MM(
                random.randint(-20, 20),
                random.randint(-20, 20)
            ))
        
        # Move drawings
        for draw in pcb.GetDrawings():
            draw.Move(pcbnew.VECTOR2I_MM(
                random.randint(-20, 20),
                random.randint(-20, 20)
            ))

MoveItemsRandomly().register()

Plugin Management

The ACTION_PLUGINS class manages all registered plugins:
class ACTION_PLUGINS
{
public:
    static void register_action( ACTION_PLUGIN* aAction );
    static bool deregister_object( void* aObject );
    static ACTION_PLUGIN* GetAction( const wxString& aName );
    static ACTION_PLUGIN* GetAction( int aIndex );
    static ACTION_PLUGIN* GetActionByMenu( int aMenu );
    static ACTION_PLUGIN* GetActionByButton( int aButton );
    static ACTION_PLUGIN* GetActionByPath( const wxString& aPath );
    static int GetActionsCount();
    static bool IsActionRunning();
    static void SetActionRunning( bool aRunning );
    static void UnloadAll();
};

Plugin Installation

Directory Structure

Plugins are loaded from these directories (in order):
  1. Stock plugins: <kicad>/scripting/plugins/
  2. User plugins: ~/.kicad/scripting/plugins/ (Linux/macOS)
  3. User plugins: %APPDATA%/kicad/scripting/plugins/ (Windows)
  4. Third-party: $KICAD_3RD_PARTY/plugins/

File Organization

plugins/
├── my_plugin.py          # Simple single-file plugin
└── complex_plugin/       # Multi-file plugin
    ├── __init__.py       # Must contain plugin class
    ├── helpers.py
    ├── icon.png
    └── icon_dark.png

Loading Process

Plugins are automatically loaded on startup via LoadPlugins() in /scripting/kicadplugins.i:
LoadPlugins(
    bundlepath="/usr/share/kicad/scripting",
    userpath="~/.kicad/scripting",
    thirdpartypath="$KICAD_3RD_PARTY"
)

Undo/Redo Support

Action plugins automatically support undo/redo when using proper methods:
def Run(self):
    board = pcbnew.GetBoard()
    
    # Use RemoveNative() for undo support
    for footprint in board.GetFootprints():
        board.RemoveNative(footprint)
    
    # Changes are automatically tracked
    # User can undo/redo after plugin execution

Icon Guidelines

  • Size: 24x24 pixels (PNG format)
  • Style: Monochrome or simple colors
  • Transparency: Use alpha channel for toolbar integration
  • Dark theme: Provide dark_icon_file_name for dark mode

Debugging

Error Handling

import traceback

class MyPlugin(pcbnew.ActionPlugin):
    def Run(self):
        try:
            # Your code here
            board = pcbnew.GetBoard()
        except Exception as e:
            # Errors are shown in KiCad's error dialog
            import wx
            wx.MessageBox(
                f"Error: {str(e)}\n\n{traceback.format_exc()}",
                "Plugin Error",
                wx.OK | wx.ICON_ERROR
            )

Logging

Avoid using print() as it can cause IO exceptions. Use wx dialogs or write to files instead.

Best Practices

  1. Always call Refresh(): Update the display after modifications
  2. Use GetBoard(): Don’t cache the board object
  3. Handle errors gracefully: Wrap code in try/except blocks
  4. Validate inputs: Check that board elements exist before accessing
  5. Use proper units: Always convert with FromMM() or FromMils()
  6. Test undo/redo: Ensure your plugin works with the undo system
  7. Avoid blocking operations: Long operations should show progress

Common Issues

Plugin Not Loading

Check PLUGIN_DIRECTORIES_SEARCH and NOT_LOADED_WIZARDS:
import pcbnew
print(pcbnew.GetWizardsSearchPaths())
print(pcbnew.GetUnLoadableWizards())
Ensure you:
  • Called .register() at module level
  • Implemented all required methods
  • Returned valid strings from metadata methods

See Also

Build docs developers (and LLMs) love