glplot 0.1.9__tar.gz → 0.1.10__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.9 → glplot-0.1.10}/.gitignore +0 -2
- {glplot-0.1.9 → glplot-0.1.10}/PKG-INFO +3 -3
- {glplot-0.1.9 → glplot-0.1.10}/README.md +2 -2
- {glplot-0.1.9 → glplot-0.1.10}/glplot/animation.py +215 -51
- {glplot-0.1.9 → glplot-0.1.10}/glplot/engine.py +33 -14
- glplot-0.1.10/glplot/gui/background.py +111 -0
- {glplot-0.1.9 → glplot-0.1.10}/glplot/gui/datasets.py +37 -9
- {glplot-0.1.9 → glplot-0.1.10}/glplot/gui/expressions.py +17 -1
- {glplot-0.1.9 → glplot-0.1.10}/glplot/gui/layerops.py +323 -38
- glplot-0.1.10/glplot/gui/mathadvise.py +397 -0
- {glplot-0.1.9 → glplot-0.1.10}/glplot/gui/mathops.py +1151 -75
- glplot-0.1.10/glplot/gui/mathops2d.py +786 -0
- glplot-0.1.10/glplot/gui/mathopsnd.py +405 -0
- glplot-0.1.10/glplot/gui/models.py +132 -0
- glplot-0.1.10/glplot/gui/notifications.py +73 -0
- {glplot-0.1.9 → glplot-0.1.10}/glplot/gui/panels/base.py +6 -0
- {glplot-0.1.9 → glplot-0.1.10}/glplot/gui/panels/data_editor.py +466 -65
- glplot-0.1.10/glplot/gui/panels/mathlab.py +6037 -0
- {glplot-0.1.9 → glplot-0.1.10}/glplot/gui/panels/pipeline.py +202 -2
- {glplot-0.1.9 → glplot-0.1.10}/glplot/gui/panels/scene.py +6 -6
- {glplot-0.1.9 → glplot-0.1.10}/glplot/gui/panels/style.py +68 -9
- {glplot-0.1.9 → glplot-0.1.10}/glplot/gui/styles.py +25 -0
- {glplot-0.1.9 → glplot-0.1.10}/glplot/gui/widgets.py +688 -34
- {glplot-0.1.9 → glplot-0.1.10}/glplot/gui/workspace.py +153 -12
- {glplot-0.1.9 → glplot-0.1.10}/glplot/managers/hud.py +8 -0
- {glplot-0.1.9 → glplot-0.1.10}/glplot/options.py +7 -0
- {glplot-0.1.9 → glplot-0.1.10}/glplot/pyplot.py +132 -36
- {glplot-0.1.9 → glplot-0.1.10}/glplot/renderers/axis.py +16 -3
- {glplot-0.1.9 → glplot-0.1.10}/glplot/renderers/colorbar.py +81 -20
- {glplot-0.1.9 → glplot-0.1.10}/glplot/renderers/contour_labels.py +3 -1
- {glplot-0.1.9 → glplot-0.1.10}/glplot/renderers/scatter.py +6 -0
- {glplot-0.1.9 → glplot-0.1.10}/glplot/utils/anim_export.py +11 -0
- {glplot-0.1.9 → glplot-0.1.10}/glplot/utils/preview.py +119 -25
- {glplot-0.1.9 → glplot-0.1.10}/glplot/utils/shaders.py +20 -2
- {glplot-0.1.9 → glplot-0.1.10}/pyproject.toml +1 -1
- {glplot-0.1.9 → glplot-0.1.10}/tests/test_animation_api.py +19 -0
- {glplot-0.1.9 → glplot-0.1.10}/tests/test_animation_integration.py +179 -0
- {glplot-0.1.9 → glplot-0.1.10}/tests/test_encodings.py +19 -0
- {glplot-0.1.9 → glplot-0.1.10}/tests/test_export.py +112 -0
- {glplot-0.1.9 → glplot-0.1.10}/tests/test_gallery_integration.py +30 -5
- glplot-0.1.10/tests/test_gui_background.py +170 -0
- glplot-0.1.10/tests/test_gui_data_editor.py +1844 -0
- {glplot-0.1.9 → glplot-0.1.10}/tests/test_gui_datasets.py +48 -0
- glplot-0.1.10/tests/test_gui_layer_kinds.py +1130 -0
- glplot-0.1.10/tests/test_gui_mathadvise.py +295 -0
- glplot-0.1.10/tests/test_gui_mathlab.py +5921 -0
- {glplot-0.1.9 → glplot-0.1.10}/tests/test_gui_mathops.py +1236 -0
- glplot-0.1.10/tests/test_gui_mathops2d.py +548 -0
- glplot-0.1.10/tests/test_gui_mathopsnd.py +297 -0
- glplot-0.1.10/tests/test_gui_models.py +87 -0
- glplot-0.1.10/tests/test_gui_notifications.py +102 -0
- glplot-0.1.10/tests/test_gui_pipeline.py +572 -0
- {glplot-0.1.9 → glplot-0.1.10}/tests/test_gui_styles.py +50 -0
- glplot-0.1.10/tests/test_gui_widgets.py +538 -0
- {glplot-0.1.9 → glplot-0.1.10}/tests/test_mpl_compat.py +105 -27
- {glplot-0.1.9 → glplot-0.1.10}/tests/test_plot_style_api.py +30 -0
- {glplot-0.1.9 → glplot-0.1.10}/tests/test_robustness.py +38 -1
- glplot-0.1.10/tests/test_scatter_default_size.py +163 -0
- glplot-0.1.9/glplot/gui/panels/mathlab.py +0 -2498
- glplot-0.1.9/tests/test_gui_data_editor.py +0 -837
- glplot-0.1.9/tests/test_gui_layer_kinds.py +0 -537
- glplot-0.1.9/tests/test_gui_mathlab.py +0 -1209
- glplot-0.1.9/tests/test_gui_pipeline.py +0 -268
- {glplot-0.1.9 → glplot-0.1.10}/CHANGELOG.md +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/CITATION.cff +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/LICENSE +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/glplot/__init__.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/glplot/__main__.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/glplot/anim/__init__.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/glplot/anim/applier.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/glplot/anim/primitives.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/glplot/anim/processes.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/glplot/backend.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/glplot/controllers.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/glplot/core/__init__.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/glplot/core/camera3d.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/glplot/core/context.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/glplot/core/layers.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/glplot/core/layout.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/glplot/core/legacy.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/glplot/core/panel.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/glplot/core/timeline.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/glplot/gui/__init__.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/glplot/gui/actions.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/glplot/gui/app.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/glplot/gui/clipboard.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/glplot/gui/commands.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/glplot/gui/dataio.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/glplot/gui/dynamics.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/glplot/gui/fuzzy.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/glplot/gui/generators3d.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/glplot/gui/history.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/glplot/gui/icons.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/glplot/gui/keys.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/glplot/gui/layerops3d.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/glplot/gui/panels/__init__.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/glplot/gui/panels/annotate.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/glplot/gui/panels/dynamics.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/glplot/gui/panels/functions.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/glplot/gui/panels/help.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/glplot/gui/panels/objects3d.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/glplot/gui/panels/palette.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/glplot/gui/panels/presentation.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/glplot/gui/panels/selection.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/glplot/gui/panels/timeline.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/glplot/gui/panels/view3d.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/glplot/gui/pipeline.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/glplot/gui/theme.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/glplot/managers/__init__.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/glplot/managers/axis.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/glplot/managers/effects.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/glplot/managers/hud_state.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/glplot/managers/picking.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/glplot/managers/renderer_manager.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/glplot/policy.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/glplot/renderers/__init__.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/glplot/renderers/axes3d.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/glplot/renderers/base.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/glplot/renderers/density.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/glplot/renderers/exact.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/glplot/renderers/fractal.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/glplot/renderers/geometry3d.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/glplot/renderers/interaction.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/glplot/renderers/legend.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/glplot/renderers/line_family.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/glplot/renderers/patch.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/glplot/renderers/polyline.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/glplot/renderers/text.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/glplot/utils/__init__.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/glplot/utils/blending.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/glplot/utils/export.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/glplot/utils/gl_utils.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/glplot/utils/mpl_bridge.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/glplot/utils/mpl_process.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/glplot/utils/scale.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/tests/conftest.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/tests/test_anim_applier.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/tests/test_anim_export.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/tests/test_anim_primitives.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/tests/test_anim_processes.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/tests/test_api_consistency.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/tests/test_axes.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/tests/test_axes3d.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/tests/test_axis_margins.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/tests/test_blending_3d.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/tests/test_camera3d.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/tests/test_camera_anisotropy.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/tests/test_camera_projections.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/tests/test_compositing_3d.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/tests/test_compositing_api.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/tests/test_contour_labels.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/tests/test_dataio.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/tests/test_density_tint.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/tests/test_edge_cases.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/tests/test_effects_post.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/tests/test_engine.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/tests/test_fractal.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/tests/test_function_layer.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/tests/test_generators3d.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/tests/test_generators3d_extra.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/tests/test_geometry3d.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/tests/test_gui_3d_panels.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/tests/test_gui_actions.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/tests/test_gui_annotate.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/tests/test_gui_clipboard.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/tests/test_gui_commands.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/tests/test_gui_dynamics.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/tests/test_gui_engine_integration.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/tests/test_gui_expressions.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/tests/test_gui_fields.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/tests/test_gui_fuzzy.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/tests/test_gui_history.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/tests/test_gui_import_safety.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/tests/test_gui_impostor_alignment.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/tests/test_gui_input_regressions.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/tests/test_gui_keyboard_capture.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/tests/test_gui_keys.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/tests/test_gui_layer_colormaps.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/tests/test_gui_layer_params.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/tests/test_gui_legend.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/tests/test_gui_regression_r2.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/tests/test_gui_scene_delete.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/tests/test_gui_scene_order.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/tests/test_gui_style_layer_editor.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/tests/test_gui_styles_extra.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/tests/test_gui_timeline_panel.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/tests/test_helpers.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/tests/test_impostor_scatter_fidelity.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/tests/test_layerops3d.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/tests/test_layers.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/tests/test_mpl_bridge.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/tests/test_mpl_compat_3d.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/tests/test_mpl_parity_surface.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/tests/test_mpl_process.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/tests/test_objects3d_preview.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/tests/test_options.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/tests/test_outline.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/tests/test_outline_2d.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/tests/test_panels.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/tests/test_performance_benchmarks.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/tests/test_polyline_lod.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/tests/test_pyplot.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/tests/test_pyplot_3d.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/tests/test_pyplot_integration.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/tests/test_regression.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/tests/test_scatter_renderer.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/tests/test_selection_model.py +0 -0
- {glplot-0.1.9 → glplot-0.1.10}/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.10
|
|
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
|
|
@@ -547,7 +547,7 @@ interactive path never reads pixels back to the CPU.
|
|
|
547
547
|
|
|
548
548
|

