diff --git a/README.md b/README.md
index f6cae1a..482fdc3 100644
--- a/README.md
+++ b/README.md
@@ -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.
@@ -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
diff --git a/action_replicate_layout.py b/action_replicate_layout.py
index 377232b..d4299b1 100644
--- a/action_replicate_layout.py
+++ b/action_replicate_layout.py
@@ -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")
@@ -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")
diff --git a/deprecation_dialog_GUI.fbp b/deprecation_dialog_GUI.fbp
index 6189e95..1ba5e5d 100644
--- a/deprecation_dialog_GUI.fbp
+++ b/deprecation_dialog_GUI.fbp
@@ -97,7 +97,7 @@
0
0
wxID_ANY
- <b>Deprecation warrning</b>
+ <b>Notice</b>
1
0
@@ -159,7 +159,7 @@
0
0
wxID_ANY
- This plugin will only be supported until KiCad 10.0 is released
+ This plugin has been ported to work with KiCad 10.
0
0
@@ -221,7 +221,7 @@
0
0
wxID_ANY
- After KiCad 10.0 is released I will neither port the plugin neither continue with maintenance.
+ 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.
0
0
diff --git a/make_a_package.sh b/make_a_package.sh
index 6400fbf..ed45cbf 100644
--- a/make_a_package.sh
+++ b/make_a_package.sh
@@ -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
diff --git a/metadata_source.json b/metadata_source.json
index 7561480..b66061c 100644
--- a/metadata_source.json
+++ b/metadata_source.json
@@ -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,
diff --git a/replicate_layout.py b/replicate_layout.py
index f60ed48..478930a 100644
--- a/replicate_layout.py
+++ b/replicate_layout.py
@@ -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):
diff --git a/test_helpers.py b/test_helpers.py
new file mode 100644
index 0000000..b6c8ba8
--- /dev/null
+++ b/test_helpers.py
@@ -0,0 +1,250 @@
+# -*- coding: utf-8 -*-
+# test_helpers.py
+#
+# Helpers for the headless ReplicateLayout test-suite.
+#
+# Two independent ways of checking a replication result:
+#
+# * geometric_consistency() - a convention-independent correctness check. The
+# defining property of "replicate layout" is that every replicated footprint
+# keeps the same spatial relationship to its destination anchor as the source
+# footprint has to the source anchor. Rigid motions and reflections preserve
+# pairwise distances, so we verify that the matrix of pairwise distances
+# between the footprints of a section is preserved in every replicated
+# section, and that relative flip / relative orientation are preserved. This
+# needs no assumption about KiCad's rotation sign convention.
+#
+# * board_signature() / compare_signatures() - a canonical, version-independent
+# dump of all replicated geometry, used to compare the output of the current
+# KiCad against a committed reference (captured from KiCad 9) so the suite is
+# a true regression test that the KiCad 10 behaviour matches KiCad 9.
+import math
+import json
+
+import pcbnew
+
+
+# --------------------------------------------------------------------------- #
+# geometric correctness (convention independent)
+# --------------------------------------------------------------------------- #
+
+def _pose(fp):
+ p = fp.GetPosition()
+ return (p.x, p.y, round(fp.GetOrientationDegrees(), 4), bool(fp.IsFlipped()))
+
+
+def _dist(a, b):
+ return math.hypot(a[0] - b[0], a[1] - b[1])
+
+
+def _norm(a):
+ return (a + 180.0) % 360.0 - 180.0
+
+
+def geometric_consistency(board, anchor_ref, src_refs, dst_sections, tol_nm=2000):
+ """Return (ok, problems). See module docstring."""
+ problems = []
+ by_ref = {fp.GetReference(): fp for fp in board.GetFootprints()}
+
+ src_poses = [_pose(by_ref[r]) for r in src_refs]
+ n = len(src_poses)
+ a_idx = src_refs.index(anchor_ref)
+
+ def pdm(poses):
+ return [[_dist(poses[i], poses[j]) for j in range(len(poses))]
+ for i in range(len(poses))]
+
+ src_pdm = pdm(src_poses)
+
+ for s_i, dst_refs in enumerate(dst_sections):
+ if len(dst_refs) != n:
+ problems.append("sheet %d: footprint count %d != %d" % (s_i, len(dst_refs), n))
+ continue
+ dst_poses = [_pose(by_ref[r]) for r in dst_refs]
+ dst_pdm = pdm(dst_poses)
+
+ # pairwise distances preserved
+ for i in range(n):
+ for j in range(n):
+ dd = abs(src_pdm[i][j] - dst_pdm[i][j])
+ if dd > tol_nm:
+ problems.append(
+ "sheet %d: distance %s-%s changed by %.0f nm"
+ % (s_i, src_refs[i], src_refs[j], dd))
+
+ src_a_flip = src_poses[a_idx][3]
+ dst_a_flip = dst_poses[a_idx][3]
+ section_flipped = (src_a_flip != dst_a_flip)
+ src_a_or = src_poses[a_idx][2]
+ dst_a_or = dst_poses[a_idx][2]
+
+ for i in range(n):
+ # relative flip preserved
+ if (src_poses[i][3] != src_a_flip) != (dst_poses[i][3] != dst_a_flip):
+ problems.append("sheet %d: relative flip of %s not preserved"
+ % (s_i, src_refs[i]))
+ # relative orientation preserved (mirrored for flipped sections)
+ src_rel = _norm(src_poses[i][2] - src_a_or)
+ dst_rel = _norm(dst_poses[i][2] - dst_a_or)
+ if not section_flipped:
+ err = abs(_norm(src_rel - dst_rel))
+ else:
+ err = min(abs(_norm(src_rel + dst_rel)), abs(_norm(src_rel - dst_rel)))
+ if err > 0.05:
+ problems.append(
+ "sheet %d: relative orientation of %s off by %.3f deg"
+ % (s_i, src_refs[i], err))
+
+ return (len(problems) == 0, problems)
+
+
+# --------------------------------------------------------------------------- #
+# canonical signature (version independent regression check)
+# --------------------------------------------------------------------------- #
+
+def board_signature(board):
+ sig = {"footprints": {}, "tracks": [], "zones": [], "texts": [], "drawings": []}
+
+ for fp in board.GetFootprints():
+ p = fp.GetPosition()
+ sig["footprints"][fp.GetReference()] = {
+ "x": p.x, "y": p.y,
+ "orient": round(fp.GetOrientationDegrees(), 4),
+ "flip": bool(fp.IsFlipped()),
+ "layer": fp.GetLayerName(),
+ }
+
+ tracks = []
+ for t in board.GetTracks():
+ s = t.GetStart(); e = t.GetEnd()
+ is_via = (t.GetClass() == "PCB_VIA")
+ if is_via:
+ try:
+ width = t.GetWidth(t.TopLayer())
+ except Exception:
+ width = t.GetDrillValue()
+ else:
+ width = t.GetWidth()
+ rec = {"type": t.GetClass(), "start": [s.x, s.y], "end": [e.x, e.y],
+ "width": width, "layer": board.GetLayerName(t.GetLayer()),
+ "net": t.GetNetname()}
+ if is_via:
+ rec["drill"] = t.GetDrillValue()
+ rec["top"] = board.GetLayerName(t.TopLayer())
+ rec["bottom"] = board.GetLayerName(t.BottomLayer())
+ tracks.append(rec)
+ tracks.sort(key=lambda r: (r["type"], r["layer"], r["net"],
+ r["start"], r["end"], r["width"]))
+ sig["tracks"] = tracks
+
+ zones = []
+ for i in range(board.GetAreaCount()):
+ z = board.GetArea(i)
+ rings = []
+ outline = z.Outline()
+ for oi in range(outline.OutlineCount()):
+ o = outline.Outline(oi)
+ rings.append([[o.CPoint(pi).x, o.CPoint(pi).y] for pi in range(o.PointCount())])
+ zones.append({"net": z.GetNetname(),
+ "layers": sorted([board.GetLayerName(l) for l in z.GetLayerSet().Seq()]),
+ "outline": rings})
+ zones.sort(key=lambda r: (r["net"], r["layers"], json.dumps(r["outline"])))
+ sig["zones"] = zones
+
+ texts = []
+ for d in board.GetDrawings():
+ if isinstance(d, pcbnew.PCB_TEXT):
+ p = d.GetPosition()
+ texts.append({"text": d.GetText(), "x": p.x, "y": p.y,
+ "angle": round(d.GetTextAngleDegrees(), 4),
+ "layer": board.GetLayerName(d.GetLayer()),
+ "mirror": bool(d.IsMirrored())})
+ texts.sort(key=lambda r: (r["text"], r["x"], r["y"], r["layer"]))
+ sig["texts"] = texts
+
+ drawings = []
+ for d in board.GetDrawings():
+ if isinstance(d, pcbnew.PCB_TEXT):
+ continue
+ bb = d.GetBoundingBox()
+ drawings.append({"type": d.GetClass(), "layer": board.GetLayerName(d.GetLayer()),
+ "bb": [bb.GetLeft(), bb.GetTop(), bb.GetRight(), bb.GetBottom()]})
+ drawings.sort(key=lambda r: (r["type"], r["layer"], r["bb"]))
+ sig["drawings"] = drawings
+ return sig
+
+
+# --------------------------------------------------------------------------- #
+# signature comparison
+# --------------------------------------------------------------------------- #
+
+def _flat(x):
+ out = []
+ if isinstance(x, list):
+ for e in x:
+ out.extend(_flat(e))
+ else:
+ out.append(x)
+ return out
+
+
+def _match_list(la, lb, key_fields, pos_fields, name, diffs, pos_tol):
+ from collections import defaultdict
+ if len(la) != len(lb):
+ diffs.append("%s count differs %d vs %d" % (name, len(la), len(lb)))
+ ga, gb = defaultdict(list), defaultdict(list)
+ for it in la:
+ ga[tuple(str(it.get(k)) for k in key_fields)].append(it)
+ for it in lb:
+ gb[tuple(str(it.get(k)) for k in key_fields)].append(it)
+ for k in set(ga) | set(gb):
+ A, B = ga.get(k, []), gb.get(k, [])
+ if len(A) != len(B):
+ diffs.append("%s group %s count %d vs %d" % (name, k, len(A), len(B)))
+ used = [False] * len(B)
+ for a in A:
+ best, bi = None, -1
+ for i, b in enumerate(B):
+ if used[i]:
+ continue
+ d = 0.0
+ for pf in pos_fields:
+ av, bv = a.get(pf), b.get(pf)
+ if isinstance(av, list):
+ d += sum((p - q) ** 2 for p, q in zip(_flat(av), _flat(bv))) ** 0.5
+ elif isinstance(av, (int, float)):
+ d += abs(av - bv)
+ if best is None or d < best:
+ best, bi = d, i
+ if bi >= 0:
+ used[bi] = True
+ if best > pos_tol:
+ diffs.append("%s item in group %s pos delta %.0f" % (name, k, best))
+
+
+def compare_signatures(ref, test, pos_tol=1000, ang_tol=0.02):
+ """Return list of human-readable differences (empty == equivalent)."""
+ diffs = []
+ fa, fb = ref["footprints"], test["footprints"]
+ if set(fa) != set(fb):
+ diffs.append("footprint refs differ")
+ else:
+ for r in fa:
+ x, y = fa[r], fb[r]
+ dd = math.hypot(x["x"] - y["x"], x["y"] - y["y"])
+ if dd > pos_tol:
+ diffs.append("fp %s pos delta %.0f nm" % (r, dd))
+ if abs(_norm(x["orient"] - y["orient"])) > ang_tol:
+ diffs.append("fp %s orient delta %.4f" % (r, abs(_norm(x["orient"] - y["orient"]))))
+ if x["flip"] != y["flip"]:
+ diffs.append("fp %s flip differs" % r)
+ if x["layer"] != y["layer"]:
+ diffs.append("fp %s layer differs %s/%s" % (r, x["layer"], y["layer"]))
+ _match_list(ref["tracks"], test["tracks"], ["type", "layer", "net", "width"],
+ ["start", "end"], "tracks", diffs, pos_tol)
+ _match_list(ref["zones"], test["zones"], ["net"], ["outline"], "zones", diffs, pos_tol)
+ _match_list(ref["texts"], test["texts"], ["text", "layer", "mirror"],
+ ["x", "y"], "texts", diffs, pos_tol)
+ _match_list(ref["drawings"], test["drawings"], ["type", "layer"], ["bb"],
+ "drawings", diffs, pos_tol)
+ return diffs
diff --git a/test_replicate_layout.py b/test_replicate_layout.py
index 3b8c537..6fe125d 100644
--- a/test_replicate_layout.py
+++ b/test_replicate_layout.py
@@ -1,162 +1,189 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
+# test_replicate_layout.py
+#
+# Headless test-suite for the ReplicateLayout plugin, driven entirely through
+# the pcbnew scripting (SWIG) API so it can be run without the GUI:
+#
+# test_replicate_layout.py
+#
+# e.g. on macOS:
+# /Applications/KiCad/KiCad.app/Contents/Frameworks/Python.framework/\
+# Versions/3.9/bin/python3.9 test_replicate_layout.py
+#
+# Each scenario:
+# 1. loads a test project,
+# 2. runs a replication with a particular set of options,
+# 3. checks that the result is geometrically correct (every replicated
+# section keeps the same internal geometry as the source section), and
+# 4. if a committed reference signature exists in test_refs/, checks that the
+# produced geometry matches it (this reference was captured from KiCad 9,
+# so a passing run proves the KiCad 10 behaviour matches KiCad 9).
+#
+# Run with --gen-refs to (re)generate the reference signatures from the
+# current KiCad, e.g. to capture a fresh KiCad 9 baseline.
+import json
+import os
+import sys
import unittest
+
import pcbnew
-import logging
-import sys
-import os
-from compare_boards import compare_boards
-from replicate_layout import Replicator
-from replicate_layout import Settings
-
-
-def update_progress(stage, percentage, message=None):
- print(stage)
- print(percentage)
- if message is not None:
- print(message)
-
-
-def test_file(in_filename, test_filename, src_anchor_fp_reference, level, sheets, containing, remove, by_group):
- board = pcbnew.LoadBoard(in_filename)
- # get board information
- replicator = Replicator(board, src_anchor_fp_reference, update_progress)
- # get source footprint info
- src_anchor_fp = replicator.get_fp_by_ref(src_anchor_fp_reference)
- # check if there are at least two sheets pointing to same hierarchical file that the source anchor footprint belongs to
- count = 0
- for filename in replicator.dict_of_sheets.values():
- if filename[1] in src_anchor_fp.filename:
- count = count + 1
- if count < 2:
- raise Exception
- # check if source anchor footprint is on root level
- if len(src_anchor_fp.filename) == 0:
- raise Exception
-
- # have the user select replication level
- levels = src_anchor_fp.filename
- # get the level index from user
- index = levels.index(levels[level])
- # get list of sheets
- sheet_list = replicator.get_sheets_to_replicate(src_anchor_fp, src_anchor_fp.sheet_id[index])
-
- # get anchor footprints
- anchor_footprints = replicator.get_list_of_footprints_with_same_id(src_anchor_fp.fp_id)
- # find matching anchors to matching sheets
- ref_list = []
- for sheet in sheet_list:
- for fp in anchor_footprints:
- a = sheet
- b = fp.sheet_id
- if sheet == fp.sheet_id:
- ref_list.append(fp.ref)
- break
-
- # get the list selection from user
- dst_sheets = [sheet_list[i] for i in sheets]
-
- settings = Settings(rep_tracks=True, rep_zones=True, rep_text=True, rep_drawings=True,
- rep_locked_tracks=True, rep_locked_zones=True, rep_locked_text=True, rep_locked_drawings=True,
- intersecting=not containing,
- group_items=True,
- group_only=False, locked_fps=False,
- remove=False)
-
- (fps, items) = replicator.highlight_set_level(src_anchor_fp.sheet_id[0:index + 1],
- settings)
- replicator.highlight_clear_level(fps, items)
-
- # now we are ready for replication
- replicator.replicate_layout(src_anchor_fp, src_anchor_fp.sheet_id[0:index + 1], dst_sheets,
- settings, rm_duplicates=True)
- out_filename = test_filename.replace("ref", "temp")
- pcbnew.SaveBoard(out_filename, board)
- # test for connectivity isuues
- if replicator.connectivity_issues:
- report_string = ""
- for item in replicator.connectivity_issues:
- report_string = report_string + f"Footprint {item[0]}, pad {item[1]}\n"
- print(f"Make sure that you check the connectivity around:\n" + report_string)
-
- print("comparing boards")
- #return compare_boards(out_filename, test_filename)
-
-@unittest.skip
-class TestBrackets(unittest.TestCase):
- def setUp(self):
- os.chdir(os.path.join(os.path.dirname(os.path.realpath(__file__)), "brackets"))
-
- def test_inner(self):
- logger.info("Testing multiple hierarchy - inner levels")
- input_filename = 'replicate_layout_test_project.kicad_pcb'
- test_filename = input_filename.split('.')[0] + "_ref_inner" + ".kicad_pcb"
- err = test_file(input_filename, test_filename, 'U701', level=1, sheets=(1, 3, 7),
- containing=False, remove=False, by_group=True)
- self.assertEqual(err, 0, "inner levels failed")
-
-
-class TestFpText(unittest.TestCase):
- def setUp(self):
- os.chdir(os.path.join(os.path.dirname(os.path.realpath(__file__)), "bug-demo"))
- def test(self):
- logger.info("Testin fp text replication")
- input_filename = 'controller-led-matrix.kicad_pcb'
- test_filename = input_filename.split('.')[0] + "_ref_inner" + ".kicad_pcb"
- err = test_file(input_filename, test_filename, 'U1', level=0, sheets=(0, 1),
- containing=False, remove=False, by_group=False)
- self.assertEqual(err, 0, "inner levels failed")
-
-
-@unittest.skip
-class TestOfficial(unittest.TestCase):
- def setUp(self):
- os.chdir(os.path.join(os.path.dirname(os.path.realpath(__file__)), "replicate_layout_test_project"))
-
- def test_inner(self):
- logger.info("Testing multiple hierarchy - inner levels")
- input_filename = 'replicate_layout_test_project.kicad_pcb'
- test_filename = input_filename.split('.')[0] + "_ref_inner" + ".kicad_pcb"
- err = test_file(input_filename, test_filename, 'U701', level=1, sheets=(1, 3, 7),
- containing=False, remove=False, by_group=True)
- self.assertEqual(err, 0, "inner levels failed")
-
- def test_inner_alt(self):
- logger.info("Testing multiple hierarchy - inner levels")
- input_filename = 'replicate_layout_test_project.kicad_pcb'
- test_filename = input_filename.split('.')[0] + "_ref_inner_alt" + ".kicad_pcb"
- err = test_file(input_filename, test_filename, 'U1501', level=0, sheets=(2, 4, 8),
- containing=False, remove=False, by_group=True)
- self.assertEqual(err, 0, "inner levels failed")
-
- @unittest.skip
- def test_outer(self):
- logger.info("Testing multiple hierarchy - inner levels")
- input_filename = 'replicate_layout_test_project.kicad_pcb'
- test_filename = input_filename.split('.')[0] + "_ref_outer" + ".kicad_pcb"
- err = test_file(input_filename, test_filename, 'U701', level=0, sheets=(0, 1),
- containing=False, remove=False, by_group=True)
- self.assertEqual(err, 0, "outer levels failed")
-
-
-# for testing purposes only
-if __name__ == "__main__":
- file_handler = logging.FileHandler(filename='replicate_layout.log', mode='w')
- stdout_handler = logging.StreamHandler(sys.stdout)
- handlers = [file_handler, stdout_handler]
+HERE = os.path.dirname(os.path.realpath(__file__))
+sys.path.insert(0, HERE)
+
+from replicate_layout import Replicator, Settings # noqa: E402
+import test_helpers # noqa: E402
+
+REF_DIR = os.path.join(HERE, "test_refs")
+
+TP = "replicate_layout_test_project/replicate_layout_test_project.kicad_pcb"
+FT = "replicate_layout_fp_text/replicate_layout_fp_text.kicad_pcb"
+
+_GROUP = dict(group_layouts=True, group_footprints=True, group_tracks=True,
+ group_zones=True, group_text=True, group_drawings=True)
+
+# name, pcb, anchor, level index, sheet indices, settings overrides
+SCENARIOS = [
+ ("baseline_inner", TP, "U701", 1, [1, 3, 7], {}),
+ ("inner_all", TP, "U701", 1, list(range(9)), {}),
+ ("flipped_anchor_alt", TP, "U1501", 0, [2, 4, 8], {}),
+ ("outer_level", TP, "U701", 0, [0, 1], {}),
+ ("containing", TP, "U701", 1, [1, 3, 7], dict(intersecting=False)),
+ ("remove_existing", TP, "U701", 1, [1, 3, 7], dict(remove=True)),
+ ("groups", TP, "U701", 1, [1, 3, 7], _GROUP),
+ ("no_tracks_zones", TP, "U701", 1, [1, 3, 7], dict(rep_tracks=False, rep_zones=False)),
+ ("fp_text", FT, "R364", 0, [0, 1], {}),
+]
+
- logging_level = logging.INFO
+def _settings(overrides):
+ d = dict(rep_tracks=True, rep_zones=True, rep_text=True, rep_drawings=True,
+ group_layouts=False, group_footprints=False, group_tracks=False,
+ group_zones=False, group_text=False, group_drawings=False,
+ rep_locked_tracks=True, rep_locked_zones=True, rep_locked_text=True,
+ rep_locked_drawings=True, intersecting=True, group_items=True,
+ group_only=False, locked_fps=False, remove=False)
+ d.update(overrides)
+ return Settings(**d)
- logging.basicConfig(level=logging_level,
- format='%(asctime)s %(name)s %(lineno)d:%(message)s',
- datefmt='%m-%d %H:%M:%S',
- handlers=handlers
- )
- logger = logging.getLogger(__name__)
- logger.info("Plugin executed on: " + repr(sys.platform))
- logger.info("Plugin executed with python version: " + repr(sys.version))
- logger.info("KiCad build version: " + str(pcbnew.GetBuildVersion()))
+def run_scenario(pcb_rel, anchor, level, sheets, overrides):
+ """Run one replication; return (board, anchor, src_refs, dst_sections)."""
+ board = pcbnew.LoadBoard(os.path.join(HERE, pcb_rel))
- unittest.main()
+ def prog(stage, pct, msg=None):
+ pass
+
+ rep = Replicator(board, anchor, prog)
+ src = rep.get_fp_by_ref(anchor)
+ sheet_list = rep.get_sheets_to_replicate(src, src.sheet_id[level])
+ dst = [sheet_list[i] for i in sheets]
+ settings = _settings(overrides)
+ lvl = src.sheet_id[0:level + 1]
+
+ # exercise the highlight code paths too
+ fps, items = rep.highlight_set_level(lvl, settings)
+ rep.highlight_clear_level(fps, items)
+
+ rep.replicate_layout(src, lvl, dst, settings, rm_duplicates=True)
+
+ src_refs = [f.ref for f in rep.src_footprints]
+ dst_sections = []
+ for sheet in dst:
+ sheet_fps = rep.get_footprints_on_sheet(sheet)
+ dst_sections.append([rep.match_fp_in_list(sfp, sheet_fps).ref
+ for sfp in rep.src_footprints])
+ return board, anchor, src_refs, dst_sections
+
+
+class ReplicateLayoutTests(unittest.TestCase):
+ pass
+
+
+def _make_test(name, pcb, anchor, level, sheets, overrides):
+ def test(self):
+ board, a, src_refs, dst_sections = run_scenario(pcb, anchor, level, sheets, overrides)
+
+ # 1. convention-independent geometric correctness
+ ok, problems = test_helpers.geometric_consistency(board, a, src_refs, dst_sections)
+ self.assertTrue(ok, "geometric check failed:\n" + "\n".join(problems[:20]))
+
+ # 2. regression against committed (KiCad 9) reference signature
+ ref_path = os.path.join(REF_DIR, name + ".json")
+ if os.path.exists(ref_path):
+ sig = test_helpers.board_signature(board)
+ with open(ref_path) as fh:
+ ref = json.load(fh)["signature"]
+ diffs = test_helpers.compare_signatures(ref, sig)
+ self.assertEqual(diffs, [], "signature differs from reference:\n"
+ + "\n".join(diffs[:20]))
+ return test
+
+
+for _scn in SCENARIOS:
+ setattr(ReplicateLayoutTests, "test_" + _scn[0], _make_test(*_scn))
+
+
+class Issue86Tests(unittest.TestCase):
+ """Regression for issue #86: destination footprints that are already in the
+ matching 'Replicated Group ...' group (e.g. on a re-run) must not make the
+ plugin raise. The original code compared the PCB_GROUP object to a string,
+ which is always unequal, so any grouped destination footprint aborted the run."""
+
+ def _setup(self):
+ board = pcbnew.LoadBoard(os.path.join(HERE, TP))
+ rep = Replicator(board, "U701", lambda *a, **k: None)
+ src = rep.get_fp_by_ref("U701")
+ sheet_list = rep.get_sheets_to_replicate(src, src.sheet_id[1])
+ dst = [sheet_list[i] for i in [1, 3, 7]]
+ lvl = src.sheet_id[0:2]
+ return board, rep, src, dst, lvl
+
+ def test_matching_group_does_not_raise(self):
+ board, rep, src, dst, lvl = self._setup()
+ first = dst[0]
+ grp = pcbnew.PCB_GROUP(None)
+ grp.SetName("Replicated Group {}".format(first))
+ board.Add(grp)
+ for fp in rep.get_footprints_on_sheet(first):
+ grp.AddItem(fp.fp)
+ settings = _settings({}) # group_layouts off, so the group is not recreated
+ try:
+ rep.replicate_layout(src, lvl, dst, settings, rm_duplicates=True)
+ except LookupError as e:
+ self.fail("replication wrongly raised for a correctly grouped "
+ "destination footprint (issue #86): %s" % e)
+
+ def test_foreign_group_still_raises(self):
+ # the guard must still fire for a footprint in an unrelated group
+ board, rep, src, dst, lvl = self._setup()
+ first = dst[0]
+ grp = pcbnew.PCB_GROUP(None)
+ grp.SetName("Some Unrelated Group")
+ board.Add(grp)
+ for fp in rep.get_footprints_on_sheet(first):
+ grp.AddItem(fp.fp)
+ settings = _settings({})
+ with self.assertRaises(LookupError):
+ rep.replicate_layout(src, lvl, dst, settings, rm_duplicates=True)
+
+
+def gen_refs():
+ os.makedirs(REF_DIR, exist_ok=True)
+ for (name, pcb, anchor, level, sheets, overrides) in SCENARIOS:
+ board, a, src_refs, dst_sections = run_scenario(pcb, anchor, level, sheets, overrides)
+ ok, problems = test_helpers.geometric_consistency(board, a, src_refs, dst_sections)
+ sig = test_helpers.board_signature(board)
+ out = {"build": pcbnew.GetBuildVersion(), "geometric_ok": ok, "signature": sig}
+ with open(os.path.join(REF_DIR, name + ".json"), "w") as fh:
+ json.dump(out, fh, indent=1)
+ print("wrote ref %-20s geo_ok=%s (%s)" % (name, ok, pcbnew.GetBuildVersion()))
+
+
+if __name__ == "__main__":
+ print("KiCad build:", pcbnew.GetBuildVersion(), "| python:", sys.version.split()[0])
+ if "--gen-refs" in sys.argv:
+ gen_refs()
+ else:
+ unittest.main(verbosity=2)
diff --git a/version.txt b/version.txt
index aa31e71..28cbf7c 100644
--- a/version.txt
+++ b/version.txt
@@ -1 +1 @@
-4.0.3
\ No newline at end of file
+5.0.0
\ No newline at end of file