qmlmathplot 0.1.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.1.0 → qmlmathplot-0.3.0}/PKG-INFO +196 -18
- {qmlmathplot-0.1.0 → qmlmathplot-0.3.0}/README.md +192 -16
- {qmlmathplot-0.1.0 → qmlmathplot-0.3.0}/pyproject.toml +9 -3
- {qmlmathplot-0.1.0 → qmlmathplot-0.3.0}/pyproject.toml.orig +8 -2
- {qmlmathplot-0.1.0 → qmlmathplot-0.3.0}/src/qmlmathplot/__init__.py +2 -1
- qmlmathplot-0.3.0/src/qmlmathplot/__main__.py +13 -0
- {qmlmathplot-0.1.0 → qmlmathplot-0.3.0}/src/qmlmathplot/app.py +6 -3
- {qmlmathplot-0.1.0 → qmlmathplot-0.3.0}/src/qmlmathplot/camera.py +28 -0
- qmlmathplot-0.3.0/src/qmlmathplot/curve.py +453 -0
- qmlmathplot-0.3.0/src/qmlmathplot/export.py +174 -0
- {qmlmathplot-0.1.0 → qmlmathplot-0.3.0}/src/qmlmathplot/model.py +185 -29
- qmlmathplot-0.3.0/src/qmlmathplot/mpl.py +235 -0
- qmlmathplot-0.3.0/src/qmlmathplot/plot.py +396 -0
- {qmlmathplot-0.1.0 → qmlmathplot-0.3.0}/src/qmlmathplot/qml/PlotView.qml +52 -11
- qmlmathplot-0.3.0/src/qmlmathplot/themes/default.mplstyle +26 -0
- {qmlmathplot-0.1.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes.py +11 -6
- {qmlmathplot-0.1.0 → qmlmathplot-0.3.0}/src/qmlmathplot/widget.py +15 -10
- qmlmathplot-0.1.0/src/qmlmathplot/curve.py +0 -296
- qmlmathplot-0.1.0/src/qmlmathplot/plot.py +0 -213
- {qmlmathplot-0.1.0 → qmlmathplot-0.3.0}/src/qmlmathplot/qsb.py +0 -0
- {qmlmathplot-0.1.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/LICENSE.matplotlib +0 -0
- {qmlmathplot-0.1.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/README.md +0 -0
- {qmlmathplot-0.1.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/Solarize_Light2.mplstyle +0 -0
- {qmlmathplot-0.1.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/bmh.mplstyle +0 -0
- {qmlmathplot-0.1.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/classic.mplstyle +0 -0
- {qmlmathplot-0.1.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/dark_background.mplstyle +0 -0
- {qmlmathplot-0.1.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/fast.mplstyle +0 -0
- {qmlmathplot-0.1.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/fivethirtyeight.mplstyle +0 -0
- {qmlmathplot-0.1.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/ggplot.mplstyle +0 -0
- {qmlmathplot-0.1.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/grayscale.mplstyle +0 -0
- {qmlmathplot-0.1.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/petroff10.mplstyle +0 -0
- {qmlmathplot-0.1.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/seaborn-v0_8-bright.mplstyle +0 -0
- {qmlmathplot-0.1.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/seaborn-v0_8-colorblind.mplstyle +0 -0
- {qmlmathplot-0.1.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/seaborn-v0_8-dark-palette.mplstyle +0 -0
- {qmlmathplot-0.1.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/seaborn-v0_8-dark.mplstyle +0 -0
- {qmlmathplot-0.1.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/seaborn-v0_8-darkgrid.mplstyle +0 -0
- {qmlmathplot-0.1.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/seaborn-v0_8-deep.mplstyle +0 -0
- {qmlmathplot-0.1.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/seaborn-v0_8-muted.mplstyle +0 -0
- {qmlmathplot-0.1.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/seaborn-v0_8-notebook.mplstyle +0 -0
- {qmlmathplot-0.1.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/seaborn-v0_8-paper.mplstyle +0 -0
- {qmlmathplot-0.1.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/seaborn-v0_8-pastel.mplstyle +0 -0
- {qmlmathplot-0.1.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/seaborn-v0_8-poster.mplstyle +0 -0
- {qmlmathplot-0.1.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/seaborn-v0_8-talk.mplstyle +0 -0
- {qmlmathplot-0.1.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/seaborn-v0_8-ticks.mplstyle +0 -0
- {qmlmathplot-0.1.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/seaborn-v0_8-white.mplstyle +0 -0
- {qmlmathplot-0.1.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/seaborn-v0_8-whitegrid.mplstyle +0 -0
- {qmlmathplot-0.1.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/seaborn-v0_8.mplstyle +0 -0
- {qmlmathplot-0.1.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/tableau-colorblind10.mplstyle +0 -0
- {qmlmathplot-0.1.0 → qmlmathplot-0.3.0}/src/qmlmathplot/view.py +0 -0
|
@@ -1,12 +1,14 @@
|
|
|
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>
|
|
7
7
|
Requires-Dist: sympy>=1.14
|
|
8
8
|
Requires-Dist: pyside6>=6.7
|
|
9
|
-
Requires-
|
|
9
|
+
Requires-Dist: matplotlib>=3.8 ; extra == 'matplotlib'
|
|
10
|
+
Requires-Python: >=3.11
|
|
11
|
+
Provides-Extra: matplotlib
|
|
10
12
|
Description-Content-Type: text/markdown
|
|
11
13
|
|
|
12
14
|
# QMLMathPlot
|
|
@@ -38,6 +40,22 @@ window.show()
|
|
|
38
40
|
app.exec()
|
|
39
41
|
```
|
|
40
42
|
|
|
43
|
+
An un-themed plot looks like matplotlib (white background, tab10 colours, grey grid, black
|
|
44
|
+
axes through the origin with the tick labels riding them) — the default style sheet is applied
|
|
45
|
+
at construction, so `plot.theme = "default"` is the reset.
|
|
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
|
+
|
|
41
59
|
`MathPlotWidget` keeps one curve's worth of convenience (`expression`, `error`,
|
|
42
60
|
`line_width`, `curve_color`, `background_color`, `aspect`, `view_bounds()`,
|
|
43
61
|
`reset_view()`, `zoom()`, `pan_pixels()`); everything else lives on the model behind it:
|
|
@@ -116,9 +134,9 @@ The model:
|
|
|
116
134
|
|
|
117
135
|
| Object | Members |
|
|
118
136
|
|---|---|
|
|
119
|
-
| `Plot` | `camera`, `curves`, `xlim`/`ylim` (forwarded), `add_curve()` / `remove_curve()`, and the style properties: `background`, `grid`, `grid_color`, `grid_width`, `grid_alpha`, `grid_style`, `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` |
|
|
120
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()` |
|
|
121
|
-
| `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` |
|
|
122
140
|
| `CurveListModel` | the curves as a model (`curve`, `expression`, `color`, `lineWidth`, `visible`, `vertexShader`, `fragmentShader` roles) so QML puts one `ShaderEffect` behind each |
|
|
123
141
|
|
|
124
142
|
Every setter is a Qt `Property` with a notify signal, so the same calls work from QML, from a
|
|
@@ -135,8 +153,37 @@ build order — is specified in [`docs/api-design.md`](docs/api-design.md). In s
|
|
|
135
153
|
Matplotlib *vocabulary* is kept where it is vocabulary (limits, grid, title, ticks, annotate),
|
|
136
154
|
while the *mechanism* is refused — no `Figure`/`Axes`/`subplots` (one view per widget, no
|
|
137
155
|
bounded page), no `FigureCanvas`/`draw()` (the scene is live), no toolbar and no legend (host
|
|
138
|
-
chrome), and an export is a canvas region at a pixel size rather than a re-rendered page
|
|
139
|
-
implemented
|
|
156
|
+
chrome), and an export is a canvas region at a pixel size rather than a re-rendered page
|
|
157
|
+
(§9, implemented — see *Export* below).
|
|
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.
|
|
140
187
|
|
|
141
188
|
## The camera (centre, zoom, aspect)
|
|
142
189
|
|
|
@@ -164,12 +211,16 @@ plot.plot.camera.zoom_by(120, 0.5, 0.5, w, h) # one wheel notch, anchored at
|
|
|
164
211
|
|
|
165
212
|
## Themes
|
|
166
213
|
|
|
167
|
-
`Plot.theme` applies a style sheet to the style properties above
|
|
214
|
+
`Plot.theme` applies a style sheet to the style properties above; the default is
|
|
215
|
+
matplotlib's own default style, so an untouched plot already looks like matplotlib and
|
|
216
|
+
`plot.theme = "default"` is the reset:
|
|
168
217
|
|
|
169
218
|
```python
|
|
170
219
|
plot.theme = "ggplot" # any of the bundled sheets
|
|
220
|
+
plot.theme = "default" # back to matplotlib's look
|
|
171
221
|
plot.theme = "my-style" # themes/my-style.mplstyle next to the package
|
|
172
222
|
plot.theme = "path/to/any.mplstyle" # any file in the same format
|
|
223
|
+
plot.available_themes # the names, for a host's picker (both demos use one)
|
|
173
224
|
```
|
|
174
225
|
|
|
175
226
|
The 26 bundled sheets are Matplotlib's own styles, vendored verbatim into
|
|
@@ -179,6 +230,86 @@ have (spines, figure size, dpi, legend, marker styles, …) are ignored. `themes
|
|
|
179
230
|
them (`"default"` plus the 26 sheets). An unknown name raises `KeyError` and leaves the current
|
|
180
231
|
theme alone.
|
|
181
232
|
|
|
233
|
+
## Axes, grid and ticks
|
|
234
|
+
|
|
235
|
+
- The **grid** is drawn in QML (a `Canvas`), one line per tick position, in `grid_color` /
|
|
236
|
+
`grid_width` / `grid_alpha` / `grid_style` (`-`, `--`, `:`, `-.`).
|
|
237
|
+
- The **axes** are the lines through world (0,0) — the x-axis at `y = 0`, the y-axis at
|
|
238
|
+
`x = 0` — drawn in `axis_color` / `axis_width`, and the **tick marks and labels ride those
|
|
239
|
+
axes** instead of hugging the item's edges. When 0 is off screen the axis sticks to the
|
|
240
|
+
nearest edge, so its labels are never lost (and a label never leaves the item); the grid
|
|
241
|
+
stays where it is. `plot.axes_position = "edge"` restores the edge-pinned behaviour.
|
|
242
|
+
- `camera.viewport` reports the last size the view sent, so a host can size an export from it.
|
|
243
|
+
- The tick *values* are nice numbers computed in Python (`Camera.tick_values()` /
|
|
244
|
+
`nice_ticks()`, steps of 1/2/5 x 10^n) and pushed through `ticksChanged`; QML only positions
|
|
245
|
+
and formats them, so a frame never recomputes ticks.
|
|
246
|
+
- `plot.ticks_visible = False` hides the whole furniture (axes, marks and labels).
|
|
247
|
+
|
|
248
|
+
## Export
|
|
249
|
+
|
|
250
|
+
`savefig()` writes a file, `to_image()` returns a `QImage`; both take the same keywords:
|
|
251
|
+
|
|
252
|
+
```python
|
|
253
|
+
plot.savefig("out.png", xlim=(-1, 1), ylim=(-1, 1), width=1200, height=400)
|
|
254
|
+
plot.savefig("hi.png", dpi=2.0) # the live view, twice the pixels
|
|
255
|
+
image = plot.to_image(width=800, height=600) # -> QImage
|
|
256
|
+
plot.to_image() # the live view, its own size
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
- `xlim` / `ylim` default to the **camera**: the live centre and scale, extended to the export
|
|
260
|
+
size. The *region* is a parameter, so an export can cover more canvas than the widget shows.
|
|
261
|
+
- `width` / `height` are **device pixels** and default to the live view's device size; `dpi`
|
|
262
|
+
multiplies both (`dpi=2.0` doubles the pixels).
|
|
263
|
+
- `adjustable` reconciles a requested range with a requested size when their aspects disagree —
|
|
264
|
+
Matplotlib's parameter, and its own default:
|
|
265
|
+
|
|
266
|
+
| `adjustable` | What happens |
|
|
267
|
+
|---|---|
|
|
268
|
+
| `"box"` (**default**) | one scale, taken from the live scale ratio, chosen so the requested range fits: the range is centred and the leftover pixels stay background — *nothing is distorted* |
|
|
269
|
+
| `"datalim"` | the camera's own scales, with the limits expanding (never cropped) to fill the size |
|
|
270
|
+
| `"stretch"` | the requested range maps onto the requested size exactly — independent scales, so a mismatched aspect **distorts** (the "report figure" mode) |
|
|
271
|
+
|
|
272
|
+
With no explicit range all three agree, because the range derived from the camera already has
|
|
273
|
+
the export size's aspect.
|
|
274
|
+
- `transparent=True` clears to alpha 0.
|
|
275
|
+
- Everything else follows the live styling (curves, grid, axes, theme), so an export cannot
|
|
276
|
+
drift from what the user sees. Rendering goes through an offscreen `QQuickWidget`, so the
|
|
277
|
+
export needs a `QApplication` (a clear error is raised instead of a crash if there is none).
|
|
278
|
+
|
|
279
|
+
### Vector output, matplotlib and sympy
|
|
280
|
+
|
|
281
|
+
`to_matplotlib()` hands the plot to matplotlib (one `Axes`) and `savefig(..., backend="matplotlib")`
|
|
282
|
+
renders through it, so `.svg`/`.pdf` work; `to_sympy()` builds the same figure through
|
|
283
|
+
`sympy.plotting`, which is what you want when the figure should live in sympy's world:
|
|
284
|
+
|
|
285
|
+
```python
|
|
286
|
+
figure = plot.to_matplotlib(xlim=(-1, 1), ylim=(-1, 1)) # -> matplotlib.figure.Figure
|
|
287
|
+
plot.savefig("out.svg", backend="matplotlib") # vector, matplotlib's own savefig
|
|
288
|
+
handle = plot.to_sympy(xlim=(-1, 1))
|
|
289
|
+
handle.save("out.png")
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
matplotlib is an **optional** extra (`pip install "qmlmathplot[matplotlib]"`); nothing in the
|
|
293
|
+
package imports it until one of these is called, and the error names the extra. `sympy` itself is
|
|
294
|
+
a hard dependency, so `to_sympy()` needs matplotlib only because its plotting backend does.
|
|
295
|
+
Neither hand-off needs a Qt application: a `Plot` works in a plain script, which is what makes it
|
|
296
|
+
usable next to a notebook or a matplotlib-only tool.
|
|
297
|
+
|
|
298
|
+
**This is a second renderer, and a sampled one.** matplotlib draws polylines, so each curve is
|
|
299
|
+
evaluated at `samples` points (default 2000) and an oscillating function such as `sin(1/x)`
|
|
300
|
+
aliases — exactly what the Qt view avoids by evaluating the expression per pixel. SymPy does not
|
|
301
|
+
solve it either, and its adaptive mode does not help: measured on `sin(1/x)` over [-1, 1],
|
|
302
|
+
`sympy.plotting.plot(adaptive=True)` uses **289** points (fewer than a fixed 2000), emits no
|
|
303
|
+
warning in sympy 1.14, and draws the same block of vertical connectors. The difference is how
|
|
304
|
+
the two fail: a sampled renderer draws an artefact, the Qt view draws the envelope the data
|
|
305
|
+
actually supports. (`to_sympy()` returns the object sympy made, so its own `adaptive` mode is
|
|
306
|
+
available there.) Fonts and antialiasing are matplotlib's, not the Qt view's, so the two
|
|
307
|
+
renderers cannot look identical, and the style mapping is partial: background, grid (on/off and
|
|
308
|
+
colour/width/alpha/dash), per-curve colour and width, tick and text colours, font sizes, title,
|
|
309
|
+
axis colour/width, the limits, the aspect with matplotlib's own `adjustable`, and
|
|
310
|
+
`axes_position="zero"` (spines through the origin, no box). What it buys is vector output and
|
|
311
|
+
matplotlib's ecosystem — nothing else.
|
|
312
|
+
|
|
182
313
|
## RHI backends
|
|
183
314
|
|
|
184
315
|
`--backend {d3d11,d3d12,vulkan,metal,opengl,null}`; **if unspecified, Qt's default
|
|
@@ -189,6 +320,14 @@ backend is used**. The implementation sets `QSG_RHI_BACKEND` before
|
|
|
189
320
|
|
|
190
321
|
## Structure (model / view)
|
|
191
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
|
+
|
|
192
331
|
| Layer | File | Responsibility |
|
|
193
332
|
|---|---|---|
|
|
194
333
|
| Model | `src/qmlmathplot/model.py` | expression → GLSL, shader source template (no Qt dependency, unit-testable) |
|
|
@@ -197,6 +336,8 @@ backend is used**. The implementation sets `QSG_RHI_BACKEND` before
|
|
|
197
336
|
| Plot | `src/qmlmathplot/plot.py` | `Plot`: the camera, the curves and the styling the view binds to |
|
|
198
337
|
| View | `src/qmlmathplot/qml/PlotView.qml` (+ `view.py`) | QML: background, grid, ticks/title, one `ShaderEffect` per curve, pan/zoom input; `view.py` registers the types and yields the component path |
|
|
199
338
|
| Themes | `src/qmlmathplot/themes.py` + `themes/` | the vendored Matplotlib style sheets, resolved onto the `Plot`'s properties |
|
|
339
|
+
| Export | `src/qmlmathplot/export.py` | `Plot.savefig()` / `to_image()`: the canvas region rendered offscreen at a pixel size (QtWidgets is imported lazily) |
|
|
340
|
+
| Hand-off | `src/qmlmathplot/mpl.py` | `Plot.to_matplotlib()` / `to_sympy()` / `savefig(backend="matplotlib")`: a sampled second renderer for vector output (matplotlib is an optional extra, imported lazily) |
|
|
200
341
|
| Bake | `src/qmlmathplot/qsb.py` | GLSL → `.qsb` (PySide6 ships `qsb.exe`), cached by source hash |
|
|
201
342
|
| Widget | `src/qmlmathplot/widget.py` | `MathPlotWidget`: QQuickWidget bridge, drops straight into QtWidgets layouts |
|
|
202
343
|
| Entry | `src/qmlmathplot/app.py` | command line / standalone window (the `qmlmathplot` script, `examples/minimal.py`) |
|
|
@@ -235,17 +376,25 @@ QSG_RHI_BACKEND=opengl uv run pytest -m gui
|
|
|
235
376
|
(the home view at the first size, resize keeping the scale, an anchored zoom that
|
|
236
377
|
is exactly reversible, the aspect expanding but never cropping, ticks).
|
|
237
378
|
- `test_curve.py`: a failed expression keeps the last working shaders, the style
|
|
238
|
-
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°.
|
|
239
385
|
- `test_plot.py`: the plot's properties convert, notify exactly once and are wired
|
|
240
|
-
(a PySide property with an unresolved notify name silently loses it), the
|
|
241
|
-
are forwarded to the camera, and a theme applies
|
|
386
|
+
(a PySide property with an unresolved notify name silently loses it), the defaults are
|
|
387
|
+
matplotlib's default style, the limits are forwarded to the camera, and a theme applies
|
|
388
|
+
(and re-applies as a reset) while an unknown name is rejected.
|
|
242
389
|
- `test_themes.py`: the vendored style sheets resolve to this library's properties.
|
|
243
390
|
- `test_shader_bake.py`: the stage of a baked `.qsb` must match its purpose, and
|
|
244
391
|
the four backend targets GLSL/HLSL/MSL/SPIR-V must all be covered.
|
|
245
392
|
- `test_render_smoke.py`: open a window, render, read pixels; verify the thin-line
|
|
246
393
|
shape, `sin(1/x)` filling its ±1 envelope, no |y|>1 artefacts outside the central
|
|
247
394
|
columns, poles not connected, nothing drawn outside the domain, and the QML
|
|
248
|
-
furniture (grid lines exactly at the tick positions,
|
|
395
|
+
furniture (grid lines exactly at the tick positions, the axes crossing at the origin
|
|
396
|
+
with their labels on them, the axis pinning to the edge once the origin is panned
|
|
397
|
+
away, the title).
|
|
249
398
|
- `test_widget.py`: `MathPlotWidget` coexisting with neighbouring widgets — the plot
|
|
250
399
|
appearing inside a layout, an expression change / an invalid expression keeping the
|
|
251
400
|
previous graph, curves added/hidden/removed through the model, **the wheel over a
|
|
@@ -255,6 +404,17 @@ QSG_RHI_BACKEND=opengl uv run pytest -m gui
|
|
|
255
404
|
needs one, and a process may only have one).
|
|
256
405
|
- `test_qtquick_coexistence.py`: the same drag conflict in Qt Quick (a `Flickable`
|
|
257
406
|
must not steal the pan).
|
|
407
|
+
- `test_handoff.py`: the matplotlib/sympy hand-off — the figure reflects the limits, aspect,
|
|
408
|
+
`adjustable`, grid, title, spines at zero and per-curve style; the sampling is uniform and NaN
|
|
409
|
+
outside the domain (so `log(x)` breaks); `savefig(backend="matplotlib")` writes real `.svg`,
|
|
410
|
+
`.pdf` and `.png`; `to_sympy().save()` writes a file and keeps per-curve colours; importing
|
|
411
|
+
`qmlmathplot` does not import matplotlib, and hiding matplotlib produces an error naming the
|
|
412
|
+
extra.
|
|
413
|
+
- `test_export.py`: `to_image()`/`savefig()` — the image is exactly the requested device size
|
|
414
|
+
(`dpi` multiplies it), the no-argument form reproduces the live centre and scale, the three
|
|
415
|
+
`adjustable` modes reconcile range and size as documented (a world 45-degree line stays at 45
|
|
416
|
+
in `"box"`, with background margins; `"stretch"` fills the frame and distorts), the live
|
|
417
|
+
camera and background survive an export, and `transparent` clears to alpha 0.
|
|
258
418
|
|
|
259
419
|
## Pitfalls near the `sin(1/x)` singularity (fixed — do not repeat)
|
|
260
420
|
|
|
@@ -285,15 +445,21 @@ extrema/zeros by bisection. The same approach is used here:
|
|
|
285
445
|
perpendicular foot lands on a segment (on a sloped line it is displaced along x
|
|
286
446
|
by `m·d/√(1+m²)`); with a narrower window the distance degraded to "distance to
|
|
287
447
|
the nearest endpoint" and steep parts looked thin and ragged.
|
|
288
|
-
- **Break on jumps**:
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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.)
|
|
293
457
|
- **Domain** (derived by sympy, injected as `DOM`): denominator ≠ 0, `log` argument
|
|
294
458
|
> 0, `sqrt` argument ≥ 0, `asin/acos` argument ∈ [−1,1]; pixels outside the domain
|
|
295
|
-
are discarded outright
|
|
296
|
-
|
|
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.
|
|
297
463
|
- **Undersampled columns** (several oscillations inside one column) are replaced
|
|
298
464
|
wholesale by the envelope band: a polyline is random sawtooth in such columns.
|
|
299
465
|
There are two criteria, both **scale-independent**: (a) ≥ 2 direction reversals
|
|
@@ -355,6 +521,12 @@ measurement that settled each one). The Qt/packaging ones that shaped the code:
|
|
|
355
521
|
same x 9 times, and macros are preprocessor expansion with no call semantics.
|
|
356
522
|
- PySide ties a property's notify signal to the `Signal` **object**; a name string
|
|
357
523
|
silently leaves the property without one (and QML bindings then never refresh).
|
|
524
|
+
- Snap **hairlines** (grid) to a half pixel, but never a **styled** line: rounding the
|
|
525
|
+
axis position put the 1.07 px y-axis half off the centre column, so the line came out
|
|
526
|
+
pale where it should be black.
|
|
527
|
+
- `QQuickWidget.setProperty(name, …)` sets it on the **widget**, not on the QML root (the
|
|
528
|
+
widget has no such property, so it silently does nothing): inject the model with
|
|
529
|
+
`widget.rootObject().setProperty("plot", plot)`, as `MathPlotWidget` does.
|
|
358
530
|
|
|
359
531
|
## Releasing
|
|
360
532
|
|
|
@@ -376,6 +548,12 @@ be rehearsed:
|
|
|
376
548
|
pip install -i https://test.pypi.org/simple/ qmlmathplot
|
|
377
549
|
```
|
|
378
550
|
|
|
551
|
+
If the publish step fails with `invalid-publisher: valid token, but no corresponding
|
|
552
|
+
publisher`, the trusted-publisher entry is missing or does not match. The action prints the
|
|
553
|
+
claims it presented (`repository`, `workflow_ref`, `environment`) — the entry must match them
|
|
554
|
+
exactly, **including the environment name**, and it must exist on the index the run targets: a
|
|
555
|
+
manual run goes to **test.pypi.org**, a tag push to **pypi.org**, so both need an entry.
|
|
556
|
+
|
|
379
557
|
The workflow runs on Windows: that is the platform this library is verified on (the shader baker
|
|
380
558
|
`qsb` ships inside the PySide6 wheel and is only confirmed to be present there, see `qsb.py`),
|
|
381
559
|
and the GUI tests need a GPU scene graph no runner has. The artifact is `py3-none-any`, so the
|
|
@@ -27,6 +27,22 @@ window.show()
|
|
|
27
27
|
app.exec()
|
|
28
28
|
```
|
|
29
29
|
|
|
30
|
+
An un-themed plot looks like matplotlib (white background, tab10 colours, grey grid, black
|
|
31
|
+
axes through the origin with the tick labels riding them) — the default style sheet is applied
|
|
32
|
+
at construction, so `plot.theme = "default"` is the reset.
|
|
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
|
+
|
|
30
46
|
`MathPlotWidget` keeps one curve's worth of convenience (`expression`, `error`,
|
|
31
47
|
`line_width`, `curve_color`, `background_color`, `aspect`, `view_bounds()`,
|
|
32
48
|
`reset_view()`, `zoom()`, `pan_pixels()`); everything else lives on the model behind it:
|
|
@@ -105,9 +121,9 @@ The model:
|
|
|
105
121
|
|
|
106
122
|
| Object | Members |
|
|
107
123
|
|---|---|
|
|
108
|
-
| `Plot` | `camera`, `curves`, `xlim`/`ylim` (forwarded), `add_curve()` / `remove_curve()`, and the style properties: `background`, `grid`, `grid_color`, `grid_width`, `grid_alpha`, `grid_style`, `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` |
|
|
109
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()` |
|
|
110
|
-
| `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` |
|
|
111
127
|
| `CurveListModel` | the curves as a model (`curve`, `expression`, `color`, `lineWidth`, `visible`, `vertexShader`, `fragmentShader` roles) so QML puts one `ShaderEffect` behind each |
|
|
112
128
|
|
|
113
129
|
Every setter is a Qt `Property` with a notify signal, so the same calls work from QML, from a
|
|
@@ -124,8 +140,37 @@ build order — is specified in [`docs/api-design.md`](docs/api-design.md). In s
|
|
|
124
140
|
Matplotlib *vocabulary* is kept where it is vocabulary (limits, grid, title, ticks, annotate),
|
|
125
141
|
while the *mechanism* is refused — no `Figure`/`Axes`/`subplots` (one view per widget, no
|
|
126
142
|
bounded page), no `FigureCanvas`/`draw()` (the scene is live), no toolbar and no legend (host
|
|
127
|
-
chrome), and an export is a canvas region at a pixel size rather than a re-rendered page
|
|
128
|
-
implemented
|
|
143
|
+
chrome), and an export is a canvas region at a pixel size rather than a re-rendered page
|
|
144
|
+
(§9, implemented — see *Export* below).
|
|
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.
|
|
129
174
|
|
|
130
175
|
## The camera (centre, zoom, aspect)
|
|
131
176
|
|
|
@@ -153,12 +198,16 @@ plot.plot.camera.zoom_by(120, 0.5, 0.5, w, h) # one wheel notch, anchored at
|
|
|
153
198
|
|
|
154
199
|
## Themes
|
|
155
200
|
|
|
156
|
-
`Plot.theme` applies a style sheet to the style properties above
|
|
201
|
+
`Plot.theme` applies a style sheet to the style properties above; the default is
|
|
202
|
+
matplotlib's own default style, so an untouched plot already looks like matplotlib and
|
|
203
|
+
`plot.theme = "default"` is the reset:
|
|
157
204
|
|
|
158
205
|
```python
|
|
159
206
|
plot.theme = "ggplot" # any of the bundled sheets
|
|
207
|
+
plot.theme = "default" # back to matplotlib's look
|
|
160
208
|
plot.theme = "my-style" # themes/my-style.mplstyle next to the package
|
|
161
209
|
plot.theme = "path/to/any.mplstyle" # any file in the same format
|
|
210
|
+
plot.available_themes # the names, for a host's picker (both demos use one)
|
|
162
211
|
```
|
|
163
212
|
|
|
164
213
|
The 26 bundled sheets are Matplotlib's own styles, vendored verbatim into
|
|
@@ -168,6 +217,86 @@ have (spines, figure size, dpi, legend, marker styles, …) are ignored. `themes
|
|
|
168
217
|
them (`"default"` plus the 26 sheets). An unknown name raises `KeyError` and leaves the current
|
|
169
218
|
theme alone.
|
|
170
219
|
|
|
220
|
+
## Axes, grid and ticks
|
|
221
|
+
|
|
222
|
+
- The **grid** is drawn in QML (a `Canvas`), one line per tick position, in `grid_color` /
|
|
223
|
+
`grid_width` / `grid_alpha` / `grid_style` (`-`, `--`, `:`, `-.`).
|
|
224
|
+
- The **axes** are the lines through world (0,0) — the x-axis at `y = 0`, the y-axis at
|
|
225
|
+
`x = 0` — drawn in `axis_color` / `axis_width`, and the **tick marks and labels ride those
|
|
226
|
+
axes** instead of hugging the item's edges. When 0 is off screen the axis sticks to the
|
|
227
|
+
nearest edge, so its labels are never lost (and a label never leaves the item); the grid
|
|
228
|
+
stays where it is. `plot.axes_position = "edge"` restores the edge-pinned behaviour.
|
|
229
|
+
- `camera.viewport` reports the last size the view sent, so a host can size an export from it.
|
|
230
|
+
- The tick *values* are nice numbers computed in Python (`Camera.tick_values()` /
|
|
231
|
+
`nice_ticks()`, steps of 1/2/5 x 10^n) and pushed through `ticksChanged`; QML only positions
|
|
232
|
+
and formats them, so a frame never recomputes ticks.
|
|
233
|
+
- `plot.ticks_visible = False` hides the whole furniture (axes, marks and labels).
|
|
234
|
+
|
|
235
|
+
## Export
|
|
236
|
+
|
|
237
|
+
`savefig()` writes a file, `to_image()` returns a `QImage`; both take the same keywords:
|
|
238
|
+
|
|
239
|
+
```python
|
|
240
|
+
plot.savefig("out.png", xlim=(-1, 1), ylim=(-1, 1), width=1200, height=400)
|
|
241
|
+
plot.savefig("hi.png", dpi=2.0) # the live view, twice the pixels
|
|
242
|
+
image = plot.to_image(width=800, height=600) # -> QImage
|
|
243
|
+
plot.to_image() # the live view, its own size
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
- `xlim` / `ylim` default to the **camera**: the live centre and scale, extended to the export
|
|
247
|
+
size. The *region* is a parameter, so an export can cover more canvas than the widget shows.
|
|
248
|
+
- `width` / `height` are **device pixels** and default to the live view's device size; `dpi`
|
|
249
|
+
multiplies both (`dpi=2.0` doubles the pixels).
|
|
250
|
+
- `adjustable` reconciles a requested range with a requested size when their aspects disagree —
|
|
251
|
+
Matplotlib's parameter, and its own default:
|
|
252
|
+
|
|
253
|
+
| `adjustable` | What happens |
|
|
254
|
+
|---|---|
|
|
255
|
+
| `"box"` (**default**) | one scale, taken from the live scale ratio, chosen so the requested range fits: the range is centred and the leftover pixels stay background — *nothing is distorted* |
|
|
256
|
+
| `"datalim"` | the camera's own scales, with the limits expanding (never cropped) to fill the size |
|
|
257
|
+
| `"stretch"` | the requested range maps onto the requested size exactly — independent scales, so a mismatched aspect **distorts** (the "report figure" mode) |
|
|
258
|
+
|
|
259
|
+
With no explicit range all three agree, because the range derived from the camera already has
|
|
260
|
+
the export size's aspect.
|
|
261
|
+
- `transparent=True` clears to alpha 0.
|
|
262
|
+
- Everything else follows the live styling (curves, grid, axes, theme), so an export cannot
|
|
263
|
+
drift from what the user sees. Rendering goes through an offscreen `QQuickWidget`, so the
|
|
264
|
+
export needs a `QApplication` (a clear error is raised instead of a crash if there is none).
|
|
265
|
+
|
|
266
|
+
### Vector output, matplotlib and sympy
|
|
267
|
+
|
|
268
|
+
`to_matplotlib()` hands the plot to matplotlib (one `Axes`) and `savefig(..., backend="matplotlib")`
|
|
269
|
+
renders through it, so `.svg`/`.pdf` work; `to_sympy()` builds the same figure through
|
|
270
|
+
`sympy.plotting`, which is what you want when the figure should live in sympy's world:
|
|
271
|
+
|
|
272
|
+
```python
|
|
273
|
+
figure = plot.to_matplotlib(xlim=(-1, 1), ylim=(-1, 1)) # -> matplotlib.figure.Figure
|
|
274
|
+
plot.savefig("out.svg", backend="matplotlib") # vector, matplotlib's own savefig
|
|
275
|
+
handle = plot.to_sympy(xlim=(-1, 1))
|
|
276
|
+
handle.save("out.png")
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
matplotlib is an **optional** extra (`pip install "qmlmathplot[matplotlib]"`); nothing in the
|
|
280
|
+
package imports it until one of these is called, and the error names the extra. `sympy` itself is
|
|
281
|
+
a hard dependency, so `to_sympy()` needs matplotlib only because its plotting backend does.
|
|
282
|
+
Neither hand-off needs a Qt application: a `Plot` works in a plain script, which is what makes it
|
|
283
|
+
usable next to a notebook or a matplotlib-only tool.
|
|
284
|
+
|
|
285
|
+
**This is a second renderer, and a sampled one.** matplotlib draws polylines, so each curve is
|
|
286
|
+
evaluated at `samples` points (default 2000) and an oscillating function such as `sin(1/x)`
|
|
287
|
+
aliases — exactly what the Qt view avoids by evaluating the expression per pixel. SymPy does not
|
|
288
|
+
solve it either, and its adaptive mode does not help: measured on `sin(1/x)` over [-1, 1],
|
|
289
|
+
`sympy.plotting.plot(adaptive=True)` uses **289** points (fewer than a fixed 2000), emits no
|
|
290
|
+
warning in sympy 1.14, and draws the same block of vertical connectors. The difference is how
|
|
291
|
+
the two fail: a sampled renderer draws an artefact, the Qt view draws the envelope the data
|
|
292
|
+
actually supports. (`to_sympy()` returns the object sympy made, so its own `adaptive` mode is
|
|
293
|
+
available there.) Fonts and antialiasing are matplotlib's, not the Qt view's, so the two
|
|
294
|
+
renderers cannot look identical, and the style mapping is partial: background, grid (on/off and
|
|
295
|
+
colour/width/alpha/dash), per-curve colour and width, tick and text colours, font sizes, title,
|
|
296
|
+
axis colour/width, the limits, the aspect with matplotlib's own `adjustable`, and
|
|
297
|
+
`axes_position="zero"` (spines through the origin, no box). What it buys is vector output and
|
|
298
|
+
matplotlib's ecosystem — nothing else.
|
|
299
|
+
|
|
171
300
|
## RHI backends
|
|
172
301
|
|
|
173
302
|
`--backend {d3d11,d3d12,vulkan,metal,opengl,null}`; **if unspecified, Qt's default
|
|
@@ -178,6 +307,14 @@ backend is used**. The implementation sets `QSG_RHI_BACKEND` before
|
|
|
178
307
|
|
|
179
308
|
## Structure (model / view)
|
|
180
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
|
+
|
|
181
318
|
| Layer | File | Responsibility |
|
|
182
319
|
|---|---|---|
|
|
183
320
|
| Model | `src/qmlmathplot/model.py` | expression → GLSL, shader source template (no Qt dependency, unit-testable) |
|
|
@@ -186,6 +323,8 @@ backend is used**. The implementation sets `QSG_RHI_BACKEND` before
|
|
|
186
323
|
| Plot | `src/qmlmathplot/plot.py` | `Plot`: the camera, the curves and the styling the view binds to |
|
|
187
324
|
| View | `src/qmlmathplot/qml/PlotView.qml` (+ `view.py`) | QML: background, grid, ticks/title, one `ShaderEffect` per curve, pan/zoom input; `view.py` registers the types and yields the component path |
|
|
188
325
|
| Themes | `src/qmlmathplot/themes.py` + `themes/` | the vendored Matplotlib style sheets, resolved onto the `Plot`'s properties |
|
|
326
|
+
| Export | `src/qmlmathplot/export.py` | `Plot.savefig()` / `to_image()`: the canvas region rendered offscreen at a pixel size (QtWidgets is imported lazily) |
|
|
327
|
+
| Hand-off | `src/qmlmathplot/mpl.py` | `Plot.to_matplotlib()` / `to_sympy()` / `savefig(backend="matplotlib")`: a sampled second renderer for vector output (matplotlib is an optional extra, imported lazily) |
|
|
189
328
|
| Bake | `src/qmlmathplot/qsb.py` | GLSL → `.qsb` (PySide6 ships `qsb.exe`), cached by source hash |
|
|
190
329
|
| Widget | `src/qmlmathplot/widget.py` | `MathPlotWidget`: QQuickWidget bridge, drops straight into QtWidgets layouts |
|
|
191
330
|
| Entry | `src/qmlmathplot/app.py` | command line / standalone window (the `qmlmathplot` script, `examples/minimal.py`) |
|
|
@@ -224,17 +363,25 @@ QSG_RHI_BACKEND=opengl uv run pytest -m gui
|
|
|
224
363
|
(the home view at the first size, resize keeping the scale, an anchored zoom that
|
|
225
364
|
is exactly reversible, the aspect expanding but never cropping, ticks).
|
|
226
365
|
- `test_curve.py`: a failed expression keeps the last working shaders, the style
|
|
227
|
-
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°.
|
|
228
372
|
- `test_plot.py`: the plot's properties convert, notify exactly once and are wired
|
|
229
|
-
(a PySide property with an unresolved notify name silently loses it), the
|
|
230
|
-
are forwarded to the camera, and a theme applies
|
|
373
|
+
(a PySide property with an unresolved notify name silently loses it), the defaults are
|
|
374
|
+
matplotlib's default style, the limits are forwarded to the camera, and a theme applies
|
|
375
|
+
(and re-applies as a reset) while an unknown name is rejected.
|
|
231
376
|
- `test_themes.py`: the vendored style sheets resolve to this library's properties.
|
|
232
377
|
- `test_shader_bake.py`: the stage of a baked `.qsb` must match its purpose, and
|
|
233
378
|
the four backend targets GLSL/HLSL/MSL/SPIR-V must all be covered.
|
|
234
379
|
- `test_render_smoke.py`: open a window, render, read pixels; verify the thin-line
|
|
235
380
|
shape, `sin(1/x)` filling its ±1 envelope, no |y|>1 artefacts outside the central
|
|
236
381
|
columns, poles not connected, nothing drawn outside the domain, and the QML
|
|
237
|
-
furniture (grid lines exactly at the tick positions,
|
|
382
|
+
furniture (grid lines exactly at the tick positions, the axes crossing at the origin
|
|
383
|
+
with their labels on them, the axis pinning to the edge once the origin is panned
|
|
384
|
+
away, the title).
|
|
238
385
|
- `test_widget.py`: `MathPlotWidget` coexisting with neighbouring widgets — the plot
|
|
239
386
|
appearing inside a layout, an expression change / an invalid expression keeping the
|
|
240
387
|
previous graph, curves added/hidden/removed through the model, **the wheel over a
|
|
@@ -244,6 +391,17 @@ QSG_RHI_BACKEND=opengl uv run pytest -m gui
|
|
|
244
391
|
needs one, and a process may only have one).
|
|
245
392
|
- `test_qtquick_coexistence.py`: the same drag conflict in Qt Quick (a `Flickable`
|
|
246
393
|
must not steal the pan).
|
|
394
|
+
- `test_handoff.py`: the matplotlib/sympy hand-off — the figure reflects the limits, aspect,
|
|
395
|
+
`adjustable`, grid, title, spines at zero and per-curve style; the sampling is uniform and NaN
|
|
396
|
+
outside the domain (so `log(x)` breaks); `savefig(backend="matplotlib")` writes real `.svg`,
|
|
397
|
+
`.pdf` and `.png`; `to_sympy().save()` writes a file and keeps per-curve colours; importing
|
|
398
|
+
`qmlmathplot` does not import matplotlib, and hiding matplotlib produces an error naming the
|
|
399
|
+
extra.
|
|
400
|
+
- `test_export.py`: `to_image()`/`savefig()` — the image is exactly the requested device size
|
|
401
|
+
(`dpi` multiplies it), the no-argument form reproduces the live centre and scale, the three
|
|
402
|
+
`adjustable` modes reconcile range and size as documented (a world 45-degree line stays at 45
|
|
403
|
+
in `"box"`, with background margins; `"stretch"` fills the frame and distorts), the live
|
|
404
|
+
camera and background survive an export, and `transparent` clears to alpha 0.
|
|
247
405
|
|
|
248
406
|
## Pitfalls near the `sin(1/x)` singularity (fixed — do not repeat)
|
|
249
407
|
|
|
@@ -274,15 +432,21 @@ extrema/zeros by bisection. The same approach is used here:
|
|
|
274
432
|
perpendicular foot lands on a segment (on a sloped line it is displaced along x
|
|
275
433
|
by `m·d/√(1+m²)`); with a narrower window the distance degraded to "distance to
|
|
276
434
|
the nearest endpoint" and steep parts looked thin and ragged.
|
|
277
|
-
- **Break on jumps**:
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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.)
|
|
282
444
|
- **Domain** (derived by sympy, injected as `DOM`): denominator ≠ 0, `log` argument
|
|
283
445
|
> 0, `sqrt` argument ≥ 0, `asin/acos` argument ∈ [−1,1]; pixels outside the domain
|
|
284
|
-
are discarded outright
|
|
285
|
-
|
|
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.
|
|
286
450
|
- **Undersampled columns** (several oscillations inside one column) are replaced
|
|
287
451
|
wholesale by the envelope band: a polyline is random sawtooth in such columns.
|
|
288
452
|
There are two criteria, both **scale-independent**: (a) ≥ 2 direction reversals
|
|
@@ -344,6 +508,12 @@ measurement that settled each one). The Qt/packaging ones that shaped the code:
|
|
|
344
508
|
same x 9 times, and macros are preprocessor expansion with no call semantics.
|
|
345
509
|
- PySide ties a property's notify signal to the `Signal` **object**; a name string
|
|
346
510
|
silently leaves the property without one (and QML bindings then never refresh).
|
|
511
|
+
- Snap **hairlines** (grid) to a half pixel, but never a **styled** line: rounding the
|
|
512
|
+
axis position put the 1.07 px y-axis half off the centre column, so the line came out
|
|
513
|
+
pale where it should be black.
|
|
514
|
+
- `QQuickWidget.setProperty(name, …)` sets it on the **widget**, not on the QML root (the
|
|
515
|
+
widget has no such property, so it silently does nothing): inject the model with
|
|
516
|
+
`widget.rootObject().setProperty("plot", plot)`, as `MathPlotWidget` does.
|
|
347
517
|
|
|
348
518
|
## Releasing
|
|
349
519
|
|
|
@@ -365,6 +535,12 @@ be rehearsed:
|
|
|
365
535
|
pip install -i https://test.pypi.org/simple/ qmlmathplot
|
|
366
536
|
```
|
|
367
537
|
|
|
538
|
+
If the publish step fails with `invalid-publisher: valid token, but no corresponding
|
|
539
|
+
publisher`, the trusted-publisher entry is missing or does not match. The action prints the
|
|
540
|
+
claims it presented (`repository`, `workflow_ref`, `environment`) — the entry must match them
|
|
541
|
+
exactly, **including the environment name**, and it must exist on the index the run targets: a
|
|
542
|
+
manual run goes to **test.pypi.org**, a tag push to **pypi.org**, so both need an entry.
|
|
543
|
+
|
|
368
544
|
The workflow runs on Windows: that is the platform this library is verified on (the shader baker
|
|
369
545
|
`qsb` ships inside the PySide6 wheel and is only confirmed to be present there, see `qsb.py`),
|
|
370
546
|
and the GUI tests need a GPU scene graph no runner has. The artifact is `py3-none-any`, so the
|