343 lines
14 KiB
Python
343 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
"""Pick a Qt platform that VTK can actually draw into, and say which and why.
|
|
|
|
VTK's Python wheels ship no Wayland window backend - the on-screen class is
|
|
`vtkXOpenGLRenderWindow`, and EGL and OSMesa are both offscreen. Left alone,
|
|
Qt6 picks Wayland whenever `WAYLAND_DISPLAY` is set while VTK goes on creating
|
|
an X window, and the two disagree about who owns the surface: the process dies
|
|
with `BadWindow (X_ConfigureWindow)` before anything is drawn.
|
|
|
|
There is a way out of that in principle - hand VTK a
|
|
`vtkGenericOpenGLRenderWindow` and let *Qt* own the GL context, which is what
|
|
VTK's C++ `QVTKOpenGLNativeWidget` does - but not from here. pyvistaqt's
|
|
interactor is the Python `QVTKRenderWindowInteractor`, and it paints from
|
|
`paintEvent`: it never implements `paintGL`, never binds Qt's framebuffer and
|
|
never makes a context current. A generic render window given to it draws into
|
|
no context at all - a black viewport, and a VTK that will not even read its
|
|
own buffer back ("render window is not current"). So VTK keeps a window of its
|
|
own, which is an X window, and a Wayland session is served through XWayland.
|
|
|
|
The order of preference is therefore Wayland, then X11, then offscreen, with
|
|
Wayland having to prove itself first - and the choice is announced once,
|
|
because a tool that silently moves you to XWayland is a tool that will be
|
|
blamed for the missing fractional scaling.
|
|
|
|
The other half of saying why: a Qt that will not import fails the Wayland test
|
|
frame the same way a bad GL stack does, and blaming the compositor for that is
|
|
how an afternoon disappears. So the binding is checked first, and a broken one
|
|
is named as broken rather than dressed up as a display problem.
|
|
|
|
Not all of that stack is packaged everywhere - pyvista and pyvistaqt usually
|
|
are not - so the tools keep their own virtualenv at `tools/.venv`, built with
|
|
`--system-site-packages` so the distribution's Qt is still the Qt in use. If
|
|
the interpreter a tool was started with cannot find the stack and that
|
|
virtualenv can, the tool is re-run there rather than failed with a recipe to
|
|
type out.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import ctypes
|
|
import ctypes.util
|
|
import importlib
|
|
import importlib.util
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import textwrap
|
|
|
|
# The tools' own virtualenv, the modules it exists to provide, and the flag
|
|
# that keeps a hop to it from happening twice.
|
|
_VENV = os.path.join(os.path.dirname(os.path.abspath(__file__)), '.venv')
|
|
_NO_VENV = 'MIAPI_TOOLS_NO_VENV'
|
|
_STACK = ('PySide6', 'vtkmodules', 'pyvista', 'pyvistaqt')
|
|
|
|
# The platform this process settled on, so a tool that imports another tool
|
|
# does not re-decide and re-announce it.
|
|
_chosen = None
|
|
|
|
|
|
def configure(prefer=None, quiet=False):
|
|
"""Set the Qt platform and VTK widget base. Call before importing Qt.
|
|
|
|
Returns the platform name chosen. `prefer` forces one; `QT_QPA_PLATFORM`
|
|
set in the environment wins over both, because someone who set it meant it.
|
|
"""
|
|
# Before asking which display server to draw on, ask whether there is
|
|
# anything to draw with. A stack that will not import fails every probe
|
|
# too, and a probe that fails is read as "this platform does not work" -
|
|
# which is how a broken install ends up wearing a Wayland costume.
|
|
ensure_stack(quiet)
|
|
|
|
global _chosen
|
|
if _chosen:
|
|
# A second caller - one tool importing another - is asking a question
|
|
# that has already been answered, and answering it twice on stderr
|
|
# reads as indecision.
|
|
return _chosen
|
|
|
|
chosen = os.environ.get('QT_QPA_PLATFORM')
|
|
note = ''
|
|
if not chosen:
|
|
chosen, note = prefer, ''
|
|
if not chosen:
|
|
chosen, note = _detect()
|
|
os.environ['QT_QPA_PLATFORM'] = chosen
|
|
origin = 'detected'
|
|
else:
|
|
origin = 'from QT_QPA_PLATFORM'
|
|
|
|
if not quiet:
|
|
print(f'display: {chosen} ({origin}){note}', file=sys.stderr)
|
|
if chosen == 'offscreen':
|
|
print('display: no Wayland or X11 session - rendering to files only',
|
|
file=sys.stderr)
|
|
_chosen = chosen
|
|
return chosen
|
|
|
|
|
|
def ensure_stack(quiet=False):
|
|
"""Import the drawing stack, or re-run in the interpreter that has it.
|
|
|
|
`configure` calls this; so may a tool that draws without asking for a
|
|
platform. Exits with something worth reading if neither interpreter can
|
|
import the stack.
|
|
"""
|
|
# qtpy - which pyvistaqt imports - reads QT_API to choose a binding, and
|
|
# left to its own order it finds PyQt6 first on a machine that has both.
|
|
# Two bindings in one process is a crash rather than a mismatch, so ours
|
|
# is named here, before anything goes looking.
|
|
os.environ.setdefault('QT_API', 'pyside6')
|
|
|
|
missing = [m for m in _STACK if importlib.util.find_spec(m) is None]
|
|
if missing:
|
|
_hop_to_venv(missing, quiet) # replaces this process, if it can
|
|
raise SystemExit(_stack_message(missing=missing))
|
|
|
|
broken = _import_stack()
|
|
if broken:
|
|
raise SystemExit(_stack_message(broken))
|
|
|
|
|
|
def _hop_to_venv(missing, quiet):
|
|
"""Re-run this tool in `tools/.venv`, if that is where the stack lives.
|
|
|
|
Only what is *absent* sends us there. A PySide6 that is installed but will
|
|
not load is a system problem, and the venv is deliberately built on the
|
|
system's Qt, so hopping would be asking the same question twice.
|
|
"""
|
|
python = os.path.join(_VENV, 'bin', 'python')
|
|
here = os.path.realpath(sys.prefix) == os.path.realpath(_VENV)
|
|
if here or os.environ.get(_NO_VENV) or not os.path.exists(python):
|
|
return
|
|
if not quiet:
|
|
print(f'python: no {", ".join(missing)} here - using {_VENV}',
|
|
file=sys.stderr)
|
|
os.environ[_NO_VENV] = '1' # one hop: let the venv fail in plain sight
|
|
try:
|
|
os.execv(python, [python, *sys.argv])
|
|
except OSError as exc:
|
|
del os.environ[_NO_VENV]
|
|
print(f'python: {python} would not start ({exc})', file=sys.stderr)
|
|
|
|
|
|
def _import_stack():
|
|
"""Import what has to be imported early, in the order that matters.
|
|
|
|
PySide6 comes first because `vtkmodules.qt` picks its binding from
|
|
whatever is already imported, and it makes that choice once. The widget
|
|
base class is left alone: `QVTKRWIBase = 'QOpenGLWidget'` is only sound
|
|
with a render window Qt drives, and nothing here drives one. Returns the
|
|
first failure, or None.
|
|
"""
|
|
for module in ('PySide6.QtCore', 'PySide6.QtWidgets', 'vtkmodules.qt'):
|
|
try:
|
|
importlib.import_module(module)
|
|
except ImportError as exc:
|
|
return f'{module}: {exc}'
|
|
return None
|
|
|
|
|
|
def _stack_message(err=None, missing=()):
|
|
"""Say what is actually broken, and what fixes it."""
|
|
if missing:
|
|
return '\n'.join([
|
|
'display: the drawing stack is incomplete, so nothing can be drawn.',
|
|
f' no module named {", ".join(missing)}',
|
|
'', *textwrap.wrap(
|
|
'pyvista and pyvistaqt are packaged by hardly any distribution, so '
|
|
"the tools keep a virtualenv of their own for them - built on the "
|
|
"system's Qt and VTK rather than over the top of them. The tools "
|
|
'use it by themselves once it is there:', 78),
|
|
f' python -m venv --system-site-packages {_VENV}',
|
|
f' {os.path.join(_VENV, "bin", "pip")} install pyvista pyvistaqt',
|
|
])
|
|
|
|
lines = ['display: the Qt stack will not import, so no window can be opened.',
|
|
f' {err}']
|
|
|
|
# The signature of a half-finished upgrade. PySide6 links Qt's *private*
|
|
# ABI, which is versioned build-for-build on purpose, so a PySide6 and a
|
|
# libQt6Core from two different releases will not load together even
|
|
# though their public API is identical. The symbol names the release
|
|
# PySide6 was compiled against; libQt6Core will tell us its own.
|
|
want = re.search(r'QtPrivate_(\d+)_(\d+)_(\d+)', err)
|
|
if 'undefined symbol' in err and (want or 'Qt_6_PRIVATE_API' in err):
|
|
built = '.'.join(want.groups()) if want else 'another release'
|
|
have = _qt_runtime_version()
|
|
found = f', but the Qt libraries here are {have}' if have else ''
|
|
lines += ['', *textwrap.wrap(
|
|
f'PySide6 was built against Qt {built}{found}. The two share a '
|
|
'private ABI and have to be upgraded together, so this is a partial '
|
|
'upgrade rather than a display problem.', 78),
|
|
' Arch: sudo pacman -Syu',
|
|
' (upgrading qt6-base alone leaves the rest of the system behind)',
|
|
]
|
|
return '\n'.join(lines)
|
|
|
|
|
|
def _qt_runtime_version():
|
|
"""Ask libQt6Core its version, without going through the broken binding."""
|
|
try:
|
|
path = ctypes.util.find_library('Qt6Core') or 'libQt6Core.so.6'
|
|
lib = ctypes.CDLL(path)
|
|
lib.qVersion.restype = ctypes.c_char_p
|
|
return lib.qVersion().decode()
|
|
except (OSError, AttributeError, UnicodeDecodeError):
|
|
return None
|
|
|
|
|
|
def _detect():
|
|
"""Choose a platform, and carry back the reason for anything given up."""
|
|
wayland = os.environ.get('WAYLAND_DISPLAY') and _socket_exists()
|
|
x11 = bool(os.environ.get('DISPLAY'))
|
|
why = ''
|
|
if wayland:
|
|
ok, why = _probe('wayland')
|
|
if ok:
|
|
return 'wayland', ''
|
|
if x11:
|
|
return 'xcb', f' - Wayland {why}' if why else ''
|
|
if wayland:
|
|
return 'wayland', (f' - Wayland {why}, and there is no X11 to fall'
|
|
' back to') if why else ''
|
|
return 'offscreen', ''
|
|
|
|
|
|
# The failure this guards against is not an exception. A Wayland session whose
|
|
# GL stack cannot make the context current - WSLg with a software Mesa is the
|
|
# common one - takes the whole process down with an X `BadAccess` on the first
|
|
# paint, after the window is already up. Nothing in-process can catch that, so
|
|
# the question gets asked in a process we can afford to lose.
|
|
_PROBE = """
|
|
import os, sys
|
|
from PySide6 import QtCore, QtWidgets
|
|
import pyvistaqt, pyvista as pv
|
|
# Everything above is toolchain, not display. Past this line - a window, a
|
|
# context, a frame - a failure really is the display's, and the caller may say
|
|
# so. The marker goes here rather than after the window is up because opening
|
|
# the window is itself one of the things that fails.
|
|
open(sys.argv[1] + '.started', 'w').write('ok')
|
|
app = QtWidgets.QApplication(['probe'])
|
|
win = QtWidgets.QMainWindow()
|
|
view = pyvistaqt.QtInteractor(win)
|
|
win.setCentralWidget(view)
|
|
win.resize(64, 64)
|
|
win.show()
|
|
def go():
|
|
# Exercise what the tools actually ask for. A bare cube survives GL stacks
|
|
# that fall over on a textured, depth-peeled scene, and a probe that passes
|
|
# where the app crashes is worse than no probe at all.
|
|
import numpy as np
|
|
view.enable_depth_peeling(number_of_peels=8, occlusion_ratio=0.0)
|
|
mesh = pv.Cube()
|
|
mesh.active_texture_coordinates = np.random.rand(mesh.n_points, 2).astype(np.float32)
|
|
tex = pv.Texture(np.random.randint(0, 255, (8, 8, 4), dtype=np.uint8))
|
|
tex.SetInterpolate(False)
|
|
view.add_mesh(mesh, texture=tex)
|
|
view.add_light(pv.Light(position=(1, 1, 1), light_type='scene light'))
|
|
view.render()
|
|
# Read the frame back out of VTK's own window. It is proof of two things
|
|
# at once: that the paint survived, and that something was drawn into a
|
|
# buffer VTK can find - the failure that leaves the viewport black does
|
|
# not raise, it just renders nowhere, and this is where that shows up.
|
|
view.screenshot(sys.argv[1] + '.png')
|
|
open(sys.argv[1], 'w').write('ok')
|
|
app.quit()
|
|
QtCore.QTimer.singleShot(0, go)
|
|
app.exec()
|
|
"""
|
|
|
|
|
|
def _probe(platform, timeout=25):
|
|
"""Does a real VTK viewport survive its first frame on this platform?
|
|
|
|
Returns `(ok, why)`, where `why` finishes the sentence "Wayland ...". The
|
|
reason matters: a probe that died before it ever opened a window says
|
|
nothing about the display, and reporting it as a failed frame sends the
|
|
next hour after the wrong bug.
|
|
"""
|
|
if os.environ.get('MIAPI_TOOLS_NO_PROBE'):
|
|
return False, 'was not tried (MIAPI_TOOLS_NO_PROBE)'
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
flag = os.path.join(tmp, 'flag')
|
|
env = dict(os.environ, QT_QPA_PLATFORM=platform)
|
|
env.pop('MIAPI_TOOLS_NO_PROBE', None)
|
|
try:
|
|
done = subprocess.run([sys.executable, '-c', _PROBE, flag], env=env,
|
|
timeout=timeout, stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.PIPE)
|
|
except subprocess.TimeoutExpired:
|
|
return False, f'did not finish a test frame within {timeout}s'
|
|
except OSError as exc:
|
|
return False, f'could not be tested ({exc})'
|
|
if os.path.exists(flag):
|
|
return True, ''
|
|
if os.path.exists(flag + '.started'):
|
|
return False, ('is present but VTK cannot draw there - '
|
|
+ _last_error(done.stderr))
|
|
return False, f'could not be tested - {_last_error(done.stderr)}'
|
|
|
|
|
|
def _last_error(stderr):
|
|
"""The one line worth repeating out of whatever the probe printed."""
|
|
lines = [ln.strip() for ln in stderr.decode('utf-8', 'replace').splitlines()
|
|
if ln.strip()]
|
|
|
|
# An X protocol error is not raised, it is printed - five lines of it, of
|
|
# which the first and the opcode are the ones that say anything. Xlib then
|
|
# takes the process down, so this is also the last word on what happened.
|
|
for i, line in enumerate(lines):
|
|
if line.startswith('X Error of failed request:'):
|
|
what = line.split(':', 1)[1].strip()
|
|
opcode = next((ln.split(':', 1)[1].strip() for ln in lines[i:]
|
|
if ln.startswith('Major opcode')), '')
|
|
where = f" on {opcode.split(None, 1)[-1].strip('()')}" if opcode else ''
|
|
return f'X {what}{where}'
|
|
|
|
for line in reversed(lines):
|
|
if not line.startswith(('File "', 'Traceback', '^', '~', '|')):
|
|
return line
|
|
return 'the probe failed with no output'
|
|
|
|
|
|
def _socket_exists():
|
|
"""A `WAYLAND_DISPLAY` with no socket behind it is a stale export."""
|
|
name = os.environ.get('WAYLAND_DISPLAY', '')
|
|
if os.path.isabs(name):
|
|
return os.path.exists(name)
|
|
runtime = os.environ.get('XDG_RUNTIME_DIR')
|
|
return bool(runtime) and os.path.exists(os.path.join(runtime, name))
|
|
|
|
|
|
def viewport(parent):
|
|
"""The 3D view the tools draw into. Import Qt first.
|
|
|
|
Deliberately plain: pyvistaqt is left to make its own render window, since
|
|
the only one it knows how to drive is the one it makes.
|
|
"""
|
|
import pyvistaqt
|
|
|
|
return pyvistaqt.QtInteractor(parent)
|