diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 000000000..b37aafdbc
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,4 @@
+Type of PR:
+- [ ] minor/trivial bugfix
+- [ ] other minor/trivial change (ex: small changes to docs, typos, etc.)
+- [ ] new features: Reference the issue that describes the new feature and outlines the implementation, this helps facilitate rapid review and approval of your PR.
diff --git a/.github/workflows/ci-pygfx-release.yml b/.github/workflows/ci-pygfx-release.yml
index 87ed1a113..e1c7f09cd 100644
--- a/.github/workflows/ci-pygfx-release.yml
+++ b/.github/workflows/ci-pygfx-release.yml
@@ -1,4 +1,4 @@
-name: CI
+name: CI-pygfx-release
on:
push:
@@ -16,12 +16,12 @@ on:
jobs:
test-build-full:
name: Tests - pygfx release
- timeout-minutes: 25
+ timeout-minutes: 20
if: ${{ !github.event.pull_request.draft }}
strategy:
fail-fast: false
matrix:
- python: ["3.11", "3.12", "3.13"]
+ python: ["3.11", "3.13"]
imgui_dep: ["imgui", ""]
notebook_dep: ["notebook", ""]
os: ["ubuntu-latest", "macos-latest"]
@@ -63,19 +63,20 @@ jobs:
run: |
pytest -v tests/
- name: Test examples
+ if: ${{ matrix.python == '3.13' }}
env:
RENDERCANVAS_FORCE_OFFSCREEN: 1
run: |
pytest -v examples/
- name: Test examples notebooks, exclude ImageWidget notebook
- if: ${{ matrix.notebook_dep == 'notebook' }}
+ if: ${{ matrix.notebook_dep == 'notebook' && matrix.python == '3.13' }}
env:
FASTPLOTLIB_NB_TESTS: 1
# test notebooks, exclude ImageWidget notebooks
run: pytest --nbmake $(find ./examples/notebooks/ -maxdepth 1 -type f -name "*.ipynb" ! -name "image_widget*.ipynb" -print | xargs)
- - name: Test ImageWidget notebooks
+ - name: Test ImageWidget notebook
# test image widget notebooks only if imgui is installed
- if: ${{ matrix.notebook_dep == 'notebook' && matrix.imgui_dep == 'imgui' }}
+ if: ${{ matrix.notebook_dep == 'notebook' && matrix.imgui_dep == 'imgui' && matrix.python == '3.13' }}
env:
FASTPLOTLIB_NB_TESTS: 1
run: pytest --nbmake $(find ./examples/notebooks/ -maxdepth 1 -type f -name "image_widget*.ipynb" -print | xargs)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 528b62772..621870329 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,4 +1,4 @@
-name: CI
+name: CI-pygfx-main
on:
push:
@@ -15,13 +15,13 @@ on:
jobs:
test-build-full:
- name: Tests
- timeout-minutes: 25
+ name: Tests - pygfx main
+ timeout-minutes: 20
if: ${{ !github.event.pull_request.draft }}
strategy:
fail-fast: false
matrix:
- python: ["3.11", "3.12", "3.13"]
+ python: ["3.11", "3.13"]
imgui_dep: ["imgui", ""]
notebook_dep: ["notebook", ""]
os: ["ubuntu-latest", "macos-latest"]
@@ -69,19 +69,20 @@ jobs:
run: |
pytest -v tests/
- name: Test examples
+ if: ${{ matrix.python == '3.13' }}
env:
RENDERCANVAS_FORCE_OFFSCREEN: 1
run: |
pytest -v examples/
- name: Test examples notebooks, exclude ImageWidget notebook
- if: ${{ matrix.notebook_dep == 'notebook' }}
+ if: ${{ matrix.notebook_dep == 'notebook' && matrix.python == '3.13' }}
env:
FASTPLOTLIB_NB_TESTS: 1
# test notebooks, exclude ImageWidget notebooks
run: pytest --nbmake $(find ./examples/notebooks/ -maxdepth 1 -type f -name "*.ipynb" ! -name "image_widget*.ipynb" -print | xargs)
- - name: Test ImageWidget notebooks
+ - name: Test ImageWidget notebook
# test image widget notebooks only if imgui is installed
- if: ${{ matrix.notebook_dep == 'notebook' && matrix.imgui_dep == 'imgui' }}
+ if: ${{ matrix.notebook_dep == 'notebook' && matrix.imgui_dep == 'imgui' && matrix.python == '3.13' }}
env:
FASTPLOTLIB_NB_TESTS: 1
run: pytest --nbmake $(find ./examples/notebooks/ -maxdepth 1 -type f -name "image_widget*.ipynb" -print | xargs)
diff --git a/.github/workflows/screenshots.yml b/.github/workflows/screenshots.yml
index cfaf419b8..2a9a18d86 100644
--- a/.github/workflows/screenshots.yml
+++ b/.github/workflows/screenshots.yml
@@ -14,7 +14,7 @@ jobs:
screenshots:
name: Regenerate
runs-on: ubuntu-latest
- timeout-minutes: 10
+ timeout-minutes: 20
if: ${{ !github.event.pull_request.draft }}
strategy:
fail-fast: false
@@ -51,6 +51,9 @@ jobs:
env:
PYGFX_EXPECT_LAVAPIPE: true
run: |
+ # delete existing screenshots
+ rm ./examples/screenshots/*.png
+ rm ./examples/notebooks/screenshots/*.png
# regenerate screenshots
RENDERCANVAS_FORCE_OFFSCREEN=1 REGENERATE_SCREENSHOTS=1 pytest -v examples
- name: Generate screenshots notebook, exclude image widget
diff --git a/.gitignore b/.gitignore
index c599d5f8c..950f261c0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -70,6 +70,7 @@ instance/
# Sphinx documentation
docs/_build/
+docs/source/sg_execution_times.rst
# PyBuilder
target/
@@ -134,4 +135,6 @@ dmypy.json
# vs code
.vscode/
+# diffs from visual regression tests
examples/desktop/diffs/*.png
+docs/source/_gallery/
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
index 0ae81f6f0..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
@@ -105,12 +112,16 @@ advice, in confidence.
You can report issues to the fastplotlib core team:
-[Kushal Kolar](https://github.com/kushalkolar)
-[Caitlin Lewis](https://github.com/clewis7)
+Kushal Kolar:
+- kushal {at} fastplotlib.org
+
+Caitlin Lewis:
+- caitlin {at} fastplotlib.org
If your report involves any members of the fastplotlib core team, or if they feel they have
a conflict of interest in handling it, then they will recuse themselves from
-considering your report.
+considering your report. You may also contact the neutral moderator as stated in our
+[Governance document](https://github.com/fastplotlib/fastplotlib/blob/main/GOVERNANCE.md#neutral-moderator).
# Incident reporting resolution & Code of Conduct enforcement
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index be9e175e6..58b880d13 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -4,34 +4,82 @@
GPU hardware and new graphics APIs to build large-scale scientific visualizations. We welcome and encourage contributions
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).
+If your contribution/PR isn't minor (ex: more than a bugfix, small change in the docs, etc.) **please open an issue first** that briefly
+describes what you want to contribute and how you plan to implement it. This makes it much easier for us to review your PR, and
+also facilitates more rapid approval of your PR.
+
> **_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
In order to contribute, you will need to do the following:
-1) Create your own branch
-2) Make sure that tests pass
-3) Open a Pull Request
+1) If your contribution isn't trivially small (ex: bugfix, small docs change, etc.) please open an issue that briefly describes your contribution and how you plan to implement it.
+2) Create your own branch
+3) Make sure that tests pass
+4) Open a Pull Request
The `fastplotlib` package follows the [Git feature branch](https://www.atlassian.com/git/tutorials/comparing-workflows/feature-branch-workflow) workflow. In essence, `main` is the primary branch to which no one is allowed to
push directly. All development happens in separate feature branches that are then merged into `main` once we have determined they are ready. When enough changes have accumulated, a new release is
diff --git a/GOVERNANCE.md b/GOVERNANCE.md
index 876757d40..337d524c9 100644
--- a/GOVERNANCE.md
+++ b/GOVERNANCE.md
@@ -25,8 +25,8 @@ The maintainers are the core developers of fastplotlib and together have a compl
The current maintainers are:
-1. [Kushal Kolar](https://github.com/kushalkolar)
-1. [Caitlin Lewis](https://github.com/clewis7)
+1. [Kushal Kolar](https://github.com/kushalkolar) - kushal {at} fastplotlib.org
+1. [Caitlin Lewis](https://github.com/clewis7) - caitlin {at} fastplotlib.org
Responsibilities:
@@ -58,7 +58,7 @@ Responsibilities:
No voting power, has no stake in the fastplotlib project.
-* Reagan Bullins
+* Reagan Bullins - reagan {at} fastplotlib.org
Responsibilities:
@@ -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 5109d26aa..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:
@@ -142,3 +146,14 @@ You can also take a look at our [**Roadmap for 2025**](https://github.com/fastpl
- [**Amol Pasarkar**](https://github.com/apasarkar)
A special thanks to all of the `pygfx` developers and the amazing work they have done.
+
+# Sponsors
+
+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
+- 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/_templates/.class_page_toc.html.swp b/docs/source/_templates/.class_page_toc.html.swp
deleted file mode 100644
index d4ed35c1f..000000000
Binary files a/docs/source/_templates/.class_page_toc.html.swp and /dev/null differ
diff --git a/docs/source/api/graphic_features/Alpha.rst b/docs/source/api/graphic_features/Alpha.rst
new file mode 100644
index 000000000..1ee1f66ac
--- /dev/null
+++ b/docs/source/api/graphic_features/Alpha.rst
@@ -0,0 +1,35 @@
+.. _api.Alpha:
+
+Alpha
+*****
+
+=====
+Alpha
+=====
+.. currentmodule:: fastplotlib.graphics.features
+
+Constructor
+~~~~~~~~~~~
+.. autosummary::
+ :toctree: Alpha_api
+
+ Alpha
+
+Properties
+~~~~~~~~~~
+.. autosummary::
+ :toctree: Alpha_api
+
+ Alpha.value
+
+Methods
+~~~~~~~
+.. autosummary::
+ :toctree: Alpha_api
+
+ Alpha.add_event_handler
+ Alpha.block_events
+ Alpha.clear_event_handlers
+ Alpha.remove_event_handler
+ Alpha.set_value
+
diff --git a/docs/source/api/graphic_features/AlphaMode.rst b/docs/source/api/graphic_features/AlphaMode.rst
new file mode 100644
index 000000000..40e58195c
--- /dev/null
+++ b/docs/source/api/graphic_features/AlphaMode.rst
@@ -0,0 +1,35 @@
+.. _api.AlphaMode:
+
+AlphaMode
+*********
+
+=========
+AlphaMode
+=========
+.. currentmodule:: fastplotlib.graphics.features
+
+Constructor
+~~~~~~~~~~~
+.. autosummary::
+ :toctree: AlphaMode_api
+
+ AlphaMode
+
+Properties
+~~~~~~~~~~
+.. autosummary::
+ :toctree: AlphaMode_api
+
+ AlphaMode.value
+
+Methods
+~~~~~~~
+.. autosummary::
+ :toctree: AlphaMode_api
+
+ AlphaMode.add_event_handler
+ AlphaMode.block_events
+ AlphaMode.clear_event_handlers
+ AlphaMode.remove_event_handler
+ AlphaMode.set_value
+
diff --git a/docs/source/api/graphic_features/EdgeWidth.rst b/docs/source/api/graphic_features/EdgeWidth.rst
new file mode 100644
index 000000000..ba912dc2a
--- /dev/null
+++ b/docs/source/api/graphic_features/EdgeWidth.rst
@@ -0,0 +1,35 @@
+.. _api.EdgeWidth:
+
+EdgeWidth
+*********
+
+=========
+EdgeWidth
+=========
+.. currentmodule:: fastplotlib.graphics.features
+
+Constructor
+~~~~~~~~~~~
+.. autosummary::
+ :toctree: EdgeWidth_api
+
+ EdgeWidth
+
+Properties
+~~~~~~~~~~
+.. autosummary::
+ :toctree: EdgeWidth_api
+
+ EdgeWidth.value
+
+Methods
+~~~~~~~
+.. autosummary::
+ :toctree: EdgeWidth_api
+
+ EdgeWidth.add_event_handler
+ EdgeWidth.block_events
+ EdgeWidth.clear_event_handlers
+ EdgeWidth.remove_event_handler
+ EdgeWidth.set_value
+
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/PointsSizesFeature.rst b/docs/source/api/graphic_features/PointsSizesFeature.rst
deleted file mode 100644
index f3f78b74b..000000000
--- a/docs/source/api/graphic_features/PointsSizesFeature.rst
+++ /dev/null
@@ -1,37 +0,0 @@
-.. _api.PointsSizesFeature:
-
-PointsSizesFeature
-******************
-
-==================
-PointsSizesFeature
-==================
-.. currentmodule:: fastplotlib.graphics.features
-
-Constructor
-~~~~~~~~~~~
-.. autosummary::
- :toctree: PointsSizesFeature_api
-
- PointsSizesFeature
-
-Properties
-~~~~~~~~~~
-.. autosummary::
- :toctree: PointsSizesFeature_api
-
- PointsSizesFeature.buffer
- PointsSizesFeature.shared
- PointsSizesFeature.value
-
-Methods
-~~~~~~~
-.. autosummary::
- :toctree: PointsSizesFeature_api
-
- PointsSizesFeature.add_event_handler
- PointsSizesFeature.block_events
- PointsSizesFeature.clear_event_handlers
- PointsSizesFeature.remove_event_handler
- PointsSizesFeature.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/TextureArray.rst b/docs/source/api/graphic_features/TextureArray.rst
index 73facc5bf..004881282 100644
--- a/docs/source/api/graphic_features/TextureArray.rst
+++ b/docs/source/api/graphic_features/TextureArray.rst
@@ -23,7 +23,6 @@ Properties
TextureArray.buffer
TextureArray.col_indices
TextureArray.row_indices
- TextureArray.shared
TextureArray.value
Methods
diff --git a/docs/source/api/graphic_features/TextureArrayVolume.rst b/docs/source/api/graphic_features/TextureArrayVolume.rst
new file mode 100644
index 000000000..2f8599ef7
--- /dev/null
+++ b/docs/source/api/graphic_features/TextureArrayVolume.rst
@@ -0,0 +1,39 @@
+.. _api.TextureArrayVolume:
+
+TextureArrayVolume
+******************
+
+==================
+TextureArrayVolume
+==================
+.. currentmodule:: fastplotlib.graphics.features
+
+Constructor
+~~~~~~~~~~~
+.. autosummary::
+ :toctree: TextureArrayVolume_api
+
+ TextureArrayVolume
+
+Properties
+~~~~~~~~~~
+.. autosummary::
+ :toctree: TextureArrayVolume_api
+
+ TextureArrayVolume.buffer
+ TextureArrayVolume.col_indices
+ TextureArrayVolume.row_indices
+ TextureArrayVolume.value
+ TextureArrayVolume.zdim_indices
+
+Methods
+~~~~~~~
+.. autosummary::
+ :toctree: TextureArrayVolume_api
+
+ TextureArrayVolume.add_event_handler
+ TextureArrayVolume.block_events
+ TextureArrayVolume.clear_event_handlers
+ TextureArrayVolume.remove_event_handler
+ TextureArrayVolume.set_value
+
diff --git a/docs/source/api/graphic_features/UniformEdgeColor.rst b/docs/source/api/graphic_features/UniformEdgeColor.rst
new file mode 100644
index 000000000..26489e6d7
--- /dev/null
+++ b/docs/source/api/graphic_features/UniformEdgeColor.rst
@@ -0,0 +1,35 @@
+.. _api.UniformEdgeColor:
+
+UniformEdgeColor
+****************
+
+================
+UniformEdgeColor
+================
+.. currentmodule:: fastplotlib.graphics.features
+
+Constructor
+~~~~~~~~~~~
+.. autosummary::
+ :toctree: UniformEdgeColor_api
+
+ UniformEdgeColor
+
+Properties
+~~~~~~~~~~
+.. autosummary::
+ :toctree: UniformEdgeColor_api
+
+ UniformEdgeColor.value
+
+Methods
+~~~~~~~
+.. autosummary::
+ :toctree: UniformEdgeColor_api
+
+ UniformEdgeColor.add_event_handler
+ UniformEdgeColor.block_events
+ UniformEdgeColor.clear_event_handlers
+ UniformEdgeColor.remove_event_handler
+ UniformEdgeColor.set_value
+
diff --git a/docs/source/api/graphic_features/UniformMarker.rst b/docs/source/api/graphic_features/UniformMarker.rst
new file mode 100644
index 000000000..56b6c2fa4
--- /dev/null
+++ b/docs/source/api/graphic_features/UniformMarker.rst
@@ -0,0 +1,35 @@
+.. _api.UniformMarker:
+
+UniformMarker
+*************
+
+=============
+UniformMarker
+=============
+.. currentmodule:: fastplotlib.graphics.features
+
+Constructor
+~~~~~~~~~~~
+.. autosummary::
+ :toctree: UniformMarker_api
+
+ UniformMarker
+
+Properties
+~~~~~~~~~~
+.. autosummary::
+ :toctree: UniformMarker_api
+
+ UniformMarker.value
+
+Methods
+~~~~~~~
+.. autosummary::
+ :toctree: UniformMarker_api
+
+ UniformMarker.add_event_handler
+ UniformMarker.block_events
+ UniformMarker.clear_event_handlers
+ UniformMarker.remove_event_handler
+ UniformMarker.set_value
+
diff --git a/docs/source/api/graphic_features/UniformRotations.rst b/docs/source/api/graphic_features/UniformRotations.rst
new file mode 100644
index 000000000..f834dbe20
--- /dev/null
+++ b/docs/source/api/graphic_features/UniformRotations.rst
@@ -0,0 +1,35 @@
+.. _api.UniformRotations:
+
+UniformRotations
+****************
+
+================
+UniformRotations
+================
+.. currentmodule:: fastplotlib.graphics.features
+
+Constructor
+~~~~~~~~~~~
+.. autosummary::
+ :toctree: UniformRotations_api
+
+ UniformRotations
+
+Properties
+~~~~~~~~~~
+.. autosummary::
+ :toctree: UniformRotations_api
+
+ UniformRotations.value
+
+Methods
+~~~~~~~
+.. autosummary::
+ :toctree: UniformRotations_api
+
+ UniformRotations.add_event_handler
+ UniformRotations.block_events
+ UniformRotations.clear_event_handlers
+ UniformRotations.remove_event_handler
+ UniformRotations.set_value
+
diff --git a/docs/source/api/graphic_features/VectorDirections.rst b/docs/source/api/graphic_features/VectorDirections.rst
new file mode 100644
index 000000000..99e47b4a1
--- /dev/null
+++ b/docs/source/api/graphic_features/VectorDirections.rst
@@ -0,0 +1,35 @@
+.. _api.VectorDirections:
+
+VectorDirections
+****************
+
+================
+VectorDirections
+================
+.. currentmodule:: fastplotlib.graphics.features
+
+Constructor
+~~~~~~~~~~~
+.. autosummary::
+ :toctree: VectorDirections_api
+
+ VectorDirections
+
+Properties
+~~~~~~~~~~
+.. autosummary::
+ :toctree: VectorDirections_api
+
+ VectorDirections.value
+
+Methods
+~~~~~~~
+.. autosummary::
+ :toctree: VectorDirections_api
+
+ VectorDirections.add_event_handler
+ VectorDirections.block_events
+ VectorDirections.clear_event_handlers
+ VectorDirections.remove_event_handler
+ VectorDirections.set_value
+
diff --git a/docs/source/api/graphic_features/VectorPositions.rst b/docs/source/api/graphic_features/VectorPositions.rst
new file mode 100644
index 000000000..939c00e00
--- /dev/null
+++ b/docs/source/api/graphic_features/VectorPositions.rst
@@ -0,0 +1,35 @@
+.. _api.VectorPositions:
+
+VectorPositions
+***************
+
+===============
+VectorPositions
+===============
+.. currentmodule:: fastplotlib.graphics.features
+
+Constructor
+~~~~~~~~~~~
+.. autosummary::
+ :toctree: VectorPositions_api
+
+ VectorPositions
+
+Properties
+~~~~~~~~~~
+.. autosummary::
+ :toctree: VectorPositions_api
+
+ VectorPositions.value
+
+Methods
+~~~~~~~
+.. autosummary::
+ :toctree: VectorPositions_api
+
+ VectorPositions.add_event_handler
+ VectorPositions.block_events
+ VectorPositions.clear_event_handlers
+ VectorPositions.remove_event_handler
+ VectorPositions.set_value
+
diff --git a/docs/source/api/graphic_features/VertexCmap.rst b/docs/source/api/graphic_features/VertexCmap.rst
index 77d96aaf6..57b9d6311 100644
--- a/docs/source/api/graphic_features/VertexCmap.rst
+++ b/docs/source/api/graphic_features/VertexCmap.rst
@@ -20,10 +20,8 @@ Properties
.. autosummary::
:toctree: VertexCmap_api
- VertexCmap.alpha
VertexCmap.buffer
VertexCmap.name
- VertexCmap.shared
VertexCmap.transform
VertexCmap.value
diff --git a/docs/source/api/graphic_features/VertexColors.rst b/docs/source/api/graphic_features/VertexColors.rst
index d09da7a18..b72b7564a 100644
--- a/docs/source/api/graphic_features/VertexColors.rst
+++ b/docs/source/api/graphic_features/VertexColors.rst
@@ -21,7 +21,6 @@ Properties
:toctree: VertexColors_api
VertexColors.buffer
- VertexColors.shared
VertexColors.value
Methods
diff --git a/docs/source/api/graphic_features/VertexMarkers.rst b/docs/source/api/graphic_features/VertexMarkers.rst
new file mode 100644
index 000000000..bea8dd346
--- /dev/null
+++ b/docs/source/api/graphic_features/VertexMarkers.rst
@@ -0,0 +1,37 @@
+.. _api.VertexMarkers:
+
+VertexMarkers
+*************
+
+=============
+VertexMarkers
+=============
+.. currentmodule:: fastplotlib.graphics.features
+
+Constructor
+~~~~~~~~~~~
+.. autosummary::
+ :toctree: VertexMarkers_api
+
+ VertexMarkers
+
+Properties
+~~~~~~~~~~
+.. autosummary::
+ :toctree: VertexMarkers_api
+
+ VertexMarkers.buffer
+ VertexMarkers.value
+ VertexMarkers.value_int
+
+Methods
+~~~~~~~
+.. autosummary::
+ :toctree: VertexMarkers_api
+
+ VertexMarkers.add_event_handler
+ VertexMarkers.block_events
+ VertexMarkers.clear_event_handlers
+ VertexMarkers.remove_event_handler
+ VertexMarkers.set_value
+
diff --git a/docs/source/api/graphic_features/VertexPointSizes.rst b/docs/source/api/graphic_features/VertexPointSizes.rst
new file mode 100644
index 000000000..07f195f6d
--- /dev/null
+++ b/docs/source/api/graphic_features/VertexPointSizes.rst
@@ -0,0 +1,36 @@
+.. _api.VertexPointSizes:
+
+VertexPointSizes
+****************
+
+================
+VertexPointSizes
+================
+.. currentmodule:: fastplotlib.graphics.features
+
+Constructor
+~~~~~~~~~~~
+.. autosummary::
+ :toctree: VertexPointSizes_api
+
+ VertexPointSizes
+
+Properties
+~~~~~~~~~~
+.. autosummary::
+ :toctree: VertexPointSizes_api
+
+ VertexPointSizes.buffer
+ VertexPointSizes.value
+
+Methods
+~~~~~~~
+.. autosummary::
+ :toctree: VertexPointSizes_api
+
+ VertexPointSizes.add_event_handler
+ VertexPointSizes.block_events
+ VertexPointSizes.clear_event_handlers
+ VertexPointSizes.remove_event_handler
+ VertexPointSizes.set_value
+
diff --git a/docs/source/api/graphic_features/VertexPositions.rst b/docs/source/api/graphic_features/VertexPositions.rst
index d181f07b9..95480e1d4 100644
--- a/docs/source/api/graphic_features/VertexPositions.rst
+++ b/docs/source/api/graphic_features/VertexPositions.rst
@@ -21,7 +21,6 @@ Properties
:toctree: VertexPositions_api
VertexPositions.buffer
- VertexPositions.shared
VertexPositions.value
Methods
diff --git a/docs/source/api/graphic_features/VertexRotations.rst b/docs/source/api/graphic_features/VertexRotations.rst
new file mode 100644
index 000000000..97cf5f4e2
--- /dev/null
+++ b/docs/source/api/graphic_features/VertexRotations.rst
@@ -0,0 +1,36 @@
+.. _api.VertexRotations:
+
+VertexRotations
+***************
+
+===============
+VertexRotations
+===============
+.. currentmodule:: fastplotlib.graphics.features
+
+Constructor
+~~~~~~~~~~~
+.. autosummary::
+ :toctree: VertexRotations_api
+
+ VertexRotations
+
+Properties
+~~~~~~~~~~
+.. autosummary::
+ :toctree: VertexRotations_api
+
+ VertexRotations.buffer
+ VertexRotations.value
+
+Methods
+~~~~~~~
+.. autosummary::
+ :toctree: VertexRotations_api
+
+ VertexRotations.add_event_handler
+ VertexRotations.block_events
+ VertexRotations.clear_event_handlers
+ VertexRotations.remove_event_handler
+ VertexRotations.set_value
+
diff --git a/docs/source/api/graphic_features/VolumeIsoEmissive.rst b/docs/source/api/graphic_features/VolumeIsoEmissive.rst
new file mode 100644
index 000000000..4d7c4bf7d
--- /dev/null
+++ b/docs/source/api/graphic_features/VolumeIsoEmissive.rst
@@ -0,0 +1,35 @@
+.. _api.VolumeIsoEmissive:
+
+VolumeIsoEmissive
+*****************
+
+=================
+VolumeIsoEmissive
+=================
+.. currentmodule:: fastplotlib.graphics.features
+
+Constructor
+~~~~~~~~~~~
+.. autosummary::
+ :toctree: VolumeIsoEmissive_api
+
+ VolumeIsoEmissive
+
+Properties
+~~~~~~~~~~
+.. autosummary::
+ :toctree: VolumeIsoEmissive_api
+
+ VolumeIsoEmissive.value
+
+Methods
+~~~~~~~
+.. autosummary::
+ :toctree: VolumeIsoEmissive_api
+
+ VolumeIsoEmissive.add_event_handler
+ VolumeIsoEmissive.block_events
+ VolumeIsoEmissive.clear_event_handlers
+ VolumeIsoEmissive.remove_event_handler
+ VolumeIsoEmissive.set_value
+
diff --git a/docs/source/api/graphic_features/VolumeIsoShininess.rst b/docs/source/api/graphic_features/VolumeIsoShininess.rst
new file mode 100644
index 000000000..0e4ed6dd3
--- /dev/null
+++ b/docs/source/api/graphic_features/VolumeIsoShininess.rst
@@ -0,0 +1,35 @@
+.. _api.VolumeIsoShininess:
+
+VolumeIsoShininess
+******************
+
+==================
+VolumeIsoShininess
+==================
+.. currentmodule:: fastplotlib.graphics.features
+
+Constructor
+~~~~~~~~~~~
+.. autosummary::
+ :toctree: VolumeIsoShininess_api
+
+ VolumeIsoShininess
+
+Properties
+~~~~~~~~~~
+.. autosummary::
+ :toctree: VolumeIsoShininess_api
+
+ VolumeIsoShininess.value
+
+Methods
+~~~~~~~
+.. autosummary::
+ :toctree: VolumeIsoShininess_api
+
+ VolumeIsoShininess.add_event_handler
+ VolumeIsoShininess.block_events
+ VolumeIsoShininess.clear_event_handlers
+ VolumeIsoShininess.remove_event_handler
+ VolumeIsoShininess.set_value
+
diff --git a/docs/source/api/graphic_features/VolumeIsoStepSize.rst b/docs/source/api/graphic_features/VolumeIsoStepSize.rst
new file mode 100644
index 000000000..91f838d7a
--- /dev/null
+++ b/docs/source/api/graphic_features/VolumeIsoStepSize.rst
@@ -0,0 +1,35 @@
+.. _api.VolumeIsoStepSize:
+
+VolumeIsoStepSize
+*****************
+
+=================
+VolumeIsoStepSize
+=================
+.. currentmodule:: fastplotlib.graphics.features
+
+Constructor
+~~~~~~~~~~~
+.. autosummary::
+ :toctree: VolumeIsoStepSize_api
+
+ VolumeIsoStepSize
+
+Properties
+~~~~~~~~~~
+.. autosummary::
+ :toctree: VolumeIsoStepSize_api
+
+ VolumeIsoStepSize.value
+
+Methods
+~~~~~~~
+.. autosummary::
+ :toctree: VolumeIsoStepSize_api
+
+ VolumeIsoStepSize.add_event_handler
+ VolumeIsoStepSize.block_events
+ VolumeIsoStepSize.clear_event_handlers
+ VolumeIsoStepSize.remove_event_handler
+ VolumeIsoStepSize.set_value
+
diff --git a/docs/source/api/graphic_features/VolumeIsoSubStepSize.rst b/docs/source/api/graphic_features/VolumeIsoSubStepSize.rst
new file mode 100644
index 000000000..db81fee8a
--- /dev/null
+++ b/docs/source/api/graphic_features/VolumeIsoSubStepSize.rst
@@ -0,0 +1,35 @@
+.. _api.VolumeIsoSubStepSize:
+
+VolumeIsoSubStepSize
+********************
+
+====================
+VolumeIsoSubStepSize
+====================
+.. currentmodule:: fastplotlib.graphics.features
+
+Constructor
+~~~~~~~~~~~
+.. autosummary::
+ :toctree: VolumeIsoSubStepSize_api
+
+ VolumeIsoSubStepSize
+
+Properties
+~~~~~~~~~~
+.. autosummary::
+ :toctree: VolumeIsoSubStepSize_api
+
+ VolumeIsoSubStepSize.value
+
+Methods
+~~~~~~~
+.. autosummary::
+ :toctree: VolumeIsoSubStepSize_api
+
+ VolumeIsoSubStepSize.add_event_handler
+ VolumeIsoSubStepSize.block_events
+ VolumeIsoSubStepSize.clear_event_handlers
+ VolumeIsoSubStepSize.remove_event_handler
+ VolumeIsoSubStepSize.set_value
+
diff --git a/docs/source/api/graphic_features/VolumeIsoThreshold.rst b/docs/source/api/graphic_features/VolumeIsoThreshold.rst
new file mode 100644
index 000000000..9fa4ab616
--- /dev/null
+++ b/docs/source/api/graphic_features/VolumeIsoThreshold.rst
@@ -0,0 +1,35 @@
+.. _api.VolumeIsoThreshold:
+
+VolumeIsoThreshold
+******************
+
+==================
+VolumeIsoThreshold
+==================
+.. currentmodule:: fastplotlib.graphics.features
+
+Constructor
+~~~~~~~~~~~
+.. autosummary::
+ :toctree: VolumeIsoThreshold_api
+
+ VolumeIsoThreshold
+
+Properties
+~~~~~~~~~~
+.. autosummary::
+ :toctree: VolumeIsoThreshold_api
+
+ VolumeIsoThreshold.value
+
+Methods
+~~~~~~~
+.. autosummary::
+ :toctree: VolumeIsoThreshold_api
+
+ VolumeIsoThreshold.add_event_handler
+ VolumeIsoThreshold.block_events
+ VolumeIsoThreshold.clear_event_handlers
+ VolumeIsoThreshold.remove_event_handler
+ VolumeIsoThreshold.set_value
+
diff --git a/docs/source/api/graphic_features/VolumeRenderMode.rst b/docs/source/api/graphic_features/VolumeRenderMode.rst
new file mode 100644
index 000000000..8e5c1a56c
--- /dev/null
+++ b/docs/source/api/graphic_features/VolumeRenderMode.rst
@@ -0,0 +1,35 @@
+.. _api.VolumeRenderMode:
+
+VolumeRenderMode
+****************
+
+================
+VolumeRenderMode
+================
+.. currentmodule:: fastplotlib.graphics.features
+
+Constructor
+~~~~~~~~~~~
+.. autosummary::
+ :toctree: VolumeRenderMode_api
+
+ VolumeRenderMode
+
+Properties
+~~~~~~~~~~
+.. autosummary::
+ :toctree: VolumeRenderMode_api
+
+ VolumeRenderMode.value
+
+Methods
+~~~~~~~
+.. autosummary::
+ :toctree: VolumeRenderMode_api
+
+ VolumeRenderMode.add_event_handler
+ VolumeRenderMode.block_events
+ VolumeRenderMode.clear_event_handlers
+ VolumeRenderMode.remove_event_handler
+ VolumeRenderMode.set_value
+
diff --git a/docs/source/api/graphic_features/VolumeSlicePlane.rst b/docs/source/api/graphic_features/VolumeSlicePlane.rst
new file mode 100644
index 000000000..fc58ee222
--- /dev/null
+++ b/docs/source/api/graphic_features/VolumeSlicePlane.rst
@@ -0,0 +1,35 @@
+.. _api.VolumeSlicePlane:
+
+VolumeSlicePlane
+****************
+
+================
+VolumeSlicePlane
+================
+.. currentmodule:: fastplotlib.graphics.features
+
+Constructor
+~~~~~~~~~~~
+.. autosummary::
+ :toctree: VolumeSlicePlane_api
+
+ VolumeSlicePlane
+
+Properties
+~~~~~~~~~~
+.. autosummary::
+ :toctree: VolumeSlicePlane_api
+
+ VolumeSlicePlane.value
+
+Methods
+~~~~~~~
+.. autosummary::
+ :toctree: VolumeSlicePlane_api
+
+ VolumeSlicePlane.add_event_handler
+ VolumeSlicePlane.block_events
+ VolumeSlicePlane.clear_event_handlers
+ VolumeSlicePlane.remove_event_handler
+ VolumeSlicePlane.set_value
+
diff --git a/docs/source/api/graphic_features/index.rst b/docs/source/api/graphic_features/index.rst
index 90a58fe8e..71268ddab 100644
--- a/docs/source/api/graphic_features/index.rst
+++ b/docs/source/api/graphic_features/index.rst
@@ -6,18 +6,37 @@ Graphic Features
VertexColors
UniformColor
- UniformSize
SizeSpace
- Thickness
VertexPositions
- PointsSizesFeature
VertexCmap
+ MeshIndices
+ MeshCmap
+ SurfaceData
+ Thickness
+ VertexMarkers
+ UniformMarker
+ UniformEdgeColor
+ EdgeWidth
+ UniformRotations
+ VertexRotations
+ VertexPointSizes
+ UniformSize
TextureArray
ImageCmap
ImageVmin
ImageVmax
ImageInterpolation
ImageCmapInterpolation
+ TextureArrayVolume
+ VolumeRenderMode
+ VolumeIsoThreshold
+ VolumeIsoStepSize
+ VolumeIsoSubStepSize
+ VolumeIsoEmissive
+ VolumeIsoShininess
+ VolumeSlicePlane
+ VectorPositions
+ VectorDirections
TextData
FontSize
TextFaceColor
@@ -29,6 +48,9 @@ Graphic Features
Name
Offset
Rotation
+ Scale
+ Alpha
+ AlphaMode
Visible
Deleted
GraphicFeatureEvent
diff --git a/docs/source/api/graphics/Graphic.rst b/docs/source/api/graphics/Graphic.rst
index cf68888f5..f94892949 100644
--- a/docs/source/api/graphics/Graphic.rst
+++ b/docs/source/api/graphics/Graphic.rst
@@ -20,6 +20,8 @@ Properties
.. autosummary::
:toctree: Graphic_api
+ Graphic.alpha
+ Graphic.alpha_mode
Graphic.axes
Graphic.block_events
Graphic.deleted
@@ -28,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
@@ -40,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 27bda3d32..e6d02c54b 100644
--- a/docs/source/api/graphics/ImageGraphic.rst
+++ b/docs/source/api/graphics/ImageGraphic.rst
@@ -20,6 +20,8 @@ Properties
.. autosummary::
:toctree: ImageGraphic_api
+ ImageGraphic.alpha
+ ImageGraphic.alpha_mode
ImageGraphic.axes
ImageGraphic.block_events
ImageGraphic.cmap
@@ -32,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
@@ -47,8 +51,12 @@ Methods
ImageGraphic.add_event_handler
ImageGraphic.add_linear_region_selector
ImageGraphic.add_linear_selector
+ 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
new file mode 100644
index 000000000..8031f12f1
--- /dev/null
+++ b/docs/source/api/graphics/ImageVolumeGraphic.rst
@@ -0,0 +1,66 @@
+.. _api.ImageVolumeGraphic:
+
+ImageVolumeGraphic
+******************
+
+==================
+ImageVolumeGraphic
+==================
+.. currentmodule:: fastplotlib
+
+Constructor
+~~~~~~~~~~~
+.. autosummary::
+ :toctree: ImageVolumeGraphic_api
+
+ ImageVolumeGraphic
+
+Properties
+~~~~~~~~~~
+.. autosummary::
+ :toctree: ImageVolumeGraphic_api
+
+ ImageVolumeGraphic.alpha
+ ImageVolumeGraphic.alpha_mode
+ ImageVolumeGraphic.axes
+ ImageVolumeGraphic.block_events
+ ImageVolumeGraphic.cmap
+ ImageVolumeGraphic.cmap_interpolation
+ ImageVolumeGraphic.data
+ ImageVolumeGraphic.deleted
+ ImageVolumeGraphic.emissive
+ ImageVolumeGraphic.event_handlers
+ ImageVolumeGraphic.interpolation
+ ImageVolumeGraphic.mode
+ ImageVolumeGraphic.name
+ ImageVolumeGraphic.offset
+ 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
+ ImageVolumeGraphic.world_object
+
+Methods
+~~~~~~~
+.. autosummary::
+ :toctree: ImageVolumeGraphic_api
+
+ 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 12c7b5c95..5d0603ab7 100644
--- a/docs/source/api/graphics/LineCollection.rst
+++ b/docs/source/api/graphics/LineCollection.rst
@@ -20,6 +20,8 @@ Properties
.. autosummary::
:toctree: LineCollection_api
+ LineCollection.alpha
+ LineCollection.alpha_mode
LineCollection.axes
LineCollection.block_events
LineCollection.cmap
@@ -36,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
@@ -52,8 +56,12 @@ Methods
LineCollection.add_graphic
LineCollection.add_linear_region_selector
LineCollection.add_linear_selector
+ 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 c6e18b41b..428e8ef56 100644
--- a/docs/source/api/graphics/LineGraphic.rst
+++ b/docs/source/api/graphics/LineGraphic.rst
@@ -20,6 +20,8 @@ Properties
.. autosummary::
:toctree: LineGraphic_api
+ LineGraphic.alpha
+ LineGraphic.alpha_mode
LineGraphic.axes
LineGraphic.block_events
LineGraphic.cmap
@@ -31,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
@@ -46,8 +50,12 @@ Methods
LineGraphic.add_event_handler
LineGraphic.add_linear_region_selector
LineGraphic.add_linear_selector
+ 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 e1deb75ae..e7ac21343 100644
--- a/docs/source/api/graphics/LineStack.rst
+++ b/docs/source/api/graphics/LineStack.rst
@@ -20,6 +20,8 @@ Properties
.. autosummary::
:toctree: LineStack_api
+ LineStack.alpha
+ LineStack.alpha_mode
LineStack.axes
LineStack.block_events
LineStack.cmap
@@ -36,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
@@ -52,8 +56,12 @@ Methods
LineStack.add_graphic
LineStack.add_linear_region_selector
LineStack.add_linear_selector
+ 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 968f0e091..cf8e1224d 100644
--- a/docs/source/api/graphics/ScatterGraphic.rst
+++ b/docs/source/api/graphics/ScatterGraphic.rst
@@ -20,20 +20,31 @@ Properties
.. autosummary::
:toctree: ScatterGraphic_api
+ ScatterGraphic.alpha
+ ScatterGraphic.alpha_mode
ScatterGraphic.axes
ScatterGraphic.block_events
ScatterGraphic.cmap
ScatterGraphic.colors
ScatterGraphic.data
ScatterGraphic.deleted
+ ScatterGraphic.edge_colors
+ ScatterGraphic.edge_width
ScatterGraphic.event_handlers
+ ScatterGraphic.image
+ ScatterGraphic.markers
+ ScatterGraphic.mode
ScatterGraphic.name
ScatterGraphic.offset
+ ScatterGraphic.point_rotation_mode
+ 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
@@ -45,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 60cd97f40..da4909686 100644
--- a/docs/source/api/graphics/TextGraphic.rst
+++ b/docs/source/api/graphics/TextGraphic.rst
@@ -20,6 +20,8 @@ Properties
.. autosummary::
:toctree: TextGraphic_api
+ TextGraphic.alpha
+ TextGraphic.alpha_mode
TextGraphic.axes
TextGraphic.block_events
TextGraphic.deleted
@@ -32,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
@@ -45,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
new file mode 100644
index 000000000..ec7d891c0
--- /dev/null
+++ b/docs/source/api/graphics/VectorsGraphic.rst
@@ -0,0 +1,54 @@
+.. _api.VectorsGraphic:
+
+VectorsGraphic
+**************
+
+==============
+VectorsGraphic
+==============
+.. currentmodule:: fastplotlib
+
+Constructor
+~~~~~~~~~~~
+.. autosummary::
+ :toctree: VectorsGraphic_api
+
+ VectorsGraphic
+
+Properties
+~~~~~~~~~~
+.. autosummary::
+ :toctree: VectorsGraphic_api
+
+ VectorsGraphic.alpha
+ VectorsGraphic.alpha_mode
+ VectorsGraphic.axes
+ VectorsGraphic.block_events
+ VectorsGraphic.deleted
+ VectorsGraphic.directions
+ VectorsGraphic.event_handlers
+ VectorsGraphic.name
+ VectorsGraphic.offset
+ VectorsGraphic.positions
+ VectorsGraphic.right_click_menu
+ VectorsGraphic.rotation
+ VectorsGraphic.scale
+ VectorsGraphic.supported_events
+ VectorsGraphic.tooltip_format
+ VectorsGraphic.visible
+ VectorsGraphic.world_object
+
+Methods
+~~~~~~~
+.. autosummary::
+ :toctree: VectorsGraphic_api
+
+ 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 491013dff..bac85e6c1 100644
--- a/docs/source/api/graphics/index.rst
+++ b/docs/source/api/graphics/index.rst
@@ -8,6 +8,11 @@ Graphics
LineGraphic
ScatterGraphic
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 d191fe8ce..54e91b24f 100644
--- a/docs/source/api/layouts/figure.rst
+++ b/docs/source/api/layouts/figure.rst
@@ -20,6 +20,7 @@ Properties
.. autosummary::
:toctree: Figure_api
+ Figure.animations
Figure.cameras
Figure.canvas
Figure.controllers
@@ -27,8 +28,6 @@ Properties
Figure.names
Figure.renderer
Figure.shape
- Figure.show_tooltips
- Figure.tooltip_manager
Methods
~~~~~~~
@@ -38,6 +37,7 @@ Methods
Figure.add_animations
Figure.add_subplot
Figure.clear
+ Figure.clear_animations
Figure.close
Figure.export
Figure.export_numpy
diff --git a/docs/source/api/layouts/imgui_figure.rst b/docs/source/api/layouts/imgui_figure.rst
index 0abfcc067..46e0c6ed3 100644
--- a/docs/source/api/layouts/imgui_figure.rst
+++ b/docs/source/api/layouts/imgui_figure.rst
@@ -20,17 +20,17 @@ Properties
.. autosummary::
:toctree: ImguiFigure_api
+ ImguiFigure.animations
ImguiFigure.cameras
ImguiFigure.canvas
ImguiFigure.controllers
+ ImguiFigure.default_imgui_font
ImguiFigure.guis
ImguiFigure.imgui_renderer
ImguiFigure.layout
ImguiFigure.names
ImguiFigure.renderer
ImguiFigure.shape
- ImguiFigure.show_tooltips
- ImguiFigure.tooltip_manager
Methods
~~~~~~~
@@ -41,6 +41,7 @@ Methods
ImguiFigure.add_gui
ImguiFigure.add_subplot
ImguiFigure.clear
+ ImguiFigure.clear_animations
ImguiFigure.close
ImguiFigure.export
ImguiFigure.export_numpy
diff --git a/docs/source/api/layouts/subplot.rst b/docs/source/api/layouts/subplot.rst
index e1c55514d..0916859b9 100644
--- a/docs/source/api/layouts/subplot.rst
+++ b/docs/source/api/layouts/subplot.rst
@@ -20,11 +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
@@ -37,6 +40,7 @@ Properties
Subplot.selectors
Subplot.title
Subplot.toolbar
+ Subplot.tooltip
Subplot.viewport
Methods
@@ -47,19 +51,27 @@ Methods
Subplot.add_animations
Subplot.add_graphic
Subplot.add_image
+ Subplot.add_image_volume
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
Subplot.center_graphic
Subplot.center_scene
Subplot.clear
+ 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 2c23bc82a..eb48497cd 100644
--- a/docs/source/api/selectors/LinearRegionSelector.rst
+++ b/docs/source/api/selectors/LinearRegionSelector.rst
@@ -20,6 +20,8 @@ Properties
.. autosummary::
:toctree: LinearRegionSelector_api
+ LinearRegionSelector.alpha
+ LinearRegionSelector.alpha_mode
LinearRegionSelector.axes
LinearRegionSelector.axis
LinearRegionSelector.block_events
@@ -33,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
@@ -47,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 c7a8e978a..2aa334748 100644
--- a/docs/source/api/selectors/LinearSelector.rst
+++ b/docs/source/api/selectors/LinearSelector.rst
@@ -20,6 +20,8 @@ Properties
.. autosummary::
:toctree: LinearSelector_api
+ LinearSelector.alpha
+ LinearSelector.alpha_mode
LinearSelector.axes
LinearSelector.axis
LinearSelector.block_events
@@ -33,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
@@ -47,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 24928c817..51f6801a4 100644
--- a/docs/source/api/selectors/RectangleSelector.rst
+++ b/docs/source/api/selectors/RectangleSelector.rst
@@ -20,6 +20,8 @@ Properties
.. autosummary::
:toctree: RectangleSelector_api
+ RectangleSelector.alpha
+ RectangleSelector.alpha_mode
RectangleSelector.axes
RectangleSelector.axis
RectangleSelector.block_events
@@ -33,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
@@ -47,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 7c3237490..b3498dd68 100644
--- a/docs/source/api/tools/HistogramLUTTool.rst
+++ b/docs/source/api/tools/HistogramLUTTool.rst
@@ -20,17 +20,21 @@ Properties
.. autosummary::
:toctree: HistogramLUTTool_api
+ HistogramLUTTool.alpha
+ HistogramLUTTool.alpha_mode
HistogramLUTTool.axes
HistogramLUTTool.block_events
HistogramLUTTool.cmap
HistogramLUTTool.deleted
HistogramLUTTool.event_handlers
- HistogramLUTTool.image_graphic
+ HistogramLUTTool.images
HistogramLUTTool.name
HistogramLUTTool.offset
HistogramLUTTool.right_click_menu
HistogramLUTTool.rotation
+ HistogramLUTTool.scale
HistogramLUTTool.supported_events
+ HistogramLUTTool.tooltip_format
HistogramLUTTool.visible
HistogramLUTTool.vmax
HistogramLUTTool.vmin
@@ -44,7 +48,9 @@ Methods
HistogramLUTTool.add_axes
HistogramLUTTool.add_event_handler
HistogramLUTTool.clear_event_handlers
- HistogramLUTTool.disconnect_image_graphic
+ 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 e4ff72237..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 = {
@@ -57,6 +54,7 @@
"subsection_order": ExplicitOrder(
[
"../../examples/image",
+ "../../examples/image_volume",
"../../examples/heatmap",
"../../examples/image_widget",
"../../examples/gridplot",
@@ -64,10 +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",
@@ -75,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)
@@ -105,7 +106,7 @@
"check_switcher": True,
"switcher": {
"json_url": "http://www.fastplotlib.org/_static/switcher.json",
- "version_match": release
+ "version_match": release,
},
"icon_links": [
{
@@ -113,7 +114,7 @@
"url": "https://github.com/fastplotlib/fastplotlib",
"icon": "fa-brands fa-github",
}
- ]
+ ],
}
html_static_path = ["_static"]
@@ -126,11 +127,13 @@
autodoc_typehints = "description"
autodoc_typehints_description_target = "documented_params"
+autodoc_preserve_defaults = True
intersphinx_mapping = {
"python": ("https://docs.python.org/3", None),
"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 1b9b2f7ec..42f168bea 100644
--- a/docs/source/user_guide/event_tables.rst
+++ b/docs/source/user_guide/event_tables.rst
@@ -39,11 +39,11 @@ colors
**event info dict**
-+----------+-------------------+-----------------+
-| dict key | type | description |
-+==========+===================+=================+
-| value | np.ndarray [RGBA] | new color value |
-+----------+-------------------+-----------------+
++----------+--------------------------------------------------+-----------------+
+| dict key | type | description |
++==========+==================================================+=================+
+| value | str | pygfx.Color | np.ndarray | Sequence[float] | new color value |
++----------+--------------------------------------------------+-----------------+
cmap
^^^^
@@ -113,6 +113,977 @@ 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 |
++----------+------+-------------------------------+
+
+ScatterGraphic
+--------------
+
+data
+^^^^
+
+**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 |
++----------+----------------------------------------------+--------------------------------------------------------+
+
+sizes
+^^^^^
+
+**event info dict**
+
++----------+----------------------------------------------+----------------------------------------------+
+| dict key | type | description |
++==========+==============================================+==============================================+
+| key | slice, index (int) or numpy-like fancy index | key at which point sizes were indexed/sliced |
++----------+----------------------------------------------+----------------------------------------------+
+| value | int | float | array-like | new size values for points that were changed |
++----------+----------------------------------------------+----------------------------------------------+
+
+sizes
+^^^^^
+
+**event info dict**
+
++----------+-------+----------------+
+| dict key | type | description |
++==========+=======+================+
+| value | float | new size value |
++----------+-------+----------------+
+
+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 |
++==========+=======+================================+
+| key | slice | key at cmap colors were sliced |
++----------+-------+--------------------------------+
+| value | str | new cmap to set at given slice |
++----------+-------+--------------------------------+
+
+markers
+^^^^^^^
+
+**event info dict**
+
++----------+----------------------------------------------+------------------------------------------------+
+| dict key | type | description |
++==========+==============================================+================================================+
+| key | slice, index (int) or numpy-like fancy index | key at which markers were indexed/sliced |
++----------+----------------------------------------------+------------------------------------------------+
+| value | str | np.ndarray[str] | new marker values for points that were changed |
++----------+----------------------------------------------+------------------------------------------------+
+
+markers
+^^^^^^^
+
+**event info dict**
+
++----------+------------+------------------+
+| dict key | type | description |
++==========+============+==================+
+| value | str | None | new marker value |
++----------+------------+------------------+
+
+edge_colors
+^^^^^^^^^^^
+
+**event info dict**
+
++----------+--------------------------------------------------+----------------+
+| dict key | type | description |
++==========+==================================================+================+
+| value | str | np.ndarray | pygfx.Color | Sequence[float] | new edge_color |
++----------+--------------------------------------------------+----------------+
+
+edge_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 |
++------------+--------------------------------------+------------------------------------------------------+
+
+edge_width
+^^^^^^^^^^
+
+**event info dict**
+
++----------+-------+----------------+
+| dict key | type | description |
++==========+=======+================+
+| value | float | new edge_width |
++----------+-------+----------------+
+
+image
+^^^^^
+
+**event info dict**
+
++----------+--------------------------------------+--------------------------------------------------+
+| dict key | type | description |
++==========+======================================+==================================================+
+| key | slice, index, numpy-like fancy index | key at which image data was sliced/fancy indexed |
++----------+--------------------------------------+--------------------------------------------------+
+| value | np.ndarray | float | new data values |
++----------+--------------------------------------+--------------------------------------------------+
+
+size_space
+^^^^^^^^^^
+
+**event info dict**
+
++----------+------+------------------------------+
+| dict key | type | description |
++==========+======+==============================+
+| value | str | 'screen' | 'world' | 'model' |
++----------+------+------------------------------+
+
+point_rotations
+^^^^^^^^^^^^^^^
+
+**event info dict**
+
++----------+-------+----------------+
+| dict key | type | description |
++==========+=======+================+
+| value | float | new edge_width |
++----------+-------+----------------+
+
+point_rotations
+^^^^^^^^^^^^^^^
+
+**event info dict**
+
++----------+----------------------------------------------+--------------------------------------------------+
+| dict key | type | description |
++==========+==============================================+==================================================+
+| key | slice, index (int) or numpy-like fancy index | key at which point rotations were indexed/sliced |
++----------+----------------------------------------------+--------------------------------------------------+
+| value | int | float | array-like | new rotation values for points that were changed |
++----------+----------------------------------------------+--------------------------------------------------+
+
+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 |
++----------+------+-------------------------------+
+
+ImageGraphic
+------------
+
+data
+^^^^
+
+**event info dict**
+
++----------+--------------------------------------+--------------------------------------------------+
+| dict key | type | description |
++==========+======================================+==================================================+
+| key | slice, index, numpy-like fancy index | key at which image data was sliced/fancy indexed |
++----------+--------------------------------------+--------------------------------------------------+
+| value | np.ndarray | float | new data values |
++----------+--------------------------------------+--------------------------------------------------+
+
+cmap
+^^^^
+
+**event info dict**
+
++----------+------+---------------+
+| dict key | type | description |
++==========+======+===============+
+| value | str | new cmap name |
++----------+------+---------------+
+
+vmin
+^^^^
+
+**event info dict**
+
++----------+-------+----------------+
+| dict key | type | description |
++==========+=======+================+
+| value | float | new vmin value |
++----------+-------+----------------+
+
+vmax
+^^^^
+
+**event info dict**
+
++----------+-------+----------------+
+| dict key | type | description |
++==========+=======+================+
+| value | float | new vmax value |
++----------+-------+----------------+
+
+interpolation
+^^^^^^^^^^^^^
+
+**event info dict**
+
++----------+------+--------------------------------------------+
+| dict key | type | description |
++==========+======+============================================+
+| value | str | new interpolation method, nearest | linear |
++----------+------+--------------------------------------------+
+
+cmap_interpolation
+^^^^^^^^^^^^^^^^^^
+
+**event info dict**
+
++----------+------+------------------------------------------------+
+| dict key | type | description |
++==========+======+================================================+
+| value | str | new cmap interpolatio method, nearest | linear |
++----------+------+------------------------------------------------+
+
+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 |
++----------+------+-------------------------------+
+
+ImageVolumeGraphic
+------------------
+
+data
+^^^^
+
+**event info dict**
+
++----------+--------------------------------------+--------------------------------------------------+
+| dict key | type | description |
++==========+======================================+==================================================+
+| key | slice, index, numpy-like fancy index | key at which image data was sliced/fancy indexed |
++----------+--------------------------------------+--------------------------------------------------+
+| value | np.ndarray | float | new data values |
++----------+--------------------------------------+--------------------------------------------------+
+
+cmap
+^^^^
+
+**event info dict**
+
++----------+------+---------------+
+| dict key | type | description |
++==========+======+===============+
+| value | str | new cmap name |
++----------+------+---------------+
+
+vmin
+^^^^
+
+**event info dict**
+
++----------+-------+----------------+
+| dict key | type | description |
++==========+=======+================+
+| value | float | new vmin value |
++----------+-------+----------------+
+
+vmax
+^^^^
+
+**event info dict**
+
++----------+-------+----------------+
+| dict key | type | description |
++==========+=======+================+
+| value | float | new vmax value |
++----------+-------+----------------+
+
+interpolation
+^^^^^^^^^^^^^
+
+**event info dict**
+
++----------+------+--------------------------------------------+
+| dict key | type | description |
++==========+======+============================================+
+| value | str | new interpolation method, nearest | linear |
++----------+------+--------------------------------------------+
+
+cmap_interpolation
+^^^^^^^^^^^^^^^^^^
+
+**event info dict**
+
++----------+------+------------------------------------------------+
+| dict key | type | description |
++==========+======+================================================+
+| value | str | new cmap interpolatio method, nearest | linear |
++----------+------+------------------------------------------------+
+
+mode
+^^^^
+
+**event info dict**
+
++----------+------+-----------------------------------------+
+| dict key | type | description |
++==========+======+=========================================+
+| value | str | volume rendering mode that has been set |
++----------+------+-----------------------------------------+
+
+threshold
+^^^^^^^^^
+
+**event info dict**
+
++----------+-------+--------------------------+
+| dict key | type | description |
++==========+=======+==========================+
+| value | float | new isosurface threshold |
++----------+-------+--------------------------+
+
+step_size
+^^^^^^^^^
+
+**event info dict**
+
++----------+-------+--------------------------+
+| dict key | type | description |
++==========+=======+==========================+
+| value | float | new isosurface step_size |
++----------+-------+--------------------------+
+
+substep_size
+^^^^^^^^^^^^
+
+**event info dict**
+
++----------+-------+--------------------------+
+| dict key | type | description |
++==========+=======+==========================+
+| value | float | new isosurface step_size |
++----------+-------+--------------------------+
+
+emissive
+^^^^^^^^
+
+**event info dict**
+
++----------+-------------+-------------------------------+
+| dict key | type | description |
++==========+=============+===============================+
+| value | pygfx.Color | new isosurface emissive color |
++----------+-------------+-------------------------------+
+
+shininess
+^^^^^^^^^
+
+**event info dict**
+
++----------+------+--------------------------+
+| dict key | type | description |
++==========+======+==========================+
+| value | int | new isosurface shininess |
++----------+------+--------------------------+
+
+plane
+^^^^^
+
+**event info dict**
+
++----------+-----------------------------------+-----------------+
+| dict key | type | description |
++==========+===================================+=================+
+| value | tuple[float, float, float, float] | new plane slice |
++----------+-----------------------------------+-----------------+
+
+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 |
++----------+------+-------------------------------+
+
+VectorsGraphic
+--------------
+
+positions
+^^^^^^^^^
+
+**event info dict**
+
++----------+------------+----------------------+
+| dict key | type | description |
++==========+============+======================+
+| value | np.ndarray | new vector positions |
++----------+------------+----------------------+
+
+directions
+^^^^^^^^^^
+
+**event info dict**
+
++----------+------------+-----------------------+
+| dict key | type | description |
++==========+============+=======================+
+| value | np.ndarray | new vector directions |
++----------+------------+-----------------------+
+
+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 |
++----------+------+-------------------------------+
+
+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
^^^^^^^
@@ -135,7 +1106,7 @@ deleted
| value | bool | True when graphic was deleted |
+----------+------+-------------------------------+
-ScatterGraphic
+SurfaceGraphic
--------------
data
@@ -143,37 +1114,11 @@ data
**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 |
-+----------+----------------------------------------------+--------------------------------------------------------+
-
-sizes
-^^^^^
-
-**event info dict**
-
-+----------+----------------------------------------------+----------------------------------------------+
-| dict key | type | description |
-+==========+==============================================+==============================================+
-| key | slice, index (int) or numpy-like fancy index | key at which point sizes were indexed/sliced |
-+----------+----------------------------------------------+----------------------------------------------+
-| value | int | float | array-like | new size values for points that were changed |
-+----------+----------------------------------------------+----------------------------------------------+
-
-sizes
-^^^^^
-
-**event info dict**
-
-+----------+-------+----------------+
-| dict key | type | description |
-+==========+=======+================+
-| value | float | new size value |
-+----------+-------+----------------+
++----------+------------+------------------+
+| dict key | type | description |
++==========+============+==================+
+| value | np.ndarray | new surface data |
++----------+------------+------------------+
colors
^^^^^^
@@ -195,35 +1140,22 @@ colors
**event info dict**
-+----------+-------------------+-----------------+
-| dict key | type | description |
-+==========+===================+=================+
-| value | np.ndarray [RGBA] | new color value |
-+----------+-------------------+-----------------+
++----------+--------------------------------------------------+-----------------+
+| dict key | type | description |
++==========+==================================================+=================+
+| value | str | pygfx.Color | np.ndarray | Sequence[float] | new color value |
++----------+--------------------------------------------------+-----------------+
cmap
^^^^
**event info dict**
-+----------+-------+--------------------------------+
-| dict key | type | description |
-+==========+=======+================================+
-| key | slice | key at cmap colors were sliced |
-+----------+-------+--------------------------------+
-| value | str | new cmap to set at given slice |
-+----------+-------+--------------------------------+
-
-size_space
-^^^^^^^^^^
-
-**event info dict**
-
-+----------+------+------------------------------+
-| dict key | type | description |
-+==========+======+==============================+
-| value | str | 'screen' | 'world' | 'model' |
-+----------+------+------------------------------+
++----------+------------------------------------------------------------+-------------+
+| dict key | type | description |
++==========+============================================================+=============+
+| value | str | dict | pygfx.TextureMap | pygfx.Texture | np.ndarray | new cmap |
++----------+------------------------------------------------------------+-------------+
name
^^^^
@@ -258,6 +1190,39 @@ 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
^^^^^^^
@@ -280,76 +1245,56 @@ deleted
| value | bool | True when graphic was deleted |
+----------+------+-------------------------------+
-ImageGraphic
-------------
+PolygonGraphic
+--------------
data
^^^^
**event info dict**
-+----------+--------------------------------------+--------------------------------------------------+
-| dict key | type | description |
-+==========+======================================+==================================================+
-| key | slice, index, numpy-like fancy index | key at which image data was sliced/fancy indexed |
-+----------+--------------------------------------+--------------------------------------------------+
-| value | np.ndarray | float | new data values |
-+----------+--------------------------------------+--------------------------------------------------+
++----------+------------+------------------+
+| dict key | type | description |
++==========+============+==================+
+| value | np.ndarray | new surface data |
++----------+------------+------------------+
-cmap
-^^^^
+colors
+^^^^^^
**event info dict**
-+----------+------+---------------+
-| dict key | type | description |
-+==========+======+===============+
-| value | str | new cmap name |
-+----------+------+---------------+
++------------+--------------------------------------+------------------------------------------------------+
+| 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 |
++------------+--------------------------------------+------------------------------------------------------+
-vmin
-^^^^
+colors
+^^^^^^
**event info dict**
-+----------+-------+----------------+
-| dict key | type | description |
-+==========+=======+================+
-| value | float | new vmin value |
-+----------+-------+----------------+
++----------+--------------------------------------------------+-----------------+
+| dict key | type | description |
++==========+==================================================+=================+
+| value | str | pygfx.Color | np.ndarray | Sequence[float] | new color value |
++----------+--------------------------------------------------+-----------------+
-vmax
+cmap
^^^^
**event info dict**
-+----------+-------+----------------+
-| dict key | type | description |
-+==========+=======+================+
-| value | float | new vmax value |
-+----------+-------+----------------+
-
-interpolation
-^^^^^^^^^^^^^
-
-**event info dict**
-
-+----------+------+--------------------------------------------+
-| dict key | type | description |
-+==========+======+============================================+
-| value | str | new interpolation method, nearest | linear |
-+----------+------+--------------------------------------------+
-
-cmap_interpolation
-^^^^^^^^^^^^^^^^^^
-
-**event info dict**
-
-+----------+------+------------------------------------------------+
-| dict key | type | description |
-+==========+======+================================================+
-| value | str | new cmap interpolatio method, nearest | linear |
-+----------+------+------------------------------------------------+
++----------+------------------------------------------------------------+-------------+
+| dict key | type | description |
++==========+============================================================+=============+
+| value | str | dict | pygfx.TextureMap | pygfx.Texture | np.ndarray | new cmap |
++----------+------------------------------------------------------------+-------------+
name
^^^^
@@ -384,6 +1329,39 @@ 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
^^^^^^^
@@ -497,6 +1475,39 @@ 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
^^^^^^^
@@ -555,11 +1566,11 @@ colors
**event info dict**
-+----------+-------------------+-----------------+
-| dict key | type | description |
-+==========+===================+=================+
-| value | np.ndarray [RGBA] | new color value |
-+----------+-------------------+-----------------+
++----------+--------------------------------------------------+-----------------+
+| dict key | type | description |
++==========+==================================================+=================+
+| value | str | pygfx.Color | np.ndarray | Sequence[float] | new color value |
++----------+--------------------------------------------------+-----------------+
cmap
^^^^
@@ -629,6 +1640,39 @@ 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
^^^^^^^
@@ -687,11 +1731,11 @@ colors
**event info dict**
-+----------+-------------------+-----------------+
-| dict key | type | description |
-+==========+===================+=================+
-| value | np.ndarray [RGBA] | new color value |
-+----------+-------------------+-----------------+
++----------+--------------------------------------------------+-----------------+
+| dict key | type | description |
++==========+==================================================+=================+
+| value | str | pygfx.Color | np.ndarray | Sequence[float] | new color value |
++----------+--------------------------------------------------+-----------------+
cmap
^^^^
@@ -761,6 +1805,39 @@ 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
^^^^^^^
@@ -838,6 +1915,39 @@ 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
^^^^^^^
@@ -917,6 +2027,39 @@ 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
^^^^^^^
@@ -996,6 +2139,39 @@ 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
^^^^^^^
diff --git a/docs/source/user_guide/guide.rst b/docs/source/user_guide/guide.rst
index 4f3dc64cb..bd0352aa7 100644
--- a/docs/source/user_guide/guide.rst
+++ b/docs/source/user_guide/guide.rst
@@ -62,7 +62,7 @@ Before giving a detailed overview of the library, here is a minimal example::
fig.show()
if __name__ == "__main__":
- fpl.run()
+ fpl.loop.run()
.. image:: ../_static/guide_hello_world.png
@@ -71,7 +71,8 @@ This is just a simple example of how the ``fastplotlib`` API works to create a p
However, we are just scratching the surface of what is possible with ``fastplotlib``.
Next, let's take a look at the building blocks of ``fastplotlib`` and how they can be used to create more complex visualizations.
-Aside from this user guide, the Examples Gallery is the best place to learn specific things in fastplotlib.
+In addition to this user guide, the Examples Gallery is the best place to learn how to do specific things in fastplotlib. The `quickstart notebook `_ is also an excellent introduction to the API, even if you do not plan to use ``fastplotlib`` in notebooks. Remember, ``fastplotlib`` code is pretty much identical whether it's used in jupyterlab, Qt, or glfw!
+
If you still need help don't hesitate to post an issue or discussion post!
Figure
@@ -647,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/events/drag_points.py b/examples/events/drag_points.py
index 752430c7c..5a679a996 100644
--- a/examples/events/drag_points.py
+++ b/examples/events/drag_points.py
@@ -23,16 +23,16 @@
figure = fpl.Figure(size=(700, 560))
# add a line
-line_graphic = figure[0, 0].add_line(data)
+line = figure[0, 0].add_line(data)
# add a scatter, share the line graphic buffer!
-scatter_graphic = figure[0, 0].add_scatter(data=line_graphic.data, sizes=25, colors="r")
+scatter = figure[0, 0].add_scatter(data=line.data, sizes=25, colors="r")
is_moving = False
vertex_index = None
-@scatter_graphic.add_event_handler("pointer_down")
+@scatter.add_event_handler("pointer_down")
def start_drag(ev: pygfx.PointerEvent):
global is_moving
global vertex_index
@@ -42,7 +42,7 @@ def start_drag(ev: pygfx.PointerEvent):
is_moving = True
vertex_index = ev.pick_info["vertex_index"]
- scatter_graphic.colors[vertex_index] = "cyan"
+ scatter.colors[vertex_index] = "cyan"
@figure.renderer.add_event_handler("pointer_move")
@@ -63,13 +63,13 @@ def move_point(ev):
if pos is None:
# end movement
is_moving = False
- scatter_graphic.colors[vertex_index] = "r" # reset color
+ scatter.colors[vertex_index] = "r" # reset color
vertex_index = None
return
# change scatter data
# since we are sharing the buffer, the line data will also change
- scatter_graphic.data[vertex_index, :-1] = pos[:-1]
+ scatter.data[vertex_index, :-1] = pos[:-1]
# re-enable controller
figure[0, 0].controller.enabled = True
@@ -83,7 +83,7 @@ def end_drag(ev: pygfx.PointerEvent):
# end movement
if is_moving:
# reset color
- scatter_graphic.colors[vertex_index] = "r"
+ scatter.colors[vertex_index] = "r"
is_moving = False
vertex_index = None
diff --git a/examples/events/image_click.py b/examples/events/image_click.py
index 729a67586..b783e1ee0 100644
--- a/examples/events/image_click.py
+++ b/examples/events/image_click.py
@@ -18,14 +18,14 @@
figure = fpl.Figure(size=(700, 560))
# create image graphic
-image_graphic = figure[0, 0].add_image(data=data)
+image = figure[0, 0].add_image(data=data)
# show the plot
figure.show()
# adding a click event, we can also use decorators to add event handlers
-@image_graphic.add_event_handler("click")
+@image.add_event_handler("click")
def click_event(ev: pygfx.PointerEvent):
# get the click location in screen coordinates
xy = (ev.x, ev.y)
diff --git a/examples/events/line_data_thickness_event.py b/examples/events/line_data_thickness_event.py
index 83f9322cb..d28471644 100644
--- a/examples/events/line_data_thickness_event.py
+++ b/examples/events/line_data_thickness_event.py
@@ -16,18 +16,18 @@
xs = np.linspace(0, 4 * np.pi, 100)
# sine wave
ys = np.sin(xs)
-sine = np.column_stack([xs, ys])
+sine_data = np.column_stack([xs, ys])
# cosine wave
ys = np.cos(xs)
-cosine = np.column_stack([xs, ys])
+cosine_data = np.column_stack([xs, ys])
# create line graphics
-sine_graphic = figure[0, 0].add_line(data=sine)
-cosine_graphic = figure[0, 0].add_line(data=cosine, offset=(0, 4, 0))
+sine = figure[0, 0].add_line(data=sine_data)
+cosine = figure[0, 0].add_line(data=cosine_data, offset=(0, 4, 0))
# make a list of the line graphics for convenience
-lines = [sine_graphic, cosine_graphic]
+lines = [sine, cosine]
def change_thickness(ev: fpl.GraphicFeatureEvent):
@@ -66,11 +66,11 @@ def change_data(ev: fpl.GraphicFeatureEvent):
# set the y-value of the middle 40 points of the sine graphic to 1
# after the sine_graphic sets its data, the event handlers will be called
# and therefore the cosine graphic will also set its data using the event data
-sine_graphic.data[30:70, 1] = np.ones(40)
+sine.data[30:70, 1] = np.ones(40)
# set the thickness of the cosine graphic, this will trigger an event
# that causes the sine graphic's thickness to also be set from this value
-cosine_graphic.thickness = 10
+cosine.thickness = 10
# NOTE: fpl.loop.run() should not be used for interactive sessions
# See the "JupyterLab and IPython" section in the user guide
diff --git a/examples/events/scatter_hover_transforms.py b/examples/events/scatter_hover_transforms.py
index 18e6f3de5..f7b733109 100644
--- a/examples/events/scatter_hover_transforms.py
+++ b/examples/events/scatter_hover_transforms.py
@@ -13,9 +13,9 @@
# test_example = false
# sphinx_gallery_pygfx_docs = 'screenshot'
-from sklearn.datasets import fetch_california_housing
+from sklearn.datasets import load_diabetes
from sklearn.preprocessing import (
- Normalizer,
+ StandardScaler,
QuantileTransformer,
PowerTransformer,
)
@@ -24,30 +24,16 @@
import pygfx
# get the dataset
-dataset = fetch_california_housing(n_retries=5, delay=20)
-X_full, y = dataset.data, dataset.target
-feature_names = dataset.feature_names
-
-feature_mapping = {
- "MedInc": "Median income in block",
- "HouseAge": "Median house age in block",
- "AveRooms": "Average number of rooms",
- "AveBedrms": "Average number of bedrooms",
- "Population": "Block population",
- "AveOccup": "Average house occupancy",
- "Latitude": "House block latitude",
- "Longitude": "House block longitude",
-}
+dataset = load_diabetes(scaled=False)
+
# Take only 2 features to make visualization easier
-# Feature MedInc has a long tail distribution.
-# Feature AveOccup has a few but very large outliers.
-features = ["MedInc", "AveOccup"]
-features_idx = [feature_names.index(feature) for feature in features]
-X = X_full[:, features_idx]
+X = dataset["data"][:, (2, 6)]
+# target
+y = dataset["target"]
# list of our scalers and their names as strings
-scalers = [PowerTransformer, QuantileTransformer, Normalizer]
+scalers = [PowerTransformer, QuantileTransformer, StandardScaler]
names = ["Original Data", *[s.__name__ for s in scalers]]
# fastplotlib code starts here, make a figure
diff --git a/examples/gridplot/gridplot.py b/examples/gridplot/gridplot.py
index 5edd6a845..1aa8c8083 100644
--- a/examples/gridplot/gridplot.py
+++ b/examples/gridplot/gridplot.py
@@ -30,4 +30,4 @@
# See the "JupyterLab and IPython" section in the user guide
if __name__ == "__main__":
print(__doc__)
- fpl.loop.run()
+ fpl.loop.run()
\ No newline at end of file
diff --git a/examples/gridplot/multigraphic_gridplot.py b/examples/gridplot/multigraphic_gridplot.py
index d89168ec9..cbf546e2a 100644
--- a/examples/gridplot/multigraphic_gridplot.py
+++ b/examples/gridplot/multigraphic_gridplot.py
@@ -17,7 +17,7 @@
figure = fpl.Figure(
shape=(2, 2),
names=[["image-overlay", "circles"], ["line-stack", "scatter"]],
- size=(700, 560)
+ size=(700, 560),
)
img = iio.imread("imageio:coffee.png")
@@ -36,6 +36,7 @@
# add overlay to image
figure["image-overlay"].add_image(data=overlay)
+
# generate some circles
def make_circle(center, radius: float, n_points: int = 75) -> np.ndarray:
theta = np.linspace(0, 2 * np.pi, n_points)
@@ -55,10 +56,10 @@ def make_circle(center, radius: float, n_points: int = 75) -> np.ndarray:
# things like class labels, cluster labels, etc.
cmap_transform = [
- 0, 1, 1, 2,
- 0, 0, 1, 1,
- 2, 2, 8, 3,
- 1, 9, 1, 5
+ [0, 1, 1, 2],
+ [0, 0, 1, 1],
+ [2, 2, 8, 3],
+ [1, 9, 1, 5],
]
# add an image to overlay the circles on
@@ -70,10 +71,10 @@ def make_circle(center, radius: float, n_points: int = 75) -> np.ndarray:
figure["circles"].add_line_collection(
circles,
cmap="tab10",
- cmap_transform=cmap_transform,
+ cmap_transform=np.asarray(cmap_transform).ravel(),
thickness=3,
alpha=0.5,
- name="circles-graphic"
+ name="circles-graphic",
)
# move the circles graphic so that it is centered over the image
@@ -84,7 +85,7 @@ def make_circle(center, radius: float, n_points: int = 75) -> np.ndarray:
xs = np.linspace(-10, 10, 100)
# sine wave
ys = np.sin(xs)
-sine = np.dstack([xs, ys])[0]
+sine = np.column_stack([xs, ys])
# make 10 identical waves
sine_waves = 10 * [sine]
@@ -115,4 +116,3 @@ def make_circle(center, radius: float, n_points: int = 75) -> np.ndarray:
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 09a5ec990..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
@@ -61,55 +60,55 @@ def make_circle(center, radius: float, p, q, n_points: int) -> np.ndarray:
# create sine and cosine data
xs = np.linspace(0, 2 * np.pi, 360)
-sine = np.sin(xs * P)
-cosine = np.cos(xs * Q)
+sine_data = np.sin(xs * P)
+cosine_data = np.cos(xs * Q)
# circle data
circle_data = make_circle(center=(0, 0), p=P, q=Q, radius=1, n_points=360)
# make the circle line graphic, set the cmap transform using the sine function
-circle_graphic = figure["circle"].add_line(
- circle_data, thickness=4, cmap="bwr", cmap_transform=sine
+circle = figure["circle"].add_line(
+ circle_data, thickness=4, cmap="bwr", cmap_transform=sine_data
)
# line to show the circle radius
# use it to indicate the current position of the sine and cosine selctors (below)
radius_data = np.array([[0, 0, 0], [*circle_data[0], 0]])
-circle_radius_graphic = figure["circle"].add_line(
+circle_radius = figure["circle"].add_line(
radius_data, thickness=6, colors="magenta"
)
# sine line graphic, cmap transform set from the sine function
-sine_graphic = figure["sin"].add_line(
- sine, thickness=10, cmap="bwr", cmap_transform=sine
+sine = figure["sin"].add_line(
+ sine_data, thickness=10, cmap="bwr", cmap_transform=sine_data
)
# cosine line graphic, cmap transform set from the sine function
# illustrates the sine function values on the cosine graphic
-cosine_graphic = figure["cos"].add_line(
- cosine, thickness=10, cmap="bwr", cmap_transform=sine
+cosine = figure["cos"].add_line(
+ cosine_data, thickness=10, cmap="bwr", cmap_transform=sine_data
)
# add linear selectors to the sine and cosine line graphics
-sine_selector = sine_graphic.add_linear_selector()
-cosine_selector = cosine_graphic.add_linear_selector()
+sine_selector = sine.add_linear_selector()
+cosine_selector = cosine.add_linear_selector()
def set_circle_cmap(ev):
# sets the cmap transforms
cmap_transform = ev.graphic.data[:, 1] # y-val data of the sine or cosine graphic
- for g in [sine_graphic, cosine_graphic]:
+ for g in [sine, cosine]:
g.cmap.transform = cmap_transform
# set circle cmap transform
- circle_graphic.cmap.transform = cmap_transform
+ circle.cmap.transform = cmap_transform
# when the sine or cosine graphic is clicked, the cmap_transform
# of the sine, cosine and circle line graphics are all set from
# the y-values of the clicked line
-sine_graphic.add_event_handler(set_circle_cmap, "click")
-cosine_graphic.add_event_handler(set_circle_cmap, "click")
+sine.add_event_handler(set_circle_cmap, "click")
+cosine.add_event_handler(set_circle_cmap, "click")
def set_x_val(ev):
@@ -120,7 +119,7 @@ def set_x_val(ev):
sine_selector.selection = value
cosine_selector.selection = value
- circle_radius_graphic.data[1, :-1] = circle_data[index]
+ circle_radius.data[1, :-1] = circle_data[index]
# add same event handler to both graphics
sine_selector.add_event_handler(set_x_val, "selection")
@@ -138,19 +137,19 @@ def __init__(self, figure, size, location, title):
self._q = 1
def _set_data(self):
- global sine_graphic, cosine_graphic, circle_graphic, circle_radius_graphic, circle_data
+ global sine, cosine, circle, circle_radius, circle_data
# make new data
- sine = np.sin(xs * self._p)
- cosine = np.cos(xs * self._q)
+ sine_data = np.sin(xs * self._p)
+ cosine_data = np.cos(xs * self._q)
circle_data = make_circle(center=(0, 0), p=self._p, q=self._q, radius=1, n_points=360)
# set the graphics
- sine_graphic.data[:, 1] = sine
- cosine_graphic.data[:, 1] = cosine
- circle_graphic.data[:, :2] = circle_data
- circle_radius_graphic.data[1, :-1] = circle_data[sine_selector.get_selected_index()]
+ sine.data[:, 1] = sine_data
+ cosine.data[:, 1] = cosine_data
+ circle.data[:, :2] = circle_data
+ circle_radius.data[1, :-1] = circle_data[sine_selector.get_selected_index()]
def update(self):
flag_set_data = False
diff --git a/examples/heatmap/heatmap.py b/examples/heatmap/heatmap.py
index 38c9b51a7..3f02206f6 100644
--- a/examples/heatmap/heatmap.py
+++ b/examples/heatmap/heatmap.py
@@ -21,7 +21,7 @@
data = np.vstack([sine * i for i in range(2_300)])
# plot the image data
-img = figure[0, 0].add_image(data=data, name="heatmap")
+image = figure[0, 0].add_image(data=data, name="heatmap")
del data
figure.show()
diff --git a/examples/image/image_cmap.py b/examples/image/image_cmap.py
index f651f438c..8c94c6f17 100644
--- a/examples/image/image_cmap.py
+++ b/examples/image/image_cmap.py
@@ -8,19 +8,20 @@
# test_example = true
# sphinx_gallery_pygfx_docs = 'screenshot'
-import fastplotlib as fpl
import imageio.v3 as iio
+import fastplotlib as fpl
+
im = iio.imread("imageio:camera.png")
figure = fpl.Figure(size=(700, 560))
# plot the image data
-image_graphic = figure[0, 0].add_image(data=im, name="random-image")
+image = figure[0, 0].add_image(data=im, name="random-image")
figure.show()
-image_graphic.cmap = "viridis"
+image.cmap = "viridis"
# NOTE: fpl.loop.run() should not be used for interactive sessions
# See the "JupyterLab and IPython" section in the user guide
diff --git a/examples/image/image_rgb.py b/examples/image/image_rgb.py
index 187dac553..569c09f0b 100644
--- a/examples/image/image_rgb.py
+++ b/examples/image/image_rgb.py
@@ -16,7 +16,7 @@
figure = fpl.Figure(size=(700, 560))
# plot the image data
-image_graphic = figure[0, 0].add_image(data=im, name="iio astronaut")
+image = figure[0, 0].add_image(data=im, name="iio astronaut")
figure.show()
diff --git a/examples/image/image_rgbvminvmax.py b/examples/image/image_rgbvminvmax.py
index 02635f134..bf2963daf 100644
--- a/examples/image/image_rgbvminvmax.py
+++ b/examples/image/image_rgbvminvmax.py
@@ -16,12 +16,12 @@
figure = fpl.Figure(size=(700, 560))
# plot the image data
-image_graphic = figure[0, 0].add_image(data=im, name="iio astronaut")
+image = figure[0, 0].add_image(data=im, name="iio astronaut")
figure.show()
-image_graphic.vmin = 0.5
-image_graphic.vmax = 0.75
+image.vmin = 0.5
+image.vmax = 0.75
# NOTE: fpl.loop.run() should not be used for interactive sessions
# See the "JupyterLab and IPython" section in the user guide
diff --git a/examples/image/image_simple.py b/examples/image/image_simple.py
index d0910fb82..2fd7f694c 100644
--- a/examples/image/image_simple.py
+++ b/examples/image/image_simple.py
@@ -16,7 +16,7 @@
data = iio.imread("imageio:camera.png")
# plot the image data
-image_graphic = figure[0, 0].add_image(data=data, name="iio camera")
+image = figure[0, 0].add_image(data=data, name="iio camera")
figure.show()
diff --git a/examples/image/image_small.py b/examples/image/image_small.py
index 732d61d74..6acfd7250 100644
--- a/examples/image/image_small.py
+++ b/examples/image/image_small.py
@@ -18,7 +18,7 @@
[[0, 1, 2],
[3, 4, 5]]
)
-image_graphic = figure[0, 0].add_image(data)
+image = figure[0, 0].add_image(data)
figure.show()
diff --git a/examples/image/image_vminvmax.py b/examples/image/image_vminvmax.py
index e2d1c7743..1c290c587 100644
--- a/examples/image/image_vminvmax.py
+++ b/examples/image/image_vminvmax.py
@@ -16,12 +16,12 @@
data = iio.imread("imageio:astronaut.png")
# plot the image data
-image_graphic = figure[0, 0].add_image(data=data, name="iio astronaut")
+image = figure[0, 0].add_image(data=data, name="iio astronaut")
figure.show()
-image_graphic.vmin = 0.5
-image_graphic.vmax = 0.75
+image.vmin = 0.5
+image.vmax = 0.75
# NOTE: fpl.loop.run() should not be used for interactive sessions
# See the "JupyterLab and IPython" section in the user guide
diff --git a/examples/image_volume/README.rst b/examples/image_volume/README.rst
new file mode 100644
index 000000000..6c349ebfa
--- /dev/null
+++ b/examples/image_volume/README.rst
@@ -0,0 +1,2 @@
+Image Volume Examples
+=====================
diff --git a/examples/image_volume/image_volume_4d.py b/examples/image_volume/image_volume_4d.py
new file mode 100644
index 000000000..34bf9b903
--- /dev/null
+++ b/examples/image_volume/image_volume_4d.py
@@ -0,0 +1,115 @@
+"""
+Volume movie
+============
+
+View 4D data of a volume over time by updating the volume data.
+"""
+
+# test_example = false
+# sphinx_gallery_pygfx_docs = 'animate 5s'
+
+import numpy as np
+from scipy.ndimage import gaussian_filter
+import fastplotlib as fpl
+
+
+def generate_data(
+ p=1,
+ noise=0.5,
+ T=128,
+ framerate=10,
+ firerate=2.0,
+):
+ gamma = np.array([0.9])
+ dims = (128, 128, 30) # size of image
+ sig = (4, 4, 2) # neurons size
+ bkgrd = 10
+ N = 150 # number of neurons
+ np.random.seed(0)
+ centers = np.asarray(
+ [[np.random.randint(s, x - s) for x, s in zip(dims, sig)] for i in range(N)]
+ )
+ Y = np.zeros((T,) + dims, dtype=np.float32)
+ trueSpikes = np.random.rand(N, T) < firerate / float(framerate)
+ trueSpikes[:, 0] = 0
+ truth = trueSpikes.astype(np.float32)
+ for i in range(2, T):
+ if p == 2:
+ truth[:, i] += gamma[0] * truth[:, i - 1] + gamma[1] * truth[:, i - 2]
+ else:
+ truth[:, i] += gamma[0] * truth[:, i - 1]
+ for i in range(N):
+ Y[:, centers[i, 0], centers[i, 1], centers[i, 2]] = truth[i]
+ tmp = np.zeros(dims)
+ tmp[tuple(np.array(dims) // 2)] = 1.0
+ print("gaussing filtering")
+ z = np.linalg.norm(gaussian_filter(tmp, sig).ravel())
+
+ print("finishing")
+ Y = (
+ bkgrd
+ + noise * np.random.randn(*Y.shape)
+ + 10 * gaussian_filter(Y, (0,) + sig) / z
+ )
+
+ return Y
+
+
+voldata = generate_data()
+
+figure = fpl.Figure(cameras="3d", controller_types="orbit", size=(700, 560))
+
+volume = figure[0, 0].add_image_volume(
+ voldata[0],
+ vmin=10,
+ vmax=15,
+ cmap="gnuplot2",
+ alpha_mode="add",
+)
+
+hlut = fpl.HistogramLUTTool(voldata, volume)
+
+figure[0, 0].docks["right"].size = 100
+figure[0, 0].docks["right"].controller.enabled = False
+figure[0, 0].docks["right"].add_graphic(hlut)
+figure[0, 0].docks["right"].auto_scale(maintain_aspect=False)
+
+figure.show()
+
+# load a pre-saved camera state
+state = {
+ "position": np.array([-70, 90, 150]),
+ "rotation": np.array([-0.09210227, -0.47460177, -0.05001713, 0.87393857]),
+ "scale": np.array([1.0, 1.0, 1.0]),
+ "reference_up": np.array([0.0, 1.0, 0.0]),
+ "fov": 50.0,
+ "width": 27.605629518746266,
+ "height": 117.78401927998402,
+ "depth": 183.4884192530962,
+ "zoom": 0.75,
+ "maintain_aspect": True,
+ "depth_range": None,
+}
+
+figure[0, 0].camera.set_state(state)
+
+
+i = 0
+def update():
+ global i
+
+ volume.data = voldata[i]
+
+ i += 1
+ if i == voldata.shape[0]:
+ i = 0
+
+
+figure.add_animations(update)
+
+
+# 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/image_volume/image_volume_mip.py b/examples/image_volume/image_volume_mip.py
new file mode 100644
index 000000000..73ae7803f
--- /dev/null
+++ b/examples/image_volume/image_volume_mip.py
@@ -0,0 +1,47 @@
+"""
+Volume Mip mode
+===============
+
+View a volume using MIP (Maximum Intensity Projection) rendering.
+"""
+
+# test_example = true
+# sphinx_gallery_pygfx_docs = 'screenshot'
+
+import numpy as np
+import fastplotlib as fpl
+import imageio.v3 as iio
+
+voldata = iio.imread("imageio:stent.npz").astype(np.float32)
+
+figure = fpl.Figure(cameras="3d", controller_types="orbit", size=(700, 560))
+
+figure[0, 0].add_image_volume(voldata, mode="mip", alpha_mode="add")
+
+figure.show()
+
+
+# load a pre-saved camera state
+state = {
+ "position": np.array([-120, 90, 330]),
+ "rotation": np.array([-0.07280538, -0.41100206, -0.03295049, 0.90812496]),
+ "scale": np.array([1.0, 1.0, 1.0]),
+ "reference_up": np.array([0.0, 1.0, 0.0]),
+ "fov": 50.0,
+ "width": 128.0,
+ "height": 128.0,
+ "depth": 313,
+ "zoom": 0.75,
+ "maintain_aspect": True,
+ "depth_range": None,
+}
+
+
+figure[0, 0].camera.set_state(state)
+
+
+# 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/image_volume/image_volume_multi_channel.py b/examples/image_volume/image_volume_multi_channel.py
new file mode 100644
index 000000000..01fc27ac6
--- /dev/null
+++ b/examples/image_volume/image_volume_multi_channel.py
@@ -0,0 +1,48 @@
+"""
+Multi channel volumes
+=====================
+
+Example with multi-channel volume images. Use alpha_mode "add" for additive blending.
+"""
+
+# test_example = false
+# run_example = false
+# sphinx_gallery_pygfx_docs = 'code'
+
+import fastplotlib as fpl
+from ome_zarr.io import parse_url
+from ome_zarr.reader import Reader
+
+
+# load data
+url = "https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.5/idr0062A/6001240_labels.zarr"
+
+# read the image data
+reader = Reader(parse_url(url))
+# first node is image data
+image_node = next(reader())
+
+dask_data = image_node.data
+
+# use the highest resolution image in the pyramid zarr
+voldata = dask_data[0]
+
+figure = fpl.Figure(
+ cameras="3d",
+ controller_types="orbit",
+ size=(700, 700)
+)
+
+# add first channel, use cyan colormap
+vol_ch0 = figure[0, 0].add_image_volume(voldata[0], cmap="cyan", alpha_mode="add")
+# add another channel, use magenta cmap
+vol_ch1 = figure[0, 0].add_image_volume(voldata[1], cmap="magenta", alpha_mode="add")
+
+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/image_volume/image_volume_non_orthogonal_slicing.py b/examples/image_volume/image_volume_non_orthogonal_slicing.py
new file mode 100644
index 000000000..dc74a5e0a
--- /dev/null
+++ b/examples/image_volume/image_volume_non_orthogonal_slicing.py
@@ -0,0 +1,56 @@
+"""
+Volume non-orthogonal slicing
+=============================
+
+Perform non-orthogonal slicing of image volumes.
+
+For an example with UI sliders see the "Volume modes" example.
+"""
+
+# test_example = true
+# sphinx_gallery_pygfx_docs = 'screenshot'
+
+import numpy as np
+import fastplotlib as fpl
+import imageio.v3 as iio
+
+
+voldata = iio.imread("imageio:stent.npz").astype(np.float32)
+
+figure = fpl.Figure(
+ cameras="3d",
+ controller_types="orbit",
+ size=(700, 560)
+)
+
+vol = figure[0, 0].add_image_volume(voldata, mode="slice")
+
+# a plane is defined by ax + by + cz + d = 0
+# the plane property sets (a, b, c, d)
+vol.plane = (0, 0.5, 0.5, -70)
+
+# just a pre-saved camera state to view the plot area
+state = {
+ "position": np.array([-160.0, 105.0, 205.0]),
+ "rotation": np.array([-0.1, -0.6, -0.07, 0.8]),
+ "scale": np.array([1., 1., 1.]),
+ "reference_up": np.array([0., 1., 0.]),
+ "fov": 50.0,
+ "width": 128.0,
+ "height": 128.0,
+ "depth": 315,
+ "zoom": 0.75,
+ "maintain_aspect": True,
+ "depth_range": None
+}
+
+figure.show()
+
+figure[0, 0].camera.set_state(state)
+
+
+# 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/image_volume/image_volume_render_modes.py b/examples/image_volume/image_volume_render_modes.py
new file mode 100644
index 000000000..36705d17d
--- /dev/null
+++ b/examples/image_volume/image_volume_render_modes.py
@@ -0,0 +1,88 @@
+"""
+Volume modes
+============
+
+View a volume using different rendering modes.
+"""
+
+# test_example = true
+# sphinx_gallery_pygfx_docs = 'screenshot'
+
+import numpy as np
+import fastplotlib as fpl
+from fastplotlib.ui import EdgeWindow
+from fastplotlib.graphics.features import VOLUME_RENDER_MODES
+import imageio.v3 as iio
+from imgui_bundle import imgui
+
+voldata = iio.imread("imageio:stent.npz").astype(np.float32)
+
+figure = fpl.Figure(
+ cameras="3d",
+ controller_types="orbit",
+ size=(700, 560)
+)
+
+figure[0, 0].add_image_volume(voldata, name="vol-img")
+
+# add an hlut tool
+hlut = fpl.HistogramLUTTool(voldata, figure[0, 0]["vol-img"])
+
+figure[0, 0].docks["right"].size = 80
+figure[0, 0].docks["right"].controller.enabled = False
+figure[0, 0].docks["right"].add_graphic(hlut)
+figure[0, 0].docks["right"].auto_scale(maintain_aspect=False)
+
+
+class GUI(EdgeWindow):
+ def __init__(self, figure, title="Render options", location="right", size=300):
+ super().__init__(figure, title=title, location=location, size=size)
+
+ # reference to the graphic for convenience
+ self.graphic: fpl.ImageVolumeGraphic = self._figure[0, 0]["vol-img"]
+
+ def update(self):
+ imgui.text("Switch render mode:")
+
+ # add buttons to switch between modes
+ for mode in VOLUME_RENDER_MODES.keys():
+ if imgui.button(mode):
+ self.graphic.mode = mode
+
+ # add sliders to change iso rendering properties
+ if self.graphic.mode == "iso":
+ _, self.graphic.threshold = imgui.slider_float(
+ "threshold", v=self.graphic.threshold, v_max=255, v_min=1,
+ )
+ _, self.graphic.step_size = imgui.slider_float(
+ "step_size", v=self.graphic.step_size, v_max=10.0, v_min=0.1,
+ )
+ _, self.graphic.substep_size = imgui.slider_float(
+ "substep_size", v=self.graphic.substep_size, v_max=10.0, v_min=0.1,
+ )
+
+ 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")
+ _, a = imgui.slider_float("a", v=self.graphic.plane[0], v_min=-1, v_max=1.0)
+ _, b = imgui.slider_float("b", v=self.graphic.plane[1], v_min=-1, v_max=1.0)
+ _, c = imgui.slider_float("c", v=self.graphic.plane[2], v_min=-1, v_max=1.0)
+
+ largest_dim = max(self.graphic.data.value.shape)
+ _, d = imgui.slider_float("d", v=self.graphic.plane[3], v_min=0, v_max=largest_dim * 2)
+
+ self.graphic.plane = (a, b, c, d)
+
+gui = GUI(figure=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()
diff --git a/examples/image_volume/image_volume_share_buffer.py b/examples/image_volume/image_volume_share_buffer.py
new file mode 100644
index 000000000..cc9f07915
--- /dev/null
+++ b/examples/image_volume/image_volume_share_buffer.py
@@ -0,0 +1,75 @@
+"""
+Volume share buffers
+====================
+
+Share the data buffer between two graphics. This example creates one Graphic using MIP rendering, and another graphic
+to display a slice of the volume. We can share the data buffer on the GPU between these graphics.
+"""
+
+# test_example = true
+# sphinx_gallery_pygfx_docs = 'screenshot'
+
+from imgui_bundle import imgui
+import fastplotlib as fpl
+from fastplotlib.ui import EdgeWindow
+import imageio.v3 as iio
+from skimage.filters import gaussian
+
+
+data = iio.imread("imageio:stent.npz")
+
+
+figure = fpl.Figure(
+ cameras="3d",
+ controller_types="orbit",
+ size=(700, 560),
+)
+
+# MIP rendering is the default `mode`
+vol_mip = figure[0, 0].add_image_volume(gaussian(data, sigma=2.0))
+
+# make another graphic to show a slice of the volume
+vol_slice = figure[0, 0].add_image_volume(
+ vol_mip.data, # pass the data property from the previous volume so they share the same buffer on the GPU
+ mode="slice",
+ plane=(0, -0.5, -0.5, 50),
+ offset=(150, 0, 0) # place the graphic at x=150
+)
+
+
+class GUI(EdgeWindow):
+ def __init__(self, figure, title="change data buffer", location="right", size=200):
+ super().__init__(figure, title=title, location=location, size=size)
+ self._sigma = 2
+
+ def update(self):
+ changed, self._sigma = imgui.slider_int("sigma", v=self._sigma, v_min=0, v_max=5)
+
+ if changed:
+ vol_mip.data = gaussian(data, sigma=self._sigma)
+ vol_mip.reset_vmin_vmax()
+ vol_slice.reset_vmin_vmax()
+
+ imgui.text("Select plane defined by:\nax + by + cz + d = 0")
+ _, a = imgui.slider_float("a", v=vol_slice.plane[0], v_min=-1, v_max=1.0)
+ _, b = imgui.slider_float("b", v=vol_slice.plane[1], v_min=-1, v_max=1.0)
+ _, c = imgui.slider_float("c", v=vol_slice.plane[2], v_min=-1, v_max=1.0)
+
+ largest_dim = max(vol_slice.data.value.shape)
+ _, d = imgui.slider_float(
+ "d", v=vol_slice.plane[3], v_min=0, v_max=largest_dim * 2
+ )
+
+ vol_slice.plane = (a, b, c, d)
+
+gui = GUI(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()
diff --git a/examples/image_volume/image_volume_slicing_animation.py b/examples/image_volume/image_volume_slicing_animation.py
new file mode 100644
index 000000000..ab671eec6
--- /dev/null
+++ b/examples/image_volume/image_volume_slicing_animation.py
@@ -0,0 +1,64 @@
+"""
+Volume non-orthogonal slicing animation
+=======================================
+
+Perform non-orthogonal slicing of image volumes.
+
+For an example with UI sliders see the "Volume modes" example.
+"""
+
+# test_example = false
+# sphinx_gallery_pygfx_docs = 'animate 8s'
+
+import numpy as np
+import fastplotlib as fpl
+import imageio.v3 as iio
+
+
+voldata = iio.imread("imageio:stent.npz").astype(np.float32)
+
+figure = fpl.Figure(
+ cameras="3d",
+ controller_types="orbit",
+ size=(700, 560)
+)
+
+vol = figure[0, 0].add_image_volume(voldata, mode="slice")
+
+# a plane is defined by ax + by + cz + d = 0
+# the plane property sets (a, b, c, d)
+vol.plane = (0, 0.5, 0.5, -20)
+
+# just a pre-saved camera state to view the plot area
+state = {
+ "position": np.array([-110.0, 160.0, 240.0]),
+ "rotation": np.array([-0.25, -0.5, -0.15, 0.85]),
+ "scale": np.array([1., 1., 1.]),
+ "reference_up": np.array([0., 1., 0.]),
+ "fov": 50.0,
+ "width": 128.0,
+ "height": 128.0,
+ "depth": 315,
+ "zoom": 0.75,
+ "maintain_aspect": True,
+ "depth_range": None
+}
+
+def update():
+ # increase d by 1
+ vol.plane = (0, 0.5, 0.5, vol.plane[-1] - 1)
+ if vol.plane[-1] < -200:
+ vol.plane = (0, 0.5, 0.5, -20)
+
+figure[0, 0].add_animations(update)
+
+figure.show()
+
+figure[0, 0].camera.set_state(state)
+
+
+# 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/image_volume/image_volume_toy_data.py b/examples/image_volume/image_volume_toy_data.py
new file mode 100644
index 000000000..5c081542d
--- /dev/null
+++ b/examples/image_volume/image_volume_toy_data.py
@@ -0,0 +1,31 @@
+"""
+Volume rendering of toy data
+============================
+
+Volume rendering of toy trig data
+"""
+
+import fastplotlib as fpl
+import numpy as np
+
+n_cols = 100
+n_rows = 100
+z = 50
+
+xs = np.linspace(0, 1_000, n_cols)
+
+sine = np.sin(np.sqrt(xs))
+
+data = np.dstack([np.vstack([sine * i for i in range(n_rows)]).astype(np.float32) * j for j in range(z)])
+
+figure = fpl.Figure(cameras="3d", controller_types="orbit")
+
+volume = figure[0, 0].add_image_volume(data)
+
+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/line/line.py b/examples/line/line.py
index fb8834759..f7839a1c4 100644
--- a/examples/line/line.py
+++ b/examples/line/line.py
@@ -16,25 +16,25 @@
xs = np.linspace(-10, 10, 100)
# sine wave
ys = np.sin(xs)
-sine = np.dstack([xs, ys])[0]
+sine_data = np.column_stack([xs, ys])
# cosine wave
ys = np.cos(xs) + 5
-cosine = np.dstack([xs, ys])[0]
+cosine_data = np.column_stack([xs, ys])
# sinc function
a = 0.5
ys = np.sinc(xs) * 3 + 8
-sinc = np.dstack([xs, ys])[0]
+sinc_data = np.column_stack([xs, ys])
-sine_graphic = figure[0, 0].add_line(data=sine, thickness=5, colors="magenta")
+sine = figure[0, 0].add_line(data=sine_data, thickness=5, colors="magenta")
# you can also use colormaps for lines!
-cosine_graphic = figure[0, 0].add_line(data=cosine, thickness=12, cmap="autumn")
+cosine = figure[0, 0].add_line(data=cosine_data, thickness=12, cmap="autumn")
# or a list of colors for each datapoint
colors = ["r"] * 25 + ["purple"] * 25 + ["y"] * 25 + ["b"] * 25
-sinc_graphic = figure[0, 0].add_line(data=sinc, thickness=5, colors=colors)
+sinc = figure[0, 0].add_line(data=sinc_data, thickness=5, colors=colors)
figure[0, 0].axes.grids.xy.visible = True
figure.show()
diff --git a/examples/line/line_cmap.py b/examples/line/line_cmap.py
index af24f1c63..3d2b5e8c9 100644
--- a/examples/line/line_cmap.py
+++ b/examples/line/line_cmap.py
@@ -16,24 +16,24 @@
xs = np.linspace(-10, 10, 100)
# sine wave
ys = np.sin(xs)
-sine = np.dstack([xs, ys])[0]
+sine_data = np.column_stack([xs, ys])
# cosine wave
ys = np.cos(xs) - 5
-cosine = np.dstack([xs, ys])[0]
+cosine_data = np.column_stack([xs, ys])
# cmap_transform from an array, so the colors on the sine line will be based on the sine y-values
-sine_graphic = figure[0, 0].add_line(
- data=sine,
+sine = figure[0, 0].add_line(
+ data=sine_data,
thickness=10,
cmap="plasma",
- cmap_transform=sine[:, 1]
+ cmap_transform=sine_data[:, 1]
)
# qualitative colormaps, useful for cluster labels or other types of categorical labels
labels = [0] * 25 + [5] * 10 + [1] * 35 + [2] * 30
-cosine_graphic = figure[0, 0].add_line(
- data=cosine,
+cosine = figure[0, 0].add_line(
+ data=cosine_data,
thickness=10,
cmap="tab10",
cmap_transform=labels
diff --git a/examples/line/line_colorslice.py b/examples/line/line_colorslice.py
index 2d4c0dcaa..b6865eadb 100644
--- a/examples/line/line_colorslice.py
+++ b/examples/line/line_colorslice.py
@@ -16,26 +16,26 @@
xs = np.linspace(-10, 10, 100)
# sine wave
ys = np.sin(xs)
-sine = np.column_stack([xs, ys])
+sine_data = np.column_stack([xs, ys])
# cosine wave
ys = np.cos(xs)
-cosine = np.column_stack([xs, ys])
+cosine_data = np.column_stack([xs, ys])
# sinc function
a = 0.5
ys = np.sinc(xs) * 3
-sinc = np.column_stack([xs, ys])
+sinc_data = np.column_stack([xs, ys])
-sine_graphic = figure[0, 0].add_line(
- data=sine,
+sine = figure[0, 0].add_line(
+ data=sine_data,
thickness=5,
colors="magenta"
)
# you can also use colormaps for lines!
-cosine_graphic = figure[0, 0].add_line(
- data=cosine,
+cosine = figure[0, 0].add_line(
+ data=cosine_data,
thickness=12,
cmap="autumn",
offset=(0, 3, 0) # places the graphic at a y-axis offset of 3, offsets don't affect data
@@ -43,8 +43,8 @@
# or a list of colors for each datapoint
colors = ["r"] * 25 + ["purple"] * 25 + ["y"] * 25 + ["b"] * 25
-sinc_graphic = figure[0, 0].add_line(
- data=sinc,
+sinc = figure[0, 0].add_line(
+ data=sinc_data,
thickness=5,
colors=colors,
offset=(0, 6, 0)
@@ -52,7 +52,7 @@
zeros = np.zeros(xs.size)
zeros_data = np.column_stack([xs, zeros])
-zeros_graphic = figure[0, 0].add_line(
+zeros = figure[0, 0].add_line(
data=zeros_data,
thickness=8,
colors="w",
@@ -62,25 +62,25 @@
figure.show()
# indexing of colors
-cosine_graphic.colors[:15] = "magenta"
-cosine_graphic.colors[90:] = "red"
-cosine_graphic.colors[60] = "w"
+cosine.colors[:15] = "magenta"
+cosine.colors[90:] = "red"
+cosine.colors[60] = "w"
# more complex indexing, set the blue value directly from an array
-cosine_graphic.colors[65:90, 0] = np.linspace(0, 1, 90-65)
+cosine.colors[65:90, 0] = np.linspace(0, 1, 90 - 65)
# additional fancy indexing using numpy
key = np.array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 67, 19])
-sinc_graphic.colors[key] = "Red"
+sinc.colors[key] = "Red"
# boolean fancy indexing
-zeros_graphic.colors[xs < -5] = "green"
+zeros.colors[xs < -5] = "green"
# assign colormap to an entire line
-sine_graphic.cmap = "seismic"
+sine.cmap = "seismic"
# or to segments of a line
-zeros_graphic.cmap[50:75] = "jet"
-zeros_graphic.cmap[75:] = "viridis"
+zeros.cmap[50:75] = "jet"
+zeros.cmap[75:] = "viridis"
# NOTE: fpl.loop.run() should not be used for interactive sessions
diff --git a/examples/line/line_dataslice.py b/examples/line/line_dataslice.py
index ca0f48518..6ef9d0d90 100644
--- a/examples/line/line_dataslice.py
+++ b/examples/line/line_dataslice.py
@@ -16,35 +16,35 @@
xs = np.linspace(-10, 10, 100)
# sine wave
ys = np.sin(xs)
-sine = np.dstack([xs, ys])[0]
+sine_data = np.column_stack([xs, ys])
# cosine wave
ys = np.cos(xs) + 5
-cosine = np.dstack([xs, ys])[0]
+cosine_data = np.column_stack([xs, ys])
# sinc function
a = 0.5
ys = np.sinc(xs) * 3 + 8
-sinc = np.dstack([xs, ys])[0]
+sinc_data = np.column_stack([xs, ys])
-sine_graphic = figure[0, 0].add_line(data=sine, thickness=5, colors="magenta")
+sine = figure[0, 0].add_line(data=sine_data, thickness=5, colors="magenta")
# you can also use colormaps for lines!
-cosine_graphic = figure[0, 0].add_line(data=cosine, thickness=12, cmap="autumn")
+cosine = figure[0, 0].add_line(data=cosine_data, thickness=12, cmap="autumn")
# or a list of colors for each datapoint
colors = ["r"] * 25 + ["purple"] * 25 + ["y"] * 25 + ["b"] * 25
-sinc_graphic = figure[0, 0].add_line(data=sinc, thickness=5, colors=colors)
+sinc = figure[0, 0].add_line(data=sinc_data, thickness=5, colors=colors)
figure.show()
-cosine_graphic.data[10:50:5, :2] = sine[10:50:5]
-cosine_graphic.data[90:, 1] = 7
-cosine_graphic.data[0] = np.array([[-10, 0, 0]])
+cosine.data[10:50:5, :2] = sine_data[10:50:5]
+cosine.data[90:, 1] = 7
+cosine.data[0] = np.array([[-10, 0, 0]])
# additional fancy indexing with boolean array
bool_key = [True, True, True, False, False] * 20
-sinc_graphic.data[bool_key, 1] = 7 # y vals to 1
+sinc.data[bool_key, 1] = 7 # y vals to 1
# NOTE: fpl.loop.run() should not be used for interactive sessions
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-dev/garbage_collection.py b/examples/misc-dev/garbage_collection.py
index baa92e848..56ef0792b 100644
--- a/examples/misc-dev/garbage_collection.py
+++ b/examples/misc-dev/garbage_collection.py
@@ -1,9 +1,9 @@
import numpy as np
-from wgpu.gui.auto import WgpuCanvas, run
+from rendercanvas.auto import RenderCanvas, loop
import pygfx as gfx
import subprocess
-canvas = WgpuCanvas()
+canvas = RenderCanvas()
renderer = gfx.WgpuRenderer(canvas)
scene = gfx.Scene()
camera = gfx.OrthographicCamera(5000, 5000)
@@ -28,7 +28,11 @@ def draw():
def print_nvidia(msg=""):
print(msg)
print(
- subprocess.check_output(["nvidia-smi", "--format=csv", "--query-gpu=memory.used"]).decode().split("\n")[1]
+ subprocess.check_output(
+ ["nvidia-smi", "--format=csv", "--query-gpu=memory.used"]
+ )
+ .decode()
+ .split("\n")[1]
)
print()
@@ -57,4 +61,4 @@ def remove_img(*args):
renderer.add_event_handler(add_img, "double_click")
draw()
-run()
+loop.run()
diff --git a/examples/misc-dev/selector_performance.ipynb b/examples/misc-dev/selector_performance.ipynb
index 39dbba6b8..bbbe8c40b 100644
--- a/examples/misc-dev/selector_performance.ipynb
+++ b/examples/misc-dev/selector_performance.ipynb
@@ -32,10 +32,10 @@
" xs = radius * np.sin(theta)\n",
" ys = radius * np.cos(theta)\n",
" zs = np.zeros(xs.size)\n",
- " \n",
+ "\n",
" xs += center[0]\n",
" ys += center[1]\n",
- " \n",
+ "\n",
" return np.ascontiguousarray(np.column_stack([xs, ys, zs]).astype(np.float32))"
]
},
@@ -76,7 +76,7 @@
},
"outputs": [],
"source": [
- "from wgpu.gui.auto import WgpuCanvas, run\n",
+ "from rendercanvas.auto import RenderCanvas\n",
"import pygfx as gfx"
]
},
@@ -89,7 +89,7 @@
},
"outputs": [],
"source": [
- "canvas = WgpuCanvas()\n",
+ "canvas = RenderCanvas()\n",
"renderer = gfx.WgpuRenderer(canvas)"
]
},
@@ -163,7 +163,7 @@
"\n",
"for l in lines[100:1000]:\n",
" l.visible = False\n",
- " \n",
+ "\n",
"# canvas.request_draw()\n",
"\n",
"time() - t1"
@@ -186,7 +186,7 @@
" ys = np.sin(xs) * 10\n",
" else:\n",
" ys = np.cos(xs) * 10\n",
- " \n",
+ "\n",
" temporal.append(ys)"
]
},
@@ -233,7 +233,7 @@
"def update_visible(ev):\n",
" ixs_visible = ev.pick_info[\"selected_indices\"]\n",
" ixs_hide = np.setdiff1d(np.arange(len(circles)), ixs_visible)\n",
- " \n",
+ "\n",
" # very fast, 20 ms to change 1,000\n",
" for i, g in enumerate(contours.graphics):\n",
" if not g.visible and i in ixs_visible:\n",
@@ -320,10 +320,10 @@
"\n",
"for c in contours.graphics[100:1000]:\n",
" c.visible = True\n",
- " \n",
+ "\n",
"# for i in range(100, 1000):\n",
"# contours.graphics[i].world_object.visible = True\n",
- " \n",
+ "\n",
"time() - t1"
]
},
@@ -342,7 +342,7 @@
"for c in circles:\n",
" start_offset += c.shape[0]\n",
" zero_alpha_ixs += [start_offset - 1, start_offset]\n",
- " \n",
+ "\n",
"zero_alpha_ixs = zero_alpha_ixs[:-1]"
]
},
@@ -370,13 +370,13 @@
"def set_visible_alpha(ev):\n",
" ixs_visible = ev.pick_info[\"selected_indices\"]\n",
" ixs_hide = np.setdiff1d(np.arange(len(circles)), ixs_visible)\n",
- " \n",
+ "\n",
" for i in ixs_visible:\n",
" contours.world_object.geometry.colors.data[(i * 75) + 1:(i * 75) + 74, -1] = 1\n",
- " \n",
+ "\n",
" for i in ixs_hide:\n",
" contours.world_object.geometry.colors.data[(i * 75) + 1:(i * 75) + 74, -1] = 0\n",
- " \n",
+ "\n",
" contours.world_object.geometry.colors.update_range()"
]
},
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/cycle_animation.py b/examples/misc/cycle_animation.py
index 833321453..d1a369c79 100644
--- a/examples/misc/cycle_animation.py
+++ b/examples/misc/cycle_animation.py
@@ -37,16 +37,16 @@
figure = fpl.Figure(size=(700, 560))
subplot_scatter = figure[0, 0]
# use an alpha value since this will be a lot of points
-scatter_graphic = subplot_scatter.add_scatter(data=cloud, sizes=3, colors=colors, alpha=0.6)
+scatter = subplot_scatter.add_scatter(data=cloud, sizes=3, colors=colors, alpha=0.6)
i = 0.05
def cycle_colors(subplot):
global i
# cycle the red values
- scatter_graphic.colors[n_points * 2:, 0] = np.abs(np.sin(i))
- scatter_graphic.colors[n_points * 2:, 1] = np.abs(np.sin(i + (np.pi / 4)))
- scatter_graphic.colors[n_points * 2:, 2] = np.abs(np.cos(i))
+ scatter.colors[n_points * 2:, 0] = np.abs(np.sin(i))
+ scatter.colors[n_points * 2:, 1] = np.abs(np.sin(i + (np.pi / 4)))
+ scatter.colors[n_points * 2:, 2] = np.abs(np.cos(i))
i += 0.05
subplot_scatter.add_animations(cycle_colors)
diff --git a/examples/misc/image_animation.py b/examples/misc/image_animation.py
index 1f7ff6109..324a8e727 100644
--- a/examples/misc/image_animation.py
+++ b/examples/misc/image_animation.py
@@ -16,10 +16,10 @@
figure = fpl.Figure(size=(700, 560))
# plot the image data
-image_graphic = figure[0, 0].add_image(data=data, name="random-image")
+image = figure[0, 0].add_image(data=data, name="random-image")
-# a function to update the image_graphic
+# a function to update the image
# a figure-level animation function will optionally take the figure as an argument
def update_data(figure_instance):
new_data = np.random.rand(512, 512)
diff --git a/examples/misc/line3d_animation.py b/examples/misc/line3d_animation.py
index c1d903e02..f718fff0a 100644
--- a/examples/misc/line3d_animation.py
+++ b/examples/misc/line3d_animation.py
@@ -19,11 +19,11 @@
zs = phi
# make data 3d, with shape [, 3]
-spiral = np.dstack([xs, ys, zs])[0]
+spiral = np.column_stack([xs, ys, zs])
figure = fpl.Figure(cameras="3d", size=(700, 560))
-line_graphic = figure[0,0].add_line(data=spiral, thickness=3, cmap='jet')
+line = figure[0,0].add_line(data=spiral, thickness=3, cmap='jet')
marker = figure[0,0].add_scatter(data=spiral[0], sizes=10, name="marker")
diff --git a/examples/misc/scatter_animation.py b/examples/misc/scatter_animation.py
index ee8d2a10a..d37aea976 100644
--- a/examples/misc/scatter_animation.py
+++ b/examples/misc/scatter_animation.py
@@ -37,13 +37,13 @@
figure = fpl.Figure(size=(700, 560))
subplot_scatter = figure[0, 0]
# use an alpha value since this will be a lot of points
-scatter_graphic = subplot_scatter.add_scatter(data=cloud, sizes=3, colors=colors, alpha=0.6)
+scatter = subplot_scatter.add_scatter(data=cloud, sizes=3, colors=colors, alpha=0.6)
def update_points(subplot):
# move every point by a small amount
- deltas = np.random.normal(size=scatter_graphic.data.value.shape, loc=0, scale=0.15)
- scatter_graphic.data = scatter_graphic.data.value + deltas
+ deltas = np.random.normal(size=scatter.data.value.shape, loc=0, scale=0.15)
+ scatter.data = scatter.data.value + deltas
subplot_scatter.add_animations(update_points)
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 a62190906..3a54a945b 100644
--- a/examples/misc/tooltips_custom.py
+++ b/examples/misc/tooltips_custom.py
@@ -23,7 +23,7 @@
agg = AgglomerativeClustering(n_clusters=3)
agg.fit_predict(data)
-scatter_graphic = figure[0, 0].add_scatter(
+scatter = figure[0, 0].add_scatter(
data=data[:, :-1], # use only xy data
sizes=15,
cmap="Set1",
@@ -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_graphic, custom_info=tooltip_info)
+scatter.tooltip_format = tooltip_info
figure.show()
diff --git a/examples/notebooks/quickstart.ipynb b/examples/notebooks/quickstart.ipynb
index 0d8fc3c31..7b7551588 100644
--- a/examples/notebooks/quickstart.ipynb
+++ b/examples/notebooks/quickstart.ipynb
@@ -99,7 +99,7 @@
"data = iio.imread(\"imageio:camera.png\")\n",
"\n",
"# plot the image data\n",
- "image_graphic = fig[0, 0].add_image(data=data, name=\"sample-image\")\n",
+ "image = fig[0, 0].add_image(data=data, name=\"sample-image\")\n",
"\n",
"# show the plot\n",
"fig.show(sidecar=True)"
@@ -132,7 +132,7 @@
},
"outputs": [],
"source": [
- "image_graphic.cmap = \"viridis\""
+ "image.cmap = \"viridis\""
]
},
{
@@ -158,7 +158,7 @@
"source": [
"# some graphic properties behave like arrays\n",
"# access the underlying array using .values\n",
- "image_graphic.data.value.shape"
+ "image.data.value.shape"
]
},
{
@@ -170,8 +170,8 @@
},
"outputs": [],
"source": [
- "image_graphic.data[::15, :] = 1\n",
- "image_graphic.data[:, ::15] = 1"
+ "image.data[::15, :] = 1\n",
+ "image.data[:, ::15] = 1"
]
},
{
@@ -191,7 +191,7 @@
},
"outputs": [],
"source": [
- "image_graphic.data[data > 175] = 255"
+ "image.data[data > 175] = 255"
]
},
{
@@ -211,8 +211,8 @@
},
"outputs": [],
"source": [
- "image_graphic.vmin = 50\n",
- "image_graphic.vmax = 150"
+ "image.vmin = 50\n",
+ "image.vmax = 150"
]
},
{
@@ -281,7 +281,7 @@
},
"outputs": [],
"source": [
- "image_graphic.data = gray"
+ "image.data = gray"
]
},
{
@@ -303,7 +303,7 @@
},
"outputs": [],
"source": [
- "image_graphic.reset_vmin_vmax()"
+ "image.reset_vmin_vmax()"
]
},
{
@@ -432,7 +432,7 @@
},
"outputs": [],
"source": [
- "image_graphic"
+ "image"
]
},
{
@@ -444,7 +444,7 @@
},
"outputs": [],
"source": [
- "image_graphic == fig[0, 0][\"sample-image\"]"
+ "image == fig[0, 0][\"sample-image\"]"
]
},
{
@@ -561,7 +561,7 @@
"# plot the data\n",
"fig_v[0, 0].add_image(data=data, name=\"random-image\")\n",
"\n",
- "# a function to update the image_graphic\n",
+ "# a function to update the image\n",
"# a figure-level animation function will optionally take the figure as an argument\n",
"def update_data(figure_instance):\n",
" new_data = np.random.rand(512, 512)\n",
@@ -602,13 +602,13 @@
"\n",
"data = np.random.rand(512, 512)\n",
"\n",
- "image_graphic_instance = fig_sync[0, 0].add_image(data=data, cmap=\"viridis\")\n",
+ "image = fig_sync[0, 0].add_image(data=data, cmap=\"viridis\")\n",
"\n",
"# you will need to define a new animation function for this graphic\n",
"def update_data_2():\n",
" new_data = np.random.rand(512, 512)\n",
" # alternatively, you can use the stored reference to the graphic as well instead of indexing the subplot\n",
- " image_graphic_instance.data = new_data\n",
+ " image.data = new_data\n",
"\n",
"fig_sync.add_animations(update_data_2)\n",
"\n",
@@ -620,7 +620,7 @@
"id": "f226c9c2-8d0e-41ab-9ab9-1ae31fd91de5",
"metadata": {},
"source": [
- "#### Keeping a reference to the Graphic instance, as shown above `image_graphic_instance`, is useful if you're creating something where it is convenient to keep your own reference to a `Graphic`"
+ "#### Keeping a reference to the Graphic instance, as shown above `image`, is useful if you're creating something where it is convenient to keep your own reference to a `Graphic`"
]
},
{
@@ -678,7 +678,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "8e8280da-b421-43a5-a1a6-2a196a408e9a",
+ "id": "72a665d195e61427",
"metadata": {},
"outputs": [],
"source": [
@@ -686,16 +686,16 @@
"xs = np.linspace(-10, 10, 100)\n",
"# sine wave\n",
"ys = np.sin(xs)\n",
- "sine = np.column_stack([xs, ys])\n",
+ "sine_data = np.column_stack([xs, ys])\n",
"\n",
"# cosine wave\n",
"ys = np.cos(xs) + 5\n",
- "cosine = np.column_stack([xs, ys])\n",
+ "cosine_data = np.column_stack([xs, ys])\n",
"\n",
"# sinc function\n",
"a = 0.5\n",
"ys = np.sinc(xs) * 3 + 8\n",
- "sinc = np.column_stack([xs, ys])"
+ "sinc_data = np.column_stack([xs, ys])"
]
},
{
@@ -709,7 +709,7 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "93a5d1e6-d019-4dd0-a0d1-25d1704ab7a7",
+ "id": "647c268622cc813",
"metadata": {},
"outputs": [],
"source": [
@@ -720,14 +720,14 @@
"subplot = fig_lines[0, 0]\n",
"\n",
"# plot sine wave, use a single color\n",
- "sine_graphic = subplot.add_line(data=sine, thickness=5, colors=\"magenta\")\n",
+ "sine = subplot.add_line(data=sine_data, thickness=5, colors=\"magenta\")\n",
"\n",
"# you can also use colormaps for lines!\n",
- "cosine_graphic = subplot.add_line(data=cosine, thickness=12, cmap=\"autumn\")\n",
+ "cosine = subplot.add_line(data=cosine_data, thickness=12, cmap=\"autumn\")\n",
"\n",
"# or a list of colors for each datapoint\n",
"colors = [\"r\"] * 25 + [\"purple\"] * 25 + [\"y\"] * 25 + [\"b\"] * 25\n",
- "sinc_graphic = subplot.add_line(data=sinc, thickness=5, colors = colors)\n",
+ "sinc = subplot.add_line(data=sinc_data, thickness=5, colors = colors)\n",
"\n",
"# show the plot\n",
"fig_lines.show(sidecar=True, sidecar_kwargs={\"title\": \"lines\"})"
@@ -736,10 +736,8 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "a4060576-2f29-4e4b-a86a-0410c766bd98",
- "metadata": {
- "tags": []
- },
+ "id": "80e1f1bd7ee957e9",
+ "metadata": {},
"outputs": [],
"source": [
"# testing cell, ignore\n",
@@ -791,21 +789,21 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "cb0d13ed-ef07-46ff-b19e-eeca4c831037",
+ "id": "6b7b377fcf487815",
"metadata": {},
"outputs": [],
"source": [
"# indexing of colors\n",
- "cosine_graphic.colors[:15] = \"magenta\"\n",
- "cosine_graphic.colors[90:] = \"red\"\n",
- "cosine_graphic.colors[60] = \"w\"\n",
+ "cosine.colors[:15] = \"magenta\"\n",
+ "cosine.colors[90:] = \"red\"\n",
+ "cosine.colors[60] = \"w\"\n",
"\n",
"# indexing to assign colormaps to entire lines or segments\n",
- "sinc_graphic.cmap[10:50] = \"gray\"\n",
- "sine_graphic.cmap = \"seismic\"\n",
+ "sinc.cmap[10:50] = \"gray\"\n",
+ "sine.cmap = \"seismic\"\n",
"\n",
"# more complex indexing, set the blue value directly from an array\n",
- "cosine_graphic.colors[65:90, 0] = np.linspace(0, 1, 90-65)"
+ "cosine.colors[65:90, 0] = np.linspace(0, 1, 90 - 65)"
]
},
{
@@ -827,7 +825,7 @@
" print(event_data)\n",
"\n",
"# Will print event data when the color changes\n",
- "cosine_graphic.add_event_handler(callback_func, \"colors\")"
+ "cosine.add_event_handler(callback_func, \"colors\")"
]
},
{
@@ -839,7 +837,7 @@
"source": [
"# more complex indexing of colors\n",
"# from point 15 - 30, set every 3rd point as \"cyan\"\n",
- "cosine_graphic.colors[15:50:3] = \"cyan\""
+ "cosine.colors[15:50:3] = \"cyan\""
]
},
{
@@ -866,22 +864,22 @@
{
"cell_type": "code",
"execution_count": null,
- "id": "d1a4314b-5723-43c7-94a0-b4cbb0e44d60",
+ "id": "2b2d68d0d8cc321a",
"metadata": {},
"outputs": [],
"source": [
- "cosine_graphic.data[10:50:5, :2] = sine[10:50:5]\n",
- "cosine_graphic.data[90:, 1] = 7"
+ "cosine.data[10:50:5, :2] = sine_data[10:50:5]\n",
+ "cosine.data[90:, 1] = 7"
]
},
{
"cell_type": "code",
"execution_count": null,
- "id": "682db47b-8c7a-4934-9be4-2067e9fb12d5",
+ "id": "d43b9aeca4bb8ddd",
"metadata": {},
"outputs": [],
"source": [
- "cosine_graphic.data[0] = np.array([[-10, 0, 0]])"
+ "cosine.data[0] = np.array([[-10, 0, 0]])"
]
},
{
@@ -1356,7 +1354,7 @@
"fig_scatter = fpl.Figure()\n",
"subplot_scatter = fig_scatter[0, 0]\n",
"# use an alpha value since this will be a lot of points\n",
- "scatter_graphic = subplot_scatter.add_scatter(data=cloud, sizes=3, colors=colors, alpha=0.6)\n",
+ "scatter = subplot_scatter.add_scatter(data=cloud, sizes=3, colors=colors, alpha=0.6)\n",
"\n",
"fig_scatter.show(sidecar=True)"
]
@@ -1377,7 +1375,7 @@
"outputs": [],
"source": [
"# half of the first cloud's points to red\n",
- "scatter_graphic.colors[:n_points:2] = \"r\""
+ "scatter.colors[:n_points:2] = \"r\""
]
},
{
@@ -1388,7 +1386,7 @@
"outputs": [],
"source": [
"# other half of the first cloud's points to purple\n",
- "scatter_graphic.colors[1:n_points:2] = \"purple\""
+ "scatter.colors[1:n_points:2] = \"purple\""
]
},
{
@@ -1399,7 +1397,7 @@
"outputs": [],
"source": [
"# set the green value directly\n",
- "scatter_graphic.colors[n_points:n_points * 2, 1] = 0.3"
+ "scatter.colors[n_points:n_points * 2, 1] = 0.3"
]
},
{
@@ -1410,7 +1408,7 @@
"outputs": [],
"source": [
"# set color values directly using an array\n",
- "scatter_graphic.colors[n_points * 2:] = np.repeat([[1, 1, 0, 0.5]], n_points, axis=0)"
+ "scatter.colors[n_points * 2:] = np.repeat([[1, 1, 0, 0.5]], n_points, axis=0)"
]
},
{
@@ -1421,7 +1419,7 @@
"outputs": [],
"source": [
"# change the data, change y-values\n",
- "scatter_graphic.data[n_points:n_points * 2, 1] += 15"
+ "scatter.data[n_points:n_points * 2, 1] += 15"
]
},
{
@@ -1432,7 +1430,7 @@
"outputs": [],
"source": [
"# set x values directly but using an array\n",
- "scatter_graphic.data[n_points:n_points * 2, 0] = np.linspace(-40, 0, n_points)"
+ "scatter.data[n_points:n_points * 2, 0] = np.linspace(-40, 0, n_points)"
]
},
{
@@ -1473,8 +1471,8 @@
"source": [
"def update_points(subplot):\n",
" # move every point by a small amount\n",
- " deltas = np.random.normal(size=scatter_graphic.data.value.shape, loc=0, scale=0.15)\n",
- " scatter_graphic.data = scatter_graphic.data[:] + deltas\n",
+ " deltas = np.random.normal(size=scatter.data.value.shape, loc=0, scale=0.15)\n",
+ " scatter.data = scatter.data[:] + deltas\n",
"\n",
"subplot_scatter.add_animations(update_points)"
]
@@ -1498,9 +1496,9 @@
"def cycle_colors(subplot):\n",
" global i\n",
" # cycle the red values\n",
- " scatter_graphic.colors[n_points * 2:, 0] = np.abs(np.sin(i))\n",
- " scatter_graphic.colors[n_points * 2:, 1] = np.abs(np.sin(i + (np.pi / 4)))\n",
- " scatter_graphic.colors[n_points * 2:, 2] = np.abs(np.cos(i))\n",
+ " scatter.colors[n_points * 2:, 0] = np.abs(np.sin(i))\n",
+ " scatter.colors[n_points * 2:, 1] = np.abs(np.sin(i + (np.pi / 4)))\n",
+ " scatter.colors[n_points * 2:, 2] = np.abs(np.cos(i))\n",
" i += 0.05\n",
"\n",
"subplot_scatter.add_animations(cycle_colors)"
@@ -1979,7 +1977,7 @@
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
- "version": "3.11.3"
+ "version": "3.11.12"
}
},
"nbformat": 4,
diff --git a/examples/notebooks/screenshots/nb-astronaut.png b/examples/notebooks/screenshots/nb-astronaut.png
index 2370c5988..405b26e14 100644
--- a/examples/notebooks/screenshots/nb-astronaut.png
+++ b/examples/notebooks/screenshots/nb-astronaut.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:0a6e8bb3c72f1be6915e8e78c9a4f269419cfb4faded16e39b5cb11d70bec247
-size 64185
+oid sha256:dbcc22edca9b0dd74da7754a8515c9000d6e651af39135ab90d5a1eaf438d324
+size 66559
diff --git a/examples/notebooks/screenshots/nb-astronaut_RGB.png b/examples/notebooks/screenshots/nb-astronaut_RGB.png
index 2a7eac585..0e3d21ef6 100644
--- a/examples/notebooks/screenshots/nb-astronaut_RGB.png
+++ b/examples/notebooks/screenshots/nb-astronaut_RGB.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:9f9f32e86018f87057435f7121b02bbe98823444babb330645bab618e1d586b7
-size 63838
+oid sha256:1b8f9fc3d5e14c1f90c48128be5599d64e994dcce8579bf9276453d68dfdf022
+size 64354
diff --git a/examples/notebooks/screenshots/nb-camera.png b/examples/notebooks/screenshots/nb-camera.png
index bfe226ca4..f58781bb1 100644
--- a/examples/notebooks/screenshots/nb-camera.png
+++ b/examples/notebooks/screenshots/nb-camera.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:2964d0150b38f990a7b804e9057f99505e8c99bb04538a13137989d540704593
-size 47456
+oid sha256:e744172658cf49cd7725c8ee1f3e983a3284848f10fc5805b17ae655e439cbda
+size 50393
diff --git a/examples/notebooks/screenshots/nb-image-widget-movie-set_data.png b/examples/notebooks/screenshots/nb-image-widget-movie-set_data.png
index 2578ad028..181b9cb64 100644
--- a/examples/notebooks/screenshots/nb-image-widget-movie-set_data.png
+++ b/examples/notebooks/screenshots/nb-image-widget-movie-set_data.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:78e7e99fafc15cc6edf53cfb2e5b679623ad14e0d594e0ad615088e623be22e1
-size 60988
+oid sha256:29b42b78551ad2e986eb01d996f261ef9499f059ba285cc6e37fb903df090826
+size 64007
diff --git a/examples/notebooks/screenshots/nb-image-widget-movie-single-0-reset.png b/examples/notebooks/screenshots/nb-image-widget-movie-single-0-reset.png
index 0129cb423..c8761fc8a 100644
--- a/examples/notebooks/screenshots/nb-image-widget-movie-single-0-reset.png
+++ b/examples/notebooks/screenshots/nb-image-widget-movie-single-0-reset.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:d3f5a721456b5a54e819fc987b8fa1f61d638f578339a7332ad46a22e7aa8fc0
-size 112674
+oid sha256:c9f6c3d3a76f2fbbbdcf8107e8b2db7900079d252384f7f722649c3ebbe92993
+size 115525
diff --git a/examples/notebooks/screenshots/nb-image-widget-movie-single-0.png b/examples/notebooks/screenshots/nb-image-widget-movie-single-0.png
index 0129cb423..c8761fc8a 100644
--- a/examples/notebooks/screenshots/nb-image-widget-movie-single-0.png
+++ b/examples/notebooks/screenshots/nb-image-widget-movie-single-0.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:d3f5a721456b5a54e819fc987b8fa1f61d638f578339a7332ad46a22e7aa8fc0
-size 112674
+oid sha256:c9f6c3d3a76f2fbbbdcf8107e8b2db7900079d252384f7f722649c3ebbe92993
+size 115525
diff --git a/examples/notebooks/screenshots/nb-image-widget-movie-single-279.png b/examples/notebooks/screenshots/nb-image-widget-movie-single-279.png
index 4908c8b59..7ded7f21b 100644
--- a/examples/notebooks/screenshots/nb-image-widget-movie-single-279.png
+++ b/examples/notebooks/screenshots/nb-image-widget-movie-single-279.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:4511a28e728af412f5006bb456f133aea1fdc9c1922c3174f127c79d9878401d
-size 133635
+oid sha256:45c6c01e5fd26b06af55c0e2b98d8abd40603599dd53be3838479a447e099af8
+size 137111
diff --git a/examples/notebooks/screenshots/nb-image-widget-movie-single-50-window-max-33.png b/examples/notebooks/screenshots/nb-image-widget-movie-single-50-window-max-33.png
index cfdc3c8a9..381ea8d70 100644
--- a/examples/notebooks/screenshots/nb-image-widget-movie-single-50-window-max-33.png
+++ b/examples/notebooks/screenshots/nb-image-widget-movie-single-50-window-max-33.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:c6910106cd799a4327a6650edbc956ddb9b6a489760b86b279c593575ae805b8
-size 120114
+oid sha256:415ef8938bc982678e1402da5ab40e991f600302c13a8beda9690baa672e2289
+size 124095
diff --git a/examples/notebooks/screenshots/nb-image-widget-movie-single-50-window-mean-13.png b/examples/notebooks/screenshots/nb-image-widget-movie-single-50-window-mean-13.png
index 92513cf5b..451d1060a 100644
--- a/examples/notebooks/screenshots/nb-image-widget-movie-single-50-window-mean-13.png
+++ b/examples/notebooks/screenshots/nb-image-widget-movie-single-50-window-mean-13.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:8233dfc429a7fefe96f0fdb89eb2c57188b7963c16db5d1d08f7faefb45d8cb7
-size 105755
+oid sha256:9439c5a18046ee0a9125de2fd5ecf5b5e605579d844dab9a741a1fd0833d32d3
+size 108554
diff --git a/examples/notebooks/screenshots/nb-image-widget-movie-single-50-window-mean-33.png b/examples/notebooks/screenshots/nb-image-widget-movie-single-50-window-mean-33.png
index 8bce59baf..a4031beb3 100644
--- a/examples/notebooks/screenshots/nb-image-widget-movie-single-50-window-mean-33.png
+++ b/examples/notebooks/screenshots/nb-image-widget-movie-single-50-window-mean-33.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:a4af684cdaec8f98081862eb8a377cd419efec64cdf08b662a456276b78f1fb5
-size 98091
+oid sha256:926298948f78d7f205cd95dffc1623f2980a7a3667c45e752daa96b1f64d7cf1
+size 101047
diff --git a/examples/notebooks/screenshots/nb-image-widget-movie-single-50-window-reset.png b/examples/notebooks/screenshots/nb-image-widget-movie-single-50-window-reset.png
index 61c3c4f6c..264ab5c2e 100644
--- a/examples/notebooks/screenshots/nb-image-widget-movie-single-50-window-reset.png
+++ b/examples/notebooks/screenshots/nb-image-widget-movie-single-50-window-reset.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:133dfe6b0028dda6248df1afde1288c57625be99b25c8224673597de4d4f70fc
-size 118588
+oid sha256:1a682eba89f4456cf381679da67113741bcaace7a540b68b31e6276e6a8f95ce
+size 121705
diff --git a/examples/notebooks/screenshots/nb-image-widget-movie-single-50.png b/examples/notebooks/screenshots/nb-image-widget-movie-single-50.png
index 61c3c4f6c..264ab5c2e 100644
--- a/examples/notebooks/screenshots/nb-image-widget-movie-single-50.png
+++ b/examples/notebooks/screenshots/nb-image-widget-movie-single-50.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:133dfe6b0028dda6248df1afde1288c57625be99b25c8224673597de4d4f70fc
-size 118588
+oid sha256:1a682eba89f4456cf381679da67113741bcaace7a540b68b31e6276e6a8f95ce
+size 121705
diff --git a/examples/notebooks/screenshots/nb-image-widget-single-gnuplot2.png b/examples/notebooks/screenshots/nb-image-widget-single-gnuplot2.png
index e8c02adfe..147f852bb 100644
--- a/examples/notebooks/screenshots/nb-image-widget-single-gnuplot2.png
+++ b/examples/notebooks/screenshots/nb-image-widget-single-gnuplot2.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:8c8562f8e1178cf21af98af635006c64010f3c5fc615533d1df8c49479232843
-size 217693
+oid sha256:ae9f5efe64ff9ef3be7cb22444f4afd6430642b653c6b1337167aca4e06c7d81
+size 225650
diff --git a/examples/notebooks/screenshots/nb-image-widget-single.png b/examples/notebooks/screenshots/nb-image-widget-single.png
index 8de4099fb..4d86e17aa 100644
--- a/examples/notebooks/screenshots/nb-image-widget-single.png
+++ b/examples/notebooks/screenshots/nb-image-widget-single.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:5c9bae3c9c5521a4054288be7ae548204fc7b0eafbc3e99cb6b649e0be797169
-size 207176
+oid sha256:0dc443374648f10112d4afa323a1e2ffff170ebbcbf7b640fc4dc07379f14d5d
+size 216362
diff --git a/examples/notebooks/screenshots/nb-image-widget-zfish-frame-50-frame-apply-gaussian.png b/examples/notebooks/screenshots/nb-image-widget-zfish-frame-50-frame-apply-gaussian.png
index 29fe20f44..ca40fe5ed 100644
--- a/examples/notebooks/screenshots/nb-image-widget-zfish-frame-50-frame-apply-gaussian.png
+++ b/examples/notebooks/screenshots/nb-image-widget-zfish-frame-50-frame-apply-gaussian.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:87a3947d6c59c7f67acca25911e0ab93ddc9231a8c3060d2fffe3c53f39055f2
-size 62263
+oid sha256:015933efd89517a65d73d9c91423e438260b7115fbf55386ad4b35ec72f39cb6
+size 64820
diff --git a/examples/notebooks/screenshots/nb-image-widget-zfish-frame-50-frame-apply-reset.png b/examples/notebooks/screenshots/nb-image-widget-zfish-frame-50-frame-apply-reset.png
index c7944f591..fe1f9d0ad 100644
--- a/examples/notebooks/screenshots/nb-image-widget-zfish-frame-50-frame-apply-reset.png
+++ b/examples/notebooks/screenshots/nb-image-widget-zfish-frame-50-frame-apply-reset.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:b57c65974362d258ec7be8de391c41d7909ed260b92411f4b0ed8ed03b886a29
-size 73040
+oid sha256:97dcdc4ab1a4307507b090dc2e8f7d2c5998828000be4e3b67fcf3941b835473
+size 69210
diff --git a/examples/notebooks/screenshots/nb-image-widget-zfish-frame-50-max-window-13.png b/examples/notebooks/screenshots/nb-image-widget-zfish-frame-50-max-window-13.png
index eb9c9059d..640bed79b 100644
--- a/examples/notebooks/screenshots/nb-image-widget-zfish-frame-50-max-window-13.png
+++ b/examples/notebooks/screenshots/nb-image-widget-zfish-frame-50-max-window-13.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:008381b267ae26e8693ae51e7a4fabc464288ec8aa911ff3a1deb37543cc4fbe
-size 115543
+oid sha256:6ba7d7fe7daae9e553f5ca45029822ebb432953c62cf301e9922a752ea438f17
+size 113828
diff --git a/examples/notebooks/screenshots/nb-image-widget-zfish-frame-50-mean-window-13.png b/examples/notebooks/screenshots/nb-image-widget-zfish-frame-50-mean-window-13.png
index 8b887f5fd..9198e2ad6 100644
--- a/examples/notebooks/screenshots/nb-image-widget-zfish-frame-50-mean-window-13.png
+++ b/examples/notebooks/screenshots/nb-image-widget-zfish-frame-50-mean-window-13.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:fedfec781724d4731f8cc34ffc39388d14dc60dad4a9fae9ff56625edf11f87a
-size 94178
+oid sha256:374bbf072443b243c0393d5311fb361a6f089b22e5b60b6a80c90da20ac18a8d
+size 97296
diff --git a/examples/notebooks/screenshots/nb-image-widget-zfish-frame-50-mean-window-5.png b/examples/notebooks/screenshots/nb-image-widget-zfish-frame-50-mean-window-5.png
index ef3aa7a92..ce3f25a67 100644
--- a/examples/notebooks/screenshots/nb-image-widget-zfish-frame-50-mean-window-5.png
+++ b/examples/notebooks/screenshots/nb-image-widget-zfish-frame-50-mean-window-5.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:08e8379187754fa14f360ed54f2ed8cf61b3df71a8b6f2e95ff1ed27aa435d60
-size 90105
+oid sha256:eb0c0814509246ca64f9a820dac4617d55fa6be28ec59e787719b653f60d5818
+size 89409
diff --git a/examples/notebooks/screenshots/nb-image-widget-zfish-frame-50.png b/examples/notebooks/screenshots/nb-image-widget-zfish-frame-50.png
index c7944f591..fe1f9d0ad 100644
--- a/examples/notebooks/screenshots/nb-image-widget-zfish-frame-50.png
+++ b/examples/notebooks/screenshots/nb-image-widget-zfish-frame-50.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:b57c65974362d258ec7be8de391c41d7909ed260b92411f4b0ed8ed03b886a29
-size 73040
+oid sha256:97dcdc4ab1a4307507b090dc2e8f7d2c5998828000be4e3b67fcf3941b835473
+size 69210
diff --git a/examples/notebooks/screenshots/nb-image-widget-zfish-frame-99.png b/examples/notebooks/screenshots/nb-image-widget-zfish-frame-99.png
index 0d19a35ce..19e84868b 100644
--- a/examples/notebooks/screenshots/nb-image-widget-zfish-frame-99.png
+++ b/examples/notebooks/screenshots/nb-image-widget-zfish-frame-99.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:848e89e38b9b5ef97d6bb4b301c0ae10cc29f438518721663ae52fa42f492408
-size 65267
+oid sha256:9d286be8d9ed81b84d2ebfb308287ae7df17dfad4860fde8faf41687958a6f40
+size 60572
diff --git a/examples/notebooks/screenshots/nb-image-widget-zfish-grid-frame-50-frame-apply-gaussian.png b/examples/notebooks/screenshots/nb-image-widget-zfish-grid-frame-50-frame-apply-gaussian.png
index 96a3b12c8..20b5c0a50 100644
--- a/examples/notebooks/screenshots/nb-image-widget-zfish-grid-frame-50-frame-apply-gaussian.png
+++ b/examples/notebooks/screenshots/nb-image-widget-zfish-grid-frame-50-frame-apply-gaussian.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:17cd05ae14cacdef6aa1eca3544246b814ef21762a33f6e785f6d621ea30ff96
-size 80570
+oid sha256:7c86f37cd4ad4e4a34e402008ec52fbe965d52e79b5ad8210af8813f8e4e3ed3
+size 85103
diff --git a/examples/notebooks/screenshots/nb-image-widget-zfish-grid-frame-50-frame-apply-reset.png b/examples/notebooks/screenshots/nb-image-widget-zfish-grid-frame-50-frame-apply-reset.png
index 1df19c904..9e368022a 100644
--- a/examples/notebooks/screenshots/nb-image-widget-zfish-grid-frame-50-frame-apply-reset.png
+++ b/examples/notebooks/screenshots/nb-image-widget-zfish-grid-frame-50-frame-apply-reset.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:a673fa1ffa6f746ab9f462b4d592492ec02bfdd3fb53bdf1f71fb9427f8d6d23
-size 105798
+oid sha256:c6801347572dfd3a4be34cdd18915dc8dd4787764d3c8634b34b66b2c3f130d7
+size 102913
diff --git a/examples/notebooks/screenshots/nb-image-widget-zfish-grid-frame-50-max-window-13.png b/examples/notebooks/screenshots/nb-image-widget-zfish-grid-frame-50-max-window-13.png
index 43230f8be..486934731 100644
--- a/examples/notebooks/screenshots/nb-image-widget-zfish-grid-frame-50-max-window-13.png
+++ b/examples/notebooks/screenshots/nb-image-widget-zfish-grid-frame-50-max-window-13.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:446d54cea3d54b0fd92b70abcc090cfee30b19454dce118d9875fbeb8b40b4a8
-size 141294
+oid sha256:02e91b1c0a327c6784387d279c34550bd74c5ed7e4c6587a2ef20f6f00204ff1
+size 143096
diff --git a/examples/notebooks/screenshots/nb-image-widget-zfish-grid-frame-50-mean-window-13.png b/examples/notebooks/screenshots/nb-image-widget-zfish-grid-frame-50-mean-window-13.png
index 0841a8e08..05a88a73e 100644
--- a/examples/notebooks/screenshots/nb-image-widget-zfish-grid-frame-50-mean-window-13.png
+++ b/examples/notebooks/screenshots/nb-image-widget-zfish-grid-frame-50-mean-window-13.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:99d3706d5574a1236264f556eb3ce6d71e81b65bd8dcce1c1415e5f139316c23
-size 107894
+oid sha256:e30d25ac2af4d897e2e562b31bf1b92d199e34b1da4baad35d110dc8dcb042ba
+size 115631
diff --git a/examples/notebooks/screenshots/nb-image-widget-zfish-grid-frame-50-mean-window-5.png b/examples/notebooks/screenshots/nb-image-widget-zfish-grid-frame-50-mean-window-5.png
index 28bab9f02..25c537801 100644
--- a/examples/notebooks/screenshots/nb-image-widget-zfish-grid-frame-50-mean-window-5.png
+++ b/examples/notebooks/screenshots/nb-image-widget-zfish-grid-frame-50-mean-window-5.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:ffa17fc1b71c5146cae88493ed40c606dd0a99f3e10f3827ac349d5a5d6f6108
-size 112702
+oid sha256:9d74bacc8c31e9b7896f46230a7734488c1d1ee5f833584f6be265d04b28860d
+size 117556
diff --git a/examples/notebooks/screenshots/nb-image-widget-zfish-grid-frame-50.png b/examples/notebooks/screenshots/nb-image-widget-zfish-grid-frame-50.png
index 1df19c904..9e368022a 100644
--- a/examples/notebooks/screenshots/nb-image-widget-zfish-grid-frame-50.png
+++ b/examples/notebooks/screenshots/nb-image-widget-zfish-grid-frame-50.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:a673fa1ffa6f746ab9f462b4d592492ec02bfdd3fb53bdf1f71fb9427f8d6d23
-size 105798
+oid sha256:c6801347572dfd3a4be34cdd18915dc8dd4787764d3c8634b34b66b2c3f130d7
+size 102913
diff --git a/examples/notebooks/screenshots/nb-image-widget-zfish-grid-frame-99.png b/examples/notebooks/screenshots/nb-image-widget-zfish-grid-frame-99.png
index 06ed02628..659eec9de 100644
--- a/examples/notebooks/screenshots/nb-image-widget-zfish-grid-frame-99.png
+++ b/examples/notebooks/screenshots/nb-image-widget-zfish-grid-frame-99.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:4d3e88eee05bc68dd17918197602fb5c0a959ad74a4f592aea4514e570d29232
-size 103431
+oid sha256:9ec15239b78880daafe3b5c18101019a30cd51db465c515bfc275bce9729eb5a
+size 100085
diff --git a/examples/notebooks/screenshots/nb-image-widget-zfish-grid-init-mean-window-5.png b/examples/notebooks/screenshots/nb-image-widget-zfish-grid-init-mean-window-5.png
index 61702a6d9..e73518f67 100644
--- a/examples/notebooks/screenshots/nb-image-widget-zfish-grid-init-mean-window-5.png
+++ b/examples/notebooks/screenshots/nb-image-widget-zfish-grid-init-mean-window-5.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:272156c4261bba40eba92f953a0f5078ad8ff2aa80f06a53f73a3572eb537dd5
-size 111155
+oid sha256:5bc163776b12df7289bcf6e4ed160c843cd7bae12d48c8de90e2fda7039b1755
+size 111929
diff --git a/examples/notebooks/screenshots/nb-image-widget-zfish-grid-set_data-reset-indices-false.png b/examples/notebooks/screenshots/nb-image-widget-zfish-grid-set_data-reset-indices-false.png
index 412822a40..b0d74d7e1 100644
--- a/examples/notebooks/screenshots/nb-image-widget-zfish-grid-set_data-reset-indices-false.png
+++ b/examples/notebooks/screenshots/nb-image-widget-zfish-grid-set_data-reset-indices-false.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:8203f859fe54e2b59a143a9a569c2854640b1501b9ab4f8512520bbf73dae3c6
-size 105658
+oid sha256:d4a1e4b59103361750bb76e113db7b2a49f508e53c45086c11dcea01b72c40a6
+size 102983
diff --git a/examples/notebooks/screenshots/nb-image-widget-zfish-grid-set_data-reset-indices-true.png b/examples/notebooks/screenshots/nb-image-widget-zfish-grid-set_data-reset-indices-true.png
index 234924487..a7cba3900 100644
--- a/examples/notebooks/screenshots/nb-image-widget-zfish-grid-set_data-reset-indices-true.png
+++ b/examples/notebooks/screenshots/nb-image-widget-zfish-grid-set_data-reset-indices-true.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:8ca187ba67e7928c8f96b1f9a0a18bec65f81352701e60c33d47aaadb2756d5c
-size 106446
+oid sha256:db7ca9b37fffdfbebe31a439b12c4b83a73c7893634ddfb676640035b01105da
+size 104387
diff --git a/examples/notebooks/screenshots/nb-image-widget-zfish-init-mean-window-5.png b/examples/notebooks/screenshots/nb-image-widget-zfish-init-mean-window-5.png
index 870945ce7..b288984e2 100644
--- a/examples/notebooks/screenshots/nb-image-widget-zfish-init-mean-window-5.png
+++ b/examples/notebooks/screenshots/nb-image-widget-zfish-init-mean-window-5.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:f42367c833a23d3fe10c6fb0d754338c12a30288d9769ad3f8b1159505abf8ff
-size 78796
+oid sha256:fd8b96126d72687ef5ef79990f096faff8317d8be84181ee666677e92b303677
+size 76841
diff --git a/examples/notebooks/screenshots/nb-image-widget-zfish-mixed-rgb-cockatoo-frame-50.png b/examples/notebooks/screenshots/nb-image-widget-zfish-mixed-rgb-cockatoo-frame-50.png
index 7880fc1d8..27b6d6df4 100644
--- a/examples/notebooks/screenshots/nb-image-widget-zfish-mixed-rgb-cockatoo-frame-50.png
+++ b/examples/notebooks/screenshots/nb-image-widget-zfish-mixed-rgb-cockatoo-frame-50.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:cb99cd81a18fa2f8986c5f00071c45dc778c8aa177f4b02dca6bc5fab122b054
-size 114825
+oid sha256:987eb20fcfdfb95a60e3c5cf8094bbcf4d899e6b1416ce17fc9f924e71330da4
+size 112164
diff --git a/examples/notebooks/screenshots/nb-image-widget-zfish-mixed-rgb-cockatoo-set-data.png b/examples/notebooks/screenshots/nb-image-widget-zfish-mixed-rgb-cockatoo-set-data.png
index 82f3d0a9b..67500d292 100644
--- a/examples/notebooks/screenshots/nb-image-widget-zfish-mixed-rgb-cockatoo-set-data.png
+++ b/examples/notebooks/screenshots/nb-image-widget-zfish-mixed-rgb-cockatoo-set-data.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:31b2b92b9d983950b58b90a09f16199740e35a0737fc1b18904f507ea322d8f2
-size 111118
+oid sha256:8eeb0548062ce9498b38b4f876d3d81f7de4f02b1b4fd5a47744007852d546bf
+size 107522
diff --git a/examples/notebooks/screenshots/nb-image-widget-zfish-mixed-rgb-cockatoo-windowrgb.png b/examples/notebooks/screenshots/nb-image-widget-zfish-mixed-rgb-cockatoo-windowrgb.png
index 1446c8941..1740e97de 100644
--- a/examples/notebooks/screenshots/nb-image-widget-zfish-mixed-rgb-cockatoo-windowrgb.png
+++ b/examples/notebooks/screenshots/nb-image-widget-zfish-mixed-rgb-cockatoo-windowrgb.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:0fb724e005c6e081ae3bf235e155f3f526c3480facac7479d9b9452aae81baf0
-size 111437
+oid sha256:33e57f3dba3a865f433ddc4c613f302f7b5b34053b5eb05d0b9c6d5003ef6194
+size 109368
diff --git a/examples/notebooks/screenshots/nb-lines-3d.png b/examples/notebooks/screenshots/nb-lines-3d.png
index fb84ef21a..e54c6fede 100644
--- a/examples/notebooks/screenshots/nb-lines-3d.png
+++ b/examples/notebooks/screenshots/nb-lines-3d.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:c70c01b3ade199864df227a44fb28a53626df3beecee722a7b782c9a9f4658d8
-size 19907
+oid sha256:e09b9a15d83d3c0ca45a136642f5c9da0fcf2b5fee494a522fbbe9fa6b9db9a5
+size 18097
diff --git a/examples/notebooks/screenshots/nb-lines-colors.png b/examples/notebooks/screenshots/nb-lines-colors.png
index ab221d83f..ca21cdd76 100644
--- a/examples/notebooks/screenshots/nb-lines-colors.png
+++ b/examples/notebooks/screenshots/nb-lines-colors.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:3b238b085eddb664ff56bd265423d85b35fc70769ebec050b27fefa8fe6380de
-size 35055
+oid sha256:a40192f07f4bce34832aae2c11b28806547e15fccf543e90c09f457954ef422f
+size 31081
diff --git a/examples/notebooks/screenshots/nb-lines-data.png b/examples/notebooks/screenshots/nb-lines-data.png
index 44b142f55..022d660c8 100644
--- a/examples/notebooks/screenshots/nb-lines-data.png
+++ b/examples/notebooks/screenshots/nb-lines-data.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:4df736ec3ea90478930a77437949977f8e30f7d9272f65ef9f4908f2103dd11e
-size 40679
+oid sha256:c790c3b23101a04bbab300b55d6bfdd1421fb0a0e81c5bff5f75a271e32ccab9
+size 37643
diff --git a/examples/notebooks/screenshots/nb-lines-underlay.png b/examples/notebooks/screenshots/nb-lines-underlay.png
index f4a5b4e76..f738b2293 100644
--- a/examples/notebooks/screenshots/nb-lines-underlay.png
+++ b/examples/notebooks/screenshots/nb-lines-underlay.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:3a8b59386015b4c1eaa85c33c7b041d566ac1ac76fbba829075e9a3af021bedf
-size 46228
+oid sha256:fda16460bb690ef30bfc43a3cbb4336f5bbd190ef592381269cde5f250ae7a36
+size 50977
diff --git a/examples/notebooks/screenshots/nb-lines.png b/examples/notebooks/screenshots/nb-lines.png
index 8c86b48d0..2adc08fd6 100644
--- a/examples/notebooks/screenshots/nb-lines.png
+++ b/examples/notebooks/screenshots/nb-lines.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:823558e877830b816cc87df0776a92d5316d98a4f40e475cbf997b597c5eb8de
-size 30338
+oid sha256:ecb09d9358e54a56fdd92c02d8e0f43886babfad5053086c50063bc7c8ff409c
+size 23595
diff --git a/examples/notebooks/screenshots/no-imgui-nb-astronaut.png b/examples/notebooks/screenshots/no-imgui-nb-astronaut.png
index 9f9e2013a..25a1ebb6b 100644
--- a/examples/notebooks/screenshots/no-imgui-nb-astronaut.png
+++ b/examples/notebooks/screenshots/no-imgui-nb-astronaut.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:4758a94e6c066d95569515c0bff8e4c9ec383c65c5928a827550c142214df085
-size 72372
+oid sha256:08ec733c8e28069d226ded849d9dbd2fb2fea0dac91dd4691823a2bf077cc6b0
+size 73878
diff --git a/examples/notebooks/screenshots/no-imgui-nb-astronaut_RGB.png b/examples/notebooks/screenshots/no-imgui-nb-astronaut_RGB.png
index 23d1bd906..f1cc52d2b 100644
--- a/examples/notebooks/screenshots/no-imgui-nb-astronaut_RGB.png
+++ b/examples/notebooks/screenshots/no-imgui-nb-astronaut_RGB.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:fb3c72edc6f41d6f77e44bc68e7f5277525d2548d369925827c14d855dc33bbd
-size 71588
+oid sha256:40ed1cf02ff2f8d715a2fe0dfa0589f7f7daee043ab3bd1bfa4279b8356483b4
+size 71445
diff --git a/examples/notebooks/screenshots/no-imgui-nb-camera.png b/examples/notebooks/screenshots/no-imgui-nb-camera.png
index 22c70a760..be5a96a55 100644
--- a/examples/notebooks/screenshots/no-imgui-nb-camera.png
+++ b/examples/notebooks/screenshots/no-imgui-nb-camera.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:6de3880cc22a8f6cdb77305e4d5be520fe92fd54a9a107bdbddf1e6f72c19262
-size 52157
+oid sha256:6a7b1e20fa0f6c70aaf6458c20b2fec6ffff399493ad3d15c7fa706f08adb0c0
+size 54983
diff --git a/examples/notebooks/screenshots/no-imgui-nb-lines-3d.png b/examples/notebooks/screenshots/no-imgui-nb-lines-3d.png
index 1a5a7b548..f405fb9b0 100644
--- a/examples/notebooks/screenshots/no-imgui-nb-lines-3d.png
+++ b/examples/notebooks/screenshots/no-imgui-nb-lines-3d.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:f0e63c918aac713af2015cb85289c9451be181400834b0f60bcbb50564551f08
-size 20546
+oid sha256:d3ebb5155b5451ff86ac15a141303964c90fee662d4c8291dec80410ebfc6dd8
+size 17898
diff --git a/examples/notebooks/screenshots/no-imgui-nb-lines-colors.png b/examples/notebooks/screenshots/no-imgui-nb-lines-colors.png
index cdce4bf46..435e1fe96 100644
--- a/examples/notebooks/screenshots/no-imgui-nb-lines-colors.png
+++ b/examples/notebooks/screenshots/no-imgui-nb-lines-colors.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:2bd481f558907ac1af97bd7ee08d58951bada758cc32467c73483fa66e4602f8
-size 36206
+oid sha256:31d330b3d1c48156223c3b744a2997ca33db8a86dffae2717e3f2dc231182732
+size 31672
diff --git a/examples/notebooks/screenshots/no-imgui-nb-lines-data.png b/examples/notebooks/screenshots/no-imgui-nb-lines-data.png
index 8923be766..a02431728 100644
--- a/examples/notebooks/screenshots/no-imgui-nb-lines-data.png
+++ b/examples/notebooks/screenshots/no-imgui-nb-lines-data.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:ea39e2651408431ad5e49af378828a41b7b377f7f0098adc8ce2c7b5e10d0234
-size 43681
+oid sha256:33aa8cded99e514a582d6fd92a310504ec186760029b398d23eace4a9eb9f0a8
+size 39445
diff --git a/examples/notebooks/screenshots/no-imgui-nb-lines-underlay.png b/examples/notebooks/screenshots/no-imgui-nb-lines-underlay.png
index b6b4cf340..426003b01 100644
--- a/examples/notebooks/screenshots/no-imgui-nb-lines-underlay.png
+++ b/examples/notebooks/screenshots/no-imgui-nb-lines-underlay.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:6a8d4aba2411598ecae1b7f202fbb1a1fa7416a814b7b4c5fdd1e0e584cdb06a
-size 49343
+oid sha256:2a4af7eb6420b8c25cc6ed173df66f8fd90fafbfa7e17c08e2d8ff028c2a160b
+size 53702
diff --git a/examples/notebooks/screenshots/no-imgui-nb-lines.png b/examples/notebooks/screenshots/no-imgui-nb-lines.png
index 5d03421a4..51578e9dd 100644
--- a/examples/notebooks/screenshots/no-imgui-nb-lines.png
+++ b/examples/notebooks/screenshots/no-imgui-nb-lines.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:b2fdaf79703c475521184ab9dc948d3e817160b0162e9d88fcb20207225d0233
-size 31153
+oid sha256:94c64dfe5bef35e68e1800f5d65e2453ea35378ac44de73958bcc6083d57ea0d
+size 23544
diff --git a/examples/notebooks/test_gc.ipynb b/examples/notebooks/test_gc.ipynb
index df08e7a2d..92000f27e 100644
--- a/examples/notebooks/test_gc.ipynb
+++ b/examples/notebooks/test_gc.ipynb
@@ -120,6 +120,11 @@
"\n",
"for g in objects:\n",
" for feature in g._features:\n",
+ " if not hasattr(g, f\"_{feature}\"):\n",
+ " continue\n",
+ "\n",
+ " if getattr(g, f\"_{feature}\") is None:\n",
+ " continue # not in the right mode to support this feature\n",
" g.add_event_handler(feature_changed_handler, feature)"
]
},
diff --git a/examples/scatter/scatter_cmap_iris.py b/examples/scatter/scatter_cmap_iris.py
index 139554dae..24be0c13c 100644
--- a/examples/scatter/scatter_cmap_iris.py
+++ b/examples/scatter/scatter_cmap_iris.py
@@ -20,7 +20,7 @@
agg = AgglomerativeClustering(n_clusters=3)
agg.fit_predict(data)
-scatter_graphic = figure[0, 0].add_scatter(
+scatter = figure[0, 0].add_scatter(
data=data[:, :-1], # use only xy data
sizes=15,
alpha=0.7,
@@ -30,7 +30,7 @@
figure.show()
-scatter_graphic.cmap = "tab10"
+scatter.cmap = "tab10"
if __name__ == "__main__":
diff --git a/examples/scatter/scatter_colorslice.py b/examples/scatter/scatter_colorslice.py
index a3cacee55..5b18d2e0e 100644
--- a/examples/scatter/scatter_colorslice.py
+++ b/examples/scatter/scatter_colorslice.py
@@ -36,15 +36,17 @@
colors = ["yellow"] * n_points + ["cyan"] * n_points + ["magenta"] * n_points
# use an alpha value since this will be a lot of points
-figure[0, 0].add_scatter(data=cloud, sizes=3, colors=colors, alpha=0.6)
+figure[0, 0].add_scatter(
+ data=cloud, sizes=3, colors=colors, alpha_mode="weighted_blend", alpha=0.6
+)
figure.show()
-scatter_graphic = figure[0, 0].graphics[0]
+scatter = figure[0, 0].graphics[0]
-scatter_graphic.colors[0:75] = "red"
-scatter_graphic.colors[75:150] = "white"
-scatter_graphic.colors[::2] = "blue"
+scatter.colors[0:75] = "red"
+scatter.colors[75:150] = "white"
+scatter.colors[::2] = "blue"
# NOTE: fpl.loop.run() should not be used for interactive sessions
# See the "JupyterLab and IPython" section in the user guide
diff --git a/examples/scatter/scatter_colorslice_iris.py b/examples/scatter/scatter_colorslice_iris.py
index 725374ef7..d9dc3053f 100644
--- a/examples/scatter/scatter_colorslice_iris.py
+++ b/examples/scatter/scatter_colorslice_iris.py
@@ -19,18 +19,19 @@
n_points = 50
colors = ["yellow"] * n_points + ["cyan"] * n_points + ["magenta"] * n_points
-scatter_graphic = figure[0, 0].add_scatter(
+scatter = figure[0, 0].add_scatter(
data=data[:, :-1],
sizes=6,
alpha=0.7,
- colors=colors # use colors from the list of strings
+ alpha_mode="weighted_blend", # blend overlapping dots
+ colors=colors, # use colors from the list of strings
)
figure.show()
-scatter_graphic.colors[0:75] = "red"
-scatter_graphic.colors[75:150] = "white"
-scatter_graphic.colors[::2] = "blue"
+scatter.colors[0:75] = "red"
+scatter.colors[75:150] = "white"
+scatter.colors[::2] = "blue"
if __name__ == "__main__":
diff --git a/examples/scatter/scatter_dataslice_iris.py b/examples/scatter/scatter_dataslice_iris.py
index cc688eeb4..81df632ae 100644
--- a/examples/scatter/scatter_dataslice_iris.py
+++ b/examples/scatter/scatter_dataslice_iris.py
@@ -20,16 +20,16 @@
n_points = 50
colors = ["yellow"] * n_points + ["cyan"] * n_points + ["magenta"] * n_points
-scatter_graphic = figure[0, 0].add_scatter(data=data[:, :-1], sizes=6, alpha=0.7, colors=colors)
+scatter = figure[0, 0].add_scatter(data=data[:, :-1], sizes=6, alpha=0.7, colors=colors)
figure.show()
-scatter_graphic.data[0] = np.array([[5, 3, 1.5]])
-scatter_graphic.data[1] = np.array([[4.3, 3.2, 1.3]])
-scatter_graphic.data[2] = np.array([[5.2, 2.7, 1.7]])
+scatter.data[0] = np.array([[5, 3, 1.5]])
+scatter.data[1] = np.array([[4.3, 3.2, 1.3]])
+scatter.data[2] = np.array([[5.2, 2.7, 1.7]])
-scatter_graphic.data[10:15] = scatter_graphic.data[0:5] + np.array([1, 1, 1])
-scatter_graphic.data[50:100:2] = scatter_graphic.data[100:150:2] + np.array([1, 1, 0])
+scatter.data[10:15] = scatter.data[0:5] + np.array([1, 1, 1])
+scatter.data[50:100:2] = scatter.data[100:150:2] + np.array([1, 1, 0])
if __name__ == "__main__":
diff --git a/examples/scatter/scatter_image_as_points.py b/examples/scatter/scatter_image_as_points.py
new file mode 100644
index 000000000..aeae30bd0
--- /dev/null
+++ b/examples/scatter/scatter_image_as_points.py
@@ -0,0 +1,55 @@
+"""
+Scatter image as points
+=======================
+
+Display a scatter using an image as the points. These are also called sprites.
+"""
+
+# test_example = true
+# sphinx_gallery_pygfx_docs = 'screenshot'
+
+import numpy as np
+import fastplotlib as fpl
+import imageio.v3 as iio
+
+
+xs = np.linspace(0, 2 * np.pi, 10)
+
+# make sine and cosine data
+sine = np.column_stack([xs, np.sin(xs)])
+cosine = np.column_stack([xs, np.cos(xs)])
+
+# a simple image to display as the points
+array = np.array([
+ [1, 0, 1],
+ [0, 1, 0],
+ [1, 1, 1],
+])
+
+# load an image of Almar's cat
+wikkie = np.flipud(iio.imread("imageio:wikkie.png"))
+
+figure = fpl.Figure(size=(700, 350))
+
+scatter = figure[0, 0].add_scatter(
+ data=sine,
+ mode="image", # mode must be "image", otherwise the `image` arg is ignored and markers are used
+ image=array,
+ cmap="jet", # the image is multiplied by the scatter point colors if provided
+ sizes=25,
+)
+
+scatter2 = figure[0, 0].add_scatter(
+ data=cosine,
+ mode="image",
+ image=wikkie, # if an RGB(A) image is provided and no colors are provided, then the image is shown as-is
+ sizes=40,
+)
+
+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/scatter/scatter_iris.py b/examples/scatter/scatter_iris.py
index 94c8acca1..b9df16026 100644
--- a/examples/scatter/scatter_iris.py
+++ b/examples/scatter/scatter_iris.py
@@ -6,24 +6,36 @@
"""
# test_example = true
-# sphinx_gallery_pygfx_docs = 'hidden'
+# sphinx_gallery_pygfx_docs = 'screenshot'
import fastplotlib as fpl
import numpy as np
-from pathlib import Path
-import sys
+from sklearn.cluster import AgglomerativeClustering
+from sklearn import datasets
+
figure = fpl.Figure(size=(700, 560))
-current_file = Path(sys.argv[0]).resolve()
+data, target = datasets.load_iris(return_X_y=True)
+data = data[:, :2] # use only first 2 features
+
+# map target class to scatter point marker
+markers_map = {0: "o", 1: "s", 2: "+"}
+markers = list(map(markers_map.get, target))
-data_path = Path(__file__).parent.parent.joinpath("data", "iris.npy")
-data = np.load(data_path)
+agg = AgglomerativeClustering(n_clusters=3)
+agg.fit_predict(data)
-n_points = 50
-colors = ["yellow"] * n_points + ["cyan"] * n_points + ["magenta"] * n_points
+clusters_labels = agg.labels_
-scatter_graphic = figure[0, 0].add_scatter(data=data[:, :-1], sizes=6, alpha=0.7, colors=colors)
+scatter = figure[0, 0].add_scatter(
+ data=data,
+ sizes=10,
+ alpha=0.7,
+ cmap="tab10",
+ cmap_transform=clusters_labels,
+ markers=markers,
+)
figure.show()
diff --git a/examples/scatter/scatter_validate.py b/examples/scatter/scatter_validate.py
new file mode 100644
index 000000000..abddffee0
--- /dev/null
+++ b/examples/scatter/scatter_validate.py
@@ -0,0 +1,77 @@
+"""
+Scatter validation
+==================
+
+Example that shows some scatter plot features for test validation.
+"""
+
+# test_example = true
+# sphinx_gallery_pygfx_docs = 'screenshot'
+
+import numpy as np
+import fastplotlib as fpl
+import imageio.v3 as iio
+
+xs = np.linspace(0, 2 * np.pi, 10)
+
+# make sine and cosine data
+sine = np.column_stack([xs, np.sin(xs)])
+cosine = np.column_stack([xs, np.cos(xs)])
+
+# a simple image to display as the points
+array = np.array([
+ [1, 0, 1],
+ [0, 1, 0],
+ [1, 1, 1],
+])
+
+# load an image of Almar's cat
+wikkie = np.flipud(iio.imread("imageio:wikkie.png"))
+
+figure = fpl.Figure(
+ size=(700, 560)
+)
+
+figure[0, 0].add_scatter(sine)
+
+# combinations of per-point markers, colors and edge colors
+figure[0, 0].add_scatter(
+ sine,
+ colors=["magenta"] * 3 + ["cyan"] * 3 + ["yellow"] * 3 + ["purple"],
+ uniform_edge_color=False,
+ edge_colors=["w"] * 3 + ["orange"] * 3 + ["blue"] * 3 + ["green"],
+ markers=list("osD+x^v<>*"),
+ edge_width=2.0,
+ sizes=20,
+ uniform_size=True,
+)
+
+
+# per-point rotations
+figure[0, 0].add_scatter(
+ sine,
+ markers="^",
+ sizes=20,
+ point_rotation_mode="vertex",
+ point_rotations=xs,
+ uniform_size=True,
+ offset=(0, 1, 0)
+)
+
+
+# point sizes
+figure[0, 0].add_scatter(
+ sine,
+ markers="s",
+ sizes=xs * 5,
+ offset=(0, 2, 0)
+)
+
+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/scatter/spinning_spiral.py b/examples/scatter/spinning_spiral.py
index 80e893301..89e74eaec 100644
--- a/examples/scatter/spinning_spiral.py
+++ b/examples/scatter/spinning_spiral.py
@@ -34,7 +34,7 @@
canvas_kwargs={"max_fps": 500, "vsync": False}
)
-spiral = figure[0, 0].add_scatter(data, cmap="viridis_r", alpha=0.5, sizes=sizes)
+spiral = figure[0, 0].add_scatter(data, cmap="viridis_r", edge_colors=None, alpha=0.5, sizes=sizes)
# pre-generate normally distributed data to jitter the points before each render
jitter = np.random.normal(scale=0.001, size=n * 3).reshape((n, 3))
diff --git a/examples/screenshots/extent_frac_layout.png b/examples/screenshots/extent_frac_layout.png
index 7fe6d3d37..1ee20fa64 100644
--- a/examples/screenshots/extent_frac_layout.png
+++ b/examples/screenshots/extent_frac_layout.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:5991b755432318310cfc2b4826bd9639cc234883aa06f1895817f710714cb58f
-size 156297
+oid sha256:23cd21882c3599d7b911c70df62d38aac9bc2d180dbbf05dd40b9561c6c2329a
+size 144438
diff --git a/examples/screenshots/extent_layout.png b/examples/screenshots/extent_layout.png
index dec391ac2..27eb67784 100644
--- a/examples/screenshots/extent_layout.png
+++ b/examples/screenshots/extent_layout.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:0cf23f845932023789e0823a105910e9f701d0f03c04e3c18488f0da62420921
-size 123409
+oid sha256:e5316e61ae1fb48acc439f38f77306d17834e8dd295c98a58980a803354651dd
+size 117570
diff --git a/examples/screenshots/gridplot.png b/examples/screenshots/gridplot.png
index 08e6d6b78..6853e1274 100644
--- a/examples/screenshots/gridplot.png
+++ b/examples/screenshots/gridplot.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:6f424ec68dbc0761566cd147f3bf5b8f15e4126c3b30b2ff47b6fb48f04d512a
-size 252269
+oid sha256:51c44285622a9abd974a7d5abcc5506778252fbcdf68d874a73aa58e820b9655
+size 260627
diff --git a/examples/screenshots/gridplot_non_square.png b/examples/screenshots/gridplot_non_square.png
index 781de8749..4138827bd 100644
--- a/examples/screenshots/gridplot_non_square.png
+++ b/examples/screenshots/gridplot_non_square.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:9ac9ee6fd1118a06a1f0de4eee73e7b6bee188c533da872c5cbaf7119114414f
-size 194385
+oid sha256:49b726b8a48445936ed6ecd104a7221d11549efe09a75c9deb44679a4fe0b195
+size 198943
diff --git a/examples/screenshots/gridplot_viewports_check.png b/examples/screenshots/gridplot_viewports_check.png
index b1faf9b69..77793538a 100644
--- a/examples/screenshots/gridplot_viewports_check.png
+++ b/examples/screenshots/gridplot_viewports_check.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:67dd50d61a0caaf563d95110f99fa24c567ddd778a697715247d697a1b5bb1ac
-size 46667
+oid sha256:d1b31b17a2ea1d13560752a9881b0698e5edb46d9271d91cce47d2becf267888
+size 46471
diff --git a/examples/screenshots/heatmap.png b/examples/screenshots/heatmap.png
index defcca301..be0f95af9 100644
--- a/examples/screenshots/heatmap.png
+++ b/examples/screenshots/heatmap.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:0789d249cb4cfad21c9f1629721ade26ed734e05b1b13c3a5871793f6271362b
-size 91831
+oid sha256:4ff60beab679b2e922cee1ce2525a65e14f599bf54344a4b3457c57d7afd054f
+size 89365
diff --git a/examples/screenshots/image_cmap.png b/examples/screenshots/image_cmap.png
index 0301d2ed4..6576061f8 100644
--- a/examples/screenshots/image_cmap.png
+++ b/examples/screenshots/image_cmap.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:d2bbb79716fecce08479fbe7977565daccadf4688c8a99e155db297ecce4c484
-size 199979
+oid sha256:c7c252a2af881092557ec77f00832a55a2a7fc5b7bd6bb940050bb7054daff14
+size 209290
diff --git a/examples/screenshots/image_rgb.png b/examples/screenshots/image_rgb.png
index 11129ceaa..79e3f2690 100644
--- a/examples/screenshots/image_rgb.png
+++ b/examples/screenshots/image_rgb.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:23024936931651cdf4761f2cafcd8002bb12ab86e9efb13ddc99a9bf659c3935
-size 226879
+oid sha256:c6842168344b5eda6d33f5a0bfc7524bdada6faee333597f11684ab35ae9bba3
+size 234360
diff --git a/examples/screenshots/image_rgbvminvmax.png b/examples/screenshots/image_rgbvminvmax.png
index afe4de6f7..b5e5395c3 100644
--- a/examples/screenshots/image_rgbvminvmax.png
+++ b/examples/screenshots/image_rgbvminvmax.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:2fb9cd6d32813df6a9e3bf183f73cb69fdb61d290d7f2a4cc223ab34301351a1
-size 50231
+oid sha256:bf2310bab00658375cee4f0c9627c280afa59f9bbae06a1447ba5ae4188885c6
+size 43386
diff --git a/examples/screenshots/image_simple.png b/examples/screenshots/image_simple.png
index 702a1ac5c..0402fd9d7 100644
--- a/examples/screenshots/image_simple.png
+++ b/examples/screenshots/image_simple.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:b3eb6f03364226e9f1aae72f6414ad05b0239a15c2a0fbcd71d3718fee477e2c
-size 199468
+oid sha256:0fca2ebadff656b3e89e67e9288c4c2eb33b0933c9913ec615d62a167579ccdf
+size 209081
diff --git a/examples/screenshots/image_small.png b/examples/screenshots/image_small.png
index d17cb7ab2..eedd4b5ca 100644
--- a/examples/screenshots/image_small.png
+++ b/examples/screenshots/image_small.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:2dcfc7b8a964db9a950bf4d3217fb171d081251b107977f9acd612fcd5fb0be1
-size 14453
+oid sha256:2e8e8a4a4302d1e6bbba477755c11cfa57765a87c0a9c69bc186bb8a7d7ea009
+size 13079
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/image_vminvmax.png b/examples/screenshots/image_vminvmax.png
index afe4de6f7..b5e5395c3 100644
--- a/examples/screenshots/image_vminvmax.png
+++ b/examples/screenshots/image_vminvmax.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:2fb9cd6d32813df6a9e3bf183f73cb69fdb61d290d7f2a4cc223ab34301351a1
-size 50231
+oid sha256:bf2310bab00658375cee4f0c9627c280afa59f9bbae06a1447ba5ae4188885c6
+size 43386
diff --git a/examples/screenshots/image_volume_mip.png b/examples/screenshots/image_volume_mip.png
new file mode 100644
index 000000000..200685634
--- /dev/null
+++ b/examples/screenshots/image_volume_mip.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:13a2911b6f0d105d777400305f01edf60243a6d4efd4eea5d23ccb6b1a8eb79d
+size 161588
diff --git a/examples/screenshots/image_volume_non_orthogonal_slicing.png b/examples/screenshots/image_volume_non_orthogonal_slicing.png
new file mode 100644
index 000000000..7f59f073e
--- /dev/null
+++ b/examples/screenshots/image_volume_non_orthogonal_slicing.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:e4d0260a8366e7387a164537acae61159ac7c96a80fc0dea2c529fe63fa583eb
+size 51823
diff --git a/examples/screenshots/image_volume_render_modes.png b/examples/screenshots/image_volume_render_modes.png
new file mode 100644
index 000000000..f478d0e55
--- /dev/null
+++ b/examples/screenshots/image_volume_render_modes.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:022f475b961a962a03f4e83bd2544c5ef92a81d69df72745f66a8c4c8df4a174
+size 58892
diff --git a/examples/screenshots/image_volume_share_buffer.png b/examples/screenshots/image_volume_share_buffer.png
new file mode 100644
index 000000000..c4fbda272
--- /dev/null
+++ b/examples/screenshots/image_volume_share_buffer.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:f7f5a7244fb20ee64246ec07e5785650f4a600480ac5578d6726af84886d2056
+size 42816
diff --git a/examples/screenshots/image_widget.png b/examples/screenshots/image_widget.png
index 23d34ae50..89638797b 100644
--- a/examples/screenshots/image_widget.png
+++ b/examples/screenshots/image_widget.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:220ebb5286b48426f9457b62d6e7f9fe61b5a62b8874c7e010e07e146ae205a5
-size 184633
+oid sha256:4164e333375b56b1e9ea8264df820709f4cea5844460bc544e2b6dda9c26bd9a
+size 188062
diff --git a/examples/screenshots/image_widget_grid.png b/examples/screenshots/image_widget_grid.png
index a6ccd144a..db24f0148 100644
--- a/examples/screenshots/image_widget_grid.png
+++ b/examples/screenshots/image_widget_grid.png
@@ -1,4 +1,3 @@
version https://git-lfs.github.com/spec/v1
-
-oid sha256:430cd0ee5c05221c42073345480acbeee672c299311f239dc0790a9495d0d758
-size 248046
+oid sha256:fe4a0dd3f7140a652ff82b49aa2090b8b1fd0641dc45be0a2f1a5f788ce113f2
+size 242559
diff --git a/examples/screenshots/image_widget_imgui.png b/examples/screenshots/image_widget_imgui.png
index cb165cc86..908226978 100644
--- a/examples/screenshots/image_widget_imgui.png
+++ b/examples/screenshots/image_widget_imgui.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:7522a35768d013a257e3cf3b00cce626b023b169484e035f46c635efc553b0bf
-size 165747
+oid sha256:683262b7317cc3facd9b5906d3d79c177e2cec8a85dc6143c8cfceda5a6970d3
+size 173641
diff --git a/examples/screenshots/image_widget_single_video.png b/examples/screenshots/image_widget_single_video.png
index aa757a950..00f537b89 100644
--- a/examples/screenshots/image_widget_single_video.png
+++ b/examples/screenshots/image_widget_single_video.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:5f0843f4693460ae985c1f33d84936fbcc943d0405e0893186cbee7a5765dbc0
-size 90283
+oid sha256:205d3a66cc30663459f282bdde263ff1fe6358abcdaa602412e85d746b86ca78
+size 93048
diff --git a/examples/screenshots/image_widget_videos.png b/examples/screenshots/image_widget_videos.png
index 2e289ae3c..ef858f85f 100644
--- a/examples/screenshots/image_widget_videos.png
+++ b/examples/screenshots/image_widget_videos.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:eec22392f85db1fd375d7ffa995a2719cf86821fe3fe85913f4ab66084eccbf9
-size 290587
+oid sha256:833063e4af85870420108f51f8c2e4accfb645e2c16eefb2aab6c8d3c951938a
+size 310096
diff --git a/examples/screenshots/image_widget_viewports_check.png b/examples/screenshots/image_widget_viewports_check.png
index 662432e59..31e30edf3 100644
--- a/examples/screenshots/image_widget_viewports_check.png
+++ b/examples/screenshots/image_widget_viewports_check.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:1c4449f7e97375aa9d7fe1d00364945fc86b568303022157621de21a20d1d13e
-size 93914
+oid sha256:93980b00802158ea9eeb74828adb7ae63a03b8339335e2771a39117470788b19
+size 82255
diff --git a/examples/screenshots/imgui_basic.png b/examples/screenshots/imgui_basic.png
index 1ff9952a9..32e1b52c2 100644
--- a/examples/screenshots/imgui_basic.png
+++ b/examples/screenshots/imgui_basic.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:09cc7b0680e53ae1a2689b63f9b0ed641535fcffc99443cd455cc8d9b6923229
-size 36218
+oid sha256:5194566726b85eb2e5cfbe04785f86698f0bfe1f0bd4cc39bbca1102f5da655b
+size 35790
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/line.png b/examples/screenshots/line.png
index 02603b692..1f57f04ea 100644
--- a/examples/screenshots/line.png
+++ b/examples/screenshots/line.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:9bfaa54bde0967463413ecd2defa8ca18169d534163cc8b297879900e812fee8
-size 167012
+oid sha256:10a0c3dadb2b161f51c7dc6efada4c812dddaf17888ead4ddf31f6fd9846c9f4
+size 152396
diff --git a/examples/screenshots/line_cmap.png b/examples/screenshots/line_cmap.png
index 1ecc930e4..02527b7cd 100644
--- a/examples/screenshots/line_cmap.png
+++ b/examples/screenshots/line_cmap.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:d0503c008f8869dcf83793c21b15169a93558988c1a5c4edfd2aa93c549d25e1
-size 49343
+oid sha256:d103a8a054254a093500c16e8feb5f7786b2d304fd0316b7bd9c98a3a7c34949
+size 37964
diff --git a/examples/screenshots/line_cmap_more.png b/examples/screenshots/line_cmap_more.png
index 4bf597e8b..8962cbb6b 100644
--- a/examples/screenshots/line_cmap_more.png
+++ b/examples/screenshots/line_cmap_more.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:ab4d759dd679a2959c0fda724e7b7a1b7593d6f67ce797f08a5292dd0eb74fb1
-size 125023
+oid sha256:546dc7884850f620c789b103ab3fceb2d154cd78fafea427d6f380b1ec6c1160
+size 90061
diff --git a/examples/screenshots/line_collection.png b/examples/screenshots/line_collection.png
index 382132770..908c87729 100644
--- a/examples/screenshots/line_collection.png
+++ b/examples/screenshots/line_collection.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:b3b6b973a52f7088536a4f437be2a7f6ebb2787756f9170145a945c53e90093c
-size 98950
+oid sha256:232820431835de3a7795724827e8a179e5e47e32d7fabbef702b57497f16a917
+size 69488
diff --git a/examples/screenshots/line_collection_cmap_values.png b/examples/screenshots/line_collection_cmap_values.png
index c00bffdb6..4bbdaa0fe 100644
--- a/examples/screenshots/line_collection_cmap_values.png
+++ b/examples/screenshots/line_collection_cmap_values.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:45bb6652f477ab0165bf59e504c1935e5781bceea9a891fcfa9975dec92eef4b
-size 64720
+oid sha256:a3e27e891b1a3232780f159aa75592886f34492b4df1256b465379c3e4593552
+size 41492
diff --git a/examples/screenshots/line_collection_cmap_values_qualitative.png b/examples/screenshots/line_collection_cmap_values_qualitative.png
index 662d3254d..e746c2884 100644
--- a/examples/screenshots/line_collection_cmap_values_qualitative.png
+++ b/examples/screenshots/line_collection_cmap_values_qualitative.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:4e5b5cb45e78ae24d72f3cb84e482fac7bf0a98cd9b9b934444d2e67c9910d57
-size 66565
+oid sha256:964d2bccf0bd64de80c0617ddbf432ae3006ba27823a9b2107d42a2d4d6173b5
+size 44534
diff --git a/examples/screenshots/line_collection_colors.png b/examples/screenshots/line_collection_colors.png
index 3b90e5b4c..39c3ad9e0 100644
--- a/examples/screenshots/line_collection_colors.png
+++ b/examples/screenshots/line_collection_colors.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:4edf84af27535e4a30b48906ab3cacaeb38d073290828df3c5707620e222b4d3
-size 58635
+oid sha256:d3475c7485cc00633208be388b148564aea1d1883786e5402ea03bb576a8483f
+size 33098
diff --git a/examples/screenshots/line_collection_slicing.png b/examples/screenshots/line_collection_slicing.png
index e0537a261..b5f4db0bf 100644
--- a/examples/screenshots/line_collection_slicing.png
+++ b/examples/screenshots/line_collection_slicing.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:66933c1fa349ebb4dd69b9bf396acb8f0aeeabbf17a3b7054d1f1e038a6e04be
-size 129484
+oid sha256:651dd9c28fb6f4548384571bb011a4e2dc8591d6da1cc0f3fb0d5defe0fcd498
+size 75078
diff --git a/examples/screenshots/line_colorslice.png b/examples/screenshots/line_colorslice.png
index f3374e221..ea11d63aa 100644
--- a/examples/screenshots/line_colorslice.png
+++ b/examples/screenshots/line_colorslice.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:d654aa666ac1f4cfbf228fc4c5fbd2f68eed841c7cc6265637d5b836b918314c
-size 57989
+oid sha256:9dd1fab17becdae1b118f9a19e32a4f5b2ca9945da0db00394faef26fa9a6c46
+size 43271
diff --git a/examples/screenshots/line_dataslice.png b/examples/screenshots/line_dataslice.png
index 6ecf63b26..b1db2cf6f 100644
--- a/examples/screenshots/line_dataslice.png
+++ b/examples/screenshots/line_dataslice.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:a9b93af2028eb0186dd75d74c079d5effdb284a8677e6eec1a7fd2c8de4c8498
-size 70489
+oid sha256:ed1d8c7d20dc80433d7bf167eb769e96d49e47ceaf380b7e3602e7c8331e2b99
+size 44842
diff --git a/examples/screenshots/line_stack.png b/examples/screenshots/line_stack.png
index 9a9ad4fd6..f3ab2474b 100644
--- a/examples/screenshots/line_stack.png
+++ b/examples/screenshots/line_stack.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:4b6c2d1ee4c49ff5b193b5105b2794c6b5bd7a089a8a2c6fa03e09e02352aa65
-size 121462
+oid sha256:79825955b81f94f70625ee0845911bd13bd2726667a6b2db2896df1852aab944
+size 55457
diff --git a/examples/screenshots/linear_region_selectors_match_offsets.png b/examples/screenshots/linear_region_selectors_match_offsets.png
index e6fab4c4d..8bffafba1 100644
--- a/examples/screenshots/linear_region_selectors_match_offsets.png
+++ b/examples/screenshots/linear_region_selectors_match_offsets.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:f2eac8ffeb8cd35a0c65d51b0952defea61928abb53c865e681fa72af4ac4347
-size 95750
+oid sha256:6243ceed25fbb7932c3b5f8a6400c6049bdb074a7dddfab5ed620e75c1e39680
+size 62841
diff --git a/examples/screenshots/linear_selector.png b/examples/screenshots/linear_selector.png
index 8571d664b..122960918 100644
--- a/examples/screenshots/linear_selector.png
+++ b/examples/screenshots/linear_selector.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:62ded18658bc5cb41129d27eb21f47f029cf7c75bb6388b5d72af6fe9c5cada9
-size 130919
+oid sha256:fe3b3ea8beb0d4a50f24a748842e4b3eebc1ebbb7824caf3625c55f38a553c80
+size 106794
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-extent_frac_layout.png b/examples/screenshots/no-imgui-extent_frac_layout.png
index 4dc3b2aa6..e19263a19 100644
--- a/examples/screenshots/no-imgui-extent_frac_layout.png
+++ b/examples/screenshots/no-imgui-extent_frac_layout.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:5923e8b9f687f97d488b282b35f16234898ed1038b0737b7b57fb9cbd72ebf34
-size 157321
+oid sha256:c2994279094daf35ec203df4b7951e496aaf12767d570f81a792df1172a412a8
+size 144324
diff --git a/examples/screenshots/no-imgui-extent_layout.png b/examples/screenshots/no-imgui-extent_layout.png
index 16d1ff446..d400171a2 100644
--- a/examples/screenshots/no-imgui-extent_layout.png
+++ b/examples/screenshots/no-imgui-extent_layout.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:c2ffe0a8d625322cc22d2abdde80a3f179f01552dde974bbbd49f9e371ab39aa
-size 138936
+oid sha256:5ffa8ba36146fefb70ffb7c0555f0068c159bbca93fe3736bb73437c25ca8dd4
+size 128833
diff --git a/examples/screenshots/no-imgui-gridplot.png b/examples/screenshots/no-imgui-gridplot.png
index 7f870cf76..752afa712 100644
--- a/examples/screenshots/no-imgui-gridplot.png
+++ b/examples/screenshots/no-imgui-gridplot.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:b31f2002053b5934ae78393214e67717d10bd567e590212eaff4062440657acd
-size 292558
+oid sha256:ac8a61bb685f201dfcabe48c8241dbb41c5f824213dd8399117cad8e2f178906
+size 298976
diff --git a/examples/screenshots/no-imgui-gridplot_non_square.png b/examples/screenshots/no-imgui-gridplot_non_square.png
index e08d64805..f6b2b8adb 100644
--- a/examples/screenshots/no-imgui-gridplot_non_square.png
+++ b/examples/screenshots/no-imgui-gridplot_non_square.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:c9ef00db82a3559b4d7c77b68838f5876f98a2b9e80ef9ecb257f32c62161b5e
-size 216512
+oid sha256:af867dcb5ac5bcd677c31780c07b950493d404f432be7ad7853290ae827cec01
+size 219680
diff --git a/examples/screenshots/no-imgui-gridplot_viewports_check.png b/examples/screenshots/no-imgui-gridplot_viewports_check.png
index 2a8c0dc6f..61f0c5148 100644
--- a/examples/screenshots/no-imgui-gridplot_viewports_check.png
+++ b/examples/screenshots/no-imgui-gridplot_viewports_check.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:6818a7c8bdb29567bb09cfe00acaa6872a046d4d35a87ef2be7afa06c2a8a089
-size 44869
+oid sha256:8e5f030bb76739370c581715277e3da47d38ade0a041eec9b5e0d9ff9e311115
+size 41800
diff --git a/examples/screenshots/no-imgui-heatmap.png b/examples/screenshots/no-imgui-heatmap.png
index e91d06c4f..105370f32 100644
--- a/examples/screenshots/no-imgui-heatmap.png
+++ b/examples/screenshots/no-imgui-heatmap.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:875c15e74e7ea2eaa6b00ddbdd80b4775ecb1fe0002a5122371d49f975369cce
-size 95553
+oid sha256:86ba296fc5282f664a9057eb29f99c20601c025fd937540c95f60465510ece96
+size 92928
diff --git a/examples/screenshots/no-imgui-image_cmap.png b/examples/screenshots/no-imgui-image_cmap.png
index 2d42899fc..e1c95ee70 100644
--- a/examples/screenshots/no-imgui-image_cmap.png
+++ b/examples/screenshots/no-imgui-image_cmap.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:2b43bd64ceec8c5c1287a2df57abf7bd148955d6ba97a425b32ae53bad03a051
-size 216050
+oid sha256:e1851126cacf5482954f5ce3933cbcbc3253f2ff1cc6c103de69eb42cec9061e
+size 224864
diff --git a/examples/screenshots/no-imgui-image_rgb.png b/examples/screenshots/no-imgui-image_rgb.png
index 6be5205ac..4d5efaccc 100644
--- a/examples/screenshots/no-imgui-image_rgb.png
+++ b/examples/screenshots/no-imgui-image_rgb.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:42516cd0719d5b33ec32523dd2efe7874398bac6d0aecb5163ff1cb5c105135f
-size 244717
+oid sha256:61092536672c350a27f6b41a6ea9da1d5154dead93a244b2c7fc8550870c69a8
+size 250491
diff --git a/examples/screenshots/no-imgui-image_rgbvminvmax.png b/examples/screenshots/no-imgui-image_rgbvminvmax.png
index 48d8fff95..ca8659db1 100644
--- a/examples/screenshots/no-imgui-image_rgbvminvmax.png
+++ b/examples/screenshots/no-imgui-image_rgbvminvmax.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:7f8a99a9172ae5edf98f0d189455fad2074a99f2280c9352675bab8d4c0e3491
-size 50751
+oid sha256:1d5ff797c68119cbe7ea5c6ec03aa95c38b80ffce3d48c15c1f77fad2a858d42
+size 43103
diff --git a/examples/screenshots/no-imgui-image_simple.png b/examples/screenshots/no-imgui-image_simple.png
index 1e4487757..e008ba01f 100644
--- a/examples/screenshots/no-imgui-image_simple.png
+++ b/examples/screenshots/no-imgui-image_simple.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:3cfa6469803f44a682c9ce7337ae265a8d60749070991e6f3a723eb37c5a9a23
-size 215410
+oid sha256:aae7bfe952402c9a0d4e9ace56736eea1e9ea7900ab90289bc62605ba33aca11
+size 223774
diff --git a/examples/screenshots/no-imgui-image_small.png b/examples/screenshots/no-imgui-image_small.png
index 3613a8139..31e1421b2 100644
--- a/examples/screenshots/no-imgui-image_small.png
+++ b/examples/screenshots/no-imgui-image_small.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:17ccf0014c7ba7054440e3daf8d4e2a397e9013d1aea804c40dc7302dad4171e
-size 13327
+oid sha256:d844ad71bf2afe250fc5d681fc78aedd4b9879593d07aff5aa5092e03141da16
+size 11558
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-image_vminvmax.png b/examples/screenshots/no-imgui-image_vminvmax.png
index 48d8fff95..ca8659db1 100644
--- a/examples/screenshots/no-imgui-image_vminvmax.png
+++ b/examples/screenshots/no-imgui-image_vminvmax.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:7f8a99a9172ae5edf98f0d189455fad2074a99f2280c9352675bab8d4c0e3491
-size 50751
+oid sha256:1d5ff797c68119cbe7ea5c6ec03aa95c38b80ffce3d48c15c1f77fad2a858d42
+size 43103
diff --git a/examples/screenshots/no-imgui-image_volume_mip.png b/examples/screenshots/no-imgui-image_volume_mip.png
new file mode 100644
index 000000000..f35dd7c33
--- /dev/null
+++ b/examples/screenshots/no-imgui-image_volume_mip.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:39c5da8bb512f2200f53a183030ee5071ee4bdd0462a2ab5a79979816b3c8086
+size 171440
diff --git a/examples/screenshots/no-imgui-image_volume_non_orthogonal_slicing.png b/examples/screenshots/no-imgui-image_volume_non_orthogonal_slicing.png
new file mode 100644
index 000000000..be8aaab55
--- /dev/null
+++ b/examples/screenshots/no-imgui-image_volume_non_orthogonal_slicing.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:af508ae7f7252e96891a055e130eb2e2ce52417b7c3b25ac402ef86d6dad4c4a
+size 59065
diff --git a/examples/screenshots/no-imgui-line.png b/examples/screenshots/no-imgui-line.png
index cdc24e382..3ba59c9d7 100644
--- a/examples/screenshots/no-imgui-line.png
+++ b/examples/screenshots/no-imgui-line.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:d3952cf9b0c9d008a885dc4abb3aeaaed6fd94a5db05ba83c6f4c4c76fe6e925
-size 171519
+oid sha256:8fe7456b142edf45e8f51f82ea7a80af2152a9d22b485d77804e56bc1768412f
+size 154361
diff --git a/examples/screenshots/no-imgui-line_cmap.png b/examples/screenshots/no-imgui-line_cmap.png
index 4f2bbba43..ede3a56f3 100644
--- a/examples/screenshots/no-imgui-line_cmap.png
+++ b/examples/screenshots/no-imgui-line_cmap.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:d3c9ac8d2b8157ffd575e5ad2b2bb23b684b52403c2f4f021c52d100cfb28a83
-size 49048
+oid sha256:82312d1eebf0f31ba3d9d409f15e8e5dafb5fe4ccce64e7cb941f0780861f73d
+size 36599
diff --git a/examples/screenshots/no-imgui-line_cmap_more.png b/examples/screenshots/no-imgui-line_cmap_more.png
index 8125be49f..c977cc430 100644
--- a/examples/screenshots/no-imgui-line_cmap_more.png
+++ b/examples/screenshots/no-imgui-line_cmap_more.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:5ddd88200aa824d4e05ba3f94fdb4216a1e7c7137b202cd8fb47997453dfd5a6
-size 126830
+oid sha256:0772acc1c85b3c23dfa7bd17c2b9208d7e2705b17c22e7b8c0000b4f0273f9f0
+size 90814
diff --git a/examples/screenshots/no-imgui-line_collection.png b/examples/screenshots/no-imgui-line_collection.png
index a31cf55fe..1ce43345f 100644
--- a/examples/screenshots/no-imgui-line_collection.png
+++ b/examples/screenshots/no-imgui-line_collection.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:7d807f770c118e668c6bda1919856d7804f716a2bf95a5ae060345df1cd2b3c7
-size 102703
+oid sha256:4a31775cb50544545d90db48f77d2c3eba761e1dad16aed60ebf7ef2e4064625
+size 73001
diff --git a/examples/screenshots/no-imgui-line_collection_cmap_values.png b/examples/screenshots/no-imgui-line_collection_cmap_values.png
index c909c766f..f7a10c187 100644
--- a/examples/screenshots/no-imgui-line_collection_cmap_values.png
+++ b/examples/screenshots/no-imgui-line_collection_cmap_values.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:2e8612de5c3ee252ce9c8cc8afd5bd6075d5e242e8a93cd025e28ec82526120f
-size 64698
+oid sha256:1072328cb684bd785fea7726c68633205e91f5d7b1545acc61e9affe9c318e76
+size 37900
diff --git a/examples/screenshots/no-imgui-line_collection_cmap_values_qualitative.png b/examples/screenshots/no-imgui-line_collection_cmap_values_qualitative.png
index 61d5a21d0..6f359a994 100644
--- a/examples/screenshots/no-imgui-line_collection_cmap_values_qualitative.png
+++ b/examples/screenshots/no-imgui-line_collection_cmap_values_qualitative.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:7847cd4399ce5b43bda9985eb72467ad292744aaeb9e8d210dd6c86c4eb1a090
-size 67959
+oid sha256:f595b2e350cc646629bddd3df257d6fed37c60e588366b1e2c5e0ecfa84015a8
+size 40677
diff --git a/examples/screenshots/no-imgui-line_collection_colors.png b/examples/screenshots/no-imgui-line_collection_colors.png
index 567bb4d06..b150d6225 100644
--- a/examples/screenshots/no-imgui-line_collection_colors.png
+++ b/examples/screenshots/no-imgui-line_collection_colors.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:15216a0900bcaef492e5d9e3380db9f28d7b7e4bd11b26eb87ce956666dcd2b1
-size 58414
+oid sha256:48351bdfefead7fd809564508bf5196edc8805b750a09b913f1b71651ca11cea
+size 28560
diff --git a/examples/screenshots/no-imgui-line_collection_slicing.png b/examples/screenshots/no-imgui-line_collection_slicing.png
index c9bc6d931..7be9a32fd 100644
--- a/examples/screenshots/no-imgui-line_collection_slicing.png
+++ b/examples/screenshots/no-imgui-line_collection_slicing.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:e8d3d7813580be188766c2d0200bcbff28122758d36d0faa846b0bb4dceac654
-size 130453
+oid sha256:7c7e6d0a301ab18640ae98867610549af33ef2b9e89b76d306a5e0d276906e99
+size 73997
diff --git a/examples/screenshots/no-imgui-line_colorslice.png b/examples/screenshots/no-imgui-line_colorslice.png
index fe54de5d6..4e6168bd4 100644
--- a/examples/screenshots/no-imgui-line_colorslice.png
+++ b/examples/screenshots/no-imgui-line_colorslice.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:be429bf910979cf4c9483b8ae1f7aa877fde64fb6ec8a4cf32be143f282c9103
-size 57353
+oid sha256:424040bc42e702b0adc4da95c854edbfbb8c15b598fb9254d4daa9590e9ea0ad
+size 41087
diff --git a/examples/screenshots/no-imgui-line_dataslice.png b/examples/screenshots/no-imgui-line_dataslice.png
index 649a9df59..e755fc270 100644
--- a/examples/screenshots/no-imgui-line_dataslice.png
+++ b/examples/screenshots/no-imgui-line_dataslice.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:cf873f1479cec065f0062ce58ce78ddfbd5673654aacf0ecdbd559747ae741cb
-size 69381
+oid sha256:cf7cca31763750345014ee4314e915e2cd76245ae9d489a05a9983235a932df2
+size 43566
diff --git a/examples/screenshots/no-imgui-line_stack.png b/examples/screenshots/no-imgui-line_stack.png
index 3ef24e73a..aab512b83 100644
--- a/examples/screenshots/no-imgui-line_stack.png
+++ b/examples/screenshots/no-imgui-line_stack.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:4b9d02719e7051c2a0e848cc828f21be52ac108c6f9be16795d1150a1e215371
-size 123674
+oid sha256:54ec3441c4f71632e04510428cac038a1c88e4c529880804f31feb50eb6c5de6
+size 55293
diff --git a/examples/screenshots/no-imgui-linear_region_selectors_match_offsets.png b/examples/screenshots/no-imgui-linear_region_selectors_match_offsets.png
index d82efa849..cec12c158 100644
--- a/examples/screenshots/no-imgui-linear_region_selectors_match_offsets.png
+++ b/examples/screenshots/no-imgui-linear_region_selectors_match_offsets.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:1b22ee4506bc532344cfcbd5daa0c4e90d9a831d59f1d916bd28534786947771
-size 97036
+oid sha256:bbdc5f9047465d256ee1358bf9f1ecc679f1f7484b50f369442d64bf7f18da3a
+size 60305
diff --git a/examples/screenshots/no-imgui-linear_selector.png b/examples/screenshots/no-imgui-linear_selector.png
index 4416cb4d5..1580027a8 100644
--- a/examples/screenshots/no-imgui-linear_selector.png
+++ b/examples/screenshots/no-imgui-linear_selector.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:1f1a323dec6d50d1c701632aadbd17d87ee3b3b42171046ca9b1284f93576a3b
-size 131922
+oid sha256:6490694a4c7f266e62e953b3430a30511f3761077a961ce2547ab6d758ad9180
+size 105556
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-rect_frac_layout.png b/examples/screenshots/no-imgui-rect_frac_layout.png
index 4dc3b2aa6..e19263a19 100644
--- a/examples/screenshots/no-imgui-rect_frac_layout.png
+++ b/examples/screenshots/no-imgui-rect_frac_layout.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:5923e8b9f687f97d488b282b35f16234898ed1038b0737b7b57fb9cbd72ebf34
-size 157321
+oid sha256:c2994279094daf35ec203df4b7951e496aaf12767d570f81a792df1172a412a8
+size 144324
diff --git a/examples/screenshots/no-imgui-rect_layout.png b/examples/screenshots/no-imgui-rect_layout.png
index 16d1ff446..d400171a2 100644
--- a/examples/screenshots/no-imgui-rect_layout.png
+++ b/examples/screenshots/no-imgui-rect_layout.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:c2ffe0a8d625322cc22d2abdde80a3f179f01552dde974bbbd49f9e371ab39aa
-size 138936
+oid sha256:5ffa8ba36146fefb70ffb7c0555f0068c159bbca93fe3736bb73437c25ca8dd4
+size 128833
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-scatter_cmap_iris.png b/examples/screenshots/no-imgui-scatter_cmap_iris.png
index 0d1f8dbb0..60ef9f37c 100644
--- a/examples/screenshots/no-imgui-scatter_cmap_iris.png
+++ b/examples/screenshots/no-imgui-scatter_cmap_iris.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:7e197c84911cf7711d09653d6c54d7a756fbe4fe80daa84f0cf1a1d516217423
-size 60341
+oid sha256:4dccc0e78ec14b320491155fe4d3bed0b0acebc6bf25ca1d569ebffd84e74cf1
+size 42745
diff --git a/examples/screenshots/no-imgui-scatter_colorslice_iris.png b/examples/screenshots/no-imgui-scatter_colorslice_iris.png
index 84447c70f..baa7189b6 100644
--- a/examples/screenshots/no-imgui-scatter_colorslice_iris.png
+++ b/examples/screenshots/no-imgui-scatter_colorslice_iris.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:780b680de7d3a22d2cb73a6829cad1e1066163e084b8daa9e8362f2543ba62eb
-size 36881
+oid sha256:c337c47f63837df1ee22801321e092341d00e241e3624916d4a5bd7a99280419
+size 24092
diff --git a/examples/screenshots/no-imgui-scatter_dataslice_iris.png b/examples/screenshots/no-imgui-scatter_dataslice_iris.png
index a19d66270..4b8f048fe 100644
--- a/examples/screenshots/no-imgui-scatter_dataslice_iris.png
+++ b/examples/screenshots/no-imgui-scatter_dataslice_iris.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:6b4f6635f48e047944c923ac46a9bd5b77e736f26421978ff74cd37a9677c622
-size 39457
+oid sha256:a40aa8b1b9a57aaf41bbf0d79f7772d34851558a34a47cb1c30847035d0302a4
+size 24451
diff --git a/examples/screenshots/no-imgui-scatter_image_as_points.png b/examples/screenshots/no-imgui-scatter_image_as_points.png
new file mode 100644
index 000000000..4ed688e7d
--- /dev/null
+++ b/examples/screenshots/no-imgui-scatter_image_as_points.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:6d8d71ee1382ce87b25afeeb2759d1e8474699f5c86a93cffacb5c7b22e787a0
+size 58639
diff --git a/examples/screenshots/no-imgui-scatter_iris.png b/examples/screenshots/no-imgui-scatter_iris.png
index 631672504..5cec5446d 100644
--- a/examples/screenshots/no-imgui-scatter_iris.png
+++ b/examples/screenshots/no-imgui-scatter_iris.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:80cc8c1ed5276b0b8cbd5aeb3151182a73984829f889195b57442a58c3124a43
-size 38488
+oid sha256:d473448543c094e30fb4fbf602c6a5a84995ac671b1fc05a34bd1a4ee9cb1734
+size 30225
diff --git a/examples/screenshots/no-imgui-scatter_size.png b/examples/screenshots/no-imgui-scatter_size.png
index 241e38ad5..cf5b140d7 100644
--- a/examples/screenshots/no-imgui-scatter_size.png
+++ b/examples/screenshots/no-imgui-scatter_size.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:71f3db93ea28e773c708093319985fb0fe04fae9a8a78d4f4f764f0417979b72
-size 68596
+oid sha256:bf652dda41ab68d04fd98bb1a1569a2d704333181d0018957403c238ab8e8e4d
+size 43341
diff --git a/examples/screenshots/no-imgui-scatter_validate.png b/examples/screenshots/no-imgui-scatter_validate.png
new file mode 100644
index 000000000..59919b51e
--- /dev/null
+++ b/examples/screenshots/no-imgui-scatter_validate.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:6a2646bb946948ba5ce9af0e0f0adaca745c39a2770ca27af7fcd0830350fd1b
+size 19108
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/rect_frac_layout.png b/examples/screenshots/rect_frac_layout.png
index 7fe6d3d37..1ee20fa64 100644
--- a/examples/screenshots/rect_frac_layout.png
+++ b/examples/screenshots/rect_frac_layout.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:5991b755432318310cfc2b4826bd9639cc234883aa06f1895817f710714cb58f
-size 156297
+oid sha256:23cd21882c3599d7b911c70df62d38aac9bc2d180dbbf05dd40b9561c6c2329a
+size 144438
diff --git a/examples/screenshots/rect_layout.png b/examples/screenshots/rect_layout.png
index dec391ac2..27eb67784 100644
--- a/examples/screenshots/rect_layout.png
+++ b/examples/screenshots/rect_layout.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:0cf23f845932023789e0823a105910e9f701d0f03c04e3c18488f0da62420921
-size 123409
+oid sha256:e5316e61ae1fb48acc439f38f77306d17834e8dd295c98a58980a803354651dd
+size 117570
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/scatter_cmap_iris.png b/examples/screenshots/scatter_cmap_iris.png
index c069d6b11..74fae9f55 100644
--- a/examples/screenshots/scatter_cmap_iris.png
+++ b/examples/screenshots/scatter_cmap_iris.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:fad40cf8004e31f7d30f4bb552ee1c7f79a499d3bad310c0eac83396f0aabd62
-size 61193
+oid sha256:641ff08c68450320aaa31f53035d69938e4e0347273e576fc2847e3eb050e1f5
+size 44445
diff --git a/examples/screenshots/scatter_colorslice_iris.png b/examples/screenshots/scatter_colorslice_iris.png
index 58c2b61fe..821784a26 100644
--- a/examples/screenshots/scatter_colorslice_iris.png
+++ b/examples/screenshots/scatter_colorslice_iris.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:427587ef9a73bf9c3ea6e739b61d5af7380a5488c454a9d3653019b40d569292
-size 37589
+oid sha256:6df93416cee2dc13b5435ef56a0dcfe02e3b8d087ca28077693c0cf270881bf5
+size 25438
diff --git a/examples/screenshots/scatter_dataslice_iris.png b/examples/screenshots/scatter_dataslice_iris.png
index ab61f0405..55f376161 100644
--- a/examples/screenshots/scatter_dataslice_iris.png
+++ b/examples/screenshots/scatter_dataslice_iris.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:e3dd9ad854f41386d353ca0dae689a263eff942817727e328690427e2e62e2f3
-size 40112
+oid sha256:7c49cb755a020fedc9de46256357321a2bc4170a57ba96030cdbd4f0abffec6d
+size 25765
diff --git a/examples/screenshots/scatter_image_as_points.png b/examples/screenshots/scatter_image_as_points.png
new file mode 100644
index 000000000..d2a3e1821
--- /dev/null
+++ b/examples/screenshots/scatter_image_as_points.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:69d84b6e7da872fc6cce3838fb1eb2ad87fe5d846962ef264d8390904ca53abb
+size 60287
diff --git a/examples/screenshots/scatter_iris.png b/examples/screenshots/scatter_iris.png
index 01bd5cacd..8c6f8d402 100644
--- a/examples/screenshots/scatter_iris.png
+++ b/examples/screenshots/scatter_iris.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:c7978b93f7eac8176c54ed0e39178424d9cb6474c73e9013d5164d3e88d54c95
-size 39147
+oid sha256:5d78bbf60c03baf857413ca0fa0c189a5c9cdf90a7c0a1bc443b6ea7fa4d8f6b
+size 31881
diff --git a/examples/screenshots/scatter_size.png b/examples/screenshots/scatter_size.png
index 2f6c045f3..9da829f65 100644
--- a/examples/screenshots/scatter_size.png
+++ b/examples/screenshots/scatter_size.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:eb05b8378d94e16094738850dca6328caf7477c641bf474b9deae426344bc7a4
-size 70898
+oid sha256:8351d9c26f034e04d5ce7b205337e48b6e827a0ee9ecd52bf69935af7d79f9af
+size 47048
diff --git a/examples/screenshots/scatter_validate.png b/examples/screenshots/scatter_validate.png
new file mode 100644
index 000000000..1ce5e0f1d
--- /dev/null
+++ b/examples/screenshots/scatter_validate.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:680542f32e591dbf0426e5f550e26ab7a031aae52a6303528ccde7fae524124e
+size 20288
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/selection_tools/linear_region_selector.py b/examples/selection_tools/linear_region_selector.py
index 5c6d6e01b..7a9114beb 100644
--- a/examples/selection_tools/linear_region_selector.py
+++ b/examples/selection_tools/linear_region_selector.py
@@ -37,22 +37,22 @@
ys = np.sin(xs) + np.random.normal(scale=0.2, size=10000)
# make sine along x axis
-sine_graphic_x = figure[0, 0].add_line(np.column_stack([xs, ys]), thickness=1)
+sine_x = figure[0, 0].add_line(np.column_stack([xs, ys]), thickness=1)
# x = sine(y), sine(y) > 0 = 0
-sine_y = ys
-sine_y[sine_y > 0] = 0
+sine_y_data = ys
+sine_y_data[sine_y_data > 0] = 0
# sine along y axis
-sine_graphic_y = figure[0, 1].add_line(np.column_stack([ys, xs]))
+sine_y = figure[0, 1].add_line(np.column_stack([ys, xs]))
# offset the position of the graphic to demonstrate `get_selected_data()` later
-sine_graphic_y.position_x = 50
-sine_graphic_y.position_y = 50
+sine_y.position_x = 50
+sine_y.position_y = 50
# add linear selectors
-selector_x = sine_graphic_x.add_linear_region_selector((0, 100)) # default axis is "x"
-selector_y = sine_graphic_y.add_linear_region_selector(axis="y")
+selector_x = sine_x.add_linear_region_selector((0, 100)) # default axis is "x"
+selector_y = sine_y.add_linear_region_selector(axis="y")
# preallocate array for storing zoomed in data
zoomed_init = np.column_stack([np.arange(zoomed_prealloc), np.zeros(zoomed_prealloc)])
diff --git a/examples/selection_tools/linear_region_selectors_match_offsets.py b/examples/selection_tools/linear_region_selectors_match_offsets.py
index 7ac9cc486..042207152 100644
--- a/examples/selection_tools/linear_region_selectors_match_offsets.py
+++ b/examples/selection_tools/linear_region_selectors_match_offsets.py
@@ -32,22 +32,22 @@
ys = np.sin(xs) # y = sine(x)
# make sine along x axis
-sine_graphic_x = figure[0, 0].add_line(np.column_stack([xs, ys]), offset=(10, 10, 0))
+sine_x = figure[0, 0].add_line(np.column_stack([xs, ys]), offset=(10, 10, 0))
# x = sine(y), sine(y) > 0 = 0
-sine_y = ys
-sine_y[sine_y > 0] = 0
+sine_y_data = ys
+sine_y_data[sine_y_data > 0] = 0
# sine along y axis
-sine_graphic_y = figure[0, 1].add_line(np.column_stack([ys, xs]), offset=(10, 10, 0))
+sine_y = figure[0, 1].add_line(np.column_stack([ys, xs]), offset=(10, 10, 0))
# offset the position of the graphic to demonstrate `get_selected_data()` later
-sine_graphic_y.position_x = 50
-sine_graphic_y.position_y = 50
+sine_y.position_x = 50
+sine_y.position_y = 50
# add linear selectors
-selector_x = sine_graphic_x.add_linear_region_selector() # default axis is "x"
-selector_y = sine_graphic_y.add_linear_region_selector(axis="y")
+selector_x = sine_x.add_linear_region_selector() # default axis is "x"
+selector_y = sine_y.add_linear_region_selector(axis="y")
# preallocate array for storing zoomed in data
zoomed_init = np.column_stack([np.arange(zoomed_prealloc), np.zeros(zoomed_prealloc)])
diff --git a/examples/selection_tools/linear_selector.py b/examples/selection_tools/linear_selector.py
index 65fd8f1b1..8b442db20 100644
--- a/examples/selection_tools/linear_selector.py
+++ b/examples/selection_tools/linear_selector.py
@@ -2,7 +2,8 @@
Linear Selectors
================
-Example showing how to use a `LinearSelector` with lines and line collections.
+Example showing how to use a `LinearSelector` with lines and line collections. The linear selector is the yellow
+vertical line.
"""
# test_example = true
diff --git a/examples/selection_tools/linear_selector_image.py b/examples/selection_tools/linear_selector_image.py
index 04844b568..657d5ae5e 100644
--- a/examples/selection_tools/linear_selector_image.py
+++ b/examples/selection_tools/linear_selector_image.py
@@ -2,8 +2,9 @@
Linear Selectors Image
======================
-Example showing how to use a `LinearSelector` to selector rows or columns of an image. The subplot on the right
-displays the data for the selector row and column.
+Example showing how to use a `LinearSelector` to select rows or columns of an image. The subplot on the right
+displays the data for the selector row and column. Move the selectors independently or click the middle mouse
+button to move both selectors to the clicked location.
"""
# test_example = false
@@ -24,10 +25,10 @@
image = figure[0, 0].add_image(image_data)
# add a row selector
-image_row_selector = image.add_linear_selector(axis="y")
+image_row_selector = image.add_linear_selector(axis="y", edge_color="cyan")
# add column selector
-image_col_selector = image.add_linear_selector()
+image_col_selector = image.add_linear_selector(edge_color="cyan")
# make a line to indicate row data
line_image_row = figure[0, 1].add_line(image.data[0])
diff --git a/examples/selection_tools/polygon_selector.py b/examples/selection_tools/polygon_selector.py
new file mode 100644
index 000000000..b43b34811
--- /dev/null
+++ b/examples/selection_tools/polygon_selector.py
@@ -0,0 +1,65 @@
+"""
+Polygon Selectors
+=================
+
+Example showing how to use a `PolygonSelector` (a.k.a. lasso selector) with line collections
+"""
+
+# test_example = false
+# sphinx_gallery_pygfx_docs = 'screenshot'
+
+import numpy as np
+import fastplotlib as fpl
+from itertools import product
+
+# create a figure
+figure = fpl.Figure(size=(700, 560))
+
+
+# generate some data
+def make_circle(center, radius: float, n_points: int = 75) -> np.ndarray:
+ theta = np.linspace(0, 2 * np.pi, n_points)
+ xs = radius * np.sin(theta)
+ ys = radius * np.cos(theta)
+
+ return np.column_stack([xs, ys]) + center
+
+
+spatial_dims = (50, 50)
+
+circles = list()
+for center in product(range(0, spatial_dims[0], 9), range(0, spatial_dims[1], 9)):
+ circles.append(make_circle(center, 3, n_points=75))
+
+pos_xy = np.vstack(circles)
+
+# add image
+line_collection = figure[0, 0].add_line_collection(circles, cmap="jet", thickness=5)
+
+# add polygon selector to image graphic
+polygon_selector = line_collection.add_polygon_selector(
+ fill_color="#ff00ff", edge_color="#FFF", vertex_color="#FFF"
+)
+
+
+# add event handler to highlight selected indices
+@polygon_selector.add_event_handler("selection")
+def color_indices(ev):
+ line_collection.cmap = "jet"
+ ixs = ev.get_selected_indices()
+ # iterate through each of the selected indices, if the array size > 0 that mean it's under the selection
+ selected_line_ixs = [i for i in range(len(ixs)) if ixs[i].size > 0]
+ line_collection[selected_line_ixs].colors = "w"
+
+
+# # manually move selector to make a nice gallery image :D
+# polygon_selector.selection = (15, 30, 15, 30)
+
+
+figure.show()
+
+# NOTE: `if __name__ == "__main__"` is NOT how to use fastplotlib interactively
+# please see our docs for using fastplotlib interactively in ipython and jupyter
+if __name__ == "__main__":
+ print(__doc__)
+ fpl.loop.run()
diff --git a/examples/selection_tools/rectangle_selector_zoom.py b/examples/selection_tools/rectangle_selector_zoom.py
index 61e38ffc9..f8ebd975e 100644
--- a/examples/selection_tools/rectangle_selector_zoom.py
+++ b/examples/selection_tools/rectangle_selector_zoom.py
@@ -18,10 +18,10 @@
)
# add image
-image_graphic = figure[0, 0].add_image(data=iio.imread("imageio:camera.png"))
+image = figure[0, 0].add_image(data=iio.imread("imageio:camera.png"))
# add rectangle selector to image graphic
-rectangle_selector = image_graphic.add_rectangle_selector()
+rectangle_selector = image.add_rectangle_selector()
# add a zoomed plot of the selected data
zoom_ig = figure[1, 0].add_image(rectangle_selector.get_selected_data())
diff --git a/examples/selection_tools/unit_circle.py b/examples/selection_tools/unit_circle.py
index b068d1bc7..b2ba772e4 100644
--- a/examples/selection_tools/unit_circle.py
+++ b/examples/selection_tools/unit_circle.py
@@ -67,15 +67,15 @@ def make_circle(center, radius: float, n_points: int) -> np.ndarray:
# create sine and cosine data
xs = np.linspace(0, 2 * np.pi, 360)
-sine = np.sin(xs)
-cosine = np.cos(xs)
+sine_data = np.sin(xs)
+cosine_data = np.cos(xs)
# circle data
circle_data = make_circle(center=(0, 0), radius=1, n_points=360)
# make the circle line graphic, set the cmap transform using the sine function
-circle_graphic = figure["unit circle"].add_line(
- circle_data, thickness=4, cmap="bwr", cmap_transform=sine
+circle = figure["unit circle"].add_line(
+ circle_data, thickness=4, cmap="bwr", cmap_transform=sine_data
)
# line to show the circle radius
@@ -86,36 +86,36 @@ def make_circle(center, radius: float, n_points: int) -> np.ndarray:
)
# sine line graphic, cmap transform set from the sine function
-sine_graphic = figure["sin(x)"].add_line(
- sine, thickness=10, cmap="bwr", cmap_transform=sine
+sine = figure["sin(x)"].add_line(
+ sine_data, thickness=10, cmap="bwr", cmap_transform=sine_data
)
# cosine line graphic, cmap transform set from the sine function
# illustrates the sine function values on the cosine graphic
-cosine_graphic = figure["cos(x)"].add_line(
- cosine, thickness=10, cmap="bwr", cmap_transform=sine
+cosine = figure["cos(x)"].add_line(
+ cosine_data, thickness=10, cmap="bwr", cmap_transform=sine_data
)
# add linear selectors to the sine and cosine line graphics
-sine_selector = sine_graphic.add_linear_selector()
-cosine_selector = cosine_graphic.add_linear_selector()
+sine_selector = sine.add_linear_selector()
+cosine_selector = cosine.add_linear_selector()
def set_circle_cmap(ev):
# sets the cmap transforms
cmap_transform = ev.graphic.data[:, 1] # y-val data of the sine or cosine graphic
- for g in [sine_graphic, cosine_graphic]:
+ for g in [sine, cosine]:
g.cmap.transform = cmap_transform
# set circle cmap transform
- circle_graphic.cmap.transform = cmap_transform
+ circle.cmap.transform = cmap_transform
# when the sine or cosine graphic is clicked, the cmap_transform
# of the sine, cosine and circle line graphics are all set from
# the y-values of the clicked line
-sine_graphic.add_event_handler(set_circle_cmap, "click")
-cosine_graphic.add_event_handler(set_circle_cmap, "click")
+sine.add_event_handler(set_circle_cmap, "click")
+cosine.add_event_handler(set_circle_cmap, "click")
def set_x_val(ev):
@@ -132,6 +132,9 @@ def set_x_val(ev):
sine_selector.add_event_handler(set_x_val, "selection")
cosine_selector.add_event_handler(set_x_val, "selection")
+# set initial position of the selector so it's not just overlapping the y-axis
+sine_selector.selection = 100
+
figure.show()
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/test_examples.py b/examples/tests/test_examples.py
index 7fbd32e2f..caf7b5e82 100644
--- a/examples/tests/test_examples.py
+++ b/examples/tests/test_examples.py
@@ -47,7 +47,7 @@ def check_skip_imgui(module):
@pytest.mark.parametrize("module", examples_to_run, ids=lambda x: x.stem)
-def test_examples_run(module, force_offscreen):
+def test_examples_run(module, prep_environment):
"""Run every example marked to see if they run without error."""
if not fpl.IMGUI:
check_skip_imgui(module)
@@ -56,13 +56,17 @@ def test_examples_run(module, force_offscreen):
@pytest.fixture
-def force_offscreen():
+def prep_environment():
"""Force the offscreen canvas to be selected by the auto gui module."""
+ # Make that examples using rendercanvas.auto, will use the offscreen backend
os.environ["RENDERCANVAS_FORCE_OFFSCREEN"] = "true"
+ # Disable ppaa on the renderer by default. Otherwise all screenshots change when the ppaa shaders are updated.
+ os.environ["PYGFX_DEFAULT_PPAA"] = "none"
try:
yield
finally:
del os.environ["RENDERCANVAS_FORCE_OFFSCREEN"]
+ del os.environ["PYGFX_DEFAULT_PPAA"]
def test_that_we_are_on_lavapipe():
@@ -86,7 +90,7 @@ def import_from_path(module_name, filename):
@pytest.mark.parametrize("module", examples_to_test, ids=lambda x: x.stem)
-def test_example_screenshots(module, force_offscreen):
+def test_example_screenshots(module, prep_environment):
"""Make sure that every example marked outputs the expected."""
if not fpl.IMGUI:
@@ -96,27 +100,10 @@ def test_example_screenshots(module, force_offscreen):
# import the example module
example = import_from_path(module.stem, module)
- if fpl.IMGUI:
- # there doesn't seem to be a resize event for the manual offscreen canvas
- example.figure.imgui_renderer._backend.io.display_size = example.figure.canvas.get_logical_size()
- # run this once so any edge widgets set their sizes and therefore the subplots get the correct rect
- # hacky but it works for now
- example.figure.imgui_renderer.render()
-
- example.figure._fpl_reset_layout()
- # render each subplot
- for subplot in example.figure:
- subplot.viewport.render(subplot.scene, subplot.camera)
-
- # flush pygfx renderer
- example.figure.renderer.flush()
-
- if fpl.IMGUI:
- # render imgui
- example.figure.imgui_renderer.render()
-
- # render a frame
- img = np.asarray(example.figure.renderer.target.draw())
+ # Render a frame. Twice, because layouts don't settle in one go?
+ m1 = example.figure.renderer.target.draw()
+ m2 = example.figure.renderer.target.draw()
+ img = np.asarray(m2)
# check if _something_ was rendered
assert img is not None and img.size > 0
@@ -148,17 +135,16 @@ def test_example_screenshots(module, force_offscreen):
if os.environ["REGENERATE_SCREENSHOTS"] == "1":
iio.imwrite(screenshot_path, rgb)
- assert (
- screenshot_path.exists()
- ), "found # test_example = true but no reference screenshot available"
+ assert screenshot_path.exists(), (
+ "found # test_example = true but no reference screenshot available"
+ )
ref_img = iio.imread(screenshot_path)
rgb = normalize_image(rgb)
ref_img = normalize_image(ref_img)
- similar, rmse = image_similarity(rgb, ref_img, threshold=0.05)
-
+ similar, rmse = image_similarity(rgb, ref_img)
update_diffs(module.stem, similar, rgb, ref_img)
assert similar, (
f"diff {rmse} above threshold for {module.stem}, see "
@@ -200,5 +186,9 @@ def get_diffs_rgba(slicer):
if __name__ == "__main__":
- test_examples_run("simple")
- test_example_screenshots("simple")
+ # Need to apply these env vars *before* running this script,
+ # since FPL selects its backend on import.
+ # os.environ["RENDERCANVAS_FORCE_OFFSCREEN"] = "true"
+ # os.environ["PYGFX_DEFAULT_PPAA"] = "none"
+
+ test_example_screenshots(examples_to_test[0], None)
diff --git a/examples/tests/testutils.py b/examples/tests/testutils.py
index 4c23b3481..e279809e3 100644
--- a/examples/tests/testutils.py
+++ b/examples/tests/testutils.py
@@ -18,15 +18,19 @@
# examples live in themed sub-folders
example_globs = [
"image/*.py",
+ "image_volume/*.py",
"image_widget/*.py",
"heatmap/*.py",
"scatter/*.py",
"line/*.py",
"line_collection/*.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/examples/vectors/README.rst b/examples/vectors/README.rst
new file mode 100644
index 000000000..a35457409
--- /dev/null
+++ b/examples/vectors/README.rst
@@ -0,0 +1,2 @@
+Vector Examples
+===============
diff --git a/examples/vectors/vectors_interact_electric_charges.py b/examples/vectors/vectors_interact_electric_charges.py
new file mode 100644
index 000000000..4aa1d41b6
--- /dev/null
+++ b/examples/vectors/vectors_interact_electric_charges.py
@@ -0,0 +1,182 @@
+"""
+Static Electric Field
+=====================
+
+Interactively move the charges around by clicking and dragging the mouse to see
+the static field with the charges at their new positions. This is just computing
+static fields, no electrodynamics or magnetic field effects are taken into account.
+
+"""
+
+# test_example = false
+# sphinx_gallery_pygfx_docs = 'screenshot'
+
+import numpy as np
+import fastplotlib as fpl
+import pygfx
+
+
+# based on vacuum permittivity, 1/4πε from wikipedia: https://en.wikipedia.org/wiki/Coulomb%27s_law#Coulomb_constant
+k_e = 8.98755 * 10**9
+
+
+def coulombs_law(q: float, r: np.ndarray) -> np.ndarray[float, float]:
+ """
+ Compute force on a unit charge at a distance ``r`` from a particle of charge ``q``.
+ Broadcasts over ``r`` array.
+
+ q: charge in coulombs
+ r: 2D array of distance vectors, shape [n, 2]
+
+ Returns force vector at each distance ``r`` provided, shape [n, 2]
+ """
+ r_cap = r / np.linalg.norm(r, ord=2, axis=1)[:, None]
+ F = k_e * ((q * r_cap) / ((np.linalg.norm(r, ord=2, axis=1))**2)[:, None])
+
+ return F
+
+
+figure = fpl.Figure(size=(700, 750))
+
+# positions of 3 particles in a 2d plane
+positions = np.array([
+ [3, 3],
+ [8, 5],
+ [4, 8],
+])
+
+# charges of the 3 particles
+charges = np.array([
+ 3.5 * 10**-10,
+ 1 * 10**-10,
+ -3.5 * 10**-10,
+])
+
+# red to indicate positive charge, blue to indicate negative charge
+colors = ["r", "r", "b"]
+
+# scatter point to indicate particle positions
+particles = figure[0, 0].add_scatter(
+ data=positions,
+ colors=colors,
+ sizes=1,
+ edge_width=0.05,
+ uniform_edge_color=False,
+ alpha=0.7,
+ size_space="model",
+ metadata={"charges": charges}, # you can store anything as arbitrary metadata
+ alpha_mode="blend",
+)
+
+xs = np.linspace(0, 10, num=20)
+ys = np.linspace(0, 10, num=20)
+
+x, y = np.meshgrid(xs, ys)
+
+# display vectors at these positions in the field
+field_positions = np.column_stack([x.ravel(), y.ravel()])
+
+# allocate array to store direction of the field at every position due to the charge of the 3 particles
+# i.e., the force felt by a unit charge at a given position in the field
+field_directions = np.zeros(field_positions.shape, dtype=np.float32)
+
+vectors = figure[0, 0].add_vectors(
+ positions=field_positions,
+ directions=field_directions,
+ alpha=0.7,
+ alpha_mode="blend",
+)
+
+
+def update_field():
+ """update the static field w.r.t. the new positions of the particles"""
+
+ # get force vectors due to each charge and add them up
+ force_vectors_total = np.zeros(field_positions.shape)
+
+ for i in range(particles.data.value.shape[0]):
+ force_vectors = coulombs_law(
+ q=particles.metadata["charges"][i], # force due to one of the charges
+ r=field_positions - particles.data[:, :-1][i]
+ )
+
+ force_vectors_total = force_vectors_total + force_vectors
+
+ # zero out when the force is too large to display
+ # large vectors will otherwise take up the entire plot area
+ force_vectors_total[np.linalg.norm(force_vectors_total, axis=1, ord=2) > 3.5] = 0
+
+ # update the graphic
+ vectors.directions = force_vectors_total
+
+
+update_field()
+
+# render particles on top of field
+particles.world_object.material.render_queue = vectors.world_object.material.render_queue + 1
+
+# interactivity code, very similar to the "Drag points" example
+is_moving = False
+particle_index = None
+# interact with particles by moving them with mouse
+@particles.add_event_handler("pointer_down")
+def start_drag(ev: pygfx.PointerEvent):
+ global is_moving
+ global particle_index
+
+ if ev.button != 1: # check for left mouse button
+ return
+
+ is_moving = True
+ particle_index = ev.pick_info["vertex_index"]
+ # set edge color to indicate this particle has been selected
+ particles.edge_colors[particle_index] = "y"
+
+
+@figure.renderer.add_event_handler("pointer_move")
+def move_point(ev):
+ global is_moving
+ global particle_index
+
+ # if not moving, return
+ if not is_moving:
+ return
+
+ # pause controller so mouse events move the scatter and not the camera
+ with figure[0, 0].controller.pause():
+ # map x, y from screen space to world space
+ pos = figure[0, 0].map_screen_to_world(ev)
+
+ if pos is None:
+ # end movement
+ is_moving = False
+ particle_index = None
+ return
+
+ # change scatter data
+ particles.data[particle_index, :-1] = pos[:-1]
+ # update field
+ update_field()
+
+
+@figure.renderer.add_event_handler("pointer_up")
+def end_drag(ev: pygfx.PointerEvent):
+ global is_moving
+ global particle_index
+
+ # end movement
+ if is_moving:
+ # reset color
+ particles.edge_colors[particle_index] = "k"
+
+ is_moving = False
+ particle_index = None
+
+
+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/vectors/vectors_simple.py b/examples/vectors/vectors_simple.py
new file mode 100644
index 000000000..e26d6da71
--- /dev/null
+++ b/examples/vectors/vectors_simple.py
@@ -0,0 +1,43 @@
+"""
+Simple Vectors
+==============
+
+Simple example with vectors. Similar to matplotlib quiver.
+
+"""
+
+# test_example = true
+# sphinx_gallery_pygfx_docs = 'screenshot'
+
+import numpy as np
+import fastplotlib as fpl
+
+figure = fpl.Figure(size=(700, 700))
+
+start, stop, step = 0, 2 * np.pi, 0.2
+
+# get uniform x, y positions
+x, y = np.meshgrid(np.arange(start, stop, step), np.arange(start, stop, step))
+
+# vectors, u and v are x and y components indicating directions
+u = np.cos(x)
+v = np.sin(y)
+
+# positions of each vector as [n_points, 2] array
+positions = np.column_stack([x.ravel(), y.ravel()])
+# directions of each vector as a [n_points, 2] array
+directions = np.column_stack([u.ravel(), v.ravel()])
+
+
+vectors = figure[0, 0].add_vectors(
+ positions=positions,
+ directions=directions,
+)
+
+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/vectors/vectors_swirl.py b/examples/vectors/vectors_swirl.py
new file mode 100644
index 000000000..fcfcb86b0
--- /dev/null
+++ b/examples/vectors/vectors_swirl.py
@@ -0,0 +1,47 @@
+"""
+Swirling vectors
+================
+
+Example showing swirling vectors. Similar to matplotlib quiver.
+
+"""
+
+# test_example = true
+# sphinx_gallery_pygfx_docs = 'screenshot'
+
+import numpy as np
+import fastplotlib as fpl
+
+figure = fpl.Figure(cameras="3d", controller_types="orbit", size=(700, 700))
+
+start, stop, step = -1, 1, 0.3
+
+# Make the grid
+x, y, z = np.meshgrid(
+ np.arange(start, stop, step),
+ np.arange(start, stop, step),
+ np.arange(start, stop, step),
+)
+
+# Make the direction data for the arrows
+u = np.sin(np.pi * x) * np.cos(np.pi * y) * np.cos(np.pi * z)
+v = -np.cos(np.pi * x) * np.sin(np.pi * y) * np.cos(np.pi * z)
+w = np.sqrt(2.0 / 3.0) * np.cos(np.pi * x) * np.cos(np.pi * y) * np.sin(np.pi * z)
+
+positions = np.column_stack([x.ravel(), y.ravel(), z.ravel()])
+directions = np.column_stack([u.ravel(), v.ravel(), w.ravel()])
+
+
+vectors = figure[0, 0].add_vectors(
+ positions=positions,
+ directions=directions,
+)
+
+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/fastplotlib/_version.py b/fastplotlib/_version.py
index e6ac4d62d..6ce66cd9a 100644
--- a/fastplotlib/_version.py
+++ b/fastplotlib/_version.py
@@ -10,7 +10,7 @@
# This is the reference version number, to be bumped before each release.
# The build system detects this definition when building a distribution.
-__version__ = "0.5.1"
+__version__ = "0.6.1"
# Allow using nearly the same code in different projects
project_name = "fastplotlib"
diff --git a/fastplotlib/graphics/__init__.py b/fastplotlib/graphics/__init__.py
index b458a8c48..3d01e4a35 100644
--- a/fastplotlib/graphics/__init__.py
+++ b/fastplotlib/graphics/__init__.py
@@ -2,6 +2,9 @@
from .line import LineGraphic
from .scatter import ScatterGraphic
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
@@ -11,6 +14,11 @@
"LineGraphic",
"ScatterGraphic",
"ImageGraphic",
+ "ImageVolumeGraphic",
+ "VectorsGraphic",
+ "MeshGraphic",
+ "SurfaceGraphic",
+ "PolygonGraphic",
"TextGraphic",
"LineCollection",
"LineStack",
diff --git a/fastplotlib/graphics/_axes.py b/fastplotlib/graphics/_axes.py
index 10774fc2a..5b4c21682 100644
--- a/fastplotlib/graphics/_axes.py
+++ b/fastplotlib/graphics/_axes.py
@@ -3,6 +3,8 @@
import pygfx
from pylinalg import quat_from_vecs, vec_transform_quat
+from ..utils.enums import RenderQueue
+
GRID_PLANES = ["xy", "xz", "yz"]
@@ -159,31 +161,53 @@ def __init__(
):
self._plot_area = plot_area
- if x_kwargs is None:
- x_kwargs = dict()
-
- if y_kwargs is None:
- y_kwargs = dict()
+ x_kwargs = x_kwargs or {}
+ y_kwargs = y_kwargs or {}
+ z_kwargs = z_kwargs or {}
- if z_kwargs is None:
- z_kwargs = dict()
+ generic_kwargs = dict(
+ tick_size=8.0,
+ line_width=2.0,
+ tick_marker="tick", # 'tick' for both-sides, 'tick_left' or 'tick_right' for one-sided
+ color="#fff",
+ )
- x_kwargs = {
- "tick_side": "right",
+ x_kwargs = dict(
+ tick_side="right",
+ **generic_kwargs,
**x_kwargs,
- }
+ )
- y_kwargs = {"tick_side": "left", **y_kwargs}
+ y_kwargs = dict(
+ tick_side="left",
+ **generic_kwargs,
+ **y_kwargs,
+ )
- z_kwargs = {
- "tick_side": "left",
+ z_kwargs = dict(
+ tick_side="left",
+ **generic_kwargs,
**z_kwargs,
- }
+ )
# create ruler for each dim
- self._x = pygfx.Ruler(**x_kwargs)
- self._y = pygfx.Ruler(**y_kwargs)
- self._z = pygfx.Ruler(**z_kwargs)
+ self._x = pygfx.Ruler(
+ alpha_mode="solid", render_queue=RenderQueue.axes, **x_kwargs
+ )
+ self._y = pygfx.Ruler(
+ alpha_mode="solid", render_queue=RenderQueue.axes, **y_kwargs
+ )
+ self._z = pygfx.Ruler(
+ alpha_mode="solid", render_queue=RenderQueue.axes, **z_kwargs
+ )
+
+ # We render the lines and ticks as solid, but enable aa for text for prettier glyphs
+ for ruler in self._x, self._y, self._z:
+ ruler.line.material.depth_compare = "<="
+ ruler.points.material.depth_compare = "<="
+ ruler.text.material.depth_compare = "<="
+ ruler.text.material.alpha_mode = "auto"
+ ruler.text.material.aa = True
self._offset = offset
@@ -226,15 +250,23 @@ def __init__(
if grid_kwargs is None:
grid_kwargs = dict()
- grid_kwargs = {
- "major_step": 10,
- "minor_step": 1,
- "thickness_space": "screen",
- "major_thickness": 2,
- "minor_thickness": 0.5,
- "infinite": True,
+ # The grid is a bit weird, because it makes use of transparency to fade off in the distance.
+ # But w want it to write depth, so that objects that are drawn behind it are partually hidden.
+ # So we set alha_mode to 'auto'. We make it draw earlier than other 'auto' objects, under the
+ # assumption that most interesting stuff is in front of the grid, and artifacts behind the grid are less
+ # bad than those in front. Note that fully opaque objects blend perfectly fine with the grid. Artifacts
+ # should only emerge for objects that have semi-transparent fragments.
+ grid_kwargs = dict(
+ alpha_mode="auto",
+ render_queue=RenderQueue.auto + 50,
+ major_step=10,
+ minor_step=1,
+ thickness_space="screen",
+ major_thickness=2,
+ minor_thickness=0.5,
+ infinite=True,
**grid_kwargs,
- }
+ )
if grids:
_grids = dict()
diff --git a/fastplotlib/graphics/_base.py b/fastplotlib/graphics/_base.py
index bc3486696..5279cf306 100644
--- a/fastplotlib/graphics/_base.py
+++ b/fastplotlib/graphics/_base.py
@@ -1,11 +1,12 @@
+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
import pylinalg as la
-from wgpu.gui.base import log_exception
+from rendercanvas.base import log_exception
try:
from imgui_bundle import imgui
@@ -22,16 +23,24 @@
Name,
Offset,
Rotation,
+ Scale,
+ Alpha,
+ AlphaMode,
Visible,
)
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",
@@ -52,14 +61,12 @@
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 the type of the graphic in lower case like "image", "line_collection", etc.
- cls.type = (
- cls.__name__.lower()
- .replace("graphic", "")
- .replace("collection", "_collection")
- .replace("stack", "_stack")
- )
# set of all features
cls._features = {
@@ -67,6 +74,9 @@ def __init_subclass__(cls, **kwargs):
"name": Name,
"offset": Offset,
"rotation": Rotation,
+ "scale": Scale,
+ "alpha": Alpha,
+ "alpha_mode": AlphaMode,
"visible": Visible,
"deleted": Deleted,
}
@@ -75,8 +85,11 @@ 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,
metadata: Any = None,
):
@@ -93,6 +106,47 @@ 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.
+
+ The alpha value for the colors. If you make your a graphic transparent, consider setting ``alpha_mode``
+ to 'blend' or 'weighted_blend' so it won't write to the depth buffer.
+
+ alpha_mode: (str), default "auto",
+ The alpha-mode, e.g. 'auto', 'blend', 'weighted_blend', 'solid', or 'dither'.
+
+ Modes for method “opaque” (overwrites the value in the output texture):
+
+ * “solid”: alpha is ignored.
+ * “solid_premul”: the alpha is multipled with the color (making it darker).
+
+ Modes for method “blended” (per-fragment blending, a.k.a. compositing):
+
+ * “auto”: classic alpha blending, with depth_write defaulting to True. See note below.
+ * “blend”: classic alpha blending using the over-operator. depth_write defaults to False.
+ * “add”: additive blending that adds the fragment color, multiplied by alpha.
+ * “subtract”: subtractuve blending that removes the fragment color.
+ * “multiply”: multiplicative blending that multiplies the fragment color.
+
+ Modes for method “weighted” (order independent blending):
+
+ * “weighted_blend”: weighted blended order independent transparency.
+ * “weighted_solid”: fragments are combined based on alpha, but the final alpha is always 1. Great for e.g. image stitching.
+
+ Modes for method “stochastic” (alpha represents the chance of a fragment being visible):
+
+ * “dither”: stochastic transparency with blue noise. This mode handles order-independent transparency exceptionally well, but it produces results that can look somewhat noisy.
+ * “bayer”: stochastic transparency with an 8x8 Bayer pattern.
+
+
+ For details see https://docs.pygfx.org/stable/transparency.html
+
+ visible: (bool), default True
+ Whether the graphic is visible.
+
metadata: Any, optional
metadata attached to this Graphic, this is for the user to manage
@@ -118,7 +172,10 @@ 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)
self._visible = Visible(visible)
self._block_events = False
@@ -126,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"""
@@ -146,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
@@ -155,9 +217,36 @@ 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"""
+ return self._alpha.value
+
+ @alpha.setter
+ def alpha(self, value: float):
+ self._alpha.set_value(self, value)
+
+ @property
+ def alpha_mode(self) -> str:
+ """How the alpha is handled by the renderer"""
+ return self._alpha_mode.value
+
+ @alpha_mode.setter
+ def alpha_mode(self, value: str):
+ self._alpha_mode.set_value(self, value)
+
@property
def visible(self) -> bool:
"""Whether the graphic is visible"""
@@ -194,16 +283,66 @@ def world_object(self) -> pygfx.WorldObject:
def _set_world_object(self, wo: pygfx.WorldObject):
WORLD_OBJECTS[self._fpl_address] = wo
- self.world_object.visible = self.visible
+ # 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
+ self._material.opacity = self.alpha
+ self._material.alpha_mode = self.alpha_mode
+
+ if wo.material is not None:
+ wo.material.opacity = self.alpha
+ wo.material.alpha_mode = self.alpha_mode
# set offset if it's not (0., 0., 0.)
- if not all(self.world_object.world.position == self.offset):
+ if not all(wo.world.position == self.offset):
self.offset = self.offset
# set rotation if it's not (0., 0., 0., 1.)
- if not all(self.world_object.world.rotation == self.rotation):
+ 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, ...]]:
"""
@@ -270,6 +409,13 @@ def decorator(_callback):
if t in self._features.keys():
# fpl feature event
feature = getattr(self, f"_{t}")
+
+ if feature is None:
+ # feature is None in the graphic's current mode, probably is a scatter graphic
+ raise AttributeError(
+ f"{self} does not have the passed feature: '{t}' in its current mode."
+ )
+
feature.add_event_handler(_callback_wrapper)
else:
# wrap pygfx event
@@ -355,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
@@ -372,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)
@@ -460,8 +676,7 @@ def right_click_menu(self):
def right_click_menu(self, menu):
if not IMGUI:
raise ImportError(
- "imgui is required to set right-click menus:\n"
- "pip install imgui_bundle"
+ "imgui is required to set right-click menus:\npip install imgui_bundle"
)
self._right_click_menu = menu
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 4a4f5a797..af7d7badb 100644
--- a/fastplotlib/graphics/_positions_base.py
+++ b/fastplotlib/graphics/_positions_base.py
@@ -1,4 +1,4 @@
-from typing import Any
+from typing import Any, Sequence
import numpy as np
@@ -9,7 +9,6 @@
VertexColors,
UniformColor,
VertexCmap,
- PointsSizesFeature,
SizeSpace,
)
@@ -36,7 +35,7 @@ def colors(self) -> VertexColors | pygfx.Color:
return self._colors.value
@colors.setter
- def colors(self, value: str | np.ndarray | tuple[float] | list[float] | list[str]):
+ def colors(self, value: str | np.ndarray | Sequence[float] | Sequence[str]):
if isinstance(self._colors, VertexColors):
self._colors[:] = value
@@ -46,7 +45,7 @@ def colors(self, value: str | np.ndarray | tuple[float] | list[float] | list[str
@property
def cmap(self) -> VertexCmap:
"""
- Control the cmap, cmap transform, or cmap alpha
+ Control the cmap or cmap transform
For supported colormaps see the ``cmap`` library catalogue: https://cmap-docs.readthedocs.io/en/stable/catalog/
"""
@@ -77,7 +76,6 @@ def __init__(
data: Any,
colors: str | np.ndarray | tuple[float] | list[float] | list[str] = "w",
uniform_color: bool = False,
- alpha: float = 1.0,
cmap: str | VertexCmap = None,
cmap_transform: np.ndarray = None,
isolated_buffer: bool = True,
@@ -112,7 +110,6 @@ def __init__(
self._colors,
cmap_name=cmap,
transform=cmap_transform,
- alpha=alpha,
)
elif isinstance(cmap, VertexCmap):
# use existing cmap instance
@@ -129,9 +126,7 @@ def __init__(
self._colors = colors
self._colors._shared += 1
# blank colormap instance
- self._cmap = VertexCmap(
- self._colors, cmap_name=None, transform=None, alpha=alpha
- )
+ self._cmap = VertexCmap(self._colors, cmap_name=None, transform=None)
else:
if uniform_color:
if not isinstance(colors, str): # not a single color
@@ -139,17 +134,23 @@ def __init__(
raise TypeError(
"must pass a single color if using `uniform_colors=True`"
)
- self._colors = UniformColor(colors, alpha=alpha)
+ self._colors = UniformColor(colors)
self._cmap = None
else:
self._colors = VertexColors(
- colors,
- n_colors=self._data.value.shape[0],
- alpha=alpha,
+ colors, n_colors=self._data.value.shape[0]
)
self._cmap = VertexCmap(
- self._colors, cmap_name=None, transform=None, alpha=alpha
+ self._colors, cmap_name=None, transform=None
)
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
new file mode 100644
index 000000000..be90db538
--- /dev/null
+++ b/fastplotlib/graphics/_vectors.py
@@ -0,0 +1,440 @@
+from typing import Sequence
+
+import pygfx
+from pygfx.geometries.utils import merge as merge_geometries
+import pylinalg as la
+import numpy as np
+
+from ._base import Graphic
+from .features import (
+ VectorPositions,
+ VectorDirections,
+)
+
+
+class VectorsGraphic(Graphic):
+ _features = {
+ "positions": VectorPositions,
+ "directions": VectorDirections,
+ }
+
+ def __init__(
+ self,
+ positions: np.ndarray | Sequence[float],
+ directions: np.ndarray | Sequence[float],
+ color: str | Sequence[float] | np.ndarray = "w",
+ size: float = None,
+ vector_shape_options: dict = None,
+ **kwargs,
+ ):
+ """
+ Create graphic that draw vectors. Similar to matplotlib quiver.
+
+ Parameters
+ ----------
+ positions: np.ndarray | Sequence[float]
+ positions of the vectors, array-like, shape must be [n, 2] or [n, 3] where n is the number of vectors.
+
+ directions: np.ndarray | Sequence[float]
+ directions of the vectors, array-like, shape must be [n, 2] or [n, 3] where n is the number of vectors.
+
+ spacing: float
+ average distance between pairs of nearest-neighbor vectors, used for scaling
+
+ color: str | pygfx.Color | Sequence[float] | np.ndarray, default "w"
+ color of the vectors
+
+ size: float or None
+ Size of a vector of magnitude 1 in world space for display purpose.
+ Estimated from density if not provided.
+
+ vector_shape_options: dict
+ dict with the following fields that directly describes the shape of the vector arrows.
+ Overrides ``size`` argument.
+
+ * cone_radius
+ * cone_height
+ * stalk_radius
+ * stalk_height
+
+ **kwargs
+ passed to :class:`.Graphic`
+
+ """
+
+ super().__init__(**kwargs)
+
+ # TODO: once it's possible to constructor instanced objects with a shared buffer I can do this
+ # if isinstance(positions, VectorPositions):
+ # self._positions = positions
+ # else:
+ # self._positions = VectorPositions(positions)
+ #
+ # if isinstance(directions, VectorDirections):
+ # self._directions = directions
+ # else:
+ # self._directions = VectorDirections(directions)
+
+ positions = np.asarray(positions)
+ directions = np.asarray(directions)
+
+ if positions.shape != directions.shape:
+ raise ValueError(
+ f"positions.shape != directions.shape: {positions.shape} != {directions.shape}\n"
+ f"They must be of the same shape"
+ )
+
+ self._positions = VectorPositions(positions)
+ self._directions = VectorDirections(directions)
+
+ if vector_shape_options is not None:
+ required = {"cone_radius", "cone_height", "stalk_radius", "stalk_height"}
+ if set(vector_shape_options.keys()) != required:
+ raise KeyError(
+ f"`vector_shape_options` must be a dict with the following keys: {required}.\n"
+ f"You have passed: {vector_shape_options}"
+ )
+ shape_options = vector_shape_options
+ else:
+ if size is None:
+ # guess from density
+ # sort xs and then take unique to get the density along x, same for y and z
+ x_density = np.diff(np.unique(np.sort(self._positions[:, 0]))).mean()
+ y_density = np.diff(np.unique(np.sort(self._positions[:, 1]))).mean()
+ densities = [x_density, y_density]
+
+ # if z is not basically zero
+ if not np.allclose(
+ np.diff(np.unique(np.sort(self._positions[:, 2]))), 0.0
+ ):
+ z_density = np.diff(np.unique(np.sort(positions[:, 2]))).mean()
+ densities.append(z_density)
+
+ mean_density = np.mean(densities)
+
+ size = mean_density
+
+ cone_height = size / 2
+ stalk_height = size / 2
+
+ cone_radius = size / 10
+ stalk_radius = cone_radius / 8
+
+ shape_options = {
+ "cone_radius": cone_radius,
+ "cone_height": cone_height,
+ "stalk_radius": stalk_radius,
+ "stalk_height": stalk_height,
+ }
+
+ geometry = create_vector_geometry(color=color, **shape_options)
+ material = pygfx.MeshBasicMaterial(pick_write=True)
+
+ n_vectors = self._positions.value.shape[0]
+
+ world_object = pygfx.InstancedMesh(geometry, material, n_vectors)
+
+ magnitudes = np.linalg.norm(self.directions[:], axis=1, ord=2)
+
+ for i in range(n_vectors):
+ # get quaternion to rotate vector to new direction
+ rotation = la.quat_from_vecs(
+ self._directions.init_direction, self._directions[i]
+ )
+ # get the new transform
+ transform = la.mat_compose(
+ self._positions.value[i], rotation, magnitudes[i]
+ )
+ # set the buffer
+ world_object.instance_buffer.data["matrix"][i] = transform.T
+
+ world_object.instance_buffer.update_full()
+
+ self._set_world_object(world_object)
+
+ @property
+ def positions(self) -> VectorPositions:
+ """Vector positions"""
+ return self._positions
+
+ @positions.setter
+ def positions(self, new_positions):
+ self._positions.set_value(self, new_positions)
+
+ @property
+ def directions(self) -> VectorDirections:
+ """Vector directions"""
+ return self._directions
+
+ @directions.setter
+ 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(
+ radius_bottom,
+ radius_top,
+ height,
+ radial_segments,
+ height_segments,
+ theta_start,
+ theta_length,
+ z_offset=0.0,
+):
+ """copied from pygfx, generates the mesh for a cylinder with the given parameters"""
+ # compute POSITIONS assuming x-y horizontal plane and z up axis
+
+ # radius for each vertex ring from bottom to top
+ n_rings = height_segments + 1
+ radii = np.linspace(radius_bottom, radius_top, num=n_rings, dtype=np.float32)
+
+ # height for each vertex ring from bottom to top
+ half_height = height / 2
+ heights = np.linspace(-half_height, half_height, num=n_rings, dtype=np.float32)
+
+ # to enable texture mapping to fully wrap around the cylinder,
+ # we can't close the geometry and need a degenerate vertex
+ n_vertices = radial_segments + 1
+
+ # xy coordinates on unit circle for a single vertex ring
+ theta = np.linspace(
+ theta_start, theta_start + theta_length, num=n_vertices, dtype=np.float32
+ )
+ ring_xy = np.column_stack([np.cos(theta), np.sin(theta)])
+
+ # put all the rings together
+ positions = np.empty((n_rings, n_vertices, 3), dtype=np.float32)
+ positions[..., :2] = ring_xy[None, ...] * radii[:, None, None]
+ positions[..., 2] = heights[:, None] - z_offset
+
+ # the NORMALS are the same for every ring, so compute for only one ring
+ # and then repeat
+ slope = (radius_bottom - radius_top) / height
+ ring_normals = np.empty(positions.shape[1:], dtype=np.float32)
+ ring_normals[..., :2] = ring_xy
+ ring_normals[..., 2] = slope
+ ring_normals /= np.linalg.norm(ring_normals, axis=-1)[:, None]
+ normals = np.empty_like(positions)
+ normals[:] = ring_normals[None, ...]
+
+ # the TEXTURE COORDS
+ # u maps 0..1 to theta_start..theta_start+theta_length
+ # v maps 0..1 to -height/2..height/2
+ ring_u = (theta - theta_start) / theta_length
+ ring_v = (heights / height) + 0.5
+ texcoords = np.empty((n_rings, n_vertices, 2), dtype=np.float32)
+ texcoords[..., 0] = ring_u[None, :]
+ texcoords[..., 1] = ring_v[:, None]
+
+ # the face INDEX
+ # the amount of vertices
+ indices = np.arange(n_rings * n_vertices, dtype=np.uint32).reshape(
+ (n_rings, n_vertices)
+ )
+ # for every panel (height_segments, radial_segments) there is a quad (2, 3)
+ index = np.empty((height_segments, radial_segments, 2, 3), dtype=np.uint32)
+ # create a grid of initial indices for the panels
+ index[:, :, 0, 0] = indices[
+ np.arange(height_segments)[:, None], np.arange(radial_segments)[None, :]
+ ]
+ # the remainder of the indices for every panel are relative
+ index[:, :, 0, 1] = index[:, :, 0, 0] + 1
+ index[:, :, 0, 2] = index[:, :, 0, 0] + n_vertices
+ index[:, :, 1, 0] = index[:, :, 0, 0] + n_vertices + 1
+ index[:, :, 1, 1] = index[:, :, 1, 0] - 1
+ index[:, :, 1, 2] = index[:, :, 1, 0] - n_vertices
+
+ return (
+ positions.reshape((-1, 3)),
+ normals.reshape((-1, 3)),
+ texcoords.reshape((-1, 2)),
+ index.flatten(),
+ )
+
+
+def generate_cap(radius, height, radial_segments, theta_start, theta_length, up=True):
+ """copied from pygfx, generates the mesh for a circular cap with the given parameters"""
+ # compute POSITIONS assuming x-y horizontal plane and z up axis
+
+ # to enable texture mapping to fully wrap around the cylinder,
+ # we can't close the geometry and need a degenerate vertex
+ n_vertices = radial_segments + 1
+
+ # xy coordinates on unit circle for vertex ring
+ theta = np.linspace(
+ theta_start, theta_start + theta_length, num=n_vertices, dtype=np.float32
+ )
+ ring_xy = np.column_stack([np.cos(theta), np.sin(theta)])
+
+ # put the vertices together, inserting a center vertex at the start
+ positions = np.empty((1 + n_vertices, 3), dtype=np.float32)
+ positions[0, :2] = [0.0, 0.0]
+ positions[1:, :2] = ring_xy * radius
+ positions[..., 2] = height
+
+ # the NORMALS
+ normals = np.zeros_like(positions, dtype=np.float32)
+ sign = int(up) * 2.0 - 1.0
+ normals[..., 2] = sign
+
+ # the TEXTURE COORDS
+ # uv etches out a circle from the [0..1, 0..1] range
+ # direction is reversed for up=False
+ texcoords = np.empty((1 + n_vertices, 2), dtype=np.float32)
+ texcoords[0] = [0.5, 0.5]
+ texcoords[1:, 0] = ring_xy[:, 0] * 0.5 + 0.5
+ texcoords[1:, 1] = ring_xy[:, 1] * 0.5 * sign + 0.5
+
+ # the face INDEX
+ indices = np.arange(n_vertices) + 1
+ # for every radial segment there is a triangle (3)
+ index = np.empty((radial_segments, 3), dtype=np.uint32)
+ # create a grid of initial indices for the panels
+ index[:, 0] = indices[np.arange(radial_segments)]
+ # the remainder of the indices for every panel are relative
+ index[:, 1 + int(up)] = n_vertices
+ index[:, 2 - int(up)] = index[:, 0] + 1
+
+ return (
+ positions,
+ normals,
+ texcoords,
+ index.flatten(),
+ )
+
+
+def create_vector_geometry(
+ color: str | pygfx.Color | Sequence[float] | np.ndarray = "w",
+ cone_cap_color: str | pygfx.Color | Sequence[float] | np.ndarray | None = None,
+ cone_radius: float = 1.0,
+ cone_height: float = 0.5,
+ stalk_radius: float = 0.3,
+ stalk_height: float = 0.5,
+ segments: int = 12,
+):
+ """
+ Generate the mesh for a vector pointing in the direction [0, 0, 1], a unit vector in the +z direction.
+
+ Parameters
+ ----------
+ color:
+ color of the vector
+
+ cone_cap_color:
+ color of the cone cap, by default it will use a darker version of the provided vector color from above.
+
+ cone_radius:
+ radius of the bottom of the cone segment of the vector
+
+ cone_height:
+ height of the cone segment of the vector
+
+ stalk_radius:
+ radius of the vector's stalk
+
+ stalk_height:
+ height of the vector's stalk
+
+ segments:
+ number of mesh segments, more looks nicers but is also more expennsive to render, 12 looks good enough.
+
+ """
+
+ radius_top = 0 # radius top = 0 means the cylinder becomes a cone
+
+ radial_segments = segments
+
+ height_segments = 1
+ theta_start = 0.0
+ theta_length = np.pi * 2
+
+ # create cone
+ cone = generate_torso(
+ cone_radius,
+ radius_top,
+ cone_height,
+ radial_segments,
+ height_segments,
+ theta_start,
+ theta_length,
+ )
+
+ groups = [cone]
+
+ cone_cap_start_ix = len(cone[0])
+
+ # create bottom cap
+ cone_cap = generate_cap(
+ cone_radius,
+ -cone_height / 2,
+ radial_segments,
+ theta_start,
+ theta_length,
+ up=False,
+ )
+
+ cone_cap_stop_ix = cone_cap_start_ix + len(cone_cap[0])
+
+ groups.append(cone_cap)
+
+ stalk = generate_torso(
+ stalk_radius,
+ stalk_radius,
+ stalk_height,
+ radial_segments,
+ height_segments,
+ theta_start,
+ theta_length,
+ z_offset=cone_height,
+ )
+
+ groups.append(stalk)
+
+ stalk_cap = generate_cap(
+ stalk_radius,
+ -stalk_radius / 2,
+ radial_segments,
+ theta_start,
+ theta_length,
+ up=False,
+ )
+
+ groups.append(stalk_cap)
+
+ merged = merge_geometries(groups)
+
+ positions, normals, texcoords, indices = merged
+
+ color = np.array(pygfx.Color(color).rgb, dtype=np.float32)
+
+ # color the cone cap in a different color
+ if cone_cap_color is not None:
+ cone_cap_color = np.array(pygfx.Color(cone_cap_color).rgb, dtype=np.float32)
+ else:
+ # make the cone cap a slightly darker version of the cone color
+ cone_cap_color = (color - np.array([0.25, 0.25, 0.25], dtype=np.float32)).clip(
+ 0
+ )
+
+ colors = np.repeat([color], repeats=len(positions), axis=0)
+
+ colors[cone_cap_start_ix:cone_cap_stop_ix, :] = cone_cap_color
+
+ return pygfx.Geometry(
+ indices=indices.reshape((-1, 3)),
+ positions=positions,
+ normals=normals,
+ texcoords=texcoords,
+ colors=colors,
+ )
diff --git a/fastplotlib/graphics/features/__init__.py b/fastplotlib/graphics/features/__init__.py
index 18bcf5187..7f7410cf7 100644
--- a/fastplotlib/graphics/features/__init__.py
+++ b/fastplotlib/graphics/features/__init__.py
@@ -1,13 +1,30 @@
-from ._positions_graphics import (
+from ._positions import (
VertexColors,
UniformColor,
- UniformSize,
SizeSpace,
- Thickness,
VertexPositions,
- PointsSizesFeature,
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,
+ UniformMarker,
+ UniformEdgeColor,
+ EdgeWidth,
+ UniformRotations,
+ VertexRotations,
+ VertexPointSizes,
+ UniformSize,
+)
from ._image import (
TextureArray,
ImageCmap,
@@ -16,6 +33,24 @@
ImageInterpolation,
ImageCmapInterpolation,
)
+from ._volume import (
+ TextureArrayVolume,
+ VolumeRenderMode,
+ VolumeIsoThreshold,
+ VolumeIsoStepSize,
+ VolumeIsoSubStepSize,
+ VolumeIsoEmissive,
+ VolumeIsoShininess,
+ VolumeSlicePlane,
+ VOLUME_RENDER_MODES,
+ create_volume_material_kwargs,
+)
+
+from ._vectors import (
+ VectorPositions,
+ VectorDirections,
+)
+
from ._base import (
GraphicFeature,
BufferManager,
@@ -36,24 +71,43 @@
LinearRegionSelectionFeature,
RectangleSelectionFeature,
)
-from ._common import Name, Offset, Rotation, Visible, Deleted
+from ._common import Name, Offset, Rotation, Scale, Alpha, AlphaMode, Visible, Deleted
__all__ = [
"VertexColors",
"UniformColor",
- "UniformSize",
"SizeSpace",
- "Thickness",
"VertexPositions",
- "PointsSizesFeature",
"VertexCmap",
+ "MeshIndices",
+ "MeshCmap",
+ "SurfaceData",
+ "Thickness",
+ "VertexMarkers",
+ "UniformMarker",
+ "UniformEdgeColor",
+ "EdgeWidth",
+ "UniformRotations",
+ "VertexRotations",
+ "VertexPointSizes",
+ "UniformSize",
"TextureArray",
"ImageCmap",
"ImageVmin",
"ImageVmax",
"ImageInterpolation",
"ImageCmapInterpolation",
+ "TextureArrayVolume",
+ "VolumeRenderMode",
+ "VolumeIsoThreshold",
+ "VolumeIsoStepSize",
+ "VolumeIsoSubStepSize",
+ "VolumeIsoEmissive",
+ "VolumeIsoShininess",
+ "VolumeSlicePlane",
+ "VectorPositions",
+ "VectorDirections",
"TextData",
"FontSize",
"TextFaceColor",
@@ -65,6 +119,9 @@
"Name",
"Offset",
"Rotation",
+ "Scale",
+ "Alpha",
+ "AlphaMode",
"Visible",
"Deleted",
"GraphicFeatureEvent",
diff --git a/fastplotlib/graphics/features/_base.py b/fastplotlib/graphics/features/_base.py
index d32904ae5..779310476 100644
--- a/fastplotlib/graphics/features/_base.py
+++ b/fastplotlib/graphics/features/_base.py
@@ -4,7 +4,7 @@
import numpy as np
from numpy.typing import NDArray
-from wgpu.gui.base import log_exception
+from rendercanvas.base import log_exception
import pygfx
@@ -49,7 +49,8 @@ def __init__(self, type: str, info: dict):
class GraphicFeature:
- def __init__(self, **kwargs):
+ def __init__(self, property_name: str, **kwargs):
+ self._property_name = property_name
self._event_handlers = list()
self._block_events = False
@@ -139,10 +140,9 @@ def __init__(
data: NDArray | pygfx.Buffer,
buffer_type: Literal["buffer", "texture", "texture-array"] = "buffer",
isolated_buffer: bool = True,
- texture_dim: int = 2,
**kwargs,
):
- super().__init__()
+ super().__init__(**kwargs)
if isolated_buffer and not isinstance(data, pygfx.Resource):
# useful if data is read-only, example: memmaps
bdata = np.zeros(data.shape, dtype=data.dtype)
@@ -157,9 +157,6 @@ def __init__(
self._buffer = data
elif buffer_type == "buffer":
self._buffer = pygfx.Buffer(bdata)
- elif buffer_type == "texture":
- # TODO: placeholder, not currently used since TextureArray is used specifically for Image graphics
- self._buffer = pygfx.Texture(bdata, dim=texture_dim)
else:
raise ValueError(
"`data` must be a pygfx.Buffer instance or `buffer_type` must be one of: 'buffer' or 'texture'"
@@ -167,8 +164,6 @@ def __init__(
self._event_handlers: list[callable] = list()
- self._shared: int = 0
-
@property
def value(self) -> np.ndarray:
"""numpy array object representing the data managed by this buffer"""
@@ -183,11 +178,6 @@ def buffer(self) -> pygfx.Buffer | pygfx.Texture:
"""managed buffer"""
return self._buffer
- @property
- def shared(self) -> int:
- """Number of graphics that share this buffer"""
- return self._shared
-
@property
def __array_interface__(self):
raise BufferError(
@@ -299,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)
@@ -318,7 +314,7 @@ def __len__(self):
raise NotImplementedError
def __repr__(self):
- return f"{self.__class__.__name__} buffer data:\n" f"{self.value.__repr__()}"
+ return f"{self.__class__.__name__} buffer data:\n{self.value.__repr__()}"
def block_reentrance(set_value):
diff --git a/fastplotlib/graphics/features/_common.py b/fastplotlib/graphics/features/_common.py
index 71e979f77..6ce167075 100644
--- a/fastplotlib/graphics/features/_common.py
+++ b/fastplotlib/graphics/features/_common.py
@@ -1,19 +1,20 @@
+from typing import Sequence
+
import numpy as np
from ._base import GraphicFeature, GraphicFeatureEvent, block_reentrance
class Name(GraphicFeature):
- property_name = "name"
event_info_spec = [
{"dict key": "value", "type": "str", "description": "user provided name"},
]
- def __init__(self, value: str):
+ def __init__(self, value: str, property_name: str = "name"):
"""Graphic name"""
self._value = value
- super().__init__()
+ super().__init__(property_name=property_name)
@property
def value(self) -> str:
@@ -29,12 +30,11 @@ def set_value(self, graphic, value: str):
self._value = value
- event = GraphicFeatureEvent(type="name", info={"value": value})
+ event = GraphicFeatureEvent(type=self._property_name, info={"value": value})
self._call_event_handlers(event)
class Offset(GraphicFeature):
- property_name = "offset"
event_info_spec = [
{
"dict key": "value",
@@ -43,7 +43,9 @@ class Offset(GraphicFeature):
},
]
- def __init__(self, value: np.ndarray | list | tuple):
+ def __init__(
+ self, value: np.ndarray | Sequence[float], property_name: str = "offset"
+ ):
"""Offset position of the graphic, [x, y, z]"""
self._validate(value)
@@ -52,7 +54,7 @@ def __init__(self, value: np.ndarray | list | tuple):
# set values
self._value[:] = value
- super().__init__()
+ super().__init__(property_name=property_name)
def _validate(self, value):
if not len(value) == 3:
@@ -63,7 +65,7 @@ def value(self) -> np.ndarray:
return self._value
@block_reentrance
- def set_value(self, graphic, value: np.ndarray | list | tuple):
+ def set_value(self, graphic, value: np.ndarray | Sequence[float]):
self._validate(value)
value = np.asarray(value)
@@ -75,12 +77,11 @@ def set_value(self, graphic, value: np.ndarray | list | tuple):
# set value of existing feature value array
self._value[:] = value
- event = GraphicFeatureEvent(type="offset", info={"value": value})
+ event = GraphicFeatureEvent(type=self._property_name, info={"value": value})
self._call_event_handlers(event)
class Rotation(GraphicFeature):
- property_name = "offset"
event_info_spec = [
{
"dict key": "value",
@@ -89,7 +90,9 @@ class Rotation(GraphicFeature):
},
]
- def __init__(self, value: np.ndarray | list | tuple):
+ def __init__(
+ self, value: np.ndarray | Sequence[float], property_name: str = "rotation"
+ ):
"""Graphic rotation quaternion"""
self._validate(value)
@@ -97,7 +100,7 @@ def __init__(self, value: np.ndarray | list | tuple):
self._value = np.zeros(4)
self._value[:] = value
- super().__init__()
+ super().__init__(property_name=property_name)
def _validate(self, value):
if not len(value) == 4:
@@ -110,7 +113,7 @@ def value(self) -> np.ndarray:
return self._value
@block_reentrance
- def set_value(self, graphic, value: np.ndarray | list | tuple):
+ def set_value(self, graphic, value: np.ndarray | Sequence[float]):
self._validate(value)
value = np.asarray(value)
@@ -123,21 +126,131 @@ def set_value(self, graphic, value: np.ndarray | list | tuple):
# set value of existing feature value array
self._value[:] = value
- event = GraphicFeatureEvent(type="rotation", info={"value": value})
+ event = GraphicFeatureEvent(type=self._property_name, info={"value": value})
+ 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."""
+
+ event_info_spec = [
+ {"dict key": "value", "type": "float", "description": "new alpha value"},
+ ]
+
+ def __init__(self, value: float, property_name: str = "alpha"):
+ self._value = value
+ super().__init__(property_name=property_name)
+
+ @property
+ def value(self) -> float:
+ return self._value
+
+ @block_reentrance
+ def set_value(self, graphic, value: float):
+ wo = graphic.world_object
+ if wo.material is not None:
+ wo.material.opacity = value
+
+ if "Image" in graphic.__class__.__name__:
+ # Image and ImageVolume use tiling and share one material
+ graphic._material.alpha = value
+
+ self._value = value
+
+ event = GraphicFeatureEvent(type=self._property_name, info={"value": value})
+ self._call_event_handlers(event)
+
+
+class AlphaMode(GraphicFeature):
+ """The alpha-mode value of a graphic (i.e. how alpha is handled by the renderer)."""
+
+ event_info_spec = [
+ {"dict key": "value", "type": "str", "description": "new alpha mode"},
+ ]
+
+ def __init__(self, value: str, property_name: str = "alpha_mode"):
+ self._value = value
+ super().__init__(property_name=property_name)
+
+ @property
+ def value(self) -> str:
+ return self._value
+
+ @block_reentrance
+ def set_value(self, graphic, value: str):
+ wo = graphic.world_object
+ if wo.material is not None:
+ wo.alpha_mode = value
+
+ if "Image" in graphic.__class__.__name__:
+ # Image and ImageVolume use tiling and share one material
+ graphic._material.alpha_mode = value
+
+ self._value = value
+
+ event = GraphicFeatureEvent(type=self._property_name, info={"value": value})
self._call_event_handlers(event)
class Visible(GraphicFeature):
"""Access or change the visibility."""
- property_name = "offset"
event_info_spec = [
{"dict key": "value", "type": "bool", "description": "new visibility bool"},
]
- def __init__(self, value: bool):
+ def __init__(self, value: bool, property_name: str = "visible"):
self._value = value
- super().__init__()
+ super().__init__(property_name=property_name)
@property
def value(self) -> bool:
@@ -148,7 +261,7 @@ def set_value(self, graphic, value: bool):
graphic.world_object.visible = value
self._value = value
- event = GraphicFeatureEvent(type="visible", info={"value": value})
+ event = GraphicFeatureEvent(type=self._property_name, info={"value": value})
self._call_event_handlers(event)
@@ -157,7 +270,6 @@ class Deleted(GraphicFeature):
Used when a graphic is deleted, triggers events that can be useful to indicate this graphic has been deleted
"""
- property_name = "deleted"
event_info_spec = [
{
"dict key": "value",
@@ -166,9 +278,9 @@ class Deleted(GraphicFeature):
},
]
- def __init__(self, value: bool):
+ def __init__(self, value: bool, property_name: str = "deleted"):
self._value = value
- super().__init__()
+ super().__init__(property_name=property_name)
@property
def value(self) -> bool:
@@ -177,5 +289,5 @@ def value(self) -> bool:
@block_reentrance
def set_value(self, graphic, value: bool):
self._value = value
- event = GraphicFeatureEvent(type="deleted", info={"value": value})
+ event = GraphicFeatureEvent(type=self._property_name, info={"value": value})
self._call_event_handlers(event)
diff --git a/fastplotlib/graphics/features/_image.py b/fastplotlib/graphics/features/_image.py
index c47a26e6a..648f79bc8 100644
--- a/fastplotlib/graphics/features/_image.py
+++ b/fastplotlib/graphics/features/_image.py
@@ -13,8 +13,13 @@
)
-# manages an array of 8192x8192 Textures representing chunks of an image
class TextureArray(GraphicFeature):
+ """
+ Manages an array of Textures representing chunks of an image.
+
+ Creates multiple pygfx.Texture objects based on the GPU's max texture dimension limit.
+ """
+
event_info_spec = [
{
"dict key": "key",
@@ -28,8 +33,8 @@ class TextureArray(GraphicFeature):
},
]
- def __init__(self, data, isolated_buffer: bool = True):
- super().__init__()
+ def __init__(self, data, isolated_buffer: bool = True, property_name: str = "data"):
+ super().__init__(property_name=property_name)
data = self._fix_data(data)
@@ -70,8 +75,6 @@ def __init__(self, data, isolated_buffer: bool = True):
self.buffer[buffer_index] = texture
- self._shared: int = 0
-
@property
def value(self) -> np.ndarray:
return self._value
@@ -99,10 +102,6 @@ def col_indices(self) -> np.ndarray:
"""
return self._col_indices
- @property
- def shared(self) -> int:
- return self._shared
-
def _fix_data(self, data):
if data.ndim not in (2, 3):
raise ValueError(
@@ -155,7 +154,9 @@ def __setitem__(self, key, value):
for texture in self.buffer.ravel():
texture.update_range((0, 0, 0), texture.size)
- event = GraphicFeatureEvent("data", info={"key": key, "value": value})
+ event = GraphicFeatureEvent(
+ self._property_name, info={"key": key, "value": value}
+ )
self._call_event_handlers(event)
def __len__(self):
@@ -173,9 +174,9 @@ class ImageVmin(GraphicFeature):
},
]
- def __init__(self, value: float):
+ def __init__(self, value: float, property_name: str = "vmin"):
self._value = value
- super().__init__()
+ super().__init__(property_name=property_name)
@property
def value(self) -> float:
@@ -187,7 +188,7 @@ def set_value(self, graphic, value: float):
graphic._material.clim = (value, vmax)
self._value = value
- event = GraphicFeatureEvent(type="vmin", info={"value": value})
+ event = GraphicFeatureEvent(type=self._property_name, info={"value": value})
self._call_event_handlers(event)
@@ -202,9 +203,9 @@ class ImageVmax(GraphicFeature):
},
]
- def __init__(self, value: float):
+ def __init__(self, value: float, property_name: str = "vmax"):
self._value = value
- super().__init__()
+ super().__init__(property_name=property_name)
@property
def value(self) -> float:
@@ -216,7 +217,7 @@ def set_value(self, graphic, value: float):
graphic._material.clim = (vmin, value)
self._value = value
- event = GraphicFeatureEvent(type="vmax", info={"value": value})
+ event = GraphicFeatureEvent(type=self._property_name, info={"value": value})
self._call_event_handlers(event)
@@ -231,10 +232,10 @@ class ImageCmap(GraphicFeature):
},
]
- def __init__(self, value: str):
+ def __init__(self, value: str, property_name: str = "cmap"):
self._value = value
self.texture = get_cmap_texture(value)
- super().__init__()
+ super().__init__(property_name=property_name)
@property
def value(self) -> str:
@@ -247,7 +248,7 @@ def set_value(self, graphic, value: str):
graphic._material.map.texture.update_range((0, 0, 0), size=(256, 1, 1))
self._value = value
- event = GraphicFeatureEvent(type="cmap", info={"value": value})
+ event = GraphicFeatureEvent(type=self._property_name, info={"value": value})
self._call_event_handlers(event)
@@ -262,10 +263,10 @@ class ImageInterpolation(GraphicFeature):
},
]
- def __init__(self, value: str):
+ def __init__(self, value: str, property_name: str = "interpolation"):
self._validate(value)
self._value = value
- super().__init__()
+ super().__init__(property_name=property_name)
def _validate(self, value):
if value not in ["nearest", "linear"]:
@@ -297,10 +298,10 @@ class ImageCmapInterpolation(GraphicFeature):
},
]
- def __init__(self, value: str):
+ def __init__(self, value: str, property_name: str = "cmap_interpolation"):
self._validate(value)
self._value = value
- super().__init__()
+ super().__init__(property_name=property_name)
def _validate(self, value):
if value not in ["nearest", "linear"]:
@@ -321,5 +322,5 @@ def set_value(self, graphic, value: str):
graphic._material.map.mag_filter = value
self._value = value
- event = GraphicFeatureEvent(type="cmap_interpolation", info={"value": value})
+ event = GraphicFeatureEvent(type=self._property_name, info={"value": value})
self._call_event_handlers(event)
diff --git a/fastplotlib/graphics/features/_line.py b/fastplotlib/graphics/features/_line.py
new file mode 100644
index 000000000..792cb7832
--- /dev/null
+++ b/fastplotlib/graphics/features/_line.py
@@ -0,0 +1,28 @@
+from ._base import (
+ GraphicFeature,
+ GraphicFeatureEvent,
+ block_reentrance,
+)
+
+
+class Thickness(GraphicFeature):
+ event_info_spec = [
+ {"dict key": "value", "type": "float", "description": "new thickness value"},
+ ]
+
+ def __init__(self, value: float, property_name: str = "thickness"):
+ self._value = value
+ super().__init__(property_name=property_name)
+
+ @property
+ def value(self) -> float:
+ return self._value
+
+ @block_reentrance
+ def set_value(self, graphic, value: float):
+ value = float(value)
+ graphic.world_object.material.thickness = value
+ self._value = value
+
+ event = GraphicFeatureEvent(type=self._property_name, info={"value": value})
+ self._call_event_handlers(event)
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 64%
rename from fastplotlib/graphics/features/_positions_graphics.py
rename to fastplotlib/graphics/features/_positions.py
index 868701079..295d22417 100644
--- a/fastplotlib/graphics/features/_positions_graphics.py
+++ b/fastplotlib/graphics/features/_positions.py
@@ -1,4 +1,4 @@
-from typing import Any
+from typing import Any, Sequence
import numpy as np
import pygfx
@@ -17,7 +17,6 @@
class VertexColors(BufferManager):
- property_name = "colors"
event_info_spec = [
{
"dict key": "key",
@@ -38,36 +37,35 @@ class VertexColors(BufferManager):
def __init__(
self,
- colors: str | np.ndarray | tuple[float] | list[float] | list[str],
+ colors: str | pygfx.Color | np.ndarray | Sequence[float] | Sequence[str],
n_colors: int,
- alpha: float = None,
isolated_buffer: bool = True,
+ property_name: str = "colors",
):
"""
Manages the vertex color buffer for :class:`LineGraphic` or :class:`ScatterGraphic`
Parameters
----------
- colors: str | np.ndarray | tuple[float, float, float, float] | list[str] | list[float] | int | float
+ colors: str | pygfx.Color | np.ndarray | Sequence[float] | Sequence[str]
specify colors as a single human-readable string, RGBA array,
or an iterable of strings or RGBA arrays
n_colors: int
number of colors, if passing in a single str or single RGBA array
- alpha: float, optional
- alpha value for the colors
-
"""
- data = parse_colors(colors, n_colors, alpha)
+ data = parse_colors(colors, n_colors)
- super().__init__(data=data, isolated_buffer=isolated_buffer)
+ super().__init__(
+ data=data, isolated_buffer=isolated_buffer, property_name=property_name
+ )
@block_reentrance
def __setitem__(
self,
key: int | slice | np.ndarray[int | bool] | tuple[slice, ...],
- user_value: str | np.ndarray | tuple[float] | list[float] | list[str],
+ user_value: str | pygfx.Color | np.ndarray | Sequence[float] | Sequence[str],
):
user_key = key
@@ -141,7 +139,7 @@ def __setitem__(
"user_value": user_value,
}
- event = GraphicFeatureEvent("colors", info=event_info)
+ event = GraphicFeatureEvent(self._property_name, info=event_info)
self._call_event_handlers(event)
def __len__(self):
@@ -149,66 +147,41 @@ def __len__(self):
class UniformColor(GraphicFeature):
- property_name = "colors"
event_info_spec = [
{
"dict key": "value",
- "type": "np.ndarray [RGBA]",
+ "type": "str | pygfx.Color | np.ndarray | Sequence[float]",
"description": "new color value",
},
]
def __init__(
- self, value: str | np.ndarray | tuple | list | pygfx.Color, alpha: float = 1.0
+ self,
+ value: str | pygfx.Color | np.ndarray | Sequence[float],
+ property_name: str = "colors",
):
"""Manages uniform color for line or scatter material"""
- v = (*tuple(pygfx.Color(value))[:-1], alpha) # apply alpha
- self._value = pygfx.Color(v)
- super().__init__()
+ self._value = pygfx.Color(value)
+ super().__init__(property_name=property_name)
@property
def value(self) -> pygfx.Color:
return self._value
@block_reentrance
- def set_value(self, graphic, value: str | np.ndarray | tuple | list | pygfx.Color):
+ def set_value(
+ self, graphic, value: str | pygfx.Color | np.ndarray | Sequence[float]
+ ):
value = pygfx.Color(value)
graphic.world_object.material.color = value
self._value = value
- event = GraphicFeatureEvent(type="colors", info={"value": value})
- self._call_event_handlers(event)
-
-
-class UniformSize(GraphicFeature):
- property_name = "sizes"
- event_info_spec = [
- {"dict key": "value", "type": "float", "description": "new size value"},
- ]
-
- def __init__(self, value: int | float):
- """Manages uniform size for scatter material"""
-
- self._value = float(value)
- super().__init__()
-
- @property
- def value(self) -> float:
- return self._value
-
- @block_reentrance
- def set_value(self, graphic, value: float | int):
- value = float(value)
- graphic.world_object.material.size = value
- self._value = value
-
- event = GraphicFeatureEvent(type="sizes", info={"value": value})
+ event = GraphicFeatureEvent(type=self._property_name, info={"value": value})
self._call_event_handlers(event)
class SizeSpace(GraphicFeature):
- property_name = "size_space"
event_info_spec = [
{
"dict key": "value",
@@ -217,11 +190,11 @@ class SizeSpace(GraphicFeature):
},
]
- def __init__(self, value: str):
+ def __init__(self, value: str, property_name: str = "size_space"):
"""Manages the coordinate space for scatter/line graphic"""
self._value = value
- super().__init__()
+ super().__init__(property_name=property_name)
@property
def value(self) -> str:
@@ -240,12 +213,11 @@ def set_value(self, graphic, value: str):
graphic.world_object.material.size_space = value
self._value = value
- event = GraphicFeatureEvent(type="size_space", info={"value": value})
+ event = GraphicFeatureEvent(type=self._property_name, info={"value": value})
self._call_event_handlers(event)
class VertexPositions(BufferManager):
- property_name = "data"
event_info_spec = [
{
"dict key": "key",
@@ -259,18 +231,20 @@ class VertexPositions(BufferManager):
},
]
- def __init__(self, data: Any, isolated_buffer: bool = True):
+ def __init__(
+ self, data: Any, isolated_buffer: bool = True, property_name: str = "data"
+ ):
"""
Manages the vertex positions 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)
+ super().__init__(
+ data, isolated_buffer=isolated_buffer, property_name=property_name
+ )
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])
@@ -300,115 +274,13 @@ def __setitem__(
# determine offset and size for GPU upload
self._update_range(key)
- self._emit_event("data", key, value)
+ self._emit_event(self._property_name, key, value)
def __len__(self):
return len(self.buffer.data)
-class PointsSizesFeature(BufferManager):
- property_name = "sizes"
- event_info_spec = [
- {
- "dict key": "key",
- "type": "slice, index (int) or numpy-like fancy index",
- "description": "key at which point sizes were indexed/sliced",
- },
- {
- "dict key": "value",
- "type": "int | float | array-like",
- "description": "new size values for points that were changed",
- },
- ]
-
- def __init__(
- self,
- sizes: int | float | np.ndarray | list[int | float] | tuple[int | float],
- n_datapoints: int,
- isolated_buffer: bool = True,
- ):
- """
- Manages sizes buffer of scatter points.
- """
- sizes = self._fix_sizes(sizes, n_datapoints)
- super().__init__(data=sizes, isolated_buffer=isolated_buffer)
-
- def _fix_sizes(
- self,
- sizes: int | float | np.ndarray | list[int | float] | tuple[int | float],
- n_datapoints: int,
- ):
- if np.issubdtype(type(sizes), np.number):
- # single value given
- sizes = np.full(
- n_datapoints, sizes, dtype=np.float32
- ) # force it into a float to avoid weird gpu errors
-
- elif isinstance(
- sizes, (np.ndarray, tuple, list)
- ): # if it's not a ndarray already, make it one
- sizes = np.asarray(sizes, dtype=np.float32) # read it in as a numpy.float32
- if (sizes.ndim != 1) or (sizes.size != n_datapoints):
- raise ValueError(
- f"sequence of `sizes` must be 1 dimensional with "
- f"the same length as the number of datapoints"
- )
-
- else:
- raise TypeError(
- "sizes must be a single , , or a sequence (array, list, tuple) of int"
- "or float with the length equal to the number of datapoints"
- )
-
- if np.count_nonzero(sizes < 0) > 1:
- raise ValueError(
- "All sizes must be positive numbers greater than or equal to 0.0."
- )
-
- return sizes
-
- @block_reentrance
- def __setitem__(
- self,
- key: int | slice | np.ndarray[int | bool] | list[int | bool],
- value: int | float | np.ndarray | list[int | float] | tuple[int | float],
- ):
- # this is a very simple 1D buffer, no parsing required, directly set buffer
- self.buffer.data[key] = value
- self._update_range(key)
-
- self._emit_event("sizes", key, value)
-
- def __len__(self):
- return len(self.buffer.data)
-
-
-class Thickness(GraphicFeature):
- property_name = "thickness"
- event_info_spec = [
- {"dict key": "value", "type": "float", "description": "new thickness value"},
- ]
-
- def __init__(self, value: float):
- self._value = value
- super().__init__()
-
- @property
- def value(self) -> float:
- return self._value
-
- @block_reentrance
- def set_value(self, graphic, value: float):
- value = float(value)
- graphic.world_object.material.thickness = value
- self._value = value
-
- event = GraphicFeatureEvent(type="thickness", info={"value": value})
- self._call_event_handlers(event)
-
-
class VertexCmap(BufferManager):
- property_name = "cmap"
event_info_spec = [
{
"dict key": "key",
@@ -427,19 +299,18 @@ def __init__(
vertex_colors: VertexColors,
cmap_name: str | None,
transform: np.ndarray | None,
- alpha: float = 1.0,
+ property_name: str = "colors",
):
"""
Sliceable colormap feature, manages a VertexColors instance and
provides a way to set colormaps with arbitrary transforms
"""
- super().__init__(data=vertex_colors.buffer)
+ super().__init__(data=vertex_colors.buffer, property_name=property_name)
self._vertex_colors = vertex_colors
self._cmap_name = cmap_name
self._transform = transform
- self._alpha = alpha
if self._cmap_name is not None:
if not isinstance(self._cmap_name, str):
@@ -457,7 +328,6 @@ def __init__(
cmap_name=self._cmap_name,
transform=self._transform,
)
- colors[:, -1] = alpha
# set vertex colors from cmap
self._vertex_colors[:] = colors
@@ -481,7 +351,6 @@ def __setitem__(self, key: slice, cmap_name):
colors = parse_cmap_values(
n_colors=n_elements, cmap_name=cmap_name, transform=self._transform
)
- colors[:, -1] = self.alpha
self._cmap_name = cmap_name
self._vertex_colors[key] = colors
@@ -489,7 +358,7 @@ def __setitem__(self, key: slice, cmap_name):
# TODO: should we block vertex_colors from emitting an event?
# Because currently this will result in 2 emitted events, one
# for cmap and another from the colors
- self._emit_event("cmap", key, cmap_name)
+ self._emit_event(self._property_name, key, cmap_name)
@property
def name(self) -> str:
@@ -517,8 +386,6 @@ def transform(
n_colors=self.value.shape[0], cmap_name=self._cmap_name, transform=values
)
- colors[:, -1] = self.alpha
-
self._transform = values
if indices is None:
@@ -528,18 +395,6 @@ def transform(
self._emit_event("cmap.transform", indices, values)
- @property
- def alpha(self) -> float:
- """Get or set the alpha level"""
- return self._alpha
-
- @alpha.setter
- def alpha(self, value: float, indices: slice | list | np.ndarray = None):
- self._vertex_colors[indices, -1] = value
- self._alpha = value
-
- self._emit_event("cmap.alpha", indices, value)
-
def __len__(self):
raise NotImplementedError(
"len not implemented for `cmap`, use len(colors) instead"
diff --git a/fastplotlib/graphics/features/_scatter.py b/fastplotlib/graphics/features/_scatter.py
new file mode 100644
index 000000000..16671ef89
--- /dev/null
+++ b/fastplotlib/graphics/features/_scatter.py
@@ -0,0 +1,574 @@
+from typing import Sequence
+
+import numpy as np
+import pygfx
+
+from ._base import (
+ GraphicFeature,
+ BufferManager,
+ GraphicFeatureEvent,
+ block_reentrance,
+)
+
+
+marker_names = {
+ # MPL
+ "o": "circle",
+ "s": "square",
+ "D": "diamond",
+ "+": "plus",
+ "x": "cross",
+ "^": "triangle_up",
+ "<": "triangle_left",
+ ">": "triangle_right",
+ "v": "triangle_down",
+ "*": "asterisk6",
+ # Unicode
+ "●": "circle",
+ "○": "ring",
+ "■": "square",
+ "♦": "diamond",
+ "♥": "heart",
+ "♠": "spade",
+ "♣": "club",
+ "✳": "asterisk6",
+ "▲": "triangle_up",
+ "▼": "triangle_down",
+ "◀": "triangle_left",
+ "▶": "triangle_right",
+ # Emojis (these may look like their plaintext variants in your editor)
+ "❤️": "heart",
+ "♠️": "spade",
+ "♣️": "club",
+ "♦️": "diamond",
+ "💎": "diamond",
+ "💍": "ring",
+ "✳️": "asterisk6",
+ "📍": "pin",
+}
+
+
+def user_input_to_marker(name):
+ resolved_name = marker_names.get(name, name).lower()
+ if resolved_name not in pygfx.MarkerShape:
+ raise ValueError(
+ f"markers must be a string in: {list(pygfx.MarkerShape) + list(marker_names.keys())}, not {name!r}"
+ )
+
+ return resolved_name
+
+
+def validate_user_markers_array(markers):
+ # make sure all markers are valid
+ # need to validate before converting to ints because
+ # we can't use control flow in the vectorized function
+ unique_values = np.unique(markers)
+ for m in unique_values:
+ user_input_to_marker(m)
+
+
+# fast vectorized function to convert array of user markers to the standardized strings
+# TODO: can probably use search-sorted for this too
+vectorized_user_markers_to_std_markers = np.vectorize(marker_names.get, otypes=[" array of int
+# see: https://github.com/pygfx/pygfx/issues/1215
+# Prepare for searchsorted
+def init_searchsorted(markers_mapping):
+ keys = np.array(list(markers_mapping.keys()))
+ vals = np.array(list(markers_mapping.values()))
+
+ order = np.argsort(keys)
+ keys = keys[order]
+ vals = vals[order]
+
+ return keys, vals
+
+
+marker_int_searchsorted_keys, marker_int_searchsorted_vals = init_searchsorted(
+ marker_int_mapping
+)
+
+
+def searchsorted_markers_to_int_array(markers_str_array: np.ndarray[str]):
+ # Vectorized lookup
+ indices = np.searchsorted(marker_int_searchsorted_keys, markers_str_array)
+ return marker_int_searchsorted_vals[indices]
+
+
+class VertexMarkers(BufferManager):
+ event_info_spec = [
+ {
+ "dict key": "key",
+ "type": "slice, index (int) or numpy-like fancy index",
+ "description": "key at which markers were indexed/sliced",
+ },
+ {
+ "dict key": "value",
+ "type": "str | np.ndarray[str]",
+ "description": "new marker values for points that were changed",
+ },
+ ]
+
+ def __init__(
+ self,
+ markers: str | Sequence[str] | np.ndarray,
+ n_datapoints: int,
+ property_name: str = "markers",
+ ):
+ """
+ Manages the markers buffer for the scatter points. Supports fancy indexing.
+ """
+
+ # first validate then allocate buffers
+
+ if isinstance(markers, str):
+ markers = user_input_to_marker(markers)
+
+ elif isinstance(markers, (tuple, list, np.ndarray)):
+ validate_user_markers_array(markers)
+
+ # allocate buffers
+ markers_int_array = np.zeros(n_datapoints, dtype=np.int32)
+
+ marker_str_length = max(map(len, list(pygfx.MarkerShape)))
+
+ self._markers_readable_array = np.empty(
+ n_datapoints, dtype=f" np.ndarray[str]:
+ """numpy array of per-vertex marker shapes in human-readable form"""
+ return self._markers_readable_array
+
+ @property
+ def value_int(self) -> np.ndarray[np.int32]:
+ """numpy array of the actual int32 buffer that represents per-vertex marker shapes on the GPU"""
+ return self.buffer.data
+
+ def _set_markers_arrays(self, key, value, n_markers):
+ if isinstance(value, str):
+ # set markers at these indices to this value
+ m = user_input_to_marker(value)
+ self._markers_readable_array[key] = m
+ self.value_int[key] = marker_int_mapping[m]
+
+ elif isinstance(value, (np.ndarray, list, tuple)):
+ if n_markers != len(value):
+ raise IndexError(
+ f"Must provide one marker value, or an array/list/tuple of marker values with the same length "
+ f"as the slice. You have provided the slice: {key}, which refers to {n_markers} markers, but "
+ f"provided {len(value)} new marker values. You must provide 1 or {n_markers} values."
+ )
+
+ validate_user_markers_array(value)
+
+ new_markers_human_readable = vectorized_user_markers_to_std_markers(value)
+ new_markers_int = searchsorted_markers_to_int_array(
+ new_markers_human_readable
+ )
+
+ self._markers_readable_array[key] = new_markers_human_readable
+ self.value_int[key] = new_markers_int
+ else:
+ raise TypeError(
+ "new markers value must be a str, Sequence or np.ndarray of new marker values"
+ )
+
+ @block_reentrance
+ def __setitem__(
+ self,
+ key: int | slice | list[int | bool] | np.ndarray[int | bool],
+ value: str | Sequence[str] | np.ndarray[str],
+ ):
+ if isinstance(key, int):
+ if key >= self.value.size:
+ raise IndexError(f"index : {key} out of bounds: {self.value.size}")
+
+ if not isinstance(value, str):
+ # only a single marker should be provided if changing one at one index
+ raise TypeError(
+ f"you must provide a marker value if providing a single index, "
+ f"you have passed index: {key} and value: {value}"
+ )
+
+ m = user_input_to_marker(value)
+ self._markers_readable_array[key] = m
+ self.value_int[key] = marker_int_mapping[m]
+
+ elif isinstance(key, slice):
+ # find the number of new markers by converting slice to range and then parse markers
+ start, stop, step = key.indices(self.value.size)
+
+ n_markers = len(range(start, stop, step))
+ self._set_markers_arrays(key, value, n_markers)
+
+ elif isinstance(key, (list, np.ndarray)):
+ key = np.asarray(key) # convert to array if list
+
+ if key.dtype == bool:
+ # make sure len is same
+ if not key.size == self.buffer.data.shape[0]:
+ raise IndexError(
+ f"Length of array for fancy indexing must match number of datapoints.\n"
+ f"There are {len(self.buffer.data.shape[0])} datapoints and you have passed "
+ f"a bool array of size: {key.size}"
+ )
+
+ n_markers = np.count_nonzero(key)
+ self._set_markers_arrays(key, value, n_markers)
+
+ # if it's an array of int
+ elif np.issubdtype(key.dtype, np.integer):
+ if key.size > self.buffer.data.shape[0]:
+ raise IndexError(
+ f"Length of array for fancy indexing must be <= n_datapoints. "
+ f"There are: {self.buffer.data.shape[0]} datapoints, you have passed an "
+ f"integer array for fancy indexing of size: {key.size}"
+ )
+ n_markers = key.size
+ self._set_markers_arrays(key, value, n_markers)
+
+ else:
+ # fancy indexing doesn't make sense with non-integer types
+ raise TypeError(
+ f"can only using integer or booleans arrays for fancy indexing, your array is of type: {key.dtype}"
+ )
+
+ else:
+ raise TypeError(
+ f"Can only set markers by slicing/indexing using the one of the following types: "
+ f"int | slice | list[int | bool] | np.ndarray[int | bool], you have passed"
+ f"sliced using the following type: {type(key)}"
+ )
+
+ # _update_range handles parsing the key to
+ # determine offset and size for GPU upload
+ self._update_range(key)
+
+ self._emit_event(self._property_name, key, value)
+
+ def __len__(self):
+ return len(self.buffer.data)
+
+
+class UniformMarker(GraphicFeature):
+ event_info_spec = [
+ {
+ "dict key": "value",
+ "type": "str | None",
+ "description": "new marker value",
+ },
+ ]
+
+ def __init__(self, marker: str, property_name: str = "markers"):
+ """Manages evented uniform buffer for scatter marker"""
+
+ self._value = user_input_to_marker(marker)
+ super().__init__(property_name=property_name)
+
+ @property
+ def value(self) -> str:
+ return self._value
+
+ @block_reentrance
+ def set_value(self, graphic, value: str):
+ value = user_input_to_marker(value)
+ graphic.world_object.material.marker = value
+ self._value = value
+
+ event = GraphicFeatureEvent(type=self._property_name, info={"value": value})
+ self._call_event_handlers(event)
+
+
+class UniformEdgeColor(GraphicFeature):
+ event_info_spec = [
+ {
+ "dict key": "value",
+ "type": "str | np.ndarray | pygfx.Color | Sequence[float]",
+ "description": "new edge_color",
+ },
+ ]
+
+ def __init__(
+ self,
+ edge_color: str | np.ndarray | pygfx.Color | Sequence[float],
+ property_name: str = "edge_colors",
+ ):
+ """Manages evented uniform buffer for scatter marker edge_color"""
+
+ self._value = pygfx.Color(edge_color)
+ super().__init__(property_name=property_name)
+
+ @property
+ def value(self) -> pygfx.Color:
+ return self._value
+
+ @block_reentrance
+ def set_value(
+ self, graphic, value: str | np.ndarray | pygfx.Color | Sequence[float]
+ ):
+ graphic.world_object.material.edge_color = pygfx.Color(value)
+ self._value = value
+
+ event = GraphicFeatureEvent(type=self._property_name, info={"value": value})
+ self._call_event_handlers(event)
+
+
+class EdgeWidth(GraphicFeature):
+ event_info_spec = [
+ {
+ "dict key": "value",
+ "type": "float",
+ "description": "new edge_width",
+ },
+ ]
+
+ def __init__(self, edge_width: float, property_name: str = "edge_width"):
+ """Manages evented uniform buffer for scatter marker edge_width"""
+
+ self._value = edge_width
+ super().__init__(property_name=property_name)
+
+ @property
+ def value(self) -> float:
+ return self._value
+
+ @block_reentrance
+ def set_value(self, graphic, value: float):
+ graphic.world_object.material.edge_width = value
+ self._value = value
+
+ event = GraphicFeatureEvent(type=self._property_name, info={"value": value})
+ self._call_event_handlers(event)
+
+
+class UniformRotations(GraphicFeature):
+ event_info_spec = [
+ {
+ "dict key": "value",
+ "type": "float",
+ "description": "new edge_width",
+ },
+ ]
+
+ def __init__(self, edge_width: float, property_name: str = "point_rotations"):
+ """Manages evented uniform buffer for scatter marker rotation"""
+
+ self._value = edge_width
+ super().__init__(property_name=property_name)
+
+ @property
+ def value(self) -> float:
+ return self._value
+
+ @block_reentrance
+ def set_value(self, graphic, value: float):
+ graphic.world_object.material.rotations = value
+ self._value = value
+
+ event = GraphicFeatureEvent(type=self._property_name, info={"value": value})
+ self._call_event_handlers(event)
+
+
+class VertexRotations(BufferManager):
+ event_info_spec = [
+ {
+ "dict key": "key",
+ "type": "slice, index (int) or numpy-like fancy index",
+ "description": "key at which point rotations were indexed/sliced",
+ },
+ {
+ "dict key": "value",
+ "type": "int | float | array-like",
+ "description": "new rotation values for points that were changed",
+ },
+ ]
+
+ def __init__(
+ self,
+ rotations: int | float | np.ndarray | Sequence[int | float],
+ n_datapoints: int,
+ isolated_buffer: bool = True,
+ property_name: str = "point_rotations",
+ ):
+ """
+ Manages rotations buffer of scatter points.
+ """
+ sizes = self._fix_sizes(rotations, n_datapoints)
+ super().__init__(
+ data=sizes, isolated_buffer=isolated_buffer, property_name=property_name
+ )
+
+ def _fix_sizes(
+ self,
+ sizes: int | float | np.ndarray | Sequence[int | float],
+ n_datapoints: int,
+ ):
+ if np.issubdtype(type(sizes), np.number):
+ # single value given
+ sizes = np.full(
+ n_datapoints, sizes, dtype=np.float32
+ ) # force it into a float to avoid weird gpu errors
+
+ elif isinstance(
+ sizes, (np.ndarray, tuple, list)
+ ): # if it's not a ndarray already, make it one
+ sizes = np.asarray(sizes, dtype=np.float32) # read it in as a numpy.float32
+ if (sizes.ndim != 1) or (sizes.size != n_datapoints):
+ raise ValueError(
+ f"sequence of `rotations` must be 1 dimensional with "
+ f"the same length as the number of datapoints"
+ )
+
+ else:
+ raise TypeError(
+ "`rotations` must be a single , , or a sequence (array, list, tuple) of int"
+ "or float with the length equal to the number of datapoints"
+ )
+
+ return sizes
+
+ @block_reentrance
+ def __setitem__(
+ self,
+ key: int | slice | np.ndarray[int | bool] | list[int | bool],
+ value: int | float | np.ndarray | Sequence[int | float],
+ ):
+ # this is a very simple 1D buffer, no parsing required, directly set buffer
+ self.buffer.data[key] = value
+ self._update_range(key)
+
+ self._emit_event(self._property_name, key, value)
+
+ def __len__(self):
+ return len(self.buffer.data)
+
+
+class VertexPointSizes(BufferManager):
+ event_info_spec = [
+ {
+ "dict key": "key",
+ "type": "slice, index (int) or numpy-like fancy index",
+ "description": "key at which point sizes were indexed/sliced",
+ },
+ {
+ "dict key": "value",
+ "type": "int | float | array-like",
+ "description": "new size values for points that were changed",
+ },
+ ]
+
+ def __init__(
+ self,
+ sizes: int | float | np.ndarray | Sequence[int | float],
+ n_datapoints: int,
+ isolated_buffer: bool = True,
+ property_name: str = "sizes",
+ ):
+ """
+ Manages sizes buffer of scatter points.
+ """
+ sizes = self._fix_sizes(sizes, n_datapoints)
+ super().__init__(
+ data=sizes, isolated_buffer=isolated_buffer, property_name=property_name
+ )
+
+ def _fix_sizes(
+ self,
+ sizes: int | float | np.ndarray | Sequence[int | float],
+ n_datapoints: int,
+ ):
+ if np.issubdtype(type(sizes), np.number):
+ # single value given
+ sizes = np.full(
+ n_datapoints, sizes, dtype=np.float32
+ ) # force it into a float to avoid weird gpu errors
+
+ elif isinstance(
+ sizes, (np.ndarray, tuple, list)
+ ): # if it's not a ndarray already, make it one
+ sizes = np.asarray(sizes, dtype=np.float32) # read it in as a numpy.float32
+ if (sizes.ndim != 1) or (sizes.size != n_datapoints):
+ raise ValueError(
+ f"sequence of `sizes` must be 1 dimensional with "
+ f"the same length as the number of datapoints"
+ )
+
+ else:
+ raise TypeError(
+ "sizes must be a single , , or a sequence (array, list, tuple) of int"
+ "or float with the length equal to the number of datapoints"
+ )
+
+ if np.count_nonzero(sizes < 0) > 1:
+ raise ValueError(
+ "All sizes must be positive numbers greater than or equal to 0.0."
+ )
+
+ return sizes
+
+ @block_reentrance
+ def __setitem__(
+ self,
+ key: int | slice | np.ndarray[int | bool] | list[int | bool],
+ value: int | float | np.ndarray | Sequence[int | float],
+ ):
+ # this is a very simple 1D buffer, no parsing required, directly set buffer
+ self.buffer.data[key] = value
+ self._update_range(key)
+
+ self._emit_event(self._property_name, key, value)
+
+ def __len__(self):
+ return len(self.buffer.data)
+
+
+class UniformSize(GraphicFeature):
+ event_info_spec = [
+ {"dict key": "value", "type": "float", "description": "new size value"},
+ ]
+
+ def __init__(self, value: int | float, property_name: str = "sizes"):
+ """Manages uniform size for scatter material"""
+
+ self._value = float(value)
+ super().__init__(property_name=property_name)
+
+ @property
+ def value(self) -> float:
+ return self._value
+
+ @block_reentrance
+ def set_value(self, graphic, value: float | int):
+ value = float(value)
+ graphic.world_object.material.size = value
+ self._value = value
+
+ event = GraphicFeatureEvent(type=self._property_name, info={"value": value})
+ self._call_event_handlers(event)
diff --git a/fastplotlib/graphics/features/_selection_features.py b/fastplotlib/graphics/features/_selection_features.py
index 233353401..9b30dd70c 100644
--- a/fastplotlib/graphics/features/_selection_features.py
+++ b/fastplotlib/graphics/features/_selection_features.py
@@ -1,9 +1,11 @@
from typing import Sequence
import numpy as np
+import pygfx as gfx
from ...utils import mesh_masks
from ._base import GraphicFeature, GraphicFeatureEvent, block_reentrance
+from ...utils.triangulation import triangulate
class LinearSelectionFeature(GraphicFeature):
@@ -38,7 +40,7 @@ def __init__(self, axis: str, value: float, limits: tuple[float, float]):
"""
- super().__init__()
+ super().__init__(property_name="selection")
self._axis = axis
self._limits = limits
@@ -62,13 +64,13 @@ def set_value(self, selector, value: float):
elif self._axis == "y":
dim = 1
- for edge in selector._edges:
- edge.geometry.positions.data[:, dim] = value
- edge.geometry.positions.update_range()
+ edge = selector._edges[0]
+ edge.geometry.positions.data[:, dim] = value
+ edge.geometry.positions.update_range()
self._value = value
- event = GraphicFeatureEvent("selection", {"value": value})
+ event = GraphicFeatureEvent(self._property_name, {"value": value})
event.get_selected_index = selector.get_selected_index
self._call_event_handlers(event)
@@ -97,7 +99,7 @@ class LinearRegionSelectionFeature(GraphicFeature):
]
def __init__(self, value: tuple[int, int], axis: str, limits: tuple[float, float]):
- super().__init__()
+ super().__init__(property_name="selection")
self._axis = axis
self._limits = limits
@@ -150,10 +152,10 @@ def set_value(self, selector, value: Sequence[float]):
selector.fill.geometry.positions.data[mesh_masks.x_right] = value[1]
# change x position of the left edge line
- selector.edges[0].geometry.positions.data[:, 0] = value[0]
+ selector._edges[0].geometry.positions.data[:, 0] = value[0]
# change x position of the right edge line
- selector.edges[1].geometry.positions.data[:, 0] = value[1]
+ selector._edges[1].geometry.positions.data[:, 0] = value[1]
elif self.axis == "y":
# change bottom y position of the fill mesh
@@ -163,24 +165,24 @@ def set_value(self, selector, value: Sequence[float]):
selector.fill.geometry.positions.data[mesh_masks.y_top] = value[1]
# change y position of the bottom edge line
- selector.edges[0].geometry.positions.data[:, 1] = value[0]
+ selector._edges[0].geometry.positions.data[:, 1] = value[0]
# change y position of the top edge line
- selector.edges[1].geometry.positions.data[:, 1] = value[1]
+ selector._edges[1].geometry.positions.data[:, 1] = value[1]
self._value = value
# send changes to GPU
selector.fill.geometry.positions.update_range()
- selector.edges[0].geometry.positions.update_range()
- selector.edges[1].geometry.positions.update_range()
+ selector._edges[0].geometry.positions.update_range()
+ selector._edges[1].geometry.positions.update_range()
# send event
if len(self._event_handlers) < 1:
return
- event = GraphicFeatureEvent("selection", {"value": self.value})
+ event = GraphicFeatureEvent(self._property_name, {"value": self.value})
event.get_selected_indices = selector.get_selected_indices
event.get_selected_data = selector.get_selected_data
@@ -218,7 +220,7 @@ def __init__(
value: tuple[float, float, float, float],
limits: tuple[float, float, float, float],
):
- super().__init__()
+ super().__init__(property_name="selection")
self._limits = limits
self._value = tuple(int(v) for v in value)
@@ -333,7 +335,112 @@ def set_value(self, selector, value: Sequence[float]):
if len(self._event_handlers) < 1:
return
- event = GraphicFeatureEvent("selection", {"value": self.value})
+ event = GraphicFeatureEvent(self._property_name, {"value": self.value})
+
+ event.get_selected_indices = selector.get_selected_indices
+ event.get_selected_data = selector.get_selected_data
+
+ # calls any events
+ self._call_event_handlers(event)
+
+
+class PolygonSelectionFeature(GraphicFeature):
+ event_info_spec = [
+ {
+ "dict key": "value",
+ "type": "np.ndarray",
+ "description": "new array of points that represents the polygon selection",
+ },
+ ]
+
+ event_extra_attrs = [
+ {
+ "attribute": "get_selected_indices",
+ "type": "callable",
+ "description": "returns indices under the selector",
+ },
+ {
+ "attribute": "get_selected_data",
+ "type": "callable",
+ "description": "returns data under the selector",
+ },
+ ]
+
+ def __init__(
+ self,
+ value: Sequence[tuple[float]],
+ limits: tuple[float, float, float, float],
+ ):
+ super().__init__(property_name="selection")
+
+ self._limits = limits
+ self._value = np.asarray(value).reshape(-1, 3).astype(float)
+
+ @property
+ def value(self) -> np.ndarray[float]:
+ """
+ The array of the polygon, in data space
+ """
+ return self._value
+
+ @block_reentrance
+ def set_value(self, selector, value: Sequence[tuple[float]]):
+ """
+ Set the selection of the rectangle selector.
+
+ Parameters
+ ----------
+ selector: PolygonSelector
+
+ value: array
+ new values (3D points) of the selection
+ """
+
+ value = np.asarray(value, dtype=np.float32)
+
+ if not value.shape[1] == 3:
+ raise TypeError(
+ "Selection must be an array, tuple, list, or sequence of the shape Nx3."
+ )
+
+ # clip values if they are beyond the limits
+ value[:, 0] = value[:, 0].clip(self._limits[0], self._limits[1])
+ value[:, 1] = value[:, 1].clip(self._limits[2], self._limits[3])
+
+ self._value = value
+
+ if len(value) >= 3:
+ indices = triangulate(value)
+ else:
+ indices = np.zeros((0, 3), np.int32)
+
+ geometry = selector.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(value)))))
+ if need_position_size != geometry.positions.nitems:
+ arr = np.zeros((need_position_size, 3), np.float32)
+ geometry.positions = gfx.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 = gfx.Buffer(arr)
+
+ geometry.positions.data[: len(value)] = value
+ geometry.positions.data[len(value) :] = value[-1] if len(value) else (0, 0, 0)
+ geometry.positions.draw_range = 0, len(value)
+ 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})
event.get_selected_indices = selector.get_selected_indices
event.get_selected_data = selector.get_selected_data
diff --git a/fastplotlib/graphics/features/_text.py b/fastplotlib/graphics/features/_text.py
index d8e5e95e8..ed0485d3a 100644
--- a/fastplotlib/graphics/features/_text.py
+++ b/fastplotlib/graphics/features/_text.py
@@ -16,7 +16,7 @@ class TextData(GraphicFeature):
def __init__(self, value: str):
self._value = value
- super().__init__()
+ super().__init__(property_name="text")
@property
def value(self) -> str:
@@ -27,7 +27,7 @@ def set_value(self, graphic, value: str):
graphic.world_object.set_text(value)
self._value = value
- event = GraphicFeatureEvent(type="text", info={"value": value})
+ event = GraphicFeatureEvent(type=self._property_name, info={"value": value})
self._call_event_handlers(event)
@@ -42,7 +42,7 @@ class FontSize(GraphicFeature):
def __init__(self, value: float | int):
self._value = value
- super().__init__()
+ super().__init__(property_name="font_size")
@property
def value(self) -> float | int:
@@ -53,7 +53,7 @@ def set_value(self, graphic, value: float | int):
graphic.world_object.font_size = value
self._value = graphic.world_object.font_size
- event = GraphicFeatureEvent(type="font_size", info={"value": value})
+ event = GraphicFeatureEvent(type=self._property_name, info={"value": value})
self._call_event_handlers(event)
@@ -68,7 +68,7 @@ class TextFaceColor(GraphicFeature):
def __init__(self, value: str | np.ndarray | list[float] | tuple[float]):
self._value = pygfx.Color(value)
- super().__init__()
+ super().__init__(property_name="face_color")
@property
def value(self) -> pygfx.Color:
@@ -80,7 +80,7 @@ def set_value(self, graphic, value: str | np.ndarray | list[float] | tuple[float
graphic.world_object.material.color = value
self._value = graphic.world_object.material.color
- event = GraphicFeatureEvent(type="face_color", info={"value": value})
+ event = GraphicFeatureEvent(type=self._property_name, info={"value": value})
self._call_event_handlers(event)
@@ -95,7 +95,7 @@ class TextOutlineColor(GraphicFeature):
def __init__(self, value: str | np.ndarray | list[float] | tuple[float]):
self._value = pygfx.Color(value)
- super().__init__()
+ super().__init__(property_name="outline_color")
@property
def value(self) -> pygfx.Color:
@@ -107,7 +107,7 @@ def set_value(self, graphic, value: str | np.ndarray | list[float] | tuple[float
graphic.world_object.material.outline_color = value
self._value = graphic.world_object.material.outline_color
- event = GraphicFeatureEvent(type="outline_color", info={"value": value})
+ event = GraphicFeatureEvent(type=self._property_name, info={"value": value})
self._call_event_handlers(event)
@@ -122,7 +122,7 @@ class TextOutlineThickness(GraphicFeature):
def __init__(self, value: float):
self._value = value
- super().__init__()
+ super().__init__(property_name="outline_thickness")
@property
def value(self) -> float:
@@ -133,5 +133,5 @@ def set_value(self, graphic, value: float):
graphic.world_object.material.outline_thickness = value
self._value = graphic.world_object.material.outline_thickness
- event = GraphicFeatureEvent(type="outline_thickness", info={"value": value})
+ event = GraphicFeatureEvent(type=self._property_name, info={"value": value})
self._call_event_handlers(event)
diff --git a/fastplotlib/graphics/features/_vectors.py b/fastplotlib/graphics/features/_vectors.py
new file mode 100644
index 000000000..9c86d25fc
--- /dev/null
+++ b/fastplotlib/graphics/features/_vectors.py
@@ -0,0 +1,187 @@
+import numpy as np
+import pylinalg as la
+
+from ._base import (
+ GraphicFeature,
+ GraphicFeatureEvent,
+ block_reentrance,
+)
+
+
+# it doesn't make sense to modify just a portion of a vector field, I can't think of a use case.
+# so we only allow setting the entire buffer, but allow getting portions of it
+class VectorPositions(GraphicFeature):
+ event_info_spec = [
+ {
+ "dict key": "value",
+ "type": "np.ndarray",
+ "description": "new vector positions",
+ },
+ ]
+
+ def __init__(
+ self,
+ positions: np.ndarray,
+ isolated_buffer: bool = True,
+ property_name: str = "positions",
+ ):
+ """
+ Manages vector field positions by managing the translation elements of the mesh instance transform matrix buffer
+ """
+
+ positions = np.asarray(positions, dtype=np.float32)
+ if positions.ndim != 2:
+ raise ValueError(
+ f"vector field positions must be of shape [n, 2] or [n, 3]"
+ )
+
+ if positions.shape[1] == 2:
+ positions = np.column_stack(
+ [
+ positions[:, 0],
+ positions[:, 1],
+ np.zeros(positions.shape[0], dtype=np.float32),
+ ]
+ )
+
+ elif positions.shape[1] == 3:
+ pass
+
+ else:
+ raise ValueError(
+ f"vector field positions must be of shape [n, 2] or [n, 3]"
+ )
+
+ self._positions = positions
+
+ super().__init__(property_name=property_name)
+
+ @property
+ def value(self) -> np.ndarray:
+ return self._positions
+
+ def __getitem__(self, item):
+ return self.value[item]
+
+ def __setitem__(self, key, value):
+ raise NotImplementedError(
+ "cannot set individual slices of vector positions, must set all positions"
+ )
+
+ @block_reentrance
+ def set_value(self, graphic, value: np.ndarray):
+ if value.shape[0] != self._positions.shape[0]:
+ raise ValueError(
+ f"number of vector positions in passed array != number of vectors in graphic: "
+ f"{value.shape[0]} != {self._positions.shape[0]}"
+ )
+
+ if value.shape[1] == 2:
+ # assume 2d
+ self._positions[:, :-1] = value
+
+ else:
+ self._positions[:] = value
+
+ for i in range(self._positions.shape[0]):
+ # only need to update the translation vector
+ graphic.world_object.instance_buffer.data["matrix"][i][3, 0:3] = (
+ self._positions[i]
+ )
+
+ graphic.world_object.instance_buffer.update_full()
+
+ event = GraphicFeatureEvent(type="positions", info={"value": value})
+ self._call_event_handlers(event)
+
+
+class VectorDirections(GraphicFeature):
+ event_info_spec = [
+ {
+ "dict key": "value",
+ "type": "np.ndarray",
+ "description": "new vector directions",
+ },
+ ]
+
+ # vector is always pointing in [0, 0, 1] when mesh is initialized
+ init_direction = np.array([0, 0, 1])
+ init_direction.flags.writeable = False
+
+ def __init__(
+ self,
+ directions: np.ndarray,
+ isolated_buffer: bool = True,
+ property_name: str = "directions",
+ ):
+ """Manages vector field positions by managing the mesh instance buffer's full transform matrix"""
+
+ directions = np.asarray(directions, dtype=np.float32)
+ if directions.ndim != 2:
+ raise ValueError(
+ f"vector field directions must be of shape [n, 2] or [n, 3]"
+ )
+
+ if directions.shape[1] == 2:
+ directions = np.column_stack(
+ [
+ directions[:, 0],
+ directions[:, 1],
+ np.zeros(directions.shape[0], dtype=np.float32),
+ ]
+ )
+
+ elif directions.shape[1] == 3:
+ pass
+
+ else:
+ raise ValueError(
+ f"vector field directions must be of shape [n, 2] or [n, 3]"
+ )
+
+ self._directions = directions
+
+ super().__init__(property_name=property_name)
+
+ @property
+ def value(self) -> np.ndarray:
+ return self._directions
+
+ def __getitem__(self, item):
+ return self.value[item]
+
+ def __setitem__(self, key, value):
+ raise NotImplementedError(
+ "cannot set individual slices of vector directions, must set all directions"
+ )
+
+ @block_reentrance
+ def set_value(self, graphic, value: np.ndarray):
+ if value.shape[0] != self._directions.shape[0]:
+ raise ValueError(
+ f"number of vector directions in passed array != number of vectors in graphic: "
+ f"{value.shape[0]} != {self._directions.shape[0]}"
+ )
+
+ if value.shape[1] == 2:
+ # assume 2d
+ self._directions[:, :-1] = value
+
+ else:
+ self._directions[:] = value
+
+ # vector determines the size of the vector
+ magnitudes = np.linalg.norm(self._directions, axis=1, ord=2)
+
+ for i in range(self._directions.shape[0]):
+ # get quaternion to rotate vector to new direction
+ rotation = la.quat_from_vecs(self.init_direction, self._directions[i])
+ # get the new transform
+ transform = la.mat_compose(graphic.positions[i], rotation, magnitudes[i])
+ # set the buffer
+ graphic.world_object.instance_buffer.data["matrix"][i] = transform.T
+
+ graphic.world_object.instance_buffer.update_full()
+
+ event = GraphicFeatureEvent(type="directions", info={"value": value})
+ self._call_event_handlers(event)
diff --git a/fastplotlib/graphics/features/_volume.py b/fastplotlib/graphics/features/_volume.py
new file mode 100644
index 000000000..ec4c4052a
--- /dev/null
+++ b/fastplotlib/graphics/features/_volume.py
@@ -0,0 +1,445 @@
+from itertools import product
+from math import ceil
+
+import numpy as np
+import pygfx
+
+from ._base import GraphicFeature, GraphicFeatureEvent, block_reentrance
+
+VOLUME_RENDER_MODES = {
+ "mip": pygfx.VolumeMipMaterial,
+ "minip": pygfx.VolumeMinipMaterial,
+ "iso": pygfx.VolumeIsoMaterial,
+ "slice": pygfx.VolumeSliceMaterial,
+}
+
+
+class TextureArrayVolume(GraphicFeature):
+ """
+ Manages an array of Textures representing chunks of an image. Chunk size is the GPU's max texture limit.
+
+ Creates and manages multiple pygfx.Texture objects.
+ """
+
+ event_info_spec = [
+ {
+ "dict key": "key",
+ "type": "slice, index, numpy-like fancy index",
+ "description": "key at which image data was sliced/fancy indexed",
+ },
+ {
+ "dict key": "value",
+ "type": "np.ndarray | float",
+ "description": "new data values",
+ },
+ ]
+
+ def __init__(self, data, isolated_buffer: bool = True):
+ super().__init__(property_name="data")
+
+ data = self._fix_data(data)
+
+ shared = pygfx.renderers.wgpu.get_shared()
+
+ self._texture_size_limit = shared.device.limits["max-texture-dimension-3d"]
+
+ if isolated_buffer:
+ # useful if data is read-only, example: memmaps
+ self._value = np.zeros(data.shape, dtype=data.dtype)
+ self.value[:] = data[:]
+ else:
+ # user's input array is used as the buffer
+ self._value = data
+
+ # data start indices for each Texture
+ self._row_indices = np.arange(
+ 0,
+ ceil(self.value.shape[1] / self._texture_size_limit)
+ * self._texture_size_limit,
+ self._texture_size_limit,
+ )
+ self._col_indices = np.arange(
+ 0,
+ ceil(self.value.shape[2] / self._texture_size_limit)
+ * self._texture_size_limit,
+ self._texture_size_limit,
+ )
+
+ self._zdim_indices = np.arange(
+ 0,
+ ceil(self.value.shape[0] / self._texture_size_limit)
+ * self._texture_size_limit,
+ self._texture_size_limit,
+ )
+
+ shape = (self.zdim_indices.size, self.row_indices.size, self.col_indices.size)
+
+ # buffer will be an array of textures
+ self._buffer: np.ndarray[pygfx.Texture] = np.empty(shape=shape, dtype=object)
+
+ self._iter = None
+
+ # iterate through each chunk of passed `data`
+ # create a pygfx.Texture from this chunk
+ for _, buffer_index, data_slice in self:
+ texture = pygfx.Texture(self.value[data_slice], dim=3)
+
+ self.buffer[buffer_index] = texture
+
+ @property
+ def value(self) -> np.ndarray:
+ """The full array that represents all the data within this TextureArray"""
+ return self._value
+
+ def set_value(self, graphic, value):
+ self[:] = value
+
+ @property
+ def buffer(self) -> np.ndarray[pygfx.Texture]:
+ """array of buffers that are mapped to the GPU"""
+ return self._buffer
+
+ @property
+ def row_indices(self) -> np.ndarray:
+ """
+ row indices that are used to chunk the big data array
+ into individual Textures on the GPU
+ """
+ return self._row_indices
+
+ @property
+ def col_indices(self) -> np.ndarray:
+ """
+ column indices that are used to chunk the big data array
+ into individual Textures on the GPU
+ """
+ return self._col_indices
+
+ @property
+ def zdim_indices(self) -> np.ndarray:
+ """
+ z dimension indices that are used to chunk the big data array
+ into individual Textures on the GPU
+ """
+ return self._zdim_indices
+
+ def _fix_data(self, data):
+ if data.ndim not in (3, 4):
+ raise ValueError(
+ "Volume Image data must be 3D with or without an RGB(A) dimension, i.e. "
+ "it must be of shape [z, rows, cols], [z, rows, cols, 3] or [z, rows, cols, 4]"
+ )
+
+ # let's just cast to float32 always
+ return data.astype(np.float32)
+
+ def __iter__(self):
+ self._iter = product(
+ enumerate(self.zdim_indices),
+ enumerate(self.row_indices),
+ enumerate(self.col_indices),
+ )
+
+ return self
+
+ def __next__(
+ self,
+ ) -> tuple[pygfx.Texture, tuple[int, int, int], tuple[slice, slice, slice]]:
+ """
+ Iterate through each Texture within the texture array
+
+ Returns
+ -------
+ Texture, tuple[int, int], tuple[slice, slice]
+ | Texture: pygfx.Texture
+ | tuple[int, int]: chunk index, i.e corresponding index of ``self.buffer`` array
+ | tuple[slice, slice]: data slice of big array in this chunk and Texture
+ """
+ # chunk indices
+ (
+ (chunk_z, data_z_start),
+ (chunk_row, data_row_start),
+ (chunk_col, data_col_start),
+ ) = next(self._iter)
+
+ # indices for to self.buffer for this chunk
+ chunk_index = (chunk_z, chunk_row, chunk_col)
+
+ # stop indices of big data array for this chunk
+ z_stop = min(self.value.shape[0], data_z_start + self._texture_size_limit)
+ row_stop = min(self.value.shape[1], data_row_start + self._texture_size_limit)
+ col_stop = min(self.value.shape[2], data_col_start + self._texture_size_limit)
+
+ # zdim, row and column slices that slice the data for this chunk from the big data array
+ data_slice = (
+ slice(data_z_start, z_stop),
+ slice(data_row_start, row_stop),
+ slice(data_col_start, col_stop),
+ )
+
+ # texture for this chunk
+ texture = self.buffer[chunk_index]
+
+ return texture, chunk_index, data_slice
+
+ def __getitem__(self, item):
+ return self.value[item]
+
+ @block_reentrance
+ def __setitem__(self, key, value):
+ self.value[key] = value
+
+ for texture in self.buffer.ravel():
+ texture.update_range((0, 0, 0), texture.size)
+
+ event = GraphicFeatureEvent(
+ self._property_name, info={"key": key, "value": value}
+ )
+ self._call_event_handlers(event)
+
+ def __len__(self):
+ return self.buffer.size
+
+
+def create_volume_material_kwargs(graphic, mode: str):
+ kwargs = {
+ "clim": (graphic.vmin, graphic.vmax),
+ "map": graphic._texture_map,
+ "interpolation": graphic.interpolation,
+ "pick_write": True,
+ }
+
+ if mode == "iso":
+ more_kwargs = {
+ attr: getattr(graphic, attr)
+ for attr in [
+ "threshold",
+ "step_size",
+ "substep_size",
+ "emissive",
+ "shininess",
+ ]
+ }
+
+ elif mode == "slice":
+ more_kwargs = {"plane": graphic.plane}
+ else:
+ more_kwargs = {}
+
+ kwargs.update(more_kwargs)
+ return kwargs
+
+
+class VolumeRenderMode(GraphicFeature):
+ """Volume rendering mode, controls world object material"""
+
+ event_info_spec = [
+ {
+ "dict key": "value",
+ "type": "str",
+ "description": "volume rendering mode that has been set",
+ },
+ ]
+
+ def __init__(self, value: str):
+ self._validate(value)
+ self._value = value
+ super().__init__(property_name="mode")
+
+ @property
+ def value(self) -> str:
+ return self._value
+
+ def _validate(self, value):
+ if value not in VOLUME_RENDER_MODES.keys():
+ raise ValueError(
+ f"Given render mode: {value} is invalid. Valid render modes are: {VOLUME_RENDER_MODES.keys()}"
+ )
+
+ @block_reentrance
+ def set_value(self, graphic, value: str):
+ self._validate(value)
+
+ VolumeMaterialCls = VOLUME_RENDER_MODES[value]
+
+ kwargs = create_volume_material_kwargs(graphic, mode=value)
+
+ new_material = VolumeMaterialCls(**kwargs)
+ # since the world object is a group
+ for volume_tile in graphic.world_object.children:
+ volume_tile.material = new_material
+
+ # so we have one place to reference it
+ graphic._material = new_material
+ self._value = value
+
+ event = GraphicFeatureEvent(type=self._property_name, info={"value": value})
+ self._call_event_handlers(event)
+
+
+class VolumeIsoThreshold(GraphicFeature):
+ """Isosurface threshold"""
+
+ event_info_spec = [
+ {
+ "dict key": "value",
+ "type": "float",
+ "description": "new isosurface threshold",
+ },
+ ]
+
+ def __init__(self, value: float):
+ self._value = value
+ super().__init__(property_name="threshold")
+
+ @property
+ def value(self) -> float:
+ return self._value
+
+ @block_reentrance
+ def set_value(self, graphic, value: float):
+ graphic._material.threshold = value
+ self._value = graphic._material.threshold
+
+ event = GraphicFeatureEvent(type=self._property_name, info={"value": value})
+ self._call_event_handlers(event)
+
+
+class VolumeIsoStepSize(GraphicFeature):
+ """Isosurface step_size"""
+
+ event_info_spec = [
+ {
+ "dict key": "value",
+ "type": "float",
+ "description": "new isosurface step_size",
+ },
+ ]
+
+ def __init__(self, value: float):
+ self._value = value
+ super().__init__(property_name="step_size")
+
+ @property
+ def value(self) -> float:
+ return self._value
+
+ @block_reentrance
+ def set_value(self, graphic, value: float):
+ graphic._material.step_size = value
+ self._value = graphic._material.step_size
+
+ event = GraphicFeatureEvent(type=self._property_name, info={"value": value})
+ self._call_event_handlers(event)
+
+
+class VolumeIsoSubStepSize(GraphicFeature):
+ """Isosurface substep_size"""
+
+ event_info_spec = [
+ {
+ "dict key": "value",
+ "type": "float",
+ "description": "new isosurface step_size",
+ },
+ ]
+
+ def __init__(self, value: float):
+ self._value = value
+ super().__init__(property_name="substep_size")
+
+ @property
+ def value(self) -> float:
+ return self._value
+
+ @block_reentrance
+ def set_value(self, graphic, value: float):
+ graphic._material.substep_size = value
+ self._value = graphic._material.substep_size
+
+ event = GraphicFeatureEvent(type=self._property_name, info={"value": value})
+ self._call_event_handlers(event)
+
+
+class VolumeIsoEmissive(GraphicFeature):
+ """Isosurface emissive color"""
+
+ event_info_spec = [
+ {
+ "dict key": "value",
+ "type": "pygfx.Color",
+ "description": "new isosurface emissive color",
+ },
+ ]
+
+ def __init__(self, value: pygfx.Color | str | tuple | np.ndarray):
+ self._value = pygfx.Color(value)
+ super().__init__(property_name="emissive")
+
+ @property
+ def value(self) -> pygfx.Color:
+ return self._value
+
+ @block_reentrance
+ def set_value(self, graphic, value: pygfx.Color | str | tuple | np.ndarray):
+ graphic._material.emissive = value
+ self._value = graphic._material.emissive
+
+ event = GraphicFeatureEvent(type=self._property_name, info={"value": value})
+ self._call_event_handlers(event)
+
+
+class VolumeIsoShininess(GraphicFeature):
+ """Isosurface shininess"""
+
+ event_info_spec = [
+ {
+ "dict key": "value",
+ "type": "int",
+ "description": "new isosurface shininess",
+ },
+ ]
+
+ def __init__(self, value: int):
+ self._value = value
+ super().__init__(property_name="shininess")
+
+ @property
+ def value(self) -> int:
+ return self._value
+
+ @block_reentrance
+ def set_value(self, graphic, value: float):
+ graphic._material.shininess = value
+ self._value = graphic._material.shininess
+
+ event = GraphicFeatureEvent(type=self._property_name, info={"value": value})
+ self._call_event_handlers(event)
+
+
+class VolumeSlicePlane(GraphicFeature):
+ """Volume plane"""
+
+ event_info_spec = [
+ {
+ "dict key": "value",
+ "type": "tuple[float, float, float, float]",
+ "description": "new plane slice",
+ },
+ ]
+
+ def __init__(self, value: tuple[float, float, float, float]):
+ self._value = value
+ super().__init__(property_name="plane")
+
+ @property
+ def value(self) -> tuple[float, float, float, float]:
+ return self._value
+
+ @block_reentrance
+ def set_value(self, graphic, value: tuple[float, float, float, float]):
+ graphic._material.plane = value
+ self._value = graphic._material.plane
+
+ event = GraphicFeatureEvent(type=self._property_name, info={"value": value})
+ self._call_event_handlers(event)
diff --git a/fastplotlib/graphics/features/utils.py b/fastplotlib/graphics/features/utils.py
index e2f6e3428..aa4022052 100644
--- a/fastplotlib/graphics/features/utils.py
+++ b/fastplotlib/graphics/features/utils.py
@@ -6,9 +6,7 @@
def parse_colors(
- colors: str | np.ndarray | list[str] | tuple[str],
- n_colors: int | None,
- alpha: float | None = None,
+ colors: str | np.ndarray | list[str] | tuple[str], n_colors: int | None
):
"""
@@ -16,8 +14,6 @@ def parse_colors(
----------
colors
n_colors
- alpha
- key
Returns
-------
@@ -30,20 +26,23 @@ def parse_colors(
colors = colors.tolist()
# if the color is provided as a numpy array
if isinstance(colors, np.ndarray):
- if colors.shape == (4,): # single RGBA array
+ if colors.shape == (3,): # single RGB array
+ data = np.repeat(np.array([colors]), n_colors, axis=0)
+ elif colors.shape == (4,): # single RGBA array
data = np.repeat(np.array([colors]), n_colors, axis=0)
# else assume it's already a stack of RGBA arrays, keep this directly as the data
elif colors.ndim == 2:
- if colors.shape[1] != 4 and colors.shape[0] != n_colors:
+ 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 "
- "RGBA arrays for each datapoint in the shape [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:
raise ValueError(
- "Valid array color arguments must be a single RGBA array or a stack of "
- "RGBA arrays for each datapoint in the shape [n_datapoints, 4]"
+ "Valid array color arguments must be a single RGB(A) array or a stack of "
+ "RGB(A) arrays for each datapoint in the shape [n_datapoints, 3] or [n_datapoints, 4]"
)
# if the color is provided as list or tuple
@@ -58,8 +57,8 @@ def parse_colors(
data = np.vstack([np.array(pygfx.Color(c)) for c in colors])
- # if it's a single RGBA array as a tuple/list
- elif len(colors) == 4:
+ # if it's a single RGB/RGBA array as a tuple/list
+ elif len(colors) in (3, 4):
c = pygfx.Color(colors)
data = np.repeat(np.array([c]), n_colors, axis=0)
@@ -70,18 +69,11 @@ def parse_colors(
)
elif isinstance(colors, str):
if colors == "random":
- data = np.random.rand(n_colors, 4)
- data[:, -1] = alpha
+ data = np.random.rand(n_colors, 3)
else:
data = make_pygfx_colors(colors, n_colors)
else:
# assume it's a single color, use pygfx.Color to parse it
data = make_pygfx_colors(colors, n_colors)
- if alpha is not None:
- if isinstance(alpha, float):
- data[:, -1] = alpha
- else:
- raise TypeError("if alpha is provided it must be of type `float`")
-
return to_gpu_supported_dtype(data)
diff --git a/fastplotlib/graphics/image.py b/fastplotlib/graphics/image.py
index 957607fe1..44bffcedc 100644
--- a/fastplotlib/graphics/image.py
+++ b/fastplotlib/graphics/image.py
@@ -5,7 +5,12 @@
from ..utils import quick_min_max
from ._base import Graphic
-from .selectors import LinearSelector, LinearRegionSelector, RectangleSelector
+from .selectors import (
+ LinearSelector,
+ LinearRegionSelector,
+ RectangleSelector,
+ PolygonSelector,
+)
from .features import (
TextureArray,
ImageCmap,
@@ -16,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
@@ -83,8 +97,8 @@ class ImageGraphic(Graphic):
def __init__(
self,
data: Any,
- vmin: int = None,
- vmax: int = None,
+ vmin: float = None,
+ vmax: float = None,
cmap: str = "plasma",
interpolation: str = "nearest",
cmap_interpolation: str = "linear",
@@ -100,11 +114,11 @@ def __init__(
array-like, usually numpy.ndarray, must support ``memoryview()``
| shape must be ``[n_rows, n_cols]``, ``[n_rows, n_cols, 3]`` for RGB or ``[n_rows, n_cols, 4]`` for RGBA
- vmin: int, optional
- minimum value for color scaling, calculated from data if not provided
+ vmin: float, optional
+ minimum value for color scaling, estimated from data if not provided
- vmax: int, optional
- maximum value for color scaling, calculated from data if not provided
+ vmax: float, optional
+ maximum value for color scaling, estimated from data if not provided
cmap: str, optional, default "plasma"
colormap to use to display the data. For supported colormaps see the
@@ -131,11 +145,20 @@ def __init__(
world_object = pygfx.Group()
- # texture array that manages the textures on the GPU for displaying this image
- self._data = TextureArray(data, isolated_buffer=isolated_buffer)
+ if isinstance(data, TextureArray):
+ # share buffer
+ self._data = data
+ else:
+ # create new texture array to manage buffer
+ # texture array that manages the multiple textures on the GPU that represent this image
+ self._data = TextureArray(data, isolated_buffer=isolated_buffer)
if (vmin is None) or (vmax is None):
- vmin, vmax = quick_min_max(data)
+ _vmin, _vmax = quick_min_max(self.data.value)
+ if vmin is None:
+ vmin = _vmin
+ if vmax is None:
+ vmax = _vmax
# other graphic features
self._vmin = ImageVmin(vmin)
@@ -167,9 +190,8 @@ def __init__(
)
# iterate through each texture chunk and create
- # an _ImageTIle, offset the tile using the data indices
+ # an _ImageTile, offset the tile using the data indices
for texture, chunk_index, data_slice in self._data:
-
# create an ImageTile using the texture for this chunk
img = _ImageTile(
geometry=pygfx.Geometry(grid=texture),
@@ -201,15 +223,16 @@ def data(self, data):
self._data[:] = data
@property
- def cmap(self) -> str:
+ def cmap(self) -> str | None:
"""
- Get or set the colormap
+ Get or set the colormap for grayscale images. Returns ``None`` if image is RGB(A).
For supported colormaps see the ``cmap`` library catalogue: https://cmap-docs.readthedocs.io/en/stable/catalog/
"""
- if self.data.value.ndim > 2:
- raise AttributeError("RGB(A) images do not have a colormap property")
- return self._cmap.value
+ if self._cmap is not None:
+ return self._cmap.value
+
+ return None
@cmap.setter
def cmap(self, name: str):
@@ -310,9 +333,6 @@ def add_linear_selector(
self._plot_area.add_graphic(selector, center=False)
- # place selector above this graphic
- selector.offset = selector.offset + (0.0, 0.0, self.offset[-1] + 1)
-
return selector
def add_linear_region_selector(
@@ -388,9 +408,6 @@ def add_linear_region_selector(
self._plot_area.add_graphic(selector, center=False)
- # place above this graphic
- selector.offset = selector.offset + (0.0, 0.0, self.offset[-1] + 1)
-
return selector
def add_rectangle_selector(
@@ -433,7 +450,52 @@ def add_rectangle_selector(
self._plot_area.add_graphic(selector, center=False)
- # place above this graphic
- selector.offset = selector.offset + (0.0, 0.0, self.offset[-1] + 1)
+ return selector
+
+ def add_polygon_selector(
+ self,
+ selection: List[tuple[float, float]] = None,
+ fill_color=(0, 0, 0.35, 0.2),
+ **kwargs,
+ ) -> PolygonSelector:
+ """
+ Add a :class:`.PolygonSelector`.
+
+ Selectors are just ``Graphic`` objects, so you can manage, remove, or delete them
+ from a plot area just like any other ``Graphic``.
+
+ Parameters
+ ----------
+ 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).
+
+ """
+
+ # min/max limits are image shape
+ # rows are ys, columns are xs
+ limits = (0, self._data.value.shape[1], 0, self._data.value.shape[0])
+
+ selector = PolygonSelector(
+ selection,
+ limits,
+ fill_color=fill_color,
+ parent=self,
+ **kwargs,
+ )
+
+ 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
new file mode 100644
index 000000000..db8f29eaa
--- /dev/null
+++ b/fastplotlib/graphics/image_volume.py
@@ -0,0 +1,436 @@
+from typing import *
+
+import numpy as np
+import pygfx
+
+from ..utils import quick_min_max
+from ._base import Graphic
+from .features import (
+ TextureArrayVolume,
+ ImageCmap,
+ ImageVmin,
+ ImageVmax,
+ ImageInterpolation,
+ ImageCmapInterpolation,
+ VolumeRenderMode,
+ VolumeIsoThreshold,
+ VolumeIsoStepSize,
+ VolumeIsoSubStepSize,
+ VolumeIsoEmissive,
+ VolumeIsoShininess,
+ VolumeSlicePlane,
+ VOLUME_RENDER_MODES,
+ create_volume_material_kwargs,
+)
+
+
+class _VolumeTile(pygfx.Volume):
+ """
+ Similar to pygfx.Volume, only difference is that it modifies the pick_info
+ by adding the data row start indices that correspond to this chunk of the big Volume
+ """
+
+ def __init__(
+ self,
+ geometry,
+ material,
+ data_slice: tuple[slice, slice, slice],
+ chunk_index: tuple[int, int, int],
+ **kwargs,
+ ):
+ super().__init__(geometry, material, **kwargs)
+
+ self._data_slice = data_slice
+ self._chunk_index = chunk_index
+
+ def _wgpu_get_pick_info(self, pick_value):
+ pick_info = super()._wgpu_get_pick_info(pick_value)
+
+ data_z_start, data_row_start, data_col_start = (
+ self.data_slice[0].start,
+ self.data_slice[1].start,
+ self.data_slice[2].start,
+ )
+
+ # add the actual data row and col start indices
+ x, y, z = pick_info["index"]
+ x += data_col_start
+ y += data_row_start
+ z += data_z_start
+ pick_info["index"] = (x, y, z)
+
+ xp, yp, zp = pick_info["voxel_coord"]
+ xp += data_col_start
+ yp += data_row_start
+ zp += data_z_start
+ pick_info["voxel_coord"] = (xp, yp, zp)
+
+ # add row chunk and col chunk index to pick_info dict
+ return {
+ **pick_info,
+ "data_slice": self.data_slice,
+ "chunk_index": self.chunk_index,
+ }
+
+ @property
+ def data_slice(self) -> tuple[slice, slice, slice]:
+ return self._data_slice
+
+ @property
+ def chunk_index(self) -> tuple[int, int, int]:
+ return self._chunk_index
+
+
+class ImageVolumeGraphic(Graphic):
+ _features = {
+ "data": TextureArrayVolume,
+ "cmap": ImageCmap,
+ "vmin": ImageVmin,
+ "vmax": ImageVmax,
+ "interpolation": ImageInterpolation,
+ "cmap_interpolation": ImageCmapInterpolation,
+ "mode": VolumeRenderMode,
+ "threshold": VolumeIsoThreshold,
+ "step_size": VolumeIsoStepSize,
+ "substep_size": VolumeIsoSubStepSize,
+ "emissive": VolumeIsoEmissive,
+ "shininess": VolumeIsoShininess,
+ "plane": VolumeSlicePlane,
+ }
+
+ def __init__(
+ self,
+ data: Any,
+ mode: str = "mip",
+ vmin: float = None,
+ vmax: float = None,
+ cmap: str = "plasma",
+ interpolation: str = "linear",
+ cmap_interpolation: str = "linear",
+ plane: tuple[float, float, float, float] = (0, 0, -1, 0),
+ threshold: float = 0.5,
+ step_size: float = 1.0,
+ substep_size: float = 0.1,
+ emissive: str | tuple | np.ndarray = (0, 0, 0),
+ shininess: int = 30,
+ isolated_buffer: bool = True,
+ **kwargs,
+ ):
+ """
+ Create an ImageVolumeGraphic.
+
+ Parameters
+ ----------
+ data: array-like
+ array-like, usually numpy.ndarray, must support ``memoryview()``.
+ Shape must be [n_planes, n_rows, n_cols] for grayscale, or [n_planes, n_rows, n_cols, 3 | 4] for RGB(A)
+
+ mode: str, default "mip"
+ render mode, one of "mip", "minip", "iso" or "slice"
+
+ vmin: float
+ lower contrast limit
+
+ vmax: float
+ upper contrast limit
+
+ cmap: str, default "plasma"
+ colormap for grayscale volumes
+
+ interpolation: str, default "linear"
+ interpolation method for sampling pixels
+
+ cmap_interpolation: str, default "linear"
+ interpolation method for sampling from colormap
+
+ plane: (float, float, float, float), default (0, 0, -1, 0)
+ Slice volume 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"
+
+ threshold : float, default 0.5
+ The threshold texture value at which the surface is rendered.
+ Used only if `mode` = "iso"
+
+ step_size : float, default 1.0
+ The size of the initial ray marching step for the initial surface finding. Smaller values will result in
+ more accurate surfaces but slower rendering.
+ Used only if `mode` = "iso"
+
+ substep_size : float, default 0.1
+ The size of the raymarching step for the refined surface finding. Smaller values will result in more
+ accurate surfaces but slower rendering.
+ Used only if `mode` = "iso"
+
+ emissive : Color, default (0, 0, 0, 1)
+ The emissive color of the surface. I.e. the color that the object emits even when not lit by a light
+ source. This color is added to the final color and unaffected by lighting. The alpha channel is ignored.
+ Used only if `mode` = "iso"
+
+ shininess : int, default 30
+ How shiny the specular highlight is; a higher value gives a sharper highlight.
+ Used only if `mode` = "iso"
+
+ 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.
+
+ kwargs
+ additional keyword arguments passed to :class:`.Graphic`
+
+ """
+
+ valid_modes = VOLUME_RENDER_MODES.keys()
+ if mode not in valid_modes:
+ raise ValueError(
+ f"invalid mode specified: {mode}, valid modes are: {valid_modes}"
+ )
+
+ super().__init__(**kwargs)
+
+ world_object = pygfx.Group()
+
+ if isinstance(data, TextureArrayVolume):
+ # share existing buffer
+ self._data = data
+ else:
+ # create new texture array to manage buffer
+ # texture array that manages the textures on the GPU that represent this image volume
+ self._data = TextureArrayVolume(data, isolated_buffer=isolated_buffer)
+
+ if (vmin is None) or (vmax is None):
+ _vmin, _vmax = quick_min_max(self.data.value)
+ if vmin is None:
+ vmin = _vmin
+ if vmax is None:
+ vmax = _vmax
+
+ # other graphic features
+ self._vmin = ImageVmin(vmin)
+ self._vmax = ImageVmax(vmax)
+
+ self._interpolation = ImageInterpolation(interpolation)
+
+ # TODO: I'm assuming RGB volume images aren't supported???
+ # use TextureMap for grayscale images
+ self._cmap = ImageCmap(cmap)
+ self._cmap_interpolation = ImageCmapInterpolation(cmap_interpolation)
+
+ self._texture_map = pygfx.TextureMap(
+ self._cmap.texture,
+ filter=self._cmap_interpolation.value,
+ wrap="clamp-to-edge",
+ )
+
+ self._plane = VolumeSlicePlane(plane)
+ self._threshold = VolumeIsoThreshold(threshold)
+ self._step_size = VolumeIsoStepSize(step_size)
+ self._substep_size = VolumeIsoSubStepSize(substep_size)
+ self._emissive = VolumeIsoEmissive(emissive)
+ self._shininess = VolumeIsoShininess(shininess)
+
+ material_kwargs = create_volume_material_kwargs(graphic=self, mode=mode)
+
+ VolumeMaterialCls = VOLUME_RENDER_MODES[mode]
+
+ self._material = VolumeMaterialCls(**material_kwargs)
+
+ self._mode = VolumeRenderMode(mode)
+
+ # iterate through each texture chunk and create
+ # a _VolumeTile, offset the tile using the data indices
+ for texture, chunk_index, data_slice in self._data:
+ # create a _VolumeTile using the texture for this chunk
+ vol = _VolumeTile(
+ geometry=pygfx.Geometry(grid=texture),
+ material=self._material,
+ data_slice=data_slice, # used to parse pick_info
+ chunk_index=chunk_index,
+ )
+
+ # row and column start index for this chunk
+ data_z_start = data_slice[0].start
+ data_row_start = data_slice[1].start
+ data_col_start = data_slice[2].start
+
+ # offset tile position using the indices from the big data array
+ # that correspond to this chunk
+ vol.world.z = data_z_start
+ vol.world.x = data_col_start
+ vol.world.y = data_row_start
+
+ world_object.add(vol)
+
+ self._set_world_object(world_object)
+
+ @property
+ def data(self) -> TextureArrayVolume:
+ """Get or set the image data"""
+ return self._data
+
+ @data.setter
+ def data(self, data):
+ self._data[:] = data
+
+ @property
+ def mode(self) -> str:
+ """Get or set the volume rendering mode"""
+ return self._mode.value
+
+ @mode.setter
+ def mode(self, mode: str):
+ self._mode.set_value(self, mode)
+
+ @property
+ def cmap(self) -> str:
+ """Get or set colormap name"""
+ return self._cmap.value
+
+ @cmap.setter
+ def cmap(self, name: str):
+ self._cmap.set_value(self, name)
+
+ @property
+ def vmin(self) -> float:
+ """Get or set the lower contrast limit"""
+ return self._vmin.value
+
+ @vmin.setter
+ def vmin(self, value: float):
+ self._vmin.set_value(self, value)
+
+ @property
+ def vmax(self) -> float:
+ """Get or set the upper contrast limit"""
+ return self._vmax.value
+
+ @vmax.setter
+ def vmax(self, value: float):
+ self._vmax.set_value(self, value)
+
+ @property
+ def interpolation(self) -> str:
+ """Get or set the image data interpolation method"""
+ return self._interpolation.value
+
+ @interpolation.setter
+ def interpolation(self, value: str):
+ self._interpolation.set_value(self, value)
+
+ @property
+ def cmap_interpolation(self) -> str:
+ """Get or set the cmap interpolation method"""
+ return self._cmap_interpolation.value
+
+ @cmap_interpolation.setter
+ def cmap_interpolation(self, value: str):
+ self._cmap_interpolation.set_value(self, value)
+
+ @property
+ def plane(self) -> tuple[float, float, float, float]:
+ """Get or set displayed plane in the volume. Valid only for `slice` render mode."""
+ 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)
+
+ @property
+ def threshold(self) -> float:
+ """Get or set isosurface threshold, only for `iso` mode"""
+ return self._threshold.value
+
+ @threshold.setter
+ def threshold(self, value: float):
+ if self.mode != "iso":
+ raise TypeError(
+ "`threshold` property is only used for `iso` rendering mode"
+ )
+
+ self._threshold.set_value(self, value)
+
+ @property
+ def step_size(self) -> float:
+ """Get or set isosurface step_size, only for `iso` mode"""
+ return self._step_size.value
+
+ @step_size.setter
+ def step_size(self, value: float):
+ if self.mode != "iso":
+ raise TypeError(
+ "`step_size` property is only used for `iso` rendering mode"
+ )
+
+ self._step_size.set_value(self, value)
+
+ @property
+ def substep_size(self) -> float:
+ """Get or set isosurface substep_size, only for `iso` mode"""
+ return self._substep_size.value
+
+ @substep_size.setter
+ def substep_size(self, value: float):
+ if self.mode != "iso":
+ raise TypeError(
+ "`substep_size` property is only used for `iso` rendering mode"
+ )
+
+ self._substep_size.set_value(self, value)
+
+ @property
+ def emissive(self) -> pygfx.Color:
+ """Get or set isosurface emissive color, only for `iso` mode. Pass a color, RGBA array or pygfx.Color"""
+ return self._emissive.value
+
+ @emissive.setter
+ def emissive(self, value: pygfx.Color | str | tuple | np.ndarray):
+ if self.mode != "iso":
+ raise TypeError("`emissive` property is only used for `iso` rendering mode")
+
+ self._emissive.set_value(self, value)
+
+ @property
+ def shininess(self) -> int:
+ """Get or set isosurface shininess"""
+ return self._shininess.value
+
+ @shininess.setter
+ def shininess(self, value: int):
+ if self.mode != "iso":
+ raise TypeError(
+ "`shininess` property is only used for `iso` rendering mode"
+ )
+
+ self._shininess.set_value(self, value)
+
+ def reset_vmin_vmax(self):
+ """
+ Reset the vmin, vmax by *estimating* it from the data
+
+ Returns
+ -------
+ None
+
+ """
+
+ 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 4cdc7f413..a4f42704f 100644
--- a/fastplotlib/graphics/line.py
+++ b/fastplotlib/graphics/line.py
@@ -5,7 +5,12 @@
import pygfx
from ._positions_base import PositionsGraphic
-from .selectors import LinearRegionSelector, LinearSelector, RectangleSelector
+from .selectors import (
+ LinearRegionSelector,
+ LinearSelector,
+ RectangleSelector,
+ PolygonSelector,
+)
from .features import (
Thickness,
VertexPositions,
@@ -32,7 +37,6 @@ def __init__(
thickness: float = 2.0,
colors: str | np.ndarray | Sequence = "w",
uniform_color: bool = False,
- alpha: float = 1.0,
cmap: str = None,
cmap_transform: np.ndarray | Sequence = None,
isolated_buffer: bool = True,
@@ -61,9 +65,6 @@ def __init__(
if True, uses a uniform buffer for the line color,
basically saves GPU VRAM when the entire line has a single color
- alpha: float, optional, default 1.0
- alpha value for the colors
-
cmap: str, optional
Apply a colormap to the line instead of assigning colors manually, this
overrides any argument passed to "colors". For supported colormaps see the
@@ -84,7 +85,6 @@ def __init__(
data=data,
colors=colors,
uniform_color=uniform_color,
- alpha=alpha,
cmap=cmap,
cmap_transform=cmap_transform,
isolated_buffer=isolated_buffer,
@@ -96,24 +96,31 @@ def __init__(
if thickness < 1.1:
MaterialCls = pygfx.LineThinMaterial
+ aa = True
else:
MaterialCls = pygfx.LineMaterial
+ aa = kwargs.get("alpha_mode", "auto") in ("blend", "weighted_blend")
+
if uniform_color:
geometry = pygfx.Geometry(positions=self._data.buffer)
material = MaterialCls(
+ aa=aa,
thickness=self.thickness,
color_mode="uniform",
color=self.colors,
pick_write=True,
thickness_space=self.size_space,
+ depth_compare="<=",
)
else:
material = MaterialCls(
+ aa=aa,
thickness=self.thickness,
color_mode="vertex",
pick_write=True,
thickness_space=self.size_space,
+ depth_compare="<=",
)
geometry = pygfx.Geometry(
positions=self._data.buffer, colors=self._colors.buffer
@@ -175,9 +182,6 @@ def add_linear_selector(
self._plot_area.add_graphic(selector, center=False)
- # place selector above this graphic
- selector.offset = selector.offset + (0.0, 0.0, self.offset[-1] + 1)
-
return selector
def add_linear_region_selector(
@@ -234,9 +238,6 @@ def add_linear_region_selector(
self._plot_area.add_graphic(selector, center=False)
- # place selector below this graphic
- selector.offset = selector.offset + (0.0, 0.0, self.offset[-1] - 1)
-
# PlotArea manages this for garbage collection etc. just like all other Graphics
# so we should only work with a proxy on the user-end
return selector
@@ -288,6 +289,46 @@ def add_rectangle_selector(
return selector
+ def add_polygon_selector(
+ self,
+ selection: List[tuple[float, float]] = None,
+ **kwargs,
+ ) -> PolygonSelector:
+ """
+ Add a :class:`.PolygonSelector`.
+
+ Selectors are just ``Graphic`` objects, so you can manage, remove, or delete them from a
+ plot area just like any other ``Graphic``.
+
+ Parameters
+ ----------
+ 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).
+ """
+
+ # remove any nans
+ data = self.data.value[~np.any(np.isnan(self.data.value), axis=1)]
+
+ x_axis_vals = data[:, 0]
+ y_axis_vals = data[:, 1]
+
+ ymin = np.floor(y_axis_vals.min()).astype(int)
+ ymax = np.ceil(y_axis_vals.max()).astype(int)
+
+ # min/max limits
+ limits = (x_axis_vals[0], x_axis_vals[-1], ymin * 1.5, ymax * 1.5)
+
+ selector = PolygonSelector(
+ selection,
+ limits,
+ parent=self,
+ **kwargs,
+ )
+
+ self._plot_area.add_graphic(selector, center=False)
+
+ return selector
+
# TODO: this method is a bit of a mess, can refactor later
def _get_linear_selector_init_args(
self, axis: str, padding
diff --git a/fastplotlib/graphics/line_collection.py b/fastplotlib/graphics/line_collection.py
index de4139679..d08231f7d 100644
--- a/fastplotlib/graphics/line_collection.py
+++ b/fastplotlib/graphics/line_collection.py
@@ -7,7 +7,12 @@
from ..utils import parse_cmap_values
from ._collection_base import CollectionIndexer, GraphicCollection, CollectionFeature
from .line import LineGraphic
-from .selectors import LinearRegionSelector, LinearSelector, RectangleSelector
+from .selectors import (
+ LinearRegionSelector,
+ LinearSelector,
+ RectangleSelector,
+ PolygonSelector,
+)
class _LineCollectionProperties:
@@ -67,7 +72,7 @@ def cmap(self) -> CollectionFeature:
"""
Get or set a cmap along the line collection.
- Optionally set using a tuple ("cmap", , ) to set the transform and/or alpha.
+ Optionally set using a tuple ("cmap", ) to set the transform..
Example:
line_collection.cmap = ("jet", sine_transform_vals, 0.7)
@@ -79,23 +84,20 @@ def cmap(self) -> CollectionFeature:
def cmap(self, args):
if isinstance(args, str):
name = args
- transform, alpha = None, 1.0
+ transform = None
elif len(args) == 1:
name = args[0]
- transform, alpha = None, None
-
+ transform = None
elif len(args) == 2:
name, transform = args
- alpha = None
-
- elif len(args) == 3:
- name, transform, alpha = args
+ else:
+ raise ValueError(
+ "Too many values for cmap (note that alpha is deprecated, set alpha on the graphic instead)"
+ )
- colors = parse_cmap_values(
+ self.colors = parse_cmap_values(
n_colors=len(self), cmap_name=name, transform=transform
)
- colors[:, -1] = alpha
- self.colors = colors
@property
def thickness(self) -> np.ndarray:
@@ -127,7 +129,6 @@ def __init__(
thickness: float | Sequence[float] = 2.0,
colors: str | Sequence[str] | np.ndarray | Sequence[np.ndarray] = "w",
uniform_colors: bool = False,
- alpha: float = 1.0,
cmap: Sequence[str] | str = None,
cmap_transform: np.ndarray | List = None,
name: str = None,
@@ -159,9 +160,6 @@ def __init__(
| if ``list`` of ``str``, represents color for each individual line, example ["w", "b", "r",...]
| if ``RGBA array`` of shape [data_size, 4], represents a single RGBA array for each line
- alpha: float, optional
- alpha value for colors, if colors is a ``str``
-
cmap: Iterable of str or str, optional
| if ``str``, single cmap will be used for all lines
| if ``list`` of ``str``, each cmap will apply to the individual lines
@@ -198,19 +196,19 @@ def __init__(
if not isinstance(thickness, (float, int)):
if len(thickness) != len(data):
raise ValueError(
- f"len(thickness) != len(data)\n" f"{len(thickness)} != {len(data)}"
+ f"len(thickness) != len(data)\n{len(thickness)} != {len(data)}"
)
if names is not None:
if len(names) != len(data):
raise ValueError(
- f"len(names) != len(data)\n" f"{len(names)} != {len(data)}"
+ f"len(names) != len(data)\n{len(names)} != {len(data)}"
)
if metadatas is not None:
if len(metadatas) != len(data):
raise ValueError(
- f"len(metadata) != len(data)\n" f"{len(metadatas)} != {len(data)}"
+ f"len(metadata) != len(data)\n{len(metadatas)} != {len(data)}"
)
if kwargs_lines is not None:
@@ -248,7 +246,7 @@ def __init__(
else:
if isinstance(colors, np.ndarray):
# single color for all lines in the collection as RGBA
- if colors.shape == (4,):
+ if colors.shape in [(3,), (4,)]:
single_color = True
# colors specified for each line as array of shape [n_lines, RGBA]
@@ -263,8 +261,7 @@ def __init__(
elif isinstance(colors, str):
if colors == "random":
- colors = np.random.rand(len(data), 4)
- colors[:, -1] = alpha
+ colors = np.random.rand(len(data), 3)
single_color = False
else:
# parse string color
@@ -381,9 +378,6 @@ def add_linear_selector(
self._plot_area.add_graphic(selector, center=False)
- # place selector above this graphic
- selector.offset = selector.offset + (0.0, 0.0, self.offset[-1] + 1)
-
return selector
def add_linear_region_selector(
@@ -438,16 +432,13 @@ def add_linear_region_selector(
self._plot_area.add_graphic(selector, center=False)
- # place selector below this graphic
- selector.offset = selector.offset + (0.0, 0.0, self.offset[-1] - 1)
-
# PlotArea manages this for garbage collection etc. just like all other Graphics
# so we should only work with a proxy on the user-end
return selector
def add_rectangle_selector(
self,
- selection: tuple[float, float, float, float] = None,
+ selection: tuple[float, float, float] = None,
**kwargs,
) -> RectangleSelector:
"""
@@ -486,6 +477,43 @@ def add_rectangle_selector(
return selector
+ def add_polygon_selector(
+ self,
+ selection: List[tuple[float, float]] = None,
+ **kwargs,
+ ) -> PolygonSelector:
+ """
+ Add a :class:`.PolygonSelector`. Selectors are just ``Graphic`` objects, so you can manage,
+ remove, or delete them from a plot area just like any other ``Graphic``.
+
+ Parameters
+ ----------
+ 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()
+
+ xdata = np.array(self.data[:, 0])
+ xmin, xmax = (np.nanmin(xdata), np.nanmax(xdata))
+
+ ydata = np.array(self.data[:, 1])
+ ymin = np.floor(ydata.min()).astype(int)
+
+ ymax = np.ptp(bbox[:, 1])
+
+ limits = (xmin, xmax, ymin - (ymax * 1.5 - ymax), ymax * 1.5)
+
+ selector = PolygonSelector(
+ selection,
+ limits,
+ parent=self,
+ **kwargs,
+ )
+
+ self._plot_area.add_graphic(selector, center=False)
+
+ return selector
+
def _get_linear_selector_init_args(self, axis, padding):
# use bbox to get size and center
bbox = self.world_object.get_world_bounding_box()
@@ -526,7 +554,6 @@ def __init__(
data: List[np.ndarray],
thickness: float | Iterable[float] = 2.0,
colors: str | Iterable[str] | np.ndarray | Iterable[np.ndarray] = "w",
- alpha: float = 1.0,
cmap: Iterable[str] | str = None,
cmap_transform: np.ndarray | List = None,
name: str = None,
@@ -560,9 +587,6 @@ def __init__(
| if ``list`` of ``str``, represents color for each individual line, example ["w", "b", "r",...]
| if ``RGBA array`` of shape [data_size, 4], represents a single RGBA array for each line
- alpha: float, optional
- alpha value for colors, if colors is a ``str``
-
cmap: Iterable of str or str, optional
| if ``str``, single cmap will be used for all lines
| if ``list`` of ``str``, each cmap will apply to the individual lines
@@ -604,7 +628,6 @@ def __init__(
data=data,
thickness=thickness,
colors=colors,
- alpha=alpha,
cmap=cmap,
cmap_transform=cmap_transform,
name=name,
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/scatter.py b/fastplotlib/graphics/scatter.py
index b31022f5b..a2e696a82 100644
--- a/fastplotlib/graphics/scatter.py
+++ b/fastplotlib/graphics/scatter.py
@@ -5,37 +5,58 @@
from ._positions_base import PositionsGraphic
from .features import (
- PointsSizesFeature,
+ VertexPointSizes,
UniformSize,
SizeSpace,
VertexPositions,
VertexColors,
UniformColor,
VertexCmap,
+ VertexMarkers,
+ UniformMarker,
+ UniformEdgeColor,
+ EdgeWidth,
+ UniformRotations,
+ VertexRotations,
+ TextureArray,
)
class ScatterGraphic(PositionsGraphic):
_features = {
"data": VertexPositions,
- "sizes": (PointsSizesFeature, UniformSize),
+ "sizes": (VertexPointSizes, UniformSize),
"colors": (VertexColors, UniformColor),
"cmap": (VertexCmap, None),
+ "markers": (VertexMarkers, UniformMarker, None),
+ "edge_colors": (UniformEdgeColor, VertexColors, None),
+ "edge_width": (EdgeWidth, None),
+ "image": (TextureArray, None),
"size_space": SizeSpace,
+ "point_rotations": (UniformRotations, VertexRotations, None),
}
def __init__(
self,
data: Any,
- colors: str | np.ndarray | tuple[float] | list[float] | list[str] = "w",
+ colors: str | np.ndarray | Sequence[float] | Sequence[str] = "w",
uniform_color: bool = False,
- alpha: float = 1.0,
cmap: str = None,
cmap_transform: np.ndarray = None,
- isolated_buffer: bool = True,
+ mode: Literal["markers", "simple", "gaussian", "image"] = "markers",
+ markers: str | np.ndarray | Sequence[str] = "o",
+ uniform_marker: bool = False,
+ custom_sdf: str = None,
+ edge_colors: str | np.ndarray | pygfx.Color | Sequence[float] = "black",
+ uniform_edge_color: bool = True,
+ edge_width: float = 1.0,
+ image: np.ndarray = None,
+ point_rotations: float | np.ndarray = 0,
+ point_rotation_mode: Literal["uniform", "vertex", "curve"] = "uniform",
sizes: float | np.ndarray | Sequence[float] = 1,
uniform_size: bool = False,
size_space: str = "screen",
+ isolated_buffer: bool = True,
**kwargs,
):
"""
@@ -55,9 +76,6 @@ def __init__(
if True, uses a uniform buffer for the scatter point colors. Useful if you need to
save GPU VRAM when all points have the same color.
- alpha: float, optional, default 1.0
- alpha value for the colors
-
cmap: str, optional
apply a colormap to the scatter instead of assigning colors manually, this
overrides any argument passed to "colors". For supported colormaps see the
@@ -66,9 +84,65 @@ def __init__(
cmap_transform: 1D array-like or list of numerical values, optional
if provided, these values are used to map the colors from the cmap
- isolated_buffer: bool, default True
- whether the buffers should be isolated from the user input array.
- Generally always ``True``, ``False`` is for rare advanced use if you have large arrays.
+ mode: one of: "markers", "simple", "gaussian", "image", default "markers"
+ The scatter points mode, cannot be changed after the graphic has been created.
+
+ * markers: represent points with various or custom markers, default
+ * simple: all scatters points are simple circles
+ * gaussian: each point is a gaussian blob
+ * image: use an image for each point, pass an array to the `image` kwarg, these are also called sprites
+
+ markers: None | str | np.ndarray | Sequence[str], default "o"
+ The shape of the markers when `mode` is "markers"
+
+ Supported values:
+
+ * A string from pygfx.MarkerShape enum
+ * Matplotlib compatible characters: "osD+x^v<>*".
+ * Unicode symbols: "●○■♦♥♠♣✳▲▼◀▶".
+ * Emojis: "❤️♠️♣️♦️💎💍✳️📍".
+ * A string containing the value "custom". In this case, WGSL code defined by ``custom_sdf`` will be used.
+
+ uniform_marker: bool, default False
+ Use the same marker for all points. Only valid when `mode` is "markers". Useful if you need to use
+ the same marker for all points and want to save GPU RAM.
+
+ custom_sdf: str = None,
+ The SDF code for the marker shape when the marker is set to custom.
+ Can be used when `mode` is "markers".
+
+ Negative values are inside the shape, positive values are outside the
+ shape.
+
+ The SDF's takes in two parameters `coords: vec2` and `size: f32`.
+ The first is a WGSL coordinate and `size` is the overall size of
+ the texture. The returned value should be the signed distance from
+ any edge of the shape. Distances (positive and negative) that are
+ less than half the `edge_width` in absolute terms will be colored
+ with the `edge_color`. Other negative distances will be colored by
+ `colors`.
+
+ edge_colors: str | np.ndarray | pygfx.Color | Sequence[float], default "black"
+ edge color of the markers, used when `mode` is "markers"
+
+ uniform_edge_color: bool, default True
+ Set the same edge color for all markers. Useful for saving GPU RAM.
+
+ edge_width: float = 1.0,
+ Width of the marker edges. used when `mode` is "markers".
+
+ image: ArrayLike, optional
+ renders an image at the scatter points, also known as sprites.
+ The image color is multiplied with the point's "normal" color.
+
+ point_rotations: float | ArrayLike = 0,
+ The rotation of the scatter points in radians. Default 0. A single float rotation value can be set on all
+ points, or an array of rotation values can be used to set per-point rotations
+
+ point_rotation_mode: one of: "uniform" | "vertex" | "curve", default "uniform"
+ * uniform: set the same rotation for every point, useful to save GPU RAM
+ * vertex: set per-vertex rotations
+ * curve: The rotation follows the curve of the line defined by the points (in screen space)
sizes: float or iterable of float, optional, default 1.0
sizes of the scatter points
@@ -78,7 +152,11 @@ def __init__(
save GPU VRAM when all points have the same size.
size_space: str, default "screen"
- coordinate space in which the size is expressed ("screen", "world", "model")
+ coordinate space in which the size is expressed, one of ("screen", "world", "model")
+
+ isolated_buffer: bool, default True
+ whether the buffers should be isolated from the user input array.
+ Generally always ``True``, ``False`` is for rare advanced use if you have large arrays.
kwargs
passed to :class:`.Graphic`
@@ -89,7 +167,6 @@ def __init__(
data=data,
colors=colors,
uniform_color=uniform_color,
- alpha=alpha,
cmap=cmap,
cmap_transform=cmap_transform,
isolated_buffer=isolated_buffer,
@@ -100,37 +177,259 @@ def __init__(
n_datapoints = self.data.value.shape[0]
geo_kwargs = {"positions": self._data.buffer}
- material_kwargs = {"pick_write": True}
+
+ aa = kwargs.get("alpha_mode", "auto") in ("blend", "weighted_blend")
+
+ material_kwargs = dict(
+ pick_write=True,
+ aa=aa,
+ depth_compare="<=",
+ )
+
+ self._markers: VertexMarkers | UniformMarker | None = None
+ self._edge_colors: UniformEdgeColor | VertexColors | None = None
+ self._edge_width: EdgeWidth | None = None
+ self._point_rotations: VertexRotations | UniformRotations | None = None
+ self._image: TextureArray | None = None
+
+ # material cannot be changed after the ScatterGraphic is created
+ self._mode = mode
+ match self.mode:
+ case "markers":
+ # default
+ material = pygfx.PointsMarkerMaterial
+
+ if uniform_marker:
+ if not isinstance(markers, str):
+ raise TypeError(
+ "must pass a single marker if uniform_marker is True"
+ )
+
+ self._markers = UniformMarker(markers)
+
+ material_kwargs["marker_mode"] = pygfx.MarkerMode.uniform
+ material_kwargs["marker"] = self._markers.value
+ else:
+ material_kwargs["marker_mode"] = pygfx.MarkerMode.vertex
+
+ self._markers = VertexMarkers(markers, n_datapoints)
+
+ geo_kwargs["markers"] = self._markers.buffer
+
+ if edge_colors is None:
+ # interpret as no edge color
+ edge_colors = (0, 0, 0, 0)
+
+ if uniform_edge_color:
+ if not isinstance(edge_colors, (str, pygfx.Color)):
+ if len(edge_colors) not in [3, 4]:
+ raise TypeError(
+ f"if `uniform_edge_color` is True, then `edge_color` must be a str, pygfx.Color, "
+ f"or an RGB(A) tuple, list, array representation of a single color. You have passed: "
+ f"{edge_colors}"
+ )
+
+ self._edge_colors = UniformEdgeColor(edge_colors)
+ material_kwargs["edge_color"] = self._edge_colors.value
+ material_kwargs["edge_color_mode"] = pygfx.ColorMode.uniform
+ else:
+ self._edge_colors = VertexColors(
+ edge_colors, n_datapoints, property_name="edge_colors"
+ )
+ material_kwargs["edge_color_mode"] = pygfx.ColorMode.vertex
+ geo_kwargs["edge_colors"] = self._edge_colors.buffer
+
+ self._edge_width = EdgeWidth(edge_width)
+ material_kwargs["edge_width"] = self._edge_width.value
+ material_kwargs["custom_sdf"] = custom_sdf
+
+ case "simple":
+ # basic points material
+ material = pygfx.PointsMaterial
+
+ case "gaussian":
+ material = pygfx.PointsGaussianBlobMaterial
+
+ case "image":
+ material = pygfx.PointsSpriteMaterial
+ # sprites should actually only be one texture, but we don't
+ # want to create a new buffer manager just for sprites.
+ # If someone is creating scatter plots with images of size
+ # larger than the typical limit of 16384, I'm very curious
+ # to know what they're trying to visualize
+ shared = pygfx.renderers.wgpu.get_shared()
+ limit = shared.device.limits["max-texture-dimension-2d"]
+ if any([dim > limit for dim in image.shape]):
+ raise BufferError(
+ f"Scatter point image dimension is greater than the device texture limit."
+ f"Your device limit is: {limit} but your image shape is: {image.shape}"
+ )
+
+ # create texture array with normalized image
+ self._image = TextureArray(
+ image / np.nanmax(image), property_name="image"
+ )
+
+ material_kwargs["sprite"] = self._image.buffer[0, 0]
+
self._size_space = SizeSpace(size_space)
if uniform_color:
- material_kwargs["color_mode"] = "uniform"
+ material_kwargs["color_mode"] = pygfx.ColorMode.uniform
material_kwargs["color"] = self.colors
else:
- material_kwargs["color_mode"] = "vertex"
+ material_kwargs["color_mode"] = pygfx.ColorMode.vertex
geo_kwargs["colors"] = self.colors.buffer
if uniform_size:
- material_kwargs["size_mode"] = "uniform"
+ material_kwargs["size_mode"] = pygfx.SizeMode.uniform
self._sizes = UniformSize(sizes)
material_kwargs["size"] = self.sizes
else:
- material_kwargs["size_mode"] = "vertex"
- self._sizes = PointsSizesFeature(sizes, n_datapoints=n_datapoints)
+ material_kwargs["size_mode"] = pygfx.SizeMode.vertex
+ self._sizes = VertexPointSizes(sizes, n_datapoints=n_datapoints)
geo_kwargs["sizes"] = self.sizes.buffer
+ match point_rotation_mode:
+ case pygfx.enums.RotationMode.vertex:
+ self._point_rotations = VertexRotations(
+ point_rotations, n_datapoints=n_datapoints
+ )
+ geo_kwargs["rotations"] = self._point_rotations.buffer
+
+ case pygfx.enums.RotationMode.uniform:
+ self._point_rotations = UniformRotations(point_rotations)
+
+ case pygfx.enums.RotationMode.curve:
+ pass # nothing special for curve rotation mode
+
+ case _:
+ raise ValueError(
+ f"`point_rotation_mode` must be one of: {pygfx.enums.RotationMode}, "
+ f"you have passed: {point_rotation_mode}"
+ )
+
+ material_kwargs["rotation_mode"] = point_rotation_mode
material_kwargs["size_space"] = self.size_space
+
world_object = pygfx.Points(
pygfx.Geometry(**geo_kwargs),
- material=pygfx.PointsMaterial(**material_kwargs),
+ material=material(**material_kwargs),
)
self._set_world_object(world_object)
@property
- def sizes(self) -> PointsSizesFeature | float:
+ def mode(self) -> str:
+ """scatter point display mode"""
+ return self._mode
+
+ @property
+ def markers(self) -> str | VertexMarkers | None:
+ """markers if mode is 'marker'"""
+ if isinstance(self._markers, VertexMarkers):
+ return self._markers
+ elif isinstance(self._markers, UniformMarker):
+ return self._markers.value
+
+ @markers.setter
+ def markers(self, value: str | np.ndarray[str] | Sequence[str]):
+ if self.mode != "markers":
+ raise AttributeError(
+ f"scatter plot is: {self.mode}. The mode must be 'markers' to set the markers"
+ )
+ if isinstance(self._markers, VertexMarkers):
+ self._markers[:] = value
+ elif isinstance(self._markers, UniformMarker):
+ self._markers.set_value(self, value)
+
+ @property
+ def edge_colors(self) -> str | pygfx.Color | VertexColors | None:
+ """edge_colors if mode is 'marker'"""
+
+ if isinstance(self._edge_colors, VertexColors):
+ return self._edge_colors
+
+ elif isinstance(self._edge_colors, UniformEdgeColor):
+ return self._edge_colors.value
+
+ @edge_colors.setter
+ def edge_colors(self, value: str | np.ndarray | Sequence[str] | Sequence[float]):
+ if self.mode != "markers":
+ raise AttributeError(
+ f"scatter plot is: {self.mode}. The mode must be 'markers' to set the edge_colors"
+ )
+
+ if isinstance(self._edge_colors, VertexColors):
+ self._edge_colors[:] = value
+
+ elif isinstance(self._edge_colors, UniformEdgeColor):
+ self._edge_colors.set_value(self, value)
+
+ @property
+ def edge_width(self) -> float | None:
+ """Get or set the edge_width if mode is 'markers'"""
+ if self._edge_width is None:
+ return None
+
+ return self._edge_width.value
+
+ @edge_width.setter
+ def edge_width(self, value: float):
+ if self.mode != "markers":
+ raise AttributeError(
+ f"scatter plot is: {self.mode}. The mode must be 'markers' to set the edge_width"
+ )
+
+ self._edge_width.set_value(self, value)
+
+ @property
+ def point_rotation_mode(self) -> str:
+ """point rotation mode, read-only, one of 'uniform', 'vertex', or 'curve'"""
+ return self.world_object.material.rotation_mode
+
+ @property
+ def point_rotations(self) -> VertexRotations | float | None:
+ """rotation of each point, in radians, if `point_rotation_mode` is 'uniform' or 'vertex'"""
+
+ if isinstance(self._point_rotations, VertexRotations):
+ return self._point_rotations
+
+ elif isinstance(self._point_rotations, UniformRotations):
+ return self._point_rotations.value
+
+ @point_rotations.setter
+ def point_rotations(self, value: float | np.ndarray[float]):
+ if self.point_rotation_mode not in ["uniform", "vertex"]:
+ raise AttributeError(
+ f"point_rotation_mode is: {self.point_rotation_mode}. "
+ f"it be 'uniform' or 'vertex' to set the `point_rotations`"
+ )
+
+ if isinstance(self._point_rotations, VertexRotations):
+ self._point_rotations[:] = value
+
+ elif isinstance(self._point_rotations, UniformRotations):
+ self._point_rotations.set_value(self, value)
+
+ @property
+ def image(self) -> TextureArray | None:
+ """Get or set the image data, returns None if scatter plot mode is not 'image'"""
+ return self._image
+
+ @image.setter
+ def image(self, data):
+ if self.mode != "image":
+ raise AttributeError(
+ f"scatter plot is: {self.mode}. The mode must be 'image' to set the image"
+ )
+
+ self._image[:] = data
+
+ @property
+ def sizes(self) -> VertexPointSizes | float:
"""Get or set the scatter point size(s)"""
- if isinstance(self._sizes, PointsSizesFeature):
+ if isinstance(self._sizes, VertexPointSizes):
return self._sizes
elif isinstance(self._sizes, UniformSize):
@@ -138,7 +437,7 @@ def sizes(self) -> PointsSizesFeature | float:
@sizes.setter
def sizes(self, value):
- if isinstance(self._sizes, PointsSizesFeature):
+ if isinstance(self._sizes, VertexPointSizes):
self._sizes[:] = value
elif isinstance(self._sizes, UniformSize):
diff --git a/fastplotlib/graphics/selectors/_base_selector.py b/fastplotlib/graphics/selectors/_base_selector.py
index 5cef0c6b0..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
@@ -111,6 +113,7 @@ def edge_color(self, color: str | Sequence[float]):
def __init__(
self,
edges: Tuple[Line, ...] = None,
+ outer_edges: Tuple[Line, ...] = None,
fill: Tuple[Mesh, ...] = None,
vertices: Tuple[Points, ...] = None,
hover_responsive: Tuple[WorldObject, ...] = None,
@@ -122,6 +125,9 @@ def __init__(
if edges is None:
edges = tuple()
+ if outer_edges is None:
+ outer_edges = tuple()
+
if fill is None:
fill = tuple()
@@ -129,11 +135,15 @@ def __init__(
vertices = tuple()
self._edges: Tuple[Line, ...] = edges
+ self._outer_edges: Tuple[Line, ...] = outer_edges
self._fill: Tuple[Mesh, ...] = fill
self._vertices: Tuple[Points, ...] = vertices
self._world_objects: Tuple[WorldObject, ...] = (
- self._edges + self._fill + self._vertices
+ *self._edges,
+ *self._outer_edges,
+ *self._fill,
+ *self._vertices,
)
for wo in self._world_objects:
@@ -148,7 +158,7 @@ def __init__(
self._hover_colors = {}
if hover_responsive is not None:
- for wo in self._hover_responsive:
+ for wo in [*self._hover_responsive, *self._outer_edges]:
self._original_colors[wo] = wo.material.color
self._axis = axis
@@ -215,7 +225,7 @@ def _fpl_add_plot_area_hook(self, plot_area):
wo.add_event_handler(self._toggle_arrow_key_moveable, "double_click")
for fill in self._fill:
- if fill.material.color_is_transparent:
+ if fill.material.color.a < 1 or fill.material.opacity < 1:
self._pfunc_fill = partial(self._check_fill_pointer_event, fill)
self._plot_area.renderer.add_event_handler(
self._pfunc_fill, "pointer_down"
@@ -231,7 +241,7 @@ def _fpl_add_plot_area_hook(self, plot_area):
self._plot_area.renderer.add_event_handler(self._move_to_pointer, "click")
# mouse hover color events
- for wo in self._hover_responsive:
+ for wo in [*self._hover_responsive, *self._outer_edges]:
wo.add_event_handler(self._pointer_enter, "pointer_enter")
wo.add_event_handler(self._pointer_leave, "pointer_leave")
@@ -282,6 +292,12 @@ def _move_start(self, event_source: WorldObject, ev):
"""
position = self._plot_area.map_screen_to_world(ev)
+ # if the event source was an outer transparent line, get the
+ # corresponding inner line since it's just a proxy
+ if event_source in self._outer_edges:
+ index = self._outer_edges.index(event_source)
+ event_source = self._edges[index]
+
self._move_info = MoveInfo(
start_selection=None,
start_position=position,
@@ -393,14 +409,20 @@ def _move_to_pointer(self, ev):
self._move_graphic(move_info)
def _pointer_enter(self, ev):
-
if self._hover_responsive is None:
return
wo = ev.pick_info["world_object"]
- if wo not in self._hover_responsive:
+ if wo not in [*self._hover_responsive, *self._outer_edges]:
return
+ # if it's an outer edge, highlight the corresponding inner edge instead
+ if wo in self._outer_edges:
+ # get index
+ index = self._outer_edges.index(wo)
+ # now use inner edge
+ wo = self._edges[index]
+
if wo in self._edges:
self._edge_hovered = True
@@ -416,7 +438,7 @@ def _pointer_leave(self, ev):
self._edge_hovered = False
# reset colors
- for wo in self._hover_responsive:
+ for wo in [*self._hover_responsive, *self._outer_edges]:
if self._moving:
self._hover_colors[wo] = self._original_colors[wo]
else:
diff --git a/fastplotlib/graphics/selectors/_linear.py b/fastplotlib/graphics/selectors/_linear.py
index 64a673768..0c956d57b 100644
--- a/fastplotlib/graphics/selectors/_linear.py
+++ b/fastplotlib/graphics/selectors/_linear.py
@@ -5,6 +5,7 @@
import numpy as np
import pygfx
+from ...utils.enums import RenderQueue
from .._base import Graphic
from .._collection_base import GraphicCollection
from ..features._selection_features import LinearSelectionFeature
@@ -77,9 +78,10 @@ def __init__(
limits: Sequence[float],
axis: str = "x",
parent: Graphic = None,
- edge_color: str | Sequence[float] | np.ndarray = "w",
- thickness: float = 2.5,
+ edge_color: str | Sequence[float] | np.ndarray = "yellow",
+ thickness: float = 1.0,
arrow_keys_modifier: str = "Shift",
+ extra_width: float = 14.0,
name: str = None,
):
"""
@@ -110,6 +112,9 @@ def __init__(
edge_color: str | tuple | np.ndarray, default "w"
color of the selector
+ extra_width: float, default 14.0
+ the width around the selector which is responsive to mouse events, in logical pixels
+
name: str, optional
name of linear selector
@@ -140,38 +145,58 @@ def __init__(
material = pygfx.LineInfiniteSegmentMaterial
- self.colors_outer = pygfx.Color([0.3, 0.3, 0.3, 1.0])
-
line_inner = pygfx.Line(
# self.data.feature_data because data is a Buffer
geometry=pygfx.Geometry(positions=line_data),
- material=material(thickness=thickness, color=edge_color, pick_write=True),
+ material=material(
+ thickness=thickness,
+ color=edge_color,
+ alpha_mode="blend",
+ aa=True,
+ render_queue=RenderQueue.selector,
+ depth_test=False,
+ depth_write=False,
+ pick_write=True,
+ ),
)
- self.line_outer = pygfx.Line(
- geometry=pygfx.Geometry(positions=line_data),
+ line_outer = pygfx.Line(
+ geometry=line_inner.geometry,
material=material(
- thickness=thickness + 6, color=self.colors_outer, pick_write=True
+ thickness=thickness + extra_width,
+ color=pygfx.Color([0, 0, 0]),
+ opacity=0,
+ alpha_mode="blend",
+ aa=True,
+ render_queue=RenderQueue.selector,
+ depth_test=False,
+ depth_write=False,
+ pick_write=True,
),
)
- line_inner.world.z = self.line_outer.world.z + 1
+ # Inner line goes on top of the outer line
+ line_inner.render_order = 1
world_object = pygfx.Group()
- world_object.add(self.line_outer)
+ 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__(
self,
- edges=(line_inner, self.line_outer),
- hover_responsive=(line_inner, self.line_outer),
+ edges=(line_inner,),
+ outer_edges=(line_outer,),
+ hover_responsive=(line_inner,),
arrow_keys_modifier=arrow_keys_modifier,
axis=axis,
parent=parent,
diff --git a/fastplotlib/graphics/selectors/_linear_region.py b/fastplotlib/graphics/selectors/_linear_region.py
index e93e2a147..70a8dffa8 100644
--- a/fastplotlib/graphics/selectors/_linear_region.py
+++ b/fastplotlib/graphics/selectors/_linear_region.py
@@ -8,6 +8,7 @@
from .._collection_base import GraphicCollection
from ..features._selection_features import LinearRegionSelectionFeature
from ._base_selector import BaseSelector, MoveInfo
+from ...utils.enums import RenderQueue
class LinearRegionSelector(BaseSelector):
@@ -63,9 +64,10 @@ def __init__(
parent: Graphic = None,
resizable: bool = True,
fill_color: str | Sequence[float] = (0, 0, 0.35),
- edge_color: str | Sequence[float] = (0.8, 0.6, 0),
- edge_thickness: float = 8,
+ edge_color: str | Sequence[float] = "yellow",
+ edge_thickness: float = 1.0,
arrow_keys_modifier: str = "Shift",
+ extra_width: float = 14.0,
name: str = None,
):
"""
@@ -112,6 +114,9 @@ def __init__(
modifier key that must be pressed to initiate movement using arrow keys, must be one of:
"Control", "Shift", "Alt" or ``None``
+ extra_width: float, default 14.0
+ the width around the selector lines which is responsive to mouse events, in logical pixels
+
name: str, optional
name of this selector graphic
@@ -140,7 +145,13 @@ def __init__(
mesh = pygfx.Mesh(
pygfx.box_geometry(1, size, 1),
pygfx.MeshBasicMaterial(
- color=pygfx.Color(self.fill_color), pick_write=True
+ color=pygfx.Color(self.fill_color),
+ alpha_mode="blend",
+ opacity=0.4,
+ render_queue=RenderQueue.selector,
+ depth_test=False,
+ depth_write=False,
+ pick_write=True,
),
)
@@ -148,12 +159,21 @@ def __init__(
mesh = pygfx.Mesh(
pygfx.box_geometry(size, 1, 1),
pygfx.MeshBasicMaterial(
- color=pygfx.Color(self.fill_color), pick_write=True
+ color=pygfx.Color(self.fill_color),
+ alpha_mode="blend",
+ opacity=0.4,
+ render_queue=RenderQueue.selector,
+ depth_test=False,
+ depth_write=False,
+ pick_write=True,
),
)
else:
raise ValueError("`axis` must be one of 'x' or 'y'")
+ # Render the mesh before the lines
+ mesh.render_order = -1
+
# the fill of the selection
self.fill = mesh
# no x, y offsets for linear region selector
@@ -188,31 +208,84 @@ def __init__(
positions=init_line_data.copy()
), # copy so the line buffer is isolated
pygfx.LineMaterial(
- thickness=edge_thickness, color=self.edge_color, pick_write=True
+ thickness=edge_thickness,
+ color=self.edge_color,
+ alpha_mode="blend",
+ opacity=1,
+ aa=True,
+ render_queue=RenderQueue.selector,
+ depth_test=False,
+ depth_write=False,
+ pick_write=True,
),
)
+
+ line0_outer = pygfx.Line(
+ pygfx.Geometry(
+ # share buffer with inner line so they can both be managed together
+ positions=line0.geometry.positions
+ ),
+ pygfx.LineMaterial(
+ thickness=edge_thickness + extra_width,
+ color=pygfx.Color([0, 0, 0]),
+ alpha_mode="blend",
+ opacity=0,
+ aa=True,
+ render_queue=RenderQueue.selector,
+ depth_test=False,
+ depth_write=False,
+ pick_write=True,
+ ),
+ )
+
line1 = pygfx.Line(
pygfx.Geometry(
positions=init_line_data.copy()
), # copy so the line buffer is isolated
pygfx.LineMaterial(
- thickness=edge_thickness, color=self.edge_color, pick_write=True
+ thickness=edge_thickness,
+ color=self.edge_color,
+ alpha_mode="blend",
+ opacity=1,
+ aa=True,
+ render_queue=RenderQueue.selector,
+ depth_test=False,
+ depth_write=False,
+ pick_write=True,
),
)
- self.edges: tuple[pygfx.Line, pygfx.Line] = (line0, line1)
+ line1_outer = pygfx.Line(
+ pygfx.Geometry(
+ # share buffer with inner line so they can both be managed together
+ positions=line1.geometry.positions
+ ),
+ pygfx.LineMaterial(
+ thickness=edge_thickness + extra_width,
+ color=pygfx.Color([0, 0, 0]),
+ alpha_mode="blend",
+ opacity=0,
+ aa=True,
+ render_queue=RenderQueue.selector,
+ depth_test=False,
+ depth_write=False,
+ pick_write=True,
+ ),
+ )
- # add the edge lines
- for edge in self.edges:
- edge.world.z = -0.5
- group.add(edge)
+ edges: tuple[pygfx.Line, pygfx.Line] = (line0, line1)
+ 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
@@ -225,9 +298,10 @@ def __init__(
BaseSelector.__init__(
self,
- edges=self.edges,
+ edges=edges,
+ outer_edges=outer_edges,
fill=(self.fill,),
- hover_responsive=self.edges,
+ hover_responsive=edges,
arrow_keys_modifier=arrow_keys_modifier,
axis=axis,
parent=parent,
@@ -369,7 +443,6 @@ def get_selected_indices(
return np.arange(*bounds, dtype=int)
def _move_graphic(self, move_info: MoveInfo):
-
# If this the first move in this drag, store initial selection
if move_info.start_selection is None:
move_info.start_selection = self.selection
@@ -396,12 +469,12 @@ def _move_graphic(self, move_info: MoveInfo):
# if event source was an edge and selector is resizable,
# move the edge that caused the event
- if move_info.source == self.edges[0]:
+ if move_info.source == self._edges[0]:
# change only left or bottom bound
new_min = min(cur_min + delta, cur_max)
self._selection.set_value(self, (new_min, cur_max))
- elif move_info.source == self.edges[1]:
+ elif move_info.source == self._edges[1]:
# change only right or top bound
new_max = max(cur_max + delta, cur_min)
self._selection.set_value(self, (cur_min, new_max))
diff --git a/fastplotlib/graphics/selectors/_polygon.py b/fastplotlib/graphics/selectors/_polygon.py
index 22e42e63e..e02c627ac 100644
--- a/fastplotlib/graphics/selectors/_polygon.py
+++ b/fastplotlib/graphics/selectors/_polygon.py
@@ -1,152 +1,575 @@
+import warnings
from typing import *
-import numpy as np
+from dataclasses import dataclass
+from numbers import Real
+import numpy as np
import pygfx
-from ._base_selector import BaseSelector, MoveInfo
+from ...utils.enums import RenderQueue
from .._base import Graphic
+from .._collection_base import GraphicCollection
+from ..features._selection_features import PolygonSelectionFeature
+from ._base_selector import BaseSelector
+
+
+@dataclass
+class MoveInfo:
+ """Movement info specific to the polygon selector."""
+
+ # The interaction mode: None, 'create', or 'drag'
+ mode: str
+
+ # The index of the point in the polygon that is currently being manipulated
+ index: int
+
+ # The index of the point in the polygon to snap to. This is used to merge (i.e. delete) points, and to finish se the polygon.
+ snap_index: int
+
+ # The position of the cursor at the start of a drag
+ start_pos: np.ndarray | None
+
+ # The position of the vertices at the start of a drag
+ start_positions: np.ndarray | None
class PolygonSelector(BaseSelector):
+ _features = {"selection": PolygonSelectionFeature}
+
+ @property
+ def parent(self) -> Graphic | None:
+ """Graphic that selector is associated with."""
+ return self._parent
+
+ @property
+ def selection(self) -> np.ndarray[float]:
+ """
+ The polygon as an array of 3D points. The shape is [n_points, 3].
+ """
+ return self._selection.value.copy()
+
+ @selection.setter
+ def selection(self, selection: np.ndarray[float]):
+ graphic = self._parent
+
+ if isinstance(graphic, GraphicCollection):
+ pass
+
+ self._selection.set_value(self, selection)
+
+ @property
+ def limits(self) -> Tuple[float, float, float, float]:
+ """Return the limits of the selector."""
+ return self._limits
+
+ @limits.setter
+ def limits(self, values: Tuple[float, float, float, float]):
+ if len(values) != 4 or not all(map(lambda v: isinstance(v, Real), values)):
+ raise TypeError("limits must be an iterable of two numeric values")
+ self._limits = tuple(
+ map(round, values)
+ ) # if values are close to zero things get weird so round them
+ self._selection._limits = self._limits
+
def __init__(
self,
- edge_color="magenta",
- edge_width: float = 3,
+ selection: Optional[Sequence[Tuple[float]]],
+ limits: Sequence[float],
parent: Graphic = None,
+ resizable: bool = True,
+ fill_color=(0, 0, 0.35),
+ edge_color=(0.8, 0.6, 0),
+ edge_thickness: float = 4,
+ vertex_color=(0.7, 0.4, 0),
+ vertex_size: float = 12,
name: str = None,
):
- self.parent = parent
-
- group = pygfx.Group()
-
- self._set_world_object(group)
+ self._parent = parent
+ self._resizable = bool(resizable)
- self.edge_color = edge_color
- self.edge_width = edge_width
+ BaseSelector.__init__(self, name=name, parent=parent)
+ self._move_info = MoveInfo("none", -1, -1, None, None)
- self._move_info: MoveInfo = None
+ # Initialize geometry with space for 8 points. The buffers are oversized, so we only need to create new buffers when the allocated space is full.
+ # The points are 3D, even though the z-component is always 0. Indices represent the faces (i.e. the triangles).
+ self.geometry = pygfx.Geometry(
+ positions=np.zeros((8, 3), np.float32),
+ indices=np.zeros((8, 3), np.int32),
+ )
- self._current_mode = None
+ # The draw range allows us to draw only part of the buffer, i.e. it allows us to oversize our buffers to avoid creating a new one for every added point.
+ self.geometry.positions.draw_range = 0, 0
+ self.geometry.indices.draw_range = 0, 0
+
+ self._line = pygfx.Line(
+ self.geometry,
+ pygfx.LineMaterial(
+ thickness=edge_thickness,
+ color=edge_color,
+ alpha_mode="blend",
+ aa=True,
+ render_queue=RenderQueue.selector,
+ depth_test=False,
+ depth_write=False,
+ pick_write=True,
+ ),
+ )
+ self._points = pygfx.Points(
+ self.geometry,
+ pygfx.PointsMaterial(
+ size=vertex_size,
+ color=vertex_color,
+ alpha_mode="blend",
+ aa=True,
+ render_queue=RenderQueue.selector,
+ depth_test=False,
+ depth_write=False,
+ pick_write=True,
+ ),
+ )
+ self._indicator = pygfx.Points(
+ pygfx.Geometry(positions=[[0, 0, 0]]),
+ pygfx.PointsMaterial(
+ size=15,
+ color=vertex_color,
+ alpha_mode="blend",
+ opacity=0.3,
+ aa=True,
+ render_queue=RenderQueue.selector,
+ depth_test=False,
+ depth_write=False,
+ ),
+ )
+ self._indicator.visible = False
+ self._mesh = pygfx.Mesh(
+ self.geometry,
+ pygfx.MeshBasicMaterial(
+ color=fill_color,
+ alpha_mode="blend",
+ opacity=0.4,
+ render_queue=RenderQueue.selector,
+ depth_test=False,
+ depth_write=False,
+ pick_write=True,
+ ),
+ )
+ group = pygfx.Group().add(self._line, self._points, self._mesh, self._indicator)
+ self._set_world_object(group)
- BaseSelector.__init__(self, name=name)
+ # Points go on top of lines, which go on top of the mesh. And indicator in between.
+ self._line.render_order = 1
+ self._indicator.render_order = 2
+ self._points.render_order = 3
- def get_vertices(self) -> np.ndarray:
- """Get the vertices for the polygon"""
- vertices = list()
- for child in self.world_object.children:
- vertices.append(child.geometry.positions.data[:, :2])
+ if selection is None:
+ selection = []
+ self._selection = PolygonSelectionFeature(selection, (0, 0, 0, 0))
- return np.vstack(vertices)
+ self.edge_color = edge_color
+ self.edge_width = edge_thickness
+ self.limits = limits
+ self.selection = self.selection # trigger positions to be created
+
+ def get_selected_data(
+ self, graphic: Graphic = None, mode: str = "full"
+ ) -> Union[np.ndarray, List[np.ndarray]]:
+ """
+ Get the ``Graphic`` data bounded by the current selection.
+ Returns a view of the data array.
+
+ If the ``Graphic`` is a collection, such as a ``LineStack``, it returns a list of views of the full array.
+ Can be performed on the ``parent`` Graphic or on another graphic by passing to the ``graphic`` arg.
+
+ Parameters
+ ----------
+ graphic: Graphic, optional, default ``None``
+ if provided, returns the data selection from this graphic instead of the graphic set as ``parent``
+ mode: str, default 'full'
+ One of 'full', 'partial', or 'ignore'. Indicates how selected data should be returned based on the
+ selectors position over the graphic. Only used for ``LineGraphic``, ``LineCollection``, and ``LineStack``
+ | If 'full', will return all data bounded by the x and y limits of the selector even if partial indices
+ along one axis are not fully covered by the selector.
+ | If 'partial' will return only the data that is bounded by the selector, missing indices not bounded by the
+ selector will be set to NaNs
+ | If 'ignore', will only return data for graphics that have indices completely bounded by the selector
+
+ Returns
+ -------
+ np.ndarray or List[np.ndarray]
+ view or list of views of the full array, returns empty array if selection is empty
+ """
+ source = self._get_source(graphic)
+ ixs = self.get_selected_indices(source)
+
+ # do not need to check for mode for images, because the selector is bounded by the image shape
+ # will always be `full`
+ if "Image" in source.__class__.__name__:
+ return source.data[ixs[:, 1], ixs[:, 0]]
+
+ if mode not in ["full", "partial", "ignore"]:
+ raise ValueError(
+ f"`mode` must be one of 'full', 'partial', or 'ignore', you have passed {mode}"
+ )
+ if "Line" in source.__class__.__name__:
+ if isinstance(source, GraphicCollection):
+ data_selections: List[np.ndarray] = list()
+
+ for i, g in enumerate(source.graphics):
+ # want to keep same length as the original line collection
+ if ixs[i].size == 0:
+ data_selections.append(
+ np.array([], dtype=np.float32).reshape(0, 3)
+ )
+ else:
+ # s gives entire slice of data along the x
+ s = slice(
+ ixs[i][0], ixs[i][-1] + 1
+ ) # add 1 because these are direct indices
+ # slices n_datapoints dim
+
+ # calculate missing ixs using set difference
+ # then calculate shift
+ missing_ixs = (
+ np.setdiff1d(np.arange(ixs[i][0], ixs[i][-1] + 1), ixs[i])
+ - ixs[i][0]
+ )
+
+ match mode:
+ # take all ixs, ignore missing
+ case "full":
+ data_selections.append(g.data[s])
+ # set missing ixs data to NaNs
+ case "partial":
+ if len(missing_ixs) > 0:
+ data = g.data[s].copy()
+ data[missing_ixs] = np.nan
+ data_selections.append(data)
+ else:
+ data_selections.append(g.data[s])
+ # ignore lines that do not have full ixs to start
+ case "ignore":
+ if len(missing_ixs) > 0:
+ data_selections.append(
+ np.array([], dtype=np.float32).reshape(0, 3)
+ )
+ else:
+ data_selections.append(g.data[s])
+ return data_selections
+ else: # for lines
+ if ixs.size == 0:
+ # empty selection
+ return np.array([], dtype=np.float32).reshape(0, 3)
+
+ # add 1 to end because these are direct indices
+ s = slice(ixs[0], ixs[-1] + 1)
+ # slices n_datapoints dim
+ # slice with min, max is faster than using all the indices
+
+ # get missing ixs
+ missing_ixs = np.setdiff1d(np.arange(ixs[0], ixs[-1] + 1), ixs) - ixs[0]
+
+ match mode:
+ # return all, do not care about missing
+ case "full":
+ return source.data[s]
+ # set missing to NaNs
+ case "partial":
+ if len(missing_ixs) > 0:
+ data = source.data[s].copy()
+ data[missing_ixs] = np.nan
+ return data
+ else:
+ return source.data[s]
+ # missing means nothing will be returned even if selector is partially over data
+ # warn the user and return empty
+ case "ignore":
+ if len(missing_ixs) > 0:
+ warnings.warn(
+ "You have selected 'ignore' mode. Selected graphic has incomplete indices. "
+ "Move the selector or change the mode to one of `partial` or `full`."
+ )
+ return np.array([], dtype=np.float32)
+ else:
+ return source.data[s]
+
+ def get_selected_indices(
+ self, graphic: Graphic = None
+ ) -> np.ndarray | tuple[np.ndarray]:
+ """
+ Returns the indices of the ``Graphic`` data bounded by the current selection.
+
+ These are the data indices which correspond to the data under the selector.
+
+ Parameters
+ ----------
+ graphic: Graphic, default ``None``
+ If provided, returns the selection indices from this graphic instead of the graphic set as ``parent``
+
+ Returns
+ -------
+ Union[np.ndarray, List[np.ndarray]]
+ data indicies of the selection
+ | array of (x, y) indices if the graphic is an image
+ | list of indices along the x-dimension for each line if graphic is a line collection
+ | array of indices along the x-dimension if graphic is a line
+ """
+ # get indices from source
+ source = self._get_source(graphic)
+
+ # selector (xmin, xmax, ymin, ymax) values
+ polygon = self.selection[:, :2]
+
+ # Empty ...
+ if len(polygon) == 0:
+ if "Image" in source.__class__.__name__:
+ return np.zeros((0, 2), np.int32)
+ if "Line" in source.__class__.__name__:
+ if isinstance(source, GraphicCollection):
+ return [np.zeros((0, 1), np.int32) for _ in source.graphics]
+ else:
+ return np.zeros((0, 1), np.int32)
+
+ # Get bounding box to be able to do first selection
+ xmin, xmax = polygon[:, 0].min(), polygon[:, 0].max()
+ ymin, ymax = polygon[:, 1].min(), polygon[:, 1].max()
+
+ # image data does not need to check for mode because the selector is always bounded
+ # to the image
+ if "Image" in source.__class__.__name__:
+ shape = source.data.value.shape
+ col_ixs = np.arange(max(0, xmin), min(xmax, shape[1] - 1), dtype=int)
+ row_ixs = np.arange(max(0, ymin), min(ymax, shape[0] - 1), dtype=int)
+ indices = []
+ for y in row_ixs:
+ for x in col_ixs:
+ p = np.array([x, y], np.float32)
+ if point_in_polygon((x, y), polygon):
+ indices.append(p)
+ return np.array(indices, np.int32).reshape(-1, 2)
+
+ if "Line" in source.__class__.__name__:
+ if isinstance(source, GraphicCollection):
+ ixs = list()
+ for g in source.graphics:
+ points = g.data.value[:, :2] + g.offset[:2]
+ g_ixs = np.where(
+ (points[:, 0] >= xmin)
+ & (points[:, 0] <= xmax)
+ & (points[:, 1] >= ymin)
+ & (points[:, 1] <= ymax)
+ )[0]
+ g_ixs = np.array(
+ [i for i in g_ixs if point_in_polygon(points[i], polygon)],
+ g_ixs.dtype,
+ )
+ ixs.append(g_ixs)
+ else:
+ # map only this graphic
+ points = source.data.value[:2]
+ ixs = np.where(
+ (points[:, 0] >= xmin)
+ & (points[:, 0] <= xmax)
+ & (points[:, 1] >= ymin)
+ & (points[:, 1] <= ymax)
+ )[0]
+ ixs = np.array(
+ [i for i in ixs if point_in_polygon(points[i], polygon)],
+ ixs.dtype,
+ )
+
+ return ixs
def _fpl_add_plot_area_hook(self, plot_area):
self._plot_area = plot_area
- # click to add new segment
- self._plot_area.renderer.add_event_handler(self._add_segment, "click")
-
# pointer move to change endpoint of segment
self._plot_area.renderer.add_event_handler(
- self._move_segment_endpoint, "pointer_move"
+ self._on_pointer_down, "pointer_down"
)
-
- # click to finish existing segment
- self._plot_area.renderer.add_event_handler(self._finish_segment, "click")
-
- # double click to finish polygon
- self._plot_area.renderer.add_event_handler(self._finish_polygon, "double_click")
-
- self.position_z = len(self._plot_area) + 10
-
- def _add_segment(self, ev):
- """After click event, adds a new line segment"""
- self._current_mode = "add"
-
- position = self._plot_area.map_screen_to_world(ev)
- self._move_info = MoveInfo(
- start_selection=None,
- start_position=position,
- delta=np.zeros_like(position),
- source=None,
- )
-
- # line with same position for start and end until mouse moves
- data = np.array([position, position])
-
- new_line = pygfx.Line(
- geometry=pygfx.Geometry(positions=data.astype(np.float32)),
- material=pygfx.LineMaterial(
- thickness=self.edge_width,
- color=pygfx.Color(self.edge_color),
- pick_write=True,
- ),
+ self._plot_area.renderer.add_event_handler(
+ self._on_pointer_move, "pointer_move"
)
+ self._plot_area.renderer.add_event_handler(self._on_pointer_up, "pointer_up")
- self.world_object.add(new_line)
-
- def _move_segment_endpoint(self, ev):
- """After mouse pointer move event, moves endpoint of current line segment"""
- if self._move_info is None:
- return
- self._current_mode = "move"
+ self.position_z = len(self._plot_area) + 10
+ if len(self.selection) == 0:
+ self._start_move_mode("create", -1)
+
+ def start_new_polygon(self):
+ """Remove the current polygon and start drawing a new one."""
+ self.selection = np.zeros((0, 3), np.float32)
+ self._start_move_mode("create", -1)
+
+ def _start_move_mode(self, what, index, start_pos=None):
+ self._plot_area.controller.enabled = False
+ self._move_info.mode = what
+ self._move_info.index = index
+ self._move_info.snap_index = None
+ self._indicator.material.size = 15
+ self._indicator.visible = True
+ if start_pos is not None:
+ self._move_info.start_pos = start_pos
+ self._move_info.start_positions = self.selection.copy()
+ self._indicator.visible = False
+
+ def _end_move_mode(self):
+ if self._move_info.mode == "create":
+ self.world_object.children[0].material.loop = True
+ self._plot_area.controller.enabled = True
+ self._move_info.mode = None
+ self._move_info.start_pos = None
+ self._move_info.start_positions = None
+ self._indicator.visible = False
+
+ def _on_pointer_down(self, ev):
world_pos = self._plot_area.map_screen_to_world(ev)
-
if world_pos is None:
return
- # change endpoint
- self.world_object.children[-1].geometry.positions.data[1] = np.array(
- [world_pos]
- ).astype(np.float32)
- self.world_object.children[-1].geometry.positions.update_range()
-
- def _finish_segment(self, ev):
- """After click event, ends a line segment"""
- # should start a new segment
- if self._move_info is None:
- return
-
- # since both _add_segment and _finish_segment use the "click" callback
- # this is to block _finish_segment right after a _add_segment call
- if self._current_mode == "add":
+ if self._move_info.mode == "create":
+ # Add a polygon or finish it
+ if self._move_info.snap_index is not None:
+ pass # on release we finish the polygon
+ else:
+ self._insert_polygon_vertex(999999, world_pos)
+
+ elif self._move_info.mode is None:
+ # Maybe initiate a drag
+ if ev.target is self._points:
+ index = ev.pick_info["vertex_index"]
+ self._start_move_mode("drag", index)
+ elif ev.target is self._line:
+ index = ev.pick_info["vertex_index"]
+ if ev.pick_info["segment_coord"] > 0:
+ index += 1
+ self._insert_polygon_vertex(index, world_pos)
+ self._start_move_mode("drag", index)
+ elif ev.target is self._mesh:
+ index = None # move whole polygon
+ self._start_move_mode("drag", index, world_pos)
+
+ def _on_pointer_move(self, ev):
+ """After mouse pointer move event, moves endpoint of current line segment"""
+ if self._move_info.mode is None:
return
-
- # just make move info None so that _move_segment_endpoint is not called
- # and _add_segment gets triggered for "click"
- self._move_info = None
-
- self._current_mode = "finish-segment"
-
- def _finish_polygon(self, ev):
- """finishes the polygon, disconnects events"""
- world_pos = self._plot_area.map_screen_to_world(ev)
-
+ world_pos = self._plot_area.map_screen_to_world((ev.x, ev.y))
if world_pos is None:
return
- # make new line to connect first and last vertices
- data = np.vstack(
- [world_pos, self.world_object.children[0].geometry.positions.data[0]]
- )
-
- new_line = pygfx.Line(
- geometry=pygfx.Geometry(positions=data.astype(np.float32)),
- material=pygfx.LineMaterial(
- thickness=self.edge_width,
- color=pygfx.Color(self.edge_color),
- pick_write=True,
- ),
- )
-
- self.world_object.add(new_line)
-
- handlers = {
- self._add_segment: "click",
- self._move_segment_endpoint: "pointer_move",
- self._finish_segment: "click",
- self._finish_polygon: "double_click",
- }
-
- for handler, event in handlers.items():
- self._plot_area.renderer.remove_event_handler(handler, event)
+ # Are we close to a point that we can snap to?
+ # The concept of snapping does multiple things:
+ # - preventing the user from creating points that are very close to each-other,
+ # - allowing the user to finish the polygon by connecting to the start-point when in 'create' mode.
+ # - allowing the user to merge points by dragging one onto its neighbour.
+ index = self._move_info.index
+ snap_index = None
+
+ # Use numpy to select the nearest point.
+ # This is because we cannot use picking on the actual points because
+ # then we'd always pick the point being moved. We don't use a depth buffer
+ # so we cannot move the point backwards to avoid it being picked.
+ # An advantage is that we can make the snap-radius larger than the size of the points.
+ world_pos2 = self._plot_area.map_screen_to_world((ev.x + 1, ev.y))
+ world_pos_scale = float(np.linalg.norm(world_pos - world_pos2))
+ snap_radius = 20 # logical screen pixels
+ if len(self.selection) > 0:
+ distances = np.linalg.norm(self.selection[:, :2] - world_pos[:2], axis=1)
+ distances /= world_pos_scale
+ distances[index] = np.inf
+ snap_index = int(np.argmin(distances))
+ if distances[snap_index] > snap_radius:
+ snap_index = None
+
+ if snap_index == index: # just in case, dont snap to moving point
+ snap_index = None
+ if len(self.selection) < 4:
+ snap_index = None
+ if self._move_info.mode == "create" and snap_index != 0:
+ snap_index = None
+ if self._move_info.mode == "drag" and index is not None:
+ last_index = len(self.selection) - 1
+ if not (
+ (index == 0 and snap_index == last_index)
+ or (index == last_index and snap_index == 0)
+ or (snap_index in (index - 1, index + 1))
+ ):
+ snap_index = None
+ self._move_info.snap_index = snap_index
+
+ # Show state of snap index to user
+ if snap_index is not None:
+ world_pos = self.geometry.positions.data[snap_index]
+ self._indicator.material.size = 30
+ else:
+ self._indicator.material.size = 15
+
+ self._indicator.local.position = world_pos
+
+ # Update data
+ if self._move_info.mode in ("create", "drag"):
+ data = self.selection
+ if len(data) > 0:
+ if self._move_info.index is None:
+ delta = world_pos - self._move_info.start_pos
+ data[:] = self._move_info.start_positions + delta
+ else:
+ data[self._move_info.index] = world_pos
+ self._selection.set_value(self, data)
+
+ def _on_pointer_up(self, ev):
+ if self._move_info.mode in ("create", "drag"):
+ # If we snapped, we dissolve (i.e. delete the vertex being moved)
+ if self._move_info.snap_index is not None:
+ assert self._move_info.index is not None
+ self._delete_polygon_vertex(self._move_info.index)
+
+ # Moving the mouse up may end the move action
+ if self._move_info.mode == "create":
+ if self._move_info.snap_index is not None:
+ self._end_move_mode()
+ elif self._move_info.mode == "drag":
+ self._end_move_mode()
+
+ def _insert_polygon_vertex(self, i, world_pos):
+ selection = self.selection
+ if len(selection) == 0:
+ data = np.vstack([selection, world_pos, world_pos])
+ else:
+ data = np.vstack([selection[:i], world_pos, selection[i:]])
+ self._selection.set_value(self, data)
+
+ def _delete_polygon_vertex(self, i):
+ selection = self.selection
+ if i < 0:
+ data = selection[:i]
+ else:
+ data = np.vstack([selection[:i], selection[i + 1 :]])
+ self._selection.set_value(self, data)
+
+
+def is_left(p0, p1, p2):
+ """Test if point p2 is left of the line formed by p0 → p1"""
+ return (p1[0] - p0[0]) * (p2[1] - p0[1]) - (p2[0] - p0[0]) * (p1[1] - p0[1])
+
+
+def point_in_polygon(point, polygon):
+ """Determines if the point is inside the polygon using the winding number algorithm."""
+ wn = 0 # winding number counter
+ n = len(polygon)
+
+ for i in range(n):
+ p0 = polygon[i]
+ p1 = polygon[(i + 1) % n]
+
+ if p0[1] <= point[1]: # start y <= point.y
+ if p1[1] > point[1]: # upward crossing
+ if is_left(p0, p1, point) > 0:
+ wn += 1 # point is left of edge
+ else: # start y > point.y
+ if p1[1] <= point[1]: # downward crossing
+ if is_left(p0, p1, point) < 0:
+ wn -= 1 # point is right of edge
+
+ return wn != 0
diff --git a/fastplotlib/graphics/selectors/_rectangle.py b/fastplotlib/graphics/selectors/_rectangle.py
index db7691e07..e30165dae 100644
--- a/fastplotlib/graphics/selectors/_rectangle.py
+++ b/fastplotlib/graphics/selectors/_rectangle.py
@@ -6,6 +6,7 @@
import pygfx
from .._collection_base import GraphicCollection
+from ...utils.enums import RenderQueue
from .._base import Graphic
from ..features import RectangleSelectionFeature
from ._base_selector import BaseSelector, MoveInfo
@@ -135,7 +136,13 @@ def __init__(
self.fill = pygfx.Mesh(
pygfx.box_geometry(width, height, 1),
pygfx.MeshBasicMaterial(
- color=pygfx.Color(self.fill_color), pick_write=True
+ color=pygfx.Color(self.fill_color),
+ alpha_mode="blend",
+ opacity=0.4,
+ render_queue=RenderQueue.selector,
+ depth_test=False,
+ depth_write=False,
+ pick_write=True,
),
)
@@ -153,7 +160,15 @@ def __init__(
left_line = pygfx.Line(
pygfx.Geometry(positions=left_line_data.copy()),
- pygfx.LineMaterial(thickness=edge_thickness, color=self.edge_color),
+ pygfx.LineMaterial(
+ thickness=edge_thickness,
+ color=self.edge_color,
+ alpha_mode="blend",
+ aa=True,
+ render_queue=RenderQueue.selector,
+ depth_test=False,
+ depth_write=False,
+ ),
)
# position data for the right edge line
@@ -166,7 +181,15 @@ def __init__(
right_line = pygfx.Line(
pygfx.Geometry(positions=right_line_data.copy()),
- pygfx.LineMaterial(thickness=edge_thickness, color=self.edge_color),
+ pygfx.LineMaterial(
+ thickness=edge_thickness,
+ color=self.edge_color,
+ alpha_mode="blend",
+ aa=True,
+ render_queue=RenderQueue.selector,
+ depth_test=False,
+ depth_write=False,
+ ),
)
# position data for the left edge line
@@ -179,7 +202,15 @@ def __init__(
bottom_line = pygfx.Line(
pygfx.Geometry(positions=bottom_line_data.copy()),
- pygfx.LineMaterial(thickness=edge_thickness, color=self.edge_color),
+ pygfx.LineMaterial(
+ thickness=edge_thickness,
+ color=self.edge_color,
+ alpha_mode="blend",
+ aa=True,
+ render_queue=RenderQueue.selector,
+ depth_test=False,
+ depth_write=False,
+ ),
)
# position data for the right edge line
@@ -192,7 +223,15 @@ def __init__(
top_line = pygfx.Line(
pygfx.Geometry(positions=top_line_data.copy()),
- pygfx.LineMaterial(thickness=edge_thickness, color=self.edge_color),
+ pygfx.LineMaterial(
+ thickness=edge_thickness,
+ color=self.edge_color,
+ alpha_mode="blend",
+ aa=True,
+ render_queue=RenderQueue.selector,
+ depth_test=False,
+ depth_write=False,
+ ),
)
self.edges: Tuple[pygfx.Line, pygfx.Line, pygfx.Line, pygfx.Line] = (
@@ -203,9 +242,10 @@ def __init__(
) # left line, right line, bottom line, top line
# add the edge lines
+
for edge in self.edges:
- edge.world.z = -0.5
- group.add(edge)
+ edge.render_order = 1
+ group.add(*self.edges)
# vertices
top_left_vertex_data = (xmin, ymax, 1)
@@ -221,6 +261,11 @@ def __init__(
color=self.vertex_color,
size_mode="vertex",
edge_color=self.vertex_color,
+ alpha_mode="blend",
+ aa=True,
+ render_queue=RenderQueue.selector,
+ depth_test=False,
+ depth_write=False,
),
)
@@ -232,6 +277,11 @@ def __init__(
color=self.vertex_color,
size_mode="vertex",
edge_color=self.vertex_color,
+ alpha_mode="blend",
+ aa=True,
+ render_queue=RenderQueue.selector,
+ depth_test=False,
+ depth_write=False,
),
)
@@ -243,6 +293,11 @@ def __init__(
color=self.vertex_color,
size_mode="vertex",
edge_color=self.vertex_color,
+ alpha_mode="blend",
+ aa=True,
+ render_queue=RenderQueue.selector,
+ depth_test=False,
+ depth_write=False,
),
)
@@ -254,6 +309,11 @@ def __init__(
color=self.vertex_color,
size_mode="vertex",
edge_color=self.vertex_color,
+ alpha_mode="blend",
+ aa=True,
+ render_queue=RenderQueue.selector,
+ depth_test=False,
+ depth_write=False,
),
)
@@ -265,7 +325,7 @@ def __init__(
)
for vertex in self.vertices:
- vertex.world.z = -0.25
+ vertex.render_order = 2
group.add(vertex)
self._selection = RectangleSelectionFeature(selection, limits=self._limits)
@@ -337,7 +397,6 @@ def get_selected_data(
f"`mode` must be one of 'full', 'partial', or 'ignore', you have passed {mode}"
)
if "Line" in source.__class__.__name__:
-
if isinstance(source, GraphicCollection):
data_selections: List[np.ndarray] = list()
@@ -431,7 +490,7 @@ def get_selected_indices(
Parameters
----------
graphic: Graphic, default ``None``
- If provided, returns the selection indices from this graphic instrad of the graphic set as ``parent``
+ If provided, returns the selection indices from this graphic instead of the graphic set as ``parent``
Returns
-------
@@ -479,7 +538,6 @@ def get_selected_indices(
return ixs
def _move_graphic(self, move_info: MoveInfo):
-
# If this the first move in this drag, store initial selection
if move_info.start_selection is None:
move_info.start_selection = self.selection
diff --git a/fastplotlib/graphics/text.py b/fastplotlib/graphics/text.py
index fd0e9d702..37e559576 100644
--- a/fastplotlib/graphics/text.py
+++ b/fastplotlib/graphics/text.py
@@ -1,6 +1,7 @@
import pygfx
import numpy as np
+from ..utils.enums import RenderQueue
from ._base import Graphic
from .features import (
TextData,
@@ -20,6 +21,8 @@ class TextGraphic(Graphic):
"outline_thickness": TextOutlineThickness,
}
+ _fpl_support_tooltip = False
+
def __init__(
self,
text: str,
@@ -78,12 +81,21 @@ def __init__(
self._outline_color = TextOutlineColor(outline_color)
self._outline_thickness = TextOutlineThickness(outline_thickness)
+ # Text is usually used for annotations and the like. But we still want it to write depth.
+ # We make it render later than other 'auto' objects, assuming that most of these don't have transparent fragments.
+
+ # The aa is on because it makes the glyphs prettier. It can result in artifacts, but these often express a different color outline
+ # which is actually not so bad; it would look weird on a line, but for text it helps the contrast of the glyph!
+
world_object = pygfx.Text(
text=self.text,
font_size=self.font_size,
screen_space=screen_space,
anchor=anchor,
material=pygfx.TextMaterial(
+ alpha_mode="auto",
+ render_queue=RenderQueue.auto + 50,
+ aa=True,
color=self.face_color,
outline_color=self.outline_color,
outline_thickness=self.outline_thickness,
diff --git a/fastplotlib/layouts/_figure.py b/fastplotlib/layouts/_figure.py
index bfd97000b..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()
@@ -534,39 +533,28 @@ def names(self) -> np.ndarray[str]:
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
-
- @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 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}
def _render(self, draw=True):
# draw the underlay planes
self.renderer.render(self._underlay_scene, self._underlay_camera, flush=False)
+ # With new pygfx' blending, the depth buffer is only cleared after each flush, we need a manual depth
+ # clear to erase the depth values set by the underlay.
+ if hasattr(self.renderer, "clear"):
+ self.renderer.clear(depth=True)
+
# call the animation functions before render
self._call_animate_functions(self._animate_funcs_pre)
for subplot in self:
subplot._render()
# overlay render pass
- self.renderer.render(self._overlay_scene, self._overlay_camera, flush=False)
+ if hasattr(self.renderer, "clear"):
+ self.renderer.clear(depth=True)
+ self.renderer.render(self._fpl_overlay_scene, self._overlay_camera, flush=False)
self.renderer.flush()
@@ -585,6 +573,7 @@ def show(
self,
autoscale: bool = True,
maintain_aspect: bool = None,
+ axes_visible: bool = True,
sidecar: bool = False,
sidecar_kwargs: dict = None,
):
@@ -599,6 +588,9 @@ def show(
maintain_aspect: bool, default ``True``
maintain aspect ratio
+ axes_visible: bool, default ``True``
+ show axes
+
sidecar: bool, default ``True``
display plot in a ``jupyterlab-sidecar``, only in jupyter
@@ -637,6 +629,11 @@ def show(
_maintain_aspect = maintain_aspect
subplot.auto_scale(maintain_aspect=maintain_aspect)
+ # set axes visibility if False
+ if not axes_visible:
+ for subplot in self:
+ subplot.axes.visible = False
+
# parse based on canvas type
if self.canvas.__class__.__name__ == "JupyterRenderCanvas":
if sidecar:
@@ -749,6 +746,37 @@ def remove_animation(self, func):
if func in self._animate_funcs_post:
self._animate_funcs_post.remove(func)
+ def clear_animations(self, removal: str = None):
+ """
+ Remove animation functions.
+
+ Parameters
+ ----------
+ removal: str, default ``None``
+ The type of animation functions to clear. One of 'pre' or 'post'. If `None`, removes all animation
+ functions.
+ """
+ if removal is None:
+ # remove all
+ for func in self._animate_funcs_pre:
+ self._animate_funcs_pre.remove(func)
+
+ for func in self._animate_funcs_post:
+ self._animate_funcs_post.remove(func)
+ elif removal == "pre":
+ # only pre
+ for func in self._animate_funcs_pre:
+ self._animate_funcs_pre.remove(func)
+ elif removal == "post":
+ # only post
+ for func in self._animate_funcs_post:
+ self._animate_funcs_post.remove(func)
+ else:
+ raise ValueError(
+ f"Animation type: {removal} must be one of 'pre' or 'post'. To remove all animation "
+ f"functions, pass `type=None`"
+ )
+
def clear(self):
"""Clear all Subplots"""
for subplot in self:
diff --git a/fastplotlib/layouts/_frame.py b/fastplotlib/layouts/_frame.py
index cd2a1cbc2..1c308590f 100644
--- a/fastplotlib/layouts/_frame.py
+++ b/fastplotlib/layouts/_frame.py
@@ -1,6 +1,7 @@
import numpy as np
import pygfx
+from ..utils.enums import RenderQueue
from ._rect import RectManager
from ._utils import IMGUI_TOOLBAR_HEIGHT
from ..utils.types import SelectorColorStates
@@ -37,7 +38,7 @@
# wgsl shader snippet for SDF function that defines the resize handler, a lower right triangle.
sdf_wgsl_resize_handle = """
-// hardcode square root of 2
+// hardcode square root of 2
let m_sqrt_2 = 1.4142135;
// given a distance from an origin point, this defines the hypotenuse of a lower right triangle
@@ -171,16 +172,28 @@ def __init__(
else:
title_text = title
self._title_graphic = TextGraphic(title_text, font_size=16, face_color="white")
+ m = self._title_graphic.world_object.material
+ m.alpha_mode = "blend"
+ m.render_queue = RenderQueue.background
+ m.depth_write = False
+ m.depth_test = False
wobjects.append(self._title_graphic.world_object)
# init mesh of size 1 to graphically represent rect
geometry = pygfx.plane_geometry(1, 1)
- material = pygfx.MeshBasicMaterial(color=self.plane_color.idle, pick_write=True)
+ material = pygfx.MeshBasicMaterial(
+ alpha_mode="blend",
+ render_queue=RenderQueue.background,
+ color=self.plane_color.idle,
+ depth_write=False,
+ depth_test=False,
+ pick_write=True,
+ )
self._plane = pygfx.Mesh(geometry, material)
wobjects.append(self._plane)
- # otherwise text isn't visible
- self._plane.world.z = 0.5
+ # Plane gets rendered before text and point
+ self._plane.render_order = -1
# create resize handler at point (x1, y1)
x1, y1 = self.extent[[1, 3]]
@@ -189,21 +202,21 @@ def __init__(
# subtract 7 so that the bottom right corner of the triangle is at the center
pygfx.Geometry(positions=[[x1 - 7, -y1 + 7, 0]]),
pygfx.PointsMarkerMaterial(
+ alpha_mode="blend",
+ render_queue=RenderQueue.background,
color=self.resize_handle_color.idle,
marker="custom",
custom_sdf=sdf_wgsl_resize_handle,
size=12,
size_space="screen",
+ depth_write=False,
+ depth_test=False,
pick_write=True,
),
)
if not resizeable:
- # set all color states to transparent if Frame isn't resizeable
- c = (0, 0, 0, 0)
- self._resize_handle.material.color = c
- self._resize_handle.material.edge_width = 0
- self.resize_handle_color = SelectorColorStates(c, c, c)
+ self._resize_handle.visible = False
wobjects.append(self._resize_handle)
diff --git a/fastplotlib/layouts/_graphic_methods_mixin.py b/fastplotlib/layouts/_graphic_methods_mixin.py
index cb9cd04c0..06a4c7517 100644
--- a/fastplotlib/layouts/_graphic_methods_mixin.py
+++ b/fastplotlib/layouts/_graphic_methods_mixin.py
@@ -4,6 +4,8 @@
import numpy
+import pygfx
+
from ..graphics import *
from ..graphics._base import Graphic
@@ -26,8 +28,8 @@ def _create_graphic(self, graphic_class, *args, **kwargs) -> Graphic:
def add_image(
self,
data: Any,
- vmin: int = None,
- vmax: int = None,
+ vmin: float = None,
+ vmax: float = None,
cmap: str = "plasma",
interpolation: str = "nearest",
cmap_interpolation: str = "linear",
@@ -44,11 +46,11 @@ def add_image(
array-like, usually numpy.ndarray, must support ``memoryview()``
| shape must be ``[n_rows, n_cols]``, ``[n_rows, n_cols, 3]`` for RGB or ``[n_rows, n_cols, 4]`` for RGBA
- vmin: int, optional
- minimum value for color scaling, calculated from data if not provided
+ vmin: float, optional
+ minimum value for color scaling, estimated from data if not provided
- vmax: int, optional
- maximum value for color scaling, calculated from data if not provided
+ vmax: float, optional
+ maximum value for color scaling, estimated from data if not provided
cmap: str, optional, default "plasma"
colormap to use to display the data. For supported colormaps see the
@@ -83,13 +85,114 @@ def add_image(
**kwargs,
)
+ def add_image_volume(
+ self,
+ data: Any,
+ mode: str = "mip",
+ vmin: float = None,
+ vmax: float = None,
+ cmap: str = "plasma",
+ interpolation: str = "linear",
+ cmap_interpolation: str = "linear",
+ plane: tuple[float, float, float, float] = (0, 0, -1, 0),
+ threshold: float = 0.5,
+ step_size: float = 1.0,
+ substep_size: float = 0.1,
+ emissive: str | tuple | numpy.ndarray = (0, 0, 0),
+ shininess: int = 30,
+ isolated_buffer: bool = True,
+ **kwargs,
+ ) -> ImageVolumeGraphic:
+ """
+
+ Create an ImageVolumeGraphic.
+
+ Parameters
+ ----------
+ data: array-like
+ array-like, usually numpy.ndarray, must support ``memoryview()``.
+ Shape must be [n_planes, n_rows, n_cols] for grayscale, or [n_planes, n_rows, n_cols, 3 | 4] for RGB(A)
+
+ mode: str, default "mip"
+ render mode, one of "mip", "minip", "iso" or "slice"
+
+ vmin: float
+ lower contrast limit
+
+ vmax: float
+ upper contrast limit
+
+ cmap: str, default "plasma"
+ colormap for grayscale volumes
+
+ interpolation: str, default "linear"
+ interpolation method for sampling pixels
+
+ cmap_interpolation: str, default "linear"
+ interpolation method for sampling from colormap
+
+ plane: (float, float, float, float), default (0, 0, -1, 0)
+ Slice volume 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"
+
+ threshold : float, default 0.5
+ The threshold texture value at which the surface is rendered.
+ Used only if `mode` = "iso"
+
+ step_size : float, default 1.0
+ The size of the initial ray marching step for the initial surface finding. Smaller values will result in
+ more accurate surfaces but slower rendering.
+ Used only if `mode` = "iso"
+
+ substep_size : float, default 0.1
+ The size of the raymarching step for the refined surface finding. Smaller values will result in more
+ accurate surfaces but slower rendering.
+ Used only if `mode` = "iso"
+
+ emissive : Color, default (0, 0, 0, 1)
+ The emissive color of the surface. I.e. the color that the object emits even when not lit by a light
+ source. This color is added to the final color and unaffected by lighting. The alpha channel is ignored.
+ Used only if `mode` = "iso"
+
+ shininess : int, default 30
+ How shiny the specular highlight is; a higher value gives a sharper highlight.
+ Used only if `mode` = "iso"
+
+ 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.
+
+ kwargs
+ additional keyword arguments passed to :class:`.Graphic`
+
+
+ """
+ return self._create_graphic(
+ ImageVolumeGraphic,
+ data,
+ mode,
+ vmin,
+ vmax,
+ cmap,
+ interpolation,
+ cmap_interpolation,
+ plane,
+ threshold,
+ step_size,
+ substep_size,
+ emissive,
+ shininess,
+ isolated_buffer,
+ **kwargs,
+ )
+
def add_line_collection(
self,
data: Union[numpy.ndarray, List[numpy.ndarray]],
thickness: Union[float, Sequence[float]] = 2.0,
colors: Union[str, Sequence[str], numpy.ndarray, Sequence[numpy.ndarray]] = "w",
uniform_colors: bool = False,
- alpha: float = 1.0,
cmap: Union[Sequence[str], str] = None,
cmap_transform: Union[numpy.ndarray, List] = None,
name: str = None,
@@ -122,9 +225,6 @@ def add_line_collection(
| if ``list`` of ``str``, represents color for each individual line, example ["w", "b", "r",...]
| if ``RGBA array`` of shape [data_size, 4], represents a single RGBA array for each line
- alpha: float, optional
- alpha value for colors, if colors is a ``str``
-
cmap: Iterable of str or str, optional
| if ``str``, single cmap will be used for all lines
| if ``list`` of ``str``, each cmap will apply to the individual lines
@@ -162,7 +262,6 @@ def add_line_collection(
thickness,
colors,
uniform_colors,
- alpha,
cmap,
cmap_transform,
name,
@@ -180,7 +279,6 @@ def add_line(
thickness: float = 2.0,
colors: Union[str, numpy.ndarray, Sequence] = "w",
uniform_color: bool = False,
- alpha: float = 1.0,
cmap: str = None,
cmap_transform: Union[numpy.ndarray, Sequence] = None,
isolated_buffer: bool = True,
@@ -210,9 +308,6 @@ def add_line(
if True, uses a uniform buffer for the line color,
basically saves GPU VRAM when the entire line has a single color
- alpha: float, optional, default 1.0
- alpha value for the colors
-
cmap: str, optional
Apply a colormap to the line instead of assigning colors manually, this
overrides any argument passed to "colors". For supported colormaps see the
@@ -235,7 +330,6 @@ def add_line(
thickness,
colors,
uniform_color,
- alpha,
cmap,
cmap_transform,
isolated_buffer,
@@ -248,7 +342,6 @@ def add_line_stack(
data: List[numpy.ndarray],
thickness: Union[float, Iterable[float]] = 2.0,
colors: Union[str, Iterable[str], numpy.ndarray, Iterable[numpy.ndarray]] = "w",
- alpha: float = 1.0,
cmap: Union[Iterable[str], str] = None,
cmap_transform: Union[numpy.ndarray, List] = None,
name: str = None,
@@ -283,9 +376,6 @@ def add_line_stack(
| if ``list`` of ``str``, represents color for each individual line, example ["w", "b", "r",...]
| if ``RGBA array`` of shape [data_size, 4], represents a single RGBA array for each line
- alpha: float, optional
- alpha value for colors, if colors is a ``str``
-
cmap: Iterable of str or str, optional
| if ``str``, single cmap will be used for all lines
| if ``list`` of ``str``, each cmap will apply to the individual lines
@@ -329,7 +419,6 @@ def add_line_stack(
data,
thickness,
colors,
- alpha,
cmap,
cmap_transform,
name,
@@ -343,18 +432,167 @@ 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,
- colors: str | numpy.ndarray | tuple[float] | list[float] | list[str] = "w",
+ colors: Union[str, numpy.ndarray, Sequence[float], Sequence[str]] = "w",
uniform_color: bool = False,
- alpha: float = 1.0,
cmap: str = None,
cmap_transform: numpy.ndarray = None,
- isolated_buffer: bool = True,
+ mode: Literal["markers", "simple", "gaussian", "image"] = "markers",
+ markers: Union[str, numpy.ndarray, Sequence[str]] = "o",
+ uniform_marker: bool = False,
+ custom_sdf: str = None,
+ edge_colors: Union[
+ str, pygfx.utils.color.Color, numpy.ndarray, Sequence[float]
+ ] = "black",
+ uniform_edge_color: bool = True,
+ edge_width: float = 1.0,
+ image: numpy.ndarray = None,
+ point_rotations: float | numpy.ndarray = 0,
+ point_rotation_mode: Literal["uniform", "vertex", "curve"] = "uniform",
sizes: Union[float, numpy.ndarray, Sequence[float]] = 1,
uniform_size: bool = False,
size_space: str = "screen",
+ isolated_buffer: bool = True,
**kwargs,
) -> ScatterGraphic:
"""
@@ -375,9 +613,6 @@ def add_scatter(
if True, uses a uniform buffer for the scatter point colors. Useful if you need to
save GPU VRAM when all points have the same color.
- alpha: float, optional, default 1.0
- alpha value for the colors
-
cmap: str, optional
apply a colormap to the scatter instead of assigning colors manually, this
overrides any argument passed to "colors". For supported colormaps see the
@@ -386,9 +621,65 @@ def add_scatter(
cmap_transform: 1D array-like or list of numerical values, optional
if provided, these values are used to map the colors from the cmap
- isolated_buffer: bool, default True
- whether the buffers should be isolated from the user input array.
- Generally always ``True``, ``False`` is for rare advanced use if you have large arrays.
+ mode: one of: "markers", "simple", "gaussian", "image", default "markers"
+ The scatter points mode, cannot be changed after the graphic has been created.
+
+ * markers: represent points with various or custom markers, default
+ * simple: all scatters points are simple circles
+ * gaussian: each point is a gaussian blob
+ * image: use an image for each point, pass an array to the `image` kwarg, these are also called sprites
+
+ markers: None | str | np.ndarray | Sequence[str], default "o"
+ The shape of the markers when `mode` is "markers"
+
+ Supported values:
+
+ * A string from pygfx.MarkerShape enum
+ * Matplotlib compatible characters: "osD+x^v<>*".
+ * Unicode symbols: "●○■♦♥♠♣✳▲▼◀▶".
+ * Emojis: "❤️♠️♣️♦️💎💍✳️📍".
+ * A string containing the value "custom". In this case, WGSL code defined by ``custom_sdf`` will be used.
+
+ uniform_marker: bool, default False
+ Use the same marker for all points. Only valid when `mode` is "markers". Useful if you need to use
+ the same marker for all points and want to save GPU RAM.
+
+ custom_sdf: str = None,
+ The SDF code for the marker shape when the marker is set to custom.
+ Can be used when `mode` is "markers".
+
+ Negative values are inside the shape, positive values are outside the
+ shape.
+
+ The SDF's takes in two parameters `coords: vec2` and `size: f32`.
+ The first is a WGSL coordinate and `size` is the overall size of
+ the texture. The returned value should be the signed distance from
+ any edge of the shape. Distances (positive and negative) that are
+ less than half the `edge_width` in absolute terms will be colored
+ with the `edge_color`. Other negative distances will be colored by
+ `colors`.
+
+ edge_colors: str | np.ndarray | pygfx.Color | Sequence[float], default "black"
+ edge color of the markers, used when `mode` is "markers"
+
+ uniform_edge_color: bool, default True
+ Set the same edge color for all markers. Useful for saving GPU RAM.
+
+ edge_width: float = 1.0,
+ Width of the marker edges. used when `mode` is "markers".
+
+ image: ArrayLike, optional
+ renders an image at the scatter points, also known as sprites.
+ The image color is multiplied with the point's "normal" color.
+
+ point_rotations: float | ArrayLike = 0,
+ The rotation of the scatter points in radians. Default 0. A single float rotation value can be set on all
+ points, or an array of rotation values can be used to set per-point rotations
+
+ point_rotation_mode: one of: "uniform" | "vertex" | "curve", default "uniform"
+ * uniform: set the same rotation for every point, useful to save GPU RAM
+ * vertex: set per-vertex rotations
+ * curve: The rotation follows the curve of the line defined by the points (in screen space)
sizes: float or iterable of float, optional, default 1.0
sizes of the scatter points
@@ -398,7 +689,11 @@ def add_scatter(
save GPU VRAM when all points have the same size.
size_space: str, default "screen"
- coordinate space in which the size is expressed ("screen", "world", "model")
+ coordinate space in which the size is expressed, one of ("screen", "world", "model")
+
+ isolated_buffer: bool, default True
+ whether the buffers should be isolated from the user input array.
+ Generally always ``True``, ``False`` is for rare advanced use if you have large arrays.
kwargs
passed to :class:`.Graphic`
@@ -410,16 +705,83 @@ def add_scatter(
data,
colors,
uniform_color,
- alpha,
cmap,
cmap_transform,
- isolated_buffer,
+ mode,
+ markers,
+ uniform_marker,
+ custom_sdf,
+ edge_colors,
+ uniform_edge_color,
+ edge_width,
+ image,
+ point_rotations,
+ point_rotation_mode,
sizes,
uniform_size,
size_space,
+ isolated_buffer,
**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,
@@ -483,3 +845,58 @@ def add_text(
anchor,
**kwargs,
)
+
+ def add_vectors(
+ self,
+ positions: Union[numpy.ndarray, Sequence[float]],
+ directions: Union[numpy.ndarray, Sequence[float]],
+ color: Union[str, Sequence[float], numpy.ndarray] = "w",
+ size: float = None,
+ vector_shape_options: dict = None,
+ **kwargs,
+ ) -> VectorsGraphic:
+ """
+
+ Create graphic that draw vectors. Similar to matplotlib quiver.
+
+ Parameters
+ ----------
+ positions: np.ndarray | Sequence[float]
+ positions of the vectors, array-like, shape must be [n, 2] or [n, 3] where n is the number of vectors.
+
+ directions: np.ndarray | Sequence[float]
+ directions of the vectors, array-like, shape must be [n, 2] or [n, 3] where n is the number of vectors.
+
+ spacing: float
+ average distance between pairs of nearest-neighbor vectors, used for scaling
+
+ color: str | pygfx.Color | Sequence[float] | np.ndarray, default "w"
+ color of the vectors
+
+ size: float or None
+ Size of a vector of magnitude 1 in world space for display purpose.
+ Estimated from density if not provided.
+
+ vector_shape_options: dict
+ dict with the following fields that directly describes the shape of the vector arrows.
+ Overrides ``size`` argument.
+
+ * cone_radius
+ * cone_height
+ * stalk_radius
+ * stalk_height
+
+ **kwargs
+ passed to :class:`.Graphic`
+
+
+ """
+ return self._create_graphic(
+ VectorsGraphic,
+ positions,
+ directions,
+ color,
+ size,
+ vector_shape_options,
+ **kwargs,
+ )
diff --git a/fastplotlib/layouts/_imgui_figure.py b/fastplotlib/layouts/_imgui_figure.py
index c54890239..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,12 +60,19 @@ def __init__(
canvas_kwargs=canvas_kwargs,
size=size,
names=names,
- show_tooltips=show_tooltips,
)
self._imgui_renderer = ImguiRenderer(self.renderer.device, self.canvas)
- fronts_path = str(
+ # This loads both the Roboto Font and FontAwesome 6 icons and creates and merged font
+ # allowing us to use both without pushing and popping to display icons or regular text
+ sans_serif_font = str(
+ Path(imgui_bundle.__file__).parent.joinpath(
+ "assets", "fonts", "Roboto", "Roboto-Regular.ttf"
+ )
+ )
+
+ fa_6_fonts_path = str(
Path(imgui_bundle.__file__).parent.joinpath(
"assets", "fonts", "Font_Awesome_6_Free-Solid-900.otf"
)
@@ -74,12 +80,20 @@ def __init__(
io = imgui.get_io()
- self._fa_icons = io.fonts.add_font_from_file_ttf(
- fronts_path, 16, glyph_ranges_as_int_list=[fa.ICON_MIN_FA, fa.ICON_MAX_FA]
+ self._default_imgui_font = io.fonts.add_font_from_file_ttf(
+ sans_serif_font, 14, imgui.ImFontConfig()
+ )
+
+ font_config = imgui.ImFontConfig()
+ font_config.merge_mode = True
+
+ self._default_imgui_font = io.fonts.add_font_from_file_ttf(
+ fa_6_fonts_path,
+ 14,
+ font_config,
)
- io.fonts.build()
- self.imgui_renderer.backend.create_fonts_texture()
+ imgui.push_font(self._default_imgui_font, self._default_imgui_font.legacy_size)
self.imgui_renderer.set_gui(self._draw_imgui)
@@ -88,12 +102,10 @@ def __init__(
)
for i, subplot in enumerate(self._subplots.ravel()):
- toolbar = SubplotToolbar(subplot=subplot, fa_icons=self._fa_icons)
+ toolbar = SubplotToolbar(subplot=subplot)
self._subplot_toolbars[i] = toolbar
- self._right_click_menu = StandardRightClickMenu(
- figure=self, fa_icons=self._fa_icons
- )
+ self._right_click_menu = StandardRightClickMenu(figure=self)
self._popups: dict[str, Popup] = {}
@@ -102,6 +114,10 @@ def __init__(
self.register_popup(ColormapPicker)
+ @property
+ def default_imgui_font(self) -> imgui.ImFont:
+ return self._default_imgui_font
+
@property
def guis(self) -> dict[str, EdgeWindow]:
"""GUI windows added to the Figure"""
@@ -125,7 +141,7 @@ def _render(self, draw=False):
self.canvas.request_draw()
def _draw_imgui(self) -> imgui.ImDrawData:
- imgui.new_frame()
+ # imgui.new_frame()
for subplot, toolbar in zip(
self._subplots.ravel(), self._subplot_toolbars.ravel()
@@ -144,11 +160,11 @@ def _draw_imgui(self) -> imgui.ImDrawData:
self._right_click_menu.update()
- imgui.end_frame()
+ # imgui.end_frame()
- imgui.render()
+ # imgui.render()
- return imgui.get_draw_data()
+ # return imgui.get_draw_data()
def add_gui(self, gui: EdgeWindow):
"""
@@ -179,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,
@@ -192,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]:
@@ -201,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 2542fc215..5d38ce37d 100644
--- a/fastplotlib/layouts/_plot_area.py
+++ b/fastplotlib/layouts/_plot_area.py
@@ -9,10 +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:
@@ -87,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()
@@ -111,10 +115,21 @@ def __init__(
(0.0, 0.0, 0.0, 1.0),
(0.0, 0.0, 0.0, 1.0),
(0.0, 0.0, 0.0, 1.0),
+ alpha_mode="blend",
)
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:
@@ -164,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)
@@ -272,17 +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
@@ -299,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)
@@ -308,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)
@@ -319,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:
@@ -393,6 +549,60 @@ def remove_animation(self, func):
if func in self._animate_funcs_post:
self._animate_funcs_post.remove(func)
+ def clear_animations(self, removal: str = None):
+ """
+ Remove animation functions.
+
+ Parameters
+ ----------
+ removal: str, default ``None``
+ The type of animation functions to clear. One of 'pre' or 'post'. If `None`, removes all animation
+ functions.
+ """
+ if removal is None:
+ # remove all
+ for func in self._animate_funcs_pre:
+ self._animate_funcs_pre.remove(func)
+
+ for func in self._animate_funcs_post:
+ self._animate_funcs_post.remove(func)
+ elif removal == "pre":
+ # only pre
+ for func in self._animate_funcs_pre:
+ self._animate_funcs_pre.remove(func)
+ elif removal == "post":
+ # only post
+ for func in self._animate_funcs_post:
+ self._animate_funcs_post.remove(func)
+ else:
+ raise ValueError(
+ f"Animation type: {removal} must be one of 'pre' or 'post'. To remove all animation "
+ f"functions, pass `type=None`"
+ )
+
+ def _sort_images_by_depth(self):
+ """
+ In general, we want to avoid setting the offset of a graphic, because the
+ z-dimension may actually mean something; we cannot know whether the user is
+ building a 3D scene or not. We could check whether the 3d dimension of line/point data
+ is all zeros, but maybe this is intended, and *other* graphics in the same scene
+ may be actually 3D. We could check camera.fov being zero, but maybe the user
+ switches to a 3D camera later, or uses a 3D orthographic camera.
+
+ The one exception, kindof, is images, which are inherently 2D, and for which
+ layering helps a lot to get things rendered correctly. So we basically layer the
+ images, in the order that they were added, pushing older images backwards (away
+ from the camera).
+ """
+ count = 0
+ for graphic in reversed(self._graphics):
+ if isinstance(graphic, ImageGraphic):
+ count += 1
+ auto_depth = -count
+ user_changed_depth = graphic.offset[2] % 1 > 0.0 # i.e. is not integer
+ if not user_changed_depth:
+ graphic.offset = (*graphic.offset[:-1], auto_depth)
+
def add_graphic(self, graphic: Graphic, center: bool = True):
"""
Add a Graphic to the scene
@@ -415,10 +625,8 @@ def add_graphic(self, graphic: Graphic, center: bool = True):
self._add_or_insert_graphic(graphic=graphic, center=center, action="add")
- if self.camera.fov == 0:
- # for orthographic positions stack objects along the z-axis
- # for perspective projections we assume the user wants full 3D control
- graphic.offset = (*graphic.offset[:-1], len(self))
+ if isinstance(graphic, ImageGraphic):
+ self._sort_images_by_depth()
def insert_graphic(
self,
@@ -457,17 +665,14 @@ def insert_graphic(
graphic=graphic, center=center, action="insert", index=index
)
- if self.camera.fov == 0:
- # for orthographic positions stack objects along the z-axis
- # for perspective projections we assume the user wants full 3D control
- if auto_offset:
- graphic.offset = (*graphic.offset[:-1], index)
+ if isinstance(graphic, ImageGraphic):
+ self._sort_images_by_depth()
def _add_or_insert_graphic(
self,
graphic: Graphic,
center: bool = True,
- action: str = Literal["insert", "add"],
+ action: Literal["insert", "add"] = "add",
index: int = 0,
):
"""Private method to handle inserting or adding a graphic to a PlotArea."""
@@ -491,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")
@@ -551,14 +752,33 @@ def center_scene(self, *, zoom: float = 1.0):
if not len(self._fpl_graphics_scene.children) > 0:
return
- # scale all cameras associated with this controller
- # else it looks wonky
- for camera in self.controller.cameras:
- camera.show_object(self._fpl_graphics_scene)
+ if self.parent.__class__.__name__.endswith("Figure"):
+ # always use figure._subplots.ravel() in internal fastplotlib code
+ # otherwise if we use `for subplot in figure`, this could conflict
+ # with a user's iterator where they are doing `for subplot in figure` !!!
+ for subplot in self.parent._subplots.ravel():
+ # scale all cameras associated with this controller
+ if subplot.camera in self.controller.cameras:
+ # skip if the scene is empty
+ if len(subplot._fpl_graphics_scene.children) < 1:
+ continue
+
+ # center the camera in the other subplot w.r.t. the scene in that other subplot!
+ self._auto_center_scene(
+ subplot.camera, subplot._fpl_graphics_scene, zoom
+ )
+ else:
+ # just change for this plot area
+ # this is probably a dock area
+ self._auto_center_scene(self.camera, self._fpl_graphics_scene, zoom)
- # camera.show_object can cause the camera width and height to increase so apply a zoom to compensate
- # probably because camera.show_object uses bounding sphere
- camera.zoom = zoom
+ def _auto_center_scene(
+ self, camera: pygfx.PerspectiveCamera, scene: pygfx.Scene, zoom: float
+ ):
+ camera.show_object(scene)
+ # camera.show_object can cause the camera width and height to increase so apply a zoom to compensate
+ # probably because camera.show_object uses bounding sphere
+ camera.zoom = zoom
def auto_scale(
self,
@@ -586,16 +806,43 @@ def auto_scale(
self.center_scene()
if maintain_aspect is None: # if not provided keep current setting
+ # use the same maintain apsect for all other cameras that this controller manages
+ # I think this make sense for most use cases, even when the other controllers are
+ # only managing one or 2 axes
maintain_aspect = self.camera.maintain_aspect
- # scale all cameras associated with this controller else it looks wonky
- for camera in self.controller.cameras:
- camera.maintain_aspect = maintain_aspect
+ if self.parent.__class__.__name__.endswith("Figure"):
+ # always use figure._subplots.ravel() in internal fastplotlib code
+ # otherwise if we use `for subplot in figure`, this could conflict
+ # with a user's iterator where they are doing `for subplot in figure` !!!
+ for subplot in self.parent._subplots.ravel():
+ # skip if the scene is empty
+ if len(subplot._fpl_graphics_scene.children) < 1:
+ continue
- if len(self._fpl_graphics_scene.children) > 0:
- width, height, depth = np.ptp(
- self._fpl_graphics_scene.get_world_bounding_box(), axis=0
+ # scale the camera in the other subplot w.r.t. the scene in that other subplot!
+ if subplot.camera in self.controller.cameras:
+ camera = subplot.camera
+ self._auto_scale_scene(
+ camera, subplot._fpl_graphics_scene, zoom, maintain_aspect
+ )
+ else:
+ # just change for this plot area, this is probably a dock area
+ self._auto_scale_scene(
+ self.camera, self._fpl_graphics_scene, zoom, maintain_aspect
)
+
+ def _auto_scale_scene(
+ self,
+ camera: pygfx.PerspectiveCamera,
+ scene: pygfx.Scene,
+ zoom: float,
+ maintain_aspect: bool,
+ ):
+ camera.maintain_aspect = maintain_aspect
+
+ if len(scene.children) > 0:
+ width, height, depth = np.ptp(scene.get_world_bounding_box(), axis=0)
else:
width, height, depth = (1, 1, 1)
@@ -605,12 +852,10 @@ def auto_scale(
if height < 0.01:
height = 1
- # scale all cameras associated with this controller else it looks wonky
- for camera in self.controller.cameras:
- camera.width = width
- camera.height = height
+ camera.width = width
+ camera.height = height
- camera.zoom = zoom
+ camera.zoom = zoom
def remove_graphic(self, graphic: Graphic):
"""
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/layouts/_utils.py b/fastplotlib/layouts/_utils.py
index 98a6268f1..49120c71a 100644
--- a/fastplotlib/layouts/_utils.py
+++ b/fastplotlib/layouts/_utils.py
@@ -17,7 +17,7 @@
# number of pixels taken by the imgui toolbar when present
-IMGUI_TOOLBAR_HEIGHT = 39
+IMGUI_TOOLBAR_HEIGHT = 36
def make_canvas_and_renderer(
diff --git a/fastplotlib/legends/legend.py b/fastplotlib/legends/legend.py
index 69a556109..9da836fd7 100644
--- a/fastplotlib/legends/legend.py
+++ b/fastplotlib/legends/legend.py
@@ -5,6 +5,7 @@
import numpy as np
import pygfx
+from ..utils.enums import RenderQueue
from ..graphics import Graphic
from ..graphics.features import GraphicFeatureEvent
from ..graphics import LineGraphic, ScatterGraphic, ImageGraphic
@@ -70,26 +71,34 @@ def __init__(
# construct Line WorldObject
data = np.array([[0, 0, 0], [3, 0, 0]], dtype=np.float32)
- material = pygfx.LineMaterial
-
self._line_world_object = pygfx.Line(
geometry=pygfx.Geometry(positions=data),
- material=material(thickness=8, color=self._color),
+ material=pygfx.LineMaterial(
+ alpha_mode="blend",
+ render_queue=RenderQueue.overlay,
+ thickness=8,
+ color=self._color,
+ depth_write=False,
+ depth_test=False,
+ ),
)
# self._line_world_object.world.x = position[0]
self._label_world_object = pygfx.Text(
- geometry=pygfx.TextGeometry(
- text=str(label),
- font_size=6,
- screen_space=False,
- anchor="middle-left",
- ),
+ text=str(label),
+ font_size=6,
+ screen_space=False,
+ anchor="middle-left",
material=pygfx.TextMaterial(
+ alpha_mode="blend",
+ aa=True,
+ render_queue=RenderQueue.overlay,
color="w",
outline_color="w",
outline_thickness=0,
+ depth_write=False,
+ depth_test=False,
),
)
@@ -101,7 +110,6 @@ def __init__(
self._label_world_object.world.x = position[0] + 10
self.world_object.world.y = position[1]
- self.world_object.world.z = 2
self.world_object.add_event_handler(
partial(self._highlight_graphic, graphic), "click"
@@ -175,10 +183,18 @@ def __init__(
self._mesh = pygfx.Mesh(
pygfx.box_geometry(50, 10, 1),
pygfx.MeshBasicMaterial(
- color=pygfx.Color([0.1, 0.1, 0.1, 1]), wireframe_thickness=10
+ alpha_mode="blend",
+ render_queue=RenderQueue.overlay,
+ color=pygfx.Color([0.1, 0.1, 0.1, 1]),
+ wireframe_thickness=10,
+ depth_write=False,
+ depth_test=False,
),
)
+ # Plane gets rendered before text and line
+ self._mesh.render_order = -1
+
self.world_object.add(self._mesh)
self.world_object.add(self._legend_items_group)
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 aeb8dd996..d651137da 100644
--- a/fastplotlib/tools/_histogram_lut.py
+++ b/fastplotlib/tools/_histogram_lut.py
@@ -1,4 +1,5 @@
from math import ceil
+from typing import Sequence
import weakref
import numpy as np
@@ -6,7 +7,7 @@
import pygfx
from ..utils import subsample_array
-from ..graphics import LineGraphic, ImageGraphic, TextGraphic
+from ..graphics import LineGraphic, ImageGraphic, ImageVolumeGraphic, TextGraphic
from ..graphics.utils import pause_events
from ..graphics._base import Graphic
from ..graphics.selectors import LinearRegionSelector
@@ -26,31 +27,63 @@ 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,
- image_graphic: ImageGraphic,
+ images: (
+ ImageGraphic
+ | ImageVolumeGraphic
+ | Sequence[ImageGraphic | ImageVolumeGraphic]
+ ),
nbins: int = 100,
flank_divisor: float = 5.0,
**kwargs,
):
"""
+ HistogramLUT tool that can be used to control the vmin, vmax of ImageGraphics or ImageVolumeGraphics.
+ If used to control multiple images or image volumes it is assumed that they share a representation of
+ the same data, and that their histogram, vmin, and vmax are identical. For example, displaying a
+ ImageVolumeGraphic and several images that represent slices of the same volume data.
Parameters
----------
- data
- image_graphic
+ data: np.ndarray
+
+ images: ImageGraphic | ImageVolumeGraphic | tuple[ImageGraphic | ImageVolumeGraphic]
+
nbins: int, defaut 100.
Total number of bins used in the histogram
+
flank_divisor: float, default 5.0.
Fraction of empty histogram bins on the tails of the distribution set `np.inf` for no flanks
- kwargs
+
+ kwargs: passed to ``Graphic``
+
"""
super().__init__(**kwargs)
self._nbins = nbins
self._flank_divisor = flank_divisor
- self._image_graphic = image_graphic
+
+ if isinstance(images, (ImageGraphic, ImageVolumeGraphic)):
+ images = (images,)
+ elif isinstance(images, Sequence):
+ if not all(
+ [isinstance(ig, (ImageGraphic, ImageVolumeGraphic)) for ig in images]
+ ):
+ raise TypeError(
+ f"`images` argument must be an ImageGraphic, ImageVolumeGraphic, or a "
+ f"tuple or list or ImageGraphic | ImageVolumeGraphic"
+ )
+ else:
+ raise TypeError(
+ f"`images` argument must be an ImageGraphic, ImageVolumeGraphic, or a "
+ f"tuple or list or ImageGraphic | ImageVolumeGraphic"
+ )
+
+ self._images = images
self._data = weakref.proxy(data)
@@ -60,7 +93,9 @@ def __init__(
line_data = np.column_stack([hist_scaled, edges_flanked])
- self._histogram_line = LineGraphic(line_data)
+ self._histogram_line = LineGraphic(
+ line_data, colors=(0.8, 0.8, 0.8), alpha_mode="solid", offset=(0, 0, -1)
+ )
bounds = (edges[0] * self._scale_factor, edges[-1] * self._scale_factor)
limits = (edges_flanked[0], edges_flanked[-1])
@@ -73,19 +108,18 @@ def __init__(
size=size,
center=origin[0],
axis="y",
- edge_thickness=8,
parent=self._histogram_line,
)
+ self._vmin = self.images[0].vmin
+ self._vmax = self.images[0].vmax
+
# there will be a small difference with the histogram edges so this makes them both line up exactly
self._linear_region_selector.selection = (
- self._image_graphic.vmin * self._scale_factor,
- self._image_graphic.vmax * self._scale_factor,
+ self._vmin * self._scale_factor,
+ self._vmax * self._scale_factor,
)
- self._vmin = self.image_graphic.vmin
- self._vmax = self.image_graphic.vmax
-
vmin_str, vmax_str = self._get_vmin_vmax_str()
self._text_vmin = TextGraphic(
@@ -94,7 +128,8 @@ def __init__(
offset=(0, 0, 0),
anchor="top-left",
outline_color="black",
- outline_thickness=1,
+ outline_thickness=0.5,
+ alpha_mode="solid",
)
self._text_vmin.world_object.material.pick_write = False
@@ -105,7 +140,8 @@ def __init__(
offset=(0, 0, 0),
anchor="bottom-left",
outline_color="black",
- outline_thickness=1,
+ outline_thickness=0.5,
+ alpha_mode="solid",
)
self._text_vmax.world_object.material.pick_write = False
@@ -130,12 +166,13 @@ def __init__(
self._linear_region_handler, "selection"
)
- ig_events = _get_image_graphic_events(self.image_graphic)
+ ig_events = _get_image_graphic_events(self.images[0])
- self.image_graphic.add_event_handler(self._image_cmap_handler, *ig_events)
+ for ig in self.images:
+ ig.add_event_handler(self._image_cmap_handler, *ig_events)
# colorbar for grayscale images
- if self.image_graphic.data.value.ndim != 3:
+ if self.images[0].cmap is not None:
self._colorbar: ImageGraphic = self._make_colorbar(edges_flanked)
self._colorbar.add_event_handler(self._open_cmap_picker, "click")
@@ -162,13 +199,13 @@ def _make_colorbar(self, edges_flanked) -> ImageGraphic:
data=colorbar_data,
vmin=self.vmin,
vmax=self.vmax,
- cmap=self.image_graphic.cmap,
+ cmap=self.images[0].cmap,
interpolation="linear",
offset=(-55, edges_flanked[0], -1),
)
cbar.world_object.world.scale_x = 20
- self._cmap = self.image_graphic.cmap
+ self._cmap = self.images[0].cmap
return cbar
@@ -256,8 +293,9 @@ def cmap(self, name: str):
if self._colorbar is None:
return
- with pause_events(self.image_graphic):
- self.image_graphic.cmap = name
+ with pause_events(*self.images):
+ for ig in self.images:
+ ig.cmap = name
self._cmap = name
self._colorbar.cmap = name
@@ -268,14 +306,15 @@ def vmin(self) -> float:
@vmin.setter
def vmin(self, value: float):
- with pause_events(self.image_graphic, self._linear_region_selector):
+ with pause_events(self._linear_region_selector, *self.images):
# must use world coordinate values directly from selection()
# otherwise the linear region bounds jump to the closest bin edges
self._linear_region_selector.selection = (
value * self._scale_factor,
self._linear_region_selector.selection[1],
)
- self.image_graphic.vmin = value
+ for ig in self.images:
+ ig.vmin = value
self._vmin = value
if self._colorbar is not None:
@@ -291,7 +330,7 @@ def vmax(self) -> float:
@vmax.setter
def vmax(self, value: float):
- with pause_events(self.image_graphic, self._linear_region_selector):
+ with pause_events(self._linear_region_selector, *self.images):
# must use world coordinate values directly from selection()
# otherwise the linear region bounds jump to the closest bin edges
self._linear_region_selector.selection = (
@@ -299,7 +338,8 @@ def vmax(self, value: float):
value * self._scale_factor,
)
- self.image_graphic.vmax = value
+ for ig in self.images:
+ ig.vmax = value
self._vmax = value
if self._colorbar is not None:
@@ -326,7 +366,7 @@ def set_data(self, data, reset_vmin_vmax: bool = True):
self._linear_region_selector.limits = limits
self._linear_region_selector.selection = bounds
else:
- with pause_events(self.image_graphic, self._linear_region_selector):
+ with pause_events(self._linear_region_selector, *self.images):
# don't change the current selection
self._linear_region_selector.limits = limits
@@ -336,7 +376,7 @@ def set_data(self, data, reset_vmin_vmax: bool = True):
self._colorbar.clear_event_handlers()
self.world_object.remove(self._colorbar.world_object)
- if self.image_graphic.data.value.ndim != 3:
+ if self.images[0].cmap is not None:
self._colorbar: ImageGraphic = self._make_colorbar(edges_flanked)
self._colorbar.add_event_handler(self._open_cmap_picker, "click")
@@ -349,34 +389,39 @@ def set_data(self, data, reset_vmin_vmax: bool = True):
self._plot_area.auto_scale()
@property
- def image_graphic(self) -> ImageGraphic:
- return self._image_graphic
-
- @image_graphic.setter
- def image_graphic(self, graphic):
- if not isinstance(graphic, ImageGraphic):
+ def images(self) -> tuple[ImageGraphic | ImageVolumeGraphic]:
+ return self._images
+
+ @images.setter
+ def images(self, images):
+ if isinstance(images, (ImageGraphic, ImageVolumeGraphic)):
+ images = (images,)
+ elif isinstance(images, Sequence):
+ if not all(
+ [isinstance(ig, (ImageGraphic, ImageVolumeGraphic)) for ig in images]
+ ):
+ raise TypeError(
+ f"`images` argument must be an ImageGraphic, ImageVolumeGraphic, or a "
+ f"tuple or list or ImageGraphic | ImageVolumeGraphic"
+ )
+ else:
raise TypeError(
- f"HistogramLUTTool can only use ImageGraphic types, you have passed: {type(graphic)}"
- )
-
- if self._image_graphic is not None:
- # cleanup events from current image graphic
- ig_events = _get_image_graphic_events(self._image_graphic)
- self._image_graphic.remove_event_handler(
- self._image_cmap_handler, *ig_events
+ f"`images` argument must be an ImageGraphic, ImageVolumeGraphic, or a "
+ f"tuple or list or ImageGraphic | ImageVolumeGraphic"
)
- self._image_graphic = graphic
+ if self._images is not None:
+ for ig in self._images:
+ # cleanup events from current image graphics
+ ig_events = _get_image_graphic_events(ig)
+ ig.remove_event_handler(self._image_cmap_handler, *ig_events)
- ig_events = _get_image_graphic_events(self._image_graphic)
+ self._images = images
- self.image_graphic.add_event_handler(self._image_cmap_handler, *ig_events)
+ ig_events = _get_image_graphic_events(self._images[0])
- def disconnect_image_graphic(self):
- ig_events = _get_image_graphic_events(self._image_graphic)
- self._image_graphic.remove_event_handler(self._image_cmap_handler, *ig_events)
- del self._image_graphic
- # self._image_graphic = None
+ for ig in self.images:
+ ig.add_event_handler(self._image_cmap_handler, *ig_events)
def _open_cmap_picker(self, ev):
# check if right click
diff --git a/fastplotlib/tools/_tooltip.py b/fastplotlib/tools/_textbox.py
similarity index 50%
rename from fastplotlib/tools/_tooltip.py
rename to fastplotlib/tools/_textbox.py
index 2fbdfcec2..46a468ae7 100644
--- a/fastplotlib/tools/_tooltip.py
+++ b/fastplotlib/tools/_textbox.py
@@ -1,10 +1,7 @@
-from functools import partial
-
import numpy as np
import pygfx
-from ..graphics import LineGraphic, ImageGraphic, ScatterGraphic, Graphic
-from ..graphics.features import GraphicFeatureEvent
+from ..utils.enums import RenderQueue
class MeshMasks:
@@ -50,28 +47,64 @@ 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(
- color="w",
- outline_color="w",
- outline_thickness=0.0,
+ alpha_mode="blend",
+ aa=True,
+ render_queue=RenderQueue.overlay,
+ color=text_color,
+ depth_write=False,
+ depth_test=False,
pick_write=False,
),
)
# plane for the background of the text object
geometry = pygfx.plane_geometry(1, 1)
- material = pygfx.MeshBasicMaterial(color=(0.1, 0.1, 0.3, 0.95))
+ material = pygfx.MeshBasicMaterial(
+ alpha_mode="blend",
+ render_queue=RenderQueue.overlay,
+ color=background_color,
+ depth_write=False,
+ depth_test=False,
+ )
self._plane = pygfx.Mesh(geometry, material)
- # else text not visible
- self._plane.world.z = 0.5
# line to outline the plane mesh
self._line = pygfx.Line(
@@ -87,21 +120,33 @@ def __init__(self):
dtype=np.float32,
)
),
- material=pygfx.LineThinMaterial(thickness=1.0, color=(0.8, 0.8, 1.0, 1.0)),
+ material=pygfx.LineThinMaterial(
+ alpha_mode="blend",
+ render_queue=RenderQueue.overlay,
+ thickness=1.0,
+ color=outline_color,
+ depth_write=False,
+ depth_test=False,
+ ),
)
+ # 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):
@@ -153,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
----------
@@ -163,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]
@@ -188,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 e31dd8d4a..355edc46d 100644
--- a/fastplotlib/ui/_base.py
+++ b/fastplotlib/ui/_base.py
@@ -1,3 +1,4 @@
+import enum
from typing import Literal
import numpy as np
@@ -6,7 +7,7 @@
from ..layouts._figure import Figure
-GUI_EDGES = ["right", "bottom"]
+GUI_EDGES = ["right", "bottom", "top"]
class BaseGUI:
@@ -40,9 +41,9 @@ def __init__(
self,
figure: Figure,
size: int,
- location: Literal["bottom", "right"],
+ location: Literal["bottom", "right", "top"],
title: str,
- window_flags: int = imgui.WindowFlags_.no_collapse
+ window_flags: enum.IntFlag = imgui.WindowFlags_.no_collapse
| imgui.WindowFlags_.no_resize,
*args,
**kwargs,
@@ -64,8 +65,8 @@ def __init__(
title: str
window title
- window_flags: int
- window flag enum, valid flags are:
+ window_flags: enum.IntFlag
+ Window flag enum, can be compared with ``|`` operator. Valid flags are:
.. code-block:: py
@@ -109,7 +110,6 @@ def __init__(
self._location = location
self._title = title
self._window_flags = window_flags
- self._fa_icons = self._figure._fa_icons
self._x, self._y, self._width, self._height = self.get_rect()
@@ -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):
@@ -231,7 +241,6 @@ def __init__(self, figure: Figure, *args, **kwargs):
super().__init__()
self._figure = figure
- self._fa_icons = self._figure._fa_icons
self.is_open = False
diff --git a/fastplotlib/ui/_subplot_toolbar.py b/fastplotlib/ui/_subplot_toolbar.py
index a06e81b90..435de4206 100644
--- a/fastplotlib/ui/_subplot_toolbar.py
+++ b/fastplotlib/ui/_subplot_toolbar.py
@@ -6,14 +6,13 @@
class SubplotToolbar(Window):
- def __init__(self, subplot: Subplot, fa_icons: imgui.ImFont):
+ def __init__(self, subplot: Subplot):
"""
Subplot toolbar shown below all subplots
"""
super().__init__()
self._subplot = subplot
- self._fa_icons = fa_icons
def update(self):
# get subplot rect
@@ -32,42 +31,32 @@ def update(self):
imgui.begin(f"Toolbar-{hex(id(self._subplot))}", p_open=None, flags=flags)
- # icons for buttons
- imgui.push_font(self._fa_icons)
-
# push ID to prevent conflict between multiple figs with same UI
imgui.push_id(self._id_counter)
with imgui_ctx.begin_horizontal(f"toolbar-{hex(id(self._subplot))}"):
# autoscale button
if imgui.button(fa.ICON_FA_MAXIMIZE):
self._subplot.auto_scale()
- imgui.pop_font()
if imgui.is_item_hovered(0):
imgui.set_tooltip("autoscale scene")
# center scene
- imgui.push_font(self._fa_icons)
if imgui.button(fa.ICON_FA_ALIGN_CENTER):
self._subplot.center_scene()
- imgui.pop_font()
if imgui.is_item_hovered(0):
imgui.set_tooltip("center scene")
- imgui.push_font(self._fa_icons)
# checkbox controller
_, self._subplot.controller.enabled = imgui.checkbox(
fa.ICON_FA_COMPUTER_MOUSE, self._subplot.controller.enabled
)
- imgui.pop_font()
if imgui.is_item_hovered(0):
imgui.set_tooltip("enable/disable controller")
- imgui.push_font(self._fa_icons)
- # checkbox maintain_apsect
+ # checkbox maintain_aspect
_, self._subplot.camera.maintain_aspect = imgui.checkbox(
fa.ICON_FA_EXPAND, self._subplot.camera.maintain_aspect
)
- imgui.pop_font()
if imgui.is_item_hovered(0):
imgui.set_tooltip("maintain aspect")
diff --git a/fastplotlib/ui/right_click_menus/_colormap_picker.py b/fastplotlib/ui/right_click_menus/_colormap_picker.py
index 3c48bd4d8..a80e5b2aa 100644
--- a/fastplotlib/ui/right_click_menus/_colormap_picker.py
+++ b/fastplotlib/ui/right_click_menus/_colormap_picker.py
@@ -26,14 +26,13 @@ class ColormapPicker(Popup):
name = "colormap-picker"
def __init__(self, figure):
- super().__init__(figure=figure, fa_icons=None)
+ super().__init__(figure=figure)
self.renderer = self._figure.renderer
self.imgui_renderer = self._figure.imgui_renderer
# maps str cmap names -> int texture IDs
- self._texture_ids: dict[str, int] = {}
- self._textures = list()
+ self._cmap_texture_refs: dict[str, imgui.ImTextureRef] = dict()
# make all colormaps and upload representative texture for each cmap to the GPU
for name in all_cmaps:
@@ -45,8 +44,7 @@ def __init__(self, figure):
data = np.vstack([[data]] * 2).astype(np.uint8)
# upload the texture to the GPU, get the texture ID and texture
- self._texture_ids[name], texture = self._create_texture_and_upload(data)
- self._textures.append(texture)
+ self._cmap_texture_refs[name] = self._create_texture_and_upload(data)
# used to set the states of the UI
self._lut_tool = None
@@ -83,12 +81,8 @@ def _create_texture_and_upload(self, data: np.ndarray) -> tuple[int, GPUTexture]
# get a view
texture_view = texture.create_view()
- # get the id so that imgui can display it
- id_texture = ctypes.c_int32(id(texture_view)).value
- # add texture view to the backend so that it can be retrieved for rendering
- self.imgui_renderer.backend._texture_views[id_texture] = texture_view
-
- return id_texture, texture
+ # return texture ref
+ return self.imgui_renderer.backend.register_texture(texture_view)
def open(self, pos: tuple[int, int], lut_tool):
"""
@@ -121,10 +115,19 @@ def close(self):
self.is_open = False
def _add_cmap_menu_item(self, cmap_name: str):
- texture_id = self._texture_ids[cmap_name]
+ # white border around cmap image
+ imgui.push_style_color(imgui.Col_.border, (1.0, 1.0, 1.0, 1.0))
+ imgui.push_style_var(imgui.StyleVar_.image_border_size, 1.0)
+
+ # cmap image
+ texture_ref = self._cmap_texture_refs[cmap_name]
imgui.image(
- texture_id, image_size=(50, self._texture_height), border_col=(1, 1, 1, 1)
+ texture_ref,
+ image_size=(50, self._texture_height),
)
+ # pop white border
+ imgui.pop_style_var()
+ imgui.pop_style_color()
imgui.same_line()
@@ -148,13 +151,10 @@ def update(self):
self.is_open = True
# make the cmap image height the same as the text height
- self._texture_height = (
- self.imgui_renderer.backend.io.font_global_scale
- * imgui.get_font().font_size
- ) - 2
+ self._texture_height = (imgui.get_font_size()) - 2
if imgui.menu_item("Reset vmin-vmax", "", False)[0]:
- self._lut_tool.image_graphic.reset_vmin_vmax()
+ self._lut_tool.images[0].reset_vmin_vmax()
# add all the cmap options
for cmap_type in COLORMAP_NAMES.keys():
diff --git a/fastplotlib/ui/right_click_menus/_standard_menu.py b/fastplotlib/ui/right_click_menus/_standard_menu.py
index 4bb59c51d..bb9e5bdef 100644
--- a/fastplotlib/ui/right_click_menus/_standard_menu.py
+++ b/fastplotlib/ui/right_click_menus/_standard_menu.py
@@ -22,8 +22,8 @@ def flip_axis(subplot: PlotArea, axis: str, flip: bool):
class StandardRightClickMenu(Popup):
"""Right click menu that is shown on subplots"""
- def __init__(self, figure, fa_icons):
- super().__init__(figure=figure, fa_icons=fa_icons)
+ def __init__(self, figure):
+ super().__init__(figure=figure)
self._last_right_click_pos = None
self._mouse_down: bool = False
@@ -182,19 +182,4 @@ def update(self):
imgui.end_menu()
- # renderer blend modes
- if imgui.begin_menu("Blend mode"):
- for blend_mode in sorted(
- self.get_subplot().renderer._blenders_available.keys()
- ):
- clicked, _ = imgui.menu_item(
- label=blend_mode,
- shortcut="",
- p_selected=self.get_subplot().renderer.blend_mode == blend_mode,
- )
-
- if clicked:
- self.get_subplot().renderer.blend_mode = blend_mode
- imgui.end_menu()
-
imgui.end_popup()
diff --git a/fastplotlib/utils/__init__.py b/fastplotlib/utils/__init__.py
index dce4d96f9..dd527ca67 100644
--- a/fastplotlib/utils/__init__.py
+++ b/fastplotlib/utils/__init__.py
@@ -5,6 +5,7 @@
from .functions import *
from .gpu import enumerate_adapters, select_adapter, print_wgpu_report
from ._plot_helpers import *
+from .enums import *
@dataclass
diff --git a/fastplotlib/utils/enums.py b/fastplotlib/utils/enums.py
new file mode 100644
index 000000000..3901b082c
--- /dev/null
+++ b/fastplotlib/utils/enums.py
@@ -0,0 +1,15 @@
+from enum import IntEnum
+
+
+class RenderQueue(IntEnum):
+ # Defaults by PyGfx
+ background = 1000
+ opaque = 2000
+ opaque_with_discard = 2400
+ auto = 2600
+ transparent = 3000
+ overlay = 4000
+ # For axes and selectors we use a higher render_queue, so they get rendered later than
+ # the graphics. Axes (rulers) have depth_compare '<=' and selectors don't compare depth.
+ axes = 3400 # still in 'object' group
+ selector = 3600 # considered in 'overlay' group
diff --git a/fastplotlib/utils/functions.py b/fastplotlib/utils/functions.py
index a1d6d476a..bc282287b 100644
--- a/fastplotlib/utils/functions.py
+++ b/fastplotlib/utils/functions.py
@@ -59,7 +59,7 @@
"RdYlBu",
"RdYlGn",
"Spectral",
- "coolwarm",
+ "matplotlib:coolwarm",
"bwr",
"seismic",
"berlin",
@@ -273,15 +273,15 @@ def quick_min_max(data: np.ndarray, max_size=1e6) -> tuple[float, float]:
Parameters
----------
- data: np.ndarray or array-like with `min` and `max` attributes
+ data: np.ndarray or array-like
max_size : int, optional
- largest array size allowed in the subsampled array. Default is 1e6.
+ subsamples data array to this max size
Returns
-------
(float, float)
- (min, max)
+ (min, max) estimate
"""
if hasattr(data, "min") and hasattr(data, "max"):
diff --git a/fastplotlib/utils/mapbox_earcut.py b/fastplotlib/utils/mapbox_earcut.py
new file mode 100644
index 000000000..ecb129593
--- /dev/null
+++ b/fastplotlib/utils/mapbox_earcut.py
@@ -0,0 +1,835 @@
+# The code below is copied from https://github.com/MIERUNE/earcut-py/blob/cb30bff5458fca224c573187f36d889068ebd4e0/src/earcut/__init__.py
+# which is a port of Mapbox' JS earcut (https://github.com/mapbox/earcut) version 2.2.4
+# The code is not modified, except maybe formatting to keep the linter happy.
+#
+# ISC License
+#
+# Copyright (c) 2016, Mapbox
+# Copyright (c) 2023, MIERUNE Inc.
+#
+# Permission to use, copy, modify, and/or distribute this software for any purpose
+# with or without fee is hereby granted, provided that the above copyright notice
+# and this permission notice appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+# REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
+# FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+# INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS
+# OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER
+# TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
+# THIS SOFTWARE.
+
+import math
+from typing import Optional
+
+
+def earcut(data, hole_indices=None, dim=2):
+ has_holes = bool(hole_indices)
+ outer_len = hole_indices[0] * dim if has_holes else len(data)
+ outer_node = _linked_list(data, 0, outer_len, dim, True)
+ triangles = []
+
+ if (not outer_node) or outer_node.next == outer_node.prev:
+ return triangles
+
+ min_x = min_y = inv_size = None
+
+ if has_holes:
+ outer_node = _eliminate_holes(data, hole_indices, outer_node, dim)
+
+ # if the shape is not too simple, we'll use z-order curve hash later; calculate polygon bbox
+ if len(data) > 80 * dim:
+ min_x = max_x = data[0]
+ min_y = max_y = data[1]
+
+ for i in range(dim, outer_len, dim):
+ x = data[i]
+ y = data[i + 1]
+ if x < min_x:
+ min_x = x
+ if y < min_y:
+ min_y = y
+ if x > max_x:
+ max_x = x
+ if y > max_y:
+ max_y = y
+
+ # minX, minY and invSize are later used to transform coords into integers for z-order calculation
+ inv_size = max(max_x - min_x, max_y - min_y)
+ inv_size = 32767 / inv_size if inv_size != 0 else 0
+
+ _earcut_linked(outer_node, triangles, dim, min_x, min_y, inv_size)
+
+ return triangles
+
+
+# create a circular doubly linked list from polygon points in the specified winding order
+def _linked_list(data, start, end, dim, clockwise):
+ last = None
+
+ if clockwise == (_signed_area(data, start, end, dim) > 0):
+ for i in range(start, end, dim):
+ last = _insert_node(i, data[i], data[i + 1], last)
+ else:
+ for i in reversed(range(start, end, dim)):
+ last = _insert_node(i, data[i], data[i + 1], last)
+
+ if last and _equals(last, last.next):
+ _remove_node(last)
+ last = last.next
+
+ return last
+
+
+# eliminate colinear or duplicate points
+def _filter_points(start, end=None):
+ if not start:
+ return start
+
+ if not end:
+ end = start
+
+ p = start
+ while True:
+ again = False
+
+ if not p.steiner and (_equals(p, p.next) or _area(p.prev, p, p.next) == 0):
+ _remove_node(p)
+ p = end = p.prev
+ if p == p.next:
+ break
+ again = True
+
+ else:
+ p = p.next
+
+ if (not again) and p == end:
+ break
+
+ return end
+
+
+# main ear slicing loop which triangulates a polygon (given as a linked list)
+def _earcut_linked(ear, triangles, dim, min_x, min_y, inv_size, _pass=0):
+ if not ear:
+ return
+
+ # interlink polygon nodes in z-order
+ if not _pass and inv_size:
+ _index_curve(ear, min_x, min_y, inv_size)
+
+ stop = ear
+
+ # iterate through ears, slicing them one by one
+ while ear.prev != ear.next:
+ prev = ear.prev
+ next = ear.next
+ is_ear = (
+ _is_ear_hashed(ear, min_x, min_y, inv_size) if inv_size else _is_ear(ear)
+ )
+
+ if is_ear:
+ # cut off the triangle
+ triangles.append(prev.i // dim)
+ triangles.append(ear.i // dim)
+ triangles.append(next.i // dim)
+
+ _remove_node(ear)
+
+ # skipping the next vertex leads to less sliver triangles
+ ear = next.next
+ stop = next.next
+
+ continue
+
+ ear = next
+
+ # if we looped through the whole remaining polygon and can't find any more ears
+ if ear == stop:
+ # try filtering points and slicing again
+ if not _pass:
+ _earcut_linked(
+ _filter_points(ear), triangles, dim, min_x, min_y, inv_size, 1
+ )
+
+ # if this didn't work, try curing all small self-intersections locally
+ elif _pass == 1:
+ ear = _cure_local_intersections(_filter_points(ear), triangles, dim)
+ _earcut_linked(ear, triangles, dim, min_x, min_y, inv_size, 2)
+
+ # as a last resort, try splitting the remaining polygon into two
+ elif _pass == 2:
+ _split_earcut(ear, triangles, dim, min_x, min_y, inv_size)
+
+ break
+
+
+# check whether a polygon node forms a valid ear with adjacent nodes
+def _is_ear(ear):
+ a = ear.prev
+ b = ear
+ c = ear.next
+
+ if _area(a, b, c) >= 0:
+ return False # reflex, can't be an ear
+
+ # now make sure we don't have other points inside the potential ear
+ ax = a.x
+ ay = a.y
+ bx = b.x
+ by = b.y
+ cx = c.x
+ cy = c.y
+
+ # triangle bbox; min & max are calculated like this for speed
+ x0 = (ax if ax < cx else cx) if ax < bx else (bx if bx < cx else cx)
+ y0 = (ay if ay < cy else cy) if ay < by else (by if by < cy else cy)
+ x1 = (ax if ax > cx else cx) if ax > bx else (bx if bx > cx else cx)
+ y1 = (ay if ay > cy else cy) if ay > by else (by if by > cy else cy)
+
+ p = c.next
+ while p != a:
+ if (
+ (p.x >= x0 and p.x <= x1 and p.y >= y0 and p.y <= y1)
+ and _point_in_triangle(ax, ay, bx, by, cx, cy, p.x, p.y)
+ and _area(p.prev, p, p.next) >= 0
+ ):
+ return False
+ p = p.next
+
+ return True
+
+
+def _is_ear_hashed(ear, min_x, min_y, inv_size):
+ a = ear.prev
+ b = ear
+ c = ear.next
+
+ if _area(a, b, c) >= 0:
+ return False # reflex, can't be an ear
+
+ ax = a.x
+ ay = a.y
+ bx = b.x
+ by = b.y
+ cx = c.x
+ cy = c.y
+
+ # triangle bbox; min & max are calculated like this for speed
+ x0 = (ax if ax < cx else cx) if ax < bx else (bx if bx < cx else cx)
+ y0 = (ay if ay < cy else cy) if ay < by else (by if by < cy else cy)
+ x1 = (ax if ax > cx else cx) if ax > bx else (bx if bx > cx else cx)
+ y1 = (ay if ay > cy else cy) if ay > by else (by if by > cy else cy)
+
+ # z-order range for the current triangle bbox
+ min_z = _z_order(x0, y0, min_x, min_y, inv_size)
+ max_z = _z_order(x1, y1, min_x, min_y, inv_size)
+
+ p = ear.prev_z
+ n = ear.next_z
+
+ # look for points inside the triangle in both directions
+ while p and p.z >= min_z and n and n.z <= max_z:
+ if (
+ (p.x >= x0 and p.x <= x1 and p.y >= y0 and p.y <= y1)
+ and (p != a and p != c)
+ and _point_in_triangle(ax, ay, bx, by, cx, cy, p.x, p.y)
+ and _area(p.prev, p, p.next) >= 0
+ ):
+ return False
+ p = p.prev_z
+
+ if (
+ (n.x >= x0 and n.x <= x1 and n.y >= y0 and n.y <= y1)
+ and (n != a and n != c)
+ and _point_in_triangle(ax, ay, bx, by, cx, cy, n.x, n.y)
+ and _area(n.prev, n, n.next) >= 0
+ ):
+ return False
+ n = n.next_z
+
+ # look for remaining points in decreasing z-order
+ while p and p.z >= min_z:
+ if (
+ (p != ear.prev and p != ear.next)
+ and _point_in_triangle(ax, ay, bx, by, cx, cy, p.x, p.y)
+ and _area(p.prev, p, p.next) >= 0
+ ):
+ return False
+ p = p.prev_z
+
+ # look for remaining points in increasing z-order
+ while n and n.z <= max_z:
+ if (
+ (n != ear.prev and n != ear.next)
+ and _point_in_triangle(ax, ay, bx, by, cx, cy, n.x, n.y)
+ and _area(n.prev, n, n.next) >= 0
+ ):
+ return False
+ n = n.next_z
+
+ return True
+
+
+# go through all polygon nodes and cure small local self-intersections
+def _cure_local_intersections(start, triangles, dim):
+ p = start
+ while True:
+ a = p.prev
+ b = p.next.next
+
+ if (
+ not _equals(a, b)
+ and _intersects(a, p, p.next, b)
+ and _locally_inside(a, b)
+ and _locally_inside(b, a)
+ ):
+ triangles.append(a.i // dim)
+ triangles.append(p.i // dim)
+ triangles.append(b.i // dim)
+
+ # remove two nodes involved
+ _remove_node(p)
+ _remove_node(p.next)
+
+ p = start = b
+
+ p = p.next
+ if p == start:
+ break
+
+ return _filter_points(p)
+
+
+# try splitting polygon into two and triangulate them independently
+def _split_earcut(start, triangles, dim, min_x, min_y, inv_size):
+ # look for a valid diagonal that divides the polygon into two
+ a = start
+ while True:
+ b = a.next.next
+ while b != a.prev:
+ if a.i != b.i and _is_valid_diagonal(a, b):
+ # split the polygon in two by the diagonal
+ c = _split_polygon(a, b)
+
+ # filter colinear points around the cuts
+ a = _filter_points(a, a.next)
+ c = _filter_points(c, c.next)
+
+ # run earcut on each half
+ _earcut_linked(a, triangles, dim, min_x, min_y, inv_size)
+ _earcut_linked(c, triangles, dim, min_x, min_y, inv_size)
+ return
+ b = b.next
+ a = a.next
+ if a == start:
+ break
+
+
+# link every hole into the outer loop, producing a single-ring polygon without holes
+def _eliminate_holes(data, hole_indices, outer_node, dim):
+ queue = []
+ _len = len(hole_indices)
+
+ for i in range(_len):
+ start = hole_indices[i] * dim
+ end = hole_indices[i + 1] * dim if i < _len - 1 else len(data)
+ lst = _linked_list(data, start, end, dim, False)
+ if lst:
+ if lst == lst.next:
+ lst.steiner = True
+ queue.append(_get_leftmost(lst))
+
+ queue.sort(key=lambda i: i.x)
+
+ # process holes from left to right
+ for q_i in queue:
+ outer_node = _eliminate_hole(q_i, outer_node)
+
+ return outer_node
+
+
+# find a bridge between vertices that connects hole with an outer ring and and link it
+def _eliminate_hole(hole, outer_node):
+ bridge = _find_hole_bridge(hole, outer_node)
+ if not bridge:
+ return outer_node
+
+ bridge_reverse = _split_polygon(bridge, hole)
+
+ _filter_points(bridge_reverse, bridge_reverse.next)
+ return _filter_points(bridge, bridge.next)
+
+
+# David Eberly's algorithm for finding a bridge between hole and outer polygon
+def _find_hole_bridge(hole, outer_node):
+ p = outer_node
+ hx = hole.x
+ hy = hole.y
+ qx = -math.inf
+ m = None
+
+ # find a segment intersected by a ray from the hole's leftmost point to the left
+ # segment's endpoint with lesser x will be potential connection point
+ while True:
+ px = p.x
+ py = p.y
+ if hy <= py and hy >= p.next.y and p.next.y != py:
+ x = px + (hy - py) * (p.next.x - px) / (p.next.y - py)
+ if x <= hx and x > qx:
+ qx = x
+ m = p if px < p.next.x else p.next
+ if x == hx:
+ # hole touches outer segment; pick leftmost endpoint
+ return m
+ p = p.next
+ if p == outer_node:
+ break
+
+ if not m:
+ return None
+
+ # look for points inside the triangle of hole point, segment intersection and endpoint
+ # if there are no points found, we have a valid connection
+ # otherwise choose the point of the minimum angle with the ray as connection point
+
+ stop = m
+ mx = m.x
+ my = m.y
+ tan_min = math.inf
+
+ p = m
+
+ while True:
+ px = p.x
+ py = p.y
+ if (hx >= px and px >= mx and hx != px) and _point_in_triangle(
+ hx if hy < my else qx,
+ hy,
+ mx,
+ my,
+ qx if hy < my else hx,
+ hy,
+ px,
+ py,
+ ):
+ tan = abs(hy - py) / (hx - px) # tangential
+
+ if _locally_inside(p, hole) and (
+ tan < tan_min
+ or (
+ tan == tan_min
+ and (px > m.x or (px == m.x and _sector_contains_sector(m, p)))
+ )
+ ):
+ m = p
+ tan_min = tan
+
+ p = p.next
+ if p == stop:
+ break
+
+ return m
+
+
+# whether sector in vertex m contains sector in vertex p in the same coordinates
+def _sector_contains_sector(m, p):
+ return _area(m.prev, m, p.prev) < 0 and _area(p.next, m, m.next) < 0
+
+
+# interlink polygon nodes in z-order
+def _index_curve(start, min_x, min_y, inv_size):
+ p = start
+ while True:
+ if p.z is None:
+ p.z = _z_order(p.x, p.y, min_x, min_y, inv_size)
+ p.prev_z = p.prev
+ p.next_z = p.next
+ p = p.next
+ if p == start:
+ break
+
+ p.prev_z.next_z = None
+ p.prev_z = None
+
+ _sort_linked(p)
+
+
+# Simon Tatham's linked list merge sort algorithm
+# http://www.chiark.greenend.org.uk/~sgtatham/algorithms/listsort.html
+def _sort_linked(_list):
+ in_size = 1
+
+ while True:
+ p = _list
+ _list = None
+ tail = None
+ num_merges = 0
+
+ while p:
+ num_merges += 1
+ q = p
+ p_size = 0
+ for i in range(in_size):
+ p_size += 1
+ q = q.next_z
+ if not q:
+ break
+ q_size = in_size
+
+ while p_size > 0 or (q_size > 0 and q):
+ if p_size != 0 and (q_size == 0 or not q or p.z <= q.z):
+ e = p
+ p = p.next_z
+ p_size -= 1
+ else:
+ e = q
+ q = q.next_z
+ q_size -= 1
+
+ if tail:
+ tail.next_z = e
+ else:
+ _list = e
+
+ e.prev_z = tail
+ tail = e
+
+ p = q
+
+ tail.next_z = None
+ in_size *= 2
+
+ if num_merges <= 1:
+ break
+
+ return _list
+
+
+# z-order of a point given coords and inverse of the longer side of data bbox
+def _z_order(x, y, min_x, min_y, inv_size):
+ # coords are transformed into non-negative 15-bit integer range
+ x = int((x - min_x) * inv_size)
+ y = int((y - min_y) * inv_size)
+
+ x = (x | (x << 8)) & 0x00FF00FF
+ x = (x | (x << 4)) & 0x0F0F0F0F
+ x = (x | (x << 2)) & 0x33333333
+ x = (x | (x << 1)) & 0x55555555
+
+ y = (y | (y << 8)) & 0x00FF00FF
+ y = (y | (y << 4)) & 0x0F0F0F0F
+ y = (y | (y << 2)) & 0x33333333
+ y = (y | (y << 1)) & 0x55555555
+
+ return x | (y << 1)
+
+
+# find the leftmost node of a polygon ring
+def _get_leftmost(start):
+ p = start
+ leftmost = start
+
+ while True:
+ if p.x < leftmost.x or (p.x == leftmost.x and p.y < leftmost.y):
+ leftmost = p
+
+ p = p.next
+ if p == start:
+ break
+
+ return leftmost
+
+
+# check if a point lies within a convex triangle
+def _point_in_triangle(ax, ay, bx, by, cx, cy, px, py):
+ pax = ax - px
+ pay = ay - py
+ pbx = bx - px
+ pby = by - py
+ pcx = cx - px
+ pcy = cy - py
+ return (
+ pcx * pay - pax * pcy >= 0
+ and pax * pby - pbx * pay >= 0
+ and pbx * pcy - pcx * pby >= 0
+ )
+
+
+# check if a diagonal between two polygon nodes is valid (lies in polygon interior)
+def _is_valid_diagonal(a, b):
+ return (
+ # dones't intersect other edges
+ (a.next.i != b.i and a.prev.i != b.i and not _intersects_polygon(a, b))
+ and (
+ # locally visible
+ (_locally_inside(a, b) and _locally_inside(b, a) and _middle_inside(a, b))
+ # does not create opposite-facing sectors
+ and (_area(a.prev, a, b.prev) or _area(a, b.prev, b))
+ # special zero-length case
+ or (
+ _equals(a, b)
+ and _area(a.prev, a, a.next) > 0
+ and _area(b.prev, b, b.next) > 0
+ )
+ )
+ )
+
+
+# signed area of a triangle
+def _area(p, q, r):
+ px = p.x
+ py = p.y
+ qx = q.x
+ qy = q.y
+ rx = r.x
+ ry = r.y
+ return (qy - py) * (rx - qx) - (qx - px) * (ry - qy)
+
+
+# check if two points are equal
+def _equals(p1, p2):
+ return p1.x == p2.x and p1.y == p2.y
+
+
+# check if two segments intersect
+def _intersects(p1, q1, p2, q2):
+ o1 = _sign(_area(p1, q1, p2))
+ o2 = _sign(_area(p1, q1, q2))
+ o3 = _sign(_area(p2, q2, p1))
+ o4 = _sign(_area(p2, q2, q1))
+
+ if (
+ (o1 != o2 and o3 != o4) # general case
+ or (
+ o1 == 0 and _on_segment(p1, p2, q1)
+ ) # p1, q1 and p2 are collinear and p2 lies on p1q1
+ or (
+ o2 == 0 and _on_segment(p1, q2, q1)
+ ) # p1, q1 and q2 are collinear and q2 lies on p1q1
+ or (
+ o3 == 0 and _on_segment(p2, p1, q2)
+ ) # p2, q2 and p1 are collinear and p1 lies on p2q2
+ or (
+ o4 == 0 and _on_segment(p2, q1, q2)
+ ) # p2, q2 and q1 are collinear and q1 lies on p2q2
+ ):
+ return True
+
+ return False
+
+
+# for collinear points p, q, r, check if point q lies on segment pr
+def _on_segment(p, q, r):
+ return (
+ q.x <= max(p.x, r.x)
+ and q.x >= min(p.x, r.x)
+ and q.y <= max(p.y, r.y)
+ and q.y >= min(p.y, r.y)
+ )
+
+
+def _sign(num):
+ if num > 0:
+ return 1
+ elif num < 0:
+ return -1
+ else:
+ return 0
+
+
+# check if a polygon diagonal intersects any polygon segments
+def _intersects_polygon(a, b):
+ p = a
+ while True:
+ pi = p.i
+ ai = a.i
+ bi = b.i
+ pnext = p.next
+ pnexti = pnext.i
+ if (pi != ai and pnexti != ai and pi != bi and pnexti != bi) and _intersects(
+ p, pnext, a, b
+ ):
+ return True
+
+ p = pnext
+ if p == a:
+ break
+
+ return False
+
+
+# check if a polygon diagonal is locally inside the polygon
+def _locally_inside(a, b):
+ aprev = a.prev
+ anext = a.next
+ if _area(aprev, a, anext) < 0:
+ return _area(a, b, anext) >= 0 and _area(a, aprev, b) >= 0
+ else:
+ return _area(a, b, aprev) < 0 or _area(a, anext, b) < 0
+
+
+# check if the middle point of a polygon diagonal is inside the polygon
+def _middle_inside(a, b):
+ p = a
+ inside = False
+ px = (a.x + b.x) / 2
+ py = (a.y + b.y) / 2
+ while True:
+ p_x = p.x
+ p_y = p.y
+ p_next = p.next
+ p_next_y = p_next.y
+ if (
+ (p_y > py) != (p_next_y > py)
+ and p_next.y != p_y
+ and (px < (p_next.x - p_x) * (py - p_y) / (p_next_y - p_y) + p_x)
+ ):
+ inside = not inside
+ p = p_next
+ if p == a:
+ break
+
+ return inside
+
+
+# link two polygon vertices with a bridge; if the vertices belong to the same ring, it splits polygon into two
+# if one belongs to the outer ring and another to a hole, it merges it into a single ring
+def _split_polygon(a, b):
+ a2 = _Node(a.i, a.x, a.y)
+ b2 = _Node(b.i, b.x, b.y)
+ an = a.next
+ bp = b.prev
+
+ a.next = b
+ b.prev = a
+
+ a2.next = an
+ an.prev = a2
+ b2.next = a2
+ a2.prev = b2
+ bp.next = b2
+ b2.prev = bp
+
+ return b2
+
+
+# create a node and optionally link it with previous one (in a circular doubly linked list)
+def _insert_node(i, x, y, last):
+ p = _Node(i, x, y)
+
+ if not last:
+ p.prev = p
+ p.next = p
+
+ else:
+ p.next = last.next
+ p.prev = last
+ last.next.prev = p
+ last.next = p
+
+ return p
+
+
+def _remove_node(p):
+ p.next.prev = p.prev
+ p.prev.next = p.next
+
+ if p.prev_z:
+ p.prev_z.next_z = p.next_z
+
+ if p.next_z:
+ p.next_z.prev_z = p.prev_z
+
+
+class _Node:
+ __slots__ = ["i", "x", "y", "prev", "next", "z", "prev_z", "next_z", "steiner"]
+ i: int
+ x: float
+ y: float
+ prev: Optional["_Node"]
+ next: Optional["_Node"]
+ z: Optional[int]
+ prev_z: Optional["_Node"]
+ next_z: Optional["_Node"]
+ steiner: bool
+
+ def __init__(self, i, x, y):
+ # vertex index in coordinates array
+ self.i = i
+
+ # vertex coordinates
+ self.x = x
+ self.y = y
+
+ # previous and next vertex nodes in a polygon ring
+ self.prev = None
+ self.next = None
+
+ # z-order curve value
+ self.z = None
+
+ # previous and next nodes in z-order
+ self.prev_z = None
+ self.next_z = None
+
+ # indicates whether this is a steiner point
+ self.steiner = False
+
+
+def _signed_area(data, start, end, dim):
+ sum = 0
+ j = end - dim
+ for i in range(start, end, dim):
+ sum += (data[j] - data[i]) * (data[i + 1] + data[j + 1])
+ j = i
+
+ return sum
+
+
+# return a percentage difference between the polygon area and its triangulation area
+# used to verify correctness of triangulation
+def deviation(data, hole_indices, dim, triangles):
+ has_holes = hole_indices and len(hole_indices)
+ outer_len = hole_indices[0] * dim if has_holes else len(data)
+
+ polygon_area = abs(_signed_area(data, 0, outer_len, dim))
+ if has_holes:
+ _len = len(hole_indices)
+ for i in range(_len):
+ start = hole_indices[i] * dim
+ end = hole_indices[i + 1] * dim if i < _len - 1 else len(data)
+ polygon_area -= abs(_signed_area(data, start, end, dim))
+
+ triangles_area = 0
+ for i in range(0, len(triangles), 3):
+ a = triangles[i] * dim
+ b = triangles[i + 1] * dim
+ c = triangles[i + 2] * dim
+ triangles_area += abs(
+ (data[a] - data[c]) * (data[b + 1] - data[a + 1])
+ - (data[a] - data[b]) * (data[c + 1] - data[a + 1])
+ )
+
+ if polygon_area == 0 and triangles_area == 0:
+ return 0
+ return abs((triangles_area - polygon_area) / polygon_area)
+
+
+# turn a polygon in a multi-dimensional array form (e.g. as in GeoJSON) into a form Earcut accepts
+def flatten(data):
+ dim = len(data[0][0])
+ vertices = []
+ holes = []
+ hole_index = 0
+
+ for i in range(len(data)):
+ for j in range(len(data[i])):
+ for d in range(dim):
+ vertices.append(data[i][j][d])
+
+ if i > 0:
+ hole_index += len(data[i - 1])
+ holes.append(hole_index)
+
+ return (vertices, holes, dim)
diff --git a/fastplotlib/utils/triangulation.py b/fastplotlib/utils/triangulation.py
new file mode 100644
index 000000000..7abe089de
--- /dev/null
+++ b/fastplotlib/utils/triangulation.py
@@ -0,0 +1,70 @@
+import logging
+
+import numpy as np
+from .mapbox_earcut import earcut as mapbox_earcut
+
+
+logger = logging.getLogger("fastplotlib")
+
+
+# Note: the current triangulation is in pure Python. If the results or performance of the current implementation
+# proves inadequate, we can have a look at Bermuda: https://github.com/napari/bermuda
+
+
+def triangulate(positions, method="earcut"):
+ """Triangulate the given vertex positions.
+
+ Returns an Nx3 integer array of faces that form a surface-mesh over the
+ given positions, where N is the length of the positions minus 2,
+ expressed in (local) vertex indices. The faces won't contain any
+ forbidden_edges.
+ """
+ if len(positions) < 3:
+ return np.zeros((0,), np.int32)
+ if len(positions) == 3:
+ return np.array([0, 1, 2], np.int32)
+
+ # Anticipating more variations ...
+ if method == "earcut":
+ method = "mapbox_earcut"
+
+ if method == "naive":
+ faces = _triangulate_naive(positions)
+ elif method == "mapbox_earcut":
+ positions2d = positions[:, :2].flatten()
+ faces = mapbox_earcut(positions2d)
+ faces = np.array(faces, np.int32).reshape(-1, 3)
+ else:
+ raise ValueError(f"Invalid triangulation method: {method}")
+
+ return faces
+
+
+def _triangulate_naive(positions, forbidden_edges=None):
+ """This tesselation algorithm simply creates edges from one vertex to all the others."""
+
+ nverts = len(positions)
+ nfaces = nverts - 2
+ forbidden_edges = forbidden_edges or []
+
+ # Determine a good point to be a reference
+ forbidden_start_points = set()
+ for i1, i2 in forbidden_edges:
+ forbidden_start_points.add(i1)
+ forbidden_start_points.add(i2)
+ for i in range(len(positions)):
+ if i not in forbidden_start_points:
+ start_point = i
+ break
+ else:
+ # In real meshes this cannot happen, but it can from the POV of this function's API
+ raise RuntimeError("Cannot tesselate.")
+
+ # Collect the faces
+ faces = []
+ i0 = start_point
+ for i in range(start_point, start_point + nfaces):
+ i1 = (i + 1) % nverts
+ i2 = (i + 2) % nverts
+ faces.append([i0, i1, i2])
+ return np.array(faces, np.int32)
diff --git a/fastplotlib/widgets/image_widget/_sliders.py b/fastplotlib/widgets/image_widget/_sliders.py
index c8ad67f39..393b13273 100644
--- a/fastplotlib/widgets/image_widget/_sliders.py
+++ b/fastplotlib/widgets/image_widget/_sliders.py
@@ -56,19 +56,15 @@ def update(self):
flag_index_changed = False
# reset vmin-vmax using full orig data
- imgui.push_font(self._fa_icons)
if imgui.button(label=fa.ICON_FA_CIRCLE_HALF_STROKE + fa.ICON_FA_FILM):
self._image_widget.reset_vmin_vmax()
- imgui.pop_font()
if imgui.is_item_hovered(0):
imgui.set_tooltip("reset contrast limits using full movie/stack")
# reset vmin-vmax using currently displayed ImageGraphic data
- imgui.push_font(self._fa_icons)
imgui.same_line()
if imgui.button(label=fa.ICON_FA_CIRCLE_HALF_STROKE):
self._image_widget.reset_vmin_vmax_frame()
- imgui.pop_font()
if imgui.is_item_hovered(0):
imgui.set_tooltip("reset contrast limits using current frame")
@@ -78,7 +74,6 @@ def update(self):
# buttons and slider UI elements for each dim
for dim in self._image_widget.slider_dims:
imgui.push_id(f"{self._id_counter}_{dim}")
- imgui.push_font(self._fa_icons)
if self._playing[dim]:
# show pause button if playing
@@ -119,7 +114,6 @@ def update(self):
imgui.same_line()
# loop checkbox
_, self._loop = imgui.checkbox(label=fa.ICON_FA_ROTATE, v=self._loop)
- imgui.pop_font()
if imgui.is_item_hovered(0):
imgui.set_tooltip("loop playback")
diff --git a/fastplotlib/widgets/image_widget/_widget.py b/fastplotlib/widgets/image_widget/_widget.py
index 650097951..86a01b083 100644
--- a/fastplotlib/widgets/image_widget/_widget.py
+++ b/fastplotlib/widgets/image_widget/_widget.py
@@ -562,7 +562,7 @@ def __init__(
subplot.add_graphic(ig)
if self._histogram_widget:
- hlut = HistogramLUTTool(data=d, image_graphic=ig, name="histogram_lut")
+ hlut = HistogramLUTTool(data=d, images=ig, name="histogram_lut")
subplot.docks["right"].add_graphic(hlut)
subplot.docks["right"].size = 80
@@ -929,6 +929,11 @@ def set_data(
for i, (new_array, current_array, subplot) in enumerate(
zip(new_data, self._data, self.figure)
):
+ # if the new array is the same as the existing array, skip
+ # this allows setting just a subset of the arrays in the ImageWidget
+ if new_data is self._data[i]:
+ continue
+
# check last two dims (x and y) to see if data shape is changing
old_data_shape = self._data[i].shape[-self.n_img_dims[i] :]
self._data[i] = new_array
@@ -944,7 +949,7 @@ def set_data(
if self._histogram_widget:
# set hlut tool to use new graphic
- subplot.docks["right"]["histogram_lut"].image_graphic = new_graphic
+ subplot.docks["right"]["histogram_lut"].images = new_graphic
# delete old graphic after setting hlut tool to new graphic
# this ensures gc
@@ -961,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/pyproject.toml b/pyproject.toml
index cda3b65b1..42a0e9e87 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -17,11 +17,11 @@ keywords = [
"vulkan",
"gpu",
]
-requires-python = ">= 3.10"
+requires-python = ">= 3.11"
dependencies = [
"numpy>=1.23.0",
- "pygfx==0.12.0",
- "wgpu>=0.20.0",
+ "pygfx==0.15.3",
+ "wgpu", # Let pygfx constrain the wgpu version
"cmap>=0.1.3",
# (this comment keeps this list multiline in VSCode)
]
@@ -39,6 +39,7 @@ docs = [
"imageio[ffmpeg]",
"matplotlib",
"scikit-learn",
+ "ome-zarr",
]
notebook = [
"jupyterlab",
@@ -54,8 +55,9 @@ tests = [
"imageio[ffmpeg]",
"scikit-learn",
"tqdm",
+ "ome-zarr",
]
-imgui = ["imgui-bundle>=1.6.0,<1.92.0"]
+imgui = ["wgpu[imgui]"]
dev = ["fastplotlib[docs,notebook,tests,imgui]"]
[project.urls]
diff --git a/scripts/generate_add_graphic_methods.py b/scripts/generate_add_graphic_methods.py
index 85e0be669..865eab27f 100644
--- a/scripts/generate_add_graphic_methods.py
+++ b/scripts/generate_add_graphic_methods.py
@@ -1,5 +1,6 @@
import inspect
import pathlib
+import re
import black
@@ -19,6 +20,8 @@
for name, obj in inspect.getmembers(graphics):
if inspect.isclass(obj):
+ if obj.__name__ == "Graphic":
+ continue # skip the base class
modules.append(obj)
@@ -30,6 +33,7 @@ def generate_add_graphics_methods():
f.write("from typing import *\n\n")
f.write("import numpy\n\n")
+ f.write("import pygfx\n\n")
f.write("from ..graphics import *\n")
f.write("from ..graphics._base import Graphic\n\n")
@@ -50,10 +54,9 @@ def generate_add_graphics_methods():
for m in modules:
cls = m
- if cls.__name__ == "Graphic":
- # skip base class
- continue
- method_name = cls.type
+ cls_name = cls.__name__.replace("Graphic", "")
+ # from https://stackoverflow.com/a/1176023
+ method_name = re.sub(r"(? n_subplots
with pytest.raises(ValueError):
- fpl.Figure(
- shape=(2, 3),
- names=["1", "2", "3", "4", "5", "6", "7"]
- )
+ fpl.Figure(shape=(2, 3), names=["1", "2", "3", "4", "5", "6", "7"])
with pytest.raises(ValueError):
- fpl.Figure(
- shape=(2, 3),
- names=["1", "2", "3", "4", None, "6", "7"]
- )
+ fpl.Figure(shape=(2, 3), names=["1", "2", "3", "4", None, "6", "7"])
with pytest.raises(ValueError):
- fpl.Figure(
- shape=(2, 3),
- names=["1", None, "3", "4", None, "6", "7"]
- )
+ fpl.Figure(shape=(2, 3), names=["1", None, "3", "4", None, "6", "7"])
diff --git a/tests/test_image_volume_graphic.py b/tests/test_image_volume_graphic.py
new file mode 100644
index 000000000..3cb574e78
--- /dev/null
+++ b/tests/test_image_volume_graphic.py
@@ -0,0 +1,190 @@
+import numpy as np
+from numpy import testing as npt
+import imageio.v3 as iio
+
+import pygfx
+
+import fastplotlib as fpl
+from fastplotlib.graphics.features import GraphicFeatureEvent
+from fastplotlib.utils import make_colors
+
+
+# load only first 128 planes because we set a limit for the tests
+SIMPLE_IMAGE = iio.imread("imageio:stent.npz")[:128]
+
+EVENT_RETURN_VALUE: GraphicFeatureEvent = None
+
+
+def event_handler(ev):
+ global EVENT_RETURN_VALUE
+ EVENT_RETURN_VALUE = ev
+
+
+def check_event(graphic, feature, value):
+ global EVENT_RETURN_VALUE
+ assert isinstance(EVENT_RETURN_VALUE, GraphicFeatureEvent)
+ assert EVENT_RETURN_VALUE.type == feature
+ assert EVENT_RETURN_VALUE.graphic == graphic
+ assert EVENT_RETURN_VALUE.target == graphic.world_object
+ if isinstance(EVENT_RETURN_VALUE.info["value"], float):
+ # floating point error
+ npt.assert_almost_equal(EVENT_RETURN_VALUE.info["value"], value)
+ else:
+ assert EVENT_RETURN_VALUE.info["value"] == value
+
+
+def check_set_slice(
+ data: np.ndarray,
+ image_graphic: fpl.ImageGraphic,
+ row_slice: slice,
+ col_slice: slice,
+ zpl_slice: slice,
+):
+ image_graphic.data[row_slice, col_slice, zpl_slice] = 1
+ data_values = image_graphic.data.value
+ npt.assert_almost_equal(data_values[row_slice, col_slice, zpl_slice], 1)
+
+ # make sure other vals unchanged
+ npt.assert_almost_equal(data_values[: row_slice.start], data[: row_slice.start])
+ npt.assert_almost_equal(data_values[row_slice.stop :], data[row_slice.stop :])
+ npt.assert_almost_equal(
+ data_values[:, : col_slice.start], data[:, : col_slice.start]
+ )
+ npt.assert_almost_equal(data_values[:, col_slice.stop :], data[:, col_slice.stop :])
+ npt.assert_almost_equal(
+ data_values[:, :, : zpl_slice.start], data[:, :, : zpl_slice.start]
+ )
+ npt.assert_almost_equal(
+ data_values[:, :, zpl_slice.stop :], data[:, :, zpl_slice.stop :]
+ )
+
+ global EVENT_RETURN_VALUE
+ assert isinstance(EVENT_RETURN_VALUE, GraphicFeatureEvent)
+ assert EVENT_RETURN_VALUE.type == "data"
+ assert EVENT_RETURN_VALUE.graphic == image_graphic
+ assert EVENT_RETURN_VALUE.target == image_graphic.world_object
+ assert EVENT_RETURN_VALUE.info["key"] == (row_slice, col_slice, zpl_slice)
+ npt.assert_almost_equal(EVENT_RETURN_VALUE.info["value"], 1)
+
+
+def test_gray():
+ fig = fpl.Figure()
+ ig = fig[0, 0].add_image_volume(SIMPLE_IMAGE)
+ assert isinstance(ig, fpl.ImageVolumeGraphic)
+
+ ig.add_event_handler(
+ event_handler,
+ "data",
+ "cmap",
+ "vmin",
+ "vmax",
+ "interpolation",
+ "cmap_interpolation",
+ )
+
+ # make sure entire data is the same
+ npt.assert_almost_equal(ig.data.value, SIMPLE_IMAGE)
+
+ # since this entire image is under the wgpu max texture limit,
+ # the entire image should be in the single Texture buffer
+ npt.assert_almost_equal(ig.data.buffer[0, 0, 0].data, SIMPLE_IMAGE)
+
+ assert isinstance(ig._material, pygfx.VolumeMipMaterial)
+ assert isinstance(ig._material.map, pygfx.TextureMap)
+ assert isinstance(ig._material.map.texture, pygfx.Texture)
+
+ ig.cmap = "viridis"
+ assert ig.cmap == "viridis"
+ check_event(graphic=ig, feature="cmap", value="viridis")
+
+ new_colors = make_colors(256, "viridis")
+ for child in ig.world_object.children:
+ npt.assert_almost_equal(child.material.map.texture.data, new_colors)
+
+ ig.cmap = "jet"
+ assert ig.cmap == "jet"
+
+ new_colors = make_colors(256, "jet")
+ for child in ig.world_object.children:
+ npt.assert_almost_equal(child.material.map.texture.data, new_colors)
+
+ assert ig.interpolation == "linear"
+ for child in ig.world_object.children:
+ assert child.material.interpolation == "linear"
+
+ ig.interpolation = "nearest"
+ assert ig.interpolation == "nearest"
+ for child in ig.world_object.children:
+ assert child.material.interpolation == "nearest"
+ check_event(graphic=ig, feature="interpolation", value="nearest")
+
+ assert ig.cmap_interpolation == "linear"
+ for child in ig.world_object.children:
+ assert child.material.map.min_filter == "linear"
+ assert child.material.map.mag_filter == "linear"
+
+ ig.cmap_interpolation = "nearest"
+ assert ig.cmap_interpolation == "nearest"
+ for child in ig.world_object.children:
+ assert child.material.map.min_filter == "nearest"
+ assert child.material.map.mag_filter == "nearest"
+
+ check_event(graphic=ig, feature="cmap_interpolation", value="nearest")
+
+ # make sure they all use the same material
+ for child in ig.world_object.children:
+ assert ig._material is child.material
+
+ # render modes
+ ig.mode = "mip"
+ assert isinstance(ig._material, pygfx.VolumeMipMaterial)
+ for child in ig.world_object.children:
+ assert ig._material is child.material
+ ig.mode = "minip"
+ assert isinstance(ig._material, pygfx.VolumeMinipMaterial)
+ for child in ig.world_object.children:
+ assert ig._material is child.material
+ ig.mode = "iso"
+ assert isinstance(ig._material, pygfx.VolumeIsoMaterial)
+ for child in ig.world_object.children:
+ assert ig._material is child.material
+
+ ig.threshold = 50
+ assert ig._material.threshold == 50
+ ig.emissive = (1, 0, 0, 1)
+ assert tuple(ig._material.emissive) == (1.0, 0.0, 0.0, 1.0)
+ ig.shininess = 40
+ assert ig._material.shininess == 40
+
+ ig.mode = "slice"
+ assert isinstance(ig._material, pygfx.VolumeSliceMaterial)
+ for child in ig.world_object.children:
+ assert ig._material is child.material
+ ig.plane = (0, 0.5, 0.5, -100)
+ npt.assert_almost_equal(ig._material.plane, np.array([0.0, 0.5, 0.5, -100.0]))
+
+ npt.assert_almost_equal(ig.vmin, SIMPLE_IMAGE.min())
+ npt.assert_almost_equal(ig.vmax, SIMPLE_IMAGE.max())
+
+ ig.vmin = 50
+ assert ig.vmin == 50
+ for child in ig.world_object.children:
+ assert child.material.clim == (50, ig.vmax)
+ check_event(graphic=ig, feature="vmin", value=50)
+
+ ig.vmax = 100
+ assert ig.vmax == 100
+ for child in ig.world_object.children:
+ assert child.material.clim == (ig.vmin, 100)
+ check_event(graphic=ig, feature="vmax", value=100)
+
+ # test reset
+ ig.reset_vmin_vmax()
+ npt.assert_almost_equal(ig.vmin, SIMPLE_IMAGE.min())
+ npt.assert_almost_equal(ig.vmax, SIMPLE_IMAGE.max())
+
+ check_set_slice(SIMPLE_IMAGE, ig, slice(50, 60), slice(20, 30), slice(80, 100))
+
+ # test setting all values
+ ig.data = 1
+ npt.assert_almost_equal(ig.data.value, 1)
diff --git a/tests/test_markers_buffer_manager.py b/tests/test_markers_buffer_manager.py
new file mode 100644
index 000000000..65ead392e
--- /dev/null
+++ b/tests/test_markers_buffer_manager.py
@@ -0,0 +1,143 @@
+import numpy as np
+from numpy import testing as npt
+import pytest
+
+import fastplotlib as fpl
+import pygfx
+from fastplotlib.graphics.features import GraphicFeatureEvent, VertexMarkers
+from fastplotlib.graphics.features._scatter import marker_names, vectorized_user_markers_to_std_markers
+
+from .utils import (
+ generate_slice_indices,
+ generate_positions_spiral_data,
+)
+
+
+EVENT_RETURN_VALUE: GraphicFeatureEvent = None
+
+
+def event_handler(ev):
+ global EVENT_RETURN_VALUE
+ EVENT_RETURN_VALUE = ev
+
+
+MARKERS1 = list("osD+x^v<>*")
+MARKERS2 = list(">+vx*"))
+def test_uniform_markers(marker):
+ fig = fpl.Figure()
+
+ data = generate_positions_spiral_data("xyz")
+
+ scatter = fig[0, 0].add_scatter(data, markers=marker, uniform_marker=True)
+
+ marker_full_name = marker_names.get(marker)
+
+ assert isinstance(scatter.world_object.material, pygfx.PointsMarkerMaterial)
+ assert scatter.world_object.material.marker_mode == pygfx.MarkerMode.uniform
+ assert isinstance(scatter._markers, UniformMarker)
+
+ assert scatter.markers == marker_full_name
+ assert scatter.world_object.material.marker == marker_full_name
+
+ # test changes and event
+ scatter.add_event_handler(event_handler, "markers")
+ scatter.markers = "o"
+ assert scatter.markers == pygfx.MarkerShape.circle
+ assert scatter.world_object.material.marker == pygfx.MarkerShape.circle
+
+ check_event(scatter, "markers", pygfx.MarkerShape.circle)
+
+
+@pytest.mark.parametrize("to_type", [list, tuple, np.array])
+@pytest.mark.parametrize("uniform_marker", [True, False])
+def test_incompatible_marker_args(to_type, uniform_marker):
+ markers = ["o"] * 3 + ["s"] * 3 + ["+"] * 3 + ["x"]
+
+ markers = to_type(markers)
+
+ data = generate_positions_spiral_data("xyz")
+
+ fig = fpl.Figure()
+
+ if uniform_marker:
+ with pytest.raises(TypeError):
+ scatter = fig[0, 0].add_scatter(data, markers=markers, uniform_marker=True)
+
+ else:
+ scatter = fig[0, 0].add_scatter(data, markers=markers, uniform_marker=False)
+ assert isinstance(scatter._markers, VertexMarkers)
+ assert scatter.world_object.material.marker_mode == pygfx.MarkerMode.vertex
+
+
+def test_uniform_custom_sdf():
+ lower_right_triangle_sdf = """
+ // hardcode square root of 2
+ let m_sqrt_2 = 1.4142135;
+
+ // given a distance from an origin point, this defines the hypotenuse of a lower right triangle
+ let distance = (-coord.x + coord.y) / m_sqrt_2;
+
+ // return distance for this position
+ return distance * size;
+ """
+
+ data = generate_positions_spiral_data("xyz")
+
+ fig = fpl.Figure()
+
+ scatter = fig[0, 0].add_scatter(
+ data, markers="custom", uniform_marker=True, custom_sdf=lower_right_triangle_sdf
+ )
+
+ assert scatter.markers == "custom"
+ assert scatter.world_object.material.marker == "custom"
+ assert scatter.world_object.material.custom_sdf == lower_right_triangle_sdf
+
+# test with both list[str] and 2D numpy array inputs as colors
+@pytest.mark.parametrize("edge_colors",[generate_color_inputs("multi")[0], generate_color_inputs("multi")[1]])
+def test_edge_colors(edge_colors):
+ fig = fpl.Figure()
+
+ data = generate_positions_spiral_data("xyz")
+
+ scatter = fig[0, 0].add_scatter(
+ data=data,
+ edge_colors=edge_colors,
+ uniform_edge_color=False,
+ )
+
+ assert isinstance(scatter._edge_colors, VertexColors)
+
+ npt.assert_almost_equal(scatter.edge_colors.value, MULTI_COLORS_TRUTH)
+
+ assert (
+ scatter.edge_colors.buffer is scatter.world_object.geometry.edge_colors
+ )
+
+ # test changes, don't need to test extensively here since it's tested in the main VertexColors test
+ new_colors, array = generate_color_inputs("multi2")
+ scatter.edge_colors = new_colors
+ npt.assert_almost_equal(scatter.edge_colors.value, array)
+
+
+@pytest.mark.parametrize("edge_color", ["r", (1, 0, 0), [1, 0, 0], np.array([1, 0, 0])])
+def test_uniform_edge_colors(edge_color):
+ fig = fpl.Figure()
+
+ data = generate_positions_spiral_data("xyz")
+
+ scatter = fig[0, 0].add_scatter(
+ data=data, edge_colors=edge_color, uniform_edge_color=True
+ )
+
+ assert isinstance(scatter._edge_colors, UniformEdgeColor)
+ assert scatter.edge_colors == pygfx.Color(edge_color)
+ assert scatter.world_object.material.edge_color == pygfx.Color(edge_color)
+
+ # test changes and event
+ scatter.add_event_handler(event_handler, "edge_colors")
+ scatter.edge_colors = "g"
+
+ assert scatter.edge_colors == pygfx.Color("g")
+ assert scatter.world_object.material.edge_color == pygfx.Color("g")
+
+ check_event(scatter, "edge_colors", pygfx.Color("g"))
+
+
+@pytest.mark.parametrize("edge_colors", [generate_color_inputs("multi")[0],generate_color_inputs("multi")[1]])
+@pytest.mark.parametrize("uniform_edge_color", [False, True])
+def test_incompatible_edge_colors_args(edge_colors, uniform_edge_color):
+ fig = fpl.Figure()
+
+ data = generate_positions_spiral_data("xyz")
+
+ if uniform_edge_color:
+ with pytest.raises(TypeError):
+ scatter = fig[0, 0].add_scatter(
+ data=data,
+ edge_colors=edge_colors,
+ uniform_edge_color=uniform_edge_color,
+ )
+
+
+@pytest.mark.parametrize("edge_width", [0.0, 0.5, 1.0, 5.0])
+def test_edge_width(edge_width):
+ fig = fpl.Figure()
+
+ data = generate_positions_spiral_data("xyz")
+
+ scatter = fig[0, 0].add_scatter(
+ data=data,
+ edge_width=edge_width,
+ )
+
+ assert isinstance(scatter._edge_width, EdgeWidth)
+ assert scatter.world_object.material.edge_width == edge_width
+ assert scatter.edge_width == edge_width
+
+ # test changes and events
+ scatter.add_event_handler(event_handler, "edge_width")
+ scatter.edge_width = 2.0
+
+ npt.assert_almost_equal(scatter.edge_width, 2.0)
+ npt.assert_almost_equal(scatter.world_object.material.edge_width, 2.0)
+
+ check_event(scatter, "edge_width", 2.0)
+
+
+def test_uniform_rotation():
+ fig = fpl.Figure()
+
+ data = generate_positions_spiral_data("xyz")
+
+ scatter = fig[0, 0].add_scatter(
+ data=data,
+ point_rotations=np.pi / 2,
+ )
+
+ assert scatter.point_rotation_mode == "uniform"
+ npt.assert_almost_equal(scatter.point_rotations, np.pi / 2)
+
+ # test changes and events
+ scatter.add_event_handler(event_handler, "point_rotations")
+ scatter.point_rotations = np.pi / 3
+
+ npt.assert_almost_equal(scatter.point_rotations, np.pi / 3)
+
+ check_event(scatter, "point_rotations", np.pi / 3)
+
+
+def test_sprite():
+ image = np.array(
+ [
+ [1, 0, 1],
+ [0, 1, 0],
+ [1, 1, 1],
+ ]
+ )
+
+ data = generate_positions_spiral_data("xyz")
+
+ fig = fpl.Figure()
+
+ scatter = fig[0, 0].add_scatter(
+ data=data,
+ mode="image",
+ image=image,
+ )
+
+ # make sure the image is a fpl TextureArray
+ assert isinstance(scatter.image, TextureArray)
+ # make sure the sprite is the TextureArray buffer, i.e. a pygfx.Texture
+ assert scatter.world_object.material.sprite is scatter.image.buffer[0, 0]
+ assert scatter.image.buffer.size == 1
+
+ npt.assert_almost_equal(scatter.image.value, image)
+ npt.assert_almost_equal(scatter.image.buffer[0, 0].data, image)
+
+ # test changes and event
+
+ image2 = np.array(
+ [
+ [0, 1, 0],
+ [1, 0, 1],
+ [0, 1, 0]
+ ]
+ )
+
+ scatter.add_event_handler(event_handler, "image")
+
+ scatter.image = image2
+ npt.assert_almost_equal(scatter.image.buffer[0, 0].data, image2)
+
+ assert EVENT_RETURN_VALUE.graphic is scatter
+ assert EVENT_RETURN_VALUE.target is scatter.world_object
+ assert EVENT_RETURN_VALUE.type == "image"
+ npt.assert_almost_equal(EVENT_RETURN_VALUE.info["value"], image2)
diff --git a/tests/test_sizes_buffer_manager.py b/tests/test_sizes_buffer_manager.py
index 2f55eab27..d1260e27c 100644
--- a/tests/test_sizes_buffer_manager.py
+++ b/tests/test_sizes_buffer_manager.py
@@ -2,7 +2,7 @@
from numpy import testing as npt
import pytest
-from fastplotlib.graphics.features import PointsSizesFeature
+from fastplotlib.graphics.features import VertexPointSizes
from .utils import generate_slice_indices
@@ -28,7 +28,7 @@ def generate_data(input_type: str) -> np.ndarray | float:
@pytest.mark.parametrize("data", [generate_data(v) for v in ["float", "sine"]])
def test_create_buffer(data):
- sizes = PointsSizesFeature(data, n_datapoints=10)
+ sizes = VertexPointSizes(data, n_datapoints=10)
if isinstance(data, float):
npt.assert_almost_equal(sizes[:], generate_data("float"))
@@ -50,7 +50,7 @@ def test_slice(slice_method: dict, user_input: str):
size = slice_method["size"]
others = slice_method["others"]
- sizes = PointsSizesFeature(data, n_datapoints=10)
+ sizes = VertexPointSizes(data, n_datapoints=10)
match user_input:
case "float":
diff --git a/tests/test_texture_array_volume.py b/tests/test_texture_array_volume.py
new file mode 100644
index 000000000..f2d28501b
--- /dev/null
+++ b/tests/test_texture_array_volume.py
@@ -0,0 +1,206 @@
+import numpy as np
+from numpy import testing as npt
+import pytest
+
+import pygfx
+
+import fastplotlib as fpl
+from fastplotlib.graphics.features import TextureArrayVolume
+from fastplotlib.graphics.image_volume import _VolumeTile
+
+
+MAX_TEXTURE_SIZE_3D = 128
+
+
+def make_data(z: int, n_rows: int, n_cols: int) -> np.ndarray:
+ """
+ Makes a 2D array where the amplitude of the sine wave
+ is increasing along the y-direction (along rows), and
+ the wavelength is increasing along the x-axis (columns)
+ """
+ xs = np.linspace(0, 100, n_cols)
+
+ sine = np.sin(np.sqrt(xs))
+
+ data = np.dstack(
+ [
+ np.column_stack([sine * i for i in range(n_rows)]).astype(np.float32) * j
+ for j in range(z)
+ ]
+ )
+
+ return data.T
+
+
+def check_texture_array(
+ data: np.ndarray,
+ ta: TextureArrayVolume,
+ buffer_size: int,
+ buffer_shape: tuple[int, int, int],
+ zdim_indices_size: int,
+ row_indices_size: int,
+ col_indices_size: int,
+ zdim_indices_values: np.ndarray,
+ row_indices_values: np.ndarray,
+ col_indices_values: np.ndarray,
+):
+
+ npt.assert_almost_equal(ta.value, data)
+
+ assert ta.buffer.size == buffer_size
+ assert ta.buffer.shape == buffer_shape
+
+ assert all([isinstance(texture, pygfx.Texture) for texture in ta.buffer.ravel()])
+
+ assert ta.zdim_indices.size == zdim_indices_size
+ assert ta.row_indices.size == row_indices_size
+ assert ta.col_indices.size == col_indices_size
+
+ npt.assert_array_equal(ta.zdim_indices, zdim_indices_values)
+ npt.assert_array_equal(ta.row_indices, row_indices_values)
+ npt.assert_array_equal(ta.col_indices, col_indices_values)
+
+ # make sure chunking is correct
+ for texture, chunk_index, data_slice in ta:
+ assert ta.buffer[chunk_index] is texture
+ chunk_z, chunk_row, chunk_col = chunk_index
+
+ data_z_start_index = chunk_z * MAX_TEXTURE_SIZE_3D
+ data_row_start_index = chunk_row * MAX_TEXTURE_SIZE_3D
+ data_col_start_index = chunk_col * MAX_TEXTURE_SIZE_3D
+
+ data_z_stop_index = min(data.shape[0], data_z_start_index + MAX_TEXTURE_SIZE_3D)
+
+ data_row_stop_index = min(
+ data.shape[1], data_row_start_index + MAX_TEXTURE_SIZE_3D
+ )
+ data_col_stop_index = min(
+ data.shape[2], data_col_start_index + MAX_TEXTURE_SIZE_3D
+ )
+
+ zdim_slice = slice(data_z_start_index, data_z_stop_index)
+ row_slice = slice(data_row_start_index, data_row_stop_index)
+ col_slice = slice(data_col_start_index, data_col_stop_index)
+
+ assert data_slice == (zdim_slice, row_slice, col_slice)
+
+
+def check_set_slice(data, ta, zdim_slice, row_slice, col_slice):
+ ta[zdim_slice, row_slice, col_slice] = 1
+ npt.assert_almost_equal(ta[zdim_slice, row_slice, col_slice], 1)
+
+ # make sure other vals unchanged
+ npt.assert_almost_equal(ta[: zdim_slice.start], data[: zdim_slice.start])
+ npt.assert_almost_equal(ta[zdim_slice.stop :], data[zdim_slice.stop :])
+
+ npt.assert_almost_equal(ta[:, : row_slice.start], data[:, : row_slice.start])
+ npt.assert_almost_equal(ta[:, row_slice.stop :], data[:, row_slice.stop :])
+
+ npt.assert_almost_equal(ta[:, :, : col_slice.start], data[:, :, : col_slice.start])
+ npt.assert_almost_equal(ta[:, :, col_slice.stop :], data[:, :, col_slice.stop :])
+
+
+def make_image_volume_graphic(data) -> fpl.ImageVolumeGraphic:
+ fig = fpl.Figure(cameras="3d")
+ return fig[0, 0].add_image_volume(data, offset=(0, 0, 0))
+
+
+def check_image_graphic(texture_array, graphic):
+ # make sure each ImageTile has the right texture
+ for (texture, chunk_index, data_slice), img in zip(
+ texture_array, graphic.world_object.children
+ ):
+ assert isinstance(img, _VolumeTile)
+ assert img.geometry.grid is texture
+ assert img.world.z == data_slice[0].start
+ assert img.world.x == data_slice[2].start
+ assert img.world.y == data_slice[1].start
+
+
+@pytest.mark.parametrize("test_graphic", [False, True])
+def test_small_texture(test_graphic):
+ # tests TextureArray with dims that requires only 1 texture
+ data = make_data(32, 64, 64)
+
+ if test_graphic:
+ graphic = make_image_volume_graphic(data)
+ ta = graphic.data
+ else:
+ ta = TextureArrayVolume(data)
+
+ check_texture_array(
+ data=data,
+ ta=ta,
+ buffer_size=1,
+ buffer_shape=(1, 1, 1),
+ zdim_indices_size=1,
+ row_indices_size=1,
+ col_indices_size=1,
+ zdim_indices_values=np.array([0]),
+ row_indices_values=np.array([0]),
+ col_indices_values=np.array([0]),
+ )
+
+ if test_graphic:
+ check_image_graphic(ta, graphic)
+
+ check_set_slice(data, ta, slice(5, 20), slice(10, 40), slice(20, 50))
+
+
+@pytest.mark.parametrize("test_graphic", [False, True])
+def test_texture_at_limit(test_graphic):
+ # tests TextureArray with data that is 512 x 512 x 512
+ data = make_data(MAX_TEXTURE_SIZE_3D, MAX_TEXTURE_SIZE_3D, MAX_TEXTURE_SIZE_3D)
+
+ if test_graphic:
+ graphic = make_image_volume_graphic(data)
+ ta = graphic.data
+ else:
+ ta = TextureArrayVolume(data)
+
+ check_texture_array(
+ data=data,
+ ta=ta,
+ buffer_size=1,
+ buffer_shape=(1, 1, 1),
+ zdim_indices_size=1,
+ row_indices_size=1,
+ col_indices_size=1,
+ zdim_indices_values=np.array([0]),
+ row_indices_values=np.array([0]),
+ col_indices_values=np.array([0]),
+ )
+
+ if test_graphic:
+ check_image_graphic(ta, graphic)
+
+ check_set_slice(data, ta, slice(5, 40), slice(10, 100), slice(20, 110))
+
+
+@pytest.mark.parametrize("test_graphic", [False, True])
+def test_high_cols(test_graphic):
+ data = make_data(10, 100, 300)
+
+ if test_graphic:
+ graphic = make_image_volume_graphic(data)
+ ta = graphic.data
+ else:
+ ta = TextureArrayVolume(data)
+
+ check_texture_array(
+ data,
+ ta=ta,
+ buffer_size=3,
+ buffer_shape=(1, 1, 3),
+ zdim_indices_size=1,
+ row_indices_size=1,
+ col_indices_size=3,
+ zdim_indices_values=np.array([0]),
+ row_indices_values=np.array([0]),
+ col_indices_values=np.array([0, MAX_TEXTURE_SIZE_3D, 2 * MAX_TEXTURE_SIZE_3D]),
+ )
+
+ if test_graphic:
+ check_image_graphic(ta, graphic)
+
+ check_set_slice(data, ta, slice(2, 7), slice(60, 90), slice(100, 180))
diff --git a/tests/utils.py b/tests/utils.py
index bc9a092c8..6da080433 100644
--- a/tests/utils.py
+++ b/tests/utils.py
@@ -148,6 +148,23 @@ def generate_color_inputs(
array = np.vstack([pygfx.Color(c) for c in s])
return [s, array]
+ if name == "multi2":
+ # a second multi option
+ s = [
+ "g",
+ "r",
+ "cyan",
+ "magenta",
+ "b",
+ "black",
+ "white",
+ "purple",
+ "yellow",
+ "pink",
+ ]
+ array = np.vstack([pygfx.Color(c) for c in s])
+ return [s, array]
+
color = pygfx.Color(name)
s = name
@@ -172,3 +189,37 @@ def generate_color_inputs(
[1.0, 0.6470588445663452, 0.0, 1.0],
]
)
+
+
+TRUTH_CMAPS = {
+ "jet": np.array(
+ [
+ [0.0, 0.0, 0.5, 1.0],
+ [0.0, 0.0, 0.99910873, 1.0],
+ [0.0, 0.37843138, 1.0, 1.0],
+ [0.0, 0.8333333, 1.0, 1.0],
+ [0.30044276, 1.0, 0.66729915, 1.0],
+ [0.65464896, 1.0, 0.31309298, 1.0],
+ [1.0, 0.90123457, 0.0, 1.0],
+ [1.0, 0.4945534, 0.0, 1.0],
+ [1.0, 0.08787218, 0.0, 1.0],
+ [0.5, 0.0, 0.0, 1.0],
+ ],
+ dtype=np.float32,
+ ),
+ "viridis": np.array(
+ [
+ [0.267004, 0.004874, 0.329415, 1.0],
+ [0.281412, 0.155834, 0.469201, 1.0],
+ [0.244972, 0.287675, 0.53726, 1.0],
+ [0.190631, 0.407061, 0.556089, 1.0],
+ [0.147607, 0.511733, 0.557049, 1.0],
+ [0.119483, 0.614817, 0.537692, 1.0],
+ [0.20803, 0.718701, 0.472873, 1.0],
+ [0.421908, 0.805774, 0.35191, 1.0],
+ [0.699415, 0.867117, 0.175971, 1.0],
+ [0.993248, 0.906157, 0.143936, 1.0],
+ ],
+ dtype=np.float32,
+ ),
+}