glplot 0.1.10__tar.gz → 0.1.11__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {glplot-0.1.10 → glplot-0.1.11}/PKG-INFO +2 -2
- {glplot-0.1.10 → glplot-0.1.11}/README.md +1 -1
- {glplot-0.1.10 → glplot-0.1.11}/glplot/__init__.py +1 -1
- {glplot-0.1.10 → glplot-0.1.11}/glplot/animation.py +139 -39
- {glplot-0.1.10 → glplot-0.1.11}/glplot/engine.py +154 -3
- {glplot-0.1.10 → glplot-0.1.11}/glplot/gui/panels/timeline.py +9 -3
- {glplot-0.1.10 → glplot-0.1.11}/glplot/gui/workspace.py +3 -2
- {glplot-0.1.10 → glplot-0.1.11}/glplot/utils/export.py +23 -8
- {glplot-0.1.10 → glplot-0.1.11}/glplot/utils/preview.py +22 -1
- {glplot-0.1.10 → glplot-0.1.11}/pyproject.toml +1 -1
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_animation_api.py +148 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_animation_integration.py +38 -1
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_gui_input_regressions.py +89 -1
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_gui_timeline_panel.py +22 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_scatter_default_size.py +29 -1
- {glplot-0.1.10 → glplot-0.1.11}/.gitignore +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/CHANGELOG.md +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/CITATION.cff +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/LICENSE +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/__main__.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/anim/__init__.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/anim/applier.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/anim/primitives.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/anim/processes.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/backend.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/controllers.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/core/__init__.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/core/camera3d.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/core/context.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/core/layers.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/core/layout.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/core/legacy.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/core/panel.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/core/timeline.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/gui/__init__.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/gui/actions.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/gui/app.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/gui/background.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/gui/clipboard.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/gui/commands.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/gui/dataio.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/gui/datasets.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/gui/dynamics.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/gui/expressions.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/gui/fuzzy.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/gui/generators3d.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/gui/history.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/gui/icons.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/gui/keys.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/gui/layerops.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/gui/layerops3d.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/gui/mathadvise.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/gui/mathops.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/gui/mathops2d.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/gui/mathopsnd.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/gui/models.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/gui/notifications.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/gui/panels/__init__.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/gui/panels/annotate.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/gui/panels/base.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/gui/panels/data_editor.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/gui/panels/dynamics.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/gui/panels/functions.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/gui/panels/help.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/gui/panels/mathlab.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/gui/panels/objects3d.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/gui/panels/palette.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/gui/panels/pipeline.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/gui/panels/presentation.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/gui/panels/scene.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/gui/panels/selection.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/gui/panels/style.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/gui/panels/view3d.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/gui/pipeline.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/gui/styles.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/gui/theme.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/gui/widgets.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/managers/__init__.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/managers/axis.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/managers/effects.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/managers/hud.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/managers/hud_state.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/managers/picking.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/managers/renderer_manager.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/options.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/policy.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/pyplot.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/renderers/__init__.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/renderers/axes3d.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/renderers/axis.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/renderers/base.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/renderers/colorbar.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/renderers/contour_labels.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/renderers/density.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/renderers/exact.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/renderers/fractal.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/renderers/geometry3d.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/renderers/interaction.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/renderers/legend.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/renderers/line_family.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/renderers/patch.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/renderers/polyline.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/renderers/scatter.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/renderers/text.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/utils/__init__.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/utils/anim_export.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/utils/blending.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/utils/gl_utils.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/utils/mpl_bridge.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/utils/mpl_process.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/utils/scale.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/glplot/utils/shaders.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/conftest.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_anim_applier.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_anim_export.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_anim_primitives.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_anim_processes.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_api_consistency.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_axes.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_axes3d.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_axis_margins.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_blending_3d.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_camera3d.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_camera_anisotropy.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_camera_projections.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_compositing_3d.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_compositing_api.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_contour_labels.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_dataio.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_density_tint.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_edge_cases.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_effects_post.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_encodings.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_engine.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_export.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_fractal.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_function_layer.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_gallery_integration.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_generators3d.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_generators3d_extra.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_geometry3d.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_gui_3d_panels.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_gui_actions.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_gui_annotate.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_gui_background.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_gui_clipboard.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_gui_commands.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_gui_data_editor.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_gui_datasets.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_gui_dynamics.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_gui_engine_integration.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_gui_expressions.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_gui_fields.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_gui_fuzzy.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_gui_history.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_gui_import_safety.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_gui_impostor_alignment.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_gui_keyboard_capture.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_gui_keys.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_gui_layer_colormaps.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_gui_layer_kinds.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_gui_layer_params.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_gui_legend.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_gui_mathadvise.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_gui_mathlab.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_gui_mathops.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_gui_mathops2d.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_gui_mathopsnd.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_gui_models.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_gui_notifications.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_gui_pipeline.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_gui_regression_r2.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_gui_scene_delete.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_gui_scene_order.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_gui_style_layer_editor.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_gui_styles.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_gui_styles_extra.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_gui_widgets.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_helpers.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_impostor_scatter_fidelity.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_layerops3d.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_layers.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_mpl_bridge.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_mpl_compat.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_mpl_compat_3d.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_mpl_parity_surface.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_mpl_process.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_objects3d_preview.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_options.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_outline.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_outline_2d.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_panels.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_performance_benchmarks.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_plot_style_api.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_polyline_lod.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_pyplot.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_pyplot_3d.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_pyplot_integration.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_regression.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_robustness.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_scatter_renderer.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_selection_model.py +0 -0
- {glplot-0.1.10 → glplot-0.1.11}/tests/test_timeline.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: glplot
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.11
|
|
4
4
|
Summary: High-performance OpenGL plotting library for Python
|
|
5
5
|
Project-URL: Homepage, https://github.com/AkarisDimitry/GLPlot
|
|
6
6
|
Project-URL: Repository, https://github.com/AkarisDimitry/GLPlot
|
|
@@ -486,7 +486,7 @@ plt.show()
|
|
|
486
486
|
```
|
|
487
487
|
</details>
|
|
488
488
|
|
|
489
|
-
**All of the above render at 60+ FPS** with interactive panning, zooming, and rotation, regardless of point count. Discover more in the [example gallery](examples/gallery/README.md) (28 static scripts) and the [animated gallery](examples/gallery/animations/README.md) (
|
|
489
|
+
**All of the above render at 60+ FPS** with interactive panning, zooming, and rotation, regardless of point count. Discover more in the [example gallery](examples/gallery/README.md) (28 static scripts) and the [animated gallery](examples/gallery/animations/README.md) (16 animated scripts).
|
|
490
490
|
|
|
491
491
|
## Features
|
|
492
492
|
|
|
@@ -442,7 +442,7 @@ plt.show()
|
|
|
442
442
|
```
|
|
443
443
|
</details>
|
|
444
444
|
|
|
445
|
-
**All of the above render at 60+ FPS** with interactive panning, zooming, and rotation, regardless of point count. Discover more in the [example gallery](examples/gallery/README.md) (28 static scripts) and the [animated gallery](examples/gallery/animations/README.md) (
|
|
445
|
+
**All of the above render at 60+ FPS** with interactive panning, zooming, and rotation, regardless of point count. Discover more in the [example gallery](examples/gallery/README.md) (28 static scripts) and the [animated gallery](examples/gallery/animations/README.md) (16 animated scripts).
|
|
446
446
|
|
|
447
447
|
## Features
|
|
448
448
|
|
|
@@ -38,7 +38,7 @@ except Exception: # pragma: no cover - imgui_bundle is optional at import time
|
|
|
38
38
|
from .engine import GPULinePlot
|
|
39
39
|
from .options import BlendMode, EngineOptions, RenderMode
|
|
40
40
|
|
|
41
|
-
__version__ = "0.1.
|
|
41
|
+
__version__ = "0.1.10"
|
|
42
42
|
__author__ = "Juan Manuel Lombardi"
|
|
43
43
|
__license__ = "MIT"
|
|
44
44
|
|
|
@@ -32,13 +32,10 @@ How a frame is produced
|
|
|
32
32
|
-----------------------
|
|
33
33
|
:func:`figure_to_rgb` is the whole bridge, and it has two modes:
|
|
34
34
|
|
|
35
|
-
* **
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
panel/scissor/projection logic and go stale the first time the render pipeline changed —
|
|
40
|
-
there is no "give me the pixels" entry point to call instead, and this is the one GL render
|
|
41
|
-
mode this module has, so paying that cost once here beats maintaining a second copy.
|
|
35
|
+
* **OpenGL** — when ``fig.window`` exists, or ``Animation.save`` is explicitly given
|
|
36
|
+
``savefig_kwargs={"renderer": "opengl"}``, the scene is rendered offscreen through the
|
|
37
|
+
real GPU pipeline at full quality. ``ExportManager.render_rgb`` returns the framebuffer
|
|
38
|
+
directly, so animation frames do not make a temporary PNG round trip.
|
|
42
39
|
* **Headless** — when it does not,
|
|
43
40
|
:func:`glplot.utils.preview.render_preview_array` draws the same scene through
|
|
44
41
|
matplotlib's Agg backend and hands back the rendered pixels directly, with no file
|
|
@@ -52,11 +49,12 @@ How a frame is produced
|
|
|
52
49
|
``savefig()`` call uses) is unchanged; :func:`render_preview_array` is a second entry
|
|
53
50
|
point onto the same figure-building code, not a rewrite of it — see its docstring.
|
|
54
51
|
|
|
55
|
-
**The
|
|
52
|
+
**The matplotlib path is not pixel-identical to the GL path.** ``render_preview`` is a
|
|
56
53
|
matplotlib re-drawing of the scene, not the GPU renderer; it approximates. An animation
|
|
57
|
-
saved without a window
|
|
58
|
-
|
|
59
|
-
``
|
|
54
|
+
saved without a window uses this backwards-compatible path unless a renderer is requested.
|
|
55
|
+
Pass ``savefig_kwargs={"renderer": "opengl"}`` when the GPU look matters, ``"matplotlib"``
|
|
56
|
+
to force the portable preview, or ``"auto"`` to try an invisible GL context and warn before
|
|
57
|
+
falling back to matplotlib when no context can be created.
|
|
60
58
|
|
|
61
59
|
Limitations, stated plainly
|
|
62
60
|
---------------------------
|
|
@@ -95,7 +93,9 @@ import itertools
|
|
|
95
93
|
import json
|
|
96
94
|
import os
|
|
97
95
|
import shutil
|
|
96
|
+
import sys
|
|
98
97
|
import tempfile
|
|
98
|
+
import threading
|
|
99
99
|
import uuid
|
|
100
100
|
import warnings
|
|
101
101
|
from pathlib import Path
|
|
@@ -309,12 +309,104 @@ def figure_to_rgb(fig: Any, dpi: Optional[float] = None) -> np.ndarray:
|
|
|
309
309
|
)
|
|
310
310
|
|
|
311
311
|
|
|
312
|
-
def
|
|
313
|
-
"""
|
|
312
|
+
def _normalize_frame_renderer(renderer: Any) -> Optional[str]:
|
|
313
|
+
"""Validate the GLPlot-only renderer option carried inside ``savefig_kwargs``."""
|
|
314
|
+
if renderer is None:
|
|
315
|
+
return None
|
|
316
|
+
value = str(renderer).strip().lower()
|
|
317
|
+
if value not in {"auto", "opengl", "matplotlib"}:
|
|
318
|
+
raise ValueError(
|
|
319
|
+
"animation renderer must be 'auto', 'opengl', or 'matplotlib'; "
|
|
320
|
+
f"got {renderer!r}"
|
|
321
|
+
)
|
|
322
|
+
return value
|
|
314
323
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
324
|
+
|
|
325
|
+
@contextlib.contextmanager
|
|
326
|
+
def _frame_renderer_override(fig: Any, renderer: str) -> Iterator[None]:
|
|
327
|
+
"""Temporarily select the route used by :func:`figure_to_rgb`."""
|
|
328
|
+
missing = object()
|
|
329
|
+
previous = getattr(fig, "_animation_renderer_override", missing)
|
|
330
|
+
fig._animation_renderer_override = renderer
|
|
331
|
+
try:
|
|
332
|
+
yield
|
|
333
|
+
finally:
|
|
334
|
+
if previous is missing:
|
|
335
|
+
with contextlib.suppress(AttributeError):
|
|
336
|
+
del fig._animation_renderer_override
|
|
337
|
+
else:
|
|
338
|
+
fig._animation_renderer_override = previous
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
@contextlib.contextmanager
|
|
342
|
+
def _animation_render_context(fig: Any, renderer: Optional[str]) -> Iterator[None]:
|
|
343
|
+
"""Keep the selected animation renderer ready for one complete save operation.
|
|
344
|
+
|
|
345
|
+
``None`` preserves the historical behaviour: use GL only if the caller already opened
|
|
346
|
+
a context, otherwise use matplotlib. Explicit ``auto`` is opt-in and tries to create an
|
|
347
|
+
invisible context once before falling back. Explicit ``opengl`` reports setup failure
|
|
348
|
+
instead of silently producing a visually different animation.
|
|
349
|
+
"""
|
|
350
|
+
renderer = _normalize_frame_renderer(renderer)
|
|
351
|
+
|
|
352
|
+
if not hasattr(fig, "scene"):
|
|
353
|
+
if renderer == "opengl":
|
|
354
|
+
raise ValueError("renderer='opengl' requires a GLPlot figure")
|
|
355
|
+
yield
|
|
356
|
+
return
|
|
357
|
+
|
|
358
|
+
if renderer is None:
|
|
359
|
+
with _frame_renderer_override(fig, "auto"):
|
|
360
|
+
yield
|
|
361
|
+
return
|
|
362
|
+
|
|
363
|
+
if renderer == "matplotlib":
|
|
364
|
+
with _frame_renderer_override(fig, "matplotlib"):
|
|
365
|
+
yield
|
|
366
|
+
return
|
|
367
|
+
|
|
368
|
+
setup_error: Optional[BaseException] = None
|
|
369
|
+
stack = contextlib.ExitStack()
|
|
370
|
+
try:
|
|
371
|
+
if (
|
|
372
|
+
getattr(fig, "window", None) is None
|
|
373
|
+
and sys.platform == "darwin"
|
|
374
|
+
and threading.current_thread() is not threading.main_thread()
|
|
375
|
+
):
|
|
376
|
+
raise RuntimeError(
|
|
377
|
+
"macOS requires creation of the GLFW/OpenGL context on the main thread"
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
context_factory = getattr(fig, "_offscreen_export_context", None)
|
|
381
|
+
if not callable(context_factory):
|
|
382
|
+
raise RuntimeError("this GLPlot figure cannot create an offscreen OpenGL context")
|
|
383
|
+
stack.enter_context(context_factory())
|
|
384
|
+
except Exception as exc:
|
|
385
|
+
setup_error = exc
|
|
386
|
+
stack.close()
|
|
387
|
+
|
|
388
|
+
if setup_error is not None:
|
|
389
|
+
if renderer == "opengl":
|
|
390
|
+
raise RuntimeError(
|
|
391
|
+
"Could not create the OpenGL context requested for animation export"
|
|
392
|
+
) from setup_error
|
|
393
|
+
warnings.warn(
|
|
394
|
+
"OpenGL animation export is unavailable; falling back to the matplotlib "
|
|
395
|
+
f"renderer ({setup_error}).",
|
|
396
|
+
RuntimeWarning,
|
|
397
|
+
stacklevel=3,
|
|
398
|
+
)
|
|
399
|
+
with _frame_renderer_override(fig, "matplotlib"):
|
|
400
|
+
yield
|
|
401
|
+
return
|
|
402
|
+
|
|
403
|
+
with stack:
|
|
404
|
+
with _frame_renderer_override(fig, "opengl"):
|
|
405
|
+
yield
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _render_glplot_frame(fig: Any, scale: float) -> np.ndarray:
|
|
409
|
+
"""Render one GLPlot frame through the selected OpenGL or matplotlib route.
|
|
318
410
|
|
|
319
411
|
**Headless** does not: :func:`glplot.utils.preview.render_preview_array` hands back the
|
|
320
412
|
rendered pixels directly from matplotlib's Agg canvas, skipping the PNG encode and the
|
|
@@ -323,17 +415,18 @@ def _render_glplot_frame(fig: Any, scale: float) -> np.ndarray:
|
|
|
323
415
|
common case, since a saved animation is usually built without ever calling ``fig.run()``
|
|
324
416
|
— this removes roughly a third of each frame's cost with no change in what gets drawn.
|
|
325
417
|
|
|
326
|
-
``ExportManager.
|
|
327
|
-
|
|
328
|
-
swallowed for the duration of the GL branch — this is the only place that print reaches,
|
|
329
|
-
and suppressing it here beats every alternative that would require editing the engine.
|
|
418
|
+
The OpenGL branch calls ``ExportManager.render_rgb`` so it shares the export pipeline's
|
|
419
|
+
panel, scissor, projection, depth and shader logic without touching the filesystem.
|
|
330
420
|
"""
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
return
|
|
421
|
+
renderer = getattr(fig, "_animation_renderer_override", "auto")
|
|
422
|
+
if renderer != "matplotlib" and getattr(fig, "window", None) is not None:
|
|
423
|
+
export = getattr(fig, "export", None)
|
|
424
|
+
render_rgb = getattr(export, "render_rgb", None)
|
|
425
|
+
if callable(render_rgb):
|
|
426
|
+
return render_rgb(scale=scale)
|
|
427
|
+
|
|
428
|
+
if renderer == "opengl":
|
|
429
|
+
raise RuntimeError("OpenGL animation rendering was requested, but no context is active")
|
|
337
430
|
|
|
338
431
|
from .utils.preview import render_preview_array
|
|
339
432
|
|
|
@@ -1340,8 +1433,10 @@ class Animation:
|
|
|
1340
1433
|
twice the default size. See :func:`figure_pixel_size` for what that works out to.
|
|
1341
1434
|
|
|
1342
1435
|
``extra_anim`` is honoured: the animations are stepped together, one saved frame per
|
|
1343
|
-
tick, as in matplotlib. ``savefig_kwargs``
|
|
1344
|
-
``
|
|
1436
|
+
tick, as in matplotlib. Most ``savefig_kwargs`` are accepted for compatibility and
|
|
1437
|
+
ignored; GLPlot additionally consumes ``renderer`` with values ``"opengl"``,
|
|
1438
|
+
``"matplotlib"`` or ``"auto"``. Omitting it preserves the historical behaviour:
|
|
1439
|
+
an already-live GL context is used, otherwise rendering stays headless through Agg.
|
|
1345
1440
|
"""
|
|
1346
1441
|
all_anim: List[Animation] = [self]
|
|
1347
1442
|
if extra_anim is not None:
|
|
@@ -1371,6 +1466,7 @@ class Animation:
|
|
|
1371
1466
|
else:
|
|
1372
1467
|
savefig_kwargs = dict(savefig_kwargs)
|
|
1373
1468
|
savefig_kwargs.pop("bbox_inches", None)
|
|
1469
|
+
renderer = _normalize_frame_renderer(savefig_kwargs.pop("renderer", None))
|
|
1374
1470
|
|
|
1375
1471
|
if fps is None and hasattr(self, "_interval"):
|
|
1376
1472
|
fps = MS_PER_SECOND / float(self._interval)
|
|
@@ -1390,17 +1486,18 @@ class Animation:
|
|
|
1390
1486
|
writer = writer_cls(fps if fps is not None else 5, **writer_kwargs)
|
|
1391
1487
|
|
|
1392
1488
|
total_frames = _total_frames(all_anim)
|
|
1393
|
-
with
|
|
1394
|
-
|
|
1395
|
-
anim
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
for anim
|
|
1399
|
-
anim
|
|
1400
|
-
|
|
1401
|
-
progress_callback
|
|
1402
|
-
|
|
1403
|
-
|
|
1489
|
+
with _animation_render_context(self._fig, renderer):
|
|
1490
|
+
with writer.saving(self._fig, str(filename), dpi):
|
|
1491
|
+
for anim in all_anim:
|
|
1492
|
+
anim._init_draw()
|
|
1493
|
+
frame_number = 0
|
|
1494
|
+
for data in zip(*[anim.new_saved_frame_seq() for anim in all_anim]):
|
|
1495
|
+
for anim, datum in zip(all_anim, data):
|
|
1496
|
+
anim._draw_next_frame(datum, blit=False)
|
|
1497
|
+
if progress_callback is not None:
|
|
1498
|
+
progress_callback(frame_number, total_frames)
|
|
1499
|
+
frame_number += 1
|
|
1500
|
+
writer.grab_frame(**savefig_kwargs)
|
|
1404
1501
|
|
|
1405
1502
|
# -- HTML ------------------------------------------------------------------------
|
|
1406
1503
|
|
|
@@ -1723,6 +1820,9 @@ class TimedAnimation(Animation):
|
|
|
1723
1820
|
timeline.set_loop("loop" if self._repeat else "once")
|
|
1724
1821
|
timeline.play()
|
|
1725
1822
|
self._live_timeline = timeline
|
|
1823
|
+
register_timeline = getattr(fig, "register_live_animation_timeline", None)
|
|
1824
|
+
if callable(register_timeline):
|
|
1825
|
+
register_timeline(timeline)
|
|
1726
1826
|
|
|
1727
1827
|
live_seq = {"iter": self.frame_seq}
|
|
1728
1828
|
state = {"last_index": None}
|
|
@@ -2,7 +2,9 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import logging
|
|
4
4
|
import math
|
|
5
|
+
import sys
|
|
5
6
|
import time
|
|
7
|
+
from contextlib import contextmanager
|
|
6
8
|
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
7
9
|
|
|
8
10
|
import glfw
|
|
@@ -160,6 +162,11 @@ class GPULinePlot:
|
|
|
160
162
|
#: Per-frame callbacks, run at the top of the main loop. See
|
|
161
163
|
#: :meth:`add_frame_callback` — the hook that makes live animation possible.
|
|
162
164
|
self._frame_callbacks: List[Callable[[float], None]] = []
|
|
165
|
+
#: Timelines currently driving live ``FuncAnimation`` objects. A camera click may
|
|
166
|
+
#: activate a different subplot, but transport controls must keep addressing the
|
|
167
|
+
#: animation they were created for instead of toggling the newly active panel's
|
|
168
|
+
#: unrelated timeline.
|
|
169
|
+
self._live_animation_timelines: List[Any] = []
|
|
163
170
|
self._axes3d_labels: List[axes3d.LabelAnchor] = []
|
|
164
171
|
#: The padded box those anchors were computed against, so the HUD projects them
|
|
165
172
|
#: with exactly the matrix the geometry was drawn with.
|
|
@@ -794,6 +801,33 @@ class GPULinePlot:
|
|
|
794
801
|
self._frame_callbacks.append(fn)
|
|
795
802
|
return fn
|
|
796
803
|
|
|
804
|
+
def register_live_animation_timeline(self, timeline: Any) -> Any:
|
|
805
|
+
"""Remember a timeline controlled by a live animation and return it.
|
|
806
|
+
|
|
807
|
+
The list is identity-deduplicated because several animations may deliberately
|
|
808
|
+
share one panel timeline. Keeping this on the figure lets keyboard and GUI
|
|
809
|
+
transports survive active-panel changes caused by camera interaction.
|
|
810
|
+
"""
|
|
811
|
+
if not any(existing is timeline for existing in self._live_animation_timelines):
|
|
812
|
+
self._live_animation_timelines.append(timeline)
|
|
813
|
+
return timeline
|
|
814
|
+
|
|
815
|
+
def transport_timeline(self) -> Any:
|
|
816
|
+
"""Return the timeline global playback controls should operate on.
|
|
817
|
+
|
|
818
|
+
Prefer the active panel when it owns a live animation. If exactly one live
|
|
819
|
+
animation exists elsewhere, keep controlling that one after the user clicks or
|
|
820
|
+
orbits another subplot. Figures without a live animation retain the historical
|
|
821
|
+
per-active-panel behaviour.
|
|
822
|
+
"""
|
|
823
|
+
active = self.active_panel.timeline
|
|
824
|
+
live = self._live_animation_timelines
|
|
825
|
+
if any(candidate is active for candidate in live):
|
|
826
|
+
return active
|
|
827
|
+
if len(live) == 1:
|
|
828
|
+
return live[0]
|
|
829
|
+
return active
|
|
830
|
+
|
|
797
831
|
def remove_frame_callback(self, fn: Callable[[float], None]) -> bool:
|
|
798
832
|
"""Unregister a callback added by :meth:`add_frame_callback`."""
|
|
799
833
|
for i, existing in enumerate(self._frame_callbacks):
|
|
@@ -1835,6 +1869,29 @@ class GPULinePlot:
|
|
|
1835
1869
|
glfw.swap_buffers(self.window)
|
|
1836
1870
|
return
|
|
1837
1871
|
|
|
1872
|
+
# On macOS, especially after an earlier invisible export context, Cocoa may not
|
|
1873
|
+
# finish registering the new native window until its first event-pump pass. Focus
|
|
1874
|
+
# requested directly after ``create_window`` is then ignored. Present it only once
|
|
1875
|
+
# all GUI modules are ready, drain those creation events, and request focus after.
|
|
1876
|
+
glfw.show_window(self.window)
|
|
1877
|
+
glfw.poll_events()
|
|
1878
|
+
if sys.platform == "darwin":
|
|
1879
|
+
try:
|
|
1880
|
+
# A process whose first Cocoa window was the invisible export context is
|
|
1881
|
+
# treated as a background application. GLFW can mark the later window
|
|
1882
|
+
# visible, but ``focus_window`` alone cannot activate the application, so
|
|
1883
|
+
# the GUI remains behind every normal app. PyObjC is optional: when it is
|
|
1884
|
+
# present, ask Cocoa to activate this process before requesting GLFW focus.
|
|
1885
|
+
from AppKit import NSApplication, NSApplicationActivationPolicyRegular
|
|
1886
|
+
|
|
1887
|
+
application = NSApplication.sharedApplication()
|
|
1888
|
+
application.setActivationPolicy_(NSApplicationActivationPolicyRegular)
|
|
1889
|
+
application.unhide_(None)
|
|
1890
|
+
application.activateIgnoringOtherApps_(True)
|
|
1891
|
+
except Exception: # pragma: no cover - optional platform bridge
|
|
1892
|
+
pass
|
|
1893
|
+
glfw.focus_window(self.window)
|
|
1894
|
+
glfw.poll_events()
|
|
1838
1895
|
self._main_loop()
|
|
1839
1896
|
|
|
1840
1897
|
def save_current_view(self, filename: Optional[str] = None, scale: float = 2.0) -> None:
|
|
@@ -1855,7 +1912,7 @@ class GPULinePlot:
|
|
|
1855
1912
|
# Init
|
|
1856
1913
|
# --------------------------------------------------------
|
|
1857
1914
|
|
|
1858
|
-
def _init_window(self) -> None:
|
|
1915
|
+
def _init_window(self, *, visible: Optional[bool] = None) -> None:
|
|
1859
1916
|
if not glfw.init():
|
|
1860
1917
|
raise RuntimeError("Failed to initialize GLFW")
|
|
1861
1918
|
|
|
@@ -1876,8 +1933,12 @@ class GPULinePlot:
|
|
|
1876
1933
|
if self.options.enable_multisample:
|
|
1877
1934
|
glfw.window_hint(glfw.SAMPLES, 4)
|
|
1878
1935
|
|
|
1879
|
-
|
|
1880
|
-
|
|
1936
|
+
# GLFW hints persist until they are changed or reset. Set visibility on every
|
|
1937
|
+
# creation so a temporary invisible animation-export window cannot accidentally
|
|
1938
|
+
# make the next interactive ``show()`` window invisible as well.
|
|
1939
|
+
if visible is None:
|
|
1940
|
+
visible = not self._is_test_mode
|
|
1941
|
+
glfw.window_hint(glfw.VISIBLE, glfw.TRUE if visible else glfw.FALSE)
|
|
1881
1942
|
|
|
1882
1943
|
self.window = glfw.create_window(self.width, self.height, self.options.title, None, None)
|
|
1883
1944
|
if not self.window:
|
|
@@ -1934,6 +1995,80 @@ class GPULinePlot:
|
|
|
1934
1995
|
if self.scene.lines.count > 0:
|
|
1935
1996
|
self.exact_renderer.upload(self.scene.lines)
|
|
1936
1997
|
|
|
1998
|
+
def _reset_layer_gl_state(self) -> None:
|
|
1999
|
+
"""Forget context-owned layer handles and request a fresh GPU upload.
|
|
2000
|
+
|
|
2001
|
+
A layer may belong to any panel, not only the currently active one. Temporary
|
|
2002
|
+
animation contexts are destroyed after saving, so every VAO/VBO allocated in that
|
|
2003
|
+
context becomes invalid and must not survive into a later interactive ``show()``.
|
|
2004
|
+
"""
|
|
2005
|
+
for panel in self.panels:
|
|
2006
|
+
for layer in panel.scene.layers:
|
|
2007
|
+
if hasattr(layer, "_gl"):
|
|
2008
|
+
layer._gl = None
|
|
2009
|
+
layer.dirty.gpu_dirty = True
|
|
2010
|
+
|
|
2011
|
+
def _init_export_modules(self) -> None:
|
|
2012
|
+
"""Initialize only the OpenGL modules needed by offscreen figure export.
|
|
2013
|
+
|
|
2014
|
+
The export pass draws through ``RendererManager`` (and optionally the density
|
|
2015
|
+
renderer). It does not need picking, the interaction cache, post-processing, or an
|
|
2016
|
+
ImGui context. Keeping those out is both faster and avoids constructing GUI state
|
|
2017
|
+
for a window which is deliberately never shown.
|
|
2018
|
+
"""
|
|
2019
|
+
self._reset_layer_gl_state()
|
|
2020
|
+
self.density_renderer.initialize(self.fb_width, self.fb_height)
|
|
2021
|
+
self.renderer_manager.initialize()
|
|
2022
|
+
|
|
2023
|
+
@contextmanager
|
|
2024
|
+
def _offscreen_export_context(self):
|
|
2025
|
+
"""Provide a current GL context, creating one invisible window when necessary.
|
|
2026
|
+
|
|
2027
|
+
A newly created context lives for the whole ``with`` block so an animation can
|
|
2028
|
+
render hundreds of frames without rebuilding shaders for every frame. Existing
|
|
2029
|
+
interactive contexts are borrowed, never destroyed. Temporary-context resources
|
|
2030
|
+
are discarded with the GLFW window and Python-side handles are reset before a
|
|
2031
|
+
later ``show()`` can reuse the figure.
|
|
2032
|
+
"""
|
|
2033
|
+
# ``glfw.get_current_context()`` itself emits GLFW_NOT_INITIALIZED when this is
|
|
2034
|
+
# the first GL operation in a headless script, so initialize before asking which
|
|
2035
|
+
# context (if any) needs to be restored afterwards.
|
|
2036
|
+
if not glfw.init():
|
|
2037
|
+
raise RuntimeError("Failed to initialize GLFW")
|
|
2038
|
+
previous_context = glfw.get_current_context()
|
|
2039
|
+
|
|
2040
|
+
if self.window is not None:
|
|
2041
|
+
glfw.make_context_current(self.window)
|
|
2042
|
+
try:
|
|
2043
|
+
yield
|
|
2044
|
+
finally:
|
|
2045
|
+
if previous_context != self.window:
|
|
2046
|
+
glfw.make_context_current(previous_context)
|
|
2047
|
+
return
|
|
2048
|
+
|
|
2049
|
+
temporary_window = None
|
|
2050
|
+
try:
|
|
2051
|
+
self._init_window(visible=False)
|
|
2052
|
+
temporary_window = self.window
|
|
2053
|
+
self._init_gl()
|
|
2054
|
+
self._init_export_modules()
|
|
2055
|
+
yield
|
|
2056
|
+
finally:
|
|
2057
|
+
if temporary_window is not None:
|
|
2058
|
+
# Destroying a context releases all of its GL objects. Replace managers
|
|
2059
|
+
# that still contain numeric names from that context, otherwise a later
|
|
2060
|
+
# interactive initialization could mistake stale IDs for live resources.
|
|
2061
|
+
glfw.make_context_current(temporary_window)
|
|
2062
|
+
glfw.destroy_window(temporary_window)
|
|
2063
|
+
if self.window == temporary_window:
|
|
2064
|
+
self.window = None
|
|
2065
|
+
self.renderer_manager = RendererManager(self)
|
|
2066
|
+
self.density_renderer = DensityRenderer(self)
|
|
2067
|
+
self._reset_layer_gl_state()
|
|
2068
|
+
|
|
2069
|
+
if previous_context != temporary_window:
|
|
2070
|
+
glfw.make_context_current(previous_context)
|
|
2071
|
+
|
|
1937
2072
|
# --------------------------------------------------------
|
|
1938
2073
|
# Frame policies
|
|
1939
2074
|
# --------------------------------------------------------
|
|
@@ -3393,6 +3528,22 @@ class GPULinePlot:
|
|
|
3393
3528
|
def _on_key(self, window: Any, key: int, sc: int, action: int, mods: int) -> None:
|
|
3394
3529
|
self.frame.dirty_ui = True
|
|
3395
3530
|
self.hud.on_key(window, key, sc, action, mods)
|
|
3531
|
+
|
|
3532
|
+
# Workspace shortcuts are normally dispatched from ``Workspace.draw``. With the
|
|
3533
|
+
# HUD disabled that method is intentionally never called, so Space used to vanish
|
|
3534
|
+
# until F3 made the GUI visible. Handle the transport key here only in that hidden
|
|
3535
|
+
# state; when the HUD is enabled the regular action registry remains the sole owner
|
|
3536
|
+
# (including its text-input safety), avoiding a double toggle.
|
|
3537
|
+
if (
|
|
3538
|
+
not self.options.enable_hud
|
|
3539
|
+
and action == glfw.PRESS
|
|
3540
|
+
and key == glfw.KEY_SPACE
|
|
3541
|
+
):
|
|
3542
|
+
self.transport_timeline().toggle()
|
|
3543
|
+
self.frame.dirty_scene = True
|
|
3544
|
+
self.cache.refresh_requested = True
|
|
3545
|
+
return
|
|
3546
|
+
|
|
3396
3547
|
if self.hud.wants_keyboard():
|
|
3397
3548
|
return
|
|
3398
3549
|
|
|
@@ -1074,7 +1074,7 @@ def queue_playback(panel: Panel) -> None:
|
|
|
1074
1074
|
if engine_drives_playback(panel):
|
|
1075
1075
|
return
|
|
1076
1076
|
|
|
1077
|
-
timeline = panel.
|
|
1077
|
+
timeline = panel.timeline
|
|
1078
1078
|
now = _time.perf_counter()
|
|
1079
1079
|
|
|
1080
1080
|
def apply() -> None:
|
|
@@ -1161,8 +1161,14 @@ class TimelinePanel(Panel):
|
|
|
1161
1161
|
|
|
1162
1162
|
@property
|
|
1163
1163
|
def timeline(self) -> Timeline:
|
|
1164
|
-
"""The
|
|
1165
|
-
|
|
1164
|
+
"""The timeline currently addressed by the figure's transport controls.
|
|
1165
|
+
|
|
1166
|
+
Ordinarily this is the active panel's timeline. A sole live ``FuncAnimation``
|
|
1167
|
+
keeps ownership after a camera click activates another subplot, so pausing,
|
|
1168
|
+
orbiting and resuming cannot accidentally toggle an empty timeline instead.
|
|
1169
|
+
"""
|
|
1170
|
+
resolve = getattr(self.plot, "transport_timeline", None)
|
|
1171
|
+
return resolve() if callable(resolve) else self.plot.active_panel.timeline
|
|
1166
1172
|
|
|
1167
1173
|
def _sync_selection(self) -> None:
|
|
1168
1174
|
"""Drop a selection whose track or keyframe has left the timeline."""
|
|
@@ -1662,9 +1662,10 @@ class Workspace:
|
|
|
1662
1662
|
return "presentation" in self.panels
|
|
1663
1663
|
|
|
1664
1664
|
def _timeline(self) -> Optional[Any]:
|
|
1665
|
-
"""The
|
|
1665
|
+
"""The timeline addressed by global transport controls, or None."""
|
|
1666
1666
|
try:
|
|
1667
|
-
|
|
1667
|
+
resolve = getattr(self.plot, "transport_timeline", None)
|
|
1668
|
+
return resolve() if callable(resolve) else self.plot.active_panel.timeline
|
|
1668
1669
|
except Exception: # pragma: no cover - defensive: a plot without panels
|
|
1669
1670
|
return None
|
|
1670
1671
|
|
|
@@ -50,6 +50,25 @@ class ExportManager:
|
|
|
50
50
|
"""
|
|
51
51
|
Renders the current scene to an offscreen buffer at high resolution.
|
|
52
52
|
"""
|
|
53
|
+
image = self.render_rgb(scale=scale, mode=mode, exact_budget=exact_budget)
|
|
54
|
+
|
|
55
|
+
import matplotlib.pyplot as plt
|
|
56
|
+
|
|
57
|
+
plt.imsave(filename, image)
|
|
58
|
+
print(f"Exported high-res image to {filename} ({image.shape[1]}x{image.shape[0]})")
|
|
59
|
+
|
|
60
|
+
def render_rgb(
|
|
61
|
+
self,
|
|
62
|
+
scale: float = 1.0,
|
|
63
|
+
mode: Optional[RenderMode] = None,
|
|
64
|
+
exact_budget: Optional[int] = None,
|
|
65
|
+
) -> np.ndarray:
|
|
66
|
+
"""Render the current scene with OpenGL and return top-down RGB pixels.
|
|
67
|
+
|
|
68
|
+
This is the in-memory counterpart of :meth:`savefig`. Animation export uses it so
|
|
69
|
+
each frame goes directly from the OpenGL framebuffer to the movie writer instead
|
|
70
|
+
of being encoded to a temporary PNG and immediately decoded again.
|
|
71
|
+
"""
|
|
53
72
|
# 1. Prepare target resolution
|
|
54
73
|
width = int(self.engine.fb_width * scale)
|
|
55
74
|
height = int(self.engine.fb_height * scale)
|
|
@@ -79,7 +98,8 @@ class ExportManager:
|
|
|
79
98
|
# 3. Save engine state and configure for export
|
|
80
99
|
old_viewport = glGetIntegerv(GL_VIEWPORT)
|
|
81
100
|
glViewport(0, 0, width, height)
|
|
82
|
-
|
|
101
|
+
background = self.engine.options.visual.background_color
|
|
102
|
+
glClearColor(background[0], background[1], background[2], 1.0)
|
|
83
103
|
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)
|
|
84
104
|
|
|
85
105
|
# 4. Render
|
|
@@ -198,11 +218,7 @@ class ExportManager:
|
|
|
198
218
|
glFinish()
|
|
199
219
|
pixels = glReadPixels(0, 0, width, height, GL_RGB, GL_UNSIGNED_BYTE)
|
|
200
220
|
image = np.frombuffer(pixels, dtype=np.uint8).reshape((height, width, 3))
|
|
201
|
-
image = np.flipud(image)
|
|
202
|
-
|
|
203
|
-
import matplotlib.pyplot as plt
|
|
204
|
-
|
|
205
|
-
plt.imsave(filename, image)
|
|
221
|
+
image = np.ascontiguousarray(np.flipud(image))
|
|
206
222
|
|
|
207
223
|
# 6. Cleanup
|
|
208
224
|
glBindFramebuffer(GL_FRAMEBUFFER, 0)
|
|
@@ -210,5 +226,4 @@ class ExportManager:
|
|
|
210
226
|
glDeleteFramebuffers(1, [fbo])
|
|
211
227
|
glDeleteTextures(1, [tex])
|
|
212
228
|
glDeleteRenderbuffers(1, [rbo])
|
|
213
|
-
|
|
214
|
-
print(f"Exported high-res image to {filename} ({width}x{height})")
|
|
229
|
+
return image
|
|
@@ -25,6 +25,22 @@ LEGEND_FONTSIZE = 24
|
|
|
25
25
|
#: area, which is what "the default points are tiny" was.
|
|
26
26
|
DEFAULT_SCATTER_SIZE_PT2 = (2.0 * 6.0) ** 2 # 6.0 == matplotlib's rcParams lines.markersize
|
|
27
27
|
|
|
28
|
+
|
|
29
|
+
def _point_diameter_px_to_area_pt2(size_px, engine: object = None):
|
|
30
|
+
"""Convert GLPlot's marker diameter in pixels to matplotlib's marker area in pt^2.
|
|
31
|
+
|
|
32
|
+
The live 3D renderer reads ``scatter3d(s=...)`` as a screen-space pixel diameter,
|
|
33
|
+
whereas matplotlib's ``Axes3D.scatter`` reads the same number as an area in points
|
|
34
|
+
squared. Passing the number through unchanged therefore makes the two views diverge
|
|
35
|
+
quadratically: a value such as 72 is a huge 72 px disc live but only an 8.5 pt marker
|
|
36
|
+
in an exported GIF. Convert the units before squaring so both paths represent the same
|
|
37
|
+
physical marker diameter at the figure's configured DPI.
|
|
38
|
+
"""
|
|
39
|
+
figure_dpi = float(getattr(engine, "_figure_dpi", 100.0) or 100.0)
|
|
40
|
+
diameter_pt = np.maximum(np.asarray(size_px, dtype=float), 0.5) * 72.0 / figure_dpi
|
|
41
|
+
area_pt2 = diameter_pt * diameter_pt
|
|
42
|
+
return float(area_pt2) if area_pt2.ndim == 0 else area_pt2
|
|
43
|
+
|
|
28
44
|
#: A colorbar's own chrome. Smaller than the panel-scale sizes above (the bar is a
|
|
29
45
|
#: narrow strip beside a full axis, not an axis itself) but nowhere near matplotlib's
|
|
30
46
|
#: unstyled ~10pt default, which is what these used to fall through to -- a bar's
|
|
@@ -207,6 +223,11 @@ def _draw_layers(ax: object, layers, has_3d: bool, engine: object = None) -> Non
|
|
|
207
223
|
continue
|
|
208
224
|
if layer.layer_type in {"scatter3d", "volume3d"} and layer.vertices is not None:
|
|
209
225
|
verts = layer.vertices
|
|
226
|
+
size_px = (
|
|
227
|
+
layer.sizes
|
|
228
|
+
if layer.sizes is not None and len(layer.sizes) == len(verts)
|
|
229
|
+
else layer.style.point_size
|
|
230
|
+
)
|
|
210
231
|
# A marker's outline is its edge, which matplotlib takes as a keyword pair
|
|
211
232
|
# rather than as a path effect. `_outline_edge_kwargs` is empty for a layer with
|
|
212
233
|
# no outline, so the call is character-for-character the historical one.
|
|
@@ -215,7 +236,7 @@ def _draw_layers(ax: object, layers, has_3d: bool, engine: object = None) -> Non
|
|
|
215
236
|
verts[:, 1],
|
|
216
237
|
verts[:, 2],
|
|
217
238
|
c=layer.colors,
|
|
218
|
-
s=
|
|
239
|
+
s=_point_diameter_px_to_area_pt2(size_px, engine),
|
|
219
240
|
depthshade=False,
|
|
220
241
|
label=_legend_label(layer),
|
|
221
242
|
**_outline_edge_kwargs(layer.style),
|