用 OCCT 造一只啤酒瓶:完整实录

0 阅读13分钟

跟着官方教程用 OCCT 造一只啤酒瓶:建模、可视化到参数化实验的完整实录

一只 50×70×30mm 的参数化瓶子,用 Open CASCADE Technology(OCCT)从 5 个二维点开始, 经轮廓、拉伸、圆角、抽壳、放样螺纹,最后组装成 Compound——全程跟着官方 tutorial.md 走, 并在 macOS 上搭起一个可交互的查看器:三种显示模式、对象切换、运行时参数化实验。 本文记录完整实现过程,以及踩过的每一个坑。

成品先看(官方教程最终效果,我们在自己的查看器里 1:1 复现):

QQ_1792749872067.png


1. OCCT 与"瓶子教程"

Open CASCADE Technology(OCCT)是一个开源的 3D 建模内核,CAD 软件界的常青树, FreeCAD、若干国产 CAD 的几何底层都与它有渊源。它的 B-Rep(边界表示)体系把几何 (Geom 包)与拓扑(TopoDS 包)分离:点/线/面是"几何数据",顶点/边/线框/面/壳/实体 是"拓扑结构",组合起来才能描述一个可制造的零件。

官方文档里最经典的入门材料就是 Bottle Tutorial(dox/tutorial/tutorial.md): 从 5 个二维支撑点出发,造一只带螺纹瓶盖的啤酒瓶。麻雀虽小五脏俱全——拉伸、圆角、 布尔并、抽壳、放样五大建模操作全部覆盖。

我们的目标:

  1. 逐节实现教程,不用教程自带的 Qt 示例框架,而是配一个精简的 GLFW + OCCT 查看器;
  2. 可视化增强:线框 / 着色 / 三角网格三种显示模式随时切换;
  3. 参数化实验:运行时用方向键改壁厚、螺纹圈数、螺纹位置,实时重建模型。

最终工程结构(本文所有代码在 learning/ 下):

occt-test/
├── CMakeLists.txt              # 根构建(查看器 occt_viewer + 子目录)
├── CMakePresets.json           # 锁定 Apple clang + Debug(踩坑产物,见 §9.1)
├── learning/
│   ├── CMakeLists.txt          # bottle_tutorial 目标
│   └── bottle_tutorial.cpp     # 全部教程代码 + 查看器(单文件,约 600 行)
└── build-debug/                # Debug 构建目录

2. 环境搭建(macOS):版本对齐是第一原则

2.1 安装与源码

brew install opencascade glfw cmake   # Homebrew 的 OpenCASCADE 当前是 7.9.3

教程文档建议直接下载与安装库同版本的源码用于对照(GitHub tag V7_9_3)。 这里有个隐蔽的版本陷阱:OCCT master 分支的 tutorial.md 已经是 8.0 风格 (occ::handle<T>、NCollection_List<T>),而 7.9.3 的库只认旧式写法 (Handle(T)、TopTools_ListOfShape)。两份教程长得几乎一样,代码却不能混抄—— 文档版本必须和链接的库版本一致,这是本次实验的第一课。

教程 8.0 写法7.9.3 对应写法(本文采用)
occ::handle<Geom_TrimmedCurve>Handle(Geom_TrimmedCurve)
occ::down_cast<Geom_Plane>(aSurface)Handle(Geom_Plane)::DownCast(aSurface)
NCollection_List<TopoDS_Shape>TopTools_ListOfShape
GC_MakeSegment2dGCE2d_MakeSegment

对齐版本后,教程附录代码几乎可以逐字照抄。

2.2 编译器:必须 Apple clang,不能用 Homebrew g++

第一个真坑。同一份代码,命令行 cmake 能编译,IDE 里重新配置就报一屏 CGPDFDictionaryApplierBlock has not been declared、expected unqualified-id before '^' token……

原因:macOS 系统头文件里的 blocks 语法(^) 只有 Apple clang 默认开启; CMake 在某些配置路径下会找到 Homebrew 的 g++-15,它根本不认识这套语法。 解法是用 CMakePresets.json 把编译器锁死(预设模式还会让 CMake Tools 绕过 容易选错编译器的 Kit 机制):

