Gmsh 源码深度解析-第 8 章 数值内核与高阶有限元

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 n_{ij}, n_k 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;
  }
}

值得琢磨的细节:

  1. 零阶单元也在列 。MSH_LIN_1、MSH_TRI_1、MSH_QUA_1、MSH_TET_1、MSH_HEX_1、 MSH_PYR_1 和 MSH_PRI_1 分别是各形状的"只有一个节点"退化单元。它们不是笔误,而是 为连续性/插值场景保留的占位类型------例如某些降阶表示需要一个"纯点"三角形。
  2. I 后缀 = Incomplete(Serendipity) 。枚举里出现了 MSH_TRI_15I、MSH_TRI_21I、 MSH_QUA_16I、MSH_QUA_36I------I 表示"不完备族 ",即只保留角点与边点、去掉内部点的 Serendipity 单元。这就解释了为什么后面需要单独的 getSerendipity 查询:同一个阶数可能对应 完备族与不完备族两种节点数。
  3. _SUB 家族 = XFEM 子单元 。扩展有限元(XFEM)需要把标准单元切分成子单元以便积分不连续 场,故有 MSH_PNT_SUB/MSH_LIN_SUB/MSH_TRI_SUB/MSH_TET_SUB,它们统一归到 TYPE_XFEM。
  4. MINI 家族 。MSH_TRI_MINI/MSH_TET_MINI 是 "MINI 单元"(气泡加权的低阶单元),归到 TYPE_MINI。

8.2.3 阶数、维度、节点数:三张表