|
|
549
549
|
|
|
550
|
-
See [
|
|
550
|
+
See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)
|
|
551
551
|
for the full derivation of each stage, including the density-accumulation math and the
|
|
552
552
|
viewport-relative projection that keeps zoom numerically stable.
|
|
553
553
|
|
|
@@ -568,7 +568,7 @@ for GPU/environment diagnostics.
|
|
|
568
568
|
## Documentation
|
|
569
569
|
|
|
570
570
|
- API reference: docstrings in `glplot.pyplot`, or the built docs — see [docs/README.md](docs/README.md)
|
|
571
|
-
- Architecture: [
|
|
571
|
+
- Architecture: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)
|
|
572
572
|
- Dev tools: [tools/README.md](tools/README.md)
|
|
573
573
|
- Contributing: [CONTRIBUTING.md](CONTRIBUTING.md) · Code of conduct: [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md)
|
|
574
574
|
|
|
@@ -503,7 +503,7 @@ interactive path never reads pixels back to the CPU.
|
|
|
503
503
|
|
|
504
504
|

|
|
505
505
|
|
|
506
|
-
See [
|
|
506
|
+
See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)
|
|
507
507
|
for the full derivation of each stage, including the density-accumulation math and the
|
|
508
508
|
viewport-relative projection that keeps zoom numerically stable.
|
|
509
509
|
|
|
@@ -524,7 +524,7 @@ for GPU/environment diagnostics.
|
|
|
524
524
|
## Documentation
|
|
525
525
|
|
|
526
526
|
- API reference: docstrings in `glplot.pyplot`, or the built docs — see [docs/README.md](docs/README.md)
|
|
527
|
-
- Architecture: [
|
|
527
|
+
- Architecture: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)
|
|
528
528
|
- Dev tools: [tools/README.md](tools/README.md)
|
|
529
529
|
- Contributing: [CONTRIBUTING.md](CONTRIBUTING.md) · Code of conduct: [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md)
|
|
530
530
|
|
|
@@ -33,18 +33,24 @@ How a frame is produced
|
|
|
33
33
|
:func:`figure_to_rgb` is the whole bridge, and it has two modes:
|
|
34
34
|
|
|
35
35
|
* **Live GL** — when ``fig.window`` exists, ``fig.export.savefig()`` renders the scene
|
|
36
|
-
offscreen through the real pipeline at full quality
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
the
|
|
46
|
-
|
|
47
|
-
|
|
36
|
+
offscreen through the real pipeline at full quality, to a temporary PNG that is read back
|
|
37
|
+
immediately after. That round trip is a deliberate trade: reimplementing
|
|
38
|
+
``ExportManager.savefig``'s ``glReadPixels`` here to skip the file would duplicate the
|
|
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.
|
|
42
|
+
* **Headless** — when it does not,
|
|
43
|
+
:func:`glplot.utils.preview.render_preview_array` draws the same scene through
|
|
44
|
+
matplotlib's Agg backend and hands back the rendered pixels directly, with no file
|
|
45
|
+
involved. This is the path that makes ``ani.save(...)`` work in CI, in a notebook, and in
|
|
46
|
+
any script that never called ``show()`` — the common case for a saved animation, and the
|
|
47
|
+
one with hundreds of frames to pay a per-frame cost on. It used to round-trip through a
|
|
48
|
+
PNG the same way the GL path still does; profiling a real animation loop found PIL's PNG
|
|
49
|
+
encoder alone cost as much as matplotlib's entire draw, so that encode plus the disk
|
|
50
|
+
write/read it required were pure overhead for a caller (this one) that only ever wanted
|
|
51
|
+
the array. :func:`glplot.utils.preview.render_preview` (the file-writing function a plain
|
|
52
|
+
``savefig()`` call uses) is unchanged; :func:`render_preview_array` is a second entry
|
|
53
|
+
point onto the same figure-building code, not a rewrite of it — see its docstring.
|
|
48
54
|
|
|
49
55
|
**The headless path is not pixel-identical to the GL path.** ``render_preview`` is a
|
|
50
56
|
matplotlib re-drawing of the scene, not the GPU renderer; it approximates. An animation
|
|
@@ -59,13 +65,17 @@ Limitations, stated plainly
|
|
|
59
65
|
changes nothing except that it does not raise. Output is identical either way — blitting
|
|
60
66
|
is an optimisation in matplotlib, not a correctness flag — so ignoring it cannot make a
|
|
61
67
|
saved animation wrong. It is recorded in ``ani._blit`` if you want to assert on it.
|
|
62
|
-
* **
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
``
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
68
|
+
* **Live on-screen playback rides the engine's frame callback, not a real timer.**
|
|
69
|
+
``GPULinePlot.run()`` owns its main loop and has no toolkit timer for a
|
|
70
|
+
``TimedAnimation`` to hook the way matplotlib's does — but it does expose
|
|
71
|
+
``add_frame_callback`` (:meth:`glplot.engine.GPULinePlot.add_frame_callback`), a per-frame
|
|
72
|
+
hook driven by wall-clock time, and :class:`TimedAnimation` uses exactly that to step
|
|
73
|
+
itself at ``interval``. A ``FuncAnimation``/``ArtistAnimation`` built against a live
|
|
74
|
+
GLPlot figure therefore *does* play when you call ``plt.show()`` — no ``draw_event``, no
|
|
75
|
+
blitting, just the same full-scene re-render :meth:`Animation.save` uses, paced in real
|
|
76
|
+
time instead of frame-by-frame. :meth:`Animation.pause` / :meth:`Animation.resume` stop and
|
|
77
|
+
restart that stepping. A real matplotlib ``Figure`` is untouched by any of this: it keeps
|
|
78
|
+
using its own canvas and timer, exactly as before.
|
|
69
79
|
* **In-place array mutation is assumed.** A user callback that writes into
|
|
70
80
|
``layer.pts[:, 1]`` leaves no trace numpy can report, so after every callback this module
|
|
71
81
|
marks every layer dirty rather than trying to detect what changed. Cheap, and the only
|
|
@@ -275,46 +285,59 @@ def figure_to_rgb(fig: Any, dpi: Optional[float] = None) -> np.ndarray:
|
|
|
275
285
|
|
|
276
286
|
The single bridge between "a figure" and "a frame", and the only function in this
|
|
277
287
|
module that knows a GLPlot engine from a matplotlib one. See the module docstring for
|
|
278
|
-
the two GLPlot render modes and
|
|
288
|
+
the two GLPlot render modes, and :func:`_render_glplot_frame` for which of them still
|
|
289
|
+
goes through a temporary PNG and which does not.
|
|
279
290
|
|
|
280
291
|
Raises :class:`RuntimeError` naming the figure type if neither renderer applies —
|
|
281
292
|
passing something that is not a figure at all is otherwise diagnosed several frames
|
|
282
293
|
later as a confusing Pillow error.
|
|
283
294
|
"""
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
if _is_mpl_figure(fig):
|
|
295
|
+
if _is_mpl_figure(fig):
|
|
296
|
+
with tempfile.TemporaryDirectory(prefix="glplot-anim-") as tmpdir:
|
|
297
|
+
target = os.path.join(tmpdir, "frame.png")
|
|
288
298
|
fig.savefig(target, dpi=dpi, format="png")
|
|
289
|
-
|
|
290
|
-
_render_glplot_figure(fig, target, _dpi_to_scale(dpi))
|
|
291
|
-
else:
|
|
292
|
-
raise RuntimeError(
|
|
293
|
-
f"Cannot grab a frame from {type(fig).__name__}: expected a "
|
|
294
|
-
f"glplot.engine.GPULinePlot (what glplot.pyplot.figure() returns) or a "
|
|
295
|
-
f"matplotlib Figure. Pass the figure object itself, not an axes or a "
|
|
296
|
-
f"layer."
|
|
297
|
-
)
|
|
299
|
+
return anim_export.normalize_frame(_read_png(target))
|
|
298
300
|
|
|
299
|
-
|
|
301
|
+
if hasattr(fig, "scene"):
|
|
302
|
+
return anim_export.normalize_frame(_render_glplot_frame(fig, _dpi_to_scale(dpi)))
|
|
300
303
|
|
|
304
|
+
raise RuntimeError(
|
|
305
|
+
f"Cannot grab a frame from {type(fig).__name__}: expected a "
|
|
306
|
+
f"glplot.engine.GPULinePlot (what glplot.pyplot.figure() returns) or a "
|
|
307
|
+
f"matplotlib Figure. Pass the figure object itself, not an axes or a "
|
|
308
|
+
f"layer."
|
|
309
|
+
)
|
|
301
310
|
|
|
302
|
-
|
|
303
|
-
|
|
311
|
+
|
|
312
|
+
def _render_glplot_frame(fig: Any, scale: float) -> np.ndarray:
|
|
313
|
+
"""Render one frame of a GLPlot engine, through GL if there is a context and Agg if not.
|
|
314
|
+
|
|
315
|
+
**Live GL** still round-trips through a temporary PNG — see the module docstring's
|
|
316
|
+
"Rejected: reimplementing ExportManager.savefig's glReadPixels here", unchanged reasoning
|
|
317
|
+
so this stays unchanged.
|
|
318
|
+
|
|
319
|
+
**Headless** does not: :func:`glplot.utils.preview.render_preview_array` hands back the
|
|
320
|
+
rendered pixels directly from matplotlib's Agg canvas, skipping the PNG encode and the
|
|
321
|
+
disk write/read a file-based caller needs. Profiled: PIL's PNG encoder alone cost as much
|
|
322
|
+
as matplotlib's entire draw, so for an animation's hundreds of headless frames — the
|
|
323
|
+
common case, since a saved animation is usually built without ever calling ``fig.run()``
|
|
324
|
+
— this removes roughly a third of each frame's cost with no change in what gets drawn.
|
|
304
325
|
|
|
305
326
|
``ExportManager.savefig`` prints a "Exported high-res image to ..." line on every call.
|
|
306
327
|
That is fine for one interactive export and intolerable at 300 frames, so stdout is
|
|
307
|
-
swallowed for the duration — this is the only place that print reaches,
|
|
308
|
-
it here beats every alternative that would require editing the engine.
|
|
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.
|
|
309
330
|
"""
|
|
310
331
|
if getattr(fig, "window", None) is not None and hasattr(fig, "export"):
|
|
311
|
-
with
|
|
312
|
-
|
|
313
|
-
|
|
332
|
+
with tempfile.TemporaryDirectory(prefix="glplot-anim-") as tmpdir:
|
|
333
|
+
target = os.path.join(tmpdir, "frame.png")
|
|
334
|
+
with contextlib.redirect_stdout(io.StringIO()):
|
|
335
|
+
fig.export.savefig(target, scale=scale)
|
|
336
|
+
return _read_png(target)
|
|
314
337
|
|
|
315
|
-
from .utils.preview import
|
|
338
|
+
from .utils.preview import render_preview_array
|
|
316
339
|
|
|
317
|
-
|
|
340
|
+
return render_preview_array(fig, scale)
|
|
318
341
|
|
|
319
342
|
|
|
320
343
|
def _read_png(path: str) -> np.ndarray:
|
|
@@ -419,9 +442,9 @@ class _NullTimer:
|
|
|
419
442
|
|
|
420
443
|
:class:`TimedAnimation` needs *an* event source; it does not need one that works in
|
|
421
444
|
order for :meth:`Animation.save` to render every frame, because saving drives the frame
|
|
422
|
-
sequence directly and never consults the timer. Live playback
|
|
423
|
-
|
|
424
|
-
|
|
445
|
+
sequence directly and never consults the timer. Live playback does not go through this
|
|
446
|
+
timer either -- :meth:`TimedAnimation._start_live_playback` drives it straight off
|
|
447
|
+
``fig.add_frame_callback`` instead, since that is the hook the engine actually has.
|
|
425
448
|
"""
|
|
426
449
|
|
|
427
450
|
def __init__(self, interval: Optional[float] = None, callbacks: Any = None) -> None:
|
|
@@ -1195,9 +1218,9 @@ class Animation:
|
|
|
1195
1218
|
"""Base class for animations, with matplotlib's constructor and public methods.
|
|
1196
1219
|
|
|
1197
1220
|
Subclasses supply the frame data (:meth:`new_frame_seq`) and what to do with one
|
|
1198
|
-
(:meth:`_draw_frame`); this class supplies :meth:`save` and the HTML exports
|
|
1199
|
-
|
|
1200
|
-
|
|
1221
|
+
(:meth:`_draw_frame`); this class supplies :meth:`save` and the HTML exports. Live
|
|
1222
|
+
playback (:class:`TimedAnimation`'s ``fig.add_frame_callback`` hook, module docstring)
|
|
1223
|
+
is the other consumer of that same frame-stepping machinery.
|
|
1201
1224
|
"""
|
|
1202
1225
|
|
|
1203
1226
|
def __init__(self, fig: Any, event_source: Any = None, blit: bool = False) -> None:
|
|
@@ -1557,10 +1580,30 @@ class TimedAnimation(Animation):
|
|
|
1557
1580
|
|
|
1558
1581
|
``interval`` (milliseconds per frame) is what :meth:`Animation.save` turns into ``fps``
|
|
1559
1582
|
when the caller does not pass one, which is why ``FuncAnimation(..., interval=20)``
|
|
1560
|
-
followed by a bare ``save()`` produces a 50 fps file.
|
|
1561
|
-
|
|
1583
|
+
followed by a bare ``save()`` produces a 50 fps file.
|
|
1584
|
+
|
|
1585
|
+
It is also what makes live on-screen playback possible, and *controllable*: when ``fig``
|
|
1586
|
+
is a GLPlot figure (no real matplotlib canvas), :meth:`_start_live_playback` hands this
|
|
1587
|
+
animation's frames to ``fig.active_panel.timeline`` -- the same playhead the GUI's
|
|
1588
|
+
Timeline panel already has Play/Pause/Stop buttons and a scrub bar for -- instead of
|
|
1589
|
+
running its own independent clock. ``plt.show()`` after building a ``FuncAnimation``
|
|
1590
|
+
plays it, and if the Timeline panel is open, its transport controls drive this animation
|
|
1591
|
+
exactly as they drive a hand-keyed one. See :meth:`_start_live_playback`.
|
|
1562
1592
|
"""
|
|
1563
1593
|
|
|
1594
|
+
#: A frame sequence at most this long is materialised into a concrete list so the
|
|
1595
|
+
#: Timeline's scrub bar can jump to any frame directly. Past this (or for an unbounded
|
|
1596
|
+
#: ``frames=None`` generator), only forward playback is exact -- see
|
|
1597
|
+
#: :meth:`_materialize_frames`.
|
|
1598
|
+
_MAX_SEEKABLE_FRAMES = 100_000
|
|
1599
|
+
|
|
1600
|
+
#: Duration assigned to the panel's timeline when the frame count is unknowable
|
|
1601
|
+
#: (``frames=None``) or too large to materialise. Arbitrary but not consequential: it
|
|
1602
|
+
#: only sets how far the scrub bar's grid extends before it loops, not how long the
|
|
1603
|
+
#: animation itself runs, since :meth:`_start_live_playback`'s callback keeps stepping
|
|
1604
|
+
#: forward regardless of where in that grid the playhead sits.
|
|
1605
|
+
_UNKNOWN_DURATION_SECONDS = 60.0
|
|
1606
|
+
|
|
1564
1607
|
def __init__(
|
|
1565
1608
|
self,
|
|
1566
1609
|
fig: Any,
|
|
@@ -1576,16 +1619,137 @@ class TimedAnimation(Animation):
|
|
|
1576
1619
|
self._repeat_delay = repeat_delay if repeat_delay is not None else 0
|
|
1577
1620
|
self._repeat = repeat
|
|
1578
1621
|
self._paused = False
|
|
1622
|
+
self._live_timeline: Any = None
|
|
1579
1623
|
if event_source is None:
|
|
1580
1624
|
canvas = _canvas_for(fig)
|
|
1581
1625
|
event_source = canvas.new_timer(interval=self._interval)
|
|
1582
1626
|
super().__init__(fig, event_source=event_source, *args, **kwargs)
|
|
1627
|
+
self._start_live_playback(fig)
|
|
1583
1628
|
|
|
1584
1629
|
@property
|
|
1585
1630
|
def repeat(self) -> bool:
|
|
1586
1631
|
"""Whether the animation loops. Read-only, as in matplotlib."""
|
|
1587
1632
|
return self._repeat
|
|
1588
1633
|
|
|
1634
|
+
def pause(self) -> None:
|
|
1635
|
+
"""Pause the animation.
|
|
1636
|
+
|
|
1637
|
+
Proxies to ``fig.active_panel.timeline.pause()`` when live playback is bound (see
|
|
1638
|
+
:meth:`_start_live_playback`), so this has the same effect as pressing Pause in the
|
|
1639
|
+
Timeline panel. Otherwise a documented no-op, as it always was; see the module
|
|
1640
|
+
docstring.
|
|
1641
|
+
"""
|
|
1642
|
+
self._paused = True
|
|
1643
|
+
if self._live_timeline is not None:
|
|
1644
|
+
self._live_timeline.pause()
|
|
1645
|
+
if self.event_source is not None:
|
|
1646
|
+
with contextlib.suppress(Exception):
|
|
1647
|
+
self.event_source.stop()
|
|
1648
|
+
|
|
1649
|
+
def resume(self) -> None:
|
|
1650
|
+
"""Resume the animation. See :meth:`pause`; proxies to ``timeline.play()`` likewise."""
|
|
1651
|
+
self._paused = False
|
|
1652
|
+
if self._live_timeline is not None:
|
|
1653
|
+
self._live_timeline.play()
|
|
1654
|
+
if self.event_source is not None:
|
|
1655
|
+
with contextlib.suppress(Exception):
|
|
1656
|
+
self.event_source.start()
|
|
1657
|
+
|
|
1658
|
+
def _materialize_frames(self) -> Optional[List[Any]]:
|
|
1659
|
+
"""This animation's frame data as a concrete, randomly-indexable list, or ``None``.
|
|
1660
|
+
|
|
1661
|
+
``None`` means the Timeline can only step forward through the sequence (an
|
|
1662
|
+
unbounded ``frames=None`` generator, or a declared length past
|
|
1663
|
+
:data:`_MAX_SEEKABLE_FRAMES`) -- scrubbing backward then restarts it from frame 0
|
|
1664
|
+
rather than landing on the exact frame, since an arbitrary generator cannot be
|
|
1665
|
+
rewound. Every other case -- :class:`ArtistAnimation` (already a concrete list),
|
|
1666
|
+
an integer or sized ``frames=``, an explicit ``save_count`` -- seeks exactly,
|
|
1667
|
+
because the whole sequence is known and cheap to hold: it is usually the caller's
|
|
1668
|
+
own list or array already sitting in memory, and ``new_frame_seq()`` just re-reads
|
|
1669
|
+
it rather than duplicating anything expensive.
|
|
1670
|
+
"""
|
|
1671
|
+
existing = getattr(self, "_framedata", None)
|
|
1672
|
+
if isinstance(existing, (list, tuple)):
|
|
1673
|
+
return list(existing)
|
|
1674
|
+
save_count = getattr(self, "_save_count", None)
|
|
1675
|
+
if save_count is not None and 0 < save_count <= self._MAX_SEEKABLE_FRAMES:
|
|
1676
|
+
return list(itertools.islice(self.new_frame_seq(), save_count))
|
|
1677
|
+
return None
|
|
1678
|
+
|
|
1679
|
+
def _start_live_playback(self, fig: Any) -> None:
|
|
1680
|
+
"""Bind this animation's frames to ``fig.active_panel.timeline``'s playhead.
|
|
1681
|
+
|
|
1682
|
+
A real matplotlib ``Figure`` already has a toolkit timer driving it -- ``self._canvas``
|
|
1683
|
+
is the real canvas in that case (:func:`_canvas_for`), so this does nothing and that
|
|
1684
|
+
timer keeps doing its job unmodified. Only a GLPlot figure (``_NullCanvas``) with a
|
|
1685
|
+
panel to bind to gets this treatment.
|
|
1686
|
+
|
|
1687
|
+
The bound timeline is configured to this animation's own cadence (``fps = 1000 /
|
|
1688
|
+
interval``, ``duration`` from the frame count or :data:`_UNKNOWN_DURATION_SECONDS`
|
|
1689
|
+
when that is not knowable, ``loop`` from ``repeat``) and started playing -- an
|
|
1690
|
+
animation you just built plays immediately, same as before. From there the engine's
|
|
1691
|
+
own per-frame hook (``fig.add_frame_callback``, invoked *after*
|
|
1692
|
+
``GPULinePlot._advance_timelines`` already moved the playhead this frame -- see
|
|
1693
|
+
``engine.py``) does one thing every frame: compute which frame the playhead's
|
|
1694
|
+
*current* time corresponds to, and draw it if that differs from what is already on
|
|
1695
|
+
screen. It does not advance the clock itself. That is deliberate: Play/Pause only
|
|
1696
|
+
has to flip ``timeline.playing`` (which gates whether the playhead moves at all) and
|
|
1697
|
+
Stop/seek/step only have to move ``timeline.time`` directly (which the GUI's
|
|
1698
|
+
transport buttons already do) for this animation to follow suit exactly the way a
|
|
1699
|
+
hand-keyed one does -- there is no separate "is it playing" state here to keep in
|
|
1700
|
+
sync with the Timeline panel's.
|
|
1701
|
+
"""
|
|
1702
|
+
register = getattr(fig, "add_frame_callback", None)
|
|
1703
|
+
panel = getattr(fig, "active_panel", None)
|
|
1704
|
+
timeline = getattr(panel, "timeline", None)
|
|
1705
|
+
if not callable(register) or not isinstance(self._canvas, _NullCanvas) or timeline is None:
|
|
1706
|
+
return
|
|
1707
|
+
|
|
1708
|
+
frames = self._materialize_frames()
|
|
1709
|
+
interval_s = max(float(self._interval), 1.0) / MS_PER_SECOND
|
|
1710
|
+
duration = (
|
|
1711
|
+
max(len(frames) - 1, 0) * interval_s if frames else self._UNKNOWN_DURATION_SECONDS
|
|
1712
|
+
)
|
|
1713
|
+
timeline.set_fps(1.0 / interval_s)
|
|
1714
|
+
if timeline.is_empty():
|
|
1715
|
+
# No hand-authored keyframes here yet -- the overwhelmingly common case for a
|
|
1716
|
+
# freshly-built animation -- so this animation may as well own the grid exactly:
|
|
1717
|
+
# duration matches its real length, and the scrub bar has nothing past the last
|
|
1718
|
+
# frame to run into. A timeline that already has keyframes on it keeps its own
|
|
1719
|
+
# length (never shrinks) rather than have this animation truncate them.
|
|
1720
|
+
timeline.set_duration(duration)
|
|
1721
|
+
else:
|
|
1722
|
+
timeline.fit_duration(minimum=duration)
|
|
1723
|
+
timeline.set_loop("loop" if self._repeat else "once")
|
|
1724
|
+
timeline.play()
|
|
1725
|
+
self._live_timeline = timeline
|
|
1726
|
+
|
|
1727
|
+
live_seq = {"iter": self.frame_seq}
|
|
1728
|
+
state = {"last_index": None}
|
|
1729
|
+
|
|
1730
|
+
def _tick(now: float) -> None:
|
|
1731
|
+
index = timeline.frame_index()
|
|
1732
|
+
if index == state["last_index"]:
|
|
1733
|
+
return
|
|
1734
|
+
if frames is not None:
|
|
1735
|
+
framedata = frames[min(index, len(frames) - 1)]
|
|
1736
|
+
else:
|
|
1737
|
+
if state["last_index"] is not None and index < state["last_index"]:
|
|
1738
|
+
live_seq["iter"] = self.new_frame_seq() # scrubbed back / looped: restart
|
|
1739
|
+
try:
|
|
1740
|
+
framedata = next(live_seq["iter"])
|
|
1741
|
+
except StopIteration:
|
|
1742
|
+
if not self._repeat:
|
|
1743
|
+
fig.remove_frame_callback(_tick)
|
|
1744
|
+
return
|
|
1745
|
+
live_seq["iter"] = self.new_frame_seq()
|
|
1746
|
+
framedata = next(live_seq["iter"])
|
|
1747
|
+
state["last_index"] = index
|
|
1748
|
+
self._draw_was_started = True
|
|
1749
|
+
self._draw_next_frame(framedata, blit=False)
|
|
1750
|
+
|
|
1751
|
+
register(_tick)
|
|
1752
|
+
|
|
1589
1753
|
|
|
1590
1754
|
class FuncAnimation(TimedAnimation):
|
|
1591
1755
|
"""Animation built by repeatedly calling *func*. The class almost everyone uses.
|
|
@@ -52,11 +52,11 @@ from .options import (
|
|
|
52
52
|
)
|
|
53
53
|
from .policy import RenderPolicyManager
|
|
54
54
|
from .renderers import axes3d
|
|
55
|
+
from .renderers.colorbar import draw_colorbars
|
|
56
|
+
from .renderers.contour_labels import draw_contour_labels
|
|
55
57
|
from .renderers.density import DensityRenderer
|
|
56
58
|
from .renderers.exact import ExactLineRenderer
|
|
57
59
|
from .renderers.interaction import InteractionRenderer
|
|
58
|
-
from .renderers.colorbar import draw_colorbars
|
|
59
|
-
from .renderers.contour_labels import draw_contour_labels
|
|
60
60
|
from .renderers.legend import draw_legend
|
|
61
61
|
from .utils.export import ExportManager
|
|
62
62
|
from .utils.scale import forward as _scale_forward
|
|
@@ -779,8 +779,9 @@ class GPULinePlot:
|
|
|
779
779
|
The hook the engine was missing. ``run()`` owns its loop and exposed no way in, so
|
|
780
780
|
anything that wanted to drive the scene over time — a ``FuncAnimation``, a
|
|
781
781
|
simulation stepping itself, a recorder — could render frames offline but could not
|
|
782
|
-
make an open window move.
|
|
783
|
-
|
|
782
|
+
make an open window move. ``glplot.animation.TimedAnimation`` now uses exactly this
|
|
783
|
+
hook (``_start_live_playback``) so a ``FuncAnimation`` plays live, not just on
|
|
784
|
+
``save()``.
|
|
784
785
|
|
|
785
786
|
``fn`` receives the loop's wall-clock time in seconds. It runs on the GL thread at
|
|
786
787
|
the top of the frame, in the same slot the command queue drains, so it may mutate
|
|
@@ -2251,11 +2252,6 @@ class GPULinePlot:
|
|
|
2251
2252
|
t_start = time.perf_counter()
|
|
2252
2253
|
self._apply_blending_policy()
|
|
2253
2254
|
|
|
2254
|
-
# Disable world clipping for screen-space impostor
|
|
2255
|
-
if self.options.enable_clipping_optimization:
|
|
2256
|
-
for i in range(4):
|
|
2257
|
-
glDisable(GL_CLIP_DISTANCE0 + i)
|
|
2258
|
-
|
|
2259
2255
|
current_window = self.camera_controller.world_window(self.width, self.height)
|
|
2260
2256
|
if (
|
|
2261
2257
|
self.options.enable_cache_interaction_path
|
|
@@ -2272,18 +2268,28 @@ class GPULinePlot:
|
|
|
2272
2268
|
# occupies a sub-rect, so fall back to an exact per-panel redraw during drags.
|
|
2273
2269
|
and len(self.panels) == 1
|
|
2274
2270
|
):
|
|
2271
|
+
# World clipping is only meaningless for the impostor itself -- a screen-space
|
|
2272
|
+
# textured quad, not world geometry -- so the disable/re-enable is scoped to
|
|
2273
|
+
# just this draw. It used to bracket the whole if/else, which meant the *else*
|
|
2274
|
+
# branch's real geometry redraw (below) ran with every shader's world-window
|
|
2275
|
+
# clip planes silently off: nothing then stopped a marker, line or patch whose
|
|
2276
|
+
# centre had drifted outside the current view from drawing straight through the
|
|
2277
|
+
# axis margins and out to the window edge, which is what a script bouncing
|
|
2278
|
+
# in and out of INTERACTIVE mode (any HUD interaction, or once the cache
|
|
2279
|
+
# activates after a view change) saw as the plot "spilling out of its frame".
|
|
2280
|
+
if self.options.enable_clipping_optimization:
|
|
2281
|
+
for i in range(4):
|
|
2282
|
+
glDisable(GL_CLIP_DISTANCE0 + i)
|
|
2275
2283
|
current_fbo = glGetIntegerv(GL_FRAMEBUFFER_BINDING)
|
|
2276
2284
|
self.interaction_renderer.draw_cached_impostor(
|
|
2277
2285
|
self.cache.capture_window, current_window, target_fbo=current_fbo
|
|
2278
2286
|
)
|
|
2287
|
+
if self.options.enable_clipping_optimization:
|
|
2288
|
+
for i in range(4):
|
|
2289
|
+
glEnable(GL_CLIP_DISTANCE0 + i)
|
|
2279
2290
|
else:
|
|
2280
2291
|
self._draw_exact_view()
|
|
2281
2292
|
|
|
2282
|
-
# Re-enable if needed for next passes (exact view usually enables it anyway)
|
|
2283
|
-
if self.options.enable_clipping_optimization:
|
|
2284
|
-
for i in range(4):
|
|
2285
|
-
glEnable(GL_CLIP_DISTANCE0 + i)
|
|
2286
|
-
|
|
2287
2293
|
self.hud.state.gpu_timings["Interaction"] = time.perf_counter() - t_start
|
|
2288
2294
|
|
|
2289
2295
|
def _capture_interaction_cache(self) -> None:
|
|
@@ -2484,6 +2490,19 @@ class GPULinePlot:
|
|
|
2484
2490
|
self.effects.draw_background()
|
|
2485
2491
|
self._apply_blending_policy()
|
|
2486
2492
|
|
|
2493
|
+
# World-space clip planes are switched off for the screen-space chrome
|
|
2494
|
+
# drawn after the scene (zoom box, HUD, axis labels/legend -- see the
|
|
2495
|
+
# "Disable world clipping" spots below) and never switched back on before
|
|
2496
|
+
# this point, so without this every scene redraw following one of those
|
|
2497
|
+
# would inherit CLIP_DISTANCE left disabled from the *previous* frame.
|
|
2498
|
+
# Every primitive shader now writes gl_ClipDistance against the visible
|
|
2499
|
+
# world window (see SCATTER_VS, PATCH_VS, WIDE_SEGMENT_INSTANCED_VS,
|
|
2500
|
+
# WIDE_LINES_INSTANCED_VS), so that state must be back on before any of
|
|
2501
|
+
# them draws or their geometry is free to spill past the axis margins.
|
|
2502
|
+
if self.options.enable_clipping_optimization:
|
|
2503
|
+
for i in range(4):
|
|
2504
|
+
glEnable(GL_CLIP_DISTANCE0 + i)
|
|
2505
|
+
|
|
2487
2506
|
if self.policy.runtime.current_mode == RenderMode.INTERACTIVE:
|
|
2488
2507
|
self._draw_panels(self._draw_interaction_view)
|
|
2489
2508
|
else:
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Background execution for potentially-slow GUI computations.
|
|
2
|
+
|
|
3
|
+
Why threads, not the subprocess pattern utils/mpl_process.py uses: that module spawns
|
|
4
|
+
a fresh interpreter because a live GL context is unsafe to inherit via ``fork()`` on
|
|
5
|
+
macOS -- Cocoa's autorelease pool and the MacOSX matplotlib backend both abort outside
|
|
6
|
+
the main thread. None of that applies here: the work this module backgrounds (calls
|
|
7
|
+
into ``mathops``/``mathops2d``/``mathopsnd`` -- numpy/scipy on plain arrays) has no
|
|
8
|
+
Cocoa, GL, or imgui entanglement, and spends most of its time inside C extensions that
|
|
9
|
+
release the GIL. A plain ``threading.Thread`` gives real responsiveness with none of a
|
|
10
|
+
subprocess's pickling/IPC overhead, and needs no change to the functions it runs.
|
|
11
|
+
|
|
12
|
+
Cancellation is cooperative, not a hard kill. CPython cannot forcibly terminate a
|
|
13
|
+
thread -- there is no SIGKILL-equivalent for one thread inside a live process (killing
|
|
14
|
+
the *computation* would mean killing the whole process, GL context and all).
|
|
15
|
+
:meth:`BackgroundJob.cancel` therefore only marks the job abandoned: :meth:`poll`
|
|
16
|
+
reports ``"cancelled"`` immediately and permanently from that call on, even though the
|
|
17
|
+
underlying call may keep running to completion, unobserved, until it returns. This is
|
|
18
|
+
the same model production tools use to "cancel" a library call with no internal
|
|
19
|
+
cancellation hook (a database client's "cancel query", a browser's "stop" on an
|
|
20
|
+
in-flight synchronous request) -- from the caller's perspective the job *is*
|
|
21
|
+
cancelled: nothing about it is waited on, shown, or acted on ever again.
|
|
22
|
+
|
|
23
|
+
A caller that needs GL-thread safety gets it for free: nothing in this module touches
|
|
24
|
+
``plot.scene``/``frame``/``cache`` or imgui, so CONTRACT 1.1 is simply not in play --
|
|
25
|
+
:meth:`poll` only ever hands back plain Python/numpy values for the CALLER to apply on
|
|
26
|
+
its own thread, exactly like any other panel-local state read from ``draw()``.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import threading
|
|
32
|
+
import time
|
|
33
|
+
from dataclasses import dataclass
|
|
34
|
+
from typing import Any, Callable, Optional
|
|
35
|
+
|
|
36
|
+
__all__ = ["BackgroundJob", "JobStatus"]
|
|
37
|
+
|
|
38
|
+
#: The states a JobStatus can report. "cancelled" is sticky: once cancel() is called,
|
|
39
|
+
#: every subsequent poll() reports it, regardless of what the thread does afterward.
|
|
40
|
+
JOB_STATES = ("running", "done", "error", "cancelled")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class JobStatus:
|
|
45
|
+
"""A snapshot of a :class:`BackgroundJob` at the moment :meth:`BackgroundJob.poll`
|
|
46
|
+
was called.
|
|
47
|
+
|
|
48
|
+
``result`` is only meaningful when ``state == "done"``; ``error`` only when
|
|
49
|
+
``state == "error"``. ``elapsed`` is wall-clock seconds since the job started.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
state: str
|
|
53
|
+
result: Any = None
|
|
54
|
+
error: Optional[str] = None
|
|
55
|
+
elapsed: float = 0.0
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class BackgroundJob:
|
|
59
|
+
"""Runs ``fn`` on a daemon thread; :meth:`poll` for its result without blocking.
|
|
60
|
+
|
|
61
|
+
``fn`` takes no arguments -- wrap whatever inputs it needs in a closure (e.g.
|
|
62
|
+
``BackgroundJob(lambda: mathops.smooth(y, method="savgol"))``). Starts immediately
|
|
63
|
+
on construction; there is no separate "start" step.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(self, fn: Callable[[], Any]) -> None:
|
|
67
|
+
self._fn = fn
|
|
68
|
+
self._lock = threading.Lock()
|
|
69
|
+
self._done = False
|
|
70
|
+
self._cancelled = False
|
|
71
|
+
self._result: Any = None
|
|
72
|
+
self._error: Optional[str] = None
|
|
73
|
+
self._started = time.monotonic()
|
|
74
|
+
self._thread = threading.Thread(target=self._run, daemon=True)
|
|
75
|
+
self._thread.start()
|
|
76
|
+
|
|
77
|
+
def _run(self) -> None:
|
|
78
|
+
try:
|
|
79
|
+
result = self._fn()
|
|
80
|
+
except Exception as exc: # noqa: BLE001 - marshaled to the poller, never raised here
|
|
81
|
+
with self._lock:
|
|
82
|
+
if not self._cancelled:
|
|
83
|
+
self._error = str(exc)
|
|
84
|
+
self._done = True
|
|
85
|
+
return
|
|
86
|
+
with self._lock:
|
|
87
|
+
if not self._cancelled:
|
|
88
|
+
self._result = result
|
|
89
|
+
self._done = True
|
|
90
|
+
|
|
91
|
+
def cancel(self) -> None:
|
|
92
|
+
"""Abandon this job. See the module docstring: cooperative, not a hard kill."""
|
|
93
|
+
with self._lock:
|
|
94
|
+
self._cancelled = True
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def cancelled(self) -> bool:
|
|
98
|
+
with self._lock:
|
|
99
|
+
return self._cancelled
|
|
100
|
+
|
|
101
|
+
def poll(self) -> JobStatus:
|
|
102
|
+
"""Non-blocking status check. Cheap enough to call once every frame."""
|
|
103
|
+
elapsed = time.monotonic() - self._started
|
|
104
|
+
with self._lock:
|
|
105
|
+
if self._cancelled:
|
|
106
|
+
return JobStatus("cancelled", elapsed=elapsed)
|
|
107
|
+
if not self._done:
|
|
108
|
+
return JobStatus("running", elapsed=elapsed)
|
|
109
|
+
if self._error is not None:
|
|
110
|
+
return JobStatus("error", error=self._error, elapsed=elapsed)
|
|
111
|
+
return JobStatus("done", result=self._result, elapsed=elapsed)
|