fix(plot): bound adaptive sampling and preserve curve discontinuities

This commit is contained in:
2026-09-07 00:43:34 +08:00
parent 4276cb73c2
commit f91c26451b
3 changed files with 201 additions and 12 deletions
+75 -12
View File
@@ -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,