基于Qt与QGraphicsView的轻量级CAD图形编辑器开发实践
2026/9/17 0:36:59 网站建设 项目流程

1. 项目概述:为什么我又造了一个CAD轮子

先说结论:这个项目不是要跟AutoCAD、中望CAD这类专业软件掰手腕,而是解决一个很具体的痛点——在很多内部工具链场景里,我们其实只需要一个能画、能改、能存、能导出图片的轻量级二维图形编辑器,但市面上找不到一个“能塞进自己项目里、又不用付授权费、还方便二次开发”的现成方案。

我自己在做工业设备上位机软件时,就反复遇到这种需求:客户想要一个简单的工艺布局图编辑器,操作员可以拖几个矩形、圆、箭头,标注一下尺寸,保存成配置文件,下次打开还能继续编辑。用AutoCAD太重,操作员也不会用;用网页版SVG编辑器又跟C++的后端难以集成;自己从头写,光是一个橡皮筋拖拽和选择框就要折腾不少时间。所以最后干脆基于Qt框架,开发了一款轻量级CAD画图软件,功能聚焦在二维图形绘制、编辑、保存与导出上,代码量控制在合理范围内,方便二次集成。

对于正在学Qt、或者想了解“如何用Qt做一个图形编辑器”的朋友来说,这个项目的参考价值在于:它能完整展示一套图形编辑器的核心骨架——自定义图形项、交互式绘制、选中与编辑、序列化保存、视图缩放平移、性能优化,这些知识点在官方Demo里是零散的,但在这里是串起来的一条完整链路。

如果你想把类似的编辑器嵌入到自己的桌面应用里,或者是做课程设计、毕设,这个项目的思路和关键代码可以直接用。后面我讲的每一块,都是实际开发中踩过坑之后沉淀下来的方案,不是那种“看起来能跑”的伪代码。

2. 整体设计与技术选型:为什么是Qt而不是别的

2.1 从需求反推技术路线

做任何项目,第一步不是打开IDE敲代码,而是把需求列清楚。轻量级CAD软件的核心需求,我用一句话来概括:让用户能用鼠标完成“画-选-改-存”这个闭环。拆开来看就是:

  • 支持基本的二维图形:直线、矩形、圆、椭圆、多段线,最好还有尺寸标注和文字。
  • 交互方式符合CAD软件习惯:鼠标左键绘制、右键取消或完成、框选选中、拖拽移动、夹点拉伸。
  • 能保存成自定义格式的文件,也能打开继续编辑。
  • 能导出为图片,方便放到文档里。
  • 在万级图元级别保持流畅的缩放、平移和重绘性能。

基于这些需求,技术选型其实没有太多悬念。我用的是Qt Widgets + QGraphicsView框架,而不是QPainter直接绘制,也不是Qt Quick/QML。为什么这么选?这里有一个很多人新手容易踩的坑:他们一上来就重写paintEvent,用QPainter把所有的图元硬画在一个大Widget上,结果还没实现到“选中”功能就开始头疼了——因为你要自己计算“鼠标点到了哪个图元上”,要做图元级别的碰撞检测,还要管理图元的局部刷新区间,这都是重复造轮子。

QGraphicsView架构天生就是为这种需求设计的。它把“场景数据”(QGraphicsScene)、“视图显示”(QGraphicsView)、“图形项”(QGraphicsItem)三层拆开,图元的命中测试、局部重绘、坐标变换全部由框架处理。一个最简单的CAD软件,代码量可以压缩到几百行,但功能框架是完整并且可以无限扩展的。

方案绘制方式命中测试二次开发难度适用场景
QPainter直接绘制自绘所有内容需自行实现极简展示型工具
QGraphicsView框架图元自绘框架内置交互式图形编辑器
QML + Canvas脚本绘制需自行封装偏动态UI效果

2.2 图元设计:继承、组合与多态

确定了用QGraphicsView之后,接下来最重要的设计决策就是图元类结构。我的做法是定义一个统一的基类,然后让每种图形继承它。这个基类不是直接使用QGraphicsItem,而是从一个自定义类继承:

