glplot 0.1.0__py3-none-any.whl

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/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ from .engine import GPULinePlot
2
+ from .options import EngineOptions, RenderMode, BlendMode
3
+
4
+ __version__ = "0.1.0"
5
+
6
+ __all__ = ["GPULinePlot", "EngineOptions", "RenderMode", "BlendMode"]
glplot/backend.py ADDED
@@ -0,0 +1,15 @@
1
+ """
2
+ Backward compatibility shim for glplot.backend.
3
+ New code should import from glplot or specific modules.
4
+ """
5
+ import warnings
6
+ from .engine import GPULinePlot
7
+ from .options import EngineOptions, RenderMode, BlendMode
8
+
9
+ warnings.warn(
10
+ "Importing from glplot.backend is deprecated. "
11
+ "Please import from 'glplot' directly.",
12
+ DeprecationWarning, stacklevel=2
13
+ )
14
+
15
+ __all__ = ["GPULinePlot", "EngineOptions", "RenderMode", "BlendMode"]
glplot/controllers.py ADDED
@@ -0,0 +1,69 @@
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
+ aspect = max(width, 1) / max(height, 1)
17
+ half_h = padding / self.camera.zoom
18
+ half_w = half_h * aspect
19
+ l = self.camera.cx - half_w
20
+ r = self.camera.cx + half_w
21
+ b = self.camera.cy - half_h
22
+ t = self.camera.cy + half_h
23
+ return l, r, b, t
24
+
25
+ def mvp(self, width: int, height: int, window: Optional[Tuple[float, float, float, float]] = None) -> np.ndarray:
26
+ l, r, b, t = window if window is not None else self.world_window(width, height)
27
+ return ortho(l, r, b, t)
28
+
29
+ def screen_to_world(self, sx: float, sy: float, width: int, height: int) -> Tuple[float, float]:
30
+ l, r, b, t = self.world_window(width, height)
31
+ x = l + (sx / width) * (r - l)
32
+ y = b + ((height - sy) / height) * (t - b)
33
+ return x, y
34
+
35
+ def apply_zoom_at_cursor(self, factor: float, mx: float, my: float, width: int, height: int) -> None:
36
+ wx0, wy0 = self.screen_to_world(mx, my, width, height)
37
+ self.camera.zoom = float(np.clip(self.camera.zoom * factor, self.camera.zoom_min, self.camera.zoom_max))
38
+ wx1, wy1 = self.screen_to_world(mx, my, width, height)
39
+ self.camera.cx += (wx0 - wx1)
40
+ self.camera.cy += (wy0 - wy1)
41
+
42
+ def fit_bounds(self, xmin: float, xmax: float, ymin: float, ymax: float, width: int, height: int) -> None:
43
+ """Calculate best cx, cy, and zoom to fit the given bounds into the current viewport."""
44
+ self.camera.cx = 0.5 * (xmin + xmax)
45
+ self.camera.cy = 0.5 * (ymin + ymax)
46
+
47
+ span_x = max(1e-9, xmax - xmin)
48
+ span_y = max(1e-9, ymax - ymin)
49
+
50
+ aspect = max(width, 1) / max(height, 1)
51
+
52
+ # Calculate zooms required for each axis
53
+ # Zoom = 1.0 / half_h
54
+ # half_h_required = span_y / 2.0
55
+ # half_w_required = span_x / 2.0
56
+
57
+ # In our world_window logic: half_w = (1.0 / zoom) * aspect
58
+ # So zoom_x = aspect / half_w_req = (2.0 * aspect) / span_x
59
+ # zoom_y = 1.0 / half_h_req = 2.0 / span_y
60
+
61
+ zx = (2.0 * aspect) / span_x
62
+ zy = 2.0 / span_y
63
+
64
+ self.camera.zoom = float(np.clip(min(zx, zy), self.camera.zoom_min, self.camera.zoom_max))
65
+
66
+ def reset_view(self) -> None:
67
+ self.camera.cx = 0.0
68
+ self.camera.cy = 0.0
69
+ self.camera.zoom = 1.0
File without changes
glplot/core/context.py ADDED
@@ -0,0 +1,50 @@
1
+ from __future__ import annotations
2
+ from dataclasses import dataclass
3
+ from typing import Tuple, TYPE_CHECKING, Optional
4
+ import numpy as np
5
+
6
+ if TYPE_CHECKING:
7
+ from ..options import EngineOptions, RenderMode
8
+
9
+ @dataclass
10
+ class RenderContext:
11
+ """
12
+ Stable rendering state provided to compilers and renderers.
13
+ Ensures consistent projection and policy across a single frame.
14
+ """
15
+ mvp: np.ndarray # 4x4 Ortho projection matrix
16
+ window_world: Tuple[float, float, float, float] # (l, r, b, t) in world units
17
+
18
+ width_px: int # Screen width
19
+ height_px: int # Screen height
20
+ fb_width: int # Framebuffer width (for multisampling/HighDPI)
21
+ fb_height: int # Framebuffer height
22
+
23
+ mode: RenderMode # EXACT, INTERACTIVE, or PICKING
24
+
25
+ # Passing global settings
26
+ global_alpha: float = 1.0
27
+ lod_keep_prob: float = 1.0
28
+
29
+ # Picking context
30
+ id_offset: int = 0
31
+
32
+ # Time for animated effects (grain, shimmer, etc)
33
+ time: float = 0.0
34
+
35
+ # Device Pixel Ratio for HighDPI / Retina consistency
36
+ dpr: float = 1.0
37
+
38
+ # Orthographic specialization (skip 4x4 matrix multiplication in hot paths)
39
+ ndc_scale: Tuple[float, float] = (1.0, 1.0)
40
+ ndc_offset: Tuple[float, float] = (0.0, 0.0)
41
+
42
+ @property
43
+ def aspect(self) -> float:
44
+ return max(self.width_px, 1) / max(self.height_px, 1)
45
+
46
+ def screen_to_world(self, sx: float, sy: float) -> Tuple[float, float]:
47
+ l, r, b, t = self.window_world
48
+ x = l + (sx / self.width_px) * (r - l)
49
+ y = b + ((self.height_px - sy) / self.height_px) * (t - b)
50
+ return x, y
glplot/core/layers.py ADDED
@@ -0,0 +1,163 @@
1
+ from __future__ import annotations
2
+ from dataclasses import dataclass, field
3
+ from typing import Optional, Tuple, List, Any, Protocol, TYPE_CHECKING
4
+ import numpy as np
5
+ import uuid
6
+
7
+ if TYPE_CHECKING:
8
+ from .context import RenderContext
9
+
10
+ @dataclass
11
+ class LayerStyle:
12
+ """Encapsulates all non-geometric visual properties of a layer."""
13
+ visible: bool = True
14
+ alpha: float = 1.0
15
+ zorder: int = 0
16
+ pickable: bool = False
17
+
18
+ # Colors
19
+ color: Optional[Tuple[float, float, float, float]] = None # Primary (Lines, edges)
20
+ edge_color: Optional[Tuple[float, float, float, float]] = None # Edges for patches
21
+ face_color: Optional[Tuple[float, float, float, float]] = None # Fill for patches
22
+
23
+ # Geometry
24
+ line_width: float = 1.0
25
+ point_size: float = 6.0
26
+
27
+ # Scatter Polish
28
+ point_outline_enabled: bool = False
29
+ point_outline_color: Tuple[float, float, float, float] = (0.0, 0.0, 0.0, 1.0)
30
+ point_outline_width: float = 1.0
31
+
32
+ # Colormapping
33
+ use_colormap: bool = False
34
+ cmap: Optional[str] = None
35
+ vmin: Optional[float] = None
36
+ vmax: Optional[float] = None
37
+
38
+ # Text
39
+ text_size_px: float = 12.0
40
+
41
+ @dataclass
42
+ class LayerDirtyState:
43
+ """Fine-grained invalidation flags to optimize GPU updates."""
44
+ data_dirty: bool = True
45
+ style_dirty: bool = True
46
+ gpu_dirty: bool = True
47
+ bounds_dirty: bool = True
48
+
49
+ def clear(self):
50
+ self.data_dirty = False
51
+ self.style_dirty = False
52
+ self.gpu_dirty = False
53
+ self.bounds_dirty = False
54
+
55
+ class CompiledLayer:
56
+ """GPU-ready geometry and cached bounds."""
57
+ def __init__(self, layer_id: int):
58
+ self.layer_id = layer_id
59
+ self.bounds_world: Optional[Tuple[float, float, float, float]] = None
60
+ self.gpu_initialized: bool = False
61
+
62
+ class BaseLayer:
63
+ """Abstract base for all visual primitives."""
64
+ def __init__(self, layer_type: str, label: str = ""):
65
+ self.layer_id = uuid.uuid4().int & (1<<31)-1
66
+ self.layer_type = layer_type
67
+ self.label = label
68
+ self.style = LayerStyle()
69
+ self.dirty = LayerDirtyState()
70
+ self.bounds_world: Optional[Tuple[float, float, float, float]] = None
71
+ self.translation: Tuple[float, float] = (0.0, 0.0)
72
+ self.metadata: dict[str, Any] = {}
73
+
74
+ def get_intrinsic_bounds(self) -> Optional[Tuple[float, float, float, float]]:
75
+ return None
76
+
77
+ @dataclass
78
+ class LineFamilyLayer(BaseLayer):
79
+ """High-performance layer for millions of lines y = ax + b."""
80
+ ab: Optional[np.ndarray] = None
81
+ colors: Optional[np.ndarray] = None
82
+ x_range: Tuple[float, float] = (-1.0, 1.0)
83
+
84
+ def __init__(self, ab: Optional[np.ndarray] = None, colors: Optional[np.ndarray] = None, x_range: Tuple[float, float] = (-1.0, 1.0), label: str = ""):
85
+ super().__init__(layer_type="line_family", label=label)
86
+ self.ab = ab
87
+ self.colors = colors
88
+ self.x_range = x_range
89
+
90
+ def get_intrinsic_bounds(self) -> Optional[Tuple[float, float, float, float]]:
91
+ if self.ab is None or len(self.ab) == 0: return None
92
+ x0, x1 = self.x_range
93
+ y_at_x0 = self.ab[:, 0] * x0 + self.ab[:, 1]
94
+ y_at_x1 = self.ab[:, 0] * x1 + self.ab[:, 1]
95
+ return (x0, x1, float(min(np.min(y_at_x0), np.min(y_at_x1))), float(max(np.max(y_at_x0), np.max(y_at_x1))))
96
+
97
+ @dataclass
98
+ class ScatterLayer(BaseLayer):
99
+ """Layer for point clouds."""
100
+ pts: Optional[np.ndarray] = None
101
+ colors: Optional[np.ndarray] = None
102
+
103
+ def __init__(self, pts: Optional[np.ndarray] = None, colors: Optional[np.ndarray] = None, size: float = 6.0, label: str = ""):
104
+ super().__init__(layer_type="scatter", label=label)
105
+ self.pts = pts
106
+ self.colors = colors
107
+ self.style.point_size = size
108
+
109
+ def get_intrinsic_bounds(self) -> Optional[Tuple[float, float, float, float]]:
110
+ if self.pts is None or len(self.pts) == 0: return None
111
+ return (float(np.min(self.pts[:, 0])), float(np.max(self.pts[:, 0])),
112
+ float(np.min(self.pts[:, 1])), float(np.max(self.pts[:, 1])))
113
+
114
+ @dataclass
115
+ class PolylineLayer(BaseLayer):
116
+ """Layer for connected line segments (Polyline)."""
117
+ pts: Optional[np.ndarray] = None
118
+
119
+ def __init__(self, pts: Optional[np.ndarray] = None, color: Optional[Tuple[float, float, float, float]] = None, width: float = 1.0, label: str = ""):
120
+ super().__init__(layer_type="polyline", label=label)
121
+ self.pts = pts
122
+ if color: self.style.color = color
123
+ self.style.line_width = width
124
+
125
+ def get_intrinsic_bounds(self) -> Optional[Tuple[float, float, float, float]]:
126
+ if self.pts is None or len(self.pts) == 0: return None
127
+ return (float(np.min(self.pts[:, 0])), float(np.max(self.pts[:, 0])),
128
+ float(np.min(self.pts[:, 1])), float(np.max(self.pts[:, 1])))
129
+
130
+ @dataclass
131
+ class PatchLayer(BaseLayer):
132
+ """Layer for filled areas (tri-strips, bars, rects)."""
133
+ vertices: Optional[np.ndarray] = None # (N, 2)
134
+ indices: Optional[np.ndarray] = None # (M,)
135
+ mode: str = "strip" # "strip", "triangles", "rects"
136
+
137
+ def __init__(self, vertices: Optional[np.ndarray] = None, indices: Optional[np.ndarray] = None, mode: str = "strip", label: str = ""):
138
+ super().__init__(layer_type="patch", label=label)
139
+ self.vertices = vertices
140
+ self.indices = indices
141
+ self.mode = mode
142
+
143
+ def get_intrinsic_bounds(self) -> Optional[Tuple[float, float, float, float]]:
144
+ if self.vertices is None or len(self.vertices) == 0: return None
145
+ return (float(np.min(self.vertices[:, 0])), float(np.max(self.vertices[:, 0])),
146
+ float(np.min(self.vertices[:, 1])), float(np.max(self.vertices[:, 1])))
147
+
148
+ @dataclass
149
+ class TextLayer(BaseLayer):
150
+ """Layer for text labels proyected from world coordinates."""
151
+ x: float = 0.0
152
+ y: float = 0.0
153
+ text: str = ""
154
+
155
+ def __init__(self, x: float = 0.0, y: float = 0.0, text: str = "", label: str = ""):
156
+ super().__init__(layer_type="text", label=label)
157
+ self.x = x
158
+ self.y = y
159
+ self.text = text
160
+
161
+ def get_intrinsic_bounds(self) -> Optional[Tuple[float, float, float, float]]:
162
+ # Text does not participate in autoscale by default
163
+ return None
glplot/core/legacy.py ADDED
@@ -0,0 +1,98 @@
1
+ from __future__ import annotations
2
+ from dataclasses import dataclass, field
3
+ from typing import Optional, Tuple, List, Dict, Any
4
+ import numpy as np
5
+ from .layers import BaseLayer
6
+
7
+
8
+ @dataclass
9
+ class LineDataset:
10
+ ab: Optional[np.ndarray] = None # shape (N, 2)
11
+ colors: Optional[np.ndarray] = None # shape (N, 4)
12
+ x_range: Tuple[float, float] = (-3.0, 3.0)
13
+
14
+ def validate(self) -> None:
15
+ if self.ab is None:
16
+ return
17
+ if self.ab.ndim != 2 or self.ab.shape[1] != 2:
18
+ raise ValueError("ab must have shape (N,2)")
19
+ if self.ab.dtype != np.float32:
20
+ raise ValueError("ab must be float32")
21
+ if self.colors is not None:
22
+ if self.colors.shape != (self.ab.shape[0], 4):
23
+ raise ValueError("colors must have shape (N,4)")
24
+ if self.colors.dtype != np.float32:
25
+ raise ValueError("colors must be float32")
26
+
27
+ @property
28
+ def count(self) -> int:
29
+ return 0 if self.ab is None else int(self.ab.shape[0])
30
+
31
+ @dataclass
32
+ class ScatterDataset:
33
+ pts: Optional[np.ndarray] = None # shape (M,2)
34
+ colors: Optional[np.ndarray] = None # shape (M,4)
35
+ size: float = 5.0
36
+
37
+ @dataclass
38
+ class StripDataset:
39
+ pts: Optional[np.ndarray] = None # shape (K,2)
40
+ color: Tuple[float, float, float, float] = (0.0, 0.0, 0.0, 1.0)
41
+
42
+ @dataclass
43
+ class SceneData:
44
+ layers: List[BaseLayer] = field(default_factory=list)
45
+ lines: LineDataset = field(default_factory=LineDataset)
46
+ scatters: List[ScatterDataset] = field(default_factory=list)
47
+ strips: List[StripDataset] = field(default_factory=list)
48
+ texts: List[Dict[str, Any]] = field(default_factory=list)
49
+
50
+ @dataclass
51
+ class CameraState:
52
+ cx: float = 0.0
53
+ cy: float = 0.0
54
+ zoom: float = 1.0
55
+ zoom_min: float = 0.02
56
+ zoom_max: float = 500.0
57
+
58
+ @dataclass
59
+ class InteractionState:
60
+ drag_active: bool = False
61
+ drag_confirmed: bool = False
62
+ right_drag_active: bool = False
63
+ last_mouse: Tuple[float, float] = (0.0, 0.0)
64
+ press_mouse: Tuple[float, float] = (0.0, 0.0)
65
+ right_press_mouse: Tuple[float, float] = (0.0, 0.0)
66
+ shift_down: bool = False
67
+ ctrl_down: bool = False
68
+ alt_down: bool = False
69
+
70
+ drag_mode: str = "pan" # "pan", "move"
71
+ selected_layer_id: Optional[int] = None
72
+ drag_start_world: Tuple[float, float] = (0.0, 0.0)
73
+ drag_start_translation: Optional[Tuple[float, float]] = None
74
+ explicit_pick_requested: bool = False # Set on Shift+Click; bypasses the shift_down gate
75
+
76
+ hover_idx: int = -1
77
+ hover_type: Optional[str] = None
78
+ selected_idx: Any = -1
79
+ selected_type: Optional[str] = None
80
+
81
+ last_hover_pick_time: float = 0.0
82
+ hover_resume_time: float = 0.0
83
+
84
+ @dataclass
85
+ class CacheState:
86
+ active: bool = False
87
+ capture_window: Optional[Tuple[float, float, float, float]] = None
88
+ refresh_requested: bool = False
89
+ last_capture_time: float = 0.0
90
+ release_deadline: float = 0.0
91
+
92
+ @dataclass
93
+ class FrameState:
94
+ dirty_scene: bool = True
95
+ dirty_ui: bool = True
96
+ dirty_pick: bool = True
97
+ last_frame_time: float = 0.0
98
+ fps_estimate: float = 0.0