diff --git a/docs/source/api/graphic_features/MeshCmap.rst b/docs/source/api/graphic_features/MeshCmap.rst new file mode 100644 index 000000000..865ac13d9 --- /dev/null +++ b/docs/source/api/graphic_features/MeshCmap.rst @@ -0,0 +1,35 @@ +.. _api.MeshCmap: + +MeshCmap +******** + +======== +MeshCmap +======== +.. currentmodule:: fastplotlib.graphics.features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: MeshCmap_api + + MeshCmap + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: MeshCmap_api + + MeshCmap.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: MeshCmap_api + + MeshCmap.add_event_handler + MeshCmap.block_events + MeshCmap.clear_event_handlers + MeshCmap.remove_event_handler + MeshCmap.set_value + diff --git a/docs/source/api/graphic_features/MeshIndices.rst b/docs/source/api/graphic_features/MeshIndices.rst new file mode 100644 index 000000000..6005ca0c0 --- /dev/null +++ b/docs/source/api/graphic_features/MeshIndices.rst @@ -0,0 +1,36 @@ +.. _api.MeshIndices: + +MeshIndices +*********** + +=========== +MeshIndices +=========== +.. currentmodule:: fastplotlib.graphics.features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: MeshIndices_api + + MeshIndices + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: MeshIndices_api + + MeshIndices.buffer + MeshIndices.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: MeshIndices_api + + MeshIndices.add_event_handler + MeshIndices.block_events + MeshIndices.clear_event_handlers + MeshIndices.remove_event_handler + MeshIndices.set_value + diff --git a/docs/source/api/graphic_features/SurfaceData.rst b/docs/source/api/graphic_features/SurfaceData.rst new file mode 100644 index 000000000..87828d226 --- /dev/null +++ b/docs/source/api/graphic_features/SurfaceData.rst @@ -0,0 +1,35 @@ +.. _api.SurfaceData: + +SurfaceData +*********** + +=========== +SurfaceData +=========== +.. currentmodule:: fastplotlib.graphics.features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: SurfaceData_api + + SurfaceData + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: SurfaceData_api + + SurfaceData.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: SurfaceData_api + + SurfaceData.add_event_handler + SurfaceData.block_events + SurfaceData.clear_event_handlers + SurfaceData.remove_event_handler + SurfaceData.set_value + diff --git a/docs/source/api/graphic_features/index.rst b/docs/source/api/graphic_features/index.rst index 5c5c2b464..cd11544be 100644 --- a/docs/source/api/graphic_features/index.rst +++ b/docs/source/api/graphic_features/index.rst @@ -9,6 +9,9 @@ Graphic Features SizeSpace VertexPositions VertexCmap + MeshIndices + MeshCmap + SurfaceData Thickness VertexMarkers UniformMarker diff --git a/docs/source/api/graphics/MeshGraphic.rst b/docs/source/api/graphics/MeshGraphic.rst new file mode 100644 index 000000000..5e2c5dac5 --- /dev/null +++ b/docs/source/api/graphics/MeshGraphic.rst @@ -0,0 +1,55 @@ +.. _api.MeshGraphic: + +MeshGraphic +*********** + +=========== +MeshGraphic +=========== +.. currentmodule:: fastplotlib + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: MeshGraphic_api + + MeshGraphic + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: MeshGraphic_api + + MeshGraphic.alpha + MeshGraphic.alpha_mode + MeshGraphic.axes + MeshGraphic.block_events + MeshGraphic.clim + MeshGraphic.cmap + MeshGraphic.colors + MeshGraphic.deleted + MeshGraphic.event_handlers + MeshGraphic.indices + MeshGraphic.mapcoords + MeshGraphic.mode + MeshGraphic.name + MeshGraphic.offset + MeshGraphic.plane + MeshGraphic.positions + MeshGraphic.right_click_menu + MeshGraphic.rotation + MeshGraphic.supported_events + MeshGraphic.visible + MeshGraphic.world_object + +Methods +~~~~~~~ +.. autosummary:: + :toctree: MeshGraphic_api + + MeshGraphic.add_axes + MeshGraphic.add_event_handler + MeshGraphic.clear_event_handlers + MeshGraphic.remove_event_handler + MeshGraphic.rotate + diff --git a/docs/source/api/graphics/PolygonGraphic.rst b/docs/source/api/graphics/PolygonGraphic.rst new file mode 100644 index 000000000..f9446f425 --- /dev/null +++ b/docs/source/api/graphics/PolygonGraphic.rst @@ -0,0 +1,56 @@ +.. _api.PolygonGraphic: + +PolygonGraphic +************** + +============== +PolygonGraphic +============== +.. currentmodule:: fastplotlib + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: PolygonGraphic_api + + PolygonGraphic + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: PolygonGraphic_api + + PolygonGraphic.alpha + PolygonGraphic.alpha_mode + PolygonGraphic.axes + PolygonGraphic.block_events + PolygonGraphic.clim + PolygonGraphic.cmap + PolygonGraphic.colors + PolygonGraphic.data + PolygonGraphic.deleted + PolygonGraphic.event_handlers + PolygonGraphic.indices + PolygonGraphic.mapcoords + PolygonGraphic.mode + PolygonGraphic.name + PolygonGraphic.offset + PolygonGraphic.plane + PolygonGraphic.positions + PolygonGraphic.right_click_menu + PolygonGraphic.rotation + PolygonGraphic.supported_events + PolygonGraphic.visible + PolygonGraphic.world_object + +Methods +~~~~~~~ +.. autosummary:: + :toctree: PolygonGraphic_api + + PolygonGraphic.add_axes + PolygonGraphic.add_event_handler + PolygonGraphic.clear_event_handlers + PolygonGraphic.remove_event_handler + PolygonGraphic.rotate + diff --git a/docs/source/api/graphics/SurfaceGraphic.rst b/docs/source/api/graphics/SurfaceGraphic.rst new file mode 100644 index 000000000..385ce2432 --- /dev/null +++ b/docs/source/api/graphics/SurfaceGraphic.rst @@ -0,0 +1,56 @@ +.. _api.SurfaceGraphic: + +SurfaceGraphic +************** + +============== +SurfaceGraphic +============== +.. currentmodule:: fastplotlib + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: SurfaceGraphic_api + + SurfaceGraphic + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: SurfaceGraphic_api + + SurfaceGraphic.alpha + SurfaceGraphic.alpha_mode + SurfaceGraphic.axes + SurfaceGraphic.block_events + SurfaceGraphic.clim + SurfaceGraphic.cmap + SurfaceGraphic.colors + SurfaceGraphic.data + SurfaceGraphic.deleted + SurfaceGraphic.event_handlers + SurfaceGraphic.indices + SurfaceGraphic.mapcoords + SurfaceGraphic.mode + SurfaceGraphic.name + SurfaceGraphic.offset + SurfaceGraphic.plane + SurfaceGraphic.positions + SurfaceGraphic.right_click_menu + SurfaceGraphic.rotation + SurfaceGraphic.supported_events + SurfaceGraphic.visible + SurfaceGraphic.world_object + +Methods +~~~~~~~ +.. autosummary:: + :toctree: SurfaceGraphic_api + + SurfaceGraphic.add_axes + SurfaceGraphic.add_event_handler + SurfaceGraphic.clear_event_handlers + SurfaceGraphic.remove_event_handler + SurfaceGraphic.rotate + diff --git a/docs/source/api/graphics/index.rst b/docs/source/api/graphics/index.rst index ac47a7dfd..bac85e6c1 100644 --- a/docs/source/api/graphics/index.rst +++ b/docs/source/api/graphics/index.rst @@ -10,6 +10,9 @@ Graphics ImageGraphic ImageVolumeGraphic VectorsGraphic + MeshGraphic + SurfaceGraphic + PolygonGraphic TextGraphic LineCollection LineStack diff --git a/docs/source/api/layouts/subplot.rst b/docs/source/api/layouts/subplot.rst index 4e40e8d08..93db00a2e 100644 --- a/docs/source/api/layouts/subplot.rst +++ b/docs/source/api/layouts/subplot.rst @@ -20,12 +20,14 @@ Properties .. autosummary:: :toctree: Subplot_api + Subplot.ambient_light Subplot.animations Subplot.axes Subplot.background_color Subplot.camera Subplot.canvas Subplot.controller + Subplot.directional_light Subplot.docks Subplot.frame Subplot.graphics @@ -52,7 +54,10 @@ Methods Subplot.add_line Subplot.add_line_collection Subplot.add_line_stack + Subplot.add_mesh + Subplot.add_polygon Subplot.add_scatter + Subplot.add_surface Subplot.add_text Subplot.add_vectors Subplot.auto_scale diff --git a/docs/source/conf.py b/docs/source/conf.py index 74a1fbaf9..8547e9ae7 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -10,15 +10,12 @@ os.environ["WGPU_FORCE_OFFSCREEN"] = "1" import fastplotlib -import pygfx from pygfx.utils.gallery_scraper import find_examples_for_gallery from pathlib import Path import sys from sphinx_gallery.sorting import ExplicitOrder import imageio.v3 as iio -MAX_TEXTURE_SIZE = 2048 -pygfx.renderers.wgpu.set_wgpu_limits(**{"max-texture-dimension-2d": MAX_TEXTURE_SIZE}) ROOT_DIR = Path(__file__).parents[1].parents[0] # repo root EXAMPLES_DIR = Path.joinpath(ROOT_DIR, "examples") @@ -44,7 +41,7 @@ "sphinx.ext.viewcode", "sphinx_copybutton", "sphinx_design", - "sphinx_gallery.gen_gallery" + "sphinx_gallery.gen_gallery", ] sphinx_gallery_conf = { @@ -65,6 +62,7 @@ "../../examples/controllers", "../../examples/line", "../../examples/line_collection", + "../../examples/mesh", "../../examples/scatter", "../../examples/vectors", "../../examples/text", @@ -77,9 +75,9 @@ "../../examples/qt", ] ), - "ignore_pattern": r'__init__\.py', + "ignore_pattern": r"__init__\.py", "nested_sections": False, - "thumbnail_size": (250, 250) + "thumbnail_size": (250, 250), } extra_conf = find_examples_for_gallery(EXAMPLES_DIR) @@ -107,7 +105,7 @@ "check_switcher": True, "switcher": { "json_url": "http://www.fastplotlib.org/_static/switcher.json", - "version_match": release + "version_match": release, }, "icon_links": [ { @@ -115,7 +113,7 @@ "url": "https://github.com/fastplotlib/fastplotlib", "icon": "fa-brands fa-github", } - ] + ], } html_static_path = ["_static"] diff --git a/docs/source/user_guide/event_tables.rst b/docs/source/user_guide/event_tables.rst index 8e942830e..ba53c3411 100644 --- a/docs/source/user_guide/event_tables.rst +++ b/docs/source/user_guide/event_tables.rst @@ -897,6 +897,405 @@ deleted | value | bool | True when graphic was deleted | +----------+------+-------------------------------+ +MeshGraphic +----------- + +positions +^^^^^^^^^ + +**event info dict** + ++----------+----------------------------------------------+--------------------------------------------------------+ +| dict key | type | description | ++==========+==============================================+========================================================+ +| key | slice, index (int) or numpy-like fancy index | key at which vertex positions data were indexed/sliced | ++----------+----------------------------------------------+--------------------------------------------------------+ +| value | int | float | array-like | new data values for points that were changed | ++----------+----------------------------------------------+--------------------------------------------------------+ + +indices +^^^^^^^ + +**event info dict** + ++----------+----------------------------------------------+-------------------------------------------------+ +| dict key | type | description | ++==========+==============================================+=================================================+ +| key | slice, index (int) or numpy-like fancy index | key at which vertex indices were indexed/sliced | ++----------+----------------------------------------------+-------------------------------------------------+ +| value | int | float | array-like | new data values for indices that were changed | ++----------+----------------------------------------------+-------------------------------------------------+ + +colors +^^^^^^ + +**event info dict** + ++------------+--------------------------------------+------------------------------------------------------+ +| dict key | type | description | ++============+======================================+======================================================+ +| key | slice, index, numpy-like fancy index | index/slice at which colors were indexed/sliced | ++------------+--------------------------------------+------------------------------------------------------+ +| value | np.ndarray [n_points_changed, RGBA] | new color values for points that were changed | ++------------+--------------------------------------+------------------------------------------------------+ +| user_value | str or array-like | user input value that was parsed into the RGBA array | ++------------+--------------------------------------+------------------------------------------------------+ + +colors +^^^^^^ + +**event info dict** + ++----------+--------------------------------------------------+-----------------+ +| dict key | type | description | ++==========+==================================================+=================+ +| value | str | pygfx.Color | np.ndarray | Sequence[float] | new color value | ++----------+--------------------------------------------------+-----------------+ + +cmap +^^^^ + +**event info dict** + ++----------+------------------------------------------------------------+-------------+ +| dict key | type | description | ++==========+============================================================+=============+ +| value | str | dict | pygfx.TextureMap | pygfx.Texture | np.ndarray | new cmap | ++----------+------------------------------------------------------------+-------------+ + +name +^^^^ + +**event info dict** + ++----------+------+--------------------+ +| dict key | type | description | ++==========+======+====================+ +| value | str | user provided name | ++----------+------+--------------------+ + +offset +^^^^^^ + +**event info dict** + ++----------+---------------------------------+----------------------+ +| dict key | type | description | ++==========+=================================+======================+ +| value | np.ndarray[float, float, float] | new offset (x, y, z) | ++----------+---------------------------------+----------------------+ + +rotation +^^^^^^^^ + +**event info dict** + ++----------+----------------------------------------+-------------------------+ +| dict key | type | description | ++==========+========================================+=========================+ +| value | np.ndarray[float, float, float, float] | new rotation quaternion | ++----------+----------------------------------------+-------------------------+ + +alpha +^^^^^ + +**event info dict** + ++----------+-------+-----------------+ +| dict key | type | description | ++==========+=======+=================+ +| value | float | new alpha value | ++----------+-------+-----------------+ + +alpha_mode +^^^^^^^^^^ + +**event info dict** + ++----------+------+----------------+ +| dict key | type | description | ++==========+======+================+ +| value | str | new alpha mode | ++----------+------+----------------+ + +visible +^^^^^^^ + +**event info dict** + ++----------+------+---------------------+ +| dict key | type | description | ++==========+======+=====================+ +| value | bool | new visibility bool | ++----------+------+---------------------+ + +deleted +^^^^^^^ + +**event info dict** + ++----------+------+-------------------------------+ +| dict key | type | description | ++==========+======+===============================+ +| value | bool | True when graphic was deleted | ++----------+------+-------------------------------+ + +SurfaceGraphic +-------------- + +data +^^^^ + +**event info dict** + ++----------+------------+------------------+ +| dict key | type | description | ++==========+============+==================+ +| value | np.ndarray | new surface data | ++----------+------------+------------------+ + +colors +^^^^^^ + +**event info dict** + ++------------+--------------------------------------+------------------------------------------------------+ +| dict key | type | description | ++============+======================================+======================================================+ +| key | slice, index, numpy-like fancy index | index/slice at which colors were indexed/sliced | ++------------+--------------------------------------+------------------------------------------------------+ +| value | np.ndarray [n_points_changed, RGBA] | new color values for points that were changed | ++------------+--------------------------------------+------------------------------------------------------+ +| user_value | str or array-like | user input value that was parsed into the RGBA array | ++------------+--------------------------------------+------------------------------------------------------+ + +colors +^^^^^^ + +**event info dict** + ++----------+--------------------------------------------------+-----------------+ +| dict key | type | description | ++==========+==================================================+=================+ +| value | str | pygfx.Color | np.ndarray | Sequence[float] | new color value | ++----------+--------------------------------------------------+-----------------+ + +cmap +^^^^ + +**event info dict** + ++----------+------------------------------------------------------------+-------------+ +| dict key | type | description | ++==========+============================================================+=============+ +| value | str | dict | pygfx.TextureMap | pygfx.Texture | np.ndarray | new cmap | ++----------+------------------------------------------------------------+-------------+ + +name +^^^^ + +**event info dict** + ++----------+------+--------------------+ +| dict key | type | description | ++==========+======+====================+ +| value | str | user provided name | ++----------+------+--------------------+ + +offset +^^^^^^ + +**event info dict** + ++----------+---------------------------------+----------------------+ +| dict key | type | description | ++==========+=================================+======================+ +| value | np.ndarray[float, float, float] | new offset (x, y, z) | ++----------+---------------------------------+----------------------+ + +rotation +^^^^^^^^ + +**event info dict** + ++----------+----------------------------------------+-------------------------+ +| dict key | type | description | ++==========+========================================+=========================+ +| value | np.ndarray[float, float, float, float] | new rotation quaternion | ++----------+----------------------------------------+-------------------------+ + +alpha +^^^^^ + +**event info dict** + ++----------+-------+-----------------+ +| dict key | type | description | ++==========+=======+=================+ +| value | float | new alpha value | ++----------+-------+-----------------+ + +alpha_mode +^^^^^^^^^^ + +**event info dict** + ++----------+------+----------------+ +| dict key | type | description | ++==========+======+================+ +| value | str | new alpha mode | ++----------+------+----------------+ + +visible +^^^^^^^ + +**event info dict** + ++----------+------+---------------------+ +| dict key | type | description | ++==========+======+=====================+ +| value | bool | new visibility bool | ++----------+------+---------------------+ + +deleted +^^^^^^^ + +**event info dict** + ++----------+------+-------------------------------+ +| dict key | type | description | ++==========+======+===============================+ +| value | bool | True when graphic was deleted | ++----------+------+-------------------------------+ + +PolygonGraphic +-------------- + +data +^^^^ + +**event info dict** + ++----------+------------+------------------+ +| dict key | type | description | ++==========+============+==================+ +| value | np.ndarray | new surface data | ++----------+------------+------------------+ + +colors +^^^^^^ + +**event info dict** + ++------------+--------------------------------------+------------------------------------------------------+ +| dict key | type | description | ++============+======================================+======================================================+ +| key | slice, index, numpy-like fancy index | index/slice at which colors were indexed/sliced | ++------------+--------------------------------------+------------------------------------------------------+ +| value | np.ndarray [n_points_changed, RGBA] | new color values for points that were changed | ++------------+--------------------------------------+------------------------------------------------------+ +| user_value | str or array-like | user input value that was parsed into the RGBA array | ++------------+--------------------------------------+------------------------------------------------------+ + +colors +^^^^^^ + +**event info dict** + ++----------+--------------------------------------------------+-----------------+ +| dict key | type | description | ++==========+==================================================+=================+ +| value | str | pygfx.Color | np.ndarray | Sequence[float] | new color value | ++----------+--------------------------------------------------+-----------------+ + +cmap +^^^^ + +**event info dict** + ++----------+------------------------------------------------------------+-------------+ +| dict key | type | description | ++==========+============================================================+=============+ +| value | str | dict | pygfx.TextureMap | pygfx.Texture | np.ndarray | new cmap | ++----------+------------------------------------------------------------+-------------+ + +name +^^^^ + +**event info dict** + ++----------+------+--------------------+ +| dict key | type | description | ++==========+======+====================+ +| value | str | user provided name | ++----------+------+--------------------+ + +offset +^^^^^^ + +**event info dict** + ++----------+---------------------------------+----------------------+ +| dict key | type | description | ++==========+=================================+======================+ +| value | np.ndarray[float, float, float] | new offset (x, y, z) | ++----------+---------------------------------+----------------------+ + +rotation +^^^^^^^^ + +**event info dict** + ++----------+----------------------------------------+-------------------------+ +| dict key | type | description | ++==========+========================================+=========================+ +| value | np.ndarray[float, float, float, float] | new rotation quaternion | ++----------+----------------------------------------+-------------------------+ + +alpha +^^^^^ + +**event info dict** + ++----------+-------+-----------------+ +| dict key | type | description | ++==========+=======+=================+ +| value | float | new alpha value | ++----------+-------+-----------------+ + +alpha_mode +^^^^^^^^^^ + +**event info dict** + ++----------+------+----------------+ +| dict key | type | description | ++==========+======+================+ +| value | str | new alpha mode | ++----------+------+----------------+ + +visible +^^^^^^^ + +**event info dict** + ++----------+------+---------------------+ +| dict key | type | description | ++==========+======+=====================+ +| value | bool | new visibility bool | ++----------+------+---------------------+ + +deleted +^^^^^^^ + +**event info dict** + ++----------+------+-------------------------------+ +| dict key | type | description | ++==========+======+===============================+ +| value | bool | True when graphic was deleted | ++----------+------+-------------------------------+ + TextGraphic ----------- diff --git a/examples/mesh/README.rst b/examples/mesh/README.rst new file mode 100644 index 000000000..99e569fed --- /dev/null +++ b/examples/mesh/README.rst @@ -0,0 +1,2 @@ +Mesh Examples +============= diff --git a/examples/mesh/image_surface.py b/examples/mesh/image_surface.py new file mode 100644 index 000000000..fce3c4958 --- /dev/null +++ b/examples/mesh/image_surface.py @@ -0,0 +1,37 @@ +""" +Image surface +============= + +Example showing an image as a surface. +""" + +# test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' + +import imageio.v3 as iio +import fastplotlib as fpl +import scipy.ndimage + +im = iio.imread("imageio:astronaut.png") + +figure = fpl.Figure(size=(700, 560), cameras="3d", controller_types="orbit") + + +# Create the height map from the image +z = im.mean(axis=2) +z = scipy.ndimage.gaussian_filter(z, 5) # 2nd arg is sigma + +mesh = figure[0, 0].add_surface(z, cmap=im) +mesh.world_object.local.scale_y = -1 + + +figure[0, 0].axes.grids.xy.visible = True +figure[0, 0].camera.show_object(mesh.world_object, (1, 2, -1), up=(0, 0, 1)) +figure.show() + + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/examples/mesh/mesh.py b/examples/mesh/mesh.py new file mode 100644 index 000000000..4c8de088d --- /dev/null +++ b/examples/mesh/mesh.py @@ -0,0 +1,36 @@ +""" +Simple mesh +=========== + +Example showing a simple mesh +""" + +# test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' + +import fastplotlib as fpl +import pygfx as gfx + + +figure = fpl.Figure(size=(700, 560), cameras="3d", controller_types="orbit") + + +# Load geometry using Pygfx's geometry util +geo = gfx.geometries.torus_knot_geometry() +positions = geo.positions.data +indices = geo.indices.data + +mesh = fpl.MeshGraphic(positions, indices, colors="magenta") + +figure[0, 0].add_graphic(mesh) +figure[0, 0].axes.grids.xy.visible = True +figure[0, 0].camera.show_object(mesh.world_object, (1, 1, -1), up=(0, 0, 1)) + +figure.show() + + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/examples/mesh/polygon_animation.py b/examples/mesh/polygon_animation.py new file mode 100644 index 000000000..6d4bc7bf0 --- /dev/null +++ b/examples/mesh/polygon_animation.py @@ -0,0 +1,76 @@ +""" +Polygon animation +================= + +Polygon animation example that changes the polygon data. Random points are generated by sampling from a +2D gaussian and a polygon is updated to visualize a convex hull for the sampled points. + +""" + +# test_example = false +# sphinx_gallery_pygfx_docs = 'animate 8s' + +import numpy as np +from scipy.spatial import ConvexHull +import fastplotlib as fpl + + +def points_to_hull(points) -> np.ndarray: + hull = ConvexHull(points, qhull_options="Qs") + return points[hull.vertices] + + +figure = fpl.Figure(size=(700, 560)) + + +cov = np.array([[1, 0], [0, 1]]) + +# sample points from a 2d gaussian +samples1 = np.random.multivariate_normal((0, 0), cov, size=20) +samples2 = np.random.multivariate_normal((5, 0), cov, size=50) + +# add the convex hull as a polygon +polygon1 = figure[0, 0].add_polygon( + points_to_hull(samples1), colors="cyan", alpha=0.7, alpha_mode="blend" +) +# add the sampled points +scatter1 = figure[0, 0].add_scatter( + samples1, sizes=8, colors="blue", alpha=0.7, alpha_mode="blend" +) + +# add the second gaussian and convex hull polygon +polygon2 = figure[0, 0].add_polygon( + points_to_hull(samples2), colors="magenta", alpha=0.7, alpha_mode="blend" +) +scatter2 = figure[0, 0].add_scatter( + samples2, sizes=8, colors="r", alpha=0.7, alpha_mode="blend" +) + + +def animate(): + # set new scatter data + scatter1.data[:, :-1] += np.random.normal(0, 0.05, size=samples1.size).reshape( + samples1.shape + ) + # set convex hull with new polygon vertices + polygon1.data = points_to_hull(scatter1.data[:, :-1]) + + # set the other scatter and polygon + scatter2.data[:, :-1] += np.random.normal(0, 0.05, size=samples2.size).reshape( + samples2.shape + ) + polygon2.data = points_to_hull(scatter2.data[:, :-1]) + + +figure.show() +figure[0, 0].camera.width = 10 +figure[0, 0].camera.height = 10 + +figure.add_animations(animate) + + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/examples/mesh/polygons.py b/examples/mesh/polygons.py new file mode 100644 index 000000000..616c2e0fb --- /dev/null +++ b/examples/mesh/polygons.py @@ -0,0 +1,61 @@ +""" +Polygons +======== + +An example with polygons. + +""" + +# test_example = True +# sphinx_gallery_pygfx_docs = 'screenshot' + +import fastplotlib as fpl +import numpy as np +from cmap import Colormap + +figure = fpl.Figure(size=(700, 560)) + + +def make_circle(center, radius: float, n_points: int = 75) -> np.ndarray: + theta = np.linspace(0, 2 * np.pi, n_points, endpoint=False) + xs = radius * np.sin(theta) + ys = radius * np.cos(theta) + + return np.column_stack([xs, ys]) + np.asarray(center)[None] + + +# define vertices for some polygons +circle_data = make_circle(center=(0, 0), radius=5) +octogon_data = make_circle(center=(15, 0), radius=7, n_points=8) +rectangle_data = np.array([[10, 10], [20, 10], [20, 15], [10, 15]]) +triangle_data = np.array( + [ + [-5, 8], + [5, 8], + [0, 15], + [-5, 8], + ] +) + +# add polygons +figure[0, 0].add_polygon(circle_data, name="circle") +figure[0, 0].add_polygon( + octogon_data, + colors=Colormap("jet").lut(8), # set vertex colors from jet cmap + name="octogon" +) +figure[0, 0].add_polygon( + rectangle_data, + colors=["r", "r", "cyan", "y"], # manually specify vertex colors + name="rectangle" +) +figure[0, 0].add_polygon(triangle_data, colors="m") + +figure.show() + + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/examples/mesh/surface_earth.py b/examples/mesh/surface_earth.py new file mode 100644 index 000000000..c2e137bc8 --- /dev/null +++ b/examples/mesh/surface_earth.py @@ -0,0 +1,95 @@ +""" +Earth sphere animation +====================== + +Example showing how to create a sphere with an image of the Earth and rotate it around its 23.44° axis of rotation +with respect to the ecliptic (the xz plane in the visualization). + +""" + +# test_example = false +# sphinx_gallery_pygfx_docs = 'animate 8s' + +import fastplotlib as fpl +import numpy as np +import imageio.v3 as iio +import pylinalg as la + + +figure = fpl.Figure(size=(700, 560), cameras="3d", controller_types="orbit") + +# create a sphere from spherical coordinates +# see this for reference: https://mathworld.wolfram.com/SphericalCoordinates.html +# phi and theta are swapped in this example w.r.t. the wolfram alpha description +radius = 10 +nx = 101 +phi = np.linspace(0, np.pi * 2, num=nx, dtype=np.float32) +ny = 51 +theta = np.linspace(0, np.pi, num=ny, dtype=np.float32) + +phi_grid, theta_grid = np.meshgrid(phi, theta) + +# convert to cartesian coordinates +theta_grid_sin = np.sin(theta_grid) +x = radius * np.cos(phi_grid) * theta_grid_sin * -1 +y = radius * np.cos(theta_grid) +z = radius * np.sin(phi_grid) * theta_grid_sin + +# get texture coords to map the image onto the mesh positions +u = phi_grid / (np.pi * 2) +v = 1 - (theta_grid / np.pi) +texcoords = np.dstack([u, v]).reshape(-1, 2) + +# get an image of the earth from nasa +image = iio.imread( + "https://svs.gsfc.nasa.gov/vis/a000000/a003600/a003615/flat_earth_Largest_still.0330.jpg" +) +# images coordinate systems are typically inverted in y, so flip the image +image = np.ascontiguousarray(np.flipud(image)) + +# create a sphere +sphere = figure[0, 0].add_surface( + np.dstack([x, y, z]), + mode="phong", + colors="magenta", + cmap=image, + mapcoords=texcoords, +) + +# display xz plane as a grid +figure[0, 0].axes.grids.xz.visible = True +figure.show() + +# view from top right angle +figure[0, 0].camera.show_object(sphere.world_object, (-0.5, -0.25, -1), up=(0, 1, 0)) +figure[0, 0].camera.zoom = 1.25 + +# create quaternion for 23.44 degrees axial tilt +axial_tilt = la.quat_from_euler((np.radians(23.44), 0), order="XY") + +# a line to indicate the axial tilt +figure[0, 0].add_line( + np.array([[0, -20, 0], [0, 20, 0]]), rotation=axial_tilt, colors="magenta" +) + +rot = 1 + + +def rotate(): + # rotate by 1 degree + global rot + rot += 1 + rot_quat = la.quat_from_euler((0, np.radians(rot)), order="XY") + + # apply rotation w.r.t. axial tilt + sphere.rotation = la.quat_mul(axial_tilt, rot_quat) + + +figure[0, 0].add_animations(rotate) + + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/examples/mesh/surface_ellipsoid.py b/examples/mesh/surface_ellipsoid.py new file mode 100644 index 000000000..6d7cdae7b --- /dev/null +++ b/examples/mesh/surface_ellipsoid.py @@ -0,0 +1,55 @@ +""" +Ellipsoid surface +================= + +Simple example of a sphere surface mesh with a colormap indicating z values. + +""" + +# test_example = false +# sphinx_gallery_pygfx_docs = 'screenshot' + +import fastplotlib as fpl +import numpy as np + +figure = fpl.Figure(size=(700, 560), cameras="3d", controller_types="orbit") + +# create an ellipsoid from spherical coordinates +# see this for reference: https://mathworld.wolfram.com/SphericalCoordinates.html +# phi and theta are swapped in this example w.r.t. the wolfram alpha description +radius = 10 + +nx = 101 +phi = np.linspace(0, np.pi * 2, num=nx, dtype=np.float32) +ny = 51 +theta = np.linspace(0, np.pi, num=ny, dtype=np.float32) + +phi_grid, theta_grid = np.meshgrid(phi, theta) + +# convert to cartesian coordinates +theta_grid_sin = np.sin(theta_grid) +x = radius * np.cos(phi_grid) * theta_grid_sin * -1 +y = radius * np.cos(theta_grid) + +# elongate along z axis +z = radius * 2 * np.sin(phi_grid) * theta_grid_sin + +sphere = figure[0, 0].add_surface( + np.dstack([x, y, z]), + mode="phong", + cmap="bwr", # by default, providing a colormap name will map the colors to z values +) + +# display xz plane as a grid +figure[0, 0].axes.grids.xy.visible = True +figure.show() + +# view from top right angle +figure[0, 0].camera.show_object(sphere.world_object, (1, 1, -1), up=(0, 0, 1)) + + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/examples/mesh/surface_gaussian.py b/examples/mesh/surface_gaussian.py new file mode 100644 index 000000000..6a9fb0f1d --- /dev/null +++ b/examples/mesh/surface_gaussian.py @@ -0,0 +1,45 @@ +""" +Gaussian kernel as a surface +============================ + +Example showing a gaussian kernel as a surface mesh +""" + +# test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' + +import fastplotlib as fpl +import numpy as np + + +figure = fpl.Figure(size=(700, 560), cameras="3d", controller_types="orbit") + + +def gaus2d(x=0, y=0, mx=0, my=0, sx=1, sy=1): + return ( + 1.0 + / (2.0 * np.pi * sx * sy) + * np.exp( + -((x - mx) ** 2.0 / (2.0 * sx**2.0) + (y - my) ** 2.0 / (2.0 * sy**2.0)) + ) + ) + + +r = np.linspace(0, 10, num=200) +x, y = np.meshgrid(r, r) +z = gaus2d(x, y, mx=5, my=5, sx=1, sy=1) * 50 + +mesh = figure[0, 0].add_surface( + np.dstack([x, y, z]), mode="phong", cmap="jet" +) + +# figure[0, 0].axes.grids.xy.visible = True +figure[0, 0].camera.show_object(mesh.world_object, (-2, 2, -2), up=(0, 0, 1)) +figure.show() + + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/examples/mesh/surface_height.py b/examples/mesh/surface_height.py new file mode 100644 index 000000000..1e1db7ffe --- /dev/null +++ b/examples/mesh/surface_height.py @@ -0,0 +1,35 @@ +""" +Simple surface +============== + +Example showing a surface mesh +""" + +# test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' + +import fastplotlib as fpl +import numpy as np +import pygfx as gfx + + +figure = fpl.Figure(size=(700, 560), cameras="3d", controller_types="orbit") + + +t = np.linspace(0, 6, 100).astype(np.float32) +x = np.sin(t) +y = np.cos(t * 2) +z = (x.reshape(1, -1) * x.reshape(-1, 1)) * 50 # 100x100 + +surface = figure[0, 0].add_surface(z, cmap="bwr") + +# figure[0, 0].axes.grids.xy.visible = True +figure[0, 0].camera.show_object(surface.world_object, (-2, 2, -3), up=(0, 0, 1)) +figure.show() + + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/examples/mesh/surface_ripple.py b/examples/mesh/surface_ripple.py new file mode 100644 index 000000000..ac556bd1b --- /dev/null +++ b/examples/mesh/surface_ripple.py @@ -0,0 +1,62 @@ +""" +Surface animation +================= + +Example of a surface ripple animation by setting the z-height data on every render. + +""" + +# test_example = false +# sphinx_gallery_pygfx_docs = 'animate 6s' + +import fastplotlib as fpl +import numpy as np + + +figure = fpl.Figure(size=(700, 560), cameras="3d", controller_types="orbit") + + +def create_ripple(shape=(100, 100), phase=0.0, freq=np.pi / 4, ampl=1.0): + m, n = shape + y, x = np.ogrid[-m / 2 : m / 2, -n / 2 : n / 2] + r = np.sqrt(x**2 + y**2) + z = (ampl * np.sin(freq * r + phase)) / np.sqrt(r + 1) + + return z * 8 + + +z = create_ripple() + +# set the clim vmax +max_z = create_ripple(phase=(np.pi / 4) - (np.pi / 2)).max() + +surface = figure[0, 0].add_surface( + z, mode="basic", cmap="viridis", clim=(-max_z, max_z) +) + +figure[0, 0].camera.show_object(surface.world_object, (-1, 3, -1), up=(0, 0, 1)) +figure.show() + +figure[0, 0].camera.zoom = 1.15 + +phase = 0.0 + + +def animate(): + global phase + + z = create_ripple(phase=phase) + + surface.data = z + + phase -= 0.1 + + +figure[0, 0].add_animations(animate) + + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/examples/mesh/surface_sphere_ripple.py b/examples/mesh/surface_sphere_ripple.py new file mode 100644 index 000000000..6caa03465 --- /dev/null +++ b/examples/mesh/surface_sphere_ripple.py @@ -0,0 +1,81 @@ +""" +Sphere ripple animation +======================= + +Example of a sphere with a ripple effect by setting the data on every render. + +""" + +# test_example = false +# sphinx_gallery_pygfx_docs = 'animate 6s' + +import fastplotlib as fpl +import numpy as np + +figure = fpl.Figure(size=(700, 560), cameras="3d", controller_types="orbit") + +# create an ellipsoid from spherical coordinates +# see this for reference: https://mathworld.wolfram.com/SphericalCoordinates.html +# phi and theta are swapped in this example w.r.t. the wolfram alpha description +radius = 10 +nx = 250 +phi = np.linspace(0, np.pi * 2, num=nx, dtype=np.float32) +ny = 250 +theta = np.linspace(0, np.pi, num=ny, dtype=np.float32) + +phi_grid, theta_grid = np.meshgrid(phi, theta) + +# convert to cartesian coordinates +theta_grid_sin = np.sin(theta_grid) +x = radius * np.cos(phi_grid) * theta_grid_sin * -1 +y = radius * np.cos(theta_grid) + +ripple_amplitude = 1.0 +ripple_frequency = 20.0 +ripple = ripple_amplitude * np.sin(ripple_frequency * theta_grid) + +z_ref = radius * np.sin(phi_grid) * theta_grid_sin +z = z_ref * (1 + ripple / radius) + +sphere = figure[0, 0].add_surface( + np.dstack([x, y, z]), + mode="phong", + colors="red", + cmap="jet", +) + +# display xz plane as a grid +figure[0, 0].axes.grids.xy.visible = True +figure.show() + +figure[0, 0].camera.show_object(sphere.world_object, (10, 1, -1), up=(0, 0, 1)) +figure[0, 0].camera.zoom = 1.3 + + +start = 0 + + +def animate(): + global start + theta = np.linspace(start, start + np.pi, num=ny, dtype=np.float32) + _, theta_grid = np.meshgrid(phi, theta) + ripple = ripple_amplitude * np.sin(ripple_frequency * theta_grid) + + z = z_ref * (1 + ripple / radius) + + sphere.data = np.dstack([x, y, z]) + + start += 0.005 + + if start > np.pi * 2: + start = 0 + + +figure[0, 0].add_animations(animate) + + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/examples/mesh/surface_terrain.py b/examples/mesh/surface_terrain.py new file mode 100644 index 000000000..f747a708c --- /dev/null +++ b/examples/mesh/surface_terrain.py @@ -0,0 +1,36 @@ +""" +Elevation map of the earth +========================== + +Surface graphic showing elevation map of the earth +""" + +# run_example = false +# sphinx_gallery_pygfx_docs = 'code' + +import imageio.v3 as iio +import fastplotlib as fpl +import numpy as np + +# grayscale image of the earth where the pixel value indicates elevation +elevation = iio.imread("https://neo.gsfc.nasa.gov/archive/bluemarble/bmng/topography/srtm_ramp2.world.5400x2700.jpg").astype(np.float32) +elevation /= 2 + +figure = fpl.Figure(size=(700, 560), cameras="3d", controller_types="orbit") + +mesh = figure[0, 0].add_surface(elevation, cmap="terrain") +mesh.world_object.local.scale_y = -1 + + +figure[0, 0].axes.grids.xy.visible = True +figure[0, 0].camera.show_object(mesh.world_object, (-4, 2, -1), up=(0, 0, 1)) +figure.show() + +figure[0, 0].camera.zoom = 2.5 + + +# NOTE: fpl.loop.run() should not be used for interactive sessions +# See the "JupyterLab and IPython" section in the user guide +if __name__ == "__main__": + print(__doc__) + fpl.loop.run() diff --git a/examples/screenshots/image_surface.png b/examples/screenshots/image_surface.png new file mode 100644 index 000000000..86300a7d4 --- /dev/null +++ b/examples/screenshots/image_surface.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c0a74e7a23147dc7c50b085c9beb7e1d41d012546606b586692b3b4968947569 +size 301999 diff --git a/examples/screenshots/mesh.png b/examples/screenshots/mesh.png new file mode 100644 index 000000000..8a2d5c219 --- /dev/null +++ b/examples/screenshots/mesh.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a040db1c5159f0e8e9b3dfb61b7076909481d7f3c21b25722cd0b50c14c30b2d +size 320096 diff --git a/examples/screenshots/no-imgui-image_surface.png b/examples/screenshots/no-imgui-image_surface.png new file mode 100644 index 000000000..5ebc655d1 --- /dev/null +++ b/examples/screenshots/no-imgui-image_surface.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0703c47bee63b8170100fbe58a72b41a4c40525adf2ed2c16fa2d860c627ed21 +size 311054 diff --git a/examples/screenshots/no-imgui-mesh.png b/examples/screenshots/no-imgui-mesh.png new file mode 100644 index 000000000..5a83fc871 --- /dev/null +++ b/examples/screenshots/no-imgui-mesh.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:675e4b8201f6dc77d1f7bd5269ad948b45bbdcfb400d764c771a89e8528b974f +size 325110 diff --git a/examples/screenshots/no-imgui-surface_gaussian.png b/examples/screenshots/no-imgui-surface_gaussian.png new file mode 100644 index 000000000..849d4d9cb --- /dev/null +++ b/examples/screenshots/no-imgui-surface_gaussian.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5ccd13d12890895cc70bf4b9e071ea75f6a23a90f885ac6986ac6fb1fd6d544b +size 33510 diff --git a/examples/screenshots/no-imgui-surface_height.png b/examples/screenshots/no-imgui-surface_height.png new file mode 100644 index 000000000..789783464 --- /dev/null +++ b/examples/screenshots/no-imgui-surface_height.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e8662ea400572e3a9730b42d915c093040ed2694c0f02439438898570ca41666 +size 51219 diff --git a/examples/screenshots/no-imgui-vectors_simple.png b/examples/screenshots/no-imgui-vectors_simple.png new file mode 100644 index 000000000..02f067c08 --- /dev/null +++ b/examples/screenshots/no-imgui-vectors_simple.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8eb8ba74def34c750e876c1811800157606d71423fa27c5f3e338b66513a30ee +size 129275 diff --git a/examples/screenshots/no-imgui-vectors_swirl.png b/examples/screenshots/no-imgui-vectors_swirl.png new file mode 100644 index 000000000..63300917b --- /dev/null +++ b/examples/screenshots/no-imgui-vectors_swirl.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9e41489d3fffefe5b4217879d8fd166205e81b91f7e88c2437ae21113b3937c1 +size 72255 diff --git a/examples/screenshots/surface_gaussian.png b/examples/screenshots/surface_gaussian.png new file mode 100644 index 000000000..8e9a414c4 --- /dev/null +++ b/examples/screenshots/surface_gaussian.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a40c79782f8498d4c03f0f6f09583c8a7d139a73a8457b30158d5625d77792ba +size 32108 diff --git a/examples/screenshots/surface_height.png b/examples/screenshots/surface_height.png new file mode 100644 index 000000000..56a6a2c9b --- /dev/null +++ b/examples/screenshots/surface_height.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b1f9bb4570725a7876f5296a69e9325b81e1e58633c46ae502adc0dc6ad00aca +size 50123 diff --git a/examples/screenshots/vectors_simple.png b/examples/screenshots/vectors_simple.png new file mode 100644 index 000000000..332b37812 --- /dev/null +++ b/examples/screenshots/vectors_simple.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6bf6bdfb4530a434417480bcd10713ccdc43db3a9c554da4be8e333760a8984d +size 126670 diff --git a/examples/screenshots/vectors_swirl.png b/examples/screenshots/vectors_swirl.png new file mode 100644 index 000000000..ab6f298e9 --- /dev/null +++ b/examples/screenshots/vectors_swirl.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ec512fd733f25df5706055efbdb077db5fa034349c5611a86a20f3055b0d8123 +size 71012 diff --git a/examples/tests/testutils.py b/examples/tests/testutils.py index 7b70defdb..aad729c7a 100644 --- a/examples/tests/testutils.py +++ b/examples/tests/testutils.py @@ -24,7 +24,8 @@ "scatter/*.py", "line/*.py", "line_collection/*.py", - "vectors/*.py" + "vectors/*.py", + "mesh/*.py", "gridplot/*.py", "window_layouts/*.py", "events/*.py", diff --git a/fastplotlib/graphics/__init__.py b/fastplotlib/graphics/__init__.py index 46051479d..3d01e4a35 100644 --- a/fastplotlib/graphics/__init__.py +++ b/fastplotlib/graphics/__init__.py @@ -4,6 +4,7 @@ from .image import ImageGraphic from .image_volume import ImageVolumeGraphic from ._vectors import VectorsGraphic +from .mesh import MeshGraphic, SurfaceGraphic, PolygonGraphic from .text import TextGraphic from .line_collection import LineCollection, LineStack @@ -15,6 +16,9 @@ "ImageGraphic", "ImageVolumeGraphic", "VectorsGraphic", + "MeshGraphic", + "SurfaceGraphic", + "PolygonGraphic", "TextGraphic", "LineCollection", "LineStack", diff --git a/fastplotlib/graphics/features/__init__.py b/fastplotlib/graphics/features/__init__.py index f745f10c8..cf99d376d 100644 --- a/fastplotlib/graphics/features/__init__.py +++ b/fastplotlib/graphics/features/__init__.py @@ -1,10 +1,19 @@ -from ._positions_graphics import ( +from ._positions import ( VertexColors, UniformColor, SizeSpace, VertexPositions, VertexCmap, ) +from ._mesh import ( + MeshIndices, + MeshCmap, + SurfaceData, + PolygonData, + resolve_cmap_mesh, + surface_data_to_mesh, + triangulate_polygon, +) from ._line import Thickness from ._scatter import ( VertexMarkers, @@ -71,6 +80,9 @@ "SizeSpace", "VertexPositions", "VertexCmap", + "MeshIndices", + "MeshCmap", + "SurfaceData", "Thickness", "VertexMarkers", "UniformMarker", diff --git a/fastplotlib/graphics/features/_base.py b/fastplotlib/graphics/features/_base.py index 5dec9f1e5..779310476 100644 --- a/fastplotlib/graphics/features/_base.py +++ b/fastplotlib/graphics/features/_base.py @@ -289,6 +289,12 @@ def _update_range( # the first dimension corresponding to n_datapoints key: int | np.ndarray[int | bool] | slice = key[0] + if isinstance(key, slice): + if key == slice(None): + # directly update full, don't need to figure out chunks + self.buffer.update_full() + return + offset, size = self._parse_offset_size(key, upper_bound) self.buffer.update_range(offset=offset, size=size) diff --git a/fastplotlib/graphics/features/_mesh.py b/fastplotlib/graphics/features/_mesh.py new file mode 100644 index 000000000..7355acb4e --- /dev/null +++ b/fastplotlib/graphics/features/_mesh.py @@ -0,0 +1,284 @@ +from typing import Any, Sequence + +import numpy as np +import pygfx + +from ._base import ( + GraphicFeature, + GraphicFeatureEvent, + to_gpu_supported_dtype, + block_reentrance, +) + +from ._positions import VertexPositions +from ...utils.functions import get_cmap +from ...utils.triangulation import triangulate + + +def resolve_cmap_mesh(cmap) -> pygfx.TextureMap | None: + """Turn a user-provided in a pygfx.TextureMap, supporting 1D, 2D and 3D data.""" + + if cmap is None: + pygfx_cmap = None + elif isinstance(cmap, pygfx.TextureMap): + pygfx_cmap = cmap + elif isinstance(cmap, pygfx.Texture): + pygfx_cmap = pygfx.TextureMap(cmap) + elif isinstance(cmap, (str, dict)): + pygfx_cmap = pygfx.cm.create_colormap(get_cmap(cmap)) + else: + map = np.asarray(cmap) + if map.ndim == 2: # 1D plus color + pygfx_cmap = pygfx.cm.create_colormap(cmap) + else: + tex = pygfx.Texture(map, dim=map.ndim - 1) + pygfx_cmap = pygfx.TextureMap(tex) + + return pygfx_cmap + + +class MeshIndices(VertexPositions): + event_info_spec = [ + { + "dict key": "key", + "type": "slice, index (int) or numpy-like fancy index", + "description": "key at which vertex indices were indexed/sliced", + }, + { + "dict key": "value", + "type": "int | float | array-like", + "description": "new data values for indices that were changed", + }, + ] + + def __init__( + self, data: Any, isolated_buffer: bool = True, property_name: str = "indices" + ): + """ + Manages the vertex indices buffer shown in the graphic. + Supports fancy indexing if the data array also supports it. + """ + + data = self._fix_data(data) + super().__init__( + data, isolated_buffer=isolated_buffer, property_name=property_name + ) + + def _fix_data(self, data): + if data.ndim != 2 or data.shape[1] not in (3, 4): + raise ValueError( + f"indices must be of shape: [n_vertices, 3] or [n_vertices, 4], " + f"you passed an array of shape: {data.shape}" + ) + return data.astype("i4") + + +class MeshCmap(GraphicFeature): + event_info_spec = [ + { + "dict key": "value", + "type": "str | dict | pygfx.TextureMap | pygfx.Texture | np.ndarray", + "description": "new cmap", + }, + ] + + def __init__( + self, + value: str | dict | pygfx.TextureMap | pygfx.Texture | np.ndarray | None, + property_name: str = "cmap", + ): + """Manages a mesh colormap""" + + self._value = value + super().__init__(property_name=property_name) + + @property + def value( + self, + ) -> str | dict | pygfx.TextureMap | pygfx.Texture | np.ndarray | None: + return self._value + + @block_reentrance + def set_value( + self, + graphic, + value: str | dict | pygfx.TextureMap | pygfx.Texture | np.ndarray | None, + ): + graphic.world_object.material.map = resolve_cmap_mesh(value) + self._value = value + + event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) + self._call_event_handlers(event) + + +def surface_data_to_mesh(data: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + """ + surface data to mesh positions and indices + + expects data that is of shape: [m, n, 3] or [m, n] + """ + + data = np.asarray(data) + + if data.ndim == 2: + # "image" of z values passed + # [m, n] -> [n_vertices, 3] + y = ( + np.arange(data.shape[0]) + .reshape(data.shape[0], 1) + .repeat(data.shape[1], axis=1) + ) + x = ( + np.arange(data.shape[1]) + .reshape(1, data.shape[1]) + .repeat(data.shape[0], axis=0) + ) + positions = np.column_stack((x.ravel(), y.ravel(), data.ravel())) + else: + if data.ndim != 3: + raise ValueError( + f"expect data that is of shape: [m, n, 3], [m, n]\n" + f"you passed: {data.shape}" + ) + if data.shape[2] != 3: + raise ValueError( + f"expect data that is of shape: [m, n, 3], [m, n]\n" + f"you passed: {data.shape}" + ) + + # [m, n, 3] -> [n_vertices, 3] + positions = data.reshape(-1, 3) + + # Create faces + w = data.shape[1] + i = np.arange(data.shape[0] - 1) + j = np.arange(w - 1) + + j, i = np.meshgrid(j, i, indexing="ij") + start = j.ravel() + w * i.ravel() + + indices = np.column_stack([start, start + 1, start + w + 1, start + w]) + + return positions, indices + + +class SurfaceData(GraphicFeature): + event_info_spec = [ + { + "dict key": "value", + "type": "np.ndarray", + "description": "new surface data", + }, + ] + + def __init__(self, value: np.ndarray | Sequence, property_name: str = "data"): + self._value = np.asarray(value, dtype=np.float32) + super().__init__(property_name=property_name) + + @property + def value(self) -> np.ndarray: + return self._value + + @block_reentrance + def set_value(self, graphic, value: np.ndarray): + positions, indices = surface_data_to_mesh(value) + + graphic.positions = positions + graphic.indices = indices + + # if cmap is a 1D texture we need to set the texcoords again using new z values + if graphic.world_object.material.map is not None: + if graphic.world_object.material.map.texture.dim == 1: + mapcoords = positions[:, 2] + + if graphic.clim is None: + clim = mapcoords.min(), mapcoords.max() + else: + clim = graphic.clim + mapcoords = (mapcoords - clim[0]) / (clim[1] - clim[0]) + graphic.mapcoords = mapcoords + + self._value = value + + event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) + self._call_event_handlers(event) + + +def triangulate_polygon(data: np.ndarray | Sequence): + """vertices of shape [n_vertices , 2] -> positions, indices""" + data = np.asarray(data, dtype=np.float32) + + err_msg = ( + f"polygon vertex data must be of shape [n_vertices, 2], you passed: {data}" + ) + + if data.ndim != 2: + raise ValueError(err_msg) + if data.shape[1] != 2: + raise ValueError(err_msg) + + if len(data) >= 3: + indices = triangulate(data) + else: + indices = np.arange((0, 3), np.int32) + + data = np.column_stack([data, np.zeros(data.shape[0], dtype=np.float32)]) + + return data, indices + + +class PolygonData(GraphicFeature): + event_info_spec = [ + { + "dict key": "value", + "type": "np.ndarray", + "description": "new polygon vertex data", + }, + ] + + def __init__(self, value: np.ndarray, property_name: str = "data"): + self._value = np.asarray(value, dtype=np.float32) + super().__init__(property_name=property_name) + + @property + def value(self) -> np.ndarray: + return self._value + + @block_reentrance + def set_value(self, graphic, value: np.ndarray | Sequence): + value = np.asarray(value, dtype=np.float32) + + positions, indices = triangulate_polygon(value) + + geometry = graphic.world_object.geometry + + # Need larger (or smaller) buffer? Scale up/down with factors of 2. + need_position_size = 2 ** int(np.ceil(np.log2(max(8, len(positions))))) + if need_position_size != geometry.positions.nitems: + arr = np.zeros((need_position_size, 3), np.float32) + geometry.positions = pygfx.Buffer(arr) + need_indices_size = 2 ** int(np.ceil(np.log2(max(8, len(indices))))) + if need_indices_size != geometry.indices.nitems: + arr = np.zeros((need_indices_size, 3), np.int32) + geometry.indices = pygfx.Buffer(arr) + + geometry.positions.data[: len(positions)] = positions + geometry.positions.data[len(positions) :] = ( + positions[-1] if len(positions) else (0, 0, 0) + ) + geometry.positions.draw_range = 0, len(positions) + geometry.positions.update_full() + + geometry.indices.data[: len(indices)] = indices + geometry.indices.data[len(indices) :] = 0 + geometry.indices.draw_range = 0, len(indices) + geometry.indices.update_full() + + # send event + if len(self._event_handlers) < 1: + return + + event = GraphicFeatureEvent(self._property_name, {"value": self.value}) + + # calls any events + self._call_event_handlers(event) diff --git a/fastplotlib/graphics/features/_positions_graphics.py b/fastplotlib/graphics/features/_positions.py similarity index 99% rename from fastplotlib/graphics/features/_positions_graphics.py rename to fastplotlib/graphics/features/_positions.py index ae57e77d7..295d22417 100644 --- a/fastplotlib/graphics/features/_positions_graphics.py +++ b/fastplotlib/graphics/features/_positions.py @@ -245,8 +245,6 @@ def __init__( ) def _fix_data(self, data): - # data = to_gpu_supported_dtype(data) - if data.ndim == 1: # if user provides a 1D array, assume these are y-values data = np.column_stack([np.arange(data.size, dtype=data.dtype), data]) diff --git a/fastplotlib/graphics/features/utils.py b/fastplotlib/graphics/features/utils.py index 408610e1e..aa4022052 100644 --- a/fastplotlib/graphics/features/utils.py +++ b/fastplotlib/graphics/features/utils.py @@ -34,8 +34,9 @@ def parse_colors( elif colors.ndim == 2: if not (colors.shape[1] in (3, 4) and colors.shape[0] == n_colors): raise ValueError( - "Valid array color arguments must be a single RGBA array or a stack of " - "RGB or RGBA arrays for each datapoint in the shape [n_datapoints, 3] or [n_datapoints, 4]" + f"Valid array color arguments must be a single RGBA array or a stack of " + f"RGB or RGBA arrays for each datapoint in the shape [n_datapoints, 3] or [n_datapoints, 4].\n" + f"n_datapoints is: {n_colors}, you passed a colors array of shape: {colors.shape}" ) data = colors else: diff --git a/fastplotlib/graphics/mesh.py b/fastplotlib/graphics/mesh.py new file mode 100644 index 000000000..2e5a11851 --- /dev/null +++ b/fastplotlib/graphics/mesh.py @@ -0,0 +1,473 @@ +from typing import Sequence, Any, Literal + +import numpy as np + +import pygfx + +from ._positions_base import Graphic +from .features import ( + VertexPositions, + MeshIndices, + MeshCmap, + SurfaceData, + surface_data_to_mesh, + VertexColors, + UniformColor, + resolve_cmap_mesh, + VolumeSlicePlane, + PolygonData, + triangulate_polygon, +) + + +class MeshGraphic(Graphic): + _features = { + "positions": VertexPositions, + "indices": MeshIndices, + "colors": (VertexColors, UniformColor), + "cmap": MeshCmap, + } + + def __init__( + self, + positions: Any, + indices: Any, + mode: Literal["basic", "phong", "slice"] = "phong", + plane: tuple[float, float, float, float] = (0.0, 0.0, 1.0, 0.0), + colors: str | np.ndarray | Sequence = "w", + mapcoords: Any = None, + cmap: str | dict | pygfx.Texture | pygfx.TextureMap | np.ndarray = None, + clim: tuple[float, float] = None, + isolated_buffer: bool = True, + **kwargs, + ): + """ + Create a mesh Graphic. + + Parameters + ---------- + positions: array-like + The 3D positions of the vertices. + + indices: array-like + The indices into the positions that make up the triangles. Each 3 + subsequent indices form a triangle. + + mode: one of "basic", "phong", "slice", default "phong" + * basic: illuminate mesh with only ambient lighting + * phong: phong lighting model, good for most use cases, see https://en.wikipedia.org/wiki/Phong_shading + * slice: display a slice of the mesh at the specified ``plane`` + + plane: (float, float, float, float), default (0., 0., 1., 0.) + Slice mesh at this plane. Sets (a, b, c, d) in the equation the defines a plane: ax + by + cz + d = 0. + Used only if `mode` = "slice". The plane is defined in world space. + + colors: str, array, or iterable, default "w" + A uniform color, or the per-position colors. + + mapcoords: array-like + The per-position coordinates to which to apply the colormap (a.k.a. texcoords). + These can e.g. be some domain-specific value, mapped to [0..1]. + If ``mapcoords`` and ``cmap`` are given, they are used instead of ``colors``. + + cmap: str, optional + Apply a colormap to the mesh, this overrides any argument passed to + "colors". For supported colormaps see the ``cmap`` library + catalogue: https://cmap-docs.readthedocs.io/en/stable/catalog/ + Both 1D and 2D colormaps are supported, though the mapcoords has to match the dimensionality. + An image can also be used, this is basically a 2D colormap. + + isolated_buffer: bool, default True + If True, initialize a buffer with the same shape as the input data and then + set the data, useful if the data arrays are ready-only such as memmaps. + If False, the input array is itself used as the buffer - useful if the + array is large. In almost all cases this should be ``True``. + + **kwargs + passed to :class:`.Graphic` + + """ + + super().__init__(**kwargs) + + if isinstance(positions, VertexPositions): + self._positions = positions + else: + self._positions = VertexPositions( + positions, isolated_buffer=isolated_buffer, property_name="positions" + ) + + if isinstance(positions, MeshIndices): + self._indices = indices + else: + self._indices = MeshIndices( + indices, isolated_buffer=isolated_buffer, property_name="indices" + ) + + self._cmap = MeshCmap(cmap) + + # Apply contrast limits. Would be nice if Pygfx mesh material had clim too! But + # for now we apply it as a pre-processing step. + if clim is None and mapcoords is not None: + clim = mapcoords.min(), mapcoords.max() + + if mapcoords is not None: + mapcoords = (mapcoords - clim[0]) / (clim[1] - clim[0]) + self._mapcoords = pygfx.Buffer(np.asarray(mapcoords, dtype=np.float32)) + else: + self._mapcoords = None + + self._clim = clim + + uniform_color = "w" + per_vertex_colors = False + + if cmap is None: + if colors is None: + uniform_color = "w" + self._colors = UniformColor(uniform_color) + elif isinstance(colors, str) or isinstance(colors, tuple): + uniform_color = colors + self._colors = UniformColor(uniform_color) + elif isinstance(colors, VertexColors): + per_vertex_colors = True + self._colors = colors + else: + per_vertex_colors = True + self._colors = VertexColors( + colors, n_colors=self._positions.value.shape[0] + ) + + geometry = pygfx.Geometry( + positions=self._positions.buffer, indices=self._indices._buffer + ) + + valid_modes = ["basic", "phong", "slice"] + if mode not in valid_modes: + raise ValueError(f"mode must be one of: {valid_modes}\nYou passed: {mode}") + self._mode = mode + + material_cls = getattr(pygfx, f"Mesh{mode.capitalize()}Material") + + if mode == "slice": + self._plane = VolumeSlicePlane(plane) + add_kwargs = {"plane": self._plane.value} + else: + # for basic and phong, maybe later we can add more of the properties + add_kwargs = {} + + material = material_cls( + color_mode="uniform", + color=uniform_color, + pick_write=True, + **add_kwargs, + ) + + # Set all the data + if per_vertex_colors: + geometry.colors = self._colors.buffer + if self._mapcoords is not None: + geometry.texcoords = self._mapcoords + if cmap is not None: + material.map = resolve_cmap_mesh(cmap) + + # Decide on color mode + # uniform = None #: Use the uniform color (usually ``material.color``). + # vertex = None #: Use the per-vertex color specified in the geometry (usually ``geometry.colors``). + # face = None #: Use the per-face color specified in the geometry (usually ``geometry.colors``). + # vertex_map = None #: Use per-vertex texture coords (``geometry.texcoords``), and sample these in ``material.map``. + # face_map = None #: Use per-face texture coords (``geometry.texcoords``), and sample these in ``material.map``. + if mapcoords is not None and cmap is not None: + material.color_mode = "vertex_map" + elif per_vertex_colors: + material.color_mode = "vertex" + else: + material.color_mode = "uniform" + + world_object: pygfx.Mesh = pygfx.Mesh(geometry=geometry, material=material) + + self._set_world_object(world_object) + + @property + def mode(self) -> Literal["basic", "phong", "slice"]: + """get mesh rendering mode""" + return self._mode + + @property + def positions(self) -> VertexPositions: + """Get or set the vertex positions""" + return self._positions + + @positions.setter + def positions(self, new_positions): + self._positions[:] = new_positions + + @property + def indices(self) -> MeshIndices: + """Get or set the vertex indices""" + return self._indices + + @indices.setter + def indices(self, mew_indices): + self._indices[:] = mew_indices + + @property + def mapcoords(self) -> np.ndarray | None: + """get or set the mapcoords""" + if self._mapcoords is not None: + return self._mapcoords.data + + @mapcoords.setter + def mapcoords(self, new_mapcoords: np.ndarray | None): + if new_mapcoords is None: + self.world_object.geometry.texcoords = None + self._mapcoords = None + return + + if new_mapcoords.shape == self._mapcoords.data.shape: + self._mapcoords.data[:] = new_mapcoords + self._mapcoords.update_full() + else: + # allocate new buffer + self._mapcoords = pygfx.Buffer(np.asarray(new_mapcoords, dtype=np.float32)) + self.world_object.geometry.texcoords = self._mapcoords + + @property + def clim(self) -> tuple[float, float] | None: + """get or set the colormap limits""" + return self._clim + + @clim.setter + def clim(self, new_clim: tuple[float, float]): + if len(new_clim) != 2: + raise ValueError("clim must be a: tuple[float, float]") + + self._clim = tuple(new_clim) + + self.mapcoords = (self.mapcoords - self.clim[0]) / (self.clim[1] - self.clim[0]) + + @property + def colors(self) -> VertexColors | pygfx.Color: + """Get or set the colors""" + if isinstance(self._colors, VertexColors): + return self._colors + + elif isinstance(self._colors, UniformColor): + return self._colors.value + + @colors.setter + def colors(self, value: str | np.ndarray | Sequence[float] | Sequence[str]): + if isinstance(self._colors, VertexColors): + self._colors[:] = value + + elif isinstance(self._colors, UniformColor): + self._colors.set_value(self, value) + + @property + def cmap(self) -> str | dict | pygfx.Texture | pygfx.TextureMap | np.ndarray | None: + """get or set the cmap""" + if self._cmap is not None: + return self._cmap.value + + @cmap.setter + def cmap( + self, + new_cmap: str | dict | pygfx.Texture | pygfx.TextureMap | np.ndarray | None, + ): + self._cmap.set_value(self, new_cmap) + + @property + def plane(self) -> tuple[float, float, float, float] | None: + """Get or set the current slice plane. Valid only for ``"slice"`` render mode.""" + if self.mode != "slice": + return + + return self._plane.value + + @plane.setter + def plane(self, value: tuple[float, float, float, float]): + if self.mode != "slice": + raise TypeError("`plane` property is only valid for `slice` render mode.") + + self._plane.set_value(self, value) + + +class SurfaceGraphic(MeshGraphic): + _features = { + "data": SurfaceData, + "colors": (VertexColors, UniformColor), + "cmap": MeshCmap, + } + + def __init__( + self, + data: np.ndarray, + mode: Literal["basic", "phong", "slice"] = "phong", + colors: str | np.ndarray | Sequence = "w", + mapcoords: Any = None, + cmap: str | dict | pygfx.Texture | pygfx.TextureMap | np.ndarray = None, + clim: tuple[float, float] | None = None, + **kwargs, + ): + """ + Create a Surface mesh Graphic + + Parameters + ---------- + data: array-like + A height-map (an image where the values indicate height, i.e. z values). + Can also be a [m, n, 3] to explicitly specify the x and y values in addition to the z values. + [m, n, 3] is a dstack of (x, y, z) values that form a grid on the xy plane. + + mode: one of "basic", "phong", "slice", default "phong" + * basic: illuminate mesh with only ambient lighting + * phong: phong lighting model, good for most use cases, see https://en.wikipedia.org/wiki/Phong_shading + + colors: str, array, or iterable, default "w" + A uniform color, or the per-position colors. + + mapcoords: array-like + The per-position coordinates to which to apply the colormap (a.k.a. texcoords). + These can e.g. be some domain-specific value (mapped to [0..1] using ``clim``). + If not given, they will be the depth (z-coordinate) of the surface. + + cmap: str, optional + Apply a colormap to the mesh, this overrides any argument passed to + "colors". For supported colormaps see the ``cmap`` library + catalogue: https://cmap-docs.readthedocs.io/en/stable/catalog/ + Both 1D and 2D colormaps are supported, though the mapcoords has to match the dimensionality. + + clim: tuple[float, float] + The colormap limits. If the mapcoords has values between e.g. 5 and 90, you want to set the clim + to e.g. (5, 90) or (0, 100) to determine how the values map onto the colormap. + + **kwargs + passed to :class:`.Graphic` + + """ + + self._data = SurfaceData(data) + + positions, indices = surface_data_to_mesh(data) + + cmap_tex_view = resolve_cmap_mesh(cmap) + if (cmap_tex_view is not None) and (mapcoords is None): + if cmap_tex_view.texture.dim == 1: # 1d + mapcoords = positions[:, 2] + + elif cmap_tex_view.texture.dim == 2: + mapcoords = np.column_stack((positions[:, 0], positions[:, 1])).astype( + np.float32 + ) + + super().__init__( + positions, + indices, + mode=mode, + colors=colors, + mapcoords=mapcoords, + cmap=cmap, + clim=clim, + **kwargs, + ) + + @property + def data(self) -> np.ndarray: + """get or set the surface data""" + return self._data.value + + @data.setter + def data(self, new_data: np.ndarray): + self._data.set_value(self, new_data) + + +class PolygonGraphic(MeshGraphic): + _features = { + "data": SurfaceData, + "colors": (VertexColors, UniformColor), + "cmap": MeshCmap, + } + + def __init__( + self, + data: np.ndarray, + mode: Literal["basic", "phong"] = "basic", + colors: str | np.ndarray | Sequence = "w", + mapcoords: Any = None, + cmap: str | dict | pygfx.Texture | pygfx.TextureMap | np.ndarray = None, + clim: tuple[float, float] | None = None, + **kwargs, + ): + """ + Create a polygon mesh graphic. + + The data are always in the 'xy' plane. Set a rotation to display the polygon in another plane or in 3D space. + + Parameters + ---------- + data: array-like + The polygon vertices, must be of shape: [n_vertices, 2] + + mode: one of "basic", "phong", "slice", default "phong" + * basic: illuminate mesh with only ambient lighting + * phong: phong lighting model, good for most use cases, see https://en.wikipedia.org/wiki/Phong_shading + + colors: str, array, or iterable, default "w" + A uniform color, or the per-position colors. + + mapcoords: array-like + The per-position coordinates to which to apply the colormap (a.k.a. texcoords). + These can e.g. be some domain-specific value (mapped to [0..1] using ``clim``). + If not given, they will be the depth (z-coordinate) of the surface. + + cmap: str, optional + Apply a colormap to the mesh, this overrides any argument passed to + "colors". For supported colormaps see the ``cmap`` library + catalogue: https://cmap-docs.readthedocs.io/en/stable/catalog/ + Both 1D and 2D colormaps are supported, though the mapcoords has to match the dimensionality. + + clim: tuple[float, float] + The colormap limits. If the mapcoords has values between e.g. 5 and 90, you want to set the clim + to e.g. (5, 90) or (0, 100) to determine how the values map onto the colormap. + + **kwargs + passed to :class:`.Graphic` + """ + + positions, indices = triangulate_polygon(data) + + self._data = PolygonData(positions) + + super().__init__( + positions, + indices, + mode=mode, + colors=colors, + mapcoords=mapcoords, + cmap=cmap, + clim=clim, + **kwargs, + ) + + @property + def data(self) -> np.ndarray: + """get or set the polygon vertex data""" + return self._data.value + + @data.setter + def data(self, new_data: np.ndarray | Sequence): + self._data.set_value(self, new_data) + + @property + def clim(self) -> tuple[float, float] | None: + """get or set the colormap limits""" + return self._clim + + @clim.setter + def clim(self, new_clim: tuple[float, float]): + if len(new_clim) != 2: + raise ValueError("clim must be a: tuple[float, float]") + + self._clim = tuple(new_clim) + + self.mapcoords = (self.mapcoords - self.clim[0]) / (self.clim[1] - self.clim[0]) diff --git a/fastplotlib/layouts/_graphic_methods_mixin.py b/fastplotlib/layouts/_graphic_methods_mixin.py index e7ff99a1d..06a4c7517 100644 --- a/fastplotlib/layouts/_graphic_methods_mixin.py +++ b/fastplotlib/layouts/_graphic_methods_mixin.py @@ -432,6 +432,144 @@ def add_line_stack( **kwargs, ) + def add_mesh( + self, + positions: Any, + indices: Any, + mode: Literal["basic", "phong", "slice"] = "phong", + plane: tuple[float, float, float, float] = (0.0, 0.0, 1.0, 0.0), + colors: Union[str, numpy.ndarray, Sequence] = "w", + mapcoords: Any = None, + cmap: ( + str + | dict + | pygfx.resources._texture.Texture + | pygfx.resources._texturemap.TextureMap + | numpy.ndarray + ) = None, + clim: tuple[float, float] = None, + isolated_buffer: bool = True, + **kwargs, + ) -> MeshGraphic: + """ + + Create a mesh Graphic. + + Parameters + ---------- + positions: array-like + The 3D positions of the vertices. + + indices: array-like + The indices into the positions that make up the triangles. Each 3 + subsequent indices form a triangle. + + mode: one of "basic", "phong", "slice", default "phong" + * basic: illuminate mesh with only ambient lighting + * phong: phong lighting model, good for most use cases, see https://en.wikipedia.org/wiki/Phong_shading + * slice: display a slice of the mesh at the specified ``plane`` + + plane: (float, float, float, float), default (0., 0., 1., 0.) + Slice mesh at this plane. Sets (a, b, c, d) in the equation the defines a plane: ax + by + cz + d = 0. + Used only if `mode` = "slice". The plane is defined in world space. + + colors: str, array, or iterable, default "w" + A uniform color, or the per-position colors. + + mapcoords: array-like + The per-position coordinates to which to apply the colormap (a.k.a. texcoords). + These can e.g. be some domain-specific value, mapped to [0..1]. + If ``mapcoords`` and ``cmap`` are given, they are used instead of ``colors``. + + cmap: str, optional + Apply a colormap to the mesh, this overrides any argument passed to + "colors". For supported colormaps see the ``cmap`` library + catalogue: https://cmap-docs.readthedocs.io/en/stable/catalog/ + Both 1D and 2D colormaps are supported, though the mapcoords has to match the dimensionality. + An image can also be used, this is basically a 2D colormap. + + isolated_buffer: bool, default True + If True, initialize a buffer with the same shape as the input data and then + set the data, useful if the data arrays are ready-only such as memmaps. + If False, the input array is itself used as the buffer - useful if the + array is large. In almost all cases this should be ``True``. + + **kwargs + passed to :class:`.Graphic` + + + """ + return self._create_graphic( + MeshGraphic, + positions, + indices, + mode, + plane, + colors, + mapcoords, + cmap, + clim, + isolated_buffer, + **kwargs, + ) + + def add_polygon( + self, + data: numpy.ndarray, + mode: Literal["basic", "phong"] = "basic", + colors: Union[str, numpy.ndarray, Sequence] = "w", + mapcoords: Any = None, + cmap: ( + str + | dict + | pygfx.resources._texture.Texture + | pygfx.resources._texturemap.TextureMap + | numpy.ndarray + ) = None, + clim: tuple[float, float] | None = None, + **kwargs, + ) -> PolygonGraphic: + """ + + Create a polygon mesh graphic. + + The data are always in the 'xy' plane. Set a rotation to display the polygon in another plane or in 3D space. + + Parameters + ---------- + data: array-like + The polygon vertices, must be of shape: [n_vertices, 2] + + mode: one of "basic", "phong", "slice", default "phong" + * basic: illuminate mesh with only ambient lighting + * phong: phong lighting model, good for most use cases, see https://en.wikipedia.org/wiki/Phong_shading + + colors: str, array, or iterable, default "w" + A uniform color, or the per-position colors. + + mapcoords: array-like + The per-position coordinates to which to apply the colormap (a.k.a. texcoords). + These can e.g. be some domain-specific value (mapped to [0..1] using ``clim``). + If not given, they will be the depth (z-coordinate) of the surface. + + cmap: str, optional + Apply a colormap to the mesh, this overrides any argument passed to + "colors". For supported colormaps see the ``cmap`` library + catalogue: https://cmap-docs.readthedocs.io/en/stable/catalog/ + Both 1D and 2D colormaps are supported, though the mapcoords has to match the dimensionality. + + clim: tuple[float, float] + The colormap limits. If the mapcoords has values between e.g. 5 and 90, you want to set the clim + to e.g. (5, 90) or (0, 100) to determine how the values map onto the colormap. + + **kwargs + passed to :class:`.Graphic` + + """ + return self._create_graphic( + PolygonGraphic, data, mode, colors, mapcoords, cmap, clim, **kwargs + ) + def add_scatter( self, data: Any, @@ -586,6 +724,64 @@ def add_scatter( **kwargs, ) + def add_surface( + self, + data: numpy.ndarray, + mode: Literal["basic", "phong", "slice"] = "phong", + colors: Union[str, numpy.ndarray, Sequence] = "w", + mapcoords: Any = None, + cmap: ( + str + | dict + | pygfx.resources._texture.Texture + | pygfx.resources._texturemap.TextureMap + | numpy.ndarray + ) = None, + clim: tuple[float, float] | None = None, + **kwargs, + ) -> SurfaceGraphic: + """ + + Create a Surface mesh Graphic + + Parameters + ---------- + data: array-like + A height-map (an image where the values indicate height, i.e. z values). + Can also be a [m, n, 3] to explicitly specify the x and y values in addition to the z values. + [m, n, 3] is a dstack of (x, y, z) values that form a grid on the xy plane. + + mode: one of "basic", "phong", "slice", default "phong" + * basic: illuminate mesh with only ambient lighting + * phong: phong lighting model, good for most use cases, see https://en.wikipedia.org/wiki/Phong_shading + + colors: str, array, or iterable, default "w" + A uniform color, or the per-position colors. + + mapcoords: array-like + The per-position coordinates to which to apply the colormap (a.k.a. texcoords). + These can e.g. be some domain-specific value (mapped to [0..1] using ``clim``). + If not given, they will be the depth (z-coordinate) of the surface. + + cmap: str, optional + Apply a colormap to the mesh, this overrides any argument passed to + "colors". For supported colormaps see the ``cmap`` library + catalogue: https://cmap-docs.readthedocs.io/en/stable/catalog/ + Both 1D and 2D colormaps are supported, though the mapcoords has to match the dimensionality. + + clim: tuple[float, float] + The colormap limits. If the mapcoords has values between e.g. 5 and 90, you want to set the clim + to e.g. (5, 90) or (0, 100) to determine how the values map onto the colormap. + + **kwargs + passed to :class:`.Graphic` + + + """ + return self._create_graphic( + SurfaceGraphic, data, mode, colors, mapcoords, cmap, clim, **kwargs + ) + def add_text( self, text: str, diff --git a/fastplotlib/layouts/_plot_area.py b/fastplotlib/layouts/_plot_area.py index 606f83909..01721780c 100644 --- a/fastplotlib/layouts/_plot_area.py +++ b/fastplotlib/layouts/_plot_area.py @@ -117,6 +117,12 @@ def __init__( self._background = pygfx.Background(None, self._background_material) self.scene.add(self._background) + self._ambient_light = pygfx.AmbientLight() + self._directional_light = pygfx.DirectionalLight() + + self.scene.add(self._ambient_light) + self.scene.add(self._camera.add(self._directional_light)) + def get_figure(self, obj=None): """Get Figure instance that contains this plot area""" if obj is None: @@ -166,6 +172,8 @@ def camera(self, new_camera: str | pygfx.PerspectiveCamera): # user wants to set completely new camera, remove current camera from controller if isinstance(new_camera, pygfx.PerspectiveCamera): self.controller.remove_camera(self._camera) + # add directional light to new camera + new_camera.add(self._directional_light) # add new camera to controller self.controller.add_camera(new_camera) @@ -274,6 +282,16 @@ def background_color(self, colors: str | tuple[float]): """1, 2, or 4 colors, each color must be acceptable by pygfx.Color""" self._background_material.set_colors(*colors) + @property + def ambient_light(self) -> pygfx.AmbientLight: + """the ambient lighting in the scene""" + return self._ambient_light + + @property + def directional_light(self) -> pygfx.DirectionalLight: + """the directional lighting on the camera in the scene""" + return self._directional_light + @property def animations(self) -> dict[str, list[callable]]: """Returns a dictionary of 'pre' and 'post' animation functions."""