diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 2b04065..f416def 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -28,9 +28,9 @@ jobs:
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
- - name: Install system OpenGL/EGL libraries (Ubuntu)
+ - name: Install system OSMesa/OpenGL libraries (Ubuntu)
if: matrix.os == 'ubuntu'
- run: sudo apt-get install -y --no-install-recommends libegl1 libgl1
+ run: sudo apt-get install -y --no-install-recommends libosmesa6 libgl1
- name: Install dependencies
run: |
python -m pip install --progress-bar off --upgrade pip setuptools wheel
diff --git a/.github/workflows/code-style.yml b/.github/workflows/code-style.yml
index ecc3e00..69cb7af 100644
--- a/.github/workflows/code-style.yml
+++ b/.github/workflows/code-style.yml
@@ -20,7 +20,7 @@ jobs:
with:
python-version: '3.13'
cache: 'pip'
- - name: Install dependencies
+ - name: Install python dependencies
run: |
python -m pip install --progress-bar off --upgrade pip setuptools wheel
python -m pip install --progress-bar off .[style]
diff --git a/.github/workflows/doc.yml b/.github/workflows/doc.yml
index bf01390..88077c3 100644
--- a/.github/workflows/doc.yml
+++ b/.github/workflows/doc.yml
@@ -28,7 +28,7 @@ jobs:
- name: Install system dependencies
run: |
sudo apt-get update
- sudo apt-get -y install libgl1 libegl1 libxcb-cursor0 pandoc
+ sudo apt-get -y install libgl1 libosmesa6 libxcb-cursor0 pandoc
- name: Install package
run: |
python -m pip install --progress-bar off --upgrade pip setuptools wheel
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 2501a08..cfe8e9d 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -18,8 +18,8 @@ jobs:
with:
python-version: '3.13'
- name: Install system dependencies
- run: sudo apt-get install -y --no-install-recommends libegl1 libgl1
- - name: Install dependencies
+ run: sudo apt-get install -y --no-install-recommends libosmesa6 libgl1
+ - name: Install python dependencies
run: |
python -m pip install --progress-bar off --upgrade pip setuptools wheel
python -m pip install --progress-bar off .[build]
diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml
index 59f2311..f678530 100644
--- a/.github/workflows/pytest.yml
+++ b/.github/workflows/pytest.yml
@@ -32,9 +32,29 @@ jobs:
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
- - name: Install system OpenGL/EGL libraries (Ubuntu)
+ - name: Install system OSMesa library (Ubuntu)
if: matrix.os == 'ubuntu'
- run: sudo apt-get install -y --no-install-recommends libegl1 libgl1
+ run: sudo apt-get install -y --no-install-recommends libosmesa6 libgl1
+ - name: Install Mesa software OpenGL (Windows)
+ if: matrix.os == 'windows'
+ # mesa-dist-win (MSVC build) provides software OpenGL 3.3 Core via
+ # llvmpipe — compatible with Python's MSVC runtime.
+ # All x64 DLLs are extracted to C:\mesa and prepended to PATH so that
+ # Windows DLL search finds opengl32.dll and all its Mesa dependencies.
+ shell: pwsh
+ run: |
+ $url = "https://github.com/pal1000/mesa-dist-win/releases/download/24.3.4/mesa3d-24.3.4-release-msvc.7z"
+ $archive = "$env:TEMP\mesa.7z"
+ Invoke-WebRequest -Uri $url -OutFile $archive
+ New-Item -ItemType Directory -Force -Path "C:\mesa" | Out-Null
+ 7z e $archive -o"C:\mesa" "x64\*" -y
+ echo "C:\mesa" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
+ # Rendering strategy per platform:
+ # Ubuntu: no display → GLFW fails → OSMesa headless (libosmesa6)
+ # macOS: rendering tests SKIPPED — NSGL requires a real display
+ # connection even for invisible windows; CI runners have none
+ # Windows: Mesa opengl32.dll (MSVC build) on PATH → GLFW invisible
+ # window with Core Profile + FORWARD_COMPAT via llvmpipe
- name: Install package
run: |
python -m pip install --progress-bar off --upgrade pip setuptools wheel
diff --git a/DOCKER.md b/DOCKER.md
index fbdbc33..fe2fba4 100644
--- a/DOCKER.md
+++ b/DOCKER.md
@@ -1,7 +1,9 @@
# Docker Guide
-The Docker image provides a fully headless rendering environment with EGL
-off-screen support. No display server or `xvfb` is required.
+The Docker image provides a fully headless rendering environment using
+[OSMesa](https://docs.mesa3d.org/osmesa.html) (Mesa's off-screen software
+renderer). No display server, `xvfb`, or GPU is required — rendering runs
+entirely in software on the CPU.
The default entry point is `whippersnap4` (four-view batch rendering).
`whippersnap1` (single-view snapshot and rotation video) can be invoked by
@@ -167,4 +169,7 @@ parent directory to retrieve them on the host.
not root.
- The interactive GUI (`whippersnap`) is **not** available in the Docker image —
it requires a display server and PyQt6, which are not installed.
+- Headless rendering uses **OSMesa** (Mesa's CPU software renderer, provided by
+ the `libosmesa6` system package). No GPU, no `/dev/dri/` device, and no
+ `--privileged` flag are needed.
diff --git a/Dockerfile b/Dockerfile
index ea56592..1d6686d 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,7 +1,10 @@
FROM python:3.11-slim
+# libosmesa6 — OSMesa software renderer for headless OpenGL (no GPU/display needed)
+# libgl1 — base OpenGL shared library required by PyOpenGL
+# libglib2.0-0, libfontconfig1, libdbus-1-3 — runtime deps for Pillow / font rendering
RUN apt-get update && apt-get install -y --no-install-recommends \
- libegl1 \
+ libosmesa6 \
libgl1 \
libglib2.0-0 \
libfontconfig1 \
diff --git a/README.md b/README.md
index 0a7f81a..1b56854 100644
--- a/README.md
+++ b/README.md
@@ -5,8 +5,9 @@ with color overlays or parcellations and generate screenshots — from the
command line, in Jupyter notebooks, or via a desktop GUI.
It works with FreeSurfer and FastSurfer brain surfaces as well as any
-triangle mesh in OFF, legacy ASCII VTK PolyData, ASCII PLY, or GIfTI (.gii, .surf.gii) format, or
-passed directly as a NumPy ``(vertices, faces)`` tuple.
+triangle mesh in OFF, legacy ASCII VTK PolyData, ASCII PLY, or GIfTI
+(`.gii`, `.surf.gii`) format, or passed directly as a NumPy
+`(vertices, faces)` tuple.
## Installation
@@ -14,7 +15,7 @@ passed directly as a NumPy ``(vertices, faces)`` tuple.
pip install whippersnappy
```
-For rotation video support (MP4/WebM):
+For rotation video support (MP4/WebM — GIF works without this):
```bash
pip install 'whippersnappy[video]'
@@ -32,8 +33,11 @@ For interactive 3D in Jupyter notebooks:
pip install 'whippersnappy[notebook]'
```
-Off-screen (headless) rendering is supported natively via EGL on Linux — no
-`xvfb` required. See the Docker guide for headless usage.
+Off-screen (headless) rendering on **Linux** is supported natively via
+OSMesa — no `xvfb` or GPU required. On **macOS** and **Windows** a real
+display connection is needed (GLFW creates an invisible window backed by the
+system GPU driver). See the Docker guide for
+headless Linux usage.
## Command-Line Usage
@@ -64,31 +68,37 @@ whippersnap1 --mesh $SUBJECT_DIR/surf/lh.white \
--view left \
-o snap1.png
-# Also works with OFF / VTK / PLY
-whippersnap1 --mesh mesh.off --overlay values.mgh -o snap1.png
+# Also works with OFF / VTK / PLY / GIfTI
+whippersnap1 --mesh mesh.off --overlay values.txt -o snap1.png
whippersnap1 --mesh surface.surf.gii --overlay overlay.func.gii -o snap1.png
```
### Rotation video (`whippersnap1 --rotate`)
-Renders a 360° animation of any triangular surface mesh:
+Renders a 360° animation of any triangular surface mesh. GIF output uses
+pure PIL (no extra install); MP4/WebM requires `pip install 'whippersnappy[video]'`.
```bash
whippersnap1 --mesh $SUBJECT_DIR/surf/lh.white \
--overlay $LH_OVERLAY \
--rotate \
-o rotation.mp4
+
+whippersnap1 --mesh $SUBJECT_DIR/surf/lh.white \
+ --rotate \
+ -o rotation.gif
```
### Desktop GUI (`whippersnap`)
-Launches an interactive Qt window with live threshold controls.
+Launches an interactive Qt window with live threshold controls and
+mouse-driven rotation, pan, and zoom. Requires
+`pip install 'whippersnappy[gui]'`.
**General mode** — any triangular mesh:
```bash
-pip install 'whippersnappy[gui]'
-whippersnap --mesh mesh.off --overlay values.mgh
+whippersnap --mesh mesh.off --overlay values.txt
whippersnap --mesh lh.white --overlay lh.thickness --bg-map lh.curv
```
@@ -104,61 +114,83 @@ For all options run `whippersnap4 --help`, `whippersnap1 --help`, or `whippersna
## Python API
```python
-from whippersnappy import snap1, snap4, snap_rotate, plot3d
+from whippersnappy import snap1, snap4, snap_rotate, ViewType
+from whippersnappy import plot3d # requires whippersnappy[notebook]
```
-| Function | Description |
+| Function / Class | Description |
|---|---|
| `snap1` | Single-view snapshot of any triangular mesh → PIL Image |
| `snap4` | Four-view composed image (FreeSurfer subject, lateral/medial both hemispheres) |
| `snap_rotate` | 360° rotation video of any triangular surface mesh (MP4, WebM, or GIF) |
| `plot3d` | Interactive 3D WebGL viewer for Jupyter notebooks |
+| `ViewType` | Enum of camera presets used by `snap1` and `snap_rotate` |
+
+**`ViewType` values** — pass to the `view` parameter of `snap1` or the
+`start_view` parameter of `snap_rotate`:
+
+| Value | Description |
+|---|---|
+| `ViewType.LEFT` | Left lateral view *(default)* |
+| `ViewType.RIGHT` | Right lateral view |
+| `ViewType.FRONT` | Frontal / anterior view |
+| `ViewType.BACK` | Posterior view |
+| `ViewType.TOP` | Superior / dorsal view |
+| `ViewType.BOTTOM` | Inferior / ventral view |
**Supported mesh inputs for `snap1`, `snap_rotate`, and `plot3d`:**
-FreeSurfer binary surfaces (e.g. `lh.white`), OFF (`.off`), legacy ASCII VTK PolyData (`.vtk`), ASCII PLY (`.ply`), GIfTI surface (`.gii`, `.surf.gii`), or a `(vertices, faces)` NumPy array tuple.
+FreeSurfer binary surfaces (e.g. `lh.white`), OFF (`.off`), legacy ASCII
+VTK PolyData (`.vtk`), ASCII PLY (`.ply`), GIfTI surface
+(`.gii`, `.surf.gii`), or a `(vertices, faces)` NumPy array tuple.
**Supported overlay/label inputs:**
-FreeSurfer morph (`.curv`, `.thickness`), MGH/MGZ, ASCII (`.txt`, `.csv`), NumPy (`.npy`, `.npz`), GIfTI functional/label (`.func.gii`, `.label.gii`, `.gii`).
+FreeSurfer morph (`.curv`, `.thickness`), MGH/MGZ (`.mgh`, `.mgz`),
+plain text (`.txt`, `.csv`), NumPy (`.npy`, `.npz`),
+GIfTI functional/label (`.func.gii`, `.label.gii`).
-### Example
+### Examples
```python
-from whippersnappy import snap1, snap4
+from whippersnappy import snap1, snap4, ViewType
-# FreeSurfer surface with overlay
+# FreeSurfer surface with overlay — default left lateral view
img = snap1('lh.white',
overlay='lh.thickness',
bg_map='lh.curv',
roi='lh.cortex.label')
img.save('snap1.png')
+# Specific view
+img = snap1('lh.white', overlay='lh.thickness', view=ViewType.FRONT)
+img.save('snap1_front.png')
+
# Four-view overview (FreeSurfer subject directory)
-img = snap4(sdir='/path/to/subject',
- lh_overlay='/path/to/lh.thickness',
+img = snap4(lh_overlay='/path/to/lh.thickness',
rh_overlay='/path/to/rh.thickness',
- colorbar=True, caption='Cortical Thickness (mm)')
+ sdir='/path/to/subject',
+ colorbar=True,
+ caption='Cortical Thickness (mm)')
img.save('snap4.png')
# OFF / VTK / PLY / GIfTI mesh
-img = snap1('mesh.off', overlay='values.mgh')
+img = snap1('mesh.off', overlay='values.txt')
img = snap1('surface.surf.gii', overlay='overlay.func.gii')
# Array inputs (e.g. from LaPy or trimesh)
import numpy as np
-v = np.random.randn(1000, 3).astype(np.float32)
-f = np.array([[0, 1, 2]], dtype=np.uint32)
-overlay = np.random.randn(1000).astype(np.float32)
+v = np.array([[0,0,0],[1,0,0],[0,1,0],[0,0,1]], dtype=np.float32)
+f = np.array([[0,2,1],[0,1,3],[0,3,2],[1,2,3]], dtype=np.uint32)
+overlay = np.array([0.1, 0.5, 0.9, 0.3], dtype=np.float32)
img = snap1((v, f), overlay=overlay)
```
-
See `tutorials/whippersnappy_tutorial.ipynb` for complete notebook examples.
## Docker
-The Docker image provides a fully headless EGL rendering environment — no
-display server or `xvfb` required. See DOCKER.md for details.
+The Docker image provides a fully headless OSMesa rendering environment — no
+display server, `xvfb`, or GPU required. See DOCKER.md for details.
## API Documentation
diff --git a/doc/conf.py b/doc/conf.py
index de7fd73..73bcc56 100644
--- a/doc/conf.py
+++ b/doc/conf.py
@@ -152,7 +152,7 @@
# Whether to create a Sphinx table of contents for the lists of class methods and attributes.
# If a table of contents is made, Sphinx expects each entry to have a separate page. True by default.
#numpydoc_class_members_toctree = False
-#numpydoc_attributes_as_param_list = False
+numpydoc_attributes_as_param_list = False # dataclass fields → Attributes, not Parameters
numpydoc_show_class_members = False
# x-ref
@@ -194,6 +194,13 @@
# Imported third-party objects exposed in plot3d module
r"\.HTML$",
r"\.VBox$",
+ # stdlib dataclasses re-exported into cli module scope
+ r"\.dataclass$",
+ r"\.field$",
+ # GUI-only dataclass: fields are documented as Attributes; numpydoc also
+ # validates the auto-generated __init__ signature and raises PR01 because
+ # the same names are not repeated in a Parameters section.
+ r"\.ViewState$",
}
# -- sphinxcontrib-bibtex ----------------------------------------------------
diff --git a/tests/test_array_and_rendering.py b/tests/test_array_and_rendering.py
index b5c723d..ff1bc5d 100644
--- a/tests/test_array_and_rendering.py
+++ b/tests/test_array_and_rendering.py
@@ -5,6 +5,9 @@
triangle meshes.
"""
+import os
+import sys
+
import numpy as np
import pytest
@@ -21,6 +24,15 @@
prepare_geometry_from_arrays,
)
+# macOS CI runners (GitHub Actions) have no display connection.
+# NSGL (Apple's OpenGL layer) requires a real display even for invisible
+# windows — glfwCreateWindow always fails with "NSGL: Failed to find a
+# suitable pixel format" regardless of hints. Skip rendering tests there.
+_SKIP_RENDER_MACOS = pytest.mark.skipif(
+ sys.platform == "darwin" and "CI" in os.environ,
+ reason="macOS CI runners have no display; NSGL cannot create an OpenGL context.",
+)
+
# ---------------------------------------------------------------------------
# Minimal synthetic mesh (tetrahedron)
# ---------------------------------------------------------------------------
@@ -189,13 +201,19 @@ def test_invalid_mesh_type_raises(self):
def _snap1_offscreen(**kwargs):
"""Call snap1 with an invisible (offscreen) GLFW context.
- On macOS a visible GLFW window goes through the Cocoa compositor; the
- first glReadPixels call may return all-black before the compositor has
- finished its first composite pass. An invisible context renders
- directly to the driver framebuffer and reads back correctly.
+ Forces ``visible=False`` so that:
- Skips the test automatically if no OpenGL context can be created
- (headless CI without GPU or EGL support).
+ - On **Windows CI**, Mesa ``opengl32.dll`` is on ``PATH`` (installed by
+ the workflow), providing software OpenGL 3.3 Core via llvmpipe.
+ - On **Linux CI** (no display) GLFW fails entirely and
+ :func:`~whippersnappy.gl.utils.create_window_with_fallback` falls back
+ to OSMesa software rendering.
+ - On **macOS CI** the containing :class:`TestSnap1Rendering` is skipped
+ entirely via ``_SKIP_RENDER_MACOS`` — NSGL requires a real display
+ connection even for invisible windows, which CI runners don't provide.
+
+ The ``pytest.skip`` inside this function is a safety net for any other
+ environment where context creation fails unexpectedly.
"""
import whippersnappy.gl.utils as gl_utils # noqa: PLC0415
@@ -217,6 +235,7 @@ def _invisible(*args, **kw):
gl_utils.create_window_with_fallback = original
+@_SKIP_RENDER_MACOS
class TestSnap1Rendering:
"""End-to-end rendering tests: snap1 must return a non-empty PIL Image.
@@ -224,8 +243,18 @@ class TestSnap1Rendering:
visible from any camera direction, unlike a flat surface which can
appear edge-on and produce an all-black image.
- Tests use an offscreen GLFW context (see ``_snap1_offscreen``) and are
- skipped automatically when no OpenGL context is available.
+ Tests use an offscreen GLFW context (see :func:`_snap1_offscreen`) and
+ run on:
+
+ - **Ubuntu CI**: OSMesa headless rendering (``libosmesa6`` installed).
+ - **Windows CI**: GLFW invisible window backed by Mesa ``opengl32.dll``
+ (software OpenGL 3.3 Core via llvmpipe, on ``PATH`` from CI workflow).
+ - **macOS CI**: **skipped** — NSGL requires a real display connection
+ even for invisible windows; GitHub Actions runners have none.
+ - **local macOS**: runs if a display is connected (normal developer use).
+
+ The ``pytest.skip`` inside ``_snap1_offscreen`` is a safety net for any
+ other environment where context creation fails completely.
"""
def test_snap1_basic(self):
diff --git a/whippersnappy/__init__.py b/whippersnappy/__init__.py
index d656cbc..95df89f 100644
--- a/whippersnappy/__init__.py
+++ b/whippersnappy/__init__.py
@@ -7,12 +7,12 @@
- **3D plotting**: For Jupyter notebooks with mouse-controlled 3D (via Three.js)
- **GUI**: Interactive desktop viewer via the ``whippersnap`` command
- **CLI tools**: ``whippersnap1`` and ``whippersnap4`` for batch processing
-- **Local mesh IO**: OFF, VTK ASCII PolyData, and PLY in addition to FreeSurfer surfaces
+- **Local mesh IO**: OFF, VTK ASCII PolyData, PLY, and GIFTI in addition to
+ FreeSurfer surfaces
For static image generation::
- from whippersnappy import snap1, snap4
- from whippersnappy.utils.types import ViewType
+ from whippersnappy import snap1, snap4, ViewType
from IPython.display import display
img = snap1('path/to/lh.white', view=ViewType.LEFT)
@@ -40,42 +40,6 @@
"""
-import os
-import sys
-
-
-def _check_display():
- """Return True if a display is available or the platform handles GL natively.
-
- On macOS (CGL) and Windows (WGL) PyOpenGL does not use EGL, so we always
- return True to avoid setting ``PYOPENGL_PLATFORM=egl`` on those systems.
- On Linux we probe for an X11/Wayland display server.
- """
- if sys.platform != "linux":
- # macOS uses CGL, Windows uses WGL — no EGL needed on either.
- return True
- display = os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")
- if not display:
- return False
- try:
- import ctypes
- import ctypes.util
- libx11 = ctypes.CDLL(ctypes.util.find_library("X11") or "libX11.so.6")
- libx11.XOpenDisplay.restype = ctypes.c_void_p
- libx11.XOpenDisplay.argtypes = [ctypes.c_char_p]
- libx11.XCloseDisplay.restype = None
- libx11.XCloseDisplay.argtypes = [ctypes.c_void_p]
- dpy = libx11.XOpenDisplay(display.encode())
- if dpy:
- libx11.XCloseDisplay(dpy)
- return True
- return False
- except Exception:
- return False
-
-if "PYOPENGL_PLATFORM" not in os.environ:
- if not _check_display():
- os.environ["PYOPENGL_PLATFORM"] = "egl"
from ._config import sys_info # noqa: F401, E402
from ._version import __version__ # noqa: F401, E402
diff --git a/whippersnappy/cli/whippersnap.py b/whippersnappy/cli/whippersnap.py
index 9e9bd19..1a5c074 100644
--- a/whippersnappy/cli/whippersnap.py
+++ b/whippersnappy/cli/whippersnap.py
@@ -28,6 +28,7 @@
import logging
import os
import sys
+from dataclasses import dataclass, field
if __name__ == "__main__" and __package__ is None:
# Replace the current process with `python -m whippersnappy.cli.whippersnap`
@@ -36,6 +37,7 @@
os.execv(sys.executable, [sys.executable, "-m", "whippersnappy.cli.whippersnap"] + sys.argv[1:])
import glfw
+import numpy as np
import OpenGL.GL as gl
import pyrr
@@ -48,20 +50,148 @@
from .._version import __version__
from ..geometry import get_surf_name, prepare_geometry
from ..gl import (
- ViewState,
- arcball_rotation_matrix,
- arcball_vector,
capture_window,
- compute_view_matrix,
- get_view_matrices,
init_window,
setup_shader,
)
-from ..utils.types import ViewType
+from ..utils.types import ViewType, get_view_matrices
# Module logger
logger = logging.getLogger(__name__)
+
+# ---------------------------------------------------------------------------
+# ViewState — interactive GUI view parameters (GUI-only, not part of gl pkg)
+# ---------------------------------------------------------------------------
+
+@dataclass
+class ViewState:
+ """Mutable view parameters for the interactive render loop.
+
+ All mouse/keyboard interaction updates this object; the view matrix is
+ recomputed from it each frame via :func:`compute_view_matrix`.
+
+ Attributes
+ ----------
+ rotation : np.ndarray
+ 4×4 float32 rotation matrix (identity = no rotation applied).
+ pan : np.ndarray
+ (x, y) pan offset in normalised screen-space units.
+ zoom : float
+ Z-translation contribution to the transform matrix.
+ last_mouse_pos : np.ndarray or None
+ Last recorded mouse position in pixels; ``None`` when no button held.
+ left_button_down : bool
+ Whether the left mouse button is currently pressed.
+ right_button_down : bool
+ Whether the right mouse button is currently pressed.
+ middle_button_down : bool
+ Whether the middle mouse button is currently pressed.
+ """
+ rotation: np.ndarray = field(
+ default_factory=lambda: np.eye(4, dtype=np.float32)
+ )
+ pan: np.ndarray = field(
+ default_factory=lambda: np.zeros(2, dtype=np.float32)
+ )
+ zoom: float = 0.4
+ last_mouse_pos: np.ndarray | None = None
+ left_button_down: bool = False
+ right_button_down: bool = False
+ middle_button_down: bool = False
+
+
+def arcball_vector(x: float, y: float, width: int, height: int) -> np.ndarray:
+ """Map a 2-D screen pixel to a point on the unit arcball sphere.
+
+ Parameters
+ ----------
+ x, y : float
+ Mouse position in pixels.
+ width, height : int
+ Window dimensions in pixels.
+
+ Returns
+ -------
+ np.ndarray
+ Unit 3-vector on (or clamped to) the arcball sphere.
+ """
+ s = min(width, height)
+ p = np.array([
+ (2.0 * x - width) / s,
+ -(2.0 * y - height) / s,
+ 0.0,
+ ], dtype=np.float64)
+ sq = p[0] ** 2 + p[1] ** 2
+ if sq <= 1.0:
+ p[2] = np.sqrt(1.0 - sq)
+ else:
+ p /= np.sqrt(sq)
+ n = np.linalg.norm(p)
+ return p / n if n > 0 else p
+
+
+def arcball_rotation_matrix(v1: np.ndarray, v2: np.ndarray) -> np.ndarray:
+ """Return a 4×4 rotation matrix that rotates unit vector *v1* to *v2*.
+
+ Uses Rodrigues' rotation formula in pure numpy.
+ Returns identity when *v1* and *v2* are coincident.
+
+ Parameters
+ ----------
+ v1, v2 : np.ndarray
+ Unit 3-vectors on the arcball sphere.
+
+ Returns
+ -------
+ np.ndarray
+ 4×4 float32 rotation matrix.
+ """
+ axis = np.cross(v1, v2)
+ axis_len = np.linalg.norm(axis)
+ if axis_len < 1e-10:
+ return np.eye(4, dtype=np.float32)
+ axis = axis / axis_len
+ angle = np.arctan2(axis_len, np.dot(v1, v2))
+ c, s_a = np.cos(angle), np.sin(angle)
+ t = 1.0 - c
+ x, y, z = axis
+ r3 = np.array([
+ [t*x*x + c, t*x*y - s_a*z, t*x*z + s_a*y],
+ [t*x*y + s_a*z, t*y*y + c, t*y*z - s_a*x],
+ [t*x*z - s_a*y, t*y*z + s_a*x, t*z*z + c ],
+ ], dtype=np.float32)
+ r4 = np.eye(4, dtype=np.float32)
+ r4[:3, :3] = r3
+ return r4
+
+
+def compute_view_matrix(view_state: ViewState, base_view: np.ndarray) -> np.ndarray:
+ """Return the ``transform`` uniform for the current :class:`ViewState`.
+
+ Packs ``transl * rotation * base_view`` matching the snap_rotate convention.
+
+ Parameters
+ ----------
+ view_state : ViewState
+ Current interactive view state.
+ base_view : np.ndarray
+ Fixed 4×4 orientation preset from :func:`~whippersnappy.utils.types.get_view_matrices`.
+
+ Returns
+ -------
+ np.ndarray
+ 4×4 float32 matrix for the ``transform`` shader uniform.
+ """
+ transl = pyrr.Matrix44.from_translation((
+ view_state.pan[0],
+ view_state.pan[1],
+ 0.4 + view_state.zoom,
+ ))
+ rot = pyrr.Matrix44(view_state.rotation)
+ return np.array(transl * rot * pyrr.Matrix44(base_view), dtype=np.float32)
+
+
# Global thresholds shared between the GL render loop and the Qt config panel.
# All access is from the main thread — no locking needed.
current_fthresh_ = None
diff --git a/whippersnappy/gl/__init__.py b/whippersnappy/gl/__init__.py
index 4b458bf..e1f57e9 100644
--- a/whippersnappy/gl/__init__.py
+++ b/whippersnappy/gl/__init__.py
@@ -1,13 +1,15 @@
"""OpenGL helper utilities (gl package).
-This package replaces the previous `gl_utils.py` module.
-Functions are re-exported at package level for convenience, e.g.:
+This package contains the low-level OpenGL helpers used by the renderers.
+View preset matrices (:func:`~whippersnappy.utils.types.get_view_matrices`)
+live in :mod:`whippersnappy.utils.types` alongside :class:`~whippersnappy.utils.types.ViewType`.
+
+Functions are re-exported at package level for convenience::
from whippersnappy.gl import init_window, setup_shader
"""
-from . import _platform # noqa: F401 — MUST be first; sets PYOPENGL_PLATFORM
from .camera import make_model, make_projection, make_view
from .shaders import get_default_shaders, get_webgl_shaders
from .utils import (
@@ -25,22 +27,12 @@
setup_vertex_attributes,
terminate_context,
)
-from .views import (
- ViewState,
- arcball_rotation_matrix,
- arcball_vector,
- compute_view_matrix,
- get_view_matrices,
- get_view_matrix,
-)
__all__ = [
'create_vao', 'compile_shader_program', 'setup_buffers', 'setup_vertex_attributes',
'set_default_gl_state', 'set_camera_uniforms', 'set_lighting_uniforms',
'init_window', 'render_scene', 'setup_shader', 'capture_window',
+ 'create_window_with_fallback', 'terminate_context',
'make_model', 'make_projection', 'make_view',
- 'get_default_shaders', 'get_view_matrices', 'get_view_matrix',
- 'get_webgl_shaders', 'terminate_context',
- 'ViewState', 'compute_view_matrix',
- 'arcball_vector', 'arcball_rotation_matrix',
+ 'get_default_shaders', 'get_webgl_shaders',
]
diff --git a/whippersnappy/gl/_platform.py b/whippersnappy/gl/_platform.py
deleted file mode 100644
index 8a94ad4..0000000
--- a/whippersnappy/gl/_platform.py
+++ /dev/null
@@ -1,17 +0,0 @@
-"""Bootstrap PyOpenGL platform selection — must be imported first.
-
-Imported unconditionally at the top of gl/__init__.py before any other
-OpenGL symbol. Sets PYOPENGL_PLATFORM=egl when running headless on Linux
-so that PyOpenGL uses the EGL backend instead of GLX.
-
-On macOS PyOpenGL uses CGL and on Windows it uses WGL — both are handled
-natively without EGL. If the user has already set PYOPENGL_PLATFORM that
-value is always respected.
-"""
-import os
-import sys
-
-if "PYOPENGL_PLATFORM" not in os.environ and sys.platform == "linux":
- # No X11/Wayland display on Linux → force EGL headless backend.
- if not os.environ.get("DISPLAY") and not os.environ.get("WAYLAND_DISPLAY"):
- os.environ["PYOPENGL_PLATFORM"] = "egl"
diff --git a/whippersnappy/gl/egl_context.py b/whippersnappy/gl/egl_context.py
deleted file mode 100644
index 950dffa..0000000
--- a/whippersnappy/gl/egl_context.py
+++ /dev/null
@@ -1,374 +0,0 @@
-"""EGL off-screen (headless) OpenGL context via pbuffer + FBO.
-
-This module provides a drop-in alternative to GLFW window creation for
-headless environments (CI, Docker, HPC clusters) where no X11/Wayland
-display is available. It requires:
-
- - A system EGL library (``libegl1`` on Debian/Ubuntu, already present
- in the WhipperSnapPy Dockerfile).
- - PyOpenGL >= 3.1 (already a project dependency), which ships
- ``OpenGL.EGL`` bindings.
- - Either an NVIDIA GPU with the EGL driver, or Mesa ``libEGL-mesa0``
- (llvmpipe software renderer) for CPU-only systems.
-
-Typical usage (internal, called from ``create_window_with_fallback``)::
-
- from whippersnappy.gl.egl_context import EGLContext
-
- ctx = EGLContext(width, height)
- ctx.make_current()
- # ... OpenGL calls ...
- img = ctx.read_pixels()
- ctx.destroy()
-"""
-
-import ctypes
-import logging
-import os
-import sys
-
-if sys.platform == "darwin":
- raise ImportError("EGL is not available on macOS; use GLFW/CGL instead.")
-
-# Must be set before OpenGL.GL is imported anywhere in the process.
-# If already set (e.g. user set it, or GLFW succeeded), respect it.
-# We set it here because this module is only imported when EGL is needed.
-if os.environ.get("PYOPENGL_PLATFORM") != "egl":
- os.environ["PYOPENGL_PLATFORM"] = "egl"
-
-import OpenGL.GL as gl
-from PIL import Image
-
-logger = logging.getLogger(__name__)
-
-# ---------------------------------------------------------------------------
-# EGL constants not exposed by all PyOpenGL versions
-# ---------------------------------------------------------------------------
-_EGL_SURFACE_TYPE = 0x3033
-_EGL_PBUFFER_BIT = 0x0001
-_EGL_RENDERABLE_TYPE = 0x3040
-_EGL_OPENGL_BIT = 0x0008
-_EGL_NONE = 0x3038
-_EGL_WIDTH = 0x3057
-_EGL_HEIGHT = 0x3056
-_EGL_OPENGL_API = 0x30A2
-_EGL_CONTEXT_MAJOR_VERSION = 0x3098
-_EGL_CONTEXT_MINOR_VERSION = 0x30FB
-_EGL_PLATFORM_DEVICE_EXT = 0x313F
-
-
-class EGLContext:
- """A headless OpenGL 3.3 Core context backed by an EGL pbuffer + FBO.
-
- The pbuffer surface is created solely to satisfy EGL's requirement for
- a surface when calling ``eglMakeCurrent``. All rendering is directed
- into an off-screen Framebuffer Object (FBO) so that ``glReadPixels``
- captures exactly what was rendered regardless of platform quirks with
- pbuffer readback.
-
- Parameters
- ----------
- width, height : int
- Dimensions of the off-screen render target in pixels.
-
- Attributes
- ----------
- width, height : int
- Render target dimensions.
- fbo : int
- OpenGL FBO handle (valid after ``make_current`` is called).
-
- Raises
- ------
- ImportError
- If ``OpenGL.EGL`` bindings are not available.
- RuntimeError
- If any EGL initialisation step fails.
- """
-
- def __init__(self, width: int, height: int):
- self.width = width
- self.height = height
- self._libegl = None
- self._display = None
- self._surface = None
- self._context = None
- self._config = None
- self.fbo = None
- self._rbo_color = None
- self._rbo_depth = None
- self._init_egl()
-
- # ------------------------------------------------------------------
- # Private helpers
- # ------------------------------------------------------------------
-
- def _get_ext_fn(self, name, restype, argtypes):
- """Load an EGL extension function via eglGetProcAddress."""
- addr = self._libegl.eglGetProcAddress(name.encode())
- if not addr:
- raise RuntimeError(
- f"eglGetProcAddress('{name}') returned NULL — "
- f"extension not available on this driver."
- )
- FuncType = ctypes.CFUNCTYPE(restype, *argtypes)
- return FuncType(addr)
-
- def _init_egl(self):
- import ctypes.util
-
- egl_name = ctypes.util.find_library("EGL") or "libEGL.so.1"
- try:
- libegl = ctypes.CDLL(egl_name)
- except OSError as e:
- raise RuntimeError(
- f"Could not load {egl_name}. "
- "Install libegl1-mesa and retry."
- ) from e
- self._libegl = libegl # keep reference alive
-
- # Set signatures for direct (non-extension) EGL symbols
- libegl.eglGetProcAddress.restype = ctypes.c_void_p
- libegl.eglGetProcAddress.argtypes = [ctypes.c_char_p]
- libegl.eglQueryString.restype = ctypes.c_char_p
- libegl.eglQueryString.argtypes = [ctypes.c_void_p, ctypes.c_int]
- libegl.eglInitialize.restype = ctypes.c_bool
- libegl.eglInitialize.argtypes = [ctypes.c_void_p,
- ctypes.POINTER(ctypes.c_int),
- ctypes.POINTER(ctypes.c_int)]
- libegl.eglBindAPI.restype = ctypes.c_bool
- libegl.eglBindAPI.argtypes = [ctypes.c_uint]
- libegl.eglChooseConfig.restype = ctypes.c_bool
- libegl.eglChooseConfig.argtypes = [ctypes.c_void_p,
- ctypes.POINTER(ctypes.c_int),
- ctypes.c_void_p, ctypes.c_int,
- ctypes.POINTER(ctypes.c_int)]
- libegl.eglCreatePbufferSurface.restype = ctypes.c_void_p
- libegl.eglCreatePbufferSurface.argtypes = [ctypes.c_void_p, ctypes.c_void_p,
- ctypes.POINTER(ctypes.c_int)]
- libegl.eglCreateContext.restype = ctypes.c_void_p
- libegl.eglCreateContext.argtypes = [ctypes.c_void_p, ctypes.c_void_p,
- ctypes.c_void_p,
- ctypes.POINTER(ctypes.c_int)]
- libegl.eglMakeCurrent.restype = ctypes.c_bool
- libegl.eglMakeCurrent.argtypes = [ctypes.c_void_p, ctypes.c_void_p,
- ctypes.c_void_p, ctypes.c_void_p]
- libegl.eglDestroyContext.restype = ctypes.c_bool
- libegl.eglDestroyContext.argtypes = [ctypes.c_void_p, ctypes.c_void_p]
- libegl.eglDestroySurface.restype = ctypes.c_bool
- libegl.eglDestroySurface.argtypes = [ctypes.c_void_p, ctypes.c_void_p]
- libegl.eglTerminate.restype = ctypes.c_bool
- libegl.eglTerminate.argtypes = [ctypes.c_void_p]
-
- # Check extensions and load ext functions via eglGetProcAddress
- _EGL_EXTENSIONS = 0x3055
- client_exts = libegl.eglQueryString(None, _EGL_EXTENSIONS) or b""
- logger.debug("EGL client extensions: %s", client_exts.decode())
-
- has_device_enum = b"EGL_EXT_device_enumeration" in client_exts
- has_platform_base = b"EGL_EXT_platform_base" in client_exts
-
- display = None
- if has_device_enum and has_platform_base:
- eglQueryDevicesEXT = self._get_ext_fn(
- "eglQueryDevicesEXT",
- ctypes.c_bool,
- [ctypes.c_int, ctypes.c_void_p, ctypes.POINTER(ctypes.c_int)],
- )
- eglGetPlatformDisplayEXT = self._get_ext_fn(
- "eglGetPlatformDisplayEXT",
- ctypes.c_void_p,
- [ctypes.c_int, ctypes.c_void_p, ctypes.POINTER(ctypes.c_int)],
- )
- display = self._open_device_display(
- eglQueryDevicesEXT, eglGetPlatformDisplayEXT
- )
-
- if display is None:
- logger.debug("Falling back to eglGetDisplay(EGL_DEFAULT_DISPLAY)")
- libegl.eglGetDisplay.restype = ctypes.c_void_p
- libegl.eglGetDisplay.argtypes = [ctypes.c_void_p]
- display = libegl.eglGetDisplay(ctypes.c_void_p(0))
-
- if not display:
- raise RuntimeError(
- "Could not obtain any EGL display. "
- "Install libegl1-mesa for CPU rendering."
- )
- self._display = display
-
- major, minor = ctypes.c_int(0), ctypes.c_int(0)
- if not libegl.eglInitialize(
- self._display, ctypes.byref(major), ctypes.byref(minor)
- ):
- raise RuntimeError("eglInitialize failed.")
- logger.debug("EGL %d.%d", major.value, minor.value)
-
- if not libegl.eglBindAPI(_EGL_OPENGL_API):
- raise RuntimeError("eglBindAPI(OpenGL) failed.")
-
- cfg_attribs = (ctypes.c_int * 7)(
- _EGL_SURFACE_TYPE, _EGL_PBUFFER_BIT,
- _EGL_RENDERABLE_TYPE, _EGL_OPENGL_BIT,
- _EGL_NONE,
- )
- configs = (ctypes.c_void_p * 1)()
- num_cfgs = ctypes.c_int(0)
- if not libegl.eglChooseConfig(
- self._display, cfg_attribs, configs, 1, ctypes.byref(num_cfgs)
- ) or num_cfgs.value == 0:
- raise RuntimeError("eglChooseConfig: no suitable config.")
- self._config = configs[0]
-
- pbuf_attribs = (ctypes.c_int * 5)(
- _EGL_WIDTH, 1, _EGL_HEIGHT, 1, _EGL_NONE
- )
- self._surface = libegl.eglCreatePbufferSurface(
- self._display, self._config, pbuf_attribs
- )
- if not self._surface:
- raise RuntimeError("eglCreatePbufferSurface failed.")
-
- ctx_attribs = (ctypes.c_int * 5)(
- _EGL_CONTEXT_MAJOR_VERSION, 3,
- _EGL_CONTEXT_MINOR_VERSION, 3,
- _EGL_NONE,
- )
- self._context = libegl.eglCreateContext(
- self._display, self._config, None, ctx_attribs
- )
- if not self._context:
- raise RuntimeError(
- "eglCreateContext for OpenGL 3.3 Core failed. "
- "Try: MESA_GL_VERSION_OVERRIDE=3.3 MESA_GLSL_VERSION_OVERRIDE=330"
- )
- logger.info("EGL context created (%dx%d)", self.width, self.height)
-
-
- def _open_device_display(self, eglQueryDevicesEXT, eglGetPlatformDisplayEXT):
- """Enumerate EGL devices and return first usable display pointer."""
- n = ctypes.c_int(0)
- if not eglQueryDevicesEXT(0, None, ctypes.byref(n)) or n.value == 0:
- logger.warning("eglQueryDevicesEXT: no devices.")
- return None
- logger.debug("EGL: %d device(s) found", n.value)
- devices = (ctypes.c_void_p * n.value)()
- eglQueryDevicesEXT(n.value, devices, ctypes.byref(n))
- no_attribs = (ctypes.c_int * 1)(_EGL_NONE)
- for i, dev in enumerate(devices):
- dpy = eglGetPlatformDisplayEXT(
- _EGL_PLATFORM_DEVICE_EXT, ctypes.c_void_p(dev), no_attribs
- )
- if dpy:
- logger.debug("EGL: using device %d", i)
- return dpy
- return None
-
-
- def make_current(self):
- """Make this EGL context current and set up the FBO render target.
-
- Must be called before any OpenGL commands. Creates and binds an
- FBO backed by two renderbuffers (RGBA color + depth/stencil).
- """
- if not self._libegl.eglMakeCurrent(
- self._display, self._surface, self._surface, self._context
- ):
- raise RuntimeError("eglMakeCurrent failed.")
-
- # Force PyOpenGL to discover and cache the context we just made current.
- # PyOpenGL's contextdata module only recognizes contexts it has "seen"
- # via at least one GL call; glGetError() is the cheapest trigger.
- gl.glGetError()
-
- # Build FBO so rendering is directed off-screen
- self.fbo = gl.glGenFramebuffers(1)
- gl.glBindFramebuffer(gl.GL_FRAMEBUFFER, self.fbo)
-
- # Color renderbuffer
- self._rbo_color = gl.glGenRenderbuffers(1)
- gl.glBindRenderbuffer(gl.GL_RENDERBUFFER, self._rbo_color)
- gl.glRenderbufferStorage(
- gl.GL_RENDERBUFFER, gl.GL_RGBA8, self.width, self.height
- )
- gl.glFramebufferRenderbuffer(
- gl.GL_FRAMEBUFFER,
- gl.GL_COLOR_ATTACHMENT0,
- gl.GL_RENDERBUFFER,
- self._rbo_color,
- )
-
- # Depth + stencil renderbuffer
- self._rbo_depth = gl.glGenRenderbuffers(1)
- gl.glBindRenderbuffer(gl.GL_RENDERBUFFER, self._rbo_depth)
- gl.glRenderbufferStorage(
- gl.GL_RENDERBUFFER,
- gl.GL_DEPTH24_STENCIL8,
- self.width,
- self.height,
- )
- gl.glFramebufferRenderbuffer(
- gl.GL_FRAMEBUFFER,
- gl.GL_DEPTH_STENCIL_ATTACHMENT,
- gl.GL_RENDERBUFFER,
- self._rbo_depth,
- )
-
- status = gl.glCheckFramebufferStatus(gl.GL_FRAMEBUFFER)
- if status != gl.GL_FRAMEBUFFER_COMPLETE:
- raise RuntimeError(
- f"FBO is not complete after EGL setup (status=0x{status:X})."
- )
-
- # Set the viewport to match the render target
- gl.glViewport(0, 0, self.width, self.height)
- logger.debug("EGL FBO complete and bound (%dx%d)", self.width, self.height)
-
- def read_pixels(self) -> Image.Image:
- """Read the FBO contents and return a PIL RGB Image.
-
- Returns
- -------
- PIL.Image.Image
- Captured frame, vertically flipped to convert from OpenGL's
- bottom-left origin to image top-left convention.
- """
- gl.glBindFramebuffer(gl.GL_FRAMEBUFFER, self.fbo)
- gl.glPixelStorei(gl.GL_PACK_ALIGNMENT, 1)
- buf = gl.glReadPixels(
- 0, 0, self.width, self.height, gl.GL_RGB, gl.GL_UNSIGNED_BYTE
- )
- img = Image.frombytes("RGB", (self.width, self.height), buf)
- return img.transpose(Image.FLIP_TOP_BOTTOM)
-
- def destroy(self):
- libegl = self._libegl
- # GL cleanup first (context must be current)
- if self.fbo is not None:
- gl.glDeleteFramebuffers(1, [self.fbo])
- self.fbo = None
- if self._rbo_color is not None:
- gl.glDeleteRenderbuffers(1, [self._rbo_color])
- self._rbo_color = None
- if self._rbo_depth is not None:
- gl.glDeleteRenderbuffers(1, [self._rbo_depth])
- self._rbo_depth = None
- if self._display:
- libegl.eglMakeCurrent(self._display, None, None, None)
- if self._context:
- libegl.eglDestroyContext(self._display, self._context)
- if self._surface:
- libegl.eglDestroySurface(self._display, self._surface)
- libegl.eglTerminate(self._display)
- self._display = None
- self._context = None
- self._surface = None
- logger.debug("EGL context destroyed.")
-
- # Allow use as a context manager
- def __enter__(self):
- self.make_current()
- return self
-
- def __exit__(self, *_):
- self.destroy()
diff --git a/whippersnappy/gl/osmesa_context.py b/whippersnappy/gl/osmesa_context.py
new file mode 100644
index 0000000..e011642
--- /dev/null
+++ b/whippersnappy/gl/osmesa_context.py
@@ -0,0 +1,195 @@
+"""OSMesa off-screen (headless) OpenGL context via software rendering.
+
+This module provides a drop-in alternative to GLFW window creation for
+headless environments (CI, Docker, HPC clusters) where no X11/Wayland
+display is available and no GPU is required. It requires:
+
+ - The system OSMesa library (``libosmesa6`` on Debian/Ubuntu,
+ ``mesa-libOSMesa`` on RHEL/Fedora — loaded via ctypes at runtime).
+ - PyOpenGL >= 3.1 (already a project dependency).
+ - No GPU, no ``/dev/dri/`` devices, no display server.
+
+This module is not intended to be used directly. It is instantiated by
+:func:`~whippersnappy.gl.utils.create_window_with_fallback` when GLFW
+cannot create a window (Linux headless). That function also sets
+``PYOPENGL_PLATFORM=osmesa`` at import time so that PyOpenGL resolves
+function pointers via ``OSMesaGetProcAddress``.
+
+Notes
+-----
+OSMesa renders into its own pixel buffer which acts as the default
+framebuffer (FBO 0). No explicit FBO creation is needed — ``glReadPixels``
+reads directly from the OSMesa buffer.
+"""
+
+import ctypes
+import ctypes.util
+import logging
+
+import OpenGL.GL as gl
+from PIL import Image
+
+logger = logging.getLogger(__name__)
+
+# ---------------------------------------------------------------------------
+# OSMesa constants
+# ---------------------------------------------------------------------------
+_OSMESA_RGBA = 0x1908
+_OSMESA_ROW_LENGTH = 0x10
+_OSMESA_Y_UP = 0x11
+_GL_UNSIGNED_BYTE = 0x1401
+
+
+def _load_libosmesa():
+ """Try several candidate library names and return the loaded ctypes CDLL.
+
+ Only called on Linux — OSMesa is not attempted on macOS or Windows
+ (GLFW handles those platforms).
+ """
+ candidates = ["libOSMesa.so.8", "libOSMesa.so.6", "libOSMesa.so"]
+
+ # ctypes.util.find_library may resolve a shorter name to the real path
+ found = ctypes.util.find_library("OSMesa")
+ if found and found not in candidates:
+ candidates.insert(0, found)
+
+ last_err = None
+ for name in candidates:
+ try:
+ lib = ctypes.CDLL(name)
+ _ = lib.OSMesaCreateContextExt
+ logger.debug("Loaded OSMesa from: %s", name)
+ return lib
+ except (OSError, AttributeError) as exc:
+ last_err = exc
+
+ raise RuntimeError(
+ f"Could not load libOSMesa ({last_err}). "
+ "Install with: sudo apt-get install libosmesa6 (Debian/Ubuntu) "
+ "or: sudo dnf install mesa-libOSMesa (RHEL/Fedora)"
+ )
+
+
+class OSMesaContext:
+ """A headless OpenGL context backed by OSMesa software rendering.
+
+ OSMesa renders into its own pixel buffer which serves as the default
+ framebuffer. No explicit FBO is required — ``glReadPixels`` reads
+ directly from the OSMesa buffer.
+
+ Parameters
+ ----------
+ width, height : int
+ Dimensions of the off-screen render target in pixels.
+
+ Raises
+ ------
+ RuntimeError
+ If libOSMesa cannot be loaded or context creation fails.
+ """
+
+ def __init__(self, width: int, height: int):
+ self.width = width
+ self.height = height
+ self._libosmesa = None
+ self._ctx = None
+ self._buf = None
+ self._init_osmesa()
+
+ def _init_osmesa(self):
+ lib = _load_libosmesa()
+ self._libosmesa = lib
+
+ lib.OSMesaCreateContextExt.restype = ctypes.c_void_p
+ lib.OSMesaCreateContextExt.argtypes = [
+ ctypes.c_uint, # format (OSMESA_RGBA)
+ ctypes.c_int, # depthBits (24)
+ ctypes.c_int, # stencilBits (8)
+ ctypes.c_int, # accumBits (0)
+ ctypes.c_void_p, # sharelist (None)
+ ]
+ lib.OSMesaMakeCurrent.restype = ctypes.c_bool
+ lib.OSMesaMakeCurrent.argtypes = [
+ ctypes.c_void_p, # ctx
+ ctypes.c_void_p, # buffer
+ ctypes.c_uint, # type (GL_UNSIGNED_BYTE)
+ ctypes.c_int, # width
+ ctypes.c_int, # height
+ ]
+ lib.OSMesaDestroyContext.restype = None
+ lib.OSMesaDestroyContext.argtypes = [ctypes.c_void_p]
+
+ ctx = lib.OSMesaCreateContextExt(
+ _OSMESA_RGBA, # pixel format
+ 24, # depth bits
+ 8, # stencil bits
+ 0, # accum bits
+ None, # no shared context
+ )
+ if not ctx:
+ raise RuntimeError(
+ "OSMesaCreateContextExt failed. "
+ "Try: MESA_GL_VERSION_OVERRIDE=3.3 MESA_GLSL_VERSION_OVERRIDE=330"
+ )
+ self._ctx = ctx
+
+ # Allocate the pixel buffer that OSMesa renders into
+ buf_size = self.width * self.height * 4 # RGBA bytes
+ self._buf = (ctypes.c_ubyte * buf_size)()
+ logger.info("OSMesa context created (%dx%d)", self.width, self.height)
+
+ def make_current(self):
+ """Make this OSMesa context current.
+
+ ``OSMesaMakeCurrent`` binds ``self._buf`` as the default framebuffer
+ (FBO 0) of this context. The buffer already has colour (RGBA8),
+ depth (24-bit) and stencil (8-bit) attached — requested via
+ ``OSMesaCreateContextExt`` — so no explicit FBO creation is needed.
+ All rendering goes into ``self._buf`` automatically.
+ """
+ ok = self._libosmesa.OSMesaMakeCurrent(
+ self._ctx,
+ self._buf,
+ _GL_UNSIGNED_BYTE,
+ self.width,
+ self.height,
+ )
+ if not ok:
+ raise RuntimeError("OSMesaMakeCurrent failed.")
+
+ gl.glViewport(0, 0, self.width, self.height)
+ logger.debug("OSMesa context is current (%dx%d)", self.width, self.height)
+
+ def read_pixels(self) -> Image.Image:
+ """Read the OSMesa framebuffer and return a PIL RGB Image.
+
+ FBO 0 is always current (set by ``OSMesaMakeCurrent``), so
+ ``glReadPixels`` reads from ``self._buf`` directly.
+
+ Returns
+ -------
+ PIL.Image.Image
+ Captured frame, vertically flipped to top-left origin convention.
+ """
+ gl.glPixelStorei(gl.GL_PACK_ALIGNMENT, 1)
+ buf = gl.glReadPixels(
+ 0, 0, self.width, self.height, gl.GL_RGB, gl.GL_UNSIGNED_BYTE
+ )
+ img = Image.frombytes("RGB", (self.width, self.height), buf)
+ return img.transpose(Image.Transpose.FLIP_TOP_BOTTOM)
+
+ def destroy(self):
+ """Destroy the OSMesa context and release the pixel buffer."""
+ if self._ctx is not None:
+ self._libosmesa.OSMesaDestroyContext(self._ctx)
+ self._ctx = None
+ self._buf = None
+ logger.debug("OSMesa context destroyed.")
+
+ def __enter__(self):
+ self.make_current()
+ return self
+
+ def __exit__(self, *_):
+ self.destroy()
+
diff --git a/whippersnappy/gl/utils.py b/whippersnappy/gl/utils.py
index b24bd08..55ffc1b 100644
--- a/whippersnappy/gl/utils.py
+++ b/whippersnappy/gl/utils.py
@@ -1,6 +1,15 @@
"""GL helper utilities.
Contains the implementation of OpenGL helpers used by the package.
+Headless rendering on Linux uses OSMesa (CPU software renderer) via
+:class:`~whippersnappy.gl.osmesa_context.OSMesaContext` as a fallback
+when no display server or GPU is available. No EGL or GPU driver is
+required for headless operation.
+
+On Linux with no ``DISPLAY`` or ``WAYLAND_DISPLAY`` set,
+``PYOPENGL_PLATFORM=osmesa`` is set automatically at import time (before
+``OpenGL.GL`` is imported) so that PyOpenGL resolves all function pointers
+via ``OSMesaGetProcAddress`` rather than GLX.
"""
import logging
@@ -9,6 +18,18 @@
import warnings
from typing import Any
+# On Linux with no display, pre-set PYOPENGL_PLATFORM=osmesa before
+# importing OpenGL.GL so PyOpenGL uses OSMesaGetProcAddress for function
+# pointer resolution instead of GLX (which returns null pointers without
+# an X11/Wayland display). Has no effect on macOS or Windows.
+if (
+ sys.platform == "linux"
+ and "PYOPENGL_PLATFORM" not in os.environ
+ and not os.environ.get("DISPLAY")
+ and not os.environ.get("WAYLAND_DISPLAY")
+):
+ os.environ["PYOPENGL_PLATFORM"] = "osmesa"
+
import glfw
import OpenGL.GL as gl
import OpenGL.GL.shaders as shaders
@@ -20,8 +41,9 @@
# Module logger
logger = logging.getLogger(__name__)
-# Module-level EGL context handle (None when GLFW is used instead)
-_egl_context: Any = None
+# Module-level offscreen context handle (None when GLFW is used instead).
+# May hold an OSMesaContext instance on headless environments.
+_offscreen_context: Any = None
def create_vao():
@@ -164,59 +186,117 @@ def set_lighting_uniforms(shader, specular=True, ambient=0.0, light_color=(1.0,
gl.glUniform1f(ambient_loc, ambient)
-def init_window(width, height, title="PyOpenGL", visible=True):
- """Create a GLFW window, make an OpenGL context current and return the window handle.
+
+def _try_glfw_window(width, height, title, visible, core_profile):
+ """Attempt to create a single GLFW window with the given profile settings.
+
+ Calls ``glfw.init()`` before and ``glfw.terminate()`` on failure so that
+ each attempt starts from a clean GLFW state.
Parameters
----------
- width, height : int
- Window dimensions in pixels.
- title : str, optional, default 'PyOpenGL'
- Window title.
- visible : bool, optional, default True
- If False create an invisible/offscreen window (useful for headless
- rendering when a display is available but no screen is needed).
+ core_profile : bool
+ If True request OpenGL 3.3 Core Profile + ``FORWARD_COMPAT``
+ (preferred on all platforms).
+ If False request OpenGL 3.3 Compatibility Profile (fallback for
+ Windows software renderers that don't support Core Profile).
Returns
-------
- window or False
- GLFW window handle on success, or False on failure.
+ window or None
"""
with warnings.catch_warnings():
warnings.simplefilter("ignore")
if not glfw.init():
- return False
+ return None
+ glfw.default_window_hints()
glfw.window_hint(glfw.CONTEXT_VERSION_MAJOR, 3)
glfw.window_hint(glfw.CONTEXT_VERSION_MINOR, 3)
- glfw.window_hint(glfw.OPENGL_FORWARD_COMPAT, True)
- glfw.window_hint(glfw.OPENGL_PROFILE, glfw.OPENGL_CORE_PROFILE)
+ if core_profile:
+ glfw.window_hint(glfw.OPENGL_FORWARD_COMPAT, True)
+ glfw.window_hint(glfw.OPENGL_PROFILE, glfw.OPENGL_CORE_PROFILE)
+ else:
+ glfw.window_hint(glfw.OPENGL_FORWARD_COMPAT, False)
+ glfw.window_hint(glfw.OPENGL_PROFILE, glfw.OPENGL_COMPAT_PROFILE)
if not visible:
glfw.window_hint(glfw.VISIBLE, glfw.FALSE)
+
window = glfw.create_window(width, height, title, None, None)
if not window:
glfw.terminate()
- return False
- glfw.set_input_mode(window, glfw.STICKY_KEYS, gl.GL_TRUE)
- glfw.make_context_current(window)
- glfw.swap_interval(0)
+ return None
return window
+def init_window(width, height, title="PyOpenGL", visible=True):
+ """Create a GLFW window, make an OpenGL context current and return the window handle.
+
+ Tries OpenGL 3.3 Core Profile + ``FORWARD_COMPAT`` first, then falls
+ back to Compatibility Profile on non-macOS platforms. NSGL (macOS) does
+ not support Compatibility Profile, so only one attempt is made there.
+
+ Each attempt calls ``glfw.init()`` / ``glfw.terminate()`` independently
+ so that a failed attempt leaves no stale GLFW state for the next.
+
+ Parameters
+ ----------
+ width, height : int
+ Window dimensions in pixels.
+ title : str, optional, default 'PyOpenGL'
+ Window title.
+ visible : bool, optional, default True
+ If False create an invisible/offscreen window.
+
+ Returns
+ -------
+ window or False
+ GLFW window handle on success, or False on failure.
+ """
+ # Core Profile (required on macOS, preferred everywhere).
+ window = _try_glfw_window(width, height, title, visible, core_profile=True)
+ if window:
+ glfw.set_input_mode(window, glfw.STICKY_KEYS, gl.GL_TRUE)
+ glfw.make_context_current(window)
+ glfw.swap_interval(0)
+ return window
+
+ # macOS NSGL does not support Compatibility Profile — don't retry.
+ if sys.platform == "darwin":
+ return False
+
+ # Non-macOS: retry with Compatibility Profile (helps on some Windows CI
+ # runners with software renderers that support compat but not core).
+ logger.debug(
+ "OpenGL 3.3 Core Profile unavailable; retrying with Compatibility Profile."
+ )
+ window = _try_glfw_window(width, height, title, visible, core_profile=False)
+ if window:
+ glfw.set_input_mode(window, glfw.STICKY_KEYS, gl.GL_TRUE)
+ glfw.make_context_current(window)
+ glfw.swap_interval(0)
+ return window
+
+ return False
+
+
def create_window_with_fallback(width, height, title="WhipperSnapPy", visible=True):
- """Create an OpenGL context, trying GLFW first and EGL as a fallback.
+ """Create an OpenGL context, trying GLFW first and OSMesa as a fallback.
The function attempts context creation in this priority order:
- 1. **GLFW visible window** — normal path on workstations.
- 2. **GLFW invisible window** — when a display exists but no screen
- is needed (e.g. a remote desktop session).
- 3. **EGL pbuffer** — fully headless; no display server required.
- Works with NVIDIA/AMD GPU drivers and Mesa (llvmpipe) on CPU-only
- systems. Requires ``libegl1`` (already installed in the Docker
- image) and ``pyopengl >= 3.1``.
-
- When EGL is used the module-level ``_egl_context`` is set and
+ 1. **GLFW visible window** — normal path on workstations with a display.
+ 2. **GLFW invisible window** — when a display exists but no on-screen
+ window is needed (e.g. batch rendering). Core Profile +
+ ``FORWARD_COMPAT`` is tried first; Compatibility Profile is retried
+ on non-macOS platforms (NSGL does not support Compatibility Profile).
+ 3. **OSMesa software rendering** — Linux only. Used when both GLFW
+ attempts fail (no display server). Requires ``libosmesa6``
+ (Debian/Ubuntu) or ``mesa-libOSMesa`` (RHEL/Fedora). On macOS and
+ Windows a platform-specific ``RuntimeError`` is raised instead,
+ because neither platform supports OSMesa in standard distributions.
+
+ When OSMesa is used the module-level ``_offscreen_context`` is set and
``make_current()`` is called so that subsequent OpenGL calls work
identically to the GLFW path.
@@ -234,38 +314,17 @@ def create_window_with_fallback(width, height, title="WhipperSnapPy", visible=Tr
Returns
-------
GLFWwindow or None
- GLFW window handle when GLFW succeeded, ``None`` when EGL is used
- (the context is already current via ``_egl_context.make_current()``).
+ GLFW window handle when GLFW succeeded, ``None`` when OSMesa is used
+ (the context is already current via ``_offscreen_context.make_current()``).
Raises
------
RuntimeError
- If all three methods fail to produce a usable OpenGL context.
+ If no usable OpenGL context can be created. On Linux this means
+ both GLFW and OSMesa failed. On macOS/Windows it means GLFW failed
+ (those platforms have no OSMesa fallback).
"""
- global _egl_context
-
- # Fast-path: if _check_display() already determined there is no working
- # display, skip the two doomed GLFW attempts and go straight to EGL.
- # This avoids warning noise and wasted time in Docker/CI/headless SSH.
- # The sys.platform guard is preserved — EGL is Linux-only.
- if os.environ.get("PYOPENGL_PLATFORM") == "egl":
- if sys.platform != "linux":
- raise RuntimeError(
- f"Could not create any OpenGL context via GLFW on {sys.platform}. "
- "Ensure a display is available."
- )
- logger.info("No working display detected — using EGL headless directly.")
- try:
- from .egl_context import EGLContext
- ctx = EGLContext(width, height)
- ctx.make_current()
- _egl_context = ctx
- logger.info("Using EGL headless context — no display server required.")
- return None
- except (ImportError, RuntimeError) as exc:
- raise RuntimeError(
- f"EGL headless context failed: {exc}"
- ) from exc
+ global _offscreen_context
# --- Step 1: GLFW visible window ---
window = init_window(width, height, title, visible=visible)
@@ -281,27 +340,32 @@ def create_window_with_fallback(width, height, title="WhipperSnapPy", visible=Tr
if window:
return window
- # --- Step 3: EGL headless pbuffer (Linux only) ---
- logger.warning(
- "GLFW context creation failed entirely (no display?). "
- "Attempting EGL headless context."
- )
+ # --- Step 3: OSMesa software rendering (Linux headless only) ---
+ # Only reached on Linux when both GLFW attempts failed (no display server).
+ # On macOS and Windows GLFW is the only supported headless path; if it
+ # failed here it means the system has no usable OpenGL driver at all.
if sys.platform != "linux":
raise RuntimeError(
- f"Could not create any OpenGL context via GLFW on {sys.platform}. "
- "Ensure a display is available."
+ "Could not create a GLFW OpenGL context. "
+ "On macOS a display connection is required (NSGL does not support "
+ "headless rendering). "
+ "On Windows ensure a GPU driver or Mesa opengl32.dll is available."
)
+ # PYOPENGL_PLATFORM=osmesa was already set at module import time (top of
+ # this file) when no display was detected, so PyOpenGL uses
+ # OSMesaGetProcAddress for function pointer resolution.
+ logger.info("No display detected — trying OSMesa software rendering (CPU).")
try:
- from .egl_context import EGLContext
- ctx = EGLContext(width, height)
+ from .osmesa_context import OSMesaContext # noqa: PLC0415
+ ctx = OSMesaContext(width, height)
ctx.make_current()
- _egl_context = ctx
- logger.info("Using EGL headless context — no display server required.")
+ _offscreen_context = ctx
+ logger.info("Using OSMesa headless context — no display server or GPU required.")
return None
except (ImportError, RuntimeError) as exc:
raise RuntimeError(
"Could not create any OpenGL context (tried GLFW visible, "
- f"GLFW invisible, EGL pbuffer). Last error: {exc}"
+ f"GLFW invisible, OSMesa). Last error: {exc}"
) from exc
@@ -309,19 +373,19 @@ def terminate_context(window):
"""Release the active OpenGL context regardless of how it was created.
This is a drop-in replacement for ``glfw.terminate()`` that also
- handles the EGL path. Call it at the end of every rendering function
- instead of calling ``glfw.terminate()`` directly.
+ handles the OSMesa headless path. Call it at the end of every rendering
+ function instead of calling ``glfw.terminate()`` directly.
Parameters
----------
window : GLFWwindow or None
The GLFW window handle returned by ``create_window_with_fallback``,
- or ``None`` when EGL is active.
+ or ``None`` when an OSMesa context is active.
"""
- global _egl_context
- if _egl_context is not None:
- _egl_context.destroy() # type: ignore[union-attr]
- _egl_context = None
+ global _offscreen_context
+ if _offscreen_context is not None:
+ _offscreen_context.destroy() # type: ignore[union-attr]
+ _offscreen_context = None
else:
glfw.terminate()
@@ -371,15 +435,16 @@ def setup_shader(meshdata, triangles, width, height, specular=True, ambient=0.0)
def capture_window(window):
"""Read the current GL framebuffer and return it as a PIL Image (RGB).
- Works for both GLFW windows and EGL headless contexts. When EGL is
- active (``window`` is ``None``) the pixels are read from the FBO that
- was set up by :class:`~whippersnappy.gl.egl_context.EGLContext`; in
- that case there is no HiDPI scaling to account for.
+ Works for both GLFW windows and OSMesa headless contexts. When OSMesa is
+ active (``window`` is ``None``) the pixels are read directly from the
+ OSMesa pixel buffer, which acts as the default framebuffer (FBO 0) — no
+ explicit FBO is created by :class:`~whippersnappy.gl.osmesa_context.OSMesaContext`;
+ in that case there is no HiDPI scaling to account for.
Parameters
----------
window : GLFWwindow or None
- GLFW window handle, or ``None`` when an EGL context is active.
+ GLFW window handle, or ``None`` when an OSMesa context is active.
Returns
-------
@@ -387,11 +452,11 @@ def capture_window(window):
RGB image of the rendered frame, with the vertical flip applied so
that the origin is at the top-left (image convention).
"""
- global _egl_context
+ global _offscreen_context
- # --- EGL path: read directly from the FBO ---
- if _egl_context is not None:
- return _egl_context.read_pixels() # type: ignore[union-attr]
+ # --- OSMesa path: read from the OSMesa pixel buffer (default framebuffer) ---
+ if _offscreen_context is not None:
+ return _offscreen_context.read_pixels() # type: ignore[union-attr]
# --- GLFW path: read from the default framebuffer ---
monitor = glfw.get_primary_monitor()
diff --git a/whippersnappy/gl/views.py b/whippersnappy/gl/views.py
deleted file mode 100644
index 0d2af24..0000000
--- a/whippersnappy/gl/views.py
+++ /dev/null
@@ -1,199 +0,0 @@
-"""View matrices, presets, and interactive view state under gl package."""
-
-from dataclasses import dataclass, field
-
-import numpy as np
-import pyrr # still needed for compute_view_matrix
-
-from ..utils.types import ViewType
-
-# ---------------------------------------------------------------------------
-# ViewState — single source of truth for all mutable view parameters
-# ---------------------------------------------------------------------------
-
-@dataclass
-class ViewState:
- """Mutable view parameters for the interactive GUI render loop.
-
- All mouse/keyboard interaction updates this object; the view matrix is
- recomputed from it each frame via :func:`compute_view_matrix`.
-
- Parameters
- ----------
- rotation : np.ndarray
- 4×4 float32 rotation matrix (identity = no rotation applied).
- pan : np.ndarray
- (x, y) pan offset in normalised screen-space units.
- zoom : float
- Z-translation packed into the transform matrix.
- last_mouse_pos : np.ndarray or None
- Last recorded mouse position in pixels; ``None`` when no button held.
- left_button_down : bool
- Whether the left mouse button is currently pressed.
- right_button_down : bool
- Whether the right mouse button is currently pressed.
- middle_button_down : bool
- Whether the middle mouse button is currently pressed.
- """
- rotation: np.ndarray = field(
- default_factory=lambda: np.eye(4, dtype=np.float32)
- )
- pan: np.ndarray = field(
- default_factory=lambda: np.zeros(2, dtype=np.float32)
- )
- zoom: float = 0.4
- last_mouse_pos: np.ndarray | None = None
- left_button_down: bool = False
- right_button_down: bool = False
- middle_button_down: bool = False
-
-
-def compute_view_matrix(view_state: ViewState, base_view: np.ndarray) -> np.ndarray:
- """Return the ``transform`` uniform — exactly as snap_rotate does it.
-
- Packs ``transl * rotation * base_view`` into a single matrix, matching
- the snap_rotate convention (line: ``viewmat = transl * rot * base_view``).
- The ``model`` and ``view`` uniforms are left as set by ``setup_shader``
- (identity and camera respectively) and must not be overwritten.
-
- Parameters
- ----------
- view_state : ViewState
- Current interactive view state.
- base_view : np.ndarray
- Fixed 4×4 orientation preset from :func:`get_view_matrices`.
-
- Returns
- -------
- np.ndarray
- 4×4 float32 matrix for the ``transform`` shader uniform.
- """
- transl = pyrr.Matrix44.from_translation((
- view_state.pan[0],
- view_state.pan[1],
- 0.4 + view_state.zoom,
- ))
- rot = pyrr.Matrix44(view_state.rotation)
- return np.array(transl * rot * pyrr.Matrix44(base_view), dtype=np.float32)
-
-
-
-# ---------------------------------------------------------------------------
-# Arcball helpers
-# ---------------------------------------------------------------------------
-
-def arcball_vector(x: float, y: float, width: int, height: int) -> np.ndarray:
- """Map a 2-D screen pixel to a point on the unit arcball sphere.
-
- Normalises (x, y) to [-1, 1] NDC, then projects onto the unit sphere.
- Points outside the sphere radius are clamped to the rim (z = 0).
-
- Parameters
- ----------
- x, y : float
- Mouse position in pixels.
- width, height : int
- Window dimensions in pixels.
-
- Returns
- -------
- np.ndarray
- Unit 3-vector on (or clamped to) the arcball sphere.
- """
- s = min(width, height)
- p = np.array([
- (2.0 * x - width) / s,
- -(2.0 * y - height) / s,
- 0.0,
- ], dtype=np.float64)
- sq = p[0] ** 2 + p[1] ** 2
- if sq <= 1.0:
- p[2] = np.sqrt(1.0 - sq)
- else:
- p /= np.sqrt(sq) # clamp to rim
- n = np.linalg.norm(p)
- return p / n if n > 0 else p
-
-
-def arcball_rotation_matrix(v1: np.ndarray, v2: np.ndarray) -> np.ndarray:
- """Return a 4×4 rotation matrix that rotates unit vector *v1* to *v2*.
-
- Uses Rodrigues' rotation formula in pure numpy — no pyrr dependency.
- Returns identity when *v1* and *v2* are coincident.
-
- Parameters
- ----------
- v1, v2 : np.ndarray
- Unit 3-vectors on the arcball sphere.
-
- Returns
- -------
- np.ndarray
- 4×4 float32 rotation matrix compatible with pyrr.
- """
- axis = np.cross(v1, v2)
- axis_len = np.linalg.norm(axis)
- if axis_len < 1e-10:
- return np.eye(4, dtype=np.float32)
-
- axis = axis / axis_len
- angle = np.arctan2(axis_len, np.dot(v1, v2))
-
- # Rodrigues' formula: R = I cos(a) + sin(a) [axis]× + (1-cos(a)) axis⊗axis
- c, s = np.cos(angle), np.sin(angle)
- t = 1.0 - c
- x, y, z = axis
- r3 = np.array([
- [t*x*x + c, t*x*y - s*z, t*x*z + s*y],
- [t*x*y + s*z, t*y*y + c, t*y*z - s*x],
- [t*x*z - s*y, t*y*z + s*x, t*z*z + c ],
- ], dtype=np.float32)
-
- r4 = np.eye(4, dtype=np.float32)
- r4[:3, :3] = r3
- return r4
-
-
-def get_view_matrices():
- """Return canonical 4x4 view matrices for common brain orientations.
-
- The returned dictionary maps :class:`whippersnappy.utils.types.ViewType`
- enum members to corresponding 4x4 view matrices (dtype float32) that
- can be used as camera/view transforms in the OpenGL renderer.
-
- Returns
- -------
- dict
- Mapping of :class:`ViewType` -> 4x4 numpy.ndarray view matrix.
- """
- view_left = np.array([[0, 0, -1, 0], [-1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 0, 1]], dtype=np.float32)
- view_right = np.array([[0, 0, 1, 0], [1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 0, 1]], dtype=np.float32)
- view_back = np.array([[1, 0, 0, 0], [0, 0, -1, 0], [0, 1, 0, 0], [0, 0, 0, 1]], dtype=np.float32)
- view_front = np.array([[-1, 0, 0, 0], [0, 0, 1, 0], [0, 1, 0, 0], [0, 0, 0, 1]], dtype=np.float32)
- view_bottom = np.array([[-1, 0, 0, 0], [0, 1, 0, 0], [0, 0, -1, 0], [0, 0, 0, 1]], dtype=np.float32)
- view_top = np.array([[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]], dtype=np.float32)
-
- return {
- ViewType.LEFT: view_left,
- ViewType.RIGHT: view_right,
- ViewType.BACK: view_back,
- ViewType.FRONT: view_front,
- ViewType.TOP: view_top,
- ViewType.BOTTOM: view_bottom,
- }
-
-
-def get_view_matrix(view_type):
- """Return the 4x4 view matrix for a single :class:`ViewType`.
-
- Parameters
- ----------
- view_type : ViewType
- Enum member indicating the requested view.
-
- Returns
- -------
- numpy.ndarray
- 4x4 float32 view matrix.
- """
- return get_view_matrices()[view_type]
diff --git a/whippersnappy/snap.py b/whippersnappy/snap.py
index f3dbcd2..4706a10 100644
--- a/whippersnappy/snap.py
+++ b/whippersnappy/snap.py
@@ -12,9 +12,8 @@
from .geometry import estimate_overlay_thresholds, get_surf_name
from .geometry.prepare import prepare_and_validate_geometry
from .gl.utils import capture_window, create_window_with_fallback, render_scene, setup_shader, terminate_context
-from .gl.views import get_view_matrices
from .utils.image import create_colorbar, draw_caption, draw_colorbar, load_roboto_font, text_size
-from .utils.types import ColorSelection, OrientationType, ViewType
+from .utils.types import ColorSelection, OrientationType, ViewType, get_view_matrices
# Module logger
logger = logging.getLogger(__name__)
diff --git a/whippersnappy/utils/types.py b/whippersnappy/utils/types.py
index 8d0e417..2869e72 100644
--- a/whippersnappy/utils/types.py
+++ b/whippersnappy/utils/types.py
@@ -2,6 +2,7 @@
This module defines small enumeration types used across the package for
controlling color selection, colorbar orientation, and predefined views.
+It also provides the canonical per-view 4×4 matrices used by the renderers.
Classes
-------
@@ -11,10 +12,19 @@
Orientation of UI elements such as the colorbar (horizontal or vertical).
ViewType
Predefined canonical view orientations for rendering the brain surface.
+
+Functions
+---------
+get_view_matrices
+ Return a dict mapping every :class:`ViewType` to its 4×4 numpy matrix.
+get_view_matrix
+ Return the 4×4 numpy matrix for a single :class:`ViewType`.
"""
import enum
+import numpy as np
+
class ColorSelection(enum.Enum):
"""Enum to select which sign(s) of overlay values to color.
@@ -91,3 +101,40 @@ class ViewType(enum.Enum):
TOP = 5
BOTTOM = 6
+
+def get_view_matrices() -> dict:
+ """Return canonical 4×4 view matrices for every :class:`ViewType`.
+
+ The matrices are pure numpy arrays (no OpenGL calls) and can be used
+ as the ``base_view`` argument to any renderer.
+
+ Returns
+ -------
+ dict
+ Mapping of :class:`ViewType` → 4×4 float32 numpy.ndarray.
+ """
+ return {
+ ViewType.LEFT: np.array([[ 0, 0,-1, 0],[-1, 0, 0, 0],[ 0, 1, 0, 0],[0, 0, 0, 1]], dtype=np.float32),
+ ViewType.RIGHT: np.array([[ 0, 0, 1, 0],[ 1, 0, 0, 0],[ 0, 1, 0, 0],[0, 0, 0, 1]], dtype=np.float32),
+ ViewType.BACK: np.array([[ 1, 0, 0, 0],[ 0, 0,-1, 0],[ 0, 1, 0, 0],[0, 0, 0, 1]], dtype=np.float32),
+ ViewType.FRONT: np.array([[-1, 0, 0, 0],[ 0, 0, 1, 0],[ 0, 1, 0, 0],[0, 0, 0, 1]], dtype=np.float32),
+ ViewType.TOP: np.array([[ 1, 0, 0, 0],[ 0, 1, 0, 0],[ 0, 0, 1, 0],[0, 0, 0, 1]], dtype=np.float32),
+ ViewType.BOTTOM: np.array([[-1, 0, 0, 0],[ 0, 1, 0, 0],[ 0, 0,-1, 0],[0, 0, 0, 1]], dtype=np.float32),
+ }
+
+
+def get_view_matrix(view_type: "ViewType") -> np.ndarray:
+ """Return the 4×4 view matrix for a single :class:`ViewType`.
+
+ Parameters
+ ----------
+ view_type : ViewType
+ Enum member indicating the requested view.
+
+ Returns
+ -------
+ numpy.ndarray
+ 4×4 float32 view matrix.
+ """
+ return get_view_matrices()[view_type]
+