Gmsh 源码深度解析-第 7 章 网格质量、优化与高级技术
本章目标:讲清 Gmsh 的质量度量体系 (gamma/SICN/SIGE/eta/NCJ/多项式雅可比)、 统一优化入口
OptimizeMesh的方法表 、局部修改算子 (边翻转、面翻转、点重定位、 Cavity 重剖分)、Winslow 解缠 、细化/细分 、边界层 、高阶曲边网格与三种优化 、 METIS 分区 、Mesquite/Netgen 端口 ,以及质量统计与自适应闭环。前六章已经建立了"几何 → 尺寸场 → 二维网格 → 三维网格"的主链。但一条链路的下游还有 一件极重要的事:网格好不好、怎么把它变好、以及如何把它升级成边界层/高阶/分区等高级 形态 。这些内容全部落在
src/mesh/目录与contrib/里,它们彼此耦合,因此放在同一章。
7.1 质量度量体系
7.1.1 为什么质量度量是网格优化的地基
任何"优化"都必须先回答"什么叫好"。在有限元语境里,单元质量直接影响:
- 刚度矩阵的条件数:畸变单元会放大条件数,恶化迭代求解器的收敛;
- 插值误差:退化(接近共线/共面)单元会让插值误差失控;
- 正性/翻转:负雅可比意味着单元翻转,装配出的刚度项符号错误,结果不可信。
所以 Gmsh 的质量度量不是"锦上添花的显示指标",而是贯穿网格生成、优化、自适应、 高阶化的公共货币 。这套货币定义在 src/mesh/qualityMeasures.h 与 src/mesh/qualityMeasures.cpp,并由 MElement(src/geo/MElement.h)在多边形/多面体层面向上层暴露。
7.1.2 度量类的组织:五个类,一族度量
qualityMeasures.h 的结构非常克制------按单元类型分五个类,每个类提供一组静态函数 (无状态,可自由调用):
cpp
class qmTriangle {
public:
static double gamma(MTriangle *f);
static double gamma(BDS_Face *f);
static double gamma(const BDS_Point *p1, const BDS_Point *p2,
const BDS_Point *p3);
static double gamma(const MVertex *v1, const MVertex *v2, const MVertex *v3);
static double gamma(const double *d1, const double *d2, const double *d3);
static double gamma(const double &x1, const double &y1, const double &z1,
const double &x2, const double &y2, const double &z2,
const double &x3, const double &y3, const double &z3);
static double eta(MTriangle *el);
static double angles(MTriangle *e);
static double minNCJ(const MTriangle *e);
static void NCJRange(const MTriangle *e, double &valMin, double &valMax);
static inline int numNCJVal() { return 3; }
static void NCJ(const SPoint3 &p0, const SPoint3 &p1, const SPoint3 &p2,
const SVector3 &normal, std::vector<double> &NCJ);
static void NCJAndGradients(const SPoint3 &p0, const SPoint3 &p1,
const SPoint3 &p2, const SVector3 &normal,
std::vector<double> &NCJ,
std::vector<double> &dNCJ);
};
class qmTetrahedron {
public:
enum Measures { QMTET_GAMMA, QMTET_ETA, QMTET_ONE, QMTET_COND };
static double qm(MTetrahedron *t, const Measures &cr,
double *volume = nullptr);
...
static double minNCJ(const MTetrahedron *e);
static inline int numNCJVal() { return 4; }
};
注意几个设计细节:
- 同一度量多种重载 :
gamma有 6 个重载,分别接受MTriangle*、BDS 面、三个 BDS 点、 三个MVertex*、三个double*、九个坐标。这样在算法的任何阶段都能算质量------无论手头 是 Gmsh 的单元对象、还是 BDS(二维 Delaunay 内部结构),还是裸坐标。 - 类即命名空间 :构造函数被"省略"(全静态),类只起分类作用。这与 Gmsh 里
MElement的继承体系不冲突------质量函数是自由函数放在类里。 numNCJVal()是inline:三角形 3 个、四边形 4 个、四面体 4 个、棱柱 6 个、 六面体 8 个------即"角点/主节点数",用于分配 NCJ 数组。
7.1.3 gamma:2ρ/R 的几何直觉与推导
最经典、最常用的度量是 gamma。它的定义是两倍内切圆半径除以外接圆半径 (src/mesh/qualityMeasures.cpp 第 163--189 行):
cpp
// Triangle abc
// quality is between 0 and 1
double qmTriangle::gamma(const double &xa, const double &ya, const double &za,
const double &xb, const double &yb, const double &zb,
const double &xc, const double &yc, const double &zc)
{
// quality = rho / R = 2 * inscribed radius / circumradius
double a[3] = {xc - xb, yc - yb, zc - zb};
double b[3] = {xa - xc, ya - yc, za - zc};
double c[3] = {xb - xa, yb - ya, zb - za};
norme(a);
norme(b);
norme(c);
double pva[3];
prodve(b, c, pva);
const double sina = norm3(pva);
double pvb[3];
prodve(c, a, pvb);
const double sinb = norm3(pvb);
double pvc[3];
prodve(a, b, pvc);
const double sinc = norm3(pvc);
if(sina == 0.0 && sinb == 0.0 && sinc == 0.0)
return 0.0;
else
return 2 * (2 * sina * sinb * sinc / (sina + sinb + sinc));
}
这段实现的精妙之处在于它根本不需要计算实际的半径。几何恒等式告诉我们:对任意三角形,
R2r=sinα+sinβ+sinγ2sinαsinβsinγ
其中 α,β,γ 是三个内角。而三个内角的正弦可以通过单位化后的边向量叉积 算出 (边的法向投影),norme 把向量单位化,prodve 求叉积,norm3 求模。于是 gamma 只需 三个归一化边向量和三次叉积,完全是浮点乘加,没有开方、没有三角函数 (除模长 norm3 的 一次 sqrt)。这是算法内部高频调用所必需的。
性质:
- 正三角形时 α=β=γ=60°, sin 均为 3 /2,代入得
gamma = 1; - 退化(三点共线)时所有 sin 为零,函数返回 0(源码显式处理了这个退化分支);
- 范围是 0, 1,1 表示完美等边。注意 gamma 与"面积归一化"无关------一个很小的等边 三角形 gamma 同样是 1,这是它与后面 SICN 的关键差别之一。
- 有一处细节:源码里用的是
xn == 0.0而非< eps。这是刻意为之------它只在三个 sine 同时精确为零时返回 0,否则走公式;因为若单个 sine 为零(退化到只剩一个方向),公式仍 给出确定的 0,无需特判。
gamma 的多态重载(第 137--160 行)把上述坐标版本层层包装:
cpp
double qmTriangle::gamma(const MVertex *v1, const MVertex *v2, const MVertex *v3)
{ return gamma(v1->x(), v1->y(), v1->z(), v2->x(), v2->y(), v2->z(),
v3->x(), v3->y(), v3->z()); } // MVertex* 版本
double qmTriangle::gamma(MTriangle *t)
{ return gamma(t->getVertex(0), t->getVertex(1), t->getVertex(2)); }
double qmTriangle::gamma(BDS_Face *t)
{ BDS_Point *n[4]; t->getNodes(n); return gamma(n[0], n[1], n[2]); }
这三层分别对应"算法手里有 MVertex""有 MTriangle""有 BDS 面"三种情形。重复定义这么多 重载看似冗余,实则是性能与可读性的平衡:调用方永远不必自己拆对象,编译器内联后开销为零。
7.1.4 eta 与 angles:只盯最差的那个角
gamma 综合了三个角,但有时我们更关心"最差角有多差"。eta 就是为此设计的 (第 191--202 行):
cpp
double qmTriangle::eta(MTriangle *el)
{
MVertex *_v[3] = {el->getVertex(0), el->getVertex(1), el->getVertex(2)};
double a1 = 180 * angle3Vertices(_v[0], _v[1], _v[2]) / M_PI;
double a2 = 180 * angle3Vertices(_v[1], _v[2], _v[0]) / M_PI;
double a3 = 180 * angle3Vertices(_v[2], _v[0], _v[1]) / M_PI;
double amin = std::min(std::min(a1, a2), a3);
double angle = std::abs(60. - amin);
return 1. - angle * INV_60; // INV_60 = 1/60
}
常量 INV_60 = 1.0/60.0(第 23 行)把"最小角离 60° 差多少度"线性映射到 0,1:正三角形 最小角恰为 60°,eta = 1;最小角趋近 0° 或 120°,eta 趋近 0(甚至可能为负------源码未截断, 调用方需注意)。gamma 看整体、eta 盯局部,两者互补。
angles(第 204--271 行)是另一套更"平滑"的角度量:它用 atan 的 S 形函数把角度偏差 压缩到 0,1,并在发现单元翻转(orientation < 0)时直接返回 -std::numeric_limits<double>::max()。这是一条硬约束信号------用于优化器判断"这个单元 已经不可接受"。
对四边形,qmQuadrangle::gamma 其实是 eta 的别名:
cpp
class qmQuadrangle {
public:
static double gamma(MQuadrangle *el) { return eta(el); }
static double eta(MQuadrangle *el);
...
};
因为对四边形,内切圆/外接圆比值不像三角形那样有干净的闭式,Gmsh 干脆用"最大直角偏差" (第 396--437 行):四个角点处的法向叉积若有一个反向,sign = -1(四边形非凸或翻转), 否则 sign = 1;再取四个内角与 90° 偏差的最大值 angle,返回 sign * (1. - angle * INV_90) * AR(INV_90 = 1/90,AR 是长宽比惩罚,源码里暂设为 1)。
这里有三个要点:
sign:四个角点处的法向叉积若有一个与其它反向,说明四边形非凸或翻转 ,sign = -1把质量变成负值------这是有符号质量的雏形;INV_90 = 1/90(第 24 行)把"最大直角偏差"线性映射到 0,1;AR本该是"长宽比惩罚",但源码里被临时禁用(AR = 1,注释里还留着pow(el->minEdge()/el->maxEdge(), .25)),说明这是一个仍在演进的实现。
7.1.5 NCJ:归一化雅可比
比 gamma/eta 更深刻的是 NCJ(Normalized Condition number of the Jacobian) 。它衡量的是 单元映射在参数域内各点的雅可比行列式的归一化值。对二阶及以上曲边单元,顶点处质量好 不代表内部好------NCJ 就是把"正性"从顶点推广到整个单元的关键。
对三角形,NCJ 在每个角点处定义为
NCJi=3 21(ua×(−ub))⋅n
其中 ua,ub 是角点 i 出发的两条单位 边向量, n 是单元法向, 2/3 =0.5/Aequilateral 是等边单元的面积归一化因子。源码(第 294--308 行):
cpp
void qmTriangle::NCJ(const SPoint3 &p0, const SPoint3 &p1, const SPoint3 &p2,
const SVector3 &normal, std::vector<double> &NCJ)
{
// Compute unit vectors for each edge
SVector3 v01n(p0, p1), v12n(p1, p2), v20n(p2, p0);
v01n.normalize();
v12n.normalize();
v20n.normalize();
// Compute NCJ at vertex from unit vectors a and b as
// 0.5*||a^b||/A_equilateral Factor = 2./sqrt(3.) = 0.5/A_equilateral
NCJ[0] = 2.0 / std::sqrt(3.0) * dot(crossprod(v01n, -v20n), normal);
NCJ[1] = 2.0 / std::sqrt(3.0) * dot(crossprod(v12n, -v01n), normal);
NCJ[2] = 2.0 / std::sqrt(3.0) * dot(crossprod(v20n, -v12n), normal);
}
性质:
- 正三角形每个角点的 NCJ = 1(归一化到理想形状);
- NCJ < 0 意味着该角点处单元翻转(雅可比为负);
- 四边形的 NCJ(第 519--536 行)去掉 2/3 (因为它的理想形状是正方形,面积归一化成 了单位),在每个角点计算两条单位边的叉积点乘法向。
真正让它成为"优化货币"的是它对节点坐标的梯度 。NCJAndGradients(第 314--394 行) 对三角形给出 3 个 NCJ 值及其对 9 个坐标(3 顶点 × 3 分量)的 27 个偏导数;对四边形给出 4 个 NCJ 及 36 个偏导(numNCJVal() × 3 × 3)。中间的 NCJAndGrad2D(第 47--87 行)用叉积的 乘积法则算导数:const SVector3 dvndx0 = crossprod(v01, dv02dp0[0]) + crossprod(dv01dp0[0], v02);,再 NCJ = dot(crossprod(v01, v02), normal)、dNCJ[0] = dot(dvndx0, normal);unitVecAndGrad(第 31--42 行)则给出单位向量对坐标的导数 (grad[0] = invN * vec[0] * vec; grad[0][0] -= invN;)------这是链式法则里最容易出错的一环。
为什么梯度如此重要?因为Winslow 解缠、目标驱动光滑、高阶优化都需要把"质量"写成一个 可微目标函数 ,用梯度/牛顿迭代去最小化。NCJAndGradients 就是"度量(7.1)"通向"优化 (7.4/7.7)"的那座桥。注释里还留了大量 DBGTT(debug this)打印语句,说明这套梯度曾被 逐项对照验证过------这类代码没有捷径,梯度错了优化就不收敛。
7.1.6 MElement 层的有符号判据:SICN / SIGE / disto
qmXxx 是"某种单元类型"的度量;而网格优化器面对的是任意 MElement*。为此 MElement(src/geo/MElement.h 第 271--297 行)提供了一族 虚函数度量,由各子类重载:
cpp
virtual double gammaShapeMeasure() { return 0.; }
virtual double etaShapeMeasure() { return 0.; }
double minSICNShapeMeasure()
{
double sICNMin, sICNMax;
signedInvCondNumRange(sICNMin, sICNMax);
return sICNMin;
}
double minSIGEShapeMeasure()
{
...
}
double distoShapeMeasure() { ... }
virtual void signedInvCondNumRange(double &iCNMin, double &iCNMax, ...);
它们的含义:
| 度量 | 全称 | 判据 | 典型用途 |
|---|---|---|---|
gammaShapeMeasure |
γ | 2ρ/R / 直角偏差 | 二维/三维显示与优化目标 |
etaShapeMeasure |
η | 最小角偏差 | 四边形优化 |
minSICNShapeMeasure |
Signed Inverse Condition Number | 参数域内 ICN 的最小值(有符号) | 三维优化主判据 |
minSIGEShapeMeasure |
Signed Inverse Gradient Error | 梯度误差的倒数(有符号) | 更精细的形状度量 |
distoShapeMeasure |
Distortion | 畸变率 | 高阶单元正性检查 |
关键在 Signed :这些量在单元翻转时为负,优化器的阈值判断(例如 qMin 与 sliverLimit = 0.001)就能同时捕捉"质量差"与"已翻转"两种病态。minSICN 取"参数域内 最小值"的思想与 NCJ 一脉相承------它不满足于顶点处好,要求处处好。
7.1.7 直三角形的快速路径:sicnStraightTriangle
三维优化器会极其频繁地 查询三角/四面体质量(每次边翻转评估、每次节点移动评估都算一遍 相邻单元)。对直线(一阶)3 节点三角形 ,雅可比是常量,通用机制(基函数查找、 fullMatrix 分配、getNodesCoord、Eigen 乘积)纯属浪费。Gmsh 因此写了一条手写快速路径 (src/mesh/Generator.cpp 第 355--410 行),注释把来龙去脉讲得很清楚:
cpp
// Same quantity as MElement::minSICNShapeMeasure() (signedInvCondNumRange
// with no GEntity), 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) (see
// GradientBasis::calcMapFromIdealElement), and the formula below is
// calcInvCondNum2D (CondNumBasis.cpp) verbatim.
static double sicnStraightTriangle(const MElement *e)
{
const MVertex *v0 = e->getVertex(0), *v1 = e->getVertex(1),
*v2 = e->getVertex(2);
static const double c1 = -1. / std::sqrt(3.), c2 = 2. / std::sqrt(3.);
const double u1x = v1->x() - v0->x(), u1y = v1->y() - v0->y(),
u1z = v1->z() - v0->z();
const double u2x = v2->x() - v0->x(), u2y = v2->y() - v0->y(),
u2z = v2->z() - v0->z();
const double dxdX = u1x, dydX = u1y, dzdX = u1z;
const double dxdY = c2 * u2x + c1 * u1x, dydY = c2 * u2y + c1 * u1y,
dzdY = c2 * u2z + c1 * u1z;
// face normal (as MFace::normal, up to positive normalization, which does
// not affect the sign of the dot product below)
const double nx = u1y * u2z - u1z * u2y, ny = u1z * u2x - u1x * u2z,
nz = u1x * u2y - u1y * u2x;
...
const double sigma1Sq = 0.5 * (N + sqrtS), sigma2Sq = 0.5 * (N - sqrtS);
const double iCN = 2. * sqrt(sigma1Sq * sigma2Sq) / (sigma1Sq + sigma2Sq);
const double lnx = dydX * dzdY - dzdX * dydY,
lny = dzdX * dxdY - dxdX * dzdY,
lnz = dxdX * dydY - dydX * dxdY;
const double dp = lnx * nx + lny * ny + lnz * nz;
return (dp >= 0.) ? iCN : -iCN; // 有符号!
}
数学骨架 :把三角形映射到理想(等边)参考单元,其雅可比 J 的奇异值 σ1,σ2 满足 N=∥J∥F2=σ12+σ22、 detJ=σ1σ2。于是
iCN=σ12+σ222σ12σ22 =∥J∥F22detJ
(源码用 S 展开 ∥J∥F2 的平方,逐个标量相乘以省去矩阵分配)。正三角形 iCN = 1; 退化单元 iCN → 0。最后用参考单元雅可比的行列式向量 l 与面法向 n 的 点积 dp 定符号:dp < 0 代表翻转 ,返回 -iCN。
这段代码的价值在于示范了 Gmsh 性能工程的一个套路:通用模板 + 热点特化 。当 MElement 是已知的一阶三角时,用 20 个浮点乘加替换掉整套通用机制;否则退回 e->minSICNShapeMeasure()。
7.1.8 直四面体的快速路径:sicnStraightTetrahedron
四面体同理(第 412--449 行),公式退化为 3detJ/(∥J∥∥adjJ∥):
cpp
// the same for a straight tetrahedron: 3 det(J) / (|J| |adj(J)|), with J the
// (constant) jacobian with respect to the regular reference tetrahedron
static double sicnStraightTetrahedron(const MElement *e)
{
const MVertex *v0 = e->getVertex(0);
double u[3][3];
for(int i = 0; i < 3; i++) {
const MVertex *v = e->getVertex(i + 1);
u[i][0] = v->x() - v0->x();
u[i][1] = v->y() - v0->y();
u[i][2] = v->z() - v0->z();
}
static const double c1 = 1. / std::sqrt(3.), c2 = 1. / std::sqrt(6.),
c3 = std::sqrt(1.5);
double J[3][3]; // J[row][column]
for(int k = 0; k < 3; k++) {
J[k][0] = u[0][k];
J[k][1] = -c1 * u[0][k] + 2. * c1 * u[1][k];
J[k][2] = -c2 * (u[0][k] + u[1][k]) + c3 * u[2][k];
}
const double I[9] = { ... 伴随矩阵 adj(J) 的 9 个元素 ... };
const double D = J[0][0] * I[0] + J[0][1] * I[3] + J[0][2] * I[6];
if(D == 0.) return 0.;
double nJ = 0., nI = 0.;
for(int k = 0; k < 9; k++) {
nJ += J[k / 3][k % 3] * J[k / 3][k % 3];
nI += I[k] * I[k];
}
return 3. * D / std::sqrt(nJ * nI);
}
关键点:
c1 = 1/√3, c2 = 1/√6, c3 = √1.5是把"正四面体参考单元"的映射写进 J 的列 (与cond里的INVW矩阵是同一套几何);- D=detJ(用第一行与伴随矩阵点积算,避免
inv3x3的开销); nJ = ‖J‖_F²、nI = ‖adj J‖_F²,于是 ∥J∥∥adjJ∥=nJ⋅nI ;- 返回 3D/nJnI ,天然有符号 ( D 为负即翻转)。
7.1.9 GetQualityFast:min/avg 的并行统计
把上面的快速路径装进一个并行归约统计函数,就是 GetQualityFast (第 451--479 行):
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;
}
三处工程细节值得记住:
- 类型分派在循环内 :用
getTypeForMSH()一次判断,走三条路径。对网格化后的大网格, 三角/四面体占绝大多数,所以这两条快速路径命中率最高。 - OpenMP 归约 :
reduction(min: qm)与reduction(+: qa)让每个线程各算一部分, 最后合并。最小值与求和都是可结合归约,天然并行安全。 - 结果写入 CTX :调用点(src/mesh/Generator.cpp 第 1806 行) 把结果存进
CTX::instance()->mesh.minQuality/avgQuality,供 GUI 与脚本读取:
cpp
GetQualityFast(m, m->getMeshStatus(), CTX::instance()->mesh.minQuality,
CTX::instance()->mesh.avgQuality);
Msg::Debug("ICN mesh quality: min=%g avg=%g",
CTX::instance()->mesh.minQuality,
CTX::instance()->mesh.avgQuality);
注意它传入的是 m->getMeshStatus() 作为 dim------即"当前网格最高维",所以三维网格就算 体单元,二维网格就算面单元,正好对应"哪个维度最重要"。
7.1.10 直方图与 GetStatistics
min/avg 两个数字不足以刻画质量分布。GetStatistics (第 256--353 行)额外算三个 101 段的直方图(SICN、gamma、SIGE):
cpp
template <class L, class H>
static int histogramBin(double v, double guess, L lo, H hi)
{
if(!(guess > -2. && guess < 103.)) return -1; // (NaN, or far outside)
int j0 = (int)std::ceil(guess);
for(int j = j0 - 1; j <= j0 + 1; j++)
if(j >= 0 && j <= 100 && lo(j) < v && v <= hi(j)) return j;
return -1;
}
addQualityMeasures 依次取 gammaShapeMeasure/minSICNShapeMeasure/minSIGEShapeMeasure 并调用 histogramBin 累加到三个直方图。histogramBin 先用一个解析近似(guess)定位桶,再在相邻三个桶里按真实上下界校正------ 这是一个O(1) 分桶 技巧,避免了遍历 101 个桶。而且它对 NaN 有防线(!(guess > -2) 在 NaN 时也成立,因为 NaN 的任何比较都为 false)。
质量统计也是并行 的(getQualityMeasures,第 232--254 行):把元素切成 nthreads 段, 每段独立累加 qualityStats,最后按序相加。有一个微妙的预处理:
cpp
// (the bases the measures use are made by the first element of each type,
// before the threads, which then only read them)
std::set<int> types;
std::vector<bool> first(elements.size(), false);
for(std::size_t i = 0; i < elements.size(); i++)
if(types.insert(elements[i]->getTypeForMSH()).second) {
addQualityMeasures(elements[i], q);
first[i] = true;
}
每个类型的第一元素先单独算一遍 ,其作用是"构造并缓存该类型度量所需的基函数" (JacobianBasis、GradientBasis 等有多线程不安全的懒初始化),之后的线程只读这些基。 这是一个非常典型的"用一次串行预热换取并行安全"的模式。
统计结果落到 stat[18..26]:sicn/gamma/sige 各自的 avg/min/max;直方图由 quality[3][101] 输出,供 GUI 的统计窗口绘制。这就是 Gmsh 里"Mesh quality"面板背后的 数据来源。
7.1.11 高阶多项式雅可比:正性检查的终极手段
对高阶(曲边)单元,仅看顶点远远不够------一个 27 节点六面体可能顶点处雅可比全正,内部却 翻转。Gmsh 的解法是构造雅可比的行列式多项式并用其贝塞尔表示检查正性:
- src/numeric/JacobianBasis.h 负责构造雅可比多项式。 之所以存储为贝塞尔(Bernstein)形式 ,是因为在贝塞尔基下,若所有控制点(Bezier 系数)非负,则多项式在整个单纯形上非负------正性检查等价于检查有限个系数,无需解方程。
- src/mesh/qualityMeasuresJacobian.h /
.cpp在单元上求雅可比多项式的极值,得到 NCJ 与畸变。
HighOrder.cpp 里的 checkHighOrderTriangles/checkHighOrderTetrahedron 用两条度量报告 最差单元(第 1973--1978 行附近被 SetOrderN 调用):
cpp
if(!linear && !CTX::instance()->mesh.hoSkipQualityCheck) {
std::vector<MElement *> bad;
double worst;
checkHighOrderTriangles("Surface mesh", m, bad, worst);
checkHighOrderTetrahedron("Volume mesh", m, bad, worst);
}
(SetOrderN 第 1973--1978 行)。checkHighOrder* 内部用 distoShapeMeasure() 与 gammaShapeMeasure() 报告,并把 disto < 0 的单元收集进 bad、disto < 0.2 的计入 "欠佳"。这就是"高阶网格生成后自动体检"的入口;Mesh.HighOrderSkipQualityCheck = 1 可 关掉它以省时间。
小结本节 :度量体系是分层 的------底层是 qmXxx 的纯几何公式(gamma/eta/NCJ/cond), 上层是 MElement 的有符号虚函数(SICN/SIGE/disto),再上层是并行统计与直方图,最上层是 高阶多项式雅可比的正性检查。优化的每一个决策都建立在这套度量之上。
7.2 OptimizeMesh:方法表即能力表
7.2.1 统一入口与名字校验
OptimizeMesh(src/mesh/Generator.cpp 第 1061 行; 声明在 src/mesh/Generator.h)是所有优化的公共入口:
cpp
void OptimizeMesh(GModel *m, const std::string &how = "", bool force = false,
int niter = 1, double quality = 0.0);
它的第一件事是用一个长 if 校验方法名(第 1065--1075 行):
cpp
if(how != "" && how != "Gmsh" && how != "Optimize" && how != "Optimize2D" &&
how != "MesquiteImprove3D" && how != "MesquiteImprove2D" &&
how != "UntangleTris" && how != "UntangleTets" && how != "Netgen" &&
how != "HighOrder" && how != "HighOrderElastic" &&
how != "HighOrderFastCurving" && how != "Laplace2D" &&
how != "Relocate2D" && how != "Relocate3D" &&
how != "QuadCavityRemeshing" && how != "QuadQuasiStructured" &&
how != "UntangleMeshGeometry" && how != "HXT" && how != "HXT_FlipOnly") {
Msg::Error("Unknown mesh optimization method '%s'", how.c_str());
return;
}
这张字符串名单就是 Gmsh 的完整优化能力表 。它的设计与前几章的一贯风格一致:用字符串 方法名 作为稳定接口(脚本、GUI、CLI 都能传),底层实现可以自由替换。脚本里 Mesh.Optimize / OptimizeMesh "..." 就是直接映射到这里的 how。
7.2.2 逐项拆解方法表
下表把每个方法、实现文件与核心动作列全(how == "" / "Gmsh" / "Optimize" 三者等价):
| 方法名 | 实现位置 | 核心动作 |
|---|---|---|
"" / Gmsh / Optimize |
meshGRegionOptimize.cpp |
对每个体做自研三维优化 + setAllVolumesPositive |
Optimize2D |
meshGFaceDelaunay.cpp |
仅对平面 面做 PolyMeshDelaunayize(边翻转 Delaunay 化) |
UntangleTets |
meshUntangling.cpp |
对每个体做 Winslow 解缠 untangleGRegionMeshConstrained |
UntangleTris |
meshUntangling.cpp |
对每个面做 untangleGFaceMeshConstrained |
MesquiteImprove2D |
meshMesquite.cpp |
Mesquite 的 ShapeImprover(三角形) |
MesquiteImprove3D |
meshMesquite.cpp |
Mesquite 的 ShapeImprover(四面体) |
Netgen |
meshGRegionOptimize.cpp |
optimizeMeshGRegionNetgen(Netgen 端口) |
HighOrder |
contrib/HighOrderMeshOptimizer |
通用曲边优化 HighOrderMeshOptimizer |
HighOrderElastic |
contrib/HighOrderMeshOptimizer |
弹性类比 HighOrderMeshElasticAnalogy |
HighOrderFastCurving |
contrib/HighOrderMeshOptimizer |
快速曲边 HighOrderMeshFastCurving |
Laplace2D |
meshGFaceOptimize.cpp |
Laplace 光滑 laplaceSmoothing |
Relocate2D |
meshRelocateVertex.cpp |
面顶点重定位(黄金分割搜索) |
Relocate3D |
meshRelocateVertex.cpp |
体顶点重定位 |
QuadCavityRemeshing |
meshQuadQuasiStructured.cpp |
四边形腔体重剖分 optimizeTopologyWithCavityRemeshing |
QuadQuasiStructured |
meshQuadQuasiStructured.cpp |
准结构化四边形 + 腔体重剖分 |
UntangleMeshGeometry |
meshUntangling.cpp |
平动面解缠 + 三维解缠 |
HXT / HXT_FlipOnly |
HXT 模块 | optimizeMeshHXT(TetGen 系并行优化,翻转/全量) |
下面逐个拆解,并附真实源码。
7.2.3 默认路径:Gmsh 三维优化
当 how 为空/Gmsh/Optimize 时(第 1083--1090 行):
cpp
if(how == "" || how == "Gmsh" || how == "Optimize") {
for(auto it = m->firstRegion(); it != m->lastRegion(); it++) {
optimizeMeshGRegion opt;
opt(*it, force);
// RelocateVerticesOfPyramids(*it, 10, 1.e-6);
}
m->setAllVolumesPositive();
}
它遍历所有 GRegion,对每个体跑 optimizeMeshGRegion------这是一个函数对象 (functor), 内部按体的单元类型分派到不同的优化流程。最后一句 m->setAllVolumesPositive() 是兜底: 把所有体单元翻正(因为优化过程可能留下个别负体积,或 Mesquite/HXT 这类外部库不保证方向)。 这是一个贯穿所有优化方法的收尾动作------下面每一个分支几乎都以它结束。
7.2.4 UntangleTets / UntangleTris:直接解缠
UntangleTets 对每个体调用 untangleGRegionMeshConstrained(gr, nIterWinslow, timeMax) (默认 nIterWinslow = 10、timeMax = 100.),最后 setAllVolumesPositive;UntangleTris 对每个面调用 untangleGFaceMeshConstrained(gf)。注意默认参数"每个体最多 10 次外层 Winslow 迭代、总 CPU 预算 100 秒"是"投入多少算力去解缠"的直接控制。
7.2.5 面/体顶点重定位与 Laplace 光滑
三者都是全局光滑 ,用 niter 控制迭代次数(第 3 个形参,脚本可传):Laplace2D 对每个面 调 laplaceSmoothing(gf, niter);Relocate2D 对每个面调 RelocateVertices(gf, niter); Relocate3D 对每个体调 RelocateVertices(gr, niter)。
Laplace2D:把每个内部节点移到其星形邻域的参数域质心,或做"最小面积"迭代;Relocate2D:更"聪明"的版本,用黄金分割搜索在"质心 → 原位"的直线上找质量最优点;Relocate3D:三维版本,逐步松弛(第一轮只走 1/niter,逐渐加满)。
7.2.6 四边形腔体重剖分
QuadCavityRemeshing 有一个非常 Gmsh 式的操作:先把所有 DONE 的面重置为 PENDING , 调 transferSeamGEdgesVerticesToGFace(m) 与 optimizeTopologyWithCavityRemeshing(m),处理完 再把 PENDING 恢复为 DONE。因为优化器内部只处理 PENDING 的面(meshQuadQuasiStructured.cpp 第 2058 行 if(gf->meshStatistics.status != GFace::PENDING) continue;)。这利用了 meshStatistics.status 这个既有状态机来充当临时的"工作集"标记 ,而不用新引入参数。 QuadQuasiStructured 分支类似,只是多了 quadMeshingOfSimpleFacesWithPatterns(简单面的 图案化四边形划分)。
7.2.7 高阶分支与 Netgen 分支
高阶三分支都受 HAVE_OPTHOM 保护,缺模块时报错而非静默跳过(第 1129--1168 行):
cpp
else if(how == "HighOrder") {
#if defined(HAVE_OPTHOM)
OptHomParameters p;
p.nbLayers = CTX::instance()->mesh.hoNLayers;
p.BARRIER_MIN = CTX::instance()->mesh.hoThresholdMin;
p.BARRIER_MAX = CTX::instance()->mesh.hoThresholdMax;
p.itMax = CTX::instance()->mesh.hoIterMax;
p.optPassMax = CTX::instance()->mesh.hoPassMax;
p.fixBndNodes = CTX::instance()->mesh.hoFixBndNodes;
p.dim = m->getDim();
p.optPrimSurfMesh = CTX::instance()->mesh.hoPrimSurfMesh;
p.optCAD = CTX::instance()->mesh.hoDistCAD;
HighOrderMeshOptimizer(m, p);
#else
Msg::Error("High-order mesh optimization requires the OPTHOM module");
#endif
}
Netgen 分支则把工作交给 optimizeMeshGRegionNetgen(第 1122--1128 行),体现"外部优化器 以端口形式接入"的一致接口。
7.2.8 收尾:一致性检查与计时
无论走哪个分支,末尾都会(第 1249--1255 行):
cpp
if(Msg::GetVerbosity() > 98)
std::for_each(m->firstRegion(), m->lastRegion(),
EmbeddedCompatibilityTest());
double t2 = Cpu(), w2 = TimeOfDay();
Msg::StatusBar(true, "Done optimizing mesh (Wall %gs, CPU %gs)", w2 - w1,
t2 - t1);
EmbeddedCompatibilityTest 只在极高 verbosity(>98)时跑,用于验证嵌入实体与体的兼容性------ 这是"深度调试"模式下才开启的自检。
7.3 局部修改算子:边翻转、面翻转、点重定位、Cavity
7.3.1 三维优化的主循环
默认的 Gmsh 优化会调用 optimizeMeshGRegion,其核心是 src/mesh/meshGRegionOptimize.cpp 里的 optimizeMesh(GRegion *gr, const qmTetrahedron::Measures &qm)。文件顶部的注释交代了一个 重要事实------有两套内核做同一件事:
cpp
// Improving the tetrahedra of a region by local mesh modifications. Two
// kernels do the same work: the original one, on the MTet4 objects, which is
// below, and the flat one, on index-based arrays, which is the default
// (Mesh.FlatOptimize3D), lives in meshGRegionOptimizeFlat.cpp, and is the one
// meant to remain.
即:老的 MTet4(面向对象)版本正在被新的扁平数组(flat)版本 取代,由 Mesh.FlatOptimize3D(默认 1)选择。函数开头就分派:
cpp
if(CTX::instance()->mesh.flatOptimize3D && optimizeMeshFlat(gr, qm)) return;
若扁内核可用,直接返回;否则走下面这套 MTet4 流程。
7.3.2 数据结构:MTet4 与连通性
老内核把每个 MTetrahedron 包装成 MTet4,放在一整块连续内存里:
cpp
typedef std::vector<MTet4 *> CONTAINER;
std::vector<MTet4> initialTets(gr->tetrahedra.size());
auto inBlock = [&initialTets](MTet4 *t) { // 区分"块内 / 单独 new"
return t >= initialTets.data() && t < initialTets.data() + initialTets.size();
};
CONTAINER allTets; allTets.reserve(gr->tetrahedra.size());
for(std::size_t i = 0; i < gr->tetrahedra.size(); i++) {
MTet4 *t = &initialTets[i];
*t = MTet4(gr->tetrahedra[i], qm); t->setOnWhat(gr); allTets.push_back(t);
}
gr->tetrahedra.clear();
初始单元整块分配 (缓存友好),后续局部修改新建的单元单个分配;inBlock 谓词让收尾时能 区分"块内统一释放"与"单独 delete"。
MTet4 缓存了质量 getQuality(),并在其上维护 isDeleted() 标志与邻接指针(connectTets)。 连通时区分"有无嵌入面":
cpp
if(allEmbeddedFaces.empty()) {
connectTetsFast(allTets.begin(), allTets.end());
}
else {
// the embedded faces have to be left unconnected, which the bucketed
// version above does not know how to do
connectTets(allTets.begin(), allTets.end(), &allEmbeddedFaces);
}
嵌入面(embedded faces)必须保持不连通------它们代表内部界面,翻转跨越它们会破坏几何 分类。这与 7.1.6 的"保留边界分类"直接相关。
7.3.3 质量直方图与优化起点
优化的第一步是报告"优化前"的分布(第 83--116 行):
cpp
std::vector<MTet4 *> illegals;
int quality_ranges[nbRanges]; // nbRanges = 10
{
double totalVolumeb = 0.0, worst = 1.0, avg = 0;
int count = 0;
for(int i = 0; i < nbRanges; i++) quality_ranges[i] = 0;
for(auto it = allTets.begin(); it != allTets.end(); ++it) {
if(!(*it)->isDeleted()) {
double vol = fabs((*it)->tet()->getVolume());
double qual = (*it)->getQuality();
worst = std::min(qual, worst);
avg += qual; count++; totalVolumeb += vol;
for(int i = 0; i < nbRanges; i++) {
double low = (double)i / nbRanges;
double high = (double)(i + 1) / nbRanges;
if(qual >= low && qual < high) quality_ranges[i]++;
}
}
}
Msg::Info("Optimization starts (volume = %g) with worst = %g / average = %g:",
totalVolumeb, worst, avg / count);
...
}
nbRanges = 10(文件作用域常量,注释解释是为了让 lambda 不用捕获它)。这个直方图比 7.1.10 的 101 段粗很多------因为它是优化过程的诊断输出,只需知道"有多少单元在 [0.3,0.4) 区间"即可。
7.3.4 主循环:边翻转 → 节点重定位 → 收敛
优化是一个 while(1) 的外层迭代,每轮做两件事:
cpp
while(1) {
std::vector<MTet4 *> newTets;
illegals.clear();
for(int i = 0; i < nbRanges; i++) quality_ranges[i] = 0;
for(auto it = allTets.begin(); it != allTets.end(); ++it) {
if(!(*it)->isDeleted()) {
double qq = (*it)->getQuality();
if(qq < qMin) {
for(int i = 0; i < 6; i++) {
MEdge ed = (*it)->tet()->getEdge(i);
if(allEmbeddedEdges.find(ed) == allEmbeddedEdges.end()) {
if(edgeSwap(newTets, *it, i, qm, allEmbeddedFaces)) {
nbESwap++;
break;
}
}
}
}
if(!(*it)->isDeleted()) {
if(qq < sliverLimit) illegals.push_back(*it);
for(int i = 0; i < nbRanges; i++) { ... quality_ranges[i]++; }
}
}
}
if(!newTets.size()) { break; }
...
}
要点:
- 只优化低于阈值的单元 :
qMin = CTX::instance()->mesh.optimizeThreshold(默认 0.3, 对应Mesh.OptimizeThreshold)。若qMin <= 0则整个优化直接返回------这给了一个"关闭 优化"的开关。 - 对每个坏单元试 6 条边 :
edgeSwap(newTets, *it, i, qm, allEmbeddedFaces),成功一条 就break(不做更多尝试)。嵌入边跳过不翻转。 sliverLimit = 0.001:质量低于 0.001 的记为illegals(片状单元),最后报告 "还有多少个病态单元"。- 若无新单元产生,跳出:这是"没有可改进项"的自然终止。
第二轮是节点重定位(第 166--178 行):
cpp
// relocate vertices
if(gr->hexahedra.empty() && gr->prisms.empty() && gr->pyramids.empty()) {
for(auto it = allTets.begin(); it != allTets.end(); ++it) {
if(!(*it)->isDeleted()) {
double qq = (*it)->getQuality();
if(qq < qMin) {
for(int i = 0; i < 4; i++) {
if(smoothVertex(*it, i, qm)) nbReloc++;
}
}
}
}
}
注意条件 gr->hexahedra.empty() && ...:只有纯四面体体才做节点光滑。混合网格里, 移动节点可能翻转它没"看见"的六面体/棱柱------这是保守设计。
收敛判据(第 199--200 行)用相邻两轮的最差质量变化:
cpp
if(worstA != 0.0 && worst - worstA < 1.e-6) break;
worstA = worst;
即最差质量在两轮间改善不足 10−6 就停止。这避免了"质量已达标但仍在空转"。每轮打印 %d edge swaps, %d node relocations: worst = ... / average = ...,构成完整的优化日志。
7.3.5 局部算子的声明:meshGRegionLocalMeshMod.h
所有三维局部算子的接口都在 src/mesh/meshGRegionLocalMeshMod.h:
cpp
// Local mesh modification operators. Those operators only apply to
// the "bulk" of the mesh and cannot be applied to boundaries.
enum localMeshModAction { GMSH_DOIT, GMSH_EVALONLY };
// the possible retriangulations of the ring of an edge, for edge swaps
typedef struct {
int nbr_triangles; // number of different triangles
int (*triangles)[3]; // triangles array
int nbr_trianguls; // number of different triangulations
int nbr_triangles_2; // number of triangles / triangulation
int (*trianguls)[5]; // retriangulations array
} SwapPattern;
void BuildSwapPattern3(SwapPattern *sc);
void BuildSwapPattern4(SwapPattern *sc);
void BuildSwapPattern5(SwapPattern *sc);
void BuildSwapPattern6(SwapPattern *sc);
void BuildSwapPattern7(SwapPattern *sc);
bool edgeSwap(std::vector<MTet4 *> &newTets, MTet4 *tet, int iLocalEdge,
const qmTetrahedron::Measures &cr,
const std::set<MFace, MFaceLessThan> &embeddedFaces);
bool faceSwap(std::vector<MTet4 *> &newTets, MTet4 *tet, int iLocalFace,
const qmTetrahedron::Measures &cr,
const std::set<MFace, MFaceLessThan> &embeddedFaces);
bool smoothVertex(MTet4 *t, int iLocalVertex,
const qmTetrahedron::Measures &cr);
bool collapseVertex(std::vector<MTet4 *> &newTets, MTet4 *t, int iVertex,
int iTarget, const qmTetrahedron::Measures &cr,
const localMeshModAction = GMSH_DOIT,
double *result = nullptr);
四类算子:
edgeSwap(边翻转) :把共享一条边的 n 个四面体,重新三角剖分成共享另一条(或 其它拓扑)的四面体组。SwapPattern描述了"边周围环的多种重三角剖分方案"------BuildSwapPattern3..7分别预计算 3~7 个四面体环绕时的所有可行配置。经典的 2-3 翻转 (两个四面体 → 三个)与 3-2 翻转(三个 → 两个)是其中最常见的两种。faceSwap(面翻转):把一个四面体的一个面换成另一个面(保持拓扑合法的局部重组)。smoothVertex(节点光滑):把一个顶点移到其邻域的最优位置。collapseVertex(顶点塌缩) :把一个顶点塌缩到另一个目标顶点------用于消除片状(sliver) 单元。GMSH_EVALONLY让它只评估(给result回填质量)而不真正执行。
localMeshModAction 这个双值枚举很关键:同一算子既能"试算"也能"执行" 。优化器先 EVALONLY 评估某操作是否会改善质量,只有在确认改善后才 DOIT。这是安全优化(保证质量 单调不降)的实现基础。
7.3.6 扁平内核:index-based arrays
新内核 src/mesh/meshGRegionOptimizeFlat.cpp 的注释 把动机讲透:
cpp
// Flat port of optimizeMesh() (meshGRegionOptimize.cpp): the mesh is
// held in index-based arrays (four vertex indices and four packed neighbors
// per tet, coordinates per vertex) instead of MTet4 wrappers over
// MTetrahedron over MVertex. The optimization itself is unchanged - sweep the
// tets, edge-swap the ones below the quality threshold, then relocate the
// nodes of those that remain - but the sweeps walk contiguous arrays, the
// adjacency is built once on dense indices, and a swap rewrites integers
三个性能收益:
- 连续数组扫描:四个顶点索引 + 四个打包邻居 + 顶点坐标,全是紧凑数组,缓存命中率 远高于指针追逐;
- 稠密索引邻接 :一次性在稠密索引上建邻接,避免
MVertex*哈希查找; - 翻转只改写整数:不 new/delete 对象,只改索引。
这是一次典型的"面向对象 → 面向数据"重构(data-oriented design),在性能敏感的 内核里越来越常见。
7.3.7 节点重定位的目标函数与黄金分割搜索
三维优化的"光滑"其实是一个质量驱动的重定位 ------不是简单移到邻近质心,而是沿着某个 方向搜索质量最优点。这在 src/mesh/meshRelocateVertex.cpp 里实现。三维目标函数 objective_function(xi, ver, xTarget, yTarget, zTarget, lt) 把顶点暂时移到 (1-xi)*原位 + xi*目标位、算完质量再原地恢复(无副作用)。三个要点 值得细读:
xi是插值参数 :把顶点暂时移到(1-xi)*原位 + xi*目标位,算完质量再原地恢复 。 目标函数是无副作用的------这是搜索类优化的基石。- 取邻域最小值 :不是平均质量,而是
min------因为优化关注最差单元,一荣俱荣不算好。 - 负体积惩罚 :
if(V > 0) Q = -Q;------注意gamma返回的是幅值,这里用体积符号把它 变成有符号量:负体积的单元质量记为负 ,从而被min优先"照顾"。这解释了为什么 前面反复强调"有符号判据"。
同样思路有面版本(第 81--132 行),把 2D 顶点的目标位设为参数域质心 ,再投影回曲面: SPoint2 p = p1 * (1. - xi) + p2 * xi; GPoint pp = gf->point(p);。
参数域插值 + 几何投影 是 Gmsh 处理曲面节点的标准手法:在 (u,v) 平面上走直线(保证 点始终在曲面参数域内),再用 gf->point(p) 映射回三维------从而节点永不脱离曲面。这是 第 5 章"参数化网格"思想的延续。
搜索算法用黄金分割 (第 139--178 行):lambda = (√5-1)/2、mu = 1-lambda,每步舍弃 约 0.382 的区间,直到区间长度小于 tol。三维版本取 b = 2.0(允许过冲 ,因为最优位置 可能不在"原位→质心"线段上),二维版本 b = 1.0(参数域线段本身有几何意义)。
顶点的最终移动(第 325--361 行)分两步:先试全量移到邻域质心 (xi=1),若质量改善就 接受;否则再做黄金分割细搜------先试大步、再细搜,既快又稳。
7.3.8 逐步松弛与只动"看见全部单元"的顶点
RelocateVertices(GRegion...)(第 459--476 行)用渐进松弛避免早期大幅移动引发翻转:
cpp
v2t_cont adj;
buildVertexToElement(region->tetrahedra, adj);
buildVertexToElement(region->pyramids, adj);
buildVertexToElement(region->prisms, adj);
buildVertexToElement(region->hexahedra, adj);
for(int i = 0; i < niter + 2; i++) {
auto it = adj.begin();
double relax = std::min((double)(i + 1) / niter, 1.0);
while(it != adj.end()) {
relocateVertexGolden(it->first, it->second, relax, tol);
++it;
}
}
relax 从 1/(niter) 逐步升到 1.0:第一轮只走很小一步 ,之后逐渐走满。这样即便某个 方向不理想,也不会一步跨到坏位置。跑 niter + 2 轮(多出的两轮是"放松"阶段)。
对金字塔的节点(金字塔顶点,位于四边形面中心上方),有专门的重定位(第 274--323 行):
cpp
if(pyramid) {
MFace q = pyramid->getFace(4);
double A = q.approximateArea();
SVector3 n = q.normal();
n.normalize();
SPoint3 c = q.barycenter();
SVector3 d(x - c.x(), y - c.y(), z - c.z());
if(dot(n, d) < 0) n = n * (-1.0);
double H = .5 * sqrt(fabs(A));
double XOPT = c.x() + relax * H * n.x();
...
double FULL_MOVE_OBJ = objective_function(1.0, ver, XOPT, YOPT, ZOPT, lt, true);
if(FULL_MOVE_OBJ > 1e-6) { ver->x() = XOPT; ... return; }
}
金字塔的顶点沿四边形面法向 放到高度 H=0.5A 处------这是让金字塔"等高"的启发式。 optimizePyramids < 0 时甚至会固定顶点在法向线上(第 779--783 行),可见这套启发式仍在 演进。
7.3.9 二维 Laplace 光滑与"好主意"判据
二维的光滑是 src/mesh/meshGFaceOptimize.cpp 的 laplaceSmoothing(第 985--1016 行):先检测"一阶网格"假设(否则报错并返回);收集 边界层节点 vs(跳过它们);用 VertexToElementCSR adj 建节点→单元邻接;然后 niter 轮, 对每个不在 vs 中的节点调 _relocate(gf, v, adj.elements(j), ...)。
三个设计点:
- 拒绝高阶网格:一阶假设不成立时直接报错,把用户导向高阶工具------接口清晰、不静默做错。
- 跳过边界层节点 :
getAllBoundaryLayerVertices收集所有边界层列节点,if(vs.find(v) == vs.end())跳过------因为边界层节点已经沿法向精心排布,Laplace 会毁掉各向异性结构。 - CSR 邻接 :
VertexToElementCSR(见 src/mesh/meshGFaceOptimize.h 第 51--101 行)用扁平 CSR 取代std::map邻接,注释说"迭代顺序完全一致(确定性)但 构建成本大幅降低"。
真正的移动判据 _isItAGoodIdeaToMoveThatVertex(第 658--686 行)体现了"不降低质量才动 ": 它把候选参数坐标 after 经 gf->point 映射到三维、调 star.evaluate 得到新的参数域面积 surface_new 与最小质量 minq_new;若面积增大 ((1+1e-10)*surface < surface_new)或 质量降低 (minq_new < minq),就把顶点回滚到原位 并返回 false;否则接受并更新 surface/minq。
双重约束 :移动后既不能增大参数域面积(防止节点被"外推"导致面积膨胀),也不能降低 最小质量。任一不满足就回滚到原位 (保存了 pbefore 以便精确恢复)。1 + 1e-10 是 容差,避免浮点噪声触发误回滚。
vertexStar(第 532--651 行)是这套评估的核心数据:它缓存了邻域每个单元最多 4 个角点的 参数坐标 (u,v) 与笛卡尔坐标 ,从而在 evaluate 里能不查询几何 地重算面积与质量。 其中三角单元的质量用 _minCornerSin2Cos 在缓存坐标上直接算(比 gammaShapeMeasure 快), 四边形才退回 etaShapeMeasure。这就是"把几何查询集中一次、评估反复用"的经典优化。
7.3.10 Cavity 重剖分与"何时可以拓扑优化"
二维四边形网格的拓扑优化需要删除节点------这会破坏网格拓扑。函数 _isModelOkForTopologicalOpti(第 1304--1312 行)解释了为什么要有前置检查:
cpp
// The topological optimization routines remove nodes of gf, which assumes a
// complete mesh topology: in multi-surface STL files read without
// CreateTopology or ReclassifySurfaces, elements of a surface can have nodes
// owned by an adjacent surface, and removing them leaves an invalid mesh (and
// crashes). ...
static bool _isFaceOkForTopologicalOpti(GFace *gf)
{
for(std::size_t j = 0; j < gf->getNumMeshElements(); j++) {
MElement *e = gf->getMeshElement(j);
for(std::size_t k = 0; k < e->getNumVertices(); k++) {
GEntity *ge = e->getVertex(k)->onWhat();
if(!ge) return false;
if(ge->dim() == 2 && ge != gf) return false; // 节点属于别的面 → 不安全
}
}
return true;
}
也就是说:只有当一个面的所有单元节点都属于自己(或更低维边界)时,才允许删点做拓扑 优化;否则必须跳过(Msg::Info("Skipping topological optimization - mesh topology is not complete"))。这回答了一个常见疑问:"为什么有时候 Gmsh 跳过四边形拓扑优化?"------ 因为模型没有完整拓扑(典型是直接导入的 STL 多面)。
recombineIntoQuads(第 1314--1370 行)把重组流程串起来:
cpp
void recombineIntoQuads(GFace *gf, bool blossom, int topologicalOptiPasses,
bool nodeRepositioning, double minqual)
{
_recombineIntoQuads(gf, blossom); // 1) 成对三角 → 四边形
if(haveParam && nodeRepositioning)
RelocateVertices(gf, CTX::instance()->mesh.nbSmoothing); // 2) 光滑
if(topologicalOptiPasses > 0) { // 3) 拓扑优化(去二价点 / 菱形)
while(nbTwoQuadNodes || nbDiamonds) {
nbTwoQuadNodes = removeTwoQuadsNodes(gf);
nbDiamonds = haveParam ? removeDiamonds(gf) : 0;
if(haveParam && nodeRepositioning) RelocateVertices(gf, ...);
if(++iter > topologicalOptiPasses) break;
}
}
if(minqual > 0) quadsToTriangles(gf, minqual); // 4) 差四边形退回三角形
...
}
这里有五步 :成对重组(blossom 布尔选择 Blossom 匹配)、光滑、拓扑优化(去"二价节点" 与"菱形"结构)、质量回退(把太差的四边形拆回三角)、再光滑。整段被 #pragma omp critical 保护------重组涉及全局节点删除,不是线程安全的 (注释 // FIXME: not thread-safe)。
7.3.11 保边界分类与几何投影的原则
以上算子有一个共同约束:只在网格"内部"动手,边界处保守。具体体现在:
- 三维:
smoothVertex只对"纯四面体体"启用;嵌入面/嵌入边不参与翻转; - 二维:
RelocateVertices只在ver->onWhat()->dim() == 2的顶点上工作(relocateVertex第 394 行if(ver->onWhat()->dim() != 2) return 2.0;),曲线/点上的节点不移动 ; 若邻域含**缝边(seam)**则直接放弃(第 410--414 行if(ge->isSeam(gf)) return 2.0;); - 所有曲面节点的移动都投影回几何 (
gf->point(p)或gf->closestPoint),保证节点 始终落在被分类的曲面上。
一句话总结 :Gmsh 的优化是"保守的局部搜索"------只改内部拓扑,只在质量确不降低时 接受,所有移动都投影回原几何,边界与分类永不被破坏。
7.4 Winslow 解缠:当网格已经"打结"
7.4.1 为什么需要解缠
边翻转/光滑是局部 方法:它们能改善质量,但前提是网格拓扑上仍然合法 (没有翻转单元)。 当网格已经出现负面积三角 或负体积四面体("打结"),局部方法往往无能为力------因为 任何局部修改都要评估"质量是否改善",而翻转单元的存在使质量景观变得极不平坦,局部搜索 容易卡死。
这时需要全局解缠(untangling) :把网格当作一个整体,求解一个凸/近凸的全局优化 , 把所有节点一次性推到无翻转的构型。Gmsh 用的是 Winslow 光滑 ------一个来自 椭圆网格生成的思想。网格层的封装在 src/mesh/meshUntangling.h / src/mesh/meshUntangling.cpp,核心求解器在 src/numeric/WinslowUntangler.h / .cpp。
7.4.2 Winslow 的思想:把物理网格映射为调和方程
Winslow 网格生成的基本观察是:如果网格节点在逻辑(参考)域上满足 Laplace 方程,那么 物理网格会很光滑、且倾向于避免翻转 。具体地,令物理坐标 (x,y) 关于逻辑坐标 (ξ,η) 满足
∂ξ2∂2x+∂η2∂2x=0,∂ξ2∂2y+∂η2∂2y=0
即物理坐标是逻辑坐标的调和函数 。反过来(Winslow 解缠用的正是这个反向形式),把逻辑 坐标表为物理坐标的调和函数,可以构造一个以单元目标形状为约束的能量 ,求解得到既光滑 又无翻转的网格。这就是"把网格质量写成一个变分问题"------与 7.1.5 里 NCJ 梯度的用途 一脉相承。
WinslowUntangler 的接口是一个目标形状显式给出 的通用 2D/3D 解缠器:调用者给 points(当前坐标)、locked(哪些点不许动)、triangles/tets(单元)、 triIdealShapes/tetIdealShapes(每个单元的理想形状),它求解使整个网格逼近这些理想形状、 同时消除翻转。
7.4.3 二维:把单元转成"目标三角形"
buildTrianglesAndTargetsFromElements(第 43--79 行)干的就是这件事:
cpp
bool buildTrianglesAndTargetsFromElements(
const std::vector<std::array<uint32_t, 4>> &elements,
std::vector<std::array<uint32_t, 3>> &triangles,
std::vector<std::array<std::array<double, 2>, 3>> &triIdealShapes)
{
const uint32_t NO_U32 = (uint32_t)-1;
// 理想形状:单位面积的等边三角形 equi;单位正方形 qtarget
std::array<vec2, 3> equi = {vec2{1., 0.}, /*120°*/ ..., /*240°*/ ...};
... 缩放 equi 到单位面积;qtarget = {(0,0),(1,0),(1,1),(0,1)} ...
for(const auto &e : elements) {
if(e[0] == NO_U32) continue; // 空位
else if(e[3] == NO_U32) { // 三角形
triangles.push_back({e[0], e[1], e[2]});
triIdealShapes.push_back(equi); // → 等边目标
}
else for(size_t k = 0; k < 4; ++k) { // 四边形 → 4 个重叠角三角形
triangles.push_back({e[quad_dcp[k][0]], e[quad_dcp[k][1]],
e[quad_dcp[k][2]]});
triIdealShapes.push_back(/* qtarget 对应三角 */); // → 直角目标
}
}
return true;
}
关键:
- 三角形 :理想目标是单位面积、居中的等边三角形 (
equi); - 四边形 :拆成四个重叠的角三角形 (
quad_dcp定义了四个角三角形的顶点索引), 每个角三角形的理想目标是单位正方形的对应三角(直角); NO_U32 = (uint32_t)-1是"空位"哨兵------三角单元的第 4 个索引就是它。
quad_dcp 的定义(第 33 行):
cpp
static const int quad_dcp[4][3] = {{0, 1, 2}, {2, 3, 0}, {1, 2, 3}, {3, 0, 1}};
四个角三角形覆盖了整个四边形,它们重叠,从而在解缠时四边形内部没有"照顾不到"的区域。
7.4.4 分片解缠:近似平面上的局部 Winslow
WinslowUntangler 本质是二维求解器,因此 Gmsh 先把三维曲面切成近似平面的小片(patch) , 每片投到自己的**平均平面(mean plane)**上解缠,再把移动后的点投影回曲面。这就是 untangleGFaceMeshConstrained(第 219--269 行):
cpp
bool untangleGFaceMeshConstrained(GFace *gf)
{
const double threshold = 1.e-2;
double L = gf->bounds().diag();
std::map<MVertex *, std::vector<MElement *>> v2e; // 节点 → 邻域单元
...
std::set<MVertex *> touched;
for(size_t k = 0; k < v2e.size(); k++) {
MVertex *v = gf->mesh_vertices[iter % gf->mesh_vertices.size()];
if(touched.count(v)) continue;
std::vector<MElement *> es = v2e[v];
getVertices(es, vall, vbound);
mean_plane mp = computeMeanPlaneSimple(vall);
if(tooFarFromPlane(vall, mp, threshold * L)) continue;
while(1) { // 贪心扩张 patch,只要仍"近似平面"(偏离 < threshold*L)
... 加入 vbound 顶点的邻居单元,直到无法再扩或偏离超限 ...
}
for(auto vv : vall) if(!vbound.count(vv)) touched.insert(vv); // 内部点已处理
untangleGFaceMeanPlane(gf, es, mp, iter);
}
return true;
}
patch 的生长策略:
- 从某个未处理顶点出发,取其星形单元为种子;
- 计算这组顶点的平均平面 ;若已偏离平面超过
threshold * L(对角尺寸的 1%),放弃; - 否则不断把边界顶点的邻居单元拉进来,只要新集合仍"近似平面";
- 达到极限后,对这个 patch 做一次平面内解缠;
- 把 patch 的内部顶点 标记为
touched(它们的解缠贡献已包含在本 patch 中),避免重复处理。
这样,每片都是近似平面------Winslow 二维求解器正好适用。
7.4.5 平面内解缠:锁定边界、投影回面
untangleGFaceMeanPlane(第 111--196 行)把三维坐标投到平均平面得到二维坐标,标记哪些点 锁定,再调用求解器:
cpp
static bool untangleGFaceMeanPlane(GFace *gf, std::vector<MElement *> &els,
mean_plane &mp, int iter)
{
...
SVector3 t1(...), t2(...); // 平均平面的两个正交基
t1.normalize(); t2.normalize();
SPoint3 X0(mp.x, mp.y, mp.z);
for(auto v : vall) { // 投到平均平面,得二维坐标 (X,Y)
projectPointToPlane(v->point(), ptProj, mp);
SVector3 D = ptProj - X0;
points.push_back({dot(t1, D), dot(t2, D)});
if(!vbound.count(v) && v->onWhat()->dim() == 2)
locked.push_back(false); // 内部点:可动
else
locked.push_back(true); // 边界/曲线点:锁定
v->setIndex(i++);
}
... 统计 nbPos/nbNeg;理想的角三角形按 |area| 缩放填入 triIdealShapes ...
// most elements are reversed in this plane: retry with the other orientation
if(nbPos < nbNeg && iter > 0)
return untangleGFaceMeanPlane(gf, els, mp, -iter);
WinslowUntangler::Options options;
options.lambda = 1.e-2; options.maxInnerIterations = 200;
options.maxOuterIterations = 3; options.maxFailures = 300;
options.timeMax = 1.e3;
options.numThreads = CTX::instance()->numThreadsFor(triangles.size(), 5000);
WinslowUntangler::untangle2D(points, locked, triangles, triIdealShapes, options);
for(auto v : vall) { // 未锁定节点:映射回三维并投影回曲面
if(!locked[v->getIndex()]) {
SVector3 P = X0 + t1 * points[i][0] + t2 * points[i][1];
GPoint gp = gf->closestPoint(SPoint3(P.x(), P.y(), P.z()), {0, 0});
v->setXYZ(gp.x(), gp.y(), gp.z());
v->setParameter(gp.u(), gp.v());
}
}
return true;
}
这段代码浓缩了整套解缠设计的所有关键点:
- 锁定规则 :
locked = true的条件是"属于 patch 边界(vbound)或 结点不在本面 (dim() != 2,即曲线/点上的节点)"。于是只有面内部的自由节点会动,边界与曲线 节点固定------几何分类与边界被严格保护。这与 7.4 开头"Vertices on curves are fixed"的 注释完全一致。 - 方向检测与重试 :统计平面内正/负面积单元数(
nbPos/nbNeg)。若负的更多,说明 整体朝向反了 (从背面投影),于是用-iter递归重试(iter变负号,下一次if(iter < 0) std::swap(i0, i1)会翻转三角形定向)。这是处理"面朝向不确定"的实用技巧。 - 理想形状随面积缩放 :
s = sqrt(fabs(area))------理想三角形按每个单元的实际面积缩放, 于是解缠倾向于保持单元大小,不会把大单元压小。 - 求解参数 :
lambda = 1e-2、maxInnerIterations = 200、maxOuterIterations = 3、maxFailures = 300、timeMax = 1e3。2D 相对便宜,所以预算宽松。 - 投影回面 :移动后,把所有未锁定 节点的平面坐标 (X,Y) 映射回三维点
P = X0 + t1*X + t2*Y,再用gf->closestPoint投影回曲面 ,并更新参数坐标(u,v)。这保证节点精确落回曲面。
7.4.6 三维解缠:把六面体/金字塔拆成重叠四面体
三维情况的核心难题是:WinslowUntangler 的三维求解器只处理四面体 ,而网格里可能有 六面体、棱柱、金字塔。Gmsh 的解法是把六面体拆成 8/24/32 个(重叠的)四面体 ,把金字塔 拆成 4 个,只解四面体,再把结果反映回原单元。
拆解表定义在匿名命名空间(第 271--360 行):
cpp
const uint32_t pyr2tet_corners[4][4] = {
{0, 1, 2, 4}, {1, 2, 3, 4}, {2, 3, 0, 4}, {3, 0, 1, 4}}; // 金字塔 → 4 四面体
const uint32_t hex2tet_corners[8][4] = {
{0, 1, 2, 5}, {1, 2, 3, 6}, {2, 3, 0, 7}, {3, 0, 1, 4},
{6, 5, 4, 1}, {5, 4, 7, 0}, {4, 7, 6, 3}, {7, 6, 5, 2}}; // 六面体 → 8 四面体
const uint32_t hex2tet_24[24][4] = { ... 24 项,围绕 6 条体对角线 ... }; // → 24 四面体
tetsFromHex(第 289--307 行)据此产生 tets,HexDcp 枚举给出三种分解粒度:
cpp
enum HexDcp { TO_8TETS, TO_24TETS, TO_32TETS };
std::vector<std::array<uint32_t, 4>>
tetsFromHex(const array<uint32_t, 8> &hex, HexDcp dcp = TO_24TETS)
{
vector<array<uint32_t, 4>> tets;
if(dcp == TO_8TETS || dcp == TO_32TETS) // 8 个
for(size_t i = 0; i < 8; ++i)
tets.push_back({hex[hex2tet_corners[i][0]], ...});
if(dcp == TO_24TETS || dcp == TO_32TETS) // 再 24 个(更细)
for(size_t i = 0; i < 24; ++i)
tets.push_back({hex[hex2tet_24[i][0]], ...});
return tets;
}
在 buildVerticesAndTetrahedra(第 461--511 行)里,dcpHex = 32(8 + 24 = 32 个四面体, 最细粒度),并把surface/curve/point 上的节点锁定:
cpp
points.resize(vertices.size());
locked.resize(vertices.size(), false);
for(size_t v = 0; v < points.size(); ++v) {
points[v] = {vertices[v]->x(), vertices[v]->y(), vertices[v]->z()};
if(vertices[v]->onWhat()->dim() <= 2) { locked[v] = true; }
}
dim() <= 2 即曲面(2)、曲线(1)、点(0)上的节点------都锁定。只有体内部节点移动。
理想形状由 tet_ideal_shape(一个正四面体)与 unit_cube/unit_pyr 的对应分解给出 (第 309--351 行)。注意一个朝向陷阱(注释处处提醒):
cpp
// warning: tet orientation in untangler is inverted compared to gmsh
// orientation
tet = invert_tet(tet);
...
ishape = invert_shape(ishape);
invert_tet 交换第 3、4 个节点({t[0], t[1], t[3], t[2]}),把 Gmsh 的单元朝向转成 解缠器期望的朝向。理想形状必须用同一变换 (invert_shape),否则目标形状与单元错配, 解缠会"反向优化"。这是一个非常容易踩的坑,代码用注释与对称的函数名把它固定下来。
7.4.7 三维主流程与 SICN 前后对比
untangleGRegionMeshConstrained(第 532--575 行)是三维入口:
cpp
bool untangleGRegionMeshConstrained(GRegion *gr, int iterMax, double timeMax)
{
if(gr->getNumMeshElements() == 0) { ...; return false; }
double t0 = Cpu();
double sicnMinB, sicnAvgB;
computeSICNquality(gr, sicnMinB, sicnAvgB); // 解缠前质量
vector<MVertex *> vertices; vector<vec3> points; vector<bool> locked;
vector<std::array<uint32_t, 4>> tets;
std::vector<std::array<std::array<double, 3>, 4>> tetIdealShapes;
if(!buildVerticesAndTetrahedra(gr, vertices, points, locked, tets,
tetIdealShapes))
return false;
WinslowUntangler::Options options;
options.lambda = 1.025;
options.maxInnerIterations = 300;
options.maxOuterIterations = iterMax;
options.maxFailures = 10;
options.timeMax = timeMax;
options.numThreads = CTX::instance()->numThreadsFor(tets.size(), 5000);
WinslowUntangler::untangle3D(points, locked, tets, tetIdealShapes, options);
for(size_t v = 0; v < points.size(); ++v)
if(!locked[v]) {
vertices[v]->setXYZ(points[v][0], points[v][1], points[v][2]);
}
double sicnMinA, sicnAvgA;
computeSICNquality(gr, sicnMinA, sicnAvgA); // 解缠后质量
Msg::Info("- Region %i: Winslow untangling, SICN min: %.3f -> %.3f, avg: "
"%.3f -> %.3f (%li vertices, %.3f seconds)",
gr->tag(), sicnMinB, sicnMinA, sicnAvgB, sicnAvgA, vertices.size(),
Cpu() - t0);
return true;
}
对比 2D 版本(lambda = 1e-2),三维用 lambda = 1.025------不同的阻尼/正则化参数 (lambda 控制解缠目标里"理想形状"与"原形状"的权重)。三维更难,参数更保守。maxFailures = 10(远小于 2D 的 300):三维一旦连续失败就放弃,避免在体网格上耗掉大量时间。timeMax 由调用者传入(OptimizeMesh 里默认 100 秒,见 7.2.4)。
前后的 SICN min/avg 对比是解缠是否成功的直接证据,也是用户日志里最有信息量的一行。
7.4.8 Options 的含义
WinslowUntangler::Options 的字段在两种调用里各有取值,汇总其语义:
| 字段 | 含义 | 2D 面 | 3D 体 |
|---|---|---|---|
lambda |
目标形状权重/正则化 | 1e-2 | 1.025 |
maxInnerIterations |
内层(每 patch)迭代上限 | 200 | 300 |
maxOuterIterations |
外层迭代上限 | 3 | iterMax(默认 10) |
maxFailures |
连续失败容忍上限 | 300 | 10 |
timeMax |
CPU 时间预算(秒) | 1e3 | 由调用者给定 |
numThreads |
线程数 | 按单元数估计 | 按四面体数估计 |
numThreadsFor(n, 5000)(src/common/Context.h 的方法)按"每个线程 至少 5000 个单元"来分配线程------小任务用单线程,大任务才并行,避免并行开销吃掉收益。
7.4.9 为什么解缠对边界层与拉伸网格尤其重要
边界层与各向异性网格的特征是极扁的单元 (长宽比可达 103)。一个小法向扰动就能让薄层 单元翻转,而局部优化在极端各向异性下几乎无从下手。Winslow 解缠的优势:全局视角 (同时 求解所有自由节点)、目标形状显式 (对极端长宽比同样适用)、边界固定 (边界层节点在 曲面上被锁定,不破坏法向分层)。因此 UntangleMeshGeometry 先对平面、再对所有体做 Winslow, 作为"几何级"的最后一道保险。
7.5 细化与细分:从一阶到高阶拓扑,从四面体到六面体
7.5.1 RefineMesh 的三段式流程
RefineMesh(src/mesh/meshRefine.cpp 第 465--531 行; 声明在 src/mesh/meshRefine.h)不只是"把单元切小",它其实 是一套完整的"一阶 → 二阶 → 细分 → 一阶"流水线:
cpp
void RefineMesh(GModel *m, bool linear, bool splitIntoQuads,
bool splitIntoHexas)
{
Msg::StatusBar(true, "Refining mesh...");
// reset all elements to order 1 (if the mesh was already complete second
// order we could use it as-is; however SetOrderN() does not currently
// distinguish between complete and incomplete second order when testing if
// the mesh should be modified)
SetOrder1(m);
// Create 2nd order mesh (using "2nd order complete" elements) to
// generate vertex positions
SetOrderN(m, 2, linear, false);
// Optimize high order elements
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");
// store periodic node correspondences
FixPeriodicMesh(m);
// only used when splitting tets into hexes
faceContainer faceVertices;
// Subdivide the second order elements to create the refined linear mesh
for(auto it = m->firstEdge(); it != m->lastEdge(); ++it) Subdivide(*it);
std::map<MFace, MVertex *, MFaceLessThan> centers;
for(auto it = m->firstFace(); it != m->lastFace(); ++it)
Subdivide(*it, splitIntoQuads, splitIntoHexas, faceVertices, linear,
centers);
...
for(auto it = m->firstRegion(); it != m->lastRegion(); ++it)
Subdivide(*it, splitIntoHexas, faceVertices);
// Check all 3D elements for negative volume and reverse if needed
m->setAllVolumesPositive();
...
}
核心洞见 :Gmsh 的细分不是直接切一阶单元,而是:
- 先把网格降回一阶(
SetOrder1)------避免"已经是二阶但阶数不完整"的歧义; - 再生成完备二阶网格 (
SetOrderN(m, 2, linear, false),linear=false表示节点落在 精确几何上)------二阶单元的新增节点(边中点、面中心、体中心)就是细分后新单元的顶点; - 可选地做高阶优化(按
Mesh.HighOrderOptimize的 1/2/3/4 分派到三种优化器); FixPeriodicMesh保存周期对应关系;- 最后
Subdivide把二阶单元**"拆掉",只保留其节点构成的更细的一阶单元**。
这个"借二阶单元生成细分节点、再拆成一阶 "的技巧很聪明:它复用了 SetOrderN 那套 成熟的节点放置(精确几何投影、并行、周期处理),而不必为细分单独写一套几何投影逻辑。
7.5.2 Subdivide(GEdge):一条二阶线 → 两条一阶线
cpp
static void Subdivide(GEdge *ge)
{
std::vector<MLine *> lines2;
for(std::size_t i = 0; i < ge->lines.size(); i++) {
MLine *l = ge->lines[i];
if(l->getNumVertices() == 3) { // 3 节点二阶线
lines2.push_back(new MLine(l->getVertex(0), l->getVertex(2)));
lines2.push_back(new MLine(l->getVertex(2), l->getVertex(1)));
}
delete l;
}
ge->lines = lines2;
// 二阶网格打乱了曲线节点顺序 → 按参数 u 重新排序
std::sort(ge->mesh_vertices.begin(), ge->mesh_vertices.end(),
MVertexPtrLessThanParam());
...
}
二阶线有 3 个节点(两端 + 中点),拆成 (v0,v2) 与 (v2,v1) 两条一阶线。但曲线的 节点顺序被二阶网格打乱了(二阶节点是并行创建的),所以要用参数坐标重新排序:
cpp
struct MVertexPtrLessThanParam {
bool operator()(const MVertex *v1, const MVertex *v2) const {
double u1 = 0., u2 = 1.;
v1->getParameter(0, u1);
v2->getParameter(0, u2);
return u1 < u2;
}
};
按参数 u 排序------这是恢复"曲线节点按弧长排列"这一不变量的必要步骤(否则后续算法依赖 顺序时会出错)。
7.5.3 Subdivide(GFace):三角形/四边形的细分
面细分分两条路径(第 113--192 行):
cpp
static void Subdivide(GFace *gf, bool splitIntoQuads, bool splitIntoHexas,
faceContainer &faceVertices, bool linear,
std::map<MFace, MVertex *, MFaceLessThan> ¢ers)
{
if(!splitIntoQuads && !splitIntoHexas) {
// 三角形:6 节点(0/1/2 角点, 3/4/5 边中点) → 4 个一阶三角形
std::vector<MTriangle *> triangles2;
for(std::size_t i = 0; i < gf->triangles.size(); i++) {
MTriangle *t = gf->triangles[i];
if(t->getNumVertices() == 6) {
// 三个角部三角 (0-3-5 / 3-1-4 / 5-4-2) + 中心"倒置"三角 (3-4-5)
triangles2.push_back(new MTriangle(t->getVertex(3), t->getVertex(4),
t->getVertex(5)));
... /* 另三个 */
setBLData(t);
}
delete t;
}
gf->triangles = triangles2;
}
// 四边形:9 节点(0-3 角点, 4-7 边中点, 8 面中心) → 4 个共享面中心的一阶四边形
std::vector<MQuadrangle *> quadrangles2;
for(std::size_t i = 0; i < gf->quadrangles.size(); i++) {
MQuadrangle *q = gf->quadrangles[i];
if(q->getNumVertices() == 9) {
// (0,4,8,7) (4,1,5,8) (8,5,2,6) (7,8,6,3)
quadrangles2.push_back(new MQuadrangle(q->getVertex(0), q->getVertex(4),
q->getVertex(8), q->getVertex(7)));
... /* 另三个 */
setBLData(q);
}
delete q;
}
...
}
节点编号的语义(二阶完备单元):
- 6 节点三角形 :0/1/2 是角点,3/4/5 是三条边的中点;细分得到 4 个小三角形------中心 那个是"倒置"的(用三个边中点 3/4/5 组成);
- 9 节点四边形 :0/1/2/3 是角点,4/5/6/7 是边中点,8 是面中心;细分得到 4 个四边形, 中心节点 8 是它们共享的公共顶点。
若要求"三角 → 四边 "(splitIntoQuads,第 153--183 行):
cpp
if(splitIntoQuads || splitIntoHexas) {
for(std::size_t i = 0; i < gf->triangles.size(); i++) {
MTriangle *t = gf->triangles[i];
if(t->getNumVertices() == 6) {
MVertex *newv;
if(linear) {
SPoint3 ptx;
t->pnt(1. / 3., 1. / 3., 0., ptx); // is the barycenter
newv = new MVertex(ptx.x(), ptx.y(), ptx.z(), gf);
}
else {
SPoint3 ctr = t->barycenter();
const double pp[2] = {0.5, 0.5}; // should be improved...
GPoint gp = gf->closestPoint(ctr, pp); // orthogonal projection
newv = new MFaceVertex(gp.x(), gp.y(), gp.z(), gf, gp.u(), gp.v());
}
gf->mesh_vertices.push_back(newv);
centers[t->getFace(0)] = newv;
if(splitIntoHexas) faceVertices[t->getFace(0)].push_back(newv);
quadrangles2.push_back(new MQuadrangle(t->getVertex(0), t->getVertex(3),
newv, t->getVertex(5)));
quadrangles2.push_back(new MQuadrangle(t->getVertex(3), t->getVertex(1),
t->getVertex(4), newv));
quadrangles2.push_back(new MQuadrangle(
t->getVertex(5), newv, t->getVertex(4), t->getVertex(2)));
if(setBLData(t)) setBLData(newv);
}
delete t;
}
gf->triangles.clear();
}
要点:
- 每个三角形拆成 3 个四边形 ,围绕一个新增的面中心节点
newv; linear决定新节点位置:true用三角形重心 (直边插值),false用gf->closestPoint把重心正交投影到曲面------后者才是真正的曲边细分;centers映射记录"每个三角形面 → 其中心节点",用于后续周期对应对(第 508--521 行)。 这是处理周期面细分的关键:从面中心节点要能找回主面的对应节点。
7.5.4 Subdivide(GRegion):四面体的 8 分与 Bey/Freudenthal
体细分最漂亮的一段是四面体 10 节点 → 8 个小四面体(第 197--229 行):
cpp
if(!splitIntoHexas) {
// Split tets into other tets
std::vector<MTetrahedron *> tetrahedra2;
for(std::size_t i = 0; i < gr->tetrahedra.size(); i++) {
MTetrahedron *t = gr->tetrahedra[i];
// Use a template that maximizes the quality, which is a modification of
// Algorithm RedRefinement3D in: Bey, Jürgen. "Simplicial grid refinement:
// on Freudenthal's algorithm and the optimal number of congruence
// classes." Numerische Mathematik 85.1 (2000): 1-29. Contributed by Jose
// Paulo Moitinho de Almeida, April 2019.
if(t->getNumVertices() == 10) {
// 四个"角"四面体(各含一个原角点,如 0-4-6-7)
tetrahedra2.push_back(new MTetrahedron(t->getVertex(0), t->getVertex(4),
t->getVertex(6), t->getVertex(7)));
... /* 另三个角四面体:4-1-5-9 / 6-5-2-8 / 7-9-8-3 */
// 中间四个(用面/边中点,如 4-6-7-9)
tetrahedra2.push_back(new MTetrahedron(t->getVertex(4), t->getVertex(6),
t->getVertex(7), t->getVertex(9)));
... /* 4-9-5-6 / 6-7-9-8 / 6-8-9-5 */
setBLData(t);
}
delete t;
}
gr->tetrahedra = tetrahedra2;
}
四面体的 10 节点二阶编号约定是:
- 0--3:四个角点;
- 4--9:六条边的中点(4=01, 5=12, 6=02, 7=03, 8=13, 9=23 之类的约定);
- 细分后得到 8 个小四面体:四个"角"四面体(各占一个原角点)+ 中间四个。
注释引用了 Bey 的 Freudenthal 算法 ------它是一个经典的单纯形细分方案,保证细分后的小 四面体只有有限多个全等类(congruence classes) ,从而质量不会在反复细分中无限退化。 "用最大化质量的模板"这句话很重要:同样能分成 8 个四面体,但分法有多种(取决于 对角线的选择),Gmsh 选了使小单元质量最好的那种。这是一个被贡献者(Jose Paulo Moitinho de Almeida, 2019)单独优化的细节。
六面体 27 节点 → 8 个小六面体(第 231--267 行,此段略去逐个索引),以及四面体 → 六面体 (第 269--311 行):
cpp
// Split tets into other hexes.
if(splitIntoHexas) {
for(std::size_t i = 0; i < gr->tetrahedra.size(); i++) {
MTetrahedron *t = gr->tetrahedra[i];
if(t->getNumVertices() == 10) {
std::vector<MVertex *> newv;
for(int j = 0; j < t->getNumFaces(); j++) { // 4 个面中心
MFace face = t->getFace(j);
auto fIter = faceVertices.find(face);
if(fIter != faceVertices.end()) { newv.push_back(fIter->second[0]); }
else {
SPoint3 pc = face.barycenter();
newv.push_back(new MVertex(pc.x(), pc.y(), pc.z(), gr));
faceVertices[face].push_back(newv.back()); // 共享面复用
gr->mesh_vertices.push_back(newv.back());
}
}
newv.push_back(new MVertex(/* 体中心坐标 */)); // 体中心
hexahedra2.push_back(new MHexahedron(t->getVertex(0), t->getVertex(4),
newv[0], t->getVertex(6), t->getVertex(7), newv[1], newv[4], newv[2]));
... /* 共 4 个六面体 */
}
}
gr->tetrahedra.clear();
...
}
每个四面体拆成 4 个六面体 :为四个面各加一个面中心节点、为体加一个体中心节点,于是 每个角点周围形成一个六面体(原角点 + 两条边中点 + 面中心 + 体中心)。faceVertices 保证 共享面的相邻四面体用同一个面中心节点------这是**共形性(conformity)**的要求:交界面上 不能出现裂缝。
同样的思路用于棱柱 → 六面体 (第 313--357 行,每个 18 节点棱柱拆 6 个六面体)与 金字塔 → 88 个六面体(第 359--370 行):
cpp
// Yamakawa subdivision of a pyramid into 88 hexes (thanks to Tristan
// Carrier Baudouin!)
std::vector<MHexahedron *> dwarfs88;
for(std::size_t i = 0; i < gr->pyramids.size(); i++) {
MPyramid *p = gr->pyramids[i];
if(p->getNumVertices() == 14) {
subdivide_pyramid(p, gr, faceVertices, dwarfs88);
for(int j = 0; j < 88; j++) hexahedra2.push_back(dwarfs88[j]);
}
delete p;
}
gr->pyramids.clear();
金字塔的 88 分来自 Schneiders 的著名构造。subdivide_pyramid(第 748--838 行)里用了 一张 105 节点的坐标表 (schneiders_x/y/z)与一张 88×8 的连接表 (schneiders_connect):
cpp
static double schneiders_x(int i) { double coord[105] = { 0.500000, 0.666667, ... }; return coord[i]; }
...
void subdivide_pyramid(MElement *element, GRegion *gr,
faceContainer &faceVertices,
std::vector<MHexahedron *> &dwarfs88)
{
std::vector<MVertex *> v(105, (MVertex *)nullptr);
v[29] = element->getVertex(0); v[27] = element->getVertex(1); // 底四角
v[3] = element->getVertex(2); v[5] = element->getVertex(3);
v[102] = element->getVertex(4); // 顶点
... /* 面中心优先复用 faceVertices;其余用 element->pnt() 插值 */
for(int i = 0; i < 105; i++)
if(!v[i]) { element->pnt(schneiders_z(i), schneiders_x(i),
schneiders_y(i) / 1.414213, point);
v[i] = new MVertex(point.x(), point.y(), point.z(), gr); }
dwarfs88.resize(88);
for(int i = 0; i < 88; i++) // 查 schneiders_connect 得每个小六面体的 8 个节点
dwarfs88[i] = new MHexahedron(v[schneiders_connect(0, i)], ...,
v[schneiders_connect(7, i)]);
}
这套表驱动写法把一个几何上极复杂的构造(金字塔的 88 六面体分解)压缩成"查表 + 插值", 且面中心节点优先复用 (faceVertices.find)以保证共形。
7.5.5 BarycentricRefineMesh:最简细分
如果只要"每个单元按重心一分为 N ",BarycentricRefineMesh(第 533--594 行)更直接:
cpp
void BarycentricRefineMesh(GModel *m)
{
...
if(m->getNumRegions() == 0) { // 2D:三角形 → 3 个(绕重心)
for(auto it = m->firstFace(); it != m->lastFace(); ++it) {
GFace *gf = *it;
std::size_t numt = gf->triangles.size();
std::vector<MTriangle *> triangles2(3 * numt);
for(std::size_t i = 0; i < numt; i++) {
MTriangle *t = gf->triangles[i];
MVertex *v = new MVertex(t->barycenter(), gf);
triangles2[3*i] = new MTriangle(t->getVertex(0), t->getVertex(1), v);
triangles2[3*i + 1] = new MTriangle(t->getVertex(1), t->getVertex(2), v);
triangles2[3*i + 2] = new MTriangle(t->getVertex(2), t->getVertex(0), v);
delete t;
}
gf->triangles = triangles2;
}
}
else { // 3D:四面体 → 4 个(重心 + 每面)
... 对每个 MTetrahedron t,取重心 v,生成
(0,1,2,v) (1,2,3,v) (2,3,0,v) (3,0,1,v) 四个子四面体 ...
}
}
三角形 → 3 个 (重心),四面体 → 4 个 (重心 + 每个面)。它不做几何投影 (注释留了 FIXME: create an MFaceVertex (with correct parametric coordinates)?)------因此 是"纯拓扑细化",用于把网格变密以观察或做后处理,不是几何精确的加密。
7.5.6 与 algoSubdivide 的连接
RefineMesh / BarycentricRefineMesh 由 Mesh.SubdivisionAlgorithm(第 4 章 algoSubdivide) 触发。三种语义对应:
Mesh.SubdivisionAlgorithm |
行为 |
|---|---|
| 0 | 不细分 |
| 1 | RefineMesh(m, linear, true):三角 → 四边 |
| 2 | RefineMesh(m, linear, false, true):四面体/棱柱/金字塔 → 六面体 |
| 3 | BarycentricRefineMesh(m):重心细分 |
这是 Gmsh "从四面体网格得到六面体网格 "的一条简易途径------不是像 PACK/Blossom 那样的 纯六面体算法,而是"用二阶节点做模板、把每个单元拆成原胞的若干六面体"。它的优势是 能处理任意混合网格且保证共形;代价是得到的六面体质量取决于原四面体质量(因此细分布局 被专门优化过,见 7.5.4)。
7.6 边界层:沿法向逐层拉伸的全局操作
7.6.1 为什么边界层必须"全局"
回忆第 4 章:Mesh2D 里 if(!Mesh2DWithBoundaryLayers(m)) 的语义------边界层是一次全局 操作,必须在普通二维网格之前/之中统一处理。原因在于:
- 边界层要在源实体 (source,真正被挤出的曲线/面)上先做一次面网格,并算出光滑法向 场;这需要跨实体信息;
- 边界层实体的"复制(COPIED_ENTITY)"语义、
ExtrudeParams::normals是静态成员------ 全局共享两张法向场(索引 0/1); - 角点处的法向冲突需要用**扇形(fan)**过渡,需要邻域信息。
7.6.2 收集源实体与依赖
Mesh2DWithBoundaryLayers(src/mesh/BoundaryLayers.cpp 第 310 行)第一步是收集源(第 319--390 行):
cpp
// 2D boundary layers
for(auto it = m->firstEdge(); it != m->lastEdge(); it++) {
GEdge *ge = *it;
if(ge->getNativeType() == GEntity::GmshModel &&
ge->geomType() == GEntity::BoundaryLayerCurve) {
ExtrudeParams *ep = ge->meshAttributes.extrude;
if(ep && ep->mesh.ExtrudeMesh && ep->geo.Mode == COPIED_ENTITY) {
GEdge *from = m->getEdgeByTag(std::abs(ep->geo.Source)); // 源曲线
if(!from) { Msg::Error("Unknown source curve %d ...", ep->geo.Source);
return 0; }
// 记录"谁想从这个源挤出"(方向 / 层索引 / 视图)
sourceEdgeInfo[from->tag()].insert({ep->geo.Source < 0,
{ep->mesh.BoundaryLayerIndex, ep->mesh.ViewIndex}});
sourceEdges.insert(from);
edgeSkipScaleCalc[from->tag()] = !ep->mesh.ScaleLast; // ScaleLast 联动
if(ep->mesh.ScaleLast)
ExtrudeParams::calcLayerScaleFactor[ep->mesh.BoundaryLayerIndex] = true;
}
}
}
相同逻辑对三维边界层 (BoundaryLayerSurface,第 353--390 行;源是面)。要点:
- 边界层实体是"复制"来的 (
COPIED_ENTITY),类型BoundaryLayerCurve/BoundaryLayerSurface(第 1 章GeomType枚举里就有),源实体from才是要被挤出的 曲线/面; sourceEdgeInfo[from->tag()]收集"谁想从这个源挤出 "的全部信息(方向是否反转、BoundaryLayerIndex(0 或 1)、ViewIndex(用哪个后处理视图的法向));ExtrudeParams::calcLayerScaleFactor[i](i为层索引 0/1)与ScaleLast联动------见 7.6.5。
7.6.3 源面的网格化与定向
源实体必须先网格化,且法向必须正确朝外:
cpp
// make sure the source surfaces for the boundary layers are oriented
// correctly (normally we do this only after the 3D mesh is done; but here
// it's critical since we use the normals for the extrusion)
std::for_each(sourceFaces.begin(), sourceFaces.end(), orientMeshGFace());
注释点出了关键 :通常 Gmsh 只在三维网格完成后才统一定向(setAllVolumesPositive 之 类),但边界层手上有法向就立刻用 ,所以必须提前定向,否则会沿错误方向挤出(向内 挤到几何内部)。
7.6.4 光滑法向场:addExtrudeNormals
法向场由两张静态的 smooth_data承载(第 452--459 行):
cpp
for(int i = 0; i < 2; i++) {
if(ExtrudeParams::normals[i]) delete ExtrudeParams::normals[i];
ExtrudeParams::normals[i] = new smooth_data();
}
if(sourceFaces.empty())
addExtrudeNormals(sourceEdges, sourceEdgeInfo, edgeSkipScaleCalc);
else
addExtrudeNormals(sourceFaces, sourceFaceInfo, faceSkipScaleCalc);
addExtrudeNormals(第 40--124 行)的模板版本展示了"Gouraud 着色法向"的构造:
cpp
else { // 用 Gouraud 着色的单元法向构造节点法向
for(std::size_t i = 0; i < elements.size(); i++) {
MElement *ele = elements[i];
SVector3 n(0, 0, 0);
if(ele->getDim() == 2) n = ele->getFace(0).normal();
else n = crossprod(ele->getEdge(0).tangent(), SVector3(0., 0., 1.));
if(invert) n *= -1.;
for(std::size_t k = 0; k < ele->getNumVertices(); k++) {
MVertex *v = ele->getVertex(k);
SVector3 nk = n;
if(ele->getDim() == 2) { // 按顶点张角加权
double fact = ele->getAngleAtVertex(v) * 2 / M_PI;
if(fact > 0) nk *= fact;
else Msg::Warning("Ignoring invalid angle scaling %g", fact);
}
ExtrudeParams::normals[index]->add(v->x(), v->y(), v->z(), 3, nnk);
}
}
}
三个关键机制:
- 从单元法向到节点法向 :每个单元把自己的法向加到其每个顶点上;
- 角度加权 (仅二维):
fact = getAngleAtVertex(v) * 2/M_PI------顶点处的张角越大, 该顶点的法向贡献越大 。这是为了在角点处让相邻单元的法向按张角加权平均 ,得到更 光滑的节点法向。若fact <= 0(单元退化)则警告并跳过; normals[index]->add(...)是累加 ------最后统一normalize()(第 202--204 行),把 累加结果变成单位法向场。
还有两种"非几何"法向来源(第 50--80 行与第 218--244 行):
- 后处理视图的法向 (
octree->searchVector):从用户提供的向量视图取法向,可用于 指定合成方向; - 场(Field)缩放 :
extrudeField时,把normals[i]->vals[k] *= radius,radius由 背景尺寸场给出; - 特殊 3D 盒 (
view == -3):把法向吸附到 ±x/±y/±z 轴(第 205--217 行),用于 构造规整的"盒子"边界层。
7.6.5 ScaleLast 与"按 section 局部"的缩放
ScaleLast(是否让最后一层按源网格尺寸缩放)是边界层最微妙的一个开关。它按 section 局部生效,注释(第 337--347 行)讲得很清楚:
cpp
// Added to scale last layer size locally: Do not worry if one section
// of the boundary layer index = 0 or 1 is not supposed to be
// scaled...that section's normals will have scaleFactor = 1.0 (exactly
// 1.0 to all sig figs) ...however, if that non-scaled section borders a
// scaled section, the boundary normals will extrude scaled.
然后是 FixErasedExtrScaleFlags(第 264--308 行)的兜底修复------因为 ReplaceDuplicates() 可能擦掉 ScaleLast 标志,所以要扫一遍所有包围区域 重新判定,必要时把 ScaleLast = false 改回 true 并给出警告:
cpp
Msg::Warning(
"%d entities were changed from ScaleLast = false to ScaleLast = true",
num_changed);
这种"事后修复被擦除的标志"是一个典型的工程防御------注释里也坦诚了"maybe this could be done in the TreeUtils.... but I do not want to change the code too much and create a bug"。
7.6.6 扇形(fan):凸角处法向冲突的过渡
两个不同方向的壁面在角点相遇时,法向场会"打架"。Gmsh 用扇形 过渡。数据结构在 src/geo/boundaryLayersData.h:
cpp
const int USEFANS__ = 1;
struct BoundaryLayerFan {
MEdge _e1, _e2;
bool sense;
int type;
BoundaryLayerFan(MEdge e1, MEdge e2, bool s = true, int type = 0)
: _e1(e1), _e2(e2), sense(s), type(type) {}
};
BoundaryLayerColumns 持有 _fans(节点 → 扇形)、_data(节点 → 列)、_normals (边 → 法向)等容器。getColumn(v, e) 处理"一个顶点有多列(多方向)"的情形:若只有一列 直接返回;否则用扇形 _fans[v] 的 _e1 与查询边比较,决定取左列还是右列。
扇形类型 (fanType)定义在 src/geo/boundaryLayersData.cpp (第 465--497 行):{0: 无扇形, 1: cross 交叉, 2: mix 混合, 3: tail 尾接, -1: fan 扇形, 4: skip 跳过}。fanSize(背 _fanPointTags/_fanSizes 两张表,见 src/mesh/Field.h 第 238--248 行)决定扇形里放多少个单元 ; 缺省值来自 Mesh.BoundaryLayerFanElements(默认 5)。
扇形在 treat2Connections(boundaryLayersData.cpp 第 163 行)里生成:根据 fanType 算出 fanSize2,然后 _columns->addFan(_myVert, ee[0], ee[1], true, fanType),并对 i = -1 .. fanSize2 取参数 t = (i+1)/(fanSize2+1)------把角点的转向均分成若干方向、在 扇入/扇出之间线性插值法向,生成一圈过渡单元,把两个壁面的边界层"缝"在一起。
7.6.7 BoundaryLayerField 与各向异性度量
边界层的"参数"由 BoundaryLayerField(src/mesh/Field.h 第 195--270 行)描述。其公开字段:hWallN(首层高度)、ratio(增长率)、hFar(远场尺寸)、 thickness(总厚度)、betaLaw(增长律)、nb_divisions(层数)、tgtAnisoRatio (目标各向异性比);私有成员包含按顶点的首层高度覆盖 _hWallNNodes、扇形表 _fanPointTags/_fanSizes 等。isotropic() 返回 false------边界层场是各向异性 的, 通过 SMetric3 给出每个点的各向异性度量张量。
各字段含义:
| 字段 | 含义 | 脚本名 |
|---|---|---|
hWallN |
首层(贴壁)法向高度 | .hwall_n / .hwall |
ratio |
相邻层高度增长率 | .ratio |
hFar |
远场(外层)目标尺寸 | .hfar |
thickness |
边界层总厚度 | .thickness |
betaLaw |
增长律(几何/幂次等) | .Qua/beta |
nb_divisions |
层数(divisions) | .nb_divisions |
tgtAnisoRatio |
目标各向异性比 | 内部 |
注意 isotropic() 返回 false------边界层场是各向异性 的:它通过 SMetric3 给每个点一个 各向异性度量张量 ,其中法向的特征长度是 hWallN 量级、切向是面网格尺寸。这恰好对应 第 5 章"度量场 + 各向异性网格生成"的机制------边界层不是靠专门的"拉伸器",而是把各向异性 度量交给普通网格生成器 ,让它自然生成扁单元。hWall(iV) 允许按顶点 覆盖首层高度 (_hWallNNodes),实现壁面不同部位不同厚度的精细控制。
Field 的 operator()(SMetric3 &metr 版本)做的是:给定点与最近的距离场 cc,按 增长律算出该点在"边界层法向"方向上的目标尺寸,写入度量张量。点上的 _fanPointTags/ _fanSizes 就是 7.6.6 的扇形表。
7.6.8 二维修正与边界层节点的保护
边界层一旦生成,其节点就被"标记"为不可随意移动。这在多处体现:
- 二维 Laplace 光滑跳过 所有边界层节点(7.3.9 里
getAllBoundaryLayerVertices); - 三维优化里
smoothVertex只对纯四面体体启用,边界层附近多为棱柱/六面体,天然被排除; - 解缠时边界层节点在曲面上(
dim<=2),自动锁定(7.4.6)。
getAllBoundaryLayerVertices(meshGFaceOptimize.cpp 第 688--697 行)就是取所有列的节点:
cpp
void getAllBoundaryLayerVertices(GFace *gf, std::set<MVertex *> &vs)
{
vs.clear();
BoundaryLayerColumns *_columns = gf->getColumns();
if(!_columns) return;
for(auto it = _columns->_data.begin(); it != _columns->_data.end(); it++) {
BoundaryLayerData &data = it->second;
for(size_t i = 0; i < data._column.size(); i++) vs.insert(data._column[i]);
}
}
"列(column)" 是边界层的基本组织单位:一个源节点 → 沿法向的一串节点 (BoundaryLayerData::_column)。所有列合起来就是边界层的主体。这个结构让"哪一层在 第几个节点"一目了然,也让节点的保护变得精确。
7.6.9 边界层与 Mesh2DWithBoundaryLayers 的关系
回顾整体流程(BoundaryLayers.cpp,第 310--520 行附近):
- 收集源边/源面与依赖(
checkDepends); - 先网格化源面(及其依赖);
- 定向源面(
orientMeshGFace); - 构造光滑法向场(
addExtrudeNormals); - 对每个
BoundaryLayerCurve/BoundaryLayerSurface的挤出实体 (EXTRUDED_ENTITY) 用ep->Extrude(...)沿法向逐层生成节点; - 生成扇形过渡与列结构。
所有这些都发生在普通二维网格之前 (Mesh2D 里先试 Mesh2DWithBoundaryLayers),因此 后续的二维网格生成器只需面对一个"已经包含边界层节点"的模型。这就是"边界层是全局操作 " 的真正含义------它不是某个面的局部后处理,而是在网格生成早期就重塑了整个模型。
7.7 高阶曲边网格:SetOrderN 与三种优化
7.7.1 SetOrderN:把低阶网格提升到任意阶
SetOrderN(src/mesh/HighOrder.cpp 第 1836 行; 声明在 src/mesh/HighOrder.h)的注释完整交代了三个开关的语义 (第 1841--1856 行):
cpp
// replace all the elements in the mesh with second order elements
// by creating unique vertices on the edges/faces of the mesh:
//
// - if linear is set to true, new vertices are created by linear
// interpolation between existing ones. If not, new vertices are
// created on the exact geometry, provided that the geometrical
// edges/faces are discretized with 1D/2D elements. (I.e., if
// there are only 3D elements in the mesh then any new vertices on
// the boundary will always be created by linear interpolation,
// whether linear is set or not.)
//
// - if incomplete is set to true, we only create new vertices on
// edges (creating 8-node quads, 20-node hexas, etc., instead of
// 9-node quads, 27-node hexas, etc.)
//
// - if onlyVisible is true, then only the visible entities will be curved.
对应关系一览:
| 开关 | 取值 | 结果 |
|---|---|---|
linear |
true |
新节点在直边上线性插值:折线逼近 |
linear |
false |
新节点投影到精确几何 :真正曲边(球面被精确表示)------但前提是几何边/面已被 1D/2D 单元离散 |
incomplete |
true |
只在边上加节点:Serendipity 族(8 节点四边形、20 节点六面体) |
incomplete |
false |
完备族(9 节点四边形、27 节点六面体) |
onlyVisible |
true |
只对可见实体做曲边 |
有一个容易被忽略但极其重要的细节 :linear=false 时,只有当几何边/面已经被一维/ 二维单元离散,才能把新节点投到精确几何上 。注释特别强调:如果只有三维单元,边界上的新 节点永远是线性插值------因为没有一维/二维网格作为投影的"落脚点"。
7.7.2 并行与分片容器
高阶节点放置天然并行。注释(第 1877--1882 行):
cpp
// Curving the elements of a surface or of a volume is done in parallel, on
// General.NumThreads threads, or on Mesh.MaxNumThreads2D/3D if they are set -
// as for the meshers. The containers below are shared, hence sharded (with
// more shards than threads, so that two threads rarely want the same one at
// the same time). A single thread means a single shard, i.e. no locking and
// the exact serial behaviour, down to the node numbering.
代码里:
cpp
const int nthreadsMax = std::max(nthreads2D, nthreads3D);
const std::size_t nShards = (nthreadsMax > 1) ? 16 * nthreadsMax : 1;
...
edgeContainer edgeVertices(nShards);
faceContainer faceVertices(nShards);
分片数 = 16 × 线程数 (多线程时),单线程时 nShards = 1。设计意图是让两个线程 很少争抢同一分片 ,从而降低锁竞争。注释还特别强调单线程时"精确的串行行为,连节点 编号都不变"------这对复现性(同一脚本在不同机器/线程数下产出逐字节相同的网格)至关重要。
还有一个关键的临时开关(第 1870--1875 行):
cpp
// placing high-order nodes always provides an initial guess for the point
// projections, and checks the result (see computeEquidistantParameters() and
// getFaceVerticesOnGeo()), so we can use the fast local projection here even
// though it is off by default for the rest of the code
int oldFastProjection = CTX::instance()->geom.occFastProjection;
CTX::instance()->geom.occFastProjection = 1;
即临时开启 OCC 的快速局部投影 (第 2 章 OCCFace 的 parFromPoint/closestPoint 局部 模式)。理由是"高阶节点放置总有初值猜测 (相邻低阶节点的位置),且会检查结果 ", 所以可以安全用快算法;用完再恢复(第 1961 行 CTX::instance()->geom.occFastProjection = oldFastProjection;)。这是"把已知信息转化为性能优势"的典范。
7.7.3 分维处理与存储
SetOrderN 按"边 → 面 → 体"三级循环(第 1929--1959 行):
cpp
for(auto it = m->firstEdge(); it != m->lastEdge(); ++it) // 边先建边中点
if(getOrder(*it) != order) setHighOrder(*it, edgeVertices, linear, nPts);
else setHighOrderFromExistingMesh(*it, edgeVertices);
for(auto it = m->firstFace(); it != m->lastFace(); ++it) // 面再建面内节点
if(getOrder(*it) != order)
setHighOrder(*it, edgeVertices, faceVertices, linear, incomplete, nPts,
threadsFor(*it, nthreads2D));
else setHighOrderFromExistingMesh(*it, edgeVertices, faceVertices);
for(auto it = m->firstRegion(); it != m->lastRegion(); ++it) // 体最后建体节点
if(getOrder(*it) != order)
setHighOrder(*it, edgeVertices, faceVertices, incomplete, nPts,
threadsFor(*it, nthreads3D));
"边 → 面 → 体"的次序不可颠倒 :体的面节点、面的边节点都必须在更低的维度先建好 , 才能被上层复用(edgeVertices/faceVertices 两个分片容器就是这种"低维先建、高维复用" 的缓存)。这就是为什么 setHighOrder(GRegion*) 能直接查表拿到边中点与面中心------它们已在 前两轮建好。
cpp
int nPts = order - 1; // 每条边/面上的内部节点数
最后统一编号(第 1963--1968 行):
cpp
if(otherOrder ||
!storeNewVertices(m, maxVertexBefore, maxElementBefore)) {
m->pruneMeshVertexAssociations();
m->renumberMeshCanonically(maxVertexBefore, maxElementBefore);
}
注释解释(第 1901--1902 行):节点是按线程创建的次序 编号的,最后要规范重编号------ 排在原有节点之后。这保证了并行结果与串行结果在编号上也一致。
7.7.4 节点放置:等距 / GLL / 投影
曲线与面上的内部节点位置由 computeEquidistantParameters(第 215--268 行)等函数计算。 它以弧长等距为目标,用 Newton 迭代解一个残差方程:
cpp
static void myresid(int N, GEdge *ge, double *u, fullVector<double> &r, ...)
{
...
}
static bool computeEquidistantParameters(GEdge *ge, double u0, double uN, int N,
...)
对 P6(6 阶) 有专门的高斯-勒让德(GLL)版本 computeGLLParametersP6(第 269 行)------ 因为高阶单元在某些阶数下用 GLL 节点(谱元风格)比等距更稳定。面上节点则用 getFaceVerticesOnGeo(第 931 行)投影到几何面:
cpp
// closestPoint is absolutely necessary when the parameterization is
...
GPoint gp = gf->closestPoint(pc, GUESS);
// guess is not reliable across a seam, so check that the projection...
...
gp = gf->closestPoint(SPoint3(X, Y, Z), nullptr);
注释点出两个坑:参数化不可靠时必须用 closestPoint ;缝(seam)附近猜测不可靠 , 所以投影后要校验 ,必要时用无猜测(nullptr)重投一次。这些细节决定了高阶网格在 球面、圆柱面等有缝曲面上的正确性。
7.7.5 三种高阶优化之一:通用优化 HighOrderMeshOptimizer
生成曲边后必须消除翻转。HighOrderMeshOptimizer(contrib/HighOrderMeshOptimizer/) 是通用方案。其参数结构 OptHomParameters(HighOrderMeshOptimizer.h 第 33--80 行):
cpp
struct OptHomParameters {
double BARRIER_MIN_METRIC, BARRIER_MIN, BARRIER_MAX; // 雅可比下/上界(屏障目标)
double weight; // 节点位移能量权重
int nbLayers; // 坏单元周围参与优化的层数
int dim, itMax, optPassMax; // 维度 / 迭代上限 / 优化 pass 上限
double TMAX; // CPU 时间上限
int fixBndNodes; // 边界节点固定:0 否 / 1 全部 / 2 周期
int strategy; // 0 = 连通块(blob),1 = 逐个自适应
bool optPrimSurfMesh, optCAD; // 是否优化一阶面网格 / 是否拟合 CAD
double optCADWeight, optCADDistMax;
...
};
它的 HighOrderMeshOptimizer(GModel*, OptHomParameters&)(HighOutMeshOptimizer.cpp 第 387 行)构造一串优化 pass,每个 pass 由若干 **ObjContrib(目标贡献项)**组成:
cpp
ObjContribScaledNodeDispSq<ObjContribFuncSimple> nodeDistFunc(
p.weight, Patch::LS_MAXNODEDIST); // 限制节点位移不要太大
ObjContribScaledJac<ObjContribFuncBarrierMovMin> minJacBarFunc(1.);
minJacBarFunc.setTarget(p.BARRIER_MIN, 1.); // 惩罚雅可比低于下阈
ObjContribScaledJac<ObjContribFuncBarrierMovMax> minMaxJacBarFunc(1.);
minMaxJacBarFunc.setTarget(p.BARRIER_MAX, 1.); // 惩罚雅可比高于上阈
ObjContribCADDistSq<ObjContribFuncBarrierMovMax> CADDistFunc(
p.optCADWeight, p.optCADDistMax); // 惩罚偏离 CAD
...
// 组装成若干 pass:minJacPass(位移 + 下界屏障);可选 minMaxJacPass(上下界);
// 可选 maxCADDistPass(拉回 CAD 附近);pass 由 MeshOptimizer 依次求解
MeshOptimizer(entities, par);
Barrier(屏障)思想 贯穿其中:ObjContribFuncBarrierMovMin/MovMax 让"雅可比接近阈值" 的代价趋于无穷 ,从而把解逼在可行域内部 (不产生翻转)。BARRIER_MIN(默认 0.1) 与 BARRIER_MAX(默认 2.0)就是雅可比的上下界。nbLayers(默认 6)定义"坏单元周围 多少层参与优化",strategy 选择"连通块(blob)一起优化"还是"逐个自适应"。这些参数从 CTX 的 hoNLayers/hoThresholdMin/hoThresholdMax/hoIterMax/hoPassMax/ hoFixBndNodes/hoPrimSurfMesh/hoDistCAD 传入(见 7.2.7)。
7.7.6 三种高阶优化之二:弹性类比 HighOrderMeshElasticAnalogy
弹性类比是这三者里思想最独特 的:把曲边网格看成一个弹性体 ,求解让它"放松"到无 翻转的状态。用的是 Gmsh 自己的有限元求解器(第 10 章)。接口 HighOrderMeshElasticAnalogy.h 的核心是 highOrderTools:它保存 _straightSidedLocation (直边网格位置,作参照)与 _targetLocation(当前最佳曲边网格位置,作目标);私有方法 _smoothMetric(...) 在装配时计算局部度量,_applyIncrementalDisplacement(max_incr, ...) 做 增量位移,_moveToStraightSidedLocation(...) 把坏单元移回直边;公开的 applySmoothingTo(...) 与 ensureMinimumDistorsion(...) 是入口;自由函数 HighOrderMeshElasticAnalogy(GModel *m, bool onlyVisible) 是被 OptimizeMesh 调用的顶层接口。
要点:
- 两类位置 :
_straightSidedLocation(直边网格的位置,作为"参照")与_targetLocation(当前最佳曲边网格的位置,作为"目标"); - 用
elasticityTerm(弹性项)装配有限元系统 :dofManager<double> myAssembler是一个自由度管理器,_smoothMetric在装配时计算局部度量; _applyIncrementalDisplacement(增量位移) :一步步施加载荷/位移,max_incr限制 每步最大位移------渐进放松,与 7.3.8 的逐步松弛异曲同工;ensureMinimumDistorsion(保证最小畸变) :把畸变小于阈值的单元恢复到直边位置 (_moveToStraightSidedLocation)------即"坏单元回到直边",这是一种"退化保护"。
为什么需要弹性类比? 因为曲边网格的翻转往往发生在几何曲率大的地方 :曲边节点被 投影到几何上后,如果直接优化节点位置,容易在曲率与网格尺寸不匹配处产生翻转。弹性类比把 "节点必须被投影到几何"这一约束交给有限元系统里的位移边界条件处理,而把内部节点的自由度 交给弹性求解------几何约束与质量优化在一次求解中同时满足。
7.7.7 三种高阶优化之三:快速曲边 HighOrderMeshFastCurving
FastCurving 专门处理边界层外侧的曲边 。参数 FastCurvingParameters(HighOrderMeshFastCurving.h 第 32--53 行):
cpp
struct FastCurvingParameters {
enum OUTERBLCURVE { OUTER_NOCURVE, OUTER_CURVE, OUTER_CURVECONSERVATIVE };
int dim;
bool thickness;
bool onlyVisible;
bool optimizeGeometry;
OUTERBLCURVE curveOuterBL; // Curve also the outer surface of the boundary layer?
int maxNumLayers; // Maximum number of layers of elements to curve in BL
double maxRho; // Maximum ratio min/max edge/face size for elements to curve in BL
double maxAngle; // Maximum angle between layers of elements to curve in BL
double maxAngleInner; // Maximum angle between edges/faces within layers
...
};
关键参数从 CTX 传入(7.2.7 里可见):hoNLayers(maxNumLayers)、hoMaxRho (maxRho,默认 0.3)、hoMaxAngle(maxAngle,默认 10°)、hoMaxInnerAngle (maxAngleInner,默认 30°)。curveOuterBL 的三选一决定"是否也曲化边界层外表面": OUTER_NOCURVE/OUTER_CURVE/OUTER_CURVECONSERVATIVE。
FastCurving 的"快速"在于它不求解全局优化 ,而是直接按层、按几何 把边界层节点往上 摆放,只在满足 maxRho/maxAngle 约束的单元上做曲化------因此适合超大规模边界层网格。
7.7.8 三种优化的选择(Mesh.HighOrderOptimize)
Mesh.HighOrderOptimize(默认 0)在 RefineMesh 与 GenerateMesh 里驱动选择 (7.5.1):
| 值 | 行为 |
|---|---|
| 0 | 不优化 |
| 1 | HighOrder(通用优化) |
| 2 | HighOrderElastic + HighOrder |
| 3 | HighOrderElastic |
| 4 | HighOrderFastCurving |
即:2 是"先弹性类比放松、再通用优化精修"的组合拳,这也是最常用、最稳的选择。
7.7.9 生成后的体检
最后,SetOrderN 在 !linear(真曲边)且未关闭检查时,做一次翻转体检(第 1973--1978 行):
cpp
if(!linear && !CTX::instance()->mesh.hoSkipQualityCheck) {
std::vector<MElement *> bad;
double worst;
checkHighOrderTriangles("Surface mesh", m, bad, worst);
checkHighOrderTetrahedron("Volume mesh", m, bad, worst);
}
checkHighOrder*(HighOrder.h 声明)用贝塞尔形式的雅可比多项式 (7.1.11)在单元内部 检查正性,把 disto < 0(翻转)的单元收集进 bad,disto < 0.2 的记为"欠佳"。这是 "高阶网格生成后自动报告有多少翻转单元"的机制------用户据此决定是否加大优化力度。
7.8 METIS 分区:为并行与后处理切分网格
7.8.1 分区接口
网格分区(domain decomposition)是并行有限元与分布式后处理的前提。接口在 src/mesh/meshPartition.h / src/mesh/meshPartition.cpp:
cpp
int PartitionMesh(GModel *model, int numPart);
int UnpartitionMesh(GModel *model);
int PartitionUsingThisSplit(GModel *model,
std::vector<std::pair<MElement *, int>> &elmToPartition);
int ConvertOldPartitioningToNewOne(GModel *model);
int PartitionFaceMinEdgeLength(GFace *gf, int np, double tol = 1.02);
各函数语义:
PartitionMesh:用 METIS 把网格切成numPart块;UnpartitionMesh:逆向操作 ------把分区实体合并回原始几何(注释// Un-partition a mesh and return to the initial mesh geometry);PartitionUsingThisSplit:用户给定每个元素的归属(不调用 METIS),用于外部求解器 已经算好分区的场景;ConvertOldPartitioningToNewOne:MSH 格式演进导致分区表示方式变化,提供转换;PartitionFaceMinEdgeLength:把一个面 按其最短边长度切成np份------用于"几何级的 预分区"。
7.8.2 图结构:Graph 与 CSR
METIS 需要的是图 (顶点 + 邻接),而网格是单元 + 节点 。Gmsh 用一个 Graph 类把 网格转成 METIS 的 CSR(Compressed Sparse Row)格式。注释把 METIS 的两组数组讲清楚:
cpp
// The list of nodes belonging to the ith element of the mesh are stored in
// consecutive locations of eind starting at position eptr[i] up to (but not
// including) position eptr[i+1]. ...
std::vector<idx_t> _eind;
std::vector<idx_t> _eptr;
// The metis graph structure, in CSR form
std::vector<idx_t> _xadj, _adjncy;
// The weight associated to each elements of eptr
idx_t *_vwgt;
即:
_eind/_eptr:单元 → 节点(网格本身的关联);_xadj/_adjncy:单元 → 相邻单元(对偶图,METIS 要的图);_vwgt:每个单元的权重(分区的"大小"度量)。
createDualGraph(第 288--362 行)构造对偶图:先把单元-节点关联反演 成节点-单元关联 (nptr/nind 计数排序),再对每个单元,扫描其节点所属的所有单元,用 marker 统计共享 节点数:
cpp
for(std::size_t i = 0; i < _ne; i++) {
nbrs.clear();
for(idx_t j = _eptr[i]; j < _eptr[i + 1]; j++) // 单元 i 的每个节点
for(idx_t k = nptr[_eind[j]]; k < nptr[_eind[j] + 1]; k++) { // 该节点的所有单元
const idx_t n = nind[k];
if(n == (idx_t)i) continue;
if(marker[n] == 0) nbrs.push_back(n); // 候选邻居
if(marker[n] != 255) marker[n]++; // 共享节点计数(饱和 255)
}
const int *row = threshold[etype[i]];
for(idx_t n : nbrs) {
if(marker[n] >= row[etype[n]]) _adjncy.push_back(n); // 超过阈值才算相邻
marker[n] = 0;
}
_xadj[i + 1] = (idx_t)_adjncy.size();
}
"共享多少节点才算邻居" 由 threshold[a][b] = rep[a]->numCommonNodesInDualGraph(rep[b]) 决定:
cpp
// numCommonNodesInDualGraph() is a pure function of the pair of element
// types, so resolve it once per type pair here instead of doing virtual
// calls per candidate neighbor in the loop below
例如三角形共享一条边(2 个节点)才算相邻,四面体共享一个面(3 个节点)才算------这个阈值 只依赖两个单元的类型对 ,所以预先算成 threshold[TYPE_MAX+1][TYPE_MAX+1] 表,避免在内 层循环里做虚函数调用。marker 用 unsigned char 且饱和在 255(注释说明:255 已大于 所有阈值,饱和不影响比较)------这是省内存的小技巧(每个候选邻居 1 字节)。
7.8.3 权重与 METIS 调用
fillDefaultWeights(第 363--409 行)按 Mesh.Partition*Weight 给不同单元类型赋权:
cpp
if(CTX::instance()->mesh.partitionLinWeight == 1 &&
... ) return; // 全是 1 → 不用权重数组,省内存
_vwgt = new idx_t[_ne];
if(... partitionTetWeight == -1 ...) { // -1 → 按维度自动:高维单元权重 1
for(...) _vwgt[i] = (_element[i]->getDim() == _dim ? 1 : 0);
}
else { // 显式:按单元类型查 Mesh.Partition*Weight
switch(_element[i]->getType()) {
case TYPE_LIN: _vwgt[i] = ...partitionLinWeight; break;
case TYPE_TRI: _vwgt[i] = ...partitionTriWeight; break;
case TYPE_TET: _vwgt[i] = ...partitionTetWeight; break;
...
}
}
关键设计 :-1 表示"自动"------只给最高维 单元权重 1,低维(面/边,即边界层元素) 权重 0。这样分区按体单元平衡 ,而不会因为表面的密集三角形把某个分区"压重"。这解释了 一个常见现象:为什么分区数按体单元算才合理。
真正的 METIS 调用(第 716--746 行):
cpp
metisOptions[METIS_OPTION_NUMBERING] = 0; // C 风格 0 基编号(否则错位)
idx_t objval, ne = graph.ne(), numPart = graph.nparts(), ncon = 1;
std::vector<idx_t> epart(ne);
graph.fillDefaultWeights();
graph.createDualGraph(false);
if(metisOptions[METIS_OPTION_PTYPE] == METIS_PTYPE_KWAY)
metisError = METIS_PartGraphKway(&ne, &ncon, graph.xadj(), graph.adjncy(),
graph.vwgt(), nullptr, nullptr, &numPart, nullptr, nullptr,
metisOptions, &objval, &epart[0]); // K 路,割边最小
else
metisError = METIS_PartGraphRecursive(...); // 递归二分
switch(metisError) {
case METIS_OK: break;
case METIS_ERROR_INPUT: Msg::Error("METIS input error"); return 1;
case METIS_ERROR_MEMORY: Msg::Error("METIS memory error"); return 1;
default: Msg::Error("METIS error"); return 1;
}
两个函数对应两种分区策略:METIS_PartGraphKway(K 路,割边最小 )与 METIS_PartGraphRecursive(递归二分)。objval 是割边数(分区目标值)。METIS_OPTION_ NUMBERING = 0 表示 C 风格 0 基编号(否则 METIS 默认 Fortran 1 基,会全部错位)。之后:
cpp
// Check that all partitions have at least one element
...
if(!emptyPartitions.empty()) {
Msg::Warning("METIS produced %d empty partitions:%s", ...);
}
// Check and correct the topology
correctTopology(graph, epart);
graph.partition(epart);
if(verbose) Msg::Info("%d partitions, %d total edge-cuts", numPart, objval);
空分区告警 (某分区一个单元都没有)与 correctTopology(修正不连通的分区)是两个必要 的健壮性步骤------METIS 在极度不平衡时可能产生空分区。
7.8.4 PartitionMesh 主流程
cpp
int PartitionMesh(GModel *model, int numPart)
{
Graph graph(model);
if(makeGraph(model, graph, -1)) return 1;
graph.nparts(numPart);
if(partitionGraph(graph, true)) return 1;
for(std::size_t i = 0; i < graph.ne(); i++) // 每个单元打分区号(1 基)
if(graph.element(i))
graph.element(i)->setPartition(graph.nparts() > 1 ? graph.partition(i)+1 : 1);
model->setNumPartitions(graph.nparts());
createNewEntities(model); // 生成 Partition* 实体
... 打印每个类型的分区元素数 min/max/avg ...
if(CTX::instance()->mesh.partitionCreateTopology)
createPartitionTopology(model, graph.getBoundaryElements(), graph);
assignPhysicals(model); assignMeshVertices(model); fixEmbeddedVertices(model);
if(CTX::instance()->mesh.partitionCreateGhostCells) { // ghost cells
graph.clearDualGraph(); graph.createDualGraph(true); graph.assignGhostCells();
}
return 0;
}
流程要点:
- 给每个单元打分区号 (
setPartition(i+1),1 基),并统计每个类型的分布(min/max/avg) ------这是"分区是否平衡"的直接证据; createNewEntities产生第 1 章GeomType里的PartitionPoint/PartitionCurve/ PartitionSurface/PartitionVolume实体;- 可选
createPartitionTopology(用getBoundaryElements产生分区边界的拓扑); assignPhysicals/assignMeshVertices/fixEmbeddedVertices把物理组、节点、嵌入点 重新归属到新实体;- 可选 ghost cells (
assignGhostCells):分区交界处为邻居保存的单元副本 (GhostRegion/GhostFace/GhostEdge)------正是并行交换边界数据所需。
一个注释值得一提(hashmapface 附近的第 47--50 行):分区边界元素本来用 std::vector<int> 每面存列表,但列表大量重复 (三维里每个分区一份),于是改成 "索引到一张去重的分区列表表 "------省掉了每个面每个分区一次堆分配。这是又一处 "消除冗余分配"的优化。
7.8.5 与并行、后处理、重编号的关系
分区是并行有限元 的前置:每个进程拿一个分区(含 ghost 单元)并交换交界数据;分区号存在 MElement::setPartition 上(单元自带 ,无需全局表)。分区也可用于分布式后处理(每分区 独立输出 MSH)、由 Partition 命令/GUI 触发、并牵动重编号(assignMeshVertices/renumber)。
7.9 外部优化器端口:Mesquite 与 Netgen
7.9.1 Mesquite:外部网格质量库
MesquiteImprove2D/3D 把网格交给外部库 Mesquite (ENABLE_MESQUITE)。 src/mesh/meshMesquite.cpp 的实现展示了"外部库以 ArrayMesh 形式接入"的标准套路。三维版本(第 13--80 行):
cpp
int mesquiteImprove (GRegion *gr){
for (auto t : gr->tetrahedra) t->getVertex(...)->setIndex(-1); // 重置索引
for (auto t : gr->tetrahedra) for (int i=0;i<4;i++) { // 摊平为 (verts, tets, fixed)
...
fixed.push_back(t->getVertex(i)->onWhat()->dim() != 3); // 边界节点 → 固定
}
MESQUITE_NS::ArrayMesh mesh(3, verts.size()/3, &verts[0], &fixed[0],
tets.size()/4, MESQUITE_NS::TETRAHEDRON, &tets[0]);
MESQUITE_NS::ShapeImprover shape_wrapper;
shape_wrapper.run_instructions(&mesh, err); // 改进
... vs[i]->x() = mesh_vertex.x(); ... // 写回坐标
return 0;
}
要点:
- 导出 :把 Gmsh 的
MVertex/MTetrahedron拍平成verts(坐标数组)+tets(索引数组)+fixed(固定标记),构造 Mesquite 的ArrayMesh; - 固定策略 :
dim != 3(曲面/曲线/点上的节点)标记fixed = 1,只有体内部节点可动 ------与 7.3/7.4 的保护原则一致; - 改进 :
ShapeImprover::run_instructions是 Mesquite 的质量改进器; - 导入 :把改进后的坐标写回
MVertex。
二维版本(第 82 行起)类似,只是 verts 只存 x/y(注释掉 z),并额外指定 PlanarDomain(法向 (0,0,1),原点 (0,0,0))------因为 Mesquite 的二维优化假设一个平面域。 这套"导出 → 外部库 → 导入"的适配层,正是把第三方库接入 Gmsh 内部数据模型的干净做法。
7.9.2 Netgen:外部四面体优化
Mesh.OptimizeNetgen(默认 0)与 OptimizeMesh(m, "Netgen") 走 optimizeMeshGRegionNetgen opt; opt(*it, force);(7.2.7)。Netgen 的优化是另一套 边翻转 + 光滑 策略(第 4 章已提及 optimizeNetgen 选项)。它作为 optimizeMeshGRegion 的一个变体(optimizeMeshGRegionNetgen)实现,因此与自研优化共享 同一个调用接口 ------不同点只在"用哪套局部算子"。这正是 OptimizeMesh 方法表的价值: 把多种优化算法统一到同一入口之下,用户可以按需切换。
7.10 质量统计与自适应闭环
7.10.1 GetStatistics:50 个统计槽
GetStatistics(double stat[50], double quality[3][101], bool visibleOnly) (Generator.cpp 第 256--353 行)是 Gmsh 的"网格体检报告 "生成器。stat[50] 的槽位 有约定(部分):
| 槽位 | 含义 |
|---|---|
stat[0..3] |
顶点/边/面/体数 |
stat[4] |
节点总数 |
stat[5..13] |
点/线/三角/四边/四面体/六面体/棱柱/金字塔/三棱柱数 |
stat[14..16] |
一维/二维/三维网格化计时 |
stat[18..20] |
SICN 的 avg/min/max |
stat[21..23] |
gamma 的 avg/min/max |
stat[24..26] |
SIGE 的 avg/min/max |
stat[45]、stat[46..47] |
物理组数、多边形/多面体数 |
quality[3][101] 是 7.1.10 的三条直方图(SICN/gamma/SIGE),供 GUI 的统计窗口绘制。 统计优先取三维单元(有体单元时算体),否则退到二维面单元:
cpp
double N = stat[9] + stat[10] + stat[11] + stat[12] + stat[13];
if(N) { ... 收集所有体的四面体/六面体/棱柱/金字塔 ... }
else { N = stat[7] + stat[8]; ... 收集所有面的四边形/三角形 ... }
这正是"哪个维度重要就算哪个 "的一致原则(与 7.1.9 的 GetQualityFast 相同)。
7.10.2 AdaptMesh:自适应迭代
自适应(adaptivity)是"根据误差估计局部加密 "的闭环。入口 AdaptMesh(第 1258--1271 行):
cpp
void AdaptMesh(GModel *m)
{
if(CTX::instance()->abortOnError && Msg::GetErrorCount()) return;
Msg::StatusBar(true, "Adapting 3D mesh...");
double t1 = Cpu(), w1 = TimeOfDay();
for(int i = 0; i < 10; i++)
std::for_each(m->firstRegion(), m->lastRegion(), adaptMeshGRegion());
double t2 = Cpu(), w2 = TimeOfDay();
Msg::StatusBar(true, "Done adaptating 3D mesh (Wall %gs, CPU %gs)",
w2 - w1, t2 - t1);
}
对每个体做 adaptMeshGRegion(),重复 10 次 ------这个"重复 10 次"是自适应网格的常见 做法:一次改编未必收敛,多次迭代逼近目标分布。改编的目标尺寸 来自尺寸场/后处理视图 (第 4 章的 PostViewField 等),实现"求解 → 估计误差 → 局部加密 → 再求解"的闭环。
7.10.3 与尺寸场闭环的接口
闭环是:生成网格 (用 Field)→ 求解 (第 10 章)→ 估计误差 (写成 PView)→ 误差场转尺寸场 (第 4 章 PostViewField/Attractor,大误差处用小尺寸)→ 重新生成/改编 网格 (AdaptMesh 或重新 Mesh)→ 回到求解,直到达标。质量统计与 GetQualityFast 在每轮 后提供质量反馈 ------既判断"网格是否足够好",也作为 OptimizeMesh 的目标。这正是度量体系 的终极用途:诊断工具 + 优化目标 + 自适应判据三位一体。
7.11 常见问题(Q&A)
Q1:为什么优化有时会破坏几何分类? 分类指"节点归属于哪个几何实体"。Gmsh 三层防线: ① 边界节点不动(二维 RelocateVertices 只动 dim()==2 的节点、曲线/点固定;解缠锁定 dim()<=2);② 曲面节点移动后投影回几何(gf->point/closestPoint)并更新 (u,v); ③ 嵌入面/边不参与连通、拓扑不完整时跳过。因此"破坏分类"通常只在输入拓扑本身不完整 (如未 CreateTopology 的 STL)时出现,Gmsh 用跳过而非强改应对。
Q2:为什么需要 Winslow 解缠? 边翻转/光滑是局部搜索,假设网格拓扑合法;出现翻转单元时 质量景观极不平坦、局部搜索会卡死(尤其各向异性极强时)。Winslow 解缠是全局变分求解 , 同时推动所有自由节点且目标形状显式,对极端长宽比仍有效。
Q3:高阶网格为什么需要弹性类比? 曲边节点受双重约束(落在精确几何上 + 不翻转)。 弹性类比把网格当弹性体,用有限元求解器一次满足:几何投影写成位移边界条件,内部自由度由 弹性平衡决定(7.7.6)------把"质量优化"转成"力学求解"。
Q4:边界层为什么必须是全局操作? 因为它要在源实体 上网格化、算光滑法向场 (全局静态 ExtrudeParams::normals)、角点用扇形 过渡,并在早期重塑模型 (生成 BoundaryLayerCurve/Surface 与列结构),使二维网格面对的是"已含边界层"的模型。
Q5:UntangleTets 的 timeMax = 100 秒意味着什么? 是 CPU 时间预算------解缠无保证 多项式时间,给上限防止无限耗时;到达即返回当前结果并打印 SICN 前后对比。
Q6:为什么一阶优化器在混合网格里更保守? 移动节点可能翻转它"没看见"的单元。optimizeMesh 只在纯四面体体做节点光滑;混合网格的棱柱/六面体由边界层/重组专门生成,质量已受控, 再交给通用优化器反而危险。
7.12 本章小结
7.12.1 要点回顾
- 度量分层 :底层
qmXxx纯几何公式(gamma=2ρ/R、eta、NCJ及梯度、四面体gamma/eta/cond);中层MElement的有符号 虚函数(minSICN/minSIGE/disto); 上层并行统计与直方图;最上层高阶多项式雅可比 (贝塞尔形式查正性)。热点有直三角/直四面体 快速路径(sicnStraightTriangle/sicnStraightTetrahedron)。 - 优化 = 一个入口 + 一张方法表 :
OptimizeMesh用字符串名分派(Gmsh/Optimize2D/UntangleTris/UntangleTets/Mesquite*/Netgen/HighOrder三法/Laplace2D/Relocate2D/3D/QuadCavityRemeshing/QuadQuasiStructured/HXT),均以setAllVolumesPositive收尾。 - 局部算子是保守搜索 :边/面翻转、节点光滑、顶点塌缩(
EVALONLY/DOIT);重定位用 无副作用目标函数 + 黄金分割 + 渐进松弛;二维 Laplace 用vertexStar缓存 + "面积不增、 质量不降";嵌入面/边与拓扑不完整时跳过 。MTet4与 flat 两内核同算法,flat 是未来。 - Winslow 解缠是全局手段 :三维把六面体拆 8/24/32 个、金字塔拆 4 个重叠四面体,只解四面体 再反映回原单元;分片到近似平面、锁定边界与曲线节点、投影回曲面 ;前后对比 SICN;2D 用
lambda=1e-2、3D 用1.025。 - 细化借二阶节点做模板 :
RefineMesh→SetOrder1→SetOrderN(m,2,...)→Subdivide拆成更细一阶单元(三角→4 三角 / 3 四边,四面体→8 四面体或 4 六面体,棱柱→6 六面体, 金字塔→88 六面体);由Mesh.SubdivisionAlgorithm驱动;BarycentricRefineMesh是纯拓扑细化。 - 边界层是全局操作 :网格化源实体 + 定向 + 光滑法向场(Gouraud + 角度加权),沿法向逐层 挤出(
hWallN/ratio/hFar/thickness/betaLaw/nb_divisions),角点用扇形 , 各向异性交给BoundaryLayerField+SMetric3;ScaleLast按 section 局部生效。 - 高阶三开关 + 三优化 :
linear/incomplete/onlyVisible;并行分片(单线程逐字节一致)+ 临时开 OCC 快速投影;边→面→体次序不可颠倒;优化三法为通用(Barrier + ObjContrib)、 弹性类比 (elasticityTerm+dofManager)、快速曲边(maxRho/maxAngle)。 - METIS 分区 :
Graph转 CSR 对偶图,权重按体单元;产物Partition*/Ghost*;用于并行、 后处理与重编号。外部端口 :Mesquite(ArrayMesh+ShapeImprover)、Netgen。 - 统计与自适应 :
GetStatistics(stat[50]+ 三条直方图)、GetQualityFast(并行 min/avg)、AdaptMesh(adaptMeshGRegion()×10 次)------与尺寸场构成"求解→误差→加密"闭环。
7.12.2 关键词 / 索引
- 度量 :
gamma、eta、angles、cond、NCJ/NCJAndGradients、minSICNShapeMeasure、minSIGEShapeMeasure、distoShapeMeasure、signedInvCondNumRange、JacobianBasis、sicnStraightTriangle/sicnStraightTetrahedron、GetQualityFast、GetStatistics、histogramBin - 优化 :
OptimizeMesh、qmTetrahedron::Measures、optimizeMeshGRegion(Netgen)、MTet4、edgeSwap/faceSwap/smoothVertex/collapseVertex、SwapPattern、localMeshModAction、optimizeMeshFlat(Mesh.FlatOptimize3D)、Mesh.OptimizeThreshold、laplaceSmoothing、RelocateVertices、vertexStar、Maximize_Quality_Golden_Section、recombineIntoQuads、optimizeTopologyWithCavityRemeshing、_isModelOkForTopologicalOpti - 解缠 :
untangleGFaceMeshConstrained、untangleGRegionMeshConstrained、untangleGFaceMeanPlane、WinslowUntangler::untangle2D/untangle3D(Options)、hex2tet_*、pyr2tet_corners、invert_tet、computeSICNquality、UntangleMeshGeometry - 细分 / 高阶 :
RefineMesh、BarycentricRefineMesh、Subdivide、Mesh.SubdivisionAlgorithm、SetOrder1、SetOrderN、Bey/Freudenthal、Schneiders/Yamakawa、schneiders_connect、HighOrderMeshOptimizer/OptHomParameters、ObjContrib*、HighOrderMeshElasticAnalogy、HighOrderMeshFastCurving/FastCurvingParameters、Mesh.HighOrderOptimize(0~4) - 边界层 :
Mesh2DWithBoundaryLayers、addExtrudeNormals、ExtrudeParams::normals、ScaleLast、BoundaryLayerField(hWallN/ratio/hFar/thickness/betaLaw/nb_divisions)、BoundaryLayerColumns、BoundaryLayerFan/fanType、getAllBoundaryLayerVertices - 分区 / 端口 / 自适应 :
PartitionMesh、Graph/createDualGraph、METIS_PartGraphKway、correctTopology、assignGhostCells、partition*/ghost*、mesquiteImprove、ArrayMesh、ShapeImprover、AdaptMesh、adaptMeshGRegion、PostViewField
7.12.3 术语表
| 术语 | 含义 |
|---|---|
| gamma / eta | 三角形/四面体的 2ρ/R 质量;最小角(或最大直角偏差)质量 |
| NCJ / SICN / SIGE | 归一化雅可比条件数;有符号逆条件数(优化主判据);有符号逆梯度误差 |
| disto | 高阶单元的畸变率;<0 表示翻转 |
| Barrier | 屏障函数:阈值附近代价趋于无穷,把解逼在可行域内 |
| Edge swap / Cavity remeshing | 边翻转(2-3/3-2 等);腔体重剖分(四边形拓扑优化) |
| Winslow / Patch / Locked vertex | 映射为调和方程的解缠法;近似平面片;不许移动的顶点 |
| Serendipity | 不完备高阶单元(只含边中点),如 8 节点四边形 |
| Bey/Freudenthal、Schneiders/Yamakawa | 单纯形细分方案;金字塔的 88 六面体分解构造 |
| Boundary layer / Fan / ScaleLast | 沿法向逐层拉伸的薄单元层;角点扇形过渡;末层缩放(按 section 局部) |
| METIS / Ghost cell / Mesquite | 图分区库;分区交界处的单元副本;外部质量改进库 |
第 8 章转向数值内核:单元枚举、Gauss 积分、基函数体系、雅可比、鲁棒谓词与同调。