qmlmathplot 0.3.0__tar.gz → 0.4.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {qmlmathplot-0.3.0 → qmlmathplot-0.4.1}/PKG-INFO +329 -114
- {qmlmathplot-0.3.0 → qmlmathplot-0.4.1}/README.md +328 -113
- {qmlmathplot-0.3.0 → qmlmathplot-0.4.1}/pyproject.toml +12 -1
- {qmlmathplot-0.3.0 → qmlmathplot-0.4.1}/pyproject.toml.orig +11 -1
- {qmlmathplot-0.3.0 → qmlmathplot-0.4.1}/src/qmlmathplot/__init__.py +21 -1
- qmlmathplot-0.4.1/src/qmlmathplot/animation.py +87 -0
- {qmlmathplot-0.3.0 → qmlmathplot-0.4.1}/src/qmlmathplot/camera.py +187 -33
- qmlmathplot-0.4.1/src/qmlmathplot/canvas.py +107 -0
- {qmlmathplot-0.3.0 → qmlmathplot-0.4.1}/src/qmlmathplot/curve.py +181 -58
- {qmlmathplot-0.3.0 → qmlmathplot-0.4.1}/src/qmlmathplot/export.py +2 -2
- qmlmathplot-0.4.1/src/qmlmathplot/figure.py +442 -0
- qmlmathplot-0.4.1/src/qmlmathplot/input.py +146 -0
- qmlmathplot-0.4.1/src/qmlmathplot/interval.py +561 -0
- qmlmathplot-0.4.1/src/qmlmathplot/model.py +1413 -0
- {qmlmathplot-0.3.0 → qmlmathplot-0.4.1}/src/qmlmathplot/plot.py +12 -4
- qmlmathplot-0.4.1/src/qmlmathplot/pyplot.py +330 -0
- qmlmathplot-0.4.1/src/qmlmathplot/qml/PlotCurves.qml +29 -0
- qmlmathplot-0.4.1/src/qmlmathplot/qml/PlotFurniture.qml +228 -0
- qmlmathplot-0.4.1/src/qmlmathplot/qml/PlotInput.qml +134 -0
- qmlmathplot-0.4.1/src/qmlmathplot/qml/PlotView.qml +275 -0
- qmlmathplot-0.4.1/src/qmlmathplot/qsb.py +269 -0
- {qmlmathplot-0.3.0 → qmlmathplot-0.4.1}/src/qmlmathplot/view.py +1 -1
- {qmlmathplot-0.3.0 → qmlmathplot-0.4.1}/src/qmlmathplot/widget.py +53 -23
- qmlmathplot-0.3.0/src/qmlmathplot/model.py +0 -697
- qmlmathplot-0.3.0/src/qmlmathplot/qml/PlotView.qml +0 -288
- qmlmathplot-0.3.0/src/qmlmathplot/qsb.py +0 -96
- {qmlmathplot-0.3.0 → qmlmathplot-0.4.1}/src/qmlmathplot/__main__.py +0 -0
- {qmlmathplot-0.3.0 → qmlmathplot-0.4.1}/src/qmlmathplot/app.py +0 -0
- {qmlmathplot-0.3.0 → qmlmathplot-0.4.1}/src/qmlmathplot/mpl.py +0 -0
- {qmlmathplot-0.3.0 → qmlmathplot-0.4.1}/src/qmlmathplot/themes/LICENSE.matplotlib +0 -0
- {qmlmathplot-0.3.0 → qmlmathplot-0.4.1}/src/qmlmathplot/themes/README.md +0 -0
- {qmlmathplot-0.3.0 → qmlmathplot-0.4.1}/src/qmlmathplot/themes/Solarize_Light2.mplstyle +0 -0
- {qmlmathplot-0.3.0 → qmlmathplot-0.4.1}/src/qmlmathplot/themes/bmh.mplstyle +0 -0
- {qmlmathplot-0.3.0 → qmlmathplot-0.4.1}/src/qmlmathplot/themes/classic.mplstyle +0 -0
- {qmlmathplot-0.3.0 → qmlmathplot-0.4.1}/src/qmlmathplot/themes/dark_background.mplstyle +0 -0
- {qmlmathplot-0.3.0 → qmlmathplot-0.4.1}/src/qmlmathplot/themes/default.mplstyle +0 -0
- {qmlmathplot-0.3.0 → qmlmathplot-0.4.1}/src/qmlmathplot/themes/fast.mplstyle +0 -0
- {qmlmathplot-0.3.0 → qmlmathplot-0.4.1}/src/qmlmathplot/themes/fivethirtyeight.mplstyle +0 -0
- {qmlmathplot-0.3.0 → qmlmathplot-0.4.1}/src/qmlmathplot/themes/ggplot.mplstyle +0 -0
- {qmlmathplot-0.3.0 → qmlmathplot-0.4.1}/src/qmlmathplot/themes/grayscale.mplstyle +0 -0
- {qmlmathplot-0.3.0 → qmlmathplot-0.4.1}/src/qmlmathplot/themes/petroff10.mplstyle +0 -0
- {qmlmathplot-0.3.0 → qmlmathplot-0.4.1}/src/qmlmathplot/themes/seaborn-v0_8-bright.mplstyle +0 -0
- {qmlmathplot-0.3.0 → qmlmathplot-0.4.1}/src/qmlmathplot/themes/seaborn-v0_8-colorblind.mplstyle +0 -0
- {qmlmathplot-0.3.0 → qmlmathplot-0.4.1}/src/qmlmathplot/themes/seaborn-v0_8-dark-palette.mplstyle +0 -0
- {qmlmathplot-0.3.0 → qmlmathplot-0.4.1}/src/qmlmathplot/themes/seaborn-v0_8-dark.mplstyle +0 -0
- {qmlmathplot-0.3.0 → qmlmathplot-0.4.1}/src/qmlmathplot/themes/seaborn-v0_8-darkgrid.mplstyle +0 -0
- {qmlmathplot-0.3.0 → qmlmathplot-0.4.1}/src/qmlmathplot/themes/seaborn-v0_8-deep.mplstyle +0 -0
- {qmlmathplot-0.3.0 → qmlmathplot-0.4.1}/src/qmlmathplot/themes/seaborn-v0_8-muted.mplstyle +0 -0
- {qmlmathplot-0.3.0 → qmlmathplot-0.4.1}/src/qmlmathplot/themes/seaborn-v0_8-notebook.mplstyle +0 -0
- {qmlmathplot-0.3.0 → qmlmathplot-0.4.1}/src/qmlmathplot/themes/seaborn-v0_8-paper.mplstyle +0 -0
- {qmlmathplot-0.3.0 → qmlmathplot-0.4.1}/src/qmlmathplot/themes/seaborn-v0_8-pastel.mplstyle +0 -0
- {qmlmathplot-0.3.0 → qmlmathplot-0.4.1}/src/qmlmathplot/themes/seaborn-v0_8-poster.mplstyle +0 -0
- {qmlmathplot-0.3.0 → qmlmathplot-0.4.1}/src/qmlmathplot/themes/seaborn-v0_8-talk.mplstyle +0 -0
- {qmlmathplot-0.3.0 → qmlmathplot-0.4.1}/src/qmlmathplot/themes/seaborn-v0_8-ticks.mplstyle +0 -0
- {qmlmathplot-0.3.0 → qmlmathplot-0.4.1}/src/qmlmathplot/themes/seaborn-v0_8-white.mplstyle +0 -0
- {qmlmathplot-0.3.0 → qmlmathplot-0.4.1}/src/qmlmathplot/themes/seaborn-v0_8-whitegrid.mplstyle +0 -0
- {qmlmathplot-0.3.0 → qmlmathplot-0.4.1}/src/qmlmathplot/themes/seaborn-v0_8.mplstyle +0 -0
- {qmlmathplot-0.3.0 → qmlmathplot-0.4.1}/src/qmlmathplot/themes/tableau-colorblind10.mplstyle +0 -0
- {qmlmathplot-0.3.0 → qmlmathplot-0.4.1}/src/qmlmathplot/themes.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: qmlmathplot
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.1
|
|
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 `
|
|
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`, `keyPanRepeatInterval`, `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,
|
|
154
|
-
|
|
155
|
-
bounded page), no
|
|
156
|
-
|
|
157
|
-
|
|
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
|
|
|
@@ -165,25 +239,52 @@ and `add_implicit` asks for the level-set artist explicitly — also for a funct
|
|
|
165
239
|
which is a pair of vertical lines. Only a *third* free symbol is refused: substitute parameters
|
|
166
240
|
first.
|
|
167
241
|
|
|
242
|
+
A sympy `Eq(lhs, rhs)` is always a level set: it is normalized to `lhs - rhs = 0`, and an
|
|
243
|
+
equation with only `x` still means the vertical lines where it holds (`Eq(x**2, 1)`), not
|
|
244
|
+
`y = x**2 - 1`. The canonical `expression` keeps the `Eq(...)` text and `expr` is the normalized
|
|
245
|
+
difference.
|
|
246
|
+
|
|
168
247
|
```python
|
|
248
|
+
x, y = sp.symbols("x y")
|
|
169
249
|
plot.add_curve("x**2 + y**2 - 1") # a circle: {x, y} routes to the level-set artist
|
|
170
|
-
plot.
|
|
250
|
+
plot.add_curve(sp.Eq(x**2 + y**2, 1)) # the same circle in equation form
|
|
251
|
+
plot.add_implicit(sp.Eq(x, sp.sin(y))) # asked for by name, equation form
|
|
171
252
|
plot.curves.at(0).expression = "x**2 + y**2 - 4" # re-routes the curve that is already there
|
|
172
253
|
plot.add_curve("a*sin(x)") # ValueError: substitute `a` first
|
|
173
254
|
```
|
|
174
255
|
|
|
175
256
|
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
|
|
177
|
-
`d = |F| / sqrt((Fx·dx)² + (Fy·dy)²)`, and the coverage is
|
|
178
|
-
analytic antialiasing and a stroke width that does not
|
|
179
|
-
(the unit circle measures the same 3 device pixels at
|
|
180
|
-
DPR 1.5).
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
257
|
+
two partials per pixel give a *candidate* distance to the level set in pixels,
|
|
258
|
+
`d = |F| / sqrt((Fx·dx)² + (Fy·dy)²)`, and the coverage is
|
|
259
|
+
`clamp(lineWidth/2 + 0.5 - d, 0, 1)` — analytic antialiasing and a stroke width that does not
|
|
260
|
+
depend on the angle, with no sampling grid (the unit circle measures the same 3 device pixels at
|
|
261
|
+
0°, 45°, 90° and 135° for `lineWidth=2` at DPR 1.5).
|
|
262
|
+
|
|
263
|
+
That quotient is a pseudo-zero at a pole and at a diverging domain boundary, so it is
|
|
264
|
+
**certified by projecting onto the level set**: two damped Newton steps from the pixel (or, for
|
|
265
|
+
a pixel outside the domain, from the nearest in-domain sample), accepting only a converged
|
|
266
|
+
residual (`|F| <= 0.25·R·|grad F|`) and using the distance to the projected point. The domain
|
|
267
|
+
therefore clips the *curve*, not the stroke: the half of the band lying outside is painted, so
|
|
268
|
+
`x-log(y-1)` does not thin out as it approaches `y = 1` and `y-log(x)` reaches `x = 0` in a deep
|
|
269
|
+
view. A `POLE(x,y)` factor (e.g. `cos(y)` for `tan(y)`) rejects a normal probe that crosses a
|
|
270
|
+
singularity, so `x-tan(y)` does not paint its asymptotes; far-out garbage lines are still
|
|
271
|
+
refused (the stroke is allowed one radius outside the domain, no more).
|
|
272
|
+
|
|
273
|
+
The pixel is also a **marching-squares cell**, borrowed from GeoGebra's
|
|
274
|
+
`ImplicitCurveMarchingRect`/`AdaptiveQuadTree`: the four corner values are evaluated when every
|
|
275
|
+
corner is in the domain and no pole factor changes sign between them; the edge crossings are
|
|
276
|
+
interpolated and the cell's contour segment is stroked (the saddle case is paired using the cell
|
|
277
|
+
centre's sign). The first-order/Newton path still supplies the sub-pixel width; the cell term
|
|
278
|
+
adds the coverage of a thin or tangent branch whose centre projection fails. A **singular cell**
|
|
279
|
+
(`|Fx| + |Fy| < 1e-3` in world units, plus `|F| < 0.05`) gets a small disc, so an isolated point
|
|
280
|
+
such as `x**2 + y**2 = 0` is not left to the projection alone.
|
|
281
|
+
|
|
282
|
+
Still not ported: GGB's Bernstein subdivision (**polynomial-only**), its adaptive quad-tree
|
|
283
|
+
refinement and the `ContourAssembler`/`ContourLinker` (they exist to produce a selectable vector
|
|
284
|
+
path, not to rasterise). A closed loop that fits entirely inside one pixel with no corner sign
|
|
285
|
+
change is not seen by the four-corner test; the projection is the only fallback there. The
|
|
286
|
+
matplotlib hand-off skips implicit curves (a level set has no polyline to sample), while the Qt
|
|
287
|
+
export renders them like anything else.
|
|
187
288
|
|
|
188
289
|
## The camera (centre, zoom, aspect)
|
|
189
290
|
|
|
@@ -209,6 +310,38 @@ plot.aspect = "auto" # back to following the widget
|
|
|
209
310
|
plot.plot.camera.zoom_by(120, 0.5, 0.5, w, h) # one wheel notch, anchored at the centre
|
|
210
311
|
```
|
|
211
312
|
|
|
313
|
+
## Keyboard, touch and animation
|
|
314
|
+
|
|
315
|
+
Clicking the plot gives it focus; while it has focus the arrow keys pan (10% of the viewport
|
|
316
|
+
per press by default), `+`/`=` zooms in and `-`/`_` zooms out. Held keys advance continuously:
|
|
317
|
+
one `keyPanStep` per `keyPanRepeatInterval` (100 ms by default), integrated every frame, so
|
|
318
|
+
there is no OS key-repeat delay or granular stepping. Several arrows combine, so `Left+Up`
|
|
319
|
+
moves diagonally at the normal speed. Every command is a property on the camera's `input`
|
|
320
|
+
object, so a host can rebind it — the value is a `QKeySequence` string, and `|` separates
|
|
321
|
+
alternatives (empty disables the command). `input.keyPanStep`, `input.keyPanRepeatInterval`,
|
|
322
|
+
`input.keyboardEnabled`, `panEnabled` and `zoomEnabled` shape the behaviour the same way.
|
|
323
|
+
|
|
324
|
+
```python
|
|
325
|
+
camera = plot.plot.camera
|
|
326
|
+
input = camera.input
|
|
327
|
+
input.panLeftShortcut = "A" # rebind one arrow
|
|
328
|
+
input.panRightShortcut = "D"
|
|
329
|
+
input.zoomInShortcut = "Ctrl+="
|
|
330
|
+
input.zoomOutShortcut = "Ctrl+-"
|
|
331
|
+
input.keyPanStep = 0.2 # 20% of the viewport per step
|
|
332
|
+
input.keyPanRepeatInterval = 80 # ms per step; smaller = faster pan/zoom
|
|
333
|
+
camera.animationDuration = 220 # ms; tap easing
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
Wheel and keyboard moves are **eased** (scale interpolates geometrically, so a zoom feels like
|
|
337
|
+
a constant rate); repeated wheel notches accumulate onto the in-flight target instead of
|
|
338
|
+
restarting from the last frame. Mouse drags and touch gestures are deliberately direct — the
|
|
339
|
+
pointer or the fingers are the animation.
|
|
340
|
+
|
|
341
|
+
Touch is built in: one finger pans, two fingers pinch to magnify and pan together, keeping the
|
|
342
|
+
world point under the finger centroid. They need no focus and work in both embedding modes;
|
|
343
|
+
like the mouse, they cancel an in-flight eased move as soon as they start.
|
|
344
|
+
|
|
212
345
|
## Themes
|
|
213
346
|
|
|
214
347
|
`Plot.theme` applies a style sheet to the style properties above; the default is
|
|
@@ -325,16 +458,21 @@ conversion) and `mpl.py` (the sampled hand-off) import no Qt at all** — they a
|
|
|
325
458
|
without a `QApplication`. `Camera`, `Curve` and `Plot` are the **ViewModel**: `QObject`s whose
|
|
326
459
|
`Property`es carry notify signals, which is precisely what the QML component binds to — and why
|
|
327
460
|
a Python-side assignment updates the view with no call in between. `view.py`, `PlotView.qml`,
|
|
328
|
-
`export.py` and `
|
|
329
|
-
|
|
461
|
+
`export.py`, `widget.py` and `canvas.py` are the View/embedding layer and do need Qt; the last
|
|
462
|
+
three import QtWidgets lazily, so a pure-QML host never loads it.
|
|
330
463
|
|
|
331
464
|
| Layer | File | Responsibility |
|
|
332
465
|
|---|---|---|
|
|
333
466
|
| Model | `src/qmlmathplot/model.py` | expression → GLSL, shader source template (no Qt dependency, unit-testable) |
|
|
334
467
|
| Camera | `src/qmlmathplot/camera.py` | `Camera`: centre / zoom / aspect, the derived ranges, pan/zoom/reset, the nice-number tick algorithm |
|
|
335
|
-
|
|
|
468
|
+
| Input | `src/qmlmathplot/input.py` | `ViewInput`: keyboard switch, pan step, rebindable shortcuts, key matching |
|
|
469
|
+
| Animation | `src/qmlmathplot/animation.py` | `CameraAnimator`: the one eased interpolator behind wheel/keyboard moves |
|
|
470
|
+
| Curves | `src/qmlmathplot/curve.py` | `Curve` (expression → baked `.qsb`, style, error; later edits bake on the thread pool) and `CurveListModel` |
|
|
336
471
|
| Plot | `src/qmlmathplot/plot.py` | `Plot`: the camera, the curves and the styling the view binds to |
|
|
337
|
-
|
|
|
472
|
+
| Facade | `src/qmlmathplot/figure.py` | `Figure` / `Axes`: Matplotlib-shaped names over one `Plot` (no Qt beyond the model) |
|
|
473
|
+
| Facade host | `src/qmlmathplot/canvas.py` | `FigureCanvasQt`: a `MathPlotWidget` attached to a `Figure` (QtWidgets, lazy) |
|
|
474
|
+
| Facade scripting | `src/qmlmathplot/pyplot.py` | `qmlmathplot.pyplot`: current figure, `plot`/`xlim`/`show`/`savefig` for scripts |
|
|
475
|
+
| 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
476
|
| Themes | `src/qmlmathplot/themes.py` + `themes/` | the vendored Matplotlib style sheets, resolved onto the `Plot`'s properties |
|
|
339
477
|
| Export | `src/qmlmathplot/export.py` | `Plot.savefig()` / `to_image()`: the canvas region rendered offscreen at a pixel size (QtWidgets is imported lazily) |
|
|
340
478
|
| 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 +481,60 @@ a Python-side assignment updates the view with no call in between. `view.py`, `P
|
|
|
343
481
|
| Entry | `src/qmlmathplot/app.py` | command line / standalone window (the `qmlmathplot` script, `examples/minimal.py`) |
|
|
344
482
|
| Examples | `examples/` | `minimal.py` (minimal), `explorer_qtwidgets.py` (input box + widget coexistence), `explorer_qtquick.py` (Qt Quick) |
|
|
345
483
|
|
|
346
|
-
## Algorithm (
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
484
|
+
## Algorithm (fixed-x sampled polyline + the interval column)
|
|
485
|
+
|
|
486
|
+
The supported path is GeoGebra's column idea implemented on the GPU: sample the function on a
|
|
487
|
+
sub-pixel x grid, stroke the polyline, and fill the exact interval enclosure where a column
|
|
488
|
+
cannot be represented by a line.
|
|
489
|
+
|
|
490
|
+
1. **Sub-pixel polyline stroke.** Each pixel evaluates `F` at **16 samples over ±2 columns**
|
|
491
|
+
(0.25 logical-px spacing). Adjacent valid samples form a polyline, and coverage is
|
|
492
|
+
`R - distance to the nearest segment` — Desmos' stroke rule. The sample spacing is
|
|
493
|
+
sub-pixel, so the vertices themselves are sub-pixel and the segments are diagonal: the
|
|
494
|
+
edge is smooth at any zoom, not a per-column staircase. ±2 columns (rather than the old
|
|
495
|
+
±1) cover the perpendicular foot, which a steep line displaces along x by up to the
|
|
496
|
+
stroke radius; measured `k·x` 3.00/3.00/3.00/2.94 device px at screen slopes
|
|
497
|
+
2/10/40/200 against a nominal 3.0.
|
|
498
|
+
2. **Pole/jump segment guard.** A segment is skipped when the expression's pole factor
|
|
499
|
+
changes sign between its endpoints (`POLE(prev_x) * POLE(xi) <= 0`) — the branches of
|
|
500
|
+
`tan`/`1/x` never join across a pole — and, for a dependent quotient such as
|
|
501
|
+
`x/sqrt(x**2)`, when the endpoint jump exceeds the join radius. The flat branches are
|
|
502
|
+
still drawn; measured `x/sqrt(x**2)` draws its ±1 branches without the old 454-px
|
|
503
|
+
connector at x = 0.
|
|
504
|
+
3. **Interval topology — GeoGebra's column rules on the GPU.** The expression is evaluated
|
|
505
|
+
in interval arithmetic over the pixel's column (generated from sympy in `interval.py`,
|
|
506
|
+
compiled to the GLSL `IVal FI(xlo, xhi)`): a conservative y-enclosure with topology
|
|
507
|
+
**connected / two rays around a gap / whole / empty**. The shader reproduces the reference
|
|
508
|
+
plotter's rules:
|
|
509
|
+
- **connected, sub-column oscillation** (`smoothstep(1.5, 3.0, sturns)`): GeoGebra's
|
|
510
|
+
`DrawInterval.drawJoined` path — the vertical join at `x.low` and the diagonal across
|
|
511
|
+
the column, choosing `drawUp`/`drawDown` from the high edge; an independent column is a
|
|
512
|
+
vertical at `x.high`. These are **strokes**, not a filled rectangle.
|
|
513
|
+
- **divergence** (`log`'s boundary): `IntervalPath.drawNormalInfinity` →
|
|
514
|
+
`leftToBottom`/`leftToTop`, only when the finite bound is on screen: one segment from
|
|
515
|
+
`(x.low, finite bound)` to `(x.middle, viewport edge)`. If the interval covers the
|
|
516
|
+
viewport but the finite bound is above/below it (the deep `log` view), the column is
|
|
517
|
+
effectively `whole` and gets `drawWhole` (a vertical at `x.low`) — an extension needed
|
|
518
|
+
because the Java branch draws nothing there although the visible curve lies inside the
|
|
519
|
+
column.
|
|
520
|
+
- **inverted** (`tan`/`1/x` poles): `DrawInvertedInterval` — the top segment at `x.high`
|
|
521
|
+
from the viewport top to the gap's high, the bottom at `x.low` from the gap's low to the
|
|
522
|
+
viewport bottom; no filled ray.
|
|
523
|
+
- **whole**: `DrawInterval.drawWhole` — one vertical segment at `x.low`.
|
|
524
|
+
Deliberate deviations, because this is a fragment shader and not the Java tuple list: the
|
|
525
|
+
16-sample polyline supersamples GGB's one interval per column for resolved stretches; a
|
|
526
|
+
dependent quotient's whole column is still suppressed (the fixed-x polyline draws the real
|
|
527
|
+
branches, avoiding the `x/sqrt(x**2)` connector); and a joined inverted column uses those
|
|
528
|
+
sample branches instead of replaying `JoinLines.inverted`'s neighbour decisions.
|
|
529
|
+
4. **Domain.** `DOM`/`DOMS` (from sympy) guard every `F` evaluation, so `log(negative)` is
|
|
530
|
+
never evaluated; a pixel just outside the domain still gets the band, which is centred
|
|
531
|
+
on the boundary.
|
|
532
|
+
|
|
533
|
+
An ordinary pixel costs **16 `F` evaluations** plus two interval evaluations; there is no
|
|
534
|
+
march, no tangent-foot projection and no column probe on the supported path. Expressions
|
|
535
|
+
with an operation the interval backend does not implement (`floor`, `sign`, …) compile the
|
|
536
|
+
legacy sampled band + ray + march path (`#define USE_INTERVAL 0`); it is the only user of
|
|
537
|
+
the arc-length march now.
|
|
364
538
|
|
|
365
539
|
## Tests
|
|
366
540
|
|
|
@@ -368,6 +542,7 @@ points) is only evaluated inside the band region.
|
|
|
368
542
|
uv run pytest # everything
|
|
369
543
|
uv run pytest -m "not gui" # skip the render smoke tests that open a window
|
|
370
544
|
QSG_RHI_BACKEND=opengl uv run pytest -m gui
|
|
545
|
+
uv run ruff check src tests examples # bug-level lint (undefined names, unused imports)
|
|
371
546
|
```
|
|
372
547
|
|
|
373
548
|
- `test_model.py`: GLSL generation (small integer powers as repeated
|
|
@@ -379,14 +554,19 @@ QSG_RHI_BACKEND=opengl uv run pytest -m gui
|
|
|
379
554
|
setters are idempotent, and the list model reports the right roles — plus the
|
|
380
555
|
`Expr`/string round-trip accounting (a string is parsed once, an expression never).
|
|
381
556
|
- `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),
|
|
383
|
-
geometry of a unit circle
|
|
384
|
-
radius 1 on both axes, and a stroke of the same width at 0°/45°/90°/135
|
|
557
|
+
level-set shader text (same uniform block as the 1-D one, no sampling loop), the rendered
|
|
558
|
+
geometry of a unit circle (crossings a quarter of the height from each edge, a blank middle,
|
|
559
|
+
radius 1 on both axes, and a stroke of the same width at 0°/45°/90°/135°), and the two
|
|
560
|
+
domain/pole guards: `x-tan(y)` has no connector at its poles or far pixels in a wide view,
|
|
561
|
+
`x-log(y-1)` keeps its stroke on both sides of `y = 1`, and `y-log(x)` reaches the axis in a
|
|
562
|
+
deep view.
|
|
385
563
|
- `test_plot.py`: the plot's properties convert, notify exactly once and are wired
|
|
386
564
|
(a PySide property with an unresolved notify name silently loses it), the defaults are
|
|
387
565
|
matplotlib's default style, the limits are forwarded to the camera, and a theme applies
|
|
388
566
|
(and re-applies as a reset) while an unknown name is rejected.
|
|
389
567
|
- `test_themes.py`: the vendored style sheets resolve to this library's properties.
|
|
568
|
+
- `test_qsb_cache.py`: the cache location/info/clear API and the age + LRU prune; unrelated
|
|
569
|
+
files in the temp directory are not touched.
|
|
390
570
|
- `test_shader_bake.py`: the stage of a baked `.qsb` must match its purpose, and
|
|
391
571
|
the four backend targets GLSL/HLSL/MSL/SPIR-V must all be covered.
|
|
392
572
|
- `test_render_smoke.py`: open a window, render, read pixels; verify the thin-line
|
|
@@ -410,6 +590,12 @@ QSG_RHI_BACKEND=opengl uv run pytest -m gui
|
|
|
410
590
|
`.pdf` and `.png`; `to_sympy().save()` writes a file and keeps per-curve colours; importing
|
|
411
591
|
`qmlmathplot` does not import matplotlib, and hiding matplotlib produces an error naming the
|
|
412
592
|
extra.
|
|
593
|
+
- `test_async_bake.py`: a later expression assignment bakes on the thread pool, a stale bake
|
|
594
|
+
is discarded, and a failed bake keeps the drawn curve.
|
|
595
|
+
- `test_compat.py`: the Matplotlib-shaped facade — expressions (and the refusal of x/y arrays),
|
|
596
|
+
limits/title/labels/grid/aspect, the single-axes rule, `pyplot` current-figure semantics,
|
|
597
|
+
`FigureCanvasQt` rendering and export, and that importing `qmlmathplot` does not load
|
|
598
|
+
QtWidgets.
|
|
413
599
|
- `test_export.py`: `to_image()`/`savefig()` — the image is exactly the requested device size
|
|
414
600
|
(`dpi` multiplies it), the no-argument form reproduces the live centre and scale, the three
|
|
415
601
|
`adjustable` modes reconcile range and size as documented (a world 45-degree line stays at 45
|
|
@@ -418,78 +604,63 @@ QSG_RHI_BACKEND=opengl uv run pytest -m gui
|
|
|
418
604
|
|
|
419
605
|
## Pitfalls near the `sin(1/x)` singularity (fixed — do not repeat)
|
|
420
606
|
|
|
421
|
-
All of them (macro arguments, GPU `sin`/`cos`,
|
|
422
|
-
per-column phase noise) are recorded in
|
|
423
|
-
the measurements that settled them. After
|
|
424
|
-
|
|
607
|
+
All of them (macro arguments, GPU `sin`/`cos`, the fixed-x-grid sampling era's stroke
|
|
608
|
+
saturation, per-column phase noise, the jump-discontinuity connector) are recorded in
|
|
609
|
+
`AGENTS.md` as symptom → cause → fix with the measurements that settled them. After
|
|
610
|
+
the fixes (900×600 logical, default view): the singularity column of `sin(1/x)` is lit
|
|
425
611
|
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
|
|
612
|
+
inside the central 41 columns no solid pixel falls outside |y|>1 (7961 before the
|
|
613
|
+
envelope band existed).
|
|
427
614
|
|
|
428
615
|
- Macro arguments must be parenthesized.
|
|
429
616
|
- GPU `sin`/`cos` are unreliable for large arguments.
|
|
430
617
|
- Tangent-based distance saturates on steep columns.
|
|
431
618
|
- Per-column sampling is phase noise for oscillating functions.
|
|
619
|
+
- The **join invariant** replaces pole detection: tangent-foot seeding and arc-length
|
|
620
|
+
marching, so a connector of *any* origin (poles, jumps) cannot be formed
|
|
621
|
+
(`AGENTS.md` item 44).
|
|
432
622
|
|
|
433
623
|
## Domain, poles and stroke (Desmos-style)
|
|
434
624
|
|
|
435
625
|
[Desmos's engineering blog](https://engineering.desmos.com/articles/press-a-key-in-the-calculator/)
|
|
436
626
|
spells it out: **sample** the function into points, join them into **segments**,
|
|
437
|
-
stroke along the segments; "break where neighbouring points suggest a jump"
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
- **Stroke = distance to the
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
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.
|
|
627
|
+
stroke along the segments; "break where neighbouring points suggest a jump". The same
|
|
628
|
+
approach, with the sampling made uniform in *arc length* (the march above):
|
|
629
|
+
|
|
630
|
+
- **Stroke = distance to the joined polyline segments** (not to the tangent), plus the
|
|
631
|
+
stroked **column segments** for what the march cannot reach: a pixel also measures the
|
|
632
|
+
vertical y-range of the two neighbouring columns and strokes it with the same ramp. That
|
|
633
|
+
is GeoGebra's per-column segment with a vector line width, on the GPU; a steep wall
|
|
634
|
+
(`1/x`/`tan` at large |y|, `log`'s descent) keeps its nominal width even though its x span
|
|
635
|
+
is narrower than a pixel and its march seed is a whole view up the arc.
|
|
636
|
+
- **Break on jumps — by construction, not by detection**. The join invariant (a segment
|
|
637
|
+
only joins samples within 2.5 px) refuses a connector of any origin: the poles of
|
|
638
|
+
`1/x`/`tan(x)` and plain jump discontinuities alike (`x/sqrt(x**2)` had no pole
|
|
639
|
+
denominator for the old guards to find; measured: a 454-px column at x=0). The
|
|
640
|
+
interval band does not reintroduce one: a dependent quotient (`x/sqrt(x**2)`) is left to
|
|
641
|
+
the march, and a sign-changing pole (`tan`, `1/x`) is a gap or a `whole` column, not a
|
|
642
|
+
chord.
|
|
643
|
+
- **Domain** (derived by sympy, injected as `DOM` for the scalar march): denominator ≠ 0,
|
|
644
|
+
`log` argument > 0, `sqrt`/even-root ≥ 0 (a negative power also excludes 0), `asin/acos`
|
|
645
|
+
∈ [−1,1], `x**(1/3)` all reals. The *interval* evaluator is authoritative for the band:
|
|
646
|
+
it returns empty outside the domain, `[-∞, log h]` at a `log` boundary, and the half-open
|
|
647
|
+
values at a root endpoint. A pixel just outside the boundary is painted from the stroke
|
|
648
|
+
footprint (`x ± R`) and the neighbouring column segments — not by a symbol-derived ray.
|
|
649
|
+
The legacy ray remains only in the fallback shader (`USE_INTERVAL 0`).
|
|
650
|
+
- **Undersampled columns** use the interval enclosure of the pixel's column: no ±8-column
|
|
651
|
+
window, no violation count, no chord-stroke. The midpoint probe survives only as an
|
|
652
|
+
**interval-dependency check** for a wide finite column (`x * 1/[a,b]` overestimation);
|
|
653
|
+
the band and the neighbour column segments are always the exact interval ranges.
|
|
654
|
+
|
|
655
|
+
Measured: `sin(1/x)`'s transition overfill went from 1.66 to **0.0099 world units**
|
|
656
|
+
(threshold-20 pixels; the remaining edge is the stroke/AA radius); `x**(1/3)` draws both
|
|
657
|
+
branches; the `log(x)` descent measures **3.00 device px at every sampled row** without the
|
|
658
|
+
ray; `1/x`/`tan(x)` in a y∈[50,60] view are **3 full columns** (2 before the neighbour
|
|
659
|
+
segments); `x/sqrt(x**2)` still draws no connector (worst column 4 px); `k·x` holds one
|
|
660
|
+
width at every slope (the slope test at 2/10/40/200); `tan(x)` at an enormous y span fills
|
|
661
|
+
the screen (the interval is `whole`, matching GeoGebra); `sin(x)`/`x^2`/`exp(-x²)sin(10x)`
|
|
662
|
+
are unaffected.
|
|
663
|
+
|
|
493
664
|
|
|
494
665
|
## Performance (measured)
|
|
495
666
|
|
|
@@ -500,13 +671,55 @@ Startup to the first frame is about **230 ms**, attributed as follows
|
|
|
500
671
|
|---|---|---|
|
|
501
672
|
| 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
673
|
| 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;
|
|
674
|
+
| `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
675
|
| 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
676
|
|
|
506
|
-
Per-frame
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
677
|
+
Per-frame grab cycles (900×600, `grabToImage` — frame + readback, so an upper bound on
|
|
678
|
+
the GPU cost), steady state on the same window: `sin(1/x)` default **13.6 ms**, `tan(x)`
|
|
679
|
+
default **12.4 ms**, `sin(1/x)` at zoom 90 (worst case) **12.5 ms**. The previous
|
|
680
|
+
march/interval renderer measured 27/34/33 ms on the same machine: 16 samples are cheaper
|
|
681
|
+
than the march's seed + tangent projection + walk + probe, and the march is now compiled
|
|
682
|
+
only for the legacy path. The 1-D fragment source is about 21 KB.
|
|
683
|
+
|
|
684
|
+
**Per-pixel `F` evaluations.** The supported path is fixed: 16 samples for the polyline
|
|
685
|
+
(plus two interval evaluations for the column and the stroke footprint); there is no
|
|
686
|
+
march budget and no band-window evaluation. The old fixed-grid/march table is superseded
|
|
687
|
+
and no longer describes the shipped path.
|
|
688
|
+
|
|
689
|
+
## Shader cache
|
|
690
|
+
|
|
691
|
+
`qsb` binaries are cached in the platform temp directory:
|
|
692
|
+
|
|
693
|
+
```python
|
|
694
|
+
import qmlmathplot
|
|
695
|
+
|
|
696
|
+
qmlmathplot.cache_dir() # e.g. C:\Users\me\AppData\Local\Temp\qmlmathplot-qsb
|
|
697
|
+
qmlmathplot.cache_info() # {'path': ..., 'files': ..., 'bytes': ...}
|
|
698
|
+
qmlmathplot.clear_cache() # remove this package's entries, returns bytes freed
|
|
699
|
+
```
|
|
700
|
+
|
|
701
|
+
The location is `tempfile.gettempdir()/qmlmathplot-qsb`: `%TEMP%` on Windows, the per-user
|
|
702
|
+
`/var/folders/.../T` on macOS, and `$TMPDIR` or `/tmp` on Linux. Set
|
|
703
|
+
`QMLMATHPLOT_QSB_CACHE` to use another directory.
|
|
704
|
+
|
|
705
|
+
**It is not left to grow without bound.** The first bake in a process, and at most once per
|
|
706
|
+
minute afterwards, runs `prune_cache()`:
|
|
707
|
+
|
|
708
|
+
- entries older than **30 days** are removed;
|
|
709
|
+
- if the cache is over **32 MiB** (default), the least-recently-used `.qsb` files are removed
|
|
710
|
+
until it is at 75% of the cap — so an existing 60 MB cache drops to about 24 MB on the next
|
|
711
|
+
bake;
|
|
712
|
+
- interrupted-bake leftovers (`*.qsb.tmp`, `*.vert`, `*.frag`) older than an hour are removed.
|
|
713
|
+
|
|
714
|
+
Both knobs are overridable: `QMLMATHPLOT_QSB_CACHE_MB` (size cap) and
|
|
715
|
+
`qmlmathplot.prune_cache(max_bytes=..., max_age_days=...)` for an explicit pass. The cache
|
|
716
|
+
contains compiled shaders only; the GLSL source and temporary outputs are deleted after each
|
|
717
|
+
bake. Concurrent processes share the directory safely: writes use unique temp names and an
|
|
718
|
+
atomic replace, and pruning only removes entries by age/LRU.
|
|
719
|
+
|
|
720
|
+
On macOS/Linux `qsb` is looked up in the PySide6 package (`Qt/bin`, `Qt/libexec`) and on
|
|
721
|
+
`PATH`; if the wheel does not ship it, install Qt Shader Tools (`qt6-shadertools` on most
|
|
722
|
+
distributions) or add `qsb` to `PATH`. Only the Windows wheel has been confirmed to ship it.
|
|
510
723
|
|
|
511
724
|
## Pitfalls already hit (do not repeat)
|
|
512
725
|
|
|
@@ -521,9 +734,11 @@ measurement that settled each one). The Qt/packaging ones that shaped the code:
|
|
|
521
734
|
same x 9 times, and macros are preprocessor expansion with no call semantics.
|
|
522
735
|
- PySide ties a property's notify signal to the `Signal` **object**; a name string
|
|
523
736
|
silently leaves the property without one (and QML bindings then never refresh).
|
|
524
|
-
-
|
|
525
|
-
|
|
526
|
-
|
|
737
|
+
- Screen-space furniture is QML: background `Rectangle`, grid `Canvas`, axes/ticks
|
|
738
|
+
`Rectangle`s, labels/title `Text`; curves are `ShaderEffect` passes. Snap its lines to
|
|
739
|
+
**device** pixels with an integral device width (`_deviceWidth`/`_snapLine`), not logical
|
|
740
|
+
half-pixels: the 1.07-logical axis is 2 device px at DPR 1.5 and otherwise alternated
|
|
741
|
+
between one and two rows as it drifted with the zoom.
|
|
527
742
|
- `QQuickWidget.setProperty(name, …)` sets it on the **widget**, not on the QML root (the
|
|
528
743
|
widget has no such property, so it silently does nothing): inject the model with
|
|
529
744
|
`widget.rootObject().setProperty("plot", plot)`, as `MathPlotWidget` does.
|