Technical note

一个 CAD/CAE 几何修复模块,为什么不能只封装几个 OCCT API

一次 CAD/CAE 几何修复模块分层设计的工程复盘:为什么 ShapeFix、Sewing、Filling 这些接口不能直接散落在业务代码里,而需要检测、分类、策略选择、局部编辑、验证和报告。


背景:有了 OCCT 修复接口,为什么还要再做一层

上一篇文章梳理了 OCCT 里常用的一些模型修复接口,包括 ShapeFix_ShapeShapeFix_FaceShapeFix_WireBRepBuilderAPI_SewingBRepFill_FillingBRepCheck_Analyzer

这些接口都很有价值,但真正放到 CAD/CAE 工程里以后,会很快遇到一个问题:

修复模块不能只是几个 OCCT API 的薄封装。

一开始看起来,几何修复好像就是把这些接口串起来:

checkShape(shape);
fixShape(shape);
sewShape(shape);
fillHole(shape);
checkShape(shape);

但实际做下来会发现,这样的代码很难长期维护。

因为几何修复最麻烦的地方,往往不是某个 API 怎么调用,而是这些问题:

这个问题到底属于哪一类?
是否应该自动修复?
应该优先使用哪种修复方式?
修复范围应该是全局还是局部?
修复失败后是否回滚?
修复成功后怎么证明它真的变好了?
用户怎么知道系统到底改了什么?

如果这些判断都散落在业务代码里,几何修复模块很快就会变成一堆工具函数。短期能跑,长期很难排查。

这篇文章记录的是我对几何修复模块分层设计的一次整理:为什么只封装 OCCT API 不够,以及一个更可维护的修复流程应该怎么组织。

一开始容易走偏:把修复做成工具函数集合

在 CAD 项目里,最自然的做法是先写一些工具函数。

比如:

TopoDS_Shape fixShape(const TopoDS_Shape& shape);
TopoDS_Shape sewFaces(const std::vector<TopoDS_Face>& faces);
TopoDS_Shape fillHole(const std::vector<TopoDS_Edge>& edges);
bool isShapeValid(const TopoDS_Shape& shape);

这些函数本身没有问题。问题在于,当它们被不同业务流程随手调用时,修复行为会变得不可控。

例如导入流程里可能调用一次 fixShape,交互式修复里又调用一次 sewFaces,网格前处理前再调用一次 fillHole。每个地方都觉得自己只是“做一点小修复”,但最后模型到底被改了几次、每次改了哪里、是否改变了原始拓扑语义,就很难说清楚。

这类工具函数集合通常会带来几个问题。

第一,检测和修复混在一起。有些函数看起来只是检查问题,但内部顺手做了容差调整或 wire 修复。后续排查时,很难判断 shape 是在哪一步发生变化的。

第二,策略选择没有统一入口。同样是自由边,有的地方尝试 sewing,有的地方尝试 filling,有的地方直接忽略。不同流程对同一类问题的处理不一致。

第三,修复结果没有报告。函数返回了一个新 shape,但调用方不知道修复前有多少问题、修复后少了多少、有没有引入新的风险。

第四,失败回滚很困难。如果局部修复失败,或者修复后 shape 有效性变差,需要恢复原始模型。工具函数如果没有统一上下文,回滚只能靠调用方自己处理。

这些问题在小模型、小功能里不明显。一旦修复流程开始接入导入、交互、网格、布尔或批处理,就会越来越难维护。

几何修复的主线:Detect → Classify → Repair

后来我更倾向于把几何修复理解成一个流程,而不是一组 API。

比较稳妥的主线是:

Detect   →  Classify  →  Decide  →  Repair  →  Verify  →  Report
检测问题 →  问题分类  →  策略选择 →  执行修复 →  验证结果 →  输出报告

这个流程看起来比直接调用 API 麻烦,但它解决的是工程上的可控性问题。

