d34d3ade51
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>
242 lines
9.4 KiB
Python
242 lines
9.4 KiB
Python
"""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
|