glplot 0.1.9__tar.gz → 0.1.11__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (208) hide show
  1. {glplot-0.1.9 → glplot-0.1.11}/.gitignore +0 -2
  2. {glplot-0.1.9 → glplot-0.1.11}/PKG-INFO +4 -4
  3. {glplot-0.1.9 → glplot-0.1.11}/README.md +3 -3
  4. {glplot-0.1.9 → glplot-0.1.11}/glplot/__init__.py +1 -1
  5. {glplot-0.1.9 → glplot-0.1.11}/glplot/animation.py +334 -70
  6. {glplot-0.1.9 → glplot-0.1.11}/glplot/engine.py +187 -17
  7. glplot-0.1.11/glplot/gui/background.py +111 -0
  8. {glplot-0.1.9 → glplot-0.1.11}/glplot/gui/datasets.py +37 -9
  9. {glplot-0.1.9 → glplot-0.1.11}/glplot/gui/expressions.py +17 -1
  10. {glplot-0.1.9 → glplot-0.1.11}/glplot/gui/layerops.py +323 -38
  11. glplot-0.1.11/glplot/gui/mathadvise.py +397 -0
  12. {glplot-0.1.9 → glplot-0.1.11}/glplot/gui/mathops.py +1151 -75
  13. glplot-0.1.11/glplot/gui/mathops2d.py +786 -0
  14. glplot-0.1.11/glplot/gui/mathopsnd.py +405 -0
  15. glplot-0.1.11/glplot/gui/models.py +132 -0
  16. glplot-0.1.11/glplot/gui/notifications.py +73 -0
  17. {glplot-0.1.9 → glplot-0.1.11}/glplot/gui/panels/base.py +6 -0
  18. {glplot-0.1.9 → glplot-0.1.11}/glplot/gui/panels/data_editor.py +466 -65
  19. glplot-0.1.11/glplot/gui/panels/mathlab.py +6037 -0
  20. {glplot-0.1.9 → glplot-0.1.11}/glplot/gui/panels/pipeline.py +202 -2
  21. {glplot-0.1.9 → glplot-0.1.11}/glplot/gui/panels/scene.py +6 -6
  22. {glplot-0.1.9 → glplot-0.1.11}/glplot/gui/panels/style.py +68 -9
  23. {glplot-0.1.9 → glplot-0.1.11}/glplot/gui/panels/timeline.py +9 -3
  24. {glplot-0.1.9 → glplot-0.1.11}/glplot/gui/styles.py +25 -0
  25. {glplot-0.1.9 → glplot-0.1.11}/glplot/gui/widgets.py +688 -34
  26. {glplot-0.1.9 → glplot-0.1.11}/glplot/gui/workspace.py +156 -14
  27. {glplot-0.1.9 → glplot-0.1.11}/glplot/managers/hud.py +8 -0
  28. {glplot-0.1.9 → glplot-0.1.11}/glplot/options.py +7 -0
  29. {glplot-0.1.9 → glplot-0.1.11}/glplot/pyplot.py +132 -36
  30. {glplot-0.1.9 → glplot-0.1.11}/glplot/renderers/axis.py +16 -3
  31. {glplot-0.1.9 → glplot-0.1.11}/glplot/renderers/colorbar.py +81 -20
  32. {glplot-0.1.9 → glplot-0.1.11}/glplot/renderers/contour_labels.py +3 -1
  33. {glplot-0.1.9 → glplot-0.1.11}/glplot/renderers/scatter.py +6 -0
  34. {glplot-0.1.9 → glplot-0.1.11}/glplot/utils/anim_export.py +11 -0
  35. {glplot-0.1.9 → glplot-0.1.11}/glplot/utils/export.py +23 -8
  36. {glplot-0.1.9 → glplot-0.1.11}/glplot/utils/preview.py +141 -26
  37. {glplot-0.1.9 → glplot-0.1.11}/glplot/utils/shaders.py +20 -2
  38. {glplot-0.1.9 → glplot-0.1.11}/pyproject.toml +1 -1
  39. {glplot-0.1.9 → glplot-0.1.11}/tests/test_animation_api.py +167 -0
  40. {glplot-0.1.9 → glplot-0.1.11}/tests/test_animation_integration.py +217 -1
  41. {glplot-0.1.9 → glplot-0.1.11}/tests/test_encodings.py +19 -0
  42. {glplot-0.1.9 → glplot-0.1.11}/tests/test_export.py +112 -0
  43. {glplot-0.1.9 → glplot-0.1.11}/tests/test_gallery_integration.py +30 -5
  44. glplot-0.1.11/tests/test_gui_background.py +170 -0
  45. glplot-0.1.11/tests/test_gui_data_editor.py +1844 -0
  46. {glplot-0.1.9 → glplot-0.1.11}/tests/test_gui_datasets.py +48 -0
  47. {glplot-0.1.9 → glplot-0.1.11}/tests/test_gui_input_regressions.py +89 -1
  48. glplot-0.1.11/tests/test_gui_layer_kinds.py +1130 -0
  49. glplot-0.1.11/tests/test_gui_mathadvise.py +295 -0
  50. glplot-0.1.11/tests/test_gui_mathlab.py +5921 -0
  51. {glplot-0.1.9 → glplot-0.1.11}/tests/test_gui_mathops.py +1236 -0
  52. glplot-0.1.11/tests/test_gui_mathops2d.py +548 -0
  53. glplot-0.1.11/tests/test_gui_mathopsnd.py +297 -0
  54. glplot-0.1.11/tests/test_gui_models.py +87 -0
  55. glplot-0.1.11/tests/test_gui_notifications.py +102 -0
  56. glplot-0.1.11/tests/test_gui_pipeline.py +572 -0
  57. {glplot-0.1.9 → glplot-0.1.11}/tests/test_gui_styles.py +50 -0
  58. {glplot-0.1.9 → glplot-0.1.11}/tests/test_gui_timeline_panel.py +22 -0
  59. glplot-0.1.11/tests/test_gui_widgets.py +538 -0
  60. {glplot-0.1.9 → glplot-0.1.11}/tests/test_mpl_compat.py +105 -27
  61. {glplot-0.1.9 → glplot-0.1.11}/tests/test_plot_style_api.py +30 -0
  62. {glplot-0.1.9 → glplot-0.1.11}/tests/test_robustness.py +38 -1
  63. glplot-0.1.11/tests/test_scatter_default_size.py +191 -0
  64. glplot-0.1.9/glplot/gui/panels/mathlab.py +0 -2498
  65. glplot-0.1.9/tests/test_gui_data_editor.py +0 -837
  66. glplot-0.1.9/tests/test_gui_layer_kinds.py +0 -537
  67. glplot-0.1.9/tests/test_gui_mathlab.py +0 -1209
  68. glplot-0.1.9/tests/test_gui_pipeline.py +0 -268
  69. {glplot-0.1.9 → glplot-0.1.11}/CHANGELOG.md +0 -0
  70. {glplot-0.1.9 → glplot-0.1.11}/CITATION.cff +0 -0
  71. {glplot-0.1.9 → glplot-0.1.11}/LICENSE +0 -0
  72. {glplot-0.1.9 → glplot-0.1.11}/glplot/__main__.py +0 -0
  73. {glplot-0.1.9 → glplot-0.1.11}/glplot/anim/__init__.py +0 -0
  74. {glplot-0.1.9 → glplot-0.1.11}/glplot/anim/applier.py +0 -0
  75. {glplot-0.1.9 → glplot-0.1.11}/glplot/anim/primitives.py +0 -0
  76. {glplot-0.1.9 → glplot-0.1.11}/glplot/anim/processes.py +0 -0
  77. {glplot-0.1.9 → glplot-0.1.11}/glplot/backend.py +0 -0
  78. {glplot-0.1.9 → glplot-0.1.11}/glplot/controllers.py +0 -0
  79. {glplot-0.1.9 → glplot-0.1.11}/glplot/core/__init__.py +0 -0
  80. {glplot-0.1.9 → glplot-0.1.11}/glplot/core/camera3d.py +0 -0
  81. {glplot-0.1.9 → glplot-0.1.11}/glplot/core/context.py +0 -0
  82. {glplot-0.1.9 → glplot-0.1.11}/glplot/core/layers.py +0 -0
  83. {glplot-0.1.9 → glplot-0.1.11}/glplot/core/layout.py +0 -0
  84. {glplot-0.1.9 → glplot-0.1.11}/glplot/core/legacy.py +0 -0
  85. {glplot-0.1.9 → glplot-0.1.11}/glplot/core/panel.py +0 -0
  86. {glplot-0.1.9 → glplot-0.1.11}/glplot/core/timeline.py +0 -0
  87. {glplot-0.1.9 → glplot-0.1.11}/glplot/gui/__init__.py +0 -0
  88. {glplot-0.1.9 → glplot-0.1.11}/glplot/gui/actions.py +0 -0
  89. {glplot-0.1.9 → glplot-0.1.11}/glplot/gui/app.py +0 -0
  90. {glplot-0.1.9 → glplot-0.1.11}/glplot/gui/clipboard.py +0 -0
  91. {glplot-0.1.9 → glplot-0.1.11}/glplot/gui/commands.py +0 -0
  92. {glplot-0.1.9 → glplot-0.1.11}/glplot/gui/dataio.py +0 -0
  93. {glplot-0.1.9 → glplot-0.1.11}/glplot/gui/dynamics.py +0 -0
  94. {glplot-0.1.9 → glplot-0.1.11}/glplot/gui/fuzzy.py +0 -0
  95. {glplot-0.1.9 → glplot-0.1.11}/glplot/gui/generators3d.py +0 -0
  96. {glplot-0.1.9 → glplot-0.1.11}/glplot/gui/history.py +0 -0
  97. {glplot-0.1.9 → glplot-0.1.11}/glplot/gui/icons.py +0 -0
  98. {glplot-0.1.9 → glplot-0.1.11}/glplot/gui/keys.py +0 -0
  99. {glplot-0.1.9 → glplot-0.1.11}/glplot/gui/layerops3d.py +0 -0
  100. {glplot-0.1.9 → glplot-0.1.11}/glplot/gui/panels/__init__.py +0 -0
  101. {glplot-0.1.9 → glplot-0.1.11}/glplot/gui/panels/annotate.py +0 -0
  102. {glplot-0.1.9 → glplot-0.1.11}/glplot/gui/panels/dynamics.py +0 -0
  103. {glplot-0.1.9 → glplot-0.1.11}/glplot/gui/panels/functions.py +0 -0
  104. {glplot-0.1.9 → glplot-0.1.11}/glplot/gui/panels/help.py +0 -0
  105. {glplot-0.1.9 → glplot-0.1.11}/glplot/gui/panels/objects3d.py +0 -0
  106. {glplot-0.1.9 → glplot-0.1.11}/glplot/gui/panels/palette.py +0 -0
  107. {glplot-0.1.9 → glplot-0.1.11}/glplot/gui/panels/presentation.py +0 -0
  108. {glplot-0.1.9 → glplot-0.1.11}/glplot/gui/panels/selection.py +0 -0
  109. {glplot-0.1.9 → glplot-0.1.11}/glplot/gui/panels/view3d.py +0 -0
  110. {glplot-0.1.9 → glplot-0.1.11}/glplot/gui/pipeline.py +0 -0
  111. {glplot-0.1.9 → glplot-0.1.11}/glplot/gui/theme.py +0 -0
  112. {glplot-0.1.9 → glplot-0.1.11}/glplot/managers/__init__.py +0 -0
  113. {glplot-0.1.9 → glplot-0.1.11}/glplot/managers/axis.py +0 -0
  114. {glplot-0.1.9 → glplot-0.1.11}/glplot/managers/effects.py +0 -0
  115. {glplot-0.1.9 → glplot-0.1.11}/glplot/managers/hud_state.py +0 -0
  116. {glplot-0.1.9 → glplot-0.1.11}/glplot/managers/picking.py +0 -0
  117. {glplot-0.1.9 → glplot-0.1.11}/glplot/managers/renderer_manager.py +0 -0
  118. {glplot-0.1.9 → glplot-0.1.11}/glplot/policy.py +0 -0
  119. {glplot-0.1.9 → glplot-0.1.11}/glplot/renderers/__init__.py +0 -0
  120. {glplot-0.1.9 → glplot-0.1.11}/glplot/renderers/axes3d.py +0 -0
  121. {glplot-0.1.9 → glplot-0.1.11}/glplot/renderers/base.py +0 -0
  122. {glplot-0.1.9 → glplot-0.1.11}/glplot/renderers/density.py +0 -0
  123. {glplot-0.1.9 → glplot-0.1.11}/glplot/renderers/exact.py +0 -0
  124. {glplot-0.1.9 → glplot-0.1.11}/glplot/renderers/fractal.py +0 -0
  125. {glplot-0.1.9 → glplot-0.1.11}/glplot/renderers/geometry3d.py +0 -0
  126. {glplot-0.1.9 → glplot-0.1.11}/glplot/renderers/interaction.py +0 -0
  127. {glplot-0.1.9 → glplot-0.1.11}/glplot/renderers/legend.py +0 -0
  128. {glplot-0.1.9 → glplot-0.1.11}/glplot/renderers/line_family.py +0 -0
  129. {glplot-0.1.9 → glplot-0.1.11}/glplot/renderers/patch.py +0 -0
  130. {glplot-0.1.9 → glplot-0.1.11}/glplot/renderers/polyline.py +0 -0
  131. {glplot-0.1.9 → glplot-0.1.11}/glplot/renderers/text.py +0 -0
  132. {glplot-0.1.9 → glplot-0.1.11}/glplot/utils/__init__.py +0 -0
  133. {glplot-0.1.9 → glplot-0.1.11}/glplot/utils/blending.py +0 -0
  134. {glplot-0.1.9 → glplot-0.1.11}/glplot/utils/gl_utils.py +0 -0
  135. {glplot-0.1.9 → glplot-0.1.11}/glplot/utils/mpl_bridge.py +0 -0
  136. {glplot-0.1.9 → glplot-0.1.11}/glplot/utils/mpl_process.py +0 -0
  137. {glplot-0.1.9 → glplot-0.1.11}/glplot/utils/scale.py +0 -0
  138. {glplot-0.1.9 → glplot-0.1.11}/tests/conftest.py +0 -0
  139. {glplot-0.1.9 → glplot-0.1.11}/tests/test_anim_applier.py +0 -0
  140. {glplot-0.1.9 → glplot-0.1.11}/tests/test_anim_export.py +0 -0
  141. {glplot-0.1.9 → glplot-0.1.11}/tests/test_anim_primitives.py +0 -0
  142. {glplot-0.1.9 → glplot-0.1.11}/tests/test_anim_processes.py +0 -0
  143. {glplot-0.1.9 → glplot-0.1.11}/tests/test_api_consistency.py +0 -0
  144. {glplot-0.1.9 → glplot-0.1.11}/tests/test_axes.py +0 -0
  145. {glplot-0.1.9 → glplot-0.1.11}/tests/test_axes3d.py +0 -0
  146. {glplot-0.1.9 → glplot-0.1.11}/tests/test_axis_margins.py +0 -0
  147. {glplot-0.1.9 → glplot-0.1.11}/tests/test_blending_3d.py +0 -0
  148. {glplot-0.1.9 → glplot-0.1.11}/tests/test_camera3d.py +0 -0
  149. {glplot-0.1.9 → glplot-0.1.11}/tests/test_camera_anisotropy.py +0 -0
  150. {glplot-0.1.9 → glplot-0.1.11}/tests/test_camera_projections.py +0 -0
  151. {glplot-0.1.9 → glplot-0.1.11}/tests/test_compositing_3d.py +0 -0
  152. {glplot-0.1.9 → glplot-0.1.11}/tests/test_compositing_api.py +0 -0
  153. {glplot-0.1.9 → glplot-0.1.11}/tests/test_contour_labels.py +0 -0
  154. {glplot-0.1.9 → glplot-0.1.11}/tests/test_dataio.py +0 -0
  155. {glplot-0.1.9 → glplot-0.1.11}/tests/test_density_tint.py +0 -0
  156. {glplot-0.1.9 → glplot-0.1.11}/tests/test_edge_cases.py +0 -0
  157. {glplot-0.1.9 → glplot-0.1.11}/tests/test_effects_post.py +0 -0
  158. {glplot-0.1.9 → glplot-0.1.11}/tests/test_engine.py +0 -0
  159. {glplot-0.1.9 → glplot-0.1.11}/tests/test_fractal.py +0 -0
  160. {glplot-0.1.9 → glplot-0.1.11}/tests/test_function_layer.py +0 -0
  161. {glplot-0.1.9 → glplot-0.1.11}/tests/test_generators3d.py +0 -0
  162. {glplot-0.1.9 → glplot-0.1.11}/tests/test_generators3d_extra.py +0 -0
  163. {glplot-0.1.9 → glplot-0.1.11}/tests/test_geometry3d.py +0 -0
  164. {glplot-0.1.9 → glplot-0.1.11}/tests/test_gui_3d_panels.py +0 -0
  165. {glplot-0.1.9 → glplot-0.1.11}/tests/test_gui_actions.py +0 -0
  166. {glplot-0.1.9 → glplot-0.1.11}/tests/test_gui_annotate.py +0 -0
  167. {glplot-0.1.9 → glplot-0.1.11}/tests/test_gui_clipboard.py +0 -0
  168. {glplot-0.1.9 → glplot-0.1.11}/tests/test_gui_commands.py +0 -0
  169. {glplot-0.1.9 → glplot-0.1.11}/tests/test_gui_dynamics.py +0 -0
  170. {glplot-0.1.9 → glplot-0.1.11}/tests/test_gui_engine_integration.py +0 -0
  171. {glplot-0.1.9 → glplot-0.1.11}/tests/test_gui_expressions.py +0 -0
  172. {glplot-0.1.9 → glplot-0.1.11}/tests/test_gui_fields.py +0 -0
  173. {glplot-0.1.9 → glplot-0.1.11}/tests/test_gui_fuzzy.py +0 -0
  174. {glplot-0.1.9 → glplot-0.1.11}/tests/test_gui_history.py +0 -0
  175. {glplot-0.1.9 → glplot-0.1.11}/tests/test_gui_import_safety.py +0 -0
  176. {glplot-0.1.9 → glplot-0.1.11}/tests/test_gui_impostor_alignment.py +0 -0
  177. {glplot-0.1.9 → glplot-0.1.11}/tests/test_gui_keyboard_capture.py +0 -0
  178. {glplot-0.1.9 → glplot-0.1.11}/tests/test_gui_keys.py +0 -0
  179. {glplot-0.1.9 → glplot-0.1.11}/tests/test_gui_layer_colormaps.py +0 -0
  180. {glplot-0.1.9 → glplot-0.1.11}/tests/test_gui_layer_params.py +0 -0
  181. {glplot-0.1.9 → glplot-0.1.11}/tests/test_gui_legend.py +0 -0
  182. {glplot-0.1.9 → glplot-0.1.11}/tests/test_gui_regression_r2.py +0 -0
  183. {glplot-0.1.9 → glplot-0.1.11}/tests/test_gui_scene_delete.py +0 -0
  184. {glplot-0.1.9 → glplot-0.1.11}/tests/test_gui_scene_order.py +0 -0
  185. {glplot-0.1.9 → glplot-0.1.11}/tests/test_gui_style_layer_editor.py +0 -0
  186. {glplot-0.1.9 → glplot-0.1.11}/tests/test_gui_styles_extra.py +0 -0
  187. {glplot-0.1.9 → glplot-0.1.11}/tests/test_helpers.py +0 -0
  188. {glplot-0.1.9 → glplot-0.1.11}/tests/test_impostor_scatter_fidelity.py +0 -0
  189. {glplot-0.1.9 → glplot-0.1.11}/tests/test_layerops3d.py +0 -0
  190. {glplot-0.1.9 → glplot-0.1.11}/tests/test_layers.py +0 -0
  191. {glplot-0.1.9 → glplot-0.1.11}/tests/test_mpl_bridge.py +0 -0
  192. {glplot-0.1.9 → glplot-0.1.11}/tests/test_mpl_compat_3d.py +0 -0
  193. {glplot-0.1.9 → glplot-0.1.11}/tests/test_mpl_parity_surface.py +0 -0
  194. {glplot-0.1.9 → glplot-0.1.11}/tests/test_mpl_process.py +0 -0
  195. {glplot-0.1.9 → glplot-0.1.11}/tests/test_objects3d_preview.py +0 -0
  196. {glplot-0.1.9 → glplot-0.1.11}/tests/test_options.py +0 -0
  197. {glplot-0.1.9 → glplot-0.1.11}/tests/test_outline.py +0 -0
  198. {glplot-0.1.9 → glplot-0.1.11}/tests/test_outline_2d.py +0 -0
  199. {glplot-0.1.9 → glplot-0.1.11}/tests/test_panels.py +0 -0
  200. {glplot-0.1.9 → glplot-0.1.11}/tests/test_performance_benchmarks.py +0 -0
  201. {glplot-0.1.9 → glplot-0.1.11}/tests/test_polyline_lod.py +0 -0
  202. {glplot-0.1.9 → glplot-0.1.11}/tests/test_pyplot.py +0 -0
  203. {glplot-0.1.9 → glplot-0.1.11}/tests/test_pyplot_3d.py +0 -0
  204. {glplot-0.1.9 → glplot-0.1.11}/tests/test_pyplot_integration.py +0 -0
  205. {glplot-0.1.9 → glplot-0.1.11}/tests/test_regression.py +0 -0
  206. {glplot-0.1.9 → glplot-0.1.11}/tests/test_scatter_renderer.py +0 -0
  207. {glplot-0.1.9 → glplot-0.1.11}/tests/test_selection_model.py +0 -0
  208. {glplot-0.1.9 → glplot-0.1.11}/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.11
