Files
kicad-zone-resistance/fill_resistance/dialog.py
T
janik d34d3ade51 Model via ring copper and drill mouths (capping), dialog-toggleable
Each via now contributes its ring/pad copper (full-thickness disc of
the pad diameter on every spanned layer) and its drill mouth: with
"vias filled + capped" (dialog checkbox, default on, VIAS_CAPPED) the
mouth carries a CAP_PLATING_UM (15 um) thin copper cap on the outer
layers and is an open hole on inner layers; unchecked, mouths are open
everywhere. Mouth coverage is area-weighted per cell (4x4
supersampling) through a per-cell thickness map feeding the existing
harmonic-mean face machinery, so sub-cell mouths perturb the sheet by
their true covered fraction instead of whole cells. Fully swallowed
cells leave the mask; the barrel then attaches through the ring via the
existing pad-footprint search. THT-pad copper and drills stay outside
the model. Ring discs paint before 1D trace chains (chains see them as
regular copper), mouths after wide tracks (drills go through trace
copper). standalone gains --uncapped.

Tests: cap==foil identity against the feature-off reference, strict
R(solid) < R(cap) < R(hole) ordering, ring bridging a fill gap that a
ringless barrel cannot cross, gentle sub-cell perturbation at coarse
grids, and JSON roundtrip of the new fields.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 16:42:21 +07:00

