From f1db32faae68e9c647d7d661724c8e1380e7210d Mon Sep 17 00:00:00 2001 From: Kushal Kolar Date: Sun, 16 Nov 2025 08:25:25 -0500 Subject: [PATCH 01/21] proper z layering for images (#944) --- fastplotlib/layouts/_plot_area.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fastplotlib/layouts/_plot_area.py b/fastplotlib/layouts/_plot_area.py index 8146a00de..606f83909 100644 --- a/fastplotlib/layouts/_plot_area.py +++ b/fastplotlib/layouts/_plot_area.py @@ -446,7 +446,7 @@ def _sort_images_by_depth(self): from the camera). """ count = 0 - for graphic in self._graphics: + for graphic in reversed(self._graphics): if isinstance(graphic, ImageGraphic): count += 1 auto_depth = -count From 7b4c68746ddb7028fb32ef3adb239506be5210dc Mon Sep 17 00:00:00 2001 From: Kushal Kolar Date: Sun, 16 Nov 2025 12:33:45 -0500 Subject: [PATCH 02/21] fix linear and linear region selectors offset when parent is None (#945) --- fastplotlib/graphics/selectors/_linear.py | 11 +++++++---- fastplotlib/graphics/selectors/_linear_region.py | 15 +++++++++------ 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/fastplotlib/graphics/selectors/_linear.py b/fastplotlib/graphics/selectors/_linear.py index 0364305a4..0c956d57b 100644 --- a/fastplotlib/graphics/selectors/_linear.py +++ b/fastplotlib/graphics/selectors/_linear.py @@ -183,10 +183,13 @@ def __init__( world_object.add(line_outer) world_object.add(line_inner) - if axis == "x": - offset = (parent.offset[0], 0, 0) - elif axis == "y": - offset = (0, parent.offset[1], 0) + if parent is None: + offset = (0, 0, 0) + else: + if axis == "x": + offset = (parent.offset[0], 0, 0) + elif axis == "y": + offset = (0, parent.offset[1], 0) # init base selector BaseSelector.__init__( diff --git a/fastplotlib/graphics/selectors/_linear_region.py b/fastplotlib/graphics/selectors/_linear_region.py index 9f5803c93..70a8dffa8 100644 --- a/fastplotlib/graphics/selectors/_linear_region.py +++ b/fastplotlib/graphics/selectors/_linear_region.py @@ -277,12 +277,15 @@ def __init__( outer_edges = (line0_outer, line1_outer) group.add(*edges, *outer_edges) - # TODO: if parent offset changes, we should set the selector offset too, use offset evented property - # TODO: add check if parent is `None`, will throw error otherwise - if axis == "x": - offset = (parent.offset[0], center + parent.offset[1], 0) - elif axis == "y": - offset = (center + parent.offset[1], parent.offset[1], 0) + if parent is None: + offset = (0, 0, 0) + else: + # TODO: if parent offset changes, we should set the selector offset too, use offset evented property + # TODO: add check if parent is `None`, will throw error otherwise + if axis == "x": + offset = (parent.offset[0], center + parent.offset[1], 0) + elif axis == "y": + offset = (center + parent.offset[1], parent.offset[1], 0) # set the initial bounds of the selector # compensate for any offset from the parent graphic From 9abe236407b175d5849a66b1e89bdf388177e6e9 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Thu, 4 Dec 2025 04:23:14 +0100 Subject: [PATCH 03/21] Polygon buffer also shrinks when it can (#958) * Polygon buffer also shrinks when it can. * fix for zero * remove unused import --------- Co-authored-by: kushalkolar --- examples/guis/sine_cosine_funcs.py | 1 - fastplotlib/graphics/features/_selection_features.py | 12 +++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/examples/guis/sine_cosine_funcs.py b/examples/guis/sine_cosine_funcs.py index f7dd064cf..935f9a5a1 100644 --- a/examples/guis/sine_cosine_funcs.py +++ b/examples/guis/sine_cosine_funcs.py @@ -9,7 +9,6 @@ # test_example = false # sphinx_gallery_pygfx_docs = 'screenshot' -import glfw import numpy as np import fastplotlib as fpl from fastplotlib.ui import EdgeWindow diff --git a/fastplotlib/graphics/features/_selection_features.py b/fastplotlib/graphics/features/_selection_features.py index 654b3d4c6..9b30dd70c 100644 --- a/fastplotlib/graphics/features/_selection_features.py +++ b/fastplotlib/graphics/features/_selection_features.py @@ -416,12 +416,14 @@ def set_value(self, selector, value: Sequence[tuple[float]]): geometry = selector.geometry - # Need larger buffer? - if len(value) > geometry.positions.nitems: - arr = np.zeros((geometry.positions.nitems * 2, 3), np.float32) + # Need larger (or smaller) buffer? Scale up/down with factors of 2. + need_position_size = 2 ** int(np.ceil(np.log2(max(8, len(value))))) + if need_position_size != geometry.positions.nitems: + arr = np.zeros((need_position_size, 3), np.float32) geometry.positions = gfx.Buffer(arr) - if len(indices) > geometry.indices.nitems: - arr = np.zeros((geometry.indices.nitems * 2, 3), np.int32) + 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 = gfx.Buffer(arr) geometry.positions.data[: len(value)] = value From db49c620bfe100bd8daa2b7ed3579982bcf2187e Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Thu, 4 Dec 2025 15:01:22 +0100 Subject: [PATCH 04/21] Add support for mesh and surface (#953) * Add support for mesh and surface * restore line example * Implement image-surface example * Fix 3D camera for mesh examples * Just remove selector logic for now * new examples in gallery * mesh with graphic features (#954) * mesh with gfeatures * surface graphic works * update, add PolygonGraphic, works * black * for docs and test screenshots * black * polygon data updating works * update * black * more examples * update add_graphic mixin * code review changes * fix * better polygon example * remove unused import * update graphics mixin * docs * black * don't need to set limit for docs gen * add missing comma * dont run terrain example * new screenshots * remove (i expect) unnecessary check * lights as properties * just use VertexPositions * update PolyData * I need to use precommit * update api docs --------- Co-authored-by: Kushal Kolar --- docs/source/api/graphic_features/MeshCmap.rst | 35 ++ .../api/graphic_features/MeshIndices.rst | 36 ++ .../api/graphic_features/SurfaceData.rst | 35 ++ docs/source/api/graphic_features/index.rst | 3 + docs/source/api/graphics/MeshGraphic.rst | 55 ++ docs/source/api/graphics/PolygonGraphic.rst | 56 +++ docs/source/api/graphics/SurfaceGraphic.rst | 56 +++ docs/source/api/graphics/index.rst | 3 + docs/source/api/layouts/subplot.rst | 5 + docs/source/conf.py | 14 +- docs/source/user_guide/event_tables.rst | 399 +++++++++++++++ examples/mesh/README.rst | 2 + examples/mesh/image_surface.py | 37 ++ examples/mesh/mesh.py | 36 ++ examples/mesh/polygon_animation.py | 76 +++ examples/mesh/polygons.py | 61 +++ examples/mesh/surface_earth.py | 95 ++++ examples/mesh/surface_ellipsoid.py | 55 ++ examples/mesh/surface_gaussian.py | 45 ++ examples/mesh/surface_height.py | 35 ++ examples/mesh/surface_ripple.py | 62 +++ examples/mesh/surface_sphere_ripple.py | 81 +++ examples/mesh/surface_terrain.py | 36 ++ examples/screenshots/image_surface.png | 3 + examples/screenshots/mesh.png | 3 + .../screenshots/no-imgui-image_surface.png | 3 + examples/screenshots/no-imgui-mesh.png | 3 + .../screenshots/no-imgui-surface_gaussian.png | 3 + .../screenshots/no-imgui-surface_height.png | 3 + .../screenshots/no-imgui-vectors_simple.png | 3 + .../screenshots/no-imgui-vectors_swirl.png | 3 + examples/screenshots/surface_gaussian.png | 3 + examples/screenshots/surface_height.png | 3 + examples/screenshots/vectors_simple.png | 3 + examples/screenshots/vectors_swirl.png | 3 + examples/tests/testutils.py | 3 +- fastplotlib/graphics/__init__.py | 4 + fastplotlib/graphics/features/__init__.py | 14 +- fastplotlib/graphics/features/_base.py | 6 + fastplotlib/graphics/features/_mesh.py | 284 +++++++++++ .../{_positions_graphics.py => _positions.py} | 2 - fastplotlib/graphics/features/utils.py | 5 +- fastplotlib/graphics/mesh.py | 473 ++++++++++++++++++ fastplotlib/layouts/_graphic_methods_mixin.py | 196 ++++++++ fastplotlib/layouts/_plot_area.py | 18 + 45 files changed, 2345 insertions(+), 14 deletions(-) create mode 100644 docs/source/api/graphic_features/MeshCmap.rst create mode 100644 docs/source/api/graphic_features/MeshIndices.rst create mode 100644 docs/source/api/graphic_features/SurfaceData.rst create mode 100644 docs/source/api/graphics/MeshGraphic.rst create mode 100644 docs/source/api/graphics/PolygonGraphic.rst create mode 100644 docs/source/api/graphics/SurfaceGraphic.rst create mode 100644 examples/mesh/README.rst create mode 100644 examples/mesh/image_surface.py create mode 100644 examples/mesh/mesh.py create mode 100644 examples/mesh/polygon_animation.py create mode 100644 examples/mesh/polygons.py create mode 100644 examples/mesh/surface_earth.py create mode 100644 examples/mesh/surface_ellipsoid.py create mode 100644 examples/mesh/surface_gaussian.py create mode 100644 examples/mesh/surface_height.py create mode 100644 examples/mesh/surface_ripple.py create mode 100644 examples/mesh/surface_sphere_ripple.py create mode 100644 examples/mesh/surface_terrain.py create mode 100644 examples/screenshots/image_surface.png create mode 100644 examples/screenshots/mesh.png create mode 100644 examples/screenshots/no-imgui-image_surface.png create mode 100644 examples/screenshots/no-imgui-mesh.png create mode 100644 examples/screenshots/no-imgui-surface_gaussian.png create mode 100644 examples/screenshots/no-imgui-surface_height.png create mode 100644 examples/screenshots/no-imgui-vectors_simple.png create mode 100644 examples/screenshots/no-imgui-vectors_swirl.png create mode 100644 examples/screenshots/surface_gaussian.png create mode 100644 examples/screenshots/surface_height.png create mode 100644 examples/screenshots/vectors_simple.png create mode 100644 examples/screenshots/vectors_swirl.png create mode 100644 fastplotlib/graphics/features/_mesh.py rename fastplotlib/graphics/features/{_positions_graphics.py => _positions.py} (99%) create mode 100644 fastplotlib/graphics/mesh.py 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.""" From 68dceee6d63334d098c0100854e0fbc9ab4cc3c6 Mon Sep 17 00:00:00 2001 From: Kushal Kolar Date: Tue, 16 Dec 2025 22:15:35 -0500 Subject: [PATCH 05/21] remove dim length checks in `iw.set_data()` (#965) * remove dim length checks in iw.set_data() * black --- fastplotlib/widgets/image_widget/_widget.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/fastplotlib/widgets/image_widget/_widget.py b/fastplotlib/widgets/image_widget/_widget.py index 715fe3489..86a01b083 100644 --- a/fastplotlib/widgets/image_widget/_widget.py +++ b/fastplotlib/widgets/image_widget/_widget.py @@ -966,10 +966,6 @@ def set_data( ] if max_lengths[scroll_dim] == np.inf: max_lengths[scroll_dim] = new_length - elif max_lengths[scroll_dim] != new_length: - raise ValueError( - f"New arrays have differing values along dim {scroll_dim}" - ) self._dims_max_bounds[scroll_dim] = max_lengths[scroll_dim] From 359770a4c79d680ea486a2433612f02b8b3b7b85 Mon Sep 17 00:00:00 2001 From: Flynn <75346097+FlynnOConnell@users.noreply.github.com> Date: Sun, 21 Dec 2025 14:05:08 -0500 Subject: [PATCH 06/21] center links and badges (#969) --- README.md | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 227d5bfb8..41c1ba72e 100644 --- a/README.md +++ b/README.md @@ -4,16 +4,20 @@ --- -[![CI](https://github.com/fastplotlib/fastplotlib/actions/workflows/ci.yml/badge.svg)](https://github.com/fastplotlib/fastplotlib/actions/workflows/ci.yml) -[![PyPI version](https://badge.fury.io/py/fastplotlib.svg)](https://badge.fury.io/py/fastplotlib) -[![Deploy docs](https://github.com/fastplotlib/fastplotlib/actions/workflows/docs-deploy.yml/badge.svg)](https://fastplotlib.org/ver/dev/) -[![DOI](https://zenodo.org/badge/485481453.svg)](https://zenodo.org/doi/10.5281/zenodo.13365890) - -[**Installation**](https://github.com/fastplotlib/fastplotlib#installation) | -[**GPU Drivers**](https://github.com/kushalkolar/fastplotlib#graphics-drivers) | -[**Documentation**](https://github.com/fastplotlib/fastplotlib#documentation) | -[**Examples**](https://github.com/kushalkolar/fastplotlib#examples) | -[**Contributing**](https://github.com/kushalkolar/fastplotlib#heart-contributing) +

+ CI + PyPI version + Deploy docs + DOI +

+ +

+ Installation | + GPU Drivers | + Documentation | + Examples | + Contributing +

Next-gen plotting library built using the [`pygfx`](https://github.com/pygfx/pygfx) rendering engine that utilizes [Vulkan](https://en.wikipedia.org/wiki/Vulkan), [DX12](https://en.wikipedia.org/wiki/DirectX#DirectX_12), or [Metal](https://developer.apple.com/metal/) via WGPU, so it is very fast! `fastplotlib` is an expressive plotting library that enables rapid prototyping for large scale exploratory scientific visualization. From 7075e46f79d23da43b2ca4658c15b5c8380c63d6 Mon Sep 17 00:00:00 2001 From: Kushal Kolar Date: Wed, 14 Jan 2026 12:18:46 -0500 Subject: [PATCH 07/21] cursor tool and better tooltips (#947) * new cursor tool, basics work * lint and update api docs * add custom tooltip to figure * example WIP * manual picking, world obj -> graphic mapping * fix * fix gc * stuff * cursor and tooltips refactor, basically works * much simpler tooltip and cursor * move TextBox and Tooltip, update API docs * cursor manages tooltips * update examples * black * cleanup * docstrings * update example * update example * add image space transforms examples * finish space transforms examples * typing * black * docstrings * update examples * textbox constructor takes args, docstrings * True -> true * update names * update cursor examples * new ground truth screenshots for transforms * black * update * revert persist kwarg in PlotArea animations stuff * fix * message for image volumes * Update fastplotlib/graphics/_base.py Co-authored-by: Kushal Kolar --------- Co-authored-by: clewis7 --- docs/source/api/graphic_features/Scale.rst | 35 ++ docs/source/api/graphic_features/index.rst | 1 + docs/source/api/graphics/Graphic.rst | 5 + docs/source/api/graphics/ImageGraphic.rst | 5 + .../api/graphics/ImageVolumeGraphic.rst | 5 + docs/source/api/graphics/LineCollection.rst | 5 + docs/source/api/graphics/LineGraphic.rst | 5 + docs/source/api/graphics/LineStack.rst | 5 + docs/source/api/graphics/MeshGraphic.rst | 5 + docs/source/api/graphics/PolygonGraphic.rst | 5 + docs/source/api/graphics/ScatterGraphic.rst | 5 + docs/source/api/graphics/SurfaceGraphic.rst | 5 + docs/source/api/graphics/TextGraphic.rst | 5 + docs/source/api/graphics/VectorsGraphic.rst | 5 + docs/source/api/layouts/figure.rst | 2 - docs/source/api/layouts/imgui_figure.rst | 2 - docs/source/api/layouts/subplot.rst | 3 + .../api/selectors/LinearRegionSelector.rst | 5 + docs/source/api/selectors/LinearSelector.rst | 5 + .../api/selectors/RectangleSelector.rst | 5 + docs/source/api/tools/Cursor.rst | 42 ++ docs/source/api/tools/HistogramLUTTool.rst | 5 + docs/source/api/tools/TextBox.rst | 38 ++ docs/source/api/tools/Tooltip.rst | 10 +- docs/source/api/tools/index.rst | 2 + docs/source/conf.py | 1 + docs/source/user_guide/event_tables.rst | 154 +++++++ docs/source/user_guide/guide.rst | 22 +- examples/line_collection/line_collection.py | 2 +- examples/line_collection/line_stack.py | 20 - examples/mesh/surface_ripple.py | 3 + examples/misc/cursor_transform.py | 54 +++ examples/misc/cursors.py | 48 ++ examples/misc/cursors_marker.py | 47 ++ examples/misc/tooltips.py | 54 --- examples/misc/tooltips_custom.py | 14 +- .../screenshots/no-imgui-rotation_image.png | 3 + .../screenshots/no-imgui-rotation_line.png | 3 + .../screenshots/no-imgui-scaling_image.png | 3 + .../screenshots/no-imgui-scaling_line.png | 3 + .../screenshots/no-imgui-translate_image.png | 3 + .../screenshots/no-imgui-translate_line.png | 3 + .../no-imgui-translation_scaling_image.png | 3 + .../no-imgui-translation_scaling_line.png | 3 + ...gui-translation_scaling_rotation_image.png | 3 + ...mgui-translation_scaling_rotation_line.png | 3 + examples/screenshots/rotation_image.png | 3 + examples/screenshots/rotation_line.png | 3 + examples/screenshots/scaling_image.png | 3 + examples/screenshots/scaling_line.png | 3 + examples/screenshots/translate_image.png | 3 + examples/screenshots/translate_line.png | 3 + .../screenshots/translation_scaling_image.png | 3 + .../screenshots/translation_scaling_line.png | 3 + .../translation_scaling_rotation_image.png | 3 + .../translation_scaling_rotation_line.png | 3 + examples/spaces_transforms/README.rst | 2 + examples/spaces_transforms/rotation_image.py | 94 ++++ examples/spaces_transforms/rotation_line.py | 89 ++++ examples/spaces_transforms/scaling_image.py | 94 ++++ examples/spaces_transforms/scaling_line.py | 89 ++++ examples/spaces_transforms/translate_image.py | 95 ++++ examples/spaces_transforms/translate_line.py | 90 ++++ .../translation_scaling_image.py | 99 +++++ .../translation_scaling_line.py | 94 ++++ .../translation_scaling_rotation_image.py | 102 +++++ .../translation_scaling_rotation_line.py | 99 +++++ examples/tests/testutils.py | 1 + fastplotlib/graphics/_base.py | 150 ++++++- fastplotlib/graphics/_collection_base.py | 13 +- fastplotlib/graphics/_positions_base.py | 8 + fastplotlib/graphics/_vectors.py | 12 +- fastplotlib/graphics/features/__init__.py | 3 +- fastplotlib/graphics/features/_common.py | 49 ++ fastplotlib/graphics/image.py | 22 + fastplotlib/graphics/image_volume.py | 15 + fastplotlib/graphics/mesh.py | 21 + .../graphics/selectors/_base_selector.py | 2 + fastplotlib/graphics/text.py | 2 + fastplotlib/layouts/_figure.py | 83 ++-- fastplotlib/layouts/_imgui_figure.py | 2 - fastplotlib/layouts/_plot_area.py | 139 +++++- fastplotlib/tools/__init__.py | 5 +- fastplotlib/tools/_cursor.py | 420 ++++++++++++++++++ fastplotlib/tools/_histogram_lut.py | 2 + .../tools/{_tooltip.py => _textbox.py} | 223 +++++----- 86 files changed, 2514 insertions(+), 299 deletions(-) create mode 100644 docs/source/api/graphic_features/Scale.rst create mode 100644 docs/source/api/tools/Cursor.rst create mode 100644 docs/source/api/tools/TextBox.rst create mode 100644 examples/misc/cursor_transform.py create mode 100644 examples/misc/cursors.py create mode 100644 examples/misc/cursors_marker.py delete mode 100644 examples/misc/tooltips.py create mode 100644 examples/screenshots/no-imgui-rotation_image.png create mode 100644 examples/screenshots/no-imgui-rotation_line.png create mode 100644 examples/screenshots/no-imgui-scaling_image.png create mode 100644 examples/screenshots/no-imgui-scaling_line.png create mode 100644 examples/screenshots/no-imgui-translate_image.png create mode 100644 examples/screenshots/no-imgui-translate_line.png create mode 100644 examples/screenshots/no-imgui-translation_scaling_image.png create mode 100644 examples/screenshots/no-imgui-translation_scaling_line.png create mode 100644 examples/screenshots/no-imgui-translation_scaling_rotation_image.png create mode 100644 examples/screenshots/no-imgui-translation_scaling_rotation_line.png create mode 100644 examples/screenshots/rotation_image.png create mode 100644 examples/screenshots/rotation_line.png create mode 100644 examples/screenshots/scaling_image.png create mode 100644 examples/screenshots/scaling_line.png create mode 100644 examples/screenshots/translate_image.png create mode 100644 examples/screenshots/translate_line.png create mode 100644 examples/screenshots/translation_scaling_image.png create mode 100644 examples/screenshots/translation_scaling_line.png create mode 100644 examples/screenshots/translation_scaling_rotation_image.png create mode 100644 examples/screenshots/translation_scaling_rotation_line.png create mode 100644 examples/spaces_transforms/README.rst create mode 100644 examples/spaces_transforms/rotation_image.py create mode 100644 examples/spaces_transforms/rotation_line.py create mode 100644 examples/spaces_transforms/scaling_image.py create mode 100644 examples/spaces_transforms/scaling_line.py create mode 100644 examples/spaces_transforms/translate_image.py create mode 100644 examples/spaces_transforms/translate_line.py create mode 100644 examples/spaces_transforms/translation_scaling_image.py create mode 100644 examples/spaces_transforms/translation_scaling_line.py create mode 100644 examples/spaces_transforms/translation_scaling_rotation_image.py create mode 100644 examples/spaces_transforms/translation_scaling_rotation_line.py create mode 100644 fastplotlib/tools/_cursor.py rename fastplotlib/tools/{_tooltip.py => _textbox.py} (55%) diff --git a/docs/source/api/graphic_features/Scale.rst b/docs/source/api/graphic_features/Scale.rst new file mode 100644 index 000000000..b0ef07a79 --- /dev/null +++ b/docs/source/api/graphic_features/Scale.rst @@ -0,0 +1,35 @@ +.. _api.Scale: + +Scale +***** + +===== +Scale +===== +.. currentmodule:: fastplotlib.graphics.features + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: Scale_api + + Scale + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: Scale_api + + Scale.value + +Methods +~~~~~~~ +.. autosummary:: + :toctree: Scale_api + + Scale.add_event_handler + Scale.block_events + Scale.clear_event_handlers + Scale.remove_event_handler + Scale.set_value + diff --git a/docs/source/api/graphic_features/index.rst b/docs/source/api/graphic_features/index.rst index cd11544be..71268ddab 100644 --- a/docs/source/api/graphic_features/index.rst +++ b/docs/source/api/graphic_features/index.rst @@ -48,6 +48,7 @@ Graphic Features Name Offset Rotation + Scale Alpha AlphaMode Visible diff --git a/docs/source/api/graphics/Graphic.rst b/docs/source/api/graphics/Graphic.rst index da6424e3e..f94892949 100644 --- a/docs/source/api/graphics/Graphic.rst +++ b/docs/source/api/graphics/Graphic.rst @@ -30,7 +30,9 @@ Properties Graphic.offset Graphic.right_click_menu Graphic.rotation + Graphic.scale Graphic.supported_events + Graphic.tooltip_format Graphic.visible Graphic.world_object @@ -42,6 +44,9 @@ Methods Graphic.add_axes Graphic.add_event_handler Graphic.clear_event_handlers + Graphic.format_pick_info + Graphic.map_model_to_world + Graphic.map_world_to_model Graphic.remove_event_handler Graphic.rotate diff --git a/docs/source/api/graphics/ImageGraphic.rst b/docs/source/api/graphics/ImageGraphic.rst index 457ba27ee..e6d02c54b 100644 --- a/docs/source/api/graphics/ImageGraphic.rst +++ b/docs/source/api/graphics/ImageGraphic.rst @@ -34,7 +34,9 @@ Properties ImageGraphic.offset ImageGraphic.right_click_menu ImageGraphic.rotation + ImageGraphic.scale ImageGraphic.supported_events + ImageGraphic.tooltip_format ImageGraphic.visible ImageGraphic.vmax ImageGraphic.vmin @@ -52,6 +54,9 @@ Methods ImageGraphic.add_polygon_selector ImageGraphic.add_rectangle_selector ImageGraphic.clear_event_handlers + ImageGraphic.format_pick_info + ImageGraphic.map_model_to_world + ImageGraphic.map_world_to_model ImageGraphic.remove_event_handler ImageGraphic.reset_vmin_vmax ImageGraphic.rotate diff --git a/docs/source/api/graphics/ImageVolumeGraphic.rst b/docs/source/api/graphics/ImageVolumeGraphic.rst index 8adbc7ac7..8031f12f1 100644 --- a/docs/source/api/graphics/ImageVolumeGraphic.rst +++ b/docs/source/api/graphics/ImageVolumeGraphic.rst @@ -37,11 +37,13 @@ Properties ImageVolumeGraphic.plane ImageVolumeGraphic.right_click_menu ImageVolumeGraphic.rotation + ImageVolumeGraphic.scale ImageVolumeGraphic.shininess ImageVolumeGraphic.step_size ImageVolumeGraphic.substep_size ImageVolumeGraphic.supported_events ImageVolumeGraphic.threshold + ImageVolumeGraphic.tooltip_format ImageVolumeGraphic.visible ImageVolumeGraphic.vmax ImageVolumeGraphic.vmin @@ -55,6 +57,9 @@ Methods ImageVolumeGraphic.add_axes ImageVolumeGraphic.add_event_handler ImageVolumeGraphic.clear_event_handlers + ImageVolumeGraphic.format_pick_info + ImageVolumeGraphic.map_model_to_world + ImageVolumeGraphic.map_world_to_model ImageVolumeGraphic.remove_event_handler ImageVolumeGraphic.reset_vmin_vmax ImageVolumeGraphic.rotate diff --git a/docs/source/api/graphics/LineCollection.rst b/docs/source/api/graphics/LineCollection.rst index ffbb52f2b..5d0603ab7 100644 --- a/docs/source/api/graphics/LineCollection.rst +++ b/docs/source/api/graphics/LineCollection.rst @@ -38,8 +38,10 @@ Properties LineCollection.right_click_menu LineCollection.rotation LineCollection.rotations + LineCollection.scale LineCollection.supported_events LineCollection.thickness + LineCollection.tooltip_format LineCollection.visible LineCollection.visibles LineCollection.world_object @@ -57,6 +59,9 @@ Methods LineCollection.add_polygon_selector LineCollection.add_rectangle_selector LineCollection.clear_event_handlers + LineCollection.format_pick_info + LineCollection.map_model_to_world + LineCollection.map_world_to_model LineCollection.remove_event_handler LineCollection.remove_graphic LineCollection.rotate diff --git a/docs/source/api/graphics/LineGraphic.rst b/docs/source/api/graphics/LineGraphic.rst index ddcb00c41..428e8ef56 100644 --- a/docs/source/api/graphics/LineGraphic.rst +++ b/docs/source/api/graphics/LineGraphic.rst @@ -33,9 +33,11 @@ Properties LineGraphic.offset LineGraphic.right_click_menu LineGraphic.rotation + LineGraphic.scale LineGraphic.size_space LineGraphic.supported_events LineGraphic.thickness + LineGraphic.tooltip_format LineGraphic.visible LineGraphic.world_object @@ -51,6 +53,9 @@ Methods LineGraphic.add_polygon_selector LineGraphic.add_rectangle_selector LineGraphic.clear_event_handlers + LineGraphic.format_pick_info + LineGraphic.map_model_to_world + LineGraphic.map_world_to_model LineGraphic.remove_event_handler LineGraphic.rotate diff --git a/docs/source/api/graphics/LineStack.rst b/docs/source/api/graphics/LineStack.rst index 4373454be..e7ac21343 100644 --- a/docs/source/api/graphics/LineStack.rst +++ b/docs/source/api/graphics/LineStack.rst @@ -38,8 +38,10 @@ Properties LineStack.right_click_menu LineStack.rotation LineStack.rotations + LineStack.scale LineStack.supported_events LineStack.thickness + LineStack.tooltip_format LineStack.visible LineStack.visibles LineStack.world_object @@ -57,6 +59,9 @@ Methods LineStack.add_polygon_selector LineStack.add_rectangle_selector LineStack.clear_event_handlers + LineStack.format_pick_info + LineStack.map_model_to_world + LineStack.map_world_to_model LineStack.remove_event_handler LineStack.remove_graphic LineStack.rotate diff --git a/docs/source/api/graphics/MeshGraphic.rst b/docs/source/api/graphics/MeshGraphic.rst index 5e2c5dac5..ec27f1e4e 100644 --- a/docs/source/api/graphics/MeshGraphic.rst +++ b/docs/source/api/graphics/MeshGraphic.rst @@ -38,7 +38,9 @@ Properties MeshGraphic.positions MeshGraphic.right_click_menu MeshGraphic.rotation + MeshGraphic.scale MeshGraphic.supported_events + MeshGraphic.tooltip_format MeshGraphic.visible MeshGraphic.world_object @@ -50,6 +52,9 @@ Methods MeshGraphic.add_axes MeshGraphic.add_event_handler MeshGraphic.clear_event_handlers + MeshGraphic.format_pick_info + MeshGraphic.map_model_to_world + MeshGraphic.map_world_to_model MeshGraphic.remove_event_handler MeshGraphic.rotate diff --git a/docs/source/api/graphics/PolygonGraphic.rst b/docs/source/api/graphics/PolygonGraphic.rst index f9446f425..94c75f999 100644 --- a/docs/source/api/graphics/PolygonGraphic.rst +++ b/docs/source/api/graphics/PolygonGraphic.rst @@ -39,7 +39,9 @@ Properties PolygonGraphic.positions PolygonGraphic.right_click_menu PolygonGraphic.rotation + PolygonGraphic.scale PolygonGraphic.supported_events + PolygonGraphic.tooltip_format PolygonGraphic.visible PolygonGraphic.world_object @@ -51,6 +53,9 @@ Methods PolygonGraphic.add_axes PolygonGraphic.add_event_handler PolygonGraphic.clear_event_handlers + PolygonGraphic.format_pick_info + PolygonGraphic.map_model_to_world + PolygonGraphic.map_world_to_model PolygonGraphic.remove_event_handler PolygonGraphic.rotate diff --git a/docs/source/api/graphics/ScatterGraphic.rst b/docs/source/api/graphics/ScatterGraphic.rst index 7f4336abe..cf8e1224d 100644 --- a/docs/source/api/graphics/ScatterGraphic.rst +++ b/docs/source/api/graphics/ScatterGraphic.rst @@ -40,9 +40,11 @@ Properties ScatterGraphic.point_rotations ScatterGraphic.right_click_menu ScatterGraphic.rotation + ScatterGraphic.scale ScatterGraphic.size_space ScatterGraphic.sizes ScatterGraphic.supported_events + ScatterGraphic.tooltip_format ScatterGraphic.visible ScatterGraphic.world_object @@ -54,6 +56,9 @@ Methods ScatterGraphic.add_axes ScatterGraphic.add_event_handler ScatterGraphic.clear_event_handlers + ScatterGraphic.format_pick_info + ScatterGraphic.map_model_to_world + ScatterGraphic.map_world_to_model ScatterGraphic.remove_event_handler ScatterGraphic.rotate diff --git a/docs/source/api/graphics/SurfaceGraphic.rst b/docs/source/api/graphics/SurfaceGraphic.rst index 385ce2432..228dbede1 100644 --- a/docs/source/api/graphics/SurfaceGraphic.rst +++ b/docs/source/api/graphics/SurfaceGraphic.rst @@ -39,7 +39,9 @@ Properties SurfaceGraphic.positions SurfaceGraphic.right_click_menu SurfaceGraphic.rotation + SurfaceGraphic.scale SurfaceGraphic.supported_events + SurfaceGraphic.tooltip_format SurfaceGraphic.visible SurfaceGraphic.world_object @@ -51,6 +53,9 @@ Methods SurfaceGraphic.add_axes SurfaceGraphic.add_event_handler SurfaceGraphic.clear_event_handlers + SurfaceGraphic.format_pick_info + SurfaceGraphic.map_model_to_world + SurfaceGraphic.map_world_to_model SurfaceGraphic.remove_event_handler SurfaceGraphic.rotate diff --git a/docs/source/api/graphics/TextGraphic.rst b/docs/source/api/graphics/TextGraphic.rst index 0de52942b..da4909686 100644 --- a/docs/source/api/graphics/TextGraphic.rst +++ b/docs/source/api/graphics/TextGraphic.rst @@ -34,8 +34,10 @@ Properties TextGraphic.outline_thickness TextGraphic.right_click_menu TextGraphic.rotation + TextGraphic.scale TextGraphic.supported_events TextGraphic.text + TextGraphic.tooltip_format TextGraphic.visible TextGraphic.world_object @@ -47,6 +49,9 @@ Methods TextGraphic.add_axes TextGraphic.add_event_handler TextGraphic.clear_event_handlers + TextGraphic.format_pick_info + TextGraphic.map_model_to_world + TextGraphic.map_world_to_model TextGraphic.remove_event_handler TextGraphic.rotate diff --git a/docs/source/api/graphics/VectorsGraphic.rst b/docs/source/api/graphics/VectorsGraphic.rst index 4a629f5db..ec7d891c0 100644 --- a/docs/source/api/graphics/VectorsGraphic.rst +++ b/docs/source/api/graphics/VectorsGraphic.rst @@ -32,7 +32,9 @@ Properties VectorsGraphic.positions VectorsGraphic.right_click_menu VectorsGraphic.rotation + VectorsGraphic.scale VectorsGraphic.supported_events + VectorsGraphic.tooltip_format VectorsGraphic.visible VectorsGraphic.world_object @@ -44,6 +46,9 @@ Methods VectorsGraphic.add_axes VectorsGraphic.add_event_handler VectorsGraphic.clear_event_handlers + VectorsGraphic.format_pick_info + VectorsGraphic.map_model_to_world + VectorsGraphic.map_world_to_model VectorsGraphic.remove_event_handler VectorsGraphic.rotate diff --git a/docs/source/api/layouts/figure.rst b/docs/source/api/layouts/figure.rst index e306710be..54e91b24f 100644 --- a/docs/source/api/layouts/figure.rst +++ b/docs/source/api/layouts/figure.rst @@ -28,8 +28,6 @@ Properties Figure.names Figure.renderer Figure.shape - Figure.show_tooltips - Figure.tooltip_manager Methods ~~~~~~~ diff --git a/docs/source/api/layouts/imgui_figure.rst b/docs/source/api/layouts/imgui_figure.rst index 959a98743..46e0c6ed3 100644 --- a/docs/source/api/layouts/imgui_figure.rst +++ b/docs/source/api/layouts/imgui_figure.rst @@ -31,8 +31,6 @@ Properties ImguiFigure.names ImguiFigure.renderer ImguiFigure.shape - ImguiFigure.show_tooltips - ImguiFigure.tooltip_manager Methods ~~~~~~~ diff --git a/docs/source/api/layouts/subplot.rst b/docs/source/api/layouts/subplot.rst index 93db00a2e..0916859b9 100644 --- a/docs/source/api/layouts/subplot.rst +++ b/docs/source/api/layouts/subplot.rst @@ -40,6 +40,7 @@ Properties Subplot.selectors Subplot.title Subplot.toolbar + Subplot.tooltip Subplot.viewport Methods @@ -67,8 +68,10 @@ Methods Subplot.clear_animations Subplot.delete_graphic Subplot.get_figure + Subplot.get_pick_info Subplot.insert_graphic Subplot.map_screen_to_world + Subplot.map_world_to_screen Subplot.remove_animation Subplot.remove_graphic diff --git a/docs/source/api/selectors/LinearRegionSelector.rst b/docs/source/api/selectors/LinearRegionSelector.rst index 35b5ae1f4..eb48497cd 100644 --- a/docs/source/api/selectors/LinearRegionSelector.rst +++ b/docs/source/api/selectors/LinearRegionSelector.rst @@ -35,8 +35,10 @@ Properties LinearRegionSelector.parent LinearRegionSelector.right_click_menu LinearRegionSelector.rotation + LinearRegionSelector.scale LinearRegionSelector.selection LinearRegionSelector.supported_events + LinearRegionSelector.tooltip_format LinearRegionSelector.vertex_color LinearRegionSelector.visible LinearRegionSelector.world_object @@ -49,9 +51,12 @@ Methods LinearRegionSelector.add_axes LinearRegionSelector.add_event_handler LinearRegionSelector.clear_event_handlers + LinearRegionSelector.format_pick_info LinearRegionSelector.get_selected_data LinearRegionSelector.get_selected_index LinearRegionSelector.get_selected_indices + LinearRegionSelector.map_model_to_world + LinearRegionSelector.map_world_to_model LinearRegionSelector.remove_event_handler LinearRegionSelector.rotate diff --git a/docs/source/api/selectors/LinearSelector.rst b/docs/source/api/selectors/LinearSelector.rst index 9cbe6fb26..2aa334748 100644 --- a/docs/source/api/selectors/LinearSelector.rst +++ b/docs/source/api/selectors/LinearSelector.rst @@ -35,8 +35,10 @@ Properties LinearSelector.parent LinearSelector.right_click_menu LinearSelector.rotation + LinearSelector.scale LinearSelector.selection LinearSelector.supported_events + LinearSelector.tooltip_format LinearSelector.vertex_color LinearSelector.visible LinearSelector.world_object @@ -49,9 +51,12 @@ Methods LinearSelector.add_axes LinearSelector.add_event_handler LinearSelector.clear_event_handlers + LinearSelector.format_pick_info LinearSelector.get_selected_data LinearSelector.get_selected_index LinearSelector.get_selected_indices + LinearSelector.map_model_to_world + LinearSelector.map_world_to_model LinearSelector.remove_event_handler LinearSelector.rotate diff --git a/docs/source/api/selectors/RectangleSelector.rst b/docs/source/api/selectors/RectangleSelector.rst index dc9727069..51f6801a4 100644 --- a/docs/source/api/selectors/RectangleSelector.rst +++ b/docs/source/api/selectors/RectangleSelector.rst @@ -35,8 +35,10 @@ Properties RectangleSelector.parent RectangleSelector.right_click_menu RectangleSelector.rotation + RectangleSelector.scale RectangleSelector.selection RectangleSelector.supported_events + RectangleSelector.tooltip_format RectangleSelector.vertex_color RectangleSelector.visible RectangleSelector.world_object @@ -49,9 +51,12 @@ Methods RectangleSelector.add_axes RectangleSelector.add_event_handler RectangleSelector.clear_event_handlers + RectangleSelector.format_pick_info RectangleSelector.get_selected_data RectangleSelector.get_selected_index RectangleSelector.get_selected_indices + RectangleSelector.map_model_to_world + RectangleSelector.map_world_to_model RectangleSelector.remove_event_handler RectangleSelector.rotate diff --git a/docs/source/api/tools/Cursor.rst b/docs/source/api/tools/Cursor.rst new file mode 100644 index 000000000..37a706d34 --- /dev/null +++ b/docs/source/api/tools/Cursor.rst @@ -0,0 +1,42 @@ +.. _api.Cursor: + +Cursor +****** + +====== +Cursor +====== +.. currentmodule:: fastplotlib + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: Cursor_api + + Cursor + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: Cursor_api + + Cursor.alpha + Cursor.color + Cursor.edge_color + Cursor.edge_width + Cursor.enabled + Cursor.marker + Cursor.mode + Cursor.position + Cursor.size + Cursor.size_space + +Methods +~~~~~~~ +.. autosummary:: + :toctree: Cursor_api + + Cursor.add_subplot + Cursor.clear + Cursor.remove_subplot + diff --git a/docs/source/api/tools/HistogramLUTTool.rst b/docs/source/api/tools/HistogramLUTTool.rst index 429f958e2..b3498dd68 100644 --- a/docs/source/api/tools/HistogramLUTTool.rst +++ b/docs/source/api/tools/HistogramLUTTool.rst @@ -32,7 +32,9 @@ Properties HistogramLUTTool.offset HistogramLUTTool.right_click_menu HistogramLUTTool.rotation + HistogramLUTTool.scale HistogramLUTTool.supported_events + HistogramLUTTool.tooltip_format HistogramLUTTool.visible HistogramLUTTool.vmax HistogramLUTTool.vmin @@ -46,6 +48,9 @@ Methods HistogramLUTTool.add_axes HistogramLUTTool.add_event_handler HistogramLUTTool.clear_event_handlers + HistogramLUTTool.format_pick_info + HistogramLUTTool.map_model_to_world + HistogramLUTTool.map_world_to_model HistogramLUTTool.remove_event_handler HistogramLUTTool.rotate HistogramLUTTool.set_data diff --git a/docs/source/api/tools/TextBox.rst b/docs/source/api/tools/TextBox.rst new file mode 100644 index 000000000..b202f4270 --- /dev/null +++ b/docs/source/api/tools/TextBox.rst @@ -0,0 +1,38 @@ +.. _api.TextBox: + +TextBox +******* + +======= +TextBox +======= +.. currentmodule:: fastplotlib + +Constructor +~~~~~~~~~~~ +.. autosummary:: + :toctree: TextBox_api + + TextBox + +Properties +~~~~~~~~~~ +.. autosummary:: + :toctree: TextBox_api + + TextBox.background_color + TextBox.font_size + TextBox.outline_color + TextBox.padding + TextBox.position + TextBox.text_color + TextBox.visible + +Methods +~~~~~~~ +.. autosummary:: + :toctree: TextBox_api + + TextBox.clear + TextBox.display + diff --git a/docs/source/api/tools/Tooltip.rst b/docs/source/api/tools/Tooltip.rst index 71607bf20..8e017370e 100644 --- a/docs/source/api/tools/Tooltip.rst +++ b/docs/source/api/tools/Tooltip.rst @@ -21,18 +21,20 @@ Properties :toctree: Tooltip_api Tooltip.background_color + Tooltip.continuous_update + Tooltip.enabled Tooltip.font_size Tooltip.outline_color Tooltip.padding + Tooltip.position Tooltip.text_color - Tooltip.world_object + Tooltip.visible Methods ~~~~~~~ .. autosummary:: :toctree: Tooltip_api - Tooltip.register - Tooltip.unregister - Tooltip.unregister_all + Tooltip.clear + Tooltip.display diff --git a/docs/source/api/tools/index.rst b/docs/source/api/tools/index.rst index c2666ed28..2bff8fb50 100644 --- a/docs/source/api/tools/index.rst +++ b/docs/source/api/tools/index.rst @@ -5,4 +5,6 @@ Tools :maxdepth: 1 HistogramLUTTool + TextBox Tooltip + Cursor diff --git a/docs/source/conf.py b/docs/source/conf.py index 8547e9ae7..edc172dad 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -68,6 +68,7 @@ "../../examples/text", "../../examples/events", "../../examples/selection_tools", + "../../examples/spaces_transforms", "../../examples/machine_learning", "../../examples/guis", "../../examples/ipywidgets", diff --git a/docs/source/user_guide/event_tables.rst b/docs/source/user_guide/event_tables.rst index ba53c3411..42f168bea 100644 --- a/docs/source/user_guide/event_tables.rst +++ b/docs/source/user_guide/event_tables.rst @@ -113,6 +113,17 @@ rotation | value | np.ndarray[float, float, float, float] | new rotation quaternion | +----------+----------------------------------------+-------------------------+ +scale +^^^^^ + +**event info dict** + ++----------+----------------------------------------+-------------+ +| dict key | type | description | ++==========+========================================+=============+ +| value | np.ndarray[float, float, float, float] | new scale | ++----------+----------------------------------------+-------------+ + alpha ^^^^^ @@ -378,6 +389,17 @@ rotation | value | np.ndarray[float, float, float, float] | new rotation quaternion | +----------+----------------------------------------+-------------------------+ +scale +^^^^^ + +**event info dict** + ++----------+----------------------------------------+-------------+ +| dict key | type | description | ++==========+========================================+=============+ +| value | np.ndarray[float, float, float, float] | new scale | ++----------+----------------------------------------+-------------+ + alpha ^^^^^ @@ -526,6 +548,17 @@ rotation | value | np.ndarray[float, float, float, float] | new rotation quaternion | +----------+----------------------------------------+-------------------------+ +scale +^^^^^ + +**event info dict** + ++----------+----------------------------------------+-------------+ +| dict key | type | description | ++==========+========================================+=============+ +| value | np.ndarray[float, float, float, float] | new scale | ++----------+----------------------------------------+-------------+ + alpha ^^^^^ @@ -751,6 +784,17 @@ rotation | value | np.ndarray[float, float, float, float] | new rotation quaternion | +----------+----------------------------------------+-------------------------+ +scale +^^^^^ + +**event info dict** + ++----------+----------------------------------------+-------------+ +| dict key | type | description | ++==========+========================================+=============+ +| value | np.ndarray[float, float, float, float] | new scale | ++----------+----------------------------------------+-------------+ + alpha ^^^^^ @@ -853,6 +897,17 @@ rotation | value | np.ndarray[float, float, float, float] | new rotation quaternion | +----------+----------------------------------------+-------------------------+ +scale +^^^^^ + +**event info dict** + ++----------+----------------------------------------+-------------+ +| dict key | type | description | ++==========+========================================+=============+ +| value | np.ndarray[float, float, float, float] | new scale | ++----------+----------------------------------------+-------------+ + alpha ^^^^^ @@ -996,6 +1051,17 @@ rotation | value | np.ndarray[float, float, float, float] | new rotation quaternion | +----------+----------------------------------------+-------------------------+ +scale +^^^^^ + +**event info dict** + ++----------+----------------------------------------+-------------+ +| dict key | type | description | ++==========+========================================+=============+ +| value | np.ndarray[float, float, float, float] | new scale | ++----------+----------------------------------------+-------------+ + alpha ^^^^^ @@ -1124,6 +1190,17 @@ rotation | value | np.ndarray[float, float, float, float] | new rotation quaternion | +----------+----------------------------------------+-------------------------+ +scale +^^^^^ + +**event info dict** + ++----------+----------------------------------------+-------------+ +| dict key | type | description | ++==========+========================================+=============+ +| value | np.ndarray[float, float, float, float] | new scale | ++----------+----------------------------------------+-------------+ + alpha ^^^^^ @@ -1252,6 +1329,17 @@ rotation | value | np.ndarray[float, float, float, float] | new rotation quaternion | +----------+----------------------------------------+-------------------------+ +scale +^^^^^ + +**event info dict** + ++----------+----------------------------------------+-------------+ +| dict key | type | description | ++==========+========================================+=============+ +| value | np.ndarray[float, float, float, float] | new scale | ++----------+----------------------------------------+-------------+ + alpha ^^^^^ @@ -1387,6 +1475,17 @@ rotation | value | np.ndarray[float, float, float, float] | new rotation quaternion | +----------+----------------------------------------+-------------------------+ +scale +^^^^^ + +**event info dict** + ++----------+----------------------------------------+-------------+ +| dict key | type | description | ++==========+========================================+=============+ +| value | np.ndarray[float, float, float, float] | new scale | ++----------+----------------------------------------+-------------+ + alpha ^^^^^ @@ -1541,6 +1640,17 @@ rotation | value | np.ndarray[float, float, float, float] | new rotation quaternion | +----------+----------------------------------------+-------------------------+ +scale +^^^^^ + +**event info dict** + ++----------+----------------------------------------+-------------+ +| dict key | type | description | ++==========+========================================+=============+ +| value | np.ndarray[float, float, float, float] | new scale | ++----------+----------------------------------------+-------------+ + alpha ^^^^^ @@ -1695,6 +1805,17 @@ rotation | value | np.ndarray[float, float, float, float] | new rotation quaternion | +----------+----------------------------------------+-------------------------+ +scale +^^^^^ + +**event info dict** + ++----------+----------------------------------------+-------------+ +| dict key | type | description | ++==========+========================================+=============+ +| value | np.ndarray[float, float, float, float] | new scale | ++----------+----------------------------------------+-------------+ + alpha ^^^^^ @@ -1794,6 +1915,17 @@ rotation | value | np.ndarray[float, float, float, float] | new rotation quaternion | +----------+----------------------------------------+-------------------------+ +scale +^^^^^ + +**event info dict** + ++----------+----------------------------------------+-------------+ +| dict key | type | description | ++==========+========================================+=============+ +| value | np.ndarray[float, float, float, float] | new scale | ++----------+----------------------------------------+-------------+ + alpha ^^^^^ @@ -1895,6 +2027,17 @@ rotation | value | np.ndarray[float, float, float, float] | new rotation quaternion | +----------+----------------------------------------+-------------------------+ +scale +^^^^^ + +**event info dict** + ++----------+----------------------------------------+-------------+ +| dict key | type | description | ++==========+========================================+=============+ +| value | np.ndarray[float, float, float, float] | new scale | ++----------+----------------------------------------+-------------+ + alpha ^^^^^ @@ -1996,6 +2139,17 @@ rotation | value | np.ndarray[float, float, float, float] | new rotation quaternion | +----------+----------------------------------------+-------------------------+ +scale +^^^^^ + +**event info dict** + ++----------+----------------------------------------+-------------+ +| dict key | type | description | ++==========+========================================+=============+ +| value | np.ndarray[float, float, float, float] | new scale | ++----------+----------------------------------------+-------------+ + alpha ^^^^^ diff --git a/docs/source/user_guide/guide.rst b/docs/source/user_guide/guide.rst index 8bf255507..bd0352aa7 100644 --- a/docs/source/user_guide/guide.rst +++ b/docs/source/user_guide/guide.rst @@ -648,23 +648,29 @@ There are several spaces to consider when using ``fastplotlib``: World space is the 3D space in which graphical objects live. Objects and the camera can exist anywhere in this space. -2) Data Space +2) Model or Data Space - Data space is simply the world space plus any offset or rotation that has been applied to an object. + Model/Data space is simply the world space plus any offset, scaling and rotation that has been applied to an object. .. note:: - World space does not always correspond directly to data space, you may have to adjust for any offset or rotation of the ``Graphic``. + World space does not always correspond directly to data space, + you may have to adjust for any offset, rotation, and scaling of the ``Graphic``. See below. 3) Screen Space Screen space is the 2D space in which your screen pixels reside. This space is constrained by the screen width and height in pixels. In the rendering process, the camera is responsible for projecting the world space into screen space. -.. note:: - When interacting with ``Graphic`` objects, there is a very helpful function for mapping screen space to world space - (``Figure.map_screen_to_world(pos=(x, y))``). This can be particularly useful when working with click events where click - positions are returned in screen space but ``Graphic`` objects that you may want to interact with exist in world - space. +When interacting with ``Graphic`` objects, there are helpful functions for mapping between these spaces: + - ``Subplot.map_screen_to_world((x, y))`` + - ``Subplot.map_world_to_screen((x, y, z))`` + - ``Graphic.map_model_to_world((x, y, z))`` + - ``Graphic.map_world_to_model((x, y, z))`` + +This can be particularly useful when working with click events where click positions are returned in screen space but + ``Graphic`` objects that you may want to interact with exist in world space. It can also be useful for determining + the screen/canvas pixel position of a datapoint on a graphic by mapping: model -> world -> screen. The entire inverse + transform can also be performed, screen -> world -> model. For more information on the various spaces used by rendering engines please see this `article `_ diff --git a/examples/line_collection/line_collection.py b/examples/line_collection/line_collection.py index 2ddfbe2ed..e3eea7392 100644 --- a/examples/line_collection/line_collection.py +++ b/examples/line_collection/line_collection.py @@ -29,7 +29,7 @@ def make_circle(center, radius: float, n_points: int = 75) -> np.ndarray: pos_xy = np.vstack(circles) -figure = fpl.Figure(size=(700, 560), show_tooltips=True) +figure = fpl.Figure(size=(700, 560)) figure[0, 0].add_line_collection(circles, cmap="jet", thickness=5) diff --git a/examples/line_collection/line_stack.py b/examples/line_collection/line_stack.py index 829708cb7..4376c18b4 100644 --- a/examples/line_collection/line_stack.py +++ b/examples/line_collection/line_stack.py @@ -21,7 +21,6 @@ figure = fpl.Figure( size=(700, 560), - show_tooltips=True ) line_stack = figure[0, 0].add_line_stack( @@ -32,25 +31,6 @@ ) -def tooltip_info(ev): - """A custom function to display the index of the graphic within the collection.""" - index = ev.pick_info["vertex_index"] # index of the line datapoint being hovered - - # get index of the hovered line within the line stack - line_index = np.where(line_stack.graphics == ev.graphic)[0].item() - info = f"line index: {line_index}\n" - - # append data value info - info += "\n".join(f"{dim}: {val}" for dim, val in zip("xyz", ev.graphic.data[index])) - - # return str to display in tooltip - return info - -# register the line stack with the custom tooltip function -figure.tooltip_manager.register( - line_stack, custom_info=tooltip_info -) - figure.show(maintain_aspect=False) diff --git a/examples/mesh/surface_ripple.py b/examples/mesh/surface_ripple.py index ac556bd1b..1adf676ea 100644 --- a/examples/mesh/surface_ripple.py +++ b/examples/mesh/surface_ripple.py @@ -34,6 +34,9 @@ def create_ripple(shape=(100, 100), phase=0.0, freq=np.pi / 4, ampl=1.0): z, mode="basic", cmap="viridis", clim=(-max_z, max_z) ) +# enable continuous updates for the tooltip +figure[0, 0].tooltip.continuous_update = True + figure[0, 0].camera.show_object(surface.world_object, (-1, 3, -1), up=(0, 0, 1)) figure.show() diff --git a/examples/misc/cursor_transform.py b/examples/misc/cursor_transform.py new file mode 100644 index 000000000..46478d8ce --- /dev/null +++ b/examples/misc/cursor_transform.py @@ -0,0 +1,54 @@ +""" +Cursor transform +================ + +Create a cursor and add them to subplots with a transform function. A common usecase is image registration. +""" + +# test_example = False +# sphinx_gallery_pygfx_docs = 'screenshot' + +import numpy as np +import fastplotlib as fpl +import imageio.v3 as iio + + +# get an image +img1 = iio.imread("imageio:camera.png") + +# create another image, but it is offset +img2 = np.zeros(img1.shape) +img2[50:, 20:] = img1[:-50, :-20] + +figure = fpl.Figure((1, 2), size=(700, 450)) + +# add images +figure[0, 0].add_image(img1) +figure[0, 1].add_image(img2) + +# create cursor +cursor = fpl.Cursor("crosshair") + +# add first subplot to cursor +cursor.add_subplot(figure[0, 0]) + +# a transform function for subplot 2 to indicate that the data is shifted +def transform_func(pos): + return (pos[0] + 20, pos[1] + 50) + +# add second subplot with a transform +cursor.add_subplot(figure[0, 1], transform=transform_func) + +figure.show() + +# you can programmatically set cursor position +cursor.position = (400, 120) + +# you can hide the canvas cursor, this is different and has nothing to do with the fastplotlib Cursor! +figure.canvas.set_cursor("none") + +# 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/misc/cursors.py b/examples/misc/cursors.py new file mode 100644 index 000000000..030c254a4 --- /dev/null +++ b/examples/misc/cursors.py @@ -0,0 +1,48 @@ +""" +Cursor tool +=========== + +Example with multiple subplots and an interactive cursor that marks the same position in each subplot. +Default crosshair mode. +""" + +# test_example = False +# sphinx_gallery_pygfx_docs = 'screenshot' + +import numpy as np +import fastplotlib as fpl +import imageio.v3 as iio + + +# get some data +img1 = iio.imread("imageio:camera.png") +img2 = iio.imread("imageio:wikkie.png") +scatter_data = np.random.normal(loc=256, scale=(50), size=(500)).reshape(250, 2) +line_data = np.random.rand(100, 2) * 512 + +# create a figure +figure = fpl.Figure(shape=(2, 2), size=(700, 750)) + +# plot data +figure[0, 0].add_image(img1, cmap="viridis") +figure[0, 1].add_image(img2) +figure[1, 0].add_scatter(scatter_data, sizes=5, colors="r") +figure[1, 1].add_line(line_data, colors="r") + +# creator a cursor in crosshair mode +cursor = fpl.Cursor(color="w") + +# add all subplots to the cursor +for subplot in figure: + cursor.add_subplot(subplot) + +# you can also set the cursor position programmatically +cursor.position = (256, 256) + +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/misc/cursors_marker.py b/examples/misc/cursors_marker.py new file mode 100644 index 000000000..1b5437fe4 --- /dev/null +++ b/examples/misc/cursors_marker.py @@ -0,0 +1,47 @@ +""" +Cursor tool, marker mode +======================== + +Example with multiple subplots and an interactive cursor that marks the same position in each subplot. Marker mode. +""" + +# test_example = False +# sphinx_gallery_pygfx_docs = 'screenshot' + +import numpy as np +import fastplotlib as fpl +import imageio.v3 as iio + + +# get some data +img1 = iio.imread("imageio:camera.png") +img2 = iio.imread("imageio:wikkie.png") +scatter_data = np.random.normal(loc=256, scale=(50), size=(500)).reshape(250, 2) +line_data = np.random.rand(100, 2) * 512 + +# create a figure +figure = fpl.Figure(shape=(2, 2), size=(700, 750)) + +# plot data +figure[0, 0].add_image(img1, cmap="viridis") +figure[0, 1].add_image(img2) +figure[1, 0].add_scatter(scatter_data, sizes=5, colors="r") +figure[1, 1].add_line(line_data, colors="r") + +# creator a cursor in crosshair mode +cursor = fpl.Cursor(mode="marker", color="w", size=15) + +# add all subplots to the cursor +for subplot in figure: + cursor.add_subplot(subplot) + +# you can also set the cursor position programmatically +cursor.position = (256, 256) + +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/misc/tooltips.py b/examples/misc/tooltips.py deleted file mode 100644 index cad3d807c..000000000 --- a/examples/misc/tooltips.py +++ /dev/null @@ -1,54 +0,0 @@ -""" -Tooltips -======== - -Show tooltips on all graphics -""" - -# test_example = false -# sphinx_gallery_pygfx_docs = 'screenshot' - -import numpy as np -import imageio.v3 as iio -import fastplotlib as fpl - - -# get some data -scatter_data = np.random.rand(1_000, 3) - -xs = np.linspace(0, 2 * np.pi, 100) -ys = np.sin(xs) - -gray = iio.imread("imageio:camera.png") -rgb = iio.imread("imageio:astronaut.png") - -# create a figure -figure = fpl.Figure( - cameras=["3d", "2d", "2d", "2d"], - controller_types=["orbit", "panzoom", "panzoom", "panzoom"], - size=(700, 560), - shape=(2, 2), - show_tooltips=True, # tooltip will display data value info for all graphics -) - -# create graphics -scatter = figure[0, 0].add_scatter(scatter_data, sizes=3, colors="r") -line = figure[0, 1].add_line(np.column_stack([xs, ys])) -image = figure[1, 0].add_image(gray) -image_rgb = figure[1, 1].add_image(rgb) - - -figure.show() - -# to hide tooltips for all graphics in an existing Figure -# figure.show_tooltips = False - -# to show tooltips for all graphics in an existing Figure -# figure.show_tooltips = True - - -# 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/misc/tooltips_custom.py b/examples/misc/tooltips_custom.py index d1cc1e297..3a54a945b 100644 --- a/examples/misc/tooltips_custom.py +++ b/examples/misc/tooltips_custom.py @@ -31,20 +31,26 @@ ) -def tooltip_info(ev) -> str: +def tooltip_info(pick_info: dict) -> str: # get index of the scatter point that is being hovered - index = ev.pick_info["vertex_index"] + index = pick_info["vertex_index"] # get the species name target = dataset["target"][index] cluster = agg.labels_[index] - info = f"species: {dataset['target_names'][target]}\ncluster: {cluster}" + + # the default formatting of the pick info + default_info = scatter.format_pick_info(pick_info) + + info = (f"species: {dataset['target_names'][target]}\n" + f"cluster: {cluster}\n\n" + f"{default_info}") # return this string to display it in the tooltip return info -figure.tooltip_manager.register(scatter, custom_info=tooltip_info) +scatter.tooltip_format = tooltip_info figure.show() diff --git a/examples/screenshots/no-imgui-rotation_image.png b/examples/screenshots/no-imgui-rotation_image.png new file mode 100644 index 000000000..3780dc87a --- /dev/null +++ b/examples/screenshots/no-imgui-rotation_image.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:62b9923128bebb489e7da928c5d3fc212cc6228b58dbdaf4bcbaabf0ad12b28c +size 50262 diff --git a/examples/screenshots/no-imgui-rotation_line.png b/examples/screenshots/no-imgui-rotation_line.png new file mode 100644 index 000000000..3eddc6ff2 --- /dev/null +++ b/examples/screenshots/no-imgui-rotation_line.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c922741a05bc5ab2f6bf165b909bb14d443d93517700ceba522aa05b8aa26df4 +size 42402 diff --git a/examples/screenshots/no-imgui-scaling_image.png b/examples/screenshots/no-imgui-scaling_image.png new file mode 100644 index 000000000..5d3dbeaff --- /dev/null +++ b/examples/screenshots/no-imgui-scaling_image.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d0481db08929abe0622f933b349746f40077fe930d86deed1a1ab08563ea310b +size 45587 diff --git a/examples/screenshots/no-imgui-scaling_line.png b/examples/screenshots/no-imgui-scaling_line.png new file mode 100644 index 000000000..8fd232e31 --- /dev/null +++ b/examples/screenshots/no-imgui-scaling_line.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:71940e060068b1941f81e8aa66dfb9bae19aa60bd3c4ac848f65ecf42708dc85 +size 43106 diff --git a/examples/screenshots/no-imgui-translate_image.png b/examples/screenshots/no-imgui-translate_image.png new file mode 100644 index 000000000..a875ef91a --- /dev/null +++ b/examples/screenshots/no-imgui-translate_image.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0995cdaf81fc5a25ebdd54545b7be3e4edca6c25896c2aa5ba9d7e4ab0b240e8 +size 44246 diff --git a/examples/screenshots/no-imgui-translate_line.png b/examples/screenshots/no-imgui-translate_line.png new file mode 100644 index 000000000..211c4a5d0 --- /dev/null +++ b/examples/screenshots/no-imgui-translate_line.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b8b3e79aeb1d8d0622e0928932bd98a7ee8a77d370dc7aecc7c1b923608497d7 +size 45889 diff --git a/examples/screenshots/no-imgui-translation_scaling_image.png b/examples/screenshots/no-imgui-translation_scaling_image.png new file mode 100644 index 000000000..a5c7a71d2 --- /dev/null +++ b/examples/screenshots/no-imgui-translation_scaling_image.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ca48b15e42f7e5e2f67152a31e58b2869329d361d21b17718528b9f8f16a4c92 +size 45697 diff --git a/examples/screenshots/no-imgui-translation_scaling_line.png b/examples/screenshots/no-imgui-translation_scaling_line.png new file mode 100644 index 000000000..0c7b625c7 --- /dev/null +++ b/examples/screenshots/no-imgui-translation_scaling_line.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1f2311cbd8a719d9c208d6744df56bba6d592f5e650cedc4c1251b7c5cf2c9b9 +size 42714 diff --git a/examples/screenshots/no-imgui-translation_scaling_rotation_image.png b/examples/screenshots/no-imgui-translation_scaling_rotation_image.png new file mode 100644 index 000000000..418ef1ff4 --- /dev/null +++ b/examples/screenshots/no-imgui-translation_scaling_rotation_image.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0035495345247d02c113c362699b930d11240e50c8bc14b4178457d029701629 +size 46978 diff --git a/examples/screenshots/no-imgui-translation_scaling_rotation_line.png b/examples/screenshots/no-imgui-translation_scaling_rotation_line.png new file mode 100644 index 000000000..15124c89e --- /dev/null +++ b/examples/screenshots/no-imgui-translation_scaling_rotation_line.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:de3cac77e9f6601abf050b67fdd15f14e3fcfa691cc06284379830e9be57f3d4 +size 45515 diff --git a/examples/screenshots/rotation_image.png b/examples/screenshots/rotation_image.png new file mode 100644 index 000000000..85312949a --- /dev/null +++ b/examples/screenshots/rotation_image.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a6399d67da50abbdf7af4430f2bc4264f893d239eb661d3664ead87563169bee +size 51598 diff --git a/examples/screenshots/rotation_line.png b/examples/screenshots/rotation_line.png new file mode 100644 index 000000000..08b09a417 --- /dev/null +++ b/examples/screenshots/rotation_line.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4f66b0698d2f1fc2481767413377e21fa57bc80c9b34aa3e722a63902fc34a1e +size 44395 diff --git a/examples/screenshots/scaling_image.png b/examples/screenshots/scaling_image.png new file mode 100644 index 000000000..f0b2bdb8b --- /dev/null +++ b/examples/screenshots/scaling_image.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e820b72d87156d215f895c0668bef80a4a2d7cafeb1435a5df1ac7515d2336ef +size 47270 diff --git a/examples/screenshots/scaling_line.png b/examples/screenshots/scaling_line.png new file mode 100644 index 000000000..48e71b9ab --- /dev/null +++ b/examples/screenshots/scaling_line.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9f611bbbf7c05b754a35065f7f2117fc8062f0024d209ae1fab049f6e7f2d3b8 +size 44380 diff --git a/examples/screenshots/translate_image.png b/examples/screenshots/translate_image.png new file mode 100644 index 000000000..c0e6dd76e --- /dev/null +++ b/examples/screenshots/translate_image.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2c7fb592ea62eed3be0ff6c7650d176513304e455130b64caebcefc7e5fe48e9 +size 45572 diff --git a/examples/screenshots/translate_line.png b/examples/screenshots/translate_line.png new file mode 100644 index 000000000..4c64bbd74 --- /dev/null +++ b/examples/screenshots/translate_line.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a62c00847ea65187c7025c4eb0ad80767e1609e37d88602424531cbc0c7429a2 +size 46717 diff --git a/examples/screenshots/translation_scaling_image.png b/examples/screenshots/translation_scaling_image.png new file mode 100644 index 000000000..b7d26c937 --- /dev/null +++ b/examples/screenshots/translation_scaling_image.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:393d26c54bb9a0ac690411df262c3b9c3273274edf4787a18f057a1c3e02389e +size 47386 diff --git a/examples/screenshots/translation_scaling_line.png b/examples/screenshots/translation_scaling_line.png new file mode 100644 index 000000000..e3c6835b6 --- /dev/null +++ b/examples/screenshots/translation_scaling_line.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0593fa32a6990c2e05aad1b9314b912dc3e196b499938be49fc7074e610581e0 +size 44521 diff --git a/examples/screenshots/translation_scaling_rotation_image.png b/examples/screenshots/translation_scaling_rotation_image.png new file mode 100644 index 000000000..cd384ba15 --- /dev/null +++ b/examples/screenshots/translation_scaling_rotation_image.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:42352d3bedbb42fdac5e45a789520e9f7be75748a32b12ceea7edabd4f17c500 +size 47418 diff --git a/examples/screenshots/translation_scaling_rotation_line.png b/examples/screenshots/translation_scaling_rotation_line.png new file mode 100644 index 000000000..ea92cdd09 --- /dev/null +++ b/examples/screenshots/translation_scaling_rotation_line.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:25b9c03c40a1b5c91df269f402b953986d996a95660f0c5f4d85c8ef31d479a8 +size 46453 diff --git a/examples/spaces_transforms/README.rst b/examples/spaces_transforms/README.rst new file mode 100644 index 000000000..55747c2a8 --- /dev/null +++ b/examples/spaces_transforms/README.rst @@ -0,0 +1,2 @@ +Spaces and transforms +===================== diff --git a/examples/spaces_transforms/rotation_image.py b/examples/spaces_transforms/rotation_image.py new file mode 100644 index 000000000..ebc6cb3de --- /dev/null +++ b/examples/spaces_transforms/rotation_image.py @@ -0,0 +1,94 @@ +""" +Rotate image +============ + +This examples illustrates the various spaces that you may need to map between, +plots an image to show these mappings. +""" + +# test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' + +import numpy as np +import fastplotlib as fpl + +figure = fpl.Figure(size=(700, 560)) + +# an image to demonstrate some data in model/data space +image_data = np.array( + [ + [0, 1, 2], + [3, 4, 5], + [5, 6, 7], + [8, 9, 10], + ] +) +image = figure[0, 0].add_image(image_data, cmap="turbo") + + +# a scatter that will be in the same space as the image +# used to indicates a few points on the image +scatter_data = np.array([[0, 1], [2, 3]]) +scatter = figure[0, 0].add_scatter( + scatter_data, + sizes=15, + colors=["blue", "red"], + edge_colors="w", + edge_width=2.0, +) + +# text to indicate the scatter point positions in all spaces +text_0 = figure[0, 0].add_text( + text="", + anchor="bottom-left", + face_color="w", + outline_color="k", + outline_thickness=0.5, +) +text_1 = figure[0, 0].add_text( + text="", + anchor="bottom-left", + face_color="w", + outline_color="k", + outline_thickness=0.5, +) + +# rotation of pi/4 as a quaternion +rotation_quat = (np.cos(np.pi / 8), np.sin(np.pi / 8), 0, 0) +image.rotation = rotation_quat +scatter.rotation = rotation_quat + + +def update_text(): + # get the position of the scatter points in world space + # graphics can map from model <-> world space + point_0_world = scatter.map_model_to_world(scatter.data[0]) + point_1_world = scatter.map_model_to_world(scatter.data[1]) + + # text is always just set in world space + text_0.offset = point_0_world + text_1.offset = point_1_world + + # use subplot to map to world <-> screen space + point_0_screen = figure[0, 0].map_world_to_screen(point_0_world) + point_1_screen = figure[0, 0].map_world_to_screen(point_1_world) + + # set text to display model, world and screen space position of the 2 points + text_0.text = ( + f"model pos: [{', '.join(str(round(p, 2)) for p in scatter.data[0])}]\n" + f"world pos: [{', '.join(str(round(p, 2)) for p in point_0_world)}]\n" + f"screen pos: [{', '.join(str(round(p)) for p in point_0_screen)}]" + ) + + text_1.text = ( + f"model pos: [{', '.join(str(round(p, 2)) for p in scatter.data[1])}]\n" + f"world pos: [{', '.join(str(round(p, 2)) for p in point_1_world)}]\n" + f"screen pos: [{', '.join(str(round(p)) for p in point_1_screen)}]" + ) + + +figure.add_animations(update_text) + +figure.show() + +fpl.loop.run() diff --git a/examples/spaces_transforms/rotation_line.py b/examples/spaces_transforms/rotation_line.py new file mode 100644 index 000000000..bec820eb8 --- /dev/null +++ b/examples/spaces_transforms/rotation_line.py @@ -0,0 +1,89 @@ +""" +Rotate line +=========== + +This examples illustrates the various spaces that you may need to map between, +plots a line to show these mappings. +""" + +# test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' + +import numpy as np +import fastplotlib as fpl + +figure = fpl.Figure(size=(700, 560)) + +xs = np.linspace(0, 2 * np.pi, 100) +ys = np.sin(xs) + +# a line to demonstrate some data in model/data space +line_data = np.column_stack([xs, ys]) +line = figure[0, 0].add_line(line_data, cmap="jet", thickness=10) + +# a scatter that will be in the same space as the line +# used to indicates a few points on the line +scatter_data = np.array([[np.pi / 4, np.sin(np.pi / 4)], [3 * np.pi / 2 , -1]]) +scatter = figure[0, 0].add_scatter( + scatter_data, + sizes=15, + colors=["blue", "red"], + edge_colors="w", + edge_width=2.0, +) + +# text to indicate the scatter point positions in all spaces +text_0 = figure[0, 0].add_text( + text="", + anchor="bottom-left", + face_color="w", + outline_color="k", + outline_thickness=0.5, +) +text_1 = figure[0, 0].add_text( + text="", + anchor="bottom-left", + face_color="w", + outline_color="k", + outline_thickness=0.5, +) + +# rotation of pi/4 as a quaternion +rotation_quat = (np.cos(np.pi / 8), np.sin(np.pi / 8), 0, 0) +line.rotation = rotation_quat +scatter.rotation = rotation_quat + + +def update_text(): + # get the position of the scatter points in world space + # graphics can map from model <-> world space + point_0_world = scatter.map_model_to_world(scatter.data[0]) + point_1_world = scatter.map_model_to_world(scatter.data[1]) + + # text is always just set in world space + text_0.offset = point_0_world + text_1.offset = point_1_world + + # use subplot to map to world <-> screen space + point_0_screen = figure[0, 0].map_world_to_screen(point_0_world) + point_1_screen = figure[0, 0].map_world_to_screen(point_1_world) + + # set text to display model, world and screen space position of the 2 points + text_0.text = ( + f"model pos: [{', '.join(str(round(p, 2)) for p in scatter.data[0])}]\n" + f"world pos: [{', '.join(str(round(p, 2)) for p in point_0_world)}]\n" + f"screen pos: [{', '.join(str(round(p)) for p in point_0_screen)}]" + ) + + text_1.text = ( + f"model pos: [{', '.join(str(round(p, 2)) for p in scatter.data[1])}]\n" + f"world pos: [{', '.join(str(round(p, 2)) for p in point_1_world)}]\n" + f"screen pos: [{', '.join(str(round(p)) for p in point_1_screen)}]" + ) + + +figure.add_animations(update_text) + +figure.show() + +fpl.loop.run() diff --git a/examples/spaces_transforms/scaling_image.py b/examples/spaces_transforms/scaling_image.py new file mode 100644 index 000000000..878a09010 --- /dev/null +++ b/examples/spaces_transforms/scaling_image.py @@ -0,0 +1,94 @@ +""" +Scale image +=========== + +This examples illustrates the various spaces that you may need to map between, +plots an image to show these mappings. +""" + +# test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' + +import numpy as np +import fastplotlib as fpl + +figure = fpl.Figure(size=(700, 560)) + +# an image to demonstrate some data in model/data space +image_data = np.array( + [ + [0, 1, 2], + [3, 4, 5], + [5, 6, 7], + [8, 9, 10], + ] +) +image = figure[0, 0].add_image(image_data, cmap="turbo") + + +# a scatter that will be in the same space as the image +# used to indicates a few points on the image +scatter_data = np.array([[0, 1], [2, 3]]) +scatter = figure[0, 0].add_scatter( + scatter_data, + sizes=15, + colors=["blue", "red"], + edge_colors="w", + edge_width=2.0, +) + +# text to indicate the scatter point positions in all spaces +text_0 = figure[0, 0].add_text( + text="", + anchor="bottom-left", + face_color="w", + outline_color="k", + outline_thickness=0.5, +) +text_1 = figure[0, 0].add_text( + text="", + anchor="bottom-left", + face_color="w", + outline_color="k", + outline_thickness=0.5, +) + + +scaling = (2, 0.5, 1.0) # scale (x, y, z) +image.scale = scaling +scatter.scale = scaling + + +def update_text(): + # get the position of the scatter points in world space + # graphics can map from model <-> world space + point_0_world = scatter.map_model_to_world(scatter.data[0]) + point_1_world = scatter.map_model_to_world(scatter.data[1]) + + # text is always just set in world space + text_0.offset = point_0_world + text_1.offset = point_1_world + + # use subplot to map to world <-> screen space + point_0_screen = figure[0, 0].map_world_to_screen(point_0_world) + point_1_screen = figure[0, 0].map_world_to_screen(point_1_world) + + # set text to display model, world and screen space position of the 2 points + text_0.text = ( + f"model pos: [{', '.join(str(round(p, 2)) for p in scatter.data[0])}]\n" + f"world pos: [{', '.join(str(round(p, 2)) for p in point_0_world)}]\n" + f"screen pos: [{', '.join(str(round(p)) for p in point_0_screen)}]" + ) + + text_1.text = ( + f"model pos: [{', '.join(str(round(p, 2)) for p in scatter.data[1])}]\n" + f"world pos: [{', '.join(str(round(p, 2)) for p in point_1_world)}]\n" + f"screen pos: [{', '.join(str(round(p)) for p in point_1_screen)}]" + ) + + +figure.add_animations(update_text) + +figure.show() + +fpl.loop.run() diff --git a/examples/spaces_transforms/scaling_line.py b/examples/spaces_transforms/scaling_line.py new file mode 100644 index 000000000..0fcdca55e --- /dev/null +++ b/examples/spaces_transforms/scaling_line.py @@ -0,0 +1,89 @@ +""" +Scale line +=========== + +This examples illustrates the various spaces that you may need to map between, +plots a line to show these mappings. +""" + +# test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' + +import numpy as np +import fastplotlib as fpl + +figure = fpl.Figure(size=(700, 560)) + +xs = np.linspace(0, 2 * np.pi, 100) +ys = np.sin(xs) + +# a line to demonstrate some data in model/data space +line_data = np.column_stack([xs, ys]) +line = figure[0, 0].add_line(line_data, cmap="jet", thickness=10) + +# a scatter that will be in the same space as the line +# used to indicates a few points on the line +scatter_data = np.array([[np.pi / 4, np.sin(np.pi / 4)], [3 * np.pi / 2 , -1]]) +scatter = figure[0, 0].add_scatter( + scatter_data, + sizes=15, + colors=["blue", "red"], + edge_colors="w", + edge_width=2.0, +) + +# text to indicate the scatter point positions in all spaces +text_0 = figure[0, 0].add_text( + text="", + anchor="bottom-left", + face_color="w", + outline_color="k", + outline_thickness=0.5, +) +text_1 = figure[0, 0].add_text( + text="", + anchor="bottom-left", + face_color="w", + outline_color="k", + outline_thickness=0.5, +) + + +scaling = (2, 0.5, 1.0) # scale (x, y, z) +line.scale = scaling +scatter.scale = scaling + + +def update_text(): + # get the position of the scatter points in world space + # graphics can map from model <-> world space + point_0_world = scatter.map_model_to_world(scatter.data[0]) + point_1_world = scatter.map_model_to_world(scatter.data[1]) + + # text is always just set in world space + text_0.offset = point_0_world + text_1.offset = point_1_world + + # use subplot to map to world <-> screen space + point_0_screen = figure[0, 0].map_world_to_screen(point_0_world) + point_1_screen = figure[0, 0].map_world_to_screen(point_1_world) + + # set text to display model, world and screen space position of the 2 points + text_0.text = ( + f"model pos: [{', '.join(str(round(p, 2)) for p in scatter.data[0])}]\n" + f"world pos: [{', '.join(str(round(p, 2)) for p in point_0_world)}]\n" + f"screen pos: [{', '.join(str(round(p)) for p in point_0_screen)}]" + ) + + text_1.text = ( + f"model pos: [{', '.join(str(round(p, 2)) for p in scatter.data[1])}]\n" + f"world pos: [{', '.join(str(round(p, 2)) for p in point_1_world)}]\n" + f"screen pos: [{', '.join(str(round(p)) for p in point_1_screen)}]" + ) + + +figure.add_animations(update_text) + +figure.show() + +fpl.loop.run() diff --git a/examples/spaces_transforms/translate_image.py b/examples/spaces_transforms/translate_image.py new file mode 100644 index 000000000..24a90a064 --- /dev/null +++ b/examples/spaces_transforms/translate_image.py @@ -0,0 +1,95 @@ +""" +Translate image +=============== + +This examples illustrates the various spaces that you may need to map between, +plots an image to show these mappings. +""" + +# test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' + +import numpy as np +import fastplotlib as fpl + +figure = fpl.Figure(size=(700, 560)) + +# an image to demonstrate some data in model/data space +image_data = np.array( + [ + [0, 1, 2], + [3, 4, 5], + [5, 6, 7], + [8, 9, 10], + ] +) +image = figure[0, 0].add_image(image_data, cmap="turbo") + + +# a scatter that will be in the same space as the image +# used to indicates a few points on the image +scatter_data = np.array([[0, 1], [2, 3]]) +scatter = figure[0, 0].add_scatter( + scatter_data, + sizes=15, + colors=["blue", "red"], + edge_colors="w", + edge_width=2.0, +) + +# text to indicate the scatter point positions in all spaces +text_0 = figure[0, 0].add_text( + text="", + anchor="bottom-left", + face_color="w", + outline_color="k", + outline_thickness=0.5, +) +text_1 = figure[0, 0].add_text( + text="", + anchor="bottom-left", + face_color="w", + outline_color="k", + outline_thickness=0.5, +) + + +# translation +translation = (2, 3, 0) # x, y, z translation +image.offset = translation +scatter.offset = translation + + +def update_text(): + # get the position of the scatter points in world space + # graphics can map from model <-> world space + point_0_world = scatter.map_model_to_world(scatter.data[0]) + point_1_world = scatter.map_model_to_world(scatter.data[1]) + + # text is always just set in world space + text_0.offset = point_0_world + text_1.offset = point_1_world + + # use subplot to map to world <-> screen space + point_0_screen = figure[0, 0].map_world_to_screen(point_0_world) + point_1_screen = figure[0, 0].map_world_to_screen(point_1_world) + + # set text to display model, world and screen space position of the 2 points + text_0.text = ( + f"model pos: [{', '.join(str(round(p, 2)) for p in scatter.data[0])}]\n" + f"world pos: [{', '.join(str(round(p, 2)) for p in point_0_world)}]\n" + f"screen pos: [{', '.join(str(round(p)) for p in point_0_screen)}]" + ) + + text_1.text = ( + f"model pos: [{', '.join(str(round(p, 2)) for p in scatter.data[1])}]\n" + f"world pos: [{', '.join(str(round(p, 2)) for p in point_1_world)}]\n" + f"screen pos: [{', '.join(str(round(p)) for p in point_1_screen)}]" + ) + + +figure.add_animations(update_text) + +figure.show() + +fpl.loop.run() diff --git a/examples/spaces_transforms/translate_line.py b/examples/spaces_transforms/translate_line.py new file mode 100644 index 000000000..d8821b271 --- /dev/null +++ b/examples/spaces_transforms/translate_line.py @@ -0,0 +1,90 @@ +""" +Translate line +============== + +This examples illustrates the various spaces that you may need to map between, +plots a line to show these mappings. +""" + +# test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' + +import numpy as np +import fastplotlib as fpl + +figure = fpl.Figure(size=(700, 560)) + +xs = np.linspace(0, 2 * np.pi, 100) +ys = np.sin(xs) + +# a line to demonstrate some data in model/data space +line_data = np.column_stack([xs, ys]) +line = figure[0, 0].add_line(line_data, cmap="jet", thickness=10) + +# a scatter that will be in the same space as the line +# used to indicates a few points on the line +scatter_data = np.array([[np.pi / 4, np.sin(np.pi / 4)], [3 * np.pi / 2 , -1]]) +scatter = figure[0, 0].add_scatter( + scatter_data, + sizes=15, + colors=["blue", "red"], + edge_colors="w", + edge_width=2.0, +) + +# text to indicate the scatter point positions in all spaces +text_0 = figure[0, 0].add_text( + text="", + anchor="bottom-left", + face_color="w", + outline_color="k", + outline_thickness=0.5, +) +text_1 = figure[0, 0].add_text( + text="", + anchor="bottom-left", + face_color="w", + outline_color="k", + outline_thickness=0.5, +) + + +# translation +translation = (2, 3, 0) # x, y, z translation +line.offset = translation +scatter.offset = translation + + +def update_text(): + # get the position of the scatter points in world space + # graphics can map from model <-> world space + point_0_world = scatter.map_model_to_world(scatter.data[0]) + point_1_world = scatter.map_model_to_world(scatter.data[1]) + + # text is always just set in world space + text_0.offset = point_0_world + text_1.offset = point_1_world + + # use subplot to map to world <-> screen space + point_0_screen = figure[0, 0].map_world_to_screen(point_0_world) + point_1_screen = figure[0, 0].map_world_to_screen(point_1_world) + + # set text to display model, world and screen space position of the 2 points + text_0.text = ( + f"model pos: [{', '.join(str(round(p, 2)) for p in scatter.data[0])}]\n" + f"world pos: [{', '.join(str(round(p, 2)) for p in point_0_world)}]\n" + f"screen pos: [{', '.join(str(round(p)) for p in point_0_screen)}]" + ) + + text_1.text = ( + f"model pos: [{', '.join(str(round(p, 2)) for p in scatter.data[1])}]\n" + f"world pos: [{', '.join(str(round(p, 2)) for p in point_1_world)}]\n" + f"screen pos: [{', '.join(str(round(p)) for p in point_1_screen)}]" + ) + + +figure.add_animations(update_text) + +figure.show() + +fpl.loop.run() diff --git a/examples/spaces_transforms/translation_scaling_image.py b/examples/spaces_transforms/translation_scaling_image.py new file mode 100644 index 000000000..02e3a2d41 --- /dev/null +++ b/examples/spaces_transforms/translation_scaling_image.py @@ -0,0 +1,99 @@ +""" +Translate and scale image +========================= + +This examples illustrates the various spaces that you may need to map between, +plots an image to show these mappings. +""" + +# test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' + +import numpy as np +import fastplotlib as fpl + +figure = fpl.Figure(size=(700, 560)) + +# an image to demonstrate some data in model/data space +image_data = np.array( + [ + [0, 1, 2], + [3, 4, 5], + [5, 6, 7], + [8, 9, 10], + ] +) +image = figure[0, 0].add_image(image_data, cmap="turbo") + + +# a scatter that will be in the same space as the image +# used to indicates a few points on the image +scatter_data = np.array([[0, 1], [2, 3]]) +scatter = figure[0, 0].add_scatter( + scatter_data, + sizes=15, + colors=["blue", "red"], + edge_colors="w", + edge_width=2.0, +) + +# text to indicate the scatter point positions in all spaces +text_0 = figure[0, 0].add_text( + text="", + anchor="bottom-left", + face_color="w", + outline_color="k", + outline_thickness=0.5, +) +text_1 = figure[0, 0].add_text( + text="", + anchor="bottom-left", + face_color="w", + outline_color="k", + outline_thickness=0.5, +) + + +# translation and scaling +translation = (2, 3, 0) # x, y, z translation +image.offset = translation +scatter.offset = translation + +scaling = (2, 0.5, 1.0) # scale (x, y, z) +image.scale = scaling +scatter.scale = scaling + + +def update_text(): + # get the position of the scatter points in world space + # graphics can map from model <-> world space + point_0_world = scatter.map_model_to_world(scatter.data[0]) + point_1_world = scatter.map_model_to_world(scatter.data[1]) + + # text is always just set in world space + text_0.offset = point_0_world + text_1.offset = point_1_world + + # use subplot to map to world <-> screen space + point_0_screen = figure[0, 0].map_world_to_screen(point_0_world) + point_1_screen = figure[0, 0].map_world_to_screen(point_1_world) + + # set text to display model, world and screen space position of the 2 points + text_0.text = ( + f"model pos: [{', '.join(str(round(p, 2)) for p in scatter.data[0])}]\n" + f"world pos: [{', '.join(str(round(p, 2)) for p in point_0_world)}]\n" + f"screen pos: [{', '.join(str(round(p)) for p in point_0_screen)}]" + ) + + text_1.text = ( + f"model pos: [{', '.join(str(round(p, 2)) for p in scatter.data[1])}]\n" + f"world pos: [{', '.join(str(round(p, 2)) for p in point_1_world)}]\n" + f"screen pos: [{', '.join(str(round(p)) for p in point_1_screen)}]" + ) + + +figure.add_animations(update_text) + +figure.show() + +fpl.loop.run() diff --git a/examples/spaces_transforms/translation_scaling_line.py b/examples/spaces_transforms/translation_scaling_line.py new file mode 100644 index 000000000..6afbfc11c --- /dev/null +++ b/examples/spaces_transforms/translation_scaling_line.py @@ -0,0 +1,94 @@ +""" +Translate and scale line +======================== + +This examples illustrates the various spaces that you may need to map between, +plots a line to show these mappings. +""" + +# test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' + +import numpy as np +import fastplotlib as fpl + +figure = fpl.Figure(size=(700, 560)) + +xs = np.linspace(0, 2 * np.pi, 100) +ys = np.sin(xs) + +# a line to demonstrate some data in model/data space +line_data = np.column_stack([xs, ys]) +line = figure[0, 0].add_line(line_data, cmap="jet", thickness=10) + +# a scatter that will be in the same space as the line +# used to indicates a few points on the line +scatter_data = np.array([[np.pi / 4, np.sin(np.pi / 4)], [3 * np.pi / 2 , -1]]) +scatter = figure[0, 0].add_scatter( + scatter_data, + sizes=15, + colors=["blue", "red"], + edge_colors="w", + edge_width=2.0, +) + +# text to indicate the scatter point positions in all spaces +text_0 = figure[0, 0].add_text( + text="", + anchor="bottom-left", + face_color="w", + outline_color="k", + outline_thickness=0.5, +) +text_1 = figure[0, 0].add_text( + text="", + anchor="bottom-left", + face_color="w", + outline_color="k", + outline_thickness=0.5, +) + + +# translation and scaling +translation = (2, 3, 0) # x, y, z translation +line.offset = translation +scatter.offset = translation + +scaling = (2, 0.5, 1.0) # scale (x, y, z) +line.scale = scaling +scatter.scale = scaling + + +def update_text(): + # get the position of the scatter points in world space + # graphics can map from model <-> world space + point_0_world = scatter.map_model_to_world(scatter.data[0]) + point_1_world = scatter.map_model_to_world(scatter.data[1]) + + # text is always just set in world space + text_0.offset = point_0_world + text_1.offset = point_1_world + + # use subplot to map to world <-> screen space + point_0_screen = figure[0, 0].map_world_to_screen(point_0_world) + point_1_screen = figure[0, 0].map_world_to_screen(point_1_world) + + # set text to display model, world and screen space position of the 2 points + text_0.text = ( + f"model pos: [{', '.join(str(round(p, 2)) for p in scatter.data[0])}]\n" + f"world pos: [{', '.join(str(round(p, 2)) for p in point_0_world)}]\n" + f"screen pos: [{', '.join(str(round(p)) for p in point_0_screen)}]" + ) + + text_1.text = ( + f"model pos: [{', '.join(str(round(p, 2)) for p in scatter.data[1])}]\n" + f"world pos: [{', '.join(str(round(p, 2)) for p in point_1_world)}]\n" + f"screen pos: [{', '.join(str(round(p)) for p in point_1_screen)}]" + ) + + +figure.add_animations(update_text) + +figure.show() + +fpl.loop.run() diff --git a/examples/spaces_transforms/translation_scaling_rotation_image.py b/examples/spaces_transforms/translation_scaling_rotation_image.py new file mode 100644 index 000000000..d0060401f --- /dev/null +++ b/examples/spaces_transforms/translation_scaling_rotation_image.py @@ -0,0 +1,102 @@ +""" +Translate scale and rotate image +================================ + +This examples illustrates the various spaces that you may need to map between, +plots an image to show these mappings. +""" + +# test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' + +import numpy as np +import fastplotlib as fpl + +figure = fpl.Figure(size=(700, 560)) + +# an image to demonstrate some data in model/data space +image_data = np.array( + [ + [0, 1, 2], + [3, 4, 5], + [5, 6, 7], + [8, 9, 10], + ] +) +image = figure[0, 0].add_image(image_data, cmap="turbo") + + +# a scatter that will be in the same space as the image +# used to indicates a few points on the image +scatter_data = np.array([[0, 1], [2, 3]]) +scatter = figure[0, 0].add_scatter( + scatter_data, + sizes=15, + colors=["blue", "red"], +) + +# text to indicate the scatter point positions in all spaces +text_0 = figure[0, 0].add_text( + text="", + anchor="bottom-left", + face_color="w", + outline_color="k", + outline_thickness=0.5, +) +text_1 = figure[0, 0].add_text( + text="", + anchor="bottom-left", + face_color="w", + outline_color="k", + outline_thickness=0.5, +) + + +# translation and scaling +translation = (2, 3, 0) # x, y, z translation +image.offset = translation +scatter.offset = translation + +scaling = (2, 0.5, 1.0) # scale (x, y, z) +image.scale = scaling +scatter.scale = scaling + +# rotation of pi/4 as a quaternion +rotation_quat = (np.cos(np.pi / 8), np.sin(np.pi / 8), 0, 0) +image.rotation = rotation_quat +scatter.rotation = rotation_quat + + +def update_text(): + # get the position of the scatter points in world space + # graphics can map from model <-> world space + point_0_world = scatter.map_model_to_world(scatter.data[0]) + point_1_world = scatter.map_model_to_world(scatter.data[1]) + + # text is always just set in world space + text_0.offset = point_0_world + text_1.offset = point_1_world + + # use subplot to map to world <-> screen space + point_0_screen = figure[0, 0].map_world_to_screen(point_0_world) + point_1_screen = figure[0, 0].map_world_to_screen(point_1_world) + + # set text to display model, world and screen space position of the 2 points + text_0.text = ( + f"model pos: [{', '.join(str(round(p, 2)) for p in scatter.data[0])}]\n" + f"world pos: [{', '.join(str(round(p, 2)) for p in point_0_world)}]\n" + f"screen pos: [{', '.join(str(round(p)) for p in point_0_screen)}]" + ) + + text_1.text = ( + f"model pos: [{', '.join(str(round(p, 2)) for p in scatter.data[1])}]\n" + f"world pos: [{', '.join(str(round(p, 2)) for p in point_1_world)}]\n" + f"screen pos: [{', '.join(str(round(p)) for p in point_1_screen)}]" + ) + + +figure.add_animations(update_text) + +figure.show() + +fpl.loop.run() diff --git a/examples/spaces_transforms/translation_scaling_rotation_line.py b/examples/spaces_transforms/translation_scaling_rotation_line.py new file mode 100644 index 000000000..e4c245a8e --- /dev/null +++ b/examples/spaces_transforms/translation_scaling_rotation_line.py @@ -0,0 +1,99 @@ +""" +Translate scale and rotate line +=============================== + +This examples illustrates the various spaces that you may need to map between, +plots a line to show these mappings. +""" + +# test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' + +import numpy as np +import fastplotlib as fpl + +figure = fpl.Figure(size=(700, 560)) + +xs = np.linspace(0, 2 * np.pi, 100) +ys = np.sin(xs) + +# a line to demonstrate some data in model/data space +line_data = np.column_stack([xs, ys]) +line = figure[0, 0].add_line(line_data, cmap="jet", thickness=10) + +# a scatter that will be in the same space as the line +# used to indicates a few points on the line +scatter_data = np.array([[np.pi / 4, np.sin(np.pi / 4)], [3 * np.pi / 2 , -1]]) +scatter = figure[0, 0].add_scatter( + scatter_data, + sizes=15, + colors=["blue", "red"], + edge_colors="w", + edge_width=2.0, +) + +# text to indicate the scatter point positions in all spaces +text_0 = figure[0, 0].add_text( + text="", + anchor="bottom-left", + face_color="w", + outline_color="k", + outline_thickness=0.5, +) +text_1 = figure[0, 0].add_text( + text="", + anchor="bottom-left", + face_color="w", + outline_color="k", + outline_thickness=0.5, +) + + +# translation and scaling +translation = (2, 3, 0) # x, y, z translation +line.offset = translation +scatter.offset = translation + +scaling = (2, 0.5, 1.0) # scale (x, y, z) +line.scale = scaling +scatter.scale = scaling + +# rotation of pi/4 as a quaternion +rotation_quat = (np.cos(np.pi / 8), np.sin(np.pi / 8), 0, 0) +line.rotation = rotation_quat +scatter.rotation = rotation_quat + + +def update_text(): + # get the position of the scatter points in world space + # graphics can map from model <-> world space + point_0_world = scatter.map_model_to_world(scatter.data[0]) + point_1_world = scatter.map_model_to_world(scatter.data[1]) + + # text is always just set in world space + text_0.offset = point_0_world + text_1.offset = point_1_world + + # use subplot to map to world <-> screen space + point_0_screen = figure[0, 0].map_world_to_screen(point_0_world) + point_1_screen = figure[0, 0].map_world_to_screen(point_1_world) + + # set text to display model, world and screen space position of the 2 points + text_0.text = ( + f"model pos: [{', '.join(str(round(p, 2)) for p in scatter.data[0])}]\n" + f"world pos: [{', '.join(str(round(p, 2)) for p in point_0_world)}]\n" + f"screen pos: [{', '.join(str(round(p)) for p in point_0_screen)}]" + ) + + text_1.text = ( + f"model pos: [{', '.join(str(round(p, 2)) for p in scatter.data[1])}]\n" + f"world pos: [{', '.join(str(round(p, 2)) for p in point_1_world)}]\n" + f"screen pos: [{', '.join(str(round(p)) for p in point_1_screen)}]" + ) + + +figure.add_animations(update_text) + +figure.show() + +fpl.loop.run() diff --git a/examples/tests/testutils.py b/examples/tests/testutils.py index aad729c7a..e279809e3 100644 --- a/examples/tests/testutils.py +++ b/examples/tests/testutils.py @@ -30,6 +30,7 @@ "window_layouts/*.py", "events/*.py", "selection_tools/*.py", + "spaces_transforms/*.py", "misc/*.py", "guis/*.py", ] diff --git a/fastplotlib/graphics/_base.py b/fastplotlib/graphics/_base.py index a4f3e9a67..47673cbc0 100644 --- a/fastplotlib/graphics/_base.py +++ b/fastplotlib/graphics/_base.py @@ -1,6 +1,7 @@ +from __future__ import annotations from collections import defaultdict from functools import partial -from typing import Any, Literal, TypeAlias +from typing import Any, Literal, TypeAlias, Callable import weakref import numpy as np @@ -22,6 +23,7 @@ Name, Offset, Rotation, + Scale, Alpha, AlphaMode, Visible, @@ -29,11 +31,16 @@ from ._axes import Axes HexStr: TypeAlias = str +WorldObjectID: TypeAlias = int # dict that holds all world objects for a given python kernel/session # Graphic objects only use proxies to WorldObjects WORLD_OBJECTS: dict[HexStr, pygfx.WorldObject] = dict() #: {hex id str: WorldObject} +# maps world object to the graphic which owns it, useful when manually picking from the renderer and we +# need to know the graphic associated with the target world object +WORLD_OBJECT_TO_GRAPHIC: dict[WorldObjectID, Graphic] = dict() + PYGFX_EVENTS = [ "key_down", @@ -54,6 +61,11 @@ class Graphic: _features: dict[str, type] = dict() + # It also doesn't make sense to create tooltips for some graphics + # ex: text, that would be very funny. + # They would also get in the way of selector tools + _fpl_support_tooltip: bool = True + def __init_subclass__(cls, **kwargs): # set of all features @@ -62,6 +74,7 @@ def __init_subclass__(cls, **kwargs): "name": Name, "offset": Offset, "rotation": Rotation, + "scale": Scale, "alpha": Alpha, "alpha_mode": AlphaMode, "visible": Visible, @@ -72,8 +85,9 @@ def __init_subclass__(cls, **kwargs): def __init__( self, name: str = None, - offset: np.ndarray | list | tuple = (0.0, 0.0, 0.0), - rotation: np.ndarray | list | tuple = (0.0, 0.0, 0.0, 1.0), + offset: np.ndarray | tuple[float] = (0.0, 0.0, 0.0), + rotation: np.ndarray | tuple[float] = (0.0, 0.0, 0.0, 1.0), + scale: np.ndarray | tuple[float] = (1.0, 1.0, 1.0), alpha: float = 1.0, alpha_mode: str = "auto", visible: bool = True, @@ -92,6 +106,9 @@ def __init__( rotation: (float, float, float, float), default (0, 0, 0, 1) rotation quaternion + scale: (float, float, float), default (1.0, 1.0, 1.0) + (x, y, z) scale factors + alpha: (float), default 1.0 The global alpha value, i.e. opacity, of the graphic. @@ -155,6 +172,7 @@ def __init__( self._name = Name(name) self._deleted = Deleted(False) self._rotation = Rotation(rotation) + self._scale = Scale(scale) self._offset = Offset(offset) self._alpha = Alpha(alpha) self._alpha_mode = AlphaMode(alpha_mode) @@ -165,6 +183,11 @@ def __init__( self._right_click_menu = None + # store ids of all the WorldObjects that this Graphic manages/uses + self._world_object_ids = list() + + self._tooltip_format: Callable = None + @property def supported_events(self) -> tuple[str]: """events supported by this graphic""" @@ -185,7 +208,7 @@ def offset(self) -> np.ndarray: return self._offset.value @offset.setter - def offset(self, value: np.ndarray | list | tuple): + def offset(self, value: np.ndarray | tuple[float, float, float]): self._offset.set_value(self, value) @property @@ -194,9 +217,18 @@ def rotation(self) -> np.ndarray: return self._rotation.value @rotation.setter - def rotation(self, value: np.ndarray | list | tuple): + def rotation(self, value: np.ndarray | tuple[float, float, float, float]): self._rotation.set_value(self, value) + @property + def scale(self) -> np.ndarray: + """(x, y, z) scaling factor""" + return self._scale.value + + @scale.setter + def scale(self, value: np.ndarray | tuple[float, float, float]): + self._scale.set_value(self, value) + @property def alpha(self) -> float: """The opacity of the graphic""" @@ -251,6 +283,23 @@ def world_object(self) -> pygfx.WorldObject: def _set_world_object(self, wo: pygfx.WorldObject): WORLD_OBJECTS[self._fpl_address] = wo + # add to world object -> graphic mapping + if isinstance(wo, pygfx.Group): + for child in wo.children: + if isinstance( + child, (pygfx.Image, pygfx.Volume, pygfx.Points, pygfx.Line) + ): + # unique 32 bit integer id for each world object + global_id = child.id + WORLD_OBJECT_TO_GRAPHIC[global_id] = self + # store id to pop from dict when graphic is deleted + self._world_object_ids.append(global_id) + else: + global_id = wo.id + WORLD_OBJECT_TO_GRAPHIC[global_id] = self + # store id to pop from dict when graphic is deleted + self._world_object_ids.append(global_id) + wo.visible = self.visible if "Image" in self.__class__.__name__: # Image and ImageVolume use tiling and share one material @@ -269,6 +318,27 @@ def _set_world_object(self, wo: pygfx.WorldObject): if not all(wo.world.rotation == self.rotation): self.rotation = self.rotation + @property + def tooltip_format(self) -> Callable[[dict], str] | None: + """ + set a custom tooltip format function which takes a ``pick_info`` dict and + returns a str to be displayed in the tooltip + """ + return self._tooltip_format + + @tooltip_format.setter + def tooltip_format(self, func: Callable[[dict], str] | None): + if func is None: + self._tooltip_format = None + return + + if not callable(func): + raise TypeError( + f"`tooltip_format` must be set with a callable that takes a pick_info dict, or it can be set as None" + ) + + self._tooltip_format = func + @property def event_handlers(self) -> list[tuple[str, callable, ...]]: """ @@ -427,6 +497,72 @@ def my_handler(event): feature = getattr(self, f"_{t}") feature.remove_event_handler(wrapper) + def map_model_to_world( + self, position: tuple[float, float, float] | tuple[float, float] | np.ndarray + ) -> np.ndarray: + """ + map position from model (data) space to world space, basically applies the world affine transform + + Parameters + ---------- + position: (float, float, float) or (float, float) + (x, y, z) or (x, y) position. If z is not provided then the graphic's offset z is used. + + Returns + ------- + np.ndarray + (x, y, z) position in world space + + """ + + if len(position) == 2: + # use z of the graphic + position = [*position, self.offset[-1]] + + if len(position) != 3: + raise ValueError( + f"position must be tuple or array indicating (x, y, z) position in *model space*" + ) + + # apply world transform to project from model space to world space + return la.vec_transform(position, self.world_object.world.matrix) + + def map_world_to_model( + self, position: tuple[float, float, float] | tuple[float, float] | np.ndarray + ) -> np.ndarray: + """ + map position from world space to model (data) space, basically applies the inverse world affine transform + + Parameters + ---------- + position: (float, float, float) or (float, float) + (x, y, z) or (x, y) position. If z is not provided then 0 is used. + + Returns + ------- + np.ndarray + (x, y, z) position in world space + + """ + + if len(position) == 2: + # use z of the graphic + position = [*position, self.offset[-1]] + + if len(position) != 3: + raise ValueError( + f"position must be tuple or array indicating (x, y, z) position in *model space*" + ) + + return la.vec_transform(position, self.world_object.world.inverse_matrix) + + def format_pick_info(self, ev: pygfx.PointerEvent) -> str: + """ + Takes a pygfx.PointerEvent and returns formatted pick info. + """ + + raise NotImplementedError("must be implemented in subclass") + def _fpl_add_plot_area_hook(self, plot_area): self._plot_area = plot_area @@ -444,6 +580,10 @@ def _fpl_prepare_del(self): Optionally implemented in subclasses """ + # remove from world_obj -> graphic map + for global_id in self._world_object_ids: + WORLD_OBJECT_TO_GRAPHIC.pop(global_id) + # remove axes if added to this graphic if self._axes is not None: self._plot_area.scene.remove(self._axes) diff --git a/fastplotlib/graphics/_collection_base.py b/fastplotlib/graphics/_collection_base.py index 36f83ec7a..5b1fd87f1 100644 --- a/fastplotlib/graphics/_collection_base.py +++ b/fastplotlib/graphics/_collection_base.py @@ -181,6 +181,8 @@ class GraphicCollection(Graphic, CollectionProperties): _child_type: type _indexer: type + # tooltips will come from the child graphics + _fpl_support_tooltip = False def __init_subclass__(cls, **kwargs): super().__init_subclass__(**kwargs) @@ -308,6 +310,7 @@ def _fpl_prepare_del(self): """ # clear any attached event handlers and animation functions self.world_object._event_handlers.clear() + self.world_object.clear() for g in self: g._fpl_prepare_del() @@ -318,16 +321,6 @@ def __getitem__(self, key) -> CollectionIndexer: return self._indexer(selection=self.graphics[key], features=self._features) - def __del__(self): - # detach children - self.world_object.clear() - - for g in self.graphics: - g._fpl_prepare_del() - del g - - super().__del__() - def __len__(self): return len(self._graphics) diff --git a/fastplotlib/graphics/_positions_base.py b/fastplotlib/graphics/_positions_base.py index 73520cc84..af7d7badb 100644 --- a/fastplotlib/graphics/_positions_base.py +++ b/fastplotlib/graphics/_positions_base.py @@ -146,3 +146,11 @@ def __init__( self._size_space = SizeSpace(size_space) super().__init__(*args, **kwargs) + + def format_pick_info(self, pick_info: dict) -> str: + index = pick_info["vertex_index"] + info = "\n".join( + f"{dim}: {val:.4g}" for dim, val in zip("xyz", self.data[index]) + ) + + return info diff --git a/fastplotlib/graphics/_vectors.py b/fastplotlib/graphics/_vectors.py index 6f761bd49..be90db538 100644 --- a/fastplotlib/graphics/_vectors.py +++ b/fastplotlib/graphics/_vectors.py @@ -128,7 +128,7 @@ def __init__( } geometry = create_vector_geometry(color=color, **shape_options) - material = pygfx.MeshBasicMaterial() + material = pygfx.MeshBasicMaterial(pick_write=True) n_vectors = self._positions.value.shape[0] @@ -170,6 +170,16 @@ def directions(self) -> VectorDirections: def directions(self, new_directions): self._directions.set_value(self, new_directions) + def format_pick_info(self, pick_info: dict) -> str: + index = pick_info["instance_index"] + + info = ( + f"position: {self.positions[index]}\n" + f"direction: {self.directions[index]}" + ) + + return info + # mesh code copied and adapted from pygfx def generate_torso( diff --git a/fastplotlib/graphics/features/__init__.py b/fastplotlib/graphics/features/__init__.py index cf99d376d..7f7410cf7 100644 --- a/fastplotlib/graphics/features/__init__.py +++ b/fastplotlib/graphics/features/__init__.py @@ -71,7 +71,7 @@ LinearRegionSelectionFeature, RectangleSelectionFeature, ) -from ._common import Name, Offset, Rotation, Alpha, AlphaMode, Visible, Deleted +from ._common import Name, Offset, Rotation, Scale, Alpha, AlphaMode, Visible, Deleted __all__ = [ @@ -119,6 +119,7 @@ "Name", "Offset", "Rotation", + "Scale", "Alpha", "AlphaMode", "Visible", diff --git a/fastplotlib/graphics/features/_common.py b/fastplotlib/graphics/features/_common.py index b2b99cc49..6ce167075 100644 --- a/fastplotlib/graphics/features/_common.py +++ b/fastplotlib/graphics/features/_common.py @@ -130,6 +130,55 @@ def set_value(self, graphic, value: np.ndarray | Sequence[float]): self._call_event_handlers(event) +class Scale(GraphicFeature): + event_info_spec = [ + { + "dict key": "value", + "type": "np.ndarray[float, float, float, float]", + "description": "new scale", + }, + ] + + def __init__( + self, value: np.ndarray | Sequence[float], property_name: str = "scale" + ): + """Graphic scaling factor""" + + self._validate(value) + # create ones array + self._value = np.ones(3) + + self._value[:] = value + super().__init__(property_name=property_name) + + def _validate(self, value): + if not len(value) in [2, 3]: + raise ValueError( + "scale must be a list, tuple, or array of 2 or 3 float values indicating (x, y) or (x, y, z) scaling" + ) + + @property + def value(self) -> np.ndarray: + return self._value + + @block_reentrance + def set_value(self, graphic, value: np.ndarray | Sequence[float]): + self._validate(value) + + if len(value) == 2: + value = (*value, graphic.world_object.world.scale_z) + + value = np.asarray(value) + + graphic.world_object.world.scale = value + + # set value of existing feature value array + self._value[:] = value + + event = GraphicFeatureEvent(type=self._property_name, info={"value": value}) + self._call_event_handlers(event) + + class Alpha(GraphicFeature): """The alpha value (i.e. opacity) of a graphic.""" diff --git a/fastplotlib/graphics/image.py b/fastplotlib/graphics/image.py index 1eaf54bb6..ece700385 100644 --- a/fastplotlib/graphics/image.py +++ b/fastplotlib/graphics/image.py @@ -21,6 +21,15 @@ ) +def _format_value(value: float): + """float -> rounded str, or str with scientific notation""" + abs_val = abs(value) + if abs_val < 0.01 or abs_val > 9_999: + return f"{value:.2e}" + else: + return f"{value:.4f}" + + class _ImageTile(pygfx.Image): """ Similar to pygfx.Image, only difference is that it modifies the pick_info @@ -477,3 +486,16 @@ def add_polygon_selector( self._plot_area.add_graphic(selector, center=False) return selector + + def format_pick_info(self, pick_info: dict) -> str: + col, row = pick_info["index"] + if self.data.value.ndim == 2: + val = self.data[row, col] + info = f"{val:.4g}" + else: + info = "\n".join( + f"{channel}: {val:.4g}" + for channel, val in zip("rgba", self.data[row, col]) + ) + + return info diff --git a/fastplotlib/graphics/image_volume.py b/fastplotlib/graphics/image_volume.py index db616b30d..db8f29eaa 100644 --- a/fastplotlib/graphics/image_volume.py +++ b/fastplotlib/graphics/image_volume.py @@ -419,3 +419,18 @@ def reset_vmin_vmax(self): vmin, vmax = quick_min_max(self.data.value) self.vmin = vmin self.vmax = vmax + + def format_pick_info(self, pick_info: dict) -> str: + return "image volume tooltips supported in next version" + + col, row, z = pick_info["index"] + if self.data.value.ndim == 3: + val = self.data[z, row, col] + info = f"{val:.4g}" + else: + info = "\n".join( + f"{channel}: {val:.4g}" + for channel, val in zip("rgba", self.data[z, row, col]) + ) + + return info diff --git a/fastplotlib/graphics/mesh.py b/fastplotlib/graphics/mesh.py index 2e5a11851..0e1ac42a3 100644 --- a/fastplotlib/graphics/mesh.py +++ b/fastplotlib/graphics/mesh.py @@ -291,6 +291,27 @@ def plane(self, value: tuple[float, float, float, float]): self._plane.set_value(self, value) + def format_pick_info(self, pick_info: dict) -> str: + # Get what face was clicked + face_index = pick_info["face_index"] + coords = pick_info["face_coord"] + # Select which of the three vertices was closest + # Note that you can also select all vertices for this face, + # or use the coords to select the closest edge. + sub_index = np.argmax(coords) + # Look up the vertex index + try: + vertex_index = int(self.indices[face_index, sub_index]) + except IndexError: + # if vertex buffer sizes change then the pointer event can have outdated pick info? + return "error, buffer size changed" + + info = "\n".join( + f"{dim}: {val:.4g}" for dim, val in zip("xyz", self.positions[vertex_index]) + ) + + return info + class SurfaceGraphic(MeshGraphic): _features = { diff --git a/fastplotlib/graphics/selectors/_base_selector.py b/fastplotlib/graphics/selectors/_base_selector.py index e4dbc890b..28c6534a7 100644 --- a/fastplotlib/graphics/selectors/_base_selector.py +++ b/fastplotlib/graphics/selectors/_base_selector.py @@ -40,6 +40,8 @@ class MoveInfo: # Selector base class class BaseSelector(Graphic): + _fpl_support_tooltip = False + @property def axis(self) -> str: return self._axis diff --git a/fastplotlib/graphics/text.py b/fastplotlib/graphics/text.py index 9f1aeb8af..37e559576 100644 --- a/fastplotlib/graphics/text.py +++ b/fastplotlib/graphics/text.py @@ -21,6 +21,8 @@ class TextGraphic(Graphic): "outline_thickness": TextOutlineThickness, } + _fpl_support_tooltip = False + def __init__( self, text: str, diff --git a/fastplotlib/layouts/_figure.py b/fastplotlib/layouts/_figure.py index 8fd5dc666..79b5be3a8 100644 --- a/fastplotlib/layouts/_figure.py +++ b/fastplotlib/layouts/_figure.py @@ -21,7 +21,6 @@ from ._subplot import Subplot from ._engine import GridLayout, WindowLayout, ScreenSpaceCamera from .. import ImageGraphic -from ..tools import Tooltip class Figure: @@ -52,7 +51,6 @@ def __init__( canvas_kwargs: dict = None, size: tuple[int, int] = (500, 300), names: list | np.ndarray = None, - show_tooltips: bool = False, ): """ Create a Figure containing Subplots. @@ -124,10 +122,30 @@ def __init__( names: list or array of str, optional subplot names - show_tooltips: bool, default False - show tooltips on graphics - """ + # create canvas and renderer + if canvas_kwargs is not None: + if size not in canvas_kwargs.keys(): + canvas_kwargs["size"] = size + else: + canvas_kwargs = {"size": size, "max_fps": 60.0, "vsync": True} + + canvas, renderer = make_canvas_and_renderer( + canvas, renderer, canvas_kwargs=canvas_kwargs + ) + + canvas.add_event_handler(self._fpl_reset_layout, "resize") + + self._canvas = canvas + self._renderer = renderer + + # underlay render pass + self._underlay_camera = ScreenSpaceCamera() + self._underlay_scene = pygfx.Scene() + + # overlay render pass + self._overlay_camera = ScreenSpaceCamera() + self._fpl_overlay_scene = pygfx.Scene() if rects is not None: if not all(isinstance(v, (np.ndarray, tuple, list)) for v in rects): @@ -202,18 +220,6 @@ def __init__( else: subplot_names = None - if canvas_kwargs is not None: - if size not in canvas_kwargs.keys(): - canvas_kwargs["size"] = size - else: - canvas_kwargs = {"size": size, "max_fps": 60.0, "vsync": True} - - canvas, renderer = make_canvas_and_renderer( - canvas, renderer, canvas_kwargs=canvas_kwargs - ) - - canvas.add_event_handler(self._fpl_reset_layout, "resize") - if isinstance(cameras, str): # create the array representing the views for each subplot in the grid cameras = np.array([cameras] * n_subplots) @@ -392,9 +398,6 @@ def __init__( for cam in cams[1:]: _controller.add_camera(cam) - self._canvas = canvas - self._renderer = renderer - if layout_mode == "grid": n_rows, n_cols = shape grid_index_iterator = list(product(range(n_rows), range(n_cols))) @@ -449,23 +452,10 @@ def __init__( canvas_rect=self.get_pygfx_render_area(), ) - # underlay render pass - self._underlay_camera = ScreenSpaceCamera() - self._underlay_scene = pygfx.Scene() - + # add subplot frames to underlay for subplot in self._subplots.ravel(): self._underlay_scene.add(subplot.frame._world_object) - # overlay render pass - self._overlay_camera = ScreenSpaceCamera() - self._overlay_scene = pygfx.Scene() - - # tooltip in overlay render pass - self._tooltip_manager = Tooltip() - self._overlay_scene.add(self._tooltip_manager.world_object) - - self._show_tooltips = show_tooltips - self._animate_funcs_pre: list[callable] = list() self._animate_funcs_post: list[callable] = list() @@ -533,34 +523,11 @@ def names(self) -> np.ndarray[str]: names.flags.writeable = False return names - @property - def tooltip_manager(self) -> Tooltip: - """manage tooltips""" - return self._tooltip_manager - - @property - def show_tooltips(self) -> bool: - """show/hide tooltips for all graphics""" - return self._show_tooltips - @property def animations(self) -> dict[str, list[callable]]: """Returns a dictionary of 'pre' and 'post' animation functions.""" return {"pre": self._animate_funcs_pre, "post": self._animate_funcs_post} - @show_tooltips.setter - def show_tooltips(self, val: bool): - self._show_tooltips = val - - if val: - # register all graphics - for subplot in self: - for graphic in subplot.graphics: - self._tooltip_manager.register(graphic) - - elif not val: - self._tooltip_manager.unregister_all() - def _render(self, draw=True): # draw the underlay planes self.renderer.render(self._underlay_scene, self._underlay_camera, flush=False) @@ -578,7 +545,7 @@ def _render(self, draw=True): # overlay render pass if hasattr(self.renderer, "clear"): self.renderer.clear(depth=True) - self.renderer.render(self._overlay_scene, self._overlay_camera, flush=False) + self.renderer.render(self._fpl_overlay_scene, self._overlay_camera, flush=False) self.renderer.flush() diff --git a/fastplotlib/layouts/_imgui_figure.py b/fastplotlib/layouts/_imgui_figure.py index 046c622ea..d54be4086 100644 --- a/fastplotlib/layouts/_imgui_figure.py +++ b/fastplotlib/layouts/_imgui_figure.py @@ -44,7 +44,6 @@ def __init__( canvas_kwargs: dict = None, size: tuple[int, int] = (500, 300), names: list | np.ndarray = None, - show_tooltips: bool = False, ): self._guis: dict[str, EdgeWindow] = {k: None for k in GUI_EDGES} @@ -61,7 +60,6 @@ def __init__( canvas_kwargs=canvas_kwargs, size=size, names=names, - show_tooltips=show_tooltips, ) self._imgui_renderer = ImguiRenderer(self.renderer.device, self.canvas) diff --git a/fastplotlib/layouts/_plot_area.py b/fastplotlib/layouts/_plot_area.py index 01721780c..f83dcfbcb 100644 --- a/fastplotlib/layouts/_plot_area.py +++ b/fastplotlib/layouts/_plot_area.py @@ -9,11 +9,12 @@ from rendercanvas import BaseRenderCanvas from ._utils import create_controller -from ..graphics._base import Graphic +from ..graphics._base import Graphic, WORLD_OBJECT_TO_GRAPHIC from ..graphics import ImageGraphic from ..graphics.selectors._base_selector import BaseSelector from ._graphic_methods_mixin import GraphicMethodsMixin from ..legends import Legend +from ..tools import Tooltip try: @@ -88,6 +89,8 @@ def __init__( self._animate_funcs_pre: list[callable] = list() self._animate_funcs_post: list[callable] = list() + self._animate_funcs_persist: list[callable] = list() + # list of all graphics managed by this PlotArea self._graphics: list[Graphic] = list() @@ -123,6 +126,10 @@ def __init__( self.scene.add(self._ambient_light) self.scene.add(self._camera.add(self._directional_light)) + self._tooltip = Tooltip() + self.get_figure()._fpl_overlay_scene.add(self._tooltip._fpl_world_object) + self.renderer.add_event_handler(self._fpl_set_tooltip, "pointer_move") + def get_figure(self, obj=None): """Get Figure instance that contains this plot area""" if obj is None: @@ -297,17 +304,27 @@ def animations(self) -> dict[str, list[callable]]: """Returns a dictionary of 'pre' and 'post' animation functions.""" return {"pre": self._animate_funcs_pre, "post": self._animate_funcs_post} + @property + def tooltip(self) -> Tooltip: + """The tooltip in this PlotArea""" + return self._tooltip + def map_screen_to_world( self, pos: tuple[float, float] | pygfx.PointerEvent, allow_outside: bool = False ) -> np.ndarray | None: """ - Map screen position to world position + Map screen (canvas) position to world position Parameters ---------- pos: (float, float) | pygfx.PointerEvent ``(x, y)`` screen coordinates, or ``pygfx.PointerEvent`` + Returns + ------- + (float, float, float) + (x, y, z) position in world space, z is always 0 + """ if isinstance(pos, pygfx.PointerEvent): pos = pos.x, pos.y @@ -333,6 +350,117 @@ def map_screen_to_world( # default z is zero for now return np.array([*pos_world[:2], 0]) + def map_world_to_screen( + self, pos: tuple[float, float, float] | np.ndarray + ) -> tuple[float, float]: + """ + Map world position to screen (canvas) position + + Parameters + ---------- + pos: (x, y, z) + world space position + + Returns + ------- + (float, float) + (x, y) position in screen (canvas) space + + """ + + if not len(pos) == 3: + raise ValueError(f"must pass 3d (x, y, z) position, you passed: {pos}") + + # apply camera transform and get NDC position + ndc = vec_transform(np.asarray(pos), self.camera.camera_matrix) + + # get viewport rect + x_offset, y_offset, w, h = self.viewport.rect + + # ndc to screen position + x_screen = x_offset + (ndc[0] + 1) * 0.5 * w + y_screen = y_offset + (1 - ndc[1]) * 0.5 * h + + return x_screen, y_screen + + def get_pick_info(self, pos): + """ + Get pick info at this screen position + + Parameters + ---------- + pos: (x, y) + screen space position + + Returns + ------- + dict | None + pick info if a graphic is at this position, else None + + """ + + info = self.renderer.get_pick_info(pos) + + if info["world_object"] is not None: + # if this world object is owned by a graphic + if info["world_object"].id in WORLD_OBJECT_TO_GRAPHIC.keys(): + info["graphic"] = WORLD_OBJECT_TO_GRAPHIC[info["world_object"].id] + return info + + def _fpl_set_tooltip(self, ev: pygfx.PointerEvent): + # set tooltip using pointer position + if not self._tooltip.enabled: + return + + # is pointer in this plot area + if not self.viewport.is_inside(ev.x, ev.y): + return + + # is there a world object under the pointer + if ev.target is not None: + # is it owned by a graphic + if ev.target.id in WORLD_OBJECT_TO_GRAPHIC.keys(): + graphic = WORLD_OBJECT_TO_GRAPHIC[ev.target.id] + if not graphic._fpl_support_tooltip: + return + + pick_info = ev.pick_info + if graphic.tooltip_format is not None: + # custom formatter + info = graphic.tooltip_format(pick_info) + else: + # default formatter for this graphic + info = graphic.format_pick_info(pick_info) + self._tooltip.display((ev.x, ev.y), info) + return + + # not over a graphic that supports tooltips + self._tooltip.clear() + + def _fpl_update_tooltip_render(self): + # update tooltip on every render + # TODO: improve performance + if (not self._tooltip.visible) or (not self._tooltip.enabled): + return + + pick_info = self.get_pick_info(self._tooltip.position) + + # None if no graphic is at this position + if pick_info is not None: + graphic = pick_info["graphic"] + if graphic._fpl_support_tooltip: + if graphic.tooltip_format is not None: + # custom formatter + info = graphic.tooltip_format(pick_info) + else: + # default formatter for this graphic + info = graphic.format_pick_info(pick_info) + self._tooltip.display(self._tooltip.position, info) + return + + # tooltip cleared if none of the above condiitionals reached the tooltip display call + self._tooltip.clear() + def _render(self): self._call_animate_functions(self._animate_funcs_pre) @@ -344,6 +472,9 @@ def _render(self): self._call_animate_functions(self._animate_funcs_post) + if self._tooltip.continuous_update: + self._fpl_update_tooltip_render() + def _call_animate_functions(self, funcs: list[callable]): for fn in funcs: try: @@ -565,10 +696,6 @@ def _add_or_insert_graphic( obj_list = self._graphics self._fpl_graphics_scene.add(graphic.world_object) - # add to tooltip registry - if self.get_figure().show_tooltips: - self.get_figure().tooltip_manager.register(graphic) - else: raise TypeError("graphic must be of type Graphic | BaseSelector | Legend") diff --git a/fastplotlib/tools/__init__.py b/fastplotlib/tools/__init__.py index df129a369..761183f76 100644 --- a/fastplotlib/tools/__init__.py +++ b/fastplotlib/tools/__init__.py @@ -1,7 +1,10 @@ from ._histogram_lut import HistogramLUTTool -from ._tooltip import Tooltip +from ._textbox import TextBox, Tooltip +from ._cursor import Cursor __all__ = [ "HistogramLUTTool", + "TextBox", "Tooltip", + "Cursor", ] diff --git a/fastplotlib/tools/_cursor.py b/fastplotlib/tools/_cursor.py new file mode 100644 index 000000000..6b7946cd0 --- /dev/null +++ b/fastplotlib/tools/_cursor.py @@ -0,0 +1,420 @@ +from functools import partial +from typing import Literal, Sequence, Callable + +import numpy as np +import pygfx + +from ..layouts import Subplot +from ..utils import RenderQueue + + +class Cursor: + def __init__( + self, + mode: Literal["crosshair", "marker"] = "crosshair", + size: float = 1.0, # in screen space + color: str | Sequence[float] | pygfx.Color | np.ndarray = "w", + marker: str = "+", + edge_color: str | Sequence[float] | pygfx.Color | np.ndarray = "k", + edge_width: float = 0.5, + alpha: float = 0.7, + size_space: Literal["screen", "world"] = "screen", + ): + """ + A cursor that indicates the same position in world-space across subplots. + + Parameters + ---------- + mode: "crosshair" | "marker" + cursor mode + + size: float, default 1.0 + * if ``mode`` == 'crosshair', this is the crosshair line thickness + * if ``mode`` == 'marker', it's the size of the marker + + You probably want to use ``size > 5`` if ``mode`` is 'marker' and ``size_space`` is ``screen`` + + color: str | Sequence[float] | pygfx.Color | np.ndarray, default "r" + color of the marker + + marker: str, default "+" + marker shape, used if mode == 'marker' + + edge_color: str | Sequence[float] | pygfx.Color | np.ndarray, default "k" + marker edge color, used if ``mode`` == 'marker' + + edge_width: float, default 0.5 + marker edge widget, used if ``mode`` == 'marker' + + alpha: float, default 0.7 + alpha (transparency) of the cursor + + size_space: "screen" | "world", default "screen" + size space of the cursor, if "screen" the ``size`` is exact screen pixels. + if "world" the ``size`` is in world-space + + """ + + self._cursors: dict[Subplot, pygfx.Points | pygfx.Group[pygfx.Line]] = dict() + self._transforms: dict[Subplot, Callable | None] = dict() + + self._mode = None + self.mode = mode + self.size = size + self.color = color + self.marker = marker + self.edge_color = edge_color + self.edge_width = edge_width + self.alpha = alpha + self.size_space = size_space + + self._enabled = True + + self._position: list[float, float] = [0.0, 0.0] + + @property + def mode(self) -> Literal["crosshair", "marker"]: + """cursor mode, one of 'crosshair' or 'marker'""" + return self._mode + + @mode.setter + def mode(self, mode: Literal["crosshair", "marker"]): + if not (mode == "crosshair" or mode == "marker"): + raise ValueError( + f"mode must be one of: 'crosshair' | 'marker', you passed: {mode}" + ) + + if mode == self.mode: + return + + # mode has changed, clear and create new world objects + subplots = list(self._cursors.keys()) + + self.clear() + + for subplot in subplots: + self.add_subplot(subplot) + + self._mode = mode + + @property + def size(self) -> float: + """size of marker or crosshair line thickness""" + return self._size + + @size.setter + def size(self, new_size: float): + for c in self._cursors.values(): + if self.mode == "marker": + c.material.size = new_size + elif self.mode == "crosshair": + h, v = c.children + h.material.thickness = new_size + v.material.thickness = new_size + + self._size = new_size + + @property + def size_space(self) -> Literal["screen", "world"]: + """interpret cursor size in screen or world space""" + return self._size_space + + @size_space.setter + def size_space(self, space: Literal["screen", "world"]): + if space not in ["screen", "world", "model"]: + raise ValueError( + f"valid `size_space` is one of: 'screen' | 'world'. You passed: {space}" + ) + + for c in self._cursors.values(): + if self.mode == "marker": + c.material.size_space = space + + elif self.mode == "crosshair": + h, v = c.children + h.material.thickness_space = space + v.material.thickness_space = space + + self._size_space = space + + @property + def color(self) -> pygfx.Color: + """cursor color""" + return self._color + + @color.setter + def color(self, new_color): + new_color = pygfx.Color(new_color) + + for c in self._cursors.values(): + c.material.color = new_color + + self._color = new_color + + @property + def marker(self) -> str: + """cursor marker shape, if `mode` is 'marker'""" + return self._marker + + @marker.setter + def marker(self, new_marker: str): + if self.mode == "marker": + for c in self._cursors.values(): + c.material.marker = new_marker + + self._marker = new_marker + + @property + def edge_color(self) -> pygfx.Color: + """cursor marker edge color, if `mode` is 'marker'""" + return self._edge_color + + @edge_color.setter + def edge_color(self, new_color: str | Sequence | np.ndarray | pygfx.Color): + new_color = pygfx.Color(new_color) + + if self.mode == "marker": + for c in self._cursors.values(): + c.material.edge_color = new_color + + self._edge_color = new_color + + @property + def edge_width(self) -> float: + """cursor marker edge width, if `mode` is 'marker'""" + return self._edge_width + + @edge_width.setter + def edge_width(self, new_width: float): + if self.mode == "marker": + for c in self._cursors.values(): + c.material.edge_width = new_width + + self._edge_width = new_width + + @property + def alpha(self) -> float: + """cursor alpha value""" + return self._alpha + + @alpha.setter + def alpha(self, value: float): + for c in self._cursors.values(): + c.material.opacity = value + + self._alpha = value + + @property + def enabled(self) -> bool: + """enable/disable the cursor, if False the cursor will not respond to mouse pointer events""" + return self._enabled + + @enabled.setter + def enabled(self, pause: bool): + self._enabled = bool(pause) + + @property + def position(self) -> tuple[float, float]: + """(x, y) position in world space""" + return tuple(self._position) + + @position.setter + def position(self, pos: tuple[float, float]): + for subplot, cursor in self._cursors.items(): + if self._transforms[subplot] is not None: + pos_transformed = self._transforms[subplot](pos) + else: + pos_transformed = pos + + if self.mode == "marker": + cursor.geometry.positions.data[0, :-1] = pos_transformed + cursor.geometry.positions.update_full() + + elif self.mode == "crosshair": + line_h, line_v = cursor.children + + # set x vals for horizontal line + line_h.geometry.positions.data[0, 0] = pos_transformed[0] - 1 + line_h.geometry.positions.data[1, 0] = pos[0] + 1 + + # set y value + line_h.geometry.positions.data[:, 1] = pos_transformed[1] + + line_h.geometry.positions.update_full() + + # set y vals for vertical line + line_v.geometry.positions.data[0, 1] = pos_transformed[1] - 1 + line_v.geometry.positions.data[1, 1] = pos_transformed[1] + 1 + + # set x value + line_v.geometry.positions.data[:, 0] = pos_transformed[0] + + line_v.geometry.positions.update_full() + + # set tooltip using pick info if a graphic is at this position + # for now we just set z = 1 + screen_pos = subplot.map_world_to_screen((*pos_transformed, 1)) + pick_info = subplot.get_pick_info(screen_pos) + + self._position[:] = pos_transformed + + if pick_info is not None: + graphic = pick_info["graphic"] + if ( + graphic._fpl_support_tooltip + ): # some graphics don't support tooltips, ex: Text + if graphic.tooltip_format is not None: + # custom formatter + info = graphic.tooltip_format + else: + # default formatter for this graphic + info = graphic.format_pick_info(pick_info) + + subplot.tooltip.display(screen_pos, info) + continue + + # tooltip cleared if none of the above condiitionals reached the tooltip display call + subplot.tooltip.clear() + + def add_subplot(self, subplot: Subplot, transform: Callable | None = None): + """ + Add a subplot to this cursor, with an optional position transform function + + Parameters + ---------- + subplot: Subplot + subplot to add + + transform: Callable[[tuple[float, float]], tuple[float, float]] | None + a transform function that takes the cursor's position and returns a transformed + position at which the cursor will visually appear. + + """ + if subplot in self._cursors.keys(): + raise KeyError(f"The given subplot has already been added to this cursor") + + if (not callable(transform)) and (transform is not None): + raise TypeError( + f"`transform` must be a callable or `None`, you passed: {transform}" + ) + + if self.mode == "marker": + cursor = self._create_marker() + + elif self.mode == "crosshair": + cursor = self._create_crosshair() + + subplot.scene.add(cursor) + subplot.renderer.add_event_handler( + partial(self._pointer_moved, subplot), "pointer_move" + ) + + self._cursors[subplot] = cursor + self._transforms[subplot] = transform + + # let cursor manage tooltips + subplot.renderer.remove_event_handler(subplot._fpl_set_tooltip, "pointer_move") + + def remove_subplot(self, subplot: Subplot): + """remove a subplot""" + if subplot not in self._cursors.keys(): + raise KeyError("cursor not in given supblot") + + subplot.scene.remove(self._cursors.pop(subplot)) + + # give back tooltip control to the subplot + subplot.renderer.add_event_handler(subplot._fpl_set_tooltip, "pointer_move") + + def clear(self): + """remove all subplots""" + for subplot in self._cursors.keys(): + self.remove_subplot(subplot) + + def _create_marker(self) -> pygfx.Points: + # creates a Point object, used for "marker" mode + point = pygfx.Points( + pygfx.Geometry(positions=np.array([[*self.position, 0]], dtype=np.float32)), + pygfx.PointsMarkerMaterial( + marker=self.marker, + size=self.size, + size_space=self.size_space, + color=self.color, + edge_color=self.edge_color, + edge_width=self.edge_width, + opacity=self.alpha, + alpha_mode="blend", + render_queue=RenderQueue.selector, + depth_test=False, + depth_write=False, + pick_write=False, + ), + ) + + return point + + def _create_crosshair(self) -> pygfx.Group: + # Creates two infinite lines, used for "crosshair" mode + x, y = self.position + line_h_data = np.array( + [ + [x - 1, y, 0], + [x + 1, y, 0], + ], + dtype=np.float32, + ) + + line_v_data = np.array( + [ + [x, y - 1, 0], + [x, y + 1, 0], + ], + dtype=np.float32, + ) + + line_h = pygfx.Line( + geometry=pygfx.Geometry(positions=line_h_data), + material=pygfx.LineInfiniteSegmentMaterial( + thickness=self.size, + thickness_space=self.size_space, + color=self.color, + opacity=self.alpha, + alpha_mode="blend", + aa=True, + render_queue=RenderQueue.selector, + depth_test=False, + depth_write=False, + pick_write=False, + ), + ) + + line_v = pygfx.Line( + geometry=pygfx.Geometry(positions=line_v_data), + material=pygfx.LineInfiniteSegmentMaterial( + thickness=self.size, + thickness_space=self.size_space, + color=self.color, + opacity=self.alpha, + alpha_mode="blend", + aa=True, + render_queue=RenderQueue.selector, + depth_test=False, + depth_write=False, + pick_write=False, + ), + ) + + lines = pygfx.Group() + lines.add(line_h, line_v) + + return lines + + def _pointer_moved(self, subplot, ev: pygfx.PointerEvent): + if not self.enabled: + return + + pos = subplot.map_screen_to_world(ev) + + if pos is None: + return + + self.position = pos[:-1] diff --git a/fastplotlib/tools/_histogram_lut.py b/fastplotlib/tools/_histogram_lut.py index 7507a7ff2..d651137da 100644 --- a/fastplotlib/tools/_histogram_lut.py +++ b/fastplotlib/tools/_histogram_lut.py @@ -27,6 +27,8 @@ def _get_image_graphic_events(image_graphic: ImageGraphic) -> list[str]: # TODO: This is a widget, we can think about a BaseWidget class later if necessary class HistogramLUTTool(Graphic): + _fpl_support_tooltip = False + def __init__( self, data: np.ndarray, diff --git a/fastplotlib/tools/_tooltip.py b/fastplotlib/tools/_textbox.py similarity index 55% rename from fastplotlib/tools/_tooltip.py rename to fastplotlib/tools/_textbox.py index f6c9cf531..46a468ae7 100644 --- a/fastplotlib/tools/_tooltip.py +++ b/fastplotlib/tools/_textbox.py @@ -1,11 +1,7 @@ -from functools import partial - import numpy as np import pygfx from ..utils.enums import RenderQueue -from ..graphics import LineGraphic, ImageGraphic, ScatterGraphic, Graphic -from ..graphics.features import GraphicFeatureEvent class MeshMasks: @@ -51,21 +47,48 @@ class MeshMasks: masks = MeshMasks -class Tooltip: - def __init__(self): +class TextBox: + def __init__( + self, + font_size: int = 12, + text_color: str | pygfx.Color | tuple = "w", + background_color: str | pygfx.Color | tuple = (0.1, 0.1, 0.3, 0.95), + outline_color: str | pygfx.Color | tuple = (0.8, 0.8, 1.0, 1.0), + padding: tuple[float, float] = (5, 5), + ): + """ + Create a Textbox + + Parameters + ---------- + font_size: int, default 12 + text font size + + text_color: str | pygfx.Color | tuple, default "w" + text color, interpretable by pygfx.Color + + background_color: str | pygfx.Color | tuple, default (0.1, 0.1, 0.3, 0.95), + background color, interpretable by pygfx.Color + + outline_color: str | pygfx.Color | tuple, default (0.8, 0.8, 1.0, 1.0) + outline color, interpretable by pygfx.Color + + padding: (float, float), default (5, 5) + the amount of pixels in (x, y) by which to extend the rectangle behind the text + + """ + # text object self._text = pygfx.Text( text="", - font_size=12, - screen_space=False, + font_size=font_size, + screen_space=False, # these are added to the overlay render pass so it will actually be in screen space! anchor="bottom-left", material=pygfx.TextMaterial( alpha_mode="blend", aa=True, render_queue=RenderQueue.overlay, - color="w", - outline_color="w", - outline_thickness=0.0, + color=text_color, depth_write=False, depth_test=False, pick_write=False, @@ -77,7 +100,7 @@ def __init__(self): material = pygfx.MeshBasicMaterial( alpha_mode="blend", render_queue=RenderQueue.overlay, - color=(0.1, 0.1, 0.3, 0.95), + color=background_color, depth_write=False, depth_test=False, ) @@ -101,7 +124,7 @@ def __init__(self): alpha_mode="blend", render_queue=RenderQueue.overlay, thickness=1.0, - color=(0.8, 0.8, 1.0, 1.0), + color=outline_color, depth_write=False, depth_test=False, ), @@ -109,18 +132,21 @@ def __init__(self): # Plane gets rendered before text and line self._plane.render_order = -1 - self._world_object = pygfx.Group() - self._world_object.add(self._plane, self._text, self._line) + self._fpl_world_object = pygfx.Group() + self._fpl_world_object.add(self._plane, self._text, self._line) # padded to bbox so the background box behind the text extends a bit further # making the text easier to read - self._padding = np.array([[5, 5, 0], [-5, -5, 0]], dtype=np.float32) + self._padding = np.zeros(shape=(2, 3), dtype=np.float32) + self.padding = padding - self._registered_graphics = dict() + # position of the tooltip in screen space + self._position = np.array([0.0, 0.0]) @property - def world_object(self) -> pygfx.Group: - return self._world_object + def position(self) -> np.ndarray: + """position of the tooltip in screen space""" + return self._position @property def font_size(self): @@ -172,9 +198,37 @@ def padding(self, padding_xy: tuple[float, float]): self._padding[0, :2] = padding_xy self._padding[1, :2] = -np.asarray(padding_xy) - def _set_position(self, pos: tuple[float, float]): + @property + def visible(self) -> bool: + """get or set the visibility""" + return self._fpl_world_object.visible + + @visible.setter + def visible(self, visible: bool): + self._fpl_world_object.visible = visible + + def display(self, position: tuple[float, float], info: str): + """ + display at the given position in screen space + + Parameters + ---------- + position: (x, y) + position in screen space + + info: str + tooltip text to display + + """ + # set the text and top left position of the tooltip + self.visible = True + self._text.set_text(info) + self._draw_tooltip(position) + self._position[:] = position + + def _draw_tooltip(self, pos: tuple[float, float]): """ - Set the position of the tooltip + Sets the positions of the world objects so it's draw at the given position Parameters ---------- @@ -182,6 +236,9 @@ def _set_position(self, pos: tuple[float, float]): position in screen space """ + if np.array_equal(self.position, pos): + return + # need to flip due to inverted y x, y = pos[0], pos[1] @@ -207,110 +264,36 @@ def _set_position(self, pos: tuple[float, float]): self._line.geometry.positions.data[:, :2] = pts self._line.geometry.positions.update_range() - def _event_handler(self, custom_tooltip: callable, ev: pygfx.PointerEvent): - """Handles the tooltip appear event, determines the text to be set in the tooltip""" - if custom_tooltip is not None: - info = custom_tooltip(ev) - - elif isinstance(ev.graphic, ImageGraphic): - col, row = ev.pick_info["index"] - if ev.graphic.data.value.ndim == 2: - info = str(ev.graphic.data[row, col]) - else: - info = "\n".join( - f"{channel}: {val}" - for channel, val in zip("rgba", ev.graphic.data[row, col]) - ) - - elif isinstance(ev.graphic, (LineGraphic, ScatterGraphic)): - index = ev.pick_info["vertex_index"] - info = "\n".join( - f"{dim}: {val}" for dim, val in zip("xyz", ev.graphic.data[index]) - ) - else: - raise TypeError("Unsupported graphic") - - # make the tooltip object visible - self.world_object.visible = True - - # set the text and top left position of the tooltip - self._text.set_text(info) - self._set_position((ev.x, ev.y)) - - def _clear(self, ev): + def clear(self, *args): + """clear the text box and make it invisible""" self._text.set_text("") - self.world_object.visible = False - - def register( - self, - graphic: Graphic, - appear_event: str = "pointer_move", - disappear_event: str = "pointer_leave", - custom_info: callable = None, - ): - """ - Register a Graphic to display tooltips. - - **Note:** if the passed graphic is already registered then it first unregistered - and then re-registered using the given arguments. - - Parameters - ---------- - graphic: Graphic - Graphic to register - - appear_event: str, default "pointer_move" - the pointer that triggers the tooltip to appear. Usually one of "pointer_move" | "click" | "double_click" - - disappear_event: str, default "pointer_leave" - the event that triggers the tooltip to disappear, does not have to be a pointer event. + self._fpl_world_object.visible = False - custom_info: callable, default None - a custom function that takes the pointer event defined as the `appear_event` and returns the text - to display in the tooltip - """ - if graphic in list(self._registered_graphics.keys()): - # unregister first and then re-register - self.unregister(graphic) - - pfunc = partial(self._event_handler, custom_info) - graphic.add_event_handler(pfunc, appear_event) - graphic.add_event_handler(self._clear, disappear_event) - - self._registered_graphics[graphic] = (pfunc, appear_event, disappear_event) - - # automatically unregister when graphic is deleted - graphic.add_event_handler(self.unregister, "deleted") - - def unregister(self, graphic: Graphic): - """ - Unregister a Graphic to no longer display tooltips for this graphic. - - **Note:** if the passed graphic is not registered then it is just ignored without raising any exception. - - Parameters - ---------- - graphic: Graphic - Graphic to unregister - - """ +class Tooltip(TextBox): + def __init__(self): + super().__init__() + self._enabled: bool = True + self._continuous_update = False + self.visible = False - if isinstance(graphic, GraphicFeatureEvent): - # this happens when the deleted event is triggered - graphic = graphic.graphic + @property + def enabled(self) -> bool: + """enable or disable the tooltip""" + return self._enabled - if graphic not in self._registered_graphics: - return + @enabled.setter + def enabled(self, value: bool): + self._enabled = bool(value) - # get pfunc and event names - pfunc, appear_event, disappear_event = self._registered_graphics.pop(graphic) + if not self.enabled: + self.visible = False - # remove handlers from graphic - graphic.remove_event_handler(pfunc, appear_event) - graphic.remove_event_handler(self._clear, disappear_event) + @property + def continuous_update(self) -> bool: + """update the tooltip on every render""" + return self._continuous_update - def unregister_all(self): - """unregister all graphics""" - for graphic in self._registered_graphics.keys(): - self.unregister(graphic) + @continuous_update.setter + def continuous_update(self, value: bool): + self._continuous_update = bool(value) From baf7b169e6cf01b186a2d188338fd888d4ef9266 Mon Sep 17 00:00:00 2001 From: Amol Pasarkar Date: Mon, 2 Feb 2026 19:27:49 -0500 Subject: [PATCH 08/21] Fixes bug where tooltip info is incorrectly retrieved when tooltip info is not none (#986) --- fastplotlib/tools/_cursor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fastplotlib/tools/_cursor.py b/fastplotlib/tools/_cursor.py index 6b7946cd0..21b16feef 100644 --- a/fastplotlib/tools/_cursor.py +++ b/fastplotlib/tools/_cursor.py @@ -265,7 +265,7 @@ def position(self, pos: tuple[float, float]): ): # some graphics don't support tooltips, ex: Text if graphic.tooltip_format is not None: # custom formatter - info = graphic.tooltip_format + info = graphic.tooltip_format(pick_info) else: # default formatter for this graphic info = graphic.format_pick_info(pick_info) From 317ba0cec893cde2787134087f1a3965e696c383 Mon Sep 17 00:00:00 2001 From: Kushal Kolar Date: Tue, 3 Feb 2026 09:51:56 -0500 Subject: [PATCH 09/21] fix type annot (#987) * fix type annot * fix in other places --- fastplotlib/graphics/image.py | 2 +- fastplotlib/graphics/line.py | 2 +- fastplotlib/graphics/line_collection.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/fastplotlib/graphics/image.py b/fastplotlib/graphics/image.py index ece700385..44bffcedc 100644 --- a/fastplotlib/graphics/image.py +++ b/fastplotlib/graphics/image.py @@ -466,7 +466,7 @@ def add_polygon_selector( Parameters ---------- - selection: List of positions, optional + selection: list[tuple[float, float]], optional Initial points for the polygon. If not given or None, you'll start drawing the selection (clicking adds points to the polygon). """ diff --git a/fastplotlib/graphics/line.py b/fastplotlib/graphics/line.py index f2d862067..a4f42704f 100644 --- a/fastplotlib/graphics/line.py +++ b/fastplotlib/graphics/line.py @@ -302,7 +302,7 @@ def add_polygon_selector( Parameters ---------- - selection: List of positions, optional + selection: list[tuple[float, float]], optional Initial points for the polygon. If not given or None, you'll start drawing the selection (clicking adds points to the polygon). """ diff --git a/fastplotlib/graphics/line_collection.py b/fastplotlib/graphics/line_collection.py index 275cc1e47..d08231f7d 100644 --- a/fastplotlib/graphics/line_collection.py +++ b/fastplotlib/graphics/line_collection.py @@ -488,7 +488,7 @@ def add_polygon_selector( Parameters ---------- - selection: List of positions, optional + selection: list[tuple[float, float]], optional Initial points for the polygon. If not given or None, you'll start drawing the selection (clicking adds points to the polygon). """ bbox = self.world_object.get_world_bounding_box() From 5721c9b547b6c39694725df4c863eda4f5c8a704 Mon Sep 17 00:00:00 2001 From: Kushal Kolar Date: Wed, 4 Feb 2026 10:03:22 -0500 Subject: [PATCH 10/21] rename test that was never being run before (#988) --- tests/{events.py => test_events.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/{events.py => test_events.py} (100%) diff --git a/tests/events.py b/tests/test_events.py similarity index 100% rename from tests/events.py rename to tests/test_events.py From 0d754f5e4073300460eaa390c4decfa04264bbb4 Mon Sep 17 00:00:00 2001 From: Kushal Kolar Date: Fri, 6 Feb 2026 11:08:03 -0500 Subject: [PATCH 11/21] fix typos (#991) * fix typos * add rendercanvas to intersphinx_mapping --- docs/source/conf.py | 1 + docs/source/developer_notes/graphics.rst | 11 +++++------ 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index edc172dad..52e203e9f 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -134,5 +134,6 @@ "numpy": ("https://numpy.org/doc/stable", None), "pygfx": ("https://docs.pygfx.org/stable", None), "wgpu": ("https://wgpu-py.readthedocs.io/en/latest", None), + "rendercanvas": ("https://rendercanvas.readthedocs.io/stable/", None), # "fastplotlib": ("https://www.fastplotlib.org/", None), } diff --git a/docs/source/developer_notes/graphics.rst b/docs/source/developer_notes/graphics.rst index 71d99854a..c774f1883 100644 --- a/docs/source/developer_notes/graphics.rst +++ b/docs/source/developer_notes/graphics.rst @@ -56,15 +56,14 @@ For example let's look at ``LineGraphic`` in ``fastplotlib/graphics/line.py``. E ``"data", "colors", "cmap", "thickness"`` in addition to properties common to all graphics, such as ``"name", "offset", "rotation", and "visible"`` Now look at the constructor for the ``LineGraphic`` base class ``PositionsGraphic``, it first creates an instance of ``VertexPositions``. -This is a class that manages vertex positions buffer. For the user, it defines the line data, and provides additional useful functionality. -It defines the line, and provides additional useful functionality. +This is a class that manages vertex positions buffer. For the user, it defines the line vertex positions, and provides additional useful functionality. For example, every time that the ``data`` is changed, the new data will be marked for upload to the GPU before the next draw. In addition, event handlers will be called if any event handlers are registered. -``VertexColors`` behaves similarly, but it can perform additional parsing that can create the colors buffer from different +``VertexColors`` behaves similarly, but it can perform additional parsing that can create or set the colors buffer from different forms of user input. For example if a user runs: ``line_graphic.colors = "blue"``, then ``VertexColors.__setitem__()`` will -create a buffer that corresponds to what ``pygfx.Color`` thinks is "blue". Users can also take advantage of fancy indexing, -ex: ``line_graphics.colors[bool_array] = "red"`` 😊 +set the buffer that corresponds to what ``pygfx.Color`` thinks is "blue", i.e the RGBA array `[0, 0, 1, 1]. Users can also take advantage of fancy indexing, +ex: ``line_graphics.colors[bool_array] = "red"`` 😊 to set the color of specific vertices. ``LineGraphic`` also has a ``VertexCmap``, this manages the line ``VertexColors`` instance to parse colormaps, for example: ``line_graphic.cmap = "jet"`` or even ``line_graphic.cmap[50:] = "viridis"``. @@ -73,4 +72,4 @@ ex: ``line_graphics.colors[bool_array] = "red"`` 😊 callbacks to indicate that the graphic has been deleted (for example, removing references to a graphic from a legend). Other graphics have properties that are relevant to them, for example ``ImageGraphic`` has ``cmap``, ``vmin``, ``vmax``, -properties unique to images. \ No newline at end of file +properties unique to images. From 7c2c7c8e7979495db6eb4fccbe13937f4d9942ca Mon Sep 17 00:00:00 2001 From: Kushal Kolar Date: Tue, 17 Feb 2026 14:46:52 -0500 Subject: [PATCH 12/21] make ndc pos tuple an array before adding (#992) --- fastplotlib/layouts/_plot_area.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fastplotlib/layouts/_plot_area.py b/fastplotlib/layouts/_plot_area.py index f83dcfbcb..5d38ce37d 100644 --- a/fastplotlib/layouts/_plot_area.py +++ b/fastplotlib/layouts/_plot_area.py @@ -341,7 +341,7 @@ def map_screen_to_world( ) # convert screen position to NDC - pos_ndc = (pos_rel[0] / vs[0] * 2 - 1, -(pos_rel[1] / vs[1] * 2 - 1), 0) + pos_ndc = np.asarray([pos_rel[0] / vs[0] * 2 - 1, -(pos_rel[1] / vs[1] * 2 - 1), 0]) # get world position pos_ndc += vec_transform(self.camera.world.position, self.camera.camera_matrix) From 0d4c5b9e98f7a7d271b236b49f5e56a9b21727b7 Mon Sep 17 00:00:00 2001 From: Kushal Kolar Date: Tue, 17 Feb 2026 14:50:31 -0500 Subject: [PATCH 13/21] Small updates (#989) * update readm * update year * Update conf.py --- LICENSE | 2 +- README.md | 5 +++-- docs/source/conf.py | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/LICENSE b/LICENSE index 33e2266c5..540c35e42 100644 --- a/LICENSE +++ b/LICENSE @@ -186,7 +186,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2025 Kushal Kolar, Caitlin Lewis + Copyright 2022-2026 Kushal Kolar, Caitlin Lewis Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/README.md b/README.md index 41c1ba72e..da5ed64f8 100644 --- a/README.md +++ b/README.md @@ -133,7 +133,7 @@ For more detailed information, such as use on cloud computing infrastructure, se We welcome contributions! See the contributing guide: https://github.com/fastplotlib/fastplotlib/blob/main/CONTRIBUTING.md -You can also take a look at our [**Roadmap for 2025**](https://github.com/fastplotlib/fastplotlib/issues/55) and [**Issues**](https://github.com/fastplotlib/fastplotlib/issues) for ideas on how to contribute! +You can also take a look at our [**Roadmap for 2026**](https://github.com/fastplotlib/fastplotlib/issues/55) and [**Issues**](https://github.com/fastplotlib/fastplotlib/issues) for ideas on how to contribute! # Developers :brain: @@ -152,7 +152,8 @@ A special thanks to all of the `pygfx` developers and the amazing work they have Fastplotlib is free and open source. We would like to thank the following institutions for helping to support fastplotlib over the past few years. - UNC Chapel Hill, Giovannucci Lab & Hantman Lab -- Flatiron Institute CCN, Chklovskii Lab +- NYU & Flatiron Institute CCN, Williams lab & Chklovskii Lab - Duke University, Pearson Lab +- Columbia University, Paninski lab We are always open to new sponsors that can help further develop and improve the library. diff --git a/docs/source/conf.py b/docs/source/conf.py index 52e203e9f..ead9f05c4 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -26,7 +26,7 @@ # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information project = "fastplotlib" -copyright = "2025, Kushal Kolar, Caitlin Lewis" +copyright = "2022-2026, Kushal Kolar, Caitlin Lewis" author = "Kushal Kolar, Caitlin Lewis" release = fastplotlib.__version__ From 08744986ac81ffe2c67c118d85d2b2900cd67ba3 Mon Sep 17 00:00:00 2001 From: Kushal Kolar Date: Wed, 18 Feb 2026 07:59:47 -0500 Subject: [PATCH 14/21] setting initial scale was missing from `Graphic._set_world_object` (#993) --- fastplotlib/graphics/_base.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/fastplotlib/graphics/_base.py b/fastplotlib/graphics/_base.py index 47673cbc0..5279cf306 100644 --- a/fastplotlib/graphics/_base.py +++ b/fastplotlib/graphics/_base.py @@ -318,6 +318,10 @@ def _set_world_object(self, wo: pygfx.WorldObject): if not all(wo.world.rotation == self.rotation): self.rotation = self.rotation + # set scale if it's not (1, 1, 1) + if not all(wo.world.scale == self.scale): + self.scale = self.scale + @property def tooltip_format(self) -> Callable[[dict], str] | None: """ From 9a8a1b87c6925d6f3afcb871793abe5875e24f34 Mon Sep 17 00:00:00 2001 From: Caitlin Lewis Date: Mon, 23 Feb 2026 13:05:45 -0500 Subject: [PATCH 15/21] Adding top gui to fpl (#999) * top gui * fix * proper logic * allow for y and x offset scaling * also fix for setting by pixel * add screenshot * small fixes --- examples/guis/imgui_top.py | 61 +++++++++++++++++++++++++++ examples/screenshots/imgui_top.png | Bin 0 -> 18432 bytes fastplotlib/layouts/_imgui_figure.py | 10 ++++- fastplotlib/layouts/_rect.py | 30 ++++++++----- fastplotlib/ui/_base.py | 14 +++++- 5 files changed, 102 insertions(+), 13 deletions(-) create mode 100644 examples/guis/imgui_top.py create mode 100644 examples/screenshots/imgui_top.png diff --git a/examples/guis/imgui_top.py b/examples/guis/imgui_top.py new file mode 100644 index 000000000..e1f865fe0 --- /dev/null +++ b/examples/guis/imgui_top.py @@ -0,0 +1,61 @@ +""" +ImGUI Header GUI +================ + +Basic examples demonstrating how to use create a header gui +""" + +# test_example = true +# sphinx_gallery_pygfx_docs = 'screenshot' + +import numpy as np +import fastplotlib as fpl + +# subclass from EdgeWindow to make a custom ImGUI Window to place inside the figure! +from fastplotlib.ui import EdgeWindow +from imgui_bundle import imgui + +# make some initial data +np.random.seed(0) + +xs = np.linspace(0, np.pi * 10, 100) +ys = np.sin(xs) + np.random.normal(scale=0.0, size=100) +data = np.column_stack([xs, ys]) + + +# make a figure +figure = fpl.Figure(size=(700, 560)) + +# make some scatter points at every 10th point +figure[0, 0].add_scatter(data[::10], colors="cyan", sizes=15, name="sine-scatter", uniform_color=True) + +# place a line above the scatter +figure[0, 0].add_line(data, thickness=3, colors="r", name="sine-wave", uniform_color=True) + + +class ImguiExample(EdgeWindow): + def __init__(self, figure, size, location, title): + super().__init__(figure=figure, size=size, location=location, title=title, window_flags=imgui.WindowFlags_.no_title_bar | imgui.WindowFlags_.no_resize) + + def update(self): + imgui.text("This is a top window") + + +# make GUI instance +gui = ImguiExample( + figure, # the figure this GUI instance should live inside + size=30, # width or height of the GUI window within the figure + location="top", # the edge to place this window at + title=" ", # window title +) + +# add it to the figure +figure.add_gui(gui) + +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() \ No newline at end of file diff --git a/examples/screenshots/imgui_top.png b/examples/screenshots/imgui_top.png new file mode 100644 index 0000000000000000000000000000000000000000..f83f2f49930a5a5fb9484be0dce735540527f55a GIT binary patch literal 18432 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 E0|`;K3;+NC literal 0 HcmV?d00001 diff --git a/fastplotlib/layouts/_imgui_figure.py b/fastplotlib/layouts/_imgui_figure.py index d54be4086..33cc6d925 100644 --- a/fastplotlib/layouts/_imgui_figure.py +++ b/fastplotlib/layouts/_imgui_figure.py @@ -195,6 +195,7 @@ def add_gui(self, gui: EdgeWindow): self._fpl_reset_layout() + def get_pygfx_render_area(self, *args) -> tuple[int, int, int, int]: """ Get rect for the portion of the canvas that the pygfx renderer draws to, @@ -208,6 +209,8 @@ def get_pygfx_render_area(self, *args) -> tuple[int, int, int, int]: """ width, height = self.canvas.get_logical_size() + x = 0 + y = 0 for edge in ["right"]: if self.guis[edge]: @@ -217,7 +220,12 @@ def get_pygfx_render_area(self, *args) -> tuple[int, int, int, int]: if self.guis[edge]: height -= self._guis[edge].size - return 0, 0, max(1, width), max(1, height) + for edge in ["top"]: + if self.guis[edge]: + y += self._guis[edge].size + height -= self._guis[edge].size + + return x, y, max(1, width), max(1, height) def register_popup(self, popup: Popup.__class__): """ diff --git a/fastplotlib/layouts/_rect.py b/fastplotlib/layouts/_rect.py index aa84ee8a2..a67f32133 100644 --- a/fastplotlib/layouts/_rect.py +++ b/fastplotlib/layouts/_rect.py @@ -39,8 +39,8 @@ def _set(self, rect): def _set_from_fract(self, rect): """set rect from fractional representation""" - _, _, cw, ch = self._canvas_rect - mult = np.array([cw, ch, cw, ch]) + rect = np.asarray(rect, dtype=float).copy() + x_offset, y_offset, cw, ch = self._canvas_rect # check that widths, heights are valid: if rect[0] + rect[2] > 1: @@ -54,25 +54,35 @@ def _set_from_fract(self, rect): # assign values to the arrays, don't just change the reference self._rect_frac[:] = rect - self._rect_screen_space[:] = self._rect_frac * mult + x_px = x_offset + rect[0] * cw + y_px = y_offset + rect[1] * ch + w_px = rect[2] * cw + h_px = rect[3] * ch + self._rect_screen_space[:] = np.array([x_px, y_px, w_px, h_px]) def _set_from_screen_space(self, rect): """set rect from screen space representation""" - _, _, cw, ch = self._canvas_rect + x_offset, y_offset, cw, ch = self._canvas_rect mult = np.array([cw, ch, cw, ch]) # for screen coords allow (x, y) = 1 or 0, but w, h must be > 1 # check that widths, heights are valid - if rect[0] + rect[2] > cw: + + # account for potential x and y offset + rect_offset = rect.copy() + rect_offset[0] -= x_offset + rect_offset[1] -= y_offset + + if rect_offset[0] + rect_offset[2] > cw: raise ValueError( - f"invalid rect: {rect}\n x + width > canvas width: {rect[0]} + {rect[2]} > {cw}" + f"invalid rect: {rect}\n x + width > canvas width: {rect_offset[0]} + {rect_offset[2]} > {cw}" ) - if rect[1] + rect[3] > ch: + if rect_offset[1] + rect_offset[3] > ch: raise ValueError( - f"invalid rect: {rect}\n y + height > canvas height: {rect[1]} + {rect[3]} >{ch}" + f"invalid rect: {rect}\n y + height > canvas height: {rect_offset[1]} + {rect_offset[3]} >{ch}" ) - self._rect_frac[:] = rect / mult - self._rect_screen_space[:] = rect + self._rect_frac[:] = rect_offset / mult + self._rect_screen_space[:] = rect_offset @property def x(self) -> np.float64: diff --git a/fastplotlib/ui/_base.py b/fastplotlib/ui/_base.py index 3e763e08c..355edc46d 100644 --- a/fastplotlib/ui/_base.py +++ b/fastplotlib/ui/_base.py @@ -7,7 +7,7 @@ from ..layouts._figure import Figure -GUI_EDGES = ["right", "bottom"] +GUI_EDGES = ["right", "bottom", "top"] class BaseGUI: @@ -41,7 +41,7 @@ def __init__( self, figure: Figure, size: int, - location: Literal["bottom", "right"], + location: Literal["bottom", "right", "top"], title: str, window_flags: enum.IntFlag = imgui.WindowFlags_.no_collapse | imgui.WindowFlags_.no_resize, @@ -180,6 +180,16 @@ def get_rect(self) -> tuple[int, int, int, int]: if self._figure.guis["bottom"] is not None: height -= self._figure.guis["bottom"].size + if self._figure.guis["top"] is not None: + # decrease the height + height -= self._figure.guis["top"].size + # increase the y start + y_pos += self._figure.guis["top"].size + + case "top": + x_pos, y_pos = (0, 0) + width, height = (width_canvas, self.size) + return x_pos, y_pos, width, height def draw_window(self): From 88b4e0f95d977ae6ba1c0523c84362b91ce5618a Mon Sep 17 00:00:00 2001 From: Caitlin Lewis Date: Mon, 23 Feb 2026 16:13:39 -0500 Subject: [PATCH 16/21] fix bug (#1001) --- fastplotlib/layouts/_rect.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/fastplotlib/layouts/_rect.py b/fastplotlib/layouts/_rect.py index a67f32133..7ecd6ad8b 100644 --- a/fastplotlib/layouts/_rect.py +++ b/fastplotlib/layouts/_rect.py @@ -27,7 +27,6 @@ def _set(self, rect): raise ValueError( f"Invalid rect value < 0: {rect}\n All values must be non-negative." ) - if (rect[2:] <= 1).all(): # fractional bbox self._set_from_fract(rect) @@ -66,7 +65,6 @@ def _set_from_screen_space(self, rect): mult = np.array([cw, ch, cw, ch]) # for screen coords allow (x, y) = 1 or 0, but w, h must be > 1 # check that widths, heights are valid - # account for potential x and y offset rect_offset = rect.copy() rect_offset[0] -= x_offset @@ -82,7 +80,7 @@ def _set_from_screen_space(self, rect): ) self._rect_frac[:] = rect_offset / mult - self._rect_screen_space[:] = rect_offset + self._rect_screen_space[:] = rect @property def x(self) -> np.float64: From 6ec3c9caf18339b4bc315ce51aabff0df9f21e49 Mon Sep 17 00:00:00 2001 From: Caitlin Lewis Date: Sat, 28 Feb 2026 22:12:07 -0500 Subject: [PATCH 17/21] add AI statements (#1007) * add AI statements * add more contrib details --- CODE_OF_CONDUCT.md | 7 +++++++ CONTRIBUTING.md | 51 ++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index e1b7919f2..545171687 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -87,6 +87,13 @@ Standards for behavior in the fastplotlib community are detailed in the Code of Conduct above. Participants in our community should uphold these standards in all their interactions and help others to do so as well (see next section). +# AI statement + +The fastplotlib project welcomes contributions by everyone. While we recognize that LLMs may be useful, at our core, we are a small team of developers who enjoy discussing code written by other humans. +As such, our preference is that contributions are written without the use of AI. + +Please see our [Contributing Guide](https://github.com/fastplotlib/fastplotlib/blob/main/CONTRIBUTING.md) for more specific details on AI usage in fastplotlib. + # Reporting guidelines diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index be9e175e6..a10f9fb9a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,27 +2,70 @@ `fastplotlib` is a next-generation plotting library built on top of the `pygfx` rendering engine that leverages modern GPU hardware and new graphics APIs to build large-scale scientific visualizations. We welcome and encourage contributions -from everyone! :smile: +from everyone! :smile: -This guide explains how to contribute: if you have questions about the process, please +The rest of this guide explains how to contribute; if you have questions about the process, please reach out on [GitHub Discussions](https://github.com/fastplotlib/fastplotlib/discussions). > **_NOTE:_** If you are already familiar with contributing to open-source software packages, -> please check out the [quick guide](#contributing-quick-guide)! +> please check out the [Quick Guide](#contributing-quick-guide)! ## General Guidelines Developers are encouraged to contribute to various areas of development. This could include the addition of new features (e.g. graphics or selector tools), bug fixes, or the addition of new examples to the [examples gallery](https://www.fastplotlib.org/ver/dev/_gallery/index.html). -Enhancements to documentation and the overall readability of the code are also greatly appreciated. +Enhancements to documentation and the overall readability of the code are also greatly appreciated. :) Feel free to work on any section of the code that you believe you can improve. More importantly, remember to thoroughly test all your classes and functions, and to provide clear, detailed comments within your code. This not only aids others in using the library, but also facilitates future maintenance and further development. +If your PR will introduce **significant** changes, or new features that are not in our Roadmap, please open an +Issue describing your proposed changes so we can assess whether the contribution would be accepted or not, and also so we can provide guidance +on how the proposed implementation can be tailored to conform with the rest of the codebase. + For more detailed information about `fastplotlib` modules, including design choices and implementation details, visit the [`For Develeopers`](https://www.fastplotlib.org/ver/dev/developer_notes/index.html) section of the package documentation. +## AI Policy + +*This policy was adapted from `pygfx`, `scikit-learn`, and `SciPy`* + +While we recognize that LLMs may be useful, at our core, we are a small team of developers who enjoy discussing code written by other humans. +As such, our preference is that contributions are written without the use of AI. + +### Responsibility + +You are responsible for all the code that you contribute, including AI +generated code. You must understand and be able to explain the submitted code as +well as its relation to existing code. It is not acceptable to submit a +PR for code that you cannot understand and explain yourself. + +### Disclosure + +You must disclose whether AI has been used to produce any code of your +pull-request. If so, you must document which tool(s) have been used, how they +were used, and specify what code or text is AI generated. + +### Copyright + +Contributors must own the copyright of any code submitted to `fastplotlib`. Code +generated by AI may infringe on copyright and it is your responsibility to not +infringe. We reserve the right to reject any pull requests where the copyright +is in question. + +### Communication + +When interacting with developers (in discussions, issues, pull-requests, +etc.) do not use AI to speak for you, except for translation or grammar editing. +Human-to-human communication is essential for an open source community to +thrive. + +### AI Agents + +The use of an AI agent that writes code and then submits a pull request +autonomously is not permitted. + ## Contributing to the code ### Contribution workflow cycle From b632d9056162137ff6a214786d21810a9e904c9d Mon Sep 17 00:00:00 2001 From: Caitlin Lewis Date: Wed, 4 Mar 2026 20:07:56 -0500 Subject: [PATCH 18/21] remove screenshot accidentally uploaded to repo (#1008) * remove file from repo * upload to lfs * update with new imgui release --- examples/guis/imgui_basic.py | 7 ++++--- .../image_volume/image_volume_render_modes.py | 4 +++- examples/screenshots/imgui_top.png | Bin 18432 -> 130 bytes 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/examples/guis/imgui_basic.py b/examples/guis/imgui_basic.py index 26b5603c0..74d3c3629 100644 --- a/examples/guis/imgui_basic.py +++ b/examples/guis/imgui_basic.py @@ -52,10 +52,10 @@ def update(self): # the UI will be used to modify the line self._line = figure[0, 0]["sine-wave"] - # get the current line RGB values - rgb_color = self._line.colors[:-1] + # get the current line RGBA values + rgba_color = self._line.colors # make color picker - changed_color, rgb = imgui.color_picker3("color", col=rgb_color) + changed_color, rgba = imgui.color_picker3("color", col=imgui.ImVec4(tuple(rgba_color))) # get current line color alpha value alpha = self._line.colors[-1] @@ -65,6 +65,7 @@ def update(self): # if RGB or alpha changed if changed_color | changed_alpha: # set new color along with alpha + rgb = (rgba[0], rgba[1], rgba[2]) self._line.colors = [*rgb, new_alpha] # example of a slider, you can also use input_float diff --git a/examples/image_volume/image_volume_render_modes.py b/examples/image_volume/image_volume_render_modes.py index d29e3b166..36705d17d 100644 --- a/examples/image_volume/image_volume_render_modes.py +++ b/examples/image_volume/image_volume_render_modes.py @@ -60,7 +60,9 @@ def update(self): _, self.graphic.substep_size = imgui.slider_float( "substep_size", v=self.graphic.substep_size, v_max=10.0, v_min=0.1, ) - _, self.graphic.emissive = imgui.color_picker3("emissive color", col=self.graphic.emissive.rgb) + + col = imgui.ImVec4((*self.graphic.emissive.rgb, 1)) + _, self.graphic.emissive = imgui.color_picker3("emissive color", col=col) if self.graphic.mode == "slice": imgui.text("Select plane defined by:\nax + by + cz + d = 0") diff --git a/examples/screenshots/imgui_top.png b/examples/screenshots/imgui_top.png index f83f2f49930a5a5fb9484be0dce735540527f55a..495446b34153fbd77cea34e1baaab0e6cbc1e1b9 100644 GIT binary patch literal 130 zcmWN@K@x)?3_#Jnr{Dq=0s_(7pd^Kvwm1#C=;_P+#rrpXrM8bMy`Qpa{jB}*Vws2K z+UKLawVZV58>UvXl9H?s8!{8wUf9TgI4gdfE literal 18432 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 E0|`;K3;+NC From 57fd4fb89c6684009c948085a33e531511eb0a3b Mon Sep 17 00:00:00 2001 From: Caitlin Lewis Date: Sun, 8 Mar 2026 23:24:17 -0400 Subject: [PATCH 19/21] Update GOVERNANCE.md (#1009) * Update GOVERNANCE.md * remove screenshot accidentally uploaded to repo (#1008) * remove file from repo * upload to lfs * update with new imgui release --- GOVERNANCE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/GOVERNANCE.md b/GOVERNANCE.md index 9baaaa321..337d524c9 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -122,7 +122,7 @@ Governance decisions, meeting minutes, and voting outcomes are publicly document ## Changes to this governance document -**Effective until February 5, 2026** +**Effective until February 5, 2027** Moving forward, `fastplotlib` will maintain the governance model as outlined above. The core maintainers (Kushal Kolar & Caitlin Lewis) will revisit in one year to propose any necessary changes to the governance structure. From 4a13244994d2e2ba3ccda11c42f32327354fc7ad Mon Sep 17 00:00:00 2001 From: Kushal Kolar Date: Tue, 17 Mar 2026 16:55:48 -0400 Subject: [PATCH 20/21] `Figure` can accept `rects` or `extents` as an `dict` with keys indicating subplot names (#1014) * extents or rects can also be an OrderedDict * black * even better, just regular dict --- fastplotlib/layouts/_figure.py | 33 +++++++++++++++++++++------------ 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/fastplotlib/layouts/_figure.py b/fastplotlib/layouts/_figure.py index 79b5be3a8..28b7c4a49 100644 --- a/fastplotlib/layouts/_figure.py +++ b/fastplotlib/layouts/_figure.py @@ -1,14 +1,13 @@ -import os +from inspect import getfullargspec from itertools import product, chain +import os from pathlib import Path - -import numpy as np from typing import Literal, Iterable -from inspect import getfullargspec from warnings import warn -import pygfx +import numpy as np +import pygfx from rendercanvas import BaseRenderCanvas from ._utils import ( @@ -27,8 +26,8 @@ class Figure: def __init__( self, shape: tuple[int, int] = (1, 1), - rects: list[tuple | np.ndarray] = None, - extents: list[tuple | np.ndarray] = None, + rects: list[tuple | np.ndarray] | dict[str, tuple | np.ndarray] = None, + extents: list[tuple | np.ndarray] | dict[str, tuple | np.ndarray] = None, cameras: ( Literal["2d", "3d"] | Iterable[Iterable[Literal["2d", "3d"]]] @@ -60,15 +59,17 @@ def __init__( shape: tuple[int, int], default (1, 1) shape [n_rows, n_cols] that defines a grid of subplots - rects: list of tuples or arrays - list of rects (x, y, width, height) that define the subplots. + rects: list of tuples or arrays, or a dict mapping subplot name -> rect + list or dict of rects (x, y, width, height) that define the subplots. + If it is a dict, the keys are used as the subplot names. rects can be defined in absolute pixels or as a fraction of the canvas. If width & height <= 1 the rect is assumed to be fractional. Conversely, if width & height > 1 the rect is assumed to be in absolute pixels. width & height must be > 0. Negative values are not allowed. - extents: list of tuples or arrays - list of extents (xmin, xmax, ymin, ymax) that define the subplots. + extents: list of tuples or arrays, or a dict mapping subplot name -> extent + list or dict of extents (xmin, xmax, ymin, ymax) that define the subplots. + If it is a dict, the keys are used as the subplot names. extents can be defined in absolute pixels or as a fraction of the canvas. If xmax & ymax <= 1 the extent is assumed to be fractional. Conversely, if xmax & ymax > 1 the extent is assumed to be in absolute pixels. @@ -120,7 +121,7 @@ def __init__( starting size of canvas in absolute pixels, default (500, 300) names: list or array of str, optional - subplot names + subplot names, ignored if extents or rects are provided as a dict """ # create canvas and renderer @@ -148,6 +149,10 @@ def __init__( self._fpl_overlay_scene = pygfx.Scene() if rects is not None: + if isinstance(rects, dict): + # the actual rects are the dict values, subplot names are the keys + names, rects = zip(*rects.items()) + if not all(isinstance(v, (np.ndarray, tuple, list)) for v in rects): raise TypeError( f"rects must a list of arrays, tuples, or lists of rects (x, y, w, h), you have passed: {rects}" @@ -157,6 +162,10 @@ def __init__( extents = [None] * n_subplots elif extents is not None: + if isinstance(extents, dict): + # the actual extents are the dict values, subplot names are the keys + names, extents = zip(*extents.items()) + if not all(isinstance(v, (np.ndarray, tuple, list)) for v in extents): raise TypeError( f"extents must a list of arrays, tuples, or lists of extents (xmin, xmax, ymin, ymax), " From b9cc2ec883ce553d549d146f7cdc90411125e662 Mon Sep 17 00:00:00 2001 From: Kushal Kolar Date: Fri, 10 Apr 2026 08:21:46 -0400 Subject: [PATCH 21/21] Create partial_camera_linking.py (#1020) --- .../controllers/partial_camera_linking.py | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 examples/controllers/partial_camera_linking.py diff --git a/examples/controllers/partial_camera_linking.py b/examples/controllers/partial_camera_linking.py new file mode 100644 index 000000000..5cebe66ce --- /dev/null +++ b/examples/controllers/partial_camera_linking.py @@ -0,0 +1,55 @@ +""" +Partial camera linking +====================== + +You can customize the camera axes that a controller acts on. In this example with two subplots you can pan and zoom +in x-y in each individual subplot, but only the x-axis panning is linked between the two subplots. The y-axis pan +and zoom in independent on each subplot. +""" + +# test_example = false +# sphinx_gallery_pygfx_docs = 'screenshot' + +import numpy as np +import fastplotlib as fpl +import pygfx + +xs = np.linspace(0, 2 * np.pi, 100) +ys = np.sin(xs) + +ys_big = np.random.rand(100) * 10 + +# create cameras, fov=0 means Orthographic projection +camera1 = pygfx.PerspectiveCamera(fov=0) +camera2 = pygfx.PerspectiveCamera(fov=0) + +# create controllers, first add the "main" camera for the subplot +controller1 = pygfx.PanZoomController(camera1) +controller2 = pygfx.PanZoomController(camera2) + +# add the other camera to each controller, but only include the 'x' state, i.e. 'y' for height is not included +# this must be done only after adding the "main" cameras to the controller as done above +controller1.add_camera(camera2, include_state={"x", "width"}) +controller2.add_camera(camera1, include_state={"x", "width"}) + +# create figure using these cameras and controllers +figure = fpl.Figure( + shape=(2, 1), + cameras=[camera1, camera2], + controllers=[controller1, controller2], + size=(700, 560) +) + +figure[0, 0].add_line(np.column_stack([xs, ys_big])) +figure[1, 0].add_line(np.column_stack([xs, ys])) + +for subplot in figure: + subplot.camera.zoom = 1.0 + +figure.show(maintain_aspect=False, autoscale=True) + +# 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()