
简介面向ObjectARX二次开发者的自定义实体入门实例定位于C/VC环境下从事AutoCAD插件开发的工程师适合希望创建新型几何对象并理解ARX底层机制的学习者。资源以简单直线实体为范例系统演示从新建ARX工程、派生AcDbLine子类、重写DWG/DXF序列化与worldDraw绘制到注册应用、定义命令、编译加载和调试排错的全流程让读者掌握自定义实体的完整实现路径。压缩包为RAR格式共70个文件大小约5.08MB包含h/cpp完整源码、rc资源、dsp/dsw工程配置以及编译生成的ARX与DBX模块、pdb调试文件等两个子工程TestCmd与TestEntity结构清晰便于按模块对照学习。目前已有1162人学习下载。通过研读这套实例能够掌握自定义实体的类设计、图形数据库读写、DXF输入输出、实体注册与命令封装等关键技术并能借鉴其工程组织方式为后续开发更复杂的CAD扩展功能提供直接参考。1. ObjectARX 自定义实体不只是“画个矩形”刚开始拆这份 ObjectARX 自定义实体代码时我原以为它就是给某 CAD 平台画一个矩形那么简单。真正跑完一遍才发现难的部分不是“画”而是让这个矩形在保存、重新打开、夹点拖拽、炸开、写块这些操作里表现得和原生实体一模一样。这个所谓“简单实例”其实把自定义实体的类声明、worldDraw 显示、夹点响应、DWG/DXF 读写整个链条都串了一遍。如果你正在做 CAD 二次开发被块参照和扩展数据的各种限制弄得头疼想用 C 做一个真正“有逻辑”的图元这份资源能在半天内给你铺好第一条路。新手先照代码跑通老手直接看参数和坑。2. 自定义实体机制为什么需要它以及最小骨架2.1 图块、多段线不够用你什么时候需要自定义实体先说结论80% 的图元用块 扩展数据就能应付剩下的 20%大概长这样。你要画一个设备图例矩形边长由两个参数控制中间要根据房间面积自动调整对角线角度双击之后还要弹出属性框。用块做要么靠属性块 夹点慢慢凑要么在 XData 里塞一堆自定义数据然后每次修改都要用反应器去同步图形。这套方案初期能跑等图例数量上千、文档来回保存另存为之后就会开始出现数据同步漏掉、夹点行为诡异、炸开就全崩的玄学问题。自定义实体解决的是这 20%。它的核心思路很简单你不是放一堆图元进文档而是把一个 C 类实例放进文档这个类自己回答“我长什么样、夹点在哪、数据怎么存”。用 ObjectARX 写自定义实体就是把曾经放在外部字典和反应器里的逻辑集中到这个类内部。从某 CAD 平台的角度看它和一条 LINE、一个 CIRCLE 没有本质区别唯一的区别是它的显示代码由你提供。2.2 自定义实体的最小骨架必须重写的几个方法一个能稳定驻留在图纸里的自定义实体至少要有三组能力显示、编辑、持久化。显示靠 worldDraw编辑靠 getGripPoints 和 moveGripPointsAt持久化靠 dwgOutFields、dxfOutFields 以及对应的 In 方法。这套实例里的类声明如下class MyBoxEntity : public AcDbEntity { public: ACRX_DECLARE_MEMBERS(MyBoxEntity); MyBoxEntity(); virtual ~MyBoxEntity(); void setGeometry(double bx, double by, double w, double h, int idx); // 第一组显示 Adesk::Boolean worldDraw(AcGiWorldDraw* mode) override; // 第二组夹点编辑 Acad::ErrorStatus getGripPoints(AcGePoint3dArray gripPoints, AcDbIntArray osnapModes, AcDbIntArray geomIds) const override; Acad::ErrorStatus moveGripPointsAt(const AcDbIntArray indices, const AcGeVector3d offset) override; // 第三组序列化 Acad::ErrorStatus dwgInFields(AcDbDwgFiler* pFiler) override; Acad::ErrorStatus dwgOutFields(AcDbDwgFiler* pFiler) const override; Acad::ErrorStatus dxfInFields(AcDbDxfFiler* pFiler) override; Acad::ErrorStatus dxfOutFields(AcDbDxfFiler* pFiler) const override; private: double m_baseX, m_baseY; // 左下角坐标 double m_width, m_height; // 矩形尺寸 int m_index; // 设备编号 };方法对应的职责见下表方法家族职责这个实例里做什么worldDraw控制实体在视口里的显示画矩形、对角线和编号文字getGripPoints给出可被鼠标选中的夹点返回矩形的四个角点moveGripPointsAt用户拖夹点时更新数据按拖拽方向修改坐标和尺寸dwgOutFields / dwgInFieldsDWG 保存与读取把几何参数和编号写入文件dxfOutFields / dxfInFieldsDXF 保存与读取以组码形式读写参数2.3 从 worldDraw 到 dwgOutFields一个实体的一生你看到的“实体能被选中、能保存、能拖拽”其实是两个生命周期在交替。第一个生命周期是显示周期。每次某 CAD 平台重绘视图它就调一次 worldDraw。你在这个方法里画什么用户屏幕上看就是什么。第二个生命周期是文档周期。打开图和保存图的时候平台会把实体当成一组字节流写进文件这个写入由 dwgOutFields 完成读取由 dwgInFields 完成。很多时候初学者觉得“自家实体像是黑匣子”就是因为只写了 worldDraw没写 dwgInFields。内存里跑得挺好一保存一打开图形要么变成代理实体要么干脆只剩一个夹点。有两点选型建议给你。第一能覆盖 subWorldDraw 的地方尽量用 subWorldDraw它比 worldDraw 少踩一些派生类扩展时的坑这个实例为了直观用了 worldDraw你自己扩写时注意。第二所有对外修改数据的入口都要走事务或 assertWriteEnabled 保护否则在高版本某 CAD 平台里很容易触发只读崩溃。3. 开发环境与工程骨架版本匹配、入口函数、DEF 导出3.1 版本对应关系VS 和 SDK 必须对齐ObjectARX 最折磨人的不是 C 语法而是版本匹配。SDK 版本、VS 版本、某 CAD 平台版本三者只要错一个编译出来的 arx 就加载不进去而且报错信息往往只有一句“无法加载”。常见的稳定组合见下表。注意某个 CAD 版本具体对应哪个 VS以你安装的 SDK 包内 Readme 为准不同大版本偶尔有更新。CAD 版本代际常用开发环境注意事项CAD 2013 左右VS2010老项目迁移多注意 wchar_t 设置CAD 2016 左右VS2012 / VS2013需要关掉 SDL 检查CAD 2018 左右VS2015 / VS2017工程属性里要手动加 _CRT_SECURE_NO_WARNINGSCAD 2020 之后VS2019x64 是默认目标不要再编 win32 版这份资源的代码是向后兼容的写法类声明里没有依赖太新的特性你在上述任一组合里把工程属性配好就能编。网上很多教程还停留在 VC 6.0 时代用MFC的CString和char*拿到现代 SDK 上第一步就编不过这份资源直接绕开了那堆老古董。3.2 从向导创建工程到手动配置用 ObjectARX Wizard 创建工程最省事装完 Wizard 后新建项目选 ObjectARX模版里会自动生成入口函数和 DEF 文件。没装 Wizard 也没关系手动创建同样很快核心步骤是三步。第一步新建一个空的动态链接库工程目标平台 x64。第二步在工程属性里把 SDK 的 include 和 lib 目录指进去。第三步加一个模块定义文件把两个入口符号导出。# 假设 SDK 解压在 D:\objectarx # Include 目录D:\objectarx\inc # 库目录D:\objectarx\lib\win64 # 链接库按需加acdb.lib、acge.lib、agi.lib、rxapi.lib第三步最容易翻车。arx 和普通 DLL 不一样它需要明确导出acrxEntryPoint很多新人在工程里写了入口函数却漏了 DEF 文件结果一 APPLOAD 就提示“无法找到入口”。模块定义文件内容很简单LIBRARY MYBOX EXPORTS acrxEntryPoint acrxGetApiVersion如果你的 SDK 较新建议把 acrxGetApiVersion 也导出某些版本检查 API 版本时缺这个符号会直接拒载。3.3 入口函数 acrxEntryPoint 与命令注册入口函数是所有 ARX 程序的中枢每次加载和卸载都会收到消息。一个最简入口长这样extern C AcRx::AppRetCode acrxEntryPoint(AcRx::AppMsgCode msg, void* pkt) { switch (msg) { case AcRx::kInitAppMsg: acrxDynamicLinker-unlockApplication(pkt); acrxRegisterAppMDIAware(pkt); MyBoxEntity::rxInit(); acedRegCmds-addCommand( LMYBOX_COMMANDS, LMYBOXCREATE, LMYBOXCREATE, ACRX_CMD_MODAL, createBoxCmd); break; case AcRx::kUnloadAppMsg: acedRegCmds-removeGroup(LMYBOX_COMMANDS); deleteAcRxClass(MyBoxEntity::desc()); break; } return AcRx::kRetOK; }第 11 行的 addCommand 四个参数分别是命令组名、全局命令名、本地化命令名、命令模式最后是回调函数地址。命令组名相当于命名空间卸载时按组移除最干净。第 16 行的 removeGroup 会自动清掉组内所有命令比一个一个 removeCommand 省心。如果你在 kInitAppMsg 里的初始化顺序不对比如 rxInit 放在 addCommand 之后某些版本下加载会成功但实体类不可用点实体时直接崩溃。这里的顺序是安全顺序。3.4 调试技巧让 CAD 直接当作调试器ARX 调试和普通 DLL 不太一样最常见的姿势是把 vs 的调试器直接指向某 CAD 平台的 exe。具体的做法是在 VS 工程属性里调试一栏把“命令”设为某 CAD 平台的 exe 完整路径把“工作目录”设为它的安装目录。然后按 F5某 CAD 平台会启动你在里面 APPLOAD 这个 arx断点就能命中。需要注意的是arx 必须编成 Debug 版并且加载的是 Debug 版程序Release 版某 CAD 平台也能加载 Debug arx但断点经常断不住这是常见坑。加载之后如果崩了先看调用栈绝大部分问题不是算法错而是某个方法没有 override 到。4. 写一个带夹点和序列化的自定义实体完整代码拆解4.1 实体类注册宏让平台认识你的类类的成员函数放在第 2 章已经声明好了但要让某 CAD 平台真正认识这个类还需要一个注册宏。它负责给类分配一个运行时描述符确保实体从 DWG 里读回来时平台能通过类名找到对应的 C 类。ACRX_DXF_DEFINE_MEMBERS( MyBoxEntity, // 类名 AcDbEntity, // 父类 AcDb::kDHL_CURRENT, // DWG 版本 AcDb::kMReleaseCurrent, // 维护版本 AcDb::kTransform_R0, // 变换类型 LMYBOX_ENTITY // 实体类名保存在 DWG 里 );最后那个 LMYBOX_ENTITY 是给平台识别用的字符串会直接写进文件。这个字符串一旦发布就不要改否则老图打开后平台在类字典里找不到对应类实体直接变代理。4.2 worldDraw显示逻辑拆解worldDraw 负责画图。每帧重绘都会调用所以不要在里头做复杂计算也不要做内存分配。本实例只画三样东西矩形外框、对角线、编号文字。Adesk::Boolean MyBoxEntity::worldDraw(AcGiWorldDraw* mode) { assertReadEnabled(); // 1. 画矩形外框终点回到起点形成闭合 AcGePoint3d pts[5]; pts[0].set(m_baseX, m_baseY, 0.0); pts[1].set(m_baseX m_width, m_baseY, 0.0); pts[2].set(m_baseX m_width, m_baseY m_height, 0.0); pts[3].set(m_baseX, m_baseY m_height, 0.0); pts[4].set(m_baseX, m_baseY, 0.0); mode-geometry().polyline(5, pts); // 2. 画右下到左上的对角线 AcGePoint3d diagPts[2]; diagPts[0].set(m_baseX, m_baseY, 0.0); diagPts[1].set(m_baseX m_width, m_baseY m_height, 0.0); mode-geometry().polyline(2, diagPts); // 3. 在中心画一个编号用 ASCII 格式避免字体乱码 wchar_t buf[32]; swprintf_s(buf, 32, LBOX-%d, m_index); AcGiTextStyle style; AcGePoint3d textPos(m_baseX m_width * 0.5, m_baseY m_height * 0.5, 0.0); mode-geometry().text(style, textPos, buf); return Adesk::kTrue; }第 14 行polyline(5, pts)画的是五段闭合线第五个点和第一个点重合。如果用 4 段边框在视觉上没问题但选中高亮时偶尔会漏一条边。第 27 行用 swprintf_s 生成编号文本这里特意没拼中文是为了让不配置字体的环境下也能正常显示。如果你要显示中文必须在 style 上显式设置支持中文的字体否则出来的就是问号或方块。4.3 夹点逻辑getGripPoints 与 moveGripPointsAt夹点之所以重要是因为它是用户跟自定义实体交互的主要入口。本实例把四个角点暴露给用户拖哪个角哪条边跟着动。Acad::ErrorStatus MyBoxEntity::getGripPoints( AcGePoint3dArray gripPoints, AcDbIntArray osnapModes, AcDbIntArray geomIds) const { assertReadEnabled(); gripPoints.setLogicalLength(0); gripPoints.append(AcGePoint3d(m_baseX, m_baseY, 0.0)); // 0 gripPoints.append(AcGePoint3d(m_baseX m_width, m_baseY, 0.0)); // 1 gripPoints.append(AcGePoint3d(m_baseX, m_baseY m_height, 0.0)); // 2 gripPoints.append(AcGePoint3d(m_baseX m_width, m_baseY m_height, 0.0)); // 3 return Acad::eOk; }夹点顺序是有讲究的。我固定成左下、右下、左上、右上后面 moveGripPointsAt 的判断完全依赖这个索引顺序。如果你返回的点和移动逻辑里的索引对不上就会出现“夹点飘在图形外”或者“拖一下图形斜着飞出去”的问题。拖拽逻辑如下Acad::ErrorStatus MyBoxEntity::moveGripPointsAt( const AcDbIntArray indices, const AcGeVector3d offset) { assertWriteEnabled(); for (int i 0; i indices.length(); i) { switch (indices[i]) { case 0: // 左下角 m_baseX offset.x; m_width - offset.x; m_baseY offset.y; m_height - offset.y; break; case 1: // 右下角 m_width offset.x; m_baseY offset.y; m_height - offset.y; break; case 2: // 左上角 m_baseX offset.x; m_width - offset.x; m_height offset.y; break; case 3: // 右上角 m_width offset.x; m_height offset.y; break; } } if (m_width 1.0) m_width 1.0; if (m_height 1.0) m_height 1.0; return Acad::eOk; }offset 是拖拽的全局位移向量。以 case 3 为例用户拖右上角向右偏移 50 个图纸单位m_width 加 50左上角位置不动视觉上就是矩形被拉大了。case 2 拖左上角时m_baseX 和 m_baseY 会跟着偏移同时把宽高反向减掉实现“固定右下角”的效果。这段逻辑如果只对某个角做特例会省很多代码但实际使用中四个角都会被拖这种对称写法最抗折腾。4.4 序列化DWG 和 DXF 读写序列化是自定义实体能不能存得住的关键。先看 DWG 侧写入和读取顺序必须严格一致。Acad::ErrorStatus MyBoxEntity::dwgOutFields(AcDbDwgFiler* pFiler) const { assertReadEnabled(); AcDbEntity::dwgOutFields(pFiler); pFiler-writeInt16(1); // 版本号后续扩展格式用 pFiler-writeDouble(m_baseX); pFiler-writeDouble(m_baseY); pFiler-writeDouble(m_width); pFiler-writeDouble(m_height); pFiler-writeInt32(m_index); return pFiler-filerStatus(); } Acad::ErrorStatus MyBoxEntity::dwgInFields(AcDbDwgFiler* pFiler) { assertWriteEnabled(); AcDbEntity::dwgInFields(pFiler); Adesk::Int16 version 0; pFiler-readInt16(version); if (version 1) { pFiler-readDouble(m_baseX); pFiler-readDouble(m_baseY); pFiler-readDouble(m_width); pFiler-readDouble(m_height); Adesk::Int32 index 0; pFiler-readInt32(index); m_index index; } return pFiler-filerStatus(); }第一次写版本号是这套写法里最重要的一步。你在 dwgOutFields 里只写 m_width 不写版本号读回来也能跑但后续加字段就会遇到老文件兼容问题。现在写死 1以后加字段升到 2老图进来自动走旧逻辑。DXF 侧同样要覆盖组码选择避开基类常用组码。这里用 70 存版本号40 到 43 存坐标尺寸90 存编号这些组码在 AcDbEntity 基类里不会冲突。Acad::ErrorStatus MyBoxEntity::dxfOutFields(AcDbDxfFiler* pFiler) const { assertReadEnabled(); AcDbEntity::dxfOutFields(pFiler); pFiler-wrInt16(70, 1); // 版本号 pFiler-wrDouble(40, m_baseX); pFiler-wrDouble(41, m_baseY); pFiler-wrDouble(42, m_width); pFiler-wrDouble(43, m_height); pFiler-wrInt32(90, m_index); return pFiler-filerStatus(); }如果你只写 DWG 不写 DXF在导出 DXF 或在低版本转存场景里实体会丢失自定义属性。很多“保存为 DXF 再打开就变块”的报告根因都是漏写了 dxfOutFields。4.5 加载测试从命令到夹点测试入口我放在一个命令行函数里负责创建实体并加入模型空间。static void createBoxCmd(void* /*pData*/) { AcDbDatabase* pDb acdbHostApplicationServices()-workingDatabase(); MyBoxEntity* pEnt new MyBoxEntity(); pEnt-setGeometry(100.0, 100.0, 800.0, 400.0, 1); AcDbBlockTableRecord* pRec nullptr; Acad::ErrorStatus es acdbOpenObject(pRec, pDb-currentSpaceId(), AcDb::kForWrite); if (es Acad::eOk) { AcDbObjectId eId; es pRec-appendAcDbEntity(eId, pEnt); pRec-close(); if (es Acad::eOk) pEnt-close(); } }setGeometry 的五个参数依次是左下角 X、左下角 Y、宽度、高度、编号。生产里我会把第二个参数做个命令行交互让用户点两个点决定宽高这个实例先写死。加载测试按这个顺序走编译 → APPLOAD 选中 arx → 输入 MYBOXCREATE → 选中实体看夹点 → 拖拽 → 保存 → 重开图纸确认实体还是自定义实体。5. 避坑排查加载失败、乱码、夹点脱节的几条血泪记录5.1 加载成功但命令找不到现象APPLOAD 提示加载成功输入 MYBOXCREATE 却提示未知命令。原因入口函数里没有在 kInitAppMsg 阶段注册命令或者命令组因为重复加载被某 CAD 平台静默跳过。还有一个常见根因是 acedRegCmds 在当前文档环境下不可用早期版本常踩。解决确认 addCommand 写在 kInitAppMsg 分支里而不是写在某个按钮回调里。命令回调函数必须是文件级静态函数不能是成员函数。卸载时一定要 removeGroup组名和注册时保持一致。我一般还会加一句判断if (acedRegCmds)再注册避免某些特殊启动模式下指针为空。5.2 自定义实体里的中文变成问号或方块现象实体图形正常但文字部分全部显示为 ?。原因worldDraw 里创建 AcGiTextStyle 时没有指定支持中文字体的字体文件默认字体里没有中文字形。这个坑在你第一次用自定义实体做中文标注时百分百会碰到。解决给 AcGiTextStyle 显式设置一个支持中文的字体然后传给 text 方法。如果你不想在 C 里写死字体也可以读取当前图形文字样式表里的中文字体把样式 id 传进来。这个实例的编号故意用 ASCII 编码就是为了让你在不配字体的环境下先把流程跑通等你的业务确定要用中文再按这个思路补字体。5.3 夹点拖到一半图形不跟随现象点击实体能看到夹点拖拽时夹点跟着鼠标走但矩形本身尺寸不变。原因getGripPoints 返回的坐标和 moveGripPointsAt 修改的属性没对上。最常见的情况是你返回的夹点是用 m_width 临时算出来的但 move 里改的是另一个字段比如只改了 m_width 没改 m_baseX拖左边夹点时视觉上右边在动左边一点反应都没有。解决把夹点数组的索引当作契约来对待。每个索引对应的坐标和 moveGripPointsAt 里每个 case 分支修改的变量必须一一对应。改动字段后不要手动去做更新范围的调用某 CAD 平台会把拖拽过程交给自己的事务管理你多余的刷新反而可能造成闪烁。5.4 保存再打开实体变成代理图形现象新画的实体在内存里一切正常关闭图纸再打开图形变成一个带边框的代理对象或者干脆只剩一个空夹点。原因dwgInFields 和 dwgOutFields 没写或者只写了其中一个。某个 CAD 平台打开 DWG 时会反序列化实体如果类里没有实现读取逻辑平台只能把实体标记为代理。更隐蔽的是读写字段顺序不一致读到的全错位实体虽然没有变成代理但几何尺寸完全乱掉。解决两个方法必须成对实现字段顺序保持一致。写完用最笨的办法验证画一个实体保存关闭重新打开选中它在命令行里查属性。你会发现读写顺序错了的情况图形没崩但尺寸突变这种比崩溃还难排查。5.5 改实体类名后老图里的实体认不出来了现象开发中觉得类名不贴切把 MyBoxEntity 改成 FanBoxEntity重新编译加载后旧图里的实体全部变代理有的版本连代理边界都不显示。原因实体类名 LMYBOX_ENTITY 会写进 DWG某 CAD 平台重新打开时按这个字符串去类字典里找对应的类。你改了类名等于告诉平台这个类不存在。解决一旦发布到测试环境实体类名就不要改。如果确实要重构最稳妥的办法是保留旧类名新类继承旧类在新类里做逻辑升级。这样老图打开时平台找到的还是旧类图形不会丢。6. 给实体加版本号存得下、读得回、升级不崩到这里你的自定义实体已经能画、能拖、能存了。但开发还没结束因为程序一定会有下一次改动。这第 6 章我专门说一个习惯在 dwgOutFields 的第一个字段写版本号。这个版本号不是类名而是你自己定义的数据格式版本。比如第一版只存了宽高和编号。过两个月你发现图例还要存一个旋转角度于是加了一个 double m_rotation。如果你直接往末尾写老文件读取时就会读到错误的字节流轻则角度变成随机值重则整个实体读不出来。我的习惯是从第一版就埋好版本位。写的时候先写 1读的时候先读版本再决定读哪些字段。以后加字段把写入版本改成 2读入时给一个分支Acad::ErrorStatus MyBoxEntity::dwgInFields(AcDbDwgFiler* pFiler) { assertWriteEnabled(); AcDbEntity::dwgInFields(pFiler); Adesk::Int16 version 0; pFiler-readInt16(version); if (version 1) { pFiler-readDouble(m_baseX); pFiler-readDouble(m_baseY); pFiler-readDouble(m_width); pFiler-readDouble(m_height); Adesk::Int32 index 0; pFiler-readInt32(index); m_index index; } if (version 2) // 第二版增加旋转角 { pFiler-readDouble(m_rotation); } else { m_rotation 0.0; // 老文件给默认值 } return pFiler-filerStatus(); }逻辑很简单读取永远向前兼容。版本低的文件把新字段设成默认值版本高的文件拒绝加载并提示升级程序。这套思路也适用于 DXF70 组码里存版本号读的时候先取版本再按版本分支解析后面的组码。验证方法也固定下来资源包里有一套编译好的实体我会拿它做“老图升级回归测试”。先在第一版程序里画一批实体保存成测试图。然后切到第二版程序打开这些老图逐一拖拽和重新保存确认没有出现代理实体和数据跳动再往新功能上放。从那以后我每次给自定义实体加字段都强制自己先走一遍这个流程代码写完还要拿老图纸实测一次。后来团队里有人问为什么他的实体一升级就崩我先问一句“你第一版写版本号了吗”十次里有九次答案都是没写。这个习惯帮你省掉大量以后返工的麻烦值得在第一个实体刚跑通的时候就开始用。资源包里的工程和示例代码可以按这个顺序直接编译跑通希望帮到你。本文还有配套的精品资源点击获取