简介:这是一份面向CAD二次开发入门者的中望CAD(ZWCAD)扩展开发示例工程,基于C++与QT 5.12构建,在Visual Studio 2015环境中编译运行,并附带了完整的VS与QT配置工具链说明。示例通过一个简单的QT对话框集成到CAD中,重点演示了如何读取扩展字典,帮助理解ZRX接口与QT界面协同工作的核心流程;该方案也容易平移到AutoCAD平台。压缩包共273个文件,包含cpp/h源码、dll库、lib/exp链接文件、ui界面描述、vcxproj工程文件、props配置以及调试生成的pdb/ilk等,包体约206MB,既有完整源码也有编译产物可直接参照。已有2817人学习下载,适合希望快速上手“CAD+QT”混合界面二次开发、并需要可运行样例对照源码的开发者。
1. 中望CAD二次开发:C++和Qt的组合,为什么值得先试这条路线
某个工程软件团队接到一个迁移任务:把原来在 AutoCAD 上批量改图框、刷属性、导图号的工具,整体挪到中望CAD 上。网上一搜,中望CAD 二次开发的资料又少又散,团队里有人建议用 LISP 重写,有人建议转 C#。最后真正落地的是一个 C++ 加 Qt 的方案:CAD 侧用 ZRX 接口注册命令、遍历实体、改数据库,界面侧用 Qt 做参数设置对话框。两周后这个工具上了生产线,后续改需求也基本都是一两天的事。
这个组合能跑通,核心原因在于中望CAD 的 C++ 接口 ZRX 和 ObjectARX 在命令注册、实体操作、块表遍历上几乎一一对应,官方 SDK 的结构对 ARX 开发者来说非常眼熟;而 Qt 则补上了 CAD 自带 MFC 界面开发效率低这块短板。如果你有 C++ 基础,或者在 AutoCAD 上写过 ObjectARX,再从 LISP、.NET 这些路线里选,C++ + Qt 是平衡“能不能用”和“好不好维护”最好的答案。
接下来几章我会按工程配置、最小命令、批量改图、Qt 嵌接、常见崩溃问题的顺序拆开讲。前面几章可以直接照着抄,第五章的坑基本是每个项目都会踩一遍的。
2. 先搭对工程:ZRX插件结构、编译环境和最小的命令入口
2.1 ZRX与ObjectARX的分工:中望CAD到底是怎么加载你的代码的
中望CAD的C++插件机制叫 ZRX,编出来的产物不是普通 DLL,而是扩展名为 .zrx 的动态库。CAD 启动后通过 APPLOAD 之类的加载命令把 .zrx 文件读进进程,然后调用里面的入口函数 zcrxEntryPoint。这个入口函数按消息类型分发:模块加载时收到 kInitAppMsg,卸载时收到 kUnloadAppMsg,开发者在这两个分支里做命令注册和资源清理。
ZRX 的类命名习惯和 ARX 很接近,但前缀不同:ZcDbDatabase 对应 AcDbDatabase,ZcDbEntity 对应 AcDbEntity,ZcDbCircle 对应 AcDbCircle。acedSSGet、acedRegCmds 这些全局函数也基本按原样保留。如果你之前写过 ObjectARX,刚转到中望CAD 时最需要适应的是头文件路径变了,以及不少类名的前缀从 Ac 换成了 Zc。
如果从零开始,也不用慌,主线就一条:入口函数收到 kInitAppMsg 时用 addCommand 注册命令;用户在命令行输命令,触发你的静态回调函数;回调里取当前工作数据库,用块表和迭代器去改图。整个插件不维护自己的窗口消息循环,CAD 的主循环已经跑在进程里,你的代码只是挂进去的一批回调。
2.2 建工程时要改的7项配置:头文件目录、lib、字符集和输出后缀
用 Visual Studio 建一个空白的 Win32 DLL 工程,然后把中望CAD 的 SDK 引进来。如果你拿到的是官方向导生成的工程模板,可以直接用;但手动改配置能让你更清楚每一项目的是什么。
| 配置项 | 推荐值/做法 | 说明 |
|---|---|---|
| 头文件目录 | 添加 SDK 的 inc 和 inc64 目录 | 32 位与 64 位分支不要混用 |
| 库文件目录 | 添加 SDK 的 lib 目录 | 与头文件位数保持一致 |
| 字符集 | 使用 Unicode 字符集 | CAD 内部是宽字符,代码里字符串建议写 L"" |
| 输出文件名 | 扩展名改为 .zrx | 否则 CAD 加载模块时找不到入口 |
| 模块定义文件 | 可不填 | ZRX 入口用 extern "C" 导出,一般不需要 .def |
| C++ 标准 | C++14 或以上 | 用 Qt6 则要 C++17,用 Qt5 则 C++14 足够 |
| 运行库 | 多线程 DLL(/MD) | 与 CAD 主进程的 CRT 尽量一致,减少崩溃概率 |
这几个配置里最容易被忽略的是字符集。中望CAD 内部字符串全是宽字符,如果工程误设成多字节,acedSSGet 传入的过滤器字符串就会乱码,命令名注册不上去,加载后输命令毫无反应。
下面是一个能在任意新建图纸里跑通的最小命令,功能是“提示用户框选对象,然后打印选择集数量”。这个代码量不大,但涵盖了入口函数、命令注册、选择集调用和结果输出四个基本动作。
2.3 10行代码跑通第一个命令:注册、加载、调试三步走
#include "zrx/zrx.h" #include "zrx/zced.h" #include "zrx/zcdb.h" static void cmdCountSelection() { ads_name ssName; // 五个参数:选择方式、提示文字、关键字表、过滤器、结果集 int rc = acedSSGet(nullptr, nullptr, nullptr, nullptr, ssName); if (rc != RTNORM) { ads_printf(L"取消选择。\n"); return; } long count = 0; acedSSLength(ssName, &count); ads_printf(L"选中的对象数量: %ld\n", count); acedSSFree(ssName); } extern "C" ZcRxApp::RetCode zcrxEntryPoint(ZcRxAppMsg msg, void* pkt) { if (msg == ZcRxAppMsg::kInitAppMsg) { zcRegCmds->addCommand( L"MY_TOOLS", L"MYCOUNT", L"MYCOUNT", ZCRX_CMD_MODAL, cmdCountSelection); } return ZcRxApp::RetCode::kRetOK; }acedSSGet 的五个参数分别控制:选择方式(点选、窗口、全选等)、点选时的提示文字、关键字表、实体过滤器、最终选择集结果。这里全部传 nullptr,代表让用户用默认框选方式。命令注册的五个参数里,MY_TOOLS 是命令组名,卸载时按组移除;MYCOUNT 是命令显示名,用户在命令行输入 MYCOUNT 即可触发。ZCRX_CMD_MODAL 表示命令执行期间不允许其他命令介入,如果你的命令会弹对话框,这里不要随意改成 ZCRX_CMD_TRANSPARENT。
调试时直接把中望CAD 的 exe 路径填到“调试→启动程序”里,比先开 CAD 再附加进程更省事。编译前再确认一下平台位数:64 位 CAD 只能加载 64 位插件,32 位 CAD 只能加载 32 位插件。我之前在 x64 环境编译完,拷到 32 位系统上加载,报“无法找到模块”,查了半天才发现是 SDK 的 lib 目录选错了。
3. 从命令到批量改图:实体选择、遍历和属性修改的可执行方案
3.1 用过滤器实现“选中指定图层上的圆”
第一轮的 MYCOUNT 命令只能统计数量,实际工具里更常用的是按条件筛选。ZRX 的过滤器是一个 resbuf 链表,restype 表示 DXF 组码,resval 存具体值。比如只选择图层 MY_CIRCLE 上的圆,过滤器这样构造:
static void cmdCountFilteredCircle() { ads_name ssName; resbuf* filter = new resbuf(); filter->restype = 0; // 0 组码表示实体类型 filter->resval.rstring = _tcsdup(L"CIRCLE"); filter->rbnext = new resbuf(); filter->rbnext->restype = 8; // 8 组码表示图层名 filter->rbnext->resval.rstring = _tcsdup(L"MY_CIRCLE"); int rc = acedSSGet(L"_X", nullptr, nullptr, filter, ssName); if (rc == RTNORM) { long count = 0; acedSSLength(ssName, &count); ads_printf(L"图层 MY_CIRCLE 上的圆数量: %ld\n", count); } acedSSFree(ssName); // 释放过滤器链表 resbuf* p = filter; while (p) { resbuf* next = p->rbnext; free(p->resval.rstring); delete p; p = next; } }这里的关键点是 _X 选择方式,它表示“在整个图形空间中按过滤器搜索”,不需要用户交互,批量脚本里非常常用。过滤器链表的释放要自己处理:每个 rstring 是 _tcsdup 出来的堆内存,不能只 delete resbuf。如果你用 Qt,也可以把过滤器里的字符串直接用 fromLocal8Bit 转成 char*,但要保证源字符串是 UTF-8,否则中文图层名会出问题。
3.2 遍历模型空间,把符合条件的圆改到指定图层
比选择集更底层的做法是直接遍历模型空间块表记录。它的好处是不依赖用户交互,适合“整图扫描 + 条件修改”的场景。下面这段代码会遍历模型空间里所有实体,找到半径小于等于指定值的圆,把它们挪到目标图层。
static void moveCircleToLayer(const wchar_t* layerName, double maxRadius) { ZcDbDatabase* db = zcHostAppServices()->workingDatabase(); if (!db) return; ZcDbObjectId layerId = ZcDbObjectId::kNull; if (!findOrCreateLayer(db, layerName, layerId)) return; ZcDbBlockTable* bt = nullptr; db->getBlockTable(bt, ZcDb::kForRead); if (!bt) return; ZcDbBlockTableRecord* btr = nullptr; bt->getAt(L"*Model_Space", btr, ZcDb::kForRead); bt->close(); ZcDbBlockTableRecordIterator* iter = nullptr; btr->newIterator(iter); long changed = 0; for (; !iter->done(); iter->step()) { ZcDbObjectId eid = iter->objectId(); ZcDbEntity* ent = nullptr; if (eid.openStatus(ent, ZcDb::kForWrite) != eOk) continue; if (ent->isKindOf(ZcDbCircle::desc())) { ZcDbCircle* circle = ZcDbCircle::cast(ent); if (circle->radius() <= maxRadius) { ent->setLayerId(layerId); changed++; } } ent->close(); } delete iter; btr->close(); ads_printf(L"已处理 %ld 个圆。\n", changed); }这段代码的运行逻辑是:先取当前工作数据库,拿到块表;再取模型空间块表记录;用迭代器遍历其中每个实体。每个实体打开时用 kForWrite,因为要改图层属性;判断类型后走下转型,半径符合条件就 setLayerId。最后逐个 close,迭代器也要 delete。很多新手漏掉 close 或 delete,短时间跑没问题,图纸一大就会内存暴涨。
参数方面,layerName 是目标图层名,maxRadius 是半径阈值。如果你想让用户交互输入这两个值,可以用 ads_getstring 和 ads_getreal 在命令行收集,后面接 Qt 界面时这套函数也可以保留。
3.3 封装的三个关键操作:找图层、打开实体、关闭资源
上面代码里调用的 findOrCreateLayer 是自封装函数。目标图层如果不存在,setLayerId 会失败,所以在批量修改前要先保证图层存在。这个函数可以统一处理“查询图层 + 新建图层”两个分支:
static bool findOrCreateLayer(ZcDbDatabase* db, const wchar_t* layerName, ZcDbObjectId& outId) { ZcDbLayerTable* lt = nullptr; db->getSymbolTable(lt, ZcDb::kForRead); if (lt->has(layerName)) { lt->getAt(layerName, outId); lt->close(); return true; } lt->close(); // 图层不存在,以写模式重新打开图层表 db->getSymbolTable(lt, ZcDb::kForWrite); ZcDbLayerTableRecord* rec = new ZcDbLayerTableRecord(); rec->setName(layerName); lt->add(rec); outId = rec->objectId(); rec->close(); lt->close(); return true; }这个封装的要点在于:查询用 kForRead,创建用 kForWrite,先读后写必须把读状态关闭再重开。lt->add(rec) 之后,rec 的所有权移交给图层表,不能再手动 delete。图层名重复时,getAt 返回的 outId 可以直接用于后续 setLayerId。
选择集方式和遍历方式的分工也要说清楚:如果只处理用户选中的几十个对象,用 3.1 的过滤器加 acedSSGet 更快;如果要对整张图的某类对象做全量扫描,就用 3.2 的块表遍历。实际项目里我一般两个都写,UI 上让用户勾选“处理范围”。
4. Qt界面嵌入中望CAD:两种做法,我只推荐先做第一种
4.1 模态对话框是“简单但有效”的根基:QDialog::exec()怎么做
中望CAD 主进程是 MFC 程序,默认没有 QApplication。直接用 Qt 的 QDialog 会崩溃,第一步要先在插件里创建一份 QApplication。常见做法是写一个 ensure 函数,用静态指针保证只创建一次。
#include <QApplication> #include <QDialog> QApplication* ensureZcadQtApp() { static QApplication* app = nullptr; if (!app) { int argc = 0; app = new QApplication(argc, nullptr); } return app; }这里 argc 传 0 而不是 main 函数里的参数,是为了避免 Qt 去解析中望CAD 的命令行参数。创建完 QApplication 之后,命令回调里就可以正常弹出对话框了:
static void cmdUiMoveCircle() { ensureZcadQtApp(); MyCircleDialog dlg; dlg.setWindowTitle(L"移动小圆到目标图层"); if (dlg.exec() != QDialog::Accepted) return; moveCircleToLayer(dlg.layerName(), dlg.radius()); }dlg.exec() 是模态事件循环,它会阻塞当前命令回调,但 CAD 主窗口仍然响应重绘。对话框关闭后,exec 返回,代码继续执行 moveCircleToLayer。这个方式之所以稳,是因为 Qt 的事件循环运行在自己的栈上,不会破坏 CAD 主循环。但要注意:对话框打开期间,CAD 命令行处于等待状态,所以不要让用户在对话框里二次触发 CAD 命令。
4.2 停靠面板和浮动工具栏:HWND嵌入的方案与代价
有些工具需要常驻面板,比如“实时显示选中图元坐标”或“图框目录树”。这种场景很多人第一反应是把 Qt 的 QWidget 停靠到 CAD 主窗口里。思路是把 Qt 窗口的 HWND 取出来,用 SetParent 挂到 CAD 主窗口下,再手动处理位置和大小。
HWND hCad = (HWND)zcHostAppServices()->mainWindow(); // 仅示意 SetParent((HWND)dlg.winId(), hCad); dlg.show();这段代码跑起来不难,但后续问题非常多:CAD 不跑 Qt 的事件循环,Qt 窗口里的回车、Tab 切换、下拉框滚轮有时会失灵;切换文档标签时,CAD 会重排子窗口,你的 Qt 面板位置要自己监听消息去纠正;中望CAD 某些版本对子窗口的 Z 序有自己的管理逻辑,面板偶尔会被其他窗口盖住。
| 方案 | 开发量 | 稳定性 | 适用场景 |
|---|---|---|---|
| 模态 QDialog | 小 | 高 | 批量参数设置、向导式操作 |
| 停靠 QWidget | 大 | 中 | 常驻工具面板、实时属性查看 |
实际项目中,我建议项目前期一律用模态对话框。只有当你验证过“常驻面板确实是业务刚需”之后,再投入人力做 HWND 嵌入。先出一个能用的版本,比一开始就追求“高级感”重要得多。
4.3 UI只收集参数,不要让UI直接改图
很多二次开发新手第一次在 Qt 里点按钮调 acedCommandS 发命令,结果 CAD 直接卡死或命令不执行。原因是 Qt 的槽函数运行在自己的事件循环里,此时 CAD 的命令上下文未必就绪。更稳的写法是:UI 只负责收集参数,真正改图的是 3.2 里那个独立函数。
class MyCircleDialog : public QDialog { Q_OBJECT public: QString layerName() const; double radius() const; private slots: void onApply(); private: QLineEdit* layerEdit; QDoubleSpinBox* radiusSpin; }; void MyCircleDialog::onApply() { if (layerEdit->text().trimmed().isEmpty()) { QMessageBox::warning(this, L"提示", L"图层名不能为空"); return; } accept(); }对话框关闭后,cmdUiMoveCircle 里再取 layerName() 和 radius(),把参数传给 moveCircleToLayer。这样 UI 和 CAD 数据操作完全分离,Qt 侧只依赖两个普通函数。如果你需要“点击应用后不关闭对话框,实时预览效果”,可以把 moveCircleToLayer 改成先收集选择集再执行,并且用文档锁保护,但不要让 UI 层直接拼命令字符串。
5. 中望CAD二次开发避坑:现象、原因、解决
5.1 第二次加载时命令重复注册
现象:开发阶段反复 APPLOAD 和卸载插件,某次加载后命令行提示“MYCOUNT 命令重复”,或者同一个命令被执行两次。
原因:上一轮卸载时没有移除命令组。常见的情况是 zcrxEntryPoint 的 kUnloadAppMsg 分支没有写 removeGroup,或者调试时进程异常退出导致卸载消息根本没跑。
解决:注册命令前先移除同组命令,把这一步放在 kInitAppMsg 的最前面,即使上次卸载失败也能兜底。
if (msg == ZcRxAppMsg::kInitAppMsg) { zcRegCmds->removeGroup(L"MY_TOOLS"); zcRegCmds->addCommand(/* 注册参数 */); }5.2 Qt的exec()弹出后,CAD和对话框一起冻结
现象:对话框能弹出来,但 CAD 主窗口完全不能拖动,对话框内部的输入框也不刷新。
原因:QApplication 没有先创建,或者对话框里触发了依赖 CAD 命令行的操作。更隐蔽的原因是进程里已经存在另一个 QApplication 实例,比如其他 Qt 插件先创建了,你再 new 一个直接崩。
解决:统一用 ensureZcadQtApp() 获取单例 QApplication;对话框内不要用 acedCommand 系列函数;exec 之前可以调用一次 processEvents() 让 Qt 的事件系统预热。
5.3 改完实体属性屏幕不刷新,导出却正常
现象:程序里读实体属性已经是新值,但 CAD 图形区显示的还是原来的颜色或图层;保存关闭重开后又正常。
原因:实体打开状态是 kForRead,修改被静默忽略;或者修改成功后没有通知视图刷新。
解决:实体打开时用 kForWrite;批量修改完成后调用 zcRebuildAll() 或者发送一个图形已修改的协议消息。只改几个实体时可以逐实体调用 recordGraphicsModified(),这个习惯也能避免后面做标注更新时翻车。
5.4 卸载插件时崩溃,特别是打开过Qt窗口以后
现象:用 APPLOAD 卸载 .zrx,CAD 直接异常退出,没有任何提示。
原因:Qt 窗口对象在卸载后仍被 Qt 事件系统引用,或者 QApplication 被插件卸载动作提前销毁。中望CAD 主进程是 MFC,不会正常接管 Qt 的资源清理。
解决:在 kUnloadAppMsg 分支里,先删除所有顶层 Qt 窗口,再 delete ensureZcadQtApp() 返回的指针,最后返回 kRetOK。不要在静态析构函数里做 Qt 清理,那一步的执行时机不受你控制。
else if (msg == ZcRxAppMsg::kUnloadAppMsg) { // 先回收所有已打开对话框和面板资源 QApplication* app = ensureZcadQtApp(); delete app; }5.5 多文档切换时数据串场
现象:同时打开两张图,在 A 图执行批量改图层,B 图的实体也被改了。
原因:在 zcrxEntryPoint 初始化阶段缓存了 workingDatabase 指针,之后所有命令都用这个缓存。中望CAD 是多文档,每个文档有自己的数据库,切换标签后缓存失效。
解决:每次命令回调里重新调用 zcHostAppServices()->workingDatabase(),不要把数据库指针存到 static 全局变量。Qt 对话框里如果持有数据库引用,也要在 exec 之前重新获取。
6. 调试技巧和一条值得长期坚持的开发习惯
6.1 用OutputDebugString解决“黑匣子”式排错
CAD 插件调试最难受的是断点命中后整个进程就挂住了,尤其涉及 Qt 事件循环时,F10 单步反而会让问题更难复现。我一般会在关键的入口函数、实体遍历循环、对话框 accept 三个位置加日志输出,用 OutputDebugString 写到系统调试通道,然后开一个 DbgView 工具实时查看。
#ifdef _DEBUG #define ZRX_LOG(msg) OutputDebugStringA(msg) #else #define ZRX_LOG(msg) ((void)0) #endif static void cmdUiMoveCircle() { ZRX_LOG("[zrx] enter cmdUiMoveCircle\n"); ensureZcadQtApp(); MyCircleDialog dlg; if (dlg.exec() != QDialog::Accepted) { ZRX_LOG("[zrx] dialog canceled\n"); return; } ZRX_LOG("[zrx] dialog accepted, apply move\n"); moveCircleToLayer(dlg.layerName(), dlg.radius()); }Release 版里 ZRX_LOG 自动不编译,不用手动删。碰到“对话框关闭后 CAD 崩溃”这种问题,日志能帮你定位是 exec 返回后崩,还是 moveCircleToLayer 内部崩,比看调用栈快得多。
6.2 核心算法与界面分离:同一个命令既能命令行跑,也能UI跑
moveCircleToLayer 这个函数自始至终没有依赖 Qt 和 MFC,它只接收宽字符串和 double 参数。基于这个封装,同一个逻辑可以注册两个命令入口:一个命令行版 MYMOVE,用户用键盘输参数;一个 UI 版 MYMOVEUI,用 Qt 对话框收集参数。
static void cmdMoveCircleFromCmdLine() { ZcGe::GeDouble radius; ads_getreal(L"输入半径阈值", &radius); moveCircleToLayer(L"MY_CIRCLE", radius); }命令行版的价值不只是“多一个入口”,它还是一个天然的回归测试工具。每次改完逻辑,先命令行跑一遍,再套 Qt 界面跑一遍,能快速区分“算法崩了”和“界面崩了”。我交付前都会把这两个入口都测一遍,再把命令行版留在文档里,后续维护的人不需要打开 Qt 就能验证核心逻辑。这个习惯救过我很多次,尤其是遇到那种“UI 一开就崩、命令行一切正常”的疑难杂症。希望这个习惯也能帮到你。
本文还有配套的精品资源,点击获取