glplot 0.1.0__tar.gz → 0.1.1__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 (72) hide show
  1. glplot-0.1.1/.coverage +0 -0
  2. {glplot-0.1.0 → glplot-0.1.1}/PKG-INFO +25 -2
  3. glplot-0.1.1/examples/ex_aspect_ratio_fix.py +57 -0
  4. {glplot-0.1.0 → glplot-0.1.1}/examples/ex_density.py +9 -2
  5. {glplot-0.1.0 → glplot-0.1.1}/examples/ex_mpl_bridge.py +1 -1
  6. {glplot-0.1.0 → glplot-0.1.1}/glplot/__init__.py +1 -1
  7. glplot-0.1.1/glplot/controllers.py +86 -0
  8. {glplot-0.1.0 → glplot-0.1.1}/glplot/core/context.py +1 -0
  9. {glplot-0.1.0 → glplot-0.1.1}/glplot/core/legacy.py +22 -4
  10. {glplot-0.1.0 → glplot-0.1.1}/glplot/engine.py +68 -67
  11. {glplot-0.1.0 → glplot-0.1.1}/glplot/managers/hud.py +11 -1
  12. {glplot-0.1.0 → glplot-0.1.1}/glplot/managers/renderer_manager.py +12 -5
  13. {glplot-0.1.0 → glplot-0.1.1}/glplot/options.py +1 -0
  14. {glplot-0.1.0 → glplot-0.1.1}/glplot/policy.py +5 -1
  15. {glplot-0.1.0 → glplot-0.1.1}/glplot/pyplot.py +19 -4
  16. {glplot-0.1.0 → glplot-0.1.1}/glplot/renderers/density.py +1 -0
  17. {glplot-0.1.0 → glplot-0.1.1}/glplot/renderers/exact.py +4 -1
  18. {glplot-0.1.0 → glplot-0.1.1}/glplot/renderers/line_family.py +4 -1
  19. {glplot-0.1.0 → glplot-0.1.1}/glplot/utils/export.py +1 -0
  20. {glplot-0.1.0 → glplot-0.1.1}/glplot/utils/shaders.py +43 -11
  21. {glplot-0.1.0 → glplot-0.1.1}/imgui.ini +2 -2
  22. glplot-0.1.1/pyproject.toml +65 -0
  23. glplot-0.1.1/scratch/check_import.py +8 -0
  24. glplot-0.1.1/scratch/check_runtime_math.py +40 -0
  25. glplot-0.1.1/scratch/test_asymmetric_projection.py +75 -0
  26. glplot-0.1.1/scratch/test_autoscale_all.py +35 -0
  27. {glplot-0.1.0 → glplot-0.1.1}/tests/test_backend.py +7 -6
  28. glplot-0.1.1/tests/test_camera_anisotropy.py +85 -0
  29. glplot-0.1.0/glplot/controllers.py +0 -69
  30. glplot-0.1.0/old/backend.py +0 -1615
  31. glplot-0.1.0/pyproject.toml +0 -36
  32. {glplot-0.1.0 → glplot-0.1.1}/LICENSE +0 -0
  33. {glplot-0.1.0 → glplot-0.1.1}/README.md +0 -0
  34. {glplot-0.1.0 → glplot-0.1.1}/examples/ex_density_gain.py +0 -0
  35. {glplot-0.1.0 → glplot-0.1.1}/examples/ex_expert_performance.py +0 -0
  36. {glplot-0.1.0 → glplot-0.1.1}/examples/ex_full_showcase.py +0 -0
  37. {glplot-0.1.0 → glplot-0.1.1}/examples/ex_scatter.py +0 -0
  38. {glplot-0.1.0 → glplot-0.1.1}/examples/ex_simple.py +0 -0
  39. {glplot-0.1.0 → glplot-0.1.1}/examples/ex_v2_layers.py +0 -0
  40. {glplot-0.1.0 → glplot-0.1.1}/examples/ex_viewports.py +0 -0
  41. {glplot-0.1.0 → glplot-0.1.1}/examples/verify_polyline_cmap.py +0 -0
  42. {glplot-0.1.0 → glplot-0.1.1}/glplot/backend.py +0 -0
  43. {glplot-0.1.0 → glplot-0.1.1}/glplot/core/__init__.py +0 -0
  44. {glplot-0.1.0 → glplot-0.1.1}/glplot/core/layers.py +0 -0
  45. {glplot-0.1.0 → glplot-0.1.1}/glplot/managers/__init__.py +0 -0
  46. {glplot-0.1.0 → glplot-0.1.1}/glplot/managers/axis.py +0 -0
  47. {glplot-0.1.0 → glplot-0.1.1}/glplot/managers/effects.py +0 -0
  48. {glplot-0.1.0 → glplot-0.1.1}/glplot/managers/hud_state.py +0 -0
  49. {glplot-0.1.0 → glplot-0.1.1}/glplot/managers/picking.py +0 -0
  50. {glplot-0.1.0 → glplot-0.1.1}/glplot/renderers/__init__.py +0 -0
  51. {glplot-0.1.0 → glplot-0.1.1}/glplot/renderers/axis.py +0 -0
  52. {glplot-0.1.0 → glplot-0.1.1}/glplot/renderers/base.py +0 -0
  53. {glplot-0.1.0 → glplot-0.1.1}/glplot/renderers/interaction.py +0 -0
  54. {glplot-0.1.0 → glplot-0.1.1}/glplot/renderers/patch.py +0 -0
  55. {glplot-0.1.0 → glplot-0.1.1}/glplot/renderers/polyline.py +0 -0
  56. {glplot-0.1.0 → glplot-0.1.1}/glplot/renderers/scatter.py +0 -0
  57. {glplot-0.1.0 → glplot-0.1.1}/glplot/renderers/text.py +0 -0
  58. {glplot-0.1.0 → glplot-0.1.1}/glplot/scratch/__init__.py +0 -0
  59. {glplot-0.1.0 → glplot-0.1.1}/glplot/utils/__init__.py +0 -0
  60. {glplot-0.1.0 → glplot-0.1.1}/glplot/utils/gl_utils.py +0 -0
  61. {glplot-0.1.0 → glplot-0.1.1}/glplot/utils/mpl_bridge.py +0 -0
  62. {glplot-0.1.0 → glplot-0.1.1}/scratch/check_gl_limits.py +0 -0
  63. {glplot-0.1.0 → glplot-0.1.1}/scratch/check_imgui.py +0 -0
  64. {glplot-0.1.0 → glplot-0.1.1}/scratch/diag_view.py +0 -0
  65. {glplot-0.1.0 → glplot-0.1.1}/scratch/smoke_test.py +0 -0
  66. {glplot-0.1.0 → glplot-0.1.1}/scratch/test_modular_export.py +0 -0
  67. {glplot-0.1.0 → glplot-0.1.1}/tests/test_pyplot.py +0 -0
  68. {glplot-0.1.0 → glplot-0.1.1}/tests/verify_blending_extension.py +0 -0
  69. {glplot-0.1.0 → glplot-0.1.1}/tests/verify_density.py +0 -0
  70. {glplot-0.1.0 → glplot-0.1.1}/tests/verify_headless.py +0 -0
  71. {glplot-0.1.0 → glplot-0.1.1}/tests/verify_phase4.py +0 -0
  72. {glplot-0.1.0 → glplot-0.1.1}/tests/verify_phase5_density.py +0 -0
