☰
MFC 内嵌 DXF 图纸预览:组码解析、GDI 绘图与性能优化
2026/11/5 7:02:32 网站建设 项目流程

简介:「自制DXF显示MFC界面程序」是一套面向CAD二次开发入门者与C++ Windows桌面开发者的完整工程示例,用于解决DXF矢量图纸在自建界面中解析与可视化的问题。压缩包共195个文件、约5.26MB,其中25个.h头文件与20个.cpp源文件承载核心解析与绘图逻辑,19个.obj、5个.sbr等为编译中间产物,另有可执行文件、.sln/.dsw/.vcproj工程配置、sample.dxf示例图纸、ReadMe.md说明与界面截图,并保留Git版本库信息。程序围绕DXF段结构解析、图层与线型读取、MFC窗口与消息处理、CDC设备上下文绘图及屏幕坐标转换展开,同时给出加载路径不可含中文与空格等实际约束,便于读者对照源码理解从文件解析到界面渲染的完整链路。已有874人学习,适合作为学习C++文件解析、MFC框架与图形绘制的参考项目,也可作为CAD相关软件的原型基础。

1. 一个 MFC 窗口里塞进一张 DXF:为什么我没用现成控件

去年接的一个内部工具,需求很朴素:在已有的 MFC 桌面程序里加一个页签,能把客户发来的 DXF 图纸显示出来,能缩放、能平移、能按图层开关,不要编辑。试过找现成控件,要么授权绕不过去,要么加载大图直接假死,最后干脆用 C++ 自己写了一套解析加显示的骨架。DXF 是纯文本的组码流,MFC 有现成的 CDC 绘图和消息循环,两边接起来的成本比想象中低。这套东西适合手上有 MFC 老工程、需要内嵌图纸预览、又不希望引入重依赖的开发者;如果你要的是三维渲染或图纸编辑,那它不适合你。

2. 把 DXF 拆成组码流:解析器的段结构与实体建模

DXF 看着吓人,本质非常简单:一行组码、一行值,交替往下排。组码是整数,决定这一行的值该怎么解释;值是字符串,具体含义由组码决定。整份文件就是这样一个 (code, value) 序列被 SECTION/ENDSEC 切成若干段。把这层看透,剩下的工作全是查表。

2.1 组码流读取:先把文件变成一对一对的数据

我一般不用任何第三方库,直接用一个薄的读取器把文件流包起来。这里有两个细节必须处理好:组码行可能有前导空格,值行的前导空格在某些实体里是有意义的(比如 TEXT 的内容),所以只对组码行做 Trim。

// DxfGroupReader.h —— 把 DXF 的 ASCII 流切成 (组码, 值) 对 struct DxfPair { int code = 0; CString value; }; class CDxfGroupReader { public: bool Open(const CString& path); // 以文本模式打开,逐行读 bool Next(DxfPair& out); // 取下一对;到文件尾返回 false private: CStdioFile m_file; bool ReadLineRaw(CString& line); }; bool CDxfGroupReader::Next(DxfPair& out) { CString codeLine, valueLine; if (!ReadLineRaw(codeLine)) return false; // 组码行读不到,说明文件结束 if (!ReadLineRaw(valueLine)) return false; // 半对数据,视为损坏 codeLine.Trim(); // 组码行两侧的空格、\r 全部清掉 out.code = _ttoi(codeLine); // 组码一定是整数,转换失败会得到 0 out.value = valueLine; // 值行保留前导空格,只去尾部换行 out.value.TrimRight(_T("\r\n")); return true; }

ReadLineRaw里用CStdioFile::ReadString就够,注意它遇到\n就停,需要自己剥掉\r。这里有一个容易忽略的点:如果_ttoi返回 0,可能是真的组码 0(实体/段的分隔符),也可能是文件损坏;判断依据是值行是否为空。打开文件后建议先读前两行看一眼,如果是二进制 DXF,开头会有一个特殊哨兵串而不是数字组码,遇到这种直接提示不支持,别硬解。

提示:不要让读取器一次把整个文件读进内存。几十兆的 DXF 全量载入再 split,峰值内存会翻好几倍,而且后面做增量加载时还得推倒重来。流式读,边读边建实体。

2.2 段结构:HEADER、TABLES、BLOCKS 各自管什么

