VC++ CAD源码包解析:MFC绘图、坐标变换与序列化实战 简介这是一份使用Visual C编写的仿AutoCAD CAD绘图软件完整源代码适合C初学者、图形编程爱好者及需要参考CAD基础实现的开发者。项目涵盖二维图形绘制核心功能支持圆、直线、多边形、圆弧及文字等基本元素并实现对象的编辑、删除、移动等交互操作有助于理解图形渲染、坐标变换、事件驱动编程与对象模型设计等关键知识点。压缩包共157个文件大小4.3MB以C头文件29个h、源文件28个cpp及编译中间文件obj/sbr等为主同时包含21个bmp图像资源、图标与光标文件、工程配置与资源脚本整体目录结构清晰便于按模块深入研读。该资源在CSDN已有1049人学习下载代码可编译运行适合作为课程设计、毕业设计或入门级CAD二次开发的参考蓝本帮助读者从源码层面掌握VC图形界面程序的实际构建流程。1. VC的CAD源码包这份老代码为什么仍值得你花一个晚上跑通当一个压缩包被命名为“VC开发的CAD绘图软件源代码.rar”时它通常意味着一套基于MFC的文档/视图架构、一个能交互绘制直线/圆/矩形的窗口、还有把图元数据写进文件的序列化逻辑。对新手来说这是理解Windows图形程序最直观的入口对熟手来说这是做图形编辑器原型、给老系统做功能扩展时最省事的底图。别因为它看起来老就跳过真正把代码跑通的那一晚消息循环、GDI绘图和坐标变换这几个概念你会一口气串起来。2. 源码包结构拆解图元类、文档视图架构和文件格式2.1 解压后先看什么从.dsp到.sln的工程地图拿到源码包的第一步不是急着双击编译而是先在解压目录里把文件过一遍。这类CAD工程通常由四组代码组成应用类负责程序启动和初始化文档类负责图元数据的存储与读写视图类负责屏幕绘制和鼠标交互框架类负责窗口边框、菜单和工具栏。除此之外资源脚本文件、图标、位图以及老式.dsp/.dsw工程文件也会躺在根目录里。文件类型典型作用改动频率App类 .h/.cpp程序入口、InitInstance、注册文档模板极少改Doc类 .h/.cpp图元容器、Serialize、New/Open/Save频繁View类 .h/.cppOnDraw、鼠标消息、橡皮筋、捕捉最频繁Frame类 .h/.cpp主窗口标题、工具栏停靠偶尔.rc / resource.h菜单、快捷键、对话框资源加功能时改如果你打开后只看到.dsp或.dsw文件不要慌。新版开发环境在打开旧工程时会给出转换入口或者你直接新建一个MFC工程把旧代码文件逐个加进去。我一般选后者因为旧工程转换过来常会带一堆过时配置比如废弃的API宏、过时的依赖项路径后面排错反而更痛苦。2.2 图元基类与派生体系一个纯虚函数接口撑起所有图形CAD绘图软件的数据模型核心是一个图元基类。直线、圆、圆弧、矩形、多段线都从它派生各自实现绘制、命中测试和序列化三个纯虚函数。这样做的好处是视图类只需要持有基类指针遍历时统一调用Draw不用写一堆if/else判断图元类型以后新增图元类型只影响派生类本身。// CADEntity.h —— 图元基类的接口设计 #pragma once class CEntity { public: CEntity() : m_nColor(RGB(0, 0, 0)) , m_nLineWidth(0) , m_nLayer(0) {} virtual ~CEntity() {} // 纯虚函数每个图元自己负责画自己 virtual void Draw(CDC* pDC) const 0; // 命中测试pt是已转成逻辑坐标的鼠标点 // 返回TRUE表示点落在图元拾取范围内 virtual BOOL HitTest(const CPoint pt, int nTolerance) const 0; // 序列化把图元写入文件或从文件读出 virtual void Serialize(CArchive ar) 0; protected: COLORREF m_nColor; // 图元颜色 int m_nLineWidth; // 线宽 int m_nLayer; // 图层号 };这个接口的关键在于把“知道怎么画自己”下沉到每个图元类。视图类只清楚机器约定先调用GetDocument取得图元列表再遍历每个图元调用DrawHitTest则用于选择集操作是后续实现删除、移动、复制的基础。参数nTolerance表示点在多大距离内算命中通常取5到10像素数值越小选择越精确但在对齐操作时容易点不中。2.3 文档类里的图元容器与序列化协议文档类的作用是管理图元集合。老源码包里常见两种容器CObList和std::vector。前者是MFC老式列表遍历需要POSITION游标兼容老代码顺手后者是标准库容器支持随机访问绘制大图时性能更稳。如果源码包用的是CObList可以在不破坏原有读写逻辑的前提下逐步替换成vector替换时注意序列化格式别变。// CADDoc.h —— 文档类中图元容器的最小实现 #pragma once #include vector #include CADEntity.h class CCADDoc : public CDocument { public: // 全部图元统一放在一个向量里 std::vectorCEntity* m_entities; // 添加图元文档自动进入未保存状态 void AddEntity(CEntity* pEntity) { m_entities.push_back(pEntity); SetModifiedFlag(TRUE); } // 序列化保存/打开工程的统一入口 virtual void Serialize(CArchive ar); };序列化是CAD软件文件格式的核心也是新手最容易翻车的地方。推荐的协议是先写一个版本号再写图元总数然后逐图元先写类型ID再写参数。读入时按类型ID用工厂方法new出正确的派生类再把参数依次读回。void CCADDoc::Serialize(CArchive ar) { if (ar.IsStoring()) { // 保存先写版本号方便以后升级文件格式 ar (UINT)CAD_FILE_VERSION; ar (UINT)m_entities.size(); for (size_t i 0; i m_entities.size(); i) { ar (UINT)m_entities[i]-GetTypeID(); // 类型ID先落盘 m_entities[i]-Serialize(ar); // 再写图元参数 } } else { // 读取先清空旧图元再逐条恢复 UINT nVersion, nCount, nType; ar nVersion; if (nVersion CAD_FILE_VERSION) { AfxMessageBox(_T(文件版本高于当前程序无法打开)); return; } ar nCount; for (UINT i 0; i nCount; i) { ar nType; CEntity* pEnt CEntityFactory::Create(nType); if (pEnt) { pEnt-Serialize(ar); m_entities.push_back(pEnt); } } } }参数说明版本号不是可有可无的装饰。以后每加一种图元类型或改一个数据结构版本号都必须递增。读入时先写类型ID再写参数的顺序一旦颠倒整个文件无法恢复。CEntityFactory::Create是一个简单的switch工厂switch(nType)里分别new CLineEntity、CCircleEntity等严格按ID分配。2.4 资源文件与命令消息菜单里的“画圆”是怎么到达视图类的除开代码逻辑工程的.rc资源文件决定了菜单、工具栏和快捷键。你点“画直线”菜单项实际发生的是命令消息路由菜单ID被映射为WM_COMMAND消息框架按顺序找哪个类有对应的OnXxx处理函数最后落到达视图类或文档类。理解这条链后面加功能时才不会把处理函数放错类新建图元命令应放在视图类因为需要先读鼠标输入文件保存命令应放在文档类因为数据属于文档。3. 用最小步骤把源码包跑起来环境、编译和首条直线3.1 环境准备选哪一版VC、装哪些组件先说明这类源码包不需要最新开发环境也能编译。通常建议安装带有“使用C的桌面开发”工作负载的版本这个工作负载里就包含了MFC组件。若不想装完整组件单独勾选“适用于最新生成工具的C MFC”即可。平台怎么选先选x86。老源码里的第三方库和资源几乎都是32位x86调试兼容性最好等整体跑通了再考虑x64。实际操作中有个共性问题打开.dsp旧工程时新环境会提示“新项目结构不受支持”。这时可以选“仅查看项目”然后新建一个空MFC工程把源文件逐个拖进去也可以让转换向导直接生成.vcxproj。我更倾向先试转换失败再手工关联这样能最快看到原有代码结构长什么样。3.2 字符集与依赖库编译前必须检查的三处配置旧CAD源码包编译报错最多的地方集中在字符集和链接依赖。动手编译前先检查三处工程属性 - 常规 - 字符集确认是多字节还是Unicode。老代码如果没写_T()宏用Unicode会触发大量C2664工程属性 - 链接器 - 输入 - 附加依赖项旧工程可能写死了某些.lib的绝对路径改成相对路径或用$(LibraryPath)工程属性 - C/C - 预处理器确认没有和MFC冲突的宏_WINDOWS和_AFXDLL这类要保留如果链接器报LNK2019找不到外部符号优先怀疑这三处。然后逐个核对依赖项里有没有已经不存在的旧库。现在绘图功能基本直接走GDI/GDI额外依赖项越少越好。3.3 编译通过后的闭环验证从菜单到屏幕上的一条直线编译成功只算进门了还要验证一个完整的交互闭环。标准流程如下点菜单“直线”或按快捷键进入画线模式在客户区按下左键移动鼠标看到跟随鼠标的临时直线松开左键直线固定同时标题栏出现“已修改”标记最小化再恢复窗口直线仍在说明OnDraw重绘正常保存成文件重新打开直线还在说明序列化正常如果第2步没有橡皮筋效果或鼠标一动画面就闪问题在视图类消息处理如果第4步直线消失问题多半在OnDraw没写完如果第5步打开后乱码问题在Serialize。这套流程能在十分钟内定位大多数源码包的问题。源码包里OnLButtonUp通常长这样你可以借此确认工程是否完整// View.cpp —— 直线画完后的正式落库 void CCADView::OnLButtonUp(UINT nFlags, CPoint point) { if (m_bDrawing) { // 手动准备DC保证坐标转换使用同一套映射参数 CClientDC dc(this); OnPrepareDC(dc); dc.DPtoLP(point); CLineEntity* pLine new CLineEntity(m_ptStart, point); CCADDoc* pDoc GetDocument(); pDoc-AddEntity(pLine); m_bDrawing FALSE; ReleaseCapture(); Invalidate(TRUE); // 触发OnDraw走正式绘制路径 } CView::OnLButtonUp(nFlags, point); }逻辑说明鼠标坐标先转成逻辑坐标再存进图元保证后续缩放、平移时坐标体系一致。Invalidate(TRUE)通知窗口重绘但不是立即重画而是在消息循环空闲后统一处理这是Windows绘图的异步特性。若重绘闪烁严重可改成Invalidate(FALSE)并配合重写OnEraseBkgnd。4. 核心绘图机制拆解坐标变换、橡皮筋与重绘流程4.1 逻辑坐标与设备坐标CAD软件里绕不开的映射层所有CAD绘图软件内部都维护着两套坐标。用户看到的是逻辑坐标单位是图形单位原点在图纸左下或客户区中心屏幕显示是设备坐标单位是像素原点在窗口左上角。两者之间必须有一层映射MFC里叫映射模式。常见的有MM_TEXT1像素映射1逻辑单位、MM_ISOTROPIC等比缩放、MM_ANISOTROPIC任意缩放。在视图类里重写OnPrepareDC是让GDI绘图命令统一经过这层映射的关键。下面是一个把原点放在客户区中央、x向右增、y向上增的典型配置// View.cpp —— 逻辑坐标与设备坐标映射 void CCADView::OnPrepareDC(CDC* pDC, CPrintInfo* pInfo) { // 先调用基类保证滚动视图的滚动偏移量被应用 CView::OnPrepareDC(pDC, pInfo); CRect rcClient; GetClientRect(rcClient); // 各向同性映射x和y保持等比防止图形变形 pDC-SetMapMode(MM_ANISOTROPIC); pDC-SetWindowExt(rcClient.Width(), rcClient.Height()); pDC-SetViewportExt(rcClient.Width(), -rcClient.Height()); pDC-SetViewportOrg(rcClient.Width() / 2, rcClient.Height() / 2); }参数说明SetWindowExt决定逻辑窗口宽高SetViewportExt决定视口宽高。y方向取了负值坐标轴反转逻辑坐标的y向上增大。SetViewportOrg把原点平移到客户区中央。三个参数共同决定“逻辑坐标(100,100)画在哪里”。窗口大小改变时这套映射会自动跟随变化图形不会挤在一起。4.2 鼠标画图的状态机按下、移动、抬起三段消息画一条线至少涉及三个鼠标消息按下时记录起点移动时显示临时线抬起时生成正式图元。这套状态机是所有交互绘图的基础也是理解矩形、圆、多段线绘制的钥匙。// View.cpp —— 橡皮筋画直线的关键代码 void CCADView::OnLButtonDown(UINT nFlags, CPoint point) { CClientDC dc(this); OnPrepareDC(dc); dc.DPtoLP(point); // 设备坐标转逻辑坐标 m_ptStart point; m_bDrawing TRUE; SetCapture(); // 捕获鼠标移出窗口仍能收到消息 CView::OnLButtonDown(nFlags, point); } void CCADView::OnMouseMove(UINT nFlags, CPoint point) { if (m_bDrawing) { CClientDC dc(this); OnPrepareDC(dc); dc.DPtoLP(point); // R2_NOT异或模式先擦除上一段临时线再画新的临时线 dc.SetROP2(R2_NOT); dc.MoveTo(m_ptStart); dc.LineTo(m_ptPrevMove); dc.MoveTo(m_ptStart); dc.LineTo(point); m_ptPrevMove point; } CView::OnMouseMove(nFlags, point); }这里的核心技巧是R2_NOT绘图模式。异或模式下重复画两次同一条线颜色恢复原状所以先画旧线擦除再画新线。代价是临时线颜色是背景色的反色如果背景被其他窗口遮挡后重绘橡皮筋会留残影所以正式图元落库后还要靠Invalidate重画一遍修正颜色。SetCapture很重要不捕获的话鼠标拖出窗口外松开消息收不到m_bDrawing一直停在TRUE界面就“卡”在画线状态。4.3 OnDraw重绘与闪烁双缓冲是最终答案图元落库后靠Windows发送WM_PAINT消息来重画MFC里对应的是OnDraw。没经验的开发者常犯的错误是在鼠标事件里直接调用绘图函数一旦窗口被遮挡或最小化恢复就发现图丢了。正确做法是永远把正式绘制放进OnDraw鼠标事件只改数据模型然后Invalidate请求重画。// View.cpp —— 双缓冲重绘的完整模式 void CCADView::OnDraw(CDC* pDC) { CCADDoc* pDoc GetDocument(); if (!pDoc) return; CRect rcClient; GetClientRect(rcClient); // 内存DC先在内存中画好再整体贴到屏幕 CDC memDC; CBitmap memBmp; memDC.CreateCompatibleDC(pDC); memBmp.CreateCompatibleBitmap(pDC, rcClient.Width(), rcClient.Height()); CBitmap* pOldBmp memDC.SelectObject(memBmp); // 填充白底 CBrush brWhite(RGB(255, 255, 255)); memDC.FillRect(rcClient, brWhite); // 遍历全部图元画到内存DC for (size_t i 0; i pDoc-m_entities.size(); i) { pDoc-m_entities[i]-Draw(memDC); } // 一次性把内存DC整体贴到屏幕 pDC-BitBlt(0, 0, rcClient.Width(), rcClient.Height(), memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOldBmp); }双缓冲的原理是所有GDI绘制命令先打到内存位图上最后一次BitBlt拷贝到屏幕避免一擦一画造成的闪烁。参数上注意CreateCompatibleBitmap跟随当前DC色深选中的旧位图最后要SelectObject回去否则有GDI对象泄漏风险。验证方法连续画五十条线后拖另一个窗口遮挡再恢复画面完整无闪烁就算合格。4.4 命中测试与拾取鼠标点上去怎么知道点到了谁画完图之后自然会想选中、删除、移动。命中测试的实现逻辑是把鼠标点在各个图元的“拾取阈值”内做距离判断。直线的命中是点到线段的最短距离小于阈值圆的命中是点到圆心的距离与半径之差小于阈值矩形则判断点是否落在四条边的邻域内。阈值一般取5到10像素在比较之前要把用户鼠标设备坐标转成逻辑坐标否则高DPI下距离判断会失真。5. 源码包常见问题与避坑编译、绘制、数据恢复的排障手记5.1 C2664编译错误字符串宽度不一致现象无论在哪台机器上打开同一个旧工程只要字符集默认是Unicode编译就报几十处C2664提示无法把const char[]转换为LPCTSTR。原因源码中的字符串字面量没有用_T()宏包裹老工程原本运行在多字节字符集下char恰好等于LPCTSTR升级后环境默认UnicodeLPCTSTR变成wchar_t类型对不上。解决最省力的方案是到工程属性 - 常规 - 字符集里选“使用多字节字符集”一行代码不用改立刻能编译。如果坚持Unicode就要批量给字面量加_T()注意拼接处还要处理char和wchar_t混用的问题。我建议先走多字节方案跑通后再逐步清理到Unicode一次动太多了排错很难受。同类报错如果绑定了C2143或C4430多半也是字符串宏定义在作怪。5.2 鼠标拖动时整个窗口闪成花屏现象画线过程中拖一次鼠标视图区就闪一次严重时出现黑块白块交替甚至残留破碎的线条。原因鼠标移动消息触发的重绘和系统默认的背景擦除冲突。每次Invalidate后系统先发WM_ERASEBKGND用背景色擦一遍再发WM_PAINT画图元一擦一画就有闪烁如果OnDraw还做了耗时的全图重算闪烁更明显。解决按4.3的方式做双缓冲同时重写OnEraseBkgnd立即返回TRUE跳过系统默认擦背景。若图元数量已经上万创建兼容位图时会比较慢可以把固定尺寸的位图缓存起来只在窗口尺寸变化时重建。这个方案能消除九成以上闪烁问题剩下的边缘情况多半和硬件加速渲染路径有关。5.3 高分屏上点击位置和绘图位置偏移现象在4K屏或125%/150%缩放的屏幕上运行时鼠标明显点哪不画哪画出的直线起点和光标位置差了几个像素到几十像素。原因老程序没声明DPI感知系统按虚拟分辨率缩放进程窗口MFC框架拿到的客户区尺寸和鼠标坐标不是屏幕实际像素坐标换算自然偏移。这个问题一度被当成玄学实际就是DPI缩放没有落入坐标转换链路。解决在应用类InitInstance里创建窗口之前调用SetProcessDPIAware()让进程按实际DPI运行。之后再确认OnPrepareDC里的映射基于真实客户区尺寸。如果需要支持多显示器不同缩放级别还得查Per-Monitor V2 DPI Aware配置那套参数更复杂这里不展开但务必确认是用了系统级的Per-Monitor声明而不是只改进程级开关。5.4 保存再打开后图形乱码或部分丢失现象程序里画了几条线和圆保存成文件重新打开后图形乱成一片或只有一半图元能显示。原因Serialize顺序对不上。常见两种一是保存时只写图元指针而不写类型ID读入时无法知道该new哪个子类二是文档类升级后改了字段顺序老文件按老顺序读参数全部错位。解决给文件格式加版本号按“版本号 图元总数 (类型ID 图元数据)”协议处理。老版本文件的兼容逻辑要保留读入时按版本号分支处理。不要图省事跳过版本号文件格式的兼容处理越早放进代码越好否则每次扩展图元类型都是重灾区。建议在这个基础上再做一个“另存为新版本”的菜单项方便以后客户端来回切换版本。6. 从源码包到自己手头的小工具验证方法与三个实用扩展从源码包起步最怕的是“能编译就以为完事”实际一画就露馅。我的习惯是给工程加一个“导出坐标”的测试命令把所有图元的几何参数按文本文件输出。这相当于把黑匣子变成可检视的数据画完直接肉眼对照坐标就知道几何逻辑对不对。这个验证手段成本极低但对排查序列化和坐标变换问题特别有效。// 导出坐标的简化实现验证绘图数据是否正确 void CCADView::DumpEntitiesToFile(LPCTSTR lpszFile) { CCADDoc* pDoc GetDocument(); if (!pDoc) return; CStdioFile f; if (!f.Open(lpszFile, CFile::modeCreate | CFile::modeWrite | CFile::typeText)) { return; } for (size_t i 0; i pDoc-m_entities.size(); i) { CString strLine; strLine.Format(_T(%d:%s\r\n), i, pDoc-m_entities[i]-ToString()); f.WriteString(strLine); } f.Close(); }这段代码要求图元类实现ToString虚函数。验证时画一条起点(0,0)终点(100,50)的直线导出文件里必须出现对应坐标一旦坐标值对不上问题就锁定在坐标转换环节。再往下走三个扩展方向值得优先考虑。第一是DXF文件的导入导出这是CAD领域的事实标准能让程序与更广泛的CAD生态交换文件第二是正交模式与极轴捕捉绘图时按Shift约束到0或90度实用度立竿见影第三是图层管理给每个图元加图层属性后就能按图层控制显示、颜色和线型处理复杂图纸时才不会乱套。我自己折腾这类图形程序时最容易犯的错就是跳过验证直接堆功能结果图元一多根本分不清是绘制错误还是坐标错误。后来养成习惯每加一种图元先导出坐标核对一次再往深处做。这个方法你也可以直接用希望帮到你。本文还有配套的精品资源点击获取