From f91c26451b14a7938895890c2dbe2212b9f191dd Mon Sep 17 00:00:00 2001 From: KiriAky 107 Date: Mon, 7 Sep 2026 00:43:34 +0800 Subject: [PATCH] fix(plot): bound adaptive sampling and preserve curve discontinuities --- backend/app/plot/render.py | 87 +++++++++++++++++++--- backend/tests/test_plot.py | 116 +++++++++++++++++++++++++++++ docs/development/Export开发说明.md | 10 +++ 3 files changed, 201 insertions(+), 12 deletions(-) diff --git a/backend/app/plot/render.py b/backend/app/plot/render.py index efd4d34..baee2be 100644 --- a/backend/app/plot/render.py +++ b/backend/app/plot/render.py @@ -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, diff --git a/backend/tests/test_plot.py b/backend/tests/test_plot.py index 5e547c3..433e07c 100644 --- a/backend/tests/test_plot.py +++ b/backend/tests/test_plot.py @@ -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 '= .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 diff --git a/docs/development/Export开发说明.md b/docs/development/Export开发说明.md index 249e440..ce81c24 100644 --- a/docs/development/Export开发说明.md +++ b/docs/development/Export开发说明.md @@ -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;该数据用于验证有界退出,不作为性能承诺。