qmlmathplot 0.3.0__tar.gz → 0.4.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.
Files changed (59) hide show
  1. {qmlmathplot-0.3.0 → qmlmathplot-0.4.0}/PKG-INFO +317 -113
  2. {qmlmathplot-0.3.0 → qmlmathplot-0.4.0}/README.md +316 -112
  3. {qmlmathplot-0.3.0 → qmlmathplot-0.4.0}/pyproject.toml +12 -1
  4. {qmlmathplot-0.3.0 → qmlmathplot-0.4.0}/pyproject.toml.orig +11 -1
  5. {qmlmathplot-0.3.0 → qmlmathplot-0.4.0}/src/qmlmathplot/__init__.py +21 -1
  6. qmlmathplot-0.4.0/src/qmlmathplot/animation.py +87 -0
  7. {qmlmathplot-0.3.0 → qmlmathplot-0.4.0}/src/qmlmathplot/camera.py +187 -33
  8. qmlmathplot-0.4.0/src/qmlmathplot/canvas.py +107 -0
  9. {qmlmathplot-0.3.0 → qmlmathplot-0.4.0}/src/qmlmathplot/curve.py +103 -23
  10. {qmlmathplot-0.3.0 → qmlmathplot-0.4.0}/src/qmlmathplot/export.py +2 -2
  11. qmlmathplot-0.4.0/src/qmlmathplot/figure.py +442 -0
  12. qmlmathplot-0.4.0/src/qmlmathplot/input.py +124 -0
  13. qmlmathplot-0.4.0/src/qmlmathplot/interval.py +561 -0
  14. qmlmathplot-0.4.0/src/qmlmathplot/model.py +1413 -0
  15. {qmlmathplot-0.3.0 → qmlmathplot-0.4.0}/src/qmlmathplot/plot.py +6 -0
  16. qmlmathplot-0.4.0/src/qmlmathplot/pyplot.py +330 -0
  17. qmlmathplot-0.4.0/src/qmlmathplot/qml/PlotCurves.qml +29 -0
  18. qmlmathplot-0.4.0/src/qmlmathplot/qml/PlotFurniture.qml +228 -0
  19. qmlmathplot-0.4.0/src/qmlmathplot/qml/PlotInput.qml +134 -0
  20. qmlmathplot-0.4.0/src/qmlmathplot/qml/PlotView.qml +134 -0
  21. qmlmathplot-0.4.0/src/qmlmathplot/qsb.py +269 -0
  22. {qmlmathplot-0.3.0 → qmlmathplot-0.4.0}/src/qmlmathplot/view.py +1 -1
  23. {qmlmathplot-0.3.0 → qmlmathplot-0.4.0}/src/qmlmathplot/widget.py +53 -23
  24. qmlmathplot-0.3.0/src/qmlmathplot/model.py +0 -697
  25. qmlmathplot-0.3.0/src/qmlmathplot/qml/PlotView.qml +0 -288
  26. qmlmathplot-0.3.0/src/qmlmathplot/qsb.py +0 -96
  27. {qmlmathplot-0.3.0 → qmlmathplot-0.4.0}/src/qmlmathplot/__main__.py +0 -0
  28. {qmlmathplot-0.3.0 → qmlmathplot-0.4.0}/src/qmlmathplot/app.py +0 -0
  29. {qmlmathplot-0.3.0 → qmlmathplot-0.4.0}/src/qmlmathplot/mpl.py +0 -0
  30. {qmlmathplot-0.3.0 → qmlmathplot-0.4.0}/src/qmlmathplot/themes/LICENSE.matplotlib +0 -0
  31. {qmlmathplot-0.3.0 → qmlmathplot-0.4.0}/src/qmlmathplot/themes/README.md +0 -0
  32. {qmlmathplot-0.3.0 → qmlmathplot-0.4.0}/src/qmlmathplot/themes/Solarize_Light2.mplstyle +0 -0
  33. {qmlmathplot-0.3.0 → qmlmathplot-0.4.0}/src/qmlmathplot/themes/bmh.mplstyle +0 -0
  34. {qmlmathplot-0.3.0 → qmlmathplot-0.4.0}/src/qmlmathplot/themes/classic.mplstyle +0 -0
  35. {qmlmathplot-0.3.0 → qmlmathplot-0.4.0}/src/qmlmathplot/themes/dark_background.mplstyle +0 -0
  36. {qmlmathplot-0.3.0 → qmlmathplot-0.4.0}/src/qmlmathplot/themes/default.mplstyle +0 -0
  37. {qmlmathplot-0.3.0 → qmlmathplot-0.4.0}/src/qmlmathplot/themes/fast.mplstyle +0 -0
  38. {qmlmathplot-0.3.0 → qmlmathplot-0.4.0}/src/qmlmathplot/themes/fivethirtyeight.mplstyle +0 -0
  39. {qmlmathplot-0.3.0 → qmlmathplot-0.4.0}/src/qmlmathplot/themes/ggplot.mplstyle +0 -0
  40. {qmlmathplot-0.3.0 → qmlmathplot-0.4.0}/src/qmlmathplot/themes/grayscale.mplstyle +0 -0
  41. {qmlmathplot-0.3.0 → qmlmathplot-0.4.0}/src/qmlmathplot/themes/petroff10.mplstyle +0 -0
  42. {qmlmathplot-0.3.0 → qmlmathplot-0.4.0}/src/qmlmathplot/themes/seaborn-v0_8-bright.mplstyle +0 -0
  43. {qmlmathplot-0.3.0 → qmlmathplot-0.4.0}/src/qmlmathplot/themes/seaborn-v0_8-colorblind.mplstyle +0 -0
  44. {qmlmathplot-0.3.0 → qmlmathplot-0.4.0}/src/qmlmathplot/themes/seaborn-v0_8-dark-palette.mplstyle +0 -0
  45. {qmlmathplot-0.3.0 → qmlmathplot-0.4.0}/src/qmlmathplot/themes/seaborn-v0_8-dark.mplstyle +0 -0
  46. {qmlmathplot-0.3.0 → qmlmathplot-0.4.0}/src/qmlmathplot/themes/seaborn-v0_8-darkgrid.mplstyle +0 -0
  47. {qmlmathplot-0.3.0 → qmlmathplot-0.4.0}/src/qmlmathplot/themes/seaborn-v0_8-deep.mplstyle +0 -0
  48. {qmlmathplot-0.3.0 → qmlmathplot-0.4.0}/src/qmlmathplot/themes/seaborn-v0_8-muted.mplstyle +0 -0
  49. {qmlmathplot-0.3.0 → qmlmathplot-0.4.0}/src/qmlmathplot/themes/seaborn-v0_8-notebook.mplstyle +0 -0
  50. {qmlmathplot-0.3.0 → qmlmathplot-0.4.0}/src/qmlmathplot/themes/seaborn-v0_8-paper.mplstyle +0 -0
  51. {qmlmathplot-0.3.0 → qmlmathplot-0.4.0}/src/qmlmathplot/themes/seaborn-v0_8-pastel.mplstyle +0 -0
  52. {qmlmathplot-0.3.0 → qmlmathplot-0.4.0}/src/qmlmathplot/themes/seaborn-v0_8-poster.mplstyle +0 -0
  53. {qmlmathplot-0.3.0 → qmlmathplot-0.4.0}/src/qmlmathplot/themes/seaborn-v0_8-talk.mplstyle +0 -0
  54. {qmlmathplot-0.3.0 → qmlmathplot-0.4.0}/src/qmlmathplot/themes/seaborn-v0_8-ticks.mplstyle +0 -0
  55. {qmlmathplot-0.3.0 → qmlmathplot-0.4.0}/src/qmlmathplot/themes/seaborn-v0_8-white.mplstyle +0 -0
  56. {qmlmathplot-0.3.0 → qmlmathplot-0.4.0}/src/qmlmathplot/themes/seaborn-v0_8-whitegrid.mplstyle +0 -0
  57. {qmlmathplot-0.3.0 → qmlmathplot-0.4.0}/src/qmlmathplot/themes/seaborn-v0_8.mplstyle +0 -0
  58. {qmlmathplot-0.3.0 → qmlmathplot-0.4.0}/src/qmlmathplot/themes/tableau-colorblind10.mplstyle +0 -0
  59. {qmlmathplot-0.3.0 → qmlmathplot-0.4.0}/src/qmlmathplot/themes.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: qmlmathplot
