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.
- qmlmathplot/__init__.py +84 -0
- qmlmathplot/app.py +79 -0
- qmlmathplot/camera.py +366 -0
- qmlmathplot/curve.py +296 -0
- qmlmathplot/model.py +541 -0
- qmlmathplot/plot.py +213 -0
- qmlmathplot/qml/PlotView.qml +247 -0
- qmlmathplot/qsb.py +96 -0
- qmlmathplot/themes/LICENSE.matplotlib +99 -0
- qmlmathplot/themes/README.md +15 -0
- qmlmathplot/themes/Solarize_Light2.mplstyle +53 -0
- qmlmathplot/themes/bmh.mplstyle +27 -0
- qmlmathplot/themes/classic.mplstyle +491 -0
- qmlmathplot/themes/dark_background.mplstyle +26 -0
- qmlmathplot/themes/fast.mplstyle +11 -0
- qmlmathplot/themes/fivethirtyeight.mplstyle +37 -0
- qmlmathplot/themes/ggplot.mplstyle +39 -0
- qmlmathplot/themes/grayscale.mplstyle +29 -0
- qmlmathplot/themes/petroff10.mplstyle +5 -0
- qmlmathplot/themes/seaborn-v0_8-bright.mplstyle +3 -0
- qmlmathplot/themes/seaborn-v0_8-colorblind.mplstyle +3 -0
- qmlmathplot/themes/seaborn-v0_8-dark-palette.mplstyle +3 -0
- qmlmathplot/themes/seaborn-v0_8-dark.mplstyle +30 -0
- qmlmathplot/themes/seaborn-v0_8-darkgrid.mplstyle +30 -0
- qmlmathplot/themes/seaborn-v0_8-deep.mplstyle +3 -0
- qmlmathplot/themes/seaborn-v0_8-muted.mplstyle +3 -0
- qmlmathplot/themes/seaborn-v0_8-notebook.mplstyle +21 -0
- qmlmathplot/themes/seaborn-v0_8-paper.mplstyle +21 -0
- qmlmathplot/themes/seaborn-v0_8-pastel.mplstyle +3 -0
- qmlmathplot/themes/seaborn-v0_8-poster.mplstyle +21 -0
- qmlmathplot/themes/seaborn-v0_8-talk.mplstyle +21 -0
- qmlmathplot/themes/seaborn-v0_8-ticks.mplstyle +30 -0
- qmlmathplot/themes/seaborn-v0_8-white.mplstyle +30 -0
- qmlmathplot/themes/seaborn-v0_8-whitegrid.mplstyle +30 -0
- qmlmathplot/themes/seaborn-v0_8.mplstyle +57 -0
- qmlmathplot/themes/tableau-colorblind10.mplstyle +3 -0
- qmlmathplot/themes.py +236 -0
- qmlmathplot/view.py +55 -0
- qmlmathplot/widget.py +183 -0
- qmlmathplot-0.1.0.dist-info/METADATA +383 -0
- qmlmathplot-0.1.0.dist-info/RECORD +43 -0
- qmlmathplot-0.1.0.dist-info/WHEEL +4 -0
- 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)
|