qmlmathplot 0.2.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/curve.py +190 -35
- qmlmathplot/export.py +16 -6
- qmlmathplot/model.py +185 -29
- qmlmathplot/mpl.py +21 -18
- qmlmathplot/plot.py +33 -4
- {qmlmathplot-0.2.0.dist-info → qmlmathplot-0.3.0.dist-info}/METADATA +71 -11
- {qmlmathplot-0.2.0.dist-info → qmlmathplot-0.3.0.dist-info}/RECORD +12 -11
- {qmlmathplot-0.2.0.dist-info → qmlmathplot-0.3.0.dist-info}/WHEEL +0 -0
- {qmlmathplot-0.2.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/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,10 +24,10 @@ 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
32
|
#: First colour of the default cycle (matplotlib's "tab10" blue, as in its default style).
|
|
29
33
|
DEFAULT_COLOR = "#1f77b4"
|
|
@@ -33,12 +37,63 @@ DEFAULT_COLOR_CYCLE = ("#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd",
|
|
|
33
37
|
|
|
34
38
|
|
|
35
39
|
def _color(value: object) -> QColor:
|
|
36
|
-
"""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)
|
|
37
44
|
return QColor(value) if not isinstance(value, QColor) else QColor(value)
|
|
38
45
|
|
|
39
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
|
+
|
|
40
86
|
class Curve(QObject):
|
|
41
|
-
"""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
|
|
42
97
|
|
|
43
98
|
expressionChanged = Signal()
|
|
44
99
|
styleChanged = Signal()
|
|
@@ -49,7 +104,7 @@ class Curve(QObject):
|
|
|
49
104
|
|
|
50
105
|
def __init__(
|
|
51
106
|
self,
|
|
52
|
-
expression: str = "sin(x)",
|
|
107
|
+
expression: str | sp.Expr = "sin(x)",
|
|
53
108
|
color: str | QColor = DEFAULT_COLOR,
|
|
54
109
|
line_width: float = 1.5,
|
|
55
110
|
label: str = "",
|
|
@@ -57,7 +112,8 @@ class Curve(QObject):
|
|
|
57
112
|
) -> None:
|
|
58
113
|
super().__init__(parent)
|
|
59
114
|
self._symbol = sp.Symbol("x")
|
|
60
|
-
self._expression =
|
|
115
|
+
self._expression = ""
|
|
116
|
+
self._expr: sp.Expr = sp.Integer(0) # replaced by the first assignment below
|
|
61
117
|
self._color = _color(color)
|
|
62
118
|
self._line_width = float(line_width)
|
|
63
119
|
self._label = str(label)
|
|
@@ -65,23 +121,62 @@ class Curve(QObject):
|
|
|
65
121
|
self._error = ""
|
|
66
122
|
self._vertex = QUrl()
|
|
67
123
|
self._fragment = QUrl()
|
|
68
|
-
self.
|
|
124
|
+
self._set_expression(expression)
|
|
69
125
|
|
|
70
126
|
# ---------------------------------------------------------------- Expression
|
|
71
127
|
def _get_expression(self) -> str:
|
|
72
128
|
return self._expression
|
|
73
129
|
|
|
74
|
-
def _set_expression(self, source: str) -> None:
|
|
75
|
-
|
|
76
|
-
|
|
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:
|
|
77
143
|
return
|
|
78
|
-
|
|
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
|
|
79
160
|
self.expressionChanged.emit()
|
|
80
|
-
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
|
|
81
165
|
|
|
82
166
|
# Annotate the value type (PySide6 idiom): Property is a descriptor, so without the
|
|
83
167
|
# annotation a type checker only sees the Property object and flags every read and write.
|
|
84
|
-
|
|
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)
|
|
85
180
|
|
|
86
181
|
# ------------------------------------------------------------------ Style
|
|
87
182
|
def _get_color(self) -> QColor:
|
|
@@ -147,23 +242,57 @@ class Curve(QObject):
|
|
|
147
242
|
|
|
148
243
|
error: str = Property(str, _get_error, notify=errorChanged)
|
|
149
244
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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."""
|
|
153
270
|
try:
|
|
154
|
-
|
|
155
|
-
vert_src, frag_src = shader_sources(expr)
|
|
271
|
+
vert_src, frag_src = self._sources(expr)
|
|
156
272
|
vertex = bake(vert_src, "vert")
|
|
157
273
|
fragment = bake(frag_src, "frag")
|
|
158
274
|
except Exception as exc: # noqa: BLE001 — expression or bake can fail; both reach the UI
|
|
159
275
|
self._error = f"{type(exc).__name__}: {exc}"
|
|
160
276
|
self.errorChanged.emit()
|
|
161
|
-
return
|
|
277
|
+
return False
|
|
162
278
|
self._error = ""
|
|
163
279
|
self._vertex = QUrl.fromLocalFile(str(vertex))
|
|
164
280
|
self._fragment = QUrl.fromLocalFile(str(fragment))
|
|
165
281
|
self.errorChanged.emit()
|
|
166
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
|
|
167
296
|
|
|
168
297
|
|
|
169
298
|
class CurveListModel(QAbstractListModel):
|
|
@@ -245,21 +374,16 @@ class CurveListModel(QAbstractListModel):
|
|
|
245
374
|
curve.visibleChanged.connect(lambda c=curve: self._notify(c, (self.VisibleRole,)))
|
|
246
375
|
|
|
247
376
|
# ---------------------------------------------------------------- public
|
|
248
|
-
|
|
249
|
-
@Slot(str, result=QObject)
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
expression: str,
|
|
253
|
-
color: object = None,
|
|
254
|
-
line_width: object = None,
|
|
255
|
-
label: str = "",
|
|
256
|
-
) -> Curve:
|
|
257
|
-
"""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]:
|
|
258
381
|
if color is None:
|
|
259
382
|
color = self._cycle[len(self._curves) % len(self._cycle)]
|
|
260
|
-
if line_width is None
|
|
261
|
-
|
|
262
|
-
|
|
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:
|
|
263
387
|
row = len(self._curves)
|
|
264
388
|
self.beginInsertRows(QModelIndex(), row, row)
|
|
265
389
|
self._curves.append(curve)
|
|
@@ -267,6 +391,37 @@ class CurveListModel(QAbstractListModel):
|
|
|
267
391
|
self._watch(curve)
|
|
268
392
|
return curve
|
|
269
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
|
+
|
|
270
425
|
@Slot(QObject)
|
|
271
426
|
def remove_curve(self, curve: Curve) -> None:
|
|
272
427
|
row = self.index_of(curve)
|
qmlmathplot/export.py
CHANGED
|
@@ -70,9 +70,11 @@ def render(
|
|
|
70
70
|
) -> QImage:
|
|
71
71
|
"""Render ``plot`` offscreen and return the grabbed image, in device pixels.
|
|
72
72
|
|
|
73
|
-
``xlim`` / ``ylim`` default to the camera (
|
|
74
|
-
size
|
|
75
|
-
|
|
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``.
|
|
76
78
|
"""
|
|
77
79
|
dpi = float(dpi)
|
|
78
80
|
if not dpi > 0:
|
|
@@ -122,11 +124,19 @@ def render(
|
|
|
122
124
|
widget.resize(round(logical[0]), round(logical[1]))
|
|
123
125
|
widget.show() # offscreen: WA_DontShowOnScreen, never exposed
|
|
124
126
|
|
|
125
|
-
# The range: the camera
|
|
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``.
|
|
126
136
|
range_x = _pair(xlim, "xlim") if xlim is not None else (
|
|
127
|
-
live[0] - 0.5 *
|
|
137
|
+
live[0] - 0.5 * live_size[0] * live[2], live[0] + 0.5 * live_size[0] * live[2])
|
|
128
138
|
range_y = _pair(ylim, "ylim") if ylim is not None else (
|
|
129
|
-
live[1] - 0.5 *
|
|
139
|
+
live[1] - 0.5 * live_size[1] * live[3], live[1] + 0.5 * live_size[1] * live[3])
|
|
130
140
|
span_x, span_y = range_x[1] - range_x[0], range_y[1] - range_y[0]
|
|
131
141
|
centre = (0.5 * (range_x[0] + range_x[1]), 0.5 * (range_y[0] + range_y[1]))
|
|
132
142
|
|
qmlmathplot/model.py
CHANGED
|
@@ -21,9 +21,11 @@ from sympy.printing.precedence import PRECEDENCE
|
|
|
21
21
|
|
|
22
22
|
__all__ = [
|
|
23
23
|
"FRAGMENT_TEMPLATE",
|
|
24
|
+
"IMPLICIT_FRAGMENT_TEMPLATE",
|
|
24
25
|
"VERTEX_SHADER",
|
|
25
26
|
"dfunc_glsl",
|
|
26
27
|
"func_glsl",
|
|
28
|
+
"implicit_shader_sources",
|
|
27
29
|
"shader_sources",
|
|
28
30
|
]
|
|
29
31
|
|
|
@@ -152,6 +154,7 @@ def unbounded_edges(expr: sp.Expr, name: str = "x") -> list[tuple[float, bool, b
|
|
|
152
154
|
either).
|
|
153
155
|
"""
|
|
154
156
|
sym = sp.Symbol(name)
|
|
157
|
+
poles = _pole_factors(expr)
|
|
155
158
|
out: list[tuple[float, bool, bool]] = []
|
|
156
159
|
for cond in _domain_conditions(expr):
|
|
157
160
|
lhs = cond.lhs if isinstance(cond, (sp.Gt, sp.Ge, sp.Ne)) else None
|
|
@@ -169,6 +172,14 @@ def unbounded_edges(expr: sp.Expr, name: str = "x") -> list[tuple[float, bool, b
|
|
|
169
172
|
for point in points:
|
|
170
173
|
if not point.is_number:
|
|
171
174
|
continue
|
|
175
|
+
# A boundary that is also a *pole* (a sign-changing denominator vanishes there)
|
|
176
|
+
# needs no ray: its samples already exceed any viewport, and a ray would draw the
|
|
177
|
+
# very connector the pole rules remove. This is the difference the module docstring
|
|
178
|
+
# always claimed ("1/x and tan diverge fast") — it held for tan only because its
|
|
179
|
+
# boundary is non-polynomial, while `1/x`'s x=0 boundary is polynomial and got a
|
|
180
|
+
# ray, which drew a pale line across the viewport (measured at x = ±0.014).
|
|
181
|
+
if any(sp.simplify(f.subs(sym, point)) == 0 for f in poles):
|
|
182
|
+
continue
|
|
172
183
|
down = up = False
|
|
173
184
|
for side in ("+", "-"):
|
|
174
185
|
try:
|
|
@@ -312,6 +323,28 @@ vec2 _edge_du(float ex) {
|
|
|
312
323
|
return vec2(0.0, 0.0);
|
|
313
324
|
}
|
|
314
325
|
|
|
326
|
+
// Distance (logical px) from a pixel to the boundary ray: the polyline's infinite extension
|
|
327
|
+
// along a slowly diverging domain boundary (log(x) as x→0+), which a fixed-width sampling
|
|
328
|
+
// window can never reach. nl/nh are the column's extremes — its own valid samples, or the
|
|
329
|
+
// probe taken just inside the domain when the column has none (see main). The ray is drawn
|
|
330
|
+
// only when those samples have *not* already reached that viewport edge: if the polyline
|
|
331
|
+
// already leaves the viewport it covers the visible part on its own, and extending it would
|
|
332
|
+
// be a connector.
|
|
333
|
+
float _ray_dist(vec2 p, float ex, vec2 du, float nl, float nh,
|
|
334
|
+
float y_top, float y_bot, float sx, float sy) {
|
|
335
|
+
float d = 1e30;
|
|
336
|
+
if (ex >= 1e29 || nl >= 1e29) {
|
|
337
|
+
return d;
|
|
338
|
+
}
|
|
339
|
+
if (du.x != 0.0 && nl > y_bot) {
|
|
340
|
+
d = min(d, _seg_dist(p, vec2(ex * sx, nl * sy), vec2(ex * sx, -1e12)));
|
|
341
|
+
}
|
|
342
|
+
if (du.y != 0.0 && nh > -1e29 && nh < y_top) {
|
|
343
|
+
d = min(d, _seg_dist(p, vec2(ex * sx, nh * sy), vec2(ex * sx, 1e12)));
|
|
344
|
+
}
|
|
345
|
+
return d;
|
|
346
|
+
}
|
|
347
|
+
|
|
315
348
|
#define F(u) (@FUNC@)
|
|
316
349
|
#define DF @DFUNC@
|
|
317
350
|
// Domain predicate (pixels outside the domain are not drawn) and "pole denominators"
|
|
@@ -328,17 +361,56 @@ void main() {
|
|
|
328
361
|
float x = camera.x + (vUV.x - 0.5) * size.x * camera.z;
|
|
329
362
|
float y = camera.y + (0.5 - vUV.y) * size.y * camera.w;
|
|
330
363
|
|
|
331
|
-
// Domain: pixels outside are not drawn (x<0 for log(x), x<0 for sqrt(x), |x|>1 for
|
|
332
|
-
//
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
364
|
+
// Domain: pixels outside are not drawn (x<0 for log(x), x<0 for sqrt(x), |x|>1 for asin, …)
|
|
365
|
+
// — with one exception, the unbounded-boundary ray. The stroke is a *band* centred on the
|
|
366
|
+
// boundary, and for a slowly diverging edge (log(x) as x→0+) that band hugs x = 0, so the
|
|
367
|
+
// half of it lying outside the domain must still be painted. Discarding those pixels
|
|
368
|
+
// outright left the descent with its right half only (measured: 1.5 of the nominal 3.0
|
|
369
|
+
// device px, and the missing side was exactly the x < 0 half).
|
|
338
370
|
float spany = size.y * camera.w; // visible y span (world units)
|
|
339
371
|
float dx = camera.z; // world x per pixel
|
|
340
372
|
float sx = 1.0 / camera.z; // pixels per world x
|
|
341
373
|
float sy = 1.0 / camera.w; // pixels per world y
|
|
374
|
+
float y_top = camera.y + 0.5 * spany;
|
|
375
|
+
float y_bot = camera.y - 0.5 * spany;
|
|
376
|
+
// The boundary query is the sampled interval **widened by the stroke radius**
|
|
377
|
+
// (lineWidth/2 + 0.5): the coverage ramp reaches that far, so a narrower window clips the
|
|
378
|
+
// ray's own stroke (measured: the ray was evaluated only by the ±1-column band of pixels
|
|
379
|
+
// and the descent's neighbours got distance to an endpoint — 1.00 device px instead of
|
|
380
|
+
// 3.00). The constants are the sample offsets of the loop below (-1 + 0.25/4, -1 + 7.25/4).
|
|
381
|
+
float edge_r = (lineWidth * 0.5 + 0.5) * dx; // stroke radius, world units
|
|
382
|
+
float ex_ = _edge_hit(x - 0.9375 * dx - edge_r, x + 0.8125 * dx + edge_r);
|
|
383
|
+
vec2 du_ = (ex_ < 1e29) ? _edge_du(ex_) : vec2(0.0, 0.0);
|
|
384
|
+
// A column with no valid sample (a pixel just outside the domain) still lies inside the
|
|
385
|
+
// stroke's band around the boundary, so the band must be evaluated there too — but its
|
|
386
|
+
// extent has to come from the function itself, probed just inside the domain, since every
|
|
387
|
+
// sample of such a column is out of domain. Judging it by the sentinel alone drew a
|
|
388
|
+
// full-height line at `log(x-1)`'s boundary in a shallow view (where the visible curve is
|
|
389
|
+
// nowhere near the boundary), while ignoring those pixels altogether cost the descent its
|
|
390
|
+
// whole outer half (1.00-1.67 of the nominal 3.00 device px) in a deep one.
|
|
391
|
+
float edge_nl = 1e30;
|
|
392
|
+
float edge_nh = -1e30;
|
|
393
|
+
if (ex_ < 1e29) {
|
|
394
|
+
float probe_r = F(ex_ + 0.25 * dx);
|
|
395
|
+
float probe_l = F(ex_ - 0.25 * dx);
|
|
396
|
+
if (probe_r == probe_r && abs(probe_r) < 1e30) {
|
|
397
|
+
edge_nl = min(edge_nl, probe_r);
|
|
398
|
+
edge_nh = max(edge_nh, probe_r);
|
|
399
|
+
}
|
|
400
|
+
if (probe_l == probe_l && abs(probe_l) < 1e30) {
|
|
401
|
+
edge_nl = min(edge_nl, probe_l);
|
|
402
|
+
edge_nh = max(edge_nh, probe_l);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
if (!DOM(x)) {
|
|
407
|
+
float cov = clamp(lineWidth * 0.5 + 0.5
|
|
408
|
+
- _ray_dist(vec2(x * sx, y * sy), ex_, du_, edge_nl, edge_nh,
|
|
409
|
+
y_top, y_bot, sx, sy),
|
|
410
|
+
0.0, 1.0);
|
|
411
|
+
fragColor = vec4(color.rgb * cov * color.a * qt_Opacity, cov * color.a * qt_Opacity);
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
342
414
|
|
|
343
415
|
float f0 = F(x);
|
|
344
416
|
float d1 = DF;
|
|
@@ -404,24 +476,38 @@ void main() {
|
|
|
404
476
|
bool have = false;
|
|
405
477
|
float ps = 0.0;
|
|
406
478
|
float pd = 0.0;
|
|
479
|
+
float px = 0.0;
|
|
407
480
|
for (int k = 0; k < 16; k++) {
|
|
481
|
+
float xk;
|
|
408
482
|
float s;
|
|
409
483
|
bool ok;
|
|
410
484
|
if (k - (k / 2) * 2 == 0) { // even slot: reuse a narrow-window sample
|
|
485
|
+
xk = smp_x[k / 2];
|
|
411
486
|
s = smp[k / 2];
|
|
412
487
|
ok = smp_ok[k / 2];
|
|
413
488
|
} else { // odd slot: sample additionally
|
|
414
|
-
|
|
415
|
-
s = F(
|
|
416
|
-
ok = DOMS(
|
|
489
|
+
xk = x + (-1.0 + (float(k) + 0.5) / 8.0) * h;
|
|
490
|
+
s = F(xk);
|
|
491
|
+
ok = DOMS(xk) && (s == s) && (abs(s) < 1e30);
|
|
417
492
|
}
|
|
418
493
|
if (ok) {
|
|
419
494
|
if (have) {
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
495
|
+
// A pair that straddles a pole is a jump, not a turn — and it must
|
|
496
|
+
// not seed the next comparison either: its huge difference has the
|
|
497
|
+
// opposite sign to the following pair's, which then reads as a
|
|
498
|
+
// second, fake turn. Measured at tan(x)'s pole: turns16 = 2 and
|
|
499
|
+
// w = 1 (the band filled the column solid, the connector); with the
|
|
500
|
+
// jump excluded turns16 = 0 and the column draws nothing.
|
|
501
|
+
if (POLE(px) * POLE(xk) <= 0.0) {
|
|
502
|
+
pd = 0.0;
|
|
503
|
+
} else {
|
|
504
|
+
float d = s - ps;
|
|
505
|
+
if (pd * d < 0.0) { turns16 += 1.0; }
|
|
506
|
+
pd = d;
|
|
507
|
+
}
|
|
423
508
|
}
|
|
424
509
|
ps = s;
|
|
510
|
+
px = xk;
|
|
425
511
|
have = true;
|
|
426
512
|
}
|
|
427
513
|
}
|
|
@@ -456,7 +542,14 @@ void main() {
|
|
|
456
542
|
bool prev_oki = false;
|
|
457
543
|
for (int i = 0; i < 8; i++) {
|
|
458
544
|
if (smp_ok[i]) {
|
|
459
|
-
|
|
545
|
+
// A segment that crosses a pole is not part of the curve: the two
|
|
546
|
+
// branches diverge to ±∞ there, and joining them draws the vertical
|
|
547
|
+
// connector. Decided by the denominator's sign change (world units, so
|
|
548
|
+
// it does not drift with zoom) rather than by the "gap" test below,
|
|
549
|
+
// which needs the sampled values to exceed the viewport by 32× — the
|
|
550
|
+
// pole column of a shallow view only reaches ~15×, and tan(x) then drew
|
|
551
|
+
// its connector (measured: a full-height column at x=±π/2).
|
|
552
|
+
if (prev_oki && POLE(prev_xi) * POLE(smp_x[i]) > 0.0) {
|
|
460
553
|
dmin = min(dmin, _seg_dist(p, vec2(prev_xi * sx, prev_si * sy),
|
|
461
554
|
vec2(smp_x[i] * sx, smp[i] * sy)));
|
|
462
555
|
}
|
|
@@ -465,21 +558,12 @@ void main() {
|
|
|
465
558
|
}
|
|
466
559
|
prev_oki = smp_ok[i];
|
|
467
560
|
}
|
|
468
|
-
// Infinite extension
|
|
469
|
-
//
|
|
470
|
-
//
|
|
471
|
-
//
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
if (ex_ < 1e29 && nl < 1e29) {
|
|
475
|
-
vec2 du_ = _edge_du(ex_);
|
|
476
|
-
if (du_.x != 0.0) {
|
|
477
|
-
dmin = min(dmin, _seg_dist(p, vec2(ex_ * sx, nl * sy), vec2(ex_ * sx, -1e12)));
|
|
478
|
-
}
|
|
479
|
-
if (du_.y != 0.0) {
|
|
480
|
-
dmin = min(dmin, _seg_dist(p, vec2(ex_ * sx, nh * sy), vec2(ex_ * sx, 1e12)));
|
|
481
|
-
}
|
|
482
|
-
}
|
|
561
|
+
// Infinite extension along a slowly diverging boundary (log(x) as x→0+): the ray
|
|
562
|
+
// lies on the boundary x, so the width stays constant and the columns align. See
|
|
563
|
+
// _ray_dist for when it is drawn. A column whose own samples are all out of domain
|
|
564
|
+
// borrows the probe taken above, so the band keeps its width there too.
|
|
565
|
+
dmin = min(dmin, _ray_dist(p, ex_, du_, nl < 1e29 ? nl : edge_nl,
|
|
566
|
+
nh > -1e29 ? nh : edge_nh, y_top, y_bot, sx, sy));
|
|
483
567
|
cov = clamp(lineWidth * 0.5 + 0.5 - dmin, 0.0, 1.0);
|
|
484
568
|
}
|
|
485
569
|
if (w > 0.0) {
|
|
@@ -514,6 +598,78 @@ void main() {
|
|
|
514
598
|
"""
|
|
515
599
|
|
|
516
600
|
|
|
601
|
+
# Implicit curves: the level set F(x, y) = 0, stroked by the same first-order idea as the 1-D
|
|
602
|
+
# stroke, one dimension up. One evaluation of F and its two partials give the distance to the
|
|
603
|
+
# level set in pixels:
|
|
604
|
+
#
|
|
605
|
+
# d = |F| / |grad F in pixels|, cov = clamp(lineWidth/2 + 0.5 - d, 0, 1)
|
|
606
|
+
#
|
|
607
|
+
# No sampling loop and no envelope band: this is the single-sheet, smooth case (thin features and
|
|
608
|
+
# several branches of F through one pixel, e.g. sin(1/(x*x + y*y)), are a separate problem —
|
|
609
|
+
# docs/api-design.md §6b). Out-of-domain pixels (log(x), sqrt, …) come out NaN and are dropped,
|
|
610
|
+
# so there is no DOM/POLE machinery here either.
|
|
611
|
+
IMPLICIT_FRAGMENT_TEMPLATE = """#version 440
|
|
612
|
+
layout(location = 0) in vec2 vUV;
|
|
613
|
+
layout(location = 0) out vec4 fragColor;
|
|
614
|
+
layout(std140, binding = 0) uniform buf {
|
|
615
|
+
mat4 qt_Matrix;
|
|
616
|
+
float qt_Opacity;
|
|
617
|
+
vec4 camera; // centre.x, centre.y, world units per logical pixel (x, y)
|
|
618
|
+
vec2 size; // viewport size (logical pixels)
|
|
619
|
+
float lineWidth; // line width (logical pixels)
|
|
620
|
+
vec4 color; // non-premultiplied rgba
|
|
621
|
+
};
|
|
622
|
+
|
|
623
|
+
// Argument reduction: the GPU's built-in sin/cos are unreliable for large arguments;
|
|
624
|
+
// fold into [0,2π) before calling
|
|
625
|
+
#define TAU 6.283185307179586
|
|
626
|
+
float _wrap(float t) { return t - TAU * floor(t * (1.0 / TAU)); }
|
|
627
|
+
#define SIN(t) sin(_wrap(t))
|
|
628
|
+
#define COS(t) cos(_wrap(t))
|
|
629
|
+
|
|
630
|
+
// F is a macro with two parameters (text substitution; the call always passes the plain pixel
|
|
631
|
+
// coordinates, so no extra parentheses are needed on the arguments — see the note in the 1-D
|
|
632
|
+
// template). FX/FY are *object-like* macros, i.e. the definition is spliced into a product
|
|
633
|
+
// (`gx = FX * camera.z`), so the definition itself must be parenthesized: a partial that is not
|
|
634
|
+
// a single term, e.g. d/dx (x-1)^2 = 2*x - 2, otherwise expands to `2*x - 2 * camera.z` and the
|
|
635
|
+
// gradient (hence the coverage) comes out wrong.
|
|
636
|
+
#define F(x, y) (@FUNC@)
|
|
637
|
+
#define FX (@DFUNCX@)
|
|
638
|
+
#define FY (@DFUNCY@)
|
|
639
|
+
|
|
640
|
+
void main() {
|
|
641
|
+
float x = camera.x + (vUV.x - 0.5) * size.x * camera.z;
|
|
642
|
+
float y = camera.y + (0.5 - vUV.y) * size.y * camera.w;
|
|
643
|
+
|
|
644
|
+
float f = F(x, y);
|
|
645
|
+
// |grad F| in pixels: the partial derivative per world unit times the world units per pixel
|
|
646
|
+
float gx = FX * camera.z;
|
|
647
|
+
float gy = FY * camera.w;
|
|
648
|
+
// Distance from this pixel to the level set, in pixels. The floor keeps a critical point of
|
|
649
|
+
// F (grad F = 0, e.g. the apex of a degenerate level set) from dividing by zero.
|
|
650
|
+
float d = abs(f) / max(sqrt(gx * gx + gy * gy), 1e-9);
|
|
651
|
+
|
|
652
|
+
float cov = clamp(lineWidth * 0.5 + 0.5 - d, 0.0, 1.0);
|
|
653
|
+
float a = cov * color.a * qt_Opacity;
|
|
654
|
+
if (isnan(f) || isinf(f)) {
|
|
655
|
+
a = 0.0; // pixels where F itself is undefined
|
|
656
|
+
}
|
|
657
|
+
fragColor = vec4(color.rgb * a, a); // premultiplied alpha
|
|
658
|
+
}
|
|
659
|
+
"""
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def implicit_shader_sources(expr: sp.Expr) -> tuple[str, str]:
|
|
663
|
+
"""Vertex/fragment shader sources for an implicit curve (the level set ``F(x, y) = 0``).
|
|
664
|
+
|
|
665
|
+
Same uniform block as the 1-D shader, so the QML side and the curve model need no special
|
|
666
|
+
case: only the expressions differ.
|
|
667
|
+
"""
|
|
668
|
+
return (VERTEX_SHADER, IMPLICIT_FRAGMENT_TEMPLATE
|
|
669
|
+
.replace("@FUNC@", func_glsl(expr, "x"))
|
|
670
|
+
.replace("@DFUNCX@", func_glsl(sp.diff(expr, sp.Symbol("x")), "x"))
|
|
671
|
+
.replace("@DFUNCY@", func_glsl(sp.diff(expr, sp.Symbol("y")), "x")))
|
|
672
|
+
|
|
517
673
|
def shader_sources(expr: sp.Expr) -> tuple[str, str]:
|
|
518
674
|
"""Vertex/fragment shader sources for the QML front end (implicit model)."""
|
|
519
675
|
frag = (
|
qmlmathplot/mpl.py
CHANGED
|
@@ -27,6 +27,8 @@ if TYPE_CHECKING: # pragma: no cover - typing only; the real imports stay lazy
|
|
|
27
27
|
|
|
28
28
|
__all__ = ["figure_for", "save_via_matplotlib", "sympy_plot_for"]
|
|
29
29
|
|
|
30
|
+
#: The free symbol every curve is written in (see ``Curve``).
|
|
31
|
+
_SYMBOL = sp.Symbol("x")
|
|
30
32
|
#: Logical pixels to points (96 px/inch, 72 pt/inch): our sizes are in px, matplotlib's in pt.
|
|
31
33
|
PX_TO_PT = 72.0 / 96.0
|
|
32
34
|
#: The DPI the hand-off assumes for sizes that only exist in pixels (the live viewport).
|
|
@@ -57,31 +59,38 @@ def _pair(value: Sequence[float], name: str) -> tuple[float, float]:
|
|
|
57
59
|
|
|
58
60
|
|
|
59
61
|
def _visible_curves(plot: Plot) -> list[Curve]:
|
|
60
|
-
"""The curves the Qt view would draw (a hidden curve is not handed over).
|
|
62
|
+
"""The curves the Qt view would draw (a hidden curve is not handed over).
|
|
63
|
+
|
|
64
|
+
Implicit curves (the level set ``F(x, y) = 0``) are **skipped**: a sampled polyline cannot
|
|
65
|
+
come from an implicit expression (the faithful route would be a ``contour`` on a grid, i.e. a
|
|
66
|
+
different renderer with a grid resolution), and this hand-off refuses to pretend. A plot made
|
|
67
|
+
of implicit curves only is therefore empty here — use the Qt export for it.
|
|
68
|
+
"""
|
|
61
69
|
return [plot.curves.at(index) for index in range(len(plot.curves))
|
|
62
|
-
if plot.curves.at(index).visible]
|
|
70
|
+
if plot.curves.at(index).visible and not plot.curves.at(index).implicit]
|
|
63
71
|
|
|
64
72
|
|
|
65
|
-
def _sample(
|
|
73
|
+
def _sample(curve: Curve, x0: float, x1: float, samples: int) -> tuple[object, object]:
|
|
66
74
|
"""Uniform samples of one curve; NaN where the function is outside its domain.
|
|
67
75
|
|
|
68
76
|
matplotlib breaks the line at NaN, which is how the hand-off keeps ``log(x)`` from being
|
|
69
77
|
drawn for x < 0. Infinities (poles, ``1/x`` at 0) are turned into NaN as well, so a pole
|
|
70
78
|
breaks the line instead of drawing a connector across it.
|
|
79
|
+
|
|
80
|
+
The expression comes from ``Curve.expr``: the curve parsed it once, so no string is parsed
|
|
81
|
+
again here.
|
|
71
82
|
"""
|
|
72
83
|
import numpy
|
|
73
84
|
|
|
74
|
-
symbol = sp.Symbol("x")
|
|
75
85
|
try:
|
|
76
|
-
function = sp.lambdify(
|
|
77
|
-
modules=["numpy"])
|
|
86
|
+
function = sp.lambdify(_SYMBOL, curve.expr, modules=["numpy"])
|
|
78
87
|
with numpy.errstate(all="ignore"): # log(x<0) etc.: NaN, not a warning storm
|
|
79
88
|
xs = numpy.linspace(x0, x1, samples)
|
|
80
89
|
ys = numpy.asarray(function(xs), dtype=float)
|
|
81
90
|
except Exception as exc:
|
|
82
|
-
# Broad on purpose:
|
|
83
|
-
#
|
|
84
|
-
raise ValueError(f"cannot sample {expression!r}: {exc}") from exc
|
|
91
|
+
# Broad on purpose: lambdify and the evaluation itself report by raising, and the
|
|
92
|
+
# caller wants one clear message naming the expression.
|
|
93
|
+
raise ValueError(f"cannot sample {curve.expression!r}: {exc}") from exc
|
|
85
94
|
if ys.ndim == 0: # a constant expression evaluates to a scalar
|
|
86
95
|
ys = numpy.full(xs.shape, float(ys))
|
|
87
96
|
ys[~numpy.isfinite(ys)] = numpy.nan
|
|
@@ -162,7 +171,7 @@ def figure_for(
|
|
|
162
171
|
fontweight="bold" if plot.title_bold else "normal")
|
|
163
172
|
|
|
164
173
|
for curve in _visible_curves(plot):
|
|
165
|
-
xs, ys = _sample(curve
|
|
174
|
+
xs, ys = _sample(curve, x_range[0], x_range[1], samples)
|
|
166
175
|
axes.plot(xs, ys, color=curve.color.name(), linewidth=curve.lineWidth * PX_TO_PT,
|
|
167
176
|
label=curve.label or curve.expression)
|
|
168
177
|
return figure
|
|
@@ -207,20 +216,14 @@ def sympy_plot_for(
|
|
|
207
216
|
is the same one the matplotlib hand-off would show, and every curve keeps its colour.
|
|
208
217
|
"""
|
|
209
218
|
_require() # sympy's plotting backend is matplotlib too
|
|
210
|
-
symbol = sp.Symbol("x")
|
|
211
219
|
curves = _visible_curves(plot)
|
|
212
220
|
if not curves:
|
|
213
221
|
raise ValueError("nothing to hand to sympy: the plot has no visible curve")
|
|
214
222
|
x_range = _pair(xlim, "xlim") if xlim is not None else (plot.xlim.x(), plot.xlim.y())
|
|
215
223
|
y_range = _pair(ylim, "ylim") if ylim is not None else (plot.ylim.x(), plot.ylim.y())
|
|
216
|
-
expressions = []
|
|
217
|
-
for curve in curves:
|
|
218
|
-
try:
|
|
219
|
-
expressions.append(sp.sympify(curve.expression, locals={"x": symbol}))
|
|
220
|
-
except Exception as exc:
|
|
221
|
-
raise ValueError(f"cannot parse {curve.expression!r}: {exc}") from exc
|
|
222
224
|
handle = sp.plotting.plot(
|
|
223
|
-
*
|
|
225
|
+
*(curve.expr for curve in curves), # parsed once, by the curve
|
|
226
|
+
(_SYMBOL, x_range[0], x_range[1]), show=False,
|
|
224
227
|
adaptive=adaptive, # see Plot.to_sympy for why False is the default
|
|
225
228
|
nb_of_points=samples,
|
|
226
229
|
label=[curve.label or curve.expression for curve in curves],
|
qmlmathplot/plot.py
CHANGED
|
@@ -11,6 +11,8 @@ from __future__ import annotations
|
|
|
11
11
|
from PySide6.QtCore import Property, QObject, Signal, Slot
|
|
12
12
|
from PySide6.QtGui import QColor, QImage, QVector2D
|
|
13
13
|
|
|
14
|
+
import sympy as sp
|
|
15
|
+
|
|
14
16
|
from .camera import Camera
|
|
15
17
|
from .curve import DEFAULT_COLOR_CYCLE, Curve, CurveListModel
|
|
16
18
|
from .themes import names, resolve
|
|
@@ -141,18 +143,45 @@ class Plot(QObject):
|
|
|
141
143
|
"QVariant", _get_aspect, _set_aspect, notify=aspectChanged
|
|
142
144
|
)
|
|
143
145
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
+
# "QVariant" for the expression so a sympy expression arrives *unconverted*: a `str` slot
|
|
147
|
+
# would have PySide stringify it first, i.e. the very Expr -> str -> Expr round trip this
|
|
148
|
+
# signature exists to avoid (QML passes a string, which is unchanged).
|
|
149
|
+
@Slot("QVariant", "QVariant", "QVariant", str, result=QObject)
|
|
150
|
+
@Slot("QVariant", result=QObject)
|
|
146
151
|
def add_curve(
|
|
147
152
|
self,
|
|
148
|
-
expression: str,
|
|
153
|
+
expression: "str | sp.Expr",
|
|
149
154
|
color: object = None,
|
|
150
155
|
line_width: object = None,
|
|
151
156
|
label: str = "",
|
|
152
157
|
) -> Curve:
|
|
153
|
-
"""Add a curve (colour and width default to the plot's cycle and ``lineWidth``).
|
|
158
|
+
"""Add a curve (colour and width default to the plot's cycle and ``lineWidth``).
|
|
159
|
+
|
|
160
|
+
The artist follows the expression's free symbols: ``x`` (or none) is a 1-D curve
|
|
161
|
+
``y = f(x)``, ``x`` **and** ``y`` is an implicit curve — the level set ``F(x, y) = 0``,
|
|
162
|
+
so ``add_curve("x**2 + y**2 - 1")`` is a circle — and any other free symbol raises
|
|
163
|
+
``ValueError`` (substitute parameters first).
|
|
164
|
+
"""
|
|
154
165
|
return self._curves.add_curve(expression, color, line_width, label)
|
|
155
166
|
|
|
167
|
+
@Slot("QVariant", "QVariant", "QVariant", str, result=QObject)
|
|
168
|
+
@Slot("QVariant", result=QObject)
|
|
169
|
+
def add_implicit(
|
|
170
|
+
self,
|
|
171
|
+
expression: str | sp.Expr,
|
|
172
|
+
color: object = None,
|
|
173
|
+
line_width: object = None,
|
|
174
|
+
label: str = "",
|
|
175
|
+
) -> Curve:
|
|
176
|
+
"""Add an implicit curve — the level set ``F(x, y) = 0`` — explicitly, whatever the
|
|
177
|
+
expression's free symbols (``x`` alone gives vertical lines).
|
|
178
|
+
|
|
179
|
+
Drawn per pixel like the other artists, with no sampling: ``|F|`` divided by ``|grad F|``
|
|
180
|
+
in pixels is the distance to the level set. Smooth, single-sheet level sets only for now
|
|
181
|
+
— thin features and several branches through one pixel are not handled yet.
|
|
182
|
+
"""
|
|
183
|
+
return self._curves.add_implicit(expression, color, line_width, label)
|
|
184
|
+
|
|
156
185
|
@Slot(QObject)
|
|
157
186
|
def remove_curve(self, curve: Curve) -> None:
|
|
158
187
|
self._curves.remove_curve(curve)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: qmlmathplot
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Embeddable function plotter for Qt Quick / QtWidgets (implicit per-pixel drawing, MVVM)
|
|
5
5
|
Author: I-AM-A-NOOB
|
|
6
6
|
Author-email: I-AM-A-NOOB <suzc08@outlook.com>
|
|
@@ -44,6 +44,18 @@ An un-themed plot looks like matplotlib (white background, tab10 colours, grey g
|
|
|
44
44
|
axes through the origin with the tick labels riding them) — the default style sheet is applied
|
|
45
45
|
at construction, so `plot.theme = "default"` is the reset.
|
|
46
46
|
|
|
47
|
+
`add_curve()` (and `Curve.expression`) accept the expression **either as a string or as a sympy
|
|
48
|
+
expression**: `plot.add_curve(sp.sin(1 / sp.Symbol("x")))` is the same curve as
|
|
49
|
+
`plot.add_curve("sin(1/x)")`. The stored and reported form is always the canonical string — QML,
|
|
50
|
+
a project file and the text field want one — while the parsed expression is cached and exposed as
|
|
51
|
+
`Curve.expr`, which is what re-baking and the matplotlib hand-off use. The object form exists so a
|
|
52
|
+
curve can be composed without string interpolation:
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
curve = plot.add_curve("sin(x)")
|
|
56
|
+
plot.add_curve(sp.diff(sp.sympify(curve.expression, locals={"x": sp.Symbol("x")})), label="f'")
|
|
57
|
+
```
|
|
58
|
+
|
|
47
59
|
`MathPlotWidget` keeps one curve's worth of convenience (`expression`, `error`,
|
|
48
60
|
`line_width`, `curve_color`, `background_color`, `aspect`, `view_bounds()`,
|
|
49
61
|
`reset_view()`, `zoom()`, `pan_pixels()`); everything else lives on the model behind it:
|
|
@@ -122,9 +134,9 @@ The model:
|
|
|
122
134
|
|
|
123
135
|
| Object | Members |
|
|
124
136
|
|---|---|
|
|
125
|
-
| `Plot` | `camera`, `curves`, `xlim`/`ylim`/`aspect` (forwarded), `add_curve()` / `remove_curve()`, `available_themes`, and the style properties: `background`, `grid`, `grid_color`, `grid_width`, `grid_alpha`, `grid_style`, `axis_color`, `axis_width`, `axes_position`, `color_cycle`, `line_width`, `text_color`, `tick_color`, `tick_length`, `font_size`, `tick_font_size`, `title`, `title_color`, `title_font_size`, `title_bold`, `ticks_visible`, `theme` |
|
|
137
|
+
| `Plot` | `camera`, `curves`, `xlim`/`ylim`/`aspect` (forwarded), `add_curve()` / `add_implicit()` / `remove_curve()`, `available_themes`, and the style properties: `background`, `grid`, `grid_color`, `grid_width`, `grid_alpha`, `grid_style`, `axis_color`, `axis_width`, `axes_position`, `color_cycle`, `line_width`, `text_color`, `tick_color`, `tick_length`, `font_size`, `tick_font_size`, `title`, `title_color`, `title_font_size`, `title_bold`, `ticks_visible`, `theme` |
|
|
126
138
|
| `Camera` | `centre`, `zoom` (world units per logical pixel), `aspect`, `xlim`/`ylim` (derived), `ticks_x`/`ticks_y`, `zoomStep` / `panEnabled` / `zoomEnabled`, slots `setViewport(w, h)` / `zoom_by(delta, u, v, w, h)` / `pan_pixels(dx, dy)` / `reset()` |
|
|
127
|
-
| `Curve` | `expression`, `color`, `lineWidth`, `visible`, `label`, `error` (read-only), the baked `vertexShader` / `fragmentShader` |
|
|
139
|
+
| `Curve` | `expression` (string or sympy expression), `expr` (the parsed expression, read-only), `implicit`, `color`, `lineWidth`, `visible`, `label`, `error` (read-only), the baked `vertexShader` / `fragmentShader` |
|
|
128
140
|
| `CurveListModel` | the curves as a model (`curve`, `expression`, `color`, `lineWidth`, `visible`, `vertexShader`, `fragmentShader` roles) so QML puts one `ShaderEffect` behind each |
|
|
129
141
|
|
|
130
142
|
Every setter is a Qt `Property` with a notify signal, so the same calls work from QML, from a
|
|
@@ -144,6 +156,35 @@ bounded page), no `FigureCanvas`/`draw()` (the scene is live), no toolbar and no
|
|
|
144
156
|
chrome), and an export is a canvas region at a pixel size rather than a re-rendered page
|
|
145
157
|
(§9, implemented — see *Export* below).
|
|
146
158
|
|
|
159
|
+
## Implicit curves
|
|
160
|
+
|
|
161
|
+
An expression whose free symbols are `x` **and** `y` is drawn as the level set `F(x, y) = 0`.
|
|
162
|
+
The artist follows the expression, whoever sets it: `add_curve` routes it by itself, assigning to
|
|
163
|
+
`curve.expression` re-routes the same curve (so a text field can turn `sin(x)` into a circle),
|
|
164
|
+
and `add_implicit` asks for the level-set artist explicitly — also for a function of `x` alone,
|
|
165
|
+
which is a pair of vertical lines. Only a *third* free symbol is refused: substitute parameters
|
|
166
|
+
first.
|
|
167
|
+
|
|
168
|
+
```python
|
|
169
|
+
plot.add_curve("x**2 + y**2 - 1") # a circle: {x, y} routes to the level-set artist
|
|
170
|
+
plot.add_implicit("x*y - 1") # the same artist, asked for by name
|
|
171
|
+
plot.curves.at(0).expression = "x**2 + y**2 - 4" # re-routes the curve that is already there
|
|
172
|
+
plot.add_curve("a*sin(x)") # ValueError: substitute `a` first
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
The renderer is the same idea as the 1-D stroke, one dimension up: one evaluation of `F` and its
|
|
176
|
+
two partials per pixel give the distance to the level set in *pixels*,
|
|
177
|
+
`d = |F| / sqrt((Fx·dx)² + (Fy·dy)²)`, and the coverage is `clamp(lineWidth/2 + 0.5 - d, 0, 1)` —
|
|
178
|
+
analytic antialiasing and a stroke width that does not depend on the angle, with no sampling grid
|
|
179
|
+
(the unit circle measures the same 3 device pixels at 0°, 45°, 90° and 135° for `lineWidth=2` at
|
|
180
|
+
DPR 1.5).
|
|
181
|
+
|
|
182
|
+
Known limit: **smooth, single-sheet level sets**. Thin features and several branches through one
|
|
183
|
+
pixel (`sin(1/(x*x + y*y))`), and 2-D domains/poles, are not handled yet — the assessment is
|
|
184
|
+
`docs/api-design.md` §6b; pixels where `F` is undefined are dropped. The matplotlib hand-off
|
|
185
|
+
skips implicit curves (a level set has no polyline to sample), while the Qt export renders them
|
|
186
|
+
like anything else.
|
|
187
|
+
|
|
147
188
|
## The camera (centre, zoom, aspect)
|
|
148
189
|
|
|
149
190
|
`Camera` stores three numbers that do not depend on the widget: `centre` (world coordinates in
|
|
@@ -279,6 +320,14 @@ backend is used**. The implementation sets `QSG_RHI_BACKEND` before
|
|
|
279
320
|
|
|
280
321
|
## Structure (model / view)
|
|
281
322
|
|
|
323
|
+
The layers are strict about Qt. **`model.py` (expression → GLSL), `themes.py` (style-sheet
|
|
324
|
+
conversion) and `mpl.py` (the sampled hand-off) import no Qt at all** — they are unit-testable
|
|
325
|
+
without a `QApplication`. `Camera`, `Curve` and `Plot` are the **ViewModel**: `QObject`s whose
|
|
326
|
+
`Property`es carry notify signals, which is precisely what the QML component binds to — and why
|
|
327
|
+
a Python-side assignment updates the view with no call in between. `view.py`, `PlotView.qml`,
|
|
328
|
+
`export.py` and `widget.py` are the View/embedding layer and do need Qt; `export.py` and
|
|
329
|
+
`widget.py` import QtWidgets lazily, so a pure-QML host never loads it.
|
|
330
|
+
|
|
282
331
|
| Layer | File | Responsibility |
|
|
283
332
|
|---|---|---|
|
|
284
333
|
| Model | `src/qmlmathplot/model.py` | expression → GLSL, shader source template (no Qt dependency, unit-testable) |
|
|
@@ -327,7 +376,12 @@ QSG_RHI_BACKEND=opengl uv run pytest -m gui
|
|
|
327
376
|
(the home view at the first size, resize keeping the scale, an anchored zoom that
|
|
328
377
|
is exactly reversible, the aspect expanding but never cropping, ticks).
|
|
329
378
|
- `test_curve.py`: a failed expression keeps the last working shaders, the style
|
|
330
|
-
setters are idempotent, and the list model reports the right roles
|
|
379
|
+
setters are idempotent, and the list model reports the right roles — plus the
|
|
380
|
+
`Expr`/string round-trip accounting (a string is parsed once, an expression never).
|
|
381
|
+
- `test_implicit.py`: the routing by free symbols (and the `ValueError` for a third symbol), the
|
|
382
|
+
level-set shader text (same uniform block as the 1-D one, no sampling loop), and the rendered
|
|
383
|
+
geometry of a unit circle: crossings a quarter of the height from each edge, a blank middle,
|
|
384
|
+
radius 1 on both axes, and a stroke of the same width at 0°/45°/90°/135°.
|
|
331
385
|
- `test_plot.py`: the plot's properties convert, notify exactly once and are wired
|
|
332
386
|
(a PySide property with an unresolved notify name silently loses it), the defaults are
|
|
333
387
|
matplotlib's default style, the limits are forwarded to the camera, and a theme applies
|
|
@@ -391,15 +445,21 @@ extrema/zeros by bisection. The same approach is used here:
|
|
|
391
445
|
perpendicular foot lands on a segment (on a sloped line it is displaced along x
|
|
392
446
|
by `m·d/√(1+m²)`); with a narrower window the distance degraded to "distance to
|
|
393
447
|
the nearest endpoint" and steep parts looked thin and ragged.
|
|
394
|
-
- **Break on jumps**:
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
448
|
+
- **Break on jumps**: a denominator sign change (world units) decides, in three places,
|
|
449
|
+
that nothing joins the branches — the polyline segment that straddles the pole is not
|
|
450
|
+
stroked, a straddling pair does not count as a turn in the band's turn count (it used to
|
|
451
|
+
fill the pole column solid), and the unbounded-boundary ray is not drawn there. No vertical
|
|
452
|
+
connector at the asymptotes of `1/x`, `tan(x)`. (An earlier "adjacent samples differ by
|
|
453
|
+
more than 4 viewport heights" criterion was pixel-/viewport-relative, so under magnification
|
|
454
|
+
it also cut legitimate steep segments; it was removed. The remaining "spread beyond 32
|
|
455
|
+
viewport heights" gap is a second line of defence only — at `tan`'s pole column the spread
|
|
456
|
+
is ~15 viewports, so it never fires there.)
|
|
399
457
|
- **Domain** (derived by sympy, injected as `DOM`): denominator ≠ 0, `log` argument
|
|
400
458
|
> 0, `sqrt` argument ≥ 0, `asin/acos` argument ∈ [−1,1]; pixels outside the domain
|
|
401
|
-
are discarded outright
|
|
402
|
-
|
|
459
|
+
are discarded outright — except for the **boundary ray** of a slowly diverging edge
|
|
460
|
+
(`log(x)` as x→0+), which is a band centred *on* the boundary and therefore paints the few
|
|
461
|
+
pixels outside the domain that it covers (the whole outer half of that band, otherwise).
|
|
462
|
+
NaN/inf samples take part in neither the envelope nor segment joining.
|
|
403
463
|
- **Undersampled columns** (several oscillations inside one column) are replaced
|
|
404
464
|
wholesale by the envelope band: a polyline is random sawtooth in such columns.
|
|
405
465
|
There are two criteria, both **scale-independent**: (a) ≥ 2 direction reversals
|
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
qmlmathplot/__init__.py,sha256=
|
|
2
|
-
qmlmathplot/
|
|
1
|
+
qmlmathplot/__init__.py,sha256=fJu6acfPJfV-5AhhCmSdGhf9eBzVWuLHuSr-UpfRLeU,2869
|
|
2
|
+
qmlmathplot/__main__.py,sha256=vQOzajfwfvukcwvhZSg29HtGocqQnQGBBbc-Rc2AUT4,389
|
|
3
|
+
qmlmathplot/app.py,sha256=P2RBLyUIfjn8_JGpnkJa7exKs6LcQTYY8tSZQtxyQ3I,3243
|
|
3
4
|
qmlmathplot/camera.py,sha256=YSLtGODs_efGwmtKbjT9osss-jmyV7MaLEYOu_zcxg0,16894
|
|
4
|
-
qmlmathplot/curve.py,sha256=
|
|
5
|
-
qmlmathplot/export.py,sha256=
|
|
6
|
-
qmlmathplot/model.py,sha256=
|
|
7
|
-
qmlmathplot/mpl.py,sha256=
|
|
8
|
-
qmlmathplot/plot.py,sha256=
|
|
5
|
+
qmlmathplot/curve.py,sha256=_LCoZraLBCQxVUFWh2kmi4kjBNgg3zbfZFwg_i9J-YU,18688
|
|
6
|
+
qmlmathplot/export.py,sha256=BEZh5mDHlxGH0ernsGPOCfAjZGi_qwuecANYkRnKFEk,8999
|
|
7
|
+
qmlmathplot/model.py,sha256=mbWj9Tmf3k8T17EmkGUNZGe1xdtZ6TVQvaYdGYEMVxs,34128
|
|
8
|
+
qmlmathplot/mpl.py,sha256=Q6yuVeuXlejYM5n_odCFIwLq2-w2MP8hfD9po-ZiCog,10970
|
|
9
|
+
qmlmathplot/plot.py,sha256=A_mJOSCtSeW0mPd4KvKcwITRC1h4hw1ToGwFUIX7X9c,16810
|
|
9
10
|
qmlmathplot/qml/PlotView.qml,sha256=4XfSGcmlsD6LNua_qhB5DtVuL-PTFO3gG4LhJzzQC7M,11519
|
|
10
11
|
qmlmathplot/qsb.py,sha256=kTaC3hKAZYSNU5M2ySRFcWUdwTZT8DAHUdsUgKRCanc,3964
|
|
11
12
|
qmlmathplot/themes/LICENSE.matplotlib,sha256=ojr3trhyymyw7GeYxkhO2JYoAxUVscbgMFz9b1qrcVM,4928
|
|
@@ -40,7 +41,7 @@ qmlmathplot/themes/tableau-colorblind10.mplstyle,sha256=PzUMoOtw0V6l0bPk8ApRAKvc
|
|
|
40
41
|
qmlmathplot/themes.py,sha256=QocZLOgPYXCJW9fYc6i5q6nHEE_S4mQVZXySZ4ISCPw,9225
|
|
41
42
|
qmlmathplot/view.py,sha256=G6W0WfI4yT0eX4rHswqwu3y48MQTXjcA0BnWpIS1vD0,1746
|
|
42
43
|
qmlmathplot/widget.py,sha256=HXSQko0YwixG0bx--BP6k_Fd_fn-58P-ywT2-HZw0E0,7899
|
|
43
|
-
qmlmathplot-0.
|
|
44
|
-
qmlmathplot-0.
|
|
45
|
-
qmlmathplot-0.
|
|
46
|
-
qmlmathplot-0.
|
|
44
|
+
qmlmathplot-0.3.0.dist-info/WHEEL,sha256=xTCBFIZIIyRSxyowOv5Wqg_jFU6KsdIffuulBB09Ph8,80
|
|
45
|
+
qmlmathplot-0.3.0.dist-info/entry_points.txt,sha256=4z36D_RpPSYLiJDRM8iXnrSe1T4c2alfQJPcJTss3qY,54
|
|
46
|
+
qmlmathplot-0.3.0.dist-info/METADATA,sha256=2dRCAwsWnPUtJsGG-ZYnbULVypGg5K7Lj_EKoD0z_Mk,34895
|
|
47
|
+
qmlmathplot-0.3.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|