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/pyplot.py ADDED
@@ -0,0 +1,735 @@
1
+ from __future__ import annotations
2
+
3
+ import atexit
4
+ from typing import Optional, Tuple, Sequence, Union, Literal, Iterable, Any
5
+
6
+ import numpy as np
7
+
8
+ from .engine import GPULinePlot
9
+
10
+
11
+ ColorLike = Union[
12
+ Tuple[float, float, float, float],
13
+ Sequence[float],
14
+ np.ndarray,
15
+ ]
16
+
17
+ BlendMode = Literal["auto", "on", "off"]
18
+
19
+
20
+ # ------------------------------------------------------------------
21
+ # Global pyplot-like state
22
+ # ------------------------------------------------------------------
23
+
24
+ _CURRENT_PLOT: Optional[GPULinePlot] = None
25
+ _ALL_PLOTS: list[GPULinePlot] = []
26
+
27
+
28
+ # ------------------------------------------------------------------
29
+ # Internal helpers
30
+ # ------------------------------------------------------------------
31
+
32
+ def _as_float_array(x, ndim: Optional[int] = None, name: str = "array") -> np.ndarray:
33
+ arr = np.asarray(x, dtype=np.float32)
34
+ if ndim is not None and arr.ndim != ndim:
35
+ raise ValueError(f"{name} must have ndim={ndim}, got {arr.ndim}")
36
+ return np.ascontiguousarray(arr)
37
+
38
+
39
+ def _normalize_rgba(
40
+ color: Optional[ColorLike],
41
+ n: Optional[int] = None,
42
+ default=(0.0, 0.0, 0.0, 1.0),
43
+ ) -> np.ndarray:
44
+ """
45
+ Returns:
46
+ - shape (4,) if n is None
47
+ - shape (n,4) if n is given
48
+ """
49
+ COLOR_MAP = {
50
+ 'white': (1.0, 1.0, 1.0, 1.0),
51
+ 'black': (0.0, 0.0, 0.0, 1.0),
52
+ 'red': (1.0, 0.0, 0.0, 1.0),
53
+ 'green': (0.0, 1.0, 0.0, 1.0),
54
+ 'blue': (0.0, 0.0, 1.0, 1.0),
55
+ 'cyan': (0.0, 1.0, 1.0, 1.0),
56
+ 'magenta': (1.0, 0.0, 1.0, 1.0),
57
+ 'yellow': (1.0, 1.0, 0.0, 1.0),
58
+ 'k': (0.0, 0.0, 0.0, 1.0),
59
+ 'w': (1.0, 1.0, 1.0, 1.0),
60
+ 'r': (1.0, 0.0, 0.0, 1.0),
61
+ 'g': (0.0, 1.0, 0.0, 1.0),
62
+ 'b': (0.0, 0.0, 1.0, 1.0),
63
+ }
64
+
65
+ if color is None:
66
+ base = np.asarray(default, dtype=np.float32)
67
+ elif isinstance(color, str):
68
+ c_val = COLOR_MAP.get(color.lower(), (0, 0, 0, 1))
69
+ base = np.asarray(c_val, dtype=np.float32)
70
+ else:
71
+ try:
72
+ base = np.asarray(color, dtype=np.float32)
73
+ except (ValueError, TypeError):
74
+ base = np.asarray(default, dtype=np.float32)
75
+
76
+ if n is None:
77
+ if base.ndim == 0: # single value broadcast
78
+ base = np.array([base, base, base, 1.0], dtype=np.float32)
79
+ if base.shape != (4,):
80
+ # Fallback if it's RGB
81
+ if base.shape == (3,):
82
+ base = np.array([base[0], base[1], base[2], 1.0], dtype=np.float32)
83
+ else:
84
+ raise ValueError(f"color must be a single RGBA tuple with shape (4,), got {base.shape}")
85
+ return np.ascontiguousarray(np.clip(base, 0.0, 1.0))
86
+
87
+ # Per-object/per-point color
88
+ if base.ndim == 1:
89
+ if base.shape == (3,):
90
+ base = np.array([base[0], base[1], base[2], 1.0], dtype=np.float32)
91
+ if base.shape != (4,):
92
+ raise ValueError("single color must have shape (4,)")
93
+ out = np.tile(base, (n, 1))
94
+ return np.ascontiguousarray(np.clip(out, 0.0, 1.0))
95
+
96
+ if base.ndim == 2:
97
+ if base.shape != (n, 4):
98
+ # Handle (n, 3)
99
+ if base.shape == (n, 3):
100
+ new_base = np.ones((n, 4), dtype=np.float32)
101
+ new_base[:, :3] = base
102
+ base = new_base
103
+ else:
104
+ raise ValueError(f"color array must have shape ({n},4), got {base.shape}")
105
+ return np.ascontiguousarray(np.clip(base, 0.0, 1.0))
106
+
107
+ raise ValueError("invalid color format")
108
+
109
+
110
+ def _get_or_create_plot() -> GPULinePlot:
111
+ global _CURRENT_PLOT
112
+ if _CURRENT_PLOT is None:
113
+ _CURRENT_PLOT = GPULinePlot()
114
+ _ALL_PLOTS.append(_CURRENT_PLOT)
115
+ return _CURRENT_PLOT
116
+
117
+ def get_engine() -> GPULinePlot:
118
+ """Returns the current active GPULinePlot engine, or creates one if it doesn't exist."""
119
+ return _get_or_create_plot()
120
+
121
+
122
+ def _set_dirty(plot: GPULinePlot) -> None:
123
+ if hasattr(plot, "view") and hasattr(plot.view, "dirty"):
124
+ plot.view.dirty = True
125
+ elif hasattr(plot, "frame") and hasattr(plot.frame, "dirty_scene"):
126
+ plot.frame.dirty_scene = True
127
+
128
+
129
+ def _call_if_exists(plot: GPULinePlot, method_names: Sequence[str], *args, **kwargs):
130
+ for name in method_names:
131
+ fn = getattr(plot, name, None)
132
+ if callable(fn):
133
+ return fn(*args, **kwargs)
134
+ return None
135
+
136
+
137
+ def _set_density(plot: GPULinePlot, enabled: bool) -> None:
138
+ if _call_if_exists(plot, ("set_density_enabled", "set_density_mode"), enabled) is not None:
139
+ return
140
+ if hasattr(plot, "view") and hasattr(plot.view, "show_density"):
141
+ plot.view.show_density = bool(enabled)
142
+ elif hasattr(plot, "show_density"):
143
+ plot.show_density = bool(enabled)
144
+ _set_dirty(plot)
145
+
146
+
147
+ def _set_hud(plot: GPULinePlot, enabled: bool) -> None:
148
+ if _call_if_exists(plot, ("set_hud_enabled",), enabled) is not None:
149
+ return
150
+ if hasattr(plot, "view") and hasattr(plot.view, "hud_visible"):
151
+ plot.view.hud_visible = bool(enabled)
152
+ _set_dirty(plot)
153
+
154
+
155
+ def _set_blending(plot: GPULinePlot, mode: BlendMode) -> None:
156
+ # Preferred backend API
157
+ if _call_if_exists(plot, ("set_blending_mode",), mode) is not None:
158
+ return
159
+
160
+ # Fallback attributes if backend stores policy directly
161
+ if hasattr(plot, "blending_mode"):
162
+ plot.blending_mode = mode
163
+ elif hasattr(plot, "policy") and hasattr(plot.policy, "runtime"):
164
+ # do not mutate runtime every frame if backend owns policy;
165
+ # this is just a fallback
166
+ plot.blending_mode = mode
167
+ _set_dirty(plot)
168
+
169
+
170
+ def _set_title(plot: GPULinePlot, title: str) -> None:
171
+ if _call_if_exists(plot, ("set_title",), title) is not None:
172
+ return
173
+ if hasattr(plot, "title"):
174
+ plot.title = str(title)
175
+ _set_dirty(plot)
176
+
177
+
178
+ def _set_view_limits(
179
+ plot: GPULinePlot,
180
+ xlim: Optional[Tuple[float, float]] = None,
181
+ ylim: Optional[Tuple[float, float]] = None,
182
+ ) -> None:
183
+ if _call_if_exists(plot, ("set_view",), xlim=xlim, ylim=ylim) is not None:
184
+ return
185
+
186
+ # Fallback only if backend exposes camera-like state
187
+ if hasattr(plot, "view"):
188
+ if xlim is not None and ylim is not None:
189
+ xmin, xmax = float(xlim[0]), float(xlim[1])
190
+ ymin, ymax = float(ylim[0]), float(ylim[1])
191
+ if xmax <= xmin or ymax <= ymin:
192
+ raise ValueError("invalid limits")
193
+ cx = 0.5 * (xmin + xmax)
194
+ cy = 0.5 * (ymin + ymax)
195
+ half_h = 0.5 * (ymax - ymin)
196
+ if hasattr(plot, "width") and hasattr(plot, "height"):
197
+ aspect = max(plot.width, 1) / max(plot.height, 1)
198
+ if aspect <= 0:
199
+ aspect = 1.0
200
+ # backend world_window uses half_h = padding / zoom
201
+ zoom = 1.0 / max(half_h, 1e-12)
202
+ plot.view.cx = cx
203
+ plot.view.cy = cy
204
+ plot.view.zoom = zoom
205
+ _set_dirty(plot)
206
+ return
207
+
208
+ raise AttributeError("Backend does not expose a compatible set_view/xlim/ylim API")
209
+
210
+
211
+ # ------------------------------------------------------------------
212
+ # Figure management
213
+ # ------------------------------------------------------------------
214
+
215
+ def figure(
216
+ title: str = "GLPlot",
217
+ width: int = 1280,
218
+ height: int = 800,
219
+ *,
220
+ hud: bool = False,
221
+ density: bool = False,
222
+ blending: BlendMode = "auto",
223
+ lod: bool = True,
224
+ budget: int = 8,
225
+ multisample: bool = False,
226
+ cache: bool = True,
227
+ clipping: bool = True,
228
+ ) -> GPULinePlot:
229
+ """
230
+ Create a new figure and make it current.
231
+ """
232
+ global _CURRENT_PLOT
233
+ plot = GPULinePlot(width=width, height=height, title=title)
234
+
235
+ # Apply optimization settings
236
+ plot.options.lod_enabled = bool(lod)
237
+ plot.options.lod_target_coverage = float(budget) / 8.0
238
+ plot.options.enable_hud = bool(hud)
239
+ plot.options.enable_multisample = bool(multisample)
240
+ plot.options.enable_cache_interaction_path = bool(cache)
241
+ plot.options.enable_clipping_optimization = bool(clipping)
242
+
243
+ _set_hud(plot, hud)
244
+ _set_density(plot, density)
245
+ _set_blending(plot, blending)
246
+
247
+ _CURRENT_PLOT = plot
248
+ _ALL_PLOTS.append(plot)
249
+ _set_dirty(plot)
250
+ return plot
251
+
252
+
253
+ def gcf() -> GPULinePlot:
254
+ """Get current figure."""
255
+ return _get_or_create_plot()
256
+
257
+
258
+ def options(**kwargs):
259
+ """
260
+ Update EngineOptions for the current figure.
261
+ Example: gplt.options(density_resolution_scale=0.5, cache_refresh_hz=60)
262
+ """
263
+ plot = _get_or_create_plot()
264
+ for k, v in kwargs.items():
265
+ if hasattr(plot.options, k):
266
+ setattr(plot.options, k, v)
267
+ else:
268
+ raise AttributeError(f"EngineOptions has no attribute '{k}'")
269
+ _set_dirty(plot)
270
+
271
+
272
+ def subplots(
273
+ title: str = "GLPlot",
274
+ width: int = 1280,
275
+ height: int = 800,
276
+ **kwargs,
277
+ ):
278
+ """
279
+ Matplotlib-like convenience.
280
+ For now this backend manages a single interactive axes/view.
281
+ Returns (fig, ax_like), both pointing to the same GPULinePlot object.
282
+ """
283
+ fig = figure(title=title, width=width, height=height, **kwargs)
284
+ return fig, fig
285
+
286
+
287
+ def close(fig: Optional[GPULinePlot] = None) -> None:
288
+ """
289
+ Close a figure reference from pyplot state.
290
+ Note: actual window destruction depends on backend lifecycle.
291
+ """
292
+ global _CURRENT_PLOT
293
+
294
+ if fig is None:
295
+ fig = _CURRENT_PLOT
296
+
297
+ if fig is None:
298
+ return
299
+
300
+ try:
301
+ _ALL_PLOTS.remove(fig)
302
+ except ValueError:
303
+ pass
304
+
305
+ if fig is _CURRENT_PLOT:
306
+ _CURRENT_PLOT = _ALL_PLOTS[-1] if _ALL_PLOTS else None
307
+
308
+ # Optional backend hook
309
+ _call_if_exists(fig, ("close", "shutdown"))
310
+
311
+
312
+ def clf() -> None:
313
+ """Clear current figure."""
314
+ plot = _get_or_create_plot()
315
+ if _call_if_exists(plot, ("clear", "clf", "reset_scene")) is not None:
316
+ _set_dirty(plot)
317
+ return
318
+
319
+ # Conservative fallback
320
+ if hasattr(plot, "_line_strips"):
321
+ plot._line_strips.clear()
322
+ if hasattr(plot, "_scatters"):
323
+ plot._scatters.clear()
324
+ if hasattr(plot, "_spatial_texts"):
325
+ plot._spatial_texts.clear()
326
+ if hasattr(plot, "N"):
327
+ plot.N = 0
328
+ if hasattr(plot, "_cpu_ab"):
329
+ plot._cpu_ab = None
330
+ _set_dirty(plot)
331
+
332
+
333
+ def cla() -> None:
334
+ """Alias for clf() in this single-axes backend."""
335
+ clf()
336
+
337
+
338
+ # ------------------------------------------------------------------
339
+ # Plotting primitives
340
+ # ------------------------------------------------------------------
341
+
342
+ def lines(
343
+ a: Sequence[float],
344
+ b: Sequence[float],
345
+ x_range: Tuple[float, float],
346
+ color: Optional[ColorLike] = None,
347
+ width: float = 1.0,
348
+ alpha: Optional[float] = None,
349
+ label: Optional[str] = None,
350
+ ):
351
+ """
352
+ Plot many lines in the form y = a*x + b.
353
+ This is the main high-performance primitive.
354
+ """
355
+ plot = _get_or_create_plot()
356
+
357
+ a_arr = _as_float_array(a, ndim=1, name="a")
358
+ b_arr = _as_float_array(b, ndim=1, name="b")
359
+ if len(a_arr) != len(b_arr):
360
+ raise ValueError("a and b must have the same length")
361
+
362
+ ab = np.column_stack([a_arr, b_arr]).astype(np.float32, copy=False)
363
+
364
+ # Resolve color and alpha
365
+ cols = _normalize_rgba(color, n=len(ab)) if color is not None else None
366
+ if alpha is not None:
367
+ if cols is None:
368
+ # Default to black with alpha
369
+ cols = np.zeros((len(ab), 4), dtype=np.float32)
370
+ cols[:, 3] = float(alpha)
371
+ else:
372
+ cols[:, 3] *= float(alpha)
373
+
374
+ plot.set_lines_ab(ab, x_range=x_range, colors=cols)
375
+
376
+ if hasattr(plot.scene.lines, "style"):
377
+ plot.scene.lines.style.line_width = float(width)
378
+ if alpha is not None:
379
+ plot.scene.lines.style.alpha = float(alpha)
380
+ plot.scene.lines.label = label or "Lines"
381
+
382
+ _set_dirty(plot)
383
+ return plot
384
+
385
+
386
+ def plot_lines(
387
+ a: Sequence[float],
388
+ b: Sequence[float],
389
+ x_range: Tuple[float, float],
390
+ colors: Optional[np.ndarray] = None,
391
+ ):
392
+ """
393
+ Backward-compatible alias for line family plotting.
394
+ """
395
+ plot = _get_or_create_plot()
396
+
397
+ a_arr = _as_float_array(a, ndim=1, name="a")
398
+ b_arr = _as_float_array(b, ndim=1, name="b")
399
+ if len(a_arr) != len(b_arr):
400
+ raise ValueError("a and b must have the same length")
401
+
402
+ ab = np.column_stack([a_arr, b_arr]).astype(np.float32, copy=False)
403
+ cols = None if colors is None else _as_float_array(colors, ndim=2, name="colors")
404
+ plot.set_lines_ab(ab, x_range=x_range, colors=cols)
405
+ _set_dirty(plot)
406
+ return plot
407
+
408
+
409
+ def plot(
410
+ x: Sequence[float],
411
+ y: Sequence[float],
412
+ color: ColorLike = (0.0, 0.0, 0.0, 1.0),
413
+ width: float = 1.0,
414
+ alpha: Optional[float] = None,
415
+ label: Optional[str] = None,
416
+ ):
417
+ """
418
+ Plot a traditional connected polyline.
419
+ """
420
+ plot_obj = _get_or_create_plot()
421
+ x_arr = _as_float_array(x, ndim=1, name="x")
422
+ y_arr = _as_float_array(y, ndim=1, name="y")
423
+
424
+ if len(x_arr) != len(y_arr):
425
+ raise ValueError("x and y must have the same length")
426
+ if len(x_arr) < 2:
427
+ return plot_obj
428
+
429
+ rgba = list(_normalize_rgba(color, n=None))
430
+ if alpha is not None:
431
+ rgba[3] *= float(alpha)
432
+
433
+ plot_obj.add_line_strip(x_arr, y_arr, tuple(rgba), width=float(width), label=label)
434
+ _set_dirty(plot_obj)
435
+ return plot_obj
436
+
437
+
438
+ def scatter(
439
+ x: Sequence[float],
440
+ y: Sequence[float],
441
+ color: ColorLike = (0.0, 0.0, 0.0, 1.0),
442
+ size: float = 10.0,
443
+ ):
444
+ """
445
+ Scatter plot.
446
+ """
447
+ plot_obj = _get_or_create_plot()
448
+ x_arr = _as_float_array(x, ndim=1, name="x")
449
+ y_arr = _as_float_array(y, ndim=1, name="y")
450
+
451
+ if len(x_arr) != len(y_arr):
452
+ raise ValueError("x and y must have the same length")
453
+
454
+ cols = _normalize_rgba(color, n=len(x_arr))
455
+ plot_obj.add_scatter(x_arr, y_arr, cols, float(size))
456
+ _set_dirty(plot_obj)
457
+ return plot_obj
458
+
459
+
460
+ def text(
461
+ x: float,
462
+ y: float,
463
+ s: str,
464
+ fontsize: int = 12,
465
+ color: ColorLike = (0.0, 0.0, 0.0, 1.0),
466
+ label: Optional[str] = None,
467
+ ):
468
+ """
469
+ Add text annotation.
470
+ """
471
+ plot_obj = _get_or_create_plot()
472
+
473
+ rgba = _normalize_rgba(color, n=None)
474
+ # backend may ignore fontsize/color for now, but keep API stable
475
+ plot_obj.add_text(float(x), float(y), str(s), fontsize=int(fontsize), color=rgba, label=label)
476
+ _set_dirty(plot_obj)
477
+ return plot_obj
478
+
479
+
480
+ def add_patch(
481
+ vertices: Union[np.ndarray, Sequence],
482
+ indices: Optional[np.ndarray] = None,
483
+ mode: str = "strip",
484
+ face_color: Optional[ColorLike] = None,
485
+ edge_color: Optional[ColorLike] = None,
486
+ label: Optional[str] = None,
487
+ ):
488
+ """
489
+ Add a geometric patch (polygon, strip, etc.) to the plot.
490
+ """
491
+ plot_obj = _get_or_create_plot()
492
+
493
+ verts = _as_float_array(vertices, ndim=2, name="vertices")
494
+ f_col = _normalize_rgba(face_color, n=None) if face_color is not None else None
495
+ e_col = _normalize_rgba(edge_color, n=None) if edge_color is not None else None
496
+
497
+ plot_obj.add_patch(
498
+ verts, indices=indices, mode=mode,
499
+ face_color=tuple(f_col) if f_col is not None else None,
500
+ edge_color=tuple(e_col) if e_col is not None else None,
501
+ label=label
502
+ )
503
+ _set_dirty(plot_obj)
504
+ return plot_obj
505
+
506
+
507
+ # ------------------------------------------------------------------
508
+ # View / styling / policies
509
+ # ------------------------------------------------------------------
510
+
511
+ def title(s: str) -> None:
512
+ plot = _get_or_create_plot()
513
+ _set_title(plot, s)
514
+
515
+
516
+ def xlim(left: Optional[float] = None, right: Optional[float] = None) -> Optional[Tuple[float, float]]:
517
+ """
518
+ Get or set the x-limits of the current axes.
519
+ """
520
+ plot = _get_or_create_plot()
521
+ if left is None and right is None:
522
+ return plot.get_xlim()
523
+
524
+ plot.set_view(xlim=(left, right))
525
+ _set_dirty(plot)
526
+ return (left, right)
527
+
528
+
529
+ def ylim(bottom: Optional[float] = None, top: Optional[float] = None) -> Optional[Tuple[float, float]]:
530
+ """
531
+ Get or set the y-limits of the current axes.
532
+ """
533
+ plot = _get_or_create_plot()
534
+ if bottom is None and top is None:
535
+ return plot.get_ylim()
536
+
537
+ plot.set_view(ylim=(bottom, top))
538
+ _set_dirty(plot)
539
+ return (bottom, top)
540
+
541
+
542
+ def axis(mode: Union[str, Tuple[float, float, float, float]] = "auto") -> Optional[Tuple[float, float, float, float]]:
543
+ """
544
+ Supported:
545
+ axis("auto")
546
+ axis("tight")
547
+ axis("reset")
548
+ axis((xmin, xmax, ymin, ymax))
549
+ """
550
+ plot = _get_or_create_plot()
551
+
552
+ if isinstance(mode, str):
553
+ m = mode.lower()
554
+ if m in ("auto", "tight"):
555
+ if _call_if_exists(plot, ("autoscale", "auto_view", "fit_view")) is None:
556
+ raise AttributeError("Backend does not expose autoscale()/fit_view()")
557
+ _set_dirty(plot)
558
+ return None
559
+ if m in ("reset", "home"):
560
+ if _call_if_exists(plot, ("reset_view", "home_view")) is None:
561
+ plot.set_view(xlim=(-1.0, 1.0), ylim=(-1.0, 1.0)) # Absolute reset fallback
562
+ _set_dirty(plot)
563
+ return None
564
+ raise ValueError(f"unsupported axis mode: {mode}")
565
+
566
+ if len(mode) != 4:
567
+ raise ValueError("axis tuple must be (xmin, xmax, ymin, ymax)")
568
+
569
+ xmin, xmax, ymin, ymax = map(float, mode)
570
+ plot.set_view(xlim=(xmin, xmax), ylim=(ymin, ymax))
571
+ _set_dirty(plot)
572
+ return (xmin, xmax, ymin, ymax)
573
+
574
+
575
+ def autoscale() -> None:
576
+ plot = _get_or_create_plot()
577
+ if _call_if_exists(plot, ("autoscale", "auto_view", "fit_view")) is None:
578
+ raise AttributeError("Backend does not expose autoscale()/fit_view()")
579
+ _set_dirty(plot)
580
+
581
+
582
+ def reset_view() -> None:
583
+ axis("reset")
584
+
585
+
586
+ def home() -> None:
587
+ """Home view (alias for reset_view)"""
588
+ reset_view()
589
+
590
+
591
+ def set_global_alpha(alpha: float) -> None:
592
+ plot = _get_or_create_plot()
593
+ if hasattr(plot, "set_global_alpha"):
594
+ plot.set_global_alpha(float(alpha))
595
+ else:
596
+ if hasattr(plot, "global_alpha"):
597
+ plot.global_alpha = float(alpha)
598
+ _set_dirty(plot)
599
+
600
+
601
+ def alpha(value: float) -> None:
602
+ set_global_alpha(value)
603
+
604
+
605
+ def set_lod(enabled: bool = True, max_lines_per_px: int = 8) -> None:
606
+ plot = _get_or_create_plot()
607
+
608
+ if hasattr(plot, "enable_subsample"):
609
+ plot.enable_subsample = bool(enabled)
610
+ if hasattr(plot, "max_lines_per_px"):
611
+ plot.max_lines_per_px = max(1, int(max_lines_per_px))
612
+ _set_dirty(plot)
613
+
614
+
615
+ def lod(enabled: bool = True, max_lines_per_px: int = 8) -> None:
616
+ set_lod(enabled=enabled, max_lines_per_px=max_lines_per_px)
617
+
618
+
619
+ def blending(mode: BlendMode = "auto") -> None:
620
+ plot = _get_or_create_plot()
621
+ _set_blending(plot, mode)
622
+
623
+
624
+ def density(enabled: bool = True) -> None:
625
+ plot = _get_or_create_plot()
626
+ _set_density(plot, enabled)
627
+
628
+
629
+ def density_gain(value: float) -> None:
630
+ """Set the gain/factor for density plots."""
631
+ plot = _get_or_create_plot()
632
+ if hasattr(plot, "set_density_gain"):
633
+ plot.set_density_gain(value)
634
+ _set_dirty(plot)
635
+
636
+
637
+ def hud(enabled: bool = True) -> None:
638
+ plot = _get_or_create_plot()
639
+ _set_hud(plot, enabled)
640
+
641
+
642
+ # ------------------------------------------------------------------
643
+ # Analysis / export / execution
644
+ # ------------------------------------------------------------------
645
+
646
+ def stats(scope: str = "visible"):
647
+ plot = _get_or_create_plot()
648
+ if not hasattr(plot, "get_summary_stats"):
649
+ raise AttributeError("Backend does not expose get_summary_stats()")
650
+
651
+ s = plot.get_summary_stats(scope)
652
+ print(f"\n--- Statistics ({scope}) ---")
653
+ for k, v in s.items():
654
+ if isinstance(v, float):
655
+ print(f"{k:12}: {v:.6f}")
656
+ else:
657
+ print(f"{k:12}: {v}")
658
+ return s
659
+
660
+
661
+ def profile(name: str) -> None:
662
+ """
663
+ Apply a performance profile: 'extreme', 'performance', 'balanced', 'quality'.
664
+ """
665
+ plot = _get_or_create_plot()
666
+ if hasattr(plot, "set_profile"):
667
+ plot.set_profile(name)
668
+ _set_dirty(plot)
669
+
670
+
671
+ def export(filename: Optional[str] = None, scale: float = 2.0):
672
+ plot = _get_or_create_plot()
673
+ fname = filename or f"plot_{int(time.time())}.png"
674
+ if hasattr(plot, "savefig"):
675
+ plot.savefig(fname, scale=scale)
676
+ else:
677
+ # Fallback
678
+ if _call_if_exists(plot, ("save_current_view", "export_high_res"), fname, scale=scale) is None:
679
+ raise AttributeError("Backend does not expose export functions")
680
+
681
+
682
+ def savefig(filename: str, density: Optional[bool] = None, scale: float = 2.0):
683
+ plot = _get_or_create_plot()
684
+
685
+ if density is not None:
686
+ _set_density(plot, density)
687
+
688
+ # Preferred path: direct headless/offscreen export
689
+ if hasattr(plot, "savefig"):
690
+ plot.savefig(filename, scale=scale)
691
+ return
692
+
693
+ # Fallback path: one-frame initialization then export
694
+ if hasattr(plot, "_is_test_mode"):
695
+ plot._is_test_mode = True
696
+ plot.run()
697
+ if hasattr(plot, "savefig"):
698
+ plot.savefig(filename, scale=scale)
699
+ elif _call_if_exists(plot, ("save_current_view",), filename, scale=scale) is None:
700
+ raise AttributeError("Backend does not expose a compatible export function")
701
+
702
+
703
+ def show(
704
+ density: Optional[bool] = None,
705
+ *,
706
+ test_mode: bool = False,
707
+ ) -> None:
708
+ plot = _get_or_create_plot()
709
+
710
+ if density is not None:
711
+ _set_density(plot, density)
712
+
713
+ if hasattr(plot, "_is_test_mode"):
714
+ plot._is_test_mode = bool(test_mode)
715
+
716
+ plot.run()
717
+
718
+
719
+ # ------------------------------------------------------------------
720
+ # Convenience aliases
721
+ # ------------------------------------------------------------------
722
+
723
+ lineplot = lines
724
+ points = scatter
725
+
726
+
727
+ # ------------------------------------------------------------------
728
+ # Cleanup
729
+ # ------------------------------------------------------------------
730
+
731
+ @atexit.register
732
+ def _cleanup_pyplot_state():
733
+ global _CURRENT_PLOT
734
+ _CURRENT_PLOT = None
735
+ _ALL_PLOTS.clear()