fix(plot): bound adaptive sampling and preserve curve discontinuities
This commit is contained in:
+75
-12
@@ -219,21 +219,72 @@ def _clip_polyline(
|
|||||||
return segments
|
return segments
|
||||||
|
|
||||||
|
|
||||||
|
_REFINE_MAX_DEPTH = 24
|
||||||
|
_REFINE_MAX_EVALUATIONS = 256
|
||||||
|
_CURVE_MAX_REFINEMENT_EVALUATIONS = 8192
|
||||||
|
|
||||||
|
|
||||||
|
def _refine_crossing(tree, left, right, ymin, ymax, budget=None):
|
||||||
|
"""Adaptively check both halves of a crossing; None explicitly breaks a path.
|
||||||
|
|
||||||
|
A visible midpoint is not a continuity proof. Accept a visible chord only
|
||||||
|
when its midpoint error is within a quarter pixel; otherwise subdivide both
|
||||||
|
halves. Depth, evaluation and floating-point limits always break unresolved
|
||||||
|
intervals instead of joining them. Entirely off-screen triples can be culled.
|
||||||
|
"""
|
||||||
|
remaining = _REFINE_MAX_EVALUATIONS
|
||||||
|
if budget is None:
|
||||||
|
budget = [_REFINE_MAX_EVALUATIONS]
|
||||||
|
tolerance = (ymax - ymin) / (_PLOT_Y1 - _PLOT_Y0) / 4
|
||||||
|
|
||||||
|
def refine(a, b, depth):
|
||||||
|
nonlocal remaining
|
||||||
|
x = a[0] + (b[0] - a[0]) / 2
|
||||||
|
if depth >= _REFINE_MAX_DEPTH or remaining == 0 or budget[0] == 0 or not a[0] < x < b[0]:
|
||||||
|
return [a, None, b]
|
||||||
|
remaining -= 1
|
||||||
|
budget[0] -= 1
|
||||||
|
try:
|
||||||
|
y = evaluate(tree, x)
|
||||||
|
except (ValueError, ZeroDivisionError, OverflowError, TypeError):
|
||||||
|
y = math.nan
|
||||||
|
if not isinstance(y, (int, float)):
|
||||||
|
y = math.nan
|
||||||
|
mid = (x, y)
|
||||||
|
values = (a[1], y, b[1])
|
||||||
|
if all(math.isfinite(v) for v in values):
|
||||||
|
if max(values) < ymin or min(values) > ymax:
|
||||||
|
return [a, None, b] # No visible chord; do not connect across it.
|
||||||
|
error = abs(y - (a[1] / 2 + b[1] / 2))
|
||||||
|
if any(ymin <= v <= ymax for v in values) and error <= tolerance:
|
||||||
|
return [a, mid, b]
|
||||||
|
# Refine either side of a nonfinite midpoint too: dropping the whole
|
||||||
|
# interval would erase valid branches between the original samples.
|
||||||
|
first = refine(a, mid, depth + 1)
|
||||||
|
second = refine(mid, b, depth + 1)
|
||||||
|
return first + second[1:]
|
||||||
|
|
||||||
|
return refine(left, right, 0)
|
||||||
|
|
||||||
|
|
||||||
def _sample_segments(
|
def _sample_segments(
|
||||||
tree: object,
|
tree: object,
|
||||||
xmin: float,
|
xmin: float,
|
||||||
xmax: float,
|
xmax: float,
|
||||||
ymin: float,
|
ymin: float,
|
||||||
ymax: float,
|
ymax: float,
|
||||||
|
warnings: list[str] | None = None,
|
||||||
) -> list[list[tuple[float, float]]]:
|
) -> list[list[tuple[float, float]]]:
|
||||||
"""采样并映射为像素点段,再裁剪到绘图矩形。
|
"""采样并映射为像素点段,再裁剪到绘图矩形。
|
||||||
|
|
||||||
两处断段:非有限点处(画穿渐近线);相邻有限采样点横跨可见范围上下两侧时
|
每个相邻有限采样区间都检查中点,避免端点在可见范围内的渐近线漏判。
|
||||||
(渐近点恰好落在两个采样点之间,否则会被裁剪成贯穿绘图区的伪竖线)。
|
自适应细分受区间与整条曲线预算限制,未解析区间以断点保守处理。
|
||||||
"""
|
"""
|
||||||
segments: list[list[tuple[float, float]]] = []
|
segments: list[list[tuple[float, float]]] = []
|
||||||
points: list[tuple[float, float]] = []
|
points: list[tuple[float, float]] = []
|
||||||
prev_y: float | None = None
|
prev_y: float | None = None
|
||||||
|
prev_x = xmin
|
||||||
|
budget = [_CURVE_MAX_REFINEMENT_EVALUATIONS]
|
||||||
for i in range(_SAMPLES + 1):
|
for i in range(_SAMPLES + 1):
|
||||||
x = xmin + (xmax - xmin) * i / _SAMPLES
|
x = xmin + (xmax - xmin) * i / _SAMPLES
|
||||||
try:
|
try:
|
||||||
@@ -255,19 +306,31 @@ def _sample_segments(
|
|||||||
points = []
|
points = []
|
||||||
prev_y = None
|
prev_y = None
|
||||||
continue
|
continue
|
||||||
# 渐近线检测:相邻有限采样点分居可见范围上下两侧(一个 < ymin、一个 > ymax),
|
if prev_y is not None:
|
||||||
# 说明两者之间夹着竖直渐近线,断段避免被 Liang-Barsky 裁剪成贯穿绘图区的伪竖线
|
refined = _refine_crossing(tree, (prev_x, prev_y), (x, y), ymin, ymax, budget)
|
||||||
if prev_y is not None and (
|
samples = refined[1:] # The previous endpoint is already in points.
|
||||||
(prev_y < ymin and y > ymax) or (prev_y > ymax and y < ymin)
|
else:
|
||||||
):
|
samples = [(x, y)]
|
||||||
if points:
|
for sample in samples:
|
||||||
segments.append(points)
|
mapped = None if sample is None else (
|
||||||
points = []
|
_sx(sample[0], xmin, xmax), _sy(sample[1], ymin, ymax)
|
||||||
points.append((px, py))
|
)
|
||||||
|
if mapped is None or not all(math.isfinite(value) for value in mapped):
|
||||||
|
if points:
|
||||||
|
segments.append(points)
|
||||||
|
points = []
|
||||||
|
else:
|
||||||
|
points.append(mapped)
|
||||||
prev_y = y
|
prev_y = y
|
||||||
|
prev_x = x
|
||||||
if points:
|
if points:
|
||||||
segments.append(points)
|
segments.append(points)
|
||||||
|
|
||||||
|
if budget[0] == 0 and warnings is not None:
|
||||||
|
warning = "曲线细分达到求值上限,未解析区间已断开;请缩小 domain 后重试"
|
||||||
|
if warning not in warnings:
|
||||||
|
warnings.append(warning)
|
||||||
|
|
||||||
# 裁剪到绘图矩形:reportlab 无 SVG viewport 那样的自动裁剪,超出显式 range 的
|
# 裁剪到绘图矩形:reportlab 无 SVG viewport 那样的自动裁剪,超出显式 range 的
|
||||||
# 曲线会覆盖页面其他内容,故在共享几何层统一裁剪(SVG 也一并收敛到绘图区)。
|
# 曲线会覆盖页面其他内容,故在共享几何层统一裁剪(SVG 也一并收敛到绘图区)。
|
||||||
clipped: list[list[tuple[float, float]]] = []
|
clipped: list[list[tuple[float, float]]] = []
|
||||||
@@ -320,7 +383,7 @@ def compute_geometry(plot: FunctionPlot) -> PlotGeometry:
|
|||||||
for i, (expr, tree) in enumerate(fns):
|
for i, (expr, tree) in enumerate(fns):
|
||||||
color = _safe_color(expr.color, _PALETTE[i % len(_PALETTE)])
|
color = _safe_color(expr.color, _PALETTE[i % len(_PALETTE)])
|
||||||
colors.append(color)
|
colors.append(color)
|
||||||
polylines.append(_sample_segments(tree, xmin, xmax, ymin, ymax))
|
polylines.append(_sample_segments(tree, xmin, xmax, ymin, ymax, warnings))
|
||||||
|
|
||||||
return PlotGeometry(
|
return PlotGeometry(
|
||||||
width=_WIDTH,
|
width=_WIDTH,
|
||||||
|
|||||||
@@ -441,3 +441,119 @@ def test_compute_geometry_breaks_at_asymptote() -> None:
|
|||||||
# 相邻点垂直跨度若接近整个绘图区高度,即为渐近线伪连接
|
# 相邻点垂直跨度若接近整个绘图区高度,即为渐近线伪连接
|
||||||
for (_, py0), (_, py1) in zip(seg, seg[1:]):
|
for (_, py0), (_, py1) in zip(seg, seg[1:]):
|
||||||
assert abs(py1 - py0) < full_height * 0.5
|
assert abs(py1 - py0) < full_height * 0.5
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('slope,root', [(1000, 0.0025), (-1000, 0.0025), (1000000, 0.002731)])
|
||||||
|
def test_steep_continuous_crossing_survives_svg_and_pdf(slope, root):
|
||||||
|
from app.plot.render import compute_geometry, _PLOT_Y0, _PLOT_Y1, _PLOT_X0, _PLOT_X1
|
||||||
|
from app.plot.render_reportlab import render_drawing
|
||||||
|
from reportlab.graphics.shapes import PolyLine
|
||||||
|
plot = parse_source(f'domain: -1, 1\nrange: -1, 1\ny = {slope}*(x-{root})').plot
|
||||||
|
segments = compute_geometry(plot).polylines[0]
|
||||||
|
assert len(segments) == 1
|
||||||
|
points = segments[0]
|
||||||
|
assert min(y for x,y in points) == pytest.approx(_PLOT_Y0)
|
||||||
|
assert max(y for x,y in points) == pytest.approx(_PLOT_Y1)
|
||||||
|
for x,y in points:
|
||||||
|
data_x = (x-_PLOT_X0)/(_PLOT_X1-_PLOT_X0)*2-1
|
||||||
|
data_y = 1-(y-_PLOT_Y0)/(_PLOT_Y1-_PLOT_Y0)*2
|
||||||
|
assert data_y == pytest.approx(slope*(data_x-root),abs=1e-7)
|
||||||
|
assert '<polyline ' in render_svg(plot).content
|
||||||
|
assert any(isinstance(item,PolyLine) for item in render_drawing(plot).contents)
|
||||||
|
|
||||||
|
|
||||||
|
def test_crossing_refinement_has_bounded_work(monkeypatch):
|
||||||
|
import app.plot.render as rendering
|
||||||
|
calls = []
|
||||||
|
def jump(tree, x):
|
||||||
|
calls.append(x)
|
||||||
|
return -2 if x < 0.123456789 else 2
|
||||||
|
monkeypatch.setattr(rendering, 'evaluate', jump)
|
||||||
|
samples = rendering._refine_crossing(None, (0,-2), (1,2), -1,1)
|
||||||
|
assert None in samples
|
||||||
|
assert len(calls) <= rendering._REFINE_MAX_EVALUATIONS
|
||||||
|
|
||||||
|
|
||||||
|
def test_visible_midpoint_does_not_bridge_a_pole():
|
||||||
|
from app.plot.render import compute_geometry, _PLOT_Y0, _PLOT_Y1, _PLOT_X0, _PLOT_X1
|
||||||
|
plot = parse_source('domain: 0, 2\nrange: -1, 1\ny = 1000*(x-0.0025)+0.001/(x-0.001)').plot
|
||||||
|
segments = compute_geometry(plot).polylines[0]
|
||||||
|
assert segments
|
||||||
|
for seg in segments:
|
||||||
|
for px, py in seg:
|
||||||
|
x = (px-_PLOT_X0)/(_PLOT_X1-_PLOT_X0)*2
|
||||||
|
y = 1-(py-_PLOT_Y0)/(_PLOT_Y1-_PLOT_Y0)*2
|
||||||
|
# On the visible branch, 1000*t + .001/t - 1.5 >= .5.
|
||||||
|
assert x > .001
|
||||||
|
assert y >= .5-1e-8
|
||||||
|
assert y == pytest.approx(1000*(x-.0025)+.001/(x-.001),abs=.002)
|
||||||
|
|
||||||
|
|
||||||
|
def test_refined_extreme_samples_never_emit_nonfinite_coordinates():
|
||||||
|
from app.plot.render import compute_geometry
|
||||||
|
from app.plot.render_reportlab import render_drawing
|
||||||
|
from reportlab.graphics.shapes import PolyLine
|
||||||
|
plot = parse_source('domain: 0, 2\nrange: -1e-308, 1e-308\ny = 1e-304*(x-0.00125)-1e308*x*(x-0.005)*(x-0.00125)').plot
|
||||||
|
geo = compute_geometry(plot)
|
||||||
|
for segments in geo.polylines:
|
||||||
|
for seg in segments:
|
||||||
|
assert all(math.isfinite(v) for point in seg for v in point)
|
||||||
|
svg = render_svg(plot).content
|
||||||
|
assert 'nan' not in svg and 'inf' not in svg
|
||||||
|
for shape in render_drawing(plot).contents:
|
||||||
|
if isinstance(shape, PolyLine):
|
||||||
|
assert all(math.isfinite(v) for v in shape.points)
|
||||||
|
|
||||||
|
|
||||||
|
def test_refinement_budget_is_shared_by_both_subtrees(monkeypatch):
|
||||||
|
import app.plot.render as rendering
|
||||||
|
calls = []
|
||||||
|
def oscillate(tree, x):
|
||||||
|
calls.append(x)
|
||||||
|
return .9*math.sin(1e9*x)
|
||||||
|
monkeypatch.setattr(rendering, 'evaluate', oscillate)
|
||||||
|
samples = rendering._refine_crossing(None, (0,-2), (1,2), -1,1)
|
||||||
|
assert len(calls) == rendering._REFINE_MAX_EVALUATIONS
|
||||||
|
assert None in samples # Exhaustion leaves gaps, never unchecked chords.
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('factor,pole', [(0.0001,.001),(-0.0001,.001),(.001,.001),(.0001,.0025),(.0001,.00419)])
|
||||||
|
def test_visible_endpoints_do_not_hide_a_pole(factor, pole):
|
||||||
|
from app.plot.render import compute_geometry, _PLOT_X0, _PLOT_X1
|
||||||
|
plot = parse_source(f'domain: 0, 2\nrange: -1, 1\ny = {factor}/(x-{pole})').plot
|
||||||
|
segments = compute_geometry(plot).polylines[0]
|
||||||
|
assert segments
|
||||||
|
left = right = False
|
||||||
|
for segment in segments:
|
||||||
|
xs = [(px-_PLOT_X0)/(_PLOT_X1-_PLOT_X0)*2 for px,py in segment]
|
||||||
|
assert not min(xs) < pole < max(xs)
|
||||||
|
left |= max(xs) < pole
|
||||||
|
right |= min(xs) > pole
|
||||||
|
assert left and right
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('expression', ['x', 'x^2', 'sin(x)', 'exp(x)', 'sqrt(x)', 'log(x)'])
|
||||||
|
def test_smooth_and_domain_limited_curves_remain_visible(expression):
|
||||||
|
from app.plot.render import compute_geometry, _PLOT_X0, _PLOT_X1, _PLOT_Y0, _PLOT_Y1
|
||||||
|
plot = parse_source(f'domain: -2, 2\nrange: -2, 5\ny = {expression}').plot
|
||||||
|
geometry = compute_geometry(plot)
|
||||||
|
assert geometry.polylines[0]
|
||||||
|
assert not geometry.warnings
|
||||||
|
for segment in geometry.polylines[0]:
|
||||||
|
for x,y in segment:
|
||||||
|
assert math.isfinite(x) and math.isfinite(y)
|
||||||
|
assert _PLOT_X0-1e-8 <= x <= _PLOT_X1+1e-8
|
||||||
|
assert _PLOT_Y0-1e-8 <= y <= _PLOT_Y1+1e-8
|
||||||
|
|
||||||
|
|
||||||
|
def test_curve_refinement_has_one_shared_budget(monkeypatch):
|
||||||
|
import app.plot.render as rendering
|
||||||
|
calls=[]
|
||||||
|
def oscillate(tree, x):
|
||||||
|
calls.append(x)
|
||||||
|
return .9*math.sin(1e9*x)
|
||||||
|
monkeypatch.setattr(rendering,'evaluate',oscillate)
|
||||||
|
warnings=[]
|
||||||
|
rendering._sample_segments(None,0,2,-1,1,warnings)
|
||||||
|
assert len(calls) <= rendering._SAMPLES+1+rendering._CURVE_MAX_REFINEMENT_EVALUATIONS
|
||||||
|
assert len(warnings)==1
|
||||||
|
|||||||
@@ -131,3 +131,13 @@ uv run pytest -q
|
|||||||
- DOCX 内嵌函数图像(需栅格化为 PNG,本轮范围外,仅 PDF 内嵌矢量图)。
|
- DOCX 内嵌函数图像(需栅格化为 PNG,本轮范围外,仅 PDF 内嵌矢量图)。
|
||||||
- 函数图像交互预览与缩放(前端 JS Renderer 负责,后端仅提供静态 SVG)。
|
- 函数图像交互预览与缩放(前端 JS Renderer 负责,后端仅提供静态 SVG)。
|
||||||
- 代码语法高亮(当前仅 CSS class 占位)。
|
- 代码语法高亮(当前仅 CSS class 占位)。
|
||||||
|
|
||||||
|
### PR #41:陡峭连续曲线与渐近线区分(2026-09-07)
|
||||||
|
|
||||||
|
每个相邻有限采样区间都会检查中点,不再要求端点分别位于 range 上下两侧,也不因找到一个可见中点就连接整个区间。共享几何层检查中点与弦的偏差:有可见点且误差不超过四分之一像素时保留子段,否则继续细分左右两侧。每个区间最多额外求值 256 次、深度最多 24 层;同一表达式全部区间共享 8192 次额外求值预算,避免全区间检查导致无界增长。达到限制或无法继续推进浮点坐标时,以显式断点隔开未验证子段。遇到非有限中点仍检查它的两侧,保留有效分支,但不跨过非有限点连接。整条曲线耗尽预算时返回 warning,提示缩小 domain 后重试。
|
||||||
|
|
||||||
|
采样三点全在同一不可见侧的子段直接舍弃。细分点与普通点一样检查映射后坐标是否有限,再统一裁剪。SVG 与 PDF 使用相同结果。这是有界数值采样,不是任意函数连续性的数学证明;高频或极窄特征仍受采样与精度限制。
|
||||||
|
|
||||||
|
回归覆盖陡峭正负直线、百万斜率、可见中点混合极点、极小纵轴范围、两端均在可见范围内的极点、极点恰好位于中点、常见连续函数及 log/sqrt 定义域边界;验证区间与整条曲线共享求值预算,耗尽后保留断点和 warning,SVG/PDF 曲线坐标不得包含 NaN/Infinity。
|
||||||
|
|
||||||
|
补充检测:36 组不同系数和极点位置的几何检查通过。一次本机测量中,百万斜率直线和普通倒数曲线约 3 ms,高频 `sin(1000000000*x)` 达到预算并返回 warning,约 45 ms;该数据用于验证有界退出,不作为性能承诺。
|
||||||
|
|||||||
Reference in New Issue
Block a user