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.
- glplot-0.1.1/.coverage +0 -0
- {glplot-0.1.0 → glplot-0.1.1}/PKG-INFO +25 -2
- glplot-0.1.1/examples/ex_aspect_ratio_fix.py +57 -0
- {glplot-0.1.0 → glplot-0.1.1}/examples/ex_density.py +9 -2
- {glplot-0.1.0 → glplot-0.1.1}/examples/ex_mpl_bridge.py +1 -1
- {glplot-0.1.0 → glplot-0.1.1}/glplot/__init__.py +1 -1
- glplot-0.1.1/glplot/controllers.py +86 -0
- {glplot-0.1.0 → glplot-0.1.1}/glplot/core/context.py +1 -0
- {glplot-0.1.0 → glplot-0.1.1}/glplot/core/legacy.py +22 -4
- {glplot-0.1.0 → glplot-0.1.1}/glplot/engine.py +68 -67
- {glplot-0.1.0 → glplot-0.1.1}/glplot/managers/hud.py +11 -1
- {glplot-0.1.0 → glplot-0.1.1}/glplot/managers/renderer_manager.py +12 -5
- {glplot-0.1.0 → glplot-0.1.1}/glplot/options.py +1 -0
- {glplot-0.1.0 → glplot-0.1.1}/glplot/policy.py +5 -1
- {glplot-0.1.0 → glplot-0.1.1}/glplot/pyplot.py +19 -4
- {glplot-0.1.0 → glplot-0.1.1}/glplot/renderers/density.py +1 -0
- {glplot-0.1.0 → glplot-0.1.1}/glplot/renderers/exact.py +4 -1
- {glplot-0.1.0 → glplot-0.1.1}/glplot/renderers/line_family.py +4 -1
- {glplot-0.1.0 → glplot-0.1.1}/glplot/utils/export.py +1 -0
- {glplot-0.1.0 → glplot-0.1.1}/glplot/utils/shaders.py +43 -11
- {glplot-0.1.0 → glplot-0.1.1}/imgui.ini +2 -2
- glplot-0.1.1/pyproject.toml +65 -0
- glplot-0.1.1/scratch/check_import.py +8 -0
- glplot-0.1.1/scratch/check_runtime_math.py +40 -0
- glplot-0.1.1/scratch/test_asymmetric_projection.py +75 -0
- glplot-0.1.1/scratch/test_autoscale_all.py +35 -0
- {glplot-0.1.0 → glplot-0.1.1}/tests/test_backend.py +7 -6
- glplot-0.1.1/tests/test_camera_anisotropy.py +85 -0
- glplot-0.1.0/glplot/controllers.py +0 -69
- glplot-0.1.0/old/backend.py +0 -1615
- glplot-0.1.0/pyproject.toml +0 -36
- {glplot-0.1.0 → glplot-0.1.1}/LICENSE +0 -0
- {glplot-0.1.0 → glplot-0.1.1}/README.md +0 -0
- {glplot-0.1.0 → glplot-0.1.1}/examples/ex_density_gain.py +0 -0
- {glplot-0.1.0 → glplot-0.1.1}/examples/ex_expert_performance.py +0 -0
- {glplot-0.1.0 → glplot-0.1.1}/examples/ex_full_showcase.py +0 -0
- {glplot-0.1.0 → glplot-0.1.1}/examples/ex_scatter.py +0 -0
- {glplot-0.1.0 → glplot-0.1.1}/examples/ex_simple.py +0 -0
- {glplot-0.1.0 → glplot-0.1.1}/examples/ex_v2_layers.py +0 -0
- {glplot-0.1.0 → glplot-0.1.1}/examples/ex_viewports.py +0 -0
- {glplot-0.1.0 → glplot-0.1.1}/examples/verify_polyline_cmap.py +0 -0
- {glplot-0.1.0 → glplot-0.1.1}/glplot/backend.py +0 -0
- {glplot-0.1.0 → glplot-0.1.1}/glplot/core/__init__.py +0 -0
- {glplot-0.1.0 → glplot-0.1.1}/glplot/core/layers.py +0 -0
- {glplot-0.1.0 → glplot-0.1.1}/glplot/managers/__init__.py +0 -0
- {glplot-0.1.0 → glplot-0.1.1}/glplot/managers/axis.py +0 -0
- {glplot-0.1.0 → glplot-0.1.1}/glplot/managers/effects.py +0 -0
- {glplot-0.1.0 → glplot-0.1.1}/glplot/managers/hud_state.py +0 -0
- {glplot-0.1.0 → glplot-0.1.1}/glplot/managers/picking.py +0 -0
- {glplot-0.1.0 → glplot-0.1.1}/glplot/renderers/__init__.py +0 -0
- {glplot-0.1.0 → glplot-0.1.1}/glplot/renderers/axis.py +0 -0
- {glplot-0.1.0 → glplot-0.1.1}/glplot/renderers/base.py +0 -0
- {glplot-0.1.0 → glplot-0.1.1}/glplot/renderers/interaction.py +0 -0
- {glplot-0.1.0 → glplot-0.1.1}/glplot/renderers/patch.py +0 -0
- {glplot-0.1.0 → glplot-0.1.1}/glplot/renderers/polyline.py +0 -0
- {glplot-0.1.0 → glplot-0.1.1}/glplot/renderers/scatter.py +0 -0
- {glplot-0.1.0 → glplot-0.1.1}/glplot/renderers/text.py +0 -0
- {glplot-0.1.0 → glplot-0.1.1}/glplot/scratch/__init__.py +0 -0
- {glplot-0.1.0 → glplot-0.1.1}/glplot/utils/__init__.py +0 -0
- {glplot-0.1.0 → glplot-0.1.1}/glplot/utils/gl_utils.py +0 -0
- {glplot-0.1.0 → glplot-0.1.1}/glplot/utils/mpl_bridge.py +0 -0
- {glplot-0.1.0 → glplot-0.1.1}/scratch/check_gl_limits.py +0 -0
- {glplot-0.1.0 → glplot-0.1.1}/scratch/check_imgui.py +0 -0
- {glplot-0.1.0 → glplot-0.1.1}/scratch/diag_view.py +0 -0
- {glplot-0.1.0 → glplot-0.1.1}/scratch/smoke_test.py +0 -0
- {glplot-0.1.0 → glplot-0.1.1}/scratch/test_modular_export.py +0 -0
- {glplot-0.1.0 → glplot-0.1.1}/tests/test_pyplot.py +0 -0
- {glplot-0.1.0 → glplot-0.1.1}/tests/verify_blending_extension.py +0 -0
- {glplot-0.1.0 → glplot-0.1.1}/tests/verify_density.py +0 -0
- {glplot-0.1.0 → glplot-0.1.1}/tests/verify_headless.py +0 -0
- {glplot-0.1.0 → glplot-0.1.1}/tests/verify_phase4.py +0 -0
- {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.
|
|
4
|
-
Summary: High-performance OpenGL plotting library
|
|
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
|
|
|
@@ -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
|
|
@@ -51,9 +51,25 @@ class SceneData:
|
|
|
51
51
|
class CameraState:
|
|
52
52
|
cx: float = 0.0
|
|
53
53
|
cy: float = 0.0
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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
|
-
|
|
184
|
-
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
"""
|
|
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
|
-
|
|
290
|
+
if axes == "both":
|
|
291
|
+
self.camera_controller.reset_view()
|
|
326
292
|
return
|
|
327
293
|
|
|
328
294
|
xmin, xmax, ymin, ymax = bounds
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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)
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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)
|
|
@@ -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
|
-
|
|
578
|
-
|
|
579
|
-
|
|
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:
|