Detect 负责发现问题。例如 shape 是否有效,是否存在 free edge,是否存在 open boundary,是否存在局部 wire gap,是否存在面之间拓扑未连接。

Classify 负责判断问题类型。同样是 free edge,可能是 open shell,也可能是面之间没有 sewing,也可能是真的缺失了一块面,还可能只是开放曲面本身的边界。

Decide 负责选择策略。能 sewing 的不一定要补面,能局部修复的不一定要全局修复,不应该修的也不能为了让模型“看起来更完整”而强行修。

Repair 才是真正调用 OCCT 或局部拓扑编辑的地方。这里可能用到 ShapeFixSewingFilling,也可能用到局部替换、扩展面、重建边界等操作。

Verify 负责验收。修复后不能只看 API 有没有返回成功,还要看问题数量是否下降,shape 是否仍然有效,局部拓扑是否退化,后续流程是否能继续。

Report 负责解释。对于交互式修复尤其重要。用户需要知道修了哪里、采用了什么策略、还有哪些问题没有解决。

这个流程的意义不在于形式,而在于让修复行为变得可解释、可验证、可回滚。

统一入口的价值:约束流程,而不是制造大类

很多人听到“统一入口”会觉得只是多封装一层函数。

但在几何修复里,统一入口的主要价值不是封装,而是约束流程。

一个修复入口至少应该承担这些职责:

接收原始 shape 和修复上下文;
触发问题检测;
根据问题类型选择修复策略;
调用具体修复工具;
收集修复前后状态;
返回修复后的 shape 和报告。

它不应该亲自做所有几何操作。

如果统一入口里面直接写满 ShapeFixSewingFilling、拓扑替换和各种容差判断,最后它也会变成一个巨大的工具类。

更合适的方式是让统一入口只负责调度:

统一入口
  ├─ 问题检测
  ├─ 策略决策
  ├─ OCCT 修复适配
  ├─ 局部拓扑编辑
  ├─ 专项修复流程
  └─ 修复报告

这样后续要新增一种修复策略时,不需要在导入、显示、网格或交互命令里到处加判断,而是在修复模块内部扩展策略。

比如后面要处理面缝隙、局部补面、面贴合或网格共形风险,都可以放到同一个修复流程里,而不是让每条业务线各自维护一套修复逻辑。

检测层:不要一边发现问题一边修改模型

检测层最重要的原则是:

只检测,不修改。

这一点看起来简单,但实际写代码时很容易破坏。

比如在检查 wire 是否闭合时,顺手把它修了一下;在判断自由边时,顺手做了一次 sewing;在分析 face gap 时,顺手调大了容差。

短期看这样很方便,长期看会导致一个问题:你不知道模型是什么时候被改掉的。

所以检测层更适合输出结构化的问题描述,而不是输出修复后的 shape。

例如可以抽象成:

Issue
  ├─ type: FreeEdge / FaceGap / OpenBoundary / InvalidWire / NonManifold
  ├─ relatedFaces
  ├─ relatedEdges
  ├─ severity
  ├─ location
  └─ message

这里不需要暴露内部真实结构。核心思想是:检测结果应该能被后续策略层理解,而不是只打印一段日志。

一个好的检测结果至少应该回答:

问题发生在哪里;
关联哪些 face 或 edge;
问题大概属于哪一类;
是否适合自动修;
是否需要用户选择局部区域。

这样后续策略层才能决定是 sewing、wire fix、local patch,还是提示用户手动确认。

策略层:同一个问题不一定只有一种修法

几何修复里最容易踩坑的地方,是把问题类型和修复方式一一绑定。

例如看到 free edge 就 sewing,看到 hole 就 filling,看到 invalid shape 就 ShapeFix_Shape

这种做法在简单模型上可能能工作,但复杂一点就会出问题。

同样是自由边,可能有几种情况:

相邻面几何上接近,只是拓扑没有连接;
模型确实缺失了一块面;
模型本来就是开放曲面;
边界很短,是导入噪声;
边界对应真实结构,不应该被合并;
局部开口会影响后续体网格。

这些情况对应的修复策略完全不同。

所以策略层应该解决的问题是:

这个问题是否应该修;
应该自动修还是交互修;
优先尝试哪种低风险策略;
失败后是否尝试下一种策略;
什么时候停止;
什么时候回滚;
什么时候只输出报告。

一个更工程化的修复策略通常不是单步操作,而是有优先级的尝试链。

例如面之间拓扑未连接时,可以先尝试局部 sewing;如果 sewing 后自由边没有下降,就不能假装成功。只有在确认确实缺面时,才考虑补面。补面后还要重新 sewing,并再次检查自由边和 shape 有效性。

策略层的价值就在这里:它不是简单决定“调用哪个 API”,而是控制整个修复路径的风险。

OCCT 适配层:把底层 API 调用收敛起来

OCCT 修复接口很多,参数也不少。

如果业务代码里到处直接调用这些 API,很容易出现行为不一致。

比如同样是 BRepBuilderAPI_Sewing,不同地方可能使用不同容差;有的地方会检查输出 shape,有的地方不检查;有的地方会处理空 shape,有的地方直接继续往下走。

所以我更倾向于给 OCCT 修复接口再包一层适配。

这层不是为了隐藏 OCCT,而是为了统一几件事:

统一容差输入;
统一异常和失败处理;
统一空 shape 检查;
统一修复前后验证;
统一日志和报告信息;
避免业务层直接散落调用 OCCT 修复 API。

例如 Sewing 这种操作,业务上真正关心的不是 Perform() 调没调用,而是:

输入了哪些面;
输出是否为空;
输出类型是否符合预期;
free edge 数量是否减少;
shape 是否仍然有效;
是否需要回滚。

把这些逻辑收敛之后,上层策略就不用关心底层 API 的每个细节,只需要判断这次修复是否达到预期。

这对于长期维护很重要。因为 OCCT 版本升级、参数调整、容差策略修改,都可以限制在适配层内部,不需要影响所有业务流程。

局部拓扑编辑层:修复不一定是全局替换

很多几何修复问题不适合全局处理。

比如用户只选中了几张面,希望修复局部开口;或者某个区域存在面缝隙,只需要替换局部 patch。

这时如果直接对整个 shape 做全局修复,风险会很高。

局部拓扑编辑层要解决的是:

怎么把局部修复结果安全地放回原模型。

它通常会涉及这些能力:

提取局部 face 或 edge;
构造局部 patch;
替换局部面;
重建边界关系;
重新组装 shape;
保持未修改区域尽量不变;
修复失败时恢复原始局部拓扑。

这层比直接调用 OCCT API 更接近工程实现。

例如补面操作本身可以用 BRepFill_Filling,但补出来的面怎么和原来的 shape 重新组合,怎么替换局部区域,怎么避免影响其他 face,这些不是 BRepFill_Filling 自动解决的。

同样,Sewing 可以把一组面缝起来,但如果修复目标是局部 shell,那么 sewing 的输入范围就很关键。

输入范围太小,缝不上;输入范围太大,又可能误改周围拓扑。

所以局部拓扑编辑层的价值,是把“一个 API 调用”变成“一个可控的局部修复操作”。

专项修复工具:Sewing、Gap Repair 和补面应该分开

随着修复能力增加,很容易把所有逻辑都塞进一个大函数里。

例如一个函数里同时做 free edge 检测、sewing、wire fix、filling、局部替换、再次 sewing 和结果检查。

这种函数一开始很快,但后面基本不可维护。

更合理的做法是把专项能力拆开:

面缝合:处理已有面之间的拓扑连接;
面缝隙修复:处理 free edge、gap、open boundary;
局部补面:从边界构造新面;
局部拓扑替换:把修复结果放回 shape;
网格边界检测:从后续网格风险反推几何问题;
共形风险修复:处理近接触、弱相交和模糊界面。

这些能力看起来都属于“几何修复”,但它们的输入、输出和风险完全不同。

Sewing 更偏已有拓扑连接。

Filling 更偏新增几何。

局部替换更偏 shape 结构维护。

网格边界检测更偏问题定位。

共形风险修复更偏后续网格稳定性。

如果不拆开,最后所有问题都会变成一个函数里的 if-else。而几何修复最怕的就是这种不断追加的 if-else,因为每一个新判断都可能影响旧模型的修复路径。

验证层:不能只看 API 是否成功

OCCT 很多 API 调用成功,并不代表工程修复成功。

例如 Sewing 执行成功,但自由边数量可能没有减少。Filling 生成了面,但新面可能没有和周围面正确连接。ShapeFix 处理后 shape 有效性可能提升了,也可能引入新的拓扑变化。

所以修复后至少要做几类检查:

输出 shape 是否为空;
shape 基础有效性是否通过;
修复前后的问题数量是否变化;
free edge 是否减少;
局部区域是否仍然存在 open boundary;
shape 类型是否发生非预期变化;
是否影响后续网格或布尔流程。

这里最关键的是“前后对比”。

单独看修复后的结果,很难判断它是否真的变好。

例如修复后还有 free edge,这不一定失败;如果原来有一百条,现在只剩几条,可能已经是有效修复。

反过来,如果修复后 shape valid,但拓扑被过度合并,也未必是好结果。

所以验证层不能只是:

return analyzer.IsValid();

它应该结合修复目标来看结果是否达到预期。

修复报告:用户需要知道系统改了什么

几何修复不是普通的数据转换。

它可能改变模型拓扑,甚至改变用户后续能选中的面、边和区域。

所以修复模块不应该只返回一个 bool

一个更有用的返回结果应该包含:

是否成功;
使用了什么策略;
修复前检测到哪些问题;
修复后还剩哪些问题;
涉及哪些局部区域;
是否生成了新 face;
是否删除或合并了边;
是否发生回滚;
是否建议用户手动确认。

这对交互式修复尤其重要。

用户选择几条边或几张面以后,系统如果直接替换 shape,用户很难判断这个操作是否可靠。

更好的体验是:系统能告诉用户这次修复做了什么,修复前后问题有什么变化,是否还有未闭合边界,是否需要继续处理。

对于批量修复也一样。批量修复不代表静默修复。越是自动化,越需要报告。否则后续模型出问题时,很难回溯到底是哪一步修改了拓扑。

导入阶段和修复模块要分开

几何修复分层之后,另一个很自然的结论是:导入阶段不应该承担复杂修复职责。

导入阶段可以做轻量修复,比如基础 shape fix、简单检查、必要的拓扑整理。

但像局部补面、强 sewing、面贴合、近接触处理这类操作,更适合放到专门修复模块里。

原因很简单:导入阶段缺少上下文。

用户导入一个模型,可能只是想看一下,也可能要做网格,也可能要做布尔编辑。系统如果在导入时默认做强修复,就等于替用户做了模型语义决策。

比较稳妥的边界是:

导入阶段:稳定读入,轻量修复,记录问题;
修复模块:检测、分类、策略选择、执行修复;
交互命令:让用户确认局部高风险修复;
网格前处理:根据后续计算目标补充风险检查。

这样即使模型存在问题,也不会在导入阶段被静默修改。后续需要修复时,可以进入专门流程,让用户看到问题、确认范围、执行修复、检查结果。

一个更可维护的模块结构

如果用通用名字描述,我比较认可的几何修复模块结构大概是:

Geometry Healing Facade
   ├─ Repair Issue Detector
   ├─ Repair Decision Layer
   ├─ OCCT Healing Adapter
   ├─ Local Topology Editor
   ├─ Face Sewing Tool
   ├─ Face Gap Repair Tool
   ├─ Mesh Boundary Checker
   └─ Conformity Risk Repair

这里每一层都有明确边界。

Geometry Healing Facade 负责统一入口和流程调度。它不直接写复杂几何算法,而是组织检测、决策、修复、验证和报告。

Repair Issue Detector 负责发现问题。它应该尽量只检测,不修改模型。

Repair Decision Layer 负责策略选择。它根据问题类型、修复范围和上下文,决定走 ShapeFix、Sewing、Filling、局部替换,还是提示用户手动处理。

OCCT Healing Adapter 负责封装 OCCT 修复接口。它统一处理容差、异常、空 shape、输出验证和基础报告。

Local Topology Editor 负责局部拓扑编辑。它处理局部 patch、面替换、边界重建和 shape 重组。

Face Sewing Tool 负责面缝合。它的重点是连接已有面,而不是补面。

Face Gap Repair Tool 负责面缝隙和自由边相关问题。它需要判断是 sewing、wire 修复、补面,还是保留为开放边界。

Mesh Boundary Checker 负责从网格或边界风险反推几何问题。这部分对于 CAD/CAE 前处理很重要,因为有些问题只有在网格阶段才明显。

Conformity Risk Repair 负责更偏 CAE 的共形风险处理。这类问题不一定表现为 shape invalid,但会影响后续网格接触关系和界面一致性。

这套结构的重点不是“类很多”,而是每一层解决的问题不同。

避免把分层写成过度设计

当然,分层也不是越多越好。

如果只是为了调用一次 ShapeFix_Shape,写一堆抽象类和接口没有意义。

几何修复分层的前提是:系统里确实存在多种问题、多种策略、多种调用场景,以及修复前后验证和报告的需求。

所以我理解的分层不是为了设计模式,而是为了隔离变化。

OCCT API 可能变化。

容差策略可能变化。

检测方法可能变化。

修复策略可能变化。

交互方式可能变化。

网格前处理对模型质量的要求也可能变化。

如果这些都混在一起,任何一个变化都会影响整个修复流程。

分层的目的,就是让这些变化尽量发生在自己的位置上:

OCCT 参数调整,应该尽量限制在适配层;
新增一种 gap 判断,应该放在检测层;
新增一种修复路径,应该放在策略层;
局部替换方式变化,应该放在拓扑编辑层;
报告字段变化,不应该影响底层修复 API。

这样后续维护成本会低很多。

小结:OCCT 是工具箱,修复模块是流程系统

OCCT 提供了很多模型修复工具,但工具箱本身不是修复系统。

如果只是把 ShapeFixSewingFilling 包成几个函数,短期能解决一些问题,但很难支撑复杂 CAD/CAE 场景。

真正稳定的修复模块,需要围绕问题检测、问题分类、策略选择、局部执行、结果验证和修复报告来组织。

我现在更认可的边界是:

OCCT 负责提供底层几何和拓扑修复能力;
修复模块负责决定何时调用、如何调用、修完怎么验证;
交互层负责让用户理解和确认高风险修复;
导入流程只做轻量、保守、可预期的处理。

这次整理后,我最大的感受是:CAD/CAE 修复模块不能只追求“修得动”,还要追求“修得清楚”。

“修得动”是指 API 能跑完,shape 能生成。

“修得清楚”是指系统知道自己为什么修、修了哪里、修完是否变好、失败后怎么处理。

对于 CAD/CAE 系统来说,后者可能更重要。

因为模型修复经常不是孤立功能,它会影响后续很多流程:

导入;
显示;
选择;
布尔;
网格;
边界条件;
材料区域;
共形界面;
结果复查。

如果修复行为不可解释,后面任何一个环节出问题都很难定位。

所以几何修复不是一个“工具类集合”,而是一个有流程、有边界、有报告的工程模块。

这也是后续继续做面缝合、面缝隙修复、局部补面和共形风险修复时,最需要先想清楚的一件事。