242 lines
9.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Qt selection dialog shown on plugin launch: net, layers, per-electrode
contact, test current, optional cell size. PySide6 is already a plugin
dependency (matplotlib QtAgg backend); the QApplication created here is
reused by matplotlib afterwards.
"""
from __future__ import annotations
from dataclasses import dataclass
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (QApplication, QCheckBox, QComboBox, QDialog,
QDialogButtonBox, QFormLayout, QLabel,
QLineEdit, QListWidget, QListWidgetItem,
QVBoxLayout)
from . import config, skin
ALL_LAYERS = "All selected layers"
AUTO_CONTACT = "(auto: per contact part)"
MODEL_LABELS = {
"uniform": "Uniform injection (conductor pressed on top)",
"equipotential": "Equipotential (ideal bonded lug)",
}
@dataclass
class Selection:
net: str
layers: list[str]
contact1: str # "auto", "all" or layer name
contact2: str
current_a: float
cell_um: float | None
freq_hz: float = 0.0
contact_model: str = "uniform"
include_buildup: bool = False
extra_cu_um: float = 0.0
include_tracks: bool = True
vias_capped: bool = True
class _Dialog(QDialog):
def __init__(self, candidates: dict[str, list[str]], layer_order: list[str],
default_net: str, e1_label: str, e2_label: str,
contact1: str, contact2: str, buildup_layers: list[str]):
super().__init__()
self.setWindowTitle("Fill Resistance")
self.setWindowFlag(Qt.WindowStaysOnTopHint, True)
self._candidates = candidates
self._layer_order = layer_order
form = QFormLayout()
self.net_box = QComboBox()
for net in sorted(candidates):
self.net_box.addItem(net)
self.net_box.setCurrentText(default_net)
form.addRow("Signal (net):", self.net_box)
self.layer_list = QListWidget()
self.layer_list.setMaximumHeight(120)
form.addRow("Layers:", self.layer_list)
self.tracks_check = QCheckBox("include the net's traces "
"(tracks + arcs)")
self.tracks_check.setChecked(config.INCLUDE_TRACKS)
form.addRow("Conductors:", self.tracks_check)
self.capped_check = QCheckBox(
f"vias filled + capped ({config.CAP_PLATING_UM:g} µm cap; "
f"off = open mouths)")
self.capped_check.setChecked(config.VIAS_CAPPED)
form.addRow("Vias:", self.capped_check)
self.contact1_box = QComboBox()
self.contact2_box = QComboBox()
form.addRow(f"V+ ({e1_label}):", self.contact1_box)
form.addRow(f"V ({e2_label}):", self.contact2_box)
self.model_box = QComboBox()
for key in ("uniform", "equipotential"):
self.model_box.addItem(MODEL_LABELS[key], key)
default_index = 0 if config.CONTACT_MODEL == "uniform" else 1
self.model_box.setCurrentIndex(default_index)
form.addRow("Contact model:", self.model_box)
self.current_edit = QLineEdit(f"{config.TEST_CURRENT_A:g}")
form.addRow("Test current [A]:", self.current_edit)
self.freq_edit = QLineEdit("")
self.freq_edit.setPlaceholderText("0 = DC (e.g. 142k, 1.5M)")
form.addRow("Frequency [Hz]:", self.freq_edit)
self.cell_edit = QLineEdit("")
self.cell_edit.setPlaceholderText("auto")
form.addRow("Cell size [µm]:", self.cell_edit)
self.buildup_check = QCheckBox(
f"{config.SOLDER_THICKNESS_UM:g} µm solder on mask openings"
+ (f" ({', '.join(buildup_layers)})" if buildup_layers
else " (none found)"))
self.buildup_check.setChecked(bool(buildup_layers)
and config.INCLUDE_MASK_BUILDUP)
self.buildup_check.setEnabled(bool(buildup_layers))
form.addRow("Buildup:", self.buildup_check)
self.extracu_edit = QLineEdit(f"{config.BUILDUP_EXTRA_CU_UM:g}")
self.extracu_edit.setEnabled(bool(buildup_layers))
form.addRow("Extra Cu in openings [µm]:", self.extracu_edit)
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
buttons.accepted.connect(self._try_accept)
buttons.rejected.connect(self.reject)
lay = QVBoxLayout(self)
lay.addLayout(form)
note = QLabel("Multiple layers are coupled through the net's "
"via/through-pad barrels. At f > 0 the foil-thickness "
"skin effect is applied per layer; lateral (proximity) "
"redistribution is not modeled, so AC results are a "
"lower bound.")
note.setWordWrap(True)
note.setStyleSheet("color: gray; font-size: 10px;")
lay.addWidget(note)
self.error_label = QLabel("")
self.error_label.setWordWrap(True)
self.error_label.setStyleSheet("color: #b02a2a;")
self.error_label.setVisible(False)
lay.addWidget(self.error_label)
lay.addWidget(buttons)
self._selection: Selection | None = None
self._desired1, self._desired2 = contact1, contact2
self.net_box.currentTextChanged.connect(self._refresh)
self._refresh()
def _refresh(self):
self.error_label.setVisible(False)
net = self.net_box.currentText()
layers = [n for n in self._layer_order
if n in self._candidates.get(net, [])]
self.layer_list.clear()
for name in layers:
item = QListWidgetItem(name)
item.setFlags(item.flags() | Qt.ItemIsUserCheckable)
item.setCheckState(Qt.Checked)
self.layer_list.addItem(item)
for box, desired in ((self.contact1_box, self._desired1),
(self.contact2_box, self._desired2)):
box.clear()
box.addItem(AUTO_CONTACT)
box.addItem(ALL_LAYERS)
box.addItems(layers)
if desired == "all":
box.setCurrentText(ALL_LAYERS)
elif desired in layers:
box.setCurrentText(desired)
def checked_layers(self) -> list[str]:
out = []
for i in range(self.layer_list.count()):
item = self.layer_list.item(i)
if item.checkState() == Qt.Checked:
out.append(item.text())
return out
def _build_selection(self) -> Selection:
"""Parse and validate every field; raises ValueError with a
user-readable message instead of silently substituting defaults
(a typo silently becoming 1 A / DC would mislabel the result)."""
layers = self.checked_layers()
if not layers:
raise ValueError("Check at least one layer.")
def number(edit: QLineEdit, name: str) -> float:
try:
return float(edit.text().strip().replace(",", "."))
except ValueError:
raise ValueError(f"{name}: '{edit.text()}' is not a number.")
current = number(self.current_edit, "Test current")
if current <= 0:
raise ValueError("Test current must be > 0 A.")
cell = None
if self.cell_edit.text().strip():
cell = number(self.cell_edit, "Cell size")
if cell <= 0:
raise ValueError("Cell size must be > 0 µm.")
try:
freq = skin.parse_frequency(self.freq_edit.text())
except ValueError:
raise ValueError(
f"Frequency: cannot parse '{self.freq_edit.text()}' "
f"(examples: 0, 142k, 1.5M).")
extra_cu = 0.0
if self.extracu_edit.isEnabled():
extra_cu = number(self.extracu_edit, "Extra Cu")
if extra_cu < 0:
raise ValueError("Extra Cu must be ≥ 0 µm.")
def contact(box: QComboBox) -> str:
t = box.currentText()
if t == AUTO_CONTACT:
return "auto"
return "all" if t == ALL_LAYERS else t
return Selection(net=self.net_box.currentText(), layers=layers,
contact1=contact(self.contact1_box),
contact2=contact(self.contact2_box),
current_a=current, cell_um=cell,
freq_hz=freq,
contact_model=self.model_box.currentData(),
include_buildup=self.buildup_check.isChecked(),
extra_cu_um=extra_cu,
include_tracks=self.tracks_check.isChecked(),
vias_capped=self.capped_check.isChecked())
def _try_accept(self) -> None:
try:
self._selection = self._build_selection()
except ValueError as e:
self.error_label.setText(str(e))
self.error_label.setVisible(True)
return
self.accept()
def ask(candidates: dict[str, list[str]], layer_order: list[str],
default_net: str, e1_label: str, e2_label: str,
contact1: str, contact2: str,
buildup_layers: list[str] | None = None) -> Selection | None:
"""Show the dialog; returns None on cancel."""
app = QApplication.instance() or QApplication([])
dlg = _Dialog(candidates, layer_order, default_net, e1_label, e2_label,
contact1, contact2, buildup_layers or [])
dlg.raise_()
dlg.activateWindow()
if dlg.exec() != QDialog.Accepted:
return None
return dlg._selection