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