@@ -54,6 +54,34 @@ def _lite_set_view(plotter, azimuth):
5454 return None
5555
5656
57+ # Every scene a notebook drew used to stay live for the kernel's lifetime,
58+ # because the close helpers on _LiteBackend were no-ops. Track the plotters
59+ # weakly -- so they stay collectable -- and give close_all something to free.
60+ _lite_live_plotters = []
61+
62+
63+ def _lite_release_plotter(plotter):
64+ """Hand back a plotter's meshes, JS arrays and GPU buffers."""
65+ import gc as _gc
66+ if plotter is None:
67+ return None
68+ for _i in range(len(_lite_live_plotters) - 1, -1, -1):
69+ _p = _lite_live_plotters[_i]()
70+ if _p is None or _p is plotter:
71+ del _lite_live_plotters[_i]
72+ # pyvista-js is someone else's surface, so use whichever teardown of these
73+ # it actually implements
74+ for _name in ("clear", "deep_clean", "close"):
75+ _fn = getattr(plotter, _name, None)
76+ if _fn is not None:
77+ try:
78+ _fn()
79+ except Exception:
80+ pass
81+ _gc.collect()
82+ return None
83+
84+
5785class _LiteRenderer:
5886 """Minimal MNE 3D renderer backed by pyvista-js."""
5987
@@ -63,6 +91,8 @@ def __init__(self, *args, **kwargs):
6391 self._np = _np
6492 self._pv = _pv
6593 self.plotter = _pv.Plotter()
94+ import weakref as _weakref
95+ _lite_live_plotters.append(_weakref.ref(self.plotter))
6696 _bg = kwargs.get("bgcolor", kwargs.get("background_color", "black"))
6797 try:
6898 self.plotter.background_color = self._rgb(_bg)
@@ -99,23 +129,69 @@ def _faces(self, tris):
99129 return _np.hstack([
100130 _np.full((len(_t), 1), 3, dtype=_np.int32), _t]).ravel()
101131
132+ def _subdivide(self, rr, tris):
133+ """One level of midpoint subdivision, sharing the new edge vertices."""
134+ _np = self._np
135+ _rr = [tuple(_v) for _v in _np.asarray(rr, dtype=float)]
136+ _mid = {}
137+ _out = []
138+ for _a, _b, _c in _np.asarray(tris, dtype=int):
139+ _m = []
140+ for _p, _q in ((_a, _b), (_b, _c), (_c, _a)):
141+ _k = (min(int(_p), int(_q)), max(int(_p), int(_q)))
142+ if _k not in _mid:
143+ _mid[_k] = len(_rr)
144+ _rr.append(tuple((_np.asarray(_rr[_p])
145+ + _np.asarray(_rr[_q])) / 2.0))
146+ _m.append(_mid[_k])
147+ _ab, _bc, _ca = _m
148+ _out += [[_a, _ab, _ca], [_ab, _b, _bc], [_ca, _bc, _c],
149+ [_ab, _bc, _ca]]
150+ return _np.asarray(_rr, dtype=float), _np.asarray(_out, dtype=int)
151+
102152 def _glyph_template(self, kind, radius=None, height=None, center=None,
103153 resolution=None, **kwargs):
104- """Return (rr, tris) for instanced_mesh , oriented along +x.
154+ """Return (rr, tris) for a glyph template , oriented along +x.
105155
106156 pyvista-js's Sphere/Cylinder are parametric primitives with no
107- triangle list, so build the templates here. These are markers a few
108- millimetres across, so keep them low-poly -- every instance is a
109- separate mesh and the WASM heap is not large.
157+ triangle list, so build the templates here. ``_tile`` then stamps one
158+ of these at every position and merges the result, which is what keeps
159+ these cheap -- the copies share a single mesh and a single actor.
160+
161+ Sizes follow the templates ``_pyvista.py`` hands the glyph filter, so
162+ the browser draws the markers at the size the rendered docs do.
110163 """
111164 _np = self._np
112- if kind == "sphere":
165+ if kind in ( "sphere", "oct") :
113166 _r = 0.5 if radius is None else float(radius)
114- rr = _np.array([[_r , 0, 0], [-_r , 0, 0], [0, _r , 0],
115- [0, -_r , 0], [0, 0, _r ], [0, 0, -_r ]], float)
167+ rr = _np.array([[1.0 , 0, 0], [-1.0 , 0, 0], [0, 1.0 , 0],
168+ [0, -1.0 , 0], [0, 0, 1.0 ], [0, 0, -1.0 ]], float)
116169 tris = _np.array([[0, 2, 4], [2, 1, 4], [1, 3, 4], [3, 0, 4],
117170 [2, 0, 5], [1, 2, 5], [3, 1, 5], [0, 3, 5]], int)
118- return rr, tris
171+ # "oct" is an octahedron on purpose -- that is what _pyvista.py
172+ # hands the glyph filter. A "sphere" has to look round, though:
173+ # fiducials and dig points are drawn with it, so subdivide onto the
174+ # unit sphere to land near the reference's 8x8 sphere (58 verts).
175+ if kind == "sphere":
176+ for _ in range(2):
177+ rr, tris = self._subdivide(rr, tris)
178+ rr /= _np.linalg.norm(rr, axis=1)[:, None]
179+ return rr * _r, tris
180+ if kind == "cone":
181+ # apex along +x so the glyph filter's orientation applies, matching
182+ # pyvista.Cone(center=(0.5, 0, 0)): base at x=0, apex at x=height
183+ _r = 0.15 if radius is None else float(radius)
184+ _h = 1.0 if height is None else float(height)
185+ _n = 8 if not resolution else max(3, int(resolution) // 2)
186+ _ang = _np.linspace(0.0, 2 * _np.pi, _n, endpoint=False)
187+ _ring = _np.column_stack([_np.zeros(_n), _r * _np.cos(_ang),
188+ _r * _np.sin(_ang)])
189+ rr = _np.vstack([_ring, [[_h, 0, 0]], [[0.0, 0, 0]]])
190+ tris = []
191+ for _i in range(_n):
192+ _j = (_i + 1) % _n
193+ tris += [[_i, _j, _n], [_n + 1, _j, _i]] # side, base
194+ return rr, _np.asarray(tris, int)
119195 # cylinder along +x, matching _cylinder_geom's convention
120196 _r = 0.1 if radius is None else float(radius)
121197 _h = 1.0 if height is None else float(height)
@@ -147,6 +223,45 @@ def _add(self, points, tris, color, opacity=1.0):
147223 smooth_shading=True)
148224 return _actor, _pd
149225
226+ def _rots_from_dirs(self, dirs):
227+ """Rotations carrying +x onto each direction, as the glyphs assume."""
228+ _np = self._np
229+ from mne.transforms import _find_vector_rotation as _fvr
230+ _x = _np.array([1.0, 0.0, 0.0])
231+ return _np.asarray([_fvr(_x, _d) for _d in dirs], dtype=float)
232+
233+ def _tile(self, rr, tris, positions, scales=None, rots=None,
234+ axis_scales=None):
235+ """Stamp one template mesh at many positions as a single mesh.
236+
237+ ``_pyvista.py`` hands its template to VTK's glyph filter, which bakes
238+ every copy into one mesh and adds it once. Doing this per position
239+ instead means an oct-6 source space becomes 8196 meshes and 8196
240+ actors, which is enough to run the browser tab out of memory.
241+ """
242+ _np = self._np
243+ _rr = _np.asarray(rr, dtype=float)
244+ _tris = _np.asarray(tris, dtype=int)
245+ _pos = _np.atleast_2d(_np.asarray(positions, dtype=float))[:, :3]
246+ _n = len(_pos)
247+ _pts = _np.repeat(_rr[None, :, :], _n, axis=0)
248+ if axis_scales is not None:
249+ # tubes span a given length without fattening, so scale the
250+ # template's axis alone
251+ _ax = _np.atleast_1d(_np.asarray(axis_scales, dtype=float))
252+ _pts[:, :, 0] *= _ax[_np.arange(_n) % len(_ax)][:, None]
253+ if scales is not None:
254+ _sa = _np.atleast_1d(_np.asarray(scales, dtype=float))
255+ _pts *= _sa[_np.arange(_n) % len(_sa)][:, None, None]
256+ if rots is not None:
257+ _ra = _np.asarray(rots, dtype=float)
258+ _pts = _np.einsum(
259+ 'nij,nkj->nki', _ra[_np.arange(_n) % len(_ra)], _pts)
260+ _pts += _pos[:, None, :]
261+ _off = (_np.arange(_n) * len(_rr))[:, None, None]
262+ return (_pts.reshape(-1, 3),
263+ (_tris[None, :, :] + _off).reshape(-1, 3))
264+
150265 # -- drawing ------------------------------------------------------------
151266 def mesh(self, x, y, z, triangles, color=None, opacity=1.0, *args, **kwargs):
152267 _np = self._np
@@ -162,92 +277,135 @@ def sphere(self, center, color=None, scale=1.0, opacity=1.0,
162277 resolution=8, backface_culling=False, radius=None, **kwargs):
163278 _np = self._np
164279 _c = _np.atleast_2d(_np.asarray(center, dtype=float))
280+ if not len(_c):
281+ return None, None
165282 _r = float(radius if radius is not None else scale)
166- _actor = _mesh = None
167- for _p in _c:
168- _mesh = self._pv.Sphere(
169- radius=_r, center=tuple(float(_q) for _q in _p[:3]))
170- _actor = self.plotter.add_mesh(
171- _mesh, color=self._rgb(color), opacity=float(opacity),
172- smooth_shading=True)
173- return _actor, _mesh
174-
175- def tube(self, origin, destination, radius=0.001, color=None, *args, **kwargs):
283+ _rr, _tris = self._glyph_template("sphere", radius=_r,
284+ resolution=resolution)
285+ _pts, _faces = self._tile(_rr, _tris, _c)
286+ return self._add(_pts, _faces, color, opacity)
287+
288+ def tube(self, origin, destination, radius=0.001, color=None, *args,
289+ **kwargs):
176290 _np = self._np
177- _o = _np.atleast_2d(_np.asarray(origin, dtype=float))
178- _d = _np.atleast_2d(_np.asarray(destination, dtype=float))
179- _actor = _mesh = None
180- for _a, _b in zip(_o, _d):
181- _vec = _b[:3] - _a[:3]
182- _len = float(_np.linalg.norm(_vec))
183- if _len == 0.0:
184- continue
185- _mesh = self._pv.Cylinder(
186- center=tuple(float(_q) for _q in (_a[:3] + _b[:3]) / 2.0),
187- direction=tuple(float(_q) for _q in _vec / _len),
188- radius=float(radius), height=_len)
189- _actor = self.plotter.add_mesh(
190- _mesh, color=self._rgb(color), smooth_shading=True)
191- return _actor, _mesh
291+ _o = _np.atleast_2d(_np.asarray(origin, dtype=float))[:, :3]
292+ _d = _np.atleast_2d(_np.asarray(destination, dtype=float))[:, :3]
293+ _n = min(len(_o), len(_d))
294+ if not _n:
295+ return None, None
296+ _vec = _d[:_n] - _o[:_n]
297+ _len = _np.linalg.norm(_vec, axis=1)
298+ _keep = _len > 0
299+ if not _keep.any():
300+ return None, None
301+ _vec, _len = _vec[_keep], _len[_keep]
302+ _ctr = (_o[:_n][_keep] + _d[:_n][_keep]) / 2.0
303+ # one unit-height template stretched to each segment, merged into a
304+ # single mesh rather than a cylinder primitive per segment
305+ _rr, _tris = self._glyph_template("cylinder", radius=float(radius),
306+ height=1.0)
307+ _pts, _faces = self._tile(
308+ _rr, _tris, _ctr, rots=self._rots_from_dirs(_vec / _len[:, None]),
309+ axis_scales=_len)
310+ return self._add(_pts, _faces, color, kwargs.get("opacity", 1.0))
192311
193312 def quiver3d(self, x, y, z, u, v, w, color=None, scale=1.0, mode="arrow",
194- opacity=1.0, *args, **kwargs):
313+ opacity=1.0, *, glyph_height=None, glyph_center=None,
314+ glyph_resolution=None, glyph_radius=0.15,
315+ solid_transform=None, **kwargs):
316+ """Draw one merged glyph mesh, the way the glyph filter would.
317+
318+ ``_pyvista.py`` builds a template, lets VTK's glyph filter bake a copy
319+ at every point into one mesh, and adds that once. Drawing a primitive
320+ per point instead is what made ``20_source_alignment`` -- an oct-6
321+ source space, so 8196 glyphs, twice -- exhaust the browser tab.
322+ """
195323 _np = self._np
196324 _x, _y, _z = (_np.atleast_1d(_np.asarray(_q, dtype=float))
197325 for _q in (x, y, z))
326+ _ctr = _np.column_stack([_x, _y, _z])
327+ _n = len(_ctr)
328+ if not _n:
329+ return None, None
330+ _s = float(_np.asarray(scale).ravel()[0]) if _np.size(scale) else 1.0
331+ _i = _np.arange(_n)
198332 _u, _v, _w = (_np.atleast_1d(_np.asarray(_q, dtype=float))
199333 for _q in (u, v, w))
200- _s = float(_np.asarray(scale).ravel()[0]) if _np.size(scale) else 1.0
201- _actor = _g = None
202- for _i in range(len(_x)):
203- _ctr = (float(_x[_i]), float(_y[_i]), float(_z[_i]))
204- _dir = (float(_u[_i % len(_u)]), float(_v[_i % len(_v)]),
205- float(_w[_i % len(_w)]))
206- if _np.linalg.norm(_dir) == 0.0:
207- _dir = (0.0, 0.0, 1.0)
208- if mode == "sphere":
209- _g = self._pv.Sphere(radius=_s / 2.0, center=_ctr)
210- elif mode in ("cylinder", "oct"):
211- _g = self._pv.Cylinder(center=_ctr, direction=_dir,
212- radius=_s / 4.0, height=_s)
213- else: # arrow / cone / 2darrow
214- _g = self._pv.Cone(center=_ctr, direction=_dir,
215- height=_s, radius=_s / 2.0)
216- _actor = self.plotter.add_mesh(
217- _g, color=self._rgb(color), opacity=float(opacity),
218- smooth_shading=True)
219- return _actor, _g
334+ _dirs = _np.column_stack([_u[_i % len(_u)], _v[_i % len(_v)],
335+ _w[_i % len(_w)]])
336+ _norm = _np.linalg.norm(_dirs, axis=1)
337+ _flat = _norm == 0
338+ _dirs[_flat] = (1.0, 0.0, 0.0)
339+ _norm[_flat] = 1.0
340+ _dirs = _dirs / _norm[:, None]
341+ # the same templates _pyvista.py feeds the filter; `scale` then plays
342+ # the part its `factor` does
343+ if mode == "oct":
344+ # vtkPlatonicSolidSource puts its octahedron on the unit
345+ # circumsphere, and the MRI fiducials get their real size from
346+ # solid_transform (mri_fid_scale, 5 mm) rather than from `scale`
347+ _kind, _tkw = "oct", dict(radius=1.0)
348+ elif mode == "sphere":
349+ _kind, _tkw = "sphere", dict(radius=0.5)
350+ elif mode == "cylinder":
351+ _kind = "cylinder"
352+ _tkw = dict(radius=glyph_radius, height=glyph_height,
353+ center=glyph_center, resolution=glyph_resolution)
354+ else: # arrow / cone / 2darrow
355+ _kind = "cone"
356+ _tkw = dict(radius=glyph_radius, height=glyph_height,
357+ resolution=glyph_resolution)
358+ _rr, _tris = self._glyph_template(_kind, **_tkw)
359+ if solid_transform is not None:
360+ # _pyvista.py transforms the template before glyphing, and this is
361+ # where the fiducial markers get their size and 45 deg roll
362+ _st = _np.asarray(solid_transform, dtype=float)
363+ _rr = _rr @ _st[:3, :3].T + _st[:3, 3]
364+ _rots = (None if mode in ("sphere", "oct")
365+ else self._rots_from_dirs(_dirs))
366+ _pts, _faces = self._tile(_rr, _tris, _ctr, scales=_s, rots=_rots)
367+ return self._add(_pts, _faces, color, opacity)
220368
221369 def instanced_mesh(self, rr, tris, positions, quats=None, colors=None,
222370 scales=None, opacity=1.0, *args, **kwargs):
223- # one copy of the template per position; rotate with MNE's own
224- # quaternion helper so oriented glyphs (EEG cylinders) point the way
225- # MNE intended rather than all along +x.
371+ """Stamp the template at every position, merged per distinct color.
372+
373+ Rotate with MNE's own quaternion helper so oriented glyphs (EEG
374+ cylinders) point the way MNE intended rather than all along +x.
375+ pyvista-js has no per-vertex color, so instances are grouped by the
376+ color they asked for and each group becomes one mesh -- a handful of
377+ actors for a sensor array instead of one per sensor.
378+ """
226379 _np = self._np
227- _rr = _np.asarray(rr , dtype=float)
228- _pos = _np.atleast_2d(_np.asarray(positions, dtype=float) )
229- _quats = None if quats is None else _np.atleast_2d(
230- _np.asarray(quats, dtype=float))
380+ _pos = _np.atleast_2d(_np. asarray(positions , dtype=float))[:, :3]
381+ _n = len(_pos )
382+ if not _n:
383+ return None, None
231384 _rot = None
232- if _quats is not None:
233- try:
234- from mne.transforms import quat_to_rot as _q2r
235- _rot = _q2r(_quats)
236- except Exception:
237- _rot = None
385+ if quats is not None:
386+ from mne.transforms import quat_to_rot as _q2r
387+ _rot = _np.asarray(_q2r(_np.atleast_2d(
388+ _np.asarray(quats, dtype=float))), dtype=float)
389+ _idx = _np.arange(_n)
390+ if colors is not None and _np.ndim(colors) > 1:
391+ _ca = _np.asarray(colors)
392+ _uniq, _inv = _np.unique(_ca[_idx % len(_ca)], axis=0,
393+ return_inverse=True)
394+ _inv = _np.asarray(_inv).ravel()
395+ _groups = [(_uniq[_k], _idx[_inv == _k])
396+ for _k in range(len(_uniq))]
397+ else:
398+ _groups = [(colors, _idx)]
238399 _out = (None, None)
239- for _i, _p in enumerate(_pos) :
240- _s = 1.0
400+ for _col, _sel in _groups :
401+ _sc = None
241402 if scales is not None:
242403 _sa = _np.atleast_1d(_np.asarray(scales, dtype=float))
243- _s = float(_sa[_i % len(_sa)])
244- _col = colors
245- if colors is not None and _np.ndim(colors) > 1:
246- _col = _np.asarray(colors)[_i % len(colors)]
247- _pts = _rr * _s
248- if _rot is not None:
249- _pts = _pts @ _np.asarray(_rot[_i % len(_rot)]).T
250- _out = self._add(_pts + _p[:3], tris, _col, opacity)
404+ _sc = _sa[_sel % len(_sa)]
405+ _rt = None if _rot is None else _rot[_sel % len(_rot)]
406+ _pts, _faces = self._tile(rr, tris, _pos[_sel], scales=_sc,
407+ rots=_rt)
408+ _out = self._add(_pts, _faces, _col, opacity)
251409 return _out
252410
253411 # -- things the static docs do not need ---------------------------------
@@ -344,9 +502,19 @@ def _set_3d_title(self, figure, title, size=40, color="white",
344502 return None
345503
346504 def _close_3d_figure(self, figure):
505+ _lite_release_plotter(figure)
347506 return None
348507
349508 def _close_all(self):
509+ # the registry holds weak references, so deref before releasing --
510+ # handing the ref itself to _lite_release_plotter matches nothing and
511+ # never shortens the list
512+ while _lite_live_plotters:
513+ _p = _lite_live_plotters[-1]()
514+ if _p is None:
515+ _lite_live_plotters.pop()
516+ else:
517+ _lite_release_plotter(_p)
350518 return None
351519
352520
0 commit comments