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_ReaderIGESControl_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。

后续导入管线根据 hasBRephasMeshsourceKind 决定下一步怎么处理。

BRep 模型可以进入轻量检查和受控修复。

mesh-only 模型则应该走快速通道,避免执行不适合的 BRep 修复。

这样既保留了统一入口,也没有抹掉格式差异。

补充:导出和导入的取舍刚好相反

导出和导入相比,工程复杂度通常低一些。

导入面对的是外部不可控数据,所以要处理脏模型、格式语义差异、poly-only 判断、轻量修复、可取消和软进度。导出面对的则是系统内部已经管理过的 shape、mesh、属性和层级。真正需要判断的不是“怎么把模型修好”,而是“这次要向外表达什么语义”。

如果只是导出精确 BRep 几何,常见路径是 STEPControl_WriterIGESControl_WriterBRepTools::Write。这类导出更适合几何交换、调试和工程复现。

如果希望保留装配结构、名称、颜色和层级,就不应该只导出一个聚合后的 shape,而应该考虑 XDE 路径,例如 STEPCAFControl_Writer 或 glTF 的 CafWriter。这类导出关注的不只是几何本身,还包括模型的组织关系和可视化语义。

如果导出 STL、OBJ 或 glTF,本质上是在导出 mesh scene。这里要明确一点:mesh 导出通常会丢掉一部分 CAD 拓扑语义。STL 更适合制造、网格交换或简单几何输出;OBJ 和 glTF 更适合显示、材质和轻量场景交换。它们不应该被当成 STEP / BREP 的等价替代。

所以导出的核心问题可以概括成一句话:

导入要判断外部文件真实是什么;
导出要判断我们希望向外表达什么。

这和前面讨论的导入策略是一致的:不要把 BRep、XDE 和 Mesh Scene 强行抹平成同一种数据。导入时要尊重外部格式的语义,导出时也要根据目标格式选择合适的表达方式。

小结

这次整理模型导入接口以后,我最大的感受是:导入格式的差异不能只看文件后缀,而要看它背后的数据语义。

STEPControl_ReaderIGESControl_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 就先适配成统一导入结果。

这不是很复杂的架构,但能避免很多后续问题。