qmlmathplot 0.2.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 (62) hide show
  1. qmlmathplot-0.4.0/PKG-INFO +765 -0
  2. qmlmathplot-0.4.0/README.md +752 -0
  3. {qmlmathplot-0.2.0 → qmlmathplot-0.4.0}/pyproject.toml +12 -1
  4. {qmlmathplot-0.2.0 → qmlmathplot-0.4.0}/pyproject.toml.orig +11 -1
  5. {qmlmathplot-0.2.0 → qmlmathplot-0.4.0}/src/qmlmathplot/__init__.py +23 -2
  6. qmlmathplot-0.4.0/src/qmlmathplot/__main__.py +13 -0
  7. qmlmathplot-0.4.0/src/qmlmathplot/animation.py +87 -0
  8. {qmlmathplot-0.2.0 → qmlmathplot-0.4.0}/src/qmlmathplot/app.py +6 -3
  9. {qmlmathplot-0.2.0 → qmlmathplot-0.4.0}/src/qmlmathplot/camera.py +187 -33
  10. qmlmathplot-0.4.0/src/qmlmathplot/canvas.py +107 -0
  11. qmlmathplot-0.4.0/src/qmlmathplot/curve.py +533 -0
  12. {qmlmathplot-0.2.0 → qmlmathplot-0.4.0}/src/qmlmathplot/export.py +18 -8
  13. qmlmathplot-0.4.0/src/qmlmathplot/figure.py +442 -0
  14. qmlmathplot-0.4.0/src/qmlmathplot/input.py +124 -0
  15. qmlmathplot-0.4.0/src/qmlmathplot/interval.py +561 -0
  16. qmlmathplot-0.4.0/src/qmlmathplot/model.py +1413 -0
  17. {qmlmathplot-0.2.0 → qmlmathplot-0.4.0}/src/qmlmathplot/mpl.py +21 -18
  18. {qmlmathplot-0.2.0 → qmlmathplot-0.4.0}/src/qmlmathplot/plot.py +39 -4
  19. qmlmathplot-0.4.0/src/qmlmathplot/pyplot.py +330 -0
  20. qmlmathplot-0.4.0/src/qmlmathplot/qml/PlotCurves.qml +29 -0
  21. qmlmathplot-0.4.0/src/qmlmathplot/qml/PlotFurniture.qml +228 -0
  22. qmlmathplot-0.4.0/src/qmlmathplot/qml/PlotInput.qml +134 -0
  23. qmlmathplot-0.4.0/src/qmlmathplot/qml/PlotView.qml +134 -0
  24. qmlmathplot-0.4.0/src/qmlmathplot/qsb.py +269 -0
  25. {qmlmathplot-0.2.0 → qmlmathplot-0.4.0}/src/qmlmathplot/view.py +1 -1
  26. {qmlmathplot-0.2.0 → qmlmathplot-0.4.0}/src/qmlmathplot/widget.py +53 -23
  27. qmlmathplot-0.2.0/PKG-INFO +0 -501
  28. qmlmathplot-0.2.0/README.md +0 -488
  29. qmlmathplot-0.2.0/src/qmlmathplot/curve.py +0 -298
  30. qmlmathplot-0.2.0/src/qmlmathplot/model.py +0 -541
  31. qmlmathplot-0.2.0/src/qmlmathplot/qml/PlotView.qml +0 -288
  32. qmlmathplot-0.2.0/src/qmlmathplot/qsb.py +0 -96
  33. {qmlmathplot-0.2.0 → qmlmathplot-0.4.0}/src/qmlmathplot/themes/LICENSE.matplotlib +0 -0
  34. {qmlmathplot-0.2.0 → qmlmathplot-0.4.0}/src/qmlmathplot/themes/README.md +0 -0
  35. {qmlmathplot-0.2.0 → qmlmathplot-0.4.0}/src/qmlmathplot/themes/Solarize_Light2.mplstyle +0 -0
  36. {qmlmathplot-0.2.0 → qmlmathplot-0.4.0}/src/qmlmathplot/themes/bmh.mplstyle +0 -0
  37. {qmlmathplot-0.2.0 → qmlmathplot-0.4.0}/src/qmlmathplot/themes/classic.mplstyle +0 -0
  38. {qmlmathplot-0.2.0 → qmlmathplot-0.4.0}/src/qmlmathplot/themes/dark_background.mplstyle +0 -0
  39. {qmlmathplot-0.2.0 → qmlmathplot-0.4.0}/src/qmlmathplot/themes/default.mplstyle +0 -0
  40. {qmlmathplot-0.2.0 → qmlmathplot-0.4.0}/src/qmlmathplot/themes/fast.mplstyle +0 -0
  41. {qmlmathplot-0.2.0 → qmlmathplot-0.4.0}/src/qmlmathplot/themes/fivethirtyeight.mplstyle +0 -0
  42. {qmlmathplot-0.2.0 → qmlmathplot-0.4.0}/src/qmlmathplot/themes/ggplot.mplstyle +0 -0
  43. {qmlmathplot-0.2.0 → qmlmathplot-0.4.0}/src/qmlmathplot/themes/grayscale.mplstyle +0 -0
  44. {qmlmathplot-0.2.0 → qmlmathplot-0.4.0}/src/qmlmathplot/themes/petroff10.mplstyle +0 -0
  45. {qmlmathplot-0.2.0 → qmlmathplot-0.4.0}/src/qmlmathplot/themes/seaborn-v0_8-bright.mplstyle +0 -0
  46. {qmlmathplot-0.2.0 → qmlmathplot-0.4.0}/src/qmlmathplot/themes/seaborn-v0_8-colorblind.mplstyle +0 -0
  47. {qmlmathplot-0.2.0 → qmlmathplot-0.4.0}/src/qmlmathplot/themes/seaborn-v0_8-dark-palette.mplstyle +0 -0
  48. {qmlmathplot-0.2.0 → qmlmathplot-0.4.0}/src/qmlmathplot/themes/seaborn-v0_8-dark.mplstyle +0 -0
  49. {qmlmathplot-0.2.0 → qmlmathplot-0.4.0}/src/qmlmathplot/themes/seaborn-v0_8-darkgrid.mplstyle +0 -0
  50. {qmlmathplot-0.2.0 → qmlmathplot-0.4.0}/src/qmlmathplot/themes/seaborn-v0_8-deep.mplstyle +0 -0
  51. {qmlmathplot-0.2.0 → qmlmathplot-0.4.0}/src/qmlmathplot/themes/seaborn-v0_8-muted.mplstyle +0 -0
  52. {qmlmathplot-0.2.0 → qmlmathplot-0.4.0}/src/qmlmathplot/themes/seaborn-v0_8-notebook.mplstyle +0 -0
  53. {qmlmathplot-0.2.0 → qmlmathplot-0.4.0}/src/qmlmathplot/themes/seaborn-v0_8-paper.mplstyle +0 -0
  54. {qmlmathplot-0.2.0 → qmlmathplot-0.4.0}/src/qmlmathplot/themes/seaborn-v0_8-pastel.mplstyle +0 -0
  55. {qmlmathplot-0.2.0 → qmlmathplot-0.4.0}/src/qmlmathplot/themes/seaborn-v0_8-poster.mplstyle +0 -0
  56. {qmlmathplot-0.2.0 → qmlmathplot-0.4.0}/src/qmlmathplot/themes/seaborn-v0_8-talk.mplstyle +0 -0
  57. {qmlmathplot-0.2.0 → qmlmathplot-0.4.0}/src/qmlmathplot/themes/seaborn-v0_8-ticks.mplstyle +0 -0
  58. {qmlmathplot-0.2.0 → qmlmathplot-0.4.0}/src/qmlmathplot/themes/seaborn-v0_8-white.mplstyle +0 -0
  59. {qmlmathplot-0.2.0 → qmlmathplot-0.4.0}/src/qmlmathplot/themes/seaborn-v0_8-whitegrid.mplstyle +0 -0
  60. {qmlmathplot-0.2.0 → qmlmathplot-0.4.0}/src/qmlmathplot/themes/seaborn-v0_8.mplstyle +0 -0
  61. {qmlmathplot-0.2.0 → qmlmathplot-0.4.0}/src/qmlmathplot/themes/tableau-colorblind10.mplstyle +0 -0
  62. {qmlmathplot-0.2.0 → qmlmathplot-0.4.0}/src/qmlmathplot/themes.py +0 -0
