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.
Files changed (49) hide show
  1. {qmlmathplot-0.1.0 → qmlmathplot-0.3.0}/PKG-INFO +196 -18
  2. {qmlmathplot-0.1.0 → qmlmathplot-0.3.0}/README.md +192 -16
  3. {qmlmathplot-0.1.0 → qmlmathplot-0.3.0}/pyproject.toml +9 -3
  4. {qmlmathplot-0.1.0 → qmlmathplot-0.3.0}/pyproject.toml.orig +8 -2
  5. {qmlmathplot-0.1.0 → qmlmathplot-0.3.0}/src/qmlmathplot/__init__.py +2 -1
  6. qmlmathplot-0.3.0/src/qmlmathplot/__main__.py +13 -0
  7. {qmlmathplot-0.1.0 → qmlmathplot-0.3.0}/src/qmlmathplot/app.py +6 -3
  8. {qmlmathplot-0.1.0 → qmlmathplot-0.3.0}/src/qmlmathplot/camera.py +28 -0
  9. qmlmathplot-0.3.0/src/qmlmathplot/curve.py +453 -0
  10. qmlmathplot-0.3.0/src/qmlmathplot/export.py +174 -0
  11. {qmlmathplot-0.1.0 → qmlmathplot-0.3.0}/src/qmlmathplot/model.py +185 -29
  12. qmlmathplot-0.3.0/src/qmlmathplot/mpl.py +235 -0
  13. qmlmathplot-0.3.0/src/qmlmathplot/plot.py +396 -0
  14. {qmlmathplot-0.1.0 → qmlmathplot-0.3.0}/src/qmlmathplot/qml/PlotView.qml +52 -11
  15. qmlmathplot-0.3.0/src/qmlmathplot/themes/default.mplstyle +26 -0
  16. {qmlmathplot-0.1.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes.py +11 -6
  17. {qmlmathplot-0.1.0 → qmlmathplot-0.3.0}/src/qmlmathplot/widget.py +15 -10
  18. qmlmathplot-0.1.0/src/qmlmathplot/curve.py +0 -296
  19. qmlmathplot-0.1.0/src/qmlmathplot/plot.py +0 -213
  20. {qmlmathplot-0.1.0 → qmlmathplot-0.3.0}/src/qmlmathplot/qsb.py +0 -0
  21. {qmlmathplot-0.1.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/LICENSE.matplotlib +0 -0
  22. {qmlmathplot-0.1.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/README.md +0 -0
  23. {qmlmathplot-0.1.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/Solarize_Light2.mplstyle +0 -0
  24. {qmlmathplot-0.1.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/bmh.mplstyle +0 -0
  25. {qmlmathplot-0.1.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/classic.mplstyle +0 -0
  26. {qmlmathplot-0.1.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/dark_background.mplstyle +0 -0
  27. {qmlmathplot-0.1.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/fast.mplstyle +0 -0
  28. {qmlmathplot-0.1.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/fivethirtyeight.mplstyle +0 -0
  29. {qmlmathplot-0.1.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/ggplot.mplstyle +0 -0
  30. {qmlmathplot-0.1.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/grayscale.mplstyle +0 -0
  31. {qmlmathplot-0.1.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/petroff10.mplstyle +0 -0
  32. {qmlmathplot-0.1.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/seaborn-v0_8-bright.mplstyle +0 -0
  33. {qmlmathplot-0.1.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/seaborn-v0_8-colorblind.mplstyle +0 -0
  34. {qmlmathplot-0.1.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/seaborn-v0_8-dark-palette.mplstyle +0 -0
  35. {qmlmathplot-0.1.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/seaborn-v0_8-dark.mplstyle +0 -0
  36. {qmlmathplot-0.1.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/seaborn-v0_8-darkgrid.mplstyle +0 -0
  37. {qmlmathplot-0.1.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/seaborn-v0_8-deep.mplstyle +0 -0
  38. {qmlmathplot-0.1.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/seaborn-v0_8-muted.mplstyle +0 -0
  39. {qmlmathplot-0.1.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/seaborn-v0_8-notebook.mplstyle +0 -0
  40. {qmlmathplot-0.1.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/seaborn-v0_8-paper.mplstyle +0 -0
  41. {qmlmathplot-0.1.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/seaborn-v0_8-pastel.mplstyle +0 -0
  42. {qmlmathplot-0.1.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/seaborn-v0_8-poster.mplstyle +0 -0
  43. {qmlmathplot-0.1.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/seaborn-v0_8-talk.mplstyle +0 -0
  44. {qmlmathplot-0.1.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/seaborn-v0_8-ticks.mplstyle +0 -0
  45. {qmlmathplot-0.1.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/seaborn-v0_8-white.mplstyle +0 -0
  46. {qmlmathplot-0.1.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/seaborn-v0_8-whitegrid.mplstyle +0 -0
  47. {qmlmathplot-0.1.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/seaborn-v0_8.mplstyle +0 -0
  48. {qmlmathplot-0.1.0 → qmlmathplot-0.3.0}/src/qmlmathplot/themes/tableau-colorblind10.mplstyle +0 -0
  49. {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.1.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-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,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 (not
139
- implemented yet).
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 limits
241
- are forwarded to the camera, and a theme applies / an unknown name is rejected.
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, ticks/labels, title).
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**: via the **analytic pole gap** (denominator sign change, world
289
- units) — no vertical connector is drawn at the asymptotes of `1/x`, `tan(x)`.
290
- (An earlier "adjacent samples differ by more than 4 viewport heights" criterion
291
- was pixel-/viewport-relative, so under magnification it also cut legitimate steep
292
- segments; it was removed.)
448
+ - **Break on jumps**: a denominator sign change (world units) decides, in three places,
449
+ that nothing joins the branches — the polyline segment that straddles the pole is not
450
+ stroked, a straddling pair does not count as a turn in the band's turn count (it used to
451
+ fill the pole column solid), and the unbounded-boundary ray is not drawn there. No vertical
452
+ connector at the asymptotes of `1/x`, `tan(x)`. (An earlier "adjacent samples differ by
453
+ more than 4 viewport heights" criterion was pixel-/viewport-relative, so under magnification
454
+ it also cut legitimate steep segments; it was removed. The remaining "spread beyond 32
455
+ viewport heights" gap is a second line of defence only — at `tan`'s pole column the spread
456
+ is ~15 viewports, so it never fires there.)
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. NaN/inf samples take part in neither the envelope nor
296
- segment joining.
459
+ are discarded outright — except for the **boundary ray** of a slowly diverging edge
460
+ (`log(x)` as x→0+), which is a band centred *on* the boundary and therefore paints the few
461
+ pixels outside the domain that it covers (the whole outer half of that band, otherwise).
462
+ NaN/inf samples take part in neither the envelope nor segment joining.
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 (not
128
- implemented yet).
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 limits
230
- are forwarded to the camera, and a theme applies / an unknown name is rejected.
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, ticks/labels, title).
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**: via the **analytic pole gap** (denominator sign change, world
278
- units) — no vertical connector is drawn at the asymptotes of `1/x`, `tan(x)`.
279
- (An earlier "adjacent samples differ by more than 4 viewport heights" criterion
280
- was pixel-/viewport-relative, so under magnification it also cut legitimate steep
281
- segments; it was removed.)
435
+ - **Break on jumps**: a denominator sign change (world units) decides, in three places,
436
+ that nothing joins the branches — the polyline segment that straddles the pole is not
437
+ stroked, a straddling pair does not count as a turn in the band's turn count (it used to
438
+ fill the pole column solid), and the unbounded-boundary ray is not drawn there. No vertical
439
+ connector at the asymptotes of `1/x`, `tan(x)`. (An earlier "adjacent samples differ by
440
+ more than 4 viewport heights" criterion was pixel-/viewport-relative, so under magnification
441
+ it also cut legitimate steep segments; it was removed. The remaining "spread beyond 32
442
+ viewport heights" gap is a second line of defence only — at `tan`'s pole column the spread
443
+ is ~15 viewports, so it never fires there.)
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. NaN/inf samples take part in neither the envelope nor
285
- segment joining.
446
+ are discarded outright — except for the **boundary ray** of a slowly diverging edge
447
+ (`log(x)` as x→0+), which is a band centred *on* the boundary and therefore paints the few
448
+ pixels outside the domain that it covers (the whole outer half of that band, otherwise).
449
+ NaN/inf samples take part in neither the envelope nor segment joining.
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