tests / fedora:latest (push) Successful in 1m15s
tests / ubuntu-latest · py3.11 (push) Successful in 1m32s
tests / ubuntu-latest · py3.13 (push) Successful in 1m1s
tests / archlinux:latest (push) Successful in 53s
tests / debian:12 (push) Successful in 51s
tests / ubuntu:24.04 (push) Successful in 1m8s
tests / NixOS (FHS wrapper from docs/NIXOS.md) (push) Skipped
Build PCM package / build (push) Successful in 10s
With neither mode derivable (nothing selected, no marker rectangles, no config) the dialog now opens anyway instead of failing with an error figure: both radios disabled with their reasons, OK/Save disabled, Load config… live - a saved config can bootstrap the run. README: note that macOS/NixOS field tests last ran with plugin v1.3. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HjSRLWQ8ywBBYyakvr3YTx
1048 lines
48 KiB
Python
1048 lines
48 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.
|
||
|
||
Widget defaults come from a configfile.DialogDefaults (config.py
|
||
constants overlaid with the optional fill_res_config.json) - the dialog
|
||
never reads config.* seeds directly, so the file's precedence lives in
|
||
one place.
|
||
|
||
Two run modes share the dialog, chosen by a radio at the top:
|
||
|
||
- Classic: one V+ and one V- terminal (each may bundle several contact
|
||
parts), contact scopes, contact model, one test current;
|
||
- PDN: two editable terminal tables instead - one for supplies, one
|
||
for loads, each titled with its marker layer. A load row takes a
|
||
current draw, a supply row an output resistance and an optional
|
||
open-circuit voltage; every row also picks the contacted copper
|
||
layer and shows the component it belongs to (read-only, from
|
||
board_io.component_hints). The terminal set comes either from the
|
||
live marker-rectangle scan (PdnSetup.from_config False: User.1
|
||
rects are supplies, User.2 rects are loads) or from a config file's
|
||
terminals section (from_config True: the file says WHICH terminals
|
||
exist, everything else - mode, net, values, layers, active,
|
||
comments - stays editable; nothing is pinned).
|
||
|
||
"Load config…" swaps the whole setup for another config file: ask()
|
||
then returns a LoadRequest instead of a Selection and main re-derives
|
||
everything from that file and reopens the dialog. The dialog even
|
||
opens with NO runnable mode at all (nothing selected, no marker
|
||
rectangles, no config) - load-only: both radios unchecked and
|
||
disabled with their reasons, OK/Save disabled - so a saved config can
|
||
bootstrap the run.
|
||
|
||
Row identity is POSITIONAL: the tables never sort or reorder, so
|
||
main.py zips Selection.pdn_rows with its own parallel terminal list.
|
||
Rows carry the nets their contacts overlap; rows not on the active net
|
||
are hidden, and come back (like unchecked rows) with active=False -
|
||
still saved to the config, just not part of the run.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass
|
||
from pathlib import Path
|
||
|
||
from PySide6.QtCore import Qt
|
||
from PySide6.QtWidgets import (QAbstractScrollArea, QApplication, QCheckBox,
|
||
QComboBox, QDialog, QDialogButtonBox,
|
||
QFileDialog, QFormLayout, QFrame,
|
||
QHBoxLayout, QLabel, QLineEdit, QListWidget,
|
||
QListWidgetItem, QRadioButton, QScrollArea,
|
||
QSplitter, QTableWidget, QTableWidgetItem,
|
||
QVBoxLayout, QWidget)
|
||
|
||
from . import config, configfile, skin
|
||
from .configfile import DialogDefaults, dialog_defaults
|
||
from .errors import ConfigError
|
||
|
||
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)",
|
||
}
|
||
|
||
|
||
def _parse_number(text: str, name: str) -> float:
|
||
"""Shared by QLineEdits and table cells: float with SI suffixes
|
||
(50m = 0.05, 4.7k = 4700) and the decimal-comma normalization,
|
||
ValueError with a user-readable message."""
|
||
try:
|
||
return skin.parse_engineering(text)
|
||
except ValueError as exc:
|
||
if "separator" in str(exc):
|
||
raise ValueError(f"{name}: {exc}")
|
||
raise ValueError(f"{name}: '{text}' is not a number "
|
||
f"(SI suffixes work: 50m, 4.7k, 2M).")
|
||
|
||
|
||
@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
|
||
trim_enabled: bool = False # EXPERIMENTAL low-current copper marking
|
||
trim_mode: str = "pct" # "pct" (% of the mean |J|) or "abs" (A/mm2)
|
||
trim_value: float = 10.0 # threshold in the unit trim_mode names
|
||
mode: str = "classic" # "classic" | "pdn" (current_a then
|
||
# carries the summed load draw)
|
||
pdn_rows: list | None = None # PDN: validated PdnTerminalRow list,
|
||
# same order and LENGTH as the
|
||
# PdnSetup given in; a row's `active`
|
||
# is False when unchecked OR hidden
|
||
# by the net filter (not in the run,
|
||
# but still saved)
|
||
v_nominal: float | None = None # PDN: default supply v_oc [V]
|
||
|
||
|
||
@dataclass
|
||
class PdnTerminalRow:
|
||
"""One terminal in the PDN tables. Identity is positional (the
|
||
dialog never reorders rows), so main.py zips the returned list
|
||
against its own parallel terminal list - no key needed."""
|
||
name: str
|
||
role: str # "supply" | "load"
|
||
resolved: str # read-only geometry description
|
||
component: str = "" # read-only owner hint ("U5" /
|
||
# "near U5", board_io.component_hints)
|
||
active: bool = True # checkbox: false = the terminal is
|
||
# kept (and saved) but takes no part
|
||
# in the run; its value cells may
|
||
# then stay blank
|
||
comment: str = "" # free-text note, saved to the config
|
||
i_draw_a: float | None = None # loads; None = not entered yet
|
||
r_out_ohm: float | None = None # supplies; None = not entered yet
|
||
v_oc: float | None = None # supplies; None = v_nominal
|
||
bonded: bool = False # checkbox: short the contacts into
|
||
# one lug - the TOTAL value applies,
|
||
# the per-contact split is a solve
|
||
# outcome. Seeded True for same-name
|
||
# rectangle groups and from the
|
||
# config's "bonded" key; a single
|
||
# contact checked = equipotential lug
|
||
contact: str = "all" # terminal-level layer scope: "auto"
|
||
# (per contact part - config-backed
|
||
# rows only), "all", or a layer name
|
||
from_config: bool = False # this ROW's geometry comes from the
|
||
# config file (a setup may mix file
|
||
# terminals with newly drawn
|
||
# rectangles)
|
||
nets: frozenset | None = None # nets whose copper the contacts
|
||
# overlap; the row is HIDDEN while
|
||
# the active net is not in the set
|
||
# (skipped by validation and solve,
|
||
# SAVED as active: false). None =
|
||
# always shown (no net info)
|
||
|
||
|
||
@dataclass
|
||
class LoadRequest:
|
||
"""Returned by ask() instead of a Selection when the user picked a
|
||
file with "Load config…" - main re-derives everything from that
|
||
config and reopens the dialog."""
|
||
path: Path
|
||
|
||
|
||
@dataclass
|
||
class PdnSetup:
|
||
"""The PDN side of the dialog - plain data so main.py builds it
|
||
without the dialog importing board_io."""
|
||
rows: list # [PdnTerminalRow] in display order
|
||
source: str # header: config file name, or
|
||
# "marker rectangles on User.1/User.2"
|
||
from_config: bool = False # rows come from a config file: the
|
||
# file is authoritative for WHICH
|
||
# terminals exist (structural edits
|
||
# happen there), everything else is
|
||
# editable - nothing is pinned
|
||
note: str = "" # extra hint ("selection ignored")
|
||
|
||
|
||
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],
|
||
defaults: DialogDefaults | None = None,
|
||
pdn: PdnSetup | None = None,
|
||
pdn_candidates: dict | None = None,
|
||
classic_reason: str | None = None,
|
||
pdn_reason: str | None = None,
|
||
save_callback=None, save_target=None, load_dir=None,
|
||
start_mode: str = "classic"):
|
||
super().__init__()
|
||
d = defaults if defaults is not None else dialog_defaults(None)
|
||
self.setWindowTitle("Fill Resistance")
|
||
self.setWindowFlag(Qt.WindowStaysOnTopHint, True)
|
||
self._layer_order = layer_order
|
||
self._pdn = pdn
|
||
self._save_callback = save_callback
|
||
self._save_target = save_target # picker seed; last save wins
|
||
self._load_dir = load_dir
|
||
self._load_request: Path | None = None
|
||
self._classic_ok = classic_reason is None
|
||
self._pdn_ok = pdn is not None
|
||
self._candidates_classic = candidates
|
||
self._candidates_pdn = pdn_candidates or {}
|
||
self._candidates: dict = {}
|
||
# config-provided layer subset: applied while the dialog shows
|
||
# the net it was written for; switching nets re-checks all
|
||
self._preset_layers = d.layers
|
||
self._preset_net = default_net
|
||
|
||
# --- mode selector ---------------------------------------------
|
||
# "Classic", not "two-terminal": classic terminals can bundle
|
||
# many contact parts - the old label read like a 2-contact cap
|
||
self.mode_classic = QRadioButton("Classic")
|
||
self.mode_pdn = QRadioButton("PDN")
|
||
self.mode_classic.setEnabled(self._classic_ok)
|
||
self.mode_pdn.setEnabled(self._pdn_ok)
|
||
# with NEITHER mode derivable (nothing selected, no marker
|
||
# rectangles, no config terminals) the dialog still opens
|
||
# LOAD-ONLY: no radio checked, OK/Save disabled below - so
|
||
# "Load config…" can pull in a saved setup instead of the
|
||
# launch dying with an error figure
|
||
self._no_mode = not (self._classic_ok or self._pdn_ok)
|
||
if not self._no_mode:
|
||
start_pdn = self._pdn_ok and (not self._classic_ok
|
||
or start_mode == "pdn")
|
||
(self.mode_pdn if start_pdn
|
||
else self.mode_classic).setChecked(True)
|
||
reasons = []
|
||
if not self._classic_ok and classic_reason:
|
||
reasons.append(f"Classic unavailable: {classic_reason}")
|
||
self.mode_classic.setToolTip(classic_reason)
|
||
if not self._pdn_ok and pdn_reason:
|
||
reasons.append(f"PDN unavailable: {pdn_reason}")
|
||
self.mode_pdn.setToolTip(pdn_reason)
|
||
if self._no_mode and load_dir is not None:
|
||
reasons.append("Load config… below can still set up a run "
|
||
"from a saved config file.")
|
||
reason = "\n".join(reasons) if reasons else None
|
||
|
||
# --- shared form #1 --------------------------------------------
|
||
form1 = QFormLayout()
|
||
|
||
self.net_box = QComboBox()
|
||
form1.addRow("Signal (net):", self.net_box)
|
||
|
||
self.layer_list = QListWidget()
|
||
self.layer_list.setMaximumHeight(120)
|
||
form1.addRow("Layers:", self.layer_list)
|
||
|
||
self.tracks_check = QCheckBox("include the net's traces "
|
||
"(tracks + arcs)")
|
||
self.tracks_check.setChecked(d.include_tracks)
|
||
form1.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(d.vias_capped)
|
||
form1.addRow("Vias:", self.capped_check)
|
||
|
||
self.cap_drill_edit = QLineEdit(f"{d.cap_max_drill_mm:g}")
|
||
self.cap_drill_edit.setEnabled(d.vias_capped)
|
||
self.capped_check.toggled.connect(self.cap_drill_edit.setEnabled)
|
||
form1.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(d.adaptive)
|
||
form1.addRow("Grid:", self.adaptive_check)
|
||
|
||
# --- classic section (only when classic mode is available) -----
|
||
# rows are CREATED conditionally, never shown-but-ignored; the
|
||
# switchable case toggles the whole section widget instead
|
||
# (portable to old Qt - QFormLayout.setRowVisible is 6.4+)
|
||
self.classic_section = None
|
||
self.contact1_box = None
|
||
self.contact2_box = None
|
||
self.model_box = None
|
||
self.current_edit = None
|
||
if self._classic_ok:
|
||
self.classic_section = QWidget()
|
||
cform = QFormLayout(self.classic_section)
|
||
cform.setContentsMargins(0, 0, 0, 0)
|
||
self.contact1_box = QComboBox()
|
||
self.contact2_box = QComboBox()
|
||
cform.addRow(f"V+ ({e1_label}):", self.contact1_box)
|
||
cform.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 d.contact_model == "uniform" else 1
|
||
self.model_box.setCurrentIndex(default_index)
|
||
cform.addRow("Contact model:", self.model_box)
|
||
|
||
self.current_edit = QLineEdit(f"{d.current_a:g}")
|
||
cform.addRow("Test current [A]:", self.current_edit)
|
||
|
||
# --- PDN section (only when a PdnSetup is given) ----------------
|
||
self.pdn_section = None
|
||
self.pdn_sup_table = None
|
||
self.pdn_load_table = None
|
||
self.pdn_splitter = None
|
||
self.pdn_totals = None
|
||
self.vnominal_edit = None
|
||
self._pdn_map: list = [] # rows[i] -> (table, table row)
|
||
self._pdn_layer_combos: list = []
|
||
self._pdn_layer_desired: list = []
|
||
self._pdn_hidden: list = [] # rows[i] not on the active net
|
||
if pdn is not None:
|
||
self.pdn_section = QWidget()
|
||
pv = QVBoxLayout(self.pdn_section)
|
||
pv.setContentsMargins(0, 0, 0, 0)
|
||
hdr = QLabel(f"PDN terminals — {pdn.source}")
|
||
hdr.setStyleSheet("font-weight: bold;")
|
||
pv.addWidget(hdr)
|
||
self._build_pdn_tables(pdn, pv)
|
||
self.pdn_totals = QLabel("")
|
||
pv.addWidget(self.pdn_totals)
|
||
hints = []
|
||
if pdn.note:
|
||
hints.append(pdn.note)
|
||
if pdn.from_config:
|
||
hints.append(f"geometry from {pdn.source}; edit the "
|
||
f"file to change terminals")
|
||
else:
|
||
# the role/layer mapping lives in the table titles now
|
||
hints.append("name from a text item inside the "
|
||
"rectangle; empty V_oc = V nominal")
|
||
hints.append("Bonded = contacts joined into one lug (the "
|
||
"per-contact split is solved); Layer = the "
|
||
"copper the terminal contacts; values take SI "
|
||
"suffixes (50m = 0.05)")
|
||
hint = QLabel(" — ".join(hints))
|
||
hint.setWordWrap(True)
|
||
hint.setStyleSheet("color: gray; font-size: 10px;")
|
||
pv.addWidget(hint)
|
||
pform = QFormLayout()
|
||
self.vnominal_edit = QLineEdit(
|
||
f"{d.v_nominal:g}" if d.v_nominal is not None
|
||
else f"{config.PDN_V_NOMINAL:g}")
|
||
pform.addRow("V nominal [V]:", self.vnominal_edit)
|
||
pv.addLayout(pform)
|
||
for t in (self.pdn_sup_table, self.pdn_load_table):
|
||
t.cellChanged.connect(lambda *_: self._update_totals())
|
||
self._update_totals()
|
||
|
||
# --- shared form #2 --------------------------------------------
|
||
form2 = QFormLayout()
|
||
|
||
self.freq_edit = QLineEdit(f"{d.freq_hz:g}" if d.freq_hz else "")
|
||
self.freq_edit.setPlaceholderText("0 = DC (e.g. 142k, 1.5M)")
|
||
form2.addRow("Frequency [Hz]:", self.freq_edit)
|
||
|
||
self.cell_edit = QLineEdit(f"{d.cell_um:g}" if d.cell_um else "")
|
||
self.cell_edit.setPlaceholderText("auto")
|
||
form2.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 d.include_buildup)
|
||
self.buildup_check.setEnabled(bool(buildup_layers))
|
||
form2.addRow("Buildup:", self.buildup_check)
|
||
|
||
self.extracu_edit = QLineEdit(f"{d.extra_cu_um:g}")
|
||
self.extracu_edit.setEnabled(bool(buildup_layers))
|
||
form2.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(d.push_overlays)
|
||
form2.addRow("Overlays:", self.overlay_check)
|
||
|
||
tfirst, tlast = config.TRIM_LAYERS[0], config.TRIM_LAYERS[-1]
|
||
self.trim_check = QCheckBox(
|
||
f"experimental: mark copper below the threshold as polygons "
|
||
f"on {tfirst}..{tlast} (replaces polygons there; a suggestion "
|
||
f"only - removing copper shifts current elsewhere)")
|
||
self.trim_check.setChecked(d.trim_enabled)
|
||
form2.addRow("Low-current copper:", self.trim_check)
|
||
|
||
self.trim_mode_box = QComboBox()
|
||
self.trim_mode_box.addItem("% of mean |J|", "pct")
|
||
self.trim_mode_box.addItem("A/mm²", "abs")
|
||
self.trim_mode_box.setCurrentIndex(1 if d.trim_mode == "abs" else 0)
|
||
self.trim_edit = QLineEdit(f"{d.trim_value:g}"
|
||
if d.trim_value is not None
|
||
else self._trim_default())
|
||
for w in (self.trim_edit, self.trim_mode_box):
|
||
w.setEnabled(d.trim_enabled)
|
||
self.trim_check.toggled.connect(w.setEnabled)
|
||
self.trim_mode_box.currentIndexChanged.connect(
|
||
self._trim_mode_changed)
|
||
trim_row = QWidget()
|
||
trim_lay = QHBoxLayout(trim_row)
|
||
trim_lay.setContentsMargins(0, 0, 0, 0)
|
||
trim_lay.addWidget(self.trim_edit, 1)
|
||
trim_lay.addWidget(self.trim_mode_box)
|
||
form2.addRow("Threshold:", trim_row)
|
||
|
||
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
||
buttons.accepted.connect(self._try_accept)
|
||
buttons.rejected.connect(self.reject)
|
||
self.ok_button = buttons.button(QDialogButtonBox.Ok)
|
||
self.load_button = None
|
||
self.save_button = None
|
||
if load_dir is not None:
|
||
self.load_button = buttons.addButton(
|
||
"Load config…", QDialogButtonBox.ActionRole)
|
||
self.load_button.clicked.connect(self._load_config)
|
||
if save_callback is not None:
|
||
self.save_button = buttons.addButton(
|
||
"Save config…", QDialogButtonBox.ActionRole)
|
||
self.save_button.clicked.connect(self._save_config)
|
||
if self._no_mode:
|
||
# nothing to run or to save until a load re-derives; Load
|
||
# config… and Cancel stay live
|
||
self.ok_button.setEnabled(False)
|
||
if self.save_button is not None:
|
||
self.save_button.setEnabled(False)
|
||
|
||
content = QWidget()
|
||
lay = QVBoxLayout(content)
|
||
lay.setContentsMargins(0, 0, 0, 0)
|
||
mode_row = QHBoxLayout()
|
||
mode_row.addWidget(QLabel("Mode:"))
|
||
mode_row.addWidget(self.mode_classic)
|
||
mode_row.addWidget(self.mode_pdn)
|
||
mode_row.addStretch(1)
|
||
lay.addLayout(mode_row)
|
||
self.reason_label = None
|
||
if reason is not None:
|
||
self.reason_label = QLabel(reason)
|
||
self.reason_label.setWordWrap(True)
|
||
self.reason_label.setStyleSheet("color: gray; font-size: 10px;")
|
||
lay.addWidget(self.reason_label)
|
||
lay.addLayout(form1)
|
||
if self.classic_section is not None:
|
||
lay.addWidget(self.classic_section)
|
||
if self.pdn_section is not None:
|
||
# stretch 1: enlarging the dialog grows the tables, not
|
||
# the form spacing
|
||
lay.addWidget(self.pdn_section, 1)
|
||
lay.addLayout(form2)
|
||
note = QLabel("Multiple layers are coupled through the net's "
|
||
"via/through-pad barrels. f > 0 applies only the "
|
||
"foil-thickness skin effect (a lower bound on the "
|
||
"resistance rise) - not an AC impedance simulation: "
|
||
"proximity and inductance are not modeled.")
|
||
note.setWordWrap(True)
|
||
note.setStyleSheet("color: gray; font-size: 10px;")
|
||
lay.addWidget(note)
|
||
# zero-stretch spacer: pools surplus height below the form
|
||
# when no table is there to absorb it (classic mode in an
|
||
# enlarged dialog) - the visible PDN section's stretch 1
|
||
# otherwise wins all of it
|
||
lay.addStretch()
|
||
# everything above scrolls when the content outgrows the
|
||
# screen-capped dialog; the error line and the buttons stay
|
||
# outside the scroll area so they are always visible
|
||
self._scroll = QScrollArea()
|
||
self._scroll.setWidgetResizable(True)
|
||
self._scroll.setFrameShape(QFrame.NoFrame)
|
||
# sizeHint tracks the content, so adjustSize() opens the
|
||
# dialog content-sized (clamped to the screen by Qt)
|
||
self._scroll.setSizeAdjustPolicy(
|
||
QAbstractScrollArea.AdjustToContents)
|
||
self._scroll.setWidget(content)
|
||
outer = QVBoxLayout(self)
|
||
outer.addWidget(self._scroll, 1)
|
||
self.error_label = QLabel("")
|
||
self.error_label.setWordWrap(True)
|
||
self.error_label.setVisible(False)
|
||
outer.addWidget(self.error_label)
|
||
outer.addWidget(buttons)
|
||
|
||
self._selection: Selection | None = None
|
||
self._desired1, self._desired2 = contact1, contact2
|
||
self._apply_mode()
|
||
if default_net:
|
||
self.net_box.setCurrentText(default_net)
|
||
self._refresh()
|
||
self.net_box.currentTextChanged.connect(self._refresh)
|
||
# one toggled signal fires for any radio switch (auto-exclusive)
|
||
self.mode_classic.toggled.connect(lambda _c: self._apply_mode())
|
||
|
||
# --- mode handling ----------------------------------------------------
|
||
|
||
def _active_mode(self) -> str:
|
||
return "pdn" if self.mode_pdn.isChecked() else "classic"
|
||
|
||
def _apply_mode(self) -> None:
|
||
"""Toggle the mode sections and swap the net combo between the
|
||
classic and PDN candidate sets (a net present in both stays
|
||
selected across the switch)."""
|
||
pdn_mode = self._active_mode() == "pdn"
|
||
if self.classic_section is not None:
|
||
self.classic_section.setVisible(not pdn_mode)
|
||
if self.pdn_section is not None:
|
||
self.pdn_section.setVisible(pdn_mode)
|
||
cands = (self._candidates_pdn if pdn_mode
|
||
else self._candidates_classic)
|
||
if cands is not self._candidates:
|
||
current = self.net_box.currentText()
|
||
self._candidates = cands
|
||
self.net_box.blockSignals(True)
|
||
self.net_box.clear()
|
||
for net in sorted(cands):
|
||
self.net_box.addItem(net)
|
||
if current in cands:
|
||
self.net_box.setCurrentText(current)
|
||
self.net_box.blockSignals(False)
|
||
self._refresh()
|
||
self._fit_size()
|
||
|
||
def _fit_size(self) -> None:
|
||
"""Default dialog size for the active mode: content-sized, but
|
||
in PDN mode at least ~60% of the available screen height so
|
||
the tables open with real room (extra height flows into them
|
||
via the stretch; the scroll area covers whatever still does
|
||
not fit). The KiCad window itself is not reachable through the
|
||
IPC API, so the screen is the reference. Everything stays
|
||
user-resizable afterwards."""
|
||
hint = self.sizeHint()
|
||
w, h = hint.width(), hint.height()
|
||
screen = self.screen() or QApplication.primaryScreen()
|
||
if screen is not None:
|
||
avail = screen.availableGeometry()
|
||
if self._active_mode() == "pdn":
|
||
h = max(h, int(avail.height() * 0.6))
|
||
w = min(w, int(avail.width() * 0.9))
|
||
h = min(h, int(avail.height() * 0.85))
|
||
self.resize(w, h)
|
||
|
||
# --- PDN tables -------------------------------------------------------
|
||
|
||
def _build_pdn_tables(self, pdn: PdnSetup, layout: QVBoxLayout) -> None:
|
||
"""One table per role - supplies and loads carry different value
|
||
columns, so mixing them forced grayed-out cells. Each title
|
||
names its role's marker layer: that is where a NEW rectangle
|
||
becomes a new terminal, whatever the current rows' source. Row
|
||
identity stays POSITIONAL: _pdn_map[i] is (table, table row)
|
||
for PdnSetup.rows[i], and neither table ever sorts (Qt
|
||
default). The Layer combos start empty; _refresh populates
|
||
them with the active net's layers."""
|
||
titles = {
|
||
"supply": (f"Supplies — rectangles on "
|
||
f"{config.ELECTRODE_POS_LAYER}"),
|
||
"load": (f"Loads — rectangles on "
|
||
f"{config.ELECTRODE_NEG_LAYER}"),
|
||
}
|
||
# both tables live in a vertical splitter: each sizes itself
|
||
# to its rows (no fixed cap), the drag handle redistributes
|
||
# height between them, and growing the dialog grows the
|
||
# splitter (the section has stretch 1); past the screen the
|
||
# dialog's scroll area takes over
|
||
splitter = QSplitter(Qt.Vertical)
|
||
splitter.setChildrenCollapsible(False)
|
||
tables = {}
|
||
labels = {}
|
||
for role, cols in (
|
||
("supply",
|
||
["Active", "Name", "Component", "R_out [Ω]",
|
||
"V_oc [V]", "Bonded", "Layer", "Contact parts",
|
||
"Comment"]),
|
||
("load",
|
||
["Active", "Name", "Component", "I draw [A]",
|
||
"Bonded", "Layer", "Contact parts", "Comment"])):
|
||
n = sum(1 for r in pdn.rows if r.role == role)
|
||
panel = QWidget()
|
||
pv = QVBoxLayout(panel)
|
||
pv.setContentsMargins(0, 0, 0, 0)
|
||
lab = QLabel(titles[role])
|
||
lab.setStyleSheet("font-weight: bold;")
|
||
pv.addWidget(lab)
|
||
labels[role] = lab
|
||
t = QTableWidget(n, len(cols))
|
||
t.setHorizontalHeaderLabels(cols)
|
||
t.verticalHeader().setVisible(False)
|
||
t.setMinimumHeight(84) # header + ~2 rows floor
|
||
t.setSizeAdjustPolicy(QAbstractScrollArea.AdjustToContents)
|
||
pv.addWidget(t)
|
||
splitter.addWidget(panel)
|
||
tables[role] = t
|
||
layout.addWidget(splitter, 1)
|
||
self.pdn_splitter = splitter
|
||
fill = {"supply": 0, "load": 0}
|
||
for row in pdn.rows:
|
||
t = tables[row.role]
|
||
i = fill[row.role]
|
||
fill[row.role] += 1
|
||
self._pdn_map.append((t, i))
|
||
values = ([row.r_out_ohm, row.v_oc] if row.role == "supply"
|
||
else [row.i_draw_a])
|
||
# tail columns: ... | Bonded | Layer | parts | Comment
|
||
last = t.columnCount() - 1
|
||
cells = [(1, row.name, False), (2, row.component, False)]
|
||
for col, value in enumerate(values, start=3):
|
||
cells.append((col, "" if value is None else f"{value:g}",
|
||
True))
|
||
cells.append((last - 1, row.resolved, False))
|
||
cells.append((last, row.comment, True))
|
||
for col, text, editable in cells:
|
||
it = QTableWidgetItem(str(text))
|
||
it.setFlags((Qt.ItemIsEnabled | Qt.ItemIsSelectable
|
||
| Qt.ItemIsEditable) if editable
|
||
else Qt.ItemIsEnabled)
|
||
t.setItem(i, col, it)
|
||
box = QTableWidgetItem("")
|
||
box.setFlags(Qt.ItemIsEnabled | Qt.ItemIsUserCheckable)
|
||
box.setCheckState(Qt.Checked if row.active else Qt.Unchecked)
|
||
t.setItem(i, 0, box)
|
||
bond = QTableWidgetItem("")
|
||
bond.setFlags(Qt.ItemIsEnabled | Qt.ItemIsUserCheckable)
|
||
bond.setCheckState(Qt.Checked if row.bonded
|
||
else Qt.Unchecked)
|
||
t.setItem(i, last - 3, bond)
|
||
combo = QComboBox()
|
||
idx = len(self._pdn_layer_combos)
|
||
self._pdn_layer_combos.append(combo)
|
||
self._pdn_layer_desired.append(row.contact)
|
||
# activated fires only on a USER pick: the sticky desired
|
||
# value survives repopulation on net/mode switches
|
||
combo.activated.connect(
|
||
lambda _i, idx=idx: self._layer_picked(idx))
|
||
t.setCellWidget(i, last - 2, combo)
|
||
for t in tables.values():
|
||
t.resizeColumnsToContents()
|
||
self.pdn_sup_table = tables["supply"]
|
||
self.pdn_load_table = tables["load"]
|
||
self.pdn_sup_label = labels["supply"]
|
||
self.pdn_load_label = labels["load"]
|
||
|
||
def _layer_picked(self, idx: int) -> None:
|
||
self._pdn_layer_desired[idx] = (
|
||
self._pdn_layer_combos[idx].currentData())
|
||
|
||
def _row_hidden(self, i: int) -> bool:
|
||
return bool(self._pdn_hidden) and self._pdn_hidden[i]
|
||
|
||
def _apply_net_filter(self, net: str) -> None:
|
||
"""Hide the rows whose contacts carry no copper of the active
|
||
net: they are not part of this run (skipped by validation,
|
||
totals and the solve) but they stay in the returned row list,
|
||
so a save keeps them - as "active": false, since a saved PDN
|
||
config pins this very net. Rows with nets=None always show."""
|
||
self._pdn_hidden = []
|
||
for i, row in enumerate(self._pdn.rows):
|
||
hidden = row.nets is not None and net not in row.nets
|
||
t, r = self._pdn_map[i]
|
||
t.setRowHidden(r, hidden)
|
||
self._pdn_hidden.append(hidden)
|
||
self._update_totals()
|
||
|
||
def _refresh_layer_combos(self, layers: list) -> None:
|
||
"""Repopulate the per-terminal Layer combos for the active
|
||
net's layers; the desired value is re-selected when available,
|
||
else the combo falls back to its first entry. Config-backed
|
||
ROWS also offer "auto" - per contact part, the schema
|
||
default - which a live rectangle does not need (a rectangle's
|
||
natural scope IS all layers)."""
|
||
for idx, combo in enumerate(self._pdn_layer_combos):
|
||
combo.blockSignals(True)
|
||
combo.clear()
|
||
if self._pdn.rows[idx].from_config:
|
||
combo.addItem(AUTO_CONTACT, "auto")
|
||
combo.addItem(ALL_LAYERS, "all")
|
||
for name in layers:
|
||
combo.addItem(name, name)
|
||
i = combo.findData(self._pdn_layer_desired[idx])
|
||
combo.setCurrentIndex(i if i >= 0 else 0)
|
||
combo.blockSignals(False)
|
||
|
||
def _update_totals(self) -> None:
|
||
"""Best-effort live sum of the load draws under the tables;
|
||
unparseable cells are simply skipped (OK validates properly).
|
||
Counts only the checked rows on the active net; unchecked and
|
||
hidden rows are called out so a missing terminal is
|
||
explainable."""
|
||
total = 0.0
|
||
ns = nl = off = 0
|
||
for i, row in enumerate(self._pdn.rows):
|
||
if self._row_hidden(i):
|
||
continue
|
||
t, r = self._pdn_map[i]
|
||
box = t.item(r, 0)
|
||
if box is not None and box.checkState() != Qt.Checked:
|
||
off += 1
|
||
continue
|
||
if row.role != "load":
|
||
ns += 1
|
||
continue
|
||
nl += 1
|
||
it = t.item(r, 3)
|
||
text = it.text().strip() if it is not None else ""
|
||
if not text:
|
||
continue
|
||
try:
|
||
total += skin.parse_engineering(text)
|
||
except ValueError:
|
||
pass
|
||
text = f"{ns} supplies, {nl} loads, {total:g} A total draw"
|
||
notes = []
|
||
if off:
|
||
notes.append(f"{off} disabled")
|
||
hidden = sum(self._pdn_hidden)
|
||
if hidden:
|
||
notes.append(f"{hidden} not on this net: hidden")
|
||
if notes:
|
||
text += f" ({'; '.join(notes)})"
|
||
self.pdn_totals.setText(text)
|
||
|
||
def _read_pdn_rows(self) -> list:
|
||
"""Read + validate the tables into fresh PdnTerminalRow objects
|
||
(same order and length as PdnSetup.rows - EVERY row comes back,
|
||
so nothing in the dialog is ever lost on save); ValueError
|
||
names the offending terminal. The returned `active` records
|
||
in-run status: the checkbox AND the net filter. An off-net row
|
||
can never run under this net - and a saved PDN config pins its
|
||
net - so it is saved as "active": false while its geometry,
|
||
values and comment are all kept. Any row not in the run may
|
||
leave its value cells blank, but anything entered must still be
|
||
valid (a typo is never silently dropped on save)."""
|
||
out = []
|
||
for i, row in enumerate(self._pdn.rows):
|
||
t, r = self._pdn_map[i]
|
||
|
||
def cell(col):
|
||
it = t.item(r, col)
|
||
return it.text().strip() if it is not None else ""
|
||
new = PdnTerminalRow(name=row.name, role=row.role,
|
||
resolved=row.resolved,
|
||
component=row.component,
|
||
from_config=row.from_config)
|
||
box = t.item(r, 0)
|
||
checked = (box is None
|
||
or box.checkState() == Qt.Checked)
|
||
new.active = checked and not self._row_hidden(i)
|
||
bond = t.item(r, t.columnCount() - 4)
|
||
new.bonded = (bond is not None
|
||
and bond.checkState() == Qt.Checked)
|
||
new.comment = cell(t.columnCount() - 1)
|
||
combo = self._pdn_layer_combos[i]
|
||
new.contact = (combo.currentData() if combo.count()
|
||
else row.contact)
|
||
if row.role == "load":
|
||
text = cell(3)
|
||
if not text:
|
||
if new.active:
|
||
raise ValueError(
|
||
f"Terminal '{row.name}': I draw is required "
|
||
f"(0 = voltage probe).")
|
||
else:
|
||
v = _parse_number(text,
|
||
f"Terminal '{row.name}': I draw")
|
||
if v < 0:
|
||
raise ValueError(
|
||
f"Terminal '{row.name}': I draw must be "
|
||
f"≥ 0 A.")
|
||
new.i_draw_a = v
|
||
else:
|
||
text = cell(3)
|
||
if not text:
|
||
if new.active:
|
||
raise ValueError(
|
||
f"Terminal '{row.name}': R_out is required "
|
||
f"(0 = ideal source).")
|
||
else:
|
||
v = _parse_number(text,
|
||
f"Terminal '{row.name}': R_out")
|
||
if v < 0:
|
||
raise ValueError(
|
||
f"Terminal '{row.name}': R_out must be "
|
||
f"≥ 0 Ω.")
|
||
new.r_out_ohm = v
|
||
vtext = cell(4)
|
||
if vtext:
|
||
vv = _parse_number(
|
||
vtext, f"Terminal '{row.name}': V_oc")
|
||
if vv <= 0:
|
||
raise ValueError(
|
||
f"Terminal '{row.name}': V_oc must be > 0 V "
|
||
f"(leave empty for V nominal).")
|
||
new.v_oc = vv
|
||
out.append(new)
|
||
return out
|
||
|
||
# --- shared helpers ---------------------------------------------------
|
||
|
||
def _show_error(self, msg: str) -> None:
|
||
self.error_label.setStyleSheet("color: #b02a2a;")
|
||
self.error_label.setText(msg)
|
||
self.error_label.setVisible(True)
|
||
|
||
def _show_info(self, msg: str) -> None:
|
||
self.error_label.setStyleSheet("color: #2a7a2a;")
|
||
self.error_label.setText(msg)
|
||
self.error_label.setVisible(True)
|
||
|
||
def _trim_default(self, mode: str | None = None) -> str:
|
||
mode = mode or self.trim_mode_box.currentData()
|
||
return (f"{config.TRIM_THRESHOLD_A_MM2:g}" if mode == "abs"
|
||
else f"{config.TRIM_THRESHOLD_PCT:g}")
|
||
|
||
def _trim_mode_changed(self):
|
||
# swap in the new unit's default, but never clobber a number the
|
||
# user typed themselves
|
||
mode = self.trim_mode_box.currentData()
|
||
other = "pct" if mode == "abs" else "abs"
|
||
if self.trim_edit.text().strip() in ("", self._trim_default(other)):
|
||
self.trim_edit.setText(self._trim_default(mode))
|
||
|
||
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)
|
||
checked = True
|
||
if self._preset_layers is not None and net == self._preset_net:
|
||
checked = name in self._preset_layers
|
||
item.setCheckState(Qt.Checked if checked else Qt.Unchecked)
|
||
self.layer_list.addItem(item)
|
||
if self._pdn is not None:
|
||
self._refresh_layer_combos(layers)
|
||
self._apply_net_filter(net)
|
||
boxes = []
|
||
if self.contact1_box is not None:
|
||
boxes = [(self.contact1_box, self._desired1),
|
||
(self.contact2_box, self._desired2)]
|
||
for box, desired in boxes:
|
||
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:
|
||
return _parse_number(edit.text().strip(), name)
|
||
|
||
pdn_mode = self._active_mode() == "pdn"
|
||
pdn_rows = None
|
||
v_nominal = None
|
||
if pdn_mode:
|
||
pdn_rows = self._read_pdn_rows()
|
||
live = [r for r in pdn_rows if r.active]
|
||
for role in ("supply", "load"):
|
||
if not any(r.role == role for r in live):
|
||
raise ValueError(
|
||
f"At least one active {role} is needed - check "
|
||
f"an Active box in the {role} table.")
|
||
vtext = self.vnominal_edit.text().strip()
|
||
if not vtext:
|
||
raise ValueError("V nominal is required in PDN mode "
|
||
"(the default supply open-circuit "
|
||
"voltage).")
|
||
v_nominal = _parse_number(vtext, "V nominal")
|
||
if v_nominal <= 0:
|
||
raise ValueError("V nominal must be > 0 V.")
|
||
current = sum(r.i_draw_a for r in live if r.role == "load")
|
||
else:
|
||
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 as exc:
|
||
# as in number(): keep parse_frequency's own explanation for
|
||
# the inputs it rejects deliberately, not just "unparseable"
|
||
if any(k in str(exc) for k in ("separator", "negative")):
|
||
raise ValueError(f"Frequency: {exc}")
|
||
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.")
|
||
trim_mode = self.trim_mode_box.currentData()
|
||
trim_value = float(self._trim_default(trim_mode))
|
||
if self.trim_check.isChecked():
|
||
trim_value = number(self.trim_edit, "Trim threshold")
|
||
if trim_mode == "pct" and not 0 < trim_value < 100:
|
||
raise ValueError("Trim threshold must be between 0 and "
|
||
"100 (% of the mean |J|).")
|
||
if trim_mode == "abs" and trim_value <= 0:
|
||
raise ValueError("Trim threshold must be > 0 A/mm².")
|
||
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 | None) -> str:
|
||
if box is None or pdn_mode:
|
||
return "auto" # PDN: scopes live per terminal
|
||
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()
|
||
if self.model_box is not None
|
||
and not pdn_mode else "uniform"),
|
||
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(),
|
||
trim_enabled=self.trim_check.isChecked(),
|
||
trim_mode=trim_mode, trim_value=trim_value,
|
||
mode="pdn" if pdn_mode else "classic",
|
||
pdn_rows=pdn_rows, v_nominal=v_nominal)
|
||
|
||
def _load_config(self) -> None:
|
||
"""'Load config…': pick a config file - a VALID pick closes the
|
||
dialog with a LoadRequest (main re-derives everything from that
|
||
file and reopens the dialog), an invalid one shows the loader's
|
||
error and stays open. Nothing in the current form is validated:
|
||
loading replaces it wholesale."""
|
||
path, _filter = QFileDialog.getOpenFileName(
|
||
self, "Load config", str(self._load_dir),
|
||
"Config files (*.json)")
|
||
if not path:
|
||
return
|
||
try:
|
||
configfile.load_config(Path(path))
|
||
except ConfigError as e:
|
||
self._show_error(str(e))
|
||
return
|
||
self._load_request = Path(path)
|
||
self.accept()
|
||
|
||
def _save_config(self) -> None:
|
||
"""'Save config…': validate like OK, ask for the target file
|
||
(name editable - seeded with the loaded config, or the default
|
||
name; the next save re-seeds with whatever was chosen), then
|
||
hand Selection + path to the callback (which writes the file
|
||
and returns its name). The dialog stays open."""
|
||
try:
|
||
sel = self._build_selection()
|
||
except ValueError as e:
|
||
self._show_error(str(e))
|
||
return
|
||
path, _filter = QFileDialog.getSaveFileName(
|
||
self, "Save config",
|
||
str(self._save_target) if self._save_target else "",
|
||
"Config files (*.json)")
|
||
if not path:
|
||
return
|
||
target = Path(path)
|
||
if target.suffix.lower() != ".json":
|
||
# non-native pickers do not append the filter's suffix
|
||
target = target.with_name(target.name + ".json")
|
||
try:
|
||
saved_to = self._save_callback(sel, target)
|
||
except Exception as e:
|
||
self._show_error(str(e))
|
||
return
|
||
self._save_target = target
|
||
self._show_info(f"saved to {saved_to}")
|
||
|
||
def _try_accept(self) -> None:
|
||
try:
|
||
self._selection = self._build_selection()
|
||
except ValueError as e:
|
||
self._show_error(str(e))
|
||
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,
|
||
defaults: DialogDefaults | None = None,
|
||
pdn: PdnSetup | None = None,
|
||
pdn_candidates: dict | None = None,
|
||
classic_reason: str | None = None,
|
||
pdn_reason: str | None = None,
|
||
save_callback=None, save_target=None, load_dir=None,
|
||
start_mode: str = "classic"):
|
||
"""Show the dialog; returns a Selection, a LoadRequest (the user
|
||
picked another config with "Load config…" - re-derive and call ask
|
||
again), or None on cancel. defaults: widget seeds (config.py +
|
||
config file); pdn: the PDN terminal setup (editable tables);
|
||
pdn_candidates: net candidates for PDN mode (classic uses
|
||
`candidates`); classic_reason / pdn_reason: why a mode is
|
||
unavailable (its radio is disabled with the reason shown; with
|
||
BOTH unavailable the dialog opens load-only - OK and Save disabled
|
||
until Load config… returns a LoadRequest);
|
||
save_callback(selection, target_path) -> saved name string enables
|
||
the "Save config…" button in both modes (the file name is asked
|
||
per save, seeded with save_target); load_dir (the board directory)
|
||
enables the "Load config…" button; start_mode ("classic"/"pdn") is
|
||
only the STARTING radio - both stay switchable while available."""
|
||
app = QApplication.instance() or QApplication([])
|
||
dlg = _Dialog(candidates, layer_order, default_net, e1_label, e2_label,
|
||
contact1, contact2, buildup_layers or [],
|
||
defaults=defaults, pdn=pdn, pdn_candidates=pdn_candidates,
|
||
classic_reason=classic_reason, pdn_reason=pdn_reason,
|
||
save_callback=save_callback, save_target=save_target,
|
||
load_dir=load_dir, start_mode=start_mode)
|
||
dlg.raise_()
|
||
dlg.activateWindow()
|
||
if dlg.exec() != QDialog.Accepted:
|
||
return None
|
||
if dlg._load_request is not None:
|
||
return LoadRequest(dlg._load_request)
|
||
return dlg._selection
|