@@ -0,0 +1,765 @@
1
+ Metadata-Version: 2.3
2
+ Name: qmlmathplot
3
+ Version: 0.4.0
4
+ Summary: Embeddable function plotter for Qt Quick / QtWidgets (implicit per-pixel drawing, MVVM)
5
+ Author: I-AM-A-NOOB
6
+ Author-email: I-AM-A-NOOB <suzc08@outlook.com>
7
+ Requires-Dist: sympy>=1.14
8
+ Requires-Dist: pyside6>=6.7
9
+ Requires-Dist: matplotlib>=3.8 ; extra == 'matplotlib'
10
+ Requires-Python: >=3.11
11
+ Provides-Extra: matplotlib
12
+ Description-Content-Type: text/markdown
13
+
14
+ # QMLMathPlot
15
+
16
+ An embeddable function-plotting component for Qt Quick apps. The curve is drawn
17
+ **per pixel by a fragment shader** (implicit signed-distance model): the per-frame
18
+ cost is ∝ the pixel count and independent of the function's frequency, so
19
+ oscillating functions (e.g. `sin(1/x)`) cannot drag the frame rate down through
20
+ polyline aliasing, and no CPU-side sampling is needed.
21
+
22
+ The state is a **camera on an infinite canvas**: a `Plot` holds a `Camera` (where you look)
23
+ and a list of `Curve`s (what is drawn), and the QML component `PlotView` binds to it. Several
24
+ panels are several `Plot`s in a Qt layout — there is no figure, no axes box and no page.
25
+
26
+ ## Quick start
27
+
28
+ One line in a QtWidgets layout (least effort):
29
+
30
+ ```python
31
+ from PySide6.QtWidgets import QApplication, QVBoxLayout, QWidget
32
+ from qmlmathplot import MathPlotWidget
33
+
34
+ app = QApplication([])
35
+ window = QWidget()
36
+ box = QVBoxLayout(window)
37
+ plot = MathPlotWidget("sin(1/x)") # drag to pan / wheel zooms anchored at the cursor
38
+ box.addWidget(plot)
39
+ window.show()
40
+ app.exec()
41
+ ```
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
+
47
+ `add_curve()` (and `Curve.expression`) accept the expression **either as a string or as a sympy
48
+ expression**: `plot.add_curve(sp.sin(1 / sp.Symbol("x")))` is the same curve as
49
+ `plot.add_curve("sin(1/x)")`. The stored and reported form is always the canonical string — QML,
50
+ a project file and the text field want one — while the parsed expression is cached and exposed as
51
+ `Curve.expr`, which is what re-baking and the matplotlib hand-off use. The object form exists so a
52
+ curve can be composed without string interpolation:
53
+
54
+ ```python
55
+ curve = plot.add_curve("sin(x)")
56
+ plot.add_curve(sp.diff(sp.sympify(curve.expression, locals={"x": sp.Symbol("x")})), label="f'")
57
+ ```
58
+
59
+ `MathPlotWidget` keeps one curve's worth of convenience (`expression`, `error`,
60
+ `line_width`, `curve_color`, `background_color`, `aspect`, `view_bounds()`,
61
+ `reset_view()`, `zoom()`, `pan_pixels()`); everything else lives on the model behind it:
62
+
63
+ ```python
64
+ plot.plot.curves.add_curve("tan(x)", color="#ff8866", label="tan") # a second curve
65
+ plot.plot.camera.aspect = 1.0 # square units ("auto" by default)
66
+ plot.plot.grid = False
67
+ plot.plot.theme = "ggplot"
68
+ ```
69
+
70
+ Standalone window / command line:
71
+
72
+ ```
73
+ uv run qmlmathplot "sin(1/x)" # equivalent to python examples/minimal.py …
74
+ uv run qmlmathplot --backend d3d11 "sin(1/x)" # force a specific backend
75
+ python examples/explorer_qtwidgets.py # function input box + sibling widgets
76
+ ```
77
+
78
+ The examples live in `examples/`: `minimal.py` is the smallest window;
79
+ `explorer_qtwidgets.py` deliberately puts the plot widget into a "grabby"
80
+ environment (input box, `QScrollArea`, `QSplitter`, overlay) to verify the widgets
81
+ do not fight each other; `explorer_qtquick.py` (plus `explorer_qtquick.qml`) is
82
+ the pure Qt Quick demo.
83
+
84
+ ## Embedding into an app
85
+
86
+ **QtWidgets**: `MathPlotWidget` is an ordinary `QWidget` (internally it bridges the
87
+ QML component into the widgets world with a `QQuickWidget` and renders into its own
88
+ FBO, so it can be stacked on top of / coexist with sibling widgets). Properties and
89
+ methods: `expression` / `error` / `line_width` / `curve_color` / `background_color` /
90
+ `aspect`, `view_bounds()` / `reset_view()` / `zoom(delta, u, v)` / `pan_pixels(dx, dy)`,
91
+ the signals `expressionChanged` / `errorChanged`, and the model itself as `.plot`.
92
+
93
+ **Pure QML (Qt Quick)**: create the model in Python and inject it (MVVM, see below).
94
+
95
+ ```python
96
+ from PySide6.QtCore import QUrl
97
+ from PySide6.QtGui import QGuiApplication
98
+ from PySide6.QtQuick import QQuickView
99
+ from qmlmathplot import Plot, qml_component_path, register_qml_types
100
+
101
+ app = QGuiApplication([])
102
+ register_qml_types() # registers Plot, Camera, Curve and PlotView
103
+
104
+ plot = Plot() # the model (owned by the app)
105
+ plot.add_curve("sin(1/x)")
106
+ view = QQuickView()
107
+ view.setSource(QUrl.fromLocalFile(qml_component_path()))
108
+ view.rootObject().setProperty("plot", plot)
109
+ view.show()
110
+ app.exec()
111
+ ```
112
+
113
+ ```qml
114
+ import QmlMathPlot 1.0
115
+
116
+ PlotView { // inject a Plot, or let the component create its own
117
+ anchors.fill: parent
118
+ plot: myPlot
119
+
120
+ // Arbitrary QML in world coordinates: the transform is the view's own
121
+ Rectangle {
122
+ x: mapToScreen(0, 0).x - 4
123
+ y: mapToScreen(0, 0).y - 4
124
+ width: 8; height: 8; radius: 4; color: "#ffcc00"
125
+ }
126
+ }
127
+ ```
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
+
138
+ `PlotView` exposes `mapToScreen(x, y)` / `mapFromScreen(point)` plus the forwarders
139
+ `camera` and `curves`; input handling (pan by left-drag, wheel zoom anchored at the cursor)
140
+ is built in and switchable through `camera.panEnabled` / `camera.zoomEnabled`.
141
+
142
+ The model:
143
+
144
+ | Object | Members |
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` |
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)` |
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 |
151
+
152
+ Every setter is a Qt `Property` with a notify signal, so the same calls work from QML, from a
153
+ Qt Designer slot or from a `QTimer`:
154
+
155
+ ```python
156
+ QTimer.singleShot(1000, lambda: setattr(curve, "expression", "sin(3/x)"))
157
+ ```
158
+
159
+ ## Design notes
160
+
161
+ The design — the camera model, the Qt property/signal contract, the object model and the
162
+ build order — is specified in [`docs/api-design.md`](docs/api-design.md). In short: the
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.
232
+
233
+ ## Implicit curves
234
+
235
+ An expression whose free symbols are `x` **and** `y` is drawn as the level set `F(x, y) = 0`.
236
+ The artist follows the expression, whoever sets it: `add_curve` routes it by itself, assigning to
237
+ `curve.expression` re-routes the same curve (so a text field can turn `sin(x)` into a circle),
238
+ and `add_implicit` asks for the level-set artist explicitly — also for a function of `x` alone,
239
+ which is a pair of vertical lines. Only a *third* free symbol is refused: substitute parameters
240
+ first.
241
+
242
+ ```python
243
+ plot.add_curve("x**2 + y**2 - 1") # a circle: {x, y} routes to the level-set artist
244
+ plot.add_implicit("x*y - 1") # the same artist, asked for by name
245
+ plot.curves.at(0).expression = "x**2 + y**2 - 4" # re-routes the curve that is already there
246
+ plot.add_curve("a*sin(x)") # ValueError: substitute `a` first
247
+ ```
248
+
249
+ The renderer is the same idea as the 1-D stroke, one dimension up: one evaluation of `F` and its
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.
281
+
282
+ ## The camera (centre, zoom, aspect)
283
+
284
+ `Camera` stores three numbers that do not depend on the widget: `centre` (world coordinates in
285
+ the middle of the view), `zoom` (world units per **logical** pixel on x) and `aspect` (the
286
+ ratio of the y scale to the x scale). `xlim` / `ylim` are *derived* from them and the last
287
+ reported size, so they always say what is on screen; assigning them moves the camera.
288
+
289
+ - `aspect = "auto"` (the default) keeps the two scales independent: the widget's shape decides
290
+ how much canvas is visible, and neither axis is tied to the other. A number keeps the ratio
291
+ fixed (matplotlib's convention: `1.0` = square units, `2.0` = the y-unit twice as tall).
292
+ Setting it only ever **shows more** — the camera keeps its centre and expands, never crops.
293
+ - **Resizing never zooms**: the scales are kept and the visible range follows the widget, so
294
+ dragging a window edge or a splitter shows more or less canvas instead of scaling the curve.
295
+ - `camera.reset()` returns to the home view (the classic 12 x 4 window, resolved for the
296
+ current size); the aspect stays as configured.
297
+ - The home is a *view*, so the first size report turns it into scales for that widget: opening
298
+ a narrow window shows the same window of canvas rather than a sliver of it.
299
+
300
+ ```python
301
+ plot = MathPlotWidget("sin(1/x)", aspect=1.0) # square units
302
+ plot.aspect = "auto" # back to following the widget's shape
303
+ plot.plot.camera.zoom_by(120, 0.5, 0.5, w, h) # one wheel notch, anchored at the centre
304
+ ```
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
+
334
+ ## Themes
335
+
336
+ `Plot.theme` applies a style sheet to the style properties above; the default is
337
+ matplotlib's own default style, so an untouched plot already looks like matplotlib and
338
+ `plot.theme = "default"` is the reset:
339
+
340
+ ```python
341
+ plot.theme = "ggplot" # any of the bundled sheets
342
+ plot.theme = "default" # back to matplotlib's look
343
+ plot.theme = "my-style" # themes/my-style.mplstyle next to the package
344
+ plot.theme = "path/to/any.mplstyle" # any file in the same format
345
+ plot.available_themes # the names, for a host's picker (both demos use one)
346
+ ```
347
+
348
+ The 26 bundled sheets are Matplotlib's own styles, vendored verbatim into
349
+ `qmlmathplot/themes/*.mplstyle` and converted at load time: Matplotlib's `rcParams` keys are
350
+ mapped onto this library's property names, and keys describing things this library does not
351
+ have (spines, figure size, dpi, legend, marker styles, …) are ignored. `themes.names()` lists
352
+ them (`"default"` plus the 26 sheets). An unknown name raises `KeyError` and leaves the current
353
+ theme alone.
354
+
355
+ ## Axes, grid and ticks
356
+
357
+ - The **grid** is drawn in QML (a `Canvas`), one line per tick position, in `grid_color` /
358
+ `grid_width` / `grid_alpha` / `grid_style` (`-`, `--`, `:`, `-.`).
359
+ - The **axes** are the lines through world (0,0) — the x-axis at `y = 0`, the y-axis at
360
+ `x = 0` — drawn in `axis_color` / `axis_width`, and the **tick marks and labels ride those
361
+ axes** instead of hugging the item's edges. When 0 is off screen the axis sticks to the
362
+ 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"` restores the edge-pinned behaviour.
364
+ - `camera.viewport` reports the last size the view sent, so a host can size an export from it.
365
+ - The tick *values* are nice numbers computed in Python (`Camera.tick_values()` /
366
+ `nice_ticks()`, steps of 1/2/5 x 10^n) and pushed through `ticksChanged`; QML only positions
367
+ and formats them, so a frame never recomputes ticks.
368
+ - `plot.ticks_visible = False` hides the whole furniture (axes, marks and labels).
369
+
370
+ ## Export
371
+
372
+ `savefig()` writes a file, `to_image()` returns a `QImage`; both take the same keywords:
373
+
374
+ ```python
375
+ plot.savefig("out.png", xlim=(-1, 1), ylim=(-1, 1), width=1200, height=400)
376
+ plot.savefig("hi.png", dpi=2.0) # the live view, twice the pixels
377
+ image = plot.to_image(width=800, height=600) # -> QImage
378
+ plot.to_image() # the live view, its own size
379
+ ```
380
+
381
+ - `xlim` / `ylim` default to the **camera**: the live centre and scale, extended to the export
382
+ size. The *region* is a parameter, so an export can cover more canvas than the widget shows.
383
+ - `width` / `height` are **device pixels** and default to the live view's device size; `dpi`
384
+ multiplies both (`dpi=2.0` doubles the pixels).
385
+ - `adjustable` reconciles a requested range with a requested size when their aspects disagree —
386
+ Matplotlib's parameter, and its own default:
387
+
388
+ | `adjustable` | What happens |
389
+ |---|---|
390
+ | `"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* |
391
+ | `"datalim"` | the camera's own scales, with the limits expanding (never cropped) to fill the size |
392
+ | `"stretch"` | the requested range maps onto the requested size exactly — independent scales, so a mismatched aspect **distorts** (the "report figure" mode) |
393
+
394
+ With no explicit range all three agree, because the range derived from the camera already has
395
+ the export size's aspect.
396
+ - `transparent=True` clears to alpha 0.
397
+ - Everything else follows the live styling (curves, grid, axes, theme), so an export cannot
398
+ drift from what the user sees. Rendering goes through an offscreen `QQuickWidget`, so the
399
+ export needs a `QApplication` (a clear error is raised instead of a crash if there is none).
400
+
401
+ ### Vector output, matplotlib and sympy
402
+
403
+ `to_matplotlib()` hands the plot to matplotlib (one `Axes`) and `savefig(..., backend="matplotlib")`
404
+ renders through it, so `.svg`/`.pdf` work; `to_sympy()` builds the same figure through
405
+ `sympy.plotting`, which is what you want when the figure should live in sympy's world:
406
+
407
+ ```python
408
+ figure = plot.to_matplotlib(xlim=(-1, 1), ylim=(-1, 1)) # -> matplotlib.figure.Figure
409
+ plot.savefig("out.svg", backend="matplotlib") # vector, matplotlib's own savefig
410
+ handle = plot.to_sympy(xlim=(-1, 1))
411
+ handle.save("out.png")
412
+ ```
413
+
414
+ matplotlib is an **optional** extra (`pip install "qmlmathplot[matplotlib]"`); nothing in the
415
+ package imports it until one of these is called, and the error names the extra. `sympy` itself is
416
+ a hard dependency, so `to_sympy()` needs matplotlib only because its plotting backend does.
417
+ Neither hand-off needs a Qt application: a `Plot` works in a plain script, which is what makes it
418
+ usable next to a notebook or a matplotlib-only tool.
419
+
420
+ **This is a second renderer, and a sampled one.** matplotlib draws polylines, so each curve is
421
+ evaluated at `samples` points (default 2000) and an oscillating function such as `sin(1/x)`
422
+ aliases — exactly what the Qt view avoids by evaluating the expression per pixel. SymPy does not
423
+ solve it either, and its adaptive mode does not help: measured on `sin(1/x)` over [-1, 1],
424
+ `sympy.plotting.plot(adaptive=True)` uses **289** points (fewer than a fixed 2000), emits no
425
+ warning in sympy 1.14, and draws the same block of vertical connectors. The difference is how
426
+ the two fail: a sampled renderer draws an artefact, the Qt view draws the envelope the data
427
+ actually supports. (`to_sympy()` returns the object sympy made, so its own `adaptive` mode is
428
+ available there.) Fonts and antialiasing are matplotlib's, not the Qt view's, so the two
429
+ renderers cannot look identical, and the style mapping is partial: background, grid (on/off and
430
+ colour/width/alpha/dash), per-curve colour and width, tick and text colours, font sizes, title,
431
+ axis colour/width, the limits, the aspect with matplotlib's own `adjustable`, and
432
+ `axes_position="zero"` (spines through the origin, no box). What it buys is vector output and
433
+ matplotlib's ecosystem — nothing else.
434
+
435
+ ## RHI backends
436
+
437
+ `--backend {d3d11,d3d12,vulkan,metal,opengl,null}`; **if unspecified, Qt's default
438
+ backend is used**. The implementation sets `QSG_RHI_BACKEND` before
439
+ `QGuiApplication` (Qt reads it during platform initialisation, and a later
440
+ `setGraphicsApi()` has no effect). On this machine (Intel driver 32.0.101.8826)
441
+ `d3d11` and `opengl` measured pixel-for-pixel identical.
442
+
443
+ ## Structure (model / view)
444
+
445
+ The layers are strict about Qt. **`model.py` (expression → GLSL), `themes.py` (style-sheet
446
+ conversion) and `mpl.py` (the sampled hand-off) import no Qt at all** — they are unit-testable
447
+ without a `QApplication`. `Camera`, `Curve` and `Plot` are the **ViewModel**: `QObject`s whose
448
+ `Property`es carry notify signals, which is precisely what the QML component binds to — and why
449
+ a Python-side assignment updates the view with no call in between. `view.py`, `PlotView.qml`,
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.
452
+
453
+ | Layer | File | Responsibility |
454
+ |---|---|---|
455
+ | Model | `src/qmlmathplot/model.py` | expression → GLSL, shader source template (no Qt dependency, unit-testable) |
456
+ | Camera | `src/qmlmathplot/camera.py` | `Camera`: centre / zoom / aspect, the derived ranges, pan/zoom/reset, the nice-number tick algorithm |
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` |
460
+ | Plot | `src/qmlmathplot/plot.py` | `Plot`: the camera, the curves and the styling the view binds to |
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) |
465
+ | Themes | `src/qmlmathplot/themes.py` + `themes/` | the vendored Matplotlib style sheets, resolved onto the `Plot`'s properties |
466
+ | Export | `src/qmlmathplot/export.py` | `Plot.savefig()` / `to_image()`: the canvas region rendered offscreen at a pixel size (QtWidgets is imported lazily) |
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) |
468
+ | Bake | `src/qmlmathplot/qsb.py` | GLSL → `.qsb` (PySide6 ships `qsb.exe`), cached by source hash |
469
+ | Widget | `src/qmlmathplot/widget.py` | `MathPlotWidget`: QQuickWidget bridge, drops straight into QtWidgets layouts |
470
+ | Entry | `src/qmlmathplot/app.py` | command line / standalone window (the `qmlmathplot` script, `examples/minimal.py`) |
471
+ | Examples | `examples/` | `minimal.py` (minimal), `explorer_qtwidgets.py` (input box + widget coexistence), `explorer_qtquick.py` (Qt Quick) |
472
+
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.
527
+
528
+ ## Tests
529
+
530
+ ```
531
+ uv run pytest # everything
532
+ uv run pytest -m "not gui" # skip the render smoke tests that open a window
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)
535
+ ```
536
+
537
+ - `test_model.py`: GLSL generation (small integer powers as repeated
538
+ multiplication, avoiding `pow(x, y)`'s undefined behaviour for x<0), shader
539
+ injection (including the camera uniform and the mapping), and the camera math
540
+ (the home view at the first size, resize keeping the scale, an anchored zoom that
541
+ is exactly reversible, the aspect expanding but never cropping, ticks).
542
+ - `test_curve.py`: a failed expression keeps the last working shaders, the style
543
+ setters are idempotent, and the list model reports the right roles — plus the
544
+ `Expr`/string round-trip accounting (a string is parsed once, an expression never).
545
+ - `test_implicit.py`: the routing by free symbols (and the `ValueError` for a third symbol), the
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.
552
+ - `test_plot.py`: the plot's properties convert, notify exactly once and are wired
553
+ (a PySide property with an unresolved notify name silently loses it), the defaults are
554
+ matplotlib's default style, the limits are forwarded to the camera, and a theme applies
555
+ (and re-applies as a reset) while an unknown name is rejected.
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.
559
+ - `test_shader_bake.py`: the stage of a baked `.qsb` must match its purpose, and
560
+ the four backend targets GLSL/HLSL/MSL/SPIR-V must all be covered.
561
+ - `test_render_smoke.py`: open a window, render, read pixels; verify the thin-line
562
+ shape, `sin(1/x)` filling its ±1 envelope, no |y|>1 artefacts outside the central
563
+ columns, poles not connected, nothing drawn outside the domain, and the QML
564
+ furniture (grid lines exactly at the tick positions, the axes crossing at the origin
565
+ with their labels on them, the axis pinning to the edge once the origin is panned
566
+ away, the title).
567
+ - `test_widget.py`: `MathPlotWidget` coexisting with neighbouring widgets — the plot
568
+ appearing inside a layout, an expression change / an invalid expression keeping the
569
+ previous graph, curves added/hidden/removed through the model, **the wheel over a
570
+ 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 staying out of the
572
+ Tab focus chain. `conftest.py` provides a session-scoped `QApplication` (QtWidgets
573
+ needs one, and a process may only have one).
574
+ - `test_qtquick_coexistence.py`: the same drag conflict in Qt Quick (a `Flickable`
575
+ must not steal the pan).
576
+ - `test_handoff.py`: the matplotlib/sympy hand-off — the figure reflects the limits, aspect,
577
+ `adjustable`, grid, title, spines at zero and per-curve style; the sampling is uniform and NaN
578
+ outside the domain (so `log(x)` breaks); `savefig(backend="matplotlib")` writes real `.svg`,
579
+ `.pdf` and `.png`; `to_sympy().save()` writes a file and keeps per-curve colours; importing
580
+ `qmlmathplot` does not import matplotlib, and hiding matplotlib produces an error naming the
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.
588
+ - `test_export.py`: `to_image()`/`savefig()` — the image is exactly the requested device size
589
+ (`dpi` multiplies it), the no-argument form reproduces the live centre and scale, the three
590
+ `adjustable` modes reconcile range and size as documented (a world 45-degree line stays at 45
591
+ in `"box"`, with background margins; `"stretch"` fills the frame and distorts), the live
592
+ camera and background survive an export, and `transparent` clears to alpha 0.
593
+
594
+ ## Pitfalls near the `sin(1/x)` singularity (fixed — do not repeat)
595
+
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
600
+ over a ratio of 0.50 (= half a column filling the ±1 envelope) with flat edges, and
601
+ inside the central 41 columns no solid pixel falls outside |y|>1 (7961 before the
602
+ envelope band existed).
603
+
604
+ - Macro arguments must be parenthesized.
605
+ - GPU `sin`/`cos` are unreliable for large arguments.
606
+ - Tangent-based distance saturates on steep columns.
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).
611
+
612
+ ## Domain, poles and stroke (Desmos-style)
613
+
614
+ [Desmos's engineering blog](https://engineering.desmos.com/articles/press-a-key-in-the-calculator/)
615
+ spells it out: **sample** the function into points, join them into **segments**,
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
+
653
+
654
+ ## Performance (measured)
655
+
656
+ Startup to the first frame is about **230 ms**, attributed as follows
657
+ (Windows / D3D11 / 900×600):
658
+
659
+ | Stage | Time | Notes |
660
+ |---|---|---|
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 |
662
+ | Window creation + QML load + first frame | ~80 ms | from the second window on (device already created) this is the order of magnitude |
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) |
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) |
665
+
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.
712
+
713
+ ## Pitfalls already hit (do not repeat)
714
+
715
+ `AGENTS.md` at the repo root carries all the records (symptom → cause → fix, with the
716
+ measurement that settled each one). The Qt/packaging ones that shaped the code:
717
+
718
+ - qsb decides the stage from the source file suffix (see `qsb.bake()` and
719
+ `test_shader_bake.py`).
720
+ - Read pixels with the async `QQuickItem.grabToImage()`, never
721
+ `QQuickWindow.grabWindow()`.
722
+ - Expressions are **macros** rather than GLSL user functions: a pixel evaluates the
723
+ same x 9 times, and macros are preprocessor expansion with no call semantics.
724
+ - PySide ties a property's notify signal to the `Signal` **object**; a name string
725
+ silently leaves the property without one (and QML bindings then never refresh).
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.
731
+ - `QQuickWidget.setProperty(name, …)` sets it on the **widget**, not on the QML root (the
732
+ widget has no such property, so it silently does nothing): inject the model with
733
+ `widget.rootObject().setProperty("plot", plot)`, as `MathPlotWidget` does.
734
+
735
+ ## Releasing
736
+
737
+ 1. Bump `version` in `pyproject.toml` and commit.
738
+ 2. Tag and push: `git tag v0.2.0 && git push origin v0.2.0`.
739
+ 3. `.github/workflows/publish.yml` then runs the non-GUI test suite, builds the sdist and wheel
740
+ with `uv build`, and uploads them to PyPI. The tag must match `pyproject.toml` or the run
741
+ fails before anything is published.
742
+
743
+ Authentication is PyPI's **trusted publishing** (OIDC) — no API token, no repository secret.
744
+ Configure it once at <https://pypi.org/manage/account/publishing/> (and the same on
745
+ test.pypi.org) with the owner, the repository name, the workflow file name `publish.yml` and the
746
+ environment `pypi`. A project that does not exist on PyPI yet uses the "pending publisher" form
747
+ there and is created by the first successful run. Running the workflow manually
748
+ (*Actions → Publish to PyPI → Run workflow*) publishes to TestPyPI by default, so a release can
749
+ be rehearsed:
750
+
751
+ ```
752
+ pip install -i https://test.pypi.org/simple/ qmlmathplot
753
+ ```
754
+
755
+ If the publish step fails with `invalid-publisher: valid token, but no corresponding
756
+ publisher`, the trusted-publisher entry is missing or does not match. The action prints the
757
+ claims it presented (`repository`, `workflow_ref`, `environment`) — the entry must match them
758
+ exactly, **including the environment name**, and it must exist on the index the run targets: a
759
+ manual run goes to **test.pypi.org**, a tag push to **pypi.org**, so both need an entry.
760
+
761
+ The workflow runs on Windows: that is the platform this library is verified on (the shader baker
762
+ `qsb` ships inside the PySide6 wheel and is only confirmed to be present there, see `qsb.py`),
763
+ and the GUI tests need a GPU scene graph no runner has. The artifact is `py3-none-any`, so the
764
+ platform does not change what is published. The GUI tests stay a developer-machine thing:
765
+ `uv run pytest` (everything) versus `uv run pytest -m "not gui"` (what CI runs).