Gmsh 源码深度解析-第 6 章 三维体网格算法

Gmsh 源码深度解析-第 6 章 三维体网格算法

本章目标:拆解 Gmsh 的三维体网格生成------算法枚举与分派、三维 Delaunay 的四步走 (外接球心、初始四面体剖分、边界恢复、点插入)、鲁棒谓词、TetGen 风格的边界恢复、 局部修改内核,以及 HXT、pdel3d、MMG3D、Netgen 四个自由网格引擎与 DOMHEX 六面体路线、 Transfinite/Extruded 结构化路线,最后落到收尾与一致性检查。

第 4 章建立了 Mesh3D 的调度骨架(先拉伸体、再细分、然后 Netgen 前沿、最后按连通分量 逐个 Delaunay),第 5 章讲完了二维曲面网格。本章进入三维------这是 Gmsh 里算法分支最多、 外部依赖最多、数值细节最硬的一层。

6.0 承上启下:为什么三维是另一片战场

三维体网格与二维曲面网格在接口层 是同构的:都面对"已经网格化的边界",都要在内部 填充单元,都要与几何实体保持共形。但在算法层两者差异极大,这是本章要反复强调的主线:

维度 二维曲面网格(第 5 章) 三维体网格(本章)
约束实体 曲线段 → 三角形边 三角面片 → 四面体面;曲线段 → 四面体边
边界恢复难度 一条线段翻转即可 面片恢复常需插入新点(Steiner)
空球判据 外接圆 + 空圆 外接球 + 空球(insphere)
内部点插入判据 三角形面积/外接圆 四面体外接半径 + 尺寸场
并行难度 低(前沿天然局部) 高(分区 + 冲突 + 幽灵四面体)
可选引擎 少 多(del3d / HXT / pdel3d / MMG3D / Netgen)
混合单元 四边形/三角形 六面体/棱柱/金字塔/三面体 + 四面体

一句话总结:二维的"恢复线段"是小麻烦,三维的"恢复三角面片"是核心难题 ;Gmsh 为此 引入了 TetGen 风格的边界恢复(meshGRegionBoundaryRecovery.cpp),并在这套恢复之上叠加了 多个点插入内核(对象式 MTet4、平面数组 flat、并行 HXT/pdel3d)。

三维网格的入口在第 4 章看到的 Mesh3D(src/mesh/Generator.cpp 第 958 行)。它把三维工作分成三段:

cpp 复制代码
// mesh the extruded volumes first
std::for_each(m->firstRegion(), m->lastRegion(), meshGRegionExtruded());

// then subdivide if necessary (unfortunately the subdivision is a
// global operation, which can require changing the surface mesh!)
SubdivideExtrudedMesh(m);

// then mesh all the non-delaunay regions (front3D with netgen)
std::vector<GRegion *> delaunay;
std::for_each(m->firstRegion(), m->lastRegion(), meshGRegion(delaunay));

// and finally mesh the delaunay regions (again, this is global; but
// we mesh each connected part separately for performance and mesh
// quality reasons)
std::vector<std::vector<GRegion *>> connected;
FindConnectedRegions(delaunay, connected);

这段注释本身就是三维调度哲学的自白:拉伸体最先做(结构化、便宜);拉伸体细分是全局 操作(可能反过来改曲面网格,第 4 章提过);Netgen 前沿单独分流;剩下的 Delaunay 类算法 按连通分量分组,逐组调用 MeshDelaunayVolume。

6.1 算法枚举与分派

6.1.1 algo3d 枚举

三维体网格算法由 CTX::instance()->mesh.algo3d 选择。枚举定义在 src/common/GmshDefines.h 第 250--257 行:

c 复制代码
// 3D meshing algorithms (numbers should not be changed)
#define ALGO_3D_DELAUNAY     1
#define ALGO_3D_INITIAL_ONLY 3
#define ALGO_3D_FRONTAL      4
#define ALGO_3D_MMG3D        7
#define ALGO_3D_RTREE        9
#define ALGO_3D_HXT          10
#define ALGO_3D_PDEL3D       11

注释 "numbers should not be changed" 是硬约束:这些数字会写进 .msh 文件、脚本与 GUI 状态, 一旦改动就破坏向后兼容(第 3 章讲过 Gmsh 对文件/脚本兼容性的执着)。注意编号是稀疏的 (缺 2、5、6、8)------那些编号要么是历史遗留(二维算法的 ALGO_2D_* 占据了邻近值),要么是 实验算法废弃后留下的空洞。紧邻的第 239--248 行正是二维枚举:

c 复制代码
#define ALGO_2D_MESHADAPT         1
#define ALGO_2D_AUTO              2
#define ALGO_2D_INITIAL_ONLY      3
#define ALGO_2D_DELAUNAY          5
#define ALGO_2D_FRONTAL           6
#define ALGO_2D_BAMG              7
...

可见二维的 2(AUTO)与三维缺失的 2 并非同源:三维的 2 曾对应 "New Delaunay", 现已并入 1(见 6.1.2)。

6.1.2 选项的注册、setter 与字符串映射

algo3d 是一个注册过的 option,其 setter 在 src/common/Options.cpp 第 6665 行:

cpp 复制代码
double opt_mesh_algo3d(OPT_ARGS_NUM)
{
  if(action & GMSH_SET) {
    if(!(action & GMSH_SET_DEFAULT) && (int)val != CTX::instance()->mesh.algo3d)
      Msg::SetOnelabChanged(2);                       // 通知 Onelab 参数系统
    CTX::instance()->mesh.algo3d = (int)val;
    // "New Delaunay" is now simply "Delaunay"
    if(CTX::instance()->mesh.algo3d == 2) CTX::instance()->mesh.algo3d = 1;
  }
#if defined(HAVE_FLTK)
  if(FlGui::available() && (action & GMSH_GUI)) {     // GUI 下拉框索引 ↔ 内部枚举
    switch(CTX::instance()->mesh.algo3d) { /* ... value(0..5) 逐一映射 ... */ }
  }
#endif
  return CTX::instance()->mesh.algo3d;
}

三件事同时发生:

  1. 脏标记 :值变化时 Msg::SetOnelabChanged(2) 通知 Onelab 参数系统(第 1 章讲过 Onelab 是 Gmsh 的参数/交互层),让 GUI 与参数树同步刷新。
  2. 历史兼容 :algo3d == 2 被静默改写为 1。这是"New Delaunay 现在是 Delaunay"的落地, 保证老脚本不会失效。
  3. GUI 往返 :FlGui 分支把内部枚举映射回下拉框索引------注意 GUI 里 Delaunay 被放在 index 0,而内部是 1;pdel3d 在 GUI 是 1,内部是 11。内部编号与 UI 顺序是两条独立序列, 这也是这个 switch 存在的原因。

默认值注册在 src/common/DefaultOptions.h 第 1208 行:

c 复制代码
{ F|O, "Algorithm3D" , opt_mesh_algo3d , ALGO_3D_DELAUNAY ,
  "3D mesh algorithm (1: Delaunay, 3: Initial mesh only, 4: Frontal, "
  "7: MMG3D, 9: R-tree, 10: HXT, 11: Parallel Delaunay" },

这里默认仍是 ALGO_3D_DELAUNAY。但要注意:在新版 Gmsh 中 HXT 已成为推荐/默认的高性能 路径(很多发行版通过编译期或脚本把默认切到 HXT);option 默认值只是"未设置时"的回退。

命令行 -algo 的字符串到枚举映射在 src/common/CommandLine.cpp 第 1014--1027 行:

cpp 复制代码
else if(argv[i] == "del3d" || argv[i] == "gmsh3d")
  opt_mesh_algo3d(0, GMSH_SET, ALGO_3D_DELAUNAY);
else if(argv[i] == "front3d" || argv[i] == "netgen")
  opt_mesh_algo3d(0, GMSH_SET, ALGO_3D_FRONTAL);
else if(argv[i] == "mmg3d")
  opt_mesh_algo3d(0, GMSH_SET, ALGO_3D_MMG3D);
else if(argv[i] == "rtree3d")
  opt_mesh_algo3d(0, GMSH_SET, ALGO_3D_RTREE);
else if(argv[i] == "hxt")
  opt_mesh_algo3d(0, GMSH_SET, ALGO_3D_HXT);
else if(argv[i] == "pdel3d")
  opt_mesh_algo3d(0, GMSH_SET, ALGO_3D_PDEL3D);
else if(argv[i] == "initial3d")
  opt_mesh_algo3d(0, GMSH_SET, ALGO_3D_INITIAL_ONLY);

于是 gmsh -3 -algo hxt、-algo pdel3d、-algo front3d 都对应到上面的枚举。CommandLine.cpp 第 1044 行还有一个组合快捷方式:

cpp 复制代码
else if(argv[i] == "-hybrid") {
  opt_mesh_recombine3d_all(0,GMSH_SET, 1);
  opt_mesh_algo3d(0, GMSH_SET, ALGO_3D_RTREE);
  opt_mesh_algo2d(0, GMSH_SET, ALGO_2D_PACK_PRLGRMS);
  i++;
}

-hybrid 一次性把三维算法设为 R-tree(DOMHEX)、要求全体重组合、二维用平行六面体堆叠 (PACK_PRLGRMS)------这是生成六面体主导网格的"一条指令",把三个旋钮绑在一起(见 6.10 与 第 7 章)。

除了算法枚举,三维还有一大批与之搭配的开关,散落在 Context.h 与 DefaultOptions.h 中。 Context.h 第 26、38 行给出结构成员:

cpp 复制代码
double optimizePyramids;                        // 金字塔优化强度/开关
int nbSmoothing, algo2d, algo3d, algoSubdivide, algoSwitchOnFailure;

与本章直接相关的几个选项(名字即语义,后文逐一用到):

选项 作用 本章出现处
Mesh.Algorithm3D 选算法(上表枚举) 6.1
Mesh.OptimizePyramids 是否/如何生成并优化金字塔;>= -2 决定是否启用 splitQuadRecovery 6.3、6.10
Mesh.Recombine3DAll 是否对所有体做六面体重组合 6.10
Mesh.MaxIterDelaunay3D 点插入最大迭代轮数 6.4
Mesh.FlatRefine3D 选 flat(默认)还是 MTet4 点插入内核 6.4、6.5
Mesh.RandomFactor3D del3d/pdel3d 的坐标扰动量(见 6.2.5、6.7) 6.2、6.7
Mesh.Mmg3dCombineDomains MMG3D 是否合并多体域一起适应 6.8
Mesh.ToleranceInitialDelaunay 传给 HXT/TetGen 的初始 Delaunay 容差 6.6
Mesh.MaxNumThreads3D 三维阶段线程数上限 6.6、6.7
Mesh.CompoundClassify 复合面是否参与重新分类 6.3.4

6.1.3 顶层分派:MeshDelaunayVolume

所有"Delaunay 类"算法最终都从 MeshDelaunayVolume 进入( src/mesh/meshGRegion.cpp 第 337 行)。它是三维分派的枢纽:

cpp 复制代码
void MeshDelaunayVolume(std::vector<GRegion *> &regions)
{
  if(regions.empty()) return;

  if(CTX::instance()->mesh.algo3d == ALGO_3D_HXT) {
    if(meshGRegionHxt(regions) != 0) { Msg::Error("HXT 3D mesh failed"); }
    return;                             // HXT 独占整条路径
  }

  if(CTX::instance()->mesh.algo3d == ALGO_3D_PDEL3D) {
    int ret = meshGRegionParallelDelaunay(regions);
    if(ret == 1) Msg::Error("Parallel Delaunay 3D mesh failed");
    if(ret != 2) return;
    Msg::Warning("Falling back to Delaunay (del3d)");   // ret == 2 → 回退顺序版
  }
  if(CTX::instance()->mesh.algo3d != ALGO_3D_RTREE &&
     CTX::instance()->mesh.algo3d != ALGO_3D_DELAUNAY &&
     CTX::instance()->mesh.algo3d != ALGO_3D_PDEL3D &&
     CTX::instance()->mesh.algo3d != ALGO_3D_INITIAL_ONLY &&
     CTX::instance()->mesh.algo3d != ALGO_3D_MMG3D)
    return;

  GRegion *gr = regions[0];
  splitQuadRecovery sqr(CTX::instance()->mesh.optimizePyramids >= -2);
  std::vector<GFace *> allFaces;
  bool success;
  {
    // the recovery works on regions[0] with the boundary of the whole group
    regionGroupBoundary group(regions);
    allFaces = group.allFaces;
    success = meshGRegionBoundaryRecovery(gr, &sqr);
  }
  ...
}

这段代码把三维策略梳理得非常清晰:

  1. HXT 独占 :选 HXT 就调用并 return。HXT 自带边界恢复、细化与优化(见 6.6), 完全不经过 Gmsh 自己的 del3d 管线。失败只报错、不回退(HXT 是"要么成、要么停")。
  2. pdel3d 有回退 :并行 Delaunay 的返回值有三态语义------0 成功、1 出错、2"输入不支持, 请回退"。只有 ret != 2 才 return;ret == 2 时打警告并继续往下走顺序 del3d。 这是"并行快,但失败要有顺序兜底"的稳健策略(第 6.7 节详解)。
  3. 一条共享管线 :Delaunay / RTREE / MMG3D / INITIAL_ONLY 共用"边界恢复 + 点插入"骨架。 注意 regionGroupBoundary group(regions) 的作用域被限制在一个 { } 块里------进入块时它把 regions[0] 的曲面/嵌入实体替换成整组的,出块时析构函数恢复原状(见 6.3.4)。

splitQuadRecovery sqr(...) 的构造参数 optimizePyramids >= -2 决定"是否要为四边形面预留 金字塔顶点";这一枚开关会在边界恢复和收尾两处反复出现(6.3.3、6.10)。

6.1.4 meshGRegion::operator() 的 Transfinite/Netgen/Delaunay 分流

在按连通分量调用 MeshDelaunayVolume 之前,Mesh3D 先对每个体执行 meshGRegion::operator()(src/mesh/meshGRegion.cpp 第 435 行):

cpp 复制代码
void meshGRegion::operator()(GRegion *gr)
{
  gr->model()->setCurrentMeshEntity(gr);

  if(gr->isFullyDiscrete()) return;
  if(gr->meshAttributes.method == MESH_NONE) return;
  if(CTX::instance()->mesh.meshOnlyVisible && !gr->getVisibility()) return;
  if(CTX::instance()->mesh.meshOnlyEmpty && gr->getNumMeshElements()) return;

  ExtrudeParams *ep = gr->meshAttributes.extrude;
  if(ep && ep->mesh.ExtrudeMesh) return;      // 拉伸体已由 meshGRegionExtruded 处理

  // destroy the mesh if it exists
  deMeshGRegion dem;
  dem(gr);

  if(MeshTransfiniteVolume(gr)) return;       // Transfinite 优先

  if(CTX::instance()->mesh.algo3d != ALGO_3D_FRONTAL) {
    delaunay.push_back(gr);                   // 收集进 Delaunay 队列,稍后统一处理
  }
  else if(CTX::instance()->mesh.algo3d == ALGO_3D_FRONTAL) {
    meshGRegionNetgen(gr);                    // ALGO_3D_FRONTAL → Netgen 前沿推进
  }
}

分流规则可以画成一张判定表:

体的状态 处理
完全离散(isFullyDiscrete) 跳过(网格已存在,不做任何事)
method == MESH_NONE 跳过(用户声明"不网格化")
不可见 + 只看可见 / 非空 + 只网格空体 跳过(meshOnlyVisible/meshOnlyEmpty)
有拉伸参数(ExtrudeMesh) 跳过(拉伸体由 meshGRegionExtruded 先行处理)
MeshTransfiniteVolume 成功 返回(结构化剖分完成)
algo3d != FRONTAL 压入 delaunay 队列(含 del3d/HXT/pdel3d/MMG3D/RTREE/INITIAL_ONLY)
algo3d == FRONTAL 立即 meshGRegionNetgen(gr)

两个微妙点:

  • deMeshGRegion dem; dem(gr); 无条件删除已有网格 (除 isFullyDiscrete 外), 因为重新分派算法前必须清空旧的体单元,否则会与新算法冲突。deMeshGRegion(第 429 行) 本身对完全离散体直接返回------它与前面 isFullyDiscrete 的检查互为双保险。
  • delaunay.push_back(gr) 只是"登记" ,真正网格化在 Mesh3D 里按连通分量统一进行。 这正是"全局操作要分组做"的体现:边界恢复与点插入对整组共享边界更高效、且能保证共形。

于是三维算法可归为四条路线:

  1. 结构化路线 :Transfinite(MeshTransfiniteVolume)与 Extruded(meshGRegionExtruded + SubdivideExtrudedMesh)。拓扑与质量由几何/参数决定,不依赖 Delaunay(6.11)。
  2. Delaunay 自由网格路线:del3d(顺序)、HXT(并行)、pdel3d(Gmsh 自研并行)、MMG3D (各向异性适应)。共享边界恢复骨架(6.2--6.9)。
  3. Netgen 前沿路线 :ALGO_3D_FRONTAL → front3D(6.9)。
  4. DOMHEX 六面体路线 :ALGO_3D_RTREE → 八叉树/背景网格点插入 + meshCombine3D + 三面体过渡(6.10)。

6.1.5 能力矩阵

把这些引擎摊开对比,能看清 Gmsh 为什么需要同时维护这么多实现:

引擎 并行 边界只能是三角形 各向异性 依赖宏 失败行为
del3d(顺序) 否 否(可含四边形→金字塔) 否(各向同性) 无 报错/留空
HXT 是(图着色) 是 否 HAVE_HXT 报错,不回退
pdel3d 是(Moore+分区) 否 否 无 ret==2 回退 del3d
MMG3D 否(禁并发) 否 是(张量度量) HAVE_MMG 报错
Netgen 否 是 否 HAVE_NETGEN 报错
RTREE/DOMHEX 有限 否 否 HAVE_DOMHEX(+HAVE_HXT) 跳过

这张表解释了 MeshDelaunayVolume 里那些看似啰嗦的分支:每个引擎的能力边界不同,Gmsh 用 "能力矩阵 + 回退链"把它们包在统一入口下。第 7 章会看到优化阶段也遵循同一模式。

6.2 三维 Delaunay 的核心概念与流程

6.2.1 del3d 文件头:四步走