3
- Version: 0.3.0
3
+ Version: 0.4.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>
@@ -126,6 +126,15 @@ PlotView { // inject a Plot, or let the component create i
126
126
  }
127
127
  ```
128
128
 
129
+ For a standalone view with no injected model, the curve is explicit:
130
+
131
+ ```qml
132
+ PlotView { defaultExpression: "sin(x)"; anchors.fill: parent }
133
+ ```
134
+
135
+ There is no hidden default curve: the component creates its own empty `Plot` when none is
136
+ injected, and `defaultExpression` is the only way it adds one.
137
+
129
138
  `PlotView` exposes `mapToScreen(x, y)` / `mapFromScreen(point)` plus the forwarders
130
139
  `camera` and `curves`; input handling (pan by left-drag, wheel zoom anchored at the cursor)
131
140
  is built in and switchable through `camera.panEnabled` / `camera.zoomEnabled`.
@@ -134,8 +143,9 @@ The model:
134
143
 
135
144
  | Object | Members |
136
145
  |---|---|
137
- | `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`, `title_color`, `title_font_size`, `title_bold`, `ticks_visible`, `theme` |
138
- | `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()` |
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` |
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)` |
139
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` |
140
150
  | `CurveListModel` | the curves as a model (`curve`, `expression`, `color`, `lineWidth`, `visible`, `vertexShader`, `fragmentShader` roles) so QML puts one `ShaderEffect` behind each |
141
151
 
@@ -150,11 +160,75 @@ QTimer.singleShot(1000, lambda: setattr(curve, "expression", "sin(3/x)"))
150
160
 
151
161
  The design — the camera model, the Qt property/signal contract, the object model and the
152
162
  build order — is specified in [`docs/api-design.md`](docs/api-design.md). In short: the
