Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
123 commits
Select commit Hold shift + click to select a range
0e3ad3c
Add camera projection Transformation
gselzer Jul 10, 2025
39ca744
Add a projection example
gselzer Jul 11, 2025
f7b57e6
Remove zoom and center
gselzer Jul 14, 2025
fb48e04
Add projection setter to Camera adapter
gselzer Jul 14, 2025
244282a
perspective: Fix Transform construction
gselzer Jul 14, 2025
5fbfb39
Merge remote-tracking branch 'upstream/main' into camera-projection
gselzer Jul 14, 2025
7d63ca4
Copy clims between render materials
gselzer Jul 14, 2025
0363267
Test orthographic projection matrices
gselzer Jul 14, 2025
2ef5f0b
Improve perspective transforms
gselzer Jul 14, 2025
ee65c56
Minor fixes
gselzer Jul 14, 2025
ed1bbc0
HACK: Zero out the perspective matrix
gselzer Jul 14, 2025
7a9a2a9
perspective: Don't use Matrix3D
gselzer Jul 15, 2025
09e5b4d
Add pylinalg as explicit dependency
gselzer Jul 15, 2025
09058cc
Transform: Use Annotated for root type
gselzer Jul 15, 2025
c3211e0
Merge remote-tracking branch 'upstream/main' into camera-projection
gselzer Jul 15, 2025
748679b
Fix projection transform example
gselzer Jul 15, 2025
7920d4c
vispy projections: Starting to work
gselzer Jul 16, 2025
732b9c2
HACK: vispy: Tap into vispy view size computation
gselzer Jul 16, 2025
94fe13f
Clean up vispy camera class
gselzer Jul 16, 2025
798a787
Delete obsolete projection code
gselzer Jul 16, 2025
2ca7ca6
Fix typo
gselzer Jul 16, 2025
336a139
Remove camera type from model
gselzer Jul 17, 2025
8f91699
Update default projection matrices
gselzer Jul 17, 2025
85d1235
Clean up vispy adaptor tests
gselzer Jul 17, 2025
9fbeae3
Add pygfx camera adaptor tests
gselzer Jul 17, 2025
84eec49
Add Node.bounding_box
gselzer Jul 17, 2025
82a2b78
Recursively include child bounding boxes
gselzer Jul 18, 2025
fcb8fd8
Write model-based zoom-to-fit
gselzer Jul 18, 2025
9ddde41
Remove adaptor zoom-to-fits
gselzer Jul 18, 2025
b655e9d
Clean and test zoom_to_fit
gselzer Jul 22, 2025
61aafe8
Events
gselzer Jul 31, 2025
4e68694
CI matrix
gselzer Jul 31, 2025
d4c32fc
Get tests passing
gselzer Jul 31, 2025
8a0eebb
WIP: Jupyter events
gselzer Aug 4, 2025
a9c7a47
More Jupyter work
gselzer Aug 19, 2025
3350069
More fixes
gselzer Aug 19, 2025
d9da369
Wx event filter
gselzer Aug 19, 2025
18cae3e
Adjust matrix to add jupyter, wx
gselzer Aug 19, 2025
d4e293e
More matrix changes
gselzer Aug 20, 2025
ab77dfa
Remove --active from pyright pre-commit
gselzer Aug 20, 2025
9a62b74
Fix ci matrix excludes
gselzer Aug 20, 2025
fd52597
Why da matrix no work >:(
gselzer Aug 20, 2025
12697af
Remove glfw from jupyter extra
gselzer Aug 20, 2025
3d3628c
Try adding back 3.11/12 tests
gselzer Aug 20, 2025
5c2c7c1
Use CallAfter to show window
gselzer Aug 20, 2025
e01ac5e
Add a qt runner
gselzer Aug 20, 2025
3508a17
Add interactive camera to basic scene example
gselzer Aug 20, 2025
24d629e
Use Camera instead of PerspectiveCamera
gselzer Aug 20, 2025
3e6fec4
Fix VisPy canvas being vertically inverted
gselzer Aug 20, 2025
cb67a74
Add a slightly better error message for glfw run
gselzer Aug 21, 2025
f9e64e1
WIP: Orbit camera controller
gselzer Aug 21, 2025
122e293
Write a better GLFW loop error message
gselzer Aug 21, 2025
8b28c93
Orbit behavior
gselzer Aug 21, 2025
3361797
WIP: Bad attempt
gselzer Aug 28, 2025
1ceb14b
Orbit camera
gselzer Aug 28, 2025
5f77b31
OrbitController: Add pan/zoom
gselzer Aug 28, 2025
0c485c8
Test controllers
gselzer Aug 29, 2025
99b8967
Move _canvas_to_world to the canvas
gselzer Aug 29, 2025
d49d07c
Add Camera.look_at(), Camera.up, Camera.forward
gselzer Aug 29, 2025
3a2f835
test events backends
gselzer Sep 2, 2025
cad94bf
Try GLFW last
gselzer Sep 3, 2025
fc16036
Add testqt for qt runs
gselzer Sep 3, 2025
3c75a0a
Fix Jupyter tests
gselzer Sep 3, 2025
b557efc
Skip macos+vispy+jupyter
gselzer Sep 3, 2025
3112399
Revert Jupyter dependency group
gselzer Sep 3, 2025
d4eb575
Prevent multiple view copies in vispy canvas
gselzer Sep 3, 2025
aa7ae68
Move some stuff around
gselzer Sep 4, 2025
eed9bfc
tidy up examples
gselzer Sep 4, 2025
edcab79
Clean up Jupyter example
gselzer Sep 4, 2025
669db93
Relax numpy version
gselzer Sep 4, 2025
33abbb2
Jupyter: No mouse button on wheel events
gselzer Sep 4, 2025
7d5b87f
Remove testjupyter group
gselzer Sep 4, 2025
2d86143
Repurpose _snx_get_native for native widget
gselzer Sep 4, 2025
2d1ab03
Fix pre-commit
gselzer Sep 4, 2025
5016c7a
Fix some linting errors
gselzer Sep 4, 2025
46fcf08
WIP: Fix wx tests
gselzer Sep 4, 2025
b71e30a
Try cleaning up the rendercanvas logic
gselzer Sep 4, 2025
b651bbd
Clean up some comments
gselzer Sep 4, 2025
3175a0c
Clean up wx HACK
gselzer Sep 5, 2025
78bce75
vispy canvas: remove commented code
gselzer Sep 5, 2025
042ce10
Avoid interacting with non-interactive non-cameras
gselzer Sep 5, 2025
3f04f88
Avoid making vispy nodes interactive
gselzer Sep 5, 2025
4cf37ac
Remove commented-out code
gselzer Sep 5, 2025
21b520a
Swtich from Event.type string to subdataclasses
gselzer Sep 5, 2025
f5b343c
Merge remote-tracking branch 'upstream/main' into events
gselzer Sep 5, 2025
7cf33f1
Pin pygfx<0.13.0 for now
gselzer Sep 5, 2025
4ac5bde
Fix more linting errors
gselzer Sep 5, 2025
7e8702d
Try adding wx back to dev deps
gselzer Sep 5, 2025
6bdd854
Lint on Windows instead
gselzer Sep 5, 2025
972150e
Use camera vectors to compute camera right vector
gselzer Sep 10, 2025
969b13d
Add in a blending mode
gselzer Sep 11, 2025
392cd01
Correct vispy test
gselzer Sep 11, 2025
864b992
Reconstruct WgpuRenderer when changing canvas
gselzer Sep 11, 2025
2f04c56
Pygfx: Set renderer after
gselzer Sep 12, 2025
cef51b2
look_at: Set the near plane much closer
gselzer Sep 12, 2025
70b38ae
Add a note about zoom_to_fit
gselzer Sep 12, 2025
d322681
Add canvas resizing
gselzer Sep 12, 2025
d1753bb
Add size HACK for pygfx+wx
gselzer Sep 12, 2025
5807000
Couple more pygfx fixes
gselzer Sep 12, 2025
bcd983f
Add some view+canvas compatibility constraints
gselzer Sep 12, 2025
6af6bdd
Fix some resizing bugs
gselzer Sep 15, 2025
c3a938f
Change linting back to ubuntu
gselzer Sep 15, 2025
f2842b7
Remove wx from dev deps
gselzer Sep 16, 2025
8cd7634
Try out types-wxpython
gselzer Sep 16, 2025
28dcc4a
Let's try windows linting again?
gselzer Sep 16, 2025
6f06e55
Fix remaining linting error
gselzer Sep 17, 2025
e1efa49
Add note about why linting happens on Windows now
gselzer Sep 17, 2025
bf924e9
Skip windows+pygfx
gselzer Sep 17, 2025
d64c23e
Small fixes to wx test?
gselzer Sep 17, 2025
d46ff9b
Add a block_events to the app
gselzer Sep 17, 2025
22f7ad2
Remove glfw event filter
gselzer Sep 17, 2025
5b6af06
Put an event filter on the view
gselzer Sep 19, 2025
71a5760
adjust origin in passes_through
gselzer Sep 25, 2025
c5603ec
Open right edge of image intersections
gselzer Sep 25, 2025
436392a
Add mouse enter and leave events
gselzer Sep 26, 2025
7ecf1fe
Fix RGB images in pygfx
gselzer Sep 26, 2025
8549ed5
Add App.call_in_main_thread
gselzer Oct 1, 2025
89552e5
Dynamic slot import
gselzer Oct 1, 2025
e632ee2
Validate view.filter_event results
gselzer Oct 2, 2025
4d4d289
First cut: Meshes
gselzer Oct 2, 2025
6dd4335
Add basic passes-through implementation
gselzer Oct 2, 2025
5e22dca
Flesh out implementation, add tests
gselzer Oct 3, 2025
fb45ccd
Fix mesh tests
gselzer Oct 7, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 34 additions & 10 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,28 +22,52 @@ jobs:
- run: pipx run check-manifest

lint:
runs-on: ubuntu-latest
# We lint on Windows so we can lint wx code.
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v6
with:
enable-cache: true
python-version: "3.13"
- uses: tox-dev/action-pre-commit-uv@v1
with:
extra_args: --all-files --verbose
- run: uvx pre-commit run --all-files --verbose

test:
name: test ${{ matrix.os }} py${{ matrix.python-version }} ${{ matrix.gfx }}
name: test ${{ matrix.os }} py${{ matrix.python-version }} ${{ matrix.gfx }} ${{ matrix.canvas }}
runs-on: ${{ matrix.os }}
env:
UV_FROZEN: 1
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
python-version: ["3.10", "3.11", "3.12", "3.13"]
python-version: ["3.10", "3.13"]
gfx: [pygfx, vispy]
canvas: [pyqt, jupyter, wx]
exclude:
# glfw.GLFWError: (65545) b'NSGL: Failed to find a suitable pixel format'
# (Under the hood GLFW gives vispy the OpenGL context.)
- os: macos-latest
canvas: jupyter
gfx: vispy
# wxpython does not build wheels for ubuntu or macos-latest py3.10
- os: ubuntu-latest
canvas: wx
- os: macos-latest
canvas: wx
python-version: "3.10"
# FIXME: On CI: AttributeError: 'Shared' object has no attribute '_device'. Did you mean: 'device'?
# Related to pygfx v0.13.0
# Tests pass locally
- os: windows-latest
gfx: pygfx
include:
- python-version: "3.11"
gfx: pygfx
canvas: pyqt
os: ubuntu-latest
- python-version: "3.12"
gfx: pygfx
canvas: pyqt
os: ubuntu-latest

steps:
- uses: actions/checkout@v4
Expand All @@ -61,7 +85,7 @@ jobs:
sudo apt install -y libegl1-mesa-dev libgl1-mesa-dri libxcb-xfixes0-dev mesa-vulkan-drivers

- name: Install dependencies
run: uv sync --no-dev --group test --extra ${{ matrix.gfx }} ${{ matrix.python-version != '3.10' && '--extra imgui' || '' }}
run: uv sync --no-dev --group test --extra ${{ matrix.gfx }} --extra ${{matrix.canvas}} ${{ matrix.python-version != '3.10' && '--extra imgui' || '' }} ${{ matrix.canvas == 'pyqt' && '--group testqt' || '' }}

- name: Test
shell: bash
Expand All @@ -70,7 +94,7 @@ jobs:
- name: Upload coverage
uses: actions/upload-artifact@v4
with:
name: covreport-${{ matrix.os }}-py${{ matrix.python-version }}-${{ matrix.gfx }}
name: covreport-${{ matrix.os }}-py${{ matrix.python-version }}-${{ matrix.gfx }}-${{ matrix.canvas }}
path: ./.coverage*
include-hidden-files: true

Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -109,3 +109,5 @@ ENV/
# IDE settings
.vscode/
.idea/

uv.lock
8 changes: 4 additions & 4 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -27,26 +27,26 @@ repos:
language: system
types_or: [python, pyi]
require_serial: true
entry: uv run ruff format --force-exclude
entry: uv run --active ruff format --force-exclude
- id: ruff-check
name: ruff check
language: system
types_or: [python, pyi]
require_serial: true
entry: uv run ruff check --force-exclude
entry: uv run --active ruff check --force-exclude
args: [--output-format=full, --show-fixes, --exit-non-zero-on-fix]
- id: mypy
name: mypy
language: system
types_or: [python, pyi]
require_serial: true
entry: uv run mypy
entry: uv run --active mypy
- id: pyright
name: pyright
language: system
types_or: [python, pyi]
require_serial: true
entry: uv run pyright
entry: uv run --active pyright

- repo: https://github.com/crate-ci/typos
rev: v1.34.0
Expand Down
92 changes: 92 additions & 0 deletions examples/basic_mesh.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import cmap
import numpy as np

import scenex as snx
from scenex.app.events import (
Event,
MouseButton,
MouseMoveEvent,
MousePressEvent,
)
from scenex.utils.controllers import PanZoomController


# Create a more complex mesh - a grid of vertices
def create_grid_mesh(
size: int = 10, spacing: float = 0.2
) -> tuple[np.ndarray, np.ndarray]:
"""Create a grid mesh with given size and spacing."""
vertices = []
faces = []

# Create vertices in a grid
for i in range(size):
for j in range(size):
x = i * spacing
y = j * spacing
z = 0.0
vertices.append([x, y, z])

# Create triangular faces
for i in range(size - 1):
for j in range(size - 1):
# Current vertex indices
v0 = i * size + j
v1 = i * size + (j + 1)
v2 = (i + 1) * size + j
v3 = (i + 1) * size + (j + 1)

# Two triangles per grid square
faces.append([v0, v1, v2])
faces.append([v1, v3, v2])

return np.array(vertices), np.array(faces)


# Create the mesh
original_vertices, original_faces = create_grid_mesh(size=15, spacing=0.15)

mesh = snx.Mesh(
vertices=original_vertices, faces=original_faces, color=cmap.Color("cyan")
)

view = snx.View(
scene=snx.Scene(children=[mesh]),
camera=snx.Camera(controller=PanZoomController(), interactive=True),
)


def event_filter(event: Event) -> bool:
"""Interactive mesh manipulation based on mouse events."""
if isinstance(event, MouseMoveEvent):
if intersections := event.world_ray.intersections(view.scene):
# Find mesh intersection
for node, _distance in intersections:
if isinstance(node, snx.Mesh):
# Remove the intersected face
indices = [i for i, _d in node.intersecting_faces(event.world_ray)]
node.faces = np.delete(node.faces, indices, axis=0)
return True
elif isinstance(event, MousePressEvent):
if event.buttons & MouseButton.LEFT:
# Reset the mesh on click
mesh.vertices = original_vertices.copy()
mesh.faces = original_faces.copy()
return True

return False


# Set up the event filter
view.set_event_filter(event_filter)

# Show and position camera
snx.use("vispy")
snx.show(view)

print("Interactive Mesh Demo:")
print("- Move mouse over mesh to delete intersected faces")
print("- Left click to reset all faces")
print("- Use mouse to pan/zoom the camera")

snx.run()
3 changes: 3 additions & 0 deletions examples/basic_scene.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import numpy as np

import scenex as snx
from scenex.utils.controllers import PanZoomController

try:
from scenex.imgui import add_imgui_controls
Expand All @@ -28,6 +29,7 @@
),
]
),
camera=snx.Camera(controller=PanZoomController(), interactive=True),
)