4
4
  Summary: High-performance OpenGL plotting library for Python
5
5
  Project-URL: Homepage, https://github.com/AkarisDimitry/GLPlot
6
6
  Project-URL: Repository, https://github.com/AkarisDimitry/GLPlot
@@ -486,7 +486,7 @@ plt.show()
486
486
  ```
487
487
  </details>
488
488
 
489
- **All of the above render at 60+ FPS** with interactive panning, zooming, and rotation, regardless of point count. Discover more in the [example gallery](examples/gallery/README.md) (28 static scripts) and the [animated gallery](examples/gallery/animations/README.md) (15 animated scripts).
489
+ **All of the above render at 60+ FPS** with interactive panning, zooming, and rotation, regardless of point count. Discover more in the [example gallery](examples/gallery/README.md) (28 static scripts) and the [animated gallery](examples/gallery/animations/README.md) (16 animated scripts).
490
490
 
491
491
  ## Features
492
492
 
@@ -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
 
@@ -442,7 +442,7 @@ plt.show()
442
442
  ```
443
443
  </details>
444
444
 
445
- **All of the above render at 60+ FPS** with interactive panning, zooming, and rotation, regardless of point count. Discover more in the [example gallery](examples/gallery/README.md) (28 static scripts) and the [animated gallery](examples/gallery/animations/README.md) (15 animated scripts).
445
+ **All of the above render at 60+ FPS** with interactive panning, zooming, and rotation, regardless of point count. Discover more in the [example gallery](examples/gallery/README.md) (28 static scripts) and the [animated gallery](examples/gallery/animations/README.md) (16 animated scripts).
446
446
 
