qmlmathplot 0.1.0__tar.gz → 0.2.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 (47) hide show
  1. {qmlmathplot-0.1.0 → qmlmathplot-0.2.0}/PKG-INFO +127 -9
  2. {qmlmathplot-0.1.0 → qmlmathplot-0.2.0}/README.md +123 -7
  3. {qmlmathplot-0.1.0 → qmlmathplot-0.2.0}/pyproject.toml +9 -3
  4. {qmlmathplot-0.1.0 → qmlmathplot-0.2.0}/pyproject.toml.orig +8 -2
  5. {qmlmathplot-0.1.0 → qmlmathplot-0.2.0}/src/qmlmathplot/camera.py +28 -0
  6. {qmlmathplot-0.1.0 → qmlmathplot-0.2.0}/src/qmlmathplot/curve.py +5 -3
  7. qmlmathplot-0.2.0/src/qmlmathplot/export.py +164 -0
  8. qmlmathplot-0.2.0/src/qmlmathplot/mpl.py +232 -0
  9. qmlmathplot-0.2.0/src/qmlmathplot/plot.py +367 -0
  10. {qmlmathplot-0.1.0 → qmlmathplot-0.2.0}/src/qmlmathplot/qml/PlotView.qml +52 -11
  11. qmlmathplot-0.2.0/src/qmlmathplot/themes/default.mplstyle +26 -0
  12. {qmlmathplot-0.1.0 → qmlmathplot-0.2.0}/src/qmlmathplot/themes.py +11 -6
  13. {qmlmathplot-0.1.0 → qmlmathplot-0.2.0}/src/qmlmathplot/widget.py +15 -10
  14. qmlmathplot-0.1.0/src/qmlmathplot/plot.py +0 -213
  15. {qmlmathplot-0.1.0 → qmlmathplot-0.2.0}/src/qmlmathplot/__init__.py +0 -0
  16. {qmlmathplot-0.1.0 → qmlmathplot-0.2.0}/src/qmlmathplot/app.py +0 -0
  17. {qmlmathplot-0.1.0 → qmlmathplot-0.2.0}/src/qmlmathplot/model.py +0 -0
  18. {qmlmathplot-0.1.0 → qmlmathplot-0.2.0}/src/qmlmathplot/qsb.py +0 -0
  19. {qmlmathplot-0.1.0 → qmlmathplot-0.2.0}/src/qmlmathplot/themes/LICENSE.matplotlib +0 -0
  20. {qmlmathplot-0.1.0 → qmlmathplot-0.2.0}/src/qmlmathplot/themes/README.md +0 -0
  21. {qmlmathplot-0.1.0 → qmlmathplot-0.2.0}/src/qmlmathplot/themes/Solarize_Light2.mplstyle +0 -0
  22. {qmlmathplot-0.1.0 → qmlmathplot-0.2.0}/src/qmlmathplot/themes/bmh.mplstyle +0 -0
  23. {qmlmathplot-0.1.0 → qmlmathplot-0.2.0}/src/qmlmathplot/themes/classic.mplstyle +0 -0
  24. {qmlmathplot-0.1.0 → qmlmathplot-0.2.0}/src/qmlmathplot/themes/dark_background.mplstyle +0 -0
  25. {qmlmathplot-0.1.0 → qmlmathplot-0.2.0}/src/qmlmathplot/themes/fast.mplstyle +0 -0
  26. {qmlmathplot-0.1.0 → qmlmathplot-0.2.0}/src/qmlmathplot/themes/fivethirtyeight.mplstyle +0 -0
  27. {qmlmathplot-0.1.0 → qmlmathplot-0.2.0}/src/qmlmathplot/themes/ggplot.mplstyle +0 -0
  28. {qmlmathplot-0.1.0 → qmlmathplot-0.2.0}/src/qmlmathplot/themes/grayscale.mplstyle +0 -0
  29. {qmlmathplot-0.1.0 → qmlmathplot-0.2.0}/src/qmlmathplot/themes/petroff10.mplstyle +0 -0
  30. {qmlmathplot-0.1.0 → qmlmathplot-0.2.0}/src/qmlmathplot/themes/seaborn-v0_8-bright.mplstyle +0 -0
  31. {qmlmathplot-0.1.0 → qmlmathplot-0.2.0}/src/qmlmathplot/themes/seaborn-v0_8-colorblind.mplstyle +0 -0
  32. {qmlmathplot-0.1.0 → qmlmathplot-0.2.0}/src/qmlmathplot/themes/seaborn-v0_8-dark-palette.mplstyle +0 -0
  33. {qmlmathplot-0.1.0 → qmlmathplot-0.2.0}/src/qmlmathplot/themes/seaborn-v0_8-dark.mplstyle +0 -0
  34. {qmlmathplot-0.1.0 → qmlmathplot-0.2.0}/src/qmlmathplot/themes/seaborn-v0_8-darkgrid.mplstyle +0 -0
  35. {qmlmathplot-0.1.0 → qmlmathplot-0.2.0}/src/qmlmathplot/themes/seaborn-v0_8-deep.mplstyle +0 -0
  36. {qmlmathplot-0.1.0 → qmlmathplot-0.2.0}/src/qmlmathplot/themes/seaborn-v0_8-muted.mplstyle +0 -0
  37. {qmlmathplot-0.1.0 → qmlmathplot-0.2.0}/src/qmlmathplot/themes/seaborn-v0_8-notebook.mplstyle +0 -0
  38. {qmlmathplot-0.1.0 → qmlmathplot-0.2.0}/src/qmlmathplot/themes/seaborn-v0_8-paper.mplstyle +0 -0
  39. {qmlmathplot-0.1.0 → qmlmathplot-0.2.0}/src/qmlmathplot/themes/seaborn-v0_8-pastel.mplstyle +0 -0
  40. {qmlmathplot-0.1.0 → qmlmathplot-0.2.0}/src/qmlmathplot/themes/seaborn-v0_8-poster.mplstyle +0 -0
  41. {qmlmathplot-0.1.0 → qmlmathplot-0.2.0}/src/qmlmathplot/themes/seaborn-v0_8-talk.mplstyle +0 -0
  42. {qmlmathplot-0.1.0 → qmlmathplot-0.2.0}/src/qmlmathplot/themes/seaborn-v0_8-ticks.mplstyle +0 -0
  43. {qmlmathplot-0.1.0 → qmlmathplot-0.2.0}/src/qmlmathplot/themes/seaborn-v0_8-white.mplstyle +0 -0
  44. {qmlmathplot-0.1.0 → qmlmathplot-0.2.0}/src/qmlmathplot/themes/seaborn-v0_8-whitegrid.mplstyle +0 -0
  45. {qmlmathplot-0.1.0 → qmlmathplot-0.2.0}/src/qmlmathplot/themes/seaborn-v0_8.mplstyle +0 -0
  46. {qmlmathplot-0.1.0 → qmlmathplot-0.2.0}/src/qmlmathplot/themes/tableau-colorblind10.mplstyle +0 -0
  47. {qmlmathplot-0.1.0 → qmlmathplot-0.2.0}/src/qmlmathplot/view.py +0 -0
