qmlmathplot 0.4.4__py3-none-any.whl → 0.5.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/curve.py +24 -8
- qmlmathplot/ggb_compute.py +55 -0
- qmlmathplot/ggb_path.py +354 -0
- qmlmathplot/ggb_reference.py +77 -0
- qmlmathplot/interval.py +80 -329
- qmlmathplot/model.py +74 -1187
- qmlmathplot/pathcurve.py +167 -0
- qmlmathplot/qml/CpuCurves.qml +23 -0
- qmlmathplot/qml/PlotCurves.qml +21 -19
- qmlmathplot/qml/PlotView.qml +3 -1
- qmlmathplot/qsb.py +9 -4
- qmlmathplot/rhi.py +389 -0
- qmlmathplot/shaderbuild.py +52 -0
- qmlmathplot/shaders/__init__.py +1 -0
- qmlmathplot/shaders/common/arg_reduction.glsl +6 -0
- qmlmathplot/shaders/common/real_pow.glsl +4 -0
- qmlmathplot/shaders/common/seg_dist.glsl +7 -0
- qmlmathplot/shaders/common/span_cov.glsl +5 -0
- qmlmathplot/shaders/common/uniforms.glsl +8 -0
- qmlmathplot/shaders/fullscreen.vert +12 -0
- qmlmathplot/shaders/implicit.frag +283 -0
- qmlmathplot/shaders/interval_lib.glsl +328 -0
- qmlmathplot/shaders/interval_path.comp +332 -0
- qmlmathplot/shaders/oned_sampled.frag +490 -0
- qmlmathplot/shaders/path.frag +4 -0
- qmlmathplot/shaders/path.vert +8 -0
- qmlmathplot/view.py +4 -0
- {qmlmathplot-0.4.4.dist-info → qmlmathplot-0.5.0.dist-info}/METADATA +74 -64
- {qmlmathplot-0.4.4.dist-info → qmlmathplot-0.5.0.dist-info}/RECORD +33 -11
- qmlmathplot-0.5.0.dist-info/licenses/LICENSE +674 -0
- qmlmathplot-0.5.0.dist-info/licenses/LICENSES/EUPL-1.2.txt +287 -0
- {qmlmathplot-0.4.4.dist-info → qmlmathplot-0.5.0.dist-info}/WHEEL +0 -0
- {qmlmathplot-0.4.4.dist-info → qmlmathplot-0.5.0.dist-info}/entry_points.txt +0 -0
qmlmathplot/curve.py
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
A curve knows nothing about the view: the camera decides where we look, the curve decides what is
|
|
4
4
|
drawn. One curve = one fragment shader pass, so ``visible`` is the lever that costs nothing.
|
|
5
5
|
|
|
6
|
-
Two artists share the class: ``Curve`` draws ``y = f(x)`` (see ``
|
|
7
|
-
``ImplicitCurve`` draws the level set ``F(x, y) = 0`` (``
|
|
6
|
+
Two artists share the class: ``Curve`` draws ``y = f(x)`` (see ``shaders/oned_sampled.frag``) and
|
|
7
|
+
``ImplicitCurve`` draws the level set ``F(x, y) = 0`` (``shaders/implicit.frag``).
|
|
8
8
|
They differ only in the shader *sources* — the uniform block, the model roles and the QML side
|
|
9
9
|
are the same — so ``curve_for()`` can pick one from the expression's free symbols.
|
|
10
10
|
"""
|
|
@@ -27,6 +27,7 @@ from PySide6.QtCore import (
|
|
|
27
27
|
)
|
|
28
28
|
from PySide6.QtGui import QColor
|
|
29
29
|
|
|
30
|
+
from .interval import compute_supported
|
|
30
31
|
from .model import implicit_shader_sources, shader_sources
|
|
31
32
|
from .qsb import bake
|
|
32
33
|
|
|
@@ -310,6 +311,8 @@ class Curve(QObject):
|
|
|
310
311
|
def _get_fragment_shader(self) -> QUrl:
|
|
311
312
|
return self._fragment
|
|
312
313
|
|
|
314
|
+
#: Empty when the compute renderer draws this expression: the RHI generates its path
|
|
315
|
+
#: from `expr`. Only the sampled fallback and implicit curves bake these shaders.
|
|
313
316
|
vertexShader: QUrl = Property(QUrl, _get_vertex_shader, notify=shadersChanged)
|
|
314
317
|
fragmentShader: QUrl = Property(QUrl, _get_fragment_shader, notify=shadersChanged)
|
|
315
318
|
|
|
@@ -340,8 +343,13 @@ class Curve(QObject):
|
|
|
340
343
|
"first"
|
|
341
344
|
)
|
|
342
345
|
implicit = self._force_implicit if force_implicit is None else force_implicit
|
|
343
|
-
|
|
344
|
-
|
|
346
|
+
if implicit or "y" in names:
|
|
347
|
+
return implicit_shader_sources(expr)
|
|
348
|
+
if compute_supported(expr):
|
|
349
|
+
# The compute renderer generates its shader from `expr`; baking the sampled
|
|
350
|
+
# fallback here would be dead work (qsb + the edge analysis, ~130 ms/curve).
|
|
351
|
+
return "", ""
|
|
352
|
+
return shader_sources(expr)
|
|
345
353
|
|
|
346
354
|
def _start_bake(
|
|
347
355
|
self,
|
|
@@ -364,6 +372,11 @@ class Curve(QObject):
|
|
|
364
372
|
self._error = f"{type(exc).__name__}: {exc}"
|
|
365
373
|
self.errorChanged.emit()
|
|
366
374
|
return
|
|
375
|
+
if not vertex_source and not fragment_source:
|
|
376
|
+
self._pending_expr = expr
|
|
377
|
+
self._pending_implicit = force_implicit
|
|
378
|
+
self._on_bake_done(generation, (None, None))
|
|
379
|
+
return
|
|
367
380
|
self._pending_expr = expr
|
|
368
381
|
self._pending_implicit = force_implicit
|
|
369
382
|
if synchronous or not self.bake_async or QCoreApplication.instance() is None:
|
|
@@ -397,13 +410,16 @@ class Curve(QObject):
|
|
|
397
410
|
return
|
|
398
411
|
if expr is None:
|
|
399
412
|
return
|
|
400
|
-
|
|
413
|
+
if result == (None, None):
|
|
414
|
+
vertex = fragment = None
|
|
415
|
+
else:
|
|
416
|
+
vertex, fragment = result # type: ignore[misc]
|
|
401
417
|
self._expr = expr
|
|
402
418
|
if implicit is not None:
|
|
403
419
|
self._force_implicit = bool(implicit)
|
|
404
420
|
self._error = ""
|
|
405
|
-
self._vertex = QUrl.fromLocalFile(str(vertex))
|
|
406
|
-
self._fragment = QUrl.fromLocalFile(str(fragment))
|
|
421
|
+
self._vertex = QUrl() if vertex is None else QUrl.fromLocalFile(str(vertex))
|
|
422
|
+
self._fragment = QUrl() if fragment is None else QUrl.fromLocalFile(str(fragment))
|
|
407
423
|
self.exprChanged.emit()
|
|
408
424
|
self.errorChanged.emit()
|
|
409
425
|
self.shadersChanged.emit()
|
|
@@ -422,7 +438,7 @@ class ImplicitCurve(Curve):
|
|
|
422
438
|
|
|
423
439
|
|
|
424
440
|
class CurveListModel(QAbstractListModel):
|
|
425
|
-
"""The plot's curves, as a model
|
|
441
|
+
"""The plot's curves, as a model the QRhi renderer and QML chrome read."""
|
|
426
442
|
|
|
427
443
|
CurveRole = int(Qt.ItemDataRole.UserRole) + 1
|
|
428
444
|
ExpressionRole = CurveRole + 1
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2007-2026 GeoGebra GmbH
|
|
2
|
+
# SPDX-License-Identifier: GPL-3.0-only
|
|
3
|
+
# Portions derived from GeoGebra (EUPL-1.2); see THIRD_PARTY_NOTICES.md.
|
|
4
|
+
"""GLSL generation for the sequential GPU port of GGB's interval path.
|
|
5
|
+
|
|
6
|
+
One compute invocation walks the screen columns in order, evaluates ``FI`` (the interval of
|
|
7
|
+
the function on the column), and runs the same state machine as
|
|
8
|
+
:mod:`qmlmathplot.ggb_path`: ``IntervalPath`` (break/join), ``DrawInterval``
|
|
9
|
+
(joined/independent/whole), ``DrawInvertedInterval`` (gap rays) and ``JoinLines``
|
|
10
|
+
(neighbour-aware completion). The emitted segments are written into a storage buffer that a
|
|
11
|
+
tiny vertex/fragment pair draws; no CPU sampling is involved.
|
|
12
|
+
|
|
13
|
+
The neighbour state is taken from the previously evaluated column and a one-column prefetch,
|
|
14
|
+
so the whole pass pays one ``FI`` per column plus one. No scalar ``F`` evaluations are used:
|
|
15
|
+
the path is the interval's own endpoints, which is what GGB draws.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import sympy as sp
|
|
21
|
+
|
|
22
|
+
from .shaderbuild import render, shader_source
|
|
23
|
+
from .interval import has_sign_pole, interval_glsl, interval_lib_for
|
|
24
|
+
from .model import _domain_is_pole_only, _non_negative, _pole_glsl, _pole_spacing
|
|
25
|
+
|
|
26
|
+
__all__ = ["PATH_VERTEX", "PATH_FRAGMENT", "path_compute_source", "path_subsamples"]
|
|
27
|
+
|
|
28
|
+
PATH_VERTEX = shader_source("path.vert")
|
|
29
|
+
|
|
30
|
+
PATH_FRAGMENT = shader_source("path.frag")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def path_subsamples(expression: str | sp.Basic) -> int:
|
|
34
|
+
# GGB FunctionSampler: one interval per screen column.
|
|
35
|
+
return 1
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def path_compute_source(expression: str | sp.Basic) -> str:
|
|
41
|
+
expr = expression if isinstance(expression, sp.Basic) else sp.sympify(expression)
|
|
42
|
+
pole = has_sign_pole(expr)
|
|
43
|
+
intervals = interval_glsl(expr)
|
|
44
|
+
return render(
|
|
45
|
+
"interval_path.comp",
|
|
46
|
+
SUBSAMPLES=path_subsamples(expression),
|
|
47
|
+
POLE=_pole_glsl(expr, "u") if pole else "1.0",
|
|
48
|
+
HAS_POLE="1" if pole else "0",
|
|
49
|
+
POLE_SPACING=f"{_pole_spacing(expr):.17g}",
|
|
50
|
+
UNIFORM_FILL="1" if (
|
|
51
|
+
pole and _non_negative(expr) and _domain_is_pole_only(expr)
|
|
52
|
+
) else "0",
|
|
53
|
+
INTERVAL_LIB=interval_lib_for(intervals),
|
|
54
|
+
INTERVALS=intervals,
|
|
55
|
+
)
|
qmlmathplot/ggb_path.py
ADDED
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2007-2026 GeoGebra GmbH
|
|
2
|
+
# SPDX-License-Identifier: GPL-3.0-only
|
|
3
|
+
# Portions derived from GeoGebra (EUPL-1.2); see THIRD_PARTY_NOTICES.md.
|
|
4
|
+
"""GeoGebra's interval path as a state machine over column topologies.
|
|
5
|
+
|
|
6
|
+
This is the semantic core of ``org.geogebra.common.euclidian.plot.interval``:
|
|
7
|
+
``IntervalPath`` (break/join state), ``DrawInterval`` (joined/independent/whole),
|
|
8
|
+
``DrawInvertedInterval`` (gap rays) and ``JoinLines`` (neighbour-aware completion of the
|
|
9
|
+
inverted and infinite cases). It contains **no interval arithmetic and no sampling**: the
|
|
10
|
+
input is one y-topology per screen column (empty / connected / inverted / whole), the output
|
|
11
|
+
is screen-space segments.
|
|
12
|
+
|
|
13
|
+
Keeping the decisions here rather than inline in the GLSL gives the GPU path a host-testable
|
|
14
|
+
specification: synthetic topologies can exercise every branch without a GPU and without a
|
|
15
|
+
second interval evaluator. ``ggb_reference`` feeds it from ``mpmath.iv``; ``ggb_compute``
|
|
16
|
+
mirrors it in the compute shader, consuming the float32 ``INTERVAL_LIB`` topology.
|
|
17
|
+
|
|
18
|
+
Coordinates are screen pixels (y down), matching GGB's path. The y interval is stored in
|
|
19
|
+
world units; the conversions happen at the edges.
|
|
20
|
+
|
|
21
|
+
Both ``JoinLines`` comparisons are ported literally, because GGB's own plotter output
|
|
22
|
+
shows they matter: the top-edge equality is a **tolerance** (``MAX_PRECISION = 1e-12`` in
|
|
23
|
+
double; a float-safe relative tolerance on the GPU), and ``toBottom`` compares differences
|
|
24
|
+
**after** ``toScreenCoordYd``, which reverses their order. ``VERTICAL_MARGIN`` is not
|
|
25
|
+
reproduced; the GPU clips the segment to the viewport anyway.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import math
|
|
31
|
+
from dataclasses import dataclass
|
|
32
|
+
from typing import Literal
|
|
33
|
+
|
|
34
|
+
__all__ = ["IVal", "Column", "PathPoint", "IntervalPath", "IINF"]
|
|
35
|
+
|
|
36
|
+
Kind = Literal["empty", "connected", "inverted", "whole"]
|
|
37
|
+
|
|
38
|
+
#: The GLSL library's infinity sentinel (``IINF``); real infinities are avoided on the GPU.
|
|
39
|
+
IINF = 1e30
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class IVal:
|
|
44
|
+
"""A conservative y value: a connected range, a gap, the whole line, or empty."""
|
|
45
|
+
|
|
46
|
+
lo: float
|
|
47
|
+
hi: float
|
|
48
|
+
kind: Kind = "connected"
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def empty(self) -> bool:
|
|
52
|
+
return self.kind == "empty"
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def whole(self) -> bool:
|
|
56
|
+
return self.kind == "whole"
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def inverted(self) -> bool:
|
|
60
|
+
return self.kind == "inverted"
|
|
61
|
+
|
|
62
|
+
def has_infinity(self) -> bool:
|
|
63
|
+
if self.kind == "empty":
|
|
64
|
+
return False
|
|
65
|
+
if self.kind == "whole":
|
|
66
|
+
return True
|
|
67
|
+
return self.lo <= -IINF or self.hi >= IINF
|
|
68
|
+
|
|
69
|
+
# Payload edges: an inverted value stores the excluded gap, a whole value is unbounded.
|
|
70
|
+
@property
|
|
71
|
+
def payload_lo(self) -> float:
|
|
72
|
+
if self.kind == "whole":
|
|
73
|
+
return -IINF
|
|
74
|
+
return self.lo
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def payload_hi(self) -> float:
|
|
78
|
+
if self.kind == "whole":
|
|
79
|
+
return IINF
|
|
80
|
+
return self.hi
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True)
|
|
84
|
+
class Column:
|
|
85
|
+
x_lo: float
|
|
86
|
+
x_hi: float
|
|
87
|
+
y: IVal
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass
|
|
91
|
+
class PathPoint:
|
|
92
|
+
x: float
|
|
93
|
+
y: float
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _near(a: float, b: float, fraction: float = 1e-9) -> bool:
|
|
97
|
+
"""``DoubleUtil.isEqual(0, |a-b|, MAX_PRECISION)`` with a scale-aware epsilon.
|
|
98
|
+
|
|
99
|
+
The double version uses 1e-12; the GPU needs a larger relative epsilon for float32.
|
|
100
|
+
"""
|
|
101
|
+
return abs(a - b) <= fraction * max(1.0, abs(a), abs(b))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class IntervalPath:
|
|
105
|
+
"""Turns per-column topologies into a path, the way GGB's ``IntervalPath`` does.
|
|
106
|
+
|
|
107
|
+
``columns`` must be in screen-column order. The x interval of a column is its world
|
|
108
|
+
span; ``width``/``height`` are the view's pixel size and the ``x_*``/``y_*`` arguments
|
|
109
|
+
are its world bounds.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
def __init__(
|
|
113
|
+
self,
|
|
114
|
+
columns: list[Column],
|
|
115
|
+
width: float,
|
|
116
|
+
height: float,
|
|
117
|
+
x_lo: float,
|
|
118
|
+
x_hi: float,
|
|
119
|
+
y_lo: float,
|
|
120
|
+
y_hi: float,
|
|
121
|
+
) -> None:
|
|
122
|
+
self.columns = columns
|
|
123
|
+
self.width = float(width)
|
|
124
|
+
self.height = float(height)
|
|
125
|
+
self.x_lo = float(x_lo)
|
|
126
|
+
self.x_hi = float(x_hi)
|
|
127
|
+
self.y_lo = float(y_lo)
|
|
128
|
+
self.y_hi = float(y_hi)
|
|
129
|
+
self.segments: list[tuple[PathPoint, PathPoint]] = []
|
|
130
|
+
self._point: PathPoint | None = None
|
|
131
|
+
self._last: IVal | None = None
|
|
132
|
+
|
|
133
|
+
# ------------------------------------------------------------------ driver
|
|
134
|
+
def update(self) -> list[tuple[PathPoint, PathPoint]]:
|
|
135
|
+
"""Reset and rebuild the path; returns screen-space segments."""
|
|
136
|
+
del self.segments[:]
|
|
137
|
+
self._point = None
|
|
138
|
+
self._last = None
|
|
139
|
+
for index in range(len(self.columns)):
|
|
140
|
+
self._draw_at(index)
|
|
141
|
+
return list(self.segments)
|
|
142
|
+
|
|
143
|
+
def _draw_at(self, index: int) -> None:
|
|
144
|
+
column = self.columns[index]
|
|
145
|
+
y = column.y
|
|
146
|
+
if y.empty:
|
|
147
|
+
self._last = None
|
|
148
|
+
return
|
|
149
|
+
if self._last is None:
|
|
150
|
+
self._draw_independent(index)
|
|
151
|
+
elif y.whole:
|
|
152
|
+
self._draw_whole(column)
|
|
153
|
+
elif y.inverted:
|
|
154
|
+
self._draw_inverted(index)
|
|
155
|
+
elif y.has_infinity():
|
|
156
|
+
self._draw_normal_infinity(column)
|
|
157
|
+
else:
|
|
158
|
+
self._draw_joined(column)
|
|
159
|
+
|
|
160
|
+
# ------------------------------------------------------- DrawInterval
|
|
161
|
+
def _draw_joined(self, column: Column) -> None:
|
|
162
|
+
"""``DrawInterval.drawUp``/``drawDown``: connect to the previous column edge."""
|
|
163
|
+
assert self._last is not None
|
|
164
|
+
y = column.y
|
|
165
|
+
# GGB compares the *screen* high edges; screen high is the world low edge.
|
|
166
|
+
up = y.lo < self._last.lo
|
|
167
|
+
if up:
|
|
168
|
+
first = (self._sx(column.x_lo), self._sy(y.hi))
|
|
169
|
+
second = (self._sx(column.x_hi), self._sy(y.lo))
|
|
170
|
+
else:
|
|
171
|
+
first = (self._sx(column.x_lo), self._sy(y.lo))
|
|
172
|
+
second = (self._sx(column.x_hi), self._sy(y.hi))
|
|
173
|
+
self._line_to(*first)
|
|
174
|
+
self._line_to(*second)
|
|
175
|
+
self._last = y
|
|
176
|
+
|
|
177
|
+
def _draw_independent(self, index: int) -> None:
|
|
178
|
+
"""``IntervalPath.drawTupleIndependent``."""
|
|
179
|
+
column = self.columns[index]
|
|
180
|
+
y = column.y
|
|
181
|
+
if y.whole:
|
|
182
|
+
self._draw_whole(column)
|
|
183
|
+
return
|
|
184
|
+
if y.inverted:
|
|
185
|
+
self._draw_inverted(index)
|
|
186
|
+
return
|
|
187
|
+
# DrawInterval.drawIndependent -> line(x, y): a vertical at the right edge. Infinities
|
|
188
|
+
# are clamped by ``_sy``; the segment runs off screen and is clipped.
|
|
189
|
+
self._segment(
|
|
190
|
+
(self._sx(column.x_hi), self._sy(y.hi)),
|
|
191
|
+
(self._sx(column.x_hi), self._sy(y.lo)),
|
|
192
|
+
)
|
|
193
|
+
self._last = y
|
|
194
|
+
|
|
195
|
+
def _draw_whole(self, column: Column) -> None:
|
|
196
|
+
"""``DrawInterval.drawWhole``: one vertical at x.low, then a path break."""
|
|
197
|
+
x = self._sx(column.x_lo)
|
|
198
|
+
self._segment((x, 0.0), (x, self.height))
|
|
199
|
+
self._last = None
|
|
200
|
+
|
|
201
|
+
# ------------------------------------------------ DrawNormalInfinity
|
|
202
|
+
def _draw_normal_infinity(self, column: Column) -> None:
|
|
203
|
+
"""``IntervalPath.drawNormalInfinity`` / ``IntervalPathPlotter.leftToTop``."""
|
|
204
|
+
y = column.y
|
|
205
|
+
if y.lo <= -IINF and y.hi >= IINF:
|
|
206
|
+
self._last = None
|
|
207
|
+
return
|
|
208
|
+
if y.hi >= IINF:
|
|
209
|
+
if self.y_lo <= y.lo <= self.y_hi:
|
|
210
|
+
self._segment(
|
|
211
|
+
(self._sx(column.x_lo), self._sy(y.lo)),
|
|
212
|
+
(self._sx((column.x_lo + column.x_hi) * 0.5), 0.0),
|
|
213
|
+
)
|
|
214
|
+
# ``lastY = zero()`` in screen space: the path continues from the top edge.
|
|
215
|
+
self._last = IVal(self.y_hi, self.y_hi, "connected")
|
|
216
|
+
else:
|
|
217
|
+
self._last = None
|
|
218
|
+
return
|
|
219
|
+
if self.y_lo <= y.hi <= self.y_hi:
|
|
220
|
+
self._segment(
|
|
221
|
+
(self._sx(column.x_lo), self._sy(y.hi)),
|
|
222
|
+
(self._sx((column.x_lo + column.x_hi) * 0.5), self.height),
|
|
223
|
+
)
|
|
224
|
+
self._last = IVal(self.y_lo, self.y_lo, "connected")
|
|
225
|
+
else:
|
|
226
|
+
self._last = None
|
|
227
|
+
|
|
228
|
+
# ------------------------------------- DrawInvertedInterval / JoinLines
|
|
229
|
+
def _draw_inverted(self, index: int) -> None:
|
|
230
|
+
"""``DrawInvertedInterval.draw`` for both the joined and independent cases.
|
|
231
|
+
|
|
232
|
+
``drawJoined`` does not use ``lastY`` for the inverted branch; it only resets it
|
|
233
|
+
when the current topology is whole, so the two entry points coincide.
|
|
234
|
+
"""
|
|
235
|
+
column = self.columns[index]
|
|
236
|
+
right = self.columns[index + 1] if index + 1 < len(self.columns) else None
|
|
237
|
+
if right is not None and right.y.inverted:
|
|
238
|
+
self._inverted_segments(column)
|
|
239
|
+
else:
|
|
240
|
+
self._inverted_joined(index)
|
|
241
|
+
# DrawInvertedInterval.draw() clears lastYSet when the next column is not
|
|
242
|
+
# inverted, so the following connected column restarts independently. Without
|
|
243
|
+
# this the stale lastY joins the two branches across the pole.
|
|
244
|
+
self._last = None
|
|
245
|
+
|
|
246
|
+
def _inverted_segments(self, column: Column) -> None:
|
|
247
|
+
"""``DrawInvertedInterval.drawSegments``: the gap is excluded, so two rays."""
|
|
248
|
+
gap = column.y
|
|
249
|
+
if gap.hi > self.y_lo:
|
|
250
|
+
self._segment(
|
|
251
|
+
(self._sx(column.x_lo), self.height), # world y_min
|
|
252
|
+
(self._sx(column.x_hi), self._sy(gap.lo)),
|
|
253
|
+
)
|
|
254
|
+
if gap.lo < self.y_hi:
|
|
255
|
+
self._segment(
|
|
256
|
+
(self._sx(column.x_hi), 0.0), # world y_max
|
|
257
|
+
(self._sx(column.x_hi), self._sy(gap.hi)),
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
def _inverted_joined(self, index: int) -> None:
|
|
261
|
+
"""``JoinLines.inverted`` with the current/left/right tuples."""
|
|
262
|
+
column = self.columns[index]
|
|
263
|
+
gap = column.y
|
|
264
|
+
left = self.columns[index - 1] if index > 0 else None
|
|
265
|
+
right = self.columns[index + 1] if index + 1 < len(self.columns) else None
|
|
266
|
+
left_y = left.y if left is not None else None
|
|
267
|
+
right_y = right.y if right is not None else None
|
|
268
|
+
|
|
269
|
+
if gap.hi < IINF:
|
|
270
|
+
if not self._infinite_boundary(left_y):
|
|
271
|
+
self._to_top(column, left, right)
|
|
272
|
+
if gap.lo > -IINF:
|
|
273
|
+
if self._infinite_boundary(right_y):
|
|
274
|
+
y1 = left_y.lo if left_y is not None and not left_y.empty else gap.lo
|
|
275
|
+
x = self._sx(column.x_lo)
|
|
276
|
+
self._segment((x, self._sy(y1)), (x, self.height))
|
|
277
|
+
else:
|
|
278
|
+
self._to_bottom(column, left, right)
|
|
279
|
+
|
|
280
|
+
@staticmethod
|
|
281
|
+
def _infinite_boundary(y: IVal | None) -> bool:
|
|
282
|
+
return y is not None and not y.empty and y.has_infinity()
|
|
283
|
+
|
|
284
|
+
def _to_top(self, column: Column, left: Column | None, right: Column | None) -> None:
|
|
285
|
+
current = column.y.payload_hi
|
|
286
|
+
left_diff = abs(current - left.y.payload_hi) if left is not None else float("inf")
|
|
287
|
+
right_diff = abs(current - right.y.payload_hi) if right is not None else float("inf")
|
|
288
|
+
# GGB reads ``left/rightXHigh`` without a null check; at a view edge one neighbour is
|
|
289
|
+
# missing, so fall back to this column's own edges (the segment is clipped anyway).
|
|
290
|
+
left_x = self._sx(left.x_hi) if left is not None else self._sx(column.x_lo)
|
|
291
|
+
right_x = self._sx(right.x_hi) if right is not None else self._sx(column.x_hi)
|
|
292
|
+
x_low = self._sx(column.x_lo)
|
|
293
|
+
x_high = self._sx(column.x_hi)
|
|
294
|
+
if left is not None and _near(left_diff, 0.0):
|
|
295
|
+
self._segment((x_low, self._sy(left.y.payload_hi)), (x_low, 0.0))
|
|
296
|
+
elif right is not None and _near(right_diff, 0.0):
|
|
297
|
+
self._segment((x_high, self._sy(right.y.payload_hi)), (x_high, 0.0))
|
|
298
|
+
elif left_diff < right_diff:
|
|
299
|
+
self._segment((right_x, 0.0), (left_x, self._sy(current)))
|
|
300
|
+
else:
|
|
301
|
+
self._segment((right_x, 0.0), (right_x, self._sy(current)))
|
|
302
|
+
|
|
303
|
+
def _screen_difference(self, value: float, neighbour: Column | None) -> float:
|
|
304
|
+
"""``JoinLines.getLeftScreenDifference``: the difference in *screen* space.
|
|
305
|
+
|
|
306
|
+
``toScreenCoordYd`` is decreasing, so this reverses the ordering of a world-space
|
|
307
|
+
comparison. GGB's own tan output depends on it; using raw world differences selected
|
|
308
|
+
the mirrored branch and drew the asymptote on the wrong side.
|
|
309
|
+
"""
|
|
310
|
+
if neighbour is None or neighbour.y.empty:
|
|
311
|
+
return -1.0
|
|
312
|
+
if neighbour.y.whole:
|
|
313
|
+
return -math.inf
|
|
314
|
+
return self._sy(value)
|
|
315
|
+
|
|
316
|
+
def _to_bottom(self, column: Column, left: Column | None, right: Column | None) -> None:
|
|
317
|
+
current = column.y.payload_lo
|
|
318
|
+
left_diff = (self._screen_difference(abs(current - left.y.payload_lo), left)
|
|
319
|
+
if left is not None else -1.0)
|
|
320
|
+
right_diff = (self._screen_difference(abs(current - right.y.payload_lo), right)
|
|
321
|
+
if right is not None else -1.0)
|
|
322
|
+
right_x = self._sx(right.x_hi) if right is not None else self._sx(column.x_hi)
|
|
323
|
+
x_low = self._sx(column.x_lo)
|
|
324
|
+
x_high = self._sx(column.x_hi)
|
|
325
|
+
if left_diff == 0.0 and left is not None:
|
|
326
|
+
self._segment((x_low, self._sy(left.y.payload_lo)), (x_low, self.height))
|
|
327
|
+
elif right_diff == 0.0 and right is not None:
|
|
328
|
+
self._segment((x_high, self._sy(right.y.payload_lo)), (x_high, self.height))
|
|
329
|
+
elif left_diff < right_diff:
|
|
330
|
+
# JoinLines.toBottomLeft: to *currentXHigh*, not currentXLow.
|
|
331
|
+
self._segment((right_x, self._sy(current)), (x_high, self.height))
|
|
332
|
+
else:
|
|
333
|
+
self._segment((x_low, self._sy(current)), (x_high, self.height))
|
|
334
|
+
|
|
335
|
+
# ------------------------------------------------------------- primitives
|
|
336
|
+
def _line_to(self, x: float, y: float) -> None:
|
|
337
|
+
if self._point is None:
|
|
338
|
+
self._point = PathPoint(x, y)
|
|
339
|
+
return
|
|
340
|
+
self.segments.append((self._point, PathPoint(x, y)))
|
|
341
|
+
self._point = PathPoint(x, y)
|
|
342
|
+
|
|
343
|
+
def _segment(self, start: tuple[float, float], end: tuple[float, float]) -> None:
|
|
344
|
+
"""``IntervalPathPlotter.segment``: starts a new subpath."""
|
|
345
|
+
self.segments.append((PathPoint(*start), PathPoint(*end)))
|
|
346
|
+
self._point = PathPoint(*end)
|
|
347
|
+
|
|
348
|
+
def _sx(self, x: float) -> float:
|
|
349
|
+
return (x - self.x_lo) / (self.x_hi - self.x_lo) * self.width
|
|
350
|
+
|
|
351
|
+
def _sy(self, y: float) -> float:
|
|
352
|
+
# Clamp before mapping: the sentinel infinities would otherwise produce inf/NaN.
|
|
353
|
+
y = max(-1e12, min(1e12, y))
|
|
354
|
+
return (self.y_hi - y) / (self.y_hi - self.y_lo) * self.height
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2007-2026 GeoGebra GmbH
|
|
2
|
+
# SPDX-License-Identifier: GPL-3.0-only
|
|
3
|
+
# Portions derived from GeoGebra (EUPL-1.2); see THIRD_PARTY_NOTICES.md.
|
|
4
|
+
"""CPU sampler for GeoGebra's interval path, using the shared state machine.
|
|
5
|
+
|
|
6
|
+
``IntervalSampler`` produces one conservative y interval per screen column with
|
|
7
|
+
``mpmath.iv`` (the CPU analogue of the GPU's ``INTERVAL_LIB``). The path decisions live in
|
|
8
|
+
:mod:`qmlmathplot.ggb_path`, which is shared with the tests and mirrored by the compute
|
|
9
|
+
shader; this module adds no geometry of its own.
|
|
10
|
+
|
|
11
|
+
``mpmath.iv`` cannot express a gap: an interval through a pole (``tan``, ``1/x``) comes back
|
|
12
|
+
as ``[-inf, +inf]``, which GGB's ``IntervalSet`` would call *inverted* with a finite gap.
|
|
13
|
+
Such a column is reported as **empty** here — a path break — because drawing it as a whole
|
|
14
|
+
column would bridge the pole. The GPU's ``FI`` does produce the inverted topology, and the
|
|
15
|
+
state machine handles it; the synthetic tests in ``tests/test_ggb_path.py`` cover that
|
|
16
|
+
branch.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import mpmath as mp
|
|
22
|
+
import sympy as sp
|
|
23
|
+
|
|
24
|
+
from .ggb_path import Column, IINF, IntervalPath, IVal, PathPoint
|
|
25
|
+
|
|
26
|
+
__all__ = ["Column", "PathPoint", "IVal", "IntervalSampler", "IntervalPathReference"]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class IntervalSampler:
|
|
30
|
+
"""One conservative y interval per screen column (GGB FunctionSampler shape)."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, expression: str | sp.Basic, x_lo: float, x_hi: float,
|
|
33
|
+
width: int) -> None:
|
|
34
|
+
x = sp.Symbol("x")
|
|
35
|
+
self._expr = expression if isinstance(expression, sp.Basic) else sp.sympify(expression)
|
|
36
|
+
self._func = sp.lambdify(x, self._expr, modules=[_iv_module()])
|
|
37
|
+
self._x_lo = x_lo
|
|
38
|
+
self._x_hi = x_hi
|
|
39
|
+
self._width = max(1, int(width))
|
|
40
|
+
|
|
41
|
+
def columns(self) -> list[Column]:
|
|
42
|
+
dx = (self._x_hi - self._x_lo) / self._width
|
|
43
|
+
out: list[Column] = []
|
|
44
|
+
for i in range(self._width):
|
|
45
|
+
lo = self._x_lo + i * dx
|
|
46
|
+
hi = lo + dx
|
|
47
|
+
out.append(Column(lo, hi, self._y_interval(lo, hi)))
|
|
48
|
+
return out
|
|
49
|
+
|
|
50
|
+
def _y_interval(self, lo: float, hi: float) -> IVal:
|
|
51
|
+
try:
|
|
52
|
+
value = self._func(mp.iv.mpf([lo, hi]))
|
|
53
|
+
a, b = float(value.a), float(value.b)
|
|
54
|
+
except (ValueError, ZeroDivisionError, OverflowError):
|
|
55
|
+
return IVal(0.0, 0.0, "empty")
|
|
56
|
+
if not (a == a and b == b) or a > b:
|
|
57
|
+
return IVal(0.0, 0.0, "empty")
|
|
58
|
+
if a <= -IINF and b >= IINF:
|
|
59
|
+
# A pole: the true image is an inverted gap, which mpmath.iv cannot express.
|
|
60
|
+
return IVal(0.0, 0.0, "empty")
|
|
61
|
+
return IVal(max(a, -IINF), min(b, IINF), "connected")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _iv_module() -> dict[str, object]:
|
|
65
|
+
return {
|
|
66
|
+
"sin": mp.iv.sin,
|
|
67
|
+
"cos": mp.iv.cos,
|
|
68
|
+
"tan": mp.iv.tan,
|
|
69
|
+
"sqrt": mp.iv.sqrt,
|
|
70
|
+
"exp": mp.iv.exp,
|
|
71
|
+
"log": mp.iv.log,
|
|
72
|
+
"Abs": abs,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
#: Backwards-compatible name: the CPU backend and its tests import this.
|
|
77
|
+
IntervalPathReference = IntervalPath
|