MFC动态绘制圆弧:鼠标三点定弧与GDI双缓冲实践

发布时间:2026/9/8 16:45:33
MFC动态绘制圆弧:鼠标三点定弧与GDI双缓冲实践 简介一套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 - p1b 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|² / 2c2 |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::vectorCPoint 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::vectorCArcData 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::vectorCPoint 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::vectorCPoint 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 接近 0GetCircleCenter 返回 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 函数调得最花哨而在于把数学计算、交互状态和绘制逻辑拆开。先把三点求圆心和方向判定写对后面加预览、撤销、编辑、序列化都是顺着同一个骨架长出来的。希望这篇能帮你少走几步弯路把那层“鼠标动作到绘图参数”的翻译理解透。本文还有配套的精品资源点击获取