简介:一份面向MFC初学者的完整可运行案例,演示如何在视图类中基于GDI动态绘制圆弧。资源围绕自定义CView派生类展开,通过CArcView等类实现绘图逻辑,覆盖重写OnDraw、使用CDC与GDI函数、鼠标消息驱动参数更新,以及用ArcTo实现精确弧线等内容,能帮助读者理解MFC事件驱动模型与Windows图形设备接口的实际配合方式,同时提供了接口设计思路供扩展学习。压缩包共19个文件,体积仅81KB,以6个头文件(.h)与4个源文件(.cpp)为主体,另含工程配置(.vcxproj、.sln)、资源文件(.rc、.ico)及项目说明(ReadMe.txt)等,目录结构清晰,可导入Visual Studio直接编译运行并观察效果。这一轻量级项目也便于对照阅读、逐行断点调试。目前已有924人浏览学习。通过该案例,读者可掌握动态绘图中的坐标换算、窗口重绘刷新、消息处理流程等关键机制,并可将圆弧参数设置与绘制逻辑迁移到其他MFC或Windows绘图项目中,是课程设计、日常练习与入门GDI编程的实用参考。 在MFC里做动态绘制圆弧,算是一个经典到不能再经典的图形交互案例。但经典归经典,真动手实现一遍,很多人会卡在几个看似不起眼的地方:CDC的Arc函数参数到底怎么传?为什么拖出来的弧总在乱跳?橡皮筋预览怎么做到不闪不重影?我把这个实例完整做了一遍,工程是在VS2013下建的MFC单文档程序,核心思路和代码直接分享出来,适合刚入门MFC图形编程,或者准备在项目里加入画图类交互功能的同学参考。
1. 项目背景与整体设计思路
1.1 为什么选MFC来做这个图形交互实例
先交代背景。MFC虽然老,但在工业上位机、教学软件、小型工具类程序里,它的地位依然稳固。原因很简单:向导成熟,资料多,团队协作成本低。而且MFC对GDI(图形设备接口)的封装足够方便,在窗口客户区里画点、画线、画圆弧,本质上都是调用CDC类的方法,逻辑简单直接。
我这次做的实例,目标是做一个MFC单文档程序,用户按住鼠标左键在客户区拖动,屏幕上就能实时看到一条圆弧跟着鼠标变化,松开鼠标后圆弧固定保留在画布上,并且可以连续绘制多条圆弧。这个交互过程看起来简单,核心其实是三个层面的问题:几何计算、消息驱动的状态管理、以及实时绘制时的图形擦除与刷新。把这三块想清楚,这个实例就完成了一大半。
1.2 交互方案选型:为什么不用对话框输入参数
画一段圆弧的方式其实有很多,最常见的有三种:
| 方案 | 交互方式 | 优点 | 缺点 |
|---|---|---|---|
| A. 参数对话框 | 用户输入圆心、半径、起止角度 | 实现最简单,参数精确 | 交互割裂,看不到实时反馈 |
| B. 代码固定绘制 | 程序按预定参数画弧 | 适合测试与动画演示 | 不具备交互性 |
| C. 鼠标拖拽动态绘制 | 按住拖动,实时预览 | 所见即所得,最能体现“动态” | 状态管理复杂,需处理实时刷新 |
这个实例选择方案C,原因很实际:动态拖拽绘制的应用场景太多了,小到画图工具里的弧线,大到工业软件里的轨迹编辑,底层逻辑都是同一个套路。走通这一遍,后续再做矩形、折线、椭圆,无非是换一套几何计算,交互骨架是可以直接复用的。
另外要提前说清楚一个概念:圆弧的几何定义方式。实际工程中主要用两种:
- 三点定弧:由起点、经过点、终点确定唯一圆弧。
- 圆心+半径+起止角度:适合参数化存储,也适合计算。
我这个实例的交互输入采用三点定弧的思路,但在存储时转成“圆心+半径+起止角”,这样后续做重绘、缩放、轨迹规划都比较方便。
2. 画弧前必须搞懂的数学与GDI细节
2.1 CDC::Arc函数的参数机制,这里有个高频陷阱
MFC画圆弧用的是CDC::Arc,函数签名如下:
BOOL Arc(LPCRECT lpRect, POINT ptStart, POINT ptEnd); // 或者 BOOL Arc(int x1, int y1, int x2, int y2, int x3, int y3, int x4, int y4);这里第一个参数是边界矩形,后四个参数是两个点。很多人第一次用就栽在这两个点上——它们并不是圆弧的起点和终点坐标,而是“方向参考点”。GDI的实际逻辑是:先找到矩形中心,向ptStart作一条射线,射线与矩形内切圆的交点,才是真正的圆弧起点;同样向ptEnd作射线,交点就是圆弧终点。接着GDI按逆时针方向从起点画到终点。
举个例子:
// 边界矩形(100,100)到(300,300),中心(200,200),半径100 // ptStart=(350,200)表示圆心正右方,ptEnd=(200,50)表示圆心正上方 pDC->Arc(100, 100, 300, 300, 350, 200, 200, 50);这段代码会画出一段从右侧起点开始,逆时针经过下方、左侧,最后到达上方终点的3/4圆弧。理解这一点之后,就会发现Arc函数其实很聪明:只要给出方向,不用自己精确计算圆周上的交点坐标。
2.2 三点定弧的圆心求解
动态拖拽绘制时,我的做法是:按下鼠标记下起点A,拖动过程中以当前位置为终点C,同时实时计算一个经过点B,A、B、C三点确定一段圆弧。要让这三个点落在同一个圆上,圆心就是它们外接圆的圆心。
已知三点坐标求圆心,可以用垂径定理:两条弦的中垂线交点就是圆心。写代码时如果按这个几何思路求,要处理直线斜率不存在的情况,比较啰嗦。更稳妥的办法是直接列方程:因为圆心到三点距离相等,展开之后得到两个线性方程,解二元一次方程组即可。
CPoint CalcCircleCenter(const CPoint& ptA, const CPoint& ptB, const CPoint& ptC, BOOL& bValid) { double ax = ptA.x, ay = ptA.y; double bx = ptB.x, by = ptB.y; double cx = ptC.x, cy = ptC.y; double d = 2 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by)); if (fabs(d) < 1e-6) { bValid = FALSE; // 三点共线,或距离过近 return CPoint(0, 0); } double ux = ((ax * ax + ay * ay) * (by - cy) + (bx * bx + by * by) * (cy - ay) + (cx * cx + cy * cy) * (ay - by)) / d; double uy = ((ax * ax + ay * ay) * (cx - bx) + (bx * bx + by * by) * (ax - cx) + (cx * cx + cy * cy) * (bx - ax)) / d; bValid = TRUE; return CPoint((int)ux, (int)uy); }这段代码里d接近于0时说明三点近似共线,这时候不存在外接圆,需要做防御处理,否则会产生一个极远处的圆心,画出来的弧完全失控。
2.3 角度计算、弧度制与Arc的方向控制
求出圆心O后,半径就是圆心到任一点的距离。要调用Arc,可以直接把A点、C点作为方向参考点传给函数,因为它们本身就是圆周上的点,方向天然正确。但不少场景还是要计算角度,比如存储时要记录起止角,或者之后要做轨迹插值。
角度计算用atan2:
double angleA = atan2((double)(ptA.y - center.y), (double)(ptA.x - center.x)); double angleC = atan2((double)(ptC.y - center.y), (double)(ptC.x - center.x)); double radius = sqrt((double)(ptA.x-center.x)*(ptA.x-center.x) + (double)(ptA.y-center.y)*(ptA.y-center.y));这里有两个特别容易踩的坑。第一,atan2返回的是弧度,不是角度,后续如果做sin/cos运算必须统一用弧度制,千万别混。第二,屏幕坐标系的y轴向下,所以角度正方向在视觉上是顺时针的,这和数学坐标系相反。如果后面做轨迹规划、角度插值,要始终记得这个坐标系差异。
另外还有一个方向判断问题:Arc函数默认从起点逆时针画到终点,但A、B、C三点确定的弧可能是顺时针走向。如果不管方向直接画,可能画出来的弧没有经过B点,而是走了圆的另一侧。判断方法很简单:分别计算三个点的极角,看B是否落在A到C的逆时针区间内,不在就说明应该画顺时针弧,此时把起点终点对调传给Arc即可。
3. 动态交互绘制:从鼠标消息到橡皮筋效果
3.1 工程搭建与绘图环境准备
我用VS2013创建一个MFC单文档应用程序,工程名取ArcDrawDemo,View类的基类选择CView。如果用VS2019或VS2022,流程完全一样。创建的要点不用多说,关键是后面的消息处理。
在View类里需要维护这些成员变量:
class CArcDrawView : public CView { protected: BOOL m_bDrawing; // 是否正在拖拽绘制 CPoint m_ptDown; // 按下鼠标时的起点 CPoint m_ptCurrent; // 拖动过程中的当前点 CPoint m_ptMid; // 动态计算的经过点 ArcData m_tempArc; // 当前拖拽的临时弧 std::vector<ArcData> m_arcList; // 已完成的圆弧列表 };其中ArcData是自定义的结构体:
struct ArcData { CPoint center; // 圆心 double radius; // 半径 double startAngle; // 起始角(弧度) double endAngle; // 终止角(弧度) BOOL bClockwise; // 是否顺时针 };之所以要把完成的弧存下来,是因为窗口一旦重绘(比如被遮挡后恢复、最小化再打开),客户区内容会全部丢失,必须在OnDraw里从m_arcList重新绘制。这是MFC绘图程序的基本功:所有图形数据都由程序自己维护,窗口重绘时用数据重建画面。
3.2 经过点的动态计算与临时弧更新
三点定弧需要三个点,但我想让用户一次拖拽就完成操作,所以第三个点不单独让用户选,而是在拖动过程中自动计算。计算方法如下:取起点A和当前点C的中点,沿AC法线方向偏移一段距离,作为经过点B。这样弧线会随鼠标移动自然弯曲,体验接近很多绘图软件里的“画弧线”操作。实现代码:
void CArcDrawView::CalcMidPoint() { double dx = m_ptCurrent.x - m_ptDown.x; double dy = m_ptCurrent.y - m_ptDown.y; double len = sqrt(dx * dx + dy * dy); if (len < 1) { m_ptMid = m_ptCurrent; return; } CPoint mid((m_ptDown.x + m_ptCurrent.x) / 2, (m_ptDown.y + m_ptCurrent.y) / 2); double offset = len * 0.25; // 弓高,可调 m_ptMid.x = mid.x + (int)(dy / len * offset); m_ptMid.y = mid.y - (int)(dx / len * offset); }注意这里的法向量方向会让弧线偏向AC连线的一侧。如果要双向弯曲,可以做成按键切换,或者根据鼠标在AC两侧的位置动态换方向,这个就看需求的灵活度了。offset取len的0.25倍是一个经验值,太小弧线看起来接近直线,太大会显得夸张。
有了三个点,先求圆心,再计算起止角和方向,把结果存到m_tempArc里。
3.3 橡皮筋效果:XOR绘图与旧弧擦除
动态绘制的核心难点是怎么让预览弧“动”起来还不产生残影。最经典的做法是用GDI的异或光栅操作(R2_XORPEN)。简单说,在XOR模式下,同样的内容画两次,画面就恢复原样,于是我们可以在鼠标移动时“先画一遍旧弧擦掉,再画新弧”。代码骨架如下:
void CArcDrawView::DrawTempArc(CDC* pDC) { pDC->SetROP2(R2_XORPEN); CPen pen(PS_SOLID, 2, RGB(255, 0, 0)); CPen* pOldPen = pDC->SelectObject(&pen); // 画新的临时弧 DrawArcData(pDC, m_tempArc); pDC->SelectObject(pOldPen); pDC->SetROP2(R2_COPYPEN); }在OnMouseMove里,每次先调用一次DrawArcData把上一帧的临时弧再画一遍(相当于擦除),更新m_tempArc之后,再调用一次DrawTempArc画新弧。这样客户区始终只保留一条预览弧,松开鼠标后,临时弧被正式存进列表,再统一刷新。
这里有一个非常关键的点:擦除旧弧时用的参数必须是上一帧的m_tempArc,所以更新m_tempArc之前要先画一遍旧值。如果顺序反了,残影会一直留在画布上,这也是很多人做橡皮筋效果时最常见的错误。
OnDraw里的重绘逻辑则很直接:遍历m_arcList,把每条弧的几何数据转成矩形和方向点,调用Arc画出来。这里不涉及动态擦除,使用普通的R2_COPYPEN还原模式即可。
4. 常见问题与实用排查技巧
4.1 拖动图形闪烁、残影的排查思路
闪烁的一个常见来源是把Windows的擦除背景和你的绘制混在了一起。如果你在OnMouseMove里调用Invalidate(),窗口会先按背景色清掉整个客户区,再重画所有图形,在大范围刷新时肉眼就能看出闪烁。做橡皮筋预览时,我建议不要在拖动过程中走Invalidate,而是直接用CClientDC在客户区绘图,配合XOR方式擦旧画新,实测下来流畅度好很多。
残影问题则几乎都是“擦除旧弧失败”。排查顺序是这样:先确认上一帧的m_tempArc确实保存在成员变量里;再确认更新m_tempArc之前已经完成擦除;最后确认XOR模式下画笔颜色与背景色的异或结果有效。如果换用了非XOR模式,就必须先恢复背景再绘制,否则一定残留。
4.2 窗口重绘后图形消失,图形数据必须持久化
很多第一次写图形程序的开发者会遇到一个问题:画好的圆弧,窗口一最小化再恢复就没了。原因是Windows不会自动保存客户区的绘制内容,系统只保存窗口的“位图”很少的部分,一旦需要重绘,所有客户区内容都要由你的OnDraw重新生成。所以要养成好习惯:所有图形数据存入列表,OnDraw只负责“重放”。这也是我把m_arcList单独抽出来的原因。数据存储越早做,后期功能扩展越省力。
4.3 三点共线和角度跳变的防御
前面提到求圆心的函数有共线判断,这是必须的。当鼠标拖动的距离极短,或者恰好拖动成一条直线时,外接圆不存在,程序要直接忽略这一次绘制,或者给出提示,而不是硬算出一个偏差极大的圆心。
角度跳变是另一个容易忽略的问题。atan2返回值的范围是[-π, π],如果起点角是170度,鼠标稍微一动,终点角从-170度开始,如果不做处理,程序会认为要从170度画到-170度,结果画出一小段而不是用户预期的大圆弧。解决办法是在存储和计算时做角度归一化,比如统一把角度调整到[0, 2π),再判断是否要加一圈,保证弧长方向正确。
完整角度归一化可以参考:
double NormalizeAngle(double angle) { while (angle < 0) angle += 2 * PI; while (angle >= 2 * PI) angle -= 2 * PI; return angle; }5. 顺着实例还能延伸出什么
5.1 从绘制圆弧到圆弧轨迹规划
画圆弧这个功能天然可以延伸到运动控制领域。很多设备在运动时走的是圆弧轨迹,比如点胶机、雕刻机、机械臂,所谓的圆弧轨迹规划,就是把你画出来的圆弧离散成一系列点,逐个下发到运动控制卡或执行器。离散化思路很直接:根据起止角度和弧长把角度均匀切分成N份,然后用圆心+半径+角度算出每个点的坐标。
我在实际做这类项目时,会直接把ArcData导出成有固定采样间隔的坐标点序列,然后在界面上回放这些点模拟运动轨迹。先在MFC里把轨迹画出来调参数,再回到真实设备上运行,开发效率会高很多。
5.2 界面增强与项目发布
这个实例做完基础版之后,还有很多可以增强的地方:用自定义按钮来切换“三点画弧”“圆心半径画弧”;加入列表控件显示已经画出的图形参数,支持删除和导出;如果想让画面更漂亮,可以接入OpenGL做渲染,MFC+OpenGL的组合在工业软件里非常常见,两者互不冲突,OpenGL只管3D窗口,MFC负责交互和界面。
项目完成后还要考虑打包发布。VS自带的部署项目在新版本里用起来别扭,我现在一般用Inno Setup做安装包,脚本简单、绿色小巧,适合给MFC程序做发布。需要注意一点:如果你的程序用了动态链接的MFC库,目标机器上可能缺少对应运行库,建议在项目属性里选择“在静态库中使用MFC”,这样生成的exe小范围拷贝就能跑。
最后再分享一个我个人做这类图形交互的小经验:别急着写代码,先把几何计算拆清楚。动态绘制看似是鼠标消息的问题,其实大部分坑都出在几何换算上——圆心计算、角度方向、坐标系的y轴翻转,这些只要有一个地方想岔了,界面表现就会非常诡异。调试时可以先用TRACE把圆心、半径、起止角打出来,每次鼠标松开后看日志,数值符合预期再继续优化交互手感,能省下不少排查时间。
这个例子做完以后,我又把同样的交互方式扩展到了矩形和椭圆,骨架改动很小。如果你们正在做类似的图形编辑器或者轨迹编辑功能,希望能给大家一点参考。
本文还有配套的精品资源,点击获取