{
  "version": 6,
  "configurePresets": [{
    "name": "debug",
    "displayName": "Debug (Apple clang)",
    "generator": "Unix Makefiles",
    "binaryDir": "${sourceDir}/build-debug",
    "cacheVariables": {
      "CMAKE_BUILD_TYPE": "Debug",
      "CMAKE_C_COMPILER": "/usr/bin/clang",
      "CMAKE_CXX_COMPILER": "/usr/bin/c++"
    }
  }]
}

2.3 IDE 断点调试:cppdbg 在 macOS 上不可用

VS Code / Cursor 里 C/C++ 扩展(cppdbg)默认用 lldb --interpreter=mi 启动调试, 而苹果 Command Line Tools 的 lldb 不支持 MI 模式,必报 unknown option: --interpreter=mi。解法是改用 CodeLLDB 扩展(自带独立调试器):

// .vscode/settings.json
{ "cmake.useCMakePresets": "always", "cmake.debugger.type": "code-lldb" }
// .vscode/launch.json —— F5 一键构建+调试
{ "version": "0.2.0", "configurations": [{
    "name": "调试 bottle_tutorial", "type": "lldb", "request": "launch",
    "program": "${workspaceFolder}/build-debug/learning/bottle_tutorial",
    "cwd": "${workspaceFolder}", "console": "integratedTerminal",
    "preLaunchTask": "构建 bottle_tutorial" }] }

日常使用:1/2/3 选对象、W 切显示模式、断点单步看 OCCT 对象内部, Cmd+Shift+P → CMake: Select Configure Preset 选 "Debug (Apple clang)"。


3. 第一步:Building the Profile(轮廓)

教程 sec2 的目标是得到瓶子半边轮廓的镜像合并体。四小节层层递进: 支撑点 → 几何曲线 → 拓扑边/线框 → 镜像合并。

3.1 支撑点:值语义的 gp_Pnt

QQ_1792750059732.png

gp_Pnt aPnt1(-myWidth / 2., 0, 0);
gp_Pnt aPnt2(-myWidth / 2., -myThickness / 4., 0);
gp_Pnt aPnt3(0, -myThickness / 2., 0);
gp_Pnt aPnt4(myWidth / 2., -myThickness / 4., 0);
gp_Pnt aPnt5(myWidth / 2., 0, 0);

教程先讲了个选择:描述一个点可以用值语义的 gp_Pnt,也可以用引用计数、 可长期存活的 Geom_CartesianPoint(handle 智能指针)。这些点只是构建曲线的 临时脚手架,生命周期极短——选值语义的 gp_Pnt。

调试小知识:后面在调试器里看到的 myRefCount_ 是 handle 的引用计数 (多少智能指针指向共享的 TShape),跟任何业务数量(比如"几个面")都无关。 拓扑数据的真实容器是 TopoDS_Shape → myTShape → TopoDS_TShape 三层结构。

3.2 几何:GC 算法包把"作图"变成一行代码

QQ_1792750090566.png

Handle(Geom_TrimmedCurve) anArcOfCircle = GC_MakeArcOfCircle(aPnt2, aPnt3, aPnt4);
Handle(Geom_TrimmedCurve) aSegment1     = GC_MakeSegment(aPnt1, aPnt2);
Handle(Geom_TrimmedCurve) aSegment2     = GC_MakeSegment(aPnt4, aPnt5);

GC 包的构造器可直接函数式调用拿结果,返回 Geom_TrimmedCurve—— 在参数区间上裁剪的基曲线。失败时会抛异常,生产代码建议改用 IsDone()/Value() 模式(这个建议在本文 §9.6 会变成一次真实的翻车现场)。

3.3 拓扑:曲线只是数据,边和线框才是结构

QQ_1792750120132.png

TopoDS_Edge anEdge1 = BRepBuilderAPI_MakeEdge(aSegment1);   // 曲线 → 边
TopoDS_Edge anEdge2 = BRepBuilderAPI_MakeEdge(anArcOfCircle);
TopoDS_Edge anEdge3 = BRepBuilderAPI_MakeEdge(aSegment2);
TopoDS_Wire aWire   = BRepBuilderAPI_MakeWire(anEdge1, anEdge2, anEdge3);  // 边 → 线框

