十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

从零实现小型CAD:MFC图元模型、坐标变换与DXF兼容

从零实现小型CAD:MFC图元模型、坐标变换与DXF兼容 简介用VC开发小型AutoCAD画图软件的完整工程包也就是WCAD项目面向希望掌握Windows桌面图形编程和CAD基础实现的开发者。资源共172个文件rar压缩包约2.13MB其中50个h头文件与47个cpp源文件构成主体另有40个obj编译中间文件以及cur、bmp、ico等光标、工具栏、图标资源文件并附带可直接运行的exe适合结合源码和效果一起学习。项目覆盖GDI/GDI基础图元绘制、MFC菜单与工具栏交互、画布缩放与视口管理、多边形和表格几何算法、尺寸标注、鼠标消息处理、图形文件保存与加载、性能优化以及错误处理与调试等关键模块内容基本串起一个简易CAD从界面到存储的完整链路。已有405人学习下载适合希望结合实例理解VC、MFC与CAD软件设计思路的初学者也适合有经验开发者快速参考工程结构。WCAD项目本身带有清晰的目录结构便于按模块拆解学习。 说实话我最初盯上“用VC开发小型AutoCAD画图软件”这个项目纯粹是想弄明白一个很朴素的问题CAD 软件凭什么能用鼠标拖几下就把图形画出来像 AutoCAD 这种功能多到头皮发麻的巨无霸底层到底是怎么组织直线、圆、圆弧这些实体的与其瞎猜不如自己用 VCVisual C MFC从零写一个轻量级画图工具。做完这个项目我才发现所谓“CAD”的核心其实就是交互、数据模型、坐标变换和文件格式这几块理解透之后你再看 AutoCAD 的任何操作都会觉得通透。这篇文章就围绕这个“小型 CAD 画图软件”展开内容包括项目架构选型、图元数据模型、鼠标交互实现、平移缩放、DXF 文件兼容以及我在调试过程中踩过的一堆坑。适合想搞懂 CAD 原理的学生、准备做轻量化看图工具的工程师以及任何对 MFC/GDI 绘图感兴趣的开发者。1. 项目定位与整体方案选型1.1 这个项目到底解决了什么问题先说清楚这个软件不是要去复刻 AutoCAD而是做一个“用起来像那么回事”的二维矢量绘图工具。我的目标功能清单很明确图元绘制直线、圆、圆弧、矩形、多段线视图操作滚轮缩放、鼠标中键平移、全图显示编辑能力点选、框选、删除、移动、复制文件交互DXF 格式导入导出、自有工程文件保存这套功能做下来软件体积不到 1MB但它覆盖了 CAD 最核心的机制图元怎么存、怎么画、怎么选、怎么和文件格式互相转换。我做完之后最直接的感受是AutoCAD 里那些“理所当然”的操作背后每一处都是精心的数据设计。适合参考这个项目的人我分成三类。第一类是学生想从原理上理解图形软件第二类是工程师需要在某个垂直工具里嵌入简易画图能力比如工艺图纸标记、简易排料预览第三类是纯粹对 MFC 老技术有兴趣的开发者这套架构依然是理解 Windows 桌面程序的好素材。1.2 为什么选 MFC 的单文档视图架构开发环境用的 VC 6.0 MFC框架直接选了单文档视图SDI。不是没有考虑过 Win32 SDK 手写但真的不推荐。CAD 这种程序命令、鼠标消息、菜单操作都很复杂用 SDK 手写消息分发会死在重复代码上。MFC 的文档/视图架构天然契合 CAD 软件文档类CDocument负责保存图元数据文件读写也放这里视图类CView负责绘制显示、鼠标键盘交互、缩放平移框架负责窗口管理菜单命令自动路由到文档或视图这样一来数据与显示是分离的。我可以在不碰任何绘图代码的情况下先把图元数据模型调通再做交互和界面。对 CAD 这种“数据驱动显示”的程序这个分离太重要了。1.3 为什么不直接走 AutoCAD 二次开发很多人会问想玩 CAD 开发直接用 AutoCAD 的 ObjectARX 或 AutoLISP 不就行了对商业软件确实提供了强大的二次开发接口但那些接口是建立在闭源内核之上的你调用的是一个封装好的“黑盒”。你调用它的 AddLine 方法但看不到它内部怎么存储数据、怎么处理视图变换、怎么实现橡皮筋效果。自己从零写一遍等于把这个黑盒彻底拆开。就算代码结构没有商业软件那么优雅但每一步逻辑都是清晰的图元类从哪来、坐标怎么换算、点选命中怎么判断、DXF 怎么解析全部可掌控。顺便提醒一句AutoCAD 是商业软件直接去碰破解版或者绕过授权检测的行为绝对不可取我们做的是自己的实现研究的是公开的 DXF 文本交换格式和基本的图形学原理。2. 图元数据模型与坐标系统设计2.1 图元对象的基类设计整个程序的根基是一个图元基类。所有图元直线、圆、圆弧等都从它继承基类定义纯虚接口子类实现具体的绘制、命中测试和文件读写。这样做的好处是视图层只面对基类指针不管它到底是直线还是圆。// Entity.h enum EntityType { ENT_LINE, ENT_CIRCLE, ENT_ARC, ENT_RECT }; class CEntity { public: CEntity(); virtual ~CEntity(); // 绘制到指定DC virtual void Draw(CDC* pDC) 0; // 平移 virtual void Move(double dx, double dy) 0; // 点选命中测试 virtual bool HitTest(const CPoint pt, double tolerance) 0; // 包围盒 virtual void GetBoundingBox(double xmin, double ymin, double xmax, double ymax) 0; // DXF读写 virtual bool ReadFromDXF(std::ifstream ifs) 0; virtual void WriteToDXF(std::ofstream ofs) 0; int m_nEntityType; // 图元类型 int m_nLayer; // 图层号 int m_nColor; // 颜色索引 };基类里的 HitTest 值得多说一句。屏幕上点选时鼠标不可能精确点在线段上所以需要一个容差范围通常取 3 到 5 个像素。判断时要把这个容差从设备坐标换算成世界坐标再计算否则在缩放比例很大的时候你会发现明明点在线段旁边程序却死活选不中。2.2 坐标变换世界坐标与设备坐标这是整个软件最容易绕晕的地方。CAD 里图元存的坐标是“世界坐标”单位是毫米或英寸屏幕上显示的是“设备坐标”单位是像素。两者不能混用必须在绘图和点击时做双向换算。我维护四个视图参数显示区域的左边界 m_dViewLeft、下边界 m_dViewBottom、缩放比例 m_dScale像素/单位以及视图矩形 m_rcView。void CMyView::WorldToScreen(double wx, double wy, int sx, int sy) { sx m_rcView.left (int)((wx - m_dViewLeft) * m_dScale); sy m_rcView.bottom - (int)((wy - m_dViewBottom) * m_dScale); } void CMyView::ScreenToWorld(int sx, int sy, double wx, double wy) { wx m_dViewLeft (double)(sx - m_rcView.left) / m_dScale; wy m_dViewBottom (double)(m_rcView.bottom - sy) / m_dScale; }注意 y 轴方向是反的。屏幕坐标系原点在左上角y 轴向下数学坐标系原点在左下角y 轴向上。所以 WorldToScreen 用的是 m_rcView.bottom 减去偏移量。这个细节第一次写的时候很容易搞反一旦反了画出来的图会上下镜像而且你还不容易察觉。把坐标变换单独做出来之后所有交互逻辑都统一调用这两个函数。鼠标点击、拖拉框选、滚轮缩放全部基于同一套换算代码量大幅减少也不容易出现坐标错乱。2.3 数据容器的选择图元容器我用的是 std::vectorCEntity*。原因很简单小型图纸通常几千到几万个图元vector 的连续内存遍历效率最高随机访问也快。虽然删除中间元素需要移动后续数据但这个量级完全无感。删除图元时有个小技巧先和最后一个元素交换再 pop_back。这样避免大段内存移动配合在文档类里统一管理操作非常简单。如果你预期图纸未来会到几十万图元那建议引入网格空间索引或四叉树但那是另一个量级的优化了初期不要过度设计。3. 绘制交互从画线到缩放平移3.1 橡皮筋交互绘制CAD 画直线时鼠标按下是起点拖动时屏幕上实时拉出一条线松开才真正生成图元。这个“实时拉线”就是橡皮筋效果。我的实现不搞专门橡皮筋类而是利用视图重绘来完成。鼠标左键按下时记录起点进入绘制状态鼠标移动时更新当前终点然后 Invalidate(FALSE) 触发重绘OnDraw 里先画所有已提交图元再画一条从起点到当前鼠标位置的预览线。松开左键时把坐标转换为世界坐标创建正式图元加入文档数据退出绘制状态。void CMyView::OnMouseMove(UINT nFlags, CPoint point) { if (m_bDrawing) { // 更新终点并触发重绘 m_ptEnd point; Invalidate(FALSE); } CView::OnMouseMove(nFlags, point); } void CMyView::OnLButtonUp(UINT nFlags, CPoint point) { if (m_bDrawing) { double x1, y1, x2, y2; ScreenToWorld(m_ptStart.x, m_ptStart.y, x1, y1); ScreenToWorld(point.x, point.y, x2, y2); // 创建直线图元加入文档 CEntity* pEnt new CLine(x1, y1, x2, y2); GetDocument()-AddEntity(pEnt); m_bDrawing FALSE; ReleaseCapture(); } CView::OnLButtonUp(nFlags, point); }这里有个重要原则预览图元绝不能加入数据容器。如果加入了你会发现它还停留在选择集里会影响点选命中文件存储时也会莫名其妙多出很多半成品。预览线只在 OnDraw 里用临时状态绘制比如用虚线或不同颜色区分。3.2 平移与滚轮缩放平移的实现很直接鼠标中键按下并拖动时计算鼠标在设备坐标上的位移换算成世界坐标位移然后更新 m_dViewLeft 和 m_dViewBottom。滚轮缩放稍微复杂一点关键点在于“以鼠标位置为锚点”。也就是你要保证缩放前后鼠标指向的世界坐标不变。否则缩放时视角会乱跑操作体验非常差。void CMyView::OnMouseWheel(UINT nFlags, short zDelta, CPoint pt) { double factor (zDelta 0) ? 1.2 : 1.0 / 1.2; // 缩放前先算出鼠标位置对应的世界坐标 double worldX, worldY; ScreenToWorld(pt.x, pt.y, worldX, worldY); // 调整缩放比例并限制范围 m_dScale * factor; if (m_dScale 1e8) m_dScale 1e8; if (m_dScale 1e-8) m_dScale 1e-8; // 重新计算视口边界让鼠标指向的世界坐标保持不动 m_dViewLeft worldX - (pt.x - m_rcView.left) / m_dScale; m_dViewBottom worldY - (m_rcView.bottom - pt.y) / m_dScale; Invalidate(FALSE); }这个公式其实就是 ScreenToWorld 的逆运算。需要注意的地方是把缩放比例限制在合理范围否则用户疯狂滚轮后坐标会被拉到 10 的几十次方后续所有计算都会出现精度问题图形直接消失。3.3 对象选择与高亮点选逻辑其实就是一个 hit test。遍历所有图元调用每个图元的 HitTest第一个命中的作为选中目标。选中后要做的第一件事很简单把选中图元存进一个选中集合然后刷新重绘。高亮显示我在 OnDraw 里做判断如果图元在选中集合中就用高亮色加粗重绘。效果是选中的图元变成红色一眼就能看到。框选时鼠标按下创建一个临时选区矩形抬起时检查每个图元的包围盒是否与选区相交相交就加入选中集合。注意框选有两种模式从左上到右下是包含模式完全在框内才选中从右下到左上是相交模式碰到就算选中。AutoCAD 用的是这个逻辑我也照做了。这个细节很容易被忽略但用户体验差别很大。4. 文件存取与 DXF 兼容性4.1 DXF 文件格式的解析思路DXF 是 Autodesk 公开的文本交换格式虽然不是最精简的格式但胜在开放且有文档可查。它的基础结构是“组码 值”成对出现一行组码、一行值。比如一个直线实体在 DXF 文件里长这样0 SECTION 2 ENTITIES 0 LINE 8 0 10 0.0 20 0.0 11 100.0 21 50.0其中 10/20 是起点坐标11/21 是终点坐标8 是图层名。解析时逐行读取每次读一个组码和值然后根据当前所在的上下文处理。比如读到组码 0 且值是 LINE就知道接下来要读一个直线图元读到 10、20、11、21就依次填到 CLine 对象的坐标里。解析最容易出 bug 的地方是上下文状态。DXF 文件里有 HEADER、TABLES、BLOCKS、ENTITIES 等多个段不同段里相同的组码含义完全不同。我一开始偷懒只扫描 ENTITIES 段结果图纸里带块引用的文件全乱套。后来老老实实加了个当前段标记只用 ENTITIES 段的图元BLOCKS 先解析但不展开这样至少能保证常见图纸正常显示。4.2 项目文件序列化设计虽然 DXF 能互通但作为主工作文件文本解析效率偏低文件也大。我给自己加了一个二进制工程文件格式后缀随意叫 .caddoc。包含文件头魔数 版本号 图元数量后面依次序列化每个图元的类型、颜色、图层和坐标。MFC 的 CArchive 序列化可以省不少事。只需让每个图元类实现 Serialize(CArchive ar)文档保存时遍历图元容器逐个调用即可。二进制格式的好处是读写快、体积小但要注意版本兼容文件头里的版本号就是为此准备的。一旦图元结构改了版本号递增旧文件走迁移逻辑。我自己实际用下来的建议是内部编辑用二进制工程文件对外分享一律导出 DXF。这样兼顾效率和兼容性。5. 调试踩坑记录与性能优化5.1 重绘闪烁问题几乎所有 GDI 绘图程序都会遇到闪烁问题。根因是 Windows 在收到 WM_ERASEBKGND 消息时会用背景色擦除整个客户区紧接着 OnDraw 又重画所有内容一擦一画之间用户肉眼就能看到闪动。最简单的解法是重载 OnEraseBkgnd直接返回 TRUE禁止擦除背景。这样所有内容都交给 OnDraw 重画闪烁立刻减轻一大半。如果还有轻微闪动就上双缓冲在内存 DC 里把全部图元画好然后一次 BitBlt 贴到窗口。BOOL CMyView::OnEraseBkgnd(CDC* pDC) { return TRUE; // 不擦背景避免闪烁 }这个改动虽小但对交互类软件的影响是质的改善。橡皮筋绘制时每一帧都在重绘如果还带背景擦除视觉上就会抖动得厉害。5.2 GDI 对象泄漏GDI 对象泄漏是 MFC 程序最容易犯的错而且症状隐蔽通常表现为程序长时间运行后绘制越来越慢、界面异常花掉。我早期在 OnDraw 里直接创建画笔画刷用完忘记恢复和释放跑一个下午GDI 对象数直接冲上好几万。后来养成一个习惯所有画笔、画刷、字体、位图一律用 MFC 的封装类作为局部变量使用。它们离开作用域会自动析构底层调用 DeleteObject不会漏。// 推荐写法 CPen pen(PS_SOLID, 1, RGB(255, 0, 0)); CPen* pOldPen pDC-SelectObject(pen); // ... 绘制代码 pDC-SelectObject(pOldPen); // 恢复旧对象只要注意“成对选择、成对恢复”配合局部对象自动析构GDI 泄漏基本能从根上杜绝。调试阶段可以打开任务管理器的 GDI 对象列盯一段时间数量稳定不增长基本就没问题了。5.3 大图元数量下的性能优化当图元数量到一万左右直接全量重绘会明显感觉卡顿尤其缩放和平移时。我实践下来的最有效优化是显示区域裁剪OnDraw 里拿到当前视图的显示范围然后遍历图元时先用包围盒判断是否与显示区域相交不相交的直接跳过连绘制函数都不调用。实测效果很明显我的机器上做了一组对比图元数量全量重绘区域裁剪重绘1000约3ms约1ms10000约35ms约8ms50000约180ms约22ms到了 5 万图元全量重绘已经开始让交互有黏滞感但配合区域裁剪后还要继续优化就得考虑网格索引按图元包围盒分桶存储点选时只检查鼠标所在格子附近的图元。不过基本裁剪已经能覆盖绝大多数小型画图场景。做完这个项目我最大的体会是CAD 这套东西并没有想象中神秘它无非是“数据结构 坐标变换 交互反馈”的循环。每个看似简单的操作底下都对应一套清晰的设计决策。后来我再看到 AutoCAD 里的捕捉、标注、图块这类高级功能第一反应不再是“这东西好牛”而是“它大概是用什么数据结构实现的”。如果你想搞清楚一个成熟软件背后的原理最好的方式就是自己动手写一个缩小版这个思路在什么技术栈下都适用。本文还有配套的精品资源点击获取
返回列表