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
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.
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 227d5bfb8..da5ed64f8 100644
--- a/README.md
+++ b/README.md
@@ -4,16 +4,20 @@
---
-[](https://github.com/fastplotlib/fastplotlib/actions/workflows/ci.yml)
-[](https://badge.fury.io/py/fastplotlib)
-[](https://fastplotlib.org/ver/dev/)
-[](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)
+
+ 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.
@@ -129,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:
@@ -148,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/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/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/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..71268ddab 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
@@ -45,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
new file mode 100644
index 000000000..ec27f1e4e
--- /dev/null
+++ b/docs/source/api/graphics/MeshGraphic.rst
@@ -0,0 +1,60 @@
+.. _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.scale
+ MeshGraphic.supported_events
+ MeshGraphic.tooltip_format
+ MeshGraphic.visible
+ MeshGraphic.world_object
+
+Methods
+~~~~~~~
+.. autosummary::
+ :toctree: MeshGraphic_api
+
+ 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
new file mode 100644
index 000000000..94c75f999
--- /dev/null
+++ b/docs/source/api/graphics/PolygonGraphic.rst
@@ -0,0 +1,61 @@
+.. _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.scale
+ PolygonGraphic.supported_events
+ PolygonGraphic.tooltip_format
+ PolygonGraphic.visible
+ PolygonGraphic.world_object
+
+Methods
+~~~~~~~
+.. autosummary::
+ :toctree: PolygonGraphic_api
+
+ 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
new file mode 100644
index 000000000..228dbede1
--- /dev/null
+++ b/docs/source/api/graphics/SurfaceGraphic.rst
@@ -0,0 +1,61 @@
+.. _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.scale
+ SurfaceGraphic.supported_events
+ SurfaceGraphic.tooltip_format
+ SurfaceGraphic.visible
+ SurfaceGraphic.world_object
+
+Methods
+~~~~~~~
+.. autosummary::
+ :toctree: SurfaceGraphic_api
+
+ 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/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/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 4e40e8d08..0916859b9 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
@@ -38,6 +40,7 @@ Properties
Subplot.selectors
Subplot.title
Subplot.toolbar
+ Subplot.tooltip
Subplot.viewport
Methods
@@ -52,7 +55,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
@@ -62,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 74a1fbaf9..ead9f05c4 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")
@@ -29,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__
@@ -44,7 +41,7 @@
"sphinx.ext.viewcode",
"sphinx_copybutton",
"sphinx_design",
- "sphinx_gallery.gen_gallery"
+ "sphinx_gallery.gen_gallery",
]
sphinx_gallery_conf = {
@@ -65,11 +62,13 @@
"../../examples/controllers",
"../../examples/line",
"../../examples/line_collection",
+ "../../examples/mesh",
"../../examples/scatter",
"../../examples/vectors",
"../../examples/text",
"../../examples/events",
"../../examples/selection_tools",
+ "../../examples/spaces_transforms",
"../../examples/machine_learning",
"../../examples/guis",
"../../examples/ipywidgets",
@@ -77,9 +76,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 +106,7 @@
"check_switcher": True,
"switcher": {
"json_url": "http://www.fastplotlib.org/_static/switcher.json",
- "version_match": release
+ "version_match": release,
},
"icon_links": [
{
@@ -115,7 +114,7 @@
"url": "https://github.com/fastplotlib/fastplotlib",
"icon": "fa-brands fa-github",
}
- ]
+ ],
}
html_static_path = ["_static"]
@@ -135,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.
diff --git a/docs/source/user_guide/event_tables.rst b/docs/source/user_guide/event_tables.rst
index 8e942830e..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,449 @@ 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
+^^^^^
+
+**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 |
++----------+------+-------------------------------+
+
+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 |
++----------+----------------------------------------+-------------------------+
+
+scale
+^^^^^
+
+**event info dict**
+
++----------+----------------------------------------+-------------+
+| dict key | type | description |
++==========+========================================+=============+
+| value | np.ndarray[float, float, float, float] | new scale |
++----------+----------------------------------------+-------------+
+
+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 |
++----------+----------------------------------------+-------------------------+
+
+scale
+^^^^^
+
+**event info dict**
+
++----------+----------------------------------------+-------------+
+| dict key | type | description |
++==========+========================================+=============+
+| value | np.ndarray[float, float, float, float] | new scale |
++----------+----------------------------------------+-------------+
+
+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 |
++----------+----------------------------------------+-------------------------+
+
+scale
+^^^^^
+
+**event info dict**
+
++----------+----------------------------------------+-------------+
+| dict key | type | description |
++==========+========================================+=============+
+| value | np.ndarray[float, float, float, float] | new scale |
++----------+----------------------------------------+-------------+
+
alpha
^^^^^
@@ -988,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
^^^^^
@@ -1142,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
^^^^^
@@ -1296,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
^^^^^
@@ -1395,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
^^^^^
@@ -1496,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
^^^^^
@@ -1597,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/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()
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/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/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/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/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/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..1adf676ea
--- /dev/null
+++ b/examples/mesh/surface_ripple.py
@@ -0,0 +1,65 @@
+"""
+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)
+)
+
+# 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()
+
+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/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/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/imgui_top.png b/examples/screenshots/imgui_top.png
new file mode 100644
index 000000000..495446b34
--- /dev/null
+++ b/examples/screenshots/imgui_top.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:8ffee260e87dd673f0b27f81503a38581d21ff4aff1d485936a1e989896d5767
+size 18567
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-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-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-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/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/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/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/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/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/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 7b70defdb..e279809e3 100644
--- a/examples/tests/testutils.py
+++ b/examples/tests/testutils.py
@@ -24,11 +24,13 @@
"scatter/*.py",
"line/*.py",
"line_collection/*.py",
- "vectors/*.py"
+ "vectors/*.py",
+ "mesh/*.py",
"gridplot/*.py",
"window_layouts/*.py",
"events/*.py",
"selection_tools/*.py",
+ "spaces_transforms/*.py",
"misc/*.py",
"guis/*.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/_base.py b/fastplotlib/graphics/_base.py
index a4f3e9a67..5279cf306 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,31 @@ 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:
+ """
+ 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 +501,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 +584,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 f745f10c8..7f7410cf7 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,
@@ -62,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__ = [
@@ -71,6 +80,9 @@
"SizeSpace",
"VertexPositions",
"VertexCmap",
+ "MeshIndices",
+ "MeshCmap",
+ "SurfaceData",
"Thickness",
"VertexMarkers",
"UniformMarker",
@@ -107,6 +119,7 @@
"Name",
"Offset",
"Rotation",
+ "Scale",
"Alpha",
"AlphaMode",
"Visible",
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/_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/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/_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
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/image.py b/fastplotlib/graphics/image.py
index 1eaf54bb6..44bffcedc 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
@@ -457,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).
"""
@@ -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/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()
diff --git a/fastplotlib/graphics/mesh.py b/fastplotlib/graphics/mesh.py
new file mode 100644
index 000000000..0e1ac42a3
--- /dev/null
+++ b/fastplotlib/graphics/mesh.py
@@ -0,0 +1,494 @@
+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)
+
+ 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 = {
+ "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/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/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
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..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 (
@@ -21,15 +20,14 @@
from ._subplot import Subplot
from ._engine import GridLayout, WindowLayout, ScreenSpaceCamera
from .. import ImageGraphic
-from ..tools import Tooltip
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"]]]
@@ -52,7 +50,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.
@@ -62,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.
@@ -122,14 +121,38 @@ def __init__(
starting size of canvas in absolute pixels, default (500, 300)
names: list or array of str, optional
- subplot names
-
- show_tooltips: bool, default False
- show tooltips on graphics
+ subplot names, ignored if extents or rects are provided as a dict
"""
+ # 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 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}"
@@ -139,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), "
@@ -202,18 +229,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 +407,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 +461,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 +532,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 +554,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/_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/_imgui_figure.py b/fastplotlib/layouts/_imgui_figure.py
index 046c622ea..33cc6d925 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)
@@ -197,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,
@@ -210,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]:
@@ -219,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/_plot_area.py b/fastplotlib/layouts/_plot_area.py
index 8146a00de..5d38ce37d 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()
@@ -117,6 +120,16 @@ 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))
+
+ 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:
@@ -166,6 +179,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,22 +289,42 @@ 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."""
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
@@ -306,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)
@@ -315,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)
@@ -326,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:
@@ -446,7 +595,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
@@ -547,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/layouts/_rect.py b/fastplotlib/layouts/_rect.py
index aa84ee8a2..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)
@@ -39,8 +38,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,24 +53,33 @@ 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_frac[:] = rect_offset / mult
self._rect_screen_space[:] = rect
@property
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..21b16feef
--- /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(pick_info)
+ 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)
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):
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]
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