几何曲线之间没有任何"相连"关系,TopoDS 的 Edge/Wire 才把三段曲线缝合成整体。

3.4 镜像合并:gp_Trsf + TopoDS::Wire 转型

QQ_1792750181699.png

gp_Ax1 xAxis = gp::OX();          // 全局 X 轴
gp_Trsf aTrsf;
aTrsf.SetMirror(xAxis);           // 定义镜像变换
BRepBuilderAPI_Transform aBRepTrsf(aWire, aTrsf);
TopoDS_Wire aMirroredWire = TopoDS::Wire(aBRepTrsf.Shape());  // Shape→Wire 转型

BRepBuilderAPI_MakeWire mkWire;
mkWire.Add(aWire);
mkWire.Add(aMirroredWire);
TopoDS_Wire myWireProfile = mkWire.Wire();   // 上下两个半轮廓 → 完整闭合轮廓

第 1 步产物:一条闭合的瓶子轮廓 wire(程序里临时以线框显示验证)。


4. 第二步:Building the Body(瓶身)

4.1 拉伸:Wire → Face → Solid

教程给出一张"什么生成什么"的表:Vertex→Edge、Edge→Face、Wire→Shell、Face→Solid。 轮廓是 wire,所以先升维成 Face 再拉伸:

TopoDS_Face myFaceProfile = BRepBuilderAPI_MakeFace(myWireProfile);  // wire 共面, 自动生成平面
gp_Vec aPrismVec(0, 0, myHeight);
TopoDS_Shape myBody = BRepPrimAPI_MakePrism(myFaceProfile, aPrismVec);  // 沿 Z 拉伸 70mm

4.2 圆角:遍历器的"三板斧"

QQ_1792750224071.png

BRepFilletAPI_MakeFillet mkFillet(myBody);
TopExp_Explorer anEdgeExplorer(myBody, TopAbs_EDGE);   // 遍历实体的全部边
while (anEdgeExplorer.More()) {
  mkFillet.Add(myThickness / 12., TopoDS::Edge(anEdgeExplorer.Current()));
  anEdgeExplorer.Next();
}
myBody = mkFillet.Shape();

TopExp_Explorer 的 More()/Current()/Next() 三板斧是 OCCT 遍历子形状的万能套路, 后文找"瓶口顶面"还要再用一次。注意圆角半径 myThickness/12——教程把所有尺寸都 定义成三个规格参数(宽/高/厚)的分数,这正是后面参数化实验能一键联动的原因。

4.3 瓶颈:共享坐标系 + 布尔并

QQ_1792750274905.png

gp_Pnt neckLocation(0, 0, myHeight);   // 瓶身顶面中心
gp_Ax2 neckAx2(neckLocation, gp::DZ()); // ★ 记住这个坐标系, 螺纹全靠它
myBody = BRepAlgoAPI_Fuse(myBody, myNeck);  // 圆柱瓶颈 + 布尔并

4.4 抽壳:找到"瓶口顶面"

image.png

抽壳(Thick Solid)需要告诉算法移除哪个面来形成开口——瓶口顶面。特征: 是平面(Geom_Plane),且在所有平面里 Z 最高:

for (TopExp_Explorer aFaceExplorer(myBody, TopAbs_FACE); aFaceExplorer.More(); aFaceExplorer.Next()) {
  TopoDS_Face aFace = TopoDS::Face(aFaceExplorer.Current());
  Handle(Geom_Surface) aSurface = BRep_Tool::Surface(aFace);       // 拓扑面 → 几何表面
  if (aSurface->DynamicType() == STANDARD_TYPE(Geom_Plane)) {      // 运行时类型识别
    Handle(Geom_Plane) aPlane = Handle(Geom_Plane)::DownCast(aSurface);
    gp_Pnt aPnt = aPlane->Location();
    if (aPnt.Z() > zMax) { zMax = aPnt.Z(); faceToRemove = aFace; }
  }
}
TopTools_ListOfShape facesToRemove;
facesToRemove.Append(faceToRemove);
BRepOffsetAPI_MakeThickSolid aSolidMaker;
aSolidMaker.MakeThickSolidByJoin(myBody, facesToRemove, -myThickness / 50, 1.e-3);
myBody = aSolidMaker.Shape();   // 壁厚 0.6mm 的空心瓶身

