qmlmathplot 0.1.0__py3-none-any.whl

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 (43) hide show
  1. qmlmathplot/__init__.py +84 -0
  2. qmlmathplot/app.py +79 -0
  3. qmlmathplot/camera.py +366 -0
  4. qmlmathplot/curve.py +296 -0
  5. qmlmathplot/model.py +541 -0
  6. qmlmathplot/plot.py +213 -0
  7. qmlmathplot/qml/PlotView.qml +247 -0
  8. qmlmathplot/qsb.py +96 -0
  9. qmlmathplot/themes/LICENSE.matplotlib +99 -0
  10. qmlmathplot/themes/README.md +15 -0
  11. qmlmathplot/themes/Solarize_Light2.mplstyle +53 -0
  12. qmlmathplot/themes/bmh.mplstyle +27 -0
  13. qmlmathplot/themes/classic.mplstyle +491 -0
  14. qmlmathplot/themes/dark_background.mplstyle +26 -0
  15. qmlmathplot/themes/fast.mplstyle +11 -0
  16. qmlmathplot/themes/fivethirtyeight.mplstyle +37 -0
  17. qmlmathplot/themes/ggplot.mplstyle +39 -0
  18. qmlmathplot/themes/grayscale.mplstyle +29 -0
  19. qmlmathplot/themes/petroff10.mplstyle +5 -0
  20. qmlmathplot/themes/seaborn-v0_8-bright.mplstyle +3 -0
  21. qmlmathplot/themes/seaborn-v0_8-colorblind.mplstyle +3 -0
  22. qmlmathplot/themes/seaborn-v0_8-dark-palette.mplstyle +3 -0
  23. qmlmathplot/themes/seaborn-v0_8-dark.mplstyle +30 -0
  24. qmlmathplot/themes/seaborn-v0_8-darkgrid.mplstyle +30 -0
  25. qmlmathplot/themes/seaborn-v0_8-deep.mplstyle +3 -0
  26. qmlmathplot/themes/seaborn-v0_8-muted.mplstyle +3 -0
  27. qmlmathplot/themes/seaborn-v0_8-notebook.mplstyle +21 -0
  28. qmlmathplot/themes/seaborn-v0_8-paper.mplstyle +21 -0
  29. qmlmathplot/themes/seaborn-v0_8-pastel.mplstyle +3 -0
  30. qmlmathplot/themes/seaborn-v0_8-poster.mplstyle +21 -0
  31. qmlmathplot/themes/seaborn-v0_8-talk.mplstyle +21 -0
  32. qmlmathplot/themes/seaborn-v0_8-ticks.mplstyle +30 -0
  33. qmlmathplot/themes/seaborn-v0_8-white.mplstyle +30 -0
  34. qmlmathplot/themes/seaborn-v0_8-whitegrid.mplstyle +30 -0
  35. qmlmathplot/themes/seaborn-v0_8.mplstyle +57 -0
  36. qmlmathplot/themes/tableau-colorblind10.mplstyle +3 -0
  37. qmlmathplot/themes.py +236 -0
  38. qmlmathplot/view.py +55 -0
  39. qmlmathplot/widget.py +183 -0
  40. qmlmathplot-0.1.0.dist-info/METADATA +383 -0
  41. qmlmathplot-0.1.0.dist-info/RECORD +43 -0
  42. qmlmathplot-0.1.0.dist-info/WHEEL +4 -0
  43. qmlmathplot-0.1.0.dist-info/entry_points.txt +3 -0
