Qt自定义图元框架:支持缩放同步与锚点连接的C++绘图系统
2026/9/12 10:59:05 网站建设 项目流程

简介:这是一份面向高校C++与Qt开发初学者的课程设计级绘图软件项目,适用于软件工程、K12信息技术拓展及C++期末实践教学场景。项目实现了完整的图形编辑功能:支持文本、线段、矩形、椭圆、箭头等基础图元的添加、删除、移动、旋转与缩放;具备视图拖拽缩放、图元连接、图纸导出为图片/PDF、配置文件导入导出等实用能力。资源包共44个文件,含9个核心cpp源码、8个h头文件构成模块化架构(如circuitbaseobject、circuittextobject等),1个ui界面文件、1个pro工程配置及qss样式文件,辅以20张运行截图和1份docx使用说明,整体仅247KB,轻量易读。目前已有313人学习下载,代码结构清晰、注释规范,完整呈现Qt信号槽机制、图形项管理(QGraphicsItem)与场景视图(QGraphicsScene/QGraphicsView)的典型应用,是理解Qt绘图框架与面向对象设计思想的优质入门范例。

1. 这不是“画图板”,而是一套可扩展的 Qt 图元管理框架:C++ 课设里真正能跑通、能调试、能交差的绘图系统

很多同学拿到“Qt 绘图软件”课设题时,第一反应是打开 Qt Creator 新建 Widget 项目,拖几个按钮,QPainter一画了事——结果鼠标拖拽卡顿、图元缩放后线条模糊、删除一个矩形连带箭头断连、导出 PDF 字体乱码。这套drawing-qt项目恰恰反其道而行:它不依赖QGraphicsView的默认渲染管线,而是用QGraphicsItem自定义图元类(circuitbaseobject.h及其子类)构建完整对象模型,所有图元(Text/Line/Rect/Ellipse/Arrow)均继承自统一基类CircuitBaseObject,支持独立坐标系、局部变换矩阵、连接锚点绑定与事件拦截。它解决的不是“怎么画一条线”,而是“如何让 5 类图元在缩放、旋转、移动时保持拓扑关系一致、属性可序列化、视图交互无撕裂”。适合软件工程方向学生理解 MVC 分离、面向对象设计原则,也适合作为 K12 教学工具链中图形化编程模块的底层支撑——你改一行circuittextobject.cpppaint()实现,就能看到所有文本图元实时响应;导出配置时xml/目录下生成的是标准 DOM 结构而非二进制 blob;打印功能直接调用QPrinter+QPainter::setRenderHint(QPainter::Antialiasing),规避了常见 Qt 打印失真问题。

2. 图元生命周期与事件驱动机制:从CircuitBaseObjectCircuitTextObject的继承链解析

2.1 基类CircuitBaseObject的核心契约设计

circuitbaseobject.h定义了所有图元必须实现的最小接口集,它不是抽象基类(未声明纯虚函数),而是通过virtual函数提供可覆盖行为。关键成员包括:

class CircuitBaseObject : public QGraphicsItem { public: explicit CircuitBaseObject(QGraphicsItem *parent = nullptr); virtual ~CircuitBaseObject() override; // 必须重写:返回图元类型标识(用于序列化/反序列化) virtual ObjectType type() const { return ObjectType::Unknown; } // 必须重写:绘制逻辑(由 QGraphicsView 调用) virtual void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override = 0; // 必须重写:返回包围盒(影响碰撞检测与重绘区域) virtual QRectF boundingRect() const override = 0; // 可选重写:鼠标按下事件(支持拖拽起点记录) virtual void mousePressEvent(QGraphicsSceneMouseEvent *event) override; // 可选重写:鼠标移动事件(支持实时拖拽更新) virtual void mouseMoveEvent(QGraphicsSceneMouseEvent *event) override; // 可选重写:鼠标释放事件(触发位置提交与连接线重算) virtual void mouseReleaseEvent(QGraphicsSceneMouseEvent *event) override; protected: // 内部状态:是否处于选中态(影响绘制高亮边框) bool m_selected{false}; // 内部状态:是否启用连接锚点(Arrow/Line 需要) bool m_hasAnchorPoints{false}; // 内部状态:当前缩放比例(用于动态调整画笔宽度) qreal m_scaleFactor{1.0}; };

提示:m_scaleFactor不是 Qt 内置属性,而是项目自定义字段。它在CircuitBaseView::scale()中被同步更新,并通过CircuitBaseObject::setScaleFactor(qreal)通知所有图元。这意味着图元缩放不是简单调用setScale(),而是主动感知视图缩放并调整自身QPen宽度——这正是摘要中“图元的放缩致使画笔也应该随之变化”的实现基础。

2.2 文本图元CircuitTextObject的典型实现细节

circuittextobject.hcircuittextobject.cpp展示了如何将抽象契约落地。其paint()函数不直接调用QPainter::drawText(),而是先计算文本布局:

void CircuitTextObject::paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) { // 1. 启用抗锯齿(避免文字边缘发虚) painter->setRenderHint(QPainter::Antialiasing, true); // 2. 根据当前缩放因子动态设置字体大小(核心优化点) QFont font = m_font; font.setPointSizeF(m_font.pointSizeF() * m_scaleFactor); // 关键:字体随缩放等比放大 // 3. 计算文本实际渲染区域(考虑 DPI 缩放) QRectF textRect = boundingRect(); textRect.adjust(2, 2, -2, -2); // 留出内边距 // 4. 设置画笔颜色(支持选中态高亮) QPen pen = m_pen; if (m_selected) { pen.setColor(Qt::red); pen.setWidthF(2.0 / m_scaleFactor); // 画笔宽度反比于缩放,保证视觉粗细一致 } painter->setPen(pen); // 5. 渲染文本(居中对齐) painter->setFont(font); painter->drawText(textRect, Qt::AlignCenter, m_text); }
2.2.1boundingRect()的精确计算逻辑

文本图元的包围盒不能简单返回QFontMetrics::boundingRect(),因为该函数返回的是逻辑像素尺寸,未考虑m_scaleFactorCircuitTextObject::boundingRect()实现如下:

QRectF CircuitTextObject::boundingRect() const { QFontMetricsF fm(m_font); // 获取未缩放时的原始尺寸 QRectF baseRect = fm.boundingRect(m_text); // 按当前缩放因子等比放大 return QRectF( baseRect.x() * m_scaleFactor, baseRect.y() * m_scaleFactor, baseRect.width() * m_scaleFactor, baseRect.height() * m_scaleFactor ); }

注意:QFontMetricsF使用浮点精度,避免整数截断导致的尺寸偏差。baseRectx()/y()是负值(因字体基线偏移),直接乘以m_scaleFactor保证了锚点位置缩放一致性。

2.3 连接线图元CircuitLineObject的锚点绑定机制

circuitlineobject.h定义了startAnchor()endAnchor()两个QPointF成员,它们不直接存储绝对坐标,而是指向被连接图元的锚点 ID(如"top""bottom")。CircuitLineObject::updatePosition()在图元移动时被调用:

void CircuitLineObject::updatePosition() { if (!m_startItem || !m_endItem) return; // 1. 查询起始图元的 top 锚点世界坐标 QPointF startWorldPos = m_startItem->mapToScene( m_startItem->anchorPoint("top") // 返回局部坐标 ); // 2. 查询结束图元的 bottom 锚点世界坐标 QPointF endWorldPos = m_endItem->mapToScene( m_endItem->anchorPoint("bottom") ); // 3. 更新线段端点(触发重绘) prepareGeometryChange(); m_line.setP1(startWorldPos); m_line.setP2(endWorldPos); }

anchorPoint()CircuitBaseObject中默认返回(0,0),但CircuitRectObject重写了它:

QPointF CircuitRectObject::anchorPoint(const QString &anchorName) const { QRectF rect = boundingRect(); if (anchorName == "top") return QPointF(rect.center().x(), rect.top()); if (anchorName == "bottom") return QPointF(rect.center().x(), rect.bottom()); if (anchorName == "left") return QPointF(rect.left(), rect.center().y()); if (anchorName == "right") return QPointF(rect.right(), rect.center().y()); return QPointF(0, 0); }

这种设计使连接线完全解耦于具体图元形状——只要图元提供锚点,线就能自动吸附。这也是摘要中“图元移动时,连线也跟随移动”的技术保障。

3. 视图控制与交互逻辑:CircuitBaseView如何协调缩放、拖拽与图元选择

3.1CircuitBaseView的三大核心重载函数

circuitbaseview.h继承自QGraphicsView,其wheelEvent()mousePressEvent()mouseMoveEvent()构成交互主干。与默认QGraphicsView不同,它禁用了内置缩放(setTransformationAnchor(NoAnchor)),转而手动控制:

void CircuitBaseView::wheelEvent(QWheelEvent *event) { // 1. 获取滚轮角度(单位为 1/8 度) int delta = event->angleDelta().y(); // 2. 计算缩放因子增量(每滚轮格 1.1 倍) qreal scaleFactor = 1.1; if (delta < 0) scaleFactor = 1.0 / scaleFactor; // 3. 以鼠标位置为中心缩放(关键:避免视图平移错位) QPointF centerBefore = mapToScene(event->pos()); scale(scaleFactor, scaleFactor); QPointF centerAfter = mapToScene(event->pos()); QPointF offset = centerBefore - centerAfter; translate(offset.x(), offset.y()); // 4. 同步所有图元的 m_scaleFactor foreach (QGraphicsItem *item, scene()->items()) { if (auto *obj = dynamic_cast<CircuitBaseObject*>(item)) { obj->setScaleFactor(transform().m11()); // transform().m11() 即 x 方向缩放系数 } } }
3.1.1transform().m11()的物理意义

QTransform矩阵结构为:

[ m11 m12 dx ] [ m21 m22 dy ] [ 0 0 1 ]

其中m11是 x 轴缩放系数,m22是 y 轴缩放系数。项目假设等比缩放(m11 == m22),故仅读取m11。该值直接传给图元setScaleFactor(),驱动paint()中字体/画笔的动态调整。

3.2 多图元选择与批量操作的实现策略

mainwindow.cppon_actionSelect_triggered()并非简单调用scene()->selectedItems(),而是通过CircuitBaseObject::setSelected(bool)显式控制:

void MainWindow::on_actionSelect_triggered() { QList<QGraphicsItem*> items = scene->selectedItems(); foreach (QGraphicsItem *item, items) { if (auto *obj = dynamic_cast<CircuitBaseObject*>(item)) { obj->setSelected(true); // 触发图元内部重绘(高亮边框) } } }

CircuitBaseObject::setSelected()内部会:

  • 设置m_selected = true
  • 调用update()请求重绘
  • 若为连接线,递归调用startItem->setSelected(true)endItem->setSelected(true),确保连接关系可视化

删除操作 (on_actionDelete_triggered()) 同样显式遍历:

void MainWindow::on_actionDelete_triggered() { QList<QGraphicsItem*> items = scene->selectedItems(); foreach (QGraphicsItem *item, items) { if (auto *obj = dynamic_cast<CircuitBaseObject*>(item)) { // 1. 断开所有连接到此图元的线 obj->disconnectAllLines(); // 2. 从场景移除 scene->removeItem(item); // 3. 删除对象(注意:QGraphicsItem 析构需手动 delete) delete item; } } }

disconnectAllLines()CircuitBaseObject中遍历m_connectedLinesQList<CircuitLineObject*>),调用line->setStartItem(nullptr)line->setEndItem(nullptr),再从linem_startItem/m_endItem反向列表中移除自身——形成双向解绑闭环。

3.3 打印与导出功能的技术路径对比

功能实现方式关键参数说明
图片导出QPixmap::grabWidget()+QPixmap::save()grabWidget()捕获当前视图区域,save("out.png", "PNG", 100)100% 质量
PDF 导出QPrinter+QPainter+scene()->render()QPrinter::setOutputFormat(QPrinter::PdfFormat)render()指定目标区域
配置导出QDomDocument+QFile,序列化每个图元的type()pos()rotation()QDomElement层级结构严格对应CircuitBaseObject子类字段,支持 XML Schema 验证

scenes/circuitbasescene.cppexportToXml()函数生成的 XML 示例:

<drawing version="1.0"> <text id="1" x="100.5" y="200.0" text="Hello" font-size="12" rotation="0"/> <rect id="2" x="300" y="150" width="100" height="60" fill="#ffcc00" stroke="#000000"/> <line id="3" x1="100" y1="200" x2="300" y2="150" stroke="#0000ff" width="2"/> </drawing>

提示:QDomDocumentsave()方法默认 UTF-8 编码,中文文本无需额外处理。但QFile打开时需指定QIODevice::Text模式,否则 Windows 下可能产生 BOM 头导致解析失败。

4. 配置导入与暗黑模式切换:基于QSSresources.qrc的主题热加载

4.1resources.qrc的资源组织逻辑

resources.qrc文件将qss/目录下的样式表打包为 Qt 资源系统的一部分:

<RCC> <qresource prefix="/styles"> <file>qss/light.qss</file> <file>qss/dark.qss</file> <file>images/logo.ico</file> </qresource> </RCC>

编译后,可通过":/styles/light.qss"路径访问。mainwindow.cppapplyStyleSheet()函数实现热切换:

void MainWindow::applyStyleSheet(const QString &styleName) { QFile file(QString(":/styles/%1.qss").arg(styleName)); if (file.open(QFile::ReadOnly | QFile::Text)) { QTextStream stream(&file); QString style = stream.readAll(); qApp->setStyleSheet(style); // 全局应用 file.close(); } }

light.qssdark.qss的差异集中在QGraphicsViewQMenuBarQStatusBarbackground-colorcolor属性。例如dark.qss中:

QGraphicsView { background-color: #2d2d2d; border: 1px solid #444; } QMenuBar::item:selected { background-color: #4a4a4a; }

4.2 配置导入时的图元重建流程

importFromXml()函数解析 DOM 后,根据<text><rect>等标签名创建对应图元实例:

void CircuitBaseScene::importFromXml(const QString &filePath) { QDomDocument doc; QFile file(filePath); if (!file.open(QIODevice::ReadOnly)) return; if (!doc.setContent(&file)) { file.close(); return; } file.close(); QDomElement root = doc.documentElement(); QDomNodeList items = root.elementsByTagName("text"); for (int i = 0; i < items.length(); ++i) { QDomElement elem = items.at(i).toElement(); CircuitTextObject *text = new CircuitTextObject(); text->setPos(elem.attribute("x").toDouble(), elem.attribute("y").toDouble()); text->setText(elem.attribute("text")); text->setFont(QFont("Arial", elem.attribute("font-size").toInt())); text->setRotation(elem.attribute("rotation").toDouble()); addItem(text); } // ... 同理处理 rect/line/ellipse/arrow }

注意:addItem()将图元加入QGraphicsScene,但不会自动调用CircuitBaseObject::setScaleFactor()。因此导入后需显式遍历所有图元,调用obj->setScaleFactor(view->transform().m11())同步缩放状态,否则新图元在缩放视图中尺寸异常。

4.3 暗黑模式下图元绘制的兼容性处理

CircuitBaseObject::paint()中,QPen颜色需适配主题。CircuitTextObject重写了penColor()

QColor CircuitTextObject::penColor() const { // 检测当前应用样式表是否含 dark 关键字 QString styleSheet = qApp->styleSheet(); if (styleSheet.contains("dark.qss") || styleSheet.contains("#2d2d2d")) { return Qt::white; // 暗黑模式用白色文字 } return Qt::black; // 默认用黑色 }

CircuitRectObject::paint()同理,fillColor()根据主题返回#ffcc00(亮色)或#8a6d3b(暗色),确保图元在两种模式下均有足够对比度。

5. 课设交付与调试技巧:快速验证图元行为、定位缩放失真、修复连接断连

5.1 三步法验证图元缩放一致性

当发现缩放后文字模糊或线条过细时,按顺序检查:

  1. 确认CircuitBaseView::wheelEvent()是否正确调用setScaleFactor()
    wheelEvent()末尾添加日志:

    qDebug() << "Scale factor:" << transform().m11();

    滚动鼠标,观察输出是否随缩放递增(如 1.0 → 1.1 → 1.21 → 1.331)。

  2. 确认图元paint()中字体/画笔是否使用m_scaleFactor
    CircuitTextObject::paint()开头添加:

    qDebug() << "Text scale:" << m_scaleFactor << "font size:" << m_font.pointSizeF() * m_scaleFactor;

    拖拽图元时若m_scaleFactor为 1.0,则说明setScaleFactor()未被调用。

  3. 确认boundingRect()返回值是否匹配缩放后尺寸
    CircuitTextObject::boundingRect()返回前添加:

    qDebug() << "Bounding rect:" << baseRect << "scaled:" << QRectF(...);

    对比baseRectscaled的宽高比,应严格等于m_scaleFactor

5.2 连接线断连的定位与修复表

现象根本原因修复指令
移动图元后连线消失CircuitBaseObject::mouseMoveEvent()未调用updatePosition()mouseMoveEvent()末尾添加if (m_connectedLines.size()) updateConnectedLines();
连线端点漂移至图元中心anchorPoint()返回(0,0)而非实际锚点坐标检查CircuitRectObject::anchorPoint()是否正确计算rect.center().x()等表达式
删除图元后连线仍显示悬空disconnectAllLines()未清空m_connectedLinesCircuitBaseObject::~CircuitBaseObject()中添加qDeleteAll(m_connectedLines);

updateConnectedLines()实现:

void CircuitBaseObject::updateConnectedLines() { foreach (CircuitLineObject *line, m_connectedLines) { line->updatePosition(); // 强制重算端点 } }

5.3 K12 教学场景下的轻量化改造建议

针对中小学信息课需求,可快速启用以下功能:

  • 禁用复杂操作:注释mainwindow.uiactionRotateactionScale对应的triggered连接,保留actionSelect/actionDelete/actionPrint
  • 简化图元面板:修改mainwindow.cppinitToolButtons(),只保留TextRectLine三个按钮。
  • 强制亮色模式:删除qss/dark.qss,在main()中移除applyStyleSheet("dark")调用,避免学生误操作切换主题。

最终交付包只需包含bin/(可执行文件)、images/(图标)、qss/light.qss三个目录,体积压缩至 2MB 以内,满足校园机房快速部署要求。

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

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

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

立即咨询