153
- Matplotlib *vocabulary* is kept where it is vocabulary (limits, grid, title, ticks, annotate),
154
- while the *mechanism* is refused — no `Figure`/`Axes`/`subplots` (one view per widget, no
155
- bounded page), no `FigureCanvas`/`draw()` (the scene is live), no toolbar and no legend (host
156
- chrome), and an export is a canvas region at a pixel size rather than a re-rendered page
157
- (§9, implemented — see *Export* below).
163
+ Matplotlib *vocabulary* is kept where it is vocabulary (limits, grid, title, ticks), while the
164
+ *mechanism* is refused — no `Figure`/`Axes`/`subplots` in the core (one view per widget, no
165
+ bounded page), no redraw loop (the scene is live), no toolbar and no legend (host chrome), and
166
+ an export is a canvas region at a pixel size rather than a re-rendered page (§9, implemented —
167
+ see *Export* below). A **thin compatibility facade** (`Figure`, `Axes`, `FigureCanvasQt`,
168
+ `qmlmathplot.pyplot`) provides the familiar names for porting scripts; it maps onto the same
169
+ model and refuses the mechanisms that do not fit (see *Matplotlib-style facade* below).
170
+
171
+ ## Matplotlib-style facade
172
+
173
+ For porting scripts and for code that expects Matplotlib's vocabulary, the package ships a
174
+ **thin** compatibility layer. It maps onto the same `Plot`/`Camera` model and refuses the
175
+ mechanisms that do not fit an infinite, live canvas.
176
+
177
+ ```python
178
+ from qmlmathplot import Figure, FigureCanvasQt
179
+
180
+ fig = Figure(figsize=(6.4, 4.8)) # stored for the window size only; no page is implied
181
+ ax = fig.gca() # exactly one axes per figure
182
+ ax.plot("sin(1/x)", label="sin(1/x)")
183
+ ax.set_xlim(-1, 1)
184
+ ax.set_ylim(-2, 2)
185
+ ax.set_title("sin(1/x)")
186
+ ax.set_xlabel("x")
187
+ ax.set_ylabel("y")
188
+ ax.grid(True)
189
+ ax.set_aspect("equal")
190
+
191
+ canvas = FigureCanvasQt(fig) # a QWidget, like MathPlotWidget
192
+ layout.addWidget(canvas)
193
+ canvas.draw() # no-op: the Qt scene is already live
194
+ ```
195
+
196
+ The same model through a pyplot-shaped module (global current figure, for scripts and
197
+ notebooks; applications should keep the object API):
198
+
199
+ ```python
200
+ import qmlmathplot.pyplot as plt
201
+
202
+ plt.figure()
203
+ plt.plot("sin(1/x)")
204
+ plt.xlim(-1, 1)
205
+ plt.title("sin(1/x)")
206
+ plt.xlabel("x")
207
+ plt.grid(True)
208
+ plt.savefig("out.png") # Qt raster export
209
+ plt.show() # blocks in a script; use block=False in a host
210
+ ```
211
+
212
+ What maps: one figure/one axes (`subplots()` and `subplot(111)`), `plot(expression)`,
213
+ `xlim`/`ylim`, `title`/`xlabel`/`ylabel`, `grid`, `aspect`, `savefig`/`to_image`, themes.
214
+ `plt.gcf()`/`gca()`/`sca()`/`close()`/`show()` follow Matplotlib's current-figure shape.
215
+
216
+ What is deliberately different:
217
+
218
+ - **One view per figure.** `subplots(2, 1)`/`add_subplot(211)` raise `NotImplementedError`;
219
+ several panels are several `Figure` objects in a Qt layout, not a `GridSpec`.
220
+ - **Expressions, not x/y arrays.** `ax.plot("sin(x)")` is native; `ax.plot(x, y)` is refused
221
+ because the Qt renderer evaluates an expression per pixel. For sampled data use
222
+ `Plot.to_matplotlib()` (the hand-off), or add a data-series artist.
223
+ - **`draw()` / `draw_idle()` are no-ops.** The scene repaints from model signals; there is no
224
+ deferred renderer or blitting.
225
+ - **`savefig` is a region at a pixel size**, not a page at a dpi. `width`/`height` are device
226
+ pixels and `dpi` is a multiplier; `backend="matplotlib"` is the vector route.
227
+ - **No legends, annotations, log axes or manual ticks yet.** `legend()`, `annotate()`,
228
+ `set_xscale()` and `set_xticks()` raise with the reason; chrome belongs to the host.
229
+ - **Themes are per figure**, not `rcParams`; `plt` has no global style state beyond the current
230
+ figure.
231
+ - **`mpl_connect`/`blit` are absent.** Qt signals are the event system.
158
232
 
159
233
  ## Implicit curves
160
234
 
@@ -173,17 +247,37 @@ plot.add_curve("a*sin(x)") # ValueError: substitute `a` first
173
247
  ```
174
248
 
175
249
  The renderer is the same idea as the 1-D stroke, one dimension up: one evaluation of `F` and its
176
- two partials per pixel give the distance to the level set in *pixels*,
177
- `d = |F| / sqrt((Fx·dx)² + (Fy·dy)²)`, and the coverage is `clamp(lineWidth/2 + 0.5 - d, 0, 1)` —
178
- analytic antialiasing and a stroke width that does not depend on the angle, with no sampling grid
179
- (the unit circle measures the same 3 device pixels at 0°, 45°, 90° and 135° for `lineWidth=2` at
180
- DPR 1.5).
181
-
182
- Known limit: **smooth, single-sheet level sets**. Thin features and several branches through one
183
- pixel (`sin(1/(x*x + y*y))`), and 2-D domains/poles, are not handled yet — the assessment is
184
- `docs/api-design.md` §6b; pixels where `F` is undefined are dropped. The matplotlib hand-off
185
- skips implicit curves (a level set has no polyline to sample), while the Qt export renders them
186
- like anything else.
250
+ two partials per pixel give a *candidate* distance to the level set in pixels,
251
+ `d = |F| / sqrt((Fx·dx)² + (Fy·dy)²)`, and the coverage is
252
+ `clamp(lineWidth/2 + 0.5 - d, 0, 1)` — analytic antialiasing and a stroke width that does not
253
+ depend on the angle, with no sampling grid (the unit circle measures the same 3 device pixels at
254
+ 0°, 45°, 90° and 135° for `lineWidth=2` at DPR 1.5).
255
+
256
+ That quotient is a pseudo-zero at a pole and at a diverging domain boundary, so it is
257
+ **certified by projecting onto the level set**: two damped Newton steps from the pixel (or, for
258
+ a pixel outside the domain, from the nearest in-domain sample), accepting only a converged
259
+ residual (`|F| <= 0.25·R·|grad F|`) and using the distance to the projected point. The domain
260
+ therefore clips the *curve*, not the stroke: the half of the band lying outside is painted, so
261
+ `x-log(y-1)` does not thin out as it approaches `y = 1` and `y-log(x)` reaches `x = 0` in a deep
262
+ view. A `POLE(x,y)` factor (e.g. `cos(y)` for `tan(y)`) rejects a normal probe that crosses a
263
+ singularity, so `x-tan(y)` does not paint its asymptotes; far-out garbage lines are still
264
+ refused (the stroke is allowed one radius outside the domain, no more).
265
+
266
+ The pixel is also a **marching-squares cell**, borrowed from GeoGebra's
267
+ `ImplicitCurveMarchingRect`/`AdaptiveQuadTree`: the four corner values are evaluated when every
268
+ corner is in the domain and no pole factor changes sign between them; the edge crossings are
269
+ interpolated and the cell's contour segment is stroked (the saddle case is paired using the cell
270
+ centre's sign). The first-order/Newton path still supplies the sub-pixel width; the cell term
271
+ adds the coverage of a thin or tangent branch whose centre projection fails. A **singular cell**
272
+ (`|Fx| + |Fy| < 1e-3` in world units, plus `|F| < 0.05`) gets a small disc, so an isolated point
273
+ such as `x**2 + y**2 = 0` is not left to the projection alone.
274
+
275
+ Still not ported: GGB's Bernstein subdivision (**polynomial-only**), its adaptive quad-tree
276
+ refinement and the `ContourAssembler`/`ContourLinker` (they exist to produce a selectable vector
277
+ path, not to rasterise). A closed loop that fits entirely inside one pixel with no corner sign
278
+ change is not seen by the four-corner test; the projection is the only fallback there. The
279
+ matplotlib hand-off skips implicit curves (a level set has no polyline to sample), while the Qt
280
+ export renders them like anything else.
187
281
 
188
282
  ## The camera (centre, zoom, aspect)
189
283
 
@@ -209,6 +303,34 @@ plot.aspect = "auto" # back to following the widget
209
303
  plot.plot.camera.zoom_by(120, 0.5, 0.5, w, h) # one wheel notch, anchored at the centre
210
304
  ```
