Gmsh 源码深度解析-第 5 章 二维曲面网格算法
本章目标:讲清 Gmsh 的二维曲面网格体系------算法枚举与分派、曲面参数化、Shephard-Beall 双向数据结构(BDS)、各向异性 Delaunay、边界恢复、前沿推进、从三角形到全四边形的 重组合算法,以及准结构化(QuadQS)与超限插值(Transfinite)两条结构化路线。
第 4 章讲了一维边离散与尺寸场(Field)的求值;本章讲这些一维节点如何被"缝"进一张二维 曲面网格,以及曲面网格如何被优化、重组合、并交给第 6 章的三维网格器当边界。
5.0 一张全景图
先给出本章骨干结论,后面所有小节都是在为它提供证据:
- 除 Transfinite 与 Extrude 两条结构化/拉伸路径外,Gmsh 的所有二维自由网格算法都以同一 套 "BDS + 边界恢复 + 增量 Delaunay" 骨架为核心。区别只在"内部点从哪里来"------纯 Delaunay 只插入边界离散点;Frontal-Delaunay 主动按尺寸场造点;PACK 从背景场打包点; QuadQS 用背景网格 + 引导场(cross field)驱动。
- 一切三角化都发生在参数平面
(u,v)里 。Delaunay 的空圆判据只在平面上有定义;曲面 通过GFace::point(u,v)、parFromPoint映射回三维,通过buildMetric构造的度量张量 补偿参数化畸变。 - 网格对象必须保持几何分类 。边界点必须落在原曲线上,边界边必须等于原一维网格线段, 否则曲面网格与边界网格对不上,第 6 章的三维网格也无从下手。因此 Delaunay 中每一次边翻转 都要经过
BDS_SwapEdgeTest家族审查。 - 一维网格的自交是二维网格失败的头号原因 。
EdgeToRecover+recover_edge+remeshUnrecoveredEdges+maxRetries构成"检测交叉→加密一维网格→整体重来"的回路。
本章按数据流顺序展开:算法与分派(5.1)→ 参数化(5.2)与各向异性度量(5.3)→ 核心数据结构 BDS(5.4)→ 边界恢复(5.5)→ 结果回填(5.6)→ 主控制流(5.7)→ 前沿推进(5.8)→ 打包 (5.9)→ 重组合(5.10)→ QuadQS(5.11)→ Transfinite(5.12)→ 质量与优化(5.13)→ 接口 (5.14)→ Q&A(5.15)→ 术语表(5.16)→ 小结(5.17)。
5.1 算法枚举与分派
5.1.1 algo2d 枚举
二维曲面网格算法由 CTX::instance()->mesh.algo2d 全局选择(每个面可用 gf->meshAttributes.algorithm 覆盖)。枚举定义在 src/common/GmshDefines.h(第 239--248 行),注释写着 "numbers should not be changed"------这些整数会写进 .msh 与脚本,属于稳定 ABI:
c
// 2D meshing algorithms (numbers should not be changed)
#define ALGO_2D_MESHADAPT 1
#define ALGO_2D_AUTO 2
#define ALGO_2D_INITIAL_ONLY 3
#define ALGO_2D_DELAUNAY 5
#define ALGO_2D_FRONTAL 6
#define ALGO_2D_BAMG 7
#define ALGO_2D_FRONTAL_QUAD 8
#define ALGO_2D_PACK_PRLGRMS 9
#define ALGO_2D_PACK_PRLGRMS_CSTR 10
#define ALGO_2D_QUAD_QUASI_STRUCT 11
注意编号里 4 被跳过(历史遗留)。逐个解释:
| 值 | 名称 | 含义 | 本节 |
|---|---|---|---|
| 1 | ALGO_2D_MESHADAPT |
不跑 Delaunay 主路径,靠 refineMeshBDS 的 split/swap/collapse/smooth 迭代 |
5.7 |
| 2 | ALGO_2D_AUTO |
默认值:平面走 Delaunay,曲面自动 | 5.1.3 |
| 3 | ALGO_2D_INITIAL_ONLY |
只做边界点的初始三角化,不插内部点,仍参与重组合 | 5.7.1 |
| 5 | ALGO_2D_DELAUNAY |
经典增量 Bowyer-Watson,只用边界离散点 | 5.8 |
| 6 | ALGO_2D_FRONTAL |
前沿推进:在前沿上按尺寸场主动造点 | 5.8 |
| 7 | ALGO_2D_BAMG |
调用 BAMG 各向异性网格器 | 5.3、5.8 |
| 8 | ALGO_2D_FRONTAL_QUAD |
Frontal 的四边形变体 | 5.8.2 |
| 9 | ALGO_2D_PACK_PRLGRMS |
打包平行四边形(PACK) | 5.9 |
| 10 | ALGO_2D_PACK_PRLGRMS_CSTR |
PACK 的约束变体,已废弃 | 5.9 |
| 11 | ALGO_2D_QUAD_QUASI_STRUCT |
准结构化全四边形(QuadQS) | 5.11 |
5.1.2 逐面覆盖与第一层分派
GFace::getMeshingAlgo(src/geo/GFace.cpp 第 59--65 行)把"逐面 覆盖 + 全局回落"落实:
cpp
int GFace::getMeshingAlgo() const
{
if(meshAttributes.algorithm) return meshAttributes.algorithm; // 面级覆盖
else return CTX::instance()->mesh.algo2d; // 全局默认
}
meshGFace.cpp 的所有分支都建立在一个判断上------这个面是否走"基于 Delaunay 的路径" (第 301--318 行):
cpp
static bool algoDelaunay2D(GFace *gf)
{
if(gf->getMeshingAlgo() == ALGO_2D_DELAUNAY ||
gf->getMeshingAlgo() == ALGO_2D_BAMG ||
gf->getMeshingAlgo() == ALGO_2D_FRONTAL ||
gf->getMeshingAlgo() == ALGO_2D_FRONTAL_QUAD ||
gf->getMeshingAlgo() == ALGO_2D_PACK_PRLGRMS ||
gf->getMeshingAlgo() == ALGO_2D_PACK_PRLGRMS_CSTR ||
gf->getMeshingAlgo() == ALGO_2D_BAMG)
return true;
if(gf->getMeshingAlgo() == ALGO_2D_AUTO && gf->geomType() == GEntity::Plane) return true;
if(gf->getMeshingAlgo() == ALGO_2D_INITIAL_ONLY) return true;
return false;
}
两个细节:ALGO_2D_BAMG 出现了两次 (第 304、309 行),属无害冗余,是编辑遗留------提醒 读者别假设"无重复"是设计意图;ALGO_2D_INITIAL_ONLY 返回 true,因为"初始网格"本身仍 需要 Delaunay 骨架完成边界恢复与外围剔除,只是不进入造点阶段。
"走 Delaunay 骨架"与"跑哪个造点算法"是两件事:前者由 algoDelaunay2D 决定,后者由 runDelaunayAlgorithm 里的二次分派决定。
5.1.3 meshGFace::operator():顶层分派
meshGFace::operator()(第 2339--2478 行)是每维分派点,按顺序处理这些"短路",顺序本身 有语义:
cpp
void meshGFace::operator()(GFace *gf, bool print)
{
gf->model()->setCurrentMeshEntity(gf);
if(gf->meshAttributes.method == MESH_NONE) return; // 无网格需求
if(CTX::instance()->mesh.meshOnlyVisible && !gf->getVisibility()) return;
if(CTX::instance()->mesh.meshOnlyEmpty && gf->getNumMeshElements()) return;
deMeshGFace dem; dem(gf); // 先删旧网格
if(MeshTransfiniteSurface(gf)) { ... return; } // Transfinite 路径
if(MeshExtrudedSurface(gf)) return; // Extrude 路径
if(gf->getMeshMaster() != gf) { copyMesh(gff, gf); ... return; } // 网格从属面
...
}
逐条:MESH_NONE 表示脚本里 Mesh 0/-1,这个面不参与网格生成;meshOnlyVisible / meshOnlyEmpty 支持大模型增量重网格;deMeshGFace 删旧网格(isFullyDiscrete() 的离散面 跳过,因为它的网格就是几何);Transfinite 优先于一切 (返回非零即"已处理完"), Extrude 次之 ,两者都不走 Delaunay;网格从属面要等主面 DONE 后 copyMesh。
处理完短路后判断是否周期性:
cpp
bool singularEdges = false;
auto ite = gf->edges().begin();
while(ite != gf->edges().end()) { if((*ite)->isSeam(gf)) singularEdges = true; ite++; }
bool periodic = (gf->getNativeType() != GEntity::GmshModel) &&
(gf->periodic(0) || gf->periodic(1) || singularEdges);
singularEdges 指缝边(seam) ------一条曲线在参数域两端首尾相接(柱面的母线就是典型)。 有缝边会触发"周期性生成器"meshGeneratorPeriodic。注意 getNativeType() != GEntity::GmshModel:自研 GEO 内核不走周期性生成器,这是双内核差异在网格层的体现 (对照第 2 章 2.1.3)。
cpp
quadMeshRemoveHalfOfOneDMesh halfmesh(gf, periodic);
if(periodic) {
if(!meshGeneratorPeriodic(gf, 0, repairSelfIntersecting1dMesh, false)) { ... }
}
else {
meshGenerator(gf, 0, repairSelfIntersecting1dMesh,
(gf->getMeshingAlgo() == ALGO_2D_INITIAL_ONLY) ?
MeshExtent::InitialOnly : MeshExtent::Full,
false, nullptr);
}
quadMeshRemoveHalfOfOneDMesh halfmesh(gf, periodic); 是一个 RAII 对象 :构造时把一维网格 "隔一个点删一个"(5.10.3),finish() 时再细分回来;只对全四边形重组合 (algoRecombine >= 2 且非 4)生效。meshGenerator 与 meshGeneratorPeriodic 是并列的两条 主线,共享大量子过程(BDS2GMSH、setupBackgroundMesh、runDelaunayAlgorithm、 recombineSurfaceMesh、finishSurfaceMesh),区别在边界节点收集(集合去重 vs 保序环形列表) 与缝节点处理(_periodicCounterpart、双朝向、容差逐级放大)。
5.1.4 runDelaunayAlgorithm:决定"内部点从哪来"
cpp
static void
runDelaunayAlgorithm(GFace *gf, bool infty, bool seedBamgWithBowyerWatson,
std::map<MVertex *, MVertex *> *equivalence,
std::map<MVertex *, SPoint2> *parametricCoordinates,
std::vector<SPoint2> *true_boundary)
{
const bool flat = CTX::instance()->mesh.flatRefine2D != 0;
if(gf->getMeshingAlgo() == ALGO_2D_FRONTAL) {
if(flat) bowyerWatsonFrontalFlat(gf, equivalence, parametricCoordinates, true_boundary);
else bowyerWatsonFrontal(gf, equivalence, parametricCoordinates, true_boundary);
}
else if(gf->getMeshingAlgo() == ALGO_2D_FRONTAL_QUAD) {
bowyerWatsonFrontalLayers(gf, true, equivalence, parametricCoordinates);
}
else if(gf->getMeshingAlgo() == ALGO_2D_PACK_PRLGRMS) {
bowyerWatsonParallelograms(gf, equivalence, parametricCoordinates);
}
else if(gf->getMeshingAlgo() == ALGO_2D_PACK_PRLGRMS_CSTR) {
Msg::Error("ALGO_2D_PACK_PRLGRMS_CSTR deprecated");
}
else if(gf->getMeshingAlgo() == ALGO_2D_DELAUNAY || gf->getMeshingAlgo() == ALGO_2D_AUTO) {
if(flat) bowyerWatsonFlat(gf, 1000000000, equivalence, parametricCoordinates);
else bowyerWatson(gf, 1000000000, equivalence, parametricCoordinates);
}
else {
if(seedBamgWithBowyerWatson) {
if(flat) bowyerWatsonFlat(gf, 15000, equivalence, parametricCoordinates);
else bowyerWatson(gf, 15000, equivalence, parametricCoordinates);
}
meshGFaceBamg(gf);
}
if(!infty || !(CTX::instance()->mesh.recombineAll || gf->meshAttributes.recombine))
laplaceSmoothing(gf, CTX::instance()->mesh.nbSmoothing, infty);
}
flat不是另一个算法,而是另一个内核 。Mesh.FlatRefine2D非零时,经典的std::set<MTri3*>版本被换成meshGFaceDelaunayFlat.cpp里的索引数组版本。该文件头注释 申明:"These are not different algorithms: same in-circle and optimal-point formulas... they produce the same mesh, element for element and node for node." 当FlatRefine2D = 1时网格 逐元素、逐节点完全相同,只是数据结构不同;设成2才打开若干浮点捷径,此时网格只是 "等价"(注释量化:平面提速约 14%,曲面几乎为 0,节点数差异 0.01% 以内)。bowyerWatson(gf, 1000000000, ...)的第二参数是点数上限;1e9 表示"不设实际上限",终止由 "最差三角形半径 < √2/2"驱动。- BAMG 的种子 :
seedBamgWithBowyerWatson只在非周期生成器里为 true。注释解释两条生成器 行为不同,且 BAMG 本身非确定("a plain square gives four different meshes in five runs"), 因此保留差异、不做统一。 - 末尾平滑 :
laplaceSmoothing只在"非无限度量"或"不要求重组合"时执行。infty来自setupBackgroundMesh:对FRONTAL_QUAD与PACK_*,生成器工作在背景网格的"无限度量"下, 此时不额外做 Laplace 平滑。
5.1.5 失败回退:algoSwitchOnFailure
(第 2465--2477 行)
cpp
if(CTX::instance()->mesh.algoSwitchOnFailure &&
gf->getNativeType() != GEntity::GmshModel &&
gf->geomType() != GEntity::Plane && algoDelaunay2D(gf) && !isMeshValid(gf)) {
Msg::Debug("Delaunay-based mesher failed on surface %d -> moving to MeshAdapt", gf->tag());
deMeshGFace killer; killer(gf);
gf->setMeshingAlgo(1); // 临时改成 ALGO_2D_MESHADAPT
(*this)(gf, print); // 递归调用自己
gf->unsetMeshingAlgo(); // 恢复
}
isMeshValid(第 2306--2317 行)用 TRIANGLE_VALIDITY(第 2285--2304 行)统计法向不一致的 三角形:三个角点法向平均后与三角形几何法向点乘,< 0 记为无效;"全正或全负都算有效" (整体朝向可能合法地是反的),只有"正负混杂"才说明有折叠/翻卷。第 2466 行 getNativeType() != GEntity::GmshModel 的原因见注释:"currently we cannot reliably evaluate the normal on the boundary of surfaces with the Gmsh kernel"。这个递归是本函数唯一的自调用, 也是 ALGO_2D_MESHADAPT 至今保留的现实理由之一。
5.1.6 算法矩阵
| 算法 | 走 Delaunay/BDS 骨架? | 内部点来源 | 生成器 |
|---|---|---|---|
| MESHADAPT(1) | 否(用 refineMeshBDS 四件套) |
按尺寸场 split/swap/collapse | 非周期 |
| AUTO(2) | 平面是;曲面否 | 同 Delaunay 或 MeshAdapt | 视 geomType |
| INITIAL_ONLY(3) | 是,但只到初始三角化 | 无内部点 | 非周期 |
| DELAUNAY(5) | 是 | 仅边界离散点 | 双路 |
| FRONTAL(6) | 是 | 前沿上按尺寸场造点 | 双路 |
| BAMG(7) | 是(+BAMG 造点) | BAMG 各向异性 | 双路不同 |
| FRONTAL_QUAD(8) | 是 | 前沿造点,倾向四边形 | 双路 |
| PACK_PRLGRMS(9) | 是 | 打包平行四边形 | 双路 |
| PACK_PRLGRMS_CSTR(10) | ---(已废弃) | --- | --- |
| QUAD_QUASI_STRUCT(11) | 是(内部改用 9) | QuadQS 引导场 | 双路 |
| Transfinite | 否 | 超限插值 | 独立 |
| Extrude | 否 | 拉伸/复制 | 独立 |
结论:除最后两行,全部共享同一副骨架------这就是为什么先把 BDS 与边界恢复讲透,后面所有 算法都会"复用一半"。
5.2 曲面参数化:一切在 (u,v) 平面里做
5.2.1 为什么必须参数化
Delaunay 的定义依赖平面上的"外接圆"(任一三角形的外接圆内不含其它点)。曲面上没有"圆", 因此 Gmsh 把曲面拉平到参数域 (u,v),在参数平面里跑 Delaunay,再映射回三维:
- 边界曲线
GEdge上的一维离散点,用reparamMeshVertexOnFace求出它们在gf参数域里的(u,v); - 这些
(u,v)进入 BDS 网格,在参数平面里被三角化; - 参数平面里的每个三角形顶点,用
GFace::point(u,v)得到三维坐标; - 三维节点以
MFaceVertex保存,同时携带(x,y,z)与(u,v)。
代价是参数化畸变 :物理空间里的等边三角形,在参数平面里可能被拉成细长三角形(想想 u=θ, v=z 参数化的圆柱面,u 一圈是 2π 而 v 可能只有 1 个单位长)。所以 Gmsh 不能 简单地用欧氏距离做 Delaunay,而要引入度量(5.3)。
5.2.2 参数化契约
gmshFace(src/geo/gmshFace.h 第 23--40 行)声明:
cpp
virtual GPoint point(double par1, double par2) const; // (u,v) → 3D
virtual GPoint closestPoint(const SPoint3 &queryPoint,
const double initialGuess[2]) const; // 3D → 最近点
virtual bool containsPoint(const SPoint3 &pt) const;
virtual double getMetricEigenvalue(const SPoint2 &); // 度量特征值
virtual SVector3 normal(const SPoint2 ¶m) const;
virtual std::pair<SVector3, SVector3> firstDer(const SPoint2 ¶m) const;
virtual void secondDer(const SPoint2 &, SVector3 &, SVector3 &, SVector3 &) const;
virtual SPoint2 parFromPoint(const SPoint3 &, bool onSurface = true,
bool convTestXYZ = false) const; // 3D → (u,v)
三对最关键:point(u,v)/parFromPoint(xyz)(正逆变)、firstDer(u,v)(参数化雅可比,构造 度量的原料,5.3.1)、normal(u,v)(方向一致性检查与 BDS_SwapEdgeTestNormals)。
自研内核实现(src/geo/gmshFace.cpp 第 261--297 行)对平面特判:
cpp
GPoint gmshFace::point(double par1, double par2) const
{
double pp[2] = {par1, par2};
if(_s->Typ == MSH_SURF_PLAN && !_s->geometry) {
double x, y, z, VX[3], VY[3];
getMeanPlaneData(VX, VY, x, y, z); // 平面的局部坐标基
return GPoint(x + VX[0]*par1 + VY[0]*par2, y + VX[1]*par1 + VY[1]*par2,
z + VX[2]*par1 + VY[2]*par2, this, pp);
}
else {
Vertex v = InterpolateSurface(_s, par1, par2, 0, 0); // 一般曲面:插值
return GPoint(v.Pos.X, v.Pos.Y, v.Pos.Z, this, pp);
}
}
对无附加几何数据的平面(MSH_SURF_PLAN && !_s->geometry),参数就是平面局部坐标系两个方向 上的坐标------此时参数化是等距(isometric)的 ,参数平面与物理平面只差一个刚体变换,没有 畸变。这就是 runDelaunayAlgorithm、_recombineIntoQuads 等处会专门检查 geomType() == GEntity::Plane 并走更快欧氏路径的原因。逆映射 parFromPoint(第 283 行)对 平面用解析投影(prosca 沿 VX、VY 分解),一般曲面委托基类(各内核用自己的牛顿迭代)。
5.2.3 参数化畸变与度量补偿的动机
设圆柱面 r(u,v) = (R cos u, R sin u, v),u ∈ [0,2π]。切向量 ∂r/∂u = (-R sin u, R cos u, 0)、 |∂r/∂u| = R,∂r/∂v = (0,0,1)------参数平面里 1×1 的正方形映射到三维变成 R×1 的长方形; 在参数平面里做等边三角化,物理空间里得到的却是被拉长 R 倍的各向异性三角形 。解决办法是 度量张量 M(u,v) = Jᵀ J(J = [∂r/∂u ∂r/∂v]),使"度量长度" √(Δᵀ M Δ) 恰等于物理 长度;在 M 定义的度量空间里做等边三角化,拉回物理空间才接近等边。
5.2.4 reparamOnFace 与跨面参数转换
一维节点携带的是曲线参数 t(MEdgeVertex::getParameter(0, t)),不是面参数 (u,v)。 把它转成面参数的动作叫重参数化 :reparamMeshVertexOnFace(MVertex*, GFace*, SPoint2&) 先把顶点分类在 gf 上时直接读缓存 (u,v),否则经 GEdge::reparamOnFace(face, epar, dir) (src/geo/gmshEdge.h 第 32 行)转换,dir 用于缝边(同一物理点有 两个参数值)。reparamOnFace 就是 Gmsh 处理"一条边同时属于两个面"的机制:同一三维点,在 面 A 里的 (u,v) 与在面 B 里的 (u,v) 完全不同,两个面的网格要在公共边上一致,就必须 各自算对。
buildConsecutiveListOfVertices(meshGFace.cpp 第 1610--1885 行)的注释(第 1619--1627 行)总结 了周期/缝场景下重参数化的三大难点:
cpp
// building the list of nodes in the parametric space for periodic surfaces is
// tricky:
// 1) both reparametrizations of the seams must be tested, as the topological
// representation does not know which one to use
// 2) both orientations of curves must be tested, as the topological
// representation of periodic curves cannot distinguish them
// 3) reparametrization of curves on surfaces can lead to slightly different
// parametric coordinates, especially with OpenCASCADE - so a tolerance is
// needed
即:缝边两种重参数化(dir = ±1)都要试;曲线两个朝向都要试;OCC 的 pcurve 在面上重参数化 后可能有微小差异,需要容差------且注释第 1714--1716 行特别提醒"OCC pcurves can require a large UV tolerance: it must not let a different endpoint replace the current mesh vertex",因此代码 先检查 verts.back() == v.front() 再比较距离。为此把候选参数组织成四元组 p/p_alt/p_rev/ p_alt_rev(第 1660--1694 行),并按最短参数距离选取;容差从 1e-12·LC2D 逐级放大到 1e-1·LC2D(第 1937 行 fact[6]),每级先试"不把缝边放首位",再试"把缝边放首位":
cpp
const double fact[6] = {1.e-12, 1.e-9, 1.e-6, 1.e-3, 1.e-2, 1.e-1};
for(int i = 0; i < 6; i++) {
if(buildConsecutiveListOfVertices(gf, *it, edgeLoop_BDS, bbox, m, recoverMap,
nbPointsLocal, nbPointsTotal, fact[i]*LC2D)) { ok = true; break; }
if(buildConsecutiveListOfVertices(gf, *it, edgeLoop_BDS, bbox, m, recoverMap,
nbPointsLocal, nbPointsTotal, fact[i]*LC2D,
true /* seam_the_first */)) { ok = true; break; }
}
这是"用容差做分级试探"的典型工程写法:宁可多算十几次,也要让面自动跨过 OCC 容差问题, 而不是报错让用户手工调参。
5.2.5 MFaceVertex 与投影修正
参数化的产物是 MFaceVertex(携带 (u,v) 的面网格节点),用于 BDS2GMSH 新建内部点、 Frontal 造点(insertAPoint 第 771 行)、Transfinite 插值点(第 563 行)、 quadMeshRemoveHalfOfOneDMesh::_subdivide 的中点/形心等。它的 (u,v) 在后续优化/重组合/投影 中反复被读,避免每次从三维反解参数(那需牛顿迭代,昂贵);_relocate 里显式跳过边界层节点:
cpp
MFaceVertex *fv = dynamic_cast<MFaceVertex *>(ver);
if(fv && fv->bl_data) return; // 边界层节点不参与平滑
参数平面里得到的点用 gf->point(u,v) 映射回三维后可能不在原曲面上(参数域角落对应奇异 点,或数值误差),因此某些算法会再做一次投影。Transfinite 的注释很坦率(第 664--665 行):
cpp
// xp,yp,zp can be off the surface so we cannot use parFromPoint
gf->XYZtoUV(xp, yp, zp, Up, Vp, 1.0, false);
即点已偏离曲面时不能再用 parFromPoint(会得到错误参数),只能用 XYZtoUV。这类"投影修正" 是曲面网格相对平面网格额外的复杂度。
5.3 各向异性度量与 Delaunay 判据
5.3.1 buildMetric
buildMetric(src/mesh/meshGFaceDelaunay.h 第 22 行; 实现见 src/mesh/meshGFaceDelaunay.cpp 第 144--151 行) 只有四行:
cpp
void buildMetric(GFace *gf, double *uv, double *metric)
{
std::pair<SVector3, SVector3> der = gf->firstDer(SPoint2(uv[0], uv[1]));
metric[0] = dot(der.first, der.first); // a = ∂u·∂u
metric[1] = dot(der.second, der.first); // b = ∂v·∂u
metric[2] = dot(der.second, der.second); // d = ∂v·∂v
}
把 M = [[a, b],[b, d]] 存成长度 3 的数组(只存对称矩阵三个独立分量)------这是定死的内存布局 约定,所有读 metric 的代码都照此解释。M 的特征值 λ1, λ2 对应参数平面单位长度在两个主 方向上的"物理长度平方";getMetricEigenvalue 就返回它,det(M) = a·d - b² 被 inCircumCircleAniso、optimalPointFrontal 的 RATIO 用到。
5.3.2 度量空间的外接圆
度量下"圆"变椭圆。"到三点等距"仍是一组二次方程,两两相减后二次项消掉,剩线性系统。 circumCenterMetric(第 99--142 行):
cpp
static void circumCenterMetric(double *pa, double *pb, double *pc,
const double *metric, double *x, double &Radius2)
{
// d = (u2-u1) M (u2-u1) = u2 M u2 + u1 M u1 - 2 u2 M u1
const double a = metric[0], b = metric[1], d = metric[2];
sys[0][0] = 2.*a*(pa[0]-pb[0]) + 2.*b*(pa[1]-pb[1]);
sys[0][1] = 2.*d*(pa[1]-pb[1]) + 2.*b*(pa[0]-pb[0]);
sys[1][0] = 2.*a*(pa[0]-pc[0]) + 2.*b*(pa[1]-pc[1]);
sys[1][1] = 2.*d*(pa[1]-pc[1]) + 2.*b*(pa[0]-pc[0]);
rhs[0] = a*(pa[0]*pa[0]-pb[0]*pb[0]) + d*(pa[1]*pa[1]-pb[1]*pb[1]) + 2.*b*(pa[0]*pa[1]-pb[0]*pb[1]);
rhs[1] = a*(pa[0]*pa[0]-pc[0]*pc[0]) + d*(pa[1]*pa[1]-pc[1]*pc[1]) + 2.*b*(pa[0]*pa[1]-pc[0]*pc[1]);
if(!sys2x2(sys, rhs, x)) { }
Radius2 = (x[0]-pa[0])*(x[0]-pa[0])*a + (x[1]-pa[1])*(x[1]-pa[1])*d
+ 2.*(x[0]-pa[0])*(x[1]-pa[1])*b;
}
d(u1,u2) = (u2-u1)ᵀ M (u2-u1);要求 x 满足 d(x,pa)=d(x,pb) 与 d(x,pa)=d(x,pc),两式 相减后 xᵀ M x 抵消,得到关于 x 的线性 2×2 系统,用 sys2x2 求解。Radius2 是 x 到 pa 的度量平方距离。
5.3.3 inCircumCircleAniso
(第 160--173 行)把两步合起来:
cpp
int inCircumCircleAniso(GFace *gf, double *p1, double *p2, double *p3,
double *uv, double *metric)
{
double x[2], Radius2;
circumCenterMetric(p1, p2, p3, metric, x, Radius2);
const double a = metric[0], b = metric[1], d = metric[2];
const double d0 = (x[0] - uv[0]), d1 = (x[1] - uv[1]);
const double d3 = d0*d0*a + d1*d1*d + 2.0*d0*d1*b;
const double tolerance = computeTolerance(Radius2);
return d3 < Radius2 - tolerance;
}
computeTolerance(第 153--158 行)随半径量级变化:≤1e3 取 1e-12,≤1e5 取 1e-11, 否则 1e-9------大三角形坐标值大、浮点误差大,不放宽容差会把"落在圆上"误判为"落在圆内", 造成无意义翻转。第二组重载(第 175--204 行)在 metricb 为空时用三角形形心处的度量代替:
cpp
if(!metricb) {
double pa[2] = {(data.Us[i0] + data.Us[i1] + data.Us[i2]) * ONE_THIRD,
(data.Vs[i0] + data.Vs[i1] + data.Vs[i2]) * ONE_THIRD};
buildMetric(gf, pa, metric);
}
即"一个三角形用单一度量近似"的工程取舍------更精确的三点各自度量会让判据失去"线性系统可解" 的简洁性。
5.3.4 鲁棒谓词
纯浮点谓词在退化(共线/共圆)时会给出错误符号,Gmsh 用 robustPredicates(Shewchuk 自适应 精度)解决。BDS 侧(BDS.cpp 第 885--888 行):
cpp
double ori_t1 = robustPredicates::orient2d(op1, p1, op2);
double ori_t2 = robustPredicates::orient2d(op1, op2, p2);
return (ori_t1 * ori_t2 > 0); // the quadrangle was strictly convex !
经典内核侧(meshGFaceDelaunay.cpp 第 320--332 行,inCircumCircleXY):
cpp
double result = robustPredicates::incircle(p1, p2, p3, pp) *
robustPredicates::orient2d(p1, p2, p3);
return (result > 0) ? 1 : 0;
incircle 返回 4×4 行列式,其符号本身依赖三点朝向,故乘 orient2d 归一化------这样"点在圆内" 永远对应正的乘积。这是"鲁棒谓词 + 显式朝向修正"的标准写法(第 8 章细讲 Shewchuk 实现)。 swapEdgePass 也反复调用它:翻转构造的四边形是否"严格凸",由四个 orient2d 的六对乘积全正 决定(meshGFaceBDS.cpp 第 341--347 行)。
5.3.5 各向异性为何是曲面"必需品";无限度量
把圆柱例子代入:M = diag(R²,1)。若在参数平面追求"各向同性正三角形",物理空间所有三角形 都会沿 u 方向被拉长 R 倍,产生细长"乐高砖块"。用 M 后,度量空间里的"正三角形"在参数 平面里实际是沿 u 压扁 R 倍的三角形,拉回物理空间才接近等边。各向异性还有额外价值:边界层 网格 希望在法向极密、切向较疏,这种"方向性"天然可用一个各向异性度量表达------BAMG 正是为此 设计的原生各向异性网格器(meshMetric、F_Lc_aniso,第 4 章已见)。
Frontal-Quad 与 PACK 还在一个特殊背景里工作:背景网格定义 cross field 与理想尺寸,生成器 认为"理想度量是无限的",用 lengthInfniteNorm(meshGFaceDelaunay.cpp 第 1120--1132 行)在 cross field 旋转后的坐标系里取各方向投影的最大跨度:
cpp
static double lengthInfniteNorm(const double p[2], const double q[2], const double quadAngle)
{
double const xp = p[0]*std::cos(quadAngle) + p[1]*std::sin(quadAngle);
double const yp = -p[0]*std::sin(quadAngle) + p[1]*std::cos(quadAngle);
...
return std::max(xmax - xmin, ymax - ymin); // L∞ 范数
}
名字里的 "Infnite" 是 "Infinite" 的历史拼写,反映"四边形网格在旋转后坐标系里对齐"的理念 ------这是 5.8 与 5.9 的伏笔。
5.4 BDS:Shephard-Beall 双向数据结构
二维 Delaunay 的核心数据结构是 src/mesh/BDS.h 里的 BDS------ Bidirectional Data Structure,源自 Shephard & Beall 的经典论文。文件头注释(第 9--12 行):
cpp
// This is a 2D version of the Bidirectional Data Structure (BDS)
// of shephard and beall
// points may know the normals to the surface they are classified on
// default values are 0,0,1
"points may know the normals to the surface they are classified on"说明 BDS 点不仅记坐标与 参数,还知道所属曲面法向(默认 (0,0,1))------这就是 BDS_SwapEdgeTestNormals 能拿 gf 与点 查法向的原因。BDS 由 BDS_Point/BDS_Edge/BDS_Face 三类对象构成,它们互相持有指针 ("双向"得名):点知道所有相邻边,边知道两个端点与相邻面,面知道三条(或四条)边,提供 O(1) 邻域访问,是 Delaunay 中"沿边行走"的基础。
5.4.1 BDS_GeomEntity:几何分类
(BDS.h 第 32--48 行)
cpp
class BDS_GeomEntity {
public:
int classif_tag; // 实体编号(如曲线 3、曲面 7)
int classif_degree; // 维度:0=点、1=边、2=面
BDS_GeomEntity(int a, int b) : classif_tag(a), classif_degree(b) {}
bool operator<(const BDS_GeomEntity &other) const
{
if(classif_degree < other.classif_degree) return true;
if(classif_degree > other.classif_degree) return false;
if(classif_tag < other.classif_tag) return true;
return false;
}
};
operator< 先按维度、再按标签 排序,使 geom 集合(std::set<BDS_GeomEntity*, GeomLessThan>)有序、get_geom 可二分。约定:负 classif_tag 表示网格器内部生成的辅助 实体 (如 add_geom(-1, 2));classif_degree < 2 表示"落在曲线或点上"(边界),== 2 表示"在曲面内部"。许多判定(如 BDS_SwapEdgeTestQuality 的"避免二邻居点")直接读它。 GeomLessThan(第 297--302 行)只是把 operator< 包成函数对象供 std::set 用。
5.4.2 BDS_Point:双尺寸与周期对应
(第 50--85 行)
cpp
class BDS_Point {
// the first size is the one dictated by the Background Mesh the
// second one is dictated by characteristic lengths at points and is propagated
double _lcBGM, _lcPTS;
public:
double X, Y, Z, u, v;
bool config_modified;
short degenerated;
BDS_Point *_periodicCounterpart;
int iD;
BDS_GeomEntity *g;
std::vector<BDS_Edge *> edges;
double &lcBGM() { return _lcBGM; }
double &lc() { return _lcPTS; }
};
_lcBGM/_lcPTS双尺寸 :前者"背景网格(BGM)给出的目标尺寸",后者"由点上的特征长度 给出并被传播的尺寸";最终取哪个取决于Extend1dMeshIn2dSurfaces(gf)(5.7.4 的NewGetLc)。X,Y,Z三维坐标、u,v参数坐标并存------前者用于质量度量,后者用于 Delaunay 判定与几何 分类查询。config_modified脏标记:swap_edge里if(!force && !p1->config_modified && !p2->config_modified && !op[0]->config_modified && !op[1]->config_modified) return false;(BDS.cpp 第 1051--1053 行)------四个点都没变过就跳过翻转测试,把 Delaunay 翻转到局部。degenerated:0正常,1表示点在u方向退化(自由坐标是u),2表示v方向退化。注释"the free coordinate of a degenerated point (u if degenerated == 1, v if 2) is arbitrary, e.g. anywhere in 0, 2 pi at the apex of a cone"说明锥顶点这类奇异点的参数 不确定性(BDS.cpp 第 34--36 行)。_periodicCounterpart:周期/缝面里同一物理节点在参数域有两份,互为 counterpart (meshGeneratorPeriodic第 1972--1974 行建立配对)。iD是内部主键,BDS_Point::operator<就是iD < other.iD,故points(std::set<BDS_Point*, PointLessThan>)按 id 有序,find_point可二分。- 构造函数把两个尺寸初始化为
1.e22("尚未设置"),config_modified = true;del(BDS_Edge*)用 erase-remove 从edges删边。
5.4.3 BDS_Edge:_f2 内联缓存与溢出列表
(第 87--201 行,注释第 89--94 行)
cpp
class BDS_Edge {
// An edge of a surface mesh has two faces, so they are stored inline: a
// std::vector here meant one extra heap block, and one extra cache miss on
// every faces() call, for every edge of the mesh. The overflow list is only
// ever allocated on a non manifold configuration.
BDS_Face *_f[2];
int _nf;
std::vector<BDS_Face *> *_moreFaces;
曲面网格里绝大多数边恰好只有两个相邻面 ,故用内联数组 _f[2] 存这两个面,仅在非流形时才 分配溢出 std::vector。注释坦率说明动机:用 std::vector 会让 faces() 每次调用多一次堆 访问和缓存缺失,乘上所有边就是可观代价------"以常见情况优化、为异常情况兜底"的典型微观优化。 配套逻辑:
cpp
BDS_Face *faces(std::size_t const i) const { return i < 2 ? _f[i] : (*_moreFaces)[i - 2]; }
int numfaces() const { return _nf; }
void addface(BDS_Face *f)
{
if(_nf < 2) { _f[_nf] = f; }
else { if(!_moreFaces) _moreFaces = new std::vector<BDS_Face *>(); _moreFaces->push_back(f); }
_nf++;
}
void del(BDS_Face *t) // 原地压缩、保序,避免 std::remove_if 的重分配
{
if(!_nf) return;
int n = _nf; _nf = 0;
for(int i = 0; i < n; i++) {
BDS_Face *f = i < 2 ? _f[i] : (*_moreFaces)[i - 2];
if(f == t) continue;
if(_nf < 2) { _f[_nf] = f; } else { (*_moreFaces)[_nf - 2] = f; }
_nf++;
}
if(_moreFaces && _nf <= 2) _moreFaces->clear();
else if(_moreFaces) _moreFaces->resize(_nf - 2);
}
构造函数(第 97--110 行)做规范化端点顺序 与双向注册:
cpp
BDS_Edge(BDS_Point *A, BDS_Point *B) : _nf(0), _moreFaces(nullptr), deleted(false), g(nullptr)
{
if(*A < *B) { p1 = A; p2 = B; } else { p1 = B; p2 = A; } // p1->iD < p2->iD
p1->edges.push_back(this);
p2->edges.push_back(this);
}
规范化保证边 (A,B) 与 (B,A) 是同一等价类,find_edge 才能命中。再看 commonvertexQuiet(第 135--140 行)与报错版 commonvertex(第 125--132 行):
cpp
// same, without the error message: used to fill BDS_Face's node cache, where
// a failure must still be reported by getNodes() and not at construction
inline BDS_Point *commonvertexQuiet(const BDS_Edge *other) const
{
if(p1 == other->p1 || p1 == other->p2) return p1;
if(p2 == other->p1 || p2 == other->p2) return p2;
return nullptr;
}
为什么要有"不报错"版本? 它在 BDS_Face 构造时被调用,若不慎返回 null,构造阶段不应报错 (那一刻还没有完整上下文),而应把"这是不是坏面"留到 getNodes() 判定------这是把诊断信息 推迟到真正被使用时。
length()(第 117--122 行)是三维欧氏长度 (度量长度在 NewGetLc 里另算); numTriangles()(BDS.cpp 第 1133--1139 行)统计三角形邻居数:
cpp
int BDS_Edge::numTriangles() const
{
int n = 0;
for(int i = 0; i < numfaces(); i++) if(faces(i)->numEdges() == 3) n++;
return n;
}
因为 BDS_Face 既可能三角形也可能四边形,故"面数"与"三角形数"不是一回事。recover_edge 里 if(e->numfaces() != e->numTriangles()) return nullptr;(第 437 行)------只被四边形共享的 边不参与恢复 。otherFace 取"对面的面",oppositeof/computeNeighborhood 求边两侧三角 形的对角顶点。
5.4.4 BDS_Face:_n4 节点缓存
(第 203--295 行,注释第 212--213 行)
cpp
class BDS_Face {
public:
BDS_Face(BDS_Edge *A, BDS_Edge *B, BDS_Edge *C, BDS_Edge *D = nullptr)
: deleted(false), e1(A), e2(B), e3(C), e4(D), g(nullptr)
{
e1->addface(this); e2->addface(this); e3->addface(this);
if(e4) e4->addface(this);
// the edges of a face never change once it is built, so its nodes can be
// computed once here instead of on every getNodes() call
if(!e4) {
_n[0] = e1->commonvertexQuiet(e3); _n[1] = e1->commonvertexQuiet(e2);
_n[2] = e2->commonvertexQuiet(e3); _n[3] = nullptr;
_nodes = _n[0] && _n[1] && _n[2];
}
else { ... 四个 commonvertexQuiet,_nodes = 全部非空 ... }
}
面的边在构造后不再变化,于是构造时一次算好节点并缓存 在 _n[4],_nodes 布尔标记表示 缓存是否有效。getNodes 变成直接读取(第 256--266 行):
cpp
inline bool getNodes(BDS_Point *n[4]) const
{
if(_nodes) { n[0] = _n[0]; n[1] = _n[1]; n[2] = _n[2]; n[3] = _n[3]; return true; }
return _reportInvalidNodes(n);
}
仅当缓存标记为假(异常面)才回退 _reportInvalidNodes(第 278--294 行)重新查表并报错------注意 回退版用会报错的 commonvertex 而非常 Quiet 版,恰好符合 5.4.3 那句注释的意图 。 getNodes 返回 bool,调用方必须检查(如 computeNeighborhood 里 if(!faces(0)->getNodes(pts1)) return;)。deleted 是软删除标记,cleanup() 时才真正 delete,避免遍历中途释放指针导致悬垂。
5.4.5 BDS_Mesh:容器与基本操作
(BDS.h 第 417--474 行)
cpp
class BDS_Mesh {
public:
int MAXPOINTNUMBER;
BDS_Mesh(int MAXX = 0) : MAXPOINTNUMBER(MAXX) {}
std::set<BDS_GeomEntity *, GeomLessThan> geom;
std::set<BDS_Point *, PointLessThan> points;
std::vector<BDS_Point *> orphanPoints;
std::vector<BDS_Edge *> edges;
std::vector<BDS_Face *> triangles;
BDS_Point *add_point(int num, double u, double v, GFace *gf);
BDS_Edge *recover_edge(int p1, int p2, bool &_fatal, std::set<EdgeToRecover> *e2r = nullptr,
std::set<EdgeToRecover> *not_recovered = nullptr);
bool swap_edge(BDS_Edge *, const BDS_SwapEdgeTest &theTest, bool force = false);
bool collapse_edge_parametric(BDS_Edge *, BDS_Point *, bool = false);
bool smooth_point_centroid(BDS_Point *p, GFace *gf, double thresh);
bool split_edge(BDS_Edge *, BDS_Point *, bool check_area_param = false);
void cleanup();
void removeOrphanPoints();
};
要点:
points/geom用std::set(需唯一性、可二分),edges/triangles用std::vector(只需遍历与追加)------刻意的混合选择。orphanPoints是"被边折叠孤立出来的点"的暂存区。注释(第 470--472 行)解释为何不能直接 删:"The points themselves are kept alive because callers key external maps on them."------ 外部std::map<BDS_Point*, ...>仍以这些指针为键,删除会造成悬垂,故只从points移出、 延迟到析构统一释放。MAXPOINTNUMBER跟踪已分配最大 id,供"只允许折叠 id 大于MAXNP的点"类判定(5.7.4)。
add_point 第二重载是参数化进入 BDS 的唯一入口:
cpp
BDS_Point *BDS_Mesh::add_point(int num, double u, double v, GFace *gf)
{
GPoint gp = gf->point(u, v);
BDS_Point *pp = new BDS_Point(num, gp.x(), gp.y(), gp.z());
pp->u = u; pp->v = v;
points.insert(pp);
MAXPOINTNUMBER = std::max(MAXPOINTNUMBER, num);
return pp;
}
add_edge 先 find_edge 查重再追加;add_triangle 先建三条边再建面。del_face 只从边里 摘除自己并置 deleted、不 delete 对象:
cpp
void BDS_Mesh::del_face(BDS_Face *t)
{
if(!t) return;
t->e1->del(t); t->e2->del(t); t->e3->del(t);
if(t->e4) t->e4->del(t);
t->deleted = true; // 只置标记
}
真正释放交给 cleanup()(第 709--730 行)------它用 std::partition 把未删除的移到前面再释放 后半段("逻辑删除 + 批量物理删除"两阶段,在并行/回溯场景下很安全):
cpp
void BDS_Mesh::cleanup()
{
auto last = std::partition(triangles.begin(), triangles.end(), is_not_deleted());
for(auto it = last; it != triangles.end(); ++it) delete *it;
triangles.erase(last, triangles.end());
... // edges 同理
}
find_edge(BDS_Point*, BDS_Point*, BDS_Face*)(第 482--493 行)有一段性能注释:
cpp
// this only needs the two ids in the order BDS_Edge would store them; it
// used to build two BDS_Points and a BDS_Edge on the stack to get them,
// which allocated the two points' edge lists on every call
const int a = std::min(p1->iD, p2->iD), b = std::max(p1->iD, p2->iD);
if(t->e1->p1->iD == a && t->e1->p2->iD == b) return t->e1;
if(t->e2->p1->iD == a && t->e2->p2->iD == b) return t->e2;
if(t->e3->p1->iD == a && t->e3->p2->iD == b) return t->e3;
return nullptr;
旧实现为复用 BDS_Edge 构造函数,在栈上建两个 BDS_Point 与一个 BDS_Edge------而 BDS_Edge 构造会往两个点的 edges 里 push_back,即每次查询都产生堆分配 。新实现只比 id,零分配------这是"为查询路径消除隐式分配"的典型案例。add_geom/get_geom 用 std::set 去重。
5.4.6 外围染色与清理
初始三角化会把整个凸包都三角化,包括域外的三角形 ,必须标出并清掉。 colorExteriorTriangles(meshGFace.cpp 第 771--844 行)分三步:清空所有 t->g;找"与四个假点 (iD < 0)相连"的三角形,从它做 recur_tag(t, CLASS_EXTERIOR) 洪水填充标出外部;找一条 "一侧外部、一侧内部"的边,从内侧面填 CLASS_F,再把 CLASS_EXTERIOR 清空:
cpp
static void colorExteriorTriangles(BDS_Mesh *m, BDS_GeomEntity *CLASS_F,
BDS_GeomEntity *CLASS_EXTERIOR, bool tagFromOppositeNegativeNode)
{
for(auto t : m->triangles) t->g = nullptr;
for(auto t : m->triangles) {
BDS_Point *n[4];
if(!t->deleted && t->getNodes(n) && (n[0]->iD < 0 || n[1]->iD < 0 || n[2]->iD < 0)) {
recur_tag(t, CLASS_EXTERIOR); break; // 从"连着假点"的面出发填外部
}
}
for(auto e : m->edges)
if(e->g && e->numfaces() == 2) {
if(e->faces(0)->g == CLASS_EXTERIOR) { recur_tag(e->faces(1), CLASS_F); break; }
else if(e->faces(1)->g == CLASS_EXTERIOR) { recur_tag(e->faces(0), CLASS_F); break; }
}
for(auto t : m->triangles) if(t->g == CLASS_EXTERIOR) t->g = nullptr;
...
if(tagFromOppositeNegativeNode) { ... 再从"对角点是假点"的边填一次内部 ... }
}
CLASS_F(1,2) 与 CLASS_EXTERIOR(1,3) 在 meshGenerator 里构造(第 1466--1467 行)------ classif_degree 都是 1、tag 分别 2 与 3,是"借用曲线编号空间表示内部/外部"的约定。 recur_tag(BDS.cpp 第 672--693 行)是显式栈的洪水填充:
cpp
void recur_tag(BDS_Face *t, BDS_GeomEntity *g)
{
std::stack<BDS_Face *> _stack; _stack.push(t);
while(!_stack.empty()) {
t = _stack.top(); _stack.pop();
if(!t->g) {
t->g = g;
if(!t->e1->g && t->e1->numfaces() == 2) _stack.push(t->e1->otherFace(t));
if(!t->e2->g && t->e2->numfaces() == 2) _stack.push(t->e2->otherFace(t));
if(!t->e3->g && t->e3->numfaces() == 2) _stack.push(t->e3->otherFace(t));
}
}
}
关键守卫 !e->g :一旦某条边被"染色"(成边界边),洪水填充就不再跨过它------这正是"边界把 内部分成不连通区域"的机制;numfaces() == 2 保证只在流形内部传播。 tagFromOppositeNegativeNode 是两条生成器的一个分歧:只有非周期生成器最后还会从"对角点 是假点"的边再填一次内部(注释第 765--770 行指出"that pass really does fire - on most faces of the regression suite - so unifying it would change meshes"),故参数化而非统一。
pruneAndCleanupBDS(第 500--533 行)染完色后清理,其中点的分类继承规则值得细看:
cpp
static void pruneAndCleanupBDS(BDS_Mesh *m, BDS_GeomEntity *CLASS_F)
{
for(auto t : m->triangles) if(!t->g) m->del_face(t); // 未染色(域外)的面删除
m->cleanup();
for(auto e : m->edges) {
if(e->numfaces() == 0) m->del_edge(e); // 无面边删除
else {
if(!e->g) e->g = CLASS_F; // 无分类的边归为面内
if(!e->p1->g || e->p1->g->classif_degree > e->g->classif_degree) e->p1->g = e->g;
if(!e->p2->g || e->p2->g->classif_degree > e->g->classif_degree) e->p2->g = e->g;
}
}
m->cleanup();
m->del_point(m->find_point(-1)); // 删四个假点
m->del_point(m->find_point(-2));
m->del_point(m->find_point(-3));
m->del_point(m->find_point(-4));
}
classif_degree > e->g->classif_degree 时才用边的分类覆盖点------"低维度优先":点已在曲线 上(degree 1)就不会被面内边(degree 2)的分类覆盖,保证"边界点永远保留边界分类"。
5.4.7 边翻转测试族 BDS_SwapEdgeTest
BDS 的边翻转必须通过可行性测试。测试族(BDS.h 第 331--384 行):
cpp
class BDS_SwapEdgeTest {
public:
virtual bool operator()(BDS_Point *p1, BDS_Point *p2, BDS_Point *q1, BDS_Point *q2) const = 0;
virtual bool operator()(BDS_Point *p1, BDS_Point *p2, BDS_Point *p3, BDS_Point *q1,
BDS_Point *q2, BDS_Point *q3, BDS_Point *op1, BDS_Point *op2,
BDS_Point *op3, BDS_Point *oq1, BDS_Point *oq2,
BDS_Point *oq3) const = 0;
virtual ~BDS_SwapEdgeTest() {}
};
两个重载的原因 :第一个是"翻转前快速检查"(翻转边两端点 + 两个对角点),第二个是"翻转后 完整检查"(翻转前后两个"四边形"的六个顶点)。实现有三个:BDS_SwapEdgeTestRecover(只查 翻转后四边形严格凸)、BDS_SwapEdgeTestQuality(查质量)、BDS_SwapEdgeTestNormals(查法向, 仅非 Gmsh 内核的最终阶段)。
BDS_SwapEdgeTestQuality 第一重载(BDS.cpp 第 905--929 行):
cpp
if(!testSmallTriangles) return true;
// AVOID CREATING POINTS WITH 2 NEIGHBORING TRIANGLES
if(_p1->g && _p1->g->classif_degree == 2 && _p1->edges.size() <= 4) return false;
if(_p2->g && _p2->g->classif_degree == 2 && _p2->edges.size() <= 4) return false;
if(_p1->g && _p1->g->classif_degree < 2 && _p1->edges.size() <= 3) return false;
if(_p2->g && _p2->g->classif_degree < 2 && _p2->edges.size() <= 3) return false;
double s1 = fabs(surface_triangle_param(_p1, _p2, _q1));
double s2 = fabs(surface_triangle_param(_p1, _p2, _q2));
double s3 = fabs(surface_triangle_param(_p1, _q1, _q2));
double s4 = fabs(surface_triangle_param(_p2, _q1, _q2));
if(fabs(s1 + s2 - s3 - s4) > 1.e-12 * (s3 + s4)) return false; // 参数面积守恒
return true;
两块逻辑:(1) 避免制造只有两个相邻三角形的点 (面内点 ≤4 条边、边界点 ≤3 条边即拒绝), 把拓扑健康度纳入质量判据;(2) 参数面积守恒 校验(翻转前后四块面积和不变,容差 1e-12)。
第二重载(第 931--973 行)做真正的质量比较:
cpp
// Check if new edge is not on a seam or degenerated
BDS_Point *p1 = nullptr, *p2 = nullptr;
if(_op1 != _oq1 && _op1 != _oq2 && _op1 != _oq3) { p1 = _op2; p2 = _op3; }
else if(_op2 != _oq1 && _op2 != _oq2 && _op2 != _oq3) { p1 = _op1; p2 = _op3; }
else if(_op3 != _oq1 && _op3 != _oq2 && _op3 != _oq3) { p1 = _op1; p2 = _op2; }
else Msg::Warning("Unable to detect the new edge in BDS_SwapEdgeTestQuality\n");
if(p1 && p2) {
if(p1->degenerated && p2->degenerated) return false; // 退化点之间的边
if(p1->_periodicCounterpart && p2->_periodicCounterpart) return false; // 缝两侧对应点
}
if(!testQuality) return true;
double const mina = std::min(qmTriangle::gamma(_p1,_p2,_p3), qmTriangle::gamma(_q1,_q2,_q3));
double const minb = std::min(qmTriangle::gamma(_op1,_op2,_op3), qmTriangle::gamma(_oq1,_oq2,_oq3));
return minb > mina; // 翻转后最小 gamma 质量更大才允许
BDS_SwapEdgeTestNormals(第 975--1006 行)把质量与法向结合:OLD/NEW 分别取翻转前后四块 三角形 gamma × _cos_N 的最小值,仅当 OLD < 0.5 && OLD < NEW 才翻转。_cos_N (BDS.cpp 第 29--67 行)是"三角形几何法向与曲面法向的余弦",且对退化点特殊处理(把退化点的 自由参数剔除出形心平均):
cpp
static double _cos_N(BDS_Point *_p1, BDS_Point *_p2, BDS_Point *_p3, GFace *gf)
{
double n[3]; normal_triangle(_p1, _p2, _p3, n); // 三角形几何法向
double u = 0., v = 0.; int nu = 0, nv = 0;
for(int i = 0; i < 3; i++) { // 退化点的自由坐标剔除出形心
if(p[i]->degenerated != 1) { u += p[i]->u; nu++; }
if(p[i]->degenerated != 2) { v += p[i]->v; nv++; }
}
SVector3 N = gf->normal(SPoint2(u, v));
double c = N.x()*n[0] + N.y()*n[1] + N.z()*n[2];
for(int i = 0; i < 3; i++) { // 角点法向与形心法向比较
if(p[i]->degenerated) continue;
SVector3 Ni = gf->normal(SPoint2(p[i]->u, p[i]->v));
if((Ni.x()*n[0] + Ni.y()*n[1] + Ni.z()*n[2]) * c >= 0.) return c;
A += Ni; k++;
}
...
}
文件头注释(第 21--28 行)解释这套两级判断:只看角点法向 在小区域高曲率处(如 U 形后缘, issue #2472)会误导;只看形心法向 也糟------薄锥面的三角形参数像几乎绕轴一整圈,形心落在 锥对侧、法向反了,折叠的三角形反而"看起来合法"。故规则是:当角点法向与形心法向全部不 一致时,以角点法向为准 ------即代码里 (Ni·n) * c >= 0. 那行"若有点与形心同号就返回形心 结果,否则用角点平均"。
5.4.8 swap_edge:翻转的执行
BDS_Mesh::swap_edge(BDS.cpp 第 1008--1131 行)是翻转执行体:
cpp
bool BDS_Mesh::swap_edge(BDS_Edge *e, const BDS_SwapEdgeTest &theTest, bool force)
{
if(e->deleted) return false;
if(e->numfaces() != 2) return false;
if(e->g && e->g->classif_degree == 1) return false; // 模型边不翻转
BDS_Point *op[2], *pts1[4], *pts2[4];
e->computeNeighborhood(pts1, pts2, op);
if(!op[0] || !op[1]) return false;
if(!force && !p1->config_modified && !p2->config_modified &&
!op[0]->config_modified && !op[1]->config_modified) return false; // 局部性优化
int orientation = 0;
for(int i = 0; i < 3; i++)
if(pts1[i] == p1) { orientation = pts1[(i + 1) % 3] == p2 ? 1 : -1; break; }
// 先"翻转后完整检查",再"翻转前快速检查"
if(orientation == 1) {
if(!theTest(p1,p2,op[0], p2,p1,op[1], p1,op[1],op[0], op[1],p2,op[0])) return false;
}
else {
if(!theTest(p2,p1,op[0], p1,p2,op[1], p1,op[0],op[1], op[1],op[0],p2)) return false;
}
if(!theTest(p1, p2, op[0], op[1])) return false;
BDS_Edge *p1_op1 = find_edge(p1, op[0], e->faces(0));
BDS_Edge *op1_p2 = find_edge(op[0], p2, e->faces(0));
BDS_Edge *p1_op2 = find_edge(p1, op[1], e->faces(1));
BDS_Edge *op2_p2 = find_edge(op[1], p2, e->faces(1));
if(p1_op1 == p1_op2 || op2_p2 == op1_p2) return false; // 退化
if(e->faces(0)) { g1 = e->faces(0)->g; del_face(e->faces(0)); }
// not a bug !!! 第一次删 faces(0) 后,原来的 faces(1) 会滑到 faces(0)
if(e->faces(0)) { g2 = e->faces(0)->g; del_face(e->faces(0)); }
del_edge(e);
edges.push_back(new BDS_Edge(op[0], op[1])); // 新边 = 两个对角点
... // 依 orientation 建 t1,t2,继承 g1,g2,ge
p1->config_modified = p2->config_modified = true;
op[0]->config_modified = op[1]->config_modified = true;
return true;
}
几个"坑点":e->g->classif_degree == 1 直接拒绝 (模型边不可翻转,否则边界被破坏------分类 保持第一道闸);// not a bug !!! (第 1098 行)------第一次 del_face 后 _f[0] 被压缩、 原 _f[1] 滑到 _f[0],故第二次仍写 faces(0),注释提醒"这不是 bug";ge = e->g 在 函数开头保存 ,新边继承旧边分类;翻转后四个相关点置 config_modified = true 触发局部重判。
5.4.9 collapse_edge_parametric 与 split_edge
collapse_edge_parametric(第 1157--1320 行)把边 e 的一个端点合并到另一端。第一段守卫:
cpp
if(!force && e->numfaces() != 2) return false;
if(!force && p->g && p->g->classif_degree == 0) return false; // 模型顶点不折叠
if(!force && p->g && p->g->classif_degree == 1) return false; // 模型边点不折叠
if(!force && e->g && p->g) { if(e->g->classif_degree == 2 && p->g != e->g) return false; }
注释说明动机:"not really ok but 'til now this is the best choice not to do collapses on model edges"------不在模型边上折叠 ,因为那会破坏边界。接着是周期性守卫(第 1176--1186 行):若 p1 的某邻居与 p2 的某邻居互为 _periodicCounterpart,折叠会把缝两侧粘成同一点,拒绝:
cpp
for(size_t i = 0; i < e->p1->edges.size(); i++)
for(size_t j = 0; j < e->p2->edges.size(); j++)
if(e->p1->edges[i]->othervertex(e->p1)->_periodicCounterpart ==
e->p2->edges[j]->othervertex(e->p2)) return false;
随后统计"星形邻域"的面积变化与最小质量,用 Cavity 结构缓存(第 1219--1233 行):
cpp
// these used to be four 1024-entry stack arrays, indexed by the number of
// triangles and of edges around p with no bound check at all
struct Cavity { std::vector<BDS_Point *> pt[3], ept[2]; std::vector<BDS_GeomEntity *> gs, egs; };
static thread_local Cavity c;
注释坦承旧实现有栈数组越界风险;新实现用 thread_local 缓存 std::vector------**"每线程复用 缓冲"**是并行网格化的常见优化。代码做两类完整性检查(snew < .02 * sold 拒绝"折叠产生退化 三角形";面积不守恒拒绝),只有参数面积守恒且无极小三角形时才真正折叠。
split_edge(第 756--875 行)把边 e 在 mid 处一分为二、生成四个三角形;可选 check_area_param 做面积守恒检查(1.1/0.9 是经验阈值,注释直接写 heuristic),并按 orientation 把几何分类 g1,g2,ge 正确分配到新对象(第 851--861 行)。
5.4.10 smooth_point_centroid:Tutte 能量平滑
BDS_Mesh::smooth_point_centroid(第 1724--1784 行)先做守卫,再取星形邻域按序排列 (getOrderedNeighboringVertices,第 1347--1426 行),计算 Tutte 能量:
cpp
static inline double getTutteEnergy(const BDS_Point *p, const std::vector<BDS_Point *> &nbg,
double &RATIO)
{
if(nbg.empty()) return 1.e22;
double E = 0; double MAX = 0., MIN = 0.;
for(size_t i = 0; i < nbg.size(); ++i) {
const double dx = p->X-nbg[i]->X, dy = p->Y-nbg[i]->Y, dz = p->Z-nbg[i]->Z;
const double l2 = dx*dx + dy*dy + dz*dz;
MAX = i ? std::max(MAX, l2) : l2;
MIN = i ? std::min(MIN, l2) : l2;
E += l2;
}
if(!MAX) return 1.e22;
RATIO = MIN / MAX; // 最短/最长邻边平方比,判邻域是否退化
return E;
}
getOrderedNeighboringVertices 有一处性能注释:旧实现每绕一步都重新算三角形节点(O(n²)), 新实现先一次性缓存"每个三角形里不是 p 的两个点"再线性组装------与 5.4.5 的 find_edge 同属 "消除重复计算"。平滑先在参数域尝试(minimizeTutteEnergyParam),不成功再在三维尝试并投影 (minimizeTutteEnergyProj):
cpp
if(!minimizeTutteEnergyParam(p, E_unmoved, RATIO, nbg, kernel, lc, gf, CHECK)) {
if(!minimizeTutteEnergyProj(p, E_unmoved, RATIO, nbg, kernel, lc, gf, CHECK)) return false;
}
else { p->config_modified = true; }
return true;
BDS_SmoothScratch(BDS.h 第 409--415 行)复用 nbg/lc/kernel/ts 四个缓冲,避免"每点 每趟分配四个 vector"(在 smoothVertexPass 里被跨点复用)。kernel("核")是待移动点周围 由邻点参数坐标构成的多边形区域(computeSomeKindOfKernel),把理想位置限制在可行区域内------ 让平滑"不越界"的关键机制。
5.4.11 几何分类保持:BDS 的隐性契约
把本节串起来看,BDS 的所有局部操作都遵守一条隐性契约:绝不破坏几何分类 。翻转拒绝 classif_degree == 1 的模型边;折叠拒绝模型点/模型边上的端点并带周期性守卫;分裂显式把 g1,g2,ge 分配到新对象;平滑拒绝 classif_degree <= 1 的点并跳过边界层节点。这就是 BDS_GeomEntity 存在的全部理由------理解了它,就理解了为什么 BDS 每个操作都要拐弯抹角地看 g。
5.5 边界恢复:让边界边真的出现在网格里
5.5.1 问题定义与两个基本构件
Delaunay 三角化只保证"空外接圆",不保证指定的边一定出现在三角网里 。但二维曲面网格 必须让边界曲线(GEdge)上的每条一维网格线段都成为网格边,否则曲面网格与边界网格不匹配, 第 6 章的三维网格会留下裂缝。这就是边界恢复(boundary recovery)。
两个基本构件:
-
EdgeToRecover(BDS.h 第 386--407 行)记录"哪条待恢复边":cppstruct EdgeToRecover { int p1, p2; GEdge *ge; EdgeToRecover(int _p1, int _p2, GEdge *_ge) : ge(_ge) { if(_p1 < _p2) { p1 = _p1; p2 = _p2; } else { p2 = _p1; p1 = _p2; } } bool operator<(const EdgeToRecover &other) const { ... p1/p2 字典序 ... } };构造时规范化端点顺序 (
p1 < p2)并提供operator<,故可放进std::set<EdgeToRecover>;ge是这条边所属的模型曲线(诊断用)。 -
recoverMap/recoverMapInv:一对互逆映射,把"BDS 点"和"Gmsh 网格顶点"关联。meshGenerator(第 1468--1469 行)声明:cppstd::map<BDS_Point *, MVertex *, PointLessThan> recoverMap; std::map<MVertex *, BDS_Point *> recoverMapInv;buildBDSPoints(第 1000--1026 行)同时维护两者,并累积参数包围盒bbox += SPoint3(param[0], param[1], 0)供插入四个假点用。为何要正反两张表? 恢复时以"Gmsh 一维线段端点"为输入 查 BDS 点(用recoverMapInv);BDS2GMSH回填时以"BDS 点"为输入查/新建网格顶点(用recoverMap)。方向相反,各取所需。
5.5.2 两遍(two-pass)策略
核心是 recoverEdge(meshGFace.cpp 第 320--388 行):
cpp
static bool recoverEdge(BDS_Mesh *m, GFace *gf, GEdge *ge,
std::map<MVertex *, BDS_Point *> &recoverMapInv,
std::set<EdgeToRecover> *e2r,
std::set<EdgeToRecover> *notRecovered, int pass)
{
BDS_GeomEntity *g = nullptr;
if(pass == 2) { m->add_geom(ge->tag(), 1); g = m->get_geom(ge->tag(), 1); } // 第 2 遍才需分类对象
bool _fatallyFailed;
for(std::size_t i = 0; i < ge->lines.size(); i++) {
MVertex *vstart = ge->lines[i]->getVertex(0), *vend = ge->lines[i]->getVertex(1);
auto itpstart = recoverMapInv.find(vstart), itpend = recoverMapInv.find(vend);
if(itpstart != recoverMapInv.end() && itpend != recoverMapInv.end()) {
BDS_Point *pstart = itpstart->second, *pend = itpend->second;
if(pass == 1)
e2r->insert(EdgeToRecover(pstart->iD, pend->iD, ge)); // 第 1 遍:只登记
else {
BDS_Edge *e = m->recover_edge(pstart->iD, pend->iD, _fatallyFailed, e2r, notRecovered);
if(e) e->g = g; // 第 2 遍:真正恢复
else {
if(_fatallyFailed) {
Msg::Warning("Unable to recover the edge %d (%d/%d) on curve %d (on surface %d)",
ge->lines[i]->getNum(), i + 1, ge->lines.size(), ge->tag(), gf->tag());
if(Msg::GetVerbosity() == 99) {
outputScalarField(m->triangles, "wrongmesh.pos", 0); // 欧氏空间
outputScalarField(m->triangles, "wrongparam.pos", 1); // 参数空间
}
}
return !_fatallyFailed;
}
}
}
}
... // 第 2 遍末尾:给还没分类的端点补 (degree 0)
return true;
}
要点:
- 第 1 遍(
pass == 1)只登记 :把"两端点都在 BDS 里"的一维线段登记到e2r。为什么先 登记? 因为recover_edge在翻转中需要知道"哪些边是必须保留的模型边",才能给出诊断 ("edge A on model edge X cannot be recovered because it intersects B on model edge Y")。 - 第 2 遍才真正恢复 :成功则把边的几何分类设为
g;失败且致命则报错、可选输出双视图, 返回!_fatallyFailed。 _fatallyFailed语义 :区分"这条边根本恢复不了"(置 true)与"因一维网格自交而无法 恢复"(走notRecovered分支但不置 fatal)。前者报Msg::Warning,后者交remeshUnrecoveredEdges。- 第 2 遍末尾 处理退化/缝边端点:若曲线起止顶点在 BDS 里还没分类(
!pstart->g),补add_geom(pstart->iD, 0)(degree 0 = 点)。
两遍策略的价值:先把所有要求搞清楚,再动手做------否则边恢复过程中才知道要什么,就无法在 诊断里指出"被交叉的那条边属于哪条模型曲线"。
5.5.3 recover_edge:翻转恢复算法
BDS_Mesh::recover_edge(BDS.cpp 第 389--480 行)核心是"沿要恢复的线段,逐段翻转掉与它相交 的边":
cpp
BDS_Edge *BDS_Mesh::recover_edge(int num1, int num2, bool &_fatal,
std::set<EdgeToRecover> *e2r,
std::set<EdgeToRecover> *not_recovered)
{
BDS_Edge *e = find_edge(num1, num2);
_fatal = false;
if(e) return e; // 已存在,无需恢复
BDS_Point *p1 = find_point(num1), *p2 = find_point(num2);
if(!p1 || !p2) { Msg::Error("Could not find points %d or %d in BDS mesh", num1, num2); return nullptr; }
int ix = 0; double x[2];
while(1) {
std::vector<BDS_Edge *> intersected; bool selfIntersection = false;
for(auto it = edges.begin(); it != edges.end(); ++it) {
e = (*it);
if(!e->deleted && e->p1 != p1 && e->p1 != p2 && e->p2 != p1 && e->p2 != p2)
if(Intersect_Edges_2d(e->p1->u, e->p1->v, e->p2->u, e->p2->v,
p1->u, p1->v, p2->u, p2->v, x)) {
if(e2r && e2r->find(EdgeToRecover(e->p1->iD, e->p2->iD, nullptr)) != e2r->end()) {
not_recovered->insert(EdgeToRecover(num1, num2, itr2->ge)); // 一维网格自交
not_recovered->insert(EdgeToRecover(e->p1->iD, e->p2->iD, itr1->ge));
selfIntersection = true;
}
if(e->numfaces() != e->numTriangles()) return nullptr; // 四边形边无法翻转
intersected.push_back(e);
}
}
if(selfIntersection) return nullptr;
if(!intersected.size() || ix > 300) { // 无相交 或 迭代上限
BDS_Edge *eee = find_edge(num1, num2);
if(!eee) { ... _fatal = true; return nullptr; }
return eee;
}
std::vector<int>::size_type ichoice = 0; bool success = false;
while(!success && ichoice < intersected.size())
success = swap_edge(intersected[ichoice++], BDS_SwapEdgeTestRecover());
if(!success) { _fatal = true; return nullptr; }
ix++;
}
}
逐段:
Intersect_Edges_2d(BDS.cpp 第 349--363 行)用 2×2 线性系统求两条线段参数交点,再判断 参数都在[0,1]:mat的列分别是两条线段方向、rhs是起点差向量,sys2x2(mat, rhs, x)解出参数x[0]/x[1],只有两者都落在[0,1]才算相交。注意它在参数平面(u,v)里 判断------恢复是参数空间里的事。- 自交检测 :若相交的边本身也在
e2r里(也是一条必须恢复的模型边),说明两条模型边在 参数空间交叉 (一维网格自交),把两条边都记入not_recovered并返回,交给remeshUnrecoveredEdges。 e->numfaces() != e->numTriangles()返回 null:属于四边形的边无法用三角形翻转消除。- 翻转循环 对每条相交边尝试
swap_edge(..., BDS_SwapEdgeTestRecover())("翻转后严格凸"), 成功一条就重扫;ix > 300是迭代上限 ,防病态无限循环;_fatal = true只在"确实恢复 不了"时设置。
5.5.4 一维网格自交:remeshUnrecoveredEdges 与重试闭环
recoverBoundaryEdges(第 1161--1244 行)发现 edgesNotRecovered 非零时进入"加密一维网格再 重来"分支,并返回三态枚举(第 1149--1153 行):
cpp
enum class EdgeRecovery {
Recovered, // every model edge is now an edge of the triangulation
Failed, // unrecoverable, the face is marked FAILED
Retry // the 1D mesh self-intersects; it has been refined, mesh again
};
cpp
if(edgesNotRecovered.size() || gf->meshStatistics.refineAllEdges) {
...
if(repairSelfIntersecting1dMesh) {
remeshUnrecoveredEdges(recoverMapInv, edgesNotRecovered);
gf->meshStatistics.refineAllEdges = false;
}
return EdgeRecovery::Retry;
}
Retry 让 meshGenerator 递归重试(meshGenerator(gf, RECUR_ITER + 1, ...),第 1517--1520 行),直到 RECUR_ITER >= CTX::instance()->mesh.maxRetries。remeshUnrecoveredEdges(第 201--299 行)把每条未恢复的一维线段在中点一分为二 ,按 BGM_MeshSize 重建目标尺寸:
cpp
// should be better, i.e. equidistant
double t = 0.5 * (t2 + t1);
double lc = 0.5 * (lc1 + lc2);
GPoint V = itr->ge->point(t);
MEdgeVertex *newv = new MEdgeVertex(V.x(), V.y(), V.z(), itr->ge, t, 0, lc);
newLines.push_back(new MLine(v1, newv));
newLines.push_back(new MLine(newv, v2));
注释"should be better, i.e. equidistant"说明中点取参数平均是"近似等距"的简化。函数先 deMeshGFace 掉相邻面网格并把状态设为 PENDING(加密一维后相邻面必须重网格);周期性场景用 recoverMultiMapInv(std::multimap)处理"一个顶点对应两个 BDS 点"的缝情况。另有**"全体 分裂"**策略:gf->meshStatistics.refineAllEdges 为真时打印带表情的日志 Msg::Info("8-| Splitting all edges and trying again"),且 meshGeneratorPeriodic 里有 nbRefineAllEdges < 3 上限(第 2161--2171 行)------每面最多"全体分裂"三次,因为每次加倍 节点数,且注释指出锥顶点附近"the circumference always ends up smaller than the mesh size", 无解。
5.5.5 失败诊断
设计很贴心:
- verbosity 99 时
recoverEdge把错误网格写成wrongmesh.pos(欧氏)/wrongparam.pos(参数) 两个视图(第 355--358 行)。 debugViews(第 488--495 行)输出surface<tag>-<what>-real.pos与...-param.pos。recoverBoundaryEdges在 debug 时写整个模型.msh:surface%d-not_yet_recovered-real-%d.msh(第 1219--1224 行)。BDS_Mesh::recover_edge在 verbosity > 98 时输出debugp.pos/debugr.pos(第 448--455 行)。
参数空间视图尤其关键:曲面网格的失败几乎都在参数平面里显现(自交、退化),而欧氏空间里 可能"看起来正常"。让开发者两个视角都能看,是很实用的调试手段。
5.6 从 BDS 回到 Gmsh:BDS2GMSH
5.6.1 BDS2GMSH
算法在 BDS 里跑完,最终要把结果转成 Gmsh 的 MTriangle/MQuadrangle(meshGFace.cpp 第 390--424 行):
cpp
static void
BDS2GMSH(BDS_Mesh *m, GFace *gf, std::map<BDS_Point *, MVertex *, PointLessThan> &recoverMap)
{
auto itt = m->triangles.begin();
while(itt != m->triangles.end()) {
BDS_Face *t = *itt;
if(!t->deleted) {
BDS_Point *n[4]; t->getNodes(n);
MVertex *v[4] = {nullptr, nullptr, nullptr, nullptr};
for(int i = 0; i < 4; i++) {
if(!n[i]) continue;
if(recoverMap.find(n[i]) == recoverMap.end()) {
v[i] = new MFaceVertex(n[i]->X, n[i]->Y, n[i]->Z, gf, n[i]->u, n[i]->v);
recoverMap[n[i]] = v[i];
gf->mesh_vertices.push_back(v[i]);
}
else v[i] = recoverMap[n[i]];
}
if(!v[3]) { // 三角形
// when a singular point is present, degenerated triangles may be
// created, for example on a sphere that contains one pole
if(v[0] != v[1] && v[0] != v[2] && v[1] != v[2])
gf->triangles.push_back(new MTriangle(v[0], v[1], v[2]));
}
else gf->quadrangles.push_back(new MQuadrangle(v[0], v[1], v[2], v[3]));
}
++itt;
}
}
三个细节:
- 新顶点一律建
MFaceVertex(携带(u,v))便于后续投影/优化;已存在的(边界点)从recoverMap直接取,不重建。 - 退化三角形被丢弃 ------注释举例"a sphere that contains one pole":球面极点处参数奇异会生成 零面积三角形(三顶点中两个重合),判据
v[0]!=v[1] && ...是顶点指针相等性 ------极点处两个 不同 BDS 点被映射到同一个MVertex。 if(!v[3])区分三角形与四边形 :getNodes对三角形把n[3]置 null,故v[3]是否被 填上即判据,无需再看e4。
5.6.2 deleteUnusedVertices
finishSurfaceMesh(第 654--662 行)算质量统计 + 清无用节点。deleteUnusedVertices (第 426--482 行)用索引标记法 (而非 std::set<MVertex*>)做 O(n) 过滤:
cpp
// O(n) via index marking instead of inserting millions of pointers into a
// std::set. Clobbering MVertex::_index is safe here: only vertices
// classified on this face are touched (private to the face, so also safe
// under the per-face OpenMP parallelism of Mesh2D) ...
// Sentinels: -2 = used by an element, -3 = already collected.
for(std::size_t i = 0; i < gf->mesh_vertices.size(); i++)
gf->mesh_vertices[i]->setIndex(-1);
for(std::size_t i = 0; i < gf->triangles.size(); i++)
for(int j = 0; j < 3; j++) {
MVertex *v = gf->triangles[i]->getVertex(j);
if(v->onWhat() == gf) v->setIndex(-2);
}
注释交代三个安全性前提:只碰"分类在这个面上"的顶点(面私有)、面之间 OpenMP 并行无竞争、 _index 在此阶段无含义。用哨兵 -2/-3 区分"被元素使用"与"已收集"避免重复插入。最后 还保持历史上"按 num 排序"的契约,且只在必要时排序:
cpp
if(!std::is_sorted(allverts.begin(), allverts.end(), MVertexPtrLessThan()))
std::sort(allverts.begin(), allverts.end(), MVertexPtrLessThan());
5.6.3 repairDuplicateTriangles:缝 + 极点的重复三角形修复
周期性曲面在"缝 + 极点"的粗网格下会生成两个物理上重合但朝向相反 的三角形。 repairDuplicateTriangles(第 547--632 行)修复它,注释把陷阱讲得很透:
cpp
// On a surface with a seam and a pole (a degenerate curve collapsed to a
// point), a coarse mesh can have an interior node adjacent to both sides of
// the seam next to the pole: the two parametric triangles joining it to the
// pole and to the first seam node are then the same real triangle, with
// opposite orientations, and the pole belongs to nothing else. The two
// triangles across the pole's ring (sharing the seam node - interior node
// edge) span the pole without touching it. Repair: drop the pair and split
// that edge at the pole, which gives a fan of four triangles around it.
实现用"排序后的三顶点"作 key 把三角形分桶(std::map<Key, std::vector<size_t>>)找出重复对, 再对每对识别"极点"(只有一个邻接三角形的顶点)、缝节点 s 与另一顶点 q,把"跨过极点环"的 两个三角形替换为绕极点展开的四块三角形扇。失败时报 Msg::Warning("Cannot repair the duplicate triangle on surface %d") 并返回 false。
5.6.4 质量统计
computeElementShapes(meshGFaceOptimize.cpp 第 1483--1521 行)计算面网格效率指标。它用数值 等价的公式替代逐三角形归一化:
cpp
// Same quantity as qmTriangle::gamma (2 * inradius / circumradius), in
// the algebraically equivalent form 4 |u x v|^2 / (a b c (a + b + c)):
// one cross product and three square roots instead of three vector
// normalizations plus three cross-product norms. Values agree to
// roundoff (max observed difference ~1e-14).
const double c2 = cx*cx + cy*cy + cz*cz;
const double la = std::sqrt(...), lb = std::sqrt(...), lc = std::sqrt(...);
const double den = la * lb * lc * (la + lb + lc);
const double q = (den > 0.) ? 4. * c2 / den : 0.;
if(q > .9) greaterThan++;
avg += q; worst = std::min(worst, q); best = std::max(best, q); nT++;
gamma 与 4|u×v|²/(abc(a+b+c)) 代数等价(注释量化误差 "max observed difference ~1e-14")。 统计进入 gf->meshStatistics(worst_element_shape、average_element_shape、 best_element_shape、nbGoodQuality),是质量报告(tau 类效率指标)的来源。
5.7 meshGenerator:二维自由网格的主控制流
5.7.1 全流程逐段
meshGenerator(meshGFace.cpp 第 1455--1604 行)是非周期面的主控制流:
cpp
bool meshGenerator(GFace *gf, int RECUR_ITER, bool repairSelfIntersecting1dMesh,
MeshExtent extent, bool debug, std::vector<GEdge *> *replacementEdges)
{
BDS_GeomEntity CLASS_F(1, 2); // 面板 → 面内分类
BDS_GeomEntity CLASS_EXTERIOR(1, 3); // 面板 → 域外分类
std::map<BDS_Point *, MVertex *, PointLessThan> recoverMap;
std::map<MVertex *, BDS_Point *> recoverMapInv;
std::vector<GEdge *> edges = replacementEdges ? *replacementEdges : gf->edges();
std::vector<GEdge *> emb_edges = gf->getEmbeddedEdges();
std::set<MVertex *, MVertexPtrLessThan> all_vertices;
if(!collectBoundaryNodes(gf, edges, emb_edges, all_vertices, debug && RECUR_ITER == 0))
return false;
if(all_vertices.size() < 3) { ...跳过... }
else if(all_vertices.size() == 3) { ...直接建一个三角形... }
BDS_Mesh *m = new BDS_Mesh; std::vector<BDS_Point *> points; SBoundingBox3d bbox;
buildBDSPoints(gf, all_vertices, m, points, bbox, recoverMap, recoverMapInv);
initialTriangulation(gf, m, points, bbox, all_vertices, recoverMapInv, replacementEdges, RECUR_ITER);
std::set<EdgeToRecover> edgesToRecover, edgesNotRecovered;
EdgeRecovery rec = recoverBoundaryEdges(gf, m, edges, emb_edges, recoverMapInv,
edgesToRecover, edgesNotRecovered,
RECUR_ITER, repairSelfIntersecting1dMesh, debug);
if(rec != EdgeRecovery::Recovered) {
delete m;
if(rec == EdgeRecovery::Retry && RECUR_ITER < CTX::instance()->mesh.maxRetries)
return meshGenerator(gf, RECUR_ITER + 1, ...); // 自交 → 递归重试
return false;
}
... // 恢复嵌入边(第 2 遍)
colorExteriorTriangles(m, &CLASS_F, &CLASS_EXTERIOR, true);
computeSizeField(m, recoverMap); // 非 BAMG 时才设尺寸
pruneAndCleanupBDS(m, &CLASS_F);
delaunayizeBDS(gf, *m, nb_swap); // 翻转至 Delaunay
if(!algoDelaunay2D(gf) && extent == MeshExtent::Full) {
refineMeshBDS(gf, *m, refineSteps, true, &recoverMapInv, nullptr); // MeshAdapt 造点
refineMeshBDS(gf, *m, refineSteps, false, &recoverMapInv, nullptr);
}
gf->meshStatistics.status = GFace::DONE;
BDS2GMSH(m, gf, recoverMap); // BDS → MVertex/MTriangle
bool infty = setupBackgroundMesh(gf, extent == MeshExtent::Full, nullptr, nullptr);
if(extent == MeshExtent::Full) modifyInitialMeshForBoundaryLayers(gf, blQuads, blTris, verts, debug);
if(algoDelaunay2D(gf) && extent == MeshExtent::Full)
runDelaunayAlgorithm(gf, infty, true, nullptr, nullptr, nullptr); // 造点
delete m;
insertBoundaryLayerElements(gf, blQuads, blTris, verts);
splitElementsInBoundaryLayerIfNeeded(gf);
if(extent != MeshExtent::BoundaryLayerRemesh) recombineSurfaceMesh(gf);
finishSurfaceMesh(gf); // 质量统计 + 清无用点
return true;
}
阶段小结:① 边界节点收集(collectBoundaryNodes,集合去重,环不闭合则 FAILED;注意 degeneratedVertices::instance() 是全局单例、无自带锁,那里的循环是并行网格化的串行化点 , #pragma omp critical,第 926--929 行);② 特殊情况短路(<3 点跳过、=3 点直接建三角形); ③ 建 BDS 点 + 初始三角化;④ 两遍边界恢复;⑤ 外围染色 + 清理 + 恢复嵌入边;⑥ 尺寸场计算 (仅非 BAMG 且 Full);⑦ Delaunay 化;⑧ 造点(refineMeshBDS 或 runDelaunayAlgorithm); ⑨ 回填 + 边界层 + 重组合 + 收尾。
5.7.2 MeshExtent:三种"走多深"
src/mesh/meshGFaceInternal.h 第 15--27 行:
cpp
enum class MeshExtent {
Full, // 全流程:尺寸场、内部点、边界层、Delaunay、重组合
InitialOnly, // 只到初始三角化,但仍参与重组合(Mesh.Algorithm = 3)
BoundaryLayerRemesh, // 同上且不重组合:边界层代码回调时用
};
InitialOnly 对应 Mesh.Algorithm = 3;BoundaryLayerRemesh 由边界层代码回调用 (meshGFaceBoundaryLayer.cpp 第 470 行)。同一函数用枚举参数表达三种深度而非三个函数, 避免大量重复。
5.7.3 MeshAdapt 路线:refineMeshBDS 的四件套
当算法不是 Delaunay 系(即 ALGO_2D_MESHADAPT)时,内部点由 refineMeshBDS (meshGFaceBDS.cpp 第 755--1003 行)添加。主循环是 split → smooth → swap → collapse → swap → smooth 的迭代,每轮统计局部修改数:
cpp
const double MINE_ = 0.7, MAXE_ = 1.4;
while(1) {
setDegeneracy(deg, degType);
int nb_split = 0, nb_smooth = 0, nb_collaps = 0, nb_swap = 0;
splitEdgePass(gf, m, MAXE_, nb_split, true_boundary, t_spl);
if(IT == 0) splitEdgePass(gf, m, MAXE_, nb_split, true_boundary, t_spl);
smoothVertexPass(gf, m, nb_smooth, false, .5, t_sm);
swapEdgePass(gf, m, nb_swap, t_sw);
smoothVertexPass(gf, m, nb_smooth, false, .5, t_sm);
collapseEdgePass(gf, m, MINE_, MAXNP, nb_collaps, t_col);
smoothVertexPass(gf, m, nb_smooth, false, .5, t_sm);
swapEdgePass(gf, m, nb_swap, t_sw);
smoothVertexPass(gf, m, nb_smooth, false, .5, t_sm);
if(IT == abs(NIT)) { collapseEdgePass(gf, m, .45, MAXNP, nb_collaps, t_col); ... }
m.cleanup(); m.removeOrphanPoints();
double minL = 1.e22, maxL = 0; // 统计每条边长度比 lone = 2*len/(lc1+lc2)
...
if((minL > MINE_ && maxL < MAXE_) || IT > (abs(NIT))) break;
IT++;
if(nb_split == 0 && nb_collaps == 0) break;
}
四个 pass:
splitEdgePass(第 435--533 行)把"太长"(长度比> 1.4)的面内边在中点分裂,中点按middlePoint/参数加权求位置,并检查ParametricDomainChecker::inside确保落在真实域内; 周期性缝两侧还补"-10.0"优先级的边(第 448--465 行)。collapseEdgePass(第 561--615 行)把"太短"(长度比< 0.7)的边折叠,折叠到哪个端点看getMaxLcWhenCollapsingEdge(哪种折叠让长度比更接近 1)。swapEdgePass(第 206--236 行)翻转改善质量:先算THRESH = cos(allowSwapEdgeAngle·π/180), 用edgeSwapTestAngle快速筛掉"翻转会造出极小角"的候选(注释:"the angle test does not depend on the four qualities below, so evaluate it first and skip them altogether when it fails"),再比质量;FINALIZE且非 Gmsh 内核时用BDS_SwapEdgeTestNormals。smoothVertexPass(第 617--630 行)对每个neighboringModified的点调smooth_point_centroid,复用一份BDS_SmoothScratch。
收敛判据 是"所有边长度比都在 0.7, 1.4 内",或达迭代上限。maxE 第 0 轮分裂两次是 heuristic(先建立尺度分布)。
5.7.4 尺寸场:NewGetLc / correctLC_ / computeNodalSizes / computeSizeField
各 pass 用统一的"边长比"函数。NewGetLc(第 137--152 行)返回"实际长度 / 目标长度":
cpp
static double NewGetLc(BDS_Point *p1, BDS_Point *p2, GFace *const face, const BDSSizeContext &ctx)
{
if(!(*p1 < *p2)) std::swap(p1, p2);
double len = face->geomType() == GEntity::Plane ?
computeEdgeLinearLength(p1, p2) : computeEdgeLinearLength(p1, p2, face);
return len / correctLC_(p1, p2, face, ctx);
}
两个设计点:
-
平面走欧氏长度,曲面走"折线长度" :
computeEdgeLinearLength(p1,p2,GFace*)(第 61--79 行) 取曲面中点,长度是|p1-mid| + |mid-p2|(中点映射失败时回退欧氏)。为何不用度量张量? 这里要和尺寸场(也是物理长度)比较,折线近似比度量积分更便宜也更鲁棒。 -
BDSSizeContext(第 93--101 行)把两个"整面网格期间不变"的选项读一次:cppstruct BDSSizeContext { bool extend1d, lcFromCurvature; BDSSizeContext(GFace *gf) : extend1d(Extend1dMeshIn2dSurfaces(gf)), lcFromCurvature(CTX::instance()->mesh.lcFromCurvature) {} };注释解释动机:"they used to be re-read from the (non inlined) option structures for every single edge; they are now looked up once per pass and threaded through."------把"每边重读全局 选项"改成"每趟读一次并传参",正是第 4 章提到的尺寸场求值热点的实例。
correctLC_(第 108--132 行)综合两端点尺寸与中点 BGM_MeshSize 取最小;开启 lcFromCurvature 时再做 min(lcmin*1.2, ...) 平滑。NewGetLc(BDS_Point*, ctx)(第 103--106 行):
cpp
return ctx.extend1d ? std::min(point->lc(), point->lcBGM()) : point->lcBGM();
这就是 _lcBGM/_lcPTS 双尺寸的用法 :extend1d 真时取更小者,否则只用 BGM。 Extend1dMeshIn2dSurfaces 决定是否让一维特征长度传播到二维内部。computeNodalSizes (第 632--717 行)从一维网格反推节点尺寸(取所有"模型边邻居边"的最大长度),其中第 658--716 行 是被 return 提前跳过的旧实现(死代码)------读 Gmsh 源码要习惯这种"留着的历史实现"。
computeSizeField(meshGFace.cpp 第 875--899 行)在造点前用 recoverMapInv 把每个 BDS 点尺寸 设为 BGM_MeshSize,并同步 lc() = lcBGM():
cpp
if(ge->dim() == 0) pp->lcBGM() = BGM_MeshSize(ge, 0, 0, here->x(), here->y(), here->z());
else if(ge->dim() == 1) { double u; here->getParameter(0, u);
pp->lcBGM() = BGM_MeshSize(ge, u, 0, here->x(), here->y(), here->z()); }
else pp->lcBGM() = MAX_LC;
pp->lc() = pp->lcBGM();
只对 algo2d != ALGO_2D_BAMG 且 Full 时调用(第 1541--1542 行)------BAMG 自管尺寸。
5.7.5 退化点的处理
参数域奇异点(极点、锥顶)会产生退化边(两端点在三维里映射到同一个点)。处理分三步:
-
getDegeneratedVertices(第 29--51 行)找出"只有一个面、且两端映射到同一MVertex"的边。 -
modifyInitialMeshToRemoveDegeneracies(第 719--751 行)给退化点打标记:退化边沿u = const标 2,否则标 1;再collapse_edge_parametric(..., force = true)折叠退化边, 并从recoverMap删掉被折叠端点:cppif(std::abs(degenerated_edges[K]->p1->u - degenerated_edges[K]->p2->u) < 1e-12) { Msg::Debug("Degenerated edge on u = cst axis: treated as well now!"); it->first->degenerated = 2; } else { it->first->degenerated = 1; }degenerated = 1表示"自由坐标是 u",2表示 v------与 5.4.2 一致。 -
splitAllEdgesConnectedToSingularity(第 406--433 行)需算节点尺寸场时,把所有与奇异点 相连的边分裂一次(中点调gf->point(U,V),检查split_edge(e, mid, true)面积守恒);注释 "abort if one of the splits leads to an invalid mesh in the param plane"------一旦破坏参数平面 有效性就整体放弃。
此外 refineMeshBDS 用 degType 在一次迭代中"临时清空退化标记"、迭代末恢复(第 805--819、 987 行):getDegeneracy 收集退化点,setDegeneracy(deg, degType) 每轮恢复------**"临时取消标记 让该点参与移动,但保持退化属性"**是微妙的折中。
5.7.6 delaunayizeBDS:把初始网格变成 Delaunay
初始三角化后网格还不是 Delaunay。delaunayizeBDS(meshGFaceBDS.cpp 第 266--287 行)用"不断翻转 直到没有可翻的边":
cpp
void delaunayizeBDS(GFace *gf, BDS_Mesh &m, int &nb_swap)
{
nb_swap = 0; std::set<swapquad> configs;
while(1) {
size_type NSW = 0;
for(size_type index = 0; index < m.edges.size(); ++index)
if(!m.edges.at(index)->deleted)
if(edgeSwapTestDelaunayAniso(m.edges.at(index), gf, configs))
if(m.swap_edge(m.edges.at(index), BDS_SwapEdgeTestQuality(false))) ++NSW;
nb_swap += NSW;
if(!NSW) return; // 一轮没有任何翻转 → 收敛
}
}
edgeSwapTestDelaunayAniso(第 238--264 行)是"各向异性空椭球"判据 + configs (std::set<swapquad>)去重:
cpp
if(!e->p1->config_modified && !e->p2->config_modified) return false;
if(e->numfaces() != 2) return false;
e->oppositeof(op);
swapquad sq(e->p1->iD, e->p2->iD, op[0]->iD, op[1]->iD);
if(configs.find(sq) != configs.end()) return false; // 这个四元组已经试过
configs.insert(sq);
double edgeCenter[2] = {0.5*(e->p1->u+e->p2->u), 0.5*(e->p1->v+e->p2->v)};
buildMetric(gf, edgeCenter, metric);
if(!inCircumCircleAniso(gf, p1, p2, p3, p4, metric)) return false;
return true;
configs 防止同一个四元组被反复翻转 (A→B→A→B...)------翻转类算法必须防震荡。注意这里 swap_edge 用 BDS_SwapEdgeTestQuality(false)(testQuality=false),即只保留"避免二邻居点
- 面积守恒",质量比较交给空椭球判据。
5.8 前沿推进:Frontal-Delaunay
5.8.1 Rebay 的思路与活动三角形
meshGFaceDelaunay.cpp 第 1116--1118 行注释交代来源:
cpp
// Let's try a frontal delaunay approach now that the delaunay mesher is stable.
// We use the approach of Rebay (JCP 1993) that we extend to anisotropic
// metrics. The point isertion is done on the Voronoi Edges.
即:沿用 Rebay 1993 JCP 论文,并推广到各向异性度量;新点插在 Voronoi 边上(度量外接圆心与 活动边中点的连线方向)。
三角形的"归一化外接半径"由 MTri3::MTri3(第 206--271 行)按 radiusNorm 计算:2 用三维 外接圆半径除以 lc,3 用 1/gammaShapeMeasure,-1(Frontal-Quad 用)用参数平面某方向最大 跨度除以 RATIO·lc,其中 RATIO = pow(metric[0]*metric[2] - metric[1]*metric[1], -0.25)(即 det(M)^(-1/4))。"活动三角形"由 isActive(第 52--82 行)判定:存在一个没有邻居(或邻居 半径小于阈值)的局部边:
cpp
static bool isActive(MTri3 *t, double limit_, int &active)
{
if(t->isDeleted()) return false;
for(active = 0; active < 3; active++) {
MTri3 *neigh = t->getNeigh(active);
if(!neigh || (neigh->getRadius() < limit_ && neigh->getRadius() > 0)) return true;
}
return false;
}
LIMIT_ = 0.5 * sqrt(2)(第 37 行)------"理想网格在该度量下归一化外接半径约为 √2/2"的阈值。
5.8.2 三种最优新点、主循环与分层
optimalPointFrontal(第 1189--1243 行):算度量外接圆心与活动边中点,求方向dir与RATIO, 取rhoM = min(vSizes, vSizesBGM),d = rhoM·sqrt(3)/2(上方注释推导"正三角形高 = √3/2·lc"),L = min(d, q)(q是圆心-中点的度量距离,"不能越过 Voronoi 边"),newPoint = midpoint + L*dir/RATIO。optimalPointFrontalB(第 1261--1314 行):先调 A 得初值,再用intersectCurveSurface(离散面 用intersectionWithCircle)把点投影到曲面------"最优位置必须在曲面上"。optimalPointFrontalQuad(第 1396--1482 行):在 cross field 旋转后的坐标系里按lengthInfniteNorm(L∞ 范数)算四边形前沿的理想步长与方向。
bowyerWatsonFrontal(第 1316--1394 行)主循环:
cpp
MTri3 *worst = (*ActiveTris.begin()); ActiveTris.erase(ActiveTris.begin());
if(!worst->isDeleted() && isActive(worst, LIMIT_, active_edge) && worst->getRadius() > LIMIT_) {
double newPoint[2], metric[3];
if(optimalPointFrontalB(gf, worst, active_edge, DATA, newPoint, metric)) {
SPoint2 NP(newPoint[0], newPoint[1]); int nnnn;
if(!true_boundary || pointInsideParametricDomain(*true_boundary, NP, FAR, nnnn))
insertAPoint(gf, AllTris.end(), newPoint, metric, DATA, AllTris,
&ActiveTris, worst, nullptr, testStarShapeness);
}
}
bowyerWatsonFrontalLayers(第 1528--1650 行)是"分层"版本(Frontal-Quad 入口):用 std::set<MEdge, MEdgeLessThan> _front 显式跟踪前沿并限制层数------quad 真时 LIMIT_ = sqrt(2)*0.99、radiusNorm = -1、max_layers = 10000,否则 max_layers = 4:
cpp
std::set<MEdge, MEdgeLessThan> _front;
for(; it != AllTris.end(); ++it)
if(isActive(*it, LIMIT_, active_edge)) { ActiveTris.insert(*it); updateActiveEdges(*it, LIMIT_, _front); }
else if((*it)->getRadius() < LIMIT_) break;
int max_layers = quad ? 10000 : 4;
...
if(!worst->isDeleted() &&
(ITERATION > max_layers ? isActive(worst, LIMIT_, active_edge)
: isActive(worst, LIMIT_, active_edge, &_front)) &&
worst->getRadius() > LIMIT_) {
double newPoint[2], metric[3] = {1, 0, 1};
if(quad) optimalPointFrontalQuad(gf, worst, active_edge, DATA, newPoint, metric);
else optimalPointFrontalB(gf, worst, active_edge, DATA, newPoint, metric);
insertAPoint(gf, AllTris.end(), newPoint, nullptr, DATA, AllTris, &ActiveTris, worst);
}
"层"的意义 :每轮只处理"当前前沿上的活动三角形",其余收进 ActiveTrisNotInFront 下一轮 再决定。这样新点从边界向内一层层推进而非随机撒点------这是 Frontal-Quad 能生成整齐四边形带的 原因。
5.8.3 经典 Bowyer-Watson 与插入机制
bowyerWatson(第 1058--1114 行)是纯 Delaunay:只不断找"半径最差"的三角形,在其外接圆心处 插点,直到最差半径 < 0.5*sqrt(2) 或点数超 MAXPNT:
cpp
while(1) {
MTri3 *worst = *AllTris.begin();
if(worst->isDeleted()) { delete worst->tri(); delete worst; AllTris.erase(AllTris.begin()); }
else {
if(worst->getRadius() < 0.5 * std::sqrt(2.0) || (int)DATA.vSizes.size() > MAXPNT) break;
double center[2], metric[3], r2;
buildMetric(gf, pa, metric); // pa = 形心
circumCenterMetric(worst->tri(), metric, DATA, center, r2);
insertAPoint(gf, AllTris.begin(), center, metric, DATA, AllTris);
}
}
支撑它的机制:
recurFindCavityAniso(第 356--380 行)递归找出"外接椭球含新点"的三角形(待删腔体)并 收集边界边shell;内部边(嵌入边)不可穿越 :if(!neigh || it != data.internalEdges.end()) shell.push_back(exf);。insertVertexB(第 486--621 行)删腔体、以 shell 边为底、新点v为顶点重建三角形,并 校验shell.size() == cavity.size() + 2(欧拉公式)、体积守恒 (fabs(oldVolume-newVolume) < EPS*oldVolume)、新点不太靠近已有边(onePointIsTooClose); 失败返回-2/-3/-4/-5并回滚。connectNewTriangles(第 447--484 行)用 O(k) 连接新三角形扇(取代旧实现的排序)。insertAPoint(第 720--834 行):先判新点能否破坏最差三角形(inCircumCircleAniso),能 则直接从 worst 找腔体,否则用search4Triangle沿邻居"行走"定位宿主;并复用thread_local的shell/cavity缓冲(第 738--744 行注释:旧实现"allocated one list node per shell edge and per cavity triangle",现改为"one buffer pair per thread")。
transferDataStructure(第 991--1056 行)把三角形交还 gf->triangles 并统一朝向 :以第一个 三角形在参数平面的法向 n1 为基准,逐个比较 prosca(n1, n2) < 0 就 t->reverse()。
5.8.4 flat 内核(Mesh.FlatRefine2D)
meshGFaceDelaunayFlat.cpp(文件头第 6--36 行)把同一批算法换成"索引数组"表示:顶点是按 MVertex::getNum() 排序的并行数组,三角形是顶点 id 三元组 + 邻居/度量半径/标志的并行数组 (删除槽位用 free-list 回收),队列是 (radius, slot) 二叉堆,腔体用显式栈扩张,最后一次性创建 MVertex/MTriangle。它是"同一算法、不同表示" :FlatRefine2D = 1 时网格逐元素相同;= 2 打开浮点捷径(缓存外接圆、平面闭式半径、平面不投影最优点),此时网格仅"等价"(注释量化:平面 提速约 14%、曲面无提速、节点数差异 0.01% 以内)------一次"数据结构换性能而不换算法"的干净改造。
5.9 PACK:打包平行四边形
ALGO_2D_PACK_PRLGRMS 的入口是 bowyerWatsonParallelograms(第 1652--1747 行)。它先要求曲面 必须有参数化 ,再依 HAVE_DOMHEX 与 old_algo_hexa() 分流到打包器:
cpp
if(!gf->haveParametrization()) {
Msg::Error("- Face %i: no CAD parametrization available, cannot mesh with algo PACK", gf->tag());
return;
}
#if defined(HAVE_DOMHEX)
if(old_algo_hexa()) { packingOfParallelograms(gf, packed, metrics); }
else { Filler2D f; f.pointInsertion2D(gf, packed, metrics); }
#else
Msg::Error("Packing of parallelograms algorithm requires DOMHEX");
#endif
打包器(contrib/domhex/surfaceFiller.cpp 第 418 行起) 入口注释点明前提:
cpp
if(fields->getBackgroundField() > 0) {
cross_field = fields->get(fields->getBackgroundField());
if(cross_field->numComponents() != 3) { // we have a true scaled cross field !!
Msg::Error("Packing of Parallelograms require a scaled cross field");
即 PACK 需要一个"缩放过的 cross field"(3 分量背景场) ,它同时给出方向与尺度。打包把边界点 放进 FIFO,每取出一点就用 compute4neighbors 在"排除区域"外生成 4 个邻居(必要时考虑周期平移 du/dv),用 RTree 索引;生成点先 SortHilbert(packed) 排序再逐个插入:
cpp
for(std::size_t i = 0; i < packed.size();) {
MTri3 *worst = *AllTris.begin();
if(worst->isDeleted()) { ... }
else {
packed[i]->getParameter(0, newPoint[0]); packed[i]->getParameter(1, newPoint[1]);
delete packed[i];
double metric[3]; buildMetric(gf, newPoint, metric);
bool success = insertAPoint(gf, AllTris.begin(), newPoint, metric, DATA, AllTris,
nullptr, oneNewTriangle, &oneNewTriangle);
if(!success) oneNewTriangle = nullptr;
i++;
}
}
oneNewTriangle 是上次成功插入产生的第一个新三角形,作为下次 search4Triangle 的起点。背景 网格由 setupBackgroundMesh(第 674--692 行)与 buildBackgroundMesh(第 1484--1526 行)准备: 后者先用临时 bowyerWatson(gf, 40000, ...) 造粗背景网格(期间临时关掉曲率尺寸 lcFromCurvature = 0),再 backgroundMesh::set(gf)(解 PDE)或 setCrossFieldsByDistance。
5.10 三角形 → 四边形:重组合三条路线
5.10.1 分派
recombineSurfaceMesh(meshGFace.cpp 第 634--650 行):
cpp
static void recombineSurfaceMesh(GFace *gf)
{
if((CTX::instance()->mesh.recombineAll || gf->meshAttributes.recombine) &&
(CTX::instance()->mesh.algoRecombine <= 1 || CTX::instance()->mesh.algoRecombine == 4)) {
if(CTX::instance()->mesh.algoRecombine == 4) {
meshGFaceQuadrangulateBipartiteLabelling(gf->tag()); // 路线 C
}
else {
bool blossom = (CTX::instance()->mesh.algoRecombine == 1);
int topo = CTX::instance()->mesh.recombineOptimizeTopology;
int repos = CTX::instance()->mesh.recombineNodeRepositioning;
double minqual = CTX::instance()->mesh.recombineMinimumQuality;
recombineIntoQuads(gf, blossom, topo, repos, minqual); // 路线 A(0=贪心, 1=blossom)
}
}
}
它只处理 algoRecombine <= 1 || == 4;algoRecombine == 2(simple full quad)与 3 (blossom full quad)在别处处理 ------它们需先"删一半一维网格",由 quadMeshRemoveHalfOfOneDMesh 完成(5.10.3)。合法范围 0..4 在 src/common/Options.cpp 第 6528--6533 行强制。
5.10.2 路线 A:recombineIntoQuads(blossom 匹配)
_recombineIntoQuads(meshGFaceOptimize.cpp 第 1018--1260 行)先建边-元素邻接 (buildEdgeToElement),收集所有"内部、两侧都是三角形"的边为候选配对:
cpp
for(auto it = adj.begin(); it != adj.end(); ++it) {
MEdge ed(it->first.getVertex(0), it->first.getVertex(1));
if(bndedges.find(ed) == bndedges.end() && embedges.find(ed) == embedges.end() &&
it->second.second && it->second.first->getNumVertices() == 3 &&
it->second.second->getNumVertices() == 3) {
pairs.push_back(RecombineTriangle(it->first, it->second.first, it->second.second, cross_field));
}
...
}
std::sort(pairs.begin(), pairs.end());
RecombineTriangle(第 38--102 行)为每对三角形打一个代价 :先构造外接四边形 MQuadrangle(n1,n3,n2,n4),取 etaShapeMeasure 作形状分,再用四个内角与 90° 的最大偏差作 angle:
cpp
MQuadrangle q(n1, n3, n2, n4);
angle = q.etaShapeMeasure();
double const a1 = 180 * angle3Vertices(n1, n4, n2) / M_PI;
...
quality = std::max(std::abs(90. - a1), quality); // 四个角都算
if(cross_field) { ... quality /= ErrDir; if(n1->onWhat()->dim() < 2 || ...) quality /= 10; }
operator< 使"代价小(质量高)的配对"排在前面;有 cross field 时把方向偏差 ErrDir 除进 代价,并对贴边配对乘惩罚 10。
blossom 分支 (第 1086--1218 行)把配对化成一般图的最大权完美匹配 :每对三角形是一条边, 权 elen[i] = (int)1000 * exp(-pairs[i].angle)(角度越小权越大),调用 perfect_match(ncount, nullptr, ecount, &elist, &elen, ...)(Blossom IV + CONCORDE,需 HAVE_BLOSSOM);周期面的 "闭合配对"(makeGraphPeriodic)权 100000:
cpp
elen[i] = (int)1000 * std::exp(-pairs[i].angle);
int NB = 0;
if(pairs[i].n1->onWhat()->dim() < 2) NB++; ...
if(elen[i] > (int)(1000 * std::exp(0.1)) && NB > 2) elen[i] = 5000;
else if(elen[i] >= 1000 && NB > 2) elen[i] = 10000;
匹配失败时打印 "Perfect Match failed in quadrangulation, try something else" 并退化为贪心 (第 1143--1148 行)------优雅降级 ;"奇数个三角形无法匹配"先报 "Cannot apply Blossom: odd number of triangles (%d)"。贪心分支 (第 1221--1247 行)按 pairs 排序顺序,只要配对角度 < recombineAngle 且两三角形都没用过就合成四边形------touched 集合防重复使用。
recombineIntoQuads(第 1314--1374 行)是外层编排:_recombineIntoQuads → 可选 RelocateVertices → #pragma omp critical(recombineIntoQuads) 下拓扑优化 (removeTwoQuadsNodes + removeDiamonds)→ quadsToTriangles(minqual)(差四边形拆回三角形) → 最后再 RelocateVertices 并 printStats。日志带时间("%s recombination completed (Wall %gs, CPU %gs)",blossom ? "Blossom" : "Simple")。**"先贪心合成、再拓扑/几何优化、最后把残余坏 四边形拆回三角形"**就是路线 A 的骨架。
其中 removeTwoQuadsNodes(第 239--325 行)消除"只有两个四边形共享的顶点"(把两四边形合成 一个),removeDiamonds(第 720--823 行)消除"钻石"(四个四边形围绕一个顶点、对角顶点度数为 3/4),两者合并都用 _tryToCollapseThatVertex(第 388--460 行)比较"合并后最差质量是否变好"。 它们会删节点,故有前置检查 _isModelOkForTopologicalOpti(第 1304--1312 行):多面 STL 读入 且未 CreateTopology/ReclassifySurfaces 时,一个面的元素可能引用相邻面的节点,删节点会破坏 网格甚至崩溃 ,故先验证"gf 的元素只用 gf/曲线/点的节点,且别的面不用 gf 的节点"。
5.10.3 路线 B:full-quad(删一半一维网格)
quadMeshRemoveHalfOfOneDMesh(src/mesh/meshGFaceQuadHalf.cpp) 是 algoRecombine == 2/3 的机制。构造时把每条一维网格"隔一个点删一个",把删掉的中点记进 _middle,并 lcFactor *= 2:
cpp
for(std::size_t i = 0; i < (*ite)->lines.size(); i += 2) {
MVertex *v1 = (*ite)->lines[i]->getVertex(0);
MVertex *v2 = (*ite)->lines[i]->getVertex(1);
MVertex *v3 = (*ite)->lines[i + 1]->getVertex(1);
v2->x() = 0.5 * (v1->x() + v3->x()); // 中点挪到 v1-v3 中
...
temp.push_back(new MLine(v1, v3));
_middle[MEdge(v1, v3)] = v2;
}
CTX::instance()->mesh.lcFactor *= 2.0;
这要求一维网格段数为偶数(i + 1 >= lines.size() 时报 "1D mesh of MEdge %d cannot be divided by 2")。finish()(第 210--227 行)在半网格 上先 recombineIntoQuads(blossom 当 algoRecombine == 3),再 _subdivide()(每块四边形中点四分为 4 块),再 _restore() 恢复 一维网格,最后再 recombineIntoQuads(minqual = 1e-3):
cpp
CTX::instance()->mesh.lcFactor /= 2.0;
bool blossom = (CTX::instance()->mesh.algoRecombine == 3);
recombineIntoQuads(_gf, blossom, topo, repos, 0.1);
_subdivide();
_restore();
recombineIntoQuads(_gf, blossom, topo, repos, 1.e-3);
_subdivide(第 18--134 行)把每个三角形细分出 3 块四边形、每个四边形细分出 4 块四边形,中点用 _gf->point((p1+p2)*0.5) 求参数后建 MFaceVertex。"先在半网格上合成四边形、再细分回全网格" 能得到"全四边形"而非"四边形为主"------代价是对一维段数的奇偶性提出硬约束(第 4 章 4.4 节的 N 约束正是为此)。
5.10.4 路线 C:BipartiteLabelling
meshGFaceQuadrangulateBipartiteLabelling( src/mesh/meshGFaceBipartiteLabelling.cpp)用 "二部图标注 "求四边形化(Georgiadis 2021 的想法,文件头注明)。它把面读成 PolyMesh (半边结构,GFace2PolyMesh),然后:
-
边界标注 :沿每个边界环交替标 0/1,要求环上节点数为偶数------
cppif(nodeCount % 2 != 0) { Msg::Warning("Bipartite algorithm requires an even numer of boundary vertices " "on every edge loop ...", faceTag, nodeCount); }(拼写 "numer" 是源码里的原样。)这正是
algoRecombine == 2/4需要一维段数奇偶约束的原因。 -
内部标注传播 :用 BFS 队列把"与当前顶点标签相反"的标签传播给邻居;对内部顶点按与
_dirs(方向)最接近的方向优先传播:cppstd::vector<std::pair<double, PolyMesh::HalfEdge *>> bests; ... 按 -max(|dot(t1,t)|,|dot(t2,t)|) 排序 ... int count = 0; for(auto it : bests) { if(count++ > 3) break; ... _labels[best->next->v] = !currentLabel; } -
局部最优 :反复扫描所有内部顶点,比较"翻转自己标签前/后"违规邻居对数 (
nbInvalidBeforevsnbInvalidAfter),取更少者,直到不动点。 -
加 Steiner 点 :对"四角同标"或"三角同标"的四边形/三角形,在中点
split_edge并给新点 赋相反标签。 -
合并 :把"两端同标、两侧都是三角形"的边
merge_faces(he)合成四边形,clean后写回PolyMesh2GFace。
这条路线不依赖 blossom ,但依赖 HAVE_QUADMESHINGTOOLS(否则函数体退化为一句 Msg::Error("This requires quad meshing tools"),第 356--361 行)。
5.11 QuadQS:准结构化全四边形
5.11.1 前置:QuadqsContextUpdater 与自动 Transfinite
ALGO_2D_QUAD_QUASI_STRUCT 在 GenerateMesh 阶段有专门前置:构造 QuadqsContextUpdater (临时改参数,析构时用 RestoreValueAtEndOfLife 全部还原),并对"边界恰好 4 条边"的面自动 建立 transfinite:
cpp
std::set<GFace *> faces;
for(GFace *gf : m->getFaces()) if(gf->edges().size() == 4) faces.insert(gf);
MeshSetTransfiniteFacesAutomatic(faces, 2.35, true, maxDiffRel); // maxDiffRel = 0.34
QuadqsContextUpdater(src/mesh/meshQuadQuasiStructured.cpp 第 2234--2312 行)备份的选项极多:algo2d、recombineAll、algoRecombine、 recombineOptimizeTopology、lcFactor、lcMin、lcMax、lcFromPoints、minCurveNodes、 minCircleNodes、abortOnError,以及每条 GEdge 的 method/coeffTransfinite/meshSize/ nbPointsTransfinite/typeTransfinite/minimumMeshSegments/reverseMesh 与每个 GFace 的 recombine/recombineAngle/method/transfiniteArrangement/...。这是"局部改全局状态"的代价 ------QMSH 工具链需要一个与常规不同的参数环境,Gmsh 用 RAII 保证退出时复原。
5.11.2 背景网格 + 引导场
BuildBackgroundMeshAndGuidingField(第 453--878 行)是 QuadQS 与 PACK 的核心前置。它先确保有 三角化(没有就用 generateMeshWithSpecialParameters 生成,edgeScaling 略细于目标四边形网格以 获取更准的 cross field),把 GModel 网格导入 GlobalBackgroundMesh,然后逐面并行计算:
- cross field :
computeCrossFieldWithHeatEquation(N, triangles, lines, triEdgeTheta, ...)------ 用热方程在背景三角形上解一个 N 对称方向场(N = 4或6,N != 4 && N != 6直接报"guiding field: %i-symmetry field not supported",第 461--464 行)。 - 奇异点 :
detectCrossFieldSingularities(N, triangles, triEdgeTheta, ...),还可把"锐角角点" 也算作奇异点(addSingularitiesAtAcuteCorners = true,阈值 30°)。 - 尺度图 :三种方法(
quadqsSizemapMethod)------来自背景三角化边长 (fillSizemapFromTriangleSizes)、来自标量背景场(fillSizemapFromScalarBackgroundField)、 或由 cross field 的共形缩放 算出的目标四边形数(computeCrossFieldConformalScaling→computeQuadSizeMapFromCrossFieldConformalFactor)。注意midpointSubdivisionAfter(当algoRecombine ∈ {1,2,4}时关掉)会调整目标数(targetNumberOfQuads /= 4)。 - 量化滤波 (
quantileFiltering,范围 0.05)与 one-way smoothing (sizeMapOneWaySmoothing,gradientMax = 1.2:注释坦承"this param should be a global gmsh option")。 - 最后把方向场(每三角形 9 分量)与尺度图写进
buildBackgroundField(..., "guiding_field")。
要点:QuadQS 不"直接造点",而是先建立"引导场(方向+尺度)",再让后续打包与优化都对齐它 ------这是它与 Delaunay/Frontal 的根本区别。
5.11.3 中点细分 + 背景投影
RefineMeshWithBackgroundMeshProjection(第 1650--1932 行)在初始"四边形为主"网格上做中点 细分 并把新节点投影回几何。它先把曲线/面上的 MVertex 转成 MEdgeVertex/MFaceVertex (第 1698--1790 行),再投影:
cpp
/* - projections on curves */
GPoint proj = ge->closestPoint(v->point(), tGuess);
if(proj.succeeded()) { v->setXYZ(proj.x(), proj.y(), proj.z()); v->setParameter(0, proj.u()); }
/* - projections on faces */
proj = sp->closestPoint(v->point().data(), evalOnCAD, projOnCad);
面的投影用背景网格三角形构造的 SurfaceProjector(sp->initialize(gf, triangles)),失败时回退 gf->closestPoint。之后每面做 optimizeGeometryQuadMesh(gf, sp, timeMax = 0.3);若 SICN (四边形形状度量)最小值 < 0 再跑一次 2 秒版本。注释里 USE_CAD_PROJECTION 分支直接写着 "Problem: it is slow and buggy on periodic CAD"------"简单版" (RefineMeshWithBackgroundMeshProjectionSimple)才是默认路径。
5.11.4 腔体重网格与坏网格替换
optimizeTopologyWithCavityRemeshing(第 2022--2102 行)对每个 PENDING 且有四边形的面取奇异点 (优先从引导场读 getSingularitiesFromBackgroundField,否则重算)后调用 improveQuadMeshTopologyWithCavityRemeshing(gf, singularities, invertNormals)------"腔体重网格" 在奇异点附近取出一个四边形腔体,用模式库重新铺四边形。这套工具在 HAVE_QUADMESHINGTOOLS(QMSH) 里,QuadqsTopoOptimMethods 控制启用哪些(0/1/11/101/111,第 2024--2032 行)。
replaceBadQuadDominantMeshes(第 1973--1994 行)与 checkAndReplaceQuadDominantMesh (第 1934--1971 行)负责"救不回的面":若内部顶点最大价数 vMaxInt > 8 或边界顶点 vMaxBdr > 6,就退回 ALGO_2D_MESHADAPT 重网格,再重新组合:
cpp
int algo2d = gf->getMeshingAlgo();
gf->setMeshingAlgo(ALGO_2D_MESHADAPT);
gf->mesh(false);
gf->setMeshingAlgo(algo2d);
recombineIntoQuads(gf, false, 0, false, .1);
quadMeshingOfSimpleFacesWithPatterns(第 1996--2019 行)则对"无嵌入、几何简单"的面调用 meshFaceWithGlobalPattern,用预置四边形模式库(initQuadPatterns())直接铺网格。
5.11.5 缝顶点迁移:transferSeamGEdgesVerticesToGFace
transferSeamGEdgesVerticesToGFace(第 2148--2232 行)解决 QuadQS 特有的拓扑问题:缝边顶点 悬挂在 GEdge 上而不是 GFace 上 ,而 QMSH 工具要求它们属于面。它对"是 gf 的缝、且只属于 gf 一个面"的曲线,把其中间顶点用 ge->reparamOnFace(gf, t, -1) 算出面参数后重建为 MFaceVertex 并挂到 gf,同时清掉曲线的 lines:
cpp
SPoint2 uv = ge->reparamOnFace(gf, t, -1);
MVertex *nv = new MFaceVertex(p.x(), p.y(), p.z(), gf, uv[0], uv[1]);
nv->setParameter(0, uv[0]); nv->setParameter(1, uv[1]);
gf->mesh_vertices.push_back(nv);
old2new[ov] = nv; delete ov;
对"角点上的孤立 GVertex 顶点"也做迁移,但注释特意写:
cpp
/* Note/warning: let the MVertex on the GVertex live. If the MVertex
* is deleted, I/O is broken.
* FIXME: support for GVertex without MVertex in mesh I/O */
------GVertex 上的 MVertex 不能删 ,否则网格写出会出错。这类"为什么不能删"的注释是维护者 经验的结晶。最后用 old2new 把面内所有三角形/四边形的顶点指针替换(第 2216--2229 行)。
5.12 Transfinite:超限插值结构化面
5.12.1 Coons 公式与两个宏
Transfinite 面用超限插值(transfinite / Coons patch) :给定四条边界曲线 c1..c4 与四个角点 s1..s4,内部点由
scss
f(u,v) = (1-u) c4(v) + u c2(v) + (1-v) c1(u) + v c3(u)
- [ (1-u)(1-v) s1 + u(1-v) s2 + u v s3 + (1-u) v s4 ]
给出。宏 TRAN_QUA(meshGFaceTransfinite.cpp 第 37--40 行)正是它;三角形面用退化版本 TRAN_TRI(s1=s4=c4,第 44--45 行):
cpp
#define TRAN_QUA(c1, c2, c3, c4, s1, s2, s3, s4, u, v) \
(1. - u) * c4 + u * c2 + (1. - v) * c1 + v * c3 - \
((1. - u) * (1. - v) * s1 + u * (1. - v) * s2 + u * v * s3 + (1. - u) * v * s4)
#define TRAN_TRI(c1, c2, c3, s1, s2, s3, u, v) \
u * c2 + (1. - v) * c1 + v * c3 - (u * (1. - v) * s2 + u * v * s3)
5.12.2 MeshTransfiniteSurface 的步骤
MeshTransfiniteSurface(第 312 行起):
- 要求所有边界曲线有起止顶点(否则报
"Transfinite algorithm cannot be applied to surface with bounding curve %d without begin or end point"); findTransfiniteCorners找角点(用户指定或自动探测;3 边面还"try reaaally hard",第 61 行 注释),角点数必须是 3 或 4;computeEdgeLoops把边界节点按环序拼成d_vertices,环起止索引记入indices;indices.size() != 2表示有洞,报错;- 把节点旋转到从
corners[0]开始,必要时整体反转,使顺序与corners[1]一致; - 求每个节点的参数
(U,V);若在缝上还求另一侧(U2,V2),并选"最大参数间距更小"的那一套 (第 409 行,注释:"quick fix for surfaces with a single seam"); - 设
L = N2-N1(第一条边段数)、H = N3-N2,对面两边Lb/Hb必须相等(否则报"non-matching number of nodes on opposite sides"); - 累加两对边"平均节点间距"
lengths_i/lengths_j(symmetric = true,第 465 行注释:"use the average of the node spacing on the two opposing sides, so that we generate the same u, v coordinates whatever the ordering of the sides"); - 建立二维表
gf->transfinite_vertices(tab[i][j],尺寸(L+1)×(H+1)),把边界节点放进表; - 对内部
(i,j),u = lengths_i[i]/L_i、v = lengths_j[j]/L_j,用TRAN_QUA求(Up,Vp), 再gf->point(Up,Vp)建MFaceVertex放进表; - 可选椭圆/弹性平滑(见下);
- 最后用
_addQuadrangle/_addTriangle把表里的网格铺成元素------退化四边形自动降为三角形 (_addQuadrangle检查四顶点相等性,第 293--310 行)。
5.12.3 边界 N 约束与 meshGEdge 的耦合
Transfinite 面的 L/H 直接来自一维网格的节点数 ,故与第 4 章的 meshGEdge 强耦合: meshGEdgeTargetNumberOfPoints(ge)(src/mesh/meshGEdge.cpp 第 1067--1079 行)就是"这条边应有多少节点":
cpp
int meshGEdgeTargetNumberOfPoints(GEdge *ge)
{
Range<double> bounds = ge->parBounds(0);
...
int N = 0; std::vector<IntPoint> Points; double a = 0.;
int filterMinimumN = 1;
meshGEdgeProcessing(ge, t_begin, t_end, N, Points, a, filterMinimumN);
return N;
}
范围 3(三角形面)还有两个特殊开关:CTX::instance()->mesh.transfiniteTri(三角形面特化算法, 第 437--440 行)与 CTX::instance()->mesh.quasiTransfinite(rescaleIrregularTransfinite,把 两侧不等长的节点列表补成等长,第 420--421 行)。transfiniteSmoothing/nbSmoothing 控制面上是否 做椭圆平滑(symmetric 表上的加权 Laplace,第 721--759 行);3 边面用另一套"弹性力"(把邻近 6 个点的位移求和作为力,第 762--800 行)。
5.12.4 MeshSetTransfiniteFacesAutomatic
MeshSetTransfiniteFacesAutomatic(第 1201--1276 行)是"给一批面自动铺结构化网格"的入口 (QuadQS 前置调用它):
-
拓扑+几何过滤 :用
faceIsValidQuad(gf, cornerAngle)只保留"四角都接近直角"的四边形面 (cornerAngle = 2.35弧度 ≈ 134.6°------允许每个角离 90° 偏差较大,以容纳不规则四边形), 无嵌入边/点的面优先; -
build_chords(第 1152--1199 行):对每个尚未访问的GEdge做广度优先搜索 ,沿 "四边形面的对边"(quad_face_opposite_edge(gf, ge))串起一条"弦"(chord);当某条对边的 目标节点数与当前边的相对偏差> maxDiffRel时停止延伸:cppGEdge *ge2 = quad_face_opposite_edge(gf, ge); if(ge2 && done.find(ge2) == done.end()) { int n2 = meshGEdgeTargetNumberOfPoints(ge2); if(double(std::abs(n2 - n)) / double(std::max(n, n2)) > maxDiffRel) continue; Q.push(ge2); done[ge2] = true; } -
分配节点数 :每条弦取所有边节点数的平均
N,N == 0取 2,N为奇数则 +1 ------cppint N = int(avgNbPoints + 0.5); if(N == 0) N = 2; if(N % 2 == 1) N = N + 1;然后给弦上每条边设
MESH_TRANSFINITE、nbPointsTransfinite = N、typeTransfinite = 1(Progression);若algo2d == ALGO_2D_QUAD_QUASI_STRUCT,则typeTransfinite = 4(用尺寸 图)(第 1244--1246 行)。 -
对面设 transfinite :只有当某面四条边都是
MESH_TRANSFINITE且nPoints[0]==nPoints[2]、nPoints[1]==nPoints[3],才把该面设为MESH_TRANSFINITE(transfiniteArrangement = 1= Right),并按需recombine = 1、recombineAngle = 45。
faceIsValidQuad(第 1065--1150 行)用四个角的角度判定"这个面能不能当四边形块"------它是 "自动结构化"的质量闸门。
5.13 质量度量与面网格优化
5.13.1 gamma 与 eta
Gmsh 的质量函数在 qualityMeasures:qmTriangle::gamma(p1,p2,p3) 是2×内切圆半径/外接圆 半径 ,最常用的形状度量;computeElementShapes(5.6.4)给出它等价闭式 4|u×v|²/(abc(a+b+c))。etaShapeMeasure() 是单元类的"η 质量",定义为 1 - |60° - 最小角|/60(理想范围 [0,1]),RecombineTriangle 与四边形优化都用它。 gammaShapeMeasure() 在 MTri3 的 radiusNorm == 3 时被取倒数作半径。BDS 侧用 qmTriangle::gamma(BDS_Point*, ...) 重载(见 edgeSwapTest,meshGFaceBDS.cpp 第 180--183 行)。
5.13.2 laplaceSmoothing / _relocate
laplaceSmoothing(meshGFaceOptimize.cpp 第 985--1016 行)对一阶网格做 niter 趟顶点重定位, 跳过边界层顶点:
cpp
void laplaceSmoothing(GFace *gf, int niter, bool infinity_norm)
{
if((gf->triangles.size() > 0 && gf->triangles[0]->getPolynomialOrder() > 1) || ...) {
Msg::Error("Surface mesh smoothing only valid for first order mesh ..."); return;
}
if(!niter) return;
std::set<MVertex *> vs; getAllBoundaryLayerVertices(gf, vs);
VertexToElementCSR adj; adj.add(gf->triangles); adj.add(gf->quadrangles); adj.finalize();
for(int i = 0; i < niter; i++)
for(std::size_t j = 0; j < adj.numVertices(); j++) {
MVertex *v = adj.vertex(j);
if(vs.find(v) == vs.end()) _relocate(gf, v, adj.elements(j), adj.numElements(j), pts, star);
}
}
VertexToElementCSR(第 933--983 行)是"顶点→单元"的 CSR 邻接:顶点号连续时用计数排序 O(N), 稀疏时用 stable_sort------两种情形分别优化 。_relocate(第 829--931 行)的每一步都很有讲究: 跳过非本面、边界层节点(fv->bl_data)的顶点;度量加权求目标位置:
cpp
for(auto it = pts.begin(); it != pts.end(); ++it) {
SVector2 d(adj.x()-before.x(), adj.y()-before.y()); d.normalize();
buildMetric(gf, adj, metric);
const double F = sqrt(metric[0]*d.x()*d.x() + 2*metric[1]*d.x()*d.y() + metric[2]*d.y()*d.y());
after += adj * F; COUNT += F;
}
after *= (1.0 / COUNT);
这就是"各向异性 Laplace":每个邻点的权重是它在度量下的方向长度 。之后用 star.evaluate(...) 求"星形"的参数面积与最小质量 ,只有"面积不增、最小质量不降"才接受 移动(_isItAGoodIdeaToMoveThatVertex,第 658--686 行),并用 FACTOR 从 1 逐步除以 1.4 做 5 次 尝试(阻尼式逼近)。
vertexStar(第 532--651 行)缓存"待移动顶点周围所有角的参数与三维坐标",并用 _minCornerSin2Cos(第 471--493 行)在不做 atan2/sqrt 的前提下比较三角形质量------注释 (第 462--470 行)解释:正弦平方 |a×b|² 三角共享,而 cos 越大角越小,故最小角就是 c 最大 者;elementQuality::operator< 用"缩放后的 cos²"比较,eta() 只在必要时算。这是延迟求值 + 代数化简的性能手法。
5.13.3 Optimize2D / Delaunayize / UntangleTris
GenerateMesh 在二维阶段末尾还会调用一系列全局优化(与第 7 章合并讨论): OptimizeMesh(m, "Delaunayize")(对平面面做 PolyMeshDelaunayize)、 OptimizeMesh(m, "UntangleTris")(解缠三角形网格,消除负面积/翻卷单元)、 OptimizeMesh(m, "Laplace2D")/Relocate2D(Laplace 光滑与顶点重定位)、 OptimizeMesh(m, "QuadQuasiStructured")(QuadQS 的后处理链)。对 QuadQS/PACK,UntangleTris 是 bowyerWatsonParallelograms 之后的重要一步------打包得到的三角形铺砌可能缠结,必须先解缠再 重组合。
5.14 与第 4 章、第 6 章的接口
与第 4 章(一维离散与尺寸场):
- N 的奇偶约束 :当
algoRecombine ∈ {2, 4}(full-quad / bipartite)时,一维段数需为偶数 (quadMeshRemoveHalfOfOneDMesh的lines.size()必须整除 2;BipartiteLabelling 要求边界环 节点数为偶数)。第 4 章 4.4 节的约束正来源于此。 - 尺寸场 :二维通过
BGM_MeshSize(ge, u, v, x, y, z)查询第 4 章的Field;_lcBGM是它的 一次快照。computeSizeField/correctLC_是二维侧的消费点。 - Transfinite 的 N :
meshGEdgeTargetNumberOfPoints(ge)直接决定 Transfinite 面的L/H(5.12.3),也决定MeshSetTransfiniteFacesAutomatic的弦节点数(5.12.4)。
与第 6 章(三维网格的边界):
- 三维网格器把面网格当作边界约束输入:第 6 章的 Delaunay/HXT 需要"边界面上已有三角形/ 四边形",因此第 5 章必须保证:边界边已恢复(5.5)、几何分类已保持(5.4.11)、面网格与一维 网格在公共边上一致(5.2.4)。
directions_storage(gf)(当algo3d == ALGO_3D_RTREE时调用,meshGFace.cpp 第 2450 行)为 RTREE 体网格保存面方向信息。- 面网格的
meshStatistics.status(PENDING/DONE/FAILED)是三维网格器的同步信号:三维在GenerateMesh里先确保所有面DONE(必要时重试),再开始体网格。
5.15 常见问题 Q&A
Q1:为什么曲面网格非要在参数平面里做? 因为 Delaunay 的空圆判据只在平面上有定义。曲面上 没有"圆",只有度量;Gmsh 选择"拉平到 (u,v) + 度量补偿"这条路(5.2、5.3)。另一范式是"直接 三维做",但那需要三维 Delaunay 曲面重建,复杂度和鲁棒性都差得多。
Q2:非流形边(一条边接三个及以上面)怎么处理? BDS 用 BDS_Edge::_f[2] 内联存两个面,超过 两个才分配 _moreFaces(5.4.3),但大量操作直接拒绝非流形:swap_edge 要求 numfaces() == 2;recover_edge 里 if(e->numfaces() != e->numTriangles()) return nullptr;; recur_tag 只在 numfaces() == 2 时传播。结论:二维自由网格算法基本假设流形,非流形只被 "兜底存储"而不被主动处理。
Q3:边界恢复失败怎么办? 三层次:(1) 若源于一维网格自交,recover_edge 记入 not_recovered,remeshUnrecoveredEdges 加密一维网格原地重来(受 maxRetries 限制,5.5.4); (2) 若某面反复失败,可设 refineAllEdges 全体分裂,但最多 3 次;(3) 最终失败则面状态置 FAILED,verbosity 99 可拿到 wrongmesh.pos/wrongparam.pos 双视图定位(5.5.5)。实用建议: 先查一维网格是否自交(通常源于几何重叠或尺寸突变),再查是否有"锥顶"这类无解几何。
Q4:各向异性度量怎么构造? 由参数化雅可比 J = firstDer(u,v) 得 M = JᵀJ(buildMetric, 存成 [Ju·Ju, Jv·Ju, Jv·Jv]);度量外接圆由线性系统解出(circumCenterMetric),空圆判据是 "第四点到圆心的度量平方距离 < Radius2 - tol"(inCircumCircleAniso)。想换度量(边界层、用户 方向场),改 firstDer 或直接喂度量场。
Q5:BDS 和 MTri3/bidimMeshData 为什么是两套? BDS 是"带几何分类的完整拓扑网格",适合边界 恢复与需分类保持的局部操作;MTri3 + bidimMeshData 是"为增量插点优化的紧凑结构"。口诀: BDS 管"对不对"(分类/恢复),MTri3 管"快不快"(造点/搜索) ;两者用 BDS2GMSH 与 buildMeshGenerationDataStructures 桥接。
Q6:要新增一个二维算法,接在哪里? 两条缝:algoDelaunay2D(是否走骨架)与 runDelaunayAlgorithm(造点方式);若需背景网格/引导场则挂到 setupBackgroundMesh。再在 GmshDefines.h 加新 ALGO_2D_* 值,并在 meshGFace::operator() 的 switch 里补日志名。
5.16 术语表
| 术语 | 含义 | 出处 |
|------------------------------|---------------------------------------------------|--------------|------|--------|
| BDS | Bidirectional Data Structure(Shephard-Beall 双向结构) | BDS.h |
| classif_degree / classif_tag | 几何分类维度与实体号(0 点、1 边、2 面) | BDS.h 32--48 |
| _lcBGM / _lcPTS | 背景网格尺寸 / 传播的特征长度尺寸 | BDS.h 50--67 |
| periodicCounterpart | 周期/缝面上同一物理点的另一参数副本 | BDS.h 61 |
| degenerated | 退化标记:1 表示自由坐标 u,2 表示 v | 5.7.5 |
| 度量张量 M | 由 firstDer 得到的 JᵀJ,把参数距离折算成物理距离 | 5.3.1 |
| inCircumCircleAniso | 度量空间"空椭球"判据 | 5.3.3 |
| 两遍恢复 | 先 EdgeToRecover 登记,再 recover_edge 执行 | 5.5.2 |
| Frontal-Delaunay / LIMIT | Rebay 前沿推进;归一化外接半径阈值 √2/2 | 5.8 |
| cross field / 引导场 | 面内方向场(N=4 或 6 对称)+ 尺度图 | 5.11.2 |
| PACK_PRLGRMS | 打包平行四边形算法,需 scaled cross field | 5.9 |
| Recombine / Blossom | 三角形重组合成四边形;最大权完美匹配 | 5.10 |
| Bipartite Labelling | 二部图标注的四边形化 | 5.10.4 |
| QuadQS | 准结构化全四边形,背景网格 + 引导场驱动 | 5.11 |
| Transfinite / Coons | 超限插值结构化面 | 5.12 |
| gamma / eta | 形状质量度量(2r/R;1- | 60°-最小角 | /60) | 5.13.1 |
| MeshExtent | Full / InitialOnly / BoundaryLayerRemesh 三种深度 | 5.7.2 |
5.17 本章小结
第 5 章要点浓缩成七条:
- 除 Transfinite 与 Extrude 外,一切共享 Delaunay/BDS 骨架。 三层分派:
algoDelaunay2D(是否走骨架)→meshGenerator/meshGeneratorPeriodic(非周期/周期)→runDelaunayAlgorithm(造点方式)。5.1.6 的算法矩阵是本章地图。 - 参数平面是主战场。 边界点先重参数化到
(u,v)(reparamOnFace处理缝与朝向),在参数 域三角化,再用GFace::point(u,v)回三维;MFaceVertex携带(u,v)避免反解参数。 - 各向异性度量补偿畸变。
buildMetric用雅可比构造M = JᵀJ,circumCenterMetric解度量 外接圆,inCircumCircleAniso给空椭球判据;鲁棒性由robustPredicates保证。 - BDS 的设计围绕"分类保持"与"常见情况性能"。 点/边/面互指;
_f[2]内联缓存 +_moreFaces溢出;_n[4]节点缓存;每个局部操作都审查classif_degree。注释里// not a bug !!!、// no bound check at all之类的坦白,是理解工程取舍的钥匙。 - 边界恢复是两遍、可重试、可诊断的闭环。 登记 → 逐段翻转 → 自交则加密一维网格递归重来 → 失败给双视图调试;
recoverMapInv/_periodicCounterpart支撑缝边界。 - 四边形是一等公民,有四条路线 :A
recombineIntoQuads(贪心或 Blossom + 拓扑/几何优化 +quadsToTriangles兜底);BalgoRecombine=2/3的"删一半一维网格 + 半网格合成 + 细分"; C BipartiteLabelling;以及 QuadQS 的"背景网格 + 引导场 + 打包 + 腔体重网格"。它们与第 4 章的 一维 N 奇偶约束、尺寸场深度耦合。 - Transfinite 是独立范式。 Coons 超限插值 +
meshGEdgeTargetNumberOfPoints的 N 约束 +MeshSetTransfiniteFacesAutomatic(build_chordsBFS + 奇偶修正 + QuadQS 用尺寸图)。
第 6 章转向三维:Delaunay、HXT、MMG、Netgen 与并行 Delaunay------它们都以本章产出的二维曲面网格 作为边界约束输入。
关键词与索引
- 数据结构:
BDS_Point/BDS_Edge/BDS_Face/BDS_Mesh(BDS.h、BDS.cpp);bidimMeshData、MTri3(meshGFaceDelaunay.cpp、meshGFaceTri3.h);PolyMesh(meshTriangulation.h)。 - 算法入口:
bowyerWatson/bowyerWatsonFrontal/bowyerWatsonFrontalLayers/bowyerWatsonParallelograms(meshGFaceDelaunay.cpp);meshGenerator/meshGeneratorPeriodic(meshGFace.cpp);refineMeshBDS(meshGFaceBDS.cpp);MeshTransfiniteSurface(meshGFaceTransfinite.cpp);recombineIntoQuads(meshGFaceOptimize.cpp)。 - 关键谓词/度量:
buildMetric、inCircumCircleAniso、circumCenterMetric、robustPredicates::orient2d/incircle、qmTriangle::gamma、etaShapeMeasure。 - 关键选项:
Mesh.Algorithm(algo2d)、Mesh.RecombinationAlgorithm(algoRecombine)、Mesh.RecombineAll、Mesh.RecombineOptimizeTopology、Mesh.RecombineNodeRepositioning、Mesh.RecombineMinimumQuality、Mesh.FlatRefine2D、Mesh.QuasiTransfinite、Mesh.TransfiniteTri、Mesh.MaxRetries、Mesh.AlgorithmSwitchOnFailure。 v