class BaseItem : public QGraphicsItem { public: enum ItemType { Line, Rect, Ellipse, Polyline, Text, Dimension }; int type() const override { return m_type; } void setColor(const QColor &color); void setWidth(qreal width); QJsonObject toJson() const; void fromJson(const QJsonObject &obj); protected: int m_type; QColor m_color; qreal m_width; };

这里最关键的设计是toJson()fromJson()两个函数。CAD软件的“保存/打开”本质上就是图元的序列化与反序列化,而这个基类把这一职责统一收口,每种图元只需要告诉JSON解析器“我有哪几个属性”,整个文件的读写逻辑就完全通用了。

图形项的类型判断,我用的是type()函数加自定义枚举。这个做法比dynamic_cast更干净,也更快,在鼠标点击需要频繁判断图元类型时性能优势明显。

2.3 视图交互状态机:从“点击”到“命令”的抽象

CAD软件和普通绘图软件最大的区别在交互逻辑上:绘制一个矩形需要经过“按下起点、拖动、松开确定终点”这样的完整动作链,而且中途按右键要能取消。这种交互如果一个个去硬编码,代码很快就会乱成一团。

我的解法是引入一个简单的“命令状态机”。每一个绘制工具对应一个命令类,命令类有四个核心方法:mousePressmouseMovemouseReleasepaintPreview。视图接收到鼠标事件后,转发给当前激活的命令,而不是自己在事件函数里写一大堆if (currentTool == Rect)之类的分支。

class DrawCommand { public: virtual void mousePress(QMouseEvent *e) = 0; virtual void mouseMove(QMouseEvent *e) = 0; virtual void mouseRelease(QMouseEvent *e) = 0; virtual void paintPreview(QPainter &p) = 0; };

视图侧只维护一个QScopedPointer<DrawCommand> currentCommand,切换工具栏按钮时直接替换命令实例。这样做的好处是逻辑解耦:每个绘图工具的代码独立成类,新增一种图形,只需要新写一个命令类,完全不需要动视图核心代码。后来我在项目里添加“画箭头”功能时,总共只花了一个小时,就是这个状态机设计的功劳。

3. 核心细节解析:图元绘制、选中与编辑的实现方案

3.1 图元的数据结构与包围盒计算

QGraphicsItem要求每个图元必须实现boundingRect(),这个函数不仅决定重绘区域,还影响碰撞检测精确度。我建议返回的是“包含图元所有像素的最小矩形”,并且留出2到3个像素的余量,避免抗锯齿边缘被裁剪。

实际开发中,我把每种图元的核心坐标都归一化到一个逻辑结构体里。以矩形为例,它只需要存两个点:起点和终点。包围盒就是这两个点的归一化矩形。但圆的包围盒就没有这么简单了——如果用户画椭圆时起点在左上、结束点在右下,矩形的包围盒当然没问题;但如果是“中心点+半径”的画法,就要多做一步坐标转换。

QRectF RectItem::boundingRect() const { QRectF rect = QRectF(m_startPoint, m_endPoint).normalized(); return rect.adjusted(-2, -2, 2, 2); }

这里有一个新手必踩的坑:QRectF在构造时如果起点在终点的右下角,长宽会是负数,绘制时会出问题。所以一定要记得调用.normalized(),它会自动把矩形的坐标规范化成左上、右下两个点。

3.2 橡皮筋预览:双缓冲与临时橡皮筋图元

在按下鼠标到拖动结束这段区间,屏幕上需要实时显示一个“半透明预览图形”,这就是橡皮筋绘制。QGraphicsView框架下有两种常见方案,我分别说一下优劣。

方案一是在mouseMove里调用scene()->update()强制刷新,然后临时绘制。这个方案简单,但频繁的全场景刷新在图形多的时候会掉帧。

方案二是用一个专门用于预览的QGraphicsItem实例,每次鼠标移动时更新它的几何数据并调用prepareGeometryChange()update(),由框架自动算出脏矩形并局部刷新。推荐用这个方案。实测在万级图元的场景下,预览线依然能保持60帧的流畅度。

3.3 选中、移动与夹点编辑

选中逻辑我分了三层:单击选中、框选选中、按住Shift加选。在QGraphicsView中,单击选中可以重写mousePressEvent,通过scene()->itemAt(pos, transform())拿到点击位置的顶层图元。这里有个细节:itemAt默认返回的是“最上层的Z值最高的项”,如果图元之间重叠,需要先设置好zValue

