diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 1c2dcf0ef..586ad3c53 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -1,6 +1,6 @@ name: Python package -on: [push, pull_request] +on: push concurrency: group: check-${{ github.ref }} @@ -29,10 +29,10 @@ jobs: fail-fast: false matrix: py: - - "3.12" - - "3.11" - - "3.10" - - "3.9" + #- "3.12" + #- "3.11" + #- "3.10" + #- "3.9" - "3.8" os: - ubuntu-latest @@ -41,6 +41,9 @@ jobs: steps: - name: Setup headless display uses: pyvista/setup-headless-display-action@v2 + with: + qt: true + if: startsWith(matrix.os, 'ubuntu') - uses: actions/checkout@v4 with: fetch-depth: 0 diff --git a/magpylib/_src/defaults/defaults_utility.py b/magpylib/_src/defaults/defaults_utility.py index 77e33dabf..988b0ad5b 100644 --- a/magpylib/_src/defaults/defaults_utility.py +++ b/magpylib/_src/defaults/defaults_utility.py @@ -9,7 +9,8 @@ from magpylib._src.defaults.defaults_values import DEFAULTS -SUPPORTED_PLOTTING_BACKENDS = ("matplotlib", "plotly", "pyvista") + +SUPPORTED_PLOTTING_BACKENDS = ("matplotlib", "plotly", "pyvista", "mayavi") ALLOWED_SYMBOLS = (".", "+", "D", "d", "s", "x", "o") diff --git a/magpylib/_src/display/backend_mayavi.py b/magpylib/_src/display/backend_mayavi.py new file mode 100644 index 000000000..8a2bcc3d6 --- /dev/null +++ b/magpylib/_src/display/backend_mayavi.py @@ -0,0 +1,203 @@ +from functools import lru_cache + +import numpy as np +from matplotlib.colors import colorConverter + +try: + from mayavi import mlab +except ImportError as missing_module: # pragma: no cover + raise ModuleNotFoundError( + """In order to use the mayavi plotting backend, you need to install mayavi via pip or + conda, see https://docs.enthought.com/mayavi/mayavi/installation.html""" + ) from missing_module +from magpylib._src.display.traces_utility import subdivide_mesh_by_facecolor +from magpylib._src.utility import is_notebook + +# from magpylib._src.utility import format_obj_input + +SYMBOLS_MAYAVI = { + "circle": "2dcircle", + "cross": "2dcross", + "diamond": "2ddiamond", + "square": "2dsquare", + "x": "2dcross", + ".": "sphere", + "o": "2dcircle", + "+": "2dcross", + "D": "2ddiamond", + "d": "2ddiamond", + "s": "2dsquare", +} + + +@lru_cache(maxsize=None) +def to_rgba_array(color, opacity=1): + """Convert color to rgba_array""" + return colorConverter.to_rgba_array( + (0.0, 0.0, 0.0, opacity) if color is None else color + )[0] + + +@lru_cache(maxsize=None) +def colorscale_to_lut(colorscale, opacity=1): + "Convert plotly colorscale to vtk lut array." + colors = np.array([to_rgba_array(v[1], opacity) * 255 for v in colorscale]) + inds = np.array([int(256 * v[0]) for v in colorscale]) + return np.array([np.interp([*range(256)], inds, c) for c in colors.T]).T + + +def generic_trace_to_mayavi(trace): + """Transform a generic trace into a mayavi trace""" + traces_mvi = [] + if trace["type"] == "mesh3d": + subtraces = [trace] + if trace.get("facecolor", None) is not None: + subtraces = subdivide_mesh_by_facecolor(trace) + for subtrace in subtraces: + x, y, z = np.array([subtrace[k] for k in "xyz"], dtype=float) + triangles = np.array([subtrace[k] for k in "ijk"]).T + opacity = trace.get("opacity", 1) + color = subtrace.get("color", None) + color = colorConverter.to_rgb( + (0.0, 0.0, 0.0, opacity) if color is None else color + ) + color_kwargs = {"color": color, "opacity": opacity} + colorscale = subtrace.get("colorscale", None) + if colorscale is not None: + color_kwargs = {"lut": colorscale_to_lut(colorscale, opacity)} + trace_mvi = { + "constructor": "triangular_mesh", + "mlab_source_names": {"x": x, "y": y, "z": z, "triangles": triangles}, + "args": (x, y, z, triangles), + "kwargs": { + "scalars": subtrace.get("intensity", None), + **color_kwargs, + }, + } + traces_mvi.append(trace_mvi) + elif trace["type"] == "scatter3d": + x, y, z = np.array([trace[k] for k in "xyz"], dtype=float) + opacity = trace.get("opacity", 1) + line = trace.get("line", {}) + line_color = line.get("color", trace.get("line_color", None)) + line_color = colorConverter.to_rgb( + (0.0, 0.0, 0.0, opacity) if line_color is None else line_color + ) + marker_color = line.get("color", trace.get("marker_color", None)) + marker_color = colorConverter.to_rgb( + line_color if marker_color is None else marker_color + ) + trace_mvi_base = { + "mlab_source_names": {"x": x, "y": y, "z": z}, + "args": (x, y, z), + } + kwargs = {"opacity": opacity, "color": line_color} + mode = trace.get("mode", None) + if mode is not None: + if "markers" in mode: + marker = trace.get("marker", {}) + marker_size = marker.get("size", trace.get("marker_size", 1)) / 5 + marker_symbol = marker.get( + "symbol", trace.get("marker_symbol", "2dcross") + ) + marker_symbol = SYMBOLS_MAYAVI.get(marker_symbol, "2dcross") + trace_mvi1 = {"constructor": "points3d", **trace_mvi_base} + trace_mvi1["kwargs"] = { + "scale_factor": 0.2 * marker_size, + "mode": marker_symbol, + "color": marker_color, + **kwargs, + } + traces_mvi.append(trace_mvi1) + if "lines" in mode: + trace_mvi2 = {"constructor": "plot3d", **trace_mvi_base} + trace_mvi2["kwargs"] = {**kwargs} + traces_mvi.append(trace_mvi2) + if "text" in mode and trace.get("text", False): + for xs, ys, zs, txt in zip(x, y, z, trace["text"]): + trace_mvi3 = { + "constructor": "text3d", + **trace_mvi_base, + "args": (xs, ys, zs, str(txt)), + } + trace_mvi3["kwargs"] = {**kwargs, "scale": 0.5} + traces_mvi.append(trace_mvi3) + else: # pragma: no cover + raise ValueError( + f"Trace type {trace['type']!r} cannot be transformed into mayavi trace" + ) + return traces_mvi + + +def display_mayavi( + data, + canvas=None, + return_fig=False, + legend_max_items=20, + **kwargs, # pylint: disable=unused-argument +): + """Display objects and paths graphically using the mayavi library.""" + + # flat_obj_list = format_obj_input(obj_list) + + frames = data["frames"] + animation = bool(len(frames) > 1) + in_notebook_env = is_notebook() + if in_notebook_env: + mlab.init_notebook() + show_canvas = False + if canvas is None: + if not return_fig: + show_canvas = True # pragma: no cover + canvas = mlab.figure() + fig = canvas + + for fr in frames: + new_data = [] + for tr in fr["data"]: + new_data.extend(generic_trace_to_mayavi(tr)) + fr["data"] = new_data + + mayvi_traces = [] + + def draw_frame(frame_ind): + for trace_ind, tr1 in enumerate(frames[frame_ind]["data"]): + if frame_ind == 0: + constructor = tr1["constructor"] + args = tr1["args"] + kwargs = tr1["kwargs"] + lut = kwargs.pop("lut", None) + tr = getattr(mlab, constructor)(*args, **kwargs) + if lut is not None: + tr.module_manager.scalar_lut_manager.lut.table = lut + tr.actor.mapper.interpolate_scalars_before_mapping = True + mayvi_traces.append(tr) + else: + mlab_source = getattr(mayvi_traces[trace_ind], "mlab_source", None) + if mlab_source is not None: + mlab_source.trait_set(**tr1["mlab_source_names"]) + mlab.draw(fig) + + draw_frame(0) + + if animation: + + @mlab.animate(delay=data["frame_duration"]) + def anim(): + while 1: + for frame_ind, _ in enumerate(frames): + if frame_ind > 0: + draw_frame(frame_ind) + yield + + anim() + if in_notebook_env and not return_fig: + # pylint: disable=import-outside-toplevel + from IPython.display import display + + display(fig) + if return_fig and not show_canvas: + return fig + if show_canvas: + mlab.show() + return None diff --git a/magpylib/_src/display/display.py b/magpylib/_src/display/display.py index 58b72318f..a2b621472 100644 --- a/magpylib/_src/display/display.py +++ b/magpylib/_src/display/display.py @@ -295,6 +295,7 @@ def show( - with matplotlib: `matplotlib.figure.Figure`. - with plotly: `plotly.graph_objects.Figure` or `plotly.graph_objects.FigureWidget`. - with pyvista: `pyvista.Plotter`. + - with mayavi: `mayavi.core.scene.Scene`. row: int or None, If provided specifies the row in which the objects will be displayed. @@ -509,3 +510,12 @@ def reset(self, reset_show_return_value=True): supports_colorgradient=True, supports_animation_output=True, ) + +RegisteredBackend( + name="mayavi", + show_func_getter=get_show_func("mayavi"), + supports_animation=True, + supports_subplots=False, + supports_colorgradient=True, + supports_animation_output=False, +) diff --git a/pyproject.toml b/pyproject.toml index f6a318d16..460aa1734 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,6 +64,7 @@ test = [ "imageio[tifffile]", "kaleido", "jupyterlab", + "mayavi", ] binder = [ "jupytext", @@ -79,5 +80,5 @@ Changelog = "https://github.com/magpylib/magpylib/blob/master/CHANGELOG.md" [tool.pytest.ini_options] -addopts = "--doctest-modules" +addopts = "--doctest-modules -ra" testpaths = ["magpylib", "tests"] diff --git a/tests/test__missing_optional_modules.py b/tests/test__missing_optional_modules.py index 7ee84dedf..c8904e814 100644 --- a/tests/test__missing_optional_modules.py +++ b/tests/test__missing_optional_modules.py @@ -1,22 +1,32 @@ -import sys from unittest.mock import patch import pytest import magpylib as magpy +# Note: Running pytest on docstrings and test folder makes the following tests fail. +# Needs to be fixed. + def test_show_with_missing_pyvista(): """Should raise if pyvista is not installed""" src = magpy.magnet.Cuboid((0, 0, 1000), (1, 1, 1)) - with patch.dict(sys.modules, {"pyvista": None}): + with patch.dict("sys.modules", {"pyvista": None}): # with pytest.raises(ModuleNotFoundError): src.show(return_fig=True, backend="pyvista") +def test_show_with_missing_mayavi(): + """Should raise if mayavi is not installed""" + src = magpy.magnet.Cuboid((0, 0, 1000), (1, 1, 1)) + with patch.dict("sys.modules", {"mayavi": None}): + # with pytest.raises(ModuleNotFoundError): + src.show(return_fig=True, backend="mayavi") + + def test_dataframe_output_missing_pandas(): """test if pandas is installed when using dataframe output in `getBH`""" src = magpy.magnet.Cuboid((0, 0, 1000), (1, 1, 1)) - with patch.dict(sys.modules, {"pandas": None}): + with patch.dict("sys.modules", {"pandas": None}): with pytest.raises(ModuleNotFoundError): src.getB((0, 0, 0), output="dataframe") diff --git a/tests/test_display_mayavi.py b/tests/test_display_mayavi.py new file mode 100644 index 000000000..ecbdacc53 --- /dev/null +++ b/tests/test_display_mayavi.py @@ -0,0 +1,44 @@ +import mayavi +from mayavi import mlab + +import magpylib as magpy + + +def test_Cuboid_display(): + "test simple display with path" + src = magpy.magnet.Cuboid((0, 0, 1000), (1, 1, 1)) + src.move([[i, 0, 0] for i in range(2)], start=0) + fig = src.show(return_fig=True, style_path_numbering=True, backend="mayavi") + mlab.options.offscreen = True + assert isinstance(fig, mayavi.core.scene.Scene) + + +def test_extra_generic_trace(): + "test simple display with path" + src = magpy.magnet.Cuboid((0, 0, 1000), (1, 1, 1)) + src.style.model3d.add_trace( + { + "constructor": "mesh3d", + "kwargs": { + "i": [7, 0, 0, 0, 4, 4, 2, 6, 4, 0, 3, 7], + "j": [0, 7, 1, 2, 6, 7, 1, 2, 5, 5, 2, 2], + "k": [3, 4, 2, 3, 5, 6, 5, 5, 0, 1, 7, 6], + "x": [-1, -1, 1, 1, -1, -1, 1, 1], + "y": [-1, 1, 1, -1, -1, 1, 1, -1], + "z": [-1, -1, -1, -1, 1, 1, 1, 1], + "facecolor": ["red"] * 12, + }, + "show": True, + } + ) + mlab.options.offscreen = True + fig = mlab.figure() + src.show(canvas=fig, style_path_numbering=True, backend="mayavi") + + +def test_animation(): + "test simple display with path" + mlab.options.offscreen = True + src = magpy.magnet.Cuboid((0, 0, 1000), (1, 1, 1)) + src.move([[i, 0, 0] for i in range(2)], start=0) + src.show(animation=True, style_path_numbering=True, backend="mayavi")