Files
kicad-layout-replicator/plugins/layout_replicator/__init__.py
mikael-lovqvists-claude-agent d8eeb3425a Add Via Net Assign feature
Iterates all vias in the active selection and assigns each one to the
net of the nearest copper item (track or pad) on the board. Intended
for re-using via layouts after copying traces to a new net.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 19:16:36 +00:00

247 lines
7.6 KiB
Python

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):
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()
Layout_Replicator_Action().register()
Via_Net_Assign_Action().register()