在CAD里块估计是最常见也最容易被忽视的对象了。做二次开发的谁没被获取块的信息这种事绊过几跤你可能只是想统计一张图里插了多少个门块或者想批量读出所有设备块的编号属性结果一打开图纸就发现块的表现形式五花八门——有普通块、有带属性的块、有嵌套块、还有动态块和匿名块。不同情况拿不到数据的原因完全不一样。我在开发某跨平台系统时就靠一套稳健的块信息读取逻辑撑起了整张图的构件统计功能。这篇就把思路和踩过的坑一起捋清楚代码用C#基于.NET API来写自己搭框架或接手老项目都能直接用上。1. 拆清楚需求你要拿的到底是哪一层数据1.1 块定义、块引用和属性三者别混为一谈很多新手第一次接触块信息容易把图里有多少个块和有哪些块定义混作一谈。这两件事差着十万八千里。简单说块定义BlockTableRecord存在块表BlockTable里本质是一组图元对象的容器。它描述了块长什么样和插没插进图纸没关系。每张图还有两个特殊的块定义模型空间和图纸空间它们表面上是块表记录但性质完全不同。块引用BlockReference这才是真正摆在图里的那个对象。一个块定义可以对应无数个块引用引用记录了自己放在哪、转了多少度、缩放比例是多少。属性AttributeReference挂在块引用上的附加数据比如设备编号、规格型号、生产厂家。举个例子。某设计院的标准图框是一个块定义几十张图纸都插入了这个块但每张图的图框块引用上都挂着不同的图号、工程名属性。你统计块定义只能知道图纸用了哪些图框样式只有你遍历块引用的时候才能知道图框在各张图里的分布情况与属性差异。这里通常有个误区直接用BlockReference拿名字。BlockReference本身并没有直接暴露块名你得通过ObjectId去块表记录里取。很多人在这一步栽跟头不怪他们——API就是这么绕但它也是块信息读取的必经之路。1.2 拿到正确数据后的三个经典应用场景获取块信息本身不是目的它能解决的问题大致分三类分类统计遍历整图所有块引用按块定义名合并同类项统计数量与分布位置。这是材料统计、图纸审查类工具的基础。属性提取把带属性块的属性值批量读出来生成设备清单、接线表或门窗表。建筑、电气、水暖专业的图审工具都在干这件事。层级关系分析识别哪些块里嵌套了哪些块理清装配结构或图元从属关系。机械设备图、装配图里用得非常多。这三类需求我都实际做过最后一个容易出死循环问题——嵌套块引用自己写递归遍历时一定要有防重标记具体在第4节细讲。1.3 开发环境准备别一上来就写代码做CAD二次开发第一步不是敲代码而是把环境和引用关系理顺。不同CAD平台的.NET API微有差异但整体架构一致下面以一款主流国产CAD的.NET API为例说明限于合规要求不指名具体平台大家按自己实际使用的CAD平台对应文档来对号入座。项目引用要有三个核心程序集HostMgd.dll托管入口连接主程序与托管代码。MgdDbServices.dll数据库服务包含数据库、事务、对象等核心类型。MgdApplicationServices.dll应用服务提供文档、命令行交互等功能。框架这块老项目常见的是.NET Framework 4.7.2或4.8因为CAD插件大多跑Full Framework。新上手的项目可以优先套这个组合兼容性广踩坑少。如果你用的是支持.NET Core/6/8的最新一代CAD平台那直接上现代框架也行但需要注意调用入口略有不同。代码入口建议写成命令形式用CommandMethod特性标注方法名方便命令行直接调用。调试时建议用平台自带的托管调试器附加进程或者直接在IDE里按F5启动宿主CAD实测下来效率比手动附加高太多。2. 理解CAD数据库模型块信息读取的底层逻辑2.1 从Database到Entity的访问链条要想游刃有余地拿块信息必须弄懂CAD数据库的层级结构。这个结构不复杂但链子长任何一个环节搞错都拿不到数据Database数据库 └── BlockTable块表包含所有的块定义记录 └── BlockTableRecord每个块定义的具体内容 └── Entity块定义内包含的具体图元块表是一张总目录里面每一行是一条块表记录。模型空间、图纸空间和普通块定义共用这一张表通过BlockTableRecord.IsLayout属性可以区分它们是否为布局空间。在遍历全图块引用时一般只关心模型空间和图纸空间里实际插入的块。访问链条看起来长但核心思路只有一个凡是对象都要先拿到ObjectId再在事务里打开它。CAD托管API设计了全套事务机制说白了就是——读写对象不允许直接拿裸指针必须通过事务来管理状态和生命周期。2.2 关键类型速查ObjectId、DBObject与事务这里列出几个必须烂熟于心的类型面试新人和带团队时我都拿这套来摸底类型作用关键点ObjectId对象的唯一标识符类似指针本身不包含数据只是一个句柄DBObject所有数据库对象的基类一切对象皆派生自它BlockTable块表用SymbolUtilityServices获取模型空间等特殊记录BlockTableRecord块定义记录用Open(OpenMode.ForRead)打开BlockReference块引用实体插入点、缩放、旋转全靠它AttributeReference属性引用挂在块引用上本质也是实体Transaction事务管理器所有打开操作都在事务内进行很多教程对Transaction一笔带过但我建议你把它理解成借书登记你想看一本书的内容得先到图书馆柜台办手续StartTransaction拿到书Open看完归还Commit或Dispose。不办手续直接上手翻轻则数据不对重则软件崩溃。2.3 为什么要用事务一个真实崩溃现场我之前调试过一个统计程序现象是偶尔崩溃、偶尔正常完全随机。查到最后发现是打开对象后忘记释放导致了对象被垃圾回收时触发托管运行时内部错误。在CAD二次开发里Transaction的Dispose或者Commit不是可选项而是必选项。通俗点说事务就是对象的生命周期保险养成了好习惯后面很多诡异问题自动消失。我自己的习惯写法是这样的using (var trans db.TransactionManager.StartTransaction()) { // ... 在这里面做所有打开、读取操作 trans.Commit(); }using语法保证异常时也会正确释放事务。用Commit之前数据修改类操作需要提交才生效只读查询时其实不提交也不会崩但统一保留Commit是最稳妥的。3. 核心代码实现从块表到块引用的信息获取3.1 第一步遍历块表筛选真正的块定义先写一个最基础的版本打开块表遍历所有块表记录输出块定义名字。[CommandMethod(ListBlockDefs)] public static void ListBlockDefs() { var doc Application.DocumentManager.MdiActiveDocument; if (doc null) return; var db doc.Database; using (var trans db.TransactionManager.StartTransaction()) { // 1. 打开块表 var blockTable (BlockTable)trans.GetObject(db.BlockTableId, OpenMode.ForRead); // 2. 遍历块表中的每条记录 foreach (ObjectId btrId in blockTable) { var btr (BlockTableRecord)trans.GetObject(btrId, OpenMode.ForRead); // 3. 过滤掉布局空间 if (btr.IsLayout) continue; // 4. 输出块定义名字 ed.WriteMessage($\n块定义: {btr.Name}); } trans.Commit(); } }注意这里两个细节点IsLayout是用来排除模型空间和图纸空间等特殊记录的。如果不加这个判断你会在结果里看到一堆名为Model、Layout1之类的特殊块定义。块定义名字是能用中文的这也是CAD在国内落地深的体现。后续如果要按名字过滤建议用精确匹配别用模糊匹配不然容易误伤。3.2 第二步遍历块引用拿到插入点、缩放和旋转块定义拿到了还只是第一步。一般来说客户要的是图里哪个位置摆了哪些块。所以真正干活的是块引用遍历。[CommandMethod(ListBlockRefs)] public static void ListBlockRefs() { var doc Application.DocumentManager.MdiActiveDocument; if (doc null) return; var db doc.Database; var ed doc.Editor; using (var trans db.TransactionManager.StartTransaction()) { var bt (BlockTable)trans.GetObject(db.BlockTableId, OpenMode.ForRead); // 打开模型空间记录 var ms (BlockTableRecord)trans.GetObject(bt[BlockTableRecord.ModelSpace], OpenMode.ForRead); foreach (ObjectId entId in ms) { var ent trans.GetObject(entId, OpenMode.ForRead); if (ent is BlockReference br) { // 通过ObjectId找到BlockTableRecord取块名 var btr (BlockTableRecord)trans.GetObject(br.BlockTableRecord, OpenMode.ForRead); string blockName btr.Name; // 读取变换数据 var position br.Position; var scale br.ScaleFactors; double rotation br.Rotation * 180.0 / Math.PI; ed.WriteMessage($\n块名: {blockName}, 位置: ({position.X:F2},{position.Y:F2},{position.Z:F2}), 缩放: ({scale.X:F2},{scale.Y:F2},{scale.Z:F2}), 旋转: {rotation:F2}°); } } trans.Commit(); } }这里顺便解释一下为什么旋转要转成角度br.Rotation返回的是弧度直接输出小数对设计人员非常不友好转成角度之后才是他们熟悉的数值。3.3 第三步读取属性提取块里的业务数据块引用的属性在CAD里长这样一个块定义里先定义好属性定义AttributeDefinition插入图块时会根据定义生成实际的属性引用AttributeReference每个AttributeReference有Tag和TextString两个关键字段。if (ent is BlockReference br) { // 遍历属性集合 foreach (ObjectId attId in br.AttributeCollection) { var att (AttributeReference)trans.GetObject(attId, OpenMode.ForRead); ed.WriteMessage($\n 属性标签: {att.Tag}, 属性值: {att.TextString}); } }这段代码有个隐含前提块的属性已经是属性引用而非属性定义。如果你直接遍历块定义BlockTableRecord里嵌套的属性定义对象拿到的是AttributeDefinition它们多用于创建块时定义模板不构成具体块实例的业务数据。很多人在块定义记录里翻了半天找不到属性值就是这个原因——属性值必须从块引用实例上读。3.4 完整的块信息汇总工具把上面几步合并写一个相对完整的命令遍历模型空间所有块引用把块名、位置、缩比、旋转和属性统一打印出来。[CommandMethod(DumpBlockInfo)] public static void DumpBlockInfo() { var doc Application.DocumentManager.MdiActiveDocument; if (doc null) return; var db doc.Database; var ed doc.Editor; using (var trans db.TransactionManager.StartTransaction()) { var bt (BlockTable)trans.GetObject(db.BlockTableId, OpenMode.ForRead); var ms (BlockTableRecord)trans.GetObject(bt[BlockTableRecord.ModelSpace], OpenMode.ForRead); foreach (ObjectId entId in ms) { var ent trans.GetObject(entId, OpenMode.ForRead); if (ent is BlockReference br) { var btr (BlockTableRecord)trans.GetObject(br.BlockTableRecord, OpenMode.ForRead); var inVisible btr.IsAnonymous; // 是否匿名块 string name inVisible ? $匿名块({btr.Name}) : btr.Name; ed.WriteMessage($\n 块引用: {name}); ed.WriteMessage($\n插入点: ({br.Position.X:F2}, {br.Position.Y:F2}, {br.Position.Z:F2})); ed.WriteMessage($\n缩放: ({br.ScaleFactors.X:F2}, {br.ScaleFactors.Y:F2}, {br.ScaleFactors.Z:F2})); ed.WriteMessage($\n旋转角度: {br.Rotation * 180.0 / Math.PI:F2}°); foreach (ObjectId attId in br.AttributeCollection) { var att (AttributeReference)trans.GetObject(attId, OpenMode.ForRead); ed.WriteMessage($\n [{att.Tag}] {att.TextString}); } } } trans.Commit(); } }4. 常见问题与排查五个让人头秃的经典场景4.1 块名拿不到先确认你是不是开了匿名块你有没有遇到过这种情况遍历模型空间发现出现了一堆名为*U123的块。这不是乱码这些是匿名块通常由标注、关联阵列或动态块自动生成。用btr.Name拿到的就是这种带*前缀的内部名字如果你按业务名统计这些匿名块会全部变成噪声数据。处理办法读取块定义前先判断IsAnonymous真要拿它的逻辑名需要走BlockTableRecord.GetBlockReferenceIds或另外查找动态块的EffectiveName。对于大多数统计场景直接跳过匿名块更省事。4.2 嵌套块的引用怎么递归取机械图里经常出现总装块里套子装配块子装配块里又套标准件块。读嵌套块需要递归遍历但不能没尽头地套下去。我的做法是写一个递归方法对每个BlockReference先拿到块表记录然后遍历块表记录里的实体如果实体还是BlockReference就继续递归如果实体是AttributeDefinition则读定义。同时维护一个HashSetObjectId记录已经访问过的块定义防止环状引用导致死循环。private static void TraverseBlockReference(Transaction trans, ObjectId blockRefId, HashSetObjectId visited) { var br (BlockReference)trans.GetObject(blockRefId, OpenMode.ForRead); var btr (BlockTableRecord)trans.GetObject(br.BlockTableRecord, OpenMode.ForRead); if (!visited.Add(btr.ObjectId)) { return; } foreach (ObjectId innerId in btr) { var innerObj trans.GetObject(innerId, OpenMode.ForRead); if (innerObj is BlockReference innerBr) { TraverseBlockReference(trans, innerBr.ObjectId, visited); } } }visited.Add的返回值正好用来判断是否这次是首次访问重复访问就直接剪枝双保险稳。4.3 动态块的坑名字对不上、属性读不到动态块在Cad里是块引用的高级形态btr.Name拿到的是动态块的内部名而用户录入的是EffectiveName两者经常对不上。更麻烦的是动态块可选参数不同块引用的几何表现完全不同。解决思路是两条腿走路靠名字统计时用BlockReference.Name这个属性。它对动态块会返回有效名对普通块就是定义名。注意这个属性不是什么时候都能用的某些版本环境下需要先Open块表记录后再看。靠几何位置处理时不能用块定义里的坐标数据硬套必须通过块引用的BlockTransform把实际位置和朝向算出来。string effectiveName br.Name; // 动态块会返回EffectiveName if (string.IsNullOrEmpty(effectiveName)) { effectiveName btr.Name; }顺便说一句动态块遍历时还会遇到同一个动态块引用在块表记录里拥有多个可见性状态的问题要拿当前可见的几何实体还得做额外过滤这一步极其消耗时间和耐心能不做就不做。4.4 外部参照带来的幽灵块图纸用了外部参照时模型空间里并不直接出现被参照的块实体但遍历某些容器时还是会看到外部参照引用。遇到这种情况先判断IsExternalReference要么跳过要么单独处理。千万别把外部参照里的块当成当前图纸的真实块统计否则数据直接失真。顺带留个处理技巧判断代码很简单if (btr.IsExternalReference) { // 是外部参照跳过或做特殊标记 continue; }4.5 事务未提交或过早释放的崩溃这个问题的报错往往有点难懂什么eLockViolation或ObjectErased排查到最后发现是事务嵌套或对象被提前Dispose。我有个实用原则一个命令一个事务打开对象用完即弃绝不过早Dispose或二次提交。如果需要在循环中打开很多对象也别开一个事务循环一次事务数量过多性能会暴跌。我递进组内代码时有个不写进文档的规范所有GetObject调用必须标注OpenMode.ForRead还是OpenMode.ForWrite一旦发现有人用ForWrite打开只读对象一律打回。5. 进阶块信息统计与导出示例5.1 统计全图块数量按名称分组有些场景不需要属性只需要图里某块用了多少次。这种统计本质是对字典操作但在CAD里必须注意统计对象要从模型空间、图纸空间以及所有块定义里嵌套的引用一起去查。这里给出一个简化版本只统计模型空间和当前图纸空间的顶层引用。[CommandMethod(CountBlocks)] public static void CountBlocks() { var doc Application.DocumentManager.MdiActiveDocument; if (doc null) return; var db doc.Database; var ed doc.Editor; using (var trans db.TransactionManager.StartTransaction()) { var bt (BlockTable)trans.GetObject(db.BlockTableId, OpenMode.ForRead); var ms (BlockTableRecord)trans.GetObject(bt[BlockTableRecord.ModelSpace], OpenMode.ForRead); var countDict new Dictionarystring, int(); foreach (ObjectId entId in ms) { var ent trans.GetObject(entId, OpenMode.ForRead); if (ent is BlockReference br) { var btr (BlockTableRecord)trans.GetObject(br.BlockTableRecord, OpenMode.ForRead); if (btr.IsAnonymous) continue; string key btr.Name; if (countDict.ContainsKey(key)) countDict[key]; else countDict[key] 1; } } foreach (var pair in countDict) { ed.WriteMessage($\n块名: {pair.Key}, 数量: {pair.Value}); } trans.Commit(); } }5.2 导出到外部文件给Excel/数据库用的CSV弹窗输出只是一时方便要交付给下游系统我通常导出CSV。编码务必选UTF-8 with BOM否则在Excel里打开中文会乱码。using System.IO; using System.Text; // ... 在上述统计循环基础上 var sb new StringBuilder(); sb.AppendLine(BlockName,PositionX,PositionY,RotationDeg,Tag,Value); // 在遍历块引用内部 sb.AppendLine(${blockName},{br.Position.X:F2},{br.Position.Y:F2},{br.Rotation * 180.0 / Math.PI:F2},{tag},{value}); File.WriteAllText(block_info.csv, sb.ToString(), new UTF8Encoding(true));注意CSV里的字段如果包含逗号或引号必须做转义否则打开Excel直接错列。我的做法是给所有字符串字段都套上双引号再用双写引号替代内嵌引号这是最省心的方案。5.3 联动业务场景设备清单自动生成最后一个例子比较综合来自我实际做过的某设备管理插件——图纸里的设备都是带属性的块要求自动生成设备清单。处理思路比上面复杂的点在于设备块定义里挂的属性定义五花八门。有些块用编号作标签有些用设备编号还有的混用中文标签和英文标签。写代码的时候我用一个配置字典做标签映射var tagMapping new Dictionarystring, string() { { 编号, DeviceId }, { 设备编号, DeviceId }, { 型号, Model }, { 规格, Spec }, { 厂家, Manufacturer } };然后在遍历块引用的属性时根据att.Tag查映射表常见的直接映射未知的保留原标签。这一招非常实用能应付大量看着不规范但大体一致的图纸数据。少部分极端不规范的图纸再走人工处理流程。6. 性能优化与工程化落地的几条经验6.1 大数据量图纸怎么提速度图纸里动辄上万个块引用时逐一遍历虽然逻辑简单但速度很拉胯。实测过一份三万多个块引用的图纸不加优化的遍历耗时好几分钟。提速三板斧开事务时指定只读模式能ForRead就不ForWrite写模式会引入锁和事务日志开销大得多。用OpenObject而非GetObject获取同一事务内已打开的对象如果你频繁访问同一对象可以考虑用Transaction.GetObject配合变量缓存避免反复查表。避免逐点输出到命令行高密度WriteMessage会有UI开销。实测最快的方式是攒一批字符串最后统一输出一次或者干脆写进文件。6.2 面向多种CAD平台兼容要注意的事现在很多项目并非只跑一款CAD软件还有兼容国产CAD的需求。API大体一致但细节总有差异。我的建议是核心逻辑层不依赖具体平台把获取块信息封装成独立的数据访问层上层业务全走自己的数据结构。调用入口层做一个可比对的适配器切换平台时只改入口层不动业务逻辑。版本差异最常出现在属性名和行为细节上比如某平台对BlockReference.Name的实现就不完全一致尽量用基础API少用带平台特色的新特性。6.3 调试技巧快要崩溃时怎么定位CAD二次开发有个很磨人的特点异常有时候不会当场弹出而是在事务提交时或下一次命令时冒出来。我调试时的经验是把所有外层命令方法包一层try-catch在catch里输出完整堆栈到文件。在每个GetObject调用上一行加注释标明这个对象干什么用的一旦性能或行为异常直接用注释当线索排查。用ed.WriteMessage输出中间值逐段验证数据走到了哪一步。这套方法在排查明明有块但统计数量是0的问题时帮了大忙——最后发现是遍历的容器选错了一直在图纸空间容器里数没进模型空间。7. 聊聊我在实际项目里体会最深的三件事第一件块信息获取看似简单但它几乎是所有批量处理功能的地基。无论图审、算量、清单生成还是数据迁移底层都是把块的信息准确、完整、高效地捞出来。地基不稳楼上全塌。我在项目里接到过不少统计不准的客诉查到最后全是某些特殊块没被正确处理造成的。第二件一定要有一套自己的边界测试图。我电脑里常备一张测试图纸里面专门堆了嵌套块、动态块、匿名块、外部参照块、属性缺失块、重名块等极端情况。每改一次块读取逻辑拿这张图整体跑一遍至少能挡掉七成回归问题。没有边界测试图很多坑都是上线才被发现。第三件不要迷信一篇代码抄到底。不同CAD平台、不同版本、不同图纸的细节差异太多了抄来的代码大概率要你自己动手调试。掌握原理理解事务和对象模型远比背代码片段重要。最后分享一个小技巧如果你要遍历大量块引用并判断它们是否重合别先算坐标距离先按块名分桶。把同一块名的引用归到一组以后组内比较数量小得多性能提升是成倍的。CAD二次开发很多时候不是不会写而是没找到合适的简化路径。希望这篇能让你少走几步弯路。
阅读完成 · 觉得有帮助?