@@ -1,12 +1,14 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: qmlmathplot
3
- Version: 0.1.0
3
+ Version: 0.2.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-Python: >=3.14
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,10 @@ 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
+
41
47
  `MathPlotWidget` keeps one curve's worth of convenience (`expression`, `error`,
42
48
  `line_width`, `curve_color`, `background_color`, `aspect`, `view_bounds()`,
43
49
  `reset_view()`, `zoom()`, `pan_pixels()`); everything else lives on the model behind it:
@@ -116,7 +122,7 @@ The model:
116
122
 
117
123
  | Object | Members |
118
124
  |---|---|
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` |
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` |
120
126
  | `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
127
  | `Curve` | `expression`, `color`, `lineWidth`, `visible`, `label`, `error` (read-only), the baked `vertexShader` / `fragmentShader` |
122
128
  | `CurveListModel` | the curves as a model (`curve`, `expression`, `color`, `lineWidth`, `visible`, `vertexShader`, `fragmentShader` roles) so QML puts one `ShaderEffect` behind each |
@@ -135,8 +141,8 @@ build order — is specified in [`docs/api-design.md`](docs/api-design.md). In s
135
141
  Matplotlib *vocabulary* is kept where it is vocabulary (limits, grid, title, ticks, annotate),
136
142
  while the *mechanism* is refused — no `Figure`/`Axes`/`subplots` (one view per widget, no
137
143
  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 (not
139
- implemented yet).
144
+ chrome), and an export is a canvas region at a pixel size rather than a re-rendered page
145
+ (§9, implemented — see *Export* below).
140
146
 
141
147
  ## The camera (centre, zoom, aspect)
142
148
 
@@ -164,12 +170,16 @@ plot.plot.camera.zoom_by(120, 0.5, 0.5, w, h) # one wheel notch, anchored at
164
170
 
165
171
  ## Themes
166
172
 
167
- `Plot.theme` applies a style sheet to the style properties above:
173
+ `Plot.theme` applies a style sheet to the style properties above; the default is
174
+ matplotlib's own default style, so an untouched plot already looks like matplotlib and
175
+ `plot.theme = "default"` is the reset:
168
176
 
169
177
  ```python
170
178
  plot.theme = "ggplot" # any of the bundled sheets
179
+ plot.theme = "default" # back to matplotlib's look
171
180
  plot.theme = "my-style" # themes/my-style.mplstyle next to the package
172
181
  plot.theme = "path/to/any.mplstyle" # any file in the same format
182
+ plot.available_themes # the names, for a host's picker (both demos use one)
173
183
  ```
174
184
 
175
185
  The 26 bundled sheets are Matplotlib's own styles, vendored verbatim into
@@ -179,6 +189,86 @@ have (spines, figure size, dpi, legend, marker styles, …) are ignored. `themes
179
189
  them (`"default"` plus the 26 sheets). An unknown name raises `KeyError` and leaves the current
180
190
  theme alone.
181
191
 
