5. GUI and packaging
5. GUI and packaging¶
| Tool | Licence | Note |
|---|---|---|
| PySide6 6.11.1 | LGPL-3 OR GPL-2 OR GPL-3 | Use this. LGPL option means no licence conflict |
| PyQt6 | GPL-3 only | Avoid unless the project is GPL |
PySide6.QtWebEngineWidgets.QWebEngineView |
— | Hosts NGSolve webgui [verified]1. The earlier "imports cleanly" held on a machine with the GL libraries present; see the caveat below, which is a property of the machine and not of the wheel |
| PyInstaller | — | Chosen for the bundle, one-dir (ADR-004, amended 20 September 2026). collect_all over ngsolve, netgen and ngsolve_openblas, plus the netgen-occt libraries collected by hand (below); upx=False, which mangles Qt WebEngine's helper executable |
| briefcase / conda-constructor | — | The other two candidates; not used |
PySide6 imports are a system-library question, not a wheel question [verified]1¶
Measured 20 September 2026, PySide6 6.11.2, in the Linux development container:
from PySide6 import QtWidgets -> ImportError: libEGL.so.1: cannot open shared object file
from PySide6 import QtWebEngineWidgets -> ImportError: libEGL.so.1: cannot open shared object file
QtWidgets itself, not only WebEngine. The wheel installs perfectly and import PySide6 succeeds;
a GL-dependent payload then dlopens and fails. This is the same failure class the gmsh wheel has
(OSError: libGLU.so.1, handled at tests/tier1/test_mesh_quality.py), which is why the skip idiom
for either is except (ImportError, OSError) and never pytest.importorskip.
Consequence for testing a GUI: anything that must run on the push gate has to be Qt-free. Widget
construction belongs on the windows-latest and macos-latest matrix jobs, where Qt's platform
plugin works unaided and QT_QPA_PLATFORM=offscreen is all that is needed. Installing system
packages on a Linux gate job to work around this buys Linux coverage at the price of a portability
promise per runner image.
A missing libEGL.so.1 can be shimmed for local work, never for the gate [verified]1¶
Superseded on 24 October 2026. The shim now fails at the Playwright Chromium bundle
/opt/pw-browsers/chromium-1243/chrome-linux64/ with undefined symbol: eglQueryString; see the next
section. The text below is kept as the record of 20 September 2026.
Measured 20 September 2026. The pre-installed Playwright Chromium carries a real
libEGL.so, and symlinking it as libEGL.so.1 (with libGLESv2.so as libGLESv2.so.2) into a
scratch directory on LD_LIBRARY_PATH makes from PySide6 import QtWidgets succeed, a
QApplication construct under QT_QPA_PLATFORM=offscreen, and the whole Qt widget suite run.
Useful for checking widget code that would otherwise ship having never been executed.
It is not a way to get Linux widget coverage on the push gate. It depends on a browser bundle
that has nothing to do with this project, and CON-07's posture is that nothing on the user path
needs a build or an install step. Widget coverage belongs on windows-latest and macos-latest,
where Qt's platform plugin works unaided.
Offscreen Qt runs with system libraries, and headless Playwright draws a host document [verified]5¶
Measured 24 October 2026 in the Linux development container. Installing the system libraries makes the Qt imports work without a shim:
apt-get install -y --no-install-recommends libegl1 libgl1 libxkbcommon0 libnss3 libxcomposite1 libxdamage1 libxrandr2 libxtst6 libxkbfile1 libasound2t64
With them, from PySide6 import QtWidgets, QtWebEngineWidgets imports, and the whole MainWindow
constructs and runs under QT_QPA_PLATFORM=offscreen with QTWEBENGINE_CHROMIUM_FLAGS="--disable-gpu --no-sandbox".
.claude/scripts/gui/capture_qt.py drove it through example 07 (a charge build and a charged solve) to
22 window grabs in about eight minutes.
Offscreen, a QWebEngineView loads the host document but gets no WebGL context. The scene's
initialisation throws TypeError: Cannot read properties of null (reading 'getExtension'), and the shell
shows its own "did not draw" diagnostic in place of the view. A picture of a web view therefore comes
from the HTML document the render child wrote, not from the grab.
Node Playwright 1.63.0 is installed globally for Node 22, at /opt/node22/lib/node_modules/playwright,
and is found through NODE_PATH=/usr/local/lib/node_modules_global. NODE_PATH serves require only,
not import, so a script that uses it is CommonJS (.cjs). It is not in the uv environment. Launched
with --enable-unsafe-swiftshader, its Chromium renders a host_document page (WebGL2: colour bar, mesh)
headless with no GPU. node .claude/scripts/gui/shoot.cjs --self-check <dir> checks it.
This is for local design work and the /np-gui render qt target, never for the push gate (CON-07).
QWebEngineView loads a webgui document with no GPU at all [verified]1¶
Measured 20 September 2026, PySide6 6.11.2, Linux, no display and no GPU, with
QT_QPA_PLATFORM=offscreen and QTWEBENGINE_CHROMIUM_FLAGS=--disable-gpu --no-sandbox. Qt logs
QRhiGles2: Failed to create temporary context
Failed to create RHI for backend: OpenGL
and then reaches loadFinished(True) anyway. So a headless packaging check can wait for the
document load rather than merely constructing the view.
What it does not establish is that the scene rendered. WebGLScene.GenerateHTML() emits a
document that fetches its renderer from https://cdn.jsdelivr.net/npm/webgui@<version>/dist/webgui.js,
so on a machine with no network the page loads and the console says webgui is not defined.
loadFinished is a statement about the document, not about the picture.
A webgui scene is JSON, costs ~200–320 B per element, and needs no Jupyter [tested: tests/tier1/test_library_behaviour.py::test_ngsolve_newton_and_webgui_api]¶
Measured 21 September 2026, P2 GridFunction on a unit-square mesh,
Draw(gf, mesh, order=k, show=False).GetData() then json.dumps:
| elements | order | GetData |
JSON | per element |
|---|---|---|---|---|
| 224 | 1 | 3.0 ms | 0.045 MB | 202 B |
| 224 | 2 | 2.3 ms | 0.075 MB | 334 B |
| 2,550 | 1 | 15.7 ms | 0.496 MB | 194 B |
| 2,550 | 2 | 25.4 ms | 0.825 MB | 324 B |
| 16,036 | 1 | 157.9 ms | 3.094 MB | 193 B |
| 16,036 | 2 | 142.6 ms | 5.153 MB | 321 B |
Linear in elements to within 5 %. Extrapolated to the 120,917-triangle reference mesh that is
23.3 MB at order 1 and 38.8 MB at order 2, built in roughly 1.2 s. A scene of a production mesh
is therefore a file, not a queue payload and not a setHtml argument — QWebEngineView.setHtml
percent-encodes into a data URL.
GetData() runs headlessly and without anywidget: netgen.webgui defines WebGuiWidget
inside a try: import anywidget and falls back to a stub with the same constructor
(netgen/webgui.py:423–435), so WebGLScene.__init__ succeeds with no Jupyter stack present.
The returned dict is plain numbers and base64 strings, so it crosses a process boundary and
serialises without help.
netgen.webgui.GenerateHTML ignores its template argument [verified]¶
Read from the installed source, 21 September 2026:
def GenerateHTML(data, filename=None, template=None):
if template is None:
template = _html_template
...
html = _html_template.replace('{render}', jscode)
template is assigned and never read; the substitution is always into the module global. So a
caller cannot redirect the renderer <script src=…> by passing a template, and anything that
needs a different renderer source has to build the host document itself. The document is four lines
plus the render data, so that is cheap — but it is not optional.
The renderer itself is npm webgui, "license": "LGPL-2.1-or-later" (npm registry metadata,
[verified]2). Its tarball for 0.2.39 is 503,052 B, and its published dist.integrity is
sha512-yDd6YR7E…Zubqw==. The 1,206,946 B figure npm reports is the unpacked tarball. The file a
page loads, package/dist/webgui.js, is 779,484 B: a minified IIFE that defines the global
webgui.
That build bundles two further works: three.js r152 (MIT, which keeps its @license banner)
and dat.gui 0.7 (Apache-2.0, which keeps no banner). The tarball also carries src/*.ts, the
shaders and the vite configuration, so it is the corresponding source of the LGPL part as it
stands. [verified], 22 September 2026.
cdn.jsdelivr.net answers this development container's proxy with 403, but
registry.npmjs.org does not. The tarball downloads here, and its SHA-512 matches the
published integrity exactly. That is how it is vendored: third_party/webgui-0.2.39.tgz, with
webgui.js shipped from it under nanopnp/gui/assets/webgui/.
Loaded from there as a file: URL by a host document that is itself a file: URL in another
directory, it reaches the page in Qt WebEngine 6.11.2. renderer_loaded is true offline, both in
the frozen probe bundle and in a plain QWebEngineView. With no GPU, new webgui.Scene() then
throws TypeError: Cannot read properties of null (reading 'getExtension'): WebGL has no
context, which is a property of the machine and not of the renderer.
A webgui scene draws on a material region, not only on a whole mesh [verified]¶
Measured 21 September 2026 on a restored four-material ePNP-NS solution, 124 triangles.
Draw(cf, mesh.Materials("electrolyte|cis|trans"), show=False, order=2).GetData() succeeds and
returns a smaller scene than the whole-mesh draw of the same field (30.5 kB against 43.1 kB here),
because it carries only the retained elements.
That matters for honesty rather than for size. A field declared on the fluid alone evaluates to
zero inside the membrane, and a whole-mesh draw of a concentration therefore paints a zero inside
a wall that a reader cannot distinguish from a converged depletion — which is the same trap
post/export.py documents for the IF-07 export, and it is avoided the same way.
The same call draws a two-component vector field without special handling: all five fields of
the epnp-ns family (the potential, two concentrations, the velocity and the pressure) produce a
scene from one code path, the vector one costing about 1.5x the scalars.
A fixed colour range needs the scene's keys and the viewer's own settings [verified]4¶
webgui 0.2.39 has one colour map per scene, and by default it autoscales to the sampled
funcmin/funcmax, which Draw(...).GetData() returns. A diverging map centred on zero, or χ on
[0, 1], needs that range fixed, and the scene keys alone do not fix it. funcmin, funcmax and
autoscale: false in the scene set the defaults. The viewer then reads gui_settings, maps its
autoscale, colormap_min and colormap_max onto its Colormap settings, and autoscales again
unless gui_settings.autoscale is false too. So gui/render.py writes all five. Read from the
shipped webgui.js and from the scenes the render child writes, 2 October 2026 (WP31 D8). The
fixed range has not been watched drawing in a browser here, because this container has no GPU for
WebGL.
QWebEngineView can be constructed in this container with the Playwright GL shim [verified]2¶
Measured 21 September 2026. With libEGL.so.1 and libGLESv2.so.2 symlinked from
/opt/pw-browsers/chromium-*/chrome-linux/ onto LD_LIBRARY_PATH, and
QT_QPA_PLATFORM=offscreen, both from PySide6 import QtWidgets and
from PySide6.QtWebEngineWidgets import QWebEngineView succeed and the whole Tier-1 widget suite
runs — including a QPainter repaint into a QPixmap and a QWebEngineView construction.
This is the §5 shim recorded above, used for the purpose that section already states: checking
widget code that would otherwise ship having never been executed. It stays off the push gate, where
widget coverage remains windows-latest and macos-latest.
SIGTERM stops a child busy inside netgen in milliseconds [verified]¶
Neither netgen nor NGSolve installs a SIGTERM handler, so the signal's default action ends a
spawn child at once even while it is inside compiled meshing code; only a Python-level handler
would defer it until the interpreter is re-entered. Measured: a child in GenerateMesh(maxh=0.002)
joined 14 ms after terminate(), exit code −15. RenderProcess.terminate's synchronous join
therefore does not stall the Qt thread, and it is what keeps two render children of one run from
sweeping each other's files. (Codebase review of 2026-09-28.)
A PyInstaller bundle carries neither OCCT nor OpenBLAS unless told to [verified]2¶
Measured 22 September 2026: PyInstaller 6.22.3, contrib hooks 2026.7, NGSolve and Netgen
6.2.2606, netgen-occt 7.8.1, ngsolve-openblas 0.3.33. The Windows build log and a Linux
rebuild of the same recipe in this container agree.
OCCT. import netgen runs netgen.load_occ_libs(), which does three things in order:
- calls
importlib.metadata.metadata("netgen-occt"); - builds a stem-to-path map from
metadata.files("netgen-occt"); ctypes.CDLLs 28TK*libraries in dependency order, starting withlib_paths["tkernel"].
The netgen-occt wheel installs those libraries outside site-packages, through its .data/data
scheme: bin/TK*.dll at the environment root on Windows, and lib/libTK*.so.7.8.1 on Linux. So
its RECORD names them ../../bin/TKernel.dll, relative to the dist-info's parent. A one-dir
bundle then fails twice, independently:
- PyInstaller does not find the libraries. The build log carries
Library not found: could not resolve 'TKernel.dll', dependency of ...\netgen\nglib.dllfor every one of them.collect_allcannot help, because they belong to no importable package. - PyInstaller does collect the dist-info. So
metadata()succeeds, and thePackageNotFoundErrorno-op branch is not taken. But since Python 3.12,Distribution.filesfilters its result throughskip_missing_files, and from_internal/every../../entry points outside the bundle. The map comes back empty, and the first lookup raisesKeyError: 'tkernel'. That is the exact failure of the gatedbundlejob, before any Qt is touched.
The fix makes netgen's own loader work unchanged (packaging/nanopnp-probe.spec):
- collect the existing
netgen-occtfiles that match its filter (*libTK*or*.dll) into the bundle root; - replace the collected dist-info with one whose RECORD names them by bare file name.
OpenBLAS. ngsolve/__init__.py imports ngsolve_openblas and preloads the libraries it
ships as package data. Static analysis collects the module but not the libraries, so the frozen
import ngsolve fails with libopenblas.so.0: cannot open shared object file.
collect_all("ngsolve_openblas") carries them. There is no macOS wheel, and ngsolve does not
import it there.
With both fixes, the probe bundle built here runs --selftest to completion on Linux under the §5
GL shim. The unresolved-library warnings that remain in the build log are libopenblas.so.0 and
the optional CUDA libraries of _ngscuda.so. The first is satisfied at run time by the preload;
the second is never loaded unless a CUDA solver is asked for.
The geometry payloads bundle through collect_all; Gmsh's library does not [tested: tests/tier1/test_gui_probe.py::test_rsk13_the_probe_names_the_payloads_it_must_carry]¶
Measured 29 September 2026 on a Linux rebuild of the probe recipe in this container (WP24 D16, D17):
PyInstaller 6.22.3, MDAnalysis 2.10.0, gemmi 0.7.5, scikit-image 0.26.0, Shapely 2.1.2 with GEOS
3.13.1, and Gmsh 4.15.2. The Windows bundle is the gated bundle job's to confirm; nothing here
was measured on Windows.
MDAnalysis, gemmi, scikit-image and Shapely need nothing but collect_all. It carries
Shapely's GEOS pair from shapely.libs/, and the frozen --selftest exercises all four. Their
footprint in _internal/ is:
| Payload | Size |
|---|---|
| MDAnalysis | 45 MB |
| scikit-image | 30 MB |
| gemmi | 6.8 MB |
| Shapely | 5.0 MB, plus 5.6 MB of GEOS |
Gmsh repeats the OCCT problem, differently. The wheel installs gmsh.py in site-packages and
libgmsh.so.4.15 in lib/ at the environment root; on Windows it is Lib/gmsh-4.15.dll.
gmsh.py does not use metadata to find it. Instead it tries a fixed list of paths relative to its
own __file__, and then ctypes.util.find_library. The first path is the module's own directory,
which for a PYZ module in a one-dir bundle is _internal/. So the library goes to the bundle root,
and Gmsh then loads unchanged. The library is 89 MB.
import gmsh succeeds without its library. With libgmsh.so.4.15 removed from the built
bundle, the import prints a warning listing the paths it searched and returns. The first call then
raises AttributeError: ...: undefined symbol: gmshInitialize. A probe that only imported Gmsh
would pass on a bundle that cannot mesh. This is why the probe exercises each payload once, and it
is RSK-13's failure in its plainest form.
The whole bundle is 1.3 GB, 501 MB of it PySide6, and builds in 74 s. The five exercises take
0.46 s together, unfrozen. One detail of the exercise: skimage.measure.find_contours needs an
ndarray and raises AttributeError: 'list' object has no attribute 'shape' on a nested list. The
probe therefore builds its 3 × 3 input with skimage.morphology.disk(1), so that it imports no
undeclared NumPy.
PDB2PQR and PROPKA bundle through collect_all, and fail inside PDB2PQR's run [verified]¶
Measured 2 October 2026 on a Linux rebuild of the probe recipe in this container (WP31 D14, D15),
with PyInstaller 6.22.3, PDB2PQR 3.7.1 and PROPKA 3.5.1. As with the geometry payloads, Windows
is the bundle job's to confirm.
Both are pure Python, and both find their data through Path(__file__).parent. For PDB2PQR
that is pdb2pqr/dat/, the force-field and naming files. For PROPKA it is propka.cfg and
protein_bonds.json. collect_all puts both trees beside the modules in _internal/. The frozen
--selftest then protonates GLU 18–LEU 26 of 2WCD chain A to +0 e at pH 2 and −3 e at pH 8,
the unfrozen values. The cost to the bundle is small:
| Payload | Size in _internal/ |
|---|---|
| PDB2PQR | 1.1 MB, of which dat/ is 0.50 MB |
| PROPKA | 0.34 MB, as 26 source files and its two data files |
| GridDataFormats | 0.12 MB, as source files |
The whole bundle is 1.23 GB and builds in 2 min 25 s from clean. The exercise takes 0.82 s unfrozen in a fresh process, and 0.23 s once warm.
A missing data file surfaces out of PDB2PQR's run. With propka.cfg removed from the built
bundle, the run raises FileNotFoundError naming the file. With pdb2pqr/dat/ removed, it raises
a bare AssertionError from pdb2pqr.io.test_for_file (assert dirpath_dat.is_dir()). Both come
out of pdb2pqr.main.run_pdb2pqr, so a probe that named the failing call would name PDB2PQR for
PROPKA's missing file. The probe names the library whose code is in the traceback instead. PDB2PQR
calls PROPKA and PROPKA never calls back, so a traceback that enters PROPKA's directory is PROPKA's
failure. Rebuilt and rerun, the bundle without propka.cfg fails naming propka.
module_collection_mode="py" puts the modules on disk. With it set for propka, MDAnalysis
and gridData, each package's modules are in _internal/ as .py files. MDAnalysis keeps its
compiled extensions beside them, at the same 45.7 MB. A traceback through them then carries real
file paths, which is what lets the probe tell PROPKA's failure from PDB2PQR's.
Without a titration method, the pH reaches nothing. With --with-ph and
--titration-state-method removed, PDB2PQR runs cleanly and gives equal charges at pH 2 and pH 8
(section 3). So the probe compares the two charges, and fails naming PROPKA when they are equal.
In this container the frozen --selftest stops at Gmsh, because libGLU.so.1 is not installed.
The figures above come from a local build whose selftest left Gmsh out. That edit was never
committed.
The geometry tab's view-models stay under the schema's cost [verified]3¶
Measured 29 September 2026, best of three fresh processes, warm cache:
| Module | Cost |
|---|---|
nanopnp.gui.geometry |
157 ms |
nanopnp.gui.assess |
52 ms |
nanopnp.gui.run_model |
200 ms, the comparison |
Neither of the two new modules brings in PySide6, NGSolve, Netgen, NumPy, MDAnalysis,
scikit-image, Shapely or Gmsh. nanopnp.gui.assess imports geometry.contour, and with it
scikit-image, only inside the spawned child. A drag on the editor canvas can be driven offscreen by
sending QMouseEvents through QApplication.sendEvent: press, move, release. That is how
test_gui_widgets.py asserts it.
nanopnp.io.case pulls in neither NGSolve nor NumPy, and costs 250–350 ms [tested: tests/tier1/test_library_behaviour.py::test_cli_deferred_imports_and_hashing_duck_typing]¶
Measured 20 September 2026 on the development interpreter (3.12). After import nanopnp.io.case,
"ngsolve" in sys.modules and "numpy" in sys.modules are both False. The cost is pydantic and
PyYAML building the nanopnp/case/v1 model tree, paid once.
So a caller can enumerate, display and validate an entire case document without the solver — which
is what lets a schema-generated editor stay near the CLI's 56 ms stage-introspection budget in
spirit, with NGSolve imported only in whatever process actually solves. io/case.py imports only
core at run time: the registry checks that need materials and physics live in
pipeline/checks.py. After import nanopnp.io.case the only nanopnp subpackages in
sys.modules are core and io [tested: tests/tier1/test_library_behaviour.py::test_cli_deferred_imports_and_hashing_duck_typing], 6 October 2026. import nanopnp.pipeline.case,
which holds the case loader and the resolver that Case.load and Session.run call, also loads charge, density, geometry, materials,
mesh, numerics and physics, and still leaves ngsolve and numpy out of sys.modules; that
holds because those modules defer their own NGSolve and NumPy imports.
Re-measured 20 September 2026 on an idle container: 349 ms cold (no __pycache__), 253 ms warm,
against the 622 ms first recorded the same day. Neither cache state reproduces 622 ms here, so that
figure was taken under load or by a different method. Treat the absolute number as
machine-dependent and the ratio as the fact: importing the case schema costs roughly what one
NGSolve import (~370 ms) costs, and the load-bearing half — neither ngsolve nor numpy in
sys.modules afterwards — is re-confirmed and is not a timing claim at all.
The desktop shell's view-model layer costs what the schema costs [tested: tests/tier1/test_gui_viewmodels.py::test_if09_the_view_models_import_no_qt_and_no_ngsolve]¶
Measured 20 September 2026, best of three on an idle container, warm cache:
| Module | Cost | What it pulls in |
|---|---|---|
nanopnp.io.case |
253 ms | pydantic and PyYAML building the nanopnp/case/v1 model tree |
nanopnp.gui.case_model |
251 ms | the above, and about a millisecond of its own |
nanopnp.gui.run_model |
273 ms | the above, plus io/manifest.py and pipeline/run.py |
nanopnp.gui.solver |
73 ms | core/errors.py and multiprocessing; everything else is deferred into the child |
nanopnp.gui.probe |
59 ms | core/paths.py only — every payload is deferred |
nanopnp.cli |
62 ms | the comparison, and the budget the deferred-import rule protects |
After importing all three view-models, none of PySide6, PyQt5, PyQt6, ngsolve, netgen
or numpy is in sys.modules — asserted in a subprocess by
tests/tier1/test_gui_viewmodels.py::test_if09_the_view_models_import_no_qt_and_no_ngsolve. The
editor therefore enumerates and validates the whole schema at the cost of the schema, and a shell
that is only browsing a case never pays for a solver or a toolkit.
-
docs/plans/wp14-gui-shell-packaging-probe.md(PySide6 GL system-library requirement and Playwright Chromium GL shim). ↩↩↩↩ -
docs/plans/wp15-live-convergence-and-viewer.md(ngsolve_webgui_js0.2.39 npm metadata,src/scene.ts, and PyInstaller OCCT/OpenBLAS bundling). ↩↩↩ -
docs/plans/wp24-gui-geometry-pipeline.md, D16–D17 (geometry tab view-model import timings). ↩ -
git show 0ff2d619:docs/plans/wp31-gui-charge-pipeline.md, D8 (scene colour limits andgui_settings.autoscale). ↩ -
docs/plans/wp5.01-workbench-design-study.md, Grounding D1 and D4 and item 1's Outcome (offscreen Qt and headless Playwright render checks). ↩