211
305
 
306
+ ## Keyboard, touch and animation
307
+
308
+ Clicking the plot gives it focus; while it has focus the arrow keys pan (10% of the viewport
309
+ per press by default), `+`/`=` zooms in and `-`/`_` zooms out. Every command is a property on
310
+ the camera's `input` object, so a host can rebind it — the value is a `QKeySequence` string,
311
+ and `|` separates alternatives (empty disables the command). `input.keyPanStep`,
312
+ `input.keyboardEnabled`, `panEnabled` and `zoomEnabled` shape the behaviour the same way.
313
+
314
+ ```python
315
+ camera = plot.plot.camera
316
+ input = camera.input
317
+ input.panLeftShortcut = "A" # rebind one arrow
318
+ input.panRightShortcut = "D"
319
+ input.zoomInShortcut = "Ctrl+="
320
+ input.zoomOutShortcut = "Ctrl+-"
321
+ input.keyPanStep = 0.2 # 20% of the viewport per arrow press
322
+ camera.animationDuration = 220 # ms; 0 disables easing
323
+ ```
324
+
325
+ Wheel and keyboard moves are **eased** (scale interpolates geometrically, so a zoom feels like
326
+ a constant rate); repeated wheel notches accumulate onto the in-flight target instead of
327
+ restarting from the last frame. Mouse drags and touch gestures are deliberately direct — the
328
+ pointer or the fingers are the animation.
329
+
330
+ Touch is built in: one finger pans, two fingers pinch to magnify and pan together, keeping the
331
+ world point under the finger centroid. They need no focus and work in both embedding modes;
332
+ like the mouse, they cancel an in-flight eased move as soon as they start.
333
+
212
334
  ## Themes
213
335
 
214
336
  `Plot.theme` applies a style sheet to the style properties above; the default is
@@ -325,16 +447,21 @@ conversion) and `mpl.py` (the sampled hand-off) import no Qt at all** — they a
325
447
  without a `QApplication`. `Camera`, `Curve` and `Plot` are the **ViewModel**: `QObject`s whose
326
448
  `Property`es carry notify signals, which is precisely what the QML component binds to — and why
327
449
  a Python-side assignment updates the view with no call in between. `view.py`, `PlotView.qml`,
328
- `export.py` and `widget.py` are the View/embedding layer and do need Qt; `export.py` and
329
- `widget.py` import QtWidgets lazily, so a pure-QML host never loads it.
450
+ `export.py`, `widget.py` and `canvas.py` are the View/embedding layer and do need Qt; the last
451
+ three import QtWidgets lazily, so a pure-QML host never loads it.
330
452
 
331
453
  | Layer | File | Responsibility |
332
454
  |---|---|---|
333
455
  | Model | `src/qmlmathplot/model.py` | expression → GLSL, shader source template (no Qt dependency, unit-testable) |
334
456
  | Camera | `src/qmlmathplot/camera.py` | `Camera`: centre / zoom / aspect, the derived ranges, pan/zoom/reset, the nice-number tick algorithm |
335
- | Curves | `src/qmlmathplot/curve.py` | `Curve` (expression → baked `.qsb`, style, error) and `CurveListModel` |
457
+ | Input | `src/qmlmathplot/input.py` | `ViewInput`: keyboard switch, pan step, rebindable shortcuts, key matching |
458
+ | Animation | `src/qmlmathplot/animation.py` | `CameraAnimator`: the one eased interpolator behind wheel/keyboard moves |
459
+ | Curves | `src/qmlmathplot/curve.py` | `Curve` (expression → baked `.qsb`, style, error; later edits bake on the thread pool) and `CurveListModel` |
336
460
  | Plot | `src/qmlmathplot/plot.py` | `Plot`: the camera, the curves and the styling the view binds to |
337
- | 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 |
461
+ | Facade | `src/qmlmathplot/figure.py` | `Figure` / `Axes`: Matplotlib-shaped names over one `Plot` (no Qt beyond the model) |
462
+ | Facade host | `src/qmlmathplot/canvas.py` | `FigureCanvasQt`: a `MathPlotWidget` attached to a `Figure` (QtWidgets, lazy) |
463
+ | Facade scripting | `src/qmlmathplot/pyplot.py` | `qmlmathplot.pyplot`: current figure, `plot`/`xlim`/`show`/`savefig` for scripts |
464
+ | View | `src/qmlmathplot/qml/PlotView.qml` (+ `PlotCurves.qml`, `PlotFurniture.qml`, `PlotInput.qml`, `view.py`) | composition: model injection, camera mapping and keyboard focus; curves, furniture and pointer input live in the three child components (one `ShaderEffect` per curve) |
338
465
  | Themes | `src/qmlmathplot/themes.py` + `themes/` | the vendored Matplotlib style sheets, resolved onto the `Plot`'s properties |
339
466
  | Export | `src/qmlmathplot/export.py` | `Plot.savefig()` / `to_image()`: the canvas region rendered offscreen at a pixel size (QtWidgets is imported lazily) |
340
467
  | 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) |
