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/engine.py
ADDED
|
@@ -0,0 +1,1270 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import math
|
|
3
|
+
import time
|
|
4
|
+
from typing import Optional, Tuple, Any
|
|
5
|
+
import numpy as np
|
|
6
|
+
import glfw
|
|
7
|
+
from OpenGL.GL import *
|
|
8
|
+
|
|
9
|
+
from .options import EngineOptions, RenderMode, BlendMode
|
|
10
|
+
from .policy import RenderPolicyManager
|
|
11
|
+
from .core.legacy import (
|
|
12
|
+
SceneData, CameraState, InteractionState,
|
|
13
|
+
CacheState, FrameState, LineDataset,
|
|
14
|
+
ScatterDataset, StripDataset
|
|
15
|
+
)
|
|
16
|
+
from .core.layers import BaseLayer, LineFamilyLayer, ScatterLayer, PolylineLayer, PatchLayer, TextLayer
|
|
17
|
+
from .core.context import RenderContext
|
|
18
|
+
from .controllers import CameraController
|
|
19
|
+
from .renderers.exact import ExactLineRenderer
|
|
20
|
+
from .renderers.interaction import InteractionRenderer
|
|
21
|
+
from .renderers.density import DensityRenderer
|
|
22
|
+
from .managers.hud import HudManager
|
|
23
|
+
from .managers.picking import PickingManager
|
|
24
|
+
from .utils.export import ExportManager
|
|
25
|
+
from .utils.shaders import DENSITY_SCHEMES
|
|
26
|
+
from .managers.effects import EffectManager
|
|
27
|
+
from .managers.renderer_manager import RendererManager
|
|
28
|
+
from .managers.axis import AxisManager
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class GPULinePlot:
|
|
32
|
+
def __init__(self, width: int = 1280, height: int = 800, title: str = "GLPlot", options: Optional[EngineOptions] = None):
|
|
33
|
+
self.options = options or EngineOptions(window_width=width, window_height=height, title=title)
|
|
34
|
+
self.policy = RenderPolicyManager(self.options)
|
|
35
|
+
self.scene = SceneData()
|
|
36
|
+
self.camera = CameraState()
|
|
37
|
+
self.interaction = InteractionState()
|
|
38
|
+
self.cache = CacheState()
|
|
39
|
+
self.frame = FrameState()
|
|
40
|
+
|
|
41
|
+
self.window = None
|
|
42
|
+
self.width = self.options.window_width
|
|
43
|
+
self.height = self.options.window_height
|
|
44
|
+
self.fb_width = self.options.window_width
|
|
45
|
+
self.fb_height = self.options.window_height
|
|
46
|
+
|
|
47
|
+
self.camera_controller = CameraController(self.camera, self.options)
|
|
48
|
+
self.exact_renderer = ExactLineRenderer(self.options)
|
|
49
|
+
self.interaction_renderer = InteractionRenderer(self)
|
|
50
|
+
self.hud = HudManager(self)
|
|
51
|
+
self.picking = PickingManager(self.options)
|
|
52
|
+
self.export = ExportManager(self)
|
|
53
|
+
self.renderer_manager = RendererManager(self)
|
|
54
|
+
self.axis_manager = AxisManager(self)
|
|
55
|
+
|
|
56
|
+
self._cpu_line_copy: Optional[np.ndarray] = None
|
|
57
|
+
self._is_test_mode: bool = False
|
|
58
|
+
self.display_density: bool = False
|
|
59
|
+
self.density_renderer = DensityRenderer(self)
|
|
60
|
+
|
|
61
|
+
self.picked_info: Optional[dict] = None
|
|
62
|
+
self.mouse_world: Optional[Tuple[float, float]] = None
|
|
63
|
+
self._last_perf_t = time.perf_counter()
|
|
64
|
+
|
|
65
|
+
self.effects = EffectManager(self)
|
|
66
|
+
self._shim_cache: Dict[str, BaseLayer] = {}
|
|
67
|
+
|
|
68
|
+
# --------------------------------------------------------
|
|
69
|
+
# Public API
|
|
70
|
+
# --------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
def set_lines_ab(self, ab: np.ndarray, x_range=(-3.0, 3.0), colors: Optional[np.ndarray] = None, label: Optional[str] = None) -> None:
|
|
73
|
+
ab = np.ascontiguousarray(ab, np.float32)
|
|
74
|
+
cols = None if colors is None else np.ascontiguousarray(colors, np.float32)
|
|
75
|
+
x_range = (float(x_range[0]), float(x_range[1]))
|
|
76
|
+
|
|
77
|
+
# --- Legacy LineDataset (kept for exact_renderer compatibility) ---
|
|
78
|
+
self.scene.lines = LineDataset(ab=ab, colors=cols, x_range=x_range)
|
|
79
|
+
self.scene.lines.validate()
|
|
80
|
+
self._cpu_line_copy = ab
|
|
81
|
+
|
|
82
|
+
# --- Layer registration: make the line family visible in the HUD ---
|
|
83
|
+
# Reuse the existing layer if one was already created by a previous call.
|
|
84
|
+
existing = getattr(self, "_primary_line_layer", None)
|
|
85
|
+
if existing is None:
|
|
86
|
+
layer_label = label or "Lines"
|
|
87
|
+
existing = LineFamilyLayer(
|
|
88
|
+
ab=ab, colors=cols, x_range=x_range, label=layer_label
|
|
89
|
+
)
|
|
90
|
+
self._primary_line_layer = existing
|
|
91
|
+
self.scene.layers.insert(0, existing) # lines always render first
|
|
92
|
+
else:
|
|
93
|
+
# Update data in-place so the GPU buffers are refreshed next frame
|
|
94
|
+
existing.ab = ab
|
|
95
|
+
existing.colors = cols
|
|
96
|
+
existing.x_range = x_range
|
|
97
|
+
existing.dirty.gpu_dirty = True
|
|
98
|
+
if label:
|
|
99
|
+
existing.label = label
|
|
100
|
+
|
|
101
|
+
self.frame.dirty_scene = True
|
|
102
|
+
self.frame.dirty_pick = True
|
|
103
|
+
if self.exact_renderer.buffers.vao:
|
|
104
|
+
self.exact_renderer.upload(self.scene.lines)
|
|
105
|
+
|
|
106
|
+
def add_text(self, x: float, y: float, text: str, fontsize: int = 12, color: Optional[Any] = None, label: Optional[str] = None) -> None:
|
|
107
|
+
layer_label = label or f"Text: {text[:10]}"
|
|
108
|
+
layer = TextLayer(x=x, y=y, text=text, label=layer_label)
|
|
109
|
+
layer.style.text_size_px = fontsize
|
|
110
|
+
if color is not None: layer.style.color = color
|
|
111
|
+
self.scene.layers.append(layer)
|
|
112
|
+
self.frame.dirty_ui = True
|
|
113
|
+
|
|
114
|
+
def add_scatter(self, x: np.ndarray, y: np.ndarray, colors: np.ndarray, size: float = 6.0, label: Optional[str] = None) -> None:
|
|
115
|
+
pts = np.column_stack([x, y]).astype(np.float32)
|
|
116
|
+
cols = np.ascontiguousarray(colors, np.float32)
|
|
117
|
+
layer_label = label or f"Scatter {len(self.scene.layers)}"
|
|
118
|
+
layer = ScatterLayer(pts=pts, colors=cols, size=size, label=layer_label)
|
|
119
|
+
self.scene.layers.append(layer)
|
|
120
|
+
self.frame.dirty_scene = True
|
|
121
|
+
|
|
122
|
+
def add_line_strip(self, x: np.ndarray, y: np.ndarray, color: Tuple[float, float, float, float] = (0,0,0,1), width: float = 1.0, label: Optional[str] = None) -> None:
|
|
123
|
+
pts = np.column_stack([x, y]).astype(np.float32)
|
|
124
|
+
layer_label = label or f"Polyline {len(self.scene.layers)}"
|
|
125
|
+
layer = PolylineLayer(pts=pts, color=color, width=width, label=layer_label)
|
|
126
|
+
self.scene.layers.append(layer)
|
|
127
|
+
self.frame.dirty_scene = True
|
|
128
|
+
|
|
129
|
+
def add_patch(self, vertices: np.ndarray, indices: Optional[np.ndarray] = None, mode: str = "strip", face_color: Optional[Tuple] = None, edge_color: Optional[Tuple] = None, label: Optional[str] = None) -> None:
|
|
130
|
+
layer_label = label or f"Patch {len(self.scene.layers)}"
|
|
131
|
+
layer = PatchLayer(vertices=vertices, indices=indices, mode=mode, label=layer_label)
|
|
132
|
+
if face_color is not None: layer.style.face_color = face_color
|
|
133
|
+
if edge_color is not None: layer.style.edge_color = edge_color
|
|
134
|
+
self.scene.layers.append(layer)
|
|
135
|
+
self.frame.dirty_scene = True
|
|
136
|
+
|
|
137
|
+
def set_density_enabled(self, enabled: bool) -> None:
|
|
138
|
+
self.display_density = bool(enabled)
|
|
139
|
+
self.frame.dirty_scene = True
|
|
140
|
+
self.cache.refresh_requested = True
|
|
141
|
+
|
|
142
|
+
def set_density_gain(self, value: float) -> None:
|
|
143
|
+
self.options.density_gain = float(value)
|
|
144
|
+
self.frame.dirty_scene = True
|
|
145
|
+
self.cache.refresh_requested = True
|
|
146
|
+
|
|
147
|
+
def increase_density_gain(self) -> None:
|
|
148
|
+
self.options.density_gain *= self.options.density_gain_step
|
|
149
|
+
self.frame.dirty_scene = True
|
|
150
|
+
self.cache.refresh_requested = True
|
|
151
|
+
|
|
152
|
+
def decrease_density_gain(self) -> None:
|
|
153
|
+
self.options.density_gain /= self.options.density_gain_step
|
|
154
|
+
self.frame.dirty_scene = True
|
|
155
|
+
self.cache.refresh_requested = True
|
|
156
|
+
|
|
157
|
+
def next_density_scheme(self) -> None:
|
|
158
|
+
self.options.density_scheme_index = (self.options.density_scheme_index + 1) % len(DENSITY_SCHEMES)
|
|
159
|
+
self.frame.dirty_scene = True
|
|
160
|
+
self.cache.refresh_requested = True
|
|
161
|
+
|
|
162
|
+
def previous_density_scheme(self) -> None:
|
|
163
|
+
self.options.density_scheme_index = (self.options.density_scheme_index - 1) % len(DENSITY_SCHEMES)
|
|
164
|
+
self.frame.dirty_scene = True
|
|
165
|
+
self.cache.refresh_requested = True
|
|
166
|
+
|
|
167
|
+
def toggle_density(self) -> None:
|
|
168
|
+
self.set_density_enabled(not self.display_density)
|
|
169
|
+
|
|
170
|
+
def rebuild_density_renderer(self) -> None:
|
|
171
|
+
"""Trigger a resource reconstruction for the density engine when scale changes."""
|
|
172
|
+
self.density_renderer.rebuild_target(self.fb_width, self.fb_height)
|
|
173
|
+
self.frame.dirty_scene = True
|
|
174
|
+
|
|
175
|
+
def set_view(self, xlim: Optional[Tuple[float, float]] = None, ylim: Optional[Tuple[float, float]] = None) -> None:
|
|
176
|
+
"""
|
|
177
|
+
Sets the world-space view limits, mimicking Matplotlib's xlim/ylim.
|
|
178
|
+
Calculates required center and zoom while maintaining the window aspect ratio.
|
|
179
|
+
"""
|
|
180
|
+
if xlim is None and ylim is None:
|
|
181
|
+
return
|
|
182
|
+
|
|
183
|
+
# 1. Resolve requested bounds
|
|
184
|
+
cur_xlim = self.get_xlim()
|
|
185
|
+
cur_ylim = self.get_ylim()
|
|
186
|
+
|
|
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))
|
|
207
|
+
|
|
208
|
+
self.frame.dirty_scene = True
|
|
209
|
+
self.cache.refresh_requested = True
|
|
210
|
+
|
|
211
|
+
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)
|
|
214
|
+
|
|
215
|
+
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)
|
|
218
|
+
|
|
219
|
+
def set_hud_enabled(self, enabled: bool) -> None:
|
|
220
|
+
self.options.enable_hud = bool(enabled)
|
|
221
|
+
self.frame.dirty_ui = True
|
|
222
|
+
|
|
223
|
+
def set_blending_mode(self, mode: str | BlendMode) -> None:
|
|
224
|
+
if isinstance(mode, str):
|
|
225
|
+
mapping = {
|
|
226
|
+
"auto": BlendMode.AUTO,
|
|
227
|
+
"alpha": BlendMode.ALPHA,
|
|
228
|
+
"on": BlendMode.ALPHA, # Legacy shim
|
|
229
|
+
"additive": BlendMode.ADDITIVE,
|
|
230
|
+
"subtractive": BlendMode.SUBTRACTIVE,
|
|
231
|
+
"screen": BlendMode.SCREEN,
|
|
232
|
+
"off": BlendMode.OFF
|
|
233
|
+
}
|
|
234
|
+
m = mode.lower()
|
|
235
|
+
if m not in mapping:
|
|
236
|
+
raise ValueError("blend mode must be 'auto', 'alpha', 'additive', 'subtractive', 'screen', or 'off'")
|
|
237
|
+
mode = mapping[m]
|
|
238
|
+
|
|
239
|
+
self.options.blend_mode = mode
|
|
240
|
+
self.frame.dirty_scene = True
|
|
241
|
+
|
|
242
|
+
def cycle_blending_mode(self) -> None:
|
|
243
|
+
modes = [
|
|
244
|
+
BlendMode.AUTO,
|
|
245
|
+
BlendMode.ALPHA,
|
|
246
|
+
BlendMode.ADDITIVE,
|
|
247
|
+
BlendMode.SUBTRACTIVE,
|
|
248
|
+
BlendMode.SCREEN,
|
|
249
|
+
BlendMode.OFF
|
|
250
|
+
]
|
|
251
|
+
try:
|
|
252
|
+
current_idx = modes.index(self.options.blend_mode)
|
|
253
|
+
except ValueError:
|
|
254
|
+
current_idx = 0
|
|
255
|
+
|
|
256
|
+
idx = (current_idx + 1) % len(modes)
|
|
257
|
+
self.options.blend_mode = modes[idx]
|
|
258
|
+
self.frame.dirty_scene = True
|
|
259
|
+
self.frame.dirty_ui = True
|
|
260
|
+
|
|
261
|
+
def set_profile(self, name: str) -> None:
|
|
262
|
+
"""
|
|
263
|
+
Applies a performance preset.
|
|
264
|
+
Options: 'extreme', 'performance', 'balanced', 'quality'.
|
|
265
|
+
"""
|
|
266
|
+
if name == 'extreme':
|
|
267
|
+
self.options.default_line_budget_per_px = 0.5
|
|
268
|
+
self.options.interaction_budget_lines_per_screen_px = 1.0
|
|
269
|
+
self.options.enable_cache_interaction_path = True
|
|
270
|
+
self.options.cache_safe_margin = 0.4
|
|
271
|
+
elif name == 'performance':
|
|
272
|
+
self.options.default_line_budget_per_px = 1.0
|
|
273
|
+
self.options.interaction_budget_lines_per_screen_px = 2.0
|
|
274
|
+
self.options.enable_cache_interaction_path = True
|
|
275
|
+
elif name == 'balanced':
|
|
276
|
+
self.options.default_line_budget_per_px = 5.0
|
|
277
|
+
self.options.interaction_budget_lines_per_screen_px = 5.0
|
|
278
|
+
self.options.enable_cache_interaction_path = True
|
|
279
|
+
elif name == 'quality':
|
|
280
|
+
self.options.default_line_budget_per_px = 20.0
|
|
281
|
+
self.options.interaction_budget_lines_per_screen_px = 20.0
|
|
282
|
+
self.options.enable_cache_interaction_path = False
|
|
283
|
+
self.frame.dirty_scene = True
|
|
284
|
+
|
|
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
|
+
|
|
311
|
+
def _get_all_layers(self) -> List[BaseLayer]:
|
|
312
|
+
"""
|
|
313
|
+
Internal bridge: returns all active layers.
|
|
314
|
+
The legacy LineDataset is now always mirrored into scene.layers as
|
|
315
|
+
_primary_line_layer, so we just return scene.layers directly.
|
|
316
|
+
"""
|
|
317
|
+
return list(self.scene.layers)
|
|
318
|
+
|
|
319
|
+
def autoscale(self) -> None:
|
|
320
|
+
"""Autoscale view to fit all (legacy and layer) data."""
|
|
321
|
+
layers = self._get_all_layers()
|
|
322
|
+
bounds = self.renderer_manager.get_bounds(layers)
|
|
323
|
+
|
|
324
|
+
if bounds is None:
|
|
325
|
+
self.camera_controller.reset_view()
|
|
326
|
+
return
|
|
327
|
+
|
|
328
|
+
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
|
|
333
|
+
|
|
334
|
+
self.camera_controller.fit_bounds(
|
|
335
|
+
xmin - dx, xmax + dx,
|
|
336
|
+
ymin - dy, ymax + dy,
|
|
337
|
+
self.width, self.height
|
|
338
|
+
)
|
|
339
|
+
self.frame.dirty_scene = True
|
|
340
|
+
|
|
341
|
+
def reset_view(self) -> None:
|
|
342
|
+
self.camera_controller.reset_view()
|
|
343
|
+
self.frame.dirty_scene = True
|
|
344
|
+
|
|
345
|
+
def clear(self) -> None:
|
|
346
|
+
self.scene = SceneData()
|
|
347
|
+
self.frame.dirty_scene = True
|
|
348
|
+
|
|
349
|
+
def close(self) -> None:
|
|
350
|
+
if self.window:
|
|
351
|
+
glfw.set_window_should_close(self.window, True)
|
|
352
|
+
|
|
353
|
+
def run(self) -> None:
|
|
354
|
+
self._init_window()
|
|
355
|
+
self._init_gl()
|
|
356
|
+
self._init_modules()
|
|
357
|
+
if self._is_test_mode:
|
|
358
|
+
self._update_runtime_policy()
|
|
359
|
+
glViewport(0, 0, self.fb_width, self.fb_height)
|
|
360
|
+
# 1. Clear Frame (Primary Surface)
|
|
361
|
+
c = self.options.visual.background_color
|
|
362
|
+
glClearColor(c[0], c[1], c[2], 1.0)
|
|
363
|
+
glClear(GL_COLOR_BUFFER_BIT)
|
|
364
|
+
self._apply_blending_policy()
|
|
365
|
+
self._draw_exact_view()
|
|
366
|
+
glfw.swap_buffers(self.window)
|
|
367
|
+
return
|
|
368
|
+
|
|
369
|
+
self._main_loop()
|
|
370
|
+
|
|
371
|
+
def savefig(self, filename: str, scale: float = 1.0) -> None:
|
|
372
|
+
"""
|
|
373
|
+
Public API for saving high-resolution figures.
|
|
374
|
+
"""
|
|
375
|
+
self.export.savefig(filename, scale=scale)
|
|
376
|
+
|
|
377
|
+
def save_current_view(self, filename: Optional[str] = None, scale: float = 2.0) -> None:
|
|
378
|
+
# Legacy shim
|
|
379
|
+
fname = filename or f"plot_{int(time.time())}.png"
|
|
380
|
+
self.savefig(fname, scale=scale)
|
|
381
|
+
|
|
382
|
+
# --------------------------------------------------------
|
|
383
|
+
# Init
|
|
384
|
+
# --------------------------------------------------------
|
|
385
|
+
|
|
386
|
+
def _init_window(self) -> None:
|
|
387
|
+
if not glfw.init():
|
|
388
|
+
raise RuntimeError("Failed to initialize GLFW")
|
|
389
|
+
|
|
390
|
+
glfw.window_hint(glfw.CONTEXT_VERSION_MAJOR, 3)
|
|
391
|
+
glfw.window_hint(glfw.CONTEXT_VERSION_MINOR, 3)
|
|
392
|
+
glfw.window_hint(glfw.OPENGL_PROFILE, glfw.OPENGL_CORE_PROFILE)
|
|
393
|
+
glfw.window_hint(glfw.DOUBLEBUFFER, glfw.TRUE)
|
|
394
|
+
if self.options.enable_multisample:
|
|
395
|
+
glfw.window_hint(glfw.SAMPLES, 4)
|
|
396
|
+
|
|
397
|
+
if self._is_test_mode:
|
|
398
|
+
glfw.window_hint(glfw.VISIBLE, glfw.FALSE)
|
|
399
|
+
|
|
400
|
+
self.window = glfw.create_window(self.width, self.height, self.options.title, None, None)
|
|
401
|
+
if not self.window:
|
|
402
|
+
glfw.terminate()
|
|
403
|
+
raise RuntimeError("Failed to create GLFW window")
|
|
404
|
+
|
|
405
|
+
glfw.make_context_current(self.window)
|
|
406
|
+
self.width, self.height = glfw.get_window_size(self.window)
|
|
407
|
+
self.fb_width, self.fb_height = glfw.get_framebuffer_size(self.window)
|
|
408
|
+
|
|
409
|
+
glfw.set_window_size_callback(self.window, self._on_resize)
|
|
410
|
+
glfw.set_framebuffer_size_callback(self.window, self._on_fb_resize)
|
|
411
|
+
glfw.set_scroll_callback(self.window, self._on_scroll)
|
|
412
|
+
glfw.set_mouse_button_callback(self.window, self._on_mouse_button)
|
|
413
|
+
glfw.set_cursor_pos_callback(self.window, self._on_cursor)
|
|
414
|
+
glfw.set_key_callback(self.window, self._on_key)
|
|
415
|
+
glfw.set_char_callback(self.window, self._on_char)
|
|
416
|
+
|
|
417
|
+
def _init_gl(self) -> None:
|
|
418
|
+
glViewport(0, 0, self.fb_width, self.fb_height)
|
|
419
|
+
glClearColor(1.0, 1.0, 1.0, 1.0)
|
|
420
|
+
|
|
421
|
+
# Clipping Optimizations (Must be enabled for shaders to work correctly)
|
|
422
|
+
if self.options.enable_clipping_optimization:
|
|
423
|
+
for i in range(4):
|
|
424
|
+
glEnable(GL_CLIP_DISTANCE0 + i)
|
|
425
|
+
|
|
426
|
+
if self.options.enable_multisample:
|
|
427
|
+
glEnable(GL_MULTISAMPLE)
|
|
428
|
+
else:
|
|
429
|
+
glDisable(GL_MULTISAMPLE)
|
|
430
|
+
|
|
431
|
+
def _init_modules(self) -> None:
|
|
432
|
+
self.exact_renderer.initialize()
|
|
433
|
+
self.interaction_renderer.initialize(self.fb_width, self.fb_height)
|
|
434
|
+
self.density_renderer.initialize(self.fb_width, self.fb_height)
|
|
435
|
+
self.hud.initialize(self.window)
|
|
436
|
+
self.picking.initialize(self.fb_width, self.fb_height)
|
|
437
|
+
self.effects.ensure_resources()
|
|
438
|
+
self.renderer_manager.initialize()
|
|
439
|
+
|
|
440
|
+
if self.scene.lines.count > 0:
|
|
441
|
+
self.exact_renderer.upload(self.scene.lines)
|
|
442
|
+
|
|
443
|
+
# --------------------------------------------------------
|
|
444
|
+
# Frame policies
|
|
445
|
+
# --------------------------------------------------------
|
|
446
|
+
|
|
447
|
+
def _update_runtime_policy(self) -> None:
|
|
448
|
+
self.policy.update(self.scene, self.interaction, self.cache)
|
|
449
|
+
|
|
450
|
+
def _get_adaptive_alpha(self, count: int) -> float:
|
|
451
|
+
"""
|
|
452
|
+
Calculates a balanced alpha value based on object count and display density (DPR).
|
|
453
|
+
Ensures visibility on High-DPI displays while preventing saturation on dense datasets.
|
|
454
|
+
"""
|
|
455
|
+
base_alpha = self.options.default_global_alpha
|
|
456
|
+
|
|
457
|
+
if self.options.enable_auto_alpha and count > 1000:
|
|
458
|
+
scale_factor = math.sqrt(count / 1000.0)
|
|
459
|
+
# Unified Floor at 0.15 to ensure visibility
|
|
460
|
+
base_alpha = max(0.15, base_alpha / scale_factor)
|
|
461
|
+
|
|
462
|
+
# High-DPI (Retina) compensation: single-pixel lines are physically thinner,
|
|
463
|
+
# so we boost alpha to maintain perceived weight.
|
|
464
|
+
dpr = self.fb_width / max(self.width, 1)
|
|
465
|
+
if dpr > 1.1:
|
|
466
|
+
base_alpha = min(1.0, base_alpha * 1.5)
|
|
467
|
+
|
|
468
|
+
return float(base_alpha)
|
|
469
|
+
|
|
470
|
+
def _compute_lod_keep_prob(self) -> float:
|
|
471
|
+
"""
|
|
472
|
+
Calculates the fraction of objects to keep during interaction (LOD).
|
|
473
|
+
Uses a width-aware policy that accounts for fill-rate.
|
|
474
|
+
"""
|
|
475
|
+
if not self.options.lod_enabled:
|
|
476
|
+
return 1.0
|
|
477
|
+
|
|
478
|
+
window = self.camera_controller.world_window(self.width, self.height)
|
|
479
|
+
ndc_scale, ndc_offset = self._get_ndc_transform(window)
|
|
480
|
+
|
|
481
|
+
ctx = RenderContext(
|
|
482
|
+
mvp=self.camera_controller.mvp(self.width, self.height),
|
|
483
|
+
window_world=window,
|
|
484
|
+
ndc_scale=ndc_scale,
|
|
485
|
+
ndc_offset=ndc_offset,
|
|
486
|
+
width_px=self.width,
|
|
487
|
+
height_px=self.height,
|
|
488
|
+
fb_width=self.fb_width,
|
|
489
|
+
fb_height=self.fb_height,
|
|
490
|
+
mode=self.policy.runtime.current_mode,
|
|
491
|
+
global_alpha=self.options.default_global_alpha,
|
|
492
|
+
lod_keep_prob=1.0,
|
|
493
|
+
time=time.perf_counter()
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
return self.policy.calculate_width_aware_lod(self.scene, ctx)
|
|
497
|
+
|
|
498
|
+
def _get_ndc_transform(self, window: Tuple[float, float, float, float]) -> Tuple[Tuple[float, float], Tuple[float, float]]:
|
|
499
|
+
"""Calculate scale and offset to transform world coordinates to NDC [-1, 1]."""
|
|
500
|
+
l, r, b, t = window
|
|
501
|
+
rl = r - l
|
|
502
|
+
tb = t - b
|
|
503
|
+
sx = 2.0 / max(rl, 1e-12)
|
|
504
|
+
sy = 2.0 / max(tb, 1e-12)
|
|
505
|
+
ox = -(r + l) / max(rl, 1e-12)
|
|
506
|
+
oy = -(t + b) / max(tb, 1e-12)
|
|
507
|
+
return (sx, sy), (ox, oy)
|
|
508
|
+
|
|
509
|
+
def _apply_blending_policy(self) -> None:
|
|
510
|
+
if not self.policy.runtime.blending_enabled:
|
|
511
|
+
glDisable(GL_BLEND)
|
|
512
|
+
return
|
|
513
|
+
|
|
514
|
+
glEnable(GL_BLEND)
|
|
515
|
+
glBlendEquation(GL_FUNC_ADD) # Default reset
|
|
516
|
+
|
|
517
|
+
from .options import BlendMode
|
|
518
|
+
m = self.options.blend_mode
|
|
519
|
+
if m == BlendMode.ALPHA or m == BlendMode.AUTO:
|
|
520
|
+
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
|
|
521
|
+
elif m == BlendMode.ADDITIVE:
|
|
522
|
+
glBlendFunc(GL_SRC_ALPHA, GL_ONE)
|
|
523
|
+
elif m == BlendMode.SUBTRACTIVE:
|
|
524
|
+
glBlendEquation(GL_FUNC_REVERSE_SUBTRACT)
|
|
525
|
+
glBlendFunc(GL_SRC_ALPHA, GL_ONE)
|
|
526
|
+
elif m == BlendMode.SCREEN:
|
|
527
|
+
glBlendFunc(GL_ONE, GL_ONE_MINUS_SRC_COLOR)
|
|
528
|
+
|
|
529
|
+
# --------------------------------------------------------
|
|
530
|
+
# Render
|
|
531
|
+
# --------------------------------------------------------
|
|
532
|
+
|
|
533
|
+
def _draw_exact_view(self) -> None:
|
|
534
|
+
t_start = time.perf_counter()
|
|
535
|
+
self._apply_blending_policy()
|
|
536
|
+
|
|
537
|
+
# 1. Prepare RenderContext for this frame
|
|
538
|
+
mvp = self.camera_controller.mvp(self.width, self.height)
|
|
539
|
+
window = self.camera_controller.world_window(self.width, self.height)
|
|
540
|
+
prob = self._compute_lod_keep_prob()
|
|
541
|
+
base_alpha = self._get_adaptive_alpha(self.scene.lines.count)
|
|
542
|
+
|
|
543
|
+
ndc_scale, ndc_offset = self._get_ndc_transform(window)
|
|
544
|
+
|
|
545
|
+
ctx = RenderContext(
|
|
546
|
+
mvp=mvp,
|
|
547
|
+
window_world=window,
|
|
548
|
+
ndc_scale=ndc_scale,
|
|
549
|
+
ndc_offset=ndc_offset,
|
|
550
|
+
width_px=self.width,
|
|
551
|
+
height_px=self.height,
|
|
552
|
+
fb_width=self.fb_width,
|
|
553
|
+
fb_height=self.fb_height,
|
|
554
|
+
dpr=self.fb_width / max(self.width, 1),
|
|
555
|
+
mode=self.policy.runtime.current_mode,
|
|
556
|
+
global_alpha=base_alpha,
|
|
557
|
+
lod_keep_prob=prob,
|
|
558
|
+
time=time.perf_counter()
|
|
559
|
+
)
|
|
560
|
+
|
|
561
|
+
# 2. Draw using the new RendererManager (Modular Architecture)
|
|
562
|
+
layers = self._get_all_layers()
|
|
563
|
+
self.axis_manager.update(ctx)
|
|
564
|
+
|
|
565
|
+
# Always draw Axes/Framework first (unless hidden via HUD)
|
|
566
|
+
self.renderer_manager.draw_axes(self.axis_manager, ctx)
|
|
567
|
+
|
|
568
|
+
if self.display_density:
|
|
569
|
+
# Modular Density Pass (Lines, Scatters)
|
|
570
|
+
self.renderer_manager.draw_density(layers, ctx)
|
|
571
|
+
else:
|
|
572
|
+
# Standard Pass
|
|
573
|
+
self.renderer_manager.draw_exact(layers, ctx)
|
|
574
|
+
|
|
575
|
+
# Overlay Text pass (screen-aligned, always last)
|
|
576
|
+
self.renderer_manager.renderers["text"].draw_all(layers, ctx)
|
|
577
|
+
|
|
578
|
+
self.hud.state.gpu_timings["Exact Render"] = time.perf_counter() - t_start
|
|
579
|
+
|
|
580
|
+
def _draw_interaction_view(self) -> None:
|
|
581
|
+
t_start = time.perf_counter()
|
|
582
|
+
self._apply_blending_policy()
|
|
583
|
+
|
|
584
|
+
# Disable world clipping for screen-space impostor
|
|
585
|
+
if self.options.enable_clipping_optimization:
|
|
586
|
+
for i in range(4): glDisable(GL_CLIP_DISTANCE0 + i)
|
|
587
|
+
|
|
588
|
+
current_window = self.camera_controller.world_window(self.width, self.height)
|
|
589
|
+
if self.options.enable_cache_interaction_path and self.cache.capture_window is not None:
|
|
590
|
+
self.interaction_renderer.draw_cached_impostor(self.cache.capture_window, current_window)
|
|
591
|
+
else:
|
|
592
|
+
self._draw_exact_view()
|
|
593
|
+
|
|
594
|
+
# Re-enable if needed for next passes (exact view usually enables it anyway)
|
|
595
|
+
if self.options.enable_clipping_optimization:
|
|
596
|
+
for i in range(4): glEnable(GL_CLIP_DISTANCE0 + i)
|
|
597
|
+
|
|
598
|
+
self.hud.state.gpu_timings["Interaction"] = time.perf_counter() - t_start
|
|
599
|
+
|
|
600
|
+
def _capture_interaction_cache(self) -> None:
|
|
601
|
+
capture_window = self.camera_controller.world_window(
|
|
602
|
+
self.width,
|
|
603
|
+
self.height,
|
|
604
|
+
padding=self.options.cache_padding,
|
|
605
|
+
)
|
|
606
|
+
mvp = self.camera_controller.mvp(self.width, self.height, window=capture_window)
|
|
607
|
+
target_fbo = self.interaction_renderer.cache_target.fbo
|
|
608
|
+
target_size = (self.fb_width, self.fb_height)
|
|
609
|
+
|
|
610
|
+
glBindFramebuffer(GL_FRAMEBUFFER, target_fbo)
|
|
611
|
+
glViewport(0, 0, self.fb_width, self.fb_height)
|
|
612
|
+
# Transparent background for the cache to allow blending during interaction
|
|
613
|
+
glClearColor(1.0, 1.0, 1.0, 0.0)
|
|
614
|
+
glClear(GL_COLOR_BUFFER_BIT)
|
|
615
|
+
|
|
616
|
+
prob = self._compute_lod_keep_prob()
|
|
617
|
+
base_alpha = self._get_adaptive_alpha(self.scene.lines.count)
|
|
618
|
+
|
|
619
|
+
if prob < 1.0:
|
|
620
|
+
base_alpha = 1.0
|
|
621
|
+
|
|
622
|
+
ndc_scale, ndc_offset = self._get_ndc_transform(capture_window)
|
|
623
|
+
|
|
624
|
+
ctx = RenderContext(
|
|
625
|
+
mvp=mvp,
|
|
626
|
+
window_world=capture_window,
|
|
627
|
+
ndc_scale=ndc_scale,
|
|
628
|
+
ndc_offset=ndc_offset,
|
|
629
|
+
width_px=self.width,
|
|
630
|
+
height_px=self.height,
|
|
631
|
+
fb_width=self.fb_width,
|
|
632
|
+
fb_height=self.fb_height,
|
|
633
|
+
dpr=self.fb_width / max(self.width, 1),
|
|
634
|
+
mode=RenderMode.INTERACTIVE,
|
|
635
|
+
global_alpha=base_alpha,
|
|
636
|
+
lod_keep_prob=prob,
|
|
637
|
+
time=time.perf_counter()
|
|
638
|
+
)
|
|
639
|
+
|
|
640
|
+
layers = self._get_all_layers()
|
|
641
|
+
|
|
642
|
+
if self.display_density:
|
|
643
|
+
self.renderer_manager.draw_density(layers, ctx, target_fbo=target_fbo, target_size=target_size)
|
|
644
|
+
else:
|
|
645
|
+
self._apply_blending_policy()
|
|
646
|
+
# Only draw primal geometry into the interaction cache
|
|
647
|
+
# HUD/Axes/Labels are overlays drawn in the main view pass
|
|
648
|
+
self.renderer_manager.draw_exact(layers, ctx)
|
|
649
|
+
|
|
650
|
+
glBindFramebuffer(GL_FRAMEBUFFER, 0)
|
|
651
|
+
self.cache.capture_window = capture_window
|
|
652
|
+
self.cache.last_capture_time = glfw.get_time()
|
|
653
|
+
self.cache.refresh_requested = False
|
|
654
|
+
|
|
655
|
+
def _cache_needs_refresh(self) -> bool:
|
|
656
|
+
if not self.cache.capture_window:
|
|
657
|
+
return True
|
|
658
|
+
|
|
659
|
+
cl, cr, cb, ct = self.cache.capture_window
|
|
660
|
+
l, r, b, t = self.camera_controller.world_window(self.width, self.height)
|
|
661
|
+
margin = self.options.cache_safe_margin
|
|
662
|
+
cw, ch = (cr - cl), (ct - cb)
|
|
663
|
+
return (
|
|
664
|
+
l < cl + cw * margin or
|
|
665
|
+
r > cr - cw * margin or
|
|
666
|
+
b < cb + ch * margin or
|
|
667
|
+
t > ct - ch * margin
|
|
668
|
+
)
|
|
669
|
+
|
|
670
|
+
def _service_deferred_cache_refresh(self) -> None:
|
|
671
|
+
if not self.cache.active:
|
|
672
|
+
return
|
|
673
|
+
if not self.cache.refresh_requested:
|
|
674
|
+
return
|
|
675
|
+
now = glfw.get_time()
|
|
676
|
+
min_dt = 1.0 / max(self.options.cache_refresh_hz, 1e-6)
|
|
677
|
+
if now - self.cache.last_capture_time >= min_dt:
|
|
678
|
+
self._capture_interaction_cache()
|
|
679
|
+
|
|
680
|
+
# --------------------------------------------------------
|
|
681
|
+
# Main loop
|
|
682
|
+
# --------------------------------------------------------
|
|
683
|
+
|
|
684
|
+
def _main_loop(self) -> None:
|
|
685
|
+
glfw.swap_interval(1)
|
|
686
|
+
|
|
687
|
+
while not glfw.window_should_close(self.window):
|
|
688
|
+
glfw.poll_events()
|
|
689
|
+
|
|
690
|
+
# 1. Update Input and State
|
|
691
|
+
self.hud.process_inputs()
|
|
692
|
+
self._update_runtime_policy()
|
|
693
|
+
|
|
694
|
+
# 2. Start ImGui frame before ANY rendering/processing happens
|
|
695
|
+
self.hud.begin()
|
|
696
|
+
|
|
697
|
+
self._service_deferred_cache_refresh()
|
|
698
|
+
|
|
699
|
+
# Picking Pass (Deferred).
|
|
700
|
+
# dirty_pick is set on explicit Shift+Click → always honour it.
|
|
701
|
+
# The extra gate only applies to continuous hover-picking when shift is held.
|
|
702
|
+
if self.frame.dirty_pick:
|
|
703
|
+
run_pick = (not self.options.shift_required_for_picking) or \
|
|
704
|
+
self.interaction.shift_down or \
|
|
705
|
+
self.interaction.explicit_pick_requested
|
|
706
|
+
if run_pick:
|
|
707
|
+
self._run_picking_pass()
|
|
708
|
+
self.frame.dirty_pick = False
|
|
709
|
+
self.interaction.explicit_pick_requested = False
|
|
710
|
+
|
|
711
|
+
t0 = glfw.get_time()
|
|
712
|
+
|
|
713
|
+
self.effects.begin_scene()
|
|
714
|
+
|
|
715
|
+
self.effects.draw_background()
|
|
716
|
+
self._apply_blending_policy()
|
|
717
|
+
|
|
718
|
+
if self.policy.runtime.current_mode == RenderMode.INTERACTIVE:
|
|
719
|
+
self._draw_interaction_view()
|
|
720
|
+
else:
|
|
721
|
+
self._draw_exact_view()
|
|
722
|
+
|
|
723
|
+
# Draw zoom box if active
|
|
724
|
+
if self.interaction.right_drag_active:
|
|
725
|
+
if self.options.enable_clipping_optimization:
|
|
726
|
+
for i in range(4): glDisable(GL_CLIP_DISTANCE0 + i)
|
|
727
|
+
self._draw_zoom_box()
|
|
728
|
+
|
|
729
|
+
self.effects.end_scene()
|
|
730
|
+
|
|
731
|
+
# Update HUD metrics and Draw
|
|
732
|
+
self._service_hud_metrics(t0)
|
|
733
|
+
|
|
734
|
+
# Disable world clipping for HUD
|
|
735
|
+
if self.options.enable_clipping_optimization:
|
|
736
|
+
for i in range(4): glDisable(GL_CLIP_DISTANCE0 + i)
|
|
737
|
+
|
|
738
|
+
# HUD panels are only updated if HUD is enabled, but begin/end must wrap all
|
|
739
|
+
if self.policy.runtime.hud_enabled_this_frame:
|
|
740
|
+
self.hud.update()
|
|
741
|
+
|
|
742
|
+
self.hud.end()
|
|
743
|
+
|
|
744
|
+
# Note: GL state is cleaned up/reset at start of next frame or specific renderers
|
|
745
|
+
|
|
746
|
+
glfw.swap_buffers(self.window)
|
|
747
|
+
t1 = glfw.get_time()
|
|
748
|
+
|
|
749
|
+
dt = max(t1 - t0, 1e-6)
|
|
750
|
+
self.frame.fps_estimate = 1.0 / dt
|
|
751
|
+
self.frame.last_frame_time = t1
|
|
752
|
+
self.frame.dirty_scene = False
|
|
753
|
+
self.frame.dirty_ui = False
|
|
754
|
+
|
|
755
|
+
if self.cache.active and not self.interaction.drag_active and not self.interaction.right_drag_active and t1 >= self.cache.release_deadline:
|
|
756
|
+
self.cache.active = False
|
|
757
|
+
self.frame.dirty_scene = True
|
|
758
|
+
|
|
759
|
+
self.effects.shutdown()
|
|
760
|
+
|
|
761
|
+
def _service_hud_metrics(self, t0: float) -> None:
|
|
762
|
+
now = glfw.get_time()
|
|
763
|
+
|
|
764
|
+
# Fast bucket (Every frame)
|
|
765
|
+
self.hud.state.cpu_frame_times.append(time.perf_counter() - self._last_perf_t)
|
|
766
|
+
self._last_perf_t = time.perf_counter()
|
|
767
|
+
self.hud.state.selected_object = self.picked_info
|
|
768
|
+
|
|
769
|
+
# Medium bucket (4 Hz)
|
|
770
|
+
if now - self.hud.state.last_medium_update > 0.25:
|
|
771
|
+
self.hud.state.last_medium_update = now
|
|
772
|
+
# Profiler stats
|
|
773
|
+
self.hud.state.fps_history.append(self.frame.fps_estimate)
|
|
774
|
+
|
|
775
|
+
# Slow bucket (2 Hz or Idle)
|
|
776
|
+
if now - self.hud.state.last_slow_update > 0.5:
|
|
777
|
+
self.hud.state.last_slow_update = now
|
|
778
|
+
self._update_slow_analysis()
|
|
779
|
+
|
|
780
|
+
def _update_slow_analysis(self):
|
|
781
|
+
# Sampled histograms for performance
|
|
782
|
+
if self.scene.lines.ab is not None:
|
|
783
|
+
n = self.scene.lines.count
|
|
784
|
+
sample_size = min(n, 10000)
|
|
785
|
+
indices = np.random.choice(n, sample_size, replace=False)
|
|
786
|
+
sample = self.scene.lines.ab[indices]
|
|
787
|
+
|
|
788
|
+
# Simple histogram calculation
|
|
789
|
+
hist_a, _ = np.histogram(sample[:, 0], bins=50)
|
|
790
|
+
hist_b, _ = np.histogram(sample[:, 1], bins=50)
|
|
791
|
+
self.hud.state.sampled_histogram_a = hist_a.astype(np.float32)
|
|
792
|
+
self.hud.state.sampled_histogram_b = hist_b.astype(np.float32)
|
|
793
|
+
|
|
794
|
+
def _draw_zoom_box(self) -> None:
|
|
795
|
+
# Modern replacement for immediate mode glBegin
|
|
796
|
+
px, py = self.interaction.right_press_mouse
|
|
797
|
+
mx, my = self.interaction.last_mouse
|
|
798
|
+
|
|
799
|
+
# Screen to NDC [-1, 1]
|
|
800
|
+
x0, y0 = 2.0 * px / self.width - 1.0, 1.0 - 2.0 * py / self.height
|
|
801
|
+
x1, y1 = 2.0 * mx / self.width - 1.0, 1.0 - 2.0 * my / self.height
|
|
802
|
+
|
|
803
|
+
# We reuse the TextRenderer's unit quad or similar to avoid defining a new VAO just for this.
|
|
804
|
+
# However, for robustness, we'll just use AxisRenderer's logic or simple GL lines.
|
|
805
|
+
# Actually, let's just use the TextRenderer's draw_list approach if available,
|
|
806
|
+
# but since we are in the engine, we'll do a quick VAO-less draw if possible,
|
|
807
|
+
# or just use a simple 4-vertex local buffer.
|
|
808
|
+
|
|
809
|
+
glDisable(GL_DEPTH_TEST)
|
|
810
|
+
glEnable(GL_BLEND)
|
|
811
|
+
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
|
|
812
|
+
|
|
813
|
+
# For V1 optimization, we'll use a simple attribute-less draw or just keep it simple.
|
|
814
|
+
# Since this is a UI element, using the ImGui draw list is the best path.
|
|
815
|
+
draw_list = self.hud.get_draw_list()
|
|
816
|
+
if draw_list:
|
|
817
|
+
color = 0x4C3366CC # Abgr: (0.3, 0.4, 0.8, 1.0) approx
|
|
818
|
+
draw_list.add_rect_filled(px, py, mx, my, color)
|
|
819
|
+
draw_list.add_rect(px, py, mx, my, 0xCC3366CC)
|
|
820
|
+
|
|
821
|
+
# --------------------------------------------------------
|
|
822
|
+
# Callbacks
|
|
823
|
+
# --------------------------------------------------------
|
|
824
|
+
|
|
825
|
+
def _on_resize(self, window, w, h) -> None:
|
|
826
|
+
self.width = max(1, int(w))
|
|
827
|
+
self.height = max(1, int(h))
|
|
828
|
+
self.frame.dirty_scene = True
|
|
829
|
+
self.frame.dirty_pick = True
|
|
830
|
+
|
|
831
|
+
def _on_fb_resize(self, window, w, h) -> None:
|
|
832
|
+
self.fb_width = max(1, int(w))
|
|
833
|
+
self.fb_height = max(1, int(h))
|
|
834
|
+
glViewport(0, 0, self.fb_width, self.fb_height)
|
|
835
|
+
self.interaction_renderer.rebuild_cache_target(self.fb_width, self.fb_height)
|
|
836
|
+
self.density_renderer.rebuild_target(self.fb_width, self.fb_height)
|
|
837
|
+
self.picking.rebuild_target(self.fb_width, self.fb_height)
|
|
838
|
+
self.effects.on_resize()
|
|
839
|
+
self.frame.dirty_scene = True
|
|
840
|
+
self.frame.dirty_pick = True
|
|
841
|
+
|
|
842
|
+
def _on_scroll(self, window, dx, dy) -> None:
|
|
843
|
+
self.hud.on_scroll(window, dx, dy)
|
|
844
|
+
if self.hud.wants_mouse():
|
|
845
|
+
return
|
|
846
|
+
|
|
847
|
+
if not self.cache.active:
|
|
848
|
+
self.cache.active = True
|
|
849
|
+
self.cache.refresh_requested = True
|
|
850
|
+
self.cache.release_deadline = glfw.get_time() + 0.20
|
|
851
|
+
|
|
852
|
+
factor = self.options.zoom_scroll_factor if dy > 0 else 1.0 / self.options.zoom_scroll_factor
|
|
853
|
+
mx, my = glfw.get_cursor_pos(self.window)
|
|
854
|
+
self.camera_controller.apply_zoom_at_cursor(factor, mx, my, self.width, self.height)
|
|
855
|
+
|
|
856
|
+
self.frame.dirty_scene = True
|
|
857
|
+
self.frame.dirty_pick = True
|
|
858
|
+
|
|
859
|
+
def _on_mouse_button(self, window, button, action, mods) -> None:
|
|
860
|
+
self.hud.on_mouse_button(window, button, action, mods)
|
|
861
|
+
if self.hud.wants_mouse():
|
|
862
|
+
return
|
|
863
|
+
|
|
864
|
+
mx, my = glfw.get_cursor_pos(self.window)
|
|
865
|
+
|
|
866
|
+
if button == glfw.MOUSE_BUTTON_LEFT:
|
|
867
|
+
if action == glfw.PRESS:
|
|
868
|
+
# 1. Picking Pass (Shift + Click)
|
|
869
|
+
if (mods & glfw.MOD_SHIFT) or self.interaction.shift_down:
|
|
870
|
+
self.interaction.last_mouse = (mx, my)
|
|
871
|
+
self.frame.dirty_pick = True
|
|
872
|
+
self.interaction.explicit_pick_requested = True
|
|
873
|
+
self.frame.dirty_scene = True
|
|
874
|
+
|
|
875
|
+
# 2. Start Drag State
|
|
876
|
+
self.interaction.drag_active = True
|
|
877
|
+
self.interaction.drag_confirmed = False
|
|
878
|
+
self.interaction.drag_start_translation = None
|
|
879
|
+
self.interaction.press_mouse = (mx, my)
|
|
880
|
+
self.interaction.last_mouse = (mx, my)
|
|
881
|
+
self.interaction.drag_start_world = self.camera_controller.screen_to_world(mx, my, self.width, self.height)
|
|
882
|
+
|
|
883
|
+
# 3. Determine Drag Mode
|
|
884
|
+
if (mods & glfw.MOD_CONTROL) or (mods & glfw.MOD_SHIFT):
|
|
885
|
+
self.interaction.drag_mode = "move"
|
|
886
|
+
if self.interaction.selected_layer_id is not None:
|
|
887
|
+
layer = next((l for l in self.scene.layers if l.layer_id == self.interaction.selected_layer_id), None)
|
|
888
|
+
if layer:
|
|
889
|
+
self.interaction.drag_start_translation = layer.translation
|
|
890
|
+
else:
|
|
891
|
+
self.interaction.drag_mode = "pan"
|
|
892
|
+
|
|
893
|
+
elif action == glfw.RELEASE:
|
|
894
|
+
self.interaction.drag_active = False
|
|
895
|
+
if self.cache.active:
|
|
896
|
+
self.cache.release_deadline = glfw.get_time() + 0.05
|
|
897
|
+
self.frame.dirty_scene = True
|
|
898
|
+
|
|
899
|
+
elif button == glfw.MOUSE_BUTTON_RIGHT:
|
|
900
|
+
if action == glfw.PRESS:
|
|
901
|
+
self.interaction.right_drag_active = True
|
|
902
|
+
self.interaction.right_press_mouse = (mx, my)
|
|
903
|
+
self.interaction.last_mouse = (mx, my)
|
|
904
|
+
elif action == glfw.RELEASE:
|
|
905
|
+
if self.interaction.right_drag_active:
|
|
906
|
+
px, py = self.interaction.right_press_mouse
|
|
907
|
+
if abs(mx - px) > 5 and abs(my - py) > 5:
|
|
908
|
+
w0, h0 = self.camera_controller.screen_to_world(px, py, self.width, self.height)
|
|
909
|
+
w1, h1 = self.camera_controller.screen_to_world(mx, my, self.width, self.height)
|
|
910
|
+
self.set_view(xlim=(min(w0, w1), max(w0, w1)), ylim=(min(h0, h1), max(h0, h1)))
|
|
911
|
+
self.interaction.right_drag_active = False
|
|
912
|
+
self.frame.dirty_scene = True
|
|
913
|
+
|
|
914
|
+
def _on_cursor(self, window, x, y) -> None:
|
|
915
|
+
if self.hud.wants_mouse():
|
|
916
|
+
# Still update world coords for status panel
|
|
917
|
+
self.mouse_world = self.camera_controller.screen_to_world(x, y, self.width, self.height)
|
|
918
|
+
return
|
|
919
|
+
|
|
920
|
+
self.mouse_world = self.camera_controller.screen_to_world(x, y, self.width, self.height)
|
|
921
|
+
self.frame.dirty_ui = True
|
|
922
|
+
|
|
923
|
+
if self.interaction.drag_active:
|
|
924
|
+
px, py = self.interaction.press_mouse
|
|
925
|
+
dist2 = (x - px) ** 2 + (y - py) ** 2
|
|
926
|
+
if not self.interaction.drag_confirmed and dist2 > self.options.drag_threshold_px ** 2:
|
|
927
|
+
self.interaction.drag_confirmed = True
|
|
928
|
+
self.cache.active = True
|
|
929
|
+
self.cache.refresh_requested = True
|
|
930
|
+
self.cache.release_deadline = glfw.get_time() + 0.20
|
|
931
|
+
|
|
932
|
+
if self.interaction.drag_mode == "move" and self.interaction.selected_layer_id is not None:
|
|
933
|
+
# MOVE MODE: Translate the layer
|
|
934
|
+
layer = next((l for l in self.scene.layers if l.layer_id == self.interaction.selected_layer_id), None)
|
|
935
|
+
if layer:
|
|
936
|
+
# Late capture of start translation if it's the first frame for this layer
|
|
937
|
+
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
|
+
self.interaction.drag_start_translation = layer.translation
|
|
941
|
+
self.interaction.drag_start_world = self.camera_controller.screen_to_world(x, y, self.width, self.height)
|
|
942
|
+
|
|
943
|
+
curr_world = self.camera_controller.screen_to_world(x, y, self.width, self.height)
|
|
944
|
+
start_world = self.interaction.drag_start_world
|
|
945
|
+
start_trans = self.interaction.drag_start_translation
|
|
946
|
+
|
|
947
|
+
dx = curr_world[0] - start_world[0]
|
|
948
|
+
dy = curr_world[1] - start_world[1]
|
|
949
|
+
layer.translation = (start_trans[0] + dx, start_trans[1] + dy)
|
|
950
|
+
|
|
951
|
+
# Force cache to redraw so we see it moving
|
|
952
|
+
self.cache.refresh_requested = True
|
|
953
|
+
else:
|
|
954
|
+
# PAN MODE: Translate the camera
|
|
955
|
+
lx, ly = self.interaction.last_mouse
|
|
956
|
+
wx0, wy0 = self.camera_controller.screen_to_world(lx, ly, self.width, self.height)
|
|
957
|
+
wx1, wy1 = self.camera_controller.screen_to_world(x, y, self.width, self.height)
|
|
958
|
+
self.camera.cx -= (wx1 - wx0)
|
|
959
|
+
self.camera.cy -= (wy1 - wy0)
|
|
960
|
+
|
|
961
|
+
self.interaction.last_mouse = (x, y)
|
|
962
|
+
self.frame.dirty_scene = True
|
|
963
|
+
if self.cache.active and self._cache_needs_refresh():
|
|
964
|
+
self.cache.refresh_requested = True
|
|
965
|
+
elif self.interaction.right_drag_active:
|
|
966
|
+
self.interaction.last_mouse = (x, y)
|
|
967
|
+
self.frame.dirty_ui = True
|
|
968
|
+
|
|
969
|
+
def _run_picking_pass(self) -> None:
|
|
970
|
+
if not self.interaction.last_mouse:
|
|
971
|
+
return
|
|
972
|
+
|
|
973
|
+
mx, my = self.interaction.last_mouse
|
|
974
|
+
mvp = self.camera_controller.mvp(self.width, self.height)
|
|
975
|
+
window = self.camera_controller.world_window(self.width, self.height)
|
|
976
|
+
|
|
977
|
+
# Scale to framebuffer (pixel) coordinates for Retina / High-DPI displays.
|
|
978
|
+
# GLFW cursor positions are in logical window units; the picking FBO is in pixels.
|
|
979
|
+
dpr_x = self.fb_width / max(self.width, 1)
|
|
980
|
+
dpr_y = self.fb_height / max(self.height, 1)
|
|
981
|
+
px = mx * dpr_x
|
|
982
|
+
py = my * dpr_y
|
|
983
|
+
|
|
984
|
+
# 1. Render scene to picking buffer
|
|
985
|
+
self.picking.draw_pick_scene(self.scene, self.exact_renderer.buffers, mvp, window)
|
|
986
|
+
|
|
987
|
+
# 2. Read back hit result at cursor (in pixel coords)
|
|
988
|
+
hit = self.picking.pick_readback(px, py, self.scene)
|
|
989
|
+
|
|
990
|
+
if hit:
|
|
991
|
+
self.picked_info = {
|
|
992
|
+
"type": hit["type"],
|
|
993
|
+
"layer_id": hit["layer_id"],
|
|
994
|
+
"element_idx": hit["element_idx"],
|
|
995
|
+
"layer": hit["layer"],
|
|
996
|
+
"x": self.mouse_world[0] if self.mouse_world else 0.0,
|
|
997
|
+
"y": self.mouse_world[1] if self.mouse_world else 0.0
|
|
998
|
+
}
|
|
999
|
+
# Update interaction selection
|
|
1000
|
+
self.interaction.selected_layer_id = hit["layer_id"]
|
|
1001
|
+
|
|
1002
|
+
# Specific logic for lines to get exact Y
|
|
1003
|
+
if hit["type"] == "line_family" and hit["layer"].ab is not None:
|
|
1004
|
+
ei = hit["element_idx"]
|
|
1005
|
+
layer = hit["layer"]
|
|
1006
|
+
tx, ty = layer.translation
|
|
1007
|
+
wx = self.picked_info["x"]
|
|
1008
|
+
# Line Eq is local: y_local = a * (wx - tx) + b
|
|
1009
|
+
# Then y_global = y_local + ty
|
|
1010
|
+
y_local = layer.ab[ei, 0] * (wx - tx) + layer.ab[ei, 1]
|
|
1011
|
+
self.picked_info["y"] = y_local + ty
|
|
1012
|
+
else:
|
|
1013
|
+
self.picked_info = None
|
|
1014
|
+
# We don't clear selected_layer_id on "miss" to allow dragging it
|
|
1015
|
+
# after selection even if the cursor moves off.
|
|
1016
|
+
def get_xlim(self) -> Tuple[float, float]:
|
|
1017
|
+
l, r, _, _ = self.camera_controller.world_window(self.width, self.height)
|
|
1018
|
+
return l, r
|
|
1019
|
+
|
|
1020
|
+
def get_ylim(self) -> Tuple[float, float]:
|
|
1021
|
+
_, _, b, t = self.camera_controller.world_window(self.width, self.height)
|
|
1022
|
+
return b, t
|
|
1023
|
+
|
|
1024
|
+
def savefig(self, filename: str, scale: float = 2.0) -> None:
|
|
1025
|
+
self.export.savefig(filename, scale=scale)
|
|
1026
|
+
|
|
1027
|
+
def _create_rgba_fbo(self, width: int, height: int) -> Tuple[int, int]:
|
|
1028
|
+
fbo = glGenFramebuffers(1)
|
|
1029
|
+
glBindFramebuffer(GL_FRAMEBUFFER, fbo)
|
|
1030
|
+
tex = glGenTextures(1)
|
|
1031
|
+
glBindTexture(GL_TEXTURE_2D, tex)
|
|
1032
|
+
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, None)
|
|
1033
|
+
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR)
|
|
1034
|
+
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR)
|
|
1035
|
+
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, tex, 0)
|
|
1036
|
+
|
|
1037
|
+
if glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE:
|
|
1038
|
+
glBindFramebuffer(GL_FRAMEBUFFER, 0)
|
|
1039
|
+
glDeleteFramebuffers(1, [fbo])
|
|
1040
|
+
glDeleteTextures(1, [tex])
|
|
1041
|
+
raise RuntimeError("Failed to create RGBA export framebuffer")
|
|
1042
|
+
return fbo, tex
|
|
1043
|
+
|
|
1044
|
+
def capture_snapshot(
|
|
1045
|
+
self,
|
|
1046
|
+
scale: float = 1.0,
|
|
1047
|
+
transparent: bool = True,
|
|
1048
|
+
include_axes: bool = False,
|
|
1049
|
+
include_postfx: bool = True,
|
|
1050
|
+
preserve_screen_space_styles: bool = True
|
|
1051
|
+
) -> "GLPlotSnapshot":
|
|
1052
|
+
"""
|
|
1053
|
+
Level 1 API: Capture the current viewport as a raster image + extent.
|
|
1054
|
+
Ensures perfect GL state restoration.
|
|
1055
|
+
"""
|
|
1056
|
+
from .utils.mpl_bridge import GLPlotSnapshot
|
|
1057
|
+
|
|
1058
|
+
target_w = max(1, int(round(self.fb_width * scale)))
|
|
1059
|
+
target_h = max(1, int(round(self.fb_height * scale)))
|
|
1060
|
+
|
|
1061
|
+
# Capture state to restore
|
|
1062
|
+
prev_fbo = glGetIntegerv(GL_FRAMEBUFFER_BINDING)
|
|
1063
|
+
prev_viewport = glGetIntegerv(GL_VIEWPORT)
|
|
1064
|
+
prev_clear_col = glGetFloatv(GL_COLOR_CLEAR_VALUE)
|
|
1065
|
+
|
|
1066
|
+
fbo, tex = self._create_rgba_fbo(target_w, target_h)
|
|
1067
|
+
|
|
1068
|
+
xmin, xmax = self.get_xlim()
|
|
1069
|
+
ymin, ymax = self.get_ylim()
|
|
1070
|
+
window = (xmin, xmax, ymin, ymax)
|
|
1071
|
+
|
|
1072
|
+
try:
|
|
1073
|
+
glBindFramebuffer(GL_FRAMEBUFFER, fbo)
|
|
1074
|
+
glViewport(0, 0, target_w, target_h)
|
|
1075
|
+
|
|
1076
|
+
if transparent:
|
|
1077
|
+
glClearColor(0.0, 0.0, 0.0, 0.0)
|
|
1078
|
+
else:
|
|
1079
|
+
c = self.options.visual.background_color
|
|
1080
|
+
glClearColor(c[0], c[1], c[2], 1.0)
|
|
1081
|
+
glClear(GL_COLOR_BUFFER_BIT)
|
|
1082
|
+
|
|
1083
|
+
# Style scaling for high-res
|
|
1084
|
+
style_scale = scale if preserve_screen_space_styles else 1.0
|
|
1085
|
+
|
|
1086
|
+
mvp = self.camera_controller.mvp(self.width, self.height)
|
|
1087
|
+
ndc_scale, ndc_offset = self._get_ndc_transform(window)
|
|
1088
|
+
prob = self._compute_lod_keep_prob()
|
|
1089
|
+
alpha = self._get_adaptive_alpha(self.scene.lines.count)
|
|
1090
|
+
|
|
1091
|
+
ctx = RenderContext(
|
|
1092
|
+
mvp=mvp,
|
|
1093
|
+
window_world=window,
|
|
1094
|
+
ndc_scale=ndc_scale,
|
|
1095
|
+
ndc_offset=ndc_offset,
|
|
1096
|
+
width_px=target_w,
|
|
1097
|
+
height_px=target_h,
|
|
1098
|
+
fb_width=target_w,
|
|
1099
|
+
fb_height=target_h,
|
|
1100
|
+
dpr=style_scale * (self.fb_width / max(self.width, 1)),
|
|
1101
|
+
mode=self.policy.runtime.current_mode,
|
|
1102
|
+
global_alpha=alpha,
|
|
1103
|
+
lod_keep_prob=prob,
|
|
1104
|
+
time=time.perf_counter()
|
|
1105
|
+
)
|
|
1106
|
+
|
|
1107
|
+
self._apply_blending_policy()
|
|
1108
|
+
layers = self._get_all_layers()
|
|
1109
|
+
|
|
1110
|
+
if include_axes:
|
|
1111
|
+
self.axis_manager.update(ctx)
|
|
1112
|
+
self.renderer_manager.draw_axes(self.axis_manager, ctx)
|
|
1113
|
+
|
|
1114
|
+
# Pass to modular managers
|
|
1115
|
+
if self.display_density:
|
|
1116
|
+
self.renderer_manager.draw_density(layers, ctx, target_fbo=fbo, target_size=(target_w, target_h))
|
|
1117
|
+
else:
|
|
1118
|
+
self.renderer_manager.draw_exact(layers, ctx)
|
|
1119
|
+
|
|
1120
|
+
# Text overlay
|
|
1121
|
+
self.renderer_manager.renderers["text"].draw_all(layers, ctx)
|
|
1122
|
+
|
|
1123
|
+
glReadBuffer(GL_COLOR_ATTACHMENT0)
|
|
1124
|
+
glPixelStorei(GL_PACK_ALIGNMENT, 1)
|
|
1125
|
+
raw = glReadPixels(0, 0, target_w, target_h, GL_RGBA, GL_UNSIGNED_BYTE)
|
|
1126
|
+
rgba = np.frombuffer(raw, dtype=np.uint8).reshape((target_h, target_w, 4))
|
|
1127
|
+
rgba = np.flipud(rgba)
|
|
1128
|
+
|
|
1129
|
+
finally:
|
|
1130
|
+
glBindFramebuffer(GL_FRAMEBUFFER, prev_fbo)
|
|
1131
|
+
glViewport(*prev_viewport)
|
|
1132
|
+
glClearColor(*prev_clear_col)
|
|
1133
|
+
glDeleteFramebuffers(1, [fbo])
|
|
1134
|
+
glDeleteTextures(1, [tex])
|
|
1135
|
+
|
|
1136
|
+
return GLPlotSnapshot(
|
|
1137
|
+
rgba=rgba,
|
|
1138
|
+
extent=window,
|
|
1139
|
+
xlim=(xmin, xmax),
|
|
1140
|
+
ylim=(ymin, ymax),
|
|
1141
|
+
width_px=target_w,
|
|
1142
|
+
height_px=target_h,
|
|
1143
|
+
transparent=transparent
|
|
1144
|
+
)
|
|
1145
|
+
|
|
1146
|
+
def to_matplotlib(self, ax=None, **kwargs):
|
|
1147
|
+
"""Level 2 API: Render and embed directly into Matplotlib."""
|
|
1148
|
+
from .utils.mpl_bridge import snapshot_to_matplotlib
|
|
1149
|
+
snap = self.capture_snapshot(**kwargs)
|
|
1150
|
+
return snapshot_to_matplotlib(snap, ax=ax)
|
|
1151
|
+
|
|
1152
|
+
def set_matplotlib_transfer_target(self, ax=None, callback=None):
|
|
1153
|
+
"""Level 3 API Setup: Redirect 'M' key transfers."""
|
|
1154
|
+
self._mpl_transfer_ax = ax
|
|
1155
|
+
self._mpl_transfer_callback = callback
|
|
1156
|
+
|
|
1157
|
+
def transfer_to_matplotlib_default(self):
|
|
1158
|
+
"""Default action for Key 'M'."""
|
|
1159
|
+
if hasattr(self, "_mpl_transfer_callback") and self._mpl_transfer_callback:
|
|
1160
|
+
snap = self.capture_snapshot(scale=2.0)
|
|
1161
|
+
self._mpl_transfer_callback(snap)
|
|
1162
|
+
return
|
|
1163
|
+
|
|
1164
|
+
import matplotlib.pyplot as plt
|
|
1165
|
+
ax = getattr(self, "_mpl_transfer_ax", None)
|
|
1166
|
+
fig, ax, artist = self.to_matplotlib(ax=ax, scale=2.0)
|
|
1167
|
+
plt.show(block=False)
|
|
1168
|
+
fig.canvas.draw_idle()
|
|
1169
|
+
|
|
1170
|
+
def toggle_line_colormap(self) -> None:
|
|
1171
|
+
self.options.line_colormap_enabled = not self.options.line_colormap_enabled
|
|
1172
|
+
self.frame.dirty_scene = True
|
|
1173
|
+
|
|
1174
|
+
def _on_key(self, window, key, sc, action, mods) -> None:
|
|
1175
|
+
self.hud.on_key(window, key, sc, action, mods)
|
|
1176
|
+
|
|
1177
|
+
if action in (glfw.PRESS, glfw.REPEAT):
|
|
1178
|
+
shift = (mods & glfw.MOD_SHIFT)
|
|
1179
|
+
|
|
1180
|
+
if key == glfw.KEY_ESCAPE:
|
|
1181
|
+
glfw.set_window_should_close(self.window, True)
|
|
1182
|
+
|
|
1183
|
+
elif key in (glfw.KEY_R, glfw.KEY_HOME):
|
|
1184
|
+
self.reset_view()
|
|
1185
|
+
|
|
1186
|
+
elif key == glfw.KEY_D and action == glfw.PRESS:
|
|
1187
|
+
self.toggle_density()
|
|
1188
|
+
|
|
1189
|
+
elif key == glfw.KEY_C and action == glfw.PRESS:
|
|
1190
|
+
self.toggle_line_colormap()
|
|
1191
|
+
|
|
1192
|
+
# --- Visual Parameters (Arrows) ---
|
|
1193
|
+
if key == glfw.KEY_UP:
|
|
1194
|
+
if self.display_density:
|
|
1195
|
+
self.options.density_gain *= 1.2
|
|
1196
|
+
else:
|
|
1197
|
+
self.options.default_global_alpha = min(1.0, self.options.default_global_alpha * 1.2)
|
|
1198
|
+
self.frame.dirty_scene = True
|
|
1199
|
+
self.frame.dirty_ui = True
|
|
1200
|
+
|
|
1201
|
+
elif key == glfw.KEY_DOWN:
|
|
1202
|
+
if self.display_density:
|
|
1203
|
+
self.options.density_gain /= 1.2
|
|
1204
|
+
else:
|
|
1205
|
+
self.options.default_global_alpha = max(0.001, self.options.default_global_alpha / 1.2)
|
|
1206
|
+
self.frame.dirty_scene = True
|
|
1207
|
+
self.frame.dirty_ui = True
|
|
1208
|
+
|
|
1209
|
+
elif key == glfw.KEY_LEFT:
|
|
1210
|
+
self.previous_density_scheme()
|
|
1211
|
+
|
|
1212
|
+
elif key == glfw.KEY_RIGHT:
|
|
1213
|
+
self.next_density_scheme()
|
|
1214
|
+
|
|
1215
|
+
# --- Global Density / Style Controls (PgUp/PgDn and Brackets) ---
|
|
1216
|
+
|
|
1217
|
+
# --- Zoom ---
|
|
1218
|
+
elif key == glfw.KEY_EQUAL or key == glfw.KEY_KP_ADD:
|
|
1219
|
+
self.camera_controller.apply_zoom_at_cursor(
|
|
1220
|
+
self.options.zoom_scroll_factor,
|
|
1221
|
+
self.width * 0.5,
|
|
1222
|
+
self.height * 0.5,
|
|
1223
|
+
self.width,
|
|
1224
|
+
self.height
|
|
1225
|
+
)
|
|
1226
|
+
|
|
1227
|
+
elif key == glfw.KEY_MINUS or key == glfw.KEY_KP_SUBTRACT:
|
|
1228
|
+
self.camera_controller.apply_zoom_at_cursor(
|
|
1229
|
+
1.0 / self.options.zoom_scroll_factor,
|
|
1230
|
+
self.width * 0.5,
|
|
1231
|
+
self.height * 0.5,
|
|
1232
|
+
self.width,
|
|
1233
|
+
self.height
|
|
1234
|
+
)
|
|
1235
|
+
|
|
1236
|
+
elif key == glfw.KEY_B and action == glfw.PRESS:
|
|
1237
|
+
self.cycle_blending_mode()
|
|
1238
|
+
|
|
1239
|
+
elif key == glfw.KEY_BACKSLASH and action == glfw.PRESS:
|
|
1240
|
+
self.options.enable_auto_alpha = not self.options.enable_auto_alpha
|
|
1241
|
+
self.frame.dirty_scene = True
|
|
1242
|
+
|
|
1243
|
+
elif key == glfw.KEY_LEFT_BRACKET and action in (glfw.PRESS, glfw.REPEAT):
|
|
1244
|
+
self.options.density_log_scale = max(0.1, self.options.density_log_scale - 0.2)
|
|
1245
|
+
self.frame.dirty_scene = True
|
|
1246
|
+
|
|
1247
|
+
elif key == glfw.KEY_RIGHT_BRACKET and action in (glfw.PRESS, glfw.REPEAT):
|
|
1248
|
+
self.options.density_log_scale += 0.2
|
|
1249
|
+
self.frame.dirty_scene = True
|
|
1250
|
+
|
|
1251
|
+
elif key == glfw.KEY_H and action == glfw.PRESS:
|
|
1252
|
+
self.set_hud_enabled(not self.options.enable_hud)
|
|
1253
|
+
|
|
1254
|
+
elif key == glfw.KEY_S and action == glfw.PRESS:
|
|
1255
|
+
self.savefig(f"plot_{int(time.time())}.png", scale=self.options.export_scale)
|
|
1256
|
+
|
|
1257
|
+
elif key == glfw.KEY_M and action == glfw.PRESS:
|
|
1258
|
+
self.transfer_to_matplotlib_default()
|
|
1259
|
+
|
|
1260
|
+
self.frame.dirty_scene = True
|
|
1261
|
+
|
|
1262
|
+
if action == glfw.PRESS:
|
|
1263
|
+
if key in (glfw.KEY_LEFT_SHIFT, glfw.KEY_RIGHT_SHIFT):
|
|
1264
|
+
self.interaction.shift_down = True
|
|
1265
|
+
elif action == glfw.RELEASE:
|
|
1266
|
+
if key in (glfw.KEY_LEFT_SHIFT, glfw.KEY_RIGHT_SHIFT):
|
|
1267
|
+
self.interaction.shift_down = False
|
|
1268
|
+
|
|
1269
|
+
def _on_char(self, window, char) -> None:
|
|
1270
|
+
self.hud.on_char(window, char)
|