qmlmathplot 0.1.0__py3-none-any.whl → 0.3.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.
- qmlmathplot/__init__.py +2 -1
- qmlmathplot/__main__.py +13 -0
- qmlmathplot/app.py +6 -3
- qmlmathplot/camera.py +28 -0
- qmlmathplot/curve.py +195 -38
- qmlmathplot/export.py +174 -0
- qmlmathplot/model.py +185 -29
- qmlmathplot/mpl.py +235 -0
- qmlmathplot/plot.py +216 -33
- qmlmathplot/qml/PlotView.qml +52 -11
- qmlmathplot/themes/default.mplstyle +26 -0
- qmlmathplot/themes.py +11 -6
- qmlmathplot/widget.py +15 -10
- {qmlmathplot-0.1.0.dist-info → qmlmathplot-0.3.0.dist-info}/METADATA +196 -18
- {qmlmathplot-0.1.0.dist-info → qmlmathplot-0.3.0.dist-info}/RECORD +17 -13
- {qmlmathplot-0.1.0.dist-info → qmlmathplot-0.3.0.dist-info}/WHEEL +1 -1
- {qmlmathplot-0.1.0.dist-info → qmlmathplot-0.3.0.dist-info}/entry_points.txt +0 -0
qmlmathplot/__init__.py
CHANGED
|
@@ -51,7 +51,7 @@ Demos: ``examples/minimal.py``, ``examples/explorer_qtwidgets.py``,
|
|
|
51
51
|
from typing import TYPE_CHECKING
|
|
52
52
|
|
|
53
53
|
from .camera import HOME_SIZE, HOME_VIEW, Camera, nice_ticks
|
|
54
|
-
from .curve import Curve, CurveListModel
|
|
54
|
+
from .curve import Curve, CurveListModel, ImplicitCurve
|
|
55
55
|
from .plot import Plot
|
|
56
56
|
from .view import QML_MAJOR, QML_MINOR, QML_URI, qml_component_path, register_qml_types
|
|
57
57
|
|
|
@@ -64,6 +64,7 @@ __all__ = [
|
|
|
64
64
|
"CurveListModel",
|
|
65
65
|
"HOME_SIZE",
|
|
66
66
|
"HOME_VIEW",
|
|
67
|
+
"ImplicitCurve",
|
|
67
68
|
"MathPlotWidget",
|
|
68
69
|
"nice_ticks",
|
|
69
70
|
"Plot",
|
qmlmathplot/__main__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""``python -m qmlmathplot`` — the same entry point as the installed ``qmlmathplot`` script.
|
|
2
|
+
|
|
3
|
+
Keeping it here means the command works even when the console script is not on ``PATH`` (a
|
|
4
|
+
``pip install --user``, a virtualenv used through ``python -m``, or a checkout run with
|
|
5
|
+
``uv run``).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
from .app import main
|
|
11
|
+
|
|
12
|
+
if __name__ == "__main__":
|
|
13
|
+
sys.exit(main())
|
qmlmathplot/app.py
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
"""MVP entry point: wraps the component into a minimal verifiable window
|
|
2
|
-
`uv run qmlmathplot`).
|
|
1
|
+
"""MVP entry point: wraps the component into a minimal verifiable window.
|
|
3
2
|
|
|
4
|
-
|
|
3
|
+
Reachable as the installed ``qmlmathplot`` console script, as ``python -m qmlmathplot``, and as
|
|
4
|
+
``examples/minimal.py``:
|
|
5
|
+
|
|
6
|
+
python -m qmlmathplot --backend d3d11 "sin(1/x)"
|
|
7
|
+
uv run qmlmathplot "tan(x)"
|
|
5
8
|
|
|
6
9
|
The RHI backend comes from the launch argument; without it, Qt's default backend is used. When
|
|
7
10
|
specified it must be written to ``QSG_RHI_BACKEND`` *before* ``QGuiApplication`` — Qt reads it
|
qmlmathplot/camera.py
CHANGED
|
@@ -219,6 +219,13 @@ class Camera(QObject):
|
|
|
219
219
|
xlim: QVector2D = Property("QVariant", _get_xlim, _set_xlim, notify=viewChanged)
|
|
220
220
|
ylim: QVector2D = Property("QVariant", _get_ylim, _set_ylim, notify=viewChanged)
|
|
221
221
|
|
|
222
|
+
def _get_viewport(self) -> QVector2D:
|
|
223
|
+
width, height = self._effective_size()
|
|
224
|
+
return QVector2D(width, height)
|
|
225
|
+
|
|
226
|
+
#: Last reported size in logical pixels (the reference size before the first report).
|
|
227
|
+
viewport: QVector2D = Property("QVariant", _get_viewport, notify=viewChanged)
|
|
228
|
+
|
|
222
229
|
# --------------------------------------------------------------- ticks
|
|
223
230
|
def tick_values(self) -> tuple[list[tuple[float, str]], list[tuple[float, str]]]:
|
|
224
231
|
"""Ticks for both axes of the visible range, as ``(value, label)`` pairs."""
|
|
@@ -280,6 +287,27 @@ class Camera(QObject):
|
|
|
280
287
|
self.viewChanged.emit()
|
|
281
288
|
self.ticksChanged.emit()
|
|
282
289
|
|
|
290
|
+
@Slot(float, float, float, float)
|
|
291
|
+
def set_view(self, centre_x: float, centre_y: float, scale_x: float, scale_y: float) -> None:
|
|
292
|
+
"""Set the centre and *both* scales at once (world units per logical pixel).
|
|
293
|
+
|
|
294
|
+
The camera normally derives the y scale from ``aspect``; this is the primitive for a
|
|
295
|
+
caller that has two scales in hand — the exporter borrowing the camera for a render,
|
|
296
|
+
and restoring it afterwards. ``"auto"`` stays "auto" (the scales are simply set), a
|
|
297
|
+
numeric aspect is re-derived from the pair so the two scales are exactly as asked.
|
|
298
|
+
"""
|
|
299
|
+
scale_x = min(max(float(scale_x), self.MIN_SCALE), self.MAX_SCALE)
|
|
300
|
+
scale_y = min(max(float(scale_y), self.MIN_SCALE), self.MAX_SCALE)
|
|
301
|
+
self._centre = QVector2D(float(centre_x), float(centre_y))
|
|
302
|
+
self._zoom = scale_x
|
|
303
|
+
if isinstance(self._aspect, str):
|
|
304
|
+
self._y_scale = scale_y
|
|
305
|
+
else:
|
|
306
|
+
self._aspect = scale_x / scale_y
|
|
307
|
+
self.aspectChanged.emit()
|
|
308
|
+
self._home = False
|
|
309
|
+
self._view_changed()
|
|
310
|
+
|
|
283
311
|
# --------------------------------------------------------------- slots
|
|
284
312
|
@Slot(float, float)
|
|
285
313
|
def setViewport(self, width: float, height: float) -> None:
|
qmlmathplot/curve.py
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
"""Curve layer: one expression -> GLSL -> .qsb plus its style, and the list model QML repeats.
|
|
2
2
|
|
|
3
|
-
A curve knows nothing about the view: the camera decides where we look, the curve decides
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
A curve knows nothing about the view: the camera decides where we look, the curve decides what is
|
|
4
|
+
drawn. One curve = one fragment shader pass, so ``visible`` is the lever that costs nothing.
|
|
5
|
+
|
|
6
|
+
Two artists share the class: ``Curve`` draws ``y = f(x)`` (see ``model.FRAGMENT_TEMPLATE``) and
|
|
7
|
+
``ImplicitCurve`` draws the level set ``F(x, y) = 0`` (``model.IMPLICIT_FRAGMENT_TEMPLATE``).
|
|
8
|
+
They differ only in the shader *sources* — the uniform block, the model roles and the QML side
|
|
9
|
+
are the same — so ``curve_for()`` can pick one from the expression's free symbols.
|
|
6
10
|
"""
|
|
7
11
|
|
|
8
12
|
from __future__ import annotations
|
|
@@ -20,23 +24,76 @@ from PySide6.QtCore import (
|
|
|
20
24
|
)
|
|
21
25
|
from PySide6.QtGui import QColor
|
|
22
26
|
|
|
23
|
-
from .model import shader_sources
|
|
27
|
+
from .model import implicit_shader_sources, shader_sources
|
|
24
28
|
from .qsb import bake
|
|
25
29
|
|
|
26
|
-
__all__ = ["Curve", "CurveListModel"]
|
|
30
|
+
__all__ = ["Curve", "CurveListModel", "ImplicitCurve", "curve_for", "parsed"]
|
|
27
31
|
|
|
28
|
-
#: First colour of the default cycle (
|
|
29
|
-
DEFAULT_COLOR = "#
|
|
30
|
-
|
|
32
|
+
#: First colour of the default cycle (matplotlib's "tab10" blue, as in its default style).
|
|
33
|
+
DEFAULT_COLOR = "#1f77b4"
|
|
34
|
+
#: matplotlib's default ten-colour cycle.
|
|
35
|
+
DEFAULT_COLOR_CYCLE = ("#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd",
|
|
36
|
+
"#8c564b", "#e377c2", "#7f7f7f", "#bcbd22", "#17becf")
|
|
31
37
|
|
|
32
38
|
|
|
33
39
|
def _color(value: object) -> QColor:
|
|
34
|
-
"""A QColor from a QColor
|
|
40
|
+
"""A QColor from a QColor, anything QColor() understands ("#rrggbb", a name, …), or None
|
|
41
|
+
for the default curve colour."""
|
|
42
|
+
if value is None:
|
|
43
|
+
return QColor(DEFAULT_COLOR)
|
|
35
44
|
return QColor(value) if not isinstance(value, QColor) else QColor(value)
|
|
36
45
|
|
|
37
46
|
|
|
47
|
+
def parsed(value: str | sp.Expr) -> tuple[str, sp.Expr]:
|
|
48
|
+
"""The canonical string and the parsed expression of a curve input.
|
|
49
|
+
|
|
50
|
+
A string is parsed here, once; a sympy expression is used as it is (no ``Expr -> str ->
|
|
51
|
+
Expr`` round trip). The canonical string is what identifies the expression.
|
|
52
|
+
"""
|
|
53
|
+
if isinstance(value, sp.Basic):
|
|
54
|
+
return str(value), value
|
|
55
|
+
return str(value), sp.sympify(value, locals={"x": sp.Symbol("x"), "y": sp.Symbol("y")})
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def curve_for(
|
|
59
|
+
expression: str | sp.Expr,
|
|
60
|
+
*,
|
|
61
|
+
implicit: bool | None = None,
|
|
62
|
+
color: object = None,
|
|
63
|
+
line_width: object = None,
|
|
64
|
+
label: str = "",
|
|
65
|
+
parent: QObject | None = None,
|
|
66
|
+
) -> Curve:
|
|
67
|
+
"""Create the artist an expression needs.
|
|
68
|
+
|
|
69
|
+
Auto-routed by the free symbols: ``x`` (or none) is a 1-D curve ``y = f(x)``, ``x`` and ``y``
|
|
70
|
+
is an implicit curve (the level set ``F(x, y) = 0``), and anything else raises — a parameter
|
|
71
|
+
must be substituted before it is plotted, and silently evaluating it as a pixel coordinate is
|
|
72
|
+
exactly the trap this avoids. ``implicit=True`` picks the level-set artist regardless of the
|
|
73
|
+
symbols (``add_implicit``).
|
|
74
|
+
"""
|
|
75
|
+
text, expr = parsed(expression)
|
|
76
|
+
names = {symbol.name for symbol in expr.free_symbols}
|
|
77
|
+
if names - {"x", "y"}:
|
|
78
|
+
raise ValueError(
|
|
79
|
+
f"{sorted(names - {'x', 'y'})} must be substituted first: only x (or x and y) may "
|
|
80
|
+
f"be free, got {text!r}"
|
|
81
|
+
)
|
|
82
|
+
cls: type[Curve] = ImplicitCurve if (implicit or "y" in names) else Curve
|
|
83
|
+
return cls(expr, color=color, line_width=line_width, label=label, parent=parent)
|
|
84
|
+
|
|
85
|
+
|
|
38
86
|
class Curve(QObject):
|
|
39
|
-
"""One curve: an expression, its baked shaders, its style and its error text.
|
|
87
|
+
"""One curve: an expression, its baked shaders, its style and its error text.
|
|
88
|
+
|
|
89
|
+
The fragment shader follows the expression: ``y = f(x)`` while only ``x`` (or nothing) is
|
|
90
|
+
free, the level set ``F(x, y) = 0`` as soon as ``y`` appears — so ``add_curve`` and assigning
|
|
91
|
+
to ``expression`` both route themselves, and a text field can turn a function into a level
|
|
92
|
+
set. ``ImplicitCurve`` forces the level-set template regardless of the symbols.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
#: Forced to the level-set template (see ``ImplicitCurve``).
|
|
96
|
+
_force_implicit = False
|
|
40
97
|
|
|
41
98
|
expressionChanged = Signal()
|
|
42
99
|
styleChanged = Signal()
|
|
@@ -47,7 +104,7 @@ class Curve(QObject):
|
|
|
47
104
|
|
|
48
105
|
def __init__(
|
|
49
106
|
self,
|
|
50
|
-
expression: str = "sin(x)",
|
|
107
|
+
expression: str | sp.Expr = "sin(x)",
|
|
51
108
|
color: str | QColor = DEFAULT_COLOR,
|
|
52
109
|
line_width: float = 1.5,
|
|
53
110
|
label: str = "",
|
|
@@ -55,7 +112,8 @@ class Curve(QObject):
|
|
|
55
112
|
) -> None:
|
|
56
113
|
super().__init__(parent)
|
|
57
114
|
self._symbol = sp.Symbol("x")
|
|
58
|
-
self._expression =
|
|
115
|
+
self._expression = ""
|
|
116
|
+
self._expr: sp.Expr = sp.Integer(0) # replaced by the first assignment below
|
|
59
117
|
self._color = _color(color)
|
|
60
118
|
self._line_width = float(line_width)
|
|
61
119
|
self._label = str(label)
|
|
@@ -63,23 +121,62 @@ class Curve(QObject):
|
|
|
63
121
|
self._error = ""
|
|
64
122
|
self._vertex = QUrl()
|
|
65
123
|
self._fragment = QUrl()
|
|
66
|
-
self.
|
|
124
|
+
self._set_expression(expression)
|
|
67
125
|
|
|
68
126
|
# ---------------------------------------------------------------- Expression
|
|
69
127
|
def _get_expression(self) -> str:
|
|
70
128
|
return self._expression
|
|
71
129
|
|
|
72
|
-
def _set_expression(self, source: str) -> None:
|
|
73
|
-
|
|
74
|
-
|
|
130
|
+
def _set_expression(self, source: str | sp.Expr) -> None:
|
|
131
|
+
"""Set the expression from a string (parsed here, once) or from a sympy expression.
|
|
132
|
+
|
|
133
|
+
The canonical **string** is what identifies the expression — ``sp.sin(x)`` and
|
|
134
|
+
``"sin(x)"`` are the same, and re-setting either is free — while the parsed
|
|
135
|
+
``sp.Expr`` is cached for the bake and for the matplotlib hand-off, so neither ever
|
|
136
|
+
stringifies and re-parses again.
|
|
137
|
+
"""
|
|
138
|
+
if isinstance(source, str):
|
|
139
|
+
canonical = source
|
|
140
|
+
else:
|
|
141
|
+
canonical = str(source)
|
|
142
|
+
if canonical == self._expression:
|
|
75
143
|
return
|
|
76
|
-
|
|
144
|
+
if isinstance(source, sp.Basic): # already an expression: use it as it is
|
|
145
|
+
expr: sp.Expr = source
|
|
146
|
+
else:
|
|
147
|
+
try:
|
|
148
|
+
expr = sp.sympify(source, locals={"x": self._symbol, "y": sp.Symbol("y")})
|
|
149
|
+
except Exception as exc:
|
|
150
|
+
# Keep the last parsed expression: the shaders stay, `expression` is what was
|
|
151
|
+
# typed and `error` says why.
|
|
152
|
+
self._expression = str(canonical)
|
|
153
|
+
self._error = f"{type(exc).__name__}: {exc}"
|
|
154
|
+
self.expressionChanged.emit()
|
|
155
|
+
self.errorChanged.emit()
|
|
156
|
+
return
|
|
157
|
+
previous = self._expr
|
|
158
|
+
self._expression = str(canonical)
|
|
159
|
+
self._expr = expr
|
|
77
160
|
self.expressionChanged.emit()
|
|
78
|
-
self._compile(
|
|
161
|
+
if not self._compile(expr):
|
|
162
|
+
# `expression` stays what was set (the text field shows it), `expr` stays what is
|
|
163
|
+
# drawn — the same rule as the unparsable string above.
|
|
164
|
+
self._expr = previous
|
|
79
165
|
|
|
80
166
|
# Annotate the value type (PySide6 idiom): Property is a descriptor, so without the
|
|
81
167
|
# annotation a type checker only sees the Property object and flags every read and write.
|
|
82
|
-
|
|
168
|
+
# Declared "QVariant" so an ``sp.Expr`` reaches the setter unconverted; the getter is always
|
|
169
|
+
# the canonical string (QML, a project file and the text field all want one).
|
|
170
|
+
expression: str | sp.Expr = Property("QVariant", _get_expression, _set_expression,
|
|
171
|
+
notify=expressionChanged)
|
|
172
|
+
|
|
173
|
+
def _get_expr(self) -> sp.Expr:
|
|
174
|
+
return self._expr
|
|
175
|
+
|
|
176
|
+
#: The parsed expression being drawn (read-only): the object form the setter accepts, and
|
|
177
|
+
#: the one the bake and the matplotlib hand-off use. It follows the last expression that
|
|
178
|
+
#: compiled, so `expression` can show a failing edit while `expr` keeps the drawn curve.
|
|
179
|
+
expr: sp.Expr = Property("QVariant", _get_expr, notify=expressionChanged)
|
|
83
180
|
|
|
84
181
|
# ------------------------------------------------------------------ Style
|
|
85
182
|
def _get_color(self) -> QColor:
|
|
@@ -145,23 +242,57 @@ class Curve(QObject):
|
|
|
145
242
|
|
|
146
243
|
error: str = Property(str, _get_error, notify=errorChanged)
|
|
147
244
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
245
|
+
@property
|
|
246
|
+
def implicit(self) -> bool:
|
|
247
|
+
"""Whether this curve is currently drawn as a level set ``F(x, y) = 0``.
|
|
248
|
+
|
|
249
|
+
Read-only, and it follows the expression: assigning ``"x**2 + y**2 - 1"`` switches this
|
|
250
|
+
curve's shader to the implicit one. The matplotlib hand-off uses it to skip level sets,
|
|
251
|
+
which cannot be sampled into a polyline.
|
|
252
|
+
"""
|
|
253
|
+
return self._force_implicit or "y" in {symbol.name for symbol in self._expr.free_symbols}
|
|
254
|
+
|
|
255
|
+
def _sources(self, expr: sp.Expr) -> tuple[str, str]:
|
|
256
|
+
"""The shader sources for this expression; anything unrenderable raises ``ValueError``
|
|
257
|
+
(which ``_compile`` turns into ``error``, keeping the last working shaders)."""
|
|
258
|
+
names = {symbol.name for symbol in expr.free_symbols}
|
|
259
|
+
if names - {"x", "y"}:
|
|
260
|
+
raise ValueError(
|
|
261
|
+
f"only x (or x and y) may be free, got {sorted(names)}: substitute parameters "
|
|
262
|
+
"first"
|
|
263
|
+
)
|
|
264
|
+
return implicit_shader_sources(expr) if (self._force_implicit or "y" in names) \
|
|
265
|
+
else shader_sources(expr)
|
|
266
|
+
|
|
267
|
+
def _compile(self, expr: sp.Expr) -> bool:
|
|
268
|
+
"""expr -> GLSL -> .qsb; on failure keep the last working shaders, write the reason into
|
|
269
|
+
``error`` and return False. The expression is parsed by the setter, never here."""
|
|
151
270
|
try:
|
|
152
|
-
|
|
153
|
-
vert_src, frag_src = shader_sources(expr)
|
|
271
|
+
vert_src, frag_src = self._sources(expr)
|
|
154
272
|
vertex = bake(vert_src, "vert")
|
|
155
273
|
fragment = bake(frag_src, "frag")
|
|
156
274
|
except Exception as exc: # noqa: BLE001 — expression or bake can fail; both reach the UI
|
|
157
275
|
self._error = f"{type(exc).__name__}: {exc}"
|
|
158
276
|
self.errorChanged.emit()
|
|
159
|
-
return
|
|
277
|
+
return False
|
|
160
278
|
self._error = ""
|
|
161
279
|
self._vertex = QUrl.fromLocalFile(str(vertex))
|
|
162
280
|
self._fragment = QUrl.fromLocalFile(str(fragment))
|
|
163
281
|
self.errorChanged.emit()
|
|
164
282
|
self.shadersChanged.emit()
|
|
283
|
+
return True
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
class ImplicitCurve(Curve):
|
|
287
|
+
"""A curve drawn from the level set ``F(x, y) = 0``, whatever the free symbols.
|
|
288
|
+
|
|
289
|
+
No sampling and no envelope band: one evaluation of ``F`` and its two partials per pixel give
|
|
290
|
+
the distance to the level set (``docs/api-design.md`` §6b). Thin features and several
|
|
291
|
+
branches through one pixel are not handled yet, and a domain/pole curve is not special-cased
|
|
292
|
+
either — pixels where ``F`` is undefined come out NaN and are dropped.
|
|
293
|
+
"""
|
|
294
|
+
|
|
295
|
+
_force_implicit = True
|
|
165
296
|
|
|
166
297
|
|
|
167
298
|
class CurveListModel(QAbstractListModel):
|
|
@@ -243,21 +374,16 @@ class CurveListModel(QAbstractListModel):
|
|
|
243
374
|
curve.visibleChanged.connect(lambda c=curve: self._notify(c, (self.VisibleRole,)))
|
|
244
375
|
|
|
245
376
|
# ---------------------------------------------------------------- public
|
|
246
|
-
|
|
247
|
-
@Slot(str, result=QObject)
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
expression: str,
|
|
251
|
-
color: object = None,
|
|
252
|
-
line_width: object = None,
|
|
253
|
-
label: str = "",
|
|
254
|
-
) -> Curve:
|
|
255
|
-
"""Append a curve. Colour and line width default to the plot's cycle and width."""
|
|
377
|
+
# "QVariant" so a sympy expression is not stringified on the way in (see Plot.add_curve).
|
|
378
|
+
@Slot("QVariant", "QVariant", "QVariant", str, result=QObject)
|
|
379
|
+
@Slot("QVariant", result=QObject)
|
|
380
|
+
def _defaults(self, color: object, line_width: object) -> tuple[QColor, float]:
|
|
256
381
|
if color is None:
|
|
257
382
|
color = self._cycle[len(self._curves) % len(self._cycle)]
|
|
258
|
-
if line_width is None
|
|
259
|
-
|
|
260
|
-
|
|
383
|
+
return _color(color), (self._default_line_width if line_width is None
|
|
384
|
+
else float(line_width)) # type: ignore[arg-type]
|
|
385
|
+
|
|
386
|
+
def _append(self, curve: Curve) -> Curve:
|
|
261
387
|
row = len(self._curves)
|
|
262
388
|
self.beginInsertRows(QModelIndex(), row, row)
|
|
263
389
|
self._curves.append(curve)
|
|
@@ -265,6 +391,37 @@ class CurveListModel(QAbstractListModel):
|
|
|
265
391
|
self._watch(curve)
|
|
266
392
|
return curve
|
|
267
393
|
|
|
394
|
+
@Slot("QVariant", "QVariant", "QVariant", str, result=QObject)
|
|
395
|
+
@Slot("QVariant", result=QObject)
|
|
396
|
+
def add_curve(
|
|
397
|
+
self,
|
|
398
|
+
expression: str | sp.Expr,
|
|
399
|
+
color: object = None,
|
|
400
|
+
line_width: object = None,
|
|
401
|
+
label: str = "",
|
|
402
|
+
) -> Curve:
|
|
403
|
+
"""Append the artist the expression needs: ``y = f(x)``, or the level set
|
|
404
|
+
``F(x, y) = 0`` when both symbols are free (see ``curve_for``). Colour and line width
|
|
405
|
+
default to the plot's cycle and width."""
|
|
406
|
+
default_color, default_width = self._defaults(color, line_width)
|
|
407
|
+
return self._append(curve_for(expression, color=default_color, line_width=default_width,
|
|
408
|
+
label=label, parent=self))
|
|
409
|
+
|
|
410
|
+
@Slot("QVariant", "QVariant", "QVariant", str, result=QObject)
|
|
411
|
+
@Slot("QVariant", result=QObject)
|
|
412
|
+
def add_implicit(
|
|
413
|
+
self,
|
|
414
|
+
expression: str | sp.Expr,
|
|
415
|
+
color: object = None,
|
|
416
|
+
line_width: object = None,
|
|
417
|
+
label: str = "",
|
|
418
|
+
) -> Curve:
|
|
419
|
+
"""Append an implicit curve (the level set ``F(x, y) = 0``) explicitly, whatever the free
|
|
420
|
+
symbols: ``x`` alone is a valid level set (vertical lines)."""
|
|
421
|
+
default_color, default_width = self._defaults(color, line_width)
|
|
422
|
+
return self._append(curve_for(expression, implicit=True, color=default_color,
|
|
423
|
+
line_width=default_width, label=label, parent=self))
|
|
424
|
+
|
|
268
425
|
@Slot(QObject)
|
|
269
426
|
def remove_curve(self, curve: Curve) -> None:
|
|
270
427
|
row = self.index_of(curve)
|
qmlmathplot/export.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Export layer: render a region of this canvas at a pixel size (docs/api-design.md §9).
|
|
2
|
+
|
|
3
|
+
An export is not a re-draw at another ``dpi`` — the plot *is* a Qt scene, so the same scene is
|
|
4
|
+
rendered into a hidden ``QQuickWidget`` and read back synchronously. Everything (curves, grid,
|
|
5
|
+
axes, theme) follows the live styling, so an export cannot drift from what the user sees; only
|
|
6
|
+
the camera is *borrowed* for the duration of the render and restored before the event loop runs
|
|
7
|
+
again, so the live view never repaints the export state.
|
|
8
|
+
|
|
9
|
+
QtWidgets is imported lazily (a pure-QML app that never exports never loads it).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from collections.abc import Sequence
|
|
15
|
+
from typing import TYPE_CHECKING
|
|
16
|
+
|
|
17
|
+
from PySide6.QtCore import QSize, Qt, QUrl
|
|
18
|
+
from PySide6.QtGui import QColor, QImage
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING: # pragma: no cover - typing only; the real imports stay lazy
|
|
21
|
+
from PySide6.QtQuickWidgets import QQuickWidget
|
|
22
|
+
from PySide6.QtWidgets import QApplication
|
|
23
|
+
|
|
24
|
+
from .plot import Plot
|
|
25
|
+
|
|
26
|
+
__all__ = ["ADJUSTABLES", "render"]
|
|
27
|
+
|
|
28
|
+
#: How a requested range and a requested pixel size are reconciled (see the module docstring).
|
|
29
|
+
ADJUSTABLES = ("box", "datalim", "stretch")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _pair(value: Sequence[float], name: str) -> tuple[float, float]:
|
|
33
|
+
try:
|
|
34
|
+
lo, hi = (float(value[0]), float(value[1]))
|
|
35
|
+
except (TypeError, IndexError, KeyError):
|
|
36
|
+
raise ValueError(f"{name} must be a (min, max) pair, got {value!r}") from None
|
|
37
|
+
if not hi > lo:
|
|
38
|
+
raise ValueError(f"{name} must be increasing, got {value!r}")
|
|
39
|
+
return lo, hi
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _widgets() -> tuple[type[QApplication], type[QQuickWidget]]:
|
|
43
|
+
"""The QtWidgets classes the export needs, or a clear error when they are not usable."""
|
|
44
|
+
try:
|
|
45
|
+
from PySide6.QtQuickWidgets import QQuickWidget
|
|
46
|
+
from PySide6.QtWidgets import QApplication
|
|
47
|
+
except ImportError as exc: # pragma: no cover - both ship with PySide6
|
|
48
|
+
raise RuntimeError("exporting needs QtWidgets/QtQuickWidgets (PySide6)") from exc
|
|
49
|
+
if not isinstance(QApplication.instance(), QApplication):
|
|
50
|
+
# Creating a QWidget under a bare QGuiApplication aborts the process inside Qt (and
|
|
51
|
+
# `QApplication.instance()` *is* the QGuiApplication there), so this has to be refused
|
|
52
|
+
# before the offscreen widget is touched.
|
|
53
|
+
raise RuntimeError(
|
|
54
|
+
"exporting needs a QApplication: the offscreen render uses a QQuickWidget; "
|
|
55
|
+
"create one (MathPlotWidget does it for you) or use a widgets host"
|
|
56
|
+
)
|
|
57
|
+
return QApplication, QQuickWidget
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def render(
|
|
61
|
+
plot: Plot,
|
|
62
|
+
*,
|
|
63
|
+
xlim: Sequence[float] | None = None,
|
|
64
|
+
ylim: Sequence[float] | None = None,
|
|
65
|
+
width: float | None = None,
|
|
66
|
+
height: float | None = None,
|
|
67
|
+
dpi: float = 1.0,
|
|
68
|
+
adjustable: str | None = None,
|
|
69
|
+
transparent: bool = False,
|
|
70
|
+
) -> QImage:
|
|
71
|
+
"""Render ``plot`` offscreen and return the grabbed image, in device pixels.
|
|
72
|
+
|
|
73
|
+
``xlim`` / ``ylim`` default to the camera's own view (its centre and scales applied to the
|
|
74
|
+
size it was told it has — that is exactly ``camera.xlim`` / ``ylim``), ``width`` /
|
|
75
|
+
``height`` default to the live view's device size (``dpi`` multiplies both), and
|
|
76
|
+
``adjustable`` reconciles the two parameter sets. The pixel size changes the resolution,
|
|
77
|
+
never the region: the region cannot depend on the screen's ``devicePixelRatio``.
|
|
78
|
+
"""
|
|
79
|
+
dpi = float(dpi)
|
|
80
|
+
if not dpi > 0:
|
|
81
|
+
raise ValueError(f"dpi must be positive, got {dpi!r}")
|
|
82
|
+
mode = "box" if adjustable is None else str(adjustable) # matplotlib's own default
|
|
83
|
+
if mode not in ADJUSTABLES:
|
|
84
|
+
raise ValueError(f"adjustable must be one of {ADJUSTABLES}, got {adjustable!r}")
|
|
85
|
+
for name, value in (("width", width), ("height", height)):
|
|
86
|
+
if value is not None and not float(value) > 0:
|
|
87
|
+
raise ValueError(f"{name} must be positive, got {value!r}")
|
|
88
|
+
|
|
89
|
+
_app, quick_widget = _widgets()
|
|
90
|
+
from .view import qml_component_path, register_qml_types
|
|
91
|
+
|
|
92
|
+
register_qml_types()
|
|
93
|
+
camera = plot.camera
|
|
94
|
+
live = (camera.centre.x(), camera.centre.y(), camera.scale_x, camera.scale_y)
|
|
95
|
+
live_size = (camera.viewport.x(), camera.viewport.y())
|
|
96
|
+
background = plot.background
|
|
97
|
+
|
|
98
|
+
widget: QQuickWidget = quick_widget()
|
|
99
|
+
widget.setAttribute(Qt.WidgetAttribute.WA_DontShowOnScreen, True)
|
|
100
|
+
widget.setResizeMode(quick_widget.ResizeMode.SizeRootObjectToView)
|
|
101
|
+
widget.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
|
102
|
+
image = QImage()
|
|
103
|
+
device = (0, 0)
|
|
104
|
+
try:
|
|
105
|
+
if transparent:
|
|
106
|
+
plot.background = QColor(0, 0, 0, 0)
|
|
107
|
+
widget.setClearColor(QColor(0, 0, 0, 0))
|
|
108
|
+
widget.setSource(QUrl.fromLocalFile(qml_component_path()))
|
|
109
|
+
if widget.status() != quick_widget.Status.Ready:
|
|
110
|
+
errors = "; ".join(error.toString() for error in widget.errors())
|
|
111
|
+
raise RuntimeError(f"the QML component failed to load: {errors}")
|
|
112
|
+
root = widget.rootObject() # the widget itself has no such property
|
|
113
|
+
if root is None:
|
|
114
|
+
raise RuntimeError("the QML component has no root object")
|
|
115
|
+
root.setProperty("plot", plot)
|
|
116
|
+
|
|
117
|
+
# width/height are device pixels, so the offscreen view gets the logical size
|
|
118
|
+
pixel_ratio = widget.devicePixelRatioF()
|
|
119
|
+
device = (
|
|
120
|
+
round((float(width) if width is not None else live_size[0] * pixel_ratio) * dpi),
|
|
121
|
+
round((float(height) if height is not None else live_size[1] * pixel_ratio) * dpi),
|
|
122
|
+
)
|
|
123
|
+
logical = (device[0] / pixel_ratio, device[1] / pixel_ratio)
|
|
124
|
+
widget.resize(round(logical[0]), round(logical[1]))
|
|
125
|
+
widget.show() # offscreen: WA_DontShowOnScreen, never exposed
|
|
126
|
+
|
|
127
|
+
# The range: the camera's own view — its centre and scales applied to the size it was
|
|
128
|
+
# told it has, which is exactly ``camera.xlim``/``ylim`` — unless the caller asked for
|
|
129
|
+
# one. Not ``logical`` (the export's size): the scale is world units per logical pixel
|
|
130
|
+
# *of the live view*, so deriving the range from the offscreen widget's logical size
|
|
131
|
+
# made the region depend on the screen's devicePixelRatio and silently cropped it by
|
|
132
|
+
# that factor (at DPR 1.5 a ±2.5 view came out as ±1.67 at 400×400 device pixels, and a
|
|
133
|
+
# level set whose visible arc sat at |x| > 1.67 was not drawn at all). ``width``,
|
|
134
|
+
# ``height`` and ``dpi`` change the number of pixels, never the region; a caller that
|
|
135
|
+
# wants a different region passes ``xlim``/``ylim``.
|
|
136
|
+
range_x = _pair(xlim, "xlim") if xlim is not None else (
|
|
137
|
+
live[0] - 0.5 * live_size[0] * live[2], live[0] + 0.5 * live_size[0] * live[2])
|
|
138
|
+
range_y = _pair(ylim, "ylim") if ylim is not None else (
|
|
139
|
+
live[1] - 0.5 * live_size[1] * live[3], live[1] + 0.5 * live_size[1] * live[3])
|
|
140
|
+
span_x, span_y = range_x[1] - range_x[0], range_y[1] - range_y[0]
|
|
141
|
+
centre = (0.5 * (range_x[0] + range_x[1]), 0.5 * (range_y[0] + range_y[1]))
|
|
142
|
+
|
|
143
|
+
if mode == "stretch": # the range maps onto the size exactly
|
|
144
|
+
scale = (span_x / logical[0], span_y / logical[1])
|
|
145
|
+
elif mode == "box": # the live ratio, the range drawn as large as it fits
|
|
146
|
+
# The largest scale (the smallest world units per pixel) for which the requested
|
|
147
|
+
# range still fits: the binding axis is exact, the other keeps background margins.
|
|
148
|
+
# Drawn without distortion — a world circle stays round, only the visible span
|
|
149
|
+
# changes when the requested range's aspect differs from the size's.
|
|
150
|
+
ratio = live[2] / live[3]
|
|
151
|
+
scale_x = max(span_x / logical[0], ratio * span_y / logical[1])
|
|
152
|
+
scale = (scale_x, scale_x / ratio)
|
|
153
|
+
else: # "datalim": the live scales, the limits expand
|
|
154
|
+
scale = (max(live[2], span_x / logical[0]), max(live[3], span_y / logical[1]))
|
|
155
|
+
|
|
156
|
+
camera.setViewport(logical[0], logical[1])
|
|
157
|
+
camera.set_view(centre[0], centre[1], scale[0], scale[1])
|
|
158
|
+
image = widget.grabFramebuffer()
|
|
159
|
+
finally:
|
|
160
|
+
# Restore before the event loop runs again, so no live view repaints the export state.
|
|
161
|
+
camera.set_view(live[0], live[1], live[2], live[3])
|
|
162
|
+
camera.setViewport(live_size[0], live_size[1])
|
|
163
|
+
plot.background = background
|
|
164
|
+
widget.hide()
|
|
165
|
+
widget.deleteLater()
|
|
166
|
+
|
|
167
|
+
if image.size() != QSize(device[0], device[1]):
|
|
168
|
+
# At a fractional devicePixelRatio the offscreen widget can land within one pixel of the
|
|
169
|
+
# requested size; make the promise exact (a 1 px nearest resample is invisible).
|
|
170
|
+
image = image.scaled(
|
|
171
|
+
device[0], device[1],
|
|
172
|
+
Qt.AspectRatioMode.IgnoreAspectRatio, Qt.TransformationMode.FastTransformation,
|
|
173
|
+
)
|
|
174
|
+
return image
|