qmlmathplot 0.1.0__tar.gz → 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {qmlmathplot-0.1.0 → qmlmathplot-0.2.0}/PKG-INFO +127 -9
- {qmlmathplot-0.1.0 → qmlmathplot-0.2.0}/README.md +123 -7
- {qmlmathplot-0.1.0 → qmlmathplot-0.2.0}/pyproject.toml +9 -3
- {qmlmathplot-0.1.0 → qmlmathplot-0.2.0}/pyproject.toml.orig +8 -2
- {qmlmathplot-0.1.0 → qmlmathplot-0.2.0}/src/qmlmathplot/camera.py +28 -0
- {qmlmathplot-0.1.0 → qmlmathplot-0.2.0}/src/qmlmathplot/curve.py +5 -3
- qmlmathplot-0.2.0/src/qmlmathplot/export.py +164 -0
- qmlmathplot-0.2.0/src/qmlmathplot/mpl.py +232 -0
- qmlmathplot-0.2.0/src/qmlmathplot/plot.py +367 -0
- {qmlmathplot-0.1.0 → qmlmathplot-0.2.0}/src/qmlmathplot/qml/PlotView.qml +52 -11
- qmlmathplot-0.2.0/src/qmlmathplot/themes/default.mplstyle +26 -0
- {qmlmathplot-0.1.0 → qmlmathplot-0.2.0}/src/qmlmathplot/themes.py +11 -6
- {qmlmathplot-0.1.0 → qmlmathplot-0.2.0}/src/qmlmathplot/widget.py +15 -10
- qmlmathplot-0.1.0/src/qmlmathplot/plot.py +0 -213
- {qmlmathplot-0.1.0 → qmlmathplot-0.2.0}/src/qmlmathplot/__init__.py +0 -0
- {qmlmathplot-0.1.0 → qmlmathplot-0.2.0}/src/qmlmathplot/app.py +0 -0
- {qmlmathplot-0.1.0 → qmlmathplot-0.2.0}/src/qmlmathplot/model.py +0 -0
- {qmlmathplot-0.1.0 → qmlmathplot-0.2.0}/src/qmlmathplot/qsb.py +0 -0
- {qmlmathplot-0.1.0 → qmlmathplot-0.2.0}/src/qmlmathplot/themes/LICENSE.matplotlib +0 -0
- {qmlmathplot-0.1.0 → qmlmathplot-0.2.0}/src/qmlmathplot/themes/README.md +0 -0
- {qmlmathplot-0.1.0 → qmlmathplot-0.2.0}/src/qmlmathplot/themes/Solarize_Light2.mplstyle +0 -0
- {qmlmathplot-0.1.0 → qmlmathplot-0.2.0}/src/qmlmathplot/themes/bmh.mplstyle +0 -0
- {qmlmathplot-0.1.0 → qmlmathplot-0.2.0}/src/qmlmathplot/themes/classic.mplstyle +0 -0
- {qmlmathplot-0.1.0 → qmlmathplot-0.2.0}/src/qmlmathplot/themes/dark_background.mplstyle +0 -0
- {qmlmathplot-0.1.0 → qmlmathplot-0.2.0}/src/qmlmathplot/themes/fast.mplstyle +0 -0
- {qmlmathplot-0.1.0 → qmlmathplot-0.2.0}/src/qmlmathplot/themes/fivethirtyeight.mplstyle +0 -0
- {qmlmathplot-0.1.0 → qmlmathplot-0.2.0}/src/qmlmathplot/themes/ggplot.mplstyle +0 -0
- {qmlmathplot-0.1.0 → qmlmathplot-0.2.0}/src/qmlmathplot/themes/grayscale.mplstyle +0 -0
- {qmlmathplot-0.1.0 → qmlmathplot-0.2.0}/src/qmlmathplot/themes/petroff10.mplstyle +0 -0
- {qmlmathplot-0.1.0 → qmlmathplot-0.2.0}/src/qmlmathplot/themes/seaborn-v0_8-bright.mplstyle +0 -0
- {qmlmathplot-0.1.0 → qmlmathplot-0.2.0}/src/qmlmathplot/themes/seaborn-v0_8-colorblind.mplstyle +0 -0
- {qmlmathplot-0.1.0 → qmlmathplot-0.2.0}/src/qmlmathplot/themes/seaborn-v0_8-dark-palette.mplstyle +0 -0
- {qmlmathplot-0.1.0 → qmlmathplot-0.2.0}/src/qmlmathplot/themes/seaborn-v0_8-dark.mplstyle +0 -0
- {qmlmathplot-0.1.0 → qmlmathplot-0.2.0}/src/qmlmathplot/themes/seaborn-v0_8-darkgrid.mplstyle +0 -0
- {qmlmathplot-0.1.0 → qmlmathplot-0.2.0}/src/qmlmathplot/themes/seaborn-v0_8-deep.mplstyle +0 -0
- {qmlmathplot-0.1.0 → qmlmathplot-0.2.0}/src/qmlmathplot/themes/seaborn-v0_8-muted.mplstyle +0 -0
- {qmlmathplot-0.1.0 → qmlmathplot-0.2.0}/src/qmlmathplot/themes/seaborn-v0_8-notebook.mplstyle +0 -0
- {qmlmathplot-0.1.0 → qmlmathplot-0.2.0}/src/qmlmathplot/themes/seaborn-v0_8-paper.mplstyle +0 -0
- {qmlmathplot-0.1.0 → qmlmathplot-0.2.0}/src/qmlmathplot/themes/seaborn-v0_8-pastel.mplstyle +0 -0
- {qmlmathplot-0.1.0 → qmlmathplot-0.2.0}/src/qmlmathplot/themes/seaborn-v0_8-poster.mplstyle +0 -0
- {qmlmathplot-0.1.0 → qmlmathplot-0.2.0}/src/qmlmathplot/themes/seaborn-v0_8-talk.mplstyle +0 -0
- {qmlmathplot-0.1.0 → qmlmathplot-0.2.0}/src/qmlmathplot/themes/seaborn-v0_8-ticks.mplstyle +0 -0
- {qmlmathplot-0.1.0 → qmlmathplot-0.2.0}/src/qmlmathplot/themes/seaborn-v0_8-white.mplstyle +0 -0
- {qmlmathplot-0.1.0 → qmlmathplot-0.2.0}/src/qmlmathplot/themes/seaborn-v0_8-whitegrid.mplstyle +0 -0
- {qmlmathplot-0.1.0 → qmlmathplot-0.2.0}/src/qmlmathplot/themes/seaborn-v0_8.mplstyle +0 -0
- {qmlmathplot-0.1.0 → qmlmathplot-0.2.0}/src/qmlmathplot/themes/tableau-colorblind10.mplstyle +0 -0
- {qmlmathplot-0.1.0 → qmlmathplot-0.2.0}/src/qmlmathplot/view.py +0 -0
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: qmlmathplot
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Embeddable function plotter for Qt Quick / QtWidgets (implicit per-pixel drawing, MVVM)
|
|
5
5
|
Author: I-AM-A-NOOB
|
|
6
6
|
Author-email: I-AM-A-NOOB <suzc08@outlook.com>
|
|
7
7
|
Requires-Dist: sympy>=1.14
|
|
8
8
|
Requires-Dist: pyside6>=6.7
|
|
9
|
-
Requires-
|
|
9
|
+
Requires-Dist: matplotlib>=3.8 ; extra == 'matplotlib'
|
|
10
|
+
Requires-Python: >=3.11
|
|
11
|
+
Provides-Extra: matplotlib
|
|
10
12
|
Description-Content-Type: text/markdown
|
|
11
13
|
|
|
12
14
|
# QMLMathPlot
|
|
@@ -38,6 +40,10 @@ window.show()
|
|
|
38
40
|
app.exec()
|
|
39
41
|
```
|
|
40
42
|
|
|
43
|
+
An un-themed plot looks like matplotlib (white background, tab10 colours, grey grid, black
|
|
44
|
+
axes through the origin with the tick labels riding them) — the default style sheet is applied
|
|
45
|
+
at construction, so `plot.theme = "default"` is the reset.
|
|
46
|
+
|
|
41
47
|
`MathPlotWidget` keeps one curve's worth of convenience (`expression`, `error`,
|
|
42
48
|
`line_width`, `curve_color`, `background_color`, `aspect`, `view_bounds()`,
|
|
43
49
|
`reset_view()`, `zoom()`, `pan_pixels()`); everything else lives on the model behind it:
|
|
@@ -116,7 +122,7 @@ The model:
|
|
|
116
122
|
|
|
117
123
|
| Object | Members |
|
|
118
124
|
|---|---|
|
|
119
|
-
| `Plot` | `camera`, `curves`, `xlim`/`ylim` (forwarded), `add_curve()` / `remove_curve()`, and the style properties: `background`, `grid`, `grid_color`, `grid_width`, `grid_alpha`, `grid_style`, `color_cycle`, `line_width`, `text_color`, `tick_color`, `tick_length`, `font_size`, `tick_font_size`, `title`, `title_color`, `title_font_size`, `title_bold`, `ticks_visible`, `theme` |
|
|
125
|
+
| `Plot` | `camera`, `curves`, `xlim`/`ylim`/`aspect` (forwarded), `add_curve()` / `remove_curve()`, `available_themes`, and the style properties: `background`, `grid`, `grid_color`, `grid_width`, `grid_alpha`, `grid_style`, `axis_color`, `axis_width`, `axes_position`, `color_cycle`, `line_width`, `text_color`, `tick_color`, `tick_length`, `font_size`, `tick_font_size`, `title`, `title_color`, `title_font_size`, `title_bold`, `ticks_visible`, `theme` |
|
|
120
126
|
| `Camera` | `centre`, `zoom` (world units per logical pixel), `aspect`, `xlim`/`ylim` (derived), `ticks_x`/`ticks_y`, `zoomStep` / `panEnabled` / `zoomEnabled`, slots `setViewport(w, h)` / `zoom_by(delta, u, v, w, h)` / `pan_pixels(dx, dy)` / `reset()` |
|
|
121
127
|
| `Curve` | `expression`, `color`, `lineWidth`, `visible`, `label`, `error` (read-only), the baked `vertexShader` / `fragmentShader` |
|
|
122
128
|
| `CurveListModel` | the curves as a model (`curve`, `expression`, `color`, `lineWidth`, `visible`, `vertexShader`, `fragmentShader` roles) so QML puts one `ShaderEffect` behind each |
|
|
@@ -135,8 +141,8 @@ build order — is specified in [`docs/api-design.md`](docs/api-design.md). In s
|
|
|
135
141
|
Matplotlib *vocabulary* is kept where it is vocabulary (limits, grid, title, ticks, annotate),
|
|
136
142
|
while the *mechanism* is refused — no `Figure`/`Axes`/`subplots` (one view per widget, no
|
|
137
143
|
bounded page), no `FigureCanvas`/`draw()` (the scene is live), no toolbar and no legend (host
|
|
138
|
-
chrome), and an export is a canvas region at a pixel size rather than a re-rendered page
|
|
139
|
-
implemented
|
|
144
|
+
chrome), and an export is a canvas region at a pixel size rather than a re-rendered page
|
|
145
|
+
(§9, implemented — see *Export* below).
|
|
140
146
|
|
|
141
147
|
## The camera (centre, zoom, aspect)
|
|
142
148
|
|
|
@@ -164,12 +170,16 @@ plot.plot.camera.zoom_by(120, 0.5, 0.5, w, h) # one wheel notch, anchored at
|
|
|
164
170
|
|
|
165
171
|
## Themes
|
|
166
172
|
|
|
167
|
-
`Plot.theme` applies a style sheet to the style properties above
|
|
173
|
+
`Plot.theme` applies a style sheet to the style properties above; the default is
|
|
174
|
+
matplotlib's own default style, so an untouched plot already looks like matplotlib and
|
|
175
|
+
`plot.theme = "default"` is the reset:
|
|
168
176
|
|
|
169
177
|
```python
|
|
170
178
|
plot.theme = "ggplot" # any of the bundled sheets
|
|
179
|
+
plot.theme = "default" # back to matplotlib's look
|
|
171
180
|
plot.theme = "my-style" # themes/my-style.mplstyle next to the package
|
|
172
181
|
plot.theme = "path/to/any.mplstyle" # any file in the same format
|
|
182
|
+
plot.available_themes # the names, for a host's picker (both demos use one)
|
|
173
183
|
```
|
|
174
184
|
|
|
175
185
|
The 26 bundled sheets are Matplotlib's own styles, vendored verbatim into
|
|
@@ -179,6 +189,86 @@ have (spines, figure size, dpi, legend, marker styles, …) are ignored. `themes
|
|
|
179
189
|
them (`"default"` plus the 26 sheets). An unknown name raises `KeyError` and leaves the current
|
|
180
190
|
theme alone.
|
|
181
191
|
|
|
192
|
+
## Axes, grid and ticks
|
|
193
|
+
|
|
194
|
+
- The **grid** is drawn in QML (a `Canvas`), one line per tick position, in `grid_color` /
|
|
195
|
+
`grid_width` / `grid_alpha` / `grid_style` (`-`, `--`, `:`, `-.`).
|
|
196
|
+
- The **axes** are the lines through world (0,0) — the x-axis at `y = 0`, the y-axis at
|
|
197
|
+
`x = 0` — drawn in `axis_color` / `axis_width`, and the **tick marks and labels ride those
|
|
198
|
+
axes** instead of hugging the item's edges. When 0 is off screen the axis sticks to the
|
|
199
|
+
nearest edge, so its labels are never lost (and a label never leaves the item); the grid
|
|
200
|
+
stays where it is. `plot.axes_position = "edge"` restores the edge-pinned behaviour.
|
|
201
|
+
- `camera.viewport` reports the last size the view sent, so a host can size an export from it.
|
|
202
|
+
- The tick *values* are nice numbers computed in Python (`Camera.tick_values()` /
|
|
203
|
+
`nice_ticks()`, steps of 1/2/5 x 10^n) and pushed through `ticksChanged`; QML only positions
|
|
204
|
+
and formats them, so a frame never recomputes ticks.
|
|
205
|
+
- `plot.ticks_visible = False` hides the whole furniture (axes, marks and labels).
|
|
206
|
+
|
|
207
|
+
## Export
|
|
208
|
+
|
|
209
|
+
`savefig()` writes a file, `to_image()` returns a `QImage`; both take the same keywords:
|
|
210
|
+
|
|
211
|
+
```python
|
|
212
|
+
plot.savefig("out.png", xlim=(-1, 1), ylim=(-1, 1), width=1200, height=400)
|
|
213
|
+
plot.savefig("hi.png", dpi=2.0) # the live view, twice the pixels
|
|
214
|
+
image = plot.to_image(width=800, height=600) # -> QImage
|
|
215
|
+
plot.to_image() # the live view, its own size
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
- `xlim` / `ylim` default to the **camera**: the live centre and scale, extended to the export
|
|
219
|
+
size. The *region* is a parameter, so an export can cover more canvas than the widget shows.
|
|
220
|
+
- `width` / `height` are **device pixels** and default to the live view's device size; `dpi`
|
|
221
|
+
multiplies both (`dpi=2.0` doubles the pixels).
|
|
222
|
+
- `adjustable` reconciles a requested range with a requested size when their aspects disagree —
|
|
223
|
+
Matplotlib's parameter, and its own default:
|
|
224
|
+
|
|
225
|
+
| `adjustable` | What happens |
|
|
226
|
+
|---|---|
|
|
227
|
+
| `"box"` (**default**) | one scale, taken from the live scale ratio, chosen so the requested range fits: the range is centred and the leftover pixels stay background — *nothing is distorted* |
|
|
228
|
+
| `"datalim"` | the camera's own scales, with the limits expanding (never cropped) to fill the size |
|
|
229
|
+
| `"stretch"` | the requested range maps onto the requested size exactly — independent scales, so a mismatched aspect **distorts** (the "report figure" mode) |
|
|
230
|
+
|
|
231
|
+
With no explicit range all three agree, because the range derived from the camera already has
|
|
232
|
+
the export size's aspect.
|
|
233
|
+
- `transparent=True` clears to alpha 0.
|
|
234
|
+
- Everything else follows the live styling (curves, grid, axes, theme), so an export cannot
|
|
235
|
+
drift from what the user sees. Rendering goes through an offscreen `QQuickWidget`, so the
|
|
236
|
+
export needs a `QApplication` (a clear error is raised instead of a crash if there is none).
|
|
237
|
+
|
|
238
|
+
### Vector output, matplotlib and sympy
|
|
239
|
+
|
|
240
|
+
`to_matplotlib()` hands the plot to matplotlib (one `Axes`) and `savefig(..., backend="matplotlib")`
|
|
241
|
+
renders through it, so `.svg`/`.pdf` work; `to_sympy()` builds the same figure through
|
|
242
|
+
`sympy.plotting`, which is what you want when the figure should live in sympy's world:
|
|
243
|
+
|
|
244
|
+
```python
|
|
245
|
+
figure = plot.to_matplotlib(xlim=(-1, 1), ylim=(-1, 1)) # -> matplotlib.figure.Figure
|
|
246
|
+
plot.savefig("out.svg", backend="matplotlib") # vector, matplotlib's own savefig
|
|
247
|
+
handle = plot.to_sympy(xlim=(-1, 1))
|
|
248
|
+
handle.save("out.png")
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
matplotlib is an **optional** extra (`pip install "qmlmathplot[matplotlib]"`); nothing in the
|
|
252
|
+
package imports it until one of these is called, and the error names the extra. `sympy` itself is
|
|
253
|
+
a hard dependency, so `to_sympy()` needs matplotlib only because its plotting backend does.
|
|
254
|
+
Neither hand-off needs a Qt application: a `Plot` works in a plain script, which is what makes it
|
|
255
|
+
usable next to a notebook or a matplotlib-only tool.
|
|
256
|
+
|
|
257
|
+
**This is a second renderer, and a sampled one.** matplotlib draws polylines, so each curve is
|
|
258
|
+
evaluated at `samples` points (default 2000) and an oscillating function such as `sin(1/x)`
|
|
259
|
+
aliases — exactly what the Qt view avoids by evaluating the expression per pixel. SymPy does not
|
|
260
|
+
solve it either, and its adaptive mode does not help: measured on `sin(1/x)` over [-1, 1],
|
|
261
|
+
`sympy.plotting.plot(adaptive=True)` uses **289** points (fewer than a fixed 2000), emits no
|
|
262
|
+
warning in sympy 1.14, and draws the same block of vertical connectors. The difference is how
|
|
263
|
+
the two fail: a sampled renderer draws an artefact, the Qt view draws the envelope the data
|
|
264
|
+
actually supports. (`to_sympy()` returns the object sympy made, so its own `adaptive` mode is
|
|
265
|
+
available there.) Fonts and antialiasing are matplotlib's, not the Qt view's, so the two
|
|
266
|
+
renderers cannot look identical, and the style mapping is partial: background, grid (on/off and
|
|
267
|
+
colour/width/alpha/dash), per-curve colour and width, tick and text colours, font sizes, title,
|
|
268
|
+
axis colour/width, the limits, the aspect with matplotlib's own `adjustable`, and
|
|
269
|
+
`axes_position="zero"` (spines through the origin, no box). What it buys is vector output and
|
|
270
|
+
matplotlib's ecosystem — nothing else.
|
|
271
|
+
|
|
182
272
|
## RHI backends
|
|
183
273
|
|
|
184
274
|
`--backend {d3d11,d3d12,vulkan,metal,opengl,null}`; **if unspecified, Qt's default
|
|
@@ -197,6 +287,8 @@ backend is used**. The implementation sets `QSG_RHI_BACKEND` before
|
|
|
197
287
|
| Plot | `src/qmlmathplot/plot.py` | `Plot`: the camera, the curves and the styling the view binds to |
|
|
198
288
|
| View | `src/qmlmathplot/qml/PlotView.qml` (+ `view.py`) | QML: background, grid, ticks/title, one `ShaderEffect` per curve, pan/zoom input; `view.py` registers the types and yields the component path |
|
|
199
289
|
| Themes | `src/qmlmathplot/themes.py` + `themes/` | the vendored Matplotlib style sheets, resolved onto the `Plot`'s properties |
|
|
290
|
+
| Export | `src/qmlmathplot/export.py` | `Plot.savefig()` / `to_image()`: the canvas region rendered offscreen at a pixel size (QtWidgets is imported lazily) |
|
|
291
|
+
| Hand-off | `src/qmlmathplot/mpl.py` | `Plot.to_matplotlib()` / `to_sympy()` / `savefig(backend="matplotlib")`: a sampled second renderer for vector output (matplotlib is an optional extra, imported lazily) |
|
|
200
292
|
| Bake | `src/qmlmathplot/qsb.py` | GLSL → `.qsb` (PySide6 ships `qsb.exe`), cached by source hash |
|
|
201
293
|
| Widget | `src/qmlmathplot/widget.py` | `MathPlotWidget`: QQuickWidget bridge, drops straight into QtWidgets layouts |
|
|
202
294
|
| Entry | `src/qmlmathplot/app.py` | command line / standalone window (the `qmlmathplot` script, `examples/minimal.py`) |
|
|
@@ -237,15 +329,18 @@ QSG_RHI_BACKEND=opengl uv run pytest -m gui
|
|
|
237
329
|
- `test_curve.py`: a failed expression keeps the last working shaders, the style
|
|
238
330
|
setters are idempotent, and the list model reports the right roles.
|
|
239
331
|
- `test_plot.py`: the plot's properties convert, notify exactly once and are wired
|
|
240
|
-
(a PySide property with an unresolved notify name silently loses it), the
|
|
241
|
-
are forwarded to the camera, and a theme applies
|
|
332
|
+
(a PySide property with an unresolved notify name silently loses it), the defaults are
|
|
333
|
+
matplotlib's default style, the limits are forwarded to the camera, and a theme applies
|
|
334
|
+
(and re-applies as a reset) while an unknown name is rejected.
|
|
242
335
|
- `test_themes.py`: the vendored style sheets resolve to this library's properties.
|
|
243
336
|
- `test_shader_bake.py`: the stage of a baked `.qsb` must match its purpose, and
|
|
244
337
|
the four backend targets GLSL/HLSL/MSL/SPIR-V must all be covered.
|
|
245
338
|
- `test_render_smoke.py`: open a window, render, read pixels; verify the thin-line
|
|
246
339
|
shape, `sin(1/x)` filling its ±1 envelope, no |y|>1 artefacts outside the central
|
|
247
340
|
columns, poles not connected, nothing drawn outside the domain, and the QML
|
|
248
|
-
furniture (grid lines exactly at the tick positions,
|
|
341
|
+
furniture (grid lines exactly at the tick positions, the axes crossing at the origin
|
|
342
|
+
with their labels on them, the axis pinning to the edge once the origin is panned
|
|
343
|
+
away, the title).
|
|
249
344
|
- `test_widget.py`: `MathPlotWidget` coexisting with neighbouring widgets — the plot
|
|
250
345
|
appearing inside a layout, an expression change / an invalid expression keeping the
|
|
251
346
|
previous graph, curves added/hidden/removed through the model, **the wheel over a
|
|
@@ -255,6 +350,17 @@ QSG_RHI_BACKEND=opengl uv run pytest -m gui
|
|
|
255
350
|
needs one, and a process may only have one).
|
|
256
351
|
- `test_qtquick_coexistence.py`: the same drag conflict in Qt Quick (a `Flickable`
|
|
257
352
|
must not steal the pan).
|
|
353
|
+
- `test_handoff.py`: the matplotlib/sympy hand-off — the figure reflects the limits, aspect,
|
|
354
|
+
`adjustable`, grid, title, spines at zero and per-curve style; the sampling is uniform and NaN
|
|
355
|
+
outside the domain (so `log(x)` breaks); `savefig(backend="matplotlib")` writes real `.svg`,
|
|
356
|
+
`.pdf` and `.png`; `to_sympy().save()` writes a file and keeps per-curve colours; importing
|
|
357
|
+
`qmlmathplot` does not import matplotlib, and hiding matplotlib produces an error naming the
|
|
358
|
+
extra.
|
|
359
|
+
- `test_export.py`: `to_image()`/`savefig()` — the image is exactly the requested device size
|
|
360
|
+
(`dpi` multiplies it), the no-argument form reproduces the live centre and scale, the three
|
|
361
|
+
`adjustable` modes reconcile range and size as documented (a world 45-degree line stays at 45
|
|
362
|
+
in `"box"`, with background margins; `"stretch"` fills the frame and distorts), the live
|
|
363
|
+
camera and background survive an export, and `transparent` clears to alpha 0.
|
|
258
364
|
|
|
259
365
|
## Pitfalls near the `sin(1/x)` singularity (fixed — do not repeat)
|
|
260
366
|
|
|
@@ -355,6 +461,12 @@ measurement that settled each one). The Qt/packaging ones that shaped the code:
|
|
|
355
461
|
same x 9 times, and macros are preprocessor expansion with no call semantics.
|
|
356
462
|
- PySide ties a property's notify signal to the `Signal` **object**; a name string
|
|
357
463
|
silently leaves the property without one (and QML bindings then never refresh).
|
|
464
|
+
- Snap **hairlines** (grid) to a half pixel, but never a **styled** line: rounding the
|
|
465
|
+
axis position put the 1.07 px y-axis half off the centre column, so the line came out
|
|
466
|
+
pale where it should be black.
|
|
467
|
+
- `QQuickWidget.setProperty(name, …)` sets it on the **widget**, not on the QML root (the
|
|
468
|
+
widget has no such property, so it silently does nothing): inject the model with
|
|
469
|
+
`widget.rootObject().setProperty("plot", plot)`, as `MathPlotWidget` does.
|
|
358
470
|
|
|
359
471
|
## Releasing
|
|
360
472
|
|
|
@@ -376,6 +488,12 @@ be rehearsed:
|
|
|
376
488
|
pip install -i https://test.pypi.org/simple/ qmlmathplot
|
|
377
489
|
```
|
|
378
490
|
|
|
491
|
+
If the publish step fails with `invalid-publisher: valid token, but no corresponding
|
|
492
|
+
publisher`, the trusted-publisher entry is missing or does not match. The action prints the
|
|
493
|
+
claims it presented (`repository`, `workflow_ref`, `environment`) — the entry must match them
|
|
494
|
+
exactly, **including the environment name**, and it must exist on the index the run targets: a
|
|
495
|
+
manual run goes to **test.pypi.org**, a tag push to **pypi.org**, so both need an entry.
|
|
496
|
+
|
|
379
497
|
The workflow runs on Windows: that is the platform this library is verified on (the shader baker
|
|
380
498
|
`qsb` ships inside the PySide6 wheel and is only confirmed to be present there, see `qsb.py`),
|
|
381
499
|
and the GUI tests need a GPU scene graph no runner has. The artifact is `py3-none-any`, so the
|
|
@@ -27,6 +27,10 @@ window.show()
|
|
|
27
27
|
app.exec()
|
|
28
28
|
```
|
|
29
29
|
|
|
30
|
+
An un-themed plot looks like matplotlib (white background, tab10 colours, grey grid, black
|
|
31
|
+
axes through the origin with the tick labels riding them) — the default style sheet is applied
|
|
32
|
+
at construction, so `plot.theme = "default"` is the reset.
|
|
33
|
+
|
|
30
34
|
`MathPlotWidget` keeps one curve's worth of convenience (`expression`, `error`,
|
|
31
35
|
`line_width`, `curve_color`, `background_color`, `aspect`, `view_bounds()`,
|
|
32
36
|
`reset_view()`, `zoom()`, `pan_pixels()`); everything else lives on the model behind it:
|
|
@@ -105,7 +109,7 @@ The model:
|
|
|
105
109
|
|
|
106
110
|
| Object | Members |
|
|
107
111
|
|---|---|
|
|
108
|
-
| `Plot` | `camera`, `curves`, `xlim`/`ylim` (forwarded), `add_curve()` / `remove_curve()`, and the style properties: `background`, `grid`, `grid_color`, `grid_width`, `grid_alpha`, `grid_style`, `color_cycle`, `line_width`, `text_color`, `tick_color`, `tick_length`, `font_size`, `tick_font_size`, `title`, `title_color`, `title_font_size`, `title_bold`, `ticks_visible`, `theme` |
|
|
112
|
+
| `Plot` | `camera`, `curves`, `xlim`/`ylim`/`aspect` (forwarded), `add_curve()` / `remove_curve()`, `available_themes`, and the style properties: `background`, `grid`, `grid_color`, `grid_width`, `grid_alpha`, `grid_style`, `axis_color`, `axis_width`, `axes_position`, `color_cycle`, `line_width`, `text_color`, `tick_color`, `tick_length`, `font_size`, `tick_font_size`, `title`, `title_color`, `title_font_size`, `title_bold`, `ticks_visible`, `theme` |
|
|
109
113
|
| `Camera` | `centre`, `zoom` (world units per logical pixel), `aspect`, `xlim`/`ylim` (derived), `ticks_x`/`ticks_y`, `zoomStep` / `panEnabled` / `zoomEnabled`, slots `setViewport(w, h)` / `zoom_by(delta, u, v, w, h)` / `pan_pixels(dx, dy)` / `reset()` |
|
|
110
114
|
| `Curve` | `expression`, `color`, `lineWidth`, `visible`, `label`, `error` (read-only), the baked `vertexShader` / `fragmentShader` |
|
|
111
115
|
| `CurveListModel` | the curves as a model (`curve`, `expression`, `color`, `lineWidth`, `visible`, `vertexShader`, `fragmentShader` roles) so QML puts one `ShaderEffect` behind each |
|
|
@@ -124,8 +128,8 @@ build order — is specified in [`docs/api-design.md`](docs/api-design.md). In s
|
|
|
124
128
|
Matplotlib *vocabulary* is kept where it is vocabulary (limits, grid, title, ticks, annotate),
|
|
125
129
|
while the *mechanism* is refused — no `Figure`/`Axes`/`subplots` (one view per widget, no
|
|
126
130
|
bounded page), no `FigureCanvas`/`draw()` (the scene is live), no toolbar and no legend (host
|
|
127
|
-
chrome), and an export is a canvas region at a pixel size rather than a re-rendered page
|
|
128
|
-
implemented
|
|
131
|
+
chrome), and an export is a canvas region at a pixel size rather than a re-rendered page
|
|
132
|
+
(§9, implemented — see *Export* below).
|
|
129
133
|
|
|
130
134
|
## The camera (centre, zoom, aspect)
|
|
131
135
|
|
|
@@ -153,12 +157,16 @@ plot.plot.camera.zoom_by(120, 0.5, 0.5, w, h) # one wheel notch, anchored at
|
|
|
153
157
|
|
|
154
158
|
## Themes
|
|
155
159
|
|
|
156
|
-
`Plot.theme` applies a style sheet to the style properties above
|
|
160
|
+
`Plot.theme` applies a style sheet to the style properties above; the default is
|
|
161
|
+
matplotlib's own default style, so an untouched plot already looks like matplotlib and
|
|
162
|
+
`plot.theme = "default"` is the reset:
|
|
157
163
|
|
|
158
164
|
```python
|
|
159
165
|
plot.theme = "ggplot" # any of the bundled sheets
|
|
166
|
+
plot.theme = "default" # back to matplotlib's look
|
|
160
167
|
plot.theme = "my-style" # themes/my-style.mplstyle next to the package
|
|
161
168
|
plot.theme = "path/to/any.mplstyle" # any file in the same format
|
|
169
|
+
plot.available_themes # the names, for a host's picker (both demos use one)
|
|
162
170
|
```
|
|
163
171
|
|
|
164
172
|
The 26 bundled sheets are Matplotlib's own styles, vendored verbatim into
|
|
@@ -168,6 +176,86 @@ have (spines, figure size, dpi, legend, marker styles, …) are ignored. `themes
|
|
|
168
176
|
them (`"default"` plus the 26 sheets). An unknown name raises `KeyError` and leaves the current
|
|
169
177
|
theme alone.
|
|
170
178
|
|
|
179
|
+
## Axes, grid and ticks
|
|
180
|
+
|
|
181
|
+
- The **grid** is drawn in QML (a `Canvas`), one line per tick position, in `grid_color` /
|
|
182
|
+
`grid_width` / `grid_alpha` / `grid_style` (`-`, `--`, `:`, `-.`).
|
|
183
|
+
- The **axes** are the lines through world (0,0) — the x-axis at `y = 0`, the y-axis at
|
|
184
|
+
`x = 0` — drawn in `axis_color` / `axis_width`, and the **tick marks and labels ride those
|
|
185
|
+
axes** instead of hugging the item's edges. When 0 is off screen the axis sticks to the
|
|
186
|
+
nearest edge, so its labels are never lost (and a label never leaves the item); the grid
|
|
187
|
+
stays where it is. `plot.axes_position = "edge"` restores the edge-pinned behaviour.
|
|
188
|
+
- `camera.viewport` reports the last size the view sent, so a host can size an export from it.
|
|
189
|
+
- The tick *values* are nice numbers computed in Python (`Camera.tick_values()` /
|
|
190
|
+
`nice_ticks()`, steps of 1/2/5 x 10^n) and pushed through `ticksChanged`; QML only positions
|
|
191
|
+
and formats them, so a frame never recomputes ticks.
|
|
192
|
+
- `plot.ticks_visible = False` hides the whole furniture (axes, marks and labels).
|
|
193
|
+
|
|
194
|
+
## Export
|
|
195
|
+
|
|
196
|
+
`savefig()` writes a file, `to_image()` returns a `QImage`; both take the same keywords:
|
|
197
|
+
|
|
198
|
+
```python
|
|
199
|
+
plot.savefig("out.png", xlim=(-1, 1), ylim=(-1, 1), width=1200, height=400)
|
|
200
|
+
plot.savefig("hi.png", dpi=2.0) # the live view, twice the pixels
|
|
201
|
+
image = plot.to_image(width=800, height=600) # -> QImage
|
|
202
|
+
plot.to_image() # the live view, its own size
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
- `xlim` / `ylim` default to the **camera**: the live centre and scale, extended to the export
|
|
206
|
+
size. The *region* is a parameter, so an export can cover more canvas than the widget shows.
|
|
207
|
+
- `width` / `height` are **device pixels** and default to the live view's device size; `dpi`
|
|
208
|
+
multiplies both (`dpi=2.0` doubles the pixels).
|
|
209
|
+
- `adjustable` reconciles a requested range with a requested size when their aspects disagree —
|
|
210
|
+
Matplotlib's parameter, and its own default:
|
|
211
|
+
|
|
212
|
+
| `adjustable` | What happens |
|
|
213
|
+
|---|---|
|
|
214
|
+
| `"box"` (**default**) | one scale, taken from the live scale ratio, chosen so the requested range fits: the range is centred and the leftover pixels stay background — *nothing is distorted* |
|
|
215
|
+
| `"datalim"` | the camera's own scales, with the limits expanding (never cropped) to fill the size |
|
|
216
|
+
| `"stretch"` | the requested range maps onto the requested size exactly — independent scales, so a mismatched aspect **distorts** (the "report figure" mode) |
|
|
217
|
+
|
|
218
|
+
With no explicit range all three agree, because the range derived from the camera already has
|
|
219
|
+
the export size's aspect.
|
|
220
|
+
- `transparent=True` clears to alpha 0.
|
|
221
|
+
- Everything else follows the live styling (curves, grid, axes, theme), so an export cannot
|
|
222
|
+
drift from what the user sees. Rendering goes through an offscreen `QQuickWidget`, so the
|
|
223
|
+
export needs a `QApplication` (a clear error is raised instead of a crash if there is none).
|
|
224
|
+
|
|
225
|
+
### Vector output, matplotlib and sympy
|
|
226
|
+
|
|
227
|
+
`to_matplotlib()` hands the plot to matplotlib (one `Axes`) and `savefig(..., backend="matplotlib")`
|
|
228
|
+
renders through it, so `.svg`/`.pdf` work; `to_sympy()` builds the same figure through
|
|
229
|
+
`sympy.plotting`, which is what you want when the figure should live in sympy's world:
|
|
230
|
+
|
|
231
|
+
```python
|
|
232
|
+
figure = plot.to_matplotlib(xlim=(-1, 1), ylim=(-1, 1)) # -> matplotlib.figure.Figure
|
|
233
|
+
plot.savefig("out.svg", backend="matplotlib") # vector, matplotlib's own savefig
|
|
234
|
+
handle = plot.to_sympy(xlim=(-1, 1))
|
|
235
|
+
handle.save("out.png")
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
matplotlib is an **optional** extra (`pip install "qmlmathplot[matplotlib]"`); nothing in the
|
|
239
|
+
package imports it until one of these is called, and the error names the extra. `sympy` itself is
|
|
240
|
+
a hard dependency, so `to_sympy()` needs matplotlib only because its plotting backend does.
|
|
241
|
+
Neither hand-off needs a Qt application: a `Plot` works in a plain script, which is what makes it
|
|
242
|
+
usable next to a notebook or a matplotlib-only tool.
|
|
243
|
+
|
|
244
|
+
**This is a second renderer, and a sampled one.** matplotlib draws polylines, so each curve is
|
|
245
|
+
evaluated at `samples` points (default 2000) and an oscillating function such as `sin(1/x)`
|
|
246
|
+
aliases — exactly what the Qt view avoids by evaluating the expression per pixel. SymPy does not
|
|
247
|
+
solve it either, and its adaptive mode does not help: measured on `sin(1/x)` over [-1, 1],
|
|
248
|
+
`sympy.plotting.plot(adaptive=True)` uses **289** points (fewer than a fixed 2000), emits no
|
|
249
|
+
warning in sympy 1.14, and draws the same block of vertical connectors. The difference is how
|
|
250
|
+
the two fail: a sampled renderer draws an artefact, the Qt view draws the envelope the data
|
|
251
|
+
actually supports. (`to_sympy()` returns the object sympy made, so its own `adaptive` mode is
|
|
252
|
+
available there.) Fonts and antialiasing are matplotlib's, not the Qt view's, so the two
|
|
253
|
+
renderers cannot look identical, and the style mapping is partial: background, grid (on/off and
|
|
254
|
+
colour/width/alpha/dash), per-curve colour and width, tick and text colours, font sizes, title,
|
|
255
|
+
axis colour/width, the limits, the aspect with matplotlib's own `adjustable`, and
|
|
256
|
+
`axes_position="zero"` (spines through the origin, no box). What it buys is vector output and
|
|
257
|
+
matplotlib's ecosystem — nothing else.
|
|
258
|
+
|
|
171
259
|
## RHI backends
|
|
172
260
|
|
|
173
261
|
`--backend {d3d11,d3d12,vulkan,metal,opengl,null}`; **if unspecified, Qt's default
|
|
@@ -186,6 +274,8 @@ backend is used**. The implementation sets `QSG_RHI_BACKEND` before
|
|
|
186
274
|
| Plot | `src/qmlmathplot/plot.py` | `Plot`: the camera, the curves and the styling the view binds to |
|
|
187
275
|
| View | `src/qmlmathplot/qml/PlotView.qml` (+ `view.py`) | QML: background, grid, ticks/title, one `ShaderEffect` per curve, pan/zoom input; `view.py` registers the types and yields the component path |
|
|
188
276
|
| Themes | `src/qmlmathplot/themes.py` + `themes/` | the vendored Matplotlib style sheets, resolved onto the `Plot`'s properties |
|
|
277
|
+
| Export | `src/qmlmathplot/export.py` | `Plot.savefig()` / `to_image()`: the canvas region rendered offscreen at a pixel size (QtWidgets is imported lazily) |
|
|
278
|
+
| Hand-off | `src/qmlmathplot/mpl.py` | `Plot.to_matplotlib()` / `to_sympy()` / `savefig(backend="matplotlib")`: a sampled second renderer for vector output (matplotlib is an optional extra, imported lazily) |
|
|
189
279
|
| Bake | `src/qmlmathplot/qsb.py` | GLSL → `.qsb` (PySide6 ships `qsb.exe`), cached by source hash |
|
|
190
280
|
| Widget | `src/qmlmathplot/widget.py` | `MathPlotWidget`: QQuickWidget bridge, drops straight into QtWidgets layouts |
|
|
191
281
|
| Entry | `src/qmlmathplot/app.py` | command line / standalone window (the `qmlmathplot` script, `examples/minimal.py`) |
|
|
@@ -226,15 +316,18 @@ QSG_RHI_BACKEND=opengl uv run pytest -m gui
|
|
|
226
316
|
- `test_curve.py`: a failed expression keeps the last working shaders, the style
|
|
227
317
|
setters are idempotent, and the list model reports the right roles.
|
|
228
318
|
- `test_plot.py`: the plot's properties convert, notify exactly once and are wired
|
|
229
|
-
(a PySide property with an unresolved notify name silently loses it), the
|
|
230
|
-
are forwarded to the camera, and a theme applies
|
|
319
|
+
(a PySide property with an unresolved notify name silently loses it), the defaults are
|
|
320
|
+
matplotlib's default style, the limits are forwarded to the camera, and a theme applies
|
|
321
|
+
(and re-applies as a reset) while an unknown name is rejected.
|
|
231
322
|
- `test_themes.py`: the vendored style sheets resolve to this library's properties.
|
|
232
323
|
- `test_shader_bake.py`: the stage of a baked `.qsb` must match its purpose, and
|
|
233
324
|
the four backend targets GLSL/HLSL/MSL/SPIR-V must all be covered.
|
|
234
325
|
- `test_render_smoke.py`: open a window, render, read pixels; verify the thin-line
|
|
235
326
|
shape, `sin(1/x)` filling its ±1 envelope, no |y|>1 artefacts outside the central
|
|
236
327
|
columns, poles not connected, nothing drawn outside the domain, and the QML
|
|
237
|
-
furniture (grid lines exactly at the tick positions,
|
|
328
|
+
furniture (grid lines exactly at the tick positions, the axes crossing at the origin
|
|
329
|
+
with their labels on them, the axis pinning to the edge once the origin is panned
|
|
330
|
+
away, the title).
|
|
238
331
|
- `test_widget.py`: `MathPlotWidget` coexisting with neighbouring widgets — the plot
|
|
239
332
|
appearing inside a layout, an expression change / an invalid expression keeping the
|
|
240
333
|
previous graph, curves added/hidden/removed through the model, **the wheel over a
|
|
@@ -244,6 +337,17 @@ QSG_RHI_BACKEND=opengl uv run pytest -m gui
|
|
|
244
337
|
needs one, and a process may only have one).
|
|
245
338
|
- `test_qtquick_coexistence.py`: the same drag conflict in Qt Quick (a `Flickable`
|
|
246
339
|
must not steal the pan).
|
|
340
|
+
- `test_handoff.py`: the matplotlib/sympy hand-off — the figure reflects the limits, aspect,
|
|
341
|
+
`adjustable`, grid, title, spines at zero and per-curve style; the sampling is uniform and NaN
|
|
342
|
+
outside the domain (so `log(x)` breaks); `savefig(backend="matplotlib")` writes real `.svg`,
|
|
343
|
+
`.pdf` and `.png`; `to_sympy().save()` writes a file and keeps per-curve colours; importing
|
|
344
|
+
`qmlmathplot` does not import matplotlib, and hiding matplotlib produces an error naming the
|
|
345
|
+
extra.
|
|
346
|
+
- `test_export.py`: `to_image()`/`savefig()` — the image is exactly the requested device size
|
|
347
|
+
(`dpi` multiplies it), the no-argument form reproduces the live centre and scale, the three
|
|
348
|
+
`adjustable` modes reconcile range and size as documented (a world 45-degree line stays at 45
|
|
349
|
+
in `"box"`, with background margins; `"stretch"` fills the frame and distorts), the live
|
|
350
|
+
camera and background survive an export, and `transparent` clears to alpha 0.
|
|
247
351
|
|
|
248
352
|
## Pitfalls near the `sin(1/x)` singularity (fixed — do not repeat)
|
|
249
353
|
|
|
@@ -344,6 +448,12 @@ measurement that settled each one). The Qt/packaging ones that shaped the code:
|
|
|
344
448
|
same x 9 times, and macros are preprocessor expansion with no call semantics.
|
|
345
449
|
- PySide ties a property's notify signal to the `Signal` **object**; a name string
|
|
346
450
|
silently leaves the property without one (and QML bindings then never refresh).
|
|
451
|
+
- Snap **hairlines** (grid) to a half pixel, but never a **styled** line: rounding the
|
|
452
|
+
axis position put the 1.07 px y-axis half off the centre column, so the line came out
|
|
453
|
+
pale where it should be black.
|
|
454
|
+
- `QQuickWidget.setProperty(name, …)` sets it on the **widget**, not on the QML root (the
|
|
455
|
+
widget has no such property, so it silently does nothing): inject the model with
|
|
456
|
+
`widget.rootObject().setProperty("plot", plot)`, as `MathPlotWidget` does.
|
|
347
457
|
|
|
348
458
|
## Releasing
|
|
349
459
|
|
|
@@ -365,6 +475,12 @@ be rehearsed:
|
|
|
365
475
|
pip install -i https://test.pypi.org/simple/ qmlmathplot
|
|
366
476
|
```
|
|
367
477
|
|
|
478
|
+
If the publish step fails with `invalid-publisher: valid token, but no corresponding
|
|
479
|
+
publisher`, the trusted-publisher entry is missing or does not match. The action prints the
|
|
480
|
+
claims it presented (`repository`, `workflow_ref`, `environment`) — the entry must match them
|
|
481
|
+
exactly, **including the environment name**, and it must exist on the index the run targets: a
|
|
482
|
+
manual run goes to **test.pypi.org**, a tag push to **pypi.org**, so both need an entry.
|
|
483
|
+
|
|
368
484
|
The workflow runs on Windows: that is the platform this library is verified on (the shader baker
|
|
369
485
|
`qsb` ships inside the PySide6 wheel and is only confirmed to be present there, see `qsb.py`),
|
|
370
486
|
and the GUI tests need a GPU scene graph no runner has. The artifact is `py3-none-any`, so the
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "qmlmathplot"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.2.0"
|
|
4
4
|
description = "Embeddable function plotter for Qt Quick / QtWidgets (implicit per-pixel drawing, MVVM)"
|
|
5
5
|
readme = "README.md"
|
|
6
|
-
requires-python = ">=3.
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
7
|
dependencies = [
|
|
8
8
|
"sympy>=1.14",
|
|
9
9
|
"PySide6>=6.7",
|
|
@@ -13,6 +13,9 @@ dependencies = [
|
|
|
13
13
|
name = "I-AM-A-NOOB"
|
|
14
14
|
email = "suzc08@outlook.com"
|
|
15
15
|
|
|
16
|
+
[project.optional-dependencies]
|
|
17
|
+
matplotlib = ["matplotlib>=3.8"]
|
|
18
|
+
|
|
16
19
|
[project.scripts]
|
|
17
20
|
qmlmathplot = "qmlmathplot.app:main"
|
|
18
21
|
|
|
@@ -21,7 +24,10 @@ requires = ["uv_build>=0.12.18,<0.13.0"]
|
|
|
21
24
|
build-backend = "uv_build"
|
|
22
25
|
|
|
23
26
|
[dependency-groups]
|
|
24
|
-
dev = [
|
|
27
|
+
dev = [
|
|
28
|
+
"pytest>=9.1.1",
|
|
29
|
+
"matplotlib>=3.8",
|
|
30
|
+
]
|
|
25
31
|
|
|
26
32
|
[tool.pytest.ini_options]
|
|
27
33
|
testpaths = ["tests"]
|
|
@@ -1,17 +1,22 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "qmlmathplot"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.2.0"
|
|
4
4
|
description = "Embeddable function plotter for Qt Quick / QtWidgets (implicit per-pixel drawing, MVVM)"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
authors = [
|
|
7
7
|
{ name = "I-AM-A-NOOB", email = "suzc08@outlook.com" }
|
|
8
8
|
]
|
|
9
|
-
requires-python = ">=3.
|
|
9
|
+
requires-python = ">=3.11"
|
|
10
10
|
dependencies = [
|
|
11
11
|
"sympy>=1.14",
|
|
12
12
|
"PySide6>=6.7",
|
|
13
13
|
]
|
|
14
14
|
|
|
15
|
+
[project.optional-dependencies]
|
|
16
|
+
# The matplotlib hand-off (Plot.to_matplotlib / savefig(backend="matplotlib")) is optional:
|
|
17
|
+
# nothing in the package imports matplotlib until that path is used.
|
|
18
|
+
matplotlib = ["matplotlib>=3.8"]
|
|
19
|
+
|
|
15
20
|
[project.scripts]
|
|
16
21
|
qmlmathplot = "qmlmathplot.app:main"
|
|
17
22
|
|
|
@@ -22,6 +27,7 @@ build-backend = "uv_build"
|
|
|
22
27
|
[dependency-groups]
|
|
23
28
|
dev = [
|
|
24
29
|
"pytest>=9.1.1",
|
|
30
|
+
"matplotlib>=3.8", # the hand-off tests need it (the feature itself stays optional)
|
|
25
31
|
]
|
|
26
32
|
|
|
27
33
|
[tool.pytest.ini_options]
|
|
@@ -219,6 +219,13 @@ class Camera(QObject):
|
|
|
219
219
|
xlim: QVector2D = Property("QVariant", _get_xlim, _set_xlim, notify=viewChanged)
|
|
220
220
|
ylim: QVector2D = Property("QVariant", _get_ylim, _set_ylim, notify=viewChanged)
|
|
221
221
|
|
|
222
|
+
def _get_viewport(self) -> QVector2D:
|
|
223
|
+
width, height = self._effective_size()
|
|
224
|
+
return QVector2D(width, height)
|
|
225
|
+
|
|
226
|
+
#: Last reported size in logical pixels (the reference size before the first report).
|
|
227
|
+
viewport: QVector2D = Property("QVariant", _get_viewport, notify=viewChanged)
|
|
228
|
+
|
|
222
229
|
# --------------------------------------------------------------- ticks
|
|
223
230
|
def tick_values(self) -> tuple[list[tuple[float, str]], list[tuple[float, str]]]:
|
|
224
231
|
"""Ticks for both axes of the visible range, as ``(value, label)`` pairs."""
|
|
@@ -280,6 +287,27 @@ class Camera(QObject):
|
|
|
280
287
|
self.viewChanged.emit()
|
|
281
288
|
self.ticksChanged.emit()
|
|
282
289
|
|
|
290
|
+
@Slot(float, float, float, float)
|
|
291
|
+
def set_view(self, centre_x: float, centre_y: float, scale_x: float, scale_y: float) -> None:
|
|
292
|
+
"""Set the centre and *both* scales at once (world units per logical pixel).
|
|
293
|
+
|
|
294
|
+
The camera normally derives the y scale from ``aspect``; this is the primitive for a
|
|
295
|
+
caller that has two scales in hand — the exporter borrowing the camera for a render,
|
|
296
|
+
and restoring it afterwards. ``"auto"`` stays "auto" (the scales are simply set), a
|
|
297
|
+
numeric aspect is re-derived from the pair so the two scales are exactly as asked.
|
|
298
|
+
"""
|
|
299
|
+
scale_x = min(max(float(scale_x), self.MIN_SCALE), self.MAX_SCALE)
|
|
300
|
+
scale_y = min(max(float(scale_y), self.MIN_SCALE), self.MAX_SCALE)
|
|
301
|
+
self._centre = QVector2D(float(centre_x), float(centre_y))
|
|
302
|
+
self._zoom = scale_x
|
|
303
|
+
if isinstance(self._aspect, str):
|
|
304
|
+
self._y_scale = scale_y
|
|
305
|
+
else:
|
|
306
|
+
self._aspect = scale_x / scale_y
|
|
307
|
+
self.aspectChanged.emit()
|
|
308
|
+
self._home = False
|
|
309
|
+
self._view_changed()
|
|
310
|
+
|
|
283
311
|
# --------------------------------------------------------------- slots
|
|
284
312
|
@Slot(float, float)
|
|
285
313
|
def setViewport(self, width: float, height: float) -> None:
|
|
@@ -25,9 +25,11 @@ from .qsb import bake
|
|
|
25
25
|
|
|
26
26
|
__all__ = ["Curve", "CurveListModel"]
|
|
27
27
|
|
|
28
|
-
#: First colour of the default cycle (
|
|
29
|
-
DEFAULT_COLOR = "#
|
|
30
|
-
|
|
28
|
+
#: First colour of the default cycle (matplotlib's "tab10" blue, as in its default style).
|
|
29
|
+
DEFAULT_COLOR = "#1f77b4"
|
|
30
|
+
#: matplotlib's default ten-colour cycle.
|
|
31
|
+
DEFAULT_COLOR_CYCLE = ("#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd",
|
|
32
|
+
"#8c564b", "#e377c2", "#7f7f7f", "#bcbd22", "#17becf")
|
|
31
33
|
|
|
32
34
|
|
|
33
35
|
def _color(value: object) -> QColor:
|