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