资讯详情

Qt 手搓类 Simulink 仿真界面:拖拽连线与拓扑调度实战

📅 2026/10/21 12:46:48 | 华诺云谱 👁 阅读
Qt 手搓类 Simulink 仿真界面:拖拽连线与拓扑调度实战
简介这是一份基于Qt框架、在Visual Studio 2010与Qt 4.8.5环境下开发的类Simulink仿真界面小程序源码面向具备一定C基础、希望深入理解GUI设计与控制系统仿真的开发者。项目通过QGraphicsView、QGraphicsScene与QGraphicsItem构建可拖放、可配置的交互式仿真画布涵盖闭环控制回路绘制、PID模块、图形对话框及实时仿真结果动态更新等核心功能帮助读者掌握将Qt图形界面与数值计算结合以模拟连续时间系统离散化行为的完整思路。资源包共122个文件以cpp与h源码、obj中间文件、tlog日志、ui界面文件为主另含qrc资源、ico图标、sln工程与vcxproj项目文件整体约21.78MB目录结构清晰便于在VS2010中直接编译、调试与二次开发。目前已有3632人学习下载适合作为学习Qt绘图框架、数字仿真算法与工程化项目组织的实践参考。1. 从零手搓一个 Qt 仿真界面为什么我不建议你直接套现成框架去年帮一个学弟看毕设题目是「基于 Qt 的图形化仿真环境搭建」他上来就想找一个开源仿真框架改改交差。我让他先想清楚一件事Simulink 那套东西的核心到底是什么不是求解器不是算法库而是画布上的模块拖拽、连线、参数编辑、仿真调度这一整套交互闭环。你如果只是要交一份课设或毕设真正值钱的部分恰恰是这套交互骨架而不是底层跑什么微分方程。这个项目要解决的需求很具体用 Qt 和 C 做一个类似 Simulink 的仿真界面小程序支持从左侧模块库拖拽模块到画布、用连线连接模块端口、双击模块编辑参数、点击运行后按拓扑顺序执行仿真并输出结果。适合正在做 Qt 上位机、图形编辑器、仿真工具类毕设的学生也适合想练手 Qt Graphics View 框架的 C 开发者。下面这套方案从需求拆到验收你可以直接照着搭。2. 需求拆解与技术选型Graphics View 框架 自定义图元体系2.1 功能边界怎么划先把「类似 Simulink」这个模糊描述拆成可交付的功能点。一个能过答辩、能演示的最小闭环包含五块模块库面板左侧列出可用模块增益、求和、常量、示波器等支持拖拽到画布。画布编辑区模块可选中、移动、删除端口位置随模块移动自动更新。连线系统从输出端口拖到输入端口生成贝塞尔曲线连线随模块移动实时重绘。参数编辑双击模块弹出属性对话框修改增益值、常量值等参数。仿真执行点击运行按模块连接关系做拓扑排序依次计算每个模块的输出结果送到示波器模块显示。这五块里前四块是交互第五块是逻辑。很多人把精力全砸在第五块结果界面卡成 PPT答辩时老师一拖模块就露馅。我的建议是交互占七成精力仿真逻辑占三成因为演示效果决定第一印象。2.2 为什么选 QGraphicsView 而不是 QWidget 拼Qt 里做图形编辑器有两条路一是用 QWidget 自己拼每个模块一个 widget连线用 QPainter 在父 widget 上画二是用 Graphics View 框架场景QGraphicsScene 视图QGraphicsView 图元QGraphicsItem。我一般会直接上 Graphics View原因有三。第一它自带坐标变换和碰撞检测模块移动时不用自己算重绘区域。第二图元可以设置 ItemIsMovable、ItemIsSelectable 标志选中和拖动几乎零代码。第三连线作为独立图元加入场景模块移动时只需调用连线的 updatePosition()不用手动触发整个画布重绘。用 QWidget 拼的方案模块一多移动时的重绘闪烁和坐标换算能把你折磨到怀疑人生。Graphics View 的坑主要在坐标系理解上这个后面避坑章节细说。2.3 模块与连线的数据结构设计在动手写图元之前先把数据模型定下来。我习惯把「数据」和「显示」分开数据层用纯 C 类描述模块和连接关系显示层用 QGraphicsItem 子类负责画。这样仿真调度时只碰数据层不依赖 Qt 的图形对象。// 模块数据模型与图形显示解耦 struct ModuleData { int id; // 模块唯一标识 QString type; // 模块类型gain / sum / constant / scope QPointF position; // 画布坐标 QMapQString, double params; // 参数表如 {gain: 2.0} QVectorint inputIds; // 输入端口连接的模块 id QVectorint outputIds; // 输出端口连接的模块 id }; // 连接关系记录从哪个模块的哪个端口连到哪个模块的哪个端口 struct ConnectionData { int fromModuleId; int fromPortIndex; int toModuleId; int toPortIndex; };ModuleData里用QMap存参数而不是固定字段是为了新增模块类型时不用改结构体。inputIds和outputIds存的是模块 id 而非指针避免模块删除后的悬空指针问题——这是血泪经验早期用裸指针存连接关系删一个模块程序直接崩。仿真调度时遍历所有ModuleData根据inputIds做拓扑排序得到执行顺序再依次调用每个模块的计算函数。计算函数根据type分发增益模块输出 输入 × gain求和模块输出 所有输入之和常量模块输出 常量值示波器模块把输入存进缓冲区。3. 画布与图元实现模块拖拽、端口吸附与贝塞尔连线3.1 自定义模块图元每个模块在画布上是一个 QGraphicsItem。我一般继承 QGraphicsObject 而不是 QGraphicsItem因为前者自带信号槽端口位置变化时能发信号通知连线更新。class ModuleItem : public QGraphicsObject { Q_OBJECT public: ModuleItem(const ModuleData data, QGraphicsItem *parent nullptr); QRectF boundingRect() const override; // 返回模块外框矩形 void paint(QPainter *painter, ...) override; // 绘制模块外观 QPointF inputPortPos(int index) const; // 输入端口中心坐标 QPointF outputPortPos(int index) const; // 输出端口中心坐标 int moduleId() const { return m_data.id; } protected: QVariant itemChange(GraphicsItemChange change, const QVariant value) override; private: ModuleData m_data; static constexpr qreal WIDTH 80.0; static constexpr qreal HEIGHT 50.0; };boundingRect()返回模块的矩形范围Qt 用它做碰撞检测和重绘裁剪必须准确否则会出现拖动残影。paint()里画圆角矩形加模块名端口画成小圆点。itemChange()是关键当模块位置变化ItemPositionHasChanged时遍历所有关联连线调用连线的更新函数重算路径。端口坐标的计算要统一。我习惯把输入端口放在模块左侧输出端口放在右侧垂直方向按端口数量均分。inputPortPos(index)返回的是场景坐标用mapToScene()转换这样连线图元拿到的是绝对坐标不用关心父项变换。3.2 拖拽创建从模块库到画布左侧模块库用 QListWidget每一项设置自定义 MIME 数据。画布所在的 QGraphicsView 重写 dragEnterEvent 和 dropEvent。void CanvasView::dropEvent(QDropEvent *event) { const QMimeData *mime event-mimeData(); if (!mime-hasFormat(application/x-module-type)) return; QString type QString::fromUtf8(mime-data(application/x-module-type)); QPointF scenePos mapToScene(event-pos()); // 视图坐标转场景坐标 emit moduleDropped(type, scenePos); // 通知主窗口创建模块 event-acceptProposedAction(); }这里有个容易翻车的点event-pos()是视图坐标必须用mapToScene()转成场景坐标否则模块会落在错误位置尤其是画布滚动或缩放后偏移更明显。emit moduleDropped把创建逻辑交给主窗口保持视图类职责单一。3.3 连线图元与实时更新连线继承 QGraphicsPathItem路径用三次贝塞尔曲线。控制点根据起点和终点的水平距离动态计算距离越远弧度越大视觉上更像 Simulink。void ConnectionItem::updatePath() { QPointF start m_fromItem-outputPortPos(m_fromPort); QPointF end m_toItem-inputPortPos(m_toPort); qreal dx qAbs(end.x() - start.x()) * 0.5; // 控制点水平偏移量 QPainterPath path(start); path.cubicTo(start QPointF(dx, 0), // 第一控制点 end - QPointF(dx, 0), // 第二控制点 end); setPath(path); }dx取水平距离的一半保证曲线平滑且不会自交。连线创建流程鼠标在输出端口按下时记录起点模块和端口移动时画一条临时线跟随鼠标松开时检测落点是否在某个输入端口上是则创建 ConnectionItem 并写入数据模型。检测落点用scene()-items(pos)拿到该位置所有图元遍历找端口。连线更新靠信号槽ModuleItem 位置变化时发positionChanged信号ConnectionItem 槽函数调用updatePath()。注意连线本身要设置setZValue(-1)让它画在模块下面否则连线会盖住模块文字。4. 仿真调度与参数编辑拓扑排序与属性对话框4.1 拓扑排序确定执行顺序仿真执行的核心是确定模块的计算顺序。有向无环图用 Kahn 算法做拓扑排序先统计每个模块的入度即输入连接数入度为 0 的入队出队时把其输出连接的模块入度减一减到 0 再入队。QVectorint Scheduler::topologicalSort(const QVectorModuleData modules) { QHashint, int inDegree; for (const auto m : modules) inDegree[m.id] m.inputIds.size(); QQueueint queue; for (auto it inDegree.begin(); it ! inDegree.end(); it) if (it.value() 0) queue.enqueue(it.key()); QVectorint order; while (!queue.isEmpty()) { int id queue.dequeue(); order.append(id); // 找到该模块遍历其输出连接的目标模块 for (int nextId : findModule(modules, id).outputIds) { if (--inDegree[nextId] 0) queue.enqueue(nextId); } } if (order.size() ! modules.size()) qWarning() 检测到环路仿真无法执行; // 有环时排序结果不完整 return order; }排序结果order就是执行序列。如果order长度小于模块总数说明存在环路比如两个增益模块互相连接这时要弹提示而不是硬跑否则仿真会死循环。每个模块的计算函数签名统一为double compute(const QVectordouble inputs)调度器按顺序调用把结果缓存起来供下游模块读取。4.2 参数编辑对话框双击模块触发mouseDoubleClickEvent弹出 QDialog。对话框内容根据模块类型动态生成增益模块一个 QDoubleSpinBox 编辑增益值常量模块一个编辑框求和模块选择符号。用 QFormLayout 布局确定后把值写回ModuleData.params并刷新模块显示。void ModuleItem::mouseDoubleClickEvent(QGraphicsSceneMouseEvent *event) { ParameterDialog dlg(m_data.type, m_data.params); if (dlg.exec() QDialog::Accepted) { m_data.params dlg.parameters(); // 取回修改后的参数 update(); // 触发重绘模块上显示新参数 } QGraphicsObject::mouseDoubleClickEvent(event); }参数写回后调用update()重绘模块把关键参数显示在模块下方答辩时老师一眼能看到参数变化对结果的影响。示波器模块的结果显示用 QChart 或自绘折线我倾向自绘依赖少、可控性强。4.3 仿真结果可视化示波器模块维护一个QVectordouble缓冲区每次仿真步进把输入值追加进去。仿真结束后示波器窗口用 QPainter 画折线图横轴时间步、纵轴数值自动缩放坐标范围。如果要动态显示用 QTimer 每 50ms 刷新一次模拟实时波形效果演示时观感更好。5. 避坑与常见问题排查坐标系、内存与重绘的三个大坑5.1 模块拖动后连线错位现象拖动模块连线端点不跟随或者跟随但偏移一个固定距离。原因端口坐标计算时混用了视图坐标和场景坐标。event-pos()是视图坐标QGraphicsItem::pos()是父项坐标只有mapToScene()才是场景坐标。连线图元在场景中必须用场景坐标。解决统一所有端口坐标返回场景坐标在inputPortPos()内部用mapToScene(本地端口坐标)转换。连线图元直接用返回值不再做任何坐标变换。5.2 删除模块后程序崩溃现象选中模块按 Delete 删除再点运行或拖动其他模块程序闪退。原因数据模型里用裸指针存连接关系模块删除后指针悬空仿真调度或连线更新时访问野指针。解决数据层用 id 而非指针关联删除模块时同步清理所有涉及该 id 的连接记录并删除对应的 ConnectionItem。图形层删除用scene-removeItem()后手动delete或者设置父项让 Qt 自动管理。5.3 模块数量多时拖动卡顿现象画布上超过二三十个模块后拖动一个模块整个界面卡顿。原因itemChange()里对每个位置变化都触发全场景重绘或者连线更新时遍历了所有图元。解决只更新关联连线不要调scene-update()全刷。给 ModuleItem 设置setCacheMode(QGraphicsItem::DeviceCoordinateCache)Qt 会缓存模块的绘制结果移动时直接复用位图。另外把boundingRect()返回值收紧到实际范围减少重绘区域。5.4 连线端口吸附不准现象鼠标明明落在端口上却创建不了连线。原因端口检测用图元的boundingRect()判断而端口小圆点的实际范围比 boundingRect 小或者检测时没考虑图元的 Z 值顺序。解决端口检测单独用一个圆形区域判断以端口中心为圆心、半径 8 像素内都算命中。用scene-items(scenePos)拿到候选图元后遍历判断是否为端口图元而不是判断整个模块图元。5.5 仿真结果全为零现象点击运行示波器没有任何波形或者全是零。原因拓扑排序后执行时模块读取输入值用的是初始值而非上游模块的计算结果或者常量模块没被正确初始化。解决调度器维护一个QHashint, double outputCache每个模块计算前先从缓存取输入值计算后把结果写入缓存。常量模块入度为 0最先执行其输出写入缓存供下游读取。调试时在计算函数里打印模块 id 和输出值一眼能看出哪一环断了。6. 验收与答辩怎么演示才能让老师点头验收这件事很多人栽在「功能都有但演示翻车」上。我一般会按下面的清单提前走一遍确保答辩时不出岔子。验收项操作步骤合格标准模块拖拽从左侧拖三个不同模块到画布模块落在鼠标位置无偏移连线创建从输出端口拖到输入端口生成平滑曲线端点吸附准确模块移动拖动中间模块连线实时跟随无残影参数编辑双击增益模块改增益值对话框弹出修改后模块显示更新仿真运行搭建「常量→增益→示波器」链路运行示波器显示放大后的波形异常处理搭建环路运行弹出环路提示不崩溃删除模块删除中间模块关联连线一并删除程序稳定演示流程我建议按「搭一个完整链路」来讲而不是逐个功能点。比如先拖一个常量模块设值为 1再拖一个增益模块设增益为 3连线再拖示波器连线点运行示波器显示一条值为 3 的直线。然后现场改增益为 5再运行波形变成 5。这一套下来交互、参数、仿真、可视化全展示到了比干巴巴说「我实现了拖拽功能」有说服力得多。答辩时老师最爱问的三个问题提前准备好答案一是「你的拓扑排序怎么处理环路」答 Kahn 算法检测入度排序结果不完整就报错二是「模块和连线怎么关联的」答数据层用 id 关联图形层用信号槽更新三是「为什么用 Graphics View 不用 QWidget」答坐标变换和碰撞检测现成模块多了性能更好。从那以后我每次做图形编辑器类的项目都强制自己先把数据模型和图形显示分开设计再动手写图元。这个习惯帮我省了至少三次重构。希望这套方案能帮到你需要完整工程源码和模块清单的可以按项目标题去找对应的资源包里面连参数对话框的 UI 文件都配齐了。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