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.
- {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/PKG-INFO +71 -11
- {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/README.md +70 -10
- {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/pyproject.toml +1 -1
- {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/pyproject.toml.orig +1 -1
- {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/src/qmlmathplot/__init__.py +2 -1
- qmlmathplot-0.3.0/src/qmlmathplot/__main__.py +13 -0
- {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/src/qmlmathplot/app.py +6 -3
- qmlmathplot-0.3.0/src/qmlmathplot/curve.py +453 -0
- {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/src/qmlmathplot/export.py +16 -6
- {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/src/qmlmathplot/model.py +185 -29
- {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/src/qmlmathplot/mpl.py +21 -18
- {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/src/qmlmathplot/plot.py +33 -4
- qmlmathplot-0.2.0/src/qmlmathplot/curve.py +0 -298
- {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/src/qmlmathplot/camera.py +0 -0
- {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/src/qmlmathplot/qml/PlotView.qml +0 -0
- {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/src/qmlmathplot/qsb.py +0 -0
- {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/LICENSE.matplotlib +0 -0
- {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/README.md +0 -0
- {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/Solarize_Light2.mplstyle +0 -0
- {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/bmh.mplstyle +0 -0
- {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/classic.mplstyle +0 -0
- {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/dark_background.mplstyle +0 -0
- {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/default.mplstyle +0 -0
- {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/fast.mplstyle +0 -0
- {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/fivethirtyeight.mplstyle +0 -0
- {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/ggplot.mplstyle +0 -0
- {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/grayscale.mplstyle +0 -0
- {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/petroff10.mplstyle +0 -0
- {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/seaborn-v0_8-bright.mplstyle +0 -0
- {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/seaborn-v0_8-colorblind.mplstyle +0 -0
- {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/seaborn-v0_8-dark-palette.mplstyle +0 -0
- {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/seaborn-v0_8-dark.mplstyle +0 -0
- {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/seaborn-v0_8-darkgrid.mplstyle +0 -0
- {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/seaborn-v0_8-deep.mplstyle +0 -0
- {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/seaborn-v0_8-muted.mplstyle +0 -0
- {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/seaborn-v0_8-notebook.mplstyle +0 -0
- {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/seaborn-v0_8-paper.mplstyle +0 -0
- {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/seaborn-v0_8-pastel.mplstyle +0 -0
- {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/seaborn-v0_8-poster.mplstyle +0 -0
- {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/seaborn-v0_8-talk.mplstyle +0 -0
- {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/seaborn-v0_8-ticks.mplstyle +0 -0
- {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/seaborn-v0_8-white.mplstyle +0 -0
- {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/seaborn-v0_8-whitegrid.mplstyle +0 -0
- {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/seaborn-v0_8.mplstyle +0 -0
- {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/tableau-colorblind10.mplstyle +0 -0
- {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes.py +0 -0
- {qmlmathplot-0.2.0 → qmlmathplot-0.3.0}/src/qmlmathplot/view.py +0 -0
- {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.
|
|
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
|
|
@@ -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**:
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
|
389
|
-
|
|
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
|
|
@@ -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
|
|
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
|