glplot 0.1.1__tar.gz → 0.1.2__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 (73) hide show
  1. {glplot-0.1.1 → glplot-0.1.2}/.coverage +0 -0
  2. glplot-0.1.2/.gitignore +1 -0
  3. glplot-0.1.2/.vscode/settings.json +11 -0
  4. glplot-0.1.2/LICENSE +21 -0
  5. {glplot-0.1.1 → glplot-0.1.2}/PKG-INFO +1 -1
  6. glplot-0.1.2/examples/ex_read_density.py +33 -0
  7. {glplot-0.1.1 → glplot-0.1.2}/glplot/controllers.py +62 -3
  8. {glplot-0.1.1 → glplot-0.1.2}/glplot/engine.py +129 -29
  9. {glplot-0.1.1 → glplot-0.1.2}/glplot/managers/effects.py +14 -0
  10. {glplot-0.1.1 → glplot-0.1.2}/glplot/managers/hud.py +90 -37
  11. {glplot-0.1.1 → glplot-0.1.2}/glplot/options.py +6 -1
  12. {glplot-0.1.1 → glplot-0.1.2}/glplot/policy.py +10 -2
  13. {glplot-0.1.1 → glplot-0.1.2}/glplot/renderers/axis.py +51 -7
  14. {glplot-0.1.1 → glplot-0.1.2}/glplot/renderers/density.py +64 -2
  15. {glplot-0.1.1 → glplot-0.1.2}/glplot/renderers/interaction.py +2 -1
  16. {glplot-0.1.1 → glplot-0.1.2}/glplot/utils/mpl_bridge.py +3 -4
  17. {glplot-0.1.1 → glplot-0.1.2}/glplot/utils/shaders.py +210 -5
  18. {glplot-0.1.1 → glplot-0.1.2}/imgui.ini +9 -4
  19. {glplot-0.1.1 → glplot-0.1.2}/pyproject.toml +1 -1
  20. {glplot-0.1.1 → glplot-0.1.2}/tests/test_backend.py +18 -0
  21. glplot-0.1.1/LICENSE +0 -0
  22. {glplot-0.1.1 → glplot-0.1.2}/README.md +0 -0
  23. {glplot-0.1.1 → glplot-0.1.2}/examples/ex_aspect_ratio_fix.py +0 -0
  24. {glplot-0.1.1 → glplot-0.1.2}/examples/ex_density.py +0 -0
  25. {glplot-0.1.1 → glplot-0.1.2}/examples/ex_density_gain.py +0 -0
  26. {glplot-0.1.1 → glplot-0.1.2}/examples/ex_expert_performance.py +0 -0
  27. {glplot-0.1.1 → glplot-0.1.2}/examples/ex_full_showcase.py +0 -0
  28. {glplot-0.1.1 → glplot-0.1.2}/examples/ex_mpl_bridge.py +0 -0
  29. {glplot-0.1.1 → glplot-0.1.2}/examples/ex_scatter.py +0 -0
  30. {glplot-0.1.1 → glplot-0.1.2}/examples/ex_simple.py +0 -0
  31. {glplot-0.1.1 → glplot-0.1.2}/examples/ex_v2_layers.py +0 -0
  32. {glplot-0.1.1 → glplot-0.1.2}/examples/ex_viewports.py +0 -0
  33. {glplot-0.1.1 → glplot-0.1.2}/examples/verify_polyline_cmap.py +0 -0
  34. {glplot-0.1.1 → glplot-0.1.2}/glplot/__init__.py +0 -0
  35. {glplot-0.1.1 → glplot-0.1.2}/glplot/backend.py +0 -0
  36. {glplot-0.1.1 → glplot-0.1.2}/glplot/core/__init__.py +0 -0
  37. {glplot-0.1.1 → glplot-0.1.2}/glplot/core/context.py +0 -0
  38. {glplot-0.1.1 → glplot-0.1.2}/glplot/core/layers.py +0 -0
  39. {glplot-0.1.1 → glplot-0.1.2}/glplot/core/legacy.py +0 -0
  40. {glplot-0.1.1 → glplot-0.1.2}/glplot/managers/__init__.py +0 -0
  41. {glplot-0.1.1 → glplot-0.1.2}/glplot/managers/axis.py +0 -0
  42. {glplot-0.1.1 → glplot-0.1.2}/glplot/managers/hud_state.py +0 -0
  43. {glplot-0.1.1 → glplot-0.1.2}/glplot/managers/picking.py +0 -0
  44. {glplot-0.1.1 → glplot-0.1.2}/glplot/managers/renderer_manager.py +0 -0
  45. {glplot-0.1.1 → glplot-0.1.2}/glplot/pyplot.py +0 -0
  46. {glplot-0.1.1 → glplot-0.1.2}/glplot/renderers/__init__.py +0 -0
  47. {glplot-0.1.1 → glplot-0.1.2}/glplot/renderers/base.py +0 -0
  48. {glplot-0.1.1 → glplot-0.1.2}/glplot/renderers/exact.py +0 -0
  49. {glplot-0.1.1 → glplot-0.1.2}/glplot/renderers/line_family.py +0 -0
  50. {glplot-0.1.1 → glplot-0.1.2}/glplot/renderers/patch.py +0 -0
  51. {glplot-0.1.1 → glplot-0.1.2}/glplot/renderers/polyline.py +0 -0
  52. {glplot-0.1.1 → glplot-0.1.2}/glplot/renderers/scatter.py +0 -0
  53. {glplot-0.1.1 → glplot-0.1.2}/glplot/renderers/text.py +0 -0
  54. {glplot-0.1.1 → glplot-0.1.2}/glplot/scratch/__init__.py +0 -0
  55. {glplot-0.1.1 → glplot-0.1.2}/glplot/utils/__init__.py +0 -0
  56. {glplot-0.1.1 → glplot-0.1.2}/glplot/utils/export.py +0 -0
  57. {glplot-0.1.1 → glplot-0.1.2}/glplot/utils/gl_utils.py +0 -0
  58. {glplot-0.1.1 → glplot-0.1.2}/scratch/check_gl_limits.py +0 -0
  59. {glplot-0.1.1 → glplot-0.1.2}/scratch/check_imgui.py +0 -0
  60. {glplot-0.1.1 → glplot-0.1.2}/scratch/check_import.py +0 -0
  61. {glplot-0.1.1 → glplot-0.1.2}/scratch/check_runtime_math.py +0 -0
  62. {glplot-0.1.1 → glplot-0.1.2}/scratch/diag_view.py +0 -0
  63. {glplot-0.1.1 → glplot-0.1.2}/scratch/smoke_test.py +0 -0
  64. {glplot-0.1.1 → glplot-0.1.2}/scratch/test_asymmetric_projection.py +0 -0
  65. {glplot-0.1.1 → glplot-0.1.2}/scratch/test_autoscale_all.py +0 -0
  66. {glplot-0.1.1 → glplot-0.1.2}/scratch/test_modular_export.py +0 -0
  67. {glplot-0.1.1 → glplot-0.1.2}/tests/test_camera_anisotropy.py +0 -0
  68. {glplot-0.1.1 → glplot-0.1.2}/tests/test_pyplot.py +0 -0
  69. {glplot-0.1.1 → glplot-0.1.2}/tests/verify_blending_extension.py +0 -0
  70. {glplot-0.1.1 → glplot-0.1.2}/tests/verify_density.py +0 -0
  71. {glplot-0.1.1 → glplot-0.1.2}/tests/verify_headless.py +0 -0
  72. {glplot-0.1.1 → glplot-0.1.2}/tests/verify_phase4.py +0 -0
  73. {glplot-0.1.1 → glplot-0.1.2}/tests/verify_phase5_density.py +0 -0
