做DXF解析做了几年最让人头疼的往往不是复杂的块表、标注、填充反而是那种看起来人畜无害的轻量多段线LWPOLYLINE顶点坐标读出来了直线段画得挺好一碰到圆弧就全乱套。尤其当你看到坐标后面跟着一个42组码后面写着0.5、1甚至-0.4142这种数字时很容易直接懵掉——它到底代表半径还是角度要不要转弧度转换公式又是什么这篇文章就把LWPOLYLINE多线段凸度bulge这个概念彻底拆开从 DXF 里的存储位置、几何定义到弧度转换、半径圆心重建、实际代码解析一步步讲清楚。适合正在写 DXF 解析器的开发者也适合只想把图纸里的圆弧正确显示到自研工具里的朋友。1. LWPOLYLINE的存储结构42组码才是圆弧的“暗号”1.1 一个带弧的LWPOLYLINE实体长什么样一张 DXF 文件本质上就是一个“组码/值”交替出现的文本流。LWPOLYLINE并不是像POLYLINE那样拆成一堆子实体而是把顶点直接内嵌在实体记录里。看一下最简单的两顶点圆弧示例0 LWPOLYLINE 5 2A 100 AcDbEntity 100 AcDbPolyline 90 2 70 0 10 0 20 0 42 1 10 10 20 0 42 0这里的含义是有 2 个顶点组码90第一个顶点(0,0)第二个顶点(10,0)。第一个顶点的坐标后面跟了一个42值为1表示从第一个顶点到第二个顶点之间不是直线而是一段圆弧。重点就在这个42组码上。很多人第一次读 DXF 文档看到42以为是宽度、线宽甚至是半径实际上它是多段线凸度bulge。对一条LWPOLYLINE42不是全局属性而是跟随顶点出现的。也就是说它描述的不是某个顶点本身而是“从当前顶点到下一个顶点”这一段的样子。一个顶点如果不带42默认凸度就是0代表直线段。一旦某个顶点后面出现了42哪怕值很小这一段也变成圆弧了。1.2 和传统POLYLINE的差异为什么轻量多段线更容易踩坑老一点 DXF 版本里的POLYLINE实体结构和LWPOLYLINE完全不同。POLYLINE本身只是壳真正的顶点散落在后面的一组VERTEX子实体里读取时要用66组码判断顶点数然后一路读VERTEX直到SEQEND。而LWPOLYLINE是后来为轻量化引入的实体顶点数量、顶点坐标、凸度全部集中在一块解析效率高很多但代价是代码里必须自己数“第几个10后面有没有42”。两者有一个容易出错的地方老POLYLINE的顶点通常有明确的10、20、30组码且42出现在VERTEX中的位置比较直观而LWPOLYLINE只有10和20Z 坐标不在这里默认用38组码统一作为该实体的标高。很多刚入门的开发者按坐标三件套去解析LWPOLYLINE结果找不到30反而把后面的42当成 Z 坐标画出来自然是一堆莫名其妙的线。另外LWPOLYLINE的组码顺序并不总是规规矩矩地“每顶点都带 42”。实际文件里经常是有弧的顶点才给42直线的顶点就不写所以解析器必须把每个顶点的凸度默认初始化为0碰到42再覆盖。这个细节后面实操部分会再次强调。2. 凸度(Bulge)到底是什么从弦高到四分角的正切2.1 从一段弦和拱高推导出凸度公式假设当前顶点是 P0下一顶点是 P1两点的连线就是一条弦。如果这一段是圆弧那么弧线和弦之间会有一个拱高h。凸度的定义就是b 2h / d其中d是 P0 到 P1 的弦长h是弦中点沿垂直于弦方向到弧线的距离。这个定义看起来像是一个“高跨比”但它真正的几何意义比这深。把弦两端点和圆心连起来形成一个等腰三角形。设圆弧对应的圆心角为θ半径为R那么弦长d 2R · sin(θ / 2)弦中点到圆心的距离是R·cos(θ/2)于是拱高h R - R·cos(θ/2) R·(1 - cos(θ/2))代回b 2h/db (1 - cos(θ/2)) / sin(θ/2)利用半角公式它恰好等于b tan(θ/4)这就是凸度最初级的核心公式。所以42组码后面那个数字是“圆心角的四分之一”的正切值。它不是半径也不是圆心角本身。2.2 凸度和圆心角之间的弧度转换从b tan(θ/4)反过来求角度θ 4 · atan(b)这里的atan返回的是弧度。也就是说如果你拿到421那么对应圆心角是θ 4 · atan(1) 4 × 0.785398 ≈ 3.14159正好是 180 度即半圆弧。如果项目里统一用角度制别忘了转一下θ_deg 4 * math.degrees(math.atan(b))凸度可以大于 1 也可以小于 -1。b2.4142时对应的是 270 度的大弧。很多绘图引擎里大弧和小弧画起来是两套逻辑所以解析时不要想当然地认为圆弧最多 180 度。2.3 常见凸度值的几何对照表凸度 b圆心角 θ弧度圆心角 θ角度圆弧形态000°直线0.41421.570890°四分之一圆弧13.1416180°半圆2.41424.7124270°四分之三圆弧-1-3.1416-180°顺时针半圆正负号的意义也在这张表里体现出来了。DXF 规定凸度为正表示从当前顶点到下一顶点沿逆时针方向画弧凸度为负表示沿顺时针方向画弧。这个方向和坐标系的 Y 轴朝向有很强的关系后面我在坑里细说。3. 弧度转换与圆弧要素重建半径、圆心、起止角3.1 已知坐标和凸度求半径公式是怎么来的解析 DXF 的最终目的往往是重新画出这条弧或者把它转成 SVG、Canvas、布尔运算库能用的几何对象。只知道圆心角还不够必须重建出半径、圆心、起始角、终止角。设 P0 和 P1 之间的距离为d。因为d 2R·sin(θ/2)所以R d / (2·sin(θ/2))又因为θ 4·atan(b)令半角的一半代入sin(θ/2) sin(2·atan(b)) 2b/(1b²)整理后R d · (1 b²) / (4|b|)看到这个公式时我第一反应是半径只和弦长以及凸度绝对值有关和凸度正负无关。是的方向不影响圆本身的大小只影响取哪一半弧。这也是为什么代码里求半径时必须对b取绝对值否则负凸度会算出一个负半径。3.2 圆心和起止角的计算注意大弧(180°)的情况有了弦长d、半径R、以及“拱高”h |b|·d/2圆心位置就可以确定了。关键一步是找到 P0→P1 方向的垂直单位向量。设dx x1 - x0 dy y1 - y0 d sqrt(dx² dy²)方向单位向量是(dx/d, dy/d)。取左手垂直向量nx -dy / d ny dx / d这个(nx, ny)指向“弦的左侧”。凸度为正时圆心应该在左侧凸度为负时圆心在右侧。从弦中点(mx, my)沿法线方向移动一段距离就能得到圆心。这里的移动距离不是半径而是offset R - h为什么不是直接移动R因为圆心和弦中点的距离在凹侧和弧线侧并不一样。用上面的半圆例子最直观凸度b1时R d/2h d/2offset 0圆心正好落在弦中点上也就是半圆弧的圆心确实在弦中点。对于四分之一圆弧b0.4142R大于h圆心在弦中点的另一侧。中心公式cx mx sign(b) * offset * nx cy my sign(b) * offset * ny起始角就是 P0 相对圆心的角度startAngle atan2(y0 - cy, x0 - cx)扫描角度就是带符号的θsweep 4 * atan(b)终止角直接endAngle startAngle sweep这里有一个很多教程都不会强调的坑不要用atan2(y1-cy, x1-cx)去算终止角然后做归一化。因为当凸度绝对值大于 1、圆弧超过 180 度时直接由终点坐标反推的角度会丢掉“这是大弧”这个信息。正确的是保留sweep让绘图引擎根据sweep是否大于π来判断用大弧还是小弧。3.3 一个极简的BulgeToArc参考实现下面是一个 Python 参考实现输入 P0、P1 和凸度输出一个(cx, cy, radius, startAngle, sweep)结构import math def bulge_to_arc(p0, p1, bulge): x0, y0 p0 x1, y1 p1 dx, dy x1 - x0, y1 - y0 chord math.hypot(dx, dy) if abs(bulge) 1e-12 or chord 1e-12: return None sweep 4.0 * math.atan(bulge) radius chord * (1.0 bulge * bulge) / (4.0 * abs(bulge)) mx, my (x0 x1) / 2.0, (y0 y1) / 2.0 # 左手垂直向量 nx, ny -dy / chord, dx / chord h abs(bulge) * chord / 2.0 offset radius - h side 1.0 if bulge 0 else -1.0 cx mx side * offset * nx cy my side * offset * ny start_angle math.atan2(y0 - cy, x0 - cx) return (cx, cy, radius, start_angle, sweep)这个函数返回的start_angle sweep就是结束角。如果熟悉 SVG 的弧线语法可以直接用sweep的正负来决定sweep-flag用abs(sweep) math.pi来决定large-arc-flag非常方便。4. 从DXF文本到可绘制圆弧解析与重建全流程4.1 按组码设计一个健壮的LWPOLYLINE解析器前面反复提到“组码/值交替”解析器实际上不需要按行读直接整体切分 tokens 就行。下面这个解析器会提取所有LWPOLYLINE实体的顶点和凸度import math def parse_lwpolyline_entities(dxf_text): tokens dxf_text.replace(\t, ).split() i 0 entities [] while i len(tokens) - 1: if tokens[i] 0 and tokens[i 1].upper() LWPOLYLINE: i 2 ent { type: LWPOLYLINE, count: 0, flags: 0, elevation: 0.0, vertices: [] } current None while i len(tokens) - 1: code tokens[i] if code 0: break value tokens[i 1] if code 90: ent[count] int(value) elif code 70: ent[flags] int(value) elif code 38: ent[elevation] float(value) elif code 10: if current is not None and y in current: ent[vertices].append(current) current {x: float(value), y: None, bulge: 0.0} elif code 20: if current is not None: current[y] float(value) elif code 42: if current is not None: current[bulge] float(value) ent[vertices].append(current) current None i 2 if current is not None and y in current: ent[vertices].append(current) entities.append(ent) else: i 2 return entities这个解析器的核心逻辑是遇到10就准备开始一个新顶点遇到42就把当前顶点结束并存放如果下一个还是10说明上一个没有42的顶点也该结束了。它天然支持“有弧的顶点带 42直线顶点不带 42”的混合情况。4.2 空段组装与闭合段处理拿到顶点列表之后真正的“多段线”是由相邻顶点两两组成的段。对直线段和圆弧段统一处理时建议把每个段都做成一个结构体方便后续渲染。def build_segments(ent): verts ent[vertices] if len(verts) 2: return [] closed bool(ent[flags] 1) segments [] for i in range(len(verts) - 1): segments.append({ start: verts[i], end: verts[i 1], bulge: verts[i][bulge] }) if closed: segments.append({ start: verts[-1], end: verts[0], bulge: verts[-1][bulge] }) return segments这段代码里最关键的一行是bulge取verts[-1][bulge]。闭合多段线的最后一段是从最后一个顶点回到第一个顶点如果这是一段弧那么它的凸度存储在最后一个顶点的42上。很多人只循环到len(verts)-2结果闭合多段线的收尾弧永远画不对。4.3 把结果输出成SVG/Canvas可用的绘图指令有了段列表就可以逐段输出绘图指令。对直线段直接输出L x1 y1对圆弧段如果目标是 SVG最常见的方式是输出A命令def segment_to_svg(seg): p0 (seg[start][x], seg[start][y]) p1 (seg[end][x], seg[end][y]) bulge seg[bulge] if abs(bulge) 1e-12: return fL {p1[0]:.4f} {p1[1]:.4f} arc bulge_to_arc(p0, p1, bulge) cx, cy, radius, start_angle, sweep arc large_arc_flag 1 if abs(sweep) math.pi else 0 sweep_flag 1 if sweep 0 else 0 return ( fA {radius:.4f} {radius:.4f} 0 f{large_arc_flag} {sweep_flag} f{p1[0]:.4f} {p1[1]:.4f} )SVG 里的sweep-flag含义是“弧沿正角度方向绘制”正凸度对应正扫描角所以取1负凸度取0。很多误区是把负凸度直接当成“反向半径”其实只需要正确地传递正负号绘图层就会自动决定方向。5. 我在实际项目中踩过的凸度坑5.1 闭合多段线的最后一段圆弧不见了某次做一个 DXF 转 SVG 的工具拿到图纸后大部分弧都能转唯独几个闭合的圆环变成了半边弧加一条直线。排查到最后发现那条闭合多段线没有重复起点70组码的闭合位是1最后一个顶点确实带了42我却在生成段时只循环到len(verts)-1直接把收尾段丢了。画面上看起来就是“最后一段应该是个弧结果却是一条直线把终点和前一个点连起来”。这个问题在很多图纸里表现都不一样。有的 CAD 程序会把闭合多段线的第一个顶点在末尾再写一遍有的不会。不能指望文件格式统一帮你补起点自己检测70 1才是最可靠的。5.2 OCS坐标系、镜像和Y轴方向带来的“画反”凸度的正负号完全是建立在 DXF 坐标系上的。正常情况下 DXF 使用右手坐标系X 轴向右Y 轴向上。正凸度表示逆时针。但浏览器 Canvas 坐标系的 Y 轴向下很多人在页面上绘图的习惯动作是“直接用读取到的坐标画”这样整张图相当于被 Y 轴镜像了一次原本逆时针的弧全都变成顺时针。解决方式不是把凸度取反而是先建立正确的坐标系变换把 DXF 坐标转换到目标坐标系后再重新计算角度。如果只是简单地为显示而缩放平移可以采用整体矩阵变换如果是在 Canvas 上一步步画最好先ctx.scale(1, -1)再画或者在读取顶点坐标时统一做y -y然后再调bulge_to_arc。还有一类更难排查的坑是210组码。LWPOLYLINE的坐标是在对象坐标系OCS里的如果图块或实体带了一个非默认的法向量那么 XY 坐标不能直接当成世界坐标使用。遇到210不是(0,0,1)的情况需要做一次从 OCS 到 WCS 的转换。做图纸预览工具时这个转换绕不开。5.3 微小凸度与大半径精度和性能的取舍有些设计文件里会出现42 1e-7这种看起来几乎等于零的值。它确实代表一段轻微的弧但圆弧半径可能大到几万甚至几百万个绘图单位。如果你把它当圆弧渲染某些绘图引擎会在切向量处出现数值抖动甚至因为精度不足把圆弧渲染成一个变形图案。我的处理习惯是设一个凸度阈值比如1e-6。小于这个值直接按直线段处理。如果应用场景对精度要求极高那也不能盲目按直线处理可以增加一个判断根据半径和弦长计算实际矢高如果矢高小于某个公差再按直线处理。5.4 42组码缺失导致的“上一条凸度串位”还有一种很低级但很常见的错误解析器把顶点读成列表后遇到某些顶点没有42就沿用了上一个顶点的42。结果是一条直线段被硬生生画成了弧而且弧的方向五花八门。DXF 规范中缺失42的顶点等同于凸度为0。解析时必须保证bulge默认初始化为0否则数据一“串位”后续所有以42组码标识的弧都会错。特别是在 DXF 文本流里42并不是固定出现在每个顶点后的所以写解析器时要极其注意状态的清理。相信我这种错误不是看一眼就能发现的它的表现往往是“部分线段正常、部分线段带弧度”非常容易误判成文件损坏。最后再分享一个小技巧做 DXF 解析时不要只依赖文档可以手工构造几个简单的已知图形导出 DXF比如画一条带半圆的闭合多段线然后用解析器读出来对照42值和重建后的圆心坐标。这套自测流程能帮你把凸度相关的坑一次性排掉后面再遇到千奇百怪的 DXF 文件至少心里有个底。