qmlmathplot 0.4.1__tar.gz → 0.4.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/PKG-INFO +110 -22
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/README.md +109 -21
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/pyproject.toml +1 -1
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/pyproject.toml.orig +1 -1
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/__init__.py +3 -0
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/curve.py +5 -0
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/export.py +5 -1
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/figure.py +88 -14
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/model.py +29 -7
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/mpl.py +67 -6
- qmlmathplot-0.4.2/src/qmlmathplot/overlay.py +426 -0
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/plot.py +232 -3
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/pyplot.py +40 -0
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/qml/PlotFurniture.qml +156 -20
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/qml/PlotInput.qml +4 -0
- qmlmathplot-0.4.2/src/qmlmathplot/qml/PlotLegend.qml +66 -0
- qmlmathplot-0.4.2/src/qmlmathplot/qml/PlotOverlay.qml +111 -0
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/qml/PlotView.qml +14 -3
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/qsb.py +6 -0
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/widget.py +3 -3
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/__main__.py +0 -0
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/animation.py +0 -0
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/app.py +0 -0
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/camera.py +0 -0
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/canvas.py +0 -0
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/input.py +0 -0
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/interval.py +0 -0
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/qml/PlotCurves.qml +0 -0
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/LICENSE.matplotlib +0 -0
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/README.md +0 -0
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/Solarize_Light2.mplstyle +0 -0
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/bmh.mplstyle +0 -0
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/classic.mplstyle +0 -0
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/dark_background.mplstyle +0 -0
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/default.mplstyle +0 -0
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/fast.mplstyle +0 -0
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/fivethirtyeight.mplstyle +0 -0
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/ggplot.mplstyle +0 -0
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/grayscale.mplstyle +0 -0
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/petroff10.mplstyle +0 -0
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/seaborn-v0_8-bright.mplstyle +0 -0
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/seaborn-v0_8-colorblind.mplstyle +0 -0
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/seaborn-v0_8-dark-palette.mplstyle +0 -0
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/seaborn-v0_8-dark.mplstyle +0 -0
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/seaborn-v0_8-darkgrid.mplstyle +0 -0
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/seaborn-v0_8-deep.mplstyle +0 -0
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/seaborn-v0_8-muted.mplstyle +0 -0
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/seaborn-v0_8-notebook.mplstyle +0 -0
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/seaborn-v0_8-paper.mplstyle +0 -0
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/seaborn-v0_8-pastel.mplstyle +0 -0
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/seaborn-v0_8-poster.mplstyle +0 -0
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/seaborn-v0_8-talk.mplstyle +0 -0
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/seaborn-v0_8-ticks.mplstyle +0 -0
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/seaborn-v0_8-white.mplstyle +0 -0
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/seaborn-v0_8-whitegrid.mplstyle +0 -0
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/seaborn-v0_8.mplstyle +0 -0
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/tableau-colorblind10.mplstyle +0 -0
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/themes.py +0 -0
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.2}/src/qmlmathplot/view.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: qmlmathplot
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.2
|
|
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`,
|
|
214
|
-
`
|
|
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
|
-
- **
|
|
228
|
-
|
|
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.
|
|
@@ -312,8 +317,9 @@ plot.plot.camera.zoom_by(120, 0.5, 0.5, w, h) # one wheel notch, anchored at
|
|
|
312
317
|
|
|
313
318
|
## Keyboard, touch and animation
|
|
314
319
|
|
|
315
|
-
Clicking the plot
|
|
316
|
-
per press by default), `+`/`=` zooms in and
|
|
320
|
+
Clicking the plot or pressing Tab while it is in the focus chain gives it focus; while it has
|
|
321
|
+
focus the arrow keys pan (10% of the viewport per press by default), `+`/`=` zooms in and
|
|
322
|
+
`-`/`_` zooms out. Held keys advance continuously:
|
|
317
323
|
one `keyPanStep` per `keyPanRepeatInterval` (100 ms by default), integrated every frame, so
|
|
318
324
|
there is no OS key-repeat delay or granular stepping. Several arrows combine, so `Left+Up`
|
|
319
325
|
moves diagonally at the normal speed. Every command is a property on the camera's `input`
|
|
@@ -359,7 +365,7 @@ plot.available_themes # the names, for a host's picker (both dem
|
|
|
359
365
|
The 26 bundled sheets are Matplotlib's own styles, vendored verbatim into
|
|
360
366
|
`qmlmathplot/themes/*.mplstyle` and converted at load time: Matplotlib's `rcParams` keys are
|
|
361
367
|
mapped onto this library's property names, and keys describing things this library does not
|
|
362
|
-
have (
|
|
368
|
+
have (figure size, dpi, legend, marker styles, …) are ignored. `themes.names()` lists
|
|
363
369
|
them (`"default"` plus the 26 sheets). An unknown name raises `KeyError` and leaves the current
|
|
364
370
|
theme alone.
|
|
365
371
|
|
|
@@ -371,13 +377,77 @@ theme alone.
|
|
|
371
377
|
`x = 0` — drawn in `axis_color` / `axis_width`, and the **tick marks and labels ride those
|
|
372
378
|
axes** instead of hugging the item's edges. When 0 is off screen the axis sticks to the
|
|
373
379
|
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"`
|
|
380
|
+
stays where it is. `plot.axes_position = "edge"` keeps the labels at the edges without
|
|
381
|
+
drawing axis lines.
|
|
382
|
+
- `plot.axes_position = "box"` draws the four Matplotlib-style **spines** around the viewport
|
|
383
|
+
(same `axis_color` / `axis_width`), with ticks and labels on the bottom/left edges. It is a
|
|
384
|
+
property, not a separate theme, so it composes with any theme:
|
|
385
|
+
```python
|
|
386
|
+
plot.axes_position = "box"
|
|
387
|
+
plot.grid = True
|
|
388
|
+
```
|
|
389
|
+
- Furniture is part of the same QML scene, so `to_image()` / `savefig()` capture the box,
|
|
390
|
+
grid, ticks, labels and title exactly as the live view renders them (there is an export
|
|
391
|
+
regression test for the four spines).
|
|
375
392
|
- `camera.viewport` reports the last size the view sent, so a host can size an export from it.
|
|
376
393
|
- The tick *values* are nice numbers computed in Python (`Camera.tick_values()` /
|
|
377
394
|
`nice_ticks()`, steps of 1/2/5 x 10^n) and pushed through `ticksChanged`; QML only positions
|
|
378
395
|
and formats them, so a frame never recomputes ticks.
|
|
379
396
|
- `plot.ticks_visible = False` hides the whole furniture (axes, marks and labels).
|
|
380
397
|
|
|
398
|
+
## Annotations, legend and guides
|
|
399
|
+
|
|
400
|
+
Annotation elements are **model-driven**, not host QML: the same objects render in the live view
|
|
401
|
+
and in `to_image()` / `savefig()`.
|
|
402
|
+
|
|
403
|
+
```python
|
|
404
|
+
plot.annotate("pole", xy=(0.0, 0.0), xytext=(24, -18), arrowprops={"color": "#333333"})
|
|
405
|
+
plot.text(2.0, 1.0, "f(2)=1", color="#333333", font_size=12)
|
|
406
|
+
plot.add_hline(0.5, color="#888888", style="--") # horizontal guide
|
|
407
|
+
plot.add_vline(1.0, color="#888888", style=":") # vertical guide
|
|
408
|
+
|
|
409
|
+
plot.add_curve("sin(x)", label="sin")
|
|
410
|
+
plot.add_curve("cos(x)", label="cos")
|
|
411
|
+
plot.legend = True
|
|
412
|
+
plot.legend_position = "upper right" # best | upper left | lower right | lower left
|
|
413
|
+
|
|
414
|
+
plot.set_xticks([-3, 0, 3], ["-3", "0", "3"]) # explicit positions + labels
|
|
415
|
+
plot.set_yticks([-1, 1])
|
|
416
|
+
plot.grid_x = True
|
|
417
|
+
plot.grid_y = False # per-axis grid
|
|
418
|
+
plot.tick_width = 1.5
|
|
419
|
+
plot.tick_pad = 4.0
|
|
420
|
+
plot.ticks_top = True
|
|
421
|
+
plot.ticks_right = True
|
|
422
|
+
plot.minor_ticks = True # four minor marks per major interval
|
|
423
|
+
plot.spine_top = False # "box" mode only
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
`xy` is in world coordinates and `xytext` is a **logical-pixel offset** from it, so annotations
|
|
427
|
+
stay attached to the data while the label keeps a stable screen offset. `Annotation` and `Guide`
|
|
428
|
+
are also exposed as objects (`plot.annotations.at(i)`, `plot.guides.at(i)`) for per-item edits.
|
|
429
|
+
|
|
430
|
+
The Matplotlib facade forwards the same surface:
|
|
431
|
+
|
|
432
|
+
```python
|
|
433
|
+
ax.annotate("pole", xy=(0, 0), xytext=(24, -18), arrowprops={"color": "#333"})
|
|
434
|
+
ax.text(2, 1, "f(2)=1")
|
|
435
|
+
ax.legend(loc="lower right")
|
|
436
|
+
ax.set_xticks([-3, 0, 3], labels=["-3", "0", "3"])
|
|
437
|
+
ax.tick_params(axis="x", length=6, width=1.5, top=True)
|
|
438
|
+
ax.grid(True, axis="x")
|
|
439
|
+
ax.axhline(0.5, color="#888888")
|
|
440
|
+
ax.axvline(1.0, color="#888888")
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
`plt.annotate` / `plt.text` / `plt.legend` / `plt.xticks` / `plt.yticks` /
|
|
444
|
+
`plt.tick_params` / `plt.axhline` / `plt.axvline` forward to the current axes. The
|
|
445
|
+
`to_matplotlib()` hand-off maps labels, legend, annotations, guides and explicit ticks as well.
|
|
446
|
+
|
|
447
|
+
The annotation/guide/legend layers are part of the QML scene. The export path now drains the
|
|
448
|
+
queued Canvas paints before the synchronous readback, so the grid, annotations and guides are
|
|
449
|
+
present in screenshots too (previously a Canvas-painted grid could miss the grab).
|
|
450
|
+
|
|
381
451
|
## Export
|
|
382
452
|
|
|
383
453
|
`savefig()` writes a file, `to_image()` returns a `QImage`; both take the same keywords:
|
|
@@ -487,7 +557,7 @@ The supported path is GeoGebra's column idea implemented on the GPU: sample the
|
|
|
487
557
|
sub-pixel x grid, stroke the polyline, and fill the exact interval enclosure where a column
|
|
488
558
|
cannot be represented by a line.
|
|
489
559
|
|
|
490
|
-
1. **Sub-pixel polyline stroke.** Each pixel evaluates `F` at **
|
|
560
|
+
1. **Sub-pixel polyline stroke.** Each pixel evaluates `F` at **14 samples over ±2 columns**
|
|
491
561
|
(0.25 logical-px spacing). Adjacent valid samples form a polyline, and coverage is
|
|
492
562
|
`R - distance to the nearest segment` — Desmos' stroke rule. The sample spacing is
|
|
493
563
|
sub-pixel, so the vertices themselves are sub-pixel and the segments are diagonal: the
|
|
@@ -522,7 +592,7 @@ cannot be represented by a line.
|
|
|
522
592
|
viewport bottom; no filled ray.
|
|
523
593
|
- **whole**: `DrawInterval.drawWhole` — one vertical segment at `x.low`.
|
|
524
594
|
Deliberate deviations, because this is a fragment shader and not the Java tuple list: the
|
|
525
|
-
|
|
595
|
+
14-sample polyline supersamples GGB's one interval per column for resolved stretches; a
|
|
526
596
|
dependent quotient's whole column is still suppressed (the fixed-x polyline draws the real
|
|
527
597
|
branches, avoiding the `x/sqrt(x**2)` connector); and a joined inverted column uses those
|
|
528
598
|
sample branches instead of replaying `JoinLines.inverted`'s neighbour decisions.
|
|
@@ -530,7 +600,7 @@ cannot be represented by a line.
|
|
|
530
600
|
never evaluated; a pixel just outside the domain still gets the band, which is centred
|
|
531
601
|
on the boundary.
|
|
532
602
|
|
|
533
|
-
An ordinary pixel costs **
|
|
603
|
+
An ordinary pixel costs **14 `F` evaluations** plus two interval evaluations; there is no
|
|
534
604
|
march, no tangent-foot projection and no column probe on the supported path. Expressions
|
|
535
605
|
with an operation the interval backend does not implement (`floor`, `sign`, …) compile the
|
|
536
606
|
legacy sampled band + ray + march path (`#define USE_INTERVAL 0`); it is the only user of
|
|
@@ -565,6 +635,8 @@ uv run ruff check src tests examples # bug-level lint (undefined names, unused
|
|
|
565
635
|
matplotlib's default style, the limits are forwarded to the camera, and a theme applies
|
|
566
636
|
(and re-applies as a reset) while an unknown name is rejected.
|
|
567
637
|
- `test_themes.py`: the vendored style sheets resolve to this library's properties.
|
|
638
|
+
- `test_overlay.py`: annotations (`annotate`/`text`), horizontal/vertical guides, legend and
|
|
639
|
+
custom ticks, the `Axes`/`pyplot` forwarding, and the matplotlib hand-off mapping.
|
|
568
640
|
- `test_qsb_cache.py`: the cache location/info/clear API and the age + LRU prune; unrelated
|
|
569
641
|
files in the temp directory are not touched.
|
|
570
642
|
- `test_shader_bake.py`: the stage of a baked `.qsb` must match its purpose, and
|
|
@@ -579,11 +651,11 @@ uv run ruff check src tests examples # bug-level lint (undefined names, unused
|
|
|
579
651
|
appearing inside a layout, an expression change / an invalid expression keeping the
|
|
580
652
|
previous graph, curves added/hidden/removed through the model, **the wheel over a
|
|
581
653
|
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
|
|
583
|
-
|
|
654
|
+
fixed aspect being undistorted by the widget's shape, and Tab reaching the plot and leaving
|
|
655
|
+
it again. `conftest.py` provides a session-scoped `QApplication` (QtWidgets
|
|
584
656
|
needs one, and a process may only have one).
|
|
585
657
|
- `test_qtquick_coexistence.py`: the same drag conflict in Qt Quick (a `Flickable`
|
|
586
|
-
must not steal the pan).
|
|
658
|
+
must not steal the pan), plus Tab focus entering and leaving the QML plot.
|
|
587
659
|
- `test_handoff.py`: the matplotlib/sympy hand-off — the figure reflects the limits, aspect,
|
|
588
660
|
`adjustable`, grid, title, spines at zero and per-curve style; the sampling is uniform and NaN
|
|
589
661
|
outside the domain (so `log(x)` breaks); `savefig(backend="matplotlib")` writes real `.svg`,
|
|
@@ -677,14 +749,27 @@ Startup to the first frame is about **230 ms**, attributed as follows
|
|
|
677
749
|
Per-frame grab cycles (900×600, `grabToImage` — frame + readback, so an upper bound on
|
|
678
750
|
the GPU cost), steady state on the same window: `sin(1/x)` default **13.6 ms**, `tan(x)`
|
|
679
751
|
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:
|
|
752
|
+
march/interval renderer measured 27/34/33 ms on the same machine: 14 samples are cheaper
|
|
681
753
|
than the march's seed + tangent projection + walk + probe, and the march is now compiled
|
|
682
754
|
only for the legacy path. The 1-D fragment source is about 21 KB.
|
|
683
755
|
|
|
684
|
-
**Per-pixel `F` evaluations.** The supported path
|
|
685
|
-
|
|
686
|
-
march budget and no band-window evaluation.
|
|
687
|
-
|
|
756
|
+
**Per-pixel `F` evaluations.** The supported path uses 14 samples for the polyline in the live
|
|
757
|
+
view and in exports; two interval evaluations cover the column and the stroke footprint. There
|
|
758
|
+
is no march budget and no band-window evaluation. Anti-aliasing comes from the analytic
|
|
759
|
+
coverage ramp, not from the sample count; the samples only approximate the polyline. Sampling
|
|
760
|
+
was tuned by the render tests: 8 samples overfills the `sin(1/x)` transition and breaks the
|
|
761
|
+
`log` descent, 12 still overfills slightly, 14 passes.
|
|
762
|
+
|
|
763
|
+
An interval early-out over ±2 columns rejects background pixels before the sample loop: if the
|
|
764
|
+
pixel is farther than the stroke radius from the curve's y range in that window, it returns
|
|
765
|
+
transparent without evaluating `F`. Measured by alternating two live 900×600 views (15 pans
|
|
766
|
+
each, `grabToImage`, including readback): `sin(x)` 23.7 → 22.2 ms, `sin(1/x)` 23.6 → 21.0 ms,
|
|
767
|
+
`x**2` 22.7 → 20.4 ms. Whole/infinite columns keep the full path, so `tan`/`1/x` near a pole
|
|
768
|
+
are unchanged.
|
|
769
|
+
|
|
770
|
+
The stroke-footprint interval is evaluated only when the column interval is empty (the domain
|
|
771
|
+
boundary's outer half). On the common path this is two interval evaluations (`wide`, `own`)
|
|
772
|
+
instead of three: another 17.0 → 16.4 ms on `sin(x)` in the same two-view measurement.
|
|
688
773
|
|
|
689
774
|
## Shader cache
|
|
690
775
|
|
|
@@ -739,6 +824,9 @@ measurement that settled each one). The Qt/packaging ones that shaped the code:
|
|
|
739
824
|
**device** pixels with an integral device width (`_deviceWidth`/`_snapLine`), not logical
|
|
740
825
|
half-pixels: the 1.07-logical axis is 2 device px at DPR 1.5 and otherwise alternated
|
|
741
826
|
between one and two rows as it drifted with the zoom.
|
|
827
|
+
- `qsb.exe` is a **console-subsystem** program. A windowed/frozen host (`pythonw`,
|
|
828
|
+
PyInstaller `--windowed`) would flash a cmd window on every cache-miss bake; `qsb.bake()`
|
|
829
|
+
passes `CREATE_NO_WINDOW` on Windows. `capture_output=True` alone does not prevent it.
|
|
742
830
|
- `QQuickWidget.setProperty(name, …)` sets it on the **widget**, not on the QML root (the
|
|
743
831
|
widget has no such property, so it silently does nothing): inject the model with
|
|
744
832
|
`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`,
|
|
201
|
-
`
|
|
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
|
-
- **
|
|
215
|
-
|
|
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.
|
|
@@ -299,8 +304,9 @@ plot.plot.camera.zoom_by(120, 0.5, 0.5, w, h) # one wheel notch, anchored at
|
|
|
299
304
|
|
|
300
305
|
## Keyboard, touch and animation
|
|
301
306
|
|
|
302
|
-
Clicking the plot
|
|
303
|
-
per press by default), `+`/`=` zooms in and
|
|
307
|
+
Clicking the plot or pressing Tab while it is in the focus chain gives it focus; while it has
|
|
308
|
+
focus the arrow keys pan (10% of the viewport per press by default), `+`/`=` zooms in and
|
|
309
|
+
`-`/`_` zooms out. Held keys advance continuously:
|
|
304
310
|
one `keyPanStep` per `keyPanRepeatInterval` (100 ms by default), integrated every frame, so
|
|
305
311
|
there is no OS key-repeat delay or granular stepping. Several arrows combine, so `Left+Up`
|
|
306
312
|
moves diagonally at the normal speed. Every command is a property on the camera's `input`
|
|
@@ -346,7 +352,7 @@ plot.available_themes # the names, for a host's picker (both dem
|
|
|
346
352
|
The 26 bundled sheets are Matplotlib's own styles, vendored verbatim into
|
|
347
353
|
`qmlmathplot/themes/*.mplstyle` and converted at load time: Matplotlib's `rcParams` keys are
|
|
348
354
|
mapped onto this library's property names, and keys describing things this library does not
|
|
349
|
-
have (
|
|
355
|
+
have (figure size, dpi, legend, marker styles, …) are ignored. `themes.names()` lists
|
|
350
356
|
them (`"default"` plus the 26 sheets). An unknown name raises `KeyError` and leaves the current
|
|
351
357
|
theme alone.
|
|
352
358
|
|
|
@@ -358,13 +364,77 @@ theme alone.
|
|
|
358
364
|
`x = 0` — drawn in `axis_color` / `axis_width`, and the **tick marks and labels ride those
|
|
359
365
|
axes** instead of hugging the item's edges. When 0 is off screen the axis sticks to the
|
|
360
366
|
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"`
|
|
367
|
+
stays where it is. `plot.axes_position = "edge"` keeps the labels at the edges without
|
|
368
|
+
drawing axis lines.
|
|
369
|
+
- `plot.axes_position = "box"` draws the four Matplotlib-style **spines** around the viewport
|
|
370
|
+
(same `axis_color` / `axis_width`), with ticks and labels on the bottom/left edges. It is a
|
|
371
|
+
property, not a separate theme, so it composes with any theme:
|
|
372
|
+
```python
|
|
373
|
+
plot.axes_position = "box"
|
|
374
|
+
plot.grid = True
|
|
375
|
+
```
|
|
376
|
+
- Furniture is part of the same QML scene, so `to_image()` / `savefig()` capture the box,
|
|
377
|
+
grid, ticks, labels and title exactly as the live view renders them (there is an export
|
|
378
|
+
regression test for the four spines).
|
|
362
379
|
- `camera.viewport` reports the last size the view sent, so a host can size an export from it.
|
|
363
380
|
- The tick *values* are nice numbers computed in Python (`Camera.tick_values()` /
|
|
364
381
|
`nice_ticks()`, steps of 1/2/5 x 10^n) and pushed through `ticksChanged`; QML only positions
|
|
365
382
|
and formats them, so a frame never recomputes ticks.
|
|
366
383
|
- `plot.ticks_visible = False` hides the whole furniture (axes, marks and labels).
|
|
367
384
|
|
|
385
|
+
## Annotations, legend and guides
|
|
386
|
+
|
|
387
|
+
Annotation elements are **model-driven**, not host QML: the same objects render in the live view
|
|
388
|
+
and in `to_image()` / `savefig()`.
|
|
389
|
+
|
|
390
|
+
```python
|
|
391
|
+
plot.annotate("pole", xy=(0.0, 0.0), xytext=(24, -18), arrowprops={"color": "#333333"})
|
|
392
|
+
plot.text(2.0, 1.0, "f(2)=1", color="#333333", font_size=12)
|
|
393
|
+
plot.add_hline(0.5, color="#888888", style="--") # horizontal guide
|
|
394
|
+
plot.add_vline(1.0, color="#888888", style=":") # vertical guide
|
|
395
|
+
|
|
396
|
+
plot.add_curve("sin(x)", label="sin")
|
|
397
|
+
plot.add_curve("cos(x)", label="cos")
|
|
398
|
+
plot.legend = True
|
|
399
|
+
plot.legend_position = "upper right" # best | upper left | lower right | lower left
|
|
400
|
+
|
|
401
|
+
plot.set_xticks([-3, 0, 3], ["-3", "0", "3"]) # explicit positions + labels
|
|
402
|
+
plot.set_yticks([-1, 1])
|
|
403
|
+
plot.grid_x = True
|
|
404
|
+
plot.grid_y = False # per-axis grid
|
|
405
|
+
plot.tick_width = 1.5
|
|
406
|
+
plot.tick_pad = 4.0
|
|
407
|
+
plot.ticks_top = True
|
|
408
|
+
plot.ticks_right = True
|
|
409
|
+
plot.minor_ticks = True # four minor marks per major interval
|
|
410
|
+
plot.spine_top = False # "box" mode only
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
`xy` is in world coordinates and `xytext` is a **logical-pixel offset** from it, so annotations
|
|
414
|
+
stay attached to the data while the label keeps a stable screen offset. `Annotation` and `Guide`
|
|
415
|
+
are also exposed as objects (`plot.annotations.at(i)`, `plot.guides.at(i)`) for per-item edits.
|
|
416
|
+
|
|
417
|
+
The Matplotlib facade forwards the same surface:
|
|
418
|
+
|
|
419
|
+
```python
|
|
420
|
+
ax.annotate("pole", xy=(0, 0), xytext=(24, -18), arrowprops={"color": "#333"})
|
|
421
|
+
ax.text(2, 1, "f(2)=1")
|
|
422
|
+
ax.legend(loc="lower right")
|
|
423
|
+
ax.set_xticks([-3, 0, 3], labels=["-3", "0", "3"])
|
|
424
|
+
ax.tick_params(axis="x", length=6, width=1.5, top=True)
|
|
425
|
+
ax.grid(True, axis="x")
|
|
426
|
+
ax.axhline(0.5, color="#888888")
|
|
427
|
+
ax.axvline(1.0, color="#888888")
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
`plt.annotate` / `plt.text` / `plt.legend` / `plt.xticks` / `plt.yticks` /
|
|
431
|
+
`plt.tick_params` / `plt.axhline` / `plt.axvline` forward to the current axes. The
|
|
432
|
+
`to_matplotlib()` hand-off maps labels, legend, annotations, guides and explicit ticks as well.
|
|
433
|
+
|
|
434
|
+
The annotation/guide/legend layers are part of the QML scene. The export path now drains the
|
|
435
|
+
queued Canvas paints before the synchronous readback, so the grid, annotations and guides are
|
|
436
|
+
present in screenshots too (previously a Canvas-painted grid could miss the grab).
|
|
437
|
+
|
|
368
438
|
## Export
|
|
369
439
|
|
|
370
440
|
`savefig()` writes a file, `to_image()` returns a `QImage`; both take the same keywords:
|
|
@@ -474,7 +544,7 @@ The supported path is GeoGebra's column idea implemented on the GPU: sample the
|
|
|
474
544
|
sub-pixel x grid, stroke the polyline, and fill the exact interval enclosure where a column
|
|
475
545
|
cannot be represented by a line.
|
|
476
546
|
|
|
477
|
-
1. **Sub-pixel polyline stroke.** Each pixel evaluates `F` at **
|
|
547
|
+
1. **Sub-pixel polyline stroke.** Each pixel evaluates `F` at **14 samples over ±2 columns**
|
|
478
548
|
(0.25 logical-px spacing). Adjacent valid samples form a polyline, and coverage is
|
|
479
549
|
`R - distance to the nearest segment` — Desmos' stroke rule. The sample spacing is
|
|
480
550
|
sub-pixel, so the vertices themselves are sub-pixel and the segments are diagonal: the
|
|
@@ -509,7 +579,7 @@ cannot be represented by a line.
|
|
|
509
579
|
viewport bottom; no filled ray.
|
|
510
580
|
- **whole**: `DrawInterval.drawWhole` — one vertical segment at `x.low`.
|
|
511
581
|
Deliberate deviations, because this is a fragment shader and not the Java tuple list: the
|
|
512
|
-
|
|
582
|
+
14-sample polyline supersamples GGB's one interval per column for resolved stretches; a
|
|
513
583
|
dependent quotient's whole column is still suppressed (the fixed-x polyline draws the real
|
|
514
584
|
branches, avoiding the `x/sqrt(x**2)` connector); and a joined inverted column uses those
|
|
515
585
|
sample branches instead of replaying `JoinLines.inverted`'s neighbour decisions.
|
|
@@ -517,7 +587,7 @@ cannot be represented by a line.
|
|
|
517
587
|
never evaluated; a pixel just outside the domain still gets the band, which is centred
|
|
518
588
|
on the boundary.
|
|
519
589
|
|
|
520
|
-
An ordinary pixel costs **
|
|
590
|
+
An ordinary pixel costs **14 `F` evaluations** plus two interval evaluations; there is no
|
|
521
591
|
march, no tangent-foot projection and no column probe on the supported path. Expressions
|
|
522
592
|
with an operation the interval backend does not implement (`floor`, `sign`, …) compile the
|
|
523
593
|
legacy sampled band + ray + march path (`#define USE_INTERVAL 0`); it is the only user of
|
|
@@ -552,6 +622,8 @@ uv run ruff check src tests examples # bug-level lint (undefined names, unused
|
|
|
552
622
|
matplotlib's default style, the limits are forwarded to the camera, and a theme applies
|
|
553
623
|
(and re-applies as a reset) while an unknown name is rejected.
|
|
554
624
|
- `test_themes.py`: the vendored style sheets resolve to this library's properties.
|
|
625
|
+
- `test_overlay.py`: annotations (`annotate`/`text`), horizontal/vertical guides, legend and
|
|
626
|
+
custom ticks, the `Axes`/`pyplot` forwarding, and the matplotlib hand-off mapping.
|
|
555
627
|
- `test_qsb_cache.py`: the cache location/info/clear API and the age + LRU prune; unrelated
|
|
556
628
|
files in the temp directory are not touched.
|
|
557
629
|
- `test_shader_bake.py`: the stage of a baked `.qsb` must match its purpose, and
|
|
@@ -566,11 +638,11 @@ uv run ruff check src tests examples # bug-level lint (undefined names, unused
|
|
|
566
638
|
appearing inside a layout, an expression change / an invalid expression keeping the
|
|
567
639
|
previous graph, curves added/hidden/removed through the model, **the wheel over a
|
|
568
640
|
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
|
|
570
|
-
|
|
641
|
+
fixed aspect being undistorted by the widget's shape, and Tab reaching the plot and leaving
|
|
642
|
+
it again. `conftest.py` provides a session-scoped `QApplication` (QtWidgets
|
|
571
643
|
needs one, and a process may only have one).
|
|
572
644
|
- `test_qtquick_coexistence.py`: the same drag conflict in Qt Quick (a `Flickable`
|
|
573
|
-
must not steal the pan).
|
|
645
|
+
must not steal the pan), plus Tab focus entering and leaving the QML plot.
|
|
574
646
|
- `test_handoff.py`: the matplotlib/sympy hand-off — the figure reflects the limits, aspect,
|
|
575
647
|
`adjustable`, grid, title, spines at zero and per-curve style; the sampling is uniform and NaN
|
|
576
648
|
outside the domain (so `log(x)` breaks); `savefig(backend="matplotlib")` writes real `.svg`,
|
|
@@ -664,14 +736,27 @@ Startup to the first frame is about **230 ms**, attributed as follows
|
|
|
664
736
|
Per-frame grab cycles (900×600, `grabToImage` — frame + readback, so an upper bound on
|
|
665
737
|
the GPU cost), steady state on the same window: `sin(1/x)` default **13.6 ms**, `tan(x)`
|
|
666
738
|
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:
|
|
739
|
+
march/interval renderer measured 27/34/33 ms on the same machine: 14 samples are cheaper
|
|
668
740
|
than the march's seed + tangent projection + walk + probe, and the march is now compiled
|
|
669
741
|
only for the legacy path. The 1-D fragment source is about 21 KB.
|
|
670
742
|
|
|
671
|
-
**Per-pixel `F` evaluations.** The supported path
|
|
672
|
-
|
|
673
|
-
march budget and no band-window evaluation.
|
|
674
|
-
|
|
743
|
+
**Per-pixel `F` evaluations.** The supported path uses 14 samples for the polyline in the live
|
|
744
|
+
view and in exports; two interval evaluations cover the column and the stroke footprint. There
|
|
745
|
+
is no march budget and no band-window evaluation. Anti-aliasing comes from the analytic
|
|
746
|
+
coverage ramp, not from the sample count; the samples only approximate the polyline. Sampling
|
|
747
|
+
was tuned by the render tests: 8 samples overfills the `sin(1/x)` transition and breaks the
|
|
748
|
+
`log` descent, 12 still overfills slightly, 14 passes.
|
|
749
|
+
|
|
750
|
+
An interval early-out over ±2 columns rejects background pixels before the sample loop: if the
|
|
751
|
+
pixel is farther than the stroke radius from the curve's y range in that window, it returns
|
|
752
|
+
transparent without evaluating `F`. Measured by alternating two live 900×600 views (15 pans
|
|
753
|
+
each, `grabToImage`, including readback): `sin(x)` 23.7 → 22.2 ms, `sin(1/x)` 23.6 → 21.0 ms,
|
|
754
|
+
`x**2` 22.7 → 20.4 ms. Whole/infinite columns keep the full path, so `tan`/`1/x` near a pole
|
|
755
|
+
are unchanged.
|
|
756
|
+
|
|
757
|
+
The stroke-footprint interval is evaluated only when the column interval is empty (the domain
|
|
758
|
+
boundary's outer half). On the common path this is two interval evaluations (`wide`, `own`)
|
|
759
|
+
instead of three: another 17.0 → 16.4 ms on `sin(x)` in the same two-view measurement.
|
|
675
760
|
|
|
676
761
|
## Shader cache
|
|
677
762
|
|
|
@@ -726,6 +811,9 @@ measurement that settled each one). The Qt/packaging ones that shaped the code:
|
|
|
726
811
|
**device** pixels with an integral device width (`_deviceWidth`/`_snapLine`), not logical
|
|
727
812
|
half-pixels: the 1.07-logical axis is 2 device px at DPR 1.5 and otherwise alternated
|
|
728
813
|
between one and two rows as it drifted with the zoom.
|
|
814
|
+
- `qsb.exe` is a **console-subsystem** program. A windowed/frozen host (`pythonw`,
|
|
815
|
+
PyInstaller `--windowed`) would flash a cmd window on every cache-miss bake; `qsb.bake()`
|
|
816
|
+
passes `CREATE_NO_WINDOW` on Windows. `capture_output=True` alone does not prevent it.
|
|
729
817
|
- `QQuickWidget.setProperty(name, …)` sets it on the **widget**, not on the QML root (the
|
|
730
818
|
widget has no such property, so it silently does nothing): inject the model with
|
|
731
819
|
`widget.rootObject().setProperty("plot", plot)`, as `MathPlotWidget` does.
|
|
@@ -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.
|