Binary file
@@ -0,0 +1 @@
1
+ .DS_Store
@@ -0,0 +1,11 @@
1
+ {
2
+ "python.testing.unittestArgs": [
3
+ "-v",
4
+ "-s",
5
+ "./tests",
6
+ "-p",
7
+ "test*.py"
8
+ ],
9
+ "python.testing.pytestEnabled": false,
10
+ "python.testing.unittestEnabled": true
11
+ }
glplot-0.1.2/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Juan Manuel Lombardi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: glplot
3
- Version: 0.1.1
3
+ Version: 0.1.2
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
@@ -0,0 +1,33 @@
1
+ import numpy as np
2
+ import glplot.pyplot as gplt
3
+
4
+ def demo_read_density():
5
+ n = 500000
6
+ a = np.random.randn(n) * 0.15
7
+ b = np.random.randn(n) * 0.15
8
+
9
+ # Enable density mode
10
+ gplt.figure("Read Density Example", density=True, hud=True)
11
+ gplt.lines(a, b, x_range=(-1, 1), color='cyan')
12
+
13
+ # We retrieve the GPULinePlot engine instance
14
+ plot = gplt.gcf()
15
+
16
+ # Force a single frame draw so densities are accumulated on the GPU before readback.
17
+ plot._draw_exact_view()
18
+
19
+ # Retrieve the densities as a 2D numpy array
20
+ densities = plot.get_density_array()
21
+
22
+ print("\n--- Density Array Extracted via API ---")
23
+ print(f"Array shape: {densities.shape} (height, width)")
24
+ print(f"Data type: {densities.dtype}")
25
+ print(f"Min density: {densities.min()}")
26
+ print(f"Max density: {densities.max()}")
27
+ print(f"Mean density: {densities.mean()}")
28
+ print("----------------------------------------\n")
29
+
30
+ gplt.show()
31
+
32
+ if __name__ == "__main__":
33
+ demo_read_density()
@@ -28,12 +28,71 @@ class CameraController:
28
28
 
29
29
  def mvp(self, width: int, height: int, window: Optional[Tuple[float, float, float, float]] = None) -> np.ndarray:
30
30
  l, r, b, t = window if window is not None else self.world_window(width, height)
