Release 1.4.2: load-only dialog when nothing is selected
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
This commit is contained in:
janik
2026-08-31 11:42:51 +07:00
co-authored by Claude Fable 5
parent 19327c23b1
commit 634df87fe0
9 changed files with 144 additions and 41 deletions
+18 -12
View File
@@ -45,8 +45,8 @@ SWIG API. Requires KiCad **10.0.1+**.
| Platform | Status | Verified by |
|-----------------------------|:------:|-------------|
| Windows | ✅ | development platform, full suite before every release |
| macOS | ✅ | field-tested in KiCad 10 |
| NixOS | ✅ | field-tested in KiCad 10 ([setup](docs/NIXOS.md)) |
| macOS | ✅ | field-tested in KiCad 10 (last on plugin v1.3) |
| NixOS | ✅ | field-tested in KiCad 10 (last on plugin v1.3, [setup](docs/NIXOS.md)) |
| Debian 12 | ✅ | CI test suite in container |
| Ubuntu 24.04 | ✅ | CI test suite in container |
| Fedora (latest) | ✅ | CI test suite in container |
@@ -57,16 +57,17 @@ rasterizer, and the platform-fallback regressions — headless against
the real pip wheels of each Linux row, including the
`PySide6.QtWidgets` import probe that decides the matplotlib backend.
What CI *cannot* do is launch KiCad itself, so "runs inside KiCad"
remains field-tested (Windows continuously, macOS and NixOS per
release).
remains field-tested (Windows continuously; macOS and NixOS last
field-tested with plugin v1.3 — later versions are covered there by
the test suites only).
## Setup (one-time)
The plugin is developed and tested on **Windows**; **macOS works**
(field-tested on KiCad 10 after a round of mac-specific fixes), and
**Linux works** (field-tested on NixOS — the hardest Linux to run pip
wheels on; mainstream FHS distributions should be no harder, reports
welcome). KiCad builds the plugin a private Python venv from
(field-tested on KiCad 10 after a round of mac-specific fixes, last
with plugin v1.3), and **Linux works** (field-tested on NixOS — the
hardest Linux to run pip wheels on, last with plugin v1.3; mainstream
FHS distributions should be no harder, reports welcome). KiCad builds the plugin a private Python venv from
`requirements.txt` on every platform, from pre-built wheels only, no
compiler needed. Steps 14 are the same everywhere; OS specifics are
spelled out per step and in *Platform notes* below.
@@ -110,7 +111,8 @@ spelled out per step and in *Platform notes* below.
- **Windows** is the development and test platform — everything in
this README was exercised here. KiCad's bundled Python is 3.13, so
the venv gets the current dependency stack.
- **macOS** — **works** (field-tested on KiCad 10). Requires
- **macOS** — **works** (field-tested on KiCad 10, last with plugin
v1.3). Requires
macOS 12+ (KiCad's own minimum; Intel and Apple Silicon — the dmg
is universal). KiCad's bundled Python is **3.9**, so pip resolves
an older stack (numpy 2.0, scipy 1.13, matplotlib 3.9,
@@ -119,8 +121,9 @@ spelled out per step and in *Platform notes* below.
Plot and dialog windows may open **behind** the KiCad window (they
are raised best-effort) — check the Dock if nothing seems to appear
after a solve.
- **Linux** — **works** (field-tested on NixOS, KiCad 10; mainstream
distributions are audited but not yet field-tested). The venv uses
- **Linux** — **works** (field-tested on NixOS, KiCad 10, last with
plugin v1.3; mainstream distributions are audited but not yet
field-tested). The venv uses
the system Python (3.9+), so the stack matches your distribution.
On **ARM64 (aarch64)** there are no pyamg wheels —
`requirements.txt` skips pyamg there and the solver falls back to
@@ -236,7 +239,10 @@ the picked file. **Save config…** asks for the target file name each
time (pre-filled with the loaded config), so writing back and saving
a variant under a new `fill_res_config.<name>.json` are both one
click — a name outside the auto-load set prints a reminder that it
needs Load config….
needs Load config…. The dialog opens even when nothing on the board
yields a setup (nothing selected, no marker rectangles, no config):
load-only, with both mode radios disabled and their reasons shown, so
**Load config…** can bootstrap the run.
**Precedence**: `config.py` constants < config file < dialog edits.
The file pre-fills the dialog; what the dialog shows is what runs. A
+11
View File
@@ -0,0 +1,11 @@
Launching with nothing usable on the board no longer dead-ends: when
neither mode can be derived (nothing selected, no marker rectangles,
no config), the dialog now opens anyway - load-only - instead of
failing with an error figure.
- Both mode radios are unchecked and disabled with their reasons
shown; OK and Save config... are disabled too. Load config... (and
Cancel) stay live, so a saved config can bootstrap the run without
first selecting pads or drawing marker rectangles.
- A loaded file re-derives everything exactly as if it had been
present at launch - mode, net, terminals, values.
+1 -1
View File
@@ -4,4 +4,4 @@ __version__ is the runtime source of truth (metadata.json and
pyproject.toml are not deployed with the plugin); a test keeps the
three in sync.
"""
__version__ = "1.4.1"
__version__ = "1.4.2"
+47 -19
View File
@@ -26,7 +26,11 @@ Two run modes share the dialog, chosen by a radio at the top:
"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.
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.
@@ -205,16 +209,28 @@ class _Dialog(QDialog):
self.mode_pdn = QRadioButton("PDN")
self.mode_classic.setEnabled(self._classic_ok)
self.mode_pdn.setEnabled(self._pdn_ok)
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)
reason = None
# 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:
reason = f"Classic unavailable: {classic_reason}"
reasons.append(f"Classic unavailable: {classic_reason}")
self.mode_classic.setToolTip(classic_reason)
elif not self._pdn_ok and pdn_reason:
reason = f"PDN unavailable: {pdn_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()
@@ -387,14 +403,23 @@ class _Dialog(QDialog):
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:
load_btn = buttons.addButton("Load config…",
QDialogButtonBox.ActionRole)
load_btn.clicked.connect(self._load_config)
self.load_button = buttons.addButton(
"Load config…", QDialogButtonBox.ActionRole)
self.load_button.clicked.connect(self._load_config)
if save_callback is not None:
save_btn = buttons.addButton("Save config…",
QDialogButtonBox.ActionRole)
save_btn.clicked.connect(self._save_config)
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)
@@ -405,11 +430,12 @@ class _Dialog(QDialog):
mode_row.addWidget(self.mode_pdn)
mode_row.addStretch(1)
lay.addLayout(mode_row)
self.reason_label = None
if reason is not None:
rl = QLabel(reason)
rl.setWordWrap(True)
rl.setStyleSheet("color: gray; font-size: 10px;")
lay.addWidget(rl)
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)
@@ -997,7 +1023,9 @@ def ask(candidates: dict[str, list[str]], layer_order: list[str],
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);
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)
+15 -6
View File
@@ -7,7 +7,10 @@ config's terminal set) -> gather fills -> dialog with a Classic/PDN
mode selector (classic: the two-contact form; PDN: editable per-role
terminal tables) -> extract vias -> solve -> figures + report. The
dialog's "Load config…" button loops back to the derivation with the
picked file, so a run can be set up from any saved config.
picked file, so a run can be set up from any saved config - and the
dialog opens even when NEITHER mode is derivable (nothing selected, no
marker rectangles, no config): load-only, both radios disabled with
their reasons, so a saved config can bootstrap the run.
Every failure is reported twice: on stdout (lands in the KiCad status-bar
warning list) and as a matplotlib error figure, so it cannot be missed.
@@ -101,8 +104,10 @@ def main() -> None:
configfile.apply_physics(cfg)
# BOTH terminal derivations always run; a failure only
# disables that mode's radio (with the reason shown) - the
# launch dies only when neither mode is possible
# disables that mode's radio (with the reason shown).
# Even with neither mode possible the dialog still
# opens - load-only - so "Load config…" works without
# first selecting pads or drawing marker rectangles
classic_reason = pdn_reason = None
es1: list = []
es2: list = []
@@ -210,9 +215,13 @@ def main() -> None:
f"workable supply+load copper; PDN "
f"candidates: {', '.join(pdn_nets)}")
if classic_reason is not None and pdn_reason is not None:
raise SelectionError(
f"{classic_reason}\n(PDN mode is also "
f"unavailable: {pdn_reason})")
# nothing runnable right now: the dialog opens with
# both radios and OK/Save disabled, offering only
# Load config… (and Cancel) - a loaded file then
# re-derives everything through the loop below
print(f"note: classic mode unavailable: "
f"{classic_reason}")
print(f"note: PDN mode unavailable: {pdn_reason}")
buildups = board_io.gather_mask_buildups(board)
except ApiError as e:
raise UserFacingError(
+1 -1
View File
@@ -17,7 +17,7 @@
},
"versions": [
{
"version": "1.4.1",
"version": "1.4.2",
"status": "stable",
"kicad_version": "10.0",
"runtime": "ipc"
+1 -1
View File
@@ -3,7 +3,7 @@
# the dependency list there in sync with [project.dependencies].
[project]
name = "fill-resistance"
version = "1.4.1"
version = "1.4.2"
description = "DC resistance of copper zone fills and traces between two contacts (KiCad 10 plugin)"
license = "GPL-3.0-or-later"
requires-python = ">=3.11"
+49
View File
@@ -590,6 +590,55 @@ def test_load_button_cancelled_picker_does_nothing(app, tmp_path,
assert not dlg.error_label.isVisible()
# --- no runnable mode: the load-only dialog ----------------------------------
def _no_mode_dlg(app, load_dir=None, save_callback=None):
"""Neither mode derivable (nothing selected, no marker rects, no
config): main opens the dialog anyway so a config can be loaded."""
return _Dialog({}, ORDER, "", "", "", "auto", "auto",
buildup_layers=[], pdn=None, pdn_candidates={},
classic_reason="nothing selected",
pdn_reason="no rectangles found",
save_callback=save_callback, load_dir=load_dir)
def test_no_mode_opens_load_only(app, tmp_path):
dlg = _no_mode_dlg(app, load_dir=tmp_path,
save_callback=lambda *_a: "x")
assert not dlg.mode_classic.isEnabled()
assert not dlg.mode_pdn.isEnabled()
# no mode is even checked - there is nothing to run
assert not dlg.mode_classic.isChecked()
assert not dlg.mode_pdn.isChecked()
assert not dlg.ok_button.isEnabled()
assert not dlg.save_button.isEnabled()
assert dlg.load_button.isEnabled()
# both reasons and the load hint are shown together
text = dlg.reason_label.text()
assert "nothing selected" in text
assert "no rectangles found" in text
assert "Load config…" in text
def test_no_mode_load_config_still_works(app, tmp_path, monkeypatch):
path = tmp_path / "fill_res_config.saved.json"
path.write_text('{"version": 1}', encoding="utf-8")
monkeypatch.setattr(dialog_mod, "QFileDialog", _FakePicker)
_FakePicker.result = (str(path), "json")
dlg = _no_mode_dlg(app, load_dir=tmp_path)
dlg._load_config()
assert dlg._load_request == path
assert dlg.result() == QDialog.Accepted
def test_available_modes_keep_ok_and_save_enabled(app, tmp_path):
dlg = _dlg(app, pdn=_setup(), save_callback=lambda *_a: "x",
load_dir=tmp_path)
assert dlg.ok_button.isEnabled()
assert dlg.save_button.isEnabled()
assert dlg.load_button.isEnabled()
# --- save button -------------------------------------------------------------
def _patch_save(monkeypatch, name):
Generated
+1 -1
View File
@@ -216,7 +216,7 @@ wheels = [
[[package]]
name = "fill-resistance"
version = "1.4.1"
version = "1.4.2"
source = { virtual = "." }
dependencies = [
{ name = "kicad-python" },