447
447
  ## Features
448
448
 
@@ -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
 
@@ -38,7 +38,7 @@ except Exception: # pragma: no cover - imgui_bundle is optional at import time
38
38
  from .engine import GPULinePlot
39
39
  from .options import BlendMode, EngineOptions, RenderMode
40
40
 
41
- __version__ = "0.1.6"
41
+ __version__ = "0.1.10"
42
42
  __author__ = "Juan Manuel Lombardi"
43
43
  __license__ = "MIT"
44
44
 
@@ -32,25 +32,29 @@ How a frame is produced
32
32
  -----------------------
33
33
  :func:`figure_to_rgb` is the whole bridge, and it has two modes:
34
34
 
35
- * **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.
48
-
49
- **The headless path is not pixel-identical to the GL path.** ``render_preview`` is a
35
+ * **OpenGL** — when ``fig.window`` exists, or ``Animation.save`` is explicitly given
36
+ ``savefig_kwargs={"renderer": "opengl"}``, the scene is rendered offscreen through the
37
+ real GPU pipeline at full quality. ``ExportManager.render_rgb`` returns the framebuffer
38
+ directly, so animation frames do not make a temporary PNG round trip.
39
+ * **Headless** when it does not,
40
+ :func:`glplot.utils.preview.render_preview_array` draws the same scene through
41
+ matplotlib's Agg backend and hands back the rendered pixels directly, with no file
42
+ involved. This is the path that makes ``ani.save(...)`` work in CI, in a notebook, and in
43
+ any script that never called ``show()`` the common case for a saved animation, and the
44
+ one with hundreds of frames to pay a per-frame cost on. It used to round-trip through a
45
+ PNG the same way the GL path still does; profiling a real animation loop found PIL's PNG
46
+ encoder alone cost as much as matplotlib's entire draw, so that encode plus the disk
47
+ write/read it required were pure overhead for a caller (this one) that only ever wanted
48
+ the array. :func:`glplot.utils.preview.render_preview` (the file-writing function a plain
49
+ ``savefig()`` call uses) is unchanged; :func:`render_preview_array` is a second entry
50
+ point onto the same figure-building code, not a rewrite of it — see its docstring.
51
+
52
+ **The matplotlib path is not pixel-identical to the GL path.** ``render_preview`` is a
50
53
  matplotlib re-drawing of the scene, not the GPU renderer; it approximates. An animation