31
- return ortho(l, r, b, t)
31
+
32
+ # Apply inset margins for axes and labels visibility
33
+ margin_l = 60.0
34
+ margin_r = 20.0
35
+ margin_b = 40.0
36
+ margin_t = 20.0
37
+
38
+ rl, tb = (r - l), (t - b)
39
+
40
+ # Calculate NDC scale and translation for margins
41
+ w_px = max(width, 1e-12)
42
+ h_px = max(height, 1e-12)
43
+ sx = (width - margin_l - margin_r) / w_px
44
+ tx = (margin_l - margin_r) / w_px
45
+ sy = (height - margin_t - margin_b) / h_px
46
+ ty = (margin_b - margin_t) / h_px
47
+
48
+ # Standard ortho values
49
+ m00 = 2.0 / max(rl, 1e-12)
50
+ m03 = -(r + l) / max(rl, 1e-12)
51
+ m11 = 2.0 / max(tb, 1e-12)
52
+ m13 = -(t + b) / max(tb, 1e-12)
53
+
54
+ # Apply transformation
55
+ r00 = sx * m00
56
+ r03 = sx * m03 + tx
57
+ r11 = sy * m11
58
+ r13 = sy * m13 + ty
59
+
60
+ return np.array([
61
+ [r00, 0.0, 0.0, r03],
62
+ [0.0, r11, 0.0, r13],
63
+ [0.0, 0.0, -1.0, 0.0],
64
+ [0.0, 0.0, 0.0, 1.0]
65
+ ], dtype=np.float32)
32
66
 
33
67
  def screen_to_world(self, sx: float, sy: float, width: int, height: int) -> Tuple[float, float]:
34
68
  l, r, b, t = self.world_window(width, height)
35
- x = l + (sx / width) * (r - l)
36
- y = b + ((height - sy) / height) * (t - b)
69
+
70
+ # Apply inset margins for axes and labels visibility
71
+ margin_l = 60.0
72
+ margin_r = 20.0
73
+ margin_b = 40.0
74
+ margin_t = 20.0
75
+
76
+ w_px = max(width, 1e-12)
77
+ h_px = max(height, 1e-12)
78
+
79
+ # Scale and translation in NDC space
80
+ scale_x = (width - margin_l - margin_r) / w_px
81
+ trans_x = (margin_l - margin_r) / w_px
82
+ scale_y = (height - margin_t - margin_b) / h_px
83
+ trans_y = (margin_b - margin_t) / h_px
84
+
85
+ # Screen to NDC inset
86
+ ndc_x_inset = (sx / w_px) * 2.0 - 1.0
87
+ ndc_y_inset = ((height - sy) / h_px) * 2.0 - 1.0
88
+
89
+ # NDC inset to standard NDC
90
+ ndc_x = (ndc_x_inset - trans_x) / max(scale_x, 1e-12)
91
+ ndc_y = (ndc_y_inset - trans_y) / max(scale_y, 1e-12)
92
+
93
+ # Standard NDC to world
94
+ x = l + (ndc_x + 1.0) * 0.5 * (r - l)
95
+ y = b + (ndc_y + 1.0) * 0.5 * (t - b)
37
96
  return x, y
38
97
 
39
98
  def apply_zoom_at_cursor(self, factor: float, mx: float, my: float, width: int, height: int) -> None:
@@ -64,6 +64,7 @@ class GPULinePlot:
64
64
 
65
65
  self.effects = EffectManager(self)
66
66
  self._shim_cache: Dict[str, BaseLayer] = {}
67
+ self._apply_background_mode()
67
68
 
68
69
  # --------------------------------------------------------
69
70
  # Public API
@@ -353,6 +354,15 @@ class GPULinePlot:
353
354
  fname = filename or f"plot_{int(time.time())}.png"
354
355
  self.savefig(fname, scale=scale)
355
356
 
357
+ def get_density_array(self) -> np.ndarray:
358
+ """
359
+ Read back the accumulated density values from the GPU framebuffer texture
360
+ and return them as a 2D numpy array of shape (height, width).
361
+ """
362
+ if self.window:
363
+ glfw.make_context_current(self.window)
364
+ return self.density_renderer.get_density_array()
365
+
356
366
  # --------------------------------------------------------
357
367
  # Init
358
368
  # --------------------------------------------------------
@@ -419,7 +429,29 @@ class GPULinePlot:
419
429
  # --------------------------------------------------------
420
430
 
421
431
  def _update_runtime_policy(self) -> None:
432
+ prev_mode = self.policy.runtime.current_mode
422
433
  self.policy.update(self.scene, self.interaction, self.cache)
