qmlmathplot 0.4.0__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.0 → qmlmathplot-0.4.2}/PKG-INFO +127 -28
- {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/README.md +126 -27
- {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/pyproject.toml +1 -1
- {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/pyproject.toml.orig +1 -1
- {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/__init__.py +3 -0
- {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/curve.py +88 -40
- {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/export.py +5 -1
- {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/figure.py +88 -14
- {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/input.py +22 -0
- {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/model.py +29 -7
- {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/mpl.py +67 -6
- qmlmathplot-0.4.2/src/qmlmathplot/overlay.py +426 -0
- {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/plot.py +238 -7
- {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/pyplot.py +40 -0
- {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/qml/PlotFurniture.qml +156 -20
- {qmlmathplot-0.4.0 → 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.2/src/qmlmathplot/qml/PlotView.qml +286 -0
- {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/qsb.py +6 -0
- {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/widget.py +3 -3
- qmlmathplot-0.4.0/src/qmlmathplot/qml/PlotView.qml +0 -134
- {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/__main__.py +0 -0
- {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/animation.py +0 -0
- {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/app.py +0 -0
- {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/camera.py +0 -0
- {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/canvas.py +0 -0
- {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/interval.py +0 -0
- {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/qml/PlotCurves.qml +0 -0
- {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/LICENSE.matplotlib +0 -0
- {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/README.md +0 -0
- {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/Solarize_Light2.mplstyle +0 -0
- {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/bmh.mplstyle +0 -0
- {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/classic.mplstyle +0 -0
- {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/dark_background.mplstyle +0 -0
- {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/default.mplstyle +0 -0
- {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/fast.mplstyle +0 -0
- {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/fivethirtyeight.mplstyle +0 -0
- {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/ggplot.mplstyle +0 -0
- {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/grayscale.mplstyle +0 -0
- {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/petroff10.mplstyle +0 -0
- {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/seaborn-v0_8-bright.mplstyle +0 -0
- {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/seaborn-v0_8-colorblind.mplstyle +0 -0
- {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/seaborn-v0_8-dark-palette.mplstyle +0 -0
- {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/seaborn-v0_8-dark.mplstyle +0 -0
- {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/seaborn-v0_8-darkgrid.mplstyle +0 -0
- {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/seaborn-v0_8-deep.mplstyle +0 -0
- {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/seaborn-v0_8-muted.mplstyle +0 -0
- {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/seaborn-v0_8-notebook.mplstyle +0 -0
- {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/seaborn-v0_8-paper.mplstyle +0 -0
- {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/seaborn-v0_8-pastel.mplstyle +0 -0
- {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/seaborn-v0_8-poster.mplstyle +0 -0
- {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/seaborn-v0_8-talk.mplstyle +0 -0
- {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/seaborn-v0_8-ticks.mplstyle +0 -0
- {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/seaborn-v0_8-white.mplstyle +0 -0
- {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/seaborn-v0_8-whitegrid.mplstyle +0 -0
- {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/seaborn-v0_8.mplstyle +0 -0
- {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/themes/tableau-colorblind10.mplstyle +0 -0
- {qmlmathplot-0.4.0 → qmlmathplot-0.4.2}/src/qmlmathplot/themes.py +0 -0
- {qmlmathplot-0.4.0 → 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
|
-
| `ViewInput` | `keyboardEnabled`, `keyPanStep`, `panLeftShortcut` / `panRightShortcut` / `panUpShortcut` / `panDownShortcut` / `zoomInShortcut` / `zoomOutShortcut`, `matches(key, modifiers, shortcut)` |
|
|
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.
|
|
@@ -239,9 +244,16 @@ and `add_implicit` asks for the level-set artist explicitly — also for a funct
|
|
|
239
244
|
which is a pair of vertical lines. Only a *third* free symbol is refused: substitute parameters
|
|
240
245
|
first.
|
|
241
246
|
|
|
247
|
+
A sympy `Eq(lhs, rhs)` is always a level set: it is normalized to `lhs - rhs = 0`, and an
|
|
248
|
+
equation with only `x` still means the vertical lines where it holds (`Eq(x**2, 1)`), not
|
|
249
|
+
`y = x**2 - 1`. The canonical `expression` keeps the `Eq(...)` text and `expr` is the normalized
|
|
250
|
+
difference.
|
|
251
|
+
|
|
242
252
|
```python
|
|
253
|
+
x, y = sp.symbols("x y")
|
|
243
254
|
plot.add_curve("x**2 + y**2 - 1") # a circle: {x, y} routes to the level-set artist
|
|
244
|
-
plot.
|
|
255
|
+
plot.add_curve(sp.Eq(x**2 + y**2, 1)) # the same circle in equation form
|
|
256
|
+
plot.add_implicit(sp.Eq(x, sp.sin(y))) # asked for by name, equation form
|
|
245
257
|
plot.curves.at(0).expression = "x**2 + y**2 - 4" # re-routes the curve that is already there
|
|
246
258
|
plot.add_curve("a*sin(x)") # ValueError: substitute `a` first
|
|
247
259
|
```
|
|
@@ -305,10 +317,14 @@ plot.plot.camera.zoom_by(120, 0.5, 0.5, w, h) # one wheel notch, anchored at
|
|
|
305
317
|
|
|
306
318
|
## Keyboard, touch and animation
|
|
307
319
|
|
|
308
|
-
Clicking the plot
|
|
309
|
-
per press by default), `+`/`=` zooms in and
|
|
310
|
-
|
|
311
|
-
|
|
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:
|
|
323
|
+
one `keyPanStep` per `keyPanRepeatInterval` (100 ms by default), integrated every frame, so
|
|
324
|
+
there is no OS key-repeat delay or granular stepping. Several arrows combine, so `Left+Up`
|
|
325
|
+
moves diagonally at the normal speed. Every command is a property on the camera's `input`
|
|
326
|
+
object, so a host can rebind it — the value is a `QKeySequence` string, and `|` separates
|
|
327
|
+
alternatives (empty disables the command). `input.keyPanStep`, `input.keyPanRepeatInterval`,
|
|
312
328
|
`input.keyboardEnabled`, `panEnabled` and `zoomEnabled` shape the behaviour the same way.
|
|
313
329
|
|
|
314
330
|
```python
|
|
@@ -318,8 +334,9 @@ input.panLeftShortcut = "A" # rebind one arrow
|
|
|
318
334
|
input.panRightShortcut = "D"
|
|
319
335
|
input.zoomInShortcut = "Ctrl+="
|
|
320
336
|
input.zoomOutShortcut = "Ctrl+-"
|
|
321
|
-
input.keyPanStep = 0.2 # 20% of the viewport per
|
|
322
|
-
|
|
337
|
+
input.keyPanStep = 0.2 # 20% of the viewport per step
|
|
338
|
+
input.keyPanRepeatInterval = 80 # ms per step; smaller = faster pan/zoom
|
|
339
|
+
camera.animationDuration = 220 # ms; tap easing
|
|
323
340
|
```
|
|
324
341
|
|
|
325
342
|
Wheel and keyboard moves are **eased** (scale interpolates geometrically, so a zoom feels like
|
|
@@ -348,7 +365,7 @@ plot.available_themes # the names, for a host's picker (both dem
|
|
|
348
365
|
The 26 bundled sheets are Matplotlib's own styles, vendored verbatim into
|
|
349
366
|
`qmlmathplot/themes/*.mplstyle` and converted at load time: Matplotlib's `rcParams` keys are
|
|
350
367
|
mapped onto this library's property names, and keys describing things this library does not
|
|
351
|
-
have (
|
|
368
|
+
have (figure size, dpi, legend, marker styles, …) are ignored. `themes.names()` lists
|
|
352
369
|
them (`"default"` plus the 26 sheets). An unknown name raises `KeyError` and leaves the current
|
|
353
370
|
theme alone.
|
|
354
371
|
|
|
@@ -360,13 +377,77 @@ theme alone.
|
|
|
360
377
|
`x = 0` — drawn in `axis_color` / `axis_width`, and the **tick marks and labels ride those
|
|
361
378
|
axes** instead of hugging the item's edges. When 0 is off screen the axis sticks to the
|
|
362
379
|
nearest edge, so its labels are never lost (and a label never leaves the item); the grid
|
|
363
|
-
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).
|
|
364
392
|
- `camera.viewport` reports the last size the view sent, so a host can size an export from it.
|
|
365
393
|
- The tick *values* are nice numbers computed in Python (`Camera.tick_values()` /
|
|
366
394
|
`nice_ticks()`, steps of 1/2/5 x 10^n) and pushed through `ticksChanged`; QML only positions
|
|
367
395
|
and formats them, so a frame never recomputes ticks.
|
|
368
396
|
- `plot.ticks_visible = False` hides the whole furniture (axes, marks and labels).
|
|
369
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
|
+
|
|
370
451
|
## Export
|
|
371
452
|
|
|
372
453
|
`savefig()` writes a file, `to_image()` returns a `QImage`; both take the same keywords:
|
|
@@ -476,7 +557,7 @@ The supported path is GeoGebra's column idea implemented on the GPU: sample the
|
|
|
476
557
|
sub-pixel x grid, stroke the polyline, and fill the exact interval enclosure where a column
|
|
477
558
|
cannot be represented by a line.
|
|
478
559
|
|
|
479
|
-
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**
|
|
480
561
|
(0.25 logical-px spacing). Adjacent valid samples form a polyline, and coverage is
|
|
481
562
|
`R - distance to the nearest segment` — Desmos' stroke rule. The sample spacing is
|
|
482
563
|
sub-pixel, so the vertices themselves are sub-pixel and the segments are diagonal: the
|
|
@@ -511,7 +592,7 @@ cannot be represented by a line.
|
|
|
511
592
|
viewport bottom; no filled ray.
|
|
512
593
|
- **whole**: `DrawInterval.drawWhole` — one vertical segment at `x.low`.
|
|
513
594
|
Deliberate deviations, because this is a fragment shader and not the Java tuple list: the
|
|
514
|
-
|
|
595
|
+
14-sample polyline supersamples GGB's one interval per column for resolved stretches; a
|
|
515
596
|
dependent quotient's whole column is still suppressed (the fixed-x polyline draws the real
|
|
516
597
|
branches, avoiding the `x/sqrt(x**2)` connector); and a joined inverted column uses those
|
|
517
598
|
sample branches instead of replaying `JoinLines.inverted`'s neighbour decisions.
|
|
@@ -519,7 +600,7 @@ cannot be represented by a line.
|
|
|
519
600
|
never evaluated; a pixel just outside the domain still gets the band, which is centred
|
|
520
601
|
on the boundary.
|
|
521
602
|
|
|
522
|
-
An ordinary pixel costs **
|
|
603
|
+
An ordinary pixel costs **14 `F` evaluations** plus two interval evaluations; there is no
|
|
523
604
|
march, no tangent-foot projection and no column probe on the supported path. Expressions
|
|
524
605
|
with an operation the interval backend does not implement (`floor`, `sign`, …) compile the
|
|
525
606
|
legacy sampled band + ray + march path (`#define USE_INTERVAL 0`); it is the only user of
|
|
@@ -554,6 +635,8 @@ uv run ruff check src tests examples # bug-level lint (undefined names, unused
|
|
|
554
635
|
matplotlib's default style, the limits are forwarded to the camera, and a theme applies
|
|
555
636
|
(and re-applies as a reset) while an unknown name is rejected.
|
|
556
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.
|
|
557
640
|
- `test_qsb_cache.py`: the cache location/info/clear API and the age + LRU prune; unrelated
|
|
558
641
|
files in the temp directory are not touched.
|
|
559
642
|
- `test_shader_bake.py`: the stage of a baked `.qsb` must match its purpose, and
|
|
@@ -568,11 +651,11 @@ uv run ruff check src tests examples # bug-level lint (undefined names, unused
|
|
|
568
651
|
appearing inside a layout, an expression change / an invalid expression keeping the
|
|
569
652
|
previous graph, curves added/hidden/removed through the model, **the wheel over a
|
|
570
653
|
scroll area being consumed by the plot (zoom instead of scrolling the parent)**, the
|
|
571
|
-
fixed aspect being undistorted by the widget's shape, and the plot
|
|
572
|
-
|
|
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
|
|
573
656
|
needs one, and a process may only have one).
|
|
574
657
|
- `test_qtquick_coexistence.py`: the same drag conflict in Qt Quick (a `Flickable`
|
|
575
|
-
must not steal the pan).
|
|
658
|
+
must not steal the pan), plus Tab focus entering and leaving the QML plot.
|
|
576
659
|
- `test_handoff.py`: the matplotlib/sympy hand-off — the figure reflects the limits, aspect,
|
|
577
660
|
`adjustable`, grid, title, spines at zero and per-curve style; the sampling is uniform and NaN
|
|
578
661
|
outside the domain (so `log(x)` breaks); `savefig(backend="matplotlib")` writes real `.svg`,
|
|
@@ -666,14 +749,27 @@ Startup to the first frame is about **230 ms**, attributed as follows
|
|
|
666
749
|
Per-frame grab cycles (900×600, `grabToImage` — frame + readback, so an upper bound on
|
|
667
750
|
the GPU cost), steady state on the same window: `sin(1/x)` default **13.6 ms**, `tan(x)`
|
|
668
751
|
default **12.4 ms**, `sin(1/x)` at zoom 90 (worst case) **12.5 ms**. The previous
|
|
669
|
-
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
|
|
670
753
|
than the march's seed + tangent projection + walk + probe, and the march is now compiled
|
|
671
754
|
only for the legacy path. The 1-D fragment source is about 21 KB.
|
|
672
755
|
|
|
673
|
-
**Per-pixel `F` evaluations.** The supported path
|
|
674
|
-
|
|
675
|
-
march budget and no band-window evaluation.
|
|
676
|
-
|
|
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.
|
|
677
773
|
|
|
678
774
|
## Shader cache
|
|
679
775
|
|
|
@@ -728,6 +824,9 @@ measurement that settled each one). The Qt/packaging ones that shaped the code:
|
|
|
728
824
|
**device** pixels with an integral device width (`_deviceWidth`/`_snapLine`), not logical
|
|
729
825
|
half-pixels: the 1.07-logical axis is 2 device px at DPR 1.5 and otherwise alternated
|
|
730
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.
|
|
731
830
|
- `QQuickWidget.setProperty(name, …)` sets it on the **widget**, not on the QML root (the
|
|
732
831
|
widget has no such property, so it silently does nothing): inject the model with
|
|
733
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
|
-
| `ViewInput` | `keyboardEnabled`, `keyPanStep`, `panLeftShortcut` / `panRightShortcut` / `panUpShortcut` / `panDownShortcut` / `zoomInShortcut` / `zoomOutShortcut`, `matches(key, modifiers, shortcut)` |
|
|
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.
|
|
@@ -226,9 +231,16 @@ and `add_implicit` asks for the level-set artist explicitly — also for a funct
|
|
|
226
231
|
which is a pair of vertical lines. Only a *third* free symbol is refused: substitute parameters
|
|
227
232
|
first.
|
|
228
233
|
|
|
234
|
+
A sympy `Eq(lhs, rhs)` is always a level set: it is normalized to `lhs - rhs = 0`, and an
|
|
235
|
+
equation with only `x` still means the vertical lines where it holds (`Eq(x**2, 1)`), not
|
|
236
|
+
`y = x**2 - 1`. The canonical `expression` keeps the `Eq(...)` text and `expr` is the normalized
|
|
237
|
+
difference.
|
|
238
|
+
|
|
229
239
|
```python
|
|
240
|
+
x, y = sp.symbols("x y")
|
|
230
241
|
plot.add_curve("x**2 + y**2 - 1") # a circle: {x, y} routes to the level-set artist
|
|
231
|
-
plot.
|
|
242
|
+
plot.add_curve(sp.Eq(x**2 + y**2, 1)) # the same circle in equation form
|
|
243
|
+
plot.add_implicit(sp.Eq(x, sp.sin(y))) # asked for by name, equation form
|
|
232
244
|
plot.curves.at(0).expression = "x**2 + y**2 - 4" # re-routes the curve that is already there
|
|
233
245
|
plot.add_curve("a*sin(x)") # ValueError: substitute `a` first
|
|
234
246
|
```
|
|
@@ -292,10 +304,14 @@ plot.plot.camera.zoom_by(120, 0.5, 0.5, w, h) # one wheel notch, anchored at
|
|
|
292
304
|
|
|
293
305
|
## Keyboard, touch and animation
|
|
294
306
|
|
|
295
|
-
Clicking the plot
|
|
296
|
-
per press by default), `+`/`=` zooms in and
|
|
297
|
-
|
|
298
|
-
|
|
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:
|
|
310
|
+
one `keyPanStep` per `keyPanRepeatInterval` (100 ms by default), integrated every frame, so
|
|
311
|
+
there is no OS key-repeat delay or granular stepping. Several arrows combine, so `Left+Up`
|
|
312
|
+
moves diagonally at the normal speed. Every command is a property on the camera's `input`
|
|
313
|
+
object, so a host can rebind it — the value is a `QKeySequence` string, and `|` separates
|
|
314
|
+
alternatives (empty disables the command). `input.keyPanStep`, `input.keyPanRepeatInterval`,
|
|
299
315
|
`input.keyboardEnabled`, `panEnabled` and `zoomEnabled` shape the behaviour the same way.
|
|
300
316
|
|
|
301
317
|
```python
|
|
@@ -305,8 +321,9 @@ input.panLeftShortcut = "A" # rebind one arrow
|
|
|
305
321
|
input.panRightShortcut = "D"
|
|
306
322
|
input.zoomInShortcut = "Ctrl+="
|
|
307
323
|
input.zoomOutShortcut = "Ctrl+-"
|
|
308
|
-
input.keyPanStep = 0.2 # 20% of the viewport per
|
|
309
|
-
|
|
324
|
+
input.keyPanStep = 0.2 # 20% of the viewport per step
|
|
325
|
+
input.keyPanRepeatInterval = 80 # ms per step; smaller = faster pan/zoom
|
|
326
|
+
camera.animationDuration = 220 # ms; tap easing
|
|
310
327
|
```
|
|
311
328
|
|
|
312
329
|
Wheel and keyboard moves are **eased** (scale interpolates geometrically, so a zoom feels like
|
|
@@ -335,7 +352,7 @@ plot.available_themes # the names, for a host's picker (both dem
|
|
|
335
352
|
The 26 bundled sheets are Matplotlib's own styles, vendored verbatim into
|
|
336
353
|
`qmlmathplot/themes/*.mplstyle` and converted at load time: Matplotlib's `rcParams` keys are
|
|
337
354
|
mapped onto this library's property names, and keys describing things this library does not
|
|
338
|
-
have (
|
|
355
|
+
have (figure size, dpi, legend, marker styles, …) are ignored. `themes.names()` lists
|
|
339
356
|
them (`"default"` plus the 26 sheets). An unknown name raises `KeyError` and leaves the current
|
|
340
357
|
theme alone.
|
|
341
358
|
|
|
@@ -347,13 +364,77 @@ theme alone.
|
|
|
347
364
|
`x = 0` — drawn in `axis_color` / `axis_width`, and the **tick marks and labels ride those
|
|
348
365
|
axes** instead of hugging the item's edges. When 0 is off screen the axis sticks to the
|
|
349
366
|
nearest edge, so its labels are never lost (and a label never leaves the item); the grid
|
|
350
|
-
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).
|
|
351
379
|
- `camera.viewport` reports the last size the view sent, so a host can size an export from it.
|
|
352
380
|
- The tick *values* are nice numbers computed in Python (`Camera.tick_values()` /
|
|
353
381
|
`nice_ticks()`, steps of 1/2/5 x 10^n) and pushed through `ticksChanged`; QML only positions
|
|
354
382
|
and formats them, so a frame never recomputes ticks.
|
|
355
383
|
- `plot.ticks_visible = False` hides the whole furniture (axes, marks and labels).
|
|
356
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
|
+
|
|
357
438
|
## Export
|
|
358
439
|
|
|
359
440
|
`savefig()` writes a file, `to_image()` returns a `QImage`; both take the same keywords:
|
|
@@ -463,7 +544,7 @@ The supported path is GeoGebra's column idea implemented on the GPU: sample the
|
|
|
463
544
|
sub-pixel x grid, stroke the polyline, and fill the exact interval enclosure where a column
|
|
464
545
|
cannot be represented by a line.
|
|
465
546
|
|
|
466
|
-
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**
|
|
467
548
|
(0.25 logical-px spacing). Adjacent valid samples form a polyline, and coverage is
|
|
468
549
|
`R - distance to the nearest segment` — Desmos' stroke rule. The sample spacing is
|
|
469
550
|
sub-pixel, so the vertices themselves are sub-pixel and the segments are diagonal: the
|
|
@@ -498,7 +579,7 @@ cannot be represented by a line.
|
|
|
498
579
|
viewport bottom; no filled ray.
|
|
499
580
|
- **whole**: `DrawInterval.drawWhole` — one vertical segment at `x.low`.
|
|
500
581
|
Deliberate deviations, because this is a fragment shader and not the Java tuple list: the
|
|
501
|
-
|
|
582
|
+
14-sample polyline supersamples GGB's one interval per column for resolved stretches; a
|
|
502
583
|
dependent quotient's whole column is still suppressed (the fixed-x polyline draws the real
|
|
503
584
|
branches, avoiding the `x/sqrt(x**2)` connector); and a joined inverted column uses those
|
|
504
585
|
sample branches instead of replaying `JoinLines.inverted`'s neighbour decisions.
|
|
@@ -506,7 +587,7 @@ cannot be represented by a line.
|
|
|
506
587
|
never evaluated; a pixel just outside the domain still gets the band, which is centred
|
|
507
588
|
on the boundary.
|
|
508
589
|
|
|
509
|
-
An ordinary pixel costs **
|
|
590
|
+
An ordinary pixel costs **14 `F` evaluations** plus two interval evaluations; there is no
|
|
510
591
|
march, no tangent-foot projection and no column probe on the supported path. Expressions
|
|
511
592
|
with an operation the interval backend does not implement (`floor`, `sign`, …) compile the
|
|
512
593
|
legacy sampled band + ray + march path (`#define USE_INTERVAL 0`); it is the only user of
|
|
@@ -541,6 +622,8 @@ uv run ruff check src tests examples # bug-level lint (undefined names, unused
|
|
|
541
622
|
matplotlib's default style, the limits are forwarded to the camera, and a theme applies
|
|
542
623
|
(and re-applies as a reset) while an unknown name is rejected.
|
|
543
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.
|
|
544
627
|
- `test_qsb_cache.py`: the cache location/info/clear API and the age + LRU prune; unrelated
|
|
545
628
|
files in the temp directory are not touched.
|
|
546
629
|
- `test_shader_bake.py`: the stage of a baked `.qsb` must match its purpose, and
|
|
@@ -555,11 +638,11 @@ uv run ruff check src tests examples # bug-level lint (undefined names, unused
|
|
|
555
638
|
appearing inside a layout, an expression change / an invalid expression keeping the
|
|
556
639
|
previous graph, curves added/hidden/removed through the model, **the wheel over a
|
|
557
640
|
scroll area being consumed by the plot (zoom instead of scrolling the parent)**, the
|
|
558
|
-
fixed aspect being undistorted by the widget's shape, and the plot
|
|
559
|
-
|
|
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
|
|
560
643
|
needs one, and a process may only have one).
|
|
561
644
|
- `test_qtquick_coexistence.py`: the same drag conflict in Qt Quick (a `Flickable`
|
|
562
|
-
must not steal the pan).
|
|
645
|
+
must not steal the pan), plus Tab focus entering and leaving the QML plot.
|
|
563
646
|
- `test_handoff.py`: the matplotlib/sympy hand-off — the figure reflects the limits, aspect,
|
|
564
647
|
`adjustable`, grid, title, spines at zero and per-curve style; the sampling is uniform and NaN
|
|
565
648
|
outside the domain (so `log(x)` breaks); `savefig(backend="matplotlib")` writes real `.svg`,
|
|
@@ -653,14 +736,27 @@ Startup to the first frame is about **230 ms**, attributed as follows
|
|
|
653
736
|
Per-frame grab cycles (900×600, `grabToImage` — frame + readback, so an upper bound on
|
|
654
737
|
the GPU cost), steady state on the same window: `sin(1/x)` default **13.6 ms**, `tan(x)`
|
|
655
738
|
default **12.4 ms**, `sin(1/x)` at zoom 90 (worst case) **12.5 ms**. The previous
|
|
656
|
-
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
|
|
657
740
|
than the march's seed + tangent projection + walk + probe, and the march is now compiled
|
|
658
741
|
only for the legacy path. The 1-D fragment source is about 21 KB.
|
|
659
742
|
|
|
660
|
-
**Per-pixel `F` evaluations.** The supported path
|
|
661
|
-
|
|
662
|
-
march budget and no band-window evaluation.
|
|
663
|
-
|
|
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.
|
|
664
760
|
|
|
665
761
|
## Shader cache
|
|
666
762
|
|
|
@@ -715,6 +811,9 @@ measurement that settled each one). The Qt/packaging ones that shaped the code:
|
|
|
715
811
|
**device** pixels with an integral device width (`_deviceWidth`/`_snapLine`), not logical
|
|
716
812
|
half-pixels: the 1.07-logical axis is 2 device px at DPR 1.5 and otherwise alternated
|
|
717
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.
|
|
718
817
|
- `QQuickWidget.setProperty(name, …)` sets it on the **widget**, not on the QML root (the
|
|
719
818
|
widget has no such property, so it silently does nothing): inject the model with
|
|
720
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",
|