乐于分享
好东西不私藏

DXF 编辑软件与文件格式底层解析:从组码语法到 B 样条求值

DXF 编辑软件与文件格式底层解析:从组码语法到 B 样条求值
点击上方蓝字订阅!

DXF(Drawing Exchange Format)自 1982 年由 Autodesk 推出以来,一直是 CAD 领域最"尴尬"却又最无法绕开的格式——它既不是给人类读的(虽然 ASCII 版可以),也不是给机器最高效读的(二进制 DWG 更小),但它几乎是唯一能被几乎所有 CAD、CAM、激光下料、CNC 软件同时吞下的交换格式。理解 DXF 的编辑软件选型,本质上要先理解 DXF 本身的文件语法;否则"选哪个编辑器"这个问题永远停留在"好不好用"的表层。

DXF 的文件语法:组码(Group Code)才是灵魂

DXF 文件本体是一连串组(Group)的线性排列,每组占两行——第一行是组码(1~3 位非负整数,右对齐,不足补前导空格),第二行是组值。组码决定组值的数据类型和语义,而不是靠关键字或括号。

一个完整的 DXF 文件由 7 个 SECTION顺次构成:HEADERCLASSESTABLESBLOCKSENTITIESOBJECTSTHUMBNAILIMAGE(可选)。每个 SECTION 以0\nSECTION\n2\n节名开头,以0\nENDSEC结束;每个实体(LINE / CIRCLE / SPLINE…)以0\n实体类型名 开头。

组码的区间约定大致是:

  • • 0–9 字符串(实体类型名、图层名、线型名、句柄)
  • • 10–59 双精度浮点(几何坐标,10/20/30 分别是 X/Y/Z,11–19 是第二组点)
  • • 60–79 16 位整型(颜色号、可见性)
  • • 90–99 32 位整型
  • • 40–48 双精度标量(半径、文字高、多段线宽度)

举一个最朴素的 LINE 实体为例:

0
LINE
5
2F
8
0
10
0.0
20
0.0
30
0.0
11
10.0
21
10.0
31
0.0

0→LINE 声明实体类型,8→0 丢到图层 "0",10/20/30 是起点  是终点 。这就是 DXF 的全部——没有花括号、没有 JSON 的层级,全靠"组码+组值"两行对咬着往前走。

下面这段代码不依赖任何第三方库,纯 stdlib 把一个 DXF 的 HEADER 节和 ENTITIES 里的 LINE 捞出来,每一块都能单独存成 .py 直接跑:

# dxf_minimal_parser.py
# 依赖:仅 Python 3.x stdlib,独立可运行
import re
from pathlib import Path
from dataclasses import dataclass
from typing importListDictAny

@dataclass
classDXFLine:
    handle: str
    layer: str
    start: tuple[floatfloatfloat]
    end: tuple[floatfloatfloat]

defparse_dxf_minimal(dxf_path: str) -> Dict[strAny]:
"""手写字面量解析 DXF:只认 HEADER 里的 $ACADVER,和 ENTITIES 里的 LINE"""
    lines = Path(dxf_path).read_text(errors="ignore").splitlines()
    it = iter(lines)
    result = {"acadver"None"lines": []}
    current_section = None
    current_entity = None
    buf_key = None

for raw in it:
        line = raw.strip()
# 组码行
if re.fullmatch(r"\d{1,3}", line):
            code = int(line)
try:
                val = next(it).strip()
except StopIteration:
break

# 节切换
if code == 0and val == "SECTION":
                nxt = next(it).strip()  # 读 2 的组值
next(it)  # 跳过 "2"
                current_section = nxt
continue
if code == 0and val == "ENDSEC":
                current_section = None
continue

# HEADER 变量
if current_section == "HEADER"and code == 9:
                var = val  # 如 "$ACADVER"
if var == "$ACADVER":
# 下一对:1 → "AC1021"
                    c2 = int(next(it).strip())
                    v2 = next(it).strip()
                    result["acadver"] = v2
continue