434
+ if prev_mode == RenderMode.INTERACTIVE and self.policy.runtime.current_mode == RenderMode.EXACT:
435
+ self.frame.dirty_scene = True
436
+ if self.hud.state.show_profiler:
437
+ self.policy.runtime.hud_enabled_this_frame = True
438
+
439
+ def _apply_background_mode(self) -> None:
440
+ """Apply style overrides for background, axes, and grid based on light_bg_mode."""
441
+ if getattr(self.options, "light_bg_mode", False):
442
+ # Light BG mode
443
+ self.options.visual.background_color = (1.0, 1.0, 1.0)
444
+ self.options.visual.gradient_background.enabled = True
445
+ self.options.visual.gradient_background.top_color = (1.0, 1.0, 1.0)
446
+ self.options.visual.gradient_background.bottom_color = (0.95, 0.95, 0.95)
447
+ self.options.axis_grid_color = (0.8, 0.8, 0.8)
448
+ else:
449
+ # Dark BG mode
450
+ self.options.visual.background_color = (0.0, 0.0, 0.0)
451
+ self.options.visual.gradient_background.enabled = True
452
+ self.options.visual.gradient_background.top_color = (0.0, 0.0, 0.0)
453
+ self.options.visual.gradient_background.bottom_color = (0.08, 0.08, 0.12)
454
+ self.options.axis_grid_color = (0.2, 0.2, 0.2)
423
455
 
424
456
  def _get_adaptive_alpha(self, count: int) -> float:
425
457
  """
@@ -543,7 +575,8 @@ class GPULinePlot:
543
575
 
544
576
  if self.display_density:
545
577
  # Modular Density Pass (Lines, Scatters)
546
- self.renderer_manager.draw_density(layers, ctx)
578
+ current_fbo = glGetIntegerv(GL_FRAMEBUFFER_BINDING)
579
+ self.renderer_manager.draw_density(layers, ctx, target_fbo=current_fbo)
547
580
  else:
548
581
  # Standard Pass
549
582
  self.renderer_manager.draw_exact(layers, ctx)
@@ -563,7 +596,8 @@ class GPULinePlot:
563
596
 
564
597
  current_window = self.camera_controller.world_window(self.width, self.height)
565
598
  if self.options.enable_cache_interaction_path and self.cache.capture_window is not None:
566
- self.interaction_renderer.draw_cached_impostor(self.cache.capture_window, current_window)
599
+ current_fbo = glGetIntegerv(GL_FRAMEBUFFER_BINDING)
600
+ self.interaction_renderer.draw_cached_impostor(self.cache.capture_window, current_window, target_fbo=current_fbo)
567
601
  else:
568
602
  self._draw_exact_view()
569
603
 
@@ -662,11 +696,34 @@ class GPULinePlot:
662
696
  glfw.swap_interval(1)
663
697
 
664
698
  while not glfw.window_should_close(self.window):
665
- glfw.poll_events()
699
+ if self.options.reactive_rendering:
700
+ glfw.wait_events_timeout(0.02)
701
+ else:
702
+ glfw.poll_events()
666
703
 
667
704
  # 1. Update Input and State
668
705
  self.hud.process_inputs()
669
706
  self._update_runtime_policy()
707
+ self._apply_background_mode()
708
+
709
+ # Check cache release deadline
710
+ t_now = glfw.get_time()
711
+ if self.cache.active and not self.interaction.drag_active and not self.interaction.right_drag_active and t_now >= self.cache.release_deadline:
712
+ self.cache.active = False
713
+ self.frame.dirty_scene = True
714
+
715
+ need_render = (
716
+ not self.options.reactive_rendering or
717
+ self.frame.dirty_scene or
718
+ self.frame.dirty_ui or
719
+ self.frame.dirty_pick or
720
+ self.interaction.drag_active or
721
+ self.interaction.right_drag_active or
722
+ self.hud.state.show_profiler
723
+ )
724
+
725
+ if not need_render:
726
+ continue
670
727
 
671
728
  # 2. Start ImGui frame before ANY rendering/processing happens
672
729
  self.hud.begin()
@@ -687,23 +744,28 @@ class GPULinePlot:
687
744
 
688
745
  t0 = glfw.get_time()
689
746
 
690
- self.effects.begin_scene()
747
+ t_scene_start = time.perf_counter()
748
+ if self.frame.dirty_scene or not self.effects.any_post_enabled():
749
+ self.effects.begin_scene()
691
750
 
692
- self.effects.draw_background()
693
- self._apply_blending_policy()
751
+ self.effects.draw_background()
752
+ self._apply_blending_policy()
694
753
 
695
- if self.policy.runtime.current_mode == RenderMode.INTERACTIVE:
696
- self._draw_interaction_view()
697
- else:
698
- self._draw_exact_view()
754
+ if self.policy.runtime.current_mode == RenderMode.INTERACTIVE:
755
+ self._draw_interaction_view()
756
+ else:
757
+ self._draw_exact_view()
699
758
 
700
- # Draw zoom box if active
701
- if self.interaction.right_drag_active:
702
- if self.options.enable_clipping_optimization:
703
- for i in range(4): glDisable(GL_CLIP_DISTANCE0 + i)
704
- self._draw_zoom_box()
705
-
706
- self.effects.end_scene()
759
+ # Draw zoom box if active
760
+ if self.interaction.right_drag_active:
761
+ if self.options.enable_clipping_optimization:
762
+ for i in range(4): glDisable(GL_CLIP_DISTANCE0 + i)
763
+ self._draw_zoom_box()
764
+
765
+ self.effects.end_scene()
766
+ else:
767
+ self.effects.resolve()
768
+ self.hud.state.gpu_timings["Render Scene"] = time.perf_counter() - t_scene_start
707
769
 
708
770
  # Update HUD metrics and Draw
709
771
  self._service_hud_metrics(t0)
@@ -712,15 +774,43 @@ class GPULinePlot:
712
774
  if self.options.enable_clipping_optimization:
713
775
  for i in range(4): glDisable(GL_CLIP_DISTANCE0 + i)
714
776
 
777
+ t_hud_start = time.perf_counter()
778
+ # Draw Axis Labels (Scale) on every frame so they update dynamically and persist when the scene is cached.
779
+ if self.options.axis_show_labels:
780
+ window_world = self.camera_controller.world_window(self.width, self.height)
781
+ mvp = self.camera_controller.mvp(self.width, self.height)
782
+ ndc_scale, ndc_offset = self._get_ndc_transform(window_world)
783
+ ctx_labels = RenderContext(
784
+ mvp=mvp,
785
+ window_world=window_world,
786
+ ndc_scale=ndc_scale,
787
+ ndc_offset=ndc_offset,
788
+ width_px=self.width,
789
+ height_px=self.height,
790
+ fb_width=self.fb_width,
791
+ fb_height=self.fb_height,
792
+ dpr=self.fb_width / max(self.width, 1),
793
+ mode=self.policy.runtime.current_mode,
794
+ global_alpha=1.0,
795
+ lod_keep_prob=1.0,
796
+ is_density=self.display_density,
797
+ time=time.perf_counter()
798
+ )
799
+ self.axis_manager.update(ctx_labels)
800
+ self.renderer_manager.renderers["axis"]._draw_labels(self.axis_manager, ctx_labels)
801
+
715
802
  # HUD panels are only updated if HUD is enabled, but begin/end must wrap all
716
803
  if self.policy.runtime.hud_enabled_this_frame:
717
804
  self.hud.update()
718
805
 
719
806
  self.hud.end()
807
+ self.hud.state.gpu_timings["UI Panels"] = time.perf_counter() - t_hud_start
720
808
 
721
809
  # Note: GL state is cleaned up/reset at start of next frame or specific renderers
722
810
 
811
+ t_swap_start = time.perf_counter()
723
812
  glfw.swap_buffers(self.window)
813
+ self.hud.state.gpu_timings["Buffer Swap"] = time.perf_counter() - t_swap_start
724
814
  t1 = glfw.get_time()
725
815
 
726
816
  dt = max(t1 - t0, 1e-6)
@@ -729,10 +819,6 @@ class GPULinePlot:
729
819
  self.frame.dirty_scene = False
730
820
  self.frame.dirty_ui = False
731
821
 
732
- if self.cache.active and not self.interaction.drag_active and not self.interaction.right_drag_active and t1 >= self.cache.release_deadline:
733
- self.cache.active = False
734
- self.frame.dirty_scene = True
735
-
736
822
  self.effects.shutdown()
737
823
 
738
824
  def _service_hud_metrics(self, t0: float) -> None:
@@ -834,6 +920,7 @@ class GPULinePlot:
834
920
  self.frame.dirty_pick = True
835
921
 
836
922
  def _on_mouse_button(self, window, button, action, mods) -> None:
923
+ self.frame.dirty_ui = True
837
924
  self.hud.on_mouse_button(window, button, action, mods)
838
925
  if self.hud.wants_mouse():
839
926
  return
@@ -903,14 +990,16 @@ class GPULinePlot:
903
990
  self.frame.dirty_scene = True
904
991
 
905
992
  def _on_cursor(self, window, x, y) -> None:
906
- if self.hud.wants_mouse():
907
- # Still update world coords for status panel
908
- self.mouse_world = self.camera_controller.screen_to_world(x, y, self.width, self.height)
993
+ if (x, y) == self.interaction.last_mouse:
909
994
  return
910
-
995
+
911
996
  self.mouse_world = self.camera_controller.screen_to_world(x, y, self.width, self.height)
912
997
  self.frame.dirty_ui = True
913
998
 
999
+ if self.hud.wants_mouse():
1000
+ self.interaction.last_mouse = (x, y)
1001
+ return
1002
+
914
1003
  if self.interaction.drag_active:
915
1004
  px, py = self.interaction.press_mouse
916
1005
  dist2 = (x - px) ** 2 + (y - py) ** 2
@@ -965,6 +1054,8 @@ class GPULinePlot:
965
1054
  elif self.interaction.right_drag_active:
966
1055
  self.interaction.last_mouse = (x, y)
967
1056
  self.frame.dirty_ui = True
1057
+ else:
1058
+ self.interaction.last_mouse = (x, y)
968
1059
 
969
1060
  def _run_picking_pass(self) -> None:
970
1061
  if not self.interaction.last_mouse:
@@ -1144,11 +1235,11 @@ class GPULinePlot:
1144
1235
  transparent=transparent
1145
1236
  )
1146
1237
 
1147
- def to_matplotlib(self, ax=None, **kwargs):
1238
+ def to_matplotlib(self, ax: Optional[Axes] = None, mpl_kwargs: dict = {}, **kwargs):
1148
1239
  """Level 2 API: Render and embed directly into Matplotlib."""
1149
1240
  from .utils.mpl_bridge import snapshot_to_matplotlib
1150
1241
  snap = self.capture_snapshot(**kwargs)
1151
- return snapshot_to_matplotlib(snap, ax=ax)
1242
+ return snapshot_to_matplotlib(snap, ax=ax, **mpl_kwargs)
1152
1243
 
1153
1244
  def set_matplotlib_transfer_target(self, ax=None, callback=None):
1154
1245
  """Level 3 API Setup: Redirect 'M' key transfers."""
@@ -1173,6 +1264,7 @@ class GPULinePlot:
1173
1264
  self.frame.dirty_scene = True
1174
1265
 
1175
1266
  def _on_key(self, window, key, sc, action, mods) -> None:
1267
+ self.frame.dirty_ui = True
1176
1268
  self.hud.on_key(window, key, sc, action, mods)
1177
1269
 
1178
1270
  if action in (glfw.PRESS, glfw.REPEAT):
@@ -1190,6 +1282,13 @@ class GPULinePlot:
1190
1282
  elif key == glfw.KEY_C and action == glfw.PRESS:
1191
1283
  self.toggle_line_colormap()
1192
1284
 
1285
+ elif key == glfw.KEY_F3 and action == glfw.PRESS:
1286
+ self.hud.state.show_profiler = not self.hud.state.show_profiler
1287
+ if self.hud.state.show_profiler:
1288
+ self.options.enable_hud = True
1289
+ self.frame.dirty_scene = True
1290
+ self.frame.dirty_ui = True
1291
+
1193
1292
  # --- Visual Parameters (Arrows) ---
1194
1293
  if key == glfw.KEY_UP:
1195
1294
  if self.display_density:
@@ -1242,11 +1341,11 @@ class GPULinePlot:
1242
1341
  self.frame.dirty_scene = True
1243
1342
 
1244
1343
  elif key == glfw.KEY_LEFT_BRACKET and action in (glfw.PRESS, glfw.REPEAT):
1245
- self.options.density_log_scale = max(0.1, self.options.density_log_scale - 0.2)
1344
+ self.decrease_density_gain()
1246
1345
  self.frame.dirty_scene = True
1247
1346
 
1248
1347
  elif key == glfw.KEY_RIGHT_BRACKET and action in (glfw.PRESS, glfw.REPEAT):
1249
- self.options.density_log_scale += 0.2
1348
+ self.increase_density_gain()
1250
1349
  self.frame.dirty_scene = True
1251
1350
 
1252
1351
  elif key == glfw.KEY_H and action == glfw.PRESS:
@@ -1268,4 +1367,5 @@ class GPULinePlot:
1268
1367
  self.interaction.shift_down = False
1269
1368
 
1270
1369
  def _on_char(self, window, char) -> None:
1370
+ self.frame.dirty_ui = True
1271
1371
  self.hud.on_char(window, char)
@@ -74,6 +74,8 @@ class EffectManager:
74
74
  # ------------------------------------------------------------------
75
75
 
76
76
  def any_post_enabled(self) -> bool:
77
+ if self.options.reactive_rendering:
78
+ return True
77
79
  v = self.options.visual
78
80
  return v.glow.enabled
79
81
 
@@ -168,6 +170,18 @@ class EffectManager:
168
170
  """