51
- saved without a window looks like a matplotlib plot of the same data, which is usually
52
- what a user wants from CI and never quite what they want from a demo reel. Call
53
- ``fig.run()`` first if the GPU look matters.
54
+ saved without a window uses this backwards-compatible path unless a renderer is requested.
55
+ Pass ``savefig_kwargs={"renderer": "opengl"}`` when the GPU look matters, ``"matplotlib"``
56
+ to force the portable preview, or ``"auto"`` to try an invisible GL context and warn before
57
+ falling back to matplotlib when no context can be created.
54
58
 
55
59
  Limitations, stated plainly
56
60
  ---------------------------
@@ -59,13 +63,17 @@ Limitations, stated plainly
59
63
  changes nothing except that it does not raise. Output is identical either way — blitting
60
64
  is an optimisation in matplotlib, not a correctness flag — so ignoring it cannot make a
61
65
  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.
66
+ * **Live on-screen playback rides the engine's frame callback, not a real timer.**
67
+ ``GPULinePlot.run()`` owns its main loop and has no toolkit timer for a
68
+ ``TimedAnimation`` to hook the way matplotlib's does but it does expose
69
+ ``add_frame_callback`` (:meth:`glplot.engine.GPULinePlot.add_frame_callback`), a per-frame
70
+ hook driven by wall-clock time, and :class:`TimedAnimation` uses exactly that to step
71
+ itself at ``interval``. A ``FuncAnimation``/``ArtistAnimation`` built against a live
72
+ GLPlot figure therefore *does* play when you call ``plt.show()`` — no ``draw_event``, no
73
+ blitting, just the same full-scene re-render :meth:`Animation.save` uses, paced in real
74
+ time instead of frame-by-frame. :meth:`Animation.pause` / :meth:`Animation.resume` stop and
75
+ restart that stepping. A real matplotlib ``Figure`` is untouched by any of this: it keeps
76
+ using its own canvas and timer, exactly as before.
69
77
  * **In-place array mutation is assumed.** A user callback that writes into