glplot-0.1.1/.coverage ADDED
Binary file
@@ -1,9 +1,26 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: glplot
3
- Version: 0.1.0
4
- Summary: High-performance OpenGL plotting library, similar to Matplotlib
3
+ Version: 0.1.1
4
+ Summary: High-performance OpenGL plotting library for Python
5
+ Project-URL: Homepage, https://github.com/AkarisDimitry/GLPlot
6
+ Project-URL: Repository, https://github.com/AkarisDimitry/GLPlot
7
+ Project-URL: Issues, https://github.com/AkarisDimitry/GLPlot/issues
5
8
  Author-email: Juan Manuel Lombardi <lombardi@fhi-berlin.mpg.de>
9
+ License: MIT
6
10
  License-File: LICENSE
11
+ Keywords: gpu,opengl,plotting,scientific plotting,visualization
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: Science/Research
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.9
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Topic :: Scientific/Engineering :: Visualization
23
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
7
24
  Requires-Python: >=3.9
8
25
  Requires-Dist: glfw
9
26
  Requires-Dist: imgui
@@ -12,6 +29,12 @@ Requires-Dist: matplotlib
12
29
  Requires-Dist: numpy
13
30
  Requires-Dist: pyopengl
14
31
  Requires-Dist: scipy
32
+ Provides-Extra: dev
33
+ Requires-Dist: build; extra == 'dev'
34
+ Requires-Dist: pytest; extra == 'dev'
35
+ Requires-Dist: pytest-cov; extra == 'dev'
36
+ Requires-Dist: pytest-mock; extra == 'dev'
37
+ Requires-Dist: twine; extra == 'dev'
15
38
  Provides-Extra: test
16
39
  Requires-Dist: pytest; extra == 'test'
17
40
  Requires-Dist: pytest-cov; extra == 'test'
