☰
MFC动态绘制圆弧:鼠标三点定弧与GDI双缓冲实践
2026/11/5 19:04:45 网站建设 项目流程

简介:一套MFC动态绘制圆弧实例源码包,主要面向Windows桌面开发初学者和MFC/GDI学习者,重点演示如何基于CView派生类与CDC设备上下文,在窗口内通过鼠标交互实时绘制并更新圆弧。包内共19个文件,压缩包约81KB,核心为6个头文件与4个C++源文件,包含自定义视图类、对话框资源、工程配置文件及ReadMe说明;代码覆盖视图类重写、GDI绘图函数调用和消息映射配置,结构紧凑,可直接用Visual Studio打开编译运行。已有924人学习下载。实例从定义圆心、半径和起止角度等参数入手,借助OnDraw结合ArcTo实现精确弧线,并通过OnLButtonDown、OnMouseMove等鼠标消息和Invalidate触发重绘,同时梳理起点与终点的角度比较、绘制方向判断以及鼠标按下与拖动时的参数更新方式,完整呈现动态交互绘制流程,亦可用于理解MFC事件驱动模型与Windows GDI基本绘图机制。 做 MFC 绘图的人,基本都会遇到“动态绘制圆弧”这个需求。我最初做轨迹编辑工具时,跟大多数初学者一样,在 OnDraw 里直接调 CDC::Arc,把圆心、半径、起始角、扫角全写死。程序运行后弧倒是能出来,可换成另一个人来操作,立马卡壳——没人能在拖动鼠标的同时准确报出圆心坐标和扫过角度。后来我把交互改成“鼠标连点三个点”,由程序反算圆心和半径,再根据点序决定画哪一段弧,整个工具才真正能用了。这篇博文就把这套方案的落地过程完整记录一遍:几何推导、鼠标状态机、GDI 绘制、双缓冲防闪烁,以及我实测中踩过的好几个坑。适合正在学 MFC 图形编程,或者想给自己项目加一个“自由画弧”功能的人参考。

1. 从“用户动作”倒推交互设计:为什么鼠标画弧更适合三点定弧

1.1 圆心半径角度方案的笨重感

先说为什么教材里“圆心 + 半径 + 起始角 + 扫角”这套参数不适合直接做成鼠标交互。假如让用户先点圆心,再拖出半径,那么下一步的“起始角”还得在圆周上指定,再加上扫角方向,一次画弧要按五次鼠标,而且每一步心里都得换算“我要的到底哪条弧”。我最早做工具给同事用,按这种方式做了一个星期,同事反馈永远是在起点和终点上反复试。原因很合理:大家画弧时脑子里只有“从哪开始、经过哪、到哪结束”,根本没有“圆心角”这个概念。

你可能会说,专业 CAD 软件不也提供圆心法吗?没错,但那是给专业用户用的,而且 CAD 通常配合键盘输入精确值,不是纯鼠标操作。普通绘图工具里,鼠标应该负责“位置”,键盘负责“数值”,如果你让鼠标去承担角度输入,交互负担就太重了。

1.2 三点定弧为什么顺手

换成三点定弧之后,一句话就能讲清交互:先点起点,再点经过点,最后点终点,程序自动补出一条通过这三个点的圆弧。画一条完整的动态圆弧,鼠标只需按下三次,中间两次移动可以实时预览,所见即所得。三点定弧还有一个隐藏优点:它把“圆弧两端落在哪”和“这条弧弯向哪边”都用鼠标位置直接表示出来了。用起点、经过点、终点去约束一个圆,数学上恰好有唯一解,正好用在这里。

三种常见交互方案的对比:

方案操作成本直观程度实现复杂度适用场景
圆心 + 半径 + 角度4~5 次输入低,需要心理换算低后台计算、参数化建模
两点 + 圆心3 次输入中,仍要推断角度中专业 CAD 风格工具
三点定弧3 次点击高,接近手绘直觉中通用绘图编辑工具

1.3 动态绘制需要的状态路径

“三次按下”听起来简单,但要让动效顺滑,必须拆成几个阶段:阶段 0 表示空闲,阶段 1 已确定起点并正在移动经过点,阶段 2 确定经过点后正在移动终点。每次鼠标移动都刷新预览,右键随时取消。这个状态设计就是后面消息处理函数的骨架,没必要引入复杂状态机框架,一个 int 加一个 bool 就能撑住整个流程。

2. 三点求圆心与弧的方向判定:几何计算其实很简短

2.1 用两条弦的垂直平分线求圆心

三个不共线的点可以确定一个圆,这个结论大家都熟,但代码怎么快速算出来,很多人第一反应是“解圆的方程”。实际项目里我更推荐用弦的垂直平分线:任意一条弦的垂直平分线一定穿过圆心,两条不同弦的垂直平分线交点就是圆心。

