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.
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/PKG-INFO +115 -24
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/README.md +114 -23
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/pyproject.toml +1 -1
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/pyproject.toml.orig +1 -1
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/__init__.py +3 -0
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/curve.py +5 -0
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/export.py +5 -1
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/figure.py +88 -14
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/model.py +36 -8
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/mpl.py +67 -6
- qmlmathplot-0.4.3/src/qmlmathplot/overlay.py +426 -0
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/plot.py +232 -3
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/pyplot.py +40 -0
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/qml/PlotFurniture.qml +156 -20
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/qml/PlotInput.qml +4 -0
- qmlmathplot-0.4.3/src/qmlmathplot/qml/PlotLegend.qml +66 -0
- qmlmathplot-0.4.3/src/qmlmathplot/qml/PlotOverlay.qml +111 -0
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/qml/PlotView.qml +14 -3
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/qsb.py +6 -0
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/widget.py +3 -3
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/__main__.py +0 -0
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/animation.py +0 -0
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/app.py +0 -0
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/camera.py +0 -0
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/canvas.py +0 -0
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/input.py +0 -0
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/interval.py +0 -0
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/qml/PlotCurves.qml +0 -0
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/themes/LICENSE.matplotlib +0 -0
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/themes/README.md +0 -0
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/themes/Solarize_Light2.mplstyle +0 -0
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/themes/bmh.mplstyle +0 -0
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/themes/classic.mplstyle +0 -0
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/themes/dark_background.mplstyle +0 -0
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/themes/default.mplstyle +0 -0
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/themes/fast.mplstyle +0 -0
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/themes/fivethirtyeight.mplstyle +0 -0
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/themes/ggplot.mplstyle +0 -0
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/themes/grayscale.mplstyle +0 -0
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/themes/petroff10.mplstyle +0 -0
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/themes/seaborn-v0_8-bright.mplstyle +0 -0
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/themes/seaborn-v0_8-colorblind.mplstyle +0 -0
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/themes/seaborn-v0_8-dark-palette.mplstyle +0 -0
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/themes/seaborn-v0_8-dark.mplstyle +0 -0
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/themes/seaborn-v0_8-darkgrid.mplstyle +0 -0
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/themes/seaborn-v0_8-deep.mplstyle +0 -0
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/themes/seaborn-v0_8-muted.mplstyle +0 -0
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/themes/seaborn-v0_8-notebook.mplstyle +0 -0
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/themes/seaborn-v0_8-paper.mplstyle +0 -0
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/themes/seaborn-v0_8-pastel.mplstyle +0 -0
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/themes/seaborn-v0_8-poster.mplstyle +0 -0
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/themes/seaborn-v0_8-talk.mplstyle +0 -0
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/themes/seaborn-v0_8-ticks.mplstyle +0 -0
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/themes/seaborn-v0_8-white.mplstyle +0 -0
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/themes/seaborn-v0_8-whitegrid.mplstyle +0 -0
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/themes/seaborn-v0_8.mplstyle +0 -0
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/themes/tableau-colorblind10.mplstyle +0 -0
- {qmlmathplot-0.4.1 → qmlmathplot-0.4.3}/src/qmlmathplot/themes.py +0 -0
- {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.
|
|
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`,
|
|
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.
|
|
@@ -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,
|
|
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
|
|
316
|
-
per press by default), `+`/`=` zooms in and
|
|
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 (
|
|
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"`
|
|
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 **
|
|
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
|
-
|
|
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 **
|
|
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
|
|
583
|
-
|
|
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:
|
|
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
|
|
685
|
-
|
|
686
|
-
march budget and no band-window evaluation.
|
|
687
|
-
|
|
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`,
|
|
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.
|
|
@@ -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,
|
|
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
|
|
303
|
-
per press by default), `+`/`=` zooms in and
|
|
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 (
|
|
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"`
|
|
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 **
|
|
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
|
-
|
|
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 **
|
|
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
|
|
570
|
-
|
|
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:
|
|
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
|
|
672
|
-
|
|
673
|
-
march budget and no band-window evaluation.
|
|
674
|
-
|
|
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.
|
|
@@ -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.
|