Compare commits
10
Commits
v1.2.2
..
dfba5561bb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dfba5561bb | ||
|
|
de7a4f8641 | ||
|
|
a4945f419c | ||
|
|
64676624e6 | ||
|
|
7604e18587 | ||
|
|
24fed64f83 | ||
|
|
23edb39f52 | ||
|
|
346016ba8f | ||
|
|
f9abc06082 | ||
|
|
3c90f96a63 |
@@ -0,0 +1,143 @@
|
||||
# Runs the full pytest suite (solver, raster, platform fallbacks)
|
||||
# against real pip wheels on every platform row advertised in the
|
||||
# README's "Platform support" table. What it cannot do is launch KiCad
|
||||
# itself — the plugin-inside-KiCad integration stays field-tested.
|
||||
#
|
||||
# Gitea/Forgejo Actions syntax is GitHub-Actions-compatible; if the
|
||||
# repo is ever mirrored to github.com, copy this file to
|
||||
# .github/workflows/ (GitHub does not read .gitea/). The
|
||||
# windows/macos and NixOS jobs need runners that only github.com
|
||||
# provides for free, so they are gated on the server URL instead of
|
||||
# queueing forever on a self-hosted instance; drop the `if:` lines if
|
||||
# you register your own runners with those labels.
|
||||
# Third-party actions pinned to commit SHAs, matching build-pcm.yml:
|
||||
# mutable tags could be repointed at malicious code.
|
||||
name: tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
UV_PYTHON: "3.12"
|
||||
# No test opens a window, but if one ever creates a figure the Qt
|
||||
# backend must not try to reach a display on a headless runner.
|
||||
QT_QPA_PLATFORM: offscreen
|
||||
|
||||
jobs:
|
||||
linux:
|
||||
name: ubuntu-latest · py${{ matrix.python }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python: ["3.11", "3.13"]
|
||||
env:
|
||||
UV_PYTHON: ${{ matrix.python }}
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- name: Qt runtime libraries (PySide6 wheels dlopen these)
|
||||
run: >
|
||||
sudo apt-get update -qq && sudo apt-get install -y -qq
|
||||
libglib2.0-0 libgl1 libegl1 libopengl0 libfontconfig1
|
||||
libfreetype6 libdbus-1-3 libxkbcommon0 libzstd1 libxcb1
|
||||
libx11-6 libxext6 libxrender1 libsm6 libice6
|
||||
- uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2
|
||||
- run: uv sync --frozen
|
||||
- run: uv run python -c "import PySide6.QtWidgets"
|
||||
- run: uv run pytest -q
|
||||
|
||||
distros:
|
||||
name: ${{ matrix.distro }}
|
||||
runs-on: ubuntu-latest
|
||||
container: ${{ matrix.distro }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- distro: debian:12
|
||||
setup: >
|
||||
apt-get update -qq && apt-get install -y -qq
|
||||
curl ca-certificates
|
||||
libglib2.0-0 libgl1 libegl1 libopengl0 libfontconfig1
|
||||
libfreetype6 libdbus-1-3 libxkbcommon0 libzstd1 libxcb1
|
||||
libx11-6 libxext6 libxrender1 libsm6 libice6
|
||||
- distro: ubuntu:24.04
|
||||
setup: >
|
||||
apt-get update -qq && apt-get install -y -qq
|
||||
curl ca-certificates
|
||||
libglib2.0-0 libgl1 libegl1 libopengl0 libfontconfig1
|
||||
libfreetype6 libdbus-1-3 libxkbcommon0 libzstd1 libxcb1
|
||||
libx11-6 libxext6 libxrender1 libsm6 libice6
|
||||
- distro: fedora:latest
|
||||
setup: >
|
||||
dnf install -y -q
|
||||
glib2 mesa-libGL mesa-libEGL libglvnd-opengl fontconfig
|
||||
freetype dbus-libs libxkbcommon libzstd libxcb libX11
|
||||
libXext libXrender libSM libICE
|
||||
- distro: archlinux:latest
|
||||
setup: >
|
||||
pacman -Syu --noconfirm --needed
|
||||
curl glib2 libglvnd fontconfig freetype2 dbus
|
||||
libxkbcommon zstd libxcb libx11 libxext libxrender
|
||||
libsm libice
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- name: Distro Qt runtime libraries
|
||||
run: ${{ matrix.setup }}
|
||||
- name: Install uv
|
||||
run: |
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
- run: uv sync --frozen
|
||||
- run: uv run python -c "import PySide6.QtWidgets"
|
||||
- run: uv run pytest -q
|
||||
|
||||
desktop:
|
||||
name: ${{ matrix.os }} · py${{ matrix.python }}
|
||||
# github.com only: no free Windows/macOS runners on self-hosted
|
||||
# Gitea/Forgejo. Skipped (not queued) elsewhere.
|
||||
if: github.server_url == 'https://github.com'
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [windows-latest, macos-latest]
|
||||
python: ["3.11", "3.13"]
|
||||
env:
|
||||
UV_PYTHON: ${{ matrix.python }}
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2
|
||||
- run: uv sync --frozen
|
||||
- run: uv run python -c "import PySide6.QtWidgets"
|
||||
- run: uv run pytest -q
|
||||
|
||||
nixos:
|
||||
name: NixOS (FHS wrapper from docs/NIXOS.md)
|
||||
# Needs unprivileged user namespaces for bubblewrap, which the
|
||||
# GitHub ubuntu VM allows but a docker-based act_runner does not.
|
||||
if: github.server_url == 'https://github.com'
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: DeterminateSystems/nix-installer-action@e50d5f73bfe71c2dd0aa4218de8f4afa59f8f81d # v16
|
||||
- name: Build the FHS environment (docs/NIXOS.md library set)
|
||||
run: nix-build tools/ci-fhs.nix -o ci-fhs
|
||||
- name: Run the suite inside the FHS env
|
||||
# shellcheck disable=SC2016 -- $HOME/$PATH expand inside the
|
||||
# FHS bash, not the runner shell; single quotes are the point.
|
||||
run: |
|
||||
# shellcheck disable=SC2016
|
||||
./ci-fhs/bin/fill-resistance-ci -c '
|
||||
set -e
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
export UV_PROJECT_ENVIRONMENT=/tmp/venv
|
||||
uv sync --frozen
|
||||
uv run python -c "import PySide6.QtWidgets"
|
||||
uv run pytest -q
|
||||
'
|
||||
@@ -28,16 +28,51 @@ happens around the notch on F.Cu.*
|
||||
Uses the KiCad **IPC API** (`kicad-python` / `kipy`), not the deprecated
|
||||
SWIG API. Requires KiCad **10.0.1+**.
|
||||
|
||||
## Platform support
|
||||
|
||||
[](https://git.b4l.co.th/B4L/kicad-zone-resistance/actions)
|
||||
|
||||
| Platform | Status | Verified by |
|
||||
|-----------------------------|:------:|-------------|
|
||||
| Windows | ✅ | development platform; CI test suite |
|
||||
| macOS | ✅ | field-tested in KiCad 10; CI test suite |
|
||||
| NixOS | ✅ | field-tested in KiCad 10 ([setup](docs/NIXOS.md)); CI runs the suite inside the documented FHS env |
|
||||
| Debian 12 | ✅ | CI test suite in container |
|
||||
| Ubuntu 24.04 | ✅ | CI test suite in container |
|
||||
| Fedora (latest) | ✅ | CI test suite in container |
|
||||
| Arch (latest) | ✅ | CI test suite in container |
|
||||
|
||||
CI (`.gitea/workflows/ci.yml`) runs the full pytest suite — solver,
|
||||
rasterizer, and the platform-fallback regressions — headless against
|
||||
the real pip wheels of each 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).
|
||||
|
||||
## 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 1–4 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
|
||||
*Enable KiCad API*.
|
||||
2. **Check the interpreter path** on the same page: should point at the
|
||||
KiCad 10 Python, e.g. `C:\Program Files\KiCad\10.0\bin\pythonw.exe`
|
||||
on Windows or `/usr/bin/python3` on Linux (after a 9→10 upgrade it
|
||||
can point at KiCad 9).
|
||||
2. **Check the interpreter path** on the same page (after a 9→10
|
||||
upgrade it can still point at KiCad 9):
|
||||
- **Windows**: KiCad's own Python,
|
||||
`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
|
||||
*Packaging / publishing*):
|
||||
*Packaging / publishing*). Windows:
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File deploy.ps1 # junction (dev)
|
||||
powershell -ExecutionPolicy Bypass -File deploy.ps1 -Mode Copy
|
||||
@@ -47,14 +82,49 @@ SWIG API. Requires KiCad **10.0.1+**.
|
||||
python3 tools/deploy.py # symlink (dev)
|
||||
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,
|
||||
matplotlib, PySide6 — takes minutes; the Ω button appears when done).
|
||||
If stuck: in the PCB editor, Preferences → *PCB Editor → Action
|
||||
Plugins*, **right-click** the plugin's row → *Recreate Plugin
|
||||
Environment* (context menu only — there is no button). Manual
|
||||
equivalent: delete
|
||||
`%LOCALAPPDATA%\kicad\10.0\python-environments\th.co.b4l.fill-resistance`
|
||||
and restart KiCad.
|
||||
equivalent: delete the plugin's venv and restart KiCad —
|
||||
- Windows: `%LOCALAPPDATA%\kicad\10.0\python-environments\th.co.b4l.fill-resistance`
|
||||
- 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
|
||||
|
||||
@@ -86,7 +156,10 @@ SWIG API. Requires KiCad **10.0.1+**.
|
||||
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
|
||||
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` /
|
||||
`4_power_density` PNGs, `summary.txt` (incl. the busiest vias with
|
||||
per-via current and dissipation, and the **current through each
|
||||
@@ -292,9 +365,9 @@ accordingly more trustworthy than absolute numbers.
|
||||
|
||||
Every run writes `geometry_dump.json`; re-solve without KiCad:
|
||||
|
||||
```powershell
|
||||
uv run python -m fill_resistance.standalone dump.json `
|
||||
[--current 40] [--cell-um 50] [--layers F.Cu,In1.Cu] [--no-show] `
|
||||
```sh
|
||||
uv run python -m fill_resistance.standalone dump.json
|
||||
[--current 40] [--cell-um 50] [--layers F.Cu,In1.Cu] [--no-show]
|
||||
[--out DIR] [--force-iterative]
|
||||
```
|
||||
|
||||
@@ -302,7 +375,7 @@ Dev environment, tests, headless extraction — [uv](https://docs.astral.sh/uv/)
|
||||
manages the venv from `pyproject.toml`/`uv.lock` (`requirements.txt`
|
||||
stays: KiCad builds the plugin's runtime venv from it):
|
||||
|
||||
```powershell
|
||||
```sh
|
||||
uv sync # one-time env setup
|
||||
uv run pytest -q # incl. exact analytic cases
|
||||
uv run python tools/api_probe.py # IPC API probe vs live KiCad
|
||||
@@ -332,7 +405,8 @@ GPL-3.0-or-later — see [LICENSE](LICENSE).
|
||||
- **No toolbar button**: venv still building (wait), or build failed →
|
||||
*Recreate Plugin Environment* (right-click the plugin's row in
|
||||
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
|
||||
KiCad not running (no headless mode in KiCad 10).
|
||||
- **"KiCad is busy"**: a modal dialog is open in KiCad — close it, rerun.
|
||||
|
||||
+128
@@ -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.
|
||||
@@ -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.
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
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 ---
|
||||
# Benchmarked on the VOUT+ plane (147x59 mm): R changes < 0.3% from
|
||||
|
||||
+17
-2
@@ -39,9 +39,24 @@ def _fail(message: str, outdir) -> None:
|
||||
def main() -> None:
|
||||
outdir = None
|
||||
try:
|
||||
from kipy.errors import ApiError
|
||||
try:
|
||||
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:
|
||||
kicad, board = board_io.connect()
|
||||
stackup = board_io.get_stackup_info(board)
|
||||
|
||||
+25
-10
@@ -1,8 +1,9 @@
|
||||
"""Figures: per-layer rasterized maps, potential, current density, power
|
||||
density, and the error figure. PNGs are saved BEFORE any window opens.
|
||||
|
||||
Backend: interactive if a GUI toolkit exists (tkinter, else Qt), else Agg
|
||||
with os.startfile on the saved PNGs so results are never silent.
|
||||
Backend: interactive if a GUI toolkit exists (Qt first, tkinter as a
|
||||
fallback), else Agg with the OS default viewer on the saved PNGs so
|
||||
results are never silent.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -18,19 +19,33 @@ import numpy as np
|
||||
|
||||
def _pick_backend():
|
||||
"""matplotlib.use() is lazy and 'succeeds' for backends whose GUI
|
||||
toolkit is missing (KiCad's Python has no tkinter), so probe the
|
||||
toolkits explicitly."""
|
||||
toolkit is missing (KiCad's Windows Python has no tkinter), so probe
|
||||
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 QtWidgets, not just the package or QtCore:
|
||||
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"), and on a
|
||||
partially provisioned system QtCore's deps (glib, icu) can be
|
||||
present while QtWidgets/QtGui still miss libGL/libEGL. matplotlib's
|
||||
qt backend imports QtCore, QtGui and QtWidgets, so probe the widest
|
||||
one - promising QtAgg then kills even the error figure at
|
||||
switch_backend time."""
|
||||
for qt in ("PySide6", "PyQt6", "PyQt5", "PySide2"):
|
||||
try:
|
||||
__import__(qt + ".QtWidgets")
|
||||
return "QtAgg" if qt in ("PySide6", "PyQt6") else "Qt5Agg"
|
||||
except Exception:
|
||||
continue
|
||||
try:
|
||||
import tkinter # noqa: F401
|
||||
return "TkAgg"
|
||||
except Exception:
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Output directory, summary.txt, geometry dump, stdout one-liner."""
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
@@ -14,8 +15,20 @@ from .solver import Result
|
||||
|
||||
def make_output_dir(board_dir: Path) -> Path:
|
||||
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
out = Path(board_dir) / config.OUTPUT_DIRNAME / stamp
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
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)
|
||||
return out
|
||||
|
||||
|
||||
@@ -55,7 +68,7 @@ def write_summary(outdir: Path, problem: Problem, stack: RasterStack,
|
||||
f"resistivity: {problem.rho_ohm_m:.3e} ohm*m",
|
||||
f"via plating: {problem.plating_nm / 1000:.0f} um",
|
||||
"",
|
||||
(f"frequency: "
|
||||
("frequency: "
|
||||
+ (f"{result.freq_hz:g} Hz (skin depth {result.skin_depth_um:.0f} um)"
|
||||
if result.freq_hz > 0 else "DC")),
|
||||
f"RESISTANCE: {result.R_ohm * 1000:.6g} mOhm"
|
||||
@@ -114,7 +127,7 @@ def write_summary(outdir: Path, problem: Problem, stack: RasterStack,
|
||||
f"contact model: {result.contact_model}"
|
||||
+ (" (uniform orthogonal injection; R is the upper contact bound)"
|
||||
if result.contact_model == "uniform" else " (ideal bonded lug)"),
|
||||
f"terminals:",
|
||||
"terminals:",
|
||||
f" V+ ({len(problem.electrodes1)} injection area(s)):",
|
||||
*(f" {_electrode_line(e)}" for e in problem.electrodes1),
|
||||
f" V- ({len(problem.electrodes2)} injection area(s)):",
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@
|
||||
},
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.2.2",
|
||||
"version": "1.3.0",
|
||||
"status": "stable",
|
||||
"kicad_version": "10.0",
|
||||
"runtime": "ipc"
|
||||
|
||||
+2
-2
@@ -3,7 +3,7 @@
|
||||
# the dependency list there in sync with [project.dependencies].
|
||||
[project]
|
||||
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)"
|
||||
license = "GPL-3.0-or-later"
|
||||
requires-python = ">=3.11"
|
||||
@@ -11,7 +11,7 @@ dependencies = [
|
||||
"kicad-python>=0.7.0",
|
||||
"numpy",
|
||||
"scipy",
|
||||
"pyamg",
|
||||
"pyamg ; sys_platform != 'linux' or platform_machine != 'aarch64'",
|
||||
"matplotlib",
|
||||
"PySide6",
|
||||
]
|
||||
|
||||
+3
-1
@@ -1,6 +1,8 @@
|
||||
kicad-python>=0.7.0
|
||||
numpy
|
||||
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
|
||||
PySide6
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"""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_qt_gui_stack(monkeypatch):
|
||||
# NixOS: `import PySide6` succeeds (a pure-Python __init__) while
|
||||
# the native .so's cannot load the FHS system libraries pip wheels
|
||||
# expect; on a partially provisioned system even QtCore loads
|
||||
# (glib, icu present) while QtWidgets/QtGui still miss libGL. The
|
||||
# probe must import QtWidgets and fall through - promising QtAgg
|
||||
# kills even the error figure at switch_backend time, and the
|
||||
# failure report with it. The mock mirrors that faithfully (bare
|
||||
# package and QtCore succeed, GUI modules fail) so a probe reverted
|
||||
# to `__import__(qt)` or `.QtCore` would wrongly return QtAgg here.
|
||||
import builtins
|
||||
import types
|
||||
real_import = builtins.__import__
|
||||
|
||||
def broken_qt(name, *args, **kwargs):
|
||||
root, _, sub = name.partition(".")
|
||||
if root in ("PySide6", "PyQt6", "PyQt5", "PySide2"):
|
||||
if sub in ("", "QtCore"):
|
||||
return types.ModuleType(name)
|
||||
raise ImportError("libGL.so.1: 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
|
||||
@@ -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.")
|
||||
@@ -0,0 +1,25 @@
|
||||
# CI-only: the FHS environment from docs/NIXOS.md minus KiCad itself.
|
||||
# The suite runs against pip wheels exactly as the plugin's venv does
|
||||
# inside the wrapped KiCad: PySide6 dlopens these libraries at FHS
|
||||
# paths. Keep this library list in sync with the buildFHSEnv recipe in
|
||||
# docs/NIXOS.md — CI failing here means the documented recipe broke.
|
||||
{ pkgs ? import <nixpkgs> { } }:
|
||||
|
||||
pkgs.buildFHSEnv {
|
||||
name = "fill-resistance-ci";
|
||||
targetPkgs = p: with p; [
|
||||
bashInteractive curl cacert
|
||||
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
|
||||
];
|
||||
# See docs/NIXOS.md failure layer 3: the desktop's QT_PLUGIN_PATH
|
||||
# must not leak into the wheel's bundled Qt.
|
||||
profile = "unset QT_PLUGIN_PATH";
|
||||
runScript = "bash";
|
||||
}
|
||||
@@ -216,14 +216,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "fill-resistance"
|
||||
version = "1.2.2"
|
||||
version = "1.3.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "kicad-python" },
|
||||
{ name = "matplotlib" },
|
||||
{ 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 = "pyamg" },
|
||||
{ name = "pyamg", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" },
|
||||
{ name = "pyside6" },
|
||||
{ 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'" },
|
||||
@@ -239,7 +239,7 @@ requires-dist = [
|
||||
{ name = "kicad-python", specifier = ">=0.7.0" },
|
||||
{ name = "matplotlib" },
|
||||
{ name = "numpy" },
|
||||
{ name = "pyamg" },
|
||||
{ name = "pyamg", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" },
|
||||
{ name = "pyside6" },
|
||||
{ name = "scipy" },
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user