简介:面向需要在C#.NET项目中直接读取DWG格式CAD文件的开发者,这份基于DWGdirect_NET_3_02动态库的资源包,能有效解决在.NET环境下解析DWG图纸信息的常见难题,适用场景包括图纸数据批量提取、CAD文件信息识别以及与企业现有系统对接。压缩包为ZIP格式,整体大小约8.89MB,内含可直接运行的DEMO项目与动态库文件,DEMO在VS2010下测试通过并带有自测注释,便于快速理解调用流程和集成方式,可避开许多常见的环境配置问题。已有1317人学习下载,对CAD二次开发入门者及有DWG数据读取需求的工程师具有参考价值,无论是纯新手还是有一定经验的开发人员都能从中受益。通过该资源可掌握DWGdirect动态库的核心调用方法、DWG文件信息的读取流程,并能基于DEMO进行二次开发或移植到实际项目中,大幅节省自行寻找方案和调试的时间,为后续业务集成提供可靠基础。
1. C#.NET 读写 DWG:为什么选 DWGdirect_NET_3_02
接到一个纯后端任务:每天从合作方丢过来的几千个 DWG 图纸里取图层和实体坐标,写进业务库。第一反应是装 AutoCAD 用 COM 调,可服务器不能装桌面软件;转 DXF 又怕丢数据。最后选定的是 ODA(Open Design Alliance)的 DWGdirect_NET_3_02——一套让 C#.NET 直接读写二进制 DWG 的托管封装,不需要图纸软件参与,目标机器上只有我们的服务和这份库。
这篇按实际落地顺序来写:先讲为什么选它而不是 DXF 中转或 COM 自动化,再给最小读取代码、写出代码,最后是回读校验和版本兼容上的坑。适合没有 AutoCAD 环境、需要在服务端批量把 DWG 导入业务系统的 .NET 工程师,也适合想自研轻量 CAD 看图工具、又不愿意碰 DXF 解析的人。
2. DWG 是封闭二进制格式,选型先想清楚再动手
2.1 DWG 与 DXF 的本质差别:句柄、对象映射和增量保存
DWG 不是文本格式。文件开头有版本码(AC1015 对应 AutoCAD 2000,AC1018 对应 2004),中间是对象映射表、句柄表和按需延迟加载区,实体之间的引用靠句柄(Handle)串联。这个结构是 Autodesk 为 AutoCAD 增量保存设计的,从来没有公开成正式规范。
这就是"LibreCAD 解析 DWG 是逆向技术吗"这个问题的来源:开源社区里 LibreCAD 默认走的其实是 DXF 通路(libdxfrw),对 DWG 的解析能力一直很弱;真正把 DWG 当成二进制格式完整解出来的是 ODA 这个联盟,用干净室逆向的方式维护了一套跨平台 SDK,成员付费使用。DWGdirect 就是 ODA 这套 SDK 的 .NET 托管封装,DWGdirect_NET_3_02 是其中一个较早的交付版本。
相比之下 DXF 是 ASCII 交换格式,文档齐全、解析容易,但图纸里不少数据(特别是代理实体、自定义对象、某些扩展字典)走 DXF 会丢失或变形。如果上游只给 DWG,绕开 DXF 是更稳的路径。
2.2 C# 环境下四种读写方案的取舍
下面这张表是我在服务器场景里的真实对比结果:
| 方案 | 是否依赖 AutoCAD | 许可与成本 | 典型场景 |
|---|---|---|---|
AutoCAD COM/ActiveX(AcadApplication) | 必须安装 AutoCAD | 桌面许可证,服务器部署麻烦 | 有 CAD 的办公机上的交互插件 |
| DWGdirect/Teigha(ODA SDK) | 否,纯 SDK | 按 ODA 会员授权 | 服务端批量处理、无头读图、自研看图 |
| DXF 中转(netDxf、ezdxf) | 否 | 开源免费 | 自己生成的图纸、数据交换 |
| libredwg / libdxfrw | 否 | GPL 或弱许可 | 开源工具链,商用要注意 GPL 传染 |
我一般会选 ODA 路线,核心理由是:不依赖桌面软件、进程内直接读写、支持 x86/x64 服务部署。网上那些"dwg 转换 shp 免费软件"类工具,底层也大量是 ODA 库或类似实现,可见这条路在 GIS 和 BIM 转换链路上已经被验证过。缺点是授权不便宜,而且老版本 SDK 对新版 DWG 的支持有一个明确的天花板,这个在第四章展开。
2.3 DWGdirect_NET_3_02 的对象模型和命名空间
DWGdirect 的 .NET API 沿用了 ODA 一贯的对象模型,理解下面五层就够了:
Database:对应一个 DWG 文件,负责打开、保存、版本转换。TransactionManager:所有读写都要起事务,防止对象缓存和底层数据库状态不一致。BlockTable:图纸里的块表,里面是若干BlockTableRecord。BlockTableRecord:一个块定义;模型空间本身就是一条特殊的记录。Entity:直线、圆、多段线、块引用等图元,全部继承自它。
对象引用同时存在两个身份:ObjectId是本次会话内的索引,用完即失效;Handle是文件内持久句柄,跨会话稳定,适合做去重和外键映射。3.02 时代的程序集命名空间在不同发行版里不完全一致,代码开头一般是下面这样,以你实际引用的程序集为准:
// 3.02 的命名空间常见为 ODA.DwgDirect / ODA.Kernel // ODA 后来改名为 Teigha,命名空间变成 Teigha.DatabaseServices / Teigha.Runtime using ODA.DwgDirect; using ODA.Kernel;核心类的职责可以先用这张表记着,后面代码里还会反复出现:
| 类 | 职责 |
|---|---|
Database | 打开/创建/保存 DWG,持有块表和其他字典 |
Transaction | 读写操作的隔离边界,控制对象状态 |
BlockTable | 所有块定义的容器 |
BlockTableRecord | 块内实体集合,模型空间是其中一条 |
Entity | 图元基类,含Layer、ColorIndex、Handle |
OpenMode | ForRead/ForWrite/ForNotify三种打开方式 |
这里有个容易踩的认知偏差:很多人以为 DWG 解析就是把文件按偏移量读出来拼成对象,实际用 DWGdirect 时你基本不碰偏移量,只操作这套对象模型。它内部负责把句柄和对象映射还原成可遍历的实体集合,这正是商业库的价值所在。
3. 最小读取链路:初始化宿主、打开数据库、遍历模型空间
3.1 引用配置和 x86/x64 本机库部署
DWGdirect_NET_3_02 是托管封装,但底层仍是原生库。部署时有三个容易翻车的地方。
第一,项目引用里除了托管程序集,还要把 SDK 自带的原生动态库和授权文件放进输出目录。授权文件缺失或路径不对,进程启动后第一次打开数据库就会抛异常,而且错误信息很隐晦,类似无效的许可证指纹。
第二,3.02 年代的库大多是 32 位编译的。如果你的程序跑在 x64 服务上,IIS 应用池要启用 32 位应用程序,或者让进程跑 WOW64;纯 64 位进程加载 32 位本机库会直接报 BadImageFormatException。这一点在部署文档里最容易忽略,因为编译时托管代码不会报错。
第三,引用方式直接用 HintPath 指向 SDK 里的 DLL 最简单,不要扔进 GAC,方便切换版本:
<ItemGroup> <Reference Include="DwgDirectNet"> <HintPath>lib\DwgDirectNet.dll</HintPath> <Private>true</Private> </Reference> </ItemGroup>Private设为 true 保证拷贝到输出目录,和本机库、授权文件放一起。启动时把工作目录设为 exe 所在目录,避免 Windows 服务里相对路径找不到这些依赖。
3.2 打开 DWG 并枚举模型空间实体的最小代码
下面的代码完成了从打开文件到枚举模型空间全部实体的一次完整闭环:
using System; using System.IO; using ODA.DwgDirect; using ODA.Kernel; public static class MinimalReader { public static void Run(string dwgPath) { // 第一个参数 false:不创建默认图面,只打开已有文件 // 第二个参数 true:不绑定到文档界面,适合无头环境 using (Database db = new Database(false, true)) { db.ReadDwgFile(dwgPath, FileShare.Read, true, ""); using (Transaction tr = db.TransactionManager.StartTransaction()) { BlockTable bt = (BlockTable)tr.GetObject( db.BlockTableId, OpenMode.ForRead); BlockTableRecord ms = (BlockTableRecord)tr.GetObject( bt[BlockTableRecord.ModelSpace], OpenMode.ForRead); int count = 0; foreach (ObjectId id in ms) { // ForRead 打开不会触发写锁,多个线程读同一文件安全 Entity ent = (Entity)tr.GetObject(id, OpenMode.ForRead); Console.WriteLine("{0,-14} layer={1,-12} handle={2}", ent.GetType().Name, ent.Layer, ent.Handle); count++; } tr.Commit(); // 读事务也要提交,否则内部缓存可能被延迟清理 Console.WriteLine($"共 {count} 个实体"); } } } }几个参数说明:
ReadDwgFile(path, FileShare.Read, true, ""):第三个参数代表是否允许代码页转换,老图纸经常需要;第四个是密码,没有就传空字符串。如果文件正被 AutoCAD 以独占方式打开,这里会收到IOException,而不是静默失败。foreach (ObjectId id in ms)遍历的是模型空间内的实体引用,不直接返回对象,这是 ODA 的统一模式。- 同一个事务里对同一个
ObjectId多次GetObject返回的是同一个托管实例,可以直接用==比较,不要用Equals去比句柄。
这个最小代码段就是后面做"cad 快速看图"类工具的地基:把Console.WriteLine换成把实体类型、图层、句柄写进列表,再结合几何提取就能画预览。
3.3 从实体上提取几何:Line、Circle、Polyline、BlockReference
上一步只拿到实体外壳,真正要入库的是几何数据。不同实体类型的读取方式差异很大,下面是我常用的分派写法:
private static void DumpGeometry(Entity ent, Transaction tr) { if (ent is Line line) { Console.WriteLine($"LINE ({line.StartPoint.X:F3},{line.StartPoint.Y:F3}) -> " + $"({line.EndPoint.X:F3},{line.EndPoint.Y:F3})"); } else if (ent is Circle circle) { Console.WriteLine($"CIRCLE center=({circle.Center.X:F3},{circle.Center.Y:F3}) " + $"r={circle.Radius:F3}"); } else if (ent is Polyline pline) { // 轻量多段线:顶点是二维点,圆弧段用 bulge(凸度)表达 for (int i = 0; i < pline.NumberOfVertices; i++) { Point2d p = pline.GetPoint2dAt(i); Console.WriteLine($" vertex[{i}] ({p.X:F3},{p.Y:F3}) bulge={pline.GetBulgeAt(i):F4}"); } } else if (ent is BlockReference br) { Console.WriteLine($"INSERT block={br.Name} pos=({br.Position.X:F3},{br.Position.Y:F3})"); } }多段线的 bulge 是很多人的盲区:bulge 为 0 表示直线段;非 0 时表示从当前顶点到下一个顶点之间有一段圆弧,圆弧夹角theta = 4 * atan(bulge),半径r = d * (1 + bulge^2) / (4 * |bulge|),其中d是弦长。如果只取顶点画折线,所有圆弧段都会变成直线,图纸细节肉眼可见地变形。
BlockReference只给了插入点和块名,块内部的实体要再去块表里按br.Name找到对应BlockTableRecord再遍历,而且还要叠加插入点的平移、缩放系数和旋转角。这也是 DWG 转 SHP、转 GIS 工具里最容易做错的一层——炸弹块(嵌套块)要递归展开,主流实现都会写一个递归深度限制防止环引用死循环。
3.4 读取时的三个必调参数:OpenMode、FileShare 和事务生命周期
读取阶段真正需要反复斟酌的参数就三个,其他都可以用默认值:
| 参数 | 可选值 | 使用建议 |
|---|---|---|
OpenMode | ForRead/ForWrite/ForNotify | 只读操作用ForRead;要改属性必须ForWrite,先 Read 再升级会抛异常 |
FileShare | Read/ReadWrite | 服务器上同时有 AutoCAD 打开同一文件时,用Read可能拿不到独占锁,要捕获重试 |
| 事务生命周期 | Commit()/Dispose() | 读事务也建议Commit(),否则延迟清理可能带着未提交状态一起回收 |
这里有一个实战细节:GetObject拿到实体后,即使不再使用,也尽量不要在事务提交前让托管对象被 GC 回收。ODA 库的内部对象表和托管对象是一一绑定的,提前析构会导致后续遍历时出现"对象已释放"的诡异异常。规律就是:一个事务里集中读取、集中处理、最后统一提交释放。
4. 写出 DWG:创建实体、追加到模型空间、保存版本
4.1 新文件从 Database(true, true) 开始,再设单位和精度
创建新文件和打开旧文件不同,第一个参数要传 true,表示同时构建默认图面(0 图层、默认样式、模型空间记录都就绪)。第二个参数保持 true,不挂接文档界面:
using (Database db = new Database(true, true)) // 创建完整默认图纸 { // 插入单位:告诉宿主 CAD 这个文件按什么单位解释 db.Insunits = UnitsValue.Millimeters; // 线性单位和精度,等价于设置 LUNITS / LUPREC 系统变量 db.SetVariable("LUNITS", 2); // 十进制 db.SetVariable("LUPREC", 3); // 3 位小数 // ... 在这里创建实体并保存 }Insunits很关键。DWG 内部坐标本身没有单位,长度 1000 到底是一米还是一毫米,取决于读取方在插入时怎么解释。如果图纸要交给做结构或机械的人,统一设为毫米;要进 GIS 管线,可能更希望是米。SetVariable设置的是系统变量表,效果和在 AutoCAD 命令行敲LUNITS相同。注意不同版本里SetVariable的签名可能不同,有的接受 short,有的接受 object,以你引用的程序集为准。
4.2 在事务里新增实体并提交
写出实体必须走写事务,而且每个新实体都要做两步:追加到容器,再注册进事务。完整示例:
using (Transaction tr = db.TransactionManager.StartTransaction()) { BlockTable bt = (BlockTable)tr.GetObject(db.BlockTableId, OpenMode.ForWrite); BlockTableRecord ms = (BlockTableRecord)tr.GetObject( bt[BlockTableRecord.ModelSpace], OpenMode.ForWrite); // 直线:起点 (0,0) 到 (5000,3000) Line line = new Line(new Point3d(0, 0, 0), new Point3d(5000, 3000, 0)); line.Layer = "0"; line.ColorIndex = 1; // 1 = 红色 ms.AppendEntity(line); tr.AddNewlyCreatedDBObject(line, true); // 圆:圆心、法向、半径 Circle circle = new Circle(new Point3d(2500, 1500, 0), new Vector3d(0, 0, 1), 800); circle.ColorIndex = 2; // 2 = 黄色 ms.AppendEntity(circle); tr.AddNewlyCreatedDBObject(circle, true); // 单行文字:Height 是字高,和图纸单位一致 DBText text = new DBText(); text.Position = new Point3d(100, 100, 0); text.TextString = "C#.NET 写出的 DWG"; text.Height = 200; // 单位是图纸单位,不是像素 ms.AppendEntity(text); tr.AddNewlyCreatedDBObject(text, true); tr.Commit(); // 不 Commit 则全部回滚 } db.SaveAs("output.dwg", dwgVersion); // dwgVersion 见 4.3ms.AppendEntity(line)负责分配 ObjectId 并挂到模型空间记录的实体表里;tr.AddNewlyCreatedDBObject(line, true)把对象生命周期交给事务管理,第二个参数传 true 表示添加到数据库后允许在事务里继续修改。少了这一步最常见的表现是:保存成功但文件里找不到这个实体,或者下次打开报"数据库对象未添加"。
DBText对应单行文字,MText是多行文字。单行文字适合程序自动标注,多行文字内部带格式控制码,拼接时需要额外处理,批量标注场景我一般首选DBText。
4.3 SaveAs 的版本参数表与兼容性取舍
SaveAs的第二个参数决定输出文件能被哪个版本开。ODA 不同版本里这个枚举可能叫DwgVersion、SaveFormat,甚至直接传整型,对照关系是一致的:
| DwgVersion | AutoCAD 版本 | 说明 |
|---|---|---|
| AC1012 | R13 | 最老,兼容性最好但特性最少 |
| AC1014 | R14 | 老工地图纸常见 |
| AC1015 | 2000 | 大多数处理管线的最低要求 |
| AC1018 | 2004 | 中等兼容,文件更紧凑 |
| AC1021 | 2007 | 支持注释性缩放 |
| AC1024 | 2010 | 常见交付格式 |
| AC1027 | 2013 | 当前主流交付 |
| AC1032 | 2018 | 新图纸,老库不支持 |
这里有个必须诚实面对的限制:DWGdirect_NET_3_02 是 ODA 较早的版本,对高版本 DWG 的支持停在 AC1015/AC1018 这个区间。拿 3.02 去读 AC1027 图纸会直接报版本不支持;拿它去写 AC1027 也会失败。生产环境遇到这种图纸,有两个选择:换用新版 ODA Drawings(原 Teigha,API 基本兼容,升级成本主要是重编译和重新验证);或者在上游约定统一转成低版本再交付。前者是正路,后者只能作为过渡手段。
4.4 坐标精度陷阱:为什么会出现 2.1616e+16 这类坐标
CAD 里"画直线显示 2.1616e+16"是一个被问烂的问题,根源在 double 的精度。double 只有大约 15 到 17 位有效数字,当坐标值到了 10 的 16 次方量级,最后一个毫米位早就被舍入吃掉了。出现这种情况通常有两种来源:图纸内容距离原点极远;或者有人把投影坐标(高斯坐标、经纬度)直接当成模型空间坐标塞了进来。
批量处理时,我建议写入前先检查包围盒,如果坐标绝对值超过 1e8,就整体平移到原点附近再保存:
// 把模型空间所有实体平移到原点附近,避免天文坐标 Vector3d offset = new Vector3d(500000, 400000, 0); // 原始基准点 foreach (ObjectId id in ms) { Entity e = (Entity)tr.GetObject(id, OpenMode.ForWrite); e.TransformBy(Matrix3d.Displacement(offset.Negate())); }平移前要记录基准点,下游读取时再加回来。另一个高频问题是单位换算:GIS 系统的数据常以米为单位,写进以毫米为单位的 CAD 里要把坐标乘 1000,否则一张 10 米见方的图在 CAD 里只有 0.01 个单位,肉眼几乎看不见。这也是 DWG 与 SHP 互转工具里最常见的"图没了"的原因——不是数据丢了,是单位和数量级错了。
5. 用一个回读校验闭环收尾:写出的文件先自己读一遍
5.1 回读校验:实体数、图层、包围盒三重对比
写出来的文件在交给下游之前,先用同一套库重新打开一次,做三重校验。这套逻辑我每个批量任务都会加:
public static void Verify(string file) { using (Database db = new Database(false, true)) { db.ReadDwgFile(file, FileShare.Read, true, ""); using (Transaction tr = db.TransactionManager.StartTransaction()) { BlockTable bt = (BlockTable)tr.GetObject(db.BlockTableId, OpenMode.ForRead); BlockTableRecord ms = (BlockTableRecord)tr.GetObject( bt[BlockTableRecord.ModelSpace], OpenMode.ForRead); Dictionary<string, int> groups = new Dictionary<string, int>(); Extents3d ext = new Extents3d(); foreach (ObjectId id in ms) { Entity e = (Entity)tr.GetObject(id, OpenMode.ForRead); string typeName = e.GetType().Name; if (groups.ContainsKey(typeName)) groups[typeName]++; else groups[typeName] = 1; // GeometricExtents 是便宜的包围盒,不触发完整几何解析 ext.AddExtents(e.GeometricExtents); } // 1) 实体分布;2) 包围盒范围;3) 图层集合 Console.WriteLine("实体分布: " + string.Join(", ", groups)); Console.WriteLine($"包围盒 min=({ext.MinPoint.X},{ext.MinPoint.Y}) " + $"max=({ext.MaxPoint.X},{ext.MaxPoint.Y})"); tr.Commit(); } } }GeometricExtents是校验坐标范围最快的入口,比逐个解析几何便宜一个量级。对比时和写入前记录的期望分布、期望包围盒对账,任何一项对不上就拒绝交付。这样能兜住 4.4 里说的量级错误,也能兜住实体创建时漏掉AddNewlyCreatedDBObject导致的"实体数少了"问题。
5.2 大批量时的三个实战习惯:事务范围、本机库位、版本白名单
批量跑几千个文件时,我还会额外注意三件事。第一,每个文件单独new Database,处理完用using释放,绝不在两个文件之间复用同一个 Database 实例;ODA 库的全局状态比想象中多,复用会导致句柄错乱。第二,把"dwg 如何提取图层信息"这种高频需求做成独立工具函数,先按图层名分组统计实体数,如果某个图层的实体数异常(比如 0),直接在入队阶段拦截,而不是等保存完才发现。第三,维护一个版本白名单:读取时捕获版本不支持的异常,把文件路径和版本号写进失败队列,让上游统一转档后重跑,而不是在循环里反复重试同一个必然失败的文件。
如果团队里有人习惯用 Python,也可以通过 pythonnet 加载同一个托管程序集,但事务和缓存行为不变,建议先在 C# 里跑通回读校验,再封装成 Python 可调的入口。回到开头那个批量任务,先让回读校验在 10 个样本图纸上全绿,再放开全量,之后把图层名和句柄映射写进缓存,批处理就不再每天重读同一批文件了。
本文还有配套的精品资源,点击获取