简介:这是一份面向C++与MFC初学者及CAD图形开发者的DXF文件显示程序源码,基于Visual Studio工程构建,可加载并可视化DXF矢量图形,帮助理解文件解析与界面绘制的完整链路。压缩包共195个文件,约5.26MB,以h头文件、cpp源文件、obj编译中间文件为主,另含sln、dsw、vcproj等工程配置,sample.dxf示例文件、ReadMe.md说明、ScreenShot.png界面截图及Bin可执行目录,结构完整便于直接编译运行。程序覆盖DXF分段解析、MFC窗口与消息处理、CDC绘图与坐标转换、文件路径限制等关键知识点,并附示例数据供测试。已有873人学习下载,适合作为CAD相关软件开发或DXF数据处理项目的参考范例,帮助读者快速掌握解析、渲染与工程配置的实践思路。
1. 从一份 DXF 文件说起:为什么我选择用 MFC 手搓一个显示程序
前阵子帮一个做机械加工的朋友处理图纸,他发来一堆.dxf文件,问我能不能写个小工具直接打开看轮廓,别每次都开笨重的 CAD。我第一反应是找现成库,但试了几个要么依赖太重,要么在 Windows 上编译各种报错。后来干脆用最朴素的路子:C++ 直接解析 DXF 的组码,MFC 负责画窗口。这就是这个「自制 DXF 显示 MFC 界面程序」的由来——它不追求渲染多华丽,目标很明确:把 DXF 里的直线、圆弧、圆、多段线读出来,在 MFC 视图里按坐标画出来,支持缩放和平移。适合谁?适合正在学 C++、想搞懂图形文件解析、又需要一个能跑起来的 MFC 练手项目的开发者。下面我按「能干什么 → 怎么跑 → 怎么改」的顺序,把这份源码拆开讲。
2. 项目定位与技术选型:为什么是 MFC + 原生解析,而不是 Qt 或第三方库
2.1 这个程序到底解决什么问题
DXF(Drawing Exchange Format)是 CAD 领域通用的矢量图形交换格式,本质是纯文本,用「组码 + 值」成对描述实体。这个程序做的事就三件:读文件、解析实体、画到窗口。它不处理三维、不处理图层样式、不做编辑,只做「看」。听起来简单,但真动手会发现坑不少:DXF 有 ASCII 和二进制两种,版本从 R12 到 2018 差异很大,实体类型几十种。这个项目只覆盖最常见的 LINE、CIRCLE、ARC、LWPOLYLINE 四类,够看大部分二维轮廓图。
为什么不用 Qt?因为目标环境是 Windows 桌面,MFC 是 VS 自带,不用配额外依赖,编译出来就是单个 exe,发给朋友直接能跑。为什么不用第三方 DXF 库?一是学习目的,二是很多库为了通用性把代码写得极重,读源码反而费劲。自己解析组码,几百行就能跑通,改起来心里有数。
2.2 技术栈与目录结构
技术栈很干净:C++11、MFC(基于对话框或 SDI 单文档)、GDI 绘图、标准库文件流。没有 OpenGL,没有 DirectX,所有绘制走CDC的MoveTo、LineTo、Ellipse、Arc。项目结构大致如下:
DxfViewer/ ├── DxfViewer.sln ├── DxfViewer/ │ ├── DxfViewer.h/.cpp // 应用入口 │ ├── MainFrm.h/.cpp // 主框架窗口 │ ├── DxfViewerView.h/.cpp // 视图类,负责绘制 │ ├── DxfViewerDoc.h/.cpp // 文档类,持有实体数据 │ ├── DxfParser.h/.cpp // DXF 解析核心 │ ├── DxfEntity.h // 实体数据结构 │ └── res/ // 图标资源这个划分是 MFC 文档/视图架构的标准做法:Doc存数据,View负责显示,Parser独立出来方便复用。我一般会把解析逻辑和界面彻底分开,这样以后想换渲染方式(比如换成 GDI+ 或 Direct2D),只动 View 就行。
2.3 实体数据结构设计
解析出来的每个图形,需要一个统一的结构来存。这里用基类 + 派生类的做法:
// DxfEntity.h enum class EntityType { LINE, CIRCLE, ARC, POLYLINE }; struct DxfEntity { EntityType type; COLORREF color; // 实体颜色,默认黑 virtual ~DxfEntity() = default; }; struct DxfLine : DxfEntity { double x1, y1, x2, y2; // 起点终点 }; struct DxfCircle : DxfEntity { double cx, cy, r; // 圆心半径 }; struct DxfArc : DxfEntity { double cx, cy, r; double startAngle, endAngle; // 弧度制 }; struct DxfPolyline : DxfEntity { std::vector<std::pair<double,double>> points; bool closed; };用enum class而不是裸枚举,避免命名污染。坐标统一用double,因为 DXF 里坐标精度可能到小数点后好几位,用int会丢精度。颜色先留字段,实际解析时如果组码 62 存在就赋值,不存在就用默认。这个结构后面画图时直接switch(type)分发即可。
3. 解析核心:从组码到实体,一步步把 DXF 读进内存
3.1 DXF 文件格式速览
DXF 是成对出现的:一行组码(整数),一行值。比如:
0 LINE 8 0 10 100.0 20 50.0 11 200.0 21 150.00表示实体开始,值是LINE;8是图层名;10/20是起点 X/Y;11/21是终点 X/Y。解析就是循环读这两行,根据组码决定当前在填哪个字段。关键点:组码和值可能有多余空格,读进来要trim;文件可能以EOF结尾,遇到就停。
3.2 解析器主循环实现
// DxfParser.cpp bool DxfParser::Parse(const std::string& path, std::vector<std::unique_ptr<DxfEntity>>& out) { std::ifstream fin(path); if (!fin) return false; std::string codeLine, valueLine; std::unique_ptr<DxfEntity> current; DxfPolyline* poly = nullptr; while (std::getline(fin, codeLine) && std::getline(fin, valueLine)) { int code = std::stoi(Trim(codeLine)); std::string val = Trim(valueLine); if (code == 0) { // 遇到新实体,先把上一个存起来 if (current) out.push_back(std::move(current)); if (val == "LINE") current = std::make_unique<DxfLine>(); else if (val == "CIRCLE") current = std::make_unique<DxfCircle>(); else if (val == "ARC") current = std::make_unique<DxfArc>(); else if (val == "LWPOLYLINE") { auto p = std::make_unique<DxfPolyline>(); poly = p.get(); current = std::move(p); } else if (val == "EOF") break; else current.reset(); // 不支持的实体先跳过 } else if (current) { FillEntity(current.get(), poly, code, val); } } if (current) out.push_back(std::move(current)); return true; }逻辑说明:外层while每次读两行,保证组码和值配对。code == 0是实体分隔符,先把上一个实体move进结果集,再根据值创建新实体。LWPOLYLINE特殊,因为它的顶点是多个10/20 组码连续出现,需要额外指针poly记录当前多段线。EOF直接跳出。不认识的实体(比如TEXT、DIMENSION)就reset掉,不解析也不报错,保证程序不崩。
参数说明:path是文件路径,out是输出实体列表。返回false只表示文件打不开,解析过程中遇到未知实体不算失败。
3.3 字段填充与多段线处理
void DxfParser::FillEntity(DxfEntity* e, DxfPolyline* poly, int code, const std::string& val) { double d = 0.0; try { d = std::stod(val); } catch (...) { return; } switch (e->type) { case EntityType::LINE: { auto* l = static_cast<DxfLine*>(e); if (code == 10) l->x1 = d; else if (code == 20) l->y1 = d; else if (code == 11) l->x2 = d; else if (code == 21) l->y2 = d; break; } case EntityType::CIRCLE: { auto* c = static_cast<DxfCircle*>(e); if (code == 10) c->cx = d; else if (code == 20) c->cy = d; else if (code == 40) c->r = d; break; } case EntityType::ARC: { auto* a = static_cast<DxfArc*>(e); if (code == 10) a->cx = d; else if (code == 20) a->cy = d; else if (code == 40) a->r = d; else if (code == 50) a->startAngle = d * 3.1415926 / 180.0; else if (code == 51) a->endAngle = d * 3.1415926 / 180.0; break; } case EntityType::POLYLINE: { if (code == 10) poly->points.emplace_back(d, 0.0); else if (code == 20 && !poly->points.empty()) poly->points.back().second = d; else if (code == 70) poly->closed = (static_cast<int>(d) & 1) != 0; break; } } }这里注意 ARC 的角度:DXF 里是度,GDI 的Arc用的是弧度,所以解析时直接转。多段线的70组码是标志位,最低位为 1 表示闭合。10和20是成对出现的,所以10先 push 一个点,20再补 Y 值。如果文件里20出现在10之前(极少见但存在),这里会丢点,稳妥做法是缓存上一个 X,但多数 DXF 都按顺序来,先这样处理够用。
4. 绘制与交互:把实体画到 MFC 视图,并支持缩放平移
4.1 视图类绘制入口
MFC 里绘制走OnDraw,但为了支持缩放平移,我一般把绘制逻辑抽到Render函数,OnDraw只负责调用。核心是维护一个「世界坐标 → 屏幕坐标」的变换:screen = (world - offset) * scale。
// DxfViewerView.cpp void CDxfViewerView::OnDraw(CDC* pDC) { CDxfViewerDoc* pDoc = GetDocument(); CRect rect; GetClientRect(&rect); // 双缓冲,避免闪烁 CDC memDC; memDC.CreateCompatibleDC(pDC); CBitmap bmp; bmp.CreateCompatibleBitmap(pDC, rect.Width(), rect.Height()); memDC.SelectObject(&bmp); memDC.FillSolidRect(&rect, RGB(255,255,255)); for (auto& e : pDoc->m_entities) { DrawEntity(&memDC, e.get()); } pDC->BitBlt(0, 0, rect.Width(), rect.Height(), &memDC, 0, 0, SRCCOPY); }双缓冲是 MFC 绘图的老规矩,不加的话缩放时闪得没法看。m_entities是文档类里存的实体列表,解析完直接塞进去。
4.2 坐标变换与实体绘制
CPoint CDxfViewerView::WorldToScreen(double x, double y) { int sx = static_cast<int>((x - m_offsetX) * m_scale); int sy = static_cast<int>(m_clientHeight - (y - m_offsetY) * m_scale); return CPoint(sx, sy); } void CDxfViewerView::DrawEntity(CDC* pDC, DxfEntity* e) { CPen pen(PS_SOLID, 1, RGB(0,0,0)); CPen* old = pDC->SelectObject(&pen); switch (e->type) { case EntityType::LINE: { auto* l = static_cast<DxfLine*>(e); pDC->MoveTo(WorldToScreen(l->x1, l->y1)); pDC->LineTo(WorldToScreen(l->x2, l->y2)); break; } case EntityType::CIRCLE: { auto* c = static_cast<DxfCircle*>(e); CPoint center = WorldToScreen(c->cx, c->cy); int r = static_cast<int>(c->r * m_scale); pDC->Ellipse(center.x - r, center.y - r, center.x + r, center.y + r); break; } case EntityType::ARC: { auto* a = static_cast<DxfArc*>(e); CPoint center = WorldToScreen(a->cx, a->cy); int r = static_cast<int>(a->r * m_scale); // GDI Arc 角度是逆时针,且 Y 轴向下,需要转换 pDC->Arc(center.x - r, center.y - r, center.x + r, center.y + r, WorldToScreen(a->cx + a->r*cos(a->startAngle), a->cy + a->r*sin(a->startAngle)).x, WorldToScreen(a->cx + a->r*cos(a->startAngle), a->cy + a->r*sin(a->startAngle)).y, WorldToScreen(a->cx + a->r*cos(a->endAngle), a->cy + a->r*sin(a->endAngle)).x, WorldToScreen(a->cx + a->r*cos(a->endAngle), a->cy + a->r*sin(a->endAngle)).y); break; } case EntityType::POLYLINE: { auto* p = static_cast<DxfPolyline*>(e); if (p->points.empty()) break; pDC->MoveTo(WorldToScreen(p->points[0].first, p->points[0].second)); for (size_t i = 1; i < p->points.size(); ++i) pDC->LineTo(WorldToScreen(p->points[i].first, p->points[i].second)); if (p->closed) pDC->LineTo(WorldToScreen(p->points[0].first, p->points[0].second)); break; } } pDC->SelectObject(old); }WorldToScreen里 Y 轴翻转是因为 DXF 的 Y 向上,屏幕 Y 向下。m_clientHeight在OnSize里更新。圆弧的绘制是这里最绕的:GDI 的Arc需要外接矩形和起止点,起止点用角度算出来再转屏幕坐标。注意 GDI 角度方向是逆时针,而屏幕 Y 翻转后视觉上会变成顺时针,实际测试时如果弧画反了,把起止角度对调即可。
4.3 鼠标滚轮缩放与拖拽平移
BOOL CDxfViewerView::OnMouseWheel(UINT nFlags, short zDelta, CPoint pt) { double factor = (zDelta > 0) ? 1.1 : 0.9; // 以鼠标位置为中心缩放 CPoint worldPt = ScreenToWorld(pt); m_scale *= factor; CPoint newScreen = WorldToScreen(worldPt.x, worldPt.y); m_offsetX += (newScreen.x - pt.x) / m_scale; m_offsetY -= (newScreen.y - pt.y) / m_scale; Invalidate(); return TRUE; }以鼠标为中心缩放,体验比以原点缩放好得多。ScreenToWorld是WorldToScreen的逆运算。平移在OnMouseMove里处理:按下左键记录起点,移动时算差值加到m_offsetX/Y。这些交互代码不长,但决定了程序好不好用,建议一开始就加上。
5. 避坑与常见问题:解析和绘制时最容易翻车的几个点
5.1 现象:打开某些 DXF 后程序直接崩溃
原因:std::stod遇到空字符串或非数字会抛异常,而 DXF 里有些组码的值是文本(比如图层名),如果没判断就转数字,直接崩。解决:所有stod/stoi都包在try-catch里,或者先判断字符串是否只含数字和符号。我在FillEntity里已经加了try,但Parse主循环里的stoi(codeLine)也要加,因为有些文件开头有 BOM 或空行。
5.2 现象:图形画出来是上下颠倒的
原因:DXF 坐标系 Y 轴向上,Windows 屏幕坐标 Y 轴向下,忘了翻转。解决:WorldToScreen里用m_clientHeight - y做翻转。如果发现整体镜像,检查是不是漏了这一步,或者m_clientHeight没在OnSize里更新导致用了旧值。
5.3 现象:圆弧方向画反,或者画出来是个大饼
原因:GDIArc的角度参数和 DXF 的起止角定义不一致,加上 Y 翻转后方向变化。解决:先确认 DXF 里角度是度还是弧度(标准是度),转弧度;然后测试一个 0 到 90 度的弧,看画出来是四分之一圆还是补角。如果反了,把起止点参数对调。另外Arc的外接矩形必须用int,半径乘scale后如果小于 1 会画不出,加个最小值判断。
5.4 现象:大图纸缩放后线条消失或错位
原因:int溢出。坐标值乘scale后可能超过int范围(比如坐标 100000,scale 100,就是 10^7,接近int上限)。解决:中间计算用double,最后转int前做 clamp,或者用long long。另外m_scale要限制范围,比如 0.01 到 1000,防止用户滚太快导致数值爆炸。
5.5 现象:多段线只画出一个点
原因:LWPOLYLINE的顶点组码是10/20交替,但有些文件里20可能缺失或顺序错乱。解决:在FillEntity里维护一个「上一个 X」缓存,遇到20时如果当前点列表为空或最后一个点已有 Y 值,就补一个新点。更稳的做法是解析完整个实体后检查点列表,把 Y 为默认值的点过滤掉。
6. 二次开发与改造:从「能看」到「好用」的几个方向
这个项目跑通之后,能改的地方很多。我一般会按优先级来:先加图层控制,再加测量,最后考虑导出。图层控制最简单:解析时把组码8的值存到实体里,视图里加个CListBox列出所有图层,勾选控制显示。测量功能也不难,鼠标点两个点算距离,用WorldToScreen的逆变换拿到世界坐标,勾股定理一算就行。
如果想让渲染更顺滑,可以把 GDI 换成 GDI+,Graphics类自带抗锯齿,圆弧和圆会好看很多。改造时注意:GDI+ 的DrawLine需要Pen和PointF,坐标变换逻辑不变,只是把CDC换成Graphics。下面是个 GDI+ 画线的示例:
// 在 View 里初始化 GDI+ Gdiplus::Graphics graphics(pDC->GetSafeHdc()); graphics.SetSmoothingMode(Gdiplus::SmoothingModeAntiAlias); Gdiplus::Pen pen(Gdiplus::Color(255, 0, 0, 0), 1.0f); graphics.DrawLine(&pen, (float)sx1, (float)sy1, (float)sx2, (float)sy2);参数说明:SmoothingModeAntiAlias开抗锯齿,Pen的宽度用float,颜色用 ARGB。注意 GDI+ 需要GdiplusStartup和GdiplusShutdown,一般在App类里做。
另一个实用改造是支持拖拽打开文件。在OnDropFiles里拿路径,调Parse,然后UpdateAllViews。这样用户不用点菜单,直接把 DXF 拖进窗口就行。我还会加个「适应窗口」按钮,根据所有实体的包围盒自动算scale和offset,省得手动调。
最后说个血泪经验:改解析器时,千万别只测一个文件。我当初用一张简单图纸调通了,结果换了个带多段线的文件,发现70组码没处理闭合,画出来全是断的。从那以后我每次改完解析逻辑,都强制走一遍「直线 + 圆 + 弧 + 闭合多段线 + 开放多段线」这五种实体的测试文件,确认都画对才提交。希望这个项目能帮你少走点弯路,拿到源码后先跑通,再按自己的需求一点点加功能,比一上来就大改要稳得多。
本文还有配套的精品资源,点击获取