qmlmathplot 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- qmlmathplot-0.1.0/PKG-INFO +383 -0
- qmlmathplot-0.1.0/README.md +372 -0
- qmlmathplot-0.1.0/pyproject.toml +28 -0
- qmlmathplot-0.1.0/pyproject.toml.orig +31 -0
- qmlmathplot-0.1.0/src/qmlmathplot/__init__.py +84 -0
- qmlmathplot-0.1.0/src/qmlmathplot/app.py +79 -0
- qmlmathplot-0.1.0/src/qmlmathplot/camera.py +366 -0
- qmlmathplot-0.1.0/src/qmlmathplot/curve.py +296 -0
- qmlmathplot-0.1.0/src/qmlmathplot/model.py +541 -0
- qmlmathplot-0.1.0/src/qmlmathplot/plot.py +213 -0
- qmlmathplot-0.1.0/src/qmlmathplot/qml/PlotView.qml +247 -0
- qmlmathplot-0.1.0/src/qmlmathplot/qsb.py +96 -0
- qmlmathplot-0.1.0/src/qmlmathplot/themes/LICENSE.matplotlib +99 -0
- qmlmathplot-0.1.0/src/qmlmathplot/themes/README.md +15 -0
- qmlmathplot-0.1.0/src/qmlmathplot/themes/Solarize_Light2.mplstyle +53 -0
- qmlmathplot-0.1.0/src/qmlmathplot/themes/bmh.mplstyle +27 -0
- qmlmathplot-0.1.0/src/qmlmathplot/themes/classic.mplstyle +491 -0
- qmlmathplot-0.1.0/src/qmlmathplot/themes/dark_background.mplstyle +26 -0
- qmlmathplot-0.1.0/src/qmlmathplot/themes/fast.mplstyle +11 -0
- qmlmathplot-0.1.0/src/qmlmathplot/themes/fivethirtyeight.mplstyle +37 -0
- qmlmathplot-0.1.0/src/qmlmathplot/themes/ggplot.mplstyle +39 -0
- qmlmathplot-0.1.0/src/qmlmathplot/themes/grayscale.mplstyle +29 -0
- qmlmathplot-0.1.0/src/qmlmathplot/themes/petroff10.mplstyle +5 -0
- qmlmathplot-0.1.0/src/qmlmathplot/themes/seaborn-v0_8-bright.mplstyle +3 -0
- qmlmathplot-0.1.0/src/qmlmathplot/themes/seaborn-v0_8-colorblind.mplstyle +3 -0
- qmlmathplot-0.1.0/src/qmlmathplot/themes/seaborn-v0_8-dark-palette.mplstyle +3 -0
- qmlmathplot-0.1.0/src/qmlmathplot/themes/seaborn-v0_8-dark.mplstyle +30 -0
- qmlmathplot-0.1.0/src/qmlmathplot/themes/seaborn-v0_8-darkgrid.mplstyle +30 -0
- qmlmathplot-0.1.0/src/qmlmathplot/themes/seaborn-v0_8-deep.mplstyle +3 -0
- qmlmathplot-0.1.0/src/qmlmathplot/themes/seaborn-v0_8-muted.mplstyle +3 -0
- qmlmathplot-0.1.0/src/qmlmathplot/themes/seaborn-v0_8-notebook.mplstyle +21 -0
- qmlmathplot-0.1.0/src/qmlmathplot/themes/seaborn-v0_8-paper.mplstyle +21 -0
- qmlmathplot-0.1.0/src/qmlmathplot/themes/seaborn-v0_8-pastel.mplstyle +3 -0
- qmlmathplot-0.1.0/src/qmlmathplot/themes/seaborn-v0_8-poster.mplstyle +21 -0
- qmlmathplot-0.1.0/src/qmlmathplot/themes/seaborn-v0_8-talk.mplstyle +21 -0
- qmlmathplot-0.1.0/src/qmlmathplot/themes/seaborn-v0_8-ticks.mplstyle +30 -0
- qmlmathplot-0.1.0/src/qmlmathplot/themes/seaborn-v0_8-white.mplstyle +30 -0
- qmlmathplot-0.1.0/src/qmlmathplot/themes/seaborn-v0_8-whitegrid.mplstyle +30 -0
- qmlmathplot-0.1.0/src/qmlmathplot/themes/seaborn-v0_8.mplstyle +57 -0
- qmlmathplot-0.1.0/src/qmlmathplot/themes/tableau-colorblind10.mplstyle +3 -0
- qmlmathplot-0.1.0/src/qmlmathplot/themes.py +236 -0
- qmlmathplot-0.1.0/src/qmlmathplot/view.py +55 -0
- qmlmathplot-0.1.0/src/qmlmathplot/widget.py +183 -0
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: qmlmathplot
|
|
3
|
+
Version: 0.1.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-Python: >=3.14
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# QMLMathPlot
|
|
13
|
+
|
|
14
|
+
An embeddable function-plotting component for Qt Quick apps. The curve is drawn
|
|
15
|
+
**per pixel by a fragment shader** (implicit signed-distance model): the per-frame
|
|
16
|
+
cost is ∝ the pixel count and independent of the function's frequency, so
|
|
17
|
+
oscillating functions (e.g. `sin(1/x)`) cannot drag the frame rate down through
|
|
18
|
+
polyline aliasing, and no CPU-side sampling is needed.
|
|
19
|
+
|
|
20
|
+
The state is a **camera on an infinite canvas**: a `Plot` holds a `Camera` (where you look)
|
|
21
|
+
and a list of `Curve`s (what is drawn), and the QML component `PlotView` binds to it. Several
|
|
22
|
+
panels are several `Plot`s in a Qt layout — there is no figure, no axes box and no page.
|
|
23
|
+
|
|
24
|
+
## Quick start
|
|
25
|
+
|
|
26
|
+
One line in a QtWidgets layout (least effort):
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from PySide6.QtWidgets import QApplication, QVBoxLayout, QWidget
|
|
30
|
+
from qmlmathplot import MathPlotWidget
|
|
31
|
+
|
|
32
|
+
app = QApplication([])
|
|
33
|
+
window = QWidget()
|
|
34
|
+
box = QVBoxLayout(window)
|
|
35
|
+
plot = MathPlotWidget("sin(1/x)") # drag to pan / wheel zooms anchored at the cursor
|
|
36
|
+
box.addWidget(plot)
|
|
37
|
+
window.show()
|
|
38
|
+
app.exec()
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`MathPlotWidget` keeps one curve's worth of convenience (`expression`, `error`,
|
|
42
|
+
`line_width`, `curve_color`, `background_color`, `aspect`, `view_bounds()`,
|
|
43
|
+
`reset_view()`, `zoom()`, `pan_pixels()`); everything else lives on the model behind it:
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
plot.plot.curves.add_curve("tan(x)", color="#ff8866", label="tan") # a second curve
|
|
47
|
+
plot.plot.camera.aspect = 1.0 # square units ("auto" by default)
|
|
48
|
+
plot.plot.grid = False
|
|
49
|
+
plot.plot.theme = "ggplot"
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Standalone window / command line:
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
uv run qmlmathplot "sin(1/x)" # equivalent to python examples/minimal.py …
|
|
56
|
+
uv run qmlmathplot --backend d3d11 "sin(1/x)" # force a specific backend
|
|
57
|
+
python examples/explorer_qtwidgets.py # function input box + sibling widgets
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
The examples live in `examples/`: `minimal.py` is the smallest window;
|
|
61
|
+
`explorer_qtwidgets.py` deliberately puts the plot widget into a "grabby"
|
|
62
|
+
environment (input box, `QScrollArea`, `QSplitter`, overlay) to verify the widgets
|
|
63
|
+
do not fight each other; `explorer_qtquick.py` (plus `explorer_qtquick.qml`) is
|
|
64
|
+
the pure Qt Quick demo.
|
|
65
|
+
|
|
66
|
+
## Embedding into an app
|
|
67
|
+
|
|
68
|
+
**QtWidgets**: `MathPlotWidget` is an ordinary `QWidget` (internally it bridges the
|
|
69
|
+
QML component into the widgets world with a `QQuickWidget` and renders into its own
|
|
70
|
+
FBO, so it can be stacked on top of / coexist with sibling widgets). Properties and
|
|
71
|
+
methods: `expression` / `error` / `line_width` / `curve_color` / `background_color` /
|
|
72
|
+
`aspect`, `view_bounds()` / `reset_view()` / `zoom(delta, u, v)` / `pan_pixels(dx, dy)`,
|
|
73
|
+
the signals `expressionChanged` / `errorChanged`, and the model itself as `.plot`.
|
|
74
|
+
|
|
75
|
+
**Pure QML (Qt Quick)**: create the model in Python and inject it (MVVM, see below).
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from PySide6.QtCore import QUrl
|
|
79
|
+
from PySide6.QtGui import QGuiApplication
|
|
80
|
+
from PySide6.QtQuick import QQuickView
|
|
81
|
+
from qmlmathplot import Plot, qml_component_path, register_qml_types
|
|
82
|
+
|
|
83
|
+
app = QGuiApplication([])
|
|
84
|
+
register_qml_types() # registers Plot, Camera, Curve and PlotView
|
|
85
|
+
|
|
86
|
+
plot = Plot() # the model (owned by the app)
|
|
87
|
+
plot.add_curve("sin(1/x)")
|
|
88
|
+
view = QQuickView()
|
|
89
|
+
view.setSource(QUrl.fromLocalFile(qml_component_path()))
|
|
90
|
+
view.rootObject().setProperty("plot", plot)
|
|
91
|
+
view.show()
|
|
92
|
+
app.exec()
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
```qml
|
|
96
|
+
import QmlMathPlot 1.0
|
|
97
|
+
|
|
98
|
+
PlotView { // inject a Plot, or let the component create its own
|
|
99
|
+
anchors.fill: parent
|
|
100
|
+
plot: myPlot
|
|
101
|
+
|
|
102
|
+
// Arbitrary QML in world coordinates: the transform is the view's own
|
|
103
|
+
Rectangle {
|
|
104
|
+
x: mapToScreen(0, 0).x - 4
|
|
105
|
+
y: mapToScreen(0, 0).y - 4
|
|
106
|
+
width: 8; height: 8; radius: 4; color: "#ffcc00"
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
`PlotView` exposes `mapToScreen(x, y)` / `mapFromScreen(point)` plus the forwarders
|
|
112
|
+
`camera` and `curves`; input handling (pan by left-drag, wheel zoom anchored at the cursor)
|
|
113
|
+
is built in and switchable through `camera.panEnabled` / `camera.zoomEnabled`.
|
|
114
|
+
|
|
115
|
+
The model:
|
|
116
|
+
|
|
117
|
+
| Object | Members |
|
|
118
|
+
|---|---|
|
|
119
|
+
| `Plot` | `camera`, `curves`, `xlim`/`ylim` (forwarded), `add_curve()` / `remove_curve()`, and the style properties: `background`, `grid`, `grid_color`, `grid_width`, `grid_alpha`, `grid_style`, `color_cycle`, `line_width`, `text_color`, `tick_color`, `tick_length`, `font_size`, `tick_font_size`, `title`, `title_color`, `title_font_size`, `title_bold`, `ticks_visible`, `theme` |
|
|
120
|
+
| `Camera` | `centre`, `zoom` (world units per logical pixel), `aspect`, `xlim`/`ylim` (derived), `ticks_x`/`ticks_y`, `zoomStep` / `panEnabled` / `zoomEnabled`, slots `setViewport(w, h)` / `zoom_by(delta, u, v, w, h)` / `pan_pixels(dx, dy)` / `reset()` |
|
|
121
|
+
| `Curve` | `expression`, `color`, `lineWidth`, `visible`, `label`, `error` (read-only), the baked `vertexShader` / `fragmentShader` |
|
|
122
|
+
| `CurveListModel` | the curves as a model (`curve`, `expression`, `color`, `lineWidth`, `visible`, `vertexShader`, `fragmentShader` roles) so QML puts one `ShaderEffect` behind each |
|
|
123
|
+
|
|
124
|
+
Every setter is a Qt `Property` with a notify signal, so the same calls work from QML, from a
|
|
125
|
+
Qt Designer slot or from a `QTimer`:
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
QTimer.singleShot(1000, lambda: setattr(curve, "expression", "sin(3/x)"))
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## Design notes
|
|
132
|
+
|
|
133
|
+
The design — the camera model, the Qt property/signal contract, the object model and the
|
|
134
|
+
build order — is specified in [`docs/api-design.md`](docs/api-design.md). In short: the
|
|
135
|
+
Matplotlib *vocabulary* is kept where it is vocabulary (limits, grid, title, ticks, annotate),
|
|
136
|
+
while the *mechanism* is refused — no `Figure`/`Axes`/`subplots` (one view per widget, no
|
|
137
|
+
bounded page), no `FigureCanvas`/`draw()` (the scene is live), no toolbar and no legend (host
|
|
138
|
+
chrome), and an export is a canvas region at a pixel size rather than a re-rendered page (not
|
|
139
|
+
implemented yet).
|
|
140
|
+
|
|
141
|
+
## The camera (centre, zoom, aspect)
|
|
142
|
+
|
|
143
|
+
`Camera` stores three numbers that do not depend on the widget: `centre` (world coordinates in
|
|
144
|
+
the middle of the view), `zoom` (world units per **logical** pixel on x) and `aspect` (the
|
|
145
|
+
ratio of the y scale to the x scale). `xlim` / `ylim` are *derived* from them and the last
|
|
146
|
+
reported size, so they always say what is on screen; assigning them moves the camera.
|
|
147
|
+
|
|
148
|
+
- `aspect = "auto"` (the default) keeps the two scales independent: the widget's shape decides
|
|
149
|
+
how much canvas is visible, and neither axis is tied to the other. A number keeps the ratio
|
|
150
|
+
fixed (matplotlib's convention: `1.0` = square units, `2.0` = the y-unit twice as tall).
|
|
151
|
+
Setting it only ever **shows more** — the camera keeps its centre and expands, never crops.
|
|
152
|
+
- **Resizing never zooms**: the scales are kept and the visible range follows the widget, so
|
|
153
|
+
dragging a window edge or a splitter shows more or less canvas instead of scaling the curve.
|
|
154
|
+
- `camera.reset()` returns to the home view (the classic 12 x 4 window, resolved for the
|
|
155
|
+
current size); the aspect stays as configured.
|
|
156
|
+
- The home is a *view*, so the first size report turns it into scales for that widget: opening
|
|
157
|
+
a narrow window shows the same window of canvas rather than a sliver of it.
|
|
158
|
+
|
|
159
|
+
```python
|
|
160
|
+
plot = MathPlotWidget("sin(1/x)", aspect=1.0) # square units
|
|
161
|
+
plot.aspect = "auto" # back to following the widget's shape
|
|
162
|
+
plot.plot.camera.zoom_by(120, 0.5, 0.5, w, h) # one wheel notch, anchored at the centre
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
## Themes
|
|
166
|
+
|
|
167
|
+
`Plot.theme` applies a style sheet to the style properties above:
|
|
168
|
+
|
|
169
|
+
```python
|
|
170
|
+
plot.theme = "ggplot" # any of the bundled sheets
|
|
171
|
+
plot.theme = "my-style" # themes/my-style.mplstyle next to the package
|
|
172
|
+
plot.theme = "path/to/any.mplstyle" # any file in the same format
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
The 26 bundled sheets are Matplotlib's own styles, vendored verbatim into
|
|
176
|
+
`qmlmathplot/themes/*.mplstyle` and converted at load time: Matplotlib's `rcParams` keys are
|
|
177
|
+
mapped onto this library's property names, and keys describing things this library does not
|
|
178
|
+
have (spines, figure size, dpi, legend, marker styles, …) are ignored. `themes.names()` lists
|
|
179
|
+
them (`"default"` plus the 26 sheets). An unknown name raises `KeyError` and leaves the current
|
|
180
|
+
theme alone.
|
|
181
|
+
|
|
182
|
+
## RHI backends
|
|
183
|
+
|
|
184
|
+
`--backend {d3d11,d3d12,vulkan,metal,opengl,null}`; **if unspecified, Qt's default
|
|
185
|
+
backend is used**. The implementation sets `QSG_RHI_BACKEND` before
|
|
186
|
+
`QGuiApplication` (Qt reads it during platform initialisation, and a later
|
|
187
|
+
`setGraphicsApi()` has no effect). On this machine (Intel driver 32.0.101.8826)
|
|
188
|
+
`d3d11` and `opengl` measured pixel-for-pixel identical.
|
|
189
|
+
|
|
190
|
+
## Structure (model / view)
|
|
191
|
+
|
|
192
|
+
| Layer | File | Responsibility |
|
|
193
|
+
|---|---|---|
|
|
194
|
+
| Model | `src/qmlmathplot/model.py` | expression → GLSL, shader source template (no Qt dependency, unit-testable) |
|
|
195
|
+
| Camera | `src/qmlmathplot/camera.py` | `Camera`: centre / zoom / aspect, the derived ranges, pan/zoom/reset, the nice-number tick algorithm |
|
|
196
|
+
| Curves | `src/qmlmathplot/curve.py` | `Curve` (expression → baked `.qsb`, style, error) and `CurveListModel` |
|
|
197
|
+
| Plot | `src/qmlmathplot/plot.py` | `Plot`: the camera, the curves and the styling the view binds to |
|
|
198
|
+
| View | `src/qmlmathplot/qml/PlotView.qml` (+ `view.py`) | QML: background, grid, ticks/title, one `ShaderEffect` per curve, pan/zoom input; `view.py` registers the types and yields the component path |
|
|
199
|
+
| Themes | `src/qmlmathplot/themes.py` + `themes/` | the vendored Matplotlib style sheets, resolved onto the `Plot`'s properties |
|
|
200
|
+
| Bake | `src/qmlmathplot/qsb.py` | GLSL → `.qsb` (PySide6 ships `qsb.exe`), cached by source hash |
|
|
201
|
+
| Widget | `src/qmlmathplot/widget.py` | `MathPlotWidget`: QQuickWidget bridge, drops straight into QtWidgets layouts |
|
|
202
|
+
| Entry | `src/qmlmathplot/app.py` | command line / standalone window (the `qmlmathplot` script, `examples/minimal.py`) |
|
|
203
|
+
| Examples | `examples/` | `minimal.py` (minimal), `explorer_qtwidgets.py` (input box + widget coexistence), `explorer_qtquick.py` (Qt Quick) |
|
|
204
|
+
|
|
205
|
+
## Algorithm (the two pieces that make it visually "perfect")
|
|
206
|
+
|
|
207
|
+
1. **Screen-space distance stroke**: for every pixel, compute its first-order
|
|
208
|
+
distance to the curve from `f` and `f'`,
|
|
209
|
+
`cov = clamp(lineWidth/2 + 0.5 − d, 0, 1)`. Equal width at any slope, naturally
|
|
210
|
+
antialiased, and correct by construction at poles (`tan(x)`) and jumps.
|
|
211
|
+
2. **Undersampled envelope band**: when a pixel's x interval holds several
|
|
212
|
+
oscillations (local period < 1 pixel), the per-pixel distance is meaningless
|
|
213
|
+
(it renders as moiré). In that case the column's `[min, max]` is filled as a
|
|
214
|
+
solid band — for `sin(1/x)` this equals filling its true ±1 envelope.
|
|
215
|
+
|
|
216
|
+
Both criteria are **scale-independent** and are unioned: (a) ≥ 2 direction
|
|
217
|
+
reversals within the column (8 sample points for an ordinary pixel, topped up to 16
|
|
218
|
+
inside the band region); (d) the derivative prediction `|f'|·dx` far exceeds the
|
|
219
|
+
measured spread (hard threshold 16× and `pred ≥ 4px`). One gate: a spread < 1.5 px
|
|
220
|
+
is not filled (do not turn peak ripple into a block of colour). An ordinary pixel
|
|
221
|
+
costs 1 `f` + 1 `f'` + 8 narrow-window samples; the wide window (±8 columns, 16
|
|
222
|
+
points) is only evaluated inside the band region.
|
|
223
|
+
|
|
224
|
+
## Tests
|
|
225
|
+
|
|
226
|
+
```
|
|
227
|
+
uv run pytest # everything
|
|
228
|
+
uv run pytest -m "not gui" # skip the render smoke tests that open a window
|
|
229
|
+
QSG_RHI_BACKEND=opengl uv run pytest -m gui
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
- `test_model.py`: GLSL generation (small integer powers as repeated
|
|
233
|
+
multiplication, avoiding `pow(x, y)`'s undefined behaviour for x<0), shader
|
|
234
|
+
injection (including the camera uniform and the mapping), and the camera math
|
|
235
|
+
(the home view at the first size, resize keeping the scale, an anchored zoom that
|
|
236
|
+
is exactly reversible, the aspect expanding but never cropping, ticks).
|
|
237
|
+
- `test_curve.py`: a failed expression keeps the last working shaders, the style
|
|
238
|
+
setters are idempotent, and the list model reports the right roles.
|
|
239
|
+
- `test_plot.py`: the plot's properties convert, notify exactly once and are wired
|
|
240
|
+
(a PySide property with an unresolved notify name silently loses it), the limits
|
|
241
|
+
are forwarded to the camera, and a theme applies / an unknown name is rejected.
|
|
242
|
+
- `test_themes.py`: the vendored style sheets resolve to this library's properties.
|
|
243
|
+
- `test_shader_bake.py`: the stage of a baked `.qsb` must match its purpose, and
|
|
244
|
+
the four backend targets GLSL/HLSL/MSL/SPIR-V must all be covered.
|
|
245
|
+
- `test_render_smoke.py`: open a window, render, read pixels; verify the thin-line
|
|
246
|
+
shape, `sin(1/x)` filling its ±1 envelope, no |y|>1 artefacts outside the central
|
|
247
|
+
columns, poles not connected, nothing drawn outside the domain, and the QML
|
|
248
|
+
furniture (grid lines exactly at the tick positions, ticks/labels, title).
|
|
249
|
+
- `test_widget.py`: `MathPlotWidget` coexisting with neighbouring widgets — the plot
|
|
250
|
+
appearing inside a layout, an expression change / an invalid expression keeping the
|
|
251
|
+
previous graph, curves added/hidden/removed through the model, **the wheel over a
|
|
252
|
+
scroll area being consumed by the plot (zoom instead of scrolling the parent)**, the
|
|
253
|
+
fixed aspect being undistorted by the widget's shape, and the plot staying out of the
|
|
254
|
+
Tab focus chain. `conftest.py` provides a session-scoped `QApplication` (QtWidgets
|
|
255
|
+
needs one, and a process may only have one).
|
|
256
|
+
- `test_qtquick_coexistence.py`: the same drag conflict in Qt Quick (a `Flickable`
|
|
257
|
+
must not steal the pan).
|
|
258
|
+
|
|
259
|
+
## Pitfalls near the `sin(1/x)` singularity (fixed — do not repeat)
|
|
260
|
+
|
|
261
|
+
All of them (macro arguments, GPU `sin`/`cos`, stroke saturation on steep columns,
|
|
262
|
+
per-column phase noise) are recorded in `AGENTS.md` as symptom → cause → fix with
|
|
263
|
+
the measurements that settled them. After the fixes (1350×900, default view, D3D11
|
|
264
|
+
and OpenGL pixel-for-pixel identical): the singularity column of `sin(1/x)` is lit
|
|
265
|
+
over a ratio of 0.50 (= half a column filling the ±1 envelope) with flat edges, and
|
|
266
|
+
inside the central 41 columns no solid pixel falls outside |y|>1 (7961 before).
|
|
267
|
+
|
|
268
|
+
- Macro arguments must be parenthesized.
|
|
269
|
+
- GPU `sin`/`cos` are unreliable for large arguments.
|
|
270
|
+
- Tangent-based distance saturates on steep columns.
|
|
271
|
+
- Per-column sampling is phase noise for oscillating functions.
|
|
272
|
+
|
|
273
|
+
## Domain, poles and stroke (Desmos-style)
|
|
274
|
+
|
|
275
|
+
[Desmos's engineering blog](https://engineering.desmos.com/articles/press-a-key-in-the-calculator/)
|
|
276
|
+
spells it out: **sample** the function into points, join them into **segments**,
|
|
277
|
+
stroke along the segments; "break where neighbouring points suggest a jump"; refine
|
|
278
|
+
extrema/zeros by bisection. The same approach is used here:
|
|
279
|
+
|
|
280
|
+
- **Stroke = distance to the sampled polyline segments** (not to the tangent). The
|
|
281
|
+
tangent approximation overestimates the distance on steep or strongly curved
|
|
282
|
+
parts, so the stroke thins out or even fades away (`log(x)` as x→0+, the steep
|
|
283
|
+
stretch of `sin(1/x)`); measuring to the segment is **equal width everywhere**.
|
|
284
|
+
Sampled over ±1 column: the polyline must be wide enough that a pixel's
|
|
285
|
+
perpendicular foot lands on a segment (on a sloped line it is displaced along x
|
|
286
|
+
by `m·d/√(1+m²)`); with a narrower window the distance degraded to "distance to
|
|
287
|
+
the nearest endpoint" and steep parts looked thin and ragged.
|
|
288
|
+
- **Break on jumps**: via the **analytic pole gap** (denominator sign change, world
|
|
289
|
+
units) — no vertical connector is drawn at the asymptotes of `1/x`, `tan(x)`.
|
|
290
|
+
(An earlier "adjacent samples differ by more than 4 viewport heights" criterion
|
|
291
|
+
was pixel-/viewport-relative, so under magnification it also cut legitimate steep
|
|
292
|
+
segments; it was removed.)
|
|
293
|
+
- **Domain** (derived by sympy, injected as `DOM`): denominator ≠ 0, `log` argument
|
|
294
|
+
> 0, `sqrt` argument ≥ 0, `asin/acos` argument ∈ [−1,1]; pixels outside the domain
|
|
295
|
+
are discarded outright. NaN/inf samples take part in neither the envelope nor
|
|
296
|
+
segment joining.
|
|
297
|
+
- **Undersampled columns** (several oscillations inside one column) are replaced
|
|
298
|
+
wholesale by the envelope band: a polyline is random sawtooth in such columns.
|
|
299
|
+
There are two criteria, both **scale-independent**: (a) ≥ 2 direction reversals
|
|
300
|
+
within the column — 8 points in the narrow window for an ordinary pixel, and
|
|
301
|
+
inside the band region 8 additional interleaved points make it 16 (with 8 points,
|
|
302
|
+
random sampling misses the reversals in ~11% of columns, showing up as residual
|
|
303
|
+
vertical stripes; sampling 16 points globally would cost 8 extra `f` evaluations
|
|
304
|
+
for every pixel, not worth paying for a few columns); (d) the distance `pred` a
|
|
305
|
+
column should traverse according to the derivative far exceeds the measured
|
|
306
|
+
`spread` (hard threshold 16×, and `pred ≥ 4px`) — sub-pixel oscillation sampling
|
|
307
|
+
necessarily misses reversals (it may even come out monotone), and this criterion
|
|
308
|
+
does not depend on sampling randomness. Pixel-unit gates are avoided here (a
|
|
309
|
+
spread measured in pixels stops being meaningful once the viewport is deep);
|
|
310
|
+
poles are handled by the analytic gap instead.
|
|
311
|
+
- **Infinite extension at unbounded domain boundaries** (`unbounded_edges()`, sympy
|
|
312
|
+
one-sided limits): `log(x)` **diverges slowly** (logarithmically) as x→0+, so a
|
|
313
|
+
fixed-width sampling window never reaches deep enough values — in a deep viewport
|
|
314
|
+
the visible curve lies at x≈e^y (e.g. for y∈[-16,-12], x∈[1e-7,6e-6]), while the
|
|
315
|
+
leftmost sample only reaches log≈-7, so the **whole segment disappears**. Fix:
|
|
316
|
+
when the window crosses such a boundary, extend the polyline along the boundary x
|
|
317
|
+
into a vertical ray out to ±1e12, which draws at any depth (the ray lies on the
|
|
318
|
+
boundary x, so the width is constant and the columns align; the x<0 half is cut
|
|
319
|
+
away by the domain, leaving exactly half a stroke visible). `1/x` and `tan(x)`
|
|
320
|
+
diverge fast, so their sampled values naturally exceed any viewport and no ray is
|
|
321
|
+
needed; `sqrt` (limit 0) and `sin(1/x)` (no limit) are not on the list either.
|
|
322
|
+
|
|
323
|
+
Measured: the steep stretch of `log(x)` has a constant horizontal width of 1~2
|
|
324
|
+
device pixels (previously a tapering gradient); the thin line of `sin(1/x)` is
|
|
325
|
+
uniform in width; `1/x` and `tan(x)` are not connected across their poles;
|
|
326
|
+
`sin(x)`/`x^2`/`exp(-x²)sin(10x)` are unaffected.
|
|
327
|
+
|
|
328
|
+
## Performance (measured)
|
|
329
|
+
|
|
330
|
+
Startup to the first frame is about **230 ms**, attributed as follows
|
|
331
|
+
(Windows / D3D11 / 900×600):
|
|
332
|
+
|
|
333
|
+
| Stage | Time | Notes |
|
|
334
|
+
|---|---|---|
|
|
335
|
+
| 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 |
|
|
336
|
+
| Window creation + QML load + first frame | ~80 ms | from the second window on (device already created) this is the order of magnitude |
|
|
337
|
+
| `qsb` bake | 0 / ~126 ms | cached by source hash: 0.15 ms on a hit; after a source change (code edit) it must re-bake once |
|
|
338
|
+
| 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) |
|
|
339
|
+
|
|
340
|
+
Per-frame GPU cost (2400×1500, extreme zoom, worst case) is **31 ms**; about 1/7 of
|
|
341
|
+
that at 900×600, far below the 16.7 ms of 60 Hz. The shader source is about 6.9 KB.
|
|
342
|
+
Every visible curve is one full-screen fragment pass, so `Curve.visible` is the lever
|
|
343
|
+
when a figure carries many of them.
|
|
344
|
+
|
|
345
|
+
## Pitfalls already hit (do not repeat)
|
|
346
|
+
|
|
347
|
+
`AGENTS.md` at the repo root carries all the records (symptom → cause → fix, with the
|
|
348
|
+
measurement that settled each one). The Qt/packaging ones that shaped the code:
|
|
349
|
+
|
|
350
|
+
- qsb decides the stage from the source file suffix (see `qsb.bake()` and
|
|
351
|
+
`test_shader_bake.py`).
|
|
352
|
+
- Read pixels with the async `QQuickItem.grabToImage()`, never
|
|
353
|
+
`QQuickWindow.grabWindow()`.
|
|
354
|
+
- Expressions are **macros** rather than GLSL user functions: a pixel evaluates the
|
|
355
|
+
same x 9 times, and macros are preprocessor expansion with no call semantics.
|
|
356
|
+
- PySide ties a property's notify signal to the `Signal` **object**; a name string
|
|
357
|
+
silently leaves the property without one (and QML bindings then never refresh).
|
|
358
|
+
|
|
359
|
+
## Releasing
|
|
360
|
+
|
|
361
|
+
1. Bump `version` in `pyproject.toml` and commit.
|
|
362
|
+
2. Tag and push: `git tag v0.2.0 && git push origin v0.2.0`.
|
|
363
|
+
3. `.github/workflows/publish.yml` then runs the non-GUI test suite, builds the sdist and wheel
|
|
364
|
+
with `uv build`, and uploads them to PyPI. The tag must match `pyproject.toml` or the run
|
|
365
|
+
fails before anything is published.
|
|
366
|
+
|
|
367
|
+
Authentication is PyPI's **trusted publishing** (OIDC) — no API token, no repository secret.
|
|
368
|
+
Configure it once at <https://pypi.org/manage/account/publishing/> (and the same on
|
|
369
|
+
test.pypi.org) with the owner, the repository name, the workflow file name `publish.yml` and the
|
|
370
|
+
environment `pypi`. A project that does not exist on PyPI yet uses the "pending publisher" form
|
|
371
|
+
there and is created by the first successful run. Running the workflow manually
|
|
372
|
+
(*Actions → Publish to PyPI → Run workflow*) publishes to TestPyPI by default, so a release can
|
|
373
|
+
be rehearsed:
|
|
374
|
+
|
|
375
|
+
```
|
|
376
|
+
pip install -i https://test.pypi.org/simple/ qmlmathplot
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
The workflow runs on Windows: that is the platform this library is verified on (the shader baker
|
|
380
|
+
`qsb` ships inside the PySide6 wheel and is only confirmed to be present there, see `qsb.py`),
|
|
381
|
+
and the GUI tests need a GPU scene graph no runner has. The artifact is `py3-none-any`, so the
|
|
382
|
+
platform does not change what is published. The GUI tests stay a developer-machine thing:
|
|
383
|
+
`uv run pytest` (everything) versus `uv run pytest -m "not gui"` (what CI runs).
|