# example of adding an object to a scene
Expand All @@ -41,6 +43,7 @@
# snx.use("vispy")

snx.show(view)

if add_imgui_controls is not None:
add_imgui_controls(view)
snx.run()
24 changes: 22 additions & 2 deletions examples/basic_volume.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import numpy as np

import scenex as snx
from scenex.model._transform import Transform
from scenex.utils import projections
from scenex.utils.controllers import OrbitController

try:
from imageio.v2 import volread
Expand All @@ -16,11 +19,28 @@
snx.Volume(
data=data,
clims=(data.min(), data.max()),
),
)
]
),
camera=snx.Camera(type="perspective"),
camera=snx.Camera(interactive=True),
)

snx.use("vispy")
snx.show(view)

# Orbit around the center of the volume
orbit_center = np.mean(np.asarray(view.scene.bounding_box), axis=0)

# Place the camera along the x axis, looking at the orbit center
view.camera.transform = Transform().translated(orbit_center).translated((300, 0, 0))
view.camera.look_at(orbit_center, up=(0, 0, 1))
# Perspective projection for 3D
view.camera.projection = projections.perspective(
fov=70,
near=1,
far=1_000_000, # Just need something big
)
view.camera.set_event_filter(OrbitController(orbit_center))


snx.run()
96 changes: 96 additions & 0 deletions examples/blending.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
"""An example demonstrating different blend modes.

Unaltered, each channel of the volume is blended additively, resulting in transparency.

By clicking on the volume, the blend mode will alternate to an opaque strategy. In this
case, fragments written by the nuclei channel will always be visible over fragments
written by the membranes channel (because its draw order is higher).
"""

