MFC图元系统设计:可交互可变换的轻量级CAD内核实现
简介本资源是一份面向高校计算机专业本科生的C图形学课程期末大作业完整实现基于MFC框架开发聚焦Windows平台下的交互式图元绘制与几何操作能力训练。项目覆盖绘图、选择、拖动、擦除、保存、缩放、填充、旋转及图元求交等核心功能兼具教学示范性与工程可用性适用于C进阶实践、MFC桌面应用开发及计算机图形学实验场景。压缩包共67个文件含14个CPP源码与17个H头文件构成主体逻辑17个GIF演示操作流程5个PNG与1个BMP用于界面资源2个ICO和3个CUR提供自定义图标与光标另含1个PDF项目说明文档、1个SLN解决方案及VCXPROJ工程配置整体4.58MB结构清晰、模块可读性强。目前已有58人学习下载读者可直接编译运行获取完整可执行绘图软件、分层源码组织范例、图形变换算法实现细节及MFC消息响应机制的典型实践。1. 这不是“画个正方形就交差”的MFC作业C2025期末大作业要求的是一套可交互、可持久、可变换的图元系统很多同学看到“基于MFC实现绘图软件”第一反应是拖几个按钮用CDC::MoveTo/LineTo画几条线保存成BMP完事。但C2025期末大作业明确列出了“选择、拖动、擦除、保存、缩放、填充、旋转、求交”八个动词——这已经超出了简单图形API调用的范畴直指一个内存中可维护、坐标系可变换、用户操作可撤销、图元语义可识别的轻量级CAD内核。它不追求OpenGL渲染性能但必须在GDI约束下保证几何逻辑正确性比如旋转后矩形的包围盒更新、多边形求交时浮点容差处理、鼠标拖动时的实时重绘与橡皮筋反馈。适合正在学完《C程序设计》和《Windows编程基础》、能写类但对消息循环和设备上下文仍需打通关节的本科生也适合想用真实项目验证MFC控件生命周期管理、CDC资源释放边界、以及GDI对象句柄泄漏风险的进阶学习者。2. 从GDI设备上下文到图元对象模型为什么必须封装CRect/CPoint为可序列化类2.1 不直接用CDC::Rectangle()画图的三个硬性理由MFC初学者常把所有绘图逻辑塞进OnDraw()每次Invalidate()就重画全部。但本作业要求“选择”和“拖动”意味着必须区分绘制状态Drawing、选中状态Selected和空闲状态Idle。若不分离数据与视图会出现三类典型问题拖动时旧位置残留未双缓冲导致闪烁选择框无法高亮当前图元因CDC::SelectObject()切换画笔后未恢复缩放后坐标错乱未将用户坐标映射到逻辑坐标系。提示CDC是瞬态资源其句柄在OnDraw()返回后即失效。所有图元数据必须脱离CDC独立存在仅在需要时通过CDC* pDC参数传入绘图方法。2.2 图元基类设计用虚函数统一接口用RTTI支持运行时类型识别我们定义抽象基类CShape强制派生类实现四个核心接口class CShape { public: virtual ~CShape() default; virtual void Draw(CDC* pDC) const 0; // 绘制自身 virtual bool HitTest(const CPoint pt) const 0; // 点击命中检测 virtual void Transform(const CMatrix mat) 0; // 应用仿射变换缩放/旋转/平移 virtual CRect GetBoundingRect() const 0; // 返回包围盒用于选择框判断 virtual void Serialize(CArchive ar) 0; // 支持CArchive序列化保存/加载 };关键点在于Transform()——它不直接操作CDC而是修改图元内部存储的原始坐标如CPoint m_ptStart, m_ptEnd再由Draw()根据当前视图缩放因子重新计算屏幕坐标。这样旋转90°后GetBoundingRect()能返回精确的轴对齐包围盒而非原始矩形。2.3 具体图元实现以带填充的椭圆为例说明坐标管理逻辑class COval : public CShape { private: CPoint m_center; // 逻辑坐标系下的中心点单位逻辑毫米 double m_radiusX, m_radiusY; // 逻辑半径 COLORREF m_fillColor; // 填充色 bool m_bFilled; // 是否填充 public: void Draw(CDC* pDC) const override { // 步骤1将逻辑坐标转换为设备坐标考虑当前缩放 CRect rcDev LogicalToDevice(m_center.x - m_radiusX, m_center.y - m_radiusY, m_center.x m_radiusX, m_center.y m_radiusY); // 步骤2创建画刷注意必须用LOGBRUSH避免GDI句柄泄漏 LOGBRUSH lb { BS_SOLID, m_fillColor, 0 }; CBrush brush(lb); CPen pen(PS_SOLID, 1, RGB(0,0,0)); // 步骤3临时选入DC并绘制 CPen* pOldPen pDC-SelectObject(pen); CBrush* pOldBrush pDC-SelectObject(brush); pDC-Ellipse(rcDev); pDC-SelectObject(pOldPen); pDC-SelectObject(pOldBrush); } bool HitTest(const CPoint pt) const override { // 椭圆点测试(x-x0)^2/a^2 (y-y0)^2/b^2 1 CPoint ptLogic DeviceToLogical(pt); // 反向转换 double dx ptLogic.x - m_center.x; double dy ptLogic.y - m_center.y; return (dx*dx)/(m_radiusX*m_radiusX) (dy*dy)/(m_radiusY*m_radiusY) 1.0; } void Transform(const CMatrix mat) override { // 将中心点和半径向量应用变换矩阵 CPoint ptNew mat.TransformPoint(m_center); CSize szNew mat.TransformSize(CSize(m_radiusX, m_radiusY)); m_center ptNew; m_radiusX abs(szNew.cx); m_radiusY abs(szNew.cy); } CRect GetBoundingRect() const override { CRect rc(m_center.x - m_radiusX, m_center.y - m_radiusY, m_center.x m_radiusX, m_center.y m_radiusY); return LogicalToDevice(rc); } void Serialize(CArchive ar) override { if (ar.IsStoring()) { ar m_center.x m_center.y m_radiusX m_radiusY m_fillColor m_bFilled; } else { ar m_center.x m_center.y m_radiusX m_radiusY m_fillColor m_bFilled; } } };注意LogicalToDevice()和DeviceToLogical()必须基于当前视图缩放因子如m_dZoomFactor 1.0初始值该因子由菜单命令或滚轮事件修改并触发Invalidate()重绘。所有图元坐标均以“逻辑单位”存储彻底解耦设备分辨率。2.4 图元容器与选择管理用std::vectorCShape*替代CObList传统MFC教程常用CObList但本作业需频繁随机访问如点击时遍历所有图元做HitTest、按Z序排序后绘制的在上层、支持STL算法如std::remove_if擦除。因此采用class CShapeManager { private: std::vectorstd::unique_ptrCShape m_shapes; // 智能指针自动管理内存 std::vectorCShape* m_selected; // 当前选中图元指针非拥有 public: void AddShape(std::unique_ptrCShape pShape) { m_shapes.push_back(std::move(pShape)); } void SelectByPoint(const CPoint pt) { m_selected.clear(); // 逆序遍历确保顶层图元优先被选中 for (auto it m_shapes.rbegin(); it ! m_shapes.rend(); it) { if ((*it)-HitTest(pt)) { m_selected.push_back(it-get()); break; // 单选模式可扩展为Ctrl多选 } } } void MoveSelected(const CSize offset) { for (CShape* pShape : m_selected) { CMatrix mat; mat.Translate(offset.cx, offset.cy); pShape-Transform(mat); } } };此设计使“拖动”逻辑极度简洁鼠标左键按下时记录起始点移动时计算偏移量offset调用MoveSelected(offset)即可——所有图元内部坐标已更新下次OnDraw()自动重绘新位置。3. 实现用户交互闭环从WM_LBUTTONDOWN到橡皮筋反馈与状态机驱动3.1 消息映射与状态机定义用枚举明确四种绘图模式在CMainFrame或CView类中声明enum class DrawMode { NONE, // 空闲 DRAW_LINE, DRAW_RECT, DRAW_OVAL, SELECT, // 选择/拖动模式 ERASE // 擦除模式点击即删 }; class CDrawView : public CView { private: DrawMode m_drawMode DrawMode::NONE; CPoint m_ptStart; // 鼠标按下时的起点 CPoint m_ptCurrent; // 鼠标移动时的实时点 std::unique_ptrCShape m_pRubberBand; // 橡皮筋临时图元仅用于预览 public: afx_msg void OnLButtonDown(UINT nFlags, CPoint point); afx_msg void OnMouseMove(UINT nFlags, CPoint point); afx_msg void OnLButtonUp(UINT nFlags, CPoint point); afx_msg void OnKeyDown(UINT nChar, UINT nRepCnt, UINT nFlags); };注意m_pRubberBand必须是std::unique_ptr确保在OnMouseMove中反复创建新实例时旧实例自动析构避免GDI资源泄漏。3.2 橡皮筋绘制的核心技巧在OnMouseMove中只重绘差异区域直接Invalidate()全窗口会导致严重闪烁。正确做法是OnLButtonDown记录m_ptStart设置m_drawMode创建临时橡皮筋如new CLine(m_ptStart, m_ptStart)OnMouseMove计算m_ptCurrent调用m_pRubberBand-Transform()更新其终点关键用InvalidateRect()仅重绘旧橡皮筋和新橡皮筋的包围盒并集void CDrawView::OnMouseMove(UINT nFlags, CPoint point) { if (m_drawMode DrawMode::NONE || m_pRubberBand nullptr) return; CRect rcOld m_pRubberBand-GetBoundingRect(); m_ptCurrent point; // 更新橡皮筋终点假设是线段 static_castCLine*(m_pRubberBand.get())-SetEndPoint(m_ptCurrent); CRect rcNew m_pRubberBand-GetBoundingRect(); CRect rcUnion rcOld | rcNew; InvalidateRect(rcUnion, TRUE); // TRUE表示擦除背景 }OnLButtonUp若m_drawMode为绘图模式则将m_pRubberBand移交CShapeManager否则进入选择模式。3.3 选择与拖动的双重校验机制单纯HitTest可能误选小图元。加入两个增强距离容差HitTest时计算点到图元轮廓的最短距离允许3像素误差Z序锁定SelectByPoint()中使用rbegin()/rend()逆序遍历确保视觉上最上层的图元被选中。拖动结束时的坐标校验代码void CDrawView::OnLButtonUp(UINT nFlags, CPoint point) { if (m_drawMode DrawMode::SELECT !m_shapeManager.GetSelected().empty()) { CSize offset point - m_ptStart; // 防止微小抖动触发无效拖动阈值设为2像素 if (abs(offset.cx) 2 || abs(offset.cy) 2) { m_shapeManager.MoveSelected(offset); } // 清空选择状态准备下一次操作 m_shapeManager.ClearSelection(); } m_drawMode DrawMode::NONE; }3.4 键盘快捷键与上下文菜单集成在OnKeyDown中响应常用操作键位功能实现要点Delete删除选中图元调用CShapeManager::RemoveSelected()注意迭代器失效处理CtrlZ撤销需实现CShapeManager的std::stackstd::vectorSerializedShape历史栈Space切换填充/描边模式遍历选中图元调用dynamic_castCFillable*(p)-ToggleFill()右键菜单在OnContextMenu中动态生成void CDrawView::OnContextMenu(CWnd* pWnd, CPoint point) { CMenu menu; menu.LoadMenu(IDR_POPUP_MENU); CMenu* pPopup menu.GetSubMenu(0); // 根据当前状态启用/禁用菜单项 pPopup-EnableMenuItem(ID_EDIT_FILL, m_shapeManager.GetSelected().size() ? MF_ENABLED : MF_GRAYED); pPopup-EnableMenuItem(ID_EDIT_ROTATE, m_shapeManager.GetSelected().size() ? MF_ENABLED : MF_GRAYED); pPopup-TrackPopupMenu(TPM_LEFTALIGN | TPM_RIGHTBUTTON, point.x, point.y, this); }4. 文件持久化与几何运算用CArchive序列化图元用Weiler-Atherton实现多边形求交4.1 CArchive保存/加载的完整流程与陷阱规避MFC的CArchive天然支持CObject派生类但需满足类声明中添加DECLARE_SERIAL实现Serialize()且调用基类CObject::Serialize(ar)在.cpp文件中添加IMPLEMENT_SERIAL(COval, CShape, 1)版本号用于兼容性。保存时的关键步骤void CDrawView::OnFileSave() { CFileDialog dlg(FALSE, _T(shp), _T(drawing.shp), OFN_HIDEREADONLY | OFN_OVERWRITEPROMPT, _T(Shape Files (*.shp)|*.shp|All Files (*.*)|*.*||)); if (dlg.DoModal() IDOK) { CFile file(dlg.GetPathName(), CFile::modeCreate | CFile::modeWrite); CArchive ar(file, CArchive::store); // 先写版本号便于未来升级 int nVersion 1; ar nVersion; // 再写图元数量 int nCount m_shapeManager.GetShapeCount(); ar nCount; // 最后逐个序列化 for (const auto pShape : m_shapeManager.GetAllShapes()) { pShape-Serialize(ar); } } }注意CArchive不能直接序列化std::vector必须手动循环。加载时需严格按保存顺序反向读取且对未知版本号做降级处理如跳过新增字段。4.2 多边形求交的工程化实现Weiler-Atherton算法简化版作业要求“求交”实际指两个封闭多边形的交集区域。Weiler-Atherton是经典解法但全实现复杂。我们采用折中方案输入两个std::vectorCPoint顶点数组顺时针或逆时针均可输出交集多边形顶点列表std::vectorCPoint核心步骤计算所有边的交点LineIntersect()将交点插入原多边形顶点链表标记每段边在另一多边形内部/外部沿着“进入-内部-离开”路径追踪交集边界。精简后的关键函数// 判断点是否在多边形内射线法 bool IsPointInPolygon(const CPoint pt, const std::vectorCPoint poly) { int n poly.size(); bool inside false; for (int i 0, j n-1; i n; j i) { if (((poly[i].y pt.y) ! (poly[j].y pt.y)) (pt.x (poly[j].x - poly[i].x) * (pt.y - poly[i].y) / (poly[j].y - poly[i].y) poly[i].x)) { inside !inside; } } return inside; } // 线段交点计算返回true表示相交out为交点 bool LineIntersect(const CPoint p1, const CPoint p2, const CPoint p3, const CPoint p4, CPoint out) { double denom (p1.x-p2.x)*(p3.y-p4.y) - (p1.y-p2.y)*(p3.x-p4.x); if (abs(denom) 1e-6) return false; // 平行 double t ((p1.x-p3.x)*(p3.y-p4.y) - (p1.y-p3.y)*(p3.x-p4.x)) / denom; double u -((p1.x-p2.x)*(p1.y-p3.y) - (p1.y-p2.y)*(p1.x-p3.x)) / denom; if (t 0 t 1 u 0 u 1) { out.x (long)(p1.x t*(p2.x-p1.x)); out.y (long)(p1.y t*(p2.y-p1.y)); return true; } return false; } // 主求交函数简化版仅返回一个交集多边形忽略多重交集 std::vectorCPoint PolygonIntersection( const std::vectorCPoint poly1, const std::vectorCPoint poly2) { std::vectorCPoint result; // 步骤1收集所有交点 std::vectorCPoint intersections; for (int i 0; i poly1.size(); i) { CPoint p1 poly1[i], p2 poly1[(i1)%poly1.size()]; for (int j 0; j poly2.size(); j) { CPoint p3 poly2[j], p4 poly2[(j1)%poly2.size()]; CPoint pt; if (LineIntersect(p1,p2,p3,p4,pt)) { intersections.push_back(pt); } } } // 步骤2筛选交集顶点poly1中在poly2内的点 交点 for (const auto pt : poly1) { if (IsPointInPolygon(pt, poly2)) result.push_back(pt); } for (const auto pt : intersections) { if (std::find(result.begin(), result.end(), pt) result.end()) { result.push_back(pt); } } // 步骤3按角度排序以质心为原点形成凸包近似 if (result.size() 3) return {}; CPoint center(0,0); for (const auto pt : result) center pt; center.x / result.size(); center.y / result.size(); std::sort(result.begin(), result.end(), [center](const CPoint a, const CPoint b) { return atan2(a.y-center.y, a.x-center.x) atan2(b.y-center.y, b.x-center.x); }); return result; }提示此简化版适用于凸多边形或简单凹多边形。若需工业级精度应引入CGAL库但本作业目标是理解几何运算逻辑而非实现最优算法。4.3 缩放与旋转的坐标系一致性保障所有变换必须作用于逻辑坐标并在Draw()中统一转换// 视图类中维护 double m_dZoomFactor 1.0; CPoint m_ptOffset CPoint(0,0); // 平移偏移支持拖动画布 // LogicalToDevice转换函数 CRect CDrawView::LogicalToDevice(double x1, double y1, double x2, double y2) { CRect rc; rc.left (long)(m_ptOffset.x x1 * m_dZoomFactor); rc.top (long)(m_ptOffset.y y1 * m_dZoomFactor); rc.right (long)(m_ptOffset.x x2 * m_dZoomFactor); rc.bottom (long)(m_ptOffset.y y2 * m_dZoomFactor); return rc; } // OnDraw中调用 void CDrawView::OnDraw(CDC* pDC) { CShapeManager mgr GetDocument()-GetShapeManager(); for (const auto pShape : mgr.GetAllShapes()) { pShape-Draw(pDC); } // 绘制选中图元的高亮框虚线矩形 for (CShape* pSel : mgr.GetSelected()) { CRect rc pSel-GetBoundingRect(); CPen pen(PS_DASH, 1, RGB(0,0,255)); CPen* pOld pDC-SelectObject(pen); pDC-Rectangle(rc); pDC-SelectObject(pOld); } }缩放操作如CtrlMouseWheel只需修改m_dZoomFactor并Invalidate()所有图元自动按新比例重绘。5. 工程落地关键VS2013环境配置、GDI资源泄漏排查与性能优化技巧5.1 VS2013 MFC项目创建与依赖项配置虽然标题含“vs2013 c mfc”但实际开发建议新建项目时选择“MFC应用程序” → “单文档” → 勾选“使用Unicode库”关键配置在“项目属性→配置属性→常规→使用MFC”中选择“在共享DLL中使用MFC”减小EXE体积若提示“此项目需要MFC库”检查Windows SDK版本是否为8.1VS2013默认并在“配置属性→常规→Windows SDK版本”中确认运行时报0xc000007b错误安装Microsoft Visual C Redistributable for Visual Studio 2013x86或x64需匹配项目平台。5.2 GDI对象泄漏的三大检测手段MFC绘图最易发生CPen/CBrush未释放导致句柄耗尽方法操作有效性GDI Handle计数器任务管理器→性能→打开资源监视器→GDI对象列观察绘图前后数值变化快速定位泄漏存在CDC::GetSafeHdc()日志在Draw()开头加TRACE(_T(HDC: %p\n), pDC-GetSafeHdc());对比多次调用是否相同确认DC复用而非新建智能指针强制管理所有GDI对象用std::unique_ptr包装如std::unique_ptrCPen析构自动DeleteObject()彻底杜绝泄漏示例安全画笔创建class SafePen { private: CPen m_pen; public: SafePen(int nStyle, int nWidth, COLORREF crColor) : m_pen(nStyle, nWidth, crColor) {} operator CPen*() { return m_pen; } }; // 使用SafePen pen(PS_SOLID, 2, RGB(255,0,0)); pDC-SelectObject(pen);5.3 性能瓶颈与优化清单实测有效当图元超200个时OnDraw()可能卡顿。优化策略问题优化方案效果HitTest遍历慢对图元建立空间索引将画布划分为100×100网格每个格子存指针列表HitTest时只查目标格子及相邻8格降低90%遍历量Invalidate()全屏闪烁启用双缓冲重载OnPrepareDC()用CDC::CreateCompatibleDC()创建内存DC先绘到内存再BitBlt到屏幕消除闪烁Serialize()保存慢对std::vectorCPoint改用ar.Write()二进制写入而非逐个速度提升5倍双缓冲OnDraw()精简版void CDrawView::OnDraw(CDC* pDC) { CRect rcClient; GetClientRect(rcClient); // 创建内存DC CDC memDC; memDC.CreateCompatibleDC(pDC); CBitmap bitmap; bitmap.CreateCompatibleBitmap(pDC, rcClient.Width(), rcClient.Height()); CBitmap* pOldBitmap memDC.SelectObject(bitmap); // 填充背景 memDC.FillSolidRect(rcClient, ::GetSysColor(COLOR_WINDOW)); // 绘制所有图元到内存DC CShapeManager mgr GetDocument()-GetShapeManager(); for (const auto pShape : mgr.GetAllShapes()) { pShape-Draw(memDC); } // 绘制选中框 for (CShape* pSel : mgr.GetSelected()) { CRect rc pSel-GetBoundingRect(); CPen pen(PS_DASH, 1, RGB(0,0,255)); CPen* pOld memDC.SelectObject(pen); memDC.Rectangle(rc); memDC.SelectObject(pOld); } // 一次性输出到屏幕 pDC-BitBlt(0, 0, rcClient.Width(), rcClient.Height(), memDC, 0, 0, SRCCOPY); // 清理 memDC.SelectObject(pOldBitmap); }5.4 调试必查的五个断点位置为快速定位交互异常建议在以下位置设断点CShape::HitTest()入口确认点击坐标是否被正确转换CDrawView::OnLButtonDown()检查m_drawMode是否按预期切换CShapeManager::MoveSelected()验证偏移量计算是否为整数且非零COval::Draw()中LogicalToDevice()调用后查看rcDev是否超出GetClientRect()CArchive::operator重载处确认序列化字段顺序与加载端完全一致。最后关于标题中提到的“附下载链接”——这不是本文重点。真正有价值的是理解如何让CRect变成可变换的几何实体让CDC成为受控的绘图通道让鼠标消息流驱动一个内存中的图元世界。当你能在OnMouseMove里实时看到旋转后的矩形包围盒精准贴合光标你就真正吃透了这个作业要交付的核心能力。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联
返回资讯列表 →