ElementType 提供三张"静态表"式的查询函数。getOrder (src/numeric/ElementType.cpp)把 MSH 编号映射到阶数 pp 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)/2N=(p+1)(p+2)/2 N=(p+1)(p+2)/2( p=1,2,3,4,5,...p=1,2,3,4,5,\dots p=1,2,3,4,5,... 给出 3,6,10,15,21,...3,6,10,15,21,\dots 3,6,10,15,21,...);Serendipity 三角形的 节点数为 3p3p 3p( p=3,4,5,6,...p=3,4,5,6,\dots p=3,4,5,6,... 给出 9,12,15,18,...9,12,15,18,\dots 9,12,15,18,...)------所以 MSH_TRI_15 与 MSH_TRI_15I 节点数同为 15 却完全不同阶。完备四面体是 N=(p+1)(p+2)(p+3)/6N=(p+1)(p+2)(p+3)/6 N=(p+1)(p+2)(p+3)/6;完备四边形/六面体分别是 (p+1)2(p+1)^2 (p+1)2、 (p+1)3(p+1)^3 (p+1)3;Serendipity 四边形是 4p4p 4p、六面体是 8+12(p−1)+6(p−1)28+12(p-1)+6(p-1)^2 8+12(p−1)+6(p−1)2( p=2p=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+1p+1 p+1 同左(线段无区别)
三角形 (p+1)(p+2)/2(p+1)(p+2)/2 (p+1)(p+2)/2 3p (p≥2)3p\ (p\ge2) 3p (p≥2)
四边形 (p+1)2(p+1)^2 (p+1)2 4p (p≥2)4p\ (p\ge2) 4p (p≥2)
四面体 (p+1)(p+2)(p+3)/6(p+1)(p+2)(p+3)/6 (p+1)(p+2)(p+3)/6 4+ ( p−12 ) ⋅6 4+\tbinom{p-1}{2}\cdot 6 4+(2p−1)⋅6
六面体 (p+1)3(p+1)^3 (p+1)3 8+12(p−1)+6(p−1)28+12(p-1)+6(p-1)^2 8+12(p−1)+6(p−1)2
棱柱 3⋅ (p+1)(p+2)2 3\cdot\tfrac{(p+1)(p+2)}{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 P_2 P2,也可能被当成"只保留边点的 Serendipity 空间";而对金字塔单元来说, 形状合法并不蕴含空间合法------金字塔上的 order 都无法唯一确定空间 ,还需要两个额外整数 nij ,nk n_{ij}, n_k 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 P_p Pp),也不是张量积(不能写成三个一维区间的直积)。Gmsh 的解法是把金字塔 上的单项式定义为有理单项式 :设参考坐标 (ξ,η,ζ)(\xi,\eta,\zeta) (ξ,η,ζ),令

X= ξ1−ζ ,Y= η1−ζ ,Z=1−ζ. X=\frac{\xi}{1-\zeta},\qquad Y=\frac{\eta}{1-\zeta},\qquad Z=1-\zeta. X=1−ζξ,Y=1−ζη,Z=1−ζ.

当 ζ→1\zeta\to1 ζ→1(接近顶点)时,分母 1−ζ→01-\zeta\to0 1−ζ→0, X,YX,Y X,Y 的取值由分子分母的相对阶数 决定, 因此空间必须规定" X,YX,Y X,Y 的幂次上界如何随 ZZ Z 的幂次变化"。注释给出两族:

  • 金字塔型(_pyramidalSpace == true) : {XiYjZk∣i,j≤k+ nij , k≤nk}\{X^iY^jZ^k \mid i,j \le k+n_{ij},\ k \le n_k\} {XiYjZk∣i,j≤k+nij, k≤nk}
  • 六面体型(_pyramidalSpace == false) : {XiYjZk∣i,j≤ nij , k≤nk}\{X^iY^jZ^k \mid i,j \le n_{ij},\ k \le n_k\} {XiYjZk∣i,j≤nij, k≤nk}

两者的区别只在 i,ji,j i,j 的上界是否随 kk k 变化 。金字塔型里上界是 k+ nij k+n_{ij} k+nij,越靠近顶点( kk k 越大)允许的横向幂次越高------这恰恰抵消了 X,YX,Y X,Y 分母趋零带来的奇性,使得空间在顶点附近仍然 "表现良好"。六面体型则把上界固定为常数 nij n_{ij} nij,是直接用 X,YX,Y X,Y 描述一个"退化六面体"的做法。

这个区别正是 FuncSpaceData 之所以把 _spaceOrder 单独标注"For pyramids, '_spaceOrder' is not used "的原因:对金字塔而言,空间的"大小"由 ( nij ,nk) (n_{ij},n_k) (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 = n_{ij}+n_k =nij+nk,六面体型空间阶数 =max⁡( nij ,nk) = \max(n_{ij},n_k) =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) (n_{ij},n_k) (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(因为单纯形/张量积的"方向"是对称的或可 分解的);对金字塔型空间,横向上界必须逐 kk k 层 给出(exponentZ 参数),这正是"金字塔型 空间不是单一阶数空间,而是一族按 kk 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 n_{ij} nij,再是 nk n_k 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 \partial\phi_i/\partial X ∂ϕ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!)O(p^d/d!) O(pd/d!)),forceTensorRule=true 才退化到"张量 + 折叠"的规则(点数 O(pd)O(p^d) O(pd))。
  • 对张量积单元(四边形、六面体):默认可以用省点的非张量规则,也可以强制纯张量积。
  • 对棱柱/金字塔:棱柱 = 三角形规则 × 线段规则;金字塔 = Duffy 变换后的锥体规则。

张量积规则阶数精确但点数多 ( O(nd)O(n^d) 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<21order<21 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-0.28125 −0.28125)。负权重会让积分对正被积函数的稳定性变差 ,但能显著 减少点数。Gmsh 保留它们是因为......在有限元惯例里,只要被积函数是多项式(形函数 × 形函数 × 雅可比),积分就是代数精确的,符号无关紧要。文件里的注释还专门标注了每条规则的性质:

cpp 复制代码
// 0 negative weights, 0 points outside of the triangle,  total sum of the
// weights is 0.5

三角形的面积(权重和)固定为 0.50.5 0.5(参考面积),四面体为 1/61/6 1/6,四边形/六面体为 4/8。 这个"权重和 = 参考域体积"的约定贯穿全部积分表。

8.4.3 一维 Gauss-Legendre:硬编码的 ±\pm ± 对称表

一维规则是其它一切规则的地基,放在 src/numeric/GaussLegendre1D.h, 以静态数组 + 内联分派 实现,支持 n=1...16n=1\dots16 n=1...16 与 n=20n=20 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 t_i=-t_{n-1-i} ti=−tn−1−i),权重也对称。注意区间是 −1,1-1,1 −1,1,权重和为 2------与 "权重和 = 参考域长度"的约定一致。为什么用静态数组而非运行时计算? 因为一维 Gauss-Legendre 的节点是 Legendre 多项式的根,数值求解成本高且不稳定;而 n≤20n\le20 n≤20 的节点在 文献里是"标准常数",硬编码精度最高。超过 20 点则表里没有对应项 (gmshGaussLegendre1D 返回空指针),因此 getGQLPts 实际只对 order≤39order\le39 order≤39( n≤20n\le20 n≤20)有效------这是一个硬边界, 实际有限元里线段阶数远到不了这么高,所以从未成为问题。

getGQLPts 的实现把点复制成 IntPt(pt[1]=pt[2]=0)并缓存进 static std::vector<IntPt*> GQL(大小 40),点数由 getNGQLPts(order) = (order+1)/2 向上取整给出------这就是积分 pp p 阶 多项式所需的最少点数 (Gauss-Legendre 的 nn n 点精确到 2n−12n-1 2n−1 阶,故 n=⌈(p+1)/2⌉n=\lceil(p+1)/2\rceil n=⌈(p+1)/2⌉)。 GQL 的惰性缓存形态是 8.4.7 要讲的普遍模式。

8.4.4 Gauss-Jacobi 与"带权"积分

金字塔的参考域可以用 (1−ζ)2(1-\zeta)^2 (1−ζ)2 作为权函数自然描述(8.4.6 会看到),而一维 Gauss-Jacobi 规则 正是为 ∫−11 (1−x)a(1+x)bf(x) dx \int_{-1}^{1}(1-x)^a(1+x)^b f(x)\,dx ∫−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(1-x)^2 (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(\xi,\eta)\in-1,1^2 (ξ,η)∈−1,12 通过 r= 1+ξ2 r=\frac{1+\xi}{2} r=21+ξ、 s= 1+η2 (1−r) s=\frac{1+\eta}{2}(1-r) s=21+η(1−r) 映到单位三角形时,面积元被拉伸了 14(1−r) \frac{1}{4}(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;
}

体积元的拉伸因子是 18(1−r)(1−r−s) \frac{1}{8}(1-r)(1-r-s) 81(1−r)(1−r−s)------三个"重心坐标逐步收缩"因子相乘。这就是 "退化张量积":变量替换 + Jacobian 补偿,把立方体积分表重用到单纯形上。

棱柱则是最直观的组合:三角形规则 ×\times × 一维规则(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(1-w)^2 (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; }

数学拆解:先把金字塔坐标 (ξ,η,ζ)(\xi,\eta,\zeta) (ξ,η,ζ) 参数化为 ξ= 1−ζ2 U \xi=\frac{1-\zeta}{2}U ξ=21−ζU、 η= 1−ζ2 V \eta=\frac{1-\zeta}{2}V η=21−ζV、 ζ\zeta ζ 由 WW W 映射(本实现里 ζ=12(1+W) \zeta=\frac{1}{2}(1+W) ζ=21(1+W),取 W∈−1,1W\in-1,1 W∈−1,1 对应 ζ∈0,1\zeta\in0,1 ζ∈0,1)。横向"宽度"随高度线性收缩到 0(这正是金字塔的形状特征),因此横向 积分点要乘上 (1−ζ)(1-\zeta) (1−ζ) 因子。 (1−w)2(1-w)^2 (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≥10p\ge10 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=82^3=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⌉\lceil(p+1)/2\rceil ⌈(p+1)/2⌉, 三角形/四面体在低阶用 Solin 省点表(高阶 O(pd/d!)O(p^d/d!) O(pd/d!),强制张量时 ⌈(p+d+1)/2⌉d\lceil(p+d+1)/2\rceil^d ⌈(p+d+1)/2⌉d), 四边形/六面体 ndn^d nd,棱柱为三角形×线段,金字塔为 (⌊p/2⌋+1)3(\lfloor p/2\rfloor+1)^3 (⌊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,pi\in0,p i∈0,p 仿射到 −1,1-1,1 −1,1(2/p * i - 1);三角形把 (i,j)(i,j) (i,j)( i+j≤pi+j\le p i+j≤p)缩放 到单位三角形(i/p, j/p);四边形/六面体把 0,p0,p 0,p 映射到 −1,1-1,1 −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-k' 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;
}

三个要点:

  1. 排列顺序与 MTriangle::edges_tri 严格一致 ------边点沿 {0,1},{1,2},{2,0} 生成,每边的 点数顺序由 i0→i1 决定。这就是"节点排列 = 单元类的边表"的落地。
  2. Serendipity 就是"去掉内部点" :serendip 时只有 3*order 个单项式,直接跳过 if(!serendip ...) 的内部块。
  3. 内部点用递归生成 :完备族的内部点通过对 (order-3) 阶三角形("去掉三条边各一层"后的 里层三角形)递归生成,再整体 add(1) 平移(因为里层三角形的角点指数要偏移到 1,p−11,p-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×33\times3 3×3),dddf 是三阶导 ( 3×3×33\times3\times3 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;
}

它把三种变换(哪个面、是否翻号、旋转多少)压进一个整数 ,使得"面上节点对齐"可以做成 查表:这是按 pp p 自适应/多面片连接时"保证 H1H^1 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} \{m_i\} {mi}(指数向量)、节点集合 {pj} \{p_j\} {pj}。 Vij = pj  mi V_{ij}=p_j^{\,m_i} Vij=pjmi (第 jj j 个节点代入第 ii i 个单项式的值)。形函数 ϕj \phi_j ϕj 要满足 ϕj(pk)= δjk \phi_j(p_k)=\delta_{jk} ϕj(pk)=δjk, 写成 ϕj=∑i cji mi \phi_j=\sum_i c_{ji}m_i ϕj=∑icjimi,则 ∑i cji Vik = δjk \sum_i c_{ji}V_{ik}=\delta_{jk} ∑icjiVik=δjk,即 CV=IC V=I CV=I, C=V−1C=V^{-1} C=V−1。所以 coefficients 就是 Vandermonde 矩阵的逆 。这正是"节点位置决定基函数" 的精确表述。evaluateMonomials 负责在 (u,v,w)(u,v,w) (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):三维时先乘 ww w、再乘 vv v、再乘 uu 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\le k+n_{ij} i,j≤k+nij"约束的实现 ------哪些 (i,j)(i,j) (i,j) 组合合法, 取决于当前层 kk 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\phi = \xi^i\eta^j\zeta^k(1-\zeta)^{\max(i,j)-i-j} ϕ=ξiηjζk(1−ζ)max(i,j)−i−j,即把分母 1−ζ1-\zeta 1−ζ 移入分子的 负幂。std::max(1e-14, 1 - points(l,2)) 是对 ζ=1\zeta=1 ζ=1(顶点)处的除零保护 ------用一个 极小正数替代,避免 000^0 00 或除零。这是金字塔单元数值实现里最容易踩的坑 ,源码用一行 std::max 化解。

validIJ 的语义与 8.3.2 的两族空间对应:它确定 (i,j)(i,j) (i,j) 是否在给定 kk k 层合法。所以 pyramidalBasis 生成的 monomials 不是 一个规整的矩形指数集,而是一个"按 kk k 分层的 楔形"集合。

8.6.4 贝塞尔基:正性、凸包与变差缩减

为什么需要 bezierBasis(src/numeric/bezierBasis.h)? 答案是多项式正性的判定。关键数学事实:一个多项式在单纯形上的正性,等价于它在(单纯形) 贝塞尔形式下的所有控制点为正。这来自"凸包性质"与"变差缩减性质(variation diminishing)"。

打个比方:拉格朗日形式下, ∑iciϕi \sum_i c_i\phi_i ∑iciϕi 的系数 ci c_i ci 是节点的插值值 ------它不能 保证函数在单元内部取值的符号(节点间的振荡不受控;这正是高阶拉格朗日插值出现 Runge 现象的 原因)。而贝塞尔形式的控制点 βi \beta_i βi 具有"凸包性质":整条函数曲线落在控制点的凸包内。 于是:

  • βi>0 ∀i⇒ \beta_i>0\ \forall i \Rightarrow βi>0 ∀i⇒ 多项式在单元内处处为正;
  • 若某个 βi≤0 \beta_i\le0 β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,hf,g,h f,g,h 的贝塞尔系数,直接算出 f⋅gf\cdot g f⋅g、 f⋅g⋅hf\cdot g\cdot h 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);
};

要点:

  • getSignedJacobian vs getScaledJacobian :前者是带符号的雅可比行列式(正负即单元朝向), 后者是归一化到 0,10,1 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\kappa^{-1} κ−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 基 ,按阶分层:升阶时旧形函数不变, pp 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 阶起的一维分层 函数;面基是面内部的二维分层函数;气泡基是体内部函数。"分层"的深层意义 :低阶空间的形 函数是高阶空间形函数的子集 (不像拉格朗日基那样所有函数都随阶改变),因此做 pp p-自适应时 只需追加 新函数,不必重算旧函数、也不破坏已有自由度------这是谱元与 hphp 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 为什么浮点符号会出错

三维网格的正确性高度依赖几何谓词的符号正确性 。考虑"点 DD D 是否在三角形 ABCABC ABC 的上方" 这样的判断------它等价于一个有向体积( 4×44\times4 4×4 行列式)的符号:

orient3d(A,B,C,D)=det⁡ ⁣ ( Ax Dx 1 Ay Dy 1 Az Dz 1 ) (示意)\text{orient3d}(A,B,C,D)=\det\!\begin{pmatrix}A_x&D_x&1\\A_y&D_y&1\\A_z&D_z&1\end{pmatrix}\ \text{(示意)} orient3d(A,B,C,D)=det AxAyAzDxDyDz111 (示意)

用普通 double 计算这个行列式,会遇到两个致命问题:

  1. 舍入误差 :当四点近乎共面时,真值可能只比机器精度大一点,计算结果可能符号翻转 (本该是 ++ + 却算成 −- −)。
  2. 相消(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 有向面积的两倍(点 CC C 在 AB→ \overrightarrow{AB} AB 左/右/共线) 二维 Delaunay、三角形朝向
orient3d 4 4×44\times4 4×4 有向体积(点 DD D 在 △ABC\triangle ABC △ABC 上/下/共面) 三维 Delaunay、四面体朝向、退化判定
incircle 4 点 DD D 是否在 △ABC\triangle ABC △ABC 外接圆内 二维 Delaunay 的"空圆"判据
insphere 5 点 EE E 是否在四面体 ABCDABCD 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)

逐条解读:

  1. 大写函数名 :Gmsh 的 .geo 脚本语言惯用 Sin/Cos/Exp/Sqrt/Fabs 等首字母大写 函数名,所以库在标准小写名之外同时注册大写别名 。Sum/Max/Min/Med/Rand 这 几个聚合函数也是 Gmsh 特有的。
  2. Pi 与 pi 都是常数 (原库只有 pi)。
  3. 比较运算符 <、> 返回 1 或 0:这让表达式能写"分段/条件"逻辑(配合算术运算)。
  4. 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 b_0 b0)?
  • 有几个独立的环 / 洞 (1 维 Betti 数 b1 b_1 b1)?
  • 有几个封闭空腔 (2 维 Betti 数 b2 b_2 b2)?
  • 是否存在非流形结构(同调会"看见"它)?

欧拉示性数 χ=b0−b1+b2−⋯ \chi=b_0-b_1+b_2-\cdots χ=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 处理"同一胞腔但顶点顺序不同 ⇒ 朝向符号不同"这件事------这是有向链的 关键 : ∂\partial ∂ 算子带着 ±1\pm1 ±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 −1? 因为"1 维胞腔的边界 = 终点 − 起点",这保证了 ∂∂=0\partial\partial=0 ∂∂=0(边界的边界为零)------链复形的根本性质 。同调群正是 ker⁡∂k/im⁡ ∂k+1 \ker\partial_k/\operatorname{im}\partial_{k+1} 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\chi=\sum(-1)^k n_k χ=∑(−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 \partial_k:C_k\to C_{k-1} ∂k:Ck→Ck−1 的矩阵。
  • KerCod(k) 分别算 ker⁡∂k \ker\partial_k ker∂k(闭链群 Zk Z_k Zk)与 cod⁡∂k \operatorname{cod}\partial_k cod∂k。
  • Inclusion 构造 Bk→Zk B_k\to Z_k Bk→Zk 的映射 jj j(边界是特殊闭链)。
  • Quotient 做商 Hk=ker⁡∂k/im⁡ ∂k+1 H_k=\ker\partial_k/\operatorname{im}\partial_{k+1} 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.

这段注释本身就是一篇小论文,要点:

  1. 替换 kbipack:原来用外部库 kbipack,其任意精度整数(GMP)的"每操作开销"主导了 计算时间。
  2. 守恒的数值假设 :胞腔复形的边界矩阵是完全幺模(totally unimodular)或近乎如此 , 中间量极小------所以用 原生 64 位整数 就够,不必上任意精度。
  3. 溢出保护 :每个可能溢出的操作都做范围检查,抛出 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\pm1 ±1)的行列变换把整数矩阵 AA A 变成 对角阵 SS S,对角元是不变因子(invariant factors) ,每个整除下一个。对角元为 1 的部分 对应同调群的自由部分( Zb\mathbb{Z}^b Zb, bb b 是 Betti 数);大于 1 的部分就是挠率 ( Z/dZ\mathbb{Z}/d\mathbb{Z} Z/dZ) 。所以"读 Betti 数与挠率"就是把 Smith 标准型的对角元数出来。 这正是 ChainComplex::Quotient 背后的引擎。

ElementaryOpLog 是这段代码里最巧的设计,它不构造变换矩阵,只记录变换步骤 ( AddMultiple/Rotate/Swap/Negate 四类初等操作):因为调用方(ChainComplex)之后只需 这个矩阵的少数几列 (核基常常只有一列),而矩阵本身是 n×nn\times n 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 b_k bk 与欧拉示性数的关系:

χ= ∑k=0n (−1)kbk. \chi=\sum_{k=0}^{n}(-1)^k b_k. χ=∑k=0n(−1)kbk.

对闭合曲面, χ=2−2g\chi=2-2g χ=2−2g( gg g 是亏格);对多连通区域, b0 b_0 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/00/0 0/0 奇性。Gmsh 用有理单项式 X=ξ/(1−ζ),Y=η/(1−ζ),Z=1−ζX=\xi/(1-\zeta),Y=\eta/(1-\zeta),Z=1-\zeta X=ξ/(1−ζ),Y=η/(1−ζ),Z=1−ζ,并分两族 空间(金字塔型 i,j≤k+ nij i,j\le k+n_{ij} i,j≤k+nij、六面体型 i,j≤ nij i,j\le n_{ij} i,j≤nij);因此空间不能只由阶数确定,必须 额外给 ( nij ,nk) (n_{ij},n_k) (nij,nk)------这就是 FuncSpaceData 存在、pyramidalBasis/BergotBasis 与普通 polynomialBasis 分家的原因。

Q2:贝塞尔基为何比拉格朗日基更适合高阶? 因为正性判定 。贝塞尔控制点具有凸包性质(控制点全正 ⇒\Rightarrow ⇒ 函数处处为正),而拉格 朗日节点系数无此保证(节点间会振荡,Runge 现象)。检查控制点符号既充分又无需逐点采样 , 再配自适应细分(bezierCoeff::subdivide)即正确又高效。

Q3:鲁棒谓词为何不能只用 double? 当配置近乎退化(点近乎共面/共圆)时, doubledouble double 行列式真值可能只比机器精度大一点点,舍入与 相消会让符号翻转 ;而 Delaunay 空球判据与朝向判定都是纯符号决策 ,一次错误就产生非法单元、 非流形甚至死循环。Shewchuk 的自适应谓词用"快速浮点 + 静态滤波器 + 高精度兜底"保证符号永远 正确------double 能算准大概,算不准符号。

Q4:同调在网格生成里的作用? 同调/上同调计算拓扑不变量 (Betti 数、挠率、欧拉示性数),用于:(a) 检测非流形、洞与连通 分量;(b) 在电磁等非单连通域 里构造割面与环链(generate cuts);(c) 通过插件把生成元写回 模型。技术上依靠胞腔复形 + 边界算子矩阵 + Smith 标准型,并用约简把大规模网格化简到只剩 拓扑非平凡部分。

Q5:为什么积分点生成是独立模块而非硬编码表? 因为高阶( p≥10p\ge10 p≥10)的全张量积分点会爆炸,Solin 表又只覆盖有限阶。Gmsh 采用"低阶硬编码表 + 高阶按需计算(折叠张量积 / Duffy / Jacobi)+ 惰性缓存"的混合策略。

Q6:ElementType 与 FuncSpaceData 到底谁决定谁? 后者由前者构造 :MSH 编号 →(ElementType)→ 父类型+阶数+族别 →(FuncSpaceData)→ 空间 键 →(BasisFactory)→ 具体基函数/积分/雅可比。

8.12 本章小结

数值内核的八个要点:

  1. 三元组寻址 :父类型 + 阶数 + 族别(ElementType/FuncSpaceData)决定积分与基函数; 金字塔用两族有理单项式空间特判( i,j≤k+ nij i,j\le k+n_{ij} i,j≤k+nij vs i,j≤ nij i,j\le n_{ij} i,j≤nij)。
  2. 积分按需生成 :getGQ*Pts + gaussIntegration,区分张量积与更省点的单纯形规则,支持 Gauss-Legendre、Gauss-Jacobi (1−w)2(1-w)^2 (1−w)2、折叠张量积(quadToTri/brickToTet)、Duffy 金字塔 规则;Solin 低阶表与运行期计算混合、惰性缓存。
  3. 点与单项式同序 :polynomialBasis 把节点位置转成 Vandermonde 逆(形函数系数);Serendipity 是"去掉内部点",内部点靠递归剥离。
  4. 基函数五类 + 三层导 :nodal/polynomial、分层 H1/H(curl)、正交/迷你/Bergot、贝塞尔、 条件数与雅可比基函数,全部由 BasisFactory 五张 map 缓存;贝塞尔让正性判定退化为控制点 符号检查 + 自适应细分。
  5. 鲁棒谓词 = 快速路径 + 精确兜底 :Shewchuk 自适应精度谓词 + exactinit 静态滤波器,是 Delaunay 正确性的基石,且用命名空间与 TetGen 隔开。
  6. 表达式求值器 :mathex 的"解析一次、多线程多次求值"设计,为大写函数名、Pi、比较运算符 做了 Gmsh 定制,服务 MathEval 尺寸场与插件/后处理公式。
  7. 计算拓扑是一等能力 :Chain→CellComplex→ChainComplex(边界矩阵 ∂k \partial_k ∂k)→ SmithNormalForm(Smith 标准型读 Betti 与挠率)→Homology,并通过插件暴露给脚本。
  8. 网格库需要基函数 :质量度量、网格优化、高阶曲边、后处理插值、求解器装配全都依赖 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−ζ)X=\xi/(1-\zeta) 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

下一章转向后处理与插件体系,看结果数据如何组织、可视化与扩展。

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