@@ -0,0 +1,57 @@
1
+ import os
2
+ import sys
3
+ # Force local glplot import
4
+ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
5
+
6
+ import glplot.pyplot as gplt
7
+ import numpy as np
8
+
9
+ def test_aspect_ratio():
10
+ print("Testing aspect ratio fix...")
11
+
12
+ # 1. Create data with extreme ranges
13
+ x = np.linspace(-2, 2, 1000)
14
+ y = 500 * np.sin(x * 10)
15
+
16
+ gplt.figure("Aspect Ratio Test", width=800, height=600)
17
+ gplt.plot(x, y, color='blue', label="Sinusoidal")
18
+
19
+ # 2. Set extreme limits
20
+ gplt.xlim(-2, 2)
21
+ gplt.ylim(-1000, 1000)
22
+
23
+ # 3. Verify limits via API
24
+ lx = gplt.xlim()
25
+ ly = gplt.ylim()
26
+
27
+ print(f"Current X limits: {lx}")
28
+ print(f"Current Y limits: {ly}")
29
+
30
+ expected_x = (-2.0, 2.0)
31
+ expected_y = (-1000.0, 1000.0)
32
+
33
+ # Allow small floating point epsilon
34
+ assert abs(lx[0] - expected_x[0]) < 1e-5
35
+ assert abs(lx[1] - expected_x[1]) < 1e-5
36
+ assert abs(ly[0] - expected_y[0]) < 1e-5
37
+ assert abs(ly[1] - expected_y[1]) < 1e-5
38
+
39
+ print("Success: API returns correct decoupled limits.")
40
+
41
+ # 4. Test Zero Span (Horizontal Line)
42
+ gplt.cla()
43
+ gplt.plot([-10, 10], [5, 5], color='red', label="Horizontal")
44
+ gplt.autoscale()
45
+
46
+ lx_zero = gplt.xlim()
47
+ ly_zero = gplt.ylim()
48
+ print(f"Autoscale on horizontal line: X={lx_zero}, Y={ly_zero}")
49
+
50
+ # Should have a sane Y range despite zero span in data
51
+ assert ly_zero[1] > ly_zero[0]
52
+
53
+ print("Verification complete.")
54
+ # gplt.show() # Uncomment to see visually if running locally
55
+
56
+ if __name__ == "__main__":
57
+ test_aspect_ratio()
@@ -1,11 +1,17 @@
1
1
  import numpy as np
2
+ import os
3
+ import sys
4
+ # Force local glplot import
5
+ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
6
+
2
7
  import glplot.pyplot as gplt
8
+ factor = 1000
3
9
 
4
10
  # Generate 10 million lines y = a*x + b
5
11
  N = 1_000_000
6
12
  print(f"Generating {N} lines. This tests memory throughput and GPU geometry performance.")
7
13
  a = np.random.randn(N) * 2.0
8
- b = np.random.randn(N) * 0.5
14
+ b = np.random.randn(N) * 0.5 * factor
9
15
 
10
16
  # Assign a random color with transparency
11
17
  colors = np.random.rand(N, 4)
@@ -27,7 +33,8 @@ gplt.plot_lines(a, b, x_range=(-5, 5), colors=colors)
27
33
 
28
34
  gplt.text(-4.5, 4.0, "10,000,000 Lines", fontsize=32, color="white")
29
35
  gplt.text(-4.5, 3.5, "Optimized: 0.5x Resolution Scale", fontsize=20, color="gray")
30
-
36
+ gplt.xlim(-5, 5)
37
+ gplt.ylim(-factor, factor)
31
38
  print("\nControls:")
32
39
  print(" [ D ] : Toggle Density/Exact view.")
33
40
  print(" [ UP/DOWN ] : Adjust density gain.")
@@ -54,7 +54,7 @@ def run_bridge_example():
54
54
  ax2.plot(x_mark, np.cos(x_mark), 'ro--', label="MPL Overlay")
55
55
 
56
56
  # Transfer the same view to the second subplot as a background
57
- plot.to_matplotlib(ax=ax2, scale=1.0, alpha=0.5) # Semi-transparent background
57
+ plot.to_matplotlib(ax=ax2, scale=1.0, )#alpha=0.5) # Semi-transparent background
58
58
  ax2.set_title("With Matplotlib Overlays")
59
59
  ax2.legend()
60
60
 
@@ -1,6 +1,6 @@
1
1
  from .engine import GPULinePlot
2
2
  from .options import EngineOptions, RenderMode, BlendMode
3
3
 
4
- __version__ = "0.1.0"
4
+ __version__ = "0.1.1"
5
5
 
6
6
  __all__ = ["GPULinePlot", "EngineOptions", "RenderMode", "BlendMode"]
