实战案例:从零拆解一个FEA前后处理器 / Building an FEA Pre- and Post-Processor from Scratch
📅 创建时间:2026-07-13 🏷️ 标签:#Qt #CAE #FEA #案例分析 #前后处理 #综合实战 📚 前置知识:Cae Architecture Patterns、Visualization 3d、Pre Post Processing
📋 本章目标
- 将前7篇的知识串联为一个完整的FEA前后处理器案例
- 理解从几何导入→网格划分→加载→求解→后处理的完整数据流
- 掌握Qt信号在整个系统中的端到端流动路径
- 理解模块划分与代码组织策略
- 能够将此案例架构应用到自己的CAE项目中
专题扩展
第1部分:系统总览
1.1 功能范围
┌─────────────────────────────────────────────────────────────┐
│ FastCAE — 案例FEA前后处理器 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 本案例实现的核心功能: │
│ │
│ 前处理: │
│ ┌──────────────────────────────────────────────────┐ │
│ │ 1. 导入几何(Nastran BDF网格文件) │ │
│ │ 2. 显示网格(线框/实体/收缩模式) │ │
│ │ 3. 鼠标交互(旋转/平移/缩放/拾取节点) │ │
│ │ 4. 创建载荷集(点力/压力/固定约束) │ │
│ │ 5. 分配材料属性 │ │
│ │ 6. 导出求解器输入文件(CalculiX .inp) │ │
│ └──────────────────────────────────────────────────┘ │
│ │
│ 求解: │
│ ┌──────────────────────────────────────────────────┐ │
│ │ 7. 后台启动 CalculiX 求解器 │ │
│ │ 8. 实时显示迭代进度 │ │
│ │ 9. 支持取消 │ │
│ └──────────────────────────────────────────────────┘ │
│ │
│ 后处理: │
│ ┌──────────────────────────────────────────────────┐ │
│ │ 10. 加载结果文件(.frd) │ │
│ │ 11. 云图显示(位移/应力) │ │
│ │ 12. 变形图(放大位移) │ │
│ │ 13. 节点结果表格 │ │
│ └──────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘1.2 技术栈
| 层 | 技术 | 用途 |
|---|---|---|
| UI | Qt6 Widgets + OpenGLWidgets | 主窗口、面板、视口 |
| 3D | VTK 9.x (QVTKOpenGLNativeWidget) | 网格渲染、拾取、云图 |
| 构建 | CMake 3.21+ | AUTOMOC + 模块化 |
| 求解器 | CalculiX (QProcess调用) | FEA求解 |
| 数据 | std::vector<Node/Element> | 核心数据结构 |
| 持久化 | QSettings INI + QTextStream | 项目文件 + INP导出 |
1.3 模块总览图
┌─────────────────────────────────────────────────────────────┐
│ FastCAE 模块总览 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ │
│ │ MainWindow │ ← QMainWindow │
│ └──────┬───────┘ │
│ ┌───────────────┼───────────────┐ │
│ ↓ ↓ ↓ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ VtkMeshView │ │ PartTreeView│ │NodeTableView│ ← Views │
│ │ (3D视口) │ │ (零件树) │ │ (结果表格) │ │
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
│ └───────────────┼───────────────┘ │
│ ↓ 信号槽 │
│ ┌─────────────────┐ │
│ │ ModelDocument │ ← Logic (QObject) │
│ │ MeshData │ │
│ │ LoadCases │ │
│ │ SolverWrapper │ │
│ └────────┬────────┘ │
│ ↓ 直接调用 │
│ ┌─────────────────┐ │
│ │ BdfImporter │ ← Algorithm │
│ │ InpExporter │ │
│ │ FrdReader │ │
│ └────────┬────────┘ │
│ ↓ │
│ ┌─────────────────┐ │
│ │ QFile / Stream │ ← Data I/O │
│ └─────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘第2部分:核心数据结构
2.1 基础类型——POD结构
// ===== 纯数据,不继承QObject(性能优先)=====
struct Node {
int id;
double x, y, z;
};
struct Element {
int id;
int type; // 4=TET4, 5=PYRA5, 6=PENTA6, 8=HEX8
std::array<int, 8> nodeIds; // 最多8节点
int materialId = -1;
};
struct Material {
QString name;
double youngsModulus; // Pa
double poissonRatio;
double density; // kg/m³
};
struct LoadCase {
QString name;
QString type; // "Static", "Modal", "Thermal"
QVector<Force> forces;
QVector<Pressure> pressures;
QVector<Constraint> constraints;
};2.2 MeshData——聚合数据容器
class MeshData : public QObject {
Q_OBJECT
public:
explicit MeshData(QObject* parent = nullptr) : QObject(parent) {}
// 数据访问
const std::vector<Node>& nodes() const { return m_nodes; }
const std::vector<Element>& elements() const { return m_elements; }
// 修改数据(通知Model层的View刷新)
void setNodes(std::vector<Node> nodes) {
m_nodes = std::move(nodes);
emit dataChanged();
}
void setElements(std::vector<Element> elems) {
m_elements = std::move(elems);
emit dataChanged();
}
// 几何范围
void computeBounds() {
m_boundsMin = QVector3D(1e30, 1e30, 1e30);
m_boundsMax = QVector3D(-1e30, -1e30, -1e30);
for (const auto& n : m_nodes) {
m_boundsMin.setX(qMin(m_boundsMin.x(), n.x));
m_boundsMin.setY(qMin(m_boundsMin.y(), n.y));
m_boundsMin.setZ(qMin(m_boundsMin.z(), n.z));
m_boundsMax.setX(qMax(m_boundsMax.x(), n.x));
m_boundsMax.setY(qMax(m_boundsMax.y(), n.y));
m_boundsMax.setZ(qMax(m_boundsMax.z(), n.z));
}
}
signals:
void dataChanged(); // View们,刷新吧
private:
std::vector<Node> m_nodes;
std::vector<Element> m_elements;
QVector3D m_boundsMin, m_boundsMax;
};2.3 ModelDocument——顶层文档对象
class ModelDocument : public QObject {
Q_OBJECT
public:
explicit ModelDocument(QObject* parent = nullptr) : QObject(parent) {
m_meshData = new MeshData(this); // MeshData的parent=Document
m_undoStack = new QUndoStack(this);
}
MeshData* meshData() const { return m_meshData; }
QUndoStack* undoStack() const { return m_undoStack; }
// 导入
bool importMesh(const QString& path);
// 导出
bool exportSolverInput(const QString& path);
// 求解
void runSolver();
bool isModified() const { return m_modified; }
signals:
void modifiedChanged(bool modified);
void solverProgress(int percent, const QString& msg);
void solverFinished(int exitCode);
void resultsLoaded();
private:
MeshData* m_meshData;
QUndoStack* m_undoStack;
QVector<LoadCase> m_loadCases;
QVector<Material> m_materials;
bool m_modified = false;
};第3部分:模块拆解
3.1 几何导入模块
class BdfImporter {
public:
bool import(const QString& path, MeshData& out) {
QFile file(path);
if (!file.open(QIODevice::ReadOnly | QIODevice::Text))
return false;
QTextStream in(&file);
std::vector<Node> nodes;
std::vector<Element> elements;
while (!in.atEnd()) {
QString line = in.readLine().trimmed();
// Nastran BDF 格式: GRID,ID,,X,Y,Z
if (line.startsWith("GRID")) {
QStringList f = line.split(',');
Node n;
n.id = f[1].trimmed().toInt();
n.x = f[3].trimmed().toDouble();
n.y = f[4].trimmed().toDouble();
n.z = f[5].trimmed().toDouble();
nodes.push_back(n);
}
// CTETRA,EID,PID,N1,N2,N3,N4
else if (line.startsWith("CTETRA")) {
QStringList f = line.split(',');
Element e;
e.id = f[1].trimmed().toInt();
e.type = 4; // TET4
e.nodeIds[0] = f[3].trimmed().toInt() - 1;
e.nodeIds[1] = f[4].trimmed().toInt() - 1;
e.nodeIds[2] = f[5].trimmed().toInt() - 1;
e.nodeIds[3] = f[6].trimmed().toInt() - 1;
elements.push_back(e);
}
}
out.setNodes(std::move(nodes));
out.setElements(std::move(elements));
out.computeBounds();
return true;
}
};3.2 视口桥接——QAbstractTableModel
// 让QTableView显示节点坐标
class NodeCoordinateModel : public QAbstractTableModel {
Q_OBJECT
public:
void setMeshData(MeshData* data) {
beginResetModel();
m_mesh = data;
endResetModel();
}
int rowCount(const QModelIndex&) const override {
return m_mesh ? m_mesh->nodes().size() : 0;
}
int columnCount(const QModelIndex&) const override { return 4; }
QVariant data(const QModelIndex& idx, int role) const override {
if (!m_mesh || role != Qt::DisplayRole) return {};
const auto& n = m_mesh->nodes()[idx.row()];
switch (idx.column()) {
case 0: return n.id;
case 1: return n.x;
case 2: return n.y;
case 3: return n.z;
}
return {};
}
private:
MeshData* m_mesh = nullptr;
};3.3 求解器集成模块
class SolverWrapper : public QObject {
Q_OBJECT
public:
void run(const QString& inpPath) {
m_thread = new QThread();
m_worker = new SolverWorker(inpPath);
m_worker->moveToThread(m_thread);
connect(m_thread, &QThread::started, m_worker, &SolverWorker::run);
connect(m_worker, &SolverWorker::progress, this, &SolverWrapper::progress);
connect(m_worker, &SolverWorker::finished, this, &SolverWrapper::onFinished);
connect(m_worker, &SolverWorker::finished, m_thread, &QThread::quit);
connect(m_thread, &QThread::finished, m_worker, &QObject::deleteLater);
connect(m_thread, &QThread::finished, m_thread, &QObject::deleteLater);
m_thread->start();
}
void cancel() {
QMetaObject::invokeMethod(m_worker, "cancel", Qt::QueuedConnection);
}
signals:
void progress(int percent, const QString& status);
void finished(int exitCode);
private:
QThread* m_thread = nullptr;
SolverWorker* m_worker = nullptr;
};第4部分:信号流动全景
4.1 端到端信号链路
┌─────────────────────────────────────────────────────────────┐
│ FastCAE 端到端信号流动 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. 用户点击 "File → Import" │
│ ↓ │
│ Action triggered → MainWindow::onImport() │
│ ↓ │
│ QFileDialog → 用户选择 wing.bdf │
│ ↓ │
│ BdfImporter::import("wing.bdf", meshData) │
│ ↓ │
│ meshData->setNodes/ Elements() │
│ ↓ │
│ emit MeshData::dataChanged() │
│ ↓ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ 信号扇出 (Fan-Out) │ │
│ │ ├─→ NodeCoordinateModel → QTableView 刷新 │ │
│ │ ├─→ VtkMeshView → 构建VTK Pipeline → 3D渲染 │ │
│ │ ├─→ StatusBar → "Loaded 1234567 nodes" │ │
│ │ └─→ Document → setModified(true) │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ 2. 用户点击 "Solver → Run" │
│ ↓ │
│ InpExporter::export("job.inp", mesh, loads) │
│ ↓ │
│ SolverWrapper::run("job.inp") │
│ ↓ │
│ QProcess started on worker thread │
│ ↓ │
│ readyReadStandardOutput → parse "iteration 5" → │
│ emit progress(50, "Iter 5/10") │
│ ↓ (QueuedConnection → 主线程) │
│ QProgressBar::setValue(50) │
│ │
│ 3. 求解完成 │
│ ↓ │
│ emit finished(0) → SolverWrapper → Document │
│ ↓ │
│ FrdReader::read("job.frd", results) │
│ ↓ │
│ emit Document::resultsLoaded() │
│ ↓ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ 信号扇出 │ │
│ │ ├─→ VtkMeshView → 云图渲染(位移/应力) │ │
│ │ ├─→ NodeResultModel → QTableView 结果数据 │ │
│ │ └─→ StatusBar → "Solution complete" │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘4.2 关键connect()汇总
// ===== MainWindow构造中的关键连接 =====
void MainWindow::setupConnections() {
// 文档数据变更 → 所有视图刷新
connect(m_document->meshData(), &MeshData::dataChanged,
this, &MainWindow::refreshAllViews);
// 3D视口拾取节点 → 属性面板 + 状态栏
connect(m_vtkView, &VtkMeshView::nodePicked,
this, &MainWindow::onNodePicked);
// 求解器进度 → 进度条 + 状态栏
connect(m_document, &ModelDocument::solverProgress,
this, &MainWindow::onSolverProgress);
// 求解完成 → 加载结果
connect(m_document, &ModelDocument::solverFinished,
this, &MainWindow::onSolverFinished);
// 属性面板修改 → 更新3D显示
connect(m_propertyPanel, &PropertyPanel::displayModeChanged,
m_vtkView, &VtkMeshView::setDisplayMode);
}第5部分:代码组织
5.1 目录结构
FastCAE/
├── CMakeLists.txt # 顶层构建
├── src/
│ ├── main.cpp # 入口
│ ├── core/ # 核心数据(无Qt依赖)
│ │ ├── node.h # struct Node
│ │ ├── element.h # struct Element
│ │ └── material.h # struct Material
│ ├── io/ # 文件I/O
│ │ ├── bdf_importer.h/cpp # Nastran BDF读取
│ │ ├── inp_exporter.h/cpp # CalculiX INP输出
│ │ └── frd_reader.h/cpp # CalculiX FRD结果读取
│ ├── model/ # MVD Model层
│ │ ├── mesh_data.h/cpp # QObject, 核心数据聚合
│ │ ├── model_document.h/cpp # QObject, 顶层文档
│ │ ├── node_coord_model.h/cpp # QAbstractTableModel
│ │ └── node_result_model.h/cpp# QAbstractTableModel
│ ├── solver/ # 求解器集成
│ │ └── solver_wrapper.h/cpp # QThread + QProcess
│ ├── view/ # UI视图
│ │ ├── main_window.h/cpp # QMainWindow
│ │ ├── vtk_mesh_view.h/cpp # QVTKOpenGLNativeWidget
│ │ ├── property_panel.h/cpp # QDockWidget
│ │ └── status_bar.h/cpp # QStatusBar
│ └── resources/
│ └── resources.qrc # 图标/翻译文件
└── tests/
├── test_bdf_import.cpp
└── test_mesh_data.cpp5.2 CMake模块化
# CMakeLists.txt (简化)
cmake_minimum_required(VERSION 3.21)
project(FastCAE VERSION 1.0 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_AUTOMOC ON)
find_package(Qt6 REQUIRED COMPONENTS Core Widgets OpenGLWidgets)
find_package(VTK REQUIRED COMPONENTS GUISupportQt
CommonCore FiltersSources RenderingOpenGL2)
# 核心库(无Qt依赖)
add_library(fastcae_core STATIC
src/core/node.h src/core/element.h src/core/material.h)
# IO库
add_library(fastcae_io STATIC
src/io/bdf_importer.cpp src/io/inp_exporter.cpp
src/io/frd_reader.cpp)
target_link_libraries(fastcae_io PUBLIC Qt6::Core fastcae_core)
# Model库
add_library(fastcae_model STATIC
src/model/mesh_data.cpp src/model/model_document.cpp)
target_link_libraries(fastcae_model PUBLIC fastcae_core Qt6::Core)
# Solver库
add_library(fastcae_solver STATIC src/solver/solver_wrapper.cpp)
target_link_libraries(fastcae_solver PUBLIC Qt6::Core)
# 可执行文件
add_executable(fastcae
src/main.cpp src/view/main_window.cpp
src/view/vtk_mesh_view.cpp src/view/property_panel.cpp)
target_link_libraries(fastcae PRIVATE
fastcae_io fastcae_model fastcae_solver
Qt6::Widgets Qt6::OpenGLWidgets ${VTK_LIBRARIES})第6部分:从案例到生产
6.1 本案例的简化之处
| 简化项 | 生产级替代 |
|---|---|
| 单一网格格式(BDF) | 多格式插件系统 (BDF/INP/UNV/STL) |
| 无撤销/重做 | QUndoStack (见[06]) |
| 单一求解器(CalculiX) | 求解器插件接口 (见[06]) |
| 单一视口 | 多视口 (四视图: XY/YZ/XZ/3D) |
| 无国际化 | QTranslator (见[06]) |
| 无单元测试 | QTest + Google Test |
| 同步文件I/O | 异步I/O + 进度条 |
6.2 深入学习资源
- FreeCAD:
src/Mod/Mesh/和src/Gui/View3DInventorViewer.cpp— 真正的CAD级视口 - ParaView: VTK Pipeline + Qt 的终极例子
- CalculiX GraphiX: 同类型的轻量FEA前后处理器(CGX)
核心总结
总结1:数据流向是单向的
导入 → MeshData (数据) → signal dataChanged()
→ Views (QTableView, QTreeView, VtkMeshView) 自动刷新
→ 用户编辑 → Document → dataChanged() → Views刷新
关键:数据只存在于Document中,Views是被动观察者总结2:信号槽是整个系统的"神经系统"
导入触发dataChanged,求解器进度通过QueuedConnection从Worker线程传递到UI线程,视口拾取触发属性面板更新——信号槽让所有组件松耦合协作。
总结3:POD数据 + QObject容器
百万节点用std::vector<Node>(12 bytes/node),只有一个QObject(MeshData)管理整批数据。永远不要为每个节点/单元创建QObject。
章节测试
测试1:数据流
在FastCAE中,用户导入新网格后,以下哪个描述最准确? A. 每个View各自读取文件并显示 B. Document导入→MeshData更新→emit dataChanged()→所有View自动刷新 C. MainWindow直接更新每个View D. View轮询检查文件是否变化
测试2:求解器通信
求解器进度从Worker线程到进度条的正确路径是? A. Worker直接调用 m_progressBar->setValue() B. Worker emit signal → QueuedConnection → 主线程 slot 更新进度条 C. Worker写文件 → 主线程轮询读文件 D. Worker用QMutex保护进度条然后直接更新
测试3:架构层次
以下哪个是FastCAE架构中不属于Logic层的? A. ModelDocument B. MeshData C. BdfImporter D. SolverWrapper
参考答案
测试1答案
答案:B。数据单向流动:Importer→MeshData→signal dataChanged()→Views。所有Views通过连接同一个信号来同步,Document不直接操作任何View。
测试2答案
答案:B。Worker和进度条在不同线程。Worker emit signal,Qt自动检测到跨线程→QueuedConnection→主线程事件循环执行slot→安全更新UI。这是[05-多线程]的核心模式。
测试3答案
答案:C。BdfImporter属于Algorithm层(第3层)——纯文件解析逻辑,不依赖QObject信号槽。ModelDocument、MeshData、SolverWrapper都属于Logic层。
相关笔记
- Cae Architecture Patterns - CAE软件架构的完整模式
- Visualization 3d - 3D可视化与VTK集成
- Model View Architecture - Model/View数据处理
- Multithreading Cae - QThread与求解器集成
- Pre Post Processing - 前后处理概念
下一步学习
- [ ] 用本案例的骨架代码搭建你自己的CAE工具
- [ ] 研究FreeCAD源码中的View3DInventorViewer(真正的CAD级视口)
- [ ] 阅读ParaView的ServerManager架构(分布式可视化的典范)
- [ ] 将Plugin系统([06])集成到FastCAE中,支持多种求解器和文件格式
学习状态:🟡 开始学习
案例的端到端时序
Open file
-> validate header/index
-> create Document rev 1
-> populate lightweight model
-> background load/compute chunks(rev 1)
-> GUI commits batches if revision matches
-> render uploads with scene generation
User edits -> Command -> rev 2 -> old rev 1 results rejected/rebased
Save rev 2 -> atomic commit -> dirty cleared for rev 2
Close -> cancel -> wait -> release GPU -> destroy Document这个时序是检验架构的主线:任何对象、任务、index 或 GPU handle 都应能指出在其中何时生、何时失效。
故障注入表
file truncated -> parser bounded failure, Document not half-created
disk full save -> old file remains, dirty stays true
solver cancelled -> partial result not committed unless protocol allows
window closes during upload -> generation rejects completion
plugin unload request -> wait objects/tasks before module close
GPU device loss -> rebuild cache from Document面试连续追问
问:为什么保存要绑定 revision? 答:后台保存 rev 5 时用户可能编辑到 rev 6;完成只能确认 rev 5 已落盘,不能清除 rev 6 的 dirty。
问:大工程加载如何尽快显示? 答:先读取索引/元数据建立轻量 Document 与 view,再按可见性/优先级分块加载,统一内存背压和取消。
问:如何避免旧任务覆盖新场景? 答:任务携带 document ID、input revision、scene generation,提交点全部核对。
自测与答案
- 导入失败是否应留下空 Document 标签? 答:按产品策略,但构造/切换应事务化,不能覆盖当前有效文档后才发现失败。
- Undo 命令能否引用临时 UI index? 答:不能,应保存稳定领域 ID/必要 delta。
- 关闭 timeout 后怎么办? 答:报告仍在途组件并按协议升级取消;不能直接卸载其代码/销毁其数据。