diff --git a/.binder/apt.txt b/.binder/apt.txt deleted file mode 100644 index e5f2b6eb1..000000000 --- a/.binder/apt.txt +++ /dev/null @@ -1,2 +0,0 @@ -libgl1-mesa-dev -xvfb diff --git a/.binder/labconfig/default_setting_overrides.json b/.binder/labconfig/default_setting_overrides.json deleted file mode 100644 index 84c367341..000000000 --- a/.binder/labconfig/default_setting_overrides.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "@jupyterlab/docmanager-extension:plugin": { - "defaultViewers": { - "markdown": "Jupytext Notebook", - "myst": "Jupytext Notebook" - } - } -} diff --git a/.binder/postBuild b/.binder/postBuild deleted file mode 100644 index 2514e5133..000000000 --- a/.binder/postBuild +++ /dev/null @@ -1,6 +0,0 @@ -# Stop everything if one command fails -set -e - -# See https://github.com/mwouts/jupytext/issues/803#issuecomment-982170660 -mkdir -p ${HOME}/.jupyter/labconfig -cp .binder/labconfig/* ${HOME}/.jupyter/labconfig diff --git a/.binder/requirements.txt b/.binder/requirements.txt deleted file mode 100644 index e9704b8eb..000000000 --- a/.binder/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -.[docs] diff --git a/.gitignore b/.gitignore index dd6be1e84..0147b9213 100644 --- a/.gitignore +++ b/.gitignore @@ -158,6 +158,7 @@ Thumbs.db *~ *.swp __temp*.py +__temp*.ipynb # uv uv.lock diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f3950184b..b9fd9fb9d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -40,7 +40,9 @@ repos: - id: prettier types_or: [yaml, markdown, html, css, scss, javascript, json] args: [--prose-wrap=always] - exclude: ^docs/ # messes up colon fences for grids in docs + # Exclude docs (messes up colon fences for grids) + # Excclude test YAML files from formatting (messes up fig data diffs) + exclude: ^docs/|^tests/.*\.ya?ml$ - repo: https://github.com/astral-sh/ruff-pre-commit rev: "v0.13.0" diff --git a/CHANGELOG.md b/CHANGELOG.md index 812d516e3..0e101844f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## [Unreleased] - YYYY-MM-DD +- Added new `style.pixel.field` parameters to quickly represent vector fields + ([#793](https://github.com/magpylib/magpylib/pull/793)) - Added the `current_sheet_Hfield` core computation function, and the classes `TriangleStrip` and `TriangleSheet` enabling current sheets in the object oriented interface. ([#788](https://github.com/magpylib/magpylib/issues/788)) diff --git a/docs/_pages/user_guide/docs/docs_styles.md b/docs/_pages/user_guide/docs/docs_styles.md index bdbd3a7ab..7e93cffb8 100644 --- a/docs/_pages/user_guide/docs/docs_styles.md +++ b/docs/_pages/user_guide/docs/docs_styles.md @@ -13,6 +13,7 @@ orphan: true --- (guide-graphic-styles)= + # Graphic styles The graphic styles define how Magpylib objects are displayed visually when calling `show`. They can be fine-tuned and individualized to suit requirements and taste. @@ -26,6 +27,7 @@ Graphic styles can be defined in various ways: The following sections describe these styling options and how to customize them. (guide-graphic-styles-default)= + ## Default style The default style is stored in `magpylib.defaults.display.style`. Note that the installation default styles differ slightly between different [graphic backends](guide-graphic-backends) depending on their respective capabilities. Specifically, the magnet magnetization in Matplotlib is displayed with arrows by default, while it is displayed using a color scheme in Plotly and Pyvista. The color scheme is also implemented in Matplotlib, but it is visually unsatisfactory. @@ -109,7 +111,6 @@ The default Magpylib style abides by the tri-color scheme for ideal-typical magn A list of all style options can be found [here](examples-list-of-styles). - ## Magic underscore notation To facilitate working with deeply nested properties, all style constructors and object style methods support the "magic underscore notation". It enables referencing nested properties by joining together multiple property names with underscores. This feature mainly helps reduce the code verbosity and is heavily inspired by the [Plotly underscore notation](https://plotly.com/python/creating-and-updating-figures/#magic-underscore-notation)). @@ -246,6 +247,7 @@ magpy.defaults.display.style.as_dict(flatten=True, separator=".") ``` (examples-own-3d-models)= + ## Custom 3D models Each Magpylib object has a default 3D representation that is displayed with `show`. It is possible to disable the default model and to provide Magpylib with a custom model. @@ -471,6 +473,7 @@ obj0.show(obj1, obj2, obj3, obj4, obj5, backend="plotly") ``` ((guide-docs-style-cad))= + ## Adding a CAD model The following code sample shows how a standard CAD model (*.stl file) can be transformed into a Magpylib `Trace3d` object. @@ -561,3 +564,100 @@ magpy.show(args, **kwargs, backend="plotly") ```{code-cell} ipython3 ``` + +(styles-pixel-vectorfield)= + +## Pixel Field + +:::{versionadded} 5.2 +Pixel Vector Field +::: + +The `pixel` of a `Sensor` object can be visualized as arrows representing the values of the vector fields B, H, J, or M. This allows for quick and intuitive inspection of the field distributions. + +### Parameters (`style.pixel.field`) + +- **`source`** *(default=`None`)*: + Defines the field source of the vector field representation. + - `None`: No field representation is shown + - `"B"`, `"Hxy"`, `"Jxyz"`, etc.: Colors are mapped to the magnitude of the specified field. + +- **`symbol`** *(default=`"cone"`)*: + Specifies the rendering symbol for field values. + - `"none"`: `pixel.symbol` representation takes precedence of `pixel.field.symbol`. + - `"cone"`: 3D cone representation. + - `"arrow3d"`: 3D arrow representation. + - `"arrow"`: 2D line-based arrow. + +- **`shownull`** *(default=`True`)*: + Toggles the visibility of pixel with zero and invalid field vectors. + - `True`: Null vectors are displayed. + - `False`: Null vectors are hidden. + +- **`sizescaling`** *(default=`"uniform"`)*: + Determines how arrow size relates to the `source` magnitude. + - `"uniform"`: Uniform arrow size. + - `"linear"`: Size proportional to magnitude. + - `"log"`: Size proportional to the normalized logarithm of the magnitude. + - `"log^n"`: Size proportional to the normalized nth (2 to 9) logarithm of the magnitude. + +- **`sizemin`** *(default=`0.1`)* + Minimum relative size of field symbols. A float between 0 and 1. + When displaying field vectors this controls how small the symbols + can become relative to their maximum size. A value of 0 allows symbols to shrink to zero size, + while 0.5 ensures symbols are at least 50% of their maximum size. + +- **`colorscaling`** *(default=`"uniform"`)*: + Determines how arrow color relates to the `source` magnitude. + - `"uniform"`: Uniform color for all arrows. + - `"linear"`: Color scaling proportional to magnitude. + - `"log"`: Color scaling proportional to the normalized logarithm of the magnitude. + - `"log^n"`: Color scaling proportional to the normalized nth (2 to 9) logarithm of the magnitude. + +- **`colormap`** *(default=`"Viridis"`)*: + Specifies the colormap used for color mapping. Supports standard color maps (e.g., `"Viridis"`, `"Inferno"`, `"Magma"`, etc.) compatible with both Plotly and Matplotlib. + +```{note} +- Pixels with zero or invalid field values are rendered using the default representation (`point`/`box` or according to `style.pixel.symbol`). +- Magnitude normalization is performed individually for each sensor along its path. +- `style.pixel.size` controls also the arrow size. +``` + +### Pixel Field Minimal Example + +The following example demonstrates how to visualize the `Sensor` pixel array as a vector field using the `style.pixel.field` settings. + +```{code-cell} ipython3 +:tags: [hide-input] + +import numpy as np +import magpylib as magpy + +# Define a cuboid magnet +cube = magpy.magnet.Cuboid( + polarization=(0, 0, 1), + dimension=(1, 1, 1), +) + +# Create a 2D grid of pixel positions in the xy-plane +xy_grid = np.mgrid[-2:2:15j, -2:2:15j, 0:0:1j].T[0] + +# Define pixel field style +pixel_style = { + "source" : "B", + "symbol" : "arrow3d", + "sizemode" : "uniform", + "shownull" : True, + "colormap" : "Magma" +} + +# Create sensor with pixel array and applied style +sens = magpy.Sensor( + pixel=xy_grid, + position=(0,0,2), + style_pixel_field=pixel_style, +) + +# Display the sensor and magnet using the Plotly backend +magpy.show([sens, cube], backend='plotly') +``` diff --git a/docs/_pages/user_guide/examples/examples_index.md b/docs/_pages/user_guide/examples/examples_index.md index fb649dc76..edeb67d9e 100644 --- a/docs/_pages/user_guide/examples/examples_index.md +++ b/docs/_pages/user_guide/examples/examples_index.md @@ -94,6 +94,14 @@ :img-bottom: ../../../_static/images/examples_icon_vis_pv_streamlines.png ::: +:::{grid-item-card} {ref}`examples-vis-vectorfield` +:text-align: center +:link: examples-vis-vectorfield +:link-type: ref +:link-alt: link to example +:img-bottom: ../../../_static/images/examples_icon_vis_vectorfield.png +::: + :::: @@ -296,6 +304,7 @@ examples_vis_animations.md examples_vis_subplots.md examples_vis_mpl_streamplot.md examples_vis_pv_streamlines.md +examples_vis_vectorfield.md examples_shapes_superpos.md examples_shapes_convex_hull.md diff --git a/docs/_pages/user_guide/examples/examples_vis_vectorfield.md b/docs/_pages/user_guide/examples/examples_vis_vectorfield.md new file mode 100644 index 000000000..1ee773df5 --- /dev/null +++ b/docs/_pages/user_guide/examples/examples_vis_vectorfield.md @@ -0,0 +1,162 @@ +--- +jupytext: + formats: md:myst + text_representation: + extension: .md + format_name: myst + format_version: 0.13 + jupytext_version: 1.17.1 +kernelspec: + name: python3 + display_name: Python 3 (ipykernel) + language: python +--- + +(examples-vis-vectorfield)= + +# Pixel Field (Quiver Plot) + +:::{versionadded} 5.2 +Pixel Vector Field +::: + +The `Sensor` object with its `pixel` attribute can be conveniently used to visualize vector fields `"B"`, `"H"`, `"M"`, or `"J"` as quiver plots. Detailed documentation is available in the [styles-pixel-vectorfield](styles-pixel-vectorfield) section. This notebook provides practical examples and explanations of relevant parameters for effective usage. + +## Example 1: Transparent Magnet + +A simple example using pixel field functionality, combined with magnet transparency, displays the B field on a surface passing through the magnet. + +```{code-cell} ipython3 +:tags: [hide-input] + +import numpy as np +import magpylib as magpy + +# Define a magnet with opacity +magnet = magpy.magnet.Cuboid( + polarization=(1, 1, 0), + dimension=(4e-3, 4e-3, 2e-3), + style_opacity=0.5, +) + +# Create a grid of pixel positions in the xy-plane +xy_grid = np.mgrid[-4e-3:4e-3:15j, -4e-3:4e-3:15j, 0:0:1j].T[0] + +# Create a sensor with pixel array and pixel field style +sens = magpy.Sensor( + pixel=xy_grid, + style_pixel_field_source="B", + style_pixel_field_sizescaling="log", +) + +# Display the sensor and magnet using the Plotly backend +magpy.show([sens, magnet], backend="plotly") +``` + +## Example 2: Complex Pixel Grids + +Sensor pixels are not restricted to any specific grid structure and can be positioned freely to represent curved surfaces, lines, or individual points of interest. + +The following example demonstrates visualization of the magnetic field of a magnetic pole wheel, evaluated along curved surfaces and lines, using different color maps and arrow shapes. + +```{code-cell} ipython3 +:tags: [hide-input] + +from numpy import pi, sin, cos, linspace +import magpylib as magpy + +# Create a pole wheel magnet composed of 12 alternating cylinder segments +pole_wheel = magpy.Collection() +for i in range(12): + zone = magpy.magnet.CylinderSegment( + dimension=(1.8, 2, 1, -15, 15), + polarization=((-1)**i, 0, 0), + ).rotate_from_angax(30*i, axis="z") + pole_wheel.add(zone) + +# Sensor 1: Pixel line along a circle in the xz-plane +ang1 = linspace(0, 2*pi, endpoint=False) +pixel_line = [(cos(a), 0, sin(a)) for a in ang1] + +sensor1 = magpy.Sensor( + pixel=pixel_line, + style_pixel_field_source="H", +) + +# Sensor 2: Curved surface (vertical cylinder segment) +z_values = linspace(-1, 1, 10) +ang2 = linspace(-9*pi/8, -2*pi/8, 30) +pixel_grid2 = [[(3.5*cos(a), 3.5*sin(a), z) for a in ang2] for z in z_values] + +sensor2 = magpy.Sensor( + pixel=pixel_grid2, + style_pixel_field={ + "source": "H", + "sizescaling": "uniform", + "colorscale": "Blues", + "symbol": "arrow3d", + } +) + +# Sensor 3: Curved surface (horizontal annular sector) +r_values = linspace(3, 4, 5) +ang3 = linspace(-pi/8, 6*pi/8, 30) +pixel_grid3 = [[(r*cos(a), r*sin(a), 0) for a in ang3] for r in r_values] + +sensor3 = magpy.Sensor( + pixel=pixel_grid3, + style_pixel_field={ + "source": "H", + "sizescaling": "log", + "colorscale": "Plasma", + "symbol": "arrow3d", + } +) + +# Display sensors and magnets using Plotly backend +magpy.show( + [sensor1, sensor2, sensor3, pole_wheel], + backend="plotly", + style_arrows_x_show=False, + style_arrows_y_show=False, + style_arrows_z_show=False, +) +``` + +## Example 3: Pixel Field Animation + +Pixel fields can be combined with animation to create spectacular visualizations, such as displaying the magnetic field of rotating magnets. + +```{code-cell} ipython3 +:tags: [hide-input] + +import numpy as np +import magpylib as magpy + +# Create a cuboid magnet with vertical polarization +magnet = magpy.magnet.Cuboid( + polarization=(0, 0, 1), + dimension=(1, 3, 1) +) + +# Apply a rotation to the Cuboid that generates a path with 51 steps +magnet.rotate_from_angax( + angle=np.linspace(0, 360, 51), + axis="y", + start=0 +) + +# Create a sensor with pixel grid in the xy-plane at z=1 +pixel_grid = np.mgrid[-2:2:12j, -2:2:12j, 1:1:1j].T[0] +sensor = magpy.Sensor(pixel=pixel_grid) + +# Display as animation in the Plotly backend +magpy.show( + magnet, + sensor, + animation=True, + style_pixel_field_symbol="arrow3d", + style_pixel_field_source="B", + backend="plotly", +) +``` diff --git a/docs/_pages/user_guide/guide_index.md b/docs/_pages/user_guide/guide_index.md index a63482e3d..c75ebae78 100644 --- a/docs/_pages/user_guide/guide_index.md +++ b/docs/_pages/user_guide/guide_index.md @@ -23,7 +23,7 @@ docs/docs_magpylib_force.md ``` ```{toctree} -:maxdepth: 2 +:maxdepth: 1 :caption: Resources guide_resources_01_physics.md examples/examples_index.md diff --git a/docs/_static/images/examples_icon_vis_vectorfield.png b/docs/_static/images/examples_icon_vis_vectorfield.png new file mode 100644 index 000000000..33b10b9d5 Binary files /dev/null and b/docs/_static/images/examples_icon_vis_vectorfield.png differ diff --git a/pyproject.toml b/pyproject.toml index 9b5713570..cd0bd0483 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,16 +60,17 @@ docs = [ "magpylib-material-response", "magpylib-force", "plotly>=5.16,<6.0", # see https://github.com/executablebooks/myst-nb/issues/667 + "kaleido==0.2.1", # ulterior versions don't ship chrome ] test = [ "pytest>=7.4", "pytest-cov>=3", "pandas", "pyvista", - "ipywidgets", # for plotly FigureWidget "imageio[tifffile,ffmpeg]", "jupyterlab", - "anywidget", + "ipywidgets", # for plotly FigureWidget + "anywidget", # for plotly FigureWidget ] binder = [ "jupytext", @@ -133,6 +134,7 @@ disallow_incomplete_defs = true [tool.ruff] +target-version = "py311" [tool.ruff.lint] extend-select = [ diff --git a/src/magpylib/_src/defaults/defaults_values.py b/src/magpylib/_src/defaults/defaults_values.py index 8b7ddb2fb..fc6c8a058 100644 --- a/src/magpylib/_src/defaults/defaults_values.py +++ b/src/magpylib/_src/defaults/defaults_values.py @@ -94,7 +94,16 @@ "size": 1, "sizemode": "scaled", "color": None, - "symbol": "o", + "symbol": "cube", + "field": { + "symbol": "cone", + "source": None, + "colormap": "Viridis", + "shownull": True, + "sizescaling": "uniform", + "sizemin": 0.1, + "colorscaling": "linear", + }, }, "arrows": { "x": {"color": "red"}, diff --git a/src/magpylib/_src/display/backend_matplotlib.py b/src/magpylib/_src/display/backend_matplotlib.py index aa1b5ac0f..f3b8658a3 100644 --- a/src/magpylib/_src/display/backend_matplotlib.py +++ b/src/magpylib/_src/display/backend_matplotlib.py @@ -16,7 +16,12 @@ from matplotlib import patches from matplotlib.animation import FuncAnimation -from magpylib._src.display.traces_utility import subdivide_mesh_by_facecolor +from magpylib._src.display.traces_utility import ( + get_trace_kw, + split_input_arrays, + subdivide_mesh_by_facecolor, +) +from magpylib._src.utility import is_array_like if os.getenv("MAGPYLIB_MPL_SVG") == "true": # pragma: no cover from matplotlib_inline.backend_inline import set_matplotlib_formats @@ -40,16 +45,6 @@ "longdashdot": "loosely dashdotted", } -SCATTER_KWARGS_LOOKUPS = { - "ls": ("line", "dash"), - "lw": ("line", "width"), - "color": ("line", "color"), - "marker": ("marker", "symbol"), - "mfc": ("marker", "color"), - "mec": ("marker", "color"), - "ms": ("marker", "size"), -} - class StripedHandler: """ @@ -90,115 +85,134 @@ def legend_artist(self, legend, orig_handle, fontsize, handlebox): # noqa: ARG0 current_position += patch_width * proportion -def generic_trace_to_matplotlib(trace, antialiased=True): - """Transform a generic trace into a matplotlib trace""" - traces_mpl = [] - leg_title = trace.get("legendgrouptitle_text", None) - showlegend = trace.get("showlegend", True) - if trace["type"] == "mesh3d": - subtraces = [trace] - has_facecolor = trace.get("facecolor", None) is not None - if has_facecolor: - subtraces = subdivide_mesh_by_facecolor(trace) - for ind, subtrace in enumerate(subtraces): - x, y, z = np.array([subtrace[k] for k in "xyz"], dtype=float) - triangles = np.array([subtrace[k] for k in "ijk"]).T - tr_mesh = { - "constructor": "plot_trisurf", - "args": (x, y, z), - "kwargs": { - "triangles": triangles, - "alpha": subtrace.get("opacity", None), - "color": subtrace.get("color", None), - "linewidth": 0, - "antialiased": antialiased, - }, - } - if showlegend and has_facecolor: - tr_mesh["legend_handler"] = StripedHandler(Counter(trace["facecolor"])) - if ind != 0: # hide substrace legends except first - tr_mesh["kwargs"]["label"] = "_nolegend_" - traces_mpl.append(tr_mesh) - elif "scatter" in trace["type"]: - props = { - k: trace.get(v[0], {}).get(v[1], trace.get("_".join(v), None)) - for k, v in SCATTER_KWARGS_LOOKUPS.items() +def mesh3d_to_matplotlib(trace, antialiased): + """Convert mesh3d trace input to a list of plot_trisurf constructor dicts + Note: plot_trisurf does not accept different facecolors on the same trace + so they need to be split into multiple traces + """ + traces = [] + subtraces = [trace] + has_facecolor = trace.get("facecolor", None) is not None + if has_facecolor: + subtraces = subdivide_mesh_by_facecolor(trace) + for ind, subtrace in enumerate(subtraces): + x, y, z = np.array([subtrace[k] for k in "xyz"], dtype=float) + triangles = np.array([subtrace[k] for k in "ijk"]).T + tr_mesh = { + "constructor": "plot_trisurf", + "args": (x, y, z), + "kwargs": { + "triangles": triangles, + "alpha": subtrace.get("opacity", None), + "color": subtrace.get("color", None), + "linewidth": 0, + "antialiased": antialiased, + }, } - coords_str = "xyz" - if trace["type"] == "scatter": - coords_str = "xy" - # marker size is proportional to area, not radius like generic - props["ms"] = np.pi * props["ms"] ** 2 - coords = np.array([trace[k] for k in coords_str], dtype=float) - if isinstance(props["ms"], list | tuple | np.ndarray): - traces_mpl.append( + if trace.get("showlegend", True) and has_facecolor: + tr_mesh["legend_handler"] = StripedHandler(Counter(trace["facecolor"])) + if ind != 0: # hide substrace legends except first + tr_mesh["kwargs"]["label"] = "_nolegend_" + traces.append(tr_mesh) + return traces + + +def scatter_to_matplotlib(trace): + """Convert scatter trace input to a list of plot or scatter constructor dicts + Note on `scatter` constructor: + - supports arrays for marker size and color, not symbol + - no support for line + Note on `plot` constructor: + - support for line + - no array support for marker size or marker color or line color or line style + """ + traces = [] + + # get kwargs + mode = get_trace_kw(trace, "mode", none_replace="markers") + line_color = get_trace_kw(trace, "line_color") + line_width = get_trace_kw(trace, "line_width", none_replace=1) + line_dash = get_trace_kw(trace, "line_dash") + line_dash = LINE_STYLES_TO_MATPLOTLIB.get(line_dash, line_dash) + marker_color = get_trace_kw(trace, "marker_color", none_replace=line_color) + marker_size = get_trace_kw(trace, "marker_size", none_replace=1) + marker_symbol = get_trace_kw(trace, "marker_symbol", none_replace="o") + + # get coords + coords_str = "xyz" + if trace["type"] == "scatter3d": + # for 3d traces marker size is proportional to volume, not radius like generic + marker_size = marker_size**3 + else: + coords_str = "xy" + # for 2d traces marker size is proportional to area, not radius like generic + marker_size = marker_size**2 + + coords = np.array([trace[k] for k in coords_str], dtype=float) + + # plot the marker part with `scatter` constructor + if "markers" in mode: + for (msymb_item,), inds in split_input_arrays(marker_symbol, ordered=False): + msymb = SYMBOLS_TO_MATPLOTLIB.get(msymb_item, msymb_item) + kw = {"s": marker_size, "color": marker_color} + for k, v in kw.items(): + if is_array_like(v): + kw[k] = v[inds] + traces.append( { "constructor": "scatter", - "args": (*coords,), + "args": tuple(coords[:, inds]), + "kwargs": {"marker": msymb, "label": None, **kw}, + } + ) + + # plot the line part with `plot` constructor + if "lines" in mode: + for (lcolor, lwidth), inds in split_input_arrays(line_color, line_width): + traces.append( + { + "constructor": "plot", + "args": coords[:, inds], "kwargs": { - "s": props["ms"], - "color": props["mec"], - "marker": SYMBOLS_TO_MATPLOTLIB.get( - props["marker"], props["marker"] - ), - "label": None, + "alpha": trace.get("opacity", 1), + "ls": line_dash, + "lw": lwidth, + "color": lcolor, }, } ) - props.pop("ms") - props.pop("marker") - if "ls" in props: - props["ls"] = LINE_STYLES_TO_MATPLOTLIB.get(props["ls"], props["ls"]) - if "marker" in props: - props["marker"] = SYMBOLS_TO_MATPLOTLIB.get( - props["marker"], props["marker"] - ) - mode = trace.get("mode", None) - mode = "markers" if mode is None else mode - if "lines" not in mode: - props["ls"] = "" - if "markers" in mode: - if not props.get("marker"): - props["marker"] = "o" - else: - props["marker"] = None - if "text" in mode and trace.get("text", False) and len(coords) > 0: - txt = trace["text"] - txt = [txt] * len(coords[0]) if isinstance(txt, str) else txt - for *coords_s, t in zip(*coords, txt, strict=False): - traces_mpl.append( - { - "constructor": "text", - "args": (*coords_s, t), - } - ) - traces_mpl.append( - { - "constructor": "plot", - "args": coords, - "kwargs": { - **{k: v for k, v in props.items() if v is not None}, - "alpha": trace.get("opacity", 1), - }, - } - ) + # plot the test parts with `text` constructor + if "text" in mode and trace.get("text", False) and len(coords) > 0: + txt = trace["text"] + txt = [txt] * len(coords[0]) if isinstance(txt, str) else txt + for *coords_s, t in zip(*coords, txt, strict=False): + traces.append({"constructor": "text", "args": (*coords_s, t)}) + return traces + + +def generic_trace_to_matplotlib(trace, antialiased=True): + """Transform a generic trace into a matplotlib trace""" + traces_mpl = [] + if trace["type"] == "mesh3d": + traces_mpl.extend(mesh3d_to_matplotlib(trace, antialiased)) + elif trace["type"] in ("scatter", "scatter3d"): + traces_mpl.extend(scatter_to_matplotlib(trace)) else: # pragma: no cover - msg = ( - f"Trace type {trace['type']!r} cannot be transformed into matplotlib trace" - ) + msg = f"{trace['type']!r} trace type conversion not supported" raise ValueError(msg) - for tr_mesh in traces_mpl: - tr_mesh["row"] = trace.get("row", 1) - tr_mesh["col"] = trace.get("col", 1) - tr_mesh["kwargs"] = tr_mesh.get("kwargs", {}) - if tr_mesh["constructor"] != "text": - if showlegend: - if "label" not in tr_mesh["kwargs"]: - tr_mesh["kwargs"]["label"] = trace.get("name", "") + for tr in traces_mpl: + tr["row"] = trace.get("row", 1) + tr["col"] = trace.get("col", 1) + tr["kwargs"] = tr.get("kwargs", {}) + if tr["constructor"] != "text": + if trace.get("showlegend", True): + if "label" not in tr["kwargs"]: + tr["kwargs"]["label"] = trace.get("name", "") + leg_title = trace.get("legendgrouptitle_text", None) if leg_title is not None: - tr_mesh["kwargs"]["label"] += f" ({leg_title})" + tr["kwargs"]["label"] += f" ({leg_title})" else: - tr_mesh["kwargs"]["label"] = "_nolegend" + tr["kwargs"]["label"] = "_nolegend" return traces_mpl diff --git a/src/magpylib/_src/display/backend_pyvista.py b/src/magpylib/_src/display/backend_pyvista.py index ede3cbcd3..ccf0a1dbf 100644 --- a/src/magpylib/_src/display/backend_pyvista.py +++ b/src/magpylib/_src/display/backend_pyvista.py @@ -22,7 +22,8 @@ from matplotlib.colors import LinearSegmentedColormap from pyvista.plotting.colors import Color # pylint: disable=import-error -from magpylib._src.utility import open_animation +from magpylib._src.display.traces_utility import get_trace_kw, split_input_arrays +from magpylib._src.utility import is_array_like, open_animation # from magpylib._src.utility import format_obj_input @@ -78,120 +79,134 @@ def colormap_from_colorscale(colorscale, name="plotly_to_mpl", N=256, gamma=1.0) return LinearSegmentedColormap(name, cdict, N, gamma) -def generic_trace_to_pyvista(trace): - """Transform a generic trace into a pyvista trace""" - traces_pv = [] - leg_title = trace.get("legendgrouptitle_text", None) - if trace["type"] == "mesh3d": - vertices = np.array([trace[k] for k in "xyz"], dtype=float).T - faces = np.array([trace[k] for k in "ijk"]).T.flatten() - faces = np.insert(faces, range(0, len(faces), 3), 3) - colorscale = trace.get("colorscale", None) - mesh = pv.PolyData(vertices, faces) - facecolor = trace.get("facecolor", None) - trace_pv = { - "type": "mesh", - "mesh": mesh, - "color": trace.get("color", None), - "scalars": trace.get("intensity", None), - "opacity": trace.get("opacity", None), - } - if facecolor is not None: - # pylint: disable=unsupported-assignment-operation - mesh.cell_data["colors"] = [ - Color(c, default_color=(0, 0, 0)).int_rgb for c in facecolor - ] - trace_pv.update( - { - "scalars": "colors", - "rgb": True, - "preference": "cell", - } - ) - traces_pv.append(trace_pv) - if colorscale is not None: - trace_pv["cmap"] = colormap_from_colorscale(colorscale) - elif "scatter" in trace["type"]: - line = trace.get("line", {}) - line_color = line.get("color", trace.get("line_color", None)) - line_width = line.get("width", trace.get("line_width", None)) - line_width = 1 if line_width is None else line_width - line_style = line.get("dash", trace.get("line_dash")) - marker = trace.get("marker", {}) - marker_color = marker.get("color", trace.get("marker_color", None)) - marker_size = marker.get("size", trace.get("marker_size", None)) - marker_size = 1 if marker_size is None else marker_size - marker_symbol = marker.get("symbol", trace.get("marker_symbol", None)) - mode = trace.get("mode", None) - mode = "markers" if mode is None else mode - if trace["type"] == "scatter3d": - points = np.array([trace[k] for k in "xyz"], dtype=float).T - if "lines" in mode: +def mesh3d_to_pyvista(trace): + """Convert mesh3d trace input to a list of mesh constructor dict""" + vertices = np.array([trace[k] for k in "xyz"], dtype=float).T + faces = np.array([trace[k] for k in "ijk"]).T.flatten() + faces = np.insert(faces, range(0, len(faces), 3), 3) + colorscale = trace.get("colorscale", None) + mesh = pv.PolyData(vertices, faces) + facecolor = trace.get("facecolor", None) + trace_pv = { + "type": "mesh", + "mesh": mesh, + "color": trace.get("color", None), + "scalars": trace.get("intensity", None), + "opacity": trace.get("opacity", None), + } + if facecolor is not None: + # pylint: disable=unsupported-assignment-operation + mesh.cell_data["colors"] = [ + Color(c, default_color=(0, 0, 0)).int_rgb for c in facecolor + ] + trace_pv.update( + { + "scalars": "colors", + "rgb": True, + "preference": "cell", + } + ) + if colorscale is not None: + trace_pv["cmap"] = colormap_from_colorscale(colorscale) + return trace_pv + + +def scatter_to_pyvista(trace): + """Convert scatter trace input to a list of plot or scatter constructor dicts + Note 3d scatter plot is done with the mesh constructor and does not support symbols + """ + traces = [] + + # get kwargs + mode = get_trace_kw(trace, "mode", none_replace="markers") + line_color = get_trace_kw(trace, "line_color") + line_width = get_trace_kw(trace, "line_width", none_replace=1) + line_dash = get_trace_kw(trace, "line_dash") + line_dash = LINESTYLES_TO_PYVISTA.get(line_dash, "-") + marker_color = get_trace_kw(trace, "marker_color", none_replace=line_color) + marker_size = get_trace_kw(trace, "marker_size", none_replace=1) + marker_symbol = get_trace_kw(trace, "marker_symbol", none_replace="o") + + if trace["type"] == "scatter3d": + points = np.array([trace[k] for k in "xyz"], dtype=float).T + if "lines" in mode: + for (lcol,), inds in split_input_arrays(line_color): trace_pv_line = { "type": "mesh", - "mesh": pv.lines_from_points(points), - "color": line_color, + "mesh": pv.lines_from_points(points[inds]), + "color": lcol, "line_width": line_width, "opacity": trace.get("opacity", None), } - traces_pv.append(trace_pv_line) - if "markers" in mode: - trace_pv_marker = { - "type": "mesh", - "mesh": pv.PolyData(points), - "color": marker_color, - "point_size": marker_size, - "opacity": trace.get("opacity", None), - } - traces_pv.append(trace_pv_marker) - if "text" in mode and trace.get("text", False) and len(points) > 0: - txt = trace["text"] - txt = [txt] * len(points[0]) if isinstance(txt, str) else txt - trace_pv_text = { - "type": "point_labels", - "points": points, - "labels": txt, - "always_visible": True, - } - traces_pv.append(trace_pv_text) - elif trace["type"] == "scatter": - if "lines" in mode: + traces.append(trace_pv_line) + if "markers" in mode: + splits = split_input_arrays(marker_color, marker_size, ordered=False) + for (mcolor, msize), inds in splits: + if msize != 0: + trace_pv_marker = { + "type": "mesh", + "mesh": pv.PolyData(points[inds]), + "color": mcolor, + "point_size": msize, + "opacity": trace.get("opacity", None), + } + traces.append(trace_pv_marker) + if "text" in mode and trace.get("text", False) and len(points) > 0: + txt = trace["text"] + txt = [txt] * len(points[0]) if isinstance(txt, str) else txt + trace_pv_text = { + "type": "point_labels", + "points": points, + "labels": txt, + "always_visible": True, + } + traces.append(trace_pv_text) + else: + if "lines" in mode: + splits = split_input_arrays(line_color, line_width, line_dash) + for (lcolor, lwidth, ldash), inds in splits: trace_pv_line = { "type": "line", - "x": trace["x"], - "y": trace["y"], - "color": line_color, - "width": line_width, - "style": LINESTYLES_TO_PYVISTA.get(line_style, "-"), + "x": trace["x"][inds], + "y": trace["y"][inds], + "color": lcolor, + "width": lwidth, + "style": ldash, "label": trace.get("name", ""), } - traces_pv.append(trace_pv_line) - if "markers" in mode: - trace_pv_marker = { - "type": "scatter", - "x": trace["x"], - "y": trace["y"], - "color": marker_color, - "size": marker_size, - "style": SYMBOLS_TO_PYVISTA.get(marker_symbol, "o"), - } - marker_size = ( - marker_size - if isinstance(marker_size, list | tuple | np.ndarray) - else np.array([marker_size]) - ) - for size in np.unique(marker_size): - tr = trace_pv_marker.copy() - mask = marker_size == size - tr = { - **tr, - "x": np.array(tr["x"])[mask], - "y": np.array(tr["y"][mask]), - "size": size, + traces.append(trace_pv_line) + if "markers" in mode: + for (msize,), inds in split_input_arrays(marker_size, ordered=False): + if msize != 0: + mcol = marker_color + if is_array_like(mcol): + mcol = mcol[inds] + if is_array_like(marker_symbol): + msymb = marker_symbol[inds] + msymb = [SYMBOLS_TO_PYVISTA.get(s, "o") for s in msymb] + else: + msymb = SYMBOLS_TO_PYVISTA.get(marker_symbol, "o") + trace_pv_marker = { + "type": "scatter", + "x": trace["x"][inds], + "y": trace["y"][inds], + "color": mcol, + "size": msize, + "style": msymb, } - traces_pv.append(tr) + traces.append(trace_pv_marker) + return traces + + +def generic_trace_to_pyvista(trace): + """Transform a generic trace into a pyvista traces""" + traces_pv = [] + if trace["type"] == "mesh3d": + traces_pv.append(mesh3d_to_pyvista(trace)) + elif trace["type"] in ("scatter", "scatter3d"): + traces_pv.extend(scatter_to_pyvista(trace)) else: # pragma: no cover - msg = f"Trace type {trace['type']!r} cannot be transformed into pyvista trace" + msg = f"{trace['type']!r} trace type conversion not supported" raise ValueError(msg) showlegend = trace.get("showlegend", False) for tr in traces_pv: @@ -201,6 +216,7 @@ def generic_trace_to_pyvista(trace): showlegend = False # show only first subtrace if "label" not in tr: tr["label"] = trace.get("name", "") + leg_title = trace.get("legendgrouptitle_text", None) if leg_title is not None: tr["label"] += f" ({leg_title})" if not tr.get("label", ""): diff --git a/src/magpylib/_src/display/display.py b/src/magpylib/_src/display/display.py index 047d63117..5e4b80b42 100644 --- a/src/magpylib/_src/display/display.py +++ b/src/magpylib/_src/display/display.py @@ -12,7 +12,6 @@ from magpylib._src.display.traces_generic import MagpyMarkers, get_frames from magpylib._src.display.traces_utility import ( DEFAULT_ROW_COL_PARAMS, - linearize_dict, process_show_input_objs, ) from magpylib._src.input_checks import ( @@ -91,15 +90,9 @@ def show( display_kwargs = { k: v for k, v in kwargs.items() - if any(k.startswith(arg) for arg in disp_args - {"style"}) - } - style_kwargs = {k: v for k, v in kwargs.items() if k.startswith("style")} - style_kwargs = linearize_dict(style_kwargs, separator="_") - kwargs = { - k: v - for k, v in kwargs.items() - if (k not in display_kwargs and k not in style_kwargs) + if any(k.startswith(arg) for arg in disp_args) } + kwargs = {k: v for k, v in kwargs.items() if k not in display_kwargs} backend_kwargs = { k[len(backend) + 1 :]: v for k, v in kwargs.items() @@ -127,7 +120,6 @@ def show( supports_colorgradient=self.supports["colorgradient"], backend=backend, title=title, - style_kwargs=style_kwargs, **display_kwargs, ) return self.show_func( diff --git a/src/magpylib/_src/display/traces_base.py b/src/magpylib/_src/display/traces_base.py index 48e673b99..48e93c1fa 100644 --- a/src/magpylib/_src/display/traces_base.py +++ b/src/magpylib/_src/display/traces_base.py @@ -7,7 +7,11 @@ import numpy as np from scipy.spatial import ConvexHull # pylint: disable=no-name-in-module -from magpylib._src.display.traces_utility import merge_mesh3d, place_and_orient_model3d +from magpylib._src.display.traces_utility import ( + draw_zarrow, + merge_mesh3d, + place_and_orient_model3d, +) from magpylib._src.fields.field_BH_tetrahedron import check_chirality @@ -42,7 +46,7 @@ def get_model(trace, *, backend, show, scale, kwargs): ) model["kwargs"].update(kwargs) if backend == "plotly-dict": - model = {"type": "mesh3d", **model["kwargs"]} + model = model["kwargs"] else: model["backend"] = backend model["kwargs"].pop("type", None) @@ -95,6 +99,7 @@ def make_Cuboid( """ dimension = np.array(dimension, dtype=float) trace = { + "type": "mesh3d", "i": np.array([7, 0, 0, 0, 4, 4, 2, 6, 4, 0, 3, 7]), "j": np.array([0, 7, 1, 2, 6, 7, 1, 2, 5, 5, 2, 2]), "k": np.array([3, 4, 2, 3, 5, 6, 5, 5, 0, 1, 7, 6]), @@ -192,7 +197,7 @@ def make_Prism( k = np.concatenate([k1, j2, j3, k4]) x, y, z = c.T - trace = {"x": x, "y": y, "z": z, "i": i, "j": j, "k": k} + trace = {"type": "mesh3d", "x": x, "y": y, "z": z, "i": i, "j": j, "k": k} trace = place_and_orient_model3d(trace, orientation=orientation, position=position) return get_model(trace, backend=backend, show=show, scale=scale, kwargs=kwargs) @@ -282,7 +287,7 @@ def make_Ellipsoid( j = np.concatenate([j1, j2, j3, j4]) k = np.concatenate([k1, k2, k3, k4]) - trace = {"x": x, "y": y, "z": z, "i": i, "j": j, "k": k} + trace = {"type": "mesh3d", "x": x, "y": y, "z": z, "i": i, "j": j, "k": k} trace = place_and_orient_model3d(trace, orientation=orientation, position=position) return get_model(trace, backend=backend, show=show, scale=scale, kwargs=kwargs) @@ -382,7 +387,7 @@ def make_CylinderSegment( k.extend([j5, j5 + N - 1]) i, j, k = (np.hstack(m) for m in (i, j, k)) - trace = {"x": x, "y": y, "z": z, "i": i, "j": j, "k": k} + trace = {"type": "mesh3d", "x": x, "y": y, "z": z, "i": i, "j": j, "k": k} trace = place_and_orient_model3d(trace, orientation=orientation, position=position) return get_model(trace, backend=backend, show=show, scale=scale, kwargs=kwargs) @@ -463,7 +468,7 @@ def make_Pyramid( j = i + 1 j[-1] = 0 k = np.array([N] * N, dtype=int) - trace = {"x": x, "y": y, "z": z, "i": i, "j": j, "k": k} + trace = {"type": "mesh3d", "x": x, "y": y, "z": z, "i": i, "j": j, "k": k} trace = place_and_orient_model3d(trace, orientation=orientation, position=position) return get_model(trace, backend=backend, show=show, scale=scale, kwargs=kwargs) @@ -534,21 +539,26 @@ def make_Arrow( "middle": 0, } z = validate_pivot(pivot, pivot_conditions) - cone = make_Pyramid( - backend="plotly-dict", - base=base, - diameter=d, - height=d, - position=(0, 0, z + h / 2 - d / 2), - ) - prism = make_Prism( - backend="plotly-dict", - base=base, - diameter=d / 2, - height=h - d, - position=(0, 0, z + -d / 2), - ) - trace = merge_mesh3d(cone, prism) + ttype = kwargs.pop("type", "mesh3d") + if ttype == "mesh3d": + cone = make_Pyramid( + backend="plotly-dict", + base=base, + diameter=d, + height=d, + position=(0, 0, z + h / 2 - d / 2), + ) + prism = make_Prism( + backend="plotly-dict", + base=base, + diameter=d / 2, + height=h - d, + position=(0, 0, z + -d / 2), + ) + trace = merge_mesh3d(cone, prism) + else: + x, y, z = draw_zarrow(height=h, diameter=diameter, pivot=pivot).T + trace = {"type": "scatter3d", "x": x, "y": y, "z": z, "mode": "lines"} trace = place_and_orient_model3d(trace, orientation=orientation, position=position) return get_model(trace, backend=backend, show=show, scale=scale, kwargs=kwargs) @@ -601,7 +611,10 @@ def make_Tetrahedron( # create triangles implying right vertices chirality triangles = np.array([[0, 2, 1], [0, 3, 2], [1, 3, 0], [1, 2, 3]]) points = check_chirality(np.array([vertices]))[0] - trace = dict(zip("xyzijk", [*points.T, *triangles.T], strict=False)) + trace = { + "type": "mesh3d", + **dict(zip("xyzijk", [*points.T, *triangles.T], strict=False)), + } trace = place_and_orient_model3d(trace, orientation=orientation, position=position) return get_model(trace, backend=backend, show=show, scale=scale, kwargs=kwargs) @@ -663,6 +676,6 @@ def make_TriangularMesh( hull = ConvexHull(vertices) faces = hull.simplices i, j, k = np.array(faces).T - trace = {"x": x, "y": y, "z": z, "i": i, "j": j, "k": k} + trace = {"type": "mesh3d", "x": x, "y": y, "z": z, "i": i, "j": j, "k": k} trace = place_and_orient_model3d(trace, orientation=orientation, position=position) return get_model(trace, backend=backend, show=show, scale=scale, kwargs=kwargs) diff --git a/src/magpylib/_src/display/traces_core.py b/src/magpylib/_src/display/traces_core.py index 228d051ee..a0cb183b2 100644 --- a/src/magpylib/_src/display/traces_core.py +++ b/src/magpylib/_src/display/traces_core.py @@ -7,11 +7,12 @@ # pylint: disable=cyclic-import import warnings -from itertools import combinations, cycle +from itertools import cycle from typing import Any import numpy as np from scipy.spatial import distance +from scipy.spatial.distance import pdist from scipy.spatial.transform import Rotation as RotScipy from magpylib._src.display.sensor_mesh import get_sensor_mesh @@ -31,11 +32,15 @@ create_null_dim_trace, draw_arrow_from_vertices, draw_arrow_on_circle, + get_hexcolors_from_colormap, get_legend_label, + get_orientation_from_vec, + group_traces, merge_mesh3d, place_and_orient_model3d, triangles_area, ) +from magpylib._src.utility import is_array_like def make_DefaultTrace(obj, **kwargs) -> dict[str, Any] | list[dict[str, Any]]: @@ -550,19 +555,132 @@ def make_TriangularMesh(obj, **kwargs) -> dict[str, Any] | list[dict[str, Any]]: return traces -def make_Pixels(positions, size=1) -> dict[str, Any]: - """ - Create the plotly mesh3d parameters for Sensor pixels based on pixel positions and chosen size +def _apply_scaling_transformation( + norms, scaling_type, is_null_mask, path_ind, min_=None +): + scaled_norms = norms.copy() + log_iterations = ( + int(scaling_type[4]) + if scaling_type.startswith("log^") + else scaling_type.count("log") + ) + for _ in range(log_iterations): + scaled_norms += np.nanmin(scaled_norms) + 1 # shift to positive range + scaled_norms[~is_null_mask] = np.log(scaled_norms[~is_null_mask]) + scaled_norms /= np.nanmax(scaled_norms) + if min_ is not None: + scaled_norms = min_ + scaled_norms * (1 - min_) + return scaled_norms[path_ind] + + +def make_Pixels( + *, + positions, + vectors, + colors, + symbol, + field_symbol, + shownull, + sizes, + marker2d_default_size, + null_thresh=1e-12, +) -> dict[str, Any]: + """ + Create the plotly dict for Sensor pixels based on pixel positions and chosen size For now, only "cube" shape is provided. """ - pixels = [ - make_BaseCuboid("plotly-dict", position=p, dimension=[size] * 3) - for p in positions - ] - return merge_mesh3d(*pixels) + # Note: the function must return a single object after grouping + # This is relevant for animation in plotly where a different number of traces + # in each frame results in weird artifacts. + # markers plots must share the same kw types to be able to be merged with line plots! + + sizes_2dfactor = marker2d_default_size / np.max(sizes) + allowed_symbols = { + "cone": {"type": "mesh3d", "orientable": True}, + "arrow": {"type": "scatter3d", "orientable": True}, + "arrow3d": {"type": "mesh3d", "orientable": True}, + "cube": {"type": "mesh3d", "orientable": False}, + } + field_symbol = symbol if field_symbol in ("none", None) else field_symbol + orientable = allowed_symbols.get(field_symbol, {"orientable": False}).get( + "orientable" + ) + ttype = allowed_symbols.get(symbol, {"type": "scatter3d"}).get("type") + ttype_field = allowed_symbols.get(field_symbol, {"type": "scatter3d"}).get("type") + if ttype == "scatter3d" and vectors is None: + x, y, z = positions.T + sizes = sizes if is_array_like(sizes) else np.repeat(sizes, len(x)) + return { + "type": "scatter3d", + "mode": "markers", + "x": x, + "y": y, + "z": z, + "marker_symbol": symbol, + "marker_color": colors, + "marker_size": sizes * sizes_2dfactor, + } + pixels = [] + orientations = None + is_null_vec = None + if vectors is not None: + orientations = get_orientation_from_vec(vectors) + is_null_vec = (np.abs(vectors) < null_thresh).all(axis=1) + for ind, pos in enumerate(positions): + kw = {"backend": "plotly-dict", "position": pos} + kw2d = { + "type": "scatter3d", + "mode": "markers+lines", + "marker_symbol": symbol, + "marker_color": None, + "line_color": None, + } + pix = None + size = sizes[ind] if is_array_like(sizes) else sizes + if orientable and vectors is not None and not is_null_vec[ind]: + orient = orientations[ind] + kw.update(orientation=orient, base=5, diameter=size, height=size * 2) + if field_symbol == "cone": + pix = make_BasePyramid(**kw) + elif field_symbol == "arrow3d": + pix = make_BaseArrow(**kw) + elif field_symbol == "arrow": + pix = make_BaseArrow(**kw, **kw2d) + pix["marker_size"] = np.repeat(0.0, len(pix["x"])) + elif vectors is None or shownull: + if ttype_field == "scatter3d": + x, y, z = pos[:, None] + pix = { + "x": x, + "y": y, + "z": z, + "marker_size": [size * sizes_2dfactor], + **kw2d, + } + else: + pix = make_BaseCuboid(dimension=[size] * 3, **kw) + if pix is not None: + if colors is not None: + color = colors[ind] if is_array_like(colors) else colors + if ttype_field == "scatter3d": + pix["line_color"] = np.repeat(color, len(pix["x"])) + pix["marker_color"] = pix["line_color"] + else: + pix["facecolor"] = np.repeat(color, len(pix["i"])) + pixels.append(pix) + pixels = group_traces(*pixels) + if len(pixels) != 1: + msg = ( + f"Expected exactly one pixel trace after grouping, but got {len(pixels)}. " + "This may indicate an issue with the input data or the grouping logic." + ) + raise ValueError(msg) + return pixels[0] -def make_Sensor(obj, autosize=None, **kwargs) -> dict[str, Any]: +def make_Sensor( + obj, *, autosize, path_ind=None, field_values, **kwargs +) -> dict[str, Any]: """ Create the plotly mesh3d parameters for a Sensor object in a dictionary based on the provided arguments. @@ -573,23 +691,17 @@ def make_Sensor(obj, autosize=None, **kwargs) -> dict[str, Any]: distance between any pixel of the same sensor, equal to `size_pixel`. """ style = obj.style + traces_to_merge = [] dimension = getattr(obj, "dimension", style.size) - pixel = obj.pixel - no_pix = pixel is None - if not no_pix: - pixel = np.unique(np.array(pixel).reshape((-1, 3)), axis=0) - one_pix = not no_pix and pixel.shape[0] == 1 - style_arrows = style.arrows.as_dict(flatten=True, separator="_") - sensor = get_sensor_mesh( - **style_arrows, center_color=style.color, handedness=obj.handedness - ) - vertices = np.array([sensor[k] for k in "xyz"]).T - if style.color is not None: - sensor["facecolor"][sensor["facecolor"] == "rgb(238,238,238)"] = style.color dim = np.array( [dimension] * 3 if isinstance(dimension, float | int) else dimension[:3], dtype=float, ) + pixel = obj.pixel + no_pix = pixel is None + if not no_pix: + pixel = np.array(pixel).reshape((-1, 3)) + one_pix = not no_pix and pixel.shape[0] == 1 if autosize is not None and style.sizemode == "scaled": dim *= autosize if no_pix: @@ -599,35 +711,98 @@ def make_Sensor(obj, autosize=None, **kwargs) -> dict[str, Any]: pixel = np.concatenate([[[0, 0, 0]], pixel]) hull_dim = pixel.max(axis=0) - pixel.min(axis=0) dim_ext = max(np.mean(dim), np.min(hull_dim)) - cube_mask = (abs(vertices) < 1).all(axis=1) - vertices[cube_mask] = 0 * vertices[cube_mask] - vertices[~cube_mask] = dim_ext * vertices[~cube_mask] - vertices /= 2 # sensor_mesh vertices are of length 2 - x, y, z = vertices.T - sensor.update(x=x, y=y, z=z) - meshes_to_merge = [sensor] + style_arrows = style.arrows.as_dict(flatten=True, separator="_") + if any(style_arrows[f"{k}_show"] for k in "xyz"): + sens_mesh = get_sensor_mesh( + **style_arrows, center_color=style.color, handedness=obj.handedness + ) + vertices = np.array([sens_mesh[k] for k in "xyz"]).T + if style.color is not None: + sens_mesh["facecolor"][sens_mesh["facecolor"] == "rgb(238,238,238)"] = ( + style.color + ) + cube_mask = (abs(vertices) < 1).all(axis=1) + vertices[cube_mask] = 0 * vertices[cube_mask] + vertices[~cube_mask] = dim_ext * vertices[~cube_mask] + vertices /= 2 # sensor_mesh vertices are of length 2 + x, y, z = vertices.T + sens_mesh.update(x=x, y=y, z=z) + traces_to_merge.append(sens_mesh) if not no_pix: - pixel_color = style.pixel.color - pixel_size = style.pixel.size - pixel_dim = 1 + px_color = style.pixel.color + px_size = style.pixel.size + px_dim = 1 if style.pixel.sizemode == "scaled": - combs = np.array(list(combinations(pixel, 2))) - vecs = np.diff(combs, axis=1) - dists = np.linalg.norm(vecs, axis=2) - min_dist = np.min(dists) - pixel_dim = dim_ext / 5 if min_dist == 0 else min_dist / 2 - if pixel_size > 0: - pixel_dim *= pixel_size - poss = pixel[1:] if one_pix else pixel - pixels_mesh = make_Pixels(positions=poss, size=pixel_dim) - pixels_mesh["facecolor"] = np.repeat(pixel_color, len(pixels_mesh["i"])) - meshes_to_merge.append(pixels_mesh) - hull_pos = 0.5 * (pixel.max(axis=0) + pixel.min(axis=0)) - hull_dim[hull_dim == 0] = pixel_dim / 2 - hull_mesh = make_BaseCuboid( - "plotly-dict", position=hull_pos, dimension=hull_dim - ) - hull_mesh["facecolor"] = np.repeat(style.color, len(hull_mesh["i"])) - meshes_to_merge.append(hull_mesh) - trace = merge_mesh3d(*meshes_to_merge) - return {**trace, **kwargs} + if len(pixel) < 1000: + min_dist = np.min(pdist(pixel)) + else: + # when too many pixels, min_dist computation is too expensive (On^2) + # using volume/(side length) approximation instead + vol = np.prod(np.ptp(pixel, axis=0)) + min_dist = (vol / len(pixel)) ** (1 / 3) + px_dim = dim_ext / 5 if min_dist == 0 else min_dist / 2 + if px_size > 0: + px_sizes = px_dim = px_dim * px_size + px_positions = pixel[1:] if one_pix else pixel + px_vectors, null_thresh = None, 1e-12 + px_colors = "black" if px_color is None else px_color + if field_values: + fsrc = style.pixel.field.source + field, *coords_str = fsrc + field_array = field_values[field] + px_vectors = field_values[field][path_ind] + coords_str = coords_str if coords_str else "xyz" + coords = list({"xyz".index(v) for v in coords_str if v in "xyz"}) + other_coords = [i for i in range(3) if i not in coords] + field_array[..., other_coords] = 0 # set other components to zero + norms = np.linalg.norm(field_array, axis=-1) + is_null_mask = np.logical_or(norms == 0, np.isnan(norms)) + norms[is_null_mask] = np.nan # avoid -inf + nmin, nmax = np.nanmin(norms), np.nanmax(norms) + ptp = nmax - nmin + norms = (norms - nmin) / ptp if ptp != 0 else norms * 0 + 0.5 + sizescaling = style.pixel.field.sizescaling + sizemin = style.pixel.field.sizemin + if sizescaling != "uniform": + snorms_scaled = _apply_scaling_transformation( + norms, sizescaling, is_null_mask, path_ind, min_=sizemin + ) + snorms_scaled[is_null_mask[path_ind]] = ( + 1 # keep null sizes unscaled + ) + px_sizes *= snorms_scaled + colorscaling = style.pixel.field.colorscaling + if colorscaling != "uniform": + cnorms_scaled = _apply_scaling_transformation( + norms, colorscaling, is_null_mask, path_ind + ) + px_colors = get_hexcolors_from_colormap( + values=cnorms_scaled, + colormap=style.pixel.field.colormap, + cmin=0, # scaled values are normalized to [0, 1] + cmax=1, + ) + pixels_trace = make_Pixels( + positions=px_positions, + vectors=px_vectors, + colors=px_colors, + sizes=px_sizes, + symbol=style.pixel.symbol, + field_symbol=style.pixel.field.symbol, + shownull=style.pixel.field.shownull, + null_thresh=null_thresh, + marker2d_default_size=10 * px_size, + ) + + traces_to_merge.append(pixels_trace) + # Show hull over pixels only if no field values are provided + if not field_values: + hull_pos = 0.5 * (pixel.max(axis=0) + pixel.min(axis=0)) + hull_dim[hull_dim == 0] = px_dim / 2 + hull_mesh = make_BaseCuboid( + "plotly-dict", position=hull_pos, dimension=hull_dim + ) + hull_mesh["facecolor"] = np.repeat(style.color, len(hull_mesh["i"])) + traces_to_merge.append(hull_mesh) + traces = group_traces(*traces_to_merge) + return [{**tr, **kwargs} for tr in traces] diff --git a/src/magpylib/_src/display/traces_generic.py b/src/magpylib/_src/display/traces_generic.py index 9fcd21505..79de2030f 100644 --- a/src/magpylib/_src/display/traces_generic.py +++ b/src/magpylib/_src/display/traces_generic.py @@ -25,11 +25,11 @@ draw_arrowed_line, get_legend_label, get_objects_props_by_row_col, - get_rot_pos_from_path, get_scene_ranges, getColorscale, getIntensity, group_traces, + path_frames_to_indices, place_and_orient_model3d, rescale_traces, slice_mesh_from_colorscale, @@ -225,7 +225,7 @@ def get_trace2D_dict( y = BH.T[list(coords_inds)] y = y[0] if len(coords_inds) == 1 else np.linalg.norm(y, axis=0) marker_size = np.array([3] * len(frames_indices)) - marker_size[focus_inds] = 15 + marker_size[np.clip(focus_inds, None, len(marker_size) - 1)] = 15 title = f"{field_str}{''.join(coords_str)}" unit = ( units_polarization @@ -264,7 +264,6 @@ def get_traces_2D( sumup=True, pixel_agg=None, in_out="auto", - styles=None, units_polarization="T", units_magnetization="A/m", units_length="m", # noqa: ARG001 @@ -328,8 +327,7 @@ def get_traces_2D( def get_focus_inds(*objs): focus_inds = [] for obj in objs: - style = styles.get(obj, obj.style) - frames = style.path.frames + frames = obj.style.path.frames inds = [] if frames is None else frames if isinstance(inds, numbers.Number): # pylint: disable=invalid-unary-operand-type @@ -347,10 +345,8 @@ def get_obj_list_str(objs): return obj_lst_str def get_label_and_color(obj): - style = styles.get(obj, None) - style = obj.style if style is None else style - label = get_legend_label(obj, style=style) - color = getattr(style, "color", None) + label = get_legend_label(obj) + color = getattr(obj.style, "color", None) return label, color obj_lst_str = { @@ -456,6 +452,7 @@ def get_generic_traces3D( showlegend=None, supports_colorgradient=True, extra_backend=False, + field_values=None, row=1, col=1, **kwargs, @@ -489,30 +486,51 @@ def get_generic_traces3D( is_mag_arrows = "arrow" in magstyl.mode magstyl.show = "color" in magstyl.mode - make_func = getattr(input_obj, "get_trace", None) + get_trace_method = getattr(input_obj, "get_trace", None) + if get_trace_method is not None: + + def make_func(*args, **kwargs): + out = get_trace_method(*args, **kwargs) # can return multiple traces + return list(out) if isinstance(out, list | tuple) else [out] + + else: + make_func = None + make_func_kwargs = {"legendgroup": legendgroup, **kwargs} if getattr(input_obj, "_autosize", False): make_func_kwargs["autosize"] = autosize - has_path = hasattr(input_obj, "position") and hasattr(input_obj, "orientation") + positions = getattr(input_obj, "_position", None) + orientations = getattr(input_obj, "_orientation", None) + has_path = positions is not None and orientations is not None + path_len = 1 if positions is None else len(positions) + max_pos_ind = path_len - 1 + is_frame_dependent = False + path_inds = path_inds_minimal = path_frames_to_indices(style.path.frames, path_len) + if hasattr(style, "pixel"): + make_func_kwargs["field_values"] = field_values + frsc = style.pixel.field.source + is_frame_dependent = frsc and field_values + if is_frame_dependent: + path_len = len(next(iter(field_values.values()))) + path_inds = path_frames_to_indices(style.path.frames, path_len) + path_traces_extra_non_generic_backend = [] if not has_path and make_func is not None: - tr = make_func(**make_func_kwargs) - tr["row"] = row - tr["col"] = col - out = {"generic": [tr]} + trs = make_func(**make_func_kwargs) + for tr in trs: + tr["row"] = row + tr["col"] = col + out = {"generic": trs} if extra_backend: out.update({extra_backend: path_traces_extra_non_generic_backend}) return out - orientations, positions, pos_orient_inds = get_rot_pos_from_path( - input_obj, style.path.frames - ) - traces_generic = [] - if pos_orient_inds.size != 0: + def get_traces_func(**extra_kwargs): + nonlocal is_mag + traces_generic_temp = [] if style.model3d.showdefault and make_func is not None: - p_trs = make_func(**make_func_kwargs) - p_trs = [p_trs] if isinstance(p_trs, dict) else p_trs + p_trs = make_func(**make_func_kwargs, **extra_kwargs) for p_tr_item in p_trs: p_tr = p_tr_item.copy() is_mag = p_tr.pop("ismagnet", is_mag) @@ -524,8 +542,15 @@ def get_generic_traces3D( color_slicing=not supports_colorgradient, ) - traces_generic.append(p_tr) + traces_generic_temp.append(p_tr) + return traces_generic_temp + traces_generic = [] + if path_inds.size != 0: + if is_frame_dependent: + traces_generic.append(None) + else: + traces_generic.extend(get_traces_func()) extra_model3d_traces = ( style.model3d.data if style.model3d.data is not None else [] ) @@ -561,17 +586,25 @@ def get_generic_traces3D( traces_generic.append(mag_arrow_tr) legend_label = get_legend_label(input_obj) - path_traces_generic = [] - for tr in traces_generic: + for trg in traces_generic: temp_rot_traces = [] - name_suff = tr.pop("name_suffix", None) - name = tr.get("name", "") if legendtext is None else legendtext - for orient, pos in zip(orientations, positions, strict=False): - tr1 = place_and_orient_model3d(tr, orientation=orient, position=pos) - if name_suff is not None: - tr1["name"] = f"{name}{name_suff}" - temp_rot_traces.append(tr1) + name, name_suff = "", None + for ind, path_ind in enumerate(path_inds): + pos_orient_ind = max_pos_ind if path_ind > max_pos_ind else path_ind + pos, orient = positions[pos_orient_ind], orientations[pos_orient_ind] + tr_list = [trg] + if trg is None: + tr_list = get_traces_func(path_ind=path_ind) + tr_list = [tr_list] if isinstance(tr_list, dict) else tr_list + for tr in tr_list: + if ind == 0: + name_suff = tr.pop("name_suffix", None) + name = tr.get("name", "") if legendtext is None else legendtext + tr1 = place_and_orient_model3d(tr, orientation=orient, position=pos) + if name_suff is not None: + tr1["name"] = f"{name}{name_suff}" + temp_rot_traces.append(tr1) path_traces_generic.extend(group_traces(*temp_rot_traces)) if np.array(input_obj.position).ndim > 1 and style.path.show: @@ -612,11 +645,11 @@ def get_generic_traces3D( continue extr.update(extr.updatefunc()) # update before checking backend if extr.backend == extra_backend: - for orient, pos in zip(orientations, positions, strict=False): + for path_ind in path_inds_minimal: tr_non_generic = { "model3d": extr, - "position": pos, - "orientation": orient, + "position": positions[path_ind], + "orientation": orientations[path_ind], "kwargs_extra": { "opacity": style.opacity, "color": style.color, @@ -667,8 +700,11 @@ def clean_legendgroups(frames, clean_2d=False): def process_animation_kwargs(obj_list, animation=False, **kwargs): - """Update animation kwargs""" - flat_obj_list = format_obj_input(obj_list) + """Extract animation kwargs and make sure the number of frames does not exceed + the max frames and max frame rate, downsample if necessary + """ + obj_list_semi_flat = format_obj_input(obj_list, allow="sources+sensors+collections") + flat_obj_list = format_obj_input(obj_list_semi_flat) # set animation and animation_time if isinstance(animation, numbers.Number) and not isinstance(animation, bool): kwargs["animation_time"] = animation @@ -688,8 +724,13 @@ def process_animation_kwargs(obj_list, animation=False, **kwargs): anim_def = default_settings.display.animation.copy() anim_def.update({k[10:]: v for k, v in kwargs.items()}, _match_properties=False) animation_kwargs = {f"animation_{k}": v for k, v in anim_def.as_dict().items()} - kwargs = {k: v for k, v in kwargs.items() if not k.startswith("animation")} - return kwargs, animation, animation_kwargs + + path_indices, path_digits, frame_duration = [-1], 0, 0 + if animation: + path_indices, path_digits, frame_duration = extract_animation_properties( + obj_list_semi_flat, **animation_kwargs + ) + return animation, path_indices, path_digits, frame_duration, animation_kwargs def extract_animation_properties( @@ -743,7 +784,7 @@ def extract_animation_properties( # calculate exponent of last frame index to avoid digit shift in # frame number display during animation - exp = ( + path_digits = ( np.log10(path_indices.max()).astype(int) + 1 if path_indices.ndim != 0 and path_indices.max() > 0 else 1 @@ -760,13 +801,14 @@ def extract_animation_properties( stacklevel=2, ) - return path_indices, exp, frame_duration + return path_indices, path_digits, frame_duration def get_traces_3D(flat_objs_props, extra_backend=False, autosize=None, **kwargs): """Return traces, traces to resize and extra_backend_traces""" extra_backend_traces = [] traces_dict = {} + field_by_sens = kwargs.pop("field_by_sens", {}) for obj, params_item in flat_objs_props.items(): params = {**params_item, **kwargs} if autosize is None and getattr(obj, "_autosize", False): @@ -777,20 +819,62 @@ def get_traces_3D(flat_objs_props, extra_backend=False, autosize=None, **kwargs) traces_dict[obj] = [{"x": x, "y": y, "z": z, "_autosize": True, **rc_dict}] else: traces_dict[obj] = [] - with style_temp_edit(obj, style_temp=params.pop("style", None), copy=True): - out_traces = get_generic_traces3D( - obj, - extra_backend=extra_backend, - autosize=autosize, - **params, - ) - if extra_backend: - extra_backend_traces.extend(out_traces.get(extra_backend, [])) - traces_dict[obj].extend(out_traces["generic"]) + params.pop("style", None) + out_traces = get_generic_traces3D( + obj, + extra_backend=extra_backend, + autosize=autosize, + field_values=field_by_sens.get(obj, None), + **params, + ) + if extra_backend: + extra_backend_traces.extend(out_traces.get(extra_backend, [])) + traces_dict[obj].extend(out_traces["generic"]) return traces_dict, extra_backend_traces -def draw_frame(objs, *, colorsequence, rc_params, style_kwargs, **kwargs) -> tuple: +def get_sensor_pixel_field(objects): + """get field_by_sens if sensor has style pixel field""" + # pylint: disable=import-outside-toplevel + from magpylib._src.fields.field_wrap_BH import getBH_level2 # noqa: PLC0415 + + field_by_sens = {} + sensors = format_obj_input(objects, allow="sensors+collections") + sensors = [ + sub_s + for s in sensors + for sub_s in (s.sensors_all if isinstance(s, magpy.Collection) else [s]) + ] + has_pix_field = False + for sens in sensors: + fsrc = sens.style.pixel.field.source + if fsrc: + field_by_sens[sens] = {} + if not has_pix_field: + sources = format_obj_input(objects, allow="sources") + sources = list(set(sources)) # remove duplicates + if sources: + field = fsrc[0] + has_pix_field = True + out = getBH_level2( + sources, + [sens], + sumup=True, + squeeze=False, + field=field, + pixel_agg=None, + output="ndarray", + in_out="auto", + ) + # select first source (for sumup=True there is only one) + # and path index + reshape pixel + path_len = out.shape[1] + out = out[0].reshape(path_len, -1, 3) + field_by_sens[sens][field] = out + return field_by_sens + + +def draw_frame(objs, *, rc_params, style_kwargs, **kwargs): """ Creates traces from input `objs` and provided parameters, updates the size of objects like Sensors and Dipoles in `kwargs` depending on the canvas size. @@ -800,84 +884,81 @@ def draw_frame(objs, *, colorsequence, rc_params, style_kwargs, **kwargs) -> tup traces_dicts, kwargs: dict, dict returns the traces in a obj/traces_list dictionary and updated kwargs """ - if colorsequence is None: - # pylint: disable=no-member - colorsequence = default_settings.display.colorsequence # dipoles and sensors use autosize, the trace building has to be put at the back of the queue. # autosize is calculated from the other traces overall scene range - objs_rc = get_objects_props_by_row_col( - *objs, - colorsequence=colorsequence, - style_kwargs=style_kwargs, - ) + style_kwargs = {k[6:]: v for k, v in style_kwargs.items() if k.startswith("style_")} + if style_kwargs: + for obj in objs["objects"]: + obj.style.update(style_kwargs) + traces_dict = {} extra_backend_traces = [] rc_params = {} if rc_params is None else rc_params - for rc, props in objs_rc.items(): - if props["rc_params"]["output"] == "model3d": - rc_params[rc] = rc_params.get(rc, {}) - rc_params[rc]["units_length"] = props["rc_params"]["units_length"] - rc_keys = ("row", "col") - rc_kwargs = {k: v for k, v in props["rc_params"].items() if k in rc_keys} - traces_d1, traces_ex1 = get_traces_3D( - props["objects"], **rc_kwargs, **kwargs - ) - rc_params[rc]["autosize"] = rc_params.get(rc, {}).get("autosize", None) - if rc_params[rc]["autosize"] is None: - zoom = rc_params[rc]["zoom"] = props["rc_params"]["zoom"] - traces = [t for tr in traces_d1.values() for t in tr] - ranges_rc = get_scene_ranges(*traces, *traces_ex1, zoom=zoom) - # pylint: disable=no-member - factor = default_settings.display.autosizefactor - rc_params[rc]["autosize"] = np.mean(np.diff(ranges_rc[rc])) / factor - to_resize_keys = { - k for k, v in traces_d1.items() if v and "_autosize" in v[0] - } - flat_objs_props = { - k: v for k, v in props["objects"].items() if k in to_resize_keys - } - traces_d2, traces_ex2 = get_traces_3D( - flat_objs_props, - autosize=rc_params[rc]["autosize"], - **rc_kwargs, - **kwargs, - ) - traces_dict.update( - {(k, *rc): v for k, v in {**traces_d1, **traces_d2}.items()} - ) - extra_backend_traces.extend([*traces_ex1, *traces_ex2]) + rc = objs["rc_params"]["row"], objs["rc_params"]["col"] + rc_params["units_length"] = objs["rc_params"]["units_length"] + rc_keys = ("row", "col") + kwargs.update({k: v for k, v in objs["rc_params"].items() if k in rc_keys}) + if objs["rc_params"]["output"] == "model3d": + traces_d1, traces_ex1 = get_traces_3D(objs["objects"], **kwargs) + rc_params["autosize"] = rc_params.get("autosize", None) + if rc_params["autosize"] is None: + # get the dipoles and sensors autosize from first frame + # rc_params gets returned and passed back to the function + zoom = rc_params["zoom"] = objs["rc_params"]["zoom"] + traces = [t for tr in traces_d1.values() for t in tr] + ranges_rc = get_scene_ranges(*traces, *traces_ex1, zoom=zoom) + # pylint: disable=no-member + factor = default_settings.display.autosizefactor + rc_params["autosize"] = np.mean(np.diff(ranges_rc[rc])) / factor + to_resize_keys = {k for k, v in traces_d1.items() if v and "_autosize" in v[0]} + flat_objs_props = { + k: v for k, v in objs["objects"].items() if k in to_resize_keys + } + traces_d2, traces_ex2 = get_traces_3D( + flat_objs_props, autosize=rc_params["autosize"], **kwargs + ) + traces_dict.update({**traces_d1, **traces_d2}) + extra_backend_traces.extend([*traces_ex1, *traces_ex2]) traces = group_traces(*[t for tr in traces_dict.values() for t in tr]) - styles = { - obj: params.get("style", None) - for o_rc in objs_rc.values() - for obj, params in o_rc["objects"].items() - } - for props in objs_rc.values(): - if props["rc_params"]["output"] != "model3d": - traces2d = get_traces_2D( - *props["objects"], - **props["rc_params"], - styles=styles, - ) - traces.extend(traces2d) + if objs["rc_params"]["output"] != "model3d": + traces2d = get_traces_2D( + *objs["objects"], + **objs["rc_params"], + ) + traces.extend(traces2d) return traces, extra_backend_traces, rc_params -def get_frames( - objs, - colorsequence=None, - title=None, - animation=False, - supports_colorgradient=True, - backend="generic", - style_kwargs=None, - **kwargs, -): +def get_frames(objs, *, title, supports_colorgradient, backend, **kwargs): """This is a helper function which generates frames with generic traces to be provided to the chosen backend. According to a certain zoom level, all three space direction will be equal and match the maximum of the ranges needed to display all objects, including their paths. """ + + # process all kwargs + # pylint: disable=no-member + colorsequence = kwargs.pop("colorsequence", default_settings.display.colorsequence) + + # extract style info + style_kwargs = {k: v for k, v in kwargs.items() if k.startswith("style")} + style_kwargs = linearize_dict(style_kwargs, separator="_") + kwargs = {k: v for k, v in kwargs.items() if not k.startswith("style")} + + # extract animation info + ( + is_animation, + path_indices, + path_digits, + frame_duration, + animation_kwargs, + ) = process_animation_kwargs([o for obj in objs for o in obj["objects"]], **kwargs) + kwargs = {k: v for k, v in kwargs.items() if not k.startswith("animation")} + + if kwargs: + msg = f"`show` got unexpected keyword argument(s) {kwargs!r}" + raise TypeError(msg) + # infer title if necessary if objs: style = objs[0]["objects"][0].style @@ -886,56 +967,56 @@ def get_frames( else: title = "No objects to be displayed" - # make sure the number of frames does not exceed the max frames and max frame rate - # downsample if necessary - obj_list_semi_flat = format_obj_input( - [o["objects"] for o in objs], allow="sources+sensors+collections" - ) - kwargs, animation, animation_kwargs = process_animation_kwargs( - obj_list_semi_flat, animation=animation, **kwargs + objs_rc = get_objects_props_by_row_col( + *objs, + colorsequence=colorsequence, + style_kwargs=style_kwargs, ) - path_indices = [-1] - if animation: - path_indices, exp, frame_duration = extract_animation_properties( - obj_list_semi_flat, **animation_kwargs - ) # create frame for each path index or downsampled path index - frames = [] - + style_kwargs = {} title_str = title - rc_params = {} - for i, ind in enumerate(path_indices): - extra_backend_traces = [] - if animation: - style_kwargs["style_path_frames"] = [ind] - title = "Animation 3D - " if title is None else title - title_str = f"""{title}path index: {ind + 1:0{exp}d}""" - traces, extra_backend_traces, rc_params_temp = draw_frame( - objs, - colorsequence=colorsequence, - rc_params=rc_params, - supports_colorgradient=supports_colorgradient, - extra_backend=backend, - style_kwargs=style_kwargs, - **kwargs, - ) - if i == 0: # get the dipoles and sensors autosize from first frame - rc_params = rc_params_temp - frames.append( - { - "data": traces, - "name": str(ind + 1), - "layout": {"title": title_str}, - "extra_backend_traces": extra_backend_traces, - } - ) + frames = [ + { + "name": str(path_ind + 1), + "data": [], + "extra_backend_traces": [], + "layout": {}, + } + for path_ind in path_indices + ] + for props in objs_rc.values(): + styles = {obj: prop["style"] for obj, prop in props["objects"].items()} + rc_params = None + with style_temp_edit(*styles, styles_temp=styles): + field_by_sens = get_sensor_pixel_field(list(props["objects"])) + for frame, path_ind in zip(frames, path_indices, strict=False): + if is_animation: + style_kwargs["style_path_frames"] = [path_ind] + title = "Animation 3D - " if title is None else title + title_str = f"""{title}path index: {path_ind + 1:0{path_digits}d}""" + traces, extra_backend_traces, rc_params = draw_frame( + props, + field_by_sens=field_by_sens, + rc_params=rc_params, + supports_colorgradient=supports_colorgradient, + extra_backend=backend, + style_kwargs=style_kwargs, + ) + frame["data"].extend(traces) + frame["extra_backend_traces"].extend(extra_backend_traces) + frame["layout"] = {"title": title_str} clean_legendgroups(frames) - traces = [t for frame in frames for t in frame["data"]] - zoom = {rc: v["zoom"] for rc, v in rc_params.items()} - ranges_rc = get_scene_ranges(*traces, *extra_backend_traces, zoom=zoom) + all_traces = [ + t + for frame in frames + for t in chain(frame["data"], frame["extra_backend_traces"]) + ] + zoom = {rc: v["rc_params"]["zoom"] for rc, v in objs_rc.items()} + ranges_rc = get_scene_ranges(*all_traces, zoom=zoom) labels_rc = {(1, 1): dict.fromkeys("xyz", "")} scale_factors_rc = {} - for rc, params in rc_params.items(): + for rc, props in objs_rc.items(): + params = props["rc_params"] units_length = params["units_length"] if units_length == "auto": rmax = np.amax(np.abs(ranges_rc[rc])) @@ -955,7 +1036,7 @@ def get_frames( "labels": labels_rc, "input_kwargs": {**kwargs, **animation_kwargs}, } - if animation: + if is_animation: out.update( { "frame_duration": frame_duration, diff --git a/src/magpylib/_src/display/traces_utility.py b/src/magpylib/_src/display/traces_utility.py index 6fa637c18..aa3a92d7b 100644 --- a/src/magpylib/_src/display/traces_utility.py +++ b/src/magpylib/_src/display/traces_utility.py @@ -8,13 +8,19 @@ from itertools import chain, cycle import numpy as np +from matplotlib.colors import rgb2hex +from plotly.colors import sample_colorscale from scipy.spatial.transform import Rotation as RotScipy from magpylib._src.defaults.defaults_classes import default_settings from magpylib._src.defaults.defaults_utility import linearize_dict from magpylib._src.input_checks import check_input_zoom from magpylib._src.style import get_style -from magpylib._src.utility import format_obj_input, merge_dicts_with_conflict_check +from magpylib._src.utility import ( + format_obj_input, + is_array_like, + merge_dicts_with_conflict_check, +) DEFAULT_ROW_COL_PARAMS = { "row": 1, @@ -130,6 +136,102 @@ def get_vertices_from_model(model_kwargs, model_args=None, coordsargs=None): return vertices, coordsargs, useargs +def get_orientation_from_vec(vec, ref_axis=(0, 0, 1)): + """ + Compute rotation from input vector to reference axis, handling NaNs, infs, + or zero vectors by returning an identity rotation. + + Parameters + ---------- + vec : array_like of shape (n,3) + Input vector (3D) + ref_axis : array_like, optional + Reference axis (3D), default is (0, 0, 1) + + Returns + ------- + Rotation + Rotation object from input vector to reference axis. + """ + + # normalize reference axis + ref_axis = np.array(ref_axis) / np.linalg.norm(ref_axis) + + # Initialize rotation vector array + rotvec = np.zeros_like(vec) + + # Find invalid vectors (NaNs, infs) and zero vectors + invalid_mask = np.isnan(vec).any(axis=1) | np.isinf(vec).any(axis=1) + zero_vector_mask = ~invalid_mask & (np.linalg.norm(vec, axis=1) == 0) + valid_mask = ~(invalid_mask | zero_vector_mask) + + # Normalize valid input vectors + vec_valid = vec[valid_mask] + norm_valid = np.linalg.norm(vec_valid, axis=1, keepdims=True) + vec_valid = vec_valid / norm_valid + + # get angle and axis for valid rotvecs + cross_valid = np.cross(vec_valid, ref_axis) + cross_magnitudes = np.linalg.norm(cross_valid, axis=1) + mask = cross_magnitudes > 0 + cross_valid[mask] = cross_valid[mask] / cross_magnitudes[mask][:, np.newaxis] + dot_valid = np.dot(vec_valid, ref_axis) + # Clip dot product to ensure it falls within the valid range of arccos + dot_valid = np.clip(dot_valid, -1.0, 1.0) + angle_valid = np.arccos(dot_valid) + + # Compute rotation vectors for valid inputs + rotvec_valid = -cross_valid * angle_valid[:, np.newaxis] + + # Handle the edge case where the vectors are anti-parallel + anti_parallel_mask = np.isclose(dot_valid, -1) + if np.any(anti_parallel_mask): + # Find an arbitrary axis orthogonal to the reference axis + orthogonal_axis = np.cross(ref_axis, np.array([1, 0, 0])) + # if ref_axis was colinear with orthogonal axis + if np.linalg.norm(orthogonal_axis) == 0: + orthogonal_axis = np.cross(ref_axis, np.array([0, 1, 0])) + # Apply 180 degrees rotation around the orthogonal axis + rotvec_valid[anti_parallel_mask] = np.pi * orthogonal_axis + + rotvec[valid_mask] = rotvec_valid + return RotScipy.from_rotvec(rotvec) + + +def draw_zarrow( + height=1.0, + diameter=0.1, + sign_offset=1.0, + sign=1, + pivot="middle", + include_line=True, +): + """Provides x,y,z coordinates of an arrow drawn in the x-z-plane (y=0) + centered in x,y,z=(0,0,0)""" + shift = sign_offset - 0.5 + hx = 0.6 * diameter + hz = np.sign(sign) * diameter + anchor = ( + (0, -0.5, 0) + if pivot == "tip" + else (0, 0.5, 0) + if pivot == "tail" + else (0, 0, 0) + ) + arrow = [ + [0, 0, shift], + [-hx, 0, shift - hz], + [0, 0, shift], + [hx, 0, shift - hz], + [0, 0, shift], + ] + if include_line: + arrow = [[0, 0, -0.5], *arrow, [0, 0, 0.5]] + else: + arrow = [[0, 0, -0.5], [np.nan] * 3, *arrow, [np.nan] * 3, [0, 0, 0.5]] + return (np.array(arrow) + np.array(anchor)) * height + + def draw_arrowed_line( vec, pos, @@ -230,36 +332,25 @@ def draw_arrow_on_circle(sign, diameter, arrow_size, scaled=True, angle_pos_deg= return vertices -def get_rot_pos_from_path(obj, show_path=None): - """ - subsets orientations and positions depending on `show_path` value. - examples: - show_path = [1,2,8], path_len = 6 -> path_indices = [1,2,6] - returns rots[[1,2,6]], poss[[1,2,6]] - """ +def path_frames_to_indices(frames, path_len): + """get frames indices from frames input (can be bool,int, array)""" # pylint: disable=protected-access # pylint: disable=invalid-unary-operand-type - if show_path is None: - show_path = True - pos = obj._position - orient = obj._orientation - path_len = pos.shape[0] - if show_path is True or show_path is False or show_path == 0: + if frames is None: + frames = True + if frames is True or frames is False or frames == 0: inds = np.array([-1]) - elif isinstance(show_path, int): - inds = np.arange(path_len, dtype=int)[::-show_path] - elif hasattr(show_path, "__iter__") and not isinstance(show_path, str): - inds = np.array(show_path) + elif isinstance(frames, int): + inds = np.arange(path_len, dtype=int)[::-frames] + elif hasattr(frames, "__iter__") and not isinstance(frames, str): + inds = np.array(frames) else: # pragma: no cover - msg = f"Invalid show_path value ({show_path})" + msg = f"Invalid show_path value ({frames})" raise ValueError(msg) - inds[inds >= path_len] = path_len - 1 - inds = np.unique(inds) + inds = inds[inds < path_len] if inds.size == 0: inds = np.array([path_len - 1]) - rots = orient[inds] - poss = pos[inds] - return rots, poss, inds + return inds def get_objects_props_by_row_col(*objs, colorsequence, style_kwargs): @@ -379,12 +470,31 @@ def merge_scatter3d(*traces): traces[0]["mode"] = "markers" no_gap = "line" not in mode - merged_trace = {} - for k in "xyz": + ffill = "----" + + def fill_trace(tr, fill_value): + if fill_value == ffill: + return [*tr[k], *tr[k][-1:]] + return [*tr[k], fill_value] + + fill_vals = { + "x": np.nan, + "y": np.nan, + "z": np.nan, + "marker_symbol": ffill, + "marker_size": 0, + "marker_color": ffill, + "line_color": ffill, + } + fill_vals = { + k: v for k, v in fill_vals.items() if is_array_like(traces[0].get(k, None)) + } + merged_trace = {**traces[0]} + for k, fill_val in fill_vals.items(): if no_gap: - stack = [b[k] for b in traces] + stack = [tr[k] for tr in traces] else: - stack = [pts for b in traces for pts in [[None], b[k]]] + stack = [fill_trace(tr, fill_val) for tr in traces] merged_trace[k] = np.hstack(stack) for k, v in traces[0].items(): if k not in merged_trace: @@ -583,7 +693,7 @@ def group_traces(*traces): mesh_groups = {} common_keys = ["legendgroup", "opacity", "row", "col", "color"] spec_keys = { - "mesh3d": ["colorscale", "color", "facecolor"], + "mesh3d": ["colorscale", "facecolor"], "scatter3d": [ "marker", "line_dash", @@ -596,20 +706,17 @@ def group_traces(*traces): ], } for tr_item in traces: - tr = tr_item - tr = linearize_dict( - tr, - separator="_", - ) - gr = [tr["type"]] - for k in [*common_keys, *spec_keys.get(tr["type"], [])]: - v = tr.get(k, None) is None if k == "facecolor" else tr.get(k, "") + tr = linearize_dict(tr_item, separator="_") + tr_typ = tr["type"] + gr = [tr_typ] + for k in [*common_keys, *spec_keys.get(tr_typ, [])]: + v = tr.get(k, None) + # colorscales cannot merged in mesh3d (yet) + if k != "colorscale" and is_array_like(v): + v = "array" gr.append(str(v)) - gr = "".join(gr) - if gr not in mesh_groups: - mesh_groups[gr] = [] - mesh_groups[gr].append(tr) - + gr = hash(tuple(gr)) + mesh_groups.setdefault(gr, []).append(tr) traces = [] for group in mesh_groups.values(): traces.extend(merge_traces(*group)) @@ -809,3 +916,82 @@ def create_null_dim_trace(color=None, **kwargs): if color is not None: trace["marker_color"] = color return {**trace, **kwargs} + + +def get_hexcolors_from_colormap( + values, + colormap, + cmin=None, + cmax=None, + nan_color="#b2beb5", +): + """Convert numerical values to hexadecimal colors based on a color scale. + Invalid value in the array a converted to the specified `nan_color`.""" + values = np.array(values) + nan_mask = np.isnan(values) + valid = values[~nan_mask] + cmin = np.nanmin(valid) if cmin is None else cmin + cmax = np.nanmax(valid) if cmax is None else cmax + ptp = cmax - cmin + values = (values - cmin) / ptp if ptp != 0 else values * 0 + 0.5 + rgb_colors = sample_colorscale(colormap, values[~nan_mask], colortype=None) + hex_colors = [rgb2hex(rgb) for rgb in rgb_colors] + out = np.array([""] * len(values), dtype=" array([3]) + get_kw(trace, "line_color") -> "blue" + get_kw(trace, "line_dash") -> None + get_kw(trace, "line_width", none_replace=3) -> 3 # not found is like None + """ + pref, *param = arg.split("_") + parent = trace.get(pref, {}) + res = trace.get(arg, None) + if param: + res = parent.get("".join(param), trace.get(arg, None)) + res = np.array(res) if is_array_like(res) else res + if res is None: + return none_replace + return res diff --git a/src/magpylib/_src/input_checks.py b/src/magpylib/_src/input_checks.py index 706159870..34be15356 100644 --- a/src/magpylib/_src/input_checks.py +++ b/src/magpylib/_src/input_checks.py @@ -29,7 +29,7 @@ def all_same(lst: list) -> bool: return lst[1:] == lst[:-1] -def is_array_like(inp, msg: str): +def check_array_like(inp, msg: str): """test if inp is array_like: type list, tuple or ndarray inp: test object msg: str, error msg @@ -330,7 +330,7 @@ def check_format_input_vector( if allow_None and inp is None: return None - is_array_like( + check_array_like( inp, f"Input parameter `{sig_name}` must be {sig_type}.\n" f"Instead received type {type(inp)!r}.", @@ -368,7 +368,7 @@ def check_format_input_vector2( - convert inp to ndarray with dtype float - make sure that inp.ndim = target_ndim, None dimensions are ignored """ - is_array_like( + check_array_like( inp, f"Input parameter `{param_name}` must be array_like.\n" f"Instead received type {type(inp)!r}.", diff --git a/src/magpylib/_src/style.py b/src/magpylib/_src/style.py index e6a8ea23f..39354a396 100644 --- a/src/magpylib/_src/style.py +++ b/src/magpylib/_src/style.py @@ -5,6 +5,8 @@ # pylint: disable=cyclic-import # pylint: disable=too-many-positional-arguments +import re + import numpy as np from magpylib._src.defaults.defaults_utility import ( @@ -1652,6 +1654,169 @@ def __init__(self, **kwargs): super().__init__(**kwargs) +class PixelField(MagicProperties): + """Defines the styling properties of sensor pixels. + + Parameters + ---------- + source: str, default=None + The pixel color source (e.g. "Bx", "Hxy", "J", etc.). If not specified, + the amplitude of the `source` value is used. + + colormap: str, default="Inferno", + The colormap used with `source`. + + shownull: bool, default=True + Show/hide null or invalid field values + + symbol: {"cone", "arrow", "arrow3d"}: + Orientation symbol for field vector. + + sizescaling: {"uniform", "linear","log","log^[2-9]"} + Symbol size scaling relative the the field magnitude. + + sizemin: float, default=0. + Minimum relative size of field symbols (0 to 1). + + colorscaling: {"uniform", "linear","log","log^[2-9]"} + Color scale scaling relative the the field magnitude. + """ + + _allowed_scalings_pattern = r"^(uniform|linear|(log)+|log\^[2-9])$" + _allowed_vectors = ("B", "H", "M", "J") + _allowed_symbols = ("cone", "arrow", "arrow3d", "none") + _allowed_colormaps = ( + "Viridis", + "Jet", + "Rainbow", + "Plasma", + "Inferno", + "Magma", + "Cividis", + "Greys", + "Purples", + "Blues", + "Greens", + "Oranges", + "Reds", + "YlOrBr", + "YlOrRd", + "OrRd", + "PuRd", + "RdPu", + "BuPu", + "GnBu", + "PuBu", + "YlGnBu", + "PuBuGn", + "BuGn", + "YlGn", + ) + + @property + def source(self): + """Pixel vector source.""" + return self._source + + @source.setter + def source(self, val): + valid = True + if val not in (None, False): + field_str, *coords_str = val + if not coords_str: + coords_str = list("xyz") + if field_str not in self._allowed_vectors and set(coords_str).difference( + set("xyz") + ): + valid = False + assert valid, ( + f"The `source` property of {type(self).__name__} must be None or False or start" + f" with either {self._allowed_vectors} and be followed by a combination of" + f" 'x', 'y', 'z' (e.g. 'Bxy' or ('Bxy', 'Bz') ) but received {val!r} instead." + ) + self._source = val + + @property + def colormap(self): + """Pixel vector source.""" + return self._colormap + + @colormap.setter + def colormap(self, val): + assert val is None or val in self._allowed_colormaps, ( + f"The `colormap` property of {type(self).__name__} must be one of" + f"{self._allowed_colormaps},\n" + f"but received {val!r} instead." + ) + self._colormap = val + + @property + def shownull(self): + """Show/hide null or invalid field values""" + return self._shownull + + @shownull.setter + def shownull(self, val): + assert val is None or isinstance(val, bool), ( + f"The `shownull` property of {type(self).__name__} must be either True or False," + f"but received {val!r} instead." + ) + self._shownull = val + + @property + def symbol(self): + """Pixel symbol. Can be one of `{"cone", "arrow", "arrow3d"}`.""" + return self._symbol + + @symbol.setter + def symbol(self, val): + assert val is None or val in self._allowed_symbols, ( + f"The `symbol` property of {type(self).__name__} must be one of" + f"{self._allowed_symbols},\n" + f"but received {val!r} instead." + ) + self._symbol = val + + @property + def sizescaling(self): + """Pixel sizescaling. Can be one of `{"uniform", "linear","log","log^[2-9]"}`.""" + return self._sizescaling + + @sizescaling.setter + def sizescaling(self, val): + self._sizescaling = self._validate_scaling(val, name="sizescaling") + + @property + def sizemin(self): + """Minimum relative size of field symbols (0 to 1).""" + return self._sizemin + + @sizemin.setter + def sizemin(self, val): + assert val is None or (isinstance(val, float | int) and 0 <= val <= 1), ( + "The `sizemin` property must be a value between 0 and 1,\n" + f"but received {val!r} instead." + ) + self._sizemin = val + + @property + def colorscaling(self): + """Pixel colorscaling. Can be one of `{"uniform", "linear","log","log^[2-9]"}`.""" + return self._colorscaling + + @colorscaling.setter + def colorscaling(self, val): + self._colorscaling = self._validate_scaling(val, name="colorscaling") + + def _validate_scaling(self, val, name): + assert val is None or re.match(self._allowed_scalings_pattern, str(val)), ( + f"The `{name}` property of {type(self).__name__} must match the regex pattern" + f" {self._allowed_scalings_pattern},\n" + f"but received {val!r} instead." + ) + return val + + class Pixel(MagicProperties): """Defines the styling properties of sensor pixels. @@ -1670,10 +1835,11 @@ class Pixel(MagicProperties): Defines the pixel color@property. symbol: str, default=None - Pixel symbol. Can be one of `['.', 'o', '+', 'D', 'd', 's', 'x']`. - Only applies for matplotlib plotting backend. + Pixel symbol. Can be one of `['cube', '.', 'o', '+', 'D', 'd', 's', 'x']`. """ + _allowed_symbols = ("cube", *ALLOWED_SYMBOLS) + def __init__(self, size=1, sizemode=None, color=None, symbol=None, **kwargs): super().__init__( size=size, @@ -1722,18 +1888,27 @@ def color(self, val): @property def symbol(self): - """Pixel symbol. Can be one of `['.', 'o', '+', 'D', 'd', 's', 'x']`.""" + """Pixel symbol. Can be one of `['cube', '.', 'o', '+', 'D', 'd', 's', 'x']`.""" return self._symbol @symbol.setter def symbol(self, val): - assert val is None or val in ALLOWED_SYMBOLS, ( + assert val is None or val in self._allowed_symbols, ( f"The `symbol` property of {type(self).__name__} must be one of" - f"{ALLOWED_SYMBOLS},\n" + f"{self._allowed_symbols},\n" f"but received {val!r} instead." ) self._symbol = val + @property + def field(self): + """`PixelField` object or dict.""" + return self._field + + @field.setter + def field(self, val): + self._field = validate_property_class(val, "pixel", PixelField, self) + class CurrentProperties: """Defines styling properties of line current classes. diff --git a/src/magpylib/_src/utility.py b/src/magpylib/_src/utility.py index 0247196f3..a9400474e 100644 --- a/src/magpylib/_src/utility.py +++ b/src/magpylib/_src/utility.py @@ -495,16 +495,26 @@ def merge_dicts_with_conflict_check(objs, *, target, identifiers, unique_fields) @contextmanager -def style_temp_edit(obj, style_temp, copy=True): +def style_temp_edit(*objs, styles_temp=None): """Temporary replace style to allow edits before returning to original state""" # pylint: disable=protected-access - orig_style = getattr(obj, "_style", None) + styles_temp = {} if styles_temp is None else styles_temp + orig_styles = {} try: - # temporary replace style attribute - obj._style = style_temp - if style_temp and copy: - # deepcopy style only if obj is in multiple subplots. - obj._style = style_temp.copy() + for obj in objs: + style_temp = styles_temp.get(obj, None) + orig_styles[obj] = getattr(obj, "_style", None) + # temporary save original style + if orig_styles[obj] is not None: + orig_styles[obj] = obj._style.copy() + if style_temp is not None: + obj._style = style_temp yield finally: - obj._style = orig_style + for obj in objs: + obj._style = orig_styles[obj] + + +def is_array_like(inp): + """Return boolean on whether input is an array, list or tuple""" + return isinstance(inp, list | tuple | np.ndarray) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 000000000..a5c4551fe --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +import base64 +import re +import warnings + +import numpy as np +import pytest + + +def _convert_ndarray_to_list(obj): + """Recursively convert numpy arrays in dicts/lists to lists.""" + if isinstance(obj, np.ndarray): + return obj.tolist() + if isinstance(obj, dict): + return {k: _convert_ndarray_to_list(v) for k, v in obj.items()} + if isinstance(obj, list): + return [_convert_ndarray_to_list(i) for i in obj] + return obj + + +def _sanitize_ids(obj): + """Recursively replace random id fields (e.g., id=12345678) with a constant value.""" + if isinstance(obj, dict): + return {k: _sanitize_ids(v) for k, v in obj.items()} + if isinstance(obj, list): + return [_sanitize_ids(i) for i in obj] + if isinstance(obj, str): + # Replace patterns like 'id=12345678' or 'Sensor(id=12345678)' + return re.sub(r"id=\d+", "id=ID", obj) + return obj + + +def _normalize_bdata(obj): + """Recursively decode bdata fields and convert to lists with canonical endianness.""" + if isinstance(obj, dict): + # If both bdata and dtype are present, decode and replace with list + if "bdata" in obj and "dtype" in obj: + b = base64.b64decode(obj["bdata"]) + dtype = np.dtype(obj["dtype"]) + # Always use little-endian for comparison + dtype_le = dtype.newbyteorder("<") + arr = np.frombuffer(b, dtype=dtype_le) + # Re-encode to bdata for platform-independent storage + bdata_str = base64.b64encode(arr.astype(dtype_le).tobytes()).decode("ascii") + return {"bdata": bdata_str, "dtype": obj["dtype"]} + return {k: _normalize_bdata(v) for k, v in obj.items()} + if isinstance(obj, list): + return [_normalize_bdata(i) for i in obj] + return obj + + +@pytest.fixture +def fig_regression_helper(data_regression, image_regression): + """Regression helper for Plotly figures using to_plotly_json().""" + + def check_fig(fig, mode="data"): + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=DeprecationWarning) + if mode == "data": + fig_data = fig.to_plotly_json() + fig_data = _convert_ndarray_to_list(fig_data) + fig_data = _sanitize_ids(fig_data) + fig_data = _normalize_bdata(fig_data) + data_regression.check(fig_data) + elif mode == "image": + image_bytes = fig.to_image(format="png", scale=1) + image_regression.check(image_data=image_bytes, diff_threshold=0.1) + + return check_fig diff --git a/tests/test_display_plotly.py b/tests/test_display_plotly.py index b1ba7bce9..c0d842823 100644 --- a/tests/test_display_plotly.py +++ b/tests/test_display_plotly.py @@ -1,5 +1,7 @@ import re +# pylint: disable=assignment-from-no-return +# pylint: disable=no-member import numpy as np import plotly.graph_objects as go import pytest @@ -8,9 +10,6 @@ from magpylib._src.exceptions import MagpylibBadUserInput from magpylib._src.utility import get_unit_factor -# pylint: disable=assignment-from-no-return -# pylint: disable=no-member - def test_Cylinder_display(): """testing display""" @@ -503,3 +502,79 @@ def test_units_length(): factor = get_unit_factor(inp["units_length"], target_unit="m") r = (inp["zoom"] + 1) / 2 * factor * max(dims) assert ax.range == (-r, r) + + +def test_pixel_field_directional_symbols(): + """Test different directional symbols in subplots for all symbol options.""" + c1 = magpy.magnet.Cuboid( + polarization=(1, 0, 0), dimension=(1, 1, 1), style_opacity=0.2 + ) + ls = np.linspace(-1, 1, 3) + s0 = magpy.Sensor(pixel=[[x, y, 0] for x in ls for y in ls], position=(0, 0, 0)) + symbols = ["cone", "arrow3d", "arrow"] + subplots = [] + for i, sym in enumerate(symbols, 1): + s = s0.copy( + style_pixel_field_source="B", + style_pixel_field_symbol=sym, + style_description=str(sym), + ) + subplots.append({"objects": [c1, s], "col": i}) + magpy.show(*subplots, backend="plotly", return_fig=True) + + +def test_pixel_field_sizing_modes(): + """Test sizing modes of directional symbols in subplots for all sizemode options.""" + c1 = magpy.magnet.Cuboid( + polarization=(1, 0, 0), dimension=(1, 1, 1), style_opacity=0.2 + ) + ls = np.linspace(-1, 1, 3) + s0 = magpy.Sensor(pixel=[[x, y, 0] for x in ls for y in ls], position=(0, 0, 0)) + sizemodes = ["uniform", "linear", "log", "log^2", "log^9"] + subplots = [] + for i, sm in enumerate(sizemodes, 1): + s = s0.copy( + style_pixel_field_source="B", + style_pixel_field_sizescaling=sm, + style_description=str(sm), + ) + subplots.append({"objects": [c1, s], "col": i}) + magpy.show(*subplots, backend="plotly", return_fig=True) + + +def test_pixel_field_null_values(): + """Test handling of null or NaN values in subplots for both shownull options.""" + c1 = magpy.magnet.Cuboid( + polarization=(1, 0, 0), dimension=(1, 1, 1), style_opacity=0.2 + ) + ls = np.linspace(-1, 1, 5) + s0 = magpy.Sensor(pixel=[[x, y, 0] for x in ls for y in ls], position=(0, 0, 0)) + shownulls = [True, False] + subplots = [] + for i, sn in enumerate(shownulls, 1): + s = s0.copy( + style_pixel_field_source="B", + style_pixel_field_shownull=sn, + style_description=str(sn), + ) + subplots.append({"objects": [c1, s], "col": i}) + magpy.show(*subplots, backend="plotly", return_fig=True) + + +def test_pixel_field_color_scales(): + """Test different color scales in subplots for all colorscale options.""" + c1 = magpy.magnet.Cuboid( + polarization=(1, 0, 0), dimension=(1, 1, 1), style_opacity=0.2 + ) + ls = np.linspace(-1, 1, 3) + s0 = magpy.Sensor(pixel=[[x, y, 1] for x in ls for y in ls], position=(0, 0, 0)) + colorscales = ["Viridis", "Inferno", "Oranges", "RdPu"] + subplots = [] + for i, cs in enumerate(colorscales, 1): + s = s0.copy( + style_pixel_field_source="B", + style_pixel_field_colormap=cs, + style_description=str(cs), + ) + subplots.append({"objects": [c1, s], "col": i}) + magpy.show(*subplots, backend="plotly", return_fig=True) diff --git a/tests/test_display_utility.py b/tests/test_display_utility.py index ccc410d0a..a8c90cfe5 100644 --- a/tests/test_display_utility.py +++ b/tests/test_display_utility.py @@ -9,6 +9,7 @@ import magpylib as magpy from magpylib._src.display.traces_utility import ( draw_arrow_from_vertices, + get_orientation_from_vec, merge_scatter3d, ) from magpylib._src.exceptions import MagpylibBadUserInput @@ -103,3 +104,28 @@ def get_traces(n): merge_scatter3d(*get_traces(1)) merge_scatter3d(*get_traces(3)) + + +def test_get_orientation_from_vec(): + """test get_orientation_from_vec""" + # should return ref axis non null vectors and invalid inputs + # antiparallel to the ref_axis should return valid 180 rotation + ref_axis = (0, 0, 1) + vec = np.array( + [ + [0, 0, 1], + [0, 1, 0], + [0, 0, 1], + [np.nan, 0, 0], + [0, 0, 0], + [0, 0, -1], + [1, 2, 3], + [4, 5, 6], + ] + ) + with np.errstate(divide="ignore", invalid="ignore"): + vec = (vec.T / np.linalg.norm(vec, axis=1)).T + hasnan = np.isnan(vec).any(axis=1) + orient = get_orientation_from_vec(vec, ref_axis=ref_axis) + vec[hasnan] = ref_axis + np.testing.assert_allclose(vec, orient.apply(ref_axis), atol=1e-8) diff --git a/tests/test_obj_BaseGeo.py b/tests/test_obj_BaseGeo.py index 993ac18b8..b8e24c4a7 100644 --- a/tests/test_obj_BaseGeo.py +++ b/tests/test_obj_BaseGeo.py @@ -692,7 +692,7 @@ def test_describe_with_exclude_None(): # print("test = [\n " + '",\n '.join(f'"{s}' for s in desc.split("\n")) + '",\n]') x = magpy.Sensor() test = [ - "Sensor(id=1687262758416)", + "Sensor(id=140534160166976)", " • parent: None", " • position: [0. 0. 0.] m", " • orientation: [0. 0. 0.] deg", @@ -706,8 +706,9 @@ def test_describe_with_exclude_None(): " legend=Legend(show=None, text=None), model3d=Model3d(data=[], showdefault=True)," " opacity=None, path=Path(frames=None, line=Line(color=None, style=None, width=None)," " marker=Marker(color=None, size=None, symbol=None), numbering=None, show=None)," - " pixel=Pixel(color=None, size=1, sizemode=None, symbol=None), size=None," - " sizemode=None)" + " pixel=Pixel(color=None, field=PixelField(colormap=None, colorscaling=None, shownull=None," + " sizemin=None, sizescaling=None, source=None, symbol=None), size=1," + " sizemode=None, symbol=None), size=None, sizemode=None)" ), " • volume: 0.0", ]