diff --git a/.github/workflows/ci-pygfx-release.yml b/.github/workflows/ci-pygfx-release.yml
index e93f82fd5..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"]
@@ -59,30 +59,31 @@ jobs:
python -c "from examples.tests.testutils import wgpu_backend; print(wgpu_backend)"
- name: Test components
env:
- WGPU_FORCE_OFFSCREEN: 1
+ RENDERCANVAS_FORCE_OFFSCREEN: 1
run: |
pytest -v tests/
- name: Test examples
+ if: ${{ matrix.python == '3.13' }}
env:
- WGPU_FORCE_OFFSCREEN: 1
+ 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)
- uses: actions/upload-artifact@v4
if: ${{ failure() }}
with:
- name: screenshot-diffs-${{ matrix.pyversion }}-${{ matrix.imgui_dep }}-${{ matrix.notebook_dep }}
+ name: screenshot-diffs-${{ matrix.os }}-${{ matrix.pyversion }}-${{ matrix.imgui_dep }}-${{ matrix.notebook_dep }}
path: |
examples/diffs
examples/notebooks/diffs
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 0f50b9623..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"]
@@ -52,7 +52,7 @@ jobs:
run: |
python -m pip install --upgrade pip setuptools
# remove pygfx from install_requires, we install using pygfx@main
- sed -i "/pygfx/d" ./setup.py
+ sed -i "/pygfx/d" ./pyproject.toml
pip install git+https://github.com/pygfx/pygfx.git@main
- name: Install fastplotlib
run: |
@@ -65,30 +65,31 @@ jobs:
python -c "from examples.tests.testutils import wgpu_backend; print(wgpu_backend)"
- name: Test components
env:
- WGPU_FORCE_OFFSCREEN: 1
+ RENDERCANVAS_FORCE_OFFSCREEN: 1
run: |
pytest -v tests/
- name: Test examples
+ if: ${{ matrix.python == '3.13' }}
env:
- WGPU_FORCE_OFFSCREEN: 1
+ 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)
- uses: actions/upload-artifact@v4
if: ${{ failure() }}
with:
- name: screenshot-diffs-${{ matrix.pyversion }}-${{ matrix.imgui_dep }}-${{ matrix.notebook_dep }}
+ name: screenshot-diffs-${{ matrix.os }}-${{ matrix.pyversion }}-${{ matrix.imgui_dep }}-${{ matrix.notebook_dep }}
path: |
examples/diffs
examples/notebooks/diffs
diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml
index fe267291a..470e2e5a5 100644
--- a/.github/workflows/docs-deploy.yml
+++ b/.github/workflows/docs-deploy.yml
@@ -40,7 +40,7 @@ jobs:
run: |
python -m pip install --upgrade pip setuptools
# remove pygfx from install_requires, we install using pygfx@main
- sed -i "/pygfx/d" ./setup.py
+ sed -i "/pygfx/d" ./pyproject.toml
pip install git+https://github.com/pygfx/pygfx.git@main
pip install -e ".[docs,notebook,imgui]"
- name: Show wgpu backend
@@ -69,17 +69,19 @@ jobs:
# any push to main goes to fastplotlib.org/ver/dev
run: echo "DOCS_VERSION_DIR=dev" >> "$GITHUB_ENV"
- # upload docs via FTP
+ # upload docs via SCP
- name: Deploy docs
- uses: SamKirkland/FTP-Deploy-Action@v4.3.5
+ uses: appleboy/scp-action@v0.1.7
with:
- server: ${{ secrets.DOCS_SERVER }}
+ host: ${{ secrets.DOCS_SERVER }}
username: ${{ secrets.DOCS_USERNAME }}
- password: ${{ secrets.DOCS_PASSWORD }}
- # built docs
- local-dir: docs/build/html/
- # output subdir based on the previous if statements
- server-dir: ./ver/${{ env.DOCS_VERSION_DIR }}/
+ port: ${{ secrets.DOCS_PORT }}
+ key: ${{ secrets.DOCS_KEY }}
+ passphrase: ${{ secrets.DOCS_PASS }}
+ source: "docs/build/html/*"
+ # without strip_components it creates dirs docs/build/html within /ver on the server
+ strip_components: 3
+ target: /home/${{ secrets.DOCS_USERNAME }}/public_html/ver/${{ env.DOCS_VERSION_DIR }}/
# comment on PR to provide link to built docs
- name: Add PR link in comment
@@ -89,16 +91,17 @@ jobs:
message: |
📚 Docs preview built and uploaded! https://www.fastplotlib.org/ver/${{ env.DOCS_VERSION_DIR }}
- # also deploy to root if this is a new release
- # i.e., fastplotlib.org/ points to docs for the latest release
- - name: Deploy docs
+ # upload docs via SCP
+ - name: Deploy docs release
if: ${{ github.ref_type == 'tag' }}
- uses: SamKirkland/FTP-Deploy-Action@v4.3.5
+ uses: appleboy/scp-action@v0.1.7
with:
- server: ${{ secrets.DOCS_SERVER }}
+ host: ${{ secrets.DOCS_SERVER }}
username: ${{ secrets.DOCS_USERNAME }}
- password: ${{ secrets.DOCS_PASSWORD }}
- local-dir: docs/build/html/
- server-dir: ./ # deploy to the root dir
- exclude: | # don't delete the /ver/ dir
- **/ver/**
+ port: ${{ secrets.DOCS_PORT }}
+ key: ${{ secrets.DOCS_KEY }}
+ passphrase: ${{ secrets.DOCS_PASS }}
+ source: "docs/build/html/*"
+ # without strip_components it creates dirs docs/build/html within /ver on the server
+ strip_components: 3
+ target: /home/${{ secrets.DOCS_USERNAME }}/public_html/
diff --git a/.github/workflows/screenshots.yml b/.github/workflows/screenshots.yml
index c7f3add5e..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
@@ -36,7 +36,7 @@ jobs:
run: |
python -m pip install --upgrade pip setuptools
# remove pygfx from install_requires, we install using pygfx@main
- sed -i "/pygfx/d" ./setup.py
+ sed -i "/pygfx/d" ./pyproject.toml
pip install git+https://github.com/pygfx/pygfx.git@main
- name: Install fastplotlib
run: |
@@ -51,8 +51,11 @@ jobs:
env:
PYGFX_EXPECT_LAVAPIPE: true
run: |
+ # delete existing screenshots
+ rm ./examples/screenshots/*.png
+ rm ./examples/notebooks/screenshots/*.png
# regenerate screenshots
- WGPU_FORCE_OFFSCREEN=1 REGENERATE_SCREENSHOTS=1 pytest -v examples
+ RENDERCANVAS_FORCE_OFFSCREEN=1 REGENERATE_SCREENSHOTS=1 pytest -v examples
- name: Generate screenshots notebook, exclude image widget
env:
PYGFX_EXPECT_LAVAPIPE: true
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 65efc3352..545171687 100644
--- a/CODE_OF_CONDUCT.md
+++ b/CODE_OF_CONDUCT.md
@@ -63,6 +63,7 @@ We strive to:
- Excessive profanity. Please avoid swearwords; people differ greatly in their sensitivity to swearing.
- Repeated harassment of others. In general, if someone asks you to stop, then stop.
- Advocating for, or encouraging, any of the above behavior.
+ - LLM spam or inauthentic interaction that is completely generated by an LLM is discouraged. We welcome the use of LLMs as tools, but unsolicited LLM bot accounts for example are not encouraged.
# Diversity statement
@@ -86,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
@@ -104,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 347275b6a..a10f9fb9a 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -2,27 +2,70 @@
`fastplotlib` is a next-generation plotting library built on top of the `pygfx` rendering engine that leverages modern
GPU hardware and new graphics APIs to build large-scale scientific visualizations. We welcome and encourage contributions
-from everyone! :smile:
+from everyone! :smile:
-This guide explains how to contribute: if you have questions about the process, please
+The rest of this guide explains how to contribute; if you have questions about the process, please
reach out on [GitHub Discussions](https://github.com/fastplotlib/fastplotlib/discussions).
> **_NOTE:_** If you are already familiar with contributing to open-source software packages,
-> please check out the [quick guide](#contributing-quick-guide)!
+> please check out the [Quick Guide](#contributing-quick-guide)!
## General Guidelines
Developers are encouraged to contribute to various areas of development. This could include the addition of new features (e.g.
graphics or selector tools), bug fixes, or the addition of new examples to the [examples gallery](https://www.fastplotlib.org/ver/dev/_gallery/index.html).
-Enhancements to documentation and the overall readability of the code are also greatly appreciated.
+Enhancements to documentation and the overall readability of the code are also greatly appreciated. :)
Feel free to work on any section of the code that you believe you can improve. More importantly, remember to thoroughly test all
your classes and functions, and to provide clear, detailed comments within your code. This not only aids others in using the library,
but also facilitates future maintenance and further development.
+If your PR will introduce **significant** changes, or new features that are not in our Roadmap, please open an
+Issue describing your proposed changes so we can assess whether the contribution would be accepted or not, and also so we can provide guidance
+on how the proposed implementation can be tailored to conform with the rest of the codebase.
+
For more detailed information about `fastplotlib` modules, including design choices and implementation details, visit the
[`For Develeopers`](https://www.fastplotlib.org/ver/dev/developer_notes/index.html) section of the package documentation.
+## AI Policy
+
+*This policy was adapted from `pygfx`, `scikit-learn`, and `SciPy`*
+
+While we recognize that LLMs may be useful, at our core, we are a small team of developers who enjoy discussing code written by other humans.
+As such, our preference is that contributions are written without the use of AI.
+
+### Responsibility
+
+You are responsible for all the code that you contribute, including AI
+generated code. You must understand and be able to explain the submitted code as
+well as its relation to existing code. It is not acceptable to submit a
+PR for code that you cannot understand and explain yourself.
+
+### Disclosure
+
+You must disclose whether AI has been used to produce any code of your
+pull-request. If so, you must document which tool(s) have been used, how they
+were used, and specify what code or text is AI generated.
+
+### Copyright
+
+Contributors must own the copyright of any code submitted to `fastplotlib`. Code
+generated by AI may infringe on copyright and it is your responsibility to not
+infringe. We reserve the right to reject any pull requests where the copyright
+is in question.
+
+### Communication
+
+When interacting with developers (in discussions, issues, pull-requests,
+etc.) do not use AI to speak for you, except for translation or grammar editing.
+Human-to-human communication is essential for an open source community to
+thrive.
+
+### AI Agents
+
+The use of an AI agent that writes code and then submits a pull request
+autonomously is not permitted.
+
## Contributing to the code
### Contribution workflow cycle
@@ -100,11 +143,11 @@ git checkout -b my_feature_branch
After you have made changes on this branch, add and commit them when you are ready:
```bash
-# lint your code
-black .
+# black format only the source code
+black fastplotlib/
# run tests from the repo root dir
-WGPU_FORCE_OFFSCREEN=1 pytest tests/
+RENDERCANVAS_FORCE_OFFSCREEN=1 pytest tests/
# desktop examples
pytest -v examples
@@ -195,6 +238,13 @@ The tests will produce slightly different imperceptible (to a human) results on
ground-truth. A small RMSE tolerance has been chosen, `0.025` for most examples. If the output image and
ground-truth image are within that tolerance the test will pass.
+If the test image and ground-truth image are above the threshold, the test will fail and a difference image will be located in the follow directory:
+
+```
+examples/desktop/diffs
+examples/notebooks/diffs
+```
+
Some feature development may require the ground-truth screenshots to be updated. In the event that your changes require
this, please do the following:
@@ -288,12 +338,12 @@ pip install -e ".[imgui, tests, docs, notebook]"
4) Lint codebase and make sure tests pass
```bash
-# lint codebase
-black .
+# black format only the source code
+black fastplotlib/
# run tests
# backend tests
-WGPU_FORCE_OFFSCREEN=1 pytest tests/
+RENDERCANVAS_FORCE_OFFSCREEN=1 pytest tests/
# desktop examples
pytest -v examples
diff --git a/GOVERNANCE.md b/GOVERNANCE.md
index 59b844621..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:
@@ -103,6 +103,8 @@ Anyone (absolutely anyone, not just the leadership team members) who feels that
### Process
+#### Usual process
+
1. Contact the neutral moderator with a description of the conflict, max of 250 words.
2. Neutral moderator must schedule a vote within 15 days. If that is not possible then within the next 45 days.
3. The individual who has invoked the conflict vote can choose to present their case, or they may choose to let the neutral moderator represent them.
@@ -110,12 +112,17 @@ Anyone (absolutely anyone, not just the leadership team members) who feels that
4. The maintainers vote on one of the actions from “Enforcement Guidelines”: https://www.contributor-covenant.org/version/2/1/code_of_conduct/. It is advised that the first offense leads to action (1) “Correction”. Repeated or serious offenses from the same individual/organization may lead to escalating levels of actions. Very bad behavior, as determined by the leadership team, can justify a first offense resulting in (3) “Temporary Ban” or (4) “Permanent Ban”.
5. The advisory committee members may advise on the actions, but the ultimate decision is voted on by the maintainers.
+#### Bot accounts, LLM accounts, and spam
+
+Unsolicited bot accounts, inauthentic interaction that is completetely generated by an LLM, and LLM spam are against our Code of Conduct. Bot accounts with fully LLM generated comments, issues, pull requests, discussion posts, or any other unsolicited LLM generated content will be deleted by the maintainers without notice and the account will not be allowed to interact with the fastplotlib organization.
+
## Transparency
Governance decisions, meeting minutes, and voting outcomes are publicly documented and accessible. We aim for transparency to allow the broader community to understand and trust the governance process.
## Changes to this governance document
-### Until February 28, 2025
+**Effective until February 5, 2027**
-During early stages of fastplotlib development, changes to the governance document may be made directly through unanimous approval by the original maintainers, Kushal Kolar & Caitlin Lewis. They (Kushal & Caitlin) may also add new members to the advisory committee through unanimous approval.
+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/MANIFEST.in b/MANIFEST.in
deleted file mode 100644
index b8debd28d..000000000
--- a/MANIFEST.in
+++ /dev/null
@@ -1,4 +0,0 @@
-recursive-include fastplotlib/utils/colormaps/ *
-include fastplotlib/VERSION
-recursive-include fastplotlib/assets/ *
-
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/_static/guide_ipywidgets.webp b/docs/source/_static/guide_ipywidgets.webp
new file mode 100644
index 000000000..9a7963381
Binary files /dev/null and b/docs/source/_static/guide_ipywidgets.webp differ
diff --git a/docs/source/_static/switcher.json b/docs/source/_static/switcher.json
index 67f723e2f..9f792b252 100644
--- a/docs/source/_static/switcher.json
+++ b/docs/source/_static/switcher.json
@@ -1,7 +1,22 @@
[
+ {
+ "name": "release",
+ "version": "v0.4.0",
+ "url": "http://www.fastplotlib.org/"
+ },
{
"name": "dev/main",
"version": "dev",
- "url": "http://www.fastplotlib.org/versions/dev"
+ "url": "http://www.fastplotlib.org/ver/dev"
+ },
+ {
+ "name": "v0.3.0",
+ "version": "v0.3.0",
+ "url": "http://www.fastplotlib.org/ver/0.3.0"
+ },
+ {
+ "name": "v0.4.0",
+ "version": "v0.4.0",
+ "url": "http://www.fastplotlib.org/ver/0.4.0"
}
]
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/Deleted.rst b/docs/source/api/graphic_features/Deleted.rst
index 09131c4a7..ffc704917 100644
--- a/docs/source/api/graphic_features/Deleted.rst
+++ b/docs/source/api/graphic_features/Deleted.rst
@@ -6,7 +6,7 @@ Deleted
=======
Deleted
=======
-.. currentmodule:: fastplotlib.graphics._features
+.. currentmodule:: fastplotlib.graphics.features
Constructor
~~~~~~~~~~~
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/FontSize.rst b/docs/source/api/graphic_features/FontSize.rst
index 4b8df9826..5e34c6038 100644
--- a/docs/source/api/graphic_features/FontSize.rst
+++ b/docs/source/api/graphic_features/FontSize.rst
@@ -6,7 +6,7 @@ FontSize
========
FontSize
========
-.. currentmodule:: fastplotlib.graphics._features
+.. currentmodule:: fastplotlib.graphics.features
Constructor
~~~~~~~~~~~
diff --git a/docs/source/api/graphic_features/GraphicFeatureEvent.rst b/docs/source/api/graphic_features/GraphicFeatureEvent.rst
new file mode 100644
index 000000000..233462052
--- /dev/null
+++ b/docs/source/api/graphic_features/GraphicFeatureEvent.rst
@@ -0,0 +1,38 @@
+.. _api.GraphicFeatureEvent:
+
+GraphicFeatureEvent
+*******************
+
+===================
+GraphicFeatureEvent
+===================
+.. currentmodule:: fastplotlib.graphics.features
+
+Constructor
+~~~~~~~~~~~
+.. autosummary::
+ :toctree: GraphicFeatureEvent_api
+
+ GraphicFeatureEvent
+
+Properties
+~~~~~~~~~~
+.. autosummary::
+ :toctree: GraphicFeatureEvent_api
+
+ GraphicFeatureEvent.bubbles
+ GraphicFeatureEvent.cancelled
+ GraphicFeatureEvent.current_target
+ GraphicFeatureEvent.root
+ GraphicFeatureEvent.target
+ GraphicFeatureEvent.time_stamp
+ GraphicFeatureEvent.type
+
+Methods
+~~~~~~~
+.. autosummary::
+ :toctree: GraphicFeatureEvent_api
+
+ GraphicFeatureEvent.cancel
+ GraphicFeatureEvent.stop_propagation
+
diff --git a/docs/source/api/graphic_features/ImageCmap.rst b/docs/source/api/graphic_features/ImageCmap.rst
index 23d16a4a2..2c23a3406 100644
--- a/docs/source/api/graphic_features/ImageCmap.rst
+++ b/docs/source/api/graphic_features/ImageCmap.rst
@@ -6,7 +6,7 @@ ImageCmap
=========
ImageCmap
=========
-.. currentmodule:: fastplotlib.graphics._features
+.. currentmodule:: fastplotlib.graphics.features
Constructor
~~~~~~~~~~~
diff --git a/docs/source/api/graphic_features/ImageCmapInterpolation.rst b/docs/source/api/graphic_features/ImageCmapInterpolation.rst
index 7e04ec788..0577f2d70 100644
--- a/docs/source/api/graphic_features/ImageCmapInterpolation.rst
+++ b/docs/source/api/graphic_features/ImageCmapInterpolation.rst
@@ -6,7 +6,7 @@ ImageCmapInterpolation
======================
ImageCmapInterpolation
======================
-.. currentmodule:: fastplotlib.graphics._features
+.. currentmodule:: fastplotlib.graphics.features
Constructor
~~~~~~~~~~~
diff --git a/docs/source/api/graphic_features/ImageInterpolation.rst b/docs/source/api/graphic_features/ImageInterpolation.rst
index 866e76333..ebf69c279 100644
--- a/docs/source/api/graphic_features/ImageInterpolation.rst
+++ b/docs/source/api/graphic_features/ImageInterpolation.rst
@@ -6,7 +6,7 @@ ImageInterpolation
==================
ImageInterpolation
==================
-.. currentmodule:: fastplotlib.graphics._features
+.. currentmodule:: fastplotlib.graphics.features
Constructor
~~~~~~~~~~~
diff --git a/docs/source/api/graphic_features/ImageVmax.rst b/docs/source/api/graphic_features/ImageVmax.rst
index b7dfe7e2d..aa8d6526a 100644
--- a/docs/source/api/graphic_features/ImageVmax.rst
+++ b/docs/source/api/graphic_features/ImageVmax.rst
@@ -6,7 +6,7 @@ ImageVmax
=========
ImageVmax
=========
-.. currentmodule:: fastplotlib.graphics._features
+.. currentmodule:: fastplotlib.graphics.features
Constructor
~~~~~~~~~~~
diff --git a/docs/source/api/graphic_features/ImageVmin.rst b/docs/source/api/graphic_features/ImageVmin.rst
index 0d4634894..361cc5838 100644
--- a/docs/source/api/graphic_features/ImageVmin.rst
+++ b/docs/source/api/graphic_features/ImageVmin.rst
@@ -6,7 +6,7 @@ ImageVmin
=========
ImageVmin
=========
-.. currentmodule:: fastplotlib.graphics._features
+.. currentmodule:: fastplotlib.graphics.features
Constructor
~~~~~~~~~~~
diff --git a/docs/source/api/graphic_features/LinearRegionSelectionFeature.rst b/docs/source/api/graphic_features/LinearRegionSelectionFeature.rst
index b8958c86b..9f06f2682 100644
--- a/docs/source/api/graphic_features/LinearRegionSelectionFeature.rst
+++ b/docs/source/api/graphic_features/LinearRegionSelectionFeature.rst
@@ -6,7 +6,7 @@ LinearRegionSelectionFeature
============================
LinearRegionSelectionFeature
============================
-.. currentmodule:: fastplotlib.graphics._features
+.. currentmodule:: fastplotlib.graphics.features
Constructor
~~~~~~~~~~~
diff --git a/docs/source/api/graphic_features/LinearSelectionFeature.rst b/docs/source/api/graphic_features/LinearSelectionFeature.rst
index ad7b8645a..b9e71cd7b 100644
--- a/docs/source/api/graphic_features/LinearSelectionFeature.rst
+++ b/docs/source/api/graphic_features/LinearSelectionFeature.rst
@@ -6,7 +6,7 @@ LinearSelectionFeature
======================
LinearSelectionFeature
======================
-.. currentmodule:: fastplotlib.graphics._features
+.. currentmodule:: fastplotlib.graphics.features
Constructor
~~~~~~~~~~~
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/Name.rst b/docs/source/api/graphic_features/Name.rst
index 288fcfc22..f5a5235d8 100644
--- a/docs/source/api/graphic_features/Name.rst
+++ b/docs/source/api/graphic_features/Name.rst
@@ -6,7 +6,7 @@ Name
====
Name
====
-.. currentmodule:: fastplotlib.graphics._features
+.. currentmodule:: fastplotlib.graphics.features
Constructor
~~~~~~~~~~~
diff --git a/docs/source/api/graphic_features/Offset.rst b/docs/source/api/graphic_features/Offset.rst
index 683aaf763..fdb2af66a 100644
--- a/docs/source/api/graphic_features/Offset.rst
+++ b/docs/source/api/graphic_features/Offset.rst
@@ -6,7 +6,7 @@ Offset
======
Offset
======
-.. currentmodule:: fastplotlib.graphics._features
+.. currentmodule:: fastplotlib.graphics.features
Constructor
~~~~~~~~~~~
diff --git a/docs/source/api/graphic_features/PointsSizesFeature.rst b/docs/source/api/graphic_features/PointsSizesFeature.rst
deleted file mode 100644
index 3dcc4eeb2..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/RectangleSelectionFeature.rst b/docs/source/api/graphic_features/RectangleSelectionFeature.rst
index d35752a24..cdfd1ad3f 100644
--- a/docs/source/api/graphic_features/RectangleSelectionFeature.rst
+++ b/docs/source/api/graphic_features/RectangleSelectionFeature.rst
@@ -6,7 +6,7 @@ RectangleSelectionFeature
=========================
RectangleSelectionFeature
=========================
-.. currentmodule:: fastplotlib.graphics._features
+.. currentmodule:: fastplotlib.graphics.features
Constructor
~~~~~~~~~~~
diff --git a/docs/source/api/graphic_features/Rotation.rst b/docs/source/api/graphic_features/Rotation.rst
index f8963b0fd..b7729c7a4 100644
--- a/docs/source/api/graphic_features/Rotation.rst
+++ b/docs/source/api/graphic_features/Rotation.rst
@@ -6,7 +6,7 @@ Rotation
========
Rotation
========
-.. currentmodule:: fastplotlib.graphics._features
+.. currentmodule:: fastplotlib.graphics.features
Constructor
~~~~~~~~~~~
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/SizeSpace.rst b/docs/source/api/graphic_features/SizeSpace.rst
index 0bca1ecc8..e7c8e30be 100644
--- a/docs/source/api/graphic_features/SizeSpace.rst
+++ b/docs/source/api/graphic_features/SizeSpace.rst
@@ -6,7 +6,7 @@ SizeSpace
=========
SizeSpace
=========
-.. currentmodule:: fastplotlib.graphics._features
+.. currentmodule:: fastplotlib.graphics.features
Constructor
~~~~~~~~~~~
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/TextData.rst b/docs/source/api/graphic_features/TextData.rst
index 1c27b6e48..bf08b08d6 100644
--- a/docs/source/api/graphic_features/TextData.rst
+++ b/docs/source/api/graphic_features/TextData.rst
@@ -6,7 +6,7 @@ TextData
========
TextData
========
-.. currentmodule:: fastplotlib.graphics._features
+.. currentmodule:: fastplotlib.graphics.features
Constructor
~~~~~~~~~~~
diff --git a/docs/source/api/graphic_features/TextFaceColor.rst b/docs/source/api/graphic_features/TextFaceColor.rst
index 5dae54192..5ab01b04b 100644
--- a/docs/source/api/graphic_features/TextFaceColor.rst
+++ b/docs/source/api/graphic_features/TextFaceColor.rst
@@ -6,7 +6,7 @@ TextFaceColor
=============
TextFaceColor
=============
-.. currentmodule:: fastplotlib.graphics._features
+.. currentmodule:: fastplotlib.graphics.features
Constructor
~~~~~~~~~~~
diff --git a/docs/source/api/graphic_features/TextOutlineColor.rst b/docs/source/api/graphic_features/TextOutlineColor.rst
index f7831b0df..571261625 100644
--- a/docs/source/api/graphic_features/TextOutlineColor.rst
+++ b/docs/source/api/graphic_features/TextOutlineColor.rst
@@ -6,7 +6,7 @@ TextOutlineColor
================
TextOutlineColor
================
-.. currentmodule:: fastplotlib.graphics._features
+.. currentmodule:: fastplotlib.graphics.features
Constructor
~~~~~~~~~~~
diff --git a/docs/source/api/graphic_features/TextOutlineThickness.rst b/docs/source/api/graphic_features/TextOutlineThickness.rst
index 75d485781..450ae54c9 100644
--- a/docs/source/api/graphic_features/TextOutlineThickness.rst
+++ b/docs/source/api/graphic_features/TextOutlineThickness.rst
@@ -6,7 +6,7 @@ TextOutlineThickness
====================
TextOutlineThickness
====================
-.. currentmodule:: fastplotlib.graphics._features
+.. currentmodule:: fastplotlib.graphics.features
Constructor
~~~~~~~~~~~
diff --git a/docs/source/api/graphic_features/TextureArray.rst b/docs/source/api/graphic_features/TextureArray.rst
index 79707c453..004881282 100644
--- a/docs/source/api/graphic_features/TextureArray.rst
+++ b/docs/source/api/graphic_features/TextureArray.rst
@@ -6,7 +6,7 @@ TextureArray
============
TextureArray
============
-.. currentmodule:: fastplotlib.graphics._features
+.. currentmodule:: fastplotlib.graphics.features
Constructor
~~~~~~~~~~~
@@ -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/Thickness.rst b/docs/source/api/graphic_features/Thickness.rst
index 061f96fe8..dc4c5888f 100644
--- a/docs/source/api/graphic_features/Thickness.rst
+++ b/docs/source/api/graphic_features/Thickness.rst
@@ -6,7 +6,7 @@ Thickness
=========
Thickness
=========
-.. currentmodule:: fastplotlib.graphics._features
+.. currentmodule:: fastplotlib.graphics.features
Constructor
~~~~~~~~~~~
diff --git a/docs/source/api/graphic_features/UniformColor.rst b/docs/source/api/graphic_features/UniformColor.rst
index 7370589b7..8e9d56eae 100644
--- a/docs/source/api/graphic_features/UniformColor.rst
+++ b/docs/source/api/graphic_features/UniformColor.rst
@@ -6,7 +6,7 @@ UniformColor
============
UniformColor
============
-.. currentmodule:: fastplotlib.graphics._features
+.. currentmodule:: fastplotlib.graphics.features
Constructor
~~~~~~~~~~~
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/UniformSize.rst b/docs/source/api/graphic_features/UniformSize.rst
index e342d6a70..e4727dcb9 100644
--- a/docs/source/api/graphic_features/UniformSize.rst
+++ b/docs/source/api/graphic_features/UniformSize.rst
@@ -6,7 +6,7 @@ UniformSize
===========
UniformSize
===========
-.. currentmodule:: fastplotlib.graphics._features
+.. currentmodule:: fastplotlib.graphics.features
Constructor
~~~~~~~~~~~
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 a3311d6e6..57b9d6311 100644
--- a/docs/source/api/graphic_features/VertexCmap.rst
+++ b/docs/source/api/graphic_features/VertexCmap.rst
@@ -6,7 +6,7 @@ VertexCmap
==========
VertexCmap
==========
-.. currentmodule:: fastplotlib.graphics._features
+.. currentmodule:: fastplotlib.graphics.features
Constructor
~~~~~~~~~~~
@@ -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 3c2089a78..b72b7564a 100644
--- a/docs/source/api/graphic_features/VertexColors.rst
+++ b/docs/source/api/graphic_features/VertexColors.rst
@@ -6,7 +6,7 @@ VertexColors
============
VertexColors
============
-.. currentmodule:: fastplotlib.graphics._features
+.. currentmodule:: fastplotlib.graphics.features
Constructor
~~~~~~~~~~~
@@ -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 9669ab6d5..95480e1d4 100644
--- a/docs/source/api/graphic_features/VertexPositions.rst
+++ b/docs/source/api/graphic_features/VertexPositions.rst
@@ -6,7 +6,7 @@ VertexPositions
===============
VertexPositions
===============
-.. currentmodule:: fastplotlib.graphics._features
+.. currentmodule:: fastplotlib.graphics.features
Constructor
~~~~~~~~~~~
@@ -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/Visible.rst b/docs/source/api/graphic_features/Visible.rst
index 957b4433a..06bfd2278 100644
--- a/docs/source/api/graphic_features/Visible.rst
+++ b/docs/source/api/graphic_features/Visible.rst
@@ -6,7 +6,7 @@ Visible
=======
Visible
=======
-.. currentmodule:: fastplotlib.graphics._features
+.. currentmodule:: fastplotlib.graphics.features
Constructor
~~~~~~~~~~~
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 dc88e97d6..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,5 +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
new file mode 100644
index 000000000..f94892949
--- /dev/null
+++ b/docs/source/api/graphics/Graphic.rst
@@ -0,0 +1,52 @@
+.. _api.Graphic:
+
+Graphic
+*******
+
+=======
+Graphic
+=======
+.. currentmodule:: fastplotlib
+
+Constructor
+~~~~~~~~~~~
+.. autosummary::
+ :toctree: Graphic_api
+
+ Graphic
+
+Properties
+~~~~~~~~~~
+.. autosummary::
+ :toctree: Graphic_api
+
+ Graphic.alpha
+ Graphic.alpha_mode
+ Graphic.axes
+ Graphic.block_events
+ Graphic.deleted
+ Graphic.event_handlers
+ Graphic.name
+ Graphic.offset
+ Graphic.right_click_menu
+ Graphic.rotation
+ Graphic.scale
+ Graphic.supported_events
+ Graphic.tooltip_format
+ Graphic.visible
+ Graphic.world_object
+
+Methods
+~~~~~~~
+.. autosummary::
+ :toctree: Graphic_api
+
+ 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 dd5ff1ccc..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,11 +51,13 @@ 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
- ImageGraphic.share_property
- ImageGraphic.unshare_property
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 ad4b7f929..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,11 +56,13 @@ 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
- LineCollection.share_property
- LineCollection.unshare_property
diff --git a/docs/source/api/graphics/LineGraphic.rst b/docs/source/api/graphics/LineGraphic.rst
index 4302ab56c..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,10 +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
- LineGraphic.share_property
- LineGraphic.unshare_property
diff --git a/docs/source/api/graphics/LineStack.rst b/docs/source/api/graphics/LineStack.rst
index db060a4c2..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,11 +56,13 @@ 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
- LineStack.share_property
- LineStack.unshare_property
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 83e734c61..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,8 +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
- ScatterGraphic.share_property
- ScatterGraphic.unshare_property
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 2a55d78ef..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,8 +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
- TextGraphic.share_property
- TextGraphic.unshare_property
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 b64ac53c0..bac85e6c1 100644
--- a/docs/source/api/graphics/index.rst
+++ b/docs/source/api/graphics/index.rst
@@ -4,9 +4,15 @@ Graphics
.. toctree::
:maxdepth: 1
+ Graphic
LineGraphic
- ImageGraphic
ScatterGraphic
+ ImageGraphic
+ ImageVolumeGraphic
+ VectorsGraphic
+ MeshGraphic
+ SurfaceGraphic
+ PolygonGraphic
TextGraphic
LineCollection
LineStack
diff --git a/docs/source/api/index.rst b/docs/source/api/index.rst
index 87c134782..3a1184e6c 100644
--- a/docs/source/api/index.rst
+++ b/docs/source/api/index.rst
@@ -9,6 +9,7 @@ API Reference
graphics/index
graphic_features/index
selectors/index
+ tools/index
ui/index
widgets/index
fastplotlib
diff --git a/docs/source/api/layouts/figure.rst b/docs/source/api/layouts/figure.rst
index 17ee965b6..54e91b24f 100644
--- a/docs/source/api/layouts/figure.rst
+++ b/docs/source/api/layouts/figure.rst
@@ -20,9 +20,11 @@ Properties
.. autosummary::
:toctree: Figure_api
+ Figure.animations
Figure.cameras
Figure.canvas
Figure.controllers
+ Figure.layout
Figure.names
Figure.renderer
Figure.shape
@@ -33,13 +35,15 @@ Methods
:toctree: Figure_api
Figure.add_animations
+ Figure.add_subplot
Figure.clear
+ Figure.clear_animations
Figure.close
Figure.export
+ Figure.export_numpy
Figure.get_pygfx_render_area
Figure.open_popup
Figure.remove_animation
- Figure.render
+ Figure.remove_subplot
Figure.show
- Figure.start_render
diff --git a/docs/source/api/layouts/imgui_figure.rst b/docs/source/api/layouts/imgui_figure.rst
index 38a546ae9..46e0c6ed3 100644
--- a/docs/source/api/layouts/imgui_figure.rst
+++ b/docs/source/api/layouts/imgui_figure.rst
@@ -20,11 +20,14 @@ 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
@@ -36,14 +39,16 @@ Methods
ImguiFigure.add_animations
ImguiFigure.add_gui
+ ImguiFigure.add_subplot
ImguiFigure.clear
+ ImguiFigure.clear_animations
ImguiFigure.close
ImguiFigure.export
+ ImguiFigure.export_numpy
ImguiFigure.get_pygfx_render_area
ImguiFigure.open_popup
ImguiFigure.register_popup
ImguiFigure.remove_animation
- ImguiFigure.render
+ ImguiFigure.remove_subplot
ImguiFigure.show
- ImguiFigure.start_render
diff --git a/docs/source/api/layouts/subplot.rst b/docs/source/api/layouts/subplot.rst
index 3de44222d..0916859b9 100644
--- a/docs/source/api/layouts/subplot.rst
+++ b/docs/source/api/layouts/subplot.rst
@@ -20,22 +20,27 @@ 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
Subplot.legends
Subplot.name
Subplot.objects
Subplot.parent
- Subplot.position
Subplot.renderer
Subplot.scene
Subplot.selectors
+ Subplot.title
Subplot.toolbar
+ Subplot.tooltip
Subplot.viewport
Methods
@@ -46,24 +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.center_title
Subplot.clear
+ Subplot.clear_animations
Subplot.delete_graphic
Subplot.get_figure
- Subplot.get_rect
+ Subplot.get_pick_info
Subplot.insert_graphic
Subplot.map_screen_to_world
+ Subplot.map_world_to_screen
Subplot.remove_animation
Subplot.remove_graphic
- Subplot.render
- Subplot.set_title
- Subplot.set_viewport_rect
diff --git a/docs/source/api/selectors/LinearRegionSelector.rst b/docs/source/api/selectors/LinearRegionSelector.rst
index 6c8d2eefc..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,11 +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
- LinearRegionSelector.share_property
- LinearRegionSelector.unshare_property
diff --git a/docs/source/api/selectors/LinearSelector.rst b/docs/source/api/selectors/LinearSelector.rst
index b82e3c1df..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,11 +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
- LinearSelector.share_property
- LinearSelector.unshare_property
diff --git a/docs/source/api/selectors/RectangleSelector.rst b/docs/source/api/selectors/RectangleSelector.rst
index 81c9afd66..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,11 +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
- RectangleSelector.share_property
- RectangleSelector.unshare_property
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
new file mode 100644
index 000000000..b3498dd68
--- /dev/null
+++ b/docs/source/api/tools/HistogramLUTTool.rst
@@ -0,0 +1,57 @@
+.. _api.HistogramLUTTool:
+
+HistogramLUTTool
+****************
+
+================
+HistogramLUTTool
+================
+.. currentmodule:: fastplotlib
+
+Constructor
+~~~~~~~~~~~
+.. autosummary::
+ :toctree: HistogramLUTTool_api
+
+ HistogramLUTTool
+
+Properties
+~~~~~~~~~~
+.. autosummary::
+ :toctree: HistogramLUTTool_api
+
+ HistogramLUTTool.alpha
+ HistogramLUTTool.alpha_mode
+ HistogramLUTTool.axes
+ HistogramLUTTool.block_events
+ HistogramLUTTool.cmap
+ HistogramLUTTool.deleted
+ HistogramLUTTool.event_handlers
+ 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
+ HistogramLUTTool.world_object
+
+Methods
+~~~~~~~
+.. autosummary::
+ :toctree: HistogramLUTTool_api
+
+ HistogramLUTTool.add_axes
+ HistogramLUTTool.add_event_handler
+ HistogramLUTTool.clear_event_handlers
+ HistogramLUTTool.format_pick_info
+ HistogramLUTTool.map_model_to_world
+ HistogramLUTTool.map_world_to_model
+ HistogramLUTTool.remove_event_handler
+ HistogramLUTTool.rotate
+ HistogramLUTTool.set_data
+
diff --git a/docs/source/api/tools/TextBox.rst b/docs/source/api/tools/TextBox.rst
new file mode 100644
index 000000000..b202f4270
--- /dev/null
+++ b/docs/source/api/tools/TextBox.rst
@@ -0,0 +1,38 @@
+.. _api.TextBox:
+
+TextBox
+*******
+
+=======
+TextBox
+=======
+.. currentmodule:: fastplotlib
+
+Constructor
+~~~~~~~~~~~
+.. autosummary::
+ :toctree: TextBox_api
+
+ TextBox
+
+Properties
+~~~~~~~~~~
+.. autosummary::
+ :toctree: TextBox_api
+
+ TextBox.background_color
+ TextBox.font_size
+ TextBox.outline_color
+ TextBox.padding
+ TextBox.position
+ TextBox.text_color
+ TextBox.visible
+
+Methods
+~~~~~~~
+.. autosummary::
+ :toctree: TextBox_api
+
+ TextBox.clear
+ TextBox.display
+
diff --git a/docs/source/api/tools/Tooltip.rst b/docs/source/api/tools/Tooltip.rst
new file mode 100644
index 000000000..8e017370e
--- /dev/null
+++ b/docs/source/api/tools/Tooltip.rst
@@ -0,0 +1,40 @@
+.. _api.Tooltip:
+
+Tooltip
+*******
+
+=======
+Tooltip
+=======
+.. currentmodule:: fastplotlib
+
+Constructor
+~~~~~~~~~~~
+.. autosummary::
+ :toctree: Tooltip_api
+
+ Tooltip
+
+Properties
+~~~~~~~~~~
+.. autosummary::
+ :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.visible
+
+Methods
+~~~~~~~
+.. autosummary::
+ :toctree: Tooltip_api
+
+ Tooltip.clear
+ Tooltip.display
+
diff --git a/docs/source/api/tools/index.rst b/docs/source/api/tools/index.rst
new file mode 100644
index 000000000..2bff8fb50
--- /dev/null
+++ b/docs/source/api/tools/index.rst
@@ -0,0 +1,10 @@
+Tools
+*****
+
+.. toctree::
+ :maxdepth: 1
+
+ HistogramLUTTool
+ TextBox
+ Tooltip
+ Cursor
diff --git a/docs/source/api/utils.rst b/docs/source/api/utils.rst
index 6222e22c6..be7b1a049 100644
--- a/docs/source/api/utils.rst
+++ b/docs/source/api/utils.rst
@@ -4,3 +4,7 @@ fastplotlib.utils
.. currentmodule:: fastplotlib.utils
.. automodule:: fastplotlib.utils.functions
:members:
+
+.. currentmodule:: fastplotlib.utils
+.. automodule:: fastplotlib.utils._plot_helpers
+ :members:
diff --git a/docs/source/conf.py b/docs/source/conf.py
index 66b3c9317..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,11 +41,12 @@
"sphinx.ext.viewcode",
"sphinx_copybutton",
"sphinx_design",
- "sphinx_gallery.gen_gallery"
+ "sphinx_gallery.gen_gallery",
]
sphinx_gallery_conf = {
"gallery_dirs": "_gallery",
+ "notebook_extensions": {}, # remove the download notebook button
"backreferences_dir": "_gallery/backreferences",
"doc_module": ("fastplotlib",),
"image_scrapers": ("pygfx",),
@@ -56,22 +54,31 @@
"subsection_order": ExplicitOrder(
[
"../../examples/image",
+ "../../examples/image_volume",
"../../examples/heatmap",
"../../examples/image_widget",
"../../examples/gridplot",
+ "../../examples/window_layouts",
+ "../../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",
"../../examples/misc",
"../../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)
@@ -88,8 +95,6 @@
templates_path = ["_templates"]
exclude_patterns = []
-napoleon_custom_sections = ["Features"]
-
# -- Options for HTML output -------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
@@ -101,8 +106,15 @@
"check_switcher": True,
"switcher": {
"json_url": "http://www.fastplotlib.org/_static/switcher.json",
- "version_match": release
- }
+ "version_match": release,
+ },
+ "icon_links": [
+ {
+ "name": "Github",
+ "url": "https://github.com/fastplotlib/fastplotlib",
+ "icon": "fa-brands fa-github",
+ }
+ ],
}
html_static_path = ["_static"]
@@ -115,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/developer_notes/layouts.rst b/docs/source/developer_notes/layouts.rst
index 4aacd38da..daf197c44 100644
--- a/docs/source/developer_notes/layouts.rst
+++ b/docs/source/developer_notes/layouts.rst
@@ -4,8 +4,8 @@ Layouts
PlotArea
--------
-This is the main base class within layouts. A ``Figure`` and ``Dock`` are areas within a ``Subplot`` that
-inherit from ``PlotArea``.
+This is the main base class within layouts. A ``Subplot`` and ``Dock`` are areas within a ``Figure``.
+``Subplot`` and ``Dock`` inherit from ``PlotArea``.
``PlotArea`` has the following key properties that allow it to be a "plot area" that can be used to view graphical objects:
@@ -81,4 +81,4 @@ Now that we have understood ``PlotArea`` and ``Subplot`` we need a way for the u
A ``Figure`` contains a grid of subplot and has methods such as ``show()`` to output the figure.
``Figure.__init__`` basically does a lot of parsing of user arguments to determine how to create
-the subplots. All subplots within a ``Figure`` share the same canvas and use different viewports to create the subplots.
\ No newline at end of file
+the subplots. All subplots within a ``Figure`` share the same canvas and use different viewports to create the subplots.
diff --git a/docs/source/generate_api.py b/docs/source/generate_api.py
index 6887566cb..0be967a36 100644
--- a/docs/source/generate_api.py
+++ b/docs/source/generate_api.py
@@ -1,12 +1,15 @@
-from typing import *
+from collections import defaultdict
import inspect
-from pathlib import Path
+from io import StringIO
import os
+from pathlib import Path
+from typing import *
import fastplotlib
-from fastplotlib.layouts._subplot import Subplot
+from fastplotlib.layouts import Subplot
from fastplotlib import graphics
-from fastplotlib.graphics import _features, selectors
+from fastplotlib.graphics import features, selectors
+from fastplotlib import tools
from fastplotlib import widgets
from fastplotlib import utils
from fastplotlib import ui
@@ -19,8 +22,10 @@
GRAPHICS_DIR = API_DIR.joinpath("graphics")
GRAPHIC_FEATURES_DIR = API_DIR.joinpath("graphic_features")
SELECTORS_DIR = API_DIR.joinpath("selectors")
+TOOLS_DIR = API_DIR.joinpath("tools")
WIDGETS_DIR = API_DIR.joinpath("widgets")
UI_DIR = API_DIR.joinpath("ui")
+GUIDE_DIR = current_dir.joinpath("user_guide")
doc_sources = [
API_DIR,
@@ -28,6 +33,7 @@
GRAPHICS_DIR,
GRAPHIC_FEATURES_DIR,
SELECTORS_DIR,
+ TOOLS_DIR,
WIDGETS_DIR,
UI_DIR,
]
@@ -56,16 +62,6 @@
"See the rendercanvas docs: https://rendercanvas.readthedocs.io/stable/api.html#rendercanvas.BaseLoop "
)
-with open(API_DIR.joinpath("utils.rst"), "w") as f:
- f.write(
- "fastplotlib.utils\n"
- "*****************\n\n"
-
- "..currentmodule:: fastplotlib.utils\n"
- "..automodule:: fastplotlib.utils.functions\n"
- " : members:\n"
- )
-
def get_public_members(cls) -> Tuple[List[str], List[str]]:
"""
@@ -139,12 +135,18 @@ def generate_class(
return out
-def generate_functions_module(module, name: str):
+def generate_functions_module(module, name: str, generate_header: bool = True):
underline = "*" * len(name)
+ if generate_header:
+ header = (
+ f"{name}\n"
+ f"{underline}\n"
+ f"\n"
+ )
+ else:
+ header = "\n"
out = (
- f"{name}\n"
- f"{underline}\n"
- f"\n"
+ f"{header}"
f".. currentmodule:: {name}\n"
f".. automodule:: {module.__name__}\n"
f" :members:\n"
@@ -173,6 +175,60 @@ def generate_page(
to_write = generate_class(cls, module)
f.write(to_write)
+#######################################################
+# Used for GraphicFeature class event table
+# copy-pasted from https://pablofernandez.tech/2019/03/21/turning-a-list-of-dicts-into-a-restructured-text-table/
+
+def _generate_header(field_names, column_widths):
+ with StringIO() as output:
+ for field_name in field_names:
+ output.write(f"+-{'-' * column_widths[field_name]}-")
+ output.write("+\n")
+ for field_name in field_names:
+ output.write(f"| {field_name} {' ' * (column_widths[field_name] - len(field_name))}")
+ output.write("|\n")
+ for field_name in field_names:
+ output.write(f"+={'=' * column_widths[field_name]}=")
+ output.write("+\n")
+ return output.getvalue()
+
+
+def _generate_row(row, field_names, column_widths):
+ with StringIO() as output:
+ for field_name in field_names:
+ output.write(f"| {row[field_name]}{' ' * (column_widths[field_name] - len(str(row[field_name])))} ")
+ output.write("|\n")
+ for field_name in field_names:
+ output.write(f"+-{'-' * column_widths[field_name]}-")
+ output.write("+\n")
+ return output.getvalue()
+
+
+def _get_fields(data):
+ field_names = []
+ column_widths = defaultdict(lambda: 0)
+ for row in data:
+ for field_name in row:
+ if field_name not in field_names:
+ field_names.append(field_name)
+ column_widths[field_name] = max(column_widths[field_name], len(field_name), len(str(row[field_name])))
+ return field_names, column_widths
+
+
+def dict_to_rst_table(data):
+ """convert a list of dicts to an RST table"""
+ field_names, column_widths = _get_fields(data)
+ with StringIO() as output:
+ output.write(_generate_header(field_names, column_widths))
+ for row in data:
+ output.write(_generate_row(row, field_names, column_widths))
+
+ output.write("\n")
+
+ return output.getvalue()
+
+#######################################################
+
def main():
generate_page(
@@ -211,7 +267,8 @@ def main():
)
# the rest of this is a mess and can be refactored later
-
+ ##############################################################################
+ # ** Graphic classes ** #
graphic_classes = [getattr(graphics, g) for g in graphics.__all__]
graphic_class_names = [g.__name__ for g in graphic_classes]
@@ -237,8 +294,8 @@ def main():
source_path=GRAPHICS_DIR.joinpath(f"{graphic_cls.__name__}.rst"),
)
##############################################################################
-
- feature_classes = [getattr(_features, f) for f in _features.__all__]
+ # ** GraphicFeature classes ** #
+ feature_classes = [getattr(features, f) for f in features.__all__]
feature_class_names = [f.__name__ for f in feature_classes]
@@ -258,11 +315,11 @@ def main():
generate_page(
page_name=feature_cls.__name__,
classes=[feature_cls],
- modules=["fastplotlib.graphics._features"],
+ modules=["fastplotlib.graphics.features"],
source_path=GRAPHIC_FEATURES_DIR.joinpath(f"{feature_cls.__name__}.rst"),
)
##############################################################################
-
+ # ** Selector classes ** #
selector_classes = [getattr(selectors, s) for s in selectors.__all__]
selector_class_names = [s.__name__ for s in selector_classes]
@@ -286,8 +343,35 @@ def main():
modules=["fastplotlib"],
source_path=SELECTORS_DIR.joinpath(f"{selector_cls.__name__}.rst"),
)
+
##############################################################################
+ # ** Tools classes ** #
+ tools_classes = [getattr(tools, t) for t in tools.__all__]
+
+ tools_class_names = [t.__name__ for t in tools_classes]
+
+ tools_class_names_str = "\n ".join([""] + tools_class_names)
+
+ with open(TOOLS_DIR.joinpath("index.rst"), "w") as f:
+ f.write(
+ f"Tools\n"
+ f"*****\n"
+ f"\n"
+ f".. toctree::\n"
+ f" :maxdepth: 1\n"
+ f"{tools_class_names_str}\n"
+ )
+ for tool_cls in tools_classes:
+ generate_page(
+ page_name=tool_cls.__name__,
+ classes=[tool_cls],
+ modules=["fastplotlib"],
+ source_path=TOOLS_DIR.joinpath(f"{tool_cls.__name__}.rst"),
+ )
+
+ ##############################################################################
+ # ** Widget classes ** #
widget_classes = [getattr(widgets, w) for w in widgets.__all__]
widget_class_names = [w.__name__ for w in widget_classes]
@@ -312,7 +396,7 @@ def main():
source_path=WIDGETS_DIR.joinpath(f"{widget_cls.__name__}.rst"),
)
##############################################################################
-
+ # ** UI classes ** #
ui_classes = [ui.BaseGUI, ui.Window, ui.EdgeWindow, ui.Popup]
ui_class_names = [cls.__name__ for cls in ui_classes]
@@ -340,11 +424,12 @@ def main():
##############################################################################
utils_str = generate_functions_module(utils.functions, "fastplotlib.utils")
+ utils_str += generate_functions_module(utils._plot_helpers, "fastplotlib.utils", generate_header=False)
with open(API_DIR.joinpath("utils.rst"), "w") as f:
f.write(utils_str)
- # nake API index file
+ # make API index file
with open(API_DIR.joinpath("index.rst"), "w") as f:
f.write(
"API Reference\n"
@@ -356,11 +441,49 @@ def main():
" graphics/index\n"
" graphic_features/index\n"
" selectors/index\n"
+ " tools/index\n"
" ui/index\n"
" widgets/index\n"
" fastplotlib\n"
" utils\n"
)
+ ##############################################################################
+ # graphic feature event tables
+
+ def write_table(name, feature_cls):
+ s = f"{name}\n"
+ s += "^" * len(name) + "\n\n"
+
+ if hasattr(feature_cls, "event_extra_attrs"):
+ s += "**extra attributes**\n\n"
+ s += dict_to_rst_table(feature_cls.event_extra_attrs)
+
+ s += "**event info dict**\n\n"
+ s += dict_to_rst_table(feature_cls.event_info_spec)
+
+ return s
+
+ with open(GUIDE_DIR.joinpath("event_tables.rst"), "w") as f:
+ f.write(".. _event_tables:\n\n")
+ f.write("Event Tables\n")
+ f.write("============\n\n")
+
+ for graphic_cls in [*graphic_classes, *selector_classes]:
+ if graphic_cls is graphics.Graphic:
+ # skip Graphic base class
+ continue
+ f.write(f"{graphic_cls.__name__}\n")
+ f.write("-" * len(graphic_cls.__name__) + "\n\n")
+ for name, type_ in graphic_cls._features.items():
+ if isinstance(type_, tuple):
+ for t in type_:
+ if t is None:
+ continue
+ f.write(write_table(name, t))
+ else:
+ f.write(write_table(name, type_))
+
+
if __name__ == "__main__":
main()
diff --git a/docs/source/user_guide/event_tables.rst b/docs/source/user_guide/event_tables.rst
new file mode 100644
index 000000000..42f168bea
--- /dev/null
+++ b/docs/source/user_guide/event_tables.rst
@@ -0,0 +1,2196 @@
+.. _event_tables:
+
+Event Tables
+============
+
+LineGraphic
+-----------
+
+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 |
++----------+----------------------------------------------+--------------------------------------------------------+
+
+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 |
++----------+-------+--------------------------------+
+
+thickness
+^^^^^^^^^
+
+**event info dict**
+
++----------+-------+---------------------+
+| dict key | type | description |
++==========+=======+=====================+
+| value | float | new thickness value |
++----------+-------+---------------------+
+
+size_space
+^^^^^^^^^^
+
+**event info dict**
+
++----------+------+------------------------------+
+| dict key | type | description |
++==========+======+==============================+
+| value | str | 'screen' | 'world' | 'model' |
++----------+------+------------------------------+
+
+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 |
++----------+------+-------------------------------+
+
+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
+^^^^^^^
+
+**event info dict**
+
++----------+------+---------------------+
+| dict key | type | description |
++==========+======+=====================+
+| value | bool | new visibility bool |
++----------+------+---------------------+
+
+deleted
+^^^^^^^
+
+**event info dict**
+
++----------+------+-------------------------------+
+| dict key | type | description |
++==========+======+===============================+
+| value | bool | True when graphic was deleted |
++----------+------+-------------------------------+
+
+SurfaceGraphic
+--------------
+
+data
+^^^^
+
+**event info dict**
+
++----------+------------+------------------+
+| dict key | type | description |
++==========+============+==================+
+| value | np.ndarray | new surface data |
++----------+------------+------------------+
+
+colors
+^^^^^^
+
+**event info dict**
+
++------------+--------------------------------------+------------------------------------------------------+
+| dict key | type | description |
++============+======================================+======================================================+
+| key | slice, index, numpy-like fancy index | index/slice at which colors were indexed/sliced |
++------------+--------------------------------------+------------------------------------------------------+
+| value | np.ndarray [n_points_changed, RGBA] | new color values for points that were changed |
++------------+--------------------------------------+------------------------------------------------------+
+| user_value | str or array-like | user input value that was parsed into the RGBA array |
++------------+--------------------------------------+------------------------------------------------------+
+
+colors
+^^^^^^
+
+**event info dict**
+
++----------+--------------------------------------------------+-----------------+
+| dict key | type | description |
++==========+==================================================+=================+
+| value | str | pygfx.Color | np.ndarray | Sequence[float] | new color value |
++----------+--------------------------------------------------+-----------------+
+
+cmap
+^^^^
+
+**event info dict**
+
++----------+------------------------------------------------------------+-------------+
+| dict key | type | description |
++==========+============================================================+=============+
+| value | str | dict | pygfx.TextureMap | pygfx.Texture | np.ndarray | new cmap |
++----------+------------------------------------------------------------+-------------+
+
+name
+^^^^
+
+**event info dict**
+
++----------+------+--------------------+
+| dict key | type | description |
++==========+======+====================+
+| value | str | user provided name |
++----------+------+--------------------+
+
+offset
+^^^^^^
+
+**event info dict**
+
++----------+---------------------------------+----------------------+
+| dict key | type | description |
++==========+=================================+======================+
+| value | np.ndarray[float, float, float] | new offset (x, y, z) |
++----------+---------------------------------+----------------------+
+
+rotation
+^^^^^^^^
+
+**event info dict**
+
++----------+----------------------------------------+-------------------------+
+| dict key | type | description |
++==========+========================================+=========================+
+| value | np.ndarray[float, float, float, float] | new rotation quaternion |
++----------+----------------------------------------+-------------------------+
+
+scale
+^^^^^
+
+**event info dict**
+
++----------+----------------------------------------+-------------+
+| dict key | type | description |
++==========+========================================+=============+
+| value | np.ndarray[float, float, float, float] | new scale |
++----------+----------------------------------------+-------------+
+
+alpha
+^^^^^
+
+**event info dict**
+
++----------+-------+-----------------+
+| dict key | type | description |
++==========+=======+=================+
+| value | float | new alpha value |
++----------+-------+-----------------+
+
+alpha_mode
+^^^^^^^^^^
+
+**event info dict**
+
++----------+------+----------------+
+| dict key | type | description |
++==========+======+================+
+| value | str | new alpha mode |
++----------+------+----------------+
+
+visible
+^^^^^^^
+
+**event info dict**
+
++----------+------+---------------------+
+| dict key | type | description |
++==========+======+=====================+
+| value | bool | new visibility bool |
++----------+------+---------------------+
+
+deleted
+^^^^^^^
+
+**event info dict**
+
++----------+------+-------------------------------+
+| dict key | type | description |
++==========+======+===============================+
+| value | bool | True when graphic was deleted |
++----------+------+-------------------------------+
+
+PolygonGraphic
+--------------
+
+data
+^^^^
+
+**event info dict**
+
++----------+------------+------------------+
+| dict key | type | description |
++==========+============+==================+
+| value | np.ndarray | new surface data |
++----------+------------+------------------+
+
+colors
+^^^^^^
+
+**event info dict**
+
++------------+--------------------------------------+------------------------------------------------------+
+| dict key | type | description |
++============+======================================+======================================================+
+| key | slice, index, numpy-like fancy index | index/slice at which colors were indexed/sliced |
++------------+--------------------------------------+------------------------------------------------------+
+| value | np.ndarray [n_points_changed, RGBA] | new color values for points that were changed |
++------------+--------------------------------------+------------------------------------------------------+
+| user_value | str or array-like | user input value that was parsed into the RGBA array |
++------------+--------------------------------------+------------------------------------------------------+
+
+colors
+^^^^^^
+
+**event info dict**
+
++----------+--------------------------------------------------+-----------------+
+| dict key | type | description |
++==========+==================================================+=================+
+| value | str | pygfx.Color | np.ndarray | Sequence[float] | new color value |
++----------+--------------------------------------------------+-----------------+
+
+cmap
+^^^^
+
+**event info dict**
+
++----------+------------------------------------------------------------+-------------+
+| dict key | type | description |
++==========+============================================================+=============+
+| value | str | dict | pygfx.TextureMap | pygfx.Texture | np.ndarray | new cmap |
++----------+------------------------------------------------------------+-------------+
+
+name
+^^^^
+
+**event info dict**
+
++----------+------+--------------------+
+| dict key | type | description |
++==========+======+====================+
+| value | str | user provided name |
++----------+------+--------------------+
+
+offset
+^^^^^^
+
+**event info dict**
+
++----------+---------------------------------+----------------------+
+| dict key | type | description |
++==========+=================================+======================+
+| value | np.ndarray[float, float, float] | new offset (x, y, z) |
++----------+---------------------------------+----------------------+
+
+rotation
+^^^^^^^^
+
+**event info dict**
+
++----------+----------------------------------------+-------------------------+
+| dict key | type | description |
++==========+========================================+=========================+
+| value | np.ndarray[float, float, float, float] | new rotation quaternion |
++----------+----------------------------------------+-------------------------+
+
+scale
+^^^^^
+
+**event info dict**
+
++----------+----------------------------------------+-------------+
+| dict key | type | description |
++==========+========================================+=============+
+| value | np.ndarray[float, float, float, float] | new scale |
++----------+----------------------------------------+-------------+
+
+alpha
+^^^^^
+
+**event info dict**
+
++----------+-------+-----------------+
+| dict key | type | description |
++==========+=======+=================+
+| value | float | new alpha value |
++----------+-------+-----------------+
+
+alpha_mode
+^^^^^^^^^^
+
+**event info dict**
+
++----------+------+----------------+
+| dict key | type | description |
++==========+======+================+
+| value | str | new alpha mode |
++----------+------+----------------+
+
+visible
+^^^^^^^
+
+**event info dict**
+
++----------+------+---------------------+
+| dict key | type | description |
++==========+======+=====================+
+| value | bool | new visibility bool |
++----------+------+---------------------+
+
+deleted
+^^^^^^^
+
+**event info dict**
+
++----------+------+-------------------------------+
+| dict key | type | description |
++==========+======+===============================+
+| value | bool | True when graphic was deleted |
++----------+------+-------------------------------+
+
+TextGraphic
+-----------
+
+text
+^^^^
+
+**event info dict**
+
++----------+------+---------------+
+| dict key | type | description |
++==========+======+===============+
+| value | str | new text data |
++----------+------+---------------+
+
+font_size
+^^^^^^^^^
+
+**event info dict**
+
++----------+-------------+---------------+
+| dict key | type | description |
++==========+=============+===============+
+| value | float | int | new font size |
++----------+-------------+---------------+
+
+face_color
+^^^^^^^^^^
+
+**event info dict**
+
++----------+------------------+----------------+
+| dict key | type | description |
++==========+==================+================+
+| value | str | np.ndarray | new text color |
++----------+------------------+----------------+
+
+outline_color
+^^^^^^^^^^^^^
+
+**event info dict**
+
++----------+------------------+-------------------+
+| dict key | type | description |
++==========+==================+===================+
+| value | str | np.ndarray | new outline color |
++----------+------------------+-------------------+
+
+outline_thickness
+^^^^^^^^^^^^^^^^^
+
+**event info dict**
+
++----------+-------+----------------------------+
+| dict key | type | description |
++==========+=======+============================+
+| value | float | new text outline thickness |
++----------+-------+----------------------------+
+
+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 |
++----------+------+-------------------------------+
+
+LineCollection
+--------------
+
+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 |
++----------+----------------------------------------------+--------------------------------------------------------+
+
+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 |
++----------+-------+--------------------------------+
+
+thickness
+^^^^^^^^^
+
+**event info dict**
+
++----------+-------+---------------------+
+| dict key | type | description |
++==========+=======+=====================+
+| value | float | new thickness value |
++----------+-------+---------------------+
+
+size_space
+^^^^^^^^^^
+
+**event info dict**
+
++----------+------+------------------------------+
+| dict key | type | description |
++==========+======+==============================+
+| value | str | 'screen' | 'world' | 'model' |
++----------+------+------------------------------+
+
+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 |
++----------+------+-------------------------------+
+
+LineStack
+---------
+
+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 |
++----------+----------------------------------------------+--------------------------------------------------------+
+
+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 |
++----------+-------+--------------------------------+
+
+thickness
+^^^^^^^^^
+
+**event info dict**
+
++----------+-------+---------------------+
+| dict key | type | description |
++==========+=======+=====================+
+| value | float | new thickness value |
++----------+-------+---------------------+
+
+size_space
+^^^^^^^^^^
+
+**event info dict**
+
++----------+------+------------------------------+
+| dict key | type | description |
++==========+======+==============================+
+| value | str | 'screen' | 'world' | 'model' |
++----------+------+------------------------------+
+
+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 |
++----------+------+-------------------------------+
+
+LinearSelector
+--------------
+
+selection
+^^^^^^^^^
+
+**extra attributes**
+
++--------------------+----------+----------------------------------+
+| attribute | type | description |
++====================+==========+==================================+
+| get_selected_index | callable | returns index under the selector |
++--------------------+----------+----------------------------------+
+
+**event info dict**
+
++----------+-------+-------------------------------+
+| dict key | type | description |
++==========+=======+===============================+
+| value | float | new x or y value of selection |
++----------+-------+-------------------------------+
+
+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 |
++----------+------+-------------------------------+
+
+LinearRegionSelector
+--------------------
+
+selection
+^^^^^^^^^
+
+**extra attributes**
+
++----------------------+----------+------------------------------------+
+| attribute | type | description |
++======================+==========+====================================+
+| get_selected_indices | callable | returns indices under the selector |
++----------------------+----------+------------------------------------+
+| get_selected_data | callable | returns data under the selector |
++----------------------+----------+------------------------------------+
+
+**event info dict**
+
++----------+------------+-----------------------------+
+| dict key | type | description |
++==========+============+=============================+
+| value | np.ndarray | new [min, max] of selection |
++----------+------------+-----------------------------+
+
+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 |
++----------+------+-------------------------------+
+
+RectangleSelector
+-----------------
+
+selection
+^^^^^^^^^
+
+**extra attributes**
+
++----------------------+----------+------------------------------------+
+| attribute | type | description |
++======================+==========+====================================+
+| get_selected_indices | callable | returns indices under the selector |
++----------------------+----------+------------------------------------+
+| get_selected_data | callable | returns data under the selector |
++----------------------+----------+------------------------------------+
+
+**event info dict**
+
++----------+------------+-------------------------------------------+
+| dict key | type | description |
++==========+============+===========================================+
+| value | np.ndarray | new [xmin, xmax, ymin, ymax] of selection |
++----------+------------+-------------------------------------------+
+
+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 |
++----------+------+-------------------------------+
+
diff --git a/docs/source/user_guide/faq.rst b/docs/source/user_guide/faq.rst
index 029daabab..5985efae1 100644
--- a/docs/source/user_guide/faq.rst
+++ b/docs/source/user_guide/faq.rst
@@ -44,8 +44,8 @@ How does ``fastplotlib`` relate to ``matplotlib``?
How can I learn to use ``fastplotlib``?
---------------------------------------
- We want `fastplotlib` to be easy to learn and use. To get started with the library we recommend taking a look at our `guide `_ and
- `examples gallery `_.
+ We want `fastplotlib` to be easy to learn and use. To get started with the library we recommend taking a look at our `guide `_ and
+ `examples gallery `_.
In general, if you are familiar with numpy and array notation you will already have a intuitive understanding of interacting
with your data in `fastplotlib`. If you have any questions, please do not hesitate to post an issue or discussion forum post.
@@ -56,6 +56,8 @@ Should I use ``fastplotlib`` for making publication figures?
While `fastplotlib` figures can be exported to PNG using ``figure.export()``, `fastplotlib` is not intended for creating *static*
publication figures. There are many other libraries that are well-suited for this task.
+ The rendering engine pygfx has a starting point for an svg renderer, you may try and expand upon it: https://github.com/pygfx/pygfx/tree/main/pygfx/renderers/svg
+
How does ``fastplotlib`` handle data loading?
---------------------------------------------
diff --git a/docs/source/user_guide/guide.rst b/docs/source/user_guide/guide.rst
index d544c42a3..bd0352aa7 100644
--- a/docs/source/user_guide/guide.rst
+++ b/docs/source/user_guide/guide.rst
@@ -41,8 +41,8 @@ The fundamental goal of ``fastplotlib`` is to provide a high-level, expressive A
make it easy and intuitive to produce interactive visualizations that are as performant and vibrant as a modern video game 😄
-How to use ``fastplotlib``
---------------------------
+``fastplotlib`` basics
+----------------------
Before giving a detailed overview of the library, here is a minimal example::
@@ -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,16 +71,23 @@ 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.
+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
------
-The starting point for creating any visualization in ``fastplotlib`` is a ``Figure`` object. This can be a single plot or a grid of subplots.
+The starting point for creating any visualization in ``fastplotlib`` is a ``Figure`` object. This can be a single subplot or many subplots.
The ``Figure`` object houses and takes care of the underlying rendering components such as the camera, controller, renderer, and canvas.
Most users won't need to use these directly; however, the ability to directly interact with the rendering engine is still available if
needed.
-By default, if no ``shape`` argument is provided when creating a ``Figure``, there will be a single subplot. All subplots in a ``Figure`` can be accessed using
-indexing (i.e. ``fig_object[i ,j]``).
+By default, if no ``shape`` argument is provided when creating a ``Figure``, there will be a single ``Subplot``.
+
+If a shape argument is provided, all subplots in a ``Figure`` can be accessed by indexing (i.e. ``fig_object[i ,j]``). A "window layout"
+with customizable subplot positions and sizes can also be set by providing a ``rects`` or ``extents`` argument. The Examples Gallery
+has a few examples that show how to create a "Window Layout".
After defining a ``Figure``, we can begin to add ``Graphic`` objects.
@@ -99,18 +106,22 @@ to be easily accessed from figures::
add image graphic
image_graphic = fig[0, 0].add_image(data=data, name="astronaut")
- # show plot
+ # show figure
fig.show()
- # index plot to get graphic
+ # index subplot to get graphic
fig[0, 0]["astronaut"]
+ # another way to index graphics in a subplot
+ fig[0, 0].graphics[0] is fig[0, 0]["astronaut"] # will return `True`
+
..
See the examples gallery for examples on how to create and interactive with all the various types of graphics.
-Graphics also have mutable properties that can be linked to events. Some of these properties, such as the ``data`` or ``colors`` of a line can even be indexed,
-allowing for the creation of very powerful visualizations.
+Graphics also have mutable properties. Some of these properties, such as the ``data`` or ``colors`` of a line can even be sliced,
+allowing for the creation of very powerful visualizations. Event handlers can be added to a graphic to capture changes to
+any of these properties.
(1) Common properties that all graphics have
@@ -132,17 +143,17 @@ allowing for the creation of very powerful visualizations.
(a) ``ImageGraphic``
- +------------------------+------------------------------------+
- | Feature Name | Description |
- +========================+====================================+
- | data | Underlying image data |
- +------------------------+------------------------------------+
- | vmin | Lower contrast limit of an image |
- +------------------------+------------------------------------+
- | vmax | Upper contrast limit of an image |
- +------------------------+------------------------------------+
- | cmap | Colormap of an image |
- +------------------------+------------------------------------+
+ +------------------------+---------------------------------------------------+
+ | Feature Name | Description |
+ +========================+===================================================+
+ | data | Underlying image data |
+ +------------------------+---------------------------------------------------+
+ | vmin | Lower contrast limit of an image |
+ +------------------------+---------------------------------------------------+
+ | vmax | Upper contrast limit of an image |
+ +------------------------+---------------------------------------------------+
+ | cmap | Colormap for a grayscale image, ignored if RGB(A) |
+ +------------------------+---------------------------------------------------+
(b) ``LineGraphic``, ``LineCollection``, ``LineStack``
@@ -244,14 +255,13 @@ your data, you are able to select an entire region.
See the examples gallery for more in-depth examples with selector tools.
Now we have the basics of creating a ``Figure``, adding ``Graphics`` to a ``Figure``, and working with ``Graphic`` properties to dynamically change or alter them.
-Let's take a look at how we can define events to link ``Graphics`` and their properties together.
Events
------
-Events can be a multitude of things: traditional events such as mouse or keyboard events, or events related to ``Graphic`` properties.
+Events can be a multitude of things: canvas events such as mouse or keyboard events, or events related to ``Graphic`` properties.
-There are two ways to add events in ``fastplotlib``.
+There are two ways to add events to a graphic:
1) Use the method `add_event_handler()` ::
@@ -272,24 +282,24 @@ There are two ways to add events in ``fastplotlib``.
..
-The ``event_handler`` is a user-defined function that accepts an event instance as the first and only positional argument.
+The ``event_handler`` is a user-defined callback function that accepts an event instance as the first and only positional argument.
Information about the structure of event instances are described below. The ``"event_type"``
-is a string that identifies the type of event; this can be either a ``pygfx.Event`` or a ``Graphic`` property event.
+is a string that identifies the type of event.
``graphic.supported_events`` will return a tuple of all ``event_type`` strings that this graphic supports.
When an event occurs, the user-defined event handler will receive an event object. Depending on the type of event, the
-event object will have relevant information that can be used in the callback. See below for event tables.
+event object will have relevant information that can be used in the callback. See the next section for details.
Graphic property events
^^^^^^^^^^^^^^^^^^^^^^^
-All ``Graphic`` events have the following attributes:
+All ``Graphic`` events are instances of ``fastplotlib.GraphicFeatureEvent`` and have the following attributes:
+------------+-------------+-----------------------------------------------+
| attribute | type | description |
+============+=============+===============================================+
- | type | str | "colors" - name of the event |
+ | type | str | name of the event type |
+------------+-------------+-----------------------------------------------+
| graphic | Graphic | graphic instance that the event is from |
+------------+-------------+-----------------------------------------------+
@@ -300,144 +310,80 @@ All ``Graphic`` events have the following attributes:
| time_stamp | float | time when the event occurred, in ms |
+------------+-------------+-----------------------------------------------+
-The ``info`` attribute will house additional information for different ``Graphic`` property events:
-
-event_type: "colors"
-
- Vertex Colors
-
- **info dict**
-
- +------------+-----------------------------------------------------------+----------------------------------------------------------------------------------+
- | dict key | value type | value description |
- +============+===========================================================+==================================================================================+
- | key | int | slice | np.ndarray[int | bool] | tuple[slice, ...] | key at which colors were indexed/sliced |
- +------------+-----------------------------------------------------------+----------------------------------------------------------------------------------+
- | value | np.ndarray | new color values for points that were changed, shape is [n_points_changed, RGBA] |
- +------------+-----------------------------------------------------------+----------------------------------------------------------------------------------+
- | user_value | str | np.ndarray | tuple[float] | list[float] | list[str] | user input value that was parsed into the RGBA array |
- +------------+-----------------------------------------------------------+----------------------------------------------------------------------------------+
-
- Uniform Colors
-
- **info dict**
-
- +------------+-----------------------------------------------------------+----------------------------------------------------------------------------------+
- | dict key | value type | value description |
- +============+===========================================================+==================================================================================+
- | value | np.ndarray | new color values for points that were changed, shape is [n_points_changed, RGBA] |
- +------------+-----------------------------------------------------------+----------------------------------------------------------------------------------+
+Selectors have one event called ``selection`` which has extra attributes in addition to those listed in the table above.
+The selection event section covers these.
-event_type: "sizes"
+The ``info`` attribute for most graphic property events will have one key, ``"value"``, which is the new value
+of the graphic property. Events for graphic properties that represent arrays, such the ``data`` properties for
+images, lines, and scatters will contain more entries. Here are a list of all graphic properties that have such
+additional entries:
- **info dict**
+* ``ImageGraphic``
+ * data
- +----------+----------------------------------------------------------+------------------------------------------------------------------------------------------+
- | dict key | value type | value description |
- +==========+==========================================================+==========================================================================================+
- | key | int | slice | np.ndarray[int | bool] | tuple[slice, ...] | key at which vertex positions data were indexed/sliced |
- +----------+----------------------------------------------------------+------------------------------------------------------------------------------------------+
- | value | np.ndarray | float | list[float] | new data values for points that were changed, shape depends on the indices that were set |
- +----------+----------------------------------------------------------+------------------------------------------------------------------------------------------+
+* ``LineGraphic``
+ * data, colors, cmap
-event_type: "data"
+* ``ScatterGraphic``
+ * data, colors, cmap, sizes
- **info dict**
+You can understand an event's attributes by adding a simple event handler::
- +----------+----------------------------------------------------------+------------------------------------------------------------------------------------------+
- | dict key | value type | value description |
- +==========+==========================================================+==========================================================================================+
- | key | int | slice | np.ndarray[int | bool] | tuple[slice, ...] | key at which vertex positions data were indexed/sliced |
- +----------+----------------------------------------------------------+------------------------------------------------------------------------------------------+
- | value | np.ndarray | float | list[float] | new data values for points that were changed, shape depends on the indices that were set |
- +----------+----------------------------------------------------------+------------------------------------------------------------------------------------------+
-
-event_type: "thickness"
-
- **info dict**
-
- +------------+-----------------------------------------------------------+----------------------------------------------------------------------------------+
- | dict key | value type | value description |
- +============+===========================================================+==================================================================================+
- | value | float | new thickness value |
- +------------+-----------------------------------------------------------+----------------------------------------------------------------------------------+
-
-event_type: "cmap"
-
- **info dict**
-
- +------------+-----------------------------------------------------------+----------------------------------------------------------------------------------+
- | dict key | value type | value description |
- +============+===========================================================+==================================================================================+
- | value | string | new colormap value |
- +------------+-----------------------------------------------------------+----------------------------------------------------------------------------------+
+ @graphic.add_event_handler("event_type")
+ def handler(ev):
+ print(ev.type)
+ print(ev.graphic)
+ print(ev.info)
-event_type: "selection"
+ # trigger the event
+ graphic.event_type =
- ``LinearSelector``
+ # direct example
+ @image_graphic.add_event_handler("cmap")
+ def cmap_changed(ev):
+ print(ev.type)
+ print(ev.info)
- **additional event attributes:**
+ image_graphic.cmap = "viridis"
+ # this will trigger the cmap event and print the following:
+ # 'cmap'
+ # {"value": "viridis"}
- +--------------------+----------+------------------------------------+
- | attribute | type | description |
- +====================+==========+====================================+
- | get_selected_index | callable | returns indices under the selector |
- +--------------------+----------+------------------------------------+
+..
- **info dict:**
+The :ref:`event_tables` provide a description of the event info dicts for all Graphic Feature Events.
- +----------+------------+-------------------------------+
- | dict key | value type | value description |
- +==========+============+===============================+
- | value | np.ndarray | new x or y value of selection |
- +----------+------------+-------------------------------+
+Selection event
+~~~~~~~~~~~~~~~
- ``LinearRegionSelector``
+The ``selection`` event for selectors has additional attributes, mostly ``callable`` methods, that aid in using the
+selector tool, such as getting the indices or data under the selection. The ``info`` dict will contain one entry ``value``
+which is the new selection value.
- **additional event attributes:**
+The :ref:`event_tables` provide a description of the additional attributes as well as the event info dicts for selector events.
- +----------------------+----------+------------------------------------+
- | attribute | type | description |
- +======================+==========+====================================+
- | get_selected_indices | callable | returns indices under the selector |
- +----------------------+----------+------------------------------------+
- | get_selected_data | callable | returns data under the selector |
- +----------------------+----------+------------------------------------+
+Canvas Events
+^^^^^^^^^^^^^
- **info dict:**
+Canvas events can be added to a graphic or to a Figure (see next section).
+Here is a description of all canvas events and their attributes.
- +----------+------------+-----------------------------+
- | dict key | value type | value description |
- +==========+============+=============================+
- | value | np.ndarray | new [min, max] of selection |
- +----------+------------+-----------------------------+
+The examples gallery provides several examples using pointer and key events.
-Rendering engine events from a Graphic
-^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+Pointer events
+~~~~~~~~~~~~~~
-Rendering engine event handlers can be added to a graphic or to a Figure (see next section).
-Here is a description of all rendering engine events and their attributes.
+**List of pointer events:**
* **pointer_down**: emitted when the user interacts with mouse,
- touch or other pointer devices, by pressing it down.
-
- * *x*: horizontal position of the pointer within the widget.
- * *y*: vertical position of the pointer within the widget.
- * *button*: the button to which this event applies. See "Mouse buttons" section below for details.
- * *buttons*: a tuple of buttons being pressed down.
- * *modifiers*: a tuple of modifier keys being pressed down. See section below for details.
- * *ntouches*: the number of simultaneous pointers being down.
- * *touches*: a dict with int keys (pointer id's), and values that are dicts
- that contain "x", "y", and "pressure".
- * *time_stamp*: a timestamp in seconds.
* **pointer_up**: emitted when the user releases a pointer.
- This event has the same keys as the pointer down event.
* **pointer_move**: emitted when the user moves a pointer.
- This event has the same keys as the pointer down event.
This event is throttled.
+* **click**: emmitted when a mouse button is clicked.
+
* **double_click**: emitted on a double-click.
This event looks like a pointer event, but without the touches.
@@ -465,25 +411,19 @@ Here is a description of all rendering engine events and their attributes.
* *modifiers*: a tuple of modifier keys being pressed down.
* *time_stamp*: a timestamp in seconds.
-* **key_down**: emitted when a key is pressed down.
-
- * *key*: the key being pressed as a string. See section below for details.
- * *modifiers*: a tuple of modifier keys being pressed down.
- * *time_stamp*: a timestamp in seconds.
-
-* **key_up**: emitted when a key is released.
- This event has the same keys as the key down event.
-
-
-Time stamps
-~~~~~~~~~~~
-
-Since the time origin of ``time_stamp`` values is undefined,
-time stamp values only make sense in relation to other time stamps.
+All pointer events have the following attributes:
+* *x*: horizontal position of the pointer within the widget.
+* *y*: vertical position of the pointer within the widget.
+* *button*: the button to which this event applies. See "Mouse buttons" section below for details.
+* *buttons*: a tuple of buttons being pressed down (see below)
+* *modifiers*: a tuple of modifier keys being pressed down. See section below for details.
+* *ntouches*: the number of simultaneous pointers being down.
+* *touches*: a dict with int keys (pointer id's), and values that are dicts
+ that contain "x", "y", and "pressure".
+* *time_stamp*: a timestamp in seconds.
-Mouse buttons
-~~~~~~~~~~~~~
+**Mouse buttons:**
* 0: No button.
* 1: Left button.
@@ -491,9 +431,20 @@ Mouse buttons
* 3: Middle button
* 4-9: etc.
+Key events
+~~~~~~~~~~
+
+**List of key (keyboard keys) events:**
+
+* **key_down**: emitted when a key is pressed down.
+
+* **key_up**: emitted when a key is released.
+
+Key events have the following attributes:
-Keys
-~~~~
+* *key*: the key being pressed as a string. See section below for details.
+* *modifiers*: a tuple of modifier keys being pressed down.
+* *time_stamp*: a timestamp in seconds.
The key names follow the `browser spec `_.
@@ -504,13 +455,18 @@ The key names follow the `browser spec `_ library is great for rapidly building UIs for prototyping
+in jupyter. It is particularly useful for scientific and engineering applications since we can rapidly create a UI to
+interact with our ``fastplotlib`` visualization. The main downside is that it only works in jupyter.
+
+.. image:: ../_static/guide_ipywidgets.webp
+
+For examples please see the examples gallery.
+
+Qt
+^^
+
+Qt is a very popular UI library written in C++, ``PyQt6`` and ``PySide6`` provide python bindings. There are countless
+tutorials on how to build a UI using Qt which you can easily find if you google ``PyQt``. You can embed a ``Figure`` as
+a Qt widget within a Qt application.
+
+For examples please see the examples gallery.
+
+imgui
+^^^^^
+
+`Imgui `_ is also a very popular library used for building UIs. The difference
+between imgui and ipywidgets, Qt, and wx is the imgui UI can be rendered directly on the same canvas as a fastplotlib
+``Figure``. This is hugely advantageous, it means that you can write an imgui UI and it will run on any GUI backend,
+i.e. it will work in jupyter, Qt, glfw and wx windows! The programming model is different from Qt and ipywidgets, there
+are no callbacks, but it is easy to learn if you see a few examples.
+
+.. image:: ../_static/guide_imgui.png
+
+We specifically use `imgui-bundle `_ for the python bindings in fastplotlib.
+There is large community and many resources out there on building UIs using imgui.
+
+To install ``fastplotlib`` with ``imgui`` use the ``imgui`` extras option, i.e. ``pip install fastplotlib[imgui]``, or ``pip install imgui_bundle`` if you've already installed fastplotlib.
+
+Fastplotlib comes built-in with imgui UIs for subplot toolbars and a standard right-click menu with a number of options.
+You can also make custom GUIs and embed them within the canvas, see the examples gallery for detailed examples.
+
+**Some tips:**
+
+The ``imgui-bundle`` docs as of March 2025 don't have a nice API list (as far as I know), here is how we go about developing UIs with imgui:
+
+1. Use the ``pyimgui`` API docs to locate the type of UI element we want, for example if we want a ``slider_int``: https://pyimgui.readthedocs.io/en/latest/reference/imgui.core.html#imgui.core.slider_int
+
+2. Look at the function signature in the ``imgui-bundle`` sources. You can usually access this easily with your IDE: https://github.com/pthom/imgui_bundle/blob/a5e7d46555832c40e9be277d4747eac5a303dbfc/bindings/imgui_bundle/imgui/__init__.pyi#L1693-L1696
+
+3. ``pyimgui`` and ``imgui-bundle`` sometimes don't have the same function signature, so we use a combination of the pyimgui docs and
+imgui-bundle function signature to understand and implement the UI element.
+
ImageWidget
-----------
@@ -572,12 +585,9 @@ Let's look at an example: ::
movie = iio.imread("imageio:cockatoo.mp4")
- # convert RGB movie to grayscale
- gray_movie = np.dot(movie[..., :3], [0.299, 0.587, 0.114])
-
iw_movie = ImageWidget(
- data=gray_movie,
- cmap="gray"
+ data=movie,
+ rgb=True
)
iw_movie.show()
@@ -638,63 +648,87 @@ 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 `_
-Imgui
------
+JupyterLab and IPython
+----------------------
-Fastplotlib uses `imgui_bundle `_ to provide within-canvas UI elemenents if you
-installed ``fastplotlib`` using the ``imgui`` toggle, i.e. ``fastplotlib[imgui]``, or installed ``imgui_bundle`` afterwards.
+In ``jupyter lab`` you have the option to embed ``Figures`` in regular output cells, on the side with ``sidecar``,
+or show figures in separate Qt windows. Note: Once you have selected a display mode, we do not recommend switching to
+a different display mode. Restart the kernel to reliably choose a different display mode. By default, fastplotlib
+figures will be embedded in the notebook cell's output.
-Fastplotlib comes built-in with imgui UIs for subplot toolbars and a standard right-click menu with a number of options.
-You can also make custom GUIs and embed them within the canvas, see the examples gallery for detailed examples.
+The `quickstart example notebook `_
+is also a great place to start.
-.. note::
- Imgui is optional, you can use other GUI frameworks such at Qt or ipywidgets with fastplotlib. You can also of course
- use imgui and Qt or ipywidgets.
+Notebooks and remote rendering
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
-.. image:: ../_static/guide_imgui.png
+To display the ``Figure`` in the notebook output, the ``fig.show()`` call must be the last line in the code cell. Or
+you can use ipython's display call: ``display(fig.show())``.
-Using ``fastplotlib`` in an interactive shell
----------------------------------------------
+To display the figure on the side: ``fig.show(sidecar=True)``
-There are multiple ways to use ``fastplotlib`` in interactive shells, such as ipython.
+You can make use of all `ipywidget layout `_
+options to display multiple figures::
-1) Jupyter
+ from ipywidgets import VBox, HBox
-On ``jupyter lab`` the jupyter backend (i.e. ``jupyter_rfb``) is normally selected. This works via
-client-server rendering. Images generated on the server are streamed to the client (Jupyter) via a jpeg byte stream.
-Events (such as mouse or keyboard events) are then streamed in the opposite direction prompting new images to be generated
-by the server if necessary. This remote-frame-buffer approach makes the rendering process very fast. ``fastplotlib`` viusalizations
-can be displayed in cell output or on the side using ``sidecar``.
+ # stack figures vertically or horizontally
+ VBox([fig1.show(), fig2.show()])
-A Qt backend can also optionally be used as well. If ``%gui qt`` is selected before importing ``fastplotlib`` then this backend
-will be used instead.
+Again the ``VBox([...])`` call must be the last line in the code cell, or you can use ``display(VBox([...]))``
-Lastly, users can also force using ``glfw`` by specifying this as an argument when instantiating a ``Figure`` (i.e. ``Figure(canvas="gflw"``).
+You can combine ipywidget layouting just like any other ipywidget::
-.. note::
- Do not mix between gui backends. For example, if you start the notebook using Qt, do not attempt to force using another backend such
- as ``jupyter_rfb`` later.
+ # display a figure on top of two figures laid out horizontally
+
+ VBox([
+ fig1.show(),
+ HBox([fig2.show(), fig3.show()])
+ ])
+
+Embedded figures will also render if you're using the notebook from a remote computer since rendering is done on the
+server side and the client only receives a jpeg stream of rendered frames. This allows you to visualize very large
+datasets on remote servers since the rendering is done remotely and you do not transfer any of the raw data to the
+client.
+
+You can create dashboards or webapps with ``fastplotlib`` by running the notebook with
+`voila `_. This is great for sharing visualizations of very large datasets
+that are too large to share over the internet, and creating fast interactive applications for the analysis of very
+large datasets.
+
+Qt windows in jupyter and IPython
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
-2) IPython
+Qt windows can also be used for displaying fastplotlib figures in an interactive jupyterlab or IPython. You must run
+``%gui qt`` **before** importing ``fastplotlib`` (or ``wgpu``). This would typically be done at the very top of your
+notebook.
-Users can select between using a Qt backend or gflw using the same methods as above.
+Note that this only works if you are using jupyterlab or ipython locally, this cannot be used for remote rendering.
+You can forward windows (ex: X11 forwarding) but this is much slower than the remote rendering described in the
+previous section.
diff --git a/docs/source/user_guide/index.rst b/docs/source/user_guide/index.rst
index 59189be22..92f0da98c 100644
--- a/docs/source/user_guide/index.rst
+++ b/docs/source/user_guide/index.rst
@@ -6,5 +6,6 @@ User Guide
:maxdepth: 2
guide
+ event_tables
gpu
faq
diff --git a/examples/controllers/README.rst b/examples/controllers/README.rst
new file mode 100644
index 000000000..824087ce3
--- /dev/null
+++ b/examples/controllers/README.rst
@@ -0,0 +1,2 @@
+Controller examples
+===================
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/controllers/specify_integers.py b/examples/controllers/specify_integers.py
new file mode 100644
index 000000000..e74b9dd28
--- /dev/null
+++ b/examples/controllers/specify_integers.py
@@ -0,0 +1,50 @@
+"""
+Specify IDs with integers
+=========================
+
+Specify controllers to sync subplots using integer IDs
+"""
+
+# test_example = false
+# sphinx_gallery_pygfx_docs = 'screenshot'
+
+import numpy as np
+import fastplotlib as fpl
+
+
+xs = np.linspace(0, 2 * np.pi, 100)
+sine = np.sin(xs)
+cosine = np.cos(xs)
+
+# controller IDs
+# one controller is created for each unique ID
+# if the IDs are the same, those subplots will be synced
+ids = [
+ [0, 1],
+ [2, 0],
+]
+
+figure = fpl.Figure(
+ shape=(2, 2),
+ controller_ids=ids,
+ size=(700, 560),
+)
+
+for subplot, controller_id in zip(figure, np.asarray(ids).ravel()):
+ subplot.title = f"contr. id: {controller_id}"
+
+figure[0, 0].add_line(np.column_stack([xs, sine]))
+
+figure[0, 1].add_line(np.random.rand(100))
+figure[1, 0].add_line(np.random.rand(100))
+
+figure[1, 1].add_line(np.column_stack([xs, cosine]))
+
+figure.show(maintain_aspect=False)
+
+
+# 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/controllers/specify_names.py b/examples/controllers/specify_names.py
new file mode 100644
index 000000000..0023651a7
--- /dev/null
+++ b/examples/controllers/specify_names.py
@@ -0,0 +1,47 @@
+"""
+Specify IDs with subplot names
+==============================
+
+Provide a list of tuples where each tuple has subplot names. The same controller will be used for the subplots
+indicated by each of these tuples
+"""
+
+# test_example = false
+# sphinx_gallery_pygfx_docs = 'screenshot'
+
+import numpy as np
+import fastplotlib as fpl
+
+
+xs = np.linspace(0, 2 * np.pi, 100)
+ys = np.sin(xs)
+
+# create some subplots names
+names = ["subplot_0", "subplot_1", "subplot_2", "subplot_3", "subplot_4", "subplot_5"]
+
+# list of tuples of subplot names
+# subplots within each tuple will use the same controller.
+ids = [
+ ("subplot_0", "subplot_3"),
+ ("subplot_1", "subplot_2", "subplot_4"),
+]
+
+
+figure = fpl.Figure(
+ shape=(2, 3),
+ controller_ids=ids,
+ names=names,
+ size=(700, 560),
+)
+
+for subplot in figure:
+ subplot.add_line(np.column_stack([xs, ys + np.random.normal(scale=0.1, size=100)]))
+
+figure.show(maintain_aspect=False)
+
+
+# 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/controllers/sync_all.py b/examples/controllers/sync_all.py
new file mode 100644
index 000000000..3a1ee0093
--- /dev/null
+++ b/examples/controllers/sync_all.py
@@ -0,0 +1,30 @@
+"""
+Sync subplots
+=============
+
+Use one controller for all subplots.
+"""
+
+# test_example = false
+# sphinx_gallery_pygfx_docs = 'screenshot'
+
+import numpy as np
+import fastplotlib as fpl
+
+
+xs = np.linspace(0, 2 * np.pi, 100)
+ys = np.sin(xs)
+
+figure = fpl.Figure(shape=(2, 2), controller_ids="sync", size=(700, 560))
+
+for subplot in figure:
+ subplot.add_line(np.column_stack([xs, ys + np.random.normal(scale=0.5, size=100)]))
+
+figure.show(maintain_aspect=False)
+
+
+# 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/README.rst b/examples/events/README.rst
new file mode 100644
index 000000000..8e2deca4b
--- /dev/null
+++ b/examples/events/README.rst
@@ -0,0 +1,4 @@
+Events
+======
+
+Several examples using events
\ No newline at end of file
diff --git a/examples/events/cmap_event.py b/examples/events/cmap_event.py
new file mode 100644
index 000000000..62913cb29
--- /dev/null
+++ b/examples/events/cmap_event.py
@@ -0,0 +1,75 @@
+"""
+cmap event
+==========
+
+Add a cmap event handler to multiple graphics. When any one graphic changes the cmap, the cmap of all other graphics
+is also changed.
+
+This also shows how bidirectional events are supported.
+"""
+
+# test_example = false
+# sphinx_gallery_pygfx_docs = 'screenshot'
+
+import numpy as np
+import fastplotlib as fpl
+import imageio.v3 as iio
+
+# load images
+img1 = iio.imread("imageio:camera.png")
+img2 = iio.imread("imageio:moon.png")
+
+# Create a figure
+figure = fpl.Figure(
+ shape=(2, 2),
+ size=(700, 560),
+ names=["camera", "moon", "sine", "cloud"],
+)
+
+# create graphics
+figure["camera"].add_image(img1)
+figure["moon"].add_image(img2)
+
+# sine wave
+xs = np.linspace(0, 4 * np.pi, 100)
+ys = np.sin(xs)
+
+figure["sine"].add_line(np.column_stack([xs, ys]))
+
+# make a 2D gaussian cloud
+cloud_data = np.random.normal(0, scale=3, size=1000).reshape(500, 2)
+figure["cloud"].add_scatter(
+ cloud_data,
+ sizes=3,
+ cmap="plasma",
+ cmap_transform=np.linalg.norm(cloud_data, axis=1) # cmap transform using distance from origin
+)
+figure["cloud"].axes.intersection = (0, 0, 0)
+
+# show the plot
+figure.show()
+
+
+# event handler to change the cmap of all graphics when the cmap of any one graphic changes
+def cmap_changed(ev: fpl.GraphicFeatureEvent):
+ # get the new cmap
+ new_cmap = ev.info["value"]
+
+ # set cmap of the graphics in the other subplots
+ for subplot in figure:
+ subplot.graphics[0].cmap = new_cmap
+
+
+for subplot in figure:
+ # add event handler to the graphic added to each subplot
+ subplot.graphics[0].add_event_handler(cmap_changed, "cmap")
+
+
+# change the cmap of graphic image, triggers all other graphics to set the cmap
+figure["camera"].graphics[0].cmap = "jet"
+
+# 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
new file mode 100644
index 000000000..5a679a996
--- /dev/null
+++ b/examples/events/drag_points.py
@@ -0,0 +1,99 @@
+"""
+Drag points
+===========
+
+Example where you can drag scatter points on a line. This example also demonstrates how you can use a shared buffer
+between two graphics to represent the same data using different graphics. When you update the data of one graphic the
+data of the other graphic is also changed simultaneously since they use the same underlying buffer on the GPU.
+
+"""
+
+# 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, 10)
+ys = np.sin(xs)
+
+data = np.column_stack([xs, ys])
+
+figure = fpl.Figure(size=(700, 560))
+
+# add a line
+line = figure[0, 0].add_line(data)
+
+# add a scatter, share the line graphic buffer!
+scatter = figure[0, 0].add_scatter(data=line.data, sizes=25, colors="r")
+
+is_moving = False
+vertex_index = None
+
+
+@scatter.add_event_handler("pointer_down")
+def start_drag(ev: pygfx.PointerEvent):
+ global is_moving
+ global vertex_index
+
+ if ev.button != 1:
+ return
+
+ is_moving = True
+ vertex_index = ev.pick_info["vertex_index"]
+ scatter.colors[vertex_index] = "cyan"
+
+
+@figure.renderer.add_event_handler("pointer_move")
+def move_point(ev):
+ global is_moving
+ global vertex_index
+
+ # if not moving, return
+ if not is_moving:
+ return
+
+ # disable controller
+ figure[0, 0].controller.enabled = False
+
+ # 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
+ 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.data[vertex_index, :-1] = pos[:-1]
+
+ # re-enable controller
+ figure[0, 0].controller.enabled = True
+
+
+@figure.renderer.add_event_handler("pointer_up")
+def end_drag(ev: pygfx.PointerEvent):
+ global is_moving
+ global vertex_index
+
+ # end movement
+ if is_moving:
+ # reset color
+ scatter.colors[vertex_index] = "r"
+
+ is_moving = False
+ vertex_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/events/image_click.py b/examples/events/image_click.py
new file mode 100644
index 000000000..b783e1ee0
--- /dev/null
+++ b/examples/events/image_click.py
@@ -0,0 +1,44 @@
+"""
+Image click event
+=================
+
+Example showing how to use a click event on an image.
+"""
+
+# test_example = false
+# sphinx_gallery_pygfx_docs = 'screenshot'
+
+import fastplotlib as fpl
+import pygfx
+import imageio.v3 as iio
+
+data = iio.imread("imageio:camera.png")
+
+# Create a figure
+figure = fpl.Figure(size=(700, 560))
+
+# create image graphic
+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.add_event_handler("click")
+def click_event(ev: pygfx.PointerEvent):
+ # get the click location in screen coordinates
+ xy = (ev.x, ev.y)
+
+ # map the screen coordinates to world coordinates
+ xy = figure[0, 0].map_screen_to_world(xy)[:-1]
+
+ # print the click location
+ print(xy)
+
+
+# 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/image_data_event.py b/examples/events/image_data_event.py
new file mode 100644
index 000000000..f97b1115e
--- /dev/null
+++ b/examples/events/image_data_event.py
@@ -0,0 +1,56 @@
+"""
+Image data event
+================
+
+Example showing how to add an event handler to an ImageGraphic to capture when the data changes.
+"""
+
+# test_example = false
+# sphinx_gallery_pygfx_docs = 'screenshot'
+
+import fastplotlib as fpl
+import imageio.v3 as iio
+from scipy.ndimage import gaussian_filter
+
+rgb_weights = [0.299, 0.587, 0.114]
+
+# load images, convert to grayscale
+img1 = iio.imread("imageio:wikkie.png") @ rgb_weights
+img2 = iio.imread("imageio:astronaut.png") @ rgb_weights
+
+# Create a figure
+figure = fpl.Figure(
+ shape=(1, 2),
+ size=(700, 560),
+ names=["image", "gaussian filtered image"]
+)
+
+# create image graphics
+image_raw = figure[0, 0].add_image(img1)
+image_filt = figure[0, 1].add_image(gaussian_filter(img1, sigma=5))
+
+# show the plot
+figure.show()
+
+
+# add event handler
+@image_raw.add_event_handler("data")
+def data_changed(ev: fpl.GraphicFeatureEvent):
+ # get the new image data
+ new_img = ev.info["value"]
+
+ # set the filtered image graphic
+ image_filt.data = gaussian_filter(new_img, sigma=5)
+
+
+# set the data on the first image graphic
+# this will trigger the `data_changed()` handler to be called
+image_raw.data = img2
+
+
+# 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/key_events.py b/examples/events/key_events.py
new file mode 100644
index 000000000..f8cf2f3df
--- /dev/null
+++ b/examples/events/key_events.py
@@ -0,0 +1,85 @@
+"""
+Key Events
+==========
+
+Move an image around using and change some of its properties using keyboard events.
+
+- Use the arrows keys to move the image by changing its offset
+
+- Press "v", "g", "p" to change the colormaps (viridis, grey, plasma).
+
+- Press "r" to rotate the image +18 degrees (pi / 10 radians)
+- Press "Shift + R" to rotate the image -18 degrees
+- Axis of rotation is the origin
+
+- Press "-", "=" to decrease/increase the vmin
+- Press "_", "+" to decrease/increase the vmax
+
+We use the ImageWidget here because the histogram LUT tool makes it easy to see the changes in vmin and vmax.
+"""
+
+# test_example = false
+# sphinx_gallery_pygfx_docs = 'screenshot'
+
+import numpy as np
+import fastplotlib as fpl
+import pygfx
+import imageio.v3 as iio
+
+data = iio.imread("imageio:camera.png")
+
+iw = fpl.ImageWidget(data, figure_kwargs={"size": (700, 560)})
+
+image = iw.managed_graphics[0]
+
+
+@iw.figure.renderer.add_event_handler("key_down")
+def handle_event(ev: pygfx.KeyboardEvent):
+ match ev.key:
+ # change the cmap
+ case "v":
+ image.cmap = "viridis"
+ case "g":
+ image.cmap = "grey"
+ case "p":
+ image.cmap = "plasma"
+
+ # keys to change vmin/vmax
+ case "-":
+ image.vmin -= 1
+ case "=":
+ image.vmin += 1
+ case "_":
+ image.vmax -= 1
+ case "+":
+ image.vmax += 1
+
+ # rotate
+ case "r":
+ image.rotate(np.pi / 10, axis="z")
+ case "R":
+ image.rotate(-np.pi / 10, axis="z")
+
+ # arrow key events to move the image
+ case "ArrowUp":
+ image.offset = image.offset + [0, -10, 0] # remember y-axis is flipped for images
+ case "ArrowDown":
+ image.offset = image.offset + [0, 10, 0]
+ case "ArrowLeft":
+ image.offset = image.offset + [-10, 0, 0]
+ case "ArrowRight":
+ image.offset = image.offset + [10, 0, 0]
+
+
+iw.show()
+
+
+figure = iw.figure # ignore, this is just so the docs gallery scraper picks up the figure
+
+
+# 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/line_data_thickness_event.py b/examples/events/line_data_thickness_event.py
new file mode 100644
index 000000000..d28471644
--- /dev/null
+++ b/examples/events/line_data_thickness_event.py
@@ -0,0 +1,79 @@
+"""
+Events line data thickness
+==========================
+
+Simple example of adding event handlers for line data and thickness.
+"""
+
+# test_example = false
+# sphinx_gallery_pygfx_docs = 'screenshot'
+
+import fastplotlib as fpl
+import numpy as np
+
+figure = fpl.Figure(size=(700, 560))
+
+xs = np.linspace(0, 4 * np.pi, 100)
+# sine wave
+ys = np.sin(xs)
+sine_data = np.column_stack([xs, ys])
+
+# cosine wave
+ys = np.cos(xs)
+cosine_data = np.column_stack([xs, ys])
+
+# create line graphics
+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, cosine]
+
+
+def change_thickness(ev: fpl.GraphicFeatureEvent):
+ # sets thickness of all the lines
+ new_value = ev.info["value"]
+
+ for g in lines:
+ g.thickness = new_value
+
+
+def change_data(ev: fpl.GraphicFeatureEvent):
+ # sets data of all the lines using the given event and value from the event
+
+ # the user's slice/index
+ # This can be a single int index, a slice,
+ # or even a numpy array of int or bool for fancy indexing!
+ indices = ev.info["key"]
+
+ # the new values to set at the given indices
+ new_values = ev.info["value"]
+
+ # set the data for all the lines
+ for g in lines:
+ g.data[indices] = new_values
+
+
+# add the event handlers to the line graphics
+for g in lines:
+ g.add_event_handler(change_thickness, "thickness")
+ g.add_event_handler(change_data, "data")
+
+
+figure.show()
+figure[0, 0].axes.intersection = (0, 0, 0)
+
+# 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.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.thickness = 10
+
+# 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/lines_mouse_nearest.py b/examples/events/lines_mouse_nearest.py
new file mode 100644
index 000000000..8d38e9f53
--- /dev/null
+++ b/examples/events/lines_mouse_nearest.py
@@ -0,0 +1,62 @@
+"""
+Highlight nearest circle
+========================
+
+Shows how to use the "pointer_move" event to get the nearest circle and highlight it.
+
+"""
+
+# test_example = false
+# sphinx_gallery_pygfx_docs = 'screenshot'
+
+from itertools import product
+import numpy as np
+import fastplotlib as fpl
+import pygfx
+
+
+def make_circle(center, radius: float, n_points: int) -> np.ndarray:
+ theta = np.linspace(0, 2 * np.pi, n_points)
+ xs = radius * np.cos(theta)
+ ys = radius * np.sin(theta)
+
+ return np.column_stack([xs, ys]) + center
+
+spatial_dims = (100, 100)
+
+circles = list()
+for center in product(range(0, spatial_dims[0], 15), range(0, spatial_dims[1], 15)):
+ circles.append(make_circle(center, 5, n_points=75))
+
+pos_xy = np.vstack(circles)
+
+figure = fpl.Figure(size=(700, 560))
+
+line_collection = figure[0, 0].add_line_collection(circles, colors="w", thickness=5)
+
+
+@figure.renderer.add_event_handler("pointer_move")
+def highlight_nearest(ev: pygfx.PointerEvent):
+ line_collection.colors = "w"
+
+ pos = figure[0, 0].map_screen_to_world(ev)
+ if pos is None:
+ return
+
+ # get_nearest_graphics() is a helper function
+ # sorted the passed array or collection of graphics from nearest to furthest from the passed `pos`
+ nearest = fpl.utils.get_nearest_graphics(pos, line_collection)[0]
+
+ nearest.colors = "r"
+
+
+# remove clutter
+figure[0, 0].axes.visible = False
+
+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/events/paint_image.py b/examples/events/paint_image.py
new file mode 100644
index 000000000..46ef43114
--- /dev/null
+++ b/examples/events/paint_image.py
@@ -0,0 +1,71 @@
+"""
+Paint an Image
+==============
+
+Click and drag the mouse to paint in the image
+"""
+
+# test_example = false
+# sphinx_gallery_pygfx_docs = 'screenshot'
+
+import numpy as np
+import fastplotlib as fpl
+import pygfx
+
+figure = fpl.Figure(size=(700, 560))
+
+# add a blank image
+image = figure[0, 0].add_image(np.zeros((100, 100)), vmin=0, vmax=255)
+
+painting = False # use to toggle painting state
+
+
+@image.add_event_handler("pointer_down")
+def on_pointer_down(ev: pygfx.PointerEvent):
+ # start painting when mouse button is down
+ global painting
+
+ # get image element index, (x, y) pos corresponds to array (column, row)
+ col, row = ev.pick_info["index"]
+
+ # increase value of this image element
+ image.data[row, col] = np.clip(image.data[row, col] + 50, 0, 255)
+
+ # toggle on painting state
+ painting = True
+
+ # disable controller until painting stops when mouse button is un-clicked
+ figure[0, 0].controller.enabled = False
+
+
+@image.add_event_handler("pointer_move")
+def on_pointer_move(ev: pygfx.PointerEvent):
+ # continue painting when mouse pointer is moved
+ global painting
+
+ if not painting:
+ return
+
+ # get image element index, (x, y) pos corresponds to array (column, row)
+ col, row = ev.pick_info["index"]
+
+ image.data[row, col] = np.clip(image.data[row, col] + 50, 0, 255)
+
+
+@figure.renderer.add_event_handler("pointer_up")
+def on_pointer_up(ev: pygfx.PointerEvent):
+ # toggle off painting state
+ global painting
+ painting = False
+
+ # re-enable controller
+ figure[0, 0].controller.enabled = True
+
+
+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/events/scatter_click.py b/examples/events/scatter_click.py
new file mode 100644
index 000000000..3bf85558a
--- /dev/null
+++ b/examples/events/scatter_click.py
@@ -0,0 +1,66 @@
+"""
+Scatter click
+=============
+
+Add an event handler to click on scatter points and highlight them, i.e. change the color and size of the clicked point.
+Fly around the 3D scatter using WASD keys and click on points to highlight them
+"""
+
+# test_example = false
+# sphinx_gallery_pygfx_docs = 'screenshot'
+
+import numpy as np
+import fastplotlib as fpl
+import pygfx
+
+# make a gaussian cloud
+data = np.random.normal(loc=0, scale=3, size=1500).reshape(500, 3)
+
+figure = fpl.Figure(cameras="3d", size=(700, 560))
+
+scatter = figure[0, 0].add_scatter(
+ data, # the gaussian cloud
+ sizes=10, # some big points that are easy to click
+ cmap="viridis",
+ cmap_transform=np.linalg.norm(data, axis=1) # color points using distance from origin
+)
+
+# simple dict to restore the original scatter color and size
+# of the previously clicked point upon clicking a new point
+old_props = {"index": None, "size": None, "color": None}
+
+
+@scatter.add_event_handler("click")
+def highlight_point(ev: pygfx.PointerEvent):
+ global old_props
+
+ # the index of the point that was just clicked
+ new_index = ev.pick_info["vertex_index"]
+
+ # restore old point's properties
+ if old_props["index"] is not None:
+ old_index = old_props["index"]
+ if new_index == old_index:
+ # same point was clicked, ignore
+ return
+ scatter.colors[old_index] = old_props["color"]
+ scatter.sizes[old_index] = old_props["size"]
+
+ # store the current property values of this new point
+ old_props["index"] = new_index
+ old_props["color"] = scatter.colors[new_index].copy() # if you do not copy you will just get a view of the array!
+ old_props["size"] = scatter.sizes[new_index]
+
+ # highlight this new point
+ scatter.colors[new_index] = "magenta"
+ scatter.sizes[new_index] = 20
+
+
+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/events/scatter_hover.py b/examples/events/scatter_hover.py
new file mode 100644
index 000000000..c297223d2
--- /dev/null
+++ b/examples/events/scatter_hover.py
@@ -0,0 +1,69 @@
+"""
+Scatter hover
+=============
+
+Add an event handler to hover on scatter points and highlight them, i.e. change the color and size of the clicked point.
+Fly around the 3D scatter using WASD keys and click on points to highlight them.
+
+There is no "hover" event, you can create a hover effect by using "pointer_move" events.
+"""
+
+# test_example = false
+# sphinx_gallery_pygfx_docs = 'screenshot'
+
+import numpy as np
+import fastplotlib as fpl
+import pygfx
+
+# make a gaussian cloud
+data = np.random.normal(loc=0, scale=3, size=1500).reshape(500, 3)
+
+figure = fpl.Figure(cameras="3d", size=(700, 560))
+
+scatter = figure[0, 0].add_scatter(
+ data, # the gaussian cloud
+ sizes=10, # some big points that are easy to click
+ cmap="viridis",
+ cmap_transform=np.linalg.norm(data, axis=1) # color points using distance from origin
+)
+
+# simple dict to restore the original scatter color and size
+# of the previously clicked point upon clicking a new point
+old_props = {"index": None, "size": None, "color": None}
+
+
+@scatter.add_event_handler("pointer_move")
+def highlight_point(ev: pygfx.PointerEvent):
+ global old_props
+
+ # the index of the point that was just entered
+ new_index = ev.pick_info["vertex_index"]
+
+ # if a new point has been entered, but we have not yet had
+ # a leave event for the previous point, then reset this old point
+ if old_props["index"] is not None:
+ old_index = old_props["index"]
+ if new_index == old_index:
+ # same point, ignore
+ return
+ scatter.colors[old_index] = old_props["color"]
+ scatter.sizes[old_index] = old_props["size"]
+
+ # store the current property values of this new point
+ old_props["index"] = new_index
+ old_props["color"] = scatter.colors[new_index].copy() # if you do not copy you will just get a view of the array!
+ old_props["size"] = scatter.sizes[new_index]
+
+ # highlight this new point
+ scatter.colors[new_index] = "magenta"
+ scatter.sizes[new_index] = 20
+
+
+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/events/scatter_hover_transforms.py b/examples/events/scatter_hover_transforms.py
new file mode 100644
index 000000000..f7b733109
--- /dev/null
+++ b/examples/events/scatter_hover_transforms.py
@@ -0,0 +1,112 @@
+"""
+Scatter data explore scalers
+============================
+
+Based on the sklearn preprocessing scalers example. Hover points to highlight the corresponding point of the dataset
+transformed by the various scalers.
+
+See: https://scikit-learn.org/stable/auto_examples/preprocessing/plot_all_scaling.html
+
+This is another example that uses bi-directional events.
+"""
+
+# test_example = false
+# sphinx_gallery_pygfx_docs = 'screenshot'
+
+from sklearn.datasets import load_diabetes
+from sklearn.preprocessing import (
+ StandardScaler,
+ QuantileTransformer,
+ PowerTransformer,
+)
+
+import fastplotlib as fpl
+import pygfx
+
+# get the dataset
+dataset = load_diabetes(scaled=False)
+
+
+# Take only 2 features to make visualization easier
+X = dataset["data"][:, (2, 6)]
+# target
+y = dataset["target"]
+
+# list of our scalers and their names as strings
+scalers = [PowerTransformer, QuantileTransformer, StandardScaler]
+names = ["Original Data", *[s.__name__ for s in scalers]]
+
+# fastplotlib code starts here, make a figure
+figure = fpl.Figure(
+ shape=(2, 2),
+ names=names,
+ size=(700, 780),
+)
+
+scatters = list() # list to store our 4 scatter graphics for convenience
+
+# add a scatter of the original data
+s = figure["Original Data"].add_scatter(
+ data=X,
+ cmap="viridis",
+ cmap_transform=y,
+ sizes=3,
+)
+
+# append to list of scatters
+scatters.append(s)
+
+# add the scaled data as scatter graphics
+for scaler in scalers:
+ name = scaler.__name__
+ s = figure[name].add_scatter(scaler().fit_transform(X), cmap="viridis", cmap_transform=y, sizes=3)
+ scatters.append(s)
+
+
+# simple dict to restore the original scatter color and size
+# of the previously clicked point upon clicking a new point
+old_props = {"index": None, "size": None, "color": None}
+
+
+def highlight_point(ev: pygfx.PointerEvent):
+ # event handler to highlight the point when the mouse moves over it
+ global old_props
+
+ # the index of the point that was just clicked
+ new_index = ev.pick_info["vertex_index"]
+
+ # restore old point's properties
+ if old_props["index"] is not None:
+ old_index = old_props["index"]
+ if new_index == old_index:
+ # same point was clicked, ignore
+ return
+ for s in scatters:
+ s.colors[old_index] = old_props["color"]
+ s.sizes[old_index] = old_props["size"]
+
+ # store the current property values of this new point
+ old_props["index"] = new_index
+ # all the scatters have the same colors and size for the corresponding index
+ # so we can just use the first scatter's original color and size
+ old_props["color"] = scatters[0].colors[new_index].copy() # if you do not copy you will just get a view of the array!
+ old_props["size"] = scatters[0].sizes[new_index]
+
+ # highlight this new point
+ for s in scatters:
+ s.colors[new_index] = "magenta"
+ s.sizes[new_index] = 15
+
+
+# add the event handler to all the scatter graphics
+for s in scatters:
+ s.add_event_handler(highlight_point, "pointer_move")
+
+
+figure.show(maintain_aspect=False)
+
+# 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/gridplot/README.rst b/examples/gridplot/README.rst
index 486e708e7..0a2cc1828 100644
--- a/examples/gridplot/README.rst
+++ b/examples/gridplot/README.rst
@@ -1,2 +1,2 @@
-GridPlot Examples
-=================
+Grid layout Examples
+====================
diff --git a/examples/gridplot/gridplot.py b/examples/gridplot/gridplot.py
index 5c38d6d43..1aa8c8083 100644
--- a/examples/gridplot/gridplot.py
+++ b/examples/gridplot/gridplot.py
@@ -1,8 +1,8 @@
"""
-GridPlot Simple
-===============
+Grid layout Simple
+==================
-Example showing simple 2x2 GridPlot with Standard images from imageio.
+Example showing simple 2x2 grid layout with standard images from imageio.
"""
# test_example = true
@@ -26,8 +26,8 @@
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
+# 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()
+ fpl.loop.run()
\ No newline at end of file
diff --git a/examples/gridplot/gridplot_non_square.py b/examples/gridplot/gridplot_non_square.py
index 0277bcccd..da0bf14c3 100644
--- a/examples/gridplot/gridplot_non_square.py
+++ b/examples/gridplot/gridplot_non_square.py
@@ -1,8 +1,8 @@
"""
-GridPlot Non-Square Example
-===========================
+Grid Layout 2
+=============
-Example showing simple 2x2 GridPlot with Standard images from imageio.
+Simple 2x2 grid layout Figure with standard images from imageio, one subplot is left empty
"""
# test_example = true
@@ -24,8 +24,8 @@
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
+# 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/gridplot/gridplot_viewports_check.py b/examples/gridplot/gridplot_viewports_check.py
new file mode 100644
index 000000000..45f9d7004
--- /dev/null
+++ b/examples/gridplot/gridplot_viewports_check.py
@@ -0,0 +1,37 @@
+"""
+Grid layout test viewport rects
+===============================
+
+Test figure to test that viewport rects are positioned correctly
+"""
+
+# test_example = true
+# sphinx_gallery_pygfx_docs = 'hidden'
+
+import fastplotlib as fpl
+import numpy as np
+
+
+figure = fpl.Figure(
+ shape=(2, 3),
+ size=(700, 560),
+ names=list(map(str, range(6)))
+)
+
+np.random.seed(0)
+a = np.random.rand(6, 10, 10)
+
+for data, subplot in zip(a, figure):
+ subplot.add_image(data)
+ subplot.docks["left"].size = 20
+ subplot.docks["right"].size = 30
+ subplot.docks["bottom"].size = 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/gridplot/multigraphic_gridplot.py b/examples/gridplot/multigraphic_gridplot.py
index 1bed60b31..cbf546e2a 100644
--- a/examples/gridplot/multigraphic_gridplot.py
+++ b/examples/gridplot/multigraphic_gridplot.py
@@ -1,8 +1,8 @@
"""
-Multi-Graphic GridPlot
-======================
+Multi-Graphic Grid layout
+=========================
-Example showing a Figure with multiple subplots and multiple graphic types.
+A Figure with multiple subplots and multiple graphic types.
"""
# test_example = false
@@ -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]
@@ -110,9 +111,8 @@ def make_circle(center, radius: float, n_points: int = 75) -> np.ndarray:
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
+# NOTE: fpl.loop.run() should not be used for interactive sessions
+# See the "JupyterLab and IPython" section in the user guide
if __name__ == "__main__":
print(__doc__)
fpl.loop.run()
-
diff --git a/examples/guis/image_widget_imgui.py b/examples/guis/image_widget_imgui.py
index 13d41af20..759d87a07 100644
--- a/examples/guis/image_widget_imgui.py
+++ b/examples/guis/image_widget_imgui.py
@@ -75,8 +75,8 @@ def process_image(self):
figure = iw.figure
-# NOTE: `if __name__ == "__main__"` is NOT how to use fastplotlib interactively
-# please see our docs for using fastplotlib interactively in ipython and jupyter
+# NOTE: fpl.loop.run() should not be used for interactive sessions
+# See the "JupyterLab and IPython" section in the user guide
if __name__ == "__main__":
print(__doc__)
fpl.loop.run()
diff --git a/examples/guis/imgui_basic.py b/examples/guis/imgui_basic.py
index eac39121c..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
@@ -116,8 +117,8 @@ def _set_data(self):
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
+# NOTE: fpl.loop.run() should not be used for interactive sessions
+# See the "JupyterLab and IPython" section in the user guide
if __name__ == "__main__":
print(__doc__)
fpl.loop.run()
diff --git a/examples/guis/imgui_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
new file mode 100644
index 000000000..935f9a5a1
--- /dev/null
+++ b/examples/guis/sine_cosine_funcs.py
@@ -0,0 +1,185 @@
+"""
+Sine and Cosine functions
+=========================
+
+Identical to the Unit Circle example but you can change the angular frequencies using a UI
+
+"""
+
+# test_example = false
+# sphinx_gallery_pygfx_docs = 'screenshot'
+
+import numpy as np
+import fastplotlib as fpl
+from fastplotlib.ui import EdgeWindow
+from imgui_bundle import imgui
+
+
+# initial frequency coefficients for sine and cosine functions
+P = 1
+Q = 1
+
+
+# helper function to make a circle
+def make_circle(center, radius: float, p, q, n_points: int) -> np.ndarray:
+ theta = np.linspace(0, 2 * np.pi, n_points)
+ xs = radius * np.cos(theta * p)
+ ys = radius * np.sin(theta * q)
+
+ return np.column_stack([xs, ys]) + center
+
+
+# we can define this layout using "extents", i.e. min and max ranges on the canvas
+# (x_min, x_max, y_min, y_max)
+# extents can be defined as fractions as shown here
+extents = [
+ (0, 0.5, 0, 1), # circle subplot
+ (0.5, 1, 0, 0.5), # sine subplot
+ (0.5, 1, 0.5, 1), # cosine subplot
+]
+
+# create a figure with 3 subplots
+figure = fpl.Figure(
+ extents=extents,
+ names=["circle", "sin", "cos"],
+ size=(700, 560)
+)
+
+# set more descriptive figure titles
+figure["circle"].title = "sin(x*p) over cos(x*q)"
+figure["sin"].title = "sin(x * p)"
+figure["cos"].title = "cos(x * q)"
+
+# set the axes to intersect at (0, 0, 0) to better illustrate the unit circle
+for subplot in figure:
+ subplot.axes.intersection = (0, 0, 0)
+ subplot.toolbar = False # reduce clutter
+
+figure["sin"].camera.maintain_aspect = False
+figure["cos"].camera.maintain_aspect = False
+
+# create sine and cosine data
+xs = np.linspace(0, 2 * np.pi, 360)
+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 = 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 = figure["circle"].add_line(
+ radius_data, thickness=6, colors="magenta"
+)
+
+# sine line graphic, cmap transform set from the sine function
+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 = 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.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, cosine]:
+ g.cmap.transform = cmap_transform
+
+ # set circle 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.add_event_handler(set_circle_cmap, "click")
+cosine.add_event_handler(set_circle_cmap, "click")
+
+
+def set_x_val(ev):
+ # used to sync the two selectors
+ value = ev.info["value"]
+ index = ev.get_selected_index()
+
+ sine_selector.selection = value
+ cosine_selector.selection = value
+
+ circle_radius.data[1, :-1] = circle_data[index]
+
+# add same event handler to both graphics
+sine_selector.add_event_handler(set_x_val, "selection")
+cosine_selector.add_event_handler(set_x_val, "selection")
+
+# initial selection value
+sine_selector.selection = 50
+
+
+class GUIWindow(EdgeWindow):
+ def __init__(self, figure, size, location, title):
+ super().__init__(figure=figure, size=size, location=location, title=title)
+
+ self._p = 1
+ self._q = 1
+
+ def _set_data(self):
+ global sine, cosine, circle, circle_radius, circle_data
+
+ # make new data
+ 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.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
+
+ changed, self._p = imgui.input_int("P", v=self._p, step_fast=2)
+ if changed:
+ flag_set_data = True
+
+ changed, self._q = imgui.input_int("Q", v=self._q, step_fast=2)
+ if changed:
+ flag_set_data = True
+
+ if flag_set_data:
+ self._set_data()
+
+
+gui = GUIWindow(
+ figure=figure,
+ size=100,
+ location="right",
+ title="Freq. coeffs"
+)
+
+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/heatmap/heatmap.py b/examples/heatmap/heatmap.py
index 39d76ae4e..3f02206f6 100644
--- a/examples/heatmap/heatmap.py
+++ b/examples/heatmap/heatmap.py
@@ -21,13 +21,13 @@
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()
-# NOTE: `if __name__ == "__main__"` is NOT how to use fastplotlib interactively
-# please see our docs for using fastplotlib interactively in ipython and jupyter
+# 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/image_cmap.py b/examples/image/image_cmap.py
index 99a3c1969..8c94c6f17 100644
--- a/examples/image/image_cmap.py
+++ b/examples/image/image_cmap.py
@@ -8,22 +8,23 @@
# 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: `if __name__ == "__main__"` is NOT how to use fastplotlib interactively
-# please see our docs for using fastplotlib interactively in ipython and jupyter
+# 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/image_rgb.py b/examples/image/image_rgb.py
index 5af8cee0d..569c09f0b 100644
--- a/examples/image/image_rgb.py
+++ b/examples/image/image_rgb.py
@@ -16,13 +16,13 @@
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()
-# NOTE: `if __name__ == "__main__"` is NOT how to use fastplotlib interactively
-# please see our docs for using fastplotlib interactively in ipython and jupyter
+# 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/image_rgbvminvmax.py b/examples/image/image_rgbvminvmax.py
index 08c01a36e..bf2963daf 100644
--- a/examples/image/image_rgbvminvmax.py
+++ b/examples/image/image_rgbvminvmax.py
@@ -16,15 +16,15 @@
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: `if __name__ == "__main__"` is NOT how to use fastplotlib interactively
-# please see our docs for using fastplotlib interactively in ipython and jupyter
+# 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/image_simple.py b/examples/image/image_simple.py
index 31803f2f8..2fd7f694c 100644
--- a/examples/image/image_simple.py
+++ b/examples/image/image_simple.py
@@ -16,13 +16,12 @@
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()
-
-# NOTE: `if __name__ == "__main__"` is NOT how to use fastplotlib interactively
-# please see our docs for using fastplotlib interactively in ipython and jupyter
+# 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/image_small.py b/examples/image/image_small.py
index eebc49797..6acfd7250 100644
--- a/examples/image/image_small.py
+++ b/examples/image/image_small.py
@@ -18,13 +18,12 @@
[[0, 1, 2],
[3, 4, 5]]
)
-image_graphic = figure[0, 0].add_image(data)
+image = figure[0, 0].add_image(data)
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
+# 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/image_vminvmax.py b/examples/image/image_vminvmax.py
index 6cf13834d..1c290c587 100644
--- a/examples/image/image_vminvmax.py
+++ b/examples/image/image_vminvmax.py
@@ -16,15 +16,15 @@
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: `if __name__ == "__main__"` is NOT how to use fastplotlib interactively
-# please see our docs for using fastplotlib interactively in ipython and jupyter
+# 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/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/image_widget/image_widget.py b/examples/image_widget/image_widget.py
index 4fe47b7fe..a3c332182 100644
--- a/examples/image_widget/image_widget.py
+++ b/examples/image_widget/image_widget.py
@@ -27,8 +27,8 @@
figure = iw.figure
-# NOTE: `if __name__ == "__main__"` is NOT how to use fastplotlib interactively
-# please see our docs for using fastplotlib interactively in ipython and jupyter
+# 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_widget/image_widget_grid.py b/examples/image_widget/image_widget_grid.py
index f52f38bc5..41e964e95 100644
--- a/examples/image_widget/image_widget_grid.py
+++ b/examples/image_widget/image_widget_grid.py
@@ -34,8 +34,8 @@
subplot.toolbar = False
-# NOTE: `if __name__ == "__main__"` is NOT how to use fastplotlib interactively
-# please see our docs for using fastplotlib interactively in ipython and jupyter
+# 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_widget/image_widget_single_video.py b/examples/image_widget/image_widget_single_video.py
index 3a0e94fca..86ca642fa 100644
--- a/examples/image_widget/image_widget_single_video.py
+++ b/examples/image_widget/image_widget_single_video.py
@@ -20,7 +20,7 @@
movie_sub = movie[:15, ::12, ::12].copy()
del movie
-iw = fpl.ImageWidget(movie_sub, rgb=[True], figure_kwargs={"size": (700, 560)})
+iw = fpl.ImageWidget(movie_sub, rgb=True, figure_kwargs={"size": (700, 560)})
# ImageWidget supports setting window functions the `time` "t" or `volume` "z" dimension
# These can also be given as kwargs to `ImageWidget` during instantiation
@@ -40,8 +40,8 @@
figure = iw.figure
-# NOTE: `if __name__ == "__main__"` is NOT how to use fastplotlib interactively
-# please see our docs for using fastplotlib interactively in ipython and jupyter
+# 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_widget/image_widget_videos.py b/examples/image_widget/image_widget_videos.py
index 1e367f0ad..399abbcff 100644
--- a/examples/image_widget/image_widget_videos.py
+++ b/examples/image_widget/image_widget_videos.py
@@ -29,15 +29,15 @@
[random_data, cockatoo_sub],
rgb=[False, True],
figure_shape=(2, 1), # 2 rows, 1 column
- figure_kwargs={"size": (700, 560)}
+ figure_kwargs={"size": (700, 940)}
)
iw.show()
figure = iw.figure
-# NOTE: `if __name__ == "__main__"` is NOT how to use fastplotlib interactively
-# please see our docs for using fastplotlib interactively in ipython and jupyter
+# 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_widget/image_widget_viewports_check.py b/examples/image_widget/image_widget_viewports_check.py
new file mode 100644
index 000000000..a4c0aea03
--- /dev/null
+++ b/examples/image_widget/image_widget_viewports_check.py
@@ -0,0 +1,35 @@
+"""
+ImageWidget test viewport rects
+===============================
+
+Test Figure to test that viewport rects are positioned correctly in an image widget
+"""
+
+# test_example = true
+# sphinx_gallery_pygfx_docs = 'hidden'
+
+import fastplotlib as fpl
+import numpy as np
+
+np.random.seed(0)
+a = np.random.rand(6, 15, 10, 10)
+
+iw = fpl.ImageWidget(
+ data=[img for img in a],
+ names=list(map(str, range(6))),
+ figure_kwargs={"size": (700, 560)},
+)
+
+for subplot in iw.figure:
+ subplot.docks["left"].size = 10
+ subplot.docks["bottom"].size = 40
+
+iw.show()
+
+figure = iw.figure
+
+# 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/ipywidgets/README.rst b/examples/ipywidgets/README.rst
new file mode 100644
index 000000000..3f6ae9d5f
--- /dev/null
+++ b/examples/ipywidgets/README.rst
@@ -0,0 +1,2 @@
+Using with ipywidgets
+=====================
diff --git a/examples/ipywidgets/ipywidgets_modify_image.py b/examples/ipywidgets/ipywidgets_modify_image.py
new file mode 100644
index 000000000..c0206e945
--- /dev/null
+++ b/examples/ipywidgets/ipywidgets_modify_image.py
@@ -0,0 +1,69 @@
+"""
+ipwidgets modify an ImageGraphic
+================================
+
+Use ipywidgets to modify some features of an ImageGraphic. Run in jupyterlab.
+"""
+
+# test_example = false
+# sphinx_gallery_pygfx_docs = 'code'
+
+import fastplotlib as fpl
+from scipy.ndimage import gaussian_filter
+import imageio.v3 as iio
+from ipywidgets import FloatRangeSlider, FloatSlider, Select, VBox
+
+data = iio.imread("imageio:moon.png")
+
+iw = fpl.ImageWidget(data, figure_kwargs={"size": (700, 560)})
+
+# get the ImageGraphic from the image widget
+image = iw.managed_graphics[0]
+
+min_v, max_v = fpl.utils.quick_min_max(data)
+
+# slider to adjust vmin, vmax of the image
+vmin_vmax_slider = FloatRangeSlider(value=(image.vmin, image.vmax), min=min_v, max=max_v, description="vmin, vmax:")
+
+# slider to adjust sigma of a gaussian kernel used to filter the image (i.e. gaussian blur)
+slider_sigma = FloatSlider(min=0.0, max=10.0, value=0.0, description="σ: ")
+
+# select box to choose the sample image shown in the ImageWidget
+select_image = Select(options=["moon.png", "camera.png", "checkerboard.png"], description="image: ")
+
+
+def update_vmin_vmax(change):
+ vmin, vmax = change["new"]
+
+ image = iw.managed_graphics[0]
+ image.vmin, image.vmax = vmin, vmax
+
+
+def update_sigma(change):
+ sigma = change["new"]
+
+ # set a "frame apply" dict onto the ImageWidget
+ # this maps {image_index: function}
+ # the function is applied to the image data at the image index given by the key
+ iw.frame_apply = {0: lambda image_data: gaussian_filter(image_data, sigma=sigma)}
+
+
+def update_image(change):
+ filename = change["new"]
+ data = iio.imread(f"imageio:{filename}")
+
+ iw.set_data(data)
+
+ # set vmin, vmax sliders w.r.t. this new image
+ image = iw.managed_graphics[0]
+ vmin_vmax_slider.value = image.vmin, image.vmax
+ vmin_vmax_slider.min, vmin_vmax_slider.max = fpl.utils.quick_min_max(data)
+
+
+# connect the ipywidgets to the handler functions
+vmin_vmax_slider.observe(update_vmin_vmax, "value")
+slider_sigma.observe(update_sigma, "value")
+select_image.observe(update_image, "value")
+
+# display in a vbox
+VBox([iw.show(), vmin_vmax_slider, slider_sigma, select_image])
diff --git a/examples/ipywidgets/ipywidgets_sliders_line.py b/examples/ipywidgets/ipywidgets_sliders_line.py
new file mode 100644
index 000000000..8288e5719
--- /dev/null
+++ b/examples/ipywidgets/ipywidgets_sliders_line.py
@@ -0,0 +1,91 @@
+"""
+ipywidget sliders to modify a sine wave
+=======================================
+
+Example with ipywidgets sliders to change a sine wave and view the frequency spectra. You can run this in jupyterlab
+"""
+
+# test_example = false
+# sphinx_gallery_pygfx_docs = 'code'
+
+import numpy as np
+import fastplotlib as fpl
+from ipywidgets import FloatSlider, Checkbox, VBox
+
+
+def generate_data(freq, duration, sampling_rate, ampl, noise_sigma):
+ # generate a sine wave using given params
+ xs = np.linspace(0, duration, sampling_rate * duration)
+ ys = np.sin((2 * np.pi) * freq * xs) * ampl
+
+ noise = np.random.normal(scale=noise_sigma, size=sampling_rate * duration)
+
+ signal = np.column_stack([xs, ys + noise])
+ fft_mag = np.abs(np.fft.rfft(signal[:, 1]))
+ fft_freqs = np.linspace(0, sampling_rate / 2, num=fft_mag.shape[0])
+
+ return np.column_stack([xs, ys + noise]), np.column_stack([fft_freqs, fft_mag])
+
+
+signal, fft = generate_data(
+ freq=1,
+ duration=10,
+ sampling_rate=50,
+ ampl=1,
+ noise_sigma=0.05
+)
+
+# create a figure
+figure = fpl.Figure(shape=(2, 1), names=["signal", "fft"], size=(700, 560))
+
+# line graphic for the signal
+signal_line = figure[0, 0].add_line(signal, thickness=1)
+
+# easier to understand the frequency of the sine wave if the
+# axes go through the middle of the sine wave
+figure[0, 0].axes.intersection = (0, 0, 0)
+
+# line graphic for fft
+fft_line = figure[1, 0].add_line(fft)
+
+# do not maintain the aspect ratio of the fft subplot
+figure[1, 0].camera.maintain_aspect = False
+
+# create ipywidget sliders
+slider_freq = FloatSlider(min=0.1, max=10, value=1.0, step=0.1, description="freq: ")
+slider_ampl = FloatSlider(min=0.0, max=10, value=1.0, step=0.5, description="ampl: ")
+slider_noise = FloatSlider(min=0, max=1, value=0.05, step=0.05, description="noise: ")
+
+# checkbox
+checkbox_autoscale = Checkbox(value=False, description="autoscale: ")
+
+
+def update(*args):
+ # update whenever a slider changes
+ freq = slider_freq.value
+ ampl = slider_ampl.value
+ noise = slider_noise.value
+
+ signal, fft = generate_data(
+ freq=freq,
+ duration=10,
+ sampling_rate=50,
+ ampl=ampl,
+ noise_sigma=noise,
+ )
+
+ signal_line.data[:, :-1] = signal
+ fft_line.data[:, :-1] = fft
+
+ if checkbox_autoscale.value:
+ for subplot in figure:
+ subplot.auto_scale(maintain_aspect=False)
+
+
+# when any one slider changes, it calls update
+for slider in [slider_freq, slider_ampl, slider_noise]:
+ slider.observe(update, "value")
+
+# display the fastplotlib figure and ipywidgets in a VBox (vertically stacked)
+# figure.show() just returns an ipywidget object
+VBox([figure.show(), slider_freq, slider_ampl, slider_noise, checkbox_autoscale])
diff --git a/examples/line/line.py b/examples/line/line.py
index c460c84ac..f7839a1c4 100644
--- a/examples/line/line.py
+++ b/examples/line/line.py
@@ -16,32 +16,32 @@
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()
-# NOTE: `if __name__ == "__main__"` is NOT how to use fastplotlib interactively
-# please see our docs for using fastplotlib interactively in ipython and jupyter
+# 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_cmap.py b/examples/line/line_cmap.py
index b2fb39779..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
@@ -42,8 +42,8 @@
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
+# 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_cmap_more.py b/examples/line/line_cmap_more.py
index 37fd68cdb..c7c0d80f4 100644
--- a/examples/line/line_cmap_more.py
+++ b/examples/line/line_cmap_more.py
@@ -49,8 +49,8 @@
figure.show(maintain_aspect=False)
-# NOTE: `if __name__ == "__main__"` is NOT how to use fastplotlib interactively
-# please see our docs for using fastplotlib interactively in ipython and jupyter
+# 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_colorslice.py b/examples/line/line_colorslice.py
index 788aa342d..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,29 +62,29 @@
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: `if __name__ == "__main__"` is NOT how to use fastplotlib interactively
-# please see our docs for using fastplotlib interactively in ipython and jupyter
+# 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_dataslice.py b/examples/line/line_dataslice.py
index 92f33a109..6ef9d0d90 100644
--- a/examples/line/line_dataslice.py
+++ b/examples/line/line_dataslice.py
@@ -16,39 +16,39 @@
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: `if __name__ == "__main__"` is NOT how to use fastplotlib interactively
-# please see our docs for using fastplotlib interactively in ipython and jupyter
+# 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_collection/line_collection.py b/examples/line_collection/line_collection.py
index 75b56e61e..e3eea7392 100644
--- a/examples/line_collection/line_collection.py
+++ b/examples/line_collection/line_collection.py
@@ -39,8 +39,8 @@ def make_circle(center, radius: float, n_points: int = 75) -> np.ndarray:
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
+# 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_collection/line_collection_cmap_values.py b/examples/line_collection/line_collection_cmap_values.py
index c577609f9..59f456893 100644
--- a/examples/line_collection/line_collection_cmap_values.py
+++ b/examples/line_collection/line_collection_cmap_values.py
@@ -46,8 +46,8 @@ def make_circle(center, radius: float, n_points: int = 75) -> np.ndarray:
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
+# 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_collection/line_collection_cmap_values_qualitative.py b/examples/line_collection/line_collection_cmap_values_qualitative.py
index 7b1c0a419..399f4a93d 100644
--- a/examples/line_collection/line_collection_cmap_values_qualitative.py
+++ b/examples/line_collection/line_collection_cmap_values_qualitative.py
@@ -56,8 +56,8 @@ def make_circle(center, radius: float, n_points: int = 75) -> np.ndarray:
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
+# 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_collection/line_collection_colors.py b/examples/line_collection/line_collection_colors.py
index 1d9eff45d..b7b25e853 100644
--- a/examples/line_collection/line_collection_colors.py
+++ b/examples/line_collection/line_collection_colors.py
@@ -43,8 +43,8 @@ def make_circle(center, radius: float, n_points: int = 75) -> np.ndarray:
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
+# 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_collection/line_stack.py b/examples/line_collection/line_stack.py
index 95b681b76..4376c18b4 100644
--- a/examples/line_collection/line_stack.py
+++ b/examples/line_collection/line_stack.py
@@ -19,7 +19,9 @@
data = np.column_stack([xs, ys])
multi_data = np.stack([data] * 10)
-figure = fpl.Figure(size=(700, 560))
+figure = fpl.Figure(
+ size=(700, 560),
+)
line_stack = figure[0, 0].add_line_stack(
multi_data, # shape: (10, 100, 2), i.e. [n_lines, n_points, xy]
@@ -28,11 +30,12 @@
separation=1, # spacing between lines along the separation axis, default separation along "y" axis
)
+
figure.show(maintain_aspect=False)
-# NOTE: `if __name__ == "__main__"` is NOT how to use fastplotlib interactively
-# please see our docs for using fastplotlib interactively in ipython and jupyter
+# 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_collection/line_stack_3d.py b/examples/line_collection/line_stack_3d.py
index 35fe48ca9..b4548c1c6 100644
--- a/examples/line_collection/line_stack_3d.py
+++ b/examples/line_collection/line_stack_3d.py
@@ -101,8 +101,8 @@ def animate_colors(subplot):
figure[0, 0].camera.set_state(camera_state)
-# NOTE: `if __name__ == "__main__"` is NOT how to use fastplotlib interactively
-# please see our docs for using fastplotlib interactively in ipython and jupyter
+# 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/machine_learning/covariance.py b/examples/machine_learning/covariance.py
index 84c5bf531..d918cb6b4 100644
--- a/examples/machine_learning/covariance.py
+++ b/examples/machine_learning/covariance.py
@@ -14,7 +14,7 @@
from sklearn.preprocessing import StandardScaler
# load faces dataset
-faces = datasets.fetch_olivetti_faces()
+faces = datasets.fetch_olivetti_faces(n_retries=5, delay=20)
data = faces["data"]
# sort the data so it's easier to understand the covariance matrix
@@ -87,8 +87,8 @@ def animate():
iw.show()
-# NOTE: `if __name__ == "__main__"` is NOT how to use fastplotlib interactively
-# please see our docs for using fastplotlib interactively in ipython and jupyter
+# 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/machine_learning/kmeans.py b/examples/machine_learning/kmeans.py
new file mode 100644
index 000000000..f571882ce
--- /dev/null
+++ b/examples/machine_learning/kmeans.py
@@ -0,0 +1,125 @@
+"""
+K-Means Clustering of MNIST Dataset
+===================================
+
+Example showing how you can perform K-Means clustering on the MNIST dataset.
+
+Use WASD keys on your keyboard to fly through the data in PCA space.
+Use the mouse pointer to select points.
+"""
+
+# test_example = false
+# sphinx_gallery_pygfx_docs = 'screenshot'
+
+import fastplotlib as fpl
+import numpy as np
+from sklearn.datasets import load_digits
+from sklearn.cluster import KMeans
+from sklearn.decomposition import PCA
+
+# load the data
+mnist = load_digits()
+
+# get the data and labels
+data = mnist['data'] # (1797, 64)
+labels = mnist['target'] # (1797,)
+
+# visualize the first 5 digits
+# NOTE: this is just to give a sense of the dataset if you are unfamiliar,
+# the more interesting visualization is below :D
+fig_data = fpl.Figure(shape=(1, 5), size=(900, 300))
+
+# iterate through each subplot
+for i, subplot in enumerate(fig_data):
+ # reshape each image to (8, 8)
+ subplot.add_image(data[i].reshape(8, 8), cmap="gray", interpolation="linear")
+ # add the label as a title
+ subplot.title = f"Label: {labels[i]}"
+ # turn off the axes and toolbar
+ subplot.axes.visible = False
+ subplot.toolbar = False
+
+fig_data.show()
+
+# project the data from 64 dimensions down to the number of unique digits
+n_digits = len(np.unique(labels)) # 10
+
+reduced_data = PCA(n_components=n_digits).fit_transform(data) # (1797, 10)
+
+# performs K-Means clustering, take the best of 4 runs
+kmeans = KMeans(n_clusters=n_digits, n_init=4)
+# fit the lower-dimension data
+kmeans.fit(reduced_data)
+
+# get the centroids (center of the clusters)
+centroids = kmeans.cluster_centers_
+
+# plot the kmeans result and corresponding original image
+figure = fpl.Figure(
+ shape=(1, 2),
+ size=(700, 560),
+ cameras=["3d", "2d"],
+ controller_types=["fly", "panzoom"]
+)
+
+# set the axes to False in the image subplot
+figure[0, 1].axes.visible = False
+
+figure[0, 0].title = "k-means clustering of PCA-reduced data"
+figure[0, 1].title = "handwritten digit"
+
+# plot the centroids
+figure[0, 0].add_scatter(
+ data=np.vstack([centroids[:, 0], centroids[:, 1], centroids[:, 2]]).T,
+ colors="white",
+ sizes=15
+)
+# plot the down-projected data
+digit_scatter = figure[0,0].add_scatter(
+ data=np.vstack([reduced_data[:, 0], reduced_data[:, 1], reduced_data[:, 2]]).T,
+ sizes=5,
+ cmap="tab10", # use a qualitative cmap
+ cmap_transform=kmeans.labels_, # color by the predicted cluster
+)
+
+# initial index
+ix = 0
+
+# plot the initial image
+digit_img = figure[0, 1].add_image(
+ data=data[ix].reshape(8,8),
+ cmap="gray",
+ name="digit",
+ interpolation="linear"
+)
+
+# change the color and size of the initial selected data point
+digit_scatter.colors[ix] = "magenta"
+digit_scatter.sizes[ix] = 10
+
+
+# define event handler to update the selected data point
+@digit_scatter.add_event_handler("pointer_enter")
+def update(ev):
+ # reset colors and sizes
+ digit_scatter.cmap = "tab10"
+ digit_scatter.sizes = 5
+
+ # update with new seleciton
+ ix = ev.pick_info["vertex_index"]
+
+ digit_scatter.colors[ix] = "magenta"
+ digit_scatter.sizes[ix] = 10
+
+ # update digit fig
+ figure[0, 1]["digit"].data = data[ix].reshape(8, 8)
+
+
+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/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 e369b957c..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)
@@ -54,8 +54,8 @@ def cycle_colors(subplot):
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
+# 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/em_wave_animation.py b/examples/misc/em_wave_animation.py
index 06c60ccaf..f2b9f8de5 100644
--- a/examples/misc/em_wave_animation.py
+++ b/examples/misc/em_wave_animation.py
@@ -108,8 +108,8 @@ def tick(subplot):
figure[0, 0].add_animations(tick)
print(figure[0, 0]._fpl_graphics_scene.children)
-# NOTE: `if __name__ == "__main__"` is NOT how to use fastplotlib interactively
-# please see our docs for using fastplotlib interactively in ipython and jupyter
+# 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/image_animation.py b/examples/misc/image_animation.py
index bc5f83957..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)
@@ -30,8 +30,8 @@ def update_data(figure_instance):
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
+# 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/line3d_animation.py b/examples/misc/line3d_animation.py
index b26bfd3a0..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")
@@ -54,8 +54,8 @@ def move_marker():
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
+# 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/line_animation.py b/examples/misc/line_animation.py
index 07c6a7d94..86448a78b 100644
--- a/examples/misc/line_animation.py
+++ b/examples/misc/line_animation.py
@@ -43,8 +43,8 @@ def update_line(subplot):
figure.show(maintain_aspect=False)
-# NOTE: `if __name__ == "__main__"` is NOT how to use fastplotlib interactively
-# please see our docs for using fastplotlib interactively in ipython and jupyter
+# 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/lorenz_animation.py b/examples/misc/lorenz_animation.py
index 4d4c5129f..20aee5d83 100644
--- a/examples/misc/lorenz_animation.py
+++ b/examples/misc/lorenz_animation.py
@@ -86,8 +86,8 @@ def animate(subplot):
# set initial camera position to make animation in gallery render better
figure[0, 0].camera.world.z = 80
-# NOTE: `if __name__ == "__main__"` is NOT how to use fastplotlib interactively
-# please see our docs for using fastplotlib interactively in ipython and jupyter
+# 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/multiplot_animation.py b/examples/misc/multiplot_animation.py
index 18512add1..789ce744e 100644
--- a/examples/misc/multiplot_animation.py
+++ b/examples/misc/multiplot_animation.py
@@ -2,7 +2,7 @@
Multi-Subplot Image Update
==========================
-Example showing updating a multiple subplots with new random 512x512 data.
+Multiple subplots with an image that updates with new data on every render.
"""
# test_example = false
@@ -27,7 +27,7 @@
figure[1,1]["rand-img"].cmap = "spring"
# Define a function to update the image graphics with new data
-# add_animations will pass the gridplot to the animation function
+# add_animations will pass the figure to the animation function
def update_data(f):
for subplot in f:
new_data = np.random.rand(512, 512)
@@ -37,12 +37,12 @@ def update_data(f):
# add the animation function
figure.add_animations(update_data)
-# show the gridplot
+# show the figure
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
+# 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/scatter_animation.py b/examples/misc/scatter_animation.py
index d85a33e6a..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)
@@ -51,8 +51,8 @@ def update_points(subplot):
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
+# 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/scatter_sizes_animation.py b/examples/misc/scatter_sizes_animation.py
index 45782564d..53a616a68 100644
--- a/examples/misc/scatter_sizes_animation.py
+++ b/examples/misc/scatter_sizes_animation.py
@@ -40,8 +40,8 @@ def update_sizes(subplot):
figure.show(maintain_aspect=False)
-# NOTE: `if __name__ == "__main__"` is NOT how to use fastplotlib interactively
-# please see our docs for using fastplotlib interactively in ipython and jupyter
+# 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/simple_event.py b/examples/misc/simple_event.py
deleted file mode 100644
index e382f04b5..000000000
--- a/examples/misc/simple_event.py
+++ /dev/null
@@ -1,54 +0,0 @@
-"""
-Simple Event
-============
-
-Example showing how to add a simple callback event.
-"""
-
-# test_example = false
-# sphinx_gallery_pygfx_docs = 'screenshot'
-
-import fastplotlib as fpl
-import imageio.v3 as iio
-
-data = iio.imread("imageio:camera.png")
-
-# Create a figure
-figure = fpl.Figure(size=(700, 560))
-
-# plot sine wave, use a single color
-image_graphic = figure[0,0].add_image(data=data)
-
-# show the plot
-figure.show()
-
-
-# define callback function to print the event data
-def callback_func(event_data):
- print(event_data.info)
-
-
-# Will print event data when the color changes
-image_graphic.add_event_handler(callback_func, "cmap")
-
-image_graphic.cmap = "viridis"
-
-
-# adding a click event, we can also use decorators to add event handlers
-@image_graphic.add_event_handler("click")
-def click_event(event_data):
- # get the click location in screen coordinates
- xy = (event_data.x, event_data.y)
-
- # map the screen coordinates to world coordinates
- xy = figure[0,0].map_screen_to_world(xy)[:-1]
-
- # print the click location
- print(xy)
-
-
-# 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/misc/tooltips_custom.py b/examples/misc/tooltips_custom.py
new file mode 100644
index 000000000..3a54a945b
--- /dev/null
+++ b/examples/misc/tooltips_custom.py
@@ -0,0 +1,60 @@
+"""
+Tooltips Customization
+======================
+
+Customize the information displayed in a tooltip. This example uses the Iris dataset and sets the tooltip to display
+the species and cluster label of the point that is being hovered by the mouse pointer.
+"""
+
+# test_example = false
+# sphinx_gallery_pygfx_docs = 'screenshot'
+
+
+import fastplotlib as fpl
+from sklearn.cluster import AgglomerativeClustering
+from sklearn import datasets
+
+
+figure = fpl.Figure(size=(700, 560))
+
+dataset = datasets.load_iris()
+data = dataset["data"]
+
+agg = AgglomerativeClustering(n_clusters=3)
+agg.fit_predict(data)
+
+scatter = figure[0, 0].add_scatter(
+ data=data[:, :-1], # use only xy data
+ sizes=15,
+ cmap="Set1",
+ cmap_transform=agg.labels_ # use the labels as a transform to map colors from the colormap
+)
+
+
+def tooltip_info(pick_info: dict) -> str:
+ # get index of the scatter point that is being hovered
+ index = pick_info["vertex_index"]
+
+ # get the species name
+ target = dataset["target"][index]
+ cluster = agg.labels_[index]
+
+ # 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
+
+
+scatter.tooltip_format = tooltip_info
+
+figure.show()
+
+
+if __name__ == "__main__":
+ print(__doc__)
+ fpl.loop.run()
diff --git a/examples/notebooks/nb_test_utils.py b/examples/notebooks/nb_test_utils.py
index e1c32e0a0..9d99e3be3 100644
--- a/examples/notebooks/nb_test_utils.py
+++ b/examples/notebooks/nb_test_utils.py
@@ -94,6 +94,26 @@ def plot_test(name, fig: fpl.Figure):
if not TESTING:
return
+ # otherwise the first render is wrong
+ if fpl.IMGUI:
+ # there doesn't seem to be a resize event for the manual offscreen canvas
+ fig.imgui_renderer._backend.io.display_size = fig.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
+ fig.imgui_renderer.render()
+
+ fig._fpl_reset_layout()
+ # render each subplot
+ for subplot in fig:
+ subplot.viewport.render(subplot.scene, subplot.camera)
+
+ # flush pygfx renderer
+ fig.renderer.flush()
+
+ if fpl.IMGUI:
+ # render imgui
+ fig.imgui_renderer.render()
+
snapshot = fig.canvas.snapshot()
rgb_img = rgba_to_rgb(snapshot.data)
diff --git a/examples/notebooks/quickstart.ipynb b/examples/notebooks/quickstart.ipynb
index 09317110d..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\"]"
]
},
{
@@ -463,7 +463,7 @@
"id": "5694dca1-1041-4e09-a1da-85b293c5af47",
"metadata": {},
"source": [
- "### RGB images are also supported\n",
+ "### RGB(A) images are supported\n",
"\n",
"`cmap` arguments are ignored for rgb images, but vmin vmax still works"
]
@@ -538,7 +538,7 @@
"source": [
"### Image updates\n",
"\n",
- "This examples show how you can define animation functions that run on every render cycle."
+ "This example shows how you can define animation functions that run on every render cycle."
]
},
{
@@ -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 you need flexibility in the naming of the graphics"
+ "#### 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`"
]
},
{
@@ -628,7 +628,7 @@
"id": "d11fabb7-7c76-4e94-893d-80ed9ee3be3d",
"metadata": {},
"source": [
- "### You can also use `ipywidgets.VBox` and `HBox` to stack plots. See the `subplot` notebooks for more automated subplotting"
+ "### You can also use `ipywidgets.VBox` and `HBox` to stack plots."
]
},
{
@@ -664,7 +664,7 @@
"\n",
"## 2D line plots\n",
"\n",
- "This example plots a sine wave, cosine wave, and ricker wavelet and demonstrates how **Graphic Features** can be modified by slicing!"
+ "This example plots a sine wave, cosine wave, and ricker wavelet and demonstrates how **Graphic Properties** can be modified by slicing!"
]
},
{
@@ -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",
@@ -755,7 +753,7 @@
"\n",
"Set `maintain_aspect = False` on a camera, and then use the right mouse button and move the mouse to stretch and squeeze the view!\n",
"\n",
- "You can also click the **`1:1`** button to toggle this, or use `subplot.camera.maintain_aspect`"
+ "You can also click the **`⛶`** button to toggle this, or use `subplot.camera.maintain_aspect`"
]
},
{
@@ -763,7 +761,7 @@
"id": "1651e965-f750-47ac-bf53-c23dae84cc98",
"metadata": {},
"source": [
- "### reset the plot area"
+ "### reset the plot area camera"
]
},
{
@@ -783,27 +781,29 @@
"id": "dcd68796-c190-4c3f-8519-d73b98ff6367",
"metadata": {},
"source": [
- "## Graphic features support slicing! :D "
+ "## Graphic properties support slicing! :D\n",
+ "\n",
+ "Data, colors, and cmaps can often be sliced just like arrays to set or get values!"
]
},
{
"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)"
]
},
{
@@ -811,7 +811,7 @@
"id": "c9689887-cdf3-4a4d-948f-7efdb09bde4e",
"metadata": {},
"source": [
- "## You can capture changes to a graphic feature as events"
+ "## Graphic properties are _evented_, so you can capture when they change"
]
},
{
@@ -825,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\")"
]
},
{
@@ -837,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\""
]
},
{
@@ -864,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]])"
]
},
{
@@ -1354,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)"
]
@@ -1375,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\""
]
},
{
@@ -1386,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\""
]
},
{
@@ -1397,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"
]
},
{
@@ -1408,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)"
]
},
{
@@ -1419,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"
]
},
{
@@ -1430,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)"
]
},
{
@@ -1471,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)"
]
@@ -1496,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)"
@@ -1551,7 +1551,7 @@
" subplot.add_image(data, name=\"rand-img\")\n",
"\n",
"# Define a function to update the image graphics with new data\n",
- "# add_animations will pass the gridplot to the animation function\n",
+ "# add_animations will pass the figure to the animation function\n",
"def update_data(f):\n",
" for subplot in f:\n",
" new_data = np.random.rand(512, 512)\n",
@@ -1561,7 +1561,7 @@
"# add the animation function\n",
"figure_grid.add_animations(update_data)\n",
"\n",
- "# show the gridplot\n",
+ "# show the figure\n",
"figure_grid.show()"
]
},
@@ -1575,7 +1575,7 @@
}
},
"source": [
- "### Slicing GridPlot"
+ "### Slicing a grid layout to get subplots"
]
},
{
@@ -1605,7 +1605,7 @@
}
},
"source": [
- "You can get the graphics within a subplot, just like with simple `Plot`"
+ "You can get the graphics within a subplot"
]
},
{
@@ -1661,7 +1661,7 @@
}
},
"source": [
- "more slicing with `GridPlot`"
+ "more slicing with a `Figure` that has a grid layout"
]
},
{
@@ -1695,22 +1695,6 @@
"figure_grid[\"top-right-plot\"]"
]
},
- {
- "cell_type": "code",
- "execution_count": null,
- "id": "cb7566a5",
- "metadata": {
- "collapsed": false,
- "jupyter": {
- "outputs_hidden": false
- }
- },
- "outputs": [],
- "source": [
- "# view its position\n",
- "figure_grid[\"top-right-plot\"].position"
- ]
- },
{
"cell_type": "code",
"execution_count": null,
@@ -1723,7 +1707,7 @@
},
"outputs": [],
"source": [
- "# these are really the same\n",
+ "# these are the same\n",
"figure_grid[\"top-right-plot\"] is figure_grid[0, 2]"
]
},
@@ -1765,7 +1749,7 @@
}
},
"source": [
- "## Figure subplot customization"
+ "## Figure subplot customization in a grid layout"
]
},
{
@@ -1792,13 +1776,13 @@
"]\n",
"\n",
"\n",
- "# you can give string names for each subplot within the gridplot\n",
+ "# you can give string names for each subplot within the figure\n",
"names = [\n",
" [\"subplot0\", \"subplot1\", \"subplot2\"],\n",
" [\"subplot3\", \"subplot4\", \"subplot5\"]\n",
"]\n",
"\n",
- "# Create the grid plot\n",
+ "# Create the figure\n",
"figure_grid = fpl.Figure(\n",
" shape=shape,\n",
" controller_ids=controller_ids,\n",
@@ -1835,7 +1819,7 @@
}
},
"source": [
- "Indexing the gridplot to access subplots"
+ "Slicing/indexing the figure to get subplots"
]
},
{
@@ -1850,7 +1834,7 @@
},
"outputs": [],
"source": [
- "# can access subplot by name\n",
+ "# get subplot by name\n",
"figure_grid[\"subplot0\"]"
]
},
@@ -1866,7 +1850,7 @@
},
"outputs": [],
"source": [
- "# can access subplot by index\n",
+ "# or get subplot by index\n",
"figure_grid[0, 0]"
]
},
@@ -1880,7 +1864,7 @@
}
},
"source": [
- "**subplots also support indexing!**\n",
+ "**from before, remember subplots themselves also support slicing to get graphics within them!**\n",
"\n",
"this can be used to get graphics if they are named"
]
@@ -1901,6 +1885,17 @@
"figure_grid[\"subplot0\"][\"rand-image\"]"
]
},
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "87905450bdc0ec0a",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# or by their numerical index\n",
+ "figure_grid[\"subplot0\"].graphics[0]"
+ ]
+ },
{
"cell_type": "code",
"execution_count": null,
@@ -1927,7 +1922,7 @@
}
},
"source": [
- "positional indexing also works event if subplots have string names"
+ "positional indexing also works even if subplots have string names"
]
},
{
@@ -1982,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 32b09caf9..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:8d9e2b0479d3de1c12764b984679dba83a1876ea6a88c072789a0e06f957ca2a
-size 70655
+oid sha256:dbcc22edca9b0dd74da7754a8515c9000d6e651af39135ab90d5a1eaf438d324
+size 66559
diff --git a/examples/notebooks/screenshots/nb-astronaut_RGB.png b/examples/notebooks/screenshots/nb-astronaut_RGB.png
index be498bb6d..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:e2d02877510191e951d38d03a6fe9d31f5c0c335913876c65b175c4bb1a9c0e1
-size 69942
+oid sha256:1b8f9fc3d5e14c1f90c48128be5599d64e994dcce8579bf9276453d68dfdf022
+size 64354
diff --git a/examples/notebooks/screenshots/nb-camera.png b/examples/notebooks/screenshots/nb-camera.png
index 3e9a518f9..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:5271c2204a928185b287c73c852ffa06b708d8d6a33de09acda8d2ea734e78c5
-size 51445
+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 8c353442a..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:4d8563587c4f642d5e4edb34f41b569673d7ea71bcbafdb734369272776baeef
-size 62316
+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 22c7ad73a..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:9b122f0ba9bfff0b0868778f09744870238bf7b4945e57410b6aa36341eaaf4a
-size 116781
+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 22c7ad73a..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:9b122f0ba9bfff0b0868778f09744870238bf7b4945e57410b6aa36341eaaf4a
-size 116781
+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 84e2514d0..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:fcc5092f35c881da4a9b9f3c216fb608b8dfc27a791b83e0d5184ef3973746cf
-size 139375
+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 075116ff4..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:3fabd9d52ae2815ae883a4c8c8a8b1385c0824e0212347896a09eb3600c29430
-size 124238
+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 216ae2b9e..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:86ad31cab3aefa24a1c4c0adc2033cbc9fa594e9cf8ab8e4a6ff0a3630bb7896
-size 109041
+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 99302d4e6..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:3ebf4e875199c7e682dc15aa03a36ea9f111618853a94076064b055bf6ce788e
-size 101209
+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 3bb5081f0..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:d8dbf6b76818315e40d9d4cc97807c4276c27e7a9a09d2643f74adf701ef1cdc
-size 123136
+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 3bb5081f0..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:d8dbf6b76818315e40d9d4cc97807c4276c27e7a9a09d2643f74adf701ef1cdc
-size 123136
+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 48ab5d6fe..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:c65e2dc4276393278ab769706f22172fd71e38eeb3c9f4d70fa51de31820f1d1
-size 234012
+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 5e1cb8cc1..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:7d4e4edf1429a135bafb7c1c927ea87f78a93fb5f3e0cbee2fb5c156af88d5a0
-size 220490
+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 ec2911374..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:39adce1898e5b00ccf9d8792bd4e76f2da2591a8c3f6e201a5c2af1f814d37cb
-size 58692
+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 ae72c8175..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:d50b960c66acc6672aaeb6a97f6a69aad14f9e54060c3702679d6a5bf2b70e78
-size 70582
+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 66f9136dc..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:d244a8a91d04380f2ebe255b2b56b3be5249c0a382821877710cae6bdaa2d414
-size 128643
+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 230e71c0f..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:24c991457b8b081ee271cbdb03821ea1024c8340db92340e0e445bf3c70aba40
-size 97903
+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 a355670a0..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:bdd62a9bd1ca4f1ff110a30fb4064d778f02120295a3e3d30552e06892146e40
-size 93658
+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 c47545ccb..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:db7e2cf15ff3ce61b169b114c630e2339c1c6b5c687c580e1ee6964785df6790
-size 74844
+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 69ef49149..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:64d2d3fd91ac8e10736add5a82a312927ae6f976119cfa2aaa1fc1e008bcf6f1
-size 66038
+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 bb04d1800..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:8d2a805c85e1cdf5bd2d995600b033019680ac645d7634efeaf1db7d0d00d4aa
-size 79403
+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 5b1a4a8da..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:440623bb4588994c4f52f17e027af29d1f2d5d330c5691630fd2acb9e38f8a25
-size 99033
+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 bd72160dd..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:9ee56adf8f2a516ef74a799e9e503f980c36c4dfb41f9ff6d8168cfcf65ad092
-size 132745
+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 438d1e2d4..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:de4733b82c2e77baa659582401eff0c70ec583c50462b33bcbfd2bb00ceaa517
-size 102959
+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 ee081c6df..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:6107f108b5a86ba376c53f5e207841c01a85b686100efb46e5df3565127201d2
-size 106765
+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 c2071c850..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:caa15f6bc21a3be0f480b442414ec4b96b21cc1de2cdcb891b366692962d4ef8
-size 100753
+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 3d90fd77a..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:4e23288d695a5a91188b285f6a0a2c9f0643dd19f3d6dedb56f4389f44ed1f44
-size 98621
+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 3fd5688d9..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:8b4e1bb60466d7553b4d1afc14015b7c4edc6e79c724c0afb5acd123a10093d0
-size 105541
+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 048078520..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:33ce1260b4715b3d28ba28d0ad4c5eb94c9997bdc1676ff6208121e789e168a5
-size 99287
+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 ade8fb483..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:5e08f4e4cb3330fbbbf827af56c02039af3b293036c7676f2a87c309ad07f2f6
-size 99759
+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 14d9e8448..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:3aad82db14f8100f669d2ad36b5bc3973b7c12457adfdd73adbc81c759338f7b
-size 80964
+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 af04a6f73..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:e40559eea03790315718c55b4ec4976aacb97a2f07bcdc49d917c044745687c2
-size 117144
+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 7f530e554..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:414ebe9a0b2bc4eb1caa4b4aeef070955c662bb691899c4b2046be3e2ca821e3
-size 113649
+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 e2f6b8318..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:ea6d0c4756db434af6e257b7cd809f1d49089eca6b9eae9e347801e20b175686
-size 113631
+oid sha256:33e57f3dba3a865f433ddc4c613f302f7b5b34053b5eb05d0b9c6d5003ef6194
+size 109368
diff --git a/examples/notebooks/screenshots/nb-lines-3d.png b/examples/notebooks/screenshots/nb-lines-3d.png
index 2e26a8cd7..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:857eb528b02fd7dd4b9f46ce1e65942066736f1bdf5271db141d73a0abab82b0
-size 19457
+oid sha256:e09b9a15d83d3c0ca45a136642f5c9da0fcf2b5fee494a522fbbe9fa6b9db9a5
+size 18097
diff --git a/examples/notebooks/screenshots/nb-lines-colors.png b/examples/notebooks/screenshots/nb-lines-colors.png
index 1e13983f3..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:6681a1e5658c1f2214217dcb7321cad89c7a0a3fd7919296a1069f27f1a7ee92
-size 35381
+oid sha256:a40192f07f4bce34832aae2c11b28806547e15fccf543e90c09f457954ef422f
+size 31081
diff --git a/examples/notebooks/screenshots/nb-lines-data.png b/examples/notebooks/screenshots/nb-lines-data.png
index a7e8287ef..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:043d8d9cd6dfc7627a6ccdb5810efd4b1a15e8880a4e30c0f558ae4d67c2f470
-size 42410
+oid sha256:c790c3b23101a04bbab300b55d6bfdd1421fb0a0e81c5bff5f75a271e32ccab9
+size 37643
diff --git a/examples/notebooks/screenshots/nb-lines-underlay.png b/examples/notebooks/screenshots/nb-lines-underlay.png
index c2908d479..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:c52ac60ffc08005d1f1fcad1b29339a89a0f31b58c9ca692f9d93400e7c8ac9e
-size 48540
+oid sha256:fda16460bb690ef30bfc43a3cbb4336f5bbd190ef592381269cde5f250ae7a36
+size 50977
diff --git a/examples/notebooks/screenshots/nb-lines.png b/examples/notebooks/screenshots/nb-lines.png
index f4a4d58b1..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:2cef0e2fb84e985f8d9c18f77817fb3eba31bd30b8fa4c54bb71432587909458
-size 30075
+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 a1e524e2a..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:915f6c4695c932dc2aa467be750e58a0435fe86fe0e0fa5a52c6065e05ec3193
-size 85456
+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 ec3208e01..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:31cfa60229a4e297be507a8888e08d6950c2a7d4b323d34774c9462419272ada
-size 84284
+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 31b60d9c0..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:800845fae18093945ed921237c8756b1afa31ee391fe679b03c57a67929e4ba9
-size 60087
+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 35c777e6a..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:f4253362c0908e0d983542be3691a3d94f27a0319fb9e7183315c77891dac140
-size 23232
+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 b8e34aab3..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:dc95d6291d06ab64d142ba0048318caefa28b404bb4b31635df075dc651eaa08
-size 37276
+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 8f58dbc6d..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:b8aa0b8303f0a69609198ea312800fc0eb98007c18d0ebc37672a9cf4f1cbaff
-size 46780
+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 b33cde5a6..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:822410f43d48d12e70930b5b581bafe624ea72475d53ca0d98cdaa5649338c63
-size 51849
+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 5d7e704ca..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:8e3ba744fcfa43df839fddce88f79fb8d7c5eafdd22f271e6b885e09b8891072
-size 31222
+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.py b/examples/scatter/scatter.py
index afb0a0b81..838199ecb 100644
--- a/examples/scatter/scatter.py
+++ b/examples/scatter/scatter.py
@@ -41,8 +41,8 @@
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
+# 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_cmap.py b/examples/scatter/scatter_cmap.py
index 8810e3d7b..3c7bd0e21 100644
--- a/examples/scatter/scatter_cmap.py
+++ b/examples/scatter/scatter_cmap.py
@@ -43,8 +43,8 @@
figure[0, 0].graphics[0].cmap = "viridis"
-# NOTE: `if __name__ == "__main__"` is NOT how to use fastplotlib interactively
-# please see our docs for using fastplotlib interactively in ipython and jupyter
+# 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_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 cf7472361..5b18d2e0e 100644
--- a/examples/scatter/scatter_colorslice.py
+++ b/examples/scatter/scatter_colorslice.py
@@ -36,18 +36,20 @@
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: `if __name__ == "__main__"` is NOT how to use fastplotlib interactively
-# please see our docs for using fastplotlib interactively in ipython and jupyter
+# 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_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.py b/examples/scatter/scatter_dataslice.py
index 840553237..7a30d6f70 100644
--- a/examples/scatter/scatter_dataslice.py
+++ b/examples/scatter/scatter_dataslice.py
@@ -32,8 +32,8 @@
scatter1.data[:500] = np.array([0 , 0, 0])
scatter2.data[500:] = np.array([0 , 0, 0])
-# NOTE: `if __name__ == "__main__"` is NOT how to use fastplotlib interactively
-# please see our docs for using fastplotlib interactively in ipython and jupyter
+# 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_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 e000d5a0a..b9df16026 100644
--- a/examples/scatter/scatter_iris.py
+++ b/examples/scatter/scatter_iris.py
@@ -6,30 +6,42 @@
"""
# 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()
-# NOTE: `if __name__ == "__main__"` is NOT how to use fastplotlib interactively
-# please see our docs for using fastplotlib interactively in ipython and jupyter
+# 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_size.py b/examples/scatter/scatter_size.py
index 73be31f62..30d3e6ea3 100644
--- a/examples/scatter/scatter_size.py
+++ b/examples/scatter/scatter_size.py
@@ -2,7 +2,10 @@
Scatter Plot Size
=================
-Example showing point size change for scatter plot.
+Example that shows how to set scatter sizes in two different ways.
+
+One subplot uses a single scaler value for every point, and another subplot uses an array that defines the size for
+each individual scatter point.
"""
# test_example = true
@@ -14,10 +17,10 @@
# figure with 2 rows and 3 columns
shape = (2, 1)
-# you can give string names for each subplot within the gridplot
+# you can give string names for each subplot within the figure
names = [["scalar_size"], ["array_size"]]
-# Create the grid plot
+# Create the figure
figure = fpl.Figure(shape=shape, names=names, size=(700, 560))
# get y_values using sin function
@@ -40,8 +43,8 @@
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
+# 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_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
new file mode 100644
index 000000000..89e74eaec
--- /dev/null
+++ b/examples/scatter/spinning_spiral.py
@@ -0,0 +1,86 @@
+"""
+Spinning spiral scatter
+=======================
+
+Example of a spinning spiral scatter.
+
+This example with 1 million points runs at 125 fps on an AMD RX 570.
+"""
+
+# test_example = false
+# sphinx_gallery_pygfx_docs = 'animate 15s'
+
+import numpy as np
+import fastplotlib as fpl
+
+# number of points
+n = 100_000
+
+# create data in the shape of a spiral
+phi = np.linspace(0, 30, n)
+
+xs = phi * np.cos(phi) + np.random.normal(scale=1.5, size=n)
+ys = np.random.normal(scale=1, size=n)
+zs = phi * np.sin(phi) + np.random.normal(scale=1.5, size=n)
+
+data = np.column_stack([xs, ys, zs])
+
+# generate some random sizes for the points
+sizes = np.abs(np.random.normal(loc=0, scale=1, size=n))
+
+figure = fpl.Figure(
+ cameras="3d",
+ size=(700, 560),
+ canvas_kwargs={"max_fps": 500, "vsync": False}
+)
+
+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))
+
+
+def update():
+ # rotate around y axis
+ spiral.rotate(0.005, axis="y")
+
+ # add small jitter
+ spiral.data[:] += jitter
+ # shift array to provide a random-sampling effect
+ # without re-running a random generator on each iteration
+ # generating 1 million normally distributed points takes ~50ms even with SFC64
+ jitter[1000:] = jitter[:-1000]
+ jitter[:1000] = jitter[-1000:]
+
+
+figure.add_animations(update)
+figure.show()
+
+# pre-saved camera state
+camera_state = {
+ 'position': np.array([-0.13046005, 20.09142224, 29.03347696]),
+ 'rotation': np.array([-0.44485092, 0.05335406, 0.11586037, 0.88647469]),
+ 'scale': np.array([1., 1., 1.]),
+ 'reference_up': np.array([0., 1., 0.]),
+ 'fov': 50.0,
+ 'width': 62.725074768066406,
+ 'height': 8.856056690216064,
+ 'zoom': 0.75,
+ 'maintain_aspect': True,
+ 'depth_range': None
+}
+
+figure[0, 0].camera.set_state(camera_state)
+figure[0, 0].axes.visible = False
+
+
+if fpl.IMGUI:
+ # show fps with imgui overlay
+ figure.imgui_show_fps = 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/screenshots/extent_frac_layout.png b/examples/screenshots/extent_frac_layout.png
new file mode 100644
index 000000000..1ee20fa64
--- /dev/null
+++ b/examples/screenshots/extent_frac_layout.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:23cd21882c3599d7b911c70df62d38aac9bc2d180dbbf05dd40b9561c6c2329a
+size 144438
diff --git a/examples/screenshots/extent_layout.png b/examples/screenshots/extent_layout.png
new file mode 100644
index 000000000..27eb67784
--- /dev/null
+++ b/examples/screenshots/extent_layout.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:e5316e61ae1fb48acc439f38f77306d17834e8dd295c98a58980a803354651dd
+size 117570
diff --git a/examples/screenshots/gridplot.png b/examples/screenshots/gridplot.png
index 1a222affd..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:8de769538bb435b71b33e038998b2bafa340c635211c0dfc388c7a5bf55fd36d
-size 286794
+oid sha256:51c44285622a9abd974a7d5abcc5506778252fbcdf68d874a73aa58e820b9655
+size 260627
diff --git a/examples/screenshots/gridplot_non_square.png b/examples/screenshots/gridplot_non_square.png
index 45d71abb2..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:92f55da7e2912a68e69e212b31df760f27e72253ec234fe1dd5b5463b60061b3
-size 212647
+oid sha256:49b726b8a48445936ed6ecd104a7221d11549efe09a75c9deb44679a4fe0b195
+size 198943
diff --git a/examples/screenshots/gridplot_viewports_check.png b/examples/screenshots/gridplot_viewports_check.png
new file mode 100644
index 000000000..77793538a
--- /dev/null
+++ b/examples/screenshots/gridplot_viewports_check.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:d1b31b17a2ea1d13560752a9881b0698e5edb46d9271d91cce47d2becf267888
+size 46471
diff --git a/examples/screenshots/heatmap.png b/examples/screenshots/heatmap.png
index a63eb5ec8..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:1f2f0699e01eb12c44a2dbefd1d8371b86b3b3456b28cb5f1850aed44c13f412
-size 94505
+oid sha256:4ff60beab679b2e922cee1ce2525a65e14f599bf54344a4b3457c57d7afd054f
+size 89365
diff --git a/examples/screenshots/image_cmap.png b/examples/screenshots/image_cmap.png
index 6f7081b03..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:e1482ce72511bc4f815825c29fabac5dd0f2586ac4c827a220a5cecb1162be4d
-size 210019
+oid sha256:c7c252a2af881092557ec77f00832a55a2a7fc5b7bd6bb940050bb7054daff14
+size 209290
diff --git a/examples/screenshots/image_rgb.png b/examples/screenshots/image_rgb.png
index 88beb7df3..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:8210ad8d1755f7819814bdaaf236738cdf1e9a0c4f77120aca4968fcd8aa8a7a
-size 239431
+oid sha256:c6842168344b5eda6d33f5a0bfc7524bdada6faee333597f11684ab35ae9bba3
+size 234360
diff --git a/examples/screenshots/image_rgbvminvmax.png b/examples/screenshots/image_rgbvminvmax.png
index f3ef59d84..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:8ebbcc4a2e83e9733eb438fe2341f77c86579421f3fa96b6a49e94073c0ffd32
-size 48270
+oid sha256:bf2310bab00658375cee4f0c9627c280afa59f9bbae06a1447ba5ae4188885c6
+size 43386
diff --git a/examples/screenshots/image_simple.png b/examples/screenshots/image_simple.png
index 0c7e011f4..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:44bc2d1fd97921fef0be45424f21513d5d978b807db8cf148dfc59c07f6e292f
-size 211333
+oid sha256:0fca2ebadff656b3e89e67e9288c4c2eb33b0933c9913ec615d62a167579ccdf
+size 209081
diff --git a/examples/screenshots/image_small.png b/examples/screenshots/image_small.png
index 41a4a240e..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:079ee6254dc995cc5fc8c20ff1c00cb0899f21ba2d5d1a4dc0d020c3a71902c4
-size 13022
+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 f3ef59d84..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:8ebbcc4a2e83e9733eb438fe2341f77c86579421f3fa96b6a49e94073c0ffd32
-size 48270
+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 af248dd3e..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:e2ae1938c5e7b742fb2dac0336877028f6ece26cd80e84f309195a55601025cb
-size 197495
+oid sha256:4164e333375b56b1e9ea8264df820709f4cea5844460bc544e2b6dda9c26bd9a
+size 188062
diff --git a/examples/screenshots/image_widget_grid.png b/examples/screenshots/image_widget_grid.png
index e0f0ff5c8..db24f0148 100644
--- a/examples/screenshots/image_widget_grid.png
+++ b/examples/screenshots/image_widget_grid.png
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:eeb5b86e7c15dfe2e71267453426930200223026f72156f34ff1ccc2f9389b6e
-size 253769
+oid sha256:fe4a0dd3f7140a652ff82b49aa2090b8b1fd0641dc45be0a2f1a5f788ce113f2
+size 242559
diff --git a/examples/screenshots/image_widget_imgui.png b/examples/screenshots/image_widget_imgui.png
index 135a0d4c4..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:7e2cd0e3892377e6e2d552199391fc64aac6a02413168a5b4c5c4848f3390dec
-size 166265
+oid sha256:683262b7317cc3facd9b5906d3d79c177e2cec8a85dc6143c8cfceda5a6970d3
+size 173641
diff --git a/examples/screenshots/image_widget_single_video.png b/examples/screenshots/image_widget_single_video.png
index 5d10d91a6..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:de1750c9c1c3cd28c356fb51687f4a8f00afb3cc7e365502342168fce8459d3a
-size 90307
+oid sha256:205d3a66cc30663459f282bdde263ff1fe6358abcdaa602412e85d746b86ca78
+size 93048
diff --git a/examples/screenshots/image_widget_videos.png b/examples/screenshots/image_widget_videos.png
index f0e262e24..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:23d993e0b5b6bcfe67da7aa4ceab3f06e99358b00f287b9703c4c3bff19648ba
-size 169541
+oid sha256:833063e4af85870420108f51f8c2e4accfb645e2c16eefb2aab6c8d3c951938a
+size 310096
diff --git a/examples/screenshots/image_widget_viewports_check.png b/examples/screenshots/image_widget_viewports_check.png
new file mode 100644
index 000000000..31e30edf3
--- /dev/null
+++ b/examples/screenshots/image_widget_viewports_check.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:93980b00802158ea9eeb74828adb7ae63a03b8339335e2771a39117470788b19
+size 82255
diff --git a/examples/screenshots/imgui_basic.png b/examples/screenshots/imgui_basic.png
index 27288e38f..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:3391b7cf02fc7bd2c73dc57214b21ceaca9a1513556b3a4725639f21588824e4
-size 36261
+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 492ea2ada..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:1458d472362f8d5bcef599fd64f931997a246f9e7649c80cc95f465cbd858850
-size 170243
+oid sha256:10a0c3dadb2b161f51c7dc6efada4c812dddaf17888ead4ddf31f6fd9846c9f4
+size 152396
diff --git a/examples/screenshots/line_cmap.png b/examples/screenshots/line_cmap.png
index 10779fcd5..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:66e64835f824d80dd7606d90530517dbc320bcc11a68393ab92c08fef3d23f5a
-size 48828
+oid sha256:d103a8a054254a093500c16e8feb5f7786b2d304fd0316b7bd9c98a3a7c34949
+size 37964
diff --git a/examples/screenshots/line_cmap_more.png b/examples/screenshots/line_cmap_more.png
index 56e3fe8cc..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:de08452e47799d9afcadfc583e63da1c02513cf73000bd5c2649236e61ed6b34
-size 126725
+oid sha256:546dc7884850f620c789b103ab3fceb2d154cd78fafea427d6f380b1ec6c1160
+size 90061
diff --git a/examples/screenshots/line_collection.png b/examples/screenshots/line_collection.png
index d9124daf1..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:50920f4bc21bb5beffe317777a20d8d09f90f3631a14df51c219814d3507c602
-size 100758
+oid sha256:232820431835de3a7795724827e8a179e5e47e32d7fabbef702b57497f16a917
+size 69488
diff --git a/examples/screenshots/line_collection_cmap_values.png b/examples/screenshots/line_collection_cmap_values.png
index e04289699..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:850e3deb2220d44f01e6366ee7cffb83085cad933a137b9838ce8c2231e7786a
-size 64152
+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 710cee119..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:ba5fefc8e1043fe0ebd926a6b8e6ab19e724205a4c13e4d7740122cfe464e38b
-size 67017
+oid sha256:964d2bccf0bd64de80c0617ddbf432ae3006ba27823a9b2107d42a2d4d6173b5
+size 44534
diff --git a/examples/screenshots/line_collection_colors.png b/examples/screenshots/line_collection_colors.png
index 6c1d05f04..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:17d48f07310090b835e5cd2e6fa9c178db9af8954f4b0a9d52d21997ec229abd
-size 57778
+oid sha256:d3475c7485cc00633208be388b148564aea1d1883786e5402ea03bb576a8483f
+size 33098
diff --git a/examples/screenshots/line_collection_slicing.png b/examples/screenshots/line_collection_slicing.png
index abb63760f..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:ed0d4fdb729409d07ec9ec9e05d915a04ebb237087d266591e7f46b0838e05b3
-size 130192
+oid sha256:651dd9c28fb6f4548384571bb011a4e2dc8591d6da1cc0f3fb0d5defe0fcd498
+size 75078
diff --git a/examples/screenshots/line_colorslice.png b/examples/screenshots/line_colorslice.png
index 1f100d89e..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:1b2c5562f4150ec69029a4a139469b0a2524a14078b78055df40d9b487946ce5
-size 57037
+oid sha256:9dd1fab17becdae1b118f9a19e32a4f5b2ca9945da0db00394faef26fa9a6c46
+size 43271
diff --git a/examples/screenshots/line_dataslice.png b/examples/screenshots/line_dataslice.png
index b2f963195..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:c31a12afa3e66c442e370e6157ad9a5aad225b21f0f95fb6a115066b1b4f2e73
-size 68811
+oid sha256:ed1d8c7d20dc80433d7bf167eb769e96d49e47ceaf380b7e3602e7c8331e2b99
+size 44842
diff --git a/examples/screenshots/line_stack.png b/examples/screenshots/line_stack.png
index 786f434be..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:fcfa7c49d465ff9cfe472ee885bcc9d9a44106b82adfc151544847b95035d760
-size 121640
+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 9d2371403..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:1f12310c09c4e84ea2c6f8245d1aa0ce9389a3d9637d7d4f9dc233bea173a0e3
-size 95366
+oid sha256:6243ceed25fbb7932c3b5f8a6400c6049bdb074a7dddfab5ed620e75c1e39680
+size 62841
diff --git a/examples/screenshots/linear_selector.png b/examples/screenshots/linear_selector.png
index 2db42319d..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:09f60f24e702dd6b17ba525604c1a04f23682eb08c8c2100d45a34b2626bebc6
-size 153115
+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
new file mode 100644
index 000000000..e19263a19
--- /dev/null
+++ b/examples/screenshots/no-imgui-extent_frac_layout.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:c2994279094daf35ec203df4b7951e496aaf12767d570f81a792df1172a412a8
+size 144324
diff --git a/examples/screenshots/no-imgui-extent_layout.png b/examples/screenshots/no-imgui-extent_layout.png
new file mode 100644
index 000000000..d400171a2
--- /dev/null
+++ b/examples/screenshots/no-imgui-extent_layout.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:5ffa8ba36146fefb70ffb7c0555f0068c159bbca93fe3736bb73437c25ca8dd4
+size 128833
diff --git a/examples/screenshots/no-imgui-gridplot.png b/examples/screenshots/no-imgui-gridplot.png
index 45571161d..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:a27ccf2230628980d16ab22a17df64504268da35a27cd1adb44102e64df033af
-size 329247
+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 f8c307c22..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:58f50c4fc1b00c9e78c840193d1e15d008b9fe1e7f2a3d8b90065be91e2178f5
-size 236474
+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
new file mode 100644
index 000000000..61f0c5148
--- /dev/null
+++ b/examples/screenshots/no-imgui-gridplot_viewports_check.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:8e5f030bb76739370c581715277e3da47d38ade0a041eec9b5e0d9ff9e311115
+size 41800
diff --git a/examples/screenshots/no-imgui-heatmap.png b/examples/screenshots/no-imgui-heatmap.png
index 3d1cf5ef2..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:4fac55efd9339b180b9e34d5cf244c473d6439e57e34f272c1a7e59183f1afa2
-size 98573
+oid sha256:86ba296fc5282f664a9057eb29f99c20601c025fd937540c95f60465510ece96
+size 92928
diff --git a/examples/screenshots/no-imgui-image_cmap.png b/examples/screenshots/no-imgui-image_cmap.png
index 6c565ca2b..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:82f7176a61e2c6953c22171bea561845bb79cb8179d76b20eef2b2cc475bbb23
-size 237327
+oid sha256:e1851126cacf5482954f5ce3933cbcbc3253f2ff1cc6c103de69eb42cec9061e
+size 224864
diff --git a/examples/screenshots/no-imgui-image_rgb.png b/examples/screenshots/no-imgui-image_rgb.png
index 355238724..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:fce532d713d2c664eb3b676e0128060ebf17241387134812b490d3ad398d42c2
-size 269508
+oid sha256:61092536672c350a27f6b41a6ea9da1d5154dead93a244b2c7fc8550870c69a8
+size 250491
diff --git a/examples/screenshots/no-imgui-image_rgbvminvmax.png b/examples/screenshots/no-imgui-image_rgbvminvmax.png
index 6282f2438..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:42e01469f0f7da37d3c1c90225bf7c03c44badd1f3612ac9bf88eaed5eeb6850
-size 50145
+oid sha256:1d5ff797c68119cbe7ea5c6ec03aa95c38b80ffce3d48c15c1f77fad2a858d42
+size 43103
diff --git a/examples/screenshots/no-imgui-image_simple.png b/examples/screenshots/no-imgui-image_simple.png
index d00a166ce..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:c8bb29f192617b9dde2490ce36c69bd8352b6ba5d068434bc53edaad91871356
-size 237960
+oid sha256:aae7bfe952402c9a0d4e9ace56736eea1e9ea7900ab90289bc62605ba33aca11
+size 223774
diff --git a/examples/screenshots/no-imgui-image_small.png b/examples/screenshots/no-imgui-image_small.png
index aca14cd69..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:e1ea4bcf76158169bc06973457ea09997c13ecd4a91e6e634566beb31348ef68
-size 13194
+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 6282f2438..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:42e01469f0f7da37d3c1c90225bf7c03c44badd1f3612ac9bf88eaed5eeb6850
-size 50145
+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 29610c612..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:709458b03d535bcf407fdae1720ccdcd11a5f79ccf673e85c7e64c5748f6d25e
-size 173422
+oid sha256:8fe7456b142edf45e8f51f82ea7a80af2152a9d22b485d77804e56bc1768412f
+size 154361
diff --git a/examples/screenshots/no-imgui-line_cmap.png b/examples/screenshots/no-imgui-line_cmap.png
index 9340e191e..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:69426f5aac61e59a08764626b2aded602e576479e652d76b6b3bf646e3218cc1
-size 48028
+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 f0cea4ec1..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:df9a2ef9d54b417e0387116eb6e6215c54b7c939867d0d62c768768baae27e5f
-size 129510
+oid sha256:0772acc1c85b3c23dfa7bd17c2b9208d7e2705b17c22e7b8c0000b4f0273f9f0
+size 90814
diff --git a/examples/screenshots/no-imgui-line_collection.png b/examples/screenshots/no-imgui-line_collection.png
index ca74d3362..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:90f281301e8b23a22a5333e7b34316475907ac25ffc9a23b7395b7431c965343
-size 106518
+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 df237aa1b..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:3f5a7257d121a15a8a35ca6e9c70de9d6fbb4977221c840dd34e25e67136f4ea
-size 67209
+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 0347f7361..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:89a7bc62495e6454ee008e15f1504211777cc01e52f303c18f6068fd38ab3c12
-size 70090
+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 dff4f83db..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:78b14e90e5ae1e185abb51d94ac9d99c1a4318b0ddf79c26a55e6061f22c0ed9
-size 60447
+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 70c343361..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:d6b4090d3ae9e38256c9f04e17bf2499f0a35348552f62e9c8d8dc97c9e760a7
-size 132125
+oid sha256:7c7e6d0a301ab18640ae98867610549af33ef2b9e89b76d306a5e0d276906e99
+size 73997
diff --git a/examples/screenshots/no-imgui-line_colorslice.png b/examples/screenshots/no-imgui-line_colorslice.png
index 3befac6da..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:9f161ad7f351b56c988e1b27155e3963be5191dc09cbaa55615026d07df07334
-size 56338
+oid sha256:424040bc42e702b0adc4da95c854edbfbb8c15b598fb9254d4daa9590e9ea0ad
+size 41087
diff --git a/examples/screenshots/no-imgui-line_dataslice.png b/examples/screenshots/no-imgui-line_dataslice.png
index 957462d09..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:d2f737e0afd8f57c7d621197d37fcf30199086f6c083ec0d3d8e5497965e6d12
-size 67938
+oid sha256:cf7cca31763750345014ee4314e915e2cd76245ae9d489a05a9983235a932df2
+size 43566
diff --git a/examples/screenshots/no-imgui-line_stack.png b/examples/screenshots/no-imgui-line_stack.png
index 26f4a3af8..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:4dd69dc4be7a2283ec11a8427a75a2ddfe4be0cdbbdaedef3dcbf5f567c11ea7
-size 130519
+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 9871d65c1..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:747b0915eeaf5985346e3b6807a550da53b516769d2517d7c2e0f189baefef91
-size 100604
+oid sha256:bbdc5f9047465d256ee1358bf9f1ecc679f1f7484b50f369442d64bf7f18da3a
+size 60305
diff --git a/examples/screenshots/no-imgui-linear_selector.png b/examples/screenshots/no-imgui-linear_selector.png
new file mode 100644
index 000000000..1580027a8
--- /dev/null
+++ b/examples/screenshots/no-imgui-linear_selector.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+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
new file mode 100644
index 000000000..e19263a19
--- /dev/null
+++ b/examples/screenshots/no-imgui-rect_frac_layout.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:c2994279094daf35ec203df4b7951e496aaf12767d570f81a792df1172a412a8
+size 144324
diff --git a/examples/screenshots/no-imgui-rect_layout.png b/examples/screenshots/no-imgui-rect_layout.png
new file mode 100644
index 000000000..d400171a2
--- /dev/null
+++ b/examples/screenshots/no-imgui-rect_layout.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+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 35812357a..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:74438dc47ff3fc1391b6952a52c66160fece0545de4ad40c13d3d56b2e093257
-size 59951
+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 61812c8d7..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:a02a21459deeca379a69b30054bebcc3739553b9d377d25b953315094e714d1a
-size 35763
+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 9ef39785c..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:21ccf85a9242f6d7a724c38797688abd804d9a565e818b81ea0c8931aa05ca4e
-size 38337
+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 91dc29397..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:7ec960574580af159f3502da09f1f34e841267985edb52b89baf034c1d49125e
-size 37410
+oid sha256:d473448543c094e30fb4fbf602c6a5a84995ac671b1fc05a34bd1a4ee9cb1734
+size 30225
diff --git a/examples/screenshots/no-imgui-scatter_size.png b/examples/screenshots/no-imgui-scatter_size.png
index 6fadfec4d..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:94b4b9d39f3d4ef2c46b6b4dd7f712ca612f31a7fc94ab5fad8015e48c637e91
-size 70290
+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
new file mode 100644
index 000000000..1ee20fa64
--- /dev/null
+++ b/examples/screenshots/rect_frac_layout.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:23cd21882c3599d7b911c70df62d38aac9bc2d180dbbf05dd40b9561c6c2329a
+size 144438
diff --git a/examples/screenshots/rect_layout.png b/examples/screenshots/rect_layout.png
new file mode 100644
index 000000000..27eb67784
--- /dev/null
+++ b/examples/screenshots/rect_layout.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+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 a887d1f99..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:6d6bfba80eb737099040eebce9b70e1b261720f26cc895ec4b81ca21af60471c
-size 60550
+oid sha256:641ff08c68450320aaa31f53035d69938e4e0347273e576fc2847e3eb050e1f5
+size 44445
diff --git a/examples/screenshots/scatter_colorslice_iris.png b/examples/screenshots/scatter_colorslice_iris.png
index e260df642..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:febd4aa7240eea70b2759337cf98be31cacc1b147859bf628e929ead0153ef9c
-size 36791
+oid sha256:6df93416cee2dc13b5435ef56a0dcfe02e3b8d087ca28077693c0cf270881bf5
+size 25438
diff --git a/examples/screenshots/scatter_dataslice_iris.png b/examples/screenshots/scatter_dataslice_iris.png
index e5f05bb74..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:6cfbc717281c15c6d1d8fe2989770bc9c46f42052c897c2270294ad1b4b40d66
-size 39296
+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 9c452d448..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:98eab41312eb42cbffdf8add0651b55e63b5c2fb5f4523e32dc51ed28a1be369
-size 38452
+oid sha256:5d78bbf60c03baf857413ca0fa0c189a5c9cdf90a7c0a1bc443b6ea7fa4d8f6b
+size 31881
diff --git a/examples/screenshots/scatter_size.png b/examples/screenshots/scatter_size.png
index f2f036ea4..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:e3522468f99c030cb27c225f009ecb4c7aafbd97cfc743cf1d07fb8d7ff8e0d4
-size 71336
+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/fft.py b/examples/selection_tools/fft.py
index f249f2c11..46ab8f89f 100644
--- a/examples/selection_tools/fft.py
+++ b/examples/selection_tools/fft.py
@@ -94,8 +94,8 @@ def update_images(ev):
figure = iw.figure
-# NOTE: `if __name__ == "__main__"` is NOT how to use fastplotlib interactively
-# please see our docs for using fastplotlib interactively in ipython and jupyter
+# 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/selection_tools/linear_region_line_collection.py b/examples/selection_tools/linear_region_line_collection.py
index 76739d784..05084df0f 100644
--- a/examples/selection_tools/linear_region_line_collection.py
+++ b/examples/selection_tools/linear_region_line_collection.py
@@ -59,8 +59,11 @@ def update_zoomed_subplots(ev):
for i in range(len(zoomed_data)):
# interpolate y-vals
- data = interpolate(zoomed_data[i], axis=1)
- figure[i + 1, 0]["zoomed"].data[:, 1] = data
+ if zoomed_data[i].size == 0:
+ figure[i + 1, 0]["zoomed"].data[:, 1] = 0
+ else:
+ data = interpolate(zoomed_data[i], axis=1)
+ figure[i + 1, 0]["zoomed"].data[:, 1] = data
figure[i + 1, 0].auto_scale()
@@ -74,8 +77,8 @@ def update_zoomed_subplots(ev):
figure.show(maintain_aspect=False)
-# NOTE: `if __name__ == "__main__"` is NOT how to use fastplotlib interactively
-# please see our docs for using fastplotlib interactively in ipython and jupyter
+# 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/selection_tools/linear_region_selector.py b/examples/selection_tools/linear_region_selector.py
index 6fa17db38..7a9114beb 100644
--- a/examples/selection_tools/linear_region_selector.py
+++ b/examples/selection_tools/linear_region_selector.py
@@ -29,30 +29,30 @@
names=names,
)
-# preallocated size for zoomed data
-zoomed_prealloc = 1_000
+# preallocated number of datapoints for zoomed data
+zoomed_prealloc = 5_000
# data to plot
-xs = np.linspace(0, 10 * np.pi, 1_000)
-ys = np.sin(xs) # y = sine(x)
+xs = np.linspace(0, 200 * np.pi, 10_000)
+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]))
+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() # 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)])
@@ -79,9 +79,9 @@ def set_zoom_x(ev):
if selected_data.size == 0:
# no data selected
zoomed_x.data[:, 1] = 0
-
- # interpolate the y-values since y = f(x)
- zoomed_x.data[:, 1] = interpolate(selected_data, axis=1)
+ else:
+ # interpolate the y-values since y = f(x)
+ zoomed_x.data[:, 1] = interpolate(selected_data, axis=1)
figure[1, 0].auto_scale()
@@ -92,9 +92,9 @@ def set_zoom_y(ev):
if selected_data.size == 0:
# no data selected
zoomed_y.data[:, 1] = 0
-
- # interpolate the x values since this x = f(y)
- zoomed_y.data[:, 1] = -interpolate(selected_data, axis=0)
+ else:
+ # interpolate the x values since this x = f(y)
+ zoomed_y.data[:, 1] = -interpolate(selected_data, axis=0)
figure[1, 1].auto_scale()
@@ -102,13 +102,13 @@ def set_zoom_y(ev):
selector_y.add_event_handler(set_zoom_y, "selection")
# set initial selection
-selector_x.selection = selector_y.selection = (0, 4 * np.pi)
-
+selector_x.selection = (0, 150)
+selector_y.selection = (0, 150)
figure.show(maintain_aspect=False)
-# NOTE: `if __name__ == "__main__"` is NOT how to use fastplotlib interactively
-# please see our docs for using fastplotlib interactively in ipython and jupyter
+# 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/selection_tools/linear_region_selectors_match_offsets.py b/examples/selection_tools/linear_region_selectors_match_offsets.py
index b48e30f28..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)])
@@ -74,9 +74,9 @@ def set_zoom_x(ev):
if selected_data.size == 0:
# no data selected
zoomed_x.data[:, 1] = 0
-
- # interpolate the y-values since y = f(x)
- zoomed_x.data[:, 1] = interpolate(selected_data, axis=1)
+ else:
+ # interpolate the y-values since y = f(x)
+ zoomed_x.data[:, 1] = interpolate(selected_data, axis=1)
figure[1, 0].auto_scale()
@@ -87,9 +87,9 @@ def set_zoom_y(ev):
if selected_data.size == 0:
# no data selected
zoomed_y.data[:, 1] = 0
-
- # interpolate the x values since this x = f(y)
- zoomed_y.data[:, 1] = -interpolate(selected_data, axis=0)
+ else:
+ # interpolate the x values since this x = f(y)
+ zoomed_y.data[:, 1] = -interpolate(selected_data, axis=0)
figure[1, 1].auto_scale()
@@ -102,8 +102,8 @@ def set_zoom_y(ev):
figure.show(maintain_aspect=False)
-# NOTE: `if __name__ == "__main__"` is NOT how to use fastplotlib interactively
-# please see our docs for using fastplotlib interactively in ipython and jupyter
+# 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/selection_tools/linear_selector.py b/examples/selection_tools/linear_selector.py
index 1edf6345c..8b442db20 100644
--- a/examples/selection_tools/linear_selector.py
+++ b/examples/selection_tools/linear_selector.py
@@ -2,10 +2,11 @@
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 = false
+# test_example = true
# sphinx_gallery_pygfx_docs = 'screenshot'
import fastplotlib as fpl
@@ -115,8 +116,8 @@ def line_stack_selector_changed(ev):
figure.show(maintain_aspect=False)
-# NOTE: `if __name__ == "__main__"` is NOT how to use fastplotlib interactively
-# please see our docs for using fastplotlib interactively in ipython and jupyter
+# 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/selection_tools/linear_selector_image.py b/examples/selection_tools/linear_selector_image.py
index 00484aba7..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])
@@ -66,8 +67,8 @@ def image_col_selector_changed(ev):
subplot.camera.zoom = 0.5
-# NOTE: `if __name__ == "__main__"` is NOT how to use fastplotlib interactively
-# please see our docs for using fastplotlib interactively in ipython and jupyter
+# 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/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.py b/examples/selection_tools/rectangle_selector.py
index 850937f7a..d0fd33aa9 100644
--- a/examples/selection_tools/rectangle_selector.py
+++ b/examples/selection_tools/rectangle_selector.py
@@ -59,8 +59,8 @@ def color_indices(ev):
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
+# 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/selection_tools/rectangle_selector_zoom.py b/examples/selection_tools/rectangle_selector_zoom.py
index 33ba2ae2a..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())
@@ -46,8 +46,8 @@ def update_data(ev):
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
+# 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/selection_tools/unit_circle.py b/examples/selection_tools/unit_circle.py
new file mode 100644
index 000000000..b2ba772e4
--- /dev/null
+++ b/examples/selection_tools/unit_circle.py
@@ -0,0 +1,145 @@
+"""
+Unit circle
+===========
+
+Example with linear selectors on a sine and cosine function that demonstrates the unit circle.
+
+This shows how fastplotlib supports bidirectional events, drag the linear selector on the sine
+or cosine function and they will both move together.
+
+Click on the sine or cosine function to set the colormap transform to illustrate the sine or
+cosine function output values on the unit circle.
+"""
+
+# test_example = false
+# sphinx_gallery_pygfx_docs = 'screenshot'
+
+
+import numpy as np
+import fastplotlib as fpl
+
+
+# helper function to make a cirlce
+def make_circle(center, radius: float, n_points: int) -> np.ndarray:
+ theta = np.linspace(0, 2 * np.pi, n_points)
+ xs = radius * np.cos(theta)
+ ys = radius * np.sin(theta)
+
+ return np.column_stack([xs, ys]) + center
+
+
+# We will have 3 subplots in a layout like this:
+"""
+|========|========|
+| | |
+| | sine |
+| | |
+| circle |========|
+| | |
+| | cosine |
+| | |
+|========|========|
+"""
+
+# we can define this layout using "extents", i.e. min and max ranges on the canvas
+# (x_min, x_max, y_min, y_max)
+# extents can be defined as fractions as shown here
+extents = [
+ (0, 0.5, 0, 1), # circle subplot
+ (0.5, 1, 0, 0.5), # sine subplot
+ (0.5, 1, 0.5, 1), # cosine subplot
+]
+
+# create a figure with 3 subplots
+figure = fpl.Figure(
+ extents=extents,
+ names=["unit circle", "sin(x)", "cos(x)"],
+ size=(700, 560)
+)
+
+# set the axes to intersect at (0, 0, 0) to better illustrate the unit circle
+for subplot in figure:
+ subplot.axes.intersection = (0, 0, 0)
+ subplot.toolbar = False # reduce clutter
+
+figure["sin(x)"].camera.maintain_aspect = False
+figure["cos(x)"].camera.maintain_aspect = False
+
+# create sine and cosine data
+xs = np.linspace(0, 2 * np.pi, 360)
+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 = figure["unit 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 = figure["unit circle"].add_line(
+ radius_data, thickness=6, colors="magenta"
+)
+
+# sine line graphic, cmap transform set from the sine function
+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 = 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.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, cosine]:
+ g.cmap.transform = cmap_transform
+
+ # set circle 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.add_event_handler(set_circle_cmap, "click")
+cosine.add_event_handler(set_circle_cmap, "click")
+
+
+def set_x_val(ev):
+ # used to sync the two selectors
+ value = ev.info["value"]
+ index = ev.get_selected_index()
+
+ sine_selector.selection = value
+ cosine_selector.selection = value
+
+ circle_radius.data[1, :-1] = circle_data[index]
+
+# add same event handler to both graphics
+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()
+
+
+# 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/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 67519187b..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."""
- os.environ["WGPU_FORCE_OFFSCREEN"] = "true"
+ # 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["WGPU_FORCE_OFFSCREEN"]
+ 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,28 +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()
-
- # render each subplot
- for subplot in example.figure:
- subplot.viewport.render(subplot.scene, subplot.camera)
- for dock in subplot.docks.values():
- dock.set_viewport_rect()
-
- # 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
@@ -149,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 "
@@ -201,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 f72a87123..e279809e3 100644
--- a/examples/tests/testutils.py
+++ b/examples/tests/testutils.py
@@ -18,14 +18,20 @@
# 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",
- "misc/*.py",
+ "window_layouts/*.py",
+ "events/*.py",
"selection_tools/*.py",
+ "spaces_transforms/*.py",
+ "misc/*.py",
"guis/*.py",
]
diff --git a/examples/text/README.rst b/examples/text/README.rst
new file mode 100644
index 000000000..01466a39f
--- /dev/null
+++ b/examples/text/README.rst
@@ -0,0 +1,2 @@
+Text Examples
+=============
diff --git a/examples/text/moving_label.py b/examples/text/moving_label.py
new file mode 100644
index 000000000..7ba7d85df
--- /dev/null
+++ b/examples/text/moving_label.py
@@ -0,0 +1,84 @@
+"""
+Moving TextGraphic label
+========================
+
+A TextGraphic that labels a point on a line and another TextGraphic that moves along the line on every draw.
+"""
+
+# test_example = false
+# sphinx_gallery_pygfx_docs = 'animate 10s'
+
+import numpy as np
+import fastplotlib as fpl
+
+# create a sinc wave
+xs = np.linspace(-2 * np.pi, 2 * np.pi, 200)
+ys = np.sinc(xs)
+
+data = np.column_stack([xs, ys])
+
+# create a figure
+figure = fpl.Figure(size=(700, 450))
+
+# sinc wave
+line = figure[0, 0].add_line(data, thickness=2)
+
+# position for the text label on the peak
+pos = (0, max(ys), 0)
+
+# create label for the peak
+text_peak = figure[0, 0].add_text(
+ f"peak ",
+ font_size=20,
+ anchor="bottom-right",
+ offset=pos
+)
+
+# add a point on the peak
+point_peak = figure[0, 0].add_scatter(np.asarray([pos]), sizes=10, colors="r")
+
+# create a text that will move along the line
+text_moving = figure[0, 0].add_text(
+ f"({xs[0]:.2f}, {ys[0]:.2f}) ",
+ font_size=16,
+ outline_color="k",
+ outline_thickness=1,
+ anchor="top-center",
+ offset=(*data[0], 0)
+)
+# a point that will move on the line
+point_moving = figure[0, 0].add_scatter(np.asarray([data[0]]), sizes=10, colors="magenta")
+
+
+index = 0
+def update():
+ # moves the text and point before every draw
+ global index
+ # get the new position
+ new_pos = (*data[index], 0)
+
+ # move the text and point to the new position
+ text_moving.offset = new_pos
+ point_moving.data[0] = new_pos
+
+ # set the text to the new position
+ text_moving.text = f"({new_pos[0]:.2f}, {new_pos[1]:.2f})"
+
+ # increment index
+ index += 1
+ if index == data.shape[0]:
+ index = 0
+
+
+# add update as an animation functions
+figure.add_animations(update)
+
+figure[0, 0].axes.visible = False
+figure.show(maintain_aspect=False)
+
+
+# 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/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/examples/window_layouts/README.rst b/examples/window_layouts/README.rst
new file mode 100644
index 000000000..23684627b
--- /dev/null
+++ b/examples/window_layouts/README.rst
@@ -0,0 +1,2 @@
+Window Layout Examples
+======================
diff --git a/examples/window_layouts/extent_frac_layout.py b/examples/window_layouts/extent_frac_layout.py
new file mode 100644
index 000000000..d90270c22
--- /dev/null
+++ b/examples/window_layouts/extent_frac_layout.py
@@ -0,0 +1,74 @@
+"""
+Fractional Extent Layout
+========================
+
+Create subplots using extents given as fractions of the canvas.
+This example plots two images and their histograms in separate subplots
+
+"""
+
+# test_example = true
+# sphinx_gallery_pygfx_docs = 'screenshot'
+
+import numpy as np
+import imageio.v3 as iio
+import fastplotlib as fpl
+
+# load images
+img1 = iio.imread("imageio:astronaut.png")
+img2 = iio.imread("imageio:wikkie.png")
+
+# calculate histograms
+hist_1, edges_1 = np.histogram(img1)
+centers_1 = edges_1[:-1] + np.diff(edges_1) / 2
+
+hist_2, edges_2 = np.histogram(img2)
+centers_2 = edges_2[:-1] + np.diff(edges_2) / 2
+
+# figure size in pixels
+size = (700, 560)
+
+# extent is (xmin, xmax, ymin, ymax)
+# here it is defined as fractions of the canvas
+extents = [
+ (0, 0.3, 0, 0.5), # for image1
+ (0, 0.3, 0.5, 1), # for image2
+ (0.3, 1, 0, 0.5), # for image1 histogram
+ (0.3, 1, 0.5, 1), # for image2 histogram
+]
+
+# create a figure using the rects and size
+# also give each subplot a name
+figure = fpl.Figure(
+ extents=extents,
+ names=["astronaut image", "wikkie image", "astronaut histogram", "wikkie histogram"],
+ size=size
+)
+
+# add image to the corresponding subplots
+figure["astronaut image"].add_image(img1)
+figure["wikkie image"].add_image(img2)
+
+# add histogram to the corresponding subplots
+figure["astronaut histogram"].add_line(np.column_stack([centers_1, hist_1]))
+figure["wikkie histogram"].add_line(np.column_stack([centers_2, hist_2]))
+
+
+for subplot in figure:
+ if "image" in subplot.name:
+ # remove axes from image subplots to reduce clutter
+ subplot.axes.visible = False
+ continue
+
+ # don't maintain aspect ratio for the histogram subplots
+ subplot.camera.maintain_aspect = False
+
+
+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/window_layouts/extent_layout.py b/examples/window_layouts/extent_layout.py
new file mode 100644
index 000000000..341a2f970
--- /dev/null
+++ b/examples/window_layouts/extent_layout.py
@@ -0,0 +1,74 @@
+"""
+Extent Layout
+=============
+
+Create subplots using given extents in absolute pixels.
+This example plots two images and their histograms in separate subplots
+
+"""
+
+# test_example = true
+# sphinx_gallery_pygfx_docs = 'screenshot'
+
+import numpy as np
+import imageio.v3 as iio
+import fastplotlib as fpl
+
+# load images
+img1 = iio.imread("imageio:astronaut.png")
+img2 = iio.imread("imageio:wikkie.png")
+
+# calculate histograms
+hist_1, edges_1 = np.histogram(img1)
+centers_1 = edges_1[:-1] + np.diff(edges_1) / 2
+
+hist_2, edges_2 = np.histogram(img2)
+centers_2 = edges_2[:-1] + np.diff(edges_2) / 2
+
+# figure size in pixels
+size = (640, 480)
+
+# extent is (xmin, xmax, ymin, ymax)
+# here it is defined in absolute pixels
+extents = [
+ (0, 200, 0, 240), # for image1
+ (0, 200, 240, 480), # for image2
+ (200, 640, 0, 240), # for image1 histogram
+ (200, 640, 240, 480), # for image2 histogram
+]
+
+# create a figure using the rects and size
+# also give each subplot a name
+figure = fpl.Figure(
+ extents=extents,
+ names=["astronaut image", "wikkie image", "astronaut histogram", "wikkie histogram"],
+ size=size
+)
+
+# add image to the corresponding subplots
+figure["astronaut image"].add_image(img1)
+figure["wikkie image"].add_image(img2)
+
+# add histogram to the corresponding subplots
+figure["astronaut histogram"].add_line(np.column_stack([centers_1, hist_1]))
+figure["wikkie histogram"].add_line(np.column_stack([centers_2, hist_2]))
+
+
+for subplot in figure:
+ if "image" in subplot.name:
+ # remove axes from image subplots to reduce clutter
+ subplot.axes.visible = False
+ continue
+
+ # don't maintain aspect ratio for the histogram subplots
+ subplot.camera.maintain_aspect = False
+
+
+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/window_layouts/rect_frac_layout.py b/examples/window_layouts/rect_frac_layout.py
new file mode 100644
index 000000000..070488487
--- /dev/null
+++ b/examples/window_layouts/rect_frac_layout.py
@@ -0,0 +1,74 @@
+"""
+Rect Fractional Layout
+======================
+
+Create subplots using rects given as fractions of the canvas.
+This example plots two images and their histograms in separate subplots
+
+"""
+
+# test_example = true
+# sphinx_gallery_pygfx_docs = 'screenshot'
+
+import numpy as np
+import imageio.v3 as iio
+import fastplotlib as fpl
+
+# load images
+img1 = iio.imread("imageio:astronaut.png")
+img2 = iio.imread("imageio:wikkie.png")
+
+# calculate histograms
+hist_1, edges_1 = np.histogram(img1)
+centers_1 = edges_1[:-1] + np.diff(edges_1) / 2
+
+hist_2, edges_2 = np.histogram(img2)
+centers_2 = edges_2[:-1] + np.diff(edges_2) / 2
+
+# figure size in pixels
+size = (700, 560)
+
+# rect is (x, y, width, height)
+# here it is defined as fractions of the canvas
+rects = [
+ (0, 0, 0.3, 0.5), # for image1
+ (0, 0.5, 0.3, 0.5), # for image2
+ (0.3, 0, 0.7, 0.5), # for image1 histogram
+ (0.3, 0.5, 0.7, 0.5), # for image2 histogram
+]
+
+# create a figure using the rects and size
+# also give each subplot a name
+figure = fpl.Figure(
+ rects=rects,
+ names=["astronaut image", "wikkie image", "astronaut histogram", "wikkie histogram"],
+ size=size
+)
+
+# add image to the corresponding subplots
+figure["astronaut image"].add_image(img1)
+figure["wikkie image"].add_image(img2)
+
+# add histogram to the corresponding subplots
+figure["astronaut histogram"].add_line(np.column_stack([centers_1, hist_1]))
+figure["wikkie histogram"].add_line(np.column_stack([centers_2, hist_2]))
+
+
+for subplot in figure:
+ if "image" in subplot.name:
+ # remove axes from image subplots to reduce clutter
+ subplot.axes.visible = False
+ continue
+
+ # don't maintain aspect ratio for the histogram subplots
+ subplot.camera.maintain_aspect = False
+
+
+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/window_layouts/rect_layout.py b/examples/window_layouts/rect_layout.py
new file mode 100644
index 000000000..c9fa23a0e
--- /dev/null
+++ b/examples/window_layouts/rect_layout.py
@@ -0,0 +1,74 @@
+"""
+Rect Layout
+===========
+
+Create subplots using given rects in absolute pixels.
+This example plots two images and their histograms in separate subplots
+
+"""
+
+# test_example = true
+# sphinx_gallery_pygfx_docs = 'screenshot'
+
+import numpy as np
+import imageio.v3 as iio
+import fastplotlib as fpl
+
+# load images
+img1 = iio.imread("imageio:astronaut.png")
+img2 = iio.imread("imageio:wikkie.png")
+
+# calculate histograms
+hist_1, edges_1 = np.histogram(img1)
+centers_1 = edges_1[:-1] + np.diff(edges_1) / 2
+
+hist_2, edges_2 = np.histogram(img2)
+centers_2 = edges_2[:-1] + np.diff(edges_2) / 2
+
+# figure size in pixels
+size = (640, 480)
+
+# a rect is (x, y, width, height)
+# here it is defined in absolute pixels
+rects = [
+ (0, 0, 200, 240), # for image1
+ (0, 240, 200, 240), # for image2
+ (200, 0, 440, 240), # for image1 histogram
+ (200, 240, 440, 240), # for image2 histogram
+]
+
+# create a figure using the rects and size
+# also give each subplot a name
+figure = fpl.Figure(
+ rects=rects,
+ names=["astronaut image", "wikkie image", "astronaut histogram", "wikkie histogram"],
+ size=size
+)
+
+# add image to the corresponding subplots
+figure["astronaut image"].add_image(img1)
+figure["wikkie image"].add_image(img2)
+
+# add histogram to the corresponding subplots
+figure["astronaut histogram"].add_line(np.column_stack([centers_1, hist_1]))
+figure["wikkie histogram"].add_line(np.column_stack([centers_2, hist_2]))
+
+
+for subplot in figure:
+ if "image" in subplot.name:
+ # remove axes from image subplots to reduce clutter
+ subplot.axes.visible = False
+ continue
+
+ # don't maintain aspect ratio for the histogram subplots
+ subplot.camera.maintain_aspect = False
+
+
+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 b/fastplotlib/VERSION
deleted file mode 100644
index 0d91a54c7..000000000
--- a/fastplotlib/VERSION
+++ /dev/null
@@ -1 +0,0 @@
-0.3.0
diff --git a/fastplotlib/__init__.py b/fastplotlib/__init__.py
index 7eb9554e8..6dab91605 100644
--- a/fastplotlib/__init__.py
+++ b/fastplotlib/__init__.py
@@ -1,8 +1,11 @@
from pathlib import Path
+from ._version import __version__, version_info
+
# this must be the first import for auto-canvas detection
from .utils import loop # noqa
from .graphics import *
+from .graphics.features import GraphicFeatureEvent
from .graphics.selectors import *
from .graphics.utils import pause_events
from .legends import *
@@ -20,9 +23,6 @@
from .utils import config, enumerate_adapters, select_adapter, print_wgpu_report
-with open(Path(__file__).parent.joinpath("VERSION"), "r") as f:
- __version__ = f.read().split("\n")[0]
-
if len(enumerate_adapters()) < 1:
from warnings import warn
diff --git a/fastplotlib/_version.py b/fastplotlib/_version.py
new file mode 100644
index 000000000..6ce66cd9a
--- /dev/null
+++ b/fastplotlib/_version.py
@@ -0,0 +1,113 @@
+"""
+Versioning: we use a hard-coded version number, because it's simple and always
+works. For dev installs we add extra version info from Git.
+"""
+
+import logging
+import subprocess
+from pathlib import Path
+
+
+# This is the reference version number, to be bumped before each release.
+# The build system detects this definition when building a distribution.
+__version__ = "0.6.1"
+
+# Allow using nearly the same code in different projects
+project_name = "fastplotlib"
+
+
+logger = logging.getLogger(project_name.lower())
+
+# Get whether this is a repo. If so, repo_dir is the path, otherwise repo_dir is None.
+repo_dir = Path(__file__).parents[1]
+repo_dir = repo_dir if repo_dir.joinpath(".git").is_dir() else None
+
+
+def get_version():
+ """Get the version string."""
+ if repo_dir:
+ return get_extended_version()
+ else:
+ return __version__
+
+
+def get_extended_version():
+ """Get an extended version string with information from git."""
+
+ release, post, labels = get_version_info_from_git()
+
+ # Sample first 3 parts of __version__
+ base_release = ".".join(__version__.split(".")[:3])
+
+ # Check release
+ if not release:
+ release = base_release
+ elif release != base_release:
+ logger.warning(
+ f"{project_name} version from git ({release}) and __version__ ({base_release}) don't match."
+ )
+
+ # Build the total version
+ version = release
+ if post and post != "0":
+ version += f".post{post}"
+ if labels:
+ version += "+" + ".".join(labels)
+
+ return version
+
+
+def get_version_info_from_git():
+ """Get (release, post, labels) from Git.
+
+ With `release` the version number from the latest tag, `post` the
+ number of commits since that tag, and `labels` a tuple with the
+ git-hash and optionally a dirty flag.
+ """
+
+ # Call out to Git
+ command = [
+ "git",
+ "describe",
+ "--long",
+ "--always",
+ "--tags",
+ "--dirty",
+ "--first-parent",
+ ]
+ try:
+ p = subprocess.run(command, cwd=repo_dir, capture_output=True)
+ except Exception as e:
+ logger.warning(f"Could not get {project_name} version: {e}")
+ p = None
+
+ # Parse the result into parts
+ if p is None:
+ parts = (None, None, "unknown")
+ else:
+ output = p.stdout.decode(errors="ignore")
+ if p.returncode:
+ stderr = p.stderr.decode(errors="ignore")
+ logger.warning(
+ f"Could not get {project_name} version.\n\nstdout: "
+ + output
+ + "\n\nstderr: "
+ + stderr
+ )
+ parts = (None, None, "unknown")
+ else:
+ parts = output.strip().lstrip("v").split("-")
+ if len(parts) <= 2:
+ # No tags (and thus also no post). Only git hash and maybe 'dirty'
+ parts = (None, None, *parts)
+
+ # Return unpacked parts
+ release, post, *labels = parts
+ return release, post, labels
+
+
+__version__ = get_version()
+
+version_info = tuple(
+ int(i) if i.isnumeric() else i for i in __version__.split("+")[0].split(".")
+)
diff --git a/fastplotlib/graphics/__init__.py b/fastplotlib/graphics/__init__.py
index ff96baa4c..3d01e4a35 100644
--- a/fastplotlib/graphics/__init__.py
+++ b/fastplotlib/graphics/__init__.py
@@ -1,14 +1,24 @@
+from ._base import Graphic
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
__all__ = [
+ "Graphic",
"LineGraphic",
- "ImageGraphic",
"ScatterGraphic",
+ "ImageGraphic",
+ "ImageVolumeGraphic",
+ "VectorsGraphic",
+ "MeshGraphic",
+ "SurfaceGraphic",
+ "PolygonGraphic",
"TextGraphic",
"LineCollection",
"LineStack",
diff --git a/fastplotlib/graphics/_axes.py b/fastplotlib/graphics/_axes.py
index 9541dceeb..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"]
@@ -141,108 +143,6 @@ def yz(self) -> Grid:
return self._yz
-class Ruler(pygfx.Ruler):
- def __init__(self, **kwargs):
- super().__init__(**kwargs)
- self.tick_text_mapper = None
- self.font_size = 14
-
- def _update_sub_objects(self, ticks, tick_auto_step):
- """Update the sub-objects to show the given ticks."""
- assert isinstance(ticks, dict)
-
- tick_size = 5
- min_n_slots = 8 # todo: can be (much) higher when we use a single text object!
-
- # Load config
- start_pos = self._start_pos
- end_pos = self._end_pos
- start_value = self._start_value
- end_value = self.end_value
-
- # Derive some more variables
- length = end_value - start_value
- vec = end_pos - start_pos
- if length:
- vec /= length
-
- # Get array to store positions
- n_slots = self.points.geometry.positions.nitems
- n_positions = len(ticks) + 2
- if n_positions <= n_slots <= max(min_n_slots, 2 * n_positions):
- # Re-use existing buffers
- positions = self.points.geometry.positions.data
- sizes = self.points.geometry.sizes.data
- self.points.geometry.positions.update_range()
- self.points.geometry.sizes.update_range()
- else:
- # Allocate new buffers
- new_n_slots = max(min_n_slots, int(n_positions * 1.2))
- positions = np.zeros((new_n_slots, 3), np.float32)
- sizes = np.zeros((new_n_slots,), np.float32)
- self.points.geometry.positions = pygfx.Buffer(positions)
- self.points.geometry.sizes = pygfx.Buffer(sizes)
- # Allocate text objects
- while len(self._text_object_pool) < new_n_slots:
- ob = pygfx.Text(
- pygfx.TextGeometry("", screen_space=True, font_size=self.font_size),
- pygfx.TextMaterial(aa=False),
- )
- self._text_object_pool.append(ob)
- self._text_object_pool[new_n_slots:] = []
- # Reset children
- self.clear()
- self.add(self._line, self._points, *self._text_object_pool)
-
- def define_text(pos, text):
- if self.tick_text_mapper is not None and text != "":
- text = self.tick_text_mapper(text)
-
- ob = self._text_object_pool[index]
- ob.geometry.anchor = self._text_anchor
- ob.geometry.anchor_offset = self._text_anchor_offset
- ob.geometry.set_text(text)
- ob.local.position = pos
-
- # Apply start point
- index = 0
- positions[0] = start_pos
- if self._ticks_at_end_points:
- sizes[0] = tick_size
- define_text(start_pos, f"{self._start_value:0.4g}")
- else:
- sizes[0] = 0
- define_text(start_pos, f"")
-
- # Collect ticks
- index += 1
- for value, text in ticks.items():
- pos = start_pos + vec * (value - start_value)
- positions[index] = pos
- sizes[index] = tick_size
- define_text(pos, text)
- index += 1
-
- # Handle end point, and nullify remaining slots
- positions[index:] = end_pos
- sizes[index:] = 0
- for ob in self._text_object_pool[index:]:
- ob.geometry.set_text("")
-
- # Show last tick?
- if self._ticks_at_end_points:
- sizes[index] = tick_size
- define_text(end_pos, f"{end_value:0.4g}")
-
- # Hide the ticks close to the ends?
- if self._ticks_at_end_points and ticks:
- tick_values = list(ticks.keys())
- if abs(tick_values[0] - start_value) < 0.5 * tick_auto_step:
- self._text_object_pool[1].geometry.set_text("")
- if abs(tick_values[-1] - end_value) < 0.5 * tick_auto_step:
- self._text_object_pool[index - 1].geometry.set_text("")
-
-
class Axes:
def __init__(
self,
@@ -261,31 +161,53 @@ def __init__(
):
self._plot_area = plot_area
- if x_kwargs is None:
- x_kwargs = dict()
+ x_kwargs = x_kwargs or {}
+ y_kwargs = y_kwargs or {}
+ z_kwargs = z_kwargs or {}
- if y_kwargs is None:
- y_kwargs = dict()
-
- 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 = Ruler(**x_kwargs)
- self._y = Ruler(**y_kwargs)
- self._z = 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
@@ -328,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()
@@ -400,17 +330,17 @@ def offset(self, value: np.ndarray):
self._offset = value
@property
- def x(self) -> Ruler:
+ def x(self) -> pygfx.Ruler:
"""x axis ruler"""
return self._x
@property
- def y(self) -> Ruler:
+ def y(self) -> pygfx.Ruler:
"""y axis ruler"""
return self._y
@property
- def z(self) -> Ruler:
+ def z(self) -> pygfx.Ruler:
"""z axis ruler"""
return self._z
@@ -516,7 +446,7 @@ def update_using_camera(self):
return
if self._plot_area.camera.fov == 0:
- xpos, ypos, width, height = self._plot_area.get_rect()
+ xpos, ypos, width, height = self._plot_area.viewport.rect
# orthographic projection, get ranges using inverse
# get range of screen space by getting the corners
diff --git a/fastplotlib/graphics/_base.py b/fastplotlib/graphics/_base.py
index a25bc7176..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
@@ -16,22 +17,30 @@
import pygfx
-from ._features import (
+from .features import (
BufferManager,
Deleted,
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",
@@ -50,33 +59,37 @@
class Graphic:
- _features: set[str] = {}
+ _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 = {
- *cls._features,
- "name",
- "offset",
- "rotation",
- "visible",
- "deleted",
+ **cls._features,
+ "name": Name,
+ "offset": Offset,
+ "rotation": Rotation,
+ "scale": Scale,
+ "alpha": Alpha,
+ "alpha_mode": AlphaMode,
+ "visible": Visible,
+ "deleted": Deleted,
}
super().__init_subclass__(**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,10 +183,15 @@ 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"""
- return (*tuple(self._features), *PYGFX_EVENTS)
+ return (*tuple(self._features.keys()), *PYGFX_EVENTS)
@property
def name(self) -> str | None:
@@ -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,21 +283,65 @@ 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
- def unshare_property(self, feature: str):
- raise NotImplementedError
+ # set scale if it's not (1, 1, 1)
+ if not all(wo.world.scale == self.scale):
+ self.scale = self.scale
- def share_property(self, feature: BufferManager):
- raise NotImplementedError
+ @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, ...]]:
@@ -273,9 +406,16 @@ def decorator(_callback):
# add to our record
self._event_handlers[t].add(_callback)
- if t in self._features:
+ 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
@@ -361,11 +501,77 @@ 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
def __repr__(self):
- rval = f"{self.__class__.__name__} @ {hex(id(self))}"
+ rval = f"{self.__class__.__name__}"
if self.name is not None:
return f"'{self.name}': {rval}"
else:
@@ -378,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)
@@ -466,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/_features/__init__.py b/fastplotlib/graphics/_features/__init__.py
deleted file mode 100644
index a1915bbe9..000000000
--- a/fastplotlib/graphics/_features/__init__.py
+++ /dev/null
@@ -1,70 +0,0 @@
-from ._positions_graphics import (
- VertexColors,
- UniformColor,
- UniformSize,
- SizeSpace,
- Thickness,
- VertexPositions,
- PointsSizesFeature,
- VertexCmap,
-)
-from ._image import (
- TextureArray,
- ImageCmap,
- ImageVmin,
- ImageVmax,
- ImageInterpolation,
- ImageCmapInterpolation,
-)
-from ._base import (
- GraphicFeature,
- BufferManager,
- FeatureEvent,
- to_gpu_supported_dtype,
-)
-
-from ._text import (
- TextData,
- FontSize,
- TextFaceColor,
- TextOutlineColor,
- TextOutlineThickness,
-)
-
-from ._selection_features import (
- LinearSelectionFeature,
- LinearRegionSelectionFeature,
- RectangleSelectionFeature,
-)
-from ._common import Name, Offset, Rotation, Visible, Deleted
-
-
-__all__ = [
- "VertexColors",
- "UniformColor",
- "UniformSize",
- "SizeSpace",
- "Thickness",
- "VertexPositions",
- "PointsSizesFeature",
- "VertexCmap",
- "TextureArray",
- "ImageCmap",
- "ImageVmin",
- "ImageVmax",
- "ImageInterpolation",
- "ImageCmapInterpolation",
- "TextData",
- "FontSize",
- "TextFaceColor",
- "TextOutlineColor",
- "TextOutlineThickness",
- "LinearSelectionFeature",
- "LinearRegionSelectionFeature",
- "RectangleSelectionFeature",
- "Name",
- "Offset",
- "Rotation",
- "Visible",
- "Deleted",
-]
diff --git a/fastplotlib/graphics/_features/_common.py b/fastplotlib/graphics/_features/_common.py
deleted file mode 100644
index fe32a485f..000000000
--- a/fastplotlib/graphics/_features/_common.py
+++ /dev/null
@@ -1,123 +0,0 @@
-import numpy as np
-
-from ._base import GraphicFeature, FeatureEvent
-
-
-class Name(GraphicFeature):
- """Graphic name"""
-
- def __init__(self, value: str):
- self._value = value
- super().__init__()
-
- @property
- def value(self) -> str:
- return self._value
-
- def set_value(self, graphic, value: str):
- if not isinstance(value, str):
- raise TypeError("`Graphic` name must be of type ")
-
- if graphic._plot_area is not None:
- graphic._plot_area._check_graphic_name_exists(value)
-
- self._value = value
-
- event = FeatureEvent(type="name", info={"value": value})
- self._call_event_handlers(event)
-
-
-class Offset(GraphicFeature):
- """Offset position of the graphic, [x, y, z]"""
-
- def __init__(self, value: np.ndarray | list | tuple):
- self._validate(value)
- self._value = np.array(value)
- self._value.flags.writeable = False
- super().__init__()
-
- def _validate(self, value):
- if not len(value) == 3:
- raise ValueError("offset must be a list, tuple, or array of 3 float values")
-
- @property
- def value(self) -> np.ndarray:
- return self._value
-
- def set_value(self, graphic, value: np.ndarray | list | tuple):
- self._validate(value)
-
- graphic.world_object.world.position = value
- self._value = graphic.world_object.world.position.copy()
- self._value.flags.writeable = False
-
- event = FeatureEvent(type="offset", info={"value": value})
- self._call_event_handlers(event)
-
-
-class Rotation(GraphicFeature):
- """Graphic rotation quaternion"""
-
- def __init__(self, value: np.ndarray | list | tuple):
- self._validate(value)
- self._value = np.array(value)
- self._value.flags.writeable = False
- super().__init__()
-
- def _validate(self, value):
- if not len(value) == 4:
- raise ValueError(
- "rotation quaternion must be a list, tuple, or array of 4 float values"
- )
-
- @property
- def value(self) -> np.ndarray:
- return self._value
-
- def set_value(self, graphic, value: np.ndarray | list | tuple):
- self._validate(value)
-
- graphic.world_object.world.rotation = value
- self._value = graphic.world_object.world.rotation.copy()
- self._value.flags.writeable = False
-
- event = FeatureEvent(type="rotation", info={"value": value})
- self._call_event_handlers(event)
-
-
-class Visible(GraphicFeature):
- """Access or change the visibility."""
-
- def __init__(self, value: bool):
- self._value = value
- super().__init__()
-
- @property
- def value(self) -> bool:
- return self._value
-
- def set_value(self, graphic, value: bool):
- graphic.world_object.visible = value
- self._value = value
-
- event = FeatureEvent(type="visible", info={"value": value})
- self._call_event_handlers(event)
-
-
-class Deleted(GraphicFeature):
- """
- Used when a graphic is deleted, triggers events that can be useful to indicate this graphic has been deleted
- """
-
- def __init__(self, value: bool):
- self._value = value
- super().__init__()
-
- @property
- def value(self) -> bool:
- return self._value
-
- def set_value(self, graphic, value: bool):
- self._value = value
- event = FeatureEvent(type="deleted", info={"value": value})
- self._call_event_handlers(event)
diff --git a/fastplotlib/graphics/_features/_positions_graphics.py b/fastplotlib/graphics/_features/_positions_graphics.py
deleted file mode 100644
index c4e153a31..000000000
--- a/fastplotlib/graphics/_features/_positions_graphics.py
+++ /dev/null
@@ -1,479 +0,0 @@
-from typing import Any, List
-
-import numpy as np
-import pygfx
-
-from ...utils import (
- parse_cmap_values,
-)
-from ._base import (
- GraphicFeature,
- BufferManager,
- FeatureEvent,
- to_gpu_supported_dtype,
-)
-from .utils import parse_colors
-
-
-class VertexColors(BufferManager):
- """
-
- **info dict**
- +------------+-----------------------------------------------------------+----------------------------------------------------------------------------------+
- | dict key | value type | value description |
- +============+===========================================================+==================================================================================+
- | key | int | slice | np.ndarray[int | bool] | tuple[slice, ...] | key at which colors were indexed/sliced |
- +------------+-----------------------------------------------------------+----------------------------------------------------------------------------------+
- | value | np.ndarray | new color values for points that were changed, shape is [n_points_changed, RGBA] |
- +------------+-----------------------------------------------------------+----------------------------------------------------------------------------------+
- | user_value | str | np.ndarray | tuple[float] | list[float] | list[str] | user input value that was parsed into the RGBA array |
- +------------+-----------------------------------------------------------+----------------------------------------------------------------------------------+
-
- """
-
- def __init__(
- self,
- colors: str | np.ndarray | tuple[float] | list[float] | list[str],
- n_colors: int,
- alpha: float = None,
- isolated_buffer: bool = True,
- ):
- """
- 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
- 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)
-
- super().__init__(data=data, isolated_buffer=isolated_buffer)
-
- def __setitem__(
- self,
- key: int | slice | np.ndarray[int | bool] | tuple[slice, ...],
- user_value: str | np.ndarray | tuple[float] | list[float] | list[str],
- ):
- user_key = key
-
- if isinstance(key, tuple):
- # directly setting RGBA values for points, we do no parsing
- if not isinstance(user_value, (int, float, np.ndarray)):
- raise TypeError(
- "Can only set from int, float, or array to set colors directly by slicing the entire array"
- )
- value = user_value
-
- elif isinstance(key, int):
- # set color of one point
- n_colors = 1
- value = parse_colors(user_value, n_colors)
-
- elif isinstance(key, slice):
- # find n_colors by converting slice to range and then parse colors
- start, stop, step = key.indices(self.value.shape[0])
-
- n_colors = len(range(start, stop, step))
-
- value = parse_colors(user_value, n_colors)
-
- elif isinstance(key, (np.ndarray, list)):
- if isinstance(key, list):
- # convert to array
- key = np.array(key)
-
- # make sure it's 1D
- if not key.ndim == 1:
- raise TypeError(
- "If slicing colors with an array, it must be a 1D bool or int array"
- )
-
- 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 {key.size} indices"
- )
- n_colors = np.count_nonzero(key)
-
- elif np.issubdtype(key.dtype, np.integer):
- n_colors = key.size
-
- else:
- raise TypeError(
- "If slicing colors with an array, it must be a 1D bool or int array"
- )
-
- value = parse_colors(user_value, n_colors)
-
- else:
- raise TypeError(
- f"invalid key for setting colors, you may set colors using integer indices, slices, or "
- f"fancy indexing using an array of integers or bool"
- )
-
- self.buffer.data[key] = value
-
- self._update_range(key)
-
- if len(self._event_handlers) < 1:
- return
-
- event_info = {
- "key": user_key,
- "value": value,
- "user_value": user_value,
- }
-
- event = FeatureEvent("colors", info=event_info)
- self._call_event_handlers(event)
-
- def __len__(self):
- return len(self.buffer.data)
-
-
-# Manages uniform color for line or scatter material
-class UniformColor(GraphicFeature):
- def __init__(
- self, value: str | np.ndarray | tuple | list | pygfx.Color, alpha: float = 1.0
- ):
- v = (*tuple(pygfx.Color(value))[:-1], alpha) # apply alpha
- self._value = pygfx.Color(v)
- super().__init__()
-
- @property
- def value(self) -> pygfx.Color:
- return self._value
-
- def set_value(self, graphic, value: str | np.ndarray | tuple | list | pygfx.Color):
- value = pygfx.Color(value)
- graphic.world_object.material.color = value
- self._value = value
-
- event = FeatureEvent(type="colors", info={"value": value})
- self._call_event_handlers(event)
-
-
-# manages uniform size for scatter material
-class UniformSize(GraphicFeature):
- def __init__(self, value: int | float):
- self._value = float(value)
- super().__init__()
-
- @property
- def value(self) -> float:
- return self._value
-
- def set_value(self, graphic, value: float | int):
- graphic.world_object.material.size = float(value)
- self._value = value
-
- event = FeatureEvent(type="sizes", info={"value": value})
- self._call_event_handlers(event)
-
-
-# manages the coordinate space for scatter/line
-class SizeSpace(GraphicFeature):
- def __init__(self, value: str):
- self._value = value
- super().__init__()
-
- @property
- def value(self) -> str:
- return self._value
-
- def set_value(self, graphic, value: str):
- if "Line" in graphic.world_object.material.__class__.__name__:
- graphic.world_object.material.thickness_space = value
- else:
- graphic.world_object.material.size_space = value
- self._value = value
-
- event = FeatureEvent(type="size_space", info={"value": value})
- self._call_event_handlers(event)
-
-
-class VertexPositions(BufferManager):
- """
- +----------+----------------------------------------------------------+------------------------------------------------------------------------------------------+
- | dict key | value type | value description |
- +==========+==========================================================+==========================================================================================+
- | key | int | slice | np.ndarray[int | bool] | tuple[slice, ...] | key at which vertex positions data were indexed/sliced |
- +----------+----------------------------------------------------------+------------------------------------------------------------------------------------------+
- | value | np.ndarray | float | list[float] | new data values for points that were changed, shape depends on the indices that were set |
- +----------+----------------------------------------------------------+------------------------------------------------------------------------------------------+
-
- """
-
- def __init__(self, data: Any, isolated_buffer: bool = True):
- """
- 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)
-
- 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])
-
- if data.shape[1] != 3:
- if data.shape[1] != 2:
- raise ValueError(f"Must pass 1D, 2D or 3D data")
-
- # zeros for z
- zs = np.zeros(data.shape[0], dtype=data.dtype)
-
- # column stack [x, y, z] to make data of shape [n_points, 3]
- data = np.column_stack([data[:, 0], data[:, 1], zs])
-
- return to_gpu_supported_dtype(data)
-
- def __setitem__(
- self,
- key: int | slice | np.ndarray[int | bool] | tuple[slice, ...],
- value: np.ndarray | float | list[float],
- ):
- # directly use the key to slice the buffer
- self.buffer.data[key] = value
-
- # _update_range handles parsing the key to
- # determine offset and size for GPU upload
- self._update_range(key)
-
- self._emit_event("data", key, value)
-
- def __len__(self):
- return len(self.buffer.data)
-
-
-class PointsSizesFeature(BufferManager):
- """
- +----------+-------------------------------------------------------------------+----------------------------------------------+
- | dict key | value type | value description |
- +==========+===================================================================+==============================================+
- | key | int | slice | np.ndarray[int | bool] | list[int | bool] | key at which point sizes indexed/sliced |
- +----------+-------------------------------------------------------------------+----------------------------------------------+
- | value | int | float | np.ndarray | list[int | float] | tuple[int | float] | 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
-
- 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):
- """line thickness"""
-
- def __init__(self, value: float):
- self._value = value
- super().__init__()
-
- @property
- def value(self) -> float:
- return self._value
-
- def set_value(self, graphic, value: float):
- graphic.world_object.material.thickness = value
- self._value = value
-
- event = FeatureEvent(type="thickness", info={"value": value})
- self._call_event_handlers(event)
-
-
-class VertexCmap(BufferManager):
- """
- Sliceable colormap feature, manages a VertexColors instance and
- provides a way to set colormaps with arbitrary transforms
- """
-
- def __init__(
- self,
- vertex_colors: VertexColors,
- cmap_name: str | None,
- transform: np.ndarray | None,
- alpha: float = 1.0,
- ):
- super().__init__(data=vertex_colors.buffer)
-
- 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):
- raise TypeError(
- f"cmap name must be of type , you have passed: {self._cmap_name} of type: {type(self._cmap_name)}"
- )
-
- if self._transform is not None:
- self._transform = np.asarray(self._transform)
-
- n_datapoints = vertex_colors.value.shape[0]
-
- colors = parse_cmap_values(
- n_colors=n_datapoints,
- cmap_name=self._cmap_name,
- transform=self._transform,
- )
- colors[:, -1] = alpha
- # set vertex colors from cmap
- self._vertex_colors[:] = colors
-
- def __setitem__(self, key: slice, cmap_name):
- if not isinstance(key, slice):
- raise TypeError(
- "fancy indexing not supported for VertexCmap, only slices "
- "of a continuous are supported for apply a cmap"
- )
- if key.step is not None:
- raise TypeError(
- "step sized indexing not currently supported for setting VertexCmap, "
- "slices must be a continuous region"
- )
-
- # parse slice
- start, stop, step = key.indices(self.value.shape[0])
- n_elements = len(range(start, stop, step))
-
- 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
-
- # 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)
-
- @property
- def name(self) -> str:
- return self._cmap_name
-
- @property
- def transform(self) -> np.ndarray | None:
- """Get or set the cmap transform. Maps values from the transform array to the cmap colors"""
- return self._transform
-
- @transform.setter
- def transform(
- self,
- values: np.ndarray | list[float | int],
- indices: slice | list | np.ndarray = None,
- ):
- if self._cmap_name is None:
- raise AttributeError(
- "cmap name is not set, set the cmap name before setting the transform"
- )
-
- values = np.asarray(values)
-
- colors = parse_cmap_values(
- n_colors=self.value.shape[0], cmap_name=self._cmap_name, transform=values
- )
-
- colors[:, -1] = self.alpha
-
- self._transform = values
-
- if indices is None:
- indices = slice(None)
-
- self._vertex_colors[indices] = colors
-
- 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"
- )
-
- def __repr__(self):
- return f"{self.__class__.__name__} | cmap: {self.name}\ntransform: {self.transform}"
diff --git a/fastplotlib/graphics/_positions_base.py b/fastplotlib/graphics/_positions_base.py
index 565a4cd98..af7d7badb 100644
--- a/fastplotlib/graphics/_positions_base.py
+++ b/fastplotlib/graphics/_positions_base.py
@@ -1,15 +1,14 @@
-from typing import Any
+from typing import Any, Sequence
import numpy as np
import pygfx
from ._base import Graphic
-from ._features import (
+from .features import (
VertexPositions,
VertexColors,
UniformColor,
VertexCmap,
- PointsSizesFeature,
SizeSpace,
)
@@ -19,7 +18,7 @@ class PositionsGraphic(Graphic):
@property
def data(self) -> VertexPositions:
- """Get or set the vertex positions data"""
+ """Get or set the graphic's data"""
return self._data
@data.setter
@@ -28,7 +27,7 @@ def data(self, value):
@property
def colors(self) -> VertexColors | pygfx.Color:
- """Get or set the colors data"""
+ """Get or set the colors"""
if isinstance(self._colors, VertexColors):
return self._colors
@@ -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
@@ -45,7 +44,11 @@ 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/
+ """
return self._cmap
@cmap.setter
@@ -58,7 +61,7 @@ def cmap(self, name: str):
@property
def size_space(self):
"""
- The coordinate space in which the size is expressed (‘screen’, ‘world’, ‘model’)
+ The coordinate space in which the size is expressed ('screen', 'world', 'model')
See https://docs.pygfx.org/stable/_autosummary/utils/utils/enums/pygfx.utils.enums.CoordSpace.html#pygfx.utils.enums.CoordSpace for available options.
"""
@@ -73,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,
@@ -108,7 +110,6 @@ def __init__(
self._colors,
cmap_name=cmap,
transform=cmap_transform,
- alpha=alpha,
)
elif isinstance(cmap, VertexCmap):
# use existing cmap instance
@@ -125,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
@@ -135,67 +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 unshare_property(self, property: str):
- """unshare a shared property. Experimental and untested!"""
- if not isinstance(property, str):
- raise TypeError
-
- f = getattr(self, property)
- if f.shared == 0:
- raise BufferError("Cannot detach an independent buffer")
-
- if property == "colors" and isinstance(property, VertexColors):
- self._colors._buffer = pygfx.Buffer(self._colors.value.copy())
- self.world_object.geometry.colors = self._colors.buffer
- self._colors._shared -= 1
-
- elif property == "data":
- self._data._buffer = pygfx.Buffer(self._data.value.copy())
- self.world_object.geometry.positions = self._data.buffer
- self._data._shared -= 1
-
- elif property == "sizes":
- self._sizes._buffer = pygfx.Buffer(self._sizes.value.copy())
- self.world_object.geometry.positions = self._sizes.buffer
- self._sizes._shared -= 1
-
- def share_property(
- self, property: VertexPositions | VertexColors | PointsSizesFeature
- ):
- """share a property from another graphic. Experimental and untested!"""
- if isinstance(property, VertexPositions):
- # TODO: check if this causes a memory leak
- self._data._shared -= 1
-
- self._data = property
- self._data._shared += 1
- self.world_object.geometry.positions = self._data.buffer
-
- elif isinstance(property, VertexColors):
- self._colors._shared -= 1
-
- self._colors = property
- self._colors._shared += 1
- self.world_object.geometry.colors = self._colors.buffer
-
- elif isinstance(property, PointsSizesFeature):
- self._sizes._shared -= 1
+ 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])
+ )
- self._sizes = property
- self._sizes._shared += 1
- self.world_object.geometry.sizes = self._sizes.buffer
+ 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
new file mode 100644
index 000000000..7f7410cf7
--- /dev/null
+++ b/fastplotlib/graphics/features/__init__.py
@@ -0,0 +1,128 @@
+from ._positions import (
+ VertexColors,
+ UniformColor,
+ SizeSpace,
+ VertexPositions,
+ VertexCmap,
+)
+from ._mesh import (
+ MeshIndices,
+ MeshCmap,
+ SurfaceData,
+ PolygonData,
+ resolve_cmap_mesh,
+ surface_data_to_mesh,
+ triangulate_polygon,
+)
+from ._line import Thickness
+from ._scatter import (
+ VertexMarkers,
+ UniformMarker,
+ UniformEdgeColor,
+ EdgeWidth,
+ UniformRotations,
+ VertexRotations,
+ VertexPointSizes,
+ UniformSize,
+)
+from ._image import (
+ TextureArray,
+ ImageCmap,
+ ImageVmin,
+ ImageVmax,
+ 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,
+ GraphicFeatureEvent,
+ to_gpu_supported_dtype,
+)
+
+from ._text import (
+ TextData,
+ FontSize,
+ TextFaceColor,
+ TextOutlineColor,
+ TextOutlineThickness,
+)
+
+from ._selection_features import (
+ LinearSelectionFeature,
+ LinearRegionSelectionFeature,
+ RectangleSelectionFeature,
+)
+from ._common import Name, Offset, Rotation, Scale, Alpha, AlphaMode, Visible, Deleted
+
+
+__all__ = [
+ "VertexColors",
+ "UniformColor",
+ "SizeSpace",
+ "VertexPositions",
+ "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",
+ "TextOutlineColor",
+ "TextOutlineThickness",
+ "LinearSelectionFeature",
+ "LinearRegionSelectionFeature",
+ "RectangleSelectionFeature",
+ "Name",
+ "Offset",
+ "Rotation",
+ "Scale",
+ "Alpha",
+ "AlphaMode",
+ "Visible",
+ "Deleted",
+ "GraphicFeatureEvent",
+]
diff --git a/fastplotlib/graphics/_features/_base.py b/fastplotlib/graphics/features/_base.py
similarity index 82%
rename from fastplotlib/graphics/_features/_base.py
rename to fastplotlib/graphics/features/_base.py
index 1612414a1..779310476 100644
--- a/fastplotlib/graphics/_features/_base.py
+++ b/fastplotlib/graphics/features/_base.py
@@ -1,10 +1,10 @@
from warnings import warn
-from typing import Any, Literal
+from typing import Literal
import numpy as np
from numpy.typing import NDArray
-from wgpu.gui.base import log_exception
+from rendercanvas.base import log_exception
import pygfx
@@ -23,7 +23,7 @@ def to_gpu_supported_dtype(array):
return np.asarray(array).astype(np.float32)
-class FeatureEvent(pygfx.Event):
+class GraphicFeatureEvent(pygfx.Event):
"""
**All event instances have the following attributes**
@@ -34,11 +34,11 @@ class FeatureEvent(pygfx.Event):
+------------+-------------+-----------------------------------------------+
| graphic | Graphic | graphic instance that the event is from |
+------------+-------------+-----------------------------------------------+
- | info | dict | event info dictionary (see below) |
+ | info | dict | event info dictionary |
+------------+-------------+-----------------------------------------------+
| target | WorldObject | pygfx rendering engine object for the graphic |
+------------+-------------+-----------------------------------------------+
- | time_stamp | float | time when the event occured, in ms |
+ | time_stamp | float | time when the event occurred, in ms |
+------------+-------------+-----------------------------------------------+
"""
@@ -49,12 +49,16 @@ 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
+ # used by @block_reentrance decorator to block re-entrance into set_value functions
+ self._reentrant_block: bool = False
+
@property
- def value(self) -> Any:
+ def value(self):
"""Graphic Feature value, must be implemented in subclass"""
raise NotImplemented
@@ -117,7 +121,7 @@ def clear_event_handlers(self):
"""Clear all event handlers"""
self._event_handlers.clear()
- def _call_event_handlers(self, event_data: FeatureEvent):
+ def _call_event_handlers(self, event_data: GraphicFeatureEvent):
if self._block_events:
return
@@ -136,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)
@@ -154,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'"
@@ -164,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"""
@@ -180,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(
@@ -296,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)
@@ -307,7 +306,7 @@ def _emit_event(self, type: str, key, value):
"key": key,
"value": value,
}
- event = FeatureEvent(type, info=event_info)
+ event = GraphicFeatureEvent(type, info=event_info)
self._call_event_handlers(event)
@@ -315,4 +314,34 @@ 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):
+ # decorator to block re-entrant set_value methods
+ # useful when creating complex, circular, bidirectional event graphs
+ def set_value_wrapper(self: GraphicFeature, graphic_or_key, value):
+ """
+ wraps GraphicFeature.set_value
+
+ self: GraphicFeature instance
+
+ graphic_or_key: graphic, or key if a BufferManager
+
+ value: the value passed to set_value()
+ """
+ # set_value is already in the middle of an execution, block re-entrance
+ if self._reentrant_block:
+ return
+ try:
+ # block re-execution of set_value until it has *fully* finished executing
+ self._reentrant_block = True
+ set_value(self, graphic_or_key, value)
+ except Exception as exc:
+ # raise original exception
+ raise exc # set_value has raised. The line above and the lines 2+ steps below are probably more relevant!
+ finally:
+ # set_value has finished executing, now allow future executions
+ self._reentrant_block = False
+
+ return set_value_wrapper
diff --git a/fastplotlib/graphics/features/_common.py b/fastplotlib/graphics/features/_common.py
new file mode 100644
index 000000000..6ce167075
--- /dev/null
+++ b/fastplotlib/graphics/features/_common.py
@@ -0,0 +1,293 @@
+from typing import Sequence
+
+import numpy as np
+
+from ._base import GraphicFeature, GraphicFeatureEvent, block_reentrance
+
+
+class Name(GraphicFeature):
+ event_info_spec = [
+ {"dict key": "value", "type": "str", "description": "user provided name"},
+ ]
+
+ def __init__(self, value: str, property_name: str = "name"):
+ """Graphic name"""
+
+ 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):
+ if not isinstance(value, str):
+ raise TypeError("`Graphic` name must be of type ")
+
+ if graphic._plot_area is not None:
+ graphic._plot_area._check_graphic_name_exists(value)
+
+ self._value = value
+
+ event = GraphicFeatureEvent(type=self._property_name, info={"value": value})
+ self._call_event_handlers(event)
+
+
+class Offset(GraphicFeature):
+ event_info_spec = [
+ {
+ "dict key": "value",
+ "type": "np.ndarray[float, float, float]",
+ "description": "new offset (x, y, z)",
+ },
+ ]
+
+ def __init__(
+ self, value: np.ndarray | Sequence[float], property_name: str = "offset"
+ ):
+ """Offset position of the graphic, [x, y, z]"""
+
+ self._validate(value)
+ # initialize zeros array
+ self._value = np.zeros(3)
+
+ # set values
+ self._value[:] = value
+ super().__init__(property_name=property_name)
+
+ def _validate(self, value):
+ if not len(value) == 3:
+ raise ValueError("offset must be a list, tuple, or array of 3 float values")
+
+ @property
+ def value(self) -> np.ndarray:
+ return self._value
+
+ @block_reentrance
+ def set_value(self, graphic, value: np.ndarray | Sequence[float]):
+ self._validate(value)
+ value = np.asarray(value)
+
+ graphic.world_object.world.position = value
+
+ # sometimes there are transforms so get the final position value like this
+ value = graphic.world_object.world.position.copy()
+
+ # 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 Rotation(GraphicFeature):
+ event_info_spec = [
+ {
+ "dict key": "value",
+ "type": "np.ndarray[float, float, float, float]",
+ "description": "new rotation quaternion",
+ },
+ ]
+
+ def __init__(
+ self, value: np.ndarray | Sequence[float], property_name: str = "rotation"
+ ):
+ """Graphic rotation quaternion"""
+
+ self._validate(value)
+ # create zeros array
+ self._value = np.zeros(4)
+
+ self._value[:] = value
+ super().__init__(property_name=property_name)
+
+ def _validate(self, value):
+ if not len(value) == 4:
+ raise ValueError(
+ "rotation quaternion must be a list, tuple, or array of 4 float values"
+ )
+
+ @property
+ def value(self) -> np.ndarray:
+ return self._value
+
+ @block_reentrance
+ def set_value(self, graphic, value: np.ndarray | Sequence[float]):
+ self._validate(value)
+ value = np.asarray(value)
+
+ graphic.world_object.world.rotation = value
+
+ # get the actual final quaternion value, pygfx adjusts to make sure || q ||_2 == 1
+ # i.e. pygfx checks to make sure norm 1 and other transforms
+ value = graphic.world_object.world.rotation.copy()
+
+ # 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 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."""
+
+ event_info_spec = [
+ {"dict key": "value", "type": "bool", "description": "new visibility bool"},
+ ]
+
+ def __init__(self, value: bool, property_name: str = "visible"):
+ self._value = value
+ super().__init__(property_name=property_name)
+
+ @property
+ def value(self) -> bool:
+ return self._value
+
+ @block_reentrance
+ def set_value(self, graphic, value: bool):
+ graphic.world_object.visible = value
+ self._value = value
+
+ event = GraphicFeatureEvent(type=self._property_name, info={"value": value})
+ self._call_event_handlers(event)
+
+
+class Deleted(GraphicFeature):
+ """
+ Used when a graphic is deleted, triggers events that can be useful to indicate this graphic has been deleted
+ """
+
+ event_info_spec = [
+ {
+ "dict key": "value",
+ "type": "bool",
+ "description": "True when graphic was deleted",
+ },
+ ]
+
+ def __init__(self, value: bool, property_name: str = "deleted"):
+ self._value = value
+ super().__init__(property_name=property_name)
+
+ @property
+ def value(self) -> bool:
+ return self._value
+
+ @block_reentrance
+ def set_value(self, graphic, value: bool):
+ self._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
similarity index 70%
rename from fastplotlib/graphics/_features/_image.py
rename to fastplotlib/graphics/features/_image.py
index b67bf1cd4..648f79bc8 100644
--- a/fastplotlib/graphics/_features/_image.py
+++ b/fastplotlib/graphics/features/_image.py
@@ -5,7 +5,7 @@
import numpy as np
import pygfx
-from ._base import GraphicFeature, FeatureEvent
+from ._base import GraphicFeature, GraphicFeatureEvent, block_reentrance
from ...utils import (
make_colors,
@@ -13,10 +13,28 @@
)
-# manages an array of 8192x8192 Textures representing chunks of an image
class TextureArray(GraphicFeature):
- def __init__(self, data, isolated_buffer: bool = True):
- super().__init__()
+ """
+ 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",
+ "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, property_name: str = "data"):
+ super().__init__(property_name=property_name)
data = self._fix_data(data)
@@ -57,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
@@ -86,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(
@@ -135,13 +147,16 @@ def __next__(self) -> tuple[pygfx.Texture, tuple[int, int], tuple[slice, 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 = FeatureEvent("data", info={"key": key, "value": value})
+ event = GraphicFeatureEvent(
+ self._property_name, info={"key": key, "value": value}
+ )
self._call_event_handlers(event)
def __len__(self):
@@ -151,72 +166,107 @@ def __len__(self):
class ImageVmin(GraphicFeature):
"""lower contrast limit"""
- def __init__(self, value: float):
+ event_info_spec = [
+ {
+ "dict key": "value",
+ "type": "float",
+ "description": "new vmin value",
+ },
+ ]
+
+ 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:
return self._value
+ @block_reentrance
def set_value(self, graphic, value: float):
vmax = graphic._material.clim[1]
graphic._material.clim = (value, vmax)
self._value = value
- event = FeatureEvent(type="vmin", info={"value": value})
+ event = GraphicFeatureEvent(type=self._property_name, info={"value": value})
self._call_event_handlers(event)
class ImageVmax(GraphicFeature):
"""upper contrast limit"""
- def __init__(self, value: float):
+ event_info_spec = [
+ {
+ "dict key": "value",
+ "type": "float",
+ "description": "new vmax value",
+ },
+ ]
+
+ 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:
return self._value
+ @block_reentrance
def set_value(self, graphic, value: float):
vmin = graphic._material.clim[0]
graphic._material.clim = (vmin, value)
self._value = value
- event = FeatureEvent(type="vmax", info={"value": value})
+ event = GraphicFeatureEvent(type=self._property_name, info={"value": value})
self._call_event_handlers(event)
class ImageCmap(GraphicFeature):
"""colormap for texture"""
- def __init__(self, value: str):
+ event_info_spec = [
+ {
+ "dict key": "value",
+ "type": "str",
+ "description": "new cmap name",
+ },
+ ]
+
+ 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:
return self._value
+ @block_reentrance
def set_value(self, graphic, value: str):
new_colors = make_colors(256, value)
graphic._material.map.texture.data[:] = new_colors
graphic._material.map.texture.update_range((0, 0, 0), size=(256, 1, 1))
self._value = value
- event = FeatureEvent(type="cmap", info={"value": value})
+ event = GraphicFeatureEvent(type=self._property_name, info={"value": value})
self._call_event_handlers(event)
class ImageInterpolation(GraphicFeature):
"""Image interpolation method"""
- def __init__(self, value: str):
+ event_info_spec = [
+ {
+ "dict key": "value",
+ "type": "str",
+ "description": "new interpolation method, nearest | linear",
+ },
+ ]
+
+ 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"]:
@@ -226,23 +276,32 @@ def _validate(self, value):
def value(self) -> str:
return self._value
+ @block_reentrance
def set_value(self, graphic, value: str):
self._validate(value)
graphic._material.interpolation = value
self._value = value
- event = FeatureEvent(type="interpolation", info={"value": value})
+ event = GraphicFeatureEvent(type="interpolation", info={"value": value})
self._call_event_handlers(event)
class ImageCmapInterpolation(GraphicFeature):
"""Image cmap interpolation method"""
- def __init__(self, value: str):
+ event_info_spec = [
+ {
+ "dict key": "value",
+ "type": "str",
+ "description": "new cmap interpolatio method, nearest | linear",
+ },
+ ]
+
+ 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"]:
@@ -254,6 +313,7 @@ def _validate(self, value):
def value(self) -> str:
return self._value
+ @block_reentrance
def set_value(self, graphic, value: str):
self._validate(value)
@@ -262,5 +322,5 @@ def set_value(self, graphic, value: str):
graphic._material.map.mag_filter = value
self._value = value
- event = FeatureEvent(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.py b/fastplotlib/graphics/features/_positions.py
new file mode 100644
index 000000000..295d22417
--- /dev/null
+++ b/fastplotlib/graphics/features/_positions.py
@@ -0,0 +1,404 @@
+from typing import Any, Sequence
+
+import numpy as np
+import pygfx
+
+from ...utils import (
+ parse_cmap_values,
+)
+from ._base import (
+ GraphicFeature,
+ BufferManager,
+ GraphicFeatureEvent,
+ to_gpu_supported_dtype,
+ block_reentrance,
+)
+from .utils import parse_colors
+
+
+class VertexColors(BufferManager):
+ event_info_spec = [
+ {
+ "dict key": "key",
+ "type": "slice, index, numpy-like fancy index",
+ "description": "index/slice at which colors were indexed/sliced",
+ },
+ {
+ "dict key": "value",
+ "type": "np.ndarray [n_points_changed, RGBA]",
+ "description": "new color values for points that were changed",
+ },
+ {
+ "dict key": "user_value",
+ "type": "str or array-like",
+ "description": "user input value that was parsed into the RGBA array",
+ },
+ ]
+
+ def __init__(
+ self,
+ colors: str | pygfx.Color | np.ndarray | Sequence[float] | Sequence[str],
+ n_colors: int,
+ isolated_buffer: bool = True,
+ property_name: str = "colors",
+ ):
+ """
+ Manages the vertex color buffer for :class:`LineGraphic` or :class:`ScatterGraphic`
+
+ Parameters
+ ----------
+ 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
+
+ """
+ data = parse_colors(colors, n_colors)
+
+ 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 | pygfx.Color | np.ndarray | Sequence[float] | Sequence[str],
+ ):
+ user_key = key
+
+ if isinstance(key, tuple):
+ # directly setting RGBA values for points, we do no parsing
+ if not isinstance(user_value, (int, float, np.ndarray)):
+ raise TypeError(
+ "Can only set from int, float, or array to set colors directly by slicing the entire array"
+ )
+ value = user_value
+
+ elif isinstance(key, int):
+ # set color of one point
+ n_colors = 1
+ value = parse_colors(user_value, n_colors)
+
+ elif isinstance(key, slice):
+ # find n_colors by converting slice to range and then parse colors
+ start, stop, step = key.indices(self.value.shape[0])
+
+ n_colors = len(range(start, stop, step))
+
+ value = parse_colors(user_value, n_colors)
+
+ elif isinstance(key, (np.ndarray, list)):
+ if isinstance(key, list):
+ # convert to array
+ key = np.array(key)
+
+ # make sure it's 1D
+ if not key.ndim == 1:
+ raise TypeError(
+ "If slicing colors with an array, it must be a 1D bool or int array"
+ )
+
+ 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 {key.size} indices"
+ )
+ n_colors = np.count_nonzero(key)
+
+ elif np.issubdtype(key.dtype, np.integer):
+ n_colors = key.size
+
+ else:
+ raise TypeError(
+ "If slicing colors with an array, it must be a 1D bool or int array"
+ )
+
+ value = parse_colors(user_value, n_colors)
+
+ else:
+ raise TypeError(
+ f"invalid key for setting colors, you may set colors using integer indices, slices, or "
+ f"fancy indexing using an array of integers or bool"
+ )
+
+ self.buffer.data[key] = value
+
+ self._update_range(key)
+
+ if len(self._event_handlers) < 1:
+ return
+
+ event_info = {
+ "key": user_key,
+ "value": value,
+ "user_value": user_value,
+ }
+
+ event = GraphicFeatureEvent(self._property_name, info=event_info)
+ self._call_event_handlers(event)
+
+ def __len__(self):
+ return len(self.buffer.data)
+
+
+class UniformColor(GraphicFeature):
+ event_info_spec = [
+ {
+ "dict key": "value",
+ "type": "str | pygfx.Color | np.ndarray | Sequence[float]",
+ "description": "new color value",
+ },
+ ]
+
+ def __init__(
+ self,
+ value: str | pygfx.Color | np.ndarray | Sequence[float],
+ property_name: str = "colors",
+ ):
+ """Manages uniform color for line or scatter material"""
+
+ 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 | pygfx.Color | np.ndarray | Sequence[float]
+ ):
+ value = pygfx.Color(value)
+ graphic.world_object.material.color = value
+ self._value = value
+
+ event = GraphicFeatureEvent(type=self._property_name, info={"value": value})
+ self._call_event_handlers(event)
+
+
+class SizeSpace(GraphicFeature):
+ event_info_spec = [
+ {
+ "dict key": "value",
+ "type": "str",
+ "description": "'screen' | 'world' | 'model'",
+ },
+ ]
+
+ def __init__(self, value: str, property_name: str = "size_space"):
+ """Manages the coordinate space for scatter/line graphic"""
+
+ 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):
+ if value not in ["screen", "world", "model"]:
+ raise ValueError(
+ f"`size_space` must be one of: {['screen', 'world', 'model']}"
+ )
+
+ if "Line" in graphic.world_object.material.__class__.__name__:
+ graphic.world_object.material.thickness_space = value
+ else:
+ graphic.world_object.material.size_space = value
+ self._value = value
+
+ event = GraphicFeatureEvent(type=self._property_name, info={"value": value})
+ self._call_event_handlers(event)
+
+
+class VertexPositions(BufferManager):
+ event_info_spec = [
+ {
+ "dict key": "key",
+ "type": "slice, index (int) or numpy-like fancy index",
+ "description": "key at which vertex positions data were indexed/sliced",
+ },
+ {
+ "dict key": "value",
+ "type": "int | float | array-like",
+ "description": "new data values for points that were changed",
+ },
+ ]
+
+ 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, property_name=property_name
+ )
+
+ def _fix_data(self, 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])
+
+ if data.shape[1] != 3:
+ if data.shape[1] != 2:
+ raise ValueError(f"Must pass 1D, 2D or 3D data")
+
+ # zeros for z
+ zs = np.zeros(data.shape[0], dtype=data.dtype)
+
+ # column stack [x, y, z] to make data of shape [n_points, 3]
+ data = np.column_stack([data[:, 0], data[:, 1], zs])
+
+ return to_gpu_supported_dtype(data)
+
+ @block_reentrance
+ def __setitem__(
+ self,
+ key: int | slice | np.ndarray[int | bool] | tuple[slice, ...],
+ value: np.ndarray | float | list[float],
+ ):
+ # directly use the key to slice the buffer
+ self.buffer.data[key] = value
+
+ # _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 VertexCmap(BufferManager):
+ event_info_spec = [
+ {
+ "dict key": "key",
+ "type": "slice",
+ "description": "key at cmap colors were sliced",
+ },
+ {
+ "dict key": "value",
+ "type": "str",
+ "description": "new cmap to set at given slice",
+ },
+ ]
+
+ def __init__(
+ self,
+ vertex_colors: VertexColors,
+ cmap_name: str | None,
+ transform: np.ndarray | None,
+ 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, property_name=property_name)
+
+ self._vertex_colors = vertex_colors
+ self._cmap_name = cmap_name
+ self._transform = transform
+
+ if self._cmap_name is not None:
+ if not isinstance(self._cmap_name, str):
+ raise TypeError(
+ f"cmap name must be of type , you have passed: {self._cmap_name} of type: {type(self._cmap_name)}"
+ )
+
+ if self._transform is not None:
+ self._transform = np.asarray(self._transform)
+
+ n_datapoints = vertex_colors.value.shape[0]
+
+ colors = parse_cmap_values(
+ n_colors=n_datapoints,
+ cmap_name=self._cmap_name,
+ transform=self._transform,
+ )
+ # set vertex colors from cmap
+ self._vertex_colors[:] = colors
+
+ @block_reentrance
+ def __setitem__(self, key: slice, cmap_name):
+ if not isinstance(key, slice):
+ raise TypeError(
+ "fancy indexing not supported for VertexCmap, only slices "
+ "of a continuous range are supported for applying a cmap"
+ )
+ if key.step is not None:
+ raise TypeError(
+ "step sized indexing not currently supported for setting VertexCmap, "
+ "slices must be a continuous range"
+ )
+
+ # parse slice
+ start, stop, step = key.indices(self.value.shape[0])
+ n_elements = len(range(start, stop, step))
+
+ colors = parse_cmap_values(
+ n_colors=n_elements, cmap_name=cmap_name, transform=self._transform
+ )
+
+ self._cmap_name = cmap_name
+ self._vertex_colors[key] = colors
+
+ # 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(self._property_name, key, cmap_name)
+
+ @property
+ def name(self) -> str:
+ return self._cmap_name
+
+ @property
+ def transform(self) -> np.ndarray | None:
+ """Get or set the cmap transform. Maps values from the transform array to the cmap colors"""
+ return self._transform
+
+ @transform.setter
+ def transform(
+ self,
+ values: np.ndarray | list[float | int],
+ indices: slice | list | np.ndarray = None,
+ ):
+ if self._cmap_name is None:
+ raise AttributeError(
+ "cmap name is not set, set the cmap name before setting the transform"
+ )
+
+ values = np.asarray(values)
+
+ colors = parse_cmap_values(
+ n_colors=self.value.shape[0], cmap_name=self._cmap_name, transform=values
+ )
+
+ self._transform = values
+
+ if indices is None:
+ indices = slice(None)
+
+ self._vertex_colors[indices] = colors
+
+ self._emit_event("cmap.transform", indices, values)
+
+ def __len__(self):
+ raise NotImplementedError(
+ "len not implemented for `cmap`, use len(colors) instead"
+ )
+
+ def __repr__(self):
+ return f"{self.__class__.__name__} | cmap: {self.name}\ntransform: {self.transform}"
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
similarity index 57%
rename from fastplotlib/graphics/_features/_selection_features.py
rename to fastplotlib/graphics/features/_selection_features.py
index c385f820f..9b30dd70c 100644
--- a/fastplotlib/graphics/_features/_selection_features.py
+++ b/fastplotlib/graphics/features/_selection_features.py
@@ -1,30 +1,29 @@
-from typing import Sequence, Tuple
+from typing import Sequence
import numpy as np
+import pygfx as gfx
from ...utils import mesh_masks
-from ._base import GraphicFeature, FeatureEvent
+from ._base import GraphicFeature, GraphicFeatureEvent, block_reentrance
+from ...utils.triangulation import triangulate
class LinearSelectionFeature(GraphicFeature):
- """
- **additional event attributes:**
-
- +--------------------+----------+------------------------------------+
- | attribute | type | description |
- +====================+==========+====================================+
- | get_selected_index | callable | returns indices under the selector |
- +--------------------+----------+------------------------------------+
-
- **info dict:**
-
- +----------+------------+-------------------------------+
- | dict key | value type | value description |
- +==========+============+===============================+
- | value | np.ndarray | new x or y value of selection |
- +----------+------------+-------------------------------+
-
- """
+ event_info_spec = [
+ {
+ "dict key": "value",
+ "type": "float",
+ "description": "new x or y value of selection",
+ },
+ ]
+
+ event_extra_attrs = [
+ {
+ "attribute": "get_selected_index",
+ "type": "callable",
+ "description": "returns index under the selector",
+ }
+ ]
def __init__(self, axis: str, value: float, limits: tuple[float, float]):
"""
@@ -41,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
@@ -54,6 +53,7 @@ def value(self) -> np.float32:
"""
return self._value
+ @block_reentrance
def set_value(self, selector, value: float):
# clip value between limits
value = np.clip(value, self._limits[0], self._limits[1], dtype=np.float32)
@@ -64,42 +64,42 @@ 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 = FeatureEvent("selection", {"value": value})
+ event = GraphicFeatureEvent(self._property_name, {"value": value})
event.get_selected_index = selector.get_selected_index
self._call_event_handlers(event)
class LinearRegionSelectionFeature(GraphicFeature):
- """
- **additional event attributes:**
-
- +----------------------+----------+------------------------------------+
- | attribute | type | description |
- +======================+==========+====================================+
- | get_selected_indices | callable | returns indices under the selector |
- +----------------------+----------+------------------------------------+
- | get_selected_data | callable | returns data under the selector |
- +----------------------+----------+------------------------------------+
-
- **info dict:**
-
- +----------+------------+-----------------------------+
- | dict key | value type | value description |
- +==========+============+=============================+
- | value | np.ndarray | new [min, max] of selection |
- +----------+------------+-----------------------------+
-
- """
+ event_info_spec = [
+ {
+ "dict key": "value",
+ "type": "np.ndarray",
+ "description": "new [min, max] of 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: tuple[int, int], axis: str, limits: tuple[float, float]):
- super().__init__()
+ super().__init__(property_name="selection")
self._axis = axis
self._limits = limits
@@ -117,6 +117,7 @@ def axis(self) -> str:
"""one of "x" | "y" """
return self._axis
+ @block_reentrance
def set_value(self, selector, value: Sequence[float]):
"""
Set start, stop range of selector
@@ -151,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
@@ -164,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 = FeatureEvent("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
@@ -193,33 +194,33 @@ def set_value(self, selector, value: Sequence[float]):
class RectangleSelectionFeature(GraphicFeature):
- """
- **additional event attributes:**
-
- +----------------------+----------+------------------------------------+
- | attribute | type | description |
- +======================+==========+====================================+
- | get_selected_indices | callable | returns indices under the selector |
- +----------------------+----------+------------------------------------+
- | get_selected_data | callable | returns data under the selector |
- +----------------------+----------+------------------------------------+
-
- **info dict:**
-
- +----------+------------+-------------------------------------------+
- | dict key | value type | value description |
- +==========+============+===========================================+
- | value | np.ndarray | new [xmin, xmax, ymin, ymax] of selection |
- +----------+------------+-------------------------------------------+
-
- """
+ event_info_spec = [
+ {
+ "dict key": "value",
+ "type": "np.ndarray",
+ "description": "new [xmin, xmax, ymin, ymax] of 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: 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)
@@ -231,6 +232,7 @@ def value(self) -> np.ndarray[float]:
"""
return self._value
+ @block_reentrance
def set_value(self, selector, value: Sequence[float]):
"""
Set the selection of the rectangle selector.
@@ -333,7 +335,112 @@ def set_value(self, selector, value: Sequence[float]):
if len(self._event_handlers) < 1:
return
- event = FeatureEvent("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
similarity index 52%
rename from fastplotlib/graphics/_features/_text.py
rename to fastplotlib/graphics/features/_text.py
index baa2734d5..ed0485d3a 100644
--- a/fastplotlib/graphics/_features/_text.py
+++ b/fastplotlib/graphics/features/_text.py
@@ -2,91 +2,136 @@
import pygfx
-from ._base import GraphicFeature, FeatureEvent
+from ._base import GraphicFeature, GraphicFeatureEvent, block_reentrance
class TextData(GraphicFeature):
+ event_info_spec = [
+ {
+ "dict key": "value",
+ "type": "str",
+ "description": "new text data",
+ },
+ ]
+
def __init__(self, value: str):
self._value = value
- super().__init__()
+ super().__init__(property_name="text")
@property
def value(self) -> str:
return self._value
+ @block_reentrance
def set_value(self, graphic, value: str):
- graphic.world_object.geometry.set_text(value)
+ graphic.world_object.set_text(value)
self._value = value
- event = FeatureEvent(type="text", info={"value": value})
+ event = GraphicFeatureEvent(type=self._property_name, info={"value": value})
self._call_event_handlers(event)
class FontSize(GraphicFeature):
+ event_info_spec = [
+ {
+ "dict key": "value",
+ "type": "float | int",
+ "description": "new font size",
+ },
+ ]
+
def __init__(self, value: float | int):
self._value = value
- super().__init__()
+ super().__init__(property_name="font_size")
@property
def value(self) -> float | int:
return self._value
+ @block_reentrance
def set_value(self, graphic, value: float | int):
- graphic.world_object.geometry.font_size = value
- self._value = graphic.world_object.geometry.font_size
+ graphic.world_object.font_size = value
+ self._value = graphic.world_object.font_size
- event = FeatureEvent(type="font_size", info={"value": value})
+ event = GraphicFeatureEvent(type=self._property_name, info={"value": value})
self._call_event_handlers(event)
class TextFaceColor(GraphicFeature):
+ event_info_spec = [
+ {
+ "dict key": "value",
+ "type": "str | np.ndarray",
+ "description": "new text color",
+ },
+ ]
+
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:
return self._value
+ @block_reentrance
def set_value(self, graphic, value: str | np.ndarray | list[float] | tuple[float]):
value = pygfx.Color(value)
graphic.world_object.material.color = value
self._value = graphic.world_object.material.color
- event = FeatureEvent(type="face_color", info={"value": value})
+ event = GraphicFeatureEvent(type=self._property_name, info={"value": value})
self._call_event_handlers(event)
class TextOutlineColor(GraphicFeature):
+ event_info_spec = [
+ {
+ "dict key": "value",
+ "type": "str | np.ndarray",
+ "description": "new outline color",
+ },
+ ]
+
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:
return self._value
+ @block_reentrance
def set_value(self, graphic, value: str | np.ndarray | list[float] | tuple[float]):
value = pygfx.Color(value)
graphic.world_object.material.outline_color = value
self._value = graphic.world_object.material.outline_color
- event = FeatureEvent(type="outline_color", info={"value": value})
+ event = GraphicFeatureEvent(type=self._property_name, info={"value": value})
self._call_event_handlers(event)
class TextOutlineThickness(GraphicFeature):
+ event_info_spec = [
+ {
+ "dict key": "value",
+ "type": "float",
+ "description": "new text outline thickness",
+ },
+ ]
+
def __init__(self, value: float):
self._value = value
- super().__init__()
+ super().__init__(property_name="outline_thickness")
@property
def value(self) -> float:
return self._value
+ @block_reentrance
def set_value(self, graphic, value: float):
graphic.world_object.material.outline_thickness = value
self._value = graphic.world_object.material.outline_thickness
- event = FeatureEvent(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
similarity index 69%
rename from fastplotlib/graphics/_features/utils.py
rename to 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 8b937023b..44bffcedc 100644
--- a/fastplotlib/graphics/image.py
+++ b/fastplotlib/graphics/image.py
@@ -5,8 +5,13 @@
from ..utils import quick_min_max
from ._base import Graphic
-from .selectors import LinearSelector, LinearRegionSelector, RectangleSelector
-from ._features import (
+from .selectors import (
+ LinearSelector,
+ LinearRegionSelector,
+ RectangleSelector,
+ PolygonSelector,
+)
+from .features import (
TextureArray,
ImageCmap,
ImageVmin,
@@ -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
@@ -71,13 +85,20 @@ def chunk_index(self) -> tuple[int, int]:
class ImageGraphic(Graphic):
- _features = {"data", "cmap", "vmin", "vmax", "interpolation", "cmap_interpolation"}
+ _features = {
+ "data": TextureArray,
+ "cmap": ImageCmap,
+ "vmin": ImageVmin,
+ "vmax": ImageVmax,
+ "interpolation": ImageInterpolation,
+ "cmap_interpolation": ImageCmapInterpolation,
+ }
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",
@@ -93,14 +114,15 @@ 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
+ colormap to use to display the data. For supported colormaps see the
+ ``cmap`` library catalogue: https://cmap-docs.readthedocs.io/en/stable/catalog/
interpolation: str, optional, default "nearest"
interpolation filter, one of "nearest" or "linear"
@@ -111,10 +133,11 @@ def __init__(
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.
+ If False, the input array is itself used as the buffer - useful if the
+ array is large.
kwargs:
- additional keyword arguments passed to Graphic
+ additional keyword arguments passed to :class:`.Graphic`
"""
@@ -122,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)
@@ -158,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),
@@ -192,11 +223,16 @@ def data(self, data):
self._data[:] = data
@property
- def cmap(self) -> str:
- """colormap name"""
- if self.data.value.ndim > 2:
- raise AttributeError("RGB(A) images do not have a colormap property")
- return self._cmap.value
+ def cmap(self) -> str | None:
+ """
+ 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._cmap is not None:
+ return self._cmap.value
+
+ return None
@cmap.setter
def cmap(self, name: str):
@@ -224,7 +260,7 @@ def vmax(self, value: float):
@property
def interpolation(self) -> str:
- """image data interpolation method"""
+ """Data interpolation method"""
return self._interpolation.value
@interpolation.setter
@@ -242,12 +278,7 @@ def cmap_interpolation(self, value: str):
def reset_vmin_vmax(self):
"""
- Reset the vmin, vmax by estimating it from the data
-
- Returns
- -------
- None
-
+ Reset the vmin, vmax by estimating it from the data by subsampling.
"""
vmin, vmax = quick_min_max(self._data.value)
@@ -255,19 +286,19 @@ def reset_vmin_vmax(self):
self.vmax = vmax
def add_linear_selector(
- self, selection: int = None, axis: str = "x", padding: float = None, **kwargs
+ self, selection: int = None, axis: str = "x", **kwargs
) -> LinearSelector:
"""
Adds a :class:`.LinearSelector`.
+ Selectors are just ``Graphic`` objects, so you can manage, remove, or delete them
+ from a plot area just like any other ``Graphic``.
+
Parameters
----------
selection: int, optional
initial position of the selector
- padding: float, optional
- pad the length of the selector
-
kwargs:
passed to :class:`.LinearSelector`
@@ -278,22 +309,12 @@ def add_linear_selector(
"""
if axis == "x":
- size = self._data.value.shape[0]
- center = size / 2
limits = (0, self._data.value.shape[1])
elif axis == "y":
- size = self._data.value.shape[1]
- center = size / 2
limits = (0, self._data.value.shape[0])
else:
raise ValueError("`axis` must be one of 'x' | 'y'")
- # default padding is 25% the height or width of the image
- if padding is None:
- size *= 1.25
- else:
- size += padding
-
if selection is None:
selection = limits[0]
@@ -305,8 +326,6 @@ def add_linear_selector(
selector = LinearSelector(
selection=selection,
limits=limits,
- size=size,
- center=center,
axis=axis,
parent=self,
**kwargs,
@@ -314,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(
@@ -328,8 +344,10 @@ def add_linear_region_selector(
**kwargs,
) -> LinearRegionSelector:
"""
- Add a :class:`.LinearRegionSelector`. Selectors are just ``Graphic`` objects, so you can manage,
- remove, or delete them from a plot area just like any other ``Graphic``.
+ Add a :class:`.LinearRegionSelector`.
+
+ Selectors are just ``Graphic`` objects, so you can manage, remove, or delete them
+ from a plot area just like any other ``Graphic``.
Parameters
----------
@@ -348,7 +366,6 @@ def add_linear_region_selector(
Returns
-------
LinearRegionSelector
- linear selection graphic
"""
@@ -391,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(
@@ -403,13 +417,16 @@ def add_rectangle_selector(
**kwargs,
) -> RectangleSelector:
"""
- Add a :class:`.RectangleSelector`. Selectors are just ``Graphic`` objects, so you can manage,
- remove, or delete them from a plot area just like any other ``Graphic``.
+ Add a :class:`.RectangleSelector`.
+
+ Selectors are just ``Graphic`` objects, so you can manage, remove, or delete them
+ from a plot area just like any other ``Graphic``.
Parameters
----------
selection: (float, float, float, float), optional
initial (xmin, xmax, ymin, ymax) of the selection
+
"""
# default selection is 25% of the diagonal
if selection is None:
@@ -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 8fe505ba9..a4f42704f 100644
--- a/fastplotlib/graphics/line.py
+++ b/fastplotlib/graphics/line.py
@@ -5,22 +5,40 @@
import pygfx
from ._positions_base import PositionsGraphic
-from .selectors import LinearRegionSelector, LinearSelector, RectangleSelector
-from ._features import Thickness, SizeSpace
+from .selectors import (
+ LinearRegionSelector,
+ LinearSelector,
+ RectangleSelector,
+ PolygonSelector,
+)
+from .features import (
+ Thickness,
+ VertexPositions,
+ VertexColors,
+ UniformColor,
+ VertexCmap,
+ SizeSpace,
+)
+from ..utils import quick_min_max
class LineGraphic(PositionsGraphic):
- _features = {"data", "colors", "cmap", "thickness", "size_space"}
+ _features = {
+ "data": VertexPositions,
+ "colors": (VertexColors, UniformColor),
+ "cmap": (VertexCmap, None), # none if UniformColor
+ "thickness": Thickness,
+ "size_space": SizeSpace,
+ }
def __init__(
self,
data: Any,
thickness: float = 2.0,
- colors: str | np.ndarray | Iterable = "w",
+ colors: str | np.ndarray | Sequence = "w",
uniform_color: bool = False,
- alpha: float = 1.0,
cmap: str = None,
- cmap_transform: np.ndarray | Iterable = None,
+ cmap_transform: np.ndarray | Sequence = None,
isolated_buffer: bool = True,
size_space: str = "screen",
**kwargs,
@@ -31,34 +49,35 @@ def __init__(
Parameters
----------
data: array-like
- Line data to plot, 2D must be of shape [n_points, 2], 3D must be of shape [n_points, 3]
+ Line data to plot. Can provide 1D, 2D, or a 3D data.
+ | If passing a 1D array, it is used to set the y-values and the x-values are generated as an integer range
+ from [0, data.size]
+ | 2D data must be of shape [n_points, 2]. 3D data must be of shape [n_points, 3]
thickness: float, optional, default 2.0
thickness of the line
colors: str, array, or iterable, default "w"
specify colors as a single human-readable string, a single RGBA array,
- or an iterable of strings or RGBA arrays
+ or a Sequence (array, tuple, or list) of strings or RGBA arrays
uniform_color: bool, default ``False``
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"
+ Apply a colormap to the line instead of assigning colors manually, this
+ overrides any argument passed to "colors". For supported colormaps see the
+ ``cmap`` library catalogue: https://cmap-docs.readthedocs.io/en/stable/catalog/
cmap_transform: 1D array-like of numerical values, optional
if provided, these values are used to map the colors from the cmap
size_space: str, default "screen"
- coordinate space in which the size is expressed (‘screen’, ‘world’, ‘model’)
+ coordinate space in which the thickness is expressed ("screen", "world", "model")
**kwargs
- passed to Graphic
+ passed to :class:`.Graphic`
"""
@@ -66,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,
@@ -78,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
@@ -107,7 +132,7 @@ def __init__(
@property
def thickness(self) -> float:
- """line thickness"""
+ """Get or set the line thickness"""
return self._thickness.value
@thickness.setter
@@ -115,24 +140,22 @@ def thickness(self, value: float):
self._thickness.set_value(self, value)
def add_linear_selector(
- self, selection: float = None, padding: float = 0.0, axis: str = "x", **kwargs
+ self, selection: float = None, axis: str = "x", **kwargs
) -> LinearSelector:
"""
- Adds a linear selector.
+ Adds a :class:`.LinearSelector`.
+
+ Selectors are just ``Graphic`` objects, so you can manage, remove, or delete them from a
+ plot area just like any other ``Graphic``.
- Parameters
- ----------
Parameters
----------
selection: float, optional
- selected point on the linear selector, computed from data if not provided
+ selected point on the linear selector, by default the first datapoint on the line.
axis: str, default "x"
axis that the selector resides on
- padding: float, default 0.0
- Extra padding to extend the linear selector along the orthogonal axis to make it easier to interact with.
-
kwargs
passed to :class:`.LinearSelector`
@@ -143,7 +166,7 @@ def add_linear_selector(
"""
bounds_init, limits, size, center = self._get_linear_selector_init_args(
- axis, padding
+ axis, padding=0
)
if selection is None:
@@ -152,8 +175,6 @@ def add_linear_selector(
selector = LinearSelector(
selection=selection,
limits=limits,
- size=size,
- center=center,
axis=axis,
parent=self,
**kwargs,
@@ -161,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(
@@ -174,8 +192,10 @@ def add_linear_region_selector(
**kwargs,
) -> LinearRegionSelector:
"""
- Add a :class:`.LinearRegionSelector`. Selectors are just ``Graphic`` objects, so you can manage,
- remove, or delete them from a plot area just like any other ``Graphic``.
+ Add a :class:`.LinearRegionSelector`.
+
+ Selectors are just ``Graphic`` objects, so you can manage, remove, or delete them from a
+ plot area just like any other ``Graphic``.
Parameters
----------
@@ -218,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
@@ -231,8 +248,10 @@ def add_rectangle_selector(
**kwargs,
) -> RectangleSelector:
"""
- Add a :class:`.RectangleSelector`. Selectors are just ``Graphic`` objects, so you can manage,
- remove, or delete them from a plot area just like any other ``Graphic``.
+ Add a :class:`.RectangleSelector`.
+
+ Selectors are just ``Graphic`` objects, so you can manage, remove, or delete them from a
+ plot area just like any other ``Graphic``.
Parameters
----------
@@ -270,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
@@ -298,6 +357,6 @@ def _get_linear_selector_init_args(
size = int(np.ptp(magn_vals) * 1.5 + padding)
# center of selector along the other axis
- center = np.nanmean(magn_vals)
+ center = sum(quick_min_max(magn_vals)) / 2
return bounds_init, limits, size, center
diff --git a/fastplotlib/graphics/line_collection.py b/fastplotlib/graphics/line_collection.py
index c4af5dddc..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
@@ -374,8 +371,6 @@ def add_linear_selector(
selector = LinearSelector(
selection=selection,
limits=limits,
- size=size,
- center=center,
axis=axis,
parent=self,
**kwargs,
@@ -383,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(
@@ -440,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:
"""
@@ -488,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()
@@ -528,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,
@@ -562,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
@@ -606,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 8dad7cd43..a2e696a82 100644
--- a/fastplotlib/graphics/scatter.py
+++ b/fastplotlib/graphics/scatter.py
@@ -4,24 +4,59 @@
import pygfx
from ._positions_base import PositionsGraphic
-from ._features import PointsSizesFeature, UniformSize, SizeSpace
+from .features import (
+ VertexPointSizes,
+ UniformSize,
+ SizeSpace,
+ VertexPositions,
+ VertexColors,
+ UniformColor,
+ VertexCmap,
+ VertexMarkers,
+ UniformMarker,
+ UniformEdgeColor,
+ EdgeWidth,
+ UniformRotations,
+ VertexRotations,
+ TextureArray,
+)
class ScatterGraphic(PositionsGraphic):
- _features = {"data", "sizes", "colors", "cmap", "size_space"}
+ _features = {
+ "data": VertexPositions,
+ "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,
- sizes: float | np.ndarray | Iterable[float] = 1,
+ 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,
):
"""
@@ -30,42 +65,101 @@ def __init__(
Parameters
----------
data: array-like
- Scatter data to plot, 2D must be of shape [n_points, 2], 3D must be of shape [n_points, 3]
+ Scatter data to plot, Can provide 2D, or a 3D data. 2D data must be of shape [n_points, 2].
+ 3D data must be of shape [n_points, 3]
- colors: str, array, or iterable, default "w"
- specify colors as a single human readable string, a single RGBA array,
- or an iterable of strings or RGBA arrays
+ colors: str, array, tuple, list, Sequence, default "w"
+ specify colors as a single human-readable string, a single RGBA array,
+ or a Sequence (array, tuple, or list) of strings or RGBA arrays
uniform_color: bool, default False
- if True, uses a uniform buffer for the scatter point colors,
- basically saves GPU VRAM when the entire line has a single color
-
- alpha: float, optional, default 1.0
- alpha value for the colors
+ 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.
cmap: str, optional
apply a colormap to the scatter instead of assigning colors manually, this
- overrides any argument passed to "colors"
+ overrides any argument passed to "colors". For supported colormaps see the
+ ``cmap`` library catalogue: https://cmap-docs.readthedocs.io/en/stable/catalog/
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.
+ 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
- size of the scatter points
+ sizes of the scatter points
uniform_size: bool, default False
- if True, uses a uniform buffer for the scatter point sizes,
- basically saves GPU VRAM when all scatter points are the same size
+ if True, uses a uniform buffer for the scatter point sizes. Useful if you need to
+ 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 Graphic
+ passed to :class:`.Graphic`
"""
@@ -73,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,
@@ -84,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):
@@ -122,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 5158a9239..28c6534a7 100644
--- a/fastplotlib/graphics/selectors/_base_selector.py
+++ b/fastplotlib/graphics/selectors/_base_selector.py
@@ -16,12 +16,17 @@ class MoveInfo:
stores move info for a WorldObject
"""
- # last position for an edge, fill, or vertex in world coordinates
- # can be None, such as key events
- last_position: Union[np.ndarray, None]
+ # The initial selection. Differs per type of selector
+ start_selection: Any
+
+ # The initial world position of the cursor
+ start_position: np.ndarray | None
+
+ # Delta position in world coordinates
+ delta: np.ndarray
# WorldObject or "key" event
- source: Union[WorldObject, str]
+ source: WorldObject | str
# key bindings used to move the selector
@@ -35,7 +40,7 @@ class MoveInfo:
# Selector base class
class BaseSelector(Graphic):
- _features = {"selection"}
+ _fpl_support_tooltip = False
@property
def axis(self) -> str:
@@ -108,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,
@@ -119,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()
@@ -126,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:
@@ -138,16 +151,18 @@ def __init__(
self._hover_responsive: Tuple[WorldObject, ...] = hover_responsive
+ # Original color of object that we change the colors of
+ self._original_colors = {}
+
+ # Colors as they are changed by the hover events, so they can be restored after a move action
+ self._hover_colors = {}
+
if hover_responsive is not None:
- self._original_colors = dict()
- 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
- # current delta in world coordinates
- self.delta: np.ndarray = None
-
self.arrow_keys_modifier = arrow_keys_modifier
# if not False, moves the slider on every render cycle
self._key_move_value = False
@@ -210,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"
@@ -226,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")
@@ -275,9 +290,20 @@ def _move_start(self, event_source: WorldObject, ev):
pygfx ``Event``
"""
- last_position = self._plot_area.map_screen_to_world(ev)
-
- self._move_info = MoveInfo(last_position=last_position, source=event_source)
+ 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,
+ delta=np.zeros_like(position),
+ source=event_source,
+ )
self._moving = True
self._initial_controller_state = self._plot_area.controller.enabled
@@ -300,33 +326,31 @@ def _move(self, ev):
# disable controller during moves
self._plot_area.controller.enabled = False
- # get pointer current world position
- world_pos = self._plot_area.map_screen_to_world(ev)
+ # get pointer current world position, in 'mouse capute mode'
+ world_pos = self._plot_area.map_screen_to_world(ev, allow_outside=True)
- # outside this viewport
- if world_pos is None:
- return
-
- # compute the delta
- self.delta = world_pos - self._move_info.last_position
+ # update the delta
+ self._move_info.delta = world_pos - self._move_info.start_position
self._pygfx_event = ev
- self._move_graphic(self.delta)
-
- # update last position
- self._move_info.last_position = world_pos
+ self._move_graphic(self._move_info)
# restore the initial controller state
# if it was disabled, keep it disabled
self._plot_area.controller.enabled = self._initial_controller_state
- def _move_graphic(self, delta: np.ndarray):
+ def _move_graphic(self, move_info: MoveInfo):
raise NotImplementedError("Must be implemented in subclass")
def _move_end(self, ev):
self._move_info = None
self._moving = False
+ # Reset hover state
+ for wo, color in self._hover_colors.items():
+ wo.material.color = color
+ self._hover_colors.clear()
+
# restore the initial controller state
# if it was disabled, keep it disabled
if self._initial_controller_state is not None:
@@ -362,46 +386,64 @@ def _move_to_pointer(self, ev):
if world_pos is None:
return
- self.delta = world_pos - current_pos_world
+ delta = world_pos - current_pos_world
self._pygfx_event = ev
# use fill by default as the source, such as in region selectors
if len(self._fill) > 0:
- self._move_info = MoveInfo(
- last_position=current_pos_world, source=self._fill[0]
+ move_info = MoveInfo(
+ start_selection=None,
+ start_position=None,
+ delta=delta,
+ source=self._fill[0],
)
# else use an edge, such as for linear selector
else:
- self._move_info = MoveInfo(
- last_position=current_pos_world, source=self._edges[0]
+ move_info = MoveInfo(
+ start_position=None,
+ start_selection=None,
+ delta=delta,
+ source=self._edges[0],
)
- self._move_graphic(self.delta)
- self._move_info = None
+ 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
- wo.material.color = "magenta"
+ if self._moving:
+ self._hover_colors[wo] = "magenta"
+ else:
+ wo.material.color = "magenta"
def _pointer_leave(self, ev):
if self._hover_responsive is None:
return
- # reset colors
- for wo in self._hover_responsive:
- wo.material.color = self._original_colors[wo]
-
self._edge_hovered = False
+ # reset colors
+ for wo in [*self._hover_responsive, *self._outer_edges]:
+ if self._moving:
+ self._hover_colors[wo] = self._original_colors[wo]
+ else:
+ wo.material.color = self._original_colors[wo]
+
def _toggle_arrow_key_moveable(self, ev):
self.arrow_key_events_enabled = not self.arrow_key_events_enabled
@@ -413,15 +455,23 @@ def _key_hold(self):
# set event source
# use fill by default as the source
if len(self._fill) > 0:
- self._move_info = MoveInfo(last_position=None, source=self._fill[0])
+ move_info = MoveInfo(
+ start_selection=None,
+ start_position=None,
+ delta=delta,
+ source=self._fill[0],
+ )
# else use an edge
else:
- self._move_info = MoveInfo(last_position=None, source=self._edges[0])
+ move_info = MoveInfo(
+ start_selection=None,
+ start_position=None,
+ delta=delta,
+ source=self._edges[0],
+ )
# move the graphic
- self._move_graphic(delta=delta)
-
- self._move_info = None
+ self._move_graphic(move_info)
def _key_down(self, ev):
# key bind modifier must be set and must be used for the event
@@ -443,8 +493,6 @@ def _key_up(self, ev):
if ev.key in key_bind_direction.keys():
self._key_move_value = False
- self._move_info = None
-
def _fpl_prepare_del(self):
if hasattr(self, "_pfunc_fill"):
self._plot_area.renderer.remove_event_handler(
diff --git a/fastplotlib/graphics/selectors/_linear.py b/fastplotlib/graphics/selectors/_linear.py
index fe57036a3..0c956d57b 100644
--- a/fastplotlib/graphics/selectors/_linear.py
+++ b/fastplotlib/graphics/selectors/_linear.py
@@ -5,13 +5,16 @@
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
-from ._base_selector import BaseSelector
+from ..features._selection_features import LinearSelectionFeature
+from ._base_selector import BaseSelector, MoveInfo
class LinearSelector(BaseSelector):
+ _features = {"selection": LinearSelectionFeature}
+
@property
def parent(self) -> Graphic:
return self._parent
@@ -73,13 +76,12 @@ def __init__(
self,
selection: float,
limits: Sequence[float],
- size: float,
- center: 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,
):
"""
@@ -93,12 +95,6 @@ def __init__(
limits: (int, int)
(min, max) limits along the x or y-axis for the selector, in data space
- size: float
- size of the selector, usually the range of the data
-
- center: float
- center offset of the selector on the orthogonal axis, usually the data mean
-
axis: str, default "x"
"x" | "y", the axis along which the selector can move
@@ -116,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
@@ -129,64 +128,75 @@ def __init__(
self._limits = np.asarray(limits)
- end_points = [-size / 2, size / 2]
-
if axis == "x":
- xs = np.array([selection, selection])
- ys = np.array(end_points)
- zs = np.zeros(2)
+ xs = np.array([selection, selection], dtype=np.float32)
+ ys = np.array([0, 1], dtype=np.float32)
+ zs = np.zeros(2, dtype=np.float32)
- line_data = np.column_stack([xs, ys, zs])
elif axis == "y":
- xs = np.array(end_points)
- ys = np.array([selection, selection])
- zs = np.zeros(2)
+ xs = np.array([0, 1], dtype=np.float32)
+ ys = np.array([selection, selection], dtype=np.float32)
+ zs = np.zeros(2, dtype=np.float32)
- line_data = np.column_stack([xs, ys, zs])
else:
- raise ValueError("`axis` must be one of 'x' or 'y'")
+ raise ValueError("`axis` must be one of 'x' | 'y'")
- line_data = line_data.astype(np.float32)
+ line_data = np.column_stack([xs, ys, zs])
- if thickness < 1.1:
- material = pygfx.LineThinMaterial
- else:
- material = pygfx.LineMaterial
-
- self.colors_outer = pygfx.Color([0.3, 0.3, 0.3, 1.0])
+ material = pygfx.LineInfiniteSegmentMaterial
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)
- self._move_info: dict = None
-
- if axis == "x":
- offset = (parent.offset[0], center + parent.offset[1], 0)
- elif axis == "y":
- offset = (center + parent.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,
@@ -274,7 +284,7 @@ def _get_selected_index(self, graphic):
return min(round(index), upper_bound)
- def _move_graphic(self, delta: np.ndarray):
+ def _move_graphic(self, move_info: MoveInfo):
"""
Moves the graphic
@@ -285,7 +295,9 @@ def _move_graphic(self, delta: np.ndarray):
"""
- if self.axis == "x":
- self.selection = self.selection + delta[0]
- else:
- self.selection = self.selection + delta[1]
+ # If this the first move in this drag, store initial selection
+ if move_info.start_selection is None:
+ move_info.start_selection = self.selection
+
+ delta = move_info.delta[0] if self.axis == "x" else move_info.delta[1]
+ self.selection = move_info.start_selection + delta
diff --git a/fastplotlib/graphics/selectors/_linear_region.py b/fastplotlib/graphics/selectors/_linear_region.py
index c1e6095f8..70a8dffa8 100644
--- a/fastplotlib/graphics/selectors/_linear_region.py
+++ b/fastplotlib/graphics/selectors/_linear_region.py
@@ -6,11 +6,14 @@
from .._base import Graphic
from .._collection_base import GraphicCollection
-from .._features._selection_features import LinearRegionSelectionFeature
-from ._base_selector import BaseSelector
+from ..features._selection_features import LinearRegionSelectionFeature
+from ._base_selector import BaseSelector, MoveInfo
+from ...utils.enums import RenderQueue
class LinearRegionSelector(BaseSelector):
+ _features = {"selection": LinearRegionSelectionFeature}
+
@property
def parent(self) -> Graphic | None:
"""graphic that the selector is associated with"""
@@ -61,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,
):
"""
@@ -110,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
@@ -138,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,
),
)
@@ -146,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
@@ -186,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
@@ -223,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,
@@ -286,7 +362,7 @@ def get_selected_data(
# slices n_datapoints dim
data_selections.append(g.data[s])
- return source.data[s]
+ return data_selections
else:
if ixs.size == 0:
# empty selection
@@ -366,40 +442,39 @@ def get_selected_indices(
# indices map directly to grid geometry for image data buffer
return np.arange(*bounds, dtype=int)
- def _move_graphic(self, delta: np.ndarray):
+ 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
+
# add delta to current min, max to get new positions
- if self.axis == "x":
- # add x value
- new_min, new_max = self.selection + delta[0]
+ delta = move_info.delta[0] if self.axis == "x" else move_info.delta[1]
- elif self.axis == "y":
- # add y value
- new_min, new_max = self.selection + delta[1]
+ # Get original selection
+ cur_min, cur_max = move_info.start_selection
# move entire selector if event source was fill
- if self._move_info.source == self.fill:
- # prevent weird shrinkage of selector if one edge is already at the limit
- if self.selection[0] == self.limits[0] and new_min < self.limits[0]:
- # self._move_end(None) # TODO: cancel further movement to prevent weird asynchronization with pointer
- return
- if self.selection[1] == self.limits[1] and new_max > self.limits[1]:
- # self._move_end(None)
- return
-
- # move entire selector
- self._selection.set_value(self, (new_min, new_max))
+ if move_info.source == self.fill:
+ # Limit the delta to avoid weird resizine behavior
+ min_delta = self.limits[0] - cur_min
+ max_delta = self.limits[1] - cur_max
+ delta = np.clip(delta, min_delta, max_delta)
+ # Update both bounds with equal amount
+ self._selection.set_value(self, (cur_min + delta, cur_max + delta))
return
- # if selector is not resizable return
+ # if selector not resizable return
if not self._resizable:
return
# if event source was an edge and selector is resizable,
# move the edge that caused the event
- if self._move_info.source == self.edges[0]:
+ if move_info.source == self._edges[0]:
# change only left or bottom bound
- self._selection.set_value(self, (new_min, self._selection.value[1]))
+ new_min = min(cur_min + delta, cur_max)
+ self._selection.set_value(self, (new_min, cur_max))
- elif self._move_info.source == self.edges[1]:
+ elif move_info.source == self._edges[1]:
# change only right or top bound
- self._selection.set_value(self, (self.selection[0], new_max))
+ 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 a4ecd440c..e02c627ac 100644
--- a/fastplotlib/graphics/selectors/_polygon.py
+++ b/fastplotlib/graphics/selectors/_polygon.py
@@ -1,147 +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"
-
- last_position = self._plot_area.map_screen_to_world(ev)
- self._move_info = MoveInfo(last_position=last_position, source=None)
-
- # line with same position for start and end until mouse moves
- data = np.array([last_position, last_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 51c3209b1..e30165dae 100644
--- a/fastplotlib/graphics/selectors/_rectangle.py
+++ b/fastplotlib/graphics/selectors/_rectangle.py
@@ -6,12 +6,15 @@
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
+from ..features import RectangleSelectionFeature
+from ._base_selector import BaseSelector, MoveInfo
class RectangleSelector(BaseSelector):
+ _features = {"selection": RectangleSelectionFeature}
+
@property
def parent(self) -> Graphic | None:
"""Graphic that selector is associated with."""
@@ -22,7 +25,7 @@ def selection(self) -> np.ndarray[float]:
"""
(xmin, xmax, ymin, ymax) of the rectangle selection
"""
- return self._selection.value
+ return self._selection.value.copy()
@selection.setter
def selection(self, selection: Sequence[float]):
@@ -58,7 +61,7 @@ def __init__(
edge_color=(0.8, 0.6, 0),
edge_thickness: float = 8,
vertex_color=(0.7, 0.4, 0),
- vertex_thickness: float = 8,
+ vertex_size: float = 8,
arrow_keys_modifier: str = "Shift",
name: str = None,
):
@@ -81,14 +84,17 @@ def __init__(
if ``True``, the edges can be dragged to resize the selection
fill_color: str, array, or tuple
- fill color for the selector, passed to pygfx.Color
+ fill color for the selector as a str or RGBA array
edge_color: str, array, or tuple
- edge color for the selector, passed to pygfx.Color
+ edge color for the selector as a str or RGBA array
edge_thickness: float, default 8
edge thickness
+ vertex_color: str, array, or tuple
+ vertex color for the selector as a str or RGBA array
+
arrow_keys_modifier: str
modifier key that must be pressed to initiate movement using arrow keys, must be one of:
"Control", "Shift", "Alt" or ``None``
@@ -130,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,
),
)
@@ -148,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
@@ -161,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
@@ -174,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
@@ -187,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] = (
@@ -198,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)
@@ -209,50 +254,66 @@ def __init__(
bottom_right_vertex_data = (xmax, ymin, 1)
top_left_vertex = pygfx.Points(
- pygfx.Geometry(positions=[top_left_vertex_data], sizes=[vertex_thickness]),
+ pygfx.Geometry(positions=[top_left_vertex_data], sizes=[vertex_size]),
pygfx.PointsMarkerMaterial(
marker="square",
- size=vertex_thickness,
+ size=vertex_size,
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,
),
)
top_right_vertex = pygfx.Points(
- pygfx.Geometry(positions=[top_right_vertex_data], sizes=[vertex_thickness]),
+ pygfx.Geometry(positions=[top_right_vertex_data], sizes=[vertex_size]),
pygfx.PointsMarkerMaterial(
marker="square",
- size=vertex_thickness,
+ size=vertex_size,
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,
),
)
bottom_left_vertex = pygfx.Points(
- pygfx.Geometry(
- positions=[bottom_left_vertex_data], sizes=[vertex_thickness]
- ),
+ pygfx.Geometry(positions=[bottom_left_vertex_data], sizes=[vertex_size]),
pygfx.PointsMarkerMaterial(
marker="square",
- size=vertex_thickness,
+ size=vertex_size,
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,
),
)
bottom_right_vertex = pygfx.Points(
- pygfx.Geometry(
- positions=[bottom_right_vertex_data], sizes=[vertex_thickness]
- ),
+ pygfx.Geometry(positions=[bottom_right_vertex_data], sizes=[vertex_size]),
pygfx.PointsMarkerMaterial(
marker="square",
- size=vertex_thickness,
+ size=vertex_size,
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,
),
)
@@ -264,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)
@@ -336,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()
@@ -430,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
-------
@@ -477,50 +537,57 @@ def get_selected_indices(
return ixs
- def _move_graphic(self, delta: np.ndarray):
+ 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
- # new selection positions
- xmin_new = self.selection[0] + delta[0]
- xmax_new = self.selection[1] + delta[0]
- ymin_new = self.selection[2] + delta[1]
- ymax_new = self.selection[3] + delta[1]
+ # add delta to current min, max to get new positions
+ deltax, deltay = move_info.delta[0], move_info.delta[1]
+
+ # Get original selection
+ xmin, xmax, ymin, ymax = move_info.start_selection
# move entire selector if source is fill
- if self._move_info.source == self.fill:
- if self.selection[0] == self.limits[0] and xmin_new < self.limits[0]:
- return
- if self.selection[1] == self.limits[1] and xmax_new > self.limits[1]:
- return
- if self.selection[2] == self.limits[2] and ymin_new < self.limits[2]:
- return
- if self.selection[3] == self.limits[3] and ymax_new > self.limits[3]:
- return
- # set thew new bounds
- self._selection.set_value(self, (xmin_new, xmax_new, ymin_new, ymax_new))
+ if move_info.source == self.fill:
+ # Limit the delta to avoid weird resizine behavior
+ min_deltax = self.limits[0] - xmin
+ max_deltax = self.limits[1] - xmax
+ min_deltay = self.limits[2] - ymin
+ max_deltay = self.limits[3] - ymax
+ deltax = np.clip(deltax, min_deltax, max_deltax)
+ deltay = np.clip(deltay, min_deltay, max_deltay)
+ # Update all bounds with equal amount
+ self._selection.set_value(
+ self, (xmin + deltax, xmax + deltax, ymin + deltay, ymax + deltay)
+ )
return
# if selector not resizable return
if not self._resizable:
return
- xmin, xmax, ymin, ymax = self.selection
+ xmin_new = min(xmin + deltax, xmax)
+ xmax_new = max(xmax + deltax, xmin)
+ ymin_new = min(ymin + deltay, ymax)
+ ymax_new = max(ymax + deltay, ymin)
- if self._move_info.source == self.vertices[0]: # bottom left
+ if move_info.source == self.vertices[0]: # bottom left
self._selection.set_value(self, (xmin_new, xmax, ymin_new, ymax))
- if self._move_info.source == self.vertices[1]: # bottom right
+ if move_info.source == self.vertices[1]: # bottom right
self._selection.set_value(self, (xmin, xmax_new, ymin_new, ymax))
- if self._move_info.source == self.vertices[2]: # top left
+ if move_info.source == self.vertices[2]: # top left
self._selection.set_value(self, (xmin_new, xmax, ymin, ymax_new))
- if self._move_info.source == self.vertices[3]: # top right
+ if move_info.source == self.vertices[3]: # top right
self._selection.set_value(self, (xmin, xmax_new, ymin, ymax_new))
# if event source was an edge and selector is resizable, move the edge that caused the event
- if self._move_info.source == self.edges[0]:
+ if move_info.source == self.edges[0]:
self._selection.set_value(self, (xmin_new, xmax, ymin, ymax))
- if self._move_info.source == self.edges[1]:
+ if move_info.source == self.edges[1]:
self._selection.set_value(self, (xmin, xmax_new, ymin, ymax))
- if self._move_info.source == self.edges[2]:
+ if move_info.source == self.edges[2]:
self._selection.set_value(self, (xmin, xmax, ymin_new, ymax))
- if self._move_info.source == self.edges[3]:
+ if move_info.source == self.edges[3]:
self._selection.set_value(self, (xmin, xmax, ymin, ymax_new))
def _move_to_pointer(self, ev):
diff --git a/fastplotlib/graphics/text.py b/fastplotlib/graphics/text.py
index fcee6129b..37e559576 100644
--- a/fastplotlib/graphics/text.py
+++ b/fastplotlib/graphics/text.py
@@ -1,8 +1,9 @@
import pygfx
import numpy as np
+from ..utils.enums import RenderQueue
from ._base import Graphic
-from ._features import (
+from .features import (
TextData,
FontSize,
TextFaceColor,
@@ -13,13 +14,15 @@
class TextGraphic(Graphic):
_features = {
- "text",
- "font_size",
- "face_color",
- "outline_color",
- "outline_thickness",
+ "text": TextData,
+ "font_size": FontSize,
+ "face_color": TextFaceColor,
+ "outline_color": TextOutlineColor,
+ "outline_thickness": TextOutlineThickness,
}
+ _fpl_support_tooltip = False
+
def __init__(
self,
text: str,
@@ -43,10 +46,10 @@ def __init__(
font_size: float | int, default 10
font size
- face_color: str or array, default "w"
+ face_color: str, array, list, tuple, default "w"
str or RGBA array to set the color of the text
- outline_color: str or array, default "w"
+ outline_color: str, array, list, tuple, default "w"
str or RGBA array to set the outline color of the text
outline_thickness: float, default 0
@@ -66,7 +69,7 @@ def __init__(
* Horizontal values: "left", "center", "right"
**kwargs
- passed to Graphic
+ passed to :class:`.Graphic`
"""
@@ -78,14 +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(
- pygfx.TextGeometry(
- text=self.text,
- font_size=self.font_size,
- screen_space=screen_space,
- anchor=anchor,
- ),
- pygfx.TextMaterial(
+ 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,
@@ -97,9 +107,14 @@ def __init__(
self.offset = offset
+ @property
+ def world_object(self) -> pygfx.Text:
+ """Text world object"""
+ return super(TextGraphic, self).world_object
+
@property
def text(self) -> str:
- """the text displayed"""
+ """Get or set the text"""
return self._text.value
@text.setter
@@ -108,7 +123,7 @@ def text(self, text: str):
@property
def font_size(self) -> float | int:
- """ "text font size"""
+ """Get or set the font size"""
return self._font_size.value
@font_size.setter
@@ -117,7 +132,7 @@ def font_size(self, size: float | int):
@property
def face_color(self) -> pygfx.Color:
- """text face color"""
+ """Get or set the face color"""
return self._face_color.value
@face_color.setter
@@ -126,7 +141,7 @@ def face_color(self, color: str | np.ndarray | list[float] | tuple[float]):
@property
def outline_thickness(self) -> float:
- """text outline thickness"""
+ """Get or set the outline thickness"""
return self._outline_thickness.value
@outline_thickness.setter
@@ -135,7 +150,7 @@ def outline_thickness(self, thickness: float):
@property
def outline_color(self) -> pygfx.Color:
- """text outline color"""
+ """Get or set the outline color"""
return self._outline_color.value
@outline_color.setter
diff --git a/fastplotlib/layouts/__init__.py b/fastplotlib/layouts/__init__.py
index 4a4f45174..23839586c 100644
--- a/fastplotlib/layouts/__init__.py
+++ b/fastplotlib/layouts/__init__.py
@@ -1,11 +1,6 @@
from ._figure import Figure
-
-try:
- import imgui_bundle
-except ImportError:
- IMGUI = False
-else:
- IMGUI = True
+from ._subplot import Subplot
+from ._utils import IMGUI
if IMGUI:
from ._imgui_figure import ImguiFigure
diff --git a/fastplotlib/layouts/_engine.py b/fastplotlib/layouts/_engine.py
new file mode 100644
index 000000000..bf73d5f0d
--- /dev/null
+++ b/fastplotlib/layouts/_engine.py
@@ -0,0 +1,390 @@
+from functools import partial
+
+import numpy as np
+import pygfx
+
+from ._subplot import Subplot
+from ._rect import RectManager
+
+
+class ScreenSpaceCamera(pygfx.Camera):
+ """
+ Same as pygfx.ScreenCoordsCamera but y-axis is inverted.
+
+ So top left corner is (0, 0). This is easier to manage because we
+ often resize using the bottom right corner.
+
+ """
+
+ def _update_projection_matrix(self):
+ width, height = self._view_size
+ sx, sy, sz = 2 / width, 2 / height, 1
+ dx, dy, dz = -1, 1, 0 # pygfx is -1, -1, 0
+ m = sx, 0, 0, dx, 0, sy, 0, dy, 0, 0, sz, dz, 0, 0, 0, 1
+ proj_matrix = np.array(m, dtype=float).reshape(4, 4)
+ proj_matrix.flags.writeable = False
+ return proj_matrix
+
+
+class BaseLayout:
+ def __init__(
+ self,
+ renderer: pygfx.WgpuRenderer,
+ subplots: np.ndarray[Subplot],
+ canvas_rect: tuple[float, float],
+ moveable: bool,
+ resizeable: bool,
+ ):
+ """
+ Base layout engine, subclass to create a usable layout engine.
+ """
+ self._renderer = renderer
+ self._subplots: np.ndarray[Subplot] = subplots.ravel()
+ self._canvas_rect = canvas_rect
+
+ self._last_pointer_pos: np.ndarray[np.float64, np.float64] = np.array(
+ [np.nan, np.nan]
+ )
+
+ # the current user action, move or resize
+ self._active_action: str | None = None
+ # subplot that is currently in action, i.e. currently being moved or resized
+ self._active_subplot: Subplot | None = None
+ # subplot that is in focus, i.e. being hovered by the pointer
+ self._subplot_focus: Subplot | None = None
+
+ for subplot in self._subplots:
+ # highlight plane when pointer enters it
+ subplot.frame.plane.add_event_handler(
+ partial(self._highlight_plane, subplot), "pointer_enter"
+ )
+
+ if resizeable:
+ # highlight/unhighlight resize handler when pointer enters/leaves
+ subplot.frame.resize_handle.add_event_handler(
+ partial(self._highlight_resize_handler, subplot), "pointer_enter"
+ )
+ subplot.frame.resize_handle.add_event_handler(
+ partial(self._unhighlight_resize_handler, subplot), "pointer_leave"
+ )
+
+ def _inside_render_rect(self, subplot: Subplot, pos: tuple[int, int]) -> bool:
+ """whether the pos is within the render area, used for filtering out pointer events"""
+ rect = subplot.frame.get_render_rect()
+
+ x0, y0 = rect[:2]
+
+ x1 = x0 + rect[2]
+ y1 = y0 + rect[3]
+
+ if (x0 < pos[0] < x1) and (y0 < pos[1] < y1):
+ return True
+
+ return False
+
+ def canvas_resized(self, canvas_rect: tuple):
+ """
+ called by figure when canvas is resized
+
+ Parameters
+ ----------
+ canvas_rect: (x, y, w, h)
+ the rect that pygfx can render to, excludes any areas used by imgui.
+
+ """
+
+ self._canvas_rect = canvas_rect
+ for subplot in self._subplots:
+ subplot.frame.canvas_resized(canvas_rect)
+
+ def _highlight_resize_handler(self, subplot: Subplot, ev):
+ if self._active_action == "resize":
+ return
+
+ ev.target.material.color = subplot.frame.resize_handle_color.highlight
+
+ def _unhighlight_resize_handler(self, subplot: Subplot, ev):
+ if self._active_action == "resize":
+ return
+
+ ev.target.material.color = subplot.frame.resize_handle_color.idle
+
+ def _highlight_plane(self, subplot: Subplot, ev):
+ if self._active_action is not None:
+ return
+
+ # reset color of previous focus
+ if self._subplot_focus is not None:
+ self._subplot_focus.frame.plane.material.color = (
+ subplot.frame.plane_color.idle
+ )
+
+ self._subplot_focus = subplot
+ ev.target.material.color = subplot.frame.plane_color.highlight
+
+ def __len__(self):
+ return len(self._subplots)
+
+
+class WindowLayout(BaseLayout):
+ def __init__(
+ self,
+ renderer,
+ subplots: np.ndarray[Subplot],
+ canvas_rect: tuple,
+ moveable=True,
+ resizeable=True,
+ ):
+ """
+ Flexible layout engine that allows freely moving and resizing subplots.
+ Subplots are not allowed to overlap.
+
+ We use a screenspace camera to perform an underlay render pass to draw the
+ subplot frames, there is no depth rendering so we do not allow overlaps.
+
+ """
+
+ super().__init__(renderer, subplots, canvas_rect, moveable, resizeable)
+
+ self._last_pointer_pos: np.ndarray[np.float64, np.float64] = np.array(
+ [np.nan, np.nan]
+ )
+
+ for subplot in self._subplots:
+ if moveable:
+ # start a move action
+ subplot.frame.plane.add_event_handler(
+ partial(self._action_start, subplot, "move"), "pointer_down"
+ )
+ # start a resize action
+ subplot.frame.resize_handle.add_event_handler(
+ partial(self._action_start, subplot, "resize"), "pointer_down"
+ )
+
+ if moveable or resizeable:
+ # when pointer moves, do an iteration of move or resize action
+ self._renderer.add_event_handler(self._action_iter, "pointer_move")
+
+ # end the action when pointer button goes up
+ self._renderer.add_event_handler(self._action_end, "pointer_up")
+
+ def _new_extent_from_delta(self, delta: tuple[int, int]) -> np.ndarray:
+ delta_x, delta_y = delta
+ if self._active_action == "resize":
+ # subtract only from x1, y1
+ new_extent = self._active_subplot.frame.extent - np.asarray(
+ [0, delta_x, 0, delta_y]
+ )
+ else:
+ # moving
+ new_extent = self._active_subplot.frame.extent - np.asarray(
+ [delta_x, delta_x, delta_y, delta_y]
+ )
+
+ x0, x1, y0, y1 = new_extent
+ w = x1 - x0
+ h = y1 - y0
+
+ # make sure width and height are valid
+ # min width, height is 50px
+ if w <= 50: # width > 0
+ new_extent[:2] = self._active_subplot.frame.extent[:2]
+
+ if h <= 50: # height > 0
+ new_extent[2:] = self._active_subplot.frame.extent[2:]
+
+ # ignore movement if this would cause an overlap
+ for subplot in self._subplots:
+ if subplot is self._active_subplot:
+ continue
+
+ if subplot.frame.rect_manager.overlaps(new_extent):
+ # we have an overlap, need to ignore one or more deltas
+ # ignore x
+ if not subplot.frame.rect_manager.is_left_of(
+ x0
+ ) or not subplot.frame.rect_manager.is_right_of(x1):
+ new_extent[:2] = self._active_subplot.frame.extent[:2]
+
+ # ignore y
+ if not subplot.frame.rect_manager.is_above(
+ y0
+ ) or not subplot.frame.rect_manager.is_below(y1):
+ new_extent[2:] = self._active_subplot.frame.extent[2:]
+
+ # make sure all vals are non-negative
+ if (new_extent[:2] < 0).any():
+ # ignore delta_x
+ new_extent[:2] = self._active_subplot.frame.extent[:2]
+
+ if (new_extent[2:] < 0).any():
+ # ignore delta_y
+ new_extent[2:] = self._active_subplot.frame.extent[2:]
+
+ # canvas extent
+ cx0, cy0, cw, ch = self._canvas_rect
+
+ # check if new x-range is beyond canvas x-max
+ if (new_extent[:2] > cx0 + cw).any():
+ new_extent[:2] = self._active_subplot.frame.extent[:2]
+
+ # check if new y-range is beyond canvas y-max
+ if (new_extent[2:] > cy0 + ch).any():
+ new_extent[2:] = self._active_subplot.frame.extent[2:]
+
+ return new_extent
+
+ def _action_start(self, subplot: Subplot, action: str, ev):
+ if self._inside_render_rect(subplot, pos=(ev.x, ev.y)):
+ return
+
+ if ev.button == 1: # left mouse button
+ self._active_action = action
+ if action == "resize":
+ subplot.frame.resize_handle.material.color = (
+ subplot.frame.resize_handle_color.action
+ )
+ elif action == "move":
+ subplot.frame.plane.material.color = subplot.frame.plane_color.action
+ else:
+ raise ValueError
+
+ self._active_subplot = subplot
+ self._last_pointer_pos[:] = ev.x, ev.y
+
+ def _action_iter(self, ev):
+ if self._active_action is None:
+ return
+
+ delta_x, delta_y = self._last_pointer_pos - (ev.x, ev.y)
+ new_extent = self._new_extent_from_delta((delta_x, delta_y))
+ self._active_subplot.frame.extent = new_extent
+ self._last_pointer_pos[:] = ev.x, ev.y
+
+ def _action_end(self, ev):
+ self._active_action = None
+ if self._active_subplot is not None:
+ self._active_subplot.frame.resize_handle.material.color = (
+ self._active_subplot.frame.resize_handle_color.idle
+ )
+ self._active_subplot.frame.plane.material.color = (
+ self._active_subplot.frame.plane_color.idle
+ )
+ self._active_subplot = None
+
+ self._last_pointer_pos[:] = np.nan
+
+ def set_rect(self, subplot: Subplot, rect: tuple | list | np.ndarray):
+ """
+ Set the rect of a Subplot
+
+ Parameters
+ ----------
+ subplot: Subplot
+ the subplot to set the rect of
+
+ rect: (x, y, w, h)
+ as absolute pixels or fractional.
+ 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.
+
+ """
+
+ new_rect = RectManager(*rect, self._canvas_rect)
+ extent = new_rect.extent
+ # check for overlaps
+ for s in self._subplots:
+ if s is subplot:
+ continue
+
+ if s.frame.rect_manager.overlaps(extent):
+ raise ValueError(f"Given rect: {rect} overlaps with another subplot.")
+
+ def set_extent(self, subplot: Subplot, extent: tuple | list | np.ndarray):
+ """
+ Set the extent of a Subplot
+
+ Parameters
+ ----------
+ subplot: Subplot
+ the subplot to set the extent of
+
+ extent: (xmin, xmax, ymin, ymax)
+ as absolute pixels or fractional.
+ 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.
+ Negative values are not allowed. xmax - xmin & ymax - ymin must be > 0.
+
+ """
+
+ new_rect = RectManager.from_extent(extent, self._canvas_rect)
+ extent = new_rect.extent
+ # check for overlaps
+ for s in self._subplots:
+ if s is subplot:
+ continue
+
+ if s.frame.rect_manager.overlaps(extent):
+ raise ValueError(
+ f"Given extent: {extent} overlaps with another subplot."
+ )
+
+
+class GridLayout(WindowLayout):
+ def __init__(
+ self,
+ renderer,
+ subplots: np.ndarray[Subplot],
+ canvas_rect: tuple[float, float, float, float],
+ shape: tuple[int, int],
+ ):
+ """
+ Grid layout engine that auto-sets Frame and Subplot rects such that they maintain
+ a fixed grid layout. Does not allow freely moving or resizing subplots.
+
+ """
+
+ super().__init__(
+ renderer, subplots, canvas_rect, moveable=False, resizeable=False
+ )
+
+ # {Subplot: (row_ix, col_ix)}, dict mapping subplots to their row and col index in the grid layout
+ self._subplot_grid_position: dict[Subplot, tuple[int, int]]
+ self._shape = shape
+
+ @property
+ def shape(self) -> tuple[int, int]:
+ return self._shape
+
+ def set_rect(self, subplot, rect: np.ndarray | list | tuple):
+ raise NotImplementedError(
+ "set_rect() not implemented for GridLayout which is an auto layout manager"
+ )
+
+ def set_extent(self, subplot, extent: np.ndarray | list | tuple):
+ raise NotImplementedError(
+ "set_extent() not implemented for GridLayout which is an auto layout manager"
+ )
+
+ def add_row(self):
+ raise NotImplementedError("Not yet implemented")
+
+ def add_column(self):
+ raise NotImplementedError("Not yet implemented")
+
+ def remove_row(self):
+ raise NotImplementedError("Not yet implemented")
+
+ def remove_column(self):
+ raise NotImplementedError("Not yet implemented")
+
+ def add_subplot(self):
+ raise NotImplementedError(
+ "Not implemented for GridLayout which is an auto layout manager"
+ )
+
+ def remove_subplot(self, subplot):
+ raise NotImplementedError(
+ "Not implemented for GridLayout which is an auto layout manager"
+ )
diff --git a/fastplotlib/layouts/_figure.py b/fastplotlib/layouts/_figure.py
index 70a4d41be..28b7c4a49 100644
--- a/fastplotlib/layouts/_figure.py
+++ b/fastplotlib/layouts/_figure.py
@@ -1,22 +1,24 @@
-import os
+from inspect import getfullargspec
from itertools import product, chain
-from multiprocessing import Queue
+import os
from pathlib import Path
-from time import time
-
-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 ._video_writer import VideoWriterAV
-from ._utils import make_canvas_and_renderer, create_controller, create_camera
+from ._utils import (
+ make_canvas_and_renderer,
+ create_controller,
+ create_camera,
+ get_extents_from_grid,
+)
from ._utils import controller_types as valid_controller_types
from ._subplot import Subplot
+from ._engine import GridLayout, WindowLayout, ScreenSpaceCamera
from .. import ImageGraphic
@@ -24,6 +26,8 @@ class Figure:
def __init__(
self,
shape: tuple[int, int] = (1, 1),
+ 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"]]]
@@ -43,18 +47,38 @@ def __init__(
controllers: pygfx.Controller | Iterable[Iterable[pygfx.Controller]] = None,
canvas: str | BaseRenderCanvas | pygfx.Texture = None,
renderer: pygfx.WgpuRenderer = None,
+ canvas_kwargs: dict = None,
size: tuple[int, int] = (500, 300),
names: list | np.ndarray = None,
):
"""
- A grid of subplots.
+ Create a Figure containing Subplots.
Parameters
----------
- shape: (int, int), default (1, 1)
- (n_rows, n_cols)
-
- cameras: "2d", "3", list of "2d" | "3d", Iterable of camera instances, or Iterable of "2d" | "3d", optional
+ shape: tuple[int, int], default (1, 1)
+ shape [n_rows, n_cols] that defines a grid of 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, 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.
+ Negative values are not allowed. xmax - xmin & ymax - ymin must be > 0.
+
+ If both ``rects`` and ``extents`` are provided, then ``rects`` takes precedence over ``extents``, i.e.
+ ``extents`` is ignored when ``rects`` are also provided.
+
+ cameras: "2d", "3d", list of "2d" | "3d", Iterable of camera instances, or Iterable of "2d" | "3d", optional
| if str, one of ``"2d"`` or ``"3d"`` indicating 2D or 3D cameras for all subplots
| Iterable/list/array of ``2d`` and/or ``3d`` that specifies the camera type for each subplot
| Iterable/list/array of pygfx.PerspectiveCamera instances
@@ -69,7 +93,6 @@ def __init__(
controller_ids: str, list of int, np.ndarray of int, or list with sublists of subplot str names, optional
| If `None` a unique controller is created for each subplot
| If "sync" all the subplots use the same controller
- | If array/list it must be reshapeable to ``grid_shape``.
This allows custom assignment of controllers
@@ -80,9 +103,10 @@ def __init__(
| this syncs subplot_a, subplot_b and subplot_e together; syncs subplot_c and subplot_d together
controllers: pygfx.Controller | list[pygfx.Controller] | np.ndarray[pygfx.Controller], optional
- directly provide pygfx.Controller instances(s). Useful if you want to use a controller from an existing
- plot/subplot. Other controller kwargs, i.e. ``controller_types`` and ``controller_ids`` are ignored if
- ``controllers`` are provided.
+ Directly provide pygfx.Controller instances(s). Useful if you want to use a ``Controller`` from an existing
+ subplot or a ``Controller`` you have already instantiated. Also useful if you want to provide a custom
+ ``Controller`` subclass. Other controller kwargs, i.e. ``controller_types`` and ``controller_ids``
+ are ignored if `controllers` are provided.
canvas: str, BaseRenderCanvas, pygfx.Texture
Canvas to draw the figure onto, usually auto-selected based on running environment.
@@ -90,52 +114,143 @@ def __init__(
renderer: pygfx.Renderer, optional
pygfx renderer instance
+ canvas_kwargs: dict, optional
+ kwargs to pass to the canvas
+
size: (int, int), optional
- starting size of canvas, default (500, 300)
+ starting size of canvas in absolute pixels, default (500, 300)
names: list or array of str, optional
- subplot names
+ subplot names, ignored if extents or rects are provided as a dict
+
"""
+ # create canvas and renderer
+ 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}"
+ )
+ n_subplots = len(rects)
+ layout_mode = "rect"
+ 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), "
+ f"you have passed: {extents}"
+ )
+ n_subplots = len(extents)
+ layout_mode = "extent"
+ rects = [None] * n_subplots
+
+ else:
+ if not all(isinstance(v, (int, np.integer)) for v in shape):
+ raise TypeError(
+ f"shape argument must be a tuple[n_rows, n_cols], you have passed: {shape}"
+ )
+ n_subplots = shape[0] * shape[1]
+ layout_mode = "grid"
- self._shape = shape
+ # create fractional extents from the grid
+ extents = get_extents_from_grid(shape)
+ # empty rects
+ rects = [None] * n_subplots
if names is not None:
- if len(list(chain(*names))) != len(self):
+ # user has specified subplot names
+ subplot_names = np.asarray(names).flatten()
+ # make an array without nones for sanity checks
+ subplot_names_without_nones = subplot_names[subplot_names != np.array(None)]
+
+ # make sure all names are unique
+ if (
+ subplot_names_without_nones.size
+ != np.unique(subplot_names_without_nones).size
+ ):
raise ValueError(
- "must provide same number of subplot `names` as specified by Figure `shape`"
+ f"subplot `names` must be unique, you have provided: {names}"
)
- subplot_names = np.asarray(names).reshape(self.shape)
- else:
- subplot_names = None
+ # check that there are enough subplots given the number of names
+ if subplot_names.size > n_subplots:
+ raise ValueError(
+ f"must provide same number or fewer subplot `names` than number of supblots specified by shape, "
+ f"extents, or rects."
+ f"You have specified {n_subplots} subplots, but {subplot_names.size} subplot names."
+ )
- canvas, renderer = make_canvas_and_renderer(
- canvas, renderer, canvas_kwargs={"size": size}
- )
+ if subplot_names.size < n_subplots:
+ # pad the subplot names with nones
+ subplot_names = np.concatenate(
+ [
+ subplot_names,
+ np.asarray([None] * (n_subplots - subplot_names.size)),
+ ]
+ )
+ else:
+ # no user specified subplot names
+ if layout_mode == "grid":
+ # make names that show the [row index, col index]
+ subplot_names = np.asarray(
+ list(map(str, product(range(shape[0]), range(shape[1]))))
+ )
+ else:
+ subplot_names = None
if isinstance(cameras, str):
# create the array representing the views for each subplot in the grid
- cameras = np.array([cameras] * len(self)).reshape(self.shape)
+ cameras = np.array([cameras] * n_subplots)
- # list -> array if necessary
- cameras = np.asarray(cameras).reshape(self.shape)
+ # list/tuple -> array if necessary
+ cameras = np.asarray(cameras).flatten()
- if cameras.shape != self.shape:
- raise ValueError("Number of cameras does not match the number of subplots")
+ if cameras.size != n_subplots:
+ raise ValueError(
+ f"Number of cameras: {cameras.size} does not match the number of specified subplots: {n_subplots}"
+ )
# create the cameras
- subplot_cameras = np.empty(self.shape, dtype=object)
- for i, j in product(range(self.shape[0]), range(self.shape[1])):
- subplot_cameras[i, j] = create_camera(camera_type=cameras[i, j])
+ subplot_cameras = np.empty(n_subplots, dtype=object)
+ for index in range(n_subplots):
+ subplot_cameras[index] = create_camera(camera_type=cameras[index])
# if controller instances have been specified for each subplot
if controllers is not None:
-
# one controller for all subplots
if isinstance(controllers, pygfx.Controller):
- controllers = [controllers] * len(self)
- # subplot_controllers[:] = controllers
- # # subplot_controllers = np.asarray([controllers] * len(self), dtype=object)
+ controllers = [controllers] * n_subplots
# individual controller instance specified for each subplot
else:
@@ -148,40 +263,37 @@ def __init__(
pass
else:
raise TypeError(
- "controllers argument must be a single pygfx.Controller instance, or a Iterable of "
- "pygfx.Controller instances"
+ f"controllers argument must be a single pygfx.Controller instance, or a Iterable of "
+ f"pygfx.Controller instances. You have passed: {controllers}"
)
- try:
- controllers = np.asarray(controllers).reshape(shape)
- except ValueError:
+ subplot_controllers: np.ndarray[pygfx.Controller] = np.asarray(
+ controllers
+ ).flatten()
+ if not subplot_controllers.size == n_subplots:
raise ValueError(
f"number of controllers passed must be the same as the number of subplots specified "
- f"by shape: {self.shape}. You have passed: <{controllers.size}> controllers"
+ f"by shape, extents, or rects: {n_subplots}. You have passed: {subplot_controllers.size} controllers"
) from None
- subplot_controllers: np.ndarray[pygfx.Controller] = np.empty(
- self.shape, dtype=object
- )
-
- for i, j in product(range(self.shape[0]), range(self.shape[1])):
- subplot_controllers[i, j] = controllers[i, j]
- subplot_controllers[i, j].add_camera(subplot_cameras[i, j])
+ for index in range(n_subplots):
+ subplot_controllers[index].add_camera(subplot_cameras[index])
- # parse controller_ids and controller_types to make desired controller for each supblot
+ # parse controller_ids and controller_types to make desired controller for each subplot
else:
if controller_ids is None:
# individual controller for each subplot
- controller_ids = np.arange(len(self)).reshape(self.shape)
+ controller_ids = np.arange(n_subplots)
elif isinstance(controller_ids, str):
if controller_ids == "sync":
- # this will eventually make one controller for all subplots
- controller_ids = np.zeros(self.shape, dtype=int)
+ # this will end up creating one controller to control the camera of every subplot
+ controller_ids = np.zeros(n_subplots, dtype=int)
else:
raise ValueError(
f"`controller_ids` must be one of 'sync', an array/list of subplot names, or an array/list of "
- f"integer ids. See the docstring for more details."
+ f"integer ids. You have passed: {controller_ids}.\n"
+ f"See the docstring for more details."
)
# list controller_ids
@@ -198,29 +310,36 @@ def __init__(
# make sure each controller_id str is a subplot name
if not all([n in subplot_names for n in ids_flat]):
raise KeyError(
- f"all `controller_ids` strings must be one of the subplot names"
+ f"all `controller_ids` strings must be one of the subplot names. You have passed "
+ f"the following `controller_ids`:\n{controller_ids}\n\n"
+ f"and the following subplot names:\n{subplot_names}"
)
if len(ids_flat) > len(set(ids_flat)):
raise ValueError(
- "id strings must not appear twice in `controller_ids`"
+ f"id strings must not appear twice in `controller_ids`: \n{controller_ids}"
)
# initialize controller_ids array
- ids_init = np.arange(len(self)).reshape(self.shape)
+ ids_init = np.arange(n_subplots)
# set id based on subplot position for each synced sublist
- for i, sublist in enumerate(controller_ids):
+ for row_ix, sublist in enumerate(controller_ids):
for name in sublist:
ids_init[subplot_names == name] = -(
- i + 1
- ) # use negative numbers because why not
+ row_ix + 1
+ ) # use negative numbers to avoid collision with positive numbers from np.arange
controller_ids = ids_init
# integer ids
elif all([isinstance(item, (int, np.integer)) for item in ids_flat]):
- controller_ids = np.asarray(controller_ids).reshape(self.shape)
+ controller_ids = np.asarray(controller_ids).flatten()
+ if controller_ids.max() < 0:
+ raise ValueError(
+ f"if passing an integer array of `controller_ids`, "
+ f"all the integers must be positive:{controller_ids}"
+ )
else:
raise TypeError(
@@ -228,25 +347,28 @@ def __init__(
f"you have passed: {controller_ids}"
)
- if controller_ids.shape != self.shape:
+ if controller_ids.size != n_subplots:
raise ValueError(
- "Number of controller_ids does not match the number of subplots"
+ f"Number of controller_ids: {controller_ids.size} "
+ f"does not match the number of subplots: {n_subplots}"
)
if controller_types is None:
# `create_controller()` will auto-determine controller for each subplot based on defaults
- controller_types = np.array(["default"] * len(self)).reshape(self.shape)
+ controller_types = np.array(["default"] * n_subplots)
# valid controller types
if isinstance(controller_types, str):
- controller_types = [[controller_types]]
+ controller_types = np.array([controller_types] * n_subplots)
- types_flat = list(chain(*controller_types))
+ controller_types: np.ndarray[pygfx.Controller] = np.asarray(
+ controller_types
+ ).flatten()
# str controller_type or pygfx instances
valid_str = list(valid_controller_types.keys()) + ["default"]
# make sure each controller type is valid
- for controller_type in types_flat:
+ for controller_type in controller_types:
if controller_type is None:
continue
@@ -256,12 +378,8 @@ def __init__(
f"Valid `controller_types` arguments are:\n {valid_str}"
)
- controller_types: np.ndarray[pygfx.Controller] = np.asarray(
- controller_types
- ).reshape(self.shape)
-
# make the real controllers for each subplot
- subplot_controllers = np.empty(shape=self.shape, dtype=object)
+ subplot_controllers = np.empty(shape=n_subplots, dtype=object)
for cid in np.unique(controller_ids):
cont_type = controller_types[controller_ids == cid]
if np.unique(cont_type).size > 1:
@@ -289,36 +407,64 @@ def __init__(
for cam in cams[1:]:
_controller.add_camera(cam)
- self._canvas = canvas
- self._renderer = renderer
-
- nrows, ncols = self.shape
+ if layout_mode == "grid":
+ n_rows, n_cols = shape
+ grid_index_iterator = list(product(range(n_rows), range(n_cols)))
+ self._subplots: np.ndarray[Subplot] = np.empty(shape=shape, dtype=object)
+ resizeable = False
- self._subplots: np.ndarray[Subplot] = np.ndarray(
- shape=(nrows, ncols), dtype=object
- )
+ else:
+ self._subplots: np.ndarray[Subplot] = np.empty(
+ shape=n_subplots, dtype=object
+ )
+ resizeable = True
- for i, j in self._get_iterator():
- position = (i, j)
- camera = subplot_cameras[i, j]
- controller = subplot_controllers[i, j]
+ for i in range(n_subplots):
+ camera = subplot_cameras[i]
+ controller = subplot_controllers[i]
if subplot_names is not None:
- name = subplot_names[i, j]
+ name = subplot_names[i]
else:
name = None
- self._subplots[i, j] = Subplot(
+ subplot = Subplot(
parent=self,
- position=position,
- parent_dims=(nrows, ncols),
camera=camera,
controller=controller,
canvas=canvas,
renderer=renderer,
name=name,
+ rect=rects[i],
+ extent=extents[i], # figure created extents for grid layout
+ resizeable=resizeable,
+ )
+
+ if layout_mode == "grid":
+ row_ix, col_ix = grid_index_iterator[i]
+ self._subplots[row_ix, col_ix] = subplot
+ else:
+ self._subplots[i] = subplot
+
+ if layout_mode == "grid":
+ self._layout = GridLayout(
+ self.renderer,
+ subplots=self._subplots,
+ canvas_rect=self.get_pygfx_render_area(),
+ shape=shape,
)
+ elif layout_mode == "rect" or layout_mode == "extent":
+ self._layout = WindowLayout(
+ self.renderer,
+ subplots=self._subplots,
+ canvas_rect=self.get_pygfx_render_area(),
+ )
+
+ # add subplot frames to underlay
+ for subplot in self._subplots.ravel():
+ self._underlay_scene.add(subplot.frame._world_object)
+
self._animate_funcs_pre: list[callable] = list()
self._animate_funcs_post: list[callable] = list()
@@ -328,10 +474,20 @@ def __init__(
self._output = None
+ self._pause_render = False
+
@property
- def shape(self) -> tuple[int, int]:
- """[n_rows, n_cols]"""
- return self._shape
+ def shape(self) -> list[tuple[int, int, int, int]] | tuple[int, int]:
+ """Only for grid layouts of subplots: [n_rows, n_cols]"""
+ if isinstance(self.layout, GridLayout):
+ return self.layout.shape
+
+ @property
+ def layout(self) -> WindowLayout | GridLayout:
+ """
+ Layout engine
+ """
+ return self._layout
@property
def canvas(self) -> BaseRenderCanvas:
@@ -346,59 +502,78 @@ def renderer(self) -> pygfx.WgpuRenderer:
@property
def controllers(self) -> np.ndarray[pygfx.Controller]:
"""controllers, read-only array, access individual subplots to change a controller"""
- controllers = np.asarray(
- [subplot.controller for subplot in self], dtype=object
- ).reshape(self.shape)
+ controllers = np.asarray([subplot.controller for subplot in self], dtype=object)
+
+ if isinstance(self.layout, GridLayout):
+ controllers = controllers.reshape(self.shape)
+
controllers.flags.writeable = False
return controllers
@property
def cameras(self) -> np.ndarray[pygfx.Camera]:
"""cameras, read-only array, access individual subplots to change a camera"""
- cameras = np.asarray(
- [subplot.camera for subplot in self], dtype=object
- ).reshape(self.shape)
+ cameras = np.asarray([subplot.camera for subplot in self], dtype=object)
+
+ if isinstance(self.layout, GridLayout):
+ cameras = cameras.reshape(self.shape)
+
cameras.flags.writeable = False
return cameras
@property
def names(self) -> np.ndarray[str]:
"""subplot names, read-only array, access individual subplots to change a name"""
- names = np.asarray([subplot.name for subplot in self]).reshape(self.shape)
+ names = np.asarray([subplot.name for subplot in self])
+
+ if isinstance(self.layout, GridLayout):
+ names = names.reshape(self.shape)
+
names.flags.writeable = False
return names
- def __getitem__(self, index: tuple[int, int] | str) -> Subplot:
- if isinstance(index, str):
- for subplot in self._subplots.ravel():
- if subplot.name == index:
- return subplot
- raise IndexError(f"no subplot with given name: {index}")
- else:
- return self._subplots[index[0], index[1]]
+ @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}
+
+ 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)
- def render(self, draw=True):
# call the animation functions before render
self._call_animate_functions(self._animate_funcs_pre)
-
for subplot in self:
- subplot.render()
+ subplot._render()
+
+ # overlay render pass
+ 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()
- if draw:
- self.canvas.request_draw()
# call post-render animate functions
self._call_animate_functions(self._animate_funcs_post)
- def start_render(self):
+ if draw:
+ # needs to be here else events don't get processed
+ self.canvas.request_draw()
+
+ def _start_render(self):
"""start render cycle"""
- self.canvas.request_draw(self.render)
+ self.canvas.request_draw(self._render)
def show(
self,
autoscale: bool = True,
maintain_aspect: bool = None,
+ axes_visible: bool = True,
sidecar: bool = False,
sidecar_kwargs: dict = None,
):
@@ -413,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
@@ -431,7 +609,7 @@ def show(
if self._output:
return self._output
- self.start_render()
+ self._start_render()
if sidecar_kwargs is None:
sidecar_kwargs = dict()
@@ -451,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:
@@ -471,8 +654,8 @@ def show(
elif self.canvas.__class__.__name__ == "OffscreenRenderCanvas":
# for test and docs gallery screenshots
+ self._fpl_reset_layout()
for subplot in self:
- subplot.set_viewport_rect()
subplot.axes.update_using_camera()
# render call is blocking only on github actions for some reason,
@@ -481,7 +664,7 @@ def show(
# but it is necessary for the gallery images too so that's why this check is here
if "RTD_BUILD" in os.environ.keys():
if os.environ["RTD_BUILD"] == "1":
- self.render()
+ self._render()
else: # assume GLFW
self._output = self.canvas
@@ -563,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:
@@ -601,7 +815,7 @@ def export_numpy(self, rgb: bool = False) -> np.ndarray:
def export(self, uri: str | Path | bytes, **kwargs):
"""
- Use ``imageio`` for writing the current Figure to a file, or return a byte string.
+ Use ``imageio`` to export the current Figure to a file, or return a byte string.
Must have ``imageio`` installed.
Parameters
@@ -642,14 +856,18 @@ def export(self, uri: str | Path | bytes, **kwargs):
def open_popup(self, *args, **kwargs):
warn("popups only supported by ImguiFigure")
- def get_pygfx_render_area(self, *args) -> tuple[int, int, int, int]:
+ def _fpl_reset_layout(self, *ev):
+ """set the viewport rects for all subplots, *ev argument is not used, exists because of renderer resize event"""
+ self.layout.canvas_resized(self.get_pygfx_render_area())
+
+ def get_pygfx_render_area(self, *args) -> tuple[float, float, float, float]:
"""
- Fet rect for the portion of the canvas that the pygfx renderer draws to,
+ Get rect for the portion of the canvas that the pygfx renderer draws to,
i.e. non-imgui, part of canvas
Returns
-------
- tuple[int, int, int, int]
+ tuple[float, float, float, float]
x_pos, y_pos, width, height
"""
@@ -658,29 +876,93 @@ def get_pygfx_render_area(self, *args) -> tuple[int, int, int, int]:
return 0, 0, width, height
- def _get_iterator(self):
- return product(range(self.shape[0]), range(self.shape[1]))
+ def add_subplot(
+ self,
+ rect=None,
+ extent=None,
+ camera: str | pygfx.PerspectiveCamera = "2d",
+ controller: str | pygfx.Controller = None,
+ name: str = None,
+ ) -> Subplot:
+ if isinstance(self.layout, GridLayout):
+ raise NotImplementedError(
+ "`add_subplot()` is not implemented for Figures using a GridLayout"
+ )
+
+ raise NotImplementedError("Not yet implemented")
+
+ camera = create_camera(camera)
+ controller = create_controller(controller, camera)
+
+ subplot = Subplot(
+ parent=self,
+ camera=camera,
+ controller=controller,
+ canvas=self.canvas,
+ renderer=self.renderer,
+ name=name,
+ rect=rect,
+ extent=extent, # figure created extents for grid layout
+ resizeable=True,
+ )
+
+ return subplot
+
+ def remove_subplot(self, subplot: Subplot):
+ raise NotImplementedError("Not yet implemented")
+
+ if isinstance(self.layout, GridLayout):
+ raise NotImplementedError(
+ "`remove_subplot()` is not implemented for Figures using a GridLayout"
+ )
+
+ if subplot not in self._subplots.tolist():
+ raise KeyError(f"given subplot: {subplot} not found in the layout.")
+
+ subplot.clear()
+ self._underlay_scene.remove(subplot.frame._world_object)
+ subplot.frame._world_object.clear()
+ self.layout._subplots = None
+ subplots = self._subplots.tolist()
+ subplots.remove(subplot)
+ self.layout.remove_subplot(subplot)
+ del subplot
+
+ self._subplots = np.asarray(subplots)
+ self.layout._subplots = self._subplots.ravel()
+
+ def __getitem__(self, index: str | int | tuple[int, int]) -> Subplot:
+ if isinstance(index, str):
+ for subplot in self._subplots.ravel():
+ if subplot.name == index:
+ return subplot
+ raise IndexError(f"no subplot with given name: {index}")
+
+ if isinstance(self.layout, GridLayout):
+ return self._subplots[index[0], index[1]]
+
+ return self._subplots[index]
def __iter__(self):
- self._current_iter = self._get_iterator()
+ self._current_iter = iter(range(len(self)))
return self
def __next__(self) -> Subplot:
pos = self._current_iter.__next__()
- return self._subplots[pos]
+ return self._subplots.ravel()[pos]
def __len__(self):
"""number of subplots"""
- return self.shape[0] * self.shape[1]
+ return len(self._layout)
def __str__(self):
- return f"{self.__class__.__name__} @ {hex(id(self))}"
+ return f"{self.__class__.__name__}"
def __repr__(self):
newline = "\n\t"
return (
- f"fastplotlib.{self.__class__.__name__} @ {hex(id(self))}\n"
+ f"fastplotlib.{self.__class__.__name__}"
f" Subplots:\n"
f"\t{newline.join(subplot.__str__() for subplot in self)}"
f"\n"
diff --git a/fastplotlib/layouts/_frame.py b/fastplotlib/layouts/_frame.py
new file mode 100644
index 000000000..1c308590f
--- /dev/null
+++ b/fastplotlib/layouts/_frame.py
@@ -0,0 +1,384 @@
+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
+from ..graphics import TextGraphic
+
+
+"""
+Each Subplot is framed by a 2D plane mesh, a rectangle.
+The rectangles are viewed using the UnderlayCamera where (0, 0) is the top left corner.
+We can control the bbox of this rectangle by changing the x and y boundaries of the rectangle.
+
+Note how the y values of the plane mesh are negative, this is because of the UnderlayCamera.
+We always just keep the positive y value, and make it negative only when setting the plane mesh.
+
+Illustration:
+
+(0, 0) ---------------------------------------------------
+----------------------------------------------------------
+----------------------------------------------------------
+--------------(x0, -y0) --------------- (x1, -y0) --------
+------------------------|||||||||||||||-------------------
+------------------------|||||||||||||||-------------------
+------------------------|||||||||||||||-------------------
+------------------------|||rectangle|||-------------------
+------------------------|||||||||||||||-------------------
+------------------------|||||||||||||||-------------------
+------------------------|||||||||||||||-------------------
+--------------(x0, -y1) --------------- (x1, -y1)---------
+----------------------------------------------------------
+------------------------------------------- (canvas_width, canvas_height)
+
+"""
+
+
+# wgsl shader snippet for SDF function that defines the resize handler, a lower right triangle.
+sdf_wgsl_resize_handle = """
+// 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;
+"""
+
+
+class MeshMasks:
+ """Used set the x0, x1, y0, y1 positions of the plane mesh"""
+
+ x0 = np.array(
+ [
+ [False, False, False],
+ [True, False, False],
+ [False, False, False],
+ [True, False, False],
+ ]
+ )
+
+ x1 = np.array(
+ [
+ [True, False, False],
+ [False, False, False],
+ [True, False, False],
+ [False, False, False],
+ ]
+ )
+
+ y0 = np.array(
+ [
+ [False, True, False],
+ [False, True, False],
+ [False, False, False],
+ [False, False, False],
+ ]
+ )
+
+ y1 = np.array(
+ [
+ [False, False, False],
+ [False, False, False],
+ [False, True, False],
+ [False, True, False],
+ ]
+ )
+
+
+masks = MeshMasks
+
+
+class Frame:
+ # resize handle color states
+ resize_handle_color = SelectorColorStates(
+ idle=(0.6, 0.6, 0.6, 1), # gray
+ highlight=(1, 1, 1, 1), # white
+ action=(1, 0, 1, 1), # magenta
+ )
+
+ # plane color states
+ plane_color = SelectorColorStates(
+ idle=(0.1, 0.1, 0.1), # dark grey
+ highlight=(0.2, 0.2, 0.2), # less dark grey
+ action=(0.1, 0.1, 0.2), # dark gray-blue
+ )
+
+ def __init__(
+ self,
+ viewport,
+ rect,
+ extent,
+ resizeable,
+ title,
+ docks,
+ toolbar_visible,
+ canvas_rect,
+ ):
+ """
+ Manages the plane mesh, resize handle point, and subplot title.
+ It also sets the viewport rects for the subplot rect and the rects of the docks.
+
+ Note: This is a backend class not meant to be user-facing.
+
+ Parameters
+ ----------
+ viewport: pygfx.Viewport
+ Subplot viewport
+
+ rect: tuple | np.ndarray
+ rect of this subplot
+
+ extent: tuple | np.ndarray
+ extent of this subplot
+
+ resizeable: bool
+ if the Frame is resizeable or not
+
+ title: str
+ subplot title
+
+ docks: dict[str, PlotArea]
+ subplot dock
+
+ toolbar_visible: bool
+ toolbar visibility
+
+ canvas_rect: tuple
+ figure canvas rect, the render area excluding any areas taken by imgui edge windows
+
+ """
+
+ self.viewport = viewport
+ self.docks = docks
+ self._toolbar_visible = toolbar_visible
+
+ # create rect manager to handle all the backend rect calculations
+ if rect is not None:
+ self._rect_manager = RectManager(*rect, canvas_rect)
+ elif extent is not None:
+ self._rect_manager = RectManager.from_extent(extent, canvas_rect)
+ else:
+ raise ValueError("Must provide `rect` or `extent`")
+
+ wobjects = list()
+
+ # make title graphic
+ if title is None:
+ title_text = ""
+ 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(
+ 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)
+
+ # 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]]
+ self._resize_handle = pygfx.Points(
+ # note negative y since y is inverted in UnderlayCamera
+ # 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:
+ self._resize_handle.visible = False
+
+ wobjects.append(self._resize_handle)
+
+ self._world_object = pygfx.Group()
+ self._world_object.add(*wobjects)
+
+ self._reset()
+ self.reset_viewport()
+
+ @property
+ def rect_manager(self) -> RectManager:
+ return self._rect_manager
+
+ @property
+ def extent(self) -> np.ndarray:
+ """extent, (xmin, xmax, ymin, ymax)"""
+ # not actually stored, computed when needed
+ return self._rect_manager.extent
+
+ @extent.setter
+ def extent(self, extent):
+ self._rect_manager.extent = extent
+ self._reset()
+ self.reset_viewport()
+
+ @property
+ def rect(self) -> np.ndarray[int]:
+ """rect in absolute screen space, (x, y, w, h)"""
+ return self._rect_manager.rect
+
+ @rect.setter
+ def rect(self, rect: np.ndarray):
+ self._rect_manager.rect = rect
+ self._reset()
+ self.reset_viewport()
+
+ def reset_viewport(self):
+ """reset the viewport rect for the subplot and docks"""
+
+ # get rect of the render area
+ x, y, w, h = self.get_render_rect()
+
+ # dock sizes
+ s_left = self.docks["left"].size
+ s_top = self.docks["top"].size
+ s_right = self.docks["right"].size
+ s_bottom = self.docks["bottom"].size
+
+ # top and bottom have same width
+ # subtract left and right dock sizes
+ w_top_bottom = w - s_left - s_right
+ # top and bottom have same x pos
+ x_top_bottom = x + s_left
+
+ # set dock rects
+ self.docks["left"].viewport.rect = x, y, s_left, h
+ self.docks["top"].viewport.rect = x_top_bottom, y, w_top_bottom, s_top
+ self.docks["bottom"].viewport.rect = (
+ x_top_bottom,
+ y + h - s_bottom,
+ w_top_bottom,
+ s_bottom,
+ )
+ self.docks["right"].viewport.rect = x + w - s_right, y, s_right, h
+
+ # calc subplot rect by adjusting for dock sizes
+ x += s_left
+ y += s_top
+ w -= s_left + s_right
+ h -= s_top + s_bottom
+
+ # set subplot rect
+ self.viewport.rect = x, y, w, h
+
+ def get_render_rect(self) -> tuple[float, float, float, float]:
+ """
+ Get the actual render area of the subplot, including the docks.
+
+ Excludes area taken by the subplot title and toolbar. Also adds a small amount of spacing around the subplot.
+ """
+ # the rect of the entire Frame
+ x, y, w, h = self.rect
+
+ x += 1 # add 1 so a 1 pixel edge is visible
+ w -= 2 # subtract 2, so we get a 1 pixel edge on both sides
+
+ # add 4 pixels above and below title for better spacing
+ y = y + 4 + self._title_graphic.font_size + 4
+
+ # spacing on the bottom if imgui toolbar is visible
+ if self.toolbar_visible:
+ toolbar_space = IMGUI_TOOLBAR_HEIGHT
+ resize_handle_space = 0
+ else:
+ toolbar_space = 0
+ # need some space for resize handler if imgui toolbar isn't present
+ resize_handle_space = 13
+
+ # adjust for the 4 pixels from the line above
+ # also give space for resize handler if imgui toolbar is not present
+ h = (
+ h
+ - 4
+ - self._title_graphic.font_size
+ - toolbar_space
+ - 4
+ - resize_handle_space
+ )
+
+ return x, y, w, h
+
+ def _reset(self):
+ """reset the plane mesh using the current rect state"""
+
+ x0, x1, y0, y1 = self._rect_manager.extent
+ w = self._rect_manager.w
+
+ self._plane.geometry.positions.data[masks.x0] = x0
+ self._plane.geometry.positions.data[masks.x1] = x1
+
+ # negative y because UnderlayCamera y is inverted
+ self._plane.geometry.positions.data[masks.y0] = -y0
+ self._plane.geometry.positions.data[masks.y1] = -y1
+
+ self._plane.geometry.positions.update_full()
+
+ # note negative y since y is inverted in UnderlayCamera
+ # subtract 7 so that the bottom right corner of the triangle is at the center
+ self._resize_handle.geometry.positions.data[0] = [x1 - 7, -y1 + 7, 0]
+ self._resize_handle.geometry.positions.update_full()
+
+ # set subplot title position
+ x = x0 + (w / 2)
+ y = y0 + (self._title_graphic.font_size / 2)
+ self._title_graphic.world_object.world.x = x
+ self._title_graphic.world_object.world.y = -y - 4 # add 4 pixels for spacing
+
+ @property
+ def toolbar_visible(self) -> bool:
+ return self._toolbar_visible
+
+ @toolbar_visible.setter
+ def toolbar_visible(self, visible: bool):
+ self._toolbar_visible = visible
+ self.reset_viewport()
+
+ @property
+ def title_graphic(self) -> TextGraphic:
+ return self._title_graphic
+
+ @property
+ def plane(self) -> pygfx.Mesh:
+ """the plane mesh"""
+ return self._plane
+
+ @property
+ def resize_handle(self) -> pygfx.Points:
+ """resize handler point"""
+ return self._resize_handle
+
+ def canvas_resized(self, canvas_rect):
+ """called by layout is resized"""
+ self._rect_manager.canvas_resized(canvas_rect)
+ self._reset()
+ self.reset_viewport()
diff --git a/fastplotlib/layouts/_graphic_methods_mixin.py b/fastplotlib/layouts/_graphic_methods_mixin.py
index ea553f119..06a4c7517 100644
--- a/fastplotlib/layouts/_graphic_methods_mixin.py
+++ b/fastplotlib/layouts/_graphic_methods_mixin.py
@@ -4,14 +4,13 @@
import numpy
+import pygfx
+
from ..graphics import *
from ..graphics._base import Graphic
class GraphicMethodsMixin:
- def __init__(self):
- pass
-
def _create_graphic(self, graphic_class, *args, **kwargs) -> Graphic:
if "center" in kwargs.keys():
center = kwargs.pop("center")
@@ -29,13 +28,13 @@ 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",
isolated_buffer: bool = True,
- **kwargs
+ **kwargs,
) -> ImageGraphic:
"""
@@ -45,16 +44,17 @@ def add_image(
----------
data: array-like
array-like, usually numpy.ndarray, must support ``memoryview()``
- | shape must be ``[x_dim, y_dim]``
+ | 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
+ colormap to use to display the data. For supported colormaps see the
+ ``cmap`` library catalogue: https://cmap-docs.readthedocs.io/en/stable/catalog/
interpolation: str, optional, default "nearest"
interpolation filter, one of "nearest" or "linear"
@@ -65,10 +65,11 @@ def add_image(
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.
+ If False, the input array is itself used as the buffer - useful if the
+ array is large.
kwargs:
- additional keyword arguments passed to Graphic
+ additional keyword arguments passed to :class:`.Graphic`
"""
@@ -81,7 +82,109 @@ def add_image(
interpolation,
cmap_interpolation,
isolated_buffer,
- **kwargs
+ **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(
@@ -90,7 +193,6 @@ def add_line_collection(
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,
@@ -99,7 +201,7 @@ def add_line_collection(
metadatas: Union[Sequence[Any], numpy.ndarray] = None,
isolated_buffer: bool = True,
kwargs_lines: list[dict] = None,
- **kwargs
+ **kwargs,
) -> LineCollection:
"""
@@ -123,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
@@ -163,7 +262,6 @@ def add_line_collection(
thickness,
colors,
uniform_colors,
- alpha,
cmap,
cmap_transform,
name,
@@ -172,20 +270,20 @@ def add_line_collection(
metadatas,
isolated_buffer,
kwargs_lines,
- **kwargs
+ **kwargs,
)
def add_line(
self,
data: Any,
thickness: float = 2.0,
- colors: Union[str, numpy.ndarray, Iterable] = "w",
+ colors: Union[str, numpy.ndarray, Sequence] = "w",
uniform_color: bool = False,
- alpha: float = 1.0,
cmap: str = None,
- cmap_transform: Union[numpy.ndarray, Iterable] = None,
+ cmap_transform: Union[numpy.ndarray, Sequence] = None,
isolated_buffer: bool = True,
- **kwargs
+ size_space: str = "screen",
+ **kwargs,
) -> LineGraphic:
"""
@@ -194,31 +292,35 @@ def add_line(
Parameters
----------
data: array-like
- Line data to plot, 2D must be of shape [n_points, 2], 3D must be of shape [n_points, 3]
+ Line data to plot. Can provide 1D, 2D, or a 3D data.
+ | If passing a 1D array, it is used to set the y-values and the x-values are generated as an integer range
+ from [0, data.size]
+ | 2D data must be of shape [n_points, 2]. 3D data must be of shape [n_points, 3]
thickness: float, optional, default 2.0
thickness of the line
colors: str, array, or iterable, default "w"
specify colors as a single human-readable string, a single RGBA array,
- or an iterable of strings or RGBA arrays
+ or a Sequence (array, tuple, or list) of strings or RGBA arrays
uniform_color: bool, default ``False``
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"
+ Apply a colormap to the line instead of assigning colors manually, this
+ overrides any argument passed to "colors". For supported colormaps see the
+ ``cmap`` library catalogue: https://cmap-docs.readthedocs.io/en/stable/catalog/
cmap_transform: 1D array-like of numerical values, optional
if provided, these values are used to map the colors from the cmap
+ size_space: str, default "screen"
+ coordinate space in which the thickness is expressed ("screen", "world", "model")
+
**kwargs
- passed to Graphic
+ passed to :class:`.Graphic`
"""
@@ -228,11 +330,11 @@ def add_line(
thickness,
colors,
uniform_color,
- alpha,
cmap,
cmap_transform,
isolated_buffer,
- **kwargs
+ size_space,
+ **kwargs,
)
def add_line_stack(
@@ -240,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,
@@ -251,7 +352,7 @@ def add_line_stack(
separation: float = 10.0,
separation_axis: str = "y",
kwargs_lines: list[dict] = None,
- **kwargs
+ **kwargs,
) -> LineStack:
"""
@@ -275,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
@@ -321,7 +419,6 @@ def add_line_stack(
data,
thickness,
colors,
- alpha,
cmap,
cmap_transform,
name,
@@ -332,21 +429,171 @@ def add_line_stack(
separation,
separation_axis,
kwargs_lines,
- **kwargs
+ **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,
- sizes: Union[float, numpy.ndarray, Iterable[float]] = 1,
+ 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,
- **kwargs
+ size_space: str = "screen",
+ isolated_buffer: bool = True,
+ **kwargs,
) -> ScatterGraphic:
"""
@@ -355,39 +602,101 @@ def add_scatter(
Parameters
----------
data: array-like
- Scatter data to plot, 2D must be of shape [n_points, 2], 3D must be of shape [n_points, 3]
+ Scatter data to plot, Can provide 2D, or a 3D data. 2D data must be of shape [n_points, 2].
+ 3D data must be of shape [n_points, 3]
- colors: str, array, or iterable, default "w"
- specify colors as a single human readable string, a single RGBA array,
- or an iterable of strings or RGBA arrays
+ colors: str, array, tuple, list, Sequence, default "w"
+ specify colors as a single human-readable string, a single RGBA array,
+ or a Sequence (array, tuple, or list) of strings or RGBA arrays
uniform_color: bool, default False
- if True, uses a uniform buffer for the scatter point colors,
- basically saves GPU VRAM when the entire line has a single color
-
- alpha: float, optional, default 1.0
- alpha value for the colors
+ 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.
cmap: str, optional
apply a colormap to the scatter instead of assigning colors manually, this
- overrides any argument passed to "colors"
+ overrides any argument passed to "colors". For supported colormaps see the
+ ``cmap`` library catalogue: https://cmap-docs.readthedocs.io/en/stable/catalog/
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.
+ 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
- size of the scatter points
+ sizes of the scatter points
uniform_size: bool, default False
- if True, uses a uniform buffer for the scatter point sizes,
- basically saves GPU VRAM when all scatter points are the same size
+ if True, uses a uniform buffer for the scatter point sizes. Useful if you need to
+ save GPU VRAM when all points have the same size.
+
+ size_space: str, default "screen"
+ 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 Graphic
+ passed to :class:`.Graphic`
"""
@@ -396,13 +705,81 @@ 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,
- **kwargs
+ 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(
@@ -415,7 +792,7 @@ def add_text(
screen_space: bool = True,
offset: tuple[float] = (0, 0, 0),
anchor: str = "middle-center",
- **kwargs
+ **kwargs,
) -> TextGraphic:
"""
@@ -429,10 +806,10 @@ def add_text(
font_size: float | int, default 10
font size
- face_color: str or array, default "w"
+ face_color: str, array, list, tuple, default "w"
str or RGBA array to set the color of the text
- outline_color: str or array, default "w"
+ outline_color: str, array, list, tuple, default "w"
str or RGBA array to set the outline color of the text
outline_thickness: float, default 0
@@ -452,7 +829,7 @@ def add_text(
* Horizontal values: "left", "center", "right"
**kwargs
- passed to Graphic
+ passed to :class:`.Graphic`
"""
@@ -466,5 +843,60 @@ def add_text(
screen_space,
offset,
anchor,
- **kwargs
+ **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 8621f4464..33cc6d925 100644
--- a/fastplotlib/layouts/_imgui_figure.py
+++ b/fastplotlib/layouts/_imgui_figure.py
@@ -6,13 +6,12 @@
import imgui_bundle
from imgui_bundle import imgui, icons_fontawesome_6 as fa
-from wgpu.utils.imgui import ImguiRenderer
+from wgpu.utils.imgui import ImguiRenderer, Stats
from rendercanvas import BaseRenderCanvas
import pygfx
from ._figure import Figure
-from ._utils import make_canvas_and_renderer
from ..ui import EdgeWindow, SubplotToolbar, StandardRightClickMenu, Popup, GUI_EDGES
from ..ui import ColormapPicker
@@ -21,6 +20,8 @@ class ImguiFigure(Figure):
def __init__(
self,
shape: tuple[int, int] = (1, 1),
+ rects: list[tuple | np.ndarray] = None,
+ extents: list[tuple | np.ndarray] = None,
cameras: (
Literal["2d", "3d"]
| Iterable[Iterable[Literal["2d", "3d"]]]
@@ -40,29 +41,38 @@ def __init__(
controllers: pygfx.Controller | Iterable[Iterable[pygfx.Controller]] = None,
canvas: str | BaseRenderCanvas | pygfx.Texture = None,
renderer: pygfx.WgpuRenderer = None,
+ canvas_kwargs: dict = None,
size: tuple[int, int] = (500, 300),
names: list | np.ndarray = None,
):
self._guis: dict[str, EdgeWindow] = {k: None for k in GUI_EDGES}
- canvas, renderer = make_canvas_and_renderer(
- canvas, renderer, canvas_kwargs={"size": size}
- )
- self._imgui_renderer = ImguiRenderer(renderer.device, canvas)
-
super().__init__(
shape=shape,
+ rects=rects,
+ extents=extents,
cameras=cameras,
controller_types=controller_types,
controller_ids=controller_ids,
controllers=controllers,
canvas=canvas,
renderer=renderer,
+ canvas_kwargs=canvas_kwargs,
size=size,
names=names,
)
- fronts_path = str(
+ self._imgui_renderer = ImguiRenderer(self.renderer.device, self.canvas)
+
+ # 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"
)
@@ -70,31 +80,44 @@ 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()
)
- io.fonts.build()
- self.imgui_renderer.backend.create_fonts_texture()
+ 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,
+ )
+
+ imgui.push_font(self._default_imgui_font, self._default_imgui_font.legacy_size)
self.imgui_renderer.set_gui(self._draw_imgui)
self._subplot_toolbars: np.ndarray[SubplotToolbar] = np.empty(
- shape=self._subplots.shape, dtype=object
+ shape=self._subplots.size, dtype=object
)
- for subplot in self._subplots.ravel():
- toolbar = SubplotToolbar(subplot=subplot, fa_icons=self._fa_icons)
- self._subplot_toolbars[subplot.position] = toolbar
+ for i, subplot in enumerate(self._subplots.ravel()):
+ 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] = {}
+ self.imgui_show_fps = False
+ self._stats = Stats(self.renderer.device, self.canvas)
+
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"""
@@ -105,14 +128,20 @@ def imgui_renderer(self) -> ImguiRenderer:
"""imgui renderer"""
return self._imgui_renderer
- def render(self, draw=False):
- super().render(draw)
+ def _render(self, draw=False):
+ if self.imgui_show_fps:
+ with self._stats:
+ super()._render(draw)
+ else:
+ super()._render(draw)
self.imgui_renderer.render()
+
+ # needs to be here else events don't get processed
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()
@@ -131,15 +160,15 @@ 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):
"""
- Add a GUI to the Figure. GUIs can be added to the top, bottom, left or right edge.
+ Add a GUI to the Figure. GUIs can be added to the left or bottom edge.
Parameters
----------
@@ -164,11 +193,12 @@ def add_gui(self, gui: EdgeWindow):
self.guis[location] = gui
- self._reset_viewports()
+ self._fpl_reset_layout()
+
def get_pygfx_render_area(self, *args) -> tuple[int, int, int, int]:
"""
- Fet rect for the portion of the canvas that the pygfx renderer draws to,
+ Get rect for the portion of the canvas that the pygfx renderer draws to,
i.e. non-imgui, part of canvas
Returns
@@ -179,35 +209,23 @@ 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 ["left", "right"]:
+ for edge in ["right"]:
if self.guis[edge]:
width -= self._guis[edge].size
- for edge in ["top", "bottom"]:
+ for edge in ["bottom"]:
if self.guis[edge]:
height -= self._guis[edge].size
- if self.guis["left"]:
- xpos = self.guis["left"].size
- else:
- xpos = 0
+ for edge in ["top"]:
+ if self.guis[edge]:
+ y += self._guis[edge].size
+ height -= self._guis[edge].size
- if self.guis["top"]:
- ypos = self.guis["top"].size
- else:
- ypos = 0
-
- return xpos, ypos, max(1, width), max(1, height)
-
- def _reset_viewports(self):
- # TODO: think about moving this to Figure later,
- # maybe also refactor Subplot and PlotArea so that
- # the resize event is handled at the Figure level instead
- for subplot in self:
- subplot.set_viewport_rect()
- for dock in subplot.docks.values():
- dock.set_viewport_rect()
+ 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 e096a7f21..5d38ce37d 100644
--- a/fastplotlib/layouts/_plot_area.py
+++ b/fastplotlib/layouts/_plot_area.py
@@ -9,9 +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:
@@ -24,11 +27,10 @@
IPYTHON = get_ipython()
-class PlotArea:
+class PlotArea(GraphicMethodsMixin):
def __init__(
self,
parent: Union["PlotArea", "Figure"],
- position: tuple[int, int] | str,
camera: pygfx.PerspectiveCamera,
controller: pygfx.Controller,
scene: pygfx.Scene,
@@ -70,7 +72,6 @@ def __init__(
"""
self._parent = parent
- self._position = position
self._scene = scene
self._canvas = canvas
@@ -88,14 +89,12 @@ def __init__(
self._animate_funcs_pre: list[callable] = list()
self._animate_funcs_post: list[callable] = list()
- self.renderer.add_event_handler(self.set_viewport_rect, "resize")
+ self._animate_funcs_persist: list[callable] = list()
- # list of hex id strings for all graphics managed by this PlotArea
- # the real Graphic instances are managed by REFERENCES
+ # list of all graphics managed by this PlotArea
self._graphics: list[Graphic] = list()
# selectors are in their own list so they can be excluded from scene bbox calculations
- # managed similar to GRAPHICS for garbage collection etc.
self._selectors: list[BaseSelector] = list()
# legends, managed just like other graphics as explained above
@@ -116,11 +115,20 @@ 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.set_viewport_rect()
+ 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"""
@@ -141,11 +149,6 @@ def parent(self):
"""A parent if relevant"""
return self._parent
- @property
- def position(self) -> tuple[int, int] | str:
- """Position of this plot area within a larger layout (such as a Figure) if relevant"""
- return self._position
-
@property
def scene(self) -> pygfx.Scene:
"""The Scene where Graphics lie in this plot area"""
@@ -176,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)
@@ -284,35 +289,47 @@ 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)
- def get_rect(self) -> tuple[float, float, float, float]:
- """
- Returns the viewport rect to define the rectangle
- occupied by the viewport w.r.t. the Canvas.
+ @property
+ def ambient_light(self) -> pygfx.AmbientLight:
+ """the ambient lighting in the scene"""
+ return self._ambient_light
- If this is a subplot within a Figure, it returns the rectangle
- for only this subplot w.r.t. the parent canvas.
+ @property
+ def directional_light(self) -> pygfx.DirectionalLight:
+ """the directional lighting on the camera in the scene"""
+ return self._directional_light
- Must return: [x_pos, y_pos, width_viewport, height_viewport]
+ @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}
- """
- raise NotImplementedError("Must be implemented in subclass")
+ @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
- ) -> np.ndarray:
+ 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
- if not self.viewport.is_inside(*pos):
+ if not allow_outside and not self.viewport.is_inside(*pos):
return None
vs = self.viewport.logical_size
@@ -324,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)
@@ -333,20 +350,131 @@ def map_screen_to_world(
# default z is zero for now
return np.array([*pos_world[:2], 0])
- def set_viewport_rect(self, *args):
- self.viewport.rect = self.get_rect()
+ 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
- def render(self):
+ # 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)
# does not flush, flush must be implemented in user-facing Plot objects
self.viewport.render(self.scene, self.camera)
for child in self.children:
- child.render()
+ child._render()
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:
@@ -421,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
@@ -443,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,
@@ -485,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."""
@@ -532,7 +709,6 @@ def _add_or_insert_graphic(
if center:
self.center_graphic(graphic)
- # if we don't use the weakref above, then the object lingers if a plot hook is used!
graphic._fpl_add_plot_area_hook(self)
def _check_graphic_name_exists(self, name):
@@ -576,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,
@@ -611,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)
@@ -630,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):
"""
@@ -739,7 +959,7 @@ def __str__(self):
else:
name = self.name
- return f"{name}: {self.__class__.__name__} @ {hex(id(self))}"
+ return f"{name}: {self.__class__.__name__}"
def __repr__(self):
newline = "\n\t"
diff --git a/fastplotlib/layouts/_rect.py b/fastplotlib/layouts/_rect.py
new file mode 100644
index 000000000..7ecd6ad8b
--- /dev/null
+++ b/fastplotlib/layouts/_rect.py
@@ -0,0 +1,247 @@
+import numpy as np
+
+
+class RectManager:
+ """
+ Backend management of a rect. Allows converting between rects and extents, also works with fractional inputs.
+ """
+
+ def __init__(self, x: float, y: float, w: float, h: float, canvas_rect: tuple):
+ # initialize rect state arrays
+ # used to store internal state of the rect in both fractional screen space and absolute screen space
+ # the purpose of storing the fractional rect is that it remains constant when the canvas resizes
+ self._rect_frac = np.zeros(4, dtype=np.float64)
+ self._rect_screen_space = np.zeros(4, dtype=np.float64)
+ self._canvas_rect = np.asarray(canvas_rect)
+
+ self._set((x, y, w, h))
+
+ def _set(self, rect):
+ """
+ Using the passed rect which is either absolute screen space or fractional,
+ set the internal fractional and absolute screen space rects
+ """
+ rect = np.asarray(rect)
+ for val, name in zip(rect, ["x-position", "y-position", "width", "height"]):
+ if val < 0:
+ 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)
+
+ elif (rect[2:] > 1).all(): # bbox in already in screen coords coordinates
+ self._set_from_screen_space(rect)
+
+ else:
+ raise ValueError(f"Invalid rect: {rect}")
+
+ def _set_from_fract(self, rect):
+ """set rect from fractional representation"""
+ 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:
+ raise ValueError(
+ f"invalid fractional rect: {rect}\n x + width > 1: {rect[0]} + {rect[2]} > 1"
+ )
+ if rect[1] + rect[3] > 1:
+ raise ValueError(
+ f"invalid fractional rect: {rect}\n y + height > 1: {rect[1]} + {rect[3]} > 1"
+ )
+
+ # assign values to the arrays, don't just change the reference
+ self._rect_frac[:] = rect
+ 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"""
+ 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
+ # 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_offset[0]} + {rect_offset[2]} > {cw}"
+ )
+ if rect_offset[1] + rect_offset[3] > ch:
+ raise ValueError(
+ f"invalid rect: {rect}\n y + height > canvas height: {rect_offset[1]} + {rect_offset[3]} >{ch}"
+ )
+
+ self._rect_frac[:] = rect_offset / mult
+ self._rect_screen_space[:] = rect
+
+ @property
+ def x(self) -> np.float64:
+ """x position"""
+ return self._rect_screen_space[0]
+
+ @property
+ def y(self) -> np.float64:
+ """y position"""
+ return self._rect_screen_space[1]
+
+ @property
+ def w(self) -> np.float64:
+ """width"""
+ return self._rect_screen_space[2]
+
+ @property
+ def h(self) -> np.float64:
+ """height"""
+ return self._rect_screen_space[3]
+
+ @property
+ def rect(self) -> np.ndarray:
+ """rect, (x, y, w, h)"""
+ return self._rect_screen_space
+
+ @rect.setter
+ def rect(self, rect: np.ndarray | tuple):
+ self._set(rect)
+
+ def canvas_resized(self, canvas_rect: tuple):
+ # called by Frame when canvas is resized
+ self._canvas_rect[:] = canvas_rect
+ # set new rect using existing rect_frac since this remains constant regardless of resize
+ self._set(self._rect_frac)
+
+ @property
+ def x0(self) -> np.float64:
+ """x0 position"""
+ return self.x
+
+ @property
+ def x1(self) -> np.float64:
+ """x1 position"""
+ return self.x + self.w
+
+ @property
+ def y0(self) -> np.float64:
+ """y0 position"""
+ return self.y
+
+ @property
+ def y1(self) -> np.float64:
+ """y1 position"""
+ return self.y + self.h
+
+ @classmethod
+ def from_extent(cls, extent, canvas_rect):
+ """create a RectManager from an extent"""
+ rect = cls.extent_to_rect(extent, canvas_rect)
+ return cls(*rect, canvas_rect)
+
+ @property
+ def extent(self) -> np.ndarray:
+ """extent, (xmin, xmax, ymin, ymax)"""
+ # not actually stored, computed when needed
+ return np.asarray([self.x0, self.x1, self.y0, self.y1])
+
+ @extent.setter
+ def extent(self, extent):
+ rect = RectManager.extent_to_rect(extent, canvas_rect=self._canvas_rect)
+
+ self._set(rect)
+
+ @staticmethod
+ def extent_to_rect(extent, canvas_rect):
+ """convert an extent to a rect"""
+ RectManager.validate_extent(extent, canvas_rect)
+ x0, x1, y0, y1 = extent
+
+ # width and height
+ w = x1 - x0
+ h = y1 - y0
+
+ return x0, y0, w, h
+
+ @staticmethod
+ def validate_extent(extent: np.ndarray | tuple, canvas_rect: tuple):
+ extent = np.asarray(extent)
+ cx0, cy0, cw, ch = canvas_rect
+
+ # make sure extent is valid
+ if (extent < 0).any():
+ raise ValueError(f"extent must be non-negative, you have passed: {extent}")
+
+ if extent[1] <= 1 or extent[3] <= 1: # if x1 <= 1, or y1 <= 1
+ # if fractional rect, convert to full
+ if not (extent <= 1).all(): # if x1 and y1 <= 1, then all vals must be <= 1
+ raise ValueError(
+ f"if passing a fractional extent, all values must be fractional, you have passed: {extent}"
+ )
+ extent *= np.asarray([cw, cw, ch, ch])
+
+ x0, x1, y0, y1 = extent
+
+ # width and height
+ w = x1 - x0
+ h = y1 - y0
+
+ # check if x1 - x0 <= 0
+ if w <= 0:
+ raise ValueError(f"extent x-range must be non-negative: {extent}")
+
+ # check if y1 - y0 <= 0
+ if h <= 0:
+ raise ValueError(f"extent y-range must be non-negative: {extent}")
+
+ # calc canvas extent
+ cx1 = cx0 + cw
+ cy1 = cy0 + ch
+ canvas_extent = np.asarray([cx0, cx1, cy0, cy1])
+
+ if x0 < cx0 or x1 < cx0 or x0 > cx1 or x1 > cx1:
+ raise ValueError(
+ f"extent: {extent} x-range is beyond the bounds of the canvas: {canvas_extent}"
+ )
+ if y0 < cy0 or y1 < cy0 or y0 > cy1 or y1 > cy1:
+ raise ValueError(
+ f"extent: {extent} y-range is beyond the bounds of the canvas: {canvas_extent}"
+ )
+
+ def is_above(self, y0, dist: int = 1) -> bool:
+ # our bottom < other top within given distance
+ return self.y1 < y0 + dist
+
+ def is_below(self, y1, dist: int = 1) -> bool:
+ # our top > other bottom
+ return self.y0 > y1 - dist
+
+ def is_left_of(self, x0, dist: int = 1) -> bool:
+ # our right_edge < other left_edge
+ # self.x1 < other.x0
+ return self.x1 < x0 + dist
+
+ def is_right_of(self, x1, dist: int = 1) -> bool:
+ # self.x0 > other.x1
+ return self.x0 > x1 - dist
+
+ def overlaps(self, extent: np.ndarray) -> bool:
+ """returns whether this rect overlaps with the given extent"""
+ x0, x1, y0, y1 = extent
+ return not any(
+ [
+ self.is_above(y0),
+ self.is_below(y1),
+ self.is_left_of(x0),
+ self.is_right_of(x1),
+ ]
+ )
+
+ def __repr__(self):
+ s = f"{self._rect_frac}\n{self.rect}"
+
+ return s
diff --git a/fastplotlib/layouts/_subplot.py b/fastplotlib/layouts/_subplot.py
index 7d52ebab2..73f669fe5 100644
--- a/fastplotlib/layouts/_subplot.py
+++ b/fastplotlib/layouts/_subplot.py
@@ -3,35 +3,30 @@
import numpy as np
import pygfx
-
from rendercanvas import BaseRenderCanvas
from ..graphics import TextGraphic
from ._utils import create_camera, create_controller
from ._plot_area import PlotArea
-from ._graphic_methods_mixin import GraphicMethodsMixin
+from ._frame import Frame
from ..graphics._axes import Axes
-# number of pixels taken by the imgui toolbar when present
-IMGUI_TOOLBAR_HEIGHT = 39
-
-
-class Subplot(PlotArea, GraphicMethodsMixin):
+class Subplot(PlotArea):
def __init__(
self,
parent: Union["Figure"],
- position: tuple[int, int],
- parent_dims: tuple[int, int],
camera: Literal["2d", "3d"] | pygfx.PerspectiveCamera,
- controller: pygfx.Controller,
+ controller: pygfx.Controller | str,
canvas: BaseRenderCanvas | pygfx.Texture,
+ rect: np.ndarray = None,
+ extent: np.ndarray = None,
+ resizeable: bool = True,
renderer: pygfx.WgpuRenderer = None,
name: str = None,
):
"""
- General plot object is found within a ``Figure``. Each ``Figure`` instance will have [n rows, n columns]
- of subplots.
+ Subplot class.
.. important::
``Subplot`` is not meant to be constructed directly, it only exists as part of a ``Figure``
@@ -41,12 +36,6 @@ def __init__(
parent: 'Figure' | None
parent Figure instance
- position: (int, int), optional
- corresponds to the [row, column] position of the subplot within a ``Figure``
-
- parent_dims: (int, int), optional
- dimensions of the parent ``Figure``
-
camera: str or pygfx.PerspectiveCamera, default '2d'
indicates the FOV for the camera, '2d' sets ``fov = 0``, '3d' sets ``fov = 50``.
``fov`` can be changed at any time.
@@ -67,31 +56,19 @@ def __init__(
"""
- super(GraphicMethodsMixin, self).__init__()
-
- if position is None:
- position = (0, 0)
-
- if parent_dims is None:
- parent_dims = (1, 1)
-
- self.nrows, self.ncols = parent_dims
-
camera = create_camera(camera)
controller = create_controller(controller_type=controller, camera=camera)
self._docks = dict()
- self.spacing = 2
-
- self._title_graphic: TextGraphic = None
-
- self._toolbar = True
+ if "Imgui" in parent.__class__.__name__:
+ toolbar_visible = True
+ else:
+ toolbar_visible = False
- super(Subplot, self).__init__(
+ super().__init__(
parent=parent,
- position=position,
camera=camera,
controller=controller,
scene=pygfx.Scene(),
@@ -101,29 +78,48 @@ def __init__(
)
for pos in ["left", "top", "right", "bottom"]:
- dv = Dock(self, pos, size=0)
+ dv = Dock(self, size=0)
dv.name = pos
self.docks[pos] = dv
self.children.append(dv)
- if self.name is not None:
- self.set_title(self.name)
-
self._axes = Axes(self)
self.scene.add(self.axes.world_object)
+ self._frame = Frame(
+ viewport=self.viewport,
+ rect=rect,
+ extent=extent,
+ resizeable=resizeable,
+ title=name,
+ docks=self.docks,
+ toolbar_visible=toolbar_visible,
+ canvas_rect=parent.get_pygfx_render_area(),
+ )
+
@property
def axes(self) -> Axes:
+ """Axes object"""
return self._axes
@property
def name(self) -> str:
+ """Subplot name"""
return self._name
@name.setter
def name(self, name: str):
+ if name is None:
+ self._name = None
+ return
+
+ for subplot in self.get_figure(self):
+ if (subplot is self) or (subplot is None):
+ continue
+ if subplot.name == name:
+ raise ValueError("subplot names must be unique")
+
self._name = name
- self.set_title(name)
@property
def docks(self) -> dict:
@@ -143,111 +139,43 @@ def docks(self) -> dict:
@property
def toolbar(self) -> bool:
"""show/hide toolbar"""
- return self._toolbar
+ return self.frame.toolbar_visible
@toolbar.setter
def toolbar(self, visible: bool):
- self._toolbar = bool(visible)
- self.set_viewport_rect()
+ self.frame.toolbar_visible = visible
+ self.frame.reset_viewport()
- def render(self):
+ def _render(self):
self.axes.update_using_camera()
- super().render()
+ super()._render()
- def set_title(self, text: str):
- """Sets the plot title, stored as a ``TextGraphic`` in the "top" dock area"""
- if text is None:
- return
+ @property
+ def title(self) -> TextGraphic:
+ """subplot title"""
+ return self._frame.title_graphic
+ @title.setter
+ def title(self, text: str):
text = str(text)
- if self._title_graphic is not None:
- self._title_graphic.text = text
- else:
- tg = TextGraphic(text=text, font_size=18)
- self._title_graphic = tg
-
- self.docks["top"].size = 35
- self.docks["top"].add_graphic(tg)
-
- self.center_title()
-
- def center_title(self):
- """Centers name of subplot."""
- if self._title_graphic is None:
- raise AttributeError("No title graphic is set")
-
- self._title_graphic.world_object.position = (0, 0, 0)
- self.docks["top"].center_graphic(self._title_graphic, zoom=1.5)
- self._title_graphic.world_object.position_y = -3.5
-
- def get_rect(self) -> np.ndarray:
- """
- Returns the bounding box that defines the Subplot within the canvas.
+ self.title.text = text
- Returns
- -------
- np.ndarray
- x_position, y_position, width, height
-
- """
- row_ix, col_ix = self.position
-
- x_start_render, y_start_render, width_canvas_render, height_canvas_render = (
- self.parent.get_pygfx_render_area()
- )
-
- x_pos = (
- (
- (width_canvas_render / self.ncols)
- + ((col_ix - 1) * (width_canvas_render / self.ncols))
- )
- + self.spacing
- + x_start_render
- )
- y_pos = (
- (
- (height_canvas_render / self.nrows)
- + ((row_ix - 1) * (height_canvas_render / self.nrows))
- )
- + self.spacing
- + y_start_render
- )
- width_subplot = (width_canvas_render / self.ncols) - self.spacing
- height_subplot = (height_canvas_render / self.nrows) - self.spacing
-
- if self.parent.__class__.__name__ == "ImguiFigure" and self.toolbar:
- # leave space for imgui toolbar
- height_subplot -= IMGUI_TOOLBAR_HEIGHT
-
- # clip so that min values are always 1, otherwise JupyterRenderCanvas causes issues because it
- # initializes with a width of (0, 0)
- rect = np.array([x_pos, y_pos, width_subplot, height_subplot]).clip(1)
-
- for dv in self.docks.values():
- rect = rect + dv.get_parent_rect_adjust()
-
- return rect
+ @property
+ def frame(self) -> Frame:
+ """Frame that the subplot lives in"""
+ return self._frame
class Dock(PlotArea):
- _valid_positions = ["right", "left", "top", "bottom"]
-
def __init__(
self,
parent: Subplot,
- position: str,
size: int,
):
- if position not in self._valid_positions:
- raise ValueError(
- f"the `position` of an AnchoredViewport must be one of: {self._valid_positions}"
- )
-
self._size = size
super().__init__(
parent=parent,
- position=position,
camera=pygfx.OrthographicCamera(),
controller=pygfx.PanZoomController(),
scene=pygfx.Scene(),
@@ -263,141 +191,10 @@ def size(self) -> int:
@size.setter
def size(self, s: int):
self._size = s
- self.parent.set_viewport_rect()
- self.set_viewport_rect()
-
- def get_rect(self, *args):
- """
- Returns the bounding box that defines this dock area within the canvas.
-
- Returns
- -------
- np.ndarray
- x_position, y_position, width, height
- """
- if self.size == 0:
- self.viewport.rect = None
- return
-
- row_ix_parent, col_ix_parent = self.parent.position
+ self.get_figure()._fpl_reset_layout()
- x_start_render, y_start_render, width_render_canvas, height_render_canvas = (
- self.parent.parent.get_pygfx_render_area()
- )
-
- spacing = 2 # spacing in pixels
-
- if self.position == "right":
- x_pos = (
- (width_render_canvas / self.parent.ncols)
- + ((col_ix_parent - 1) * (width_render_canvas / self.parent.ncols))
- + (width_render_canvas / self.parent.ncols)
- - self.size
- )
- y_pos = (
- (height_render_canvas / self.parent.nrows)
- + ((row_ix_parent - 1) * (height_render_canvas / self.parent.nrows))
- ) + spacing
- width_viewport = self.size
- height_viewport = (height_render_canvas / self.parent.nrows) - spacing
-
- elif self.position == "left":
- x_pos = (width_render_canvas / self.parent.ncols) + (
- (col_ix_parent - 1) * (width_render_canvas / self.parent.ncols)
- )
- y_pos = (
- (height_render_canvas / self.parent.nrows)
- + ((row_ix_parent - 1) * (height_render_canvas / self.parent.nrows))
- ) + spacing
- width_viewport = self.size
- height_viewport = (height_render_canvas / self.parent.nrows) - spacing
-
- elif self.position == "top":
- x_pos = (
- (width_render_canvas / self.parent.ncols)
- + ((col_ix_parent - 1) * (width_render_canvas / self.parent.ncols))
- + spacing
- )
- y_pos = (
- (height_render_canvas / self.parent.nrows)
- + ((row_ix_parent - 1) * (height_render_canvas / self.parent.nrows))
- ) + spacing
- width_viewport = (width_render_canvas / self.parent.ncols) - spacing
- height_viewport = self.size
-
- elif self.position == "bottom":
- x_pos = (
- (width_render_canvas / self.parent.ncols)
- + ((col_ix_parent - 1) * (width_render_canvas / self.parent.ncols))
- + spacing
- )
- y_pos = (
- (
- (height_render_canvas / self.parent.nrows)
- + ((row_ix_parent - 1) * (height_render_canvas / self.parent.nrows))
- )
- + (height_render_canvas / self.parent.nrows)
- - self.size
- )
- width_viewport = (width_render_canvas / self.parent.ncols) - spacing
- height_viewport = self.size
- else:
- raise ValueError("invalid position")
-
- if self.parent.__class__.__name__ == "ImguiFigure" and self.parent.toolbar:
- # leave space for imgui toolbar
- height_viewport -= IMGUI_TOOLBAR_HEIGHT
-
- return [
- x_pos + x_start_render,
- y_pos + y_start_render,
- width_viewport,
- height_viewport,
- ]
-
- def get_parent_rect_adjust(self):
- if self.position == "right":
- return np.array(
- [
- 0, # parent subplot x-position is same
- 0,
- -self.size, # width of parent subplot is `self.size` smaller
- 0,
- ]
- )
-
- elif self.position == "left":
- return np.array(
- [
- self.size, # `self.size` added to parent subplot x-position
- 0,
- -self.size, # width of parent subplot is `self.size` smaller
- 0,
- ]
- )
-
- elif self.position == "top":
- return np.array(
- [
- 0,
- self.size, # `self.size` added to parent subplot y-position
- 0,
- -self.size, # height of parent subplot is `self.size` smaller
- ]
- )
-
- elif self.position == "bottom":
- return np.array(
- [
- 0,
- 0, # parent subplot y-position is same,
- 0,
- -self.size, # height of parent subplot is `self.size` smaller
- ]
- )
-
- def render(self):
+ def _render(self):
if self.size == 0:
return
- super().render()
+ super()._render()
diff --git a/fastplotlib/layouts/_utils.py b/fastplotlib/layouts/_utils.py
index b42971570..49120c71a 100644
--- a/fastplotlib/layouts/_utils.py
+++ b/fastplotlib/layouts/_utils.py
@@ -1,10 +1,24 @@
import importlib
+from itertools import product
+
+import numpy as np
import pygfx
from pygfx import WgpuRenderer, Texture, Renderer
from ..utils.gui import BaseRenderCanvas, RenderCanvas
+try:
+ import imgui_bundle
+except ImportError:
+ IMGUI = False
+else:
+ IMGUI = True
+
+
+# number of pixels taken by the imgui toolbar when present
+IMGUI_TOOLBAR_HEIGHT = 36
+
def make_canvas_and_renderer(
canvas: str | BaseRenderCanvas | Texture | None,
@@ -17,12 +31,12 @@ def make_canvas_and_renderer(
"""
if canvas is None:
- canvas = RenderCanvas(max_fps=60, **canvas_kwargs)
+ canvas = RenderCanvas(**canvas_kwargs)
elif isinstance(canvas, str):
import rendercanvas
m = importlib.import_module("rendercanvas." + canvas)
- canvas = m.RenderCanvas(max_fps=60, **canvas_kwargs)
+ canvas = m.RenderCanvas(**canvas_kwargs)
elif not isinstance(canvas, (BaseRenderCanvas, Texture)):
raise TypeError(
f"canvas option must either be a valid BaseRenderCanvas implementation, a pygfx Texture"
@@ -92,3 +106,20 @@ def create_controller(
)
return controller_types[controller_type](camera)
+
+
+def get_extents_from_grid(
+ shape: tuple[int, int],
+) -> list[tuple[float, float, float, float]]:
+ """create fractional extents from a given grid shape"""
+ x_min = np.arange(0, 1, (1 / shape[1]))
+ x_max = x_min + 1 / shape[1]
+ y_min = np.arange(0, 1, (1 / shape[0]))
+ y_max = y_min + 1 / shape[0]
+
+ extents = list()
+ for row_ix, col_ix in product(range(shape[0]), range(shape[1])):
+ extent = x_min[col_ix], x_max[col_ix], y_min[row_ix], y_max[row_ix]
+ extents.append(extent)
+
+ return extents
diff --git a/fastplotlib/layouts/_video_writer.py b/fastplotlib/layouts/_video_writer.py
deleted file mode 100644
index b7e111b50..000000000
--- a/fastplotlib/layouts/_video_writer.py
+++ /dev/null
@@ -1,82 +0,0 @@
-from pathlib import Path
-from multiprocessing import Queue, Process
-
-
-def _get_av():
- try:
- import av
- except ImportError:
- raise ModuleNotFoundError(
- "Recording to video file requires `av`:\n"
- "https://github.com/PyAV-Org/PyAV"
- ) from None
- else:
- return av
-
-
-class VideoWriterAV(Process):
- """Video writer, uses PyAV in an external process to write frames to disk"""
-
- def __init__(
- self,
- path: Path | str,
- queue: Queue,
- fps: int,
- width: int,
- height: int,
- codec: str,
- pixel_format: str,
- options: dict = None,
- ):
- super().__init__()
- self.queue = queue
-
- av = _get_av()
- self.container = av.open(path, mode="w")
-
- self.stream = self.container.add_stream(codec, rate=fps, options=options)
-
- # in case libx264, trim last rows and/or column
- # because libx264 doesn't like non-even number width or height
- if width % 2 != 0:
- width -= 1
- if height % 2 != 0:
- height -= 1
-
- self.stream.width = width
- self.stream.height = height
-
- self.stream.pix_fmt = pixel_format
-
- def run(self):
- av = _get_av()
- while True:
- if self.queue.empty(): # no frame to write
- continue
-
- frame = self.queue.get()
-
- # recording has ended
- if frame is None:
- self.container.close()
- break
-
- frame = av.VideoFrame.from_ndarray(
- frame[
- : self.stream.height, : self.stream.width
- ], # trim if necessary because of x264
- format="rgb24",
- )
-
- for packet in self.stream.encode(frame):
- self.container.mux(packet)
-
- # I don't exactly know what this does, copied from pyav example
- for packet in self.stream.encode():
- self.container.mux(packet)
-
- # close file
- self.container.close()
-
- # close process, release resources
- self.close()
diff --git a/fastplotlib/legends/legend.py b/fastplotlib/legends/legend.py
index df78d5662..9da836fd7 100644
--- a/fastplotlib/legends/legend.py
+++ b/fastplotlib/legends/legend.py
@@ -5,8 +5,9 @@
import numpy as np
import pygfx
-from ..graphics._base import Graphic
-from ..graphics._features._base import FeatureEvent
+from ..utils.enums import RenderQueue
+from ..graphics import Graphic
+from ..graphics.features import GraphicFeatureEvent
from ..graphics import LineGraphic, ScatterGraphic, ImageGraphic
from ..utils import mesh_masks
@@ -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"
@@ -116,7 +124,7 @@ def label(self, text: str):
self._parent._check_label_unique(text)
self._label_world_object.geometry.set_text(text)
- def _update_color(self, ev: FeatureEvent):
+ def _update_color(self, ev: GraphicFeatureEvent):
new_color = ev.info["value"]
if np.unique(new_color, axis=0).shape[0] > 1:
raise ValueError(
@@ -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 80396c98d..761183f76 100644
--- a/fastplotlib/tools/__init__.py
+++ b/fastplotlib/tools/__init__.py
@@ -1 +1,10 @@
from ._histogram_lut import HistogramLUTTool
+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 b8c6633a8..d651137da 100644
--- a/fastplotlib/tools/_histogram_lut.py
+++ b/fastplotlib/tools/_histogram_lut.py
@@ -1,11 +1,13 @@
from math import ceil
+from typing import Sequence
import weakref
import numpy as np
import pygfx
-from ..graphics import LineGraphic, ImageGraphic, TextGraphic
+from ..utils import subsample_array
+from ..graphics import LineGraphic, ImageGraphic, ImageVolumeGraphic, TextGraphic
from ..graphics.utils import pause_events
from ..graphics._base import Graphic
from ..graphics.selectors import LinearRegionSelector
@@ -25,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)
@@ -59,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])
@@ -72,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(
@@ -93,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
@@ -104,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
@@ -129,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")
@@ -161,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
@@ -193,28 +231,10 @@ def _fpl_add_plot_area_hook(self, plot_area):
self._plot_area.controller.enabled = True
def _calculate_histogram(self, data):
- if data.ndim > 2:
- # subsample to max of 500 x 100 x 100,
- # np.histogram takes ~30ms with this size on a 8 core Ryzen laptop
- # dim0 is usually time, allow max of 500 timepoints
- ss0 = max(1, int(data.shape[0] / 500)) # max to prevent step = 0
- # allow max of 100 for x and y if ndim > 2
- ss1 = max(1, int(data.shape[1] / 100))
- ss2 = max(1, int(data.shape[2] / 100))
-
- data_ss = data[::ss0, ::ss1, ::ss2]
-
- hist, edges = np.histogram(data_ss, bins=self._nbins)
-
- else:
- # allow max of 1000 x 1000
- # this takes ~4ms on a 8 core Ryzen laptop
- ss0 = max(1, int(data.shape[0] / 1_000))
- ss1 = max(1, int(data.shape[1] / 1_000))
- data_ss = data[::ss0, ::ss1]
-
- hist, edges = np.histogram(data_ss, bins=self._nbins)
+ # get a subsampled view of this array
+ data_ss = subsample_array(data, max_size=int(1e6)) # 1e6 is default
+ hist, edges = np.histogram(data_ss, bins=self._nbins)
# used if data ptp <= 10 because event things get weird
# with tiny world objects due to floating point error
@@ -273,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
@@ -285,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:
@@ -308,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 = (
@@ -316,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:
@@ -343,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
@@ -353,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")
@@ -366,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/_textbox.py b/fastplotlib/tools/_textbox.py
new file mode 100644
index 000000000..46a468ae7
--- /dev/null
+++ b/fastplotlib/tools/_textbox.py
@@ -0,0 +1,299 @@
+import numpy as np
+import pygfx
+
+from ..utils.enums import RenderQueue
+
+
+class MeshMasks:
+ """Used set the x0, x1, y0, y1 positions of the plane mesh"""
+
+ x0 = np.array(
+ [
+ [False, False, False],
+ [True, False, False],
+ [False, False, False],
+ [True, False, False],
+ ]
+ )
+
+ x1 = np.array(
+ [
+ [True, False, False],
+ [False, False, False],
+ [True, False, False],
+ [False, False, False],
+ ]
+ )
+
+ y0 = np.array(
+ [
+ [False, True, False],
+ [False, True, False],
+ [False, False, False],
+ [False, False, False],
+ ]
+ )
+
+ y1 = np.array(
+ [
+ [False, False, False],
+ [False, False, False],
+ [False, True, False],
+ [False, True, False],
+ ]
+ )
+
+
+masks = MeshMasks
+
+
+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=font_size,
+ screen_space=False, # these are added to the overlay render pass so it will actually be in screen space!
+ anchor="bottom-left",
+ material=pygfx.TextMaterial(
+ alpha_mode="blend",
+ aa=True,
+ render_queue=RenderQueue.overlay,
+ color=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(
+ alpha_mode="blend",
+ render_queue=RenderQueue.overlay,
+ color=background_color,
+ depth_write=False,
+ depth_test=False,
+ )
+ self._plane = pygfx.Mesh(geometry, material)
+
+ # line to outline the plane mesh
+ self._line = pygfx.Line(
+ geometry=pygfx.Geometry(
+ positions=np.array(
+ [
+ [0, 0, 0],
+ [0, 0, 0],
+ [0, 0, 0],
+ [0, 0, 0],
+ [0, 0, 0],
+ ],
+ dtype=np.float32,
+ )
+ ),
+ 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._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.zeros(shape=(2, 3), dtype=np.float32)
+ self.padding = padding
+
+ # position of the tooltip in screen space
+ self._position = np.array([0.0, 0.0])
+
+ @property
+ def position(self) -> np.ndarray:
+ """position of the tooltip in screen space"""
+ return self._position
+
+ @property
+ def font_size(self):
+ """Get or set font size"""
+ return self._text.font_size
+
+ @font_size.setter
+ def font_size(self, size: float):
+ self._text.font_size = size
+
+ @property
+ def text_color(self):
+ """Get or set text color using a str or RGB(A) array"""
+ return self._text.material.color
+
+ @text_color.setter
+ def text_color(self, color: str | tuple | list | np.ndarray):
+ self._text.material.color = color
+
+ @property
+ def background_color(self):
+ """Get or set background color using a str or RGB(A) array"""
+ return self._plane.material.color
+
+ @background_color.setter
+ def background_color(self, color: str | tuple | list | np.ndarray):
+ self._plane.material.color = color
+
+ @property
+ def outline_color(self):
+ """Get or set outline color using a str or RGB(A) array"""
+ return self._line.material.color
+
+ @outline_color.setter
+ def outline_color(self, color: str | tuple | list | np.ndarray):
+ self._line.material.color = color
+
+ @property
+ def padding(self) -> np.ndarray:
+ """
+ Get or set the background padding in number of pixels.
+ The padding defines the number of pixels around the tooltip text that the background is extended by.
+ """
+
+ return self.padding[0, :2].copy()
+
+ @padding.setter
+ def padding(self, padding_xy: tuple[float, float]):
+ self._padding[0, :2] = padding_xy
+ self._padding[1, :2] = -np.asarray(padding_xy)
+
+ @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]):
+ """
+ Sets the positions of the world objects so it's draw at the given position
+
+ Parameters
+ ----------
+ pos: [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]
+
+ # put the tooltip slightly to the top right of the cursor positoin
+ x += 8
+ y -= 8
+
+ self._text.world.position = (x, -y, 0)
+
+ bbox = self._text.get_world_bounding_box() - self._padding
+ [[x0, y0, _], [x1, y1, _]] = bbox
+
+ self._plane.geometry.positions.data[masks.x0] = x0
+ self._plane.geometry.positions.data[masks.x1] = x1
+ self._plane.geometry.positions.data[masks.y0] = y0
+ self._plane.geometry.positions.data[masks.y1] = y1
+
+ self._plane.geometry.positions.update_range()
+
+ # line points
+ pts = [[x0, y0], [x0, y1], [x1, y1], [x1, y0], [x0, y0]]
+
+ self._line.geometry.positions.data[:, :2] = pts
+ self._line.geometry.positions.update_range()
+
+ def clear(self, *args):
+ """clear the text box and make it invisible"""
+ self._text.set_text("")
+ self._fpl_world_object.visible = False
+
+
+class Tooltip(TextBox):
+ def __init__(self):
+ super().__init__()
+ self._enabled: bool = True
+ self._continuous_update = False
+ self.visible = False
+
+ @property
+ def enabled(self) -> bool:
+ """enable or disable the tooltip"""
+ return self._enabled
+
+ @enabled.setter
+ def enabled(self, value: bool):
+ self._enabled = bool(value)
+
+ if not self.enabled:
+ self.visible = False
+
+ @property
+ def continuous_update(self) -> bool:
+ """update the tooltip on every render"""
+ return self._continuous_update
+
+ @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 6c134d415..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 = ["top", "right", "bottom", "left"]
+GUI_EDGES = ["right", "bottom", "top"]
class BaseGUI:
@@ -40,15 +41,15 @@ def __init__(
self,
figure: Figure,
size: int,
- location: Literal["top", "bottom", "left", "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,
):
"""
- A base class for imgui windows displayed at one of the four edges of a Figure
+ A base class for imgui windows displayed at the bottom or top edge of a Figure
Parameters
----------
@@ -58,14 +59,14 @@ def __init__(
size: int
width or height of the window, depending on its location
- location: str, "top" | "bottom" | "left" | "right"
+ location: str, "bottom" | "right"
location of the window
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()
@@ -168,10 +168,6 @@ def get_rect(self) -> tuple[int, int, int, int]:
width_canvas, height_canvas = self._figure.canvas.get_logical_size()
match self._location:
- case "top":
- x_pos, y_pos = (0, 0)
- width, height = (width_canvas, self.size)
-
case "bottom":
x_pos = 0
y_pos = height_canvas - self.size
@@ -179,32 +175,31 @@ def get_rect(self) -> tuple[int, int, int, int]:
case "right":
x_pos, y_pos = (width_canvas - self.size, 0)
-
- if self._figure.guis["top"]:
- # if there is a GUI in the top edge, make this one below
- y_pos += self._figure.guis["top"].size
-
width, height = (self.size, height_canvas)
+
if self._figure.guis["bottom"] is not None:
height -= self._figure.guis["bottom"].size
- case "left":
- x_pos, y_pos = (0, 0)
- if self._figure.guis["top"]:
- # if there is a GUI in the top edge, make this one below
+ 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
- width, height = (self.size, height_canvas)
- if self._figure.guis["bottom"] is not None:
- height -= self._figure.guis["bottom"].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):
"""helps simplify using imgui by managing window creation & position, and pushing/popping the ID"""
# window position & size
+ x, y, w, h = self.get_rect()
imgui.set_next_window_size((self.width, self.height))
imgui.set_next_window_pos((self.x, self.y))
+ # imgui.set_next_window_pos((x, y))
+ # imgui.set_next_window_size((w, h))
flags = self._window_flags
# begin window
@@ -246,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 6c1a81f73..435de4206 100644
--- a/fastplotlib/ui/_subplot_toolbar.py
+++ b/fastplotlib/ui/_subplot_toolbar.py
@@ -2,67 +2,61 @@
from ..layouts._subplot import Subplot
from ._base import Window
+from ..layouts._utils import IMGUI_TOOLBAR_HEIGHT
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
- x, y, width, height = self._subplot.get_rect()
+ x, y, width, height = self._subplot.frame.rect
# place the toolbar window below the subplot
- pos = (x, y + height)
+ pos = (x + 1, y + height - IMGUI_TOOLBAR_HEIGHT)
- imgui.set_next_window_size((width, 0))
+ imgui.set_next_window_size((width - 18, 0))
imgui.set_next_window_pos(pos)
- flags = imgui.WindowFlags_.no_collapse | imgui.WindowFlags_.no_title_bar
+ flags = (
+ imgui.WindowFlags_.no_collapse
+ | imgui.WindowFlags_.no_title_bar
+ | imgui.WindowFlags_.no_background
+ )
- imgui.begin(f"Toolbar-{self._subplot.position}", p_open=None, flags=flags)
-
- # icons for buttons
- imgui.push_font(self._fa_icons)
+ imgui.begin(f"Toolbar-{hex(id(self._subplot))}", p_open=None, flags=flags)
# push ID to prevent conflict between multiple figs with same UI
imgui.push_id(self._id_counter)
- with imgui_ctx.begin_horizontal(f"toolbar-{self._subplot.position}"):
+ 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 9a584043c..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
@@ -31,7 +31,7 @@ def __init__(self, figure, fa_icons):
# whether the right click menu is currently open or not
self.is_open: bool = False
- def get_subplot(self) -> PlotArea | bool:
+ def get_subplot(self) -> PlotArea | bool | None:
"""get the subplot that a click occurred in"""
if self._last_right_click_pos is None:
return False
@@ -40,6 +40,9 @@ def get_subplot(self) -> PlotArea | bool:
if subplot.viewport.is_inside(*self._last_right_click_pos):
return subplot
+ # not inside a subplot
+ return False
+
def cleanup(self):
"""called when the popup disappears"""
self.is_open = False
@@ -55,7 +58,7 @@ def update(self):
# open popup only if mouse was not moved between mouse_down and mouse_up events
if self._last_right_click_pos == imgui.get_mouse_pos():
- if self.get_subplot():
+ if self.get_subplot() is not False: # must explicitly check for False
# open only if right click was inside a subplot
imgui.open_popup(f"right-click-menu")
@@ -64,7 +67,7 @@ def update(self):
self.cleanup()
if imgui.begin_popup(f"right-click-menu"):
- if not self.get_subplot():
+ if self.get_subplot() is False: # must explicitly check for False
# for some reason it will still trigger at certain locations
# despite open_popup() only being called when an actual
# subplot is returned
@@ -74,12 +77,16 @@ def update(self):
return
name = self.get_subplot().name
- if name is None:
- name = self.get_subplot().position
- # text label at the top of the menu
- imgui.text(f"subplot: {name}")
- imgui.separator()
+ if name is not None:
+ # text label at the top of the menu
+ imgui.text(f"subplot: {name}")
+ imgui.separator()
+
+ _, show_fps = imgui.menu_item(
+ "Show fps", "", self.get_subplot().get_figure().imgui_show_fps
+ )
+ self.get_subplot().get_figure().imgui_show_fps = show_fps
# autoscale, center, maintain aspect
if imgui.menu_item(f"Autoscale", "", False)[0]:
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/_plot_helpers.py b/fastplotlib/utils/_plot_helpers.py
index ac0ff2cda..12afe1cb2 100644
--- a/fastplotlib/utils/_plot_helpers.py
+++ b/fastplotlib/utils/_plot_helpers.py
@@ -6,13 +6,14 @@
from ..graphics._collection_base import GraphicCollection
-def get_nearest_graphics(
+def get_nearest_graphics_indices(
pos: tuple[float, float] | tuple[float, float, float],
graphics: Sequence[Graphic] | GraphicCollection,
-) -> np.ndarray[Graphic]:
+) -> np.ndarray[int]:
"""
- Returns the nearest ``graphics`` to the passed position ``pos`` in world space.
- Uses the distance between ``pos`` and the center of the bounding sphere for each graphic.
+ Returns indices of the nearest ``graphics`` to the passed position ``pos`` in world space
+ in order of closest to furtherst. Uses the distance between ``pos`` and the center of the
+ bounding sphere for each graphic.
Parameters
----------
@@ -25,21 +26,22 @@ def get_nearest_graphics(
Returns
-------
- tuple[Graphic]
- nearest graphics to ``pos`` in order
+ ndarray[int]
+ indices of the nearest nearest graphics to ``pos`` in order
"""
-
if isinstance(graphics, GraphicCollection):
graphics = graphics.graphics
if not all(isinstance(g, Graphic) for g in graphics):
raise TypeError("all elements of `graphics` must be Graphic objects")
- pos = np.asarray(pos)
+ pos = np.asarray(pos).ravel()
- if pos.shape != (2,) or not pos.shape != (3,):
- raise TypeError
+ if pos.shape != (2,) and pos.shape != (3,):
+ raise TypeError(
+ f"pos.shape must be (2,) or (3,), the shape of pos you have passed is: {pos.shape}"
+ )
# get centers
centers = np.empty(shape=(len(graphics), len(pos)))
@@ -50,4 +52,31 @@ def get_nearest_graphics(
distances = np.linalg.norm(centers[:, : len(pos)] - pos, ord=2, axis=1)
sort_indices = np.argsort(distances)
+ return sort_indices
+
+
+def get_nearest_graphics(
+ pos: tuple[float, float] | tuple[float, float, float],
+ graphics: Sequence[Graphic] | GraphicCollection,
+) -> np.ndarray[Graphic]:
+ """
+ Returns the nearest ``graphics`` to the passed position ``pos`` in world space.
+ Uses the distance between ``pos`` and the center of the bounding sphere for each graphic.
+
+ Parameters
+ ----------
+ pos: (x, y) | (x, y, z)
+ position in world space, z-axis is ignored when calculating L2 norms if ``pos`` is 2D
+
+ graphics: Sequence, i.e. array, list, tuple, etc. of Graphic | GraphicCollection
+ the graphics from which to return a sorted array of graphics in order of closest
+ to furthest graphic
+
+ Returns
+ -------
+ ndarray[Graphic]
+ nearest graphics to ``pos`` in order
+
+ """
+ sort_indices = get_nearest_graphics_indices(pos, graphics)
return np.asarray(graphics)[sort_indices]
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 02dcd0572..a839ed9d0 100644
--- a/fastplotlib/utils/functions.py
+++ b/fastplotlib/utils/functions.py
@@ -205,7 +205,7 @@ def make_colors(n_colors: int, cmap: str, alpha: float = 1.0) -> np.ndarray:
def get_cmap_texture(name: str, alpha: float = 1.0) -> Texture:
- return cmap_lib.Colormap(name).to_pygfx()
+ return Texture(get_cmap(name, alpha), dim=1)
def make_colors_dict(labels: Sequence, cmap: str, **kwargs) -> OrderedDict:
@@ -267,19 +267,21 @@ def make_colors_dict(labels: Sequence, cmap: str, **kwargs) -> OrderedDict:
return OrderedDict(zip(labels, colors))
-def quick_min_max(data: np.ndarray) -> tuple[float, float]:
+def quick_min_max(data: np.ndarray, max_size=1e6) -> tuple[float, float]:
"""
- Adapted from pyqtgraph.ImageView.
- Estimate the min/max values of *data* by subsampling.
+ Estimate the min/max values of *data* by subsampling relative to the size of each dimension in the array.
Parameters
----------
- data: np.ndarray or array-like with `min` and `max` attributes
+ data: np.ndarray or array-like
+
+ max_size : int, optional
+ subsamples data array to this max size
Returns
-------
(float, float)
- (min, max)
+ (min, max) estimate
"""
if hasattr(data, "min") and hasattr(data, "max"):
@@ -289,11 +291,7 @@ def quick_min_max(data: np.ndarray) -> tuple[float, float]:
):
return data.min, data.max
- while data.size > 1e6:
- ax = np.argmax(data.shape)
- sl = [slice(None)] * data.ndim
- sl[ax] = slice(None, None, 2)
- data = data[tuple(sl)]
+ data = subsample_array(data, max_size=max_size)
return float(np.nanmin(data)), float(np.nanmax(data))
@@ -405,3 +403,77 @@ def parse_cmap_values(
colors = np.vstack([colormap[val] for val in norm_cmap_values])
return colors
+
+
+def subsample_array(
+ arr: np.ndarray, max_size: int = 1e6, ignore_dims: Sequence[int] | None = None
+):
+ """
+ Subsamples an input array while preserving its relative dimensional proportions.
+
+ The dimensions (shape) of the array can be represented as:
+
+ .. math::
+
+ [d_1, d_2, \\dots d_n]
+
+ The product of the dimensions can be represented as:
+
+ .. math::
+
+ \\prod_{i=1}^{n} d_i
+
+ To find the factor ``f`` by which to divide the size of each dimension in order to
+ get max_size ``s`` we must solve for ``f`` in the following expression:
+
+ .. math::
+
+ \\prod_{i=1}^{n} \\frac{d_i}{\\mathbf{f}} = \\mathbf{s}
+
+ The solution for ``f`` is is simply the nth root of the product of the dims divided by the max_size
+ where n is the number of dimensions
+
+ .. math::
+
+ \\mathbf{f} = \\sqrt[n]{\\frac{\\prod_{i=1}^{n} d_i}{\\mathbf{s}}}
+
+ Parameters
+ ----------
+ arr: np.ndarray
+ input array of any dimensionality to be subsampled.
+
+ max_size: int, default 1e6
+ maximum number of elements in subsampled array
+
+ ignore_dims: Sequence[int], optional
+ List of dimension indices to exclude from subsampling (i.e. retain full resolution).
+ For example, `ignore_dims=[0]` will avoid subsampling along the first axis.
+
+ Returns
+ -------
+ np.ndarray
+ subsample of the input array
+ """
+ full_shape = np.array(arr.shape, dtype=np.uint64)
+ if np.prod(full_shape) <= max_size:
+ return arr[:] # no need to subsample if already below the threshold
+
+ # get factor by which to divide all dims
+ f = np.power((np.prod(full_shape) / max_size), 1.0 / arr.ndim)
+
+ # new shape for subsampled array
+ ns = np.floor(np.array(full_shape) / f).clip(min=1)
+
+ # get the step size for the slices
+ slices = list(
+ slice(None, None, int(s)) for s in np.floor(full_shape / ns).astype(int)
+ )
+
+ # ignore dims e.g. RGB, which we don't want to downsample
+ if ignore_dims is not None:
+ for dim in ignore_dims:
+ slices[dim] = slice(None)
+
+ slices = tuple(slices)
+
+ return np.asarray(arr[slices])
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/utils/types.py b/fastplotlib/utils/types.py
new file mode 100644
index 000000000..e99fce2fc
--- /dev/null
+++ b/fastplotlib/utils/types.py
@@ -0,0 +1,4 @@
+from collections import namedtuple
+
+
+SelectorColorStates = namedtuple("state", ["idle", "highlight", "action"])
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 31a8176e5..86a01b083 100644
--- a/fastplotlib/widgets/image_widget/_widget.py
+++ b/fastplotlib/widgets/image_widget/_widget.py
@@ -195,33 +195,46 @@ def current_index(self, index: dict[str, int]):
if not self._initialized:
return
- if not set(index.keys()).issubset(set(self._current_index.keys())):
- raise KeyError(
- f"All dimension keys for setting `current_index` must be present in the widget sliders. "
- f"The dimensions currently used for sliders are: {list(self.current_index.keys())}"
- )
+ if self._reentrant_block:
+ return
- for k, val in index.items():
- if not isinstance(val, int):
- raise TypeError("Indices for all dimensions must be int")
- if val < 0:
- raise IndexError("negative indexing is not supported for ImageWidget")
- if val > self._dims_max_bounds[k]:
- raise IndexError(
- f"index {val} is out of bounds for dimension '{k}' "
- f"which has a max bound of: {self._dims_max_bounds[k]}"
+ try:
+ self._reentrant_block = True # block re-execution until current_index has *fully* completed execution
+ if not set(index.keys()).issubset(set(self._current_index.keys())):
+ raise KeyError(
+ f"All dimension keys for setting `current_index` must be present in the widget sliders. "
+ f"The dimensions currently used for sliders are: {list(self.current_index.keys())}"
)
- self._current_index.update(index)
+ for k, val in index.items():
+ if not isinstance(val, int):
+ raise TypeError("Indices for all dimensions must be int")
+ if val < 0:
+ raise IndexError(
+ "negative indexing is not supported for ImageWidget"
+ )
+ if val > self._dims_max_bounds[k]:
+ raise IndexError(
+ f"index {val} is out of bounds for dimension '{k}' "
+ f"which has a max bound of: {self._dims_max_bounds[k]}"
+ )
- for i, (ig, data) in enumerate(zip(self.managed_graphics, self.data)):
- frame = self._process_indices(data, self._current_index)
- frame = self._process_frame_apply(frame, i)
- ig.data = frame
+ self._current_index.update(index)
- # call any event handlers
- for handler in self._current_index_changed_handlers:
- handler(self.current_index)
+ for i, (ig, data) in enumerate(zip(self.managed_graphics, self.data)):
+ frame = self._process_indices(data, self._current_index)
+ frame = self._process_frame_apply(frame, i)
+ ig.data = frame
+
+ # call any event handlers
+ for handler in self._current_index_changed_handlers:
+ handler(self.current_index)
+ except Exception as exc:
+ # raise original exception
+ raise exc # current_index setter has raised. The lines above below are probably more relevant!
+ finally:
+ # set_value has finished executing, now allow future executions
+ self._reentrant_block = False
@property
def n_img_dims(self) -> list[int]:
@@ -329,7 +342,7 @@ def __init__(
manually provide the shape for the Figure, otherwise the number of rows and columns is estimated
figure_kwargs: dict, optional
- passed to `GridPlot`
+ passed to ``Figure``
names: Optional[str]
gives names to the subplots
@@ -347,8 +360,6 @@ def __init__(
"""
self._initialized = False
- self._names = None
-
if figure_kwargs is None:
figure_kwargs = dict()
@@ -425,7 +436,6 @@ def __init__(
raise ValueError(
"number of `names` for subplots must be same as the number of data arrays"
)
- self._names = names
else:
raise TypeError(
@@ -496,7 +506,7 @@ def __init__(
self._dims_max_bounds[_dim], array.shape[i]
)
- figure_kwargs_default = {"controller_ids": "sync"}
+ figure_kwargs_default = {"controller_ids": "sync", "names": names}
# update the default kwargs with any user-specified kwargs
# user specified kwargs will overwrite the defaults
@@ -518,10 +528,6 @@ def __init__(
self._histogram_widget = histogram_widget
for data_ix, (d, subplot) in enumerate(zip(self.data, self.figure)):
- if self._names is not None:
- name = self._names[data_ix]
- else:
- name = None
frame = self._process_indices(d, slice_indices=self._current_index)
frame = self._process_frame_apply(frame, data_ix)
@@ -554,11 +560,9 @@ def __init__(
**graphic_kwargs,
)
subplot.add_graphic(ig)
- subplot.name = name
- subplot.set_title(name)
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
@@ -583,10 +587,12 @@ def __init__(
self.figure.add_gui(self._image_widget_sliders)
- self._initialized = True
-
self._current_index_changed_handlers = set()
+ self._reentrant_block = False
+
+ self._initialized = True
+
@property
def frame_apply(self) -> dict | None:
return self._frame_apply
@@ -923,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
@@ -936,8 +947,10 @@ def set_data(
# make new graphic first
new_graphic = ImageGraphic(data=frame, name="image_widget_managed")
- # set hlut tool to use new graphic
- subplot.docks["right"]["histogram_lut"].image_graphic = new_graphic
+ if self._histogram_widget:
+ # set hlut tool to use new graphic
+ subplot.docks["right"]["histogram_lut"].images = new_graphic
+
# delete old graphic after setting hlut tool to new graphic
# this ensures gc
subplot.delete_graphic(graphic=subplot["image_widget_managed"])
@@ -953,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 4d957aee3..73dfd7ee3 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,3 +1,86 @@
+# ===== Project info
+
+[project]
+dynamic = ["version"]
+name = "fastplotlib"
+description = "Next-gen fast plotting library running on WGPU using the Pygfx rendering engine "
+readme = "README.md"
+license = { file = "LICENSE" }
+authors = [{ name = "Kushal Kolar" }, { name = "Caitlin Lewis" }]
+keywords = [
+ "visualization",
+ "science",
+ "interactive",
+ "pygfx",
+ "webgpu",
+ "wgpu",
+ "vulkan",
+ "gpu",
+]
+requires-python = ">= 3.10"
+dependencies = [
+ "numpy>=1.23.0",
+ "pygfx==0.15.3",
+ "wgpu", # Let pygfx constrain the wgpu version
+ "cmap>=0.1.3",
+ # (this comment keeps this list multiline in VSCode)
+]
+
+[project.optional-dependencies]
+docs = [
+ "sphinx",
+ "sphinx-gallery",
+ "pydata-sphinx-theme",
+ "glfw",
+ "ipywidgets>=8.0.0,<9",
+ "sphinx-copybutton",
+ "sphinx-design",
+ "pandoc",
+ "imageio[ffmpeg]",
+ "matplotlib",
+ "scikit-learn",
+ "ome-zarr",
+]
+notebook = [
+ "jupyterlab",
+ "jupyter-rfb>=0.5.1",
+ "ipywidgets>=8.0.0,<9",
+ "sidecar",
+]
+tests = [
+ "pytest",
+ "nbmake",
+ "black",
+ "scipy",
+ "imageio[ffmpeg]",
+ "scikit-learn",
+ "tqdm",
+ "ome-zarr",
+]
+imgui = ["wgpu[imgui]"]
+dev = ["fastplotlib[docs,notebook,tests,imgui]"]
+
+[project.urls]
+Homepage = "https://www.fastplotlib.org/"
+Documentation = "https://www.fastplotlib.org/"
+Repository = "https://github.com/fastplotlib/fastplotlib"
+
+# ===== Building
+
[build-system]
-requires = ["setuptools", "wheel"]
+requires = ["flit_core >=3.2,<4"]
+build-backend = "flit_core.buildapi"
+
+# ===== Tooling
+
+# [tool.ruff]
+# line-length = 88
+# [tool.ruff.lint]
+# select = ["F", "E", "W", "N", "B", "RUF", "TC"]
+# ignore = [
+# "E501", # Line too long
+# "E731", # Do not assign a `lambda` expression, use a `def`
+# "B019", # Use of `functools.lru_cache` or `functools.cache` on methods can lead to memory leaks
+# "RUF012", # Mutable class attributes should be annotated with `typing.ClassVar`"
+# ]
diff --git a/scripts/generate_add_graphic_methods.py b/scripts/generate_add_graphic_methods.py
index d69185521..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,12 +33,11 @@ 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")
f.write("\nclass GraphicMethodsMixin:\n")
- f.write(" def __init__(self):\n")
- f.write(" pass\n\n")
f.write(
" def _create_graphic(self, graphic_class, *args, **kwargs) -> Graphic:\n"
@@ -51,23 +53,25 @@ def generate_add_graphics_methods():
f.write(" return graphic\n\n")
for m in modules:
- class_name = m
- method_name = class_name.type
+ cls = m
+ cls_name = cls.__name__.replace("Graphic", "")
+ # from https://stackoverflow.com/a/1176023
+ method_name = re.sub(r"(? {class_name.__name__}:\n"
+ f" def add_{method_name}{inspect.signature(cls.__init__)} -> {cls.__name__}:\n"
)
f.write(' """\n')
- f.write(f" {class_name.__init__.__doc__}\n")
+ f.write(f" {cls.__init__.__doc__}\n")
f.write(' """\n')
f.write(
- f" return self._create_graphic({class_name.__name__}, {s} **kwargs)\n\n"
+ f" return self._create_graphic({cls.__name__}, {s} **kwargs)\n\n"
)
f.close()
diff --git a/setup.py b/setup.py
deleted file mode 100644
index 14d0f0c5b..000000000
--- a/setup.py
+++ /dev/null
@@ -1,76 +0,0 @@
-from setuptools import setup, find_packages
-from pathlib import Path
-
-
-install_requires = [
- "numpy>=1.23.0",
- "pygfx~=0.7.0",
- "wgpu>=0.18.1",
- "cmap>=0.1.3",
-]
-
-
-extras_require = {
- "docs": [
- "sphinx",
- "sphinx-gallery",
- "pydata-sphinx-theme",
- "glfw",
- "ipywidgets>=8.0.0,<9",
- "sphinx-copybutton",
- "sphinx-design",
- "pandoc",
- "imageio[ffmpeg]",
- "matplotlib",
- "scikit-learn",
- ],
- "notebook": [
- "jupyterlab",
- "jupyter-rfb>=0.5.1",
- "ipywidgets>=8.0.0,<9",
- "sidecar",
- ],
- "tests": [
- "pytest",
- "nbmake",
- "black",
- "scipy",
- "imageio[ffmpeg]",
- "scikit-learn",
- "tqdm",
- ],
- "imgui": ["imgui-bundle"],
-}
-
-
-with open(Path(__file__).parent.joinpath("README.md")) as f:
- readme = f.read()
-
-with open(Path(__file__).parent.joinpath("fastplotlib", "VERSION"), "r") as f:
- ver = f.read().split("\n")[0]
-
-
-classifiers = [
- "Programming Language :: Python :: 3",
- "Topic :: Scientific/Engineering :: Visualization",
- "License :: OSI Approved :: Apache Software License",
- "Intended Audience :: Science/Research",
-]
-
-
-setup(
- name="fastplotlib",
- version=ver,
- long_description=readme,
- long_description_content_type="text/markdown",
- packages=find_packages(),
- url="https://github.com/fastplotlib/fastplotlib",
- license="Apache 2.0",
- author="Kushal Kolar, Caitlin Lewis",
- author_email="",
- python_requires=">=3.10",
- install_requires=install_requires,
- extras_require=extras_require,
- include_package_data=True,
- description="A fast plotting library built using the pygfx render engine",
-)
diff --git a/tests/conftest.py b/tests/conftest.py
index 3f5414a71..761b0762e 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -2,7 +2,13 @@
MAX_TEXTURE_SIZE = 1024
+MAX_TEXTURE_SIZE_3D = 128
def pytest_sessionstart(session):
- pygfx.renderers.wgpu.set_wgpu_limits(**{"max-texture-dimension-2d": MAX_TEXTURE_SIZE})
+ pygfx.renderers.wgpu.set_wgpu_limits(
+ **{"max-texture-dimension-2d": MAX_TEXTURE_SIZE}
+ )
+ pygfx.renderers.wgpu.set_wgpu_limits(
+ **{"max-texture-dimension-3d": MAX_TEXTURE_SIZE_3D}
+ )
diff --git a/tests/test_colors_buffer_manager.py b/tests/test_colors_buffer_manager.py
index 8a6c5700f..7b1aef16a 100644
--- a/tests/test_colors_buffer_manager.py
+++ b/tests/test_colors_buffer_manager.py
@@ -5,7 +5,7 @@
import pygfx
import fastplotlib as fpl
-from fastplotlib.graphics._features import VertexColors, FeatureEvent
+from fastplotlib.graphics.features import VertexColors, GraphicFeatureEvent
from .utils import (
generate_slice_indices,
generate_color_inputs,
@@ -18,7 +18,7 @@ def make_colors_buffer() -> VertexColors:
return colors
-EVENT_RETURN_VALUE: FeatureEvent = None
+EVENT_RETURN_VALUE: GraphicFeatureEvent = None
def event_handler(ev):
@@ -65,7 +65,7 @@ def test_int(test_graphic):
if test_graphic:
# test event
- assert isinstance(EVENT_RETURN_VALUE, FeatureEvent)
+ assert isinstance(EVENT_RETURN_VALUE, GraphicFeatureEvent)
assert EVENT_RETURN_VALUE.graphic == graphic
assert EVENT_RETURN_VALUE.target is graphic.world_object
assert EVENT_RETURN_VALUE.info["key"] == 3
@@ -120,7 +120,7 @@ def test_tuple(test_graphic, slice_method):
if test_graphic:
# test event
- assert isinstance(EVENT_RETURN_VALUE, FeatureEvent)
+ assert isinstance(EVENT_RETURN_VALUE, GraphicFeatureEvent)
assert EVENT_RETURN_VALUE.graphic == graphic
assert EVENT_RETURN_VALUE.target is graphic.world_object
assert EVENT_RETURN_VALUE.info["key"] == (s, slice(None))
@@ -142,7 +142,7 @@ def test_tuple(test_graphic, slice_method):
if test_graphic:
# test event
- assert isinstance(EVENT_RETURN_VALUE, FeatureEvent)
+ assert isinstance(EVENT_RETURN_VALUE, GraphicFeatureEvent)
assert EVENT_RETURN_VALUE.graphic == graphic
assert EVENT_RETURN_VALUE.target is graphic.world_object
assert EVENT_RETURN_VALUE.info["key"] == slice(None)
@@ -218,7 +218,7 @@ def test_slice(color_input, slice_method: dict, test_graphic: bool):
if test_graphic:
global EVENT_RETURN_VALUE
- assert isinstance(EVENT_RETURN_VALUE, FeatureEvent)
+ assert isinstance(EVENT_RETURN_VALUE, GraphicFeatureEvent)
assert EVENT_RETURN_VALUE.graphic == graphic
assert EVENT_RETURN_VALUE.target is graphic.world_object
if isinstance(s, slice):
diff --git a/tests/test_common_features.py b/tests/test_common_features.py
index 332ac71ae..aea016aae 100644
--- a/tests/test_common_features.py
+++ b/tests/test_common_features.py
@@ -4,7 +4,13 @@
import pytest
import fastplotlib as fpl
-from fastplotlib.graphics._features import FeatureEvent, Name, Offset, Rotation, Visible
+from fastplotlib.graphics.features import (
+ GraphicFeatureEvent,
+ Name,
+ Offset,
+ Rotation,
+ Visible,
+)
def make_graphic(kind: str, **kwargs):
@@ -29,11 +35,11 @@ def make_graphic(kind: str, **kwargs):
]
-RETURN_EVENT_VALUE: FeatureEvent = None
-DECORATED_EVENT_VALUE: FeatureEvent = None
+RETURN_EVENT_VALUE: GraphicFeatureEvent = None
+DECORATED_EVENT_VALUE: GraphicFeatureEvent = None
-def return_event(ev: FeatureEvent):
+def return_event(ev: GraphicFeatureEvent):
global RETURN_EVENT_VALUE
RETURN_EVENT_VALUE = ev
@@ -138,7 +144,7 @@ def decorated_handler(ev):
assert DECORATED_EVENT_VALUE.type == "offset"
assert DECORATED_EVENT_VALUE.graphic is graphic
assert DECORATED_EVENT_VALUE.target is graphic.world_object
- assert DECORATED_EVENT_VALUE.info["value"] == (7.0, 8.0, 9.0)
+ npt.assert_almost_equal(DECORATED_EVENT_VALUE.info["value"], (7.0, 8.0, 9.0))
@pytest.mark.parametrize(
@@ -202,7 +208,7 @@ def decorated_handler(ev):
assert DECORATED_EVENT_VALUE.type == "rotation"
assert DECORATED_EVENT_VALUE.graphic is graphic
assert DECORATED_EVENT_VALUE.target is graphic.world_object
- assert DECORATED_EVENT_VALUE.info["value"] == (0, 0, 0.6, 0.8)
+ npt.assert_almost_equal(DECORATED_EVENT_VALUE.info["value"], (0, 0, 0.6, 0.8))
@pytest.mark.parametrize(
diff --git a/tests/events.py b/tests/test_events.py
similarity index 92%
rename from tests/events.py
rename to tests/test_events.py
index ea160dec3..e9b212adb 100644
--- a/tests/events.py
+++ b/tests/test_events.py
@@ -5,7 +5,7 @@
import pygfx
import fastplotlib as fpl
-from fastplotlib.graphics._features import FeatureEvent
+from fastplotlib.graphics.features import GraphicFeatureEvent
def make_positions_data() -> np.ndarray:
@@ -22,7 +22,7 @@ def make_scatter_graphic() -> fpl.ScatterGraphic:
return fpl.ScatterGraphic(make_positions_data())
-event_instance: FeatureEvent = None
+event_instance: GraphicFeatureEvent = None
def event_handler(event):
@@ -30,7 +30,7 @@ def event_handler(event):
event_instance = event
-decorated_event_instance: FeatureEvent = None
+decorated_event_instance: GraphicFeatureEvent = None
@pytest.mark.parametrize("graphic", [make_line_graphic(), make_scatter_graphic()])
@@ -42,7 +42,7 @@ def test_positions_data_event(graphic: fpl.LineGraphic | fpl.ScatterGraphic):
info = {"key": (slice(3, 8, None), 1), "value": value}
- expected = FeatureEvent(type="data", info=info)
+ expected = GraphicFeatureEvent(type="data", info=info)
def validate(graphic, handler, expected_feature_event, event_to_test):
assert expected_feature_event.type == event_to_test.type
diff --git a/tests/test_figure.py b/tests/test_figure.py
index 757b1eeae..d5d4087e3 100644
--- a/tests/test_figure.py
+++ b/tests/test_figure.py
@@ -170,3 +170,63 @@ def test_set_controllers_from_existing_controllers():
assert fig[0, 0].camera is cameras[0][0]
assert fig[0, 1].camera.fov == 50
+
+
+def test_subplot_names():
+ # names must be unique
+ with pytest.raises(ValueError):
+ fpl.Figure(shape=(2, 3), names=["1", "2", "3", "4", "4", "5"])
+
+ with pytest.raises(ValueError):
+ fpl.Figure(shape=(2, 3), names=["1", "2", None, "4", "4", "5"])
+
+ with pytest.raises(ValueError):
+ fpl.Figure(shape=(2, 3), names=[None, "2", None, "4", "4", "5"])
+
+ # len(names) <= n_subplots
+ fig = fpl.Figure(shape=(2, 3), names=["1", "2", "3", "4", "5", "6"])
+
+ assert fig[0, 0].name == "1"
+ assert fig[0, 1].name == "2"
+ assert fig[0, 2].name == "3"
+ assert fig[1, 0].name == "4"
+ assert fig[1, 1].name == "5"
+ assert fig[1, 2].name == "6"
+
+ fig = fpl.Figure(shape=(2, 3), names=["1", "2", "3", None, "5", "6"])
+
+ assert fig[0, 0].name == "1"
+ assert fig[0, 1].name == "2"
+ assert fig[0, 2].name == "3"
+ assert fig[1, 0].name is None
+ assert fig[1, 1].name == "5"
+ assert fig[1, 2].name == "6"
+
+ fig = fpl.Figure(shape=(2, 3), names=["1", "2", "3", None, "5", None])
+
+ assert fig[0, 0].name == "1"
+ assert fig[0, 1].name == "2"
+ assert fig[0, 2].name == "3"
+ assert fig[1, 0].name is None
+ assert fig[1, 1].name == "5"
+ assert fig[1, 2].name is None
+
+ # if fewer subplot names are given than n_sublots, pad with Nones
+ fig = fpl.Figure(shape=(2, 3), names=["1", "2", "3", "4"])
+
+ assert fig[0, 0].name == "1"
+ assert fig[0, 1].name == "2"
+ assert fig[0, 2].name == "3"
+ assert fig[1, 0].name == "4"
+ assert fig[1, 1].name is None
+ assert fig[1, 2].name is None
+
+ # raise if len(names) > n_subplots
+ with pytest.raises(ValueError):
+ 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"])
+
+ with pytest.raises(ValueError):
+ fpl.Figure(shape=(2, 3), names=["1", None, "3", "4", None, "6", "7"])
diff --git a/tests/test_image_graphic.py b/tests/test_image_graphic.py
index 0ea9979a6..f2d87860b 100644
--- a/tests/test_image_graphic.py
+++ b/tests/test_image_graphic.py
@@ -2,8 +2,10 @@
from numpy import testing as npt
import imageio.v3 as iio
+import pygfx
+
import fastplotlib as fpl
-from fastplotlib.graphics._features import FeatureEvent
+from fastplotlib.graphics.features import GraphicFeatureEvent
from fastplotlib.utils import make_colors
GRAY_IMAGE = iio.imread("imageio:camera.png")
@@ -16,7 +18,7 @@
# new screenshot tests too for these when in graphics
-EVENT_RETURN_VALUE: FeatureEvent = None
+EVENT_RETURN_VALUE: GraphicFeatureEvent = None
def event_handler(ev):
@@ -26,7 +28,7 @@ def event_handler(ev):
def check_event(graphic, feature, value):
global EVENT_RETURN_VALUE
- assert isinstance(EVENT_RETURN_VALUE, FeatureEvent)
+ 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
@@ -56,7 +58,7 @@ def check_set_slice(
npt.assert_almost_equal(data_values[:, col_slice.stop :], data[:, col_slice.stop :])
global EVENT_RETURN_VALUE
- assert isinstance(EVENT_RETURN_VALUE, FeatureEvent)
+ 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
@@ -86,6 +88,10 @@ def test_gray():
# the entire image should be in the single Texture buffer
npt.assert_almost_equal(ig.data.buffer[0, 0].data, GRAY_IMAGE)
+ assert isinstance(ig._material, pygfx.ImageBasicMaterial)
+ 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")
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 1d0a17f3d..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_text_graphic.py b/tests/test_text_graphic.py
index a13dfe690..ec3d0be54 100644
--- a/tests/test_text_graphic.py
+++ b/tests/test_text_graphic.py
@@ -1,8 +1,8 @@
from numpy import testing as npt
import fastplotlib as fpl
-from fastplotlib.graphics._features import (
- FeatureEvent,
+from fastplotlib.graphics.features import (
+ GraphicFeatureEvent,
TextData,
FontSize,
TextFaceColor,
@@ -25,7 +25,7 @@ def test_create_graphic():
assert text.font_size == 14
assert isinstance(text._font_size, FontSize)
- assert text.world_object.geometry.font_size == 14
+ assert text.world_object.font_size == 14
assert text.face_color == pygfx.Color("w")
assert isinstance(text._face_color, TextFaceColor)
@@ -40,7 +40,7 @@ def test_create_graphic():
assert text.world_object.material.outline_thickness == 0
-EVENT_RETURN_VALUE: FeatureEvent = None
+EVENT_RETURN_VALUE: GraphicFeatureEvent = None
def event_handler(ev):
@@ -50,7 +50,7 @@ def event_handler(ev):
def check_event(graphic, feature, value):
global EVENT_RETURN_VALUE
- assert isinstance(EVENT_RETURN_VALUE, FeatureEvent)
+ 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
@@ -82,7 +82,7 @@ def test_text_changes_events():
text.font_size = 10.0
assert text.font_size == 10.0
- assert text.world_object.geometry.font_size == 10
+ assert text.world_object.font_size == 10
check_event(text, "font_size", 10)
text.face_color = "r"
diff --git a/tests/test_texture_array.py b/tests/test_texture_array.py
index c85fc7652..6220f2fe5 100644
--- a/tests/test_texture_array.py
+++ b/tests/test_texture_array.py
@@ -5,7 +5,7 @@
import pygfx
import fastplotlib as fpl
-from fastplotlib.graphics._features import TextureArray
+from fastplotlib.graphics.features import TextureArray
from fastplotlib.graphics.image import _ImageTile
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,
+ ),
+}