框选选中用的是QGraphicsScene内置的RubberBandDrag,在这个模式下框架会自动画一个鼠标拖动的橡皮筋矩形框。关键步骤是重写drawForeground(),把框选矩形叠加显示出来。要区分“左键拖拽是框选,空白处点击是取消选择”,可以通过记录鼠标按下时是否命中了图元来判断。

夹点(控制点)编辑是CAD软件体验的重要分水岭。我给选中的图元绘制了若干个夹点小方块,拖动夹点时修改图元对应的几何参数。实现方式是为每个夹点创建一个小型QGraphicsItem子类,并把它们作为“装饰项”添加进场景,跟随移动。

void RectItem::updateGripPositions() { QRectF r = boundingRect(); m_gripTopLeft->setPos(r.topLeft()); m_gripBottomRight->setPos(r.bottomRight()); }

拖动夹点时,图元根据夹点序号重新计算对应的起点或终点坐标,然后调用update()刷新,同时更新其他夹点的位置。这一块的功能代码量不大,但却是最影响“手感”的部分——夹点的热区太小很难点中,热区太大又会影响旁边图形的选中。我的经验是夹点视觉尺寸8像素左右,命中判定范围扩到16像素,实测体验最好。

4. 实操过程:从零搭建一个可运行的轻量级CAD软件

4.1 环境与工程配置

我用的版本是Qt 5.15.2 + MSVC2019_64,操作系统Windows 10。如果是新手,建议直接用Qt官方在线安装包,勾选对应编译套件(MSVC或者MinGW二选一即可)。用qmake管理工程,最简单的.pro文件如下:

QT += core gui widgets TARGET = SimpleCAD TEMPLATE = app SOURCES += main.cpp MainWindow.cpp CadView.cpp CadScene.cpp \ commands/DrawCommand.cpp commands/RectCommand.cpp \ items/BaseItem.cpp items/RectItem.cpp HEADERS += MainWindow.h CadView.h CadScene.h \ commands/DrawCommand.h commands/RectCommand.h \ items/BaseItem.h items/RectItem.h

这个工程结构已经为后续功能扩展预留了目录,commands目录放交互命令,items目录放图形项,逻辑清晰。编译完成后得到一个可执行文件,如果要在没有装Qt的机器上运行,用windeployqt工具打包一下运行库即可。

4.2 主窗口与工具栏搭建

主窗口我用的是QMainWindow,左侧放一个垂直工具栏,中间是视图区。为了贴近真实CAD软件的交互习惯,我还在底部加了一个状态栏,实时显示当前鼠标的坐标值。

MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent) { m_scene = new CadScene(this); m_view = new CadView(m_scene, this); setCentralWidget(m_view); m_toolBar = addToolBar("Tools"); QAction *rectAction = m_toolBar->addAction("矩形"); connect(rectAction, &QAction::triggered, this, [this]() { m_view->setCommand(new RectCommand(m_scene)); }); }

这里有一个需要注意的细节:工具栏按钮触发后,视图需要切换“当前工具命令”,但命令里需要访问场景对象来添加图元。所以setCommand传入的是场景指针,命令在mouseRelease后直接把新图元addItem进场景。

坐标显示功能是在CadView里重写mouseMoveEvent实现的,通过mapToScene把视图坐标转换为场景坐标,再格式化输出到状态栏。这个功能看着不起眼,但CAD用户已经习惯了看坐标画图,缺了总觉得少了点什么。

4.3 绘制矩形的命令实现

以矩形命令为例,展示完整的状态机流转。按下鼠标时记录起点,移动时更新预览图元的终点,松开时真正创建矩形图元并清空预览。

void RectCommand::mousePress(QMouseEvent *e) { QPointF scenePos = m_view->mapToScene(e->pos()); m_startPoint = scenePos; m_previewItem = new PreviewRectItem(scenePos); m_scene->addItem(m_previewItem); } void RectCommand::mouseMove(QMouseEvent *e) { if (!m_previewItem) return; QPointF scenePos = m_view->mapToScene(e->pos()); m_previewItem->setEndPoint(scenePos); m_previewItem->update(); } void RectCommand::mouseRelease(QMouseEvent *e) { QPointF scenePos = m_view->mapToScene(e->pos()); RectItem *item = new RectItem(m_startPoint, scenePos); m_scene->addItem(item); m_scene->removeItem(m_previewItem); delete m_previewItem; m_previewItem = nullptr; }