很多人写解析器只盯着 ENTITIES,结果画出来的图能看,但颜色全黑、图层名全是数字、块引用变成一堆丢失的实体。四个段的分工必须先理清。

段名作用不解析的后果
HEADER全局变量,$EXTMIN/$EXTMAX是图纸范围,$DWGCODEPAGE是编码页,$INSUNITS是单位首屏无法自动缩放到合适视野,中文乱码
TABLES图层表、线型表、文字样式表ByLayer 颜色解析不出来,图层开关没名字可显示
BLOCKS块定义,每个块内部又是一串实体INSERT 引用无处展开,图块整个消失
ENTITIES模型空间里的实际图元什么都画不出来

$EXTMIN和$EXTMAX分别由组码 10/20/30 给出,是三个连续的 DXF 坐标。这两个值不只是用来做初始视野,还能当成解析正确性的第一道校验——如果你算出来的实体包围盒和它差了一个数量级,八成是坐标变换出了问题,后面第 5 章会细说。图层表在 TABLES 段里,每条 LAYER 记录用组码 2 给名字、62 给颜色号、6 给线型名、70 给标志位(bit 1 冻结、bit 4 锁定);62 为负数表示该图层处于关闭状态,取绝对值才是颜色。

块表稍微麻烦一点。BLOCKS 段内部是BLOCK ... ENDBLK的嵌套结构,每个 BLOCK 用组码 2 给块名、10/20/30 给基点,中间夹着一串实体,最后以ENDBLK收尾。解析方式是维护一个状态:当前在哪个块里,遇到的实体就往哪个块的实体列表里塞。

// 段分发:主循环只看 code==0 的分隔行,其余全部交给段内处理 bool CDxfDoc::Load(const CString& path) { CDxfGroupReader r; if (!r.Open(path)) return false; DxfPair g; while (r.Next(g)) { if (g.code != 0 || g.value != _T("SECTION")) continue; DxfPair name; if (!r.Next(name) || name.code != 2) break; // SECTION 后必跟 (2, 段名) const CString& sec = name.value; if (sec == _T("HEADER")) ReadHeader(r); else if (sec == _T("TABLES")) ReadTables(r); else if (sec == _T("BLOCKS")) ReadBlocks(r); else if (sec == _T("ENTITIES")) ReadEntities(r, nullptr); else SkipToEndSec(r); // 不认识的段整体跳过 } return BuildIndex(); // 建图层索引、算包围盒 }

SkipToEndSec就是一直读到(0, ENDSEC)为止,别试图理解 OBJECTS 段里的字典结构,那个东西对显示没有帮助,硬啃只会浪费时间。BuildIndex里做两件事:把图层名映射成索引,方便后面按图层批量开关;遍历所有实体算出真实包围盒,和$EXTMIN/$EXTMAX做一次比对。

2.3 实体建模:用基类收敛,用工厂分发

实体类型有几十种,全支持不现实也没必要。按经验,LINE、CIRCLE、ARC、LWPOLYLINE、POLYLINE、TEXT、MTEXT、INSERT、POINT、SOLID 这十种能覆盖绝大多数二维图纸。设计上用一个基类放公共字段,派生类只负责自己的绘制。