@@ -0,0 +1,86 @@
1
+ from __future__ import annotations
2
+ from typing import Tuple, Optional, TYPE_CHECKING
3
+ import numpy as np
4
+ from .utils.gl_utils import ortho
5
+
6
+ if TYPE_CHECKING:
7
+ from .core.legacy import CameraState
8
+ from .options import EngineOptions
9
+
10
+ class CameraController:
11
+ def __init__(self, camera: CameraState, options: EngineOptions):
12
+ self.camera = camera
13
+ self.options = options
14
+
15
+ def world_window(self, width: int, height: int, padding: float = 1.0) -> Tuple[float, float, float, float]:
16
+ """
17
+ Returns the world-space bounds (l, r, b, t) of the view,
18
+ potentially expanded by padding for cache utility.
19
+ """
20
+ half_w = padding / max(self.camera.zoom_x, 1e-12)
21
+ half_h = padding / max(self.camera.zoom_y, 1e-12)
22
+
23
+ l = self.camera.cx - half_w
24
+ r = self.camera.cx + half_w
25
+ b = self.camera.cy - half_h
26
+ t = self.camera.cy + half_h
27
+ return l, r, b, t
28
+
29
+ def mvp(self, width: int, height: int, window: Optional[Tuple[float, float, float, float]] = None) -> np.ndarray:
30
+ l, r, b, t = window if window is not None else self.world_window(width, height)
31
+ return ortho(l, r, b, t)
32
+
33
+ def screen_to_world(self, sx: float, sy: float, width: int, height: int) -> Tuple[float, float]:
34
+ l, r, b, t = self.world_window(width, height)
35
+ x = l + (sx / width) * (r - l)
36
+ y = b + ((height - sy) / height) * (t - b)
37
+ return x, y
38
+
39
+ def apply_zoom_at_cursor(self, factor: float, mx: float, my: float, width: int, height: int) -> None:
40
+ """Isotropic zoom centered at cursor position."""
41
+ wx0, wy0 = self.screen_to_world(mx, my, width, height)
42
+
43
+ self.camera.zoom_x = float(np.clip(self.camera.zoom_x * factor, self.camera.zoom_min, self.camera.zoom_max))
44
+ self.camera.zoom_y = float(np.clip(self.camera.zoom_y * factor, self.camera.zoom_min, self.camera.zoom_max))
45
+
46
+ wx1, wy1 = self.screen_to_world(mx, my, width, height)
47
+ self.camera.cx += (wx0 - wx1)
48
+ self.camera.cy += (wy0 - wy1)
49
+
50
+ def fit_bounds(
51
+ self,
52
+ xmin: float, xmax: float,
53
+ ymin: float, ymax: float,
54
+ width: int, height: int,
55
+ axes: str = "both"
56
+ ) -> None:
57
+ """
58
+ Calculates independent zoom_x and zoom_y to fit the requested bounds.
59
+ Handles zero-span degenerate cases by providing a sane default unit span.
60
+ """
61
+ # Handle degenerate spans
62
+ if abs(xmax - xmin) < 1e-9:
63
+ xmin -= 0.5
64
+ xmax += 0.5
65
+ if abs(ymax - ymin) < 1e-9:
66
+ ymin -= 0.5
67
+ ymax += 0.5
68
+
69
+ cx = 0.5 * (xmin + xmax)
70
+ cy = 0.5 * (ymin + ymax)
71
+ span_x = xmax - xmin
72
+ span_y = ymax - ymin
73
+
74
+ if "x" in axes or axes == "both":
75
+ self.camera.cx = float(cx)
76
+ self.camera.zoom_x = float(np.clip(2.0 / span_x, self.camera.zoom_min, self.camera.zoom_max))
77
+
78
+ if "y" in axes or axes == "both":
79
+ self.camera.cy = float(cy)
80
+ self.camera.zoom_y = float(np.clip(2.0 / span_y, self.camera.zoom_min, self.camera.zoom_max))
81
+
82
+ def reset_view(self) -> None:
83
+ self.camera.cx = 0.0
84
+ self.camera.cy = 0.0
85
+ self.camera.zoom_x = 1.0
86
+ self.camera.zoom_y = 1.0
@@ -21,6 +21,7 @@ class RenderContext:
21
21
  fb_height: int # Framebuffer height
22
22
 
23
23
  mode: RenderMode # EXACT, INTERACTIVE, or PICKING
24
+ is_density: bool = False # Flag for density/heatmap accumulation mode
24
25
 
25
26
  # Passing global settings
26
27
  global_alpha: float = 1.0
@@ -51,9 +51,25 @@ class SceneData:
51
51
  class CameraState:
52
52
  cx: float = 0.0
53
53
  cy: float = 0.0