PreviewRectItem和正式的RectItem用的是同一套绘制代码,只是预览时用了半透明画刷。这样可以保证预览和最终结果的表现完全一致,不会出现“预览看起来挺好,画完风格变了”的情况。

4.4 文件保存与JSON序列化

自定义CAD格式的核心是JSON。Qt自带的QJsonDocument直接支持图元数据的序列化和反序列化。每个图元存成一个JSON对象,每种类型有一个字符串标识,所有图元放在一个数组里,最后再包一层图片背景信息。

QJsonObject CadScene::toJson() const { QJsonArray items; foreach (QGraphicsItem *it, items()) { BaseItem *base = dynamic_cast<BaseItem*>(it); if (base) { items.append(base->toJson()); } } QJsonObject root; root["type"] = "simple_cad_file"; root["version"] = 1; root["items"] = items; return root; } void CadScene::fromJson(const QJsonObject &root) { clear(); QJsonArray items = root["items"].toArray(); foreach (QJsonValue v, items) { QJsonObject obj = v.toObject(); QString type = obj["type"].toString(); BaseItem *item = nullptr; if (type == "rect") item = new RectItem(obj); else if (type == "ellipse") item = new EllipseItem(obj); // ... if (item) addItem(item); } }

有一点要提醒:clear()会删除场景中所有图元,但如果在调用fromJson前界面上还有夹点装饰项没有清理干净,会出现悬空的幽灵夹点。所以我在工程里会保留一份“所有由fromJson创建的图元”的列表,加载前先遍历删除。

5. 常见问题与排查技巧实录

5.1 编译与运行期典型问题速查

我在开发过程中,包括帮别人排查代码时,遇到过不少重复出现的问题。整理了一个速查表,供参考:

问题现象可能原因解决方案
运行时报unknown module(s) in qt: serialport.pro文件中的QT模块不存在确认安装时勾选了对应模块,或移除不需要的模块依赖
图元拖动时异常闪烁boundingRect()返回区域过小,被裁剪扩大boundingRect()范围,给抗锯齿余量
图元不响应鼠标事件未设置setFlag(ItemIsSelectable)ItemIsMovable在构造函数里显式设置交互标志位
背景网格不显示绘制前景/背景时机不对drawBackground中绘制背景网格,在drawForeground中绘制选中框
导出图片为空白视图的渲染目标设置错误scene()->render(&painter)而不是view()->render()
反序列化后图元位置错乱保存了视图坐标而不是场景坐标统一存储场景坐标,加载后不再做坐标变换

5.2 性能优化:为什么图元一多就卡

轻量级CAD在千级图元时一般都很流畅,但到了万级就会开始卡。瓶颈通常不在QGraphicsView本身,而在一些容易被忽视的代码细节上。

第一个坑是在mouseMove里做过重的计算。比如每次鼠标移动都遍历场景中所有图元去更新某个状态,这是最典型的性能杀手。优化思路是把这种计算挪到需要时才触发,比如鼠标停止移动后再做。

第二个坑是QPainter的抗锯齿和高质量渲染选项。抗锯齿在图形编辑器里是必须开的,不然线条锯齿感明显,但开了之后绘制开销会成倍增加。折中方案是在交互过程中暂时关闭抗锯齿,等到鼠标松开、需要显示最终效果时再开一次并重绘。实测这种方法能将拖拽过程中的帧率提升两三倍。

第三个坑是过度使用scene()->update()全屏刷新。最好只调用图元自身的update(),让框架只重绘图元所在的最小包围盒区域。在连接大量图元时,这个习惯能救你。

5.3 坐标精度问题:double的隐患

CAD软件对坐标精度的要求极高,默认情况下Qt的QPointF使用double存储坐标,看起来精度足够了。但如果你用qreal(有些平台是float)存储坐标,在缩放比较大的情况下就会出现坐标抖动、线条错位的问题。

设置场景范围时建议不要用默认的无限区域,而是调用setSceneRect()指定一个合理的逻辑画布大小,比如从(-10000, -10000)到(10000, 10000)。这样既限定了绘图区域,也能避免一些坐标极端情况下的浮点误差。

5.4 导出图片时如何规避字体与样式丢失

CAD图形导出PNG图片时,最常见的问题就是文字字体的缺失或样式走样。这两个点要特别注意:

  • 设置QFont时不要只依赖系统默认字体,可以显式指定一个通用字体族,比如“Microsoft YaHei”,避免在字体缺失的系统上变成乱码方块。
  • 导出图片的清晰度取决于渲染分辨率和缩放比例。代码里我用了一个scale参数,导出时把画布放大到2倍或者4倍渲染,然后做平滑缩放,能明显改善锯齿。
void CadScene::exportToPng(const QString &filePath, int scale) { QPixmap pixmap(sceneRect().size().toSize() * scale); pixmap.fill(Qt::white); QPainter painter(&pixmap); painter.setRenderHint(QPainter::Antialiasing, true); setSceneRect(sceneRect()); render(&painter); pixmap.save(filePath); }

6. 进阶扩展建议

做完了基础的“画-选-改-存”之后,这个软件其实已经具备了一个商用二维编辑器所需的大部分基础能力了。如果想继续往下扩展,我个人建议按优先级推进如下几个方向:

1. 撤销/重做(Undo/Redo)

这是CAD软件用户的硬需求,没有撤销的绘图软件会让人抓狂。实现方式有两种:一种是命令模式,把每个操作封装成“执行/撤销”两个动作;另一种是快照模式,每次操作前保存一份场景的JSON快照。前者内存效率高,后者代码简单,在这个轻量级项目里用快照模式就完全够用。

2. 多选与分组

目前的框选已经支持多选了,但要拖动多选图元时,需要把它们临时绑定成一个“组合图元”。QGraphicsItemGroup可以完成这个任务,但要注意组内图元坐标是相对组坐标的,反序列化时处理起来要小心。

3. 捕捉网格与正交模式

网格捕捉可以增强鼠标操作的精确度,正交模式可以让线段保持水平或垂直。这两个功能实现起来并不复杂,捕捉只需要在mapToScene之后对坐标取整到网格步长,正交模式则是在记录拖动终点时锁定坐标轴。

4. 跨平台发布

Qt本身是跨平台的,但发布时要注意每个平台不同的打包方式。Windows用windeployqt,Linux用linuxdeployqt或者AppImage,macOS用macdeployqt。我的经验是每个平台都单独写一个发布脚本,用脚本自动化处理运行库的拷贝,避免手工漏掉某个动态库。

5. DWG/DXF兼容

虽然轻量级CAD没打算做完整DWG兼容,但如果你需要跟主流CAD软件交换数据的话,DXF格式(Autodesk公开的文本交换格式)是一个比较现实的切入点。Qt社区有第三方库可以解析DXF,自己封装也不算太复杂,核心思路是先解析DXF的ENTITIES段,把LINE、CIRCLE、ARC等实体映射到自己的图元类。

我在把这块功能做出来之后,最直观的感受是:这个轻量级的项目在这种场景下反而比AutoCAD更好用——因为它没有一堆复杂的命令面板、图层管理器和命令行的干扰,操作员只需要点几个按钮就能完成日常工作。这也是轻量级工具存在的最根本价值。

最后分享一点实际项目的经验

做到最后,比起各种技术细节,我自己体会最深的一点是:图形编辑器的开发重心,永远不要在“绘制代码”上,而是要在“数据结构和交互反馈”上。画一条线只用了十行代码,但是“这条线如何被稳定地管理、被准确地选中、被方便地编辑、被可靠地保存”,才是整个软件的灵魂。

另外,在开发过程中最好尽早引入一个自动化的文件格式测试。就是每天修改完代码后,自动把所有的示例图元保存成文件,再重新加载,对比加载前后的JSON内容是否一致。相信我,等你在图形类上加了新属性、却忘了在序列化函数里更新时,这个测试能帮你省掉大量排查时间。

如果你也想在Qt里做类似的图形编辑器,建议你从矩形和直线这两种最基础的图元开始,先把“绘制-选中-移动-保存-加载”这条主链路跑通,再不断往上叠加复杂功能。动手试一下,比读十篇教程都有用。

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

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

立即咨询