# ENTITIES:LINE 实体
if current_section == "ENTITIES":
if code == 0and val == "LINE":
                    current_entity = {"handle""""layer""0",
"sx":0.0,"sy":0.0,"sz":0.0,
"ex":0.0,"ey":0.0,"ez":0.0}
continue
if current_entity isnotNone:
if code == 5:
                        current_entity["handle"] = val
elif code == 8:
                        current_entity["layer"] = val
elif code == 10:
                        current_entity["sx"] = float(val)
elif code == 20:
                        current_entity["sy"] = float(val)
elif code == 30:
                        current_entity["sz"] = float(val)
elif code == 11:
                        current_entity["ex"] = float(val)
elif code == 21:
                        current_entity["ey"] = float(val)
elif code == 31:
                        current_entity["ez"] = float(val)
elif code == 0:  # 下一个实体开始,LINE 结束
                        result["lines"].append(DXFLine(
                            current_entity["handle"],
                            current_entity["layer"],
                            (current_entity["sx"], current_entity["sy"], current_entity["sz"]),
                            (current_entity["ex"], current_entity["ey"], current_entity["ez"]),
                        ))
                        current_entity = None
continue

# 文件末尾若 LINE 没被 0 截断也收一次
if current_entity:
        result["lines"].append(DXFLine(
            current_entity["handle"], current_entity["layer"],
            (current_entity["sx"], current_entity["sy"], current_entity["sz"]),
            (current_entity["ex"], current_entity["ey"], current_entity["ez"]),
        ))

return result


if __name__ == "__main__":
# 把下面路径换成你自己的 .dxf(ASCII 版)
    data = parse_dxf_minimal("example.dxf")
print("ACADVER:", data["acadver"])
for ln in data["lines"]:
print(f"  LINE handle={ln.handle} layer={ln.layer} "
f"start={ln.start} end={ln.end}")

这段代码之所以能跑,是因为 DXF 的"两行一组"是上下文无关语法——你不需要知道 TABLES 里有什么表,只要状态机跟着 0/SECTION → 2/节名 和 0/ENDSEC 走就行。这也是为什么几乎所有 DXF 编辑器内部都是先写个组码 tokenizer,再往上叠实体反序列化

为什么 SPLINE 实体让很多轻量编辑器"跪掉"

LINE / CIRCLE / ARC 这种初等几何,组码里直接给圆心半径起终点,任何编辑器都能画。但 DXF 的 SPLINE 实体走的是 NURBS 路线——控制点 、节点向量 、阶数 (次数 ),曲线定义为

其中  是控制点个数减 1, 是节点总数,Clamped 条件下首末节点各重复  次,让曲线过端点。基函数由 Cox–de Boor 递推给出:

约定 。递推的方向是:是分段常数盒函数,是一次分段线性,二次……每升一阶就做一次"相邻低阶基的加权平均",权重是参数在当前节点区间的位置比。基函数还有权性,所以 是凸组合,曲线落在控制点凸包内。

下面这块代码不依赖 ezdxf / numpy,纯 Python 把 Cox–de Boor 递推和一条三次()B 样条采样出来,独立可运行:

# bspline_eval.py
# 依赖:仅 stdlib,独立可运行
from typing importList

defcox_de_boor(i: int, k: int, u: float, knots: List[float]) -> float:
"""Cox-de Boor 递推,k=1 是盒函数,k 是阶数"""
if k == 1:
if knots[i] <= u < knots[i + 1]:
return1.0
return0.0
# 左支
    denom_l = knots[i + k - 1] - knots[i]
    left = 0.0if denom_l == 0.0else (u - knots[i]) / denom_l * cox_de_boor(i, k - 1, u, knots)
# 右支
    denom_r = knots[i + k] - knots[i + 1]
    right = 0.0if denom_r == 0.0else (knots[i + k] - u) / denom_r * cox_de_boor(i + 1, k - 1, u, knots)
return left + right


defeval_bspline(control: List[List[float]], knots: List[float],
                 k: int, u: float
) -> List[float]:
"""P(u) = Σ control[i] * B_{i,k}(u),控制点 dim 维"""
    n = len(control) - 1
    dim = len(control[0])
    pt = [0.0] * dim
for i inrange(n + 1):
        w = cox_de_boor(i, k, u, knots)
for d inrange(dim):
            pt[d] += control[i][d] * w
return pt


if __name__ == "__main__":
# 4 个控制点 → n=3,三次样条 k=4,clamped: 首末节点各重复 4 次
    ctrl = [[00], [13], [42], [50]]
    k = 4
    n = len(ctrl) - 1# 3
    m = n + k + 1# 8
    knots = [00001111]  # 最简 clamped 单位化

    us = [i / 50for i inrange(51)]  # u ∈ [0,1]
    pts = [eval_bspline(ctrl, knots, k, u) for u in us]

print(f"控制点 {n+1} 个, 阶 k={k}, 节点数 m={m}")
print("采样头 5 点:", pts[:5])

这段递推是递归写法,点数百级以内够用;工业级会改成 de Boor 算法的迭代版(Cox–de Boor 的另一种形态,直接算  而不展开基函数)。但递归版的好处是你能一眼看到公式和代码的一一对应——denom_l == 0 那行就是在落实"0/0=0"的约定。

回到 DXF 编辑器的语境:LibreCAD、QCAD Community 这类 2D 开源工具,对 SPLINE 的支持长期是"能读能画但不能编辑控制点",根子就在这——Cox–de Boor 实现不难,难的是把 DXF 里 SPLINE 实体的 70(阶数)、71(控制点数)、72(节点数)、40(节点值序列)、10/20(控制点坐标)这些组码正确喂给递推,再加上拟合公差 42、权值 41 这些 NURBS 扩展,工作量一下子就上去了。所以如果你要处理的 DXF 里 SPLINE 多(比如 Rhino / SolidWorks 导出来的),免费 2D 编辑器大概率翻车,得上 BricsCAD 或 AutoCAD 本体。

第三块代码:图层过滤重写(实用小工具)

编辑 DXF 最常见的需求不是画,而是"把某几个图层留下、其余删掉再发给车间"。下面这块不依赖 ezdxf,用读写流做图层白名单过滤,独立可运行,思路是:遇到 0\nLINE(或 CIRCLE/ARC…)就看下一个 8\n图层名,不在白名单里就跳过该实体块,HEADER/TABLES/BLOCKS 原样保留。

# dxf_layer_filter.py
# 依赖:仅 stdlib,独立可运行
from pathlib import Path

deffilter_dxf_by_layer(in_path: str, out_path: str, keep_layers: set[str]):
"""
    把 DXF 中图层不在 keep_layers 里的 ENTITIES 实体整块丢弃。
    只处理 ENTITIES 节,HEADER/TABLES/BLOCKS 原样拷贝。
    """

    lines = Path(in_path).read_text(errors="ignore").splitlines()
    out = []
    i = 0
    n = len(lines)
    in_entities = False
    skip_depth = 0# 当前是否处在"要跳过的实体"内

while i < n:
        raw = lines[i].strip()
# 检测节切换
if raw == "0"and i + 1 < n and lines[i + 1].strip() == "SECTION":
# 读 2/<节名>
            j = i + 2
if j < n and lines[j].strip() == "2":
                sec_name = lines[j + 1].strip() if j + 1 < n else""
if sec_name == "ENTITIES":
                    in_entities = True
elif sec_name:
                    in_entities = False
if raw == "0"and i + 1 < n and lines[i + 1].strip() == "ENDSEC":
            in_entities = False
            skip_depth = 0

# 在 ENTITIES 里遇到实体开始
if in_entities and raw == "0"and i + 1 < n:
            ent_type = lines[i + 1].strip()
if ent_type in {"LINE""CIRCLE""ARC""LWPOLYLINE""SPLINE"}:
# 往下找 8/<layer>
                layer = "0"
                j = i + 2
while j < n:
if lines[j].strip() == "8":
if j + 1 < n:
                            layer = lines[j + 1].strip()
break
if lines[j].strip() == "0":  # 下一个实体了还没找到 8,按默认
break
                    j += 1
if layer notin keep_layers:
# 跳过这块实体:从 i 开始直到下一个 "0\n<类型>" 或 ENDSEC
                    skip_depth = 1
if skip_depth:
# 如果当前行是 "0" 且下一行看起来像实体类型或 ENDSEC,跳完
if raw == "0"and i + 1 < n:
                nxt = lines[i + 1].strip()
if nxt in {"LINE","CIRCLE","ARC","LWPOLYLINE","SPLINE",
"ENDSEC","SECTION"}:
                    skip_depth = 0
# ENDSEC 要写回,SECTION 也要写回,但实体 0+类型 不写
if nxt == "ENDSEC":
                        out.append("0")
                        out.append("ENDSEC")
                    i += 2if nxt != "ENDSEC"else2
continue
# 仍在跳
                i += 1
continue
            i += 1
continue

        out.append(raw)
        i += 1

    Path(out_path).write_text("\n".join(out) + "\n")


if __name__ == "__main__":
# 例子:只保留图层 "0" 和 "WALL"
    filter_dxf_by_layer("input.dxf""output.dxf", {"0""WALL"})
print("done")

这段代码故意没走完备状态机(BLOCKS 里的同名 INSERT 没处理、LWPOLYLINE 的 90 顶点数也没跟),但对"车间下料前清图层"这种 80% 场景已经够用。它的意义不在于功能完整,而在于让你看清楚 DXF 编辑的本质:编辑软件干的活,底层就是"按组码流做有条件改写"


回到选型:三类编辑器的真实分野

把上面三组代码摆在一起再看软件选型,就能看出分档不是"贵/便宜"那么粗:

免费开源档——LibreCAD、QCAD Community、FreeCAD、OpenCAD。它们的共同点是只承诺 2D 实体(LINE/ARC/CIRCLE/LWPOLYLINE)读写稳定,对 SPLINE/NURBS 要么降级成折线要么只读不编。LibreCAD 是 Qt 写的几十 MB 轻量货,Linux 老机器也能跑;QCAD Community 界面更像 AutoCAD,DXF R12–R2018 全版本往返;FreeCAD 走参数化 3D,DXF 靠 ODA 插件桥 DWG,适合后面还要走 CAM 刀路的。临时改图、教育、Linux 环境,这一档是默认答案。

国产/轻量档——浩辰 CAD 看图王、中望 ZWCAD、nanoCAD Free。中望和浩辰的卖点是中文生态 + DWG/DXF 双向兼容 + 国内图框/钢筋/建筑标注那一堆本土习惯,公司是采购不是个人玩票时,这一档比 AutoCAD 省一大截钱。nanoCAD Free 是 Windows only 的 AutoCAD 近仿,450+ 命令,注册后免费用,2D 为主。

专业付费档——BricsCAD、DraftSight、AutoCAD。BricsCAD 是这里面"最像 AutoCAD 又不必交订阅"的那个,Lisp 能跑、DWG/DXF 原生、永久授权可选(770 年付);如果你的 DXF 工作流里 SPLINE 多、XREF 多、3D 实体多,免费档会开始漏支持,BricsCAD 是性价比实点。AutoCAD 本身不差钱就它,但 2026 年这个时间点上,纯为"读 DXF"交订阅已经越来越难 justification。

选型决策树一句话串起来:偶尔改 2D → LibreCAD/QCAD;要 3D 或 CNC → FreeCAD;国内协作/同事都用国产 → 浩辰/中望;公司采购要兼容又不想续费 → BricsCAD;不差钱 → AutoCAD

一点底层视角的收尾

DXF 这个格式活了 40 多年还没死,原因不是它设计得好——它冗余(ASCII 版一个坐标占两行)、它慢(要逐组解析)、它对 NURBS 的支持还分 Acad 版本——而是因为它足够简单到任何一个人用一个下午就能写出 parser(第一段代码就是证据),又足够完整到能承载从机械零件到建筑平面图的所有图元。这也解释了为什么 DXF 编辑器市场这么碎:门槛低到学生能写个 LibreCAD 出来,天花板又高到 BricsCAD/AutoCAD 能把 SPLINE、XREF、OLE、TrueColor、PlotStyle 全啃下来卖订阅。你选哪一档,本质上不是选"软件",是选你愿意离 DXF 的组码流多远——自己 open() 读是两行一组的纯文本,LibreCAD 是 Qt 套一层 Canvas,BricsCAD 是 ODA Kernel 直接映射 DWG 内存结构。越远越省事,越近越自由。

 • end • 

陪伴是最长情的告白

 为你推送最实用的资讯 

识别二维码 关注我们