192
+ ## Axes, grid and ticks
193
+
194
+ - The **grid** is drawn in QML (a `Canvas`), one line per tick position, in `grid_color` /
195
+ `grid_width` / `grid_alpha` / `grid_style` (`-`, `--`, `:`, `-.`).
196
+ - The **axes** are the lines through world (0,0) — the x-axis at `y = 0`, the y-axis at
197
+ `x = 0` — drawn in `axis_color` / `axis_width`, and the **tick marks and labels ride those
198
+ axes** instead of hugging the item's edges. When 0 is off screen the axis sticks to the
199
+ nearest edge, so its labels are never lost (and a label never leaves the item); the grid
200
+ stays where it is. `plot.axes_position = "edge"` restores the edge-pinned behaviour.
201
+ - `camera.viewport` reports the last size the view sent, so a host can size an export from it.
202
+ - The tick *values* are nice numbers computed in Python (`Camera.tick_values()` /
203
+ `nice_ticks()`, steps of 1/2/5 x 10^n) and pushed through `ticksChanged`; QML only positions
204
+ and formats them, so a frame never recomputes ticks.
205
+ - `plot.ticks_visible = False` hides the whole furniture (axes, marks and labels).
206
+
207
+ ## Export
208
+
209
+ `savefig()` writes a file, `to_image()` returns a `QImage`; both take the same keywords:
210
+
211
+ ```python
212
+ plot.savefig("out.png", xlim=(-1, 1), ylim=(-1, 1), width=1200, height=400)
213
+ plot.savefig("hi.png", dpi=2.0) # the live view, twice the pixels
214
+ image = plot.to_image(width=800, height=600) # -> QImage
215
+ plot.to_image() # the live view, its own size
216
+ ```
217
+
218
+ - `xlim` / `ylim` default to the **camera**: the live centre and scale, extended to the export
219
+ size. The *region* is a parameter, so an export can cover more canvas than the widget shows.
220
+ - `width` / `height` are **device pixels** and default to the live view's device size; `dpi`
221
+ multiplies both (`dpi=2.0` doubles the pixels).
222
+ - `adjustable` reconciles a requested range with a requested size when their aspects disagree —
223
+ Matplotlib's parameter, and its own default:
224
+
225
+ | `adjustable` | What happens |
226
+ |---|---|
227
+ | `"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* |
228
+ | `"datalim"` | the camera's own scales, with the limits expanding (never cropped) to fill the size |
229
+ | `"stretch"` | the requested range maps onto the requested size exactly — independent scales, so a mismatched aspect **distorts** (the "report figure" mode) |
230
+
231
+ With no explicit range all three agree, because the range derived from the camera already has
232
+ the export size's aspect.
233
+ - `transparent=True` clears to alpha 0.
234
+ - Everything else follows the live styling (curves, grid, axes, theme), so an export cannot
235
+ drift from what the user sees. Rendering goes through an offscreen `QQuickWidget`, so the
236
+ export needs a `QApplication` (a clear error is raised instead of a crash if there is none).
237
+
238
+ ### Vector output, matplotlib and sympy
239
+
240
+ `to_matplotlib()` hands the plot to matplotlib (one `Axes`) and `savefig(..., backend="matplotlib")`
241
+ renders through it, so `.svg`/`.pdf` work; `to_sympy()` builds the same figure through
242
+ `sympy.plotting`, which is what you want when the figure should live in sympy's world:
243
+
244
+ ```python
245
+ figure = plot.to_matplotlib(xlim=(-1, 1), ylim=(-1, 1)) # -> matplotlib.figure.Figure
246
+ plot.savefig("out.svg", backend="matplotlib") # vector, matplotlib's own savefig
247
+ handle = plot.to_sympy(xlim=(-1, 1))
248
+ handle.save("out.png")
249
+ ```
250
+
251
+ matplotlib is an **optional** extra (`pip install "qmlmathplot[matplotlib]"`); nothing in the
252
+ package imports it until one of these is called, and the error names the extra. `sympy` itself is
253
+ a hard dependency, so `to_sympy()` needs matplotlib only because its plotting backend does.
254
+ Neither hand-off needs a Qt application: a `Plot` works in a plain script, which is what makes it
255
+ usable next to a notebook or a matplotlib-only tool.
256
+
257
+ **This is a second renderer, and a sampled one.** matplotlib draws polylines, so each curve is
258
+ evaluated at `samples` points (default 2000) and an oscillating function such as `sin(1/x)`
259
+ aliases — exactly what the Qt view avoids by evaluating the expression per pixel. SymPy does not
260
+ solve it either, and its adaptive mode does not help: measured on `sin(1/x)` over [-1, 1],
261
+ `sympy.plotting.plot(adaptive=True)` uses **289** points (fewer than a fixed 2000), emits no
262
+ warning in sympy 1.14, and draws the same block of vertical connectors. The difference is how
263
+ the two fail: a sampled renderer draws an artefact, the Qt view draws the envelope the data
264
+ actually supports. (`to_sympy()` returns the object sympy made, so its own `adaptive` mode is
265
+ available there.) Fonts and antialiasing are matplotlib's, not the Qt view's, so the two
266
+ renderers cannot look identical, and the style mapping is partial: background, grid (on/off and
267
+ colour/width/alpha/dash), per-curve colour and width, tick and text colours, font sizes, title,
268
+ axis colour/width, the limits, the aspect with matplotlib's own `adjustable`, and
269
+ `axes_position="zero"` (spines through the origin, no box). What it buys is vector output and
270
+ matplotlib's ecosystem — nothing else.
271
+
182
272
  ## RHI backends
183
273
 
184
274
  `--backend {d3d11,d3d12,vulkan,metal,opengl,null}`; **if unspecified, Qt's default
@@ -197,6 +287,8 @@ backend is used**. The implementation sets `QSG_RHI_BACKEND` before
197
287
  | Plot | `src/qmlmathplot/plot.py` | `Plot`: the camera, the curves and the styling the view binds to |
198
288
  | 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
289
  | Themes | `src/qmlmathplot/themes.py` + `themes/` | the vendored Matplotlib style sheets, resolved onto the `Plot`'s properties |
290
+ | Export | `src/qmlmathplot/export.py` | `Plot.savefig()` / `to_image()`: the canvas region rendered offscreen at a pixel size (QtWidgets is imported lazily) |
291
+ | 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
292
  | Bake | `src/qmlmathplot/qsb.py` | GLSL → `.qsb` (PySide6 ships `qsb.exe`), cached by source hash |
201
293
  | Widget | `src/qmlmathplot/widget.py` | `MathPlotWidget`: QQuickWidget bridge, drops straight into QtWidgets layouts |
202
294
  | Entry | `src/qmlmathplot/app.py` | command line / standalone window (the `qmlmathplot` script, `examples/minimal.py`) |
@@ -237,15 +329,18 @@ QSG_RHI_BACKEND=opengl uv run pytest -m gui
237
329
  - `test_curve.py`: a failed expression keeps the last working shaders, the style
238
330
  setters are idempotent, and the list model reports the right roles.
239
331
  - `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 limits
