From 5a823e23363fd1879ffc7b7c2b3e79ab42346811 Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Tue, 11 Aug 2026 09:25:39 -0400 Subject: [PATCH 01/15] ENH: add a vtk.js backend for MNE's 3D renderer VTK cannot load in WebAssembly, so the JupyterLite notebooks need a renderer that draws with vtk.js. MNE does its geometry in numpy and only hands the result to a renderer, so replacing that last step leaves the transform maths to MNE. --- doc/changes/dev/14144.other.rst | 1 + doc/sphinxext/jupyterlite_lite_renderer.py | 590 +++++++++++++++++++++ 2 files changed, 591 insertions(+) create mode 100644 doc/changes/dev/14144.other.rst create mode 100644 doc/sphinxext/jupyterlite_lite_renderer.py diff --git a/doc/changes/dev/14144.other.rst b/doc/changes/dev/14144.other.rst new file mode 100644 index 00000000000..841403d0919 --- /dev/null +++ b/doc/changes/dev/14144.other.rst @@ -0,0 +1 @@ +Add a vtk.js drawing backend for MNE's 3D renderer, used by the JupyterLite documentation where VTK cannot load, by `Natneal B`_. diff --git a/doc/sphinxext/jupyterlite_lite_renderer.py b/doc/sphinxext/jupyterlite_lite_renderer.py new file mode 100644 index 00000000000..dc73be80743 --- /dev/null +++ b/doc/sphinxext/jupyterlite_lite_renderer.py @@ -0,0 +1,590 @@ +"""A pyvista-js drawing backend for MNE's 3D renderer, for JupyterLite. + +MNE's 3D functions (``plot_alignment``, ``plot_bem``, ``plot_sparse_source_estimates``, +``SourceSpaces.plot``, ...) all build their figure the same way: they do their own +geometry and coordinate-frame work in numpy, then hand the result to a renderer +obtained from ``mne.viz.backends.renderer._get_renderer``. Only that last step needs +VTK, and VTK cannot load in WebAssembly. + +So instead of reimplementing those functions one by one, this module supplies a +renderer that draws with pyvista-js (vtk.js) and patches the factory, along with the +``renderer.backend`` global that ``set_3d_view`` and the other scene-level helpers +read directly. MNE then does all of the transform math itself, which matters +because getting a head/MRI/device transform subtly wrong produces a +plausible-looking picture with the sensors in the wrong place, and several of these +tutorials are specifically *about* coordinate alignment. + +What is supported: meshes, surfaces, spheres, tubes and glyphs, enough for the +static figures the docs render. What is not: the interactive ``Brain`` time viewer, +which additionally needs dock widgets and toolbars, and scalar colormaps, which +pyvista-js 0.15 does not have (scalars fall back to a solid color). + +The source is kept as a string because it has to run inside the browser kernel; it +is appended to ``LITE_SETUP_CELL`` in ``jupyterlite_setup_cell.py``, which the docs +build prepends to each JupyterLite notebook. +""" + +# Authors: The MNE-Python contributors. +# License: BSD-3-Clause +# Copyright the MNE-Python contributors. + +LITE_RENDERER_CELL = r''' +# --- pyvista-js drawing backend for MNE's 3D renderer ----------------------- +# Patches mne.viz.backends.renderer._get_renderer so MNE keeps doing its own +# geometry and coordinate-frame work and only the drawing is replaced. +def _lite_view_vector(azimuth): + """Map an MNE azimuth in degrees onto the nearest pyvista-js view vector.""" + _a = float(azimuth) % 360.0 + if 45 <= _a < 135: + return (0.0, -1.0, 0.0) + elif 135 <= _a < 225: + return (1.0, 0.0, 0.0) + elif 225 <= _a < 315: + return (0.0, 1.0, 0.0) + return (-1.0, 0.0, 0.0) + + +def _lite_set_view(plotter, azimuth): + """Point a plotter at the nearest axis-aligned view; no-op without azimuth.""" + if azimuth is None: + return None + try: + plotter.view_vector(_lite_view_vector(azimuth), viewup=(0.0, 0.0, 1.0)) + except Exception: + pass + return None + + +# Every scene a notebook drew used to stay live for the kernel's lifetime, +# because the close helpers on _LiteBackend were no-ops. Track the plotters +# weakly -- so they stay collectable -- and give close_all something to free. +_lite_live_plotters = [] + + +def _lite_release_plotter(plotter, close=True): + """Hand back a plotter's meshes, JS arrays and GPU buffers. + + ``clear()`` empties the actor list, which is where the geometry is held, + so that is what frees the memory. ``close=False`` additionally says not to + tear the render window down -- what trimming an older scene wants, since + the notebook has already drawn it. pyvista-js 0.15 implements neither + ``deep_clean`` nor ``close``, so today the two paths do the same thing; + the flag keeps the intent right if that changes. + """ + import gc as _gc + if plotter is None: + return None + for _i in range(len(_lite_live_plotters) - 1, -1, -1): + _p = _lite_live_plotters[_i]() + if _p is None or _p is plotter: + del _lite_live_plotters[_i] + # pyvista-js is someone else's surface, so use whichever teardown of these + # it actually implements + _names = ("clear", "deep_clean", "close") if close else ("clear", "deep_clean") + for _name in _names: + _fn = getattr(plotter, _name, None) + if _fn is not None: + try: + _fn() + except Exception: + pass + _gc.collect() + return None + + +# Each live scene holds its meshes in the WASM heap, a copy of them in JS and +# a set of GPU buffers. Nothing in a notebook calls close_3d_figure, so without +# a cap they all stay: 20_source_alignment builds six, which is enough to run +# the tab out of memory. Keep the newest few and give the rest their geometry +# back as new ones arrive -- scrolling back shows an empty canvas, which is a +# far better outcome than losing the page. +_LITE_MAX_LIVE_SCENES = 2 + + +def _lite_trim_live_plotters(): + """Release everything but the most recent scenes.""" + while len(_lite_live_plotters) > _LITE_MAX_LIVE_SCENES: + _p = _lite_live_plotters[0]() + if _p is None: + _lite_live_plotters.pop(0) + else: + # also drops it from the registry, so this terminates + _lite_release_plotter(_p, close=False) + return None + + +class _LiteRenderer: + """Minimal MNE 3D renderer backed by pyvista-js.""" + + def __init__(self, *args, **kwargs): + import numpy as _np + import pyvista_js as _pv + self._np = _np + self._pv = _pv + # plot_alignment(fig=...) and plot_dipole_locations(fig=...) composite + # into a scene the notebook already made, so draw into that plotter + # rather than opening a second one and splitting the picture in two. + # plot_alignment passes it positionally and create_3d_figure by name, + # and `fig` is _PyVistaRenderer's first argument, so accept both. + _fig = args[0] if args else kwargs.get("fig", None) + if _fig is not None and hasattr(_fig, "add_mesh"): + self.plotter = _fig + return + self.plotter = _pv.Plotter() + import weakref as _weakref + _lite_live_plotters.append(_weakref.ref(self.plotter)) + # trim AFTER appending, so the scene being built is never the one freed + _lite_trim_live_plotters() + _bg = kwargs.get("bgcolor", kwargs.get("background_color", "black")) + try: + self.plotter.background_color = self._rgb(_bg) + except Exception: + pass + # even lighting, so a surface is not black when rotated + for _lp in ((1, 0, 0), (-1, 0, 0), (0, 1, 0), + (0, -1, 0), (0, 0, 1), (0, 0, -1)): + try: + self.plotter.add_light(_pv.Light( + position=(300.0 * _lp[0], 300.0 * _lp[1], 300.0 * _lp[2]), + focal_point=(0.0, 0.0, 0.0), intensity=0.4)) + except Exception: + pass + + # -- helpers ------------------------------------------------------------ + def _rgb(self, color): + """Return an (r, g, b) 0-1 tuple; pyvista-js rejects hex strings.""" + if color is None: + return (0.5, 0.5, 0.5) + from matplotlib.colors import to_rgb as _to_rgb + if isinstance(color, str): + return _to_rgb(color) + _c = self._np.asarray(color, dtype=float).ravel()[:3] + if _c.size < 3: + return (0.5, 0.5, 0.5) + if _c.max() > 1.0: # 0-255 form + _c = _c / 255.0 + return tuple(float(min(max(_v, 0.0), 1.0)) for _v in _c) + + def _faces(self, tris): + _np = self._np + _t = _np.asarray(tris, dtype=_np.int32).reshape(-1, 3) + return _np.hstack([ + _np.full((len(_t), 1), 3, dtype=_np.int32), _t]).ravel() + + def _subdivide(self, rr, tris): + """One level of midpoint subdivision, sharing the new edge vertices.""" + _np = self._np + _rr = [tuple(_v) for _v in _np.asarray(rr, dtype=float)] + _mid = {} + _out = [] + for _a, _b, _c in _np.asarray(tris, dtype=int): + _m = [] + for _p, _q in ((_a, _b), (_b, _c), (_c, _a)): + _k = (min(int(_p), int(_q)), max(int(_p), int(_q))) + if _k not in _mid: + _mid[_k] = len(_rr) + _rr.append(tuple((_np.asarray(_rr[_p]) + + _np.asarray(_rr[_q])) / 2.0)) + _m.append(_mid[_k]) + _ab, _bc, _ca = _m + _out += [[_a, _ab, _ca], [_ab, _b, _bc], [_ca, _bc, _c], + [_ab, _bc, _ca]] + return _np.asarray(_rr, dtype=float), _np.asarray(_out, dtype=int) + + def _glyph_template(self, kind, radius=None, height=None, center=None, + resolution=None, **kwargs): + """Return (rr, tris) for a glyph template, oriented along +x. + + pyvista-js's Sphere/Cylinder are parametric primitives with no + triangle list, so build the templates here. ``_tile`` then stamps one + of these at every position and merges the result, which is what keeps + these cheap -- the copies share a single mesh and a single actor. + + Sizes follow the templates ``_pyvista.py`` hands the glyph filter, so + the browser draws the markers at the size the rendered docs do. + """ + _np = self._np + if kind in ("sphere", "oct"): + _r = 0.5 if radius is None else float(radius) + rr = _np.array([[1.0, 0, 0], [-1.0, 0, 0], [0, 1.0, 0], + [0, -1.0, 0], [0, 0, 1.0], [0, 0, -1.0]], float) + tris = _np.array([[0, 2, 4], [2, 1, 4], [1, 3, 4], [3, 0, 4], + [2, 0, 5], [1, 2, 5], [3, 1, 5], [0, 3, 5]], int) + # "oct" is an octahedron on purpose -- that is what _pyvista.py + # hands the glyph filter. A "sphere" has to look round, though: + # fiducials and dig points are drawn with it, so subdivide onto the + # unit sphere to land near the reference's 8x8 sphere (58 verts). + if kind == "sphere": + for _ in range(2): + rr, tris = self._subdivide(rr, tris) + rr /= _np.linalg.norm(rr, axis=1)[:, None] + return rr * _r, tris + if kind == "cone": + # apex along +x so the glyph filter's orientation applies, matching + # pyvista.Cone(center=(0.5, 0, 0)): base at x=0, apex at x=height + _r = 0.15 if radius is None else float(radius) + _h = 1.0 if height is None else float(height) + _n = 8 if not resolution else max(3, int(resolution) // 2) + _ang = _np.linspace(0.0, 2 * _np.pi, _n, endpoint=False) + _ring = _np.column_stack([_np.zeros(_n), _r * _np.cos(_ang), + _r * _np.sin(_ang)]) + rr = _np.vstack([_ring, [[_h, 0, 0]], [[0.0, 0, 0]]]) + tris = [] + for _i in range(_n): + _j = (_i + 1) % _n + tris += [[_i, _j, _n], [_n + 1, _j, _i]] # side, base + return rr, _np.asarray(tris, int) + # cylinder along +x, matching _cylinder_geom's convention + _r = 0.1 if radius is None else float(radius) + _h = 1.0 if height is None else float(height) + _n = 8 if not resolution else max(3, int(resolution) // 2) + _c = _np.zeros(3) if center is None else _np.asarray(center, float) + _ang = _np.linspace(0.0, 2 * _np.pi, _n, endpoint=False) + _ring = _np.column_stack([_np.zeros(_n), _r * _np.cos(_ang), + _r * _np.sin(_ang)]) + _back = _ring + _np.array([-_h / 2.0, 0, 0]) + _front = _ring + _np.array([_h / 2.0, 0, 0]) + rr = _np.vstack([_back, _front, + [[-_h / 2.0, 0, 0]], [[_h / 2.0, 0, 0]]]) + _c + tris = [] + for _i in range(_n): + _j = (_i + 1) % _n + tris += [[_i, _j, _n + _j], [_i, _n + _j, _n + _i]] # wall + tris += [[2 * _n, _j, _i]] # back cap + tris += [[2 * _n + 1, _n + _i, _n + _j]] # front cap + return rr, _np.asarray(tris, int) + + def _add(self, points, tris, color, opacity=1.0): + """Draw a mesh and return MNE's (actor, mesh) pair. + + ``opacity=None`` means "renderer default" in MNE's renderer API, which + for PyVista reaches ``add_mesh(opacity=None)`` and draws opaque. Every + drawing method here funnels through this, so translating it once covers + all of them. + """ + _np = self._np + _pd = self._pv.PolyData( + points=_np.asarray(points, dtype=_np.float32), + faces=self._faces(tris)) + _actor = self.plotter.add_mesh( + _pd, color=self._rgb(color), + opacity=1.0 if opacity is None else float(opacity), + smooth_shading=True) + return _actor, _pd + + def _rots_from_dirs(self, dirs): + """Rotations carrying +x onto each direction, as the glyphs assume.""" + _np = self._np + from mne.transforms import _find_vector_rotation as _fvr + _x = _np.array([1.0, 0.0, 0.0]) + return _np.asarray([_fvr(_x, _d) for _d in dirs], dtype=float) + + def _tile(self, rr, tris, positions, scales=None, rots=None, + axis_scales=None): + """Stamp one template mesh at many positions as a single mesh. + + ``_pyvista.py`` hands its template to VTK's glyph filter, which bakes + every copy into one mesh and adds it once. Doing this per position + instead means an oct-6 source space becomes 8196 meshes and 8196 + actors, which is enough to run the browser tab out of memory. + """ + _np = self._np + _rr = _np.asarray(rr, dtype=float) + _tris = _np.asarray(tris, dtype=int) + _pos = _np.atleast_2d(_np.asarray(positions, dtype=float))[:, :3] + _n = len(_pos) + _pts = _np.repeat(_rr[None, :, :], _n, axis=0) + if axis_scales is not None: + # tubes span a given length without fattening, so scale the + # template's axis alone + _ax = _np.atleast_1d(_np.asarray(axis_scales, dtype=float)) + _pts[:, :, 0] *= _ax[_np.arange(_n) % len(_ax)][:, None] + if scales is not None: + _sa = _np.atleast_1d(_np.asarray(scales, dtype=float)) + _pts *= _sa[_np.arange(_n) % len(_sa)][:, None, None] + if rots is not None: + _ra = _np.asarray(rots, dtype=float) + _pts = _np.einsum( + 'nij,nkj->nki', _ra[_np.arange(_n) % len(_ra)], _pts) + _pts += _pos[:, None, :] + _off = (_np.arange(_n) * len(_rr))[:, None, None] + return (_pts.reshape(-1, 3), + (_tris[None, :, :] + _off).reshape(-1, 3)) + + # -- drawing ------------------------------------------------------------ + def mesh(self, x, y, z, triangles, color=None, opacity=1.0, *args, **kwargs): + _np = self._np + _pts = _np.column_stack([_np.asarray(x).ravel(), + _np.asarray(y).ravel(), + _np.asarray(z).ravel()]) + return self._add(_pts, triangles, color, opacity) + + def surface(self, surface, color=None, opacity=1.0, *args, **kwargs): + return self._add(surface["rr"], surface["tris"], color, opacity) + + def sphere(self, center, color=None, scale=1.0, opacity=1.0, + resolution=8, backface_culling=False, radius=None, **kwargs): + _np = self._np + _c = _np.atleast_2d(_np.asarray(center, dtype=float)) + if not len(_c): + return None, None + _r = float(radius if radius is not None else scale) + _rr, _tris = self._glyph_template("sphere", radius=_r, + resolution=resolution) + _pts, _faces = self._tile(_rr, _tris, _c) + return self._add(_pts, _faces, color, opacity) + + def tube(self, origin, destination, radius=0.001, color=None, *args, + **kwargs): + _np = self._np + _o = _np.atleast_2d(_np.asarray(origin, dtype=float))[:, :3] + _d = _np.atleast_2d(_np.asarray(destination, dtype=float))[:, :3] + _n = min(len(_o), len(_d)) + if not _n: + return None, None + _vec = _d[:_n] - _o[:_n] + _len = _np.linalg.norm(_vec, axis=1) + _keep = _len > 0 + if not _keep.any(): + return None, None + _vec, _len = _vec[_keep], _len[_keep] + _ctr = (_o[:_n][_keep] + _d[:_n][_keep]) / 2.0 + # one unit-height template stretched to each segment, merged into a + # single mesh rather than a cylinder primitive per segment + _rr, _tris = self._glyph_template("cylinder", radius=float(radius), + height=1.0) + _pts, _faces = self._tile( + _rr, _tris, _ctr, rots=self._rots_from_dirs(_vec / _len[:, None]), + axis_scales=_len) + return self._add(_pts, _faces, color, kwargs.get("opacity", 1.0)) + + def quiver3d(self, x, y, z, u, v, w, color=None, scale=1.0, mode="arrow", + opacity=1.0, *, glyph_height=None, glyph_center=None, + glyph_resolution=None, glyph_radius=0.15, + solid_transform=None, **kwargs): + """Draw one merged glyph mesh, the way the glyph filter would. + + ``_pyvista.py`` builds a template, lets VTK's glyph filter bake a copy + at every point into one mesh, and adds that once. Drawing a primitive + per point instead is what made ``20_source_alignment`` -- an oct-6 + source space, so 8196 glyphs, twice -- exhaust the browser tab. + """ + _np = self._np + _x, _y, _z = (_np.atleast_1d(_np.asarray(_q, dtype=float)) + for _q in (x, y, z)) + _ctr = _np.column_stack([_x, _y, _z]) + _n = len(_ctr) + if not _n: + return None, None + _s = float(_np.asarray(scale).ravel()[0]) if _np.size(scale) else 1.0 + _i = _np.arange(_n) + _u, _v, _w = (_np.atleast_1d(_np.asarray(_q, dtype=float)) + for _q in (u, v, w)) + _dirs = _np.column_stack([_u[_i % len(_u)], _v[_i % len(_v)], + _w[_i % len(_w)]]) + _norm = _np.linalg.norm(_dirs, axis=1) + _flat = _norm == 0 + _dirs[_flat] = (1.0, 0.0, 0.0) + _norm[_flat] = 1.0 + _dirs = _dirs / _norm[:, None] + # the same templates _pyvista.py feeds the filter; `scale` then plays + # the part its `factor` does + if mode == "oct": + # vtkPlatonicSolidSource puts its octahedron on the unit + # circumsphere, and the MRI fiducials get their real size from + # solid_transform (mri_fid_scale, 5 mm) rather than from `scale` + _kind, _tkw = "oct", dict(radius=1.0) + elif mode == "sphere": + _kind, _tkw = "sphere", dict(radius=0.5) + elif mode == "cylinder": + _kind = "cylinder" + _tkw = dict(radius=glyph_radius, height=glyph_height, + center=glyph_center, resolution=glyph_resolution) + else: # arrow / cone / 2darrow + _kind = "cone" + _tkw = dict(radius=glyph_radius, height=glyph_height, + resolution=glyph_resolution) + _rr, _tris = self._glyph_template(_kind, **_tkw) + if solid_transform is not None: + # _pyvista.py transforms the template before glyphing, and this is + # where the fiducial markers get their size and 45 deg roll + _st = _np.asarray(solid_transform, dtype=float) + _rr = _rr @ _st[:3, :3].T + _st[:3, 3] + _rots = (None if mode in ("sphere", "oct") + else self._rots_from_dirs(_dirs)) + _pts, _faces = self._tile(_rr, _tris, _ctr, scales=_s, rots=_rots) + return self._add(_pts, _faces, color, opacity) + + def instanced_mesh(self, rr, tris, positions, quats=None, colors=None, + scales=None, opacity=1.0, *args, **kwargs): + """Stamp the template at every position, merged per distinct color. + + Rotate with MNE's own quaternion helper so oriented glyphs (EEG + cylinders) point the way MNE intended rather than all along +x. + pyvista-js has no per-vertex color, so instances are grouped by the + color they asked for and each group becomes one mesh -- a handful of + actors for a sensor array instead of one per sensor. + """ + _np = self._np + _pos = _np.atleast_2d(_np.asarray(positions, dtype=float))[:, :3] + _n = len(_pos) + if not _n: + return None, None + _rot = None + if quats is not None: + from mne.transforms import quat_to_rot as _q2r + _rot = _np.asarray(_q2r(_np.atleast_2d( + _np.asarray(quats, dtype=float))), dtype=float) + _idx = _np.arange(_n) + if colors is not None and _np.ndim(colors) > 1: + _ca = _np.asarray(colors) + _uniq, _inv = _np.unique(_ca[_idx % len(_ca)], axis=0, + return_inverse=True) + _inv = _np.asarray(_inv).ravel() + _groups = [(_uniq[_k], _idx[_inv == _k]) + for _k in range(len(_uniq))] + else: + _groups = [(colors, _idx)] + _out = (None, None) + for _col, _sel in _groups: + _sc = None + if scales is not None: + _sa = _np.atleast_1d(_np.asarray(scales, dtype=float)) + _sc = _sa[_sel % len(_sa)] + _rt = None if _rot is None else _rot[_sel % len(_rot)] + _pts, _faces = self._tile(rr, tris, _pos[_sel], scales=_sc, + rots=_rt) + _out = self._add(_pts, _faces, _col, opacity) + return _out + + # -- things the static docs do not need --------------------------------- + def contour(self, *args, **kwargs): + # pyvista-js 0.15 has no scalar contouring; callers unpack a pair + return None, None + + def text2d(self, *args, **kwargs): + return None + + def text3d(self, *args, **kwargs): + return None + + def scalarbar(self, *args, **kwargs): + return None + + def legend(self, *args, **kwargs): + return None + + def subplot(self, *args, **kwargs): + return None + + def set_interaction(self, *args, **kwargs): + return None + + def remove_mesh(self, *args, **kwargs): + return None + + def project(self, xyz, ch_names): + return self._np.asarray(xyz, dtype=float)[:, :2] + + def screenshot(self, mode="rgb", filename=None, **kwargs): + return self._np.zeros((2, 2, 3), dtype="uint8") + + def close(self): + return None + + def _update(self, *args, **kwargs): + return None + + def _process_events(self, *args, **kwargs): + return None + + def _enable_time_interaction(self, *args, **kwargs): + # the figures are static here; there is no time slider to wire up + return None + + def _window_close_connect(self, *args, **kwargs): + return None + + def _window_set_cursor(self, *args, **kwargs): + return None + + def get_camera(self, *args, **kwargs): + return (0.0, 0.0, 1.0, (0.0, 0.0, 0.0), 0.0) + + def set_camera(self, azimuth=None, elevation=None, distance=None, + focalpoint=None, roll=None, *args, **kwargs): + # pyvista-js has no azimuth/elevation camera; approximate the common + # views and otherwise leave the default. + return _lite_set_view(self.plotter, azimuth) + + @property + def figure(self): + """The scene, under the name the tutorials reach for. + + ``_PyVistaRenderer`` hands out one object as both ``.figure`` and + ``.scene()``; ``20_source_alignment`` builds a renderer itself with + ``create_3d_figure(scene=False)`` and then passes ``renderer.figure`` + to ``set_3d_view``, so the two have to stay the same thing here too. + """ + return self.plotter + + def scene(self): + return self.plotter + + def show(self): + try: + self.plotter.show() + except Exception as _e: + print("[JupyterLite] pyvista-js render failed: " + repr(_e)) + return None + + +def _lite_get_renderer(*args, **kwargs): + return _LiteRenderer(*args, **kwargs) + + +class _LiteBackend: + """Stand-in for the module MNE imports into ``renderer.backend``. + + ``set_3d_view``, ``set_3d_title`` and the ``close_*`` helpers are module-level + functions that reach for that global directly instead of going through + ``_get_renderer``, so replacing the factory alone leaves them calling into + ``None``. The figure they are handed is the pyvista-js plotter that + ``_LiteRenderer.scene`` returns. + """ + + def _set_3d_view(self, figure, azimuth=None, elevation=None, + focalpoint=None, distance=None, roll=None): + return _lite_set_view(figure, azimuth) + + def _set_3d_title(self, figure, title, size=40, color="white", + position="upper_left"): + return None + + def _close_3d_figure(self, figure): + _lite_release_plotter(figure) + return None + + def _close_all(self): + # the registry holds weak references, so deref before releasing -- + # handing the ref itself to _lite_release_plotter matches nothing and + # never shortens the list + while _lite_live_plotters: + _p = _lite_live_plotters[-1]() + if _p is None: + _lite_live_plotters.pop() + else: + _lite_release_plotter(_p) + return None + + +try: + import mne.viz.backends.renderer as _mne_rend + _mne_rend._get_renderer = _lite_get_renderer + _mne_rend.backend = _LiteBackend() + # naming a backend keeps _get_3d_backend() from walking VALID_3D_BACKENDS and + # importing _qt, which would overwrite the stub above on its way to failing + _mne_rend.MNE_3D_BACKEND = "notebook" +except Exception as _e: + print("[JupyterLite] could not install the pyvista-js renderer: " + repr(_e)) +''' From eb3c8143ac9e12d48e64b0bac130938b92ffbb6f Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Mon, 17 Aug 2026 16:52:51 -0400 Subject: [PATCH 02/15] MAINT: move the vtk.js renderer into a real module The renderer was a 560-line string literal, which no linter or formatter could see. It now lives in _lite_renderer_cell.py as ordinary Python and the cell is read from there, so ruff covers it like any other file. The code itself is unchanged apart from what the formatter did to it. --- doc/sphinxext/_lite_renderer_cell.py | 649 +++++++++++++++++++++ doc/sphinxext/jupyterlite_lite_renderer.py | 575 +----------------- 2 files changed, 663 insertions(+), 561 deletions(-) create mode 100644 doc/sphinxext/_lite_renderer_cell.py diff --git a/doc/sphinxext/_lite_renderer_cell.py b/doc/sphinxext/_lite_renderer_cell.py new file mode 100644 index 00000000000..ddbd3827bf9 --- /dev/null +++ b/doc/sphinxext/_lite_renderer_cell.py @@ -0,0 +1,649 @@ +# Authors: The MNE-Python contributors. +# License: BSD-3-Clause +# Copyright the MNE-Python contributors. +# --- pyvista-js drawing backend for MNE's 3D renderer ----------------------- +# Patches mne.viz.backends.renderer._get_renderer so MNE keeps doing its own +# geometry and coordinate-frame work and only the drawing is replaced. +def _lite_view_vector(azimuth): + """Map an MNE azimuth in degrees onto the nearest pyvista-js view vector.""" + _a = float(azimuth) % 360.0 + if 45 <= _a < 135: + return (0.0, -1.0, 0.0) + elif 135 <= _a < 225: + return (1.0, 0.0, 0.0) + elif 225 <= _a < 315: + return (0.0, 1.0, 0.0) + return (-1.0, 0.0, 0.0) + + +def _lite_set_view(plotter, azimuth): + """Point a plotter at the nearest axis-aligned view; no-op without azimuth.""" + if azimuth is None: + return None + try: + plotter.view_vector(_lite_view_vector(azimuth), viewup=(0.0, 0.0, 1.0)) + except Exception: + pass + return None + + +# Every scene a notebook drew used to stay live for the kernel's lifetime, +# because the close helpers on _LiteBackend were no-ops. Track the plotters +# weakly -- so they stay collectable -- and give close_all something to free. +_lite_live_plotters = [] + + +def _lite_release_plotter(plotter, close=True): + """Hand back a plotter's meshes, JS arrays and GPU buffers. + + ``clear()`` empties the actor list, which is where the geometry is held, + so that is what frees the memory. ``close=False`` additionally says not to + tear the render window down -- what trimming an older scene wants, since + the notebook has already drawn it. pyvista-js 0.15 implements neither + ``deep_clean`` nor ``close``, so today the two paths do the same thing; + the flag keeps the intent right if that changes. + """ + import gc as _gc + + if plotter is None: + return None + for _i in range(len(_lite_live_plotters) - 1, -1, -1): + _p = _lite_live_plotters[_i]() + if _p is None or _p is plotter: + del _lite_live_plotters[_i] + # pyvista-js is someone else's surface, so use whichever teardown of these + # it actually implements + _names = ("clear", "deep_clean", "close") if close else ("clear", "deep_clean") + for _name in _names: + _fn = getattr(plotter, _name, None) + if _fn is not None: + try: + _fn() + except Exception: + pass + _gc.collect() + return None + + +# Each live scene holds its meshes in the WASM heap, a copy of them in JS and +# a set of GPU buffers. Nothing in a notebook calls close_3d_figure, so without +# a cap they all stay: 20_source_alignment builds six, which is enough to run +# the tab out of memory. Keep the newest few and give the rest their geometry +# back as new ones arrive -- scrolling back shows an empty canvas, which is a +# far better outcome than losing the page. +_LITE_MAX_LIVE_SCENES = 2 + + +def _lite_trim_live_plotters(): + """Release everything but the most recent scenes.""" + while len(_lite_live_plotters) > _LITE_MAX_LIVE_SCENES: + _p = _lite_live_plotters[0]() + if _p is None: + _lite_live_plotters.pop(0) + else: + # also drops it from the registry, so this terminates + _lite_release_plotter(_p, close=False) + return None + + +class _LiteRenderer: + """Minimal MNE 3D renderer backed by pyvista-js.""" + + def __init__(self, *args, **kwargs): + import numpy as _np + import pyvista_js as _pv + + self._np = _np + self._pv = _pv + # plot_alignment(fig=...) and plot_dipole_locations(fig=...) composite + # into a scene the notebook already made, so draw into that plotter + # rather than opening a second one and splitting the picture in two. + # plot_alignment passes it positionally and create_3d_figure by name, + # and `fig` is _PyVistaRenderer's first argument, so accept both. + _fig = args[0] if args else kwargs.get("fig", None) + if _fig is not None and hasattr(_fig, "add_mesh"): + self.plotter = _fig + return + self.plotter = _pv.Plotter() + import weakref as _weakref + + _lite_live_plotters.append(_weakref.ref(self.plotter)) + # trim AFTER appending, so the scene being built is never the one freed + _lite_trim_live_plotters() + _bg = kwargs.get("bgcolor", kwargs.get("background_color", "black")) + try: + self.plotter.background_color = self._rgb(_bg) + except Exception: + pass + # even lighting, so a surface is not black when rotated + for _lp in ( + (1, 0, 0), + (-1, 0, 0), + (0, 1, 0), + (0, -1, 0), + (0, 0, 1), + (0, 0, -1), + ): + try: + self.plotter.add_light( + _pv.Light( + position=(300.0 * _lp[0], 300.0 * _lp[1], 300.0 * _lp[2]), + focal_point=(0.0, 0.0, 0.0), + intensity=0.4, + ) + ) + except Exception: + pass + + # -- helpers ------------------------------------------------------------ + def _rgb(self, color): + """Return an (r, g, b) 0-1 tuple; pyvista-js rejects hex strings.""" + if color is None: + return (0.5, 0.5, 0.5) + from matplotlib.colors import to_rgb as _to_rgb + + if isinstance(color, str): + return _to_rgb(color) + _c = self._np.asarray(color, dtype=float).ravel()[:3] + if _c.size < 3: + return (0.5, 0.5, 0.5) + if _c.max() > 1.0: # 0-255 form + _c = _c / 255.0 + return tuple(float(min(max(_v, 0.0), 1.0)) for _v in _c) + + def _faces(self, tris): + _np = self._np + _t = _np.asarray(tris, dtype=_np.int32).reshape(-1, 3) + return _np.hstack([_np.full((len(_t), 1), 3, dtype=_np.int32), _t]).ravel() + + def _subdivide(self, rr, tris): + """One level of midpoint subdivision, sharing the new edge vertices.""" + _np = self._np + _rr = [tuple(_v) for _v in _np.asarray(rr, dtype=float)] + _mid = {} + _out = [] + for _a, _b, _c in _np.asarray(tris, dtype=int): + _m = [] + for _p, _q in ((_a, _b), (_b, _c), (_c, _a)): + _k = (min(int(_p), int(_q)), max(int(_p), int(_q))) + if _k not in _mid: + _mid[_k] = len(_rr) + _rr.append( + tuple((_np.asarray(_rr[_p]) + _np.asarray(_rr[_q])) / 2.0) + ) + _m.append(_mid[_k]) + _ab, _bc, _ca = _m + _out += [[_a, _ab, _ca], [_ab, _b, _bc], [_ca, _bc, _c], [_ab, _bc, _ca]] + return _np.asarray(_rr, dtype=float), _np.asarray(_out, dtype=int) + + def _glyph_template( + self, kind, radius=None, height=None, center=None, resolution=None, **kwargs + ): + """Return (rr, tris) for a glyph template, oriented along +x. + + pyvista-js's Sphere/Cylinder are parametric primitives with no + triangle list, so build the templates here. ``_tile`` then stamps one + of these at every position and merges the result, which is what keeps + these cheap -- the copies share a single mesh and a single actor. + + Sizes follow the templates ``_pyvista.py`` hands the glyph filter, so + the browser draws the markers at the size the rendered docs do. + """ + _np = self._np + if kind in ("sphere", "oct"): + _r = 0.5 if radius is None else float(radius) + rr = _np.array( + [ + [1.0, 0, 0], + [-1.0, 0, 0], + [0, 1.0, 0], + [0, -1.0, 0], + [0, 0, 1.0], + [0, 0, -1.0], + ], + float, + ) + tris = _np.array( + [ + [0, 2, 4], + [2, 1, 4], + [1, 3, 4], + [3, 0, 4], + [2, 0, 5], + [1, 2, 5], + [3, 1, 5], + [0, 3, 5], + ], + int, + ) + # "oct" is an octahedron on purpose -- that is what _pyvista.py + # hands the glyph filter. A "sphere" has to look round, though: + # fiducials and dig points are drawn with it, so subdivide onto the + # unit sphere to land near the reference's 8x8 sphere (58 verts). + if kind == "sphere": + for _ in range(2): + rr, tris = self._subdivide(rr, tris) + rr /= _np.linalg.norm(rr, axis=1)[:, None] + return rr * _r, tris + if kind == "cone": + # apex along +x so the glyph filter's orientation applies, matching + # pyvista.Cone(center=(0.5, 0, 0)): base at x=0, apex at x=height + _r = 0.15 if radius is None else float(radius) + _h = 1.0 if height is None else float(height) + _n = 8 if not resolution else max(3, int(resolution) // 2) + _ang = _np.linspace(0.0, 2 * _np.pi, _n, endpoint=False) + _ring = _np.column_stack( + [_np.zeros(_n), _r * _np.cos(_ang), _r * _np.sin(_ang)] + ) + rr = _np.vstack([_ring, [[_h, 0, 0]], [[0.0, 0, 0]]]) + tris = [] + for _i in range(_n): + _j = (_i + 1) % _n + tris += [[_i, _j, _n], [_n + 1, _j, _i]] # side, base + return rr, _np.asarray(tris, int) + # cylinder along +x, matching _cylinder_geom's convention + _r = 0.1 if radius is None else float(radius) + _h = 1.0 if height is None else float(height) + _n = 8 if not resolution else max(3, int(resolution) // 2) + _c = _np.zeros(3) if center is None else _np.asarray(center, float) + _ang = _np.linspace(0.0, 2 * _np.pi, _n, endpoint=False) + _ring = _np.column_stack( + [_np.zeros(_n), _r * _np.cos(_ang), _r * _np.sin(_ang)] + ) + _back = _ring + _np.array([-_h / 2.0, 0, 0]) + _front = _ring + _np.array([_h / 2.0, 0, 0]) + rr = _np.vstack([_back, _front, [[-_h / 2.0, 0, 0]], [[_h / 2.0, 0, 0]]]) + _c + tris = [] + for _i in range(_n): + _j = (_i + 1) % _n + tris += [[_i, _j, _n + _j], [_i, _n + _j, _n + _i]] # wall + tris += [[2 * _n, _j, _i]] # back cap + tris += [[2 * _n + 1, _n + _i, _n + _j]] # front cap + return rr, _np.asarray(tris, int) + + def _add(self, points, tris, color, opacity=1.0): + """Draw a mesh and return MNE's (actor, mesh) pair. + + ``opacity=None`` means "renderer default" in MNE's renderer API, which + for PyVista reaches ``add_mesh(opacity=None)`` and draws opaque. Every + drawing method here funnels through this, so translating it once covers + all of them. + """ + _np = self._np + _pd = self._pv.PolyData( + points=_np.asarray(points, dtype=_np.float32), faces=self._faces(tris) + ) + _actor = self.plotter.add_mesh( + _pd, + color=self._rgb(color), + opacity=1.0 if opacity is None else float(opacity), + smooth_shading=True, + ) + return _actor, _pd + + def _rots_from_dirs(self, dirs): + """Rotations carrying +x onto each direction, as the glyphs assume.""" + _np = self._np + from mne.transforms import _find_vector_rotation as _fvr + + _x = _np.array([1.0, 0.0, 0.0]) + return _np.asarray([_fvr(_x, _d) for _d in dirs], dtype=float) + + def _tile(self, rr, tris, positions, scales=None, rots=None, axis_scales=None): + """Stamp one template mesh at many positions as a single mesh. + + ``_pyvista.py`` hands its template to VTK's glyph filter, which bakes + every copy into one mesh and adds it once. Doing this per position + instead means an oct-6 source space becomes 8196 meshes and 8196 + actors, which is enough to run the browser tab out of memory. + """ + _np = self._np + _rr = _np.asarray(rr, dtype=float) + _tris = _np.asarray(tris, dtype=int) + _pos = _np.atleast_2d(_np.asarray(positions, dtype=float))[:, :3] + _n = len(_pos) + _pts = _np.repeat(_rr[None, :, :], _n, axis=0) + if axis_scales is not None: + # tubes span a given length without fattening, so scale the + # template's axis alone + _ax = _np.atleast_1d(_np.asarray(axis_scales, dtype=float)) + _pts[:, :, 0] *= _ax[_np.arange(_n) % len(_ax)][:, None] + if scales is not None: + _sa = _np.atleast_1d(_np.asarray(scales, dtype=float)) + _pts *= _sa[_np.arange(_n) % len(_sa)][:, None, None] + if rots is not None: + _ra = _np.asarray(rots, dtype=float) + _pts = _np.einsum("nij,nkj->nki", _ra[_np.arange(_n) % len(_ra)], _pts) + _pts += _pos[:, None, :] + _off = (_np.arange(_n) * len(_rr))[:, None, None] + return (_pts.reshape(-1, 3), (_tris[None, :, :] + _off).reshape(-1, 3)) + + # -- drawing ------------------------------------------------------------ + def mesh(self, x, y, z, triangles, color=None, opacity=1.0, *args, **kwargs): + _np = self._np + _pts = _np.column_stack( + [_np.asarray(x).ravel(), _np.asarray(y).ravel(), _np.asarray(z).ravel()] + ) + return self._add(_pts, triangles, color, opacity) + + def surface(self, surface, color=None, opacity=1.0, *args, **kwargs): + return self._add(surface["rr"], surface["tris"], color, opacity) + + def sphere( + self, + center, + color=None, + scale=1.0, + opacity=1.0, + resolution=8, + backface_culling=False, + radius=None, + **kwargs, + ): + _np = self._np + _c = _np.atleast_2d(_np.asarray(center, dtype=float)) + if not len(_c): + return None, None + _r = float(radius if radius is not None else scale) + _rr, _tris = self._glyph_template("sphere", radius=_r, resolution=resolution) + _pts, _faces = self._tile(_rr, _tris, _c) + return self._add(_pts, _faces, color, opacity) + + def tube(self, origin, destination, radius=0.001, color=None, *args, **kwargs): + _np = self._np + _o = _np.atleast_2d(_np.asarray(origin, dtype=float))[:, :3] + _d = _np.atleast_2d(_np.asarray(destination, dtype=float))[:, :3] + _n = min(len(_o), len(_d)) + if not _n: + return None, None + _vec = _d[:_n] - _o[:_n] + _len = _np.linalg.norm(_vec, axis=1) + _keep = _len > 0 + if not _keep.any(): + return None, None + _vec, _len = _vec[_keep], _len[_keep] + _ctr = (_o[:_n][_keep] + _d[:_n][_keep]) / 2.0 + # one unit-height template stretched to each segment, merged into a + # single mesh rather than a cylinder primitive per segment + _rr, _tris = self._glyph_template("cylinder", radius=float(radius), height=1.0) + _pts, _faces = self._tile( + _rr, + _tris, + _ctr, + rots=self._rots_from_dirs(_vec / _len[:, None]), + axis_scales=_len, + ) + return self._add(_pts, _faces, color, kwargs.get("opacity", 1.0)) + + def quiver3d( + self, + x, + y, + z, + u, + v, + w, + color=None, + scale=1.0, + mode="arrow", + opacity=1.0, + *, + glyph_height=None, + glyph_center=None, + glyph_resolution=None, + glyph_radius=0.15, + solid_transform=None, + **kwargs, + ): + """Draw one merged glyph mesh, the way the glyph filter would. + + ``_pyvista.py`` builds a template, lets VTK's glyph filter bake a copy + at every point into one mesh, and adds that once. Drawing a primitive + per point instead is what made ``20_source_alignment`` -- an oct-6 + source space, so 8196 glyphs, twice -- exhaust the browser tab. + """ + _np = self._np + _x, _y, _z = (_np.atleast_1d(_np.asarray(_q, dtype=float)) for _q in (x, y, z)) + _ctr = _np.column_stack([_x, _y, _z]) + _n = len(_ctr) + if not _n: + return None, None + _s = float(_np.asarray(scale).ravel()[0]) if _np.size(scale) else 1.0 + _i = _np.arange(_n) + _u, _v, _w = (_np.atleast_1d(_np.asarray(_q, dtype=float)) for _q in (u, v, w)) + _dirs = _np.column_stack([_u[_i % len(_u)], _v[_i % len(_v)], _w[_i % len(_w)]]) + _norm = _np.linalg.norm(_dirs, axis=1) + _flat = _norm == 0 + _dirs[_flat] = (1.0, 0.0, 0.0) + _norm[_flat] = 1.0 + _dirs = _dirs / _norm[:, None] + # the same templates _pyvista.py feeds the filter; `scale` then plays + # the part its `factor` does + if mode == "oct": + # vtkPlatonicSolidSource puts its octahedron on the unit + # circumsphere, and the MRI fiducials get their real size from + # solid_transform (mri_fid_scale, 5 mm) rather than from `scale` + _kind, _tkw = "oct", dict(radius=1.0) + elif mode == "sphere": + _kind, _tkw = "sphere", dict(radius=0.5) + elif mode == "cylinder": + _kind = "cylinder" + _tkw = dict( + radius=glyph_radius, + height=glyph_height, + center=glyph_center, + resolution=glyph_resolution, + ) + else: # arrow / cone / 2darrow + _kind = "cone" + _tkw = dict( + radius=glyph_radius, height=glyph_height, resolution=glyph_resolution + ) + _rr, _tris = self._glyph_template(_kind, **_tkw) + if solid_transform is not None: + # _pyvista.py transforms the template before glyphing, and this is + # where the fiducial markers get their size and 45 deg roll + _st = _np.asarray(solid_transform, dtype=float) + _rr = _rr @ _st[:3, :3].T + _st[:3, 3] + _rots = None if mode in ("sphere", "oct") else self._rots_from_dirs(_dirs) + _pts, _faces = self._tile(_rr, _tris, _ctr, scales=_s, rots=_rots) + return self._add(_pts, _faces, color, opacity) + + def instanced_mesh( + self, + rr, + tris, + positions, + quats=None, + colors=None, + scales=None, + opacity=1.0, + *args, + **kwargs, + ): + """Stamp the template at every position, merged per distinct color. + + Rotate with MNE's own quaternion helper so oriented glyphs (EEG + cylinders) point the way MNE intended rather than all along +x. + pyvista-js has no per-vertex color, so instances are grouped by the + color they asked for and each group becomes one mesh -- a handful of + actors for a sensor array instead of one per sensor. + """ + _np = self._np + _pos = _np.atleast_2d(_np.asarray(positions, dtype=float))[:, :3] + _n = len(_pos) + if not _n: + return None, None + _rot = None + if quats is not None: + from mne.transforms import quat_to_rot as _q2r + + _rot = _np.asarray( + _q2r(_np.atleast_2d(_np.asarray(quats, dtype=float))), dtype=float + ) + _idx = _np.arange(_n) + if colors is not None and _np.ndim(colors) > 1: + _ca = _np.asarray(colors) + _uniq, _inv = _np.unique(_ca[_idx % len(_ca)], axis=0, return_inverse=True) + _inv = _np.asarray(_inv).ravel() + _groups = [(_uniq[_k], _idx[_inv == _k]) for _k in range(len(_uniq))] + else: + _groups = [(colors, _idx)] + _out = (None, None) + for _col, _sel in _groups: + _sc = None + if scales is not None: + _sa = _np.atleast_1d(_np.asarray(scales, dtype=float)) + _sc = _sa[_sel % len(_sa)] + _rt = None if _rot is None else _rot[_sel % len(_rot)] + _pts, _faces = self._tile(rr, tris, _pos[_sel], scales=_sc, rots=_rt) + _out = self._add(_pts, _faces, _col, opacity) + return _out + + # -- things the static docs do not need --------------------------------- + def contour(self, *args, **kwargs): + # pyvista-js 0.15 has no scalar contouring; callers unpack a pair + return None, None + + def text2d(self, *args, **kwargs): + return None + + def text3d(self, *args, **kwargs): + return None + + def scalarbar(self, *args, **kwargs): + return None + + def legend(self, *args, **kwargs): + return None + + def subplot(self, *args, **kwargs): + return None + + def set_interaction(self, *args, **kwargs): + return None + + def remove_mesh(self, *args, **kwargs): + return None + + def project(self, xyz, ch_names): + return self._np.asarray(xyz, dtype=float)[:, :2] + + def screenshot(self, mode="rgb", filename=None, **kwargs): + return self._np.zeros((2, 2, 3), dtype="uint8") + + def close(self): + return None + + def _update(self, *args, **kwargs): + return None + + def _process_events(self, *args, **kwargs): + return None + + def _enable_time_interaction(self, *args, **kwargs): + # the figures are static here; there is no time slider to wire up + return None + + def _window_close_connect(self, *args, **kwargs): + return None + + def _window_set_cursor(self, *args, **kwargs): + return None + + def get_camera(self, *args, **kwargs): + return (0.0, 0.0, 1.0, (0.0, 0.0, 0.0), 0.0) + + def set_camera( + self, + azimuth=None, + elevation=None, + distance=None, + focalpoint=None, + roll=None, + *args, + **kwargs, + ): + # pyvista-js has no azimuth/elevation camera; approximate the common + # views and otherwise leave the default. + return _lite_set_view(self.plotter, azimuth) + + @property + def figure(self): + """The scene, under the name the tutorials reach for. + + ``_PyVistaRenderer`` hands out one object as both ``.figure`` and + ``.scene()``; ``20_source_alignment`` builds a renderer itself with + ``create_3d_figure(scene=False)`` and then passes ``renderer.figure`` + to ``set_3d_view``, so the two have to stay the same thing here too. + """ + return self.plotter + + def scene(self): + return self.plotter + + def show(self): + try: + self.plotter.show() + except Exception as _e: + print("[JupyterLite] pyvista-js render failed: " + repr(_e)) + return None + + +def _lite_get_renderer(*args, **kwargs): + return _LiteRenderer(*args, **kwargs) + + +class _LiteBackend: + """Stand-in for the module MNE imports into ``renderer.backend``. + + ``set_3d_view``, ``set_3d_title`` and the ``close_*`` helpers are module-level + functions that reach for that global directly instead of going through + ``_get_renderer``, so replacing the factory alone leaves them calling into + ``None``. The figure they are handed is the pyvista-js plotter that + ``_LiteRenderer.scene`` returns. + """ + + def _set_3d_view( + self, + figure, + azimuth=None, + elevation=None, + focalpoint=None, + distance=None, + roll=None, + ): + return _lite_set_view(figure, azimuth) + + def _set_3d_title( + self, figure, title, size=40, color="white", position="upper_left" + ): + return None + + def _close_3d_figure(self, figure): + _lite_release_plotter(figure) + return None + + def _close_all(self): + # the registry holds weak references, so deref before releasing -- + # handing the ref itself to _lite_release_plotter matches nothing and + # never shortens the list + while _lite_live_plotters: + _p = _lite_live_plotters[-1]() + if _p is None: + _lite_live_plotters.pop() + else: + _lite_release_plotter(_p) + return None + + +try: + import mne.viz.backends.renderer as _mne_rend + + _mne_rend._get_renderer = _lite_get_renderer + _mne_rend.backend = _LiteBackend() + # naming a backend keeps _get_3d_backend() from walking VALID_3D_BACKENDS and + # importing _qt, which would overwrite the stub above on its way to failing + _mne_rend.MNE_3D_BACKEND = "notebook" +except Exception as _e: + print("[JupyterLite] could not install the pyvista-js renderer: " + repr(_e)) diff --git a/doc/sphinxext/jupyterlite_lite_renderer.py b/doc/sphinxext/jupyterlite_lite_renderer.py index dc73be80743..249dc40c40f 100644 --- a/doc/sphinxext/jupyterlite_lite_renderer.py +++ b/doc/sphinxext/jupyterlite_lite_renderer.py @@ -19,572 +19,25 @@ which additionally needs dock widgets and toolbars, and scalar colormaps, which pyvista-js 0.15 does not have (scalars fall back to a solid color). -The source is kept as a string because it has to run inside the browser kernel; it -is appended to ``LITE_SETUP_CELL`` in ``jupyterlite_setup_cell.py``, which the docs -build prepends to each JupyterLite notebook. +The renderer itself lives in ``_lite_renderer_cell.py`` as ordinary Python, so ruff +lints and formats it like any other module. This module only reads that file and +exposes it as a string, which is the form the browser kernel needs: it is appended +to ``LITE_SETUP_CELL`` in ``jupyterlite_setup_cell.py``, which the docs build +prepends to each JupyterLite notebook. """ # Authors: The MNE-Python contributors. # License: BSD-3-Clause # Copyright the MNE-Python contributors. -LITE_RENDERER_CELL = r''' -# --- pyvista-js drawing backend for MNE's 3D renderer ----------------------- -# Patches mne.viz.backends.renderer._get_renderer so MNE keeps doing its own -# geometry and coordinate-frame work and only the drawing is replaced. -def _lite_view_vector(azimuth): - """Map an MNE azimuth in degrees onto the nearest pyvista-js view vector.""" - _a = float(azimuth) % 360.0 - if 45 <= _a < 135: - return (0.0, -1.0, 0.0) - elif 135 <= _a < 225: - return (1.0, 0.0, 0.0) - elif 225 <= _a < 315: - return (0.0, 1.0, 0.0) - return (-1.0, 0.0, 0.0) +from pathlib import Path +_SOURCE = Path(__file__).parent / "_lite_renderer_cell.py" +# Everything from the banner onwards is what the notebook runs. The license header +# above it belongs to the file rather than to the cell, so it is left behind. +_BANNER = "# --- pyvista-js drawing backend" -def _lite_set_view(plotter, azimuth): - """Point a plotter at the nearest axis-aligned view; no-op without azimuth.""" - if azimuth is None: - return None - try: - plotter.view_vector(_lite_view_vector(azimuth), viewup=(0.0, 0.0, 1.0)) - except Exception: - pass - return None - - -# Every scene a notebook drew used to stay live for the kernel's lifetime, -# because the close helpers on _LiteBackend were no-ops. Track the plotters -# weakly -- so they stay collectable -- and give close_all something to free. -_lite_live_plotters = [] - - -def _lite_release_plotter(plotter, close=True): - """Hand back a plotter's meshes, JS arrays and GPU buffers. - - ``clear()`` empties the actor list, which is where the geometry is held, - so that is what frees the memory. ``close=False`` additionally says not to - tear the render window down -- what trimming an older scene wants, since - the notebook has already drawn it. pyvista-js 0.15 implements neither - ``deep_clean`` nor ``close``, so today the two paths do the same thing; - the flag keeps the intent right if that changes. - """ - import gc as _gc - if plotter is None: - return None - for _i in range(len(_lite_live_plotters) - 1, -1, -1): - _p = _lite_live_plotters[_i]() - if _p is None or _p is plotter: - del _lite_live_plotters[_i] - # pyvista-js is someone else's surface, so use whichever teardown of these - # it actually implements - _names = ("clear", "deep_clean", "close") if close else ("clear", "deep_clean") - for _name in _names: - _fn = getattr(plotter, _name, None) - if _fn is not None: - try: - _fn() - except Exception: - pass - _gc.collect() - return None - - -# Each live scene holds its meshes in the WASM heap, a copy of them in JS and -# a set of GPU buffers. Nothing in a notebook calls close_3d_figure, so without -# a cap they all stay: 20_source_alignment builds six, which is enough to run -# the tab out of memory. Keep the newest few and give the rest their geometry -# back as new ones arrive -- scrolling back shows an empty canvas, which is a -# far better outcome than losing the page. -_LITE_MAX_LIVE_SCENES = 2 - - -def _lite_trim_live_plotters(): - """Release everything but the most recent scenes.""" - while len(_lite_live_plotters) > _LITE_MAX_LIVE_SCENES: - _p = _lite_live_plotters[0]() - if _p is None: - _lite_live_plotters.pop(0) - else: - # also drops it from the registry, so this terminates - _lite_release_plotter(_p, close=False) - return None - - -class _LiteRenderer: - """Minimal MNE 3D renderer backed by pyvista-js.""" - - def __init__(self, *args, **kwargs): - import numpy as _np - import pyvista_js as _pv - self._np = _np - self._pv = _pv - # plot_alignment(fig=...) and plot_dipole_locations(fig=...) composite - # into a scene the notebook already made, so draw into that plotter - # rather than opening a second one and splitting the picture in two. - # plot_alignment passes it positionally and create_3d_figure by name, - # and `fig` is _PyVistaRenderer's first argument, so accept both. - _fig = args[0] if args else kwargs.get("fig", None) - if _fig is not None and hasattr(_fig, "add_mesh"): - self.plotter = _fig - return - self.plotter = _pv.Plotter() - import weakref as _weakref - _lite_live_plotters.append(_weakref.ref(self.plotter)) - # trim AFTER appending, so the scene being built is never the one freed - _lite_trim_live_plotters() - _bg = kwargs.get("bgcolor", kwargs.get("background_color", "black")) - try: - self.plotter.background_color = self._rgb(_bg) - except Exception: - pass - # even lighting, so a surface is not black when rotated - for _lp in ((1, 0, 0), (-1, 0, 0), (0, 1, 0), - (0, -1, 0), (0, 0, 1), (0, 0, -1)): - try: - self.plotter.add_light(_pv.Light( - position=(300.0 * _lp[0], 300.0 * _lp[1], 300.0 * _lp[2]), - focal_point=(0.0, 0.0, 0.0), intensity=0.4)) - except Exception: - pass - - # -- helpers ------------------------------------------------------------ - def _rgb(self, color): - """Return an (r, g, b) 0-1 tuple; pyvista-js rejects hex strings.""" - if color is None: - return (0.5, 0.5, 0.5) - from matplotlib.colors import to_rgb as _to_rgb - if isinstance(color, str): - return _to_rgb(color) - _c = self._np.asarray(color, dtype=float).ravel()[:3] - if _c.size < 3: - return (0.5, 0.5, 0.5) - if _c.max() > 1.0: # 0-255 form - _c = _c / 255.0 - return tuple(float(min(max(_v, 0.0), 1.0)) for _v in _c) - - def _faces(self, tris): - _np = self._np - _t = _np.asarray(tris, dtype=_np.int32).reshape(-1, 3) - return _np.hstack([ - _np.full((len(_t), 1), 3, dtype=_np.int32), _t]).ravel() - - def _subdivide(self, rr, tris): - """One level of midpoint subdivision, sharing the new edge vertices.""" - _np = self._np - _rr = [tuple(_v) for _v in _np.asarray(rr, dtype=float)] - _mid = {} - _out = [] - for _a, _b, _c in _np.asarray(tris, dtype=int): - _m = [] - for _p, _q in ((_a, _b), (_b, _c), (_c, _a)): - _k = (min(int(_p), int(_q)), max(int(_p), int(_q))) - if _k not in _mid: - _mid[_k] = len(_rr) - _rr.append(tuple((_np.asarray(_rr[_p]) - + _np.asarray(_rr[_q])) / 2.0)) - _m.append(_mid[_k]) - _ab, _bc, _ca = _m - _out += [[_a, _ab, _ca], [_ab, _b, _bc], [_ca, _bc, _c], - [_ab, _bc, _ca]] - return _np.asarray(_rr, dtype=float), _np.asarray(_out, dtype=int) - - def _glyph_template(self, kind, radius=None, height=None, center=None, - resolution=None, **kwargs): - """Return (rr, tris) for a glyph template, oriented along +x. - - pyvista-js's Sphere/Cylinder are parametric primitives with no - triangle list, so build the templates here. ``_tile`` then stamps one - of these at every position and merges the result, which is what keeps - these cheap -- the copies share a single mesh and a single actor. - - Sizes follow the templates ``_pyvista.py`` hands the glyph filter, so - the browser draws the markers at the size the rendered docs do. - """ - _np = self._np - if kind in ("sphere", "oct"): - _r = 0.5 if radius is None else float(radius) - rr = _np.array([[1.0, 0, 0], [-1.0, 0, 0], [0, 1.0, 0], - [0, -1.0, 0], [0, 0, 1.0], [0, 0, -1.0]], float) - tris = _np.array([[0, 2, 4], [2, 1, 4], [1, 3, 4], [3, 0, 4], - [2, 0, 5], [1, 2, 5], [3, 1, 5], [0, 3, 5]], int) - # "oct" is an octahedron on purpose -- that is what _pyvista.py - # hands the glyph filter. A "sphere" has to look round, though: - # fiducials and dig points are drawn with it, so subdivide onto the - # unit sphere to land near the reference's 8x8 sphere (58 verts). - if kind == "sphere": - for _ in range(2): - rr, tris = self._subdivide(rr, tris) - rr /= _np.linalg.norm(rr, axis=1)[:, None] - return rr * _r, tris - if kind == "cone": - # apex along +x so the glyph filter's orientation applies, matching - # pyvista.Cone(center=(0.5, 0, 0)): base at x=0, apex at x=height - _r = 0.15 if radius is None else float(radius) - _h = 1.0 if height is None else float(height) - _n = 8 if not resolution else max(3, int(resolution) // 2) - _ang = _np.linspace(0.0, 2 * _np.pi, _n, endpoint=False) - _ring = _np.column_stack([_np.zeros(_n), _r * _np.cos(_ang), - _r * _np.sin(_ang)]) - rr = _np.vstack([_ring, [[_h, 0, 0]], [[0.0, 0, 0]]]) - tris = [] - for _i in range(_n): - _j = (_i + 1) % _n - tris += [[_i, _j, _n], [_n + 1, _j, _i]] # side, base - return rr, _np.asarray(tris, int) - # cylinder along +x, matching _cylinder_geom's convention - _r = 0.1 if radius is None else float(radius) - _h = 1.0 if height is None else float(height) - _n = 8 if not resolution else max(3, int(resolution) // 2) - _c = _np.zeros(3) if center is None else _np.asarray(center, float) - _ang = _np.linspace(0.0, 2 * _np.pi, _n, endpoint=False) - _ring = _np.column_stack([_np.zeros(_n), _r * _np.cos(_ang), - _r * _np.sin(_ang)]) - _back = _ring + _np.array([-_h / 2.0, 0, 0]) - _front = _ring + _np.array([_h / 2.0, 0, 0]) - rr = _np.vstack([_back, _front, - [[-_h / 2.0, 0, 0]], [[_h / 2.0, 0, 0]]]) + _c - tris = [] - for _i in range(_n): - _j = (_i + 1) % _n - tris += [[_i, _j, _n + _j], [_i, _n + _j, _n + _i]] # wall - tris += [[2 * _n, _j, _i]] # back cap - tris += [[2 * _n + 1, _n + _i, _n + _j]] # front cap - return rr, _np.asarray(tris, int) - - def _add(self, points, tris, color, opacity=1.0): - """Draw a mesh and return MNE's (actor, mesh) pair. - - ``opacity=None`` means "renderer default" in MNE's renderer API, which - for PyVista reaches ``add_mesh(opacity=None)`` and draws opaque. Every - drawing method here funnels through this, so translating it once covers - all of them. - """ - _np = self._np - _pd = self._pv.PolyData( - points=_np.asarray(points, dtype=_np.float32), - faces=self._faces(tris)) - _actor = self.plotter.add_mesh( - _pd, color=self._rgb(color), - opacity=1.0 if opacity is None else float(opacity), - smooth_shading=True) - return _actor, _pd - - def _rots_from_dirs(self, dirs): - """Rotations carrying +x onto each direction, as the glyphs assume.""" - _np = self._np - from mne.transforms import _find_vector_rotation as _fvr - _x = _np.array([1.0, 0.0, 0.0]) - return _np.asarray([_fvr(_x, _d) for _d in dirs], dtype=float) - - def _tile(self, rr, tris, positions, scales=None, rots=None, - axis_scales=None): - """Stamp one template mesh at many positions as a single mesh. - - ``_pyvista.py`` hands its template to VTK's glyph filter, which bakes - every copy into one mesh and adds it once. Doing this per position - instead means an oct-6 source space becomes 8196 meshes and 8196 - actors, which is enough to run the browser tab out of memory. - """ - _np = self._np - _rr = _np.asarray(rr, dtype=float) - _tris = _np.asarray(tris, dtype=int) - _pos = _np.atleast_2d(_np.asarray(positions, dtype=float))[:, :3] - _n = len(_pos) - _pts = _np.repeat(_rr[None, :, :], _n, axis=0) - if axis_scales is not None: - # tubes span a given length without fattening, so scale the - # template's axis alone - _ax = _np.atleast_1d(_np.asarray(axis_scales, dtype=float)) - _pts[:, :, 0] *= _ax[_np.arange(_n) % len(_ax)][:, None] - if scales is not None: - _sa = _np.atleast_1d(_np.asarray(scales, dtype=float)) - _pts *= _sa[_np.arange(_n) % len(_sa)][:, None, None] - if rots is not None: - _ra = _np.asarray(rots, dtype=float) - _pts = _np.einsum( - 'nij,nkj->nki', _ra[_np.arange(_n) % len(_ra)], _pts) - _pts += _pos[:, None, :] - _off = (_np.arange(_n) * len(_rr))[:, None, None] - return (_pts.reshape(-1, 3), - (_tris[None, :, :] + _off).reshape(-1, 3)) - - # -- drawing ------------------------------------------------------------ - def mesh(self, x, y, z, triangles, color=None, opacity=1.0, *args, **kwargs): - _np = self._np - _pts = _np.column_stack([_np.asarray(x).ravel(), - _np.asarray(y).ravel(), - _np.asarray(z).ravel()]) - return self._add(_pts, triangles, color, opacity) - - def surface(self, surface, color=None, opacity=1.0, *args, **kwargs): - return self._add(surface["rr"], surface["tris"], color, opacity) - - def sphere(self, center, color=None, scale=1.0, opacity=1.0, - resolution=8, backface_culling=False, radius=None, **kwargs): - _np = self._np - _c = _np.atleast_2d(_np.asarray(center, dtype=float)) - if not len(_c): - return None, None - _r = float(radius if radius is not None else scale) - _rr, _tris = self._glyph_template("sphere", radius=_r, - resolution=resolution) - _pts, _faces = self._tile(_rr, _tris, _c) - return self._add(_pts, _faces, color, opacity) - - def tube(self, origin, destination, radius=0.001, color=None, *args, - **kwargs): - _np = self._np - _o = _np.atleast_2d(_np.asarray(origin, dtype=float))[:, :3] - _d = _np.atleast_2d(_np.asarray(destination, dtype=float))[:, :3] - _n = min(len(_o), len(_d)) - if not _n: - return None, None - _vec = _d[:_n] - _o[:_n] - _len = _np.linalg.norm(_vec, axis=1) - _keep = _len > 0 - if not _keep.any(): - return None, None - _vec, _len = _vec[_keep], _len[_keep] - _ctr = (_o[:_n][_keep] + _d[:_n][_keep]) / 2.0 - # one unit-height template stretched to each segment, merged into a - # single mesh rather than a cylinder primitive per segment - _rr, _tris = self._glyph_template("cylinder", radius=float(radius), - height=1.0) - _pts, _faces = self._tile( - _rr, _tris, _ctr, rots=self._rots_from_dirs(_vec / _len[:, None]), - axis_scales=_len) - return self._add(_pts, _faces, color, kwargs.get("opacity", 1.0)) - - def quiver3d(self, x, y, z, u, v, w, color=None, scale=1.0, mode="arrow", - opacity=1.0, *, glyph_height=None, glyph_center=None, - glyph_resolution=None, glyph_radius=0.15, - solid_transform=None, **kwargs): - """Draw one merged glyph mesh, the way the glyph filter would. - - ``_pyvista.py`` builds a template, lets VTK's glyph filter bake a copy - at every point into one mesh, and adds that once. Drawing a primitive - per point instead is what made ``20_source_alignment`` -- an oct-6 - source space, so 8196 glyphs, twice -- exhaust the browser tab. - """ - _np = self._np - _x, _y, _z = (_np.atleast_1d(_np.asarray(_q, dtype=float)) - for _q in (x, y, z)) - _ctr = _np.column_stack([_x, _y, _z]) - _n = len(_ctr) - if not _n: - return None, None - _s = float(_np.asarray(scale).ravel()[0]) if _np.size(scale) else 1.0 - _i = _np.arange(_n) - _u, _v, _w = (_np.atleast_1d(_np.asarray(_q, dtype=float)) - for _q in (u, v, w)) - _dirs = _np.column_stack([_u[_i % len(_u)], _v[_i % len(_v)], - _w[_i % len(_w)]]) - _norm = _np.linalg.norm(_dirs, axis=1) - _flat = _norm == 0 - _dirs[_flat] = (1.0, 0.0, 0.0) - _norm[_flat] = 1.0 - _dirs = _dirs / _norm[:, None] - # the same templates _pyvista.py feeds the filter; `scale` then plays - # the part its `factor` does - if mode == "oct": - # vtkPlatonicSolidSource puts its octahedron on the unit - # circumsphere, and the MRI fiducials get their real size from - # solid_transform (mri_fid_scale, 5 mm) rather than from `scale` - _kind, _tkw = "oct", dict(radius=1.0) - elif mode == "sphere": - _kind, _tkw = "sphere", dict(radius=0.5) - elif mode == "cylinder": - _kind = "cylinder" - _tkw = dict(radius=glyph_radius, height=glyph_height, - center=glyph_center, resolution=glyph_resolution) - else: # arrow / cone / 2darrow - _kind = "cone" - _tkw = dict(radius=glyph_radius, height=glyph_height, - resolution=glyph_resolution) - _rr, _tris = self._glyph_template(_kind, **_tkw) - if solid_transform is not None: - # _pyvista.py transforms the template before glyphing, and this is - # where the fiducial markers get their size and 45 deg roll - _st = _np.asarray(solid_transform, dtype=float) - _rr = _rr @ _st[:3, :3].T + _st[:3, 3] - _rots = (None if mode in ("sphere", "oct") - else self._rots_from_dirs(_dirs)) - _pts, _faces = self._tile(_rr, _tris, _ctr, scales=_s, rots=_rots) - return self._add(_pts, _faces, color, opacity) - - def instanced_mesh(self, rr, tris, positions, quats=None, colors=None, - scales=None, opacity=1.0, *args, **kwargs): - """Stamp the template at every position, merged per distinct color. - - Rotate with MNE's own quaternion helper so oriented glyphs (EEG - cylinders) point the way MNE intended rather than all along +x. - pyvista-js has no per-vertex color, so instances are grouped by the - color they asked for and each group becomes one mesh -- a handful of - actors for a sensor array instead of one per sensor. - """ - _np = self._np - _pos = _np.atleast_2d(_np.asarray(positions, dtype=float))[:, :3] - _n = len(_pos) - if not _n: - return None, None - _rot = None - if quats is not None: - from mne.transforms import quat_to_rot as _q2r - _rot = _np.asarray(_q2r(_np.atleast_2d( - _np.asarray(quats, dtype=float))), dtype=float) - _idx = _np.arange(_n) - if colors is not None and _np.ndim(colors) > 1: - _ca = _np.asarray(colors) - _uniq, _inv = _np.unique(_ca[_idx % len(_ca)], axis=0, - return_inverse=True) - _inv = _np.asarray(_inv).ravel() - _groups = [(_uniq[_k], _idx[_inv == _k]) - for _k in range(len(_uniq))] - else: - _groups = [(colors, _idx)] - _out = (None, None) - for _col, _sel in _groups: - _sc = None - if scales is not None: - _sa = _np.atleast_1d(_np.asarray(scales, dtype=float)) - _sc = _sa[_sel % len(_sa)] - _rt = None if _rot is None else _rot[_sel % len(_rot)] - _pts, _faces = self._tile(rr, tris, _pos[_sel], scales=_sc, - rots=_rt) - _out = self._add(_pts, _faces, _col, opacity) - return _out - - # -- things the static docs do not need --------------------------------- - def contour(self, *args, **kwargs): - # pyvista-js 0.15 has no scalar contouring; callers unpack a pair - return None, None - - def text2d(self, *args, **kwargs): - return None - - def text3d(self, *args, **kwargs): - return None - - def scalarbar(self, *args, **kwargs): - return None - - def legend(self, *args, **kwargs): - return None - - def subplot(self, *args, **kwargs): - return None - - def set_interaction(self, *args, **kwargs): - return None - - def remove_mesh(self, *args, **kwargs): - return None - - def project(self, xyz, ch_names): - return self._np.asarray(xyz, dtype=float)[:, :2] - - def screenshot(self, mode="rgb", filename=None, **kwargs): - return self._np.zeros((2, 2, 3), dtype="uint8") - - def close(self): - return None - - def _update(self, *args, **kwargs): - return None - - def _process_events(self, *args, **kwargs): - return None - - def _enable_time_interaction(self, *args, **kwargs): - # the figures are static here; there is no time slider to wire up - return None - - def _window_close_connect(self, *args, **kwargs): - return None - - def _window_set_cursor(self, *args, **kwargs): - return None - - def get_camera(self, *args, **kwargs): - return (0.0, 0.0, 1.0, (0.0, 0.0, 0.0), 0.0) - - def set_camera(self, azimuth=None, elevation=None, distance=None, - focalpoint=None, roll=None, *args, **kwargs): - # pyvista-js has no azimuth/elevation camera; approximate the common - # views and otherwise leave the default. - return _lite_set_view(self.plotter, azimuth) - - @property - def figure(self): - """The scene, under the name the tutorials reach for. - - ``_PyVistaRenderer`` hands out one object as both ``.figure`` and - ``.scene()``; ``20_source_alignment`` builds a renderer itself with - ``create_3d_figure(scene=False)`` and then passes ``renderer.figure`` - to ``set_3d_view``, so the two have to stay the same thing here too. - """ - return self.plotter - - def scene(self): - return self.plotter - - def show(self): - try: - self.plotter.show() - except Exception as _e: - print("[JupyterLite] pyvista-js render failed: " + repr(_e)) - return None - - -def _lite_get_renderer(*args, **kwargs): - return _LiteRenderer(*args, **kwargs) - - -class _LiteBackend: - """Stand-in for the module MNE imports into ``renderer.backend``. - - ``set_3d_view``, ``set_3d_title`` and the ``close_*`` helpers are module-level - functions that reach for that global directly instead of going through - ``_get_renderer``, so replacing the factory alone leaves them calling into - ``None``. The figure they are handed is the pyvista-js plotter that - ``_LiteRenderer.scene`` returns. - """ - - def _set_3d_view(self, figure, azimuth=None, elevation=None, - focalpoint=None, distance=None, roll=None): - return _lite_set_view(figure, azimuth) - - def _set_3d_title(self, figure, title, size=40, color="white", - position="upper_left"): - return None - - def _close_3d_figure(self, figure): - _lite_release_plotter(figure) - return None - - def _close_all(self): - # the registry holds weak references, so deref before releasing -- - # handing the ref itself to _lite_release_plotter matches nothing and - # never shortens the list - while _lite_live_plotters: - _p = _lite_live_plotters[-1]() - if _p is None: - _lite_live_plotters.pop() - else: - _lite_release_plotter(_p) - return None - - -try: - import mne.viz.backends.renderer as _mne_rend - _mne_rend._get_renderer = _lite_get_renderer - _mne_rend.backend = _LiteBackend() - # naming a backend keeps _get_3d_backend() from walking VALID_3D_BACKENDS and - # importing _qt, which would overwrite the stub above on its way to failing - _mne_rend.MNE_3D_BACKEND = "notebook" -except Exception as _e: - print("[JupyterLite] could not install the pyvista-js renderer: " + repr(_e)) -''' +_text = _SOURCE.read_text() +if _BANNER not in _text: + raise RuntimeError(f"{_SOURCE.name} is missing the {_BANNER!r} banner") +LITE_RENDERER_CELL = "\n" + _text[_text.index(_BANNER) :] From d2ac08c4505889b86e62b8c2ae7cfce509d1ff1c Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 20:53:52 +0000 Subject: [PATCH 03/15] [autofix.ci] apply automated fixes --- doc/sphinxext/_lite_renderer_cell.py | 1 + 1 file changed, 1 insertion(+) diff --git a/doc/sphinxext/_lite_renderer_cell.py b/doc/sphinxext/_lite_renderer_cell.py index ddbd3827bf9..88222727140 100644 --- a/doc/sphinxext/_lite_renderer_cell.py +++ b/doc/sphinxext/_lite_renderer_cell.py @@ -1,6 +1,7 @@ # Authors: The MNE-Python contributors. # License: BSD-3-Clause # Copyright the MNE-Python contributors. + # --- pyvista-js drawing backend for MNE's 3D renderer ----------------------- # Patches mne.viz.backends.renderer._get_renderer so MNE keeps doing its own # geometry and coordinate-frame work and only the drawing is replaced. From 06343de22c89a647b7b84edb616e644f6fa4b698 Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Sat, 22 Aug 2026 23:25:48 -0400 Subject: [PATCH 04/15] MAINT: move the vtk.js renderer into mne/viz/backends and test it --- doc/changes/dev/14144.other.rst | 2 +- doc/sphinxext/jupyterlite_lite_renderer.py | 62 ++--- mne/utils/config.py | 1 + mne/utils/tests/test_config.py | 2 +- .../viz/backends/_lite.py | 84 +++++- mne/viz/backends/tests/test_lite.py | 260 ++++++++++++++++++ pyproject.toml | 3 + tools/vulture_allowlist.py | 1 + 8 files changed, 367 insertions(+), 48 deletions(-) rename doc/sphinxext/_lite_renderer_cell.py => mne/viz/backends/_lite.py (88%) create mode 100644 mne/viz/backends/tests/test_lite.py diff --git a/doc/changes/dev/14144.other.rst b/doc/changes/dev/14144.other.rst index 841403d0919..843413e4d52 100644 --- a/doc/changes/dev/14144.other.rst +++ b/doc/changes/dev/14144.other.rst @@ -1 +1 @@ -Add a vtk.js drawing backend for MNE's 3D renderer, used by the JupyterLite documentation where VTK cannot load, by `Natneal B`_. +Add a vtk.js drawing backend for MNE's 3D renderer in ``mne/viz/backends/_lite.py``, used by the JupyterLite documentation where VTK cannot load, by `Natneal B`_. diff --git a/doc/sphinxext/jupyterlite_lite_renderer.py b/doc/sphinxext/jupyterlite_lite_renderer.py index 249dc40c40f..6f145f5fc39 100644 --- a/doc/sphinxext/jupyterlite_lite_renderer.py +++ b/doc/sphinxext/jupyterlite_lite_renderer.py @@ -1,43 +1,33 @@ -"""A pyvista-js drawing backend for MNE's 3D renderer, for JupyterLite. - -MNE's 3D functions (``plot_alignment``, ``plot_bem``, ``plot_sparse_source_estimates``, -``SourceSpaces.plot``, ...) all build their figure the same way: they do their own -geometry and coordinate-frame work in numpy, then hand the result to a renderer -obtained from ``mne.viz.backends.renderer._get_renderer``. Only that last step needs -VTK, and VTK cannot load in WebAssembly. - -So instead of reimplementing those functions one by one, this module supplies a -renderer that draws with pyvista-js (vtk.js) and patches the factory, along with the -``renderer.backend`` global that ``set_3d_view`` and the other scene-level helpers -read directly. MNE then does all of the transform math itself, which matters -because getting a head/MRI/device transform subtly wrong produces a -plausible-looking picture with the sensors in the wrong place, and several of these -tutorials are specifically *about* coordinate alignment. - -What is supported: meshes, surfaces, spheres, tubes and glyphs, enough for the -static figures the docs render. What is not: the interactive ``Brain`` time viewer, -which additionally needs dock widgets and toolbars, and scalar colormaps, which -pyvista-js 0.15 does not have (scalars fall back to a solid color). - -The renderer itself lives in ``_lite_renderer_cell.py`` as ordinary Python, so ruff -lints and formats it like any other module. This module only reads that file and -exposes it as a string, which is the form the browser kernel needs: it is appended -to ``LITE_SETUP_CELL`` in ``jupyterlite_setup_cell.py``, which the docs build -prepends to each JupyterLite notebook. +"""Turn on MNE's pyvista-js 3D renderer inside the JupyterLite kernel. + +VTK cannot load in WebAssembly, so MNE's 3D output would otherwise be +unavailable in the browser. The renderer that replaces it lives in MNE itself, +at ``mne/viz/backends/_lite.py``, so it is ordinary library code: linted, +formatted and unit tested like any other module, and shipped in the wheel the +browser kernel installs. + +That leaves this module with one job. It exposes the few lines of notebook code +that import the renderer and switch MNE over to it. ``LITE_RENDERER_CELL`` is +appended to ``LITE_SETUP_CELL`` in ``jupyterlite_setup_cell.py``, which the docs +build prepends to each JupyterLite notebook. + +The cell degrades quietly: if the installed MNE predates the renderer, or +pyvista-js is missing, the notebook prints why and carries on with everything +that does not need 3D. """ # Authors: The MNE-Python contributors. # License: BSD-3-Clause # Copyright the MNE-Python contributors. -from pathlib import Path - -_SOURCE = Path(__file__).parent / "_lite_renderer_cell.py" -# Everything from the banner onwards is what the notebook runs. The license header -# above it belongs to the file rather than to the cell, so it is left behind. -_BANNER = "# --- pyvista-js drawing backend" +LITE_RENDERER_CELL = """ +# Draw MNE's 3D figures with pyvista-js (vtk.js). VTK has no WebAssembly build, +# so this swaps only the drawing step; MNE still does its own geometry and +# coordinate-frame work. See mne/viz/backends/_lite.py. +try: + from mne.viz.backends._lite import _activate as _mne_activate_lite_renderer -_text = _SOURCE.read_text() -if _BANNER not in _text: - raise RuntimeError(f"{_SOURCE.name} is missing the {_BANNER!r} banner") -LITE_RENDERER_CELL = "\n" + _text[_text.index(_BANNER) :] + _mne_activate_lite_renderer() +except Exception as _e: + print("[JupyterLite] could not install the pyvista-js renderer: " + repr(_e)) +""" diff --git a/mne/utils/config.py b/mne/utils/config.py index 470226ac637..b2f16436e59 100644 --- a/mne/utils/config.py +++ b/mne/utils/config.py @@ -937,6 +937,7 @@ def sys_info( "nbclient", "nbformat", "nitime", + "pyvista-js", "imageio", "imageio-ffmpeg", "snirf", diff --git a/mne/utils/tests/test_config.py b/mne/utils/tests/test_config.py index 60783c70ce6..81f4c468542 100644 --- a/mne/utils/tests/test_config.py +++ b/mne/utils/tests/test_config.py @@ -167,7 +167,7 @@ def test_sys_info_complete(): ] missing = [] for dep in deps: - dep = dep.split("[")[0].split(">")[0].strip() + dep = dep.split(";")[0].split("[")[0].split(">")[0].strip() if f" {dep}" not in out: missing.append(dep) if missing: diff --git a/doc/sphinxext/_lite_renderer_cell.py b/mne/viz/backends/_lite.py similarity index 88% rename from doc/sphinxext/_lite_renderer_cell.py rename to mne/viz/backends/_lite.py index 88222727140..748a3b9663a 100644 --- a/doc/sphinxext/_lite_renderer_cell.py +++ b/mne/viz/backends/_lite.py @@ -1,10 +1,39 @@ +""" +A pyvista-js drawing backend for MNE's 3D renderer. + +MNE's 3D functions (``plot_alignment``, ``plot_bem``, +``plot_sparse_source_estimates``, ``SourceSpaces.plot``, ...) all build their +figure the same way: they do their own geometry and coordinate-frame work in +numpy, then hand the result to a renderer obtained from +:func:`mne.viz.backends.renderer._get_renderer`. Only that last step needs VTK, +and VTK cannot load in WebAssembly. + +Rather than reimplement those functions one by one, this module supplies a +renderer that draws with `pyvista-js `__ +(vtk.js) and, via :func:`_activate`, patches that factory along with the +``renderer.backend`` global that ``set_3d_view`` and the other scene-level +helpers read directly. MNE keeps doing all of the transform math itself, which +matters because getting a head/MRI/device transform subtly wrong produces a +plausible-looking picture with the sensors in the wrong place. + +Supported: meshes, surfaces, spheres, tubes and glyphs, which covers the static +figures the documentation renders. Not supported: the interactive +:class:`mne.viz.Brain` time viewer, which additionally needs dock widgets and a +time slider, and scalar colormaps, which pyvista-js 0.15 does not implement +(scalars fall back to a solid color). + +Importing this module has no side effects and does not require pyvista-js; +:func:`_activate` is what installs the renderer, and pyvista-js is imported +lazily when a scene is first built. +""" + # Authors: The MNE-Python contributors. # License: BSD-3-Clause # Copyright the MNE-Python contributors. -# --- pyvista-js drawing backend for MNE's 3D renderer ----------------------- -# Patches mne.viz.backends.renderer._get_renderer so MNE keeps doing its own -# geometry and coordinate-frame work and only the drawing is replaced. +from ._abstract import _AbstractRenderer + + def _lite_view_vector(azimuth): """Map an MNE azimuth in degrees onto the nearest pyvista-js view vector.""" _a = float(azimuth) % 360.0 @@ -87,9 +116,16 @@ def _lite_trim_live_plotters(): return None -class _LiteRenderer: +class _LiteRenderer(_AbstractRenderer): """Minimal MNE 3D renderer backed by pyvista-js.""" + # Callers branch on this to pick behaviour rather than to identify a + # backend, and "notebook" is the branch that is right here: it matches the + # MNE_3D_BACKEND that _activate registers, and it keeps code such as + # mne/gui/_coreg.py from taking its `_kind != "notebook"` path, which + # starts a Qt event loop that does not exist in a browser. + _kind = "notebook" + def __init__(self, *args, **kwargs): import numpy as _np import pyvista_js as _pv @@ -638,13 +674,41 @@ def _close_all(self): return None -try: - import mne.viz.backends.renderer as _mne_rend +_LITE_SAVED = {} + + +def _activate(): + """Install this renderer as the one MNE draws with. + + Replaces the ``_get_renderer`` factory and the ``renderer.backend`` global + that ``set_3d_view``, ``set_3d_title`` and the ``close_*`` helpers read + directly, so that patching the factory alone does not leave them calling + into ``None``. Returns the previous state, which :func:`_deactivate` puts + back. + """ + from . import renderer as _mne_rend + if not _LITE_SAVED: + _LITE_SAVED.update( + _get_renderer=_mne_rend._get_renderer, + backend=_mne_rend.backend, + MNE_3D_BACKEND=_mne_rend.MNE_3D_BACKEND, + ) _mne_rend._get_renderer = _lite_get_renderer _mne_rend.backend = _LiteBackend() - # naming a backend keeps _get_3d_backend() from walking VALID_3D_BACKENDS and - # importing _qt, which would overwrite the stub above on its way to failing + # Naming a backend keeps _get_3d_backend() from walking VALID_3D_BACKENDS and + # importing _qt, which would overwrite the stub above on its way to failing. _mne_rend.MNE_3D_BACKEND = "notebook" -except Exception as _e: - print("[JupyterLite] could not install the pyvista-js renderer: " + repr(_e)) + return dict(_LITE_SAVED) + + +def _deactivate(): + """Undo :func:`_activate`, restoring whatever MNE was drawing with before.""" + if not _LITE_SAVED: + return None + from . import renderer as _mne_rend + + for _name, _value in _LITE_SAVED.items(): + setattr(_mne_rend, _name, _value) + _LITE_SAVED.clear() + return None diff --git a/mne/viz/backends/tests/test_lite.py b/mne/viz/backends/tests/test_lite.py new file mode 100644 index 00000000000..993ebe64e20 --- /dev/null +++ b/mne/viz/backends/tests/test_lite.py @@ -0,0 +1,260 @@ +# Authors: The MNE-Python contributors. +# License: BSD-3-Clause +# Copyright the MNE-Python contributors. + +import subprocess +import sys + +import numpy as np +import pytest + +from mne.viz.backends._abstract import _AbstractRenderer +from mne.viz.backends._lite import ( + _LITE_MAX_LIVE_SCENES, + _activate, + _deactivate, + _lite_live_plotters, + _lite_view_vector, + _LiteBackend, + _LiteRenderer, +) + +# a unit square, split into two triangles +_RR = np.array([[0.0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0]]) +_TRIS = np.array([[0, 1, 2], [0, 2, 3]]) + + +@pytest.fixture +def lite_scene(): + """Skip without pyvista-js, and give each test a clean live-scene registry.""" + pytest.importorskip("pyvista_js") + _lite_live_plotters.clear() + yield + _lite_live_plotters.clear() + _deactivate() + + +def test_implements_abstract_renderer(): + """The lite renderer must satisfy the full _AbstractRenderer contract. + + This is the test that matters when someone adds a method to the abstract + renderer: without it the browser build keeps importing and only fails at + the point a tutorial tries to draw. + """ + assert issubclass(_LiteRenderer, _AbstractRenderer) + assert not _LiteRenderer.__abstractmethods__ + + +def test_kind_matches_the_registered_backend(): + """``_kind`` must stay in step with the backend :func:`_activate` names. + + Callers branch on ``_kind`` to choose behaviour: ``mne/gui/_coreg.py`` + calls ``_qt_app_exec`` whenever it is not ``"notebook"``, and there is no + Qt event loop in a browser to exec. + """ + from mne.viz.backends import renderer + + _activate() + try: + assert _LiteRenderer._kind == renderer.MNE_3D_BACKEND == "notebook" + finally: + _deactivate() + + +def test_import_is_side_effect_free(): + """Importing the module must not pull in VTK or touch the drawing factory. + + The browser kernel has no VTK at all and installs pyvista-js from piplite + only after MNE is imported, so importing this module has to stay cheap and + leave ``mne.viz.backends.renderer`` alone until :func:`_activate` is + called. Run in a subprocess because by this point in a full test session + another backend has usually already imported VTK. + """ + code = ( + "import sys\n" + "import mne.viz.backends._lite # noqa: F401\n" + "from mne.viz.backends import renderer\n" + "assert 'vtk' not in sys.modules, 'importing _lite pulled in vtk'\n" + "assert 'vtkmodules' not in sys.modules, 'importing _lite pulled in vtk'\n" + "assert 'pyvista_js' not in sys.modules, 'pyvista-js imported early'\n" + "assert renderer._get_renderer.__module__.endswith('renderer')\n" + "print('ok')\n" + ) + out = subprocess.run( + [sys.executable, "-c", code], capture_output=True, text=True, check=False + ) + assert out.returncode == 0, out.stderr + assert "ok" in out.stdout + + +@pytest.mark.parametrize( + "azimuth, expected", + [ + (0, (-1.0, 0.0, 0.0)), + (90, (0.0, -1.0, 0.0)), + (180, (1.0, 0.0, 0.0)), + (270, (0.0, 1.0, 0.0)), + (360, (-1.0, 0.0, 0.0)), # wraps + (-90, (0.0, 1.0, 0.0)), # wraps + ], +) +def test_view_vector(azimuth, expected): + """Azimuths map onto the nearest axis-aligned view, wrapping past 360.""" + assert _lite_view_vector(azimuth) == expected + + +def test_draws_every_primitive(lite_scene): + """Each drawing primitive must add exactly one actor to the scene.""" + r = _LiteRenderer(size=(200, 200), bgcolor="white") + assert len(r.plotter.actors) == 0 + + r.mesh(_RR[:, 0], _RR[:, 1], _RR[:, 2], _TRIS, color="red", opacity=0.5) + r.surface(dict(rr=_RR, tris=_TRIS), color="blue") + r.sphere(np.array([[0.0, 0, 0]]), "green", 0.1) + r.tube([[0.0, 0, 0]], [[1.0, 1, 1]], radius=0.01, color="black") + r.quiver3d( + np.r_[0.0], + np.r_[0.0], + np.r_[0.0], + np.r_[1.0], + np.r_[0.0], + np.r_[0.0], + color="orange", + scale=0.1, + mode="arrow", + ) + assert len(r.plotter.actors) == 5 + + +def test_draws_into_an_existing_figure(lite_scene): + """``fig=`` composites into a scene rather than opening a second one. + + ``plot_alignment`` passes it positionally and ``create_3d_figure`` by name, + so both spellings have to land on the same plotter. + """ + first = _LiteRenderer(size=(200, 200)) + by_name = _LiteRenderer(fig=first.plotter) + by_position = _LiteRenderer(first.plotter) + assert by_name.plotter is first.plotter + assert by_position.plotter is first.plotter + + by_name.sphere(np.array([[0.0, 0, 0]]), "red", 1.0) + assert len(first.plotter.actors) == 1 + + +def test_live_scenes_are_capped(lite_scene): + """Old scenes are released, so a notebook cannot run the tab out of memory. + + Every live scene holds its meshes in the WASM heap, a copy in JS and a set + of GPU buffers, and nothing in a notebook calls ``close_3d_figure``. + """ + kept = [_LiteRenderer() for _ in range(_LITE_MAX_LIVE_SCENES + 3)] + assert len(_lite_live_plotters) == _LITE_MAX_LIVE_SCENES + # the survivors are the most recent ones + live = [ref() for ref in _lite_live_plotters] + assert live == [r.plotter for r in kept[-_LITE_MAX_LIVE_SCENES:]] + + +def test_activate_and_deactivate_round_trip(lite_scene): + """Activation swaps the factory and the backend global, and undoes itself.""" + from mne.viz.backends import renderer + + before = (renderer._get_renderer, renderer.backend, renderer.MNE_3D_BACKEND) + + _activate() + assert renderer._get_renderer(size=(100, 100)).__class__ is _LiteRenderer + assert isinstance(renderer.backend, _LiteBackend) + # a named backend stops _get_3d_backend() walking VALID_3D_BACKENDS and + # importing _qt, which would undo the line above on its way to failing + assert renderer.MNE_3D_BACKEND is not None + + _deactivate() + assert (renderer._get_renderer, renderer.backend, renderer.MNE_3D_BACKEND) == before + + +def test_activate_is_idempotent(lite_scene): + """Activating twice must still restore the original state once.""" + from mne.viz.backends import renderer + + before = renderer._get_renderer + _activate() + _activate() + _deactivate() + assert renderer._get_renderer is before + + +def test_backend_scene_helpers(lite_scene): + """The scene-level helpers MNE calls on ``renderer.backend`` all work. + + ``set_3d_view`` and the ``close_*`` helpers read that global directly + instead of going through ``_get_renderer``, so a renderer alone is not + enough. + """ + backend = _LiteBackend() + r = _LiteRenderer() + r.sphere(np.array([[0.0, 0, 0]]), "red", 1.0) + + assert backend._set_3d_view(r.plotter, azimuth=90) is None + assert backend._set_3d_title(r.plotter, "ignored") is None + + backend._close_3d_figure(r.plotter) + assert len(r.plotter.actors) == 0 + assert _lite_live_plotters == [] + + +def test_close_all_releases_every_scene(lite_scene): + """``close_all`` must drain the registry, not spin on dead references.""" + scenes = [_LiteRenderer() for _ in range(_LITE_MAX_LIVE_SCENES)] + assert _lite_live_plotters + _LiteBackend()._close_all() + assert _lite_live_plotters == [] + assert all(len(s.plotter.actors) == 0 for s in scenes) + + +def test_public_helpers_route_through_the_backend(lite_scene): + """``mne.viz.set_3d_view`` and friends must work once activated. + + These are the calls the tutorials actually make. They read + ``renderer.backend`` directly rather than going through ``_get_renderer``, + so a renderer on its own is not enough to make them work. + """ + from mne.viz import close_all_3d_figures, set_3d_view + + _activate() + r = _LiteRenderer(size=(200, 200)) + r.sphere(np.array([[0.0, 0, 0]]), "red", 1.0) + + set_3d_view(r.scene(), azimuth=90, elevation=45) + close_all_3d_figures() + assert len(r.plotter.actors) == 0 + assert _lite_live_plotters == [] + + +def test_renders_in_a_notebook_kernel(nbexec, lite_scene): + """Draw through MNE's own factory inside a live Jupyter kernel. + + Everything above drives the renderer in-process. This goes through + ``_get_renderer`` in a real kernel, which is the path a notebook actually + takes, and checks the scene serialises to the vtk.js HTML the browser + consumes. The body below is executed by that kernel rather than here. + """ + import numpy as np + + from mne.viz.backends import renderer + from mne.viz.backends._lite import _activate, _deactivate + + _activate() + try: + assert renderer._get_renderer.__name__ == "_lite_get_renderer" + r = renderer._get_renderer(size=(200, 200), bgcolor="white") + assert type(r).__name__ == "_LiteRenderer" + + rr = np.array([[0.0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0]]) + tris = np.array([[0, 1, 2], [0, 2, 3]]) + r.mesh(rr[:, 0], rr[:, 1], rr[:, 2], tris, color="red") + assert len(r.plotter.actors) == 1 + + html = r.plotter.generate_standalone_html() + assert "= 0.7", "pymef", + # drawing backend for the browser docs, see mne/viz/backends/_lite.py; it + # needs 3.12, which every CI job that installs this group is already on + "pyvista-js; python_version >= '3.12'", "statsmodels", {include-group = "test_extra_ft"}, ] diff --git a/tools/vulture_allowlist.py b/tools/vulture_allowlist.py index 8b72ed27282..c83813075ea 100644 --- a/tools/vulture_allowlist.py +++ b/tools/vulture_allowlist.py @@ -16,6 +16,7 @@ windows_like_datetime garbage_collect renderer_notebook +lite_scene qt_windows_closed download_is_error exitstatus From a27b1826ffb19426642aac6bc75029facf7574d3 Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Mon, 24 Aug 2026 11:04:53 -0400 Subject: [PATCH 05/15] MAINT: address review on the vtk.js renderer --- doc/sphinxext/jupyterlite_lite_renderer.py | 24 +++++++--------------- mne/viz/backends/_lite.py | 8 ++------ mne/viz/backends/_pyvista.py | 10 ++++----- mne/viz/backends/_utils.py | 10 +++++++++ mne/viz/backends/tests/test_utils.py | 16 +++++++++++++++ pyproject.toml | 2 +- 6 files changed, 41 insertions(+), 29 deletions(-) diff --git a/doc/sphinxext/jupyterlite_lite_renderer.py b/doc/sphinxext/jupyterlite_lite_renderer.py index 6f145f5fc39..9394b894c74 100644 --- a/doc/sphinxext/jupyterlite_lite_renderer.py +++ b/doc/sphinxext/jupyterlite_lite_renderer.py @@ -1,19 +1,10 @@ """Turn on MNE's pyvista-js 3D renderer inside the JupyterLite kernel. -VTK cannot load in WebAssembly, so MNE's 3D output would otherwise be -unavailable in the browser. The renderer that replaces it lives in MNE itself, -at ``mne/viz/backends/_lite.py``, so it is ordinary library code: linted, -formatted and unit tested like any other module, and shipped in the wheel the -browser kernel installs. - -That leaves this module with one job. It exposes the few lines of notebook code -that import the renderer and switch MNE over to it. ``LITE_RENDERER_CELL`` is -appended to ``LITE_SETUP_CELL`` in ``jupyterlite_setup_cell.py``, which the docs -build prepends to each JupyterLite notebook. - -The cell degrades quietly: if the installed MNE predates the renderer, or -pyvista-js is missing, the notebook prints why and carries on with everything -that does not need 3D. +VTK has no WebAssembly build, so the browser draws with pyvista-js instead. The +renderer itself is ordinary library code in ``mne/viz/backends/_lite.py``; this +module only exposes the few lines of notebook code that switch MNE over to it. +``LITE_RENDERER_CELL`` is appended to ``LITE_SETUP_CELL`` in +``jupyterlite_setup_cell.py``, which the docs build prepends to each notebook. """ # Authors: The MNE-Python contributors. @@ -21,9 +12,8 @@ # Copyright the MNE-Python contributors. LITE_RENDERER_CELL = """ -# Draw MNE's 3D figures with pyvista-js (vtk.js). VTK has no WebAssembly build, -# so this swaps only the drawing step; MNE still does its own geometry and -# coordinate-frame work. See mne/viz/backends/_lite.py. +# Using pyvista-js (vtk.js) to draw MNE's 3D rendering in JupyterLite. +# See mne/viz/backends/_lite.py for more details. try: from mne.viz.backends._lite import _activate as _mne_activate_lite_renderer diff --git a/mne/viz/backends/_lite.py b/mne/viz/backends/_lite.py index 748a3b9663a..197299f7898 100644 --- a/mne/viz/backends/_lite.py +++ b/mne/viz/backends/_lite.py @@ -32,6 +32,7 @@ # Copyright the MNE-Python contributors. from ._abstract import _AbstractRenderer +from ._utils import _vtk_faces def _lite_view_vector(azimuth): @@ -188,11 +189,6 @@ def _rgb(self, color): _c = _c / 255.0 return tuple(float(min(max(_v, 0.0), 1.0)) for _v in _c) - def _faces(self, tris): - _np = self._np - _t = _np.asarray(tris, dtype=_np.int32).reshape(-1, 3) - return _np.hstack([_np.full((len(_t), 1), 3, dtype=_np.int32), _t]).ravel() - def _subdivide(self, rr, tris): """One level of midpoint subdivision, sharing the new edge vertices.""" _np = self._np @@ -308,7 +304,7 @@ def _add(self, points, tris, color, opacity=1.0): """ _np = self._np _pd = self._pv.PolyData( - points=_np.asarray(points, dtype=_np.float32), faces=self._faces(tris) + points=_np.asarray(points, dtype=_np.float32), faces=_vtk_faces(tris) ) _actor = self.plotter.add_mesh( _pd, diff --git a/mne/viz/backends/_pyvista.py b/mne/viz/backends/_pyvista.py index 5d3bb8babb6..e2c290f00f2 100644 --- a/mne/viz/backends/_pyvista.py +++ b/mne/viz/backends/_pyvista.py @@ -62,6 +62,7 @@ _alpha_blend_background, _get_colormap_from_array, _init_mne_qtapp, + _vtk_faces, ) try: @@ -428,7 +429,7 @@ def mesh( **kwargs, ): vertices = np.c_[x, y, z].astype(float) - triangles = np.c_[np.full(len(triangles), 3), triangles] + triangles = _vtk_faces(triangles) mesh = PolyData(vertices, triangles) return self.polydata( mesh=mesh, @@ -465,8 +466,7 @@ def contour( colormap = _get_colormap_from_array(colormap, normalized_colormap) vertices = np.array(surface["rr"]) triangles = np.array(surface["tris"]) - n_triangles = len(triangles) - triangles = np.c_[np.full(n_triangles, 3), triangles] + triangles = _vtk_faces(triangles) mesh = PolyData(vertices, triangles) mesh.point_data["scalars"] = scalars contour = mesh.contour(isosurfaces=contours) @@ -504,7 +504,7 @@ def surface( normals = surface.get("nn", None) vertices = np.array(surface["rr"]) triangles = np.array(surface["tris"]) - triangles = np.c_[np.full(len(triangles), 3), triangles] + triangles = _vtk_faces(triangles) mesh = PolyData(vertices, triangles) colormap = _get_colormap_from_array(colormap, normalized_colormap) if scalars is not None: @@ -695,7 +695,7 @@ def instanced_mesh( *, name=None, ): - faces = np.c_[np.full(len(tris), 3), tris] + faces = _vtk_faces(tris) geom = PolyData(np.asarray(rr, float), faces) _compute_normals(geom) diff --git a/mne/viz/backends/_utils.py b/mne/viz/backends/_utils.py index b631bacd8fc..64cf5181022 100644 --- a/mne/viz/backends/_utils.py +++ b/mne/viz/backends/_utils.py @@ -50,6 +50,16 @@ def _get_colormap_from_array( return cmap +def _vtk_faces(tris): + """Return triangles as the (n, 4) face array VTK and vtk.js both accept. + + Each row is ``(3, i, j, k)``: the leading 3 is the vertex count the VTK cell + format expects ahead of every triangle. + """ + tris = np.asarray(tris) + return np.c_[np.full(len(tris), 3), tris] + + def _check_color(color): from matplotlib.colors import colorConverter diff --git a/mne/viz/backends/tests/test_utils.py b/mne/viz/backends/tests/test_utils.py index cabdccb6b62..bac7993e7d6 100644 --- a/mne/viz/backends/tests/test_utils.py +++ b/mne/viz/backends/tests/test_utils.py @@ -11,6 +11,7 @@ import numpy as np import pytest +from numpy.testing import assert_array_equal from mne import create_info from mne.io import RawArray @@ -22,6 +23,7 @@ _pixmap_to_ndarray, _qt_block, _qt_is_dark, + _vtk_faces, ) from mne.viz.utils import _is_dark @@ -69,6 +71,20 @@ def _assert_correct_darkness(widget, want_dark): @pytest.mark.pgtest +def test_vtk_faces(): + """Test building the VTK cell array both 3D renderers draw from.""" + tris = np.array([[0, 1, 2], [0, 2, 3]]) + faces = _vtk_faces(tris) + assert faces.shape == (2, 4) + # each row is the vertex count followed by the triangle + assert_array_equal(faces[:, 0], 3) + assert_array_equal(faces[:, 1:], tris) + # an empty surface must stay empty rather than raise + assert _vtk_faces(np.zeros((0, 3), int)).shape == (0, 4) + # and a list is as good as an array + assert_array_equal(_vtk_faces([[0, 1, 2]]), [[3, 0, 1, 2]]) + + @pytest.mark.parametrize("theme", ("auto", "light", "dark")) def test_theme_colors(pg_backend, theme, monkeypatch, tmp_path): """Test that theme colors propagate properly.""" diff --git a/pyproject.toml b/pyproject.toml index 45dc836212a..6d901bcf0d7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -70,7 +70,7 @@ test_extra = [ "pymef", # drawing backend for the browser docs, see mne/viz/backends/_lite.py; it # needs 3.12, which every CI job that installs this group is already on - "pyvista-js; python_version >= '3.12'", + "pyvista-js >= 0.15; python_version >= '3.12'", "statsmodels", {include-group = "test_extra_ft"}, ] From 2ef8c817559112e2d4ebc4b4188f23c9664840a2 Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Mon, 24 Aug 2026 18:57:50 -0400 Subject: [PATCH 06/15] MAINT: address further review on the vtk.js renderer --- mne/viz/backends/_lite.py | 220 +++++++++++++-------------- mne/viz/backends/tests/test_lite.py | 90 +++++++---- mne/viz/backends/tests/test_utils.py | 2 +- pyproject.toml | 6 +- 4 files changed, 164 insertions(+), 154 deletions(-) diff --git a/mne/viz/backends/_lite.py b/mne/viz/backends/_lite.py index 197299f7898..66ead260a33 100644 --- a/mne/viz/backends/_lite.py +++ b/mne/viz/backends/_lite.py @@ -22,39 +22,37 @@ time slider, and scalar colormaps, which pyvista-js 0.15 does not implement (scalars fall back to a solid color). -Importing this module has no side effects and does not require pyvista-js; -:func:`_activate` is what installs the renderer, and pyvista-js is imported -lazily when a scene is first built. +Importing this module needs pyvista-js, the same way importing ``_pyvista`` +needs VTK, but has no other effect: :func:`_activate` is what puts this +renderer in front of MNE's own. """ # Authors: The MNE-Python contributors. # License: BSD-3-Clause # Copyright the MNE-Python contributors. +import numpy as np +import pyvista_js as pv + +from ...transforms import _find_vector_rotation, _sph_to_cart, quat_to_rot from ._abstract import _AbstractRenderer from ._utils import _vtk_faces -def _lite_view_vector(azimuth): - """Map an MNE azimuth in degrees onto the nearest pyvista-js view vector.""" - _a = float(azimuth) % 360.0 - if 45 <= _a < 135: - return (0.0, -1.0, 0.0) - elif 135 <= _a < 225: - return (1.0, 0.0, 0.0) - elif 225 <= _a < 315: - return (0.0, 1.0, 0.0) - return (-1.0, 0.0, 0.0) - - -def _lite_set_view(plotter, azimuth): - """Point a plotter at the nearest axis-aligned view; no-op without azimuth.""" - if azimuth is None: +def _lite_set_view(plotter, azimuth=None, elevation=None): + """Point a plotter along the requested azimuth and elevation.""" + if azimuth is None and elevation is None: return None - try: - plotter.view_vector(_lite_view_vector(azimuth), viewup=(0.0, 0.0, 1.0)) - except Exception: - pass + phi = np.deg2rad(90.0 if azimuth is None else azimuth) + theta = np.deg2rad(90.0 if elevation is None else elevation) + position = _sph_to_cart(np.array([[1.0, phi, theta]]))[0] + # view up flips near the poles, matching the 5/175 threshold _set_3d_view + # uses, because there the view plane normal runs parallel to the camera + if 5.0 <= abs(np.rad2deg(theta)) <= 175.0: + viewup = (0.0, 0.0, 1.0) + else: + viewup = (0.0, 1.0, 0.0) + plotter.view_vector(-position, viewup=viewup) return None @@ -120,19 +118,15 @@ def _lite_trim_live_plotters(): class _LiteRenderer(_AbstractRenderer): """Minimal MNE 3D renderer backed by pyvista-js.""" - # Callers branch on this to pick behaviour rather than to identify a - # backend, and "notebook" is the branch that is right here: it matches the - # MNE_3D_BACKEND that _activate registers, and it keeps code such as - # mne/gui/_coreg.py from taking its `_kind != "notebook"` path, which - # starts a Qt event loop that does not exist in a browser. - _kind = "notebook" + # Its own kind rather than "notebook": the desktop notebook backend shares + # a kernel with the page but still has VTK, a filesystem and OS threads, + # and none of those are here. The one place that would care, + # mne/gui/_coreg.py, never reaches its `_kind != "notebook"` branch in the + # browser, because _configure_dock asks the renderer for ten _dock_add_* + # methods this one does not have and fails first. + _kind = "jupyterlite_notebook" def __init__(self, *args, **kwargs): - import numpy as _np - import pyvista_js as _pv - - self._np = _np - self._pv = _pv # plot_alignment(fig=...) and plot_dipole_locations(fig=...) composite # into a scene the notebook already made, so draw into that plotter # rather than opening a second one and splitting the picture in two. @@ -142,7 +136,7 @@ def __init__(self, *args, **kwargs): if _fig is not None and hasattr(_fig, "add_mesh"): self.plotter = _fig return - self.plotter = _pv.Plotter() + self.plotter = pv.Plotter() import weakref as _weakref _lite_live_plotters.append(_weakref.ref(self.plotter)) @@ -164,7 +158,7 @@ def __init__(self, *args, **kwargs): ): try: self.plotter.add_light( - _pv.Light( + pv.Light( position=(300.0 * _lp[0], 300.0 * _lp[1], 300.0 * _lp[2]), focal_point=(0.0, 0.0, 0.0), intensity=0.4, @@ -182,7 +176,7 @@ def _rgb(self, color): if isinstance(color, str): return _to_rgb(color) - _c = self._np.asarray(color, dtype=float).ravel()[:3] + _c = np.asarray(color, dtype=float).ravel()[:3] if _c.size < 3: return (0.5, 0.5, 0.5) if _c.max() > 1.0: # 0-255 form @@ -191,23 +185,20 @@ def _rgb(self, color): def _subdivide(self, rr, tris): """One level of midpoint subdivision, sharing the new edge vertices.""" - _np = self._np - _rr = [tuple(_v) for _v in _np.asarray(rr, dtype=float)] + _rr = [tuple(_v) for _v in np.asarray(rr, dtype=float)] _mid = {} _out = [] - for _a, _b, _c in _np.asarray(tris, dtype=int): + for _a, _b, _c in np.asarray(tris, dtype=int): _m = [] for _p, _q in ((_a, _b), (_b, _c), (_c, _a)): _k = (min(int(_p), int(_q)), max(int(_p), int(_q))) if _k not in _mid: _mid[_k] = len(_rr) - _rr.append( - tuple((_np.asarray(_rr[_p]) + _np.asarray(_rr[_q])) / 2.0) - ) + _rr.append(tuple((np.asarray(_rr[_p]) + np.asarray(_rr[_q])) / 2.0)) _m.append(_mid[_k]) _ab, _bc, _ca = _m _out += [[_a, _ab, _ca], [_ab, _b, _bc], [_ca, _bc, _c], [_ab, _bc, _ca]] - return _np.asarray(_rr, dtype=float), _np.asarray(_out, dtype=int) + return np.asarray(_rr, dtype=float), np.asarray(_out, dtype=int) def _glyph_template( self, kind, radius=None, height=None, center=None, resolution=None, **kwargs @@ -222,10 +213,9 @@ def _glyph_template( Sizes follow the templates ``_pyvista.py`` hands the glyph filter, so the browser draws the markers at the size the rendered docs do. """ - _np = self._np if kind in ("sphere", "oct"): _r = 0.5 if radius is None else float(radius) - rr = _np.array( + rr = np.array( [ [1.0, 0, 0], [-1.0, 0, 0], @@ -236,7 +226,7 @@ def _glyph_template( ], float, ) - tris = _np.array( + tris = np.array( [ [0, 2, 4], [2, 1, 4], @@ -256,7 +246,7 @@ def _glyph_template( if kind == "sphere": for _ in range(2): rr, tris = self._subdivide(rr, tris) - rr /= _np.linalg.norm(rr, axis=1)[:, None] + rr /= np.linalg.norm(rr, axis=1)[:, None] return rr * _r, tris if kind == "cone": # apex along +x so the glyph filter's orientation applies, matching @@ -264,35 +254,33 @@ def _glyph_template( _r = 0.15 if radius is None else float(radius) _h = 1.0 if height is None else float(height) _n = 8 if not resolution else max(3, int(resolution) // 2) - _ang = _np.linspace(0.0, 2 * _np.pi, _n, endpoint=False) - _ring = _np.column_stack( - [_np.zeros(_n), _r * _np.cos(_ang), _r * _np.sin(_ang)] + _ang = np.linspace(0.0, 2 * np.pi, _n, endpoint=False) + _ring = np.column_stack( + [np.zeros(_n), _r * np.cos(_ang), _r * np.sin(_ang)] ) - rr = _np.vstack([_ring, [[_h, 0, 0]], [[0.0, 0, 0]]]) + rr = np.vstack([_ring, [[_h, 0, 0]], [[0.0, 0, 0]]]) tris = [] for _i in range(_n): _j = (_i + 1) % _n tris += [[_i, _j, _n], [_n + 1, _j, _i]] # side, base - return rr, _np.asarray(tris, int) + return rr, np.asarray(tris, int) # cylinder along +x, matching _cylinder_geom's convention _r = 0.1 if radius is None else float(radius) _h = 1.0 if height is None else float(height) _n = 8 if not resolution else max(3, int(resolution) // 2) - _c = _np.zeros(3) if center is None else _np.asarray(center, float) - _ang = _np.linspace(0.0, 2 * _np.pi, _n, endpoint=False) - _ring = _np.column_stack( - [_np.zeros(_n), _r * _np.cos(_ang), _r * _np.sin(_ang)] - ) - _back = _ring + _np.array([-_h / 2.0, 0, 0]) - _front = _ring + _np.array([_h / 2.0, 0, 0]) - rr = _np.vstack([_back, _front, [[-_h / 2.0, 0, 0]], [[_h / 2.0, 0, 0]]]) + _c + _c = np.zeros(3) if center is None else np.asarray(center, float) + _ang = np.linspace(0.0, 2 * np.pi, _n, endpoint=False) + _ring = np.column_stack([np.zeros(_n), _r * np.cos(_ang), _r * np.sin(_ang)]) + _back = _ring + np.array([-_h / 2.0, 0, 0]) + _front = _ring + np.array([_h / 2.0, 0, 0]) + rr = np.vstack([_back, _front, [[-_h / 2.0, 0, 0]], [[_h / 2.0, 0, 0]]]) + _c tris = [] for _i in range(_n): _j = (_i + 1) % _n tris += [[_i, _j, _n + _j], [_i, _n + _j, _n + _i]] # wall tris += [[2 * _n, _j, _i]] # back cap tris += [[2 * _n + 1, _n + _i, _n + _j]] # front cap - return rr, _np.asarray(tris, int) + return rr, np.asarray(tris, int) def _add(self, points, tris, color, opacity=1.0): """Draw a mesh and return MNE's (actor, mesh) pair. @@ -302,9 +290,8 @@ def _add(self, points, tris, color, opacity=1.0): drawing method here funnels through this, so translating it once covers all of them. """ - _np = self._np - _pd = self._pv.PolyData( - points=_np.asarray(points, dtype=_np.float32), faces=_vtk_faces(tris) + _pd = pv.PolyData( + points=np.asarray(points, dtype=np.float32), faces=_vtk_faces(tris) ) _actor = self.plotter.add_mesh( _pd, @@ -316,11 +303,8 @@ def _add(self, points, tris, color, opacity=1.0): def _rots_from_dirs(self, dirs): """Rotations carrying +x onto each direction, as the glyphs assume.""" - _np = self._np - from mne.transforms import _find_vector_rotation as _fvr - - _x = _np.array([1.0, 0.0, 0.0]) - return _np.asarray([_fvr(_x, _d) for _d in dirs], dtype=float) + _x = np.array([1.0, 0.0, 0.0]) + return np.asarray([_find_vector_rotation(_x, _d) for _d in dirs], dtype=float) def _tile(self, rr, tris, positions, scales=None, rots=None, axis_scales=None): """Stamp one template mesh at many positions as a single mesh. @@ -330,32 +314,30 @@ def _tile(self, rr, tris, positions, scales=None, rots=None, axis_scales=None): instead means an oct-6 source space becomes 8196 meshes and 8196 actors, which is enough to run the browser tab out of memory. """ - _np = self._np - _rr = _np.asarray(rr, dtype=float) - _tris = _np.asarray(tris, dtype=int) - _pos = _np.atleast_2d(_np.asarray(positions, dtype=float))[:, :3] + _rr = np.asarray(rr, dtype=float) + _tris = np.asarray(tris, dtype=int) + _pos = np.atleast_2d(np.asarray(positions, dtype=float))[:, :3] _n = len(_pos) - _pts = _np.repeat(_rr[None, :, :], _n, axis=0) + _pts = np.repeat(_rr[None, :, :], _n, axis=0) if axis_scales is not None: # tubes span a given length without fattening, so scale the # template's axis alone - _ax = _np.atleast_1d(_np.asarray(axis_scales, dtype=float)) - _pts[:, :, 0] *= _ax[_np.arange(_n) % len(_ax)][:, None] + _ax = np.atleast_1d(np.asarray(axis_scales, dtype=float)) + _pts[:, :, 0] *= _ax[np.arange(_n) % len(_ax)][:, None] if scales is not None: - _sa = _np.atleast_1d(_np.asarray(scales, dtype=float)) - _pts *= _sa[_np.arange(_n) % len(_sa)][:, None, None] + _sa = np.atleast_1d(np.asarray(scales, dtype=float)) + _pts *= _sa[np.arange(_n) % len(_sa)][:, None, None] if rots is not None: - _ra = _np.asarray(rots, dtype=float) - _pts = _np.einsum("nij,nkj->nki", _ra[_np.arange(_n) % len(_ra)], _pts) + _ra = np.asarray(rots, dtype=float) + _pts = np.einsum("nij,nkj->nki", _ra[np.arange(_n) % len(_ra)], _pts) _pts += _pos[:, None, :] - _off = (_np.arange(_n) * len(_rr))[:, None, None] + _off = (np.arange(_n) * len(_rr))[:, None, None] return (_pts.reshape(-1, 3), (_tris[None, :, :] + _off).reshape(-1, 3)) # -- drawing ------------------------------------------------------------ def mesh(self, x, y, z, triangles, color=None, opacity=1.0, *args, **kwargs): - _np = self._np - _pts = _np.column_stack( - [_np.asarray(x).ravel(), _np.asarray(y).ravel(), _np.asarray(z).ravel()] + _pts = np.column_stack( + [np.asarray(x).ravel(), np.asarray(y).ravel(), np.asarray(z).ravel()] ) return self._add(_pts, triangles, color, opacity) @@ -373,8 +355,7 @@ def sphere( radius=None, **kwargs, ): - _np = self._np - _c = _np.atleast_2d(_np.asarray(center, dtype=float)) + _c = np.atleast_2d(np.asarray(center, dtype=float)) if not len(_c): return None, None _r = float(radius if radius is not None else scale) @@ -383,14 +364,13 @@ def sphere( return self._add(_pts, _faces, color, opacity) def tube(self, origin, destination, radius=0.001, color=None, *args, **kwargs): - _np = self._np - _o = _np.atleast_2d(_np.asarray(origin, dtype=float))[:, :3] - _d = _np.atleast_2d(_np.asarray(destination, dtype=float))[:, :3] + _o = np.atleast_2d(np.asarray(origin, dtype=float))[:, :3] + _d = np.atleast_2d(np.asarray(destination, dtype=float))[:, :3] _n = min(len(_o), len(_d)) if not _n: return None, None _vec = _d[:_n] - _o[:_n] - _len = _np.linalg.norm(_vec, axis=1) + _len = np.linalg.norm(_vec, axis=1) _keep = _len > 0 if not _keep.any(): return None, None @@ -435,17 +415,16 @@ def quiver3d( per point instead is what made ``20_source_alignment`` -- an oct-6 source space, so 8196 glyphs, twice -- exhaust the browser tab. """ - _np = self._np - _x, _y, _z = (_np.atleast_1d(_np.asarray(_q, dtype=float)) for _q in (x, y, z)) - _ctr = _np.column_stack([_x, _y, _z]) + _x, _y, _z = (np.atleast_1d(np.asarray(_q, dtype=float)) for _q in (x, y, z)) + _ctr = np.column_stack([_x, _y, _z]) _n = len(_ctr) if not _n: return None, None - _s = float(_np.asarray(scale).ravel()[0]) if _np.size(scale) else 1.0 - _i = _np.arange(_n) - _u, _v, _w = (_np.atleast_1d(_np.asarray(_q, dtype=float)) for _q in (u, v, w)) - _dirs = _np.column_stack([_u[_i % len(_u)], _v[_i % len(_v)], _w[_i % len(_w)]]) - _norm = _np.linalg.norm(_dirs, axis=1) + _s = float(np.asarray(scale).ravel()[0]) if np.size(scale) else 1.0 + _i = np.arange(_n) + _u, _v, _w = (np.atleast_1d(np.asarray(_q, dtype=float)) for _q in (u, v, w)) + _dirs = np.column_stack([_u[_i % len(_u)], _v[_i % len(_v)], _w[_i % len(_w)]]) + _norm = np.linalg.norm(_dirs, axis=1) _flat = _norm == 0 _dirs[_flat] = (1.0, 0.0, 0.0) _norm[_flat] = 1.0 @@ -476,7 +455,7 @@ def quiver3d( if solid_transform is not None: # _pyvista.py transforms the template before glyphing, and this is # where the fiducial markers get their size and 45 deg roll - _st = _np.asarray(solid_transform, dtype=float) + _st = np.asarray(solid_transform, dtype=float) _rr = _rr @ _st[:3, :3].T + _st[:3, 3] _rots = None if mode in ("sphere", "oct") else self._rots_from_dirs(_dirs) _pts, _faces = self._tile(_rr, _tris, _ctr, scales=_s, rots=_rots) @@ -502,23 +481,20 @@ def instanced_mesh( color they asked for and each group becomes one mesh -- a handful of actors for a sensor array instead of one per sensor. """ - _np = self._np - _pos = _np.atleast_2d(_np.asarray(positions, dtype=float))[:, :3] + _pos = np.atleast_2d(np.asarray(positions, dtype=float))[:, :3] _n = len(_pos) if not _n: return None, None _rot = None if quats is not None: - from mne.transforms import quat_to_rot as _q2r - - _rot = _np.asarray( - _q2r(_np.atleast_2d(_np.asarray(quats, dtype=float))), dtype=float + _rot = np.asarray( + quat_to_rot(np.atleast_2d(np.asarray(quats, dtype=float))), dtype=float ) - _idx = _np.arange(_n) - if colors is not None and _np.ndim(colors) > 1: - _ca = _np.asarray(colors) - _uniq, _inv = _np.unique(_ca[_idx % len(_ca)], axis=0, return_inverse=True) - _inv = _np.asarray(_inv).ravel() + _idx = np.arange(_n) + if colors is not None and np.ndim(colors) > 1: + _ca = np.asarray(colors) + _uniq, _inv = np.unique(_ca[_idx % len(_ca)], axis=0, return_inverse=True) + _inv = np.asarray(_inv).ravel() _groups = [(_uniq[_k], _idx[_inv == _k]) for _k in range(len(_uniq))] else: _groups = [(colors, _idx)] @@ -526,7 +502,7 @@ def instanced_mesh( for _col, _sel in _groups: _sc = None if scales is not None: - _sa = _np.atleast_1d(_np.asarray(scales, dtype=float)) + _sa = np.atleast_1d(np.asarray(scales, dtype=float)) _sc = _sa[_sel % len(_sa)] _rt = None if _rot is None else _rot[_sel % len(_rot)] _pts, _faces = self._tile(rr, tris, _pos[_sel], scales=_sc, rots=_rt) @@ -560,10 +536,17 @@ def remove_mesh(self, *args, **kwargs): return None def project(self, xyz, ch_names): - return self._np.asarray(xyz, dtype=float)[:, :2] + raise NotImplementedError( + "Projecting 3D positions onto the scene is not supported in the " + "browser: it has to return a _Projection built from the render " + "window, and pyvista-js does not expose one." + ) def screenshot(self, mode="rgb", filename=None, **kwargs): - return self._np.zeros((2, 2, 3), dtype="uint8") + raise NotImplementedError( + "Taking a screenshot is not supported in the browser: vtk.js draws " + "to a live canvas and pyvista-js cannot read it back as an array." + ) def close(self): return None @@ -585,7 +568,10 @@ def _window_set_cursor(self, *args, **kwargs): return None def get_camera(self, *args, **kwargs): - return (0.0, 0.0, 1.0, (0.0, 0.0, 0.0), 0.0) + # Same order as _get_3d_view: roll, distance, azimuth, elevation and + # then the focalpoint. Brain unpacks positions 3 and 4 as the angles, + # so the focalpoint has to be the last element rather than the fourth. + return (0.0, 1.0, 0.0, 0.0, np.zeros(3)) def set_camera( self, @@ -597,9 +583,7 @@ def set_camera( *args, **kwargs, ): - # pyvista-js has no azimuth/elevation camera; approximate the common - # views and otherwise leave the default. - return _lite_set_view(self.plotter, azimuth) + return _lite_set_view(self.plotter, azimuth, elevation) @property def figure(self): @@ -646,7 +630,7 @@ def _set_3d_view( distance=None, roll=None, ): - return _lite_set_view(figure, azimuth) + return _lite_set_view(figure, azimuth, elevation) def _set_3d_title( self, figure, title, size=40, color="white", position="upper_left" diff --git a/mne/viz/backends/tests/test_lite.py b/mne/viz/backends/tests/test_lite.py index 993ebe64e20..d9671db970c 100644 --- a/mne/viz/backends/tests/test_lite.py +++ b/mne/viz/backends/tests/test_lite.py @@ -8,13 +8,15 @@ import numpy as np import pytest -from mne.viz.backends._abstract import _AbstractRenderer -from mne.viz.backends._lite import ( +pytest.importorskip("pyvista_js") # _lite imports it at module level + +from mne.viz.backends._abstract import _AbstractRenderer # noqa: E402 +from mne.viz.backends._lite import ( # noqa: E402 _LITE_MAX_LIVE_SCENES, _activate, _deactivate, _lite_live_plotters, - _lite_view_vector, + _lite_set_view, _LiteBackend, _LiteRenderer, ) @@ -45,30 +47,25 @@ def test_implements_abstract_renderer(): assert not _LiteRenderer.__abstractmethods__ -def test_kind_matches_the_registered_backend(): - """``_kind`` must stay in step with the backend :func:`_activate` names. +def test_kind_is_its_own(): + """``_kind`` must stay distinct from the desktop notebook backend. - Callers branch on ``_kind`` to choose behaviour: ``mne/gui/_coreg.py`` - calls ``_qt_app_exec`` whenever it is not ``"notebook"``, and there is no - Qt event loop in a browser to exec. + Callers branch on ``_kind`` to pick behaviour, and this environment has no + VTK, no filesystem and no OS threads, so it must not be mistaken for the + notebook backend that does. """ - from mne.viz.backends import renderer - - _activate() - try: - assert _LiteRenderer._kind == renderer.MNE_3D_BACKEND == "notebook" - finally: - _deactivate() + assert _LiteRenderer._kind == "jupyterlite_notebook" + # the two desktop backends, which this must not be confused with + assert _LiteRenderer._kind not in ("notebook", "qt") def test_import_is_side_effect_free(): """Importing the module must not pull in VTK or touch the drawing factory. - The browser kernel has no VTK at all and installs pyvista-js from piplite - only after MNE is imported, so importing this module has to stay cheap and - leave ``mne.viz.backends.renderer`` alone until :func:`_activate` is - called. Run in a subprocess because by this point in a full test session - another backend has usually already imported VTK. + The browser kernel has no VTK at all, and ``mne.viz.backends.renderer`` + has to keep its own factory until :func:`_activate` is called. Run in a + subprocess because by this point in a full test session another backend + has usually already imported VTK. """ code = ( "import sys\n" @@ -76,7 +73,6 @@ def test_import_is_side_effect_free(): "from mne.viz.backends import renderer\n" "assert 'vtk' not in sys.modules, 'importing _lite pulled in vtk'\n" "assert 'vtkmodules' not in sys.modules, 'importing _lite pulled in vtk'\n" - "assert 'pyvista_js' not in sys.modules, 'pyvista-js imported early'\n" "assert renderer._get_renderer.__module__.endswith('renderer')\n" "print('ok')\n" ) @@ -88,19 +84,20 @@ def test_import_is_side_effect_free(): @pytest.mark.parametrize( - "azimuth, expected", - [ - (0, (-1.0, 0.0, 0.0)), - (90, (0.0, -1.0, 0.0)), - (180, (1.0, 0.0, 0.0)), - (270, (0.0, 1.0, 0.0)), - (360, (-1.0, 0.0, 0.0)), # wraps - (-90, (0.0, 1.0, 0.0)), # wraps - ], + "azimuth, elevation", + [(0, None), (90, None), (180, None), (270, None), (45, 30), (None, 2), (None, 90)], ) -def test_view_vector(azimuth, expected): - """Azimuths map onto the nearest axis-aligned view, wrapping past 360.""" - assert _lite_view_vector(azimuth) == expected +def test_set_view(azimuth, elevation, lite_scene): + """Every azimuth/elevation pair must reach the camera, poles included.""" + r = _LiteRenderer(size=(200, 200)) + # 2 and 90 degrees sit either side of the 5/175 view-up flip + assert _lite_set_view(r.plotter, azimuth, elevation) is None + + +def test_set_view_without_angles_is_a_no_op(lite_scene): + """No azimuth and no elevation means leave the camera alone.""" + r = _LiteRenderer(size=(200, 200)) + assert _lite_set_view(r.plotter, None, None) is None def test_draws_every_primitive(lite_scene): @@ -155,6 +152,33 @@ def test_live_scenes_are_capped(lite_scene): assert live == [r.plotter for r in kept[-_LITE_MAX_LIVE_SCENES:]] +def test_get_camera_matches_the_expected_order(lite_scene): + """``get_camera`` must unpack the way ``_get_3d_view`` does. + + ``Brain`` reads it as ``_, _, azimuth, elevation, _``, so the focalpoint + has to be last; putting it fourth hands ``Brain`` a tuple for an angle. + """ + roll, distance, azimuth, elevation, focalpoint = _LiteRenderer( + size=(200, 200) + ).get_camera() + for angle in (roll, distance, azimuth, elevation): + assert isinstance(angle, float) + assert np.asarray(focalpoint).shape == (3,) + + +@pytest.mark.parametrize("method, args", [("project", ({}, [])), ("screenshot", ())]) +def test_unsupported_methods_say_so(method, args, lite_scene): + """Things pyvista-js cannot do must raise, not hand back a plausible stub. + + ``project`` used to return an array where callers expect a ``_Projection`` + and would fail a line later on ``.visible()``; ``screenshot`` used to + return a 2x2 black image. + """ + r = _LiteRenderer(size=(200, 200)) + with pytest.raises(NotImplementedError, match="browser"): + getattr(r, method)(*args) + + def test_activate_and_deactivate_round_trip(lite_scene): """Activation swaps the factory and the backend global, and undoes itself.""" from mne.viz.backends import renderer diff --git a/mne/viz/backends/tests/test_utils.py b/mne/viz/backends/tests/test_utils.py index bac7993e7d6..a9529f29cc8 100644 --- a/mne/viz/backends/tests/test_utils.py +++ b/mne/viz/backends/tests/test_utils.py @@ -70,7 +70,6 @@ def _assert_correct_darkness(widget, want_dark): assert dark == want_dark, f"{widget} pixmap dark={dark} want_dark={want_dark}" -@pytest.mark.pgtest def test_vtk_faces(): """Test building the VTK cell array both 3D renderers draw from.""" tris = np.array([[0, 1, 2], [0, 2, 3]]) @@ -85,6 +84,7 @@ def test_vtk_faces(): assert_array_equal(_vtk_faces([[0, 1, 2]]), [[3, 0, 1, 2]]) +@pytest.mark.pgtest @pytest.mark.parametrize("theme", ("auto", "light", "dark")) def test_theme_colors(pg_backend, theme, monkeypatch, tmp_path): """Test that theme colors propagate properly.""" diff --git a/pyproject.toml b/pyproject.toml index 6d901bcf0d7..b0897101966 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -68,8 +68,10 @@ test_extra = [ "nbclient", # requires jupyter_client "nitime >= 0.7", "pymef", - # drawing backend for the browser docs, see mne/viz/backends/_lite.py; it - # needs 3.12, which every CI job that installs this group is already on + # drawing backend for the browser docs, see mne/viz/backends/_lite.py. The + # marker is pyvista-js's own requires-python (>= 3.12, < 3.15) rather than + # anything about JupyterLite: without it this group cannot resolve at all on + # the 3.11 MNE still supports. Every CI job that installs it is on 3.12+. "pyvista-js >= 0.15; python_version >= '3.12'", "statsmodels", {include-group = "test_extra_ft"}, From bb425c3fbc79390af0adc5f56bed4c874085caf4 Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Mon, 24 Aug 2026 19:32:39 -0400 Subject: [PATCH 07/15] FIX: implement _clear_3d_figure for the browser backend --- mne/viz/backends/_lite.py | 10 ++++++-- mne/viz/backends/tests/test_lite.py | 39 +++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/mne/viz/backends/_lite.py b/mne/viz/backends/_lite.py index 66ead260a33..848e24f3e88 100644 --- a/mne/viz/backends/_lite.py +++ b/mne/viz/backends/_lite.py @@ -122,8 +122,8 @@ class _LiteRenderer(_AbstractRenderer): # a kernel with the page but still has VTK, a filesystem and OS threads, # and none of those are here. The one place that would care, # mne/gui/_coreg.py, never reaches its `_kind != "notebook"` branch in the - # browser, because _configure_dock asks the renderer for ten _dock_add_* - # methods this one does not have and fails first. + # browser, because _configure_dock asks the renderer for a dock and toolbar + # API this one does not implement and fails first. _kind = "jupyterlite_notebook" def __init__(self, *args, **kwargs): @@ -637,6 +637,12 @@ def _set_3d_title( ): return None + def _clear_3d_figure(self, figure): + # close=False is already the "give the geometry back but keep the + # scene" path, which is what clearing means + _lite_release_plotter(figure, close=False) + return None + def _close_3d_figure(self, figure): _lite_release_plotter(figure) return None diff --git a/mne/viz/backends/tests/test_lite.py b/mne/viz/backends/tests/test_lite.py index d9671db970c..6429dbb7862 100644 --- a/mne/viz/backends/tests/test_lite.py +++ b/mne/viz/backends/tests/test_lite.py @@ -4,6 +4,7 @@ import subprocess import sys +from pathlib import Path import numpy as np import pytest @@ -207,6 +208,44 @@ def test_activate_is_idempotent(lite_scene): assert renderer._get_renderer is before +def test_backend_covers_everything_renderer_calls(): + """``_LiteBackend`` must implement every helper ``renderer.py`` reaches for. + + These are module-level functions that read ``renderer.backend`` directly + rather than going through ``_get_renderer``, so a new one added upstream + breaks the browser silently. ``clear_3d_figure`` did exactly that. + """ + import ast + + from mne.viz.backends import renderer + + src = Path(renderer.__file__).read_text() + needed = { + n.attr + for n in ast.walk(ast.parse(src)) + if isinstance(n, ast.Attribute) + and isinstance(n.value, ast.Name) + and n.value.id == "backend" + } + # _Renderer is the factory and _testing_context is test-only scaffolding + needed -= {"_Renderer", "_testing_context"} + missing = sorted(m for m in needed if not hasattr(_LiteBackend, m)) + assert not missing, f"_LiteBackend is missing {missing}" + + +def test_clear_keeps_the_scene(lite_scene): + """Clearing drops the geometry but leaves the scene open to draw into.""" + r = _LiteRenderer(size=(200, 200)) + r.sphere(np.array([[0.0, 0, 0]]), "red", 1.0) + assert len(r.plotter.actors) == 1 + + _LiteBackend()._clear_3d_figure(r.plotter) + assert len(r.plotter.actors) == 0 + # still usable, unlike after _close_3d_figure + r.sphere(np.array([[1.0, 0, 0]]), "blue", 1.0) + assert len(r.plotter.actors) == 1 + + def test_backend_scene_helpers(lite_scene): """The scene-level helpers MNE calls on ``renderer.backend`` all work. From 36bcfd3c0f1903f1ba6443603dadadb30fc3fa56 Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Tue, 25 Aug 2026 13:13:54 -0400 Subject: [PATCH 08/15] MAINT: register the vtk.js renderer as a 3D backend Replaces the _activate monkeypatching with a real "jupyterlite" backend so it works with the pytest renderer fixtures. Also fixes the glyph templates, which drew spheres at twice their size and EEG cylinders off their own axis. --- doc/changes/dev/14144.other.rst | 2 +- doc/sphinxext/jupyterlite_lite_renderer.py | 8 +- mne/conftest.py | 20 + mne/viz/_3d.py | 4 +- mne/viz/backends/_lite.py | 872 ++++++++++++--------- mne/viz/backends/_utils.py | 6 + mne/viz/backends/renderer.py | 90 ++- mne/viz/backends/tests/test_lite.py | 430 +++++----- mne/viz/backends/tests/test_renderer.py | 2 +- tools/vulture_allowlist.py | 2 +- 10 files changed, 839 insertions(+), 597 deletions(-) diff --git a/doc/changes/dev/14144.other.rst b/doc/changes/dev/14144.other.rst index 843413e4d52..434936fe032 100644 --- a/doc/changes/dev/14144.other.rst +++ b/doc/changes/dev/14144.other.rst @@ -1 +1 @@ -Add a vtk.js drawing backend for MNE's 3D renderer in ``mne/viz/backends/_lite.py``, used by the JupyterLite documentation where VTK cannot load, by `Natneal B`_. +Add a vtk.js drawing backend for MNE's 3D renderer, selected with ``mne.viz.set_3d_backend("jupyterlite")`` and used by the JupyterLite documentation where VTK cannot load, by `Natneal B`_. diff --git a/doc/sphinxext/jupyterlite_lite_renderer.py b/doc/sphinxext/jupyterlite_lite_renderer.py index 9394b894c74..c57c315cdef 100644 --- a/doc/sphinxext/jupyterlite_lite_renderer.py +++ b/doc/sphinxext/jupyterlite_lite_renderer.py @@ -12,12 +12,12 @@ # Copyright the MNE-Python contributors. LITE_RENDERER_CELL = """ -# Using pyvista-js (vtk.js) to draw MNE's 3D rendering in JupyterLite. +# Draw MNE's 3D figures with pyvista-js (vtk.js): VTK has no WebAssembly build. # See mne/viz/backends/_lite.py for more details. try: - from mne.viz.backends._lite import _activate as _mne_activate_lite_renderer + import mne.viz - _mne_activate_lite_renderer() + mne.viz.set_3d_backend("jupyterlite") except Exception as _e: - print("[JupyterLite] could not install the pyvista-js renderer: " + repr(_e)) + print("[JupyterLite] could not select the pyvista-js renderer: " + repr(_e)) """ diff --git a/mne/conftest.py b/mne/conftest.py index da87c72468f..cb301c6d898 100644 --- a/mne/conftest.py +++ b/mne/conftest.py @@ -755,6 +755,22 @@ def renderer_notebook(request, options_3d): yield renderer +@pytest.fixture(params=[pytest.param("jupyterlite", marks=pytest.mark.pvtest)]) +def renderer_lite(request, options_3d): + """Yield the JupyterLite (vtk.js) renderer.""" + from mne.viz.backends import renderer as renderer_module + + # use_3d_backend only puts a backend back if one was already selected, and + # this one draws for a browser, so make sure it is never what a later test + # inherits + was = (renderer_module.MNE_3D_BACKEND, renderer_module.backend) + try: + with _use_backend(request.param, interactive=False) as renderer: + yield renderer + finally: + renderer_module.MNE_3D_BACKEND, renderer_module.backend = was + + @pytest.fixture(params=[pytest.param("pyvistaqt", marks=pytest.mark.pvtest)]) def renderer_interactive_pyvistaqt(request, options_3d, qt_windows_closed): """Yield the interactive PyVista backend.""" @@ -798,6 +814,10 @@ def _use_backend(backend_name, interactive): def _check_skip_backend(name): from mne.viz.backends._utils import _notebook_vtk_works + if name == "jupyterlite": + # draws with vtk.js in a browser: no VTK, no Qt, no ffmpeg + pytest.importorskip("pyvista_js") + return pytest.importorskip("pyvista") pytest.importorskip("imageio_ffmpeg") if name == "pyvistaqt": diff --git a/mne/viz/_3d.py b/mne/viz/_3d.py index 273a9f7e689..41a9232ea3d 100644 --- a/mne/viz/_3d.py +++ b/mne/viz/_3d.py @@ -841,7 +841,7 @@ def plot_alignment( # initialize figure renderer = _get_renderer( - fig, + fig=fig, name=f"Sensor alignment: {subject}", bgcolor=(0.5, 0.5, 0.5), size=(800, 800), @@ -3931,7 +3931,7 @@ def snapshot_brain_montage(fig, montage, hide_sensors=True): ) # initialize figure - renderer = _get_renderer(fig, show=True) + renderer = _get_renderer(fig=fig, show=True) xyz = np.vstack(xyz) proj = renderer.project(xyz=xyz, ch_names=ch_names) diff --git a/mne/viz/backends/_lite.py b/mne/viz/backends/_lite.py index 848e24f3e88..4dbb3b83b4c 100644 --- a/mne/viz/backends/_lite.py +++ b/mne/viz/backends/_lite.py @@ -10,34 +10,80 @@ Rather than reimplement those functions one by one, this module supplies a renderer that draws with `pyvista-js `__ -(vtk.js) and, via :func:`_activate`, patches that factory along with the -``renderer.backend`` global that ``set_3d_view`` and the other scene-level -helpers read directly. MNE keeps doing all of the transform math itself, which -matters because getting a head/MRI/device transform subtly wrong produces a -plausible-looking picture with the sensors in the wrong place. +(vtk.js). It is a 3D backend like ``_qt`` and ``_notebook`` are, selected with +``mne.viz.set_3d_backend("jupyterlite")``, which is all a browser kernel has to +do. MNE keeps doing all of the transform math itself, which matters because +getting a head/MRI/device transform subtly wrong produces a plausible-looking +picture with the sensors in the wrong place. Supported: meshes, surfaces, spheres, tubes and glyphs, which covers the static figures the documentation renders. Not supported: the interactive :class:`mne.viz.Brain` time viewer, which additionally needs dock widgets and a -time slider, and scalar colormaps, which pyvista-js 0.15 does not implement -(scalars fall back to a solid color). +time slider, and scalar colormaps: ``Plotter.add_mesh`` takes ``scalars`` and +``cmap`` and writes them into the scene, but the vtk.js template it renders +through builds no lookup table and never reads them, so a mesh carrying +scalars draws in a solid color. Importing this module needs pyvista-js, the same way importing ``_pyvista`` -needs VTK, but has no other effect: :func:`_activate` is what puts this -renderer in front of MNE's own. +needs VTK. """ # Authors: The MNE-Python contributors. # License: BSD-3-Clause # Copyright the MNE-Python contributors. +import gc +import weakref +from contextlib import nullcontext + import numpy as np import pyvista_js as pv - -from ...transforms import _find_vector_rotation, _sph_to_cart, quat_to_rot +from matplotlib.colors import to_rgb + +from ...surface import _tessellate_sphere +from ...transforms import ( + _cart_to_sph, + _find_vector_rotation, + _sph_to_cart, + quat_to_rot, +) +from ...utils import _check_option from ._abstract import _AbstractRenderer from ._utils import _vtk_faces +# vtk.js positions text in normalized window coordinates, where PyVista takes +# the names MNE's set_3d_title passes through +_TITLE_POSITIONS = { + "lower_left": (0.05, 0.05), + "lower_right": (0.65, 0.05), + "upper_left": (0.05, 0.90), + "upper_right": (0.65, 0.90), +} + +# what MNE means by ``color=None``: "whatever the renderer draws by default", +# which for PyVista is resolved inside add_mesh and has to be picked here +_DEFAULT_COLOR = (0.5, 0.5, 0.5) + + +def _rgb(color): + """Return an (r, g, b) 0-1 tuple, the only color form pyvista-js takes. + + Its own parser knows fourteen color names and rejects hex strings, so do + the conversion here. ``to_rgb`` covers every form MNE hands a renderer -- + color names, hex, ``"C0"`` and 0-1 ``(r, g, b[, a])`` sequences -- and + raises on anything else, which is what should happen. + """ + return _DEFAULT_COLOR if color is None else to_rgb(color) + + +def _lite_add_text(plotter, text, position, size, color): + """Add a vtk.js 2D text actor and return it, the way MNE's text2d does.""" + actor = pv.Text(str(text), position=tuple(float(coord) for coord in position)) + actor.prop.font_size = int(size) + actor.prop.color = _rgb(color) + plotter.add_text(actor) + return actor + def _lite_set_view(plotter, azimuth=None, elevation=None): """Point a plotter along the requested azimuth and elevation.""" @@ -56,9 +102,36 @@ def _lite_set_view(plotter, azimuth=None, elevation=None): return None +def _lite_get_view(plotter): + """Return the direction a plotter is looking along, as _get_3d_view orders it. + + ``view_vector`` stores that direction on the plotter's renderer and leaves + ``Plotter.camera`` unset, which is deliberate: the vtk.js side then calls + ``resetCamera()`` and frames the scene, whereas a camera object would have + to carry a distance, and MNE asks for ``distance=None`` more often than not. + The cost is that ``Plotter.camera_position`` reads that unset camera and so + stays ``None``; the direction below is the scene's actual camera state, and + inverting it recovers the angles :func:`_lite_set_view` applied. + """ + view_vector = plotter._renderer._view_vector # pyvista-js 0.15 + if view_vector is None: # no view set yet, so vtk.js is choosing one + return (0.0, 1.0, 0.0, 0.0, np.zeros(3)) + _, phi, theta = _cart_to_sph(-np.asarray(view_vector, float)[np.newaxis])[0] + # roll is 0 because the only camera roll _lite_set_view applies is the view + # up flip at the poles; the distance and focalpoint are the ones the vtk.js + # resetCamera() gives this path, a unit direction aimed at the origin + return ( + 0.0, + 1.0, + float(np.rad2deg(phi)) % 360, + float(np.rad2deg(theta)) % 180, + np.zeros(3), + ) + + # Every scene a notebook drew used to stay live for the kernel's lifetime, -# because the close helpers on _LiteBackend were no-ops. Track the plotters -# weakly -- so they stay collectable -- and give close_all something to free. +# because the close helpers were no-ops. Track the plotters weakly -- so they +# stay collectable -- and give close_all something to free. _lite_live_plotters = [] @@ -72,25 +145,20 @@ def _lite_release_plotter(plotter, close=True): ``deep_clean`` nor ``close``, so today the two paths do the same thing; the flag keeps the intent right if that changes. """ - import gc as _gc - if plotter is None: return None - for _i in range(len(_lite_live_plotters) - 1, -1, -1): - _p = _lite_live_plotters[_i]() - if _p is None or _p is plotter: - del _lite_live_plotters[_i] + for idx in range(len(_lite_live_plotters) - 1, -1, -1): + live = _lite_live_plotters[idx]() + if live is None or live is plotter: + del _lite_live_plotters[idx] # pyvista-js is someone else's surface, so use whichever teardown of these # it actually implements - _names = ("clear", "deep_clean", "close") if close else ("clear", "deep_clean") - for _name in _names: - _fn = getattr(plotter, _name, None) - if _fn is not None: - try: - _fn() - except Exception: - pass - _gc.collect() + names = ("clear", "deep_clean", "close") if close else ("clear", "deep_clean") + for name in names: + teardown = getattr(plotter, name, None) + if teardown is not None: + teardown() + gc.collect() return None @@ -106,12 +174,12 @@ def _lite_release_plotter(plotter, close=True): def _lite_trim_live_plotters(): """Release everything but the most recent scenes.""" while len(_lite_live_plotters) > _LITE_MAX_LIVE_SCENES: - _p = _lite_live_plotters[0]() - if _p is None: + oldest = _lite_live_plotters[0]() + if oldest is None: _lite_live_plotters.pop(0) else: # also drops it from the registry, so this terminates - _lite_release_plotter(_p, close=False) + _lite_release_plotter(oldest, close=False) return None @@ -126,29 +194,21 @@ class _LiteRenderer(_AbstractRenderer): # API this one does not implement and fails first. _kind = "jupyterlite_notebook" - def __init__(self, *args, **kwargs): + def __init__(self, fig=None, size=(600, 600), bgcolor="black", **kwargs): # plot_alignment(fig=...) and plot_dipole_locations(fig=...) composite # into a scene the notebook already made, so draw into that plotter # rather than opening a second one and splitting the picture in two. - # plot_alignment passes it positionally and create_3d_figure by name, - # and `fig` is _PyVistaRenderer's first argument, so accept both. - _fig = args[0] if args else kwargs.get("fig", None) - if _fig is not None and hasattr(_fig, "add_mesh"): - self.plotter = _fig + if fig is not None: + self.plotter = fig return self.plotter = pv.Plotter() - import weakref as _weakref - - _lite_live_plotters.append(_weakref.ref(self.plotter)) - # trim AFTER appending, so the scene being built is never the one freed + _lite_live_plotters.append(weakref.ref(self.plotter)) + # trim after appending, so the new scene counts towards the cap and + # _LITE_MAX_LIVE_SCENES is the number that actually stays live _lite_trim_live_plotters() - _bg = kwargs.get("bgcolor", kwargs.get("background_color", "black")) - try: - self.plotter.background_color = self._rgb(_bg) - except Exception: - pass + self.plotter.background_color = _rgb(bgcolor) # even lighting, so a surface is not black when rotated - for _lp in ( + for direction in ( (1, 0, 0), (-1, 0, 0), (0, 1, 0), @@ -156,130 +216,105 @@ def __init__(self, *args, **kwargs): (0, 0, 1), (0, 0, -1), ): - try: - self.plotter.add_light( - pv.Light( - position=(300.0 * _lp[0], 300.0 * _lp[1], 300.0 * _lp[2]), - focal_point=(0.0, 0.0, 0.0), - intensity=0.4, - ) + self.plotter.add_light( + pv.Light( + position=tuple(300.0 * coord for coord in direction), + focal_point=(0.0, 0.0, 0.0), + intensity=0.4, ) - except Exception: - pass + ) # -- helpers ------------------------------------------------------------ - def _rgb(self, color): - """Return an (r, g, b) 0-1 tuple; pyvista-js rejects hex strings.""" - if color is None: - return (0.5, 0.5, 0.5) - from matplotlib.colors import to_rgb as _to_rgb - - if isinstance(color, str): - return _to_rgb(color) - _c = np.asarray(color, dtype=float).ravel()[:3] - if _c.size < 3: - return (0.5, 0.5, 0.5) - if _c.max() > 1.0: # 0-255 form - _c = _c / 255.0 - return tuple(float(min(max(_v, 0.0), 1.0)) for _v in _c) - - def _subdivide(self, rr, tris): - """One level of midpoint subdivision, sharing the new edge vertices.""" - _rr = [tuple(_v) for _v in np.asarray(rr, dtype=float)] - _mid = {} - _out = [] - for _a, _b, _c in np.asarray(tris, dtype=int): - _m = [] - for _p, _q in ((_a, _b), (_b, _c), (_c, _a)): - _k = (min(int(_p), int(_q)), max(int(_p), int(_q))) - if _k not in _mid: - _mid[_k] = len(_rr) - _rr.append(tuple((np.asarray(_rr[_p]) + np.asarray(_rr[_q])) / 2.0)) - _m.append(_mid[_k]) - _ab, _bc, _ca = _m - _out += [[_a, _ab, _ca], [_ab, _b, _bc], [_ca, _bc, _c], [_ab, _bc, _ca]] - return np.asarray(_rr, dtype=float), np.asarray(_out, dtype=int) - def _glyph_template( self, kind, radius=None, height=None, center=None, resolution=None, **kwargs ): """Return (rr, tris) for a glyph template, oriented along +x. pyvista-js's Sphere/Cylinder are parametric primitives with no - triangle list, so build the templates here. ``_tile`` then stamps one - of these at every position and merges the result, which is what keeps - these cheap -- the copies share a single mesh and a single actor. + triangle list, so the templates come from MNE's own tessellation or are + built here. ``_tile`` then stamps one of these at every position and + merges the result, which is what keeps these cheap -- the copies share + a single mesh and a single actor. Sizes follow the templates ``_pyvista.py`` hands the glyph filter, so the browser draws the markers at the size the rendered docs do. """ if kind in ("sphere", "oct"): - _r = 0.5 if radius is None else float(radius) - rr = np.array( - [ - [1.0, 0, 0], - [-1.0, 0, 0], - [0, 1.0, 0], - [0, -1.0, 0], - [0, 0, 1.0], - [0, 0, -1.0], - ], - float, + scale = 0.5 if radius is None else float(radius) + # "oct" is an octahedron on purpose -- that is what _pyvista.py + # hands the glyph filter, and level 1 is the unit octahedron + # vtkPlatonicSolidSource draws. A "sphere" has to look round, + # though: fiducials and dig points are drawn with it, so level 3 + # subdivides that octahedron onto the unit sphere at 66 vertices, + # near the reference's 8x8 sphere (58). + rr, tris = _tessellate_sphere(1 if kind == "oct" else 3) + return rr * scale, tris + if kind == "arrow": + # vtkArrowSource, which is what _pyvista.py glyphs mode="arrow" + # with: a cylindrical shaft carrying a cone tip, together spanning + # 0 to 1 along +x. Its defaults, not glyph_radius, set the widths. + # both halves are placed here rather than through the cylinder's + # `center`, which is read in _cylinder_geom's pre-rotation frame + shaft_rr, shaft_tris = self._glyph_template( + "cylinder", radius=0.03, height=0.65, resolution=12 ) - tris = np.array( - [ - [0, 2, 4], - [2, 1, 4], - [1, 3, 4], - [3, 0, 4], - [2, 0, 5], - [1, 2, 5], - [3, 1, 5], - [0, 3, 5], - ], - int, + shaft_rr = shaft_rr + np.array([0.325, 0, 0]) + tip_rr, tip_tris = self._glyph_template( + "cone", radius=0.1, height=0.35, resolution=12 + ) + tip_rr = tip_rr + np.array([0.65, 0, 0]) + return ( + np.vstack([shaft_rr, tip_rr]), + np.vstack([shaft_tris, tip_tris + len(shaft_rr)]), ) - # "oct" is an octahedron on purpose -- that is what _pyvista.py - # hands the glyph filter. A "sphere" has to look round, though: - # fiducials and dig points are drawn with it, so subdivide onto the - # unit sphere to land near the reference's 8x8 sphere (58 verts). - if kind == "sphere": - for _ in range(2): - rr, tris = self._subdivide(rr, tris) - rr /= np.linalg.norm(rr, axis=1)[:, None] - return rr * _r, tris if kind == "cone": # apex along +x so the glyph filter's orientation applies, matching # pyvista.Cone(center=(0.5, 0, 0)): base at x=0, apex at x=height - _r = 0.15 if radius is None else float(radius) - _h = 1.0 if height is None else float(height) - _n = 8 if not resolution else max(3, int(resolution) // 2) - _ang = np.linspace(0.0, 2 * np.pi, _n, endpoint=False) - _ring = np.column_stack( - [np.zeros(_n), _r * np.cos(_ang), _r * np.sin(_ang)] + rad = 0.15 if radius is None else float(radius) + hgt = 1.0 if height is None else float(height) + n_side = 8 if not resolution else max(3, int(resolution) // 2) + angles = np.linspace(0.0, 2 * np.pi, n_side, endpoint=False) + ring = np.column_stack( + [np.zeros(n_side), rad * np.cos(angles), rad * np.sin(angles)] ) - rr = np.vstack([_ring, [[_h, 0, 0]], [[0.0, 0, 0]]]) + rr = np.vstack([ring, [[hgt, 0, 0]], [[0.0, 0, 0]]]) tris = [] - for _i in range(_n): - _j = (_i + 1) % _n - tris += [[_i, _j, _n], [_n + 1, _j, _i]] # side, base + for this in range(n_side): + nxt = (this + 1) % n_side + tris += [[this, nxt, n_side], [n_side + 1, nxt, this]] # side, base return rr, np.asarray(tris, int) # cylinder along +x, matching _cylinder_geom's convention - _r = 0.1 if radius is None else float(radius) - _h = 1.0 if height is None else float(height) - _n = 8 if not resolution else max(3, int(resolution) // 2) - _c = np.zeros(3) if center is None else np.asarray(center, float) - _ang = np.linspace(0.0, 2 * np.pi, _n, endpoint=False) - _ring = np.column_stack([np.zeros(_n), _r * np.cos(_ang), _r * np.sin(_ang)]) - _back = _ring + np.array([-_h / 2.0, 0, 0]) - _front = _ring + np.array([_h / 2.0, 0, 0]) - rr = np.vstack([_back, _front, [[-_h / 2.0, 0, 0]], [[_h / 2.0, 0, 0]]]) + _c + rad = 0.1 if radius is None else float(radius) + hgt = 1.0 if height is None else float(height) + # half the sides VTK would use: _tile stamps this template at every + # sensor, so the side count multiplies straight into the WASM heap and + # a 16-sided EEG cylinder is smooth enough at the size it draws + n_side = 8 if not resolution else max(3, int(resolution) // 2) + # _cylinder_geom builds the cylinder along y and turns it 90 degrees + # about z to point it along x, which carries the center round with it: + # (cx, cy, cz) lands at (-cy, cx, cz). _3d.py gives the EEG electrode + # offset in that pre-turn frame, so turn it here too, or the cylinders + # sit beside their sensors instead of standing on them. + if center is None: + offset = np.zeros(3) + else: + center = np.asarray(center, dtype=float) + offset = np.array([-center[1], center[0], center[2]]) + angles = np.linspace(0.0, 2 * np.pi, n_side, endpoint=False) + ring = np.column_stack( + [np.zeros(n_side), rad * np.cos(angles), rad * np.sin(angles)] + ) + back = ring + np.array([-hgt / 2.0, 0, 0]) + front = ring + np.array([hgt / 2.0, 0, 0]) + rr = ( + np.vstack([back, front, [[-hgt / 2.0, 0, 0]], [[hgt / 2.0, 0, 0]]]) + offset + ) tris = [] - for _i in range(_n): - _j = (_i + 1) % _n - tris += [[_i, _j, _n + _j], [_i, _n + _j, _n + _i]] # wall - tris += [[2 * _n, _j, _i]] # back cap - tris += [[2 * _n + 1, _n + _i, _n + _j]] # front cap + for this in range(n_side): + nxt = (this + 1) % n_side + tris += [[this, nxt, n_side + nxt], [this, n_side + nxt, n_side + this]] + tris += [[2 * n_side, nxt, this]] # back cap + tris += [[2 * n_side + 1, n_side + this, n_side + nxt]] # front cap return rr, np.asarray(tris, int) def _add(self, points, tris, color, opacity=1.0): @@ -290,21 +325,21 @@ def _add(self, points, tris, color, opacity=1.0): drawing method here funnels through this, so translating it once covers all of them. """ - _pd = pv.PolyData( + mesh = pv.PolyData( points=np.asarray(points, dtype=np.float32), faces=_vtk_faces(tris) ) - _actor = self.plotter.add_mesh( - _pd, - color=self._rgb(color), + actor = self.plotter.add_mesh( + mesh, + color=_rgb(color), opacity=1.0 if opacity is None else float(opacity), smooth_shading=True, ) - return _actor, _pd + return actor, mesh def _rots_from_dirs(self, dirs): """Rotations carrying +x onto each direction, as the glyphs assume.""" - _x = np.array([1.0, 0.0, 0.0]) - return np.asarray([_find_vector_rotation(_x, _d) for _d in dirs], dtype=float) + x_axis = np.array([1.0, 0.0, 0.0]) + return np.asarray([_find_vector_rotation(x_axis, d) for d in dirs], dtype=float) def _tile(self, rr, tris, positions, scales=None, rots=None, axis_scales=None): """Stamp one template mesh at many positions as a single mesh. @@ -313,35 +348,70 @@ def _tile(self, rr, tris, positions, scales=None, rots=None, axis_scales=None): every copy into one mesh and adds it once. Doing this per position instead means an oct-6 source space becomes 8196 meshes and 8196 actors, which is enough to run the browser tab out of memory. + + This is the shared step behind every glyph method here, including + :meth:`instanced_mesh`; it takes rotation matrices because that is what + ``quiver3d`` and ``tube`` already have, and ``instanced_mesh`` converts + its quaternions once on the way in. """ - _rr = np.asarray(rr, dtype=float) - _tris = np.asarray(tris, dtype=int) - _pos = np.atleast_2d(np.asarray(positions, dtype=float))[:, :3] - _n = len(_pos) - _pts = np.repeat(_rr[None, :, :], _n, axis=0) + rr = np.asarray(rr, dtype=float) + tris = np.asarray(tris, dtype=int) + positions = np.atleast_2d(np.asarray(positions, dtype=float))[:, :3] + n_pos = len(positions) + points = np.repeat(rr[None, :, :], n_pos, axis=0) if axis_scales is not None: # tubes span a given length without fattening, so scale the # template's axis alone - _ax = np.atleast_1d(np.asarray(axis_scales, dtype=float)) - _pts[:, :, 0] *= _ax[np.arange(_n) % len(_ax)][:, None] + axis_scales = np.atleast_1d(np.asarray(axis_scales, dtype=float)) + points[:, :, 0] *= axis_scales[np.arange(n_pos) % len(axis_scales)][:, None] if scales is not None: - _sa = np.atleast_1d(np.asarray(scales, dtype=float)) - _pts *= _sa[np.arange(_n) % len(_sa)][:, None, None] + scales = np.atleast_1d(np.asarray(scales, dtype=float)) + points *= scales[np.arange(n_pos) % len(scales)][:, None, None] if rots is not None: - _ra = np.asarray(rots, dtype=float) - _pts = np.einsum("nij,nkj->nki", _ra[np.arange(_n) % len(_ra)], _pts) - _pts += _pos[:, None, :] - _off = (np.arange(_n) * len(_rr))[:, None, None] - return (_pts.reshape(-1, 3), (_tris[None, :, :] + _off).reshape(-1, 3)) + rots = np.asarray(rots, dtype=float) + points = np.einsum( + "nij,nkj->nki", rots[np.arange(n_pos) % len(rots)], points + ) + points += positions[:, None, :] + offsets = (np.arange(n_pos) * len(rr))[:, None, None] + return (points.reshape(-1, 3), (tris[None, :, :] + offsets).reshape(-1, 3)) # -- drawing ------------------------------------------------------------ - def mesh(self, x, y, z, triangles, color=None, opacity=1.0, *args, **kwargs): - _pts = np.column_stack( + # Three arguments below are named rather than swallowed by **kwargs, because + # MNE passes them on the paths the docs render and vtk.js cannot honour any + # of them: it has no backface culling (its actor style only covers + # representation, shading and edges), it recomputes normals itself with + # vtkPolyDataNormals rather than taking an array, and it has no actor + # registry to look a `name` up in later. The visible cost is that a + # transparent surface shows its own inside. + def mesh( + self, + x, + y, + z, + triangles, + color=None, + opacity=1.0, + backface_culling=False, + normals=None, + *args, + **kwargs, + ): + points = np.column_stack( [np.asarray(x).ravel(), np.asarray(y).ravel(), np.asarray(z).ravel()] ) - return self._add(_pts, triangles, color, opacity) + return self._add(points, triangles, color, opacity) - def surface(self, surface, color=None, opacity=1.0, *args, **kwargs): + def surface( + self, + surface, + color=None, + opacity=1.0, + backface_culling=False, + name=None, + *args, + **kwargs, + ): return self._add(surface["rr"], surface["tris"], color, opacity) def sphere( @@ -355,38 +425,55 @@ def sphere( radius=None, **kwargs, ): - _c = np.atleast_2d(np.asarray(center, dtype=float)) - if not len(_c): + center = np.atleast_2d(np.asarray(center, dtype=float)) + if not len(center): return None, None - _r = float(radius if radius is not None else scale) - _rr, _tris = self._glyph_template("sphere", radius=_r, resolution=resolution) - _pts, _faces = self._tile(_rr, _tris, _c) - return self._add(_pts, _faces, color, opacity) - - def tube(self, origin, destination, radius=0.001, color=None, *args, **kwargs): - _o = np.atleast_2d(np.asarray(origin, dtype=float))[:, :3] - _d = np.atleast_2d(np.asarray(destination, dtype=float))[:, :3] - _n = min(len(_o), len(_d)) - if not _n: + # _pyvista.py glyphs a radius-0.5 sphere by `scale`, or a + # radius-`radius` sphere by 1, so the drawn radius is half of `scale` + rr, tris = self._glyph_template( + "sphere", radius=0.5 * float(scale) if radius is None else float(radius) + ) + # one template stamped at every center, which is exactly instanced_mesh + # without orientations or per-instance colors + return self.instanced_mesh(rr, tris, center, colors=color, opacity=opacity) + + def tube( + self, + origin, + destination, + radius=0.001, + color=None, + opacity=1.0, + *args, + **kwargs, + ): + origin = np.atleast_2d(np.asarray(origin, dtype=float))[:, :3] + destination = np.atleast_2d(np.asarray(destination, dtype=float))[:, :3] + n_seg = min(len(origin), len(destination)) + if not n_seg: return None, None - _vec = _d[:_n] - _o[:_n] - _len = np.linalg.norm(_vec, axis=1) - _keep = _len > 0 - if not _keep.any(): + vec = destination[:n_seg] - origin[:n_seg] + length = np.linalg.norm(vec, axis=1) + keep = length > 0 + if not keep.any(): return None, None - _vec, _len = _vec[_keep], _len[_keep] - _ctr = (_o[:_n][_keep] + _d[:_n][_keep]) / 2.0 + vec, length = vec[keep], length[keep] + centers = (origin[:n_seg][keep] + destination[:n_seg][keep]) / 2.0 # one unit-height template stretched to each segment, merged into a - # single mesh rather than a cylinder primitive per segment - _rr, _tris = self._glyph_template("cylinder", radius=float(radius), height=1.0) - _pts, _faces = self._tile( - _rr, - _tris, - _ctr, - rots=self._rots_from_dirs(_vec / _len[:, None]), - axis_scales=_len, + # single mesh rather than a cylinder primitive per segment. This cannot + # go through instanced_mesh: the stretch is along the template's axis + # only, and instanced_mesh scales every instance isotropically. + rr, tris = self._glyph_template( + "cylinder", radius=float(radius), height=1.0, resolution=20 + ) # 20 is _PyVistaRenderer.tube_n_sides, halved on the way in + points, faces = self._tile( + rr, + tris, + centers, + rots=self._rots_from_dirs(vec / length[:, None]), + axis_scales=length, ) - return self._add(_pts, _faces, color, kwargs.get("opacity", 1.0)) + return self._add(points, faces, color, opacity) def quiver3d( self, @@ -401,11 +488,14 @@ def quiver3d( mode="arrow", opacity=1.0, *, + scale_mode="none", + scalars=None, glyph_height=None, glyph_center=None, glyph_resolution=None, glyph_radius=0.15, solid_transform=None, + backface_culling=False, **kwargs, ): """Draw one merged glyph mesh, the way the glyph filter would. @@ -415,51 +505,74 @@ def quiver3d( per point instead is what made ``20_source_alignment`` -- an oct-6 source space, so 8196 glyphs, twice -- exhaust the browser tab. """ - _x, _y, _z = (np.atleast_1d(np.asarray(_q, dtype=float)) for _q in (x, y, z)) - _ctr = np.column_stack([_x, _y, _z]) - _n = len(_ctr) - if not _n: + x, y, z = (np.atleast_1d(np.asarray(q, dtype=float)) for q in (x, y, z)) + centers = np.column_stack([x, y, z]) + n_pos = len(centers) + if not n_pos: return None, None - _s = float(np.asarray(scale).ravel()[0]) if np.size(scale) else 1.0 - _i = np.arange(_n) - _u, _v, _w = (np.atleast_1d(np.asarray(_q, dtype=float)) for _q in (u, v, w)) - _dirs = np.column_stack([_u[_i % len(_u)], _v[_i % len(_v)], _w[_i % len(_w)]]) - _norm = np.linalg.norm(_dirs, axis=1) - _flat = _norm == 0 - _dirs[_flat] = (1.0, 0.0, 0.0) - _norm[_flat] = 1.0 - _dirs = _dirs / _norm[:, None] + factor = float(np.asarray(scale).ravel()[0]) if np.size(scale) else 1.0 + idx = np.arange(n_pos) + u, v, w = (np.atleast_1d(np.asarray(q, dtype=float)) for q in (u, v, w)) + dirs = np.column_stack([u[idx % len(u)], v[idx % len(v)], w[idx % len(w)]]) + norms = np.linalg.norm(dirs, axis=1) + flat = norms == 0 + dirs[flat] = (1.0, 0.0, 0.0) + dirs = dirs / np.where(flat, 1.0, norms)[:, None] + # per-glyph size, matching what _pyvista.py hands the glyph filter: it + # sends "arrow" through _glyph, whose own default scales by the scalars, + # and "2darrow" through _arrow_glyph, which scales by the vector; every + # other mode gets whatever scale_mode asks for. plot_alignment's + # show_axes draws its three axis arrows at 1/3, 2/3 and full length this + # way, so ignoring it would make the coordinate frames wrong. + _check_option("scale_mode", scale_mode, ("none", "scalar", "vector")) + if mode == "arrow": + scale_mode = "scalar" + elif mode == "2darrow": + scale_mode = "vector" + if scale_mode == "scalar": + values = ( + np.ones(n_pos) + if scalars is None + else np.atleast_1d(np.asarray(scalars, dtype=float)).ravel() + ) + sizes = factor * values[idx % len(values)] + elif scale_mode == "vector": + sizes = factor * norms + else: + sizes = factor # the same templates _pyvista.py feeds the filter; `scale` then plays # the part its `factor` does if mode == "oct": # vtkPlatonicSolidSource puts its octahedron on the unit # circumsphere, and the MRI fiducials get their real size from # solid_transform (mri_fid_scale, 5 mm) rather than from `scale` - _kind, _tkw = "oct", dict(radius=1.0) + kind, template_kw = "oct", dict(radius=1.0) elif mode == "sphere": - _kind, _tkw = "sphere", dict(radius=0.5) + kind, template_kw = "sphere", dict(radius=0.5) elif mode == "cylinder": - _kind = "cylinder" - _tkw = dict( + kind = "cylinder" + template_kw = dict( radius=glyph_radius, height=glyph_height, center=glyph_center, resolution=glyph_resolution, ) - else: # arrow / cone / 2darrow - _kind = "cone" - _tkw = dict( + elif mode == "cone": + kind = "cone" + template_kw = dict( radius=glyph_radius, height=glyph_height, resolution=glyph_resolution ) - _rr, _tris = self._glyph_template(_kind, **_tkw) + else: # arrow / 2darrow, both of which vtk draws with a shaft and a tip + kind, template_kw = "arrow", dict() + rr, tris = self._glyph_template(kind, **template_kw) if solid_transform is not None: # _pyvista.py transforms the template before glyphing, and this is # where the fiducial markers get their size and 45 deg roll - _st = np.asarray(solid_transform, dtype=float) - _rr = _rr @ _st[:3, :3].T + _st[:3, 3] - _rots = None if mode in ("sphere", "oct") else self._rots_from_dirs(_dirs) - _pts, _faces = self._tile(_rr, _tris, _ctr, scales=_s, rots=_rots) - return self._add(_pts, _faces, color, opacity) + solid_transform = np.asarray(solid_transform, dtype=float) + rr = rr @ solid_transform[:3, :3].T + solid_transform[:3, 3] + rots = None if mode in ("sphere", "oct") else self._rots_from_dirs(dirs) + points, faces = self._tile(rr, tris, centers, scales=sizes, rots=rots) + return self._add(points, faces, color, opacity) def instanced_mesh( self, @@ -470,6 +583,8 @@ def instanced_mesh( colors=None, scales=None, opacity=1.0, + backface_culling=False, + name=None, *args, **kwargs, ): @@ -481,97 +596,177 @@ def instanced_mesh( color they asked for and each group becomes one mesh -- a handful of actors for a sensor array instead of one per sensor. """ - _pos = np.atleast_2d(np.asarray(positions, dtype=float))[:, :3] - _n = len(_pos) - if not _n: + positions = np.atleast_2d(np.asarray(positions, dtype=float))[:, :3] + n_pos = len(positions) + if not n_pos: return None, None - _rot = None + rots = None if quats is not None: - _rot = np.asarray( + rots = np.asarray( quat_to_rot(np.atleast_2d(np.asarray(quats, dtype=float))), dtype=float ) - _idx = np.arange(_n) + idx = np.arange(n_pos) if colors is not None and np.ndim(colors) > 1: - _ca = np.asarray(colors) - _uniq, _inv = np.unique(_ca[_idx % len(_ca)], axis=0, return_inverse=True) - _inv = np.asarray(_inv).ravel() - _groups = [(_uniq[_k], _idx[_inv == _k]) for _k in range(len(_uniq))] + colors = np.asarray(colors) + uniq, inverse = np.unique( + colors[idx % len(colors)], axis=0, return_inverse=True + ) + inverse = np.asarray(inverse).ravel() + groups = [(uniq[k], idx[inverse == k]) for k in range(len(uniq))] else: - _groups = [(colors, _idx)] - _out = (None, None) - for _col, _sel in _groups: - _sc = None + groups = [(colors, idx)] + out = (None, None) + for color, sel in groups: + group_scales = None if scales is not None: - _sa = np.atleast_1d(np.asarray(scales, dtype=float)) - _sc = _sa[_sel % len(_sa)] - _rt = None if _rot is None else _rot[_sel % len(_rot)] - _pts, _faces = self._tile(rr, tris, _pos[_sel], scales=_sc, rots=_rt) - _out = self._add(_pts, _faces, _col, opacity) - return _out - - # -- things the static docs do not need --------------------------------- - def contour(self, *args, **kwargs): - # pyvista-js 0.15 has no scalar contouring; callers unpack a pair - return None, None + group_scales = np.atleast_1d(np.asarray(scales, dtype=float)) + group_scales = group_scales[sel % len(group_scales)] + group_rots = None if rots is None else rots[sel % len(rots)] + points, faces = self._tile( + rr, tris, positions[sel], scales=group_scales, rots=group_rots + ) + out = self._add(points, faces, color, opacity) + return out - def text2d(self, *args, **kwargs): - return None + def text2d( + self, + x_window, + y_window, + text, + size=14, + color="white", + justification=None, + font_file=None, + ): + """Draw text over the scene, in normalized window coordinates.""" + if justification is not None or font_file is not None: + raise NotImplementedError( + "Justified text and custom fonts are not supported in the " + "browser: vtk.js draws text at a point, in the page's font." + ) + return _lite_add_text(self.plotter, text, (x_window, y_window), size, color) - def text3d(self, *args, **kwargs): + # -- nothing to do in a browser ----------------------------------------- + # These are reached while drawing the figures the docs render, and there is + # genuinely nothing for them to do here, so each says why rather than + # raising and taking a working page down with it. + def set_interaction(self, *args, **kwargs): + # plot_alignment sets this unconditionally (mne/viz/_3d.py); vtk.js + # ships one trackball style and no way to swap it return None - def scalarbar(self, *args, **kwargs): + def _update(self, *args, **kwargs): + # plot_alignment calls this to force a repaint of a window already up; + # the browser paints from the JS side, after the cell has finished return None - def legend(self, *args, **kwargs): + def _window_close_connect(self, *args, **kwargs): + # mne/viz/ui_events.py asks to be told when the window closes, and a + # canvas in an output cell has no close event to connect to return None - def subplot(self, *args, **kwargs): + def text3d(self, *args, **kwargs): + # plot_alignment(show_channel_names=True) labels each sensor, which + # needs a follow-the-camera 3D text actor. pyvista-js 0.15 has only + # Text, positioned in normalized window coordinates, and projecting the + # sensor positions into those is exactly what `project` cannot do, so + # the sensors are drawn without their labels. return None - def set_interaction(self, *args, **kwargs): + def close(self): + _lite_release_plotter(self.plotter) return None - def remove_mesh(self, *args, **kwargs): - return None + # -- things pyvista-js cannot do ---------------------------------------- + def contour(self, *args, **kwargs): + # PolyData.contour can extract the isolines -- it marches triangles in + # JS at render time -- but with no lookup table they would all draw in + # one color, and plot_evoked_field asks for a single set spanning + # -vmax to +vmax. Drawing a field map whose positive and negative lines + # look identical is worse than not drawing it. + raise NotImplementedError( + "Drawing contours is not supported in the browser: every line would " + "come out the same color, which for a field map is misleading." + ) - def project(self, xyz, ch_names): + def scalarbar(self, *args, **kwargs): + # Plotter.add_scalar_bar records the request on the Python side, but + # nothing reaches the scene: the vtk.js template draws no scalar bar, + # and there is no colormap for one to label anyway raise NotImplementedError( - "Projecting 3D positions onto the scene is not supported in the " - "browser: it has to return a _Projection built from the render " - "window, and pyvista-js does not expose one." + "Drawing a scalar bar is not supported in the browser: vtk.js draws " + "no scalar bar and nothing here is colored by scalars." ) - def screenshot(self, mode="rgb", filename=None, **kwargs): + def legend(self, *args, **kwargs): + # only mne coreg asks for one, and pyvista-js has no add_legend raise NotImplementedError( - "Taking a screenshot is not supported in the browser: vtk.js draws " - "to a live canvas and pyvista-js cannot read it back as an array." + "Drawing a legend is not supported in the browser: pyvista-js does " + "not have one." ) - def close(self): - return None + def subplot(self, *args, **kwargs): + # a pyvista-js Plotter renders a single vtk.js view into one canvas + raise NotImplementedError( + "Subplots are not supported in the browser: a scene is one canvas." + ) - def _update(self, *args, **kwargs): - return None + def remove_mesh(self, *args, **kwargs): + # add_mesh hands back a dict describing the actor rather than a handle + # the plotter can look up again + raise NotImplementedError( + "Removing a mesh is not supported in the browser: pyvista-js draws " + "the scene as a whole and cannot take one mesh back out of it." + ) def _process_events(self, *args, **kwargs): - return None + # the kernel and the canvas are different threads here, and the Pyodide + # worker cannot drive the page's event loop + raise NotImplementedError( + "Draining the event loop is not supported in the browser: the page " + "runs the loop, not the kernel." + ) + + def _window_set_cursor(self, *args, **kwargs): + # pyvista-js has no window object to set a cursor on + raise NotImplementedError( + "Setting the cursor is not supported in the browser: the scene is a " + "canvas in an output cell, not a window." + ) def _enable_time_interaction(self, *args, **kwargs): - # the figures are static here; there is no time slider to wire up - return None + # inheriting renderer._TimeInteraction would not help: it builds the + # slider out of _dock_add_slider and the rest of the dock and toolbar + # API, none of which is implemented here. The _Ipy* mixins that do + # implement it live in _notebook.py, which imports _pyvista at module + # level and so cannot be imported at all without VTK. + raise NotImplementedError( + "The time slider is not supported in the browser: it needs dock " + "widgets, which this backend does not draw." + ) - def _window_close_connect(self, *args, **kwargs): - return None + def project(self, xyz, ch_names): + # a _Projection needs the render window's coordinate transform, and + # pyvista-js does not expose a render window + raise NotImplementedError( + "Projecting 3D positions onto the scene is not supported in the browser." + ) - def _window_set_cursor(self, *args, **kwargs): - return None + def screenshot(self, mode="rgb", filename=None, **kwargs): + # Plotter.screenshot does exist, but it renders the scene by driving a + # headless browser with Playwright from outside the page, which is not + # something the page can do to itself + raise NotImplementedError( + "Taking a screenshot is not supported in the browser: vtk.js draws " + "to a live canvas that cannot be read back as an array." + ) + # -- camera and scene --------------------------------------------------- def get_camera(self, *args, **kwargs): # Same order as _get_3d_view: roll, distance, azimuth, elevation and # then the focalpoint. Brain unpacks positions 3 and 4 as the angles, # so the focalpoint has to be the last element rather than the fourth. - return (0.0, 1.0, 0.0, 0.0, np.zeros(3)) + return _lite_get_view(self.plotter) def set_camera( self, @@ -583,6 +778,9 @@ def set_camera( *args, **kwargs, ): + # distance, focalpoint and roll go unused: vtk.js frames the scene with + # resetCamera() on this path, which is what lets it handle the + # distance=None that MNE asks for far more often. See _lite_get_view. return _lite_set_view(self.plotter, azimuth, elevation) @property @@ -600,101 +798,53 @@ def scene(self): return self.plotter def show(self): - try: - self.plotter.show() - except Exception as _e: - print("[JupyterLite] pyvista-js render failed: " + repr(_e)) + self.plotter.show() return None -def _lite_get_renderer(*args, **kwargs): - return _LiteRenderer(*args, **kwargs) +# -- the module surface renderer.py expects of a 3D backend ----------------- +# set_3d_view, set_3d_title and the close_* helpers reach for these on the +# ``renderer.backend`` global rather than going through _get_renderer, and the +# figure they hand over is the pyvista-js plotter _LiteRenderer.scene returns. +_Renderer = _LiteRenderer +# nothing here draws differently under test, the way an on-screen window does +_testing_context = nullcontext -class _LiteBackend: - """Stand-in for the module MNE imports into ``renderer.backend``. - ``set_3d_view``, ``set_3d_title`` and the ``close_*`` helpers are module-level - functions that reach for that global directly instead of going through - ``_get_renderer``, so replacing the factory alone leaves them calling into - ``None``. The figure they are handed is the pyvista-js plotter that - ``_LiteRenderer.scene`` returns. - """ +def _set_3d_view( + figure, azimuth=None, elevation=None, focalpoint=None, distance=None, roll=None +): + return _lite_set_view(figure, azimuth, elevation) - def _set_3d_view( - self, - figure, - azimuth=None, - elevation=None, - focalpoint=None, - distance=None, - roll=None, - ): - return _lite_set_view(figure, azimuth, elevation) - - def _set_3d_title( - self, figure, title, size=40, color="white", position="upper_left" - ): - return None - - def _clear_3d_figure(self, figure): - # close=False is already the "give the geometry back but keep the - # scene" path, which is what clearing means - _lite_release_plotter(figure, close=False) - return None - - def _close_3d_figure(self, figure): - _lite_release_plotter(figure) - return None - - def _close_all(self): - # the registry holds weak references, so deref before releasing -- - # handing the ref itself to _lite_release_plotter matches nothing and - # never shortens the list - while _lite_live_plotters: - _p = _lite_live_plotters[-1]() - if _p is None: - _lite_live_plotters.pop() - else: - _lite_release_plotter(_p) - return None +def _set_3d_title(figure, title, size=16, *, color="white", position="upper_left"): + if isinstance(position, str): + _check_option("position", position, sorted(_TITLE_POSITIONS)) + position = _TITLE_POSITIONS[position] + return _lite_add_text(figure, title, position, size, color) -_LITE_SAVED = {} +def _clear_3d_figure(figure): + # close=False is already the "give the geometry back but keep the scene" + # path, which is what clearing means + _lite_release_plotter(figure, close=False) + return None -def _activate(): - """Install this renderer as the one MNE draws with. - - Replaces the ``_get_renderer`` factory and the ``renderer.backend`` global - that ``set_3d_view``, ``set_3d_title`` and the ``close_*`` helpers read - directly, so that patching the factory alone does not leave them calling - into ``None``. Returns the previous state, which :func:`_deactivate` puts - back. - """ - from . import renderer as _mne_rend - - if not _LITE_SAVED: - _LITE_SAVED.update( - _get_renderer=_mne_rend._get_renderer, - backend=_mne_rend.backend, - MNE_3D_BACKEND=_mne_rend.MNE_3D_BACKEND, - ) - _mne_rend._get_renderer = _lite_get_renderer - _mne_rend.backend = _LiteBackend() - # Naming a backend keeps _get_3d_backend() from walking VALID_3D_BACKENDS and - # importing _qt, which would overwrite the stub above on its way to failing. - _mne_rend.MNE_3D_BACKEND = "notebook" - return dict(_LITE_SAVED) +def _close_3d_figure(figure): + _lite_release_plotter(figure) + return None -def _deactivate(): - """Undo :func:`_activate`, restoring whatever MNE was drawing with before.""" - if not _LITE_SAVED: - return None - from . import renderer as _mne_rend - for _name, _value in _LITE_SAVED.items(): - setattr(_mne_rend, _name, _value) - _LITE_SAVED.clear() +def _close_all(): + # the registry holds weak references, so deref before releasing -- handing + # the ref itself to _lite_release_plotter matches nothing and never + # shortens the list + while _lite_live_plotters: + plotter = _lite_live_plotters[-1]() + if plotter is None: + _lite_live_plotters.pop() + else: + _lite_release_plotter(plotter) return None diff --git a/mne/viz/backends/_utils.py b/mne/viz/backends/_utils.py index 561447105bf..0ca779415c8 100644 --- a/mne/viz/backends/_utils.py +++ b/mne/viz/backends/_utils.py @@ -29,7 +29,13 @@ VALID_3D_BACKENDS = ( "pyvistaqt", # default 3d backend "notebook", + "jupyterlite", ) +# 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 +# displays inside a browser kernel, so picking it on a desktop that happens to +# have pyvista-js installed would quietly produce figures nothing can show. +_AUTO_3D_BACKENDS = ("pyvistaqt", "notebook") ALLOWED_QUIVER_MODES = ("2darrow", "arrow", "cone", "cylinder", "sphere", "oct") _ICONS_PATH = Path(__file__).parents[2] / "icons" diff --git a/mne/viz/backends/renderer.py b/mne/viz/backends/renderer.py index ed9261bbd76..7af340e7356 100644 --- a/mne/viz/backends/renderer.py +++ b/mne/viz/backends/renderer.py @@ -22,7 +22,7 @@ ) from .._3d import _get_3d_option from ..utils import safe_event -from ._utils import VALID_3D_BACKENDS +from ._utils import _AUTO_3D_BACKENDS, VALID_3D_BACKENDS MNE_3D_BACKEND = None MNE_3D_BACKEND_TESTING = False @@ -31,6 +31,7 @@ _backend_name_map = dict( pyvistaqt="._qt", notebook="._notebook", + jupyterlite="._lite", ) backend = None @@ -71,7 +72,7 @@ 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'`` and ``'notebook'``). + backend (``'pyvistaqt'``, ``'notebook'`` and ``'jupyterlite'``). .. versionchanged:: 0.24 The ``'pyvista'`` backend was renamed ``'pyvistaqt'``. @@ -87,50 +88,55 @@ 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`` backend draws with vtk.js rather than VTK, which has + no WebAssembly build. It is what the documentation's browser notebooks + run on; on a desktop the other two are better in every way, so it is + never selected automatically. + This table shows the capabilities of each backend ("✓" for full support, and "-" for partial support): .. table:: :widths: auto - +--------------------------------------+-----------+----------+ - | **3D function:** | pyvistaqt | notebook | - +======================================+===========+==========+ - | :func:`plot_vector_source_estimates` | ✓ | ✓ | - +--------------------------------------+-----------+----------+ - | :func:`plot_source_estimates` | ✓ | ✓ | - +--------------------------------------+-----------+----------+ - | :func:`plot_alignment` | ✓ | ✓ | - +--------------------------------------+-----------+----------+ - | :func:`plot_sparse_source_estimates` | ✓ | ✓ | - +--------------------------------------+-----------+----------+ - | :func:`plot_evoked_field` | ✓ | ✓ | - +--------------------------------------+-----------+----------+ - | :func:`snapshot_brain_montage` | ✓ | ✓ | - +--------------------------------------+-----------+----------+ - | :func:`link_brains` | ✓ | | - +--------------------------------------+-----------+----------+ - +--------------------------------------+-----------+----------+ - | **Feature:** | - +--------------------------------------+-----------+----------+ - | Large data | ✓ | ✓ | - +--------------------------------------+-----------+----------+ - | Opacity/transparency | ✓ | ✓ | - +--------------------------------------+-----------+----------+ - | Support geometric glyph | ✓ | ✓ | - +--------------------------------------+-----------+----------+ - | Smooth shading | ✓ | ✓ | - +--------------------------------------+-----------+----------+ - | Subplotting | ✓ | ✓ | - +--------------------------------------+-----------+----------+ - | Inline plot in Jupyter Notebook | | ✓ | - +--------------------------------------+-----------+----------+ - | Inline plot in JupyterLab | | ✓ | - +--------------------------------------+-----------+----------+ - | Inline plot in Google Colab | | | - +--------------------------------------+-----------+----------+ - | Toolbar | ✓ | ✓ | - +--------------------------------------+-----------+----------+ + +--------------------------------------+-----------+----------+-------------+ + | **3D function:** | pyvistaqt | notebook | jupyterlite | + +======================================+===========+==========+=============+ + | :func:`plot_vector_source_estimates` | ✓ | ✓ | | + +--------------------------------------+-----------+----------+-------------+ + | :func:`plot_source_estimates` | ✓ | ✓ | | + +--------------------------------------+-----------+----------+-------------+ + | :func:`plot_alignment` | ✓ | ✓ | - | + +--------------------------------------+-----------+----------+-------------+ + | :func:`plot_sparse_source_estimates` | ✓ | ✓ | ✓ | + +--------------------------------------+-----------+----------+-------------+ + | :func:`plot_evoked_field` | ✓ | ✓ | | + +--------------------------------------+-----------+----------+-------------+ + | :func:`snapshot_brain_montage` | ✓ | ✓ | | + +--------------------------------------+-----------+----------+-------------+ + | :func:`link_brains` | ✓ | | | + +--------------------------------------+-----------+----------+-------------+ + +--------------------------------------+-----------+----------+-------------+ + | **Feature:** | + +--------------------------------------+-----------+----------+-------------+ + | Large data | ✓ | ✓ | ✓ | + +--------------------------------------+-----------+----------+-------------+ + | Opacity/transparency | ✓ | ✓ | ✓ | + +--------------------------------------+-----------+----------+-------------+ + | Support geometric glyph | ✓ | ✓ | ✓ | + +--------------------------------------+-----------+----------+-------------+ + | Smooth shading | ✓ | ✓ | ✓ | + +--------------------------------------+-----------+----------+-------------+ + | Subplotting | ✓ | ✓ | | + +--------------------------------------+-----------+----------+-------------+ + | Inline plot in Jupyter Notebook | | ✓ | ✓ | + +--------------------------------------+-----------+----------+-------------+ + | Inline plot in JupyterLab | | ✓ | ✓ | + +--------------------------------------+-----------+----------+-------------+ + | Inline plot in Google Colab | | | | + +--------------------------------------+-----------+----------+-------------+ + | Toolbar | ✓ | ✓ | | + +--------------------------------------+-----------+----------+-------------+ """ global MNE_3D_BACKEND old_backend_name = MNE_3D_BACKEND @@ -169,7 +175,7 @@ def _get_3d_backend(): MNE_3D_BACKEND = get_config(key="MNE_3D_BACKEND", default=None) if MNE_3D_BACKEND is None: # try them in order errors = dict() - for name in VALID_3D_BACKENDS: + for name in _AUTO_3D_BACKENDS: try: _reload_backend(name) except ImportError as exc: @@ -210,7 +216,7 @@ def use_3d_backend(backend_name): # numpydoc ignore=YD01 Parameters ---------- - backend_name : {'pyvistaqt', 'notebook'} + backend_name : {'pyvistaqt', 'notebook', 'jupyterlite'} The 3d backend to use in the context. """ old_backend = set_3d_backend(backend_name) diff --git a/mne/viz/backends/tests/test_lite.py b/mne/viz/backends/tests/test_lite.py index 6429dbb7862..d31ae0f1ec7 100644 --- a/mne/viz/backends/tests/test_lite.py +++ b/mne/viz/backends/tests/test_lite.py @@ -2,6 +2,7 @@ # License: BSD-3-Clause # Copyright the MNE-Python contributors. +import ast import subprocess import sys from pathlib import Path @@ -9,43 +10,54 @@ import numpy as np import pytest -pytest.importorskip("pyvista_js") # _lite imports it at module level - -from mne.viz.backends._abstract import _AbstractRenderer # noqa: E402 -from mne.viz.backends._lite import ( # noqa: E402 - _LITE_MAX_LIVE_SCENES, - _activate, - _deactivate, - _lite_live_plotters, - _lite_set_view, - _LiteBackend, - _LiteRenderer, -) +from mne.viz.backends._abstract import _AbstractRenderer + +# imported this way rather than with a plain import so that the whole file +# skips without pyvista-js, which _lite needs at module level +_lite = pytest.importorskip("mne.viz.backends._lite") # a unit square, split into two triangles -_RR = np.array([[0.0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0]]) +_RR = np.array([[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0]], dtype=float) _TRIS = np.array([[0, 1, 2], [0, 2, 3]]) -@pytest.fixture -def lite_scene(): - """Skip without pyvista-js, and give each test a clean live-scene registry.""" - pytest.importorskip("pyvista_js") - _lite_live_plotters.clear() - yield - _lite_live_plotters.clear() - _deactivate() +def test_is_a_registered_backend(renderer_lite): + """``set_3d_backend("jupyterlite")`` must hand out this renderer.""" + assert renderer_lite.get_3d_backend() == "jupyterlite" + assert renderer_lite.backend._Renderer is _lite._LiteRenderer + assert isinstance(renderer_lite._get_renderer(size=(200, 200)), _lite._LiteRenderer) + + +def test_module_covers_everything_renderer_calls(): + """``_lite`` must define every helper ``renderer.py`` reaches for. + + These are module-level functions that read the ``renderer.backend`` global + directly rather than going through ``_get_renderer``, so a new one added + upstream breaks the browser silently. ``clear_3d_figure`` did exactly that. + """ + from mne.viz.backends import renderer + + src = Path(renderer.__file__).read_text() + needed = { + node.attr + for node in ast.walk(ast.parse(src)) + if isinstance(node, ast.Attribute) + and isinstance(node.value, ast.Name) + and node.value.id == "backend" + } + missing = sorted(name for name in needed if not hasattr(_lite, name)) + assert not missing, f"mne.viz.backends._lite is missing {missing}" def test_implements_abstract_renderer(): """The lite renderer must satisfy the full _AbstractRenderer contract. - This is the test that matters when someone adds a method to the abstract - renderer: without it the browser build keeps importing and only fails at - the point a tutorial tries to draw. + ``_AbstractRenderer`` declares its API with ``@abstractmethod``, so leaving + one out makes ``_LiteRenderer(...)`` raise ``TypeError`` the first time a + notebook draws. Assert the class is instantiable instead of waiting for it. """ - assert issubclass(_LiteRenderer, _AbstractRenderer) - assert not _LiteRenderer.__abstractmethods__ + assert issubclass(_lite._LiteRenderer, _AbstractRenderer) + assert not _lite._LiteRenderer.__abstractmethods__ def test_kind_is_its_own(): @@ -55,26 +67,24 @@ def test_kind_is_its_own(): VTK, no filesystem and no OS threads, so it must not be mistaken for the notebook backend that does. """ - assert _LiteRenderer._kind == "jupyterlite_notebook" - # the two desktop backends, which this must not be confused with - assert _LiteRenderer._kind not in ("notebook", "qt") + assert _lite._LiteRenderer._kind == "jupyterlite_notebook" def test_import_is_side_effect_free(): - """Importing the module must not pull in VTK or touch the drawing factory. + """Importing the module must not pull in VTK or pick a backend. - The browser kernel has no VTK at all, and ``mne.viz.backends.renderer`` - has to keep its own factory until :func:`_activate` is called. Run in a - subprocess because by this point in a full test session another backend - has usually already imported VTK. + The browser kernel has no VTK at all, and ``mne.viz.backends.renderer`` has + to keep drawing with whatever it was until something asks for this one. Run + in a subprocess because by this point in a full test session another + backend has usually already imported VTK. """ code = ( "import sys\n" - "import mne.viz.backends._lite # noqa: F401\n" + "import mne.viz.backends._lite\n" "from mne.viz.backends import renderer\n" "assert 'vtk' not in sys.modules, 'importing _lite pulled in vtk'\n" "assert 'vtkmodules' not in sys.modules, 'importing _lite pulled in vtk'\n" - "assert renderer._get_renderer.__module__.endswith('renderer')\n" + "assert renderer.MNE_3D_BACKEND is None\n" "print('ok')\n" ) out = subprocess.run( @@ -86,28 +96,57 @@ def test_import_is_side_effect_free(): @pytest.mark.parametrize( "azimuth, elevation", - [(0, None), (90, None), (180, None), (270, None), (45, 30), (None, 2), (None, 90)], + [ + (None, None), + (0, None), + (90, None), + (180, None), + (270, None), + (45, 30), + (None, 2), + (None, 90), + ], ) -def test_set_view(azimuth, elevation, lite_scene): +def test_set_view_reaches_the_camera(azimuth, elevation, renderer_lite): """Every azimuth/elevation pair must reach the camera, poles included.""" - r = _LiteRenderer(size=(200, 200)) - # 2 and 90 degrees sit either side of the 5/175 view-up flip - assert _lite_set_view(r.plotter, azimuth, elevation) is None + r = renderer_lite._get_renderer(size=(200, 200)) + r.set_camera(azimuth=azimuth, elevation=elevation) + roll, distance, got_azimuth, got_elevation, focalpoint = r.get_camera() + assert np.asarray(focalpoint).shape == (3,) + if azimuth is None and elevation is None: + # no angle to point at, so the camera is left for vtk.js to frame + assert (roll, distance, got_azimuth, got_elevation) == (0.0, 1.0, 0.0, 0.0) + return + # one angle given means "leave the other alone", which _lite_set_view + # spells 90; 2 and 90 degrees sit either side of the 5/175 view-up flip + assert got_azimuth == pytest.approx(90.0 if azimuth is None else azimuth % 360) + assert got_elevation == pytest.approx( + 90.0 if elevation is None else elevation % 180 + ) + assert (roll, distance) == (0.0, 1.0) -def test_set_view_without_angles_is_a_no_op(lite_scene): - """No azimuth and no elevation means leave the camera alone.""" - r = _LiteRenderer(size=(200, 200)) - assert _lite_set_view(r.plotter, None, None) is None +def test_get_camera_matches_the_expected_order(renderer_lite): + """``get_camera`` must unpack the way ``_get_3d_view`` does. -def test_draws_every_primitive(lite_scene): + ``Brain`` reads it as ``_, _, azimuth, elevation, _``, so the focalpoint + has to be last; putting it fourth hands ``Brain`` a tuple for an angle. + """ + r = renderer_lite._get_renderer(size=(200, 200)) + roll, distance, azimuth, elevation, focalpoint = r.get_camera() + for angle in (roll, distance, azimuth, elevation): + assert isinstance(angle, float) + assert np.asarray(focalpoint).shape == (3,) + + +def test_draws_every_primitive(renderer_lite): """Each drawing primitive must add exactly one actor to the scene.""" - r = _LiteRenderer(size=(200, 200), bgcolor="white") + r = renderer_lite._get_renderer(size=(200, 200), bgcolor="white") assert len(r.plotter.actors) == 0 r.mesh(_RR[:, 0], _RR[:, 1], _RR[:, 2], _TRIS, color="red", opacity=0.5) - r.surface(dict(rr=_RR, tris=_TRIS), color="blue") + r.surface(dict(rr=_RR, tris=_TRIS), color="#0000ff") r.sphere(np.array([[0.0, 0, 0]]), "green", 0.1) r.tube([[0.0, 0, 0]], [[1.0, 1, 1]], radius=0.01, color="black") r.quiver3d( @@ -117,165 +156,180 @@ def test_draws_every_primitive(lite_scene): np.r_[1.0], np.r_[0.0], np.r_[0.0], - color="orange", + color=(1.0, 0.5, 0.0), scale=0.1, mode="arrow", ) assert len(r.plotter.actors) == 5 -def test_draws_into_an_existing_figure(lite_scene): - """``fig=`` composites into a scene rather than opening a second one. +def test_glyphs_scale_by_their_scalars(renderer_lite): + """``mode="arrow"`` must size each glyph by its scalar, as the filter does. - ``plot_alignment`` passes it positionally and ``create_3d_figure`` by name, - so both spellings have to land on the same plotter. + ``plot_alignment(show_axes=True)`` draws a coordinate frame as three arrows + with ``scalars=[0.33, 0.66, 1.0]``, so ignoring them gives three + equal-length arrows and a wrong-looking frame. """ - first = _LiteRenderer(size=(200, 200)) - by_name = _LiteRenderer(fig=first.plotter) - by_position = _LiteRenderer(first.plotter) - assert by_name.plotter is first.plotter - assert by_position.plotter is first.plotter - - by_name.sphere(np.array([[0.0, 0, 0]]), "red", 1.0) - assert len(first.plotter.actors) == 1 + xyz = np.zeros(3) + uvw = np.eye(3) + _, mesh = renderer_lite._get_renderer(size=(200, 200)).quiver3d( + *xyz[:, None].repeat(3, 1), + *uvw, + mode="arrow", + scale=2e-2, + color="red", + scale_mode="scalar", + scalars=[0.33, 0.66, 1.0], + ) + # one copy of the template per glyph, in order + lengths = np.linalg.norm(np.asarray(mesh.points).reshape(3, -1, 3), axis=2).max( + axis=1 + ) + assert lengths / lengths.max() == pytest.approx([0.33, 0.66, 1.0]) -def test_live_scenes_are_capped(lite_scene): - """Old scenes are released, so a notebook cannot run the tab out of memory. +def test_arrow_template_matches_vtk(renderer_lite): + """``mode="arrow"`` must be a shaft plus a tip, not a bare cone. - Every live scene holds its meshes in the WASM heap, a copy in JS and a set - of GPU buffers, and nothing in a notebook calls ``close_3d_figure``. + ``_pyvista.py`` glyphs it with ``vtkArrowSource``, whose defaults put a + 0.03-radius shaft under a 0.1-radius tip starting at 0.65, over a total + length of 1. """ - kept = [_LiteRenderer() for _ in range(_LITE_MAX_LIVE_SCENES + 3)] - assert len(_lite_live_plotters) == _LITE_MAX_LIVE_SCENES - # the survivors are the most recent ones - live = [ref() for ref in _lite_live_plotters] - assert live == [r.plotter for r in kept[-_LITE_MAX_LIVE_SCENES:]] + rr, _ = renderer_lite._get_renderer(size=(200, 200))._glyph_template("arrow") + x = rr[:, 0] + radius = np.linalg.norm(rr[:, 1:], axis=1) + assert (x.min(), x.max()) == pytest.approx((0.0, 1.0)) + assert radius[x < 0.6].max() == pytest.approx(0.03) + assert radius[x > 0.6].max() == pytest.approx(0.1) + assert x[radius > 0.05].min() == pytest.approx(0.65) -def test_get_camera_matches_the_expected_order(lite_scene): - """``get_camera`` must unpack the way ``_get_3d_view`` does. +def test_sphere_radius_matches_pyvista(renderer_lite): + """``scale`` sizes a radius-0.5 template, so the drawn radius is half of it. - ``Brain`` reads it as ``_, _, azimuth, elevation, _``, so the focalpoint - has to be last; putting it fourth hands ``Brain`` a tuple for an angle. + ``_pyvista.py`` glyphs ``pyvista.Sphere(radius=0.5)`` by ``scale``; taking + ``scale`` as the radius draws every dig point and fiducial twice too big, + and no caller in ``_3d.py`` passes ``radius`` to say otherwise. """ - roll, distance, azimuth, elevation, focalpoint = _LiteRenderer( - size=(200, 200) - ).get_camera() - for angle in (roll, distance, azimuth, elevation): - assert isinstance(angle, float) - assert np.asarray(focalpoint).shape == (3,) + r = renderer_lite._get_renderer(size=(200, 200)) + _, mesh = r.sphere(np.zeros((1, 3)), "red", 0.01) + assert np.linalg.norm(np.asarray(mesh.points), axis=1).max() == pytest.approx(0.005) + # an explicit radius is used as-is, again matching _pyvista.py + _, mesh = r.sphere(np.zeros((1, 3)), "red", 1.0, radius=0.02) + assert np.linalg.norm(np.asarray(mesh.points), axis=1).max() == pytest.approx(0.02) -@pytest.mark.parametrize("method, args", [("project", ({}, [])), ("screenshot", ())]) -def test_unsupported_methods_say_so(method, args, lite_scene): - """Things pyvista-js cannot do must raise, not hand back a plausible stub. +def test_cylinder_center_is_turned_with_the_axis(renderer_lite): + """``center`` arrives in ``_cylinder_geom``'s pre-rotation frame. - ``project`` used to return an array where callers expect a ``_Projection`` - and would fail a line later on ``.visible()``; ``screenshot`` used to - return a 2x2 black image. + That helper builds the cylinder along y and turns it 90 degrees about z, so + ``(cx, cy, cz)`` lands at ``(-cy, cx, cz)``. ``_3d.py`` gives the EEG + electrode offset that way, and skipping the turn stands the cylinders + beside their sensors instead of on them. """ - r = _LiteRenderer(size=(200, 200)) - with pytest.raises(NotImplementedError, match="browser"): - getattr(r, method)(*args) - - -def test_activate_and_deactivate_round_trip(lite_scene): - """Activation swaps the factory and the backend global, and undoes itself.""" - from mne.viz.backends import renderer + rr, _ = renderer_lite._get_renderer(size=(200, 200))._glyph_template( + "cylinder", radius=0.5, height=3.0, center=(0.0, -0.75, 0.0), resolution=16 + ) + # the offset lands on the axis, not across it + assert rr.min(axis=0) == pytest.approx([-0.75, -0.5, -0.5]) + assert rr.max(axis=0) == pytest.approx([2.25, 0.5, 0.5]) + + +def test_instances_are_merged_per_color(renderer_lite): + """``instanced_mesh`` draws one actor per distinct color, not one per instance.""" + r = renderer_lite._get_renderer(size=(200, 200)) + colors = np.array([[1.0, 0, 0], [0, 1.0, 0], [1.0, 0, 0]]) + r.instanced_mesh( + _RR, + _TRIS, + np.zeros((3, 3)), + quats=np.tile([1.0, 0, 0, 0], (3, 1)), + colors=colors, + ) + assert len(r.plotter.actors) == 2 - before = (renderer._get_renderer, renderer.backend, renderer.MNE_3D_BACKEND) - _activate() - assert renderer._get_renderer(size=(100, 100)).__class__ is _LiteRenderer - assert isinstance(renderer.backend, _LiteBackend) - # a named backend stops _get_3d_backend() walking VALID_3D_BACKENDS and - # importing _qt, which would undo the line above on its way to failing - assert renderer.MNE_3D_BACKEND is not None +def test_draws_into_an_existing_figure(renderer_lite): + """``fig=`` composites into a scene rather than opening a second one.""" + first = renderer_lite._get_renderer(size=(200, 200)) + second = renderer_lite._get_renderer(fig=first.plotter) + assert second.plotter is first.plotter - _deactivate() - assert (renderer._get_renderer, renderer.backend, renderer.MNE_3D_BACKEND) == before + second.sphere(np.array([[0.0, 0, 0]]), "red", 1.0) + assert len(first.plotter.actors) == 1 -def test_activate_is_idempotent(lite_scene): - """Activating twice must still restore the original state once.""" - from mne.viz.backends import renderer +def test_live_scenes_are_capped(renderer_lite): + """Old scenes are released, so a notebook cannot run the tab out of memory. - before = renderer._get_renderer - _activate() - _activate() - _deactivate() - assert renderer._get_renderer is before + Every live scene holds its meshes in the WASM heap, a copy in JS and a set + of GPU buffers, and nothing in a notebook calls ``close_3d_figure``. + """ + kept = [ + renderer_lite._get_renderer() for _ in range(_lite._LITE_MAX_LIVE_SCENES + 3) + ] + assert len(_lite._lite_live_plotters) == _lite._LITE_MAX_LIVE_SCENES + # the survivors are the most recent ones + live = [ref() for ref in _lite._lite_live_plotters] + assert live == [r.plotter for r in kept[-_lite._LITE_MAX_LIVE_SCENES :]] -def test_backend_covers_everything_renderer_calls(): - """``_LiteBackend`` must implement every helper ``renderer.py`` reaches for. +@pytest.mark.parametrize( + "method, args", + [ + ("project", ({}, [])), + ("screenshot", ()), + ("contour", ()), + ("scalarbar", ()), + ("legend", ()), + ("subplot", ()), + ("remove_mesh", ()), + ("_process_events", ()), + ("_window_set_cursor", ()), + ("_enable_time_interaction", ()), + ], +) +def test_unsupported_methods_raise(method, args, renderer_lite): + """Things pyvista-js cannot do must raise, not hand back a plausible stub. - These are module-level functions that read ``renderer.backend`` directly - rather than going through ``_get_renderer``, so a new one added upstream - breaks the browser silently. ``clear_3d_figure`` did exactly that. + ``project`` used to return an array where callers expect a ``_Projection`` + and would fail a line later on ``.visible()``; ``screenshot`` used to + return a 2x2 black image. """ - import ast + r = renderer_lite._get_renderer(size=(200, 200)) + with pytest.raises(NotImplementedError, match="browser"): + getattr(r, method)(*args) - from mne.viz.backends import renderer - src = Path(renderer.__file__).read_text() - needed = { - n.attr - for n in ast.walk(ast.parse(src)) - if isinstance(n, ast.Attribute) - and isinstance(n.value, ast.Name) - and n.value.id == "backend" - } - # _Renderer is the factory and _testing_context is test-only scaffolding - needed -= {"_Renderer", "_testing_context"} - missing = sorted(m for m in needed if not hasattr(_LiteBackend, m)) - assert not missing, f"_LiteBackend is missing {missing}" +def test_text_is_drawn(renderer_lite): + """Text lands on the canvas with the size and color that was asked for.""" + r = renderer_lite._get_renderer(size=(200, 200)) + text = r.text2d(0.1, 0.9, "hello", size=12, color="red") + assert (text.input, text.position) == ("hello", (0.1, 0.9)) + assert (text.prop.font_size, text.prop.color) == (12, (1.0, 0.0, 0.0)) + title = renderer_lite.set_3d_title(figure=r.scene(), title="a title", size=20) + assert title.input == "a title" + # justification and a font file are the two things vtk.js cannot honour + with pytest.raises(NotImplementedError, match="browser"): + r.text2d(0.1, 0.9, "hello", justification="center") -def test_clear_keeps_the_scene(lite_scene): + +def test_clear_keeps_the_scene(renderer_lite): """Clearing drops the geometry but leaves the scene open to draw into.""" - r = _LiteRenderer(size=(200, 200)) + r = renderer_lite._get_renderer(size=(200, 200)) r.sphere(np.array([[0.0, 0, 0]]), "red", 1.0) assert len(r.plotter.actors) == 1 - _LiteBackend()._clear_3d_figure(r.plotter) + renderer_lite.clear_3d_figure(r.scene()) assert len(r.plotter.actors) == 0 - # still usable, unlike after _close_3d_figure + # still usable, unlike after close_3d_figure r.sphere(np.array([[1.0, 0, 0]]), "blue", 1.0) assert len(r.plotter.actors) == 1 -def test_backend_scene_helpers(lite_scene): - """The scene-level helpers MNE calls on ``renderer.backend`` all work. - - ``set_3d_view`` and the ``close_*`` helpers read that global directly - instead of going through ``_get_renderer``, so a renderer alone is not - enough. - """ - backend = _LiteBackend() - r = _LiteRenderer() - r.sphere(np.array([[0.0, 0, 0]]), "red", 1.0) - - assert backend._set_3d_view(r.plotter, azimuth=90) is None - assert backend._set_3d_title(r.plotter, "ignored") is None - - backend._close_3d_figure(r.plotter) - assert len(r.plotter.actors) == 0 - assert _lite_live_plotters == [] - - -def test_close_all_releases_every_scene(lite_scene): - """``close_all`` must drain the registry, not spin on dead references.""" - scenes = [_LiteRenderer() for _ in range(_LITE_MAX_LIVE_SCENES)] - assert _lite_live_plotters - _LiteBackend()._close_all() - assert _lite_live_plotters == [] - assert all(len(s.plotter.actors) == 0 for s in scenes) - - -def test_public_helpers_route_through_the_backend(lite_scene): - """``mne.viz.set_3d_view`` and friends must work once activated. +def test_public_helpers_route_through_the_backend(renderer_lite): + """``mne.viz.set_3d_view`` and friends must work once the backend is set. These are the calls the tutorials actually make. They read ``renderer.backend`` directly rather than going through ``_get_renderer``, @@ -283,17 +337,27 @@ def test_public_helpers_route_through_the_backend(lite_scene): """ from mne.viz import close_all_3d_figures, set_3d_view - _activate() - r = _LiteRenderer(size=(200, 200)) + r = renderer_lite._get_renderer(size=(200, 200)) r.sphere(np.array([[0.0, 0, 0]]), "red", 1.0) set_3d_view(r.scene(), azimuth=90, elevation=45) + assert r.get_camera()[2:4] == pytest.approx((90.0, 45.0)) + close_all_3d_figures() assert len(r.plotter.actors) == 0 - assert _lite_live_plotters == [] + assert _lite._lite_live_plotters == [] -def test_renders_in_a_notebook_kernel(nbexec, lite_scene): +def test_close_all_releases_every_scene(renderer_lite): + """``close_all`` must drain the registry, not spin on dead references.""" + scenes = [renderer_lite._get_renderer() for _ in range(_lite._LITE_MAX_LIVE_SCENES)] + assert _lite._lite_live_plotters + renderer_lite.backend._close_all() + assert _lite._lite_live_plotters == [] + assert all(len(s.plotter.actors) == 0 for s in scenes) + + +def test_renders_in_a_notebook_kernel(nbexec): """Draw through MNE's own factory inside a live Jupyter kernel. Everything above drives the renderer in-process. This goes through @@ -304,20 +368,16 @@ def test_renders_in_a_notebook_kernel(nbexec, lite_scene): import numpy as np from mne.viz.backends import renderer - from mne.viz.backends._lite import _activate, _deactivate - - _activate() - try: - assert renderer._get_renderer.__name__ == "_lite_get_renderer" - r = renderer._get_renderer(size=(200, 200), bgcolor="white") - assert type(r).__name__ == "_LiteRenderer" - - rr = np.array([[0.0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0]]) - tris = np.array([[0, 1, 2], [0, 2, 3]]) - r.mesh(rr[:, 0], rr[:, 1], rr[:, 2], tris, color="red") - assert len(r.plotter.actors) == 1 - - html = r.plotter.generate_standalone_html() - assert " Date: Tue, 25 Aug 2026 17:55:41 -0400 Subject: [PATCH 09/15] MAINT: rename the browser backend to jupyterlite_notebook Matches what the review asked for and lines the backend name up with _kind. The capability table goes back to upstream's three columns, with the browser backend described in the notes instead. --- doc/changes/dev/14144.other.rst | 2 +- doc/sphinxext/jupyterlite_lite_renderer.py | 4 +- mne/conftest.py | 4 +- mne/viz/backends/_lite.py | 2 +- mne/viz/backends/_utils.py | 2 +- mne/viz/backends/renderer.py | 95 ++++++++++++---------- mne/viz/backends/tests/test_lite.py | 8 +- mne/viz/backends/tests/test_renderer.py | 2 +- 8 files changed, 62 insertions(+), 57 deletions(-) diff --git a/doc/changes/dev/14144.other.rst b/doc/changes/dev/14144.other.rst index 434936fe032..dce99387f96 100644 --- a/doc/changes/dev/14144.other.rst +++ b/doc/changes/dev/14144.other.rst @@ -1 +1 @@ -Add a vtk.js drawing backend for MNE's 3D renderer, selected with ``mne.viz.set_3d_backend("jupyterlite")`` and used by the JupyterLite documentation where VTK cannot load, by `Natneal B`_. +Add a vtk.js drawing backend for MNE's 3D renderer, selected with ``mne.viz.set_3d_backend("jupyterlite_notebook")`` and used by the JupyterLite documentation where VTK cannot load, by `Natneal B`_. diff --git a/doc/sphinxext/jupyterlite_lite_renderer.py b/doc/sphinxext/jupyterlite_lite_renderer.py index c57c315cdef..76584ec1ec3 100644 --- a/doc/sphinxext/jupyterlite_lite_renderer.py +++ b/doc/sphinxext/jupyterlite_lite_renderer.py @@ -12,12 +12,12 @@ # Copyright the MNE-Python contributors. LITE_RENDERER_CELL = """ -# Draw MNE's 3D figures with pyvista-js (vtk.js): VTK has no WebAssembly build. +# Using pyvista-js (vtk.js) to draw MNE's 3D rendering in JupyterLite. # See mne/viz/backends/_lite.py for more details. try: import mne.viz - mne.viz.set_3d_backend("jupyterlite") + mne.viz.set_3d_backend("jupyterlite_notebook") except Exception as _e: print("[JupyterLite] could not select the pyvista-js renderer: " + repr(_e)) """ diff --git a/mne/conftest.py b/mne/conftest.py index cb301c6d898..e979abfdb22 100644 --- a/mne/conftest.py +++ b/mne/conftest.py @@ -755,7 +755,7 @@ def renderer_notebook(request, options_3d): yield renderer -@pytest.fixture(params=[pytest.param("jupyterlite", marks=pytest.mark.pvtest)]) +@pytest.fixture(params=[pytest.param("jupyterlite_notebook", marks=pytest.mark.pvtest)]) def renderer_lite(request, options_3d): """Yield the JupyterLite (vtk.js) renderer.""" from mne.viz.backends import renderer as renderer_module @@ -814,7 +814,7 @@ def _use_backend(backend_name, interactive): def _check_skip_backend(name): from mne.viz.backends._utils import _notebook_vtk_works - if name == "jupyterlite": + if name == "jupyterlite_notebook": # draws with vtk.js in a browser: no VTK, no Qt, no ffmpeg pytest.importorskip("pyvista_js") return diff --git a/mne/viz/backends/_lite.py b/mne/viz/backends/_lite.py index 4dbb3b83b4c..4a68e51cb2c 100644 --- a/mne/viz/backends/_lite.py +++ b/mne/viz/backends/_lite.py @@ -11,7 +11,7 @@ Rather than reimplement those functions one by one, this module supplies a renderer that draws with `pyvista-js `__ (vtk.js). It is a 3D backend like ``_qt`` and ``_notebook`` are, selected with -``mne.viz.set_3d_backend("jupyterlite")``, which is all a browser kernel has to +``mne.viz.set_3d_backend("jupyterlite_notebook")``, which is all a browser kernel has to do. MNE keeps doing all of the transform math itself, which matters because getting a head/MRI/device transform subtly wrong produces a plausible-looking picture with the sensors in the wrong place. diff --git a/mne/viz/backends/_utils.py b/mne/viz/backends/_utils.py index 0ca779415c8..6bb740abc99 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", + "jupyterlite_notebook", ) # 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 7af340e7356..c3acdcab528 100644 --- a/mne/viz/backends/renderer.py +++ b/mne/viz/backends/renderer.py @@ -31,7 +31,7 @@ _backend_name_map = dict( pyvistaqt="._qt", notebook="._notebook", - jupyterlite="._lite", + jupyterlite_notebook="._lite", ) backend = None @@ -72,7 +72,8 @@ 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'``). + backend (``'pyvistaqt'``, ``'notebook'`` and + ``'jupyterlite_notebook'``). .. versionchanged:: 0.24 The ``'pyvista'`` backend was renamed ``'pyvistaqt'``. @@ -88,10 +89,14 @@ 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`` backend draws with vtk.js rather than VTK, which has - no WebAssembly build. It is what the documentation's browser notebooks - run on; on a desktop the other two are better in every way, so it is - never selected automatically. + The ``jupyterlite_notebook`` 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, while + :class:`mne.viz.Brain`, :func:`plot_evoked_field` and + :func:`snapshot_brain_montage` do not. On a desktop the other two are better + in every way, so it is never selected automatically. This table shows the capabilities of each backend ("✓" for full support, and "-" for partial support): @@ -99,44 +104,44 @@ def set_3d_backend(backend_name, verbose=None): .. table:: :widths: auto - +--------------------------------------+-----------+----------+-------------+ - | **3D function:** | pyvistaqt | notebook | jupyterlite | - +======================================+===========+==========+=============+ - | :func:`plot_vector_source_estimates` | ✓ | ✓ | | - +--------------------------------------+-----------+----------+-------------+ - | :func:`plot_source_estimates` | ✓ | ✓ | | - +--------------------------------------+-----------+----------+-------------+ - | :func:`plot_alignment` | ✓ | ✓ | - | - +--------------------------------------+-----------+----------+-------------+ - | :func:`plot_sparse_source_estimates` | ✓ | ✓ | ✓ | - +--------------------------------------+-----------+----------+-------------+ - | :func:`plot_evoked_field` | ✓ | ✓ | | - +--------------------------------------+-----------+----------+-------------+ - | :func:`snapshot_brain_montage` | ✓ | ✓ | | - +--------------------------------------+-----------+----------+-------------+ - | :func:`link_brains` | ✓ | | | - +--------------------------------------+-----------+----------+-------------+ - +--------------------------------------+-----------+----------+-------------+ - | **Feature:** | - +--------------------------------------+-----------+----------+-------------+ - | Large data | ✓ | ✓ | ✓ | - +--------------------------------------+-----------+----------+-------------+ - | Opacity/transparency | ✓ | ✓ | ✓ | - +--------------------------------------+-----------+----------+-------------+ - | Support geometric glyph | ✓ | ✓ | ✓ | - +--------------------------------------+-----------+----------+-------------+ - | Smooth shading | ✓ | ✓ | ✓ | - +--------------------------------------+-----------+----------+-------------+ - | Subplotting | ✓ | ✓ | | - +--------------------------------------+-----------+----------+-------------+ - | Inline plot in Jupyter Notebook | | ✓ | ✓ | - +--------------------------------------+-----------+----------+-------------+ - | Inline plot in JupyterLab | | ✓ | ✓ | - +--------------------------------------+-----------+----------+-------------+ - | Inline plot in Google Colab | | | | - +--------------------------------------+-----------+----------+-------------+ - | Toolbar | ✓ | ✓ | | - +--------------------------------------+-----------+----------+-------------+ + +--------------------------------------+-----------+----------+ + | **3D function:** | pyvistaqt | notebook | + +======================================+===========+==========+ + | :func:`plot_vector_source_estimates` | ✓ | ✓ | + +--------------------------------------+-----------+----------+ + | :func:`plot_source_estimates` | ✓ | ✓ | + +--------------------------------------+-----------+----------+ + | :func:`plot_alignment` | ✓ | ✓ | + +--------------------------------------+-----------+----------+ + | :func:`plot_sparse_source_estimates` | ✓ | ✓ | + +--------------------------------------+-----------+----------+ + | :func:`plot_evoked_field` | ✓ | ✓ | + +--------------------------------------+-----------+----------+ + | :func:`snapshot_brain_montage` | ✓ | ✓ | + +--------------------------------------+-----------+----------+ + | :func:`link_brains` | ✓ | | + +--------------------------------------+-----------+----------+ + +--------------------------------------+-----------+----------+ + | **Feature:** | + +--------------------------------------+-----------+----------+ + | Large data | ✓ | ✓ | + +--------------------------------------+-----------+----------+ + | Opacity/transparency | ✓ | ✓ | + +--------------------------------------+-----------+----------+ + | Support geometric glyph | ✓ | ✓ | + +--------------------------------------+-----------+----------+ + | Smooth shading | ✓ | ✓ | + +--------------------------------------+-----------+----------+ + | Subplotting | ✓ | ✓ | + +--------------------------------------+-----------+----------+ + | Inline plot in Jupyter Notebook | | ✓ | + +--------------------------------------+-----------+----------+ + | Inline plot in JupyterLab | | ✓ | + +--------------------------------------+-----------+----------+ + | Inline plot in Google Colab | | | + +--------------------------------------+-----------+----------+ + | Toolbar | ✓ | ✓ | + +--------------------------------------+-----------+----------+ """ global MNE_3D_BACKEND old_backend_name = MNE_3D_BACKEND @@ -216,7 +221,7 @@ def use_3d_backend(backend_name): # numpydoc ignore=YD01 Parameters ---------- - backend_name : {'pyvistaqt', 'notebook', 'jupyterlite'} + backend_name : {'pyvistaqt', 'notebook', 'jupyterlite_notebook'} The 3d backend to use in the context. """ old_backend = set_3d_backend(backend_name) diff --git a/mne/viz/backends/tests/test_lite.py b/mne/viz/backends/tests/test_lite.py index d31ae0f1ec7..2ce489c81a3 100644 --- a/mne/viz/backends/tests/test_lite.py +++ b/mne/viz/backends/tests/test_lite.py @@ -22,8 +22,8 @@ def test_is_a_registered_backend(renderer_lite): - """``set_3d_backend("jupyterlite")`` must hand out this renderer.""" - assert renderer_lite.get_3d_backend() == "jupyterlite" + """``set_3d_backend("jupyterlite_notebook")`` must hand out this renderer.""" + assert renderer_lite.get_3d_backend() == "jupyterlite_notebook" assert renderer_lite.backend._Renderer is _lite._LiteRenderer assert isinstance(renderer_lite._get_renderer(size=(200, 200)), _lite._LiteRenderer) @@ -369,8 +369,8 @@ def test_renders_in_a_notebook_kernel(nbexec): from mne.viz.backends import renderer - renderer.set_3d_backend("jupyterlite") - assert renderer.get_3d_backend() == "jupyterlite" + renderer.set_3d_backend("jupyterlite_notebook") + assert renderer.get_3d_backend() == "jupyterlite_notebook" r = renderer._get_renderer(size=(200, 200), bgcolor="white") assert type(r).__name__ == "_LiteRenderer" diff --git a/mne/viz/backends/tests/test_renderer.py b/mne/viz/backends/tests/test_renderer.py index 64d2db5264a..6c23c12158e 100644 --- a/mne/viz/backends/tests/test_renderer.py +++ b/mne/viz/backends/tests/test_renderer.py @@ -283,7 +283,7 @@ 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'" + match = "Allowed values are 'pyvistaqt', 'notebook', and 'jupyterlite_notebook'" with pytest.raises(ValueError, match=match): set_3d_backend("invalid") From c08cff3f39d225c42ad683c6cad0f2d4bbf4c3e0 Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Tue, 25 Aug 2026 18:24:11 -0400 Subject: [PATCH 10/15] MAINT: return every actor from the browser instanced_mesh It was handing back only the last color group and dropping the rest. --- mne/viz/backends/_lite.py | 17 +++++++++++---- mne/viz/backends/tests/test_lite.py | 32 +++++++++++++++++++++-------- 2 files changed, 37 insertions(+), 12 deletions(-) diff --git a/mne/viz/backends/_lite.py b/mne/viz/backends/_lite.py index 4a68e51cb2c..9eeee15bf5e 100644 --- a/mne/viz/backends/_lite.py +++ b/mne/viz/backends/_lite.py @@ -594,7 +594,9 @@ def instanced_mesh( cylinders) point the way MNE intended rather than all along +x. pyvista-js has no per-vertex color, so instances are grouped by the color they asked for and each group becomes one mesh -- a handful of - actors for a sensor array instead of one per sensor. + actors for a sensor array instead of one per sensor. That means one + distinct color returns ``(actor, mesh)`` like ``_PyVistaRenderer`` + does, and several return the lists of both. """ positions = np.atleast_2d(np.asarray(positions, dtype=float))[:, :3] n_pos = len(positions) @@ -615,7 +617,7 @@ def instanced_mesh( groups = [(uniq[k], idx[inverse == k]) for k in range(len(uniq))] else: groups = [(colors, idx)] - out = (None, None) + actors, meshes = list(), list() for color, sel in groups: group_scales = None if scales is not None: @@ -625,8 +627,15 @@ def instanced_mesh( points, faces = self._tile( rr, tris, positions[sel], scales=group_scales, rots=group_rots ) - out = self._add(points, faces, color, opacity) - return out + actor, mesh = self._add(points, faces, color, opacity) + actors.append(actor) + meshes.append(mesh) + # one group is the common case and matches _PyVistaRenderer, which + # colors per instance inside a single actor; hand back the pair then, + # and the whole set when the colors had to be split across meshes + if len(actors) == 1: + return actors[0], meshes[0] + return actors, meshes def text2d( self, diff --git a/mne/viz/backends/tests/test_lite.py b/mne/viz/backends/tests/test_lite.py index 2ce489c81a3..b9e496d337e 100644 --- a/mne/viz/backends/tests/test_lite.py +++ b/mne/viz/backends/tests/test_lite.py @@ -236,17 +236,33 @@ def test_cylinder_center_is_turned_with_the_axis(renderer_lite): def test_instances_are_merged_per_color(renderer_lite): - """``instanced_mesh`` draws one actor per distinct color, not one per instance.""" + """``instanced_mesh`` draws one actor per distinct color, not one per instance. + + One color hands back ``(actor, mesh)``, the way ``_PyVistaRenderer`` always + does; several hand back both lists, since vtk.js cannot color per instance + inside a single actor. + """ + quats = np.tile([1.0, 0, 0, 0], (3, 1)) + positions = np.zeros((3, 3)) + + # one color: a single actor, and the pair _PyVistaRenderer also hands back + r = renderer_lite._get_renderer(size=(200, 200)) + colors = np.tile([1.0, 0, 0], (3, 1)) + actor, mesh = r.instanced_mesh(_RR, _TRIS, positions, quats, colors=colors) + assert len(r.plotter.actors) == 1 + assert not isinstance(actor, list) and not isinstance(mesh, list) + + # two colors: one actor each, and both lists come back r = renderer_lite._get_renderer(size=(200, 200)) colors = np.array([[1.0, 0, 0], [0, 1.0, 0], [1.0, 0, 0]]) - r.instanced_mesh( - _RR, - _TRIS, - np.zeros((3, 3)), - quats=np.tile([1.0, 0, 0, 0], (3, 1)), - colors=colors, - ) + actors, meshes = r.instanced_mesh(_RR, _TRIS, positions, quats, colors=colors) assert len(r.plotter.actors) == 2 + assert len(actors) == len(meshes) == 2 + + # sphere routes through instanced_mesh with a single color, so it has to + # keep handing back the pair its own callers unpack + actor, mesh = r.sphere(np.zeros((1, 3)), "red", 0.01) + assert not isinstance(actor, list) and not isinstance(mesh, list) def test_draws_into_an_existing_figure(renderer_lite): From c2ce141b29766ed1c2d23048adc4aa5bdc6fc529 Mon Sep 17 00:00:00 2001 From: natinew77-creator Date: Tue, 25 Aug 2026 18:54:11 -0400 Subject: [PATCH 11/15] MAINT: tighten the browser backend after review Drops a wrong claim about plot_bem, makes the drawing tests assert geometry rather than actor counts, and stops accepting arguments without saying why they cannot be honoured. --- mne/viz/backends/_lite.py | 124 ++++++++++++++++++---------- mne/viz/backends/tests/test_lite.py | 77 +++++++++++++++-- 2 files changed, 152 insertions(+), 49 deletions(-) diff --git a/mne/viz/backends/_lite.py b/mne/viz/backends/_lite.py index 9eeee15bf5e..93c4e4327a9 100644 --- a/mne/viz/backends/_lite.py +++ b/mne/viz/backends/_lite.py @@ -1,12 +1,11 @@ """ A pyvista-js drawing backend for MNE's 3D renderer. -MNE's 3D functions (``plot_alignment``, ``plot_bem``, -``plot_sparse_source_estimates``, ``SourceSpaces.plot``, ...) all build their -figure the same way: they do their own geometry and coordinate-frame work in -numpy, then hand the result to a renderer obtained from -:func:`mne.viz.backends.renderer._get_renderer`. Only that last step needs VTK, -and VTK cannot load in WebAssembly. +MNE's 3D functions (``plot_alignment``, ``plot_sparse_source_estimates``, +``SourceSpaces.plot``, ...) all build their figure the same way: they do their +own geometry and coordinate-frame work in numpy, then hand the result to a +renderer obtained from :func:`mne.viz.backends.renderer._get_renderer`. Only +that last step needs VTK, and VTK cannot load in WebAssembly. Rather than reimplement those functions one by one, this module supplies a renderer that draws with `pyvista-js `__ @@ -22,7 +21,9 @@ time slider, and scalar colormaps: ``Plotter.add_mesh`` takes ``scalars`` and ``cmap`` and writes them into the scene, but the vtk.js template it renders through builds no lookup table and never reads them, so a mesh carrying -scalars draws in a solid color. +scalars draws in a solid color. Figure size is fixed too: pyvista-js writes a +600x400 canvas and offers no way to change it, so the ``size`` MNE asks for has +no effect. Importing this module needs pyvista-js, the same way importing ``_pyvista`` needs VTK. @@ -65,6 +66,26 @@ _DEFAULT_COLOR = (0.5, 0.5, 0.5) +def _lite_n_side(resolution): + """Return the side count to build a cone or cylinder with. + + Callers give the side count VTK would use; take half of it, because + ``_tile`` stamps the template at every sensor and the side count multiplies + straight into the WASM heap, and eight sides is smooth enough at the size + these draw. Three is the fewest that still closes a ring, and eight is the + default for callers that name no resolution at all. + """ + return 8 if resolution is None else max(3, int(resolution) // 2) + + +def _lite_ring(n_side, radius): + """Return one ``n_side`` circle of radius ``radius``, in the x=0 plane.""" + angles = np.linspace(0.0, 2 * np.pi, n_side, endpoint=False) + return np.column_stack( + [np.zeros(n_side), radius * np.cos(angles), radius * np.sin(angles)] + ) + + def _rgb(color): """Return an (r, g, b) 0-1 tuple, the only color form pyvista-js takes. @@ -135,15 +156,17 @@ def _lite_get_view(plotter): _lite_live_plotters = [] -def _lite_release_plotter(plotter, close=True): +def _lite_release_plotter(plotter): """Hand back a plotter's meshes, JS arrays and GPU buffers. ``clear()`` empties the actor list, which is where the geometry is held, - so that is what frees the memory. ``close=False`` additionally says not to - tear the render window down -- what trimming an older scene wants, since - the notebook has already drawn it. pyvista-js 0.15 implements neither - ``deep_clean`` nor ``close``, so today the two paths do the same thing; - the flag keeps the intent right if that changes. + so that is what frees the memory, and it is the only teardown pyvista-js + 0.15 offers: there is no ``close()`` to tear the render window down as + well, which is why closing a figure and clearing one do the same thing + here. + + Collecting is left to the caller, so draining a whole registry sweeps once + rather than once per scene. """ if plotter is None: return None @@ -151,14 +174,7 @@ def _lite_release_plotter(plotter, close=True): live = _lite_live_plotters[idx]() if live is None or live is plotter: del _lite_live_plotters[idx] - # pyvista-js is someone else's surface, so use whichever teardown of these - # it actually implements - names = ("clear", "deep_clean", "close") if close else ("clear", "deep_clean") - for name in names: - teardown = getattr(plotter, name, None) - if teardown is not None: - teardown() - gc.collect() + plotter.clear() return None @@ -173,13 +189,17 @@ def _lite_release_plotter(plotter, close=True): def _lite_trim_live_plotters(): """Release everything but the most recent scenes.""" + trimmed = False while len(_lite_live_plotters) > _LITE_MAX_LIVE_SCENES: oldest = _lite_live_plotters[0]() if oldest is None: _lite_live_plotters.pop(0) else: # also drops it from the registry, so this terminates - _lite_release_plotter(oldest, close=False) + _lite_release_plotter(oldest) + trimmed = True + if trimmed: + gc.collect() return None @@ -195,6 +215,10 @@ class _LiteRenderer(_AbstractRenderer): _kind = "jupyterlite_notebook" def __init__(self, fig=None, size=(600, 600), bgcolor="black", **kwargs): + # `size` is named to match _PyVistaRenderer but cannot be honoured: + # pv.Plotter takes only a lighting mode, and generate_standalone_html + # emits a fixed 600x400 canvas with no knob for it. + # # plot_alignment(fig=...) and plot_dipole_locations(fig=...) composite # into a scene the notebook already made, so draw into that plotter # rather than opening a second one and splitting the picture in two. @@ -207,7 +231,11 @@ def __init__(self, fig=None, size=(600, 600), bgcolor="black", **kwargs): # _LITE_MAX_LIVE_SCENES is the number that actually stays live _lite_trim_live_plotters() self.plotter.background_color = _rgb(bgcolor) - # even lighting, so a surface is not black when rotated + # A scene light in vtk.js lights only what faces it, so a single one + # leaves half of a head dark as soon as it is turned. Six along the axes + # cover every side; each is well under full intensity because a surface + # facing two of them at once would otherwise blow out. The distance only + # has to sit outside the scene, which is metres-scale here. for direction in ( (1, 0, 0), (-1, 0, 0), @@ -272,12 +300,8 @@ def _glyph_template( # pyvista.Cone(center=(0.5, 0, 0)): base at x=0, apex at x=height rad = 0.15 if radius is None else float(radius) hgt = 1.0 if height is None else float(height) - n_side = 8 if not resolution else max(3, int(resolution) // 2) - angles = np.linspace(0.0, 2 * np.pi, n_side, endpoint=False) - ring = np.column_stack( - [np.zeros(n_side), rad * np.cos(angles), rad * np.sin(angles)] - ) - rr = np.vstack([ring, [[hgt, 0, 0]], [[0.0, 0, 0]]]) + n_side = _lite_n_side(resolution) + rr = np.vstack([_lite_ring(n_side, rad), [[hgt, 0, 0]], [[0.0, 0, 0]]]) tris = [] for this in range(n_side): nxt = (this + 1) % n_side @@ -286,10 +310,7 @@ def _glyph_template( # cylinder along +x, matching _cylinder_geom's convention rad = 0.1 if radius is None else float(radius) hgt = 1.0 if height is None else float(height) - # half the sides VTK would use: _tile stamps this template at every - # sensor, so the side count multiplies straight into the WASM heap and - # a 16-sided EEG cylinder is smooth enough at the size it draws - n_side = 8 if not resolution else max(3, int(resolution) // 2) + n_side = _lite_n_side(resolution) # _cylinder_geom builds the cylinder along y and turns it 90 degrees # about z to point it along x, which carries the center round with it: # (cx, cy, cz) lands at (-cy, cx, cz). _3d.py gives the EEG electrode @@ -300,10 +321,7 @@ def _glyph_template( else: center = np.asarray(center, dtype=float) offset = np.array([-center[1], center[0], center[2]]) - angles = np.linspace(0.0, 2 * np.pi, n_side, endpoint=False) - ring = np.column_stack( - [np.zeros(n_side), rad * np.cos(angles), rad * np.sin(angles)] - ) + ring = _lite_ring(n_side, rad) back = ring + np.array([-hgt / 2.0, 0, 0]) front = ring + np.array([hgt / 2.0, 0, 0]) rr = ( @@ -325,6 +343,8 @@ def _add(self, points, tris, color, opacity=1.0): drawing method here funnels through this, so translating it once covers all of them. """ + # float32 halves what the merged glyph meshes cost in the WASM heap, + # and vtk.js uses single precision on the GPU regardless mesh = pv.PolyData( points=np.asarray(points, dtype=np.float32), faces=_vtk_faces(tris) ) @@ -425,6 +445,11 @@ def sphere( radius=None, **kwargs, ): + # `resolution` has no equivalent here: _pyvista.py asks pyvista.Sphere + # for that many theta and phi bands, while this template comes from a + # subdivided octahedron, whose vertex count goes 6, 18, 66, 258. Level 3 + # is the one that lands near the default 8x8 sphere, and nothing in + # mne/viz asks for another, so it is fixed rather than approximated. center = np.atleast_2d(np.asarray(center, dtype=float)) if not len(center): return None, None @@ -510,7 +535,8 @@ def quiver3d( n_pos = len(centers) if not n_pos: return None, None - factor = float(np.asarray(scale).ravel()[0]) if np.size(scale) else 1.0 + # MNE always passes a scalar here; VTK's SetScaleFactor takes one too + factor = float(scale) idx = np.arange(n_pos) u, v, w = (np.atleast_1d(np.asarray(q, dtype=float)) for q in (u, v, w)) dirs = np.column_stack([u[idx % len(u)], v[idx % len(v)], w[idx % len(w)]]) @@ -562,7 +588,11 @@ def quiver3d( template_kw = dict( radius=glyph_radius, height=glyph_height, resolution=glyph_resolution ) - else: # arrow / 2darrow, both of which vtk draws with a shaft and a tip + else: + # "arrow" is vtkArrowSource, a shaft with a cone tip. "2darrow" is + # really vtkGlyphSource2D with FilledOff, a flat outline; vtk.js has + # no 2D glyph source, so it borrows the 3D arrow. Only Brain asks + # for it, and Brain does not run here. kind, template_kw = "arrow", dict() rr, tris = self._glyph_template(kind, **template_kw) if solid_transform is not None: @@ -570,6 +600,10 @@ def quiver3d( # where the fiducial markers get their size and 45 deg roll solid_transform = np.asarray(solid_transform, dtype=float) rr = rr @ solid_transform[:3, :3].T + solid_transform[:3, 3] + # a sphere looks the same however it is turned, so skip the rotation + # rather than build N matrices for it. "oct" joins it because the only + # caller (the MRI fiducials) points every glyph along +x, which is the + # identity; a future caller pointing them elsewhere would need this back rots = None if mode in ("sphere", "oct") else self._rots_from_dirs(dirs) points, faces = self._tile(rr, tris, centers, scales=sizes, rots=rots) return self._add(points, faces, color, opacity) @@ -824,6 +858,9 @@ def show(self): def _set_3d_view( figure, azimuth=None, elevation=None, focalpoint=None, distance=None, roll=None ): + # distance, focalpoint and roll go unused here for the same reason they do + # in _LiteRenderer.set_camera: vtk.js frames the scene with resetCamera() + # on this path. See _lite_get_view. return _lite_set_view(figure, azimuth, elevation) @@ -835,14 +872,16 @@ def _set_3d_title(figure, title, size=16, *, color="white", position="upper_left def _clear_3d_figure(figure): - # close=False is already the "give the geometry back but keep the scene" - # path, which is what clearing means - _lite_release_plotter(figure, close=False) + _lite_release_plotter(figure) + gc.collect() return None def _close_3d_figure(figure): + # the same as clearing: vtk.js draws into a canvas in an output cell, so + # there is no window left to close once the geometry is gone _lite_release_plotter(figure) + gc.collect() return None @@ -856,4 +895,5 @@ def _close_all(): _lite_live_plotters.pop() else: _lite_release_plotter(plotter) + gc.collect() # once for the whole registry, not once per scene return None diff --git a/mne/viz/backends/tests/test_lite.py b/mne/viz/backends/tests/test_lite.py index b9e496d337e..1f7d87b6b66 100644 --- a/mne/viz/backends/tests/test_lite.py +++ b/mne/viz/backends/tests/test_lite.py @@ -9,6 +9,7 @@ import numpy as np import pytest +from numpy.testing import assert_allclose from mne.viz.backends._abstract import _AbstractRenderer @@ -141,28 +142,79 @@ def test_get_camera_matches_the_expected_order(renderer_lite): def test_draws_every_primitive(renderer_lite): - """Each drawing primitive must add exactly one actor to the scene.""" + """Every primitive must add one actor holding the geometry it was asked for. + + Counting actors alone would pass on empty or misplaced meshes, so each + check below pins where the mesh actually landed. + """ r = renderer_lite._get_renderer(size=(200, 200), bgcolor="white") assert len(r.plotter.actors) == 0 - r.mesh(_RR[:, 0], _RR[:, 1], _RR[:, 2], _TRIS, color="red", opacity=0.5) - r.surface(dict(rr=_RR, tris=_TRIS), color="#0000ff") - r.sphere(np.array([[0.0, 0, 0]]), "green", 0.1) - r.tube([[0.0, 0, 0]], [[1.0, 1, 1]], radius=0.01, color="black") - r.quiver3d( + # a flat unit square, drawn as given + _, mesh = r.mesh(_RR[:, 0], _RR[:, 1], _RR[:, 2], _TRIS, color="red", opacity=0.5) + assert_allclose(np.asarray(mesh.points), _RR, atol=1e-6) + + # the same square, reached through the surface dict + _, mesh = r.surface(dict(rr=_RR, tris=_TRIS), color="#0000ff") + assert_allclose(np.asarray(mesh.points), _RR, atol=1e-6) + + # scale 0.1 means radius 0.05, centered where it was asked for + _, mesh = r.sphere(np.array([[1.0, 0, 0]]), "green", 0.1) + points = np.asarray(mesh.points) + assert_allclose(points.mean(axis=0), [1, 0, 0], atol=1e-6) + assert np.linalg.norm(points - [1, 0, 0], axis=1).max() == pytest.approx(0.05) + + # a tube spans origin to destination, no further + _, mesh = r.tube([[0.0, 0, 0]], [[0.0, 0, 1.0]], radius=0.01, color="black") + points = np.asarray(mesh.points) + assert points[:, 2].min() == pytest.approx(0.0) + assert points[:, 2].max() == pytest.approx(1.0) + assert np.linalg.norm(points[:, :2], axis=1).max() == pytest.approx(0.01) + + # an arrow of length `scale` pointing the way it was given + _, mesh = r.quiver3d( np.r_[0.0], np.r_[0.0], np.r_[0.0], - np.r_[1.0], np.r_[0.0], + np.r_[1.0], np.r_[0.0], color=(1.0, 0.5, 0.0), scale=0.1, mode="arrow", ) + points = np.asarray(mesh.points) + assert points[:, 1].max() == pytest.approx(0.1) # along +y, at `scale` + # and no wider than its own tip, which is 0.1 of the scaled length + assert np.linalg.norm(points[:, [0, 2]], axis=1).max() <= 0.01 + 1e-9 + assert len(r.plotter.actors) == 5 +def test_tube_stretches_each_segment_on_its_own(renderer_lite): + """``tube`` scales along the template axis alone, per segment. + + That is the one place ``_tile`` scales anisotropically, and getting it + wrong would fatten the tubes as they lengthen. + """ + r = renderer_lite._get_renderer(size=(200, 200)) + _, mesh = r.tube( + [[0.0, 0, 0], [0.0, 0, 0]], # one 1 m segment and one 2 m segment + [[1.0, 0, 0], [0.0, 2.0, 0]], + radius=0.01, + color="black", + ) + points = np.asarray(mesh.points) + assert points[:, 0].max() == pytest.approx(1.0) + assert points[:, 1].max() == pytest.approx(2.0) + + # neither got thicker for being longer: the two segments are stamped in + # order, so split them and measure each one away from its own axis + first, second = points.reshape(2, -1, 3) + assert np.linalg.norm(first[:, 1:], axis=1).max() == pytest.approx(0.01) + assert np.linalg.norm(second[:, [0, 2]], axis=1).max() == pytest.approx(0.01) + + def test_glyphs_scale_by_their_scalars(renderer_lite): """``mode="arrow"`` must size each glyph by its scalar, as the filter does. @@ -381,6 +433,8 @@ def test_renders_in_a_notebook_kernel(nbexec): takes, and checks the scene serialises to the vtk.js HTML the browser consumes. The body below is executed by that kernel rather than here. """ + import json + import numpy as np from mne.viz.backends import renderer @@ -395,5 +449,14 @@ def test_renders_in_a_notebook_kernel(nbexec): r.mesh(rr[:, 0], rr[:, 1], rr[:, 2], tris, color="red") assert len(r.plotter.actors) == 1 + # the html must carry this mesh, not merely be a vtk.js page: an empty + # scene still ships the script tag, so look for the points themselves html = r.plotter.generate_standalone_html() assert " Date: Wed, 26 Aug 2026 15:00:23 -0400 Subject: [PATCH 12/15] FIX: hand back an instance cloud from instanced_mesh gh-13074 made mne/viz/_3d.py write channel names onto the second value instanced_mesh returns, which broke plot_alignment here three ways: a pyvista-js PolyData has no field_data, several colours came back as a list, and empty positions came back as None. Return the per-instance point cloud instead, always one object with the mapping attached, which is what _PyVistaRenderer returns. --- mne/viz/backends/_lite.py | 44 ++++++++++++----- mne/viz/backends/tests/test_lite.py | 73 ++++++++++++++++++++++------- 2 files changed, 88 insertions(+), 29 deletions(-) diff --git a/mne/viz/backends/_lite.py b/mne/viz/backends/_lite.py index 93c4e4327a9..4a8609ce4da 100644 --- a/mne/viz/backends/_lite.py +++ b/mne/viz/backends/_lite.py @@ -156,6 +156,22 @@ def _lite_get_view(plotter): _lite_live_plotters = [] +def _lite_instance_cloud(positions): + """Return the per-instance point cloud ``instanced_mesh`` hands back. + + ``_PyVistaRenderer`` glyphs its template over a ``PolyData`` of the instance + positions and returns that object, and :mod:`mne.viz._3d` hangs channel + names off its ``field_data`` (gh-13074). vtk.js has no equivalent, and a + pyvista-js ``PolyData`` carries no ``field_data`` of its own, so build the + same cloud here and give it the mapping. Nothing in the browser reads it + back: the one reader is the dipole-fit GUI, which needs a picker vtk.js + does not provide. + """ + cloud = pv.PolyData(points=np.asarray(positions, dtype=float).reshape(-1, 3)) + cloud.field_data = dict() + return cloud + + def _lite_release_plotter(plotter): """Hand back a plotter's meshes, JS arrays and GPU buffers. @@ -628,14 +644,18 @@ def instanced_mesh( cylinders) point the way MNE intended rather than all along +x. pyvista-js has no per-vertex color, so instances are grouped by the color they asked for and each group becomes one mesh -- a handful of - actors for a sensor array instead of one per sensor. That means one - distinct color returns ``(actor, mesh)`` like ``_PyVistaRenderer`` - does, and several return the lists of both. + actors for a sensor array instead of one per sensor. One distinct color + hands back that single actor, several hand back the list of them. + + The second return value is the per-instance point cloud, always one + object whatever the colors did, because that is what + ``_PyVistaRenderer`` returns and what mne/viz/_3d.py writes channel + names onto. """ positions = np.atleast_2d(np.asarray(positions, dtype=float))[:, :3] n_pos = len(positions) if not n_pos: - return None, None + return None, _lite_instance_cloud(positions) rots = None if quats is not None: rots = np.asarray( @@ -651,7 +671,9 @@ def instanced_mesh( groups = [(uniq[k], idx[inverse == k]) for k in range(len(uniq))] else: groups = [(colors, idx)] - actors, meshes = list(), list() + # only the actors are collected: _add already registers each mesh with + # the plotter, and the object callers want back is the instance cloud + actors = list() for color, sel in groups: group_scales = None if scales is not None: @@ -661,15 +683,15 @@ def instanced_mesh( points, faces = self._tile( rr, tris, positions[sel], scales=group_scales, rots=group_rots ) - actor, mesh = self._add(points, faces, color, opacity) + actor, _ = self._add(points, faces, color, opacity) actors.append(actor) - meshes.append(mesh) # one group is the common case and matches _PyVistaRenderer, which - # colors per instance inside a single actor; hand back the pair then, - # and the whole set when the colors had to be split across meshes + # colors per instance inside a single actor; hand that actor back on + # its own, and the whole set when the colors had to be split + cloud = _lite_instance_cloud(positions) if len(actors) == 1: - return actors[0], meshes[0] - return actors, meshes + return actors[0], cloud + return actors, cloud def text2d( self, diff --git a/mne/viz/backends/tests/test_lite.py b/mne/viz/backends/tests/test_lite.py index 1f7d87b6b66..1867d49ee60 100644 --- a/mne/viz/backends/tests/test_lite.py +++ b/mne/viz/backends/tests/test_lite.py @@ -22,6 +22,16 @@ _TRIS = np.array([[0, 1, 2], [0, 2, 3]]) +def _drawn(renderer): + """Return the geometry of the mesh the renderer drew last. + + sphere() and the other instanced_mesh callers hand back the instance cloud + rather than the drawn geometry, matching _PyVistaRenderer, so read what + actually reached the plotter instead of the return value. + """ + return renderer.plotter.actors[-1]["mesh"] + + def test_is_a_registered_backend(renderer_lite): """``set_3d_backend("jupyterlite_notebook")`` must hand out this renderer.""" assert renderer_lite.get_3d_backend() == "jupyterlite_notebook" @@ -159,8 +169,8 @@ def test_draws_every_primitive(renderer_lite): assert_allclose(np.asarray(mesh.points), _RR, atol=1e-6) # scale 0.1 means radius 0.05, centered where it was asked for - _, mesh = r.sphere(np.array([[1.0, 0, 0]]), "green", 0.1) - points = np.asarray(mesh.points) + r.sphere(np.array([[1.0, 0, 0]]), "green", 0.1) + points = np.asarray(_drawn(r).points) assert_allclose(points.mean(axis=0), [1, 0, 0], atol=1e-6) assert np.linalg.norm(points - [1, 0, 0], axis=1).max() == pytest.approx(0.05) @@ -264,11 +274,13 @@ def test_sphere_radius_matches_pyvista(renderer_lite): and no caller in ``_3d.py`` passes ``radius`` to say otherwise. """ r = renderer_lite._get_renderer(size=(200, 200)) - _, mesh = r.sphere(np.zeros((1, 3)), "red", 0.01) - assert np.linalg.norm(np.asarray(mesh.points), axis=1).max() == pytest.approx(0.005) + r.sphere(np.zeros((1, 3)), "red", 0.01) + drawn = np.asarray(_drawn(r).points) + assert np.linalg.norm(drawn, axis=1).max() == pytest.approx(0.005) # an explicit radius is used as-is, again matching _pyvista.py - _, mesh = r.sphere(np.zeros((1, 3)), "red", 1.0, radius=0.02) - assert np.linalg.norm(np.asarray(mesh.points), axis=1).max() == pytest.approx(0.02) + r.sphere(np.zeros((1, 3)), "red", 1.0, radius=0.02) + drawn = np.asarray(_drawn(r).points) + assert np.linalg.norm(drawn, axis=1).max() == pytest.approx(0.02) def test_cylinder_center_is_turned_with_the_axis(renderer_lite): @@ -290,31 +302,56 @@ def test_cylinder_center_is_turned_with_the_axis(renderer_lite): def test_instances_are_merged_per_color(renderer_lite): """``instanced_mesh`` draws one actor per distinct color, not one per instance. - One color hands back ``(actor, mesh)``, the way ``_PyVistaRenderer`` always - does; several hand back both lists, since vtk.js cannot color per instance - inside a single actor. + vtk.js cannot color per instance inside a single actor, so one color gives + one actor and several give the list of them. The second return value is the + instance cloud either way, matching ``_PyVistaRenderer``. """ quats = np.tile([1.0, 0, 0, 0], (3, 1)) - positions = np.zeros((3, 3)) + positions = np.array([[0.0, 0, 0], [1.0, 0, 0], [2.0, 0, 0]]) - # one color: a single actor, and the pair _PyVistaRenderer also hands back + # one color: a single actor r = renderer_lite._get_renderer(size=(200, 200)) colors = np.tile([1.0, 0, 0], (3, 1)) - actor, mesh = r.instanced_mesh(_RR, _TRIS, positions, quats, colors=colors) + actor, cloud = r.instanced_mesh(_RR, _TRIS, positions, quats, colors=colors) assert len(r.plotter.actors) == 1 - assert not isinstance(actor, list) and not isinstance(mesh, list) + assert not isinstance(actor, list) + assert_allclose(np.asarray(cloud.points), positions, atol=1e-6) - # two colors: one actor each, and both lists come back + # two colors: one actor each, and the cloud is still a single object r = renderer_lite._get_renderer(size=(200, 200)) colors = np.array([[1.0, 0, 0], [0, 1.0, 0], [1.0, 0, 0]]) - actors, meshes = r.instanced_mesh(_RR, _TRIS, positions, quats, colors=colors) + actors, cloud = r.instanced_mesh(_RR, _TRIS, positions, quats, colors=colors) assert len(r.plotter.actors) == 2 - assert len(actors) == len(meshes) == 2 + assert len(actors) == 2 + assert not isinstance(cloud, list) + assert_allclose(np.asarray(cloud.points), positions, atol=1e-6) # sphere routes through instanced_mesh with a single color, so it has to # keep handing back the pair its own callers unpack - actor, mesh = r.sphere(np.zeros((1, 3)), "red", 0.01) - assert not isinstance(actor, list) and not isinstance(mesh, list) + actor, cloud = r.sphere(np.zeros((1, 3)), "red", 0.01) + assert not isinstance(actor, list) and not isinstance(cloud, list) + + +def test_instance_cloud_takes_channel_names(renderer_lite): + """mne/viz/_3d.py writes channel names onto the cloud (gh-13074). + + _PyVistaRenderer hands back a PolyData whose ``field_data`` takes them; a + pyvista-js PolyData has no such attribute, so the renderer supplies one. + An empty ``positions`` must still give a cloud, since the caller assigns + without checking. + """ + r = renderer_lite._get_renderer(size=(200, 200)) + positions = np.array([[0.0, 0, 0], [1.0, 0, 0]]) + _, cloud = r.instanced_mesh(_RR, _TRIS, positions, colors=(1.0, 0, 0)) + # one cloud point per instance, in order: _3d.py indexes the names against + # them, so a cloud that did not carry the positions would mislabel sensors + assert_allclose(np.asarray(cloud.points), positions, atol=1e-6) + cloud.field_data["ch_names"] = np.array(["MEG 0113", "MEG 0112"], dtype="U") + assert list(cloud.field_data["ch_names"]) == ["MEG 0113", "MEG 0112"] + + actor, cloud = r.instanced_mesh(_RR, _TRIS, np.zeros((0, 3))) + assert actor is None + cloud.field_data["ch_names"] = np.array([], dtype="U") # must not raise def test_draws_into_an_existing_figure(renderer_lite): From 1e9c9db28e407a6ce13cfe50cc34a2f9363703c4 Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Thu, 3 Sep 2026 12:56:28 -0400 Subject: [PATCH 13/15] FIX: Fixes --- .../{14144.other.rst => 14144.newfeature.rst} | 0 mne/conftest.py | 32 +- mne/report/tests/test_report.py | 6 +- mne/tests/test_transforms.py | 20 + mne/transforms.py | 63 +++- mne/viz/_3d.py | 3 +- mne/viz/_brain/tests/test_brain.py | 6 +- mne/viz/backends/_lite.py | 346 ++++++++++++------ mne/viz/backends/tests/test_lite.py | 139 ++++++- mne/viz/backends/tests/test_renderer.py | 96 +++-- mne/viz/tests/test_3d.py | 29 +- 11 files changed, 540 insertions(+), 200 deletions(-) rename doc/changes/dev/{14144.other.rst => 14144.newfeature.rst} (100%) diff --git a/doc/changes/dev/14144.other.rst b/doc/changes/dev/14144.newfeature.rst similarity index 100% rename from doc/changes/dev/14144.other.rst rename to doc/changes/dev/14144.newfeature.rst diff --git a/mne/conftest.py b/mne/conftest.py index e979abfdb22..f9953f1e80f 100644 --- a/mne/conftest.py +++ b/mne/conftest.py @@ -734,7 +734,12 @@ def browser_backend(request, garbage_collect, monkeypatch): mne_qt_browser._browser_instances.clear() -@pytest.fixture(params=[pytest.param("pyvistaqt", marks=pytest.mark.pvtest)]) +@pytest.fixture( + params=[ + pytest.param("pyvistaqt", marks=pytest.mark.pvtest), + pytest.param("jupyterlite_notebook", marks=pytest.mark.pvtest), + ] +) def renderer(request, options_3d, garbage_collect): """Yield the 3D backends.""" with _use_backend(request.param, interactive=False) as renderer: @@ -757,18 +762,9 @@ def renderer_notebook(request, options_3d): @pytest.fixture(params=[pytest.param("jupyterlite_notebook", marks=pytest.mark.pvtest)]) def renderer_lite(request, options_3d): - """Yield the JupyterLite (vtk.js) renderer.""" - from mne.viz.backends import renderer as renderer_module - - # use_3d_backend only puts a backend back if one was already selected, and - # this one draws for a browser, so make sure it is never what a later test - # inherits - was = (renderer_module.MNE_3D_BACKEND, renderer_module.backend) - try: - with _use_backend(request.param, interactive=False) as renderer: - yield renderer - finally: - renderer_module.MNE_3D_BACKEND, renderer_module.backend = was + """Yield the JupyterLite (vtk.js) renderer alone, for its own tests.""" + with _use_backend(request.param, interactive=False) as renderer: + yield renderer @pytest.fixture(params=[pytest.param("pyvistaqt", marks=pytest.mark.pvtest)]) @@ -798,15 +794,21 @@ def _use_backend(backend_name, interactive): # figure-count test (in other modules) fails. Restore it on teardown. mpl_backend = matplotlib.get_backend() _check_skip_backend(backend_name) + from mne.viz.backends import renderer + + # use_3d_backend only puts a backend back if one was already selected, so + # the first renderer test of a session would otherwise decide the backend + # every later test inherits; the JupyterLite one draws for a browser, so + # that must never be it + was = (renderer.MNE_3D_BACKEND, renderer.backend) try: with _use_test_3d_backend(backend_name, interactive=interactive): - from mne.viz.backends import renderer - try: yield renderer finally: renderer.backend._close_all() finally: + renderer.MNE_3D_BACKEND, renderer.backend = was if matplotlib.get_backend() != mpl_backend: matplotlib.use(mpl_backend, force=True) diff --git a/mne/report/tests/test_report.py b/mne/report/tests/test_report.py index 96af2bc37e5..6cd1f5b3c29 100644 --- a/mne/report/tests/test_report.py +++ b/mne/report/tests/test_report.py @@ -393,7 +393,7 @@ def test_report_raw_psd_and_date(tmp_path): @pytest.mark.slowtest # slow on Azure @testing.requires_testing_data -def test_render_add_sections(renderer, tmp_path): +def test_render_add_sections(renderer_pyvistaqt, tmp_path): """Test adding figures/images to section.""" pytest.importorskip("nibabel") try: @@ -443,7 +443,7 @@ def test_render_add_sections(renderer, tmp_path): @pytest.mark.slowtest @testing.requires_testing_data -def test_render_mri(renderer, tmp_path): +def test_render_mri(renderer_pyvistaqt, tmp_path): """Test rendering MRI for mne report.""" pytest.importorskip("nibabel") trans_fname_new = tmp_path / "temp-trans.fif" @@ -1199,7 +1199,7 @@ def test_report_backward_compat(tmp_path): @pytest.mark.slowtest # 30 s on Azure @testing.requires_testing_data -def test_manual_report_3d(tmp_path, renderer): +def test_manual_report_3d(tmp_path, renderer_pyvistaqt): """Simulate adding 3D sections.""" pytest.importorskip("nibabel") r = Report(title="My Report") diff --git a/mne/tests/test_transforms.py b/mne/tests/test_transforms.py index 9a90629f042..8293f56701d 100644 --- a/mne/tests/test_transforms.py +++ b/mne/tests/test_transforms.py @@ -386,6 +386,26 @@ def test_vector_rotation(): quat_1 = rot_to_quat(rot) quat_2 = rot_to_quat(np.eye(3)) assert_allclose(_angle_between_quats(quat_1, quat_2), np.pi / 2.0) + # many at once, including the parallel and antiparallel cases, which + # have no rotation axis of their own + rng = np.random.default_rng(0) + b = rng.normal(size=(50, 3)) + b /= np.linalg.norm(b, axis=1, keepdims=True) + b = np.concatenate([b, [x, -x, [-1, 1e-8, 0]]]) + b /= np.linalg.norm(b, axis=1, keepdims=True) + rots = _find_vector_rotation(x, b) + assert rots.shape == (len(b), 3, 3) + assert_allclose(rots @ x, b, atol=1e-12) + assert_allclose(np.linalg.det(rots), 1.0, atol=1e-12) + eye = np.broadcast_to(np.eye(3), rots.shape) + assert_allclose(rots @ rots.transpose(0, 2, 1), eye, atol=1e-12) + assert_allclose(rots[-3], np.eye(3), atol=1e-12) + assert_allclose(rots[-2], np.diag([-1, -1, 1]), atol=1e-12) + # each is the minimal rotation, so its axis is perpendicular to both + for rot, this_b in zip(rots[:50], b[:50]): + assert_allclose(rot, _find_vector_rotation(x, this_b)) + angle = _angle_between_quats(rot_to_quat(rot), np.zeros(3)) + assert_allclose(angle, np.arccos(np.dot(x, this_b))) def test_average_quats(): diff --git a/mne/transforms.py b/mne/transforms.py index 011bf64925d..24c73ab53f1 100644 --- a/mne/transforms.py +++ b/mne/transforms.py @@ -1418,21 +1418,62 @@ def _skew_symmetric_cross(a): def _find_vector_rotation(a, b): - """Find the rotation matrix that maps unit vector a to b.""" + """Find the rotation matrix that maps unit vector a to unit vector(s) b. + + Parameters + ---------- + a : array, shape (3,) + The unit vector to rotate. + b : array, shape (3,) | shape (..., 3) + The unit vector(s) to rotate ``a`` onto. + + Returns + ------- + R : array, shape (3, 3) | shape (..., 3, 3) + The rotation(s) about ``a x b`` by the angle between them, so that + ``R @ a`` is ``b``. Antiparallel vectors, where that axis vanishes, + get a half turn about an arbitrary axis perpendicular to ``a``. + + Notes + ----- + Mapping one vector onto another leaves a free parameter: the roll about + ``b``. Any rotation about ``b`` composed with the result maps ``a`` onto + ``b`` just as well, and this function settles it by taking the minimal + rotation, about ``a x b``. So it is right for things that look the same + however they are rolled about their axis, like arrows, tubes and the EEG + electrode cylinders, and wrong for a flat MEG coil, whose orientation + needs the full rotation from ``_loc_to_coil_trans``. + """ # Rodrigues' rotation formula: # https://en.wikipedia.org/wiki/Rodrigues%27_rotation_formula # http://math.stackexchange.com/a/476311 + a = np.asarray(a, float) + b = np.asarray(b, float) + assert a.shape == (3,), a.shape assert np.isclose(np.linalg.norm(a), 1.0), np.linalg.norm(a) - assert np.isclose(np.linalg.norm(b), 1.0), np.linalg.norm(b) - R = np.eye(3) - v = np.cross(a, b) - if np.allclose(v, 0.0): # identical - return R - s = np.dot(v, v) # sine of the angle between them - c = np.dot(a, b) # cosine of the angle between them - vx = _skew_symmetric_cross(v) - R += vx + np.dot(vx, vx) * (1 - c) / s - # Now we have: np.allclose(R @ a, b) + assert b.shape[-1:] == (3,), b.shape + assert np.allclose(np.linalg.norm(b, axis=-1), 1.0), np.linalg.norm(b, axis=-1) + v = np.cross(a, b) # rotation axis, with the sine of the angle as its length + s = (v * v).sum(-1) # sine squared + c = b @ a # cosine + # skew-symmetric cross-product matrices, one per b + vx = np.zeros(b.shape[:-1] + (3, 3)) + vx[..., 0, 1], vx[..., 0, 2] = -v[..., 2], v[..., 1] + vx[..., 1, 0], vx[..., 1, 2] = v[..., 2], -v[..., 0] + vx[..., 2, 0], vx[..., 2, 1] = -v[..., 1], v[..., 0] + # (1 - c) / s is 1 / (1 + c), but written this way it stays accurate as b + # approaches -a, where 1 + c cancels and s does not. Only an s that has + # vanished outright needs special handling below. + degenerate = s < np.finfo(float).tiny + factor = (1.0 - c) / np.where(degenerate, 1.0, s) + R = np.eye(3) + vx + vx @ vx * factor[..., np.newaxis, np.newaxis] + if degenerate.any(): + # parallel is the identity (vx is zero); antiparallel is a half turn + # about a unit vector k perpendicular to a, for which the coordinate + # axis least aligned with a serves, since it is never parallel to it + k = np.cross(a, np.eye(3)[np.argmin(np.abs(a))]) + k /= np.linalg.norm(k) + R[degenerate & (c < 0)] = 2 * np.outer(k, k) - np.eye(3) return R diff --git a/mne/viz/_3d.py b/mne/viz/_3d.py index ab2543852a3..197ee0421d6 100644 --- a/mne/viz/_3d.py +++ b/mne/viz/_3d.py @@ -1442,8 +1442,7 @@ def _plot_glyphs( ) x_axis = np.array([1.0, 0.0, 0.0]) nn = vectors / np.linalg.norm(vectors, axis=1, keepdims=True) - rots = np.array([_find_vector_rotation(x_axis, this_nn) for this_nn in nn]) - quats = rot_to_quat(rots) + quats = rot_to_quat(_find_vector_rotation(x_axis, nn)) rr, tris = renderer._glyph_template(kind, **template_kw) actor, cloud = renderer.instanced_mesh( rr=rr, diff --git a/mne/viz/_brain/tests/test_brain.py b/mne/viz/_brain/tests/test_brain.py index 23673f9ad8e..ee5ec5f0c68 100644 --- a/mne/viz/_brain/tests/test_brain.py +++ b/mne/viz/_brain/tests/test_brain.py @@ -208,9 +208,9 @@ def test_brain_data_gc(renderer_interactive_pyvistaqt, brain_gc): @testing.requires_testing_data -def test_brain_routines(renderer, brain_gc): +def test_brain_routines(renderer_pyvistaqt, brain_gc): """Test backend agnostic Brain routines.""" - brain_klass = renderer.get_brain_class() + brain_klass = renderer_pyvistaqt.get_brain_class() from mne.viz._brain import Brain assert brain_klass == Brain @@ -981,7 +981,7 @@ def test_single_hemi(hemi, renderer_interactive_pyvistaqt, brain_gc): @testing.requires_testing_data @pytest.mark.slowtest @pytest.mark.parametrize("interactive_state", (False, True)) -def test_brain_save_movie(tmp_path, renderer, brain_gc, interactive_state): +def test_brain_save_movie(tmp_path, renderer_pyvistaqt, brain_gc, interactive_state): """Test saving a movie of a Brain instance.""" pytest.importorskip("imageio") imageio_ffmpeg = pytest.importorskip("imageio_ffmpeg") diff --git a/mne/viz/backends/_lite.py b/mne/viz/backends/_lite.py index 4a8609ce4da..bcf4d6492a6 100644 --- a/mne/viz/backends/_lite.py +++ b/mne/viz/backends/_lite.py @@ -33,7 +33,6 @@ # License: BSD-3-Clause # Copyright the MNE-Python contributors. -import gc import weakref from contextlib import nullcontext @@ -48,9 +47,9 @@ _sph_to_cart, quat_to_rot, ) -from ...utils import _check_option -from ._abstract import _AbstractRenderer -from ._utils import _vtk_faces +from ...utils import _check_option, _validate_type +from ._abstract import Figure3D, _AbstractRenderer +from ._utils import ALLOWED_QUIVER_MODES, _vtk_faces # vtk.js positions text in normalized window coordinates, where PyVista takes # the names MNE's set_3d_title passes through @@ -59,6 +58,10 @@ "lower_right": (0.65, 0.05), "upper_left": (0.05, 0.90), "upper_right": (0.65, 0.90), + "lower_edge": (0.35, 0.05), + "upper_edge": (0.35, 0.90), + "left_edge": (0.05, 0.50), + "right_edge": (0.65, 0.50), } # what MNE means by ``color=None``: "whatever the renderer draws by default", @@ -106,48 +109,60 @@ def _lite_add_text(plotter, text, position, size, color): return actor +def _lite_view_angles(plotter): + """Return the (azimuth, elevation) a plotter looks from, in degrees, or None. + + ``view_vector`` stores the camera position, as a direction from the focal + point, on the plotter's renderer and leaves ``Plotter.camera`` unset, + which is deliberate: the vtk.js side then calls ``resetCamera()`` and + frames the scene, whereas a camera object would have to carry a distance, + and MNE asks for ``distance=None`` more often than not. The cost is that + ``Plotter.camera_position`` reads that unset camera and so stays ``None``; + the direction below is the scene's actual camera state, and inverting it + recovers the angles :func:`_lite_set_view` applied. + """ + view_vector = plotter._renderer._view_vector # pyvista-js 0.15 + if view_vector is None: # no view set yet, so vtk.js is choosing one + return None + _, phi, theta = _cart_to_sph(np.asarray(view_vector, float)[np.newaxis])[0] + return float(np.rad2deg(phi)) % 360, float(np.rad2deg(theta)) % 180 + + def _lite_set_view(plotter, azimuth=None, elevation=None): - """Point a plotter along the requested azimuth and elevation.""" + """Point a plotter along the requested azimuth and elevation. + + Giving one angle leaves the other where it is, as ``_pyvista._set_3d_view`` + does; before any view is set it defaults to 90 degrees, the anterior view + ``plot_alignment`` ends on. + """ if azimuth is None and elevation is None: return None - phi = np.deg2rad(90.0 if azimuth is None else azimuth) - theta = np.deg2rad(90.0 if elevation is None else elevation) - position = _sph_to_cart(np.array([[1.0, phi, theta]]))[0] + current = _lite_view_angles(plotter) or (90.0, 90.0) + 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 the 5/175 threshold _set_3d_view # uses, because there the view plane normal runs parallel to the camera - if 5.0 <= abs(np.rad2deg(theta)) <= 175.0: + if elevation is None or 5.0 <= abs(elevation) <= 175.0: viewup = (0.0, 0.0, 1.0) else: viewup = (0.0, 1.0, 0.0) - plotter.view_vector(-position, viewup=viewup) + # the vector is the camera position: the vtk.js side sets it, aims the + # camera at the origin and then frames the scene with resetCamera(), so a + # unit direction from the focal point is exactly what it needs + position = _sph_to_cart(np.array([[1.0, phi, theta]]))[0] + plotter.view_vector(tuple(position), viewup=viewup) return None def _lite_get_view(plotter): - """Return the direction a plotter is looking along, as _get_3d_view orders it. - - ``view_vector`` stores that direction on the plotter's renderer and leaves - ``Plotter.camera`` unset, which is deliberate: the vtk.js side then calls - ``resetCamera()`` and frames the scene, whereas a camera object would have - to carry a distance, and MNE asks for ``distance=None`` more often than not. - The cost is that ``Plotter.camera_position`` reads that unset camera and so - stays ``None``; the direction below is the scene's actual camera state, and - inverting it recovers the angles :func:`_lite_set_view` applied. - """ - view_vector = plotter._renderer._view_vector # pyvista-js 0.15 - if view_vector is None: # no view set yet, so vtk.js is choosing one + """Return the camera state, ordered the way _get_3d_view orders it.""" + angles = _lite_view_angles(plotter) + if angles is None: return (0.0, 1.0, 0.0, 0.0, np.zeros(3)) - _, phi, theta = _cart_to_sph(-np.asarray(view_vector, float)[np.newaxis])[0] # roll is 0 because the only camera roll _lite_set_view applies is the view # up flip at the poles; the distance and focalpoint are the ones the vtk.js # resetCamera() gives this path, a unit direction aimed at the origin - return ( - 0.0, - 1.0, - float(np.rad2deg(phi)) % 360, - float(np.rad2deg(theta)) % 180, - np.zeros(3), - ) + return (0.0, 1.0, angles[0], angles[1], np.zeros(3)) # Every scene a notebook drew used to stay live for the kernel's lifetime, @@ -179,10 +194,8 @@ def _lite_release_plotter(plotter): so that is what frees the memory, and it is the only teardown pyvista-js 0.15 offers: there is no ``close()`` to tear the render window down as well, which is why closing a figure and clearing one do the same thing - here. - - Collecting is left to the caller, so draining a whole registry sweeps once - rather than once per scene. + here. Nothing here holds a reference cycle, so dropping the actors is + enough, and a ``gc.collect()`` on top would only cost time. """ if plotter is None: return None @@ -205,7 +218,6 @@ def _lite_release_plotter(plotter): def _lite_trim_live_plotters(): """Release everything but the most recent scenes.""" - trimmed = False while len(_lite_live_plotters) > _LITE_MAX_LIVE_SCENES: oldest = _lite_live_plotters[0]() if oldest is None: @@ -213,12 +225,45 @@ def _lite_trim_live_plotters(): else: # also drops it from the registry, so this terminates _lite_release_plotter(oldest) - trimmed = True - if trimmed: - gc.collect() return None +def _lite_opacity(color, opacity): + """Fold the alpha of an RGBA color into the opacity. + + ``_PyVistaRenderer`` hands instance colors to VTK as RGBA scalars, so the + alpha column is what makes MEG coils translucent (``sensor_alpha``); vtk.js + draws each mesh in one solid color and ``to_rgb`` drops the alpha, so the + group's alpha has to become its opacity instead. + """ + opacity = 1.0 if opacity is None else float(opacity) + if not isinstance(color, str) and np.shape(color) == (4,): + opacity *= float(color[3]) + return opacity + + +class _LiteFigure(Figure3D): + """pyvista-js-based 3D figure, the object MNE's 3D functions hand back. + + It carries the pyvista-js plotter as ``.plotter``, the way + ``PyVistaFigure`` carries PyVista's, so the module-level helpers + (``set_3d_view``, ``close_3d_figure``, ...) and ``isinstance(fig, + Figure3D)`` checks treat both backends alike. + """ + + def __init__(self): + pass + + def _init(self, plotter): + self._plotter = plotter # read through Figure3D.plotter + return self + + +# figures made with an integer handle, so that create_3d_figure(handle=...) +# draws into the same scene again, as _pyvista._FIGURES does +_lite_figures = dict() + + class _LiteRenderer(_AbstractRenderer): """Minimal MNE 3D renderer backed by pyvista-js.""" @@ -230,18 +275,42 @@ class _LiteRenderer(_AbstractRenderer): # API this one does not implement and fails first. _kind = "jupyterlite_notebook" - def __init__(self, fig=None, size=(600, 600), bgcolor="black", **kwargs): - # `size` is named to match _PyVistaRenderer but cannot be honoured: - # pv.Plotter takes only a lighting mode, and generate_standalone_html - # emits a fixed 600x400 canvas with no knob for it. + def __init__( + self, + fig=None, + size=(600, 600), + bgcolor="black", + *, + name=None, + show=False, + shape=(1, 1), + notebook=None, + smooth_shading=True, + splash=False, + multi_samples=None, + ): + # The signature is _PyVistaRenderer's, so every _get_renderer call + # binds the same way, but most of it cannot be honoured: pv.Plotter + # takes only a lighting mode and generate_standalone_html emits a + # fixed 600x400 canvas, so `size` and `shape` have no effect; there is + # no window to give `name` to; and `show` means nothing before anything + # is drawn, because the canvas is written when show() runs rather than + # kept live. # # plot_alignment(fig=...) and plot_dipole_locations(fig=...) composite - # into a scene the notebook already made, so draw into that plotter + # into a scene the notebook already made, so draw into that figure # rather than opening a second one and splitting the picture in two. + # An int is a handle naming a figure to make now and reuse later. + _validate_type(fig, (None, int, _LiteFigure), "fig") + handle = fig if isinstance(fig, int) else None + if handle is not None: + fig = _lite_figures.get(handle) if fig is not None: - self.plotter = fig + self._figure = fig return - self.plotter = pv.Plotter() + self._figure = _LiteFigure()._init(pv.Plotter()) + if handle is not None: + _lite_figures[handle] = self._figure _lite_live_plotters.append(weakref.ref(self.plotter)) # trim after appending, so the new scene counts towards the cap and # _LITE_MAX_LIVE_SCENES is the number that actually stays live @@ -269,6 +338,11 @@ def __init__(self, fig=None, size=(600, 600), bgcolor="black", **kwargs): ) # -- helpers ------------------------------------------------------------ + @property + def plotter(self): + """The pyvista-js plotter the figure draws into.""" + return self._figure.plotter + def _glyph_template( self, kind, radius=None, height=None, center=None, resolution=None, **kwargs ): @@ -360,9 +434,12 @@ def _add(self, points, tris, color, opacity=1.0): all of them. """ # float32 halves what the merged glyph meshes cost in the WASM heap, - # and vtk.js uses single precision on the GPU regardless + # and vtk.js uses single precision on the GPU regardless. The faces go + # over flat: pyvista-js serialises them as given, and vtk.js reads the + # result as one VTK cell array, not as rows. mesh = pv.PolyData( - points=np.asarray(points, dtype=np.float32), faces=_vtk_faces(tris) + points=np.asarray(points, dtype=np.float32), + faces=_vtk_faces(tris).ravel(), ) actor = self.plotter.add_mesh( mesh, @@ -374,8 +451,7 @@ def _add(self, points, tris, color, opacity=1.0): def _rots_from_dirs(self, dirs): """Rotations carrying +x onto each direction, as the glyphs assume.""" - x_axis = np.array([1.0, 0.0, 0.0]) - return np.asarray([_find_vector_rotation(x_axis, d) for d in dirs], dtype=float) + return _find_vector_rotation(np.array([1.0, 0.0, 0.0]), dirs) def _tile(self, rr, tris, positions, scales=None, rots=None, axis_scales=None): """Stamp one template mesh at many positions as a single mesh. @@ -419,18 +495,29 @@ def _tile(self, rr, tris, positions, scales=None, rots=None, axis_scales=None): # representation, shading and edges), it recomputes normals itself with # vtkPolyDataNormals rather than taking an array, and it has no actor # registry to look a `name` up in later. The visible cost is that a - # transparent surface shows its own inside. + # transparent surface shows its own inside. Scalars and colormaps are + # accepted and ignored for the reason given at the top of the module. The + # signatures otherwise follow _PyVistaRenderer's, so that a positional + # call binds the same argument on both backends. def mesh( self, x, y, z, triangles, - color=None, + color, opacity=1.0, + *, backface_culling=False, + scalars=None, + colormap=None, + vmin=None, + vmax=None, + interpolate_before_map=True, + representation="surface", + line_width=1.0, normals=None, - *args, + name=None, **kwargs, ): points = np.column_stack( @@ -443,10 +530,14 @@ def surface( surface, color=None, opacity=1.0, + vmin=None, + vmax=None, + colormap=None, + normalized_colormap=False, + scalars=None, backface_culling=False, + *, name=None, - *args, - **kwargs, ): return self._add(surface["rr"], surface["tris"], color, opacity) @@ -459,7 +550,6 @@ def sphere( resolution=8, backface_culling=False, radius=None, - **kwargs, ): # `resolution` has no equivalent here: _pyvista.py asks pyvista.Sphere # for that many theta and phi bands, while this template comes from a @@ -483,11 +573,18 @@ def tube( origin, destination, radius=0.001, - color=None, - opacity=1.0, - *args, - **kwargs, + color="white", + scalars=None, + vmin=None, + vmax=None, + colormap="RdBu", + normalized_colormap=False, + reverse_lut=False, + opacity=None, ): + # `color` defaults to white as _PyVistaRenderer's does, and not to + # _DEFAULT_COLOR: plot_alignment draws the fNIRS source-detector pairs + # with no color on a (0.5, 0.5, 0.5) background, which is that gray origin = np.atleast_2d(np.asarray(origin, dtype=float))[:, :3] destination = np.atleast_2d(np.asarray(destination, dtype=float))[:, :3] n_seg = min(len(origin), len(destination)) @@ -524,20 +621,21 @@ def quiver3d( u, v, w, - color=None, - scale=1.0, - mode="arrow", - opacity=1.0, + color, + scale, + mode, *, - scale_mode="none", - scalars=None, glyph_height=None, glyph_center=None, glyph_resolution=None, + opacity=1.0, + scale_mode="none", + scalars=None, + colormap=None, + backface_culling=False, glyph_radius=0.15, solid_transform=None, - backface_culling=False, - **kwargs, + clim=None, ): """Draw one merged glyph mesh, the way the glyph filter would. @@ -566,6 +664,7 @@ def quiver3d( # other mode gets whatever scale_mode asks for. plot_alignment's # show_axes draws its three axis arrows at 1/3, 2/3 and full length this # way, so ignoring it would make the coordinate frames wrong. + _check_option("mode", mode, ALLOWED_QUIVER_MODES) _check_option("scale_mode", scale_mode, ("none", "scalar", "vector")) if mode == "arrow": scale_mode = "scalar" @@ -634,9 +733,8 @@ def instanced_mesh( scales=None, opacity=1.0, backface_culling=False, + *, name=None, - *args, - **kwargs, ): """Stamp the template at every position, merged per distinct color. @@ -658,12 +756,14 @@ def instanced_mesh( return None, _lite_instance_cloud(positions) rots = None if quats is not None: - rots = np.asarray( - quat_to_rot(np.atleast_2d(np.asarray(quats, dtype=float))), dtype=float - ) + quats = np.atleast_2d(np.asarray(quats, dtype=float)) + # MNE's (x, y, z) with w implied, as _PyVistaRenderer also insists: + # a (w, x, y, z) row would silently be read as a half turn + assert quats.shape[-1] == 3, quats.shape + rots = quat_to_rot(quats) idx = np.arange(n_pos) if colors is not None and np.ndim(colors) > 1: - colors = np.asarray(colors) + colors = np.asarray(colors, dtype=float) uniq, inverse = np.unique( colors[idx % len(colors)], axis=0, return_inverse=True ) @@ -683,7 +783,7 @@ def instanced_mesh( points, faces = self._tile( rr, tris, positions[sel], scales=group_scales, rots=group_rots ) - actor, _ = self._add(points, faces, color, opacity) + actor, _ = self._add(points, faces, color, _lite_opacity(color, opacity)) actors.append(actor) # one group is the common case and matches _PyVistaRenderer, which # colors per instance inside a single actor; hand that actor back on @@ -715,22 +815,22 @@ def text2d( # These are reached while drawing the figures the docs render, and there is # genuinely nothing for them to do here, so each says why rather than # raising and taking a working page down with it. - def set_interaction(self, *args, **kwargs): + def set_interaction(self, interaction): # plot_alignment sets this unconditionally (mne/viz/_3d.py); vtk.js # ships one trackball style and no way to swap it return None - def _update(self, *args, **kwargs): + def _update(self): # plot_alignment calls this to force a repaint of a window already up; # the browser paints from the JS side, after the cell has finished return None - def _window_close_connect(self, *args, **kwargs): + def _window_close_connect(self, func, *, after=True): # mne/viz/ui_events.py asks to be told when the window closes, and a # canvas in an output cell has no close event to connect to return None - def text3d(self, *args, **kwargs): + def text3d(self, x, y, z, text, scale, color="white"): # plot_alignment(show_channel_names=True) labels each sensor, which # needs a follow-the-camera 3D text actor. pyvista-js 0.15 has only # Text, positioned in normalized window coordinates, and projecting the @@ -739,7 +839,20 @@ def text3d(self, *args, **kwargs): return None def close(self): - _lite_release_plotter(self.plotter) + _close_3d_figure(self._figure) + return None + + def remove_mesh(self, mesh_data): + # add_mesh hands back the dict the renderer keeps, and the plotter + # keeps a second dict pointing at it, so drop both; instanced_mesh + # hands back one dict per color group + 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 + ] return None # -- things pyvista-js cannot do ---------------------------------------- @@ -776,14 +889,6 @@ def subplot(self, *args, **kwargs): "Subplots are not supported in the browser: a scene is one canvas." ) - def remove_mesh(self, *args, **kwargs): - # add_mesh hands back a dict describing the actor rather than a handle - # the plotter can look up again - raise NotImplementedError( - "Removing a mesh is not supported in the browser: pyvista-js draws " - "the scene as a whole and cannot take one mesh back out of it." - ) - def _process_events(self, *args, **kwargs): # the kernel and the canvas are different threads here, and the Pyodide # worker cannot drive the page's event loop @@ -817,17 +922,11 @@ def project(self, xyz, ch_names): "Projecting 3D positions onto the scene is not supported in the browser." ) - def screenshot(self, mode="rgb", filename=None, **kwargs): - # Plotter.screenshot does exist, but it renders the scene by driving a - # headless browser with Playwright from outside the page, which is not - # something the page can do to itself - raise NotImplementedError( - "Taking a screenshot is not supported in the browser: vtk.js draws " - "to a live canvas that cannot be read back as an array." - ) + def screenshot(self, mode="rgb", filename=None): + return _take_3d_screenshot(self._figure, mode=mode, filename=filename) # -- camera and scene --------------------------------------------------- - def get_camera(self, *args, **kwargs): + def get_camera(self, *, rigid=None): # Same order as _get_3d_view: roll, distance, azimuth, elevation and # then the focalpoint. Brain unpacks positions 3 and 4 as the angles, # so the focalpoint has to be the last element rather than the fourth. @@ -840,12 +939,13 @@ def set_camera( distance=None, focalpoint=None, roll=None, - *args, - **kwargs, + *, + rigid=None, + update=True, ): # distance, focalpoint and roll go unused: vtk.js frames the scene with # resetCamera() on this path, which is what lets it handle the - # distance=None that MNE asks for far more often. See _lite_get_view. + # distance=None that MNE asks for far more often. See _lite_view_angles. return _lite_set_view(self.plotter, azimuth, elevation) @property @@ -857,10 +957,10 @@ def figure(self): ``create_3d_figure(scene=False)`` and then passes ``renderer.figure`` to ``set_3d_view``, so the two have to stay the same thing here too. """ - return self.plotter + return self._figure def scene(self): - return self.plotter + return self._figure def show(self): self.plotter.show() @@ -870,7 +970,7 @@ def show(self): # -- the module surface renderer.py expects of a 3D backend ----------------- # set_3d_view, set_3d_title and the close_* helpers reach for these on the # ``renderer.backend`` global rather than going through _get_renderer, and the -# figure they hand over is the pyvista-js plotter _LiteRenderer.scene returns. +# figure they hand over is the _LiteFigure _LiteRenderer.scene returns. _Renderer = _LiteRenderer # nothing here draws differently under test, the way an on-screen window does @@ -878,32 +978,54 @@ def show(self): def _set_3d_view( - figure, azimuth=None, elevation=None, focalpoint=None, distance=None, roll=None + figure, + azimuth=None, + elevation=None, + focalpoint=None, + distance=None, + roll=None, + rigid=None, + update=True, ): # distance, focalpoint and roll go unused here for the same reason they do # in _LiteRenderer.set_camera: vtk.js frames the scene with resetCamera() - # on this path. See _lite_get_view. - return _lite_set_view(figure, azimuth, elevation) + # on this path. See _lite_view_angles. + return _lite_set_view(figure.plotter, azimuth, elevation) def _set_3d_title(figure, title, size=16, *, color="white", position="upper_left"): if isinstance(position, str): _check_option("position", position, sorted(_TITLE_POSITIONS)) position = _TITLE_POSITIONS[position] - return _lite_add_text(figure, title, position, size, color) + return _lite_add_text(figure.plotter, title, position, size, color) + + +def _check_3d_figure(figure): + _validate_type(figure, _LiteFigure, "figure") + + +def _take_3d_screenshot(figure, mode="rgb", filename=None): + # Plotter.screenshot does exist, but it renders the scene by driving a + # headless browser with Playwright from outside the page, which is not + # something the page can do to itself + raise NotImplementedError( + "Taking a screenshot is not supported in the browser: vtk.js draws " + "to a live canvas that cannot be read back as an array." + ) def _clear_3d_figure(figure): - _lite_release_plotter(figure) - gc.collect() + _lite_release_plotter(figure.plotter) return None def _close_3d_figure(figure): # the same as clearing: vtk.js draws into a canvas in an output cell, so - # there is no window left to close once the geometry is gone - _lite_release_plotter(figure) - gc.collect() + # there is no window left to close once the geometry is gone. The handle + # is forgotten too, so the next create_3d_figure with it starts afresh. + _lite_release_plotter(figure.plotter) + for handle in [key for key, fig in _lite_figures.items() if fig is figure]: + del _lite_figures[handle] return None @@ -917,5 +1039,5 @@ def _close_all(): _lite_live_plotters.pop() else: _lite_release_plotter(plotter) - gc.collect() # once for the whole registry, not once per scene + _lite_figures.clear() return None diff --git a/mne/viz/backends/tests/test_lite.py b/mne/viz/backends/tests/test_lite.py index 1867d49ee60..a7f736c82e6 100644 --- a/mne/viz/backends/tests/test_lite.py +++ b/mne/viz/backends/tests/test_lite.py @@ -11,6 +11,7 @@ import pytest from numpy.testing import assert_allclose +from mne.viz import Figure3D from mne.viz.backends._abstract import _AbstractRenderer # imported this way rather than with a plain import so that the whole file @@ -32,6 +33,13 @@ def _drawn(renderer): return renderer.plotter.actors[-1]["mesh"] +def _serialized(renderer): + """Return the actor sources as the vtk.js page will receive them.""" + return [ + a["source"] for a in renderer.plotter._renderer._build_scene_data()["actors"] + ] + + def test_is_a_registered_backend(renderer_lite): """``set_3d_backend("jupyterlite_notebook")`` must hand out this renderer.""" assert renderer_lite.get_3d_backend() == "jupyterlite_notebook" @@ -129,8 +137,8 @@ def test_set_view_reaches_the_camera(azimuth, elevation, renderer_lite): # no angle to point at, so the camera is left for vtk.js to frame assert (roll, distance, got_azimuth, got_elevation) == (0.0, 1.0, 0.0, 0.0) return - # one angle given means "leave the other alone", which _lite_set_view - # spells 90; 2 and 90 degrees sit either side of the 5/175 view-up flip + # one angle given means "leave the other alone", which before any view is + # set means 90; 2 and 90 degrees sit either side of the 5/175 view-up flip assert got_azimuth == pytest.approx(90.0 if azimuth is None else azimuth % 360) assert got_elevation == pytest.approx( 90.0 if elevation is None else elevation % 180 @@ -138,6 +146,25 @@ def test_set_view_reaches_the_camera(azimuth, elevation, renderer_lite): assert (roll, distance) == (0.0, 1.0) +def test_set_view_matches_pyvista(renderer_lite): + """The camera has to end up where _pyvista._set_3d_view would put it. + + vtk.js reads ``view_vector`` as the camera *position*, so the anterior view + plot_alignment ends on (azimuth and elevation both 90) must put the camera + on +y and looking back at the head, not on -y looking at its back. And + giving one angle must leave the other alone, rather than reset it to 90. + """ + r = renderer_lite._get_renderer(size=(200, 200)) + r.set_camera(azimuth=90, elevation=90) + assert_allclose(r.plotter._renderer._view_vector, [0, 1, 0], atol=1e-12) + r.set_camera(elevation=0) # top view + assert_allclose(r.plotter._renderer._view_vector, [0, 0, 1], atol=1e-12) + r.set_camera(azimuth=180) # still a top view + assert r.get_camera()[2:4] == pytest.approx((180.0, 0.0)) + r.set_camera(elevation=90) # ... now from the left + assert_allclose(r.plotter._renderer._view_vector, [-1, 0, 0], atol=1e-12) + + def test_get_camera_matches_the_expected_order(renderer_lite): """``get_camera`` must unpack the way ``_get_3d_view`` does. @@ -160,9 +187,11 @@ def test_draws_every_primitive(renderer_lite): r = renderer_lite._get_renderer(size=(200, 200), bgcolor="white") assert len(r.plotter.actors) == 0 - # a flat unit square, drawn as given + # a flat unit square, drawn as given, whose faces reach vtk.js as the flat + # cell array it reads (nested rows would serialise to an empty one) _, mesh = r.mesh(_RR[:, 0], _RR[:, 1], _RR[:, 2], _TRIS, color="red", opacity=0.5) assert_allclose(np.asarray(mesh.points), _RR, atol=1e-6) + assert _serialized(r)[-1]["polys"] == [3, 0, 1, 2, 3, 0, 2, 3] # the same square, reached through the surface dict _, mesh = r.surface(dict(rr=_RR, tris=_TRIS), color="#0000ff") @@ -180,6 +209,7 @@ def test_draws_every_primitive(renderer_lite): assert points[:, 2].min() == pytest.approx(0.0) assert points[:, 2].max() == pytest.approx(1.0) assert np.linalg.norm(points[:, :2], axis=1).max() == pytest.approx(0.01) + assert r.plotter.actors[-1]["opacity"] == 1.0 # opacity=None is opaque # an arrow of length `scale` pointing the way it was given _, mesh = r.quiver3d( @@ -201,6 +231,39 @@ def test_draws_every_primitive(renderer_lite): assert len(r.plotter.actors) == 5 +def test_tube_defaults_to_white(renderer_lite): + """An uncolored tube is white, as _PyVistaRenderer draws it. + + plot_alignment draws fNIRS source-detector pairs with no color on a + (0.5, 0.5, 0.5) background, and that gray is this backend's fallback for + ``color=None`` elsewhere, so the wrong default makes the pairs vanish. + """ + r = renderer_lite._get_renderer(size=(200, 200)) + r.tube([[0.0, 0, 0]], [[1.0, 0, 0]], radius=0.01, opacity=0.5) + assert r.plotter.actors[-1]["color"] == (1.0, 1.0, 1.0) + assert r.plotter.actors[-1]["opacity"] == 0.5 + + +@pytest.mark.parametrize("mode", ("arrow", "cone", "cylinder")) +def test_glyphs_point_backwards(mode, renderer_lite): + """A glyph along -x must point along -x, not +x. + + The templates are built along +x and turned onto their direction, and the + antiparallel case has no rotation axis to speak of; it used to come out as + the identity, so every such glyph pointed the wrong way. + """ + _, mesh = renderer_lite._get_renderer(size=(200, 200)).quiver3d( + [0.0], [0.0], [0.0], [-1.0], [0.0], [0.0], color="red", scale=1.0, mode=mode + ) + x = np.asarray(mesh.points)[:, 0] + if mode == "cylinder": # centered on its position + assert (x.min(), x.max()) == pytest.approx((-0.5, 0.5)) + else: # base at the position, tip along the direction + assert (x.min(), x.max()) == pytest.approx((-1.0, 0.0)) + if mode == "cone": # the apex is the single point furthest along + assert (x == x.min()).sum() == 1 + + def test_tube_stretches_each_segment_on_its_own(renderer_lite): """``tube`` scales along the template axis alone, per segment. @@ -306,7 +369,7 @@ def test_instances_are_merged_per_color(renderer_lite): one actor and several give the list of them. The second return value is the instance cloud either way, matching ``_PyVistaRenderer``. """ - quats = np.tile([1.0, 0, 0, 0], (3, 1)) + quats = np.zeros((3, 3)) # identity, in MNE's (x, y, z) convention positions = np.array([[0.0, 0, 0], [1.0, 0, 0], [2.0, 0, 0]]) # one color: a single actor @@ -331,6 +394,29 @@ def test_instances_are_merged_per_color(renderer_lite): actor, cloud = r.sphere(np.zeros((1, 3)), "red", 0.01) assert not isinstance(actor, list) and not isinstance(cloud, list) + # a (w, x, y, z) quaternion would silently be read as a half turn + with pytest.raises(AssertionError, match=r"\(3, 4\)"): + r.instanced_mesh(_RR, _TRIS, positions, np.zeros((3, 4)), colors=colors) + + +def test_instance_alpha_becomes_opacity(renderer_lite): + """The alpha of RGBA instance colors sets the opacity of that group. + + ``plot_alignment`` makes MEG coils translucent (``sensor_alpha``) by + scaling the alpha column of the colors it hands ``instanced_mesh``, which + _PyVistaRenderer draws as RGBA scalars; here each color group is one solid + mesh, so its alpha has to become the mesh opacity or every coil is opaque. + """ + r = renderer_lite._get_renderer(size=(200, 200)) + positions = np.array([[0.0, 0, 0], [1.0, 0, 0], [2.0, 0, 0]]) + colors = np.array([[1.0, 0, 0, 0.25], [0, 1.0, 0, 1.0], [1.0, 0, 0, 0.25]]) + actors, _ = r.instanced_mesh(_RR, _TRIS, positions, colors=colors, opacity=0.5) + got = {a["color"]: a["opacity"] for a in actors} + assert got == {(1.0, 0.0, 0.0): 0.125, (0.0, 1.0, 0.0): 0.5} + # and a bare RGB row is drawn at the opacity asked for + r.sphere(np.zeros((1, 3)), (0.0, 0.0, 1.0), 0.01, opacity=0.5) + assert r.plotter.actors[-1]["opacity"] == 0.5 + def test_instance_cloud_takes_channel_names(renderer_lite): """mne/viz/_3d.py writes channel names onto the cloud (gh-13074). @@ -357,12 +443,28 @@ def test_instance_cloud_takes_channel_names(renderer_lite): def test_draws_into_an_existing_figure(renderer_lite): """``fig=`` composites into a scene rather than opening a second one.""" first = renderer_lite._get_renderer(size=(200, 200)) - second = renderer_lite._get_renderer(fig=first.plotter) + fig = first.scene() + assert isinstance(fig, Figure3D) + assert fig is first.figure # the tutorials reach for it under both names + second = renderer_lite._get_renderer(fig=fig) assert second.plotter is first.plotter second.sphere(np.array([[0.0, 0, 0]]), "red", 1.0) assert len(first.plotter.actors) == 1 + # an int handle names a scene to make now and draw into again later, as + # create_3d_figure(handle=...) does; closing it forgets the handle + third = renderer_lite._get_renderer(fig=7) + assert third.plotter is not first.plotter + assert renderer_lite._get_renderer(fig=7).plotter is third.plotter + renderer_lite.close_3d_figure(third.scene()) + assert renderer_lite._get_renderer(fig=7).plotter is not third.plotter + + with pytest.raises(TypeError, match="instance of None, int, or _LiteFigure"): + renderer_lite._get_renderer(fig=first.plotter) + with pytest.raises(TypeError, match="instance of _LiteFigure"): + renderer_lite.backend._check_3d_figure(first.plotter) + def test_live_scenes_are_capped(renderer_lite): """Old scenes are released, so a notebook cannot run the tab out of memory. @@ -388,7 +490,6 @@ def test_live_scenes_are_capped(renderer_lite): ("scalarbar", ()), ("legend", ()), ("subplot", ()), - ("remove_mesh", ()), ("_process_events", ()), ("_window_set_cursor", ()), ("_enable_time_interaction", ()), @@ -406,6 +507,21 @@ def test_unsupported_methods_raise(method, args, renderer_lite): getattr(r, method)(*args) +def test_remove_mesh(renderer_lite): + """``remove_mesh`` takes a drawn mesh, or a color-split set of them, back out.""" + r = renderer_lite._get_renderer(size=(200, 200)) + kept = r.mesh(_RR[:, 0], _RR[:, 1], _RR[:, 2], _TRIS, color="red") + gone = r.mesh(_RR[:, 0], _RR[:, 1], _RR[:, 2], _TRIS, color="blue") + positions = np.array([[0.0, 0, 0], [1.0, 0, 0]]) + split = r.instanced_mesh(_RR, _TRIS, positions, colors=np.eye(2, 3)) + assert len(r.plotter.actors) == 4 + r.remove_mesh(gone) + r.remove_mesh(split) + assert len(r.plotter.actors) == 1 + assert r.plotter.actors[0]["actor"] is kept[0] + assert len(_serialized(r)) == 1 # and the page does not get it either + + def test_text_is_drawn(renderer_lite): """Text lands on the canvas with the size and color that was asked for.""" r = renderer_lite._get_renderer(size=(200, 200)) @@ -415,6 +531,12 @@ def test_text_is_drawn(renderer_lite): title = renderer_lite.set_3d_title(figure=r.scene(), title="a title", size=20) assert title.input == "a title" + # every position name PyVista's add_text takes, since set_3d_title passes + # them through + for position in ("lower_edge", "right_edge"): + renderer_lite.set_3d_title(figure=r.scene(), title="t", position=position) + with pytest.raises(ValueError, match="Invalid value for the 'position'"): + renderer_lite.set_3d_title(figure=r.scene(), title="t", position="middle") # justification and a font file are the two things vtk.js cannot honour with pytest.raises(NotImplementedError, match="browser"): r.text2d(0.1, 0.9, "hello", justification="center") @@ -483,8 +605,10 @@ def test_renders_in_a_notebook_kernel(nbexec): rr = np.array([[0.0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0]]) tris = np.array([[0, 1, 2], [0, 2, 3]]) + fig = r.scene() r.mesh(rr[:, 0], rr[:, 1], rr[:, 2], tris, color="red") assert len(r.plotter.actors) == 1 + renderer.set_3d_view(fig, azimuth=90, elevation=90) # the html must carry this mesh, not merely be a vtk.js page: an empty # scene still ships the script tag, so look for the points themselves @@ -495,5 +619,8 @@ def test_renders_in_a_notebook_kernel(nbexec): drawn = np.asarray(scene["actors"][0]["source"]["points"], float).reshape(-1, 3) assert drawn.shape == rr.shape np.testing.assert_allclose(drawn, rr, atol=1e-6) + # the flat VTK cell array vtk.js reads, and the camera on +y looking back + assert scene["actors"][0]["source"]["polys"] == [3, 0, 1, 2, 3, 0, 2, 3] + np.testing.assert_allclose(scene["camera"]["viewVector"], [0, 1, 0], atol=1e-12) packed = json.dumps(scene["actors"][0]["source"]["points"]).replace(" ", "") assert packed in html.replace(" ", "") # whatever spacing json chose diff --git a/mne/viz/backends/tests/test_renderer.py b/mne/viz/backends/tests/test_renderer.py index 6c23c12158e..46e8fe9c6d5 100644 --- a/mne/viz/backends/tests/test_renderer.py +++ b/mne/viz/backends/tests/test_renderer.py @@ -5,6 +5,7 @@ import os import platform import sys +from contextlib import nullcontext import numpy as np import pytest @@ -18,6 +19,13 @@ from mne.viz.backends.renderer import _get_renderer +def _unsupported(renderer): + """Return a context for what the browser backend says it cannot draw.""" + if renderer.get_3d_backend() == "jupyterlite_notebook": + return pytest.raises(NotImplementedError, match="browser") + return nullcontext() + + @pytest.mark.parametrize( "backend", [ @@ -57,13 +65,14 @@ def test_3d_functions(renderer): distance=None, ) renderer.set_3d_title(figure=fig, title="foo") - renderer.backend._take_3d_screenshot(figure=fig) - assert len(fig.plotter.renderer.actors) > 0 + with _unsupported(renderer): + renderer.backend._take_3d_screenshot(figure=fig) + assert len(fig.plotter.actors) > 0 renderer.clear_3d_figure(fig) - assert len(fig.plotter.renderer.actors) == 0 + assert len(fig.plotter.actors) == 0 # the (empty) figure can be reused renderer.backend._Renderer(fig=fig).sphere(np.array([0.0, 0.0, 0.0]), "w", 1.0) - assert len(fig.plotter.renderer.actors) > 0 + assert len(fig.plotter.actors) > 0 renderer.close_3d_figure(fig) renderer.close_all_3d_figures() @@ -130,12 +139,11 @@ def test_3d_backend(renderer): rend.remove_mesh(mesh_data) # use contour - rend.contour( - surface=ct_surface, scalars=ct_scalars, contours=ct_levels, kind="line" - ) - rend.contour( - surface=ct_surface, scalars=ct_scalars, contours=ct_levels, kind="tube" - ) + for kind in ("line", "tube"): + with _unsupported(renderer): + rend.contour( + surface=ct_surface, scalars=ct_scalars, contours=ct_levels, kind=kind + ) # use sphere rend.sphere(center=sph_center, color=sph_color, scale=sph_scale, radius=1.0) @@ -159,17 +167,6 @@ def test_3d_backend(renderer): rend.quiver3d(mode="foo", **kwargs) # use instanced_mesh - # VTK must interpret our wxyz quaternions with the same convention as - # quat_to_rot, otherwise instanced sensors render with wrong rotations - from vtkmodules.vtkCommonCore import vtkMath - - from mne.viz.backends._pyvista import _quat_to_vtk_wxyz - - quat = np.array([0.1, -0.2, 0.3]) - mat = [[0.0] * 3 for _ in range(3)] - vtkMath.QuaternionToMatrix3x3(_quat_to_vtk_wxyz(quat[np.newaxis])[0], mat) - assert_allclose(mat, quat_to_rot(quat), atol=1e-12) - inst_positions = np.array([[0.0, 0.0, 0.0], [tet_size, 0.0, 0.0]]) inst_quats = np.array([rot_to_quat(np.eye(3)), rot_to_quat(np.eye(3))]) inst_colors = np.array([[1.0, 0.0, 0.0, 1.0], [0.0, 1.0, 0.0, 1.0]]) @@ -181,9 +178,12 @@ def test_3d_backend(renderer): colors=inst_colors, ) # colors can be updated in place (e.g. for future sensor - # highlighting/hover) without rebuilding the actor or its geometry - inst_cloud.point_data["colors"][0] = [0, 0, 255, 255] - inst_cloud.Modified() + # 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": + inst_cloud.point_data["colors"][0] = [0, 0, 255, 255] + inst_cloud.Modified() # use tube rend.tube(origin=np.array([[0, 0, 0]]), destination=np.array([[0, 1, 0]])) @@ -194,24 +194,27 @@ def test_3d_backend(renderer): ) # scalar bar - rend.scalarbar(source=tube, title="Scalar Bar", bgcolor=[1, 1, 1]) + with _unsupported(renderer): + rend.scalarbar(source=tube, title="Scalar Bar", bgcolor=[1, 1, 1]) # use text - rend.text2d( - x_window=txt_x, - y_window=txt_y, - text=txt_text, - size=txt_size, - justification="right", - ) + with _unsupported(renderer): + rend.text2d( + x_window=txt_x, + y_window=txt_y, + text=txt_text, + size=txt_size, + justification="right", + ) # test font_file passthrough with a real font from matplotlib font_path = findfont("serif") - rend.text2d( - x_window=txt_x + 0.1, - y_window=txt_y + 0.1, - text="font test", - font_file=font_path, - ) + with _unsupported(renderer): + rend.text2d( + x_window=txt_x + 0.1, + y_window=txt_y + 0.1, + text="font test", + font_file=font_path, + ) rend.text3d(x=0, y=0, z=0, text=txt_text, scale=1.0) rend.set_camera( azimuth=180.0, elevation=90.0, distance=cam_distance, focalpoint=center @@ -219,8 +222,23 @@ def test_3d_backend(renderer): rend.show() -def test_renderer_internal_helpers(renderer): +def test_quat_to_vtk_wxyz(): + """Test that VTK reads our quaternions the way quat_to_rot does. + + Otherwise instanced sensors render with wrong rotations. + """ + vtkMath = pytest.importorskip("vtkmodules.vtkCommonCore").vtkMath + from mne.viz.backends._pyvista import _quat_to_vtk_wxyz + + quat = np.array([0.1, -0.2, 0.3]) + mat = [[0.0] * 3 for _ in range(3)] + vtkMath.QuaternionToMatrix3x3(_quat_to_vtk_wxyz(quat[np.newaxis])[0], mat) + assert_allclose(mat, quat_to_rot(quat), atol=1e-12) + + +def test_renderer_internal_helpers(renderer_pyvistaqt): """Test internal helper methods used by mne.gui.coregistration.""" + renderer = renderer_pyvistaqt rend = renderer.create_3d_figure((300, 300), scene=False) # _remove_actors accepts a single actor or a list of actors diff --git a/mne/viz/tests/test_3d.py b/mne/viz/tests/test_3d.py index d9b23fd6bcd..7fa9ee2c546 100644 --- a/mne/viz/tests/test_3d.py +++ b/mne/viz/tests/test_3d.py @@ -185,6 +185,7 @@ def test_plot_evoked_field(renderer): """Test plotting evoked field.""" evoked = read_evokeds(evoked_fname, condition="Left Auditory", baseline=(-0.2, 0.0)) evoked.pick(evoked.ch_names[::10]) # speed + lite = renderer.get_3d_backend() == "jupyterlite_notebook" for t, n_contours, up in zip(["meg", None], [21, 0], [2, 1]): with pytest.warns(RuntimeWarning, match="projection"), catch_logging() as log: maps = make_field_map( @@ -203,8 +204,14 @@ def test_plot_evoked_field(renderer): assert "Upsampling" not in log else: assert "Upsampling" in log + if lite: # field maps need contours, which the browser cannot color + with pytest.raises(NotImplementedError, match="browser"): + evoked.plot_field(maps, time=0.1, n_contours=n_contours) + continue evoked.plot_field(maps, time=0.1, n_contours=n_contours) renderer.backend._close_all() + if lite: # and Brain needs the dock widgets the browser does not draw + return # Test plotting inside an existing Brain figure. Check that units are taken into # account. @@ -311,7 +318,7 @@ def test_plot_evoked_field_notebook(renderer_notebook, nbexec): def _assert_n_actors(fig, renderer, n_actors): __tracebackhide__ = True assert isinstance(fig, Figure3D) - assert len(fig.plotter.renderer.actors) == n_actors + assert len(fig.plotter.actors) == n_actors @pytest.mark.slowtest # can be slow on OSX @@ -546,7 +553,12 @@ def test_plot_alignment_meg(renderer, system): eeg=False, sensor_colors=dict(meg=rng.random((n_meg, 4))), ) - _assert_n_actors(fig2, renderer, n_shapes + 2) + # ... except in the browser, which cannot color per instance and so + # draws one solid mesh per distinct color + if renderer.get_3d_backend() == "jupyterlite_notebook": + _assert_n_actors(fig2, renderer, n_meg + 2) + else: + _assert_n_actors(fig2, renderer, n_shapes + 2) # check error raising for wrong meg value: info = read_info(evoked_fname) @@ -568,18 +580,16 @@ def test_plot_alignment_meg_coil_orientation(renderer, monkeypatch): so the per-instance quaternion must encode the full rotation from ``_loc_to_coil_trans``, not just the coil normal direction. """ - from mne.viz.backends._pyvista import _PyVistaRenderer - info = read_info(evoked_fname) info = pick_info(info, pick_types(info, meg="grad")[:4]) calls = list() - orig = _PyVistaRenderer.instanced_mesh + orig = renderer.backend._Renderer.instanced_mesh def capture(self, *args, **kwargs): calls.append((kwargs["positions"], kwargs["quats"])) return orig(self, *args, **kwargs) - monkeypatch.setattr(_PyVistaRenderer, "instanced_mesh", capture) + monkeypatch.setattr(renderer.backend._Renderer, "instanced_mesh", capture) plot_alignment(info, meg="sensors", coord_frame="meg") # all four grads share one coil shape, so they form a single instanced # actor whose instance order follows the channel order @@ -672,11 +682,12 @@ def test_plot_alignment_info(renderer, evoked): fig = plot_alignment(info) # works: surfaces='auto' default # set_view=False keeps the view of the figure it is given, True resets it set_3d_view(fig, azimuth=11, elevation=22, distance=0.33) - pos = np.array(fig.plotter.camera.position, float) + view = renderer.backend._Renderer(fig=fig).get_camera()[2:4] + assert_allclose(view, (11, 22), atol=1e-4) plot_alignment(info, fig=fig, set_view=False) - assert_allclose(fig.plotter.camera.position, pos, atol=1e-4) + assert_allclose(renderer.backend._Renderer(fig=fig).get_camera()[2:4], view) plot_alignment(info, fig=fig) - assert not np.allclose(fig.plotter.camera.position, pos, atol=1e-4) + assert not np.allclose(renderer.backend._Renderer(fig=fig).get_camera()[2:4], view) # check error raised if incorrect info provided with pytest.raises(TypeError, match="instance of Info"): plot_alignment("foo", trans_fname, subject="sample", subjects_dir=subjects_dir) From ad848f57cf7860c42f153f90b1acd4b27515bf66 Mon Sep 17 00:00:00 2001 From: Eric Larson Date: Thu, 3 Sep 2026 13:32:49 -0400 Subject: [PATCH 14/15] FIX: Trim now that its a renderer --- mne/tests/test_transforms.py | 17 +- mne/viz/backends/_lite.py | 919 +++++++----------------- mne/viz/backends/tests/test_lite.py | 626 ---------------- mne/viz/backends/tests/test_renderer.py | 174 ++++- 4 files changed, 432 insertions(+), 1304 deletions(-) delete mode 100644 mne/viz/backends/tests/test_lite.py diff --git a/mne/tests/test_transforms.py b/mne/tests/test_transforms.py index 8293f56701d..105c18b0975 100644 --- a/mne/tests/test_transforms.py +++ b/mne/tests/test_transforms.py @@ -386,26 +386,19 @@ def test_vector_rotation(): quat_1 = rot_to_quat(rot) quat_2 = rot_to_quat(np.eye(3)) assert_allclose(_angle_between_quats(quat_1, quat_2), np.pi / 2.0) - # many at once, including the parallel and antiparallel cases, which - # have no rotation axis of their own - rng = np.random.default_rng(0) - b = rng.normal(size=(50, 3)) - b /= np.linalg.norm(b, axis=1, keepdims=True) + # many at once, including the parallel, antiparallel and nearly antiparallel + # cases, the first two of which have no rotation axis of their own + b = np.random.default_rng(0).normal(size=(20, 3)) b = np.concatenate([b, [x, -x, [-1, 1e-8, 0]]]) b /= np.linalg.norm(b, axis=1, keepdims=True) rots = _find_vector_rotation(x, b) - assert rots.shape == (len(b), 3, 3) assert_allclose(rots @ x, b, atol=1e-12) - assert_allclose(np.linalg.det(rots), 1.0, atol=1e-12) eye = np.broadcast_to(np.eye(3), rots.shape) assert_allclose(rots @ rots.transpose(0, 2, 1), eye, atol=1e-12) - assert_allclose(rots[-3], np.eye(3), atol=1e-12) assert_allclose(rots[-2], np.diag([-1, -1, 1]), atol=1e-12) - # each is the minimal rotation, so its axis is perpendicular to both - for rot, this_b in zip(rots[:50], b[:50]): - assert_allclose(rot, _find_vector_rotation(x, this_b)) + for rot, this_b in zip(rots[:3], b): # each is the minimal rotation angle = _angle_between_quats(rot_to_quat(rot), np.zeros(3)) - assert_allclose(angle, np.arccos(np.dot(x, this_b))) + assert_allclose(angle, np.arccos(x @ this_b)) def test_average_quats(): diff --git a/mne/viz/backends/_lite.py b/mne/viz/backends/_lite.py index bcf4d6492a6..6d5309834b2 100644 --- a/mne/viz/backends/_lite.py +++ b/mne/viz/backends/_lite.py @@ -1,32 +1,14 @@ -""" -A pyvista-js drawing backend for MNE's 3D renderer. - -MNE's 3D functions (``plot_alignment``, ``plot_sparse_source_estimates``, -``SourceSpaces.plot``, ...) all build their figure the same way: they do their -own geometry and coordinate-frame work in numpy, then hand the result to a -renderer obtained from :func:`mne.viz.backends.renderer._get_renderer`. Only -that last step needs VTK, and VTK cannot load in WebAssembly. - -Rather than reimplement those functions one by one, this module supplies a -renderer that draws with `pyvista-js `__ -(vtk.js). It is a 3D backend like ``_qt`` and ``_notebook`` are, selected with -``mne.viz.set_3d_backend("jupyterlite_notebook")``, which is all a browser kernel has to -do. MNE keeps doing all of the transform math itself, which matters because -getting a head/MRI/device transform subtly wrong produces a plausible-looking -picture with the sensors in the wrong place. +"""A pyvista-js (vtk.js) drawing backend for MNE's 3D renderer. + +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")``. Supported: meshes, surfaces, spheres, tubes and glyphs, which covers the static -figures the documentation renders. Not supported: the interactive -:class:`mne.viz.Brain` time viewer, which additionally needs dock widgets and a -time slider, and scalar colormaps: ``Plotter.add_mesh`` takes ``scalars`` and -``cmap`` and writes them into the scene, but the vtk.js template it renders -through builds no lookup table and never reads them, so a mesh carrying -scalars draws in a solid color. Figure size is fixed too: pyvista-js writes a -600x400 canvas and offers no way to change it, so the ``size`` MNE asks for has -no effect. - -Importing this module needs pyvista-js, the same way importing ``_pyvista`` -needs VTK. +figures. Not supported: :class:`mne.viz.Brain` (needs dock widgets), scalars, +colormaps and contours (the vtk.js template builds no lookup table, so every +mesh is one solid color), and figure size (pyvista-js writes a 600x400 canvas). """ # Authors: The MNE-Python contributors. @@ -51,57 +33,31 @@ from ._abstract import Figure3D, _AbstractRenderer from ._utils import ALLOWED_QUIVER_MODES, _vtk_faces -# vtk.js positions text in normalized window coordinates, where PyVista takes -# the names MNE's set_3d_title passes through +# vtk.js places text in normalized window coordinates; PyVista takes these names _TITLE_POSITIONS = { "lower_left": (0.05, 0.05), "lower_right": (0.65, 0.05), "upper_left": (0.05, 0.90), "upper_right": (0.65, 0.90), - "lower_edge": (0.35, 0.05), - "upper_edge": (0.35, 0.90), - "left_edge": (0.05, 0.50), - "right_edge": (0.65, 0.50), } - -# what MNE means by ``color=None``: "whatever the renderer draws by default", -# which for PyVista is resolved inside add_mesh and has to be picked here -_DEFAULT_COLOR = (0.5, 0.5, 0.5) - - -def _lite_n_side(resolution): - """Return the side count to build a cone or cylinder with. - - Callers give the side count VTK would use; take half of it, because - ``_tile`` stamps the template at every sensor and the side count multiplies - straight into the WASM heap, and eight sides is smooth enough at the size - these draw. Three is the fewest that still closes a ring, and eight is the - default for callers that name no resolution at all. - """ - return 8 if resolution is None else max(3, int(resolution) // 2) - - -def _lite_ring(n_side, radius): - """Return one ``n_side`` circle of radius ``radius``, in the x=0 plane.""" - angles = np.linspace(0.0, 2 * np.pi, n_side, endpoint=False) - return np.column_stack( - [np.zeros(n_side), radius * np.cos(angles), radius * np.sin(angles)] - ) +_DEFAULT_COLOR = (0.5, 0.5, 0.5) # what color=None draws as +# 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 +_LITE_MAX_LIVE_SCENES = 2 +_lite_live_plotters = [] # weakrefs, so scenes stay collectable def _rgb(color): - """Return an (r, g, b) 0-1 tuple, the only color form pyvista-js takes. - - Its own parser knows fourteen color names and rejects hex strings, so do - the conversion here. ``to_rgb`` covers every form MNE hands a renderer -- - color names, hex, ``"C0"`` and 0-1 ``(r, g, b[, a])`` sequences -- and - raises on anything else, which is what should happen. - """ + """Return the (r, g, b) tuple pyvista-js takes; it cannot parse hex itself.""" return _DEFAULT_COLOR if color is None else to_rgb(color) +def _lite_unsupported(what): + raise NotImplementedError(f"{what} is not supported in the browser.") + + def _lite_add_text(plotter, text, position, size, color): - """Add a vtk.js 2D text actor and return it, the way MNE's text2d does.""" actor = pv.Text(str(text), position=tuple(float(coord) for coord in position)) actor.prop.font_size = int(size) actor.prop.color = _rgb(color) @@ -110,131 +66,57 @@ def _lite_add_text(plotter, text, position, size, color): def _lite_view_angles(plotter): - """Return the (azimuth, elevation) a plotter looks from, in degrees, or None. - - ``view_vector`` stores the camera position, as a direction from the focal - point, on the plotter's renderer and leaves ``Plotter.camera`` unset, - which is deliberate: the vtk.js side then calls ``resetCamera()`` and - frames the scene, whereas a camera object would have to carry a distance, - and MNE asks for ``distance=None`` more often than not. The cost is that - ``Plotter.camera_position`` reads that unset camera and so stays ``None``; - the direction below is the scene's actual camera state, and inverting it - recovers the angles :func:`_lite_set_view` applied. + """Return the (azimuth, elevation) in degrees the plotter 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 + object, which would need the distance that MNE mostly passes as None. """ view_vector = plotter._renderer._view_vector # pyvista-js 0.15 - if view_vector is None: # no view set yet, so vtk.js is choosing one + if view_vector is None: # nothing set yet, so vtk.js chooses return None _, phi, theta = _cart_to_sph(np.asarray(view_vector, float)[np.newaxis])[0] return float(np.rad2deg(phi)) % 360, float(np.rad2deg(theta)) % 180 def _lite_set_view(plotter, azimuth=None, elevation=None): - """Point a plotter along the requested azimuth and elevation. - - Giving one angle leaves the other where it is, as ``_pyvista._set_3d_view`` - does; before any view is set it defaults to 90 degrees, the anterior view - ``plot_alignment`` ends on. - """ + """Point the plotter, keeping the angle not given as _pyvista._set_3d_view does.""" if azimuth is None and elevation is None: - return None - current = _lite_view_angles(plotter) or (90.0, 90.0) + return + current = _lite_view_angles(plotter) or (90.0, 90.0) # plot_alignment's view 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 the 5/175 threshold _set_3d_view - # uses, because there the view plane normal runs parallel to the camera - if elevation is None or 5.0 <= abs(elevation) <= 175.0: - viewup = (0.0, 0.0, 1.0) - else: - viewup = (0.0, 1.0, 0.0) - # the vector is the camera position: the vtk.js side sets it, aims the - # camera at the origin and then frames the scene with resetCamera(), so a - # unit direction from the focal point is exactly what it needs - position = _sph_to_cart(np.array([[1.0, phi, theta]]))[0] - plotter.view_vector(tuple(position), viewup=viewup) - return None + # view up flips near the poles, matching _set_3d_view + up = ( + (0.0, 0.0, 1.0) + if elevation is None or 5 <= abs(elevation) <= 175 + else (0.0, 1.0, 0.0) + ) + plotter.view_vector( + tuple(_sph_to_cart(np.array([[1.0, phi, theta]]))[0]), viewup=up + ) def _lite_get_view(plotter): - """Return the camera state, ordered the way _get_3d_view orders it.""" - angles = _lite_view_angles(plotter) - if angles is None: - return (0.0, 1.0, 0.0, 0.0, np.zeros(3)) - # roll is 0 because the only camera roll _lite_set_view applies is the view - # up flip at the poles; the distance and focalpoint are the ones the vtk.js - # resetCamera() gives this path, a unit direction aimed at the origin - return (0.0, 1.0, angles[0], angles[1], np.zeros(3)) - - -# Every scene a notebook drew used to stay live for the kernel's lifetime, -# because the close helpers were no-ops. Track the plotters weakly -- so they -# stay collectable -- and give close_all something to free. -_lite_live_plotters = [] - - -def _lite_instance_cloud(positions): - """Return the per-instance point cloud ``instanced_mesh`` hands back. - - ``_PyVistaRenderer`` glyphs its template over a ``PolyData`` of the instance - positions and returns that object, and :mod:`mne.viz._3d` hangs channel - names off its ``field_data`` (gh-13074). vtk.js has no equivalent, and a - pyvista-js ``PolyData`` carries no ``field_data`` of its own, so build the - same cloud here and give it the mapping. Nothing in the browser reads it - back: the one reader is the dipole-fit GUI, which needs a picker vtk.js - does not provide. - """ - cloud = pv.PolyData(points=np.asarray(positions, dtype=float).reshape(-1, 3)) - cloud.field_data = dict() - return cloud + """Return (roll, distance, azimuth, elevation, focalpoint) as _get_3d_view does.""" + azimuth, elevation = _lite_view_angles(plotter) or (0.0, 0.0) + return (0.0, 1.0, azimuth, elevation, np.zeros(3)) def _lite_release_plotter(plotter): - """Hand back a plotter's meshes, JS arrays and GPU buffers. - - ``clear()`` empties the actor list, which is where the geometry is held, - so that is what frees the memory, and it is the only teardown pyvista-js - 0.15 offers: there is no ``close()`` to tear the render window down as - well, which is why closing a figure and clearing one do the same thing - here. Nothing here holds a reference cycle, so dropping the actors is - enough, and a ``gc.collect()`` on top would only cost time. - """ - if plotter is None: - return None - for idx in range(len(_lite_live_plotters) - 1, -1, -1): - live = _lite_live_plotters[idx]() - if live is None or live is plotter: - del _lite_live_plotters[idx] - plotter.clear() - return None - - -# Each live scene holds its meshes in the WASM heap, a copy of them in JS and -# a set of GPU buffers. Nothing in a notebook calls close_3d_figure, so without -# a cap they all stay: 20_source_alignment builds six, which is enough to run -# the tab out of memory. Keep the newest few and give the rest their geometry -# back as new ones arrive -- scrolling back shows an empty canvas, which is a -# far better outcome than losing the page. -_LITE_MAX_LIVE_SCENES = 2 - - -def _lite_trim_live_plotters(): - """Release everything but the most recent scenes.""" - while len(_lite_live_plotters) > _LITE_MAX_LIVE_SCENES: - oldest = _lite_live_plotters[0]() - if oldest is None: - _lite_live_plotters.pop(0) - else: - # also drops it from the registry, so this terminates - _lite_release_plotter(oldest) - return None + """Drop a plotter's geometry and forget it; clear() is all the teardown there is.""" + _lite_live_plotters[:] = [ + ref for ref in _lite_live_plotters if ref() not in (None, plotter) + ] + if plotter is not None: + plotter.clear() def _lite_opacity(color, opacity): - """Fold the alpha of an RGBA color into the opacity. + """Fold RGBA alpha into the opacity, since each mesh here is one solid color. - ``_PyVistaRenderer`` hands instance colors to VTK as RGBA scalars, so the - alpha column is what makes MEG coils translucent (``sensor_alpha``); vtk.js - draws each mesh in one solid color and ``to_rgb`` drops the alpha, so the - group's alpha has to become its opacity instead. + _PyVistaRenderer draws instance colors as RGBA scalars, and the alpha is + how plot_alignment fades the MEG coils. """ opacity = 1.0 if opacity is None else float(opacity) if not isinstance(color, str) and np.shape(color) == (4,): @@ -242,37 +124,45 @@ def _lite_opacity(color, opacity): return opacity -class _LiteFigure(Figure3D): - """pyvista-js-based 3D figure, the object MNE's 3D functions hand back. +def _lite_revolve(profile, n_side): + """Return (rr, tris) of rings of ``radius`` at ``x`` about +x, joined and capped.""" + angles = np.linspace(0.0, 2 * np.pi, n_side, endpoint=False) + rr = [ + np.column_stack([np.full(n_side, x), r * np.cos(angles), r * np.sin(angles)]) + for x, r in profile + ] + idx = np.arange(n_side) + nxt = (idx + 1) % n_side + tris = [] + for a in range(0, (len(profile) - 1) * n_side, n_side): + b = a + n_side + tris += [np.c_[a + idx, a + nxt, b + nxt], np.c_[a + idx, b + nxt, b + idx]] + n_points = len(profile) * n_side + ends = ((0, profile[0], True), (n_points - n_side, profile[-1], False)) + for ring, (x, r), flip in ends: + if r > 0: # cap it, wound to face outward + rr.append([[x, 0.0, 0.0]]) + cap = np.c_[np.full(n_side, n_points), ring + idx, ring + nxt] + tris.append(cap[:, ::-1] if flip else cap) + n_points += 1 + return np.vstack(rr), np.vstack(tris).astype(int) - It carries the pyvista-js plotter as ``.plotter``, the way - ``PyVistaFigure`` carries PyVista's, so the module-level helpers - (``set_3d_view``, ``close_3d_figure``, ...) and ``isinstance(fig, - Figure3D)`` checks treat both backends alike. - """ + +class _LiteFigure(Figure3D): + """pyvista-js-based 3D figure; ``.plotter`` is the pyvista-js plotter.""" def __init__(self): pass def _init(self, plotter): - self._plotter = plotter # read through Figure3D.plotter + self._plotter = plotter return self -# figures made with an integer handle, so that create_3d_figure(handle=...) -# draws into the same scene again, as _pyvista._FIGURES does -_lite_figures = dict() - - class _LiteRenderer(_AbstractRenderer): - """Minimal MNE 3D renderer backed by pyvista-js.""" - - # Its own kind rather than "notebook": the desktop notebook backend shares - # a kernel with the page but still has VTK, a filesystem and OS threads, - # and none of those are here. The one place that would care, - # mne/gui/_coreg.py, never reaches its `_kind != "notebook"` branch in the - # browser, because _configure_dock asks the renderer for a dock and toolbar - # API this one does not implement and fails first. + """MNE 3D renderer backed by pyvista-js.""" + + # not "notebook": that backend has VTK, a filesystem and OS threads _kind = "jupyterlite_notebook" def __init__( @@ -289,157 +179,100 @@ def __init__( splash=False, multi_samples=None, ): - # The signature is _PyVistaRenderer's, so every _get_renderer call - # binds the same way, but most of it cannot be honoured: pv.Plotter - # takes only a lighting mode and generate_standalone_html emits a - # fixed 600x400 canvas, so `size` and `shape` have no effect; there is - # no window to give `name` to; and `show` means nothing before anything - # is drawn, because the canvas is written when show() runs rather than - # kept live. - # - # plot_alignment(fig=...) and plot_dipole_locations(fig=...) composite - # into a scene the notebook already made, so draw into that figure - # rather than opening a second one and splitting the picture in two. - # An int is a handle naming a figure to make now and reuse later. - _validate_type(fig, (None, int, _LiteFigure), "fig") - handle = fig if isinstance(fig, int) else None - if handle is not None: - fig = _lite_figures.get(handle) - if fig is not None: + # _PyVistaRenderer's signature, but size, shape, name and show cannot + # be honored: the canvas is fixed and written only when show() runs + _validate_type(fig, (None, _LiteFigure), "fig") + if fig is not None: # plot_alignment(fig=...) composites into it self._figure = fig return self._figure = _LiteFigure()._init(pv.Plotter()) - if handle is not None: - _lite_figures[handle] = self._figure _lite_live_plotters.append(weakref.ref(self.plotter)) - # trim after appending, so the new scene counts towards the cap and - # _LITE_MAX_LIVE_SCENES is the number that actually stays live - _lite_trim_live_plotters() + while len(_lite_live_plotters) > _LITE_MAX_LIVE_SCENES: + _lite_release_plotter(_lite_live_plotters[0]()) self.plotter.background_color = _rgb(bgcolor) - # A scene light in vtk.js lights only what faces it, so a single one - # leaves half of a head dark as soon as it is turned. Six along the axes - # cover every side; each is well under full intensity because a surface - # facing two of them at once would otherwise blow out. The distance only - # has to sit outside the scene, which is metres-scale here. - for direction in ( - (1, 0, 0), - (-1, 0, 0), - (0, 1, 0), - (0, -1, 0), - (0, 0, 1), - (0, 0, -1), - ): + # one light per axis direction, since a vtk.js light only lights what + # faces it, each dim enough that a surface facing two does not blow out + for position in np.vstack([np.eye(3), -np.eye(3)]) * 300.0: self.plotter.add_light( pv.Light( - position=tuple(300.0 * coord for coord in direction), - focal_point=(0.0, 0.0, 0.0), - intensity=0.4, + position=tuple(position), focal_point=(0.0,) * 3, intensity=0.4 ) ) - # -- helpers ------------------------------------------------------------ @property def plotter(self): - """The pyvista-js plotter the figure draws into.""" return self._figure.plotter - def _glyph_template( - self, kind, radius=None, height=None, center=None, resolution=None, **kwargs - ): - """Return (rr, tris) for a glyph template, oriented along +x. + @property + def figure(self): + return self._figure # 20_source_alignment passes this to set_3d_view - pyvista-js's Sphere/Cylinder are parametric primitives with no - triangle list, so the templates come from MNE's own tessellation or are - built here. ``_tile`` then stamps one of these at every position and - merges the result, which is what keeps these cheap -- the copies share - a single mesh and a single actor. + def scene(self): + return self._figure - Sizes follow the templates ``_pyvista.py`` hands the glyph filter, so - the browser draws the markers at the size the rendered docs do. - """ + def show(self): + self.plotter.show() + + # -- geometry ----------------------------------------------------------- + def _glyph_template( + self, kind, radius=None, height=None, center=None, resolution=None + ): + """Return (rr, tris) of a glyph along +x, sized like _pyvista.py's templates.""" + # half of VTK's side count: these get stamped at every sensor + n_side = 8 if resolution is None else max(3, int(resolution) // 2) if kind in ("sphere", "oct"): - scale = 0.5 if radius is None else float(radius) - # "oct" is an octahedron on purpose -- that is what _pyvista.py - # hands the glyph filter, and level 1 is the unit octahedron - # vtkPlatonicSolidSource draws. A "sphere" has to look round, - # though: fiducials and dig points are drawn with it, so level 3 - # subdivides that octahedron onto the unit sphere at 66 vertices, - # near the reference's 8x8 sphere (58). + # level 1 is the octahedron vtkPlatonicSolidSource draws; level 3 + # is round enough for dig points at 66 vertices rr, tris = _tessellate_sphere(1 if kind == "oct" else 3) - return rr * scale, tris - if kind == "arrow": - # vtkArrowSource, which is what _pyvista.py glyphs mode="arrow" - # with: a cylindrical shaft carrying a cone tip, together spanning - # 0 to 1 along +x. Its defaults, not glyph_radius, set the widths. - # both halves are placed here rather than through the cylinder's - # `center`, which is read in _cylinder_geom's pre-rotation frame - shaft_rr, shaft_tris = self._glyph_template( - "cylinder", radius=0.03, height=0.65, resolution=12 - ) - shaft_rr = shaft_rr + np.array([0.325, 0, 0]) - tip_rr, tip_tris = self._glyph_template( - "cone", radius=0.1, height=0.35, resolution=12 + return rr * (0.5 if radius is None else radius), tris + if kind == "arrow": # vtkArrowSource: 0.03 shaft under a 0.1 tip from 0.65 + return _lite_revolve([(0, 0.03), (0.65, 0.03), (0.65, 0.1), (1, 0)], 12) + height = 1.0 if height is None else float(height) + if kind == "cone": # base at the position, apex along +x + return _lite_revolve( + [(0, 0.15 if radius is None else radius), (height, 0)], n_side ) - tip_rr = tip_rr + np.array([0.65, 0, 0]) - return ( - np.vstack([shaft_rr, tip_rr]), - np.vstack([shaft_tris, tip_tris + len(shaft_rr)]), - ) - if kind == "cone": - # apex along +x so the glyph filter's orientation applies, matching - # pyvista.Cone(center=(0.5, 0, 0)): base at x=0, apex at x=height - rad = 0.15 if radius is None else float(radius) - hgt = 1.0 if height is None else float(height) - n_side = _lite_n_side(resolution) - rr = np.vstack([_lite_ring(n_side, rad), [[hgt, 0, 0]], [[0.0, 0, 0]]]) - tris = [] - for this in range(n_side): - nxt = (this + 1) % n_side - tris += [[this, nxt, n_side], [n_side + 1, nxt, this]] # side, base - return rr, np.asarray(tris, int) - # cylinder along +x, matching _cylinder_geom's convention - rad = 0.1 if radius is None else float(radius) - hgt = 1.0 if height is None else float(height) - n_side = _lite_n_side(resolution) - # _cylinder_geom builds the cylinder along y and turns it 90 degrees - # about z to point it along x, which carries the center round with it: - # (cx, cy, cz) lands at (-cy, cx, cz). _3d.py gives the EEG electrode - # offset in that pre-turn frame, so turn it here too, or the cylinders - # sit beside their sensors instead of standing on them. - if center is None: - offset = np.zeros(3) - else: - center = np.asarray(center, dtype=float) - offset = np.array([-center[1], center[0], center[2]]) - ring = _lite_ring(n_side, rad) - back = ring + np.array([-hgt / 2.0, 0, 0]) - front = ring + np.array([hgt / 2.0, 0, 0]) - rr = ( - np.vstack([back, front, [[-hgt / 2.0, 0, 0]], [[hgt / 2.0, 0, 0]]]) + offset - ) - tris = [] - for this in range(n_side): - nxt = (this + 1) % n_side - tris += [[this, nxt, n_side + nxt], [this, n_side + nxt, n_side + this]] - tris += [[2 * n_side, nxt, this]] # back cap - tris += [[2 * n_side + 1, n_side + this, n_side + nxt]] # front cap - return rr, np.asarray(tris, int) + assert kind == "cylinder", kind + radius = 0.1 if radius is None else float(radius) + rr, tris = _lite_revolve([(-height / 2, radius), (height / 2, radius)], n_side) + if center is not None: + # _cylinder_geom builds along y and turns 90 degrees about z, so the + # (EEG electrode) offset it is given lands at (-cy, cx, cz) + rr = rr + np.array([-center[1], center[0], center[2]], float) + return rr, tris - def _add(self, points, tris, color, opacity=1.0): - """Draw a mesh and return MNE's (actor, mesh) pair. + def _tile(self, rr, tris, positions, scales=None, rots=None, axis_scales=None): + """Stamp a template at every position into one mesh, as VTK's glyph filter does. - ``opacity=None`` means "renderer default" in MNE's renderer API, which - for PyVista reaches ``add_mesh(opacity=None)`` and draws opaque. Every - drawing method here funnels through this, so translating it once covers - all of them. + One mesh per position runs the tab out of memory for an oct-6 source + space. ``axis_scales`` stretches the template along x alone (tubes). """ - # float32 halves what the merged glyph meshes cost in the WASM heap, - # and vtk.js uses single precision on the GPU regardless. The faces go - # over flat: pyvista-js serialises them as given, and vtk.js reads the - # result as one VTK cell array, not as rows. + rr = np.asarray(rr, float) + positions = np.atleast_2d(np.asarray(positions, float))[:, :3] + n_pos = len(positions) + idx = np.arange(n_pos) + points = np.repeat(rr[np.newaxis], n_pos, axis=0) + if axis_scales is not None: + axis_scales = np.atleast_1d(np.asarray(axis_scales, float)) + points[:, :, 0] *= axis_scales[idx % len(axis_scales)][:, np.newaxis] + if scales is not None: + scales = np.atleast_1d(np.asarray(scales, float)) + points *= scales[idx % len(scales)][:, np.newaxis, np.newaxis] + if rots is not None: + points = np.einsum( + "nij,nkj->nki", np.asarray(rots)[idx % len(rots)], points + ) + points += positions[:, np.newaxis] + offsets = (idx * len(rr))[:, np.newaxis, np.newaxis] + tris = np.asarray(tris, int)[np.newaxis] + offsets + return points.reshape(-1, 3), tris.reshape(-1, 3) + + def _add(self, points, tris, color, opacity=1.0): + """Draw one solid-color mesh and return MNE's (actor, mesh) pair.""" + # float32 halves the WASM cost, and vtk.js is single precision anyway; + # the faces go over flat because vtk.js reads one VTK cell array mesh = pv.PolyData( - points=np.asarray(points, dtype=np.float32), - faces=_vtk_faces(tris).ravel(), + points=np.asarray(points, np.float32), faces=_vtk_faces(tris).ravel() ) actor = self.plotter.add_mesh( mesh, @@ -449,56 +282,9 @@ def _add(self, points, tris, color, opacity=1.0): ) return actor, mesh - def _rots_from_dirs(self, dirs): - """Rotations carrying +x onto each direction, as the glyphs assume.""" - return _find_vector_rotation(np.array([1.0, 0.0, 0.0]), dirs) - - def _tile(self, rr, tris, positions, scales=None, rots=None, axis_scales=None): - """Stamp one template mesh at many positions as a single mesh. - - ``_pyvista.py`` hands its template to VTK's glyph filter, which bakes - every copy into one mesh and adds it once. Doing this per position - instead means an oct-6 source space becomes 8196 meshes and 8196 - actors, which is enough to run the browser tab out of memory. - - This is the shared step behind every glyph method here, including - :meth:`instanced_mesh`; it takes rotation matrices because that is what - ``quiver3d`` and ``tube`` already have, and ``instanced_mesh`` converts - its quaternions once on the way in. - """ - rr = np.asarray(rr, dtype=float) - tris = np.asarray(tris, dtype=int) - positions = np.atleast_2d(np.asarray(positions, dtype=float))[:, :3] - n_pos = len(positions) - points = np.repeat(rr[None, :, :], n_pos, axis=0) - if axis_scales is not None: - # tubes span a given length without fattening, so scale the - # template's axis alone - axis_scales = np.atleast_1d(np.asarray(axis_scales, dtype=float)) - points[:, :, 0] *= axis_scales[np.arange(n_pos) % len(axis_scales)][:, None] - if scales is not None: - scales = np.atleast_1d(np.asarray(scales, dtype=float)) - points *= scales[np.arange(n_pos) % len(scales)][:, None, None] - if rots is not None: - rots = np.asarray(rots, dtype=float) - points = np.einsum( - "nij,nkj->nki", rots[np.arange(n_pos) % len(rots)], points - ) - points += positions[:, None, :] - offsets = (np.arange(n_pos) * len(rr))[:, None, None] - return (points.reshape(-1, 3), (tris[None, :, :] + offsets).reshape(-1, 3)) - # -- drawing ------------------------------------------------------------ - # Three arguments below are named rather than swallowed by **kwargs, because - # MNE passes them on the paths the docs render and vtk.js cannot honour any - # of them: it has no backface culling (its actor style only covers - # representation, shading and edges), it recomputes normals itself with - # vtkPolyDataNormals rather than taking an array, and it has no actor - # registry to look a `name` up in later. The visible cost is that a - # transparent surface shows its own inside. Scalars and colormaps are - # accepted and ignored for the reason given at the top of the module. The - # signatures otherwise follow _PyVistaRenderer's, so that a positional - # call binds the same argument on both backends. + # The signatures follow _PyVistaRenderer's so positional calls bind alike; + # scalars, colormaps, culling, normals and names are accepted and ignored. def mesh( self, x, @@ -520,9 +306,7 @@ def mesh( name=None, **kwargs, ): - points = np.column_stack( - [np.asarray(x).ravel(), np.asarray(y).ravel(), np.asarray(z).ravel()] - ) + points = np.column_stack([np.ravel(x), np.ravel(y), np.ravel(z)]) return self._add(points, triangles, color, opacity) def surface( @@ -551,21 +335,13 @@ def sphere( backface_culling=False, radius=None, ): - # `resolution` has no equivalent here: _pyvista.py asks pyvista.Sphere - # for that many theta and phi bands, while this template comes from a - # subdivided octahedron, whose vertex count goes 6, 18, 66, 258. Level 3 - # is the one that lands near the default 8x8 sphere, and nothing in - # mne/viz asks for another, so it is fixed rather than approximated. - center = np.atleast_2d(np.asarray(center, dtype=float)) + # _pyvista.py glyphs a radius-0.5 sphere by `scale`, or `radius` by 1 + center = np.atleast_2d(np.asarray(center, float)) if not len(center): return None, None - # _pyvista.py glyphs a radius-0.5 sphere by `scale`, or a - # radius-`radius` sphere by 1, so the drawn radius is half of `scale` rr, tris = self._glyph_template( - "sphere", radius=0.5 * float(scale) if radius is None else float(radius) + "sphere", radius=0.5 * scale if radius is None else radius ) - # one template stamped at every center, which is exactly instanced_mesh - # without orientations or per-instance colors return self.instanced_mesh(rr, tris, center, colors=color, opacity=opacity) def tube( @@ -582,34 +358,19 @@ def tube( reverse_lut=False, opacity=None, ): - # `color` defaults to white as _PyVistaRenderer's does, and not to - # _DEFAULT_COLOR: plot_alignment draws the fNIRS source-detector pairs - # with no color on a (0.5, 0.5, 0.5) background, which is that gray - origin = np.atleast_2d(np.asarray(origin, dtype=float))[:, :3] - destination = np.atleast_2d(np.asarray(destination, dtype=float))[:, :3] - n_seg = min(len(origin), len(destination)) - if not n_seg: - return None, None - vec = destination[:n_seg] - origin[:n_seg] + origin = np.atleast_2d(np.asarray(origin, float))[:, :3] + destination = np.atleast_2d(np.asarray(destination, float))[:, :3] + vec = destination - origin length = np.linalg.norm(vec, axis=1) keep = length > 0 if not keep.any(): return None, None - vec, length = vec[keep], length[keep] - centers = (origin[:n_seg][keep] + destination[:n_seg][keep]) / 2.0 - # one unit-height template stretched to each segment, merged into a - # single mesh rather than a cylinder primitive per segment. This cannot - # go through instanced_mesh: the stretch is along the template's axis - # only, and instanced_mesh scales every instance isotropically. - rr, tris = self._glyph_template( - "cylinder", radius=float(radius), height=1.0, resolution=20 - ) # 20 is _PyVistaRenderer.tube_n_sides, halved on the way in + vec, length, origin = vec[keep], length[keep], origin[keep] + # a unit cylinder stretched along its axis to each segment + rr, tris = self._glyph_template("cylinder", radius=radius, resolution=20) + rots = _find_vector_rotation(np.array([1.0, 0, 0]), vec / length[:, np.newaxis]) points, faces = self._tile( - rr, - tris, - centers, - rots=self._rots_from_dirs(vec / length[:, None]), - axis_scales=length, + rr, tris, origin + vec / 2, rots=rots, axis_scales=length ) return self._add(points, faces, color, opacity) @@ -637,90 +398,53 @@ def quiver3d( solid_transform=None, clim=None, ): - """Draw one merged glyph mesh, the way the glyph filter would. - - ``_pyvista.py`` builds a template, lets VTK's glyph filter bake a copy - at every point into one mesh, and adds that once. Drawing a primitive - per point instead is what made ``20_source_alignment`` -- an oct-6 - source space, so 8196 glyphs, twice -- exhaust the browser tab. - """ - x, y, z = (np.atleast_1d(np.asarray(q, dtype=float)) for q in (x, y, z)) - centers = np.column_stack([x, y, z]) - n_pos = len(centers) + _check_option("mode", mode, ALLOWED_QUIVER_MODES) + _check_option("scale_mode", scale_mode, ("none", "scalar", "vector")) + x, y, z, u, v, w = (np.ravel(np.asarray(q, float)) for q in (x, y, z, u, v, w)) + n_pos = len(x) if not n_pos: return None, None - # MNE always passes a scalar here; VTK's SetScaleFactor takes one too - factor = float(scale) idx = np.arange(n_pos) - u, v, w = (np.atleast_1d(np.asarray(q, dtype=float)) for q in (u, v, w)) - dirs = np.column_stack([u[idx % len(u)], v[idx % len(v)], w[idx % len(w)]]) + dirs = np.column_stack([q[idx % len(q)] for q in (u, v, w)]) norms = np.linalg.norm(dirs, axis=1) - flat = norms == 0 - dirs[flat] = (1.0, 0.0, 0.0) - dirs = dirs / np.where(flat, 1.0, norms)[:, None] - # per-glyph size, matching what _pyvista.py hands the glyph filter: it - # sends "arrow" through _glyph, whose own default scales by the scalars, - # and "2darrow" through _arrow_glyph, which scales by the vector; every - # other mode gets whatever scale_mode asks for. plot_alignment's - # show_axes draws its three axis arrows at 1/3, 2/3 and full length this - # way, so ignoring it would make the coordinate frames wrong. - _check_option("mode", mode, ALLOWED_QUIVER_MODES) - _check_option("scale_mode", scale_mode, ("none", "scalar", "vector")) - if mode == "arrow": - scale_mode = "scalar" - elif mode == "2darrow": - scale_mode = "vector" + dirs[norms == 0] = (1.0, 0.0, 0.0) + dirs /= np.where(norms == 0, 1.0, norms)[:, np.newaxis] + # _pyvista.py sizes "arrow" by the scalars (plot_alignment's axes rely + # on it) and "2darrow" by the vector, whatever scale_mode says + scale_mode = {"arrow": "scalar", "2darrow": "vector"}.get(mode, scale_mode) if scale_mode == "scalar": - values = ( - np.ones(n_pos) - if scalars is None - else np.atleast_1d(np.asarray(scalars, dtype=float)).ravel() - ) - sizes = factor * values[idx % len(values)] - elif scale_mode == "vector": - sizes = factor * norms + values = np.ones(n_pos) if scalars is None else np.ravel(scalars) + sizes = scale * np.asarray(values, float)[idx % len(values)] else: - sizes = factor - # the same templates _pyvista.py feeds the filter; `scale` then plays - # the part its `factor` does - if mode == "oct": - # vtkPlatonicSolidSource puts its octahedron on the unit - # circumsphere, and the MRI fiducials get their real size from - # solid_transform (mri_fid_scale, 5 mm) rather than from `scale` - kind, template_kw = "oct", dict(radius=1.0) - elif mode == "sphere": - kind, template_kw = "sphere", dict(radius=0.5) - elif mode == "cylinder": - kind = "cylinder" - template_kw = dict( + sizes = scale * norms if scale_mode == "vector" else float(scale) + # the templates _pyvista.py feeds the glyph filter, with `scale` as its + # factor; the unit "oct" is sized by solid_transform (MRI fiducials), + # and "2darrow" borrows the 3D arrow since vtk.js has no 2D glyphs + template_kw = dict( + oct=dict(radius=1.0), + sphere=dict(radius=0.5), + cylinder=dict( radius=glyph_radius, height=glyph_height, center=glyph_center, resolution=glyph_resolution, - ) - elif mode == "cone": - kind = "cone" - template_kw = dict( + ), + cone=dict( radius=glyph_radius, height=glyph_height, resolution=glyph_resolution - ) - else: - # "arrow" is vtkArrowSource, a shaft with a cone tip. "2darrow" is - # really vtkGlyphSource2D with FilledOff, a flat outline; vtk.js has - # no 2D glyph source, so it borrows the 3D arrow. Only Brain asks - # for it, and Brain does not run here. - kind, template_kw = "arrow", dict() - rr, tris = self._glyph_template(kind, **template_kw) + ), + ).get(mode, dict()) + rr, tris = self._glyph_template( + "arrow" if mode == "2darrow" else mode, **template_kw + ) if solid_transform is not None: - # _pyvista.py transforms the template before glyphing, and this is - # where the fiducial markers get their size and 45 deg roll - solid_transform = np.asarray(solid_transform, dtype=float) + solid_transform = np.asarray(solid_transform, float) rr = rr @ solid_transform[:3, :3].T + solid_transform[:3, 3] - # a sphere looks the same however it is turned, so skip the rotation - # rather than build N matrices for it. "oct" joins it because the only - # caller (the MRI fiducials) points every glyph along +x, which is the - # identity; a future caller pointing them elsewhere would need this back - rots = None if mode in ("sphere", "oct") else self._rots_from_dirs(dirs) - points, faces = self._tile(rr, tris, centers, scales=sizes, rots=rots) + # spheres look the same however turned, and the fiducial "oct" is + # always asked for along +x, so neither needs rotations + rots = None + if mode not in ("sphere", "oct"): + rots = _find_vector_rotation(np.array([1.0, 0, 0]), dirs) + points, faces = self._tile(rr, tris, np.column_stack([x, y, z]), sizes, rots) return self._add(points, faces, color, opacity) def instanced_mesh( @@ -736,62 +460,44 @@ def instanced_mesh( *, name=None, ): - """Stamp the template at every position, merged per distinct color. - - Rotate with MNE's own quaternion helper so oriented glyphs (EEG - cylinders) point the way MNE intended rather than all along +x. - pyvista-js has no per-vertex color, so instances are grouped by the - color they asked for and each group becomes one mesh -- a handful of - actors for a sensor array instead of one per sensor. One distinct color - hands back that single actor, several hand back the list of them. - - The second return value is the per-instance point cloud, always one - object whatever the colors did, because that is what - ``_PyVistaRenderer`` returns and what mne/viz/_3d.py writes channel - names onto. + """Stamp the template at every position, one merged mesh per distinct color. + + vtk.js cannot color per instance, so several colors give a list of + actors. The cloud handed back second is what _3d.py writes channel + names onto, and is always one object. """ - positions = np.atleast_2d(np.asarray(positions, dtype=float))[:, :3] + positions = np.atleast_2d(np.asarray(positions, float))[:, :3] n_pos = len(positions) + cloud = pv.PolyData(points=positions) + cloud.field_data = dict() if not n_pos: - return None, _lite_instance_cloud(positions) + return None, cloud rots = None if quats is not None: - quats = np.atleast_2d(np.asarray(quats, dtype=float)) - # MNE's (x, y, z) with w implied, as _PyVistaRenderer also insists: - # a (w, x, y, z) row would silently be read as a half turn - assert quats.shape[-1] == 3, quats.shape + quats = np.atleast_2d(np.asarray(quats, float)) + assert quats.shape[-1] == 3, quats.shape # (w, x, y, z) would misread rots = quat_to_rot(quats) idx = np.arange(n_pos) if colors is not None and np.ndim(colors) > 1: - colors = np.asarray(colors, dtype=float) - uniq, inverse = np.unique( - colors[idx % len(colors)], axis=0, return_inverse=True - ) - inverse = np.asarray(inverse).ravel() - groups = [(uniq[k], idx[inverse == k]) for k in range(len(uniq))] + colors = np.asarray(colors, float)[idx % len(colors)] + uniq, inverse = np.unique(colors, axis=0, return_inverse=True) + groups = [(uniq[k], idx[np.ravel(inverse) == k]) for k in range(len(uniq))] else: groups = [(colors, idx)] - # only the actors are collected: _add already registers each mesh with - # the plotter, and the object callers want back is the instance cloud actors = list() for color, sel in groups: group_scales = None if scales is not None: - group_scales = np.atleast_1d(np.asarray(scales, dtype=float)) - group_scales = group_scales[sel % len(group_scales)] + scales = np.atleast_1d(np.asarray(scales, float)) + group_scales = scales[sel % len(scales)] group_rots = None if rots is None else rots[sel % len(rots)] points, faces = self._tile( - rr, tris, positions[sel], scales=group_scales, rots=group_rots + rr, tris, positions[sel], group_scales, group_rots + ) + actors.append( + self._add(points, faces, color, _lite_opacity(color, opacity))[0] ) - actor, _ = self._add(points, faces, color, _lite_opacity(color, opacity)) - actors.append(actor) - # one group is the common case and matches _PyVistaRenderer, which - # colors per instance inside a single actor; hand that actor back on - # its own, and the whole set when the colors had to be split - cloud = _lite_instance_cloud(positions) - if len(actors) == 1: - return actors[0], cloud - return actors, cloud + return (actors[0] if len(actors) == 1 else actors), cloud def text2d( self, @@ -803,133 +509,67 @@ def text2d( justification=None, font_file=None, ): - """Draw text over the scene, in normalized window coordinates.""" if justification is not None or font_file is not None: - raise NotImplementedError( - "Justified text and custom fonts are not supported in the " - "browser: vtk.js draws text at a point, in the page's font." - ) + _lite_unsupported("Justified text and custom fonts") 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 + ] + # -- nothing to do in a browser ----------------------------------------- - # These are reached while drawing the figures the docs render, and there is - # genuinely nothing for them to do here, so each says why rather than - # raising and taking a working page down with it. def set_interaction(self, interaction): - # plot_alignment sets this unconditionally (mne/viz/_3d.py); vtk.js - # ships one trackball style and no way to swap it - return None + pass # vtk.js ships one trackball style def _update(self): - # plot_alignment calls this to force a repaint of a window already up; - # the browser paints from the JS side, after the cell has finished - return None + pass # the page paints after the cell finishes def _window_close_connect(self, func, *, after=True): - # mne/viz/ui_events.py asks to be told when the window closes, and a - # canvas in an output cell has no close event to connect to - return None + pass # an output cell has no close event def text3d(self, x, y, z, text, scale, color="white"): - # plot_alignment(show_channel_names=True) labels each sensor, which - # needs a follow-the-camera 3D text actor. pyvista-js 0.15 has only - # Text, positioned in normalized window coordinates, and projecting the - # sensor positions into those is exactly what `project` cannot do, so - # the sensors are drawn without their labels. - return None + pass # no camera-facing 3D text, so sensors go unlabeled def close(self): - _close_3d_figure(self._figure) - return None - - def remove_mesh(self, mesh_data): - # add_mesh hands back the dict the renderer keeps, and the plotter - # keeps a second dict pointing at it, so drop both; instanced_mesh - # hands back one dict per color group - 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 - ] - return None + _lite_release_plotter(self.plotter) # -- things pyvista-js cannot do ---------------------------------------- def contour(self, *args, **kwargs): - # PolyData.contour can extract the isolines -- it marches triangles in - # JS at render time -- but with no lookup table they would all draw in - # one color, and plot_evoked_field asks for a single set spanning - # -vmax to +vmax. Drawing a field map whose positive and negative lines - # look identical is worse than not drawing it. - raise NotImplementedError( - "Drawing contours is not supported in the browser: every line would " - "come out the same color, which for a field map is misleading." - ) + _lite_unsupported("Drawing contours") # one color would mislead def scalarbar(self, *args, **kwargs): - # Plotter.add_scalar_bar records the request on the Python side, but - # nothing reaches the scene: the vtk.js template draws no scalar bar, - # and there is no colormap for one to label anyway - raise NotImplementedError( - "Drawing a scalar bar is not supported in the browser: vtk.js draws " - "no scalar bar and nothing here is colored by scalars." - ) + _lite_unsupported("Drawing a scalar bar") def legend(self, *args, **kwargs): - # only mne coreg asks for one, and pyvista-js has no add_legend - raise NotImplementedError( - "Drawing a legend is not supported in the browser: pyvista-js does " - "not have one." - ) + _lite_unsupported("Drawing a legend") def subplot(self, *args, **kwargs): - # a pyvista-js Plotter renders a single vtk.js view into one canvas - raise NotImplementedError( - "Subplots are not supported in the browser: a scene is one canvas." - ) + _lite_unsupported("Subplots") def _process_events(self, *args, **kwargs): - # the kernel and the canvas are different threads here, and the Pyodide - # worker cannot drive the page's event loop - raise NotImplementedError( - "Draining the event loop is not supported in the browser: the page " - "runs the loop, not the kernel." - ) + _lite_unsupported("Draining the event loop") # the page runs it def _window_set_cursor(self, *args, **kwargs): - # pyvista-js has no window object to set a cursor on - raise NotImplementedError( - "Setting the cursor is not supported in the browser: the scene is a " - "canvas in an output cell, not a window." - ) + _lite_unsupported("Setting the cursor") def _enable_time_interaction(self, *args, **kwargs): - # inheriting renderer._TimeInteraction would not help: it builds the - # slider out of _dock_add_slider and the rest of the dock and toolbar - # API, none of which is implemented here. The _Ipy* mixins that do - # implement it live in _notebook.py, which imports _pyvista at module - # level and so cannot be imported at all without VTK. - raise NotImplementedError( - "The time slider is not supported in the browser: it needs dock " - "widgets, which this backend does not draw." - ) + _lite_unsupported("The time slider") # needs dock widgets def project(self, xyz, ch_names): - # a _Projection needs the render window's coordinate transform, and - # pyvista-js does not expose a render window - raise NotImplementedError( - "Projecting 3D positions onto the scene is not supported in the browser." - ) + _lite_unsupported("Projecting 3D positions onto the scene") def screenshot(self, mode="rgb", filename=None): return _take_3d_screenshot(self._figure, mode=mode, filename=filename) - # -- camera and scene --------------------------------------------------- + # -- camera ------------------------------------------------------------- def get_camera(self, *, rigid=None): - # Same order as _get_3d_view: roll, distance, azimuth, elevation and - # then the focalpoint. Brain unpacks positions 3 and 4 as the angles, - # so the focalpoint has to be the last element rather than the fourth. return _lite_get_view(self.plotter) def set_camera( @@ -943,38 +583,13 @@ def set_camera( rigid=None, update=True, ): - # distance, focalpoint and roll go unused: vtk.js frames the scene with - # resetCamera() on this path, which is what lets it handle the - # distance=None that MNE asks for far more often. See _lite_view_angles. - return _lite_set_view(self.plotter, azimuth, elevation) - - @property - def figure(self): - """The scene, under the name the tutorials reach for. - - ``_PyVistaRenderer`` hands out one object as both ``.figure`` and - ``.scene()``; ``20_source_alignment`` builds a renderer itself with - ``create_3d_figure(scene=False)`` and then passes ``renderer.figure`` - to ``set_3d_view``, so the two have to stay the same thing here too. - """ - return self._figure - - def scene(self): - return self._figure - - def show(self): - self.plotter.show() - return None + # distance, focalpoint and roll go unused: vtk.js frames the scene + _lite_set_view(self.plotter, azimuth, elevation) # -- the module surface renderer.py expects of a 3D backend ----------------- -# set_3d_view, set_3d_title and the close_* helpers reach for these on the -# ``renderer.backend`` global rather than going through _get_renderer, and the -# figure they hand over is the _LiteFigure _LiteRenderer.scene returns. _Renderer = _LiteRenderer - -# nothing here draws differently under test, the way an on-screen window does -_testing_context = nullcontext +_testing_context = nullcontext # nothing draws differently under test def _set_3d_view( @@ -987,10 +602,7 @@ def _set_3d_view( rigid=None, update=True, ): - # distance, focalpoint and roll go unused here for the same reason they do - # in _LiteRenderer.set_camera: vtk.js frames the scene with resetCamera() - # on this path. See _lite_view_angles. - return _lite_set_view(figure.plotter, azimuth, elevation) + _lite_set_view(figure.plotter, azimuth, elevation) def _set_3d_title(figure, title, size=16, *, color="white", position="upper_left"): @@ -1005,39 +617,18 @@ def _check_3d_figure(figure): def _take_3d_screenshot(figure, mode="rgb", filename=None): - # Plotter.screenshot does exist, but it renders the scene by driving a - # headless browser with Playwright from outside the page, which is not - # something the page can do to itself - raise NotImplementedError( - "Taking a screenshot is not supported in the browser: vtk.js draws " - "to a live canvas that cannot be read back as an array." - ) + # pyvista-js can, but only by driving a headless browser from outside + _lite_unsupported("Taking a screenshot") def _clear_3d_figure(figure): - _lite_release_plotter(figure.plotter) - return None + figure.plotter.clear() def _close_3d_figure(figure): - # the same as clearing: vtk.js draws into a canvas in an output cell, so - # there is no window left to close once the geometry is gone. The handle - # is forgotten too, so the next create_3d_figure with it starts afresh. - _lite_release_plotter(figure.plotter) - for handle in [key for key, fig in _lite_figures.items() if fig is figure]: - del _lite_figures[handle] - return None + _lite_release_plotter(figure.plotter) # there is no window to close def _close_all(): - # the registry holds weak references, so deref before releasing -- handing - # the ref itself to _lite_release_plotter matches nothing and never - # shortens the list while _lite_live_plotters: - plotter = _lite_live_plotters[-1]() - if plotter is None: - _lite_live_plotters.pop() - else: - _lite_release_plotter(plotter) - _lite_figures.clear() - return None + _lite_release_plotter(_lite_live_plotters[-1]()) diff --git a/mne/viz/backends/tests/test_lite.py b/mne/viz/backends/tests/test_lite.py deleted file mode 100644 index a7f736c82e6..00000000000 --- a/mne/viz/backends/tests/test_lite.py +++ /dev/null @@ -1,626 +0,0 @@ -# Authors: The MNE-Python contributors. -# License: BSD-3-Clause -# Copyright the MNE-Python contributors. - -import ast -import subprocess -import sys -from pathlib import Path - -import numpy as np -import pytest -from numpy.testing import assert_allclose - -from mne.viz import Figure3D -from mne.viz.backends._abstract import _AbstractRenderer - -# imported this way rather than with a plain import so that the whole file -# skips without pyvista-js, which _lite needs at module level -_lite = pytest.importorskip("mne.viz.backends._lite") - -# a unit square, split into two triangles -_RR = np.array([[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0]], dtype=float) -_TRIS = np.array([[0, 1, 2], [0, 2, 3]]) - - -def _drawn(renderer): - """Return the geometry of the mesh the renderer drew last. - - sphere() and the other instanced_mesh callers hand back the instance cloud - rather than the drawn geometry, matching _PyVistaRenderer, so read what - actually reached the plotter instead of the return value. - """ - return renderer.plotter.actors[-1]["mesh"] - - -def _serialized(renderer): - """Return the actor sources as the vtk.js page will receive them.""" - return [ - a["source"] for a in renderer.plotter._renderer._build_scene_data()["actors"] - ] - - -def test_is_a_registered_backend(renderer_lite): - """``set_3d_backend("jupyterlite_notebook")`` must hand out this renderer.""" - assert renderer_lite.get_3d_backend() == "jupyterlite_notebook" - assert renderer_lite.backend._Renderer is _lite._LiteRenderer - assert isinstance(renderer_lite._get_renderer(size=(200, 200)), _lite._LiteRenderer) - - -def test_module_covers_everything_renderer_calls(): - """``_lite`` must define every helper ``renderer.py`` reaches for. - - These are module-level functions that read the ``renderer.backend`` global - directly rather than going through ``_get_renderer``, so a new one added - upstream breaks the browser silently. ``clear_3d_figure`` did exactly that. - """ - from mne.viz.backends import renderer - - src = Path(renderer.__file__).read_text() - needed = { - node.attr - for node in ast.walk(ast.parse(src)) - if isinstance(node, ast.Attribute) - and isinstance(node.value, ast.Name) - and node.value.id == "backend" - } - missing = sorted(name for name in needed if not hasattr(_lite, name)) - assert not missing, f"mne.viz.backends._lite is missing {missing}" - - -def test_implements_abstract_renderer(): - """The lite renderer must satisfy the full _AbstractRenderer contract. - - ``_AbstractRenderer`` declares its API with ``@abstractmethod``, so leaving - one out makes ``_LiteRenderer(...)`` raise ``TypeError`` the first time a - notebook draws. Assert the class is instantiable instead of waiting for it. - """ - assert issubclass(_lite._LiteRenderer, _AbstractRenderer) - assert not _lite._LiteRenderer.__abstractmethods__ - - -def test_kind_is_its_own(): - """``_kind`` must stay distinct from the desktop notebook backend. - - Callers branch on ``_kind`` to pick behaviour, and this environment has no - VTK, no filesystem and no OS threads, so it must not be mistaken for the - notebook backend that does. - """ - assert _lite._LiteRenderer._kind == "jupyterlite_notebook" - - -def test_import_is_side_effect_free(): - """Importing the module must not pull in VTK or pick a backend. - - The browser kernel has no VTK at all, and ``mne.viz.backends.renderer`` has - to keep drawing with whatever it was until something asks for this one. Run - in a subprocess because by this point in a full test session another - backend has usually already imported VTK. - """ - code = ( - "import sys\n" - "import mne.viz.backends._lite\n" - "from mne.viz.backends import renderer\n" - "assert 'vtk' not in sys.modules, 'importing _lite pulled in vtk'\n" - "assert 'vtkmodules' not in sys.modules, 'importing _lite pulled in vtk'\n" - "assert renderer.MNE_3D_BACKEND is None\n" - "print('ok')\n" - ) - out = subprocess.run( - [sys.executable, "-c", code], capture_output=True, text=True, check=False - ) - assert out.returncode == 0, out.stderr - assert "ok" in out.stdout - - -@pytest.mark.parametrize( - "azimuth, elevation", - [ - (None, None), - (0, None), - (90, None), - (180, None), - (270, None), - (45, 30), - (None, 2), - (None, 90), - ], -) -def test_set_view_reaches_the_camera(azimuth, elevation, renderer_lite): - """Every azimuth/elevation pair must reach the camera, poles included.""" - r = renderer_lite._get_renderer(size=(200, 200)) - r.set_camera(azimuth=azimuth, elevation=elevation) - roll, distance, got_azimuth, got_elevation, focalpoint = r.get_camera() - assert np.asarray(focalpoint).shape == (3,) - - if azimuth is None and elevation is None: - # no angle to point at, so the camera is left for vtk.js to frame - assert (roll, distance, got_azimuth, got_elevation) == (0.0, 1.0, 0.0, 0.0) - return - # one angle given means "leave the other alone", which before any view is - # set means 90; 2 and 90 degrees sit either side of the 5/175 view-up flip - assert got_azimuth == pytest.approx(90.0 if azimuth is None else azimuth % 360) - assert got_elevation == pytest.approx( - 90.0 if elevation is None else elevation % 180 - ) - assert (roll, distance) == (0.0, 1.0) - - -def test_set_view_matches_pyvista(renderer_lite): - """The camera has to end up where _pyvista._set_3d_view would put it. - - vtk.js reads ``view_vector`` as the camera *position*, so the anterior view - plot_alignment ends on (azimuth and elevation both 90) must put the camera - on +y and looking back at the head, not on -y looking at its back. And - giving one angle must leave the other alone, rather than reset it to 90. - """ - r = renderer_lite._get_renderer(size=(200, 200)) - r.set_camera(azimuth=90, elevation=90) - assert_allclose(r.plotter._renderer._view_vector, [0, 1, 0], atol=1e-12) - r.set_camera(elevation=0) # top view - assert_allclose(r.plotter._renderer._view_vector, [0, 0, 1], atol=1e-12) - r.set_camera(azimuth=180) # still a top view - assert r.get_camera()[2:4] == pytest.approx((180.0, 0.0)) - r.set_camera(elevation=90) # ... now from the left - assert_allclose(r.plotter._renderer._view_vector, [-1, 0, 0], atol=1e-12) - - -def test_get_camera_matches_the_expected_order(renderer_lite): - """``get_camera`` must unpack the way ``_get_3d_view`` does. - - ``Brain`` reads it as ``_, _, azimuth, elevation, _``, so the focalpoint - has to be last; putting it fourth hands ``Brain`` a tuple for an angle. - """ - r = renderer_lite._get_renderer(size=(200, 200)) - roll, distance, azimuth, elevation, focalpoint = r.get_camera() - for angle in (roll, distance, azimuth, elevation): - assert isinstance(angle, float) - assert np.asarray(focalpoint).shape == (3,) - - -def test_draws_every_primitive(renderer_lite): - """Every primitive must add one actor holding the geometry it was asked for. - - Counting actors alone would pass on empty or misplaced meshes, so each - check below pins where the mesh actually landed. - """ - r = renderer_lite._get_renderer(size=(200, 200), bgcolor="white") - assert len(r.plotter.actors) == 0 - - # a flat unit square, drawn as given, whose faces reach vtk.js as the flat - # cell array it reads (nested rows would serialise to an empty one) - _, mesh = r.mesh(_RR[:, 0], _RR[:, 1], _RR[:, 2], _TRIS, color="red", opacity=0.5) - assert_allclose(np.asarray(mesh.points), _RR, atol=1e-6) - assert _serialized(r)[-1]["polys"] == [3, 0, 1, 2, 3, 0, 2, 3] - - # the same square, reached through the surface dict - _, mesh = r.surface(dict(rr=_RR, tris=_TRIS), color="#0000ff") - assert_allclose(np.asarray(mesh.points), _RR, atol=1e-6) - - # scale 0.1 means radius 0.05, centered where it was asked for - r.sphere(np.array([[1.0, 0, 0]]), "green", 0.1) - points = np.asarray(_drawn(r).points) - assert_allclose(points.mean(axis=0), [1, 0, 0], atol=1e-6) - assert np.linalg.norm(points - [1, 0, 0], axis=1).max() == pytest.approx(0.05) - - # a tube spans origin to destination, no further - _, mesh = r.tube([[0.0, 0, 0]], [[0.0, 0, 1.0]], radius=0.01, color="black") - points = np.asarray(mesh.points) - assert points[:, 2].min() == pytest.approx(0.0) - assert points[:, 2].max() == pytest.approx(1.0) - assert np.linalg.norm(points[:, :2], axis=1).max() == pytest.approx(0.01) - assert r.plotter.actors[-1]["opacity"] == 1.0 # opacity=None is opaque - - # an arrow of length `scale` pointing the way it was given - _, mesh = r.quiver3d( - np.r_[0.0], - np.r_[0.0], - np.r_[0.0], - np.r_[0.0], - np.r_[1.0], - np.r_[0.0], - color=(1.0, 0.5, 0.0), - scale=0.1, - mode="arrow", - ) - points = np.asarray(mesh.points) - assert points[:, 1].max() == pytest.approx(0.1) # along +y, at `scale` - # and no wider than its own tip, which is 0.1 of the scaled length - assert np.linalg.norm(points[:, [0, 2]], axis=1).max() <= 0.01 + 1e-9 - - assert len(r.plotter.actors) == 5 - - -def test_tube_defaults_to_white(renderer_lite): - """An uncolored tube is white, as _PyVistaRenderer draws it. - - plot_alignment draws fNIRS source-detector pairs with no color on a - (0.5, 0.5, 0.5) background, and that gray is this backend's fallback for - ``color=None`` elsewhere, so the wrong default makes the pairs vanish. - """ - r = renderer_lite._get_renderer(size=(200, 200)) - r.tube([[0.0, 0, 0]], [[1.0, 0, 0]], radius=0.01, opacity=0.5) - assert r.plotter.actors[-1]["color"] == (1.0, 1.0, 1.0) - assert r.plotter.actors[-1]["opacity"] == 0.5 - - -@pytest.mark.parametrize("mode", ("arrow", "cone", "cylinder")) -def test_glyphs_point_backwards(mode, renderer_lite): - """A glyph along -x must point along -x, not +x. - - The templates are built along +x and turned onto their direction, and the - antiparallel case has no rotation axis to speak of; it used to come out as - the identity, so every such glyph pointed the wrong way. - """ - _, mesh = renderer_lite._get_renderer(size=(200, 200)).quiver3d( - [0.0], [0.0], [0.0], [-1.0], [0.0], [0.0], color="red", scale=1.0, mode=mode - ) - x = np.asarray(mesh.points)[:, 0] - if mode == "cylinder": # centered on its position - assert (x.min(), x.max()) == pytest.approx((-0.5, 0.5)) - else: # base at the position, tip along the direction - assert (x.min(), x.max()) == pytest.approx((-1.0, 0.0)) - if mode == "cone": # the apex is the single point furthest along - assert (x == x.min()).sum() == 1 - - -def test_tube_stretches_each_segment_on_its_own(renderer_lite): - """``tube`` scales along the template axis alone, per segment. - - That is the one place ``_tile`` scales anisotropically, and getting it - wrong would fatten the tubes as they lengthen. - """ - r = renderer_lite._get_renderer(size=(200, 200)) - _, mesh = r.tube( - [[0.0, 0, 0], [0.0, 0, 0]], # one 1 m segment and one 2 m segment - [[1.0, 0, 0], [0.0, 2.0, 0]], - radius=0.01, - color="black", - ) - points = np.asarray(mesh.points) - assert points[:, 0].max() == pytest.approx(1.0) - assert points[:, 1].max() == pytest.approx(2.0) - - # neither got thicker for being longer: the two segments are stamped in - # order, so split them and measure each one away from its own axis - first, second = points.reshape(2, -1, 3) - assert np.linalg.norm(first[:, 1:], axis=1).max() == pytest.approx(0.01) - assert np.linalg.norm(second[:, [0, 2]], axis=1).max() == pytest.approx(0.01) - - -def test_glyphs_scale_by_their_scalars(renderer_lite): - """``mode="arrow"`` must size each glyph by its scalar, as the filter does. - - ``plot_alignment(show_axes=True)`` draws a coordinate frame as three arrows - with ``scalars=[0.33, 0.66, 1.0]``, so ignoring them gives three - equal-length arrows and a wrong-looking frame. - """ - xyz = np.zeros(3) - uvw = np.eye(3) - _, mesh = renderer_lite._get_renderer(size=(200, 200)).quiver3d( - *xyz[:, None].repeat(3, 1), - *uvw, - mode="arrow", - scale=2e-2, - color="red", - scale_mode="scalar", - scalars=[0.33, 0.66, 1.0], - ) - # one copy of the template per glyph, in order - lengths = np.linalg.norm(np.asarray(mesh.points).reshape(3, -1, 3), axis=2).max( - axis=1 - ) - assert lengths / lengths.max() == pytest.approx([0.33, 0.66, 1.0]) - - -def test_arrow_template_matches_vtk(renderer_lite): - """``mode="arrow"`` must be a shaft plus a tip, not a bare cone. - - ``_pyvista.py`` glyphs it with ``vtkArrowSource``, whose defaults put a - 0.03-radius shaft under a 0.1-radius tip starting at 0.65, over a total - length of 1. - """ - rr, _ = renderer_lite._get_renderer(size=(200, 200))._glyph_template("arrow") - x = rr[:, 0] - radius = np.linalg.norm(rr[:, 1:], axis=1) - assert (x.min(), x.max()) == pytest.approx((0.0, 1.0)) - assert radius[x < 0.6].max() == pytest.approx(0.03) - assert radius[x > 0.6].max() == pytest.approx(0.1) - assert x[radius > 0.05].min() == pytest.approx(0.65) - - -def test_sphere_radius_matches_pyvista(renderer_lite): - """``scale`` sizes a radius-0.5 template, so the drawn radius is half of it. - - ``_pyvista.py`` glyphs ``pyvista.Sphere(radius=0.5)`` by ``scale``; taking - ``scale`` as the radius draws every dig point and fiducial twice too big, - and no caller in ``_3d.py`` passes ``radius`` to say otherwise. - """ - r = renderer_lite._get_renderer(size=(200, 200)) - r.sphere(np.zeros((1, 3)), "red", 0.01) - drawn = np.asarray(_drawn(r).points) - assert np.linalg.norm(drawn, axis=1).max() == pytest.approx(0.005) - # an explicit radius is used as-is, again matching _pyvista.py - r.sphere(np.zeros((1, 3)), "red", 1.0, radius=0.02) - drawn = np.asarray(_drawn(r).points) - assert np.linalg.norm(drawn, axis=1).max() == pytest.approx(0.02) - - -def test_cylinder_center_is_turned_with_the_axis(renderer_lite): - """``center`` arrives in ``_cylinder_geom``'s pre-rotation frame. - - That helper builds the cylinder along y and turns it 90 degrees about z, so - ``(cx, cy, cz)`` lands at ``(-cy, cx, cz)``. ``_3d.py`` gives the EEG - electrode offset that way, and skipping the turn stands the cylinders - beside their sensors instead of on them. - """ - rr, _ = renderer_lite._get_renderer(size=(200, 200))._glyph_template( - "cylinder", radius=0.5, height=3.0, center=(0.0, -0.75, 0.0), resolution=16 - ) - # the offset lands on the axis, not across it - assert rr.min(axis=0) == pytest.approx([-0.75, -0.5, -0.5]) - assert rr.max(axis=0) == pytest.approx([2.25, 0.5, 0.5]) - - -def test_instances_are_merged_per_color(renderer_lite): - """``instanced_mesh`` draws one actor per distinct color, not one per instance. - - vtk.js cannot color per instance inside a single actor, so one color gives - one actor and several give the list of them. The second return value is the - instance cloud either way, matching ``_PyVistaRenderer``. - """ - quats = np.zeros((3, 3)) # identity, in MNE's (x, y, z) convention - positions = np.array([[0.0, 0, 0], [1.0, 0, 0], [2.0, 0, 0]]) - - # one color: a single actor - r = renderer_lite._get_renderer(size=(200, 200)) - colors = np.tile([1.0, 0, 0], (3, 1)) - actor, cloud = r.instanced_mesh(_RR, _TRIS, positions, quats, colors=colors) - assert len(r.plotter.actors) == 1 - assert not isinstance(actor, list) - assert_allclose(np.asarray(cloud.points), positions, atol=1e-6) - - # two colors: one actor each, and the cloud is still a single object - r = renderer_lite._get_renderer(size=(200, 200)) - colors = np.array([[1.0, 0, 0], [0, 1.0, 0], [1.0, 0, 0]]) - actors, cloud = r.instanced_mesh(_RR, _TRIS, positions, quats, colors=colors) - assert len(r.plotter.actors) == 2 - assert len(actors) == 2 - assert not isinstance(cloud, list) - assert_allclose(np.asarray(cloud.points), positions, atol=1e-6) - - # sphere routes through instanced_mesh with a single color, so it has to - # keep handing back the pair its own callers unpack - actor, cloud = r.sphere(np.zeros((1, 3)), "red", 0.01) - assert not isinstance(actor, list) and not isinstance(cloud, list) - - # a (w, x, y, z) quaternion would silently be read as a half turn - with pytest.raises(AssertionError, match=r"\(3, 4\)"): - r.instanced_mesh(_RR, _TRIS, positions, np.zeros((3, 4)), colors=colors) - - -def test_instance_alpha_becomes_opacity(renderer_lite): - """The alpha of RGBA instance colors sets the opacity of that group. - - ``plot_alignment`` makes MEG coils translucent (``sensor_alpha``) by - scaling the alpha column of the colors it hands ``instanced_mesh``, which - _PyVistaRenderer draws as RGBA scalars; here each color group is one solid - mesh, so its alpha has to become the mesh opacity or every coil is opaque. - """ - r = renderer_lite._get_renderer(size=(200, 200)) - positions = np.array([[0.0, 0, 0], [1.0, 0, 0], [2.0, 0, 0]]) - colors = np.array([[1.0, 0, 0, 0.25], [0, 1.0, 0, 1.0], [1.0, 0, 0, 0.25]]) - actors, _ = r.instanced_mesh(_RR, _TRIS, positions, colors=colors, opacity=0.5) - got = {a["color"]: a["opacity"] for a in actors} - assert got == {(1.0, 0.0, 0.0): 0.125, (0.0, 1.0, 0.0): 0.5} - # and a bare RGB row is drawn at the opacity asked for - r.sphere(np.zeros((1, 3)), (0.0, 0.0, 1.0), 0.01, opacity=0.5) - assert r.plotter.actors[-1]["opacity"] == 0.5 - - -def test_instance_cloud_takes_channel_names(renderer_lite): - """mne/viz/_3d.py writes channel names onto the cloud (gh-13074). - - _PyVistaRenderer hands back a PolyData whose ``field_data`` takes them; a - pyvista-js PolyData has no such attribute, so the renderer supplies one. - An empty ``positions`` must still give a cloud, since the caller assigns - without checking. - """ - r = renderer_lite._get_renderer(size=(200, 200)) - positions = np.array([[0.0, 0, 0], [1.0, 0, 0]]) - _, cloud = r.instanced_mesh(_RR, _TRIS, positions, colors=(1.0, 0, 0)) - # one cloud point per instance, in order: _3d.py indexes the names against - # them, so a cloud that did not carry the positions would mislabel sensors - assert_allclose(np.asarray(cloud.points), positions, atol=1e-6) - cloud.field_data["ch_names"] = np.array(["MEG 0113", "MEG 0112"], dtype="U") - assert list(cloud.field_data["ch_names"]) == ["MEG 0113", "MEG 0112"] - - actor, cloud = r.instanced_mesh(_RR, _TRIS, np.zeros((0, 3))) - assert actor is None - cloud.field_data["ch_names"] = np.array([], dtype="U") # must not raise - - -def test_draws_into_an_existing_figure(renderer_lite): - """``fig=`` composites into a scene rather than opening a second one.""" - first = renderer_lite._get_renderer(size=(200, 200)) - fig = first.scene() - assert isinstance(fig, Figure3D) - assert fig is first.figure # the tutorials reach for it under both names - second = renderer_lite._get_renderer(fig=fig) - assert second.plotter is first.plotter - - second.sphere(np.array([[0.0, 0, 0]]), "red", 1.0) - assert len(first.plotter.actors) == 1 - - # an int handle names a scene to make now and draw into again later, as - # create_3d_figure(handle=...) does; closing it forgets the handle - third = renderer_lite._get_renderer(fig=7) - assert third.plotter is not first.plotter - assert renderer_lite._get_renderer(fig=7).plotter is third.plotter - renderer_lite.close_3d_figure(third.scene()) - assert renderer_lite._get_renderer(fig=7).plotter is not third.plotter - - with pytest.raises(TypeError, match="instance of None, int, or _LiteFigure"): - renderer_lite._get_renderer(fig=first.plotter) - with pytest.raises(TypeError, match="instance of _LiteFigure"): - renderer_lite.backend._check_3d_figure(first.plotter) - - -def test_live_scenes_are_capped(renderer_lite): - """Old scenes are released, so a notebook cannot run the tab out of memory. - - Every live scene holds its meshes in the WASM heap, a copy in JS and a set - of GPU buffers, and nothing in a notebook calls ``close_3d_figure``. - """ - kept = [ - renderer_lite._get_renderer() for _ in range(_lite._LITE_MAX_LIVE_SCENES + 3) - ] - assert len(_lite._lite_live_plotters) == _lite._LITE_MAX_LIVE_SCENES - # the survivors are the most recent ones - live = [ref() for ref in _lite._lite_live_plotters] - assert live == [r.plotter for r in kept[-_lite._LITE_MAX_LIVE_SCENES :]] - - -@pytest.mark.parametrize( - "method, args", - [ - ("project", ({}, [])), - ("screenshot", ()), - ("contour", ()), - ("scalarbar", ()), - ("legend", ()), - ("subplot", ()), - ("_process_events", ()), - ("_window_set_cursor", ()), - ("_enable_time_interaction", ()), - ], -) -def test_unsupported_methods_raise(method, args, renderer_lite): - """Things pyvista-js cannot do must raise, not hand back a plausible stub. - - ``project`` used to return an array where callers expect a ``_Projection`` - and would fail a line later on ``.visible()``; ``screenshot`` used to - return a 2x2 black image. - """ - r = renderer_lite._get_renderer(size=(200, 200)) - with pytest.raises(NotImplementedError, match="browser"): - getattr(r, method)(*args) - - -def test_remove_mesh(renderer_lite): - """``remove_mesh`` takes a drawn mesh, or a color-split set of them, back out.""" - r = renderer_lite._get_renderer(size=(200, 200)) - kept = r.mesh(_RR[:, 0], _RR[:, 1], _RR[:, 2], _TRIS, color="red") - gone = r.mesh(_RR[:, 0], _RR[:, 1], _RR[:, 2], _TRIS, color="blue") - positions = np.array([[0.0, 0, 0], [1.0, 0, 0]]) - split = r.instanced_mesh(_RR, _TRIS, positions, colors=np.eye(2, 3)) - assert len(r.plotter.actors) == 4 - r.remove_mesh(gone) - r.remove_mesh(split) - assert len(r.plotter.actors) == 1 - assert r.plotter.actors[0]["actor"] is kept[0] - assert len(_serialized(r)) == 1 # and the page does not get it either - - -def test_text_is_drawn(renderer_lite): - """Text lands on the canvas with the size and color that was asked for.""" - r = renderer_lite._get_renderer(size=(200, 200)) - text = r.text2d(0.1, 0.9, "hello", size=12, color="red") - assert (text.input, text.position) == ("hello", (0.1, 0.9)) - assert (text.prop.font_size, text.prop.color) == (12, (1.0, 0.0, 0.0)) - - title = renderer_lite.set_3d_title(figure=r.scene(), title="a title", size=20) - assert title.input == "a title" - # every position name PyVista's add_text takes, since set_3d_title passes - # them through - for position in ("lower_edge", "right_edge"): - renderer_lite.set_3d_title(figure=r.scene(), title="t", position=position) - with pytest.raises(ValueError, match="Invalid value for the 'position'"): - renderer_lite.set_3d_title(figure=r.scene(), title="t", position="middle") - # justification and a font file are the two things vtk.js cannot honour - with pytest.raises(NotImplementedError, match="browser"): - r.text2d(0.1, 0.9, "hello", justification="center") - - -def test_clear_keeps_the_scene(renderer_lite): - """Clearing drops the geometry but leaves the scene open to draw into.""" - r = renderer_lite._get_renderer(size=(200, 200)) - r.sphere(np.array([[0.0, 0, 0]]), "red", 1.0) - assert len(r.plotter.actors) == 1 - - renderer_lite.clear_3d_figure(r.scene()) - assert len(r.plotter.actors) == 0 - # still usable, unlike after close_3d_figure - r.sphere(np.array([[1.0, 0, 0]]), "blue", 1.0) - assert len(r.plotter.actors) == 1 - - -def test_public_helpers_route_through_the_backend(renderer_lite): - """``mne.viz.set_3d_view`` and friends must work once the backend is set. - - These are the calls the tutorials actually make. They read - ``renderer.backend`` directly rather than going through ``_get_renderer``, - so a renderer on its own is not enough to make them work. - """ - from mne.viz import close_all_3d_figures, set_3d_view - - r = renderer_lite._get_renderer(size=(200, 200)) - r.sphere(np.array([[0.0, 0, 0]]), "red", 1.0) - - set_3d_view(r.scene(), azimuth=90, elevation=45) - assert r.get_camera()[2:4] == pytest.approx((90.0, 45.0)) - - close_all_3d_figures() - assert len(r.plotter.actors) == 0 - assert _lite._lite_live_plotters == [] - - -def test_close_all_releases_every_scene(renderer_lite): - """``close_all`` must drain the registry, not spin on dead references.""" - scenes = [renderer_lite._get_renderer() for _ in range(_lite._LITE_MAX_LIVE_SCENES)] - assert _lite._lite_live_plotters - renderer_lite.backend._close_all() - assert _lite._lite_live_plotters == [] - assert all(len(s.plotter.actors) == 0 for s in scenes) - - -def test_renders_in_a_notebook_kernel(nbexec): - """Draw through MNE's own factory inside a live Jupyter kernel. - - Everything above drives the renderer in-process. This goes through - ``_get_renderer`` in a real kernel, which is the path a notebook actually - takes, and checks the scene serialises to the vtk.js HTML the browser - consumes. The body below is executed by that kernel rather than here. - """ - import json - - import numpy as np - - from mne.viz.backends import renderer - - renderer.set_3d_backend("jupyterlite_notebook") - assert renderer.get_3d_backend() == "jupyterlite_notebook" - r = renderer._get_renderer(size=(200, 200), bgcolor="white") - assert type(r).__name__ == "_LiteRenderer" - - rr = np.array([[0.0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0]]) - tris = np.array([[0, 1, 2], [0, 2, 3]]) - fig = r.scene() - r.mesh(rr[:, 0], rr[:, 1], rr[:, 2], tris, color="red") - assert len(r.plotter.actors) == 1 - renderer.set_3d_view(fig, azimuth=90, elevation=90) - - # the html must carry this mesh, not merely be a vtk.js page: an empty - # scene still ships the script tag, so look for the points themselves - html = r.plotter.generate_standalone_html() - assert " Date: Thu, 3 Sep 2026 13:58:38 -0400 Subject: [PATCH 15/15] FIX: Test --- mne/transforms.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/mne/transforms.py b/mne/transforms.py index 24c73ab53f1..7358032c248 100644 --- a/mne/transforms.py +++ b/mne/transforms.py @@ -1413,8 +1413,13 @@ def _quat_mult(one, two): def _skew_symmetric_cross(a): - """Compute the skew-symmetric cross product of a vector.""" - return np.array([[0.0, -a[2], a[1]], [a[2], 0.0, -a[0]], [-a[1], a[0], 0.0]]) + """Compute the skew-symmetric cross product matrix of (..., 3) vector(s).""" + a = np.asarray(a, float) + ax = np.zeros(a.shape + (3,)) + ax[..., 0, 1], ax[..., 0, 2] = -a[..., 2], a[..., 1] + ax[..., 1, 0], ax[..., 1, 2] = a[..., 2], -a[..., 0] + ax[..., 2, 0], ax[..., 2, 1] = -a[..., 1], a[..., 0] + return ax def _find_vector_rotation(a, b): @@ -1456,11 +1461,7 @@ def _find_vector_rotation(a, b): v = np.cross(a, b) # rotation axis, with the sine of the angle as its length s = (v * v).sum(-1) # sine squared c = b @ a # cosine - # skew-symmetric cross-product matrices, one per b - vx = np.zeros(b.shape[:-1] + (3, 3)) - vx[..., 0, 1], vx[..., 0, 2] = -v[..., 2], v[..., 1] - vx[..., 1, 0], vx[..., 1, 2] = v[..., 2], -v[..., 0] - vx[..., 2, 0], vx[..., 2, 1] = -v[..., 1], v[..., 0] + vx = _skew_symmetric_cross(v) # (1 - c) / s is 1 / (1 + c), but written this way it stays accurate as b # approaches -a, where 1 + c cancels and s does not. Only an s that has # vanished outright needs special handling below.