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);
几个关键点:
CTX::instance()->lock是一个全局串行锁:网格生成不可重入。若上一次还没结束就再次 调用(例如 GUI 里用户狂点按钮、或脚本里递归触发),直接返回"I'm busy! Ask me that later..."。 注意这个锁不只在开头读、结尾写,还在 1D/2D 抛异常时被主动清零(第 605、793 行), 否则一次异常会让整个进程永远"忙"。Msg::ResetErrorCounter():把全局错误计数归零。后续所有阶段都用CTX::instance()->abortOnError && Msg::GetErrorCount()来判断"是否要提前中止"。m->clearLastMeshEntityError()/clearLastMeshVertexError():清空"上次失败实体/节点"的记录, 这两个记录被 GUI 用来高亮出问题的实体(第 1 章 1.5.3 节)。srand(randomSeed):网格算法(Delaunay 点插入、randFactor扰动)依赖随机数, 固定种子保证同一输入产生同一网格 。这是回归测试与工程可复现性的基石。randomSeed对应选项Mesh.RandomSeed(src/common/DefaultOptions.h 第 1672 行,默认 1.0)。SetOrder1(m, false, true):如果模型里已有高阶网格,先降回一阶再重新生成,避免高阶与低阶 混用。第三个参数skipDiscrete=true表示跳过离散实体 (它们的网格是读进来的,不能被降阶)。 实现见 src/mesh/HighOrder.cpp 第 1553 行。FixPeriodicMesh(m):把周期网格的节点对应关系重新整理一遍(详见 4.8 节)。函数头尾各调一次: 开头一次是为了消除上次遗留的不一致,结尾一次是为了把新生成的网格真正变成周期的。ask参数是"要求生成到第几维":ask=1只做一维(边),ask=2到二维(面),ask=3到三维(体)。 用户可用Mesh 1/Mesh 2/Mesh 3分别触发(对应 APIgmsh::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");
}
三点值得展开:
-
定向(orient) :
orientMeshGEdge/orientMeshGFace让线/面网格的方向与几何实体、 用户方向约束一致。这解决的是"同一条边被两个面共享时,两侧的网格走向必须相反"这类一致性问题。orientMeshGEdge很短(src/mesh/meshGEdge.cpp 第 1059 行):cppvoid 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(); } -
三维优化循环 :
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 自己优化。这体现了一个反复出现的工程原则:算法与优化是同一子系统的一体两面, 谁的算法谁负责优化。 -
细分(Subdivide) :
algoSubdivide==1把二维三角形细分(一分为四,或合并成四边形);==2在三维把四面体/棱柱细分;==3做重心细分BarycentricRefineMesh。这三种都是 **"先生成低阶网格,再整体细分升阶"**的路线,而不是直接生成高阶网格。细分实现见 src/mesh/meshRefine.cpp。 -
高阶 :
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;
}
四个动作:
FixPeriodicMesh(m):新网格生成后,再次重建周期节点对应(4.8 节)。GetQualityFast:把网格质量的 min/avg 填进CTX(minQuality/avgQuality),供 GUI 与脚本读取。 高效版本见 4.3.5 节。Msg::PrintErrorCounter:打印整个阶段的错误汇总。- 释放
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 ... }
}
三层含义:
m->getFields()->initialize():让所有尺寸场进入"就绪"状态(逐个调用update())。 之所以在 0D 阶段就做,是因为接下来的一维网格会频繁查询尺寸场 ,而尺寸树的构建(如DistanceField建 KD 树、OctreeField建八叉树)代价高,必须一次性做完,不能在并行阶段 懒加载(否则会有竞态)。这个设计在 4.9 节展开。MVertex+MPoint:每个几何点GVertex生成一个网格节点,并包成MPoint(0 维网格单元)。 注意这里有if(...empty())保护:如果点已经被网格化(例如读入的网格),就不重复创建。- 周期点对应 :若
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;
线程数决策是有层次的:
- 先取
CTX::instance()->numThreads(对应选项General.NumThreads,已随选项系统初始化); - 若
Mesh.MaxNumThreads1D > 0(默认 0,见 src/common/DefaultOptions.h 第 1424 行),则用它覆盖------这是给一维网格单独限线程数的入口; - 若还是 0,用
Msg::GetMaxThreads()取硬件上限; - 一旦有边界层场(
getNumBoundaryLayerFields() > 0),强制单线程------注释写得很直白: "boundary layers are not yet thread-safe"; - 一旦任何边是拉伸网格(
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;
}
这段代码包含四个可复用的工程模式:
- 状态机驱动 :每个实体带
GEdge::PENDING/DONE/FAILED状态(meshGEdge::operator()里 在成功时置DONE,第 1056 行)。只对PENDING的调用mesh(true)。一轮下来没有PENDING了就算收敛(if(!nPending) break;)。 - 重试机制 :
if(nIter++ > CTX::instance()->mesh.maxRetries) break;,maxRetries默认 10 (src/common/DefaultOptions.h 第 1430 行)。为什么需要重试? 因为一条边的网格化可能依赖于相邻顶点/边的网格状态,并行时的顺序不确定,某些边可能在第一轮 失败(如自相交检测未通过),下一轮环境变化后再试就能成功。 - 异常隔离 :OpenMP 循环体不能直接抛异常("OpenMP forbids leaving block via exception"), Gmsh 用一个共享
exceptions标志记下"有人抛了"。注意if(exceptions) continue;让其他线程 尽快退出------但这只是"尽力而为",因为已经进入循环体的线程不会被打断。循环结束后在主线程 统一throw,并且先释放全局锁CTX::instance()->lock = 0再抛,否则锁会永久泄漏。 - 进度条 :
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;
}
关键设计:
- 只统计二维及以上实体 (
if(ge->dim() < 2) continue;),因为质量度量(SICN)在曲线/点上无意义。 - 对"直线三角形"和"直四面体"用专用快路径 。类型为
MSH_TRI_3(3 节点直线三角形)时调用sicnStraightTriangle;MSH_TET_4时调用sicnStraightTetrahedron;其余(高阶/其他形状)才调用 通用的e->minSICNShapeMeasure()。为什么值得特化? ------因为绝大多数网格单元都是直线三角形/四面体, 而通用实现要走基函数查找、fullMatrix分配、getNodesCoord、Eigen 乘积一整套机器;对直线单元, 雅可比是常量,可以化简到几十个浮点操作。 - 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 两个奇异值的函数: iCN=σ12+σ222σ12σ22 =σ12+σ222σ1σ2,取值在 −1,1。 σ12,σ22 由雅可比 Gram 矩阵的特征值给出 (sigma1Sq = 0.5*(N+sqrt(S)),sigma2Sq = 0.5*(N-sqrt(S)),其中 N 是 Gram 矩阵的迹、 S 是出现次数最多的 (tr)2−4det 展开)。最后用面法向与"映射后 z 方向"的点积定符号: dp >= 0 为正,否则取负。等边三角形的 SICN 为 1,退化(面积趋零)为 0,翻转(顶点顺序反)为负。
sicnStraightTetrahedron(第 414 行)同理,是四面体版: 3det(J)/(∥J∥∥adj(J)∥), 其中 J 是对正则参考四面体 的常雅可比。它先算出 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∥,于是积分结果就是曲线弧长。 积分精度 Prec = lcIntegrationPrecision * lc(lcIntegrationPrecision 默认 1e-9, 见 src/common/DefaultOptions.h 第 1404 行),即 "相对于模型尺寸的绝对精度"。
"闭合直线"(首尾顶点相同且中点与顶点距离小于几何容差)被特判为长度 0------ 注释说明这是"避免积分死循环"。因为一条首尾重合的"直线"参数区间上 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 行。)
这是经典的自适应梯形法递归积分:
- 取区间中点
P,用两个子区间的梯形和val2 + val3与整区间的梯形val1比较, 差err = |val1 - val2 - val3|作为误差估计; - 若
err < Prec且深度足够(depth > 6) ,或已到最大深度(depth > 25),就接受: 把中点P与右端点to依次压入Points,并计算它们的原函数累积p; - 否则继续对
[from, P]和[P, to]递归。
两个重要细节:
depth > 6的硬门槛 :即便误差已经很小,也至少递归 6 层。这不是为了精度,而是为了 保证有足够多的采样点 ,让后续的smoothPrimitive与"按 primitive 插值定位"有足够的 分辨率(否则尺寸突变处会漏采);depth > 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) :第 i 段长度正比 ari,总长 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 的平方根,见 std::sqrt(CTX::instance()->mesh.smoothRatio))允许的增长率:
要点(完整实现见 meshGEdge.cpp 第 160 行):
- 前后两趟 (朴素 Gauss-Seidel:先正向扫描、再反向扫描),直到尺寸不再变化(
count1+count2==0) 或超过 2000 次迭代。 - 触发条件 :把
x'(t)/h的"斜率"dhdt/xp与(alpha-1)*1.01比较。alpha是允许的 尺寸增长率(这里是sqrt(smoothRatio))。1.01是一个"宽容系数",注释专门解释了为什么: 反向测试用xp[i-1]比较而更新用xp[i]缩放,某些点会"反复满足触发条件但值不变", 所以只在 lc 真正改变时才计入 count,从而正确收敛。 - 重算 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;
}
...
分派顺序语义:
- 调试过滤 (
debugSurface):只看指定面周围的边; setCurrentMeshEntity(ge):把当前实体记到GModel,用于失败时定位(lastMeshEntityError);degenerate(1):一维退化边(如参数极点处)直接标DONE返回;- 与
meshGEdgeProcessing相同的前置守卫; deMeshGEdge dem; dem(ge);删除旧网格(deMeshGEdge::operator()第 571 行:ge->deleteMesh()且状态置PENDING);MeshExtrudedCurve(ge):拉伸边(由挤出/拉伸生成)由专门流程处理;- 主从边(周期) :若
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+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 与高阶节点匹配的基础。 要点:
- 方向(
direction = masterOrientation) :direction > 0正向复制,否则逆序(index倒着取)。 这处理主从边参数方向相反的情况; - 变换优先 :若从边有仿射变换(
affineTransform.size() == 16),用transform把主边节点 映射到从边,再parFromPoint反算参数;否则退化为"假设两参数完全一致"(注释写着 danger,危险), 直接按参数平移; - 对应关系 :每个新节点都登记
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();
}
几个新增要点:
- compound 串行化 :面被分成
temp(普通面,可并行)与members(compound 成员,串行)。 注释点明原因:"复合面成员必须串行网格化,由执行复合的那一个成员读写其他人的网格"。 于是#pragma omp parallel for只作用于temp,members用普通 for 串行。 compound 是 Gmsh 把"多个面当成一个面来网格化"的机制(第 2 章 2.15 节)。 backgroundMesh::current()->unset():每个面开始前重置背景网格(backgroundMesh是 一个按面缓存的尺寸查询结构,跨面复用会串味)。unset()定义在 BackgroundMesh.h/.cpp。nIter > 2后序列化:注释说明"iter == 2 是给重参数化曲面用的;之后必须串行, 因为一维网格的自相交检测不是线程安全的"。这就是**"并行逐渐退化为串行"**的重试策略: 前几轮并行快速试错,之后转为串行以求正确。- 异常隔离 同
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 行):
-
扫描边界层实体 :遍历所有边找出
geomType() == BoundaryLayerCurve的"从边" (ep->geo.Mode == COPIED_ENTITY),记录其源边sourceEdges;对三维边界层则找出BoundaryLayerSurface的源面sourceFaces。 -
依赖计算 :
checkDepends(m, *it, sourceFacesDependencies)把源面所依赖的其他面也纳入, 因为"在拉伸网格上做边界层"需要先网格化依赖面(第 408--419 行)。 -
网格化源面(含依赖) :先串行网格化依赖面(带
maxRetries重试),再网格化源面:cppfor(auto it = sourceFaces.begin(); it != sourceFaces.end(); it++) (*it)->mesh(true); -
定向 :
std::for_each(sourceFaces.begin(), sourceFaces.end(), orientMeshGFace());------注释说"这里的定向很关键,因为我们要用源面的法向做挤出"。 -
法向场 :
ExtrudeParams::normals[i]重建为new smooth_data(),再addExtrudeNormals(sourceFaces, sourceFaceInfo, faceSkipScaleCalc)(或对边版本)算出 整个面上的光滑法向场。 -
用光滑法向场定位边界层点 (第 460--485 行):对每个边界层曲线,用
ep->Extrude(ep->mesh.NbLayer - 1, ...)把源点推到位。 -
重划被修改的边 (第 487--491 行):因为边界层改动了端点,非源面必须先删网格 (
deMeshGFace)再重划,否则会与旧网格冲突。 -
沿法向挤出边界层曲线(第 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、最长/最短边、效率指数 τ)写进一个文本表。其中效率指数由 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:
- 用
srcEdges把主边src的所有网格线 按MEdge(两个顶点)建索引; - 对从边的每条网格线,用已有的
v2v(correspondingVertices)把它的两个顶点映射到主边顶点; - 用映射后的顶点对
MEdge(vtcs[0], vtcs[1])在srcEdges里查主边的对应线; - 找到后,把高阶节点 (
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();
三个关键机制:
srcFaces索引 :把主面的每个三角形/四边形按MFace(三个或四个顶点)建索引; 从面的每个单元用v2v映射顶点后在该索引里查找对应单元。- 方向反转(revert) :
bool revert = dot(tgtFace.normal(), srcIter->first.normal()) < 0;若主从面的法向相反(几何面本身朝向不同),先srcElmt->reverse()反转主单元, 匹配完再转回来。这可能是 Gmsh 里最容易出错的角落之一 ------源码里专门写了// Warning: this check is made ...提醒读者,因为"主从面朝向不同"是工程中很常见的 情形(例如为了与几何面自身的朝向一致)。reverse()只对本次匹配生效,所以结束后 又if(revert) srcElmt->reverse();还原,避免污染主面网格。 - 两套高阶匹配 :
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 (推导:几何级数第 m 层累计厚度 = hwall(rm−1)/(r−1), 反解得 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 源码索引
- 总控:src/mesh/Generator.cpp(GenerateMesh、Mesh0D..3D、OptimizeMesh、 AdaptMesh、RecombineMesh、FixPeriodicMesh、quantize1DIfNeeded、GetQualityFast、FindConnectedRegions、 MatchPeriodicNodes、EmbeddedCompatibilityTest)
- 一维:src/mesh/meshGEdge.cpp
- 二维:src/mesh/meshGFace.cpp、
meshGFaceBDS.cpp、meshGFaceDelaunay.cpp、meshGFaceOptimize.cpp、meshGFaceBamg.cpp、meshGFaceTransfinite.cpp - 三维:src/mesh/meshGRegion.cpp、
meshGRegionDelaunayInsertion.cpp、meshGRegionHxt.cpp、meshGRegionMMG3D.cpp、meshRelocateVertex.cpp - 尺寸场:src/mesh/Field.h、src/mesh/Field.cpp
- 背景网格:src/mesh/BackgroundMesh.h、 src/mesh/BackgroundMeshTools.cpp、 src/mesh/sizeField.cpp、 src/mesh/meshQuadQuasiStructured.cpp
- 细分/高阶:src/mesh/meshRefine.cpp、src/mesh/HighOrder.cpp
- 质量/优化:src/mesh/meshUntangling.cpp、
meshMesquite.cpp、HighOrderMesh*.cpp - 选项:src/common/Options.h、src/common/DefaultOptions.h、 src/common/Context.h
4.18 本章小结
- 四阶段 + 降维删除:0D→1D→2D→3D 逐层推进,上一层变化必须删掉下一层,保证 1D ⊂ 2D ⊂ 3D。
- 并行模板 :
PENDING/DONE/FAILED状态机 +#pragma omp parallel for+ 多轮重试 +renumberMeshCanonically;边界层/拉伸/周期/全四边形自动退化为单线程。 - 一维 N 由二维约束:全四边形算法强制边段数为奇数,维与维强耦合。
- 尺寸场是可扩展的能力目录 :
Field三重operator();FieldManager::mapTypeName注册 30 余种;ensureUpdated用递归锁惰性求值,initialize()串行预热以保证并行只读。 - 背景网格是高级四边形算法的地基:PACK/QUAD_QUASI_STRUCT 先建背景网格 + 引导场。
- 度量张量统一各向异性 :
SMetric3+buildMetricTangentTo*+intersection*家族,把几何曲率、 边界层、后处理张量都表达为"度量取交"。 - 算法自带优化:HXT/pdel3d 跳过通用优化,体现"谁的算法谁负责质量"。
第 5 章将深入二维曲面网格,看 Delaunay/BDS、前沿推进、BAMG 与两种四边形算法的实现细节; 第 6 章进入三维 Delaunay 与 HXT;第 7 章展开质量优化与高阶。