241
- are forwarded to the camera, and a theme applies / an unknown name is rejected.
332
+ (a PySide property with an unresolved notify name silently loses it), the defaults are
333
+ matplotlib's default style, the limits are forwarded to the camera, and a theme applies
334
+ (and re-applies as a reset) while an unknown name is rejected.
242
335
  - `test_themes.py`: the vendored style sheets resolve to this library's properties.
243
336
  - `test_shader_bake.py`: the stage of a baked `.qsb` must match its purpose, and
244
337
  the four backend targets GLSL/HLSL/MSL/SPIR-V must all be covered.
245
338
  - `test_render_smoke.py`: open a window, render, read pixels; verify the thin-line
246
339
  shape, `sin(1/x)` filling its ±1 envelope, no |y|>1 artefacts outside the central
247
340
  columns, poles not connected, nothing drawn outside the domain, and the QML
248
- furniture (grid lines exactly at the tick positions, ticks/labels, title).
341
+ furniture (grid lines exactly at the tick positions, the axes crossing at the origin
342
+ with their labels on them, the axis pinning to the edge once the origin is panned
343
+ away, the title).
249
344
  - `test_widget.py`: `MathPlotWidget` coexisting with neighbouring widgets — the plot
250
345
  appearing inside a layout, an expression change / an invalid expression keeping the
251
346
  previous graph, curves added/hidden/removed through the model, **the wheel over a
@@ -255,6 +350,17 @@ QSG_RHI_BACKEND=opengl uv run pytest -m gui
255
350
  needs one, and a process may only have one).
256
351
  - `test_qtquick_coexistence.py`: the same drag conflict in Qt Quick (a `Flickable`
257
352
  must not steal the pan).
353
+ - `test_handoff.py`: the matplotlib/sympy hand-off — the figure reflects the limits, aspect,
354
+ `adjustable`, grid, title, spines at zero and per-curve style; the sampling is uniform and NaN
355
+ outside the domain (so `log(x)` breaks); `savefig(backend="matplotlib")` writes real `.svg`,
356
+ `.pdf` and `.png`; `to_sympy().save()` writes a file and keeps per-curve colours; importing
357
+ `qmlmathplot` does not import matplotlib, and hiding matplotlib produces an error naming the
358
+ extra.
359
+ - `test_export.py`: `to_image()`/`savefig()` — the image is exactly the requested device size
360
+ (`dpi` multiplies it), the no-argument form reproduces the live centre and scale, the three
361
+ `adjustable` modes reconcile range and size as documented (a world 45-degree line stays at 45
362
+ in `"box"`, with background margins; `"stretch"` fills the frame and distorts), the live
363
+ camera and background survive an export, and `transparent` clears to alpha 0.
258
364
 
259
365
  ## Pitfalls near the `sin(1/x)` singularity (fixed — do not repeat)
260
366
 
@@ -355,6 +461,12 @@ measurement that settled each one). The Qt/packaging ones that shaped the code:
355
461
  same x 9 times, and macros are preprocessor expansion with no call semantics.
356
462
  - PySide ties a property's notify signal to the `Signal` **object**; a name string
357
463
  silently leaves the property without one (and QML bindings then never refresh).
464
+ - Snap **hairlines** (grid) to a half pixel, but never a **styled** line: rounding the
465
+ axis position put the 1.07 px y-axis half off the centre column, so the line came out
466
+ pale where it should be black.
467
+ - `QQuickWidget.setProperty(name, …)` sets it on the **widget**, not on the QML root (the
468
+ widget has no such property, so it silently does nothing): inject the model with
469
+ `widget.rootObject().setProperty("plot", plot)`, as `MathPlotWidget` does.
358
470
 
359
471
  ## Releasing
360
472
 
@@ -376,6 +488,12 @@ be rehearsed:
376
488
  pip install -i https://test.pypi.org/simple/ qmlmathplot
