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.
Files changed (208) hide show
  1. {glplot-0.1.9 → glplot-0.1.10}/.gitignore +0 -2
  2. {glplot-0.1.9 → glplot-0.1.10}/PKG-INFO +3 -3
  3. {glplot-0.1.9 → glplot-0.1.10}/README.md +2 -2
  4. {glplot-0.1.9 → glplot-0.1.10}/glplot/animation.py +215 -51
  5. {glplot-0.1.9 → glplot-0.1.10}/glplot/engine.py +33 -14
  6. glplot-0.1.10/glplot/gui/background.py +111 -0
  7. {glplot-0.1.9 → glplot-0.1.10}/glplot/gui/datasets.py +37 -9
  8. {glplot-0.1.9 → glplot-0.1.10}/glplot/gui/expressions.py +17 -1
  9. {glplot-0.1.9 → glplot-0.1.10}/glplot/gui/layerops.py +323 -38
  10. glplot-0.1.10/glplot/gui/mathadvise.py +397 -0
  11. {glplot-0.1.9 → glplot-0.1.10}/glplot/gui/mathops.py +1151 -75
  12. glplot-0.1.10/glplot/gui/mathops2d.py +786 -0
  13. glplot-0.1.10/glplot/gui/mathopsnd.py +405 -0
  14. glplot-0.1.10/glplot/gui/models.py +132 -0
  15. glplot-0.1.10/glplot/gui/notifications.py +73 -0
  16. {glplot-0.1.9 → glplot-0.1.10}/glplot/gui/panels/base.py +6 -0
  17. {glplot-0.1.9 → glplot-0.1.10}/glplot/gui/panels/data_editor.py +466 -65
  18. glplot-0.1.10/glplot/gui/panels/mathlab.py +6037 -0
  19. {glplot-0.1.9 → glplot-0.1.10}/glplot/gui/panels/pipeline.py +202 -2
  20. {glplot-0.1.9 → glplot-0.1.10}/glplot/gui/panels/scene.py +6 -6
  21. {glplot-0.1.9 → glplot-0.1.10}/glplot/gui/panels/style.py +68 -9
  22. {glplot-0.1.9 → glplot-0.1.10}/glplot/gui/styles.py +25 -0
  23. {glplot-0.1.9 → glplot-0.1.10}/glplot/gui/widgets.py +688 -34
  24. {glplot-0.1.9 → glplot-0.1.10}/glplot/gui/workspace.py +153 -12
  25. {glplot-0.1.9 → glplot-0.1.10}/glplot/managers/hud.py +8 -0
  26. {glplot-0.1.9 → glplot-0.1.10}/glplot/options.py +7 -0
  27. {glplot-0.1.9 → glplot-0.1.10}/glplot/pyplot.py +132 -36
  28. {glplot-0.1.9 → glplot-0.1.10}/glplot/renderers/axis.py +16 -3
  29. {glplot-0.1.9 → glplot-0.1.10}/glplot/renderers/colorbar.py +81 -20
  30. {glplot-0.1.9 → glplot-0.1.10}/glplot/renderers/contour_labels.py +3 -1
  31. {glplot-0.1.9 → glplot-0.1.10}/glplot/renderers/scatter.py +6 -0
  32. {glplot-0.1.9 → glplot-0.1.10}/glplot/utils/anim_export.py +11 -0
  33. {glplot-0.1.9 → glplot-0.1.10}/glplot/utils/preview.py +119 -25
  34. {glplot-0.1.9 → glplot-0.1.10}/glplot/utils/shaders.py +20 -2
  35. {glplot-0.1.9 → glplot-0.1.10}/pyproject.toml +1 -1
  36. {glplot-0.1.9 → glplot-0.1.10}/tests/test_animation_api.py +19 -0
  37. {glplot-0.1.9 → glplot-0.1.10}/tests/test_animation_integration.py +179 -0
  38. {glplot-0.1.9 → glplot-0.1.10}/tests/test_encodings.py +19 -0
  39. {glplot-0.1.9 → glplot-0.1.10}/tests/test_export.py +112 -0
  40. {glplot-0.1.9 → glplot-0.1.10}/tests/test_gallery_integration.py +30 -5
  41. glplot-0.1.10/tests/test_gui_background.py +170 -0
  42. glplot-0.1.10/tests/test_gui_data_editor.py +1844 -0
  43. {glplot-0.1.9 → glplot-0.1.10}/tests/test_gui_datasets.py +48 -0
  44. glplot-0.1.10/tests/test_gui_layer_kinds.py +1130 -0
  45. glplot-0.1.10/tests/test_gui_mathadvise.py +295 -0
  46. glplot-0.1.10/tests/test_gui_mathlab.py +5921 -0
  47. {glplot-0.1.9 → glplot-0.1.10}/tests/test_gui_mathops.py +1236 -0
  48. glplot-0.1.10/tests/test_gui_mathops2d.py +548 -0
  49. glplot-0.1.10/tests/test_gui_mathopsnd.py +297 -0
  50. glplot-0.1.10/tests/test_gui_models.py +87 -0
  51. glplot-0.1.10/tests/test_gui_notifications.py +102 -0
  52. glplot-0.1.10/tests/test_gui_pipeline.py +572 -0
  53. {glplot-0.1.9 → glplot-0.1.10}/tests/test_gui_styles.py +50 -0
  54. glplot-0.1.10/tests/test_gui_widgets.py +538 -0
  55. {glplot-0.1.9 → glplot-0.1.10}/tests/test_mpl_compat.py +105 -27
  56. {glplot-0.1.9 → glplot-0.1.10}/tests/test_plot_style_api.py +30 -0
  57. {glplot-0.1.9 → glplot-0.1.10}/tests/test_robustness.py +38 -1
  58. glplot-0.1.10/tests/test_scatter_default_size.py +163 -0
  59. glplot-0.1.9/glplot/gui/panels/mathlab.py +0 -2498
  60. glplot-0.1.9/tests/test_gui_data_editor.py +0 -837
  61. glplot-0.1.9/tests/test_gui_layer_kinds.py +0 -537
  62. glplot-0.1.9/tests/test_gui_mathlab.py +0 -1209
  63. glplot-0.1.9/tests/test_gui_pipeline.py +0 -268
  64. {glplot-0.1.9 → glplot-0.1.10}/CHANGELOG.md +0 -0
  65. {glplot-0.1.9 → glplot-0.1.10}/CITATION.cff +0 -0
  66. {glplot-0.1.9 → glplot-0.1.10}/LICENSE +0 -0
  67. {glplot-0.1.9 → glplot-0.1.10}/glplot/__init__.py +0 -0
  68. {glplot-0.1.9 → glplot-0.1.10}/glplot/__main__.py +0 -0
  69. {glplot-0.1.9 → glplot-0.1.10}/glplot/anim/__init__.py +0 -0
  70. {glplot-0.1.9 → glplot-0.1.10}/glplot/anim/applier.py +0 -0
  71. {glplot-0.1.9 → glplot-0.1.10}/glplot/anim/primitives.py +0 -0
  72. {glplot-0.1.9 → glplot-0.1.10}/glplot/anim/processes.py +0 -0
  73. {glplot-0.1.9 → glplot-0.1.10}/glplot/backend.py +0 -0
  74. {glplot-0.1.9 → glplot-0.1.10}/glplot/controllers.py +0 -0
  75. {glplot-0.1.9 → glplot-0.1.10}/glplot/core/__init__.py +0 -0
  76. {glplot-0.1.9 → glplot-0.1.10}/glplot/core/camera3d.py +0 -0
  77. {glplot-0.1.9 → glplot-0.1.10}/glplot/core/context.py +0 -0
  78. {glplot-0.1.9 → glplot-0.1.10}/glplot/core/layers.py +0 -0
  79. {glplot-0.1.9 → glplot-0.1.10}/glplot/core/layout.py +0 -0
  80. {glplot-0.1.9 → glplot-0.1.10}/glplot/core/legacy.py +0 -0
  81. {glplot-0.1.9 → glplot-0.1.10}/glplot/core/panel.py +0 -0
  82. {glplot-0.1.9 → glplot-0.1.10}/glplot/core/timeline.py +0 -0
  83. {glplot-0.1.9 → glplot-0.1.10}/glplot/gui/__init__.py +0 -0
  84. {glplot-0.1.9 → glplot-0.1.10}/glplot/gui/actions.py +0 -0
  85. {glplot-0.1.9 → glplot-0.1.10}/glplot/gui/app.py +0 -0
  86. {glplot-0.1.9 → glplot-0.1.10}/glplot/gui/clipboard.py +0 -0
  87. {glplot-0.1.9 → glplot-0.1.10}/glplot/gui/commands.py +0 -0
  88. {glplot-0.1.9 → glplot-0.1.10}/glplot/gui/dataio.py +0 -0
  89. {glplot-0.1.9 → glplot-0.1.10}/glplot/gui/dynamics.py +0 -0
  90. {glplot-0.1.9 → glplot-0.1.10}/glplot/gui/fuzzy.py +0 -0
  91. {glplot-0.1.9 → glplot-0.1.10}/glplot/gui/generators3d.py +0 -0
  92. {glplot-0.1.9 → glplot-0.1.10}/glplot/gui/history.py +0 -0
  93. {glplot-0.1.9 → glplot-0.1.10}/glplot/gui/icons.py +0 -0
  94. {glplot-0.1.9 → glplot-0.1.10}/glplot/gui/keys.py +0 -0
  95. {glplot-0.1.9 → glplot-0.1.10}/glplot/gui/layerops3d.py +0 -0
  96. {glplot-0.1.9 → glplot-0.1.10}/glplot/gui/panels/__init__.py +0 -0
  97. {glplot-0.1.9 → glplot-0.1.10}/glplot/gui/panels/annotate.py +0 -0
  98. {glplot-0.1.9 → glplot-0.1.10}/glplot/gui/panels/dynamics.py +0 -0
  99. {glplot-0.1.9 → glplot-0.1.10}/glplot/gui/panels/functions.py +0 -0
  100. {glplot-0.1.9 → glplot-0.1.10}/glplot/gui/panels/help.py +0 -0
  101. {glplot-0.1.9 → glplot-0.1.10}/glplot/gui/panels/objects3d.py +0 -0
  102. {glplot-0.1.9 → glplot-0.1.10}/glplot/gui/panels/palette.py +0 -0
  103. {glplot-0.1.9 → glplot-0.1.10}/glplot/gui/panels/presentation.py +0 -0
  104. {glplot-0.1.9 → glplot-0.1.10}/glplot/gui/panels/selection.py +0 -0
  105. {glplot-0.1.9 → glplot-0.1.10}/glplot/gui/panels/timeline.py +0 -0
  106. {glplot-0.1.9 → glplot-0.1.10}/glplot/gui/panels/view3d.py +0 -0
  107. {glplot-0.1.9 → glplot-0.1.10}/glplot/gui/pipeline.py +0 -0
  108. {glplot-0.1.9 → glplot-0.1.10}/glplot/gui/theme.py +0 -0
  109. {glplot-0.1.9 → glplot-0.1.10}/glplot/managers/__init__.py +0 -0
  110. {glplot-0.1.9 → glplot-0.1.10}/glplot/managers/axis.py +0 -0
  111. {glplot-0.1.9 → glplot-0.1.10}/glplot/managers/effects.py +0 -0
  112. {glplot-0.1.9 → glplot-0.1.10}/glplot/managers/hud_state.py +0 -0
  113. {glplot-0.1.9 → glplot-0.1.10}/glplot/managers/picking.py +0 -0
  114. {glplot-0.1.9 → glplot-0.1.10}/glplot/managers/renderer_manager.py +0 -0
  115. {glplot-0.1.9 → glplot-0.1.10}/glplot/policy.py +0 -0
  116. {glplot-0.1.9 → glplot-0.1.10}/glplot/renderers/__init__.py +0 -0
  117. {glplot-0.1.9 → glplot-0.1.10}/glplot/renderers/axes3d.py +0 -0
  118. {glplot-0.1.9 → glplot-0.1.10}/glplot/renderers/base.py +0 -0
  119. {glplot-0.1.9 → glplot-0.1.10}/glplot/renderers/density.py +0 -0
  120. {glplot-0.1.9 → glplot-0.1.10}/glplot/renderers/exact.py +0 -0
  121. {glplot-0.1.9 → glplot-0.1.10}/glplot/renderers/fractal.py +0 -0
  122. {glplot-0.1.9 → glplot-0.1.10}/glplot/renderers/geometry3d.py +0 -0
  123. {glplot-0.1.9 → glplot-0.1.10}/glplot/renderers/interaction.py +0 -0
  124. {glplot-0.1.9 → glplot-0.1.10}/glplot/renderers/legend.py +0 -0
  125. {glplot-0.1.9 → glplot-0.1.10}/glplot/renderers/line_family.py +0 -0
  126. {glplot-0.1.9 → glplot-0.1.10}/glplot/renderers/patch.py +0 -0
  127. {glplot-0.1.9 → glplot-0.1.10}/glplot/renderers/polyline.py +0 -0
  128. {glplot-0.1.9 → glplot-0.1.10}/glplot/renderers/text.py +0 -0
  129. {glplot-0.1.9 → glplot-0.1.10}/glplot/utils/__init__.py +0 -0
  130. {glplot-0.1.9 → glplot-0.1.10}/glplot/utils/blending.py +0 -0
  131. {glplot-0.1.9 → glplot-0.1.10}/glplot/utils/export.py +0 -0
  132. {glplot-0.1.9 → glplot-0.1.10}/glplot/utils/gl_utils.py +0 -0
  133. {glplot-0.1.9 → glplot-0.1.10}/glplot/utils/mpl_bridge.py +0 -0
  134. {glplot-0.1.9 → glplot-0.1.10}/glplot/utils/mpl_process.py +0 -0
  135. {glplot-0.1.9 → glplot-0.1.10}/glplot/utils/scale.py +0 -0
  136. {glplot-0.1.9 → glplot-0.1.10}/tests/conftest.py +0 -0
  137. {glplot-0.1.9 → glplot-0.1.10}/tests/test_anim_applier.py +0 -0
  138. {glplot-0.1.9 → glplot-0.1.10}/tests/test_anim_export.py +0 -0
  139. {glplot-0.1.9 → glplot-0.1.10}/tests/test_anim_primitives.py +0 -0
  140. {glplot-0.1.9 → glplot-0.1.10}/tests/test_anim_processes.py +0 -0
  141. {glplot-0.1.9 → glplot-0.1.10}/tests/test_api_consistency.py +0 -0
  142. {glplot-0.1.9 → glplot-0.1.10}/tests/test_axes.py +0 -0
  143. {glplot-0.1.9 → glplot-0.1.10}/tests/test_axes3d.py +0 -0
  144. {glplot-0.1.9 → glplot-0.1.10}/tests/test_axis_margins.py +0 -0
  145. {glplot-0.1.9 → glplot-0.1.10}/tests/test_blending_3d.py +0 -0
  146. {glplot-0.1.9 → glplot-0.1.10}/tests/test_camera3d.py +0 -0
  147. {glplot-0.1.9 → glplot-0.1.10}/tests/test_camera_anisotropy.py +0 -0
  148. {glplot-0.1.9 → glplot-0.1.10}/tests/test_camera_projections.py +0 -0
  149. {glplot-0.1.9 → glplot-0.1.10}/tests/test_compositing_3d.py +0 -0
  150. {glplot-0.1.9 → glplot-0.1.10}/tests/test_compositing_api.py +0 -0
  151. {glplot-0.1.9 → glplot-0.1.10}/tests/test_contour_labels.py +0 -0
  152. {glplot-0.1.9 → glplot-0.1.10}/tests/test_dataio.py +0 -0
  153. {glplot-0.1.9 → glplot-0.1.10}/tests/test_density_tint.py +0 -0
  154. {glplot-0.1.9 → glplot-0.1.10}/tests/test_edge_cases.py +0 -0
  155. {glplot-0.1.9 → glplot-0.1.10}/tests/test_effects_post.py +0 -0
  156. {glplot-0.1.9 → glplot-0.1.10}/tests/test_engine.py +0 -0
  157. {glplot-0.1.9 → glplot-0.1.10}/tests/test_fractal.py +0 -0
  158. {glplot-0.1.9 → glplot-0.1.10}/tests/test_function_layer.py +0 -0
  159. {glplot-0.1.9 → glplot-0.1.10}/tests/test_generators3d.py +0 -0
  160. {glplot-0.1.9 → glplot-0.1.10}/tests/test_generators3d_extra.py +0 -0
  161. {glplot-0.1.9 → glplot-0.1.10}/tests/test_geometry3d.py +0 -0
  162. {glplot-0.1.9 → glplot-0.1.10}/tests/test_gui_3d_panels.py +0 -0
  163. {glplot-0.1.9 → glplot-0.1.10}/tests/test_gui_actions.py +0 -0
  164. {glplot-0.1.9 → glplot-0.1.10}/tests/test_gui_annotate.py +0 -0
  165. {glplot-0.1.9 → glplot-0.1.10}/tests/test_gui_clipboard.py +0 -0
  166. {glplot-0.1.9 → glplot-0.1.10}/tests/test_gui_commands.py +0 -0
  167. {glplot-0.1.9 → glplot-0.1.10}/tests/test_gui_dynamics.py +0 -0
  168. {glplot-0.1.9 → glplot-0.1.10}/tests/test_gui_engine_integration.py +0 -0
  169. {glplot-0.1.9 → glplot-0.1.10}/tests/test_gui_expressions.py +0 -0
  170. {glplot-0.1.9 → glplot-0.1.10}/tests/test_gui_fields.py +0 -0
  171. {glplot-0.1.9 → glplot-0.1.10}/tests/test_gui_fuzzy.py +0 -0
  172. {glplot-0.1.9 → glplot-0.1.10}/tests/test_gui_history.py +0 -0
  173. {glplot-0.1.9 → glplot-0.1.10}/tests/test_gui_import_safety.py +0 -0
  174. {glplot-0.1.9 → glplot-0.1.10}/tests/test_gui_impostor_alignment.py +0 -0
  175. {glplot-0.1.9 → glplot-0.1.10}/tests/test_gui_input_regressions.py +0 -0
  176. {glplot-0.1.9 → glplot-0.1.10}/tests/test_gui_keyboard_capture.py +0 -0
  177. {glplot-0.1.9 → glplot-0.1.10}/tests/test_gui_keys.py +0 -0
  178. {glplot-0.1.9 → glplot-0.1.10}/tests/test_gui_layer_colormaps.py +0 -0
  179. {glplot-0.1.9 → glplot-0.1.10}/tests/test_gui_layer_params.py +0 -0
  180. {glplot-0.1.9 → glplot-0.1.10}/tests/test_gui_legend.py +0 -0
  181. {glplot-0.1.9 → glplot-0.1.10}/tests/test_gui_regression_r2.py +0 -0
  182. {glplot-0.1.9 → glplot-0.1.10}/tests/test_gui_scene_delete.py +0 -0
  183. {glplot-0.1.9 → glplot-0.1.10}/tests/test_gui_scene_order.py +0 -0
  184. {glplot-0.1.9 → glplot-0.1.10}/tests/test_gui_style_layer_editor.py +0 -0
  185. {glplot-0.1.9 → glplot-0.1.10}/tests/test_gui_styles_extra.py +0 -0
  186. {glplot-0.1.9 → glplot-0.1.10}/tests/test_gui_timeline_panel.py +0 -0
  187. {glplot-0.1.9 → glplot-0.1.10}/tests/test_helpers.py +0 -0
  188. {glplot-0.1.9 → glplot-0.1.10}/tests/test_impostor_scatter_fidelity.py +0 -0
  189. {glplot-0.1.9 → glplot-0.1.10}/tests/test_layerops3d.py +0 -0
  190. {glplot-0.1.9 → glplot-0.1.10}/tests/test_layers.py +0 -0
  191. {glplot-0.1.9 → glplot-0.1.10}/tests/test_mpl_bridge.py +0 -0
  192. {glplot-0.1.9 → glplot-0.1.10}/tests/test_mpl_compat_3d.py +0 -0
  193. {glplot-0.1.9 → glplot-0.1.10}/tests/test_mpl_parity_surface.py +0 -0
  194. {glplot-0.1.9 → glplot-0.1.10}/tests/test_mpl_process.py +0 -0
  195. {glplot-0.1.9 → glplot-0.1.10}/tests/test_objects3d_preview.py +0 -0
  196. {glplot-0.1.9 → glplot-0.1.10}/tests/test_options.py +0 -0
  197. {glplot-0.1.9 → glplot-0.1.10}/tests/test_outline.py +0 -0
  198. {glplot-0.1.9 → glplot-0.1.10}/tests/test_outline_2d.py +0 -0
  199. {glplot-0.1.9 → glplot-0.1.10}/tests/test_panels.py +0 -0
  200. {glplot-0.1.9 → glplot-0.1.10}/tests/test_performance_benchmarks.py +0 -0
  201. {glplot-0.1.9 → glplot-0.1.10}/tests/test_polyline_lod.py +0 -0
  202. {glplot-0.1.9 → glplot-0.1.10}/tests/test_pyplot.py +0 -0
  203. {glplot-0.1.9 → glplot-0.1.10}/tests/test_pyplot_3d.py +0 -0
  204. {glplot-0.1.9 → glplot-0.1.10}/tests/test_pyplot_integration.py +0 -0
  205. {glplot-0.1.9 → glplot-0.1.10}/tests/test_regression.py +0 -0
  206. {glplot-0.1.9 → glplot-0.1.10}/tests/test_scatter_renderer.py +0 -0
  207. {glplot-0.1.9 → glplot-0.1.10}/tests/test_selection_model.py +0 -0
  208. {glplot-0.1.9 → glplot-0.1.10}/tests/test_timeline.py +0 -0