70
78
  ``layer.pts[:, 1]`` leaves no trace numpy can report, so after every callback this module
71
79
  marks every layer dirty rather than trying to detect what changed. Cheap, and the only
@@ -85,7 +93,9 @@ import itertools
85
93
  import json
86
94
  import os
87
95
  import shutil
96
+ import sys
88
97
  import tempfile
98
+ import threading
89
99
  import uuid
90
100
  import warnings
91
101
  from pathlib import Path
@@ -275,46 +285,152 @@ 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))
299
+ return anim_export.normalize_frame(_read_png(target))
300
+
301
+ if hasattr(fig, "scene"):
302
+ return anim_export.normalize_frame(_render_glplot_frame(fig, _dpi_to_scale(dpi)))
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
+ )
310
+
311
+
312
+ def _normalize_frame_renderer(renderer: Any) -> Optional[str]:
313
+ """Validate the GLPlot-only renderer option carried inside ``savefig_kwargs``."""
314
+ if renderer is None:
315
+ return None
316
+ value = str(renderer).strip().lower()
317
+ if value not in {"auto", "opengl", "matplotlib"}:
318
+ raise ValueError(
319
+ "animation renderer must be 'auto', 'opengl', or 'matplotlib'; "
320
+ f"got {renderer!r}"
321
+ )
322
+ return value
323
+
324
+
325
+ @contextlib.contextmanager
326
+ def _frame_renderer_override(fig: Any, renderer: str) -> Iterator[None]:
327
+ """Temporarily select the route used by :func:`figure_to_rgb`."""
328
+ missing = object()
329
+ previous = getattr(fig, "_animation_renderer_override", missing)
330
+ fig._animation_renderer_override = renderer
331
+ try:
332
+ yield
333
+ finally:
334
+ if previous is missing:
335
+ with contextlib.suppress(AttributeError):
336
+ del fig._animation_renderer_override
291
337
  else:
338
+ fig._animation_renderer_override = previous
339
+
340
+
341
+ @contextlib.contextmanager
342
+ def _animation_render_context(fig: Any, renderer: Optional[str]) -> Iterator[None]:
343
+ """Keep the selected animation renderer ready for one complete save operation.
344
+
345
+ ``None`` preserves the historical behaviour: use GL only if the caller already opened
346
+ a context, otherwise use matplotlib. Explicit ``auto`` is opt-in and tries to create an
347
+ invisible context once before falling back. Explicit ``opengl`` reports setup failure
348
+ instead of silently producing a visually different animation.
349
+ """
350
+ renderer = _normalize_frame_renderer(renderer)
351
+
352
+ if not hasattr(fig, "scene"):
353
+ if renderer == "opengl":
354
+ raise ValueError("renderer='opengl' requires a GLPlot figure")
355
+ yield
356
+ return
357
+
358
+ if renderer is None:
359
+ with _frame_renderer_override(fig, "auto"):
360
+ yield
361
+ return
362
+
363
+ if renderer == "matplotlib":
364
+ with _frame_renderer_override(fig, "matplotlib"):
365
+ yield
366
+ return
367
+
368
+ setup_error: Optional[BaseException] = None
369
+ stack = contextlib.ExitStack()
370
+ try:
371
+ if (
372
+ getattr(fig, "window", None) is None
373
+ and sys.platform == "darwin"
374
+ and threading.current_thread() is not threading.main_thread()
375
+ ):
292
376
  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."
377
+ "macOS requires creation of the GLFW/OpenGL context on the main thread"
297
378
  )
298
379
 
299
- return anim_export.normalize_frame(_read_png(target))
380
+ context_factory = getattr(fig, "_offscreen_export_context", None)
381
+ if not callable(context_factory):
382
+ raise RuntimeError("this GLPlot figure cannot create an offscreen OpenGL context")
383
+ stack.enter_context(context_factory())
384
+ except Exception as exc:
385
+ setup_error = exc
386
+ stack.close()
387
+
388
+ if setup_error is not None:
389
+ if renderer == "opengl":
390
+ raise RuntimeError(
391
+ "Could not create the OpenGL context requested for animation export"
392
+ ) from setup_error
393
+ warnings.warn(
394
+ "OpenGL animation export is unavailable; falling back to the matplotlib "
395
+ f"renderer ({setup_error}).",
396
+ RuntimeWarning,
397
+ stacklevel=3,
398
+ )
399
+ with _frame_renderer_override(fig, "matplotlib"):
400
+ yield
401
+ return
402
+
403
+ with stack:
404
+ with _frame_renderer_override(fig, "opengl"):
405
+ yield
406
+
300
407
 
408
+ def _render_glplot_frame(fig: Any, scale: float) -> np.ndarray:
409
+ """Render one GLPlot frame through the selected OpenGL or matplotlib route.
301
410
 
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.
411
+ **Headless** does not: :func:`glplot.utils.preview.render_preview_array` hands back the
412
+ rendered pixels directly from matplotlib's Agg canvas, skipping the PNG encode and the
413
+ disk write/read a file-based caller needs. Profiled: PIL's PNG encoder alone cost as much
414
+ as matplotlib's entire draw, so for an animation's hundreds of headless frames — the
415
+ common case, since a saved animation is usually built without ever calling ``fig.run()``
416
+ — this removes roughly a third of each frame's cost with no change in what gets drawn.
304
417
 
305
- ``ExportManager.savefig`` prints a "Exported high-res image to ..." line on every call.
306
- 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.
418
+ The OpenGL branch calls ``ExportManager.render_rgb`` so it shares the export pipeline's
419
+ panel, scissor, projection, depth and shader logic without touching the filesystem.
309
420
  """
310
- 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
421
+ renderer = getattr(fig, "_animation_renderer_override", "auto")
422
+ if renderer != "matplotlib" and getattr(fig, "window", None) is not None:
423
+ export = getattr(fig, "export", None)
424
+ render_rgb = getattr(export, "render_rgb", None)
425
+ if callable(render_rgb):
426
+ return render_rgb(scale=scale)
427
+
428
+ if renderer == "opengl":
429
+ raise RuntimeError("OpenGL animation rendering was requested, but no context is active")
314
430
 
315
- from .utils.preview import render_preview
431
+ from .utils.preview import render_preview_array
316
432
 
317
- render_preview(fig, target, scale)
433
+ return render_preview_array(fig, scale)
318
434
 
319
435
 
320
436
  def _read_png(path: str) -> np.ndarray:
@@ -419,9 +535,9 @@ class _NullTimer:
419
535
 
420
536
  :class:`TimedAnimation` needs *an* event source; it does not need one that works in
421
537
  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.
538
+ sequence directly and never consults the timer. Live playback does not go through this
539
+ timer either -- :meth:`TimedAnimation._start_live_playback` drives it straight off
540
+ ``fig.add_frame_callback`` instead, since that is the hook the engine actually has.
425
541
  """
426
542
 
427
543
  def __init__(self, interval: Optional[float] = None, callbacks: Any = None) -> None:
@@ -1195,9 +1311,9 @@ class Animation:
1195
1311
  """Base class for animations, with matplotlib's constructor and public methods.
1196
1312
 
1197
1313
  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).
