From d8aa1234424d9986cadce6d22220518d538c3b1d Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Tue, 21 Jan 2025 13:00:29 -0500 Subject: [PATCH] improve pygfx --- src/ndv/models/_scene/canvas.py | 2 +- src/ndv/models/_scene/nodes/camera.py | 11 ++-- src/ndv/models/_scene/nodes/points.py | 4 +- src/ndv/models/_scene/nodes/scene.py | 4 +- src/ndv/models/_scene/view.py | 35 +++++++---- src/ndv/views/_scene/pygfx/__init__.py | 3 +- src/ndv/views/_scene/pygfx/_camera.py | 44 +++++++++----- src/ndv/views/_scene/pygfx/_canvas.py | 83 ++++++++------------------ src/ndv/views/_scene/pygfx/_image.py | 53 +++++++++------- src/ndv/views/_scene/pygfx/_node.py | 18 +++--- src/ndv/views/_scene/pygfx/_points.py | 80 +++++++++++++++++++++++++ src/ndv/views/_scene/pygfx/_scene.py | 10 +++- src/ndv/views/_scene/pygfx/_view.py | 65 ++++++++++---------- x.py | 25 ++++---- 14 files changed, 271 insertions(+), 166 deletions(-) create mode 100644 src/ndv/views/_scene/pygfx/_points.py diff --git a/src/ndv/models/_scene/canvas.py b/src/ndv/models/_scene/canvas.py index bcd9fe3..1968c90 100644 --- a/src/ndv/models/_scene/canvas.py +++ b/src/ndv/models/_scene/canvas.py @@ -76,7 +76,7 @@ def size(self) -> tuple[int, int]: return self.width, self.height @size.setter - def size(self, value: tuple[float, float]) -> None: + def size(self, value: tuple[int, int]) -> None: """Set the size of the canvas.""" self.width, self.height = value diff --git a/src/ndv/models/_scene/nodes/camera.py b/src/ndv/models/_scene/nodes/camera.py index 2b1b8b0..fb9529d 100644 --- a/src/ndv/models/_scene/nodes/camera.py +++ b/src/ndv/models/_scene/nodes/camera.py @@ -40,9 +40,8 @@ class Camera(Node["CameraAdaptorProtocol"]): ) def _set_range(self, margin: float = 0) -> None: - if self.has_backend_adaptor("vispy"): - adaptor = self.backend_adaptor() - # TODO: this method should probably be pulled off of the backend, - # calculated directly in the core, and then applied as a change to the - # camera transform - adaptor._vis_set_range(margin=margin) + adaptor = self.backend_adaptor() + # TODO: this method should probably be pulled off of the backend, + # calculated directly in the core, and then applied as a change to the + # camera transform + adaptor._vis_set_range(margin=margin) diff --git a/src/ndv/models/_scene/nodes/points.py b/src/ndv/models/_scene/nodes/points.py index 7501936..49867cb 100644 --- a/src/ndv/models/_scene/nodes/points.py +++ b/src/ndv/models/_scene/nodes/points.py @@ -52,6 +52,7 @@ def _vis_set_opacity(self, opacity: float) -> None: ... "star", "cross_lines", ] +ScalingMode = Literal[True, False, "fixed", "scene", "visual"] class Points(Node[PointsBackend]): @@ -72,7 +73,8 @@ class Points(Node[PointsBackend]): symbol: SymbolName = Field( default="disc", description="The symbol to use for the points." ) - scaling: Literal[True, False, "fixed", "scene", "visual"] = Field( + # TODO: these are vispy-specific names. Determine more general names + scaling: ScalingMode = Field( default=True, description="Determines how points scale when zooming." ) diff --git a/src/ndv/models/_scene/nodes/scene.py b/src/ndv/models/_scene/nodes/scene.py index 43255e5..78c941d 100644 --- a/src/ndv/models/_scene/nodes/scene.py +++ b/src/ndv/models/_scene/nodes/scene.py @@ -1,9 +1,9 @@ from typing import Literal -from .node import Node +from .node import Node, NodeAdaptorProtocol -class Scene(Node): +class Scene(Node[NodeAdaptorProtocol]): """A Root node for a scene graph. This really isn't anything more than a regular Node, but it's an explicit diff --git a/src/ndv/models/_scene/view.py b/src/ndv/models/_scene/view.py index 3c32aad..ac4aaa4 100644 --- a/src/ndv/models/_scene/view.py +++ b/src/ndv/models/_scene/view.py @@ -5,7 +5,7 @@ from typing import TYPE_CHECKING, Any, Protocol, TypeVar from cmap import Color -from pydantic import ConfigDict, Field +from pydantic import ConfigDict, Field, PrivateAttr, computed_field from ._vis_model import ModelBase, SupportsVisibility, VisModel from .nodes import Camera, Scene @@ -121,10 +121,31 @@ class View(VisModel[ViewAdaptorProtocol]): model_config = ConfigDict(repr_exclude_defaults=False) # type: ignore + _canvas: Canvas | None = PrivateAttr(None) + def model_post_init(self, __context: Any) -> None: super().model_post_init(__context) + self.camera.parent = self.scene self.layout.events.connect(self._on_layout_event) + @computed_field # type: ignore + @property + def canvas(self) -> Canvas: + """The canvas that the view is on. + + If one hasn't been created/assigned, a new one is created. + """ + if (canvas := self._canvas) is None: + from .canvas import Canvas + + self.canvas = canvas = Canvas() + return canvas + + @canvas.setter + def canvas(self, value: Canvas) -> None: + self._canvas = value + self._canvas.add_view(self) + def _on_layout_event(self, info: EmissionInfo) -> None: _signal_name = info.signal.name ... @@ -135,15 +156,9 @@ def show(self) -> Canvas: Convenience method for showing the canvas that the view is on. If no canvas exists, a new one is created. """ - if not hasattr(self, "_canvas"): - from .canvas import Canvas - - # TODO: we need to know/check somehow if the view is already on a canvas - # This just creates a new canvas every time - self._canvas = Canvas() - self._canvas.add_view(self) - self._canvas.show() - return self._canvas + canvas = self.canvas + canvas.show() + return self.canvas def add_node(self, node: NodeType) -> NodeType: """Add any node to the scene.""" diff --git a/src/ndv/views/_scene/pygfx/__init__.py b/src/ndv/views/_scene/pygfx/__init__.py index 3ff7c98..eadae64 100644 --- a/src/ndv/views/_scene/pygfx/__init__.py +++ b/src/ndv/views/_scene/pygfx/__init__.py @@ -2,7 +2,8 @@ from ._canvas import Canvas from ._image import Image from ._node import Node +from ._points import Points from ._scene import Scene from ._view import View -__all__ = ["Camera", "Canvas", "Image", "Node", "Scene", "View"] +__all__ = ["Camera", "Canvas", "Image", "Node", "Points", "Scene", "View"] diff --git a/src/ndv/views/_scene/pygfx/_camera.py b/src/ndv/views/_scene/pygfx/_camera.py index c7a54f4..750dbd9 100644 --- a/src/ndv/views/_scene/pygfx/_camera.py +++ b/src/ndv/views/_scene/pygfx/_camera.py @@ -1,8 +1,8 @@ from __future__ import annotations -import warnings -from typing import Any +from typing import Any, cast +import numpy as np import pygfx from ndv._types import CameraType @@ -14,18 +14,19 @@ class Camera(Node, camera.CameraAdaptorProtocol): """Adaptor for pygfx camera.""" - _pygfx_node: pygfx.Camera + _pygfx_node: pygfx.PerspectiveCamera + pygfx_controller: pygfx.Controller def __init__(self, camera: camera.Camera, **backend_kwargs: Any) -> None: + self._camera_model = camera if camera.type == CameraType.PANZOOM: - self._pygfx_node = pygfx.OrthographicCamera(**backend_kwargs) - self.controller = pygfx.PanZoomController(self._pygfx_node) + self._pygfx_node = pygfx.OrthographicCamera() + self.pygfx_controller = pygfx.PanZoomController(self._pygfx_node) elif camera.type == CameraType.ARCBALL: - self._pygfx_node = pygfx.PerspectiveCamera(**backend_kwargs) - self.controller = pygfx.OrbitOrthoController(self._pygfx_node) + self._pygfx_node = pygfx.PerspectiveCamera(70, 4 / 3) + self.pygfx_controller = pygfx.OrbitController(self._pygfx_node) - # FIXME: hardcoded - # self._pygfx_cam.scale.y = -1 + self._pygfx_node.local.scale_y = -1 # don't think this is working... def _vis_set_zoom(self, zoom: float) -> None: raise NotImplementedError @@ -44,14 +45,29 @@ def update_controller(self) -> None: # This is called by the View Adaptor in the `_visit` method # ... which is in turn called by the Canvas backend adaptor's `_animate` method # i.e. the main render loop. - self.controller.update_camera(self._pygfx_node) + self.pygfx_controller.update_camera(self._pygfx_node) def set_viewport(self, viewport: pygfx.Viewport) -> None: # This is used by the Canvas backend adaptor... # and should perhaps be moved to the View Adaptor - self.controller.add_default_event_handlers(viewport, self._pygfx_node) + self.pygfx_controller.add_default_event_handlers(viewport, self._pygfx_node) def _vis_set_range(self, margin: float) -> None: - warnings.warn( - "set_range not implemented for pygfx", RuntimeWarning, stacklevel=2 - ) + # reset camera to fit all objects + if not (scene := self._camera_model.parent): + print("No scene found for camera") + return + + py_scene = cast("pygfx.Scene", scene.backend_adaptor("pygfx")._vis_get_native()) + cam = self._pygfx_node + cam.show_object(py_scene) + + if (bb := py_scene.get_world_bounding_box()) is not None: + width, height, _depth = np.ptp(bb, axis=0) + if width < 0.01: + width = 1 + if height < 0.01: + height = 1 + cam.width = width + cam.height = height + cam.zoom = 1 - margin diff --git a/src/ndv/views/_scene/pygfx/_canvas.py b/src/ndv/views/_scene/pygfx/_canvas.py index 19d18f7..463ca57 100644 --- a/src/ndv/views/_scene/pygfx/_canvas.py +++ b/src/ndv/views/_scene/pygfx/_canvas.py @@ -1,84 +1,51 @@ from __future__ import annotations -from contextlib import suppress -from typing import TYPE_CHECKING, Any, Union, cast - -import pygfx +from typing import TYPE_CHECKING, Any, cast from ndv.models import _scene as core if TYPE_CHECKING: import numpy as np from cmap import Color - from typing_extensions import TypeAlias - from wgpu.gui import glfw, jupyter, offscreen, qt + from rendercanvas.auto import RenderCanvas from ._view import View - # from wgpu.gui.auto import WgpuCanvas - # ... will result in one of the following canvas classes - TypeWgpuCanvasType: TypeAlias = Union[ - type[offscreen.WgpuCanvas], # if WGPU_FORCE_OFFSCREEN=1 - type[jupyter.WgpuCanvas], # if is_jupyter() - type[glfw.WgpuCanvas], # if glfw is installed - type[qt.WgpuCanvas], # if any pyqt backend is installed - ] - # TODO: lol... there's probably a better way to do this :) - WgpuCanvasType: TypeAlias = Union[ - offscreen.WgpuCanvas, # if WGPU_FORCE_OFFSCREEN=1 - jupyter.WgpuCanvas, # if is_jupyter() - glfw.WgpuCanvas, # if glfw is installed - qt.WgpuCanvas, # if any pyqt backend is installed - ] - class Canvas(core.canvas.CanvasAdaptorProtocol): """Canvas interface for pygfx Backend.""" def __init__(self, canvas: core.Canvas, **backend_kwargs: Any) -> None: - # wgpu.gui.auto.WgpuCanvas is a "magic" import that itself is context sensitive - # see TYPE_CHECKING section above for details - from wgpu.gui.auto import WgpuCanvas - - WgpuCanvas = cast("TypeWgpuCanvasType", WgpuCanvas) + from rendercanvas.auto import RenderCanvas - canvas = WgpuCanvas(size=canvas.size, title=canvas.title, **backend_kwargs) - self._wgpu_canvas = cast("WgpuCanvasType", canvas) - # TODO: background_color - # the qt backend, this shows by default... - # if we need to prevent it, we could potentially monkeypatch during init. + self._wgpu_canvas = RenderCanvas() + # Qt RenderCanvas calls show() in its __init__ method, so we need to hide it if hasattr(self._wgpu_canvas, "hide"): self._wgpu_canvas.hide() - self._renderer = pygfx.renderers.WgpuRenderer(self._wgpu_canvas) - self._viewport: pygfx.Viewport = pygfx.Viewport(self._renderer) - self._views: list[View] = [] - # self._grid: dict[tuple[int, int], View] = {} + self._wgpu_canvas.set_logical_size(canvas.width, canvas.height) + self._wgpu_canvas.set_title(canvas.title) + self._views = canvas.views - def _vis_get_native(self) -> WgpuCanvasType: + def _vis_get_native(self) -> RenderCanvas: return self._wgpu_canvas def _vis_set_visible(self, arg: bool) -> None: + # show the qt canvas we patched earlier in __init__ if hasattr(self._wgpu_canvas, "show"): self._wgpu_canvas.show() - self._wgpu_canvas.request_draw(self._animate) + self._wgpu_canvas.request_draw(self._draw) - def _animate(self, viewport: pygfx.Viewport | None = None) -> None: - vp = viewport or self._viewport + def _draw(self) -> None: for view in self._views: - view._visit(vp) - if hasattr(vp.renderer, "flush"): - # an attribute error can occur if flush() is called before render() - # https://github.com/pygfx/pygfx/issues/946 - with suppress(AttributeError): - vp.renderer.flush() - if viewport is None: - self._wgpu_canvas.request_draw() + adaptor = cast("View", view.backend_adaptor("pygfx")) + adaptor._draw() def _vis_add_view(self, view: core.View) -> None: - adaptor = cast("View", view.backend_adaptor()) + pass + # adaptor = cast("View", view.backend_adaptor()) # adaptor._pygfx_cam.set_viewport(self._viewport) - self._views.append(adaptor) + # self._views.append(adaptor) def _vis_set_width(self, arg: int) -> None: _, height = self._wgpu_canvas.get_logical_size() @@ -88,11 +55,12 @@ def _vis_set_height(self, arg: int) -> None: width, _ = self._wgpu_canvas.get_logical_size() self._wgpu_canvas.set_logical_size(width, arg) - def _vis_set_background_color(self, arg: Color | None) -> None: - raise NotImplementedError() + def _vis_set_background_color(self, arg: Color) -> None: + # not sure if pygfx has both a canavs and view background color... + pass def _vis_set_title(self, arg: str) -> None: - raise NotImplementedError() + self._wgpu_canvas.set_title(arg) def _vis_close(self) -> None: """Close canvas.""" @@ -107,11 +75,10 @@ def _vis_render( alpha: bool = True, ) -> np.ndarray: """Render to screenshot.""" - from wgpu.gui.offscreen import WgpuCanvas + from rendercanvas.offscreen import OffscreenRenderCanvas + # not sure about this... w, h = self._wgpu_canvas.get_logical_size() - canvas = WgpuCanvas(width=w, height=h, pixel_ratio=1) - renderer = pygfx.renderers.WgpuRenderer(canvas) - viewport = pygfx.Viewport(renderer) - canvas.request_draw(lambda: self._animate(viewport)) + canvas = OffscreenRenderCanvas(width=w, height=h, pixel_ratio=1) + canvas.request_draw(self._draw) return cast("np.ndarray", canvas.draw()) diff --git a/src/ndv/views/_scene/pygfx/_image.py b/src/ndv/views/_scene/pygfx/_image.py index d61767e..3c027a2 100644 --- a/src/ndv/views/_scene/pygfx/_image.py +++ b/src/ndv/views/_scene/pygfx/_image.py @@ -1,15 +1,18 @@ from __future__ import annotations +import warnings from typing import TYPE_CHECKING, Any import pygfx +from ndv._types import ImageInterpolation + from ._node import Node if TYPE_CHECKING: from cmap import Colormap - from ndv._types import ArrayLike, ImageInterpolation + from ndv._types import ArrayLike from ndv.models._scene import nodes @@ -18,36 +21,44 @@ class Image(Node): _pygfx_node: pygfx.Image _material: pygfx.ImageBasicMaterial + _geometry: pygfx.Geometry def __init__(self, image: nodes.Image, **backend_kwargs: Any) -> None: - if (data := image.data) is not None: - dim = data.ndim - if dim > 2 and data.shape[-1] <= 4: - dim -= 1 # last array dim is probably (a subset of) rgba - else: - dim = 2 - # TODO: unclear whether get_view() is better here... - self._texture = pygfx.Texture(data, dim=dim) - self._geometry = pygfx.Geometry(grid=self._texture) - self._material = pygfx.ImageBasicMaterial( - clim=image.clims, - # map=str(image.cmap), # TODO: map needs to be a TextureView - ) - # TODO: gamma? - # TODO: interpolation? + self._vis_set_data(image.data) + self._material = pygfx.ImageBasicMaterial(clim=image.clims) self._pygfx_node = pygfx.Image(self._geometry, self._material) def _vis_set_cmap(self, arg: Colormap) -> None: - self._material.map = arg + self._material.map = arg.to_pygfx() def _vis_set_clims(self, arg: tuple[float, float] | None) -> None: self._material.clim = arg def _vis_set_gamma(self, arg: float) -> None: - raise NotImplementedError + warnings.warn( + "Gamma correction not supported by pygfx", RuntimeWarning, stacklevel=2 + ) def _vis_set_interpolation(self, arg: ImageInterpolation) -> None: - raise NotImplementedError + if arg is ImageInterpolation.BICUBIC: + warnings.warn( + "Bicubic interpolation not supported by pygfx", + RuntimeWarning, + stacklevel=2, + ) + arg = ImageInterpolation.LINEAR + self._material.interpolation = arg.value - def _vis_set_data(self, arg: ArrayLike) -> None: - raise NotImplementedError + def _create_texture(self, data: ArrayLike) -> pygfx.Texture: + if data is not None: + dim = data.ndim + if dim > 2 and data.shape[-1] <= 4: + dim -= 1 # last array dim is probably (a subset of) rgba + else: + dim = 2 + # TODO: unclear whether get_view() is better here... + return pygfx.Texture(data, dim=dim) + + def _vis_set_data(self, data: ArrayLike) -> None: + self._texture = self._create_texture(data) + self._geometry = pygfx.Geometry(grid=self._texture) diff --git a/src/ndv/views/_scene/pygfx/_node.py b/src/ndv/views/_scene/pygfx/_node.py index 1aeb5cb..265754c 100644 --- a/src/ndv/views/_scene/pygfx/_node.py +++ b/src/ndv/views/_scene/pygfx/_node.py @@ -1,6 +1,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any +import warnings +from typing import TYPE_CHECKING, Any, cast from ndv.models._scene.nodes import node as core_node @@ -30,31 +31,34 @@ def _vis_set_name(self, arg: str) -> None: self._name = arg def _vis_set_parent(self, arg: core_node.Node | None) -> None: - raise NotImplementedError + warnings.warn("Parenting not implemented in pygfx backend", stacklevel=2) def _vis_set_children(self, arg: list[core_node.Node]) -> None: # This is probably redundant with _vis_add_node # could maybe be a clear then add *arg - raise NotImplementedError + warnings.warn("Parenting not implemented in pygfx backend", stacklevel=2) def _vis_set_visible(self, arg: bool) -> None: self._pygfx_node.visible = arg def _vis_set_opacity(self, arg: float) -> None: - self._material.opacity = arg + if material := getattr(self, "_material", None): + material.opacity = arg def _vis_set_order(self, arg: int) -> None: self._pygfx_node.render_order = arg def _vis_set_interactive(self, arg: bool) -> None: # this one requires knowledge of the controller - raise NotImplementedError + warnings.warn("interactive not implemented in pygfx backend", stacklevel=2) def _vis_set_transform(self, arg: Transform) -> None: - self._pygfx_node.matrix = arg.root # TODO: check this + self._pygfx_node.local.matrix = arg.root def _vis_add_node(self, node: core_node.Node) -> None: - self._pygfx_node.add(node.backend_adaptor("pygfx")._vis_get_native()) + # create if it doesn't exist + adaptor = cast("Node", node.backend_adaptor("pygfx")) + self._pygfx_node.add(adaptor._vis_get_native()) def _vis_force_update(self) -> None: pass diff --git a/src/ndv/views/_scene/pygfx/_points.py b/src/ndv/views/_scene/pygfx/_points.py new file mode 100644 index 0000000..d5cd063 --- /dev/null +++ b/src/ndv/views/_scene/pygfx/_points.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Literal + +import numpy as np +import pygfx + +from ._node import Node + +if TYPE_CHECKING: + from collections.abc import Mapping + + import numpy.typing as npt + from cmap import Color + + from ndv.models._scene import nodes + from ndv.models._scene.nodes.points import ScalingMode + +SPACE_MAP: Mapping[ScalingMode, Literal["model", "screen", "world"]] = { + True: "world", + False: "screen", + "fixed": "screen", + "scene": "world", + "visual": "model", +} + + +class Points(Node): + """Vispy backend adaptor for an Points node.""" + + _pygfx_node: pygfx.Points + + def __init__(self, points: nodes.Points, **backend_kwargs: Any) -> None: + # TODO: unclear whether get_view() is better here... + coords = np.asarray(points.coords) + n_coords = len(coords) + + # ensure (N, 3) + if coords.shape[1] == 2: + coords = np.column_stack((coords, np.zeros(coords.shape[0]))) + + geo_kwargs = {} + if points.face_color is not None: + colors = np.tile(np.asarray(points.face_color), (n_coords, 1)) + geo_kwargs["colors"] = colors.astype(np.float32) + + # TODO: not sure whether/how pygfx implements all the other properties + + self._geometry = pygfx.Geometry( + positions=coords.astype(np.float32), + sizes=np.full(n_coords, points.size, dtype=np.float32), + **geo_kwargs, + ) + self._material = pygfx.PointsMaterial( + size=points.size, + size_space=SPACE_MAP[points.scaling], + aa=points.antialias > 0, + opacity=points.opacity, + color_mode="vertex", + size_mode="vertex", + ) + self._pygfx_node = pygfx.Points(self._geometry, self._material) + + def _vis_set_coords(self, coords: npt.NDArray) -> None: ... + + def _vis_set_size(self, size: float) -> None: ... + + def _vis_set_face_color(self, face_color: Color) -> None: ... + + def _vis_set_edge_color(self, edge_color: Color) -> None: ... + + def _vis_set_edge_width(self, edge_width: float) -> None: ... + + def _vis_set_symbol(self, symbol: str) -> None: ... + + def _vis_set_scaling(self, scaling: str) -> None: ... + + def _vis_set_antialias(self, antialias: float) -> None: ... + + def _vis_set_opacity(self, opacity: float) -> None: ... diff --git a/src/ndv/views/_scene/pygfx/_scene.py b/src/ndv/views/_scene/pygfx/_scene.py index 077b56c..f49672d 100644 --- a/src/ndv/views/_scene/pygfx/_scene.py +++ b/src/ndv/views/_scene/pygfx/_scene.py @@ -1,6 +1,6 @@ from typing import Any -from pygfx.objects import Scene as _Scene +import pygfx from ndv.models import _scene as core @@ -8,10 +8,14 @@ class Scene(Node): + _pygfx_node: pygfx.Scene + def __init__(self, scene: core.Scene, **backend_kwargs: Any) -> None: - self._pygfx_node = _Scene(visible=scene.visible, **backend_kwargs) + self._pygfx_node = pygfx.Scene(visible=scene.visible, **backend_kwargs) self._pygfx_node.render_order = scene.order + # Almar does this in Display.show... + self._pygfx_node.add(pygfx.AmbientLight()) + for node in scene.children: - node.backend_adaptor() # create backend adaptor if it doesn't exist self._vis_add_node(node) diff --git a/src/ndv/views/_scene/pygfx/_view.py b/src/ndv/views/_scene/pygfx/_view.py index e6c660c..f0a3cac 100644 --- a/src/ndv/views/_scene/pygfx/_view.py +++ b/src/ndv/views/_scene/pygfx/_view.py @@ -1,47 +1,54 @@ from __future__ import annotations import warnings -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast import pygfx from ndv.models import _scene as core -from ._node import Node - if TYPE_CHECKING: from cmap import Color -# FIXME: I broke this in the last commit. attribute errors all over + from . import _camera, _canvas, _scene + +class View(core.view.ViewAdaptorProtocol): + """View interface for pygfx Backend. -class View(Node, core.view.ViewAdaptorProtocol): - """View interface for pygfx Backend.""" + A view combines a scene and a camera to render a scene (onto a canvas). + """ - # _native: scene.ViewBox - # TODO: i think pygfx doesn't see a view as part of the scene like vispy does + _pygfx_scene: pygfx.Scene _pygfx_cam: pygfx.Camera def __init__(self, view: core.View, **backend_kwargs: Any) -> None: - # FIXME: hardcoded camera and scene - self.scene = pygfx.Scene() + canvas_adaptor = cast("_canvas.Canvas", view.canvas.backend_adaptor("pygfx")) + wgpu_canvas = canvas_adaptor._vis_get_native() + self._renderer = pygfx.renderers.WgpuRenderer(wgpu_canvas) - # XXX: both of these methods deserve scrutiny, and fixing :) - def _vis_set_camera(self, cam: core.Camera) -> None: - pygfx_cam = cam.backend_adaptor("pygfx")._vis_get_native() + self._vis_set_scene(view.scene) + self._vis_set_camera(view.camera) - if not isinstance(pygfx_cam, pygfx.Camera): - raise TypeError(f"cam must be a pygfx.Camera, got {type(pygfx_cam)}") - self._pygfx_cam = pygfx_cam + def _vis_get_native(self) -> pygfx.Viewport: + return pygfx.Viewport(self._renderer) + + def _vis_set_visible(self, arg: bool) -> None: + pass def _vis_set_scene(self, scene: core.Scene) -> None: - # XXX: Tricky! this call to scene.native actually has the side effect of - # creating the backend adaptor for the scene! That needs to be more explicit. - pygfx_scene = scene.backend_adaptor("pygfx")._vis_get_native() + self._scene_adaptor = cast("_scene.Scene", scene.backend_adaptor("pygfx")) + self._pygfx_scene = self._scene_adaptor._pygfx_node - if not isinstance(pygfx_scene, pygfx.Scene): - raise TypeError("Scene must be a pygfx.Scene") - self.scene = pygfx_scene + def _vis_set_camera(self, cam: core.Camera) -> None: + self._cam_adaptor = cast("_camera.Camera", cam.backend_adaptor("pygfx")) + self._pygfx_cam = self._cam_adaptor._pygfx_node + self._cam_adaptor.pygfx_controller.register_events(self._renderer) + + def _draw(self) -> None: + renderer = self._renderer + renderer.render(self._pygfx_scene, self._pygfx_cam) + renderer.request_draw() def _vis_set_position(self, arg: tuple[float, float]) -> None: warnings.warn( @@ -53,12 +60,10 @@ def _vis_set_size(self, arg: tuple[float, float] | None) -> None: "set_size not implemented for pygfx", RuntimeWarning, stacklevel=2 ) - def _vis_set_background_color(self, arg: Color | None) -> None: - warnings.warn( - "set_background_color not implemented for pygfx", - RuntimeWarning, - stacklevel=2, - ) + def _vis_set_background_color(self, color: Color | None) -> None: + colors = (color.rgba,) if color is not None else () + background = pygfx.Background(None, material=pygfx.BackgroundMaterial(*colors)) + self._pygfx_scene.add(background) def _vis_set_border_width(self, arg: float) -> None: warnings.warn( @@ -79,7 +84,3 @@ def _vis_set_margin(self, arg: int) -> None: warnings.warn( "set_margin not implemented for pygfx", RuntimeWarning, stacklevel=2 ) - - def _visit(self, viewport: pygfx.Viewport) -> None: - viewport.render(self.scene, self._pygfx_cam) - self._pygfx_cam.update_controller() diff --git a/x.py b/x.py index edec347..ae6dff5 100644 --- a/x.py +++ b/x.py @@ -1,3 +1,5 @@ +from contextlib import suppress + import numpy as np from rich import print @@ -9,25 +11,28 @@ _app.ndv_app() img1 = Image( - name="Some Image", data=np.random.randint(0, 255, (100, 100)).astype(np.uint8) + name="Some Image", + data=np.random.randint(0, 255, (100, 100)).astype(np.uint8), + clims=(0, 255), ) img2 = Image( data=np.random.randint(0, 255, (200, 200)).astype(np.uint8), cmap="viridis", transform=Transform().scaled((0.7, 0.5)).translated((-10, 20)), + clims=(0, 255), ) scene = Scene(children=[img1, img2]) -points = Points( - coords=np.random.randint(0, 200, (100, 2)), - size=5, - face_color="blue", - edge_color="yellow", - edge_width=0.5, - opacity=0.1, -) -scene.children.insert(0, points) +with suppress(Exception): + points = Points( + coords=np.random.randint(0, 200, (100, 2)).astype(np.uint8), + size=5, + face_color="coral", + edge_color="blue", + opacity=0.8, + ) + scene.children.insert(0, points) view = View(scene=scene)