在CAD .NET开发里,绘制带圆弧的多段线是一个出现频率极高的需求,但也是新手最容易绕远路的坑。我第一次接到这个需求时,心里觉得简单:非就是加点、连线嘛,结果画出来的“圆弧”全是折线段拼接的,拿到机床上走了一圈,轮廓边缘全是密密麻麻的折痕,根本没法用。后来才真正明白,CAD多段线本身就支持圆弧段,每个顶点后面藏着一个叫“凸度(Bulge)”的参数,只要把它算对,直线和圆弧可以共存于同一条多段线里。这篇文章就把我验证过的思路、计算过程和完整代码整理出来,适合正在做CAD插件开发、图纸自动化生成、加工路径输出这类工作的朋友直接参考。
1. 为什么“带圆弧的多段线”值得单独研究
1.1 一次不多见的真实需求
很多图纸里的轮廓,并不是规则的矩形或圆形,而是由长短不一的直线段和圆弧段交替组成的,比如道路中心线、施工围挡的转角、钣金件的展开边界,甚至打印路径里也会出现“走一段直线、转一个圆角、再走一段直线”的情况。用单独的直线图元加圆弧图元也能画出同样形状,但在业务逻辑里会非常麻烦:你要维护两种实体、要分别设置图层和线型,后续做裁剪、偏移、面积统计时还要来回切换处理。
如果能把整条轮廓做成一条多段线,一个实体就把直线和圆弧都装进去,管理成本能低一个量级。这也是CAD的复合线设计初衷:不要用一堆折线去逼近圆弧,而是在图元内部直接记录“这一段其实是个圆弧”。在.NET接口里实现这个能力,核心就是理解凸度参数,而不是硬拼顶点。
1.2 顶点和凸度:一节更比一节强
多数人对多段线的理解是“Polyline = 一串坐标点”,这个理解对,但不完整。在CAD的复合线数据结构里,每个顶点除了坐标,还附带一个凸度值,这个值决定了“从当前顶点到下一个顶点”之间的线段形态。
- 凸度为0:这一段就是普通直线;
- 凸度不为0:这一段是圆弧,凸度的绝对值大小决定圆弧的弯曲程度,正负决定弯曲方向。
所以,一条带圆弧的多段线,本质上仍然是一串顶点,只是其中某些顶点后面挂了一个非零的小数。你可以把凸度理解成“弦上的拱高比例”:它记录的是弧线偏离弦的幅度。这个设计非常巧妙,直线段不需要额外参数,圆弧段也只是多一个浮点数,数据量很小,解析也方便,选择、预览、文件存储都能统一处理。
1.3 为什么CAD要用“凸度”而不是直接存半径和圆心
很多人会问:为什么不直接存圆心坐标、半径、起始角、终止角?这样不是更精确吗?
从几何表达上看,圆心加半径的信息确实更完整,但多段线的设计目标并不是成为通用的圆弧存储格式,而是快速、紧凑地描述“沿着一条路径走的轨迹”。凸度方案有几个明显优势:第一,不改变顶点数组的主干结构,所有段都用统一方式引用;第二,从当前顶点到下一个顶点的局部信息是连续的,程序遍历时不需要频繁切换到别的坐标系;第三,做偏移、倒角、延伸这类操作时,圆弧段的处理逻辑可以收敛在“顶点+凸度”这个局部问题上,算法链路更短。
代价就是:计算凸度需要一点三角公式,这也是很多开发者在实战中卡壳的地方。只要把这个公式吃透,后面所有绘制圆弧多段线的场景都可以一通百通。
2. 先搞懂凸度计算的核心知识
2.1 在.NET接口里创建一条基本多段线
在开始计算凸度之前,先把多段线的创建骨架写出来。CAD的托管API里,多段线通常用对应的Polyline类表示,核心操作是往里面加顶点。
// 命名空间按你实际引用的 CAD 托管程序集调整 using CAD.DatabaseServices; using CAD.Geometry; public static ObjectId AddPolylineToModelSpace(Database db) { ObjectId plineId; using (var tr = db.TransactionManager.StartTransaction()) { // 打开块表,写入模型空间 var bt = (BlockTable)tr.GetObject(db.BlockTableId, OpenMode.ForRead); var ms = (BlockTableRecord)tr.GetObject( bt[BlockTableRecord.ModelSpace], OpenMode.ForWrite); var pline = new Polyline(); // 添加顶点:坐标 + 凸度 + 起点线宽 + 终点线宽 pline.AddVertexAt(0, new Point2d(0, 0), 0, 0, 0); pline.AddVertexAt(1, new Point2d(100, 0), 0, 0, 0); // 设置全局属性和闭合方式 pline.Closed = false; pline.ColorIndex = 1; ms.AppendEntity(pline); tr.AddNewlyCreatedDBObject(pline, true); tr.Commit(); plineId = pline.ObjectId; } return plineId; }这段代码里,AddVertexAt的第三个参数就是凸度。注意它接收的是二维坐标,因为多段线的工作平面默认是在当前坐标系下展开的,三维坐标通常需要先投影处理。
2.2 凸度的计算公式:为什么是θ除以4
凸度的正式定义可以写成:
凸度 = tan(θ / 4)
这里的θ是圆弧的包含角,也就是弧线从起点到终点所扫过的角度,单位是弧度。之所以出现4,是因为这个公式的来源和“矢高”相关:凸度也等于“弧线矢高 ÷ 半弦长”,化简之后恰好是四分之一圆心角的正切值。
推导关系大概是这样的:设半径为R,弦长度为L,包含角为θ,那么弦长 L = 2R·sin(θ/2),矢高 h = R - R·cos(θ/2)。矢高与半弦长之比为:
h / (L/2) = [R(1 - cos(θ/2))] / [R·sin(θ/2)] = (1 - cos(θ/2)) / sin(θ/2)
这个式子正好等于 tan(θ/4)。所以:
凸度 = 矢高 / 半弦长 = tan(θ/4)这个公式是所有圆弧多段线绘制的基石。如果你拿到的是圆心、半径、起始角度和终止角度,第一步就是求出包含角θ,然后带入上式。已知弧的扫过角后,辅助函数可以这样写:
public static double BulgeFromAngle(double includedAngleRadians) { return Math.Tan(includedAngleRadians / 4.0); }同理,如果已知凸度,想反推包含角,就用:
public static double AngleFromBulge(double bulge) { return 4.0 * Math.Atan(bulge); }2.3 正负号决定了圆弧弯向哪一边
凸度是否为零决定这节是不是圆弧,而正负号决定圆弧在弦的哪一侧鼓起。在CAD默认坐标系下,一般约定:正凸度产生逆时针方向的圆弧,负凸度产生顺时针方向的圆弧。换成人话就是,沿着多段线的行进方向看,正凸度让弧线弯向左侧,负凸度让弧线弯向右侧。
举个例子:如果从(0,0)走到(100,0),凸度为0.41421356,那么弧线会向上弯;如果凸度取负,则弧线向下弯。方向错了,图纸上就会出现本该外凸轮廓变成内凹的情况,所以写代码时一定要把符号规则记在心上。
做个辅助函数封装符号,会更稳妥:
public static double BulgeFromArc(bool isClockwise, double includedAngleRadians) { double b = Math.Tan(includedAngleRadians / 4.0); return isClockwise ? -b : b; }注意:不同CAD版本、不同法线方向的情况下,符号解释可能有细微差异,最稳妥的做法是先在自己当前图纸环境里画一条已知方向的圆弧,验证一下你的符号约定,再写批量逻辑。
2.4 已知起点、终点和半径时怎么算凸度
实际开发中,更多情况是只给两个端点和半径,让你生成圆弧段。这时候不能直接拿“半径”当凸度,得先算包含角。
设弦长为L,半径为R,包含角θ满足:
L = 2R·sin(θ/2)
所以 θ = 2·asin(L / (2R))。但这里有一个坑:同样的弦长和半径,可能对应劣弧(小于180度)和优弧(大于180度)两种情况。系统如果默认返回小角度,你就只能得到短弧。因此通常需要增加一个参数,比如“取优弧”或“取劣弧”,来告诉程序按哪个方向转。
具体代码如下:
public static double BulgeFromRadius( Point2d startPt, Point2d endPt, double radius, bool majorArc) { double chordLength = startPt.GetDistanceTo(endPt); double halfAngle = Math.Asin( Math.Min(1, chordLength / (2.0 * Math.Abs(radius)))); double included = majorArc ? 2.0 * Math.PI - 2.0 * halfAngle : 2.0 * halfAngle; return Math.Tan(included / 4.0); }当radius < 弦长/2 时,Math.Asin的参数会大于1,所以要限制在1以内,否则函数会抛错。这个细节容易在跑批量数据时炸出来,务必加上。
3. 实操:画一条真正带圆弧的多段线
3.1 准备一个批量添加顶点的工具函数
既然多段线的核心操作是AddVertexAt,我习惯先封装一个简单的成员函数,把“坐标 + 凸度”成对写入,减少重复代码:
private Polyline _pline; public void AddSegment(Point2d pt, double bulge) { int idx = _pline.NumberOfVertices; _pline.AddVertexAt(idx, pt, bulge, 0, 0); }这样在创建复杂轮廓时,代码可读性会好很多,每一行对应几何上的一个顶点。
3.2 用四分之一圆弧段拼接圆角轮廓
下面给一个可以直接运行的示例:画一个U形轮廓。这个轮廓包含底部直线、右侧圆弧、直线、上部圆弧、直线,最后闭合。
几何路径如下:
- 从(0,0)直线走到(60,0);
- 用90度圆弧过渡到(70,10);
- 从(70,10)直线走到(70,50);
- 用90度圆弧过渡到(60,60);
- 从(60,60)直线走到(0,60);
- 闭合,最后一段是从(0,60)回到(0,0)的直线。
90度包含角对应的凸度:
tan(90°/4) = tan(22.5°) ≈ 0.41421356代码实现:
using CAD.DatabaseServices; using CAD.Geometry; public static void CreateUShape(Database db) { using (var tr = db.TransactionManager.StartTransaction()) { var bt = (BlockTable)tr.GetObject(db.BlockTableId, OpenMode.ForRead); var ms = (BlockTableRecord)tr.GetObject( bt[BlockTableRecord.ModelSpace], OpenMode.ForWrite); var pline = new Polyline(); const double bulgeQuarter = 0.41421356; pline.AddVertexAt(0, new Point2d(0, 0), 0, 0, 0); pline.AddVertexAt(1, new Point2d(60, 0), bulgeQuarter, 0, 0); pline.AddVertexAt(2, new Point2d(70, 10), 0, 0, 0); pline.AddVertexAt(3, new Point2d(70, 50), bulgeQuarter, 0, 0); pline.AddVertexAt(4, new Point2d(60, 60), 0, 0, 0); pline.AddVertexAt(5, new Point2d(0, 60), 0, 0, 0); pline.Closed = true; ms.AppendEntity(pline); tr.AddNewlyCreatedDBObject(pline, true); tr.Commit(); } }这段代码画出来的闭合多段线,底边和左边是直线段,右上、右下两个角是标准90度圆弧,完全不需要单独添加圆弧图元,是不是比想象中简单?
3.3 已知圆弧上任意三点,反算凸度
有时候,需求给出的不是半径,而是“从A点经过P点再到B点的一段弧”,这时候就需要用三点定圆。先通过三个点求圆心,再求包含角和凸度。
三点定圆的常见做法是利用垂直平分线,我一般直接用公式法:
public static Point2d CircleCenter(Point2d a, Point2d b, Point2d c) { double ax = a.X, ay = a.Y; double bx = b.X, by = b.Y; double cx = c.X, cy = c.Y; double d = 2 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by)); if (Math.Abs(d) < 1e-12) return Point2d.Origin; // 三点共线,不构成圆 double aLen = ax * ax + ay * ay; double bLen = bx * bx + by * by; double cLen = cx * cx + cy * cy; double ux = (aLen * (by - cy) + bLen * (cy - ay) + cLen * (ay - by)) / d; double uy = (aLen * (cx - bx) + bLen * (ax - cx) + cLen * (bx - ax)) / d; return new Point2d(ux, uy); }求出圆心后,计算从圆心到A、B两条射线的夹角,再用叉积判断是顺时针还是逆时针,最后转换成凸度:
public static double BulgeFrom3Points(Point2d a, Point2d b, Point2d p) { Point2d center = CircleCenter(a, b, p); double r = a.GetDistanceTo(center); double included = Math.Abs( AngleOf(center, b) - AngleOf(center, a) ); included = Math.Min(included, 2 * Math.PI - included); double cross = (b.X - a.X) * (p.Y - a.Y) - (b.Y - a.Y) * (p.X - a.X); bool isClockwise = cross < 0; double bulge = Math.Tan(included / 4.0); return isClockwise ? -bulge : bulge; } static double AngleOf(Point2d center, Point2d pt) { return Math.Atan2(pt.Y - center.Y, pt.X - center.X); }这段代码里用p点的位置判断弧的方向,思路是:p在弦AB的哪一侧,弧就弯向哪一侧。这里有一个需要特别注意的点:如果三点对应的是优弧,直接用“取小角度”的逻辑会得错结果。业务里如果明确要优弧,请自行调整included的取值逻辑,这是我认为全篇最容易出错的地方。
3.4 从已有圆弧对象转换为多段线
还有一种高频场景:程序里已经有圆弧对象,希望把它作为一段并进多段线。这时不需要手工重画,只需要读取圆弧的起终点和角度扫过值。
假设现在的圆弧对象提供StartAngle、EndAngle、StartPoint、EndPoint这几个属性,转换逻辑可以这样写:
public static void AppendArcToPolyline(Polyline pline, Arc arc, bool isClockwise) { double sweep = arc.EndAngle - arc.StartAngle; // 如果扫过角超过π,说明跨过0度周期,需要修正 while (sweep < 0) sweep += 2 * Math.PI; if (isClockwise) sweep = 2 * Math.PI - sweep; double bulge = Math.Tan(sweep / 4.0); int idx = pline.NumberOfVertices; pline.AddVertexAt(idx, new Point2d(arc.StartPoint.X, arc.StartPoint.Y), isClockwise ? -bulge : bulge, 0, 0); pline.AddVertexAt(idx + 1, new Point2d(arc.EndPoint.X, arc.EndPoint.Y), 0, 0, 0); }实际项目中,圆弧起终点可能是三维坐标,需要根据多段线的法线方向做投影,这个后面在问题排查里讲。
3.5 圆角矩形的完整示例
最后给一个圆角矩形例子,这是CAD图里最常见的造型之一。假设矩形左下角为(0,0),宽度100,高度80,圆角半径20。顺时针方向绘制,四个角都是90度圆弧。
先确定圆角矩形的关键顶点:
- 从(20,0)直线到(80,0);
- 圆弧从(80,0)到(100,20),凸度+0.41421356;
- 直线从(100,20)到(100,60);
- 圆弧从(100,60)到(80,80),凸度+0.41421356;
- 直线从(80,80)到(20,80);
- 圆弧从(20,80)到(0,60),凸度+0.41421356;
- 直线从(0,60)到(0,20);
- 圆弧从(0,20)到(20,0),凸度+0.41421356;
- 最后闭合。
代码如下:
public static void CreateRoundedRect(Database db) { using (var tr = db.TransactionManager.StartTransaction()) { var bt = (BlockTable)tr.GetObject(db.BlockTableId, OpenMode.ForRead); var ms = (BlockTableRecord)tr.GetObject( bt[BlockTableRecord.ModelSpace], OpenMode.ForWrite); var pline = new Polyline(); const double b = 0.41421356; // 90度圆弧 pline.AddVertexAt(0, new Point2d(20, 0), 0, 0, 0); pline.AddVertexAt(1, new Point2d(80, 0), b, 0, 0); pline.AddVertexAt(2, new Point2d(100, 20), 0, 0, 0); pline.AddVertexAt(3, new Point2d(100, 60), b, 0, 0); pline.AddVertexAt(4, new Point2d(80, 80), 0, 0, 0); pline.AddVertexAt(5, new Point2d(20, 80), b, 0, 0); pline.AddVertexAt(6, new Point2d(0, 60), 0, 0, 0); pline.AddVertexAt(7, new Point2d(0, 20), b, 0, 0); pline.Closed = true; ms.AppendEntity(pline); tr.AddNewlyCreatedDBObject(pline, true); tr.Commit(); } }注意,这里我并没有把起点重复加到末尾,而是用Closed = true让多段线自动闭合。闭合后,从最后一个顶点回到第一个顶点的这段,使用的是最后一个顶点的凸度。上面最后一段的起点是(0,20),它的凸度是b,所以闭合段正好是(0,20)到(20,0)之间的圆弧,效果和前面四个角完全一致。这里有一个先决条件:闭合段的圆心与圆弧方向要符合你的预期,写完后一定要在图纸里放大确认一下。
4. 实际开发中容易踩的坑和排查技巧
4.1 圆弧方向不对,画出来内外颠倒
这是最典型的错误。明明算出的凸度插进去了,图纸上圆弧却弯到了弦的另一侧。原因几乎都是正负号取反。
排查方法:先画一条只有两个顶点的多段线,第一个顶点在(0,0),第二个顶点在(100,0),分别用凸度0.414和-0.414跑两遍。正凸度是向上弯还是向下弯,立刻就能看出来。确认好符号规律后,再去看批量逻辑里是不是统一做了取反。
4.2 半圆和大角度圆弧的“凸度爆炸”
半圆的包含角是180度,代入公式:
tan(180°/4) = tan(45°) = 1所以半圆的凸度是1,不是无限大。更大的角度呢?我们看下面的对照表:
| 包含角度 | 凸度值 |
|---|---|
| 45度 | 0.1989 |
| 90度 | 0.4142 |
| 120度 | 0.5774 |
| 180度 | 1.0000 |
| 240度 | 1.7321 |
| 270度 | 2.4142 |
| 300度 | 3.7321 |
| 360度 | 无穷大 |
凸度越大,弧线越“鼓”。当包含角接近360度时,凸度会趋向无穷大,所以单纯一条多段线段表达“完整圆”是不现实的,实际软件也不会让你这么干。遇到完整圆,要么直接用圆对象,要么拆成三段或四段圆弧多段线。在批量处理中,如果数据源给的是接近360度的圆弧,记得加一个角度阈值,超过预设值就拆段处理。
4.3 闭合多段线的最后一段是隐形的
很多人用多了AddVertexAt之后,会习惯性地把起点坐标再添加到末尾,再设置Closed。结果一检查,图上是正确的,但顶点列表里多出一个零长度顶点,个别模块做数据统计时会把它判定成异常点。
正确做法是:如果使用闭合属性,就不要在末尾重复添加起点;如果手动添加了重复起点,就不要设置Closed。两者选其一,否则多段线的封闭段逻辑会非常混乱。而且闭合段的凸度使用的是最后一个顶点的凸度,这一点在上一节的圆角矩形成功案例中体现得很明显:你把最后一段设计成圆弧时,只需要保证最后一个顶点的凸度是目标值。
4.4 三维坐标和当前坐标系不匹配
AddVertexAt接收的是平面坐标,当业务系统里给的是三维点,比如标高不同的空间点,不能直接丢进去。多段线有自己的法线方向,通常需要先把三维点转换到多段线所在的局部坐标系,再提取平面分量。
具体做法是:先创建两个三维点,计算它们的差向量,然后投影到多段线的平面上。最省事的方案是在事务里创建一个坐标系变换矩阵,用点变换函数把三维点转成当前用户坐标系下的点,再取X、Y分量。如果你只是简单做二维图纸,可以忽略这个坑;一旦开始做三维管线、建筑立面图,就必须提前设计好坐标转换函数。
4.5 批量生成多段线时的性能问题
有些工具需要一次生成上千条多段线。很多人喜欢在循环里反复开启和提交事务,这个写法不仅慢,还会造成内存和数据库锁开销。正确的做法是:打开一个事务,把要写进数据库的全部实体追加进去,最后统一提交一次。
另外,如果只是修改现有多段线的某个凸度,优先使用SetBulgeAt方法,不需要整条重建。批量操作前先把所有要改的顶点位置和凸度计算好,减少数据库访问次数,实测下来性能提升会很明显。
最后再分享两个小技巧
第一个技巧:验证凸度是否正确最直接的方法,是在画完之后用鼠标选中多段线,再打开属性面板看“顶点/凸度”列表。如果某一段显示不是直线,属性面板通常能直接读出该段的弧线信息,哪个顶点编号对应哪段弧一目了然。
第二个技巧:复制一份代码片段,把凸度计算函数单独抽到一个公共类里,写成纯静态方法,不依赖任何CAD界面对象。这样以后在控制台测试、单元测试里也能直接调用,不需要每次跑到图纸里看结果。我用这个方式重构过好几套生成工具,配合一组固定测试点,基本能做到离线验证凸度结果。这看着只是一个不起眼的封装习惯,但在项目后期能给你省大量排查时间。