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/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)