From 9902348c07ab18dfcfde051bc932a755cb7b61ba Mon Sep 17 00:00:00 2001 From: mikael-lovqvists-claude-agent Date: Fri, 13 Mar 2026 17:05:53 +0000 Subject: [PATCH] Add Layout Replicator plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replicates component placement from one hierarchical sheet instance to another. Pick source/target sheets and a reference component — all other components in the target sheet are placed using a rigid body transform (rotate around source ref position, then translate to target ref position). Co-Authored-By: Claude Sonnet 4.6 --- plugins/component_table/__init__.py | 3 + plugins/component_table/replicate.py | 241 +++++++++++++++++++++++++++ 2 files changed, 244 insertions(+) create mode 100644 plugins/component_table/replicate.py diff --git a/plugins/component_table/__init__.py b/plugins/component_table/__init__.py index 35a94a7..8b63013 100644 --- a/plugins/component_table/__init__.py +++ b/plugins/component_table/__init__.py @@ -3,6 +3,8 @@ import wx import wx.lib.mixins.listctrl as listmix import traceback +from .replicate import Layout_Replicator_Action + COLUMNS = ['Designation', 'Value', 'Library', 'Package', 'Sheet'] @@ -113,3 +115,4 @@ class Component_Table_Action(pcbnew.ActionPlugin): Component_Table_Action().register() +Layout_Replicator_Action().register() diff --git a/plugins/component_table/replicate.py b/plugins/component_table/replicate.py new file mode 100644 index 0000000..b6b2f88 --- /dev/null +++ b/plugins/component_table/replicate.py @@ -0,0 +1,241 @@ +import pcbnew +import wx +import traceback + + +# --------------------------------------------------------------------------- +# Board helpers +# --------------------------------------------------------------------------- + +def get_sheets(board): + sheets = set() + for fp in board.GetFootprints(): + name = fp.GetSheetname() + if name: + sheets.add(name) + return sorted(sheets) + + +def get_sheet_fps(board, sheet_name): + return [fp for fp in board.GetFootprints() if fp.GetSheetname() == sheet_name] + + +def local_uuid(fp): + """Last segment of the hierarchical path — same across all instances of a sheet.""" + path = fp.GetPath().AsString() + parts = [p for p in path.split('/') if p] + return parts[-1] if parts else '' + + +def build_uuid_map(footprints): + return {local_uuid(fp): fp for fp in footprints} + + +# --------------------------------------------------------------------------- +# Transform +# --------------------------------------------------------------------------- + +def apply_replication(board, source_sheet, target_sheet, ref_fp): + source_fps = get_sheet_fps(board, source_sheet) + target_fps = get_sheet_fps(board, target_sheet) + + target_map = build_uuid_map(target_fps) + ref_uuid = local_uuid(ref_fp) + + target_ref = target_map.get(ref_uuid) + if target_ref is None: + raise ValueError('Could not find matching reference component in target sheet') + + src_ref_pos = ref_fp.GetPosition() + tgt_ref_pos = target_ref.GetPosition() + delta_rot = target_ref.GetOrientationDegrees() - ref_fp.GetOrientationDegrees() + delta_pos = pcbnew.VECTOR2I( + tgt_ref_pos.x - src_ref_pos.x, + tgt_ref_pos.y - src_ref_pos.y, + ) + + count = 0 + for src_fp in source_fps: + uuid = local_uuid(src_fp) + if uuid == ref_uuid: + continue + tgt_fp = target_map.get(uuid) + if tgt_fp is None: + continue + + # Place on top of source counterpart + tgt_fp.SetPosition(src_fp.GetPosition()) + tgt_fp.SetOrientationDegrees(src_fp.GetOrientationDegrees()) + + # Rotate around source ref position (keeps relative layout intact) + if delta_rot != 0.0: + tgt_fp.Rotate(src_ref_pos, pcbnew.EDA_ANGLE(delta_rot, pcbnew.DEGREES_T)) + + # Translate to target + tgt_fp.Move(delta_pos) + + count += 1 + + pcbnew.Refresh() + return count + + +# --------------------------------------------------------------------------- +# Dialog +# --------------------------------------------------------------------------- + +class Layout_Replicator_Dialog(wx.Dialog): + def __init__(self, parent, board): + super().__init__( + parent, + title='Layout Replicator', + size=(480, 500), + style=wx.DEFAULT_DIALOG_STYLE | wx.RESIZE_BORDER, + ) + self.board = board + self._ref_fps = [] + + sizer = wx.BoxSizer(wx.VERTICAL) + + # Sheet selectors + grid = wx.FlexGridSizer(2, 2, 6, 8) + grid.AddGrowableCol(1) + + sheets = get_sheets(board) + + grid.Add(wx.StaticText(self, label='Source sheet:'), 0, wx.ALIGN_CENTER_VERTICAL) + self.source_choice = wx.Choice(self, choices=sheets) + grid.Add(self.source_choice, 1, wx.EXPAND) + + grid.Add(wx.StaticText(self, label='Target sheet:'), 0, wx.ALIGN_CENTER_VERTICAL) + self.target_choice = wx.Choice(self, choices=sheets) + grid.Add(self.target_choice, 1, wx.EXPAND) + + sizer.Add(grid, 0, wx.EXPAND | wx.ALL, 8) + + # Reference component list + sizer.Add( + wx.StaticText(self, label='Reference component (from source sheet):'), + 0, wx.LEFT | wx.TOP, 8, + ) + self.ref_list = wx.ListCtrl( + self, + style=wx.LC_REPORT | wx.LC_SINGLE_SEL | wx.LC_HRULES | wx.LC_VRULES, + ) + self.ref_list.InsertColumn(0, 'Designation', width=100) + self.ref_list.InsertColumn(1, 'Value', width=130) + self.ref_list.InsertColumn(2, 'Package', width=190) + sizer.Add(self.ref_list, 1, wx.EXPAND | wx.ALL, 6) + + # Status label + self.status = wx.StaticText(self, label='') + sizer.Add(self.status, 0, wx.LEFT | wx.BOTTOM, 8) + + # Buttons + btn_row = wx.BoxSizer(wx.HORIZONTAL) + self.btn_apply = wx.Button(self, label='Apply') + btn_close = wx.Button(self, wx.ID_CLOSE, 'Close') + self.btn_apply.Bind(wx.EVT_BUTTON, self._on_apply) + btn_close.Bind(wx.EVT_BUTTON, lambda e: self.Close()) + btn_row.Add(self.btn_apply, 0, wx.RIGHT, 6) + btn_row.Add(btn_close) + sizer.Add(btn_row, 0, wx.ALIGN_RIGHT | wx.ALL, 8) + + self.SetSizer(sizer) + self.Centre() + + self.source_choice.Bind(wx.EVT_CHOICE, lambda e: self._refresh_ref_list()) + + if sheets: + self.source_choice.SetSelection(0) + self.target_choice.SetSelection(1 if len(sheets) > 1 else 0) + self._refresh_ref_list() + + def _refresh_ref_list(self): + self.ref_list.DeleteAllItems() + idx = self.source_choice.GetSelection() + if idx == wx.NOT_FOUND: + return + sheet = self.source_choice.GetString(idx) + fps = sorted(get_sheet_fps(self.board, sheet), key=lambda f: f.GetReference()) + self._ref_fps = fps + for i, fp in enumerate(fps): + self.ref_list.InsertItem(i, fp.GetReference()) + self.ref_list.SetItem(i, 1, fp.GetValue()) + self.ref_list.SetItem(i, 2, str(fp.GetFPID().GetLibItemName())) + + def _on_apply(self, event): + src_idx = self.source_choice.GetSelection() + tgt_idx = self.target_choice.GetSelection() + ref_idx = self.ref_list.GetFirstSelected() + + if src_idx == wx.NOT_FOUND or tgt_idx == wx.NOT_FOUND: + self.status.SetLabel('Select source and target sheets.') + return + if ref_idx == -1: + self.status.SetLabel('Select a reference component.') + return + + source_sheet = self.source_choice.GetString(src_idx) + target_sheet = self.target_choice.GetString(tgt_idx) + + if source_sheet == target_sheet: + self.status.SetLabel('Source and target must be different sheets.') + return + + ref_fp = self._ref_fps[ref_idx] + + try: + count = apply_replication(self.board, source_sheet, target_sheet, ref_fp) + self.status.SetLabel(f'Done — {count} component(s) placed.') + except Exception: + self._show_error(traceback.format_exc()) + + def _show_error(self, msg): + self.status.SetLabel('Error — see details.') + dlg = wx.Dialog(self, title='Error', size=(520, 320), + style=wx.DEFAULT_DIALOG_STYLE | wx.RESIZE_BORDER) + s = wx.BoxSizer(wx.VERTICAL) + txt = wx.TextCtrl(dlg, value=msg, style=wx.TE_MULTILINE | wx.TE_READONLY | wx.HSCROLL) + txt.SetFont(wx.Font(9, wx.FONTFAMILY_TELETYPE, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL)) + s.Add(txt, 1, wx.EXPAND | wx.ALL, 6) + btn = wx.Button(dlg, wx.ID_CLOSE, 'Close') + btn.Bind(wx.EVT_BUTTON, lambda e: dlg.Close()) + s.Add(btn, 0, wx.ALIGN_RIGHT | wx.ALL, 6) + dlg.SetSizer(s) + dlg.ShowModal() + dlg.Destroy() + + +# --------------------------------------------------------------------------- +# Plugin +# --------------------------------------------------------------------------- + +class Layout_Replicator_Action(pcbnew.ActionPlugin): + def defaults(self): + self.name = 'Layout Replicator' + self.category = 'Placement' + self.description = 'Replicate component placement from one hierarchical sheet instance to another' + self.show_toolbar_button = True + self.icon_file_name = '' + + def Run(self): + try: + board = pcbnew.GetBoard() + dlg = Layout_Replicator_Dialog(None, board) + dlg.ShowModal() + dlg.Destroy() + except Exception: + msg = traceback.format_exc() + dlg = wx.Dialog(None, title='Layout Replicator — Error', size=(520, 320), + style=wx.DEFAULT_DIALOG_STYLE | wx.RESIZE_BORDER) + s = wx.BoxSizer(wx.VERTICAL) + txt = wx.TextCtrl(dlg, value=msg, style=wx.TE_MULTILINE | wx.TE_READONLY | wx.HSCROLL) + txt.SetFont(wx.Font(9, wx.FONTFAMILY_TELETYPE, wx.FONTSTYLE_NORMAL, wx.FONTWEIGHT_NORMAL)) + s.Add(txt, 1, wx.EXPAND | wx.ALL, 6) + btn = wx.Button(dlg, wx.ID_CLOSE, 'Close') + btn.Bind(wx.EVT_BUTTON, lambda e: dlg.Close()) + s.Add(btn, 0, wx.ALIGN_RIGHT | wx.ALL, 6) + dlg.SetSizer(s) + dlg.ShowModal() + dlg.Destroy()