import cmap
import numpy as np

import scenex as snx
import scenex.model
from scenex.app.events import Event, MousePressEvent
from scenex.model._transform import Transform
from scenex.utils import projections
from scenex.utils.controllers import OrbitController

try:
from imageio.v2 import volread

url = "https://gitlab.com/scikit-image/data/-/raw/2cdc5ce89b334d28f06a58c9f0ca21aa6992a5ba/cells3d.tif"
data = np.asarray(volread(url)).astype(np.uint16)[:, :, :, :]
except ImportError:
data = np.random.randint(0, 2, (3, 2, 3, 3)).astype(np.uint16)

data1 = data[:, 0, :, :]
volume1 = snx.Volume(
data=data1,
cmap=cmap.Colormap("green"),
blending=scenex.model.BlendMode.ADDITIVE,
clims=(data1.min(), data1.max()),
opacity=0.7,
order=1,
name="Cell membranes",
interactive=True,
)
data2 = data[:, 1, :, :]
volume2 = snx.Volume(
data=data2,
blending=scenex.model.BlendMode.ADDITIVE,
cmap=cmap.Colormap("magenta"),
clims=(data2.min(), data2.max()),
opacity=0.7,
order=2,
name="Cell nuclei",
interactive=True,
transform=Transform().translated((0, 0, 30)),
)

view = snx.View(
scene=snx.Scene(
children=[volume1, volume2],
interactive=True,
),
camera=snx.Camera(interactive=True),
)

blend_modes = list(scenex.model.BlendMode)


def change_blend_mode(event: Event, node: snx.Node) -> bool:
"""Change the blend mode of a volume when it is clicked."""
if isinstance(event, MousePressEvent):
idx = blend_modes.index(node.blending)
next_idx = (idx + 1) % len(blend_modes)

print(f"Changing blend mode to {blend_modes[next_idx]}")

volume1.blending = blend_modes[next_idx]
volume2.blending = blend_modes[next_idx]
return False


volume1.set_event_filter(change_blend_mode)


snx.use("vispy")
snx.show(view)

# Orbit around the center of the volume
orbit_center = np.mean(np.asarray(view.scene.bounding_box), axis=0)

# Place the camera along the x axis, looking at the orbit center
view.camera.transform = Transform().translated(orbit_center).translated((300, 0, 0))
view.camera.look_at(orbit_center, up=(0, 0, 1))
# Perspective projection for 3D
view.camera.projection = projections.perspective(
fov=70,
near=1,
far=1_000_000, # Just need something big
)
view.camera.set_event_filter(OrbitController(orbit_center))

snx.run()
Loading