377
489
  ```
378
490
 
491
+ If the publish step fails with `invalid-publisher: valid token, but no corresponding
492
+ publisher`, the trusted-publisher entry is missing or does not match. The action prints the
493
+ claims it presented (`repository`, `workflow_ref`, `environment`) — the entry must match them
494
+ exactly, **including the environment name**, and it must exist on the index the run targets: a
495
+ manual run goes to **test.pypi.org**, a tag push to **pypi.org**, so both need an entry.
496
+
379
497
  The workflow runs on Windows: that is the platform this library is verified on (the shader baker
380
498
  `qsb` ships inside the PySide6 wheel and is only confirmed to be present there, see `qsb.py`),
381
499
  and the GUI tests need a GPU scene graph no runner has. The artifact is `py3-none-any`, so the
@@ -27,6 +27,10 @@ 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
+
30
34
  `MathPlotWidget` keeps one curve's worth of convenience (`expression`, `error`,
31
35
  `line_width`, `curve_color`, `background_color`, `aspect`, `view_bounds()`,
32
36
  `reset_view()`, `zoom()`, `pan_pixels()`); everything else lives on the model behind it:
@@ -105,7 +109,7 @@ The model:
105
109
 
106
110
  | Object | Members |
107
111
  |---|---|
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` |
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` |
109
113
  | `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
114
  | `Curve` | `expression`, `color`, `lineWidth`, `visible`, `label`, `error` (read-only), the baked `vertexShader` / `fragmentShader` |
111
115
  | `CurveListModel` | the curves as a model (`curve`, `expression`, `color`, `lineWidth`, `visible`, `vertexShader`, `fragmentShader` roles) so QML puts one `ShaderEffect` behind each |
@@ -124,8 +128,8 @@ build order — is specified in [`docs/api-design.md`](docs/api-design.md). In s
124
128
  Matplotlib *vocabulary* is kept where it is vocabulary (limits, grid, title, ticks, annotate),
125
129
  while the *mechanism* is refused — no `Figure`/`Axes`/`subplots` (one view per widget, no
126
130
  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 (not
128
- implemented yet).
131
+ chrome), and an export is a canvas region at a pixel size rather than a re-rendered page
132
+ (§9, implemented — see *Export* below).
129
133
 
130
134
  ## The camera (centre, zoom, aspect)
131
135
 
@@ -153,12 +157,16 @@ plot.plot.camera.zoom_by(120, 0.5, 0.5, w, h) # one wheel notch, anchored at
153
157
 
154
158
  ## Themes
155
159
 
156
- `Plot.theme` applies a style sheet to the style properties above:
160
+ `Plot.theme` applies a style sheet to the style properties above; the default is
161
+ matplotlib's own default style, so an untouched plot already looks like matplotlib and
162
+ `plot.theme = "default"` is the reset:
157
163
 
158
164
  ```python
159
165
  plot.theme = "ggplot" # any of the bundled sheets
166
+ plot.theme = "default" # back to matplotlib's look
160
167
  plot.theme = "my-style" # themes/my-style.mplstyle next to the package
161
168
  plot.theme = "path/to/any.mplstyle" # any file in the same format
169
+ plot.available_themes # the names, for a host's picker (both demos use one)
162
170
  ```
163
171
 
164
172
  The 26 bundled sheets are Matplotlib's own styles, vendored verbatim into
