Gmsh 源码深度解析-第 4 章 网格生成总控与尺寸场

Gmsh 源码深度解析-第 4 章 网格生成总控与尺寸场

本章目标:讲清 Gmsh 网格生成的主流水线------GenerateMesh 如何分阶段调度 0D/1D/2D/3D 网格、 OpenMP 并行与多轮重试机制如何工作、一维网格如何决定"一条边上放多少个点"、 以及决定"哪里该粗、哪里该细"的尺寸场(Field)系统与背景网格(BGM)。

第 1 章建立了 GModel/GEntity 的数据模型,第 2 章讲清了几何实体的契约, 第 3 章讲了脚本如何驱动几何。本章回答最后一个上游问题:这些几何实体究竟如何被"铺满"网格 。 本章是第 5、6、7 章的"总控与调度"层:具体的二维/三维算法在第 5/6 章,质量优化与高阶在第 7 章, 而什么时候调用它们、以什么次序调用、并行还是串行、失败了怎么办 ,全部由本章的 src/mesh/Generator.cpp 决定。

4.1 总控入口 GenerateMesh

一切从 src/mesh/Generator.cpp 的 GenerateMesh 开始(第 1637 行)。 这个函数是整个网格子系统的"外壳":它把可重入保护、可复现性、四阶段调度、收尾优化与统计 全部串起来。完整函数约 187 行,我们分段逐行拆解。

4.1.1 可重入保护与可复现性种子

函数开头(第 1637--1657 行):

cpp 复制代码
void GenerateMesh(GModel *m, int ask)
{
  // ProfilerStart("gmsh.prof");
  if(CTX::instance()->lock) {
    Msg::Info("I'm busy! Ask me that later...");
    return;
  }
  CTX::instance()->lock = 1;

  Msg::ResetErrorCounter();

  m->clearLastMeshEntityError();
  m->clearLastMeshVertexError();

  // Initialize pseudo random mesh generator with the same seed
  srand(CTX::instance()->mesh.randomSeed);

  // Change any high order elements back into first order ones (but skip
  // discrete entities)
  SetOrder1(m, false, true);
  FixPeriodicMesh(m);

几个关键点:

  1. CTX::instance()->lock 是一个全局串行锁:网格生成不可重入。若上一次还没结束就再次 调用(例如 GUI 里用户狂点按钮、或脚本里递归触发),直接返回"I'm busy! Ask me that later..."。 注意这个锁不只在开头读、结尾写,还在 1D/2D 抛异常时被主动清零(第 605、793 行), 否则一次异常会让整个进程永远"忙"。
  2. Msg::ResetErrorCounter() :把全局错误计数归零。后续所有阶段都用 CTX::instance()->abortOnError && Msg::GetErrorCount() 来判断"是否要提前中止"。
  3. m->clearLastMeshEntityError()/clearLastMeshVertexError():清空"上次失败实体/节点"的记录, 这两个记录被 GUI 用来高亮出问题的实体(第 1 章 1.5.3 节)。
  4. srand(randomSeed) :网格算法(Delaunay 点插入、randFactor 扰动)依赖随机数, 固定种子保证同一输入产生同一网格 。这是回归测试与工程可复现性的基石。 randomSeed 对应选项 Mesh.RandomSeed(src/common/DefaultOptions.h 第 1672 行,默认 1.0)。
  5. SetOrder1(m, false, true) :如果模型里已有高阶网格,先降回一阶再重新生成,避免高阶与低阶 混用。第三个参数 skipDiscrete=true 表示跳过离散实体 (它们的网格是读进来的,不能被降阶)。 实现见 src/mesh/HighOrder.cpp 第 1553 行。
  6. FixPeriodicMesh(m):把周期网格的节点对应关系重新整理一遍(详见 4.8 节)。函数头尾各调一次: 开头一次是为了消除上次遗留的不一致,结尾一次是为了把新生成的网格真正变成周期的。
  7. ask 参数是"要求生成到第几维":ask=1 只做一维(边),ask=2 到二维(面),ask=3 到三维(体)。 用户可用 Mesh 1/Mesh 2/Mesh 3 分别触发(对应 API gmsh::model::mesh::generate(ask))。

4.1.2 PACK/QUAD_QUASI_STRUCT 的背景网格前置

紧接着这段(第 1659--1717 行)是给两种高级二维算法准备的"前置":打包平行四边形(PACK_PRLGRMS) 与准结构四边形(QUAD_QUASI_STRUCT)需要一个全局背景网格 + 引导场(guiding field):

cpp 复制代码
  QuadqsContextUpdater *qqs = nullptr;
  if(CTX::instance()->mesh.algo2d == ALGO_2D_PACK_PRLGRMS ||
     CTX::instance()->mesh.algo2d == ALGO_2D_QUAD_QUASI_STRUCT) {
    int old = m->getMeshStatus(false);
    bool doIt = (ask >= 1 && ask <= 3);
    bool exists = backgroundMeshAndGuidingFieldExists(m);
    bool overwriteGModelMesh = false, overwriteField = false;
    if(old == 1 && ask == 1 && exists) doIt = true;
    if(old == 1 && ask == 2 && exists) doIt = false;
    if(old == 2 && exists && (ask == 1 || ask == 2)) {   // user wants a new mesh
      doIt = true; overwriteField = true; overwriteGModelMesh = true;
    }
    if(old == 2 && ask == 1 && exists) doIt = true;
    if(old == 2 && ask == 2 && exists) doIt = true;
    if(doIt) BuildBackgroundMeshAndGuidingField(m, overwriteGModelMesh,
                                                /*deleteGModelMeshAfter=*/true, overwriteField);
    if(... QUAD_QUASI_STRUCT && old == 2 && exists && (ask == 1 || ask == 2))
      m->deleteMesh();   // quadqs removed the seam GEdge 1D meshes: rebuild from scratch
    if(... QUAD_QUASI_STRUCT) {
      qqs = new QuadqsContextUpdater();   // saves/restores many mesh options
      std::set<GFace *> faces;
      for(GFace *gf : m->getFaces()) if(gf->edges().size() == 4) faces.insert(gf);
      MeshSetTransfiniteFacesAutomatic(faces, 2.35, true, 0.34);  // 4-sided -> transfinite
    }
  }

这一段代码的意图可以用一句话概括:四边形网格算法(PACK/QUAD_QUASI_STRUCT)不能"从零开始", 它必须先有一张粗糙的三角网格作为背景,再从这张背景网格上提取交叉场(cross field)与尺寸图(size map)。

doIt 那一串 old==1/old==2/ask==1/ask==2 的组合判断,是在决定"已有网格的情况下,要不要 重建背景网格、要不要覆盖已有网格/场"。规则是:

  • 如果已有 1D 网格且背景网格存在,ask==1 时建、ask==2 时不建(因为二维会复用一维);
  • 如果已有 2D 网格(old==2)且用户要重做,则强制重建并把 field/GModel 网格都覆盖------ 因为选项可能已经变了,旧背景网格不再可信。

exists 来自 src/mesh/meshQuadQuasiStructured.cpp 第 880 行的 backgroundMeshAndGuidingFieldExists。BuildBackgroundMeshAndGuidingField (第 453 行)负责生成背景网格与引导场(详见 4.11 节)。

QuadqsContextUpdater(src/mesh/meshQuadQuasiStructured.cpp 第 2234 行)是一个 RAII 类:构造时备份并改写一大堆全局网格参数 ,析构时自动恢复。它备份的 参数有 algo2d、recombineAll、algoRecombine、recombineOptimizeTopology、lcFactor、lcMin、 lcMax、lcFromPoints、minCurveNodes、minCircleNodes,外加每条边/每个面的网格属性 (meshAttributes.method、coeffTransfinite、nbPointsTransfinite、recombine 等)。构造末尾调用 setQuadqsOptions()(第 2314 行):

cpp 复制代码
void QuadqsContextUpdater::setQuadqsOptions()
{
  Msg::Debug("set special quadqs options in the global context");

  // CTX::instance()->mesh.algo2d = ALGO_2D_QUAD_QUASI_STRUCT;
  CTX::instance()->mesh.recombineAll = 1;
  // CTX::instance()->mesh.algoRecombine = 4; /* bipartite labelling */
  if(CTX::instance()->mesh.algoRecombine != 4) {
    CTX::instance()->mesh.algoRecombine = 0; /* midpoint subdivision */
  }
  CTX::instance()->mesh.recombineOptimizeTopology = 0;
  CTX::instance()->mesh.lcFactor = 1.;
  CTX::instance()->mesh.lcMin = 0.;
  CTX::instance()->mesh.lcMax = 1.e22;
  CTX::instance()->mesh.lcFromPoints = 0;
  CTX::instance()->abortOnError = 0;
  CTX::instance()->mesh.minCurveNodes = 2;
  CTX::instance()->mesh.minCircleNodes = 0;
}

这就是"准结构四边形网格要求全局一致的环境"的体现:它把尺寸上下限、尺寸因子、 曲线最少节点数全部归零/归位,保证背景网格与引导场不被用户设置干扰。

最后 MeshSetTransfiniteFacesAutomatic(faces, 2.35, true, maxDiffRel)(定义在 src/mesh/meshGFaceTransfinite.cpp 第 1201 行) 把"四条边的面"自动当成 transfinite 面来处理------这正是准结构四边形想要的效果: 四边形面天然适合用 transfinite 插值铺网格 。maxDiffRel=0.34 限制"偏离尺寸图不超过 34%"。

4.1.3 四阶段调度与"降维删除"

GenerateMesh 的主体是四个阶段的串行推进(第 1719--1747 行):

cpp 复制代码
  // dimension of previous/existing mesh
  int old = m->getMeshStatus(false);

  // 1D mesh
  if(ask == 1 || (ask > 1 && old < 1)) {
    std::for_each(m->firstRegion(), m->lastRegion(), deMeshGRegion());
    std::for_each(m->firstFace(), m->lastFace(), deMeshGFace());
    Mesh0D(m);
    Mesh1D(m);
    quantize1DIfNeeded(m);
  }

  // 2D mesh
  if(ask == 2 || (ask > 2 && old < 2)) {
    std::for_each(m->firstRegion(), m->lastRegion(), deMeshGRegion());
    Mesh2D(m);
    // if two passes --> juste fait le ...
    //    createSizeFieldFromExistingMesh (m, false);
    // Mesh2D(m);
  }

  if(m->getMeshStatus() >= 2 && CTX::instance()->mesh.optimizePyramids < 0.) {
    // make sure surface mesh is correctly oriented before meshing in 3D if we
    // push points along the normals to create pyramids
    std::for_each(m->firstFace(), m->lastFace(), orientMeshGFace());
  }

  // 3D mesh
  if(ask == 3) { Mesh3D(m); }

这里的**"降维删除"策略**值得注意:要做 2D 就先删掉所有体的网格(因为面网格变了,体网格就 失效了);要做 1D 就先删掉所有面和体的网格。这保证网格层次的一致性(1D ⊂ 2D ⊂ 3D), 是 Gmsh "网格是几何实体的附属物"这一数据模型的必然结果(第 1 章)。

m->getMeshStatus(false) 返回"之前已经生成到第几维":它扫描所有实体的网格,取其中最高的维度。 old < 1 表示还没有一维网格,old < 2 表示还没有二维网格。于是:

  • ask=2 且 old=1:进入 2D 分支,Mesh2D;
  • ask=3 且 old=2:跳过 1D/2D 分支(因为都齐了),直接进 3D;
  • ask=3 且 old=0:先 Mesh0D+Mesh1D(old<1 成立),再 Mesh2D(old<2 成立,注意此时 old 是分支前算的旧值),再 Mesh3D。

optimizePyramids < 0 时的 orientMeshGFace 是一个容易被忽略但很关键 的分支:如果三维阶段 要把面上的点沿法向推出金字塔(Mesh.OptimizePyramids 为负),那么面网格的方向必须提前调正, 否则法向朝里、金字塔会长到体外面去。这体现了一个反复出现的原则:网格的"方向"不是装饰, 它影响后续算法的几何结果。

4.1.4 收尾:定向、优化、细分与高阶

GenerateMesh 的后半段(第 1749--1797 行)是"网格后处理":

cpp 复制代码
  if(m->getMeshStatus() >= 1)
    std::for_each(m->firstEdge(), m->lastEdge(), orientMeshGEdge());
  if(m->getMeshStatus() >= 2)
    std::for_each(m->firstFace(), m->lastFace(), orientMeshGFace());

  // Optimize quality of 3D tet mesh (HXT / InitialOnly skipped; pdel3d
  // optimizes its meshes itself)
  if(m->getMeshStatus() == 3 && CTX::instance()->mesh.algo3d != ALGO_3D_INITIAL_ONLY &&
     CTX::instance()->mesh.algo3d != ALGO_3D_HXT) {
    for(int i = 0; i < std::max(CTX::instance()->mesh.optimize,
                                CTX::instance()->mesh.optimizeNetgen); i++) {
      if(CTX::instance()->mesh.optimize > i &&
         CTX::instance()->mesh.algo3d != ALGO_3D_PDEL3D) OptimizeMesh(m);
      if(CTX::instance()->mesh.optimizeNetgen > i) OptimizeMesh(m, "Netgen");
    }
  }

  // Subdivide into quads or hexas, then build high-order elements
  if(m->getMeshStatus() == 2 && CTX::instance()->mesh.algoSubdivide == 1)
    RefineMesh(m, CTX::instance()->mesh.secondOrderLinear, true);
  else if(m->getMeshStatus() == 3 && CTX::instance()->mesh.algoSubdivide == 2)
    RefineMesh(m, CTX::instance()->mesh.secondOrderLinear, false, true);
  else if(m->getMeshStatus() >= 2 && CTX::instance()->mesh.algoSubdivide == 3)
    BarycentricRefineMesh(m);

  if(m->getMeshStatus() && CTX::instance()->mesh.order > 1) {
    SetOrderN(m, CTX::instance()->mesh.order, CTX::instance()->mesh.secondOrderLinear,
              CTX::instance()->mesh.secondOrderIncomplete, CTX::instance()->mesh.meshOnlyVisible);
    if(CTX::instance()->mesh.hoOptimize == 2 || CTX::instance()->mesh.hoOptimize == 3)
      OptimizeMesh(m, "HighOrderElastic");
    if(CTX::instance()->mesh.hoOptimize == 1 || CTX::instance()->mesh.hoOptimize == 2)
      OptimizeMesh(m, "HighOrder");
    if(CTX::instance()->mesh.hoOptimize == 4)
      OptimizeMesh(m, "HighOrderFastCurving");
  }

三点值得展开:

  1. 定向(orient) :orientMeshGEdge/orientMeshGFace 让线/面网格的方向与几何实体、 用户方向约束一致。这解决的是"同一条边被两个面共享时,两侧的网格走向必须相反"这类一致性问题。 orientMeshGEdge 很短(src/mesh/meshGEdge.cpp 第 1059 行):

    cpp 复制代码
    void orientMeshGEdge::operator()(GEdge *ge)
    {
      // apply user-specified mesh orientation constraints
      if(ge->meshAttributes.reverseMesh)
        for(std::size_t k = 0; k < ge->getNumMeshElements(); k++)
          ge->getMeshElement(k)->reverse();
    }
  2. 三维优化循环 :for(int i = 0; i < max(optimize, optimizeNetgen); i++) 交替调用 OptimizeMesh(m)(Gmsh 自己的)与 OptimizeMesh(m, "Netgen")(Netgen 的),迭代次数取两者较大值 。 但 ALGO_3D_HXT 与 ALGO_3D_INITIAL_ONLY 整体跳过------因为 HXT 库自带优化、 InitialOnly 只做初始网格;ALGO_3D_PDEL3D 跳过 Gmsh 优化但保留 Netgen------因为 并行 Delaunay 自己优化。这体现了一个反复出现的工程原则:算法与优化是同一子系统的一体两面, 谁的算法谁负责优化。

  3. 细分(Subdivide) :algoSubdivide==1 把二维三角形细分(一分为四,或合并成四边形); ==2 在三维把四面体/棱柱细分;==3 做重心细分 BarycentricRefineMesh。这三种都是 **"先生成低阶网格,再整体细分升阶"**的路线,而不是直接生成高阶网格。细分实现见 src/mesh/meshRefine.cpp。

  4. 高阶 :SetOrderN 把一阶网格升到 mesh.order 阶(例如 2 阶、3 阶...),随后按 hoOptimize 的值选择高阶优化方法(HighOrderElastic/HighOrder/HighOrderFastCurving)。 注意这些方法依赖 HAVE_OPTHOM 模块,未编译时 OptimizeMesh 里会报 "High-order mesh optimization requires the OPTHOM module"。

4.1.5 质量统计与错误计数

收尾段(第 1799--1823 行):

cpp 复制代码
  // make sure periodic meshes are actually periodic and store node correspondences
  FixPeriodicMesh(m);
  Msg::Info("%d nodes %d elements", m->getNumMeshVertices(), m->getNumMeshElements());
  GetQualityFast(m, m->getMeshStatus(), CTX::instance()->mesh.minQuality,
                 CTX::instance()->mesh.avgQuality);
  Msg::PrintErrorCounter("Mesh generation error summary");
  if(qqs != nullptr) delete qqs;
  if(backgroundMeshAndGuidingFieldExists(m)) removeAllMeshEntitiesThatHaveSmallEdges();
  CTX::instance()->lock = 0;
}

