Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
# KiCAD Replicate Layout Plugin

> [!NOTE]
> The functionality of this plugin is now available in KiCad natively! As such, this plugin is no longer updated. For details on KiCad's multichannel implementation, see [official KiCad documentation](https://docs.kicad.org/10.0/en/pcbnew/pcbnew.html#multichannel).
> This change ports the plugin to **KiCad 10** (targets KiCad 10.0). KiCad 10
> removed the `pcbnew.ID_V_TOOLBAR` constant the dialog relied on, which made the
> plugin raise on every run (issue #87); this is fixed with a fallback that still
> works on KiCad 9. It has been verified to reproduce the KiCad 9 behaviour exactly
> (see *Testing* below). Similar functionality is also available in KiCad natively;
> see the [official KiCad multichannel documentation](https://docs.kicad.org/10.0/en/pcbnew/pcbnew.html#multichannel).
> The plugin remains useful for replication driven by hierarchical sheets.

The repository includes code for KiCad Action plugin which replicates part of the PCB layout.

Expand All @@ -25,7 +31,32 @@ By default, only objects which are fully contained in the bounding box constitut

The preferred way to install the plugin is via KiCad's Plugin and Content Manager (PCM). Installation on non-networked devices can be done by downloading [the latest release](https://github.com/MitjaNemec/ReplicateLayout/releases/latest) and installing in the PCM using the `Install from file` option.

## Testing

The plugin ships with a headless, GUI-free test-suite driven entirely through the
pcbnew scripting API (`test_replicate_layout.py`). It runs a number of replication
scenarios (inner/outer hierarchy levels, flipped anchors, "contained" vs.
"intersecting" selection, removal of existing copper, grouping and footprint-text
replication), plus regression tests for issue #86 (already-grouped destination
footprints). Each replication result is checked for **geometric correctness**:
every replicated section must keep the same internal geometry (pairwise distances,
relative orientation and relative flip) as the source section. This check is
independent of any coordinate convention, so it validates the result on its own
merits.

Run it with the Python interpreter bundled with KiCad, for example on macOS:

```bash
/Applications/KiCad/KiCad.app/Contents/Frameworks/Python.framework/Versions/3.9/bin/python3.9 test_replicate_layout.py
```

The suite can additionally compare output against committed reference signatures to
prove byte-for-byte parity with a previous KiCad version: run with `--gen-refs`
under that KiCad (writing `test_refs/*.json`), then a normal run under the new KiCad
verifies the output matches. This is how the KiCad 9 → 10 parity was confirmed.

**Author :** doc.dr. Mitja Nemec
**KiCad 10 port :** 2026
**Date :** 2025


49 changes: 41 additions & 8 deletions action_replicate_layout.py
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,38 @@ def __init__(self):
def defaults(self):
pass

def get_dialog_position(self, dlg_size, logger):
"""Compute where to place the dialog, next to the right vertical toolbar.

KiCad 9 exposed ``pcbnew.ID_V_TOOLBAR`` which let us query the toolbar's
on-screen position. KiCad 10 removed that constant when the toolbars were
reworked, so we try it for backward compatibility and otherwise fall back
to the right edge of the PCB editor frame. Returns a ``wx.Point`` or
``None`` (in which case the dialog keeps its centred position)."""
# KiCad 9 path: query the vertical toolbar directly
toolbar_id = getattr(pcbnew, 'ID_V_TOOLBAR', None)
if toolbar_id is not None and self.frame is not None:
try:
toolbar = self.frame.FindWindowById(toolbar_id)
if toolbar is not None:
toolbar_pos = toolbar.GetScreenPosition()
logger.info("Toolbar position: " + repr(toolbar_pos))
return wx.Point(toolbar_pos[0] - dlg_size[0], toolbar_pos[1])
except Exception:
logger.info("Could not query toolbar position, using fallback")
# KiCad 10 fallback: place at the right edge of the editor frame
if self.frame is not None:
try:
frame_pos = self.frame.GetScreenPosition()
frame_size = self.frame.GetSize()
x = frame_pos[0] + frame_size[0] - dlg_size[0]
y = frame_pos[1] + 100
logger.info("Using frame-relative dialog position")
return wx.Point(x, y)
except Exception:
logger.info("Could not compute frame-relative position")
return None

def Run(self):
# grab PCB editor frame
self.frame = wx.FindWindowByName("PcbFrame")
Expand Down Expand Up @@ -505,15 +537,16 @@ def Run(self):
try:
dlg = ReplicateLayoutDialog(self.frame, replicator, src_anchor_fp_reference, logger)
dlg.CenterOnParent()
# find position of right toolbar
toolbar_pos = self.frame.FindWindowById(pcbnew.ID_V_TOOLBAR).GetScreenPosition()
logger.info("Toolbar position: " + repr(toolbar_pos))
# find site of dialog
# place the dialog next to the right (vertical) toolbar
# KiCad 9 exposed the toolbar window id as pcbnew.ID_V_TOOLBAR, but this
# constant was removed in KiCad 10 (the toolbars were reworked). Fall back
# gracefully to placing the dialog at the right edge of the editor frame so
# the plugin keeps working across both KiCad versions.
size = dlg.GetSize()
# place the dialog by the right toolbar
dialog_position = wx.Point(toolbar_pos[0] - size[0], toolbar_pos[1])
logger.info("Dialog position: " + repr(dialog_position))
dlg.SetPosition(dialog_position)
dialog_position = self.get_dialog_position(size, logger)
if dialog_position is not None:
logger.info("Dialog position: " + repr(dialog_position))
dlg.SetPosition(dialog_position)
dlg.Show()
except Exception:
logger.exception("Fatal error when making an instance of replicator")
Expand Down
6 changes: 3 additions & 3 deletions deprecation_dialog_GUI.fbp
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@
<property name="gripper">0</property>
<property name="hidden">0</property>
<property name="id">wxID_ANY</property>
<property name="label">&lt;b&gt;Deprecation warrning&lt;/b&gt;</property>
<property name="label">&lt;b&gt;Notice&lt;/b&gt;</property>
<property name="markup">1</property>
<property name="max_size"></property>
<property name="maximize_button">0</property>
Expand Down Expand Up @@ -159,7 +159,7 @@
<property name="gripper">0</property>
<property name="hidden">0</property>
<property name="id">wxID_ANY</property>
<property name="label">This plugin will only be supported until KiCad 10.0 is released</property>
<property name="label">This plugin has been ported to work with KiCad 10.</property>
<property name="markup">0</property>
<property name="max_size"></property>
<property name="maximize_button">0</property>
Expand Down Expand Up @@ -221,7 +221,7 @@
<property name="gripper">0</property>
<property name="hidden">0</property>
<property name="id">wxID_ANY</property>
<property name="label">After KiCad 10.0 is released I will neither port the plugin neither continue with maintenance.</property>
<property name="label">Note: KiCad 10 also includes a native multichannel layout tool. This plugin remains available for hierarchical-sheet based replication and continues to work on KiCad 10.</property>
<property name="markup">0</property>
<property name="max_size"></property>
<property name="maximize_button">0</property>
Expand Down
2 changes: 2 additions & 0 deletions make_a_package.sh
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ inkscape replicate_layout_light.svg -w 64 -h 64 -o replicate_layout.png
# refresh the GUI design
wxformbuilder -g replicate_layout_GUI.fbp
wxformbuilder -g error_dialog_GUI.fbp
wxformbuilder -g conn_issue_dialog_GUI.fbp
wxformbuilder -g deprecation_dialog_GUI.fbp

# grab version and parse it into metadata.json
cp metadata_source.json metadata_package.json
Expand Down
4 changes: 2 additions & 2 deletions metadata_source.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@
{
"version": "VERSION",
"status": "stable",
"kicad_version": "9.0",
"kicad_version_max": "9.1",
"kicad_version": "10.0",
"kicad_version_max": "10.99",
"download_url": "https://github.com/MitjaNemec/ReplicateLayout/releases/download/VERSION/ReplicateLayout-VERSION-pcm.zip",
"download_sha256": "SHA256",
"download_size": DOWNLOAD_SIZE,
Expand Down
12 changes: 8 additions & 4 deletions replicate_layout.py
Original file line number Diff line number Diff line change
Expand Up @@ -414,10 +414,14 @@ def prepare_for_replication(self, level, settings):
for fp in dst_sheet_fps:
fp_group = fp.fp.GetParentGroup()
dst_group = "Replicated Group {}".format(sheet)
if (fp_group is not None) and (fp_group != dst_group):
raise LookupError(f"Destination footprint {fp} is a member of a different group ({fp_group}). "
f"All destination footprints have either have to be members of destination group "
f"({dst_group}) or no group at all.")
# GetParentGroup() returns a PCB_GROUP (or None), so compare its
# name to the expected destination group name. Comparing the object
# directly to the string is always unequal and made the plugin fail
# whenever a destination footprint was already grouped (issue #86).
if (fp_group is not None) and (fp_group.GetName() != dst_group):
raise LookupError(f"Destination footprint {fp.ref} is a member of a different group "
f"({fp_group.GetName()}). All destination footprints either have to be "
f"members of the destination group ({dst_group}) or no group at all.")

@staticmethod
def get_footprint_id(footprint):
Expand Down
Loading