@@ -343,24 +470,60 @@ a Python-side assignment updates the view with no call in between. `view.py`, `P
343
470
  | Entry | `src/qmlmathplot/app.py` | command line / standalone window (the `qmlmathplot` script, `examples/minimal.py`) |
344
471
  | Examples | `examples/` | `minimal.py` (minimal), `explorer_qtwidgets.py` (input box + widget coexistence), `explorer_qtquick.py` (Qt Quick) |
345
472
 
346
- ## Algorithm (the two pieces that make it visually "perfect")
347
-
348
- 1. **Screen-space distance stroke**: for every pixel, compute its first-order
349
- distance to the curve from `f` and `f'`,
350
- `cov = clamp(lineWidth/2 + 0.5 − d, 0, 1)`. Equal width at any slope, naturally
351
- antialiased, and correct by construction at poles (`tan(x)`) and jumps.
352
- 2. **Undersampled envelope band**: when a pixel's x interval holds several
353
- oscillations (local period < 1 pixel), the per-pixel distance is meaningless
354
- (it renders as moiré). In that case the column's `[min, max]` is filled as a
355
- solid band — for `sin(1/x)` this equals filling its true ±1 envelope.
356
-
357
- Both criteria are **scale-independent** and are unioned: (a) ≥ 2 direction
358
- reversals within the column (8 sample points for an ordinary pixel, topped up to 16
359
- inside the band region); (d) the derivative prediction `|f'|·dx` far exceeds the
360
- measured spread (hard threshold 16× and `pred ≥ 4px`). One gate: a spread < 1.5 px
361
- is not filled (do not turn peak ripple into a block of colour). An ordinary pixel
362
- costs 1 `f` + 1 `f'` + 8 narrow-window samples; the wide window (±8 columns, 16
363
- points) is only evaluated inside the band region.
473
+ ## Algorithm (fixed-x sampled polyline + the interval column)
474
+
475
+ The supported path is GeoGebra's column idea implemented on the GPU: sample the function on a
476
+ sub-pixel x grid, stroke the polyline, and fill the exact interval enclosure where a column
477
+ cannot be represented by a line.
478
+
479
+ 1. **Sub-pixel polyline stroke.** Each pixel evaluates `F` at **16 samples over ±2 columns**
480
+ (0.25 logical-px spacing). Adjacent valid samples form a polyline, and coverage is
481
+ `R - distance to the nearest segment` — Desmos' stroke rule. The sample spacing is
482
+ sub-pixel, so the vertices themselves are sub-pixel and the segments are diagonal: the
483
+ edge is smooth at any zoom, not a per-column staircase. ±2 columns (rather than the old
484
+ ±1) cover the perpendicular foot, which a steep line displaces along x by up to the
485
+ stroke radius; measured `k·x` 3.00/3.00/3.00/2.94 device px at screen slopes
486
+ 2/10/40/200 against a nominal 3.0.
487
+ 2. **Pole/jump segment guard.** A segment is skipped when the expression's pole factor
488
+ changes sign between its endpoints (`POLE(prev_x) * POLE(xi) <= 0`) — the branches of
489
+ `tan`/`1/x` never join across a pole — and, for a dependent quotient such as
490
+ `x/sqrt(x**2)`, when the endpoint jump exceeds the join radius. The flat branches are
491
+ still drawn; measured `x/sqrt(x**2)` draws its ±1 branches without the old 454-px
492
+ connector at x = 0.
493
+ 3. **Interval topology — GeoGebra's column rules on the GPU.** The expression is evaluated
494
+ in interval arithmetic over the pixel's column (generated from sympy in `interval.py`,
495
+ compiled to the GLSL `IVal FI(xlo, xhi)`): a conservative y-enclosure with topology
496
+ **connected / two rays around a gap / whole / empty**. The shader reproduces the reference
497
+ plotter's rules:
498
+ - **connected, sub-column oscillation** (`smoothstep(1.5, 3.0, sturns)`): GeoGebra's
499
+ `DrawInterval.drawJoined` path — the vertical join at `x.low` and the diagonal across
500
+ the column, choosing `drawUp`/`drawDown` from the high edge; an independent column is a
501
+ vertical at `x.high`. These are **strokes**, not a filled rectangle.
502
+ - **divergence** (`log`'s boundary): `IntervalPath.drawNormalInfinity` →
503
+ `leftToBottom`/`leftToTop`, only when the finite bound is on screen: one segment from
504
+ `(x.low, finite bound)` to `(x.middle, viewport edge)`. If the interval covers the
505
+ viewport but the finite bound is above/below it (the deep `log` view), the column is
506
+ effectively `whole` and gets `drawWhole` (a vertical at `x.low`) — an extension needed
507
+ because the Java branch draws nothing there although the visible curve lies inside the
508
+ column.
509
+ - **inverted** (`tan`/`1/x` poles): `DrawInvertedInterval` — the top segment at `x.high`
510
+ from the viewport top to the gap's high, the bottom at `x.low` from the gap's low to the
511
+ viewport bottom; no filled ray.
512
+ - **whole**: `DrawInterval.drawWhole` — one vertical segment at `x.low`.
513
+ Deliberate deviations, because this is a fragment shader and not the Java tuple list: the
514
+ 16-sample polyline supersamples GGB's one interval per column for resolved stretches; a
515
+ dependent quotient's whole column is still suppressed (the fixed-x polyline draws the real
516
+ branches, avoiding the `x/sqrt(x**2)` connector); and a joined inverted column uses those
517
+ sample branches instead of replaying `JoinLines.inverted`'s neighbour decisions.
518
+ 4. **Domain.** `DOM`/`DOMS` (from sympy) guard every `F` evaluation, so `log(negative)` is
519
+ never evaluated; a pixel just outside the domain still gets the band, which is centred
520
+ on the boundary.
521
+
522
+ An ordinary pixel costs **16 `F` evaluations** plus two interval evaluations; there is no
523
+ march, no tangent-foot projection and no column probe on the supported path. Expressions
524
+ with an operation the interval backend does not implement (`floor`, `sign`, …) compile the
525
+ legacy sampled band + ray + march path (`#define USE_INTERVAL 0`); it is the only user of
526
+ the arc-length march now.
364
527
 
365
528
  ## Tests
366
529
 
@@ -368,6 +531,7 @@ points) is only evaluated inside the band region.
368
531
  uv run pytest # everything
369
532
  uv run pytest -m "not gui" # skip the render smoke tests that open a window
370
533
  QSG_RHI_BACKEND=opengl uv run pytest -m gui
