diff --git a/.circleci/config.yml b/.circleci/config.yml index 1ac8e04562e..1215f83b7e3 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -5,6 +5,7 @@ # Tagging a commit with the following overrides these behaviors: # - [circle front] will run the front page examples and perform test-doc # - [circle full] will run all examples and perform test-doc +# - [circle lite] will also build the JupyterLite site in a partial build # - [circle linkcheck] will run our linkcheck job # - [circle deploy] on a main or maint/* commit will try to immediately build # and deploy docs rather than waiting for the nightly build diff --git a/doc/changes/dev/14333.newfeature.rst b/doc/changes/dev/14333.newfeature.rst new file mode 100644 index 00000000000..3fad2a7d78d --- /dev/null +++ b/doc/changes/dev/14333.newfeature.rst @@ -0,0 +1 @@ +The ``jupyterlite_notebook`` 3D backend was renamed ``notebook_js`` and now supports most 3D visualizations, by `Eric Larson`_. diff --git a/doc/conf.py b/doc/conf.py index e64423e5635..50994932e6d 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -494,7 +494,8 @@ sphinx_gallery_parallel = int(os.getenv("MNE_DOC_BUILD_N_JOBS", "1")) # The JupyterLite site, and the data it serves (about 1 GB), only belong in a -# full build: `make html` turns this on, pattern and noplot builds leave it off. +# full build: `make html` turns this on, pattern and noplot builds leave it off +# (unless CircleCI's [circle lite] tag sets it). build_jupyterlite = os.getenv("MNE_DOC_BUILD_JUPYTERLITE", "0") == "1" if build_jupyterlite: from jupyterlite_data import stage_lite_data # noqa: E402 @@ -670,8 +671,8 @@ # plot_field needs the interactive viewer that the browser renderer skips "examples/visualization/mne_helmet.py", # Tier 4: mne.viz.Brain features the browser renderer lacks (see the TODOs - # in mne/viz/_brain/_brain.py). Brain itself draws (static, one time point, - # one view), and the fNIRS tutorials and 50_background_freesurfer_mne run + # in mne/viz/_brain/_brain.py). Brain itself draws (one view, with the time + # viewer), and the fNIRS tutorials and 50_background_freesurfer_mne run # in full, but these lean on add_annotation's hover callback, # brain.screenshot, legends, silhouettes or the flatmap, so most of their # cells fail. diff --git a/doc/development/contributing.rst b/doc/development/contributing.rst index acbbc54a8ef..cb06c8d23dc 100644 --- a/doc/development/contributing.rst +++ b/doc/development/contributing.rst @@ -1202,6 +1202,11 @@ down the road. Here are the guidelines: nicely formatted) and the resulting output and figures will be rendered as part of the tutorial/example. + - ``[circle lite]`` also builds the JupyterLite site in a partial + documentation build, so the "Open in JupyterLite" buttons work on the + tutorials/examples it renders (combine with ``[circle front]`` to + render the front page ones). + - Examples and tutorials should execute as quickly and with as low memory usage as possible while still conveying necessary information. To see current execution times and memory usage, visit the `sg_execution_times page`_. To see unused API diff --git a/mne/conftest.py b/mne/conftest.py index 8e30823515c..3b6734a4e11 100644 --- a/mne/conftest.py +++ b/mne/conftest.py @@ -732,7 +732,7 @@ def browser_backend(request, garbage_collect, monkeypatch): @pytest.fixture( params=[ pytest.param("pyvistaqt", marks=pytest.mark.pvtest), - pytest.param("jupyterlite_notebook", marks=pytest.mark.pvtest), + pytest.param("notebook_js", marks=pytest.mark.pvtest), ] ) def renderer(request, options_3d, garbage_collect): @@ -755,7 +755,7 @@ def renderer_notebook(request, options_3d): yield renderer -@pytest.fixture(params=[pytest.param("jupyterlite_notebook", marks=pytest.mark.pvtest)]) +@pytest.fixture(params=[pytest.param("notebook_js", marks=pytest.mark.pvtest)]) def renderer_lite(request, options_3d): """Yield the JupyterLite (vtk.js) renderer alone, for its own tests.""" with _use_backend(request.param, interactive=False) as renderer: @@ -815,9 +815,12 @@ def _use_backend(backend_name, interactive): def _check_skip_backend(name): from mne.viz.backends._utils import _notebook_vtk_works - if name == "jupyterlite_notebook": + if name == "notebook_js": # draws with vtk.js in a browser: no VTK, no Qt, no ffmpeg pytest.importorskip("pyvista_js") + # ... but the same ipywidgets GUI as the notebook backend + pytest.importorskip("ipympl") + pytest.importorskip("ipyevents") return pytest.importorskip("pyvista") pytest.importorskip("imageio_ffmpeg") diff --git a/mne/gui/_coreg.py b/mne/gui/_coreg.py index de218f124b3..8582d9cbb4f 100644 --- a/mne/gui/_coreg.py +++ b/mne/gui/_coreg.py @@ -1738,7 +1738,7 @@ def _update_fiducials_label(self): self._forward_widget_command("mri_fiducials_label", "set_value", text) def _configure_dock(self): - if self._renderer._kind == "notebook": + if self._renderer._kind.startswith("notebook"): # either notebook backend collapse = True # collapsible and collapsed else: collapse = None # not collapsible diff --git a/mne/gui/_dipolefit.py b/mne/gui/_dipolefit.py index 7d0065761a9..360120ad66b 100644 --- a/mne/gui/_dipolefit.py +++ b/mne/gui/_dipolefit.py @@ -359,7 +359,7 @@ def __init__( # window has actually appeared on screen (see `_qt_safe_window`). self._renderer.figure.splash = self._splash self._renderer.show() - if block and self._renderer._kind != "notebook": + if block and not self._renderer._kind.startswith("notebook"): _qt_app_exec(self._renderer.figure.store["app"]) @property diff --git a/mne/viz/_3d.py b/mne/viz/_3d.py index de13e212e01..88b8e166405 100644 --- a/mne/viz/_3d.py +++ b/mne/viz/_3d.py @@ -2750,10 +2750,7 @@ def _check_st_tv(show_traces, time_viewer, times): extra="when a string", ) if time_viewer == "auto": - from .backends.renderer import _get_3d_backend - - # the browser backend writes a static scene, so there is no slider to show - time_viewer = _get_3d_backend() != "jupyterlite_notebook" + time_viewer = True if show_traces == "auto": show_traces = time_viewer and times is not None and len(times) > 1 if show_traces and not time_viewer: diff --git a/mne/viz/_brain/_brain.py b/mne/viz/_brain/_brain.py index 46567fb21c0..b622be94197 100644 --- a/mne/viz/_brain/_brain.py +++ b/mne/viz/_brain/_brain.py @@ -569,9 +569,8 @@ def _on_time_change(self, event): if event.time == self._current_time: return time_idx = self._to_time_index(event.time) - self._update_current_time_idx(time_idx) + self._update_current_time_idx(time_idx) # renders publish(self, _TimeUpdated()) - self._renderer._update() def _on_colormap_range(self, event): """Respond to the colormap_range UI event.""" @@ -581,10 +580,8 @@ def _on_colormap_range(self, event): # Check if limits have changed at all. if all(val is None or val == self._data[key] for key, val in lims.items()): return - # Update the render. - self._update_colormap_range(**lims) + self._update_colormap_range(**lims) # renders publish(self, _ColormapRangeUpdated()) - self._renderer._update() def _clear_callbacks(self): # Remove the default key binding @@ -1212,7 +1209,7 @@ def _add_volume_data(self, hemi, src, volume_options): scalars = np.zeros(np.prod(dimensions)) scalars[vertices] = 1.0 # for the outer mesh # TODO: reaches into VTK through the renderer, which the - # jupyterlite_notebook backend cannot offer (its pages are excluded + # notebook_js backend cannot offer (its pages are excluded # in doc/conf.py); refactor the renderer interface so Brain only # uses its abstract methods. Same for the time label and glyph # actors below. diff --git a/mne/viz/_brain/_time_viewer.py b/mne/viz/_brain/_time_viewer.py index 05f6969c2f0..df780f6c8ab 100644 --- a/mne/viz/_brain/_time_viewer.py +++ b/mne/viz/_brain/_time_viewer.py @@ -139,9 +139,13 @@ def setup_time_viewer(self, time_viewer=True, show_traces=True): self._active_data_key = next(iter(self._all_data)) self._configure_time_label() self._configure_scalar_bar() - self._configure_shortcuts() - self._configure_picking() - self._configure_hover() + # keyboard shortcuts, picking and hover all need mouse and key events + # from the interactor, plus VTK actors (see the TODO in _add_volume_data), + # which a page drawn by the notebook_js backend has neither of + if self._renderer._kind != "notebook_js": + self._configure_shortcuts() + self._configure_picking() + self._configure_hover() self._configure_dock() self._configure_tool_bar() self._configure_status_bar() @@ -270,7 +274,9 @@ def current_time_func(): def _configure_dock_orientation_widget(self, name): layout = self._renderer._dock_add_group_box(name, collapse=True) # Renderer widget - rends = [str(i) for i in range(len(self._renderer._all_renderers))] + # one entry per subplot, which is what VTK's renderers are (asking the + # renderer for them would reach past its interface, see _add_volume_data) + rends = [str(i) for i in range(int(np.prod(self._subplot_shape)))] if len(rends) > 1: @_auto_weakref diff --git a/mne/viz/_brain/tests/test_brain.py b/mne/viz/_brain/tests/test_brain.py index 9179fb074bd..602c060d207 100644 --- a/mne/viz/_brain/tests/test_brain.py +++ b/mne/viz/_brain/tests/test_brain.py @@ -2309,13 +2309,20 @@ def test_auto_scalar_bar_fmt(): def test_brain_ui_events(renderer_interactive_pyvistaqt, brain_gc): """Test responding to Brain related UI events.""" brain = _create_testing_brain(hemi="lh", show_traces="vertex") + # each event must render once: a notebook backend redraws the whole scene + renders, update = list(), brain._renderer._update + brain._renderer._update = lambda: (renders.append(1), update()) - ui_events.publish(brain, ui_events.TimeChange(time=1)) - assert brain._current_time == 1 + new_time = brain._times[-1] + assert brain._current_time != new_time # so this is a change + ui_events.publish(brain, ui_events.TimeChange(time=new_time)) + assert brain._current_time == new_time + assert len(renders) == 1 ui_events.publish(brain, ui_events.VertexSelect(hemi="lh", vertex_id=1)) assert 1 in brain.get_picked_points()["lh"] + n_renders = len(renders) ui_events.publish( brain, ui_events.ColormapRange( @@ -2323,6 +2330,7 @@ def test_brain_ui_events(renderer_interactive_pyvistaqt, brain_gc): ), ) assert_array_equal(brain._data["ctable"][:3, 3], [0, 2, 4]) + assert len(renders) == n_renders + 1 # This event should be ignored. ui_events.publish( diff --git a/mne/viz/backends/_jupyterlite.py b/mne/viz/backends/_jupyterlite.py index 73516996994..f7e6a09354a 100644 --- a/mne/viz/backends/_jupyterlite.py +++ b/mne/viz/backends/_jupyterlite.py @@ -60,7 +60,7 @@ def setup_notebook(data_path="/tmp/mne_data"): # nosec B108 from ...viz import set_3d_backend try: - set_3d_backend("jupyterlite_notebook") + set_3d_backend("notebook_js") except Exception as exc: print(f"[JupyterLite] could not select the pyvista-js renderer: {exc!r}") diff --git a/mne/viz/backends/_lite.py b/mne/viz/backends/_lite.py index 368f702c0c8..269e49a71f5 100644 --- a/mne/viz/backends/_lite.py +++ b/mne/viz/backends/_lite.py @@ -3,27 +3,32 @@ MNE's 3D functions do their geometry and coordinate-frame work in numpy and only hand the result to a renderer, so swapping that last step is enough to draw in a browser kernel, where VTK cannot load. Selected with -``mne.viz.set_3d_backend("jupyterlite_notebook")``. +``mne.viz.set_3d_backend("notebook_js")``. Supported: meshes, surfaces, spheres, tubes and glyphs, plus per-vertex RGB(A) colors, which is how :class:`mne.viz.Brain` paints its surface, so ``stc.plot()`` -gives a static picture (one time point, no time viewer, colorbar or split -layout). Not supported: scalar colormaps and contours, subplots (``hemi="split"``, -several views), volume source estimates, vector glyphs, time labels, screenshots, -and figure size (pyvista-js writes a 600x400 canvas). +gives a picture of one time point, and the ipywidgets GUI of the ``notebook`` +backend (``_notebook.py``), which the scene is drawn into as a whole whenever +it changes. Not supported: scalar colormaps and contours, subplots +(``hemi="split"``, several views), volume source estimates, vector glyphs, +screenshots, and mouse and key events. """ # Authors: The MNE-Python contributors. # License: BSD-3-Clause # Copyright the MNE-Python contributors. +import asyncio +import html import inspect import weakref from contextlib import nullcontext import numpy as np import pyvista_js as pv +from ipywidgets import HTML, Button, GridBox, Layout from matplotlib.colors import to_rgb +from pyvista_js.rendering import scene_to_json from ...surface import _tessellate_sphere from ...transforms import ( @@ -35,6 +40,7 @@ ) from ...utils import _check_option, _validate_type from ._abstract import Figure3D, _AbstractRenderer +from ._notebook import _IpyRenderer from ._utils import ALLOWED_QUIVER_MODES, LIGHTS, _to_pos, _vtk_faces # vtk.js places text in normalized window coordinates; PyVista takes these names @@ -45,6 +51,67 @@ "upper_right": (0.65, 0.90), } _DEFAULT_COLOR = (0.5, 0.5, 0.5) # what color=None draws as +# pyvista-js writes a 600x400 box into its page; this fills the frame instead +_LITE_SIZE_CSS = """ +""" +# Seconds to wait for a page to report it painted before giving up on it +_LITE_ACK_TIMEOUT = 10.0 +# Appended to each page: once painted, show its frame (same origin, so it can +# reach it) and hide the other, unless a newer page (SEQ) already did, then +# tell the kernel by clicking its (hidden) button +_LITE_SWAP_JS = """ +""" +# 2D text as a DOM overlay rather than in the page: a time label that changes +# every step must not cost a new page (bottom-left origin, like vtk.js) +_LITE_TEXT_HTML = ( + '{text}' +) +_LITE_TEXT_SHIFT = dict(left=0, center=50, right=100) # % of the text's width +# Dims the frames with a spinner from a redraw request until the page paints +_LITE_BUSY_HTML = """ +
+
+
+""" # Nothing in a notebook closes figures, and each live scene holds its meshes in # the WASM heap, JS and GPU buffers (20_source_alignment makes six, enough to run # the tab out of memory), so only the newest few stay live @@ -61,16 +128,42 @@ def _lite_unsupported(what): raise NotImplementedError(f"{what} is not supported in the browser.") +class _LiteText(pv.Text): + """A text actor answering the VTK calls Brain makes on its time label. + + Brain reaches into VTK to place, style and update that label (see the TODO in + ``_brain.py``); the GUI's DOM overlay draws what these calls set. + """ + + justification = "left" + bold = False + + def SetInput(self, text): + self.input = str(text) + + def SetPosition(self, x, y): + self.position = (float(x), float(y)) + + def GetTextProperty(self): + return self # the style calls below are no-ops, so they can land here + + def SetJustificationToCentered(self): + self.justification = "center" + + def BoldOn(self): + self.bold = True + + def _lite_add_text(plotter, text, position, size, color): - actor = pv.Text(str(text), position=tuple(float(coord) for coord in position)) + actor = _LiteText(str(text), position=tuple(float(coord) for coord in position)) actor.prop.font_size = 14 if size is None else int(size) # Brain passes None actor.prop.color = _rgb(color) plotter.add_text(actor) return actor -def _lite_view_angles(plotter, rigid=None): - """Return the (azimuth, elevation) in degrees the plotter looks from, or None. +def _lite_view_angles(figure, rigid=None): + """Return the (azimuth, elevation) in degrees the figure looks from, or None. The view is kept as ``view_vector``, a camera position that vtk.js aims at the origin and then frames with ``resetCamera()``, rather than as a camera @@ -78,7 +171,7 @@ def _lite_view_angles(plotter, rigid=None): ``rigid`` is the frame the angles are expressed in (Brain's canonical rotation), as in ``_pyvista._get_user_camera_direction``. """ - view_vector = plotter._renderer._view_vector # pyvista-js 0.15 + view_vector = figure._view_vector # pyvista-js takes a view but has no getter if view_vector is None: # nothing set yet, so vtk.js chooses return None position = np.asarray(view_vector, float) @@ -88,11 +181,11 @@ def _lite_view_angles(plotter, rigid=None): return float(np.rad2deg(phi)) % 360, float(np.rad2deg(theta)) % 180 -def _lite_set_view(plotter, azimuth=None, elevation=None, rigid=None): - """Point the plotter, keeping the angle not given as _pyvista._set_3d_view does.""" +def _lite_set_view(figure, azimuth=None, elevation=None, rigid=None): + """Point the figure, keeping the angle not given as _pyvista._set_3d_view does.""" if azimuth is None and elevation is None: return - current = _lite_view_angles(plotter, rigid) or (90.0, 90.0) # plot_alignment + current = _lite_view_angles(figure, rigid) or (90.0, 90.0) # plot_alignment phi = np.deg2rad(current[0] if azimuth is None else azimuth) theta = np.deg2rad(current[1] if elevation is None else elevation) # view up flips near the poles, matching _set_3d_view @@ -106,12 +199,13 @@ def _lite_set_view(plotter, azimuth=None, elevation=None, rigid=None): rigid_inv = np.linalg.inv(rigid) position = apply_trans(rigid_inv, position, move=False) up = apply_trans(rigid_inv, up, move=False) - plotter.view_vector(tuple(position), viewup=tuple(up)) + figure._view_vector = tuple(position) + figure.plotter.view_vector(figure._view_vector, viewup=tuple(up)) -def _lite_get_view(plotter, rigid=None): +def _lite_get_view(figure, rigid=None): """Return (roll, distance, azimuth, elevation, focalpoint) as _get_3d_view does.""" - azimuth, elevation = _lite_view_angles(plotter, rigid) or (0.0, 0.0) + azimuth, elevation = _lite_view_angles(figure, rigid) or (0.0, 0.0) return (0.0, 1.0, azimuth, elevation, np.zeros(3)) @@ -167,11 +261,22 @@ class _LitePolyData(pv.PolyData): vtk.js only uses an array as colors directly when it is uint8. """ + _lite_version = 0 # bumped by each recoloring, so a redraw can send just that + def __setitem__(self, name, array): array = np.asarray(array) if array.ndim == 2 and array.shape[1] in (3, 4) and array.dtype != np.uint8: array = np.round(np.clip(array, 0, 1) * 255).astype(np.uint8) super().__setitem__(name, array) + self._lite_version += 1 + + +class _LiteData(HTML): + """An HTML widget whose repr leaves out its value, a page or its point data.""" + + def _repr_keys(self): + # megabytes of mesh data, which a display() outside a kernel prints + return (key for key in super()._repr_keys() if key != "value") class _LiteFigure(Figure3D): @@ -182,6 +287,7 @@ def __init__(self): def _init(self, plotter): self._plotter = plotter + self._view_vector = None # the camera position last set, see _lite_set_view return self @@ -189,7 +295,7 @@ class _LiteRenderer(_AbstractRenderer): """MNE 3D renderer backed by pyvista-js.""" # not "notebook": that backend has VTK, a filesystem and OS threads - _kind = "jupyterlite_notebook" + _kind = "notebook_js" def __init__( self, @@ -205,11 +311,14 @@ def __init__( splash=False, multi_samples=None, ): - # _PyVistaRenderer's signature, but size, shape, name and show cannot - # be honored: the canvas is fixed and written only when show() runs + # _PyVistaRenderer's signature, but shape, name and show cannot be + # honored: the page is written only when show() runs # an int is a figure number, which just means a new figure here _validate_type(fig, (None, int, _LiteFigure), "fig") + self._size = (size, size) if np.isscalar(size) else tuple(size) self._close_callbacks = {False: [], True: []} # keyed by ``after`` + self._viewer = None # the widget the scene is drawn into, once shown + self._texts = [] # 2D text the GUI renderer draws over the frames if isinstance(fig, _LiteFigure): # plot_alignment(fig=...) composites into it self._figure = fig return @@ -238,9 +347,6 @@ def figure(self): def scene(self): return self._figure - def show(self): - self.plotter.show() - # -- geometry ----------------------------------------------------------- def _glyph_template( self, kind, radius=None, height=None, center=None, resolution=None @@ -552,15 +658,9 @@ def text2d( return _lite_add_text(self.plotter, text, (x_window, y_window), size, color) def remove_mesh(self, mesh_data): - # the renderer keeps the actor dict and the plotter a dict pointing at - # it, so drop both; instanced_mesh hands back one actor per color - actor, _ = mesh_data - actors = actor if isinstance(actor, list) else [actor] - plotter = self.plotter - plotter.actors[:] = [a for a in plotter.actors if a["actor"] not in actors] - plotter._renderer.actors[:] = [ - a for a in plotter._renderer.actors if a not in actors - ] + actor, _ = mesh_data # instanced_mesh hands back one actor per color + for this_actor in actor if isinstance(actor, list) else [actor]: + self.plotter.remove_actor(this_actor) # -- nothing to do in a browser ----------------------------------------- def set_interaction(self, interaction): @@ -583,8 +683,16 @@ def subplot(self, x, y): if (x, y) != (0, 0): _lite_unsupported("Subplots") # one scene is one canvas - def _update(self): - pass # the page paints after the cell finishes + def _index_to_loc(self, idx): + assert idx == 0, idx # so there is only ever the one view + return (0, 0) + + def _loc_to_index(self, loc): + assert tuple(loc) == (0, 0), loc + return 0 + + def _process_events(self, *args, **kwargs): + pass # the page runs the event loop def _window_close_connect(self, func, *, after=True): # an output cell has no close event, so close() runs these; ui_events @@ -620,15 +728,6 @@ def contour(self, *args, **kwargs): def legend(self, *args, **kwargs): _lite_unsupported("Drawing a legend") - def _process_events(self, *args, **kwargs): - _lite_unsupported("Draining the event loop") # the page runs it - - def _window_set_cursor(self, *args, **kwargs): - _lite_unsupported("Setting the cursor") - - def _enable_time_interaction(self, *args, **kwargs): - _lite_unsupported("The time slider") # needs dock widgets - def project(self, xyz, ch_names): _lite_unsupported("Projecting 3D positions onto the scene") @@ -639,7 +738,7 @@ def screenshot(self, mode="rgb", filename=None, *, scale=1): # -- camera ------------------------------------------------------------- def get_camera(self, *, rigid=None): - return _lite_get_view(self.plotter, rigid) + return _lite_get_view(self._figure, rigid) def set_camera( self, @@ -653,11 +752,199 @@ def set_camera( update=True, ): # distance, focalpoint and roll go unused: vtk.js frames the scene - _lite_set_view(self.plotter, azimuth, elevation, rigid) + _lite_set_view(self._figure, azimuth, elevation, rigid) + + +class _Renderer(_IpyRenderer, _LiteRenderer): + """pyvista-js drawing with the ipywidgets GUI of the notebook backend.""" + + # an HTML widget has no close event, so keep _LiteRenderer's real callbacks + # rather than the no-ops _IpyWindow provides for a window-less backend + _window_close_connect = _LiteRenderer._window_close_connect + _window_close_disconnect = _LiteRenderer._window_close_disconnect + + def __init__(self, *args, **kwargs): + fullscreen = kwargs.pop("fullscreen", False) + super().__init__(*args, **kwargs) + self._window_initialize(fullscreen=fullscreen) + + def _display_default_tool_bar(self): + pass # its one button takes a screenshot, which pyvista-js cannot + + def close(self): + super().close() # after the close callbacks, which still use the widgets + self._close_widgets() + + def _viewer_widget(self): + """Return the widget the scene is drawn into, as ``show`` asks for it.""" + # Two frames in one grid cell, in HTML widgets: a redraw loads into the + # hidden one, which shows itself once painted (_LITE_SWAP_JS), so + # nothing blank is seen. Not Output widgets: the front end echoes what + # those capture back to the kernel, and the server drops the websocket + # for a message over 10 MiB, which a Brain surface is several times over + self._pages = [_LiteData(layout=Layout(grid_area="view")) for _ in range(2)] + self._busy = HTML( # last, so on top + _LITE_BUSY_HTML, layout=Layout(grid_area="view", visibility="hidden") + ) + self._painted = Button(layout=Layout(display="none")) # the page's ack + self._painted.add_class("mne-lite-painted") + self._painted.on_click(self._on_painted) + self._updates = _LiteData(layout=Layout(display="none")) # point data, to apply + self._updates.add_class("mne-lite-update") + self._labels = HTML(layout=Layout(grid_area="view")) # 2D text, on top + self._labels.add_class("mne-lite-labels") + self._viewer = GridBox( + [*self._pages, self._labels, self._busy, self._painted, self._updates], + layout=Layout(grid_template_areas='"view"'), + ) + self._dirty = False # a change since the last draw + self._in_flight = None # the timeout handle while a page is on its way + self._n_drawn = self._n_updates = 0 + self._page_key = None # what the page on screen was drawn from + self._draw_scene() + return self._viewer + + def text2d( + self, + x_window, + y_window, + text, + size=14, + color="white", + justification=None, + font_file=None, + ): + _check_option("justification", justification, (None, *_LITE_TEXT_SHIFT)) + actor = _LiteText(str(text), position=(float(x_window), float(y_window))) + actor.prop.font_size = 14 if size is None else int(size) + actor.prop.color = _rgb(color) + actor.justification = justification or "left" + self._texts.append(actor) + return actor + + def _draw_labels(self): + spans = [ + _LITE_TEXT_HTML.format( + x=100 * actor.position[0], + y=100 * actor.position[1], + shift=_LITE_TEXT_SHIFT[actor.justification], + weight="bold" if actor.bold else "normal", + # PyVista's add_text, which _pyvista.py uses, doubles viewport text + size=2 * actor.prop.font_size, + r=int(255 * actor.prop.color[0]), + g=int(255 * actor.prop.color[1]), + b=int(255 * actor.prop.color[2]), + text=html.escape(actor.input), + ) + for actor in self._texts + ] + self._labels.value = ( # clipped like VTK text, so it never scrolls the box + '
' + f"{''.join(spans)}
" + ) + + def _scene_key(self): + """Return what a page depends on besides point data, plus mesh versions.""" + actors = self.plotter.actors + key = tuple( + (id(info["mesh"]), tuple(info.get("color") or ()), info.get("opacity")) + for info in actors + ) + (self._figure._view_vector, tuple(self.plotter.background_color)) + versions = tuple(getattr(info["mesh"], "_lite_version", 0) for info in actors) + return key, versions + + def _send_updates(self, changed): + """Send the recolored meshes' point data to the page shown.""" + actors = self.plotter.actors + updates = [ + self.plotter.update_actor( + idx, point_data=dict(actors[idx]["mesh"].point_data.items()), send=False + ) + for idx in changed + ] + self._n_updates += 1 + self._updates.value = ( + f'" + ) + + def _loop(self): + """Return the kernel's event loop, or None outside a kernel.""" + try: + return asyncio.get_running_loop() + except RuntimeError: + return None + + def _schedule_draw(self): + # once the kernel is done with the message (a cell or a widget event) + # that made the change, since Brain asks several times per change + loop = self._loop() + if loop is None: + self._draw_scene() + else: + loop.call_soon(self._draw_scene) + + def _on_painted(self, *args): + """Take the page's report that it painted, or the timeout in its place.""" + if self._in_flight is not None: + self._in_flight.cancel() + self._in_flight = None + if self._dirty: + self._schedule_draw() + else: + self._busy.layout.visibility = "hidden" + + def _draw_scene(self): + # the page pyvista-js writes for a browser, framed so the vtk.js it + # loads and the scene data it embeds stay out of the notebook document + width, height = self._size + self._dirty = False + self._draw_labels() + key, versions = self._scene_key() + if self._page_key is not None and self._page_key[0] == key: + changed = [i for i, v in enumerate(versions) if v != self._page_key[1][i]] + if not changed: # Brain asks again for the same time + if self._in_flight is None: + self._busy.layout.visibility = "hidden" + return + self._send_updates(changed) + self._page_key = (key, versions) + loop = self._loop() + if loop is not None: + self._in_flight = loop.call_later(_LITE_ACK_TIMEOUT, self._on_painted) + return + self._page_key = (key, versions) + page = self.plotter.generate_standalone_html() + self._n_drawn += 1 + page = page.replace("", "" + _LITE_SIZE_CSS, 1) + swap = _LITE_SWAP_JS.replace("SEQ", str(self._n_drawn)) + page = html.escape(page.replace("", swap + ""), quote=True) + # into the frame that is hidden (or not yet drawn), until it has painted + self._pages[self._n_drawn % 2].value = ( + f'' + ) + loop = self._loop() # no kernel, no browser to hear back from + if loop is not None: + self._in_flight = loop.call_later(_LITE_ACK_TIMEOUT, self._on_painted) + + def _window_get_size(self): + return self._size + + def _update(self): + # pyvista-js serializes the whole scene into the page once, with no way + # to update it afterward, so a change means drawing it again: now, or + # once the page on its way has painted (_on_painted), so that the frame + # on screen is never the one drawn into + if self._viewer is None or self._dirty: # a draw is already coming + return + self._dirty = True + self._busy.layout.visibility = "visible" + if self._in_flight is None: + self._schedule_draw() # -- the module surface renderer.py expects of a 3D backend ----------------- -_Renderer = _LiteRenderer _testing_context = nullcontext # nothing draws differently under test @@ -671,7 +958,7 @@ def _set_3d_view( rigid=None, update=True, ): - _lite_set_view(figure.plotter, azimuth, elevation, rigid) + _lite_set_view(figure, azimuth, elevation, rigid) def _set_3d_title(figure, title, size=16, *, color="white", position="upper_left"): diff --git a/mne/viz/backends/_notebook.py b/mne/viz/backends/_notebook.py index fd842459952..8a367743b52 100644 --- a/mne/viz/backends/_notebook.py +++ b/mne/viz/backends/_notebook.py @@ -1,4 +1,4 @@ -"""Notebook implementation of _Renderer and GUI.""" +"""ipywidgets GUI shared by the notebook 3D backends (see _trame.py, _lite.py).""" # Authors: The MNE-Python contributors. # License: BSD-3-Clause @@ -6,8 +6,7 @@ import os import os.path as op -import warnings -from contextlib import contextmanager, nullcontext +from contextlib import contextmanager from ipyevents import Event from IPython.display import clear_output, display @@ -75,18 +74,6 @@ _AbstractWidgetList, _AbstractWindow, ) -from ._pyvista import ( - Plotter, - _check_3d_figure, # noqa: F401 - _clear_3d_figure, # noqa: F401 - _close_3d_figure, # noqa: F401 - _close_all, # noqa: F401 - _PyVistaRenderer, - _set_3d_title, # noqa: F401 - _set_3d_view, # noqa: F401 - _take_3d_screenshot, # noqa: F401 -) -from ._utils import _notebook_vtk_works from .renderer import _TimeInteraction # dict values are icon names from: https://fontawesome.com/icons @@ -112,46 +99,6 @@ _BASE_MIN_SIZE = "20px" _BASE_KWARGS = dict(layout=Layout(min_width=_BASE_MIN_SIZE, min_height=_BASE_MIN_SIZE)) -_JUPYTER_BACKEND = "trame" - - -class _NotebookPlotter(Plotter): - """PyVista ``Plotter`` for the notebook backend. - - Validate the object returned by show, as PyVista silently falls back static PIL - when the trame Jupyter backend cannot be loaded. - """ - - def show( - self, *args, jupyter_backend=_JUPYTER_BACKEND, return_viewer=False, **kwargs - ): - with warnings.catch_warnings(record=True) as caught: - warnings.simplefilter("always") - viewer = super().show( - *args, - jupyter_backend=jupyter_backend, - return_viewer=return_viewer, - **kwargs, - ) - if not isinstance(viewer, Widget): - reasons = "\n".join( - f"- {w.message}" - for w in caught - if any(key in str(w.message) for key in ("backend", "trame", "static")) - ) - raise RuntimeError( - f'The notebook 3D backend is not functional: the "{jupyter_backend}" ' - "PyVista Jupyter backend returned a " - f"{type(viewer).__module__}.{type(viewer).__qualname__} instead of an " - "interactive widget. This usually means the installed trame packages " - "(trame, trame-vtk, trame-vuetify, trame-pyvista) are missing or " - "mutually incompatible." - + (f"\n\nPyVista reported:\n{reasons}" if reasons else "") - ) - if return_viewer: - return viewer - - # %% # Widgets # ------- @@ -670,8 +617,7 @@ def _handle_scroll(self, scroll=None): return kwargs def _add_widget(self, widget): - # if pyvista plotter, needs to be shown - if isinstance(widget, Plotter): + if not isinstance(widget, Widget): # a PyVista plotter, which needs showing widget = widget.show(return_viewer=True) if hasattr(widget, "layout"): widget.layout.width = None # unlock the fixed layout @@ -767,25 +713,6 @@ def _close(self): clear_output() -class _3DRenderer(_PyVistaRenderer): - _kind = "notebook" - - def __init__(self, *args, **kwargs): - kwargs["notebook"] = True - super().__init__(*args, **kwargs) - if "show" in kwargs and kwargs["show"]: - self.show() - - @contextmanager - def _ensure_minimum_sizes(self): - yield - - def show(self): - viewer = self.plotter.show(return_viewer=True) - viewer.layout.width = None # unlock the fixed layout - display(viewer) - - # ------------------------------------ # Non-object-based Widget Abstractions # ------------------------------------ @@ -970,6 +897,7 @@ def _layout_initialize(self, max_width): def _layout_add_widget(self, layout, widget, stretch=0, *, row=None, col=None): widget.layout.margin = "2px 0px 2px 0px" + widget.layout.max_width = "100%" # e.g. a slider's 300px default if not isinstance(widget, Play): widget.layout.min_width = "0px" if isinstance(layout, Accordion): @@ -1044,6 +972,7 @@ def _dock_add_label( widget = HTML(value=value, disabled=True) widget.layout.width = "100px" self._layout_add_widget(layout, widget, row=row, col=col) + widget.layout.min_width = "min-content" # shrink, but not below its text return _IpyWidget(widget) def _dock_add_button( @@ -1340,16 +1269,19 @@ def _playback_initialize(self, func, timeout, value, rng, time_widget, play_widg play.max = rng[1] play.value = round(value) slider = time_widget._widget - jsdlink((play, "value"), (slider, "value")) + # a widget too, which holds both ends open + self._gui_widgets.append(jsdlink((play, "value"), (slider, "value"))) class _IpyMplInterface(_AbstractMplInterface): def _mpl_initialize(self): _soft_import("ipympl", "Drawing figures into a notebook.", strict=True) - import ipympl.backend_nbagg + # the submodule, as `import ipympl` alone does not bring it in + from ipympl.backend_nbagg import Canvas, FigureManager - self.canvas = ipympl.backend_nbagg.Canvas(self.fig) - self.manager = ipympl.backend_nbagg.FigureManager(self.canvas, 0) + self.canvas = Canvas(self.fig) + self.canvas.header_visible = False # "Figure 0", as these are not numbered + self.manager = FigureManager(self.canvas, 0) class _IpyMplCanvas(_AbstractMplCanvas, _IpyMplInterface): @@ -1359,11 +1291,17 @@ def __init__(self, width, height, dpi): class _IpyBrainMplCanvas(_AbstractBrainMplCanvas, _IpyMplInterface): + _in_layout = False # whether _IpyRenderer.show() put it under the scene + def __init__(self, brain, width, height, dpi): super().__init__(brain, width, height, dpi) self._mpl_initialize() self._connect() + def show(self): + if not self._in_layout: + super().show() + class _IpyWindow(_AbstractWindow): def _window_initialize(self, *, window=None, central_layout=None, fullscreen=False): @@ -1402,7 +1340,9 @@ def _window_get_size(self): return self.figure.plotter.window_size def _window_get_simple_canvas(self, width, height, dpi): - return _IpyMplCanvas(width, height, dpi) + canvas = _IpyMplCanvas(width, height, dpi) + self._gui_widgets.append(canvas.canvas) + return canvas def _window_get_help_canvas(self, pairs, mouse_pairs=None): text1, text2 = zip(*pairs, *(mouse_pairs or [])) @@ -1424,6 +1364,8 @@ def _window_get_mplcanvas( self._show_traces = show_traces self._separate_canvas = separate_canvas self._mplcanvas = _IpyBrainMplCanvas(brain, w, h, self._window_get_dpi()) + # now, as Brain's mpl_canvas.clear() drops it before the GUI is closed + self._gui_widgets.append(self._mplcanvas.canvas) return self._mplcanvas def _window_adjust_mplcanvas_layout(self): @@ -1548,8 +1490,7 @@ def set_shortcut(self, shortcut): pass -class _Renderer( - _PyVistaRenderer, +class _IpyRenderer( _IpyDock, _IpyToolBar, _IpyMenuBar, @@ -1560,24 +1501,22 @@ class _Renderer( _IpyKeyPress, _TimeInteraction, ): - _kind = "notebook" + """The ipywidgets GUI the notebook backends put around their drawing renderer. - def __init__(self, *args, **kwargs): + A variant mixes this in *before* its drawing ``_AbstractRenderer``, so that + ``show`` here wins over the drawing one, and supplies the widget the scene is + drawn into through ``_viewer_widget``. Nothing else here collides, so the + drawing renderer is reached by inheritance as usual. + """ + + def _window_initialize(self, *, window=None, central_layout=None, fullscreen=False): self._docks = None self._menu_bar = None self._tool_bar = None self._status_bar = None self._file_picker = _FilePckr(rows=10) - kwargs["notebook"] = True - fullscreen = kwargs.pop("fullscreen", False) - if not _notebook_vtk_works(): - raise RuntimeError( - "Using the notebook backend on Linux requires a compatible " - "VTK setup. Consider using Xfvb or xvfb-run to set up a " - "working virtual display, or install VTK with OSMesa enabled." - ) - super().__init__(*args, **kwargs) - self._window_initialize(fullscreen=fullscreen) + self._gui_widgets = list() # the roots _close_widgets closes from + super()._window_initialize(fullscreen=fullscreen) def _display_default_tool_bar(self): self._tool_bar_initialize() @@ -1586,34 +1525,60 @@ def _display_default_tool_bar(self): desc="Take a screenshot", func=self.screenshot, ) - display(self._tool_bar) + self._display_widget(self._tool_bar) def show(self): # menu bar if self._menu_bar is not None: - display(self._menu_bar) + self._display_widget(self._menu_bar) # tool bar if self._tool_bar is not None: - display(self._tool_bar) + self._display_widget(self._tool_bar) else: self._display_default_tool_bar() # viewer - viewer = self.plotter.show(return_viewer=True) + viewer = self._viewer_widget() + self.figure.display = viewer + mplcanvas = getattr(self, "_mplcanvas", None) + if mplcanvas is not None and not self._separate_canvas: + # the traces go under the scene, in its column, as in _qt.py + mplcanvas._in_layout = True + viewer = VBox([viewer, mplcanvas.canvas]) rendering_row = list() if self._docks is not None and "left" in self._docks: rendering_row.append(self._docks["left"][0]) rendering_row.append(viewer) if self._docks is not None and "right" in self._docks: rendering_row.append(self._docks["right"][0]) - display(HBox(rendering_row)) - self.figure.display = viewer + self._display_widget(HBox(rendering_row)) # status bar if self._status_bar is not None: - display(self._status_bar) + self._display_widget(self._status_bar) # file picker self._file_picker.hide() - display(self._file_picker._widget) + self._display_widget(self._file_picker._widget) return self.scene() - -_testing_context = nullcontext + def _display_widget(self, widget): + self._gui_widgets.append(widget) + display(widget) + + def _close_widgets(self): + """Close the GUI's widgets, which ipywidgets otherwise keeps alive. + + Their callbacks point back at the figure, so until then it cannot die. + """ + for widget in self._gui_widgets: + _close_widget_tree(widget) + self._gui_widgets.clear() + + +def _close_widget_tree(widget): + # closing a widget leaves open the ones it holds: a box's children, a + # widget's layout and style, a link's ends, ipympl's toolbar + widget.close() + for key in widget.keys: + values = getattr(widget, key) + for value in values if isinstance(values, tuple) else (values,): + if isinstance(value, Widget) and value.comm is not None: + _close_widget_tree(value) diff --git a/mne/viz/backends/_pyvista.py b/mne/viz/backends/_pyvista.py index 337e65b2e71..fbe4d5cc003 100644 --- a/mne/viz/backends/_pyvista.py +++ b/mne/viz/backends/_pyvista.py @@ -135,7 +135,7 @@ def _init( if "app_window_class" in signature(_SafeBackgroundPlotter).parameters: self.store["app_window_class"] = _MNEMainWindow else: - from ._notebook import _NotebookPlotter + from ._trame import _NotebookPlotter self._plotter_class = _NotebookPlotter diff --git a/mne/viz/backends/_trame.py b/mne/viz/backends/_trame.py new file mode 100644 index 00000000000..62d1b981845 --- /dev/null +++ b/mne/viz/backends/_trame.py @@ -0,0 +1,163 @@ +"""The ``notebook`` 3D backend: VTK drawn through PyVista and trame.""" + +# Authors: The MNE-Python contributors. +# License: BSD-3-Clause +# Copyright the MNE-Python contributors. + +import asyncio +import threading +import warnings +from contextlib import contextmanager, nullcontext + +from IPython.display import display +from ipywidgets import Widget + +from ._notebook import _IpyRenderer +from ._pyvista import ( + Plotter, + _check_3d_figure, # noqa: F401 + _clear_3d_figure, # noqa: F401 + _close_3d_figure, # noqa: F401 + _close_all, # noqa: F401 + _PyVistaRenderer, + _set_3d_title, # noqa: F401 + _set_3d_view, # noqa: F401 + _take_3d_screenshot, # noqa: F401 +) +from ._utils import _notebook_vtk_works + +_JUPYTER_BACKEND = "trame" + + +class _NotebookPlotter(Plotter): + """PyVista ``Plotter`` for the notebook backend. + + Validate the object returned by show, as PyVista silently falls back static PIL + when the trame Jupyter backend cannot be loaded. + """ + + def show( + self, *args, jupyter_backend=_JUPYTER_BACKEND, return_viewer=False, **kwargs + ): + if jupyter_backend == "none": # asked to display nothing (the app path) + return super().show(*args, jupyter_backend="none", **kwargs) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + viewer = super().show( + *args, + jupyter_backend=jupyter_backend, + return_viewer=return_viewer, + **kwargs, + ) + if not isinstance(viewer, Widget): + reasons = "\n".join( + f"- {w.message}" + for w in caught + if any(key in str(w.message) for key in ("backend", "trame", "static")) + ) + raise RuntimeError( + f'The notebook 3D backend is not functional: the "{jupyter_backend}" ' + "PyVista Jupyter backend returned a " + f"{type(viewer).__module__}.{type(viewer).__qualname__} instead of an " + "interactive widget. This usually means the installed trame packages " + "(trame, trame-vtk, trame-vuetify, trame-pyvista) are missing or " + "mutually incompatible." + + (f"\n\nPyVista reported:\n{reasons}" if reasons else "") + ) + if return_viewer: + return viewer + + +class _3DRenderer(_PyVistaRenderer): + _kind = "notebook" + + def __init__(self, *args, **kwargs): + kwargs["notebook"] = True + super().__init__(*args, **kwargs) + if "show" in kwargs and kwargs["show"]: + self.show() + + @contextmanager + def _ensure_minimum_sizes(self): + yield + + def show(self): + viewer = self.plotter.show(return_viewer=True) + viewer.layout.width = None # unlock the fixed layout + display(viewer) + + +class _Renderer(_IpyRenderer, _PyVistaRenderer): + """VTK drawing through PyVista and trame, with the shared ipywidgets GUI.""" + + _kind = "notebook" + + def __init__(self, *args, **kwargs): + kwargs["notebook"] = True + fullscreen = kwargs.pop("fullscreen", False) + if not _notebook_vtk_works(): + raise RuntimeError( + "Using the notebook backend on Linux requires a compatible " + "VTK setup. Consider using Xfvb or xvfb-run to set up a " + "working virtual display, or install VTK with OSMesa enabled." + ) + super().__init__(*args, **kwargs) + self._window_initialize(fullscreen=fullscreen) + + def _viewer_widget(self): + try: # the kernel's loop, on the main thread, which owns the GL context + self._main_loop = asyncio.get_running_loop() + except RuntimeError: + self._main_loop = None + self._render_pending = False + try: + return self._app_widget() + except (ImportError, RuntimeError): # trame-pyvista without the app + return self.plotter.show(return_viewer=True) + + def _app_widget(self): + """Return the widget of trame-pyvista's application showing the plotter. + + Server rendering streamed by trame-rca, with the application's own + toolbar and a switch to VTK compiled to WebAssembly in the browser. + """ + import pyvista + from trame_pyvista import module + from trame_pyvista.apps import SimpleViewer + from trame_pyvista.jupyter import elegantly_launch + from trame_pyvista.widgets import get_server + + name = pyvista.global_theme.trame.jupyter_server_name + server = get_server(name) + # the application's styles are served only if enabled before launching + server.enable_module(module) + if not server.running: + elegantly_launch(name) + # the bookkeeping show() does (first render, no longer "first time"), + # without PyVista's Jupyter backend displaying anything + self.plotter.show(jupyter_backend="none", return_viewer=False, auto_close=False) + self._trame_app = SimpleViewer(self.plotter, name, mode="remote") + width, height = self.plotter.window_size # the iframe's own size, in px + self._trame_app.ui.iframe_style = ( + f"width: {width}px; height: {height}px; border: none" + ) + return self._trame_app.ui.ipywidget + + def _update(self): + # JupyterLab (>= 4.4) delivers widget events to kernel subshells, which + # run on their own threads; VTK can only render on the thread that owns + # its OpenGL context, so render from the main loop instead. Brain asks + # several times per event, so once. + loop = getattr(self, "_main_loop", None) + if loop is None or threading.current_thread() is threading.main_thread(): + super()._update() + elif not self._render_pending: + self._render_pending = True + loop.call_soon_threadsafe(self._update_from_main) + + def _update_from_main(self): + self._render_pending = False + super()._update() + + +_testing_context = nullcontext diff --git a/mne/viz/backends/_utils.py b/mne/viz/backends/_utils.py index e57bb46b5a3..29485bb38f7 100644 --- a/mne/viz/backends/_utils.py +++ b/mne/viz/backends/_utils.py @@ -29,7 +29,7 @@ VALID_3D_BACKENDS = ( "pyvistaqt", # default 3d backend "notebook", - "jupyterlite_notebook", + "notebook_js", ) # The backends _get_3d_backend() falls back to when none has been set. The # JupyterLite one is left out on purpose: it draws through vtk.js and only diff --git a/mne/viz/backends/renderer.py b/mne/viz/backends/renderer.py index 905f9dacf88..f88d05799ba 100644 --- a/mne/viz/backends/renderer.py +++ b/mne/viz/backends/renderer.py @@ -19,6 +19,7 @@ get_config, logger, verbose_static, + warn, ) from .._3d import _get_3d_option from ..utils import safe_event @@ -30,8 +31,8 @@ _backend_name_map = dict( pyvistaqt="._qt", - notebook="._notebook", - jupyterlite_notebook="._lite", + notebook="._trame", + notebook_js="._lite", ) backend = None @@ -57,6 +58,13 @@ def _get_renderer(*args, **kwargs): def _check_3d_backend_name(backend_name): _validate_type(backend_name, str, "backend_name") backend_name = "pyvistaqt" if backend_name == "pyvista" else backend_name + if backend_name == "jupyterlite_notebook": + warn( + 'The "jupyterlite_notebook" 3D backend was renamed "notebook_js" in ' + "1.14 and the old name will be removed in 1.16.", + FutureWarning, + ) + backend_name = "notebook_js" _check_option("backend_name", backend_name, VALID_3D_BACKENDS) return backend_name @@ -73,10 +81,12 @@ def set_3d_backend(backend_name, verbose=None): backend_name : str The 3d backend to select. See Notes for the capabilities of each backend (``'pyvistaqt'``, ``'notebook'`` and - ``'jupyterlite_notebook'``). + ``'notebook_js'``). .. versionchanged:: 0.24 The ``'pyvista'`` backend was renamed ``'pyvistaqt'``. + .. versionchanged:: 1.14 + The ``'jupyterlite_notebook'`` backend was renamed ``'notebook_js'``. verbose : bool | str | int | None Control verbosity of the logging output. If ``None``, use the default verbosity level. See the :ref:`logging documentation ` and @@ -93,13 +103,15 @@ def set_3d_backend(backend_name, verbose=None): To use PyVista, set ``backend_name`` to ``pyvistaqt`` but the value ``pyvista`` is still supported for backward compatibility. - The ``jupyterlite_notebook`` backend is not in the table below because it is + The ``notebook_js`` backend is not in the table below because it is not a desktop choice: it draws with vtk.js rather than VTK, which has no WebAssembly build, and it is what the documentation's browser notebooks run on. It covers the static 3D figures, so :func:`plot_alignment` (without channel-name labels) and :func:`plot_sparse_source_estimates` work, and - :class:`mne.viz.Brain` draws a single time point with no time viewer, - colorbar or split-hemisphere layout, while :func:`plot_evoked_field` and + :class:`mne.viz.Brain` has the same ipywidgets time viewer GUI as the + ``notebook`` backend, minus the keyboard shortcuts, picking and hover that + need mouse and key events from VTK, but no colorbar or split-hemisphere + layout. :func:`plot_evoked_field` and :func:`snapshot_brain_montage` do not work. On a desktop the other two are better in every way, so it is never selected automatically. @@ -226,7 +238,7 @@ def use_3d_backend(backend_name): # numpydoc ignore=YD01 Parameters ---------- - backend_name : {'pyvistaqt', 'notebook', 'jupyterlite_notebook'} + backend_name : {'pyvistaqt', 'notebook', 'notebook_js'} The 3d backend to use in the context. """ old_backend = set_3d_backend(backend_name) @@ -570,7 +582,8 @@ def shift_time(direction): TimeChange(time=self._current_time_func() + direction * amount), ) - if self.plotter.iren is not None: + # a plotter drawing for a browser has no interactor to bind keys on + if getattr(self.plotter, "iren", None) is not None: self.plotter.add_key_event("n", partial(shift_time, direction=1)) self.plotter.add_key_event("b", partial(shift_time, direction=-1)) diff --git a/mne/viz/backends/tests/test_abstract.py b/mne/viz/backends/tests/test_abstract.py index 9209cd52b2a..1cf2edaf55c 100644 --- a/mne/viz/backends/tests/test_abstract.py +++ b/mne/viz/backends/tests/test_abstract.py @@ -7,7 +7,8 @@ from mne.viz.backends.renderer import _get_backend -def _do_widget_tests(backend): +def _do_widget_tests(backend, renderer_class=None): + """Test a backend's widgets; its 3D renderer can live in another module.""" # testing utils widget_checks = set() @@ -21,7 +22,8 @@ def callback(x=None): window = backend._AppWindow() central_layout = backend._VBoxLayout(scroll=(500, 500)) - renderer = backend._3DRenderer(name="test", size=(200, 200)) + renderer_class = renderer_class or backend._3DRenderer + renderer = renderer_class(name="test", size=(200, 200)) renderer.sphere([0, 0, 0], "red", 1) central_layout._add_widget(renderer.plotter) canvas = backend._Canvas(5, 5, 96) @@ -119,13 +121,15 @@ def test_widget_abstraction_notebook(renderer_notebook, nbexec): from IPython import get_ipython from mne.viz import set_3d_backend + from mne.viz.backends import _notebook from mne.viz.backends.renderer import _get_backend from mne.viz.backends.tests.test_abstract import _do_widget_tests set_3d_backend("notebook") backend = _get_backend() - assert Path(backend.__file__).stem == "_notebook" + assert Path(backend.__file__).stem == "_trame" ipython = get_ipython() ipython.run_line_magic(magic_name="matplotlib", line="widget") - _do_widget_tests(backend) + # the widgets are shared with the notebook_js backend + _do_widget_tests(_notebook, backend._3DRenderer) diff --git a/mne/viz/backends/tests/test_renderer.py b/mne/viz/backends/tests/test_renderer.py index 78d2d5acf56..17d95c4011a 100644 --- a/mne/viz/backends/tests/test_renderer.py +++ b/mne/viz/backends/tests/test_renderer.py @@ -2,11 +2,15 @@ # License: BSD-3-Clause # Copyright the MNE-Python contributors. +import asyncio +import gc +import json import os import platform import subprocess import sys from contextlib import nullcontext +from functools import partial import numpy as np import pytest @@ -18,14 +22,14 @@ from mne.transforms import quat_to_rot, rot_to_quat from mne.viz import Figure3D, get_3d_backend, set_3d_backend from mne.viz.backends._utils import ALLOWED_QUIVER_MODES -from mne.viz.backends.renderer import _get_renderer +from mne.viz.backends.renderer import _check_3d_backend_name, _get_renderer _data_path = testing.data_path(download=False) def _unsupported(renderer): """Return a context for what the browser backend says it cannot draw.""" - if renderer.get_3d_backend() == "jupyterlite_notebook": + if renderer.get_3d_backend() == "notebook_js": return pytest.raises(NotImplementedError, match="browser") return nullcontext() @@ -185,7 +189,7 @@ def test_3d_backend(renderer): # highlighting/hover) without rebuilding the actor or its geometry; the # browser backend merges instances into solid meshes, so it has no such # per-instance colors to update - if renderer.get_3d_backend() != "jupyterlite_notebook": + if renderer.get_3d_backend() != "notebook_js": inst_cloud.point_data["colors"][0] = [0, 0, 255, 255] inst_cloud.Modified() @@ -298,7 +302,7 @@ def test_renderer(renderer, monkeypatch): "backend = mne.viz.get_3d_backend(); " f"assert backend == {repr(backend)}, backend; " # the browser backend must never import VTK, since there is none there - f"assert backend != 'jupyterlite_notebook' or 'vtk' not in sys.modules", + f"assert backend != 'notebook_js' or 'vtk' not in sys.modules", ] monkeypatch.setenv("MNE_3D_BACKEND", backend) try: @@ -310,9 +314,13 @@ def test_renderer(renderer, monkeypatch): def test_set_3d_backend_bad(monkeypatch, tmp_path): """Test that the error emitted when a bad backend name is used.""" - match = "Allowed values are 'pyvistaqt', 'notebook', and 'jupyterlite_notebook'" + match = "Allowed values are 'pyvistaqt', 'notebook', and 'notebook_js'" with pytest.raises(ValueError, match=match): set_3d_backend("invalid") + with pytest.warns( + FutureWarning, match='renamed "notebook_js"' + ): # TODO VERSION: 1.16 + assert _check_3d_backend_name("jupyterlite_notebook") == "notebook_js" # gh-9607 def fail(x): @@ -363,7 +371,7 @@ def test_3d_warning(renderer_pyvistaqt, monkeypatch): assert not _pyvista._is_osmesa(plotter) -# -- jupyterlite_notebook (pyvista-js) backend -------------------------------- +# -- notebook_js (pyvista-js) backend -------------------------------- # What the shared tests above cannot pin down, mostly geometry, since nothing # here can be screenshotted. A unit square, split into two triangles: _RR = np.array([[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0]], float) @@ -371,8 +379,10 @@ def test_3d_warning(renderer_pyvistaqt, monkeypatch): def _scene(rend): - """Return the scene as vtk.js will receive it.""" - return rend.plotter._renderer._build_scene_data() + """Return the scene as vtk.js will receive it, read back from the page.""" + html = rend.plotter.generate_standalone_html() + start = html.index('id="scene-data">') + len('id="scene-data">') + return json.loads(html[start : html.index("", start)]) def test_lite_camera(renderer_lite): @@ -517,41 +527,43 @@ def test_lite_notebook_kernel(renderer_lite, nbexec): from mne.viz.backends import renderer - renderer.set_3d_backend("jupyterlite_notebook") + renderer.set_3d_backend("notebook_js") rend = renderer._get_renderer(bgcolor="white") rr = np.array([[0.0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0]]) rend.mesh(*rr.T, [[0, 1, 2], [0, 2, 3]], color="red") renderer.set_3d_view(rend.scene(), azimuth=90, elevation=90) - scene = rend.plotter._renderer._build_scene_data() + html = rend.plotter.generate_standalone_html() # what the page will run + start = html.index('id="scene-data">') + len('id="scene-data">') + scene = json.loads(html[start : html.index("", start)]) source = scene["actors"][0]["source"] - points = source["points"] - if hasattr(points, "tolist"): # pyvista-js >= 0.17 wraps it in a _Float32Array - points = points.tolist() - np.testing.assert_allclose(np.reshape(points, (-1, 3)), rr, atol=1e-6) - assert source["polys"] == [3, 0, 1, 2, 3, 0, 2, 3] + np.testing.assert_allclose(np.reshape(source["points"], (-1, 3)), rr, atol=1e-6) + assert list(source["polys"]) == [3, 0, 1, 2, 3, 0, 2, 3] np.testing.assert_allclose(scene["camera"]["viewVector"], [0, 1, 0], atol=1e-12) - html = rend.plotter.generate_standalone_html() # what the page will run - assert json.dumps(points).replace(" ", "") in html.replace(" ", "") @testing.requires_testing_data -def test_lite_brain(renderer_lite): +def test_lite_brain(renderer_lite, monkeypatch): """Test Brain draws a static, per-vertex-colored surface through the backend.""" + from ipywidgets import GridBox + import mne stc = mne.read_source_estimate( _data_path / "MEG" / "sample" / "sample_audvis_trunc-meg", "sample" ) kwargs = dict(subject="sample", subjects_dir=_data_path / "subjects", hemi="lh") - brain = stc.plot(views="lat", initial_time=0.1, **kwargs) # time_viewer="auto" + brain = stc.plot(views="lat", initial_time=0.1, time_viewer=False, **kwargs) assert isinstance(brain, mne.viz.Brain) assert brain.time_viewer is False and brain._scalar_bar is None + # the time label ends at x=0.95, in PyVista's font and doubled size + assert "translateX(-100%)" in brain._renderer._labels.value + assert "28px Arial" in brain._renderer._labels.value (actor,) = brain._renderer.plotter.actors colors = actor["mesh"].point_data["Data"] # curvature plus activation, as uint8 RGBA vtk.js uses directly assert colors.dtype == np.uint8 and colors.shape == (len(brain.geo["lh"].coords), 4) assert len(np.unique(colors, axis=0)) > 2 - scene = brain._renderer.plotter._renderer._build_scene_data() + scene = _scene(brain._renderer) assert scene["actors"][0]["scalars"]["direct"] is True # Brain's canonical rotation reaches the camera through `rigid` assert brain._renderer.get_camera(rigid=brain._rigid)[2:4] == pytest.approx( @@ -563,6 +575,66 @@ def test_lite_brain(renderer_lite): mne.viz.Brain(surf="inflated", **{**kwargs, "hemi": "split"}) assert list(mne.viz.Brain._instances) == [brain] # the failed one died + # time_viewer="auto" builds the ipywidgets GUI shared with the notebook backend + brain = stc.plot(views="lat", initial_time=0.1, **kwargs) + rend = brain._renderer + assert "left" in rend._docks and "time_slider" in rend._widgets + assert isinstance(rend._viewer, GridBox) # the widget the scene is drawn into + assert rend._pages[rend._n_drawn % 2].value.startswith("