推导时令圆心 O 相对 p1 的偏移为 (ox, oy),根据 |O - p1|² = |O - p2|² 和 |O - p1|² = |O - p3|²,化简后得到两个线性方程:

设向量 a = p2 - p1,b = p3 - p1,叉积 cross = a.x * b.y - a.y * b.x,那么:

ox = (c1 * b.y - c2 * a.y) / cross oy = (a.x * c2 - b.x * c1) / cross

其中 c1 = |a|² / 2,c2 = |b|² / 2,圆心就是 p1 + (ox, oy)。这个公式写成代码很直接:

bool GetCircleCenter(const CPoint& p1, const CPoint& p2, const CPoint& p3, CPoint& centerOut, double& radiusOut) { double x1 = p1.x, y1 = p1.y; double x2 = p2.x, y2 = p2.y; double x3 = p3.x, y3 = p3.y; double ax = x2 - x1, ay = y2 - y1; double bx = x3 - x1, by = y3 - y1; double cross = ax * by - ay * bx; if (fabs(cross) < 1e-6) return false; // 三点共线,构不成圆 double c1 = (ax * ax + ay * ay) / 2.0; double c2 = (bx * bx + by * by) / 2.0; double cx = x1 + (c1 * by - c2 * ay) / cross; double cy = y1 + (ax * c2 - bx * c1) / cross; centerOut = CPoint((int)(cx + 0.5), (int)(cy + 0.5)); radiusOut = hypot(x1 - cx, y1 - cy); return true; }

这里所有中间量都用 double 保存,只有生成 CPoint 时才做一次四舍五入,半径大了之后这点精度能帮你避免不少毛刺。

2.2 方向判定:叉积加角度补偿

求出圆心和半径之后,还有一个关键问题:圆上有两条从起点到终点的弧,劣弧和优弧,哪条是用户想要的?答案由中间点 p2 决定。所以不能简单的画一条从 a1 到 a3 的弧,要先判断三个点的环绕方向。

我的做法是:计算向量 (p2 - p1) 与 (p3 - p1) 的叉积。叉积大于 0 和小于 0 分别对应两个环绕方向,然后以 p1 的角度为基准,把 p3 的角与 p1 的角之间的差值,调整到与叉积符号一致的区间。这样从 a1 扫到 a1 + sweep 的弧,一定会经过 p2。

#include <vector> #include <cmath> #define _USE_MATH_DEFINES #include <math.h> bool BuildArcPoints(const CPoint& p1, const CPoint& p2, const CPoint& p3, std::vector<CPoint>& outPoints) { outPoints.clear(); CPoint center; double radius = 0; if (!GetCircleCenter(p1, p2, p3, center, radius)) return false; double a1 = atan2((double)(p1.y - center.y), (double)(p1.x - center.x)); double a3 = atan2((double)(p3.y - center.y), (double)(p3.x - center.x)); double cross = (double)(p2.x - p1.x) * (p3.y - p1.y) - (double)(p2.y - p1.y) * (p3.x - p1.x); double sweep = a3 - a1; if (cross > 0) { while (sweep <= 0.0) sweep += 2 * M_PI; } else { while (sweep >= 0.0) sweep -= 2 * M_PI; } const double step = 0.02; // 每段约 1.15 度 int nSeg = max(2, (int)(fabs(sweep) / step)); if (nSeg > 2048) nSeg = 2048; for (int i = 0; i <= nSeg; ++i) { double ang = a1 + sweep * i / nSeg; outPoints.push_back(CPoint( (int)(center.x + radius * cos(ang) + 0.5), (int)(center.y + radius * sin(ang) + 0.5))); } return true; }

这段代码默认是 MM_TEXT 坐标系,也就是 y 轴向下。如果你把视类映射模式改成了 MM_HIMETRIC 之类,先把坐标换算到同一参考系再算几何,否则方向会反。

3. 鼠标消息驱动状态机:轻点、预览、落定的完整流程

3.1 状态变量怎么设计

实现动态绘制,最忌讳一上来就设计一个复杂的状态类。三点画弧这个场景,一个 int 成员加一个 bool 成员就够:

// CMyView.h class CMyView : public CView { ... protected: bool m_bTracking; // 是否正在绘制一条弧 int m_nClickCount; // 已经确定的点数:0、1、2 CPoint m_pt[3]; // 起点、经过点、终点 std::vector<CArcData> m_arcList; // 已完成的圆弧 };

构造函数里初始化成 false 和 0。m_nClickCount 为 0 表示空闲,为 1 表示已确定起点、正在预览经过点,为 2 表示已确定经过点、正在预览终点。等第三次点击完成,就把数据打包成一个 CArcData 存入 m_arcList,然后回到空闲状态。

3.2 消息映射与完整处理代码

消息映射这里需要加四个:左键按下、鼠标移动、右键按下、背景擦除。双击消息先不映射,后面会说为什么。

BEGIN_MESSAGE_MAP(CMyView, CView) ON_WM_LBUTTONDOWN() ON_WM_MOUSEMOVE() ON_WM_RBUTTONDOWN() ON_WM_ERASEBKGND() END_MESSAGE_MAP()

消息处理函数:

void CMyView::OnLButtonDown(UINT nFlags, CPoint point) { if (!m_bTracking) { m_pt[0] = point; m_pt[1] = point; m_pt[2] = point; m_nClickCount = 1; m_bTracking = true; Invalidate(FALSE); } else if (m_nClickCount == 1) { m_pt[1] = point; m_pt[2] = point; m_nClickCount = 2; Invalidate(FALSE); } else { m_pt[2] = point; m_nClickCount = 0; m_bTracking = false; CArcData data; data.pt[0] = m_pt[0]; data.pt[1] = m_pt[1]; data.pt[2] = m_pt[2]; data.clr = RGB(180, 60, 60); data.nWidth = 2; m_arcList.push_back(data); Invalidate(FALSE); } CView::OnLButtonDown(nFlags, point); } void CMyView::OnMouseMove(UINT nFlags, CPoint point) { if (m_bTracking) { if (m_nClickCount == 1) { m_pt[1] = point; m_pt[2] = point; } else if (m_nClickCount == 2) { m_pt[2] = point; } Invalidate(FALSE); } CView::OnMouseMove(nFlags, point); } void CMyView::OnRButtonDown(UINT nFlags, CPoint point) { if (m_bTracking) { m_bTracking = false; m_nClickCount = 0; Invalidate(FALSE); } CView::OnRButtonDown(nFlags, point); }

这里有一个细节:第一次点击后,m_pt[1] 和 m_pt[2] 都等于起点,是因为还没有经过点,此时 OnDraw 里预览绘制函数会因为三点重复而返回,不会画弧。等鼠标移动,m_pt[1] 开始变化,再实时更新预览。

右键取消这个功能看着小,实际用起来特别重要,因为用户随时可能点错,没有一个低成本的“反悔”入口,工具会很劝退。

4. GDI 绘图细节:折线逼近 + 双缓冲,画得平滑又不闪

4.1 为什么不用 CDC::Arc,而是用 Polyline

虽然题目叫“画圆弧”,但项目里我几乎不在预览阶段用 CDC::Arc,而是把圆弧转成足够密的折线点,再用 Polyline 一次画出。原因有三个:

  • GDI 的 Arc 角度采用 1/10 度整数单位,受 SetArcDirection 影响,调试时很容易因为方向设置看花眼;
  • Polyline 生成的点数组可以复用去做局部刷新和命中测试;
  • 线宽、颜色、虚实样式都和绘制普通线段完全一致,视觉控制直观。

步长取 0.02 弧度,大约 1.15 度一段。对半径 500 像素的弧,相邻折线点之间的弧长不到 10 像素,肉眼基本分不清是弧线还是折线。

4.2 双缓冲加拦截背景擦除

动态绘制最怕闪屏。默认情况下,OnDraw 每次都直接往屏幕 DC 上画,鼠标一移动就 Invalidate,背景色擦来擦去,弧线会明显闪烁。我的解决方式是 in-memory DC 先画好完整一帧,再 BitBlt 一次性上屏。同时重写 OnEraseBkgnd 直接返回 TRUE,不让框架再用背景色刷一遍。

BOOL CMyView::OnEraseBkgnd(CDC* pDC) { return TRUE; }

每次 Invalidate 后,系统不会用白色清空客户区,而是直接调用 OnDraw 重画整个画面。OnDraw 里用内存位图重建全部内容:

void CMyView::OnDraw(CDC* pDC) { CRect rc; GetClientRect(&rc); CDC memDC; memDC.CreateCompatibleDC(pDC); CBitmap bmp; bmp.CreateCompatibleBitmap(pDC, rc.Width(), rc.Height()); CBitmap* pOld = memDC.SelectObject(&bmp); memDC.FillSolidRect(&rc, RGB(255, 255, 255)); CPen penArc(PS_SOLID, 2, RGB(180, 60, 60)); CPen* pOldPen = memDC.SelectObject(&penArc); for (const auto& arc : m_arcList) { std::vector<CPoint> pts; if (BuildArcPoints(arc.pt[0], arc.pt[1], arc.pt[2], pts)) memDC.Polyline(pts.data(), (int)pts.size()); } if (m_bTracking && m_nClickCount >= 2) { CPen penPrev(PS_DOT, 1, RGB(120, 120, 200)); CPen* pOldPen2 = memDC.SelectObject(&penPrev); std::vector<CPoint> pts; if (BuildArcPoints(m_pt[0], m_pt[1], m_pt[2], pts)) memDC.Polyline(pts.data(), (int)pts.size()); memDC.SelectObject(pOldPen2); } memDC.SelectObject(pOldPen); pDC->BitBlt(0, 0, rc.Width(), rc.Height(), &memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOld); }

预览弧我用虚线,落定后的弧用实线,这样用户一眼能分清“还没确定”和“已经完成”。如果你想加网格背景,也在 memDC 上先画网格再画弧,注意别在循环里反复创建画笔,能提到循环外的就提到循环外。

5. 实测中的几个坑:刷新范围、共线容差与坐标取整

5.1 刷新区域:三点外接矩形并不总是安全

如果觉得 Invalidate(FALSE) 全窗口重绘太浪费,想优化成 InvalidateRect,有一个坑必须知道:三点接近共线、半径很大时,预览弧可以远远冲出三点组成的三角形。只刷三个点外接矩形,屏幕上会留下一截残影。正确做法是遍历 BuildArcPoints 生成的折线点求外接矩形,或者干脆先用 Invalidate(FALSE) 跑通,再考虑局部刷新。我自己的经验是:一个画布上同时存在的对象不超过几百个时,全窗口刷新配合双缓冲,性能完全够用,没必要为了优化引入边界错误。

5.2 共线、重合与误差容差

三点共线时 cross 接近 0,GetCircleCenter 返回 false,这时候要在状态里给出提示,否则用户会以为程序卡了。容差阈值我写了 1e-6,但实际上屏幕鼠标点都是整数坐标,三点接近共线时 cross 也不会太小,所以用 1e-4 更稳。我建议返回 false 后,在状态栏输出“三点过于接近或共线,画不出圆弧”。

另一个细节是取整。圆心和半径全程用 double,只在生成 CPoint 时四舍五入。如果你一开始就把圆心坐标取整成 int,再拿 int 去参与后面的 sin/cos 运算,半径越大误差越明显,弧线会出现肉眼可见的偏移,尤其是在连续折线拼接的地方。

5.3 双击会多画一条弧

这个坑我一开始完全没想到。用户快速点击三次完成一条弧时,系统可能把前两次点击识别成双击,导致 OnLButtonDown 被多调一次,多生成一条废弧。最简单的处理是在视图类里把 WM_LBUTTONDBLCLK 屏蔽掉,或者记录上次点击时间,间隔小于 200 毫秒的点击直接忽略。做交互工具,这些边角事件比核心算法更容易消耗时间。

6. 让实例进化为工具:对象化、命中测试与后续扩展

6.1 先封装 ArcData,别让三个点散落在 View 里

画了几条弧之后,直接在 View 里维护 CPoint pt[3] 会越来越乱。我一般定义一个结构体:

struct CArcData { CPoint pt[3]; // 起点、经过点、终点 COLORREF clr; int nWidth; };

View 里只需要一个 std::vector 。BuildArcPoints 和 GetCircleCenter 做成自由函数或者 CArcData 的静态方法,绘制、刷新、保存都在一个地方调用,后面加撤销、重做、序列化都有地方挂。

6.2 命中测试和拖动控制点

想继续编辑画好的弧,就要命中测试。因为我们把弧转成了折线点,逐段计算鼠标点到线段的距离,容差取 4 到 6 像素即可。命中某个控制点后,鼠标拖动时修改对应 pt 坐标,再重新调用 BuildArcPoints 生成圆弧,就实现了直观的“改弧”操作。这个功能在轨迹规划工具里非常常用,找准三个控制点后会发现整个交互闭环了。

另外,如果你的项目要支持高分屏,记得处理 DPI 感知。MFC 默认不感知 DPI,在高分屏笔记本上跑,鼠标坐标和绘制内容会错位。在程序入口调用 SetProcessDPIAware,或者改 manifest 启用 Per-Monitor V2,否则后面所有“所见即所得”都建立在不一致的基础上。

说这么多,这套东西的核心其实不在哪条 GDI 函数调得最花哨,而在于把数学计算、交互状态和绘制逻辑拆开。先把三点求圆心和方向判定写对,后面加预览、撤销、编辑、序列化,都是顺着同一个骨架长出来的。希望这篇能帮你少走几步弯路,把那层“鼠标动作到绘图参数”的翻译理解透。

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

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

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

立即咨询