GraphDisplay: 一个基于 Bezier 曲线的绘制函数和曲线的控件






4.98/5 (49投票s)
一个用于绘制函数、参数曲线和极坐标曲线的WPF控件。
引言
本文介绍了一个用于绘制函数和参数曲线的控件。该控件能够接受数学定义的函数,并使用贝塞尔曲线段对其进行平滑的图形表示。最初,这是我用于使用贝塞尔曲线段创建曲线几何表示工作的一部分。随着项目的进展,我认为有必要验证所表示的几何图形是否是我所认为的。为此,我添加了网格、标尺、刻度和标签。最终,我得到了一个可以独立使用的绘图控件。
背景
本文涉及微积分、一些简单的线性代数和三角学。曲线直接根据函数或曲线及其导数的信息进行绘制。由于我无法找到这方面的推导或公式,所以我不得不从头开始重新创建它们。如果有好心的读者知道在哪里可以找到这些推导,请告知我,我会很高兴地更新本文。
如何使用
函数
由于这个控件用于绘制函数和曲线,所以最好首先明确所使用的函数或曲线的定义。简而言之,函数是由两个方法组成的:一个表示值,另一个表示斜率或导数。所有函数都继承自基类
public abstract class FunctionBase
{
public abstract double Value(double x);
public abstract double Derivative(double x);
//... Other stuff
}
绘制函数
要绘制一个函数,我们调用GraphDisplay
的AddFunction
方法。
public void AddFunction(FunctionBase f, double start,
double end, int segments, GraphStyle style)
例如
GraphStyle curveStyle = new GraphStyle()
{Stroke = new SolidColorBrush(Colors.Red ) ,Thickness = 2};
Exp ex = new Exp() ;
gDisplay.AddFunction(exp, 1, 2, 5, curveStyle);
可以使用GraphDisplay
gDisplay
来绘制函数y= ex,范围从x= 1到x= 2,颜色为红色,笔触宽度为2,使用五个贝塞尔曲线段。然而,我们还没有完成。控件需要知道如何关联函数在抽象数学空间中的值。
gDisplay.YTop = 8 ;
gDisplay.YBottom 2;
gDisplay.XLeft =0;
gDisplay.XRight =3;
这在绘制的指数函数周围留出了一点空白。我们可以向GraphDisplay
添加任意数量的函数,但所有函数都使用相同的边界。
通过继承创建函数
可以通过直接继承FunctionBase
来创建函数。例如,假设我们想要一个f(x) = sin(x)对应的函数。
public class Sin : Mathematics.BaseClasses.FunctionBase
{
public override double Value(double x)
{
return Math.Sin( x );
}
public override double Derivative(double x)
{
return Math.Cos(x);
}
}
请注意,这是一个Mathemaics.Functions.Sin
类的最小化版本,对应于f(x) = ASin(nx + d)形式的函数族。
直接创建函数
我们也可以通过Function
类来创建函数。Function
类如下:
public sealed class Function:FunctionBase
{
private Func<double,> mF;
private Func<double,> mDF;
public Function( Func<double,double> f, Func<double,double> df)
{
mF = f;
mDF = df;
}
public override double Value(double x)
{
return mF(x);
}
public override double Derivative(double x)
{
return mDF(x);
}
}
我们只需传入函数及其导数的委托即可。例如,我们可以通过以下方式再次创建f(x) = sin(x):
Function f = new Function(x=> Math.Sin(x), x=> Math.Cos(x));
对于创建函数,继承可能更受青睐,而Function
类则在运行时动态工作。
组合函数
函数还可以通过各种方式组合。重要的是要记住,派生自FunctionBase
的类实例是类似微积分的函数,因此需要遵守链式法则。幸运的是,FunctionBase
中有几个用于组合函数的静态方法。要组合y= 2x和y= sin(x)得到y=sin(2x),我们可以使用以下代码:
Function twoX = new Function (x=>2*x,x=>2);
Sin sin = new Sin();
Function composition = Function.Compose(sin ,twoX);
所有复杂性都隐藏在后台。
public static Function Compose(FunctionBase outerFunction, FunctionBase innerFunction)
{
//Change of variables for more readable code
FunctionBase f = outerFunction;
FunctionBase g = innerFunction;
return new Function(
x => f.Value(g.Value(x)), //Composition f(g(x))
x => f.Derivative(g.Value(x)) * g.Derivative(x)
//Chain Rule f'(g(x))*g'(x)
);
}
还有用于函数加、减、乘、除的静态方法。这些函数可以直接调用,也可以通过运算符使用。使用相同的sin
和twoX
,可以通过以下方式完成f(x) = sin(x) + 2x:
Function plus = Function.Sum(sin, twoX);
Function plus = sin + twoX ;
这会调用
public static Function Sum(FunctionBase f, FunctionBase g)
{
return new Function(x => f.Value(x) + g.Value(x),
x => f.Derivative(x) + g.Derivative(x)
);
}
f(x) = sin(x) - 2x 可以通过以下方式实现:
Function minus = Function.Difference(sin, twoX);
Function minus = sin - twoX ;
这会调用
public static Function Difference(FunctionBase f, FunctionBase g)
{
return new Function(x => f.Value(x) - g.Value(x),
x => f.Derivative(x) - g.Derivative(x)
);
}
f(x) = 2xsin(x) 可以通过以下方式实现:
Function times = Function.Product( twoX ,sin);
Function times = twoX * sin;
这会调用
public static Function Product(FunctionBase f, FunctionBase g)
{
return new Function(x => f.Value(x) * g.Value(x),
x => f.Derivative(x) * g.Value(x) +
g.Derivative(x) * f.Value(x) //Chain Rule f'(x)g(x) + f(x)g'(x)
);
}
f(x) = sin(x)/2x 可以通过以下方式实现:
Function divides = Function.Product( sin , twoX);
Function divides = sin /twoX;
这会调用
public static Function Quotient(FunctionBase numerator, FunctionBase denominator)
{
//Change of variables for more readable code
FunctionBase f = numerator;
FunctionBase g = denominator;
return new Function(x => f.Value(x) / g.Value(x),
//Chain rule (f'(x)g(x) - f(x)g'(x))/g(x)^2
x => (f.Derivative(x) * g.Value(x) - f.Value(x) *
g.Derivative(x)) / Math.Pow(g.Value(x), 2)
);
}
我之所以详细阐述这些变换,是因为它们很重要。创建FunctionBase
的主要原因是为了拥有一个能够自然遵循链式法则的实体。
函数示例
作为函数绘图的示例,我们有DampedSinusoid
。
public class DampedSinusoid : FunctionBase
{
public double A { get; private set; }
public double Gamma { get; private set; }
public double Omega { get; private set; }
public double Phi { get; private set; }
public DampedSinusoid(double a, double gamma, double omega, double phi)
{
A = a; Gamma = gamma; Omega = omega; Phi = phi;
}
public override double Value(double x)
{
return A * Math.Pow(Math.E, -Gamma * x) * Math.Cos(Omega * x + Phi);
}
public override double Derivative(double x)
{
return A * Math.Pow(Math.E, -Gamma * x) * Math.Cos(Omega * x + Phi) * (-Gamma)
- A * Math.Pow(Math.E, -Gamma * x) * Math.Sin(Omega * x + Phi) * Omega;
}
}
该示例显示了两个衰减的正弦波及其总和。我需要指出的是,此类中使用的所有值都在构造函数中设置,并且之后不可更改。我强烈建议遵循此策略。
曲线
曲线与函数非常相似,只是有四个方法而不是两个。
public abstract class CurveBase
{
public abstract Double X(double t);
public abstract Double Y(double t);
public abstract Double Dx(double t);
public abstract Double Dy(double t);
... other stuff
}
还有一个CyclicCurveBase
。这应该用于闭合曲线的情况。总的来说,我们不应该期望最终用户知道函数的周期。
public abstract class CyclicCurveBase:CurveBase
{
public abstract double CycleStart {get;}
public abstract double CycleEnd{ get;}
}
可以通过AddCurve
和AddCyclicCurve
方法添加曲线。
public void AddCurve(CurveBase c, double start, double end,
int segments, GraphStyle style){...}
public void AddCyclicCurve(CyclicCurveBase c, int segments, GraphStyle style){...}
曲线类层次结构
此时,查看CurveBase
的子类的类层次结构应该会有所帮助。
为了创建一条新曲线进行绘制,我们应该继承其中一个抽象基类(蓝色)或使用组合类(紫色)进行组合。有些时候,如果能够采用多重继承会很方便。例如,我希望能够从某个周期性基类继承周期性属性,并从极坐标基类继承极坐标属性。目前的实现方式涉及一些令人遗憾的重复。这些类被设计成可以扩展,而无需对绘图控件进行重大更改。例如,如果将双极坐标添加到混合体中,则无需对绘图控件进行任何更改。未来,我们可以合理地添加其他类型的曲线,如双极、椭圆或双曲线。在这种情况下,我们应该为每种坐标系创建四个类,就像我们拥有PolarCurveBase
、PolarCurve
、CyclicPolarCurveBase
和CyclicPolarCurve
一样。
极坐标曲线及其构造函数顺序
如果我们有一个函数r= f(Θ),则可以使用PolarCurve
类来构造曲线。
public sealed class PolarCurve:PolarCurveBase
{
private Func<double, double> mR;
private Func<double, double> mDr;
public PolarCurve(Function r)
{
mR = r.Value;
mDr = r.Derivative;
}
public override double R(double theta)
{
return mR(theta);
}
public override double Dr(double theta)
{
return mDr(theta);
}
}
需要注意的是,绘图控件不使用r或Θ坐标。这由基类处理。
public abstract class PolarCurveBase:CurveBase
{
public abstract double R(double theta);
public abstract double Dr(double theta);
private Curve mC;
static Cos sCos = new Cos();
static Sin sSin = new Sin();
protected PolarCurveBase() {
Function r = new Function(R,Dr);
mC = new Curve( r * sCos , r * sSin );
}
public override double X(double t)
{
return mC.X(t);
}
public override double Y(double t)
{
return mC.Y(t);
}
public override double Dx(double t)
{
return mC.Dx(t);
}
public override double Dy(double t)
{
return mC.Dy(t);
}
}
有趣的是两个构造函数调用的顺序。PolarCurveBase
的构造函数在PolarCurve
的构造函数之前被调用。
这意味着转换函数的曲线是从封装函数的方法重写创建的,然后才引入函数本身。极坐标曲线的示例是玫瑰曲线。
数学原理
贝塞尔曲线
贝塞尔曲线在绘制平滑曲线方面非常有用,因此一些动机可能是有价值的。让我们假设我们有一条我们有兴趣创建表示的曲线。它从点a = (ax, ay)开始,到点b = (bx, by)结束。我们可以用一对函数来表示它们之间的曲线。Bx(t),其中Bx(0)=ax且Bx(1)=bx,以及By(t),其中By(0)=ay且By(1)=by。
线性贝塞尔曲线
我们可以为Bx和By(B表示贝塞尔)使用的最简单的函数将是f(x) = Ax + B形式的直线,并满足初始约束。(通常使用不同的等效公式,但这会增加我们目前不需要的复杂性。)重要的是要注意,我们需要4个数字来唯一确定这条直线,2个用于Bx(t),2个用于By(t)。幸运的是,这些数字在端点的x和y坐标中都可用。通过一点重组和符号更改,这可以转化为线性贝塞尔曲线的常规定义。
B(t) = P0 + t(P1 - P0),其中P0是起点,P1是终点。
一条直线对曲线的近似效果并不好,但值得注意的是,使用它,我们可以将许多贝塞尔曲线段放在一起,并且在屏幕上显示的过程中,我们的贝塞尔曲线被转换回线段。Geometry
基类甚至有一个GetFlattenedPathGeometry
方法来完成这项工作。WPF在精神上支持这一点,通过LineSegment
类,即使名称不同。
二次贝塞尔曲线
我们可以使用二次函数(f(x) = Ax2 + Bx + C形式)而不是线性函数来更好地近似我们的曲线。需要注意的是,现在我们需要6个数字来定义我们的曲线段。这些数字的明显选择是初始点和终点的斜率。以向量形式表示,我们有:
B(t) = P0 (1-t)2 + P1 2(1-t)t + P2t2
这对应于
Bx(t) = P0x(1-t)2 + P1x 2(1-t)t + P2xt2
By(t) = P0y(1-t)2 + P1y 2(1-t)t + P2yt2
你可能会问,为什么我们需要使用这种格式而不是更简单的Ax2 + Bx + C。首先,很明显我们可以做到这一点。只需将所有内容相乘并收集项即可得到A、B和C。当我们在端点处评估条件时,这种形式的好处就显现出来了。如果我们从点a = (ax, ay)开始,到点b = (bx, by)结束,那么通过在t=0处求值,我们可以直接得到P0x = ax和P0y = ay。通过在t=0处求值,我们得到P2x = bx和P2y = by。然后,我们面临着找到P1x和P1y的更简单问题。在WPF中,二次贝塞尔曲线段由QuadraticBezierSegment
支持。QuadraticBezierSegment
的构造函数有一个用于P1和P2的空间,但没有P0。起初,这可能让人觉得它只接受4个数字而不是6个数字来定义一个二次贝塞尔曲线段。这更多地与内存管理有关,而不是数学。如果你在一个路径中有两个连续的段,第一个段的P2就充当第二个段的P0。微软通过这样做既节省了内存,又消除了我们输入错误值的可能性。它并没有改变查找P1值的动态。如果我们想以微软的方式思考,我们就必须丢弃初始点处的两个条件以及查找P0的要求。
三次贝塞尔曲线
这是我们最有可能使用的段类型。值得注意的是,它由名为BezierSegment
的类表示,而不是CubicBezierSegment
。这些也是你在Adobe Illustrator中使用钢笔工具时使用的段。以向量形式表示,它看起来像:
B(t) = P0 (1-t)3 + P1 3(1-t)2 t + P2 3(1-t)t2 + P3 t3
这对应于
Bx(t) = P0x (1-t)3 + P1x 3(1-t)2 t + P2x 3(1-t)t2 + P3x t3
By(t) = P0y (1-t)3 + P1y 3(1-t)2 t + P2y 3(1-t)t2 + P3y t3
向量形式的一个优点是它使得贝塞尔曲线段的一些常见变换变得容易。例如,如果我们想将贝塞尔曲线在x方向正向移动5,我们只需要将P0、P1、P2和P3的x分量加上5。事实上,任何可以通过矩阵乘法实现的平移、反射或其他变换都具有此属性。另外,这意味着在绘制贝塞尔曲线段的图形时,通过旋转它们不会损失任何通用性,因此更容易绘制。
P和t=0和t=1处的导数之间有一个非常重要的关系。这只是直接的代数运算,但相当繁琐,我建议使用Mathematica或类似的工具而不是纸笔。
B'y(0) /B'x(0) = (P0y - P1y) /(P0x - P1x)
B'y(1) /B'x(1) = (P2y - P3y) /(P2x - P3x)
这意味着在点P0处的贝塞尔曲线的切线与P1相交,而在P3处的切线与P2相交。因此,如果我们拥有的唯一条件是端点和端点的斜率,那么存在大量的可能P1。类似的推理也适用于P2。
此外,假设我们想选择一个P1,使其在P0处具有指定的斜率。我们可以选择任何既在该切线上又在P3方向上的点,它将在P0处具有正确的斜率。例如,我们可以有以下可能性:
在名为“倾斜”的那个中,曲线比P3延伸得更远,P1需要再远一些才能更明显。正如我们所见,我们可以得到明显不同的贝塞尔曲线段,它们具有相同的端点和端点的斜率。
与二次曲线相同的推理,我们需要8个数字来定义该段,其中4个由初始点和终点提供。
函数
现在初步工作已经完成,是时候绘制函数了。我假设f(x)及其导数f'(x)在绘制范围内也存在。之所以选择这种类型的函数,是因为它是绘图的“最佳甜蜜点”。放弃一阶导数会使事情变得非常困难,而高于一阶的导数带来的好处远不如我们所期望的那样。
Bx(t) = P0x (1-t)3 + P1x 3(1-t)2 t + P2x 3(1-t)t2 + P3x t3
By(t) = P0y (1-t)3 + P1y 3(1-t)2 t + P2y 3(1-t)t2 + P3y t3
a = P0x
f(a) = P0y
b = P3x
f(b) = P3y
f'(a) = (P0y - P1y) /(P0x - P1x)
f'(b) = (P2y - P3y) /(P2x - P3x)
经过一些简化,我们得到:
P1y -f'(a) P1x = f(a) - af'(a)
y -f'(b) P2x = f(b) - bf'(b)
看起来好一点了,但我们仍然有两个方程和四个未知数,正如我们已经看到的,这留下了许多可能的曲线段。所以我们需要更多的约束。我们有两个,尽管它们没有以等式的形式表达。首先,我们正在绘制一个函数。根据定义,这意味着对于每一个x,都必须只有一个f(x)。这使得具有循环和倾斜的贝塞尔曲线段成为函数不可接受的表示。此外,我们期望当a和b靠得越近时,贝塞尔曲线段将越精确地匹配其曲线部分,就像我们用线段逼近曲线一样。
为了进一步深入,我们需要更多以方程形式出现的条件。让我们考虑以下两个:
P1x =( 2 a + b)/3
P2x =( a + 2 b)/3
我们稍后会看到,这些是条件的绝佳选择,但值得注意的是,它们不是我们可能做出的唯一选择,并且这个选择将曲线置于正常类别中。它也符合Illustrator钢笔工具的经验法则,即控制手柄应该在节点之间的1/3处。有了这些条件,我们得到:
P1y =( 3 f(a) - a f'(a) + b f'(a))/3
P2y =( 3 f(b) - a f'(b) + b f'(b))/3
这正是我们期望的结果。P1y和P2y表示为端点处条件的线性组合。没有可能为零的分母。二次贝塞尔曲线段不被使用的主要原因之一是存在一个可能为零的分母,这会导致图形出现伪影。所以,总结一下,我们有以下结果:
Bx(t) = P0x (1-t)3 + P1x 3(1-t)2 t + P2x 3(1-t)t2 + P3x t3
By(t) = P0y (1-t)3 + P1y 3(1-t)2 t + P2y 3(1-t)t2 + P3y t3
P0x = a , P0y = f(a)
P1x = ( 2 a + b)/3 , P1y =( 3 f(a) - a f'(a) + b f'(a))/3
P2x =( a + 2 b)/3 , P2y =( 3 f(b) - a f'(b) + b f'(b))/3
P3x = b , P3y = f(b)
参数曲线
对于参数曲线,我们有一个由两个函数定义的曲线:x = x(t)和y=y(t)。根据长期传统,t用于参数(t是一个很好的时间字母)。应该记住,这个t与Bx(t)和By(t)中的t不同。对于从t = a到t= b的曲线,以及函数x(t)和y(t),我们有:
Bx(t) = P0x (1-t)3 + P1x 3(1-t)2 t + P2x 3(1-t)t2 + P3x t3
By(t) = P0y (1-t)3 + P1y 3(1-t)2 t + P2y 3(1-t)t2 + P3y t3
x(a) = P0x
y(a) = P0y
x(b) = P3x
y(b) = P3y
y'(a)/x'(a) = (P0y - P1y) /(P0x - P1x)
y'(b)/x'(b) = (P0y - P1y) /(P0x - P1x)
再次,我们需要更多的条件。循环和点可能出现在参数曲线中,如利萨茹曲线,但作为我们算法的伪影不应添加。这其中一个含义是,P1不能完全从P0处的行为确定。为了说明这一点,考虑让b越来越接近a。最终,我们将得到一个尖锐的循环或倾斜的曲线段。
我们可以考虑我们在绘制函数时使用的相同想法。由于P1的论证与P2的论证完全类似,讨论将只集中在P1上。不同之处在于,不是水平移动x距离的1/3,而是将P1放在连接P0和P3的线段上。这样做,我们就定义了一个三角形,从而能够找到P1。
首先,我们知道角度α。连接P0和P3的直线的斜率是已知的,因为P0和P3是已知的。连接P0和P1的直线的斜率是y'(a)/x'(a),这同样是已知的。根据一个著名的公式tan(α) = (mb - ms)/ (1+ mb ms),其中mb是较大的斜率,ms是较小的斜率。根据正弦定律,我们知道从P0到P1的线段长度是P0到P3距离的1/3除以sin(pi/2 - α)。
一旦我们有了P0和P1之间的长度,称之为h,我们就可以得到P1。
P1x =P0x + h cos(μ) where μ is the angle made by the tangent line at P0 with the X axis
P1y =P0y + h sin(μ)
由于我们分别有dx和dy,我们可以进一步简化,得到cos(μ) = dx/(dx2 + dyx2)1/2和sin(μ) = dy/(dx2 + dyx2)1/2。这为我们提供了一种找到P1的方法。它不像函数情况下的解决方案那么好,但它提供了一种可以用来绘制曲线的过程。
工作原理
坐标
到目前为止,坐标都被认为是理所当然的。函数和曲线在抽象的数字空间中定义,但它们必须以屏幕坐标的形式显示在屏幕上。这涉及到平移、缩放和方向的变化。在屏幕上,我们需要绘制一个TransformedFunction
而不是原始的FunctionBase
。
public class TransformedFunction
{
private FunctionBase mF;
private FunctionBase mXTrans;
private FunctionBase mYTrans;
public TransformedFunction(FunctionBase f,
FunctionBase xTrans, FunctionBase yTrans)
{
mF = f; mXTrans = xTrans; mYTrans = yTrans;
}
public double Input(double x)
{
return mXTrans.Value(x);
}
public double Value(double x)
{
return mYTrans.Value(mF.Value(x));
}
public double Derivative(double x)
{
return (mYTrans.Derivative(mYTrans.Value(x)) /
mXTrans.Derivative(x)) * mF.Derivative(x);//Chain Rule
}
}
唯一需要注意的是,所有三个项目都必须被转换:输入(x)、输出(y)和导数(dy/dx)。此外,我们应该记住,链式法则不是可选项。有趣的部分是转换x和y坐标的两个FunctionBase
。
mXTransform = new Function(x => (DisplayCanvas.ActualWidth /
(XRight - XLeft)) * (x - XLeft), x => DisplayCanvas.ActualWidth / (XRight - XLeft));
mYTransform = new Function(y => (DisplayCanvas.ActualHeight / (YBottom - YTop)) *
(y - YTop), y => DisplayCanvas.ActualHeight / (YBottom - YTop));
这涵盖了所有三个方面,包括在我们函数空间中,y的增加向上,而在画布上,y向下。这并非我们唯一可以使用的函数。稍有不同的表达式会给出对数缩放。在这个方面,曲线和函数之间的差异很小,所以我将省略讨论。
函数
绘制函数时,我们只需将其添加到GraphDisplay
中。
public void AddFunction(FunctionBase f, double start, double end,
int segments, GraphStyle style)
{
Path p = new Path();
TransformedFunction tf =
new TransformedFunction(f, mXTransform, mYTransform);
p.Data = BezierGeometry(tf, start, end, segments);
p.Fill = style.Fill;
p.Stroke = style.Stroke;
p.StrokeThickness = style.Thickness;
DisplayCanvas.Children.Add(p);
}
这利用了BezierGeometry
方法来生成几何图形。
private PathGeometry BezierGeometry(TransformedFunction tf,
double start, double end, int segments)
{
PathFigure pF = new PathFigure();
pF.StartPoint = new Point(tf.Input(start),tf.Value(start));
pF.IsClosed = false;
Func<double, double> f = tf.Value; //for readability
Func<double, double> df = tf.Derivative; //for readability
for (int i = 0; i < segments; i++)
{
double aOrig = i * (end - start) / segments + start;
double a = tf.Input(aOrig);
double bOrig = (i + 1) * (end - start) / segments + start;
double b = tf.Input(bOrig);
Point P1 = new Point((2 * a + b) / 3,
(3 * f(aOrig) - a * df(aOrig) + b * df(aOrig)) / 3);
Point P2 = new Point((2 * b + a) / 3,
(3 * f(bOrig) + a * df(bOrig) - b * df(bOrig)) / 3);
Point P3 = new Point(b, f(bOrig));
BezierSegment bs = new BezierSegment(P1, P2, P3, true);
pF.Segments.Add(bs);
}
PathGeometry pGeo = new PathGeometry();
pGeo.Figures.Add(pF);
return pGeo;
}
曲线
添加曲线或周期曲线的代码几乎是相同的。
public void AddCurve(CurveBase c, double start,
double end, int segments, GraphStyle style)
{
Path p = new Path();
TransformedCurve tf = new TransformedCurve(c, mXTransform, mYTransform);
p.Data = BezierGeometry(tf, start, end, segments);
p.Fill = style.Fill;
p.Stroke = style.Stroke;
p.StrokeThickness = style.Thickness;
DisplayCanvas.Children.Add(p);
}
唯一的区别在于BezierGeometry
方法。上面描述的算法用于生成贝塞尔曲线段。然而,生成曲线几何图形的算法不如函数情况稳定。快速的方向变化处理不当。例如,利萨茹图形可能出现尖峰。这些处理效果不佳。
因此,在计算贝塞尔曲线段时,使用了两种不同的算法:BezierBuilder
和AjustingBezierBuilder
。
private PathGeometry BezierGeometry(TransformedCurve tc,
double start, double end, int segments)
{
PathFigure pF = new PathFigure();
pF.StartPoint = new Point(tc.X(start), tc.Y(start));
pF.IsClosed = false;
Stack<double> boundaries = new Stack<double>();
for (int i = segments; i >= 0; i--)
{
boundaries.Push(i * (end - start) / segments + start);
}
for (int i = 0; i < segments; i++)
{
double a = boundaries.Pop();
double b = boundaries.Peek();
AjustingBezierBuilder abb = new AjustingBezierBuilder(tc, a, b);
abb.AddTo( pF);
}
PathGeometry pGeo = new PathGeometry();
pGeo.Figures.Add(pF);
return pGeo;
}
BezierGeometry
方法首先创建一个边界的Stack
来定义计算贝塞尔曲线段的起点和终点。这取代了函数情况下的循环。创建堆栈的这一步可能看起来很奇怪,但选择它是为了能够与几种不同的优化策略协调,而这些策略最终并没有包含在当前版本的绘图控件中。在正常情况下,AjustingBezierBuilder
的结果与使用BezierBuilder
生成线段的结果相同。正常情况意味着起点和终点之间的距离小于到附近控制点的距离。这只是遵循数学区域中描述的程序。
- 计算端点之间的距离。
- 检查端点是否足够接近,可以近似为直线。
- 到控制点的直线的斜率根据曲线在端点的导数确定。
- 确定连接线段端点的直线的斜率。
- 根据斜率确定端点与端点连接线之间的角度。
- 通过正弦定律确定连接端点到控制点的线段的长度。
- 通过沿着连接端点到控制点的线移动控制点来确定控制点,移动距离为到控制点的距离。
- 使用控制点和端点创建
BezierSegment
。
public BezierBuilder(CurveBase c, double a, double b)
{
A = a;
B = b;
Point InitialPoint = new Point(c.X(a), c.Y(a));
FinalPoint = new Point(c.X(b), c.Y(b));
//The distance between the endpoints is calculated.
double distance = Geometry.Distance(InitialPoint, FinalPoint) ;
//Check if endpoints are close enough to approximate with a straight line.
if (distance < tolerance) //optimization
{
mBs = new LineSegment(FinalPoint, true);
IsStable = true;
}
else
{
//The slope of the lines to the control points is determined
//from the derivatives of the curve at the enpoints.
double curveSlopeA = c.Slope(a); ///dy(a) / dx(a);
double curveSlopeB = c.Slope(b); ///dy(b) / dx(b);
//The slope of the line connecting
// the endpoints of the segment is detemined.
double slope = Geometry.Slope(InitialPoint, FinalPoint);
//The Angles between the slope at the endpoints
//and the endpoint connecting lines is determined from the slopes.
double angleA = Geometry.AngleBetweenLines(slope, curveSlopeA);
double angleB = Geometry.AngleBetweenLines(slope, curveSlopeB);
//The length of the segments connecting the Endpoints
//to the control points is determined via the law of sines
Double distanceIn = distance / 3; //our extra condition
Double lengthAlongLineA = distanceIn /
(Math.Sin(Math.PI / 2 - angleA)); //Law of Sines
Double lengthAlongLineB = distanceIn /
(Math.Sin(Math.PI / 2 - angleB)); //Law of Sines
//The Control points are determined by moving along the line
//conecting the enpoints to the control points
//the distance to the control points.
Point controlPointA = Geometry.AtDistanceFromPointOnLine(InitialPoint,
c.Dx(a), c.Dy(a), lengthAlongLineA);
Point controlPointB = Geometry.AtDistanceFromPointOnLine(FinalPoint,
-c.Dx(b), -c.Dy(b), lengthAlongLineB);
//The BezierSegment is created from the control points and the endpoints.
mBs = new BezierSegment(controlPointA, controlPointB, FinalPoint, true);
//Mark if Bezier segment is Stable
IsStable = distance > lengthAlongLineA &&
distance > lengthAlongLineB;
PointDistance = Geometry.Distance(InitialPoint, FinalPoint);
}
}
这工作得相当好。然而,它处理尖峰的效果不如所期望的那样好。为了处理这种情况,使用了AdjustingBezierBuilder
。
public AjustingBezierBuilder(CurveBase c, double a, double b)
{
BezierBuilder bb = new BezierBuilder(c,a,b);
if ( bb.IsStable) // No Need to Adjust
{
mSegments = new List<pathsegment>();
mSegments.Add(bb.Segment);
}else{
LinkedList<bezierbuilder> builders = new LinkedList<bezierbuilder>();
LinkedListNode<bezierbuilder> current=
builders.AddFirst(new BezierBuilder(c, a, (a + b) / 2));
builders.AddAfter(current, new BezierBuilder(c, (a + b) / 2,b ));
while (current != null )
{
if (current.Value.IsStable) //No Problems
{
current = current.Next;
}
else // There is a problem with the current Bezier builder
{
//Slices BezierBuilder into two samller Bezier builders
builders.AddAfter(current, new BezierBuilder(c,
(current.Value.A + current.Value.B) / 2, current.Value.B));
LinkedListNode<bezierbuilder> newcurrent =
builders.AddAfter(current,
new BezierBuilder(c, current.Value.A,
(current.Value.A + current.Value.B) / 2));
builders.Remove(current );
current = newcurrent;
}
}
mSegments = (from bld in builders select bld.Segment).ToList();
}
}
当BezierBuilder
正常运行时,AdjustingBezierBuilder
默认为BezierBuilder
的线段。否则,AdjustingBezierBuilder
将用于构成贝塞尔曲线段的线段分成两部分。其中每一个线段都定义了一个新的BezierBuilder
,并对其进行检查。如果它仍然不稳定,则再次分割。这个过程一直持续到所有BezierBuilder
对象都被标记为稳定为止。我们知道这个过程会停止,因为一旦起点和终点之间的距离小于BezierBuilder
中的Tolerance
的静态值,这个过程就会停止。应该注意的是,每个独立的BezierBuilder
只创建和测试一次。如果一个BezierBuilder
不稳定,它将被两个新的替换,或者等效地,在原始的之后添加两个新的,然后删除原始的。使用链表使这更加高效。
结论
我希望这个绘图控件能为您带来便利。这在几年前会是一项更艰巨的任务。它也只是我希望很快就能准备好的一套新的功能几何图形的初步工作。