54
- zoom: float = 1.0
55
- zoom_min: float = 0.02
56
- zoom_max: float = 500.0
54
+ zoom_x: float = 1.0
55
+ zoom_y: float = 1.0
56
+ zoom_min: float = 1e-12
57
+ zoom_max: float = 1e12
58
+
59
+ @property
60
+ def zoom(self) -> float:
61
+ """Legacy property: returns the vertical zoom."""
62
+ return self.zoom_y
63
+
64
+ @zoom.setter
65
+ def zoom(self, value: float) -> None:
66
+ """Legacy setter: scales both axes proportionally to preserve anisotropy."""
67
+ if self.zoom_y == 0:
68
+ self.zoom_x = self.zoom_y = float(value)
69
+ else:
70
+ factor = float(value) / self.zoom_y
71
+ self.zoom_x *= factor
72
+ self.zoom_y *= factor
57
73
 
58
74
  @dataclass
59
75
  class InteractionState:
@@ -67,10 +83,12 @@ class InteractionState:
67
83
  ctrl_down: bool = False
68
84
  alt_down: bool = False
69
85
 
70
- drag_mode: str = "pan" # "pan", "move"
86
+ drag_mode: str = "pan" # "pan", "move", "ratio"
71
87
  selected_layer_id: Optional[int] = None
72
88
  drag_start_world: Tuple[float, float] = (0.0, 0.0)
73
89
  drag_start_translation: Optional[Tuple[float, float]] = None
90
+ drag_start_zoom_x: float = 1.0
91
+ drag_start_zoom_y: float = 1.0
74
92
  explicit_pick_requested: bool = False # Set on Shift+Click; bypasses the shift_down gate
75
93
 
76
94
  hover_idx: int = -1
@@ -175,46 +175,33 @@ class GPULinePlot:
175
175
  def set_view(self, xlim: Optional[Tuple[float, float]] = None, ylim: Optional[Tuple[float, float]] = None) -> None:
176
176
  """
177
177
  Sets the world-space view limits, mimicking Matplotlib's xlim/ylim.
178
- Calculates required center and zoom while maintaining the window aspect ratio.
178
+ Allows independent scaling of X and Y axes (unforcing 1:1 data aspect).
179
179
  """
180
180
  if xlim is None and ylim is None:
181
181
  return
182
182
 
183
- # 1. Resolve requested bounds
184
- cur_xlim = self.get_xlim()
185
- cur_ylim = self.get_ylim()
183
+ tx = xlim if xlim is not None else self.get_xlim()
184
+ ty = ylim if ylim is not None else self.get_ylim()
186
185
 
187
- target_x = xlim if xlim is not None else cur_xlim
188
- target_y = ylim if ylim is not None else cur_ylim
189
-
190
- # 2. Calculate world center and required span
191
- cx = (target_x[0] + target_x[1]) * 0.5
192
- cy = (target_y[0] + target_y[1]) * 0.5
193
- span_x = abs(target_x[1] - target_x[0])
194
- span_y = abs(target_y[1] - target_y[0])
195
-
196
- # 3. Fit to aspect ratio
197
- aspect = self.width / max(self.height, 1)
198
- required_zoom_y = 2.0 / max(span_y, 1e-12)
199
- required_zoom_x = (2.0 * aspect) / max(span_x, 1e-12)
200
-
201
- # Use the most restrictive zoom to fit both ranges
202
- new_zoom = min(required_zoom_x, required_zoom_y)
203
-
204
- self.camera.cx = float(cx)
205
- self.camera.cy = float(cy)
206
- self.camera.zoom = float(np.clip(new_zoom, self.camera.zoom_min, self.camera.zoom_max))
186
+ self.camera_controller.fit_bounds(
187
+ tx[0], tx[1],
188
+ ty[0], ty[1],
189
+ self.width, self.height
190
+ )
207
191
 
192
+ # Flush interaction cache on manual view changes
193
+ self.cache.active = False
194
+ self.cache.capture_window = None
208
195
  self.frame.dirty_scene = True
209
196
  self.cache.refresh_requested = True
210
197
 
211
198
  def get_xlim(self) -> Tuple[float, float]:
212
- l, r, _, _ = self.camera_controller.world_window(self.width, self.height)
213
- return float(l), float(r)
199
+ l, r, b, t = self.camera_controller.world_window(self.width, self.height)
200
+ return (float(l), float(r))
214
201
 
215
202
  def get_ylim(self) -> Tuple[float, float]:
216
- _, _, b, t = self.camera_controller.world_window(self.width, self.height)
217
- return float(b), float(t)
203
+ l, r, b, t = self.camera_controller.world_window(self.width, self.height)
204
+ return (float(b), float(t))
218
205
 
219
206
  def set_hud_enabled(self, enabled: bool) -> None:
220
207
  self.options.enable_hud = bool(enabled)
@@ -282,31 +269,6 @@ class GPULinePlot:
282
269
  self.options.enable_cache_interaction_path = False
283
270
  self.frame.dirty_scene = True
284
271
 