这段代码浓缩了三个 OCCT 惯用法:

  • BRep_Tool::Surface:从拓扑面取几何表面(几何/拓扑分离哲学的体现);
  • DynamicType() == STANDARD_TYPE(...):handle 对象的运行时类型识别;
  • ::DownCast:基类 handle 安全向下转型。

第 2 步产物:带瓶颈的空心瓶身。


5. 第三步:Building the Threading(螺纹)——全文最妙的一节

直接构造 3D 螺旋曲线的数学非常复杂,教程的思路堪称降维打击: 把圆柱面展开成 (U,V) 参数平面,在平面上画斜的椭圆弧,让 OCCT 把它"卷"回圆柱面。

image.png

圆柱面的参数方程 P(U,V) = O + R·(cosU·xDir + sinU·yDir) + V·zDir 中, U 是圆周角、V 是高度。任何斜置于 (U,V) 平面的曲线,映射到 3D 就是螺旋线:

QQ_1792750432537.png

5.1 两张圆柱参数面

image.png

Handle(Geom_CylindricalSurface) aCyl1 = new Geom_CylindricalSurface(neckAx2, myNeckRadius * 0.99);
Handle(Geom_CylindricalSurface) aCyl2 = new Geom_CylindricalSurface(neckAx2, myNeckRadius * 1.05);

直接复用瓶颈的 neckAx2——螺纹的定位在这里就已经决定了(见 §5.4)。 0.99/1.05 两个系数保证螺纹内壁嵌进瓶颈、外壁凸出来,两实体保持干涉接触。

5.2 参数空间里的椭圆

image.png

gp_Pnt2d aPnt(2. * M_PI, myNeckHeight / 2.);
gp_Dir2d aDir(2. * M_PI, myNeckHeight / 4.);
gp_Ax2d anAx2d(aPnt, aDir);

Standard_Real aMajor = 2. * M_PI;          // 长轴 2π → 弧线 U 方向横跨约 4π = 绕两圈
Standard_Real aMinor = myNeckHeight / 10;
Handle(Geom2d_Ellipse) anEllipse1 = new Geom2d_Ellipse(anAx2d, aMajor, aMinor);
Handle(Geom2d_Ellipse) anEllipse2 = new Geom2d_Ellipse(anAx2d, aMajor, aMinor / 4);
Handle(Geom2d_TrimmedCurve) anArc1 = new Geom2d_TrimmedCurve(anEllipse1, 0, M_PI);
Handle(Geom2d_TrimmedCurve) anArc2 = new Geom2d_TrimmedCurve(anEllipse2, 0, M_PI);
...
Handle(Geom2d_TrimmedCurve) aSegment = GCE2d_MakeSegment(anEllipsePnt1, anEllipsePnt2);

5.3 从 2D 曲线到可显示的实体

TopoDS_Edge anEdge1OnSurf1 = BRepBuilderAPI_MakeEdge(anArc1, aCyl1);  // 2D曲线+曲面 → 边(pcurve)
...
BRepLib::BuildCurves3d(threadingWire1);   // 反求真实 3D 曲线(数学复杂, OCCT 代劳)

BRepOffsetAPI_ThruSections aTool(Standard_True);  // true = 生成实体
aTool.AddWire(threadingWire1);
aTool.AddWire(threadingWire2);
aTool.CheckCompatibility(Standard_False);
TopoDS_Shape myThreading = aTool.Shape();          // 放样出螺纹

pcurve(参数曲线)与 3D 曲线分离是 B-Rep 的精髓:边先记住"在曲面上怎么走", 3D 曲线等到 BuildCurves3d 时才反求。线框模式下你看到的每条螺旋线, 都是椭圆经 P(U,V) 求值"卷"出来的——单独显示螺纹实体时数一数,恰好 8 条边: 2 个半径上的弧线与线段各 2 条(4 条长螺旋)+ 放样闭合端的 4 条短连接线。

