diff --git a/docs/_static/plot-types/mesh3d-plot.png b/docs/_static/plot-types/mesh3d-plot.png new file mode 100644 index 00000000..d8c0b603 Binary files /dev/null and b/docs/_static/plot-types/mesh3d-plot.png differ diff --git a/docs/api-reference/index.md b/docs/api-reference/index.md index af729fa8..c3dd2039 100644 --- a/docs/api-reference/index.md +++ b/docs/api-reference/index.md @@ -8,10 +8,11 @@ .. autosummary:: :toctree: ../generated + inspector + mesh3d plot slicer superplot - inspector scatter scatter3d xyplot @@ -26,8 +27,8 @@ core.Node core.View core.node - core.widget_node core.show_graph + core.widget_node ``` ## Graphics @@ -41,6 +42,7 @@ graphics.ColorMapper graphics.imagefigure graphics.linefigure + graphics.mesh3dfigure graphics.scatterfigure graphics.scatter3dfigure graphics.tiled diff --git a/docs/api-reference/pythreejs.md b/docs/api-reference/pythreejs.md index 1d0304ff..7a7b0de3 100644 --- a/docs/api-reference/pythreejs.md +++ b/docs/api-reference/pythreejs.md @@ -10,4 +10,5 @@ backends.pythreejs.figure.Figure backends.pythreejs.outline.Outline backends.pythreejs.scatter3d.Scatter3d + backends.pythreejs.mesh3d.Mesh3d ``` diff --git a/docs/user-guide/index.md b/docs/user-guide/index.md index 11bc7429..76d1183f 100644 --- a/docs/user-guide/index.md +++ b/docs/user-guide/index.md @@ -2,7 +2,7 @@ ## Getting started -::::{grid} 3 +::::{grid} 2 :::{grid-item-card} {octicon}`desktop-download;1em`  Installation :link: getting-started/installation.md @@ -14,15 +14,15 @@ ::: +:::: + +::::{grid} 2 + :::{grid-item-card} {octicon}`graph;1em`  Numpy, Pandas, and Xarray :link: getting-started/numpy-pandas-xarray.ipynb ::: -:::: - -::::{grid} 3 - :::{grid-item-card} {octicon}`download;1em`  Saving figures to disk :link: plot-types/saving-figures.ipynb @@ -95,6 +95,12 @@ getting-started/saving-figures ::: +:::{grid-item-card} Mesh 3D plot +:link: plot-types/mesh3d-plot.ipynb +:img-bottom: ../_static/plot-types/mesh3d-plot.png + +::: + :::: ```{toctree} @@ -109,6 +115,7 @@ plot-types/inspector-plot plot-types/super-plot plot-types/scatter-plot plot-types/scatter3d-plot +plot-types/mesh3d-plot ``` ## Custom figures diff --git a/docs/user-guide/plot-types/mesh3d-plot.ipynb b/docs/user-guide/plot-types/mesh3d-plot.ipynb new file mode 100644 index 00000000..71538bfb --- /dev/null +++ b/docs/user-guide/plot-types/mesh3d-plot.ipynb @@ -0,0 +1,189 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0", + "metadata": {}, + "source": [ + "# 3D mesh plot\n", + "\n", + "This notebook illustrates how to render 3D meshes\n", + "by supplying a list of vertex positions and vertex indices to construct the mesh faces." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1", + "metadata": {}, + "outputs": [], + "source": [ + "import scipp as sc\n", + "import plopp as pp\n", + "from plopp.data import examples" + ] + }, + { + "cell_type": "markdown", + "id": "2", + "metadata": {}, + "source": [ + "## Loading mesh data\n", + "\n", + "We load a file containing the data to construct the [Utah teapot](https://en.wikipedia.org/wiki/Utah_teapot)\n", + "(see below for a description of the data format)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3", + "metadata": {}, + "outputs": [], + "source": [ + "dg = sc.io.load_hdf5(examples.teapot())\n", + "dg" + ] + }, + { + "cell_type": "markdown", + "id": "4", + "metadata": {}, + "source": [ + "## Creating a mesh plot\n", + "\n", + "We can now send the data to the [`mesh3d`](../../generated/plopp.mesh3d.html) function for rendering (we color the mesh according to z position):" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5", + "metadata": {}, + "outputs": [], + "source": [ + "pp.mesh3d(\n", + " vertices=dg[\"vertices\"],\n", + " faces=dg[\"faces\"],\n", + " vertexcolors=dg[\"vertices\"].fields.z,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "6", + "metadata": {}, + "source": [ + "## Adding mesh edges\n", + "\n", + "It is also possible to show the edges of the mesh using the `edgecolor` argument:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7", + "metadata": {}, + "outputs": [], + "source": [ + "pp.mesh3d(\n", + " vertices=dg[\"vertices\"],\n", + " faces=dg[\"faces\"],\n", + " vertexcolors=dg[\"vertices\"].fields.z,\n", + " edgecolor=\"black\",\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "8", + "metadata": {}, + "source": [ + "## The data format\n", + "\n", + "The data used above contains a list of `vertices` (position vectors in 3d space),\n", + "and a list of `faces` which define how the vertices are connected to each other.\n", + "\n", + "The faces is a flat list of sequences of 3 indices that code for vertices which make up mesh triangles.\n", + "\n", + "As an example, we will construct a simple tetrahedric mesh." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9", + "metadata": {}, + "outputs": [], + "source": [ + "vertices = sc.vectors(\n", + " dims=[\"vertices\"],\n", + " values=[[-1, 0, 0], [0.7, 0, 1], [0.7, 0, -1], [0, 1.3, 0]],\n", + " unit=\"m\",\n", + ")\n", + "faces = sc.array(\n", + " dims=[\"faces\"],\n", + " values=[\n", + " # First triangle\n", + " 0, 1, 3,\n", + " # Second triangle\n", + " 1, 2, 3,\n", + " # Third triangle\n", + " 2, 0, 3,\n", + " # Fourth triangle\n", + " 0, 2, 1,\n", + " ],\n", + ")\n", + "\n", + "pp.mesh3d(\n", + " vertices=vertices,\n", + " faces=faces,\n", + " edgecolor=\"black\",\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "10", + "metadata": {}, + "source": [ + "You can then also add colors on the vertices:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "11", + "metadata": {}, + "outputs": [], + "source": [ + "pp.mesh3d(\n", + " vertices=vertices,\n", + " faces=faces,\n", + " vertexcolors=vertices.fields.x,\n", + " edgecolor=\"black\",\n", + ")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/pyproject.toml b/pyproject.toml index 8336104b..58e55dac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,12 +40,14 @@ scipp = ["scipp"] all = ["scipp", "ipympl", "pythreejs", "mpltoolbox", "ipywidgets", "graphviz"] test = [ "graphviz", + "h5py", "ipympl", "ipywidgets", "kaleido", "mpltoolbox", "pandas", "plotly", + "pooch", "pyarrow", "pytest", "pythreejs", diff --git a/requirements/basetest.in b/requirements/basetest.in index 9ca0e751..6f8cf881 100644 --- a/requirements/basetest.in +++ b/requirements/basetest.in @@ -8,12 +8,14 @@ # --- END OF CUSTOM SECTION --- # The following was generated by 'tox -e deps', DO NOT EDIT MANUALLY! graphviz +h5py ipympl ipywidgets kaleido mpltoolbox pandas plotly +pooch pyarrow pytest pythreejs diff --git a/requirements/basetest.txt b/requirements/basetest.txt index f26d2899..f99bf1dc 100644 --- a/requirements/basetest.txt +++ b/requirements/basetest.txt @@ -1,4 +1,4 @@ -# SHA1:a9d74e4dbe123f33cd42c83fee4629ff6ff9527c +# SHA1:c6f601e33fd3f41cccb2b6941696c30023119c3b # # This file is autogenerated by pip-compile-multi # To update, run: @@ -7,6 +7,10 @@ # asttokens==2.4.1 # via stack-data +certifi==2024.8.30 + # via requests +charset-normalizer==3.3.2 + # via requests comm==0.2.2 # via ipywidgets contourpy==1.3.0 @@ -25,6 +29,10 @@ fonttools==4.53.1 # via matplotlib graphviz==0.20.3 # via -r basetest.in +h5py==3.11.0 + # via -r basetest.in +idna==3.10 + # via requests iniconfig==2.0.0 # via pytest ipydatawidgets==4.3.5 @@ -62,6 +70,7 @@ mpltoolbox==24.5.1 numpy==2.1.1 # via # contourpy + # h5py # ipydatawidgets # ipympl # matplotlib @@ -76,6 +85,7 @@ packaging==24.1 # via # matplotlib # plotly + # pooch # pytest # xarray pandas==2.2.2 @@ -90,10 +100,14 @@ pillow==10.4.0 # via # ipympl # matplotlib -plotly==5.24.0 +platformdirs==4.3.6 + # via pooch +plotly==5.24.1 # via -r basetest.in pluggy==1.5.0 # via pytest +pooch==1.8.2 + # via -r basetest.in prompt-toolkit==3.0.47 # via ipython ptyprocess==0.7.0 @@ -106,7 +120,7 @@ pygments==2.18.0 # via ipython pyparsing==3.1.4 # via matplotlib -pytest==8.3.2 +pytest==8.3.3 # via -r basetest.in python-dateutil==2.9.0.post0 # via @@ -114,8 +128,10 @@ python-dateutil==2.9.0.post0 # pandas pythreejs==2.4.2 # via -r basetest.in -pytz==2024.1 +pytz==2024.2 # via pandas +requests==2.32.3 + # via pooch scipp==24.9.1 # via -r basetest.in scipy==1.14.1 @@ -145,9 +161,11 @@ typing-extensions==4.12.2 # via ipython tzdata==2024.1 # via pandas +urllib3==2.2.3 + # via requests wcwidth==0.2.13 # via prompt-toolkit widgetsnbextension==4.0.13 # via ipywidgets -xarray==2024.7.0 +xarray==2024.9.0 # via -r basetest.in diff --git a/requirements/ci.txt b/requirements/ci.txt index 07ac45f8..127345df 100644 --- a/requirements/ci.txt +++ b/requirements/ci.txt @@ -17,7 +17,7 @@ colorama==0.4.6 # via tox distlib==0.3.8 # via virtualenv -filelock==3.15.4 +filelock==3.16.1 # via # tox # virtualenv @@ -25,14 +25,14 @@ gitdb==4.0.11 # via gitpython gitpython==3.1.43 # via -r ci.in -idna==3.8 +idna==3.10 # via requests packaging==24.1 # via # -r ci.in # pyproject-api # tox -platformdirs==4.2.2 +platformdirs==4.3.6 # via # tox # virtualenv @@ -48,9 +48,9 @@ tomli==2.0.1 # via # pyproject-api # tox -tox==4.18.0 +tox==4.19.0 # via -r ci.in -urllib3==2.2.2 +urllib3==2.2.3 # via requests -virtualenv==20.26.3 +virtualenv==20.26.4 # via tox diff --git a/requirements/dev.txt b/requirements/dev.txt index a11bf6e7..0d38ff51 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -91,9 +91,9 @@ prometheus-client==0.20.0 # via jupyter-server pycparser==2.22 # via cffi -pydantic==2.8.2 +pydantic==2.9.2 # via copier -pydantic-core==2.20.1 +pydantic-core==2.23.4 # via pydantic python-json-logger==2.0.7 # via jupyter-events @@ -119,7 +119,7 @@ terminado==0.18.1 # jupyter-server-terminals toposort==1.10 # via pip-compile-multi -types-python-dateutil==2.9.0.20240821 +types-python-dateutil==2.9.0.20240906 # via arrow uri-template==1.3.0 # via jsonschema diff --git a/requirements/docs.in b/requirements/docs.in index 58b0e6d9..a0b4d5c5 100644 --- a/requirements/docs.in +++ b/requirements/docs.in @@ -1,12 +1,10 @@ -r base.in -r basetest.in gitpython -h5py ipykernel ipython!=8.7.0 # Breaks syntax highlighting in Jupyter code cells. myst-parser nbsphinx -pooch pydata-sphinx-theme>=0.14 requests sphinx diff --git a/requirements/docs.txt b/requirements/docs.txt index ea46edef..8d26fe84 100644 --- a/requirements/docs.txt +++ b/requirements/docs.txt @@ -1,4 +1,4 @@ -# SHA1:5aef0021b553749f4bbc89ce00a58c083e7b4e21 +# SHA1:306fdd5d8d28daa68e493fb60648c0436bc661b0 # # This file is autogenerated by pip-compile-multi # To update, run: @@ -25,10 +25,6 @@ beautifulsoup4==4.12.3 # pydata-sphinx-theme bleach==6.1.0 # via nbconvert -certifi==2024.8.30 - # via requests -charset-normalizer==3.3.2 - # via requests debugpy==1.8.5 # via ipykernel defusedxml==0.7.1 @@ -45,10 +41,6 @@ gitdb==4.0.11 # via gitpython gitpython==3.1.43 # via -r docs.in -h5py==3.11.0 - # via -r docs.in -idna==3.8 - # via requests imagesize==1.4.1 # via sphinx ipykernel==6.29.5 @@ -63,7 +55,7 @@ jsonschema==4.23.0 # via nbformat jsonschema-specifications==2023.12.1 # via jsonschema -jupyter-client==8.6.2 +jupyter-client==8.6.3 # via # ipykernel # nbclient @@ -84,7 +76,7 @@ markupsafe==2.1.5 # via # jinja2 # nbconvert -mdit-py-plugins==0.4.1 +mdit-py-plugins==0.4.2 # via myst-parser mdurl==0.1.2 # via markdown-it-py @@ -107,12 +99,6 @@ nest-asyncio==1.6.0 # via ipykernel pandocfilters==1.5.1 # via nbconvert -platformdirs==4.2.2 - # via - # jupyter-core - # pooch -pooch==1.8.2 - # via -r docs.in psutil==6.0.0 # via ipykernel pydata-sphinx-theme==0.15.4 @@ -127,11 +113,6 @@ referencing==0.35.1 # via # jsonschema # jsonschema-specifications -requests==2.32.3 - # via - # -r docs.in - # pooch - # sphinx rpds-py==0.20.0 # via # jsonschema @@ -152,7 +133,7 @@ sphinx==8.0.2 # sphinx-copybutton # sphinx-design # sphinx-gallery -sphinx-autodoc-typehints==2.3.0 +sphinx-autodoc-typehints==2.4.1 # via -r docs.in sphinx-copybutton==0.5.2 # via -r docs.in @@ -178,8 +159,6 @@ tornado==6.4.1 # via # ipykernel # jupyter-client -urllib3==2.2.2 - # via requests webencodings==0.5.1 # via # bleach diff --git a/requirements/static.txt b/requirements/static.txt index 85da246d..1c0e7084 100644 --- a/requirements/static.txt +++ b/requirements/static.txt @@ -9,17 +9,17 @@ cfgv==3.4.0 # via pre-commit distlib==0.3.8 # via virtualenv -filelock==3.15.4 +filelock==3.16.1 # via virtualenv -identify==2.6.0 +identify==2.6.1 # via pre-commit nodeenv==1.9.1 # via pre-commit -platformdirs==4.2.2 +platformdirs==4.3.6 # via virtualenv pre-commit==3.8.0 # via -r static.in pyyaml==6.0.2 # via pre-commit -virtualenv==20.26.3 +virtualenv==20.26.4 # via pre-commit diff --git a/requirements/wheels.txt b/requirements/wheels.txt index a1fa46e2..24447442 100644 --- a/requirements/wheels.txt +++ b/requirements/wheels.txt @@ -5,7 +5,7 @@ # # pip-compile-multi # -build==1.2.1 +build==1.2.2 # via -r wheels.in packaging==24.1 # via build diff --git a/src/plopp/__init__.py b/src/plopp/__init__.py index 390953e5..51708ec8 100644 --- a/src/plopp/__init__.py +++ b/src/plopp/__init__.py @@ -23,7 +23,16 @@ scatterfigure, tiled, ) -from .plotting import inspector, plot, scatter, scatter3d, slicer, superplot, xyplot +from .plotting import ( + inspector, + mesh3d, + plot, + scatter, + scatter3d, + slicer, + superplot, + xyplot, +) del importlib @@ -50,6 +59,7 @@ def show() -> None: 'inspector', 'linefigure', 'node', + 'mesh3d', 'plot', 'scatter', 'scatterfigure', diff --git a/src/plopp/backends/pythreejs/mesh3d.py b/src/plopp/backends/pythreejs/mesh3d.py new file mode 100644 index 00000000..7f1c04eb --- /dev/null +++ b/src/plopp/backends/pythreejs/mesh3d.py @@ -0,0 +1,184 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2024 Scipp contributors (https://github.com/scipp) + +import uuid +from typing import Literal + +import numpy as np +import scipp as sc +from matplotlib.colors import to_rgb + +from ...core.limits import find_limits +from ...graphics.bbox import BoundingBox +from .canvas import Canvas + + +class Mesh3d: + """ + Artist to represent a three-dimensional mesh. + + Parameters + ---------- + canvas: + The canvas to draw the mesh on. + data: + The initial data to create the mesh from. Must be a DataGroup that contains at + least the following fields: + + - vertices: a DataArray with the vertices of the mesh. + - faces: a DataArray with the faces of the mesh. + color: + The color of the mesh. If None, the mesh will be colored according to the + artist number. + opacity: + The opacity of the mesh. + edgecolor: + The color of the edges of the mesh. If None, the edges will not be shown. + artist_number: + The number of the artist. This is used to determine the color of the mesh if + `color` is None. + """ + + def __init__( + self, + *, + canvas: Canvas, + data: sc.DataArray, + color: str | None = None, + opacity: float = 1, + edgecolor: str | None = None, + artist_number: int = 0, + ): + import pythreejs as p3 + + self._data = data + self._canvas = canvas + self._artist_number = artist_number + self._id = uuid.uuid4().hex + + # Note: index *must* be unsigned! + index = p3.BufferAttribute( + array=self._data.coords["faces"] + .value.values.flatten() + .astype('uint32', copy=False) + ) + + pos = ( + self._data.coords["vertices"].values.astype('float32') + if 'vertices' in self._data.coords + else np.array( + [ + self._data.coords["x"].values.astype('float32', copy=False), + self._data.coords["y"].values.astype('float32', copy=False), + self._data.coords["z"].values.astype('float32', copy=False), + ] + ).T + ) + attributes = { + 'position': p3.BufferAttribute(array=pos), + 'color': p3.BufferAttribute( + array=np.broadcast_to( + np.array(to_rgb(f'C{artist_number}' if color is None else color)), + (self._data.coords["x"].shape[0], 3), + ).astype('float32') + ), + } + + self.geometry = p3.BufferGeometry(index=index, attributes=attributes) + self.material = p3.MeshBasicMaterial( + vertexColors='VertexColors', + transparent=True, + side='DoubleSide', + opacity=opacity, + depthTest=opacity > 0.5, + ) + self.mesh = p3.Mesh(geometry=self.geometry, material=self.material) + self.edges = ( + p3.LineSegments( + p3.EdgesGeometry(self.geometry), + p3.LineBasicMaterial( + color=edgecolor or 'black', + linewidth=2, + opacity=opacity, + transparent=True, + ), + ) + if edgecolor is not None + else None + ) + self._canvas.add(self.mesh) + if self.edges is not None: + self._canvas.add(self.edges) + + def set_colors(self, rgba): + """ + Set the mesh's rgba colors: + + Parameters + ---------- + rgba: + The array of rgba colors. + """ + self.geometry.attributes["color"].array = rgba[..., :3].astype( + 'float32', copy=False + ) + + def update(self, new_values): + """ + Update mesh array with new values. + + Parameters + ---------- + new_values: + New data to update the mesh values from. + """ + self._data = new_values + # TODO: for now we only update the data values of the artist. + # Updating the positions of the vertices is doable but is made more complicated + # by the edges geometry, whose positions cannot just be updated. + # A new geometry and edge lines would have to be created, the old one removed + # from the scene and the new one added. + + def bbox( + self, + xscale: Literal['linear', 'log'], + yscale: Literal['linear', 'log'], + zscale: Literal['linear', 'log'], + ) -> BoundingBox: + """ + The bounding box of the mesh. + """ + coords = self._data.coords + xbounds = find_limits(coords['x'], scale=xscale) + ybounds = find_limits(coords['y'], scale=yscale) + zbounds = find_limits(coords['z'], scale=zscale) + return BoundingBox( + xmin=xbounds[0].value, + xmax=xbounds[1].value, + ymin=ybounds[0].value, + ymax=ybounds[1].value, + zmin=zbounds[0].value, + zmax=zbounds[1].value, + ) + + @property + def opacity(self): + """ + Get the material opacity. + """ + return self.material.opacity + + @opacity.setter + def opacity(self, val): + """ + Set the material opacity. + """ + self.material.opacity = val + self.material.depthTest = val > 0.5 + + @property + def data(self): + """ + Get the mesh data. + """ + return self._data diff --git a/src/plopp/backends/pythreejs/scatter3d.py b/src/plopp/backends/pythreejs/scatter3d.py index aa0f4033..eae6d6be 100644 --- a/src/plopp/backends/pythreejs/scatter3d.py +++ b/src/plopp/backends/pythreejs/scatter3d.py @@ -137,23 +137,6 @@ def update(self, new_values): check_ndim(new_values, ndim=1, origin='Scatter3d') self._data = new_values - def get_limits(self) -> tuple[sc.Variable, sc.Variable, sc.Variable]: - """ - Get the spatial extent of all the points in the cloud. - """ - xcoord = self._data.coords[self._x] - ycoord = self._data.coords[self._y] - zcoord = self._data.coords[self._z] - half_pixel = 0.5 * self._size - dx = sc.scalar(half_pixel, unit=xcoord.unit) - dy = sc.scalar(half_pixel, unit=ycoord.unit) - dz = sc.scalar(half_pixel, unit=zcoord.unit) - return ( - sc.concat([xcoord.min() - dx, xcoord.max() + dx], dim=self._x), - sc.concat([ycoord.min() - dy, ycoord.max() + dy], dim=self._y), - sc.concat([zcoord.min() - dz, zcoord.max() + dz], dim=self._z), - ) - @property def opacity(self): """ diff --git a/src/plopp/data/examples.py b/src/plopp/data/examples.py index a143cf1c..e46979b9 100644 --- a/src/plopp/data/examples.py +++ b/src/plopp/data/examples.py @@ -18,7 +18,10 @@ def _make_pooch(): env='PLOPP_DATA_DIR', base_url='https://public.esss.dk/groups/scipp/plopp/{version}/', version=_version, - registry={'nyc_taxi_data.h5': 'md5:fc0867ec061e4ac0cbe5975a665a0eea'}, + registry={ + 'nyc_taxi_data.h5': 'md5:fc0867ec061e4ac0cbe5975a665a0eea', + 'teapot.h5': 'md5:012994ffc56f520589b921c2ce655c19', + }, ) @@ -49,6 +52,19 @@ def nyc_taxi() -> str: return get_path('nyc_taxi_data.h5') +def teapot() -> str: + """ + Values extracted from the Utah teapot: + https://graphics.cs.utah.edu/courses/cs6620/fall2013/?prj=5 + using PyWavefront https://pypi.org/project/PyWavefront/ + >> import pywavefront + >> scene = pywavefront.Wavefront('path/to/teapot-low.obj', collect_faces=True) + >> vertices = scene.vertices + >> faces = scene.meshes[None].faces + """ + return get_path('teapot.h5') + + def three_bands(npeaks=200, per_peak=500, spread=30.0): """ Generate a 2D dataset with three bands of peaks. diff --git a/src/plopp/graphics/__init__.py b/src/plopp/graphics/__init__.py index d1494a99..57be986b 100644 --- a/src/plopp/graphics/__init__.py +++ b/src/plopp/graphics/__init__.py @@ -5,7 +5,13 @@ from .bbox import BoundingBox from .camera import Camera from .colormapper import ColorMapper -from .figures import linefigure, imagefigure, scatterfigure, scatter3dfigure +from .figures import ( + linefigure, + imagefigure, + mesh3dfigure, + scatterfigure, + scatter3dfigure, +) from .graphicalview import GraphicalView from .tiled import tiled @@ -18,6 +24,7 @@ 'GraphicalView', 'imagefigure', 'linefigure', + 'mesh3dfigure', 'scatter3dfigure', 'scatterfigure', 'tiled', diff --git a/src/plopp/graphics/figures.py b/src/plopp/graphics/figures.py index 22f8dbe0..b0ac0cb2 100644 --- a/src/plopp/graphics/figures.py +++ b/src/plopp/graphics/figures.py @@ -72,3 +72,17 @@ def scatter3dfigure( return backends.get(group='3d', name='figure')( view_maker, *nodes, x=x, y=y, z=z, cbar=cbar, **kwargs ) + + +def mesh3dfigure(*nodes: Node, vertexcolors, **kwargs) -> FigureLike: + colormapper = vertexcolors is not None + view_maker = partial( + GraphicalView, + dims={'x': 'x', 'y': 'y', 'z': 'z'}, + canvas_maker=backends.get(group='3d', name='canvas'), + artist_maker=backends.get(group='3d', name='mesh3d'), + colormapper=colormapper, + ) + return backends.get(group='3d', name='figure')( + view_maker, *nodes, cbar=colormapper, **kwargs + ) diff --git a/src/plopp/plotting/__init__.py b/src/plopp/plotting/__init__.py index b6e0c1f6..70a4a728 100644 --- a/src/plopp/plotting/__init__.py +++ b/src/plopp/plotting/__init__.py @@ -2,6 +2,7 @@ # Copyright (c) 2023 Scipp contributors (https://github.com/scipp) from .inspector import inspector +from .mesh3d import mesh3d from .plot import plot from .scatter import scatter from .scatter3d import scatter3d @@ -11,6 +12,7 @@ __all__ = [ 'inspector', + 'mesh3d', 'plot', 'scatter', 'scatter3d', diff --git a/src/plopp/plotting/mesh3d.py b/src/plopp/plotting/mesh3d.py new file mode 100644 index 00000000..98abb76d --- /dev/null +++ b/src/plopp/plotting/mesh3d.py @@ -0,0 +1,108 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2024 Scipp contributors (https://github.com/scipp) + +from typing import Literal + +import scipp as sc + +from ..core import Node +from ..core.typing import FigureLike, Plottable +from ..graphics import Camera +from .common import _maybe_to_variable + + +def _preprocess_mesh( + vertices: Plottable, + faces: Plottable, + vertexcolors: Plottable | None = None, +) -> sc.DataArray: + vertices, faces, vertexcolors = ( + _maybe_to_variable(data) if data is not None else None + for data in (vertices, faces, vertexcolors) + ) + + if vertices.dtype != sc.DType.vector3: + raise ValueError("Vertices must be of dtype vector3.") + out = sc.DataArray( + data=sc.broadcast(sc.empty(sizes={}), sizes=vertices.sizes), + coords={ + 'x': vertices.fields.x, + 'y': vertices.fields.y, + 'z': vertices.fields.z, + 'vertices': vertices, + 'faces': sc.scalar(faces), + }, + ) + if vertexcolors is not None: + out.data = vertexcolors + return out + + +def mesh3d( + vertices: Plottable, + faces: Plottable, + vertexcolors: Plottable | None = None, + edgecolor: str | None = None, + figsize: tuple[int, int] = (600, 400), + norm: Literal['linear', 'log'] = 'linear', + title: str | None = None, + vmin: sc.Variable | float = None, + vmax: sc.Variable | float = None, + cmap: str = 'viridis', + camera: Camera | None = None, + **kwargs, +) -> FigureLike: + """ + Create a 3D mesh plot. + + .. versionadded:: 24.10.0 + + Parameters + ---------- + vertices: + The vertices of the mesh. Must be a variable of dtype vector3. + faces: + The indices that construct the faces of the mesh. + vertexcolors: + The colors of the vertices of the mesh. If ``None``, the mesh will have a + single solid color. + edgecolor: + The color of the edges. If None, no edges are drawn. + figsize: + The size of the figure. + norm: + The normalization of the colormap. + title: + The title of the figure. + vmin: + The minimum value of the colormap. + vmax: + The maximum value of the colormap. + cmap: + The colormap to use. + camera: + The camera configuration. + """ + from ..graphics import mesh3dfigure + + input_node = Node( + _preprocess_mesh, + vertices=vertices, + faces=faces, + vertexcolors=vertexcolors, + ) + + fig = mesh3dfigure( + input_node, + vertexcolors=vertexcolors, + edgecolor=edgecolor, + figsize=figsize, + norm=norm, + title=title, + vmin=vmin, + vmax=vmax, + cmap=cmap, + camera=camera, + **kwargs, + ) + return fig diff --git a/tests/plotting/mesh3d_test.py b/tests/plotting/mesh3d_test.py new file mode 100644 index 00000000..9c7aa585 --- /dev/null +++ b/tests/plotting/mesh3d_test.py @@ -0,0 +1,65 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2024 Scipp contributors (https://github.com/scipp) + +import numpy as np +import pytest +import scipp as sc + +import plopp as pp +from plopp.data import examples + + +@pytest.fixture +def teapot_data(): + return sc.io.load_hdf5(examples.teapot()) + + +def test_mesh3d_default(teapot_data): + pp.mesh3d( + vertices=teapot_data["vertices"], + faces=teapot_data["faces"], + ) + + +def test_mesh3d_solid_color(teapot_data): + fig = pp.mesh3d( + vertices=teapot_data["vertices"], faces=teapot_data["faces"], color='red' + ) + (mesh,) = fig.artists.values() + assert np.array_equal(mesh.geometry.attributes["color"].array[0, :], (1, 0, 0)) + + +def test_mesh3d_vertexcolors(teapot_data): + z = teapot_data["vertices"].fields.z + fig = pp.mesh3d( + vertices=teapot_data["vertices"], + faces=teapot_data["faces"], + vertexcolors=z, + ) + assert fig.view.colormapper is not None + (mesh,) = fig.artists.values() + colors = mesh.geometry.attributes["color"].array + imin = np.argmin(z.values) + imax = np.argmax(z.values) + assert not np.array_equal(colors[imin, :], colors[imax, :]) + + +def test_mesh3d_edgecolor(teapot_data): + fig = pp.mesh3d( + vertices=teapot_data["vertices"], + faces=teapot_data["faces"], + vertexcolors=teapot_data["vertices"].fields.z, + edgecolor='blue', + ) + (mesh,) = fig.artists.values() + assert mesh.edges.material.color == 'blue' + + +def test_mesh3d_cmap(teapot_data): + fig = pp.mesh3d( + vertices=teapot_data["vertices"], + faces=teapot_data["faces"], + vertexcolors=teapot_data["vertices"].fields.z, + cmap='magma', + ) + assert fig.view.colormapper.cmap.name == 'magma'