Files
kicad-zone-resistance/fill_resistance/dialog.py
T
janik bfb97d5259
Build PCM package / build (push) Successful in 6s
Experimental: push |J| heatmap overlays into KiCad (dialog opt-in)
After a solve, the per-layer current-density maps can be pushed into
the open board as reference images on User.9..User.12 (stackup order,
top first) - visible right in the editor, toggled like any layer,
never plotted to gerbers. Dialog checkbox, default OFF; every push
replaces all reference images on those layers.

Rendering (fill_resistance/overlay.py, KiCad-free and tested headless):
one pixel per grid cell, opaque over copper with the log scale lifted
off the colormap's near-black bottom (dark canvas), transparent
elsewhere, one pixel of half-alpha edge bleed so the overlay reaches
the drawn outline instead of stopping half a cell short. Pushing lives
in board_io (ReferenceImage via the IPC API, KiCad >= 10.0.1; scale =
width / (pixels * 1 inch / 300 PPI), position = image center); kipy
0.7.1 swallows creation errors, so the per-item status is read from
the raw CreateItemsResponse. pipeline.run takes an optional overlay
callback; failures are reported, never fatal.

tools/kicad_overlay_test.py pushes a fiducial alignment pattern (KiCad
bbox readback verified placement to half a pixel); tools/
kicad_heatmap_overlay.py runs the whole thing headless against the
open board, filtering marker rectangles of other nets' analyses via
the exact copper test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 20:39:13 +07:00

275 lines
11 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
cap_max_drill_mm: float = 0.5
adaptive: bool = True
push_overlays: bool = False # EXPERIMENTAL in-KiCad |J| overlays
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.cap_drill_edit = QLineEdit(f"{config.CAP_MAX_DRILL_MM:g}")
self.cap_drill_edit.setEnabled(config.VIAS_CAPPED)
self.capped_check.toggled.connect(self.cap_drill_edit.setEnabled)
form.addRow("Capped up to drill [mm]:", self.cap_drill_edit)
self.adaptive_check = QCheckBox(
"adaptive cells (coarsen plane interiors; faster on large "
"boards, corrected to ≲0.03 % of the uniform grid)")
self.adaptive_check.setChecked(config.ADAPTIVE_CELLS)
form.addRow("Grid:", self.adaptive_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)
first, last = config.OVERLAY_LAYERS[0], config.OVERLAY_LAYERS[-1]
self.overlay_check = QCheckBox(
f"experimental: push per-layer |J| heatmaps into the board as "
f"reference images on {first}..{last} (replaces images there; "
f"layers must be enabled in Board Setup)")
self.overlay_check.setChecked(config.PUSH_OVERLAYS)
form.addRow("Overlays:", self.overlay_check)
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:
text = edit.text().strip()
try:
return float(skin.normalize_decimal(text))
except ValueError as exc:
if "separator" in str(exc):
raise ValueError(f"{name}: {exc}")
raise ValueError(f"{name}: '{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.")
cap_max_drill = config.CAP_MAX_DRILL_MM
if self.capped_check.isChecked():
cap_max_drill = number(self.cap_drill_edit, "Capped up to drill")
if cap_max_drill <= 0:
raise ValueError("Capped-up-to drill must be > 0 mm.")
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(),
cap_max_drill_mm=cap_max_drill,
adaptive=self.adaptive_check.isChecked(),
push_overlays=self.overlay_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