import pcbnew import wx import traceback from .via_net_assign import Via_Net_Assign_Action # --------------------------------------------------------------------------- # 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): """Direct children of sheet_name only.""" return [fp for fp in board.GetFootprints() if fp.GetSheetname() == sheet_name] def get_sheet_prefix(board, sheet_name): """Return the path prefix for sheet_name, e.g. '/uuid_sheet/'. Determined from any direct child component of that sheet.""" for fp in board.GetFootprints(): if fp.GetSheetname() == sheet_name: path = fp.GetPath().AsString() parts = [p for p in path.split('/') if p] if parts: return '/' + '/'.join(parts[:-1]) + '/' return None def get_sheet_fps_hierarchical(board, sheet_prefix): """All components whose path starts with sheet_prefix (includes sub-sheets).""" return [fp for fp in board.GetFootprints() if fp.GetPath().AsString().startswith(sheet_prefix)] def local_key(fp, sheet_prefix): """Path relative to the sheet prefix — used to match components across instances. For direct children: 'uuid_sym'. For sub-sheet children: 'uuid_subsheet/uuid_sym'.""" path = fp.GetPath().AsString() return path[len(sheet_prefix):] def build_key_map(footprints, sheet_prefix): return {local_key(fp, sheet_prefix): fp for fp in footprints} # --------------------------------------------------------------------------- # Transform # --------------------------------------------------------------------------- def apply_replication(board, source_sheet, target_sheet, ref_fp, hierarchical=False): src_prefix = get_sheet_prefix(board, source_sheet) tgt_prefix = get_sheet_prefix(board, target_sheet) if src_prefix is None or tgt_prefix is None: raise ValueError('Could not determine sheet path prefix') if hierarchical: source_fps = get_sheet_fps_hierarchical(board, src_prefix) target_fps = get_sheet_fps_hierarchical(board, tgt_prefix) else: source_fps = get_sheet_fps(board, source_sheet) target_fps = get_sheet_fps(board, target_sheet) target_map = build_key_map(target_fps, tgt_prefix) ref_key = local_key(ref_fp, src_prefix) # ref_fp is from the source sheet, so its key uses src_prefix. # Find the matching component in target using tgt_prefix. target_ref = target_map.get(ref_key) 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: key = local_key(src_fp, src_prefix) if key == ref_key: continue tgt_fp = target_map.get(key) 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) self.hierarchical_cb = wx.CheckBox(self, label='Include sub-sheets (hierarchical)') sizer.Add(self.hierarchical_cb, 0, wx.LEFT | wx.BOTTOM, 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()) self.hierarchical_cb.Bind(wx.EVT_CHECKBOX, 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) if self.hierarchical_cb.IsChecked(): prefix = get_sheet_prefix(self.board, sheet) fps = get_sheet_fps_hierarchical(self.board, prefix) if prefix else [] else: fps = get_sheet_fps(self.board, sheet) fps = sorted(fps, 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] hierarchical = self.hierarchical_cb.IsChecked() try: count = apply_replication(self.board, source_sheet, target_sheet, ref_fp, hierarchical) 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() Layout_Replicator_Action().register() Via_Net_Assign_Action().register()