From a9006654793b4352914460193fcd042d275f48e3 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Tue, 21 Jan 2025 09:14:38 -0500 Subject: [PATCH] add pygfx stub --- src/ndv/models/_scene/_transform.py | 4 +- src/ndv/models/_scene/_vis_model.py | 36 ++++++-- src/ndv/models/_scene/canvas.py | 11 ++- src/ndv/models/_scene/nodes/camera.py | 3 +- src/ndv/models/_scene/nodes/node.py | 14 ++- src/ndv/models/_sequence.py | 5 +- src/ndv/views/_scene/pygfx/__init__.py | 8 ++ src/ndv/views/_scene/pygfx/_camera.py | 57 ++++++++++++ src/ndv/views/_scene/pygfx/_canvas.py | 117 +++++++++++++++++++++++++ src/ndv/views/_scene/pygfx/_image.py | 53 +++++++++++ src/ndv/views/_scene/pygfx/_node.py | 66 ++++++++++++++ src/ndv/views/_scene/pygfx/_scene.py | 17 ++++ src/ndv/views/_scene/pygfx/_view.py | 85 ++++++++++++++++++ src/ndv/views/_scene/vispy/_node.py | 20 ++--- src/ndv/views/_scene/vispy/_view.py | 2 + x.py | 11 ++- 16 files changed, 471 insertions(+), 38 deletions(-) create mode 100644 src/ndv/views/_scene/pygfx/__init__.py create mode 100644 src/ndv/views/_scene/pygfx/_camera.py create mode 100644 src/ndv/views/_scene/pygfx/_canvas.py create mode 100644 src/ndv/views/_scene/pygfx/_image.py create mode 100644 src/ndv/views/_scene/pygfx/_node.py create mode 100644 src/ndv/views/_scene/pygfx/_scene.py create mode 100644 src/ndv/views/_scene/pygfx/_view.py diff --git a/src/ndv/models/_scene/_transform.py b/src/ndv/models/_scene/_transform.py index bac78a0..4a5f941 100644 --- a/src/ndv/models/_scene/_transform.py +++ b/src/ndv/models/_scene/_transform.py @@ -7,11 +7,9 @@ import numpy as np from numpy.typing import ArrayLike, DTypeLike, NDArray -from pydantic import ConfigDict, RootModel +from pydantic import ConfigDict, Field, RootModel from pydantic_core import core_schema -from ._vis_model import Field - if TYPE_CHECKING: from collections.abc import Iterable, Sized diff --git a/src/ndv/models/_scene/_vis_model.py b/src/ndv/models/_scene/_vis_model.py index c922b4b..aa567e7 100644 --- a/src/ndv/models/_scene/_vis_model.py +++ b/src/ndv/models/_scene/_vis_model.py @@ -5,14 +5,22 @@ from abc import abstractmethod from contextlib import suppress from importlib import import_module -from typing import TYPE_CHECKING, Any, ClassVar, Generic, Protocol, TypeVar, cast +from typing import ( + TYPE_CHECKING, + Any, + ClassVar, + Generic, + Protocol, + TypeVar, + cast, +) from psygnal import EmissionInfo, SignalGroupDescriptor from pydantic import BaseModel, ConfigDict from pydantic.fields import Field if TYPE_CHECKING: - from collections.abc import Iterable + from collections.abc import Iterable, Iterator __all__ = ["Field", "ModelBase", "SupportsVisibility", "VisModel"] @@ -252,13 +260,31 @@ def _get_default_backend() -> str: This will likely be context dependent. """ - return "vispy" + from ndv.views._app import canvas_backend + + return canvas_backend(None).value + + +def _update_blocker(adaptor: BackendAdaptor) -> contextlib.AbstractContextManager: + from ndv.models._scene.nodes.node import NodeAdaptorProtocol + + if isinstance(adaptor, NodeAdaptorProtocol): + + @contextlib.contextmanager + def blocker() -> Iterator[None]: + adaptor._vis_block_updates() + try: + yield + finally: + adaptor._vis_unblock_updates() + + return blocker() + return contextlib.nullcontext() def sync_adaptor(adaptor: BackendAdaptor, model: VisModel) -> None: """Decorator to validate and cache adaptor classes.""" - blocker = getattr(adaptor, "_vis_updates_blocked", contextlib.nullcontext) - with blocker(): + with _update_blocker(adaptor): for field_name in model.model_fields: method_name = SETTER_METHOD.format(name=field_name) value = getattr(model, field_name) diff --git a/src/ndv/models/_scene/canvas.py b/src/ndv/models/_scene/canvas.py index dfa615b..bcd9fe3 100644 --- a/src/ndv/models/_scene/canvas.py +++ b/src/ndv/models/_scene/canvas.py @@ -6,8 +6,9 @@ from cmap import Color # noqa: TC002 from psygnal.containers import EventedList +from pydantic import Field -from ._vis_model import Field, SupportsVisibility, VisModel +from ._vis_model import SupportsVisibility, VisModel from .view import View if TYPE_CHECKING: @@ -58,10 +59,8 @@ class Canvas(VisModel[CanvasAdaptorProtocol]): an orthoviewer might be a single canvas with three views, one for each axis. """ - width: float = Field(default=500, description="The width of the canvas in pixels.") - height: float = Field( - default=500, description="The height of the canvas in pixels." - ) + width: int = Field(default=500, description="The width of the canvas in pixels.") + height: int = Field(default=500, description="The height of the canvas in pixels.") background_color: Color | None = Field( default=None, description="The background color. None implies transparent " @@ -72,7 +71,7 @@ class Canvas(VisModel[CanvasAdaptorProtocol]): views: ViewList[View] = Field(default_factory=lambda: ViewList(), frozen=True) @property - def size(self) -> tuple[float, float]: + def size(self) -> tuple[int, int]: """Return the size of the canvas.""" return self.width, self.height diff --git a/src/ndv/models/_scene/nodes/camera.py b/src/ndv/models/_scene/nodes/camera.py index 7f59973..2b1b8b0 100644 --- a/src/ndv/models/_scene/nodes/camera.py +++ b/src/ndv/models/_scene/nodes/camera.py @@ -3,8 +3,9 @@ from abc import abstractmethod from typing import Literal, Protocol +from pydantic import Field + from ndv._types import CameraType -from ndv.models._scene._vis_model import Field from .node import Node, NodeAdaptorProtocol diff --git a/src/ndv/models/_scene/nodes/node.py b/src/ndv/models/_scene/nodes/node.py index f297dab..806307f 100644 --- a/src/ndv/models/_scene/nodes/node.py +++ b/src/ndv/models/_scene/nodes/node.py @@ -11,21 +11,22 @@ TypeVar, Union, cast, + runtime_checkable, ) from pydantic import ( + Field, SerializerFunctionWrapHandler, field_validator, model_serializer, ) from ndv.models._scene._transform import Transform -from ndv.models._scene._vis_model import Field, SupportsVisibility, VisModel +from ndv.models._scene._vis_model import SupportsVisibility, VisModel from ndv.models._sequence import ValidatedEventedList if TYPE_CHECKING: from collections.abc import Iterator - from contextlib import AbstractContextManager logger = logging.getLogger(__name__) NodeTypeCoV = TypeVar("NodeTypeCoV", bound="Node", covariant=True) @@ -34,6 +35,7 @@ ) +@runtime_checkable class NodeAdaptorProtocol(SupportsVisibility[NodeTypeCoV], Protocol): """Backend interface for a Node.""" @@ -58,8 +60,12 @@ def _vis_set_node_type(self, arg: str) -> None: pass @abstractmethod - def _vis_updates_blocked(self) -> AbstractContextManager: - """Return a context manager that blocks updates to the node.""" + def _vis_block_updates(self) -> None: + """Block future updates until `unblock_updates` is called.""" + + @abstractmethod + def _vis_unblock_updates(self) -> None: + """Unblock updates after `block_updates` was called.""" @abstractmethod def _vis_force_update(self) -> None: diff --git a/src/ndv/models/_sequence.py b/src/ndv/models/_sequence.py index 0dd65f1..42901ca 100644 --- a/src/ndv/models/_sequence.py +++ b/src/ndv/models/_sequence.py @@ -91,7 +91,10 @@ def __len__(self) -> int: return len(self._list) def __eq__(self, value: object) -> bool: - return self._list == value + # TODO: this can cause recursion errors for recursive models + if isinstance(value, ValidatedEventedList): + return self._list == value._list + return NotImplemented # ----------------------------------------------------- diff --git a/src/ndv/views/_scene/pygfx/__init__.py b/src/ndv/views/_scene/pygfx/__init__.py new file mode 100644 index 0000000..3ff7c98 --- /dev/null +++ b/src/ndv/views/_scene/pygfx/__init__.py @@ -0,0 +1,8 @@ +from ._camera import Camera +from ._canvas import Canvas +from ._image import Image +from ._node import Node +from ._scene import Scene +from ._view import View + +__all__ = ["Camera", "Canvas", "Image", "Node", "Scene", "View"] diff --git a/src/ndv/views/_scene/pygfx/_camera.py b/src/ndv/views/_scene/pygfx/_camera.py new file mode 100644 index 0000000..c7a54f4 --- /dev/null +++ b/src/ndv/views/_scene/pygfx/_camera.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +import warnings +from typing import Any + +import pygfx + +from ndv._types import CameraType +from ndv.models._scene.nodes import camera + +from ._node import Node + + +class Camera(Node, camera.CameraAdaptorProtocol): + """Adaptor for pygfx camera.""" + + _pygfx_node: pygfx.Camera + + def __init__(self, camera: camera.Camera, **backend_kwargs: Any) -> None: + if camera.type == CameraType.PANZOOM: + self._pygfx_node = pygfx.OrthographicCamera(**backend_kwargs) + self.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) + + # FIXME: hardcoded + # self._pygfx_cam.scale.y = -1 + + def _vis_set_zoom(self, zoom: float) -> None: + raise NotImplementedError + + def _vis_set_center(self, arg: tuple[float, ...]) -> None: + raise NotImplementedError + + def _vis_set_type(self, arg: CameraType) -> None: + raise NotImplementedError + + def _view_size(self) -> tuple[float, float] | None: + """Return the size of first parent viewbox in pixels.""" + raise NotImplementedError + + 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) + + 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) + + def _vis_set_range(self, margin: float) -> None: + warnings.warn( + "set_range not implemented for pygfx", RuntimeWarning, stacklevel=2 + ) diff --git a/src/ndv/views/_scene/pygfx/_canvas.py b/src/ndv/views/_scene/pygfx/_canvas.py new file mode 100644 index 0000000..19d18f7 --- /dev/null +++ b/src/ndv/views/_scene/pygfx/_canvas.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +from contextlib import suppress +from typing import TYPE_CHECKING, Any, Union, cast + +import pygfx + +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 ._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) + + 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. + 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] = {} + + def _vis_get_native(self) -> WgpuCanvasType: + return self._wgpu_canvas + + def _vis_set_visible(self, arg: bool) -> None: + if hasattr(self._wgpu_canvas, "show"): + self._wgpu_canvas.show() + self._wgpu_canvas.request_draw(self._animate) + + def _animate(self, viewport: pygfx.Viewport | None = None) -> None: + vp = viewport or self._viewport + 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() + + def _vis_add_view(self, view: core.View) -> None: + adaptor = cast("View", view.backend_adaptor()) + # adaptor._pygfx_cam.set_viewport(self._viewport) + self._views.append(adaptor) + + def _vis_set_width(self, arg: int) -> None: + _, height = self._wgpu_canvas.get_logical_size() + self._wgpu_canvas.set_logical_size(arg, height) + + 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_title(self, arg: str) -> None: + raise NotImplementedError() + + def _vis_close(self) -> None: + """Close canvas.""" + self._wgpu_canvas.close() + + def _vis_render( + self, + region: tuple[int, int, int, int] | None = None, + size: tuple[int, int] | None = None, + bgcolor: Color | None = None, + crop: np.ndarray | tuple[int, int, int, int] | None = None, + alpha: bool = True, + ) -> np.ndarray: + """Render to screenshot.""" + from wgpu.gui.offscreen import WgpuCanvas + + 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)) + return cast("np.ndarray", canvas.draw()) diff --git a/src/ndv/views/_scene/pygfx/_image.py b/src/ndv/views/_scene/pygfx/_image.py new file mode 100644 index 0000000..d61767e --- /dev/null +++ b/src/ndv/views/_scene/pygfx/_image.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import pygfx + +from ._node import Node + +if TYPE_CHECKING: + from cmap import Colormap + + from ndv._types import ArrayLike, ImageInterpolation + from ndv.models._scene import nodes + + +class Image(Node): + """pygfx backend adaptor for an Image node.""" + + _pygfx_node: pygfx.Image + _material: pygfx.ImageBasicMaterial + + 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._pygfx_node = pygfx.Image(self._geometry, self._material) + + def _vis_set_cmap(self, arg: Colormap) -> None: + self._material.map = arg + + 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 + + def _vis_set_interpolation(self, arg: ImageInterpolation) -> None: + raise NotImplementedError + + def _vis_set_data(self, arg: ArrayLike) -> None: + raise NotImplementedError diff --git a/src/ndv/views/_scene/pygfx/_node.py b/src/ndv/views/_scene/pygfx/_node.py new file mode 100644 index 0000000..1aeb5cb --- /dev/null +++ b/src/ndv/views/_scene/pygfx/_node.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from ndv.models._scene.nodes import node as core_node + +if TYPE_CHECKING: + from pygfx.geometries import Geometry + from pygfx.materials import Material + from pygfx.objects import WorldObject + + from ndv.models._scene import Transform + + +class Node(core_node.NodeAdaptorProtocol): + """Node adaptor for pygfx Backend.""" + + _pygfx_node: WorldObject + _material: Material + _geometry: Geometry + _name: str + + def _vis_get_native(self) -> Any: + return self._pygfx_node + + def _vis_set_name(self, arg: str) -> None: + # not sure pygfx has a name attribute... + # TODO: for that matter... do we need a name attribute? + # Could this be entirely managed on the model side/ + self._name = arg + + def _vis_set_parent(self, arg: core_node.Node | None) -> None: + raise NotImplementedError + + 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 + + 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 + + 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 + + def _vis_set_transform(self, arg: Transform) -> None: + self._pygfx_node.matrix = arg.root # TODO: check this + + def _vis_add_node(self, node: core_node.Node) -> None: + self._pygfx_node.add(node.backend_adaptor("pygfx")._vis_get_native()) + + def _vis_force_update(self) -> None: + pass + + def _vis_block_updates(self) -> None: + pass + + def _vis_unblock_updates(self) -> None: + pass diff --git a/src/ndv/views/_scene/pygfx/_scene.py b/src/ndv/views/_scene/pygfx/_scene.py new file mode 100644 index 0000000..077b56c --- /dev/null +++ b/src/ndv/views/_scene/pygfx/_scene.py @@ -0,0 +1,17 @@ +from typing import Any + +from pygfx.objects import Scene as _Scene + +from ndv.models import _scene as core + +from ._node import Node + + +class Scene(Node): + def __init__(self, scene: core.Scene, **backend_kwargs: Any) -> None: + self._pygfx_node = _Scene(visible=scene.visible, **backend_kwargs) + self._pygfx_node.render_order = scene.order + + 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 new file mode 100644 index 0000000..e6c660c --- /dev/null +++ b/src/ndv/views/_scene/pygfx/_view.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +import warnings +from typing import TYPE_CHECKING, Any + +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 + + +class View(Node, core.view.ViewAdaptorProtocol): + """View interface for pygfx Backend.""" + + # _native: scene.ViewBox + # TODO: i think pygfx doesn't see a view as part of the scene like vispy does + _pygfx_cam: pygfx.Camera + + def __init__(self, view: core.View, **backend_kwargs: Any) -> None: + # FIXME: hardcoded camera and scene + self.scene = pygfx.Scene() + + # 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() + + 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_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() + + if not isinstance(pygfx_scene, pygfx.Scene): + raise TypeError("Scene must be a pygfx.Scene") + self.scene = pygfx_scene + + def _vis_set_position(self, arg: tuple[float, float]) -> None: + warnings.warn( + "set_position not implemented for pygfx", RuntimeWarning, stacklevel=2 + ) + + def _vis_set_size(self, arg: tuple[float, float] | None) -> None: + warnings.warn( + "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_border_width(self, arg: float) -> None: + warnings.warn( + "set_border_width not implemented for pygfx", RuntimeWarning, stacklevel=2 + ) + + def _vis_set_border_color(self, arg: Color | None) -> None: + warnings.warn( + "set_border_color not implemented for pygfx", RuntimeWarning, stacklevel=2 + ) + + def _vis_set_padding(self, arg: int) -> None: + warnings.warn( + "set_padding not implemented for pygfx", RuntimeWarning, stacklevel=2 + ) + + 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/src/ndv/views/_scene/vispy/_node.py b/src/ndv/views/_scene/vispy/_node.py index 7ed7148..e084bbf 100644 --- a/src/ndv/views/_scene/vispy/_node.py +++ b/src/ndv/views/_scene/vispy/_node.py @@ -11,28 +11,20 @@ from ndv.models._scene import Transform -class VispyUpdateBlocker: - def __init__(self, vispy_node: scene.VisualNode): - self.vispy_node = vispy_node - - def __enter__(self) -> VispyUpdateBlocker: - self._update, self.vispy_node.update = self.vispy_node.update, lambda: None - return self - - def __exit__(self, *_: Any) -> None: - self.vispy_node.update = self._update - - class Node(core_node.NodeAdaptorProtocol): """Node adaptor for Vispy Backend.""" _vispy_node: scene.VisualNode + _update: Any = None def _vis_force_update(self) -> None: self._vispy_node.update() - def _vis_updates_blocked(self) -> VispyUpdateBlocker: - return VispyUpdateBlocker(self._vispy_node) + def _vis_block_updates(self) -> None: + self._update, self._vispy_node.update = self._vispy_node.update, lambda: None + + def _vis_unblock_updates(self) -> None: + self._vispy_node.update, self._update = self._update, None def _vis_get_native(self) -> Any: return self._vispy_node diff --git a/src/ndv/views/_scene/vispy/_view.py b/src/ndv/views/_scene/vispy/_view.py index 0117030..65abe70 100644 --- a/src/ndv/views/_scene/vispy/_view.py +++ b/src/ndv/views/_scene/vispy/_view.py @@ -17,6 +17,8 @@ logger = logging.getLogger(__name__) +# TODO: originally, View did need to be a Node, but not anymore, +# so this could be refactored class View(Node, core.view.ViewAdaptorProtocol): """View interface for Vispy Backend.""" diff --git a/x.py b/x.py index 5013732..edec347 100644 --- a/x.py +++ b/x.py @@ -2,7 +2,7 @@ from rich import print from ndv import run_app -from ndv.models._scene._transform import Transform +from ndv.models._scene import Transform from ndv.models._scene.nodes import Image, Points, Scene from ndv.models._scene.view import View from ndv.views import _app @@ -17,6 +17,8 @@ cmap="viridis", transform=Transform().scaled((0.7, 0.5)).translated((-10, 20)), ) + +scene = Scene(children=[img1, img2]) points = Points( coords=np.random.randint(0, 200, (100, 2)), size=5, @@ -25,7 +27,7 @@ edge_width=0.5, opacity=0.1, ) -scene = Scene(children=[points, img1, img2]) +scene.children.insert(0, points) view = View(scene=scene) @@ -37,10 +39,11 @@ # sys.exit() # print(">>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>") -json = view.model_dump_json(indent=2) +json = view.model_dump_json(indent=2, exclude_unset=True) print(json) # print(">>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>") -print(View.model_validate_json(json)) +obj = View.model_validate_json(json) +print(obj) assert View.model_json_schema()