qmlmathplot 0.2.0__tar.gz → 0.3.0__tar.gz

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.
Files changed (48) hide show
  1. {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/PKG-INFO +71 -11
  2. {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/README.md +70 -10
  3. {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/pyproject.toml +1 -1
  4. {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/pyproject.toml.orig +1 -1
  5. {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/src/qmlmathplot/__init__.py +2 -1
  6. qmlmathplot-0.3.0/src/qmlmathplot/__main__.py +13 -0
  7. {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/src/qmlmathplot/app.py +6 -3
  8. qmlmathplot-0.3.0/src/qmlmathplot/curve.py +453 -0
  9. {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/src/qmlmathplot/export.py +16 -6
  10. {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/src/qmlmathplot/model.py +185 -29
  11. {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/src/qmlmathplot/mpl.py +21 -18
  12. {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/src/qmlmathplot/plot.py +33 -4
  13. qmlmathplot-0.2.0/src/qmlmathplot/curve.py +0 -298
  14. {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/src/qmlmathplot/camera.py +0 -0
  15. {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/src/qmlmathplot/qml/PlotView.qml +0 -0
  16. {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/src/qmlmathplot/qsb.py +0 -0
  17. {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/LICENSE.matplotlib +0 -0
  18. {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/README.md +0 -0
  19. {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/Solarize_Light2.mplstyle +0 -0
  20. {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/bmh.mplstyle +0 -0
  21. {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/classic.mplstyle +0 -0
  22. {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/dark_background.mplstyle +0 -0
  23. {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/default.mplstyle +0 -0
  24. {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/fast.mplstyle +0 -0
  25. {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/fivethirtyeight.mplstyle +0 -0
  26. {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/ggplot.mplstyle +0 -0
  27. {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/grayscale.mplstyle +0 -0
  28. {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/petroff10.mplstyle +0 -0
  29. {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/seaborn-v0_8-bright.mplstyle +0 -0
  30. {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/seaborn-v0_8-colorblind.mplstyle +0 -0
  31. {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/seaborn-v0_8-dark-palette.mplstyle +0 -0
  32. {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/seaborn-v0_8-dark.mplstyle +0 -0
  33. {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/seaborn-v0_8-darkgrid.mplstyle +0 -0
  34. {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/seaborn-v0_8-deep.mplstyle +0 -0
  35. {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/seaborn-v0_8-muted.mplstyle +0 -0
  36. {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/seaborn-v0_8-notebook.mplstyle +0 -0
  37. {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/seaborn-v0_8-paper.mplstyle +0 -0
  38. {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/seaborn-v0_8-pastel.mplstyle +0 -0
  39. {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/seaborn-v0_8-poster.mplstyle +0 -0
  40. {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/seaborn-v0_8-talk.mplstyle +0 -0
  41. {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/seaborn-v0_8-ticks.mplstyle +0 -0
  42. {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/seaborn-v0_8-white.mplstyle +0 -0
  43. {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/seaborn-v0_8-whitegrid.mplstyle +0 -0
  44. {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/seaborn-v0_8.mplstyle +0 -0
  45. {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/tableau-colorblind10.mplstyle +0 -0
  46. {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes.py +0 -0
  47. {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/src/qmlmathplot/view.py +0 -0
  48. {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/src/qmlmathplot/widget.py +0 -0
@@ -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
@@ -31,6 +31,18 @@ An un-themed plot looks like matplotlib (white background, tab10 colours, grey g
31
31
  axes through the origin with the tick labels riding them) — the default style sheet is applied
32
32
  at construction, so `plot.theme = "default"` is the reset.
33
33
 
34
+ `add_curve()` (and `Curve.expression`) accept the expression **either as a string or as a sympy
35
+ expression**: `plot.add_curve(sp.sin(1 / sp.Symbol("x")))` is the same curve as
36
+ `plot.add_curve("sin(1/x)")`. The stored and reported form is always the canonical string — QML,
37
+ a project file and the text field want one — while the parsed expression is cached and exposed as
38
+ `Curve.expr`, which is what re-baking and the matplotlib hand-off use. The object form exists so a
39
+ curve can be composed without string interpolation:
40
+
41
+ ```python
42
+ curve = plot.add_curve("sin(x)")
43
+ plot.add_curve(sp.diff(sp.sympify(curve.expression, locals={"x": sp.Symbol("x")})), label="f'")
44
+ ```
45
+
34
46
  `MathPlotWidget` keeps one curve's worth of convenience (`expression`, `error`,
35
47
  `line_width`, `curve_color`, `background_color`, `aspect`, `view_bounds()`,
36
48
  `reset_view()`, `zoom()`, `pan_pixels()`); everything else lives on the model behind it:
@@ -109,9 +121,9 @@ The model:
109
121
 
110
122
  | Object | Members |
111
123
  |---|---|
112
- | `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` |
124
+ | `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` |
113
125
  | `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()` |
114
- | `Curve` | `expression`, `color`, `lineWidth`, `visible`, `label`, `error` (read-only), the baked `vertexShader` / `fragmentShader` |
126
+ | `Curve` | `expression` (string or sympy expression), `expr` (the parsed expression, read-only), `implicit`, `color`, `lineWidth`, `visible`, `label`, `error` (read-only), the baked `vertexShader` / `fragmentShader` |
115
127
  | `CurveListModel` | the curves as a model (`curve`, `expression`, `color`, `lineWidth`, `visible`, `vertexShader`, `fragmentShader` roles) so QML puts one `ShaderEffect` behind each |
116
128
 
117
129
  Every setter is a Qt `Property` with a notify signal, so the same calls work from QML, from a
@@ -131,6 +143,35 @@ bounded page), no `FigureCanvas`/`draw()` (the scene is live), no toolbar and no
131
143
  chrome), and an export is a canvas region at a pixel size rather than a re-rendered page
132
144
  (§9, implemented — see *Export* below).
133
145
 
146
+ ## Implicit curves
147
+
148
+ An expression whose free symbols are `x` **and** `y` is drawn as the level set `F(x, y) = 0`.
149
+ The artist follows the expression, whoever sets it: `add_curve` routes it by itself, assigning to
150
+ `curve.expression` re-routes the same curve (so a text field can turn `sin(x)` into a circle),
151
+ and `add_implicit` asks for the level-set artist explicitly — also for a function of `x` alone,
152
+ which is a pair of vertical lines. Only a *third* free symbol is refused: substitute parameters
153
+ first.
154
+
155
+ ```python
156
+ plot.add_curve("x**2 + y**2 - 1") # a circle: {x, y} routes to the level-set artist
157
+ plot.add_implicit("x*y - 1") # the same artist, asked for by name
158
+ plot.curves.at(0).expression = "x**2 + y**2 - 4" # re-routes the curve that is already there
159
+ plot.add_curve("a*sin(x)") # ValueError: substitute `a` first
160
+ ```
161
+
162
+ The renderer is the same idea as the 1-D stroke, one dimension up: one evaluation of `F` and its
163
+ two partials per pixel give the distance to the level set in *pixels*,
164
+ `d = |F| / sqrt((Fx·dx)² + (Fy·dy)²)`, and the coverage is `clamp(lineWidth/2 + 0.5 - d, 0, 1)` —
165
+ analytic antialiasing and a stroke width that does not depend on the angle, with no sampling grid
166
+ (the unit circle measures the same 3 device pixels at 0°, 45°, 90° and 135° for `lineWidth=2` at
167
+ DPR 1.5).
168
+
169
+ Known limit: **smooth, single-sheet level sets**. Thin features and several branches through one
170
+ pixel (`sin(1/(x*x + y*y))`), and 2-D domains/poles, are not handled yet — the assessment is
171
+ `docs/api-design.md` §6b; pixels where `F` is undefined are dropped. The matplotlib hand-off
172
+ skips implicit curves (a level set has no polyline to sample), while the Qt export renders them
173
+ like anything else.
174
+
134
175
  ## The camera (centre, zoom, aspect)
135
176
 
136
177
  `Camera` stores three numbers that do not depend on the widget: `centre` (world coordinates in
@@ -266,6 +307,14 @@ backend is used**. The implementation sets `QSG_RHI_BACKEND` before
266
307
 
267
308
  ## Structure (model / view)
268
309
 
310
+ The layers are strict about Qt. **`model.py` (expression → GLSL), `themes.py` (style-sheet
311
+ conversion) and `mpl.py` (the sampled hand-off) import no Qt at all** — they are unit-testable
312
+ without a `QApplication`. `Camera`, `Curve` and `Plot` are the **ViewModel**: `QObject`s whose
313
+ `Property`es carry notify signals, which is precisely what the QML component binds to — and why
314
+ a Python-side assignment updates the view with no call in between. `view.py`, `PlotView.qml`,
315
+ `export.py` and `widget.py` are the View/embedding layer and do need Qt; `export.py` and
316
+ `widget.py` import QtWidgets lazily, so a pure-QML host never loads it.
317
+
269
318
  | Layer | File | Responsibility |
270
319
  |---|---|---|
271
320
  | Model | `src/qmlmathplot/model.py` | expression → GLSL, shader source template (no Qt dependency, unit-testable) |
@@ -314,7 +363,12 @@ QSG_RHI_BACKEND=opengl uv run pytest -m gui
314
363
  (the home view at the first size, resize keeping the scale, an anchored zoom that
315
364
  is exactly reversible, the aspect expanding but never cropping, ticks).
316
365
  - `test_curve.py`: a failed expression keeps the last working shaders, the style
317
- setters are idempotent, and the list model reports the right roles.
366
+ setters are idempotent, and the list model reports the right roles — plus the
367
+ `Expr`/string round-trip accounting (a string is parsed once, an expression never).
368
+ - `test_implicit.py`: the routing by free symbols (and the `ValueError` for a third symbol), the
369
+ level-set shader text (same uniform block as the 1-D one, no sampling loop), and the rendered
370
+ geometry of a unit circle: crossings a quarter of the height from each edge, a blank middle,
371
+ radius 1 on both axes, and a stroke of the same width at 0°/45°/90°/135°.
318
372
  - `test_plot.py`: the plot's properties convert, notify exactly once and are wired
319
373
  (a PySide property with an unresolved notify name silently loses it), the defaults are
320
374
  matplotlib's default style, the limits are forwarded to the camera, and a theme applies
@@ -378,15 +432,21 @@ extrema/zeros by bisection. The same approach is used here:
378
432
  perpendicular foot lands on a segment (on a sloped line it is displaced along x
379
433
  by `m·d/√(1+m²)`); with a narrower window the distance degraded to "distance to
380
434
  the nearest endpoint" and steep parts looked thin and ragged.
381
- - **Break on jumps**: via the **analytic pole gap** (denominator sign change, world
382
- units) — no vertical connector is drawn at the asymptotes of `1/x`, `tan(x)`.
383
- (An earlier "adjacent samples differ by more than 4 viewport heights" criterion
384
- was pixel-/viewport-relative, so under magnification it also cut legitimate steep
385
- segments; it was removed.)
435
+ - **Break on jumps**: a denominator sign change (world units) decides, in three places,
436
+ that nothing joins the branches — the polyline segment that straddles the pole is not
437
+ stroked, a straddling pair does not count as a turn in the band's turn count (it used to
438
+ fill the pole column solid), and the unbounded-boundary ray is not drawn there. No vertical
439
+ connector at the asymptotes of `1/x`, `tan(x)`. (An earlier "adjacent samples differ by
440
+ more than 4 viewport heights" criterion was pixel-/viewport-relative, so under magnification
441
+ it also cut legitimate steep segments; it was removed. The remaining "spread beyond 32
442
+ viewport heights" gap is a second line of defence only — at `tan`'s pole column the spread
443
+ is ~15 viewports, so it never fires there.)
386
444
  - **Domain** (derived by sympy, injected as `DOM`): denominator ≠ 0, `log` argument
387
445
  > 0, `sqrt` argument ≥ 0, `asin/acos` argument ∈ [−1,1]; pixels outside the domain
388
- are discarded outright. NaN/inf samples take part in neither the envelope nor
389
- segment joining.
446
+ are discarded outright — except for the **boundary ray** of a slowly diverging edge
447
+ (`log(x)` as x→0+), which is a band centred *on* the boundary and therefore paints the few
448
+ pixels outside the domain that it covers (the whole outer half of that band, otherwise).
449
+ NaN/inf samples take part in neither the envelope nor segment joining.
390
450
  - **Undersampled columns** (several oscillations inside one column) are replaced
391
451
  wholesale by the envelope band: a polyline is random sawtooth in such columns.
392
452
  There are two criteria, both **scale-independent**: (a) ≥ 2 direction reversals
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "qmlmathplot"
3
- version = "0.2.0"
3
+ version = "0.3.0"
4
4
  description = "Embeddable function plotter for Qt Quick / QtWidgets (implicit per-pixel drawing, MVVM)"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "qmlmathplot"
3
- version = "0.2.0"
3
+ version = "0.3.0"
4
4
  description = "Embeddable function plotter for Qt Quick / QtWidgets (implicit per-pixel drawing, MVVM)"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -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())
@@ -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