@@ -168,6 +176,86 @@ have (spines, figure size, dpi, legend, marker styles, …) are ignored. `themes
168
176
  them (`"default"` plus the 26 sheets). An unknown name raises `KeyError` and leaves the current
169
177
  theme alone.
170
178
 
179
+ ## Axes, grid and ticks
180
+
181
+ - The **grid** is drawn in QML (a `Canvas`), one line per tick position, in `grid_color` /
182
+ `grid_width` / `grid_alpha` / `grid_style` (`-`, `--`, `:`, `-.`).
183
+ - The **axes** are the lines through world (0,0) — the x-axis at `y = 0`, the y-axis at
184
+ `x = 0` — drawn in `axis_color` / `axis_width`, and the **tick marks and labels ride those
185
+ axes** instead of hugging the item's edges. When 0 is off screen the axis sticks to the
186
+ nearest edge, so its labels are never lost (and a label never leaves the item); the grid
187
+ stays where it is. `plot.axes_position = "edge"` restores the edge-pinned behaviour.
188
+ - `camera.viewport` reports the last size the view sent, so a host can size an export from it.
189
+ - The tick *values* are nice numbers computed in Python (`Camera.tick_values()` /
190
+ `nice_ticks()`, steps of 1/2/5 x 10^n) and pushed through `ticksChanged`; QML only positions
191
+ and formats them, so a frame never recomputes ticks.
192
+ - `plot.ticks_visible = False` hides the whole furniture (axes, marks and labels).
193
+
194
+ ## Export
195
+
196
+ `savefig()` writes a file, `to_image()` returns a `QImage`; both take the same keywords:
197
+
198
+ ```python
199
+ plot.savefig("out.png", xlim=(-1, 1), ylim=(-1, 1), width=1200, height=400)
200
+ plot.savefig("hi.png", dpi=2.0) # the live view, twice the pixels
201
+ image = plot.to_image(width=800, height=600) # -> QImage
202
+ plot.to_image() # the live view, its own size
203
+ ```
204
+
205
+ - `xlim` / `ylim` default to the **camera**: the live centre and scale, extended to the export
206
+ size. The *region* is a parameter, so an export can cover more canvas than the widget shows.
207
+ - `width` / `height` are **device pixels** and default to the live view's device size; `dpi`
208
+ multiplies both (`dpi=2.0` doubles the pixels).
209
+ - `adjustable` reconciles a requested range with a requested size when their aspects disagree —
210
+ Matplotlib's parameter, and its own default:
211
+
212
+ | `adjustable` | What happens |
213
+ |---|---|
214
+ | `"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* |
215
+ | `"datalim"` | the camera's own scales, with the limits expanding (never cropped) to fill the size |
216
+ | `"stretch"` | the requested range maps onto the requested size exactly — independent scales, so a mismatched aspect **distorts** (the "report figure" mode) |
217
+
218
+ With no explicit range all three agree, because the range derived from the camera already has
219
+ the export size's aspect.
220
+ - `transparent=True` clears to alpha 0.
221
+ - Everything else follows the live styling (curves, grid, axes, theme), so an export cannot
222
+ drift from what the user sees. Rendering goes through an offscreen `QQuickWidget`, so the
223
+ export needs a `QApplication` (a clear error is raised instead of a crash if there is none).
224
+
225
+ ### Vector output, matplotlib and sympy
226
+
227
+ `to_matplotlib()` hands the plot to matplotlib (one `Axes`) and `savefig(..., backend="matplotlib")`
228
+ renders through it, so `.svg`/`.pdf` work; `to_sympy()` builds the same figure through
229
+ `sympy.plotting`, which is what you want when the figure should live in sympy's world:
230
+
231
+ ```python
232
+ figure = plot.to_matplotlib(xlim=(-1, 1), ylim=(-1, 1)) # -> matplotlib.figure.Figure
233
+ plot.savefig("out.svg", backend="matplotlib") # vector, matplotlib's own savefig
234
+ handle = plot.to_sympy(xlim=(-1, 1))
235
+ handle.save("out.png")
236
+ ```
237
+
238
+ matplotlib is an **optional** extra (`pip install "qmlmathplot[matplotlib]"`); nothing in the
239
+ package imports it until one of these is called, and the error names the extra. `sympy` itself is
240
+ a hard dependency, so `to_sympy()` needs matplotlib only because its plotting backend does.
241
+ Neither hand-off needs a Qt application: a `Plot` works in a plain script, which is what makes it
242
+ usable next to a notebook or a matplotlib-only tool.
243
+
244
+ **This is a second renderer, and a sampled one.** matplotlib draws polylines, so each curve is
245
+ evaluated at `samples` points (default 2000) and an oscillating function such as `sin(1/x)`
246
+ aliases — exactly what the Qt view avoids by evaluating the expression per pixel. SymPy does not
247
+ solve it either, and its adaptive mode does not help: measured on `sin(1/x)` over [-1, 1],
248
+ `sympy.plotting.plot(adaptive=True)` uses **289** points (fewer than a fixed 2000), emits no
249
+ warning in sympy 1.14, and draws the same block of vertical connectors. The difference is how
250
+ the two fail: a sampled renderer draws an artefact, the Qt view draws the envelope the data
251
+ actually supports. (`to_sympy()` returns the object sympy made, so its own `adaptive` mode is
252
+ available there.) Fonts and antialiasing are matplotlib's, not the Qt view's, so the two
253
+ renderers cannot look identical, and the style mapping is partial: background, grid (on/off and
254
+ colour/width/alpha/dash), per-curve colour and width, tick and text colours, font sizes, title,
255
+ axis colour/width, the limits, the aspect with matplotlib's own `adjustable`, and
256
+ `axes_position="zero"` (spines through the origin, no box). What it buys is vector output and
257
+ matplotlib's ecosystem — nothing else.
258
+
171
259
  ## RHI backends
172
260
 
173
261
  `--backend {d3d11,d3d12,vulkan,metal,opengl,null}`; **if unspecified, Qt's default
@@ -186,6 +274,8 @@ backend is used**. The implementation sets `QSG_RHI_BACKEND` before
186
274
  | Plot | `src/qmlmathplot/plot.py` | `Plot`: the camera, the curves and the styling the view binds to |
187
275
  | 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
276
  | Themes | `src/qmlmathplot/themes.py` + `themes/` | the vendored Matplotlib style sheets, resolved onto the `Plot`'s properties |
277
+ | Export | `src/qmlmathplot/export.py` | `Plot.savefig()` / `to_image()`: the canvas region rendered offscreen at a pixel size (QtWidgets is imported lazily) |
278
+ | 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
279
  | Bake | `src/qmlmathplot/qsb.py` | GLSL → `.qsb` (PySide6 ships `qsb.exe`), cached by source hash |
190
280
  | Widget | `src/qmlmathplot/widget.py` | `MathPlotWidget`: QQuickWidget bridge, drops straight into QtWidgets layouts |
191
281
  | Entry | `src/qmlmathplot/app.py` | command line / standalone window (the `qmlmathplot` script, `examples/minimal.py`) |
@@ -226,15 +316,18 @@ QSG_RHI_BACKEND=opengl uv run pytest -m gui
226
316
  - `test_curve.py`: a failed expression keeps the last working shaders, the style
227
317
  setters are idempotent, and the list model reports the right roles.