169
171
  Draw the background into whichever framebuffer is currently bound.
170
172
  """
173
+ # Overwrite background color with the colormap's minimum color in density mode
174
+ if getattr(self.plot, "display_density", False):
175
+ scheme_idx = getattr(self.options, "density_scheme_index", 0)
176
+ invert = getattr(self.options, "density_invert", False)
177
+ ltc = getattr(self.options, "density_light_to_color", True)
178
+
179
+ from ..utils.shaders import get_colormap_min_color
180
+ c = get_colormap_min_color(scheme_idx, invert, ltc)
181
+ glClearColor(c[0], c[1], c[2], 1.0)
182
+ glClear(GL_COLOR_BUFFER_BIT)
183
+ return
184
+
171
185
  v = self.options.visual.gradient_background
172
186
 
173
187
  if not v.enabled:
@@ -359,13 +359,43 @@ class HudManager:
359
359
  self.plot.frame.dirty_scene = True
360
360
  imgui.pop_item_width()
361
361
 
362
+ # Light BG Mode
363
+ light_mode = getattr(self.options, "light_bg_mode", False)
364
+ changed_bg, val_bg = imgui.checkbox("Light BG Mode", light_mode)
365
+ if changed_bg:
366
+ self.options.light_bg_mode = val_bg
367
+ self.options.density_invert = val_bg
368
+ self.plot.frame.dirty_scene = True
369
+ self.plot.frame.dirty_ui = True
370
+
371
+ # Invert Colors
372
+ invert_mode = getattr(self.options, "density_invert", False)
373
+ changed_inv, val_inv = imgui.checkbox("Invert Colors", invert_mode)
374
+ if changed_inv:
375
+ self.options.density_invert = val_inv
376
+ self.plot.frame.dirty_scene = True
377
+ self.plot.frame.dirty_ui = True
378
+
379
+ # Light to Color Only option
380
+ is_inverted = getattr(self.options, "density_invert", False)
381
+ label = "Light to Color Only" if is_inverted else "Dark to Color Only"
382
+ light_to_color = getattr(self.options, "density_light_to_color", True)
383
+ changed_ltc, val_ltc = imgui.checkbox(label, light_to_color)
384
+ if changed_ltc:
385
+ self.options.density_light_to_color = val_ltc
386
+ self.plot.frame.dirty_scene = True
387
+ self.plot.frame.dirty_ui = True
388
+
362
389
  # Gain and Scale
363
390
  imgui.push_item_width(180)
364
391
  changed, gain = imgui.drag_float("Gain (Intensity)", self.options.density_gain, 1.0, 0.1, 10000.0, "%.1f")
365
392
  if changed: self.options.density_gain = gain; self.plot.frame.dirty_scene = True
366
393
 
367
- changed, lscale = imgui.drag_float("Log Scale (Contrast)", self.options.density_log_scale, 0.05, 0.1, 20.0, "%.2f")
368
- if changed: self.options.density_log_scale = lscale; self.plot.frame.dirty_scene = True
394
+ is_log = getattr(self.options, "density_is_log", True)
395
+ changed_log, val_log = imgui.checkbox("Logarithmic Scale", is_log)
396
+ if changed_log:
397
+ self.options.density_is_log = val_log
398
+ self.plot.frame.dirty_scene = True
369
399
 
370
400
  changed, res = imgui.slider_float("Inner Resolution", self.options.density_resolution_scale, 0.1, 1.0, "%.2f")
371
401
  if changed: self.controller.set_density_resolution(res)
@@ -378,28 +408,25 @@ class HudManager:
378
408
  pos = imgui.get_cursor_screen_pos()
379
409
  w, h = 220, 18
380
410
 
381
- def get_scheme_col(v):
382
- scheme = self.options.density_scheme_index
383
- if scheme == 1: # Viridis
384
- if v < 0.5: return (0.2+v*0.1, 0.1+v*0.8, 0.4+v*0.2)
385
- return (0.3+(v-0.5)*1.4, 0.5+(v-0.5)*0.8, 0.5-(v-0.5)*0.8)
386
- if scheme == 2: # Plasma
387
- return (0.1+v*1.8, 0.0, 0.5+v*0.5) if v < 0.5 else (1.0, (v-0.5)*2.0, 1.0-v)
388
- if scheme == 3: # Inferno
389
- return (v*0.5, 0.0, v*0.2) if v < 0.5 else (v, (v-0.5)*1.5, (v-0.5)*2.0)
390
- if scheme == 4: # Turbo
391
- if v < 0.33: return (0.2, v*2.5, 0.8)
392
- if v < 0.66: return (v*1.5, 0.8, 0.2)
393
- return (0.9, 0.2, 0.1)
394
- if scheme == 5: # Ink Fire (White BG)
395
- return (1.0, 1.0-(v*0.5), 1.0-(v*1.5)) if v < 0.5 else (1.0-(v-0.5)*2.0, 0.25-(v-0.5)*0.5, 0.0)
396
- if scheme == 6: # Magma
397
- return (v*0.4, 0.1, 0.5) if v < 0.5 else (v, 0.5+(v-0.5), 0.8+(v-0.5)*0.4)
398
- return (v, v, v)
399
-
411
+ from ..utils.shaders import eval_colormap
412
+ is_inverted = getattr(self.options, "density_invert", False)
413
+ ltc = getattr(self.options, "density_light_to_color", True)
414
+ scheme_idx = self.options.density_scheme_index
415
+
400
416
  for i in range(20):
401
417
  v = i / 19.0
402
- c = get_scheme_col(v)
418
+ if is_inverted:
419
+ if ltc:
420
+ norm = 1.0 - 0.75 * v
421
+ else:
422
+ norm = 1.0 - v
423
+ else:
424
+ if ltc:
425
+ norm = 0.75 * v
426
+ else:
427
+ norm = v
428
+
429
+ c = eval_colormap(scheme_idx, norm)
403
430
  color = imgui.get_color_u32_rgba(max(0,min(1,c[0])), max(0,min(1,c[1])), max(0,min(1,c[2])), 1.0)
404
431
  draw_list.add_rect_filled(pos.x + i*(w/20), pos.y, pos.x + (i+1)*(w/20), pos.y + h, color)
405
432
 
@@ -456,23 +483,49 @@ class HudManager:
456
483
  imgui.end()
457
484
 
458
485
  def _draw_profiler_panel(self):
459
- imgui.set_next_window_size(300, 200, imgui.FIRST_USE_EVER)
460
- imgui.begin("Profiler", True)
461
-
462
- if self.state.cpu_frame_times:
463
- avg_ms = sum(self.state.cpu_frame_times) / len(self.state.cpu_frame_times) * 1000.0
464
- imgui.text(f"Avg CPU Frame: {avg_ms:5.2f} ms")
465
-
466
- # Sparkline
467
- import numpy as np
468
- history = np.array(self.state.cpu_frame_times, dtype=np.float32)
469
- imgui.plot_lines("##FPSGraph", history, overlay_text=f"{1000.0/avg_ms:.1f} FPS",
470
- scale_min=0, scale_max=0.033, graph_size=(0, 60))
486
+ imgui.set_next_window_size(350, 420, imgui.FIRST_USE_EVER)
487
+ expanded, opened = imgui.begin("Profiler", True)
488
+ if not opened:
489
+ self.state.show_profiler = False
490
+ imgui.end()
491
+ return
471
492
 
472
- imgui.separator()
473
- for k, v in self.state.gpu_timings.items():
474
- imgui.text(f"{k}: {v*1000.0:5.2f} ms")
493
+ if expanded:
494
+ # 1. Performance Overview
495
+ imgui.text_colored("PERFORMANCE OVERVIEW", 0.3, 0.8, 1.0)
496
+ if self.state.cpu_frame_times:
497
+ avg_ms = sum(self.state.cpu_frame_times) / len(self.state.cpu_frame_times) * 1000.0
498
+ imgui.text(f"Avg CPU Frame: {avg_ms:5.2f} ms ({1000.0/avg_ms:.1f} FPS)")
499
+
500
+ # Sparkline
501
+ import numpy as np
502
+ history = np.array(self.state.cpu_frame_times, dtype=np.float32)
503
+ imgui.plot_lines("##FPSGraph", history, overlay_text="",
504
+ scale_min=0, scale_max=0.033, graph_size=(0, 50))
505
+
506
+ imgui.separator()
507
+
508
+ # 2. Rendering Pipelines Detailed Timings
509
+ imgui.text_colored("SUB-OPERATION TIMINGS", 0.3, 0.8, 1.0)
510
+ for k, v in sorted(self.state.gpu_timings.items()):
511
+ imgui.text(f" - {k}: {v*1000.0:5.2f} ms")
475
512
 
513
+ imgui.separator()
514
+
515
+ # 3. Engine State & Statistics
516
+ imgui.text_colored("ENGINE STATISTICS", 0.3, 0.8, 1.0)
517
+ mode_str = self.plot.policy.runtime.current_mode.name
518
+ imgui.text(f"Render Mode: {mode_str}")
519
+
520
+ sleep_str = "SLEEPING (Reactive)" if self.plot.options.reactive_rendering and not self.plot.hud.state.show_profiler else "ACTIVE Redrawing"
521
+ imgui.text(f"Gating Loop: {sleep_str}")
522
+
523
+ imgui.text(f"Total Lines: {self.plot.scene.lines.count:,}")
524
+
525
+ win = self.plot.camera_controller.world_window(self.plot.width, self.plot.height)
526
+ imgui.text(f"X range: [{win[0]:.2f}, {win[1]:.2f}]")
527
+ imgui.text(f"Y range: [{win[2]:.2f}, {win[3]:.2f}]")
528
+
476
529
  imgui.end()
477
530
 
478
531
  def _draw_selection_panel(self):
@@ -88,6 +88,7 @@ class EngineOptions:
88
88
  enable_multisample: bool = False
89
89
  enable_antialiasing: bool = True
90
90
  always_lod: bool = False
91
+ reactive_rendering: bool = True
91
92
 
92
93
  # Picking policy
93
94
  shift_required_for_picking: bool = True
@@ -99,10 +100,14 @@ class EngineOptions:
99
100
  # Density rendering
100
101
  density_gain: float = 1.0
101
102
  density_resolution_scale: float = 1.0 # 1.0 = full-res, 0.5 = faster
102
- density_scheme_index: int = 0
103
+ density_scheme_index: int = 9
103
104
  density_gain_step: float = 1.25
104
105
  density_log_scale: float = 3.0 # Divisor for log normalization
105
106
  density_weighted: bool = False # Accumulate alpha instead of 1.0
107
+ density_invert: bool = True
108
+ density_is_log: bool = True # Enable logarithmic colormap scaling by default
109
+ light_bg_mode: bool = True
110
+ density_light_to_color: bool = True # Go from white to color (instead of white to color to black)
106
111
 
107
112
  # Style
108
113
  blend_mode: BlendMode = BlendMode.AUTO