Gmsh 源码深度解析-第 5 章 二维曲面网格算法

Gmsh 源码深度解析-第 5 章 二维曲面网格算法

本章目标:讲清 Gmsh 的二维曲面网格体系------算法枚举与分派、曲面参数化、Shephard-Beall 双向数据结构(BDS)、各向异性 Delaunay、边界恢复、前沿推进、从三角形到全四边形的 重组合算法,以及准结构化(QuadQS)与超限插值(Transfinite)两条结构化路线。

第 4 章讲了一维边离散与尺寸场(Field)的求值;本章讲这些一维节点如何被"缝"进一张二维 曲面网格,以及曲面网格如何被优化、重组合、并交给第 6 章的三维网格器当边界。

5.0 一张全景图

先给出本章骨干结论,后面所有小节都是在为它提供证据:

  1. 除 Transfinite 与 Extrude 两条结构化/拉伸路径外,Gmsh 的所有二维自由网格算法都以同一 套 "BDS + 边界恢复 + 增量 Delaunay" 骨架为核心。区别只在"内部点从哪里来"------纯 Delaunay 只插入边界离散点;Frontal-Delaunay 主动按尺寸场造点;PACK 从背景场打包点; QuadQS 用背景网格 + 引导场(cross field)驱动。
  2. 一切三角化都发生在参数平面 (u,v) 里 。Delaunay 的空圆判据只在平面上有定义;曲面 通过 GFace::point(u,v)、parFromPoint 映射回三维,通过 buildMetric 构造的度量张量 补偿参数化畸变。
  3. 网格对象必须保持几何分类 。边界点必须落在原曲线上,边界边必须等于原一维网格线段, 否则曲面网格与边界网格对不上,第 6 章的三维网格也无从下手。因此 Delaunay 中每一次边翻转 都要经过 BDS_SwapEdgeTest 家族审查。
  4. 一维网格的自交是二维网格失败的头号原因 。EdgeToRecover + recover_edge + remeshUnrecoveredEdges + maxRetries 构成"检测交叉→加密一维网格→整体重来"的回路。

本章按数据流顺序展开:算法与分派(5.1)→ 参数化(5.2)与各向异性度量(5.3)→ 核心数据结构 BDS(5.4)→ 边界恢复(5.5)→ 结果回填(5.6)→ 主控制流(5.7)→ 前沿推进(5.8)→ 打包 (5.9)→ 重组合(5.10)→ QuadQS(5.11)→ Transfinite(5.12)→ 质量与优化(5.13)→ 接口 (5.14)→ Q&A(5.15)→ 术语表(5.16)→ 小结(5.17)。

5.1 算法枚举与分派

5.1.1 algo2d 枚举

二维曲面网格算法由 CTX::instance()->mesh.algo2d 全局选择(每个面可用 gf->meshAttributes.algorithm 覆盖)。枚举定义在 src/common/GmshDefines.h(第 239--248 行),注释写着 "numbers should not be changed"------这些整数会写进 .msh 与脚本,属于稳定 ABI:

c 复制代码
// 2D meshing algorithms (numbers should not be changed)
#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
#define ALGO_2D_FRONTAL_QUAD      8
#define ALGO_2D_PACK_PRLGRMS      9
#define ALGO_2D_PACK_PRLGRMS_CSTR 10
#define ALGO_2D_QUAD_QUASI_STRUCT 11

注意编号里 4 被跳过(历史遗留)。逐个解释:

值 名称 含义 本节
1 ALGO_2D_MESHADAPT 不跑 Delaunay 主路径,靠 refineMeshBDS 的 split/swap/collapse/smooth 迭代 5.7
2 ALGO_2D_AUTO 默认值:平面走 Delaunay,曲面自动 5.1.3
3 ALGO_2D_INITIAL_ONLY 只做边界点的初始三角化,不插内部点,仍参与重组合 5.7.1
5 ALGO_2D_DELAUNAY 经典增量 Bowyer-Watson,只用边界离散点 5.8
6 ALGO_2D_FRONTAL 前沿推进:在前沿上按尺寸场主动造点 5.8
7 ALGO_2D_BAMG 调用 BAMG 各向异性网格器 5.3、5.8
8 ALGO_2D_FRONTAL_QUAD Frontal 的四边形变体 5.8.2
9 ALGO_2D_PACK_PRLGRMS 打包平行四边形(PACK) 5.9
10 ALGO_2D_PACK_PRLGRMS_CSTR PACK 的约束变体,已废弃 5.9
11 ALGO_2D_QUAD_QUASI_STRUCT 准结构化全四边形(QuadQS) 5.11

5.1.2 逐面覆盖与第一层分派

GFace::getMeshingAlgo(src/geo/GFace.cpp 第 59--65 行)把"逐面 覆盖 + 全局回落"落实:

cpp 复制代码
int GFace::getMeshingAlgo() const
{
  if(meshAttributes.algorithm) return meshAttributes.algorithm;   // 面级覆盖
  else return CTX::instance()->mesh.algo2d;                       // 全局默认
}

meshGFace.cpp 的所有分支都建立在一个判断上------这个面是否走"基于 Delaunay 的路径" (第 301--318 行):

cpp 复制代码
static bool algoDelaunay2D(GFace *gf)
{
  if(gf->getMeshingAlgo() == ALGO_2D_DELAUNAY ||
     gf->getMeshingAlgo() == ALGO_2D_BAMG ||
     gf->getMeshingAlgo() == ALGO_2D_FRONTAL ||
     gf->getMeshingAlgo() == ALGO_2D_FRONTAL_QUAD ||
     gf->getMeshingAlgo() == ALGO_2D_PACK_PRLGRMS ||
     gf->getMeshingAlgo() == ALGO_2D_PACK_PRLGRMS_CSTR ||
     gf->getMeshingAlgo() == ALGO_2D_BAMG)
    return true;

  if(gf->getMeshingAlgo() == ALGO_2D_AUTO && gf->geomType() == GEntity::Plane) return true;
  if(gf->getMeshingAlgo() == ALGO_2D_INITIAL_ONLY) return true;
  return false;
}

两个细节:ALGO_2D_BAMG 出现了两次 (第 304、309 行),属无害冗余,是编辑遗留------提醒 读者别假设"无重复"是设计意图;ALGO_2D_INITIAL_ONLY 返回 true,因为"初始网格"本身仍 需要 Delaunay 骨架完成边界恢复与外围剔除,只是不进入造点阶段。

"走 Delaunay 骨架"与"跑哪个造点算法"是两件事:前者由 algoDelaunay2D 决定,后者由 runDelaunayAlgorithm 里的二次分派决定。

5.1.3 meshGFace::operator():顶层分派

meshGFace::operator()(第 2339--2478 行)是每维分派点,按顺序处理这些"短路",顺序本身 有语义:

cpp 复制代码
void meshGFace::operator()(GFace *gf, bool print)
{
  gf->model()->setCurrentMeshEntity(gf);
  if(gf->meshAttributes.method == MESH_NONE) return;                 // 无网格需求
  if(CTX::instance()->mesh.meshOnlyVisible && !gf->getVisibility()) return;
  if(CTX::instance()->mesh.meshOnlyEmpty && gf->getNumMeshElements()) return;

  deMeshGFace dem;  dem(gf);                                         // 先删旧网格
  if(MeshTransfiniteSurface(gf)) { ... return; }                     // Transfinite 路径
  if(MeshExtrudedSurface(gf)) return;                                // Extrude 路径
  if(gf->getMeshMaster() != gf) { copyMesh(gff, gf); ... return; }   // 网格从属面
  ...
}

逐条:MESH_NONE 表示脚本里 Mesh 0/-1,这个面不参与网格生成;meshOnlyVisible / meshOnlyEmpty 支持大模型增量重网格;deMeshGFace 删旧网格(isFullyDiscrete() 的离散面 跳过,因为它的网格就是几何);Transfinite 优先于一切 (返回非零即"已处理完"), Extrude 次之 ,两者都不走 Delaunay;网格从属面要等主面 DONE 后 copyMesh。

处理完短路后判断是否周期性:

cpp 复制代码
  bool singularEdges = false;
  auto ite = gf->edges().begin();
  while(ite != gf->edges().end()) { if((*ite)->isSeam(gf)) singularEdges = true; ite++; }

  bool periodic = (gf->getNativeType() != GEntity::GmshModel) &&
                  (gf->periodic(0) || gf->periodic(1) || singularEdges);

singularEdges 指缝边(seam) ------一条曲线在参数域两端首尾相接(柱面的母线就是典型)。 有缝边会触发"周期性生成器"meshGeneratorPeriodic。注意 getNativeType() != GEntity::GmshModel:自研 GEO 内核不走周期性生成器,这是双内核差异在网格层的体现 (对照第 2 章 2.1.3)。

cpp 复制代码
  quadMeshRemoveHalfOfOneDMesh halfmesh(gf, periodic);
  if(periodic) {
    if(!meshGeneratorPeriodic(gf, 0, repairSelfIntersecting1dMesh, false)) { ... }
  }
  else {
    meshGenerator(gf, 0, repairSelfIntersecting1dMesh,
                  (gf->getMeshingAlgo() == ALGO_2D_INITIAL_ONLY) ?
                    MeshExtent::InitialOnly : MeshExtent::Full,
                  false, nullptr);
  }

quadMeshRemoveHalfOfOneDMesh halfmesh(gf, periodic); 是一个 RAII 对象 :构造时把一维网格 "隔一个点删一个"(5.10.3),finish() 时再细分回来;只对全四边形重组合 (algoRecombine >= 2 且非 4)生效。meshGenerator 与 meshGeneratorPeriodic 是并列的两条 主线,共享大量子过程(BDS2GMSH、setupBackgroundMesh、runDelaunayAlgorithm、 recombineSurfaceMesh、finishSurfaceMesh),区别在边界节点收集(集合去重 vs 保序环形列表) 与缝节点处理(_periodicCounterpart、双朝向、容差逐级放大)。

5.1.4 runDelaunayAlgorithm:决定"内部点从哪来"

cpp 复制代码
static void
runDelaunayAlgorithm(GFace *gf, bool infty, bool seedBamgWithBowyerWatson,
                     std::map<MVertex *, MVertex *> *equivalence,
                     std::map<MVertex *, SPoint2> *parametricCoordinates,
                     std::vector<SPoint2> *true_boundary)
{
  const bool flat = CTX::instance()->mesh.flatRefine2D != 0;

  if(gf->getMeshingAlgo() == ALGO_2D_FRONTAL) {
    if(flat) bowyerWatsonFrontalFlat(gf, equivalence, parametricCoordinates, true_boundary);
    else     bowyerWatsonFrontal(gf, equivalence, parametricCoordinates, true_boundary);
  }
  else if(gf->getMeshingAlgo() == ALGO_2D_FRONTAL_QUAD) {
    bowyerWatsonFrontalLayers(gf, true, equivalence, parametricCoordinates);
  }
  else if(gf->getMeshingAlgo() == ALGO_2D_PACK_PRLGRMS) {
    bowyerWatsonParallelograms(gf, equivalence, parametricCoordinates);
  }
  else if(gf->getMeshingAlgo() == ALGO_2D_PACK_PRLGRMS_CSTR) {
    Msg::Error("ALGO_2D_PACK_PRLGRMS_CSTR deprecated");
  }
  else if(gf->getMeshingAlgo() == ALGO_2D_DELAUNAY || gf->getMeshingAlgo() == ALGO_2D_AUTO) {
    if(flat) bowyerWatsonFlat(gf, 1000000000, equivalence, parametricCoordinates);
    else     bowyerWatson(gf, 1000000000, equivalence, parametricCoordinates);
  }
  else {
    if(seedBamgWithBowyerWatson) {
      if(flat) bowyerWatsonFlat(gf, 15000, equivalence, parametricCoordinates);
      else     bowyerWatson(gf, 15000, equivalence, parametricCoordinates);
    }
    meshGFaceBamg(gf);
  }

  if(!infty || !(CTX::instance()->mesh.recombineAll || gf->meshAttributes.recombine))
    laplaceSmoothing(gf, CTX::instance()->mesh.nbSmoothing, infty);
}
  • flat 不是另一个算法,而是另一个内核 。Mesh.FlatRefine2D 非零时,经典的 std::set<MTri3*> 版本被换成 meshGFaceDelaunayFlat.cpp 里的索引数组版本。该文件头注释 申明:"These are not different algorithms: same in-circle and optimal-point formulas... they produce the same mesh, element for element and node for node." 当 FlatRefine2D = 1 时网格 逐元素、逐节点完全相同,只是数据结构不同;设成 2 才打开若干浮点捷径,此时网格只是 "等价"(注释量化:平面提速约 14%,曲面几乎为 0,节点数差异 0.01% 以内)。
  • bowyerWatson(gf, 1000000000, ...) 的第二参数是点数上限;1e9 表示"不设实际上限",终止由 "最差三角形半径 < √2/2"驱动。
  • BAMG 的种子 :seedBamgWithBowyerWatson 只在非周期生成器里为 true。注释解释两条生成器 行为不同,且 BAMG 本身非确定("a plain square gives four different meshes in five runs"), 因此保留差异、不做统一。
  • 末尾平滑 :laplaceSmoothing 只在"非无限度量"或"不要求重组合"时执行。infty 来自 setupBackgroundMesh:对 FRONTAL_QUAD 与 PACK_*,生成器工作在背景网格的"无限度量"下, 此时不额外做 Laplace 平滑。

5.1.5 失败回退:algoSwitchOnFailure

(第 2465--2477 行)

cpp 复制代码
  if(CTX::instance()->mesh.algoSwitchOnFailure &&
     gf->getNativeType() != GEntity::GmshModel &&
     gf->geomType() != GEntity::Plane && algoDelaunay2D(gf) && !isMeshValid(gf)) {
    Msg::Debug("Delaunay-based mesher failed on surface %d -> moving to MeshAdapt", gf->tag());
    deMeshGFace killer;  killer(gf);
    gf->setMeshingAlgo(1);        // 临时改成 ALGO_2D_MESHADAPT
    (*this)(gf, print);           // 递归调用自己
    gf->unsetMeshingAlgo();       // 恢复
  }

isMeshValid(第 2306--2317 行)用 TRIANGLE_VALIDITY(第 2285--2304 行)统计法向不一致的 三角形:三个角点法向平均后与三角形几何法向点乘,< 0 记为无效;"全正或全负都算有效" (整体朝向可能合法地是反的),只有"正负混杂"才说明有折叠/翻卷。第 2466 行 getNativeType() != GEntity::GmshModel 的原因见注释:"currently we cannot reliably evaluate the normal on the boundary of surfaces with the Gmsh kernel"。这个递归是本函数唯一的自调用, 也是 ALGO_2D_MESHADAPT 至今保留的现实理由之一。

5.1.6 算法矩阵

算法 走 Delaunay/BDS 骨架? 内部点来源 生成器
MESHADAPT(1) 否(用 refineMeshBDS 四件套) 按尺寸场 split/swap/collapse 非周期
AUTO(2) 平面是;曲面否 同 Delaunay 或 MeshAdapt 视 geomType
INITIAL_ONLY(3) 是,但只到初始三角化 无内部点 非周期
DELAUNAY(5) 是 仅边界离散点 双路
FRONTAL(6) 是 前沿上按尺寸场造点 双路
BAMG(7) 是(+BAMG 造点) BAMG 各向异性 双路不同
FRONTAL_QUAD(8) 是 前沿造点,倾向四边形 双路
PACK_PRLGRMS(9) 是 打包平行四边形 双路
PACK_PRLGRMS_CSTR(10) ---(已废弃) --- ---
QUAD_QUASI_STRUCT(11) 是(内部改用 9) QuadQS 引导场 双路
Transfinite 否 超限插值 独立
Extrude 否 拉伸/复制 独立

结论:除最后两行,全部共享同一副骨架------这就是为什么先把 BDS 与边界恢复讲透,后面所有 算法都会"复用一半"。

5.2 曲面参数化:一切在 (u,v) 平面里做

5.2.1 为什么必须参数化

Delaunay 的定义依赖平面上的"外接圆"(任一三角形的外接圆内不含其它点)。曲面上没有"圆", 因此 Gmsh 把曲面拉平到参数域 (u,v),在参数平面里跑 Delaunay,再映射回三维:

  1. 边界曲线 GEdge 上的一维离散点,用 reparamMeshVertexOnFace 求出它们在 gf 参数域里的 (u,v);
  2. 这些 (u,v) 进入 BDS 网格,在参数平面里被三角化;
  3. 参数平面里的每个三角形顶点,用 GFace::point(u,v) 得到三维坐标;
  4. 三维节点以 MFaceVertex 保存,同时携带 (x,y,z) 与 (u,v)。

代价是参数化畸变 :物理空间里的等边三角形,在参数平面里可能被拉成细长三角形(想想 u=θ, v=z 参数化的圆柱面,u 一圈是 2π 而 v 可能只有 1 个单位长)。所以 Gmsh 不能 简单地用欧氏距离做 Delaunay,而要引入度量(5.3)。

5.2.2 参数化契约

gmshFace(src/geo/gmshFace.h 第 23--40 行)声明:

cpp 复制代码
virtual GPoint point(double par1, double par2) const;                 // (u,v) → 3D
virtual GPoint closestPoint(const SPoint3 &queryPoint,
                            const double initialGuess[2]) const;       // 3D → 最近点
virtual bool containsPoint(const SPoint3 &pt) const;
virtual double getMetricEigenvalue(const SPoint2 &);                   // 度量特征值
virtual SVector3 normal(const SPoint2 &param) const;
virtual std::pair<SVector3, SVector3> firstDer(const SPoint2 &param) const;
virtual void secondDer(const SPoint2 &, SVector3 &, SVector3 &, SVector3 &) const;
virtual SPoint2 parFromPoint(const SPoint3 &, bool onSurface = true,
                             bool convTestXYZ = false) const;          // 3D → (u,v)

三对最关键:point(u,v)/parFromPoint(xyz)(正逆变)、firstDer(u,v)(参数化雅可比,构造 度量的原料,5.3.1)、normal(u,v)(方向一致性检查与 BDS_SwapEdgeTestNormals)。

自研内核实现(src/geo/gmshFace.cpp 第 261--297 行)对平面特判:

cpp 复制代码
GPoint gmshFace::point(double par1, double par2) const
{
  double pp[2] = {par1, par2};
  if(_s->Typ == MSH_SURF_PLAN && !_s->geometry) {
    double x, y, z, VX[3], VY[3];
    getMeanPlaneData(VX, VY, x, y, z);            // 平面的局部坐标基
    return GPoint(x + VX[0]*par1 + VY[0]*par2, y + VX[1]*par1 + VY[1]*par2,
                  z + VX[2]*par1 + VY[2]*par2, this, pp);
  }
  else {
    Vertex v = InterpolateSurface(_s, par1, par2, 0, 0);   // 一般曲面:插值
    return GPoint(v.Pos.X, v.Pos.Y, v.Pos.Z, this, pp);
  }
}

对无附加几何数据的平面(MSH_SURF_PLAN && !_s->geometry),参数就是平面局部坐标系两个方向 上的坐标------此时参数化是等距(isometric)的 ,参数平面与物理平面只差一个刚体变换,没有 畸变。这就是 runDelaunayAlgorithm、_recombineIntoQuads 等处会专门检查 geomType() == GEntity::Plane 并走更快欧氏路径的原因。逆映射 parFromPoint(第 283 行)对 平面用解析投影(prosca 沿 VX、VY 分解),一般曲面委托基类(各内核用自己的牛顿迭代)。

5.2.3 参数化畸变与度量补偿的动机

设圆柱面 r(u,v) = (R cos u, R sin u, v),u ∈ [0,2π]。切向量 ∂r/∂u = (-R sin u, R cos u, 0)、 |∂r/∂u| = R,∂r/∂v = (0,0,1)------参数平面里 1×1 的正方形映射到三维变成 R×1 的长方形; 在参数平面里做等边三角化,物理空间里得到的却是被拉长 R 倍的各向异性三角形 。解决办法是 度量张量 M(u,v) = Jᵀ J(J = [∂r/∂u ∂r/∂v]),使"度量长度" √(Δᵀ M Δ) 恰等于物理 长度;在 M 定义的度量空间里做等边三角化,拉回物理空间才接近等边。

5.2.4 reparamOnFace 与跨面参数转换

一维节点携带的是曲线参数 t(MEdgeVertex::getParameter(0, t)),不是面参数 (u,v)。 把它转成面参数的动作叫重参数化 :reparamMeshVertexOnFace(MVertex*, GFace*, SPoint2&) 先把顶点分类在 gf 上时直接读缓存 (u,v),否则经 GEdge::reparamOnFace(face, epar, dir) (src/geo/gmshEdge.h 第 32 行)转换,dir 用于缝边(同一物理点有 两个参数值)。reparamOnFace 就是 Gmsh 处理"一条边同时属于两个面"的机制:同一三维点,在 面 A 里的 (u,v) 与在面 B 里的 (u,v) 完全不同,两个面的网格要在公共边上一致,就必须 各自算对。

buildConsecutiveListOfVertices(meshGFace.cpp 第 1610--1885 行)的注释(第 1619--1627 行)总结 了周期/缝场景下重参数化的三大难点:

cpp 复制代码
  // building the list of nodes in the parametric space for periodic surfaces is
  // tricky:
  // 1) both reparametrizations of the seams must be tested, as the topological
  //    representation does not know which one to use
  // 2) both orientations of curves must be tested, as the topological
  //    representation of periodic curves cannot distinguish them
  // 3) reparametrization of curves on surfaces can lead to slightly different
  //    parametric coordinates, especially with OpenCASCADE - so a tolerance is
  //    needed

即:缝边两种重参数化(dir = ±1)都要试;曲线两个朝向都要试;OCC 的 pcurve 在面上重参数化 后可能有微小差异,需要容差------且注释第 1714--1716 行特别提醒"OCC pcurves can require a large UV tolerance: it must not let a different endpoint replace the current mesh vertex",因此代码 先检查 verts.back() == v.front() 再比较距离。为此把候选参数组织成四元组 p/p_alt/p_rev/ p_alt_rev(第 1660--1694 行),并按最短参数距离选取;容差从 1e-12·LC2D 逐级放大到 1e-1·LC2D(第 1937 行 fact[6]),每级先试"不把缝边放首位",再试"把缝边放首位":

cpp 复制代码
      const double fact[6] = {1.e-12, 1.e-9, 1.e-6, 1.e-3, 1.e-2, 1.e-1};
      for(int i = 0; i < 6; i++) {
        if(buildConsecutiveListOfVertices(gf, *it, edgeLoop_BDS, bbox, m, recoverMap,
                                          nbPointsLocal, nbPointsTotal, fact[i]*LC2D)) { ok = true; break; }
        if(buildConsecutiveListOfVertices(gf, *it, edgeLoop_BDS, bbox, m, recoverMap,
                                          nbPointsLocal, nbPointsTotal, fact[i]*LC2D,
                                          true /* seam_the_first */)) { ok = true; break; }
      }

这是"用容差做分级试探"的典型工程写法:宁可多算十几次,也要让面自动跨过 OCC 容差问题, 而不是报错让用户手工调参。

5.2.5 MFaceVertex 与投影修正

参数化的产物是 MFaceVertex(携带 (u,v) 的面网格节点),用于 BDS2GMSH 新建内部点、 Frontal 造点(insertAPoint 第 771 行)、Transfinite 插值点(第 563 行)、 quadMeshRemoveHalfOfOneDMesh::_subdivide 的中点/形心等。它的 (u,v) 在后续优化/重组合/投影 中反复被读,避免每次从三维反解参数(那需牛顿迭代,昂贵);_relocate 里显式跳过边界层节点:

cpp 复制代码
  MFaceVertex *fv = dynamic_cast<MFaceVertex *>(ver);
  if(fv && fv->bl_data) return;      // 边界层节点不参与平滑

参数平面里得到的点用 gf->point(u,v) 映射回三维后可能不在原曲面上(参数域角落对应奇异 点,或数值误差),因此某些算法会再做一次投影。Transfinite 的注释很坦率(第 664--665 行):

cpp 复制代码
          // xp,yp,zp can be off the surface so we cannot use parFromPoint
          gf->XYZtoUV(xp, yp, zp, Up, Vp, 1.0, false);

即点已偏离曲面时不能再用 parFromPoint(会得到错误参数),只能用 XYZtoUV。这类"投影修正" 是曲面网格相对平面网格额外的复杂度。

5.3 各向异性度量与 Delaunay 判据

5.3.1 buildMetric

buildMetric(src/mesh/meshGFaceDelaunay.h 第 22 行; 实现见 src/mesh/meshGFaceDelaunay.cpp 第 144--151 行) 只有四行:

cpp 复制代码
void buildMetric(GFace *gf, double *uv, double *metric)
{
  std::pair<SVector3, SVector3> der = gf->firstDer(SPoint2(uv[0], uv[1]));
  metric[0] = dot(der.first, der.first);     // a = ∂u·∂u
  metric[1] = dot(der.second, der.first);    // b = ∂v·∂u
  metric[2] = dot(der.second, der.second);   // d = ∂v·∂v
}

把 M = [[a, b],[b, d]] 存成长度 3 的数组(只存对称矩阵三个独立分量)------这是定死的内存布局 约定,所有读 metric 的代码都照此解释。M 的特征值 λ1, λ2 对应参数平面单位长度在两个主 方向上的"物理长度平方";getMetricEigenvalue 就返回它,det(M) = a·d - b² 被 inCircumCircleAniso、optimalPointFrontal 的 RATIO 用到。

5.3.2 度量空间的外接圆

度量下"圆"变椭圆。"到三点等距"仍是一组二次方程,两两相减后二次项消掉,剩线性系统。 circumCenterMetric(第 99--142 行):

cpp 复制代码
static void circumCenterMetric(double *pa, double *pb, double *pc,
                               const double *metric, double *x, double &Radius2)
{
  // d = (u2-u1) M (u2-u1) = u2 M u2 + u1 M u1 - 2 u2 M u1
  const double a = metric[0], b = metric[1], d = metric[2];
  sys[0][0] = 2.*a*(pa[0]-pb[0]) + 2.*b*(pa[1]-pb[1]);
  sys[0][1] = 2.*d*(pa[1]-pb[1]) + 2.*b*(pa[0]-pb[0]);
  sys[1][0] = 2.*a*(pa[0]-pc[0]) + 2.*b*(pa[1]-pc[1]);
  sys[1][1] = 2.*d*(pa[1]-pc[1]) + 2.*b*(pa[0]-pc[0]);
  rhs[0] = a*(pa[0]*pa[0]-pb[0]*pb[0]) + d*(pa[1]*pa[1]-pb[1]*pb[1]) + 2.*b*(pa[0]*pa[1]-pb[0]*pb[1]);
  rhs[1] = a*(pa[0]*pa[0]-pc[0]*pc[0]) + d*(pa[1]*pa[1]-pc[1]*pc[1]) + 2.*b*(pa[0]*pa[1]-pc[0]*pc[1]);
  if(!sys2x2(sys, rhs, x)) { }
  Radius2 = (x[0]-pa[0])*(x[0]-pa[0])*a + (x[1]-pa[1])*(x[1]-pa[1])*d
          + 2.*(x[0]-pa[0])*(x[1]-pa[1])*b;
}

d(u1,u2) = (u2-u1)ᵀ M (u2-u1);要求 x 满足 d(x,pa)=d(x,pb) 与 d(x,pa)=d(x,pc),两式 相减后 xᵀ M x 抵消,得到关于 x 的线性 2×2 系统,用 sys2x2 求解。Radius2 是 x 到 pa 的度量平方距离。

5.3.3 inCircumCircleAniso

(第 160--173 行)把两步合起来:

cpp 复制代码
int inCircumCircleAniso(GFace *gf, double *p1, double *p2, double *p3,
                        double *uv, double *metric)
{
  double x[2], Radius2;
  circumCenterMetric(p1, p2, p3, metric, x, Radius2);
  const double a = metric[0], b = metric[1], d = metric[2];
  const double d0 = (x[0] - uv[0]), d1 = (x[1] - uv[1]);
  const double d3 = d0*d0*a + d1*d1*d + 2.0*d0*d1*b;
  const double tolerance = computeTolerance(Radius2);
  return d3 < Radius2 - tolerance;
}

computeTolerance(第 153--158 行)随半径量级变化:≤1e3 取 1e-12,≤1e5 取 1e-11, 否则 1e-9------大三角形坐标值大、浮点误差大,不放宽容差会把"落在圆上"误判为"落在圆内", 造成无意义翻转。第二组重载(第 175--204 行)在 metricb 为空时用三角形形心处的度量代替:

cpp 复制代码
  if(!metricb) {
    double pa[2] = {(data.Us[i0] + data.Us[i1] + data.Us[i2]) * ONE_THIRD,
                    (data.Vs[i0] + data.Vs[i1] + data.Vs[i2]) * ONE_THIRD};
    buildMetric(gf, pa, metric);
  }

即"一个三角形用单一度量近似"的工程取舍------更精确的三点各自度量会让判据失去"线性系统可解" 的简洁性。

5.3.4 鲁棒谓词

纯浮点谓词在退化(共线/共圆)时会给出错误符号,Gmsh 用 robustPredicates(Shewchuk 自适应 精度)解决。BDS 侧(BDS.cpp 第 885--888 行):

cpp 复制代码
  double ori_t1 = robustPredicates::orient2d(op1, p1, op2);
  double ori_t2 = robustPredicates::orient2d(op1, op2, p2);
  return (ori_t1 * ori_t2 > 0); // the quadrangle was strictly convex !

经典内核侧(meshGFaceDelaunay.cpp 第 320--332 行,inCircumCircleXY):

cpp 复制代码
  double result = robustPredicates::incircle(p1, p2, p3, pp) *
                  robustPredicates::orient2d(p1, p2, p3);
  return (result > 0) ? 1 : 0;

incircle 返回 4×4 行列式,其符号本身依赖三点朝向,故乘 orient2d 归一化------这样"点在圆内" 永远对应正的乘积。这是"鲁棒谓词 + 显式朝向修正"的标准写法(第 8 章细讲 Shewchuk 实现)。 swapEdgePass 也反复调用它:翻转构造的四边形是否"严格凸",由四个 orient2d 的六对乘积全正 决定(meshGFaceBDS.cpp 第 341--347 行)。

5.3.5 各向异性为何是曲面"必需品";无限度量

把圆柱例子代入:M = diag(R²,1)。若在参数平面追求"各向同性正三角形",物理空间所有三角形 都会沿 u 方向被拉长 R 倍,产生细长"乐高砖块"。用 M 后,度量空间里的"正三角形"在参数 平面里实际是沿 u 压扁 R 倍的三角形,拉回物理空间才接近等边。各向异性还有额外价值:边界层 网格 希望在法向极密、切向较疏,这种"方向性"天然可用一个各向异性度量表达------BAMG 正是为此 设计的原生各向异性网格器(meshMetric、F_Lc_aniso,第 4 章已见)。

Frontal-Quad 与 PACK 还在一个特殊背景里工作:背景网格定义 cross field 与理想尺寸,生成器 认为"理想度量是无限的",用 lengthInfniteNorm(meshGFaceDelaunay.cpp 第 1120--1132 行)在 cross field 旋转后的坐标系里取各方向投影的最大跨度:

cpp 复制代码
static double lengthInfniteNorm(const double p[2], const double q[2], const double quadAngle)
{
  double const xp = p[0]*std::cos(quadAngle) + p[1]*std::sin(quadAngle);
  double const yp = -p[0]*std::sin(quadAngle) + p[1]*std::cos(quadAngle);
  ...
  return std::max(xmax - xmin, ymax - ymin);   // L∞ 范数
}

名字里的 "Infnite" 是 "Infinite" 的历史拼写,反映"四边形网格在旋转后坐标系里对齐"的理念 ------这是 5.8 与 5.9 的伏笔。

5.4 BDS:Shephard-Beall 双向数据结构

二维 Delaunay 的核心数据结构是 src/mesh/BDS.h 里的 BDS------ Bidirectional Data Structure,源自 Shephard & Beall 的经典论文。文件头注释(第 9--12 行):

cpp 复制代码
// This is a 2D version of the Bidirectional Data Structure (BDS)
// of shephard and beall
// points may know the normals to the surface they are classified on
// default values are 0,0,1

"points may know the normals to the surface they are classified on"说明 BDS 点不仅记坐标与 参数,还知道所属曲面法向(默认 (0,0,1))------这就是 BDS_SwapEdgeTestNormals 能拿 gf 与点 查法向的原因。BDS 由 BDS_Point/BDS_Edge/BDS_Face 三类对象构成,它们互相持有指针 ("双向"得名):点知道所有相邻边,边知道两个端点与相邻面,面知道三条(或四条)边,提供 O(1) 邻域访问,是 Delaunay 中"沿边行走"的基础。

5.4.1 BDS_GeomEntity:几何分类

(BDS.h 第 32--48 行)

cpp 复制代码
class BDS_GeomEntity {
public:
  int classif_tag;      // 实体编号(如曲线 3、曲面 7)
  int classif_degree;   // 维度:0=点、1=边、2=面
  BDS_GeomEntity(int a, int b) : classif_tag(a), classif_degree(b) {}
  bool operator<(const BDS_GeomEntity &other) const
  {
    if(classif_degree < other.classif_degree) return true;
    if(classif_degree > other.classif_degree) return false;
    if(classif_tag < other.classif_tag) return true;
    return false;
  }
};

operator< 先按维度、再按标签 排序,使 geom 集合(std::set<BDS_GeomEntity*, GeomLessThan>)有序、get_geom 可二分。约定:负 classif_tag 表示网格器内部生成的辅助 实体 (如 add_geom(-1, 2));classif_degree < 2 表示"落在曲线或点上"(边界),== 2 表示"在曲面内部"。许多判定(如 BDS_SwapEdgeTestQuality 的"避免二邻居点")直接读它。 GeomLessThan(第 297--302 行)只是把 operator< 包成函数对象供 std::set 用。

5.4.2 BDS_Point:双尺寸与周期对应

(第 50--85 行)

cpp 复制代码
class BDS_Point {
  // the first size is the one dictated by the Background Mesh the
  // second one is dictated by characteristic lengths at points and is propagated
  double _lcBGM, _lcPTS;
public:
  double X, Y, Z, u, v;
  bool config_modified;
  short degenerated;
  BDS_Point *_periodicCounterpart;
  int iD;
  BDS_GeomEntity *g;
  std::vector<BDS_Edge *> edges;
  double &lcBGM() { return _lcBGM; }
  double &lc()    { return _lcPTS; }
};
  • _lcBGM/_lcPTS 双尺寸 :前者"背景网格(BGM)给出的目标尺寸",后者"由点上的特征长度 给出并被传播的尺寸";最终取哪个取决于 Extend1dMeshIn2dSurfaces(gf)(5.7.4 的 NewGetLc)。
  • X,Y,Z 三维坐标、u,v 参数坐标并存------前者用于质量度量,后者用于 Delaunay 判定与几何 分类查询。
  • config_modified 脏标记:swap_edge 里 if(!force && !p1->config_modified && !p2->config_modified && !op[0]->config_modified && !op[1]->config_modified) return false; (BDS.cpp 第 1051--1053 行)------四个点都没变过就跳过翻转测试,把 Delaunay 翻转到局部。
  • degenerated :0 正常,1 表示点在 u 方向退化(自由坐标是 u),2 表示 v 方向退化。注释"the free coordinate of a degenerated point (u if degenerated == 1, v if 2) is arbitrary, e.g. anywhere in 0, 2 pi at the apex of a cone"说明锥顶点这类奇异点的参数 不确定性(BDS.cpp 第 34--36 行)。
  • _periodicCounterpart :周期/缝面里同一物理节点在参数域有两份,互为 counterpart (meshGeneratorPeriodic 第 1972--1974 行建立配对)。
  • iD 是内部主键,BDS_Point::operator< 就是 iD < other.iD,故 points (std::set<BDS_Point*, PointLessThan>)按 id 有序,find_point 可二分。
  • 构造函数把两个尺寸初始化为 1.e22("尚未设置"),config_modified = true; del(BDS_Edge*) 用 erase-remove 从 edges 删边。

5.4.3 BDS_Edge:_f2 内联缓存与溢出列表

(第 87--201 行,注释第 89--94 行)

cpp 复制代码
class BDS_Edge {
  // An edge of a surface mesh has two faces, so they are stored inline: a
  // std::vector here meant one extra heap block, and one extra cache miss on
  // every faces() call, for every edge of the mesh. The overflow list is only
  // ever allocated on a non manifold configuration.
  BDS_Face *_f[2];
  int _nf;
  std::vector<BDS_Face *> *_moreFaces;

曲面网格里绝大多数边恰好只有两个相邻面 ,故用内联数组 _f[2] 存这两个面,仅在非流形时才 分配溢出 std::vector。注释坦率说明动机:用 std::vector 会让 faces() 每次调用多一次堆 访问和缓存缺失,乘上所有边就是可观代价------"以常见情况优化、为异常情况兜底"的典型微观优化。 配套逻辑:

cpp 复制代码
  BDS_Face *faces(std::size_t const i) const { return i < 2 ? _f[i] : (*_moreFaces)[i - 2]; }
  int numfaces() const { return _nf; }