四个动作:

  1. FixPeriodicMesh(m):新网格生成后,再次重建周期节点对应(4.8 节)。
  2. GetQualityFast :把网格质量的 min/avg 填进 CTX(minQuality/avgQuality),供 GUI 与脚本读取。 高效版本见 4.3.5 节。
  3. Msg::PrintErrorCounter:打印整个阶段的错误汇总。
  4. 释放 qqs(QuadqsContextUpdater) :触发其析构,恢复所有被改写的全局参数。若在构造函数 之后、析构之前抛异常,参数会残留------这是 RAII 想避免的,但这里用的是裸 new(不是 std::unique_ptr), 只靠正常路径的 delete(这也是源码里 // FIXME TEST 一带风格偏"实验性"的原因)。

removeAllMeshEntitiesThatHaveSmallEdges() 只在背景网格存在时调用(即 PACK/QUAD_QUASI_STRUCT 路径), 清理掉长度过短的网格边(背景网格 + 引导场算法会临时产生一些退化边,收尾时删除)。

CTX::instance()->lock = 0 是最后一步------无论成功失败都要走到这里(除了 1D/2D 抛异常的路径, 它们已在抛出前手动清零)。

4.2 Mesh0D:点网格与尺寸场初始化

Mesh0D(第 518 行)最简单,但它做了一件重要的事------初始化尺寸场并给每个几何点建网格:

cpp 复制代码
static void Mesh0D(GModel *m)
{
  m->getFields()->initialize();          // 让所有尺寸场进入就绪状态
  for(auto it = m->firstVertex(); it != m->lastVertex(); ++it) {
    GVertex *gv = *it;
    if(gv->mesh_vertices.empty())
      gv->mesh_vertices.push_back(new MVertex(gv->x(), gv->y(), gv->z(), gv));
    if(gv->points.empty()) gv->points.push_back(new MPoint(gv->mesh_vertices.back()));
  }
  // 周期点:从实体的网格点映射到主实体的第一个网格点
  for(auto it = m->firstVertex(); it != m->lastVertex(); ++it) { ... correspondingVertices ... }
}

三层含义:

  1. m->getFields()->initialize() :让所有尺寸场进入"就绪"状态(逐个调用 update())。 之所以在 0D 阶段就做,是因为接下来的一维网格会频繁查询尺寸场 ,而尺寸树的构建(如 DistanceField 建 KD 树、OctreeField 建八叉树)代价高,必须一次性做完,不能在并行阶段 懒加载(否则会有竞态)。这个设计在 4.9 节展开。
  2. MVertex + MPoint :每个几何点 GVertex 生成一个网格节点,并包成 MPoint(0 维网格单元)。 注意这里有 if(...empty()) 保护:如果点已经被网格化(例如读入的网格),就不重复创建。
  3. 周期点对应 :若 gv 是"从点"(getMeshMaster() != gv),把它的网格点映射到主点的网格点 (correspondingVertices)。这是周期网格最小但必要的一环------顶点层面的周期对应 。 边/面层面的对应在 copyMesh/FixPeriodicMesh 里(4.4.10、4.8 节)。

4.3 Mesh1D:边网格与 OpenMP 并行

Mesh1D(第 542 行)是 Gmsh 并行网格化策略的模板。它与 Mesh2D、Mesh3D 共享同一套结构: 线程数决策 → 状态机 → OpenMP 循环 + 异常隔离 → 多轮重试 → 规范重编号 → 空网格检查。

4.3.1 线程数决策

cpp 复制代码
static void Mesh1D(GModel *m)
{
  if(CTX::instance()->abortOnError && Msg::GetErrorCount()) return;
  m->getFields()->initialize();
  std::size_t maxVertexBefore, maxElementBefore;
  m->getMaxMeshNumbersInUse(maxVertexBefore, maxElementBefore);
  ...
  int nthreads = CTX::instance()->numThreads;
  if(CTX::instance()->mesh.maxNumThreads1D > 0)
    nthreads = CTX::instance()->mesh.maxNumThreads1D;
  if(!nthreads) nthreads = Msg::GetMaxThreads();

  // boundary layers are not yet thread-safe
  if(m->getFields()->getNumBoundaryLayerFields()) nthreads = 1;

  for(auto it = m->firstEdge(); it != m->lastEdge(); ++it)
    // Extruded meshes are not yet fully thread-safe (not sure why!)
    if((*it)->meshAttributes.extrude && (*it)->meshAttributes.extrude->mesh.ExtrudeMesh)
      nthreads = 1;

线程数决策是有层次的:

  1. 先取 CTX::instance()->numThreads(对应选项 General.NumThreads,已随选项系统初始化);
  2. 若 Mesh.MaxNumThreads1D > 0(默认 0,见 src/common/DefaultOptions.h 第 1424 行),则用它覆盖------这是给一维网格单独限线程数的入口;
  3. 若还是 0,用 Msg::GetMaxThreads() 取硬件上限;
  4. 一旦有边界层场(getNumBoundaryLayerFields() > 0),强制单线程------注释写得很直白: "boundary layers are not yet thread-safe";
  5. 一旦任何边是拉伸网格(meshAttributes.extrude->mesh.ExtrudeMesh),强制单线程------ 注释同样诚实:"Extruded meshes are not yet fully thread-safe (not sure why!)"。

这背后是一条重要的工程判断:并行是"默认开启但可被正确性否决"的优化。任何尚未证明线程安全的 路径一律退化到单线程,宁可慢,不可错。

4.3.2 状态机与 OpenMP 异常隔离

cpp 复制代码
  std::vector<GEdge *> temp;
  for(auto it = m->firstEdge(); it != m->lastEdge(); ++it) {
    (*it)->meshStatistics.status = GEdge::PENDING;
    temp.push_back(*it);
  }

  int nIter = 0, nTot = m->getNumEdges();
  Msg::StartProgressMeter(nTot);

  while(1) {
    if(CTX::instance()->abortOnError && Msg::GetErrorCount()) {
      Msg::Warning("Aborted 1D meshing");
      break;
    }

    int nPending = 0;
    bool exceptions = false;
#pragma omp parallel for schedule(dynamic) num_threads(nthreads)
    for(size_t K = 0; K < temp.size(); K++) {
      if(exceptions) continue;
      int localPending = 0;
      GEdge *ed = temp[K];
      if(ed->meshStatistics.status == GEdge::PENDING) {
        try { // OpenMP forbids leaving block via exception
          ed->mesh(true);
        } catch(...) {
          exceptions = true;
        }
#pragma omp atomic capture
        {
          ++nPending;
          localPending = nPending;
        }
      }
      if(!nIter) Msg::ProgressMeter(localPending, false, "Meshing 1D...");
    }
    if(exceptions) {
      CTX::instance()->lock = 0;
      throw std::runtime_error(Msg::GetLastError());
    }
    if(!nPending) break;
    if(nIter++ > CTX::instance()->mesh.maxRetries) break;
  }

这段代码包含四个可复用的工程模式:

  1. 状态机驱动 :每个实体带 GEdge::PENDING/DONE/FAILED 状态(meshGEdge::operator() 里 在成功时置 DONE,第 1056 行)。只对 PENDING 的调用 mesh(true)。一轮下来没有 PENDING 了就算收敛(if(!nPending) break;)。
  2. 重试机制 :if(nIter++ > CTX::instance()->mesh.maxRetries) break;,maxRetries 默认 10 (src/common/DefaultOptions.h 第 1430 行)。为什么需要重试? 因为一条边的网格化可能依赖于相邻顶点/边的网格状态,并行时的顺序不确定,某些边可能在第一轮 失败(如自相交检测未通过),下一轮环境变化后再试就能成功。
  3. 异常隔离 :OpenMP 循环体不能直接抛异常("OpenMP forbids leaving block via exception"), Gmsh 用一个共享 exceptions 标志记下"有人抛了"。注意 if(exceptions) continue; 让其他线程 尽快退出------但这只是"尽力而为",因为已经进入循环体的线程不会被打断。循环结束后在主线程 统一 throw,并且先释放全局锁 CTX::instance()->lock = 0 再抛,否则锁会永久泄漏。
  4. 进度条 :Msg::ProgressMeter(localPending, false, ...) 只在第一轮(!nIter)更新,且 把"完成数"作为第一个参数(false 表示不再自动推进)。#pragma omp atomic capture 保证 nPending 的读-改-写是原子的。

4.3.3 规范重编号与收尾

cpp 复制代码
  Msg::StopProgressMeter();

  // numbered as the threads created them: renumber canonically
  m->renumberMeshCanonically(maxVertexBefore, maxElementBefore);
  CheckEmptyMesh(m, 1);
  double t2 = Cpu(), w2 = TimeOfDay();
  CTX::instance()->mesh.timer[0] = w2 - w1;
  Msg::StatusBar(true, "Done meshing 1D (Wall %gs, CPU %gs)",
                 CTX::instance()->mesh.timer[0], t2 - t1);
}

并行线程创建节点的顺序是不确定的(哪个线程先抢到锁,就先分配编号)。 renumberMeshCanonically(maxVertexBefore, maxElementBefore) 在阶段末尾把它们整理成确定性的编号 : 以进入本轮前已有的最大编号为界,对本轮新产生的节点/单元按几何位置或拓扑重新排序。 这正是可复现性的最后一道保障:即使并行,结果也与串行一致 。maxVertexBefore/maxElementBefore 在函数开头用 m->getMaxMeshNumbersInUse 抓取(第 547--548 行),作为重编号的下界。

CTX::instance()->mesh.timer[0] 记录一维网格耗时(index 0=1D,1=2D,2=3D),供 GetStatistics 读取 (第 305--307 行)。

4.3.4 CheckEmptyMesh:找出"没铺上网格"的实体

CheckEmptyMesh(第 481 行)在一维阶段末尾检查是否还有实体一个单元都没生成:

(完整函数见 Generator.cpp 第 481 行。)逻辑是"排除法 ":跳过不可见(meshOnlyVisible)、 跳过纯离散实体(isFullyDiscrete,第 2 章 2.23 节)、跳过边界层实体(它们由边界层流程单独处理)、 跳过已 DONE 的实体;剩下状态为 PENDING/FAILED 且单元数为 0 的实体就是"没铺上"的, 报一条 Msg::Warning("No elements in volume/surface/curve ...") 并列出 tag。 这条警告是用户排查"网格为什么没出来"的第一线索。

4.3.5 GetQualityFast 与 sicn 快速实现

虽然名字里带 "Qual" 且是收尾统计,但 GetQualityFast(第 451 行)与一维阶段关系不大, 我们先把它讲清楚,因为它与 GetStatistics 一起构成质量度量的"快慢两条路"。

cpp 复制代码
static void GetQualityFast(GModel *m, int dim, double &qmin, double &qavg)
{
  int nthreads = CTX::instance()->numThreads;
  if(!nthreads) nthreads = Msg::GetMaxThreads();

  std::size_t N = 0;
  std::vector<GEntity *> entities;
  m->getEntities(entities, dim);
  double qm = 1e200, qa = 0;
  for(auto ge : entities) {
    if(ge->dim() < 2) continue;
    std::size_t ne = ge->getNumMeshElements();
    N += ne;
#pragma omp parallel for num_threads(nthreads) reduction(min : qm)             \
  reduction(+ : qa)
    for(std::size_t i = 0; i < ne; i++) {
      MElement *e = ge->getMeshElement(i);
      const int type = e->getTypeForMSH();
      double q = (type == MSH_TRI_3) ? sicnStraightTriangle(e) :
                 (type == MSH_TET_4) ? sicnStraightTetrahedron(e) :
                                       e->minSICNShapeMeasure();
      qm = std::min(qm, q);
      qa += q;
    }
  }
  if(N) qa /= N;
  qmin = qm;
  qavg = qa;
}

关键设计:

  1. 只统计二维及以上实体 (if(ge->dim() < 2) continue;),因为质量度量(SICN)在曲线/点上无意义。
  2. 对"直线三角形"和"直四面体"用专用快路径 。类型为 MSH_TRI_3(3 节点直线三角形)时调用 sicnStraightTriangle;MSH_TET_4 时调用 sicnStraightTetrahedron;其余(高阶/其他形状)才调用 通用的 e->minSICNShapeMeasure()。为什么值得特化? ------因为绝大多数网格单元都是直线三角形/四面体, 而通用实现要走基函数查找、fullMatrix 分配、getNodesCoord、Eigen 乘积一整套机器;对直线单元, 雅可比是常量,可以化简到几十个浮点操作。
  3. OpenMP reduction :qm(最小)用 reduction(min:),qa(求和)用 reduction(+:)。注意 reduction 作用域是每个实体的内层循环------外层 for 遍历实体是串行的,这避免了跨实体的 reduction 复杂度,但代价是线程只在单个实体内部并行。

sicnStraightTriangle(第 363 行)的注释把来龙去脉讲得很清楚:

cpp 复制代码
// Same quantity as MElement::minSICNShapeMeasure(), specialized for a straight
// 3-node triangle: the Jacobian is constant, so the generic machinery (basis
// lookup, fullMatrix allocations, getNodesCoord, Eigen products) reduces to a
// handful of flops. The gradients in the ideal (equilateral) frame are
// d/dX = p1 - p0 and d/dY = (2 (p2 - p0) - (p1 - p0)) / sqrt(3).
static double sicnStraightTriangle(const MElement *e);

翻译成数学:SICN(Scaled Inverse Condition Number,缩放逆条件数)是 σ1,σ2 \sigma_1, \sigma_2 σ1,σ2 两个奇异值的函数: iCN= 2 σ12σ22 σ12+σ22 = 2σ1σ2 σ12+σ22 iCN = \frac{2\sqrt{\sigma_1^2\sigma_2^2}}{\sigma_1^2+\sigma_2^2} = \frac{2\sigma_1\sigma_2}{\sigma_1^2+\sigma_2^2} iCN=σ12+σ222σ12σ22 =σ12+σ222σ1σ2,取值在 −1,1-1, 1 −1,1。 σ12,σ22 \sigma_1^2, \sigma_2^2 σ12,σ22 由雅可比 Gram 矩阵的特征值给出 (sigma1Sq = 0.5*(N+sqrt(S)),sigma2Sq = 0.5*(N-sqrt(S)),其中 N 是 Gram 矩阵的迹、 S 是出现次数最多的 (tr)2−4det⁡(\text{tr})^2-4\det (tr)2−4det 展开)。最后用面法向与"映射后 z 方向"的点积定符号: dp >= 0 为正,否则取负。等边三角形的 SICN 为 1,退化(面积趋零)为 0,翻转(顶点顺序反)为负。

sicnStraightTetrahedron(第 414 行)同理,是四面体版: 3det⁡(J)/(∥J∥∥adj(J)∥)3\det(J)/(\lVert J\rVert\lVert \text{adj}(J)\rVert) 3det(J)/(∥J∥∥adj(J)∥), 其中 JJ J 是对正则参考四面体 的常雅可比。它先算出 JJ J(3×3)与伴随矩阵 I(即余子式转置), 再求行列式 D 与 Frobenius 范数 nJ/nI,返回 3*D/sqrt(nJ*nI)。同样,退化四面体返回 0。

小结一下质量度量的"两级"设计:GetQualityFast 给的是 ICN(SICN)的 min/avg ,走的是快路径, 供 GUI 实时显示与 Mesh.MinQuality/Mesh.AvgQuality 读取;GetStatistics(第 256 行)给的是 完整的三族度量(gamma、SICN、SIGE)及其 101 桶直方图 ,走的是通用 MElement 接口,供统计面板显示。 两条路的取舍是"速度 vs 丰富度"。

GetStatistics 的结构值得点一句:它用一个 qualityStats 结构(第 171 行)累计三种度量的和、 最小、最大,以及 101 桶直方图 (histogramBin,第 195 行,用"猜测 + 边界校验"快速定位桶)。 getQualityMeasures(第 232 行)还做了一件细节优化:对每种单元类型,先用该类型的第一个单元 "建立度量基底"(basis),再并行计算其余单元------因为通用度量实现依赖 per-type 的基函数缓存, 先串行建好再并行读,避免竞态。

4.4 一维网格的核心:meshGEdgeProcessing

meshGEdgeProcessing(src/mesh/meshGEdge.cpp 第 776 行)是整个一维 网格的"大脑"。它只有约 110 行,却决定了"一条边上有多少个点、点放在哪里"。函数签名:

cpp 复制代码
int meshGEdgeProcessing(GEdge *ge, const double t_begin, double t_end, int &N,
                        std::vector<IntPoint> &Points, double &a,
                        int &filterMinimumN)

参数语义:

  • ge:待网格化的几何边;
  • t_begin/t_end:参数区间 [t_begin, t_end](可被边界层"加点"修改,所以 t_begin 是值传入、 t_end 是引用传入);
  • N(出参):最终段数 + 1,即点数;
  • Points(出参):采样点数组,每个点带参数 t、局部尺寸 lc、原函数累积 p(primitive)、 切向模长 xp;
  • a(出参):按尺寸折算的"段数"(浮点),是 N 的来源;
  • filterMinimumN(出参):段数下限,供 filterPoints 使用。

4.4.1 前置守卫与积分求长

cpp 复制代码
int meshGEdgeProcessing(GEdge *ge, const double t_begin, double t_end, int &N,
                        std::vector<IntPoint> &Points, double &a,
                        int &filterMinimumN)
{
  if(ge->geomType() == GEntity::BoundaryLayerCurve) return 0;
  if(ge->meshAttributes.method == MESH_NONE) return 0;
  if(CTX::instance()->mesh.meshOnlyVisible && !ge->getVisibility()) return 0;
  if(CTX::instance()->mesh.meshOnlyEmpty && ge->getNumMeshElements()) return 0;

  // first compute the length of the curve by integrating one
  double length;
  Points.clear();
  if(ge->geomType() == GEntity::Line && ge->getBeginVertex() &&
     ge->getBeginVertex() == ge->getEndVertex() &&
     // do not consider closed lines as degenerated
     (ge->position(0.5) - ge->getBeginVertex()->xyz()).norm() <
       CTX::instance()->geom.tolerance)
    length = 0.0; // special case to avoid infinite loop in integration
  else
    length = Integration(ge, t_begin, t_end, F_One(), Points,
                         CTX::instance()->mesh.lcIntegrationPrecision *
                           CTX::instance()->lc);
  ge->setLength(length);
  Points.clear();

四道守卫把"不需要网格化"的情况全部挡在门外:边界层曲线(由边界层流程处理)、 MESH_NONE(用户显式关闭该边的网格,对应脚本 Mesh.MeshSizeFromCurvature/Mesh.None 之类)、 不可见(meshOnlyVisible)、已有网格且 meshOnlyEmpty。

积分求长 用的是 Integration(ge, t_begin, t_end, F_One(), Points, Prec), 其中 F_One() 返回切向模长 ∥dγ/dt∥\lVert d\gamma/dt\rVert ∥dγ/dt∥,于是积分结果就是曲线弧长。 积分精度 Prec = lcIntegrationPrecision * lc(lcIntegrationPrecision 默认 1e-9, 见 src/common/DefaultOptions.h 第 1404 行),即 "相对于模型尺寸的绝对精度"。

"闭合直线"(首尾顶点相同且中点与顶点距离小于几何容差)被特判为长度 0------ 注释说明这是"避免积分死循环"。因为一条首尾重合的"直线"参数区间上 dγ/dtd\gamma/dt dγ/dt 可能为 0, 梯形法在退化区间会无限细分。

4.4.2 积分算法:RecursiveIntegration 与 Integration

cpp 复制代码
static void trapezoidal(...);   // 0.5*(lc1+lc2)*(t2-t1)
template <typename function>
static void RecursiveIntegration(GEdge *ge, IntPoint *from, IntPoint *to,
                                 function f, std::vector<IntPoint> &Points,
                                 double Prec, int *depth);
// Integration(...) 从 t1 到 t2 累积原函数 p 并返回 Points.back().p

(完整实现见 meshGEdge.cpp 第 411--469 行。)

这是经典的自适应梯形法递归积分:

  1. 取区间中点 P,用两个子区间的梯形和 val2 + val3 与整区间的梯形 val1 比较, 差 err = |val1 - val2 - val3| 作为误差估计;
  2. 若 err < Prec 且深度足够(depth > 6) ,或已到最大深度(depth > 25),就接受: 把中点 P 与右端点 to 依次压入 Points,并计算它们的原函数累积 p;
  3. 否则继续对 [from, P] 和 [P, to] 递归。

两个重要细节:

  • depth > 6 的硬门槛 :即便误差已经很小,也至少递归 6 层。这不是为了精度,而是为了 保证有足够多的采样点 ,让后续的 smoothPrimitive 与"按 primitive 插值定位"有足够的 分辨率(否则尺寸突变处会漏采);
  • depth > 25 :兜底,防止无限递归(对应约 2252^{25} 225 段,实际上不可能到)。

Points 是按参数 t 递增排列 的(递归顺序是先左后右),因此 Points[i].p 单调递增。 IntPoint 结构(第 151 行):

cpp 复制代码
typedef struct {
  int Num;
  // t is the local coordinate of the point
  // lc is x'(t)/h(x(t))
  // p is the value of the primitive
  // xp is the norm of the tangent vector
  double t, lc, p, xp;
} IntPoint;

注释精确说明:t 是局部参数;lc = x'(t)/h(x(t))(切向速度除以目标尺寸,即"每单位尺寸的弧长变化率"), 所以对 lc 的积分就是按尺寸归一化的弧长 ,物理意义是"这一段等价于多少个标准尺寸的段"; p 是原函数累积;xp 是切向模长。

4.4.3 尺寸函数家族

F_One(第 403 行)------纯切向模长,用于求弧长:

cpp 复制代码
struct F_One {
  double operator()(GEdge *ge, double t)
  {
    SVector3 der = ge->firstDer(t);
    return norm(der);
  }
};

F_Lc (第 228 行)------各向同性尺寸:|dγ/dt| / lc(t),其中 lc(t) 由背景网格/尺寸场给出, 且在端点处与顶点尺寸(BGM_MeshSize(ge->getBeginVertex(), ...))取最小:

cpp 复制代码
struct F_Lc {
  double operator()(GEdge *ge, double t)
  {
    GPoint p = ge->point(t);
    Range<double> bounds = ge->parBounds(0);
    // at the ends, take the min() with the vertex size (a stronger constraint)
    double lc_here = 1.e22;
    if(t == bounds.low() && ge->getBeginVertex())
      lc_here = BGM_MeshSize(ge->getBeginVertex(), t, 0, p.x(), p.y(), p.z());
    else if(t == bounds.high() && ge->getEndVertex())
      lc_here = BGM_MeshSize(ge->getEndVertex(), t, 0, p.x(), p.y(), p.z());
    lc_here = std::min(lc_here, BGM_MeshSize(ge, t, 0, p.x(), p.y(), p.z()));
    return norm(ge->firstDer(t)) / lc_here;
  }
};

这里体现了一个反复出现的**"端点取最小"**规则:一条边的端点尺寸不仅要看边自身的尺寸, 还要看端点(GVertex)的尺寸------因为顶点尺寸通常由用户 Point(...) = {..., lc} 指定, 是更强的约束。BGM_MeshSize 的完整逻辑见 4.11 节。

F_LcB (第 220 行)------只查边的尺寸,供 filterPoints 使用(不叠加顶点约束):

cpp 复制代码
struct F_LcB {
  double operator()(GEdge *ge, double t)
  {
    GPoint p = ge->point(t);
    return BGM_MeshSize(ge, t, 0, p.x(), p.y(), p.z());
  }
};

F_Lc_aniso (第 248 行)------各向异性尺寸:用度量张量 SMetric3 计算沿边方向的等效长度 sqrt(d · M · d),并且与所有边界层场的度量取交 (intersection_conserveM1):

cpp 复制代码
struct F_Lc_aniso {
  double operator()(GEdge *ge, double t) {
    SMetric3 lc_here = (在端点则 BGM_MeshMetric(顶点) else BGM_MeshMetric(ge));
    for(每个边界层场 blf) 若 !blf->isEdgeBL(ge) {
      SMetric3 lc_bgm; blf->computeFor1dMesh(p.x(), p.y(), p.z(), lc_bgm);
      lc_here = intersection_conserveM1(lc_here, lc_bgm);
    }
    SVector3 der = ge->firstDer(t);
    return std::sqrt(dot(der, lc_here, der));  // metric-space length
  }
};

只有 ALGO_2D_BAMG 或存在边界层时,一维网格才走各向异性路径。sqrt(d·M·d) 是度量空间里 沿边方向的"等效长度"。intersection_conserveM1 表示"取两个度量的交,但保持第一个的主方向" (详见 4.12 节)。

F_Transfinite (第 296 行)------transfinite 边:用户直接指定段数 nbPointsTransfinite, 本函数只负责给出尺寸分布(用于定位点,而不是定段数)。它支持多种"分布律":

cpp 复制代码
struct F_Transfinite {
  double operator()(GEdge *ge, double t_) {
    double length = ge->length();   // 零长报错
    double d = norm(ge->firstDer(t_));
    double coef = ge->meshAttributes.coeffTransfinite;
    int type = ge->meshAttributes.typeTransfinite;
    int nbpt = ge->meshAttributes.nbPointsTransfinite;
    // type 5/6/7: 用户给的是首层高度 h_wall,用 newton/bissection 反推比例系数
    if(type == 5) { coef = newton_get_r(1.1, fabs(coef), length, nbpt); type = 1; }
    if(type == 6) { coef = bissection_get_a(0.1, fabs(coef), length, nbpt); type = 2; }
    if(type == 7) { ... bissection_get_beta(2, ...); type = 3; }
    // 再按 type 1/2/3/4 计算局部尺寸 d/lc 分布(见下)
  }
};

这里暴露了 transfinite 边的一个"隐晦但强大"的特性:typeTransfinite 可以是 1/2/3/4, 也可以写成 5/6/7,分别表示"用户给的是首层高度 h_wall 而不是比例系数"。代码在 type==5/6/7 时用 newton_get_r/bissection_get_a/bissection_get_beta 反推出比例系数,再落到 1/2/3 三种分布律:

  • type 1(几何级数 progression) :第 ii i 段长度正比 aria r^i ari,总长 a(rn−1)/(r−1)a(r^{n}-1)/(r-1) a(rn−1)/(r−1);
  • type 2(鼓包 bump):中间细两头粗(或反之)的二次型分布;
  • type 3(beta 律) :dfbeta 给出的 beta 分布,常用于边界层;
  • type 4(标准边界层 progression) :代码注释写着 TODO,实现为 d/(length*t)。

反推比例系数的辅助函数在同一文件:

  • f_beta/bissection_get_beta(第 24/31 行):beta 律用二分法把 h_wall 转成 beta;
  • f_bump/bissection_get_a(第 54/73 行):bump 律用二分法把 h_wall 转成 a;
  • f_prog/newton_get_r(第 96/104 行):progression 用二分法(函数名却叫 newton_,是历史遗留) 把 h_wall 转成增长率 r;
  • newton_get_2r(第 130 行):真正的牛顿迭代版本。

flexibleTransfinite 选项(Mesh.FlexibleTransfinite)允许 transfinite 边的段数再被 lcFactor 缩放------即"transfinite 也能接受全局尺寸因子"。

4.4.4 smoothPrimitive:尺寸分布的平滑

尺寸场给出的 lc(t) 可能突变(例如两种不同尺寸的场交界处),直接按它布点会导致相邻段长剧烈变化。 smoothPrimitive(第 160 行)用Gauss-Seidel 风格的松弛 把尺寸分布"抹平"到不超过 smoothRatio(默认 1.5{1.5} 1.5 的平方根,见 std::sqrt(CTX::instance()->mesh.smoothRatio))允许的增长率:

要点(完整实现见 meshGEdge.cpp 第 160 行):

  1. 前后两趟 (朴素 Gauss-Seidel:先正向扫描、再反向扫描),直到尺寸不再变化(count1+count2==0) 或超过 2000 次迭代。
  2. 触发条件 :把 x'(t)/h 的"斜率"dhdt/xp 与 (alpha-1)*1.01 比较。alpha 是允许的 尺寸增长率(这里是 sqrt(smoothRatio))。1.01 是一个"宽容系数",注释专门解释了为什么: 反向测试用 xp[i-1] 比较而更新用 xp[i] 缩放,某些点会"反复满足触发条件但值不变", 所以只在 lc 真正改变时才计入 count,从而正确收敛。
  3. 重算 primitive :平滑完尺寸后,用新的 lc 重新累加 p(梯形法), 因为定位点是按 p 等分的,p 必须与最终尺寸一致。

smoothPrimitive 只在 algo2d != ALGO_2D_BAMG 时调用(第 846 行),因为 BAMG 有自己的处理。

4.4.5 N 的确定与 union 分支

回到 meshGEdgeProcessing 的分支(第 802--851 行):

cpp 复制代码
  filterMinimumN = 1;
  if(length == 0. && CTX::instance()->mesh.toleranceEdgeLength == 0.) { a = 0.; N = 1; }
  else if(ge->degenerate(0)) { a = 0.; N = 1; }
  else if(ge->meshAttributes.method == MESH_TRANSFINITE &&
          ge->meshAttributes.typeTransfinite == 4) {
    // prescribed number of edges, but points positioned per the size map
    a = Integration(ge, t_begin, t_end, F_Lc(), Points, lcIntegrationPrecision);
    N = ge->meshAttributes.nbPointsTransfinite;
  }
  else if(ge->meshAttributes.method == MESH_TRANSFINITE) {
    a = Integration(ge, t_begin, t_end, F_Transfinite(), Points, lcIntegrationPrecision);
    N = ge->meshAttributes.nbPointsTransfinite;
    if(CTX::instance()->mesh.flexibleTransfinite && CTX::instance()->mesh.lcFactor)
      N /= CTX::instance()->mesh.lcFactor;
  }
  else {
    a = Integration(ge, t_begin, t_end,
                    CTX::instance()->mesh.algo2d == ALGO_2D_BAMG ? F_Lc_aniso() : F_Lc(),
                    Points, lcIntegrationPrecision);
    for(std::size_t i = 0; i < Points.size(); i++)
      Points[i].xp = ge->firstDer(Points[i].t).norm();   // tangent norm, for smoothing
    if(CTX::instance()->mesh.algo2d != ALGO_2D_BAMG)
      a = smoothPrimitive(ge, std::sqrt(CTX::instance()->mesh.smoothRatio), Points);
    filterMinimumN = ge->minimumMeshSegments() + 1;
    N = std::max(filterMinimumN, (int)(a + 1.99));
  }

(其中 lcIntegrationPrecision 即 CTX::instance()->mesh.lcIntegrationPrecision;此处做了 简写,源码中为完整表达式。)

一式一分支,把 Gmsh 一维网格的全部策略都收纳进来:

  • 退化/零长边 (length==0 且容差为 0,或 degenerate(0)):a=0, N=1,避免积分死循环; 注意 toleranceEdgeLength 非零时不会走第一分支------因为用户显式要求"忽略短边"时,短边仍要网格化。
  • Transfinite 且 typeTransfinite==4 :段数由用户指定 nbPointsTransfinite,但点的位置用 标准尺寸场 F_Lc() ------这是"段数固定但位置按尺寸约束"的模式,脚本里对应 Transfinite Curve{...} 的一种用法。
  • Transfinite 其它类型 :段数由用户指定,位置用 F_Transfinite() 分布律;flexibleTransfinite 时允许段数被 lcFactor 缩放。
  • 常规 :a = 对 F_Lc(或 BAMG 下的 F_Lc_aniso)的积分,即"按尺寸折算的段数"; 然后记录每点的切向模长 xp (因为 smoothPrimitive 需要它),再平滑; 最后 filterMinimumN = ge->minimumMeshSegments() + 1、N = max(filterMinimumN, (int)(a + 1.99))。

N = (int)(a + 1.99) 是一个向上取整的小技巧 :a 通常不是整数(如 3.2), (int)(3.2+1.99) = (int)5.19 = 5 = 段数+1;若 a=3.0(恰好整除),(int)(3.0+1.99)=4。 所以 N ≈ ceil(a) + 1,多算了 1 个点以容纳端点。minimumMeshSegments() 来自 GEdge, 与选项 Mesh.MinLineNodes/MinCircleNodes/MinCurveNodes 有关(第 2 章提到 minimumMeshSegments 按曲线类型返回不同下限)。

t.end()、N、a 都在连续分支里被赋值,最后由 return N;(第 883 行)返回。

4.4.6 重组合奇偶性:increaseN

一维网格的段数 N 不是孤立的------它受二维算法的强烈约束。第 853--881 行:

cpp 复制代码
  // force odd number of points if blossom is used for recombination
  // only do it if recombination method is 2 (simple full quad) or 4
  // bipartite labelling

  if((ge->meshAttributes.method != MESH_TRANSFINITE ||
      CTX::instance()->mesh.flexibleTransfinite) &&
     CTX::instance()->mesh.algoRecombine != 0) {
    std::vector<GFace *> const &faces = ge->faces();
    if(CTX::instance()->mesh.recombineAll) {
      //            if(N == 2) N = 1;
      if(CTX::instance()->mesh.algoRecombine == 2 ||
         CTX::instance()->mesh.algoRecombine == 4){
        if(N % 2 == 0) N++;
      	N = increaseN(N);
      }
    }
    else {
      for(auto it = faces.begin(); it != faces.end(); it++) {
        if((*it)->meshAttributes.recombine) {
          if(CTX::instance()->mesh.algoRecombine == 2 ||
             CTX::instance()->mesh.algoRecombine == 4)
  	    N = increaseN(N);
            //	    printf("coucou %d\n",N);
            if(N % 2 == 0) N++;
          break;
        }
      }
    }
  }

  return N;
}

配合辅助函数 increaseN(第 596 行):

cpp 复制代码
// new algo for recombining + splitting
static int increaseN(int N)
{
  if(((N + 1) / 2 - 1) % 2 != 0) return N + 2;
  return N;
}

原因很直接:四边形网格(尤其是"二部图标注"bipartite labelling 与"简单全四边形")要求对边上的 段数满足奇偶关系,否则前沿推进/标注无法闭合 。algoRecombine==2(简单全四边形)或 ==4(二部图标注) 时,先把偶数 N 变奇数(if(N%2==0) N++),再 increaseN(N) 做一次"奇偶联动"调整:

  • increaseN(N):((N+1)/2 - 1) % 2 != 0 时返回 N+2。展开:当 N=5 时 (3-1)%2=0 不变; 当 N=6→(3-1)%2... 等等。这条规则来自"四边形二部标注"的数学条件,属于 Gmsh 里少见的 "一段看似神秘的整数奇偶逻辑" ,其正确性由 algoRecombine 的算法(第 5 章)保证。

recombineAll 为真时对所有边统一处理;否则只对"其相邻面要求 recombine"的边处理------ 这就是脚本 Recombine Surface{...} / Mesh.RecombineAll 的底层。

4.4.7 meshGEdge::operator():分派入口

meshGEdge::operator()(第 886 行)是真正被 Mesh1D 并行调用的"边网格化"入口。它按优先级处理 各种特殊情况,最后才落到 meshGEdgeProcessing。我们分段看:

cpp 复制代码
void meshGEdge::operator()(GEdge *ge)
{
  // debug stuff: keep only edges bounding debugSurface, if set
  if(CTX::instance()->debugSurface > 0) { ... if(!found) return; }

  ge->model()->setCurrentMeshEntity(ge);
  if(ge->degenerate(1)) { ge->meshStatistics.status = GEdge::DONE; return; }
  if(ge->geomType() == GEntity::BoundaryLayerCurve) return;
  if(ge->meshAttributes.method == MESH_NONE) return;
  if(CTX::instance()->mesh.meshOnlyVisible && !ge->getVisibility()) return;
  if(CTX::instance()->mesh.meshOnlyEmpty && ge->getNumMeshElements()) return;

  deMeshGEdge dem; dem(ge);   // destroy the mesh if it exists

  if(MeshExtrudedCurve(ge)) {
    // extruded edges are handled by the extrusion; only fill the periodic
    // node correspondences if needed
    if(ge->getMeshMaster() != ge) {
      GEdge *gef = dynamic_cast<GEdge *>(ge->getMeshMaster());
      fillCorrespondingNodes(gef, ge, ge->masterOrientation);
    }
    return;
  }

  if(ge->getMeshMaster() != ge) {           // periodic slave curve
    GEdge *gef = dynamic_cast<GEdge *>(ge->getMeshMaster());
    if(gef->meshStatistics.status == GEdge::PENDING) return;  // wait next round
    Msg::Info("Meshing curve %d (%s) as a copy of curve %d", ...);
    copyMesh(gef, ge, ge->masterOrientation);
    ge->meshStatistics.status = GEdge::DONE;
    return;
  }
  ...

分派顺序语义:

  1. 调试过滤 (debugSurface):只看指定面周围的边;
  2. setCurrentMeshEntity(ge) :把当前实体记到 GModel,用于失败时定位(lastMeshEntityError);
  3. degenerate(1) :一维退化边(如参数极点处)直接标 DONE 返回;
  4. 与 meshGEdgeProcessing 相同的前置守卫;
  5. deMeshGEdge dem; dem(ge); 删除旧网格(deMeshGEdge::operator() 第 571 行: ge->deleteMesh() 且状态置 PENDING);
  6. MeshExtrudedCurve(ge):拉伸边(由挤出/拉伸生成)由专门流程处理;
  7. 主从边(周期) :若 ge 是从边(getMeshMaster() != ge),先等主边完成 (if(gef->meshStatistics.status == GEdge::PENDING) return; 保持本边 PENDING, 下轮重试),再用 copyMesh 沿 masterOrientation 复制主边的网格。

这就是为什么 Mesh1D 需要多轮重试:从边依赖主边,而主边可能在并行中还没做完, 第一轮从边只能"跳过",第二轮主边做完了再复制。

4.4.8 内点定位:在参数区间按 primitive 插值

主从/拉伸都处理完之后,才真正"铺点"(第 935--1057 行):

cpp 复制代码
  Range<double> bounds = ge->parBounds(0);
  double t_begin = bounds.low(), t_end = bounds.high();
  std::vector<MVertex *> _addBegin, _addEnd;
  addBoundaryLayerPoints(ge, t_begin, t_end, _addBegin, _addEnd);   // 可能收缩区间
  int N; std::vector<IntPoint> Points; double a; int filterMinimumN;
  meshGEdgeProcessing(ge, t_begin, t_end, N, Points, a, filterMinimumN);
  if(ForceNumberOfSubdivisions > 0) N = ForceNumberOfSubdivisions + 1;
  // 等 primitive 分点(只放内部点,首尾点用 begin/end 顶点的 mesh_vertices[0])
  if(N > 1) {
    const double b = a / static_cast<double>(N - 1);
    int NUMP = 1, count = 1;  mesh_vertices.resize(N - 2);
    while(NUMP < N - 1) {
      P1 = Points[count-1]; P2 = Points[count]; const double d = NUMP * b;
      if(|P2.p| >= |d| && |P1.p| < |d|) {
        const double t = P1.t + (P2.t-P1.t)/(P2.p-P1.p) * (d - P1.p);   // 线性插值
        const double lc = norm(ge->firstDer(t)) / (P1.lc + dlc/dp*(d-P1.p));
        mesh_vertices[NUMP-1] = new MEdgeVertex(ge->point(t), base, t, 0, lc); NUMP++;
      } else count++;
    }
    mesh_vertices.resize(NUMP - 1);
  }

核心是**"等 primitive 分点"**:

  • a 是整条边按尺寸折算的总段数,b = a / (N-1) 是每一段的目标 primitive 增量;

  • 第 NUMP 个内部点的目标是 d = NUMP * b;

  • 扫描 Points(已按 t 递增、p 递增),找到第一个使 P1.p < d <= P2.p 的区间, 在区间内线性插值 求参数 t:

    t=t1+ dtdp (d−p1) t = t_1 + \frac{dt}{dp}(d - p_1) t=t1+dpdt(d−p1)

    其中 dp = P2.p - P1.p。这一步把"primitive 等分"翻译回"参数 t"。

  • 同时用线性插值估算该点的局部尺寸 lc = d / (P1.lc + dlc/dp * (d - P1.p)), 并把点建成 MEdgeVertex(带参数 t 的边节点)。

  • mesh_vertices.resize(N - 2):只放内部点 (首尾点由 getBeginVertex()/getEndVertex() 的 mesh_vertices[0] 充当,所以内部点只有 N-2 个)。

这种"等 primitive 分点"比"等参数分点"更合理:参数域不均匀的曲线上,等参数会得到疏密不均的点, 而等 primitive 得到的是按尺寸均匀分布的点。

最后(第 1034--1056 行)用相邻节点两两连成 MLine,并把闭合曲线的首点恢复(v0->x()=beg_p.x() 等), 再把状态置 DONE:

cpp 复制代码
  std::vector<MLine *> &lines = ge->lines;

  for(std::size_t i = 0; i <= mesh_vertices.size(); i++) {
    MVertex *v0 = (i == 0) ? ge->getBeginVertex()->mesh_vertices[0] : mesh_vertices[i - 1];
    MVertex *v1 = (i == mesh_vertices.size()) ? ge->getEndVertex()->mesh_vertices[0] : mesh_vertices[i];
    lines.push_back(new MLine(v0, v1));
  }

  if(ge->getBeginVertex() == ge->getEndVertex() &&
     ge->getBeginVertex()->edges().size() == 1) {
    MVertex *v0 = ge->getBeginVertex()->mesh_vertices[0];
    v0->x() = beg_p.x(); v0->y() = beg_p.y(); v0->z() = beg_p.z();
  }
  ...
  ge->meshStatistics.status = GEdge::DONE;
}

ForceNumberOfSubdivisions 是 meshGEdge 的一个成员(第 951 行使用), 由 quantize1DIfNeeded 通过 mge.ForceNumberOfSubdivisions = Ni; 设置(第 1617 行)------ 用于"强制某条边重划成指定段数"(4.5 节会讲它解决的偶数量化问题)。

4.4.9 filterPoints:删除过密的点

filterPoints(第 604 行)负责删除靠得太近的点,避免"噪声曲线/过粗的一维网格"导致的退化单元:

(完整函数见 meshGEdge.cpp 第 604 行。)算法:从起点出发,若下一点与当前参考点 v0 的距离 d < 0.3 * lc(太近),记入 lengths(键为 lc/d);否则更新参考点 v0 = v(即"拉开"), 于是密集的点成簇进入 lengths,最后按 lc/d 从大到小排序删除,但有两个保护:

  • forceOdd:若 recombineAll(全四边形),要保证删除后的点数仍为奇数 (while(last%2) last--), 否则会破坏四边形网格的奇偶性;
  • filteringObservesMinimumN:删除后剩余点数不少于 nMinimumPoints(由 filterPoints(ge, filterMinimumN - 2) 传入,见第 1030 行),否则整体放弃过滤。

调用点在 meshGEdge::operator() 第 1026--1030 行:

cpp 复制代码
  if(CTX::instance()->mesh.algo2d != ALGO_2D_BAMG &&
     CTX::instance()->mesh.algo2d != ALGO_2D_QUAD_QUASI_STRUCT &&
     CTX::instance()->mesh.algo2d != ALGO_2D_PACK_PRLGRMS)
    if(_addBegin.empty() && _addEnd.empty())
      filterPoints(ge, filterMinimumN - 2);

即:BAMG、QUAD_QUASI_STRUCT、PACK 三种算法不做 过滤(它们对点有特殊要求), 且有边界层加点(_addBegin/_addEnd 非空)时也不做。

4.4.10 边界层加点:addBoundaryLayerPoints 与 createPoints

addBoundaryLayerPoints(第 706 行,完整实现见源文件 meshGEdge.cpp)为"边界层在某条边的端点起/止" 准备点,并可能收缩参数区间 t_begin/t_end:若某边界层场把边的起点/终点标记为 "结束节点"(isEndNode),就在这一点沿边的方向 dir 逐层插入点(createPoints), 并把 t_begin/t_end 移到最后一个插入点的参数------这样就"吃掉"了端部一段, 主网格铺在剩下的区间上。约束:

  • 端部必须是直线 (geomType() == Line),否则报错"Boundary layer end point ... should lie on a straight line"------因为沿曲线方向偏移在曲线上不再曲线上;
  • 不同边界层不能相接 (_addBegin.size()/_addEnd.size() 非空再遇到就报错)。

createPoints(第 667 行)按 blf->betaLaw 二选一:beta 律用 blf->beta/nb_divisions 算层位置; 否则用几何增长 L += hWall * pow(ratio, ith),并在超过 thickness 或 0.4*LEdge 时停止 (完整实现见 meshGEdge.cpp 第 667 行)。注意这里新点的 z 坐标暂时写成 0.0(SPoint3(p.x(), p.y(), 0.0)),随后通过 ge->parFromPoint(p) 反算参数------这暗示边界层端点插入主要面向二维平面问题。

在主流程中,这些加点被拼接到内部点数组(第 1016--1024 行):

cpp 复制代码
  // Boundary Layer points are added
  {
    std::vector<MVertex *> vv;
    vv.insert(vv.end(), _addBegin.begin(), _addBegin.end());
    vv.insert(vv.end(), mesh_vertices.begin(), mesh_vertices.end());
    for(std::size_t i = 0; i < _addEnd.size(); i++)
      vv.push_back(_addEnd[_addEnd.size() - 1 - i]);
    mesh_vertices = vv;
  }

即"起始层点 → 主内部点 → 末端层点(逆序)"。

4.4.11 copyMesh 与 fillCorrespondingNodes:周期复制

copyMesh(第 482 行)把主边的网格复制到从边,同时建立节点对应 correspondingVertices:

copyMesh(第 482 行,完整实现见源文件):若从边有仿射变换(affineTransform.size() == 16), 用 transform 把主边节点映射到从边,再 parFromPoint 反算参数;否则退化为"假设两参数完全一致" (源码注释写着 danger,危险),直接按参数平移。每个新节点都登记 to->correspondingVertices[vv] = v------这条映射是 FixPeriodicMesh 与高阶节点匹配的基础。 要点:

  1. 方向(direction = masterOrientation) :direction > 0 正向复制,否则逆序(index 倒着取)。 这处理主从边参数方向相反的情况;
  2. 变换优先 :若从边有仿射变换(affineTransform.size() == 16),用 transform 把主边节点 映射到从边,再 parFromPoint 反算参数;否则退化为"假设两参数完全一致"(注释写着 danger,危险), 直接按参数平移;
  3. 对应关系 :每个新节点都登记 to->correspondingVertices[vv] = v------这条映射是 FixPeriodicMesh 与后续高阶节点匹配的基础。

fillCorrespondingNodes(第 540 行)是拉伸边版本:拉伸边的网格已经由挤出流程生成, 只需要把节点对应"填"进去(要求两边的内部点数相同,否则报"Incompatible meshes"):

fillCorrespondingNodes(第 540 行,实现见源文件)是拉伸边 版本:拉伸边的网格已经由挤出流程 生成,只需要把节点对应"填"进去(要求两边的内部点数相同,否则报"Incompatible meshes")。 注意源码里那句 // FIXME: verify that the nodes follow the transformation!------这是一个已知的隐患 : fillCorrespondingNodes 只按数组下标 配对,不验证两边节点真的经过变换对应; copyMesh(重新生成点)没有这个问题。

最后,meshGEdgeInsertBoundaryLayer(第 1093 行)是另一个专门的边界层插入函数: 当一维网格的首/末线段长度近似为 0 (l0->getLength() < 1e-12)时,它用二分法 (while(1){ t_mid...; if(fabs(d-width)<eps) break; })把曲线参数重新定位到"距端点 width 处", 把首末节点挪过去,再对中间区间重划。这是"边界层把端点附近压缩到零长后,一维网格需要修复" 的场景。

4.5 Mesh2D:面网格总控

Mesh2D(第 708 行)与 Mesh1D 结构一致(并行 + 重试 + 重编号),但多了三个额外复杂度: 更多并行退化条件、compound 串行化、以及"边界层是全局操作"。我们逐段看。

4.5.1 并行退化的条件更多

cpp 复制代码
static void Mesh2D(GModel *m)
{
  ... // abortOnError 检查 / initialize / getMaxMeshNumbersInUse
  int nthreads = CTX::instance()->numThreads;
  if(CTX::instance()->mesh.maxNumThreads2D > 0) nthreads = CTX::instance()->mesh.maxNumThreads2D;
  if(!nthreads) nthreads = Msg::GetMaxThreads();
  if(m->getFields()->getNumBoundaryLayerFields()) nthreads = 1;   // BL 不线程安全
  for(GFace *gf : m->getFaces()) {   // 逐面检查(每个面可有自己的算法)
    int a = gf->getMeshingAlgo();
    if(a == ALGO_2D_FRONTAL_QUAD || a == ALGO_2D_PACK_PRLGRMS || a == ALGO_2D_PACK_PRLGRMS_CSTR)
      nthreads = 1;                                  // 全四边形算法
    if(gf->getMeshMaster() != gf) nthreads = 1;      // 周期面
    if(gf->meshAttributes.extrude && gf->meshAttributes.extrude->mesh.ExtrudeMesh)
      nthreads = 1;                                  // 拉伸面
  }
}

与 Mesh1D 相比,二维多出的退化条件有:全四边形算法(ALGO_2D_FRONTAL_QUAD/PACK_PRLGRMS/ PACK_PRLGRMS_CSTR)非线程安全 、周期面非线程安全 、外加同样针对边界层与拉伸的退化。 注意这里用 (*it)->getMeshingAlgo() 而不是全局 algo2d------因为每个面可以有自己的算法, 所以必须逐面检查。

4.5.2 状态机、异常隔离与 compound 串行化

cpp 复制代码
  for(auto it = m->firstFace(); it != m->lastFace(); ++it) {
    (*it)->meshStatistics.status = GFace::PENDING;
    (*it)->meshStatistics.nbRefineAllEdges = 0;
  }

  // boundary layers are special: their generation (including vertices and
  // curve meshes) is global as it depends on a smooth normal field generated
  // from the surface mesh of the source surfaces
  if(!Mesh2DWithBoundaryLayers(m)) {
    std::set<GFace *, GEntityPtrLessThan> f(m->firstFace(), m->lastFace());
    int nIter = 0, nTot = m->getNumFaces();
    Msg::StartProgressMeter(nTot);

    while(1) {
      if(CTX::instance()->abortOnError && Msg::GetErrorCount()) { ...; break; }
      int nPending = 0;
      bool exceptions = false;
      // the members of a compound are meshed serially: the one that does the
      // compound job reads and replaces the meshes of the others
      std::vector<GFace *> temp, members;
      for(GFace *gf : f) (gf->compound.empty() ? temp : members).push_back(gf);
      auto meshOne = [&](GFace *gf) {
        if(exceptions) return;
        if(gf->meshStatistics.status == GFace::PENDING) {
          backgroundMesh::current()->unset();
          try { gf->mesh(true); }             // OpenMP forbids leaving by exception
          catch(...) { exceptions = true; }
#pragma omp atomic capture
          { ++nPending; }
        }
      };
#pragma omp parallel for schedule(dynamic) num_threads(nthreads)
      for(size_t K = 0; K < temp.size(); K++) meshOne(temp[K]);
      for(GFace *gf : members) meshOne(gf);   // compound members serial
      if(exceptions) { CTX::instance()->lock = 0; throw std::runtime_error(Msg::GetLastError()); }
      if(!nPending) break;
      // iter == 2 is for meshing re-parametrized surfaces; after that we
      // serialize (self-intersections of 1D meshes are not thread safe)!
      if(nIter > 2) nthreads = 1;
      if(nIter++ > CTX::instance()->mesh.maxRetries) break;
    }
    Msg::StopProgressMeter();
  }

几个新增要点:

  1. compound 串行化 :面被分成 temp(普通面,可并行)与 members(compound 成员,串行)。 注释点明原因:"复合面成员必须串行网格化,由执行复合的那一个成员读写其他人的网格"。 于是 #pragma omp parallel for 只作用于 temp,members 用普通 for 串行。 compound 是 Gmsh 把"多个面当成一个面来网格化"的机制(第 2 章 2.15 节)。
  2. backgroundMesh::current()->unset() :每个面开始前重置背景网格(backgroundMesh 是 一个按面缓存的尺寸查询结构,跨面复用会串味)。unset() 定义在 BackgroundMesh.h/.cpp。
  3. nIter > 2 后序列化:注释说明"iter == 2 是给重参数化曲面用的;之后必须串行, 因为一维网格的自相交检测不是线程安全的"。这就是**"并行逐渐退化为串行"**的重试策略: 前几轮并行快速试错,之后转为串行以求正确。
  4. 异常隔离 同 Mesh1D:共享 exceptions 标志,循环后主线程抛出,并先释放锁。

4.5.3 Mesh2DWithBoundaryLayers:边界层是全局操作

if(!Mesh2DWithBoundaryLayers(m)) 那个 if 的语义很关键:边界层网格是一次全局操作。 注释解释得很清楚(第 748--751 行):

boundary layers are special: their generation (including vertices and curve meshes) is global as it depends on a smooth normal field generated from the surface mesh of the source surfaces

也就是说,边界层要先对整个来源曲面做一次面网格、算出光滑法向场(ExtrudeParams::normals), 再沿法向挤出层层结构。所以 Mesh2DWithBoundaryLayers(src/mesh/BoundaryLayers.cpp 第 310 行)若成功(返回非 0)就接管整个二维阶段,否则才走常规逐面算法。

Mesh2DWithBoundaryLayers 的流程(第 310--501 行):

  1. 扫描边界层实体 :遍历所有边找出 geomType() == BoundaryLayerCurve 的"从边" (ep->geo.Mode == COPIED_ENTITY),记录其源边 sourceEdges;对三维边界层则找出 BoundaryLayerSurface 的源面 sourceFaces。

  2. 依赖计算 :checkDepends(m, *it, sourceFacesDependencies) 把源面所依赖的其他面也纳入, 因为"在拉伸网格上做边界层"需要先网格化依赖面(第 408--419 行)。

  3. 网格化源面(含依赖) :先串行网格化依赖面(带 maxRetries 重试),再网格化源面:

    cpp 复制代码
    for(auto it = sourceFaces.begin(); it != sourceFaces.end(); it++)
      (*it)->mesh(true);
  4. 定向 :std::for_each(sourceFaces.begin(), sourceFaces.end(), orientMeshGFace()); ------注释说"这里的定向很关键,因为我们要用源面的法向做挤出"。

  5. 法向场 :ExtrudeParams::normals[i] 重建为 new smooth_data(),再 addExtrudeNormals(sourceFaces, sourceFaceInfo, faceSkipScaleCalc)(或对边版本)算出 整个面上的光滑法向场。

  6. 用光滑法向场定位边界层点 (第 460--485 行):对每个边界层曲线,用 ep->Extrude(ep->mesh.NbLayer - 1, ...) 把源点推到位。

  7. 重划被修改的边 (第 487--491 行):因为边界层改动了端点,非源面必须先删网格 (deMeshGFace)再重划,否则会与旧网格冲突。

  8. 沿法向挤出边界层曲线(第 493 行起)。

这一整套的正确性依赖"先有源面网格 → 光滑法向场 → 再挤出"的严格次序,这就是它必须 "接管整个二维阶段"而不能混在并行逐面循环里的原因。

4.5.4 MatchPeriodicNodes:为独立网格化的周期面找回对应

常规逐面路径结束后(或边界层路径之后),二维阶段会调用 MatchPeriodicNodes(m)(第 691 行):

cpp 复制代码
// The surfaces meshed by their own algorithm (transfinite or extruded) instead
// of as copies of their master have no periodic node correspondence: find it
// by matching the nodes through the periodic transformation
static void MatchPeriodicNodes(GModel *m)
{
  double tol = -1.;
  for(GFace *gf : m->getFaces()) {
    GEntity *master = gf->getMeshMaster();
    if(master == gf || gf->affineTransform.size() != 16 ||
       !gf->correspondingVertices.empty() || !gf->getNumMeshElements())
      continue;
    if(tol < 0)
      tol = CTX::instance()->geom.matchMeshTolerance * m->bounds().diag();
    if(!gf->updateCorrespondingVertices(tol))
      Msg::Warning("Mesh of surface %d does not match the mesh of its periodic "
                   "master surface %d: no periodic node correspondence",
                   gf->tag(), master->tag());
  }
}

背景:由自身算法(transfinite 或 extrude)网格化的周期面,不会像主面的"副本"那样自动带 节点对应 。于是这里用 gf->updateCorrespondingVertices(tol) 通过周期变换 去匹配节点: 容差 tol = matchMeshTolerance * bounds().diag()(相对模型对角线)。匹配失败则警告 "no periodic node correspondence"------这是周期网格出问题时的典型信号。

4.5.5 两种四边形算法的专门收尾

二维阶段末尾针对 QUAD_QUASI_STRUCT 与 PACK_PRLGRMS 做了专门收尾(第 808--852 行):

cpp 复制代码
  if(algo2d == ALGO_2D_QUAD_QUASI_STRUCT) {
    replaceBadQuadDominantMeshes(m);
    RefineMeshWithBackgroundMeshProjection(m);   // quad-dominant -> full quad
    OptimizeMesh(m, "QuadQuasiStructured");
  }
  if(algo2d == ALGO_2D_PACK_PRLGRMS) {
    for(GFace *gf : m->getFaces())
      if(gf->meshStatistics.status == GFace::DONE) gf->meshStatistics.status = GFace::PENDING;
    transferSeamGEdgesVerticesToGFace(m);
    /* debug==99: write opti1.msh + improveQuadMeshTopologyWithCavityRemeshing */
    OptimizeMesh(m, "UntangleTris");
    /* debug==99: write opti4.msh */
    for(GFace *gf : m->getFaces())
      if(gf->meshStatistics.status == GFace::PENDING) gf->meshStatistics.status = GFace::DONE;
  }

两条"流水线"揭示了这两种高级算法的工作方式:先生成三角/四边形混合的"主导网格", 再通过全局优化与细分把它变成纯四边形网格。

  • QUAD_QUASI_STRUCT :replaceBadQuadDominantMeshes(替换坏的四边形主导网格)→ RefineMeshWithBackgroundMeshProjection(在背景网格投影下细分,把主导四边形拆成"满四边形") → OptimizeMesh(m, "QuadQuasiStructured")。代码里那条 // TODO: a faster CAD projection approach 说明这仍是一个活跃改进中的路径。
  • PACK_PRLGRMS :先把所有 DONE 面重置为 PENDING(让后续优化步骤重新处理), 再 transferSeamGEdgesVerticesToGFace(把缝合线(seam)上的边顶点搬到面参数域上)、 OptimizeMesh(m, "UntangleTris")(解缠三角),调试题(verbosity == 99)下还会写 opti1.msh/opti4.msh 并跑 improveQuadMeshTopologyWithCavityRemeshing(腔体重划分改进拓扑)。 注意末尾又把 PENDING 面标回 DONE------这整个收尾是"借用了状态机"的批处理。

4.5.6 PrintMesh2dStatistics:可选的网格统计报表

Mesh2D 最后调用 PrintMesh2dStatistics(m)(第 623 行)。它只在 createAppendMeshStatReport 为 1(写)/2(追加)时才动作,把每个面的统计(单元数、质量 worst/best/avg、最长/最短边、效率指数 τ\tau τ)写进一个文本表。其中效率指数由 Msg::Info("Efficiency index for surface mesh tau=%g ", 100 * exp(e_avg / nTotE)) 打印------ 这是 Gmsh 二维网格质量的经典指标(越接近 100 越好)。

4.6 Mesh3D:体网格总控

Mesh3D(第 958 行)的分派顺序很重要,它体现了"先易后难、先局部后全局"的策略:

cpp 复制代码
static void Mesh3D(GModel *m)
{
  m->getFields()->initialize();
  // 1) mesh the extruded volumes first
  std::for_each(m->firstRegion(), m->lastRegion(), meshGRegionExtruded());
  // 2) then subdivide if necessary (a global operation that can change the surface mesh)
  SubdivideExtrudedMesh(m);
  // 3) then mesh all the non-delaunay regions, collecting the delaunay ones
  std::vector<GRegion *> delaunay;
  std::for_each(m->firstRegion(), m->lastRegion(), meshGRegion(delaunay));
  // 4) then mesh the delaunay regions, connected part by connected part
  std::vector<std::vector<GRegion *>> connected;
  FindConnectedRegions(delaunay, connected);
  // remove quad elements on the faces of volumes that will be recombined
  ... quadsToTriangles(*f, 1000000);
  for(... connected[i] ...) MeshDelaunayVolume(connected[i]);   // (DOMHEX see 4.6.5)
  if(hexDominant) MakeHybridHexTetMeshConformalThroughTriHedron(m);
  m->setAllVolumesPositive();   // ensure all volume Jacobians > 0
  if(Msg::GetVerbosity() > 98)
    std::for_each(m->firstRegion(), m->lastRegion(), EmbeddedCompatibilityTest());
  CheckEmptyMesh(m, 3);
}

注:此处按源码结构整理(省略进度条/计时细节),第 4.6.5、4.6.7 节给出被省略段的原貌。

4.6.1 拉伸体优先与 SubdivideExtrudedMesh

拉伸体优先 :meshGRegionExtruded 直接按挤出参数分层铺网格(第 2 章 ExtrudeParams), 不走通用算法。这一步在通用 Delaunay 之前,因为拉伸体的网格是"可预测的、由挤出结构决定"。

紧接着的 SubdivideExtrudedMesh(m) 是一个全局操作,注释解释了为什么它必须在通用网格化之前: "不幸的是细分是全局操作,可能要求改动面网格"。挤出体的分层细分会影响与之相邻的面, 所以必须在其他体网格化之前完成。

4.6.2 meshGRegion:把待 Delaunay 的体收集起来

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

meshGRegion 是一个 functor:它当场网格化"非 Delaunay 算法"的体 (例如 Frontal/netgen), 同时把"应该用 Delaunay 的体"收集 进 delaunay 列表,留待后面统一处理。为什么要分开? 因为 Delaunay 是全局操作(一个体的网格依赖于邻域),必须成组做;而 Frontal 可以逐体做。

4.6.3 FindConnectedRegions:栈式 DFS 分连通分量

cpp 复制代码
static void FindConnectedRegions(const std::vector<GRegion *> &del,
                                 std::vector<std::vector<GRegion *>> &connected)
{
  std::vector<GRegion *> delaunay = del;
  // test: connected.resize(1); connected[0] = delaunay; return;

  const std::size_t nbVolumes = delaunay.size();
  if(!nbVolumes) return;
  while(delaunay.size()) {
    std::set<GRegion *> oneDomain;
    std::stack<GRegion *> _stack;
    GRegion *r = delaunay[0];
    _stack.push(r);
    while(!_stack.empty()) {
      r = _stack.top();
      _stack.pop();
      oneDomain.insert(r);
      std::vector<GFace *> faces = r->faces();
      for(auto it = faces.begin(); it != faces.end(); ++it) {
        GFace *gf = *it;
        GRegion *other =
          (gf->getRegion(0) == r) ? gf->getRegion(1) : gf->getRegion(0);
        if(other != nullptr && oneDomain.find(other) == oneDomain.end())
          _stack.push(other);
      }
    }
    std::vector<GRegion *> temp1, temp2;
    for(std::size_t i = 0; i < delaunay.size(); i++) {
      r = delaunay[i];
      if(oneDomain.find(r) == oneDomain.end())
        temp1.push_back(r);
      else
        temp2.push_back(r);
    }
    connected.push_back(temp2);
    delaunay = temp1;
  }
  Msg::Info("3D Meshing %d volume%s with %d connected component%s", nbVolumes,
            nbVolumes > 1 ? "s" : "", connected.size(),
            connected.size() > 1 ? "s" : "");
}

这是一个栈式 DFS :从 delaunay[0] 出发,反复取栈顶点 r,把与它共享面 的兄弟体 (gf->getRegion(0)/getRegion(1) 中非自己的那个)压栈,直到栈空,得到一个连通分量 oneDomain; 再从剩余体中挑出属于/不属于该分量的两组,递归处理剩余。 if(other != nullptr ...) 处理边界上"另一侧没有体"的面(外表面)。

注释(Mesh3D 里)说明了为什么要分组:"we mesh each connected part separately for performance and mesh quality reasons"------把不连通的区域分开网格化,既提升性能(可分别处理、可并行), 也提升质量(小问题隔离,一个分量出错不影响其他)。

4.6.4 quadsToTriangles:为六面体重组合"腾出三角形"

cpp 复制代码
  // remove quads elements for volumes that are recombined
  for(std::size_t i = 0; i < connected.size(); i++) {
    for(std::size_t j = 0; j < connected[i].size(); j++) {
      GRegion *gr = connected[i][j];
      if(CTX::instance()->mesh.recombine3DAll ||
         gr->meshAttributes.recombine3D) {
        std::vector<GFace *> f = gr->faces();
        for(auto it = f.begin(); it != f.end(); ++it)
          quadsToTriangles(*it, 1000000);
      }
    }
  }

若某体要重组合成六面体(recombine3D 或全局 recombine3DAll),则把它所有面上的四边形 先拆成三角形(quadsToTriangles(*it, 1000000),第二个参数是质量阈值,1e6 表示不设限)。 原因:Delaunay 三角化从三角形面网格出发;四边形面网格会干扰三角形的插入与面恢复, 所以先"拆三角",后面再由六面体重组合过程重新组织。

4.6.5 MeshDelaunayVolume 与 DOMHEX

cpp 复制代码
  for(std::size_t i = 0; i < connected.size(); i++) {
    MeshDelaunayVolume(connected[i]);
#if defined(HAVE_DOMHEX) and defined(HAVE_HXT)   // experimental hex mesh
    ... Filler/Filler3D::treat_region(); meshCombine3D(gr); RelocateVertices(...);
#endif
  }

MeshDelaunayVolume(connected[i]) 对一个连通分量整体做 Delaunay 体网格(第 6 章会展开 meshGRegionDelaunay/MeshDelaunayVolume 的细节)。之后是实验性的 DOMHEX(六面体主导)路径 : 仅当编译了 HAVE_DOMHEX 且 HAVE_HXT、三维算法为 ALGO_3D_RTREE 时启用;用 Filler/Filler3D 尝试"填充"一个区域,成功后 meshCombine3D(gr) 组合,再 RelocateVertices(gr, nbSmoothing) 做节点重定位。若走了这条路,hexDominant = true。

4.6.6 MakeHybridHexTetMeshConformalThroughTriHedron 与 setAllVolumesPositive

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

  // ensure that all volume Jacobians are positive
  m->setAllVolumesPositive();
  • MakeHybridHexTetMeshConformalThroughTriHedron :当网格是"六面体 + 四面体混合"时, 在两者交界处插入三棱锥过渡单元(trihedron,即"三棱体的锥") ,使混合网格共形 (面一致)。名字里的 "ThroughTriHedron" 就是"通过三棱锥过渡"的意思。
  • setAllVolumesPositive :遍历所有体单元,若 Jacobian 为负(即单元"翻面"), 就把它的顶点顺序反转,保证所有体单元 Jacobian 为正。这是三维网格可用性的底线。

4.6.7 EmbeddedCompatibilityTest:嵌入实体的一致性检查

三维阶段末尾(第 1046--1048 行),当 Msg::GetVerbosity() > 98 时,对每个区域跑 EmbeddedCompatibilityTest()(类定义在第 83 行)。它做的事很直白:

cpp 复制代码
class EmbeddedCompatibilityTest {
public:
  void operator()(GRegion *gr)
  {
    std::vector<GEdge *> const &e = gr->embeddedEdges();
    std::vector<GFace *> const &f = gr->embeddedFaces();
    if(e.empty() && f.empty()) return;
    std::map<MEdge, GEdge *, MEdgeLessThan> edges;
    std::map<MFace, GFace *, MFaceLessThan> faces;
    ...
    // 把嵌在体里的"应该存在的"网格边/面收集起来
    ...
    for(std::size_t k = 0; k < gr->getNumMeshElements(); k++) {
      for(int j = 0; j < gr->getMeshElement(k)->getNumEdges(); j++) {
        edges.erase(gr->getMeshElement(k)->getEdge(j));
      }
      for(int j = 0; j < gr->getMeshElement(k)->getNumFaces(); j++) {
        faces.erase(gr->getMeshElement(k)->getFace(j));
      }
    }
    if(edges.empty() && faces.empty()) {
      Msg::Info("All embedded edges and faces are present in the final mesh");
    }
    ...
    // 把剩余的(缺失的)写到文件里并报警
    sprintf(name, "missingEdgesOnRegion%d.pos", gr->tag());
    ...
  }
};

算法:先把区域嵌入的边和面 (embeddedEdges()/embeddedFaces(),第 2 章 2.11 节) 应当存在的一维/二维网格单元收集成 MEdge/MFace 集合;然后遍历体网格单元的每条边/每个面 , 用 edges.erase(getEdge(j))/faces.erase(getFace(j)) 把"实际存在于体网格里"的那些删掉; 剩下的就是"本应嵌入但缺失"的边/面 。缺失时序列化到 missingEdgesOnRegion%d.pos/missingFacesOnRegion%d.pos 并报警;全部存在时打印 "All embedded edges and faces are present in the final mesh"。(完整实现见 Generator.cpp 第 83 行 的 EmbeddedCompatibilityTest。)

4.7 OptimizeMesh 全方法表

OptimizeMesh(第 1062 行)是网格质量的"统一入口",通过字符串 how 分派到 20 余种方法。 它先做一次白名单校验,防止拼错方法名:

cpp 复制代码
void OptimizeMesh(GModel *m, const std::string &how, bool force, int niter, double quality)
{
  if(CTX::instance()->abortOnError && Msg::GetErrorCount()) return;
  // white-list check: how must be "", "Gmsh", "Optimize", "Optimize2D",
  // "MesquiteImprove3D", "MesquiteImprove2D", "UntangleTris", "UntangleTets",
  // "Netgen", "HighOrder", "HighOrderElastic", "HighOrderFastCurving",
  // "Laplace2D", "Relocate2D", "Relocate3D", "QuadCavityRemeshing",
  // "QuadQuasiStructured", "UntangleMeshGeometry", "HXT", "HXT_FlipOnly"
  ...

4.7.1 方法清单与实现要点

各分支(第 1084--1247 行)的实现要点汇总如下:

how 取值 作用对象 实现要点 依赖宏
""/Gmsh/Optimize 体 对每个 GRegion 调 optimizeMeshGRegion,再 setAllVolumesPositive ---
Optimize2D 面 仅对 Plane 面调 PolyMeshDelaunayize(gf->tag())(边交换);非平面只 Msg::Debug 提示 ---
UntangleTets 体 untangleGRegionMeshConstrained(gr, 10, 100.)(Winslow 10 次迭代、限时 100s),再 setAllVolumesPositive ---
UntangleTris 面 untangleGFaceMeshConstrained(gf) ---
MesquiteImprove2D/3D 面/体 mesquiteImprove(*it);3D 后再 setAllVolumesPositive HAVE_MESQUITE(隐式)
Netgen 体 optimizeMeshGRegionNetgen,再 setAllVolumesPositive ---
HighOrder 全 组装 OptHomParameters(hoNLayers/hoThresholdMin/hoThresholdMax/hoIterMax/hoPassMax/hoFixBndNodes/hoPrimSurfMesh/hoDistCAD)后调 HighOrderMeshOptimizer HAVE_OPTHOM
HighOrderElastic 全 HighOrderMeshElasticAnalogy(m, false)(弹性类比,抗畸变) HAVE_OPTHOM
HighOrderFastCurving 全 组装 FastCurvingParameters(maxNumLayers/maxRho/maxAngle/hoCurveOuterBL)后调 HighOrderMeshFastCurving HAVE_OPTHOM
Laplace2D 面 对每个面 laplaceSmoothing(gf, niter)(Laplace 光滑) ---
Relocate2D 面 RelocateVertices(gf, niter) ---
Relocate3D 体 RelocateVertices(gr, niter) ---
QuadCavityRemeshing 面 面状态重置为 PENDING → transferSeamGEdgesVerticesToGFace → optimizeTopologyWithCavityRemeshing → 恢复 DONE HAVE_QUADMESHINGTOOLS(间接)
QuadQuasiStructured 面 同上,但中段是 quadMeshingOfSimpleFacesWithPatterns + optimizeTopologyWithCavityRemeshing HAVE_QUADMESHINGTOOLS
UntangleMeshGeometry 面+体 平面用 untangleGFaceMeshConstrained,体用 untangleGRegionMeshConstrained,受 meshOnlyVisible 限制 ---
HXT 体 optimizeMeshHXT(m, quality, false) HAVE_HXT
HXT_FlipOnly 体 optimizeMeshHXT(m, quality, true)(只做翻边) HAVE_HXT

几个代表性分支(完整实现见 Generator.cpp 第 1084--1247 行):

cpp 复制代码
  if(how == "" || how == "Gmsh" || how == "Optimize") {
    for(auto it = m->firstRegion(); it != m->lastRegion(); it++) { optimizeMeshGRegion opt; opt(*it, force); }
    m->setAllVolumesPositive();
  }
  else if(how == "Optimize2D") {          // 仅对平面做边交换
    for(GFace *gf : m->getFaces())
      if(gf->geomType() == GFace::Plane) PolyMeshDelaunayize(gf->tag());
  }
  else if(how == "UntangleTets") {        // Winslow 10 iter, 限时 100s
    for(GRegion *gr : m->getRegions()) untangleGRegionMeshConstrained(gr, 10, 100.);
    m->setAllVolumesPositive();
  }

而高阶分支展示了参数如何从 CTX 组装:

cpp 复制代码
  else if(how == "HighOrder") {
#if defined(HAVE_OPTHOM)
    OptHomParameters p;
    p.nbLayers = CTX::instance()->mesh.hoNLayers;       // Mesh.HighOrderNumLayers
    p.BARRIER_MIN = CTX::instance()->mesh.hoThresholdMin;
    p.BARRIER_MAX = CTX::instance()->mesh.hoThresholdMax;
    p.optCAD = CTX::instance()->mesh.hoDistCAD;
    ...                                                 // hoIterMax/hoPassMax/hoFixBndNodes/...
    HighOrderMeshOptimizer(m, p);
#else
    Msg::Error("High-order mesh optimization requires the OPTHOM module");
#endif
  }

HAVE_OPTHOM 未编译时的报错是一条重要的"能力缺失"提示。UntangleMeshGeometry 分支还演示了 如何尊重 meshOnlyVisible:

cpp 复制代码
  else if(how == "UntangleMeshGeometry") {
    int nIterWinslow = 10;
    for(GFace *gf : m->getFaces()) {
      if(CTX::instance()->mesh.meshOnlyVisible && !gf->getVisibility()) continue;
      if(gf->geomType() == GFace::Plane) untangleGFaceMeshConstrained(gf);
      else Msg::Debug("- Surface %i: not planar, do not apply Winslow untangling", gf->tag());
    }
    for(GRegion *gr : m->getRegions()) {
      if(CTX::instance()->mesh.meshOnlyVisible && !gr->getVisibility()) continue;
      untangleGRegionMeshConstrained(gr, nIterWinslow, 100.);
    }
  }

函数末尾(第 1250--1256 行)在 Verbosity > 98 时对每个区域跑 EmbeddedCompatibilityTest, 并打印耗时。

4.7.2 方法选择与配置的对应

哪些方法在什么时机被自动调用?回顾 4.1.4 节:

  • 三维通用优化 :Mesh.Optimize(默认 1,见 src/common/DefaultOptions.h 第 1556 行)与 Mesh.OptimizeNetgen(默认 0,第 1560 行)交替循环,即默认执行一次 Gmsh 优化;
  • 高阶优化 :Mesh.HighOrderOptimize(默认 0,第 1348 行)取 1/2/3/4 分别对应 HighOrder/HighOrder+HighOrderElastic/HighOrderElastic/HighOrderFastCurving;
  • 二维四边形收尾 :QUAD_QUASI_STRUCT 自动调 QuadQuasiStructured,PACK 自动调 UntangleTris;
  • Optimize2D:只在进行"边交换"时用,本文的自动流程不直接调用。

用户/脚本可显式调用 Optimize "Method" 或用 API gmsh::model::mesh::optimize("Method") 触发任意方法。

4.7.3 AdaptMesh 与 RecombineMesh

除了 OptimizeMesh,还有两个独立的适配/重组合入口:

cpp 复制代码
void AdaptMesh(GModel *m) { /* 迭代 10 次 */ for(int i=0;i<10;i++)
  std::for_each(m->firstRegion(), m->lastRegion(), adaptMeshGRegion()); }

AdaptMesh(第 1259 行)对每个体迭代 10 次 adaptMeshGRegion------这是"网格自适应"的入口: 通常配合 PostView 尺寸场或误差估计使用(见 4.12.4 节)。

cpp 复制代码
void RecombineMesh(GModel *m)
{
  for(GFace *gf : m->getFaces()) {
    if(CTX::instance()->mesh.algoRecombine == 4)
      meshGFaceQuadrangulateBipartiteLabelling(gf->tag());   // 二部图标注
    else
      recombineIntoQuads(gf, /*blossom=*/(algoRecombine==1||algoRecombine==3),
                         recombineOptimizeTopology, recombineNodeRepositioning,
                         recombineMinimumQuality);           // blossom 完美匹配
  }
}

RecombineMesh(第 1274 行)把三角网格重组合成四边形网格,两种路线:

  • algoRecombine == 4:二部图标注(bipartite labelling) ------meshGFaceQuadrangulateBipartiteLabelling, 对应脚本 Recombine Surface {...} 的高级模式,也是 4.4.6 节奇偶性逻辑服务的目标;
  • 其它:recombineIntoQuads(gf, blossom, topo, repos, minqual)------经典的"blossom 完美匹配"路线, blossom 由 algoRecombine 是否为 1/3 决定,topo 控制拓扑优化、repos 控制节点重定位、 minqual 是最小质量阈值。

algoRecombine 的取值含义由 src/common/DefaultOptions.h 第 1679/1681 行给出:1 是"Blossom(全四边形)"、2 是"简单全四边形"、3 是"Blossom + 重定位"、 4 是"二部图标注"。注意:2 与 4 正是在 4.4.6 节触发奇数段数约束的两个值。

4.8 FixPeriodicMesh:周期网格的重建

FixPeriodicMesh(第 1413 行)是周期网格的"体检与修复"函数。它在 GenerateMesh 头尾各调一次, 也在 RefineMesh 里调用。它分两大部分:边(GEdge)与面(GFace)。

4.8.1 边:通过 srcEdges 重建周期对应

cpp 复制代码
void FixPeriodicMesh(GModel *m)
{
  ...
  for(auto it = m->firstEdge(); it != m->lastEdge(); ++it) {
    GEdge *tgt = *it;
    if(tgt->vertexCounterparts.empty()) continue;  // incomplete periodic info
    GEdge *src = dynamic_cast<GEdge *>(tgt->getMeshMaster());
    if(src != nullptr && src != tgt) {
      std::map<MVertex *, MVertex *> &v2v = tgt->correspondingVertices;
      std::map<MVertex *, MVertex *> &p2p = tgt->correspondingHighOrderVertices;
      p2p.clear();
      Msg::Info("Reconstructing periodicity for curve connection %d - %d", tgt->tag(), src->tag());

      std::map<MEdge, MLine *, MEdgeLessThan> srcEdges;   // master lines by edge
      for(...) srcEdges[MEdge(srcLine->getVertex(0), srcLine->getVertex(1))] = srcLine;

      for(std::size_t i = 0; i < tgt->getNumMeshElements(); ++i) {
        MLine *tgtLine = ...;
        MVertex *vtcs[2];
        for(int iVtx = 0; iVtx < 2; iVtx++) {
          auto tIter = v2v.find(tgtLine->getVertex(iVtx));
          if(tIter == v2v.end()) { Msg::Error("Cannot find periodic counterpart of node %d ..."); return; }
          else vtcs[iVtx] = tIter->second;
        }
        auto srcIter = srcEdges.find(MEdge(vtcs[0], vtcs[1]));
        if(srcIter == srcEdges.end()) { Msg::Error("Can't find periodic counterpart of mesh edge ..."); return; }
        else  // pair the high-order nodes by position
          for(std::size_t j = 2; j < tgtLine->getNumVertices(); j++)
            p2p[tgtLine->getVertex(j)] = srcIter->second->getVertex(j);
      }
    }
  }

逻辑:对每条"从边" tgt:

  1. 用 srcEdges 把主边 src 的所有网格线 按 MEdge(两个顶点)建索引;
  2. 对从边的每条网格线,用已有的 v2v(correspondingVertices)把它的两个顶点映射到主边顶点;
  3. 用映射后的顶点对 MEdge(vtcs[0], vtcs[1]) 在 srcEdges 里查主边的对应线;
  4. 找到后,把高阶节点 (i >= 2 的顶点,即不是端点的节点)按位置配对写入 p2p (correspondingHighOrderVertices)。

失败时报"Cannot find periodic counterpart of node ... / mesh edge ...",这是周期网格不一致的 典型错误。p2p.clear() 表示"高阶对应每次重建"。

4.8.2 面:通过 srcFaces 重建,含两套高阶匹配

面的逻辑与边同构,但更复杂(第 1488--1592 行)。用 srcFaces(主面的每个三角形/四边形按 MFace 建索引)查找从面单元的对应;若主从面法向相反(dot(tgtFace.normal(), srcFace.normal()) < 0)则 先 srcElmt->reverse()、匹配后还原;高阶节点有两套匹配------顶点顺序一致 时按下标配对, **独立生成(transfinite/extrude)**时用 transformed(src, tgt->affineTransform).distance(tgt->point()) 取最近点。关键片段:

cpp 复制代码
          bool revert = dot(tgtFace.normal(), srcIter->first.normal()) < 0;
          if(revert) srcElmt->reverse();
          if(sameCorners)  // 顶点顺序一致:按下标配对高阶节点
            for(j = nbVtcs; j < srcElmt->getNumVertices(); j++)
              p2p[tgtElmt->getVertex(j)] = srcElmt->getVertex(j);
          else             // 独立生成:仿射变换 + 最近点匹配
            for(j = nbVtcs; j < tgtElmt->getNumVertices(); j++) { ... 取 transformed 距离最小者 ... }
          if(revert) srcElmt->reverse();

三个关键机制:

  1. srcFaces 索引 :把主面的每个三角形/四边形按 MFace(三个或四个顶点)建索引; 从面的每个单元用 v2v 映射顶点后在该索引里查找对应单元。
  2. 方向反转(revert) :bool revert = dot(tgtFace.normal(), srcIter->first.normal()) < 0; 若主从面的法向相反(几何面本身朝向不同),先 srcElmt->reverse() 反转主单元, 匹配完再转回来。这可能是 Gmsh 里最容易出错的角落之一 ------源码里专门写了 // Warning: this check is made ... 提醒读者,因为"主从面朝向不同"是工程中很常见的 情形(例如为了与几何面自身的朝向一致)。reverse() 只对本次匹配生效,所以结束后 又 if(revert) srcElmt->reverse(); 还原,避免污染主面网格。
  3. 两套高阶匹配 :
    • sameCorners(两套网格顶点顺序一致) :直接按下标 配对高阶节点 p2p[tgtElmt->getVertex(j)] = srcElmt->getVertex(j);
    • 否则(独立生成,如 transfinite 或 extrude) :用仿射变换 + 最近点 匹配------ 对从面的每个高阶节点,遍历主单元的所有高阶节点,取 transformed(srcVertex, tfo).distance(tgtVertex->point()) 最小者作为对应。 这正是 4.8.3 节 transformed 函数(第 1404 行)的用途。

4.8.3 relocateSlaveVertices 与高阶周期

在边/面重建之间与之后,当 CTX::instance()->mesh.hoPeriodic 非零时,会调用 relocateSlaveVertices(重载版本,第 1363 行)来把从边/从面的节点实际搬到主实体变换后的位置:

cpp 复制代码
static void relocateSlaveVertices(GFace *slave,
                                  std::map<MVertex *, MVertex *> &vertS2M,
                                  bool useClosestPoint)
{
  for(auto vit = vertS2M.begin(); vit != vertS2M.end(); ++vit) {
    MFaceVertex *v = dynamic_cast<MFaceVertex *>(vit->first);
    if(v && v->onWhat() == slave) {
      SPoint3 p = transform(vit->second, slave->affineTransform);
      SPoint2 p2;
      if(useClosestPoint) {              // guess + closestPoint (exact)
        double guess[2];
        v->getParameter(0, guess[0]); v->getParameter(1, guess[1]);
        GPoint pp = slave->closestPoint(p, guess);
        p2.setPosition(pp.u(), pp.v());
      }
      else p2 = slave->parFromPoint(p);
      GPoint gp = slave->point(p2);
      v->setXYZ(gp.x(), gp.y(), gp.z());
      v->setParameter(0, gp.u()); v->setParameter(1, gp.v());
    }
  }
}

调度的重载版本(第 1363 行)先建 multimap<GEntity*, GEntity*> master2slave(跳过 0 维), 再对每对"主-从"分别对 correspondingVertices 与 correspondingHighOrderVertices 调用上面的 单实体版本(从边另有 GEdge* 版本,逻辑对称)。

要点:从节点 v 的目标位置由主节点经从面仿射变换 得到(transform(vit->second, slave->affineTransform)); 再用两种方式之一把它投回从面的参数域:

  • useClosestPoint(hoPeriodic > 1):用初值猜测 + closestPoint 精确投影;
  • 否则:直接 parFromPoint(p) 求参数。

最后 setXYZ/setParameter 更新节点。这里体现了一个重要区别:普通周期只需"节点对应", 而高阶周期(Mesh.HighOrderPeriodic)还要"把从节点挪到主节点变换后的位置" ,因为高阶节点的 几何位置必须真正落在从面上(否则单元畸变)。这也是为什么 relocateSlaveVertices 被放在 FixPeriodicMesh 里、且受 hoPeriodic 控制。

至此,GenerateMesh 的全流程------从可重入锁、四阶段调度、一维到三维、优化与周期修复------已经走完。

4.9 尺寸场系统:Field

网格的"疏密"由尺寸场(Field)决定。这是 Gmsh 中最具扩展性的子系统之一,核心在 src/mesh/Field.h。

4.9.1 Field 抽象与三重 operator()

字段是抽象基类(Field.h 第 103 行起),核心接口如下(_deprecated/isDeprecated 用于废弃选项):

cpp 复制代码
void ensureUpdated();                 // 惰性更新(见 4.9.2)
int id;
std::map<std::string, FieldOption *> options;      // 可配置选项
std::map<std::string, FieldCallback *> callbacks;  // 可触发回调
virtual int numComponents() const;                 // 1 标量 / 3 向量 / 9 张量
virtual bool isotropic() const { return true; }
virtual double operator()(double x, double y, double z, GEntity *ge = nullptr) = 0;   // 各向同性
virtual void operator()(double x, double y, double z, SVector3 &, GEntity *ge = 0) {} // 向量
virtual void operator()(double x, double y, double z, SMetric3 &, GEntity *ge = nullptr) {} // 各向异性
bool updateNeeded;
virtual const char *getName() = 0;
FieldOption *getOption(const std::string &optionName);

(Field.h 第 103 行起。)设计亮点是同一个 operator() 有三个重载 :标量(各向同性尺寸)、 SVector3(向量场)、SMetric3(各向异性度量张量)。isotropic() 声明自己属于哪一类, 网格算法据此走不同路径(如 4.4.3 节一维的 F_Lc 与 F_Lc_aniso)。numComponents() 返回 分量数(1 = 标量,3 = 向量,9 = 张量),PostViewField 用它区分标量/向量/张量视图。

三个方法构成"惰性求值"协议:

  • updateNeeded:脏标记。字段依赖的实体/网格/选项变了就置位;
  • update():重算内部数据(建 KD 树、八叉树、解析表达式...);
  • ensureUpdated():在求值前确保已更新。

4.9.2 ensureUpdated:递归互斥锁

cpp 复制代码
static std::recursive_mutex fieldUpdateMutex;

void Field::ensureUpdated()
{
  // checked again under the lock, which is recursive as an update can
  // evaluate other fields (OctreeField)
  if(!updateNeeded) return;
  std::lock_guard<std::recursive_mutex> lock(fieldUpdateMutex);
  if(updateNeeded) update();
}

(Field.cpp 第 104--113 行。)双重检查(先无锁快速返回,再在锁内检查)+ 递归互斥锁 。注释点明为何必须可重入:"an update can evaluate other fields (OctreeField)"------ 字段 A 的 update() 可能求值字段 B,B 又求值 A,普通 mutex 会死锁。

为什么"字段求值"需要加锁?因为在网格化阶段,同一字段会被多个 OpenMP 线程并发求值 (例如二维并行时多个面同时查询背景尺寸场)。update() 若在此时运行会修改内部结构(KD 树), 产生竞态。解决办法是把 update() 提前到串行阶段 :Mesh0D/Mesh1D/Mesh2D/Mesh3D 开头都调用 m->getFields()->initialize(),而 FieldManager::initialize() 就是逐个调 update():

cpp 复制代码
void FieldManager::initialize()
{
  auto it = begin();
  for(; it != end(); ++it) it->second->update();
}

(第 3292 行。)串行预热 + 并行只读 ,这是 Gmsh 尺寸场线程安全的核心策略; ensureUpdated() 只是给"网格化之外的求值"(API、GUI)兜底。

4.9.3 FieldOption 类型体系

每个字段用 options["Name"] = new FieldOptionXxx(成员引用, help, &status, deprecated) 把自己的成员变量暴露给脚本/API。基类(第 50 行):

cpp 复制代码
class FieldOption {
protected:
  bool *status;                       // 指向字段的 updateNeeded(可选)
  inline void modified() { if(status) *status = true; }
public:
  FieldOption(const std::string &help, bool *_status, bool deprecated);
  virtual FieldOptionType getType() = 0;
  virtual void numericalValue(double val) {}
  virtual double numericalValue() const { return 0.; }
  virtual const std::list<int> &list() const { ... }
  virtual std::string string() const { return ""; }
  std::string getTypeName();          // "integer"/"float"/"boolean"/"path"/"string"/"list"/"list_double"
};

类型枚举 FieldOptionType(第 29 行):FIELD_OPTION_DOUBLE/INT/STRING/PATH/BOOL/LIST/LIST_DOUBLE。 七个具体子类(FieldOptionDouble 第 295 行、FieldOptionInt 第 319 行、FieldOptionString 第 272 行、FieldOptionPath 第 399 行、FieldOptionBool 第 409 行、FieldOptionList 第 342 行、 FieldOptionListDouble 第 370 行)各持一个引用 (如 double &val;),所以设置选项就是直接 写进字段成员:

cpp 复制代码
class FieldOptionDouble : public FieldOption {
public:
  double &val;
  FieldOptionType getType() { return FIELD_OPTION_DOUBLE; }
  void numericalValue(double v) { modified(); val = v; }   // 直接写成员
  ...
};

关键点 :modified() 把 *status(通常就是字段的 updateNeeded)置位。所以 Field[2].F = "..." 或 Field[1].VIn = 0.1 一改,字段就被标记为"需要重算"------ 这就是"改一个选项 → 惰性重建"的闭环。只有需要重建的选项(如 MathEval 的表达式字符串、 Distance 的标签列表)才传 &updateNeeded;纯数值选项传 nullptr(不触发重算)。

_deprecated 标记已废弃的选项名(如 IField/LcMin/LcMax → 新名 InField/SizeMin/SizeMax), GUI/脚本仍能设置,但会提示。FieldCallback/FieldCallbackGeneric(第 39/432 行)则提供 "选项设置后触发某个方法"的能力(如重建后打印统计)。

4.9.4 FieldManager 与注册表

cpp 复制代码
class FieldManager : public std::map<int, Field *> {
private:
  int _backgroundField;
  std::vector<int> _boundaryLayerFields;
public:
  std::map<std::string, FieldFactory *> mapTypeName;   // 类型名 -> 工厂
  void initialize();                                   // 逐个 update(),串行预热
  Field *get(int id);
  Field *newField(int id, const std::string &type_name);
  void deleteField(int id);
  int newId();
  ...
  inline int getBackgroundField() { return _backgroundField; }
  inline int getNumBoundaryLayerFields() { return (int)_boundaryLayerFields.size(); }
  inline int getBoundaryLayerField(int i) { return _boundaryLayerFields[i]; }
};

(第 149 行。)FieldManager 继承 std::map<int, Field*> :本身就是一个"id → 字段"的映射, 外加三样东西:mapTypeName(类型名 → 工厂的注册表)、_backgroundField(当前背景场 id)、 _boundaryLayerFields(边界层场 id 列表)。

newField(Field.cpp 第 85 行)是创建入口:

cpp 复制代码
Field *FieldManager::newField(int id, const std::string &type_name)
{
  if(find(id) != end()) { Msg::Error("Field id %i is already defined", id); return nullptr; }
  if(mapTypeName.find(type_name) == mapTypeName.end()) {
    Msg::Error("Unknown field type \"%s\"", type_name.c_str()); return nullptr;
  }
  Field *f = (*mapTypeName[type_name])();
  if(!f) return nullptr;
  f->id = id;
  (*this)[id] = f;
  // the fields that refer to others by id resolve them again
  for(auto &it : *this) it->second->updateNeeded = true;
  return f;
}

注意最后一行:任何新字段的加入都会让所有已有字段 标记为 updateNeeded------因为其他字段 可能通过 InField/FieldsList 引用这个新 id。这种"宁可多算、不做依赖图 "的策略简单且正确。 deleteField(第 135 行)做同样的事(删除后让所有字段失效)。

newId(第 115 行)分配"最小的未用正整数":因为是 std::map(按 key 有序), 扫描是线性的,且总是复用被删掉的小 id ;return std::max(i, 1) 保证不小于 1。 FieldManager 的默认背景场 id 是 -1(表示"没有背景场")。

4.9.5 背景场、边界层场与 -bgm 兼容

_backgroundField 是"哪一个是当前背景尺寸场"的 id。脚本 Background Field = 2; 最终落到 setBackgroundFieldId(2);而 Background Mesh = iView;(老语法/-bgm)落到:

cpp 复制代码
void FieldManager::setBackgroundMesh(int iView)
{
  int id = newId();
  Field *f = newField(id, "PostView");
  f->options["ViewIndex"]->numericalValue(iView);
  (*this)[id] = f;
  _backgroundField = id;
}

(第 3364 行。)即:-bgm/Background Mesh 其实是"用后处理视图当背景场"的语法糖 ------ 它新建一个 PostView 字段并置为背景场。这就是 4.11 节 createSizeFieldFromExistingMesh 与 PostViewField 的用武之地。

_boundaryLayerFields 是一个 id 列表 (不是单个背景场),因为可以同时有多个边界层场; addBoundaryLayerFieldId 去重后追加。getNumBoundaryLayerFields() 的结果被 1D/2D/3D 各阶段 用来判断"是否要退化为单线程"(4.3、4.5 节)。边界层场在 4.10 节详述。

setBackgroundField(Field *BGF)(第 3305 行)则把已有字段设为背景场。

4.10 逐类 Field 详解

FieldManager 构造函数(第 3251 行)注册了30 余种字段类型。完整注册表:

cpp 复制代码
mapTypeName["Structured"] = new FieldFactoryT<StructuredField>();
mapTypeName["Threshold"] = new FieldFactoryT<ThresholdField>();
mapTypeName["BoundaryLayer"] = new FieldFactoryT<BoundaryLayerField>();
mapTypeName["Box"] = new FieldFactoryT<BoxField>();
mapTypeName["Cylinder"] = new FieldFactoryT<CylinderField>();
mapTypeName["Ball"] = new FieldFactoryT<BallField>();
mapTypeName["Frustum"] = new FieldFactoryT<FrustumField>();
mapTypeName["LonLat"] = new FieldFactoryT<LonLatField>();
#if defined(HAVE_POST)
mapTypeName["PostView"] = new FieldFactoryT<PostViewField>();
#endif
mapTypeName["Gradient"] = new FieldFactoryT<GradientField>();
mapTypeName["Octree"] = new FieldFactoryT<OctreeField>();
mapTypeName["Distance"] = new FieldFactoryT<DistanceField>();
mapTypeName["Attractor"] = new FieldFactoryT<DistanceField>();
mapTypeName["Extend"] = new FieldFactoryT<ExtendField>();
mapTypeName["Restrict"] = new FieldFactoryT<RestrictField>();
mapTypeName["Constant"] = new FieldFactoryT<ConstantField>();
mapTypeName["Min"] = new FieldFactoryT<MinField>();
mapTypeName["MinAniso"] = new FieldFactoryT<MinAnisoField>();
mapTypeName["IntersectAniso"] = new FieldFactoryT<IntersectAnisoField>();
mapTypeName["Max"] = new FieldFactoryT<MaxField>();
mapTypeName["Laplacian"] = new FieldFactoryT<LaplacianField>();
mapTypeName["Mean"] = new FieldFactoryT<MeanField>();
mapTypeName["Curvature"] = new FieldFactoryT<CurvatureField>();
mapTypeName["Param"] = new FieldFactoryT<ParametricField>();
mapTypeName["ExternalProcess"] = new FieldFactoryT<ExternalProcessField>();
mapTypeName["MathEval"] = new FieldFactoryT<MathEvalField>();
mapTypeName["MathEvalAniso"] = new FieldFactoryT<MathEvalFieldAniso>();
mapTypeName["AttractorAnisoCurve"] = new FieldFactoryT<AttractorAnisoCurveField>();
mapTypeName["MaxEigenHessian"] = new FieldFactoryT<MaxEigenHessianField>();
#if defined(HAVE_OCTREE_SIZE_FIELD)
mapTypeName["AutomaticMeshSizeField"] = new FieldFactoryT<OctreeSizeField>();
#endif
_backgroundField = -1;

FieldFactoryT<F>(Field.h 第 447 行)就是 new F 的工厂: virtual Field *operator()() { return new F; }。这张表本身就是 Gmsh 尺寸控制的"能力目录"。 按功能归类(并标注实现行号与是否各向同性):

类别 类型 各向同性 作用 Field.cpp 行
常量/区域 Constant 是 区域内 VIn、区域外 VOut 2100
Box 是 盒内 VIn、盒外 VOut、Thickness 过渡带 353
Cylinder 是 圆柱(含斜轴)内外 442
Ball 是 球内外 + 过渡带 501
Frustum 是 圆台(可沿轴插值) 552
LonLat 是 经纬度网格(地球) 300
Structured 是 从结构化数据文件/内存插值 149
距离/吸引子 Distance/Attractor 是 到点/线/面的最近距离(采样 + KD 树) 2487
AttractorAnisoCurve 否 到曲线的各向异性尺寸(切向/法向不同) 2163
组合/过滤 Threshold 是 距离 → 尺寸的分段/线性/Sigmoid 映射 651
Min/Max 是 多字段取最小/最大 1860/1909
MinAniso/IntersectAniso 否 各向异性度量取交(两种算法) 1729/1791
Restrict/Constant 是 限制到给定实体集合 2027/2100
Extend 是 由边界尺寸向内部延拓(调和加权) 2659
表达/数据 MathEval/MathEvalAniso 是/否 用户数学/度量表达式 1164/1199
Param 是 参数化自变量后再求值(F(FX,FY,FZ)) 1513
PostView 视情况 从后处理视图采样(标量/向量/张量) 1573
ExternalProcess 是 调用外部进程(管道传坐标) 1399
微分类 Gradient/Laplacian 是 有限差分梯度/拉普拉斯 728/913
Curvature 是 div(norm(grad(G))) 793
MaxEigenHessian 是 Hessian 最大特征值(误差指示) 849
Mean 是 邻域均值平滑 956
加速/自适应 Octree 是 把另一字段预计算到八叉树(加速求值) 2297
AutomaticMeshSizeField 是 从当前网格自动构建尺寸场(OctreeSizeField) 头文件
边界层 BoundaryLayer 否 壁面附近各向异性边界层 2847

以下逐类介绍代表实现。

4.10.1 BoxField:VIn/VOut 与过渡带

BoxField(第 353 行)用 computeDistance 算点到盒的距离,在盒内取静尺寸、盒外取粗尺寸、 可选过渡带:

cpp 复制代码
double operator()(double x, double y, double z, GEntity *ge = nullptr)
{
  // inside
  if(x >= _xMin && x <= _xMax && y >= _yMin && y <= _yMax && z >= _zMin && z <= _zMax)
    return _vIn;
  // transition layer
  if(_thick > 0) {
    double dist = computeDistance(x, y, z);
    if(dist <= _thick) return _vIn + (dist / _thick) * (_vOut - _vIn);
  }
  return _vOut;
}

computeDistance(第 392 行)把点投影到三个正交轴 nx/ny/nz 上,得到盒内投影点 psbox, 返回欧氏距离------即点到轴对齐盒的、可分解到各轴的距离 。选项:VIn/VOut/XMin/XMax/ YMin/YMax/ZMin/ZMax/Thickness。这就是脚本 Field[1] = Box; Field[1].VIn = 0.1; 的底层。 Cylinder/Ball/Frustum 同理:Cylinder 把点分解到轴向与径向;Ball 用球心距; Frustum(第 552 行)先做轴向区间判断 if(!(u >= 0 && u <= 1)) return MAX_LC;, 再按内/外半径插值------注释说明"沿轴排列的数百个圆台(喷管)大多落在轴向区间外, 先判轴向能显著加速"。

4.10.2 ThresholdField:距离 → 尺寸的标准构件

ThresholdField(第 651 行)把任意"距离场"转化为尺寸场,是脚本里 Field[2] = Threshold; Field[2].InField = 1; 的底层:

选项:InField(输入场 id,通常是距离)、DistMin/DistMax(分界距离)、 SizeMin/SizeMax(两端尺寸)、Sigmoid(用 logistic 而非线性插值)、 StopAtDistMax(超出 DistMax 直接返回 MAX_LC,即"不再约束")。 求值逻辑:d = (*InField)(x,y,z) → 归一化 r = clamp((d-DistMin)/(DistMax-DistMin), 0, 1) → lc = SizeMin*(1-r) + SizeMax*r(或用 exp(12r-6)/(1+exp(12r-6)) 做 sigmoid)。 注意开头 if(_inField == id) return MAX_LC;:防止字段自我引用(自环保护,各组合类字段都有)。

4.10.3 DistanceField / Attractor:KD 树的最近距离

DistanceField(第 2487 行)把点/线/面离散成点云,建 KD 树,求最近距离:

cpp 复制代码
// 求值:nanoflann KNN,取最近点的 outDistSqr,返回 sqrt(outDistSqr)
virtual double operator()(double X, double Y, double Z, GEntity *ge = nullptr)
{ ensureUpdated(); if(!_kdtree) return MAX_LC; ... _kdtree->findNeighbors(res, &pt[0], ...);
  _outIndex.store(index, std::memory_order_relaxed); return sqrt(outDistSqr); }

update()(第 2565 行)建点云:点直接取坐标;曲线用已有网格顶点 + 按 Sampling 补采样 ; 面用 f->fillPointCloud(maxDist, &_pc.pts, &uvpoints)(maxDist = bounds().diag()/Sampling)。 最后 new SPoint3KDTree(3, _pc2kdtree, ...) + buildIndex()。选项:PointsList/CurvesList/ SurfacesList(注意面只支持 OCC 与离散面 )、Sampling。_outIndex 是 atomic, 因为求值可能并行;getAttractorInfo() 返回"最近点的实体/参数",供边界层的各向异性构造用。 Attractor 是 Distance 的同名别名(注册表里两者都指向 DistanceField)。 AttractorAnisoCurveField(第 2163 行)是它的各向异性版:沿曲线采样点与切向 _tg, 返回 SMetric3(1/lTg², 1/lN², 1/lN², t, n0, n1)------沿切向用 SizeMinTangent/SizeMaxTangent、 法向用 SizeMinNormal/SizeMaxNormal。

4.10.4 MathEvalField:表达式求值

MathEvalField(第 1164 行)用 mathEvaluator 求值用户表达式,支持 x/y/z 与 F0/F1/... (引用其它字段):表达式字符串里出现 F<数字> 时被自动收集进 _fields 集合 (MathEvalExpression::set_function,第 1012 行),运行时把这些字段的值填进变量表。 update()(第 1176 行)解析表达式并置 updateNeeded = false;求值前 ensureUpdated()。 MathEvalAniso(第 1199 行)则用 6 个表达式分别给度量张量的 M11/M22/M33/M12/M13/M23。 ParametricField(Param,第 1513 行)先用三个表达式把 (x,y,z) 映射到 (FX,FY,FZ), 再用它去求值 Field[InField]------即"在参数空间里查尺寸"。

4.10.5 PostViewField:从后处理视图采样(自适应的入口)

PostViewField(第 1573 行)从后处理视图(PView)采样。它按视图的分量数决定行为: numComponents()==3(向量)取范数;==1(标量)直接取;张量时 numComponents()==9 且 isotropic() 返回假,走 operator()(_, SMetric3&, _)(第 1696 行),把 9 个分量填进度量。

cpp 复制代码
// 按视图分量数分支:3 -> 向量取范数;1 -> 标量直取;9 -> 走 SMetric3 重载
if(numComponents() == 3) l = sqrt(values[0]² + values[1]² + values[2]²);
else if(numComponents() == 1) v->getData()->searchScalarClosest(x, y, z, dist, &l, 0);
if(l <= 0 && _cropNegativeValues) return MAX_LC;   // 负值 -> 不约束

选项:ViewIndex/ViewTag(视图索引/标签)、CropNegativeValues(负值返回 MAX_LC, 为兼容旧的 BackgroundMesh 选项)、UseClosest(找不到精确匹配时用最近节点值)。 getView()(第 1605 行)还检查"不能拿基于当前网格的视图当背景网格"------因为网格马上会被重做, 视图会失效;提示用户改用基于列表的 .pos 视图。

这就是自适应网格的入口 :求解器算误差 → 写成视图 → PostView 当背景场 → 重新生成网格。

4.10.6 CurvatureField 与微分类:把曲率转成尺寸

CurvatureField(第 793 行)计算 div(norm(grad(G))):

cpp 复制代码
// 对邻域单位梯度做有限差分求散度:
// return (grad[0][0]-grad[1][0] + grad[2][1]-grad[3][1] + grad[4][2]-grad[5][2]) / _delta;

(完整实现见 Field.cpp 第 793 行。)

即对邻域单位梯度做有限差分求散度,得到"曲率"标量------用于"曲率大处自动加密 "。 GradientField(第 728 行)是纯梯度(Kind 选 X/Y/Z/范数);LaplacianField(第 913 行) 是六点差分减中心;MaxEigenHessianField(第 849 行)组装 Hessian(对角用二阶中心差分、 非对角用"四点混合差分"),eigenvalue(mat, eig) 后返回最大特征值------这是误差指示器 的常用形式。 MeanField(第 956 行)是六邻居 + 自身的均值(/7),常用于平滑尺寸分布。 这些"微分类"字段都依赖 Delta(有限差分步长,默认 CTX::instance()->lc / 1e4)。

注意:尺寸场系统还有一条"几何曲率"路线 ,不在字段里,而在 BackgroundMeshTools.cpp 的 LC_MVertex_CURV/LC_MVertex_CURV_ANISO(4.11.7 节)------ 由选项 Mesh.MeshSizeFromCurvature 控制,直接从几何面的主曲率 gf->curvatureMax/ gf->curvatures 算尺寸。字段版的 Curvature 是对另一个尺寸场求曲率,两者用途不同。

4.10.7 BoundaryLayerField:各向异性边界层

BoundaryLayerField(Field.h 第 195 行声明,Field.cpp 第 2847 行构造)是唯一"同时用于 各向异性网格化与边界层挤出"的字段。核心选项(第 2860--2908 行):

选项 成员 含义
CurvesList _curveTags 需要边界层的曲线 tag
PointsList _pointTags 边界层结束点 tag
FanPointsList/FanPointsSizesList _fanPointTags/_fanSizes 扇形(fan)点及其单元数
Size/SizesList hWallN/_hWallNNodes 法向首层高度(全局/逐点)
Ratio ratio 相邻层尺寸比(几何增长)
SizeFar hFar 远场尺寸
Thickness thickness 边界层最大总厚度
Quads iRecombine 边界层内是否生成四边形
IntersectMetrics iIntersect 是否对所有面的度量取交
AnisoMax tgtAnisoRatio 生成扇形单元的阈值角
BetaLaw/Beta/NbLayers betaLaw/beta/nb_divisions 用 beta 律代替几何级数
ExcludedSurfacesList _excludedSurfaceTags 不构造边界层的面

标量求值(第 3048 行)返回"距壁面的层尺寸":

cpp 复制代码
double BoundaryLayerField::operator()(double x, double y, double z, GEntity *ge)
{
  if(updateNeeded) {
    for(auto it = _pointTags.begin(); it != _pointTags.end(); ++it)
      _attFields.push_back(new DistanceField(0, *it, 100000));
    for(auto it = _curveTags.begin(); it != _curveTags.end(); ++it)
      _attFields.push_back(new DistanceField(1, *it, 300000));
    updateNeeded = false;
  }
  double dist = 1.e22;
  for(auto it = _attFields.begin(); it != _attFields.end(); ++it) {
    double cdist = (*(*it))(x, y, z);
    if(cdist < dist) dist = cdist;
  }
  if(dist > thickness * ratio) return 1.e22;
  currentDistance = dist;
  double lc = dist * (ratio - 1) + hWallN;
  return std::min(hFar, lc);
}

即:把每条边界层边/点包成 DistanceField,取最近距离 dist,尺寸 lc = dist*(ratio-1) + hWallN (推导:几何级数第 mm m 层累计厚度 = hwall (rm−1)/(r−1) h_{wall}(r^m-1)/(r-1) hwall(rm−1)/(r−1), 反解得 lc=dist(r−1)+hWalllc = dist(r-1)+hWall lc=dist(r−1)+hWall------注释第 3118--3126 行把推导写全了)。

度量求值(第 3211 行)更复杂:对每个吸引子算局部度量 localMetric,取最近的为 v; 若 iIntersect 则把所有吸引子的度量与基准度量 1/lcMax² 一一取交 (intersection_conserveM1)。operator()(DistanceField*, dist, ...)(第 3114 行) 按最近点的维度(0/1/2)分别构造度量:

  • 点 :buildMetricTangentToCurve(t1, lc_n, lc_n)(各向同性);
  • 边 :用曲率 e->curvature(u) 修正切向尺寸(oneOverD2 公式,第 3161 行), 即"沿曲线方向按曲率加密,法向保持 lc_n";
  • 面 :用主曲率 gf->curvatures(...) 得到 cmin/cmax 与主方向 dirMin/dirMax, 构造 buildMetricTangentToSurface(dirMin, dirMax, dmin, dmax, lc_n)。

setupFor1d(第 2938 行)/setupFor2d(第 2974 行)负责"只看当前边/面 "的筛选: 它们把 _curveTags/_pointTags 临时缩到与当前实体相关的子集(_curveTagsSaved 备份原集合), 并调 removeAttractors() 重建吸引子。setupFor2d 还检查"边界层只支持 2D"------ 若某条边界层边相邻于多于一个(非拉伸、非排除)面,就报 Only 2D Boundary Layers are supported。fanSize(Field.h 第 238 行)/isFanNode/isEndNode/ hWall/isEdgeBL 是给一维 createPoints(4.4.10 节)与边界层挤出用的查询接口。

4.10.8 ExtendField:由边界尺寸向内部延拓

ExtendField(第 2659 行)解决"没有背景场时,如何让内部尺寸跟着边界走":

cpp 复制代码
// F = min(SizeMax, (Sum_i w_i) / (Sum_i w_i / s_i)),  w_i = 1/d_i^2,
//     s_i = SizeBnd_i + (Ratio-1) * d_i     (带权调和平均)

选项:SurfacesList/VolumesList(要延拓到的面/体)、SizeMax(远场尺寸)、 Ratio(增长率,≥1)、IncludeEmbedded(是否含嵌入实体)。update()(第 2767 行) 用 _searchCurves(面网格化时,搜边上的线段)与 _searchSurfaces(体网格化时,搜面上的三角形) 两组 KD 树------注释指出"这些搜索树在每次网格化前由 FieldManager::initialize() 重建 "。 公式 F = min(SizeMax, Σw_i / Σ(w_i/s_i)) 是带权调和平均 ,实现"近壁细、远壁粗、增长率受限"。 这是替代"背景网格"最常用的自动尺寸方式(对应选项 Mesh.MeshSizeExtendFromBoundary)。

4.11 背景网格与 BGM

BGM_MeshSize(src/mesh/BackgroundMeshTools.cpp 第 255 行) 是所有网格器实际调用的尺寸查询函数:

cpp 复制代码
double BGM_MeshSize(GEntity *ge, double U, double V, double X, double Y, double Z)
{
  double lc = CTX::instance()->lc;                      // 默认 = 模型尺寸
  lc = std::min(lc, BGM_MeshSizeWithoutScaling(ge, U, V, X, Y, Z)); // 各来源取最小
  lc = std::max(lc, CTX::instance()->mesh.lcMin);       // 夹到 [lcMin, lcMax]
  lc = std::min(lc, CTX::instance()->mesh.lcMax);
  if(ge && ge->getMeshSizeFactor() != 1.0) lc *= ge->getMeshSizeFactor();
  return lc * CTX::instance()->mesh.lcFactor;           // 全局尺寸因子
}

BGM_MeshSizeWithoutScaling(第 211 行)内部取五路最小:顶点尺寸 LC_MVertex_PNTS (Mesh.MeshSizeFromPoints)、曲率尺寸 LC_MVertex_CURV(Mesh.MeshSizeFromCurvature)、 背景场 (*f)(X,Y,Z,ge)(4.9 节的 Field,仅当 getBackgroundField() > 0)、 实体尺寸 ge->getMeshSize()、边参数化尺寸 prescribedMeshSizeAtParam(U),最后过 GModel::lcCallback。"处处取最小" 是核心原则:任何来源都能"要求更细",但不能"要求更粗"。 BGM_MeshMetric(第 284 行)是各向异性版,以 SMetric3(1/lc²) 为基准,与背景场度量 intersection(l4, m0)、曲率度量 LC_MVertex_CURV_ANISO 取交。

-bgm 兼容 :FieldManager::setBackgroundMesh(iView)(4.9.5 节)把后处理视图包成 PostView 字段; [src/mesh/sizeField.cpp](../../src/mesh/sizeField.cpp) 的 createSizeFieldFromExistingMesh(第 40 行) 则是从已有网格 采样尺寸、写 size_field.pos、再 sizeMapOneWaySmoothing(triangles, sizeField, 1.2) 单向平滑------即"用粗网格引导细网格"。

src/mesh/BackgroundMesh.h 有两套背景网格结构:

  • backgroundMesh (第 43 行):二维、按面 的缓存,存尺寸 _sizes、交叉场角度 _angles、 顶点/三角形容器与 MElementOctree;set(GFace*)/unset()/current() 管理"当前面"的全局指针 (4.5.2 节每个面开始前 backgroundMesh::current()->unset() 的就是它)。
  • GlobalBackgroundMesh (第 133 行)+ global_bmeshes:全局 背景网格,供 QUAD_QUASI_STRUCT / PACK 用;importGModelMeshes(gm) 把 GModel 的边/面网格拷贝进来(四边形拆成两个三角形), 使背景网格与 GModel 网格解耦。

BuildBackgroundMeshAndGuidingField(src/mesh/meshQuadQuasiStructured.cpp 第 453 行)是 4.1.2 节前置调用的实现:按需生成三角网格(generateMeshWithSpecialParameters)、 importGModelMeshes 导入背景网格、必要时删除 GModel 网格,然后逐面并行地 构建交叉场 (每边一个单位向量)、保角缩放(每顶点一个标量)、偏移到目标四边形数,再全局提取尺寸图并做 Dijkstra 单向平滑;backgroundMeshAndGuidingFieldExists(第 880 行)判断是否已存在, quadqsCleanup(第 2349 行)清理。QuadqsContextUpdater(第 2234 行)是 RAII:构造时备份 一堆全局参数与每条边/面的 meshAttributes ,setQuadqsOptions()(第 2314 行)改成准结构四边形 所需的值(recombineAll=1、lcMin=0、lcMax=1e22、abortOnError=0...),析构时逐项恢复。

4.11.1 几何曲率路线

BackgroundMeshTools.cpp 还有一条几何曲率 路线(选项 Mesh.MeshSizeFromCurvature): LC_MVertex_CURV(第 48 行)按实体维度取最大曲率 Crv,返回 lc = 2π / Crv / ne(ne 是 "每 2π 曲率的目标单元数");LC_MVertex_CURV_ANISO(第 116 行)与 metric_based_on_surface_curvature(第 383 行)用面在 (u,v) 处的主曲率 cmax/cmin 与主方向 dirMax/dirMin 构造 SMetric3(1/λ1², 1/λ2², 1/λ3², dirMin, dirMax, Z)(λ = 2π/(|c|·ne))。 Mesh.MeshSizeFromCurvatureIsotropic(lcFromCurvatureIso)把 λ1=λ2 取最小,退化为各向同性。

4.12 各向异性与度量

各向同性尺寸场只回答"多大一个数";各向异性需要一个度量张量 SMetric3(第 1 章 STensor3 家族)。 Field::operator()(..., SMetric3&, ...) 与 isotropic()==false 就是各向异性的接口。构造工具:

  • buildMetricTangentToCurve(t, l_t, l_n)(第 169 行):切线方向用 l_t、两个法向用 l_n, 返回 SMetric3(1/l_t², 1/l_n², 1/l_n², t, b, c)(b/c 是正交补基)。
  • buildMetricTangentToSurface(t1, t2, l_t1, l_t2, l_n)(第 194 行):两个切向各用其尺寸、 法向(crossprod(t1,t2))用 l_n,并把 l_t1/l_t2 夹到 [lcMin, lcMax]。

度量取交家族 (第 5 章展开数学):intersection(m1,m2)(最粗交,BGM 与曲率用)、 intersection_conserveM1(保持 m1 主方向,边界层与 BGM 用)、intersection_alauzet(Alauzet 交, IntersectAniso 用)、intersection_conserve_mostaniso(保持更各向异性者方向,MinAniso 用)。 MinAnisoField(Field.cpp 第 1729 行)/IntersectAnisoField(第 1791 行)用后两者组合多个各向异性场; 标量求值(第 1765/1832 行)用 metr.eig(V, S, 1) 取最大特征值的 sqrt(1/S(2)) 得等效尺寸。

后处理视图驱动的自适应闭环 :求解器 → 误差估计 → 写成 PView → Field[i]=PostView(ViewIndex/Tag) 或 FieldManager::setBackgroundMesh(iView) → 背景场 → BGM_MeshMetric/BGM_MeshSize → 重新生成网格。 AdaptMesh(4.7.3 节)对每个体迭代 10 次 adaptMeshGRegion,是闭环里"反复细化"的那一步。

4.13 细分与高阶收尾

  • RefineMesh(m, linear, splitIntoQuads, splitIntoHexas) (src/mesh/meshRefine.cpp 第 465 行):先 SetOrder1(m),再 SetOrderN(m, 2, linear, /*incomplete=*/false) 生成二阶节点位置, 可选高阶优化 + FixPeriodicMesh,然后 Subdivide(*ge/*gf/*gr) 把二阶单元细分 成线性网格, 对周期面的中心节点建立对应,最后 setAllVolumesPositive。
  • BarycentricRefineMesh(m) (第 533 行):在每个三角形/四面体的重心 新建 MVertex 并一分为 3(二维)/4(三维),对应 Mesh.SubdivisionAlgorithm==3。
  • SetOrder1 (src/mesh/HighOrder.cpp 第 1553 行):setFirstOrder 替换为一阶并 deleteHighOrderVertices,skipDiscrete 跳过离散实体。
  • SetOrderN (第 1836 行):linear 为真时新节点由线性插值生成,否则落在精确几何 上; incomplete 为真只建边上的节点(8 节点四边形 / 20 节点六面体);occFastProjection 临时打开; 并行用分片容器 (nShards = 16 * nthreads)避免锁,末尾 renumberMeshCanonically 恢复确定性。

4.14 可复现性与性能

机制 位置 目的
srand(randomSeed) GenerateMesh 固定随机种子
renumberMeshCanonically Mesh1D/2D/3D、SetOrderN 消除并行编号不确定性
串行预热尺寸场 FieldManager::initialize() 并行阶段只读
自动退化单线程 边界层/拉伸/周期/全四边形 正确性优先
nIter>2 序列化 Mesh2D 自相交检测不线程安全
分片容器 SetOrderN 避免锁、保持确定性

4.15 常见问答

Q1:改了几何后网格会自动重做吗? CTX::geomChanged() 广播后会触发;GenerateMesh 的 "降维删除"清掉失效的下一层网格(4.1.3)。

Q2:Mesh 2 会重做 1D 吗? 只有当已有网格 old < 1 时才做 1D;已有 1D 网格则直接进 2D。

Q3:为什么一条边在不同 solid 里段数一致? 边网格是共享的 (GEdge::lines);全四边形算法 还会强制奇数段数(4.4.6)。

Q4:边界层为什么让网格变单线程? 边界层要先建全局光滑法向场再挤出,依赖全局状态, 尚未线程安全(4.3.1/4.5.3)。

Q5:-bgm 是什么? "用后处理视图或已有网格当背景尺寸"的入口;前者落到 PostView 字段 (4.9.5),后者见 createSizeFieldFromExistingMesh(4.11)。

Q6:为什么尺寸场不生效? 检查是否设了 Background Field(或 Background Mesh)------ 未设背景场时字段不会被查询;此外 lcMin/lcMax/lcFactor 会夹住/缩放结果。

Q7:HXT / pdel3d 为什么跳过通用优化? 它们自带优化(4.1.4)。

4.16 术语中英对照速查

英文/代码 中文 说明
GenerateMesh 网格生成总控 四阶段调度入口
PENDING/DONE/FAILED 网格状态机 实体级重试驱动
meshGEdgeProcessing 一维网格核心 定段数与点位置
primitive 原函数累积 按尺寸等分的依据
smoothPrimitive 尺寸平滑 限制相邻段长突变
transfinite 超限/映射网格 用户指定段数
boundary layer 边界层 壁面各向异性层
SMetric3 度量张量 各向异性尺寸
BGM 背景网格 尺寸来源
guiding field 引导场 交叉场 + 尺寸图
v2v/p2p 节点对应 周期网格
recombine 重组合 三角→四边形
ICN / SICN 逆条件数 质量度量

4.17 源码索引

4.18 本章小结

  1. 四阶段 + 降维删除:0D→1D→2D→3D 逐层推进,上一层变化必须删掉下一层,保证 1D ⊂ 2D ⊂ 3D。
  2. 并行模板 :PENDING/DONE/FAILED 状态机 + #pragma omp parallel for + 多轮重试 + renumberMeshCanonically;边界层/拉伸/周期/全四边形自动退化为单线程。
  3. 一维 N 由二维约束:全四边形算法强制边段数为奇数,维与维强耦合。
  4. 尺寸场是可扩展的能力目录 :Field 三重 operator();FieldManager::mapTypeName 注册 30 余种; ensureUpdated 用递归锁惰性求值,initialize() 串行预热以保证并行只读。
  5. 背景网格是高级四边形算法的地基:PACK/QUAD_QUASI_STRUCT 先建背景网格 + 引导场。
  6. 度量张量统一各向异性 :SMetric3 + buildMetricTangentTo* + intersection* 家族,把几何曲率、 边界层、后处理张量都表达为"度量取交"。
  7. 算法自带优化:HXT/pdel3d 跳过通用优化,体现"谁的算法谁负责质量"。

第 5 章将深入二维曲面网格,看 Delaunay/BDS、前沿推进、BAMG 与两种四边形算法的实现细节; 第 6 章进入三维 Delaunay 与 HXT;第 7 章展开质量优化与高阶。

相关推荐
鶴哥只手遮天1 小时前
Gmsh 源码深度解析-第 10 章 求解器框架、API 与语言绑定
算法·开源
鶴哥只手遮天1 小时前
OpenFOAM 源码深度剖析 · 第二季-02 燃烧与化学反应:`combustionModel`、化学求解器与 Arrhenius 反应率
算法·开源
鶴哥只手遮天1 小时前
Gmsh 源码深度解析-第 6 章 三维体网格算法
算法·开源
鶴哥只手遮天1 小时前
Gmsh 源码深度解析-第 3 章 脚本语言与解析器
算法·开源
鶴哥只手遮天1 小时前
OpenFOAM 源码深度剖析 · 第二季-16 工程与工具链:Windows 交叉编译、性能剖析、数据交换与测试
算法·开源
鶴哥只手遮天1 小时前
Gmsh 源码深度解析-第 5 章 二维曲面网格算法
算法·开源
鶴哥只手遮天1 小时前
Gmsh 源码深度解析-第 1 章 架构总览与数据模型
算法·开源
鶴哥只手遮天1 小时前
OpenFOAM 源码深度剖析 · 第二季-12 `coded` 系列:在字典里写 C++ 并在运行期编译
算法·开源
鶴哥只手遮天1 小时前
OpenFOAM 源码深度剖析 · 第二季-01 多相流与 VOF:`interFoam`、界面压缩与 MULES
算法·开源