qmlmathplot 0.4.1__tar.gz → 0.4.3__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 (59) hide show
  1. {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/PKG-INFO +115 -24
  2. {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/README.md +114 -23
  3. {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/pyproject.toml +1 -1
  4. {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/pyproject.toml.orig +1 -1
  5. {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/__init__.py +3 -0
  6. {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/curve.py +5 -0
  7. {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/export.py +5 -1
  8. {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/figure.py +88 -14
  9. {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/model.py +36 -8
  10. {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/mpl.py +67 -6
  11. qmlmathplot-0.4.3/src/qmlmathplot/overlay.py +426 -0
  12. {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/plot.py +232 -3
  13. {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/pyplot.py +40 -0
  14. {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/qml/PlotFurniture.qml +156 -20
  15. {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/qml/PlotInput.qml +4 -0
  16. qmlmathplot-0.4.3/src/qmlmathplot/qml/PlotLegend.qml +66 -0
  17. qmlmathplot-0.4.3/src/qmlmathplot/qml/PlotOverlay.qml +111 -0
  18. {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/qml/PlotView.qml +14 -3
  19. {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/qsb.py +6 -0
  20. {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/widget.py +3 -3
  21. {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/__main__.py +0 -0
  22. {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/animation.py +0 -0
  23. {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/app.py +0 -0
  24. {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/camera.py +0 -0
  25. {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/canvas.py +0 -0
  26. {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/input.py +0 -0
  27. {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/interval.py +0 -0
  28. {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/qml/PlotCurves.qml +0 -0
  29. {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/themes/LICENSE.matplotlib +0 -0
  30. {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/themes/README.md +0 -0
  31. {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/themes/Solarize_Light2.mplstyle +0 -0
  32. {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/themes/bmh.mplstyle +0 -0
  33. {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/themes/classic.mplstyle +0 -0
  34. {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/themes/dark_background.mplstyle +0 -0
  35. {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/themes/default.mplstyle +0 -0
  36. {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/themes/fast.mplstyle +0 -0
  37. {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/themes/fivethirtyeight.mplstyle +0 -0
  38. {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/themes/ggplot.mplstyle +0 -0
  39. {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/themes/grayscale.mplstyle +0 -0
  40. {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/themes/petroff10.mplstyle +0 -0
  41. {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/themes/seaborn-v0_8-bright.mplstyle +0 -0
  42. {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/themes/seaborn-v0_8-colorblind.mplstyle +0 -0
  43. {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/themes/seaborn-v0_8-dark-palette.mplstyle +0 -0
  44. {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/themes/seaborn-v0_8-dark.mplstyle +0 -0
  45. {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/themes/seaborn-v0_8-darkgrid.mplstyle +0 -0
  46. {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/themes/seaborn-v0_8-deep.mplstyle +0 -0
  47. {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/themes/seaborn-v0_8-muted.mplstyle +0 -0
  48. {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/themes/seaborn-v0_8-notebook.mplstyle +0 -0
  49. {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/themes/seaborn-v0_8-paper.mplstyle +0 -0
  50. {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/themes/seaborn-v0_8-pastel.mplstyle +0 -0
  51. {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/themes/seaborn-v0_8-poster.mplstyle +0 -0
  52. {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/themes/seaborn-v0_8-talk.mplstyle +0 -0
  53. {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/themes/seaborn-v0_8-ticks.mplstyle +0 -0
  54. {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/themes/seaborn-v0_8-white.mplstyle +0 -0
  55. {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/themes/seaborn-v0_8-whitegrid.mplstyle +0 -0
  56. {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/themes/seaborn-v0_8.mplstyle +0 -0
  57. {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/themes/tableau-colorblind10.mplstyle +0 -0
  58. {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/themes.py +0 -0
  59. {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/view.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: qmlmathplot
3
- Version: 0.4.1
3
+ Version: 0.4.3
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>
@@ -143,11 +143,13 @@ The model:
143
143
 
144
144
  | Object | Members |
145
145
  |---|---|
146
- | `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`, `xlabel`, `ylabel`, `title_color`, `title_font_size`, `title_bold`, `ticks_visible`, `theme` |
146
+ | `Plot` | `camera`, `curves`, `annotations`, `guides`, `xlim`/`ylim`/`aspect` (forwarded), `add_curve()` / `add_implicit()` / `remove_curve()`, `annotate()` / `add_text()` / `add_hline()` / `add_vline()`, `set_xticks()` / `set_yticks()`, `available_themes`, and the style properties: `background`, `grid`, `grid_x`, `grid_y`, `grid_color`, `grid_width`, `grid_alpha`, `grid_style`, `axis_color`, `axis_width`, `axes_position`, `color_cycle`, `line_width`, `text_color`, `tick_color`, `tick_length`, `tick_width`, `tick_pad`, `ticks_top`, `ticks_right`, `minor_ticks`, `spine_top` / `spine_bottom` / `spine_left` / `spine_right`, `font_size`, `tick_font_size`, `title`, `xlabel`, `ylabel`, `title_color`, `title_font_size`, `title_bold`, `legend`, `legend_position`, `ticks_visible`, `theme` |
147
147
  | `Camera` | `centre`, `zoom` (world units per logical pixel), `aspect`, `xlim`/`ylim` (derived), `ticks_x`/`ticks_y`, `zoomStep` / `panEnabled` / `zoomEnabled`, `animationDuration`, `input` (a `ViewInput`), slots `set_viewport(w, h)` / `zoom_by(delta, u, v, w, h)` / `zoom_at(factor, u, v, w, h)` / `pan_pixels(dx, dy)` / `animate_zoom(...)` / `animate_pan(dx, dy, ms)` / `stop_animation()` / `reset()` |
148
148
  | `ViewInput` | `keyboardEnabled`, `keyPanStep`, `keyPanRepeatInterval`, `panLeftShortcut` / `panRightShortcut` / `panUpShortcut` / `panDownShortcut` / `zoomInShortcut` / `zoomOutShortcut`, `matches(key, modifiers, shortcut)` |
149
149
  | `Curve` | `expression` (string or sympy expression), `expr` (the parsed expression, read-only), `implicit`, `color`, `lineWidth`, `visible`, `label`, `error` (read-only), the baked `vertexShader` / `fragmentShader` |
150
- | `CurveListModel` | the curves as a model (`curve`, `expression`, `color`, `lineWidth`, `visible`, `vertexShader`, `fragmentShader` roles) so QML puts one `ShaderEffect` behind each |
150
+ | `CurveListModel` | the curves as a model (`curve`, `expression`, `color`, `lineWidth`, `visible`, `label`, `vertexShader`, `fragmentShader` roles) so QML puts one `ShaderEffect` behind each |
151
+ | `Annotation` | `text`, `xy` (world), `xytext` (pixel offset), `arrow`, `color`, `fontSize`, `visible` |
152
+ | `Guide` | `kind` (`"h"`/`"v"`), `value`, `color`, `width`, `style`, `visible` |
151
153
 
152
154
  Every setter is a Qt `Property` with a notify signal, so the same calls work from QML, from a
153
155
  Qt Designer slot or from a `QTimer`:
@@ -210,8 +212,10 @@ plt.show() # blocks in a script; use block=False in
210
212
  ```
211
213
 
212
214
  What maps: one figure/one axes (`subplots()` and `subplot(111)`), `plot(expression)`,
213
- `xlim`/`ylim`, `title`/`xlabel`/`ylabel`, `grid`, `aspect`, `savefig`/`to_image`, themes.
214
- `plt.gcf()`/`gca()`/`sca()`/`close()`/`show()` follow Matplotlib's current-figure shape.
215
+ `xlim`/`ylim`, `title`/`xlabel`/`ylabel`, `grid(axis=...)`, `aspect`, `savefig`/`to_image`,
216
+ themes, `legend`, `annotate`/`text`, `axhline`/`axvline`, `set_xticks`/`set_yticks` and
217
+ `tick_params`. `plt.gcf()`/`gca()`/`sca()`/`close()`/`show()` follow Matplotlib's
218
+ current-figure shape.
215
219
 
216
220
  What is deliberately different:
217
221
 
@@ -224,8 +228,9 @@ What is deliberately different:
224
228
  deferred renderer or blitting.
225
229
  - **`savefig` is a region at a pixel size**, not a page at a dpi. `width`/`height` are device
226
230
  pixels and `dpi` is a multiplier; `backend="matplotlib"` is the vector route.
227
- - **No legends, annotations, log axes or manual ticks yet.** `legend()`, `annotate()`,
228
- `set_xscale()` and `set_xticks()` raise with the reason; chrome belongs to the host.
231
+ - **Log axes and full annotation styling are not implemented.** `set_xscale()`/`set_yscale()`
232
+ raise with the reason; annotations support text + a straight arrow + pixel offset, not
233
+ Matplotlib's bbox/connectionstyle engine.
229
234
  - **Themes are per figure**, not `rcParams`; `plt` has no global style state beyond the current
230
235
  figure.
231
236
  - **`mpl_connect`/`blit` are absent.** Qt signals are the event system.
@@ -276,8 +281,11 @@ corner is in the domain and no pole factor changes sign between them; the edge c
276
281
  interpolated and the cell's contour segment is stroked (the saddle case is paired using the cell
277
282
  centre's sign). The first-order/Newton path still supplies the sub-pixel width; the cell term
278
283
  adds the coverage of a thin or tangent branch whose centre projection fails. A **singular cell**
279
- (`|Fx| + |Fy| < 1e-3` in world units, plus `|F| < 0.05`) gets a small disc, so an isolated point
280
- such as `x**2 + y**2 = 0` is not left to the projection alone.
284
+ (`|Fx| + |Fy| < 1e-3` in world units, `|F| < 0.05`, **and** `|F| < R·|grad F|` in pixels) gets
285
+ a small disc, so an isolated point such as `x**2 + y**2 = 0` is not left to the projection alone.
286
+ The last test is the scale-invariant one: it requires the point to be on the set to first order,
287
+ so a scaled expression such as `(x+y)/(x·y)` — whose F and gradient are tiny far from its line —
288
+ no longer fills whole quadrants with the disc.
281
289
 
282
290
  Still not ported: GGB's Bernstein subdivision (**polynomial-only**), its adaptive quad-tree
283
291
  refinement and the `ContourAssembler`/`ContourLinker` (they exist to produce a selectable vector
@@ -312,8 +320,9 @@ plot.plot.camera.zoom_by(120, 0.5, 0.5, w, h) # one wheel notch, anchored at
312
320
 
313
321
  ## Keyboard, touch and animation
314
322
 
315
- Clicking the plot gives it focus; while it has focus the arrow keys pan (10% of the viewport
316
- per press by default), `+`/`=` zooms in and `-`/`_` zooms out. Held keys advance continuously:
323
+ Clicking the plot or pressing Tab while it is in the focus chain gives it focus; while it has
324
+ focus the arrow keys pan (10% of the viewport per press by default), `+`/`=` zooms in and
325
+ `-`/`_` zooms out. Held keys advance continuously:
317
326
  one `keyPanStep` per `keyPanRepeatInterval` (100 ms by default), integrated every frame, so
318
327
  there is no OS key-repeat delay or granular stepping. Several arrows combine, so `Left+Up`
319
328
  moves diagonally at the normal speed. Every command is a property on the camera's `input`
@@ -359,7 +368,7 @@ plot.available_themes # the names, for a host's picker (both dem
359
368
  The 26 bundled sheets are Matplotlib's own styles, vendored verbatim into
360
369
  `qmlmathplot/themes/*.mplstyle` and converted at load time: Matplotlib's `rcParams` keys are
361
370
  mapped onto this library's property names, and keys describing things this library does not
362
- have (spines, figure size, dpi, legend, marker styles, …) are ignored. `themes.names()` lists
371
+ have (figure size, dpi, legend, marker styles, …) are ignored. `themes.names()` lists
363
372
  them (`"default"` plus the 26 sheets). An unknown name raises `KeyError` and leaves the current
364
373
  theme alone.
365
374
 
@@ -371,13 +380,77 @@ theme alone.
371
380
  `x = 0` — drawn in `axis_color` / `axis_width`, and the **tick marks and labels ride those
372
381
  axes** instead of hugging the item's edges. When 0 is off screen the axis sticks to the
373
382
  nearest edge, so its labels are never lost (and a label never leaves the item); the grid
374
- stays where it is. `plot.axes_position = "edge"` restores the edge-pinned behaviour.
383
+ stays where it is. `plot.axes_position = "edge"` keeps the labels at the edges without
384
+ drawing axis lines.
385
+ - `plot.axes_position = "box"` draws the four Matplotlib-style **spines** around the viewport
386
+ (same `axis_color` / `axis_width`), with ticks and labels on the bottom/left edges. It is a
387
+ property, not a separate theme, so it composes with any theme:
388
+ ```python
389
+ plot.axes_position = "box"
390
+ plot.grid = True
391
+ ```
392
+ - Furniture is part of the same QML scene, so `to_image()` / `savefig()` capture the box,
393
+ grid, ticks, labels and title exactly as the live view renders them (there is an export
394
+ regression test for the four spines).
375
395
  - `camera.viewport` reports the last size the view sent, so a host can size an export from it.
376
396
  - The tick *values* are nice numbers computed in Python (`Camera.tick_values()` /
377
397
  `nice_ticks()`, steps of 1/2/5 x 10^n) and pushed through `ticksChanged`; QML only positions
378
398
  and formats them, so a frame never recomputes ticks.
379
399
  - `plot.ticks_visible = False` hides the whole furniture (axes, marks and labels).
380
400
 
401
+ ## Annotations, legend and guides
402
+
403
+ Annotation elements are **model-driven**, not host QML: the same objects render in the live view
404
+ and in `to_image()` / `savefig()`.
405
+
406
+ ```python
407
+ plot.annotate("pole", xy=(0.0, 0.0), xytext=(24, -18), arrowprops={"color": "#333333"})
408
+ plot.text(2.0, 1.0, "f(2)=1", color="#333333", font_size=12)
409
+ plot.add_hline(0.5, color="#888888", style="--") # horizontal guide
410
+ plot.add_vline(1.0, color="#888888", style=":") # vertical guide
411
+
412
+ plot.add_curve("sin(x)", label="sin")
413
+ plot.add_curve("cos(x)", label="cos")
414
+ plot.legend = True
415
+ plot.legend_position = "upper right" # best | upper left | lower right | lower left
416
+
417
+ plot.set_xticks([-3, 0, 3], ["-3", "0", "3"]) # explicit positions + labels
418
+ plot.set_yticks([-1, 1])
419
+ plot.grid_x = True
420
+ plot.grid_y = False # per-axis grid
421
+ plot.tick_width = 1.5
422
+ plot.tick_pad = 4.0
423
+ plot.ticks_top = True
424
+ plot.ticks_right = True
425
+ plot.minor_ticks = True # four minor marks per major interval
426
+ plot.spine_top = False # "box" mode only
427
+ ```
428
+
429
+ `xy` is in world coordinates and `xytext` is a **logical-pixel offset** from it, so annotations
430
+ stay attached to the data while the label keeps a stable screen offset. `Annotation` and `Guide`
431
+ are also exposed as objects (`plot.annotations.at(i)`, `plot.guides.at(i)`) for per-item edits.
432
+
433
+ The Matplotlib facade forwards the same surface:
434
+
435
+ ```python
436
+ ax.annotate("pole", xy=(0, 0), xytext=(24, -18), arrowprops={"color": "#333"})
437
+ ax.text(2, 1, "f(2)=1")
438
+ ax.legend(loc="lower right")
439
+ ax.set_xticks([-3, 0, 3], labels=["-3", "0", "3"])
440
+ ax.tick_params(axis="x", length=6, width=1.5, top=True)
441
+ ax.grid(True, axis="x")
442
+ ax.axhline(0.5, color="#888888")
443
+ ax.axvline(1.0, color="#888888")
444
+ ```
445
+
446
+ `plt.annotate` / `plt.text` / `plt.legend` / `plt.xticks` / `plt.yticks` /
447
+ `plt.tick_params` / `plt.axhline` / `plt.axvline` forward to the current axes. The
448
+ `to_matplotlib()` hand-off maps labels, legend, annotations, guides and explicit ticks as well.
449
+
450
+ The annotation/guide/legend layers are part of the QML scene. The export path now drains the
451
+ queued Canvas paints before the synchronous readback, so the grid, annotations and guides are
452
+ present in screenshots too (previously a Canvas-painted grid could miss the grab).
453
+
381
454
  ## Export
382
455
 
383
456
  `savefig()` writes a file, `to_image()` returns a `QImage`; both take the same keywords:
@@ -487,7 +560,7 @@ The supported path is GeoGebra's column idea implemented on the GPU: sample the
487
560
  sub-pixel x grid, stroke the polyline, and fill the exact interval enclosure where a column
488
561
  cannot be represented by a line.
489
562
 
490
- 1. **Sub-pixel polyline stroke.** Each pixel evaluates `F` at **16 samples over ±2 columns**
563
+ 1. **Sub-pixel polyline stroke.** Each pixel evaluates `F` at **14 samples over ±2 columns**
491
564
  (0.25 logical-px spacing). Adjacent valid samples form a polyline, and coverage is
492
565
  `R - distance to the nearest segment` — Desmos' stroke rule. The sample spacing is
493
566
  sub-pixel, so the vertices themselves are sub-pixel and the segments are diagonal: the
@@ -522,7 +595,7 @@ cannot be represented by a line.
522
595
  viewport bottom; no filled ray.
523
596
  - **whole**: `DrawInterval.drawWhole` — one vertical segment at `x.low`.
524
597
  Deliberate deviations, because this is a fragment shader and not the Java tuple list: the
525
- 16-sample polyline supersamples GGB's one interval per column for resolved stretches; a
598
+ 14-sample polyline supersamples GGB's one interval per column for resolved stretches; a
526
599
  dependent quotient's whole column is still suppressed (the fixed-x polyline draws the real
527
600
  branches, avoiding the `x/sqrt(x**2)` connector); and a joined inverted column uses those
528
601
  sample branches instead of replaying `JoinLines.inverted`'s neighbour decisions.
@@ -530,7 +603,7 @@ cannot be represented by a line.
530
603
  never evaluated; a pixel just outside the domain still gets the band, which is centred
531
604
  on the boundary.
532
605
 
533
- An ordinary pixel costs **16 `F` evaluations** plus two interval evaluations; there is no
606
+ An ordinary pixel costs **14 `F` evaluations** plus two interval evaluations; there is no
534
607
  march, no tangent-foot projection and no column probe on the supported path. Expressions
535
608
  with an operation the interval backend does not implement (`floor`, `sign`, …) compile the
536
609
  legacy sampled band + ray + march path (`#define USE_INTERVAL 0`); it is the only user of
@@ -565,6 +638,8 @@ uv run ruff check src tests examples # bug-level lint (undefined names, unused
565
638
  matplotlib's default style, the limits are forwarded to the camera, and a theme applies
566
639
  (and re-applies as a reset) while an unknown name is rejected.
567
640
  - `test_themes.py`: the vendored style sheets resolve to this library's properties.
641
+ - `test_overlay.py`: annotations (`annotate`/`text`), horizontal/vertical guides, legend and
642
+ custom ticks, the `Axes`/`pyplot` forwarding, and the matplotlib hand-off mapping.
568
643
  - `test_qsb_cache.py`: the cache location/info/clear API and the age + LRU prune; unrelated
569
644
  files in the temp directory are not touched.
570
645
  - `test_shader_bake.py`: the stage of a baked `.qsb` must match its purpose, and
@@ -579,11 +654,11 @@ uv run ruff check src tests examples # bug-level lint (undefined names, unused
579
654
  appearing inside a layout, an expression change / an invalid expression keeping the
580
655
  previous graph, curves added/hidden/removed through the model, **the wheel over a
581
656
  scroll area being consumed by the plot (zoom instead of scrolling the parent)**, the
582
- fixed aspect being undistorted by the widget's shape, and the plot staying out of the
583
- Tab focus chain. `conftest.py` provides a session-scoped `QApplication` (QtWidgets
657
+ fixed aspect being undistorted by the widget's shape, and Tab reaching the plot and leaving
658
+ it again. `conftest.py` provides a session-scoped `QApplication` (QtWidgets
584
659
  needs one, and a process may only have one).
585
660
  - `test_qtquick_coexistence.py`: the same drag conflict in Qt Quick (a `Flickable`
586
- must not steal the pan).
661
+ must not steal the pan), plus Tab focus entering and leaving the QML plot.
587
662
  - `test_handoff.py`: the matplotlib/sympy hand-off — the figure reflects the limits, aspect,
588
663
  `adjustable`, grid, title, spines at zero and per-curve style; the sampling is uniform and NaN
589
664
  outside the domain (so `log(x)` breaks); `savefig(backend="matplotlib")` writes real `.svg`,
@@ -677,14 +752,27 @@ Startup to the first frame is about **230 ms**, attributed as follows
677
752
  Per-frame grab cycles (900×600, `grabToImage` — frame + readback, so an upper bound on
678
753
  the GPU cost), steady state on the same window: `sin(1/x)` default **13.6 ms**, `tan(x)`
679
754
  default **12.4 ms**, `sin(1/x)` at zoom 90 (worst case) **12.5 ms**. The previous
680
- march/interval renderer measured 27/34/33 ms on the same machine: 16 samples are cheaper
755
+ march/interval renderer measured 27/34/33 ms on the same machine: 14 samples are cheaper
681
756
  than the march's seed + tangent projection + walk + probe, and the march is now compiled
682
757
  only for the legacy path. The 1-D fragment source is about 21 KB.
683
758
 
684
- **Per-pixel `F` evaluations.** The supported path is fixed: 16 samples for the polyline
685
- (plus two interval evaluations for the column and the stroke footprint); there is no
686
- march budget and no band-window evaluation. The old fixed-grid/march table is superseded
687
- and no longer describes the shipped path.
759
+ **Per-pixel `F` evaluations.** The supported path uses 14 samples for the polyline in the live
760
+ view and in exports; two interval evaluations cover the column and the stroke footprint. There
761
+ is no march budget and no band-window evaluation. Anti-aliasing comes from the analytic
762
+ coverage ramp, not from the sample count; the samples only approximate the polyline. Sampling
763
+ was tuned by the render tests: 8 samples overfills the `sin(1/x)` transition and breaks the
764
+ `log` descent, 12 still overfills slightly, 14 passes.
765
+
766
+ An interval early-out over ±2 columns rejects background pixels before the sample loop: if the
767
+ pixel is farther than the stroke radius from the curve's y range in that window, it returns
768
+ transparent without evaluating `F`. Measured by alternating two live 900×600 views (15 pans
769
+ each, `grabToImage`, including readback): `sin(x)` 23.7 → 22.2 ms, `sin(1/x)` 23.6 → 21.0 ms,
770
+ `x**2` 22.7 → 20.4 ms. Whole/infinite columns keep the full path, so `tan`/`1/x` near a pole
771
+ are unchanged.
772
+
773
+ The stroke-footprint interval is evaluated only when the column interval is empty (the domain
774
+ boundary's outer half). On the common path this is two interval evaluations (`wide`, `own`)
775
+ instead of three: another 17.0 → 16.4 ms on `sin(x)` in the same two-view measurement.
688
776
 
689
777
  ## Shader cache
690
778
 
@@ -739,6 +827,9 @@ measurement that settled each one). The Qt/packaging ones that shaped the code:
739
827
  **device** pixels with an integral device width (`_deviceWidth`/`_snapLine`), not logical
740
828
  half-pixels: the 1.07-logical axis is 2 device px at DPR 1.5 and otherwise alternated
741
829
  between one and two rows as it drifted with the zoom.
830
+ - `qsb.exe` is a **console-subsystem** program. A windowed/frozen host (`pythonw`,
831
+ PyInstaller `--windowed`) would flash a cmd window on every cache-miss bake; `qsb.bake()`
832
+ passes `CREATE_NO_WINDOW` on Windows. `capture_output=True` alone does not prevent it.
742
833
  - `QQuickWidget.setProperty(name, …)` sets it on the **widget**, not on the QML root (the
743
834
  widget has no such property, so it silently does nothing): inject the model with
744
835
  `widget.rootObject().setProperty("plot", plot)`, as `MathPlotWidget` does.
@@ -130,11 +130,13 @@ The model:
130
130
 
131
131
  | Object | Members |
132
132
  |---|---|
133
- | `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`, `xlabel`, `ylabel`, `title_color`, `title_font_size`, `title_bold`, `ticks_visible`, `theme` |
133
+ | `Plot` | `camera`, `curves`, `annotations`, `guides`, `xlim`/`ylim`/`aspect` (forwarded), `add_curve()` / `add_implicit()` / `remove_curve()`, `annotate()` / `add_text()` / `add_hline()` / `add_vline()`, `set_xticks()` / `set_yticks()`, `available_themes`, and the style properties: `background`, `grid`, `grid_x`, `grid_y`, `grid_color`, `grid_width`, `grid_alpha`, `grid_style`, `axis_color`, `axis_width`, `axes_position`, `color_cycle`, `line_width`, `text_color`, `tick_color`, `tick_length`, `tick_width`, `tick_pad`, `ticks_top`, `ticks_right`, `minor_ticks`, `spine_top` / `spine_bottom` / `spine_left` / `spine_right`, `font_size`, `tick_font_size`, `title`, `xlabel`, `ylabel`, `title_color`, `title_font_size`, `title_bold`, `legend`, `legend_position`, `ticks_visible`, `theme` |
134
134
  | `Camera` | `centre`, `zoom` (world units per logical pixel), `aspect`, `xlim`/`ylim` (derived), `ticks_x`/`ticks_y`, `zoomStep` / `panEnabled` / `zoomEnabled`, `animationDuration`, `input` (a `ViewInput`), slots `set_viewport(w, h)` / `zoom_by(delta, u, v, w, h)` / `zoom_at(factor, u, v, w, h)` / `pan_pixels(dx, dy)` / `animate_zoom(...)` / `animate_pan(dx, dy, ms)` / `stop_animation()` / `reset()` |
135
135
  | `ViewInput` | `keyboardEnabled`, `keyPanStep`, `keyPanRepeatInterval`, `panLeftShortcut` / `panRightShortcut` / `panUpShortcut` / `panDownShortcut` / `zoomInShortcut` / `zoomOutShortcut`, `matches(key, modifiers, shortcut)` |
136
136
  | `Curve` | `expression` (string or sympy expression), `expr` (the parsed expression, read-only), `implicit`, `color`, `lineWidth`, `visible`, `label`, `error` (read-only), the baked `vertexShader` / `fragmentShader` |
137
- | `CurveListModel` | the curves as a model (`curve`, `expression`, `color`, `lineWidth`, `visible`, `vertexShader`, `fragmentShader` roles) so QML puts one `ShaderEffect` behind each |
137
+ | `CurveListModel` | the curves as a model (`curve`, `expression`, `color`, `lineWidth`, `visible`, `label`, `vertexShader`, `fragmentShader` roles) so QML puts one `ShaderEffect` behind each |
138
+ | `Annotation` | `text`, `xy` (world), `xytext` (pixel offset), `arrow`, `color`, `fontSize`, `visible` |
139
+ | `Guide` | `kind` (`"h"`/`"v"`), `value`, `color`, `width`, `style`, `visible` |
138
140
 
139
141
  Every setter is a Qt `Property` with a notify signal, so the same calls work from QML, from a
140
142
  Qt Designer slot or from a `QTimer`:
@@ -197,8 +199,10 @@ plt.show() # blocks in a script; use block=False in
197
199
  ```
198
200
 
199
201
  What maps: one figure/one axes (`subplots()` and `subplot(111)`), `plot(expression)`,
200
- `xlim`/`ylim`, `title`/`xlabel`/`ylabel`, `grid`, `aspect`, `savefig`/`to_image`, themes.
201
- `plt.gcf()`/`gca()`/`sca()`/`close()`/`show()` follow Matplotlib's current-figure shape.
202
+ `xlim`/`ylim`, `title`/`xlabel`/`ylabel`, `grid(axis=...)`, `aspect`, `savefig`/`to_image`,
203
+ themes, `legend`, `annotate`/`text`, `axhline`/`axvline`, `set_xticks`/`set_yticks` and
204
+ `tick_params`. `plt.gcf()`/`gca()`/`sca()`/`close()`/`show()` follow Matplotlib's
205
+ current-figure shape.
202
206
 
203
207
  What is deliberately different:
204
208
 
@@ -211,8 +215,9 @@ What is deliberately different:
211
215
  deferred renderer or blitting.
212
216
  - **`savefig` is a region at a pixel size**, not a page at a dpi. `width`/`height` are device
213
217
  pixels and `dpi` is a multiplier; `backend="matplotlib"` is the vector route.
214
- - **No legends, annotations, log axes or manual ticks yet.** `legend()`, `annotate()`,
215
- `set_xscale()` and `set_xticks()` raise with the reason; chrome belongs to the host.
218
+ - **Log axes and full annotation styling are not implemented.** `set_xscale()`/`set_yscale()`
219
+ raise with the reason; annotations support text + a straight arrow + pixel offset, not
220
+ Matplotlib's bbox/connectionstyle engine.
216
221
  - **Themes are per figure**, not `rcParams`; `plt` has no global style state beyond the current
217
222
  figure.
218
223
  - **`mpl_connect`/`blit` are absent.** Qt signals are the event system.
@@ -263,8 +268,11 @@ corner is in the domain and no pole factor changes sign between them; the edge c
263
268
  interpolated and the cell's contour segment is stroked (the saddle case is paired using the cell
264
269
  centre's sign). The first-order/Newton path still supplies the sub-pixel width; the cell term
265
270
  adds the coverage of a thin or tangent branch whose centre projection fails. A **singular cell**
266
- (`|Fx| + |Fy| < 1e-3` in world units, plus `|F| < 0.05`) gets a small disc, so an isolated point
267
- such as `x**2 + y**2 = 0` is not left to the projection alone.
271
+ (`|Fx| + |Fy| < 1e-3` in world units, `|F| < 0.05`, **and** `|F| < R·|grad F|` in pixels) gets
272
+ a small disc, so an isolated point such as `x**2 + y**2 = 0` is not left to the projection alone.
273
+ The last test is the scale-invariant one: it requires the point to be on the set to first order,
274
+ so a scaled expression such as `(x+y)/(x·y)` — whose F and gradient are tiny far from its line —
275
+ no longer fills whole quadrants with the disc.
268
276
 
269
277
  Still not ported: GGB's Bernstein subdivision (**polynomial-only**), its adaptive quad-tree
270
278
  refinement and the `ContourAssembler`/`ContourLinker` (they exist to produce a selectable vector
@@ -299,8 +307,9 @@ plot.plot.camera.zoom_by(120, 0.5, 0.5, w, h) # one wheel notch, anchored at
299
307
 
300
308
  ## Keyboard, touch and animation
301
309
 
302
- Clicking the plot gives it focus; while it has focus the arrow keys pan (10% of the viewport
303
- per press by default), `+`/`=` zooms in and `-`/`_` zooms out. Held keys advance continuously:
310
+ Clicking the plot or pressing Tab while it is in the focus chain gives it focus; while it has
311
+ focus the arrow keys pan (10% of the viewport per press by default), `+`/`=` zooms in and
312
+ `-`/`_` zooms out. Held keys advance continuously:
304
313
  one `keyPanStep` per `keyPanRepeatInterval` (100 ms by default), integrated every frame, so
305
314
  there is no OS key-repeat delay or granular stepping. Several arrows combine, so `Left+Up`
306
315
  moves diagonally at the normal speed. Every command is a property on the camera's `input`
@@ -346,7 +355,7 @@ plot.available_themes # the names, for a host's picker (both dem
346
355
  The 26 bundled sheets are Matplotlib's own styles, vendored verbatim into
347
356
  `qmlmathplot/themes/*.mplstyle` and converted at load time: Matplotlib's `rcParams` keys are
348
357
  mapped onto this library's property names, and keys describing things this library does not
349
- have (spines, figure size, dpi, legend, marker styles, …) are ignored. `themes.names()` lists
358
+ have (figure size, dpi, legend, marker styles, …) are ignored. `themes.names()` lists
350
359
  them (`"default"` plus the 26 sheets). An unknown name raises `KeyError` and leaves the current
351
360
  theme alone.
352
361
 
@@ -358,13 +367,77 @@ theme alone.
358
367
  `x = 0` — drawn in `axis_color` / `axis_width`, and the **tick marks and labels ride those
359
368
  axes** instead of hugging the item's edges. When 0 is off screen the axis sticks to the
360
369
  nearest edge, so its labels are never lost (and a label never leaves the item); the grid
361
- stays where it is. `plot.axes_position = "edge"` restores the edge-pinned behaviour.
370
+ stays where it is. `plot.axes_position = "edge"` keeps the labels at the edges without
371
+ drawing axis lines.
372
+ - `plot.axes_position = "box"` draws the four Matplotlib-style **spines** around the viewport
373
+ (same `axis_color` / `axis_width`), with ticks and labels on the bottom/left edges. It is a
374
+ property, not a separate theme, so it composes with any theme:
375
+ ```python
376
+ plot.axes_position = "box"
377
+ plot.grid = True
378
+ ```
379
+ - Furniture is part of the same QML scene, so `to_image()` / `savefig()` capture the box,
380
+ grid, ticks, labels and title exactly as the live view renders them (there is an export
381
+ regression test for the four spines).
362
382
  - `camera.viewport` reports the last size the view sent, so a host can size an export from it.
363
383
  - The tick *values* are nice numbers computed in Python (`Camera.tick_values()` /
364
384
  `nice_ticks()`, steps of 1/2/5 x 10^n) and pushed through `ticksChanged`; QML only positions
365
385
  and formats them, so a frame never recomputes ticks.
366
386
  - `plot.ticks_visible = False` hides the whole furniture (axes, marks and labels).
367
387
 
388
+ ## Annotations, legend and guides
389
+
390
+ Annotation elements are **model-driven**, not host QML: the same objects render in the live view
391
+ and in `to_image()` / `savefig()`.
392
+
393
+ ```python
394
+ plot.annotate("pole", xy=(0.0, 0.0), xytext=(24, -18), arrowprops={"color": "#333333"})
395
+ plot.text(2.0, 1.0, "f(2)=1", color="#333333", font_size=12)
396
+ plot.add_hline(0.5, color="#888888", style="--") # horizontal guide
397
+ plot.add_vline(1.0, color="#888888", style=":") # vertical guide
398
+
399
+ plot.add_curve("sin(x)", label="sin")
400
+ plot.add_curve("cos(x)", label="cos")
401
+ plot.legend = True
402
+ plot.legend_position = "upper right" # best | upper left | lower right | lower left
403
+
404
+ plot.set_xticks([-3, 0, 3], ["-3", "0", "3"]) # explicit positions + labels
405
+ plot.set_yticks([-1, 1])
406
+ plot.grid_x = True
407
+ plot.grid_y = False # per-axis grid
408
+ plot.tick_width = 1.5
409
+ plot.tick_pad = 4.0
410
+ plot.ticks_top = True
411
+ plot.ticks_right = True
412
+ plot.minor_ticks = True # four minor marks per major interval
413
+ plot.spine_top = False # "box" mode only
414
+ ```
415
+
416
+ `xy` is in world coordinates and `xytext` is a **logical-pixel offset** from it, so annotations
417
+ stay attached to the data while the label keeps a stable screen offset. `Annotation` and `Guide`
418
+ are also exposed as objects (`plot.annotations.at(i)`, `plot.guides.at(i)`) for per-item edits.
419
+
420
+ The Matplotlib facade forwards the same surface:
421
+
422
+ ```python
423
+ ax.annotate("pole", xy=(0, 0), xytext=(24, -18), arrowprops={"color": "#333"})
424
+ ax.text(2, 1, "f(2)=1")
425
+ ax.legend(loc="lower right")
426
+ ax.set_xticks([-3, 0, 3], labels=["-3", "0", "3"])
427
+ ax.tick_params(axis="x", length=6, width=1.5, top=True)
428
+ ax.grid(True, axis="x")
429
+ ax.axhline(0.5, color="#888888")
430
+ ax.axvline(1.0, color="#888888")
431
+ ```
432
+
433
+ `plt.annotate` / `plt.text` / `plt.legend` / `plt.xticks` / `plt.yticks` /
434
+ `plt.tick_params` / `plt.axhline` / `plt.axvline` forward to the current axes. The
435
+ `to_matplotlib()` hand-off maps labels, legend, annotations, guides and explicit ticks as well.
436
+
437
+ The annotation/guide/legend layers are part of the QML scene. The export path now drains the
438
+ queued Canvas paints before the synchronous readback, so the grid, annotations and guides are
439
+ present in screenshots too (previously a Canvas-painted grid could miss the grab).
440
+
368
441
  ## Export
369
442
 
370
443
  `savefig()` writes a file, `to_image()` returns a `QImage`; both take the same keywords:
@@ -474,7 +547,7 @@ The supported path is GeoGebra's column idea implemented on the GPU: sample the
474
547
  sub-pixel x grid, stroke the polyline, and fill the exact interval enclosure where a column
475
548
  cannot be represented by a line.
476
549
 
477
- 1. **Sub-pixel polyline stroke.** Each pixel evaluates `F` at **16 samples over ±2 columns**
550
+ 1. **Sub-pixel polyline stroke.** Each pixel evaluates `F` at **14 samples over ±2 columns**
478
551
  (0.25 logical-px spacing). Adjacent valid samples form a polyline, and coverage is
479
552
  `R - distance to the nearest segment` — Desmos' stroke rule. The sample spacing is
480
553
  sub-pixel, so the vertices themselves are sub-pixel and the segments are diagonal: the
@@ -509,7 +582,7 @@ cannot be represented by a line.
509
582
  viewport bottom; no filled ray.
510
583
  - **whole**: `DrawInterval.drawWhole` — one vertical segment at `x.low`.
511
584
  Deliberate deviations, because this is a fragment shader and not the Java tuple list: the
512
- 16-sample polyline supersamples GGB's one interval per column for resolved stretches; a
585
+ 14-sample polyline supersamples GGB's one interval per column for resolved stretches; a
513
586
  dependent quotient's whole column is still suppressed (the fixed-x polyline draws the real
514
587
  branches, avoiding the `x/sqrt(x**2)` connector); and a joined inverted column uses those
515
588
  sample branches instead of replaying `JoinLines.inverted`'s neighbour decisions.
@@ -517,7 +590,7 @@ cannot be represented by a line.
517
590
  never evaluated; a pixel just outside the domain still gets the band, which is centred
518
591
  on the boundary.
519
592
 
520
- An ordinary pixel costs **16 `F` evaluations** plus two interval evaluations; there is no
593
+ An ordinary pixel costs **14 `F` evaluations** plus two interval evaluations; there is no
521
594
  march, no tangent-foot projection and no column probe on the supported path. Expressions
522
595
  with an operation the interval backend does not implement (`floor`, `sign`, …) compile the
523
596
  legacy sampled band + ray + march path (`#define USE_INTERVAL 0`); it is the only user of
@@ -552,6 +625,8 @@ uv run ruff check src tests examples # bug-level lint (undefined names, unused
552
625
  matplotlib's default style, the limits are forwarded to the camera, and a theme applies
553
626
  (and re-applies as a reset) while an unknown name is rejected.
554
627
  - `test_themes.py`: the vendored style sheets resolve to this library's properties.
628
+ - `test_overlay.py`: annotations (`annotate`/`text`), horizontal/vertical guides, legend and
629
+ custom ticks, the `Axes`/`pyplot` forwarding, and the matplotlib hand-off mapping.
555
630
  - `test_qsb_cache.py`: the cache location/info/clear API and the age + LRU prune; unrelated
556
631
  files in the temp directory are not touched.
557
632
  - `test_shader_bake.py`: the stage of a baked `.qsb` must match its purpose, and
@@ -566,11 +641,11 @@ uv run ruff check src tests examples # bug-level lint (undefined names, unused
566
641
  appearing inside a layout, an expression change / an invalid expression keeping the
567
642
  previous graph, curves added/hidden/removed through the model, **the wheel over a
568
643
  scroll area being consumed by the plot (zoom instead of scrolling the parent)**, the
569
- fixed aspect being undistorted by the widget's shape, and the plot staying out of the
570
- Tab focus chain. `conftest.py` provides a session-scoped `QApplication` (QtWidgets
644
+ fixed aspect being undistorted by the widget's shape, and Tab reaching the plot and leaving
645
+ it again. `conftest.py` provides a session-scoped `QApplication` (QtWidgets
571
646
  needs one, and a process may only have one).
572
647
  - `test_qtquick_coexistence.py`: the same drag conflict in Qt Quick (a `Flickable`
573
- must not steal the pan).
648
+ must not steal the pan), plus Tab focus entering and leaving the QML plot.
574
649
  - `test_handoff.py`: the matplotlib/sympy hand-off — the figure reflects the limits, aspect,
575
650
  `adjustable`, grid, title, spines at zero and per-curve style; the sampling is uniform and NaN
576
651
  outside the domain (so `log(x)` breaks); `savefig(backend="matplotlib")` writes real `.svg`,
@@ -664,14 +739,27 @@ Startup to the first frame is about **230 ms**, attributed as follows
664
739
  Per-frame grab cycles (900×600, `grabToImage` — frame + readback, so an upper bound on
665
740
  the GPU cost), steady state on the same window: `sin(1/x)` default **13.6 ms**, `tan(x)`
666
741
  default **12.4 ms**, `sin(1/x)` at zoom 90 (worst case) **12.5 ms**. The previous
667
- march/interval renderer measured 27/34/33 ms on the same machine: 16 samples are cheaper
742
+ march/interval renderer measured 27/34/33 ms on the same machine: 14 samples are cheaper
668
743
  than the march's seed + tangent projection + walk + probe, and the march is now compiled
669
744
  only for the legacy path. The 1-D fragment source is about 21 KB.
670
745
 
671
- **Per-pixel `F` evaluations.** The supported path is fixed: 16 samples for the polyline
672
- (plus two interval evaluations for the column and the stroke footprint); there is no
673
- march budget and no band-window evaluation. The old fixed-grid/march table is superseded
674
- and no longer describes the shipped path.
746
+ **Per-pixel `F` evaluations.** The supported path uses 14 samples for the polyline in the live
747
+ view and in exports; two interval evaluations cover the column and the stroke footprint. There
748
+ is no march budget and no band-window evaluation. Anti-aliasing comes from the analytic
749
+ coverage ramp, not from the sample count; the samples only approximate the polyline. Sampling
750
+ was tuned by the render tests: 8 samples overfills the `sin(1/x)` transition and breaks the
751
+ `log` descent, 12 still overfills slightly, 14 passes.
752
+
753
+ An interval early-out over ±2 columns rejects background pixels before the sample loop: if the
754
+ pixel is farther than the stroke radius from the curve's y range in that window, it returns
755
+ transparent without evaluating `F`. Measured by alternating two live 900×600 views (15 pans
756
+ each, `grabToImage`, including readback): `sin(x)` 23.7 → 22.2 ms, `sin(1/x)` 23.6 → 21.0 ms,
757
+ `x**2` 22.7 → 20.4 ms. Whole/infinite columns keep the full path, so `tan`/`1/x` near a pole
758
+ are unchanged.
759
+
760
+ The stroke-footprint interval is evaluated only when the column interval is empty (the domain
761
+ boundary's outer half). On the common path this is two interval evaluations (`wide`, `own`)
762
+ instead of three: another 17.0 → 16.4 ms on `sin(x)` in the same two-view measurement.
675
763
 
676
764
  ## Shader cache
677
765
 
@@ -726,6 +814,9 @@ measurement that settled each one). The Qt/packaging ones that shaped the code:
726
814
  **device** pixels with an integral device width (`_deviceWidth`/`_snapLine`), not logical
727
815
  half-pixels: the 1.07-logical axis is 2 device px at DPR 1.5 and otherwise alternated
728
816
  between one and two rows as it drifted with the zoom.
817
+ - `qsb.exe` is a **console-subsystem** program. A windowed/frozen host (`pythonw`,
818
+ PyInstaller `--windowed`) would flash a cmd window on every cache-miss bake; `qsb.bake()`
819
+ passes `CREATE_NO_WINDOW` on Windows. `capture_output=True` alone does not prevent it.
729
820
  - `QQuickWidget.setProperty(name, …)` sets it on the **widget**, not on the QML root (the
730
821
  widget has no such property, so it silently does nothing): inject the model with
731
822
  `widget.rootObject().setProperty("plot", plot)`, as `MathPlotWidget` does.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "qmlmathplot"
3
- version = "0.4.1"
3
+ version = "0.4.3"
4
4
  description = "Embeddable function plotter for Qt Quick / QtWidgets (implicit per-pixel drawing, MVVM)"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "qmlmathplot"
3
- version = "0.4.1"
3
+ version = "0.4.3"
4
4
  description = "Embeddable function plotter for Qt Quick / QtWidgets (implicit per-pixel drawing, MVVM)"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -54,6 +54,7 @@ from .camera import HOME_SIZE, HOME_VIEW, Camera, nice_ticks
54
54
  from .curve import Curve, CurveListModel, ImplicitCurve
55
55
  from .figure import Axes, Figure
56
56
  from .input import ViewInput
57
+ from .overlay import Annotation, Guide
57
58
  from .plot import Plot
58
59
  from .qsb import cache_dir, cache_info, clear_cache, prune_cache
59
60
  from .view import QML_MAJOR, QML_MINOR, QML_URI, qml_component_path, register_qml_types
@@ -63,6 +64,7 @@ if TYPE_CHECKING: # only for type checkers; at runtime the module-level __getat
63
64
  from .widget import MathPlotWidget
64
65
 
65
66
  __all__ = [
67
+ "Annotation",
66
68
  "Axes",
67
69
  "cache_dir",
68
70
  "cache_info",
@@ -72,6 +74,7 @@ __all__ = [
72
74
  "CurveListModel",
73
75
  "Figure",
74
76
  "FigureCanvasQt",
77
+ "Guide",
75
78
  "HOME_SIZE",
76
79
  "HOME_VIEW",
77
80
  "ImplicitCurve",
@@ -431,6 +431,7 @@ class CurveListModel(QAbstractListModel):
431
431
  VisibleRole = CurveRole + 4
432
432
  VertexShaderRole = CurveRole + 5
433
433
  FragmentShaderRole = CurveRole + 6
434
+ LabelRole = CurveRole + 7
434
435
 
435
436
  _ROLES = {
436
437
  CurveRole: b"curve",
@@ -440,6 +441,7 @@ class CurveListModel(QAbstractListModel):
440
441
  VisibleRole: b"visible",
441
442
  VertexShaderRole: b"vertexShader",
442
443
  FragmentShaderRole: b"fragmentShader",
444
+ LabelRole: b"label",
443
445
  }
444
446
 
445
447
  def __init__(
@@ -478,6 +480,8 @@ class CurveListModel(QAbstractListModel):
478
480
  return curve.vertexShader
479
481
  if role == self.FragmentShaderRole:
480
482
  return curve.fragmentShader
483
+ if role == self.LabelRole:
484
+ return curve.label
481
485
  return None
482
486
 
483
487
  def _notify(self, curve: Curve, roles: tuple[int, ...]) -> None:
@@ -498,6 +502,7 @@ class CurveListModel(QAbstractListModel):
498
502
  lambda c=curve: self._notify(c, (self.ColorRole, self.LineWidthRole))
499
503
  )
500
504
  curve.visibleChanged.connect(lambda c=curve: self._notify(c, (self.VisibleRole,)))
505
+ curve.labelChanged.connect(lambda c=curve: self._notify(c, (self.LabelRole,)))
501
506
 
502
507
  # ---------------------------------------------------------------- public
503
508
  def _defaults(self, color: object, line_width: object) -> tuple[QColor, float]:
@@ -14,7 +14,7 @@ from __future__ import annotations
14
14
  from collections.abc import Sequence
15
15
  from typing import TYPE_CHECKING
16
16
 
17
- from PySide6.QtCore import QSize, Qt, QUrl
17
+ from PySide6.QtCore import QCoreApplication, QSize, Qt, QUrl
18
18
  from PySide6.QtGui import QColor, QImage
19
19
 
20
20
  if TYPE_CHECKING: # pragma: no cover - typing only; the real imports stay lazy
@@ -155,6 +155,10 @@ def render(
155
155
 
156
156
  camera.set_viewport(logical[0], logical[1])
157
157
  camera.set_view(centre[0], centre[1], scale[0], scale[1])
158
+ # Let QML's Canvas-based furniture (grid, guides) handle its queued paint before the
159
+ # synchronous readback; the scene-graph items are already ready, Canvas is not.
160
+ for _ in range(3):
161
+ QCoreApplication.processEvents()
158
162
  image = widget.grabFramebuffer()
159
163
  finally:
160
164
  # Restore before the event loop runs again, so no live view repaints the export state.