5.4 螺纹为什么天生就在瓶口?

QQ_1792750563736.png

螺纹从头到尾没有发生过任何平移/旋转。定位完全靠"继承": 所有螺纹几何都构建在瓶颈的 neckAx2 坐标系上——轴线即瓶颈轴线,V=0 平面即瓶口 顶面,(U,V) 曲线的 V 范围(约 2.8~4.2)直接落到全局坐标 Z∈[72.8, 74.2], 正好悬在瓶颈高度 [70,77] 的正中。OCCT 里"装配"最优雅的方式, 不是事后用 gp_Trsf 搬运,而是在构造源头共享同一个坐标系。


6. 第四步:Building the Compound(组装)

TopoDS_Compound aRes;
BRep_Builder aBuilder;
aBuilder.MakeCompound(aRes);
aBuilder.Add(aRes, myBody);       // 瓶身
aBuilder.Add(aRes, myThreading);  // 螺纹

Compound 是"容器形状":可装任意数量、任意类型的形状。注意这里不做布尔 Fuse—— 瓶身与螺纹是两个各自完整、又彼此干涉 0.075mm 的实体,这也是参数化实验里 "改壁厚时瓶颈和螺纹一起变"的原因(它们共享 myNeckRadius 与 neckAx2)。

教程到此完结。我们的 makeBottle() 与附录参考代码逐行同源, 仅把返回值改成了 BottleParts{body, threading, full} 以便运行时切换查看。


7. 让瓶子"活"起来:查看器的三个增强

7.1 三种显示模式(W 键循环)

模式OCCT 机制用途
线框AIS_WireFrame,只画拓扑边看结构:螺纹的 8 条曲线、放样过渡
着色AIS_Shaded,三角形填色看外观
网格自实现:BRepMesh_IncrementalMesh 离散化 → 遍历 Poly_Triangulation → 每个三角形生成 3 条边看渲染用的三角剖分

网格模式的实现核心(着色模式渲染的就是同一套三角网,两种模式对照能直观理解 "精确几何 → 显示网格"的离散化过程):

BRepMesh_IncrementalMesh aMesher(theShape, 0.3);          // 弦高 0.3mm
for (TopExp_Explorer ex(theShape, TopAbs_FACE); ex.More(); ex.Next()) {
  TopLoc_Location loc;
  Handle(Poly_Triangulation) tri = BRep_Tool::Triangulation(TopoDS::Face(ex.Current()), loc);
  ...
  aTri->Triangle(i).Get(n1, n2, n3);                      // 每个三角形 → 3 条边
}

7.2 显示外观的正确姿势:找对 Aspect

改线框颜色/宽度"没反应",是因为线框里不同元素由不同 Aspect 控制 (StdPrs_WFShape 源码里 wire 用 WireAspect,LineAspect 管的是自由边):

元素Aspect
着色面ShadingAspect
wire(线框形状本体)WireAspect
自由边LineAspect
面边界描边FaceBoundaryAspect

SetColor() 是便捷方式(传播到全部 aspect),但宽度和线型必须到具体 Aspect 上设置。 另外两个显示相关的冷知识:

  • 背面剔除:OCCT 默认 FaceCulling = Auto——闭合实体自动剔除背面; 开放形状(单面/壳)双面渲染。透过瓶口看到"内壁"不是双面渲染的透视, 而是抽壳生成的空腔内壁的真实正面(法线朝向空腔)。

  • 线宽上限:macOS 的 OpenGL core profile 会把线宽钳制到 1px, SetWidth(5) 看不出变化属于系统限制。

    7.3 参数化实验(方向键实时重建)

键参数教程里的联动
↑/↓myThickness 10~60圆角 t/12、瓶颈半径 t/4、壁厚 t/50、螺纹半径全跟着变
←/→螺纹 V 偏移 ±2.5mm整条螺纹带沿瓶颈轴向平移(瓶颈不动)
,/.螺纹圈数 0.5~4椭圆长轴 = turns×π;圈数变大时 V 范围同步变宽
空格重置教程规格50/70/30、2 圈、无偏移