三维 Delaunay 与二维同源,但难度陡增。meshGRegionDelaunay.cpp 的文件头注释( src/mesh/meshGRegionDelaunay.cpp 第 6--14 行)给出了 del3d 的完整流程:

The sequential 3D Delaunay mesher (del3d): the circumcenter of a tetrahedron, the tetrahedralization of a set of points, and filling a region with tetrahedra - the boundary mesh is recovered and classified, then points are inserted at the circumcenters of the tets that are too large. Two kernels do the insertion: the flat one, on index-based arrays, which is the default (Mesh.FlatRefine3D) and lives in meshGRegionDelaunayFlat.cpp, and the original one, on the MTet4 objects, which it is meant to replace and which is below. Optimizing a region is in meshGRegionOptimize.cpp; the parallel mesher (pdel3d) is in meshGRegionParallelDelaunay.cpp.

这段注释可以展开成四个步骤 + 一个说明:

  1. 四面体外接球心 (tetcircumcenter):整个 Delaunay 的"空球"判据围绕它展开。
  2. 点集的初始四面体剖分 (delaunayMeshIn3D):对区域边界的所有顶点做 Delaunay。
  3. 边界恢复(boundary recovery):把边界三角面片与曲线段"恢复"成四面体的面与边。
  4. 点插入(point insertion):在"过大"的四面体的外接球心处插入新点,直到满足尺寸场。

以及那句关键的架构说明:点插入有两套内核 ------平面数组 flat(默认,Mesh.FlatRefine3D) 与对象式 MTet4(正被取代);区域优化另有文件 (meshGRegionOptimize.cpp,第 7 章), 并行网格器是 pdel3d(6.7)。这一句几乎就是整个三维子系统的目录。

meshGRegionDelaunay.h 的注释进一步点明归属:数据结构与内核是内部实现(meshGRegionTet4.h 与 meshGRegion{Refine,Optimize}.cpp)------对象式内核的数据结构在 meshGRegionTet4.h (MTet4 工厂),flat 内核实现在 meshGRegionDelaunayFlat.cpp,优化在 meshGRegionOptimize.cpp。外部只通过 meshGRegionDelaunay.h 暴露的四个函数接触它们:

cpp 复制代码
double tetcircumcenter(double a[3], double b[3], double c[3], double d[3],
                       double circumcenter[3], double *xi, double *eta, double *zeta);

void delaunayMeshIn3D(std::vector<MVertex *> &v, std::vector<MTetrahedron *> &result,
                      bool removeBox = false,
                      std::vector<std::int64_t> *neighbors = nullptr);

void insertVerticesInRegion(GRegion *gr, int maxIter,
                            double worstTetRadiusTarget, bool _classify = true,
                            splitQuadRecovery *sqr = nullptr);

void optimizeMesh(GRegion *gr, const qmTetrahedron::Measures &qm);

外加两个辅助函数 classifyTetrahedraInRegions(分类)与 insertVertexInFaceTriangulation (把点重新塞回曲面三角剖分)。

6.2.2 外接球心:tetcircumcenter 与鲁棒 orient3d

tetcircumcenter(src/mesh/meshGRegionDelaunay.cpp 第 52 行)是纯粹的几何计算:以四面体顶点 a 为原点,用三对棱的叉积构造外接球心。核心是:

cpp 复制代码
/* Calculate the denominator of the formulae. */
/* Use orient3d() from http://www.cs.cmu.edu/~quake/robust.html     */
/*   to ensure a correctly signed (and reasonably accurate) result, */
/*   avoiding any possibility of division by zero.                  */
const double xxx = robustPredicates::orient3d(b, c, d, a);
denominator = 0.5 / xxx;

/* Calculate offset (from `a') of circumcenter. */
xcirca = (balength * xcrosscd + calength * xcrossdb + dalength * xcrossbc) * denominator;
ycirca = (balength * ycrosscd + calength * ycrossdb + dalength * ycrossbc) * denominator;
zcirca = (balength * zcrosscd + calength * zcrossdb + dalength * zcrossbc) * denominator;
circumcenter[0] = xcirca + a[0];
circumcenter[1] = ycirca + a[1];
circumcenter[2] = zcirca + a[2];

分母用 Shewchuk 的鲁棒有向体积谓词 orient3d 计算,而不是直接用行列式展开。原因有二 (注释写得明白):

  1. 符号正确 :数值退化时普通浮点行列式可能给出错误符号;orient3d 先试快速浮点估计, 只有当结果落在静态滤波阈值内才升级到自适应精确算术,从而给出精确符号。
  2. 避免除零 :若四面体退化(四点共面),orient3d 精确返回 0,此时该四面体本就无外接球心 (denominator = 0.5 / 0 = inf),上层会据此认为该四面体不可用。

函数返回值就是 xxx(== 2 × 有向体积),调用方可据此判断方向。可选参数 xi/eta/zeta 用 Cramer 法则解出球心在 a→b、a→c、a→d 坐标系下的重心坐标,用于在点插入时把尺寸场 插值到球心(xi*... 三元组的权重,见 6.4)。谓词库头文件 src/numeric/robustPredicates.h 说明:

cpp 复制代码
namespace robustPredicates {
void exactinit(double maxx, double maxy, double maxz);
// static filters of orient3d() and insphere(), set by exactinit(): a plain
// floating-point determinant larger than them in magnitude has the right sign
extern double o3dstaticfilter, ispstaticfilter;

double insphere(const double* const __restrict__ pa, ...);
double orient3d(const double* const __restrict__ pa, const double* const __restrict__ pb,
                const double* const __restrict__ pc, const double* const __restrict__ pd);
}

命名空间 robustPredicates 的存在理由注释也给了:"to avoid conflicts with predicates used by Tetgen"------TetGen 也带一套 Shewchuk 谓词,边界恢复那段代码(6.3)会把 TetGen 的 谓词编译进来,两者必须用命名空间隔开。

6.2.3 空球判据与符号扰动 insphereTest_s

点插入的核心判定是"点 e 是否落在四面体 (a,b,c,d) 的外接球内"。实现 inSphereTest_s(第 194 行)调用 insphere:

cpp 复制代码
static bool inSphereTest_s(Vert *va, Vert *vb, Vert *vc, Vert *vd, Vert *ve)
{
  double val = robustPredicates::insphere(
    (double *)va, (double *)vb, (double *)vc, (double *)vd, (double *)ve);
  if(val == 0.0) {
    Msg::Info("Symbolic perturbation needed vol %22.15E",
              orientationTestFast((double *)va, (double *)vb, (double *)vc, (double *)vd));
    // symbolic perturbation: 五点按编号(vertLess)排序,用交换次数奇偶性修正符号
    Vert *pt[5] = {va, vb, vc, vd, ve};
    int count, swaps = 0, n = 5;
    do {
      count = 0; n = n - 1;
      for(int i = 0; i < n; i++)
        if(vertLess(pt[i + 1], pt[i])) { std::swap(pt[i], pt[i + 1]); count++; }
      swaps += count;
    } while(count > 0);
    double oriA = robustPredicates::orient3d((double *)pt[1], (double *)pt[2],
                                             (double *)pt[3], (double *)pt[4]);
    if(oriA != 0.0) { if(swaps % 2) oriA = -oriA; val = oriA; }
    else {
      double oriB = -robustPredicates::orient3d((double *)pt[0], (double *)pt[2],
                                                (double *)pt[3], (double *)pt[4]);
      if(oriB == 0.0) Msg::Error("Symbolic perturbation failed in icCircle Predicate");
      if(swaps % 2) oriB = -oriB; val = oriB;
    }
  }
  return val > 0;
}

这是共球退化(四点共球,第五点恰在球面上)时的处理,代码注释直白:"Symbolic perturbation needed"。要点:

  • insphere 精确返回 0,意味着五个点共球,空球判据无法给出确定答案。
  • 此时用符号扰动 (SoS,Simulation of Simplicity 的简化版):把五个顶点按 vertLess (即顶点编号,_num)排序,用排序的"交换次数奇偶性"去修正符号,再退而用一次/二次 orient3d 来打破平局。
  • vertLess 比较的是顶点编号而非指针 ------这是本节最容易被忽略、却最要紧的一处工程修正。 注释(第 184--188 行)说:"Comparing pointers made both depend on where the allocator put each vertex, so cospherical configurations were resolved differently from run to run." 用 指针排序会让结果依赖内存分配地址,同一输入每次运行结果不同 ;改用唯一编号后, 符号扰动、面键(Face)排序全部确定化,结果可复现。

这个细节与后续 HXT 的 options.reproducible(6.6)是同一种追求:几何算法必须可复现, 否则并行/优化结果无法比对、回归测试无从谈起。

6.2.4 初始四面体剖分:数据结构、Hilbert 排序、包围盒

第 123--126 行的注释概述了初始四面体化:

an incremental Bowyer-Watson insertion with its own vertex and tetrahedron structures, the points sorted along a Hilbert curve in coarse-to-fine rounds, inside a box of 8 extra vertices

四个要点:增量 Bowyer-Watson 、自带顶点/四面体结构 、Hilbert 曲线排序 + 粗到细多轮 、 8 个额外顶点构成的包围盒。

顶点与四面体结构

在匿名命名空间里,Gmsh 自建了一套轻量结构 Vert 与 Tet(第 129--290 行):

cpp 复制代码
struct Vert {
  double _x[3];
  double _lc;          // 该点目标尺寸
  std::size_t _num;    // 唯一编号(符号扰动/面键排序依赖它)
  inline std::size_t getNum() const { return _num; }
  inline double x() const { return _x[0]; }
  inline operator double *() { return _x; }   // 可直接当 double[3] 传给谓词
  Vert(double X = 0, double Y = 0, double Z = 0, double lc = 0, int num = 0)
    : _num(num) { _x[0] = X; _x[1] = Y; _x[2] = Z; _lc = lc; }
};

operator double *() 是关键:Vert 可以直接当 double[3] 传给 robustPredicates, 于是谓词调用无需拷贝坐标。

cpp 复制代码
struct Tet {
  Tet *T[4];          // 四个邻居
  Vert *V[4];         // 四个顶点(顶点 i 对面的邻居是 T[i])
  // circumcenter, squared circumradius and roundoff bound, cached when the
  // vertices are set so that most in-sphere tests are a simple distance
  // comparison; sphTol = 1e300 表示缓存不可信、必须走精确谓词
  double cc[3], r2, sphTol;
  bool _cavity;       // 空腔搜索的访问标记
};

T[4] 是四个邻居、V[4] 是四个顶点(约定顶点 i 对面的邻居是 T[i])。cc/r2/sphTol 是缓存 :顶点确定时算一次外接球心与半径平方,之后绝大多数空球测试退化成一次距离比较 (|p - cc|² < r²),只有接近边界(sphTol 所刻画的舍入界)时才回到精确谓词。这是三维 Delaunay 性能的主要来源之一。

Hilbert 曲线排序

HilbertSortB(第 460 行)实现空间填充曲线排序,思路与 TetGen 一致------注释引用了 graphics.stanford.edu/~seander/bithacks.html(位运算技巧)与 Gray 码:

cpp 复制代码
struct HilbertSortB {
  // transgc 表由 http://graphics.stanford.edu/~seander/bithacks.html 的位运算技巧生成
  int transgc[8][3][8];  int tsb1mod3[8];
  int maxDepth, Limit;
  SBoundingBox3d bbox;
  void ComputeGrayCode(int n);
  int Split(Vert **vertices, int arraysize, int GrayCode0, int GrayCode1, ...);
  void Sort(Vert **vertices, int arraysize, int e, int d, ..., int depth);
  // sort a fraction "ratio" of the points first (recursively), then the rest:
  // points are inserted in rounds of increasing density, each round being
  // itself Hilbert-ordered (biased randomized insertion order)
  void MultiscaleSortHilbert(Vert **vertices, int arraysize, int threshold, double ratio)
  {
    int middle = 0;
    if(arraysize >= threshold) {
      middle = (int)(arraysize * ratio);
      MultiscaleSortHilbert(vertices, middle, threshold, ratio);
    }
    Sort(&(vertices[middle]), arraysize - middle, 0, 0, bbox.min().x(),
         bbox.max().x(), bbox.min().y(), bbox.max().y(), bbox.min().z(),
         bbox.max().z(), 0);
  }
  void Apply(std::vector<Vert *> &v)
  {
    if(v.empty()) return;
    for(size_t i = 0; i < v.size(); i++) bbox += SPoint3(v[i]->x(), v[i]->y(), v[i]->z());
    bbox *= 1.01;
    MultiscaleSortHilbert(&v[0], (int)v.size(), 64, .125);   // 粗到细多轮
  }
};

要点:

  • Hilbert 曲线排序的目的 :让空间相邻的点在插入顺序上也相邻,于是每一次"定位包含新点的 四面体"的行走(walk)都从很近的地方开始,空腔(cavity)也局部化,缓存友好、速度快。
  • MultiscaleSortHilbert(..., ratio = .125) 实现粗到细多轮:先把 12.5% 的点按 Hilbert 序排好并递归(越来越小的一部分先行),再排剩下的。即"先稀疏地铺一层,再逐步加密", 避免一开始就陷入局部密集区。
  • bbox *= 1.01 轻微放大包围盒,保证 Hilbert 编号的边界点也落在盒内。
初始包围盒:8 个额外顶点

initialCube(第 1099 行)用一个包住所有点的立方体(8 个顶点)起手,构造 6 个四面体:

cpp 复制代码
static void initialCube(std::vector<Vert *> &v, Vert *box[8], tetContainer &allocator)
{
  SBoundingBox3d bbox;
  for(size_t i = 0; i < v.size(); i++) bbox += SPoint3(v[i]->x(), v[i]->y(), v[i]->z());
  bbox *= 1.3;                       // 放大 30%:确保所有点严格在盒内
  for(int c = 0; c < 8; c++)
    box[c] = new Vert(/* min/max 组合 */, bbox.diag());   // 8 个角点,lc = 对角线长
  // unique numbers after the input vertices (1..N), for vertLess
  for(int i = 0; i < 8; i++) box[i]->setNum(v.size() + i + 1);
  Tet *t0 = allocator.newTet();
  t0->setVertices(box[7], box[2], box[3], box[1]);       // 共 6 个初始四面体,略
  ...
}

box *= 1.3 与"8 个顶点编号排在输入点之后"是两处刻意的设计:放 30% 保证初始立方体把凸包 完全包住,编号排在后面保证 vertLess 里输入点永远排在盒顶点之前(符号扰动的确定性)。

6.2.5 空腔修复与星形性(P.L. George 的三维改进)

边界恢复之前的初始四面体化(以及点插入期间)都会遇到非星形空腔问题。第 727--730 行注释 引用了 P.L. George 的论文:

cpp 复制代码
// Fixing a non star shaped cavity (non delaunay triangulations). See
// P.L. George's paper "Improvements on Delaunay-based three-dimensional
// automatic mesh generator", Finite Elements in Analysis and Design 25 (1997)
// 297-317

所谓空腔星形性 :删除空腔内旧四面体后,空腔边界上的每个三角面是否都能从新插入点 "看见"(即新点与该面构成正体积四面体)。若不能,就必须继续删除"阻挡视线"的孤立四面体, 重建 Delaunay 球,直到星形性成立。判定函数 starShapeness:

cpp 复制代码
static void starShapeness(Vert *v, connContainer &bndK,
                          std::vector<std::size_t> &_negatives)
{
  _negatives.clear();
  for(std::size_t i = 0; i < bndK.size(); i++) {
    // no symbolic perturbation
    const double val = robustPredicates::orient3d(
      (double *)bndK[i].f.V[0], (double *)bndK[i].f.V[1],
      (double *)bndK[i].f.V[2], (double *)v);
    if(val <= 0.0) { _negatives.push_back(i); }
  }
}

orient3d(面三点, v) <= 0 即"该面从 v 看不见",记入 _negatives。修复循环 fixDelaunayCavity(第 814 行)反复执行:

cpp 复制代码
static bool fixDelaunayCavity(Vert *v, cavityContainer &cavity,
                              connContainer &bndK, std::vector<std::size_t> &_negatives)
{
  starShapeness(v, bndK, _negatives);
  if(_negatives.empty()) return false;              // 已星形,无需修复
  Tet *containsV = tetContainsV(v, cavity);
  if(!containsV) return true;                       // 放弃:v 不在任何旧四面体内
  while(!_negatives.empty()) {
    for(std::size_t i = 0; i < _negatives.size(); i++) {   // 删掉"挡视线"的孤立四面体
      conn &c = bndK[_negatives[i]];
      Tet *toRemove = tetInsideCavityWithFAce(c.f, cavity);
      if(toRemove) { ... cavity.erase(it); }
    }
    removeIsolatedTets(containsV, cavity, bndK);
    buildDelaunayBall(cavity, bndK);                // 重建空腔边界
    starShapeness(v, bndK, _negatives);             // 再判
  }
  return false;
}

这是三维 Delaunay 相比二维最大的复杂性来源之一:二维删掉一组三角形后,空腔边界相对新点 天然是"凸多边形围绕"(除非输入退化),星形性几乎自动成立;三维则经常出现"新点看不到某些 边界面"的情况,必须迭代删面-删体-重建,才能把空腔修成星形。

修复完成后,delaunayInsert(第 993 行)用空腔边界面的每一条边去配对新四面体之间的邻接 (ballEdge 结构 + 排序配对,见 6.2.6),完成新点的重新连接:

cpp 复制代码
// insert v: find the tet containing it, carve out the Delaunay cavity and
// retriangulate the cavity onto v. seed is the tet the walk starts from, and
// is updated to a tet of the new ball. Returns false when the cavity could
// not be made star-shaped, in which case v is not inserted.
static bool delaunayInsert(Vert *v, Tet *&seed, tetContainer &allocator,
                           insertionBuffers &buf)
{
  // in 3D, inserting a point may delete tets, so the seed may be dead
  if(!seed->V[0]) seed = randomTet(allocator);
  Tet *t;
  while(1) {
    t = walk(seed, v);
    if(t) break;
    // the domain may not be convex: restart the walk from a random tet
    seed = randomTet(allocator);
  }
  ...
  if(fixDelaunayCavity(v, cavity, bnd, buf.negatives)) return false;
  ...
}

注意"in 3D, inserting a point may delete tets, so the seed may be dead"------三维插入会删掉 旧四面体,行走起点可能已死 ,因此每轮都要检查 seed->V[0] 并可能改用 randomTet。这在 二维不常见,是三维特有的。

6.2.6 点插入的数据结构:ballEdge 配对与缓冲复用

delaunayInsert 的后半段(第 1024--1081 行)把空腔边界面的每一条边登记为一个 ballEdge (记 {a, b, 4*新四面体序号+面号}),排序 后把共享同一条边的两个新面排到一起,从而 O(k log k) 配对邻接,省去二次查找;空腔里被删四面体的内存槽被新四面体复用 (Tet *nt = (i < cSize) ? cavity[i] : allocator.newTet();),未复用的槽标记为"已死" (cavity[i]->V[0] = nullptr)。insertionBuffers(第 981 行)把 cavity/bnd/negatives/ ballEdges/newTets 等临时数组提到插入例程之外只分配一次------逐点插入是"百万次调用", 缓冲复用是必要优化。

6.3 边界恢复:三维最棘手的环节

6.3.1 问题的本质

二维的边界恢复(第 5 章)是把线段恢复成三角形边;三维则要把三角面片恢复成四面体的面 、 曲线段恢复成四面体的边 、嵌入点恢复成四面体的顶点。难点在于:

  • 初始 Delaunay 四面体化只保证空球性质,不保证给定的边界面片一定出现。一个边界三角形 可能被若干不相交的四面体"穿过",而它的三条边都不在四面体网格里。
  • 单纯靠边翻转(edge flip)往往不够 :当约束面的两侧没有足够的可翻转四面体时,必须 插入新的点(Steiner 点)来打通。这是三维与二维最本质的差别------二维总能靠翻转恢复线段, 三维不行。
  • 恢复还必须兼顾共形:相邻两个体共享的曲面必须被同一套三角形恢复,否则两组网格在界面上 不共形。

Gmsh 的实现选择移植 TetGen 的算法 (平面数组版)。头文件 src/mesh/meshGRegionBoundaryRecovery.h 用两个 结构体定义输入输出:

cpp 复制代码
// A Delaunay tetrahedralization of the vertices of the boundary mesh of a
// region, computed by the caller: tetNode holds 4 positions in vertices per
// tet, and neighbors[4 * t + f] the tet across the facet opposite node f, or -1
// on the convex hull
struct initialTetrahedralization {
  std::vector<MVertex *> vertices;
  std::vector<std::uint32_t> tetNode;
  std::vector<std::int64_t> neighbors;
};

// The boundary recovery on flat arrays: the tetrahedralization of the
// vertices (4 positions per tet in tetNode, tetNeighbors[4 * t + f] the tet
// across the facet opposite node f or -1), the triangles and segments to
// recover with an integer tag each (the tag of their surface or curve; -1 for
// a triangle edge)
struct boundaryRecoveryInput {
  std::vector<double> xyz; // 3 per vertex
  std::vector<std::uint32_t> tetNode;
  std::vector<std::int64_t> tetNeighbors;
  std::vector<std::uint32_t> triNode;
  std::vector<int> triTag;
  std::vector<std::uint32_t> segNode;
  std::vector<int> segTag;
  // remove the tets outside the surfaces (only for a closed surface mesh)
  bool carve = true;
  // TetGen's Delaunay recovery and optimization of the whole mesh afterwards
  bool postprocess = true;
  bool verbose = true;
  // the mesh is a cavity rather than a convex hull: the constraints present
  // in it are bonded before the recovery, which walks only for the missing
  // ones, and the walks may leave the mesh. triTet[i] is then the tet holding
  // triangle i (-1: none, to be recovered)
  bool nonconvex = false;
  std::vector<std::int64_t> triTet;
  // the dihedral angle (degrees) below which two facets sharing an edge are
  // reported as overlapping; negative: Mesh.AngleToleranceFacetOverlap
  double overlapAngleTolerance = -1.;
};

// The result: the Steiner points (vertex numVertices + k for the k-th one;
// type 0 in the volume, 1 on a segment, 2 on a facet, with the tags of that
// segment and of a facet at it), the tets (without the hull tets), the
// triangles and segments as recovered (split at the Steiner points) with
// their tags, and the tags of the facets and segments that received Steiner
// points
struct boundaryRecoveryOutput {
  std::vector<double> steinerXYZ;
  std::vector<int> steinerType, steinerSegTag, steinerFaceTag;
  std::vector<std::uint32_t> tetNode;
  std::vector<std::uint32_t> triNode;
  std::vector<int> triTag;
  std::vector<std::uint32_t> segNode;
  std::vector<int> segTag;
  std::set<int> changedFaces, changedEdges;
};

这套接口的设计精髓在于用"平面数组 + 整数标签"描述一切,而不依赖对象指针:

  • tetNode/tetNeighbors :四面体的四个顶点位置 + 四个邻居四面体(neighbors[4*t+f] 为面 f 对面的四面体,-1 表示凸包边界),全部整数,便于缓存与并行。
  • triTag/segTag :每个待恢复三角/线段携带其几何来源标签 (所属曲面/曲线的 tag, 三角形内部边为 -1)。恢复后这些标签决定网格元素如何分类回几何实体。
  • carve:恢复后把曲面之外的四面体删掉(闭曲面情形,得到区域内部)。
  • nonconvex :处理"网格本身是个空腔"的情形(如带孔洞的体);此时约束在恢复前先绑定 (已知在网中的三角被标记 triTet[i] >= 0),恢复只处理缺失的部分,且遍历可能离开网格。
  • overlapAngleTolerance :面重叠判定的二面角阈值;默认取 Mesh.AngleToleranceFacetOverlap。
  • Steiner 点 :当单纯边翻转无法恢复约束时插入的新点。输出用 steinerType 区分它插在 体内(0)、曲线上(1)还是面上(2),并附带曲线/曲面标签------这决定它被归到哪个几何实体。

6.3.2 总入口 meshGRegionBoundaryRecovery

meshGRegionBoundaryRecovery(src/mesh/meshGRegionBoundaryRecovery.cpp 第 94 行声明、第 955 行定义)是边界恢复的总入口。头文件注释说明了它的完整职责:

Recover the boundary mesh of gr (its surfaces, embedded curves and points) in a Delaunay tetrahedralization of its vertices with TetGen's algorithm, inserting Steiner points if needed (the curve and surface meshes are then updated, and the new boundary vertices added to their curve or surface). The tetrahedra are stored in gr->tetrahedra. The initial tetrahedralization is computed here unless init is given: the tetrahedra then cover its convex hull, otherwise those outside the surfaces are removed

注意它同时恢复曲面、嵌入曲线、嵌入点------这正是第 2 章"嵌入实体"机制在三维网格层的落地: 用户把一条曲线嵌入体中,边界恢复就必须让这条曲线成为四面体边(即一组四面体边的集合)。

入口先收集顶点(第 959 行起):

cpp 复制代码
bool meshGRegionBoundaryRecovery(GRegion *gr, splitQuadRecovery *sqr,
                                 const initialTetrahedralization *init)
{
  std::vector<MVertex *> _vertices;
  if(init) { _vertices = init->vertices; }
  else {
    std::set<MVertex *, MVertexPtrLessThan> all;
    for(GFace *gf : gr->faces()) {               // 1) 所有曲面的三角形顶点
      for(std::size_t i = 0; i < gf->triangles.size(); i++) { all.insert(v0); all.insert(v1); all.insert(v2); }
      if(sqr) for(std::size_t i = 0; i < gf->quadrangles.size(); i++) {
        // 2) 四边形:doWeCreatePyramids() 时在中心(必要时沿法向偏移)造尖顶顶点并
        //    登记 sqr->add(mf, newv, gf);否则只登记"拆成两个三角形"
        MFace mf = gf->quadrangles[i]->getFace(0);
        ...
      }
    }
    for(GEdge *ge : gr->embeddedEdges())         // 3) 嵌入曲线顶点必须在点集中
      for(std::size_t i = 0; i < ge->lines.size(); i++) {
        all.insert(ge->lines[i]->getVertex(0)); all.insert(ge->lines[i]->getVertex(1));
      }
  }
  ...

顶点集合的构造顺序值得注意:

  1. 先从所有曲面的三角形取顶点------这是边界网格的全部顶点。
  2. 若启用 sqr(optimizePyramids >= -2),额外处理四边形 :
    • doWeCreatePyramids() 为真时,在四边形中心(必要时沿法向偏移 fact × 对角长)造一个 新顶点 当作金字塔尖顶,登记进 sqr->add(mf, newv, gf),并把它一并纳入初始点集。 optimizePyramids < 0 时"推入"体内部,>= 0 时就在中心;幅度由 |optimizePyramids| 决定。
    • doWeCreatePyramids() 为假时,sqr->add(mf, nullptr, gf) 只是记录这条四边形要被拆成 两个三角形 (尖顶为 nullptr)。
  3. 再把嵌入曲线的顶点纳入------它们必须在初始点集中,否则无法恢复成四面体边。

随后调用 TetGen 的核心 runCore(in, out)(第 1101 行),成功后把结果写回 GRegion:

cpp 复制代码
const int err = runCore(in, out);
bool ret = false;
if(err == 0) {
  // Write mesh into to GRegion.
  std::vector<MVertex *> extras;
  std::map<int, GEdge *> curves;
  for(GEdge *ge : e_list) curves[ge->tag()] = ge;
  std::map<int, GFace *> surfaces;
  for(GFace *gf : f_list) surfaces[gf->tag()] = gf;
  for(std::size_t k = 0; k < out.steinerType.size(); k++) {
    MVertex *v = newSteinerVertex(out, k, curves, surfaces, gr);
    v->setIndex((long)(nv + k + 1));
    extras.push_back(v);
  }
  ...

err 的语义与 boundaryRecoveryFlat 一致:0 成功、1 内存不足、3 输入非法 (头文件第 77 行说明:"returns 0 on success, TetGen's error code otherwise (1: out of memory, 3: invalid input)")。失败时会走"重试/降级"分支(第 1172 行起),例如内存不足时打印提示。

6.3.3 Steiner 点:类型、落点与网格重建

Steiner 点的落点由 newSteinerVertex(第 1312 行)决定------它必须落在正确的几何实体上, 否则会破坏与几何的关联:

cpp 复制代码
MVertex *newSteinerVertex(const boundaryRecoveryOutput &out, std::size_t k,
                          const std::map<int, GEdge *> &curves,
                          const std::map<int, GFace *> &surfaces, GRegion *gr)
{
  const double *x = &out.steinerXYZ[3 * k];
  ...
  if(out.steinerType[k] == 1) {                 // 在曲线上
    auto it = curves.find(out.steinerSegTag[k]);
    ...
  }
  if(!ge && out.steinerType[k] > 0 && out.steinerFaceTag[k] >= 0) {  // 在曲面上
    auto it = surfaces.find(out.steinerFaceTag[k]);
    ...
  }
  // 否则落在体内,归 gr
}

逻辑是:

steinerType 落点 归属实体
0 体内部 GRegion gr
1 曲线上 GEdge(按 steinerSegTag 查)
2 曲面上 GFace(按 steinerFaceTag 查)

只有落点信息完整时才能正确归属;若曲线/曲面标签缺失,则降级到区域本身。这些新顶点会被 gr->mesh_vertices.push_back 记录,并成为四面体的真实角点。

插入 Steiner 点会改动边界网格:被插入点的曲线/曲面必须重建其线段/三角形剖分。代码(第 1124 行)直接删掉旧元素再按输出重建:

cpp 复制代码
// the segment and surface meshes with Steiner points are re-created
for(auto etag : out.changedEdges) {          // 接受 Steiner 点的曲线:删旧线段、按 out.segNode 重建
  GEdge *ge = <find by tag>;
  for(auto l : ge->lines) delete l;
  ge->lines.clear();  ge->deleteVertexArrays();
  for(std::size_t i = 0; i < out.segTag.size(); i++)
    if(out.segTag[i] == etag)
      ge->lines.push_back(new MLine(vertexOf(out.segNode[2*i]), vertexOf(out.segNode[2*i+1])));
}
for(auto ftag : out.changedFaces) {          // 接受 Steiner 点的曲面:删旧三角、按 out.triNode 重建
  GFace *gf = <find by tag>;
  for(auto t : gf->triangles) delete t;
  gf->triangles.clear();  gf->deleteVertexArrays();
  if(gf->quadrangles.size()) Msg::Warning("Steiner points not handled for quad surface mesh");
  for(std::size_t i = 0; i < out.triTag.size(); i++)
    if(out.triTag[i] == ftag) gf->triangles.push_back(new MTriangle(vertexOf(out.triNode[3*i]), ...));
}

changedEdges/changedFaces 就是"接受了 Steiner 点的曲线/曲面标签集合"。注意那句 警告:四边形曲面网格上的 Steiner 点没有处理------这再次印证"三角形是三维恢复的通用货币" (与 HXT 只吃三角形、Netgen 只吃三角形一致)。

vertexOf 是一个 Lambda,把输出里的整数索引映射回 MVertex*(前 nv 个是原顶点,之后是 Steiner 点);extras 就是新增的 Steiner 顶点。最后:

cpp 复制代码
for(std::size_t t = 0; t < out.tetNode.size() / 4; t++)
  gr->tetrahedra.push_back(new MTetrahedron(
    vertexOf(out.tetNode[4 * t]), vertexOf(out.tetNode[4 * t + 1]),
    vertexOf(out.tetNode[4 * t + 2]), vertexOf(out.tetNode[4 * t + 3])));

此时 gr->tetrahedra 里是仅由边界顶点 + Steiner 点构成、且已恢复全部约束的四面体------它 覆盖了区域内部,但还很"粗",远未满足尺寸场。细化交给 6.4 的点插入。

6.3.4 splitQuadRecovery:四边形面的金字塔/三面体预备

splitQuadRecovery 定义在 src/mesh/meshGRegion.h 第 100 行:

cpp 复制代码
// hybrid mesh recovery structure
class splitQuadRecovery {
private:
  std::map<MFace, MVertex *, MFaceLessThan> _quad;   // 四边形面 → 尖顶顶点(可能为空)
  std::map<MFace, GFace *, MFaceLessThan> _tri;      // 拆出的三角形 → 所属曲面
  bool _pyramids;
public:
  splitQuadRecovery(bool pyramids = true) : _pyramids(pyramids) {}
  void add(const MFace &f, MVertex *v, GFace *gf);
  std::map<MFace, GFace *, MFaceLessThan> &getTri() { return _tri; }
  std::map<MFace, MVertex *, MFaceLessThan> &getQuad() { return _quad; }
  int buildPyramids(GModel *gm);
  bool doWeCreatePyramids() const { return _pyramids; }
};

add 的逻辑(src/mesh/meshGRegion.cpp 第 127 行)区分两种情形:

cpp 复制代码
void splitQuadRecovery::add(const MFace &f, MVertex *v, GFace *gf)
{
  _quad[f] = v;
  if(v) {                                   // 要建金字塔:用尖顶把四边形拆成 4 个三角形
    MFace f0(f.getVertex(0), f.getVertex(1), v);
    MFace f1(f.getVertex(1), f.getVertex(2), v);
    MFace f2(f.getVertex(2), f.getVertex(3), v);
    MFace f3(f.getVertex(3), f.getVertex(0), v);
    _tri[f0] = gf; _tri[f1] = gf; _tri[f2] = gf; _tri[f3] = gf;
  }
  else {                                    // 不建金字塔:四边形的两种对角二选一
    MTriangle t0(f.getVertex(0), f.getVertex(1), f.getVertex(2));
    MTriangle t1(f.getVertex(0), f.getVertex(2), f.getVertex(3));
    double qual01 = std::min(t0.gammaShapeMeasure(), t1.gammaShapeMeasure());
    MTriangle t2(f.getVertex(1), f.getVertex(2), f.getVertex(3));
    MTriangle t3(f.getVertex(0), f.getVertex(1), f.getVertex(3));
    double qual23 = std::min(t2.gammaShapeMeasure(), t3.gammaShapeMeasure());
    if(qual01 > qual23) { /* 选 0-2 对角 */ }
    else { /* 选 1-3 对角 */ }
  }
}

两个要点:

  • 要建金字塔 时,四边形被拆成以尖顶为中心的四个三角形(_tri 记账),供恢复时作为 "约束三角面"。
  • 不建金字塔 时,四边形按两种对角线方案里质量较好的一种 拆成两个三角形(用 gammaShapeMeasure 比较)。这是"四边形转三角形"的质量感知策略------与第 4 章 Mesh3D 在重组合前的 quadsToTriangles 呼应。

这些 _tri 三角形被并入边界恢复的约束集合:在 insertVerticesInRegion 里可以看到 if(sqr) search.insert(sqr->getTri().begin(), sqr->getTri().end());(6.4)------它们参与 "按面查找所属曲面"的分类结构。buildPyramids(第 162 行)则在点插入之后,把 _quad 里 登记的四边面真正实例化成 MPyramid 或 MTrihedron:

cpp 复制代码
int splitQuadRecovery::buildPyramids(GModel *gm)
{
  if(_quad.empty()) return 0;
  ...
  for(auto it = gm->firstRegion(); it != gm->lastRegion(); it++) {
    GRegion *gr = *it;
    if(gr->meshAttributes.method == MESH_TRANSFINITE) continue;
    ...
    for(std::size_t j = 0; j < gf->quadrangles.size(); j++) {
      auto it2 = _quad.find(gf->quadrangles[j]->getFace(0));
      if(it2 != _quad.end()) {
        if(it2->second) {
          if(it2->second->onWhat()->dim() == 3) {
            // the apex is already in the volume on the other side of the
            // quadrangle: it cannot be the apex of a second pyramid
            Msg::Error("Surface %d with quadrangles bounds volumes %d and %d: "
                       "non-manifold quadrangle boundaries are not supported, "
                       "no pyramids in volume %d", ...);
            continue;
          }
          npyram++;
          gr->pyramids.push_back(new MPyramid(it2->first.getVertex(0), ...,
                                              it2->second));
          gr->mesh_vertices.push_back(it2->second);
          it2->second->setEntity(gr);
        }
        else {
          ntrihedra++;
          gr->trihedra.push_back(new MTrihedron(
            it2->first.getVertex(1), it2->first.getVertex(2),
            it2->first.getVertex(3), it2->first.getVertex(0)));
        }
      }
    }
  }
  Msg::Info("Done generating %d pyramids and %d trihedra for hybrid mesh", npyram, ntrihedra);
  return npyram + ntrihedra;
}

这里出现两种过渡单元:

  • MPyramid (5 节点):四边形面 + 尖顶。当 _quad[f] 记录了一个尖顶顶点时生成。
  • MTrihedron (三面体,4 节点):当 _quad[f] 的值为空时生成------这实际上是"把一个四边形 剖分为两个三角形"的显式记录形式(三面体退化)。它用于六面体/四面体交界处保证共形(见 6.10)。

以及一条重要的非流形检查 :若尖顶顶点已经属于另一侧的体(onWhat()->dim() == 3),说明 这个四边形是两个体的公共面,尖顶只能属于一个体,不能同时是两个金字塔的尖顶,于是报错 并跳过------避免同一顶点被两个体"拥有"并在删除时二次释放。

6.3.5 regionGroupBoundary:按组共享边界

边界恢复是对整组连通区域 的边界做的,不是逐体做。regionGroupBoundary( src/mesh/meshGRegion.cpp 第 267 行)通过 RAII 实现"临时把 regions[0] 的边界扩成整组的边界,析构时复原":

cpp 复制代码
regionGroupBoundary::regionGroupBoundary(std::vector<GRegion *> &regions) : _gr(regions[0])
{
  _faces = _gr->faces();
  _embEdges = _gr->embeddedEdges();
  _embVertices = _gr->embeddedVertices();

  std::set<GFace *, GEntityPtrLessThan> allFacesSet;
  for(std::size_t i = 0; i < regions.size(); i++) {
    std::vector<GFace *> const &f = regions[i]->faces();
    std::vector<GFace *> const &f_e = regions[i]->embeddedFaces();
    allFacesSet.insert(f.begin(), f.end());
    allFacesSet.insert(f_e.begin(), f_e.end());
  }

  // replace faces with compounds if elements from compound surface meshes are
  // not reclassified on the original surfaces
  if(CTX::instance()->mesh.compoundClassify == 0) {
    std::set<GFace *, GEntityPtrLessThan> comp;
    for(auto it = allFacesSet.begin(); it != allFacesSet.end(); it++) {
      GFace *gf = *it;
      if(!gf->compoundSurface) comp.insert(gf);
      else comp.insert(gf->compoundSurface);
    }
    allFacesSet = comp;
  }

  allFaces.assign(allFacesSet.begin(), allFacesSet.end());
  _gr->set(allFaces);              // 用整组的面替换 regions[0] 的面
  ... // 合并嵌入边、嵌入点,同理设置
}

构造函数做了三件事:

  1. 备份 regions[0] 原来的曲面/嵌入边/嵌入点(_faces/_embEdges/_embVertices)。
  2. 收集整组 的面(含嵌入式面),并入 allFaces;把整组的嵌入边、嵌入点也合并。
  3. 临时设置 regions[0]->set(allFaces),使边界恢复看到整组的边界。

析构函数(第 315 行)恢复 原状------这就是 MeshDelaunayVolume 里为什么把它放进一个 { } 作用域块:出块即还原,恢复后的分类阶段再按几何归属把四面体分发回各自的体。

compoundClassify == 0 时的替换逻辑值得单说:若复合面(compoundSurface)上的元素不会 被重新分类回原始曲面,就把所有成员面替换成它们的复合面。这是为了让边界恢复把复合面当作 一个整体约束来处理------第 2 章的"复合实体"机制在这里再次出场。

6.3.6 恢复的成果与后续

恢复成功后,MeshDelaunayVolume 对所有面三角做字典序排序 (第 370 行,便于后续二分查找 定位三角形所属曲面);若恢复失败(!success)则直接返回 ------地基没走通,点插入无从谈起。 最后提示:pdel3d(6.7)并不总是走这套全局 TetGen 恢复 ,它优先局部边删除 恢复缺失的 三角/线段,残留才用局部 TetGen,全局恢复只是兜底------体现 HXT/pdel3d 的"能在局部解决就不做 全局操作"的哲学。

6.4 点插入:从边界到体网格

边界恢复完成后,区域内的四面体只覆盖了"顶点 Delaunay 化"的结果,单元很粗,远未满足尺寸场。 MeshDelaunayVolume 的后半段(src/mesh/meshGRegion.cpp 第 401 行) 做点插入:

cpp 复制代码
else if(CTX::instance()->mesh.algo3d != ALGO_3D_INITIAL_ONLY &&
        CTX::instance()->mesh.algo3d != ALGO_3D_RTREE) {
  insertVerticesInRegion(gr, CTX::instance()->mesh.maxIterDelaunay3D, 1.,
                         true, &sqr);
  for(auto gr : regions) _deleteUnusedVertices(gr);

  int nHybrid = sqr.buildPyramids(gr->model());
  if(nHybrid && sqr.doWeCreatePyramids()) {
    gr->model()->setAllVolumesPositive();
    RelocateVerticesOfPyramids(regions, 3);    // 优化金字塔
  }

  // the mesh generator leaves the optimization of pdel3d meshes to pdel3d:
  // optimize the ones it gave up on here
  if(CTX::instance()->mesh.algo3d == ALGO_3D_PDEL3D) {
    for(int i = 0; i < CTX::instance()->mesh.optimize; i++)
      for(auto r : regions) optimizeMeshGRegion()(r);
  }
}

四个调用参数:maxIter = Mesh.MaxIterDelaunay3D(迭代上限)、worstTetRadiusTarget = 1.(质量 目标------外接球半径/目标尺寸的比值)、_classify = true(结束后按几何分类四面体)、&sqr (金字塔/四边形预备)。ALGO_3D_INITIAL_ONLY 在这里被排除------只做边界恢复与初始四面体化, 不细化,这是调试与特殊用途选项。pdel3d 也在此被排除细化(它自己内部细化),但注意它"放弃的 部分"仍要在这里补优化(那行注释)。

insertVerticesInRegion 就是"在过大四面体的外接球心插入新点"的实现,与二维前沿推进的思路相反: 不改动已有拓扑,只在质量不达标处加种子点,让 Delaunay 自己重连。

6.4.1 insertVerticesInRegion 全流程

insertVerticesInRegion(src/mesh/meshGRegionDelaunay.cpp 第 2203 行)是一段很长但结构清晰的函数。可以拆成五个阶段。

阶段一:收集每个顶点的尺寸(vSizes / vSizesBGM)

先用一个 unordered_map<MVertex*, double> 从多个来源取"每个点应多大":

cpp 复制代码
std::unordered_map<MVertex *, double> vSizesMap;
std::unordered_set<MVertex *> bndVertices;

// 1) 嵌入曲线的线段长度 → 其端点的"最小边长"(取最小,保证不越过最紧约束)
for(...) for(...) for(...) {   // lines of embedded edges
  double l = std::sqrt(dx*dx + dy*dy + dz*dz);
  if(vSizesMap[vi] > l) vSizesMap[vi] = l;    // smallest edge wins
  if(vSizesMap[vj] > l) vSizesMap[vj] = l;
}
// 2) 嵌入点:prescribedMeshSizeAtVertex() 同样取最小
// 3) 边界三角/四边形元素:setLcs(elem, vSizesMap, bndVertices)
for(auto it = gr->model()->firstFace(); it != gr->model()->lastFace(); ++it) {
  GFace *gf = *it;
  for(MTriangle *t : gf->triangles)     setLcs(t, vSizesMap, bndVertices);
  for(MQuadrangle *q : gf->quadrangles) setLcs(q, vSizesMap, bndVertices);
}
// 4) 已有四面体:setLcs(tet, vSizesMap, bndVertices)
for(MTetrahedron *t : gr->tetrahedra) setLcs(t, vSizesMap, bndVertices);

关键约定是**"取最小"**:同一个顶点若被多种来源约束,取其中最小尺寸作为它的目标边长。这 保证了细化不会越过最紧的约束。setLcs 从元素的最长边推估其顶点尺寸,bndVertices 记录 哪些点是边界点(后续用)。

随后把 map 里的顶点按顶点编号排序 并重新编号(setIndex(NUM++)),同时生成两条尺寸数组:

cpp 复制代码
// assign the vertex indices in the same order (by vertex number) as the
// former MVertexPtrLessThan-sorted map
std::vector<std::pair<std::size_t, std::pair<MVertex *, double>>> bynum;
...
for(auto &p : bynum) {
  p.second.first->setIndex(NUM++);
  vSizes.push_back(p.second.second);      // 几何尺寸(用于判据)
  vSizesBGM.push_back(p.second.second);   // 背景网格尺寸(用于判据)
}

vSizes 与 vSizesBGM 是两条平行的尺寸数组,用 MVertex::getIndex() 索引。第 7 章会看到 它们分别对应"几何尺寸"和"背景网格尺寸"两种判据(点插入时两者加权比较,见 6.4.2)。

阶段二:建立 MTet4 工厂与初始队列
cpp 复制代码
for(std::size_t i = 0; i < gr->tetrahedra.size(); i++) {
  gr->tetrahedra[i]->setVolumePositive();       // 先翻正
  tets0.push_back(myFactory.Create(gr->tetrahedra[i], vSizes, vSizesBGM));
}
std::sort(tets0.begin(), tets0.end(), compareTet4Ptr());
gr->tetrahedra.clear();
connectTetsFast(tets0.begin(), tets0.end());    // 快速建邻接

四面体从 MTetrahedron 对象包装成 MTet4(带缓存的外接球、邻接、质量)。compareTet4Ptr() 定义初始排序(外接球半径大的在前,编号小的破平)。connectTetsFast 通过面-四面体映射一次性 建立邻接关系。

阶段三:分类(把四面体分到正确的体)

边界恢复是在"整组"上做的,四面体最初都堆在 regions[0]->tetrahedra。分类阶段用面查找结构 逐块识别:

cpp 复制代码
if(_classify) {
  fs_cont search;
  buildFaceSearchStructure(gr->model(), search, true); // only triangles
  if(sqr) search.insert(sqr->getTri().begin(), sqr->getTri().end());
  for(auto it = tets0.begin(); it != tets0.end(); ++it) {
    if((*it)->onWhat()) continue;
    std::list<MTet4 *> theRegion;  std::set<GFace *> faces_bound;
    non_recursive_classify(*it, theRegion, faces_bound, (GRegion *)123, gr->model(), search);
    GRegion *myGRegion = getRegionFromBoundingFaces(gr->model(), faces_bound);
    if(myGRegion && myGRegion->tetrahedra.empty()) {     // 找到几何体
      for(auto it2 = theRegion.begin(); it2 != theRegion.end(); ++it2) (*it2)->setOnWhat(myGRegion);
    }
    else {                                               // 落在 void 里 → 删
      for(auto it2 = theRegion.begin(); it2 != theRegion.end(); ++it2) (*it2)->setDeleted(true);
    }
  }
  search.clear();
}

三个要点:

  • non_recursive_classify :从一个未分类四面体出发,沿邻接做一个受限的洪泛(flood fill), 收集"同一块"的四面体列表与它接触到的边界面集合。
  • getRegionFromBoundingFaces :用边界面集合 反查它围出的是哪个几何体。找到就把这整块 四面体的 onWhat 设为该体,并顺手把其中落在别的体上的内部点重新归属(那句 "Make sure that Steiner points will end up in the right region")。
  • "void"处理 :找不到归属的块被 setDeleted(true)------它们落在所有体之外("空洞"里), 必须剔除。这处理的是几何上非凸、或边界恢复把"外部"也四面体化了的情形。

buildFaceSearchStructure(src/mesh/meshGRegion.cpp 第 477 行) 建立 MFace → GFace* 的映射,这正是前面排序面三角的用途:用有序向量 + 映射做 O(log n) 查找。

若不分类(_classify == false),则退化处理:(*it)->setOnWhat(gr)------全部归给 regions[0] ("FIXME ... too simple")。

阶段四:嵌入面/边的邻接重建
cpp 复制代码
// store all embedded edges and faces
std::set<MFace, MFaceLessThan> allEmbeddedFaces;
edgeContainerB allEmbeddedEdges(N);
for(auto it = gr->model()->firstRegion(); it != gr->model()->lastRegion(); ++it) {
  createAllEmbeddedFaces((*it), allEmbeddedFaces);
  createAllEmbeddedEdges((*it), allEmbeddedEdges);
}
if(allEmbeddedFaces.empty()) {
  // 邻接仍有效:只需断开指向"void"已删四面体的链接
  for(auto it = tets0.begin(); it != tets0.end(); ++it)
    for(int i = 0; i < 4; i++) {
      MTet4 *n = (*it)->getNeigh(i);
      if(n && n->isDeleted()) (*it)->setNeigh(i, nullptr);
    }
}
else {
  // rebuild the adjacencies without connecting tets across embedded faces
  for(auto it = tets0.begin(); it != tets0.end(); ++it)
    (*it)->setNeigh(0, nullptr), (*it)->setNeigh(1, nullptr),
    (*it)->setNeigh(2, nullptr), (*it)->setNeigh(3, nullptr);
  connectTets(tets0.begin(), tets0.end(), &allEmbeddedFaces);
}

这段逻辑很讲究:若没有嵌入面,仅需把指向已删(void)四面体的邻接置空 ;若有嵌入面, 则必须重连,且 connectTets 不允许跨嵌入面连接 。为什么?因为嵌入面是"内部墙",两侧的 四面体在几何上属于不同材料域,跨墙连接会让空腔搜索穿越墙面,破坏嵌入约束。allEmbeddedEdges 则在点插入时检查空腔是否"切断了嵌入边"。

阶段五:选内核并细化
cpp 复制代码
if(CTX::instance()->mesh.flatRefine3D)
  refineRegionFlat(gr, maxIter, worstTetRadiusTarget, tets0, myFactory,
                   vSizes, vSizesBGM, NUM, allEmbeddedFaces, allEmbeddedEdges);
else
  refineRegionMTet4(gr, maxIter, worstTetRadiusTarget, tets0, myFactory,
                    vSizes, vSizesBGM, NUM, allEmbeddedFaces, allEmbeddedEdges);

Mesh.FlatRefine3D 默认开启 → 走 flat 内核;关闭则走对象式 MTet4。flat 内核头文件 src/mesh/meshGRegionDelaunayFlat.h 说明两者关系:

Flat-array refinement kernel of the 3D Delaunay mesher, selected with Mesh.FlatRefine3D. Same algorithm and same floating-point operations as the MTet4 kernel in meshGRegionDelaunay.cpp, on index-based arrays instead of objects; the meshes are bit-for-bit identical, node and element numbering included. Internal to the mesher - the entry points the rest of Gmsh uses are in meshGRegionDelaunay.h.

"bit-for-bit identical "是一个强保证:两套内核跑出的网格逐位相同 ,含节点与单元编号。 这意味着迁移到 flat 内核是纯粹的性能重构,不改变结果------这对于回归测试与用户复现至关重要。

6.4.2 circumradius 优先队列与尺寸判据

对象式内核 refineRegionMTet4(第 2410 行)用一个四面体外接半径优先队列驱动迭代:

cpp 复制代码
// Bowyer-Watson refinement kernel: consumes the classified and connected tets
// of tets0 (all MTet4 wrappers are freed), grows vSizes/vSizesBGM and the
// vertex index counter NUM as nodes are inserted, adds the new vertices and
// the final tets to their respective regions
static void refineRegionMTet4(GRegion *gr, int maxIter, double worstTetRadiusTarget, ...)
{
  tetRadiusQueue allTets(myFactory, worstTetRadiusTarget);

  for(auto t : tets0) allTets.push(t);
  tets0.clear();
  ...

tetRadiusQueue 的定义注释(第 1553 行)解释了这个容器的工程动机:

Priority queue of tets ordered like compareTet4Ptr (largest circumradius first, ties broken on the smaller element number). The keys are stored inline in a 4-ary heap so that comparisons do not chase MTet4 pointers, which made the former std::set-based container dominate the run time.

即:最大外接半径优先 ,键(半径)存在 4 叉堆里,比较时不追 MTet4 指针------过去用 std::set 导致比较追指针成了运行时间瓶颈。这是一个典型的"数据结构选型即性能"案例。

主循环(第 2438 行):

cpp 复制代码
while(1) {
  if(maxIter > 0 && ITER >= maxIter) { Msg::Info("Max. iterations reached - stopping"); break; }
  if(allTets.empty()) { if(!allTets.totalSize()) Msg::Warning("No tetrahedra in region %d", gr->tag()); break; }

  MTet4 *worst = allTets.top();
  if(worst->isDeleted()) { allTets.pop(); myFactory.Free(worst); }
  else {
    if(ITER++ % 500 == 0)
      Msg::Info("It. %d - %d nodes created - worst tet radius %g", ITER - 1, REALCOUNT, worst->getRadius());
    if(worst->getRadius() < worstTetRadiusTarget) break;   // 达标,停止
    allTets.pop();
    MTet4 *popped = worst;
    double center[3], uvw[3];
    // circumcenter cached at creation, computed with the exact same
    // floating-point operations as tetcircumcenter()
    worst->cachedCircumcenter(center);
    ...

两个判据决定"这条四面体是否需要细化":

  1. 外接半径判据 worst->getRadius() < worstTetRadiusTarget:半径足够小就停。注意目标 worstTetRadiusTarget 从 MeshDelaunayVolume 传入为 1.,实际含义是"外接半径与目标尺寸 的比值";配合每个四面体自身的尺寸场,等价于"单元是否已经足够小"。
  2. 尺寸判据 (插点位置处再算):候选点(外接球心)插值出几何尺寸 lc1 与背景网格尺寸 lc2:
cpp 复制代码
double lc1 = (1 - uvw[0] - uvw[1] - uvw[2]) * vSizes[worst->tet()->getVertex(0)->getIndex()] +
             uvw[0] * vSizes[worst->tet()->getVertex(1)->getIndex()] +
             uvw[1] * vSizes[worst->tet()->getVertex(2)->getIndex()] +
             uvw[2] * vSizes[worst->tet()->getVertex(3)->getIndex()];
double lc2 = BGM_MeshSize(worst->onWhat(), 0, 0, center[0], center[1], center[2]);

uvw 正是 tetcircumcenter 用 Cramer 法则算出的重心坐标------把球心处的尺寸按重心插值。lc1 来自顶点尺寸表、lc2 来自背景网格尺寸函数(第 4 章)。两个尺寸都随新点存入 vSizes/vSizesBGM, 成为后续判据的输入。这就是"点在球心,尺寸按球心的重心坐标插值"的落地。

6.4.3 空腔构造、星形化与"延迟/冲突"处理

候选点 vv 选定后先找包含它的空腔:

cpp 复制代码
MVertex vv(center[0], center[1], center[2], worst->onWhat());
findCavity(shell, cavity, &vv, worst);
bool FOUND = false;
for(auto itc = cavity.begin(); itc != cavity.end(); ++itc) {
  MTetrahedron *toto = (*itc)->tet();
  toto->xyz2uvw(center, uvw);
  if(toto->isInside(uvw[0], uvw[1], uvw[2])) { worst = (*itc); FOUND = true; break; }
}

findCavity 收集"外接球包含 vv 的连通四面体集合"(空腔)与边界(shell)。随后从空腔里 找真正包含 vv 的四面体(isInside 判断),因为"外接球包含"不等于"体内包含"。

接下来正确处理嵌入约束与星形性:

cpp 复制代码
if(FOUND && (!allEmbeddedEdges.empty() || !allEmbeddedFaces.empty())) {
  FOUND = isCavityCompatibleWithEmbeddedEdges(cavity, shell, allEmbeddedEdges) &&
          isCavityCompatibleWithEmbeddedFace(cavity, shell, allEmbeddedFaces);
}
...
if(FOUND) {
  MVertex *v = new MVertex(center[0], center[1], center[2], worst->onWhat());
  v->setIndex(NUM++);
  bool starShaped = true;
  bool correctCavity = false;
  while(1) {
    int k = makeCavityStarShaped(shell, cavity, v);
    if(k == -1) { starShaped = false; break; }
    else if(k == 0) break;
    else if(k == 1) correctCavity = true;
  }
  ...

要点:

  • 嵌入相容性 :若空腔里有嵌入边/面,插点可能把它们"切断"。isCavityCompatibleWithEmbeddedEdges/ ...Face 检查空腔边界是否仍完整保留这些嵌入实体;不兼容就放弃这次插入。
  • makeCavityStarShaped :与 6.2.5 的 fixDelaunayCavity 同源,把空腔修成星形。返回值 三态:-1 修不成(放弃插入);0 已星形;1 做了修正(correctCavity = true,需复查嵌入 相容性)。
  • 延迟/失败处理 :修不成、或修正后与嵌入实体不相容、或 insertVertexB 失败时:
cpp 复制代码
if(correctedCavityIncompatibleWithEmbeddedEntities || !starShaped ||
   !insertVertexB(shell, cavity, v, lc1, lc2, vSizes, vSizesBGM, worst,
                  myFactory, allTets, allEmbeddedFaces)) {
  COUNT_MISS_1++;
  popped->forceRadius(0.);                 // 把它"钉"成不再被选中
  failedTets.push_back(popped);            // 记入失败表,结束时不重排
  for(auto itc = cavity.begin(); itc != cavity.end(); ++itc)
    (*itc)->setDeleted(false);             // 撤销对空腔的删除标记
  delete v;
  NUM--;
}
else {
  vSizes.push_back(lc1);
  vSizesBGM.push_back(lc2);
  REALCOUNT++;
  v->onWhat()->mesh_vertices.push_back(v);
}

forceRadius(0.) 是**"延迟"策略的关键**:插入失败的那个四面体把它自己的半径置 0,于是队里 排序时它排到最后,短时间内不会被再次选中------避免在同一个"坏"四面体上无限循环。failedTets 保存这些"活着但被置 0"的四面体,循环结束后统一按原序回收(allTets.drainSorted(...))。 代码末尾的统计与收尾:

cpp 复制代码
Msg::Info("3D refinement terminated (%d nodes total):", (int)vSizes.size());
Msg::Info(" - %d Delaunay cavities modified for star shapeness", NB_CORRECTION_OF_CAVITY);
Msg::Info(" - %d nodes could not be inserted", COUNT_MISS);
Msg::Info(" - %d tetrahedra created in %g sec. (%d tets/s)", aliveTets.size(), dt, (int)(aliveTets.size() / dt));

// relocate vertices
int nbReloc = 0;
for(int SM = 0; SM < CTX::instance()->mesh.nbSmoothing; SM++) {
  for(auto it = aliveTets.begin(); it != aliveTets.end(); ++it) {
    if(!(*it)->isDeleted()) {
      double qq = (*it)->getQuality();
      if(qq < .4)
        for(int i = 0; i < 4; i++)
          if(smoothVertex(*it, i, qmTetrahedron::QMTET_GAMMA)) nbReloc++;
    }
  }
}
Msg::Info("%d node relocations", nbReloc);

细化结束时会顺带做 nbSmoothing 轮局部光滑 (对质量 qq < 0.4 的四面体做顶点平滑), 然后把存活四面体的 MTetrahedron 交还给所属体并释放 MTet4 包装。第 7 章会讲更完整的优化, 这里只是"点插入自带的轻度整理"。

6.4.4 从空间到几何:分类辅助函数

分类阶段依赖几个辅助函数:classifyTetrahedraInRegions(第 2074 行)把 regions[0]->tetrahedra 里的四面体洪泛分类 分发回各自的体(MMG3D 分支在调用 MMG3D 前先用它,见 6.8;落在 void 里的 直接丢弃);getSharedFace(第 2032 行)在两个体之间找公共曲面;insertVertexInFaceTriangulation (第 2042 行)把一个点重新塞回曲面三角剖分------注释说明它 "make v ... a genuine triangle corner again: find the triangle that contains it and split it into three, preserving orientation", 失败时只打警告、不回滚分类。

点插入的"撤销"操作与失败路径会留下不再被引用的顶点 ,_deleteUnusedVertices (src/mesh/meshGRegion.cpp 第 221 行)用 v->getIndex() 去重、 按 getNum() 排序重建 mesh_vertices。注意它带一条 FIXME:"investigate crash on exit if we delete the unused vertices (e.g. t16.geo)"------代码只重建列表,并不真的 delete 顶点对象 , 诚实地绕开了一个已知的内存管理问题:MVertex 的生命周期由 GModel 统一管理。

6.5 局部修改内核:MTet4 与 flat

6.5.1 两套内核的背景

如前所述,三维点插入有两套并存的局部操作性内核:

  • 对象式(MTet4) :四面体是 MTet4 对象,指针互引、缓存外接球与质量。
  • 平面数组式(flat) :全部四面体存在整数数组里(tetNode/tetNeighbors),默认启用。

flat 成为默认是因为对缓存与并行都更友好 :无指针追逐、可按索引向量化、易迁移到并行/HXT 风格,且与 MTet4 逐位一致 ("bit-for-bit identical")。这套"flat 思路"贯穿整个三维子系统 (边界恢复 I/O、HXT、pdel3d、Tet4 全是"平面数组 + 整数索引"),可以说换用平面数组是 Gmsh 三维性能的核心工程决策。

6.5.2 局部操作清单

不管哪套内核,局部修改(local mesh modification)的算子都是一样的,头文件 src/mesh/meshGRegionLocalMeshMod.h 声明了适用范围 ("only apply to the bulk ... cannot be applied to boundaries")与四个算子(edgeSwap/ faceSwap/smoothVertex/collapseVertex,质量度量用 qmTetrahedron::Measures):

算子 作用 改变拓扑?
edgeSwap 沿一条边删除其"环"(ring),用另一种三角剖分重新连接环顶点 是
faceSwap 两个相邻四面体构成的三棱柱,交换共享面为另一条对角线 是
smoothVertex 移动一个顶点(轻微重定位)以提升相邻单元质量 否
collapseVertex 把一个顶点合并到另一顶点,删掉退化单元 是

三点值得一提:

  1. "only apply to the bulk ... cannot be applied to boundaries":这些算子只作用于内部 ("bulk"),绝不碰边界------因为改动边界会破坏与几何的关联,且可能伤害曲面网格质量。这与 6.4.3 里"先检查嵌入约束才动空腔"是同一原则。
  2. localMeshModAction { GMSH_DOIT, GMSH_EVALONLY } :同一个算子既能真做(GMSH_DOIT), 也能"只评估、不落实"(GMSH_EVALONLY)。优化器可以先评估某次翻转能不能提升质量,再决定 是否执行------这是一种低成本的"试算"。
  3. QMTET_GAMMA 质量度量 贯穿所有 cr 参数(qmTetrahedron::Measures)。第 7 章会看到 gamma(=3×内切半径/外接半径)是 Gmsh 四面体质量的核心指标(见 pdel3d 的 gammaQuality, 6.7)。

6.5.3 翻转的执行纪律

edgeSwap 删掉以某条边为中心的"环"(ring)上的四面体,把环顶点重新三角剖分 ;环大小 3/4/5/6/7 各对应一张预计算的模式表 (SwapPattern:环面 + 若干剖分方案)。它要求 环封闭 才可翻转("return true if the cavity is closed and false if it is open"),且 "新方案必须比旧的严格更好"(if(best <= tetQualityRef + 1e-20) return false); faceSwap 对两相邻四面体构成的三棱柱交换对角线,强制 t1->onWhat() == t2->onWhat() (同一体才可交换)。所有算子都用回滚式试算 ------先算新单元质量,更优才落盘,否则原样返回、 不留副作用;GMSH_EVALONLY 让优化器"只评估不落实"。这与第 5 章二维内核同源,但三维环组合 更多、失败率更高。

一句话概括 6.5:局部修改是三维优化的"手术刀",算子在对象式与 flat 内核间共享,作用于 内部、以 gamma 质量为准绳、以"试算-落实"为纪律。

6.6 HXT:新一代并行 Delaunay

6.6.1 HXT 是什么

ALGO_3D_HXT 调用 HXT 库(src/mesh/meshGRegionHxt.cpp)。 HXT 由 C. Marot、J. Pellerin、J.-F. Remacle 等人开发(与 pdel3d 同源论文:"One machine, one minute, three billion tetrahedra",IJNME 2019),它已是 Gmsh 推荐的高性能三维算法。文件包含的 HXT 接口(第 25--38 行)勾勒出它的能力:

cpp 复制代码
#if defined(HAVE_HXT)

#include "hxt_omp.h"
extern "C" {
#include "hxt_tetMesh.h"           // 体网格主入口
#include "hxt_tetDelaunay.h"       // Delaunay 四面体化
#include "hxt_boundary_recovery.h" // 边界恢复
#include "hxt_tools.h"
#include "hxt_tetOpti.h"           // 优化
#include "hxt_tetRepair.h"         // 修复
#include "hxt_tetFlag.h"
#include "hxt_tetRefine.h"         // 细化
#include "hxt_tetColor.h"          // 着色(图着色并行)
}

hxt_tetColor(图着色)泄露了 HXT 的并行策略:与 Gmsh 的 #pragma omp parallel for 不同, HXT 用图着色把相互冲突的网格操作分组,让每组内部可以无锁并行------这是高性能网格生成器的 标准做法,也是 HXT 与 Gmsh 自研 pdel3d 共享的设计(6.7)。

HAVE_HXT 是编译期宏。若未编译 HXT,文件末尾提供同名桩函数,全部报同一句错误:

cpp 复制代码
#else
int meshGRegionHxt(std::vector<GRegion *> &regions)
{
  Msg::Error("Gmsh should be compiled with Hxt to enable this option");
  return -1;
}
void delaunayMeshIn3DHxt(std::vector<MVertex *> &v, std::vector<MTetrahedron *> &tets,
                         const std::vector<MTriangle> &triangles)
{
  Msg::Error("Gmsh should be compiled with Hxt to enable this option");
}
#endif

这就是"能力矩阵用编译宏表达 "的落地:源码永远存在,行为由 HAVE_HXT 决定。

6.6.2 尺寸回调:把尺寸场接进 HXT

HXT 生成网格时要向 Gmsh 询问"每个点处应该多大"。Gmsh 通过回调把它接上(第 55 行):

cpp 复制代码
static HXTStatus nodalSizesCallBack(double *pts, uint32_t *volume, size_t numPts, void *userData)
{
  std::vector<GRegion *> *allGR = (std::vector<GRegion *> *)userData;

  double lcGlob = CTX::instance()->lc;
  int useInterpolatedSize = Extend2dMeshIn3dVolumes();

  HXT_INFO("Computing %smesh sizes...", useInterpolatedSize ? "interpolated " : "");

  int nthreads = getNumThreads();
  bool exceptions = false;
#pragma omp parallel for schedule(dynamic) num_threads(nthreads)
  for(size_t i = 0; i < numPts; i++) {
    GRegion *gr = (*allGR)[volume[i]];
    try { // OpenMP forbids leaving block via exception
      double lc = BGM_MeshSizeWithoutScaling(gr, 0, 0, pts[4*i+0], pts[4*i+1], pts[4*i+2]);
      pts[4 * i + 3] = std::min(lcGlob, lc);
    } catch(...) { exceptions = true; }
  }
  if(exceptions) throw std::runtime_error(Msg::GetLastError());
  return HXT_STATUS_OK;
}

要点:

  • 点的布局是 pts[4*i+0..2] 坐标 + pts[4*i+3] 目标尺寸 ------HXT 用 4 个 double 打包一个 "带尺寸的点"。这是 HXT 与 Gmsh 之间的核心数据契约(pdel3d 完全一致,见 6.7 的 Mesh.xyz)。
  • 尺寸来自 BGM_MeshSizeWithoutScaling(第 4 章背景网格尺寸函数)。
  • Extend2dMeshIn3dVolumes():是否把二维网格尺寸向三维体内部插值。
  • 异常处理 :OpenMP 不允许 throw 逃出 #pragma omp parallel for 循环体,于是用 exceptions 标志"打标记",循环后统一抛出。注释 "OpenMP forbids leaving block via exception" 点明了这个语言层面的约束。
  • 两套并行在同一进程里协作:即使 HXT 内部并行,这个回调也自己用 OpenMP 并行计算尺寸。

6.6.3 桥接:Gmsh2Hxt / Hxt2Gmsh

HXT 用自己的 HXTMesh 结构,Gmsh 用 GRegion/GFace。中间靠两个函数转换: Gmsh2Hxt(第 333 行)把 Gmsh 的曲面/曲线/体转成 HXT 的 B-Rep 与约束,Hxt2Gmsh (第 196 行)把结果搬回来。getAllSurfaces(第 96 行)里有一条硬性检查:

cpp 复制代码
// verify that all elements are triangles
for(auto const &gf : allSurfacesSet) {
  if(gf->quadrangles.size() != 0 || gf->polygons.size() != 0) {
    Msg::Error("Surface %d contains %zu elements which are not triangles. "
               "The HXT 3D meshing algorithm only supports triangles.",
               gf->tag(), num);
    return HXT_STATUS_ERROR;
  }
}

HXT 只吃三角形边界 。因此若用户要六面体/棱柱边界面,必须事先把四边形转成三角形(第 4 章 看到 Mesh3D 在重组合前会 quadsToTriangles)。这说明三维算法的选择会反向约束二维网格的 形式------又一次维与维的耦合。

6.6.4 主入口与选项结构

_meshGRegionHxt(第 453 行)构造选项并调用 hxtTetMesh:

cpp 复制代码
HXTTetMeshOptions options = {
  nthreads, nthreads, nthreads,       // defaultThreads / delaunayThreads / improveThreads
  CTX::instance()->mesh.reproducible, // 可复现性
  ...,                                // verbosity / stat / refine
  CTX::instance()->mesh.optimize,     // optimize
  CTX::instance()->mesh.toleranceInitialDelaunay,   // 初始 Delaunay 容差
  {/*quality*/}, {// nodalSize: nodalSizesCallBack, &regions, lcMin, lcMax, lcFactor * ...}};
HXT_CHECK(hxtTetMesh(mesh, &options));
HXT_CHECK(Hxt2Gmsh(regions, mesh, v2c, c2v));
HXT_CHECK(hxtMeshDelete(&mesh));

HXTTetMeshOptions 暴露了 HXT 的全部可调项:三类线程数(默认/Delaunay/改进)、 reproducible(可复现性开关,对应 6.2.3 的确定性追求)、refine、optimize 及其质量阈值、 初始 Delaunay 容差、以及尺寸回调与尺寸上下限。HXT_CHECK 宏把 HXT 的错误码转成 Gmsh 的 错误上报(内部就是检查并可能抛出)。

meshGRegionHxt(第 499 行)薄薄一层,把状态码转成整数:

cpp 复制代码
int meshGRegionHxt(std::vector<GRegion *> &regions)
{
  HXTStatus status = _meshGRegionHxt(regions);
  if(status == HXT_STATUS_OK) return 0;
  return 1;                       // 非 0 → MeshDelaunayVolume 报 "HXT 3D mesh failed"
}

注意如 6.1.3 所说:HXT 失败不回退 ------MeshDelaunayVolume 只在非 0 时报错然后 return。

6.6.5 无网格生成时的纯 Delaunay:约束恢复与 Steiner 点

HXT 还能单独做"点集 + 三角约束"的四面体化(入口 _delaunayMeshIn3DHxt,第 506 行):把顶点 打进 mesh->vertices.coord(4 double/点),用 hxtDelaunaySteadyVertices 建立初始 Delaunay (HXTDelaunayOptions 里 perfectDelaunay = 1、delaunayThreads = nthreads),再把三角约束 打进 mesh->triangles.node/color 后调用 hxt_boundary_recovery(mesh, 0.0)。回搬时有三处 关键处理:

  1. Steiner 点 :恢复后若 mesh->vertices.num 超过原顶点数,多出来的就是 HXT 插入的 Steiner 点,包装成 MVertex 追加到 verts。
  2. 幽灵顶点 :node[i*4+3] == UINT32_MAX 表示该四面体的第 4 个顶点是"无穷远顶点"(幽灵 四面体,闭合凸包),必须剔除。
  3. 颜色过滤 :myColor != HXT_COLOR_OUT 才导出------color 标记每个四面体属于哪个体(体外为 COLOR_OUT),用颜色一次性把整组的四面体分发到各自的体 ,取代逐块洪泛分类(对比 6.4.1 的 non_recursive_classify)。

6.6.6 与优化 / 通用 Delaunay 接口的对接

HXT 也提供独立的优化入口 optimizeMeshHXT(第 611 行),并参与 Gmsh 的通用"仅四面体化"接口。 在 src/common/gmsh.cpp 第 9287 行可以看到算法分派:

cpp 复制代码
if(CTX::instance()->mesh.algo3d == ALGO_3D_HXT) {
  delaunayMeshIn3DHxt(verts, tets, trianglesToRecover);
}
else if(CTX::instance()->mesh.algo3d == ALGO_3D_PDEL3D) {
  if(triangles.size() > 0)
    Msg::Error("3D constrained delaunay tetrahedralization is currently only available using HXT");
  delaunayMeshIn3DParallel(verts, tets);
}
else {
  if(triangles.size() > 0) Msg::Error("... only available using HXT");
  delaunayMeshIn3D(verts, tets, true);
}
if(verts.size() > coord.size() / 3) { /* Steiner Points! */ }

这段代码(供 -tetrahedralize 之类的接口用)说明一件重要事实:带约束的四面体化只有 HXT 支持 ; pdel3d 与 del3d 在收到三角约束时只能报错。所以当用户需要"把给定三角面对四边形化为约束"时, HXT 是唯一选择。注意后面同样有 verts.size() > coord.size()/3 的"Steiner 点"检测。

6.7 pdel3d:Gmsh 自研的并行 Delaunay

6.7.1 定位与设计目标

ALGO_3D_PDEL3D 是 Gmsh 自研 的并行 Delaunay( src/mesh/meshGRegionParallelDelaunay.h)。它被 设计为"不依赖 HXT 也能并行"的方案,其数据布局与并行方案刻意模仿 HXT:

The data layout and the parallel scheme are those of HXT: flat arrays, ghost tetrahedra closing the convex hull, each thread working on a piece of a Moore curve, and conflicts retried on shifted curves.

头文件引用了同一篇论文("One machine, one minute, three billion tetrahedra",IJNME 2019),并 给出三句关键描述:

vertices are ordered along a Moore curve, each thread inserts the vertices of a contiguous piece of the curve and may only touch tetrahedra whose vertices all lie in that piece, and the convex hull is closed by ghost tetrahedra sharing a vertex at infinity.

这三点就是 pdel3d(以及 HXT)并行的全部秘诀:

  1. Moore 曲线(一种三维空间填充曲线,是 Hilbert 曲线的闭合变体):把顶点沿曲线排序, 相邻顶点在空间上也相邻。
  2. 每个线程处理曲线的一段连续区间:线程只碰"顶点全在自己区间内"的四面体,于是各线程 的工作在空间上分离,冲突极少。
  3. 幽灵四面体共享无穷远顶点 :用一个虚拟顶点 GHOST = 0xffffffff 闭合凸包,避免特殊处理 边界。

文件头注释(第 13--33 行)还列出了 pdel3d 与 HXT 的差异,最要紧的是:细化沿用 del3d 的思路 (每个四面体一个候选点、在球心或略移入内部、尺寸按节点插值、沿曲线与空腔内过滤)、边界 优先局部恢复(缺失三角/线先用边删除恢复,残留才用 TetGen)、优化增加小幅多边形重连、 坐标在网格化期间被扰动(同 del3d)。

6.7.2 平面数组与常量:与 HXT 同构的数据布局

namespace pdel3d 开头定义全套索引与标志常量( src/mesh/meshGRegionParallelDelaunay.h 第 32 行):

cpp 复制代码
typedef std::uint32_t vIdx;          // 顶点索引
typedef std::uint64_t tIdx, tRef;    // 四面体索引 / 4*tet+facet
constexpr vIdx GHOST = 0xffffffffu;  // 无穷远顶点
constexpr tRef NO_ADJ = ~0ull;
// flag bits of a tetrahedron, as in HXT
constexpr std::uint16_t F_EDGE0..5 = ...;      // 边约束位(6 条边)
constexpr std::uint16_t F_DELETED = 0x40, F_PROCESSED = 0x80;
constexpr std::uint16_t F_FACET0..3 = ...;     // 面约束位(4 个面)
constexpr std::uint16_t F_ALL_CONSTRAINTS = 0xf3f;

注意注释 "flag bits of a tetrahedron, as in HXT "------位标志的含义与 HXT 一致,这是刻意的 兼容设计。约束用面位(F_FACET0..3)与边位(F_EDGE0..5)表示,F_ALL_CONSTRAINTS 是它们的 并。Mesh 结构(第 149 行):

cpp 复制代码
struct Mesh {
  // vertices: x, y, z and the mesh size (<= 0: unknown), 4 doubles per vertex
  std::vector<double> xyz;
  // position of each vertex along the current Moore curve; ...
  std::vector<std::uint64_t> dist;
  std::size_t numDefaultDist = 0;
  double defaultBox[6] = {0., 0., 0., 0., 0., 0.};
  // tetrahedra: the arrays are sized to the capacity, the first ntet slots
  // are in use (some of them flagged deleted until removeDeleted())
  std::size_t ntet = 0;
  PodVector<vIdx> node; // 4 per tet; a ghost vertex is always node 3
  PodVector<tRef> neigh; // 4 per tet
  PodVector<std::uint16_t> flag;
  // volume of the tet (COLOR_OUT: outside); left empty when not needed
  PodVector<std::uint32_t> color;

  static constexpr std::uint32_t COLOR_OUT = 0xffffffffu;
  ...
};

PodVector(第 95 行)是一个用 realloc 扩容的可平凡拷贝数组,注释说明其内存动机:

a resizable array of a trivially copyable type, grown with realloc: a large block is then remapped rather than copied, so that the old and the new tet arrays are never both in memory

即:扩容时整块 realloc(大块常可直接重映射地址),旧数组与新数组不会同时在内存里 。 这对"三亿四面体"级别的规模至关重要------用 std::vector 扩容的"拷贝 + 释放"会短时占用双倍 内存,可能直接 OOM。

6.7.3 分区:Moore 曲线、负载均衡与冲突降级

makePartitions(第 385 行)把沿曲线排序的顶点切成 nthreads 段:

cpp 复制代码
// Cut the sorted items (dist[i] ascending, todo[i] nonzero for the ones to
// process) in nthreads pieces of about the same number of items to do,
// starting at a fraction startShift of the first piece and only where the
// curve coordinate changes; returns the number of pieces made (1 means no
// partitioning: the single partition covers everything)
inline int makePartitions(const std::uint64_t *dist, const std::uint8_t *todo,
                          std::size_t n, std::size_t numTodo, int nthreads,
                          double startShift, std::vector<Partition> &parts)

要点:

  • 按"待办项数"而非"总项数"均分 (perThread = numTodo / nthreads + 1),因为并非所有顶点 都需要插入。
  • 切分只发生在曲线坐标变化处 (if(d != prev)),保证每段的曲线坐标是连续区间------这是 "线程只碰自己区间内四面体"的前提。
  • startShift :从第一段的某个比例处开始切,用于在不同轮次之间移动分界("conflicts retried on shifted curves")------同一批冲突点,换一个分界重试就可能不再冲突。

分区之后,线程数会根据冲突比例动态下调(第 425 行):

cpp 复制代码
// halve the threads when the conflicts are too many, and keep at least
// smallestPass items per thread
inline int computeNumberOfThreads(double conflictRatio, int numThreads,
                                  std::size_t numElem, std::size_t smallestPass)
{
  const double maxBorders = 8.;
  if(conflictRatio > (numThreads - 1) * maxBorders / (numThreads * (maxBorders + 1) - 2.))
    numThreads = (numThreads + 1) / 2;
  int maxThreadsInRound = 1;
  std::size_t tmp = numElem / smallestPass;
  while(tmp > 1 && maxThreadsInRound < numThreads) { tmp /= 2; maxThreadsInRound *= 2; }
  return std::min(maxThreadsInRound, numThreads);
}

冲突太多就把线程数减半 ------这与 pdel3d 用"Moore 曲线 + 分区"的哲学一致:与其让大量线程 互相抢锁,不如少开线程、各自多干。partitionability 选项(DelaunayOptions 里 0 表示"还没 插入任何点,完全不可分区",1 - 0.5^n 表示经过 n 轮细化后的可分区程度)正是用来控制这个。

6.7.4 边界恢复:局部优先、全局兜底

如前所述,pdel3d 的恢复不是一上来就上 TetGen。头文件列出两个层次:

cpp 复制代码
// recover the missing triangles and lines (tri2tet[i] == NO_ADJ,
// line2tet[i] == NO_LINE with lineInTriangle[i] == 0) by local edge
// removals: a missing edge is created by removing an edge around which its
// two nodes are ring vertices, a missing facet by removing an edge around
// which its three nodes are. ...
std::size_t recoverLocally(Mesh &m, const std::vector<vIdx> &triNode,
                           const std::vector<vIdx> &lineNode,
                           const std::vector<std::uint8_t> &lineInTriangle,
                           std::vector<tRef> &tri2tet,
                           std::vector<std::uint64_t> &line2tet, int nthreads, int verbosity);

**边删除(edge removal)**是三维恢复的经典局部操作:要造一条缺失的边,就去删掉一条"环绕"它 两个端点的已有的边(把该边的环重新三角剖分,环顶点里就包含目标边的两端);要造一个缺失的 面,就删掉一条"环绕"它三个顶点的边。这属于"纯局部、不改全局"的策略。驱动函数 meshRegions (第 4977 行)把三层恢复串起来:

cpp 复制代码
const bool recovered = missing || missingLines;
if(recovered) {
  bool local = true;
  pdel3d::recoverLocally(m, s.triNode, s.lineNode, lineInTriangle, tri2tet,
                         line2tet, nthreads, verbosity);
  remap();
  if(missing || missingLines) {
    local = recoverWithLocalTetGen(m, s, regions, tri2tet, line2tet, lineInTriangle, nthreads);
    if(local) { remap(); local = !missing && !missingLines; }
    if(!local)
      Msg::Info("Local boundary recovery incomplete: falling back to the global one");
  }
  if(!local) {
    ... // 回滚到局部恢复前的网格(node0/neigh0/flag0 快照)
    if(!recoverBoundary(m, s, regions, sqr, sizeFactor)) {
      Msg::Error("Boundary recovery failed"); return 1;
    }
    remap();
  }
  if(missing || missingLines) {
    Msg::Error("%zu triangle(s) and %zu line(s) still missing after boundary recovery",
               missing, missingLines);
    return 1;
  }
}

三层依次是:

  1. recoverLocally:全局的边删除。
  2. recoverWithLocalTetGen:在局部空腔上跑 TetGen("局部 TetGen")。
  3. recoverBoundary :整张网格的全局 TetGen 恢复(兜底)。

而且从第 3 层回退前,代码先把网格回滚到局部恢复之前的状态 (node0/neigh0/flag0 快照)------ 这是"先干净地回退,再试全局方案"的谨慎做法,避免局部恢复的中间态污染全局恢复的输入。

6.7.5 颜色:用面颜色一次性标记体归属

colorVolumes(头文件第 228 行)是 pdel3d(与 HXT)共同的核心机制:用受约束面界定的洪泛 找出连通体,再用"围住它的曲面颜色集合"匹配已知体------volume i 得颜色 i,其余从 volumes.size() 起,体外得 COLOR_OUT。三处细节:siblings 是复合面的成员颜色集合 (呼应 6.3.5),embedded 里的曲面两侧属于同一体(嵌入式面不是"墙"),体外四面体导出时 剔除。它比 del3d 的"逐块洪泛 + 遍历"更简单,且天然并行(每四面体独立着色、最后归约), 这正是它成为并行网格器首选分类法的原因。

6.7.6 细化:候选点、尺寸过滤与曲线窗口

pdel3d 的细化沿用了 del3d 的语义,但加了并行所需的过滤。RefineOptions(头文件第 237 行)与 尺寸过滤器 tooClose(第 558 行):

cpp 复制代码
// the mesh size filter: two points at squared distance d2 with sizes s0 and
// s1 (0: unknown) are too close (opt: sizeMin, sizeMax, sizeFactor)
template <class Options>
inline bool tooClose(double s0, double s1, double d2, const Options &opt)
{
  if(s0 > 0. && s1 > 0.) {
    const double s = std::min(opt.sizeMax, std::max(opt.sizeMin, 0.5 * (s0 + s1))) * opt.sizeFactor;
    return d2 < s * s;
  }
  return false;
}

即:若两个点比它们的平均尺寸(夹在 min/max 之间、再乘因子)还近,就算太近 。头文件注释补充 了两种过滤(第 556 行的"按空腔"与 curveFilterWindow 的"沿曲线"):

candidates are filtered against the previous ones along the curve and in their cavity, and the size field is only evaluated on those kept

**"尺寸场只在保留下来的候选点上求值"**是一个关键优化------nodalSizesCallBack 那种尺寸回调 很贵,能少调用就少调用。curveFilterWindow = 16 表示"沿曲线往回看 16 个点"去判太近,是一个 局部窗口,避免全局比对。

6.7.7 驱动流程与返回码

meshRegions(第 4977 行)是 pdel3d 的主流程,串联了:坐标扰动 → 初始 Delaunay → 约束恢复 → 约束标记 → 颜色 → 细化 → 优化 → 导出 → 金字塔。坐标扰动的注释(第 4984 行)与 del3d 一脉 相承,还给了实测数据:

cpp 复制代码
// As del3d, work on coordinates perturbed by up to Mesh.RandomFactor3D times
// the size of the model, restored when the mesh is handed back ...
// Measured on benchmarks/3d (October 2026), without the perturbation:
// - the nodes of curved CAD surfaces are cospherical to rounding, those of
//   planar faces coplanar, which sends the predicates of the initial
//   tetrahedralization to the exact arithmetic (3-5x slower on fil, crux, percolation);
// - the degenerate Delaunay misses more surface triangles: the recovery is up
//   to 8x slower (geom8du, vulp5) and falls back to the global one on vulp5 and core_coil;
// - the refinement can be faster, though (U_Joint_2.stp, clscale 0.02).

这是一份实测基准说明 :不加扰动时,曲面 CAD 的节点"舍入级别共球"、平面面片"共面",会把 初始四面体化的谓词逼进精确算术(慢 3--5 倍),且退化 Delaunay 会漏掉更多边界三角(恢复慢最多 8 倍,甚至触发全局恢复兜底)。"扰动坐标"看似粗暴,实为规避数值退化的核心手段------与 6.2.3 的符号扰动互为补充。

Mesh.Regularization 无关。关键的是返回码(头文件第 626 行):

cpp 复制代码
// mesh the volumes of a group of connected regions with the parallel mesher;
// returns 0 on success, 2 if the input is not supported (the caller may fall
// back to another algorithm), 1 on error
int meshGRegionParallelDelaunay(std::vector<GRegion *> &regions);

对应 meshRegions 正文里的三处返回:

返回 触发点 含义
2 collectSurfaceMesh 失败 / 顶点未全部插入 / 有 quad/polygon/非流形 输入不支持,请调用方回退
1 边界恢复失败 / 颜色失败 / 尺寸场求值失败 出错
0 全部成功 成功

MeshDelaunayVolume 正是据此决定是否 Msg::Warning("Falling back to Delaunay (del3d)") (见 6.1.3)。特别是 collectSurfaceMesh 在遇到"曲面含多边形""四边形界两面""无法定向" 等情况时都返回 2(第 3802--3838 行的 Msg::Warning)------pdel3d 对输入有明确的能力边界, 超出就体面地回退,绝不硬撑。

最后,meshRegions 结尾的收尾与 del3d 一致:perturbation.restore() 恢复精确坐标,再 sqr.buildPyramids + RelocateVerticesOfPyramids 处理金字塔(第 5184 行),因为金字塔尖顶 被推入体内部,必须在精确坐标下做。

6.8 MMG3D:各向异性适应

6.8.1 与 Gmsh 的桥接

ALGO_3D_MMG3D 调用 MMG3D 库做各向异性网格适应 ( src/mesh/meshGRegionMMG.cpp)。它不是"从边界造网格",而是 "拿到一张已有的四面体网格,按度量场重新适应"。

桥接有两个方向。MMG2gmsh(第 34 行)把 MMG 结果搬回 Gmsh:

cpp 复制代码
static void MMG2gmsh(GRegion *gr, MMG5_pMesh mmg, std::map<int, MVertex *> &mmg2gmsh)
{
  std::map<int, MVertex *> kToMVertex;
  int np, ne, nt, na, ref;
  if(MMG3D_Get_meshSize(mmg, &np, &ne, nullptr, &nt, nullptr, &na) != 1)
    Msg::Error("Mmg3d: unable to get mesh size");
  // TODO: when MMG is allowed to modify the surface mesh, reclassify nodes accordingly
  for(int k = 1; k <= np; k++) {
    if(MMG3D_Get_vertex(mmg, &cx, &cy, &cz, &ref, nullptr, nullptr) != 1)
      Msg::Error("Mmg3d: unable to get vertex %d", k);
    auto it = mmg2gmsh.find(ref);
    if(it == mmg2gmsh.end()) { MVertex *v = new MVertex(cx, cy, cz, gr); ... }
    else kToMVertex[k] = it->second;
  }
  for(int k = 1; k <= ne; k++) { ... gr->tetrahedra.push_back(new MTetrahedron(v1, v2, v3, v4)); }
}

注意那句 TODO:"当 MMG 被允许修改曲面网格时,需要重新分类节点 "------当前设计假定 MMG3D 只动体内部、不动边界(nosurf 参数,见下)。

反方向 gmsh2MMG(第 105 行)把 Gmsh 网格与度量场送进 MMG。核心是把尺寸场转成度量:

cpp 复制代码
SMetric3 m = BGM_MeshMetric(v->onWhat(), U, V, v->x(), v->y(), v->z());
auto itv = LCS.find(v);
if(itv != LCS.end()) {
  mmg2gmsh[(*it)->getNum()] = *it;
  double LL = itv->second.first / itv->second.second;
  SMetric3 l4(1. / (LL * LL));
  SMetric3 MM = intersection_conserve_mostaniso(l4, m);
  m = MM;
}
if(MMG3D_Set_tensorSol(sol, m(0, 0), m(1, 0), m(2, 0), m(1, 1), m(2, 1), m(2, 2), k) != 1) {
  Msg::Error("Mmg3d: unable to set solution %d", k);
}

6.8.2 度量场:为什么 MMG 天然各向异性

MMG3D 的输入不是"尺寸标量"而是度量张量 (MMG5_Tensor,对称 3×3)。SMetric3 是 Gmsh 的 度量类型;BGM_MeshMetric(第 4 章)从背景网格与几何曲率得到每点度量。度量张量的特征值对应 各方向的目标长度:

  • 各向同性:三个特征值相等 → 度量退化为标量尺寸。
  • 各向异性:特征值不等 → 不同方向要求不同长度,例如薄壁/边界层方向要密、切向要疏。

intersection_conserve_mostaniso(l4, m) 把"二维网格尺寸约束"l4(由 LCS 平均边长的倒数构成, 各向同性)与几何度量 m 求交,并保留最各向异性的那个方向 ------这是"二维尺寸向三维体内部 外推"(Extend2dMeshIn3dVolumes)在度量层面的落地。这解释了 MMG 为什么能做各向异性而 del3d/HXT 不能:它压根不是按"尺寸标量"细化的,而是按度量的椭球 (衡量长度用 √(vᵀ M v))。

6.8.3 updateSizes 与数值保护

updateSizes(第 220 行)在每个新点处重算度量。它先由边界三角的 maxEdge 累积每个点的 平均边长 LCS,再对每个顶点:

cpp 复制代码
SMetric3 m = BGM_MeshMetric(gr, 0, 0, cx, cy, cz);
if(it != mmg2gmsh.end() && Extend2dMeshIn3dVolumes()) {
  auto itv = LCS.find(it->second);
  if(itv != LCS.end()) {
    double LL = itv->second.first / itv->second.second;
    SMetric3 l4(1. / (LL * LL));
    SMetric3 MM = intersection_conserve_mostaniso(l4, m);
    m = MM;
  }
}
if(m.determinant() < 1.e-30) {
  m(0, 0) += 1.e-12; m(1, 1) += 1.e-12; m(2, 2) += 1.e-12;
}

那句 determinant() < 1e-30 的保护 很关键:奇异度量(行列式为 0)会让 MMG 内部求逆失败、 产生非物理的"无限密"方向。这里给对角加微小量把它"推"离奇异------是典型的数值健壮性补丁。

6.8.4 refineMeshMMG:参数与并发禁令

refineMeshMMG(第 280 行)是入口,初始化 MMG 结构、设参数、调用 MMG3D_mmg3dlib:

cpp 复制代码
void refineMeshMMG(GRegion *gr)
{
  MMG5_pMesh mmg = nullptr;  MMG5_pSol sol = nullptr;
  MMG3D_Init_mesh(MMG5_ARG_start, MMG5_ARG_ppMesh, &mmg, MMG5_ARG_ppMet, &sol, ...);
  ...
  MMG3D_Set_iparameter(mmg, sol, MMG3D_IPARAM_nosurf, 1);        // 不改曲面
  double hausd = 0.01 * sqrt3Inv * gr->bounds().diag();          // Hausdorff 距离
  MMG3D_Set_dparameter(mmg, sol, MMG3D_DPARAM_hausd, hausd);
  if(MMG3D_mmg3dlib(mmg, sol) != MMG5_SUCCESS) { ... }           // 真正跑适应(单核)
  ...
}

三个参数:

  • MMG3D_IPARAM_nosurf = 1:不允许 MMG 改曲面------与 6.8.1 的 TODO 呼应。
  • Hausdorff 距离 hausd = 0.01 ×(1/√3)× 包围盒对角长:控制新网格对原几何的逼近误差。
  • MMG3D_mmg3dlib:真正跑适应的调用(此处单核)。

而并发禁令 是本节最值得记住的一点。回忆 MeshDelaunayVolume(6.1.3)里那段注释:

cpp 复制代码
// must stay sequential: MMG3D is not safe to call concurrently from
// multiple threads (two threads independently running
// MMG3D_mmg3dlib segfault reliably). This is not affected by
// General.NumThreads, which never parallelizes this loop or the
// per-region-group loop above it -- only the 1D/2D meshing phases
// and the (mutually exclusive) HXT 3D algorithm honor it.

它极有信息量:MMG3D 完全不能并发调用 (两个线程独立调用 MMG3D_mmg3dlib 会稳定段错误), 因此 General.NumThreads 对它无效;Gmsh 的并行边界很明确------只有 1D/2D 阶段和 HXT 真正遵守 线程数设置 。MMG3D 的实用价值在于各向异性 而非速度。mmg3dCombineDomains(第 6.1.2 节表格) 允许把多个体合并域后一起适应(refineMeshMMGGroup),以处理跨体的各向异性场。

6.9 Netgen:前沿推进体网格

6.9.1 与 Delaunay 的路线之别

ALGO_3D_FRONTAL 调用 Netgen 的前沿推进 (frontal)体网格算法( src/mesh/meshGRegionNetgen.cpp)。与 Delaunay 从"点集 四面体化 + 空球"不同,前沿推进从边界面向内部逐层推进:

  • 从边界三角面出发,构成"前沿"(front)。
  • 每一步选一个前沿面,在它"前方"造一个新顶点,生成一个新四面体,更新前沿。
  • 直到前沿为空。

优点:生成质量更高的四面体(尤其边界附近),收敛稳定;缺点:收敛性/鲁棒性不如 Delaunay, 尤其在复杂几何上可能推进失败或留下空洞。Gmsh 把它作为 Delaunay 的替代选项保留。

meshGRegion(6.1.4)只对 ALGO_3D_FRONTAL 调用它------其余算法全走 Delaunay 类 。这也解释了 为什么 meshGRegionHxt 等"Delaunay 类"都被排除在前沿路径之外。

6.9.2 桥接:buildNetgenStructure / TransferVolumeMesh

buildNetgenStructure(第 46 行)把 Gmsh 的体转成 Netgen 的 Ng_Mesh:给所有边界顶点用 setIndex(I++) 临时编号(1 起,记入 numberedV 供回搬逆映射)逐个 Ng_AddPoint,再遍历曲面 三角 Ng_AddSurfaceElement;导入已有四面体时注意 Netgen 期望负体积四面体 ------getVolumeSign() 的返回值大于 0 时先 reverse()(库间定向约定不同 ,是集成第三方网格器最常见的坑)。TransferVolumeMesh (第 110 行)回搬并"记录哪些顶点真的被四面体使用",剔除 Netgen 返回的未被引用顶点。

6.9.3 meshGRegionNetgen:入口与错误处理

cpp 复制代码
void meshGRegionNetgen(GRegion *gr)
{
#if !defined(HAVE_NETGEN)
  Msg::Error("Frontal algorithm requires Netgen");
#else
  // sanity check for frontal algo: 拒绝四边形边界
  for(GFace *gf : gr->faces())
    if(gf->quadrangles.size()) {
      Msg::Error("Cannot use frontal 3D algorithm with quadrangles on boundary"); return;
    }
  Msg::Info("Meshing volume %d (Frontal)", gr->tag());
  meshNormalsPointOutOfTheRegion(gr);          // 法向调成指向体外
  std::vector<MVertex *> numberedV;
  Ng_Mesh *ngmesh = buildNetgenStructure(gr, false, numberedV);
  SPoint3 pt = gr->bounds().center();
  double lc = BGM_MeshSize(gr, 0, 0, pt.x(), pt.y(), pt.z());   // 整体尺寸驱动
  Ng_GenerateVolumeMesh(ngmesh, lc);
  TransferVolumeMesh(gr, ngmesh, numberedV);
  Ng_DeleteMesh(ngmesh);  Ng_Exit();
#endif
}

三处要点:

  1. 只支持三角形 :与 HXT 一样,前沿算法也拒绝四边形边界("Cannot use frontal 3D algorithm with quadrangles on boundary")。这是 Netgen 前沿推进的固有约束。
  2. 法向定向 :meshNormalsPointOutOfTheRegion(gr) 把边界三角的法向调成"指向体外", 前沿推进需要一致的边界定向来判断"点在哪一侧"。
  3. 尺寸 :只在体心取一个尺寸 lc 传给 Ng_GenerateVolumeMesh------Netgen 前端在 Gmsh 里是 "整体尺寸驱动",不像 HXT/del3d 那样支持逐点尺寸回调。这限制了它对复杂尺寸场的适应能力, 也是它主要作为备选项的原因。
  4. HAVE_NETGEN 桩:未编译 Netgen 时只报"Frontal algorithm requires Netgen"。

6.9.4 Netgen 的优化器

Netgen 还提供一个独立优化器 optimizeMeshGRegionNetgen::operator()(第 342 行),它可以 对已有网格(含体单元)做前沿式优化:

cpp 复制代码
if(gr->prisms.size() || gr->hexahedra.size() || gr->pyramids.size()) {
  Msg::Info("Skipping Netgen optimizer for hybrid mesh");
  return;
}
#if !defined(HAVE_NETGEN)
  Msg::Error("Netgen optimizer is not compiled in this version of Gmsh");
#else
  Msg::Info("Optimizing volume %d", gr->tag());
  std::vector<MVertex *> numberedV;
  Ng_Mesh *ngmesh = buildNetgenStructure(gr, true, numberedV);   // 导入体网格
  deMeshGRegion dem; dem(gr);                                     // 清空后重优化
  SPoint3 pt = gr->bounds().center();
  double lc = BGM_MeshSize(gr, 0, 0, pt.x(), pt.y(), pt.z());
  Ng_OptimizeVolumeMesh(ngmesh, lc);
  TransferVolumeMesh(gr, ngmesh, numberedV);
#endif

它只吃纯四面体网格 (有棱柱/六面体/金字塔就跳过),导入体网格后清空再重优化。第 7 章会看到 OptimizeMesh 里 how == "Netgen" 就是走这条路径。

6.10 DOMHEX / RTREE:六面体主导网格

6.10.1 实验性定位与调用点

ALGO_3D_RTREE(内部名 RTREE,用户常称 DOMHEX/六面体)是 Gmsh 里最年轻的路线。它需要 HAVE_DOMHEX && HAVE_HXT 同时成立,代码在 src/mesh/Generator.cpp 第 1002 行起:

cpp 复制代码
bool hexDominant = false;

for(std::size_t i = 0; i < connected.size(); i++) {
  MeshDelaunayVolume(connected[i]);        // 先生成四面体网格

#if defined(HAVE_DOMHEX) and defined(HAVE_HXT)
  // additional code for experimental hex mesh - will eventually be replaced
  // by new HXT-based code
  for(std::size_t j = 0; j < connected[i].size(); j++) {
    GRegion *gr = connected[i][j];
    bool treat_region_ok = false;
    if(CTX::instance()->mesh.algo3d == ALGO_3D_RTREE) {
      if(old_algo_hexa()) { Filler f; f.treat_region(gr); treat_region_ok = true; }
      else { Filler3D f; treat_region_ok = f.treat_region(gr); }
    }
    if(treat_region_ok && (CTX::instance()->mesh.recombine3DAll ||
                           gr->meshAttributes.recombine3D)) {
      hexDominant = true;
      meshCombine3D(gr);
      RelocateVertices(gr, CTX::instance()->mesh.nbSmoothing);
    }
  }
#endif
}

注释 "experimental hex mesh - will eventually be replaced by new HXT-based code " 是这一路线 的自我定位说明。流程是两步:点插入 (Filler/Filler3D::treat_region,把背景网格的点铺进体, 构成适合重组合的四面体网格)→ 重组合 (meshCombine3D,把四面体合并成六面体主导网格)→ 顶点重定位 (RelocateVertices,优化形状)。注意它仍先调用 MeshDelaunayVolume:DOMHEX 不是从零造网格,而是"先生成四面体网格,再在其中识别可合并成六面体的单元"。

old_algo_hexa() 恒返回 true,于是实际走的是 Filler(旧算法) ,Filler3D 是保留的 新实现------从注释 "will eventually be replaced" 可确认这是迁移中的状态。

6.10.2 Filler / Filler3D:八叉树与背景网格点插入

点插入代码在 contrib/domhex/pointInsertion.cpp( 由 Paul-Emile Bernard 贡献)。Filler3D::treat_region(第 66 行)从背景网格(BGM)出发:

cpp 复制代码
bool Filler3D::treat_region(GRegion *gr)
{
  BGMManager::set_use_cross_field(true);
  ...
  frameFieldBackgroundMesh3D *bgm =
    dynamic_cast<frameFieldBackgroundMesh3D *>(BGMManager::get(gr));
  ...
}

frameFieldBackgroundMesh3D(帧场背景网格)是 DOMHEX 的"骨架":它用一个三维交叉场 (frame field)定义内部点如何按六面体优先的方式铺开。computeSixNeighbors(第 29 行)展示 最基础的点铺展------从一个点出发,沿帧场三个方向各以步长 h 向两侧撒一对点,递归铺满体积:

cpp 复制代码
void computeSixNeighbors(frameFieldBackgroundMesh3D *bgm, MVertex *parent,
                         std::vector<MVertex *> &spawns, STensor3 dir, double h)
{
  double x = parent->x(), y = parent->y(), z = parent->z();
  for(int i = 0; i < 3; i++)                       // 沿帧场 3 个方向,各 ±h
    spawns[i*2]   = new MVertex(x + h*dir(0,i), y + h*dir(1,i), z + h*dir(2,i), gr, 0),
    spawns[i*2+1] = new MVertex(x - h*dir(0,i), y - h*dir(1,i), z - h*dir(2,i), gr, 0);
}

这就是"八叉树 + 点插入"的核心;点集要尽量落在六面体网格的规整位置上,这是后续"合并成 六面体"的前提。

6.10.3 meshCombine3D:从四面体识别六面体

meshCombine3D(src/mesh/meshCombine3D.cpp 第 81 行)把 四面体网格转成六面体。它用 HXTCombine 工具:

其流程是识别 → 选择 → 替换 三步:createMeshStore 打包网格;ccs.computeHexes(minQuality) (配合 HXTCombine::TetMeshForCombining)找出所有能由若干四面体拼成的六面体; ccs.selectCellsGreedy(cellTypes)(cellTypes = {true,false,false,true})贪心挑选互不重叠的 集合;最后删掉全部四面体、写入被选中的六面体。剩下无法合并的四面体保留,配合 6.10.5 的三面体 过渡单元保证共形。

6.10.4 RelocateVertices / RelocateVerticesOfPyramids

合并出的六面体质量对顶点位置很敏感,因此紧接着做顶点重定位( src/mesh/meshRelocateVertex.cpp)。核心是 Maximize_Quality_Golden_Section:顶点的自由度被约束在它所依附的几何实体上(曲线一维、曲面 二维、体内三维),每次只沿一个参数方向移动,用黄金分割一维搜索 找使相邻单元质量最大的位置 (比任意方向的牛顿法更稳、更便宜)。RelocateVertices(region, niter, tol) 做通用重定位; RelocateVerticesOfPyramids 是金字塔专有版本------金字塔质量对尖顶位置极敏感,MeshDelaunayVolume 在生成金字塔后即调用它。代码里还有 _untanglePyramids(拓扑/几何两种模式)专解"金字塔缠绕", 说明金字塔是混合网格里最脆弱的一环。

6.10.5 共形过渡:MakeHybridHexTetMeshConformalThroughTriHedron

六面体与四面体交界处会出现悬挂面(hanging faces) ------一个四边形的面在一侧属于六面体、另一侧 却由两个三角面拼成。为保证共形,Gmsh 造三面体 MTrihedron 过渡(第 4 章已见其定义):

cpp 复制代码
bool MakeHybridHexTetMeshConformalThroughTriHedron(GModel *gm)
{
  fs_cont search;  buildFaceSearchStructure(gm, search);
  std::map<MFace, GRegion *, MFaceLessThan> bnd;
  for(auto rit = gm->firstRegion(); rit != gm->lastRegion(); ++rit) buildUniqueFaces(*rit, bnd);
  // bnd2 contains non conforming faces:只出现一次、且在几何边界上找不到对应的面
  std::map<MFace, GRegion *, MFaceLessThan> bnd2;
  for(auto itf = bnd.begin(); itf != bnd.end(); ++itf)
    if(!findInFaceSearchStructure(itf->first, search)) bnd2[itf->first] = itf->second;
  // create trihedron:对每个四边形悬挂面造 MTrihedron 填缝
  for(auto itf = bnd2.begin(); itf != bnd2.end(); ++itf) { ... new MTrihedron(...); }
  return true;
}

逻辑是:找出"只出现一次"的面 (buildUniqueFaces),再筛出在几何边界上找不到对应 的 (bnd2)------这些就是非共形的悬挂面;对每个四边形悬挂面,造一个 MTrihedron 填住缺口。 MTrihedron 是一个 4 节点的退化单元(把一个四边形剖分成两个三角形),使六面体侧与四面体侧 在交界面上"同构"。Mesh3D 在 hexDominant 为真时统一调用它(见 6.12.4)。

6.11 结构化三维:Transfinite 与 Extruded

与自由网格平行的结构化路线有两条,Mesh3D 最先执行它们。

  • Transfinite Volume :MeshTransfiniteVolume(gr)(src/mesh/meshGRegionTransfinite.cpp 第 343 行),meshGRegion::operator() 最先尝试,成功即返回。算法是三线性插值铺单元 ------把体 映射到标准六面体 (u,v,w) ∈ [0,1]³,按各方向分段数生成结构化点阵 tab[i][j][k],用宏 CREATE_HEX/CREATE_PRISM_*/CREATE_SIM_* 按 5 面或 6 面拓扑生成六面体/棱柱/四面体。限制 严格:仅 5/6 面体、边界曲线必须有起止点、角点数须为 6 或 8;文件头还注明 "meshing a volume with prisms or tetrahedra ...... there is no coherence check"(不做一致性检查)。
  • Extruded :meshGRegionExtruded::operator()(src/mesh/meshGRegionExtruded.cpp 第 258 行)只处理 EXTRUDED_ENTITY,用 MVertexRTree 加速顶点查重,沿 NbLayer × NbElmLayer 分层铺单元(三角面→棱柱 6 点,四边形→六面体 8 点)。SubdivideExtrudedMesh 是全局操作, 可能反过来改曲面网格(第 4 章的"维间耦合")。

结构化路线的单元用户可控(给定分区数则网格唯一确定),常用于需要严格结构化六面体的场合, 代价是要求几何自带"可映射"拓扑,因此只覆盖一部分模型。

6.12 收尾与一致性

三维网格生成完(或按组分完)后,Mesh3D 做统一收尾( src/mesh/Generator.cpp 第 1040 行):

cpp 复制代码
if(hexDominant)
  MakeHybridHexTetMeshConformalThroughTriHedron(m);

// ensure that all volume Jacobians are positive
m->setAllVolumesPositive();

if(Msg::GetVerbosity() > 98)
  std::for_each(m->firstRegion(), m->lastRegion(), EmbeddedCompatibilityTest());

if(m->getNumRegions()) { Msg::ProgressMeter(1, false, "Meshing 3D..."); Msg::StopProgressMeter(); }

CheckEmptyMesh(m, 3);

6.12.1 setAllVolumesPositive:Jacobian 为正

无论哪个算法生成网格,最终都可能有反向(负体积)单元 。setAllVolumesPositive (src/geo/GModel.cpp 第 1314 行)统一翻正:

cpp 复制代码
bool GModel::setAllVolumesPositive()
{
  int ok = 1;
  for(auto it = regions.begin(); it != regions.end(); ++it) {
    const std::size_t ne = (*it)->getNumMeshElements();
    const int nthreads = CTX::instance()->numThreadsFor(ne, 1 << 16);
#pragma omp parallel for schedule(static) num_threads(nthreads) reduction(min : ok)
    for(std::size_t i = 0; i < ne; ++i)
      if(!(*it)->getMeshElement(i)->setVolumePositive()) ok = 0;
  }
  return ok;
}

它并行 遍历所有体的所有单元,逐个 setVolumePositive()(若体积为负就把节点顺序反转)。返回 ok 表示是否所有单元都能翻正(不能翻正说明单元退化/翻转严重)。这一步至关重要:无论上游是 Delaunay、HXT 还是 MMG,统一翻正保证后续有限元计算的 Jacobian 为正 。它还出现在 MeshDelaunayVolume(生成金字塔后)与 pdel3d(生成金字塔后)------凡是有金字塔的地方都要翻正。

6.12.2 EmbeddedCompatibilityTest:嵌入边/面的最终诊断

若用户用了嵌入边/面,收尾时可以验证它们是否真的出现在最终网格里 。EmbeddedCompatibilityTest (第 83 行)只在 Msg::GetVerbosity() > 98 时执行(高调试级别):

cpp 复制代码
void operator()(GRegion *gr)
{
  std::vector<GEdge *> const &e = gr->embeddedEdges();
  std::vector<GFace *> const &f = gr->embeddedFaces();
  if(e.empty() && f.empty()) return;
  std::map<MEdge, GEdge *, MEdgeLessThan> edges;   // 收集所有嵌入边/面的网格元素
  std::map<MFace, GFace *, MFaceLessThan> faces;
  ...
  for(std::size_t k = 0; k < gr->getNumMeshElements(); k++) {   // 从体单元里逐个划掉
    for(int j = 0; j < gr->getMeshElement(k)->getNumEdges(); j++)
      edges.erase(gr->getMeshElement(k)->getEdge(j));
    for(int j = 0; j < gr->getMeshElement(k)->getNumFaces(); j++)
      faces.erase(gr->getMeshElement(k)->getFace(j));
  }
  if(edges.empty() && faces.empty()) Msg::Info("All embedded edges and faces are present");
  if(edges.size())     // → 写 missingEdgesOnRegion<tag>.pos
    Msg::Warning("Region %d : %d embedded mesh edges missing", gr->tag(), (int)edges.size());
  if(faces.size())     // → 写 missingFacesOnRegion<tag>.pos
    Msg::Warning("Volume %d : %d embedded mesh faces missing", gr->tag(), (int)faces.size());
}

它用**"先登记、再逐个从体单元里划掉"的办法:遍历所有体单元,把它们的边/面从待查集合里 erase;最后集合里剩下来的就是 该出现却没出现的嵌入边/面。若不为空,把缺失的边/面写成 .pos 可视化文件(missingEdgesOnRegion<tag>.pos / missingFacesOnRegion<tag>.pos),供用户 诊断。这是 嵌入约束是否被满足的最终验收**------也呼应第 2 章"嵌入实体"机制与 6.3.1 的 nonconvex 约束绑定。

6.12.3 CheckEmptyMesh:报告空体

CheckEmptyMesh(m, 3)(第 481 行)检查是否有体一个单元都没生成:

cpp 复制代码
static void CheckEmptyMesh(GModel *m, int dim)
{
  std::vector<int> tags;  std::vector<GEntity *> entities;
  m->getEntities(entities, dim);
  for(auto ge : entities) {
    if(CTX::instance()->mesh.meshOnlyVisible && !ge->getVisibility()) continue;
    else if(ge->isFullyDiscrete()) continue;
    // mesh still pending, failed, ...
    if(ge->getNumMeshElements() == 0) tags.push_back(ge->tag());
  }
  if(!tags.empty()) {
    std::stringstream msg;
    for(auto t : tags) msg << " " << t;
    Msg::Warning("No elements in %s%s", (dim == 3) ? "volume" : (dim == 2) ? "surface" : "curve",
                 msg.str().c_str());
  }
}

它把"仍待处理却没有单元"的体编号汇总成一条警告。这正是 MeshDelaunayVolume 各种失败路径 (HXT 报错、pdel3d 回退后失败、边界恢复失败)的"最后一公里"反馈:失败不会静默,用户在收尾时 一定会看到哪个体是空的。

6.12.4 与第 4 章调度、第 7 章优化的接口

三维阶段的完整顺序(回顾 6.0 的 Mesh3D):拉伸体 → 拉伸细分 → 非 Delaunay(Transfinite 优先、 FRONTAL→Netgen、其余入队列)→ 连通分量分组 → 逐组 MeshDelaunayVolume(含 quadsToTriangles) → DOMHEX 追加 → 共形三面体 → setAllVolumesPositive → EmbeddedCompatibilityTest(高 verbosity) → CheckEmptyMesh。它延续第 4 章的调度骨架,并把第 7 章的接口留在 optimizeMeshGRegion (对纯四面体网格做 optimizeMesh(gr, qmTetrahedron::QMTET_GAMMA),跳过 Transfinite/Extruded; pdel3d 放弃的部分由此补做)与 optimizeMeshHXT/optimizeMeshGRegionNetgen。第 7 章讲完整优化。

6.13 常见 Q&A

Q1:为什么三维 Delaunay 比二维难这么多? E:核心是约束恢复 。二维里线段总能靠有限次 边翻转恢复;三维里一个三角面片可能"穿过"多个四面体、翻转空间不足,必须插入 Steiner 点 (6.3)。其次是空腔星形性 :三维常需迭代删面-删体-重建(6.2.5)。再加上共球退化更频繁 (insphere 精确返回 0),需要符号扰动(6.2.3)。

Q2:边界恢复为什么需要 Steiner 点? E:Delaunay 只保证空球性质,不保证边界三角出现在网格 里。当翻转无法满足约束时,只能在体内/曲线上/曲面上插入新点 重排连接以腾出空间;Gmsh 用 steinerType 区分落点,并重建受影响的曲线/曲面网格(6.3.3)。

Q3:各 algo3d 该选哪个? E:经验法则:

场景 推荐
通用、追求速度与规模 HXT(并行、图着色、自带细化/优化)
无 HXT 但要并行 pdel3d(自研并行,失败回退 del3d)
各向异性(薄壁/边界层/度量场) MMG3D(唯一支持张量度量;但不可并发)
追求边界附近高质量四面体 Netgen(front3D;整体尺寸驱动、只吃三角形)
需要结构化六面体 Transfinite / Extruded
六面体主导(实验) DOMHEX(RTREE,需 HAVE_DOMHEX && HAVE_HXT)
只做初始四面体化 INITIAL_ONLY

性能上,HXT 在同类问题里通常最快且近线性扩展;pdel3d 次之;顺序 del3d 是稳健基准; MMG3D/Netgen 主要按"能力"而非"速度"选。

Q4:HXT 与 Netgen 为什么只接受三角形边界? E:两者的边界恢复/前沿推进都建立在"三角面" 之上------三角面是最简单、定向最清晰的约束。四边形面会引入非唯一剖分与共形难题,因此若要混用 六面体边界,Gmsh 会先把四边形转三角形(第 4 章 quadsToTriangles)。

Q5:MMG3D 为什么不能并发? E:源码注释直说:两个线程独立调用 MMG3D_mmg3dlib 会稳定 段错误 (全局状态共享)。General.NumThreads 对它无效------Gmsh 只在 1D/2D 阶段与 HXT 阶段 真正并行(6.8.4)。

Q6:为什么 del3d/pdel3d 要"随机扰动坐标"? E:曲面 CAD 的节点舍入级别共球、平面面片 共面,会把谓词逼进精确算术(慢 3--5 倍)、并让初始 Delaunay 漏掉更多边界三角(恢复慢最多 8 倍)。施加 Mesh.RandomFactor3D 的小扰动打破退化,实测显著加速;网格交回前再 restore() (6.2.3、6.7.7)。

Q7:pdel3d 返回 2 是什么意思? E:2 = "输入不支持,请回退"(如曲面含多边形、四边形界 两面、顶点未能全部插入)。1 = 出错,0 = 成功。MeshDelaunayVolume 见到 2 就打警告并 继续走顺序 del3d(6.1.3、6.7.7)。

Q8:金字塔与三面体起什么作用? E:MPyramid(5 节点)让四边形边界与四面体内部衔接; MTrihedron(4 节点,退化)让六面体与四面体的交界面同构。它们都是混合网格的过渡单元 , 由 splitQuadRecovery 与 MakeHybridHexTetMeshConformalThroughTriHedron 生成(6.3.4、6.10.5)。

Q9:嵌入边/面没被恢复怎么办? E:边界恢复会尽量把嵌入实体做成四面体的边/面集合;若最终 仍缺失,收尾时的 EmbeddedCompatibilityTest 会把缺失项写成 missingEdgesOnRegion*.pos / missingFacesOnRegion*.pos 供查看(6.12.2)。

6.14 术语表

术语 含义
Delaunay 四面体化 满足"空球"性质的四面体剖分:每个四面体的外接球内不含其他顶点
外接球心 四面体四顶点的球心;tetcircumcenter 计算,是点插入的候选点
orient3d / insphere Shewchuk 鲁棒有向体积/内接球谓词;robustPredicates 命名空间
鲁棒谓词 / 静态滤波 先浮点估计,超出静态阈值才升级精确算术,兼顾符号正确与速度
符号扰动(SoS) 共球退化时按顶点编号排序并修正符号打破平局,保证确定性
Bowyer-Watson / 空腔 增量点插入:找空腔(外接球含新点的连通四面体)、删空腔、以新点重连
星形性(star-shaped) 空腔边界每个面都能从新点"看见"(orient3d > 0)
Hilbert / Moore 曲线 三维空间填充曲线;用于点排序,使插入局部化
边界恢复 / Steiner 点 把边界面/段恢复成四面体的面/边;恢复时插入的额外点
carve / nonconvex 删掉曲面外四面体 / 处理空腔型网格的恢复选项
边删除(edge removal) 局部恢复操作:删环重建以造出目标边/面
幽灵四面体 / 颜色 含"无穷远顶点"闭合凸包的四面体;标记四面体所属体(COLOR_OUT 表体外)
gamma 质量 四面体质量度量(=3×内切半径/外接半径),QMTET_GAMMA
度量(metric) 对称 3×3 张量,描述各方向目标长度(MMG3D 输入)
金字塔 / 三面体 混合网格过渡单元:MPyramid(5 节点) / MTrihedron(4 节点)
前沿推进(frontal) 从边界面向内逐层推进生成单元的算法(Netgen)
HAVE_* 宏 HXT/MMG/NETGEN/DOMHEX 等第三方引擎的编译期开关
Jacobian 为正 所有体单元定向正确(体积为正),setAllVolumesPositive 保证

6.15 本章小结

三维体网格的设计要点可归纳为七条:

  1. 四条路线 :结构化(Transfinite/Extruded)、Delaunay 自由网格(del3d/HXT/pdel3d/MMG3D)、 Netgen 前沿、DOMHEX 六面体主导;MeshDelaunayVolume 是分派枢纽。
  2. Delaunay 四步走:外接球心 → 初始四面体化(Bowyer-Watson + Hilbert 排序 + 包围盒)→ 边界恢复(TetGen 风格 + Steiner 点)→ 点插入(外接半径 + 尺寸判据)。
  3. 鲁棒谓词贯穿全程 :orient3d/insphere 保证符号正确、避免除零;符号扰动保证可复现; 坐标扰动规避退化。
  4. 平面数组是主流:边界恢复 I/O、flat 内核、HXT、pdel3d、Tet4 全用整数数组;MTet4 正被 flat 取代(逐位一致)。
  5. 局部优先、全局兜底:pdel3d 恢复依次是边删除 → 局部 TetGen → 全局 TetGen;HXT 用图着色 + 颜色替代逐块分类。
  6. 能力矩阵用编译宏表达:HXT 只吃三角形且不回退;pdel3d 失败回退 del3d;MMG3D 各向异性但 禁并发;Netgen 前沿但整体尺寸驱动;DOMHEX 实验性且需双宏。
  7. 收尾统一 :setAllVolumesPositive 翻正负体积、MakeHybridHexTetMeshConformalThroughTriHedron 补共形三面体、EmbeddedCompatibilityTest 诊断嵌入缺失、CheckEmptyMesh 报告空体------失败 不会静默。

第 7 章讨论网格质量、优化算法与高级技术(边界层、高阶、分区、细化)。

关键词 / 索引

  • 算法与分派 :ALGO_3D_* 枚举;MeshDelaunayVolume、meshGRegion::operator(); Mesh.Algorithm3D、Mesh.OptimizePyramids、Mesh.FlatRefine3D、Mesh.MaxIterDelaunay3D、 Mesh.RandomFactor3D、Mesh.Mmg3dCombineDomains、Mesh.MaxNumThreads3D、Mesh.CompoundClassify。
  • del3d :tetcircumcenter、delaunayMeshIn3D、insertVerticesInRegion、 refineRegionMTet4/refineRegionFlat、delaunayInsert、walk、fixDelaunayCavity、 starShapeness、initialCube、HilbertSortB、inSphereTest_s、vertLess。
  • 鲁棒谓词 :src/numeric/robustPredicates.h 的 orient3d/insphere/exactinit/o3dstaticfilter/ispstaticfilter。
  • 边界恢复 :meshGRegionBoundaryRecovery、boundaryRecoveryInput/Output、 initialTetrahedralization、newSteinerVertex、splitQuadRecovery、regionGroupBoundary、 buildFaceSearchStructure。
  • 点插入与分类 :classifyTetrahedraInRegions、getSharedFace、insertVertexInFaceTriangulation、 _deleteUnusedVertices、tetRadiusQueue、findCavity、makeCavityStarShaped、 isCavityCompatibleWithEmbeddedEdges/Face。
  • 局部修改 :src/mesh/meshGRegionLocalMeshMod.h 的 edgeSwap/faceSwap/smoothVertex/collapseVertex/BuildSwapPattern3..7、GMSH_EVALONLY。
  • HXT :meshGRegionHxt、delaunayMeshIn3DHxt、optimizeMeshHXT、nodalSizesCallBack、 HXTTetMeshOptions、hxt_boundary_recovery、HXT_COLOR_OUT、HAVE_HXT。
  • pdel3d :meshGRegionParallelDelaunay、meshRegions、recoverLocally、colorVolumes、 makePartitions、computeNumberOfThreads、tooClose、gammaQuality、PodVector、GHOST、 COLOR_OUT、MooreCurve、perturbedCoordinates。
  • MMG3D :refineMeshMMG、MMG2gmsh、gmsh2MMG、updateSizes、SMetric3、BGM_MeshMetric、 intersection_conserve_mostaniso、MMG3D_mmg3dlib、HAVE_MMG。
  • Netgen :meshGRegionNetgen、optimizeMeshGRegionNetgen、buildNetgenStructure、 TransferVolumeMesh、Ng_GenerateVolumeMesh、meshNormalsPointOutOfTheRegion、HAVE_NETGEN。
  • DOMHEX / RTREE :Filler/Filler3D、frameFieldBackgroundMesh3D、meshCombine3D、 HXTCombine、RelocateVertices、RelocateVerticesOfPyramids、 MakeHybridHexTetMeshConformalThroughTriHedron、MTrihedron、HAVE_DOMHEX。
  • 结构化 / 收尾 :MeshTransfiniteVolume、meshGRegionExtruded、SubdivideExtrudedMesh、 setAllVolumesPositive、EmbeddedCompatibilityTest、CheckEmptyMesh、 missingEdgesOnRegion*.pos/missingFacesOnRegion*.pos。
相关推荐
鶴哥只手遮天1 小时前
OpenFOAM 源码深度剖析 · 第二季-02 燃烧与化学反应:`combustionModel`、化学求解器与 Arrhenius 反应率
算法·开源
鶴哥只手遮天1 小时前
Gmsh 源码深度解析-第 3 章 脚本语言与解析器
算法·开源
鶴哥只手遮天1 小时前
OpenFOAM 源码深度剖析 · 第二季-16 工程与工具链:Windows 交叉编译、性能剖析、数据交换与测试
算法·开源
鶴哥只手遮天1 小时前
Gmsh 源码深度解析-第 5 章 二维曲面网格算法
算法·开源
鶴哥只手遮天1 小时前
Gmsh 源码深度解析-第 1 章 架构总览与数据模型
算法·开源
鶴哥只手遮天1 小时前
OpenFOAM 源码深度剖析 · 第二季-12 `coded` 系列:在字典里写 C++ 并在运行期编译
算法·开源
鶴哥只手遮天1 小时前
OpenFOAM 源码深度剖析 · 第二季-01 多相流与 VOF:`interFoam`、界面压缩与 MULES
算法·开源
鶴哥只手遮天1 小时前
OpenFOAM 源码深度剖析 · 第二季-14 `functionObjects`、求解器控制框架 与 表达式系统
算法·开源
鶴哥只手遮天1 小时前
OpenFOAM 源码深度剖析 · 第二季-13 `fvOptions` / `fvConstraints` / `corrections`
算法·开源