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 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",
@@ -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 (called by `main.py` /
2
- `uv run qmlmathplot`).
1
+ """MVP entry point: wraps the component into a minimal verifiable window.
3
2
 
4
- python main.py --backend d3d11 "sin(1/x)"
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
- what is drawn. One curve = one fragment shader pass (see ``model.FRAGMENT_TEMPLATE``), so
5
- ``visible`` is the lever that costs nothing.
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 or anything QColor() understands ("#rrggbb", a name, …)."""
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 = str(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._compile(self._expression)
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
- source = str(source)
76
- if source == self._expression:
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
- self._expression = source
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(source)
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
- expression: str = Property(str, _get_expression, _set_expression, notify=expressionChanged)
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
- def _compile(self, source: str) -> None:
151
- """expression -> GLSL -> .qsb; on failure keep the last working shaders and write the
152
- reason into ``error``."""
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
- expr = sp.sympify(source, locals={"x": self._symbol})
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
- @Slot(str, "QVariant", "QVariant", str, result=QObject)
249
- @Slot(str, result=QObject)
250
- def add_curve(
251
- self,
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
- line_width = self._default_line_width
262
- curve = Curve(expression, color, float(line_width), label, parent=self) # type: ignore[arg-type]
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 (the live centre and scale, extended to the export
74
- size), ``width`` / ``height`` default to the live view's device size (``dpi`` multiplies
75
- both), and ``adjustable`` reconciles the two parameter sets.
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, extended to the export size, unless the caller asked for one
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 * logical[0] * live[2], live[0] + 0.5 * logical[0] * live[2])
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 * logical[1] * live[3], live[1] + 0.5 * logical[1] * live[3])
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
- // asin, …)
333
- if (!DOM(x)) {
334
- fragColor = vec4(0.0);
335
- return;
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
- float xi = x + (-1.0 + (float(k) + 0.5) / 8.0) * h;
415
- s = F(xi);
416
- ok = DOMS(xi) && (s == s) && (abs(s) < 1e30);
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
- float d = s - ps;
421
- if (pd * d < 0.0) { turns16 += 1.0; }
422
- pd = d;
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
- if (prev_oki) {
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 at unbounded boundaries: when the window crosses a domain
469
- // boundary where the function tends to ±∞, pull a vertical ray from the nearest
470
- // valid sample along the boundary to ±1e12, so a slowly diverging branch
471
- // (log(x) as x→0+) is drawn to any depth. The ray lies on the boundary x, so the
472
- // width stays constant and the columns align.
473
- float ex_ = _edge_hit(x - h, x + h);
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(expression: str, x0: float, x1: float, samples: int) -> tuple[object, object]:
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(symbol, sp.sympify(expression, locals={"x": symbol}),
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: sympify, lambdify and the evaluation itself all report by raising,
83
- # and the caller wants one clear message naming the expression.
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.expression, x_range[0], x_range[1], samples)
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
- *expressions, (symbol, x_range[0], x_range[1]), show=False,
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
- @Slot(str, "QVariant", "QVariant", str, result=QObject)
145
- @Slot(str, result=QObject)
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.2.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**: via the **analytic pole gap** (denominator sign change, world
395
- units) — no vertical connector is drawn at the asymptotes of `1/x`, `tan(x)`.
396
- (An earlier "adjacent samples differ by more than 4 viewport heights" criterion
397
- was pixel-/viewport-relative, so under magnification it also cut legitimate steep
398
- segments; it was removed.)
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. NaN/inf samples take part in neither the envelope nor
402
- segment joining.
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=C_fYVkO8WQPVW1K_wvjdH8OniRbUpdCkIPB-ULvP6Kk,2832
2
- qmlmathplot/app.py,sha256=9ojtrU9_wIcgUq7Ib449KRkuLSsr7FLzSBNguI-T8l4,3124
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=Q-3S8WhsHWanWnZXkHH_nak8q7CXDHSioqyUyblKQJ0,10723
5
- qmlmathplot/export.py,sha256=QfoE4TKnKt-xdaUAkB8IsyupyfwFKu8hn0ikKp8mzrI,8065
6
- qmlmathplot/model.py,sha256=WAY2VyoMKR5vGkPxrr2GOfUnl0xXW01ntfQ_qW8i2Wg,24961
7
- qmlmathplot/mpl.py,sha256=Liaqq1NWBjuSe95uxBsx2YbUULU4F5kvcvWDQErnE-Y,10684
8
- qmlmathplot/plot.py,sha256=XDp_Jg2FQLQLDmUhUy5tuzAFCerihwitaBICp4i_xuA,15306
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.2.0.dist-info/WHEEL,sha256=xTCBFIZIIyRSxyowOv5Wqg_jFU6KsdIffuulBB09Ph8,80
44
- qmlmathplot-0.2.0.dist-info/entry_points.txt,sha256=4z36D_RpPSYLiJDRM8iXnrSe1T4c2alfQJPcJTss3qY,54
45
- qmlmathplot-0.2.0.dist-info/METADATA,sha256=qlC49LZEVjSKctg5FELNUSiMnUkisgwwAd5VtKONzqU,30542
46
- qmlmathplot-0.2.0.dist-info/RECORD,,
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,,