qmlmathplot/model.py ADDED
@@ -0,0 +1,541 @@
1
+ """Model layer: expression -> GLSL and the shader source templates.
2
+
3
+ This layer imports no Qt and can be tested standalone without a GUI; both the camera
4
+ (camera.py) and the View (the QML ShaderEffect) build on it.
5
+
6
+ The drawing model is **implicit (signed-distance)**: the fragment shader computes, per
7
+ pixel, the first-order screen-space distance "to the curve". Cost per frame is
8
+ proportional to the pixel count and independent of function frequency (oscillatory
9
+ functions cannot drag the frame rate down through aliasing); a column that packs several
10
+ oscillations into one pixel is filled as a [min,max] envelope band instead — see the
11
+ comment above FRAGMENT_TEMPLATE.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import re
17
+
18
+ import sympy as sp
19
+ from sympy.printing.glsl import GLSLPrinter
20
+ from sympy.printing.precedence import PRECEDENCE
21
+
22
+ __all__ = [
23
+ "FRAGMENT_TEMPLATE",
24
+ "VERTEX_SHADER",
25
+ "dfunc_glsl",
26
+ "func_glsl",
27
+ "shader_sources",
28
+ ]
29
+
30
+
31
+ class _PlotGLSLPrinter(GLSLPrinter):
32
+ """Patch for the GLSL printer:
33
+
34
+ 1. Small integer powers do not emit pow(). GLSL's pow(x, y) is undefined for x < 0
35
+ (many drivers simply return NaN), yet sympy prints x**2 as pow(x, 2.0). Emit
36
+ repeated multiplication here instead; it is also faster.
37
+ 2. Optionally print a symbol as ``(name)``: expressions are inlined into
38
+ ``#define F(u) (...)``, and the macro is **text substitution**, so ``1.0/u``
39
+ with ``F(x - h)`` would become ``1.0/x - h`` — one missing pair of parentheses
40
+ changes the semantics.
41
+ """
42
+
43
+ def __init__(self, wrap_symbol: str | None = None) -> None:
44
+ super().__init__()
45
+ self._wrap_symbol = wrap_symbol
46
+
47
+ def _print_Symbol(self, expr: sp.Symbol) -> str:
48
+ text = super()._print_Symbol(expr)
49
+ return f"({text})" if expr.name == self._wrap_symbol else text
50
+
51
+ def _print_Pow(self, expr: sp.Expr) -> str:
52
+ exp = expr.exp
53
+ if exp.is_Integer and 2 <= abs(int(exp)) <= 8:
54
+ n = int(exp)
55
+ base = self.parenthesize(expr.base, PRECEDENCE["Mul"])
56
+ product = "*".join([base] * abs(n))
57
+ # Always parenthesize: the product may end up inside a denominator (sympy
58
+ # splits x**-2 into 1/x**2), and without parentheses it prints "a/x*x", which
59
+ # parses as (a/x)*x.
60
+ return f"(1.0/({product}))" if n < 0 else f"({product})"
61
+ return super()._print_Pow(expr)
62
+
63
+
64
+ # The GPU's built-in sin/cos are unreliable for large arguments (measured on Intel D3D11),
65
+ # while functions like sin(1/x) can reach arguments in the hundreds near a singularity.
66
+ # Fold the argument into [0, 2π) before calling the built-in; all implementations are
67
+ # accurate for small arguments.
68
+ _SIN_COS = re.compile(r"(?<![A-Za-z0-9_])(sin|cos)\(")
69
+
70
+
71
+ def _wrap_trig(text: str) -> str:
72
+ return _SIN_COS.sub(lambda m: ("SIN(" if m.group(1) == "sin" else "COS("), text)
73
+
74
+
75
+ def func_glsl(expr: sp.Expr, name: str = "x", wrap: bool = False) -> str:
76
+ """Print a sympy expression as a GLSL expression (returns the right-hand side of f(x)).
77
+
78
+ name renames the variable: in the shader ``#define F(u) (...)`` needs it printed as u.
79
+ wrap=True prints that variable as ``(u)``, making it safe for macro text substitution
80
+ (see _PlotGLSLPrinter). sin/cos are wrapped as the template's SIN/COS (with argument
81
+ reduction; see the template comment).
82
+ """
83
+ if name != "x":
84
+ expr = expr.subs(sp.Symbol("x"), sp.Symbol(name))
85
+ printer = _PlotGLSLPrinter(wrap_symbol=name if wrap else None)
86
+ return _wrap_trig(printer.doprint(expr))
87
+
88
+
89
+ def _pole_factors(expr: sp.Expr) -> list[sp.Expr]:
90
+ """Collect "pole denominators": where such a factor changes sign, the function jumps
91
+ from +∞ to -∞.
92
+
93
+ Only **sign-changing** factors (x, cos(x), …) are collected: a pole like 1/x² does not
94
+ change sign and needs no gap — both sides tend to +∞, so drawing connects them
95
+ naturally.
96
+ """
97
+ out: list[sp.Expr] = []
98
+ if isinstance(expr, sp.Pow) and expr.exp.is_number and expr.exp.is_negative:
99
+ out.append(expr.base)
100
+ if isinstance(expr, sp.tan | sp.sec):
101
+ out.append(sp.cos(expr.args[0]))
102
+ elif isinstance(expr, sp.cot | sp.csc):
103
+ out.append(sp.sin(expr.args[0]))
104
+ for arg in expr.args:
105
+ out.extend(_pole_factors(arg))
106
+ return out
107
+
108
+
109
+ def _domain_conditions(expr: sp.Expr) -> list[sp.Expr]:
110
+ """Collect domain conditions (sympy expressions; each must hold identically to be
111
+ inside the domain).
112
+
113
+ Covers the common domain boundaries: denominator != 0, log argument > 0, sqrt argument
114
+ >= 0, asin/acos argument ∈ [-1,1], cos != 0 for tan/sec, sin != 0 for cot/csc.
115
+ """
116
+ out: list[sp.Expr] = []
117
+ if isinstance(expr, sp.Pow) and expr.exp.is_number and expr.exp.is_negative:
118
+ out.append(sp.Ne(expr.base, 0))
119
+ elif isinstance(expr, sp.Pow) and expr.exp == sp.Rational(1, 2):
120
+ out.append(sp.Ge(expr.base, 0))
121
+ if isinstance(expr, sp.log):
122
+ out.append(sp.Gt(expr.args[0], 0))
123
+ elif isinstance(expr, sp.asin | sp.acos):
124
+ out.append(sp.And(sp.Ge(expr.args[0], -1), sp.Le(expr.args[0], 1)))
125
+ elif isinstance(expr, sp.tan | sp.sec):
126
+ out.append(sp.Ne(sp.cos(expr.args[0]), 0))
127
+ elif isinstance(expr, sp.cot | sp.csc):
128
+ out.append(sp.Ne(sp.sin(expr.args[0]), 0))
129
+ for arg in expr.args:
130
+ out.extend(_domain_conditions(arg))
131
+ return out
132
+
133
+
134
+ def _domain_is_pole_only(expr: sp.Expr) -> bool:
135
+ """True when every domain condition is just "denominator != 0" (tan, sec, 1/x, …).
136
+
137
+ Those functions are defined everywhere except at the poles, where the value itself
138
+ blows up and the NaN/inf test already rejects the sample. Evaluating the predicate
139
+ again for every sample is then pure overhead.
140
+ """
141
+ return all(isinstance(cond, sp.Ne) and cond.rhs == 0 for cond in _domain_conditions(expr))
142
+
143
+
144
+ def unbounded_edges(expr: sp.Expr, name: str = "x") -> list[tuple[float, bool, bool]]:
145
+ """Points on a domain boundary where the function genuinely tends to ±∞:
146
+ [(boundary x, tends to -∞?, tends to +∞?)].
147
+
148
+ Only **slow divergence** needs this: log(x) tends to -∞ as x→0+, but a fixed-width
149
+ sampling window can never sample deep enough, so the curve vanishes entirely in a deep
150
+ viewport. 1/x and tan diverge fast; their sampled values naturally exceed any viewport,
151
+ so they need no ray (and tan's pole set is infinite, hence not enumerable analytically
152
+ either).
153
+ """
154
+ sym = sp.Symbol(name)
155
+ out: list[tuple[float, bool, bool]] = []
156
+ for cond in _domain_conditions(expr):
157
+ lhs = cond.lhs if isinstance(cond, (sp.Gt, sp.Ge, sp.Ne)) else None
158
+ if lhs is None or sym not in lhs.free_symbols:
159
+ continue
160
+ # Only polynomial boundaries (x=0, x-1=0, …) are analysed. Non-polynomial ones
161
+ # (cos(x)=0 for tan) cost ~94ms with solve+limit, and those functions diverge fast
162
+ # so sampling is naturally deep enough without this ray.
163
+ if not lhs.is_polynomial(sym):
164
+ continue
165
+ try:
166
+ points = sp.solve(sp.Eq(lhs, 0), sym)
167
+ except Exception: # noqa: BLE001 — give up if it can't be solved
168
+ continue
169
+ for point in points:
170
+ if not point.is_number:
171
+ continue
172
+ down = up = False
173
+ for side in ("+", "-"):
174
+ try:
175
+ lim = sp.limit(expr, sym, point, side)
176
+ except Exception: # noqa: BLE001
177
+ continue
178
+ if lim is sp.oo:
179
+ up = True
180
+ elif lim is -sp.oo:
181
+ down = True
182
+ if down or up:
183
+ out.append((float(point), down, up))
184
+ return sorted(set(out))
185
+
186
+
187
+ def _edge_glsl(expr: sp.Expr) -> tuple[str, str]:
188
+ """Generate two GLSL fragments: the boundary-hit query and the boundary-direction query
189
+ (both empty when there is no boundary)."""
190
+ edges = unbounded_edges(expr)
191
+ if not edges:
192
+ return "", ""
193
+ hit = "\n".join(
194
+ f" if ((xa <= {x!r}) && ({x!r} <= xb)"
195
+ f" && abs({x!r} - 0.5 * (xa + xb)) < abs(best - 0.5 * (xa + xb))) best = {x!r};"
196
+ for x, _d, _u in edges
197
+ )
198
+ du = "\n".join(
199
+ f" if (abs(ex - {x!r}) < 1e-9) return vec2({-1.0 if d else 0.0}, {1.0 if u else 0.0});"
200
+ for x, d, u in edges
201
+ )
202
+ return hit, du
203
+
204
+
205
+ def pole_glsl(expr: sp.Expr, name: str = "x") -> str | None:
206
+ """Product of the pole denominators (GLSL). Evaluated at both interval ends; a product
207
+ <= 0 means the interval crosses a pole."""
208
+ factors = _pole_factors(expr)
209
+ if not factors:
210
+ return None
211
+ return " * ".join(f"({func_glsl(f, name)})" for f in factors)
212
+
213
+
214
+ def domain_glsl(expr: sp.Expr, name: str = "x") -> str | None:
215
+ """Domain predicate (a GLSL relational expression). Returns None when it cannot be
216
+ analysed."""
217
+ conds = _domain_conditions(expr)
218
+ if not conds:
219
+ return None
220
+ return " && ".join(f"({func_glsl(c, name)})" for c in conds)
221
+
222
+
223
+ def dfunc_glsl(expr: sp.Expr, name: str = "x") -> str:
224
+ """GLSL text for f'(x) (used by the stroke criterion and the pole criterion)."""
225
+ return func_glsl(sp.diff(expr, sp.Symbol("x")), name)
226
+ # --------------------------------------------------------------------------
227
+ # Shader sources (fragment shader + per-pixel implicit drawing)
228
+ # --------------------------------------------------------------------------
229
+
230
+ VERTEX_SHADER = """#version 440
231
+ layout(location = 0) in vec4 qt_Vertex;
232
+ layout(location = 1) in vec2 qt_MultiTexCoord0;
233
+ layout(location = 0) out vec2 vUV;
234
+ layout(std140, binding = 0) uniform buf {
235
+ mat4 qt_Matrix;
236
+ float qt_Opacity;
237
+ };
238
+ void main() {
239
+ vUV = qt_MultiTexCoord0;
240
+ gl_Position = qt_Matrix * qt_Vertex;
241
+ }
242
+ """
243
+
244
+ # Implicit (signed-distance) drawing — a puzzle in two pieces:
245
+ #
246
+ # 1) Stroke: per pixel, compute its screen-space distance to the polyline (segments
247
+ # joining adjacent samples), yielding a constant-width, anti-aliased line; steep
248
+ # segments neither thicken nor break. Uses "distance to the segment" rather than
249
+ # "distance to the tangent", which overestimates at strong curvature and makes the
250
+ # line thin out or vanish (Desmos' approach).
251
+ # 2) Envelope band: when one pixel's x interval packs several oscillations (local period
252
+ # < 1 pixel), the per-pixel distance is meaningless (it draws as moiré/random
253
+ # aliasing). Fill [min,max] as a solid band instead — for sin(1/x) this is exactly
254
+ # its true ±1 envelope.
255
+ #
256
+ # When does a column count as "packing several oscillations" (all within the ±1-column
257
+ # narrow window; ordinary pixels sample 8 points, banded columns add up to 16):
258
+ # a) >= 2 direction turns within the column: the curve reverses up/down several times
259
+ # => several oscillations (a single turn is a resolvable extremum and is stroked
260
+ # normally);
261
+ # d) the derivative prediction |f'|·dx far exceeds the measured spread (hard threshold
262
+ # 16×, with pred >= 4px): when the oscillation period is far below one column,
263
+ # sampling can miss it (8 points may happen to look monotone), and this criterion is
264
+ # independent of sampling luck, so it catches that case.
265
+ # The band's top/bottom edges use a wider ±8-column, 16-point window to estimate the
266
+ # envelope, so the edges do not bite out dark seams/steps. A pole (denominator changes
267
+ # sign and values far exceed the viewport) gets its own gap; no vertical +∞/-∞ connector
268
+ # is drawn.
269
+ #
270
+ # The expression is a macro rather than a user function: one pixel evaluates the same x
271
+ # 1 + 8 + 16 = 25 times (stroke samples + narrow window + wide window), and a macro is
272
+ # preprocessor expansion with no function-call semantics — exactly what is needed.
273
+ FRAGMENT_TEMPLATE = """#version 440
274
+ layout(location = 0) in vec2 vUV;
275
+ layout(location = 0) out vec4 fragColor;
276
+ layout(std140, binding = 0) uniform buf {
277
+ mat4 qt_Matrix;
278
+ float qt_Opacity;
279
+ vec4 camera; // centre.x, centre.y, world units per logical pixel (x, y)
280
+ vec2 size; // viewport size (logical pixels)
281
+ float lineWidth; // line width (logical pixels)
282
+ vec4 color; // non-premultiplied rgba
283
+ };
284
+
285
+ // Argument reduction: the GPU's built-in sin/cos are unreliable for large arguments;
286
+ // fold into [0,2π) before calling
287
+ #define TAU 6.283185307179586
288
+ float _wrap(float t) { return t - TAU * floor(t * (1.0 / TAU)); }
289
+ #define SIN(t) sin(_wrap(t))
290
+ #define COS(t) cos(_wrap(t))
291
+
292
+ // Point-to-segment distance (screen space). Stroking by segment => constant width
293
+ // throughout.
294
+ float _seg_dist(vec2 p, vec2 a, vec2 b) {
295
+ vec2 ab = b - a;
296
+ float t = clamp(dot(p - a, ab) / max(dot(ab, ab), 1e-12), 0.0, 1.0);
297
+ return length(p - (a + t * ab));
298
+ }
299
+
300
+ // Unbounded domain boundaries (injected from sympy one-sided limits): slowly diverging
301
+ // boundaries such as log(x) as x→0+. A fixed-width sampling window cannot sample deep
302
+ // enough, so the curve would vanish entirely in a deep viewport; here the polyline is
303
+ // extended along the boundary as a vertical ray to ±1e12, which reaches any depth and
304
+ // lies exactly on the boundary x.
305
+ float _edge_hit(float xa, float xb) {
306
+ float best = 1e30;
307
+ @EDGEHIT@
308
+ return best;
309
+ }
310
+ vec2 _edge_du(float ex) {
311
+ @EDGEDU@
312
+ return vec2(0.0, 0.0);
313
+ }
314
+
315
+ #define F(u) (@FUNC@)
316
+ #define DF @DFUNC@
317
+ // Domain predicate (pixels outside the domain are not drawn) and "pole denominators"
318
+ // (sign change => the interval crosses a pole). DOMS is the per-sample variant: for a
319
+ // purely pole-shaped domain (tan, 1/x, sec, …) it is `true`, because there the NaN/inf
320
+ // test on the value already rejects out-of-domain samples.
321
+ @DOM@
322
+ @DOMS@
323
+ @POLE@
324
+
325
+ void main() {
326
+ // Camera -> world: the centre lands in the middle of the item, one logical pixel is
327
+ // (camera.z, camera.w) world units, and vUV.y is top-down (screen y grows downwards).
328
+ float x = camera.x + (vUV.x - 0.5) * size.x * camera.z;
329
+ float y = camera.y + (0.5 - vUV.y) * size.y * camera.w;
330
+
331
+ // Domain: pixels outside are not drawn (x<0 for log(x), x<0 for sqrt(x), |x|>1 for
332
+ // asin, …)
333
+ if (!DOM(x)) {
334
+ fragColor = vec4(0.0);
335
+ return;
336
+ }
337
+
338
+ float spany = size.y * camera.w; // visible y span (world units)
339
+ float dx = camera.z; // world x per pixel
340
+ float sx = 1.0 / camera.z; // pixels per world x
341
+ float sy = 1.0 / camera.w; // pixels per world y
342
+
343
+ float f0 = F(x);
344
+ float d1 = DF;
345
+
346
+ // ---- 1) sampling + polyline stroke ----
347
+ // Desmos' approach (engineering.desmos.com): join the sample points into **segments**
348
+ // and stroke by segment. The width stays constant everywhere, whereas "distance to the
349
+ // tangent" overestimates on steep/strongly curved parts and makes the line thin out and
350
+ // eventually vanish (log(x) as x→0+, steep parts of sin(1/x)).
351
+ // Sampling half-step: 8 samples span ±1 column. The stroke needs a window that wide —
352
+ // a pixel's perpendicular foot on a sloped line is displaced along x by m·d/√(1+m²)
353
+ // (up to ~1 px), so with a narrower polyline the foot falls outside the segments and the
354
+ // distance degrades to "distance to the nearest segment endpoint": the coverage collapses
355
+ // and the stroke looks thin and ragged on steep parts (measured before the fix: 58 % of
356
+ // the nominal width at 84°, 85 % at 63°). A wider window (±2 columns) fixes the width
357
+ // fully but over-triggers the band criteria, which are calibrated for this one.
358
+ float h = dx;
359
+ float nl = 1e30;
360
+ float nh = -1e30;
361
+ // 8 points: the **even slots** of a 16-point grid (±1 column, 0.125-column spacing).
362
+ // Ordinary pixels evaluate only these 8; banded columns add the odd slots to make 16
363
+ // and count turns there (see below).
364
+ float smp[8];
365
+ float smp_x[8];
366
+ bool smp_ok[8];
367
+ for (int i = 0; i < 8; i++) {
368
+ float t = -1.0 + (float(i) + 0.25) / 4.0; // even slot of the 16-point grid
369
+ float xi = x + t * h;
370
+ float s = F(xi);
371
+ // Skip out-of-domain/NaN/inf (pole) samples: they join neither the envelope nor the
372
+ // segments. DOM is included because GLSL's log(negative) etc. are undefined and
373
+ // some drivers return finite garbage.
374
+ bool ok = DOMS(xi) && (s == s) && (abs(s) < 1e30);
375
+ smp[i] = s;
376
+ smp_x[i] = xi;
377
+ smp_ok[i] = ok;
378
+ if (ok) {
379
+ nl = min(nl, s);
380
+ nh = max(nh, s);
381
+ }
382
+ }
383
+
384
+ // ---- 2) band decision (cheap: reuses the samples above) ----
385
+ float w = 0.0; // band weight: 0 = stroke only, 1 = solid envelope band
386
+ bool gap = false; // this column straddles a pole -> draw nothing at all
387
+ if (abs(d1) * dx * sy > 1.0) {
388
+ float spread = (nh - nl) * sy; // measured spread (px, narrow window)
389
+ float hp = 0.5 * dx;
390
+ // A true jump: this column crosses a pole (denominator sign change) and the values
391
+ // far exceed the viewport => the +∞/-∞ connector is not drawn (removes the vertical
392
+ // connector of 1/x, tan(x) at asymptotes). A **bounded** oscillation such as
393
+ // sin(1/x) never exceeds the viewport and is still represented by the envelope band.
394
+ // "Sample spread > 32× the viewport height" rather than a pixel threshold: pixels
395
+ // drift with zoom, world units do not.
396
+ if (POLE(x - hp) * POLE(x + hp) <= 0.0 && (nh - nl) > 32.0 * spany) {
397
+ gap = true;
398
+ } else {
399
+ // Turn counting at 16 points: fill in the odd slots of the 16-point grid,
400
+ // interleaved with the narrow window's even slots. Computed only here — this is
401
+ // where accuracy matters, and other pixels save these 8 F evaluations.
402
+ float turns16 = 0.0;
403
+ {
404
+ bool have = false;
405
+ float ps = 0.0;
406
+ float pd = 0.0;
407
+ for (int k = 0; k < 16; k++) {
408
+ float s;
409
+ bool ok;
410
+ if (k - (k / 2) * 2 == 0) { // even slot: reuse a narrow-window sample
411
+ s = smp[k / 2];
412
+ ok = smp_ok[k / 2];
413
+ } else { // odd slot: sample additionally
414
+ float xi = x + (-1.0 + (float(k) + 0.5) / 8.0) * h;
415
+ s = F(xi);
416
+ ok = DOMS(xi) && (s == s) && (abs(s) < 1e30);
417
+ }
418
+ if (ok) {
419
+ if (have) {
420
+ float d = s - ps;
421
+ if (pd * d < 0.0) { turns16 += 1.0; }
422
+ pd = d;
423
+ }
424
+ ps = s;
425
+ have = true;
426
+ }
427
+ }
428
+ }
429
+ float pred = max(abs(d1) * dx * sy, 2.0); // derivative-predicted spread (pixels)
430
+ w = step(2.0, turns16); // a) >= 2 turns in the column (16 points)
431
+ // d) The threshold 16 is measured: a monotone concave curve such as log gives a
432
+ // ratio ~3.6, branches of tan/1/x <1, and a truly sub-pixel sin(1/x) ~58;
433
+ // pred >= 4px excludes "resolvable extrema" (at a peak pred≈0, spread≈0, and
434
+ // the ratio would misjudge).
435
+ w = max(w, step(16.0 * spread + 1e-6, pred) * step(4.0, pred));
436
+ w *= smoothstep(1.5, 4.0, spread); // no point filling a band < 1.5 pixels
437
+ }
438
+ }
439
+
440
+ // ---- 3) stroke + band, each paid for only when it survives ----
441
+ // A fully banded column (w >= 1) discards the stroke anyway (mixing with w = 1 keeps
442
+ // only the band), a stroke-only column (w == 0) never needs the wide window, and a pole
443
+ // gap draws nothing. So the per-pixel cost is 1 + 8 F evaluations in the common case,
444
+ // 1 + 8 + 8 when the turn count is needed, and 1 + 8 + 8 + 16 for a solid band.
445
+ float cov = 0.0;
446
+ if (!gap) {
447
+ if (w < 1.0) {
448
+ // Stroke: distance to the polyline of sampled segments (Desmos' approach,
449
+ // engineering.desmos.com). Constant width everywhere, whereas "distance to the
450
+ // tangent" overestimates on steep/strongly curved parts and makes the line thin
451
+ // out and eventually vanish (log(x) as x→0+, steep parts of sin(1/x)).
452
+ vec2 p = vec2(x * sx, y * sy); // screen space (pan does not affect distance)
453
+ float dmin = 1e30;
454
+ float prev_xi = 0.0;
455
+ float prev_si = 0.0;
456
+ bool prev_oki = false;
457
+ for (int i = 0; i < 8; i++) {
458
+ if (smp_ok[i]) {
459
+ if (prev_oki) {
460
+ dmin = min(dmin, _seg_dist(p, vec2(prev_xi * sx, prev_si * sy),
461
+ vec2(smp_x[i] * sx, smp[i] * sy)));
462
+ }
463
+ prev_xi = smp_x[i];
464
+ prev_si = smp[i];
465
+ }
466
+ prev_oki = smp_ok[i];
467
+ }
468
+ // Infinite extension at unbounded boundaries: when the window crosses a domain
469
+ // boundary where the function tends to ±∞, pull a vertical ray from the nearest
470
+ // valid sample along the boundary to ±1e12, so a slowly diverging branch
471
+ // (log(x) as x→0+) is drawn to any depth. The ray lies on the boundary x, so the
472
+ // width stays constant and the columns align.
473
+ float ex_ = _edge_hit(x - h, x + h);
474
+ if (ex_ < 1e29 && nl < 1e29) {
475
+ vec2 du_ = _edge_du(ex_);
476
+ if (du_.x != 0.0) {
477
+ dmin = min(dmin, _seg_dist(p, vec2(ex_ * sx, nl * sy), vec2(ex_ * sx, -1e12)));
478
+ }
479
+ if (du_.y != 0.0) {
480
+ dmin = min(dmin, _seg_dist(p, vec2(ex_ * sx, nh * sy), vec2(ex_ * sx, 1e12)));
481
+ }
482
+ }
483
+ cov = clamp(lineWidth * 0.5 + 0.5 - dmin, 0.0, 1.0);
484
+ }
485
+ if (w > 0.0) {
486
+ // The wide window (±8 columns, 16 points) only estimates the band's top/bottom
487
+ // edges: a wider window is more likely to hit the extrema, so the edges do not
488
+ // develop dark seams/steps from a too-narrow envelope.
489
+ float wl = 1e30;
490
+ float wh = -1e30;
491
+ for (int i = 0; i < 16; i++) {
492
+ float t = -8.0 + 16.0 * (float(i) + 0.5) / 16.0; // ±8 columns
493
+ float s = F(x + t * h);
494
+ if (s == s && abs(s) < 1e30) {
495
+ wl = min(wl, s);
496
+ wh = max(wh, s);
497
+ }
498
+ }
499
+ float band = clamp((wh - y) * sy + 2.0, 0.0, 1.0)
500
+ * clamp((y - wl) * sy + 2.0, 0.0, 1.0);
501
+ // A banded column is replaced wholesale by the envelope band: the polyline is
502
+ // random aliasing there, while the band is the true value range. w = 0 columns
503
+ // keep the polyline stroke (constant width).
504
+ cov = mix(cov, band, w);
505
+ }
506
+ }
507
+
508
+ float a = cov * color.a * qt_Opacity;
509
+ if (isnan(f0) || isinf(f0)) {
510
+ a = 0.0; // discard points like sin(1/0), 1/0 outright
511
+ }
512
+ fragColor = vec4(color.rgb * a, a); // premultiplied alpha
513
+ }
514
+ """
515
+
516
+
517
+ def shader_sources(expr: sp.Expr) -> tuple[str, str]:
518
+ """Vertex/fragment shader sources for the QML front end (implicit model)."""
519
+ frag = (
520
+ FRAGMENT_TEMPLATE.replace("@FUNC@", func_glsl(expr, "u", wrap=True))
521
+ .replace("@DFUNC@", dfunc_glsl(expr))
522
+ )
523
+ edge_hit, edge_du = _edge_glsl(expr)
524
+ frag = frag.replace("@EDGEHIT@", edge_hit).replace("@EDGEDU@", edge_du)
525
+ # Domain / pole: the macro argument is parenthesized, same reasoning as F (macros are
526
+ # text substitution)
527
+ dom = domain_glsl(expr, "u")
528
+ frag = frag.replace(
529
+ "@DOM@",
530
+ f"#define DOM(u) ({dom})" if dom else "#define DOM(u) true",
531
+ )
532
+ frag = frag.replace(
533
+ "@DOMS@",
534
+ "#define DOMS(u) true" if _domain_is_pole_only(expr) else "#define DOMS(u) DOM(u)",
535
+ )
536
+ pole = pole_glsl(expr, "u")
537
+ frag = frag.replace(
538
+ "@POLE@",
539
+ f"#define POLE(u) ({pole})" if pole else "#define POLE(u) 1.0",
540
+ )
541
+ return (VERTEX_SHADER, frag)