  void addface(BDS_Face *f)
  {
    if(_nf < 2) { _f[_nf] = f; }
    else { if(!_moreFaces) _moreFaces = new std::vector<BDS_Face *>(); _moreFaces->push_back(f); }
    _nf++;
  }

  void del(BDS_Face *t)   // 原地压缩、保序,避免 std::remove_if 的重分配
  {
    if(!_nf) return;
    int n = _nf;  _nf = 0;
    for(int i = 0; i < n; i++) {
      BDS_Face *f = i < 2 ? _f[i] : (*_moreFaces)[i - 2];
      if(f == t) continue;
      if(_nf < 2) { _f[_nf] = f; } else { (*_moreFaces)[_nf - 2] = f; }
      _nf++;
    }
    if(_moreFaces && _nf <= 2) _moreFaces->clear();
    else if(_moreFaces) _moreFaces->resize(_nf - 2);
  }

构造函数(第 97--110 行)做规范化端点顺序 与双向注册:

cpp 复制代码
  BDS_Edge(BDS_Point *A, BDS_Point *B) : _nf(0), _moreFaces(nullptr), deleted(false), g(nullptr)
  {
    if(*A < *B) { p1 = A; p2 = B; } else { p1 = B; p2 = A; }   // p1->iD < p2->iD
    p1->edges.push_back(this);
    p2->edges.push_back(this);
  }

规范化保证边 (A,B) 与 (B,A) 是同一等价类,find_edge 才能命中。再看 commonvertexQuiet(第 135--140 行)与报错版 commonvertex(第 125--132 行):

cpp 复制代码
  // same, without the error message: used to fill BDS_Face's node cache, where
  // a failure must still be reported by getNodes() and not at construction
  inline BDS_Point *commonvertexQuiet(const BDS_Edge *other) const
  {
    if(p1 == other->p1 || p1 == other->p2) return p1;
    if(p2 == other->p1 || p2 == other->p2) return p2;
    return nullptr;
  }

为什么要有"不报错"版本? 它在 BDS_Face 构造时被调用,若不慎返回 null,构造阶段不应报错 (那一刻还没有完整上下文),而应把"这是不是坏面"留到 getNodes() 判定------这是把诊断信息 推迟到真正被使用时。

length()(第 117--122 行)是三维欧氏长度 (度量长度在 NewGetLc 里另算); numTriangles()(BDS.cpp 第 1133--1139 行)统计三角形邻居数:

cpp 复制代码
int BDS_Edge::numTriangles() const
{
  int n = 0;
  for(int i = 0; i < numfaces(); i++) if(faces(i)->numEdges() == 3) n++;
  return n;
}

因为 BDS_Face 既可能三角形也可能四边形,故"面数"与"三角形数"不是一回事。recover_edge 里 if(e->numfaces() != e->numTriangles()) return nullptr;(第 437 行)------只被四边形共享的 边不参与恢复 。otherFace 取"对面的面",oppositeof/computeNeighborhood 求边两侧三角 形的对角顶点。

5.4.4 BDS_Face:_n4 节点缓存

(第 203--295 行,注释第 212--213 行)

cpp 复制代码
class BDS_Face {
public:
  BDS_Face(BDS_Edge *A, BDS_Edge *B, BDS_Edge *C, BDS_Edge *D = nullptr)
    : deleted(false), e1(A), e2(B), e3(C), e4(D), g(nullptr)
  {
    e1->addface(this); e2->addface(this); e3->addface(this);
    if(e4) e4->addface(this);
    // the edges of a face never change once it is built, so its nodes can be
    // computed once here instead of on every getNodes() call
    if(!e4) {
      _n[0] = e1->commonvertexQuiet(e3); _n[1] = e1->commonvertexQuiet(e2);
      _n[2] = e2->commonvertexQuiet(e3); _n[3] = nullptr;
      _nodes = _n[0] && _n[1] && _n[2];
    }
    else { ... 四个 commonvertexQuiet,_nodes = 全部非空 ... }
  }

面的边在构造后不再变化,于是构造时一次算好节点并缓存 在 _n[4],_nodes 布尔标记表示 缓存是否有效。getNodes 变成直接读取(第 256--266 行):

cpp 复制代码
  inline bool getNodes(BDS_Point *n[4]) const
  {
    if(_nodes) { n[0] = _n[0]; n[1] = _n[1]; n[2] = _n[2]; n[3] = _n[3]; return true; }
    return _reportInvalidNodes(n);
  }

仅当缓存标记为假(异常面)才回退 _reportInvalidNodes(第 278--294 行)重新查表并报错------注意 回退版用会报错的 commonvertex 而非常 Quiet 版,恰好符合 5.4.3 那句注释的意图 。 getNodes 返回 bool,调用方必须检查(如 computeNeighborhood 里 if(!faces(0)->getNodes(pts1)) return;)。deleted 是软删除标记,cleanup() 时才真正 delete,避免遍历中途释放指针导致悬垂。

5.4.5 BDS_Mesh:容器与基本操作

(BDS.h 第 417--474 行)

cpp 复制代码
class BDS_Mesh {
public:
  int MAXPOINTNUMBER;
  BDS_Mesh(int MAXX = 0) : MAXPOINTNUMBER(MAXX) {}
  std::set<BDS_GeomEntity *, GeomLessThan> geom;
  std::set<BDS_Point *, PointLessThan> points;
  std::vector<BDS_Point *> orphanPoints;
  std::vector<BDS_Edge *> edges;
  std::vector<BDS_Face *> triangles;
  BDS_Point *add_point(int num, double u, double v, GFace *gf);
  BDS_Edge *recover_edge(int p1, int p2, bool &_fatal, std::set<EdgeToRecover> *e2r = nullptr,
                         std::set<EdgeToRecover> *not_recovered = nullptr);
  bool swap_edge(BDS_Edge *, const BDS_SwapEdgeTest &theTest, bool force = false);
  bool collapse_edge_parametric(BDS_Edge *, BDS_Point *, bool = false);
  bool smooth_point_centroid(BDS_Point *p, GFace *gf, double thresh);
  bool split_edge(BDS_Edge *, BDS_Point *, bool check_area_param = false);
  void cleanup();
  void removeOrphanPoints();
};

要点:

  • points/geom 用 std::set(需唯一性、可二分),edges/triangles 用 std::vector (只需遍历与追加)------刻意的混合选择。
  • orphanPoints 是"被边折叠孤立出来的点"的暂存区。注释(第 470--472 行)解释为何不能直接 删:"The points themselves are kept alive because callers key external maps on them."------ 外部 std::map<BDS_Point*, ...> 仍以这些指针为键,删除会造成悬垂,故只从 points 移出、 延迟到析构统一释放。
  • MAXPOINTNUMBER 跟踪已分配最大 id,供"只允许折叠 id 大于 MAXNP 的点"类判定(5.7.4)。

add_point 第二重载是参数化进入 BDS 的唯一入口:

cpp 复制代码
BDS_Point *BDS_Mesh::add_point(int num, double u, double v, GFace *gf)
{
  GPoint gp = gf->point(u, v);
  BDS_Point *pp = new BDS_Point(num, gp.x(), gp.y(), gp.z());
  pp->u = u;  pp->v = v;
  points.insert(pp);
  MAXPOINTNUMBER = std::max(MAXPOINTNUMBER, num);
  return pp;
}

add_edge 先 find_edge 查重再追加;add_triangle 先建三条边再建面。del_face 只从边里 摘除自己并置 deleted、不 delete 对象:

cpp 复制代码
void BDS_Mesh::del_face(BDS_Face *t)
{
  if(!t) return;
  t->e1->del(t); t->e2->del(t); t->e3->del(t);
  if(t->e4) t->e4->del(t);
  t->deleted = true;          // 只置标记
}

真正释放交给 cleanup()(第 709--730 行)------它用 std::partition 把未删除的移到前面再释放 后半段("逻辑删除 + 批量物理删除"两阶段,在并行/回溯场景下很安全):

cpp 复制代码
void BDS_Mesh::cleanup()
{
  auto last = std::partition(triangles.begin(), triangles.end(), is_not_deleted());
  for(auto it = last; it != triangles.end(); ++it) delete *it;
  triangles.erase(last, triangles.end());
  ...   // edges 同理
}

find_edge(BDS_Point*, BDS_Point*, BDS_Face*)(第 482--493 行)有一段性能注释:

cpp 复制代码
  // this only needs the two ids in the order BDS_Edge would store them; it
  // used to build two BDS_Points and a BDS_Edge on the stack to get them,
  // which allocated the two points' edge lists on every call
  const int a = std::min(p1->iD, p2->iD), b = std::max(p1->iD, p2->iD);
  if(t->e1->p1->iD == a && t->e1->p2->iD == b) return t->e1;
  if(t->e2->p1->iD == a && t->e2->p2->iD == b) return t->e2;
  if(t->e3->p1->iD == a && t->e3->p2->iD == b) return t->e3;
  return nullptr;

旧实现为复用 BDS_Edge 构造函数,在栈上建两个 BDS_Point 与一个 BDS_Edge------而 BDS_Edge 构造会往两个点的 edges 里 push_back,即每次查询都产生堆分配 。新实现只比 id,零分配------这是"为查询路径消除隐式分配"的典型案例。add_geom/get_geom 用 std::set 去重。

5.4.6 外围染色与清理

初始三角化会把整个凸包都三角化,包括域外的三角形 ,必须标出并清掉。 colorExteriorTriangles(meshGFace.cpp 第 771--844 行)分三步:清空所有 t->g;找"与四个假点 (iD < 0)相连"的三角形,从它做 recur_tag(t, CLASS_EXTERIOR) 洪水填充标出外部;找一条 "一侧外部、一侧内部"的边,从内侧面填 CLASS_F,再把 CLASS_EXTERIOR 清空:

cpp 复制代码
static void colorExteriorTriangles(BDS_Mesh *m, BDS_GeomEntity *CLASS_F,
                                   BDS_GeomEntity *CLASS_EXTERIOR, bool tagFromOppositeNegativeNode)
{
  for(auto t : m->triangles) t->g = nullptr;
  for(auto t : m->triangles) {
    BDS_Point *n[4];
    if(!t->deleted && t->getNodes(n) && (n[0]->iD < 0 || n[1]->iD < 0 || n[2]->iD < 0)) {
      recur_tag(t, CLASS_EXTERIOR); break;      // 从"连着假点"的面出发填外部
    }
  }
  for(auto e : m->edges)
    if(e->g && e->numfaces() == 2) {
      if(e->faces(0)->g == CLASS_EXTERIOR) { recur_tag(e->faces(1), CLASS_F); break; }
      else if(e->faces(1)->g == CLASS_EXTERIOR) { recur_tag(e->faces(0), CLASS_F); break; }
    }
  for(auto t : m->triangles) if(t->g == CLASS_EXTERIOR) t->g = nullptr;
  ...
  if(tagFromOppositeNegativeNode) { ... 再从"对角点是假点"的边填一次内部 ... }
}

CLASS_F(1,2) 与 CLASS_EXTERIOR(1,3) 在 meshGenerator 里构造(第 1466--1467 行)------ classif_degree 都是 1、tag 分别 2 与 3,是"借用曲线编号空间表示内部/外部"的约定。 recur_tag(BDS.cpp 第 672--693 行)是显式栈的洪水填充:

cpp 复制代码
void recur_tag(BDS_Face *t, BDS_GeomEntity *g)
{
  std::stack<BDS_Face *> _stack;  _stack.push(t);
  while(!_stack.empty()) {
    t = _stack.top();  _stack.pop();
    if(!t->g) {
      t->g = g;
      if(!t->e1->g && t->e1->numfaces() == 2) _stack.push(t->e1->otherFace(t));
      if(!t->e2->g && t->e2->numfaces() == 2) _stack.push(t->e2->otherFace(t));
      if(!t->e3->g && t->e3->numfaces() == 2) _stack.push(t->e3->otherFace(t));
    }
  }
}

关键守卫 !e->g :一旦某条边被"染色"(成边界边),洪水填充就不再跨过它------这正是"边界把 内部分成不连通区域"的机制;numfaces() == 2 保证只在流形内部传播。 tagFromOppositeNegativeNode 是两条生成器的一个分歧:只有非周期生成器最后还会从"对角点 是假点"的边再填一次内部(注释第 765--770 行指出"that pass really does fire - on most faces of the regression suite - so unifying it would change meshes"),故参数化而非统一。

pruneAndCleanupBDS(第 500--533 行)染完色后清理,其中点的分类继承规则值得细看:

cpp 复制代码
static void pruneAndCleanupBDS(BDS_Mesh *m, BDS_GeomEntity *CLASS_F)
{
  for(auto t : m->triangles) if(!t->g) m->del_face(t);    // 未染色(域外)的面删除
  m->cleanup();
  for(auto e : m->edges) {
    if(e->numfaces() == 0) m->del_edge(e);                // 无面边删除
    else {
      if(!e->g) e->g = CLASS_F;                           // 无分类的边归为面内
      if(!e->p1->g || e->p1->g->classif_degree > e->g->classif_degree) e->p1->g = e->g;
      if(!e->p2->g || e->p2->g->classif_degree > e->g->classif_degree) e->p2->g = e->g;
    }
  }
  m->cleanup();
  m->del_point(m->find_point(-1));   // 删四个假点
  m->del_point(m->find_point(-2));
  m->del_point(m->find_point(-3));
  m->del_point(m->find_point(-4));
}

classif_degree > e->g->classif_degree 时才用边的分类覆盖点------"低维度优先":点已在曲线 上(degree 1)就不会被面内边(degree 2)的分类覆盖,保证"边界点永远保留边界分类"。

5.4.7 边翻转测试族 BDS_SwapEdgeTest

BDS 的边翻转必须通过可行性测试。测试族(BDS.h 第 331--384 行):

cpp 复制代码
class BDS_SwapEdgeTest {
public:
  virtual bool operator()(BDS_Point *p1, BDS_Point *p2, BDS_Point *q1, BDS_Point *q2) const = 0;
  virtual bool operator()(BDS_Point *p1, BDS_Point *p2, BDS_Point *p3, BDS_Point *q1,
                          BDS_Point *q2, BDS_Point *q3, BDS_Point *op1, BDS_Point *op2,
                          BDS_Point *op3, BDS_Point *oq1, BDS_Point *oq2,
                          BDS_Point *oq3) const = 0;
  virtual ~BDS_SwapEdgeTest() {}
};

两个重载的原因 :第一个是"翻转前快速检查"(翻转边两端点 + 两个对角点),第二个是"翻转后 完整检查"(翻转前后两个"四边形"的六个顶点)。实现有三个:BDS_SwapEdgeTestRecover(只查 翻转后四边形严格凸)、BDS_SwapEdgeTestQuality(查质量)、BDS_SwapEdgeTestNormals(查法向, 仅非 Gmsh 内核的最终阶段)。

BDS_SwapEdgeTestQuality 第一重载(BDS.cpp 第 905--929 行):

cpp 复制代码
  if(!testSmallTriangles) return true;
  // AVOID CREATING POINTS WITH 2 NEIGHBORING TRIANGLES
  if(_p1->g && _p1->g->classif_degree == 2 && _p1->edges.size() <= 4) return false;
  if(_p2->g && _p2->g->classif_degree == 2 && _p2->edges.size() <= 4) return false;
  if(_p1->g && _p1->g->classif_degree < 2 && _p1->edges.size() <= 3) return false;
  if(_p2->g && _p2->g->classif_degree < 2 && _p2->edges.size() <= 3) return false;

  double s1 = fabs(surface_triangle_param(_p1, _p2, _q1));
  double s2 = fabs(surface_triangle_param(_p1, _p2, _q2));
  double s3 = fabs(surface_triangle_param(_p1, _q1, _q2));
  double s4 = fabs(surface_triangle_param(_p2, _q1, _q2));
  if(fabs(s1 + s2 - s3 - s4) > 1.e-12 * (s3 + s4)) return false;   // 参数面积守恒
  return true;

两块逻辑:(1) 避免制造只有两个相邻三角形的点 (面内点 ≤4 条边、边界点 ≤3 条边即拒绝), 把拓扑健康度纳入质量判据;(2) 参数面积守恒 校验(翻转前后四块面积和不变,容差 1e-12)。

第二重载(第 931--973 行)做真正的质量比较:

cpp 复制代码
  // Check if new edge is not on a seam or degenerated
  BDS_Point *p1 = nullptr, *p2 = nullptr;
  if(_op1 != _oq1 && _op1 != _oq2 && _op1 != _oq3) { p1 = _op2; p2 = _op3; }
  else if(_op2 != _oq1 && _op2 != _oq2 && _op2 != _oq3) { p1 = _op1; p2 = _op3; }
  else if(_op3 != _oq1 && _op3 != _oq2 && _op3 != _oq3) { p1 = _op1; p2 = _op2; }
  else Msg::Warning("Unable to detect the new edge in BDS_SwapEdgeTestQuality\n");