285
- def set_view(self, xlim: Optional[Tuple[float, float]] = None, ylim: Optional[Tuple[float, float]] = None) -> None:
286
- """Set the view limits. If both are provided, finds a zoom that fits both."""
287
- if xlim is None and ylim is None:
288
- return
289
-
290
- cur_xlim = xlim or self.get_xlim()
291
- cur_ylim = ylim or self.get_ylim()
292
-
293
- self.camera_controller.fit_bounds(
294
- cur_xlim[0], cur_xlim[1],
295
- cur_ylim[0], cur_ylim[1],
296
- self.width, self.height
297
- )
298
- # Flush interaction cache on manual view changes
299
- self.cache.active = False
300
- self.cache.capture_window = None
301
- self.frame.dirty_scene = True
302
-
303
- def get_xlim(self) -> Tuple[float, float]:
304
- l, r, b, t = self.camera_controller.world_window(self.width, self.height)
305
- return (l, r)
306
-
307
- def get_ylim(self) -> Tuple[float, float]:
308
- l, r, b, t = self.camera_controller.world_window(self.width, self.height)
309
- return (b, t)
310
272
 
311
273
  def _get_all_layers(self) -> List[BaseLayer]:
312
274
  """
@@ -316,26 +278,38 @@ class GPULinePlot:
316
278
  """
317
279
  return list(self.scene.layers)
318
280
 
319
- def autoscale(self) -> None:
320
- """Autoscale view to fit all (legacy and layer) data."""
281
+ def autoscale(self, axes: str = "both", padding: float = 0.05) -> None:
282
+ """
283
+ Autoscale view to fit all (legacy and layer) data.
284
+ Supports axes="x", "y", or "both".
285
+ """
321
286
  layers = self._get_all_layers()
322
287
  bounds = self.renderer_manager.get_bounds(layers)
323
288
 
324
289
  if bounds is None:
325
- self.camera_controller.reset_view()
290
+ if axes == "both":
291
+ self.camera_controller.reset_view()
326
292
  return
327
293
 
328
294
  xmin, xmax, ymin, ymax = bounds
329
- dx = (xmax - xmin) * 0.05
330
- if dx == 0: dx = 1.0
331
- dy = (ymax - ymin) * 0.05
332
- if dy == 0: dy = 1.0
295
+
296
+ # Apply fractional padding
297
+ dx = (xmax - xmin) * padding
298
+ dy = (ymax - ymin) * padding
299
+
300
+ # Sane defaults: only apply a fixed buffer if the original data span is zero
301
+ # to prevent division by zero in camera projection.
302
+ if (xmax - xmin) < 1e-9: dx = 0.5
303
+ if (ymax - ymin) < 1e-9: dy = 0.5
333
304
 
334
305
  self.camera_controller.fit_bounds(
335
306
  xmin - dx, xmax + dx,
336
307
  ymin - dy, ymax + dy,
337
- self.width, self.height
308
+ self.width, self.height,
309
+ axes=axes
338
310
  )
311
+
312
+ self.cache.active = False
339
313
  self.frame.dirty_scene = True
340
314
 
341
315
  def reset_view(self) -> None:
@@ -490,6 +464,7 @@ class GPULinePlot:
490
464
  mode=self.policy.runtime.current_mode,
491
465
  global_alpha=self.options.default_global_alpha,
492
466
  lod_keep_prob=1.0,
467
+ is_density=self.display_density,
493
468
  time=time.perf_counter()
494
469
  )
495
470
 
@@ -555,6 +530,7 @@ class GPULinePlot:
555
530
  mode=self.policy.runtime.current_mode,
556
531
  global_alpha=base_alpha,
557
532
  lod_keep_prob=prob,
533
+ is_density=self.display_density,
558
534
  time=time.perf_counter()
559
535
  )
560
536
 
@@ -634,6 +610,7 @@ class GPULinePlot:
634
610
  mode=RenderMode.INTERACTIVE,
635
611
  global_alpha=base_alpha,
636
612
  lod_keep_prob=prob,
613
+ is_density=self.display_density,
637
614
  time=time.perf_counter()
638
615
  )
639
616
 
@@ -881,7 +858,21 @@ class GPULinePlot:
881
858
  self.interaction.drag_start_world = self.camera_controller.screen_to_world(mx, my, self.width, self.height)
882
859
 
883
860
  # 3. Determine Drag Mode
884
- if (mods & glfw.MOD_CONTROL) or (mods & glfw.MOD_SHIFT):
861
+ if (mods & glfw.MOD_CONTROL):
862
+ # For professional UX, resolve the mode ONCE at the start of the drag
863
+ self._run_picking_pass()
864
+
865
+ if self.interaction.selected_layer_id is not None:
866
+ self.interaction.drag_mode = "move"
867
+ layer = next((l for l in self.scene.layers if l.layer_id == self.interaction.selected_layer_id), None)
868
+ if layer:
869
+ self.interaction.drag_start_translation = layer.translation
870
+ else:
871
+ # Scientific ratio scaling mode
872
+ self.interaction.drag_mode = "ratio"
873
+ self.interaction.drag_start_zoom_x = self.camera.zoom_x
874
+ self.interaction.drag_start_zoom_y = self.camera.zoom_y
875
+ elif (mods & glfw.MOD_SHIFT):
885
876
  self.interaction.drag_mode = "move"