makeBottle 只改了两行(椭圆长轴与 V 中心),其余保持教程原样; 每按一次键重建全部模型(抽壳+放样约几百毫秒),并保持当前对象选择与显示模式。


8. 运行与操作

cmake --preset debug
cmake --build build-debug --target bottle_tutorial
./build-debug/learning/bottle_tutorial
键功能
1 / 2 / 3显示 瓶身 / 螺纹 / 完整瓶子
W线框 → 着色 → 网格
↑↓ / ←→ / ,. / 空格参数化实验
F / P / ESC适配视口 / 切透视 / 退出

QQ_1792749872067.png


9. 踩坑实录(每一坑都有真实报错)

9.1 Homebrew g++ 编不过系统头文件

CGPDFDictionaryApplierBlock has not been declared——macOS 系统头文件使用 blocks 语法(^),只有 Apple clang 默认支持;CMake 选到 Homebrew g++ 即崩。 修复:CMakePresets.json 锁定 /usr/bin/c++(见 §2.2)。

9.2 cppdbg 调试器在 macOS 起不来

unknown option: --interpreter=mi——苹果 lldb 没有 MI 模式。 修复:改用 CodeLLDB 扩展("cmake.debugger.type": "code-lldb")。

9.3 改了线框宽度没反应

LineAspect()->SetWidth(5) 对 wire 形状无效——wire 由 WireAspect 绘制 (StdPrs_WFShape.cxx 里写得明明白白)。读 OCCT 源码比猜快得多。

9.4 myRefCount_ = 4 是什么意思

调试器里 face 变量显示引用计数 4——那是 handle 智能指针的引用计数, 不是"4 个面"。列表的真实长度看 facesToRemove.myLength(即 Extent())。

9.5 连按 W 键崩溃:异常穿过 C 回调

调试器断在 __cxa_throw,进程 terminate。机理:OCCT 抛出的 Standard_Failure 沿调用栈上抛,穿过 GLFW 的 C 栈帧(无 C++ unwind 信息)→ std::terminate。

QQ_1792750724368.png

修复:在回调可达的所有入口就地 try/catch,失败时打印消息、保留旧显示。 规则:凡是 C 回调里调用的代码,异常绝不允许外泄。

9.6 StdFail_NotDone: BRep_API: command not done

接住异常后真凶现形:三角网格里存在两端点 3D 重合的退化边 (典型来源:圆柱面接缝 U=0/2π 处的三角形,UV 节点不同但空间同点)。 BRepBuilderAPI_MakeEdge 构造失败,未检查 IsDone() 就链式调用 .Edge() → 抛异常。

BRepBuilderAPI_MakeEdge mk12(p1, p2), mk23(p2, p3), mk31(p3, p1);
if (mk12.IsDone()) aBuilder.Add(aRes, mk12.Edge());   // 退化边跳过

这正是 8.0 版教程开头加注 "In production code, IsDone() should be checked on all builder operations" 的原因——7.9.3 的示例代码为了简洁全省略了, 我们在实验里把它踩了个正着。


10. 收获与延伸

核心收获:

  1. 几何与拓扑分离:Geom_* 描述形状数据,TopoDS_* 描述连接关系, B-Rep 建模 = 先造几何、再建拓扑、最后组合;
  2. pcurve 思想:边可以只存"在曲面上怎么走",3D 曲线延迟反求—— 螺纹一章的降维打击全靠它;
  3. 坐标系即装配:共享 gp_Ax2 比事后变换优雅得多;
  4. 边界意识:OCCT 异常不能穿 C 回调、builder 必须查 IsDone、 文档版本必须与库版本对齐。

延伸方向:

  • 导出 STEP(STEPControl_Writer),与根目录的 STEP 查看器交叉验证;
  • 给全部 builder 补上 IsDone 检查(生产级写法练习);
  • 读 BRepOffsetAPI_MakeThickSolid / BRepOffsetAPI_ThruSections 源码, 理解抽壳与放样的内部算法。

完整代码:github.com/hyswqqsl/oc…