@@ -1,7 +1,5 @@
1
1
  # System files
2
2
  .DS_Store
3
- .vscode/
4
- .idea/
5
3
  *.swp
6
4
  *.swo
7
5
  *~
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: glplot
3
- Version: 0.1.9
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
  ![GLPlot rendering pipeline](examples/dataflow.png)
549
549
 
550
- See [GLPlot_Architecture_and_Mathematical_Formulation.md](GLPlot_Architecture_and_Mathematical_Formulation.md)
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: [GLPlot_Architecture_and_Mathematical_Formulation.md](GLPlot_Architecture_and_Mathematical_Formulation.md)
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
  ![GLPlot rendering pipeline](examples/dataflow.png)
505
505
 
506
- See [GLPlot_Architecture_and_Mathematical_Formulation.md](GLPlot_Architecture_and_Mathematical_Formulation.md)
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: [GLPlot_Architecture_and_Mathematical_Formulation.md](GLPlot_Architecture_and_Mathematical_Formulation.md)
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
- * **Headless** when it does not, :func:`glplot.utils.preview.render_preview` draws the
38
- same scene through matplotlib's Agg backend. This is the path that makes
39
- ``ani.save(...)`` work in CI, in a notebook, and in any script that never called
40
- ``show()``.
41
-
42
- Both render *to a PNG file* and read it back, because that is the only frame-producing
43
- interface the engine exposes; there is no "give me the pixels" entry point to call
44
- instead. The cost is one temp-file round trip per frame, which is real but is dwarfed by
45
- the render itself. Rejected: reimplementing ``ExportManager.savefig``'s ``glReadPixels``
46
- here to skip the file. It would duplicate the panel/scissor/projection logic and would go
47
- stale the first time the render pipeline changed.
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
- * **There is no live on-screen playback.** ``GPULinePlot.run()`` owns its main loop and
63
- exposes no per-frame user hook, so an ``Animation`` cannot drive an open window the way
64
- matplotlib's timer drives a GUI canvas. Constructing an animation and calling
65
- ``plt.show()`` shows the *scene*, not the animation. :meth:`Animation.save`,
66
- :meth:`Animation.to_html5_video` and :meth:`Animation.to_jshtml` all work fully; only
67
- interactive playback does not. :meth:`Animation.pause` / :meth:`Animation.resume` set the
68
- documented flag and are otherwise inert.
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 why both go through a temporary PNG.
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
- with tempfile.TemporaryDirectory(prefix="glplot-anim-") as tmpdir:
285
- target = os.path.join(tmpdir, "frame.png")
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
- elif hasattr(fig, "scene"):
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
- return anim_export.normalize_frame(_read_png(target))
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
- def _render_glplot_figure(fig: Any, target: str, scale: float) -> None:
303
- """Render a GLPlot engine to *target*, through GL if there is a context and Agg if not.
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, and suppressing
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 contextlib.redirect_stdout(io.StringIO()):
312
- fig.export.savefig(target, scale=scale)
313
- return
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 render_preview
338
+ from .utils.preview import render_preview_array
316
339
 
317
- render_preview(fig, target, scale)
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 is what a real timer
423
- would buy, and live playback is unavailable for the separate reason given in the module
424
- docstring.
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, which is
1199
- where all the real work happens given that live playback is unavailable (module
1200
- docstring).
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. That conversion is the only place
1561
- ``interval`` has any effect here, since there is no live loop for it to pace.
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. That is why ``glplot.animation`` can ``save()`` a movie
783
- but could not play one.
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)