228
318
  - `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 limits
230
- are forwarded to the camera, and a theme applies / an unknown name is rejected.
319
+ (a PySide property with an unresolved notify name silently loses it), the defaults are
320
+ matplotlib's default style, the limits are forwarded to the camera, and a theme applies
321
+ (and re-applies as a reset) while an unknown name is rejected.
231
322
  - `test_themes.py`: the vendored style sheets resolve to this library's properties.
232
323
  - `test_shader_bake.py`: the stage of a baked `.qsb` must match its purpose, and
233
324
  the four backend targets GLSL/HLSL/MSL/SPIR-V must all be covered.
234
325
  - `test_render_smoke.py`: open a window, render, read pixels; verify the thin-line
235
326
  shape, `sin(1/x)` filling its ±1 envelope, no |y|>1 artefacts outside the central
236
327
  columns, poles not connected, nothing drawn outside the domain, and the QML
237
- furniture (grid lines exactly at the tick positions, ticks/labels, title).
328
+ furniture (grid lines exactly at the tick positions, the axes crossing at the origin
329
+ with their labels on them, the axis pinning to the edge once the origin is panned
330
+ away, the title).
238
331
  - `test_widget.py`: `MathPlotWidget` coexisting with neighbouring widgets — the plot
239
332
  appearing inside a layout, an expression change / an invalid expression keeping the
240
333
  previous graph, curves added/hidden/removed through the model, **the wheel over a
@@ -244,6 +337,17 @@ QSG_RHI_BACKEND=opengl uv run pytest -m gui
244
337
  needs one, and a process may only have one).
245
338
  - `test_qtquick_coexistence.py`: the same drag conflict in Qt Quick (a `Flickable`
246
339
  must not steal the pan).
340
+ - `test_handoff.py`: the matplotlib/sympy hand-off — the figure reflects the limits, aspect,
341
+ `adjustable`, grid, title, spines at zero and per-curve style; the sampling is uniform and NaN
342
+ outside the domain (so `log(x)` breaks); `savefig(backend="matplotlib")` writes real `.svg`,
343
+ `.pdf` and `.png`; `to_sympy().save()` writes a file and keeps per-curve colours; importing
344
+ `qmlmathplot` does not import matplotlib, and hiding matplotlib produces an error naming the
345
+ extra.
346
+ - `test_export.py`: `to_image()`/`savefig()` — the image is exactly the requested device size
347
+ (`dpi` multiplies it), the no-argument form reproduces the live centre and scale, the three
348
+ `adjustable` modes reconcile range and size as documented (a world 45-degree line stays at 45
349
+ in `"box"`, with background margins; `"stretch"` fills the frame and distorts), the live
350
+ camera and background survive an export, and `transparent` clears to alpha 0.
247
351
 
248
352
  ## Pitfalls near the `sin(1/x)` singularity (fixed — do not repeat)
249
353
 
@@ -344,6 +448,12 @@ measurement that settled each one). The Qt/packaging ones that shaped the code:
344
448
  same x 9 times, and macros are preprocessor expansion with no call semantics.
345
449
  - PySide ties a property's notify signal to the `Signal` **object**; a name string
346
450
  silently leaves the property without one (and QML bindings then never refresh).
451
+ - Snap **hairlines** (grid) to a half pixel, but never a **styled** line: rounding the
452
+ axis position put the 1.07 px y-axis half off the centre column, so the line came out
453
+ pale where it should be black.
454
+ - `QQuickWidget.setProperty(name, …)` sets it on the **widget**, not on the QML root (the
455
+ widget has no such property, so it silently does nothing): inject the model with
456
+ `widget.rootObject().setProperty("plot", plot)`, as `MathPlotWidget` does.
347
457
 
348
458
  ## Releasing
349
459
 
@@ -365,6 +475,12 @@ be rehearsed:
365
475
  pip install -i https://test.pypi.org/simple/ qmlmathplot
366
476
  ```
367
477
 
478
+ If the publish step fails with `invalid-publisher: valid token, but no corresponding
479
+ publisher`, the trusted-publisher entry is missing or does not match. The action prints the
480
+ claims it presented (`repository`, `workflow_ref`, `environment`) — the entry must match them
481
+ exactly, **including the environment name**, and it must exist on the index the run targets: a
482
+ manual run goes to **test.pypi.org**, a tag push to **pypi.org**, so both need an entry.
483
+
368
484
  The workflow runs on Windows: that is the platform this library is verified on (the shader baker
369
485
  `qsb` ships inside the PySide6 wheel and is only confirmed to be present there, see `qsb.py`),
370
486
  and the GUI tests need a GPU scene graph no runner has. The artifact is `py3-none-any`, so the
@@ -1,9 +1,9 @@
1
1
  [project]
2
2
  name = "qmlmathplot"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  description = "Embeddable function plotter for Qt Quick / QtWidgets (implicit per-pixel drawing, MVVM)"
5
5
  readme = "README.md"