534
+ uv run ruff check src tests examples # bug-level lint (undefined names, unused imports)
371
535
  ```
372
536
 
373
537
  - `test_model.py`: GLSL generation (small integer powers as repeated
@@ -379,14 +543,19 @@ QSG_RHI_BACKEND=opengl uv run pytest -m gui
379
543
  setters are idempotent, and the list model reports the right roles — plus the
380
544
  `Expr`/string round-trip accounting (a string is parsed once, an expression never).
381
545
  - `test_implicit.py`: the routing by free symbols (and the `ValueError` for a third symbol), the
382
- level-set shader text (same uniform block as the 1-D one, no sampling loop), and the rendered
383
- geometry of a unit circle: crossings a quarter of the height from each edge, a blank middle,
384
- radius 1 on both axes, and a stroke of the same width at 0°/45°/90°/135°.
546
+ level-set shader text (same uniform block as the 1-D one, no sampling loop), the rendered
547
+ geometry of a unit circle (crossings a quarter of the height from each edge, a blank middle,
548
+ radius 1 on both axes, and a stroke of the same width at 0°/45°/90°/135°), and the two
549
+ domain/pole guards: `x-tan(y)` has no connector at its poles or far pixels in a wide view,
550
+ `x-log(y-1)` keeps its stroke on both sides of `y = 1`, and `y-log(x)` reaches the axis in a
551
+ deep view.
385
552
  - `test_plot.py`: the plot's properties convert, notify exactly once and are wired
386
553
  (a PySide property with an unresolved notify name silently loses it), the defaults are
387
554
  matplotlib's default style, the limits are forwarded to the camera, and a theme applies
388
555
  (and re-applies as a reset) while an unknown name is rejected.
389
556
  - `test_themes.py`: the vendored style sheets resolve to this library's properties.
557
+ - `test_qsb_cache.py`: the cache location/info/clear API and the age + LRU prune; unrelated
558
+ files in the temp directory are not touched.
390
559
  - `test_shader_bake.py`: the stage of a baked `.qsb` must match its purpose, and
391
560
  the four backend targets GLSL/HLSL/MSL/SPIR-V must all be covered.
392
561
  - `test_render_smoke.py`: open a window, render, read pixels; verify the thin-line
@@ -410,6 +579,12 @@ QSG_RHI_BACKEND=opengl uv run pytest -m gui
410
579
  `.pdf` and `.png`; `to_sympy().save()` writes a file and keeps per-curve colours; importing
411
580
  `qmlmathplot` does not import matplotlib, and hiding matplotlib produces an error naming the
412
581
  extra.
582
+ - `test_async_bake.py`: a later expression assignment bakes on the thread pool, a stale bake
583
+ is discarded, and a failed bake keeps the drawn curve.
584
+ - `test_compat.py`: the Matplotlib-shaped facade — expressions (and the refusal of x/y arrays),
585
+ limits/title/labels/grid/aspect, the single-axes rule, `pyplot` current-figure semantics,
586
+ `FigureCanvasQt` rendering and export, and that importing `qmlmathplot` does not load
587
+ QtWidgets.
413
588
  - `test_export.py`: `to_image()`/`savefig()` — the image is exactly the requested device size
414
589
  (`dpi` multiplies it), the no-argument form reproduces the live centre and scale, the three
415
590
  `adjustable` modes reconcile range and size as documented (a world 45-degree line stays at 45
@@ -418,78 +593,63 @@ QSG_RHI_BACKEND=opengl uv run pytest -m gui
418
593
 
419
594
  ## Pitfalls near the `sin(1/x)` singularity (fixed — do not repeat)
420
595
 
421
- All of them (macro arguments, GPU `sin`/`cos`, stroke saturation on steep columns,
422
- per-column phase noise) are recorded in `AGENTS.md` as symptom → cause → fix with
423
- the measurements that settled them. After the fixes (1350×900, default view, D3D11
424
- and OpenGL pixel-for-pixel identical): the singularity column of `sin(1/x)` is lit
596
+ All of them (macro arguments, GPU `sin`/`cos`, the fixed-x-grid sampling era's stroke
597
+ saturation, per-column phase noise, the jump-discontinuity connector) are recorded in
598
+ `AGENTS.md` as symptom → cause → fix with the measurements that settled them. After
599
+ the fixes (900×600 logical, default view): the singularity column of `sin(1/x)` is lit
425
600
  over a ratio of 0.50 (= half a column filling the ±1 envelope) with flat edges, and
426
- inside the central 41 columns no solid pixel falls outside |y|>1 (7961 before).
601
+ inside the central 41 columns no solid pixel falls outside |y|>1 (7961 before the
602
+ envelope band existed).
427
603
 
428
604
  - Macro arguments must be parenthesized.
429
605
  - GPU `sin`/`cos` are unreliable for large arguments.
430
606
  - Tangent-based distance saturates on steep columns.
431
607
  - Per-column sampling is phase noise for oscillating functions.
608
+ - The **join invariant** replaces pole detection: tangent-foot seeding and arc-length
609
+ marching, so a connector of *any* origin (poles, jumps) cannot be formed
610
+ (`AGENTS.md` item 44).
432
611
 
433
612
  ## Domain, poles and stroke (Desmos-style)
434
613
 
435
614
  [Desmos's engineering blog](https://engineering.desmos.com/articles/press-a-key-in-the-calculator/)
436
615
  spells it out: **sample** the function into points, join them into **segments**,
437
- stroke along the segments; "break where neighbouring points suggest a jump"; refine
438
- extrema/zeros by bisection. The same approach is used here:
439
-
440
- - **Stroke = distance to the sampled polyline segments** (not to the tangent). The
441
- tangent approximation overestimates the distance on steep or strongly curved
442
- parts, so the stroke thins out or even fades away (`log(x)` as x→0+, the steep
443
- stretch of `sin(1/x)`); measuring to the segment is **equal width everywhere**.
444
- Sampled over ±1 column: the polyline must be wide enough that a pixel's
445
- perpendicular foot lands on a segment (on a sloped line it is displaced along x
446
- by `m·d/√(1+m²)`); with a narrower window the distance degraded to "distance to
447
- the nearest endpoint" and steep parts looked thin and ragged.
448
- - **Break on jumps**: a denominator sign change (world units) decides, in three places,
449
- that nothing joins the branches — the polyline segment that straddles the pole is not
450
- stroked, a straddling pair does not count as a turn in the band's turn count (it used to
451
- fill the pole column solid), and the unbounded-boundary ray is not drawn there. No vertical
452
- connector at the asymptotes of `1/x`, `tan(x)`. (An earlier "adjacent samples differ by
453
- more than 4 viewport heights" criterion was pixel-/viewport-relative, so under magnification
454
- it also cut legitimate steep segments; it was removed. The remaining "spread beyond 32
455
- viewport heights" gap is a second line of defence only — at `tan`'s pole column the spread
456
- is ~15 viewports, so it never fires there.)
457
- - **Domain** (derived by sympy, injected as `DOM`): denominator ≠ 0, `log` argument
458
- > 0, `sqrt` argument ≥ 0, `asin/acos` argument ∈ [−1,1]; pixels outside the domain
459
- are discarded outright — except for the **boundary ray** of a slowly diverging edge
460
- (`log(x)` as x→0+), which is a band centred *on* the boundary and therefore paints the few
461
- pixels outside the domain that it covers (the whole outer half of that band, otherwise).
462
- NaN/inf samples take part in neither the envelope nor segment joining.
463
- - **Undersampled columns** (several oscillations inside one column) are replaced
464
- wholesale by the envelope band: a polyline is random sawtooth in such columns.
465
- There are two criteria, both **scale-independent**: (a) ≥ 2 direction reversals
466
- within the column — 8 points in the narrow window for an ordinary pixel, and
467
- inside the band region 8 additional interleaved points make it 16 (with 8 points,
468
- random sampling misses the reversals in ~11% of columns, showing up as residual
469
- vertical stripes; sampling 16 points globally would cost 8 extra `f` evaluations
470
- for every pixel, not worth paying for a few columns); (d) the distance `pred` a
471
- column should traverse according to the derivative far exceeds the measured
472
- `spread` (hard threshold 16×, and `pred ≥ 4px`) — sub-pixel oscillation sampling
473
- necessarily misses reversals (it may even come out monotone), and this criterion
474
- does not depend on sampling randomness. Pixel-unit gates are avoided here (a
475
- spread measured in pixels stops being meaningful once the viewport is deep);
476
- poles are handled by the analytic gap instead.
477
- - **Infinite extension at unbounded domain boundaries** (`unbounded_edges()`, sympy
478
- one-sided limits): `log(x)` **diverges slowly** (logarithmically) as x→0+, so a
479
- fixed-width sampling window never reaches deep enough values — in a deep viewport
480
- the visible curve lies at x≈e^y (e.g. for y∈[-16,-12], x∈[1e-7,6e-6]), while the
481
- leftmost sample only reaches log≈-7, so the **whole segment disappears**. Fix:
482
- when the window crosses such a boundary, extend the polyline along the boundary x
483
- into a vertical ray out to ±1e12, which draws at any depth (the ray lies on the
484
- boundary x, so the width is constant and the columns align; the x<0 half is cut
485
- away by the domain, leaving exactly half a stroke visible). `1/x` and `tan(x)`
486
- diverge fast, so their sampled values naturally exceed any viewport and no ray is
487
- needed; `sqrt` (limit 0) and `sin(1/x)` (no limit) are not on the list either.
488
-
489
- Measured: the steep stretch of `log(x)` has a constant horizontal width of 1~2
490
- device pixels (previously a tapering gradient); the thin line of `sin(1/x)` is
491
- uniform in width; `1/x` and `tan(x)` are not connected across their poles;
492
- `sin(x)`/`x^2`/`exp(-x²)sin(10x)` are unaffected.
616
+ stroke along the segments; "break where neighbouring points suggest a jump". The same
617
+ approach, with the sampling made uniform in *arc length* (the march above):
618
+
619
+ - **Stroke = distance to the joined polyline segments** (not to the tangent), plus the
620
+ stroked **column segments** for what the march cannot reach: a pixel also measures the
621
+ vertical y-range of the two neighbouring columns and strokes it with the same ramp. That
622
+ is GeoGebra's per-column segment with a vector line width, on the GPU; a steep wall
623
+ (`1/x`/`tan` at large |y|, `log`'s descent) keeps its nominal width even though its x span
624
+ is narrower than a pixel and its march seed is a whole view up the arc.
625
+ - **Break on jumps — by construction, not by detection**. The join invariant (a segment
626
+ only joins samples within 2.5 px) refuses a connector of any origin: the poles of
627
+ `1/x`/`tan(x)` and plain jump discontinuities alike (`x/sqrt(x**2)` had no pole
628
+ denominator for the old guards to find; measured: a 454-px column at x=0). The
629
+ interval band does not reintroduce one: a dependent quotient (`x/sqrt(x**2)`) is left to
630
+ the march, and a sign-changing pole (`tan`, `1/x`) is a gap or a `whole` column, not a
631
+ chord.
632
+ - **Domain** (derived by sympy, injected as `DOM` for the scalar march): denominator ≠ 0,
633
+ `log` argument > 0, `sqrt`/even-root ≥ 0 (a negative power also excludes 0), `asin/acos`
634
+ ∈ [−1,1], `x**(1/3)` all reals. The *interval* evaluator is authoritative for the band:
635
+ it returns empty outside the domain, `[-∞, log h]` at a `log` boundary, and the half-open
636
+ values at a root endpoint. A pixel just outside the boundary is painted from the stroke
637
+ footprint (`x ± R`) and the neighbouring column segments — not by a symbol-derived ray.
638
+ The legacy ray remains only in the fallback shader (`USE_INTERVAL 0`).
639
+ - **Undersampled columns** use the interval enclosure of the pixel's column: no ±8-column
640
+ window, no violation count, no chord-stroke. The midpoint probe survives only as an
641
+ **interval-dependency check** for a wide finite column (`x * 1/[a,b]` overestimation);
642
+ the band and the neighbour column segments are always the exact interval ranges.
643
+
644
+ Measured: `sin(1/x)`'s transition overfill went from 1.66 to **0.0099 world units**
645
+ (threshold-20 pixels; the remaining edge is the stroke/AA radius); `x**(1/3)` draws both
646
+ branches; the `log(x)` descent measures **3.00 device px at every sampled row** without the
647
+ ray; `1/x`/`tan(x)` in a y∈[50,60] view are **3 full columns** (2 before the neighbour
648
+ segments); `x/sqrt(x**2)` still draws no connector (worst column 4 px); `k·x` holds one
649
+ width at every slope (the slope test at 2/10/40/200); `tan(x)` at an enormous y span fills
650
+ the screen (the interval is `whole`, matching GeoGebra); `sin(x)`/`x^2`/`exp(-x²)sin(10x)`
651
+ are unaffected.
652
+
493
653
 
494
654
  ## Performance (measured)
495
655
 
@@ -500,13 +660,55 @@ Startup to the first frame is about **230 ms**, attributed as follows
500
660
  |---|---|---|
501
661
  | Qt graphics device init (RHI/D3D11) | **~160 ms** | exactly what the first window's `show→expose` costs; a **bare window (no ShaderEffect) is the same**, so it is not the shader's fault |
502
662
  | Window creation + QML load + first frame | ~80 ms | from the second window on (device already created) this is the order of magnitude |
503
- | `qsb` bake | 0 / ~126 ms | cached by source hash: 0.15 ms on a hit; after a source change (code edit) it must re-bake once |
663
+ | `qsb` bake | 0 / ~126 ms | cached by source hash: 0.15 ms on a hit; later `expression` assignments bake on Qt's thread pool, so only the first curve construction blocks (tests force the synchronous path) |
504
664
  | Expression analysis (sympy) | 0.6~3 ms | `unbounded_edges` only analyses **polynomial** boundaries (`tan(x)`'s `cos(x)=0` takes 94 ms via solve+limit, while tan diverges fast and sampling is naturally deep enough, so that ray is not needed) |
505
665
 
506
- Per-frame GPU cost (2400×1500, extreme zoom, worst case) is **31 ms**; about 1/7 of
507
- that at 900×600, far below the 16.7 ms of 60 Hz. The shader source is about 6.9 KB.
508
- Every visible curve is one full-screen fragment pass, so `Curve.visible` is the lever
509
- when a figure carries many of them.
666
+ Per-frame grab cycles (900×600, `grabToImage` — frame + readback, so an upper bound on
667
+ the GPU cost), steady state on the same window: `sin(1/x)` default **13.6 ms**, `tan(x)`
668
+ 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: 16 samples are cheaper
670
+ than the march's seed + tangent projection + walk + probe, and the march is now compiled
671
+ only for the legacy path. The 1-D fragment source is about 21 KB.
672
+
673
+ **Per-pixel `F` evaluations.** The supported path is fixed: 16 samples for the polyline
674
+ (plus two interval evaluations for the column and the stroke footprint); there is no
675
+ march budget and no band-window evaluation. The old fixed-grid/march table is superseded
676
+ and no longer describes the shipped path.
677
+
678
+ ## Shader cache
679
+
680
+ `qsb` binaries are cached in the platform temp directory:
681
+
682
+ ```python
683
+ import qmlmathplot
684
+
685
+ qmlmathplot.cache_dir() # e.g. C:\Users\me\AppData\Local\Temp\qmlmathplot-qsb
686
+ qmlmathplot.cache_info() # {'path': ..., 'files': ..., 'bytes': ...}
687
+ qmlmathplot.clear_cache() # remove this package's entries, returns bytes freed
688
+ ```
689
+
690
+ The location is `tempfile.gettempdir()/qmlmathplot-qsb`: `%TEMP%` on Windows, the per-user
691
+ `/var/folders/.../T` on macOS, and `$TMPDIR` or `/tmp` on Linux. Set
692
+ `QMLMATHPLOT_QSB_CACHE` to use another directory.
693
+
694
+ **It is not left to grow without bound.** The first bake in a process, and at most once per
695
+ minute afterwards, runs `prune_cache()`:
696
+
697
+ - entries older than **30 days** are removed;
698
+ - if the cache is over **32 MiB** (default), the least-recently-used `.qsb` files are removed
699
+ until it is at 75% of the cap — so an existing 60 MB cache drops to about 24 MB on the next
700
+ bake;
701
+ - interrupted-bake leftovers (`*.qsb.tmp`, `*.vert`, `*.frag`) older than an hour are removed.
702
+
703
+ Both knobs are overridable: `QMLMATHPLOT_QSB_CACHE_MB` (size cap) and
704
+ `qmlmathplot.prune_cache(max_bytes=..., max_age_days=...)` for an explicit pass. The cache
705
+ contains compiled shaders only; the GLSL source and temporary outputs are deleted after each
706
+ bake. Concurrent processes share the directory safely: writes use unique temp names and an
707
+ atomic replace, and pruning only removes entries by age/LRU.
708
+
709
+ On macOS/Linux `qsb` is looked up in the PySide6 package (`Qt/bin`, `Qt/libexec`) and on
710
+ `PATH`; if the wheel does not ship it, install Qt Shader Tools (`qt6-shadertools` on most
711
+ distributions) or add `qsb` to `PATH`. Only the Windows wheel has been confirmed to ship it.
510
712
 
511
713
  ## Pitfalls already hit (do not repeat)
512
714
 
@@ -521,9 +723,11 @@ measurement that settled each one). The Qt/packaging ones that shaped the code:
521
723
  same x 9 times, and macros are preprocessor expansion with no call semantics.
522
724
  - PySide ties a property's notify signal to the `Signal` **object**; a name string
523
725
  silently leaves the property without one (and QML bindings then never refresh).
524
- - Snap **hairlines** (grid) to a half pixel, but never a **styled** line: rounding the
525
- axis position put the 1.07 px y-axis half off the centre column, so the line came out
526
- pale where it should be black.
726
+ - Screen-space furniture is QML: background `Rectangle`, grid `Canvas`, axes/ticks
727
+ `Rectangle`s, labels/title `Text`; curves are `ShaderEffect` passes. Snap its lines to
728
+ **device** pixels with an integral device width (`_deviceWidth`/`_snapLine`), not logical
729
+ half-pixels: the 1.07-logical axis is 2 device px at DPR 1.5 and otherwise alternated
730
+ between one and two rows as it drifted with the zoom.
527
731
  - `QQuickWidget.setProperty(name, …)` sets it on the **widget**, not on the QML root (the
528
732
  widget has no such property, so it silently does nothing): inject the model with
529
733
  `widget.rootObject().setProperty("plot", plot)`, as `MathPlotWidget` does.