886
877
  if self.interaction.selected_layer_id is not None:
887
878
  layer = next((l for l in self.scene.layers if l.layer_id == self.interaction.selected_layer_id), None)
@@ -933,10 +924,7 @@ class GPULinePlot:
933
924
  # MOVE MODE: Translate the layer
934
925
  layer = next((l for l in self.scene.layers if l.layer_id == self.interaction.selected_layer_id), None)
935
926
  if layer:
936
- # Late capture of start translation if it's the first frame for this layer
937
927
  if self.interaction.drag_start_translation is None:
938
- # We adjust the start world to the current world to prevent a "jump"
939
- # when selection is delayed by one frame
940
928
  self.interaction.drag_start_translation = layer.translation
941
929
  self.interaction.drag_start_world = self.camera_controller.screen_to_world(x, y, self.width, self.height)
942
930
 
@@ -947,9 +935,21 @@ class GPULinePlot:
947
935
  dx = curr_world[0] - start_world[0]
948
936
  dy = curr_world[1] - start_world[1]
949
937
  layer.translation = (start_trans[0] + dx, start_trans[1] + dy)
950
-
951
- # Force cache to redraw so we see it moving
952
938
  self.cache.refresh_requested = True
939
+ elif self.interaction.drag_mode == "ratio":
940
+ # RATIO MODE: Exponential Anisotropic Scaling
941
+ dx = x - px
942
+ dy = y - py
943
+
944
+ # Base-2 Exponential Law (100px = factor of 2.0 change)
945
+ sensitivity = 0.01
946
+ self.camera.zoom_x = self.interaction.drag_start_zoom_x * (2.0 ** (dx * sensitivity))
947
+ self.camera.zoom_y = self.interaction.drag_start_zoom_y * (2.0 ** (-dy * sensitivity))
948
+
949
+ # Clamp to safe camera limits
950
+ self.camera.zoom_x = float(np.clip(self.camera.zoom_x, self.camera.zoom_min, self.camera.zoom_max))
951
+ self.camera.zoom_y = float(np.clip(self.camera.zoom_y, self.camera.zoom_min, self.camera.zoom_max))
952
+ self.cache.refresh_requested = True
953
953
  else:
954
954
  # PAN MODE: Translate the camera
955
955
  lx, ly = self.interaction.last_mouse
@@ -1101,6 +1101,7 @@ class GPULinePlot:
1101
1101
  mode=self.policy.runtime.current_mode,
1102
1102
  global_alpha=alpha,
1103
1103
  lod_keep_prob=prob,
1104
+ is_density=self.display_density,
1104
1105
  time=time.perf_counter()
1105
1106
  )
1106
1107
 
@@ -161,7 +161,11 @@ class HudManager:
161
161
  imgui.text(f"Mouse: ({self.plot.mouse_world[0]:.4f}, {self.plot.mouse_world[1]:.4f}) | ")
162
162
  imgui.same_line()
163
163
 
164
- imgui.text(f"Alpha: {self.options.default_global_alpha:.3f}")
164
+ imgui.text(f"Alpha: {self.options.default_global_alpha:.3f} | ")
165
+ imgui.same_line()
166
+
167
+ mode = self.plot.interaction.drag_mode
168
+ imgui.text_colored(f"Drag: {mode.upper()}", 1.0, 1.0, 0.4)
165
169
 
166
170
  imgui.end()
167
171
 
@@ -327,6 +331,12 @@ class HudManager:
327
331
  self.options.blend_mode = list(BlendMode)[clicked]
328
332
  self.plot.frame.dirty_scene = True
329
333
  imgui.pop_item_width()
334
+
335
+ # Antialiasing Toggle
336
+ changed, aa_en = imgui.checkbox("Antialiasing (SDF)", self.options.enable_antialiasing)
337
+ if changed:
338
+ self.options.enable_antialiasing = aa_en
339
+ self.plot.frame.dirty_scene = True
330
340
 
331
341
  if imgui.collapsing_header("Density Engine", imgui.TREE_NODE_DEFAULT_OPEN)[0]:
332
342
  from ..utils.shaders import DENSITY_SCHEMES
@@ -78,13 +78,20 @@ class RendererManager:
78
78
  sorted_layers = self.filter_layers(layers, LayerCapability.DENSITY)
79
79
 
80
80
  # 1. Prepare the density manager for accumulation
