Technical note

模型修复为什么不能只做一键修复

记录一次 CAD/CAE 模型修复模块的工程化整理:为什么修复流程不能只是顺序调用一组 OCCT 函数,而需要围绕问题分级、局部优先、逐步验收、失败回滚和候选策略建立更可控的主链。

背景:修复模块不能只是函数集合

前面几篇文章里,我整理过一些 OCCT 修复接口的能力边界,也写过自由边、面缝隙、局部补面和交互式修复。

这些内容解决的是一个比较基础的问题:

面对某一种几何缺陷,底层有哪些工具可以用?

但当这些工具真正进入一个 CAD/CAE 模型修复模块后,还会遇到另一个更难的问题:

这些工具应该在什么时候执行?
按什么顺序执行?
对整个模型执行,还是只对问题区域执行?
某一步失败以后,是否还要继续?
生成了新的 shape,怎么判断它比原来更好?
检测、自动修复和交互式修复之间,如何复用同一套能力?

如果这些问题没有处理好,模块即使封装了很多 OCCT API,最后也很容易退化成一个不可解释的“一键修复”按钮。

按钮按下以后,系统依次调用一串算法。某个算法返回了成功,不代表目标问题真的减少;某个算法失败了,也不一定意味着前面已经得到的有效结果应该全部丢弃。

更麻烦的是,同一套强修复参数如果同时用于导入、手动修复、批处理和调试检测,风险边界会非常模糊。

所以这轮整理给我的最大感受是:模型修复的难点不只是“有没有修复函数”,而是要把修复函数组织成一条可解释、可验证、可回退的主链。

一键修复的问题

“一键修复”听起来很方便,但在工程软件里,它经常会掩盖很多细节。

最简单的做法是:

输入 shape

依次调用一组修复函数

返回 fixed shape 或失败

这种方式在 demo 或简单模型上可以工作。

但真实 CAD/CAE 模型里,问题通常不会只有一种。一个导入模型里可能同时存在自由边、开壳、非流形、小边、小面、狭长面、曲线表示缺失、局部自交、近接触共形风险等问题。

这些问题的严重程度不同,对后续网格的影响也不同。

例如,自由边和开壳可能直接阻断体网格;一个很小的倒角面不一定立刻导致失败,但可能让局部网格变密;某些自交面风险很高,但处理它可能会带来更大的拓扑变化。

如果把所有问题都放在同一个层级,然后按代码中函数排列顺序依次处理,就会出现几个问题。

第一,修复顺序不一定合理。

在模型主体拓扑还不可靠时,提前执行小特征清理,可能会改变后续缝合和补面的输入。

第二,失败语义不清楚。

某一步失败,到底是整个模型不可修,还是只是一个非关键优化没有完成?如果没有分级,系统很难判断。

第三,结果不可解释。

用户只知道“修复完成”或“修复失败”,但不知道修了哪些问题、哪些问题减少了、哪些区域被改动、哪些步骤被回滚。

第四,修改范围容易失控。

很多底层修复函数习惯接收整个 root shape,返回一个新的 root shape。局部问题可能触发全局变化,用户很难判断系统到底改了哪里。

所以模型修复不能只依赖一个“一键修复”入口。它需要更明确的问题分析、阶段划分和验收逻辑。

检测结果要能驱动修复

早期的检测结果很容易做成一个平铺列表:

TopologyError
FreeEdge
SmallEdge
SelfIntersection
SlenderFace
NonManifoldEdge
OpenShell
FaceGap
MeshConformityRisk

这样的列表当然有价值,至少能告诉用户模型里有哪些问题。

但如果检测结果只用于 UI 展示,它还没有真正参与修复流程。

我后来更倾向于让检测结果回答几个更关键的问题:

哪些问题会阻断网格主流程?
哪些问题只是次级风险?
哪些问题适合后处理阶段再做?
哪些问题有明确位置,可以尝试局部修复?
哪些问题风险太高,只适合报告或等待用户确认?

这样一来,检测结果就不只是错误文本,而是修复主链的输入。

例如,自由边、开壳、非流形、明显面缝隙这类问题,通常应该优先处理。因为它们可能直接影响后续体域建立或网格剖分。

而小面、小边、狭长面、法向统一、部分几何表示修正,则更适合放在主要拓扑问题受控之后。

这里的关键变化是:

修复顺序应该由问题语义决定,而不是由函数排列顺序决定。

这听起来很简单,但对模块结构影响很大。

一旦检测结果能驱动阶段、范围和策略选择,修复流程就会从“调用函数”转向“处理问题”。

先处理会阻断网格的缺陷

CAD/CAE 模型修复和普通几何清理不完全一样。

它最终往往要服务后续网格和求解流程。

所以修复主链里应该优先处理那些会阻断网格主流程的问题。

例如:

自由边;
开壳;
非流形边;
明显面缝隙;
会影响共形关系的接触区域风险;
严重拓扑有效性问题。

这些问题不一定都是最容易修的,但它们通常应该排在前面。

原因是,如果模型主体还没有形成可靠的拓扑边界,后续小特征清理、法向统一、局部补洞、几何简化都可能建立在不稳定输入上。

更稳妥的做法是:

先让模型的主要拓扑边界尽量可用;
再处理次级风险;
最后做整理和优化。

这不是绝对规则。不同物理场、不同求解器、不同建模来源会有差异。

但对一个通用 CAD/CAE 修复模块来说,至少应该避免在主阻断问题仍然存在时,盲目执行会改变大量局部拓扑的优化步骤。

我理解的修复主链应该更接近:

基础准备

检测当前问题

优先处理主阻断问题

重新检测

处理次级风险

后处理优化

最终验收

这个流程比简单的“一串函数顺序调用”更复杂,但它更符合真实工程模型的风险分布。

检测缓存不只是性能优化

模型检测通常会用到很多重复基础数据。

例如:

face 索引;
edge 索引;
edge 到 face 的邻接关系;
整体包围盒;
每个 face 的包围盒;
自由边列表;
非流形边列表。

如果每个检测器都重新遍历一次 shape,大模型上的重复成本会逐渐放大。

所以在一次检测请求内复用基础拓扑分析结果,是很自然的优化方向。

但检测缓存不应该只被理解成“少跑一次检测”。

它还有一个更重要的用途:为后续修复策略提供上下文。

例如,检测到自由边以后,缓存里如果能保留问题 edge、owner face、相邻 face,那么后续面缝隙修复就不必重新从全局模型里盲目搜索。它可以先围绕问题邻域尝试局部处理。

这就让缓存从性能工具变成了策略输入。

不过缓存必须有边界。

shape 被修改后,旧 shape 上的 face index、edge index、邻接关系、bbox 都不能继续假设对新 shape 有效。

所以更稳妥的原则是:

一次请求内可以复用基础分析结果;
root shape 修改后,旧缓存必须失效;
局部检测结果不能伪装成完整检测结果;
缓存不承担跨版本拓扑映射。

这里还有一个容易忽略的问题:有的命令只检测自由边,有的只检测非流形,有的只关心自交或共形风险。

如果一次局部检测结果被当成“完整结果”,后续修复流程就可能错误地认为其他问题不存在。

因此缓存需要知道自己覆盖了哪些检测范围,而不是只有“有缓存”和“没有缓存”两种状态。

这类设计不一定显眼,但它能让检测、修复和局部策略之间的关系更清楚。

局部优先,但不能假装所有问题都已经局部化

模型修复里,一个很重要的方向是局部优先。

很多问题本来就是局部的。

例如一条自由边、一组面缝隙、几张小面、一个接触区域附近的共形风险。如果每次都把整个 root shape 交给全局修复函数,很容易扩大计算范围和误修改风险。

更合理的做法是:

能定位到问题区域时,先围绕问题邻域尝试局部修复;
局部路径不适用或失败时,再进入受控的全局回退。

局部优先的价值主要有几个:

减少无关 face / edge 进入重型算法;
缩小错误修改范围;
更容易比较目标问题是否改善;
更适合交互式预览和用户确认;
失败后更容易回滚到原始 shape。

但这里要特别谨慎。

局部修复不能简单理解成“只把几张 face 取出来修一下”。

局部算法需要足够的邻域上下文;修复后的 patch 还要重新放回 root shape;局部边界要和未修改区域保持一致;face、edge、选择状态、边界条件和显示对象都可能需要重新映射。

所以有些流程虽然已经能由问题缓存触发,底层仍可能使用全局函数。这种状态不能被包装成“已经完整区域化”。

更准确的说法是:

问题定位可以先局部化;
修复执行需要逐步局部化;
最终还要建立局部替换事务。

局部优先是一条工程方向,不是一个简单开关。

如果当前局部上下文不足,或者局部结果无法安全回装,保留全局回退是必要的。

API 返回成功不等于结果可以接受

底层几何算法返回成功,只能说明这次调用完成了。

它不能自动证明结果真的更好。

例如,一个修复函数返回了新的 shape,但我们仍然要确认:

输出 shape 是否有效;
shape 类型是否异常变化;
solid 或 shell 是否意外丢失;
face / edge 数量是否出现异常波动;
free edge 是否真的减少;
目标问题是否改善;
是否引入新的表示错误;
后续网格是否更容易通过。

所以我更倾向于把每一步修复都看成一个带验收的事务。

简化流程可以写成:

before = currentShape

执行修复步骤

得到 after

构建 before / after 验收报告

结果可接受?
    ├─ 是:保留 after
    └─ 否:恢复 before,并记录原因

验收也不应该只有“成功”和“失败”两种。

有些问题可能从 20 个减少到 4 个,但没有完全消失。它不是完全解决,但也不能简单当作失败。

更合理的结果状态可以包括:

已解决;
有改善;
无改善;
退化;
输出无效;
需要人工确认。

最终是否接受,要结合当前阶段目标决定。

例如,主阻断问题没有减少,就不应该接受;但后处理优化失败,不一定要放弃前面已经修好的模型。

这种“逐步验收 + 必要回滚”的机制,是模型修复从黑盒按钮变成工程流程的关键。

不同场景需要不同修复强度

同一个修复模块会出现在不同场景里。

例如:

模型导入;
调试检测;
手动 FixAll;
交互式修复;
批处理修复。

这些场景的风险容忍度不同。

导入阶段更强调稳定和保守。用户首先需要的是模型能够可靠进入系统,而不是导入时就被大范围改写。

调试检测应该默认只分析,不修改。

手动 FixAll 可以比导入更积极,但也需要模型规模、问题数量和问题类型门控。

交互式修复有用户选择提供上下文,可以允许更明确的局部修改,但仍要通过预览和确认控制风险。

所以修复策略不能只有一套固定强度。

更合理的是根据场景做决策:

当前是导入、检测、手动修复,还是交互式修复;
模型规模有多大;
问题数量有多少;
是否存在高风险拓扑问题;
输入是否更接近离散多边形模型;
当前操作是否有用户选择提供上下文;
是否允许自动修改。

决策结果也不应该只是参数大小,还应该能解释:

为什么这次只分析不修改;
为什么跳过某个高风险修复;
为什么选择局部优先;
为什么局部失败后允许全局回退;
为什么当前结果需要人工确认。

这能避免用户把“没有自动修”理解成按钮失效,也能避免系统在高风险场景里静默修改模型。

Repair Advisor:候选、预览和执行要拆开

交互式修复不能只有一个 repair() 接口。

用户选择几张面或几条边以后,系统不应该立刻修改模型,而应该先给出候选策略。

一个候选策略至少应该回答:

它打算修什么问题;
会影响哪些面或边;
风险高不高;
计算成本大不大;
是否需要用户确认;
是否只能预览或报告;
是否已经具备安全执行路径;
执行后是替换整个 shape,还是替换局部区域;
失败时如何解释。

这里最重要的是把候选、预览和执行拆开。

候选负责告诉用户“可以怎么修”。

预览负责告诉用户“可能影响哪里,会生成什么结果”。

执行负责真正修改模型,并生成可验收、可回滚的结果。

这三件事如果混在一起,就很容易出问题。

例如,一个高风险压印或局部补面候选,如果只是因为枚举里有这个策略,就直接接入自动执行,很可能会越过预览、区域边界、tool-target 关系和结果映射。

这类操作不应该被伪装成普通的 root shape 替换。

我更倾向于把候选分成几类:

可以安全执行的策略;
需要真实几何预览后再确认的策略;
当前只能报告风险和建议的策略;
需要多对象事务支持后才能执行的策略。

候选系统的价值不是让所有按钮尽快可点,而是准确表达哪些策略已经可以安全执行,哪些仍然需要更多上下文和事务支持。

进度、取消和批处理语义

检测和修复在大模型上可能耗时较长。

因此进度和取消也应该纳入修复主链,而不是最后再补一个进度条。

比较现实的做法是,在阶段边界报告进度,并在进入下一阶段前检查是否中止。

例如检测可以按基础索引、边界问题、拓扑问题、小特征、共形风险等阶段报告。修复可以按准备、分析、主阻断问题、次级风险、后处理、验收等阶段推进。

不过这里也要诚实:很多底层几何算法内部仍然是同步调用,不一定能在任意时刻安全取消。

当前更准确的目标是:

在阶段边界报告进度;
在进入下一阶段前响应终止;
终止时保留明确状态;
批处理场景下,不再启动后续对象;
不要让用户误以为系统已经卡死。

真正的大模型异步执行、算法内部可取消、UI 无阻塞,还需要更完整的任务系统配合。

但即便只是阶段级进度和取消,也能让修复过程更可控。

这轮整理真正改变了什么

这轮优化最直接的收益,不是“某个模型一定能修好”。

更准确地说,是修复过程变得更可解释、更可回退。

以前的流程更接近:

输入 shape

连续调用一组修复函数

返回 output shape 或 false

整理之后,更像是:

输入 shape

场景和规模门控

检测并建立问题摘要

优先处理主阻断问题

重新检测

处理次级风险

后处理优化

逐步验收、失败回滚

最终有效性和残留问题检查

这条链路把几个以前容易混在一起的问题拆开了:

检测成功,不等于需要修复;
算法执行成功,不等于结果应该接受;
某一步失败,不等于整条流程必须失败;
局部问题存在,不等于必须全局修改;
有候选策略,不等于已经具备安全执行路径;
shape 有效,不等于已经适合网格。

这些边界对模型修复模块很重要。

很多时候,商业工程软件需要的不是一个“看起来很强”的自动修复按钮,而是一条用户能够理解、系统能够验证、失败以后能够回滚的修复流程。

还没有解决的问题

这轮整理也没有把所有问题都解决。

首先,不是所有“局部触发”都已经变成“局部执行”。

检测结果可以定位问题区域,但底层执行器可能仍然需要 root shape。要真正做到局部修复,还需要把问题区域、局部执行结果和 root shape 重组打通。

其次,局部替换需要完整事务语义。

局部修复不只是生成一块 patch,还要回答:

旧区域怎么定位;
新区域怎么替换;
未修改区域怎么保留;
选择和属性怎么映射;
显示对象怎么更新;
undo / redo 的粒度是什么;
局部替换失败时怎么整体回滚。

这些问题没有解决之前,局部修复就不应该被包装成一个普通的自动替换。

再次,高风险 tool-target 操作仍然需要后置。

投影、压印、截面切分、复杂 patch 等操作可能改变内部拓扑。它们需要真实几何预览、tool/target 语义、结果映射和多对象事务,不能简单套用普通 root shape 修复。

最后,性能收益还需要真实模型验证。

分析上下文、检测缓存和局部优先从结构上减少了重复工作和无关输入,但不同模型的瓶颈可能完全不同。没有真实数据之前,不应该把架构优化直接宣传成确定的性能提升。

后续真正应该记录的是:

检测总耗时和各阶段耗时;
缓存命中率;
局部区域规模;
局部路径与全局路径耗时;
修复前后问题数量;
回滚次数和失败步骤;
最终网格成功率和质量变化。

这些数据会比单纯说“优化了性能”更有价值。

后续方向

基于这条主链,后面我更关注几个方向。

第一,继续完善区域化修复。

让检测结果不只决定“要不要修”,还真正决定“只修哪里”。

第二,把预览结果和最终提交结果之间的一致性做得更严格。

用户看到的 preview,最好就是后续确认提交时复用或可追踪的结果,而不是 Enter 以后重新跑出另一份几何。

第三,统一局部替换、多对象更新和新增/删除节点的事务表达。

这样 undo、模型树、数据库和显示层才能消费同一份结果语义。

第四,补齐面向用户的失败报告。

用户需要知道的是自由边是否减少、哪些区域没有修好、为什么回滚、下一步建议选择什么策略,而不只是日志里的一句异常字符串。

第五,用真实模型建立回归基线。

修复模块的验收不应该只有单元级 API 成功,还要覆盖模型有效性、网格成功率、单元规模和关键边界保持情况。

小结

如果用一句话概括这次模型修复模块的整理,我会说:

它把“一组按顺序调用的修复函数”,逐步改造成了一条由问题驱动、能够验收和回退的修复流程。

这条流程开始明确区分:

什么问题会阻断网格;
什么问题可以后处理;
什么时候只分析不修改;
什么时候优先做局部修复;
什么时候应该回退到全局路径;
什么结果可以接受;
什么候选只能预览或报告;
什么高风险操作必须等待更完整的事务支持。

OCCT 仍然提供底层执行器。

真正让模块稳定下来的,是执行器前面的检测、分级和决策,以及后面的验证、回滚和结果表达。

模型修复也仍然不是一个“按下按钮就一定得到完美模型”的问题。

但当问题能够被分级,范围能够被控制,每一步能够被验证,失败能够被解释时,它就不再是一次不可预测的黑盒修改,而开始成为一套可以持续迭代的工程系统。