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 +6 -0
- glplot/backend.py +15 -0
- glplot/controllers.py +69 -0
- glplot/core/__init__.py +0 -0
- glplot/core/context.py +50 -0
- glplot/core/layers.py +163 -0
- glplot/core/legacy.py +98 -0
- glplot/engine.py +1270 -0
- glplot/managers/__init__.py +0 -0
- glplot/managers/axis.py +66 -0
- glplot/managers/effects.py +343 -0
- glplot/managers/hud.py +510 -0
- glplot/managers/hud_state.py +95 -0
- glplot/managers/picking.py +174 -0
- glplot/managers/renderer_manager.py +158 -0
- glplot/options.py +120 -0
- glplot/policy.py +108 -0
- glplot/pyplot.py +735 -0
- glplot/renderers/__init__.py +0 -0
- glplot/renderers/axis.py +126 -0
- glplot/renderers/base.py +40 -0
- glplot/renderers/density.py +120 -0
- glplot/renderers/exact.py +215 -0
- glplot/renderers/interaction.py +77 -0
- glplot/renderers/line_family.py +250 -0
- glplot/renderers/patch.py +149 -0
- glplot/renderers/polyline.py +230 -0
- glplot/renderers/scatter.py +185 -0
- glplot/renderers/text.py +72 -0
- glplot/scratch/__init__.py +0 -0
- glplot/utils/__init__.py +0 -0
- glplot/utils/export.py +112 -0
- glplot/utils/gl_utils.py +32 -0
- glplot/utils/mpl_bridge.py +60 -0
- glplot/utils/shaders.py +889 -0
- glplot-0.1.0.dist-info/METADATA +75 -0
- glplot-0.1.0.dist-info/RECORD +39 -0
- glplot-0.1.0.dist-info/WHEEL +4 -0
- glplot-0.1.0.dist-info/licenses/LICENSE +0 -0
glplot/__init__.py
ADDED
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
|
glplot/core/__init__.py
ADDED
|
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
|