ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

OpenMesh三角网格补洞源码解析:边界环、三角化与流形修复

OpenMesh三角网格补洞源码解析:边界环、三角化与流形修复

简介:面向三维建模、计算机图形学课程学习与网格修复实践的OpenMesh补洞算法源码包,适合已具备C++与三角网格基础、希望理解孔洞检测、邻接关系分析、填充策略选择、新面生成及拓扑更新完整流程的开发者。RAR压缩包共66个文件,约33.62MB,以9个cpp源文件、8个头文件为核心,配有7个dll运行库、6个ply示例模型、7张png效果图,以及Visual Studio工程、Qt界面和可执行程序,便于对照编译与调试。已有2777人学习下载。资料围绕带洞模型给出可运行示例与查看器,读者可据此复现补洞流程,观察边界环识别、邻接查询、插值填充与面光顺等环节,并借助计算机图形学演示文稿梳理算法背景;工程结构较完整,也适合作为课程设计、毕设或网格修复任务的参考底稿。

1. 扫描件上的破洞不是小事:openmesh 三角网格补洞源码解决的四个现场

上个月帮某实验室收尾一批扫描件,模型在查看器里看着挺完整,一进切片软件就报“存在开放边”,切片结果里凭空多出一圈空腔。追下去发现是扫描死角留下的洞,几十个面片的口子,肉眼几乎看不出来。这类活正是 openmesh 三角网格补洞这套源代码要接手的地方:它在半边结构上把边界环串出来,按洞的大小和曲率选三角化策略,再做细分、投影和光顺,最后吐一个闭合流形网格。适用于三维重建后处理、逆向抄数前的清理、打印前的模型修复、仿真网格的封闭化四个场景。做后处理的人拿到就能编译,做算法的能改权重和策略,新手照着命令跑也能先看到效果。

2. 边界环怎么找、洞怎么封:从半边结构到三角化的完整链路

补洞这件事拆开只有三步:找到洞、把洞填上、让填上去的面看起来像原来的曲面。麻烦全在细节里——洞在哪、哪些“缝”不该补、补上去的面朝哪边、顶点密度跟周围差多少。下面按源码里的实际调用顺序走一遍。

2.1 半边结构为什么天生适合找边界

OpenMesh 把每条无向边拆成两条方向相反的有向半边,每条半边记着自己所属的面、起点顶点、下一条半边和对偶半边。对内部边,两条半边各自挂着一个面;对边界边,其中一条半边的面句柄是无效的,is_boundary(halfedge)就是查这个。这个设计带来两个直接好处:判断一条边是不是边界是 O(1),不用像面表结构那样先建边表再统计引用次数;沿边界走一圈也是 O(1) 一步,因为边界半边的next_halfedge_handle被维护成指向环上的下一条边界半边,整条边界天然是个可遍历的闭环。

代价是内存,每条边多存一份拓扑指针。但补洞这种要做大量邻域查询的场景,这个代价值得付。如果换成只存三角形索引数组的结构,光是建邻接表就够写半天,还得自己处理非流形边。

源码里提取边界环的核心就十几行:

// 把所有边界半边串成闭环,返回每个环的顶点序列 #include <OpenMesh/Core/IO/MeshIO.hh> #include <OpenMesh/Core/Mesh/TriMesh_ArrayKernelT.hh> #include <vector> using TriMesh = OpenMesh::TriMesh_ArrayKernelT<>; std::vector<std::vector<OpenMesh::VertexHandle>> extractBoundaryLoops(const TriMesh& mesh) { std::vector<std::vector<OpenMesh::VertexHandle>> loops; std::vector<bool> visited(mesh.n_halfedges(), false); for (auto heh : mesh.halfedges()) { if (visited[heh.idx()] || !mesh.is_boundary(heh)) continue; std::vector<OpenMesh::VertexHandle> loop; auto cur = heh; do { visited[cur.idx()] = true; loop.push_back(mesh.from_vertex_handle(cur)); cur = mesh.next_halfedge_handle(cur); if (!mesh.is_boundary(cur)) break; // 防御:环被破坏时直接退出,别死循环 } while (cur != heh && !visited[cur.idx()]); if (loop.size() >= 3) loops.push_back(std::move(loop)); // 两个点连不成洞 } return loops; }

visited按半边编号打标记,保证一个环只走一遍。loop.size() >= 3这个判断不能省——退化边、孤立边也会表现为边界半边,两个顶点组成的“环”补不出面。from_vertex_handle取的是半边的起点,所以环上的顶点顺序就是沿着边界走一圈的顺序,后面三角化直接用这个序号当索引,不用再排序。

提示:这段代码依赖next_halfedge_handle在边界半边上的正确性。如果读进来的模型本身拓扑就坏了(比如同一个顶点被两个不连通的面片共用),环会提前断开,所以那个is_boundary(cur)的防御判断一定要留。

2.2 先量一量洞:该不该补、能不能补

不是所有边界都该补。扫一个敞口件,它的开口是个合法边界;有人把模型切成两半,切面上也是一圈边界。闷头补上去,模型体积直接翻倍,这种翻车在现场太常见了。源码里在三角化之前插了一层度量,用几个便宜指标先做筛选:

指标算法典型判据用途
环长 P环上相邻顶点距离求和P < 0.5 × 包围盒对角线太大可能是切口
顶点数 n环上顶点计数n ≤ 200 走精确算法,超过走近似选策略
平均边长 LP / n与全网格平均边长比 0.5~2判断密度是否异常
平面度环顶点做 PCA,取最小特征值比λ3/λ1 < 0.01 视为近似平面选平面三角化
边界夹角相邻边界边的转角平均转角 < 30° 视为光滑环判断是否需要光顺

平面度这个指标性价比最高。一圈点做协方差矩阵,三个特征值排序,最小特征值除以最大特征值,数量级在 1e-2 以下基本就是平面洞,扇形三角化加轻微光顺足够;比值上到 0.1 以上说明这圈点扭曲得厉害,硬用平面填会出一个大补丁,必须走细分加投影。

实际写的时候 PCA 用 3×3 对称矩阵的雅可比迭代就够,没必要拉 Eigen 进来。真要用 Eigen 也行,源码里那部分是独立的,换实现不影响主流程。

2.3 三种三角化策略:什么时候用哪个

边界环拿到手,接下来是把一圈顶点变成一堆三角形。源码里备了三条路,对应三种洞:

扇形三角化:选环的重心当扇心,或者直接以环上某个顶点为扇心,把环切成三角形。复杂度 O(n),代码最短。缺点是只对平面洞友好,稍微有点曲率就会在扇心处堆出一圈针状三角形,长宽比惨不忍睹,后续光顺都救不回来。适合 n 小(十几个点)、平面度达标的洞。

最小权三角化:环形顶点序列做区间动态规划,dp[i][j]表示环上第 i 到第 j 个点这一段的最优三角化代价,转移时枚举中间点 k,代价是三角形自身的权重加上两段子问题。复杂度 O(n³),n 在 200 以内完全跑得动,超过就得上近似或者先做环的稀疏化。

// 三角形权重:面积为主,叠加形状惩罚,避免生成针状三角形 double triWeight(const std::vector<TriMesh::Point>& P, int i, int j, int k) { const auto &a = P[i], &b = P[j], &c = P[k]; double area = 0.5 * OpenMesh::cross(b - a, c - a).norm(); double per = (b - a).norm() + (c - b).norm() + (a - c).norm(); double q = area / (per * per + 1e-12); // 等边三角形约 0.048 return area + 0.05 * std::max(0.0, 0.048 - q); // 形状项,抑制 sliver }

权重里只放面积是很多人的第一版写法,结果补出来的洞里全是细长条,一做光顺就自交。加一个归一化的形状项,量纲和面积对齐后再乘个小系数,效果立竿见影。系数 0.05 是个经验值,形状项太大会压制面积项,反而在曲率大的地方堆出密集的小三角形。

前沿推进 + 细分 + 光顺:这是处理大洞、曲面洞的正路。先把环的多边形按最小权三角化出一个粗填充,然后对超过目标边长的新边做循环细分,新顶点通过局部曲面拟合投影到估计曲面上,最后固定边界做若干轮 Laplacian 或双 Laplacian 光顺。整个流程比前两种长,但补出来的面和周围曲率衔接自然,适合 n 上百、平面度差的洞。

选型上我一般的做法是:平面度 < 0.01 且 n < 50 走扇形;平面度一般、n < 200 走最小权;其余全走前沿推进。这套判据写进代码里就是个 if-else 链,比让用户手选靠谱。

2.4 编译与最小可运行调用

源码是标准的 CMake 工程,依赖 OpenMesh 本体。构建脚本按常规写法即可:

cmake_minimum_required(VERSION 3.16) project(mesh_hole_filler CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) if(NOT CMAKE_BUILD_TYPE) set(CMAKE_BUILD_TYPE Release) # 补洞是计算密集型,Debug 下慢一个量级 endif() find_package(OpenMesh REQUIRED) include_directories(${OpenMesh_INCLUDE_DIRS}) add_executable(holefill src/main.cpp src/hole_filler.cpp src/hole_metrics.cpp) target_link_libraries(holefill PRIVATE ${OpenMesh_LIBRARIES})
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release cmake --build build -j"$(nproc)" # 最小调用:读入、补洞、导出,参数全部走命令行 ./build/holefill input.obj output.obj \ --method auto --max-perimeter 0.8 --target-edge 1.5 --fair-iters 20

find_package之后用${OpenMesh_INCLUDE_DIRS}和${OpenMesh_LIBRARIES}这两个变量,比硬编码库名稳,不同发行版打包出来的目标名不一样,硬写容易在别人机器上链接失败。--max-perimeter 0.8是包围盒对角线归一化后的长度,别照着填绝对数值,得看你模型的尺度。--target-edge 1.5表示新边目标长度取周围平均边长的 1.5 倍,这个倍数偏大一点没关系,太小会让补丁区的三角形密度明显高于周围,一眼就能看出补过。

3. 参数调不对等于白补:孔洞阈值、绕序与细分密度的取值依据

同一份源码,参数差一档,结果能从“看不出补过”变成“一眼假”。这一章把四个真正影响结果的参数拆开讲,每个都说清楚为什么这么取、取错会怎样。

3.1 孔洞判定阈值:相对量比绝对量可靠

最容易被写死的就是周长阈值。写死 5 毫米,换一个按米建模的工程文件就全漏判。源码里的做法是把所有长度都归一化到包围盒对角线,--max-perimeter的取值范围落在 0.02 到 2.0 之间,含义是“这个洞的周长不超过模型尺寸的百分之几”。

判据本身也要组合着看。单看周长会误伤:一个细细的裂缝周长可能很大,但面积很小,它该补;一个敞口件的开口面积巨大、周长也巨大,它不该补。加一条面积判据更稳——环形多边形按扇形公式算个近似面积,和包围盒截面积比一比,比值超过 0.3 的直接标记为“疑似敞口”跳过,让用户显式确认。

还有一类边界要特别当心:两个面片贴得很近但没缝合,形成的边界环顶点数很少,通常就三四个点,环长极短。这种不是洞,是接缝。判据是环长小于周围平均边长的 2 倍,直接跳过,交给后面的焊接步骤去处理。补洞算法最怕的就是把该焊的缝当成洞填了,填完表面还会鼓起一个小包,比原来更难看。

3.2 绕序与法向:新增面必须跟邻居同向

三角化只决定用哪三个点连成面,不决定绕序。绕序反了,面法向朝里,导出的 STL 在切片软件里就是一团乱,甚至直接判定为负体积。这个坑我踩过不止一次,因为补完在查看器里看不出区别——很多查看器默认双面渲染。

源码里的做法是在加面之前先算一个参考法向:把这个洞所有边界边相邻的(已有)面的法向做面积加权平均。这个平均法向基本就是洞所在局部曲面的朝向。然后每生成一个候选三角形,算它的法向跟参考法向点乘,为负就交换两个顶点的顺序再add_face。

// 按参考法向修正绕序后再加面,返回无效句柄表示加面失败 TriMesh::FaceHandle addFaceOriented(TriMesh& mesh, TriMesh::VertexHandle a, TriMesh::VertexHandle b, TriMesh::VertexHandle c, const TriMesh::Normal& ref) { const auto pa = mesh.point(a), pb = mesh.point(b), pc = mesh.point(c); auto n = OpenMesh::cross(pb - pa, pc - pa); // 不归一化,只看符号 if (n.dot(ref) < 0.0f) std::swap(b, c); // 反向则交换顶点顺序 auto fh = mesh.add_face(a, b, c); if (!fh.is_valid()) { // 加面失败常见于该三角形会让某条边挂上第三个面,形成非流形 std::cerr << "add_face failed: " << a.idx() << " " << b.idx() << " " << c.idx() << "\n"; } return fh; }

add_face的返回值必须检查。OpenMesh 在会破坏流形性时拒绝加面并返回无效句柄——这不是异常,是返回值,不检查就静默丢面,最后洞没补上还查不出原因。这个设计其实挺贴心,等于免费送了一个非流形检测。

参考法向还有个退化情况:洞特别大,周围一圈面的法向本身就不一致(比如跨过一条棱)。这时候平均值可能接近零向量,点乘符号失去意义。稳妥做法是改用环上最长那条边界边相邻面的法向当参考,或者干脆分块补——把环按转角突变点切成几段,每段独立三角化。源码里留了--split-angle参数干这事,默认 60 度。

3.3 细分密度与顶点投影:补丁不能是块平板

洞补上了但看起来像贴了块补丁,问题出在细分和投影。细分决定补丁区有多少顶点,投影决定这些顶点落在哪个曲面上。

细分的目标边长由--target-edge控制,含义是新边长度相对周围平均边长的倍数。取 1.0 意味着补丁密度和周围一致,视觉上最自然,但顶点点数会比较多;取 2.0 能省一半顶点,代价是补丁区的三角形明显比周围大,做有限元分析时会出现刚度突变。做可视化和打印,1.5 到 2.0 都行;做仿真,老老实实取 1.0 到 1.2。

投影是整个流程里最玄学的部分。有三种常见做法:直接投到拟合平面上,快但补出来是块平板;投到二次曲面(局部坐上最小二乘的高度拟合)上,能表达鼓包和凹陷,计算量适中;投到周围已有网格上做最近点查询,最准但慢,而且洞大了之后最近点可能落在洞的另一侧,反而把面拉穿。

我一般用二次曲面这一档。具体做法是以边界环的重心为原点、参考法向为 z 轴建局部坐标系,把环上顶点变换过去,用它们的 (x, y) 拟合 z = ax² + bxy + cy² + dx + ey + f,最小二乘解一个 6 元线性方程组。然后每个细分产生的新顶点按同样的 (x, y) 求 z,再变换回世界坐标。这个二次曲面在边界处跟原来的环贴合,往中间平滑过渡,鼓包和鞍面都能表达,比平面强太多。

// 固定边界,只对新增顶点做 Laplacian 光顺;边界顶点一律不动 void fairNewVertices(TriMesh& mesh, const std::vector<TriMesh::VertexHandle>& inner, int iters, float lambda) { std::vector<std::pair<TriMesh::VertexHandle, TriMesh::Point>> buf; for (int it = 0; it < iters; ++it) { buf.clear(); buf.reserve(inner.size()); for (auto vh : inner) { TriMesh::Point sum(0, 0, 0); int n = 0; for (auto vv : mesh.vv_range(vh)) { sum += mesh.point(vv); ++n; } if (n == 0) continue; const auto p = mesh.point(vh); const auto avg = sum / static_cast<float>(n); buf.emplace_back(vh, p + (avg - p) * lambda); // 向邻域均值靠拢 } for (const auto& kv : buf) mesh.set_point(kv.first, kv.second); // 同步写回 } }

两处细节值得说。一是先算完再统一写回,边算边写会让后面的顶点用到前面已经移动过的位置,收敛行为完全变样,而且结果依赖顶点遍历顺序。二是lambda别取 1.0,那是把顶点直接搬到邻域均值上,二十轮下来补丁会缩成一个塌陷的坑;0.3 到 0.5 之间配合 20 到 50 轮,收缩可控。如果做完发现补丁区整体往里塌,换成双 Laplacian(对拉普拉斯坐标再做一次拉普拉斯)能明显改善体积保持,代价是代码多一层。

3.4 验收:闭合、流形、自交三项检查

补完不等于补对。源码里跑完会输出一份指标,三项必须同时过:

第一项闭合性。统计还有多少条边界边,补完之后应该是零。不为零说明有环没被识别,或者加面失败被静默丢弃了。

第二项流形性。统计每条边被几个面引用,正常是 1 或 2,出现 3 以上说明补丁和原网格在拓扑上打架了。常见于洞的两侧本来就存在非流形结构,补的时候把问题放大了。

第三项自交。这个最贵,精确检测要做三角形对的相交测试,O(m²)。实际做法是先按包围盒做空间哈希,只测邻近的三角形对,再用一个宽松的阈值过滤。补丁区顶点数通常只占全网格的百分之几,实际测的对数不多,跑得动。

注意:三项检查里闭合性和流形性必须硬性通过,自交给个告警就行。扫描件本身经常带轻微自交,硬卡会卡掉大量本来能用的结果。

4. 补完更糟的五种情况:边界环、非流形与自交的排查清单

下面这五条都是我在实际项目里撞过的,每一条都能让补洞从“修模型”变成“毁模型”。按现象、原因、解决三段写,方便对照排查。

4.1 补完边界边数量不降反升

现象:跑之前统计边界边是 47 条,跑完变成 60 多条,模型看起来更破了。

原因是add_face加面时和已有面冲突,OpenMesh 拒绝了这次加面,但代码里没有检查返回值,继续往下走。冲突的典型来源是三角化用了重复顶点——环上同一个顶点因为拓扑断裂被记录了两次,形成的三角形退化,加面必然失败。另一个来源是环上顶点本身就是非流形顶点,挂在它上面的面已经超过两圈。

解决分两步。先检查add_face返回值,失败就打日志记录三个顶点编号,跑一遍就知道是哪个位置在打架。再对边界环做一次顶点去重,同一个顶点编号在环里出现两次就说明环自交了,从重复处切开成两个子环分别处理。源码里--dedup-loop开关默认开着,就是干这个的。

4.2 提取边界环时程序卡死或环数量对不上

现象:模型加载完迟迟不输出,用调试器打断发现卡在提取环的 do-while 里;或者明明看到三个洞,只报了一个环。

卡死几乎都是next_halfedge_handle走出了边界。理论上边界半边的 next 一定指向环上下一条边界半边,但前提是模型拓扑自洽。当同一个顶点被两个不连通的面片共享时(重建算法里很常见),环会在该顶点处接到另一圈边上,形成一条不闭合的“8”字路径,do-while 的终止条件永远不满足。

解决办法是加那个is_boundary(cur)的防御判断,一旦发现下一条不是边界半边就跳出,把这个残缺环丢掉或者单独记录。环数量对不上则多半是visited标记的问题——用半边编号做标记,别用顶点编号,因为一个顶点可能属于两个环,用顶点标记会把第二个环整条跳过。

4.3 导出后模型“内外翻转”

现象:补完的模型在查看器里看着正常,导进切片软件后整个模型被判定为负体积,或者渲染时背面剔除去掉了大半个模型。

原因是新增三角形的绕序跟原网格相反。平面三角化时按环的顶点顺序连的三角形,而环的顺序取决于起始半边的方向;如果起始半边选在了洞的“另一侧”,整片补丁的绕序就全反了。更隐蔽的情况是洞跨越了一条棱,环上前半段和后半段的正确绕序本来就不同,统一处理必然错一半。

解决就是 3.2 节那套:每个三角形单独跟参考法向比符号,而不是整片统一。跨棱的大洞再叠加--split-angle,按转角把环切段,每段算自己的参考法向。

4.4 补丁区鼓包、塌陷或者出现明显折痕

现象:洞是补上了,闭合性流形性全过,但补丁区明显高出周围一圈,或者凹进去一个坑,光一打就露馅。

这是投影环节的问题。用平面投影,曲率大的洞必然出折痕,因为环上顶点在平面上,补丁中间也是平的,跟周围曲面的切向不连续。用最近点投影则容易在洞的另一侧找到错误的对应点,把面拉穿过去,形成尖刺。

改用二次曲面拟合能解决大部分情况。判断依据看补丁区的最大偏差:拟合完之后算新增顶点到周围已有网格的最近距离,超过周围平均边长的 2 倍就说明投影跑偏了,这时候把这一片的投影权重降到零,退回纯光顺,宁可平一点也别出尖刺。

4.5 批量处理跑到一半内存爆掉

现象:单个模型跑得好好的,写个循环处理几百个文件,跑到几十个之后进程被系统杀掉。

两个原因叠加。一是 OpenMesh 的删除操作不会立刻回收内存,删掉的元素进垃圾列表,顶点数越堆越多;二是request_face_status()、request_vertex_status()这类状态请求没开,某些内部操作会退化成线性查找,复杂度上去了。还有个小概率情况是add_face反复失败但顶点已经加进去了,孤儿顶点越积越多。

解决:进循环前把需要状态位一次性请求好;每处理完一个模型就mesh.garbage_collection(),把删除的元素真正回收;给每个模型设个时间上限和新增面数上限,超了就直接跳过并记日志,别让一个坏模型拖垮整批。

5. 接进现有流程:点云重建输入、STL 导出与批量脚本

源码本身是个独立命令行工具,真实项目里它得插在流程中间——前面接重建,后面接导出和检验。这一章讲怎么把它焊进去。

5.1 输入侧:重建出来的网格先清一遍再补

从点云重建出来的网格,直接喂给补洞算法经常出怪结果,因为重建结果里带的不是“洞”,是“碎片”。孤立的三角面片、重复顶点、退化的零面积三角形,在拓扑上都会表现成边界,补洞算法会一本正经地把它们当洞填。

清一遍的顺序有讲究。先做顶点焊接,容差取平均边长的 1e-3 到 1e-2,太大会把正常特征焊掉,太小没效果。再做退化面删除,面积小于平均面积 1e-4 的直接删。最后删孤立连通分量,按面数排序,比最大分量小两个数量级的直接扔掉。这三步做完再统计边界边,数字才可信。

// 读入后先清理,再补洞;顺序反了会把碎片当洞填 TriMesh mesh; if (!OpenMesh::IO::read_mesh(mesh, input_path)) { /* 处理读取失败 */ } mesh.request_vertex_status(); // 用到状态位之前必须请求 mesh.request_edge_status(); mesh.request_face_status(); cleanMesh(mesh, /*weld_eps=*/1e-5 * meanEdgeLength(mesh)); // 焊接 removeDegenerateFaces(mesh); // 退化面 removeSmallComponents(mesh, /*ratio=*/0.01); // 孤立碎片 mesh.garbage_collection(); // 真正回收内存 auto loops = extractBoundaryLoops(mesh); // 此时统计的环才有意义

request_*_status()这一组调用容易被忽略,因为不加也能编译通过,只是某些操作会静默走低效路径或者行为不符合预期。养成读入后立刻请求的习惯,比事后 debug 便宜。

5.2 输出侧:格式、精度与批量脚本

导出格式按下游决定。给切片或渲染用二进制 STL,体积小、兼容性好,但 STL 没有任何拓扑信息,所有顶点都是散的,下游想做进一步处理还得重新建索引。给后续网格处理用 OBJ 或 PLY,保留索引和顶点,文件大一些但信息完整。源码默认按扩展名判断,.stl走二进制,.obj、.ply走文本。

批量处理用 shell 循环就够,别上重型框架:

#!/usr/bin/env bash set -euo pipefail # 一个失败就停,别让坏文件污染整批结果 IN_DIR="./scan_raw"; OUT_DIR="./scan_fixed" mkdir -p "$OUT_DIR" for f in "$IN_DIR"/*.obj; do base="$(basename "${f%.obj}")" ./build/holefill "$f" "$OUT_DIR/${base}.obj" \ --method auto \ --max-perimeter 0.6 \ --target-edge 1.5 \ --fair-iters 20 \ --report "$OUT_DIR/${base}.json" # 每个文件落一份指标,方便事后筛 done

--report落一份 JSON 是个很值的设计,里面记着原始边界边数、补上的环数、新增面数、最大投影偏差、耗时。几百个文件跑完,按“最大投影偏差”排序,偏差大的那些挑出来人工看一眼,比一个个打开看效率高得多。

5.3 下游校验:不看源码也能验的脚本

交付前我习惯用一个跟源码无关的独立脚本再验一遍,防止源码里的统计逻辑本身有 bug 导致自证清白。直接解析 OBJ 统计每条边的引用次数,逻辑简单到不会错:

# 不依赖任何第三方库:统计每条边被几个面引用,1=边界,>2=非流形 import sys, collections def check(path): edges, faces = collections.Counter(), 0 with open(path) as f: for line in f: if not line.startswith("f "): continue faces += 1 # 只取顶点索引,忽略 uv/法向部分;要求文件里的索引为正数 idx = [int(tok.split("/")[0]) for tok in line.split()[1:]] for a, b in zip(idx, idx[1:] + idx[:1]): edges[(min(a, b), max(a, b))] += 1 boundary = sum(1 for c in edges.values() if c == 1) nonmani = sum(1 for c in edges.values() if c > 2) print(f"{path}: faces={faces} boundary={boundary} nonmanifold={nonmani}") return boundary == 0 and nonmani == 0 if __name__ == "__main__": sys.exit(0 if all(check(p) for p in sys.argv[1:]) else 1)

要求导出时写正数索引(从 1 开始),别写 OBJ 支持的那种负相对索引,否则这段解析要额外处理。返回码直接给 CI 用,python3 verify.py out/*.obj || exit 1,补洞结果不合法就卡在构建阶段,别流到下游。

6. 把补洞做成可回退的流水线:日志、指标与阈值回归

补洞这活最怕的不是补不好,是补坏了还不知道。我现在的做法是把它当成一次可回退的变更:原始文件永远不改名不动,输出写新目录,每次运行的参数和指标落一份记录,验收不过就整批回滚重跑。

具体做法是给每个模型生成一个同名的 sidecar 记录,字段固定下来:输入文件名、参数组合、原始边界边数、识别出的环数与各自顶点数、新增面数、最大投影偏差、三项验收结果、耗时。这些字段攒够几百条之后就有用了——可以按“新增面数 / 原始面数”的比值排序,比值超过 0.3 的那些基本是把敞口当洞补了;按“最大投影偏差”排序,偏差大的基本是二次曲面拟合跑飞了。这两条排序规则帮我省掉的返工比任何算法改进都多。

# 参数矩阵跑一遍,同一个小样本集上对比三档策略 for m in fan mwt refine; do for t in 1.2 1.5 2.0; do ./build/holefill sample.obj "out/${m}_${t}.obj" \ --method "$m" --target-edge "$t" --report "out/${m}_${t}.json" done done # 汇总成表,人眼扫一遍就能定下默认参数 python3 tools/summarize.py out/*.json > out/param_scan.csv

这里有个反直觉的经验:参数不是越精细越好。--target-edge从 1.5 调到 1.4,新增面数涨 20%,视觉上看不出差别,但下游仿真的网格量实打实上去了。所以参数的默认值应该按最终用途定,而不是按“看起来最顺眼”定——打印件用 1.8 到 2.0,可视化用 1.5,仿真老老实实 1.0 到 1.2,中间那档没有明确服务对象。

还有一个我吃过亏的地方:阈值不要写死在代码里,用配置文件。曾经有个批次隔了两个月重跑,源码里的默认阈值被人改过,同一个输入跑出来的结果完全不同,查了一整天才定位到是默认值变了。现在所有阈值都从一份params.toml读,sidecar 里记录的是实际生效的数值而不是“默认值”,这样才能真正复现。

从那以后我每次接新的扫描批次,都强制先跑十个小样本做参数扫描,把指标表看一遍再放开跑全量;批次结束前用独立校验脚本全量过一遍,返回码不为零就不交付。这套习惯救过我好几次,尤其是那类边界环看着正常、其实跨了两个连通分量的模型,肉眼看不出,指标一看就露。希望帮到你。

本文还有配套的精品资源,点击获取

返回列表