最近在调一个路径规划小工具,需要在图纸里把“一段直线、一段圆弧、再接一段直线”的走线画出来。一开始图省事,直接往模型空间塞了一条直线加一条圆弧,后来要倒圆角、偏移、统计工程量,发现这种“散装”画法到处给自己制造麻烦。最后还是回到用CAD .NET托管API去画带圆弧的多段线(Polyline),把每一段圆弧的凸度(Bulge)算清楚,一次成型。
这篇文章就把整个过程记录下来,包括Bulge到底是怎么来的、怎么根据半径和起终点换算、代码怎么写、以及我实际画图时踩过的几个坑。适合正在做CAD二次开发、尤其被Polyline的Bulge概念绕晕的朋友。如果你只是偶尔用脚本批量生成图纸,这篇文章同样能让你少走弯路。
1. 为什么说“带圆弧的多段线”才是正确画法
1.1 图纸里少放一个实体,后面就少写一堆逻辑
很多人刚接触CAD二次开发时,看到“一段直线接一段圆弧”,第一反应是用Line加Arc两个实体拼出来。这样做不是不能画,但后续所有操作都会变重。
举个例子:一条输送路径由“直线-圆弧-直线”组成。用散装图元画,图纸里就有三个实体,选中路径时得把三个实体一起选,遍历图元时得判断哪条线跟哪条弧相连,统计总长度时得分别取Line.Length和Arc.Length再相加。改成一条带圆弧的多段线后,这些都是同一个实体,一次遍历、一次统计全部搞定。
实际工程里更麻烦的是后续修改。圆弧的一端挪动了,Arc实体的圆心和角度要重新算;而多段线只需要移动顶点,圆弧段会根据新顶点自动重算。底下的对照表是我自己项目里的真实体验:
| 对比项 | 散装Line + Arc | 带圆弧的Polyline |
|---|---|---|
| 图元数量 | 每段至少1个,多段就是多个 | 始终1个 |
| 顶点编辑 | 圆弧端点移动后要重建弧 | 拖动顶点后圆弧自动更新 |
| 面积统计 | 需手动组合或构造面域 | 可直接取Area属性 |
| 偏移和倒角 | 三种实体要分开处理 | 一个实体统一处理 |
| 二次开发遍历 | 需判断线弧连接关系 | 直接读顶点和凸度 |
1.2 一条曲线“转回来”的能力,才是最值钱的地方
做设计时经常要临时改方案。我遇到过方案调整,要求把路径某一段的圆弧半径从50改成80。散装图元的做法是删掉旧弧、算切点、再画新弧,稍不注意切点位置就对不上。多段线则简单得多:把对应顶点的凸度重新算一下,一行代码就改完。
另一个关键点是曲线转换。CAD里很多命令都只认多段线,比如倒角、圆角、生成面域、沿曲线阵列等。如果你的图元是散的,就得先花时间合并成一条多段线,而合并的前提又要求相邻图元端点严格重合。实际图纸里经常出现端点差0.001的尴尬局面,合并不上。直接用带圆弧的多段线,完全绕开了这个问题。
当然,多段线也不是万能的。它本质上是平面内的二维曲线,如果要画三维空间里的任意曲线,就应该用三维多段线或样条曲线。文章后面的例子默认画在WCS的XY平面上,这点先讲清楚。
2. Bulge不是弧度,也不是弦高,而是tan(圆心角/4)
2.1 多段线里的圆弧,是“顶点加凸度”二元组
在CAD的多段线数据模型里,每段线要么是直线,要么是圆弧。区分两者的关键字段就是凸度Bulge。
一个顶点上存一个Bulge值,它表示“从这个顶点到下一个顶点”这一段线的弯曲程度。Bulge为0表示直线;Bulge不为0表示圆弧,正负表示圆弧的走向。注意,这里说的“凸度”不是弧度,也不是弦高,而是一个比值。
这个比值用几何语言描述是:圆弧的弓高h除以半弦长a。也就是:
Bulge = 弓高 / 半弦长 = h / a弓高是弧顶点到弦的垂直距离。这个定义很直观:弧越是“鼓起来”,弓高越大,凸度越大;弧越平,凸度越接近0。
2.2 从等腰三角形推导,这个公式其实很好记
为什么要用“弓高/半弦长”而不是直接存角度?因为CAD的早期数据格式需要用一个简单比值来存,同时在计算中还保留了角度信息。这个比值恰好等于tan(圆心角/4)。
推导并不复杂。设一段圆弧的起点为A,终点为B,圆心为O,弦AB的中点为M。因为三角形OAB是等腰三角形,OM垂直平分AB。设圆心角∠AOB为θ,那么∠AOM就是θ/2。
在直角三角形OAM里,有:
tan(θ/2) = AM / OM = 半弦长 / 弦心距同时弓高h等于半径R减去弦心距OM。用三角函数的半角公式可以推出:
h / AM = tan(θ/4)所以Bulge = tan(θ/4)。这里θ的单位是弧度,且是有符号的。从当前顶点到下一顶点,如果圆弧绕圆心逆时针走,θ为正;顺时针走,θ为负。
2.3 常用角度对应的Bulge值,建议直接背下来
实际开发中经常要快速手算凸度,我整理了一张常用表:
| 圆弧角度 | Bulge值 |
|---|---|
| 0° | 0 |
| 45° | tan(11.25°) ≈ 0.199 |
| 90° | tan(22.5°) ≈ 0.414 |
| 120° | tan(30°) ≈ 0.577 |
| 180° | tan(45°) = 1 |
| 270° | tan(67.5°) ≈ 2.414 |
几个容易记的节点:
- 90度圆弧,
Bulge不是0.5,而是约0.414。 - 半圆,
Bulge是1,不是0.5。 - 超过180度的圆弧,
Bulge大于1,可以达到几甚至几十,都很正常。
3. 实际画图前,先把几何参数换成顶点坐标和凸度
3.1 从设计图到多段线,要经历两步换算
拿到一张带圆弧的路径图,第一步不是写代码,而是把设计参数翻译成多段线能理解的顶点坐标和凸度序列。
多段线的数据结构是:按顺序排列的Point2d顶点数组,以及和顶点一一对应的Bulge数组。第i个顶点的Bulge表示第i段弧,也就是从顶点i到顶点i+1这一段。最后一段如果闭合,则由最后一个顶点指向第一个顶点。
我自己的习惯是先把路径拆解成一张表:
| 段号 | 起始点 | 结束点 | 类型 | 圆心/半径/角度参数 |
|---|---|---|---|---|
| 0 | (0,0) | (100,0) | 直线 | bulge=0 |
| 1 | (100,0) | (150,50) | 圆弧 | 圆心(100,50),逆时针90° |
| 2 | (150,50) | (150,150) | 直线 | bulge=0 |
把这表列完,代码就只是翻译工作了。
3.2 已知圆心角时,直接算凸度
最理想的情况是设计图纸上直接标了圆心角。比如一段顺时针90度圆弧,圆心角θ等于-π/2,那么Bulge就是:
double bulge = Math.Tan(-Math.PI / 4); // 即 -0.414如果弧是优弧,比如从起点到终点绕了270度,那么θ等于-3π/2,Bulge就是Math.Tan(-3 * Math.PI / 8),大约-2.414。这个值足够大,但仍然合法。
3.3 已知半径和起终点时,先定位圆心再算角度
更常见的场景是:知道圆弧起点A、终点B和半径R,但没直接给圆心角。这时要先通过几何关系求出圆心。
设弦长为L,半弦长a = L/2。弦心距d为圆心到弦AB的垂直距离,由勾股定理可得:
d = sqrt(R² - a²)圆心O在弦AB的中垂线上,距离中点为d。中垂线方向向量是弦方向向量逆时针旋转90度得到的法向量,记为n。于是两个可能的圆心分别是:
O1 = 中点 + n * d O2 = 中点 - n * d选哪个圆心,取决于圆弧朝哪一侧凸。选定圆心后,用atan2计算圆心到A、B的方向角,再求差值。
我写了一个辅助函数,核心思路是先求带符号圆心角,再转凸度:
// 已知起点、终点、圆心,求该段圆弧对应的Bulge // majorArc为true表示要画优弧(大于180度) static double BulgeFromCenter(Point2d start, Point2d center, Point2d end, bool majorArc) { double a0 = Math.Atan2(start.Y - center.Y, start.X - center.X); double a1 = Math.Atan2(end.Y - center.Y, end.X - center.X); double delta = a1 - a0; // 归一化到 (-π, π],得到短弧对应的有向圆心角 while (delta > Math.PI) delta -= 2 * Math.PI; while (delta < -Math.PI) delta += 2 * Math.PI; // 如果实际走优弧,补到另一个方向 if (majorArc) delta = (delta >= 0) ? delta - 2 * Math.PI : delta + 2 * Math.PI; return Math.Tan(delta / 4.0); }这里我一直强调“带符号”。因为Bulge的正负必须和前进方向一致:从当前顶点走向下一个顶点,如果圆弧绕圆心逆时针为正,顺时针为负。稍不注意就会画反,第5章我会详细讲这个坑。
4. 用CAD .NET托管API画一条完整的闭合多段线
4.1 完整示例:直线、右上角圆弧、直线、左上角圆弧、闭合
下面这段代码可以在模型空间画出一个闭合轮廓:底边直线、右上角90度逆时针圆弧、顶边直线、左上角90度顺时针圆弧、最后直线闭合。为了简洁,省略了命名空间引用,对应的命名空间在CAD托管API里叫DatabaseServices和Geometry。
// 省略命名空间引用,对应CAD托管API的DatabaseServices和Geometry Database db = HostApplicationServices.WorkingDatabase; using (Transaction tr = db.TransactionManager.StartTransaction()) { BlockTable bt = (BlockTable)tr.GetObject(db.BlockTableId, OpenMode.ForRead); BlockTableRecord btr = (BlockTableRecord)tr.GetObject( bt[BlockTableRecord.ModelSpace], OpenMode.ForWrite); Polyline pl = new Polyline(); // 第一步:先把所有顶点按顺序加入 pl.AddVertexAt(0, new Point2d(0, 0), 0, 0, 0); pl.AddVertexAt(1, new Point2d(100, 0), 0, 0, 0); pl.AddVertexAt(2, new Point2d(150, 50), 0, 0, 0); pl.AddVertexAt(3, new Point2d(150, 150), 0, 0, 0); pl.AddVertexAt(4, new Point2d(100, 200), 0, 0, 0); pl.AddVertexAt(5, new Point2d(0, 200), 0, 0, 0); // 第二步:再逐段设置凸度 pl.SetBulgeAt(0, 0); // 第0段:直线 pl.SetBulgeAt(1, Math.Tan(Math.PI / 8)); // 第1段:90度逆时针弧,约0.414 pl.SetBulgeAt(2, 0); // 第2段:直线 pl.SetBulgeAt(3, -Math.Tan(Math.PI / 8)); // 第3段:90度顺时针弧,约-0.414 pl.SetBulgeAt(4, 0); // 第4段:直线 pl.SetBulgeAt(5, 0); // 第5段:闭合段直线 pl.Closed = true; btr.AppendEntity(pl); tr.AddNewlyCreatedDBObject(pl, true); tr.Commit(); }这段代码执行后,模型空间里会多出一条闭合多段线。用鼠标点选时只有一个对象,用LIST命令查看时能看到顶点和凸度信息。
4.2 为什么我坚持“先加顶点,后设凸度”
很多老示例代码直接在AddVertexAt里传非0的凸度参数,比如:
pl.AddVertexAt(1, new Point2d(100, 0), 0.5, 0, 0);这个写法不是不行,但容易让人误解。AddVertexAt的第三个参数是“新顶点的凸度”,指的是新顶点到下一个顶点的凸度。如果你在末尾加顶点,后面没有“下一个顶点”,这个凸度就被丢掉了。
踩过这个坑之后,我给自己定了一个规矩:第一步只加顶点,凸度全部传0;第二步再用SetBulgeAt逐个设置。这样代码的语义一目了然,也不会因为顶点顺序调整导致凸度错位。
另外要注意,对非闭合多段线,最后一个顶点的凸度实际上不会生效,因为不存在“最后顶点到下一顶点”的线段。只有把Closed设为true后,最后一个顶点到第一个顶点的闭合段才会用到最后一个顶点的凸度。
4.3 画完如何验证:读回顶点和凸度
画完之后最好立刻验证一下,不要等到打印图纸才发现方向反了。我用得最简单的验证方法是遍历多段线,把顶点和凸度打印到命令行:
Editor ed = Application.DocumentManager.MdiActiveDocument.Editor; for (int i = 0; i < pl.NumberOfVertices; i++) { Point2d pt = pl.GetPoint2dAt(i); double bulge = pl.GetBulgeAt(i); ed.WriteMessage($"\n顶点{i}: ({pt.X:F1}, {pt.Y:F1}), Bulge={bulge:F4}"); }这段代码能帮我快速定位哪一段的凸度算错了。特别是圆弧方向反了时,正负号一眼就能看出来。
5. 画完之后的实测与踩坑记录
5.1 方向陷阱:圆心在弦上侧,凸度不一定是正
这是新人最容易翻车的点。以起点(0,0)、终点(100,0)为例,如果圆心在弦上方比如(50,50),画出来的圆弧是向下凸的劣弧。从起点沿弧走到终点,绕圆心的方向是逆时针,所以凸度应为正。
很多人会凭直觉认为“圆心在上方,弧在上方”,然后画出反向弧。正确判断方法是:始终站在圆心上,观察从起点向量转到终点向量的方向。逆时针为正,顺时针为负。
我在代码里遇到方向不对时,第一反应就是检查atan2求出的圆心角差值有没有做归一化。差值大于π或小于-π时,说明取的不是同一圈上的角。
5.2 半圆凸度是1,270度凸度是2.414
半圆对应的Bulge是1,这个数太容易记成0.5了。如果用0.5去画半圆,实际画出来的弧只有大约53度,不是半圆。
我后来把常用角度的凸度做成常量写在工具类里,避免每次手算:
public const double Bulge90 = 0.4142135623730951; public const double Bulge180 = 1.0; public const double Bulge270 = 2.414213562373095;超过180度的圆弧完全可以用一个凸度表达,不需要拆段。比如270度弧的凸度约2.414,合法且稳定。千万不要看到凸度大于1就以为写错了。
5.3 闭合轮廓的最后一段圆弧,很容易漏
画闭合多段线时,最后一段回到起点的圆弧常常被忽略。假设一个矩形轮廓,想让右边那条边变成向内凹的半圆,实际上需要修改的是“最后一个顶点到第一个顶点”这一段。
具体做法是:先确保pl.Closed = true,然后对最后一个顶点调用SetBulgeAt(pl.NumberOfVertices - 1, 1.0)。很多人在闭合前把最后一个顶点的凸度设为0,闭合后才发现那条边是直线。
5.4 实体没显示,不一定是没画上去
有那么几次,执行完代码后屏幕上什么都没有,第一反应是实体没写进数据库。排查到最后发现,实体已经写进去了,只是视图没有刷新。
最简单的处理是提交事务后调用一次视图更新命令,或者手动在命令行执行REGEN。当然,更多时候是块表记录写错了:把实体加到了当前空间以外的地方。检查一下你取到的BlockTableRecord是不是模型空间的记录,这是新手高频错误。
5.5 多段线终归是二维的,别拿去画三维弧
Polyline本质上是在某个平面坐标系内表达的二维曲线,即使不设置标高,默认也在WCS的XY平面内。如果项目里有大量三维管道走向,需要的是三维多段线或者其他三维曲线类型。
我犯过的错误是试图用二维多段线去模拟空间折弯,结果画出来的全部被压平在一个平面上。后来规规矩矩按三维曲线处理,反而简单了。
6. 从“能画”到“好用”:几个可以直接扩展的方向
6.1 把已有圆弧转成带圆弧的多段线
有时候用户给的图纸里已经有大量Arc实体,需要统一转成多段线。读取Arc的起点角度、终点角度、半径和圆心后,可以计算圆心角θ,再生成顶点和凸度。
要注意的是,如果圆弧本身就是个整圆,圆心角是2π,直接算凸度会无穷大,需要拆成两条半圆多段线。其他弧段无论优弧劣弧,都可以用一个凸度表达。
6.2 给折线自动倒圆角
这套思路还能用来做折线圆角。给定三个顶点P0、P1、P2,要在转角处插入一段圆弧。先根据圆角半径求出圆弧与两条直线的切点T0、T1,两个切点连起来就是多段线的新顶点,中间那段圆弧的凸度由转角算出。
我实践中发现这个功能在道路中心线、管线转弯处理里非常实用,批量处理比手动倒圆角快很多。
6.3 批量坐标文件导入
如果有一批坐标数据要生成带圆弧路径,可以约定CSV格式,每行是一列x、y和凸度,然后循环调用AddVertexAt和SetBulgeAt。这样原来的手工作业就变成了批量批处理,出错率也大幅下降。
我自己固定使用这种工作流:先在Excel里把路径拆成顶点表和凸度表,再让工具读取生成,最后人工抽查几个关键点坐标。这样既能保证效率,又不会盲目相信脚本结果。
几次踩坑之后,我现在画带圆弧的多段线已经形成固定习惯:先在纸上列出每段起始点、终止点、圆心角,再用常用角度凸度表做一次心算校验,最后才写代码。坐标可以在模型空间里用临时辅助线检查,确认无误后再批量生成正式图元。这样虽然多花几分钟,但基本不会返工。