1314
+ (:meth:`_draw_frame`); this class supplies :meth:`save` and the HTML exports. Live
1315
+ playback (:class:`TimedAnimation`'s ``fig.add_frame_callback`` hook, module docstring)
1316
+ is the other consumer of that same frame-stepping machinery.
1201
1317
  """
1202
1318
 
1203
1319
  def __init__(self, fig: Any, event_source: Any = None, blit: bool = False) -> None:
@@ -1317,8 +1433,10 @@ class Animation:
1317
1433
  twice the default size. See :func:`figure_pixel_size` for what that works out to.
1318
1434
 
1319
1435
  ``extra_anim`` is honoured: the animations are stepped together, one saved frame per
1320
- tick, as in matplotlib. ``savefig_kwargs`` is accepted and ignored except for
1321
- ``dpi``; see :meth:`AbstractMovieWriter.grab_frame`.
1436
+ tick, as in matplotlib. Most ``savefig_kwargs`` are accepted for compatibility and
1437
+ ignored; GLPlot additionally consumes ``renderer`` with values ``"opengl"``,
1438
+ ``"matplotlib"`` or ``"auto"``. Omitting it preserves the historical behaviour:
1439
+ an already-live GL context is used, otherwise rendering stays headless through Agg.
1322
1440
  """
1323
1441
  all_anim: List[Animation] = [self]
1324
1442
  if extra_anim is not None:
@@ -1348,6 +1466,7 @@ class Animation:
1348
1466
  else:
1349
1467
  savefig_kwargs = dict(savefig_kwargs)
1350
1468
  savefig_kwargs.pop("bbox_inches", None)
1469
+ renderer = _normalize_frame_renderer(savefig_kwargs.pop("renderer", None))
1351
1470
 
1352
1471
  if fps is None and hasattr(self, "_interval"):
1353
1472
  fps = MS_PER_SECOND / float(self._interval)
@@ -1367,17 +1486,18 @@ class Animation:
1367
1486
  writer = writer_cls(fps if fps is not None else 5, **writer_kwargs)
1368
1487
 
1369
1488
  total_frames = _total_frames(all_anim)
1370
- with writer.saving(self._fig, str(filename), dpi):
1371
- for anim in all_anim:
1372
- anim._init_draw()
1373
- frame_number = 0
1374
- for data in zip(*[anim.new_saved_frame_seq() for anim in all_anim]):
1375
- for anim, datum in zip(all_anim, data):
1376
- anim._draw_next_frame(datum, blit=False)
1377
- if progress_callback is not None:
1378
- progress_callback(frame_number, total_frames)
1379
- frame_number += 1
1380
- writer.grab_frame(**savefig_kwargs)
1489
+ with _animation_render_context(self._fig, renderer):
1490
+ with writer.saving(self._fig, str(filename), dpi):
1491
+ for anim in all_anim:
1492
+ anim._init_draw()
1493
+ frame_number = 0
1494
+ for data in zip(*[anim.new_saved_frame_seq() for anim in all_anim]):
1495
+ for anim, datum in zip(all_anim, data):
1496
+ anim._draw_next_frame(datum, blit=False)
1497
+ if progress_callback is not None:
1498
+ progress_callback(frame_number, total_frames)
1499
+ frame_number += 1
1500
+ writer.grab_frame(**savefig_kwargs)
1381
1501
 
1382
1502
  # -- HTML ------------------------------------------------------------------------
1383
1503
 
@@ -1557,10 +1677,30 @@ class TimedAnimation(Animation):
1557
1677
 
1558
1678
  ``interval`` (milliseconds per frame) is what :meth:`Animation.save` turns into ``fps``
1559
1679
  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.
1680
+ followed by a bare ``save()`` produces a 50 fps file.
1681
+
1682
+ It is also what makes live on-screen playback possible, and *controllable*: when ``fig``
1683
+ is a GLPlot figure (no real matplotlib canvas), :meth:`_start_live_playback` hands this
1684
+ animation's frames to ``fig.active_panel.timeline`` -- the same playhead the GUI's
1685
+ Timeline panel already has Play/Pause/Stop buttons and a scrub bar for -- instead of
1686
+ running its own independent clock. ``plt.show()`` after building a ``FuncAnimation``
1687
+ plays it, and if the Timeline panel is open, its transport controls drive this animation
1688
+ exactly as they drive a hand-keyed one. See :meth:`_start_live_playback`.
1562
1689
  """
1563
1690
 
1691
+ #: A frame sequence at most this long is materialised into a concrete list so the
1692
+ #: Timeline's scrub bar can jump to any frame directly. Past this (or for an unbounded
1693
+ #: ``frames=None`` generator), only forward playback is exact -- see
1694
+ #: :meth:`_materialize_frames`.
1695
+ _MAX_SEEKABLE_FRAMES = 100_000
1696
+
1697
+ #: Duration assigned to the panel's timeline when the frame count is unknowable
1698
+ #: (``frames=None``) or too large to materialise. Arbitrary but not consequential: it
1699
+ #: only sets how far the scrub bar's grid extends before it loops, not how long the
1700
+ #: animation itself runs, since :meth:`_start_live_playback`'s callback keeps stepping
1701
+ #: forward regardless of where in that grid the playhead sits.
1702
+ _UNKNOWN_DURATION_SECONDS = 60.0
1703
+
1564
1704
  def __init__(
1565
1705
  self,
1566
1706
  fig: Any,
@@ -1576,16 +1716,140 @@ class TimedAnimation(Animation):
1576
1716
  self._repeat_delay = repeat_delay if repeat_delay is not None else 0
1577
1717
  self._repeat = repeat
1578
1718
  self._paused = False
1719
+ self._live_timeline: Any = None
1579
1720
  if event_source is None:
1580
1721
  canvas = _canvas_for(fig)
1581
1722
  event_source = canvas.new_timer(interval=self._interval)
1582
1723
  super().__init__(fig, event_source=event_source, *args, **kwargs)
1724
+ self._start_live_playback(fig)
1583
1725
 
1584
1726
  @property
1585
1727
  def repeat(self) -> bool:
1586
1728
  """Whether the animation loops. Read-only, as in matplotlib."""
1587
1729
  return self._repeat
1588
1730
 
1731
+ def pause(self) -> None:
1732
+ """Pause the animation.
1733
+
1734
+ Proxies to ``fig.active_panel.timeline.pause()`` when live playback is bound (see
1735
+ :meth:`_start_live_playback`), so this has the same effect as pressing Pause in the
1736
+ Timeline panel. Otherwise a documented no-op, as it always was; see the module
1737
+ docstring.
1738
+ """
1739
+ self._paused = True
1740
+ if self._live_timeline is not None:
1741
+ self._live_timeline.pause()
1742
+ if self.event_source is not None:
1743
+ with contextlib.suppress(Exception):
1744
+ self.event_source.stop()
1745
+
1746
+ def resume(self) -> None:
1747
+ """Resume the animation. See :meth:`pause`; proxies to ``timeline.play()`` likewise."""
1748
+ self._paused = False
1749
+ if self._live_timeline is not None:
1750
+ self._live_timeline.play()
1751
+ if self.event_source is not None:
1752
+ with contextlib.suppress(Exception):
1753
+ self.event_source.start()
1754
+
1755
+ def _materialize_frames(self) -> Optional[List[Any]]:
1756
+ """This animation's frame data as a concrete, randomly-indexable list, or ``None``.
1757
+
1758
+ ``None`` means the Timeline can only step forward through the sequence (an
1759
+ unbounded ``frames=None`` generator, or a declared length past
1760
+ :data:`_MAX_SEEKABLE_FRAMES`) -- scrubbing backward then restarts it from frame 0
1761
+ rather than landing on the exact frame, since an arbitrary generator cannot be
1762
+ rewound. Every other case -- :class:`ArtistAnimation` (already a concrete list),
1763
+ an integer or sized ``frames=``, an explicit ``save_count`` -- seeks exactly,
1764
+ because the whole sequence is known and cheap to hold: it is usually the caller's
1765
+ own list or array already sitting in memory, and ``new_frame_seq()`` just re-reads
1766
+ it rather than duplicating anything expensive.
1767
+ """
1768
+ existing = getattr(self, "_framedata", None)
1769
+ if isinstance(existing, (list, tuple)):
1770
+ return list(existing)
1771
+ save_count = getattr(self, "_save_count", None)
1772
+ if save_count is not None and 0 < save_count <= self._MAX_SEEKABLE_FRAMES:
1773
+ return list(itertools.islice(self.new_frame_seq(), save_count))
1774
+ return None
1775
+
1776
+ def _start_live_playback(self, fig: Any) -> None:
1777
+ """Bind this animation's frames to ``fig.active_panel.timeline``'s playhead.
1778
+
1779
+ A real matplotlib ``Figure`` already has a toolkit timer driving it -- ``self._canvas``
1780
+ is the real canvas in that case (:func:`_canvas_for`), so this does nothing and that
1781
+ timer keeps doing its job unmodified. Only a GLPlot figure (``_NullCanvas``) with a
1782
+ panel to bind to gets this treatment.
1783
+
1784
+ The bound timeline is configured to this animation's own cadence (``fps = 1000 /
1785
+ interval``, ``duration`` from the frame count or :data:`_UNKNOWN_DURATION_SECONDS`
1786
+ when that is not knowable, ``loop`` from ``repeat``) and started playing -- an
1787
+ animation you just built plays immediately, same as before. From there the engine's
1788
+ own per-frame hook (``fig.add_frame_callback``, invoked *after*
1789
+ ``GPULinePlot._advance_timelines`` already moved the playhead this frame -- see
1790
+ ``engine.py``) does one thing every frame: compute which frame the playhead's
1791
+ *current* time corresponds to, and draw it if that differs from what is already on
1792
+ screen. It does not advance the clock itself. That is deliberate: Play/Pause only
1793
+ has to flip ``timeline.playing`` (which gates whether the playhead moves at all) and
1794
+ Stop/seek/step only have to move ``timeline.time`` directly (which the GUI's
1795
+ transport buttons already do) for this animation to follow suit exactly the way a
1796
+ hand-keyed one does -- there is no separate "is it playing" state here to keep in
1797
+ sync with the Timeline panel's.
1798
+ """
1799
+ register = getattr(fig, "add_frame_callback", None)
1800
+ panel = getattr(fig, "active_panel", None)
1801
+ timeline = getattr(panel, "timeline", None)
1802
+ if not callable(register) or not isinstance(self._canvas, _NullCanvas) or timeline is None:
1803
+ return
1804
+
1805
+ frames = self._materialize_frames()
1806
+ interval_s = max(float(self._interval), 1.0) / MS_PER_SECOND
1807
+ duration = (
1808
+ max(len(frames) - 1, 0) * interval_s if frames else self._UNKNOWN_DURATION_SECONDS
1809
+ )
1810
+ timeline.set_fps(1.0 / interval_s)
1811
+ if timeline.is_empty():
1812
+ # No hand-authored keyframes here yet -- the overwhelmingly common case for a
1813
+ # freshly-built animation -- so this animation may as well own the grid exactly:
1814
+ # duration matches its real length, and the scrub bar has nothing past the last
1815
+ # frame to run into. A timeline that already has keyframes on it keeps its own
1816
+ # length (never shrinks) rather than have this animation truncate them.
1817
+ timeline.set_duration(duration)
1818
+ else:
1819
+ timeline.fit_duration(minimum=duration)
1820
+ timeline.set_loop("loop" if self._repeat else "once")
1821
+ timeline.play()
1822
+ self._live_timeline = timeline
1823
+ register_timeline = getattr(fig, "register_live_animation_timeline", None)
1824
+ if callable(register_timeline):
1825
+ register_timeline(timeline)
1826
+
1827
+ live_seq = {"iter": self.frame_seq}
1828
+ state = {"last_index": None}
1829
+
1830
+ def _tick(now: float) -> None:
1831
+ index = timeline.frame_index()
1832
+ if index == state["last_index"]:
1833
+ return
1834
+ if frames is not None:
1835
+ framedata = frames[min(index, len(frames) - 1)]
1836
+ else:
1837
+ if state["last_index"] is not None and index < state["last_index"]:
1838
+ live_seq["iter"] = self.new_frame_seq() # scrubbed back / looped: restart
1839
+ try:
1840
+ framedata = next(live_seq["iter"])
1841
+ except StopIteration:
1842
+ if not self._repeat:
1843
+ fig.remove_frame_callback(_tick)
1844
+ return
1845
+ live_seq["iter"] = self.new_frame_seq()
1846
+ framedata = next(live_seq["iter"])
1847
+ state["last_index"] = index
1848
+ self._draw_was_started = True
1849
+ self._draw_next_frame(framedata, blit=False)
1850
+
1851
+ register(_tick)
1852
+
1589
1853
 
1590
1854
  class FuncAnimation(TimedAnimation):
1591
1855
  """Animation built by repeatedly calling *func*. The class almost everyone uses.