简介:面向三维建模与计算机图形学学习者的OpenMesh三角网格补洞算法源码包,重点解决破损模型中的孔洞检测、邻接关系分析、填充策略选择、新面生成与拓扑更新等问题,适合具备C++与网格处理基础、希望理解补洞流程并二次开发的中高级读者。压缩包共66个文件、约33.62MB,以9个cpp源文件和8个头文件为核心实现,辅以工程配置、Qt界面、动态库、可执行程序、PLY示例模型和PNG效果图,另含OpenGL带洞模型演示与计算机图形学课件,便于从代码、界面和实验数据三个层面理解算法。已有2777人学习下载,读者可据此梳理孔洞边界识别、邻接信息访问、填充面构建及光顺后处理思路,并借助示例模型复现实验、排查拓扑维护问题。
1. 扫描模型破了个洞,先别急着回去重扫
上周帮人处理一批手持扫描件,三十多个 OBJ,有一半在底部或者被遮挡的位置缺了面。按常规思路是回扫描软件里补扫,但设备已经还回去了,重来一遍成本太高。真正省事的路子是拿 openmesh 做一次三角网格的补洞:读进模型,找出所有边界环,对每个洞重新三角化,再细化光顺,写出去。这份源码干的就是这件事——基于 OpenMesh 的 C++ 实现,输入一个带洞的三角网格,输出一个基本封闭的。适合手头已经有扫描数据、等着拿去做仿真或者打印前处理的开发者,也适合想把网格拓扑这块补起来的人拿它当第一个练手项目。整个工程不大,但边界检测、动态规划三角化、补片光顺这三段是完整的,能改的地方很多。
2. 工程结构与编译链路:从 CMake 到 OpenMesh 依赖怎么接
拿到一份源码,我的习惯是先看目录,再看构建脚本,最后才进 main。这三步走完,基本就知道这份代码是「能直接跑」还是「只给了核心算法、外围要自己搭」。这份工程属于前者,结构很规矩,每个文件对应一个职责,没有把两千行揉进一个 cpp 里。
2.1 目录结构与文件职责
目录大概长这样:
mesh-hole-filler/ ├── CMakeLists.txt ├── src/ │ ├── main.cpp # 命令行入口:读 → 找洞 → 补 → 写 │ ├── MeshTypes.h # TriMesh 类型别名、属性句柄声明 │ ├── BoundaryLoop.h/.cpp # 边界环检测与洞的过滤 │ ├── Triangulator.h/.cpp # 最小权三角化 + 细化 │ └── Fairing.h/.cpp # 补片光顺 ├── data/ │ └── sample_hole.obj # 带洞的示例输入 └── scripts/ └── batch_run.sh # 目录级批量处理MeshTypes.h是整个工程的地基,所有模块都从它取类型。把类型别名集中在一个头文件里,好处是后面如果要把Vec3d换成Vec3f、或者给顶点加自定义属性,只改一处,不用满工程搜TriMesh::Point。
#pragma once #include <OpenMesh/Core/Mesh/TriMesh_ArrayKernelT.hh> #include <OpenMesh/Core/Utils/Property.hh> // 自定义 traits:显式指定坐标精度,并给顶点/面挂上法向属性 struct MeshTraits : public OpenMesh::DefaultTraits { typedef OpenMesh::Vec3d Point; typedef OpenMesh::Vec3d Normal; VertexAttributes(OpenMesh::Attributes::Status | OpenMesh::Attributes::Normal); FaceAttributes(OpenMesh::Attributes::Status | OpenMesh::Attributes::Normal); EdgeAttributes(OpenMesh::Attributes::Status); }; using TriMesh = OpenMesh::TriMesh_ArrayKernelT<MeshTraits>; using VH = TriMesh::VertexHandle; using FH = TriMesh::FaceHandle; using HH = TriMesh::HalfedgeHandle;这里的Vec3d是双精度。扫描件动辄几十万顶点,坐标量级在几百毫米,单精度做 DP 权值累加时误差会累积到能选出错误对角线的程度,所以别图省内存改成 float。VertexAttributes里加Normal是为了后面光顺完能直接更新法向、写 OBJ 时带上vn,省一次全网格重算。
提示:自定义 traits 一旦改动,属性在内存里的布局就变了,之前用
add_property加过的自定义属性需要重新request,不要以为改个 typedef 就完事。
2.2 CMake 与 OpenMesh 依赖定位
构建脚本只做三件事:找 OpenMesh、编译可执行文件、链接 Core 和 Tools 两个库。
cmake_minimum_required(VERSION 3.16) project(mesh_hole_filler LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_EXPORT_COMPILE_COMMANDS ON) # OpenMesh 用 config 模式查找,COMPONENTS 明确要 Core 和 Tools find_package(OpenMesh REQUIRED COMPONENTS Core Tools) add_executable(hole_filler src/main.cpp src/BoundaryLoop.cpp src/Triangulator.cpp src/Fairing.cpp ) target_include_directories(hole_filler PRIVATE src) target_link_libraries(hole_filler PRIVATE OpenMeshCore OpenMeshTools)COMPONENTS Core Tools不能省。只写find_package(OpenMesh REQUIRED)的时候,某些安装布局下只会暴露头文件路径,链接阶段报一堆undefined reference to OpenMesh::IO::_OBJWriter::write。加上组件之后,OpenMeshCore和OpenMeshTools两个 imported target 才会被正确带出来,MeshIO里的 OBJ 读写才链得上。
编译命令我一般这么走,把 OpenMesh 的安装前缀显式喂进去,避免它去系统路径里翻到一个版本不对的旧库:
cmake -S . -B build \ -DCMAKE_BUILD_TYPE=Release \ -DOpenMesh_DIR=/opt/openmesh/lib/cmake/OpenMesh cmake --build build -j"$(nproc)"OpenMesh_DIR指向的是OpenMeshConfig.cmake所在的目录,不是安装根目录,也不是 include 目录。这一点翻车率极高,写到-DOpenMesh_DIR=/opt/openmesh会直接报Could not find a package configuration file。
Release 是必须的。DP 三角化是 O(n³),debug 模式下编译器不做内联,同一个洞从两秒变成一分半很常见,排查性能问题时容易误判成算法写崩了。
2.3 主流程数据流:读入、找洞、补、写
main.cpp是整个工程里唯一需要看命令行参数的地方,逻辑线很直:读网格 → 收集边界环 → 逐个过滤 → 三角化 → 细化 → 光顺 → 写出。
#include "BoundaryLoop.h" #include "Triangulator.h" #include "Fairing.h" #include <OpenMesh/Core/IO/MeshIO.hh> #include <iostream> int main(int argc, char** argv) { if (argc < 3) { std::cerr << "usage: hole_filler <in.obj> <out.obj> [max_hole_edges]\n"; return 1; } // 超过这个边数的洞直接跳过,防止外轮廓把 DP 拖死 const int maxEdges = (argc > 3) ? std::atoi(argv[3]) : 200; TriMesh mesh; OpenMesh::IO::Options opt; if (!OpenMesh::IO::read_mesh(mesh, argv[1], opt)) { std::cerr << "read failed: " << argv[1] << "\n"; return 2; } mesh.request_face_normals(); mesh.update_face_normals(); auto loops = findBoundaryLoops(mesh); std::cout << "boundary loops found: " << loops.size() << "\n"; int filled = 0; for (const auto& loop : loops) { if (static_cast<int>(loop.size()) < 3) continue; if (static_cast<int>(loop.size()) > maxEdges) continue; std::vector<VH> patchVerts; triangulateLoop(mesh, loop, patchVerts); // 内部 add_face,给新面打补片标记 refinePatch(mesh, patchVerts, /*areaThreshold=*/1.0); fairPatch(mesh, patchVerts, /*iters=*/20, /*lambda=*/0.5); ++filled; } mesh.garbage_collection(); mesh.request_face_normals(); mesh.update_face_normals(); OpenMesh::IO::write_mesh(mesh, argv[2], opt); std::cout << "filled " << filled << " holes\n"; return 0; }三个参数的含义要拎清楚:第三个参数max_hole_edges是安全阀,不是精度参数。扫描件的外轮廓经常是一条几千个顶点的大边界环,把它当洞去补,DP 的表会有几百万格,跑一晚上也出不来。默认 200 是我在实际数据上试出来的经验值,一般破损洞的环长在 10 到 80 之间。
garbage_collection()在写文件之前必须调一次。细化阶段用mesh.split()会删掉原面、插新面,被删的句柄留在内存里,不回收的话导出的 OBJ 里顶点数会比实际多,索引也可能错位。这个函数调用本身不便宜,但只调一次,值得。
3. 核心模块拆解:边界环检测与洞的筛选
补洞的第一步不是补,是找。找错了一切白搭——把一个本来就该开着的口补上,模型就废了。这一章讲两件事:OpenMesh 里边界到底怎么表示,以及找到之后怎么判断这个洞该不该补。
3.1 用 halfedge 找 boundary,为什么不能只看 is_boundary(vertex)
OpenMesh 是半边结构,每条边被拆成一对方向相反的 halfedge,只有一侧挂着面的那一半叫边界半边。mesh.is_boundary(HalfedgeHandle)判断的是这个,而mesh.is_boundary(VertexHandle)判断的是「该顶点上至少挂着一条边界边」。
两者差别很关键。用顶点判据去凑环,一个顶点上挂了三条边界边(非流形位置)就分不清该往哪走,环会串在一起。用半边判据,顺着next_halfedge_handle一路走,天然就能把每个洞绕出来。
std::vector<std::vector<VH>> findBoundaryLoops(TriMesh& mesh) { std::vector<std::vector<VH>> loops; OpenMesh::HPropHandleT<bool> visited; if (!mesh.get_property_handle(visited, "h_loop_visited")) mesh.add_property(visited, "h_loop_visited"); for (auto h : mesh.halfedges()) mesh.property(visited, h) = false; for (auto h0 : mesh.halfedges()) { if (!mesh.is_boundary(h0)) continue; // 只看边界半边 if (mesh.property(visited, h0)) continue; // 已被别的环走过 std::vector<VH> loop; HH h = h0; do { mesh.property(visited, h) = true; loop.push_back(mesh.to_vertex_handle(h)); h = mesh.next_halfedge_handle(h); // 防御:非流形数据下 next 可能不回到起点,加个上限 if (loop.size() > 100000u) break; } while (h.is_valid() && h != h0); if (loop.size() >= 3) loops.push_back(std::move(loop)); } return loops; }next_halfedge_handle在边界半边上的语义是「沿边界继续走一步」。OpenMesh 保证了 halfedge 的 next 环是闭合的,所以边界半边串起来必然绕洞一圈回到起点,do-while的终止条件就成立。
收集的是to_vertex_handle,也就是每条半边指向的那个顶点。按这个顺序把顶点连起来,得到的环方向和缺失面的方向是一致的,后面add_face直接按这个序列加就行,法向不会反。
那个loop.size() > 100000u是血泪经验。有些扫描件在拼接处会出现一条边挂三个面的情况,严格来说不是流形网格,next走不到头,不加这道闸程序就静默卡死,连个报错都没有,只能靠 top 看 CPU 占满然后一脸懵。
3.2 洞的过滤:周长、环长、还有一个容易漏的外轮廓判据
找到环只是第一步,哪些该补是另一个问题。我的过滤条件是三条,按顺序短路,代价从低到高。
bool shouldFill(const TriMesh& mesh, const std::vector<VH>& loop, double maxPerimeter, std::size_t totalBoundaryVerts) { if (loop.size() < 3) return false; // 条件一:环长上限,配合命令行参数一起用 if (loop.size() > 512) return false; // 条件二:周长上限,防止稀疏大洞吃掉整个模型 double per = 0.0; for (std::size_t i = 0; i < loop.size(); ++i) { const auto& a = mesh.point(loop[i]); const auto& b = mesh.point(loop[(i + 1) % loop.size()]); per += (b - a).norm(); } if (per > maxPerimeter) return false; // 条件三:一个环占了全部边界顶点的大头,多半是外壳开口 if (totalBoundaryVerts > 0 && static_cast<double>(loop.size()) / totalBoundaryVerts > 0.8) { return false; } return true; }第三个条件是我后来加的。扫描件如果只扫了正面、背面整个没扫,那条边界环会把整个模型围一圈,补出来就是一块巨大的平面片,视觉上像一个饼。这类「洞」补了没有意义,应该在前面就拒掉。
maxPerimeter得按模型尺度给,不能写死绝对值。同一个 50 对 5 的阈值,在毫米单位的模型上是「补小洞」,在米单位的模型上是「补任何洞」。我一般先跑一遍统计出所有环的周长中位数,取中位数的三倍作为阈值,这一步用--dry-run打日志很快。
3.3 环坐标提取与预处理:切口放在哪
DP 三角化需要一个线性序列,可环是闭合的,首尾相连。标准做法是把环「切开」:任选一条边(V0, Vn-1)当作多边形的最后一条边,剩下的部分构成一个普通 n 边形。
切口位置有讲究。我固定切在环上最长的那条边的两个端点之间,原因是:最长边在原始网格里通常是一条跨越大曲率的过渡边,把它当成多边形的收口边,DP 会更倾向于先连这条边,补出来的第一层三角形更贴合原曲面。
// 把闭合环旋转成以最长边为收口的线性序列 std::vector<VH> linearizeLoop(const TriMesh& mesh, const std::vector<VH>& loop) { const std::size_t n = loop.size(); std::size_t bestI = 0; double bestLen = -1.0; for (std::size_t i = 0; i < n; ++i) { const auto& a = mesh.point(loop[i]); const auto& b = mesh.point(loop[(i + 1) % n]); const double len = (b - a).sqrnorm(); if (len > bestLen) { bestLen = len; bestI = i; } } // 让 loop[bestI+1] 成为序列首元素,loop[bestI] 成为末元素 std::vector<VH> out; out.reserve(n); for (std::size_t k = 1; k <= n; ++k) out.push_back(loop[(bestI + k) % n]); return out; }这个函数不改变几何,只改变顶点在数组里的排列。看起来是小事,但它决定了 DP 里W[0][n-1]这条收口边落在哪个位置,进而影响整张三角化结果。切在短边上,收口边会变成一条「人为的折痕」,补片和原网格的接缝处会鼓起来一条脊线,很难看。
4. 补洞算法主体:最小权三角化、细化与光顺
前面都是准备动作,真正决定补片质量的是这一章。整条流水线分三段:先用动态规划把边界环三角化出来,再对过大的三角形做细分,最后用拉普拉斯把补片拉平顺。三段缺一不可——只做第一段,补片是硬折的;跳过第二段直接光顺,细节不够;只做前两段不光滑,接缝处会有明显台阶。
4.1 权值函数:为什么不能只拿面积当权
三角化要选「哪条对角线更优」,就需要一个权值来打分。最直觉的做法是用面积,但纯面积会倾向于生成扁平细长的三角形——细长三角形的面积小,反而被当成「好」的,结果补片里全是针状三角面,法向乱跳。
工程上常用形状因子,也就是三角形的「等边程度」:三个边长平方和除以 4√3 倍的面积,等边三角形正好等于 1,越细长值越大。
static double triWeight(const TriMesh& mesh, VH a, VH b, VH c) { const auto& pa = mesh.point(a); const auto& pb = mesh.point(b); const auto& pc = mesh.point(c); const double l1 = (pb - pa).sqrnorm(); const double l2 = (pc - pb).sqrnorm(); const double l3 = (pa - pc).sqrnorm(); const double area = 0.5 * ((pb - pa).cross(pc - pa)).norm(); // 退化三角形直接给一个巨大的权值,DP 会自动绕开 if (area < 1e-12) return 1e30; // 形状因子:等边时为 1,越细长越大 const double q = (l1 + l2 + l3) / (4.0 * std::sqrt(3.0) * area); // 面积做规模惩罚,q 的立方做形状惩罚,双管齐下 return area * q * q * q; }三项分别是:边长平方和、面积、形状因子。最后的返回是area * q³。area保证大的三角形被罚得多,q³保证细长的被罚得狠,指数取三次是为了让形状因素压过面积因素——毕竟补片上多几个中等三角形无所谓,多几个针状三角形就是灾难。
这个函数是整份代码里我改得最多的一个。想让它更贴原曲面,可以再加一项二面角惩罚:算出补片三角形与相邻原面之间的夹角,夹角越接近 180°(共面)权值越小。做法是把triWeight换成有上下文的版本,参数里多传一个邻面法向。
注意:
1e30这类哨兵值不要用std::numeric_limits<double>::max(),后面W[i][k] + W[k][j]累加时会溢出成 inf,DP 的比较逻辑就直接失效了。
4.2 最小权三角化的动态规划实现
多边形三角化是一个经典区间 DP。定义W[i][j]为顶点i..j组成的子多边形(含弦ij)的最小三角化权值,转移就是把i到j拆成两半:
W[i][j] = min over k in (i,j) of W[i][k] + W[k][j] + w(vi, vk, vj)代码很短,但索引边界容易写错:
using Tri = std::array<int, 3>; std::vector<Tri> minWeightTriangulation(const TriMesh& mesh, const std::vector<VH>& loop) { const int n = static_cast<int>(loop.size()); const double INF = 1e29; std::vector<std::vector<double>> W(n, std::vector<double>(n, INF)); std::vector<std::vector<int>> K(n, std::vector<int>(n, -1)); // 长度为 1 的子区间是一条边,不构成三角形,权值为 0 for (int i = 0; i + 1 < n; ++i) W[i][i + 1] = 0.0; for (int len = 2; len < n; ++len) { for (int i = 0; i + len < n; ++i) { const int j = i + len; for (int k = i + 1; k < j; ++k) { const double cand = W[i][k] + W[k][j] + triWeight(mesh, loop[i], loop[k], loop[j]); if (cand < W[i][j]) { W[i][j] = cand; K[i][j] = k; } } } } // 回溯:从 (0, n-1) 一路拆出所有三角形 std::vector<Tri> tris; std::function<void(int, int)> collect = [&](int i, int j) { if (j - i < 2) return; const int k = K[i][j]; if (k < 0) return; tris.push_back({i, k, j}); collect(i, k); collect(k, j); }; collect(0, n - 1); return tris; }W是权值表,K是决策表,记的是区间(i,j)里选中的那个分割点k。DP 算完只得到总权值,真正要的三角形得靠K从(0, n-1)往下回溯。
collect里返回的是原始loop的索引,加面时要用loop[idx]转回顶点句柄。这一点在调试时很容易混——直接拿索引当句柄用,编译器不会报错,运行时越界崩溃,堆栈还指不到出错行。
加面的时候有个坑必须处理:add_face在拓扑冲突时返回一个无效句柄,不抛异常、不打日志。
std::vector<VH> triangulateLoop(TriMesh& mesh, const std::vector<VH>& rawLoop) { std::vector<VH> patchVerts; auto loop = linearizeLoop(mesh, rawLoop); auto tris = minWeightTriangulation(mesh, loop); OpenMesh::FPropHandleT<bool> fIsPatch; if (!mesh.get_property_handle(fIsPatch, "f_is_patch")) mesh.add_property(fIsPatch, "f_is_patch"); for (const auto& t : tris) { FH fh = mesh.add_face(loop[t[0]], loop[t[1]], loop[t[2]]); if (!fh.is_valid()) { // 拓扑冲突:通常是顶点顺序与原网格朝向不一致 std::cerr << "add_face rejected: " << t[0] << " " << t[1] << " " << t[2] << "\n"; continue; } mesh.property(fIsPatch, fh) = true; } return patchVerts; // 初始三角化不产生新顶点,细化阶段再填 }f_is_patch这个面属性是给细化用的:提高面数时只分裂补片内的面,不动原网格。补片内部的三角形往往是细长的(DP 为了保证拓扑连续,不得不造一些瘦三角形),只对它们做细分不破坏原始细节。
4.3 细化与光顺:把补片从「一块布」拉成曲面
三角化完了,补片是硬邦邦的平面块,和周围的曲面之间有一道折痕。这一步补两件事:细化让三角形尺寸跟周围匹配,光顺让补片过渡到原曲面上。
void refinePatch(TriMesh& mesh, std::vector<VH>& patchVerts, double areaThreshold) { OpenMesh::FPropHandleT<bool> fIsPatch; if (!mesh.get_property_handle(fIsPatch, "f_is_patch")) return; // 先收集再操作:split 会让 face handle 失效,不能边遍历边分裂 std::vector<FH> todo; for (auto fh : mesh.faces()) { if (!mesh.property(fIsPatch, fh)) continue; const auto& p0 = mesh.point(mesh.to_vertex_handle(mesh.halfedge_handle(fh, 0))); const auto& p1 = mesh.point(mesh.to_vertex_handle(mesh.halfedge_handle(fh, 1))); const auto& p2 = mesh.point(mesh.to_vertex_handle(mesh.halfedge_handle(fh, 2))); const double area = 0.5 * ((p1 - p0).cross(p2 - p0)).norm(); if (area > areaThreshold) todo.push_back(fh); } for (auto fh : todo) { if (!fh.is_valid() || mesh.status(fh).deleted()) continue; // 重心处插点做 1-to-3 分裂,TriMesh 的 split 直接支持 TriMesh::Point c(0, 0, 0); int cnt = 0; for (auto vh : mesh.fv_range(fh)) { c += mesh.point(vh); ++cnt; } c /= static_cast<double>(cnt); VH nv = mesh.split(fh, c); if (nv.is_valid()) patchVerts.push_back(nv); } }mesh.split(fh, point)在三角网格上做的是 1 分 3,返回新插入的顶点句柄。这个 API 很省事,不用自己维护半边的重连。但有个前提:传入的fh必须还是有效的,所以收集和分裂必须分成两轮,边遍历边 split 会立刻踩到野句柄。
areaThreshold我一般取周围原面平均面积的两倍左右。设得太小,补片密度远高于周围,渲染时接缝处会有一圈「亮点」;设得太大,等于没细化,光顺也救不回来。
光顺这段只动新插入的顶点,原始边界顶点必须锁死:
void fairPatch(TriMesh& mesh, const std::vector<VH>& patchVerts, int iters, double lambda) { std::vector<VH> movable; movable.reserve(patchVerts.size()); for (auto vh : patchVerts) { if (!vh.is_valid() || mesh.status(vh).deleted()) continue; if (mesh.is_boundary(vh)) continue; // 边界顶点锁死,动了就裂开 movable.push_back(vh); } if (movable.empty()) return; for (int it = 0; it < iters; ++it) { std::vector<TriMesh::Point> next(movable.size()); for (std::size_t i = 0; i < movable.size(); ++i) { TriMesh::Point sum(0, 0, 0); int cnt = 0; for (auto vv : mesh.vv_range(movable[i])) { sum += mesh.point(vv); ++cnt; } if (cnt == 0) { next[i] = mesh.point(movable[i]); continue; } const auto avg = sum / static_cast<double>(cnt); // 朝邻域均值走 lambda 步,均匀拉普拉斯 next[i] = mesh.point(movable[i]) * (1.0 - lambda) + avg * lambda; } for (std::size_t i = 0; i < movable.size(); ++i) mesh.set_point(movable[i], next[i]); } }lambda取 0.5 是个保守值,跑 20 轮就有明显效果。超过 0.8 会过冲,补片在几轮之内塌成一个平面,反而丢了曲率。想要更贴原曲面,可以把这个均匀权重换成交权(cotangent)权重,或者直接用双拉普拉斯算子,代价是多算一轮邻接关系。
is_boundary(vh)那道判断不能删。补片和原网格交界处的顶点虽然也是「新加的」,但它们必须钉在原地,一旦被拉走,接缝处就出现一条肉眼可见的裂缝,后续做仿真会直接报非封闭。
5. 避坑与排查:补洞跑不起来、补完翻面的几种常见情况
5.1 编译期与运行期的三类报错
现象:CMake 报Could not find a package configuration file provided by "OpenMesh",但头文件明明在。
原因只有两个:要么 OpenMesh 没装全(只装了 dev 头文件包,没装带 cmake config 的那部分),要么OpenMesh_DIR指错了层级。
解决:先用find / -name "OpenMeshConfig.cmake" 2>/dev/null定位到实际文件,把它的所在目录喂给-DOpenMesh_DIR。如果压根找不到这个文件,说明安装不完整,需要从源码重新构建并make install,光拷 include 目录是没用的。
现象:链接阶段一堆undefined reference to OpenMesh::IO::_OBJReader::read。
原因:find_package没带COMPONENTS,imported target 没被创建,target_link_libraries里写的OpenMeshCore被当成一个不存在的库名静默忽略了。
解决:显式写find_package(OpenMesh REQUIRED COMPONENTS Core Tools),链接时用OpenMeshCore OpenMeshTools。别自己手写-lOpenMeshCore,不同发行版的库文件名不统一。
现象:导入 OBJ 后findBoundaryLoops返回 0,补洞函数什么都没做。
原因:OBJ 里顶点是重复的。很多导出器会把每个面的三个顶点独立写一遍,读进来之后 OpenMesh 得到的是「一堆互不相连的三角形」,没有任何一条边被两个面共享,自然也就没有边界半边。
解决:读入之后先做一次顶点焊接。可以在读之前用外部工具合并,也可以在代码里建一个空间哈希按坐标去重:
// 按坐标 <p> <a href="https://download.csdn.net/download/u010346590/7055159" style="color:#ec7500;font-size:14px;"> 本文还有配套的精品资源,点击获取 </a> <img alt="menu-r.4af5f7ec.gif" src="https://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;"> </p>