Technical note
CAD/CAE 模型导入实践:OCCT Reader、XDE 与 FBX SDK
记录一次模型导入模块整理过程:STEP、IGES、XDE、glTF 和 FBX 这些不同格式在导入接口、数据语义和后续处理上的差异。
背景:导入格式不只是扩展名不同
上一篇文章里,我主要记录了模型导入管线的工程边界:导入阶段不应该默认做重修复,poly-only 模型应该走快速通道,长耗时阶段要有可取消检查和软进度反馈。
这篇继续写导入模块里更具体的一部分:不同格式应该怎么接入。
CAD/CAE 软件里的导入格式很多。STEP、IGES、BREP 更接近传统 CAD BRep;glTF 更偏 mesh 和 scene;FBX 通常来自 DCC、仿真可视化或资产交换场景,本身并不是 OCCT 原生 CAD BRep 路径。
它们表面上都叫“模型文件”,但数据语义差异很大:
STEP / IGES:
更接近 CAD BRep,重点是曲面、边界和拓扑关系。
XDE / XCAF:
不只是 shape,还可能包含装配、颜色、名称和层级。
glTF:
更偏 mesh、scene、material 和 transform。
FBX:
通常是外部 SDK 解析出的 scene graph + mesh + transform。
所以导入模块不能只写成一堆扩展名判断:
if (ext == ".step") {
// STEP
} else if (ext == ".igs") {
// IGES
} else if (ext == ".fbx") {
// FBX
}
这种写法一开始很快,但后续会越来越难维护。真正需要区分的不是扩展名,而是每种格式背后的数据模型。
我后来更倾向于把导入结果分成几类:
BRep shape:
适合 STEP / IGES / BREP 这类 CAD 几何。
XDE document:
适合保留装配结构、名称、颜色和层级。
Mesh scene:
适合 glTF / FBX / OBJ / STL 这类 mesh 或 scene 数据。
这篇文章主要记录几个代表性格式的导入方式,以及我在整理过程中形成的一些工程判断。
STEP:最常见的 BRep 导入路径
STEP 是 CAD/CAE 场景里最常见的交换格式之一。
在 OCCT 里,最直接的 STEP 导入方式是使用 STEPControl_Reader。它的流程大致可以理解为:
STEP 文件
↓
ReadFile
↓
TransferRoots
↓
OneShape
↓
TopoDS_Shape
示意代码如下:
ImportResult importStep(const ImportRequest& request)
{
STEPControl_Reader reader;
const IFSelect_ReturnStatus status =
reader.ReadFile(request.filePath.string().c_str());
if (status != IFSelect_RetDone) {
return ImportResult::failed("failed to read STEP file");
}
if (request.cancelToken && request.cancelToken->isCancelled()) {
return ImportResult::cancelled();
}
const Standard_Integer transferred = reader.TransferRoots();
if (transferred <= 0) {
return ImportResult::failed("failed to transfer STEP roots");
}
TopoDS_Shape shape = reader.OneShape();
if (shape.IsNull()) {
return ImportResult::failed("empty STEP shape");
}
ImportResult result;
result.brepShape = shape;
result.hasBRep = true;
return result;
}
这条路径适合“只需要拿到一个聚合 shape”的场景。比如后续主要做几何检查、轻量修复、显示构建和网格前处理,那么 OneShape() 是很直接的入口。
但这里有一个容易误解的地方:ReadFile() 成功,不代表整个导入已经完成。
真正耗时的往往是:
TransferRoots
OneShape
后续 SameParameter / Analyzer / Sewing
拓扑展开
显示构建
所以即使是最普通的 STEP 导入,也不应该直接在 UI 层调用 reader。更稳妥的方式是把它放进导入任务管线里,至少在几个关键边界上检查取消,并给出阶段性进度。
另外,STEP 导入后是否要默认修复,也要保持克制。导入阶段可以做轻量检查,但不应该默认执行高风险的强修复。否则用户只是想打开文件,系统却在背后修改了大量拓扑,后面选择、属性继承、边界条件映射都可能受到影响。
IGES:接口类似,但脏数据更多
IGES 的普通导入路径和 STEP 很接近,通常使用 IGESControl_Reader:
IGES 文件
↓
ReadFile
↓
TransferRoots
↓
OneShape
↓
TopoDS_Shape
示意代码也很类似:
ImportResult importIges(const ImportRequest& request)
{
IGESControl_Reader reader;
const IFSelect_ReturnStatus status =
reader.ReadFile(request.filePath.string().c_str());
if (status != IFSelect_RetDone) {
return ImportResult::failed("failed to read IGES file");
}
if (request.cancelToken && request.cancelToken->isCancelled()) {
return ImportResult::cancelled();
}
const Standard_Integer transferred = reader.TransferRoots();
if (transferred <= 0) {
return ImportResult::failed("failed to transfer IGES roots");
}
TopoDS_Shape shape = reader.OneShape();
if (shape.IsNull()) {
return ImportResult::failed("empty IGES shape");
}
ImportResult result;
result.brepShape = shape;
result.hasBRep = true;
return result;
}
但从实际工程体验看,IGES 往往比 STEP 更容易遇到几何质量问题,例如:
自由边较多;
面方向不一致;
曲面参数不稳定;
边界关系不完整;
导入后 shape 有效性较差。
所以 IGES 导入后保留轻量修复是有意义的。
但这里仍然要注意边界:导入阶段应该尽量保证“稳定读入”,而不是试图把所有坏 IGES 都修成高质量 CAD 模型。
我更倾向于这样的策略:
1. 先读取并 transfer;
2. shape 不为空就进入后续流程;
3. 对非 poly-only 的 BRep 尝试轻量 SameParameter;
4. 做一次快速有效性检查;
5. 必要时尝试轻度 Sewing;
6. Sewing 失败不直接导致导入失败,而是记录 warning 并保留原 shape。
原因很简单:IGES 脏数据很多,强修复不一定成功,也不一定安全。默认导入阶段越激进,用户越难理解“为什么打开文件以后拓扑变了”。
XDE:当模型不只是一个 Shape
如果只是读取一个几何实体,普通 STEPControl_Reader 或 IGESControl_Reader 已经够用。
但很多时候,模型不是一个简单 shape。
比如一个 STEP 装配文件里,可能包含:
装配层级;
零件名称;
颜色;
材质;
实例关系;
子 shape 属性;
外部引用。
如果只调用 OneShape() 拿一个聚合后的 TopoDS_Shape,这些信息就可能丢失。
这时更适合走 XDE / XCAF 路径。OCCT 里常见的是 STEPCAFControl_Reader 配合 TDocStd_Document 和 XCAF 工具类。
示意流程是:
STEP 文件
↓
STEPCAFControl_Reader
↓
TDocStd_Document
↓
XCAFDoc_ShapeTool / ColorTool / Name
↓
提取装配、颜色、名称和 shape
示意代码:
ImportResult importStepXde(const ImportRequest& request)
{
Handle(TDocStd_Document) document = createXdeDocument();
STEPCAFControl_Reader reader;
reader.SetColorMode(true);
reader.SetNameMode(true);
reader.SetLayerMode(true);
const IFSelect_ReturnStatus status =
reader.ReadFile(request.filePath.string().c_str());
if (status != IFSelect_RetDone) {
return ImportResult::failed("failed to read STEP file");
}
if (request.cancelToken && request.cancelToken->isCancelled()) {
return ImportResult::cancelled();
}
if (!reader.Transfer(document)) {
return ImportResult::failed("failed to transfer STEP into XDE document");
}
ImportResult result;
extractXdeDocument(document, result);
return result;
}
这里的重点不是 extractXdeDocument() 具体怎么写,而是要意识到:XDE 路径拿到的不是单纯的 shape,而是一个带文档语义的结构。
大致需要处理:
free shape;
assembly;
component;
reference;
location;
name;
color;
layer;
material。
示意提取逻辑可以写成这样:
void extractXdeDocument(const Handle(TDocStd_Document)& document,
ImportResult& result)
{
Handle(XCAFDoc_ShapeTool) shapeTool = getShapeTool(document);
Handle(XCAFDoc_ColorTool) colorTool = getColorTool(document);
TDF_LabelSequence roots;
shapeTool->GetFreeShapes(roots);
for (Standard_Integer i = 1; i <= roots.Length(); ++i) {
const TDF_Label& root = roots.Value(i);
ImportNode node = extractXdeNode(root, shapeTool, colorTool);
result.nodes.push_back(std::move(node));
}
result.brepShape = buildCombinedShapeIfNeeded(shapeTool, roots);
result.hasBRep = !result.brepShape.IsNull();
}
这里有几个容易踩坑的点。
第一,XDE 里的 location 不能忽略。装配实例的真实位置往往来自 label 上的 location,而不是 shape 自身坐标。
第二,reference 和 component 要分清。一个零件可能被多个实例引用,如果简单把所有 shape 展开成独立 shape,可能会丢掉实例关系。
第三,颜色可能挂在不同层级。可能是零件颜色、面颜色,也可能需要向上继承。
第四,XDE 文档不应该直接泄漏给上层业务。更好的方式是先提取成自己的导入结果结构,再交给后续管线。
所以 XDE 这部分的核心判断是:
当模型包含装配、颜色、名称和层级时,
不要只拿 OneShape;
应该走文档路径,再把文档语义转换成导入结果。
glTF:OCCT 里也有偏 mesh 的入口
glTF 很适合说明另一个问题:即使入口来自 OCCT,也不代表它一定是传统 BRep 导入。
OCCT 里可以通过 RWGltf_CafReader 把 glTF 读入 XDE 文档。但 glTF 本身更偏 scene、mesh、material 和 transform,而不是 STEP / IGES 那种 CAD BRep。
所以 glTF 的语义更接近:
scene graph;
mesh;
node transform;
material;
texture;
camera;
animation。
而不是:
edge;
wire;
face;
shell;
solid。
示意流程可以理解为:
glTF 文件
↓
RWGltf_CafReader
↓
XDE document
↓
mesh / scene / material
↓
导入结果
示意代码:
ImportResult importGltf(const ImportRequest& request)
{
Handle(TDocStd_Document) document = createXdeDocument();
RWGltf_CafReader reader;
reader.SetFileName(request.filePath.string().c_str());
if (!reader.Perform(document, request.progressRange)) {
return ImportResult::failed("failed to import glTF file");
}
ImportResult result;
extractXdeDocument(document, result);
return result;
}
不同 OCCT 版本里,RWGltf_CafReader 的具体调用参数可能会有差异。正式实现时应以项目当前使用的 OCCT 版本为准。
这里真正要强调的是:glTF 不应该被硬塞进 STEP / IGES 的处理逻辑。
如果后续系统只按 BRep shape 处理所有模型,就会遇到问题:
glTF 的 mesh 语义被误认为 CAD face;
材质和层级容易丢;
transform 处理不完整;
后续修复流程可能执行在不适合的数据上。
因此,glTF 虽然可以通过 OCCT 读入,但仍然应该按 mesh scene 的语义处理。
这和上一篇文章里提到的 poly-only 快速通道是一致的:导入阶段要先判断数据类型,再决定后续路径,而不是所有格式都进入同一套 BRep 修复流程。
FBX:OCCT 之外的外部 SDK 集成
FBX 是另一个很典型的例子。
它通常不是 CAD BRep 输入,而是一个 scene graph。里面可能包含:
node 层级;
local transform;
mesh;
normal;
UV;
material;
texture;
animation;
camera;
light。
对 CAD/CAE 前处理来说,我们不一定需要完整消费所有 FBX 语义。很多时候更关心的是:把其中的 mesh、层级、变换和基础材质稳定导入系统。
FBX 不属于 OCCT 原生 reader 主路径,所以这里需要引入 Autodesk FBX SDK。
整体流程大致是:
FBX 文件
↓
FbxManager
↓
FbxIOSettings
↓
FbxImporter
↓
FbxScene
↓
遍历 FbxNode
↓
提取 FbxMesh / transform / material
↓
转换成导入结果
示意代码如下:
ImportResult importFbx(const ImportRequest& request)
{
FbxManager* manager = FbxManager::Create();
if (!manager) {
return ImportResult::failed("failed to create FBX manager");
}
FbxIOSettings* ioSettings = FbxIOSettings::Create(manager, IOSROOT);
manager->SetIOSettings(ioSettings);
FbxImporter* importer = FbxImporter::Create(manager, "");
if (!importer) {
manager->Destroy();
return ImportResult::failed("failed to create FBX importer");
}
const bool initialized = importer->Initialize(
request.filePath.string().c_str(),
-1,
manager->GetIOSettings());
if (!initialized) {
importer->Destroy();
manager->Destroy();
return ImportResult::failed("failed to initialize FBX importer");
}
FbxScene* scene = FbxScene::Create(manager, "importScene");
if (!scene) {
importer->Destroy();
manager->Destroy();
return ImportResult::failed("failed to create FBX scene");
}
if (!importer->Import(scene)) {
scene->Destroy();
importer->Destroy();
manager->Destroy();
return ImportResult::failed("failed to import FBX scene");
}
ImportResult result;
traverseFbxNode(scene->GetRootNode(), Matrix4d::Identity(), result);
result.hasMesh = !result.meshes.empty();
scene->Destroy();
importer->Destroy();
manager->Destroy();
return result;
}
上面这段是示意代码,不是推荐直接照搬的最终写法。真实工程里,FBX SDK 对象最好用 RAII 包一层,避免每个错误分支都手写 Destroy()。
可以写一个简单的释放器:
template <typename T>
struct FbxDestroyDeleter {
void operator()(T* ptr) const
{
if (ptr) {
ptr->Destroy();
}
}
};
template <typename T>
using FbxUniquePtr = std::unique_ptr<T, FbxDestroyDeleter<T>>;
然后管理对象:
FbxUniquePtr<FbxManager> manager(FbxManager::Create());
FbxUniquePtr<FbxImporter> importer(FbxImporter::Create(manager.get(), ""));
FbxUniquePtr<FbxScene> scene(FbxScene::Create(manager.get(), "importScene"));
这里要注意:FBX SDK 的对象所有权和普通 C++ 对象不完全一样。正式封装前需要确认每类对象是否都适合统一 Destroy(),不要机械套模板。
FBX 的核心:遍历 scene graph
FBX 接入真正容易出问题的地方,不是 Import() 这一句,而是怎么把 scene graph 转成系统可理解的导入结果。
最基本的遍历逻辑是:
void traverseFbxNode(FbxNode* node,
const Matrix4d& parentTransform,
ImportResult& result)
{
if (!node) {
return;
}
Matrix4d localTransform = convertFbxTransform(node->EvaluateLocalTransform());
Matrix4d worldTransform = parentTransform * localTransform;
ImportNode importNode;
importNode.name = node->GetName();
importNode.transform = worldTransform;
FbxNodeAttribute* attribute = node->GetNodeAttribute();
if (attribute && attribute->GetAttributeType() == FbxNodeAttribute::eMesh) {
FbxMesh* mesh = node->GetMesh();
if (mesh) {
MeshData meshData = extractFbxMesh(mesh, worldTransform);
const int meshIndex = static_cast<int>(result.meshes.size());
result.meshes.push_back(std::move(meshData));
importNode.meshIndices.push_back(meshIndex);
}
}
const int childCount = node->GetChildCount();
for (int i = 0; i < childCount; ++i) {
traverseFbxNode(node->GetChild(i), worldTransform, result);
}
result.nodes.push_back(std::move(importNode));
}
这里最容易漏的是 transform。
FBX 的 mesh 顶点通常在 node local space 下,node 自己还有 local transform。如果导入时只读取控制点坐标,不处理 transform,模型位置、旋转、缩放就可能不对。
所以遍历时要明确维护:
parent transform;
local transform;
world transform。
然后决定顶点是直接变换到世界坐标,还是保留局部坐标并在节点上记录 transform。
这两种方式各有取舍。
如果直接把顶点变换到世界坐标,后续显示和处理会简单一些,但会丢掉一部分层级变换语义。
如果保留 local mesh + node transform,结构更接近 FBX 原始 scene,但后续显示和选择系统必须正确处理 transform。
我更倾向于在导入结果里保留 node transform,同时根据后续系统能力决定是否 bake 到顶点上。这样至少不会在导入阶段过早丢语义。
FBX Mesh:polygon、normal 和 material
FBX mesh 也有一些细节需要注意。
第一,FBX polygon 不一定是三角形。
如果后续显示和选择都基于三角面片,那么导入阶段需要把 polygon 统一三角化。
示意代码:
MeshData extractFbxMesh(FbxMesh* mesh, const Matrix4d& worldTransform)
{
MeshData data;
FbxVector4* controlPoints = mesh->GetControlPoints();
const int polygonCount = mesh->GetPolygonCount();
for (int polygonIndex = 0; polygonIndex < polygonCount; ++polygonIndex) {
const int polygonSize = mesh->GetPolygonSize(polygonIndex);
std::vector<int> polygonIndices;
polygonIndices.reserve(polygonSize);
for (int vertexIndex = 0; vertexIndex < polygonSize; ++vertexIndex) {
const int controlPointIndex =
mesh->GetPolygonVertex(polygonIndex, vertexIndex);
FbxVector4 point = controlPoints[controlPointIndex];
Vec3d position = transformPoint(worldTransform, convertPoint(point));
const int newIndex = static_cast<int>(data.positions.size());
data.positions.push_back(position);
polygonIndices.push_back(newIndex);
}
triangulatePolygon(polygonIndices, data.indices);
}
return data;
}
第二,normal 的映射方式不固定。
FBX normal 可能按 control point 存,也可能按 polygon vertex 存。也就是说,不能简单假设“一个顶点只有一个法向”。在硬边、平滑组、材质边界存在时,同一个 control point 在不同 polygon vertex 上可能需要不同 normal。
第三,UV 和 material 也有类似问题。
FBX 里很多数据都有 mapping mode 和 reference mode。导入时要区分:
ByControlPoint;
ByPolygonVertex;
ByPolygon;
Direct;
IndexToDirect。
如果第一版只是为了保证几何导入稳定,可以先分阶段做:
第一阶段:
node hierarchy
transform
mesh positions
polygon indices
第二阶段:
normals
basic materials
colors
第三阶段:
UV
texture
animation
camera / light
这样能先让 FBX 几何稳定进入系统,再逐步补材质和更复杂的 scene 语义。
不要一开始就试图把 FBX 的所有能力都吃完整。那样很容易导致基础导入还没稳定,材质、动画、贴图路径和单位转换问题已经把流程复杂度拉满。
为什么不把 FBX 强行转成 BRep
这里还有一个比较重要的判断:FBX 导入不应该默认强行转成 BRep。
理论上,可以把 mesh 三角面片构造成 TopoDS_Face,再组合成 shell 或 compound。这样看起来就能塞进 BRep 管线。
但这不一定是好事。
原因有几个:
1. FBX mesh 本身没有 CAD 曲面语义;
2. 每个三角面都构造成 BRep face,可能带来很大内存和拓扑管理开销;
3. 后续 SameParameter、Analyzer、Sewing 对这类数据不一定有收益;
4. 用户真正需要的可能只是显示、选择和基本管理;
5. 强行 BRep 化可能让导入变慢,也让后续逻辑误判它是 CAD 曲面模型。
所以我更倾向于把 FBX 识别成 mesh scene:
FBX
↓
scene graph + mesh
↓
ImportResult(hasMesh = true)
↓
mesh 快速通道
如果后续确实需要把某些 mesh 转成 BRep,也应该作为显式转换命令,而不是导入默认行为。
这个原则和上一篇文章里提到的 poly-only 快速通道是一致的:导入阶段不应该做过度转换。
不同格式进入统一结果
虽然上面每种格式的入口不同,但最终还是需要进入统一导入结果。
这个统一结果不一定很复杂,关键是要能表达差异:
enum class ImportSourceKind {
BRepShape,
XdeDocument,
MeshScene
};
struct ImportResult {
ImportSourceKind sourceKind;
TopoDS_Shape brepShape;
std::vector<MeshData> meshes;
std::vector<ImportNode> nodes;
bool hasBRep = false;
bool hasMesh = false;
static ImportResult failed(const std::string& message);
static ImportResult cancelled();
};
也就是说,统一不是把所有格式都抹平成 TopoDS_Shape。
更合理的方式是:
STEP / IGES:
主要输出 BRep shape;
XDE STEP:
输出 BRep shape + 装配层级 + 名称颜色等语义;
glTF:
主要输出 mesh scene / XDE scene;
FBX:
通过外部 SDK 输出 mesh scene。
后续导入管线根据 hasBRep、hasMesh 和 sourceKind 决定下一步怎么处理。
BRep 模型可以进入轻量检查和受控修复。
mesh-only 模型则应该走快速通道,避免执行不适合的 BRep 修复。
这样既保留了统一入口,也没有抹掉格式差异。
补充:导出和导入的取舍刚好相反
导出和导入相比,工程复杂度通常低一些。
导入面对的是外部不可控数据,所以要处理脏模型、格式语义差异、poly-only 判断、轻量修复、可取消和软进度。导出面对的则是系统内部已经管理过的 shape、mesh、属性和层级。真正需要判断的不是“怎么把模型修好”,而是“这次要向外表达什么语义”。
如果只是导出精确 BRep 几何,常见路径是 STEPControl_Writer、IGESControl_Writer 或 BRepTools::Write。这类导出更适合几何交换、调试和工程复现。
如果希望保留装配结构、名称、颜色和层级,就不应该只导出一个聚合后的 shape,而应该考虑 XDE 路径,例如 STEPCAFControl_Writer 或 glTF 的 CafWriter。这类导出关注的不只是几何本身,还包括模型的组织关系和可视化语义。
如果导出 STL、OBJ 或 glTF,本质上是在导出 mesh scene。这里要明确一点:mesh 导出通常会丢掉一部分 CAD 拓扑语义。STL 更适合制造、网格交换或简单几何输出;OBJ 和 glTF 更适合显示、材质和轻量场景交换。它们不应该被当成 STEP / BREP 的等价替代。
所以导出的核心问题可以概括成一句话:
导入要判断外部文件真实是什么;
导出要判断我们希望向外表达什么。
这和前面讨论的导入策略是一致的:不要把 BRep、XDE 和 Mesh Scene 强行抹平成同一种数据。导入时要尊重外部格式的语义,导出时也要根据目标格式选择合适的表达方式。
小结
这次整理模型导入接口以后,我最大的感受是:导入格式的差异不能只看文件后缀,而要看它背后的数据语义。
STEPControl_Reader 和 IGESControl_Reader 更适合传统 BRep 导入。
STEPCAFControl_Reader 和 XDE 路径适合保留装配、颜色、名称和层级。
RWGltf_CafReader 虽然属于 OCCT 体系,但它处理的是更偏 mesh scene 的 glTF 数据。
FBX 则需要通过外部 SDK 解析 scene graph,再把 node、mesh、transform、material 等信息转换成系统可接受的导入结果。
所以统一导入管线不是把所有模型都强行变成一个 TopoDS_Shape,而是在保留格式差异的前提下,给后续流程一个稳定边界。
对 CAD/CAE 软件来说,这个边界很重要。
因为一旦导入阶段把 mesh 当成 BRep、把 scene 当成单个 shape、把装配层级直接拍平,后面显示、选择、属性继承、网格和模型修复都会变得别扭。
更稳妥的做法是:
BRep 就按 BRep 处理;
XDE 就保留文档语义;
mesh scene 就走 mesh 快速通道;
外部 SDK 就先适配成统一导入结果。
这不是很复杂的架构,但能避免很多后续问题。