11"""
22A pyvista-js drawing backend for MNE's 3D renderer.
33
4- MNE's 3D functions (``plot_alignment``, ``plot_bem``,
5- ``plot_sparse_source_estimates``, ``SourceSpaces.plot``, ...) all build their
6- figure the same way: they do their own geometry and coordinate-frame work in
7- numpy, then hand the result to a renderer obtained from
8- :func:`mne.viz.backends.renderer._get_renderer`. Only that last step needs VTK,
9- and VTK cannot load in WebAssembly.
4+ MNE's 3D functions (``plot_alignment``, ``plot_sparse_source_estimates``,
5+ ``SourceSpaces.plot``, ...) all build their figure the same way: they do their
6+ own geometry and coordinate-frame work in numpy, then hand the result to a
7+ renderer obtained from :func:`mne.viz.backends.renderer._get_renderer`. Only
8+ that last step needs VTK, and VTK cannot load in WebAssembly.
109
1110Rather than reimplement those functions one by one, this module supplies a
1211renderer that draws with `pyvista-js <https://github.com/tkoyama010/pyvista-js>`__
2221time slider, and scalar colormaps: ``Plotter.add_mesh`` takes ``scalars`` and
2322``cmap`` and writes them into the scene, but the vtk.js template it renders
2423through builds no lookup table and never reads them, so a mesh carrying
25- scalars draws in a solid color.
24+ scalars draws in a solid color. Figure size is fixed too: pyvista-js writes a
25+ 600x400 canvas and offers no way to change it, so the ``size`` MNE asks for has
26+ no effect.
2627
2728Importing this module needs pyvista-js, the same way importing ``_pyvista``
2829needs VTK.
6566_DEFAULT_COLOR = (0.5 , 0.5 , 0.5 )
6667
6768
69+ def _lite_n_side (resolution ):
70+ """Return the side count to build a cone or cylinder with.
71+
72+ Callers give the side count VTK would use; take half of it, because
73+ ``_tile`` stamps the template at every sensor and the side count multiplies
74+ straight into the WASM heap, and eight sides is smooth enough at the size
75+ these draw. Three is the fewest that still closes a ring, and eight is the
76+ default for callers that name no resolution at all.
77+ """
78+ return 8 if resolution is None else max (3 , int (resolution ) // 2 )
79+
80+
81+ def _lite_ring (n_side , radius ):
82+ """Return one ``n_side`` circle of radius ``radius``, in the x=0 plane."""
83+ angles = np .linspace (0.0 , 2 * np .pi , n_side , endpoint = False )
84+ return np .column_stack (
85+ [np .zeros (n_side ), radius * np .cos (angles ), radius * np .sin (angles )]
86+ )
87+
88+
6889def _rgb (color ):
6990 """Return an (r, g, b) 0-1 tuple, the only color form pyvista-js takes.
7091
@@ -135,30 +156,25 @@ def _lite_get_view(plotter):
135156_lite_live_plotters = []
136157
137158
138- def _lite_release_plotter (plotter , close = True ):
159+ def _lite_release_plotter (plotter ):
139160 """Hand back a plotter's meshes, JS arrays and GPU buffers.
140161
141162 ``clear()`` empties the actor list, which is where the geometry is held,
142- so that is what frees the memory. ``close=False`` additionally says not to
143- tear the render window down -- what trimming an older scene wants, since
144- the notebook has already drawn it. pyvista-js 0.15 implements neither
145- ``deep_clean`` nor ``close``, so today the two paths do the same thing;
146- the flag keeps the intent right if that changes.
163+ so that is what frees the memory, and it is the only teardown pyvista-js
164+ 0.15 offers: there is no ``close()`` to tear the render window down as
165+ well, which is why closing a figure and clearing one do the same thing
166+ here.
167+
168+ Collecting is left to the caller, so draining a whole registry sweeps once
169+ rather than once per scene.
147170 """
148171 if plotter is None :
149172 return None
150173 for idx in range (len (_lite_live_plotters ) - 1 , - 1 , - 1 ):
151174 live = _lite_live_plotters [idx ]()
152175 if live is None or live is plotter :
153176 del _lite_live_plotters [idx ]
154- # pyvista-js is someone else's surface, so use whichever teardown of these
155- # it actually implements
156- names = ("clear" , "deep_clean" , "close" ) if close else ("clear" , "deep_clean" )
157- for name in names :
158- teardown = getattr (plotter , name , None )
159- if teardown is not None :
160- teardown ()
161- gc .collect ()
177+ plotter .clear ()
162178 return None
163179
164180
@@ -173,13 +189,17 @@ def _lite_release_plotter(plotter, close=True):
173189
174190def _lite_trim_live_plotters ():
175191 """Release everything but the most recent scenes."""
192+ trimmed = False
176193 while len (_lite_live_plotters ) > _LITE_MAX_LIVE_SCENES :
177194 oldest = _lite_live_plotters [0 ]()
178195 if oldest is None :
179196 _lite_live_plotters .pop (0 )
180197 else :
181198 # also drops it from the registry, so this terminates
182- _lite_release_plotter (oldest , close = False )
199+ _lite_release_plotter (oldest )
200+ trimmed = True
201+ if trimmed :
202+ gc .collect ()
183203 return None
184204
185205
@@ -195,6 +215,10 @@ class _LiteRenderer(_AbstractRenderer):
195215 _kind = "jupyterlite_notebook"
196216
197217 def __init__ (self , fig = None , size = (600 , 600 ), bgcolor = "black" , ** kwargs ):
218+ # `size` is named to match _PyVistaRenderer but cannot be honoured:
219+ # pv.Plotter takes only a lighting mode, and generate_standalone_html
220+ # emits a fixed 600x400 canvas with no knob for it.
221+ #
198222 # plot_alignment(fig=...) and plot_dipole_locations(fig=...) composite
199223 # into a scene the notebook already made, so draw into that plotter
200224 # 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):
207231 # _LITE_MAX_LIVE_SCENES is the number that actually stays live
208232 _lite_trim_live_plotters ()
209233 self .plotter .background_color = _rgb (bgcolor )
210- # even lighting, so a surface is not black when rotated
234+ # A scene light in vtk.js lights only what faces it, so a single one
235+ # leaves half of a head dark as soon as it is turned. Six along the axes
236+ # cover every side; each is well under full intensity because a surface
237+ # facing two of them at once would otherwise blow out. The distance only
238+ # has to sit outside the scene, which is metres-scale here.
211239 for direction in (
212240 (1 , 0 , 0 ),
213241 (- 1 , 0 , 0 ),
@@ -272,12 +300,8 @@ def _glyph_template(
272300 # pyvista.Cone(center=(0.5, 0, 0)): base at x=0, apex at x=height
273301 rad = 0.15 if radius is None else float (radius )
274302 hgt = 1.0 if height is None else float (height )
275- n_side = 8 if not resolution else max (3 , int (resolution ) // 2 )
276- angles = np .linspace (0.0 , 2 * np .pi , n_side , endpoint = False )
277- ring = np .column_stack (
278- [np .zeros (n_side ), rad * np .cos (angles ), rad * np .sin (angles )]
279- )
280- rr = np .vstack ([ring , [[hgt , 0 , 0 ]], [[0.0 , 0 , 0 ]]])
303+ n_side = _lite_n_side (resolution )
304+ rr = np .vstack ([_lite_ring (n_side , rad ), [[hgt , 0 , 0 ]], [[0.0 , 0 , 0 ]]])
281305 tris = []
282306 for this in range (n_side ):
283307 nxt = (this + 1 ) % n_side
@@ -286,10 +310,7 @@ def _glyph_template(
286310 # cylinder along +x, matching _cylinder_geom's convention
287311 rad = 0.1 if radius is None else float (radius )
288312 hgt = 1.0 if height is None else float (height )
289- # half the sides VTK would use: _tile stamps this template at every
290- # sensor, so the side count multiplies straight into the WASM heap and
291- # a 16-sided EEG cylinder is smooth enough at the size it draws
292- n_side = 8 if not resolution else max (3 , int (resolution ) // 2 )
313+ n_side = _lite_n_side (resolution )
293314 # _cylinder_geom builds the cylinder along y and turns it 90 degrees
294315 # about z to point it along x, which carries the center round with it:
295316 # (cx, cy, cz) lands at (-cy, cx, cz). _3d.py gives the EEG electrode
@@ -300,10 +321,7 @@ def _glyph_template(
300321 else :
301322 center = np .asarray (center , dtype = float )
302323 offset = np .array ([- center [1 ], center [0 ], center [2 ]])
303- angles = np .linspace (0.0 , 2 * np .pi , n_side , endpoint = False )
304- ring = np .column_stack (
305- [np .zeros (n_side ), rad * np .cos (angles ), rad * np .sin (angles )]
306- )
324+ ring = _lite_ring (n_side , rad )
307325 back = ring + np .array ([- hgt / 2.0 , 0 , 0 ])
308326 front = ring + np .array ([hgt / 2.0 , 0 , 0 ])
309327 rr = (
@@ -325,6 +343,8 @@ def _add(self, points, tris, color, opacity=1.0):
325343 drawing method here funnels through this, so translating it once covers
326344 all of them.
327345 """
346+ # float32 halves what the merged glyph meshes cost in the WASM heap,
347+ # and vtk.js uses single precision on the GPU regardless
328348 mesh = pv .PolyData (
329349 points = np .asarray (points , dtype = np .float32 ), faces = _vtk_faces (tris )
330350 )
@@ -425,6 +445,11 @@ def sphere(
425445 radius = None ,
426446 ** kwargs ,
427447 ):
448+ # `resolution` has no equivalent here: _pyvista.py asks pyvista.Sphere
449+ # for that many theta and phi bands, while this template comes from a
450+ # subdivided octahedron, whose vertex count goes 6, 18, 66, 258. Level 3
451+ # is the one that lands near the default 8x8 sphere, and nothing in
452+ # mne/viz asks for another, so it is fixed rather than approximated.
428453 center = np .atleast_2d (np .asarray (center , dtype = float ))
429454 if not len (center ):
430455 return None , None
@@ -510,7 +535,8 @@ def quiver3d(
510535 n_pos = len (centers )
511536 if not n_pos :
512537 return None , None
513- factor = float (np .asarray (scale ).ravel ()[0 ]) if np .size (scale ) else 1.0
538+ # MNE always passes a scalar here; VTK's SetScaleFactor takes one too
539+ factor = float (scale )
514540 idx = np .arange (n_pos )
515541 u , v , w = (np .atleast_1d (np .asarray (q , dtype = float )) for q in (u , v , w ))
516542 dirs = np .column_stack ([u [idx % len (u )], v [idx % len (v )], w [idx % len (w )]])
@@ -562,14 +588,22 @@ def quiver3d(
562588 template_kw = dict (
563589 radius = glyph_radius , height = glyph_height , resolution = glyph_resolution
564590 )
565- else : # arrow / 2darrow, both of which vtk draws with a shaft and a tip
591+ else :
592+ # "arrow" is vtkArrowSource, a shaft with a cone tip. "2darrow" is
593+ # really vtkGlyphSource2D with FilledOff, a flat outline; vtk.js has
594+ # no 2D glyph source, so it borrows the 3D arrow. Only Brain asks
595+ # for it, and Brain does not run here.
566596 kind , template_kw = "arrow" , dict ()
567597 rr , tris = self ._glyph_template (kind , ** template_kw )
568598 if solid_transform is not None :
569599 # _pyvista.py transforms the template before glyphing, and this is
570600 # where the fiducial markers get their size and 45 deg roll
571601 solid_transform = np .asarray (solid_transform , dtype = float )
572602 rr = rr @ solid_transform [:3 , :3 ].T + solid_transform [:3 , 3 ]
603+ # a sphere looks the same however it is turned, so skip the rotation
604+ # rather than build N matrices for it. "oct" joins it because the only
605+ # caller (the MRI fiducials) points every glyph along +x, which is the
606+ # identity; a future caller pointing them elsewhere would need this back
573607 rots = None if mode in ("sphere" , "oct" ) else self ._rots_from_dirs (dirs )
574608 points , faces = self ._tile (rr , tris , centers , scales = sizes , rots = rots )
575609 return self ._add (points , faces , color , opacity )
@@ -824,6 +858,9 @@ def show(self):
824858def _set_3d_view (
825859 figure , azimuth = None , elevation = None , focalpoint = None , distance = None , roll = None
826860):
861+ # distance, focalpoint and roll go unused here for the same reason they do
862+ # in _LiteRenderer.set_camera: vtk.js frames the scene with resetCamera()
863+ # on this path. See _lite_get_view.
827864 return _lite_set_view (figure , azimuth , elevation )
828865
829866
@@ -835,14 +872,16 @@ def _set_3d_title(figure, title, size=16, *, color="white", position="upper_left
835872
836873
837874def _clear_3d_figure (figure ):
838- # close=False is already the "give the geometry back but keep the scene"
839- # path, which is what clearing means
840- _lite_release_plotter (figure , close = False )
875+ _lite_release_plotter (figure )
876+ gc .collect ()
841877 return None
842878
843879
844880def _close_3d_figure (figure ):
881+ # the same as clearing: vtk.js draws into a canvas in an output cell, so
882+ # there is no window left to close once the geometry is gone
845883 _lite_release_plotter (figure )
884+ gc .collect ()
846885 return None
847886
848887
@@ -856,4 +895,5 @@ def _close_all():
856895 _lite_live_plotters .pop ()
857896 else :
858897 _lite_release_plotter (plotter )
898+ gc .collect () # once for the whole registry, not once per scene
859899 return None
0 commit comments