简介:面向Qt/C++开发者及三维建模工具研究者的OpenCASCADE示例工程,核心解决OCC三维视图与Qt界面融合、常用建模操作封装问题,适合需要快速搭建三维建模原型或进行二次开发的场景。压缩包为rar格式,共94个文件、18.05MB,构成上以66个dll动态库为主体,提供OpenCASCADE运行支持;5个cpp、4个h及ui文件为核心源码,展示控件封装与建模类组织方式;同时包含pro、sln等工程文件和可执行exe,兼顾Qt Creator与Visual Studio工程,便于直接运行或编译调试。目前已有3218人学习下载。项目中封装了OCC三维控件和建模类,可实现圆柱、圆锥、立方体、管道等常规三维建模操作及展示。png图片多为建模结果或界面预览,方便对照效果。通过源码可完整梳理Qt与OCC的视图嵌入、事件交互和建模接口调用思路,对理解二者集成颇有帮助,也适合作为课程设计或二次开发的起点。
1. 从零搭一个三维建模工作台:Qt 和 OpenCASCADE 的分工
做设备软件的人大概率都会撞上同一个需求:在自家程序里嵌一个三维场景,让用户看模型、画线条、交点选,甚至做最基本的拉伸和切除。市面上的 CAD 内核要么贵,要么拿着授权费往桌面工具上一套就发懵;自己从 OSG、VTK 往上堆几何算法,做到后面往往发现离“能用”还差一个内核的距离。Qt + OpenCASCADE(OCCT)的组合是这条路上最稳的一条斜线:Qt 负责界面、事件循环和窗口,OCCT 提供 B-rep 建模、拓扑和显示。这套工程就是照着这个组合拆出来的一份可复现建模平台,适合在 Qt 里做三维显示、标注、简单建模的 C++ 开发者。它能帮你省掉的,是我第一次搭这个环境时反复翻车的几天时间。
2. 环境搭建:Qt 5.15.2 与 OCCT 7.6,从 CMake 到第一个视图窗口
2.1 版本选型:为什么是 Qt 5.15.2 + MSVC2017_64
OCCT 官方在 Windows 上发布的预编译库,长期以 MSVC 工具链为主,而 Qt 5.15.2 提供了配套的离线安装包,两者的调试信息、运行库版本能够对上。选 5.15.2 而不是 Qt 6,不是因为新版本不好,而是 OCCT 的很多第三方扩展和旧工程还在 Qt 5 的生态里,真到企业项目里你大概率要维护的不是一个全新平台,而是一堆老代码。
Qt 的编译器版本建议选择和 OCCT 预编译包一致的 MSVC2017 64 位。注意,安装 Qt 5.15.2 时勾选“msvc2017_64”组件而不是 MinGW——理由只有一个:OCCT 官方预编译库用的是 MSVC 的 ABI,用 MinGW 去链接会碰到符号表不兼容,到时你会收到一堆 undefined reference。如果你坚持用 MinGW,就得自己用同一编译器重新编译整个 OCCT,时间成本立刻上去。
2.2 获取 OCCT:vcpkg 一条命令还是源码编译
OpenCASCADE 的获取方式,主流是两种:vcpkg 安装和源码编译。我的建议是本地用 vcpkg,做跨平台交付时再走源码编译。
vcpkg install opencascade:x64-windows这条命令会把 OCCT 连同它的第三方依赖(FreeType、TBB、RapidJSON 等)一起编译安装,并生成 CMake 的 package 文件,后续 find_package(OpenCASCADE) 直接可用。vcpkg 当前默认提供 7.6 这一代接口,如果你自己编源码,也建议锁定 7.6.x 这个稳定分支,后面的接口变动不会太大。
用 vcpkg 之后,CMake 工具链文件要主动指定,否则找不到 OCCT 的配置。常见做法是在生成工程时加一句:
cmake -B build -S . -DCMAKE_TOOLCHAIN_FILE=<vcpkg-root>/scripts/buildsystems/vcpkg.cmake -DCMAKE_PREFIX_PATH=C:/Qt/5.15.2/msvc2017_64这里把 Qt 的安装路径通过 CMAKE_PREFIX_PATH 指给 CMake,让find_package(Qt5)和find_package(OpenCASCADE)都在同一个工具链下解析,避免编译器混搭。
2.3 CMake 工程骨架:链接 Qt 与 OCCT 的正确姿势
有了上述前提,CMakeLists 最短可以这么写:
cmake_minimum_required(VERSION 3.16) project(ModelingBench LANGUAGES CXX) set(CMAKE_AUTOMOC ON) set(CMAKE_CXX_STANDARD 17) find_package(Qt5 5.15 REQUIRED COMPONENTS Widgets) find_package(OpenCASCADE REQUIRED) add_executable(ModelingBench WIN32 src/main.cpp src/MainWindow.cpp src/ViewportWidget.cpp ) target_link_libraries(ModelingBench PRIVATE Qt5::Widgets ${OpenCASCADE_LIBRARIES} ) target_include_directories(ModelingBench PRIVATE ${OpenCASCADE_INCLUDE_DIR} )这里有几个容易忽略的点:CMAKE_AUTOMOC必须开,OCCT 的接口全部通过.hxx暴露,本身不需要 MOC,但我们自己的 QObject 派生类需要;WIN32关键字告诉链接器这是窗口程序,不弹黑色控制台;OpenCASCADE_LIBRARIES是一个列表,里面是几十个 lib,不用自己逐个加。
有一个经验:把 OCCT 的 include 目录放在 target_include_directories 里时,顺序上尽量排在 Qt 头文件之后。OCCT 的头文件历史上有过全局宏,先被它预处理的话可能会污染 Qt 的一些声明,排后面能少很多莫名其妙的编译错误。
2.4 把视图接进 QWidget:winId、SetWindow 与初始化时机
OCCT 在 Qt 里的显示,本质是把 OpenGL 场景绑到一个原生窗口句柄上,然后在 QWidget 里留一块区域给它。初始化代码我一般单独抽一个initOCC(),并且只在showEvent里调用一次:
void ViewportWidget::initOCC() { Handle(Aspect_DisplayConnection) conn = new Aspect_DisplayConnection(); Handle(OpenGl_GraphicDriver) driver = new OpenGl_GraphicDriver(conn); viewer = new V3d_Viewer(driver); view = viewer->CreateView(); Handle(Aspect_NeutralWindow) nativeWin = new Aspect_NeutralWindow(); nativeWin->SetNativeHandle(reinterpret_cast<AspectNativeHandle>(winId())); view->SetWindow(nativeWin); view->SetBackgroundColor(Quantity_Color(0.15, 0.15, 0.15, Quantity_TOC_RGB)); view->MustBeResized(); context = new AIS_InteractiveContext(viewer); }这段代码的顺序是有讲究的:先有 OpenGl_GraphicDriver,创建 V3d_Viewer,再创建 V3d_View,最后把窗口挂到视图上。容易漏的是view->SetWindow(nativeWin),不挂窗口的话,后面 AIS_Display 全部正常但画面始终黑屏,因为 GL 上下文没有关联到任何窗口。
winId()必须在窗口已经 show 之后调用,否则拿到的句柄是 0。所以 initOCC 不要放在构造函数里,我通常放在 showEvent 或第一次 paintEvent 时执行。
同时重写resizeEvent:
void ViewportWidget::resizeEvent(QResizeEvent* event) { QWidget::resizeEvent(event); if (!view.IsNull()) { view->MustBeResized(); } }视图尺寸不跟随窗体会出现非常直观的现象:窗口拉大后场景停留在左上角一小块,或者拉伸变形。这个函数就是 OCCT 视图的“后悔药”,每次窗口尺寸变化都要强制它重新算投影矩阵。
3. 工程架构:界面层、命令层与 OCAF 文档的职责划分
3.1 为什么必须在按钮和建模代码之间插一层命令
把建模操作直接写在按钮的槽函数里,项目前期会觉得很爽,等命令多到十几个,你会发现两件事:第一,操作历史没法回滚;第二,每个按钮都在重复“取点、建线、建面、拉伸、显示”这五步,代码量爆炸。命令模式在这里不是为了设计感,而是为了把建模动作变成可存储、可撤销的对象。
我一般先定义命令基类:
class CommandBase : public QObject { Q_OBJECT public: CommandBase(ModelingService* service, QObject* parent = nullptr) : QObject(parent), m_service(service) {} virtual void run() = 0; virtual void undo() = 0; protected: ModelingService* m_service; };按钮那边只负责实例化并调用 run(),不关心命令内部具体做了什么。比如“拉伸”命令的 run() 里做 B-rep 构建,成功后就 push 进撤销栈;至于撤销实现,最简单的一版是在 undo() 里把上一步生成的 AIS 对象从场景中移除,再重新显示备份的形状。
ModelingService 是把 OCCT 操作集中起来的地方,界面层永远不该直接调用 BRepPrimAPI:
class ModelingService { public: TopoDS_Shape buildPrism(const gp_Pnt& a, const gp_Pnt& b, Standard_Real height); TopoDS_Shape cut(const TopoDS_Shape& base, const TopoDS_Shape& tool); };这么做的收益是:将来要做尺寸驱动、参数化,只需要改 ModelingService 内部,按钮和命令层完全不动。
3.2 数据层用 OCAF:把形状挂到标签树上,而不是自己写序列化
如果你只需要在内存里显示几个模型,直接拿 TopoDS_Shape 在界面层传来传去就够了。但如果平台要做到“打开文件、保存文件”,拓扑形状的序列化是绕不开的。OCCT 自带的 OCAF 模型(OpenCASCADE Application Framework)就是干这个的,它把 B-rep 数据以属性的形式挂到一棵标签树上,同时支持文件名、颜色、图层等自定义属性。
初始化一个文档并放入形状:
Handle(TDocStd_Application) app = new TDocStd_Application(); app->DefineFormat("ModelingBench", "ModelingBench native", ".bench"); Handle(TDocStd_Document) doc; app->NewDocument("MDTV-Standard", doc); TDF_Label rootLabel = doc->Main().FindChild(1, Standard_True); TDataStd_Shape::Set(rootLabel, shape);这里面的两个参数值得留意:NewDocument 指定的是 OCAF 的内部存储格式,用 OCCT 内置的 “MDTV-Standard” 即可,里面约定好了标签树里各类属性的放置规则;FindChild 的第二个参数为 Standard_True,表示标签不存在时自动创建。
保存文件时调用:
app->SaveAs(doc, "model.bench");轨迹从加载到保存全都交给 OCAF,我们只用关心业务字段。很多人第一次接触 TDocStd_Document 会觉得抽象,但其实它就是一个带访问权限管理的树容器,比你自己撸一版 JSON 序列化可靠得多。
3.3 模块划分:小型工程也应该按职责分目录
这个项目源码目录结构不长,但每一层职责都很明确:
ModelingBench/ ├── CMakeLists.txt ├── src/ │ ├── main.cpp │ ├── MainWindow.{h,cpp} # 菜单、工具栏、命令注册 │ ├── ViewportWidget.{h,cpp} # OCCT 视图 + 鼠标交互 │ ├── commands/ │ │ ├── CommandBase.{h,cpp} │ │ └── ExtrudeCommand.{h,cpp} │ ├── modeling/ │ │ └── ModelingService.{h,cpp} │ └── document/ │ └── DocumentManager.{h,cpp}MainWindow 只负责把 QAction 映射到命令对象;ViewportWidget 只负责显示和把鼠标事件翻译成业务坐标;ModelingService 封装所有 BRep 操作;DocumentManager 管 OCAF。这样划分之后,一个新同事接手时不需要读 800 行主窗口代码才能开始加命令,这个代价在建模类工具里特别值得。
4. 第一个建模命令:画线、拉伸成体、布尔减差的完整链路
4.1 鼠标拾取坐标:屏幕像素翻转与世界坐标换算
三维建模的第一步,是用户鼠标点击屏幕上的位置,换算成三维空间里的点。OCCT 的 V3d_View 提供 Convert 方法,把窗口坐标转为世界坐标。这里有一个极其隐蔽的坑:Qt 屏幕坐标的 y 轴向下,而 OCCT 窗口坐标的 y 轴向上,忘记翻转的话,你画的线条会随着窗口高度的增加而上下错位。
void ViewportWidget::mousePressEvent(QMouseEvent* event) { if (event->button() != Qt::LeftButton) { return; } const int sx = event->pos().x(); const int sy = height() - event->pos().y(); // y 轴翻转,OCCT 原点在左下角 Standard_Real wx = 0.0, wy = 0.0, wz = 0.0; view->Convert(sx, sy, wx, wy, wz); emit pointPicked(gp_Pnt(wx, wy, wz)); }这里一定要理解 Convert 转换出来的坐标是落在“当前视角下的投影平面”上的,而不是任意空间点。你正对 Z 轴看,点击得到的是 XY 平面上的点;你转了视角再看,点就会落在屏幕对应的那个斜面上。要做真正的三维捕捉,需要配合 AIS 拾取到的形状做求交,那是后话;但对于画草图、画辅助线这类平面操作,Convert 完全够用。
4.2 从线到面到体:Wire、Face、Prism 的先后顺序
OCCT 建模有两种路径:一种用 BRepBuilderAPI 直接构造简单形状,另一种用 BRepPrimAPI 做扫掠、拉伸。要把一个草图变成三维体,必须先把线段包成闭合 Wire,再把 Wire 包成 Face,最后才能拉伸。拿一个矩形草图为例子:
gp_Pnt p1(0.0, 0.0, 0.0); gp_Pnt p2(100.0, 0.0, 0.0); gp_Pnt p3(100.0, 80.0, 0.0); gp_Pnt p4(0.0, 80.0, 0.0); BRepBuilderAPI_MakePolygon polyBuilder; polyBuilder.Add(p1); polyBuilder.Add(p2); polyBuilder.Add(p3); polyBuilder.Add(p4); polyBuilder.Close(); TopoDS_Wire wire = polyBuilder.Wire(); BRepBuilderAPI_MakeFace faceBuilder(wire); TopoDS_Face face = faceBuilder.Face(); gp_Vec extrudeVec(0.0, 0.0, 50.0); TopoDS_Shape body = BRepPrimAPI_MakePrism(face, extrudeVec);BRepBuilderAPI_MakePolygon 比逐条 Add Edge 更省事,Close() 会把最后一点和第一点连起来,生成闭合 wire。这里有个必须检查的依据:如果 p4 与 p1 重合、或者某个边长退化,Wire 构建不会报错,但后续 MakeFace 会悄然失败。所以 faceBuilder 之后要检查faceBuilder.IsDone(),BRep 这条链上的每个 Maker 都值得这么防一手。
BRepPrimAPI_MakePrism 的第二个参数是拉伸向量,方向和长度都决定最终实体。参数化建模的“参数”在这里开始体现:把 p1、p2、thickness 抽成可编辑变量,再配合命令模式,就能实现“修改参数重新建模”。
4.3 布尔减:切除体的正确打开方式与失败定位
布尔运算是建模平台绕不开的一环。先构建一个基体和工具体,然后做差集:
BRepAlgoAPI_Cut cutOp; cutOp.SetShape1(baseShape); cutOp.SetShape2(toolShape); cutOp.SetFuzzyValue(1e-6); cutOp.SetRunParallel(Standard_True); cutOp.Build(); if (!cutOp.IsDone()) { // 失败时打印诊断信息 qWarning() << "布尔减失败,检查工具体与基体的接触关系"; return; } TopoDS_Shape result = cutOp.Shape();两个参数需要解释:SetFuzzyValue 是模糊区间,用于处理两个形状间微小间隙造成的求交毛刺,值太大(比如 1e-3)会吃掉细节,值太小(默认的 Precision::Confusion() 是 1e-7)对工业模型常不够;SetRunParallel 开启多线程,大模型上有明显速度提升,结果对退化情况的处理是确定性的,可以放心开。
如果 IsDone() 返回 true 但 result 是 null,或者结果形状缺面,最常见的原因是基体和工具体存在共面区域:两个面正好贴在一起,BOP 算法在这种退化输入下很容易给出空结果。先跑诊断器再手工查:
BOPAlgo_ArgumentAnalyzer analyzer; analyzer.SetShape1(baseShape); analyzer.SetShape2(toolShape); analyzer.CheckSelfInterferences(Standard_True); analyzer.Perform();诊断器的输出会指出具体的干涉类型,比如自相交、共边、退化棱边。我碰到过的最典型情况是工具体比基体多出 0.001mm 的悬空部分,导致切除后留下微缝隙,最后解决办法是把工具体每个面沿法向膨胀 0.0005mm,再求差集。
5. 避坑手册:Qt 版本冲突、0000005 闪退、拓扑丢失与黑屏
5.1 弹窗报“cannot mix incompatible Qt library”:多套 Qt 库混进 PATH
现象:工程能编译,一运行立刻弹出fatal: cannot mix incompatible Qt library (version ex50601) with this library,程序退出。
原因:进程同时加载了两套不同版本的 Qt5Core.dll,通常是你把 OCCT 或者某个第三方工具链的 bin 目录加进了 PATH,或者手工从另一个 Qt 目录拷贝过 DLL。ex50601 是 Qt 内部版本号 0x050601 的十六进制写法,对应的是 Qt 5.6.1 这套库——连 5.6.1 都出来了,说明运行库根本没走你指定的 5.15.2。
解决:用 windeployqt 把运行所需的 DLL 固定到 exe 目录,保证程序只从本地加载一套 Qt:
C:/Qt/5.15.2/msvc2017_64/bin/windeployqt.exe build/Release/ModelingBench.exe之后把 exe 目录放到 PATH 最前面,把其它 Qt 相关目录从 PATH 里挪走。这个问题的排查本质是“看 DLL 从哪加载”,打开进程模块列表,谁的 Qt5Core.dll 路径不对,就是它。
5.2 模型一多就闪退,崩溃码 0000005:AIS 对象生命周期没管好
现象:程序在创建第三个、第四个 AIS_Shape 时突然退出,Windows 事件查看器里崩溃码是 0x00000005(访问违规),没有 Qt 报错,也没有异常捕获。
原因:OCCT 的内存模型全部基于 handle(引用计数智能指针),但很多人习惯把从 handle 里拿到的普通指针存下来,或者用局部 handle 构造完后让它销毁。AIS_Shape 对象一旦被销毁,场景里挂着的显示状态就变成了悬空引用。
解决:所有 AIS 对象一律使用Handle(AIS_Shape)持有,并且存在命令对象的成员里,不要 bare pointer。需要更新模型时,先把旧对象从 context 里 Remove,再 Display 新的:
context->Remove(aisShape, Standard_False); aisShape = new AIS_Shape(newShape); context->Display(aisShape, Standard_True);这一步是我反复强调的血泪经验:OCCT 的内存问题往往不是泄漏,而是“过早释放”。拿不准生命周期时,宁可多 hold 一份,做完再统一清理。
5.3 布尔运算返回空形状或缺面:先怀疑共面和退化边
现象:Cut 的 IsDone() 返回 true,但 Shape() 得到的是空拓扑,或者结果模型上少了整整一块面。
原因:BOP 算法在遇到两个形状共面、共边等情况时,会产生退化结果。常见的还有工具体某个面与基体表面存在 1e-5 到 1e-3 量级的间隙,默认精度下被判定为“不接触”,实际既不减到也不报错。
解决:先用 BOPAlgo_ArgumentAnalyzer 做自相交检查;再尝试把 SetFuzzyValue 从默认值放大一个数量级看看结果是否稳定;还不行就把工具体沿拉伸方向微调 0.0001mm,避开完全共面。这是最玄学的部分,但通常跑一次诊断器就能定位。
5.4 视图黑屏、缩放变形:winId 拿得太早,或者忘记 MustBeResized
现象:窗口能出现,但有半个窗口是黑色,鼠标缩放视角后内容错乱。甚至整个窗口什么都看不到,只有菜单栏。
原因:一是 winId() 在窗口还没显示时被调用,拿到的句柄为 0;二是 SetWindow 没有执行;三是窗口尺寸变化后没有调用 MustBeResized。
解决:初始化的完整时机放在 showEvent,且只执行一次;resizeEvent 里必须调用view->MustBeResized()。调试的时候,可以在 initOCC 后面加一行 qDebug 打印 winId,确认它不是 0 再往下走。我把这个检查固化成习惯之后,几乎再没被黑屏坑过。
提示:用 QWidget 嵌套 OCCT 视图时,不要给这个 widget 设置 QPainter 绘制。它会和 OCCT 的 OpenGL 上下文打架,表现是间歇性花屏。
5.5 嵌入式运行报错:could not find the qt platform plugin “linuxfb”
现象:程序从桌面环境挪到纯命令行环境跑,启动时报qt.qpa.plugin: could not find the qt platform plugin "linuxfb",进程退出。
原因:Qt 的 QPA 平台插件(linuxfb、minimal、eglfs 等)不在默认搜索路径里。开发机上 Qt 安装目录有 plugins,但部署目录里没带 plugins/platforms。
解决:把 Qt 安装目录的plugins/platforms整体拷贝到部署根的 plugins 下,并设置环境变量再启动:
export QT_QPA_PLATFORM_PLUGIN_PATH=/app/plugins export QT_QPA_PLATFORM=linuxfb ./ModelingBench交叉编译场景尤其要养成“部署目录自带 plugins 全家桶”的习惯,我见过太多在开发机上跑得好好的,拷到目标板就黑脸的案例。
6. 把交互补齐:颜色、拾取高亮与框选的最后一公里
6.1 给模型上色、做半透明
Handle(AIS_Shape) ais = new AIS_Shape(shape); ais->SetColor(Quantity_Color(0.2, 0.6, 0.8, Quantity_TOC_RGB)); ais->SetTransparency(0.25); context->Display(ais, Standard_True); context->Redisplay(ais, Standard_True);SetTransparency 对半透明材质观察非常直观,常用于显示工具体或干涉区域。改完颜色或透明度之后,一定要走一次 Redisplay 强制刷新,否则画面上还是旧材质参数。
6.2 拾取高亮
void ViewportWidget::mouseMoveEvent(QMouseEvent* event) { int sx = event->pos().x(); int sy = height() - event->pos().y(); context->MoveTo(sx, sy, view, Standard_True); }MoveTo 之后,OCCT 自己会处理高亮显示;要拿到当前拾取对象,调用context->SelectedInteractive()。一个小技巧是把它放在状态栏上实时显示,调试拾取逻辑时效率最高。
6.3 框选
框选不需要跟 V3d_View 较劲,自己用 QPainter 画一个拖拽矩形,在 mouseReleaseEvent 里取矩形两角,转换为世界坐标后,和每个 AIS_Shape 的包围盒做相交判断。关键在于矩形角点必须同样做 y 翻转,并且对旋转后的视图按当前视角的投影面换算,否则框选位置和屏幕上画的框永远对不上。
当初我把第一版框选做完,发现在透视图下框选经常漏选,后来才意识到包围盒相交测试用的世界坐标和用户看到的投影面不是一回事。从那以后我每次写交互层,都强制把“颜色 → 拾取 → 刷新”这条回归走一遍:先确认拾取高亮对,再确认框选对,最后调颜色显示。这个顺序帮我挡住了不少返工,希望帮到你。
本文还有配套的精品资源,点击获取