glplot 0.1.10__tar.gz → 0.1.11__tar.gz

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