  if(p1 && p2) {
    if(p1->degenerated && p2->degenerated) return false;                    // 退化点之间的边
    if(p1->_periodicCounterpart && p2->_periodicCounterpart) return false;  // 缝两侧对应点
  }
  if(!testQuality) return true;
  double const mina = std::min(qmTriangle::gamma(_p1,_p2,_p3), qmTriangle::gamma(_q1,_q2,_q3));
  double const minb = std::min(qmTriangle::gamma(_op1,_op2,_op3), qmTriangle::gamma(_oq1,_oq2,_oq3));
  return minb > mina;   // 翻转后最小 gamma 质量更大才允许

BDS_SwapEdgeTestNormals(第 975--1006 行)把质量与法向结合:OLD/NEW 分别取翻转前后四块 三角形 gamma × _cos_N 的最小值,仅当 OLD < 0.5 && OLD < NEW 才翻转。_cos_N (BDS.cpp 第 29--67 行)是"三角形几何法向与曲面法向的余弦",且对退化点特殊处理(把退化点的 自由参数剔除出形心平均):

cpp 复制代码
static double _cos_N(BDS_Point *_p1, BDS_Point *_p2, BDS_Point *_p3, GFace *gf)
{
  double n[3];  normal_triangle(_p1, _p2, _p3, n);      // 三角形几何法向
  double u = 0., v = 0.;  int nu = 0, nv = 0;
  for(int i = 0; i < 3; i++) {                          // 退化点的自由坐标剔除出形心
    if(p[i]->degenerated != 1) { u += p[i]->u; nu++; }
    if(p[i]->degenerated != 2) { v += p[i]->v; nv++; }
  }
  SVector3 N = gf->normal(SPoint2(u, v));
  double c = N.x()*n[0] + N.y()*n[1] + N.z()*n[2];
  for(int i = 0; i < 3; i++) {                          // 角点法向与形心法向比较
    if(p[i]->degenerated) continue;
    SVector3 Ni = gf->normal(SPoint2(p[i]->u, p[i]->v));
    if((Ni.x()*n[0] + Ni.y()*n[1] + Ni.z()*n[2]) * c >= 0.) return c;
    A += Ni; k++;
  }
  ...
}

文件头注释(第 21--28 行)解释这套两级判断:只看角点法向 在小区域高曲率处(如 U 形后缘, issue #2472)会误导;只看形心法向 也糟------薄锥面的三角形参数像几乎绕轴一整圈,形心落在 锥对侧、法向反了,折叠的三角形反而"看起来合法"。故规则是:当角点法向与形心法向全部不 一致时,以角点法向为准 ------即代码里 (Ni·n) * c >= 0. 那行"若有点与形心同号就返回形心 结果,否则用角点平均"。

5.4.8 swap_edge:翻转的执行

BDS_Mesh::swap_edge(BDS.cpp 第 1008--1131 行)是翻转执行体:

cpp 复制代码
bool BDS_Mesh::swap_edge(BDS_Edge *e, const BDS_SwapEdgeTest &theTest, bool force)
{
  if(e->deleted) return false;
  if(e->numfaces() != 2) return false;
  if(e->g && e->g->classif_degree == 1) return false;      // 模型边不翻转

  BDS_Point *op[2], *pts1[4], *pts2[4];
  e->computeNeighborhood(pts1, pts2, op);
  if(!op[0] || !op[1]) return false;
  if(!force && !p1->config_modified && !p2->config_modified &&
     !op[0]->config_modified && !op[1]->config_modified) return false;   // 局部性优化

  int orientation = 0;
  for(int i = 0; i < 3; i++)
    if(pts1[i] == p1) { orientation = pts1[(i + 1) % 3] == p2 ? 1 : -1; break; }

  // 先"翻转后完整检查",再"翻转前快速检查"
  if(orientation == 1) {
    if(!theTest(p1,p2,op[0], p2,p1,op[1], p1,op[1],op[0], op[1],p2,op[0])) return false;
  }
  else {
    if(!theTest(p2,p1,op[0], p1,p2,op[1], p1,op[0],op[1], op[1],op[0],p2)) return false;
  }
  if(!theTest(p1, p2, op[0], op[1])) return false;

  BDS_Edge *p1_op1 = find_edge(p1, op[0], e->faces(0));
  BDS_Edge *op1_p2 = find_edge(op[0], p2, e->faces(0));
  BDS_Edge *p1_op2 = find_edge(p1, op[1], e->faces(1));
  BDS_Edge *op2_p2 = find_edge(op[1], p2, e->faces(1));
  if(p1_op1 == p1_op2 || op2_p2 == op1_p2) return false;   // 退化

  if(e->faces(0)) { g1 = e->faces(0)->g; del_face(e->faces(0)); }
  // not a bug !!!  第一次删 faces(0) 后,原来的 faces(1) 会滑到 faces(0)
  if(e->faces(0)) { g2 = e->faces(0)->g; del_face(e->faces(0)); }
  del_edge(e);

  edges.push_back(new BDS_Edge(op[0], op[1]));    // 新边 = 两个对角点
  ...                                             // 依 orientation 建 t1,t2,继承 g1,g2,ge
  p1->config_modified = p2->config_modified = true;
  op[0]->config_modified = op[1]->config_modified = true;
  return true;
}

几个"坑点":e->g->classif_degree == 1 直接拒绝 (模型边不可翻转,否则边界被破坏------分类 保持第一道闸);// not a bug !!! (第 1098 行)------第一次 del_face 后 _f[0] 被压缩、 原 _f[1] 滑到 _f[0],故第二次仍写 faces(0),注释提醒"这不是 bug";ge = e->g 在 函数开头保存 ,新边继承旧边分类;翻转后四个相关点置 config_modified = true 触发局部重判。

5.4.9 collapse_edge_parametric 与 split_edge

collapse_edge_parametric(第 1157--1320 行)把边 e 的一个端点合并到另一端。第一段守卫:

cpp 复制代码
  if(!force && e->numfaces() != 2) return false;
  if(!force && p->g && p->g->classif_degree == 0) return false;   // 模型顶点不折叠
  if(!force && p->g && p->g->classif_degree == 1) return false;   // 模型边点不折叠
  if(!force && e->g && p->g) { if(e->g->classif_degree == 2 && p->g != e->g) return false; }

注释说明动机:"not really ok but 'til now this is the best choice not to do collapses on model edges"------不在模型边上折叠 ,因为那会破坏边界。接着是周期性守卫(第 1176--1186 行):若 p1 的某邻居与 p2 的某邻居互为 _periodicCounterpart,折叠会把缝两侧粘成同一点,拒绝:

cpp 复制代码
      for(size_t i = 0; i < e->p1->edges.size(); i++)
        for(size_t j = 0; j < e->p2->edges.size(); j++)
          if(e->p1->edges[i]->othervertex(e->p1)->_periodicCounterpart ==
             e->p2->edges[j]->othervertex(e->p2)) return false;

随后统计"星形邻域"的面积变化与最小质量,用 Cavity 结构缓存(第 1219--1233 行):

cpp 复制代码
  // these used to be four 1024-entry stack arrays, indexed by the number of
  // triangles and of edges around p with no bound check at all
  struct Cavity { std::vector<BDS_Point *> pt[3], ept[2]; std::vector<BDS_GeomEntity *> gs, egs; };
  static thread_local Cavity c;

注释坦承旧实现有栈数组越界风险;新实现用 thread_local 缓存 std::vector------**"每线程复用 缓冲"**是并行网格化的常见优化。代码做两类完整性检查(snew < .02 * sold 拒绝"折叠产生退化 三角形";面积不守恒拒绝),只有参数面积守恒且无极小三角形时才真正折叠。

split_edge(第 756--875 行)把边 e 在 mid 处一分为二、生成四个三角形;可选 check_area_param 做面积守恒检查(1.1/0.9 是经验阈值,注释直接写 heuristic),并按 orientation 把几何分类 g1,g2,ge 正确分配到新对象(第 851--861 行)。

5.4.10 smooth_point_centroid:Tutte 能量平滑

BDS_Mesh::smooth_point_centroid(第 1724--1784 行)先做守卫,再取星形邻域按序排列 (getOrderedNeighboringVertices,第 1347--1426 行),计算 Tutte 能量:

cpp 复制代码
static inline double getTutteEnergy(const BDS_Point *p, const std::vector<BDS_Point *> &nbg,
                                    double &RATIO)
{
  if(nbg.empty()) return 1.e22;
  double E = 0;  double MAX = 0., MIN = 0.;
  for(size_t i = 0; i < nbg.size(); ++i) {
    const double dx = p->X-nbg[i]->X, dy = p->Y-nbg[i]->Y, dz = p->Z-nbg[i]->Z;
    const double l2 = dx*dx + dy*dy + dz*dz;
    MAX = i ? std::max(MAX, l2) : l2;
    MIN = i ? std::min(MIN, l2) : l2;
    E += l2;
  }
  if(!MAX) return 1.e22;
  RATIO = MIN / MAX;      // 最短/最长邻边平方比,判邻域是否退化
  return E;
}

getOrderedNeighboringVertices 有一处性能注释:旧实现每绕一步都重新算三角形节点(O(n²)), 新实现先一次性缓存"每个三角形里不是 p 的两个点"再线性组装------与 5.4.5 的 find_edge 同属 "消除重复计算"。平滑先在参数域尝试(minimizeTutteEnergyParam),不成功再在三维尝试并投影 (minimizeTutteEnergyProj):

cpp 复制代码
  if(!minimizeTutteEnergyParam(p, E_unmoved, RATIO, nbg, kernel, lc, gf, CHECK)) {
    if(!minimizeTutteEnergyProj(p, E_unmoved, RATIO, nbg, kernel, lc, gf, CHECK)) return false;
  }
  else { p->config_modified = true; }
  return true;

BDS_SmoothScratch(BDS.h 第 409--415 行)复用 nbg/lc/kernel/ts 四个缓冲,避免"每点 每趟分配四个 vector"(在 smoothVertexPass 里被跨点复用)。kernel("核")是待移动点周围 由邻点参数坐标构成的多边形区域(computeSomeKindOfKernel),把理想位置限制在可行区域内------ 让平滑"不越界"的关键机制。

5.4.11 几何分类保持:BDS 的隐性契约

把本节串起来看,BDS 的所有局部操作都遵守一条隐性契约:绝不破坏几何分类 。翻转拒绝 classif_degree == 1 的模型边;折叠拒绝模型点/模型边上的端点并带周期性守卫;分裂显式把 g1,g2,ge 分配到新对象;平滑拒绝 classif_degree <= 1 的点并跳过边界层节点。这就是 BDS_GeomEntity 存在的全部理由------理解了它,就理解了为什么 BDS 每个操作都要拐弯抹角地看 g。

5.5 边界恢复:让边界边真的出现在网格里

5.5.1 问题定义与两个基本构件

Delaunay 三角化只保证"空外接圆",不保证指定的边一定出现在三角网里 。但二维曲面网格 必须让边界曲线(GEdge)上的每条一维网格线段都成为网格边,否则曲面网格与边界网格不匹配, 第 6 章的三维网格会留下裂缝。这就是边界恢复(boundary recovery)。

两个基本构件:

  1. EdgeToRecover (BDS.h 第 386--407 行)记录"哪条待恢复边":

    cpp 复制代码
    struct EdgeToRecover {
      int p1, p2;  GEdge *ge;
      EdgeToRecover(int _p1, int _p2, GEdge *_ge) : ge(_ge)
      { if(_p1 < _p2) { p1 = _p1; p2 = _p2; } else { p2 = _p1; p1 = _p2; } }
      bool operator<(const EdgeToRecover &other) const { ... p1/p2 字典序 ... }
    };

    构造时规范化端点顺序 (p1 < p2)并提供 operator<,故可放进 std::set<EdgeToRecover>; ge 是这条边所属的模型曲线(诊断用)。

  2. recoverMap / recoverMapInv :一对互逆映射,把"BDS 点"和"Gmsh 网格顶点"关联。 meshGenerator(第 1468--1469 行)声明:

    cpp 复制代码
    std::map<BDS_Point *, MVertex *, PointLessThan> recoverMap;
    std::map<MVertex *, BDS_Point *> recoverMapInv;

    buildBDSPoints(第 1000--1026 行)同时维护两者,并累积参数包围盒 bbox += SPoint3(param[0], param[1], 0) 供插入四个假点用。为何要正反两张表? 恢复时以"Gmsh 一维线段端点"为输入 查 BDS 点(用 recoverMapInv);BDS2GMSH 回填时以"BDS 点"为输入查/新建网格顶点(用 recoverMap)。方向相反,各取所需。

5.5.2 两遍(two-pass)策略

核心是 recoverEdge(meshGFace.cpp 第 320--388 行):

cpp 复制代码
static bool recoverEdge(BDS_Mesh *m, GFace *gf, GEdge *ge,
                        std::map<MVertex *, BDS_Point *> &recoverMapInv,
                        std::set<EdgeToRecover> *e2r,
                        std::set<EdgeToRecover> *notRecovered, int pass)
{
  BDS_GeomEntity *g = nullptr;
  if(pass == 2) { m->add_geom(ge->tag(), 1); g = m->get_geom(ge->tag(), 1); }  // 第 2 遍才需分类对象
  bool _fatallyFailed;
  for(std::size_t i = 0; i < ge->lines.size(); i++) {
    MVertex *vstart = ge->lines[i]->getVertex(0), *vend = ge->lines[i]->getVertex(1);
    auto itpstart = recoverMapInv.find(vstart), itpend = recoverMapInv.find(vend);
    if(itpstart != recoverMapInv.end() && itpend != recoverMapInv.end()) {
      BDS_Point *pstart = itpstart->second, *pend = itpend->second;
      if(pass == 1)
        e2r->insert(EdgeToRecover(pstart->iD, pend->iD, ge));      // 第 1 遍:只登记
      else {
        BDS_Edge *e = m->recover_edge(pstart->iD, pend->iD, _fatallyFailed, e2r, notRecovered);
        if(e) e->g = g;                                            // 第 2 遍:真正恢复
        else {
          if(_fatallyFailed) {
            Msg::Warning("Unable to recover the edge %d (%d/%d) on curve %d (on surface %d)",
                         ge->lines[i]->getNum(), i + 1, ge->lines.size(), ge->tag(), gf->tag());
            if(Msg::GetVerbosity() == 99) {
              outputScalarField(m->triangles, "wrongmesh.pos", 0);     // 欧氏空间
              outputScalarField(m->triangles, "wrongparam.pos", 1);    // 参数空间
            }
          }
          return !_fatallyFailed;
        }
      }
    }
  }
  ...                                     // 第 2 遍末尾:给还没分类的端点补 (degree 0)
  return true;
}

要点:

  • 第 1 遍(pass == 1)只登记 :把"两端点都在 BDS 里"的一维线段登记到 e2r。为什么先 登记? 因为 recover_edge 在翻转中需要知道"哪些边是必须保留的模型边",才能给出诊断 ("edge A on model edge X cannot be recovered because it intersects B on model edge Y")。
  • 第 2 遍才真正恢复 :成功则把边的几何分类设为 g;失败且致命则报错、可选输出双视图, 返回 !_fatallyFailed。
  • _fatallyFailed 语义 :区分"这条边根本恢复不了"(置 true)与"因一维网格自交而无法 恢复"(走 notRecovered 分支但不置 fatal)。前者报 Msg::Warning,后者交 remeshUnrecoveredEdges。
  • 第 2 遍末尾 处理退化/缝边端点:若曲线起止顶点在 BDS 里还没分类(!pstart->g),补 add_geom(pstart->iD, 0)(degree 0 = 点)。

两遍策略的价值:先把所有要求搞清楚,再动手做------否则边恢复过程中才知道要什么,就无法在 诊断里指出"被交叉的那条边属于哪条模型曲线"。

5.5.3 recover_edge:翻转恢复算法

BDS_Mesh::recover_edge(BDS.cpp 第 389--480 行)核心是"沿要恢复的线段,逐段翻转掉与它相交 的边":

cpp 复制代码
BDS_Edge *BDS_Mesh::recover_edge(int num1, int num2, bool &_fatal,
                                 std::set<EdgeToRecover> *e2r,
                                 std::set<EdgeToRecover> *not_recovered)
{
  BDS_Edge *e = find_edge(num1, num2);
  _fatal = false;
  if(e) return e;               // 已存在,无需恢复

  BDS_Point *p1 = find_point(num1), *p2 = find_point(num2);
  if(!p1 || !p2) { Msg::Error("Could not find points %d or %d in BDS mesh", num1, num2); return nullptr; }

  int ix = 0;  double x[2];
  while(1) {
    std::vector<BDS_Edge *> intersected;  bool selfIntersection = false;
    for(auto it = edges.begin(); it != edges.end(); ++it) {
      e = (*it);
      if(!e->deleted && e->p1 != p1 && e->p1 != p2 && e->p2 != p1 && e->p2 != p2)
        if(Intersect_Edges_2d(e->p1->u, e->p1->v, e->p2->u, e->p2->v,
                              p1->u, p1->v, p2->u, p2->v, x)) {
          if(e2r && e2r->find(EdgeToRecover(e->p1->iD, e->p2->iD, nullptr)) != e2r->end()) {
            not_recovered->insert(EdgeToRecover(num1, num2, itr2->ge));       // 一维网格自交
            not_recovered->insert(EdgeToRecover(e->p1->iD, e->p2->iD, itr1->ge));
            selfIntersection = true;
          }
          if(e->numfaces() != e->numTriangles()) return nullptr;   // 四边形边无法翻转
          intersected.push_back(e);
        }
    }
    if(selfIntersection) return nullptr;
    if(!intersected.size() || ix > 300) {            // 无相交 或 迭代上限
      BDS_Edge *eee = find_edge(num1, num2);
      if(!eee) { ... _fatal = true; return nullptr; }
      return eee;
    }
    std::vector<int>::size_type ichoice = 0;  bool success = false;
    while(!success && ichoice < intersected.size())
      success = swap_edge(intersected[ichoice++], BDS_SwapEdgeTestRecover());
    if(!success) { _fatal = true; return nullptr; }
    ix++;
  }
}

逐段:

  • Intersect_Edges_2d (BDS.cpp 第 349--363 行)用 2×2 线性系统求两条线段参数交点,再判断 参数都在 [0,1]:mat 的列分别是两条线段方向、rhs 是起点差向量,sys2x2(mat, rhs, x) 解出参数 x[0]/x[1],只有两者都落在 [0,1] 才算相交。注意它在参数平面 (u,v) 里 判断------恢复是参数空间里的事。
  • 自交检测 :若相交的边本身也在 e2r 里(也是一条必须恢复的模型边),说明两条模型边在 参数空间交叉 (一维网格自交),把两条边都记入 not_recovered 并返回,交给 remeshUnrecoveredEdges。
  • e->numfaces() != e->numTriangles() 返回 null:属于四边形的边无法用三角形翻转消除。
  • 翻转循环 对每条相交边尝试 swap_edge(..., BDS_SwapEdgeTestRecover())("翻转后严格凸"), 成功一条就重扫;ix > 300 是迭代上限 ,防病态无限循环;_fatal = true 只在"确实恢复 不了"时设置。

5.5.4 一维网格自交:remeshUnrecoveredEdges 与重试闭环

recoverBoundaryEdges(第 1161--1244 行)发现 edgesNotRecovered 非零时进入"加密一维网格再 重来"分支,并返回三态枚举(第 1149--1153 行):

cpp 复制代码
enum class EdgeRecovery {
  Recovered, // every model edge is now an edge of the triangulation
  Failed,    // unrecoverable, the face is marked FAILED
  Retry      // the 1D mesh self-intersects; it has been refined, mesh again
};
cpp 复制代码
  if(edgesNotRecovered.size() || gf->meshStatistics.refineAllEdges) {
    ...
    if(repairSelfIntersecting1dMesh) {
      remeshUnrecoveredEdges(recoverMapInv, edgesNotRecovered);
      gf->meshStatistics.refineAllEdges = false;
    }
    return EdgeRecovery::Retry;
  }

Retry 让 meshGenerator 递归重试(meshGenerator(gf, RECUR_ITER + 1, ...),第 1517--1520 行),直到 RECUR_ITER >= CTX::instance()->mesh.maxRetries。remeshUnrecoveredEdges(第 201--299 行)把每条未恢复的一维线段在中点一分为二 ,按 BGM_MeshSize 重建目标尺寸:

cpp 复制代码
          // should be better, i.e. equidistant
          double t = 0.5 * (t2 + t1);
          double lc = 0.5 * (lc1 + lc2);
          GPoint V = itr->ge->point(t);
          MEdgeVertex *newv = new MEdgeVertex(V.x(), V.y(), V.z(), itr->ge, t, 0, lc);
          newLines.push_back(new MLine(v1, newv));
          newLines.push_back(new MLine(newv, v2));

注释"should be better, i.e. equidistant"说明中点取参数平均是"近似等距"的简化。函数先 deMeshGFace 掉相邻面网格并把状态设为 PENDING(加密一维后相邻面必须重网格);周期性场景用 recoverMultiMapInv(std::multimap)处理"一个顶点对应两个 BDS 点"的缝情况。另有**"全体 分裂"**策略:gf->meshStatistics.refineAllEdges 为真时打印带表情的日志 Msg::Info("8-| Splitting all edges and trying again"),且 meshGeneratorPeriodic 里有 nbRefineAllEdges < 3 上限(第 2161--2171 行)------每面最多"全体分裂"三次,因为每次加倍 节点数,且注释指出锥顶点附近"the circumference always ends up smaller than the mesh size", 无解。

5.5.5 失败诊断

设计很贴心:

  • verbosity 99 时 recoverEdge 把错误网格写成 wrongmesh.pos(欧氏)/wrongparam.pos(参数) 两个视图(第 355--358 行)。
  • debugViews(第 488--495 行)输出 surface<tag>-<what>-real.pos 与 ...-param.pos。
  • recoverBoundaryEdges 在 debug 时写整个模型 .msh:surface%d-not_yet_recovered-real-%d.msh (第 1219--1224 行)。
  • BDS_Mesh::recover_edge 在 verbosity > 98 时输出 debugp.pos/debugr.pos(第 448--455 行)。

参数空间视图尤其关键:曲面网格的失败几乎都在参数平面里显现(自交、退化),而欧氏空间里 可能"看起来正常"。让开发者两个视角都能看,是很实用的调试手段。

5.6 从 BDS 回到 Gmsh:BDS2GMSH

5.6.1 BDS2GMSH

算法在 BDS 里跑完,最终要把结果转成 Gmsh 的 MTriangle/MQuadrangle(meshGFace.cpp 第 390--424 行):

cpp 复制代码
static void
BDS2GMSH(BDS_Mesh *m, GFace *gf, std::map<BDS_Point *, MVertex *, PointLessThan> &recoverMap)
{
  auto itt = m->triangles.begin();
  while(itt != m->triangles.end()) {
    BDS_Face *t = *itt;
    if(!t->deleted) {
      BDS_Point *n[4];  t->getNodes(n);
      MVertex *v[4] = {nullptr, nullptr, nullptr, nullptr};
      for(int i = 0; i < 4; i++) {
        if(!n[i]) continue;
        if(recoverMap.find(n[i]) == recoverMap.end()) {
          v[i] = new MFaceVertex(n[i]->X, n[i]->Y, n[i]->Z, gf, n[i]->u, n[i]->v);
          recoverMap[n[i]] = v[i];
          gf->mesh_vertices.push_back(v[i]);
        }
        else v[i] = recoverMap[n[i]];
      }
      if(!v[3]) {   // 三角形
        // when a singular point is present, degenerated triangles may be
        // created, for example on a sphere that contains one pole
        if(v[0] != v[1] && v[0] != v[2] && v[1] != v[2])
          gf->triangles.push_back(new MTriangle(v[0], v[1], v[2]));
      }
      else gf->quadrangles.push_back(new MQuadrangle(v[0], v[1], v[2], v[3]));
    }
    ++itt;
  }
}

三个细节:

  1. 新顶点一律建 MFaceVertex (携带 (u,v))便于后续投影/优化;已存在的(边界点)从 recoverMap 直接取,不重建。
  2. 退化三角形被丢弃 ------注释举例"a sphere that contains one pole":球面极点处参数奇异会生成 零面积三角形(三顶点中两个重合),判据 v[0]!=v[1] && ... 是顶点指针相等性 ------极点处两个 不同 BDS 点被映射到同一个 MVertex。
  3. if(!v[3]) 区分三角形与四边形 :getNodes 对三角形把 n[3] 置 null,故 v[3] 是否被 填上即判据,无需再看 e4。

5.6.2 deleteUnusedVertices

finishSurfaceMesh(第 654--662 行)算质量统计 + 清无用节点。deleteUnusedVertices (第 426--482 行)用索引标记法 (而非 std::set<MVertex*>)做 O(n) 过滤:

cpp 复制代码
  // O(n) via index marking instead of inserting millions of pointers into a
  // std::set. Clobbering MVertex::_index is safe here: only vertices
  // classified on this face are touched (private to the face, so also safe
  // under the per-face OpenMP parallelism of Mesh2D) ...
  // Sentinels: -2 = used by an element, -3 = already collected.
  for(std::size_t i = 0; i < gf->mesh_vertices.size(); i++)
    gf->mesh_vertices[i]->setIndex(-1);
  for(std::size_t i = 0; i < gf->triangles.size(); i++)
    for(int j = 0; j < 3; j++) {
      MVertex *v = gf->triangles[i]->getVertex(j);
      if(v->onWhat() == gf) v->setIndex(-2);
    }

注释交代三个安全性前提:只碰"分类在这个面上"的顶点(面私有)、面之间 OpenMP 并行无竞争、 _index 在此阶段无含义。用哨兵 -2/-3 区分"被元素使用"与"已收集"避免重复插入。最后 还保持历史上"按 num 排序"的契约,且只在必要时排序:

cpp 复制代码
  if(!std::is_sorted(allverts.begin(), allverts.end(), MVertexPtrLessThan()))
    std::sort(allverts.begin(), allverts.end(), MVertexPtrLessThan());

5.6.3 repairDuplicateTriangles:缝 + 极点的重复三角形修复

周期性曲面在"缝 + 极点"的粗网格下会生成两个物理上重合但朝向相反 的三角形。 repairDuplicateTriangles(第 547--632 行)修复它,注释把陷阱讲得很透:

cpp 复制代码
// On a surface with a seam and a pole (a degenerate curve collapsed to a
// point), a coarse mesh can have an interior node adjacent to both sides of
// the seam next to the pole: the two parametric triangles joining it to the
// pole and to the first seam node are then the same real triangle, with
// opposite orientations, and the pole belongs to nothing else. The two
// triangles across the pole's ring (sharing the seam node - interior node
// edge) span the pole without touching it. Repair: drop the pair and split
// that edge at the pole, which gives a fan of four triangles around it.

实现用"排序后的三顶点"作 key 把三角形分桶(std::map<Key, std::vector<size_t>>)找出重复对, 再对每对识别"极点"(只有一个邻接三角形的顶点)、缝节点 s 与另一顶点 q,把"跨过极点环"的 两个三角形替换为绕极点展开的四块三角形扇。失败时报 Msg::Warning("Cannot repair the duplicate triangle on surface %d") 并返回 false。

5.6.4 质量统计

computeElementShapes(meshGFaceOptimize.cpp 第 1483--1521 行)计算面网格效率指标。它用数值 等价的公式替代逐三角形归一化:

cpp 复制代码
    // Same quantity as qmTriangle::gamma (2 * inradius / circumradius), in
    // the algebraically equivalent form 4 |u x v|^2 / (a b c (a + b + c)):
    // one cross product and three square roots instead of three vector
    // normalizations plus three cross-product norms. Values agree to
    // roundoff (max observed difference ~1e-14).
    const double c2 = cx*cx + cy*cy + cz*cz;
    const double la = std::sqrt(...), lb = std::sqrt(...), lc = std::sqrt(...);
    const double den = la * lb * lc * (la + lb + lc);
    const double q = (den > 0.) ? 4. * c2 / den : 0.;
    if(q > .9) greaterThan++;
    avg += q;  worst = std::min(worst, q);  best = std::max(best, q);  nT++;

gamma 与 4|u×v|²/(abc(a+b+c)) 代数等价(注释量化误差 "max observed difference ~1e-14")。 统计进入 gf->meshStatistics(worst_element_shape、average_element_shape、 best_element_shape、nbGoodQuality),是质量报告(tau 类效率指标)的来源。

5.7 meshGenerator:二维自由网格的主控制流

5.7.1 全流程逐段

meshGenerator(meshGFace.cpp 第 1455--1604 行)是非周期面的主控制流:

cpp 复制代码
bool meshGenerator(GFace *gf, int RECUR_ITER, bool repairSelfIntersecting1dMesh,
                   MeshExtent extent, bool debug, std::vector<GEdge *> *replacementEdges)
{
  BDS_GeomEntity CLASS_F(1, 2);            // 面板 → 面内分类
  BDS_GeomEntity CLASS_EXTERIOR(1, 3);     // 面板 → 域外分类
  std::map<BDS_Point *, MVertex *, PointLessThan> recoverMap;
  std::map<MVertex *, BDS_Point *> recoverMapInv;
  std::vector<GEdge *> edges = replacementEdges ? *replacementEdges : gf->edges();
  std::vector<GEdge *> emb_edges = gf->getEmbeddedEdges();
  std::set<MVertex *, MVertexPtrLessThan> all_vertices;
  if(!collectBoundaryNodes(gf, edges, emb_edges, all_vertices, debug && RECUR_ITER == 0))
    return false;
  if(all_vertices.size() < 3) { ...跳过... }
  else if(all_vertices.size() == 3) { ...直接建一个三角形... }

  BDS_Mesh *m = new BDS_Mesh;  std::vector<BDS_Point *> points;  SBoundingBox3d bbox;
  buildBDSPoints(gf, all_vertices, m, points, bbox, recoverMap, recoverMapInv);
  initialTriangulation(gf, m, points, bbox, all_vertices, recoverMapInv, replacementEdges, RECUR_ITER);

  std::set<EdgeToRecover> edgesToRecover, edgesNotRecovered;
  EdgeRecovery rec = recoverBoundaryEdges(gf, m, edges, emb_edges, recoverMapInv,
                                          edgesToRecover, edgesNotRecovered,
                                          RECUR_ITER, repairSelfIntersecting1dMesh, debug);
  if(rec != EdgeRecovery::Recovered) {
    delete m;
    if(rec == EdgeRecovery::Retry && RECUR_ITER < CTX::instance()->mesh.maxRetries)
      return meshGenerator(gf, RECUR_ITER + 1, ...);   // 自交 → 递归重试
    return false;
  }
  ...                                    // 恢复嵌入边(第 2 遍)
  colorExteriorTriangles(m, &CLASS_F, &CLASS_EXTERIOR, true);
  computeSizeField(m, recoverMap);       // 非 BAMG 时才设尺寸
  pruneAndCleanupBDS(m, &CLASS_F);
  delaunayizeBDS(gf, *m, nb_swap);       // 翻转至 Delaunay
  if(!algoDelaunay2D(gf) && extent == MeshExtent::Full) {
    refineMeshBDS(gf, *m, refineSteps, true,  &recoverMapInv, nullptr);  // MeshAdapt 造点
    refineMeshBDS(gf, *m, refineSteps, false, &recoverMapInv, nullptr);
  }
  gf->meshStatistics.status = GFace::DONE;
  BDS2GMSH(m, gf, recoverMap);           // BDS → MVertex/MTriangle
  bool infty = setupBackgroundMesh(gf, extent == MeshExtent::Full, nullptr, nullptr);
  if(extent == MeshExtent::Full) modifyInitialMeshForBoundaryLayers(gf, blQuads, blTris, verts, debug);
  if(algoDelaunay2D(gf) && extent == MeshExtent::Full)
    runDelaunayAlgorithm(gf, infty, true, nullptr, nullptr, nullptr);   // 造点
  delete m;
  insertBoundaryLayerElements(gf, blQuads, blTris, verts);
  splitElementsInBoundaryLayerIfNeeded(gf);
  if(extent != MeshExtent::BoundaryLayerRemesh) recombineSurfaceMesh(gf);
  finishSurfaceMesh(gf);                 // 质量统计 + 清无用点
  return true;
}

阶段小结:① 边界节点收集(collectBoundaryNodes,集合去重,环不闭合则 FAILED;注意 degeneratedVertices::instance() 是全局单例、无自带锁,那里的循环是并行网格化的串行化点 , #pragma omp critical,第 926--929 行);② 特殊情况短路(<3 点跳过、=3 点直接建三角形); ③ 建 BDS 点 + 初始三角化;④ 两遍边界恢复;⑤ 外围染色 + 清理 + 恢复嵌入边;⑥ 尺寸场计算 (仅非 BAMG 且 Full);⑦ Delaunay 化;⑧ 造点(refineMeshBDS 或 runDelaunayAlgorithm); ⑨ 回填 + 边界层 + 重组合 + 收尾。

5.7.2 MeshExtent:三种"走多深"

src/mesh/meshGFaceInternal.h 第 15--27 行:

cpp 复制代码
enum class MeshExtent {
  Full,                 // 全流程:尺寸场、内部点、边界层、Delaunay、重组合
  InitialOnly,          // 只到初始三角化,但仍参与重组合(Mesh.Algorithm = 3)
  BoundaryLayerRemesh,  // 同上且不重组合:边界层代码回调时用
};

InitialOnly 对应 Mesh.Algorithm = 3;BoundaryLayerRemesh 由边界层代码回调用 (meshGFaceBoundaryLayer.cpp 第 470 行)。同一函数用枚举参数表达三种深度而非三个函数, 避免大量重复。

5.7.3 MeshAdapt 路线:refineMeshBDS 的四件套

当算法不是 Delaunay 系(即 ALGO_2D_MESHADAPT)时,内部点由 refineMeshBDS (meshGFaceBDS.cpp 第 755--1003 行)添加。主循环是 split → smooth → swap → collapse → swap → smooth 的迭代,每轮统计局部修改数:

cpp 复制代码
  const double MINE_ = 0.7, MAXE_ = 1.4;
  while(1) {
    setDegeneracy(deg, degType);
    int nb_split = 0, nb_smooth = 0, nb_collaps = 0, nb_swap = 0;
    splitEdgePass(gf, m, MAXE_, nb_split, true_boundary, t_spl);
    if(IT == 0) splitEdgePass(gf, m, MAXE_, nb_split, true_boundary, t_spl);
    smoothVertexPass(gf, m, nb_smooth, false, .5, t_sm);
    swapEdgePass(gf, m, nb_swap, t_sw);
    smoothVertexPass(gf, m, nb_smooth, false, .5, t_sm);
    collapseEdgePass(gf, m, MINE_, MAXNP, nb_collaps, t_col);
    smoothVertexPass(gf, m, nb_smooth, false, .5, t_sm);
    swapEdgePass(gf, m, nb_swap, t_sw);
    smoothVertexPass(gf, m, nb_smooth, false, .5, t_sm);
    if(IT == abs(NIT)) { collapseEdgePass(gf, m, .45, MAXNP, nb_collaps, t_col); ... }
    m.cleanup();  m.removeOrphanPoints();
    double minL = 1.e22, maxL = 0;   // 统计每条边长度比 lone = 2*len/(lc1+lc2)
    ...
    if((minL > MINE_ && maxL < MAXE_) || IT > (abs(NIT))) break;
    IT++;
    if(nb_split == 0 && nb_collaps == 0) break;
  }

四个 pass:

  • splitEdgePass(第 435--533 行)把"太长"(长度比 > 1.4)的面内边在中点分裂,中点按 middlePoint/参数加权求位置,并检查 ParametricDomainChecker::inside 确保落在真实域内; 周期性缝两侧还补"-10.0"优先级的边(第 448--465 行)。
  • collapseEdgePass(第 561--615 行)把"太短"(长度比 < 0.7)的边折叠,折叠到哪个端点看 getMaxLcWhenCollapsingEdge(哪种折叠让长度比更接近 1)。
  • swapEdgePass(第 206--236 行)翻转改善质量:先算 THRESH = cos(allowSwapEdgeAngle·π/180), 用 edgeSwapTestAngle 快速筛掉"翻转会造出极小角"的候选(注释:"the angle test does not depend on the four qualities below, so evaluate it first and skip them altogether when it fails"),再比质量;FINALIZE 且非 Gmsh 内核时用 BDS_SwapEdgeTestNormals。
  • smoothVertexPass(第 617--630 行)对每个 neighboringModified 的点调 smooth_point_centroid,复用一份 BDS_SmoothScratch。

收敛判据 是"所有边长度比都在 0.7, 1.4 内",或达迭代上限。maxE 第 0 轮分裂两次是 heuristic(先建立尺度分布)。

5.7.4 尺寸场:NewGetLc / correctLC_ / computeNodalSizes / computeSizeField

各 pass 用统一的"边长比"函数。NewGetLc(第 137--152 行)返回"实际长度 / 目标长度":

cpp 复制代码
static double NewGetLc(BDS_Point *p1, BDS_Point *p2, GFace *const face, const BDSSizeContext &ctx)
{
  if(!(*p1 < *p2)) std::swap(p1, p2);
  double len = face->geomType() == GEntity::Plane ?
                 computeEdgeLinearLength(p1, p2) : computeEdgeLinearLength(p1, p2, face);
  return len / correctLC_(p1, p2, face, ctx);
}

两个设计点:

  • 平面走欧氏长度,曲面走"折线长度" :computeEdgeLinearLength(p1,p2,GFace*)(第 61--79 行) 取曲面中点,长度是 |p1-mid| + |mid-p2|(中点映射失败时回退欧氏)。为何不用度量张量? 这里要和尺寸场(也是物理长度)比较,折线近似比度量积分更便宜也更鲁棒。

  • BDSSizeContext (第 93--101 行)把两个"整面网格期间不变"的选项读一次:

    cpp 复制代码
    struct BDSSizeContext {
      bool extend1d, lcFromCurvature;
      BDSSizeContext(GFace *gf) : extend1d(Extend1dMeshIn2dSurfaces(gf)),
        lcFromCurvature(CTX::instance()->mesh.lcFromCurvature) {}
    };

    注释解释动机:"they used to be re-read from the (non inlined) option structures for every single edge; they are now looked up once per pass and threaded through."------把"每边重读全局 选项"改成"每趟读一次并传参",正是第 4 章提到的尺寸场求值热点的实例。

correctLC_(第 108--132 行)综合两端点尺寸与中点 BGM_MeshSize 取最小;开启 lcFromCurvature 时再做 min(lcmin*1.2, ...) 平滑。NewGetLc(BDS_Point*, ctx)(第 103--106 行):

cpp 复制代码
  return ctx.extend1d ? std::min(point->lc(), point->lcBGM()) : point->lcBGM();

这就是 _lcBGM/_lcPTS 双尺寸的用法 :extend1d 真时取更小者,否则只用 BGM。 Extend1dMeshIn2dSurfaces 决定是否让一维特征长度传播到二维内部。computeNodalSizes (第 632--717 行)从一维网格反推节点尺寸(取所有"模型边邻居边"的最大长度),其中第 658--716 行 是被 return 提前跳过的旧实现(死代码)------读 Gmsh 源码要习惯这种"留着的历史实现"。

computeSizeField(meshGFace.cpp 第 875--899 行)在造点前用 recoverMapInv 把每个 BDS 点尺寸 设为 BGM_MeshSize,并同步 lc() = lcBGM():

cpp 复制代码
      if(ge->dim() == 0) pp->lcBGM() = BGM_MeshSize(ge, 0, 0, here->x(), here->y(), here->z());
      else if(ge->dim() == 1) { double u; here->getParameter(0, u);
                                pp->lcBGM() = BGM_MeshSize(ge, u, 0, here->x(), here->y(), here->z()); }
      else pp->lcBGM() = MAX_LC;
      pp->lc() = pp->lcBGM();

只对 algo2d != ALGO_2D_BAMG 且 Full 时调用(第 1541--1542 行)------BAMG 自管尺寸。

5.7.5 退化点的处理

参数域奇异点(极点、锥顶)会产生退化边(两端点在三维里映射到同一个点)。处理分三步:

  1. getDegeneratedVertices (第 29--51 行)找出"只有一个面、且两端映射到同一 MVertex"的边。

  2. modifyInitialMeshToRemoveDegeneracies (第 719--751 行)给退化点打标记:退化边沿 u = const 标 2,否则标 1;再 collapse_edge_parametric(..., force = true) 折叠退化边, 并从 recoverMap 删掉被折叠端点:

    cpp 复制代码
           if(std::abs(degenerated_edges[K]->p1->u - degenerated_edges[K]->p2->u) < 1e-12) {
             Msg::Debug("Degenerated edge on u = cst axis: treated as well now!");
             it->first->degenerated = 2;
           }
           else { it->first->degenerated = 1; }

    degenerated = 1 表示"自由坐标是 u",2 表示 v------与 5.4.2 一致。

  3. splitAllEdgesConnectedToSingularity (第 406--433 行)需算节点尺寸场时,把所有与奇异点 相连的边分裂一次(中点调 gf->point(U,V),检查 split_edge(e, mid, true) 面积守恒);注释 "abort if one of the splits leads to an invalid mesh in the param plane"------一旦破坏参数平面 有效性就整体放弃。

此外 refineMeshBDS 用 degType 在一次迭代中"临时清空退化标记"、迭代末恢复(第 805--819、 987 行):getDegeneracy 收集退化点,setDegeneracy(deg, degType) 每轮恢复------**"临时取消标记 让该点参与移动,但保持退化属性"**是微妙的折中。

5.7.6 delaunayizeBDS:把初始网格变成 Delaunay

初始三角化后网格还不是 Delaunay。delaunayizeBDS(meshGFaceBDS.cpp 第 266--287 行)用"不断翻转 直到没有可翻的边":

cpp 复制代码
void delaunayizeBDS(GFace *gf, BDS_Mesh &m, int &nb_swap)
{
  nb_swap = 0;  std::set<swapquad> configs;
  while(1) {
    size_type NSW = 0;
    for(size_type index = 0; index < m.edges.size(); ++index)
      if(!m.edges.at(index)->deleted)
        if(edgeSwapTestDelaunayAniso(m.edges.at(index), gf, configs))
          if(m.swap_edge(m.edges.at(index), BDS_SwapEdgeTestQuality(false))) ++NSW;
    nb_swap += NSW;
    if(!NSW) return;      // 一轮没有任何翻转 → 收敛
  }
}

edgeSwapTestDelaunayAniso(第 238--264 行)是"各向异性空椭球"判据 + configs (std::set<swapquad>)去重:

cpp 复制代码
  if(!e->p1->config_modified && !e->p2->config_modified) return false;
  if(e->numfaces() != 2) return false;
  e->oppositeof(op);
  swapquad sq(e->p1->iD, e->p2->iD, op[0]->iD, op[1]->iD);
  if(configs.find(sq) != configs.end()) return false;   // 这个四元组已经试过
  configs.insert(sq);
  double edgeCenter[2] = {0.5*(e->p1->u+e->p2->u), 0.5*(e->p1->v+e->p2->v)};
  buildMetric(gf, edgeCenter, metric);
  if(!inCircumCircleAniso(gf, p1, p2, p3, p4, metric)) return false;
  return true;

configs 防止同一个四元组被反复翻转 (A→B→A→B...)------翻转类算法必须防震荡。注意这里 swap_edge 用 BDS_SwapEdgeTestQuality(false)(testQuality=false),即只保留"避免二邻居点

  • 面积守恒",质量比较交给空椭球判据。

5.8 前沿推进:Frontal-Delaunay

5.8.1 Rebay 的思路与活动三角形

meshGFaceDelaunay.cpp 第 1116--1118 行注释交代来源:

cpp 复制代码
// Let's try a frontal delaunay approach now that the delaunay mesher is stable.
// We use the approach of Rebay (JCP 1993) that we extend to anisotropic
// metrics. The point isertion is done on the Voronoi Edges.

即:沿用 Rebay 1993 JCP 论文,并推广到各向异性度量;新点插在 Voronoi 边上(度量外接圆心与 活动边中点的连线方向)。

三角形的"归一化外接半径"由 MTri3::MTri3(第 206--271 行)按 radiusNorm 计算:2 用三维 外接圆半径除以 lc,3 用 1/gammaShapeMeasure,-1(Frontal-Quad 用)用参数平面某方向最大 跨度除以 RATIO·lc,其中 RATIO = pow(metric[0]*metric[2] - metric[1]*metric[1], -0.25)(即 det(M)^(-1/4))。"活动三角形"由 isActive(第 52--82 行)判定:存在一个没有邻居(或邻居 半径小于阈值)的局部边:

cpp 复制代码
static bool isActive(MTri3 *t, double limit_, int &active)
{
  if(t->isDeleted()) return false;
  for(active = 0; active < 3; active++) {
    MTri3 *neigh = t->getNeigh(active);
    if(!neigh || (neigh->getRadius() < limit_ && neigh->getRadius() > 0)) return true;
  }
  return false;
}

LIMIT_ = 0.5 * sqrt(2)(第 37 行)------"理想网格在该度量下归一化外接半径约为 √2/2"的阈值。

5.8.2 三种最优新点、主循环与分层

  • optimalPointFrontal(第 1189--1243 行):算度量外接圆心与活动边中点,求方向 dir 与 RATIO, 取 rhoM = min(vSizes, vSizesBGM),d = rhoM·sqrt(3)/2(上方注释推导"正三角形高 = √3/2·lc"), L = min(d, q)(q 是圆心-中点的度量距离,"不能越过 Voronoi 边"), newPoint = midpoint + L*dir/RATIO。
  • optimalPointFrontalB(第 1261--1314 行):先调 A 得初值,再用 intersectCurveSurface(离散面 用 intersectionWithCircle)把点投影到曲面------"最优位置必须在曲面上"。
  • optimalPointFrontalQuad(第 1396--1482 行):在 cross field 旋转后的坐标系里按 lengthInfniteNorm(L∞ 范数)算四边形前沿的理想步长与方向。

bowyerWatsonFrontal(第 1316--1394 行)主循环:

cpp 复制代码
    MTri3 *worst = (*ActiveTris.begin());  ActiveTris.erase(ActiveTris.begin());
    if(!worst->isDeleted() && isActive(worst, LIMIT_, active_edge) && worst->getRadius() > LIMIT_) {
      double newPoint[2], metric[3];
      if(optimalPointFrontalB(gf, worst, active_edge, DATA, newPoint, metric)) {
        SPoint2 NP(newPoint[0], newPoint[1]);  int nnnn;
        if(!true_boundary || pointInsideParametricDomain(*true_boundary, NP, FAR, nnnn))
          insertAPoint(gf, AllTris.end(), newPoint, metric, DATA, AllTris,
                       &ActiveTris, worst, nullptr, testStarShapeness);
      }
    }

bowyerWatsonFrontalLayers(第 1528--1650 行)是"分层"版本(Frontal-Quad 入口):用 std::set<MEdge, MEdgeLessThan> _front 显式跟踪前沿并限制层数------quad 真时 LIMIT_ = sqrt(2)*0.99、radiusNorm = -1、max_layers = 10000,否则 max_layers = 4:

cpp 复制代码
    std::set<MEdge, MEdgeLessThan> _front;
    for(; it != AllTris.end(); ++it)
      if(isActive(*it, LIMIT_, active_edge)) { ActiveTris.insert(*it); updateActiveEdges(*it, LIMIT_, _front); }
      else if((*it)->getRadius() < LIMIT_) break;
    int max_layers = quad ? 10000 : 4;
    ...
      if(!worst->isDeleted() &&
         (ITERATION > max_layers ? isActive(worst, LIMIT_, active_edge)
                                 : isActive(worst, LIMIT_, active_edge, &_front)) &&
         worst->getRadius() > LIMIT_) {
        double newPoint[2], metric[3] = {1, 0, 1};
        if(quad) optimalPointFrontalQuad(gf, worst, active_edge, DATA, newPoint, metric);
        else     optimalPointFrontalB(gf, worst, active_edge, DATA, newPoint, metric);
        insertAPoint(gf, AllTris.end(), newPoint, nullptr, DATA, AllTris, &ActiveTris, worst);
      }

"层"的意义 :每轮只处理"当前前沿上的活动三角形",其余收进 ActiveTrisNotInFront 下一轮 再决定。这样新点从边界向内一层层推进而非随机撒点------这是 Frontal-Quad 能生成整齐四边形带的 原因。

5.8.3 经典 Bowyer-Watson 与插入机制

bowyerWatson(第 1058--1114 行)是纯 Delaunay:只不断找"半径最差"的三角形,在其外接圆心处 插点,直到最差半径 < 0.5*sqrt(2) 或点数超 MAXPNT:

cpp 复制代码
  while(1) {
    MTri3 *worst = *AllTris.begin();
    if(worst->isDeleted()) { delete worst->tri(); delete worst; AllTris.erase(AllTris.begin()); }
    else {
      if(worst->getRadius() < 0.5 * std::sqrt(2.0) || (int)DATA.vSizes.size() > MAXPNT) break;
      double center[2], metric[3], r2;
      buildMetric(gf, pa, metric);                       // pa = 形心
      circumCenterMetric(worst->tri(), metric, DATA, center, r2);
      insertAPoint(gf, AllTris.begin(), center, metric, DATA, AllTris);
    }
  }

支撑它的机制:

  • recurFindCavityAniso (第 356--380 行)递归找出"外接椭球含新点"的三角形(待删腔体)并 收集边界边 shell;内部边(嵌入边)不可穿越 :if(!neigh || it != data.internalEdges.end()) shell.push_back(exf);。
  • insertVertexB (第 486--621 行)删腔体、以 shell 边为底、新点 v 为顶点重建三角形,并 校验 shell.size() == cavity.size() + 2(欧拉公式)、体积守恒 (fabs(oldVolume-newVolume) < EPS*oldVolume)、新点不太靠近已有边(onePointIsTooClose); 失败返回 -2/-3/-4/-5 并回滚。
  • connectNewTriangles(第 447--484 行)用 O(k) 连接新三角形扇(取代旧实现的排序)。
  • insertAPoint (第 720--834 行):先判新点能否破坏最差三角形(inCircumCircleAniso),能 则直接从 worst 找腔体,否则用 search4Triangle 沿邻居"行走"定位宿主;并复用 thread_local 的 shell/cavity 缓冲(第 738--744 行注释:旧实现"allocated one list node per shell edge and per cavity triangle",现改为"one buffer pair per thread")。

transferDataStructure(第 991--1056 行)把三角形交还 gf->triangles 并统一朝向 :以第一个 三角形在参数平面的法向 n1 为基准,逐个比较 prosca(n1, n2) < 0 就 t->reverse()。

5.8.4 flat 内核(Mesh.FlatRefine2D)

meshGFaceDelaunayFlat.cpp(文件头第 6--36 行)把同一批算法换成"索引数组"表示:顶点是按 MVertex::getNum() 排序的并行数组,三角形是顶点 id 三元组 + 邻居/度量半径/标志的并行数组 (删除槽位用 free-list 回收),队列是 (radius, slot) 二叉堆,腔体用显式栈扩张,最后一次性创建 MVertex/MTriangle。它是"同一算法、不同表示" :FlatRefine2D = 1 时网格逐元素相同;= 2 打开浮点捷径(缓存外接圆、平面闭式半径、平面不投影最优点),此时网格仅"等价"(注释量化:平面 提速约 14%、曲面无提速、节点数差异 0.01% 以内)------一次"数据结构换性能而不换算法"的干净改造。

5.9 PACK:打包平行四边形

ALGO_2D_PACK_PRLGRMS 的入口是 bowyerWatsonParallelograms(第 1652--1747 行)。它先要求曲面 必须有参数化 ,再依 HAVE_DOMHEX 与 old_algo_hexa() 分流到打包器:

cpp 复制代码
  if(!gf->haveParametrization()) {
    Msg::Error("- Face %i: no CAD parametrization available, cannot mesh with algo PACK", gf->tag());
    return;
  }
#if defined(HAVE_DOMHEX)
  if(old_algo_hexa()) { packingOfParallelograms(gf, packed, metrics); }
  else { Filler2D f; f.pointInsertion2D(gf, packed, metrics); }
#else
  Msg::Error("Packing of parallelograms algorithm requires DOMHEX");
#endif

打包器(contrib/domhex/surfaceFiller.cpp 第 418 行起) 入口注释点明前提:

cpp 复制代码
  if(fields->getBackgroundField() > 0) {
    cross_field = fields->get(fields->getBackgroundField());
    if(cross_field->numComponents() != 3) {  // we have a true scaled cross field !!
      Msg::Error("Packing of Parallelograms require a scaled cross field");

即 PACK 需要一个"缩放过的 cross field"(3 分量背景场) ,它同时给出方向与尺度。打包把边界点 放进 FIFO,每取出一点就用 compute4neighbors 在"排除区域"外生成 4 个邻居(必要时考虑周期平移 du/dv),用 RTree 索引;生成点先 SortHilbert(packed) 排序再逐个插入:

cpp 复制代码
  for(std::size_t i = 0; i < packed.size();) {
    MTri3 *worst = *AllTris.begin();
    if(worst->isDeleted()) { ... }
    else {
      packed[i]->getParameter(0, newPoint[0]);  packed[i]->getParameter(1, newPoint[1]);
      delete packed[i];
      double metric[3];  buildMetric(gf, newPoint, metric);
      bool success = insertAPoint(gf, AllTris.begin(), newPoint, metric, DATA, AllTris,
                                  nullptr, oneNewTriangle, &oneNewTriangle);
      if(!success) oneNewTriangle = nullptr;
      i++;
    }
  }

oneNewTriangle 是上次成功插入产生的第一个新三角形,作为下次 search4Triangle 的起点。背景 网格由 setupBackgroundMesh(第 674--692 行)与 buildBackgroundMesh(第 1484--1526 行)准备: 后者先用临时 bowyerWatson(gf, 40000, ...) 造粗背景网格(期间临时关掉曲率尺寸 lcFromCurvature = 0),再 backgroundMesh::set(gf)(解 PDE)或 setCrossFieldsByDistance。

5.10 三角形 → 四边形:重组合三条路线

5.10.1 分派

recombineSurfaceMesh(meshGFace.cpp 第 634--650 行):

cpp 复制代码
static void recombineSurfaceMesh(GFace *gf)
{
  if((CTX::instance()->mesh.recombineAll || gf->meshAttributes.recombine) &&
     (CTX::instance()->mesh.algoRecombine <= 1 || CTX::instance()->mesh.algoRecombine == 4)) {
    if(CTX::instance()->mesh.algoRecombine == 4) {
      meshGFaceQuadrangulateBipartiteLabelling(gf->tag());     // 路线 C
    }
    else {
      bool blossom = (CTX::instance()->mesh.algoRecombine == 1);
      int topo = CTX::instance()->mesh.recombineOptimizeTopology;
      int repos = CTX::instance()->mesh.recombineNodeRepositioning;
      double minqual = CTX::instance()->mesh.recombineMinimumQuality;
      recombineIntoQuads(gf, blossom, topo, repos, minqual);   // 路线 A(0=贪心, 1=blossom)
    }
  }
}

它只处理 algoRecombine <= 1 || == 4;algoRecombine == 2(simple full quad)与 3 (blossom full quad)在别处处理 ------它们需先"删一半一维网格",由 quadMeshRemoveHalfOfOneDMesh 完成(5.10.3)。合法范围 0..4 在 src/common/Options.cpp 第 6528--6533 行强制。

5.10.2 路线 A:recombineIntoQuads(blossom 匹配)

_recombineIntoQuads(meshGFaceOptimize.cpp 第 1018--1260 行)先建边-元素邻接 (buildEdgeToElement),收集所有"内部、两侧都是三角形"的边为候选配对:

cpp 复制代码
  for(auto it = adj.begin(); it != adj.end(); ++it) {
    MEdge ed(it->first.getVertex(0), it->first.getVertex(1));
    if(bndedges.find(ed) == bndedges.end() && embedges.find(ed) == embedges.end() &&
       it->second.second && it->second.first->getNumVertices() == 3 &&
       it->second.second->getNumVertices() == 3) {
      pairs.push_back(RecombineTriangle(it->first, it->second.first, it->second.second, cross_field));
    }
    ...
  }
  std::sort(pairs.begin(), pairs.end());

RecombineTriangle(第 38--102 行)为每对三角形打一个代价 :先构造外接四边形 MQuadrangle(n1,n3,n2,n4),取 etaShapeMeasure 作形状分,再用四个内角与 90° 的最大偏差作 angle:

cpp 复制代码
  MQuadrangle q(n1, n3, n2, n4);
  angle = q.etaShapeMeasure();
  double const a1 = 180 * angle3Vertices(n1, n4, n2) / M_PI;
  ...
  quality = std::max(std::abs(90. - a1), quality);      // 四个角都算
  if(cross_field) { ... quality /= ErrDir; if(n1->onWhat()->dim() < 2 || ...) quality /= 10; }

operator< 使"代价小(质量高)的配对"排在前面;有 cross field 时把方向偏差 ErrDir 除进 代价,并对贴边配对乘惩罚 10。

blossom 分支 (第 1086--1218 行)把配对化成一般图的最大权完美匹配 :每对三角形是一条边, 权 elen[i] = (int)1000 * exp(-pairs[i].angle)(角度越小权越大),调用 perfect_match(ncount, nullptr, ecount, &elist, &elen, ...)(Blossom IV + CONCORDE,需 HAVE_BLOSSOM);周期面的 "闭合配对"(makeGraphPeriodic)权 100000:

cpp 复制代码
      elen[i] = (int)1000 * std::exp(-pairs[i].angle);
      int NB = 0;
      if(pairs[i].n1->onWhat()->dim() < 2) NB++;  ...
      if(elen[i] > (int)(1000 * std::exp(0.1)) && NB > 2) elen[i] = 5000;
      else if(elen[i] >= 1000 && NB > 2) elen[i] = 10000;

匹配失败时打印 "Perfect Match failed in quadrangulation, try something else" 并退化为贪心 (第 1143--1148 行)------优雅降级 ;"奇数个三角形无法匹配"先报 "Cannot apply Blossom: odd number of triangles (%d)"。贪心分支 (第 1221--1247 行)按 pairs 排序顺序,只要配对角度 < recombineAngle 且两三角形都没用过就合成四边形------touched 集合防重复使用。

recombineIntoQuads(第 1314--1374 行)是外层编排:_recombineIntoQuads → 可选 RelocateVertices → #pragma omp critical(recombineIntoQuads) 下拓扑优化 (removeTwoQuadsNodes + removeDiamonds)→ quadsToTriangles(minqual)(差四边形拆回三角形) → 最后再 RelocateVertices 并 printStats。日志带时间("%s recombination completed (Wall %gs, CPU %gs)",blossom ? "Blossom" : "Simple")。**"先贪心合成、再拓扑/几何优化、最后把残余坏 四边形拆回三角形"**就是路线 A 的骨架。

其中 removeTwoQuadsNodes(第 239--325 行)消除"只有两个四边形共享的顶点"(把两四边形合成 一个),removeDiamonds(第 720--823 行)消除"钻石"(四个四边形围绕一个顶点、对角顶点度数为 3/4),两者合并都用 _tryToCollapseThatVertex(第 388--460 行)比较"合并后最差质量是否变好"。 它们会删节点,故有前置检查 _isModelOkForTopologicalOpti(第 1304--1312 行):多面 STL 读入 且未 CreateTopology/ReclassifySurfaces 时,一个面的元素可能引用相邻面的节点,删节点会破坏 网格甚至崩溃 ,故先验证"gf 的元素只用 gf/曲线/点的节点,且别的面不用 gf 的节点"。

5.10.3 路线 B:full-quad(删一半一维网格)

quadMeshRemoveHalfOfOneDMesh(src/mesh/meshGFaceQuadHalf.cpp) 是 algoRecombine == 2/3 的机制。构造时把每条一维网格"隔一个点删一个",把删掉的中点记进 _middle,并 lcFactor *= 2:

cpp 复制代码
      for(std::size_t i = 0; i < (*ite)->lines.size(); i += 2) {
        MVertex *v1 = (*ite)->lines[i]->getVertex(0);
        MVertex *v2 = (*ite)->lines[i]->getVertex(1);
        MVertex *v3 = (*ite)->lines[i + 1]->getVertex(1);
        v2->x() = 0.5 * (v1->x() + v3->x());   // 中点挪到 v1-v3 中
        ...
        temp.push_back(new MLine(v1, v3));
        _middle[MEdge(v1, v3)] = v2;
      }
      CTX::instance()->mesh.lcFactor *= 2.0;

这要求一维网格段数为偶数(i + 1 >= lines.size() 时报 "1D mesh of MEdge %d cannot be divided by 2")。finish()(第 210--227 行)在半网格 上先 recombineIntoQuads(blossom 当 algoRecombine == 3),再 _subdivide()(每块四边形中点四分为 4 块),再 _restore() 恢复 一维网格,最后再 recombineIntoQuads(minqual = 1e-3):

cpp 复制代码
  CTX::instance()->mesh.lcFactor /= 2.0;
  bool blossom = (CTX::instance()->mesh.algoRecombine == 3);
  recombineIntoQuads(_gf, blossom, topo, repos, 0.1);
  _subdivide();
  _restore();
  recombineIntoQuads(_gf, blossom, topo, repos, 1.e-3);

_subdivide(第 18--134 行)把每个三角形细分出 3 块四边形、每个四边形细分出 4 块四边形,中点用 _gf->point((p1+p2)*0.5) 求参数后建 MFaceVertex。"先在半网格上合成四边形、再细分回全网格" 能得到"全四边形"而非"四边形为主"------代价是对一维段数的奇偶性提出硬约束(第 4 章 4.4 节的 N 约束正是为此)。

5.10.4 路线 C:BipartiteLabelling

meshGFaceQuadrangulateBipartiteLabelling( src/mesh/meshGFaceBipartiteLabelling.cpp)用 "二部图标注 "求四边形化(Georgiadis 2021 的想法,文件头注明)。它把面读成 PolyMesh (半边结构,GFace2PolyMesh),然后:

  1. 边界标注 :沿每个边界环交替标 0/1,要求环上节点数为偶数------

    cpp 复制代码
       if(nodeCount % 2 != 0) {
         Msg::Warning("Bipartite algorithm requires an even numer of boundary vertices "
                      "on every edge loop ...", faceTag, nodeCount);
       }

    (拼写 "numer" 是源码里的原样。)这正是 algoRecombine == 2/4 需要一维段数奇偶约束的原因。

  2. 内部标注传播 :用 BFS 队列把"与当前顶点标签相反"的标签传播给邻居;对内部顶点按与 _dirs(方向)最接近的方向优先传播:

    cpp 复制代码
         std::vector<std::pair<double, PolyMesh::HalfEdge *>> bests;
         ... 按 -max(|dot(t1,t)|,|dot(t2,t)|) 排序 ...
         int count = 0;
         for(auto it : bests) { if(count++ > 3) break; ... _labels[best->next->v] = !currentLabel; }
  3. 局部最优 :反复扫描所有内部顶点,比较"翻转自己标签前/后"违规邻居对数 (nbInvalidBefore vs nbInvalidAfter),取更少者,直到不动点。

  4. 加 Steiner 点 :对"四角同标"或"三角同标"的四边形/三角形,在中点 split_edge 并给新点 赋相反标签。

  5. 合并 :把"两端同标、两侧都是三角形"的边 merge_faces(he) 合成四边形,clean 后写回 PolyMesh2GFace。

这条路线不依赖 blossom ,但依赖 HAVE_QUADMESHINGTOOLS(否则函数体退化为一句 Msg::Error("This requires quad meshing tools"),第 356--361 行)。

5.11 QuadQS:准结构化全四边形

5.11.1 前置:QuadqsContextUpdater 与自动 Transfinite

ALGO_2D_QUAD_QUASI_STRUCT 在 GenerateMesh 阶段有专门前置:构造 QuadqsContextUpdater (临时改参数,析构时用 RestoreValueAtEndOfLife 全部还原),并对"边界恰好 4 条边"的面自动 建立 transfinite:

cpp 复制代码
  std::set<GFace *> faces;
  for(GFace *gf : m->getFaces()) if(gf->edges().size() == 4) faces.insert(gf);
  MeshSetTransfiniteFacesAutomatic(faces, 2.35, true, maxDiffRel);   // maxDiffRel = 0.34

QuadqsContextUpdater(src/mesh/meshQuadQuasiStructured.cpp 第 2234--2312 行)备份的选项极多:algo2d、recombineAll、algoRecombine、 recombineOptimizeTopology、lcFactor、lcMin、lcMax、lcFromPoints、minCurveNodes、 minCircleNodes、abortOnError,以及每条 GEdge 的 method/coeffTransfinite/meshSize/ nbPointsTransfinite/typeTransfinite/minimumMeshSegments/reverseMesh 与每个 GFace 的 recombine/recombineAngle/method/transfiniteArrangement/...。这是"局部改全局状态"的代价 ------QMSH 工具链需要一个与常规不同的参数环境,Gmsh 用 RAII 保证退出时复原。

5.11.2 背景网格 + 引导场

BuildBackgroundMeshAndGuidingField(第 453--878 行)是 QuadQS 与 PACK 的核心前置。它先确保有 三角化(没有就用 generateMeshWithSpecialParameters 生成,edgeScaling 略细于目标四边形网格以 获取更准的 cross field),把 GModel 网格导入 GlobalBackgroundMesh,然后逐面并行计算:

  • cross field :computeCrossFieldWithHeatEquation(N, triangles, lines, triEdgeTheta, ...)------ 用热方程在背景三角形上解一个 N 对称方向场(N = 4 或 6,N != 4 && N != 6 直接报 "guiding field: %i-symmetry field not supported",第 461--464 行)。
  • 奇异点 :detectCrossFieldSingularities(N, triangles, triEdgeTheta, ...),还可把"锐角角点" 也算作奇异点(addSingularitiesAtAcuteCorners = true,阈值 30°)。
  • 尺度图 :三种方法(quadqsSizemapMethod)------来自背景三角化边长 (fillSizemapFromTriangleSizes)、来自标量背景场(fillSizemapFromScalarBackgroundField)、 或由 cross field 的共形缩放 算出的目标四边形数(computeCrossFieldConformalScaling → computeQuadSizeMapFromCrossFieldConformalFactor)。注意 midpointSubdivisionAfter(当 algoRecombine ∈ {1,2,4} 时关掉)会调整目标数(targetNumberOfQuads /= 4)。
  • 量化滤波 (quantileFiltering,范围 0.05)与 one-way smoothing (sizeMapOneWaySmoothing,gradientMax = 1.2:注释坦承"this param should be a global gmsh option")。
  • 最后把方向场(每三角形 9 分量)与尺度图写进 buildBackgroundField(..., "guiding_field")。

要点:QuadQS 不"直接造点",而是先建立"引导场(方向+尺度)",再让后续打包与优化都对齐它 ------这是它与 Delaunay/Frontal 的根本区别。

5.11.3 中点细分 + 背景投影

RefineMeshWithBackgroundMeshProjection(第 1650--1932 行)在初始"四边形为主"网格上做中点 细分 并把新节点投影回几何。它先把曲线/面上的 MVertex 转成 MEdgeVertex/MFaceVertex (第 1698--1790 行),再投影:

cpp 复制代码
/* - projections on curves */
      GPoint proj = ge->closestPoint(v->point(), tGuess);
      if(proj.succeeded()) { v->setXYZ(proj.x(), proj.y(), proj.z()); v->setParameter(0, proj.u()); }
/* - projections on faces */
      proj = sp->closestPoint(v->point().data(), evalOnCAD, projOnCad);

面的投影用背景网格三角形构造的 SurfaceProjector(sp->initialize(gf, triangles)),失败时回退 gf->closestPoint。之后每面做 optimizeGeometryQuadMesh(gf, sp, timeMax = 0.3);若 SICN (四边形形状度量)最小值 < 0 再跑一次 2 秒版本。注释里 USE_CAD_PROJECTION 分支直接写着 "Problem: it is slow and buggy on periodic CAD"------"简单版" (RefineMeshWithBackgroundMeshProjectionSimple)才是默认路径。

5.11.4 腔体重网格与坏网格替换

optimizeTopologyWithCavityRemeshing(第 2022--2102 行)对每个 PENDING 且有四边形的面取奇异点 (优先从引导场读 getSingularitiesFromBackgroundField,否则重算)后调用 improveQuadMeshTopologyWithCavityRemeshing(gf, singularities, invertNormals)------"腔体重网格" 在奇异点附近取出一个四边形腔体,用模式库重新铺四边形。这套工具在 HAVE_QUADMESHINGTOOLS(QMSH) 里,QuadqsTopoOptimMethods 控制启用哪些(0/1/11/101/111,第 2024--2032 行)。

replaceBadQuadDominantMeshes(第 1973--1994 行)与 checkAndReplaceQuadDominantMesh (第 1934--1971 行)负责"救不回的面":若内部顶点最大价数 vMaxInt > 8 或边界顶点 vMaxBdr > 6,就退回 ALGO_2D_MESHADAPT 重网格,再重新组合:

cpp 复制代码
  int algo2d = gf->getMeshingAlgo();
  gf->setMeshingAlgo(ALGO_2D_MESHADAPT);
  gf->mesh(false);
  gf->setMeshingAlgo(algo2d);
  recombineIntoQuads(gf, false, 0, false, .1);

quadMeshingOfSimpleFacesWithPatterns(第 1996--2019 行)则对"无嵌入、几何简单"的面调用 meshFaceWithGlobalPattern,用预置四边形模式库(initQuadPatterns())直接铺网格。

5.11.5 缝顶点迁移:transferSeamGEdgesVerticesToGFace

transferSeamGEdgesVerticesToGFace(第 2148--2232 行)解决 QuadQS 特有的拓扑问题:缝边顶点 悬挂在 GEdge 上而不是 GFace 上 ,而 QMSH 工具要求它们属于面。它对"是 gf 的缝、且只属于 gf 一个面"的曲线,把其中间顶点用 ge->reparamOnFace(gf, t, -1) 算出面参数后重建为 MFaceVertex 并挂到 gf,同时清掉曲线的 lines:

cpp 复制代码
        SPoint2 uv = ge->reparamOnFace(gf, t, -1);
        MVertex *nv = new MFaceVertex(p.x(), p.y(), p.z(), gf, uv[0], uv[1]);
        nv->setParameter(0, uv[0]); nv->setParameter(1, uv[1]);
        gf->mesh_vertices.push_back(nv);
        old2new[ov] = nv;  delete ov;

对"角点上的孤立 GVertex 顶点"也做迁移,但注释特意写:

cpp 复制代码
            /* Note/warning: let the MVertex on the GVertex live. If the MVertex
             * is deleted, I/O is broken.
             * FIXME: support for GVertex without MVertex in mesh I/O */

------GVertex 上的 MVertex 不能删 ,否则网格写出会出错。这类"为什么不能删"的注释是维护者 经验的结晶。最后用 old2new 把面内所有三角形/四边形的顶点指针替换(第 2216--2229 行)。

5.12 Transfinite:超限插值结构化面

5.12.1 Coons 公式与两个宏

Transfinite 面用超限插值(transfinite / Coons patch) :给定四条边界曲线 c1..c4 与四个角点 s1..s4,内部点由

scss 复制代码
f(u,v) = (1-u) c4(v) + u c2(v) + (1-v) c1(u) + v c3(u)
       - [ (1-u)(1-v) s1 + u(1-v) s2 + u v s3 + (1-u) v s4 ]

给出。宏 TRAN_QUA(meshGFaceTransfinite.cpp 第 37--40 行)正是它;三角形面用退化版本 TRAN_TRI(s1=s4=c4,第 44--45 行):

cpp 复制代码
#define TRAN_QUA(c1, c2, c3, c4, s1, s2, s3, s4, u, v)                         \
  (1. - u) * c4 + u * c2 + (1. - v) * c1 + v * c3 -                            \
    ((1. - u) * (1. - v) * s1 + u * (1. - v) * s2 + u * v * s3 + (1. - u) * v * s4)
#define TRAN_TRI(c1, c2, c3, s1, s2, s3, u, v)                                 \
  u * c2 + (1. - v) * c1 + v * c3 - (u * (1. - v) * s2 + u * v * s3)

5.12.2 MeshTransfiniteSurface 的步骤

MeshTransfiniteSurface(第 312 行起):

  1. 要求所有边界曲线有起止顶点(否则报 "Transfinite algorithm cannot be applied to surface with bounding curve %d without begin or end point");
  2. findTransfiniteCorners 找角点(用户指定或自动探测;3 边面还"try reaaally hard",第 61 行 注释),角点数必须是 3 或 4;
  3. computeEdgeLoops 把边界节点按环序拼成 d_vertices,环起止索引记入 indices; indices.size() != 2 表示有洞,报错;
  4. 把节点旋转到从 corners[0] 开始,必要时整体反转,使顺序与 corners[1] 一致;
  5. 求每个节点的参数 (U,V);若在缝上还求另一侧 (U2,V2),并选"最大参数间距更小"的那一套 (第 409 行,注释:"quick fix for surfaces with a single seam");
  6. 设 L = N2-N1(第一条边段数)、H = N3-N2,对面两边 Lb/Hb 必须相等(否则报 "non-matching number of nodes on opposite sides");
  7. 累加两对边"平均节点间距" lengths_i/lengths_j(symmetric = true,第 465 行注释:"use the average of the node spacing on the two opposing sides, so that we generate the same u, v coordinates whatever the ordering of the sides");
  8. 建立二维表 gf->transfinite_vertices(tab[i][j],尺寸 (L+1)×(H+1)),把边界节点放进表;
  9. 对内部 (i,j),u = lengths_i[i]/L_i、v = lengths_j[j]/L_j,用 TRAN_QUA 求 (Up,Vp), 再 gf->point(Up,Vp) 建 MFaceVertex 放进表;
  10. 可选椭圆/弹性平滑(见下);
  11. 最后用 _addQuadrangle/_addTriangle 把表里的网格铺成元素------退化四边形自动降为三角形 (_addQuadrangle 检查四顶点相等性,第 293--310 行)。

5.12.3 边界 N 约束与 meshGEdge 的耦合

Transfinite 面的 L/H 直接来自一维网格的节点数 ,故与第 4 章的 meshGEdge 强耦合: meshGEdgeTargetNumberOfPoints(ge)(src/mesh/meshGEdge.cpp 第 1067--1079 行)就是"这条边应有多少节点":

cpp 复制代码
int meshGEdgeTargetNumberOfPoints(GEdge *ge)
{
  Range<double> bounds = ge->parBounds(0);
  ...
  int N = 0;  std::vector<IntPoint> Points;  double a = 0.;
  int filterMinimumN = 1;
  meshGEdgeProcessing(ge, t_begin, t_end, N, Points, a, filterMinimumN);
  return N;
}

范围 3(三角形面)还有两个特殊开关:CTX::instance()->mesh.transfiniteTri(三角形面特化算法, 第 437--440 行)与 CTX::instance()->mesh.quasiTransfinite(rescaleIrregularTransfinite,把 两侧不等长的节点列表补成等长,第 420--421 行)。transfiniteSmoothing/nbSmoothing 控制面上是否 做椭圆平滑(symmetric 表上的加权 Laplace,第 721--759 行);3 边面用另一套"弹性力"(把邻近 6 个点的位移求和作为力,第 762--800 行)。

5.12.4 MeshSetTransfiniteFacesAutomatic

MeshSetTransfiniteFacesAutomatic(第 1201--1276 行)是"给一批面自动铺结构化网格"的入口 (QuadQS 前置调用它):

  1. 拓扑+几何过滤 :用 faceIsValidQuad(gf, cornerAngle) 只保留"四角都接近直角"的四边形面 (cornerAngle = 2.35 弧度 ≈ 134.6°------允许每个角离 90° 偏差较大,以容纳不规则四边形), 无嵌入边/点的面优先;

  2. build_chords (第 1152--1199 行):对每个尚未访问的 GEdge 做广度优先搜索 ,沿 "四边形面的对边"(quad_face_opposite_edge(gf, ge))串起一条"弦"(chord);当某条对边的 目标节点数与当前边的相对偏差 > maxDiffRel 时停止延伸:

    cpp 复制代码
         GEdge *ge2 = quad_face_opposite_edge(gf, ge);
         if(ge2 && done.find(ge2) == done.end()) {
           int n2 = meshGEdgeTargetNumberOfPoints(ge2);
           if(double(std::abs(n2 - n)) / double(std::max(n, n2)) > maxDiffRel) continue;
           Q.push(ge2); done[ge2] = true;
         }
  3. 分配节点数 :每条弦取所有边节点数的平均 N,N == 0 取 2,N 为奇数则 +1 ------

    cpp 复制代码
       int N = int(avgNbPoints + 0.5);
       if(N == 0) N = 2;
       if(N % 2 == 1) N = N + 1;

    然后给弦上每条边设 MESH_TRANSFINITE、nbPointsTransfinite = N、typeTransfinite = 1 (Progression);若 algo2d == ALGO_2D_QUAD_QUASI_STRUCT,则 typeTransfinite = 4(用尺寸 图)(第 1244--1246 行)。

  4. 对面设 transfinite :只有当某面四条边都是 MESH_TRANSFINITE 且 nPoints[0]==nPoints[2]、 nPoints[1]==nPoints[3],才把该面设为 MESH_TRANSFINITE(transfiniteArrangement = 1 = Right),并按需 recombine = 1、recombineAngle = 45。

faceIsValidQuad(第 1065--1150 行)用四个角的角度判定"这个面能不能当四边形块"------它是 "自动结构化"的质量闸门。

5.13 质量度量与面网格优化

5.13.1 gamma 与 eta

Gmsh 的质量函数在 qualityMeasures:qmTriangle::gamma(p1,p2,p3) 是2×内切圆半径/外接圆 半径 ,最常用的形状度量;computeElementShapes(5.6.4)给出它等价闭式 4|u×v|²/(abc(a+b+c))。etaShapeMeasure() 是单元类的"η 质量",定义为 1 - |60° - 最小角|/60(理想范围 [0,1]),RecombineTriangle 与四边形优化都用它。 gammaShapeMeasure() 在 MTri3 的 radiusNorm == 3 时被取倒数作半径。BDS 侧用 qmTriangle::gamma(BDS_Point*, ...) 重载(见 edgeSwapTest,meshGFaceBDS.cpp 第 180--183 行)。

5.13.2 laplaceSmoothing / _relocate

laplaceSmoothing(meshGFaceOptimize.cpp 第 985--1016 行)对一阶网格做 niter 趟顶点重定位, 跳过边界层顶点:

cpp 复制代码
void laplaceSmoothing(GFace *gf, int niter, bool infinity_norm)
{
  if((gf->triangles.size() > 0 && gf->triangles[0]->getPolynomialOrder() > 1) || ...) {
    Msg::Error("Surface mesh smoothing only valid for first order mesh ..."); return;
  }
  if(!niter) return;
  std::set<MVertex *> vs;  getAllBoundaryLayerVertices(gf, vs);
  VertexToElementCSR adj;  adj.add(gf->triangles);  adj.add(gf->quadrangles);  adj.finalize();
  for(int i = 0; i < niter; i++)
    for(std::size_t j = 0; j < adj.numVertices(); j++) {
      MVertex *v = adj.vertex(j);
      if(vs.find(v) == vs.end()) _relocate(gf, v, adj.elements(j), adj.numElements(j), pts, star);
    }
}

VertexToElementCSR(第 933--983 行)是"顶点→单元"的 CSR 邻接:顶点号连续时用计数排序 O(N), 稀疏时用 stable_sort------两种情形分别优化 。_relocate(第 829--931 行)的每一步都很有讲究: 跳过非本面、边界层节点(fv->bl_data)的顶点;度量加权求目标位置:

cpp 复制代码
    for(auto it = pts.begin(); it != pts.end(); ++it) {
      SVector2 d(adj.x()-before.x(), adj.y()-before.y());  d.normalize();
      buildMetric(gf, adj, metric);
      const double F = sqrt(metric[0]*d.x()*d.x() + 2*metric[1]*d.x()*d.y() + metric[2]*d.y()*d.y());
      after += adj * F;  COUNT += F;
    }
    after *= (1.0 / COUNT);

这就是"各向异性 Laplace":每个邻点的权重是它在度量下的方向长度 。之后用 star.evaluate(...) 求"星形"的参数面积与最小质量 ,只有"面积不增、最小质量不降"才接受 移动(_isItAGoodIdeaToMoveThatVertex,第 658--686 行),并用 FACTOR 从 1 逐步除以 1.4 做 5 次 尝试(阻尼式逼近)。

vertexStar(第 532--651 行)缓存"待移动顶点周围所有角的参数与三维坐标",并用 _minCornerSin2Cos(第 471--493 行)在不做 atan2/sqrt 的前提下比较三角形质量------注释 (第 462--470 行)解释:正弦平方 |a×b|² 三角共享,而 cos 越大角越小,故最小角就是 c 最大 者;elementQuality::operator< 用"缩放后的 cos²"比较,eta() 只在必要时算。这是延迟求值 + 代数化简的性能手法。

5.13.3 Optimize2D / Delaunayize / UntangleTris

GenerateMesh 在二维阶段末尾还会调用一系列全局优化(与第 7 章合并讨论): OptimizeMesh(m, "Delaunayize")(对平面面做 PolyMeshDelaunayize)、 OptimizeMesh(m, "UntangleTris")(解缠三角形网格,消除负面积/翻卷单元)、 OptimizeMesh(m, "Laplace2D")/Relocate2D(Laplace 光滑与顶点重定位)、 OptimizeMesh(m, "QuadQuasiStructured")(QuadQS 的后处理链)。对 QuadQS/PACK,UntangleTris 是 bowyerWatsonParallelograms 之后的重要一步------打包得到的三角形铺砌可能缠结,必须先解缠再 重组合。

5.14 与第 4 章、第 6 章的接口

与第 4 章(一维离散与尺寸场):

  • N 的奇偶约束 :当 algoRecombine ∈ {2, 4}(full-quad / bipartite)时,一维段数需为偶数 (quadMeshRemoveHalfOfOneDMesh 的 lines.size() 必须整除 2;BipartiteLabelling 要求边界环 节点数为偶数)。第 4 章 4.4 节的约束正来源于此。
  • 尺寸场 :二维通过 BGM_MeshSize(ge, u, v, x, y, z) 查询第 4 章的 Field;_lcBGM 是它的 一次快照。computeSizeField / correctLC_ 是二维侧的消费点。
  • Transfinite 的 N :meshGEdgeTargetNumberOfPoints(ge) 直接决定 Transfinite 面的 L/H (5.12.3),也决定 MeshSetTransfiniteFacesAutomatic 的弦节点数(5.12.4)。

与第 6 章(三维网格的边界):

  • 三维网格器把面网格当作边界约束输入:第 6 章的 Delaunay/HXT 需要"边界面上已有三角形/ 四边形",因此第 5 章必须保证:边界边已恢复(5.5)、几何分类已保持(5.4.11)、面网格与一维 网格在公共边上一致(5.2.4)。
  • directions_storage(gf)(当 algo3d == ALGO_3D_RTREE 时调用,meshGFace.cpp 第 2450 行)为 RTREE 体网格保存面方向信息。
  • 面网格的 meshStatistics.status(PENDING/DONE/FAILED)是三维网格器的同步信号:三维在 GenerateMesh 里先确保所有面 DONE(必要时重试),再开始体网格。

5.15 常见问题 Q&A

Q1:为什么曲面网格非要在参数平面里做? 因为 Delaunay 的空圆判据只在平面上有定义。曲面上 没有"圆",只有度量;Gmsh 选择"拉平到 (u,v) + 度量补偿"这条路(5.2、5.3)。另一范式是"直接 三维做",但那需要三维 Delaunay 曲面重建,复杂度和鲁棒性都差得多。

Q2:非流形边(一条边接三个及以上面)怎么处理? BDS 用 BDS_Edge::_f[2] 内联存两个面,超过 两个才分配 _moreFaces(5.4.3),但大量操作直接拒绝非流形:swap_edge 要求 numfaces() == 2;recover_edge 里 if(e->numfaces() != e->numTriangles()) return nullptr;; recur_tag 只在 numfaces() == 2 时传播。结论:二维自由网格算法基本假设流形,非流形只被 "兜底存储"而不被主动处理。

Q3:边界恢复失败怎么办? 三层次:(1) 若源于一维网格自交,recover_edge 记入 not_recovered,remeshUnrecoveredEdges 加密一维网格原地重来(受 maxRetries 限制,5.5.4); (2) 若某面反复失败,可设 refineAllEdges 全体分裂,但最多 3 次;(3) 最终失败则面状态置 FAILED,verbosity 99 可拿到 wrongmesh.pos/wrongparam.pos 双视图定位(5.5.5)。实用建议: 先查一维网格是否自交(通常源于几何重叠或尺寸突变),再查是否有"锥顶"这类无解几何。

Q4:各向异性度量怎么构造? 由参数化雅可比 J = firstDer(u,v) 得 M = JᵀJ(buildMetric, 存成 [Ju·Ju, Jv·Ju, Jv·Jv]);度量外接圆由线性系统解出(circumCenterMetric),空圆判据是 "第四点到圆心的度量平方距离 < Radius2 - tol"(inCircumCircleAniso)。想换度量(边界层、用户 方向场),改 firstDer 或直接喂度量场。

Q5:BDS 和 MTri3/bidimMeshData 为什么是两套? BDS 是"带几何分类的完整拓扑网格",适合边界 恢复与需分类保持的局部操作;MTri3 + bidimMeshData 是"为增量插点优化的紧凑结构"。口诀: BDS 管"对不对"(分类/恢复),MTri3 管"快不快"(造点/搜索) ;两者用 BDS2GMSH 与 buildMeshGenerationDataStructures 桥接。

Q6:要新增一个二维算法,接在哪里? 两条缝:algoDelaunay2D(是否走骨架)与 runDelaunayAlgorithm(造点方式);若需背景网格/引导场则挂到 setupBackgroundMesh。再在 GmshDefines.h 加新 ALGO_2D_* 值,并在 meshGFace::operator() 的 switch 里补日志名。

5.16 术语表

| 术语 | 含义 | 出处 |
|------------------------------|---------------------------------------------------|--------------|------|--------|
| BDS | Bidirectional Data Structure(Shephard-Beall 双向结构) | BDS.h |
| classif_degree / classif_tag | 几何分类维度与实体号(0 点、1 边、2 面) | BDS.h 32--48 |
| _lcBGM / _lcPTS | 背景网格尺寸 / 传播的特征长度尺寸 | BDS.h 50--67 |
| periodicCounterpart | 周期/缝面上同一物理点的另一参数副本 | BDS.h 61 |
| degenerated | 退化标记:1 表示自由坐标 u,2 表示 v | 5.7.5 |
| 度量张量 M | 由 firstDer 得到的 JᵀJ,把参数距离折算成物理距离 | 5.3.1 |
| inCircumCircleAniso | 度量空间"空椭球"判据 | 5.3.3 |
| 两遍恢复 | 先 EdgeToRecover 登记,再 recover_edge 执行 | 5.5.2 |
| Frontal-Delaunay / LIMIT
| Rebay 前沿推进;归一化外接半径阈值 √2/2 | 5.8 |
| cross field / 引导场 | 面内方向场(N=4 或 6 对称)+ 尺度图 | 5.11.2 |
| PACK_PRLGRMS | 打包平行四边形算法,需 scaled cross field | 5.9 |
| Recombine / Blossom | 三角形重组合成四边形;最大权完美匹配 | 5.10 |
| Bipartite Labelling | 二部图标注的四边形化 | 5.10.4 |
| QuadQS | 准结构化全四边形,背景网格 + 引导场驱动 | 5.11 |
| Transfinite / Coons | 超限插值结构化面 | 5.12 |
| gamma / eta | 形状质量度量(2r/R;1- | 60°-最小角 | /60) | 5.13.1 |
| MeshExtent | Full / InitialOnly / BoundaryLayerRemesh 三种深度 | 5.7.2 |

5.17 本章小结

第 5 章要点浓缩成七条:

  1. 除 Transfinite 与 Extrude 外,一切共享 Delaunay/BDS 骨架。 三层分派: algoDelaunay2D(是否走骨架)→ meshGenerator/meshGeneratorPeriodic(非周期/周期)→ runDelaunayAlgorithm(造点方式)。5.1.6 的算法矩阵是本章地图。
  2. 参数平面是主战场。 边界点先重参数化到 (u,v)(reparamOnFace 处理缝与朝向),在参数 域三角化,再用 GFace::point(u,v) 回三维;MFaceVertex 携带 (u,v) 避免反解参数。
  3. 各向异性度量补偿畸变。 buildMetric 用雅可比构造 M = JᵀJ,circumCenterMetric 解度量 外接圆,inCircumCircleAniso 给空椭球判据;鲁棒性由 robustPredicates 保证。
  4. BDS 的设计围绕"分类保持"与"常见情况性能"。 点/边/面互指;_f[2] 内联缓存 + _moreFaces 溢出;_n[4] 节点缓存;每个局部操作都审查 classif_degree。注释里 // not a bug !!!、// no bound check at all 之类的坦白,是理解工程取舍的钥匙。
  5. 边界恢复是两遍、可重试、可诊断的闭环。 登记 → 逐段翻转 → 自交则加密一维网格递归重来 → 失败给双视图调试;recoverMapInv / _periodicCounterpart 支撑缝边界。
  6. 四边形是一等公民,有四条路线 :A recombineIntoQuads(贪心或 Blossom + 拓扑/几何优化 + quadsToTriangles 兜底);B algoRecombine=2/3 的"删一半一维网格 + 半网格合成 + 细分"; C BipartiteLabelling;以及 QuadQS 的"背景网格 + 引导场 + 打包 + 腔体重网格"。它们与第 4 章的 一维 N 奇偶约束、尺寸场深度耦合。
  7. Transfinite 是独立范式。 Coons 超限插值 + meshGEdgeTargetNumberOfPoints 的 N 约束 + MeshSetTransfiniteFacesAutomatic(build_chords BFS + 奇偶修正 + QuadQS 用尺寸图)。

第 6 章转向三维:Delaunay、HXT、MMG、Netgen 与并行 Delaunay------它们都以本章产出的二维曲面网格 作为边界约束输入。

关键词与索引

  • 数据结构:BDS_Point/BDS_Edge/BDS_Face/BDS_Mesh(BDS.h、BDS.cpp);bidimMeshData、 MTri3(meshGFaceDelaunay.cpp、meshGFaceTri3.h);PolyMesh(meshTriangulation.h)。
  • 算法入口:bowyerWatson/bowyerWatsonFrontal/bowyerWatsonFrontalLayers/ bowyerWatsonParallelograms(meshGFaceDelaunay.cpp);meshGenerator/meshGeneratorPeriodic (meshGFace.cpp);refineMeshBDS(meshGFaceBDS.cpp);MeshTransfiniteSurface (meshGFaceTransfinite.cpp);recombineIntoQuads(meshGFaceOptimize.cpp)。
  • 关键谓词/度量:buildMetric、inCircumCircleAniso、circumCenterMetric、 robustPredicates::orient2d/incircle、qmTriangle::gamma、etaShapeMeasure。
  • 关键选项:Mesh.Algorithm(algo2d)、Mesh.RecombinationAlgorithm(algoRecombine)、 Mesh.RecombineAll、Mesh.RecombineOptimizeTopology、Mesh.RecombineNodeRepositioning、 Mesh.RecombineMinimumQuality、Mesh.FlatRefine2D、Mesh.QuasiTransfinite、 Mesh.TransfiniteTri、Mesh.MaxRetries、Mesh.AlgorithmSwitchOnFailure。 v
相关推荐
鶴哥只手遮天1 小时前
Gmsh 源码深度解析-第 3 章 脚本语言与解析器
算法·开源
鶴哥只手遮天1 小时前
OpenFOAM 源码深度剖析 · 第二季-16 工程与工具链:Windows 交叉编译、性能剖析、数据交换与测试
算法·开源
鶴哥只手遮天1 小时前
Gmsh 源码深度解析-第 1 章 架构总览与数据模型
算法·开源
鶴哥只手遮天1 小时前
OpenFOAM 源码深度剖析 · 第二季-12 `coded` 系列:在字典里写 C++ 并在运行期编译
算法·开源
鶴哥只手遮天1 小时前
OpenFOAM 源码深度剖析 · 第二季-01 多相流与 VOF:`interFoam`、界面压缩与 MULES
算法·开源
鶴哥只手遮天1 小时前
OpenFOAM 源码深度剖析 · 第二季-14 `functionObjects`、求解器控制框架 与 表达式系统
算法·开源
鶴哥只手遮天1 小时前
OpenFOAM 源码深度剖析 · 第二季-13 `fvOptions` / `fvConstraints` / `corrections`
算法·开源
鶴哥只手遮天1 小时前
OpenFOAM 源码深度剖析 · 第二季-03 拉格朗日粒子与喷雾:`Cloud`/`parcel` 模板体系、运动积分与子模型群
算法·开源
hetao17338371 小时前
校内场-提高组 ZYZSC-S-Round 2
c++·算法