6
- requires-python = ">=3.14"
6
+ requires-python = ">=3.11"
7
7
  dependencies = [
8
8
  "sympy>=1.14",
9
9
  "PySide6>=6.7",
@@ -13,6 +13,9 @@ dependencies = [
13
13
  name = "I-AM-A-NOOB"
14
14
  email = "suzc08@outlook.com"
15
15
 
16
+ [project.optional-dependencies]
17
+ matplotlib = ["matplotlib>=3.8"]
18
+
16
19
  [project.scripts]
17
20
  qmlmathplot = "qmlmathplot.app:main"
18
21
 
@@ -21,7 +24,10 @@ requires = ["uv_build>=0.12.18,<0.13.0"]
21
24
  build-backend = "uv_build"
22
25
 
23
26
  [dependency-groups]
24
- dev = ["pytest>=9.1.1"]
27
+ dev = [
28
+ "pytest>=9.1.1",
29
+ "matplotlib>=3.8",
30
+ ]
25
31
 
26
32
  [tool.pytest.ini_options]
27
33
  testpaths = ["tests"]
@@ -1,17 +1,22 @@
1
1
  [project]
2
2
  name = "qmlmathplot"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  description = "Embeddable function plotter for Qt Quick / QtWidgets (implicit per-pixel drawing, MVVM)"
5
5
  readme = "README.md"
6
6
  authors = [
7
7
  { name = "I-AM-A-NOOB", email = "suzc08@outlook.com" }
8
8
  ]
9
- requires-python = ">=3.14"
9
+ requires-python = ">=3.11"
10
10
  dependencies = [
11
11
  "sympy>=1.14",
12
12
  "PySide6>=6.7",
13
13
  ]
14
14
 
15
+ [project.optional-dependencies]
16
+ # The matplotlib hand-off (Plot.to_matplotlib / savefig(backend="matplotlib")) is optional:
17
+ # nothing in the package imports matplotlib until that path is used.
18
+ matplotlib = ["matplotlib>=3.8"]
19
+
15
20
  [project.scripts]
16
21
  qmlmathplot = "qmlmathplot.app:main"
17
22
 
@@ -22,6 +27,7 @@ build-backend = "uv_build"
22
27
  [dependency-groups]
23
28
  dev = [
24
29
  "pytest>=9.1.1",
30
+ "matplotlib>=3.8", # the hand-off tests need it (the feature itself stays optional)
25
31
  ]
26
32
 
27
33
  [tool.pytest.ini_options]
@@ -219,6 +219,13 @@ class Camera(QObject):
219
219
  xlim: QVector2D = Property("QVariant", _get_xlim, _set_xlim, notify=viewChanged)
220
220
  ylim: QVector2D = Property("QVariant", _get_ylim, _set_ylim, notify=viewChanged)
221
221
 
222
+ def _get_viewport(self) -> QVector2D:
223
+ width, height = self._effective_size()
224
+ return QVector2D(width, height)
225
+
226
+ #: Last reported size in logical pixels (the reference size before the first report).
227
+ viewport: QVector2D = Property("QVariant", _get_viewport, notify=viewChanged)
228
+
222
229
  # --------------------------------------------------------------- ticks
223
230
  def tick_values(self) -> tuple[list[tuple[float, str]], list[tuple[float, str]]]:
224
231
  """Ticks for both axes of the visible range, as ``(value, label)`` pairs."""
@@ -280,6 +287,27 @@ class Camera(QObject):
280
287
  self.viewChanged.emit()
281
288
  self.ticksChanged.emit()
282
289
 
290
+ @Slot(float, float, float, float)
291
+ def set_view(self, centre_x: float, centre_y: float, scale_x: float, scale_y: float) -> None:
292
+ """Set the centre and *both* scales at once (world units per logical pixel).
293
+
294
+ The camera normally derives the y scale from ``aspect``; this is the primitive for a
295
+ caller that has two scales in hand — the exporter borrowing the camera for a render,
296
+ and restoring it afterwards. ``"auto"`` stays "auto" (the scales are simply set), a
297
+ numeric aspect is re-derived from the pair so the two scales are exactly as asked.
298
+ """
299
+ scale_x = min(max(float(scale_x), self.MIN_SCALE), self.MAX_SCALE)
300
+ scale_y = min(max(float(scale_y), self.MIN_SCALE), self.MAX_SCALE)
301
+ self._centre = QVector2D(float(centre_x), float(centre_y))
302
+ self._zoom = scale_x
303
+ if isinstance(self._aspect, str):
304
+ self._y_scale = scale_y
305
+ else:
306
+ self._aspect = scale_x / scale_y
307
+ self.aspectChanged.emit()
308
+ self._home = False
309
+ self._view_changed()
310
+
283
311
  # --------------------------------------------------------------- slots
284
312
  @Slot(float, float)
285
313
  def setViewport(self, width: float, height: float) -> None:
@@ -25,9 +25,11 @@ from .qsb import bake
25
25
 
26
26
  __all__ = ["Curve", "CurveListModel"]
27
27
 
28
- #: First colour of the default cycle (also the classic single-curve colour).
29
- DEFAULT_COLOR = "#33ccff"
30
- DEFAULT_COLOR_CYCLE = ("#33ccff", "#ff8866", "#7ddc7d", "#e0c341", "#c98bff")
28
+ #: First colour of the default cycle (matplotlib's "tab10" blue, as in its default style).
29
+ DEFAULT_COLOR = "#1f77b4"
30
+ #: matplotlib's default ten-colour cycle.
31
+ DEFAULT_COLOR_CYCLE = ("#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd",
32
+ "#8c564b", "#e377c2", "#7f7f7f", "#bcbd22", "#17becf")
31
33
 
32
34
 
33
35
  def _color(value: object) -> QColor: