8 Commits

Author SHA1 Message Date
Berwn a4945f419c NixOS works: document the full FHS recipe, drop the nix-ld dead end
Build PCM package / build (push) Successful in 5s
First successful Linux field test (NixOS 26.05, Plasma 6, KiCad 10).
Getting there peeled three layers the error messages misattribute:
the FHS env itself (libgthread), then libs the xcb-cursor hint hides
(the xcb-util family, and libzstd for libQt6Core - in zstd.out, not
zstd's default output), and finally Plasma's QT_PLUGIN_PATH feeding
the system Qt's platform plugins to the wheel's bundled Qt, a
private-ABI mismatch that fails exactly like a missing library.

New docs/NIXOS.md carries the verified buildFHSEnv wrapper, the
steam-run quick test (now with env -u QT_PLUGIN_PATH), and a guide
for chasing future missing sonames. README upgrades Linux from
untested to works and links the doc. The in-plugin error no longer
suggests programs.nix-ld: the venv python is a Nix binary, so the
nix-ld shim never engages for it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 16:08:15 +07:00
janik 64676624e6 NixOS: steam-run alone is one library short of a working Qt
Build PCM package / build (push) Successful in 6s
Field-tested: under steam-run the PySide6 wheel loads, but Qt aborts
at platform-plugin init - the Steam runtime predates Qt 6.5, which
hard-requires libxcb-cursor0 for xcb (and its wayland stack is too
old for the wayland plugin). Document the working invocation: point
LD_LIBRARY_PATH at xcb-util-cursor on top of steam-run, or build a
proper buildFHSEnv wrapper.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 15:32:46 +07:00
janik 7604e18587 Fail usefully on NixOS: probe QtCore, explain the missing-.so error
Build PCM package / build (push) Successful in 6s
First NixOS attempt: pip wheel PySide6 cannot load libgthread-2.0.so.0
because NixOS has no FHS library paths. Nothing inside the venv can
fix that (KiCad installs wheels only) - but the plugin made it worse
twice over:

- The backend probe imported bare PySide6, whose pure-Python __init__
  succeeds even when QtCore's .so cannot load - so matplotlib was
  promised QtAgg and the error figure died at switch_backend, taking
  the failure report with it. The probe now imports <binding>.QtCore
  and falls through to Tk/Agg on a broken Qt.
- The user got a raw ImportError traceback. A cannot-open-shared-object
  failure during the kipy/dialog imports now raises a UserFacingError
  that names the actual fixes: run KiCad in an FHS environment
  (steam-run) or enable nix-ld with Qt runtime libraries. The README
  Linux notes carry the same guidance.

141 passed on the dev stack and the Python 3.9 mac-equivalent stack.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 15:22:22 +07:00
janik 24fed64f83 Release 1.3.0
Build PCM package / build (push) Successful in 7s
macOS is now field-tested and working; the README platform notes say
so instead of untested. Linux remains audited-only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 13:37:17 +07:00
janik 23edb39f52 Fix the two macOS field-test failures: backend order, RO board dir
Build PCM package / build (push) Successful in 5s
Second Mac run (KiCad demo board opened from the mounted installer
image) got past the dialog and died twice:

1. make_output_dir crashed with OSError 30: the demos volume is a
   read-only filesystem. Now falls back to a temp directory named
   after the board, with the path printed (the Messages panel showed
   it is actually read there).

2. The error figure - and every other figure - could never render:
   matplotlib refused with Cannot load backend TkAgg ... as qt is
   currently running. macOS bundled Python ships tkinter, so the
   old tk-first probe picked TkAgg while the PySide6 dialog and
   progress window had already made this a Qt process. Windows never
   saw it because KiCad Python there has no tkinter. Qt now comes
   first - PySide6 is a hard dependency, so it is always there.

Both covered by tests that fail on the old code: the dev env has both
toolkits installed, so the backend-order assertion is exercised for
real, and the RO fallback is simulated by denying mkdir under the
board dir. 140 passed on the dev stack and on the Python 3.9 +
numpy 2.0 / scipy 1.13 / matplotlib 3.9 / PySide6 6.10 stack that a
Mac venv resolves.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 13:29:22 +07:00
janik 346016ba8f Fix the macOS crash at import: defer config.py annotations
Build PCM package / build (push) Successful in 7s
First real run on a Mac died before the dialog could open:

    config.py line 15: CELL_UM_OVERRIDE: float | None = None
    TypeError: unsupported operand type(s) for |: 'type' and 'NoneType'

KiCad macOS bundles Python 3.9, where PEP 604 unions in annotations
are evaluated at import time unless deferred - config.py was the one
annotated module without the __future__ import (errors.py has no
annotations, __init__.py is empty).

A new tripwire test walks every shipped module with ast and fails if
a file uses annotations without deferring them, so the next module
added (progress.py was born only last week) cannot regress this
silently while the Windows suite stays green.

Verified for real this time, not audited: the full suite passes under
CPython 3.9.25 with the exact stack a Mac venv resolves (numpy 2.0.2,
scipy 1.13.1, matplotlib 3.9.4, PySide6 6.10.0, pyamg 5.2.1) - 137
passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 13:19:21 +07:00
janik f9abc06082 Be honest that Linux and macOS are untested
Build PCM package / build (push) Successful in 6s
The setup section and platform notes claimed all three OSes work; the
Linux and macOS statements come from a dependency/code audit only -
nobody has run the plugin there. Say so and ask for reports.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 12:10:48 +07:00
janik 3c90f96a63 Per-OS setup instructions; skip pyamg on Linux aarch64
Build PCM package / build (push) Successful in 15s
The README assumed Windows throughout. Setup now gives the interpreter
path, deploy command and venv location for Windows, macOS and Linux
(venv paths verified against KiCad 10 sources: GetUserCachePath +
python-environments), plus a Platform notes section: macOS bundles
Python 3.9.13 so pip resolves an older wheel stack (KiCad installs with
--only-binary :all:), and windows there may open behind KiCad; Linux
needs python3-venv on Debian/Ubuntu.

pyamg has never published Linux aarch64 wheels, and with KiCad's
wheels-only pip one unresolvable requirement kills the whole venv
build - so an environment marker skips pyamg there and the solver
falls back to Jacobi-CG, which it already supports.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 11:23:53 +07:00
13 changed files with 417 additions and 35 deletions
+67 -14
View File
@@ -30,14 +30,28 @@ SWIG API. Requires KiCad **10.0.1+**.
## Setup (one-time) ## 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
`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.
1. **Enable the API server**: KiCad → Preferences → Plugins → check 1. **Enable the API server**: KiCad → Preferences → Plugins → check
*Enable KiCad API*. *Enable KiCad API*.
2. **Check the interpreter path** on the same page: should point at the 2. **Check the interpreter path** on the same page (after a 9→10
KiCad 10 Python, e.g. `C:\Program Files\KiCad\10.0\bin\pythonw.exe` upgrade it can still point at KiCad 9):
on Windows or `/usr/bin/python3` on Linux (after a 9→10 upgrade it - **Windows**: KiCad's own Python,
can point at KiCad 9). `C:\Program Files\KiCad\10.0\bin\pythonw.exe`;
- **macOS**: the Python bundled inside the app,
`/Applications/KiCad/KiCad.app/Contents/Frameworks/Python.framework/Versions/Current/bin/python3`;
- **Linux**: the first `python3` on `PATH` — needs Python ≥ 3.9
with the `venv` module (Debian/Ubuntu:
`sudo apt install python3-venv`).
3. **Deploy** (dev checkout; end users install the PCM zip instead, see 3. **Deploy** (dev checkout; end users install the PCM zip instead, see
*Packaging / publishing*): *Packaging / publishing*). Windows:
```powershell ```powershell
powershell -ExecutionPolicy Bypass -File deploy.ps1 # junction (dev) powershell -ExecutionPolicy Bypass -File deploy.ps1 # junction (dev)
powershell -ExecutionPolicy Bypass -File deploy.ps1 -Mode Copy powershell -ExecutionPolicy Bypass -File deploy.ps1 -Mode Copy
@@ -47,14 +61,49 @@ SWIG API. Requires KiCad **10.0.1+**.
python3 tools/deploy.py # symlink (dev) python3 tools/deploy.py # symlink (dev)
python3 tools/deploy.py --copy python3 tools/deploy.py --copy
``` ```
Plugin directory: `Documents/KiCad/10.0/plugins` on Windows and
macOS, `~/.local/share/kicad/10.0/plugins` on Linux.
4. **Restart KiCad**; first load builds the plugin venv (numpy, scipy, 4. **Restart KiCad**; first load builds the plugin venv (numpy, scipy,
matplotlib, PySide6 — takes minutes; the Ω button appears when done). matplotlib, PySide6 — takes minutes; the Ω button appears when done).
If stuck: in the PCB editor, Preferences → *PCB Editor → Action If stuck: in the PCB editor, Preferences → *PCB Editor → Action
Plugins*, **right-click** the plugin's row → *Recreate Plugin Plugins*, **right-click** the plugin's row → *Recreate Plugin
Environment* (context menu only — there is no button). Manual Environment* (context menu only — there is no button). Manual
equivalent: delete equivalent: delete the plugin's venv and restart KiCad —
`%LOCALAPPDATA%\kicad\10.0\python-environments\th.co.b4l.fill-resistance` - Windows: `%LOCALAPPDATA%\kicad\10.0\python-environments\th.co.b4l.fill-resistance`
and restart KiCad. - macOS: `~/Library/Caches/kicad/10.0/python-environments/th.co.b4l.fill-resistance`
- Linux: `~/.cache/kicad/10.0/python-environments/th.co.b4l.fill-resistance`
### Platform notes
- **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 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,
PySide6 6.9/6.10); the plugin code is kept 3.9-compatible (guarded
by a test) and the suite is also run against that older stack.
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
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
Jacobi-CG: same results, noticeably slower on large grids.
**NixOS**: **works** (field-tested on NixOS 26.05, Plasma 6). pip's
Linux wheels link against standard FHS library paths, which NixOS
does not provide — PySide6 fails with `libgthread-2.0.so.0: cannot
open shared object file`. The plugin cannot fix this from inside
its venv (KiCad installs wheels only); run KiCad inside an FHS
environment built with `buildFHSEnv`, and — on KDE Plasma — unset
`QT_PLUGIN_PATH`, which otherwise poisons the wheel's bundled Qt
with the system's Qt plugins. The tested wrapper (exact package
list incl. the non-obvious `zstd.out` and xcb-util family), a
`steam-run` quick test, and a debugging guide are in
[docs/NIXOS.md](docs/NIXOS.md).
## Usage ## Usage
@@ -86,7 +135,10 @@ SWIG API. Requires KiCad **10.0.1+**.
multi-layer pours at fine cell sizes may run for minutes (on our multi-layer pours at fine cell sizes may run for minutes (on our
test setup a typical real-board run finishes in ≈ 8 s). Then read test setup a typical real-board run finishes in ≈ 8 s). Then read
R / voltage drop / total power in the figure titles and status R / voltage drop / total power in the figure titles and status
bar. Outputs land in `<board dir>\fill_res_results\<timestamp>\`: bar. Outputs land in `<board dir>/fill_res_results/<timestamp>/`
(if the board directory is not writable — e.g. a demo project opened
straight from the mounted installer image — a temp directory is used
instead and its path printed to the Messages panel):
per-layer `1_raster_map` / `2_potential` / `3_current_density` / per-layer `1_raster_map` / `2_potential` / `3_current_density` /
`4_power_density` PNGs, `summary.txt` (incl. the busiest vias with `4_power_density` PNGs, `summary.txt` (incl. the busiest vias with
per-via current and dissipation, and the **current through each per-via current and dissipation, and the **current through each
@@ -292,9 +344,9 @@ accordingly more trustworthy than absolute numbers.
Every run writes `geometry_dump.json`; re-solve without KiCad: Every run writes `geometry_dump.json`; re-solve without KiCad:
```powershell ```sh
uv run python -m fill_resistance.standalone dump.json ` uv run python -m fill_resistance.standalone dump.json
[--current 40] [--cell-um 50] [--layers F.Cu,In1.Cu] [--no-show] ` [--current 40] [--cell-um 50] [--layers F.Cu,In1.Cu] [--no-show]
[--out DIR] [--force-iterative] [--out DIR] [--force-iterative]
``` ```
@@ -302,7 +354,7 @@ Dev environment, tests, headless extraction — [uv](https://docs.astral.sh/uv/)
manages the venv from `pyproject.toml`/`uv.lock` (`requirements.txt` manages the venv from `pyproject.toml`/`uv.lock` (`requirements.txt`
stays: KiCad builds the plugin's runtime venv from it): stays: KiCad builds the plugin's runtime venv from it):
```powershell ```sh
uv sync # one-time env setup uv sync # one-time env setup
uv run pytest -q # incl. exact analytic cases uv run pytest -q # incl. exact analytic cases
uv run python tools/api_probe.py # IPC API probe vs live KiCad uv run python tools/api_probe.py # IPC API probe vs live KiCad
@@ -332,7 +384,8 @@ GPL-3.0-or-later — see [LICENSE](LICENSE).
- **No toolbar button**: venv still building (wait), or build failed → - **No toolbar button**: venv still building (wait), or build failed →
*Recreate Plugin Environment* (right-click the plugin's row in *Recreate Plugin Environment* (right-click the plugin's row in
Preferences → *PCB Editor → Action Plugins*); check the interpreter Preferences → *PCB Editor → Action Plugins*); check the interpreter
path (setup 2). path (setup 2); on Linux make sure `python3-venv` is installed. Last
resort: delete the venv directory by hand (setup 4) and restart.
- **"Could not connect to KiCad's IPC API"**: API server not enabled, or - **"Could not connect to KiCad's IPC API"**: API server not enabled, or
KiCad not running (no headless mode in KiCad 10). KiCad not running (no headless mode in KiCad 10).
- **"KiCad is busy"**: a modal dialog is open in KiCad — close it, rerun. - **"KiCad is busy"**: a modal dialog is open in KiCad — close it, rerun.
+128
View File
@@ -0,0 +1,128 @@
# Running Fill Resistance on NixOS
KiCad builds the plugin a private venv from pip wheels
(`requirements.txt`). Those Linux wheels — PySide6 in particular —
`dlopen` system libraries at standard FHS paths (`/usr/lib`), which
NixOS does not provide. Nothing inside the venv can fix that; KiCad
must be **launched in an environment that supplies the libraries**.
This page gives a verified, declarative setup (field-tested on
NixOS 26.05, KiCad 10, Plasma 6 on Wayland) and maps each failure
mode to its cause, because the error messages are misleading.
## The three failure layers
You will hit these in order; each fix below removes one.
1. **`libgthread-2.0.so.0: cannot open shared object file`**
(shown in the plugin's own error figure) — no FHS environment at
all. PySide6's bundled Qt cannot load glib & friends.
2. **`qt.qpa.plugin: From 6.5.0, xcb-cursor0 or libxcb-cursor0 is
needed…` / `no Qt platform plugin could be initialized`** —
FHS present but incomplete. Beyond the obvious `xcb-util-cursor`,
the bundled Qt needs the whole **xcb-util family** (`icccm`,
`image`, `keysyms`, `render-util`, `util`) and **`libzstd`**
(a hard dependency of `libQt6Core`; note that `pkgs.zstd`'s
default output ships only the CLI — the library lives in
`zstd.out`). Qt prints the xcb-cursor hint for *any* missing
dependency of the platform plugin, so don't trust it literally.
3. **`Could not load the Qt platform plugin "wayland"/"xcb" in ""
even though it was found`** with all libraries present — the
desktop session (KDE Plasma does this) exports **`QT_PLUGIN_PATH`**
pointing at the system's Qt plugin directories. That variable takes
precedence over the wheel's bundled plugins, so the venv's PySide6
(its own Qt, e.g. 6.11.1) tries to load platform plugins built
against the system Qt (e.g. 6.11.0) — a private-ABI mismatch that
fails exactly like a missing library. The fix is to **unset
`QT_PLUGIN_PATH`** for KiCad and everything it spawns. KiCad
itself is wxWidgets/GTK and does not use the variable.
## Recommended setup: an FHS wrapper
Wrap KiCad in `buildFHSEnv` so a plain `kicad` launch carries
everything. Home-manager example (`home.nix`); for a system-wide
install put the same package in `environment.systemPackages` in
`configuration.nix` instead:
```nix
{ pkgs, ... }:
let
kicad-fhs = pkgs.buildFHSEnv {
name = "kicad";
targetPkgs = p: with p; [
kicad glib fontconfig freetype dbus libGL libxkbcommon
xcb-util-cursor wayland zlib zstd.out
xorg.libX11 xorg.libxcb xorg.libXext xorg.libXrender
xorg.libSM xorg.libICE xorg.libXrandr xorg.libXi
xorg.libXcursor xorg.libXfixes
# xcb-util family needed by PySide6's bundled xcb platform plugin
xorg.xcbutil xorg.xcbutilwm xorg.xcbutilimage
xorg.xcbutilkeysyms xorg.xcbutilrenderutil
];
# Plasma exports QT_PLUGIN_PATH (system Qt plugin dirs); the venv's
# bundled Qt chokes on those mismatched plugins. KiCad is wx/GTK,
# so dropping the variable is safe.
profile = "unset QT_PLUGIN_PATH";
runScript = "kicad";
};
in
{
home.packages = [ kicad-fhs /* replaces bare pkgs.kicad */ ];
# buildFHSEnv ships no .desktop file; restore the menu launcher.
xdg.desktopEntries.kicad = {
name = "KiCad";
exec = "kicad %F";
icon = "kicad";
categories = [ "Development" "Electronics" ];
mimeType = [ "application/x-kicad-project" ];
};
}
```
Rebuild, **fully quit any running KiCad** (a running instance keeps
its old environment), relaunch, click the Ω button. A one-line
`Could not load the Qt platform plugin "wayland"` warning may remain
if the wayland client stack is unhappy — it is harmless; Qt falls
back to xcb (XWayland) and the dialog appears.
## Quick test without a rebuild
`steam-run` provides a broad FHS that is one library short
(`libxcb-cursor`, required by Qt ≥ 6.5) and, on Plasma, still leaks
`QT_PLUGIN_PATH`:
```sh
XCBCUR=$(nix build --no-link --print-out-paths nixpkgs#xcb-util-cursor)
env -u QT_PLUGIN_PATH QT_QPA_PLATFORM=xcb \
LD_LIBRARY_PATH=$XCBCUR/lib steam-run kicad
```
(Fish: `set XCBCUR (nix build --no-link --print-out-paths
nixpkgs#xcb-util-cursor)`, then the same `env …` line.)
## Debugging further failures
If a new wheel version needs another library, reproduce the plugin's
Qt startup *outside* KiCad against the wrapper's library set — the
venv survives at
`~/.cache/kicad/10.0/python-environments/th.co.b4l.fill-resistance`:
```sh
ROOTFS=$(nix-store -qR "$(readlink -f "$(command -v kicad)")" \
| grep fhsenv-rootfs | head -1)
env -i HOME=$HOME DISPLAY=$DISPLAY XAUTHORITY=$XAUTHORITY \
LD_LIBRARY_PATH=$ROOTFS/usr/lib64 QT_DEBUG_PLUGINS=1 \
~/.cache/kicad/10.0/python-environments/th.co.b4l.fill-resistance/bin/python3 \
-c 'from PySide6.QtWidgets import QApplication; \
print(QApplication([]).platformName())'
```
A missing library shows up as a plain `ImportError: libfoo.so.N:
cannot open shared object file` — map the soname to its nixpkgs
attribute (`nix-locate libfoo.so.N`, from `nix-index`) and add it to
`targetPkgs`. If instead every library loads and only the platform
plugin fails, compare the environment of the *running* KiCad
(`tr '\0' '\n' < /proc/$(pgrep -x kicad)/environ`) for Qt variables
leaking in from the session — `QT_PLUGIN_PATH` above was found
exactly this way.
+42
View File
@@ -0,0 +1,42 @@
The plugin now works on macOS. Results are unchanged from 1.2.2 for
the same board and settings - nothing in the numerics was touched;
this release is platform fixes and per-OS documentation.
macOS (field-tested on KiCad 10):
- Fixed a crash on launch. KiCad's macOS builds bundle Python 3.9,
and one module's type annotations were evaluated at import there
("unsupported operand type(s) for |: 'type' and 'NoneType'"). The
plugin now runs on 3.9, and a test walks every shipped module so
the incompatibility cannot silently return.
- Fixed every figure - the error figure included - refusing to render
with "Cannot load backend 'TkAgg' ... as 'qt' is currently
running". macOS' bundled Python ships tkinter, so matplotlib
preferred Tk while the selection dialog had already made the
process a Qt one. Qt (PySide6, a hard dependency) is now always
the first choice on every platform.
- A board in a read-only location - such as the demo projects opened
straight from the mounted installer image - no longer kills the run
when the results directory cannot be created next to the board.
Results fall back to a temp directory and the path is printed to
the Messages panel.
- The test suite additionally runs against the stack a Mac plugin
environment actually resolves (Python 3.9, numpy 2.0, scipy 1.13,
matplotlib 3.9, PySide6 6.10) - 140 tests on both stacks.
Linux:
- On ARM64 (aarch64) the plugin environment could never build: pyamg
publishes no wheels for that platform, KiCad installs wheels only,
and one unresolvable requirement fails the whole environment.
pyamg is now skipped there and the solver falls back to Jacobi-CG -
same results, noticeably slower on large grids. Linux as a whole
remains untested; reports welcome.
Documentation:
- Setup now gives dedicated instructions per operating system: which
interpreter path to check, how to deploy, and where the plugin's
Python environment lives on Windows, macOS and Linux (for the
delete-and-restart recovery). A platform-notes section records what
is actually tested on each OS and what to expect there.
+3
View File
@@ -2,6 +2,9 @@
A future version may read overrides from <project>/fill_res_config.json. A future version may read overrides from <project>/fill_res_config.json.
""" """
from __future__ import annotations # KiCad's macOS Python is 3.9: without
# this, `float | None` annotations are
# evaluated at import and crash there
# --- Grid sizing --- # --- Grid sizing ---
# Benchmarked on the VOUT+ plane (147x59 mm): R changes < 0.3% from # Benchmarked on the VOUT+ plane (147x59 mm): R changes < 0.3% from
+15
View File
@@ -38,10 +38,25 @@ def _fail(message: str, outdir) -> None:
def main() -> None: def main() -> None:
outdir = None outdir = None
try:
try: try:
from kipy.errors import ApiError from kipy.errors import ApiError
from . import board_io, dialog from . import board_io, dialog
except ImportError as e:
if "cannot open shared object file" not in str(e):
raise
# pip's Linux wheels link against FHS system libraries;
# on NixOS those paths don't exist and PySide6/pynng die
# exactly like this. Nothing inside the venv can fix it.
raise UserFacingError(
f"A compiled dependency cannot load its system "
f"libraries: {e}\nThe plugin venv is built from pip "
f"wheels, which expect standard (FHS) library paths. "
f"On NixOS, run KiCad inside an FHS environment "
f"(buildFHSEnv wrapper, or steam-run for a quick "
f"test) - see docs/NIXOS.md in the plugin repo."
)
try: try:
kicad, board = board_io.connect() kicad, board = board_io.connect()
stackup = board_io.get_stackup_info(board) stackup = board_io.get_stackup_info(board)
+21 -10
View File
@@ -1,8 +1,9 @@
"""Figures: per-layer rasterized maps, potential, current density, power """Figures: per-layer rasterized maps, potential, current density, power
density, and the error figure. PNGs are saved BEFORE any window opens. density, and the error figure. PNGs are saved BEFORE any window opens.
Backend: interactive if a GUI toolkit exists (tkinter, else Qt), else Agg Backend: interactive if a GUI toolkit exists (Qt first, tkinter as a
with os.startfile on the saved PNGs so results are never silent. fallback), else Agg with the OS default viewer on the saved PNGs so
results are never silent.
""" """
from __future__ import annotations from __future__ import annotations
@@ -18,19 +19,29 @@ import numpy as np
def _pick_backend(): def _pick_backend():
"""matplotlib.use() is lazy and 'succeeds' for backends whose GUI """matplotlib.use() is lazy and 'succeeds' for backends whose GUI
toolkit is missing (KiCad's Python has no tkinter), so probe the toolkit is missing (KiCad's Windows Python has no tkinter), so probe
toolkits explicitly.""" the toolkits explicitly. Qt MUST come first: PySide6 is a hard
dependency and the selection dialog / progress window put a Qt event
loop in this process, after which matplotlib refuses TkAgg
("Cannot load backend 'TkAgg' ... as 'qt' is currently running") -
exactly what happened on macOS, whose bundled Python ships tkinter.
The probe must import the binding's native core, not just the
package: on NixOS `import PySide6` succeeds (pure __init__) while
QtCore's .so cannot find the system libraries pip wheels expect
("libgthread-2.0.so.0: cannot open shared object file") - promising
QtAgg then kills even the error figure at switch_backend time."""
for qt in ("PySide6", "PyQt6", "PyQt5", "PySide2"):
try:
__import__(qt + ".QtCore")
return "QtAgg" if qt in ("PySide6", "PyQt6") else "Qt5Agg"
except Exception:
continue
try: try:
import tkinter # noqa: F401 import tkinter # noqa: F401
return "TkAgg" return "TkAgg"
except Exception: except Exception:
pass pass
for qt in ("PySide6", "PyQt6", "PyQt5", "PySide2"):
try:
__import__(qt)
return "QtAgg" if qt in ("PySide6", "PyQt6") else "Qt5Agg"
except Exception:
continue
return None return None
+14 -1
View File
@@ -1,6 +1,7 @@
"""Output directory, summary.txt, geometry dump, stdout one-liner.""" """Output directory, summary.txt, geometry dump, stdout one-liner."""
from __future__ import annotations from __future__ import annotations
import tempfile
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@@ -14,7 +15,19 @@ from .solver import Result
def make_output_dir(board_dir: Path) -> Path: def make_output_dir(board_dir: Path) -> Path:
stamp = datetime.now().strftime("%Y%m%d-%H%M%S") stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
out = Path(board_dir) / config.OUTPUT_DIRNAME / stamp board_dir = Path(board_dir)
out = board_dir / config.OUTPUT_DIRNAME / stamp
try:
out.mkdir(parents=True, exist_ok=True)
except OSError as e:
# The board can live somewhere unwritable - e.g. the demos
# folder on the mounted KiCad installer image (read-only, and
# how the first macOS field test was run). Results still have
# to land somewhere the figures/summary can be written.
out = (Path(tempfile.gettempdir()) / config.OUTPUT_DIRNAME
/ f"{board_dir.name}-{stamp}")
print(f"board directory not writable ({e}); saving results to "
f"{out}")
out.mkdir(parents=True, exist_ok=True) out.mkdir(parents=True, exist_ok=True)
return out return out
+1 -1
View File
@@ -17,7 +17,7 @@
}, },
"versions": [ "versions": [
{ {
"version": "1.2.2", "version": "1.3.0",
"status": "stable", "status": "stable",
"kicad_version": "10.0", "kicad_version": "10.0",
"runtime": "ipc" "runtime": "ipc"
+2 -2
View File
@@ -3,7 +3,7 @@
# the dependency list there in sync with [project.dependencies]. # the dependency list there in sync with [project.dependencies].
[project] [project]
name = "fill-resistance" name = "fill-resistance"
version = "1.2.2" version = "1.3.0"
description = "DC resistance of copper zone fills and traces between two contacts (KiCad 10 plugin)" description = "DC resistance of copper zone fills and traces between two contacts (KiCad 10 plugin)"
license = "GPL-3.0-or-later" license = "GPL-3.0-or-later"
requires-python = ">=3.11" requires-python = ">=3.11"
@@ -11,7 +11,7 @@ dependencies = [
"kicad-python>=0.7.0", "kicad-python>=0.7.0",
"numpy", "numpy",
"scipy", "scipy",
"pyamg", "pyamg ; sys_platform != 'linux' or platform_machine != 'aarch64'",
"matplotlib", "matplotlib",
"PySide6", "PySide6",
] ]
+3 -1
View File
@@ -1,6 +1,8 @@
kicad-python>=0.7.0 kicad-python>=0.7.0
numpy numpy
scipy scipy
pyamg # no pyamg wheels for Linux aarch64, and KiCad installs wheels-only
# (--only-binary): skip it there, the solver falls back to Jacobi-CG
pyamg ; sys_platform != "linux" or platform_machine != "aarch64"
matplotlib matplotlib
PySide6 PySide6
+64
View File
@@ -0,0 +1,64 @@
"""Regressions from the first macOS field test.
Two failures that only a non-Windows KiCad could produce: the board
directory was read-only (demo project opened straight from the mounted
installer image), and matplotlib picked TkAgg - macOS' bundled Python
ships tkinter, unlike KiCad's Windows Python - then refused to create
any figure because the PySide6 dialog already had a Qt event loop in
the process.
"""
import pathlib
from fill_resistance import plots, report
def test_backend_prefers_qt_over_tk():
# The dev environment has both toolkits installed, so this asserts
# the preference order for real: Qt must win, because the selection
# dialog / progress window make the process a Qt process before the
# first figure exists.
assert plots._pick_backend() in ("QtAgg", "Qt5Agg")
def test_backend_probe_requires_working_qtcore(monkeypatch):
# NixOS: `import PySide6` succeeds (a pure-Python __init__) while
# QtCore's .so cannot load the FHS system libraries pip wheels
# expect. The probe must import the native core and fall through -
# promising QtAgg kills even the error figure at switch_backend
# time, and the failure report with it.
import builtins
real_import = builtins.__import__
def broken_qt(name, *args, **kwargs):
if name.split(".")[0] in ("PySide6", "PyQt6", "PyQt5", "PySide2"):
raise ImportError("libgthread-2.0.so.0: cannot open shared "
"object file: No such file or directory")
return real_import(name, *args, **kwargs)
monkeypatch.setattr(builtins, "__import__", broken_qt)
assert plots._pick_backend() in ("TkAgg", None)
def test_output_dir_falls_back_when_board_dir_unwritable(
tmp_path, monkeypatch, capsys):
board_dir = tmp_path / "board"
board_dir.mkdir()
real_mkdir = pathlib.Path.mkdir
def deny_under_board(self, *args, **kwargs):
if str(self).startswith(str(board_dir)):
raise OSError(30, "Read-only file system", str(self))
return real_mkdir(self, *args, **kwargs)
monkeypatch.setattr(pathlib.Path, "mkdir", deny_under_board)
out = report.make_output_dir(board_dir)
assert out.is_dir()
assert not str(out).startswith(str(board_dir))
assert board_dir.name in out.name # traceable back to the board
assert "not writable" in capsys.readouterr().out
def test_output_dir_normal_case_unchanged(tmp_path):
out = report.make_output_dir(tmp_path)
assert out.is_dir()
assert out.parent.parent == tmp_path
+51
View File
@@ -0,0 +1,51 @@
"""Python 3.9 compatibility tripwire.
KiCad's macOS builds bundle Python 3.9 and build the plugin venv with
it (README: Platform notes), while the dev environment runs a current
Python - so nothing else in the suite notices a construct that only
breaks on 3.9. The first real Mac run died at import: a module-level
`float | None` annotation in config.py, evaluated at runtime because
the file lacked the future import (PEP 604 unions need Python 3.10
unless annotations are deferred).
"""
import ast
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
SHIPPED = sorted((ROOT / "fill_resistance").glob("*.py"))
SHIPPED.append(ROOT / "fill_res_action.py")
def _has_future_annotations(tree: ast.Module) -> bool:
return any(isinstance(node, ast.ImportFrom)
and node.module == "__future__"
and any(alias.name == "annotations" for alias in node.names)
for node in tree.body)
def _uses_annotations(tree: ast.Module) -> bool:
for node in ast.walk(tree):
if isinstance(node, ast.AnnAssign):
return True
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
if node.returns is not None:
return True
a = node.args
args = (a.posonlyargs + a.args + a.kwonlyargs
+ ([a.vararg] if a.vararg else [])
+ ([a.kwarg] if a.kwarg else []))
if any(arg.annotation is not None for arg in args):
return True
return False
def test_annotated_modules_defer_annotations():
offenders = []
for path in SHIPPED:
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
if _uses_annotations(tree) and not _has_future_annotations(tree):
offenders.append(path.name)
assert not offenders, (
f"{offenders} use annotations without 'from __future__ import "
f"annotations': they are evaluated at import time and PEP 604 "
f"unions crash on KiCad's macOS Python 3.9.")
Generated
+3 -3
View File
@@ -216,14 +216,14 @@ wheels = [
[[package]] [[package]]
name = "fill-resistance" name = "fill-resistance"
version = "1.2.2" version = "1.3.0"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "kicad-python" }, { name = "kicad-python" },
{ name = "matplotlib" }, { name = "matplotlib" },
{ name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
{ name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
{ name = "pyamg" }, { name = "pyamg", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" },
{ name = "pyside6" }, { name = "pyside6" },
{ name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
{ name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
@@ -239,7 +239,7 @@ requires-dist = [
{ name = "kicad-python", specifier = ">=0.7.0" }, { name = "kicad-python", specifier = ">=0.7.0" },
{ name = "matplotlib" }, { name = "matplotlib" },
{ name = "numpy" }, { name = "numpy" },
{ name = "pyamg" }, { name = "pyamg", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" },
{ name = "pyside6" }, { name = "pyside6" },
{ name = "scipy" }, { name = "scipy" },
] ]