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
|
||||
|
||||
|
||||
_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(
|
||||
tree: object,
|
||||
xmin: float,
|
||||
xmax: float,
|
||||
ymin: float,
|
||||
ymax: float,
|
||||
warnings: list[str] | None = None,
|
||||
) -> list[list[tuple[float, float]]]:
|
||||
"""采样并映射为像素点段,再裁剪到绘图矩形。
|
||||
|
||||
两处断段:非有限点处(画穿渐近线);相邻有限采样点横跨可见范围上下两侧时
|
||||
(渐近点恰好落在两个采样点之间,否则会被裁剪成贯穿绘图区的伪竖线)。
|
||||
每个相邻有限采样区间都检查中点,避免端点在可见范围内的渐近线漏判。
|
||||
自适应细分受区间与整条曲线预算限制,未解析区间以断点保守处理。
|
||||
"""
|
||||
segments: list[list[tuple[float, float]]] = []
|
||||
points: list[tuple[float, float]] = []
|
||||
prev_y: float | None = None
|
||||
prev_x = xmin
|
||||
budget = [_CURVE_MAX_REFINEMENT_EVALUATIONS]
|
||||
for i in range(_SAMPLES + 1):
|
||||
x = xmin + (xmax - xmin) * i / _SAMPLES
|
||||
try:
|
||||
@@ -255,19 +306,31 @@ def _sample_segments(
|
||||
points = []
|
||||
prev_y = None
|
||||
continue
|
||||
# 渐近线检测:相邻有限采样点分居可见范围上下两侧(一个 < ymin、一个 > ymax),
|
||||
# 说明两者之间夹着竖直渐近线,断段避免被 Liang-Barsky 裁剪成贯穿绘图区的伪竖线
|
||||
if prev_y is not None and (
|
||||
(prev_y < ymin and y > ymax) or (prev_y > ymax and y < ymin)
|
||||
):
|
||||
if points:
|
||||
segments.append(points)
|
||||
points = []
|
||||
points.append((px, py))
|
||||
if prev_y is not None:
|
||||
refined = _refine_crossing(tree, (prev_x, prev_y), (x, y), ymin, ymax, budget)
|
||||
samples = refined[1:] # The previous endpoint is already in points.
|
||||
else:
|
||||
samples = [(x, y)]
|
||||
for sample in samples:
|
||||
mapped = None if sample is None else (
|
||||
_sx(sample[0], xmin, xmax), _sy(sample[1], ymin, ymax)
|
||||
)
|
||||
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_x = x
|
||||
if 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 的
|
||||
# 曲线会覆盖页面其他内容,故在共享几何层统一裁剪(SVG 也一并收敛到绘图区)。
|
||||
clipped: list[list[tuple[float, float]]] = []
|
||||
@@ -320,7 +383,7 @@ def compute_geometry(plot: FunctionPlot) -> PlotGeometry:
|
||||
for i, (expr, tree) in enumerate(fns):
|
||||
color = _safe_color(expr.color, _PALETTE[i % len(_PALETTE)])
|
||||
colors.append(color)
|
||||
polylines.append(_sample_segments(tree, xmin, xmax, ymin, ymax))
|
||||
polylines.append(_sample_segments(tree, xmin, xmax, ymin, ymax, warnings))
|
||||
|
||||
return PlotGeometry(
|
||||
width=_WIDTH,
|
||||
|
||||
@@ -441,3 +441,119 @@ def test_compute_geometry_breaks_at_asymptote() -> None:
|
||||
# 相邻点垂直跨度若接近整个绘图区高度,即为渐近线伪连接
|
||||
for (_, py0), (_, py1) in zip(seg, seg[1:]):
|
||||
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 内嵌矢量图)。
|
||||
- 函数图像交互预览与缩放(前端 JS Renderer 负责,后端仅提供静态 SVG)。
|
||||
- 代码语法高亮(当前仅 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