81
+ target = self.plot.density_renderer.accum_target
81
82
  self.plot.density_renderer.begin_accum()
82
83
 
83
- # 2. Accumulate each layer
84
- for layer in sorted_layers:
85
- renderer = self.renderers.get(layer.layer_type)
86
- if renderer and hasattr(renderer, "draw_density"):
87
- renderer.draw_density(layer, context)
84
+ # 2. Accumulate each layer (with context override for the density surface size)
85
+ old_w, old_h = context.fb_width, context.fb_height
86
+ context.fb_width, context.fb_height = target.width, target.height
87
+
88
+ try:
89
+ for layer in sorted_layers:
90
+ renderer = self.renderers.get(layer.layer_type)
91
+ if renderer and hasattr(renderer, "draw_density"):
92
+ renderer.draw_density(layer, context)
93
+ finally:
94
+ context.fb_width, context.fb_height = old_w, old_h
88
95
 
89
96
  # 3. Resolve to the target FBO
90
97
  self.plot.density_renderer.resolve(target_fbo=target_fbo, target_size=target_size)
@@ -86,6 +86,7 @@ class EngineOptions:
86
86
  enable_cache_interaction_path: bool = True
87
87
  enable_clipping_optimization: bool = True
88
88
  enable_multisample: bool = False
89
+ enable_antialiasing: bool = True
89
90
  always_lod: bool = False
90
91
 
91
92
  # Picking policy
@@ -81,9 +81,13 @@ class RenderPolicyManager:
81
81
 
82
82
  # 1. Line Families (Approx coverage: Count * ViewportWidth * Width)
83
83
  if scene.lines.ab is not None:
84
+ from .options import RenderMode
85
+ # Relax budget for density mode as overdraw is the goal
86
+ mode_multiplier = 100.0 if ctx.is_density else 1.0
87
+
84
88
  width = self.options.global_line_width * overrides.line_width_multiplier
85
89
  est = float(len(scene.lines.ab)) * ctx.fb_width * max(1.0, width)
86
- total_est_px2 += est
90
+ total_est_px2 += est / mode_multiplier
87
91
 
88
92
  # 2. Polylines (Approx coverage: Length * Width)
89
93
  for layer in scene.layers:
@@ -521,6 +521,10 @@ def xlim(left: Optional[float] = None, right: Optional[float] = None) -> Optiona
521
521
  if left is None and right is None:
522
522
  return plot.get_xlim()
523
523
 
524
+ # Handle single tuple argument like matplotlib
525
+ if left is not None and right is None and isinstance(left, (tuple, list, np.ndarray)):
526
+ left, right = left[0], left[1]
527
+
524
528
  plot.set_view(xlim=(left, right))
525
529
  _set_dirty(plot)
526
530
  return (left, right)
@@ -534,6 +538,10 @@ def ylim(bottom: Optional[float] = None, top: Optional[float] = None) -> Optiona
534
538
  if bottom is None and top is None:
535
539
  return plot.get_ylim()
536
540
 
541
+ # Handle single tuple argument like matplotlib
542
+ if bottom is not None and top is None and isinstance(bottom, (tuple, list, np.ndarray)):
543
+ bottom, top = bottom[0], bottom[1]
544
+
537
545
  plot.set_view(ylim=(bottom, top))
538
546
  _set_dirty(plot)
539
547
  return (bottom, top)
@@ -572,11 +580,18 @@ def axis(mode: Union[str, Tuple[float, float, float, float]] = "auto") -> Option
572
580
  return (xmin, xmax, ymin, ymax)
573
581
 
574
582
 
575
- def autoscale() -> None:
583
+ def autoscale(enable: bool = True, axis: str = "both", tight: Optional[bool] = None) -> None:
584
+ """
585
+ Autoscale the view to fit data.
586
+ """
576
587
  plot = _get_or_create_plot()
577
- if _call_if_exists(plot, ("autoscale", "auto_view", "fit_view")) is None:
578
- raise AttributeError("Backend does not expose autoscale()/fit_view()")
579
- _set_dirty(plot)
588
+
589
+ # Handle tight parameter
590
+ padding = 0.0 if tight else 0.05
591
+
592
+ if enable:
593
+ plot.autoscale(axes=axis, padding=padding)
594
+ _set_dirty(plot)
580
595
 
581
596
 
582
597
  def reset_view() -> None:
@@ -82,6 +82,7 @@ class DensityRenderer:
82
82
  # DENSITY ALWAYS NEEDS ADDITIVE BLENDING for accumulation
83
83
  glEnable(GL_BLEND)
84
84
  glBlendFunc(GL_ONE, GL_ONE)
85
+ glDisable(GL_DEPTH_TEST)
85
86
 
86
87
  # Handle clipping state if enabled globally
87
88
  if self.options.enable_clipping_optimization: