☰
C++ MFC 自研 DXF 解析与 GDI 双缓冲图纸预览
2026/11/5 5:45:19 网站建设 项目流程

简介:这份面向C++/MFC初学者与CAD图形显示开发者的DXF查看程序源码包,提供从文件解析到界面绘制的完整示例。程序基于MFC构建Windows界面,围绕DXF段结构、图层与线型读取、CDC绘图及屏幕坐标转换展开,可用于学习矢量图形加载、设备上下文绘制和基础工程组织;同时提示DXF路径避免中文与空格等实战细节,适合课程设计、毕业设计或CAD工具原型参考。压缩包共195个文件,约5.26MB,以h/cpp源码、obj等编译中间产物、sample与dxf测试文件、exe可执行文件及sln/dsw/vcproj等Visual Studio工程配置为主,并含说明文档和界面截图,便于编译对照与二次修改。已有874人学习下载。读者可据此理解解析器与绘图流程的衔接,掌握坐标换算、消息交互和常见路径问题排查,也能以示例DXF快速验证显示效果,为CAD相关软件开发积累可复用的工程经验。

1. 从一份 DXF 说起:图纸预览这件小事,为什么值得自己写一个 MFC 程序

车间发来一份 DXF,客户机器上不让装 CAD,你只想双击看一眼轮廓、确认图层和尺寸标注对不对——这种需求去找现成控件,要么收费要么闭源。这个项目就是为它写的:C++ 配合 MFC 单文档框架,自己解析 DXF 文本组码,用 GDI 把 LINE、CIRCLE、ARC、LWPOLYLINE、TEXT 画到窗口里,带缩放、平移和双缓冲。适合三类人:想给自家设备做个图纸预览工具的开发、想搞懂 DXF 交换格式到底长什么样的人、以及拿 MFC 练手但不想再写计算器的朋友。整份代码不依赖第三方库,VS 打开就能编译,改起来也不用担心被许可证卡脖子。

2. 工程骨架与技术选型:MFC 单文档 + 自研解析 + GDI 绘制

2.1 工程目录:解析、渲染、界面三层切开

拿到这份代码先别急着编译,花两分钟看一眼目录。它的组织方式很直接,按职责分了三层:Dxf/只负责把文本变成内存里的实体表,Render/只负责把实体表变成像素,MFC 的 View 只负责收消息和双缓冲。三层之间靠DxfDocument这个数据对象传,解析层不认识 GDI,渲染层也不认识 CFileDialog。

DxfViewer/ ├── DxfViewer.sln └── DxfViewer/ ├── MainFrm.cpp / .h 主框架窗口 ├── DxfViewerDoc.cpp / .h 文档对象,持有 DxfDocument ├── DxfViewerApp.cpp / .h 应用入口 ├── DxfViewerView.cpp / .h 视图,消息处理 + 双缓冲 ├── Dxf/ │ ├── DxfGroupReader.cpp/.h 逐行读组码对 │ ├── DxfEntity.h 实体结构体定义 │ ├── DxfParser.cpp/.h 状态机,识别实体 │ └── DxfDocument.cpp/.h 实体表、图层表、包围盒 ├── Render/ │ ├── ViewTransform.cpp/.h 世界坐标到屏幕坐标 │ └── DxfRenderer.cpp/.h 按类型分发 GDI 绘制 └── res/ 图标、菜单资源

这样分的好处是改一处不牵动另一处。比如后面想换成 GDI+ 抗锯齿,只动Render/DxfRenderer.cpp,解析层一行不改;想支持新的实体类型,只加结构体和解析分支,视图层不用管。我见过不少 MFC 小工具把所有逻辑堆在OnDraw里,几百行下去以后加个图层开关都无从下手,这份代码的分层算是克制。

2.2 技术选型:MFC 单文档加 GDI,够用且不带依赖

选型这件事没有绝对对错,只有场景匹配。这个项目的定位是内部工具、单机预览、图纸通常在几千个实体量级,那么 MFC + GDI 是最省事的组合:VS 新建一个单文档工程就能跑,不需要额外拷 DLL,客户机器上直接扔 exe。对比一下另外两条路:

方案额外依赖上手成本适合场景
MFC + GDI无,VS 自带低几千实体、内部预览工具
Qt + QPainterQt 运行库中跨平台、UI 复杂、需要矢量导出
OpenGL / DirectX显卡驱动高十万级实体、三维、实时渲染

GDI 的短板也明显:没有原生抗锯齿,线宽缩放后会失真,Arc的角度参数还不是直接给角度。但在这个量级下,先把功能跑起来比追求画质重要。常见做法是先用 GDI 把链路走通,等真遇到大图纸卡顿了再考虑局部换 GDI+ 或做视口裁剪,而不是一上来就上 OpenGL 把项目拖进图形学的坑里。

2.3 消息链路:从点击打开到首帧上屏

MFC 的文档/视图结构在这里派上了用场。菜单里「打开」映射到 View 的命令处理函数,它弹出文件对话框、调文档对象加载、算全图适配、最后Invalidate触发重绘。整条链路短,调试时在每个环节打个断点就能看清数据从磁盘到屏幕是怎么流的。

// DxfViewerView.cpp:打开文件 -> 解析 -> 适配 -> 重绘 void CDxfViewerView::OnFileOpen() { CFileDialog dlg(TRUE, _T("dxf"), nullptr, OFN_FILEMUSTEXIST, _T("DXF 文件 (*.dxf)|*.dxf|所有文件 (*.*)|*.*||")); if (dlg.DoModal() != IDOK) return; CString path = dlg.GetPathName(); CDxfViewerDoc* pDoc = GetDocument(); if (!pDoc->LoadFromFile(path)) { // 内部调 DxfParser AfxMessageBox(_T("解析失败,请检查文件是否为 ASCII DXF")); return; } CRect rc; GetClientRect(&rc); m_xform.FitToWindow(rc, pDoc->GetBounds()); // 计算 scale 和中心点 m_bFirstPaint = false; Invalidate(); // 异步触发 OnPaint }

CFileDialog的第二个参数是默认扩展名,第四个参数OFN_FILEMUSTEXIST保证用户不会选到一个已被删掉的文件。LoadFromFile返回布尔值而不是抛异常,是因为 MFC 工程里异常跨消息循环传播很容易漏接,用返回值更稳。FitToWindow依赖文档维护的包围盒,所以解析阶段每收一个实体就要顺手更新minX/maxX/minY/maxY,别等画的时候再遍历一遍。Invalidate只是标记脏区域,真正绘制在OnPaint里,这个顺序不能反,否则会拿到空的客户区尺寸。

提示:GetClientRect在窗口最小化时返回 0 宽高,FitToWindow里要做除零保护,否则缩放比例会变成 inf,后续所有坐标都画到屏幕外。

3. DXF 解析核心:组码驱动的实体扫描器

3.1 DXF 的组码骨架:两行一组,先认段再认实体

DXF 是纯文本格式,结构极其规律:每一行组码(整数),下一行是值,成对出现。组码 0 表示「一个新对象的开始」,它的值就是对象类型,比如SECTION、ENDSEC、LINE、CIRCLE。组码 2 在段头里是段名,常见的有HEADER、TABLES、BLOCKS、ENTITIES。真正要画的图形全在ENTITIES段里,所以解析器的思路很简单:一直读到ENTITIES段开始,然后在ENDSEC之前不断按组码 0 切分实体。

0 SECTION 2 ENTITIES 0 LINE 8 0 10 0.0 20 0.0 11 100.0 21 50.0 0 ENDSEC

上面这段就是一条从 (0,0) 到 (100,50)、位于图层 0 的直线。组码 8 是图层名,10/20 是起点 X/Y,11/21 是终点 X/Y。理解了这个「两行一对」的节奏,剩下的工作就是查表:哪个组码对应哪个字段。读取层不用做任何语义判断,只负责把行读出来、去掉行尾的\r,剩下的交给解析器。

// DxfGroupReader.cpp:一次读一对组码和值 bool CDxfGroupReader::Next(DxfGroup& g) { CString codeLine, valueLine; if (!ReadLine(codeLine) || !ReadLine(valueLine)) return false; // 文件结束或读失败 codeLine.Trim(); // 去掉 \r\n 和首尾空格 g.code = _ttoi(codeLine); // 组码一定是整数 g.value = valueLine; // 值保留原样,按需转型 return true; }

这里有两个容易忽略的点。Trim()必须做,Windows 换行是\r\n,只按\n切会把\r留在值末尾,导致字符串比较_T("ENTITIES")永远不成立。另外值不做即时类型转换,因为同一个组码在不同实体里可能是字符串也可能是数字,统一留给解析器按上下文处理,读取层保持无状态。

3.2 实体扫描器:一次遍历把五类实体收进内存

解析器的骨架是一个状态机:WaitSection→InEntities→ 遇到组码 0 且值不是ENDSEC时,把从当前位置到下一个组码 0 之间的所有组码收集起来,交给ParseEntity分发。这种「先切片再解析」的写法比边读边判断清晰得多,每个实体的解析函数只需要关心自己那一段组码。

先定义实体结构体,字段够用就行,不要一上来把 DXF 规范里所有组码都建模:

// DxfEntity.h:只保留绘制必需的字段 struct DxfLine { double x1, y1, x2, y2; }; struct DxfCircle { double cx, cy, r; }; struct DxfArc { double cx, cy, r, startDeg, endDeg; }; struct DxfPoly { std::vector<CPoint2D> pts; bool closed; }; struct DxfText { double x, y, height; CString content; }; struct DxfEntity { enum Type { Line, Circle, Arc, Poly, Text } type; int color; // ACI 索引色,256 表示随层 CString layer; // 图层名 union { DxfLine line; DxfCircle circle; DxfArc arc; DxfPoly poly; DxfText text; }; };

分发函数用组码 0 的值做字符串比较,逐个填充。注意LWPOLYLINE的顶点是成对出现的 10/20,但中间可能夹着 40(起始宽度)、41(结束宽度)、42(凸度),所以不能简单地每隔一个取,必须「遇到 10 就新开一个点,遇到 20 就填当前点的 Y」。

// DxfParser.cpp:按类型分发,这里以 LINE 和 LWPOLYLINE 为例 void CDxfParser::ParseEntity(const std::vector<DxfGroup>& g) { const CString& type = g[0].value; // g[0].code 一定是 0 DxfEntity e; e.color = 256; // 默认随层 e.layer = _T("0"); if (type == _T("LINE")) { e.type = DxfEntity::Line; for (size_t i = 1; i < g.size(); ++i) { switch (g[i].code) { case 8: e.layer = g[i].value; break; case 62: e.color = _ttoi(g[i].value); break; case 10: e.line.x1 = _ttof(g[i].value); break; case 20: e.line.y1 = _ttof(g[i].value); break; case 11: e.line.x2 = _ttof(g[i].value); break; case 21: e.line.y2 = _ttof(g[i].value); break; } } } else if (type == _T("LWPOLYLINE")) { e.type = DxfEntity::Poly; double curX = 0, curY = 0; for (size_t i = 1; i < g.size(); ++i) { switch (g[i].code) { case 8: e.layer = g[i].value; break; case 62: e.color = _ttoi(g[i].value); break; case 70: e.poly.closed = (_ttoi(g[i].value) & 1) != 0; break; case 10: curX = _ttof(g[i].value); break; case 20: // 20 到了才落一个点 curY = _ttof(g[i].value); e.poly.pts.push_back(CPoint2D(curX, curY)); break; } } } // CIRCLE、ARC、TEXT 分支同理,字段分别是 10/20/40、10/20/40/50/51、10/20/40/1 m_doc->AddEntity(e); // 内部同时更新包围盒 }

_ttof是 MFC 的字符串转 double,比atof多一层_T兼容。AddEntity里顺手更新minX/maxX/minY/maxY,是为了后面FitToWindow能直接拿到全图范围,不用再遍历。这里用union存不同实体是为了省内存,代价是访问时必须先确认type,写渲染分发时按switch(e.type)走,不会误读。

3.3 图层、颜色和块引用:别把 62 当成 RGB

组码 62 给的是 ACI 索引色,不是 24 位 RGB。它是一张 256 色的固定表,1 到 255 对应 AutoCAD 的颜色索引,256 表示「随层」,0 表示「随块」。所以渲染时不能直接RGB(62,62,62),得先查表。工程里常见做法是内置一张 256 项的COLORREF数组,前 10 项手写,后面按色相渐变生成,够用且不占空间。

图层的关键在TABLES段里的LAYER表,每一项有组码 2(图层名)和 62(图层颜色)。解析ENTITIES之前先把LAYER表读进一个CMap<CString, LPCTSTR, int, int>,绘制时实体颜色为 256 就去表里取图层色,取不到再退回白色。这一步不做,所有随层实体都会变成同一个颜色,图纸层次全丢。

块引用INSERT是另一个坑。ENTITIES里出现INSERT时,它引用的是BLOCKS段里定义的一组实体,还带插入点(10/20)、缩放(41/42/43)和旋转(50)。完整支持要把块定义存起来、按变换矩阵展开;如果项目只求「能看个大概」,可以先只画插入点一个小十字,或者在界面上提示「含块引用,未展开」。我的建议是先把非块实体跑通,块展开作为第二阶段,否则解析器复杂度会翻倍。

4. 视图变换与渲染:世界坐标到屏幕像素的完整链路

4.1 三套坐标:世界坐标、图纸坐标、设备坐标

DXF 里的坐标是数学坐标系,Y 轴向上;Windows 客户区是设备坐标系,Y 轴向下,原点在左上角。中间还夹着一个「图纸坐标」,其实就是世界坐标平移缩放到视图中心后的结果,很多教程把它和世界坐标混为一谈,导致缩放中心总是算错。这个工程的做法是只保留一套变换参数:m_scale(每单位多少像素)、m_centerX/m_centerY(视图中心对应的世界坐标)、m_originX/m_originY(屏幕偏移),正反变换都从这三个量推。

// ViewTransform.cpp:世界坐标 -> 屏幕坐标 CPoint CViewTransform::WorldToScreen(double wx, double wy) const { double sx = (wx - m_centerX) * m_scale + m_originX; double sy = m_originY - (wy - m_centerY) * m_scale; // Y 翻转 return CPoint((int)lround(sx), (int)lround(sy)); } // 屏幕坐标 -> 世界坐标,滚轮缩放和点选都靠它 void CViewTransform::ScreenToWorld(CPoint pt, double& wx, double& wy) const { wx = (pt.x - m_originX) / m_scale + m_centerX; wy = (m_originY - pt.y) / m_scale + m_centerY; }

Y 翻转只翻位置、不翻尺度,这一点在画圆的时候尤其要小心:圆心翻转,半径只乘m_scale,不要再取负。用lround而不是直接强转,是因为负数直接截断会朝零取整,导致图形整体偏移半个像素,放大到一定程度能看出来。

4.2 缩放平移与全图适配:把手感调顺的三个参数

FitToWindow的目标是让整张图刚好落在客户区里,留一点边距。算法是拿包围盒的宽高和客户区宽高比,取较小的缩放比例,再乘 0.9 留白。中心点取包围盒中心,屏幕偏移取客户区中心。

// ViewTransform.cpp:全图适配 void CViewTransform::FitToWindow(const CRect& client, const DxfBounds& b) { double w = b.maxX - b.minX; double h = b.maxY - b.minY; if (w <= 1e-9 || h <= 1e-9) { // 空图或单点图 m_scale = 1.0; m_centerX = b.minX; m_centerY = b.minY; } else { double sx = client.Width() / w; double sy = client.Height() / h; m_scale = min(sx, sy) * 0.9; // 取小值保证全部可见 m_centerX = (b.minX + b.maxX) / 2; m_centerY = (b.minY + b.maxY) / 2; } m_originX = client.Width() / 2.0; // 世界中心对准屏幕中心 m_originY = client.Height() / 2.0; }

缩放要「以鼠标为中心」,不能以窗口中心为中心,否则用户滚两下就找不到刚才看的地方了。推导很简单:光标对应的世界点(wx, wy)在缩放前后必须落在同一个屏幕位置。已知sx = (wx - cx) * scale + originX,保持sx不变,新偏移就是旧偏移加上(wx - cx) * (oldScale - newScale)。

// ViewTransform.cpp:以某个世界点为锚点缩放 void CViewTransform::ZoomAt(double wx, double wy, double factor) { double newScale = m_scale * factor; if (newScale < 1e-4 || newScale > 1e5) return; // 防止缩到看不见或溢出 m_originX += (wx - m_centerX) * (m_scale - newScale); m_originY -= (wy - m_centerY) * (m_scale - newScale); m_scale = newScale; }

滚轮消息里先ScreenToClient把屏幕坐标转成客户区坐标,再ScreenToWorld求锚点,最后调ZoomAt。zDelta是 120 的倍数,向上滚是正、向下是负,用 1.15 和 1/1.15 做因子,手感比 1.1 更跟手。平移则简单:鼠标按下时记录起点和当时的origin,移动时把像素差值直接加到origin上,松手结束,不要每帧重算包围盒。

4.3 双缓冲与绘制顺序:先填背景再画实体

MFC 默认在OnPaint里直接用CPaintDC画,窗口一拖动就闪成一片。解决办法是固定套路:内存 DC + 兼容位图,画完一次性BitBlt到屏幕,同时把OnEraseBkgnd直接返回TRUE屏蔽系统擦背景。

// DxfViewerView.cpp:双缓冲绘制 void CDxfViewerView::OnPaint() { CPaintDC dc(this); CRect rc; GetClientRect(&rc); if (rc.IsRectEmpty()) return; CDC mem; mem.CreateCompatibleDC(&dc); CBitmap bmp; bmp.CreateCompatibleBitmap(&dc, rc.Width(), rc.Height()); CBitmap* pOld = mem.SelectObject(&bmp); mem.FillSolidRect(&rc, RGB(32, 32, 32)); // 深色背景更像 CAD m_doc.Draw(&mem, m_xform); // 统一渲染入口 dc.BitBlt(0, 0, rc.Width(), rc.Height(), &mem, 0, 0, SRCCOPY); mem.SelectObject(pOld); // 必须还原,否则位图泄漏 }

CreateCompatibleDC和CreateCompatibleBitmap每次OnPaint都创建一次,对于预览工具来说开销可以接受,但如果后面要做实时拖动,建议把内存 DC 和位图缓存成 View 的成员,只改尺寸时重建。SelectObject返回的旧对象一定要还原,这是 GDI 对象泄漏最常见的来源,任务管理器里 GDI 对象数一直涨就是它。绘制顺序上先画背景,再按「填充类实体(多段线)→ 线类实体(直线、圆弧、圆)→ 文字」的顺序,文字最后画,避免被图形盖住。

5. 避坑与排查:解析和显示阶段最容易翻车的五处

5.1 解析阶段:实体读不出来、圆变椭圆、多段线错位

现象一:文件明明有图形,解析后实体数为 0。原因通常是换行符处理不干净。DXF 在 Windows 下是\r\n,如果读取时只按\n切分,组码行末尾会留下\r,_ttoi还能容忍,但_T("ENTITIES")的字符串比较就不相等了,状态机永远进不了实体段。解决是在ReadLine之后统一Trim(),并把组码值比较改成不区分大小写。 另一种可能是文件本身是二进制 DXF,CFileDialog没拦住。二进制 DXF 开头有AutoCAD Binary DXF字样,读第一行判断一下,是就直接提示不支持,别硬解。

现象二:圆画出来是椭圆,或者半径明显不对。原因是 Y 轴翻转时把半径也乘了负号,或者 X、Y 方向的缩放比例不一致。检查WorldToScreen的调用处:圆心走完整变换没问题,半径只能乘m_scale,不能再做 Y 翻转。还有一种情况是客户区宽高比和图纸宽高比不一致时,有人为了「铺满」给 X、Y 设了不同的缩放,圆自然就扁了。FitToWindow里取min(sx, sy)就是为了避免这个,宁可留黑边也不要变形。

现象三:LWPOLYLINE 顶点顺序乱了,线连成一团。这是把 10/20 当成严格成对、按顺序读取的后果。实际文件里两组坐标之间会插入 42(凸度)之类的组码,正确做法是「遇到 10 记下 X,遇到 20 才落一个点」,见 3.2 的解析代码。如果还乱,打日志把每个(10, 20)打出来,和用文本编辑器打开 DXF 看到的内容对一遍,很快能定位。

5.2 显示阶段:闪烁、卡顿、圆弧方向反了

现象四:拖动或缩放时画面闪烁,松手才正常。原因就两个:没做双缓冲,或者OnEraseBkgnd没屏蔽。MFC 的绘制流程是先擦背景再画内容,两步之间屏幕会闪一下白。解决是OnEraseBkgnd直接return TRUE,所有绘制都在OnPaint的内存 DC 里完成。如果已经双缓冲还闪,检查是不是在OnMouseMove里直接调了UpdateWindow,改成Invalidate(FALSE)让系统合并重绘请求。

现象五:圆弧方向反了,或者干脆画到另一侧。GDI 的Arc不是传角度,而是传外接矩形加起止点,起点和终点是「圆心到点的射线与外接矩形的交点」,方向固定逆时针。DXF 的 ARC 给的是起始角和终止角,单位是度,逆时针从 +X 轴算起。转换时要先把角度转成弧度算出端点世界坐标,再走WorldToScreen,由于 Y 翻转,屏幕上的逆时针会变成顺时针,必要时交换起点终点。

// DxfRenderer.cpp:DXF 圆弧 -> GDI Arc void CDxfRenderer::DrawArc(CDC* pDC, const DxfArc& a, const CViewTransform& t) { double r1 = Deg2Rad(a.startDeg), r2 = Deg2Rad(a.endDeg); CPoint p1 = t.WorldToScreen(a.cx + a.r * cos(r1), a.cy + a.r * sin(r1)); CPoint p2 = t.WorldToScreen(a.cx + a.r * cos(r2), a.cy + a.r * sin(r2)); CPoint c = t.WorldToScreen(a.cx, a.cy); int rad = (int)lround(a.r * t.GetScale()); CRect box(c.x - rad, c.y - rad, c.x + rad, c.y + rad); pDC->Arc(&box, p1, p2); // 方向不对时交换 p1、p2 }

还遇到过一种「圆弧变整圆」,多半是起止角读取时把组码 50/51 当成了 40/50,字段错位。解析时给每个分支加断言,比如 ARC 必须有 40、50、51 三个组码,缺一个就跳过并记日志,比默默画错强。

注意:Arc的外接矩形在 MFC 里是left/top/right/bottom,不是宽高,别把CRect(x, y, w, h)当成CRect(x, y, x+w, y+h)用,这个笔误画出来的弧会小一半。

6. 二次开发:从「能看」到「能用」的改造路线

这份代码跑通以后,大多数人下一步想的是加功能。按投入产出比排个序,第一条是图层开关:解析阶段已经存了图层名,只需要在DxfDocument里加一个CMap<CString, BOOL>记录显隐,渲染时跳过隐藏图层的实体,界面上加一个CListCtrl勾选框。改动集中在一处,两小时能做完,但体验提升最明显。

第二条是点选和测量。世界坐标和屏幕坐标的正反变换已经现成,OnLButtonDown里把点击点转成世界坐标,遍历实体做包围盒粗筛,再对直线做点到线段距离、对圆做半径差比较,10 像素以内算命中。命中后高亮,状态栏显示实体类型和坐标。这一步做完,预览工具才真正能替代「打开 CAD 看一眼」的动作。

第三条是导出图片。CBitmap画完以后可以直接CImage保存成 PNG,或者用PrintWindow抓窗口。需要注意的是导出前要把m_scale临时调大一点,否则导出的图按客户区尺寸来,清晰度不够。

改造项改动位置预计工作量价值
图层显隐DxfDocument + View半天高
点选测量View + Renderer1 天高
导出 PNGView2 小时中
块引用展开DxfParser2~3 天中
换 GDI+ 抗锯齿Renderer1 天中

验证方法我一般用「三张图」:一张只有直线的简单图,验证坐标变换;一张含圆弧和圆的图,验证角度和 Y 翻转;一张几万实体的真实图纸,验证性能。每次改完渲染代码,三张图轮一遍,比单测还直观。大图纸卡顿时先看是解析慢还是绘制慢,在LoadFromFile前后和OnPaint前后各加一个GetTickCount,数字会告诉你该优化哪头。

最后说个我自己的教训。早期做这类工具,我习惯把所有实体画完再Invalidate,图大了以后拖动像幻灯片。后来改成视口裁剪:先用当前变换算出可视世界范围,只画包围盒与之相交的实体,几万实体瞬间掉到几百。从那以后我每次加渲染功能,都强制走一遍「几万实体的图拖一拖」这个测试,性能问题早暴露比晚暴露省太多事。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询