struct CPoint2d { double x = 0, y = 0; }; class CDxfEntity { public: CString type; // LINE / ARC / LWPOLYLINE / INSERT ... CString layer = _T("0"); // 组码 8 int color = 256; // 组码 62;256 = ByLayer,0 = ByBlock int lineweight = -1; // 组码 370,单位 0.01mm;-1 = ByLayer bool visible = true; // 组码 60,1 表示不可见 CPoint2d ext; // 组码 210/220/230,拉伸方向,默认 (0,0,1) virtual ~CDxfEntity() {} virtual void Draw(CDC& dc, const CDxfView& view) const = 0; virtual void Tessellate(std::vector<CPoint2d>& out) const {} // 弧线离散,见 3.2 };

工厂分发直接按type字符串比较,别上哈希表,几十种类型用 if-else 链反而更快也更好读。每个实体解析完自己的组码后,必须统一处理三个公共字段:8(图层)、62(颜色)、370(线宽)。这三个组码在任何实体里含义都一样,放在基类解析函数里做一次,派生类不用重复写。

LWPOLYLINE 是重点。它的结构是:90 给顶点数,70 给标志(bit 1 表示闭合),然后按顶点顺序重复出现 10/20(坐标)、40/41(起止宽)、42(凸度)。凸度 bulge 是很多人卡住的地方,它等于tan(θ/4),θ 是这一段圆弧的包含角,正负表示逆时针还是顺时针。bulge 为 0 就是直线段。POLYLINE 是它的老版本,顶点是独立的 VERTEX 实体,必须以 SEQEND 结尾,解析时要靠状态机记住「我正在一条 POLYLINE 里」,否则 VERTEX 会被当成独立实体画成一堆孤立点。

实体关键组码
LINE10/20/30 起点,11/21/31 终点
CIRCLE10/20/30 圆心,40 半径
ARC10/20/30 圆心,40 半径,50 起始角,51 终止角(度,逆时针)
LWPOLYLINE90 顶点数,70 标志,重复的 10/20/42
TEXT10/20 插入点,40 字高,1 内容,50 旋转角,72/73 对齐,11/21 对齐点
MTEXT10/20 插入点,40 字高,41 参考宽度,71 附着方式,1 与 3 分段正文
INSERT2 块名,10/20/30 插入点,41/42/43 三轴缩放,50 旋转角

MTEXT 的正文会分片:第一片在组码 1,后续续片在组码 3,必须按出现顺序拼起来,并且里面带\P换行、\f字体、{\...}分组这类格式化控制符。显示阶段把它们全部剥掉只留纯文本是可以接受的折中,但要记得剥,否则图纸上会出现一堆反斜杠。

3. MFC 视图层落地:坐标映射、双缓冲与滚轮缩放

解析出来的坐标是 DXF 世界坐标,Y 轴向上,单位可能是毫米也可能是英寸;屏幕坐标 Y 轴向下,单位是像素。这两套坐标系之间的转换如果靠每画一个点手动算一次,代码会烂到没法维护。正确做法是把变换交给 GDI 的映射模式。

3.1 用 MM_ANISOTROPIC 把变换交给 DC

MM_ANISOTROPIC允许 X 和 Y 两个方向独立设置缩放比例,正好用来做 Y 轴翻转。映射公式是:

xScreen = (xWorld - centerX) * scale + orgX yScreen = -(yWorld - centerY) * scale + orgY

scale是像素/世界单位的比例,center是当前视图中心对应的世界坐标,org是客户区中心点。落地时这样设:

void CDxfView::SetupMapping(CDC& dc) const { dc.SetMapMode(MM_ANISOTROPIC); dc.SetWindowExt(1000, 1000); // 世界窗口,固定值即可 dc.SetViewportExt((int)(1000 * m_scale), -(int)(1000 * m_scale)); // 负号 = 翻转 Y dc.SetWindowOrg((int)m_center.x, (int)m_center.y); dc.SetViewportOrg(m_org.x, m_org.y); }

SetWindowExt里的 1000 是个任意基准值,真正决定缩放的是 viewportExt 和 windowExt 的比值。把它固定住,整个程序里就只有一个m_scale需要维护,滚轮缩放改这一个变量就行。SetWindowOrg这里用了(int)强转,是因为 MFC 的SetWindowOrg只接受整型。当m_scale很小、世界坐标又很大的时候,这个取整会带来亚像素抖动,表现是缩放到极限时图元轻微跳动。要彻底解决得换成SetWorldTransform配合XFORM,那是浮点的,精度够,但需要SetGraphicsMode(GM_ADVANCED),而且和后面要说的 Y 翻转叠加时符号容易搞混。常规预览场景下,用m_scale不小于 1e-3 的下限限制就够了。

值得注意的是,Y 翻转会带来一个副作用:所有文字都会上下镜像。所以文字不能走这套映射,得单独处理,第 4 章会讲。

3.2 双缓冲:不是可选优化,是底线

MFC 的CView默认在OnDraw之前会发WM_ERASEBKGND擦背景,然后才绘制。数据量一大,擦和画之间的时间差就会被人眼捕捉到,表现为拖动时整屏闪烁。双缓冲的写法很标准:

void CDxfView::OnDraw(CDC* pDC) { CRect rc; GetClientRect(&rc); if (rc.IsRectEmpty()) return; CDC memDC; memDC.CreateCompatibleDC(pDC); CBitmap bmp; bmp.CreateCompatibleBitmap(pDC, rc.Width(), rc.Height()); CBitmap* pOld = memDC.SelectObject(&bmp); memDC.FillSolidRect(&rc, m_bgColor); memDC.SetBkMode(TRANSPARENT); int save = memDC.SaveDC(); SetupMapping(memDC); for (const auto& e : m_doc.m_entities) { if (!m_layerOn[e->layer]) continue; // 图层关闭直接跳过 if (!e->visible) continue; // 组码 60 标记为不可见 e->Draw(memDC, *this); } memDC.RestoreDC(save); pDC->BitBlt(0, 0, rc.Width(), rc.Height(), &memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOld); }

配套必须重写OnEraseBkgnd,直接return TRUE,把擦背景的活儿交给FillSolidRect,否则闪烁只是减轻而不是消失。另外CreateCompatibleBitmap每次OnDraw都调一次,会持续分配 GDI 对象,窗口尺寸不变时应该把 memDC 和 bitmap 缓存成成员变量,只在OnSize里重建。这条在开发阶段感觉不出来,跑一晚上内存监控就能看见句柄数缓慢上涨,血泪经验。

注意:SaveDC/RestoreDC一定要成对,而且RestoreDC必须在选回原位图之前。顺序反了会导致映射模式泄漏到BitBlt那一步,画面整体偏移。

3.3 滚轮缩放和平移:以光标为锚点

最简单的缩放在OnMouseWheel里把m_scale乘一个系数然后重绘,但用户会立刻发现图在往屏幕中心跑,想看的那个角落越缩越远。正确做法是让光标位置下的世界坐标在缩放前后保持不动。

BOOL CDxfView::OnMouseWheel(UINT nFlags, short zDelta, CPoint pt) { CPoint client = pt; ScreenToClient(&client); CPoint2d before = ScreenToWorld(client); // 缩放前光标下的世界坐标 double factor = (zDelta > 0) ? 1.25 : 0.8; // 每格 25% double next = m_scale * factor; if (next < 1e-3) next = 1e-3; // 下限,防抖 if (next > 1e4) next = 1e4; // 上限,防溢出 m_scale = next; CPoint2d after = ScreenToWorld(client); // 缩放后同一个屏幕点对应的世界坐标 m_center.x += before.x - after.x; // 把差值补回视图中心 m_center.y += before.y - after.y; Invalidate(FALSE); // FALSE:不擦背景,靠双缓冲盖住 return TRUE; }

ScreenToWorld就是 3.1 那个公式的反解:

CPoint2d CDxfView::ScreenToWorld(const CPoint& p) const { CPoint2d w; w.x = (p.x - m_org.x) / m_scale + m_center.x; w.y = -(p.y - m_org.y) / m_scale + m_center.y; return w; }

注意w.y前面的负号,忘了写会导致缩放锚点上下颠倒,现象是「放大时光标下方的图反而往反方向跑」,很容易被当成浮点误差排查半天。平移则是处理WM_MBUTTONDOWN/WM_MBUTTONUP/WM_MOUSEMOVE三件套:按下时记下起点并置标志位,移动时算出像素位移,除以m_scale后取反加到m_center上。Y 方向同样因为翻转要取反,这一处如果和缩放锚点的符号搞反了,表现就是「缩放正常但拖动方向是反的」,两个功能各自看起来都对,凑在一起才知道错。

4. 让图纸“像 CAD”:图层、ACI 颜色、线宽与文字

能画出线只是第一步。用户拿到预览第一句话通常是「怎么全黑的」和「图层呢」。这一层做不做,直接决定这东西是能交付还是只能自己看。

4.1 ACI 色号与 ByLayer 的解析顺序

DXF 用的是 ACI 索引色,1 到 255。前 9 个是标准色,后面是按色相/明度/饱和度排的网格。前 9 个直接查表,够覆盖大部分场景:

ACI含义COLORREF
1红RGB(255,0,0)
2黄RGB(255,255,0)
3绿RGB(0,255,0)
4青RGB(0,255,255)
5蓝RGB(0,0,255)
6洋红RGB(255,0,255)
7前景深色背景下白,浅色背景下黑
8深灰RGB(128,128,128)
9浅灰RGB(192,192,192)

7 号色是最特殊的一个,它在 CAD 里表示「随背景反色」。很多图纸主体线条都用 7 号,如果你的背景是白色却把 7 号硬编码成白色,整张图会直接消失。我的做法是在视图里维护一个m_bgColor,解析 7 号时判断背景亮度,深底给白、浅底给黑。

颜色解析的顺序必须严格按这个优先级走,跳步就会出错:

COLORREF CDxfView::ResolveColor(const CDxfEntity* e) const { int aci = e->color; if (aci == 0) { // ByBlock:继承外层 INSERT 的颜色 aci = m_blockColorStack.empty() ? 256 : m_blockColorStack.back(); } if (aci == 256) { // ByLayer:取图层表里的颜色 auto it = m_doc.m_layers.find(e->layer); aci = (it == m_doc.m_layers.end()) ? 7 : it->second.color; } if (aci < 0) aci = -aci; // 负数表示图层关闭,颜色取绝对值 if (aci == 0 || aci == 256) aci = 7; // 兜底 return AciToColorRef(aci, m_bgColor); }

ByBlock 那条分支必须有。块内实体如果用 ByBlock 色,而 INSERT 本身指定了颜色,最终应该显示 INSERT 的颜色;如果只判断 ByLayer,块内实体就会全部退回默认色,整张图看起来颜色乱掉。

4.2 线宽与线型:不追求 1:1,但要可控

组码 370 给的是线宽,单位是 0.01 毫米,-1 表示 ByLayer,-2 表示 ByBlock,-3 表示默认。真实线宽乘以m_scale才是像素宽度,但这里有个陷阱:m_scale是浮点的,CPen的宽度是整型,而且 GDI 的宽线是画在路径两侧的,缩放过程中线宽在 1 像素和 2 像素之间反复跳,视觉上像在呼吸。

我的处理是把线宽分两档:小于 0.3mm 的一律用 1 像素的 cosmetic pen(CreatePen(PS_SOLID, 0, color),宽度 0 表示始终 1 像素),大于等于 0.3mm 的才按max(1, (int)(lw * m_scale / 100.0))计算实际像素宽。这样细线在缩放时永远稳定,粗线也能体现出层次。别小看这一条,图纸里 90% 以上的线都是细线,剩下的粗线通常是轮廓或剖切线,正好是需要强调的部分。

CPen 的创建开销不小,每个实体CreatePen一次,几千个实体就是几千次 GDI 调用。做法是建一个std::map<std::pair<COLORREF,int>, CPen>的缓存,键是颜色加宽度,用完不删,跟着视图生命周期走。缓存条目数的上限大概是颜色数乘宽度档位数,撑死几百个,完全可控。

线型(组码 6 指向线型名)我一般只做三档近似:实线、虚线、点划线。真正的线型定义在 TABLES 段的 LTYPE 里,是一串「划长、空长」的重复模式,完整实现要按 DC 的SetBkMode配合CreatePen(PS_USERSTYLE, ...)和SetDashPattern。如果图纸里虚线特别多而且用户在意,值得做;否则初始版本直接三档近似,先让图能看。

4.3 文字:镜像、对齐点和编码三个坑

前面说过,Y 翻转的映射会把文字上下镜像。解决方式是把文字从映射模式里摘出来,在MM_TEXT下用自己算的屏幕坐标输出:

void CDxfText::Draw(CDC& dc, const CDxfView& view) const { int save = dc.SaveDC(); dc.SetMapMode(MM_TEXT); // 脱离 Y 翻转的映射 CPoint pt = view.WorldToScreen(m_insert); // 手动算屏幕坐标 int h = (int)(m_height * view.Scale()); if (h < 1) h = 1; CFont font; font.CreateFont(-h, 0, 0, 0, FW_NORMAL, FALSE, FALSE, FALSE, DEFAULT_CHARSET, OUT_TT_PRECIS, CLIP_DEFAULT_PRECIS, ANTIALIASED_QUALITY, DEFAULT_PITCH, m_styleName); CFont* pOld = dc.SelectObject(&font); // 对齐点:组码 11/21 存在时以它为准,否则用插入点 10/20 CPoint anchor = m_hasAlignPt ? view.WorldToScreen(m_alignPt) : pt; UINT fmt = TA_LEFT | TA_BASELINE; // 按 72/73 换算成 TA_ 组合 // ... 根据 m_hJust / m_vJust 覆盖 fmt dc.SetTextAlign(fmt); dc.TextOut(anchor.x, anchor.y, m_text); dc.SelectObject(pOld); dc.RestoreDC(save); }

CreateFont的字高参数用负数,表示字符高度而不是单元格高度,否则实际字会比预期小一圈。对齐方式由组码 72(水平:0 左、1 中、2 右)和 73(垂直:0 基线、1 底、2 中、3 顶)决定,但只有在组码 11/21 存在时才真正生效——很多图纸只给了 10/20 插入点却没给对齐点,这时候 72/73 是摆设,强行按它对齐会让文字整体偏移。判断依据就是「11 组码出现过没有」,用一个布尔标志记下来。

编码是最容易翻车的地方。DXF 是文本文件,中文图纸的编码取决于$DWGCODEPAGE,常见值是ANSI_936,对应 GBK。用CStdioFile以文本模式读的时候,MFC 会走当前 CRT locale,如果程序启动时没设对 locale,读出来的中文就是问号或者乱码。稳妥做法是:先以二进制方式扫一遍 HEADER 拿到$DWGCODEPAGE,如果值里含936就按 GBK 解码,含65001或者文件本身带 UTF-8 BOM 就按 UTF-8 解码,其余情况按当前系统代码页处理。这个判断放在Load的最前面,一次做完,后面所有字符串都按统一编码处理。

MTEXT 还有额外的转义:\P是换行,\~是不换行空格,\fSimSun|b0|i0;是字体切换,{\...}是分组。最省事的做法是写一个剥字符的小函数,扫一遍把\开头的两字符序列替换掉,遇到{}直接丢弃。丢掉格式信息,保留文字内容和换行,对预览来说足够。

5. 排查五连:解析和绘图里最容易返工的现场

这一章是我自己踩过的记录。每条都按「现象 → 原因 → 解决」写,遇到同样症状可以直接对号入座。

5.1 解析阶段的三次返工

现象一:中文图层名和文字全是问号。原因:文件是按 GBK 存的,程序以CStdioFile文本模式读取,CRT locale 是默认的 "C",多字节字符被逐字节当成无效字符替换掉了。这事有点玄学,因为同一份代码在有些机器上正常、有些机器上乱码,差别就在系统区域设置。 解决:按 4.3 说的,先读 HEADER 里的$DWGCODEPAGE,再决定解码方式。不要依赖全局 locale,setlocale(LC_ALL, "")这种写法会污染整个进程,改起来很被动。

现象二:所有图元都挤在原点附近,或者整张图偏到视野外面。原因:INSERT 的块变换没做全。块内实体的坐标是块坐标系下的,插入时要按「缩放 → 旋转 → 平移」的顺序变换到世界坐标,顺序错了结果就完全不对。更隐蔽的是组码 210/220/230 给的拉伸方向(OCS),当它不等于 (0,0,1) 时,实体的 10/20 坐标处于一个任意平面坐标系里,需要做一次 OCS 到 WCS 的变换。二维图纸里绝大多数实体的 210 都是 (0,0,1),但只要有一张图不是,整张图就会错位。 解决:在基类里统一处理。解析完实体先检查ext,如果不是(0,0,1)就套用 OCS 变换矩阵;INSERT 展开时把变换矩阵压栈,块内实体绘制前依次乘上栈里所有矩阵。矩阵用 3x3 齐次坐标足够,二维场景不需要 4x4。

现象三:图纸能打开,但 POLYLINE 画出来的东西全是散点。原因:把 VERTEX 当成独立实体处理了。POLYLINE 的顶点是(0, VERTEX)开头的独立记录,只有遇到(0, SEQEND)才算这条折线结束。如果主循环不维护状态,每个 VERTEX 都会被当成一个未知实体,要么丢弃要么画成一个点。 解决:解析器加一个小状态机——遇到 POLYLINE 就置m_inPolyline = true并压入一个临时对象,后续的 VERTEX 全部追加进去,遇到 SEQEND 才收尾。同时注意 POLYLINE 的 70 标志 bit 1 表示闭合,收尾时要补一段首尾连线。

5.2 绘图阶段的两个现场

现象四:窗口一拖动整屏疯狂闪烁,鼠标停下来才恢复。原因:OnEraseBkgnd没重写,每次Invalidate都先擦后画。而且Invalidate传了TRUE,参数本身就是「擦背景」。两层叠加,闪烁加倍。 解决:OnEraseBkgnd直接返回TRUE;所有重绘调用统一Invalidate(FALSE);双缓冲的 memDC 和 bitmap 缓存成成员,只在OnSize里重建。

现象五:小图很流畅,几万个实体的图拖一下就卡住,CPU 单核跑满。原因:每个实体一次SelectObject换笔,一次MoveTo/LineTo,GDI 调用次数是实体数的好几倍。GDI 的每次状态切换都有固定开销,几万次累加起来就是几百毫秒。 解决:三条一起上。第一,笔做缓存,同色同宽的复用;第二,把连续的线段合并成一次Polyline调用,把圆和弧全部离散成折线后一起提交;第三,加视口裁剪,屏幕外的实体直接跳过。这三条做到位,同样的图从卡顿到跟手是能明显感觉到的,具体做法下一章展开。

6. 大图不卡的几个手段与一份解析自检清单

性能这块,前两条最有效。视口裁剪是成本最低的:在Draw之前先用实体的包围盒和当前视图的世界矩形做相交测试,不相交直接continue。包围盒在解析阶段就算好存下来,不要每次绘制重算。视图矩形用ScreenToWorld把客户区四个角反解出来即可。

// 视口裁剪:包围盒与视图矩形不相交就跳过 bool CDxfView::InView(const CRect2d& box) const { if (m_viewWorld.IsRectEmpty()) return true; // 首帧还没算,全画 return !(box.right < m_viewWorld.left || box.left > m_viewWorld.right || box.top < m_viewWorld.bottom || box.bottom > m_viewWorld.top); }

弧线离散的容差要控制好。我一般按「弦高误差不超过 0.3 像素」来算分段数:

int SegmentsForArc(double radiusWorld, double scale) { double rPix = radiusWorld * scale; if (rPix < 2.0) return 4; // 半径太小,4 段足够 double step = 2.0 * acos(1.0 - 0.3 / rPix); // 弦高 0.3px 对应的圆心角 int n = (int)ceil(2.0 * 3.14159265 / step); return n < 8 ? 8 : (n > 720 ? 720 : n); // 上下限夹住 }

上限 720 段是必要的。有些图纸的圆弧半径大到几十万单位,按公式算出来要几万段,这时候离散本身就比画弧还慢。夹到 720 段,肉眼在常规缩放级别下已经看不出多边形化。

第二条是分块缓存。把所有实体按空间网格切成若干块,每块预先离散成一组Polyline的点集。绘制时只处理落在视口内的块,块内直接用Polyline一次提交。代价是内存涨了一些,换来的是拖动时几乎不碰解析逻辑。这套东西在实体数超过五万时收益开始明显,几万以下其实纯裁剪就够用了。

最后是验证。DXF 解析最容易出「看起来对、其实错了一点」的问题,我给自己定了一份固定的检查清单:

检查项期望
实体总数与 CAD 端打开后统计的数量一致(块内展开前)
包围盒与$EXTMIN/$EXTMAX的偏差在千分之一以内
图层数量与 TABLES 段里 LAYER 记录数一致
随机抽三个实体手动在 CAD 里查坐标,与解析结果逐一比对
缩放极限放大到 1e4 倍、缩小到 1e-3 倍都不出现图元消失或重叠

包围盒那一项最能提前暴露问题。我遇到过一次,图能正常显示,但$EXTMAX差了将近一倍,追下去发现有张图纸里有个巨大的 INSERT 缩放到 0.001 倍,块内实体的包围盒没跟着缩放,看起来一切正常,直到用户点了「缩放到全图」发现视野跑偏才暴露。

从那以后我每次改完解析逻辑,都会先跑这三步:打印实体计数和包围盒、拿一张已知答案的小图逐点比对、再拿一张几十兆的大图测拖动帧率。跳过任何一步,后面都要用更多时间补回来。希望帮到你。

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

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

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

立即咨询