ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

基于Qt与QGraphicsView的轻量级CAD图形编辑器开发实践

基于Qt与QGraphicsView的轻量级CAD图形编辑器开发实践 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软件和普通绘图软件最大的区别在交互逻辑上绘制一个矩形需要经过“按下起点、拖动、松开确定终点”这样的完整动作链而且中途按右键要能取消。这种交互如果一个个去硬编码代码很快就会乱成一团。我的解法是引入一个简单的“命令状态机”。每一个绘制工具对应一个命令类命令类有四个核心方法mousePress、mouseMove、mouseRelease、paintPreview。视图接收到鼠标事件后转发给当前激活的命令而不是自己在事件函数里写一大堆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; };视图侧只维护一个QScopedPointerDrawCommand 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_castBaseItem*(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用windeployqtLinux用linuxdeployqt或者AppImagemacOS用macdeployqt。我的经验是每个平台都单独写一个发布脚本用脚本自动化处理运行库的拷贝避免手工漏掉某个动态库。5. DWG/DXF兼容虽然轻量级CAD没打算做完整DWG兼容但如果你需要跟主流CAD软件交换数据的话DXF格式Autodesk公开的文本交换格式是一个比较现实的切入点。Qt社区有第三方库可以解析DXF自己封装也不算太复杂核心思路是先解析DXF的ENTITIES段把LINE、CIRCLE、ARC等实体映射到自己的图元类。我在把这块功能做出来之后最直观的感受是这个轻量级的项目在这种场景下反而比AutoCAD更好用——因为它没有一堆复杂的命令面板、图层管理器和命令行的干扰操作员只需要点几个按钮就能完成日常工作。这也是轻量级工具存在的最根本价值。最后分享一点实际项目的经验做到最后比起各种技术细节我自己体会最深的一点是图形编辑器的开发重心永远不要在“绘制代码”上而是要在“数据结构和交互反馈”上。画一条线只用了十行代码但是“这条线如何被稳定地管理、被准确地选中、被方便地编辑、被可靠地保存”才是整个软件的灵魂。另外在开发过程中最好尽早引入一个自动化的文件格式测试。就是每天修改完代码后自动把所有的示例图元保存成文件再重新加载对比加载前后的JSON内容是否一致。相信我等你在图形类上加了新属性、却忘了在序列化函数里更新时这个测试能帮你省掉大量排查时间。如果你也想在Qt里做类似的图形编辑器建议你从矩形和直线这两种最基础的图元开始先把“绘制-选中-移动-保存-加载”这条主链路跑通再不断往上叠加复杂功能。动手试一下比读十篇教程都有用。
返回列表