Soddy Crescent 构造





5.00/5 (13投票s)
构造和动画 Soddy Crescent 的算法。
引言
我最近在网上看到上面这张图,并想知道如何绘制它。这张图有时被称为Soddy Crescent(Soddy新月形)。它是一张看似简单但却颇费思量的图像,仅由几个圆组成。但如果你想一个绘制这些圆的算法,可能会一筹莫展。幸运的是,这个问题早已得到解决。上面的图像是使用Soddy圆(或称“亲吻圆”)绘制的。本文将探讨如何使用Soddy圆来绘制这个新月形。绘制过程使用WPF完成。文章开发了一个程序,通过改变半径来绘制并动画化这个新月形。在此过程中,文章还提供了将圆表示为三次贝塞尔曲线以及计算两个圆交点的方法。本文的重点是算法,WPF并非其核心。
Soddy圆
给定三个两两相切的圆,存在两个不相交的圆,它们都与这三个给定圆相切。这两个圆被称为内Soddy圆和外Soddy圆。这三个给定圆加上任意一个Soddy圆,会产生四个相互切于六个点的圆。如下图所示,三个给定圆是黑色的,内Soddy圆是绿色的,外Soddy圆是蓝色的。
四个Soddy圆的半径之间的关系由以下公式给出
K项是圆的曲率。曲率是圆半径的倒数。然而,如果一个圆包含了其他Soddy圆,那么它的半径被定义为负值。在上图中,黑色圆的半径为正。蓝色外Soddy圆的半径被视为负值,因为它包含了其他圆。
给定三个相互切的圆,Soddy公式可用于轻松计算切于这三个给定圆的Soddy圆的半径。该公式仅提供半径信息。在计算出Soddy圆的圆心之前,它并没有实际用途。内Soddy圆的圆心可以通过简单的几何方法计算得出。似乎没有简单的方法来计算外Soddy圆的圆心。
使用Soddy公式,通过展开方程的左侧并使用二次公式求解第四个半径,可以得到第四个圆的半径关于三个给定圆的表达式。这得到了下面的公式
请注意上述方程中的正负号。第四个半径有两个解。通常一个对应内Soddy圆,另一个对应外Soddy圆。然而,如果其中一个给定圆包含了其他两个圆,则会返回两个内Soddy圆。
构建Soddy Crescent
构建过程始于绘制一个以任意方便的点为圆心的巨大圆。这将形成新月形的顶部,其中包含所有后续的圆。这意味着这个大圆是一个外Soddy圆。由于其圆心已知,因此可以避免复杂的计算。新月形的底部由一个小圆形成,该小圆被大圆包含,并与大圆在底部相切。这两个圆共享相同的x坐标。小圆的y坐标是,大圆的y坐标加上其半径减去小圆的半径。下图展示了构成新月形底部和顶部的两个圆。
Soddy公式需要三个相互切的圆。新月形已经提供了两个。在应用公式之前,必须手动添加第三个圆。这个圆被添加到顶部,位于由前两个圆形成的新月形内部。将第三个圆放在这里,可以指定其半径和圆心。第三个圆的半径是大圆(形成新月形顶部)的半径与小圆(形成新月形底部)半径之差。第三个圆的y坐标是,大圆顶部到y轴的距离减去第三个圆的半径。第三个圆的顶部可以通过将其圆心y坐标与其半径相加来找到。下图展示了向新月形添加第三个圆的过程。
现在有了三个相互切的圆,就可以使用Soddy公式来查找第四个切于前三个圆的圆的半径。请记住,大圆的曲率为负,因为它包含了其他圆。通常Soddy公式会返回两个半径,但对于第四个圆,两个半径是相等的。
Soddy公式用于确定第四个圆的半径。但它的圆心在哪里呢?第四个圆与前三个圆都相切,特别是与包含在外Soddy圆内部的两个圆相切。两个不包含对方的相切圆的圆心之间的距离是它们半径之和。因此,如果取第三个圆(如下图蓝色所示),并将其半径加上第四个圆的半径,就形成了一个新圆,这个新圆是第四个圆所有可能圆心的轨迹。同样,对第二个圆(如下图红色所示)也可以进行同样的操作。这两个圆的交点确定了第四个圆的可能圆心。
为了计算新月形内部Soddy圆的圆心,需要一种计算两个圆交点的方法。起初,我以为可以搜索到现成的C#代码。令人惊讶的是,我未能找到。然而,这是一个众所周知的问题,其解决方案在网上到处都有描述。在文件MyCircle.cs中,提供了CircleIntersect
类。CircleIntersect
的构造函数接受两个圆作为参数。构造该类后,即可在intersects
属性(一个Point列表)中获取两个圆的交点。需要检查列表的Count
以确定找到0、1或2个交点。这分别对应于两个不相交、相切或重叠的圆。用于Soddy Crescent的圆交点始终返回两个交点。
现在第四个Soddy圆可以被绘制出来,因为它的半径和圆心已知。圆心有两个可能的位置,但目前只使用左侧的那个。下图展示了添加第四个圆(蓝色显示)后的新月形。
第五个及后续的Soddy圆以类似的方式绘制。但需要小心选择正确的半径和圆心。Soddy公式返回两个半径值。从上到下绘制Soddy圆时,使用较小的半径。圆交点方法也返回两个可能的圆心值。只有一个是正确的。当在新月形内部按逆时针方向绘制圆时,使用第二个值。反之,当按顺时针方向进行时,使用第一个值。
后续的Soddy圆像第五个圆一样,沿新月形左侧逆时针方向添加。每次半径都会变小,并且更接近新月形的底部。可以添加许多圆,但它们会越来越小,永远不会到达新月形的底部。新月形的右侧可以通过将左侧的圆沿着穿过前三个圆圆心的垂直线反射来绘制。或者,可以直接从第三个圆开始,使用顺时针的交点作为下一个圆心。
绘制圆
本项目使用三次贝塞尔曲线绘制圆,这增加了复杂性。那么为什么要使用贝塞尔曲线绘制圆呢?嗯,这只是我手头有的方法。您可能会发现,一旦您开始使用贝塞尔曲线,您就会尝试用它们来绘制所有形状。如果所有曲线都可以以相同的方式处理,尤其是在它们被转换、拉伸、倾斜或绕另一个曲线弯曲时,编程会更容易。幸运的是,使用三次贝塞尔曲线绘制圆是一个众所周知的问题,有一个简单的解决方案。即使不太了解贝塞尔曲线,也可以采用此解决方案。
一条贝塞尔曲线有4个点:一个起点,一个终点和两个控制点。第一个控制点决定了曲线在起点附近的形状,而第二个控制点决定了曲线在终点附近的形状。生成的曲线通过起点和终点,但通常不通过控制点。当控制点出现在图示中时,它们通常用直线连接到其关联的起点或终点。
对于图形工作来说足够精确的圆可以用4条连接的贝塞尔曲线绘制。由于使用了四条曲线,每条曲线覆盖圆的一个象限。起点和终点位于圆上,相隔90度,分别在0度和90度。控制点放置在圆在起点和终点处的切线上。从起点或终点到其关联控制点的距离对于两个控制点都是相同的。
上图展示了在圆的第一象限中定义贝塞尔曲线的点。如果曲线是逆时针绘制的,则红色虚线上的点是贝塞尔曲线的起点和第一个控制点。类似地,绿色虚线上的点是终点和第二个控制点。两个控制点与其关联的起点或终点的距离相同。问题是这个距离是多少?计算到控制点的距离的公式如下所示
用于第一象限的贝塞尔曲线与其他三个象限的曲线类似。用于生成圆的4个连接贝塞尔段的代码位于类文件MyCircle.cs中。方法名为GenerateCurve
。MyCircle
是我用来定义圆的类。它通过半径和圆心进行实例化,并包含kappa的定义。Curve
类用于定义贝塞尔段。它由一个起点和一个Points
数组组成。每个贝塞尔段的Points
数组中有3个条目:第一个控制点、第二个控制点和终点。如果一个Curve
包含多个贝塞尔段,则前一个段的终点被视为下一个段的起点。
public Curve GenerateCurve()
{
Curve curve = new Curve(4); // 4 bezier segments in curve for circle
// start and endpoint for each segment
curve.startPoint = new Point(center.X, center.Y - radius); //down from center
curve.points[2] = new Point(center.X + radius, center.Y); //right from center
curve.points[5] = new Point(center.X, center.Y + radius); //up from center
curve.points[8] = new Point(center.X - radius, center.Y); //left from center
curve.points[11] = new Point(center.X, center.Y - radius); //down from center (same as start point)
double rk = radius * Kappa;
curve.points[0] = new Point(curve.startPoint.X + rk, curve.startPoint.Y);
curve.points[1] = new Point(curve.points[2].X, curve.points[2].Y - rk);
curve.points[3] = new Point(curve.points[2].X, curve.points[2].Y + rk);
curve.points[4] = new Point(curve.points[5].X + rk, curve.points[5].Y);
curve.points[6] = new Point(curve.points[5].X - rk, curve.points[5].Y);
curve.points[7] = new Point(curve.points[8].X, curve.points[8].Y + rk);
curve.points[9] = new Point(curve.points[8].X, curve.points[8].Y - rk);
curve.points[10] = new Point(curve.points[11].X-rk, curve.points[11].Y);
return curve;
}
关于代码
MainWindow
位于文件MainWindow.xaml.cs中。它包含两个对程序至关重要的变量。它们的定义如下所示
private GrafCan gc;
private List<Curve> curves = new List<Curve>();
gc
负责将曲线中定义的逻辑点映射到物理Canvas
上的位置。gc
在程序开始时和Canvas
大小更改时实例化一次。gc知道绘图轴的逻辑尺寸和Canvas
的物理尺寸。逻辑x轴始终从0到1运行。在此程序中,逻辑y轴从-.5到+.5运行,但可以更改为任何方便的值。物理尺寸是窗口的大小。如果窗口尺寸不是正方形,则圆将显示为椭圆。作为提示,当前画布的尺寸显示在标题栏上。
curves
是要在画布上绘制的Curves
列表。Curves
可以添加到列表或在列表中更新。列表中的Curves
使用以下代码显示
for (int i = 0; i < curves.Count; i++)
{
curves[i].AddCurveToCan(gc);
}
回想一下生成Soddy Crescent的算法,一切都由前两个圆确定。如果假设前两个圆的圆心x坐标为.5,即逻辑x轴的圆心,那么只需要圆的半径就可以绘制Soddy Crescent。MainWindow.xaml.cs中的Zzzz
方法返回一个Curves
列表,该列表根据前两个圆的半径构造Soddy Crescent。返回的List
被放入curves
变量中进行显示,如下所示。
curves = Zzzz(bigRadius, smallRadius);
通过反复调用Zzzz
方法并递增smallRadius
来使显示动画化。DispatcherTimer
会触发对timer_Tick
方法的调用来执行动画。为简单起见,计时器在WPF的线程中运行,而不是在后台线程中。慢速CPU可能会导致动画不流畅。
算法讨论了给定3个Soddy圆和一个交点方向,如何生成第四个Soddy圆。这在Zzzz
方法中的一个循环中完成。查看Zzzz
内部,会发现两个如下所示的循环,它们生成所有的Soddy圆。一个循环按逆时针方向生成Soddy圆,另一个循环按顺时针方向进行。
for (int i = 0; i < 50; i++)
{
lastCircle = NextCircle(circle, circle2, lastCircle, Direction.Left);
lastCircleCurve = lastCircle.GenerateCurve();
lastCircleCurve.style.fillBrush = new SolidColorBrush(Colors.DeepPink);
lastCircleCurve.style.brush = new SolidColorBrush(Colors.Blue);
newCurves.Add(lastCircleCurve);
}
在Zzzz
中调用NextCircle
方法是计算Soddy圆的繁琐工作所在。NextCircle
实例化一个ForthSoddyCircle
类,该类包含一个可能的Soddy圆半径集合。选择最小半径,并用于创建两个圆,通过计算它们的交点来确定Soddy圆的圆心。交点是通过使用这些圆来实例化CircleIntersect
类来计算的。
Using the Code
当动画运行时,请更改窗口大小以拉伸圆。该代码旨在作为演示。将其导入其他程序需要一些努力。请参阅SoddyCircle.cs中的代码来计算Soddy圆的半径。另外,文件MyCircle.cs包含CircleIntersect
类,这在另一个项目中可能会非常有用。
关注点
本文中的图示是WPF使用纯贝塞尔曲线生成的。我希望我能激发您对贝塞尔曲线的兴趣。当以编程方式使用时,它们可以进行有趣的转换。例如,下图是WPF使用摆线贝塞尔曲线绕圆弯曲生成的。
历史
- 2014年8月:初次发布