Gmsh 源码深度解析-第 8 章 数值内核与高阶有限元
本章目标:讲清 Gmsh 的
numeric模块与它周边的一圈"硬核数学"------单元类型枚举、插值空间 描述、Gauss 积分、节点与单项式生成、有限元基函数体系、雅可比与条件数、鲁棒几何谓词、数学 表达式求值,以及一个常被忽视但极其"理论"的能力:计算拓扑(同调/上同调/Betti 数)。第 1 章建立了
GModel/GEntity的数据模型,第 2 章讲了几何层如何描述曲面与曲线,第 5、6 章讲了网格生成算法。本章换一个视角:不关心"几何长什么样、网格怎么铺",而关心"在这些网格 单元上,怎么定义一组函数、怎么积分、怎么判断它有没有翻转、怎么读出它的拓扑"。这正是src/numeric/存在的理由,也是 Gmsh 从一个"网格生成器"进一步成为一个"有限元前处理器"的 技术底座。
8.1 数值内核的职责与边界
src/numeric/ 是横切所有模块的一层:几何层要用它做鲁棒求交与曲线离散,网格层要用它算雅可比 与形函数,后处理要用它插值场数据,求解器要用它做数值积分与基函数装配。它的边界由若干"纯计算、 基本无状态(或只有缓存)"的能力构成。第 1 章的目录树已经透露了它的广度,这里再按功能域 把最关键的文件归一次类,作为本章的"地图":
| 功能域 | 代表性文件 | 本章小节 |
|---|---|---|
| 单元类型枚举与拓扑查询 | ElementType.h/.cpp |
8.2 |
| 插值空间描述 | FuncSpaceData.h/.cpp |
8.3 |
| 数值积分 | GaussIntegration.h/.cpp、GaussQuadrature*.cpp、GaussLegendre1D.h、GaussJacobi1D.*、GaussLegendreSimplex.cpp |
8.4 |
| 节点与单项式 | pointsGenerators.h/.cpp、polynomialBasis.h/.cpp |
8.5 |
| 基函数体系与工厂 | BasisFactory.h/.cpp、nodalBasis.h、bezierBasis.*、JacobianBasis.*、CondNumBasis.*、HierarchicalBasis* |
8.6 |
| 鲁棒几何谓词 | robustPredicates.h/.cpp |
8.7 |
| 表达式求值 | mathEvaluator.h/.cpp + contrib/MathEx/mathex.* |
8.8 |
| 计算拓扑(同调) | src/geo/CellComplex.*、ChainComplex.*、SmithNormalForm.h、Chain.h、Homology.* |
8.9 |
| 通用线性代数/几何工具 | fullMatrix.h、fullVector.h、Numeric.h、LBFGS.h、curvature.h 等 |
8.10 提及 |
一个必须先建立的认知是:数值内核里的"类型"与"空间"是两回事 。ElementType 回答"这是一个 什么形状的单元、几阶、几个节点";FuncSpaceData 回答"我要在这个单元上使用哪一个函数空间"。 后者比前者更细------同一个二阶三角形单元(MSH_TRI_6)既可能被当作"完备二阶空间",也可能在 某些算法里要求"Serendipity 空间";而金字塔单元的形状合法,但它的函数空间还需要额外两个参数 ( nij,nk)才能确定。这一"类型 → 空间"的细分,正是理解 8.3 与 8.6 的钥匙。
8.2 元素类型体系
8.2.1 八个父类型
Gmsh 世界里所有单元都归到**八个父类型(parent type)**之一。它们在 src/common/GmshDefines.h 里以 TYPE_* 宏定义,在数值内核里 被广泛用作 switch 的分派键:
cpp
// 摘自 src/common/GmshDefines.h
#define TYPE_PNT 1
#define TYPE_LIN 2
#define TYPE_TRI 3
#define TYPE_QUA 4
#define TYPE_TET 5
#define TYPE_PYR 6
#define TYPE_PRI 7
#define TYPE_HEX 8
#define TYPE_POLYG 9
#define TYPE_POLYH 10
#define TYPE_XFEM 11
#define TYPE_MINI 12
#define TYPE_TRIH 13
#define TYPE_MAX_NUM 13 // keep this up-to-date when adding new type
注意 TYPE_PYR=6、TYPE_PRI=7 的编号顺序(金字塔排在棱柱之前),与 MSH 编号里 MSH_PYR_5=7、MSH_PRI_6=6 的顺序正好相反 。这种"父类型编号"与"MSH 编号"不一致是历史 原因,ElementType 的映射表正是为消除这种不一致而存在。
父类型是形状的等价类 :MSH_TRI_3(一阶三角形)、MSH_TRI_6(二阶)、......MSH_TRI_66 (十阶)都属于 TYPE_TRI。这些"同一形状、不同节点数"的具体单元,则用 MSH 编号 (MSH_*) 区分。src/common/GmshDefines.h 是 MSH 编号的唯一权威来源, 它同时也是 MSH 网格文件格式的"别名表" (第 1 章讲过,MSH 2.2 的 $Elements 段只写出编号, 文件名不承载任何形状信息,全靠这张表解码):
cpp
// 摘自 src/common/GmshDefines.h
#define MSH_LIN_2 1
#define MSH_TRI_3 2
#define MSH_QUA_4 3
#define MSH_TET_4 4
#define MSH_HEX_8 5
#define MSH_PRI_6 6
#define MSH_PYR_5 7
// ... MSH_TRI_10=9, MSH_TRI_15=10, MSH_PNT=15, MSH_QUA_9=16, MSH_HEX_27=12,
// MSH_PRI_18=13, MSH_PYR_14=14 ...(直到 MSH_TET_286 / MSH_PYR_385 等高阶)
#define MSH_POLYG_ 92 // 多边形
#define MSH_POLYH_ 93 // 多面体
#define MSH_TRIH_4 97 // trihedron
#define MSH_PNT_SUB -1 // XFEM 子单元
#define MSH_LIN_SUB -2
#define MSH_TRI_SUB -3
#define MSH_TET_SUB -4
#define MSH_TRI_MINI -5 // MINI 单元
#define MSH_TET_MINI -6
命名规律一眼可读:MSH_<形状>_<节点数>,LIN=线段、TRI=三角形、QUA=四边形、TET= 四面体、HEX=六面体、PRI=棱柱(prism)、PYR=金字塔、PNT=点、POLYG_/POLYH_=多边形/ 多面体、TRIH=trihedron(三棱面)。
统一编号是网格文件格式的基础设施 。第 6 章讲过,MSH 文件只写整数类型码;Gmsh 的各语言 API(gmsh::model::mesh::getElementType、getElementProperties)也直接暴露这些编号;前置的 getElementProperties 就是 ElementType 的薄封装(返回类型、维数、阶数、节点数、节点坐标)。
8.2.2 MSH 类型 → 父类型的映射(getParentType)
ElementType::getParentType(src/numeric/ElementType.cpp) 是一张巨型 switch,把每个 MSH 编号映射到父类型。它的开头:
cpp
int ElementType::getParentType(int mshtype)
{
switch(mshtype) {
case(MSH_PNT): return TYPE_PNT;
case(MSH_LIN_2): case(MSH_LIN_3): /* ... MSH_LIN_4 ... MSH_LIN_11 ... */
case(MSH_LIN_1): return TYPE_LIN;
case(MSH_TRI_3): case(MSH_TRI_6): case(MSH_TRI_9): case(MSH_TRI_15):
case(MSH_TRI_15I): /* ... MSH_TRI_21 ... MSH_TRI_66 ... */
case(MSH_TRI_1): return TYPE_TRI;
// ... QUA / TET / PYR / PRI / HEX 同理 ...
case(MSH_POLYG_): return TYPE_POLYG;
case(MSH_POLYH_): return TYPE_POLYH;
case(MSH_PNT_SUB): case(MSH_LIN_SUB): case(MSH_TRI_SUB):
case(MSH_TET_SUB): return TYPE_XFEM;
case(MSH_TRI_MINI): case(MSH_TET_MINI): return TYPE_MINI;
case(MSH_TRIH_4): return TYPE_TRIH;
default:
Msg::Error("Unknown element MSH type %i for parent type, returning -1.", mshtype);
return -1;
}
}
值得琢磨的细节:
- 零阶单元也在列 。
MSH_LIN_1、MSH_TRI_1、MSH_QUA_1、MSH_TET_1、MSH_HEX_1、MSH_PYR_1和MSH_PRI_1分别是各形状的"只有一个节点"退化单元。它们不是笔误,而是 为连续性/插值场景保留的占位类型------例如某些降阶表示需要一个"纯点"三角形。 I后缀 = Incomplete(Serendipity) 。枚举里出现了MSH_TRI_15I、MSH_TRI_21I、MSH_QUA_16I、MSH_QUA_36I------I表示"不完备族 ",即只保留角点与边点、去掉内部点的 Serendipity 单元。这就解释了为什么后面需要单独的getSerendipity查询:同一个阶数可能对应 完备族与不完备族两种节点数。_SUB家族 = XFEM 子单元 。扩展有限元(XFEM)需要把标准单元切分成子单元以便积分不连续 场,故有MSH_PNT_SUB/MSH_LIN_SUB/MSH_TRI_SUB/MSH_TET_SUB,它们统一归到TYPE_XFEM。MINI家族 。MSH_TRI_MINI/MSH_TET_MINI是 "MINI 单元"(气泡加权的低阶单元),归到TYPE_MINI。
8.2.3 阶数、维度、节点数:三张表
ElementType 提供三张"静态表"式的查询函数。getOrder (src/numeric/ElementType.cpp)把 MSH 编号映射到阶数 p:
cpp
int ElementType::getOrder(int mshtype)
{
switch(mshtype) {
case MSH_PNT: return 0; case MSH_LIN_1: return 0;
case MSH_LIN_2: return 1; /* ... MSH_LIN_11: return 10 ... */
case MSH_TRI_3: return 1; case MSH_TRI_6: return 2;
case MSH_TRI_9: return 3; // Serendipity:9 点=3 阶
case MSH_TRI_10: return 3; // 完备:10 点=3 阶
case MSH_TRI_15: return 4;
case MSH_TRI_15I: return 5; // Serendipity:15 点=5 阶!
case MSH_TET_4: return 1; case MSH_TET_10: return 2;
case MSH_TET_16: return 3; // Serendipity:16 点=3 阶
case MSH_TET_20: return 3; // 完备:20 点=3 阶
// ... MSH_TRI_66: return 10; MSH_TET_286: return 10; QUA/HEX/PRI/PYR 同理 ...
default:
Msg::Warning("Unknown element MSH type %d, assuming order 1.", mshtype);
return 1;
}
}
注意 MSH_TRI_15 是 4 阶完备而 MSH_TRI_15I 是 5 阶 Serendipity------只看节点数无法判断阶数, 必须借助"族别" 。这正是三重寻址键(父类型、阶数、族别)必要性的最直接证据。完备三角形的节点 数满足 N=(p+1)(p+2)/2( p=1,2,3,4,5,... 给出 3,6,10,15,21,...);Serendipity 三角形的 节点数为 3p( p=3,4,5,6,... 给出 9,12,15,18,...)------所以 MSH_TRI_15 与 MSH_TRI_15I 节点数同为 15 却完全不同阶。完备四面体是 N=(p+1)(p+2)(p+3)/6;完备四边形/六面体分别是 (p+1)2、 (p+1)3;Serendipity 四边形是 4p、六面体是 8+12(p−1)+6(p−1)2( p=2 时给 20,即 MSH_HEX_20)。
getDimension(同文件)用一张 switch 把 MSH 编号映射到几何维数(0/1/2/3):
cpp
int ElementType::getDimension(int mshtype)
{
switch(mshtype) {
case(MSH_PNT):
case(MSH_PNT_SUB): return 0;
case(MSH_LIN_2): /* ... */ case(MSH_LIN_SUB): return 1;
case(MSH_TRI_3): /* ... */ case(MSH_QUA_1):
case(MSH_POLYG_): return 2;
case(MSH_TET_4): /* ... */ case(MSH_PYR_1): case(MSH_PRI_78):
case(MSH_HEX_104): case(MSH_TRIH_4):
case(MSH_POLYH_): return 3;
default: Msg::Error("Unknown element MSH type %i for dimension, returning -1.", mshtype);
return -1;
}
}
getNumVertices(同文件)给出节点数 (注意函数名叫 getNumVertices,实际返回的是节点数, 含边点/面点/体点):
cpp
int ElementType::getNumVertices(int mshtype)
{
switch(mshtype) {
case MSH_PNT: return 1;
case MSH_LIN_2: return 2; case MSH_TRI_3: return 3;
case MSH_TRI_6: return 6; case MSH_TRI_10: return 10;
/* ... MSH_TRI_66: return 66 ... */ case MSH_TET_4: return 4;
case MSH_TET_10: return 10; case MSH_TET_20: return 20;
/* ... MSH_TET_286: return 286 ... */ case MSH_QUA_4: return 4;
case MSH_HEX_8: return 8; case MSH_PYR_5: return 5;
case MSH_PRI_550: return 550; case MSH_TRIH_4: return 4;
case MSH_POLYG_: case MSH_POLYH_: return 0; // varies from one element to the other
// ...
}
}
POLYG_/POLYH_ 的节点数返回 0,注释点明"从一个单元到另一个不同"------多边形/多面体没有 固定节点数,它们的节点数由实例本身携带。这就是为什么所有"按类型查节点数"的代码都要对 多边形族特判。
| 形状 | 完备族节点数 | Serendipity 族节点数 |
|---|---|---|
| 线段 | p+1 | 同左(线段无区别) |
| 三角形 | (p+1)(p+2)/2 | 3p (p≥2) |
| 四边形 | (p+1)2 | 4p (p≥2) |
| 四面体 | (p+1)(p+2)(p+3)/6 | 4+(2p−1)⋅6 |
| 六面体 | (p+1)3 | 8+12(p−1)+6(p−1)2 |
| 棱柱 | 3⋅2(p+1)(p+2) 组合 | 角点+边点 |
| 金字塔 | 见 8.3 的两族有理空间 | 角点+边点 |
8.2.4 族别:getSerendipity 的三值语义
getSerendipity(src/numeric/ElementType.cpp)的返回 是三值而非布尔,头文件里注释得很清楚:
cpp
// Gives > 0 if element MSH type is in Serendipity Family.
// Gives < 2 if element MSH type is in 'Normal' Family.
// 1 is for element MSH type that is either Serendipity or not !
int getSerendipity(int type);
即:
- 返回 1 :这个类型"两族皆可"或"无族别意义"。典型是低阶单元(
MSH_TRI_3、MSH_QUA_4、MSH_TET_4、MSH_TET_10、MSH_HEX_8、MSH_PYR_5、MSH_PRI_6)以及多边形/多面体。 - 返回 2 :只可能是 Serendipity 的类型,例如
MSH_TRI_9、MSH_QUA_8、MSH_TET_16、MSH_HEX_20、MSH_PYR_13。 - 返回 0 :只可能是 完备(Normal)的类型,例如
MSH_TRI_10、MSH_QUA_9、MSH_TET_20、MSH_HEX_27、MSH_PYR_14、MSH_TRIH_4。
源码里的分派:
cpp
int ElementType::getSerendipity(int mshtype)
{
switch(mshtype) {
case MSH_PNT: case MSH_LIN_1: /* ... */ case MSH_TRI_6:
case MSH_QUA_4: case MSH_TET_10: case MSH_PRI_6:
case MSH_HEX_8: case MSH_PYR_5:
case MSH_POLYG_: case MSH_POLYH_: return 1; // Serendipity or not
case MSH_TRI_10: /* ... */ case MSH_QUA_9: /* ... */ case MSH_TET_20:
case MSH_PRI_18: /* ... */ case MSH_HEX_27: /* ... */ case MSH_PYR_14:
case MSH_TRIH_4: return 0; // Not Serendipity
case MSH_TRI_9: case MSH_TRI_12: case MSH_TRI_15I: case MSH_QUA_8: case MSH_QUA_12:
case MSH_QUA_16I: case MSH_TET_22: case MSH_TET_16: case MSH_PRI_15:
case MSH_PRI_78: case MSH_HEX_20: case MSH_HEX_104:
case MSH_PYR_13: /* ... */ case MSH_PYR_69: return 2; // Only Serendipity
default:
Msg::Warning("Unknown element MSH type %d, assuming not serendipity.", mshtype);
return 0;
}
}
这个三值语义在 MElement(src/geo/MElement.h)里被两个虚函数 消费:
cpp
// return true if the element can be considered as a serendipity element
virtual bool getIsAssimilatedSerendipity() const
{
return ElementType::getSerendipity(getTypeForMSH()) > 0;
}
// return true if the element has to be considered as a serendipity element
virtual bool getIsOnlySerendipity() const
{
return ElementType::getSerendipity(getTypeForMSH()) > 1;
}
Assimilated("可以当作 Serendipity")对应 >0,Only("必须当作 Serendipity")对应 >1。FuncSpaceData 从 MElement 构造时用的正是 getIsOnlySerendipity(见 8.3)。
8.2.5 反查:getType 与 getPrimaryType
getType(parentType, order, serendip) 做反查 :从"父类型 + 阶数 + 族别"回到 MSH 编号。以 三角形为例(src/numeric/ElementType.cpp):
cpp
case TYPE_TRI:
switch(order) {
case 0: return MSH_TRI_1;
case 1: return MSH_TRI_3;
case 2: return MSH_TRI_6;
case 3: return serendip ? MSH_TRI_9 : MSH_TRI_10;
case 4: return serendip ? MSH_TRI_12 : MSH_TRI_15;
case 5: return serendip ? MSH_TRI_15I: MSH_TRI_21;
case 6: return serendip ? MSH_TRI_18 : MSH_TRI_28;
case 7: return serendip ? MSH_TRI_21I: MSH_TRI_36;
case 8: return serendip ? MSH_TRI_24 : MSH_TRI_45;
case 9: return serendip ? MSH_TRI_27 : MSH_TRI_55;
case 10:return serendip ? MSH_TRI_30 : MSH_TRI_66;
default: Msg::Error("Triangle order %i unknown", order); return 0;
}
这是节点头号序表的核心 :节点头号在 Gmsh 里由"父类型 + 阶数 + Serendipity"唯一确定, 所以 getType 也因此唯一。getPrimaryType 则简单地取一阶:
cpp
int ElementType::getPrimaryType(int mshtype)
{
return getType(getParentType(mshtype), 1);
}
它返回"该形状的一阶版本"(如 MSH_TET_20 → MSH_TET_4),在很多"降阶到线性做鲁棒判断"的 算法里被反复调用。
nameOfParentType 给父类型一个人类可读的名字,复数形式供日志使用:
cpp
std::string ElementType::nameOfParentType(int parentType, bool plural)
{
switch(parentType) {
case TYPE_PNT: return plural ? "points" : "point";
case TYPE_LIN: return plural ? "lines" : "line";
case TYPE_TRI: return plural ? "triangles" : "triangle";
case TYPE_QUA: return plural ? "quadrangles" : "quadrangle";
case TYPE_TET: return plural ? "tetrahedra" : "tetrahedron";
case TYPE_PYR: return plural ? "pyramids" : "pyramid";
case TYPE_PRI: return plural ? "prisms" : "prism";
case TYPE_HEX: return plural ? "hexahedra" : "hexahedron";
case TYPE_POLYG: return plural ? "polygons" : "polygon";
case TYPE_POLYH: return plural ? "polyhedra" : "polyhedron";
case TYPE_XFEM: return "xfem";
case TYPE_MINI: return "mini";
case TYPE_TRIH: return plural ? "trihedra" : "trihedron";
default: return "undefined";
}
}
"父类型 + 阶数 + 族别"三元组是数值内核的核心寻址键,它决定了用哪组积分点、哪个基函数、 哪个雅可比多项式空间(见 8.3、8.6)。
8.2.6 节点排列约定:边表与面表
网格单元的"节点排列"(node ordering)是网格文件格式与单元库之间的契约。Gmsh 把它编码为每个 单元类的静态数组 ,最典型的是 MTriangle(src/geo/MTriangle.h):
cpp
/*
* MTriangle
*
* v
* ^
* |
* 2
* |`\
* | `\
* | `\
* | `\
* | `\
* 0----------1 --> u
*
*/
class MTriangle : public MElement {
// ...
static int edges_tri(const int edge, const int vert)
{
static const int e[3][2] = {{0, 1}, {1, 2}, {2, 0}};
return e[edge][vert];
}
// ...
};
edges_tri(e, v) 就是"三角形的第 e 条边的第 v 个端点在局部顶点编号"。MTriangle6 (二阶三角形)的图注释给出高阶节点的排列:
cpp
/*
* MTriangle6
*
* 2
* |`\
* | `\
* 5 `4
* | `\
* | `\
* 0-----3----1
*
*/
即角点 0,1,2 在前,边点 3,4,5 分属边 (0,1)、(1,2)、(2,0),且每条边的两个端点按 edges_tri 的顺序 。这就是"角点优先,其次边点,其次面点,其次体点 "的总原则。四边形、 四面体、六面体、棱柱、金字塔各自有类似的 edges_*/faces_* 表(如 MQuadrangle::edges_quad、 MHexahedron::faces_hexa),它们是 pointsGenerators(8.5)生成节点坐标、以及 nodalBasis 构造 closures(8.6)的依据。
以 MTrihedron(src/geo/MTrihedron.h)为例,它把 5 条边、3 个面写成两张表:
cpp
static int edges_trihedron(const int edge, const int vert)
{
static const int e[5][2] = {
{0, 1}, {1, 2}, {2, 3}, {3, 0}, {1, 3},
};
return e[edge][vert];
}
static int faces_trihedron(const int face, const int vert)
{
static const int f[3][4] = {
{0, 1, 2, 3},
{0, 3, 1, -1},
{1, 3, 2, -1},
};
return f[face][vert];
}
MTrihedron 是一个平面单元,由一个四边形 + 两个三角形 组成,用作非协调单元之间的界面 (interface)。它的类注释写得很直白:
cpp
/*
* MTrihedron
* A MTrihedron is a plane element composed of
* a quadrangle and two triangles.
* It serves as an interface between two non-conforming
* elements
*/
面表里 -1 是"此面只有 3 个角点"的占位。这类"混合拓扑单元"(有的面是三角形、有的是四边形) 正是棱柱、金字塔这类单元共同的特征,也是基函数必须逐面处理 closures 的原因。
8.2.7 parents/children:层次网格与悬挂节点
MElement 为一类特殊场景预留了"父子"接口(src/geo/MElement.h):
cpp
// get and set parent and children for hierarchial grids
virtual MElement *getParent() const { return nullptr; }
virtual void setParent(MElement *p, bool owner = false) {}
virtual void updateParent(GModel *gm) {} // used only for reading msh files
virtual int getNumChildren() const { return 0; }
virtual MElement *getChild(int i) const { return nullptr; }
virtual bool ownsParent() const { return false; }
这套接口服务于层次(hierarchical)网格 :一个粗单元(parent)在局部细化后产生若干细单元 (children)。默认实现全部返回空/0,说明只有需要它的单元类才重写 (典型是剖分产生的子单元 类 MSubElement,见 src/geo/MSubElement.h)。ownsParent 回答 "我是否拥有 parent 的生命周期",updateParent 则专门用于从 MSH 文件读回时重建父子指针 (写文件时父子关系不落盘,读回时按几何/序号重建)。
与父子接口相邻的还有"子单元基元"接口:
cpp
// get base element in case of MSubElement
virtual const MElement *getBaseElement() const { return this; }
virtual MElement *getBaseElement() { return this; }
MSubElement(XFEM 子单元)通过 getBaseElement 找回它所属的父基元。父子关系不是拓扑邻接, 而是几何细化关系 ------这一点常被混淆:getParent 不是"共享一个面的邻居",而是"我细化自谁"。
8.2.8 特殊单元家族一览
综合以上,ElementType 覆盖的"形状集合"远不止八个父类型,还有几个特殊家族:
| 家族 | 类型 | 用途 | 备注 |
|---|---|---|---|
| 单纯形/张量积 | TYPE_PNT/LIN/TRI/QUA/TET/PYR/PRI/HEX |
标准有限元 | 8.3--8.6 的主角 |
| 多边形/多面体 | TYPE_POLYG/POLYH |
任意面数的网格(Voronoi、聚合) | 节点数运行时决定 |
| XFEM 子单元 | TYPE_XFEM |
不连续场积分 | MSH_*_SUB |
| MINI 单元 | TYPE_MINI |
气泡增广低阶 | MSH_*_MINI |
| Trihedron | TYPE_TRIH |
非协调界面 | MSH_TRIH_4 |
这些"非标准"家族提示我们:数值内核不能假设单元一定是标准单纯形或张量积 。正因如此, 8.3 的 FuncSpaceData 才要用"父类型 switch + 金字塔特判"来组织,而不是简单地用维数。
8.3 插值空间描述:FuncSpaceData
8.3.1 为什么"类型"不够用
8.2 的 ElementType 已经回答了"形状 / 阶数 / 节点数",为什么还需要 src/numeric/FuncSpaceData.h?因为它要回答一个更细的问题: "我想在这套节点上张成哪一个多项式空间" 。同一个二阶四面体(MSH_TET_10),既可能被 当作完备二次空间 P2,也可能被当成"只保留边点的 Serendipity 空间";而对金字塔单元来说, 形状合法并不蕴含空间合法------金字塔上的 order 都无法唯一确定空间 ,还需要两个额外整数 nij,nk。把"空间"从"类型"里独立出来,是让积分、基函数、雅可比、贝塞尔转换全部能共享 一个寻址键的前提。
完整类定义(src/numeric/FuncSpaceData.h):
cpp
class FuncSpaceData {
// Store data that allows to easily know how to construct gradient, jacobian,
// bezier and metric bases.
private:
int _parentType, _spaceOrder;
bool _serendipity;
// For pyramids, '_spaceOrder' is not used. The following is for pyramids only:
int _nij, _nk;
bool _pyramidalSpace;
// if '_pyramidalSpace', the space is a pyramid-like space
// {X^i Y^j Z^k | i,j <= k+'_nij', k <= '_nk'},
// otherwise a hex-like space {X^i Y^j Z^k | i,j <= '_nij', k <= '_nk'},
// where X = xi/(1-zeta), Y = eta/(1-zeta) and Z = (1-zeta).
public:
FuncSpaceData()
: _parentType(-1), _spaceOrder(-1), _serendipity(false), _nij(-1), _nk(-1),
_pyramidalSpace(false) {}
// 从另一个空间改阶 / 改金字塔维度
FuncSpaceData(const FuncSpaceData &fsd, int order);
FuncSpaceData(const FuncSpaceData &fsd, int nij, int nk);
// 从 MElement*
FuncSpaceData(const MElement *el);
FuncSpaceData(const MElement *el, int order, bool serendip);
FuncSpaceData(const MElement *el, bool pyr, int nij, int nk, bool serendip);
// 从 element type 或 (parentType, order, serendip)
FuncSpaceData(int tag);
FuncSpaceData(int type, int order, bool serendip);
FuncSpaceData(int type, bool pyr, int nij, int nk, bool serendip);
// ... getters / print / comparison ...
};
关键洞见写在注释的前两行 :这个类存在的目的,是让 gradient / jacobian / bezier / metric 四类基函数"知道怎么构造自己 "。换言之,FuncSpaceData 是 BasisFactory(8.6)的输入键。 它的名字其实说明了一切------Function Space Data,不是 element data。
8.3.2 金字塔:两族有理单项式空间
金字塔(TYPE_PYR)是数值上最麻烦的单元。它的参考域是四边形底 + 一个顶点 ,既不是单纯形 (不能用重心坐标 Pp),也不是张量积(不能写成三个一维区间的直积)。Gmsh 的解法是把金字塔 上的单项式定义为有理单项式 :设参考坐标 (ξ,η,ζ),令
X=1−ζξ,Y=1−ζη,Z=1−ζ.
当 ζ→1(接近顶点)时,分母 1−ζ→0, X,Y 的取值由分子分母的相对阶数 决定, 因此空间必须规定" X,Y 的幂次上界如何随 Z 的幂次变化"。注释给出两族:
- 金字塔型(
_pyramidalSpace == true) : {XiYjZk∣i,j≤k+nij, k≤nk} - 六面体型(
_pyramidalSpace == false) : {XiYjZk∣i,j≤nij, k≤nk}
两者的区别只在 i,j 的上界是否随 k 变化 。金字塔型里上界是 k+nij,越靠近顶点( k 越大)允许的横向幂次越高------这恰恰抵消了 X,Y 分母趋零带来的奇性,使得空间在顶点附近仍然 "表现良好"。六面体型则把上界固定为常数 nij,是直接用 X,Y 描述一个"退化六面体"的做法。
这个区别正是 FuncSpaceData 之所以把 _spaceOrder 单独标注"For pyramids, '_spaceOrder' is not used "的原因:对金字塔而言,空间的"大小"由 (nij,nk) 决定,_spaceOrder 只是一个 派生量。派生规则在构造函数里明确(src/numeric/FuncSpaceData.cpp):
cpp
FuncSpaceData::FuncSpaceData(int type, bool pyr, int nij, int nk, bool serendip)
: _parentType(type), _spaceOrder(pyr ? nij + nk : std::max(nij, nk)),
_serendipity(serendip), _nij(nij), _nk(nk), _pyramidalSpace(pyr)
{
if(_parentType != TYPE_PYR)
Msg::Error("Creation of pyramidal space data for a non-pyramid element!");
}
即:金字塔型空间阶数 =nij+nk,六面体型空间阶数 =max(nij,nk) 。这个 std::max / 求和的分叉,是本章里少见的"同一构造器两种数学含义",务必记牢。
8.3.3 构造函数族:从元素、从类型、从空间
FuncSpaceData 有条"构造阶梯",从最具体(一个 MElement*)到最抽象(父类型 + 阶数):
cpp
FuncSpaceData::FuncSpaceData(const MElement *el)
: _parentType(el->getType()), _spaceOrder(el->getPolynomialOrder()),
_serendipity(el->getIsOnlySerendipity()), _nij(0), _nk(_spaceOrder),
_pyramidalSpace(el->getType() == TYPE_PYR)
{
}
注意 _serendipity 用的是 el->getIsOnlySerendipity()(即 getSerendipity>1,见 8.2.4), 所以"也许可 Serendipity"的低阶单元在这里一律按非 Serendipity 处理------这符合有限元惯例 (低阶时两族重合)。从 MSH 编号构造则更直接:
cpp
FuncSpaceData::FuncSpaceData(int tag)
: _parentType(ElementType::getParentType(tag)),
_spaceOrder(ElementType::getOrder(tag)),
_serendipity(ElementType::getSerendipity(tag) > 1), _nij(0),
_nk(_spaceOrder),
_pyramidalSpace(ElementType::getParentType(tag) == TYPE_PYR)
{
}
这是 8.2 与 8.3 的接缝 :ElementType 的三张表(父类型、阶数、族别)在这里被一次性吸收 进空间描述。非金字塔单元的 _nij 一律置 0、_nk = _spaceOrder,只有金字塔才真正使用 (nij,nk)。
从另一个空间"改阶"或"改金字塔维度"也有专门构造器:
cpp
FuncSpaceData::FuncSpaceData(const FuncSpaceData &fsd, int order)
: _parentType(fsd._parentType), _spaceOrder(order),
_serendipity(fsd._serendipity), _nij(0), _nk(_spaceOrder),
_pyramidalSpace(fsd._pyramidalSpace) {}
FuncSpaceData::FuncSpaceData(const FuncSpaceData &fsd, int nij, int nk)
: _parentType(fsd._parentType),
_spaceOrder(fsd._pyramidalSpace ? nij + nk : std::max(nij, nk)),
_serendipity(fsd._serendipity), _nij(nij), _nk(nk),
_pyramidalSpace(fsd._pyramidalSpace) {}
这支撑了 8.6 里 BasisFactory 需要"在同一个父类型上换不同阶/换金字塔维度"来取基函数的 场景。
8.3.4 getOrderForBezier:贝塞尔域上的阶数三元组
贝塞尔转换(8.6.4)需要知道每个方向的多项式阶数,因为贝塞尔控制点的排布是按方向分块的。 金字塔上三个方向的阶数并不相同:
cpp
void FuncSpaceData::getOrderForBezier(int order[3], int exponentZ) const
{
if(_pyramidalSpace && exponentZ < 0) {
Msg::Error("getOrderForBezier needs third exponent for pyramidal space!");
order[0] = order[1] = order[2] = -1;
return;
}
if(getType() == TYPE_PYR) {
if(_pyramidalSpace) {
order[0] = order[1] = _nij + exponentZ; // i,j 的上界随 k 变化
order[2] = _nk;
}
else {
order[0] = order[1] = _nij;
order[2] = _nk;
}
}
else
order[0] = order[1] = order[2] = _spaceOrder;
}
对非金字塔单元,三个方向阶数全等于 _spaceOrder(因为单纯形/张量积的"方向"是对称的或可 分解的);对金字塔型空间,横向上界必须逐 k 层 给出(exponentZ 参数),这正是"金字塔型 空间不是单一阶数空间,而是一族按 k 分层的空间"在接口上的体现。
8.3.5 getForNonSerendipitySpace:强制提升到完备空间
Serendipity 空间缺少内部点,有时(比如需要内部自由度、或要做完备插值时)必须补齐。这个函数 返回一个"完备化"的空间描述:
cpp
FuncSpaceData FuncSpaceData::getForNonSerendipitySpace() const
{
if(!_serendipity) return *this;
if(_parentType == TYPE_PYR)
return FuncSpaceData(_parentType, _pyramidalSpace, _nij, _nk, false);
else
return FuncSpaceData(_parentType, _spaceOrder, false);
}
它非破坏性(返回新对象),且对金字塔单独保留 _pyramidalSpace/_nij/_nk。
8.3.6 排序与相等:作为 std::map 的键
BasisFactory 用 std::map<FuncSpaceData, bezierBasis*> 这样的容器缓存基函数,因此 FuncSpaceData 必须提供严格弱序 的 operator< 与 operator== (src/numeric/FuncSpaceData.h):
cpp
inline bool operator<(const FuncSpaceData &other) const
{
if(_parentType == other._parentType) {
if(_spaceOrder == other._spaceOrder) {
if(_nij == other._nij) {
if(_nk == other._nk) {
return _pyramidalSpace ? false : other._pyramidalSpace;
}
else
return _nk < other._nk;
}
else
return _nij < other._nij;
}
else
return _spaceOrder < other._spaceOrder;
}
else
return _parentType < other._parentType;
}
inline bool operator==(const FuncSpaceData &other) const
{
return _parentType == other._parentType &&
_spaceOrder == other._spaceOrder && _nij == other._nij &&
_nk == other._nk && _pyramidalSpace == other._pyramidalSpace;
}
这是一个按字段逐层比较的字典序 :先是父类型,再是阶数,再是 nij,再是 nk,最后 是 _pyramidalSpace。最后一层借用了一个小技巧:当两空间除 _pyramidalSpace 外全同,比较 结果写成 _pyramidalSpace ? false : other._pyramidalSpace------等价于"金字塔型排在六面体型之前"。 注意 operator< 里没有比较 _serendipity ,说明 Gmsh 的缓存键里 Serendipity 是通过 _nij/_spaceOrder 等间接区分的(Serendipity 三角形与完备三角形节点数不同、空间阶数定义 不同)。这是读源码时才看得出的"隐含契约"。
另外 getDimension() 用一个 switch 直接由父类型推维数(与 ElementType::getDimension 平行的 另一份实现):
cpp
int getDimension() const
{
switch(_parentType) {
case TYPE_PNT: return 0;
case TYPE_LIN: return 1;
case TYPE_TRI:
case TYPE_QUA: return 2;
case TYPE_TET:
case TYPE_PRI:
case TYPE_HEX:
case TYPE_PYR: return 3;
default: return -1;
}
}
8.3.7 澄清:GradN 归属何处
任务清单里提到"FuncSpaceData 的 GradN",但精读源码可知:FuncSpaceData 本身没有 GradN 成员 。GradN("形函数的梯度 N")概念分落在两处:
- 数值侧 :形函数梯度由
GradientBasis(src/numeric/JacobianBasis.h,8.6.5) 提供的dShapeMat_dX/dShapeMat_dY/dShapeMat_dZ承载------它就是"在采样点上算出的 ∂ϕi/∂X"。 - 求解器侧 :
SElement(src/solver/SElement.h)里有以GradN命名的接口,属于 FEM 装配层,不在src/numeric/内。
这是一个典型的"文档与源码对不上"的例子:遇到与印象不符时,以源码为准 。本章其余部分凡涉及 形函数梯度,一律指 GradientBasis 家族。
8.4 Gauss 积分
8.4.1 IntPt 与两族查询接口
数值积分的最基本单位是一对"点 + 权"(src/numeric/GaussIntegration.h):
cpp
struct IntPt {
double pt[3];
double weight;
};
头文件里紧接着给出两族查询函数------"点数"与"点表"成对出现:
cpp
int getNGQLPts(int order); IntPt *getGQLPts(int order);
int getNGQTPts(int order, bool forceTensorRule=false); IntPt *getGQTPts(...);
int getNGQQPts(int order, bool forceTensorRule=false); IntPt *getGQQPts(...);
int getNGQTetPts(int order, bool forceTensorRule=false); IntPt *getGQTetPts(...);
int getNGQPriPts(int order, bool forceTensorRule=false); IntPt *getGQPriPts(...);
int getNGQPyrPts(int order); IntPt *getGQPyrPts(int order);
int getNGQHPts(int order, bool forceTensorRule=false); IntPt *getGQHPts(...);
int GaussLegendreTri(int n1, int n2, IntPt *pts);
int GaussLegendreTet(int n1, int n2, int n3, IntPt *pts);
命名规律:getNGQ**Pts 返回点数 (N = number),getGQ**Pts 返回点表指针 。后缀 L/T/Q/Tet/Pri/Pyr/H 分别指 line / triangle / quadrangle / tetrahedron / prism / pyramid / hexahedron。七个点表查询里只有金字塔没有 forceTensorRule 参数------因为金字塔的"张量积"本身 就是通过 Duffy 变换(带权)构造的,不存在独立的第二种方案(见 8.4.6)。
8.4.2 forceTensorRule:精度与点数的权衡
forceTensorRule 参数揭示了 Gmsh 的一个策略选择:
- 对单纯形 (三角形、四面体):默认使用非张量的省点规则 (Solin 表或锥体规则,点数 约 O(pd/d!)),
forceTensorRule=true才退化到"张量 + 折叠"的规则(点数 O(pd))。 - 对张量积单元(四边形、六面体):默认可以用省点的非张量规则,也可以强制纯张量积。
- 对棱柱/金字塔:棱柱 = 三角形规则 × 线段规则;金字塔 = Duffy 变换后的锥体规则。
张量积规则阶数精确但点数多 ( O(nd)),非张量规则用更少的点达到同样代数精度。以三角形 为例,源码直接把两条路径写出来(src/numeric/GaussQuadratureTri.cpp):
cpp
IntPt *getGQTPts(int order, bool forceTensorRule)
{
if(!forceTensorRule && order < 21) return GQTSolin[order];
if(static_cast<int>(GQTGL.size()) < order + 1)
GQTGL.resize(order + 1, nullptr);
if(!GQTGL[order]) {
int n = (order + 3) / 2;
int npts = n * n;
IntPt *intpt = new IntPt[npts];
GaussLegendreTri(n, n, intpt);
GQTGL[order] = intpt;
}
return GQTGL[order];
}
int getNGQTPts(int order, bool forceTensorRule)
{
if(!forceTensorRule && order < 21) return GQTnPtSolin[order];
return ((order + 3) / 2) * ((order + 3) / 2);
}
GQTSolin[order] 是预先算好并硬编码进源码的 Solin 表 (Solin/Segeth/Dolezel 的经典 高阶有限元积分表),只在 order<21 时可用;超过则走 GaussLegendreTri 的折叠张量积。 四面体完全平行(阈值为 22):
cpp
IntPt *getGQTetPts(int order, bool forceTensorRule)
{
if(!forceTensorRule && order < 22) return GQTetSolin[order];
if(static_cast<int>(GQTetGL.size()) < order + 1)
GQTetGL.resize(order + 1, nullptr);
if(!GQTetGL[order]) {
int n = (order + 4) / 2;
IntPt *intpt = new IntPt[n * n * n];
GaussLegendreTet(n, n, n, intpt);
GQTetGL[order] = intpt;
}
return GQTetGL[order];
}
int getNGQTetPts(int order, bool forceTensorRule)
{
if(!forceTensorRule && order < 22) return GQTetnPtSolin[order];
int n = (order + 4) / 2; return n * n * n;
}
Solin 表里会故意出现负权重 。三角形三阶规则(src/numeric/GaussQuadratureTri.cpp):
cpp
IntPt GQT3[4] = {
{{0.333333333333333, 0.333333333333333, 0.}, -0.281250000000000},
{{0.600000000000000, 0.200000000000000, 0.}, +0.260416666666667},
{{0.200000000000000, 0.600000000000000, 0.}, +0.260416666666667},
{{0.200000000000000, 0.200000000000000, 0.}, +0.260416666666667}};
四点里有一个权重是负的( −0.28125)。负权重会让积分对正被积函数的稳定性变差 ,但能显著 减少点数。Gmsh 保留它们是因为......在有限元惯例里,只要被积函数是多项式(形函数 × 形函数 × 雅可比),积分就是代数精确的,符号无关紧要。文件里的注释还专门标注了每条规则的性质:
cpp
// 0 negative weights, 0 points outside of the triangle, total sum of the
// weights is 0.5
三角形的面积(权重和)固定为 0.5(参考面积),四面体为 1/6,四边形/六面体为 4/8。 这个"权重和 = 参考域体积"的约定贯穿全部积分表。
8.4.3 一维 Gauss-Legendre:硬编码的 ± 对称表
一维规则是其它一切规则的地基,放在 src/numeric/GaussLegendre1D.h, 以静态数组 + 内联分派 实现,支持 n=1...16 与 n=20:
cpp
/* 2 point rule points */ static double GL_pt2[2] = {-5.773502691896257e-01, 5.773502691896257e-01};
/* 2 point rule weights */ static double GL_wt2[2] = {1.000000000000000e+00, 1.000000000000000e+00};
// ... GL_pt3 ... GL_pt20 ...
inline void gmshGaussLegendre1D(int nbQuadPoints, double **t, double **w)
{
switch(nbQuadPoints) {
case 1: *t = GL_pt1; *w = GL_wt1; break;
case 2: *t = GL_pt2; *w = GL_wt2; break;
// ... case 20: *t = GL_pt20; *w = GL_wt20; ...
default: *t = nullptr; *w = nullptr; break;
}
}
点关于 0 对称( ti=−tn−1−i),权重也对称。注意区间是 −1,1,权重和为 2------与 "权重和 = 参考域长度"的约定一致。为什么用静态数组而非运行时计算? 因为一维 Gauss-Legendre 的节点是 Legendre 多项式的根,数值求解成本高且不稳定;而 n≤20 的节点在 文献里是"标准常数",硬编码精度最高。超过 20 点则表里没有对应项 (gmshGaussLegendre1D 返回空指针),因此 getGQLPts 实际只对 order≤39( n≤20)有效------这是一个硬边界, 实际有限元里线段阶数远到不了这么高,所以从未成为问题。
getGQLPts 的实现把点复制成 IntPt(pt[1]=pt[2]=0)并缓存进 static std::vector<IntPt*> GQL(大小 40),点数由 getNGQLPts(order) = (order+1)/2 向上取整给出------这就是积分 p 阶 多项式所需的最少点数 (Gauss-Legendre 的 n 点精确到 2n−1 阶,故 n=⌈(p+1)/2⌉)。 GQL 的惰性缓存形态是 8.4.7 要讲的普遍模式。
8.4.4 Gauss-Jacobi 与"带权"积分
金字塔的参考域可以用 (1−ζ)2 作为权函数自然描述(8.4.6 会看到),而一维 Gauss-Jacobi 规则 正是为 ∫−11(1−x)a(1+x)bf(x)dx 设计的 (src/numeric/GaussJacobi1D.h):
cpp
void getGaussJacobiQuadrature(int a, int b, int n, double **pt, double **wt);
金字塔规则调用 getGaussJacobiQuadrature(2, 0, nbPtW, ...)------即权 (1−x)2。Jacobi 节点 不能像 Legendre 那样简单地用对称表硬编码(因为权函数破坏对称性),所以它必须是运行时计算 (源码里是一段 Newton 迭代求根)。
8.4.5 单纯形与棱柱:折叠张量积
单纯形上的张量积规则通过退化变换 把立方体映射到单纯形。三角形用 quadToTri:
cpp
// 摘自 src/numeric/GaussLegendreSimplex.cpp
void quadToTri(double xi, double eta, double *r, double *s, double *J)
{
double r1;
*r = 0.5e0 * (1.0e0 + xi);
r1 = 1.0e0 - (*r);
*s = 0.5e0 * (1.0e0 + eta) * r1;
*J = 0.25e0 * r1;
}
int GaussLegendreTri(int n1, int n2, IntPt *pts)
{
/* get degenerate n1Xn2 Gauss-Legendre scheme to integrate over a tri */
int i, j, index = 0; double *pt1, *pt2, *wt1, *wt2, dJ;
gmshGaussLegendre1D(n1, &pt1, &wt1); gmshGaussLegendre1D(n2, &pt2, &wt2);
for(i = 0; i < n1; i++)
for(j = 0; j < n2; j++) {
quadToTri(pt1[i], pt2[j], &(pts[index].pt[0]), &(pts[index].pt[1]), &dJ);
pts[index].pt[2] = 0;
pts[index++].weight = dJ * wt1[i] * wt2[j];
}
return index;
}
关键在于 Jacobian 因子 dJ :把单位正方形 (ξ,η)∈−1,12 通过 r=21+ξ、 s=21+η(1−r) 映到单位三角形时,面积元被拉伸了 41(1−r) 倍,这个因子 必须乘进权重,否则积分不守恒。同理四面体用 brickToTet:
cpp
static void brickToTet(double xi, double eta, double zeta, double *r, double *s,
double *t, double *J)
{
double r1, rs1;
*r = 0.5e0 * (1.0e0 + xi);
r1 = 1.0e0 - (*r);
*s = 0.5e0 * (1.0e0 + eta) * r1;
rs1 = 1.0e0 - (*r) - (*s);
*t = 0.5e0 * (1.0e0 + zeta) * rs1;
*J = 0.125e0 * r1 * rs1;
}
体积元的拉伸因子是 81(1−r)(1−r−s)------三个"重心坐标逐步收缩"因子相乘。这就是 "退化张量积":变量替换 + Jacobian 补偿,把立方体积分表重用到单纯形上。
棱柱则是最直观的组合:三角形规则 × 一维规则(src/numeric/GaussQuadraturePri.cpp):
cpp
IntPt *getGQPriPts(int order, bool forceTensorRule)
{
int nLin = (order + 3) / 2;
int nTri = getNGQTPts(order, forceTensorRule);
int n = nLin * nTri;
// ... 依据 forceTensorRule 选 GQPGL 或 GQP 缓存 ...
if((forceTensorRule && !GQPGL[order]) ||
(!forceTensorRule && !GQP[order])) {
double *linPt, *linWt;
IntPt *triPts = getGQTPts(order, forceTensorRule);
gmshGaussLegendre1D(nLin, &linPt, &linWt);
IntPt *intpt = new IntPt[n];
int l = 0;
for(int i = 0; i < nTri; i++) {
for(int j = 0; j < nLin; j++) {
intpt[l].pt[0] = triPts[i].pt[0];
intpt[l].pt[1] = triPts[i].pt[1];
intpt[l].pt[2] = linPt[j];
intpt[l++].weight = triPts[i].weight * linWt[j];
}
}
if(forceTensorRule) GQPGL[order] = intpt; else GQP[order] = intpt;
}
return forceTensorRule ? GQPGL[order] : GQP[order];
}
int getNGQPriPts(int order, bool forceTensorRule)
{
int nLin = (order + 3) / 2;
int nTri = getNGQTPts(order, forceTensorRule);
return nLin * nTri;
}
顶点顺序是 (u,v) 来自三角形、w 来自线段------与 MPrism 的节点排列一致。
8.4.6 金字塔:Duffy 变换(锥体规则)
金字塔是最不平凡的一个。它用 Duffy 变换 把金字塔(四边形底 + 顶点)映射到标准六面体,再 在六面体上用"一维 Legendre × 一维 Legendre × 一维 Jacobi (1−w)2"的张量积 (src/numeric/GaussQuadraturePyr.cpp):
cpp
IntPt *getGQPyrPts(int order)
{
if(static_cast<int>(GQPyr.size()) < order + 1)
GQPyr.resize(order + 1, nullptr);
if(!GQPyr[order]) {
int nbPtUV = order / 2 + 1, nbPtW = order / 2 + 1;
int nbPtUV2 = nbPtUV * nbPtUV;
double *linPt, *linWt; gmshGaussLegendre1D(nbPtUV, &linPt, &linWt);
double *GJ20Pt, *GJ20Wt; getGaussJacobiQuadrature(2, 0, nbPtW, &GJ20Pt, &GJ20Wt);
IntPt *intpt = new IntPt[getNGQPyrPts(order)];
int l = 0;
for(int i = 0; i < getNGQPyrPts(order); i++) {
// compose an integration rule for (1-w)^2 f(u,v,w) on the standard hexahedron
int iW = i / nbPtUV2, iU = (i - iW * nbPtUV2) / nbPtUV;
int iV = (i - iW * nbPtUV2 - iU * nbPtUV);
double wt = linWt[iU] * linWt[iV] * GJ20Wt[iW];
double up = linPt[iU], vp = linPt[iV], wp = GJ20Pt[iW];
// incorporate the Duffy transformation from pyramid to hexahedron
intpt[l].pt[0] = 0.5 * (1 - wp) * up;
intpt[l].pt[1] = 0.5 * (1 - wp) * vp;
intpt[l].pt[2] = 0.5 * (1 + wp);
intpt[l++].weight = wt * 0.125;
}
GQPyr[order] = intpt;
}
return GQPyr[order];
}
int getNGQPyrPts(int order)
{ int n = order / 2 + 1; return n * n * n; }
数学拆解:先把金字塔坐标 (ξ,η,ζ) 参数化为 ξ=21−ζU、 η=21−ζV、 ζ 由 W 映射(本实现里 ζ=21(1+W),取 W∈−1,1 对应 ζ∈0,1)。横向"宽度"随高度线性收缩到 0(这正是金字塔的形状特征),因此横向 积分点要乘上 (1−ζ) 因子。 (1−w)2 这个权函数就是"横向二维面积随高度平方收缩"的 Jacobian ------两个横向方向各收缩一次,故平方。0.125 是 Duffy 变换的常数 Jacobian。
这也解释了为什么金字塔没有 forceTensorRule:它的规则本身就是张量积(只不过带权), 不存在"非张量"与"张量"两条路。
8.4.7 缓存策略:40 槽的惰性向量
观察 getGQLPts、getGQPyrPts、getGQHPts 等的实现,模式高度一致:一个 static std::vector<IntPt*>,初值 40 个 nullptr,按阶惰性填充。
cpp
// GaussQuadraturePyr.cpp
static std::vector<IntPt *> GQPyr(40, nullptr);
// GaussQuadratureLin.cpp
static std::vector<IntPt *> GQL(40, nullptr);
// GaussQuadratureHex.cpp
static std::vector<IntPt *> GQHGL(40, nullptr);
为什么是独立模块而不是硬编码常量表? 因为高阶单元需要到很高阶( p≥10),全张量积分点 表会爆炸,Solin 表也不可能覆盖所有阶;所以必须按需计算并缓存 。这也解释了源文件里同时存在 两套数据:低阶用硬编码的 GQT*/GQTetSolin/GQH* 表(快、约定明确),高阶用运行时的 GaussLegendreTri/Tet 与 Duffy/Jacobi 组合(通用)。40 这个上限是"窗口大小", resize(order+1) 让它必要时自动增长。
一个细节:六面体在低阶时优先查硬编码表 (src/numeric/GaussQuadratureHex.cpp):
cpp
static IntPt *GQH[2] = {GQH1, GQH6};
static int GQHnPt[2] = {1, 6};
// ...
IntPt *getGQHPts(int order, bool forceTensorRule)
{
if(!forceTensorRule && order <= 1) return GQH[order]; // 低阶用硬编码最优表
if(static_cast<int>(GQHGL.size()) < order + 1)
GQHGL.resize(order + 1, nullptr);
if(!GQHGL[order]) {
int n = (order + 1) / (float)2 + 0.5;
double *pt, *wt; gmshGaussLegendre1D(n, &pt, &wt);
IntPt *intpt = new IntPt[n * n * n];
int l = 0;
for(int i = 0; i < n; i++) for(int j = 0; j < n; j++) for(int k = 0; k < n; k++) {
intpt[l].pt[0] = pt[i]; intpt[l].pt[1] = pt[j]; intpt[l].pt[2] = pt[k];
intpt[l++].weight = wt[i] * wt[j] * wt[k];
}
GQHGL[order] = intpt;
}
return GQHGL[order];
}
int getNGQHPts(int order, bool forceTensorRule)
{
if(!forceTensorRule && order <= 1) return GQHnPt[order];
int n = (order + 1) / (float)2 + 0.5; return n * n * n;
}
GQH1 是单点、权重 8(六面体参考体积 23=8),GQH6 是六点规则。这些是"最优的" 低阶规则,比朴素的张量积更省点,所以优先使用。
8.4.8 gaussIntegration 工厂
面向上层的统一入口是 gaussIntegration 类(src/numeric/GaussIntegration.h):
cpp
class gaussIntegration {
public:
static void get(int elementType, int order, fullMatrix<double> &pts,
fullVector<double> &weights, bool forceTensorRule = false);
static void getTriangle(int order, fullMatrix<double> &pts, fullVector<double> &weights);
static void getLine(int order, fullMatrix<double> &pts, fullVector<double> &weights);
static void getQuad(int order, fullMatrix<double> &pts, fullVector<double> &weights);
static void getTetrahedron(int order, fullMatrix<double> &pts, fullVector<double> &w);
static void getHexahedron(int order, fullMatrix<double> &pts, fullVector<double> &w);
static void getPrism(int order, fullMatrix<double> &pts, fullVector<double> &w);
static void getPyramid(int order, fullMatrix<double> &pts, fullVector<double> &w);
};
// 上面 7 个 get* 只是把点/权搬进 fullMatrix/fullVector,等价于 get(...) 的分派
实现在 src/numeric/GaussIntegration.cpp:核心工具 pts2fullMatrix(npts, pts, pMatrix, wMatrix) 把 IntPt* 数组逐点拷进 fullMatrix/fullVector (点写入 pMatrix(i,0..2)、权写入 wMatrix(i));get(elementType, order, ..., forceTensorRule) 则按 TYPE_* 分派到对应的 getNGQ*Pts/getGQ*Pts 组合,TYPE_PNT 直接给单点权 1。
注意 TYPE_PYR 分支没有 forceTensorRule 透传------与头文件一致。TYPE_PNT 直接给 单点权 1。
通用形状(TYPE_POLYG/TYPE_POLYH/TYPE_XFEM/TYPE_MINI/TYPE_TRIH)不走这条工厂 。 它们分别由各自的单元类(MPolygon/MPolyhedron/MSubElement/MTrihedron)提供 getIntegrationPoints 回调,因为积分域需要运行时分解。这是"标准单元用表、特殊单元用回调" 的分工。
"积分点按需生成并缓存"是这一小节的核心工程决策 :精度与点数之间的权衡被编码进 forceTensorRule,而成本则被惰性缓存摊平。各单元的点数规律大致是:线段 ⌈(p+1)/2⌉, 三角形/四面体在低阶用 Solin 省点表(高阶 O(pd/d!),强制张量时 ⌈(p+d+1)/2⌉d), 四边形/六面体 nd,棱柱为三角形×线段,金字塔为 (⌊p/2⌋+1)3。
8.5 节点生成与单项式指数
8.5.1 两族生成函数
高阶单元的节点位置不是随便定的,必须与基函数一致。 src/numeric/pointsGenerators.h 的注释把整个模块的 契约一句话说清:
cpp
// Functions to generate point distributions on the reference elements, for all
// orders and functions generating exponents of Pascal monomials in the same
// order than Gmsh Points.
// Points
void gmshGeneratePoints(FuncSpaceData, fullMatrix<double> &);
fullMatrix<double> gmshGeneratePointsLine(int order);
fullMatrix<double> gmshGeneratePointsTriangle(int order, bool serendip = false);
// ... Quadrangle / Tetrahedron / Hexahedron / Prism / Pyramid 同构 ...
fullMatrix<double> gmshGeneratePointsPyramidGeneral(bool pyr, int nij, int nk, bool serendip);
// Monomial exponents
void gmshGenerateMonomials(FuncSpaceData, fullMatrix<double> &);
fullMatrix<double> gmshGenerateMonomialsLine(int order, bool serendip = false);
// ... Triangle / Quadrangle / Tetrahedron / Hexahedron / Prism / Pyramid ...
注释点明的关键约束是:单项式指数的顺序必须与 Gmsh 点的顺序完全一致("in the same order than Gmsh Points")。这不是巧合,而是因为节点基函数是通过"在参考点处求值单项式并求逆"构造 的(见 8.6.2)。
8.5.2 核心技法:点 = 单项式指数的仿射映射
观察实现,会发现一个极其优雅的统一模式------先算单项式指数,再做一次仿射映射得到坐标:
cpp
fullMatrix<double> gmshGeneratePointsLine(int order)
{
fullMatrix<double> points = gmshGenerateMonomialsLine(order);
if(order == 0) return points;
points.scale(2. / order); points.add(-1.); return points; // i -> 2i/p - 1
}
fullMatrix<double> gmshGeneratePointsTriangle(int order, bool serendip)
{
fullMatrix<double> points = gmshGenerateMonomialsTriangle(order, serendip);
if(order == 0) return points;
points.scale(1. / order); return points; // (i,j) -> (i/p, j/p)
}
fullMatrix<double> gmshGeneratePointsQuadrangle(int order, bool serendip)
{
fullMatrix<double> points = gmshGenerateMonomialsQuadrangle(order, serendip);
if(order == 0) return points;
points.scale(2. / order); points.add(-1.); return points;
}
线段把指数 i∈0,p 仿射到 −1,1(2/p * i - 1);三角形把 (i,j)( i+j≤p)缩放 到单位三角形(i/p, j/p);四边形/六面体把 0,p 映射到 −1,1。同一个单项式指数矩阵, 既定义了多项式空间,又通过映射定义了节点坐标------这正是"点与单项式同序"的技术实现。
棱柱是"分裂映射"的范例:
cpp
fullMatrix<double> gmshGeneratePointsPrism(int order, bool serendip)
{
fullMatrix<double> points = gmshGenerateMonomialsPrism(order, serendip);
if(order == 0) return points;
fullMatrix<double> tmp;
tmp.setAsProxy(points, 0, 2); // 前两列(三角形方向)
tmp.scale(1. / order);
tmp.setAsProxy(points, 2, 1); // 第三列(棱方向)
tmp.scale(2. / order);
tmp.add(-1.);
return points;
}
前两列用三角形映射(1/order),第三列用线段映射(2/order - 1) 。setAsProxy 是对 fullMatrix 的"子视图"操作(不拷贝,直接引用同一块内存),因此两次 scale 作用在互不相交的 列上。这是 fullMatrix API 支持"就地分块变换"的直接体现。
金字塔的映射最绕(src/numeric/pointsGenerators.cpp):
cpp
fullMatrix<double> gmshGeneratePointsPyramidGeneral(bool pyr, int nij, int nk,
bool forSerendipPoints)
{
// if pyr:
// div = nk + nij
// monomial(i, j, k) -> (-(1-k')+2*i/div, -(1-k')+2*j/div, (nk-k)/div)
// else:
// div = max(nij, nk)
// monomial(i, j, k) -> (-1+2*i/nij)*(1-k'), (-1+2*j/nij)*(1-k'), (nk-k)/div)
fullMatrix<double> points =
gmshGenerateMonomialsPyramidGeneral(pyr, nij, nk, forSerendipPoints);
if(points.size1() == 1) return points;
const int div = pyr ? nk + nij : std::max(nij, nk);
double scale = 2. / div;
for(int i = 0; i < points.size1(); ++i) {
points(i, 2) = (nk - points(i, 2)) / div;
const double duv = 1. - points(i, 2);
if(!pyr) scale = 2. / nij * duv;
points(i, 0) = -duv + points(i, 0) * scale;
points(i, 1) = -duv + points(i, 1) * scale;
}
return points;
}
注释里的公式是可核对的权威 :金字塔型下横向坐标用 -(1-k') + 2i/div,六面体型下横向 宽度随高度 1−k′ 线性收缩 ((-1+2i/nij)*(1-k'))------这与 8.4.6 里 Duffy 变换的几何收缩 完全对应。点生成、积分规则、空间定义三者共享同一套"金字塔几何直觉"。
8.5.3 单项式指数:以三角形为例
gmshGenerateMonomialsTriangle(同文件)展示了"Pascal 三角形 + 边遍历"的生成逻辑:
cpp
fullMatrix<double> gmshGenerateMonomialsTriangle(int order, bool serendip)
{
int nbMonomials = serendip ? 3 * order : (order + 1) * (order + 2) / 2;
if(serendip && !order) nbMonomials = 1;
fullMatrix<double> monomials(nbMonomials, 2);
monomials(0, 0) = 0; monomials(0, 1) = 0; // 角点 0
if(order > 0) {
monomials(1, 0) = order; monomials(1, 1) = 0; // 角点 1
monomials(2, 0) = 0; monomials(2, 1) = order; // 角点 2
if(order > 1) {
int index = 3;
for(int iedge = 0; iedge < 3; ++iedge) { // 沿 MTriangle::edges_tri 生成边点
int i0 = MTriangle::edges_tri(iedge, 0), i1 = MTriangle::edges_tri(iedge, 1);
int u_0 = (monomials(i1, 0) - monomials(i0, 0)) / order;
int u_1 = (monomials(i1, 1) - monomials(i0, 1)) / order;
for(int i = 1; i < order; ++i, ++index) {
monomials(index, 0) = monomials(i0, 0) + u_0 * i;
monomials(index, 1) = monomials(i0, 1) + u_1 * i;
}
}
if(!serendip && order > 2) { // 内部点:递归剥离三层
fullMatrix<double> inner = gmshGenerateMonomialsTriangle(order - 3);
inner.add(1);
monomials.copy(inner, 0, nbMonomials - index, 0, 2, index, 0);
}
}
}
return monomials;
}
三个要点:
- 排列顺序与
MTriangle::edges_tri严格一致 ------边点沿{0,1},{1,2},{2,0}生成,每边的 点数顺序由i0→i1决定。这就是"节点排列 = 单元类的边表"的落地。 - Serendipity 就是"去掉内部点" :
serendip时只有3*order个单项式,直接跳过if(!serendip ...)的内部块。 - 内部点用递归生成 :完备族的内部点通过对
(order-3)阶三角形("去掉三条边各一层"后的 里层三角形)递归生成,再整体add(1)平移(因为里层三角形的角点指数要偏移到 1,p−1 区间)。这个order-3是"剥掉三层壳"的结果------三角形只有三条边,剥一层壳降 1 阶, 剥三层降 3 阶。
四边形同理(gmshGenerateMonomialsQuadrangle),但内层用 order-2 偏移(四边形有四条边, 剥掉一圈降 2 阶)。棱柱、六面体、金字塔各有对应的"剥离"层数。这是这套生成器最优雅的地方: 不同形状只在"剥离多少层"上不同,核心是同一个递归。
8.5.4 有序点:为贝塞尔转换服务
除了"Gmsh 节点序",还有一族有序点(ordered points),专门为贝塞尔转换(8.6.4)设计:
cpp
// Ordered points (and monomials)
// This order is used for expanding a function into Bezier basis: it allows
// the efficient conversion of Lagrange coefficients to Bezier coefficients
// (in method bezierCoeff::_computeCoefficients(..))
void gmshGenerateOrderedPointsLine(int order, fullVector<double> &);
void gmshGenerateOrderedPoints(FuncSpaceData data, fullMatrix<double> &points,
bool onBezierDomain = false);
void gmshGenerateOrderedMonomials(FuncSpaceData, fullMatrix<double> &);
注释解释了"为什么需要第二套顺序" :拉格朗日(节点)系数与贝塞尔系数不是同一个排布, 把前者转成后者需要一处高效的顺序对应。gmshGenerateOrderedPoints 就是这个转换的"采样点表", bezierCoeff::_computeCoefficients 依赖它。这是一个典型的"为算法优化的数据重排",而不是 "历史遗留"。
此外,高阶单元的内部节点位置有时需要根据几何曲率调整(好让曲边单元更贴合曲面), src/numeric/InnerVertexPlacement.h 提供的就是这套 "内部顶点放置"规则,供高阶网格生成/优化使用(第 10 章会看到它与 HighOrderMeshOptimizer 的配合)。
8.6 有限元基函数体系
8.6.1 BasisFactory:五类基函数与五张缓存
这是 numeric 中最庞大也最精妙的部分。基函数类族与工厂集中在 src/numeric/BasisFactory.h:
cpp
class BasisFactory {
private:
static std::map<int, nodalBasis *> fs;
static std::map<FuncSpaceData, bezierBasis *> bs;
static std::map<std::pair<int, int>, CondNumBasis *> cs;
static std::map<std::pair<int, FuncSpaceData>, JacobianBasis *> js;
static std::map<std::pair<int, FuncSpaceData>, GradientBasis *> gs;
public:
// Caution: the returned pointer can be NULL
static const nodalBasis *getNodalBasis(int tag); // Nodal
// Warning: bases returned by BasisFactory::getJacobianBasis(int tag) are the
// only safe bases for using Bezier on the jacobian determinant!
static const JacobianBasis *getJacobianBasis(int tag, FuncSpaceData);
static const JacobianBasis *getJacobianBasis(int tag, int order);
static const JacobianBasis *getJacobianBasis(int tag); // Jacobian
static const CondNumBasis *getCondNumBasis(int tag, int cnOrder = -1); // Condition number
static const GradientBasis *getGradientBasis(int tag, FuncSpaceData);
static const GradientBasis *getGradientBasis(int tag, int order);
static const GradientBasis *getGradientBasis(int tag); // Gradients
static const bezierBasis *getBezierBasis(FuncSpaceData);
static const bezierBasis *getBezierBasis(int parentType, int order);
static const bezierBasis *getBezierBasis(int tag); // Bezier
static void clearAll();
};
五种基函数各有用途,用五张静态 map 缓存(fs 用 int 键,其余用 FuncSpaceData 或其 组合键,正印证 8.3.6 里 FuncSpaceData 必须可排序):
| 工厂方法 | 基函数类 | 缓存键 | 用途 |
|---|---|---|---|
getNodalBasis |
nodalBasis/polynomialBasis/pyramidalBasis... |
int tag(MSH 编号) |
形函数与导数(有限元核心) |
getJacobianBasis |
JacobianBasis |
(tag, FuncSpaceData) |
雅可比行列式多项式(正性检查) |
getCondNumBasis |
CondNumBasis |
(tag, cnOrder) |
单元条件数质量与优化 |
getGradientBasis |
GradientBasis |
(tag, FuncSpaceData) |
形函数梯度矩阵 |
getBezierBasis |
bezierBasis |
FuncSpaceData |
贝塞尔表示(正性判定、高阶曲边) |
注意 getGradientBasis/getJacobianBasis/getBezierBasis 都有三重载 :只给 tag、给 tag + order、给 tag + FuncSpaceData。少参数的版本内部用 ElementType/默认空间补齐------ 这正是 8.2 与 8.3 的再次协作。
getNodalBasis 的输入是 MSH 编号 (int tag),而不是抽象父类型------因为形函数与具体的 节点排列强绑定(同一个父类型的不同阶、不同族别节点数不同)。头文件开头的 "Caution: the returned pointer can be NULL" 提醒:对不支持的类型(如 polyg/polyh/xfem)会返回空,调用方 必须判空。
"贝塞尔警告"是本节的题眼 :注释说 getJacobianBasis(tag) 返回的基函数是唯一能安全用于 对雅可比行列式做贝塞尔展开 的基。原因见 8.6.4 与 8.6.5------雅可比行列式是一个派生多项式, 直接在节点基上做贝塞尔展开会丢掉正性保证,必须用几何上"正确"的雅可比基。
8.6.2 nodalBasis 与 polynomialBasis:翻转 Vandermonde
nodalBasis(src/numeric/nodalBasis.h)是抽象基类,承载 "节点基"的公共数据与接口:
cpp
class nodalBasis {
public:
int type, parentType, order, dimension, numFaces;
bool serendip;
fullMatrix<double> points;
nodalBasis() {}
nodalBasis(int tag);
virtual ~nodalBasis() {}
virtual int getNumShapeFunctions() const = 0;
int getNumBubbleShapeFunctions() const;
void getReferenceNodes(fullMatrix<double> &nodes) const { nodes = points; }
const fullMatrix<double> &getReferenceNodes() const { return points; }
// project the provided points on the current control points / renumber
bool forwardTransformation(const fullMatrix<double> &otherPoints,
fullMatrix<double> &projection, int elementType = -1) const;
bool forwardRenumbering(const fullMatrix<double> &otherPoints, int *renum,
int elemenType = -1) const;
// Basis functions & gradients evaluation
virtual void f(double u, double v, double w, double *sf) const = 0;
virtual void f(const fullMatrix<double> &coord, fullMatrix<double> &sf) const = 0;
virtual void f(double u, double v, double w, int i, double *sf) const = 0;
virtual void df(double u, double v, double w, double grads[][3]) const = 0;
virtual void df(const fullMatrix<double> &coord, fullMatrix<double> &dfm) const = 0;
virtual void df(double u, double v, double w, int i, double grad[3]) const = 0;
virtual void ddf(double u, double v, double w, double grads[][3][3]) const { /* error */ }
virtual void dddf(double u, double v, double w, double grads[][3][3][3]) const { /* error */ }
// closures is the list of the nodes of each face, in the order of the
// polynomialBasis of the face; fullClosures rotates the element so that the
// considered face becomes the first one in the right orientation
class closure : public std::vector<int> { public: int type; };
typedef std::vector<closure> clCont;
clCont closures, fullClosures;
std::vector<int> closureRef;
// ...
};
几个关键设计:
f/df/ddf/dddf四层导 :ddf是 Hessian( 3×3),dddf是三阶导 ( 3×3×3)。默认实现直接报错,只有需要的基(如polynomialBasis)才实现。 为什么需要到三阶导? 曲边高阶单元的优化(HighOrderMeshOptimizer)与弹性问题的STensor(TensorialTraits,第 10 章)会用到。closures/fullClosures/closureRef:描述"每个面包含哪些节点、以什么顺序、需要怎样 旋转单元才能对齐"。closure::type记录该面是三角形还是四边形。这是因为同一个单元的不同 面可能是不同类型的(棱柱:2 个三角形面 + 3 个四边形面),面基函数的"局部顺序"必须能对齐。getClosureId/breakClosureId把"面号 + 朝向符号 + 旋转"编码成一个整数 ID:
cpp
inline int nodalBasis::getClosureId(int iFace, int iSign, int iRot) const
{
return iFace + numFaces * (iSign == 1 ? 0 : 1) + 2 * numFaces * iRot;
}
inline void nodalBasis::breakClosureId(int i, int &iFace, int &iSign, int &iRot) const
{
iFace = i % numFaces;
i = (i - iFace) / numFaces;
iSign = i % 2;
iRot = (i - iSign) / 2;
}
它把三种变换(哪个面、是否翻号、旋转多少)压进一个整数 ,使得"面上节点对齐"可以做成 查表:这是按 p 自适应/多面片连接时"保证 H1 连续性"的关键。
真正的节点多项式实现是 polynomialBasis(src/numeric/polynomialBasis.h):
cpp
class polynomialBasis : public nodalBasis {
public:
// for now the only implemented polynomial basis are nodal poly basis, we use
// the type of the corresponding gmsh element as type
fullMatrix<double> monomials;
fullMatrix<double> coefficients;
polynomialBasis(){};
polynomialBasis(int tag);
~polynomialBasis();
virtual inline int getNumShapeFunctions() const { return coefficients.size1(); }
virtual void f(double u, double v, double w, double *sf) const;
virtual void f(const fullMatrix<double> &coord, fullMatrix<double> &sf) const;
virtual void f(double u, double v, double w, int i, double *sf) const;
virtual void df(double u, double v, double w, double grads[][3]) const;
virtual void ddf(double u, double v, double w, double hess[][3][3]) const;
virtual void dddf(double u, double v, double w, double third[][3][3][3]) const;
void evaluateMonomials(double u, double v, double w, double p[]) const;
};
monomials 与 coefficients 的关系正是 8.5 节说的:系数矩阵 = 单项式在节点处的值矩阵的 逆。构造函数先按父类型选单项式(注意 Serendipity 分派),再翻转 Vandermonde:
cpp
polynomialBasis::polynomialBasis(int tag) : nodalBasis(tag)
{
switch(parentType) {
case TYPE_PNT: monomials = gmshGenerateMonomialsLine(0); break;
case TYPE_LIN: monomials = gmshGenerateMonomialsLine(order); break;
case TYPE_TRI: monomials = gmshGenerateMonomialsTriangle(order, serendip); break;
case TYPE_QUA:
monomials = serendip ? gmshGenerateMonomialsQuadSerendipity(order)
: gmshGenerateMonomialsQuadrangle(order); break;
case TYPE_TET: monomials = gmshGenerateMonomialsTetrahedron(order, serendip); break;
case TYPE_PRI:
monomials = serendip ? gmshGenerateMonomialsPrismSerendipity(order)
: gmshGenerateMonomialsPrism(order); break;
case TYPE_HEX:
monomials = serendip ? gmshGenerateMonomialsHexaSerendipity(order)
: gmshGenerateMonomialsHexahedron(order); break;
}
coefficients = generateLagrangeMonomialCoefficients(monomials, points);
}
而 generateLagrangeMonomialCoefficients 是这段代码的"数学心脏":
cpp
namespace {
fullMatrix<double>
generateLagrangeMonomialCoefficients(const fullMatrix<double> &monomial,
const fullMatrix<double> &point)
{
if(monomial.size1() != point.size1() || monomial.size2() != point.size2()) {
Msg::Error("Wrong sizes for Lagrange coefficients generation %d %d -- %d %d", ...);
return fullMatrix<double>();
}
int ndofs = monomial.size1();
int dim = monomial.size2();
fullMatrix<double> Vandermonde(ndofs, ndofs);
for(int i = 0; i < ndofs; i++)
for(int j = 0; j < ndofs; j++) {
double dd = 1.;
for(int k = 0; k < dim; k++) dd *= pow_int(point(j, k), monomial(i, k));
Vandermonde(i, j) = dd;
}
fullMatrix<double> coefficient(ndofs, ndofs);
Vandermonde.invert(coefficient);
return coefficient;
}
}
数学分解:设单项式集合 {mi}(指数向量)、节点集合 {pj}。 Vij=pjmi (第 j 个节点代入第 i 个单项式的值)。形函数 ϕj 要满足 ϕj(pk)=δjk, 写成 ϕj=∑icjimi,则 ∑icjiVik=δjk,即 CV=I, C=V−1。所以 coefficients 就是 Vandermonde 矩阵的逆 。这正是"节点位置决定基函数" 的精确表述。evaluateMonomials 负责在 (u,v,w) 处算出各单项式的值,f 再与 coefficients 做矩阵乘:
cpp
void polynomialBasis::evaluateMonomials(double u, double v, double w, double p[]) const
{
for(int j = 0; j < monomials.size1(); ++j) {
p[j] = 1.;
switch(dimension) {
case 3: p[j] *= pow_int(w, monomials(j, 2)); // 有意 fall-through
case 2: p[j] *= pow_int(v, monomials(j, 1));
case 1: p[j] *= pow_int(u, monomials(j, 0));
default: break;
}
}
}
void polynomialBasis::f(double u, double v, double w, double *sf) const
{
double p[1256]; evaluateMonomials(u, v, w, p);
for(int i = 0; i < coefficients.size1(); i++) {
sf[i] = 0.0;
for(int j = 0; j < coefficients.size2(); j++) sf[i] += coefficients(i, j) * p[j];
}
}
注意 switch 的故意 fall-through (没有 break):三维时先乘 w、再乘 v、再乘 u, 写得很紧凑,避免了逐个维度判断。double p[1256] 是栈上硬上限数组------给到十阶金字塔一类 最大规模也绰绰有余,用栈数组避免每次调用都堆分配。这是一个性能敏感的热路径(形函数在 整个前处理过程中被调用无数次)。
8.6.3 金字塔基:BergotBasis 与 pyramidalBasis
因为金字塔空间的特殊性(8.3.2),它不能直接用 polynomialBasis。Gmsh 采用Bergot 等人的 节点金字塔元 (src/numeric/BergotBasis.h):
cpp
// Basis functios for pyramidal elements: cf. M. Bergot, G. Cohen, M. Durufle,
// HIGHER-ORDER FINITE ELEMENTS FOR HYBRID MESHES USING NEW NODAL PYRAMIDAL
// ELEMENTS, J. Sci. Comput. 42, 3 (2010) 345-381", DOI:
// 10.1007/s10915-009-9334-9
class BergotBasis {
public:
BergotBasis(int p, bool incpl = false);
virtual ~BergotBasis();
void f(double u, double v, double w, double *val) const;
void df(double u, double v, double w, double grads[][3]) const;
void initialize(){};
bool validIJ(int i, int j) const;
private:
int order; //!< maximal order of surrounding functional spaces (on triangle / quad)
bool incomplete; //!< serendipity interpolation
};
validIJ 就是金字塔型空间" i,j≤k+nij"约束的实现 ------哪些 (i,j) 组合合法, 取决于当前层 k。pyramidalBasis(src/numeric/pyramidalBasis.h) 把 Bergot 的正交型基函数"节点化":
cpp
class pyramidalBasis : public nodalBasis {
private:
BergotBasis *bergot; // Orthogonal basis for the pyramid
fullMatrix<double> bergotCoefficients;
public:
fullMatrix<double> coefficients;
fullMatrix<double> monomials;
// ...
};
其构造函数做两次 Vandermonde 翻转 (src/numeric/pyramidalBasis.cpp):
cpp
pyramidalBasis::pyramidalBasis(int tag) : nodalBasis(tag), bergot(nullptr)
{
if(serendip && order > 2) {
Msg::Warning("Serendipity pyramid for order %i not yet implemented", order);
return;
}
bergot = new BergotBasis(order, serendip);
int num_points = points.size1();
bergotCoefficients.resize(num_points, num_points);
double *fval = new double[num_points];
fullMatrix<double> VDM(num_points, num_points);
// 第一层:正交基 → 节点基(在节点处求 Bergot 基值再求逆)
for(int j = 0; j < num_points; j++) {
bergot->f(points(j, 0), points(j, 1), points(j, 2), fval);
for(int i = 0; i < num_points; i++) VDM(i, j) = fval[i];
}
VDM.invert(bergotCoefficients);
coefficients.resize(num_points, num_points);
monomials.resize(num_points, 3);
int idx = 0;
for(int i = 0; i <= order; i++)
for(int j = 0; j <= order; j++)
if(bergot->validIJ(i, j)) // 金字塔型空间的 i,j<=k+nij
for(int k = 0; k <= order - std::max(i, j); k++, idx++) {
monomials(idx, 0) = i; monomials(idx, 1) = j; monomials(idx, 2) = k;
for(int l = 0; l < num_points; l++) {
double oneMinW = std::max(1e-14, 1 - points(l, 2)); // 顶点处保护除零
VDM(idx, l) = std::pow(points(l, 0), i) * std::pow(points(l, 1), j) *
std::pow(points(l, 2), k) *
std::pow(oneMinW, std::max(i, j) - i - j); // 有理部分
}
}
VDM.invert(coefficients); // 第二层:有理单项式 → 节点基
delete[] fval;
}
第二次 Vandermonde 的最后一乘 pow(oneMinW, max(i,j) - i - j) 就是"有理单项式"的 表征 : ϕ=ξiηjζk(1−ζ)max(i,j)−i−j,即把分母 1−ζ 移入分子的 负幂。std::max(1e-14, 1 - points(l,2)) 是对 ζ=1(顶点)处的除零保护 ------用一个 极小正数替代,避免 00 或除零。这是金字塔单元数值实现里最容易踩的坑 ,源码用一行 std::max 化解。
validIJ 的语义与 8.3.2 的两族空间对应:它确定 (i,j) 是否在给定 k 层合法。所以 pyramidalBasis 生成的 monomials 不是 一个规整的矩形指数集,而是一个"按 k 分层的 楔形"集合。
8.6.4 贝塞尔基:正性、凸包与变差缩减
为什么需要 bezierBasis(src/numeric/bezierBasis.h)? 答案是多项式正性的判定。关键数学事实:一个多项式在单纯形上的正性,等价于它在(单纯形) 贝塞尔形式下的所有控制点为正。这来自"凸包性质"与"变差缩减性质(variation diminishing)"。
打个比方:拉格朗日形式下, ∑iciϕi 的系数 ci 是节点的插值值 ------它不能 保证函数在单元内部取值的符号(节点间的振荡不受控;这正是高阶拉格朗日插值出现 Runge 现象的 原因)。而贝塞尔形式的控制点 βi 具有"凸包性质":整条函数曲线落在控制点的凸包内。 于是:
- βi>0 ∀i⇒ 多项式在单元内处处为正;
- 若某个 βi≤0,则一定存在负值区。
对高阶网格"是否翻转"判定,这个性质极其方便 :只要把雅可比多项式转成贝塞尔形式,检查 控制点符号即可,无需在单元内部逐点采样。这就是注释里对 getJacobianBasis 的强警告的来源。
bezierBasis 的核心是"拉格朗日系数 → 贝塞尔系数"的转换矩阵 _matrixLag2Bez:
cpp
class bezierBasis {
private:
int _numLagCoeff; // Number of corner coeff which are 'real' values of the expanded function
int _dimSimplex;
const FuncSpaceData _funcSpaceData;
bezierBasisRaiser *_raiser;
fullMatrix<double> _exponents;
fullMatrix<double> _matrixLag2Bez; // 拉格朗日系数 → 贝塞尔系数
fullVector<double> _ordered1dBezPoints;
fullMatrix<double> _samplingPntsLagDomain;
public:
bezierBasis(FuncSpaceData data);
inline int getNumCoeff() const { return _exponents.size1(); }
inline int getNumLagCoeff() const { return _numLagCoeff; }
const bezierBasisRaiser *getRaiser() const;
};
bezierBasisRaiser 类做的是乘积的贝塞尔控制点 :给定 f,g,h 的贝塞尔系数,直接算出 f⋅g、 f⋅g⋅h 的贝塞尔系数------这正是构造雅可比行列式(形函数导数的乘积)的 贝塞尔形式所需的工具:
cpp
class bezierBasisRaiser {
// Let f, g, h be three function whose Bezier coefficients are given.
// This class allows to compute the Bezier coefficients of f*g and f*g*h.
public:
void computeCoeff(const fullVector<double> &coeffA, const fullVector<double> &coeffB,
fullVector<double> &coeffSquare) const;
void computeCoeff(const fullVector<double> &coeffA, const fullVector<double> &coeffB,
const fullVector<double> &coeffC, fullVector<double> &coeffCubic) const;
};
bezierCoeff 类还实现了细分(De Casteljau 细分)与内存池优化:
cpp
class bezierCoeff {
// ...
void subdivide(std::vector<bezierCoeff *> &subCoeff) const;
static void _subdivideTriangle(const bezierCoeff &coeff, int start, ...);
static void _subdivideTetrahedron(const bezierCoeff &coeff, ...);
static void _subdivideQuadrangle(const bezierCoeff &coeff, ...);
static void _subdivideHexahedron(const bezierCoeff &coeff, ...);
static void _subdividePrism(const bezierCoeff &coeff, ...);
static void _subdividePyramid(const bezierCoeff &coeff, ...);
// ...
};
细分(subdivision)是判断正性的"二分法" :若一个贝塞尔片不能确定符号,就把它细分成更小 的片,逐片检查控制点符号。bezierCoeffMemoryPool 类(同文件)专门避免细分算法里频繁的 内存分配/释放:
cpp
class bezierCoeffMemoryPool {
// This class is to avoid multiple allocation / deallocation during
// the subdivision algorithm.
// ...
};
这体现了一个工程哲学:"贝塞尔 + 细分"比"在单元内部密集采样"既更正确(有正性保证)又更 高效(自适应细分只在需要时才切)。
单独的曲线求值/细分则由 src/numeric/decasteljau.h 提供:
cpp
// Contributed by J. Lambrechts
void decasteljau(double tol, const SPoint3 &p0, const SPoint3 &p1,
const SPoint3 &p2, std::vector<SPoint3> &pts, std::vector<double> &ts);
void decasteljau(double tol, const SPoint3 &p0, const SPoint3 &p1,
const SPoint3 &p2, const SPoint3 &p3,
std::vector<SPoint3> &pts, std::vector<double> &ts);
void decasteljau(double tol, const std::vector<SPoint3> &controlPoints,
std::vector<SPoint3> &pts, std::vector<double> &ts);
三重重载分别对应二次/三次贝塞尔曲线与任意阶控制点------它把贝塞尔曲线自适应离散成折线(点集 pts + 参数 ts),是几何层曲边离散的工具。
8.6.5 GradientBasis 与 JacobianBasis:理想单元与物理单元
src/numeric/JacobianBasis.h 里 GradientBasis 定义在 JacobianBasis 之前,因为后者依赖前者。
GradientBasis 承载"形函数梯度矩阵",并区分物理单元 与理想单元:
cpp
class GradientBasis {
public:
fullMatrix<double> dShapeMat_dX, dShapeMat_dY, dShapeMat_dZ; // 物理单元
fullMatrix<double> dShapeIdealMat_dX, dShapeIdealMat_dY, dShapeIdealMat_dZ; // 理想单元
private:
const int _elementTag;
const FuncSpaceData _data;
public:
GradientBasis(int elementTag, FuncSpaceData);
inline int getPolynomialOrder() const { return _data.getSpaceOrder(); }
int getNumSamplingPoints() const { return dShapeMat_dX.size1(); }
int getNumMapNodes() const { return dShapeMat_dX.size2(); }
void getGradientsFromNodes(const fullMatrix<double> &nodesCoord,
fullMatrix<double> *dxyzdX, fullMatrix<double> *dxyzdY,
fullMatrix<double> *dxyzdZ) const;
void getAllGradientsFromNodes(const fullMatrix<double> &nodesCoord,
fullMatrix<double> &dxyzdXYZ) const;
void getIdealGradientsFromNodes(const fullMatrix<double> &nodesCoord, /* ... */) const;
static void mapFromIdealElement(int type, fullMatrix<double> &dSMat_dX, /* ... */);
static void mapFromIdealElement(int type, double jac[3][3]);
};
"理想单元(ideal element)"是这套接口的灵魂 。理想单元是一个规则的、无畸变的参考单元 (如均匀剖分的等边三角形/正方体),它给出"最理想"的度量;dShapeIdealMat_* 就是理想单元上 的形函数梯度。mapFromIdealElement 负责在"理想梯度"与"物理梯度"之间做变换(本质是 乘以参考到理想、理想到物理的 Jacobian)。这套对照是质量度量(雅可比、条件数)的基础: 我们想知道一个实际单元离"理想形状"有多远,就需要这两套梯度做比较。
JacobianBasis 把"雅可比行列式"这件事做成关于节点坐标的多项式:
cpp
class JacobianBasis {
private:
const GradientBasis *_gradBasis;
const FuncSpaceData _data;
fullMatrix<double> dFastShapeMat_dX, dFastShapeMat_dY, dFastShapeMat_dZ;
fullVector<double> dPrimBaryShape_dX, dPrimBaryShape_dY, dPrimBaryShape_dZ;
fullVector<double> dPrimBaryIdealShape_dX, dPrimBaryIdealShape_dY, dPrimBaryIdealShape_dZ;
// Lifts Lagrange basis of primary Jac. to Lagrange basis of Jac.
fullMatrix<double> matrixPrimJac2Jac;
int numSamplingPnts, numPrimSamplingPnts, numMapNodes, numPrimMapNodes;
int numSamplingPntsFast;
public:
JacobianBasis(int elementTag, FuncSpaceData);
inline int getJacOrder() const { return _data.getSpaceOrder(); }
inline int getNumSamplingPnts() const { return numSamplingPnts; }
inline int getNumSamplingPntsFast() const { return numSamplingPntsFast; }
inline FuncSpaceData getFuncSpaceData() const { return _data; }
double getPrimNormals1D(const fullMatrix<double> &nodesXYZ, fullMatrix<double> &result) const;
double getPrimNormal2D(const fullMatrix<double> &nodesXYZ, fullMatrix<double> &result,
bool ideal = false) const;
double getPrimJac3D(const fullMatrix<double> &nodesXYZ, bool ideal = false) const;
void getSignedJacAndGradients(const fullMatrix<double> &nodesXYZ,
const fullMatrix<double> &normals, fullMatrix<double> &JDJ) const;
void getSignedJacobian(const fullMatrix<double> &nodesXYZ, fullVector<double> &jacobian,
const fullMatrix<double> *normals = nullptr) const;
void getSignedIdealJacobian(const fullMatrix<double> &nodesXYZ,
fullVector<double> &jacobian, /* ... */) const;
void getScaledJacobian(const fullMatrix<double> &nodesXYZ,
fullVector<double> &jacobian) const;
inline void primJac2Jac(const fullVector<double> &primJac, fullVector<double> &jac) const
{ matrixPrimJac2Jac.mult(primJac, jac); }
static int jacobianOrder(int tag);
static int jacobianOrder(int parentType, int order);
static FuncSpaceData jacobianMatrixSpace(int type, int order);
};
要点:
getSignedJacobianvsgetScaledJacobian:前者是带符号的雅可比行列式(正负即单元朝向), 后者是归一化到 0,1 的"缩放雅可比"(质量度量常用)。getSignedIdealJacobian:理想单元上的对应量,用于"与理想形状比较"。jacobianOrder/jacobianMatrixSpace(静态) :雅可比行列式是一个次数比形函数低一阶 的多项式(导一次降一阶),所以jacobianOrder(tag)算出这个阶,jacobianMatrixSpace返回 承载它的FuncSpaceData。这两个静态函数正是 8.6.1 里getJacobianBasis三重载的 内部依赖。matrixPrimJac2Jac:注释说它 "Lifts Lagrange basis of primary Jac. to Lagrange basis of Jac."------把"一阶单元(primary)的雅可比拉格朗日基"提升到"高阶单元的雅可比拉格朗日基"。 为什么需要? 因为一阶雅可比的采样点集合更小、更"基础",先用它算,再通过这个矩阵 提升到完整雅可比空间,计算更省。
8.6.6 CondNumBasis:条件数质量与优化
CondNumBasis(src/numeric/CondNumBasis.h)面向**条件数 (condition number)**质量度量:
cpp
class CondNumBasis {
private:
const GradientBasis *_gradBasis;
const int _tag, _dim, _condNumOrder;
fullVector<double> dPrimBaryShape_dX, dPrimBaryShape_dY, dPrimBaryShape_dZ;
int _nCondNumNodes;
int _nMapNodes, _nPrimMapNodes;
public:
CondNumBasis(int tag, int cnOrder = -1);
inline int getCondNumOrder() const { return _condNumOrder; }
inline int getNumCondNumNodes() const { return _nCondNumNodes; }
static int condNumOrder(int tag);
static int condNumOrder(int parentType, int order);
inline void getInvCondNum(const fullMatrix<double> &nodesXYZ,
fullVector<double> &invCond) const { /* ... */ }
inline void getSignedInvCondNum(const fullMatrix<double> &nodesXYZ,
const fullMatrix<double> &normals,
fullVector<double> &invCond) const { /* ... */ }
inline void getInvCondNumAndGradients(const fullMatrix<double> &nodesXYZ,
fullMatrix<double> &IDI) const { /* ... */ }
};
它的核心量是逆条件数(inverse condition number) : κ−1 越接近 1 单元越好, 越接近 0 越畸形。getInvCondNumAndGradients 同时给出梯度 ------这正是网格优化 所要的: meshGFaceOptimize/meshGRegionOptimize(第 5 章)把"最大化逆条件数"作为目标函数,用 LBFGS 无约束最小化,需要解析梯度。注意工厂键是 (tag, cnOrder) 而 cnOrder 默认 -1 ------即每次"条件数多项式"的阶可以通过 condNumOrder 覆盖,因为优化时用的阶不必等于单元阶。
8.6.7 分层基、正交基与其它特殊基
除上述"主链路"外,BasisFactory 背后还挂着一批专用基(多数通过 getNodalBasis 按 tag 返回 的具体类实现):
| 基类 | 文件 | 特点与用途 |
|---|---|---|
HierarchicalBasisH1* |
HierarchicalBasisH1{Line,Tria,Quad,Tetra,Brick,Pri,Point}.h |
分层(hierarchical)H1 基 ,按阶分层:升阶时旧形函数不变, p-自适应友好 |
HierarchicalBasisHcurl* |
HierarchicalBasisHcurl{Line,Tria,Quad,Tetra,Brick,Pri}.h |
H(curl) 分层基,保证切向连续,用于电磁/边元 |
incompleteBasis |
src/numeric/incompleteBasis.h | Serendipity(不完备族)实现 |
orthogonalBasis |
src/numeric/orthogonalBasis.h | 正交基(对角质量矩阵,谱方法/显式时间积分友好) |
miniBasis |
src/numeric/miniBasis.h | MINI 单元(气泡增广) |
BergotBasis |
src/numeric/BergotBasis.h | 金字塔正交型基函数(8.6.3) |
分层基的接口(以 HierarchicalBasisH1Line 为例,src/numeric/HierarchicalBasisH1Line.h) 把形函数按"附着实体"分类成四个向量:
cpp
// vertexBasis=[v0,v1]
// edgeBasis=[phie_{2},...,phie_{pe}]
virtual void generateBasis(double const &u, double const &v, double const &w,
std::vector<double> &vertexBasis,
std::vector<double> &edgeBasis,
std::vector<double> &faceBasis,
std::vector<double> &bubbleBasis);
顶点 / 边 / 面 / 气泡(bubble)四类 :顶点基是线性"帽函数";边基是从 2 阶起的一维分层 函数;面基是面内部的二维分层函数;气泡基是体内部函数。"分层"的深层意义 :低阶空间的形 函数是高阶空间形函数的子集 (不像拉格朗日基那样所有函数都随阶改变),因此做 p-自适应时 只需追加 新函数,不必重算旧函数、也不破坏已有自由度------这是谱元与 hp-FEM 的核心诉求。 文件注释引用了 Solin/Segeth/Dolezel 的经典教材。orientEdge/orientFace/orientOneFace 等接口则负责把不同朝向(相邻单元共享边/面时方向可能相反)的分层函数对齐------与 8.6.2 里 closures 解决的问题本质相同。
incompleteBasis(src/numeric/incompleteBasis.h)从 完备基"派生"出 Serendipity 基:
cpp
class incompleteBasis : public nodalBasis {
private:
const nodalBasis *completeBasis;
const polynomialBasis *polyBasis;
fullMatrix<double> coefficients;
public:
incompleteBasis(int tag);
virtual void f(double u, double v, double w, double *sf) const;
// ...
private:
void _computeCoefficientsTriangle();
};
它持有 completeBasis 与 polyBasis 两个引用,通过 _computeCoefficientsTriangle 把完备 基"投影/裁剪"成不完备基------这是"同一父类型、两族别"在实现层面的又一次呼应。
8.7 鲁棒几何谓词
8.7.1 为什么浮点符号会出错
三维网格的正确性高度依赖几何谓词的符号正确性 。考虑"点 D 是否在三角形 ABC 的上方" 这样的判断------它等价于一个有向体积( 4×4 行列式)的符号:
orient3d(A,B,C,D)=det AxAyAzDxDyDz111 (示意)
用普通 double 计算这个行列式,会遇到两个致命问题:
- 舍入误差 :当四点近乎共面时,真值可能只比机器精度大一点,计算结果可能符号翻转 (本该是 + 却算成 −)。
- 相消(cancellation):行列式展开中大量项相减,有效位数急剧损失。
Delaunay 三角化、面的方向判定、退化单元检测全都建立在这种符号判断上。一次符号错误可能 导致网格出现非流形、翻转或死循环。所以"不能只用 double"不是洁癖,而是正确性的硬需求。
8.7.2 接口:Shewchuk 的四个谓词
src/numeric/robustPredicates.h 封装了 Jonathan Shewchuk 的精确谓词(GPL 的 predicates.c 的 C++ 移植版):
cpp
// namespace necessary to avoid conflicts with predicates used by Tetgen
namespace robustPredicates {
void exactinit(double maxx, double maxy, double maxz);
// static filters of orient3d() and insphere(), set by exactinit(): a plain
// floating-point determinant larger than them in magnitude has the right sign
extern double o3dstaticfilter, ispstaticfilter;
double insphere(
const double* const __restrict__ pa,
const double* const __restrict__ pb,
const double* const __restrict__ pc,
const double* const __restrict__ pd,
const double* const __restrict__ pe);
double orient3d(
const double* const __restrict__ pa,
const double* const __restrict__ pb,
const double* const __restrict__ pc,
const double* const __restrict__ pd);
double incircle(
const double* const __restrict__ pa,
const double* const __restrict__ pb,
const double* const __restrict__ pc,
const double* const __restrict__ pd);
double orient2d(
const double* const __restrict__ pa,
const double* const __restrict__ pb,
const double* const __restrict__ pc);
} // namespace robustPredicates
四个谓词各自的几何含义:
| 谓词 | 点数 | 数学含义 | 典型用途 |
|---|---|---|---|
orient2d |
3 | 有向面积的两倍(点 C 在 AB 左/右/共线) | 二维 Delaunay、三角形朝向 |
orient3d |
4 | 4×4 有向体积(点 D 在 △ABC 上/下/共面) | 三维 Delaunay、四面体朝向、退化判定 |
incircle |
4 | 点 D 是否在 △ABC 外接圆内 | 二维 Delaunay 的"空圆"判据 |
insphere |
5 | 点 E 是否在四面体 ABCD 外接球内 | 三维 Delaunay 的"空球"判据 |
orient3d 与 insphere 正是 Bowyer-Watson / 增量 Delaunay 的核心判据 (第 6 章):空球判据决定"新点是否与该四面体的外接球冲突"。而 orient3d 还负责判断四面体 体积的符号(是否翻转)。orient2d/incircle 是二维对应版本。
8.7.3 实现要点:自适应精度 + 静态滤波器
Shewchuk 谓词的实现基于自适应精度的浮点展开(adaptive floating-point expansions) : 先用普通浮点计算(快速路径),若结果的绝对值小于一个保守的错误界(落在"不确定区间"内), 就用更高精度(用一串浮点数精确表示一个实数的 expansion 算术)重算,直到符号确定;符号一旦 确定就立即返回。精度逐级提升,代价也逐级提升,直到符号可靠。
exactinit 根据输入坐标范围预计算静态滤波器:
cpp
// static filters of orient3d() and insphere(), set by exactinit(): a plain
// floating-point determinant larger than them in magnitude has the right sign
extern double o3dstaticfilter, ispstaticfilter;
"静态滤波器"就是快速路径的判据 :一个普通 浮点行列式只要量级超过 o3dstaticfilter(或 ispstaticfilter),其符号就一定是正确的,无需走自适应高精度 路径。这就是**"约定俗成的正确性测试 + 昂贵的精确兜底"**(filtered exact arithmetic)的 标准范式。
注意 exactinit(maxx, maxy, maxz) 需要坐标范围------因为这些滤波器常数是关于坐标量级的 相对误差界 (误差随坐标绝对大小增长)。所以每当网格的坐标范围发生显著变化(例如读入新模型、 变换坐标系),就需要重新 exactinit。这也解释了为什么 exactinit 是显式的初始化调用 , 而不是在 orient3d 内部隐式完成------滤波器必须拿全体的坐标量级来算,单次调用拿不到。
__restrict__ 限定符(以及文件开头对 MSVC 的 #define 兜底)告诉编译器这几个指针互不重叠, 让编译器能更激进地优化/向量化精确算术------这在被调用百万次的谓词上是实打实的收益。
8.7.4 命名空间:与 TetGen 的冲突
文件第一行注释直截了当:
cpp
// namespace necessary to avoid conflicts with predicates used by Tetgen
namespace robustPredicates {
第 6 章讲过,Gmsh 在四面体网格化时调用 / 借鉴 TetGen;TetGen 自带一套同名 的 orient3d/insphere/exactinit。若不用命名空间隔开,链接期会撞名。这是"把第三方算法 内嵌进主程序"时最常见的卫生问题,注释把它记下来,是很好的工程习惯。
8.7.5 与第 5/6 章网格算法的呼应
把本节与第 5、6 章串起来看,就明白为什么 Gmsh 能兼顾速度与正确性:
- Delaunay 三角化(第 6 章) :
incircle/insphere的空外接球判据若符号出错,会构造出 "非法"四面体(其外接球含其他点),进而破坏 Delaunay 性质,甚至在翻边时进入死循环。 精确谓词是增量 Delaunay 正确性的根基。 - 面的方向与法向一致性(第 5 章) :面片朝向(
GFace::normal、单元orientation)的 判定,本质是orient3d的符号;符号错了会导致法向翻转、边界层拉伸方向错误。 - 退化与共面检测 :
orient3d≈0表示共面,是"退化单元(零体积)""是否要塌陷"的判据。 用普通浮点判定近乎共面几乎必然误判,精确谓词给出可靠的三值结果(正/零/负)。
一句话:鲁棒谓词是离散几何算法的"浮点地基"------它把"符号正确"变成可依赖的契约,上层 算法才敢在此之上做组合决策。
需要澄清的是:精确谓词只保证"符号正确",不保证"数值精确" 。需要高精度数值时(如外接球心 坐标、插值系数),Gmsh 用别的手段(稳健求解、退化情形用中垂面/重心近似)。谓词管拓扑决策 (符号),数值管几何位置(量值)------精确谓词只在需要离散决策处启用,这是性能与正确性的平衡。
8.8 表达式求值器
8.8.1 接口与编译开关
src/numeric/mathEvaluator.h 是一个独立的数学表达式求值 器接口,服务于尺寸场 MathEval、插件参数、后处理公式等"运行期字符串表达式"。它有两条 编译分支:
cpp
#if defined(HAVE_MATHEX)
#include "mathex.h"
class mathEvaluator {
private:
std::vector<smlib::mathex *> _expressions;
std::vector<double> _variables;
public:
// initialize one or more expressions depending on zero or more variables.
// If an error occurs the vector of expressions is cleared.
mathEvaluator(std::vector<std::string> &expressions,
const std::vector<std::string> &variables);
~mathEvaluator();
// evaluate the expression(s) using the given values and fill the result
// vector. Returns true if the evaluation succeeded. Several threads can
// evaluate at once.
bool eval(const std::vector<double> &values, std::vector<double> &res) const;
};
#else // 无 MathEx 时的退化实现:报错 + expressions.clear(),eval 恒返回 false
class mathEvaluator {
public:
mathEvaluator(std::vector<std::string> &expressions,
const std::vector<std::string> &variables)
{ Msg::Error("Gmsh must be compiled with MathEx support to evaluate math "
"expressions"); expressions.clear(); }
bool eval(const std::vector<double> &values, std::vector<double> &res) const
{ return false; }
};
#endif
两条分支的"接口形状完全一致" :都用 HAVE_MATHEX 控制实现有无,缺实现时退化为"报错 + 返回空"。这是 Gmsh 处理可选依赖 的通用模式(第 2 章 OCC、后文的 METIS 等都同理)------对外 接口稳定,对内按编译选项切换。注意 eval 是 const 且注释强调"若干线程可同时求值", 这一点在 8.8.5 展开。
8.8.2 底层:MathEx 的 lexer / parser
真正的求值器是 contrib/MathEx/mathex.h / mathex.cpp, 一个轻量、无外部依赖的表达式求值库(作者 Sadao Massago,LGPL)。它采用**"词法 → 语法 → 字节码 → 求值栈"**的经典结构。核心是 smlib::mathex 类里的两个 token 类型:
cpp
class mathex {
// code token used by evaluator
class CODETOKEN {
public:
enum type { VALUE=0, VARIABLE, FUNCTION, BINOP, USERFUNC, LASTTYPE=USERFUNC };
// VALUE=数值、VARIABLE=变量、FUNCTION=一元内建函数/一元运算符、
// BINOP=二元运算符、USERFUNC=用户函数
type state; unsigned numargs; double value; unsigned idx;
}; // CODETOKEN
// parse token used by parser
class PARSERTOKEN {
public:
enum type {
VALUE, VARIABLE, FUNCTION, USERFUNC,
PLUS, MINUS, TIMES, DIVIDE, MODULE, POWER,
OPAREN, CPAREN, COMMA, END, INVALID, // invalid character on token
LASTTYPE=INVALID
};
type state; int numargs; double value; int idx; // idx 有符号(未找到为 -1)
}; // parsetoken
// ...
};
两套 token 的分工非常清晰:
PARSERTOKEN是语法分析用 的"原始记号":括号、逗号、运算符、函数名、变量名、数值、 END、INVALID。解析器(parsearithmetic1..4、parseatom)按标准优先级爬升 把它们组织成 语法树------从最低优先级的加减(parsearithmetic1)逐级向上到乘除、乘方、一元负号,最后parseatom处理原子(函数、变量、数字)。CODETOKEN是求值用 的"字节码"记号:要么是在栈上压一个值/变量,要么是应用一个一元函数 或二元运算符(numargs记参数个数)。解析结果被编译成一串CODETOKEN存在bytecode里,求值时用一个小栈顺序执行。
这套"解析一次、多次求值"的设计,正是表达式在循环里被反复求值(典型:对网格的每个节点 求尺寸场值)时的效率来源------不必每次重新解析字符串。
库还维护两张表:
cpp
vector<FUNCREC> functable; // 用户函数表(函数名 + 函数指针 + 参数个数)
vector<VARREC> vartable; // 变量表(变量名 + 指向 double 的指针)
vector<CODETOKEN> bytecode; // 解析后的字节码
vector<double> evalstack; // 求值用的栈内存
string expr; enum {invalid, notparsed, parsed} status; // 表达式与解析状态
FUNCREC 记录"函数名 + 函数指针 + 参数个数",VARREC 记录"变量名 + 指向 double 的指针"。 addvar/addfunc/delvar/delfunc 用于动态注册变量与函数。内置函数由 addstdfunc() 一次性 加入。
8.8.3 为 Gmsh 做的定制
mathex.h 头部专门列出了"Changes made for Gmsh",这是理解 Gmsh 表达式语言的关键:
cpp
// Changes made for Gmsh:
// - every function also has a capitalized name, as in Gmsh's .geo files
// (Sin, Cos, Exp, Sqrt, Fabs, ..., and Sum, Max, Min, Med, Rand)
// - atanh and Atanh (not on Windows)
// - the constant Pi (as well as pi)
// - the comparison operators < and >, which give 1 or 0
// - eval(values, stack) const, which evaluates a parsed expression with the
// values of its variables in the order they were added, on a stack of the
// caller: several threads can evaluate the same expression at once (the
// arguments of user functions are no longer kept in a static vector)
逐条解读:
- 大写函数名 :Gmsh 的
.geo脚本语言惯用Sin/Cos/Exp/Sqrt/Fabs等首字母大写 函数名,所以库在标准小写名之外同时注册大写别名 。Sum/Max/Min/Med/Rand这 几个聚合函数也是 Gmsh 特有的。 Pi与pi都是常数 (原库只有pi)。- 比较运算符
<、>返回 1 或 0:这让表达式能写"分段/条件"逻辑(配合算术运算)。 eval(values, stack) const:这是为多线程做的改造 。原库求值把"用户函数的实参" 存在一个static向量里,多线程会互相踩踏。改造后求值函数是const,把工作栈 (evalstack)交给调用方传入 ,每个线程用自己的栈,就能"一份已解析表达式、多线程 并发求值"。注释最后一句专门为此道歉式说明。
第 4 条是与 8.8.1 里 mathEvaluator::eval 的"若干线程可同时求值"呼应的------两处注释讲的是 同一件事。
8.8.4 变量绑定与"按添加顺序"
mathEvaluator 构造时接收 expressions 与 variables 两个字符串向量。变量向量的顺序 就是求值时 values 向量的顺序:
cpp
bool eval(const std::vector<double> &values, std::vector<double> &res) const;
而 mathex::eval 的签名进一步说明了绑定方式:
cpp
/// eval parsed expression with the values of the variables in the order
/// they were added, on the given stack: several threads can evaluate
/// the same expression at once, each with its own stack
double eval(const double *values, vector<double> &stack) const;
"in the order they were added" 是最关键的约定:表达式的变量表(vartable)顺序,与 调用方传入的 values 数组下标一一对应。Gmsh 侧的 mathEvaluator 只是把这层包装成 "variables[i] ↔ values[i]"的向量语义。这意味着变量名本身在求值时不参与查找 (解析阶段 已经把它们编译成 VARIABLE idx 的字节码),求值只需按下标取值------这是"解析期绑定、求值期 只做算术"的高效设计。
8.8.5 与 MathEval Field、后处理公式的接口
Gmsh 的两个典型使用者:
- 尺寸场
MathEval(第 4 章) :用户用字符串描述网格尺寸,例如"0.1 + 0.05*Sin(x)",变量表是{"x","y","z"}。网格化时对每个点求值,得到局部尺寸。 - 后处理/插件公式 :例如
CutPlane、Plugin(...)的表达式参数,或MathEval型视图。 变量表随上下文不同(可能是坐标x,y,z,也可能是场值u,v等)。
为什么 Gmsh 要自带一个求值器,而不直接用宿主语言? 因为用户配置是数据 (字符串), 不是代码------表达式必须能在运行期 (甚至批处理/编译好的二进制里)解析并求值,且不能依赖 编程语言的 eval。MathEx 正是这样一个"可嵌入、可线程化、无依赖"的小语言实现。
第 3 章讲过 Gmsh 自己的 .geo/.msh 解析器(Gmsh.y)也用了一套表达式求值------但那是 编译期 绑定到语义动作(语法树在解析命令时就求值)。两个求值器互补 :Gmsh.y 处理 "命令语法里的算式",MathEx 处理"被当作数据存下来的运行期表达式"。
8.9 计算拓扑:同调、上同调与胞腔复形
8.9.1 为什么网格库需要计算拓扑
这是 Gmsh 里最"理论"的一块能力,代码位于 src/geo/ 与 src/plugin/。 背景是:在电磁、多连通域的有限元中,需要计算域的同调群/上同调群的基(generators), 用来构造"割面(cuts)"或处理非单连通区域。而在网格工程上,同调直接回答几个朴素而重要的 问题:
- 这个网格有几个连通分量 (0 维 Betti 数 b0)?
- 有几个独立的环 / 洞 (1 维 Betti 数 b1)?
- 有几个封闭空腔 (2 维 Betti 数 b2)?
- 是否存在非流形结构(同调会"看见"它)?
欧拉示性数 χ=b0−b1+b2−⋯ 也可以直接从胞腔计数算出。这套能力把"网格的拓扑 正确性"变成可计算的量。
8.9.2 Chain:带整数系数的胞腔线性组合
最底层的数据结构是链(src/geo/Chain.h)。链 = 一组"基本胞腔 + 整数 系数"的形式和。基本胞腔由网格单元(或其子面/子边/子点)构造:
ElemChain(派生自 PosetCat<ElemChain>,从而可排序、可比较)用顶点集合 + 维数 唯一标识 一个胞腔------核心成员是 _dim、_v(顶点指针)与 _si(排序后的顶点下标,用于比较朝向), 关键方法有 getTypeMSH()、createMeshElement()、compareOrientation(c2)、getNumBoundaryElemChains() 与 getBoundaryElemChain(i)。 compareOrientation 处理"同一胞腔但顶点顺序不同 ⇒ 朝向符号不同"这件事------这是有向链的 关键 : ∂ 算子带着 ±1 的朝向符号。
Chain<C> 模板把基本链组成真正的"链":
cpp
template <class C> class Chain : public VectorSpaceCat<Chain<C>, C> {
private:
int _dim; // Dimension of the chain
std::map<ElemChain, C> _elemChains; // Elementary chains and their coefficients
std::string _name;
public:
Chain(GModel *m, int physicalGroup); // 从物理组构造
void addMeshElement(MElement *e, C coeff = 1);
void addElemChain(const ElemChain &c, C coeff = 1);
Chain<C> &operator+=(const Chain<C> &chain); // 向量空间加法
Chain<C> &operator*=(const C &coeff);
C getCoefficient(const ElemChain &c2) const;
Chain<C> getBoundary() const; // 边界链
Chain<C> getTrace(GModel *m, int physicalGroup) const;
Chain<C> getProject(GModel *m, int physicalGroup) const;
int addToModel(GModel *m, bool post = true, int physicalNumRequest = -1) const;
};
Chain<C> 继承 VectorSpaceCat(向量空间范畴),因此支持 +、 ∗。边界算子定义得极为 简洁(同文件):
cpp
template <class C> Chain<C> boundary(const ElemChain &c)
{
Chain<C> result;
for(int i = 0; i < c.getNumBoundaryElemChains(); i++) {
C coeff = 1;
if(c.getDim() == 1 && i == 0) coeff = -1; // 线段:起点带 -1
result.addElemChain(c.getBoundaryElemChain(i), coeff);
}
return result;
}
为什么线段起点系数是 −1? 因为"1 维胞腔的边界 = 终点 − 起点",这保证了 ∂∂=0(边界的边界为零)------链复形的根本性质 。同调群正是 ker∂k/im∂k+1。
Chain::addToModel 把一条链变回 Gmsh 模型里的物理组与后处理视图(第 6 章讲的"割面"就 是这样落地为可视对象)。
8.9.3 CellComplex:从网格到胞腔复形
src/geo/CellComplex.h 把网格"抽成"胞腔复形,并建立 边界/共边界(boundary/coboundary)邻接:
cpp
class CellComplex {
private:
// sorted (by CellPtrLessThan) containers of unique cells, one per dimension.
// Removing a cell only clears its in-complex flag and leaves a tombstone
// entry behind, so removals during the reductions are constant time;
// tombstones are skipped when iterating and reclaimed by _compactIfNeeded()
std::vector<Cell *> _cells[4]; int _numLiveCells[4];
std::vector<Cell *> _ocells[4]; // original cells (always all live)
int _dim; bool _simplicial, _saveorig, _relative, _reduced, _periodic;
public:
CellComplex(GModel *model, std::vector<MElement *> &domainElements,
std::vector<MElement *> &subdomainElements, std::vector<MElement *> &nondomainElements,
std::vector<MElement *> &nonsubdomainElements, std::vector<MElement *> &immuneElements,
bool saveOriginalComplex = true);
int reduceComplex(int combine = 1, bool omit = true, bool homseq = false);
int coreduceComplex(int combine = 1, bool omit = true, int heuristic = 0);
void bettiReduceComplex();
int eulerCharacteristic() { return getSize(0)-getSize(1)+getSize(2)-getSize(3); }
void periodicComplex(const std::set<MVertex *> &slave, const std::set<MVertex *> &master);
bool relabel(std::vector<MElement *> &subdomainElements, std::vector<MElement *> &immuneElements);
};
几个设计要点:
- 胞腔按维数分四组存 :
_cells[0..3]分别是点/边/面/体,每维一份。这就是"复形"的 数组化表达。 tombstone(墓碑)机制 :删除一个胞腔不真的从数组里抽走,只清inComplex()标志, 留一个"墓碑";迭代时跳过墓碑,必要时_compactIfNeeded回收。为什么要这样? 因为 同调约简(reduction)过程会大量删除胞腔,如果每次都移动数组,成本 O(n);墓碑让删除变成 O(1)。reduceComplex/coreduceComplex:胞腔复形的约简 ------消除可缩的(contractible) 胞腔对,把大规模复形化简到只剩"拓扑非平凡部分"。这是一个核心加速步骤:拓扑等价但规模 小得多的复形上再算同调,代价从整网格规模降到"洞数"规模。_relative/_periodic:支持相对同调 (相对一个子域)与周期同调 (把周期等价的 胞腔粘合成商空间)。periodicComplex把 slave/master 节点上的胞腔两两合并,正是"周期性 边界条件"的拓扑版本。_simplicial:单元是否单纯形。四面体网格是单纯复形,六面体网格不是------这句话决定了 约简算法能否用单纯形的简化理论(同调对一般胞腔复形仍然成立,只是约简策略不同)。eulerCharacteristic()直接由各维胞腔计数给出 χ=∑(−1)knk,是最简单的一个 拓扑不变量。
8.9.4 ChainComplex:边界算子与同调基
src/geo/ChainComplex.h 在胞腔复形之上构造链复形, 把边界算子写成整数矩阵:
cpp
// This should only be constructed for a reduced cell complex because of
// dense matrix representations and great computational complexity in its methods.
class ChainComplex {
// boundary operator matrices h_k: C_k -> C_(k-1); empty means "no matrix"
IntegerMatrix _hMatrix[5];
// basis for kernels/codomains of the boundary operators, and the mappings
// B_k -> Z_k (_jMatrix) and H_k -> Z_k (_qMatrix)
IntegerMatrix _kerH[5], _codH[5], _jMatrix[5], _qMatrix[5];
IntegerMatrix _hbasis[5]; // bases for homology groups
std::vector<long int> _torsion[5]; // torsion coefficients (per column of _hbasis)
public:
ChainComplex(CellComplex *cellComplex, int domain = 0);
IntegerMatrix *getBasis(int dim, int basis); // 1: Z(cycles) 2: B(boundaries) 3: H(homology)
void KerCod(int dim); void Inclusion(int lowDim, int highDim);
void Quotient(int dim, int setDim); void computeHomology(bool dual = false);
int getTorsion(int dim, int num);
};
数学对应关系一望便知:
_hMatrix[k]是边界算子 ∂k:Ck→Ck−1 的矩阵。KerCod(k)分别算 ker∂k(闭链群 Zk)与 cod∂k。Inclusion构造 Bk→Zk 的映射 j(边界是特殊闭链)。Quotient做商 Hk=ker∂k/im∂k+1,得到同调群的基 与 挠率系数 (_torsion)。computeHomology(dual=false)走原复形;dual=true走对偶复形 (转置边界矩阵)得到 上同调------这正是同调与上同调共用一套代码的优雅之处:
cpp
// transpose the boundary operator matrices, these are boundary operator
// matrices for the dual mesh
void transposeHMatrices();
void transposeHMatrix(int dim);
getBasis(dim, basis) 用 1/2/3 区分闭链基 / 边界基 / 同调基------把三种空间的基统一到 一个查询接口 (第 1 章讲过 getHomologyBasis/getCohomologyBasis 就是它的上层)。
8.9.5 SmithNormalForm:整数精确线性代数
同调计算的核心难点是整数矩阵的化简 。Gmsh 自己实现了一个自包含的精确整数线代模块 src/geo/SmithNormalForm.h。文件头注释说明了它的来历与 取舍:
cpp
// Exact integer linear algebra for the homology solver (ChainComplex): dense
// matrices of 64-bit integers, with Hermite and Smith normal forms and their
// unimodular transformation matrices. Replaces the parts of the 'kbipack'
// library that Gmsh actually used: Hermite uses Kannan-Bachem [1] with the
// Chou-Collins reduction [2]; Smith diagonalizes on the shrinking Schur
// complement with extended-gcd rotations. The homology matrices are totally
// unimodular (or nearly so) and all intermediate quantities stay tiny, so
// native 64-bit integers suffice; every risky operation is range-checked and
// throws std::overflow_error, so no overflow can go undetected.
这段注释本身就是一篇小论文,要点:
- 替换 kbipack:原来用外部库 kbipack,其任意精度整数(GMP)的"每操作开销"主导了 计算时间。
- 守恒的数值假设 :胞腔复形的边界矩阵是完全幺模(totally unimodular)或近乎如此 , 中间量极小------所以用 原生 64 位整数 就够,不必上任意精度。
- 溢出保护 :每个可能溢出的操作都做范围检查,抛出
std::overflow_error------宁可报错, 也不静默出错。源码里NormalFormDetail::addMul/mulMul就是这层保护;其中__int128存在与否决定检查方式(GCC/Clang 用宽乘、MSVC 用分支判断),是跨编译器的性能工程。
核心算法是两个标准型:HermiteForm hermiteNormalForm(IntegerMatrix A)(Kannan-Bachem,返回 canonical 下三角 + 行置换 + 右逆)与 SmithForm smithNormalForm(IntegerMatrix A, bool invertedFactors)(返回 canonical 对角阵 + 左右幺模因子,均由 ElementaryOpLog 记录)。
Smith 标准型是"整数的对角化" :通过幺模(行列式 ±1)的行列变换把整数矩阵 A 变成 对角阵 S,对角元是不变因子(invariant factors) ,每个整除下一个。对角元为 1 的部分 对应同调群的自由部分( Zb, b 是 Betti 数);大于 1 的部分就是挠率 ( Z/dZ) 。所以"读 Betti 数与挠率"就是把 Smith 标准型的对角元数出来。 这正是 ChainComplex::Quotient 背后的引擎。
ElementaryOpLog 是这段代码里最巧的设计,它不构造变换矩阵,只记录变换步骤 ( AddMultiple/Rotate/Swap/Negate 四类初等操作):因为调用方(ChainComplex)之后只需 这个矩阵的少数几列 (核基常常只有一列),而矩阵本身是 n×n 的(内存开销大)。记录 操作序列后,想知道某列就从单位向量"回放"一次即可(columns(first,last)),乘积也可直接 applyLeft(M) 而不落地整矩阵。这是**"延迟求值 / 流式算子"**在数值代数里的漂亮应用。
8.9.6 Homology:面向模型的接口
把上述纯数学工具包装成用户可用能力的是 Homology (src/geo/Homology.h):
cpp
class Homology {
GModel *_model;
std::vector<int> _domain, _subdomain, _nondomain, _nonsubdomain, _imdomain;
bool _saveOrig, _omit, _smoothen, _periodic;
int _combine, _heuristic;
std::vector<int> _periodicSlave, _periodicMaster;
CellComplex *_cellComplex;
bool _homologyComputed[4], _cohomologyComputed[4];
int _betti[4];
std::vector<Chain<int> *> _chains[4], _cochains[4];
void _createCellComplex();
void _periodicCellComplex();
public:
Homology(GModel *model, const std::vector<int> &physicalDomain,
const std::vector<int> &physicalSubdomain, const std::vector<int> &physicalIm,
bool saveOrig = true, int combine = 3, bool omit = true,
bool smoothen = true, int heuristic = 1);
void setPeriodic(bool periodic, const std::vector<int> &slave = {},
const std::vector<int> &master = {});
void findHomologyBasis(std::vector<int> dim = vecN0(4));
void findCohomologyBasis(std::vector<int> dim = vecN0(4));
void findCompatibleBasisPair(int master = 0, std::vector<int> dim = vecN0(4));
void findBettiNumbers();
int betti(int dim); int eulerCharacteristic();
std::vector<int> addChainsToModel(int dim = -1, bool post = true, int physNum = -1) const;
std::vector<int> addCochainsToModel(int dim = -1, bool post = true, int physNum = -1) const;
void getHomologyBasis(int dim, std::vector<Chain<int>> &hom);
void getCohomologyBasis(int dim, std::vector<Chain<int>> &coh);
bool writeBasisMSH(bool binary = false);
};
它围绕一套纯数学的数据结构运转:_cellComplex 建胞腔复形(_createCellComplex,可按 _domain/_subdomain 限定域与相对子域)→ 可选 _periodicCellComplex 把周期等价胞腔粘合 → 用 Smith 标准型求同调/上共调基 → _createChain 把结果链存起来 → addChainsToModel 把生成元 (如"环绕一个孔的环链")作为物理组/后处理视图加回模型。_betti[4] 与 _chains[4]/ _cochains[4] 分别存 0--3 维的 Betti 数与链。
findCompatibleBasisPair 值得一提:要一对同调/上同调基,使它们的关联矩阵是单位阵。这在 电磁里就是"割面与环的配对",是保证 curl-conforming 基能正确张成上同调类的关键。
Homology 的三个构造函数参数揭示了约简的工程手段:combine(合并相邻简单胞腔为组合胞腔, 缩小矩阵) 、omit(省略最高维胞腔,把复形收缩一次,省下大量配对) 、heuristic (决定在最大元还是最小元里省略 0 维胞腔) 。默认值 combine=3, omit=true, smoothen=true, heuristic=1 反映了"默认走性能优先路径"的取向。
8.9.8 Betti 数与欧拉示性数
findBettiNumbers() 有专门的快速路径(注释:"faster than finding bases for (co)homology spaces")------它只需要 Betti 数,不需要具体的基链,因此可以调用 CellComplex::bettiReduceComplex() 做更轻量的约简。Betti 数 bk 与欧拉示性数的关系:
χ=∑k=0n(−1)kbk.
对闭合曲面, χ=2−2g( g 是亏格);对多连通区域, b0 就是连通分量数。Gmsh 的 betti(dim) 与 eulerCharacteristic() 直接暴露这两个量。
8.9.9 缓存与模型集成
Gmsh 的 GModel 为此准备了 _homologyRequests(src/geo/GModel.h 第 56 行)与 storeChain(第 818 行),让同调结果能作为一等公民存进模型。同调计算是昂贵 的(Smith 标准型可能是唯一的性能瓶颈),所以 _homologyComputed[4]/_cohomologyComputed[4] 标志避免重复计算 ,_saveOrig 保留原始(未约简)复形以便"换个相对子域重算"时不必从零 重建(配合 CellComplex::relabel)。
工程上这套能力配合后处理插件 src/plugin/HomologyComputation.cpp 暴露给脚本:
cpp
class GMSH_HomologyComputationPlugin : public GMSH_PostPlugin {
public:
std::string getName() const { return "HomologyComputation"; }
std::string getShortHelp() const { return "Compute relative (co)homology spaces"; }
std::string getAuthor() const { return "M. Pellikka"; }
PView *execute(PView *);
};
脚本里对应 Homology/Cohomology/Betti 命令(第 3 章 token 表里可见)。同调写回模型时 还会调用 writeBasisMSH 输出"生成元网格",以及 HomologyPostProcessing 把它可视化成 环/割面。
8.10 与高阶有限元的接口:为何网格库需要基函数
综合全章,可以回答一个常被问到的问题:Gmsh 是个网格生成器,为什么它内部要养一整套有限元 基函数与雅可比? 因为网格库在四个地方离不开它们:(1) 质量度量 ------雅可比行列式 (JacobianBasis)与条件数(CondNumBasis)是判断"单元是否畸形/翻转"的原始量 (AnalyseMeshQuality 插件即其消费者);(2) 网格优化 ------meshGFaceOptimize/meshGRegionOptimize 以"最大化逆条件数"为目标,getInvCondNumAndGradients 的解析梯度喂养 LBFGS;(3) 高阶网格与 曲边 ------需要节点基函数、De Casteljau 细分、理想单元对照(mapFromIdealElement)与内部顶点 放置;(4) 后处理插值与求解器装配 ------都用到 nodalBasis::f/df。
所以 numeric 不是"给外部 FEM 用的库",而是网格工具自身质量的度量衡与手术刀 。这也解释 了为什么 BasisFactory 要用五张缓存表把基函数"算一次、多次复用"。
8.11 常见 Q&A
Q1:金字塔函数空间为什么特殊? 金字塔(四边形底 + 顶点)既非单纯形也非张量积,横向截面随高度线性收缩到 0,普通单项式在顶点 处产生 0/0 奇性。Gmsh 用有理单项式 X=ξ/(1−ζ),Y=η/(1−ζ),Z=1−ζ,并分两族 空间(金字塔型 i,j≤k+nij、六面体型 i,j≤nij);因此空间不能只由阶数确定,必须 额外给 (nij,nk)------这就是 FuncSpaceData 存在、pyramidalBasis/BergotBasis 与普通 polynomialBasis 分家的原因。
Q2:贝塞尔基为何比拉格朗日基更适合高阶? 因为正性判定 。贝塞尔控制点具有凸包性质(控制点全正 ⇒ 函数处处为正),而拉格 朗日节点系数无此保证(节点间会振荡,Runge 现象)。检查控制点符号既充分又无需逐点采样 , 再配自适应细分(bezierCoeff::subdivide)即正确又高效。
Q3:鲁棒谓词为何不能只用 double? 当配置近乎退化(点近乎共面/共圆)时, double 行列式真值可能只比机器精度大一点点,舍入与 相消会让符号翻转 ;而 Delaunay 空球判据与朝向判定都是纯符号决策 ,一次错误就产生非法单元、 非流形甚至死循环。Shewchuk 的自适应谓词用"快速浮点 + 静态滤波器 + 高精度兜底"保证符号永远 正确------double 能算准大概,算不准符号。
Q4:同调在网格生成里的作用? 同调/上同调计算拓扑不变量 (Betti 数、挠率、欧拉示性数),用于:(a) 检测非流形、洞与连通 分量;(b) 在电磁等非单连通域 里构造割面与环链(generate cuts);(c) 通过插件把生成元写回 模型。技术上依靠胞腔复形 + 边界算子矩阵 + Smith 标准型,并用约简把大规模网格化简到只剩 拓扑非平凡部分。
Q5:为什么积分点生成是独立模块而非硬编码表? 因为高阶( p≥10)的全张量积分点会爆炸,Solin 表又只覆盖有限阶。Gmsh 采用"低阶硬编码表 + 高阶按需计算(折叠张量积 / Duffy / Jacobi)+ 惰性缓存"的混合策略。
Q6:ElementType 与 FuncSpaceData 到底谁决定谁? 后者由前者构造 :MSH 编号 →(ElementType)→ 父类型+阶数+族别 →(FuncSpaceData)→ 空间 键 →(BasisFactory)→ 具体基函数/积分/雅可比。
8.12 本章小结
数值内核的八个要点:
- 三元组寻址 :父类型 + 阶数 + 族别(
ElementType/FuncSpaceData)决定积分与基函数; 金字塔用两族有理单项式空间特判( i,j≤k+nij vs i,j≤nij)。 - 积分按需生成 :
getGQ*Pts+gaussIntegration,区分张量积与更省点的单纯形规则,支持 Gauss-Legendre、Gauss-Jacobi (1−w)2、折叠张量积(quadToTri/brickToTet)、Duffy 金字塔 规则;Solin 低阶表与运行期计算混合、惰性缓存。 - 点与单项式同序 :
polynomialBasis把节点位置转成 Vandermonde 逆(形函数系数);Serendipity 是"去掉内部点",内部点靠递归剥离。 - 基函数五类 + 三层导 :nodal/polynomial、分层 H1/H(curl)、正交/迷你/Bergot、贝塞尔、 条件数与雅可比基函数,全部由
BasisFactory五张map缓存;贝塞尔让正性判定退化为控制点 符号检查 + 自适应细分。 - 鲁棒谓词 = 快速路径 + 精确兜底 :Shewchuk 自适应精度谓词 +
exactinit静态滤波器,是 Delaunay 正确性的基石,且用命名空间与 TetGen 隔开。 - 表达式求值器 :
mathex的"解析一次、多线程多次求值"设计,为大写函数名、Pi、比较运算符 做了 Gmsh 定制,服务MathEval尺寸场与插件/后处理公式。 - 计算拓扑是一等能力 :
Chain→CellComplex→ChainComplex(边界矩阵 ∂k)→SmithNormalForm(Smith 标准型读 Betti 与挠率)→Homology,并通过插件暴露给脚本。 - 网格库需要基函数 :质量度量、网格优化、高阶曲边、后处理插值、求解器装配全都依赖
numeric------它是 Gmsh 从"网格生成器"迈向"有限元前处理器"的技术底座。
关键词 / 索引
- 单元类型 :
ElementType、MSH_*、TYPE_*、getParentType/getOrder/getDimension/getNumVertices/getSerendipity/getType/getPrimaryType、Serendipity(*I)、edges_tri/faces_*、getParent/getNumChildren、TYPE_TRIH/TYPE_POLYG/TYPE_POLYH/TYPE_XFEM/TYPE_MINI - 函数空间 :
FuncSpaceData、pyramidalSpace、nij/nk、getOrderForBezier、getForNonSerendipitySpace、有理单项式 X=ξ/(1−ζ) - 数值积分 :
IntPt、getGQ*Pts、forceTensorRule、GaussLegendre1D、GaussJacobi1D、GaussLegendreTri/Tet、quadToTri/brickToTet、Duffy、Solin 表、gaussIntegration - 节点与单项式 :
pointsGenerators、gmshGeneratePoints*、gmshGenerateMonomials*、 有序点、InnerVertexPlacement - 基函数 :
BasisFactory、nodalBasis、polynomialBasis、generateLagrangeMonomialCoefficients、pyramidalBasis/BergotBasis、incompleteBasis、HierarchicalBasisH1*/Hcurl*、orthogonalBasis、miniBasis、bezierBasis/bezierBasisRaiser/bezierCoeff、decasteljau、JacobianBasis、CondNumBasis、GradientBasis、mapFromIdealElement、closures - 鲁棒谓词 :
robustPredicates、orient2d/orient3d/incircle/insphere、exactinit、o3dstaticfilter/ispstaticfilter - 表达式求值 :
mathEvaluator、HAVE_MATHEX、smlib::mathex、CODETOKEN/PARSERTOKEN、Pi、eval(values, stack) const - 计算拓扑 :
Chain/ElemChain/boundary、CellComplex、ChainComplex(_hMatrix、KerCod/Inclusion/Quotient、对偶)、SmithNormalForm(IntegerMatrix、hermiteNormalForm、smithNormalForm、ElementaryOpLog)、Homology、HomologyComputation、GModel::storeChain
下一章转向后处理与插件体系,看结果数据如何组织、可视化与扩展。