Gmsh 源码深度解析-第 9 章 后处理与插件体系
本章目标:讲清 Gmsh 的后处理子系统的完整设计------视图数据模型 (
PView视图 = 数据 + 选项)、两大类数据容器 (PViewDataList列表式与PViewDataGModel网格式)、 可视化选项体系 (PViewOptions与View[...].Option的映射)、结果文件读写 (pos/msh/vtk/med/cgns/x3d...)、以及 Gmsh 最具扩展性的部分------插件(plugin)机制 与 40 余个代表插件的算法剖析,最后落到自适应可视化与自适应网格的闭环。第 1 章建立了
GModel/GEntity数据模型,第 2 章讲几何内核,第 4~8 章讲网格与求解。 本章回答一个一直被推迟的问题:求解器/文件里那些标量场、矢量场、张量场,在 Gmsh 内部 究竟是怎样被表示、读取、运算、绘制、导出,并被插件复用的?
9.1 后处理在 Gmsh 中的位置
9.1.1 一句话定位
后处理(源码目录 src/post/)的职责可以概括为一句话:
把有限元/有限体积结果(标量、矢量、张量场)读进来,组织成"视图",做运算与可视化, 再导出或回灌到网格流程中。
它与几何/网格共享同一套基础设施------GModel、MElement、MVertex、MFaceHash、 shapeFunctions 等,这正是 Gmsh 5.0 之后"前后处理一体"设计的直接体现。与很多"后处理是 独立小工具"的项目不同,Gmsh 的后处理直接认识模型的网格对象,因而能做到"在网格上读、 在网格上算、在网格上画"。
9.1.2 类层次全景
后处理的核心类层次如下:
text
PView ← 一个"视图" = 数据(PViewData) + 显示选项(PViewOptions)
├── PViewData ← 数据抽象接口(纯数据,不含显示)
│ ├── PViewDataList ← 列表式数据(不连续元素;来自文件/插件/脚本)
│ ├── PViewDataGModel ← 基于 GModel 网格的数据(共享节点;来自求解器/msh/vtu)
│ └── PViewDataRemote ← 远程数据(ONELAB/网络流式)
└── PViewOptions ← 每个视图的显示选项(颜色、范围、表示方式...)
PViewData 的支撑设施:
├── adaptiveData ← 自适应可视化的细分树与误差估计
├── OctreePost / Octree ← 视图上的空间查询(探针、剖切、等值线、插值)
├── ColorTable ← 颜色映射
└── shapeFunctions (element) ← 后处理端的一阶形函数求值
插件层(src/plugin/):
├── GMSH_Plugin ← 插件基类(选项、ABI)
│ ├── GMSH_PostPlugin ← 作用于视图的插件
│ └── GMSH_MeshPlugin ← 作用于模型/网格的插件
└── PluginManager ← 单例:注册、动态库加载、按名/按 id 查找、action
PView 是"视图对象":它持有一个 PViewData(数据是什么 )和一个 PViewOptions (怎么画 )。一个 GModel 可以挂很多视图------所有视图登记在全局静态列表 PView::list(src/post/PView.h 第 122 行)里,每个视图对应一组 结果。
9.1.3 一个视图的典型生命周期
一个视图的一生:创建 (new PView()/new PView(data)/点集构造,见 9.2.3)→ 装填数据 (readPOS/readMSHViewData/readMED/readCGNS,或脚本 View 命令,或插件输出)→ finalize (算 min/max、包围盒、时间步、每步范围,处理插值矩阵、可选 smooth(),清 _dirty 并 _stamp++)→ 绘制/查询/插件 (fillVertexArrays、getDataList/forEachNode/ createListData、OctreePost 插值)→ 导出 (PView::write 按扩展名/格式枚举分派到 POS/MSH/MED/VTU/X3D/STL/TXT,必要时生成回读脚本)。这套"创建 → finalize → 用 → 写"的节奏 贯穿全章,是理解后处理代码的骨架。
9.2 PView:视图 = 数据 + 选项
9.2.1 PView 的成员
src/post/PView.h 第 28 行起的类声明,把"视图对象"的全部职责 摊开了:
cpp
class PView {
private:
static int _globalTag; // 全局 tag 计数器
int _tag; // 视图的唯一 tag(>= 0,不可变)
int _index; // 在视图列表中的下标
bool _changed; // 视图已改变的标记
int _aliasOf; // 若本视图是另一个视图的别名,则为源视图 tag,否则 -1
SPoint3 _eye; // 眼点位置(用于透明排序)
PViewOptions *_options; // 显示选项
PViewData *_data; // 数据
adaptiveData *_clipAdaptive; // 只细化"皮"时,被剖切面切到的元素单独细化
PViewData *_clipLayer;
void _init(int tag = -1);
...
};
有几处设计可玩味:
_tag不可变,_index可变 :tag 是身份(脚本View[3]用它寻址),index 只是"排第几" (sortByName()会改它)。把身份与位置分开,避免重排后脚本引用错乱。_changed是绘制层的"需要重画"标记 ,与数据的_stamp(9.3.1)不同:前者指"视图(含 选项)变了",后者指"数据变了"。setChanged(true)还会重置眼点_eye(为透明度排序服务)。
cpp
void PView::setChanged(bool val)
{
_changed = val;
// reset the eye position everytime we change the view so that the
// arrays get resorted for transparency
if(_changed) _eye = SPoint3(0., 0., 0.);
}
这是为透明度排序服务的:一旦视图变了,之前按眼点排好的顶点顺序作废。
9.2.2 别名(alias)视图
PView(PView *ref, bool copyOptions, int tag) 构造一个别名视图 :它共享 ref 的数据, 但可以有自己的显示选项。_aliasOf 记录源视图 tag。这在"同一份数据、多种画法"时很省内存 ------例如一份应力场同时按 Continuous 和 Discrete 画,或同时显示位移云图和矢量。
9.2.3 视图的构造家族
PView 提供了一组便利构造,覆盖常见数据来源(第 54--74 行):
cpp
PView(int tag = -1); // 空的列表式视图
PView(PViewData *data, int tag = -1); // 用现成数据
PView(PView *ref, bool copyOptions = true, int tag = -1); // 别名(共享数据)
PView(..., std::vector<double> &x, &y); // 2D 点集
PView(..., x, y, z, v); // 3D 点集
PView(const std::string &name, const std::string &type, GModel *model,
std::map<int, std::vector<double> > &data, double time = 0.,
int numComp = -1, int tag = -1); // 网格式(按 tag 索引)
注意最后一个重载:它接收的是 map<tag, vector<value>> 与一个 GModel*,这正是"把结果 挂到网格上"的入口(内部走 PViewDataGModel)。配对的 addStep(GModel*, data, ...) 可以 给已有的网格式视图追加一个时间步 ,而 addStep(vector<double>&) 给列表式的 2D/3D 点集 追加一列值。
9.2.4 动画:animate
src/post/PView.cpp 第 443 行的 animate 有两种模式:
cpp
void PView::animate(bool time, int incr)
{
static int inCycle = -1;
if(time) { // 时间动画:推进时间步,跳过空步
for(每个可见视图 i) {
int step = timestep(i) + incr, numSteps = nb_timestep(i);
for(int j = 0; j < numSteps; j++) {
if(list[i]->getData()->hasTimeStep(step)) break; else step += incr;
if(step < 0) step = numSteps - 1; if(step > numSteps - 1) step = 0;
}
opt_view_timestep(i, GMSH_SET | GMSH_GUI, step);
}
return;
}
// hide all views except the one in the cycle(时间步模式下则轮换"可见视图")
}
两个要点:
time = true:时间动画 ------每个可见视图把当前时间步推进incr,并且跳过空步 (hasTimeStep为假就继续推进),这对"不同视图时间步不齐"的工程数据很实用。time = false:视图轮播 ------一次只显示一个视图(incr == 0回到第一个)。它用static int inCycle记住轮播到了哪;这也是整个代码库里少数用函数内static保存状态的 地方。
9.2.5 合并视图:combine / combineTime / combineSpace
当用户做了很多切片/运算,产生几十个视图时,Gmsh 可以把它们合成一个(src/post/PView.cpp 第 485 行):
cpp
void PView::combine(bool time, int how, bool remove, bool copyOptions)
{
// time == true: combine the timesteps (otherwise combine the elements)
// how == 0/1/2: 可见视图 / 全部视图 / 同名的视图
// 按 nameData.name 分组,组内 >1 个才尝试合并:
// bool res = time ? data->combineTime(nds[i]) : data->combineSpace(nds[i]);
}
它把候选视图按 nameData.name 分组(__all__/__vis__/真实名字),组内 >1 个才尝试合并。 合并的前提是类型一致 ------全列表式就建 PViewDataList,全网格式就建 PViewDataGModel,混搭 直接报错。真正的搬运在 combineTime/combineSpace(9.5.10):前者把每个元素的坐标只留一次、 各视图的值按时间步拼接;后者把各视图的元素直接串接(要求时间步数一致)。
nameData(第 253 行)是个简单结构,把"一组待合并的数据 + 它们的下标 + 选项"打包:
cpp
class nameData {
public:
std::string name;
std::vector<int> indices;
std::vector<PViewData *> data;
PViewOptions *options;
};
9.2.6 查找视图:getViewByName / getViewByFileName / getViewByTag
脚本里 View["myname"]、View[3]、或按文件名寻址,底层就是这三个静态函数(第 574 行起):
cpp
PView *PView::getViewByName(const std::string &name, int timeStep,
int partition, const std::string &fileName)
{
// search views from most recently to least recently added
for(int i = list.size() - 1; i >= 0; i--) {
if(list[i]->getData()->getName() == name &&
((timeStep < 0 || !list[i]->getData()->hasTimeStep(timeStep)) ||
(partition < 0 ||
!list[i]->getData()->hasPartition(timeStep, partition))) &&
(fileName.empty() || !list[i]->getData()->hasFileName(fileName)))
return list[i];
}
return nullptr;
}
注意 "从最近添加的往前找" (逆序遍历),这是刻意的:后加载的同名视图优先。timeStep/ partition/fileName 三个参数都是否定的过滤条件 ------注释写得很清楚:"if timeStep >= 0, return view only if it does not contain that timestep"。这是为"读文件时把新数据追加到 不含该步的现有视图"这一用法服务的(配合 PViewDataGModel::readInView,见 9.6.7)。
9.3 PViewData:数据抽象接口
9.3.1 脏标记与 stamp:失效广播机制
src/post/PViewData.h 是所有后处理数据的抽象基类。它首先解决 "数据新鲜度"问题:
cpp
class PViewData {
private:
// flag to mark that the data is 'dirty' and should not be displayed
bool _dirty;
int _stamp = 0;
...
public:
int getStamp() const { return _stamp; }
void changed() { _stamp++; }
virtual void setDirty(bool val) { _dirty = val; if(val) _stamp++; }
_dirty/_stamp 是一套失效通知机制 :数据一变,_stamp 自增;任何由数据本身派生 (而非视图选项派生)的缓存------八叉树、最近邻 KD 树、插值矩阵、自适应数据------都记下自己构建 时的 _stamp,一旦发现 _stamp 变了就重建。注释(第 75--78 行)说得非常明确:
bumped whenever the data may have changed (made dirty, finalized, added to): what is derived from the data alone, not from the options of the view, records it and is kept until it moves
"derived from the data alone, not from the options" 这句话是整套缓存策略的分界:数据派生 的缓存不能跨选项复用,但选项(颜色/范围/表示)的变化不破坏数据缓存。finalize 清 _dirty 并 _stamp++:
cpp
bool PViewData::finalize(bool computeMinMax, const std::string &interpolationScheme)
{ _dirty = false; _stamp++; return true; }
9.3.2 惰性空间索引:OctreePost 与 KD 树
PViewData 维护两类空间索引(第 44--50 行):OctreePost *_octree(快速空间搜索)与 SPoint3KDTree *_kdtree(最近邻节点查询),各自带 _octreeStamp/_kdtreeStamp 记录构建时 的 _stamp:
cpp
OctreePost *_octree;
SPoint3Cloud _pc;
SPoint3CloudAdaptor<SPoint3Cloud> _pc2kdtree;
SPoint3KDTree *_kdtree;
int _octreeStamp, _kdtreeStamp; // 构建时的 stamp
OctreePost *_getOctree();
两者都是惰性构建 (首次使用时才建),且各自用自己的 stamp 判断是否重建------因为构建代价 不低,而很多视图从不需要空间查询。两者都用双检锁 保护"检查 + 重建":例如 findClosestNode 在 #pragma omp critical(PViewDataFindClosestNode) 临界区里再查一次 stamp (防止两线程同时 判断为脏后各自重建),才 delete _kdtree、遍历实体/元素填点云、buildIndex();_getOctree 同样把 new OctreePost(this) 放在临界区里,成功后写回 _octreeStamp = _stamp。
OctreePost(src/post/OctreePost.h)为列表式数据建每组 (分量数, 元素类型) 一棵八叉树 (成员 Octree *_trees[3][8],另加 _topDim[3] 记每组最高维 元素),并带一个 Cache(void *element; int kind;)------头文件注释解释了它的动机: "where the next search looks first"------沿流线连续查询时,上一次找到的元素作为下一次的起点, 于是大部分查询能直接命中而跳过八叉树;此外它只保留最高维的元素,使"剖切到的结果"与"全域 搜索"一致。
9.3.3 寻址模型:(step, ent, ele, nod, comp)
PViewData 的全部访问接口围绕一个五元组展开:时间步 step、实体 ent、元素 ele、 节点 nod、分量 comp。这套寻址对两类容器给出完全一致的语义(第 158--249 行):
cpp
virtual int getDimension(int step, int ent, int ele) { return 0; }
virtual int getNumNodes(int step, int ent, int ele) { return 0; }
virtual int getNumComponents(int step, int ent, int ele) { return 0; }
virtual int getNumValues(int step, int ent, int ele) { return 0; }
virtual int getNumEdges(int step, int ent, int ele) { return 0; }
virtual int getType(int step, int ent, int ele) { return 0; }
virtual int getNode(int step, int ent, int ele, int nod, double &x, double &y, double &z)
{ return 0; }
virtual void getValue(int step, int ent, int ele, int nod, int comp, double &val) {}
- 列表式数据只有一个"实体"(
ent恒为 0,getNumEntities返回 1),ele是全局元素号; - 网格式数据的
ent是GModel实体下标,ele是该实体内的元素号。
正是这层抽象,让绘制、插件、导出、探针对两类数据一视同仁。
9.3.4 getScalarValue:任意场归约为标量
"用颜色画任意场"的核心归约在 getScalarValue(src/post/PViewData.cpp 第 92 行)。它把标量/矢量/张量统一成一个标量:
cpp
void PViewData::getScalarValue(int step, int ent, int ele, int nod, double &val,
int tensorRep, int forceNumComponents, int componentMap[9])
{
int numComp = getNumComponents(step, ent, ele);
double d[9]; // (on the stack: 最多 9 分量)
if(forceNumComponents && componentMap) { // 按映射取指定分量再归约
int n = std::min(forceNumComponents, 9);
for(int i = 0; i < n; i++) { int c = componentMap[i];
if(c >= 0 && c < numComp) getValue(step, ent, ele, nod, c, d[i]); else d[i] = 0.; }
val = ComputeScalarRep(n, d, tensorRep);
}
else if(numComp == 1) { getValue(step, ent, ele, nod, 0, val); } // 标量取原值
else { int n = std::min(numComp, 9);
for(int c = 0; c < n; c++) getValue(step, ent, ele, nod, c, d[c]);
val = ComputeScalarRep(n, d, tensorRep); } // 向量取模、张量按表示
}
按 ComputeScalarRep 的约定:标量取原值、向量取模长、张量按 tensorRep 取 Von-Mises (0)/最大特征值(1)/最小特征值(2) 。componentMap 允许把任意分量重新映射成一个新 "张量"再归约(例如把位移三分量当矢量求模)。这是所有标量着色(Iso/Continuous/Numeric) 的统一入口。
9.3.5 批量接口:为"reads millions"而设
PViewData::getElementInfo(第 217 行)与 getNodesAndValues(第 225 行)的注释点明了 它们存在的理由:
what getType(), getDimension(), getNumNodes() and getNumComponents() give, and the coordinates and the numComp first values of the numNodes nodes: the same, element by element, in one call (the drawing reads millions)
基类的默认实现就是"逐项调用再拼起来"(for(j) getNode(..., xyz[j]); for(k) getValue(..., val[j][k]);), 但两类容器都重写 成了"一次取全"的快速路径:PViewDataList 直接从缓存指针取(9.5.6), PViewDataGModel 直接读网格对象(9.6.4)。这正是"百万次读取"场景下的关键优化------把 N 次虚函数 调用折叠成 1 次。
9.3.6 表皮掩码、稳定节点 id、线程安全声明
三个"可选能力"由数据自己声明:
cpp
// getSkinMasks():每个元素的哪些面在视图"表皮"上(按绘制顺序,每面一位);null = 数据不知道
virtual const std::vector<unsigned char> *getSkinMasks() { return nullptr; }
// getNodeId(...):节点的稳定标识,共享它的元素给出同一个;0 = 数据没有节点拓扑
virtual std::size_t getNodeId(int step, int ent, int ele, int nod) { return 0; }
// isThreadSafe():能否并发访问同一数据的"不同元素"(走"最后元素"缓存时不能)
virtual bool isThreadSafe() { return false; }
getSkinMasks:表皮(skin)绘制------只画模型表面、不画内部单元。让数据自己告诉绘制层 "哪些面是表皮",比事后做面匹配更准(自适应视图尤其清楚"细化元素来自哪里")。getNodeId:列表式数据本无拓扑,但它能用坐标合并重建节点 id (9.5.7);网格式数据的 节点 id 就是MVertex::getNum()(9.6.4)。返回 0 表示"没有节点拓扑"。isThreadSafe:列表式数据靠thread_local缓存做到线程安全(9.5.6),网格式数据天然安全 (只读网格对象),于是两者都返回true;基类默认false。
9.3.7 插值矩阵:高阶与曲线单元
后处理要处理高阶单元 (例如 10 节点四面体)和曲线单元 (几何也高阶)。PViewData 用一对/两对"系数矩阵 × 单项式基"描述"从节点值到任意参数点值的映射"(第 56--61、151--201 行):
cpp
interpolationMatrices _interpolation; // 按元素类型索引
static std::map<std::string, interpolationMatrices> _interpolationSchemes; // 具名方案
// setInterpolationMatrices(type, coefVal, expVal) → 2 个矩阵(值高阶)
// setInterpolationMatrices(type, coefVal, expVal, coefGeo, expGeo) → 4 个矩阵(几何也高阶)
- 2 个矩阵:值的一阶以上插值(值在更多点上求值);
- 4 个矩阵 :几何也高阶(曲线单元 ),多出的
coefGeo/expGeo用来插值坐标。
haveHighOrderInterpolation()(第 191 行)判断"这个视图是否比一阶更高",而 PViewDataList::setOrder2(type)(9.5.9)用 BasisFactory::getNodalBasis(typeMSH) 的 系数/单项式一键把某类元素升到二阶。
9.3.8 空间搜索 API:探针、剖切、插值的共同底座
searchScalar/searchVector/searchTensor 直接在模型/视图里"按坐标查值",全部转给 OctreePost(第 319--412 行):
cpp
// searchScalar/searchVector/searchTensor(x,y,z,values,step,...) 全部转给 _getOctree()
bool PViewData::searchScalar(double x, double y, double z, double *values,
int step, double *size, int qn, double *qx, double *qy,
double *qz, bool grad, int dim)
{ return _getOctree()->searchScalar(x, y, z, values, step, size, qn, qx, qy, qz, grad, dim); }
Closest 变体(searchScalarClosest 等)在"找不到精确命中"时退化为找最近节点 , 并用 distance 参数控制容忍半径(>0 为最大距离、<0 为无限制):
cpp
// searchScalarClosest(...):找不到精确命中时退化为找最近节点------
// distance>0 为最大容忍距离、<0 为无限制;命中则 distance=0,否则 -1
// (searchVectorClosest/searchTensorClosest 同构)
Probe 插件、View[...].Probe 脚本命令、以及 MathEval 跨网格插值(9.9.1)都建立在这套 API 上。
9.3.9 I/O 虚接口
数据层直接声明了写接口(第 413--430 行):writeSTL/writeTXT/writePOS/writeMSH/ writeMED/toVector/fromVector/importLists/getListPointers/sendToServer。这使 每种数据容器都能把自己写成任意格式 ,PView::write 只是选择其中一个而已(9.7.1)。 importLists(int N[24], std::vector<double>* V[24]) 是给依法/API 直接塞入 24 组原始数组的 "后门"(对应列表式的 27 组减去几个字符串组,见 9.5.9)。
9.4 PViewOptions:可视化选项体系
9.4.1 枚举家族
src/post/PViewOptions.h 第 40 行起定义了一整套枚举。它们是 脚本 View[...].X 枚举值的"权威定义":
cpp
enum PlotType { Plot3D = 1, Plot2DSpace = 2, Plot2DTime = 3, Plot2D = 4 };
enum IntervalsType { Iso = 1, Continuous = 2, Discrete = 3, Numeric = 4 };
enum VectorType { Segment = 1, Arrow = 2, Pyramid = 3, Arrow3D = 4, Displacement = 5 };
enum TensorType {
VonMises = 1, MaxEigenValue = 2, MinEigenValue = 3, EigenVectors = 4,
Ellipse = 5, Ellipsoid = 6, Frame = 7, FrameVectors = 8,
};
enum GlyphLocation { COG = 1, Vertex = 2 };
enum RangeType { Default = 1, Custom = 2, PerTimeStep = 3 };
enum ScaleType { Linear = 1, Logarithmic = 2, SymmetricLogarithmic = 3 };
对应关系一览:
| 枚举 | 作用 | 脚本选项 |
|---|---|---|
PlotType |
3D 场景 / 2D 空间 / 2D 时间轨迹 / 2D | View.Type |
IntervalsType |
等值带 / 连续 / 离散 / 数值标注 | View.IntervalsType |
VectorType |
线段 / 箭头 / 锥 / 3D 箭头 / 位移 | View.VectorType |
TensorType |
Von-Mises / 最大最小特征 / 特征向量 / 椭圆... | View.TensorType |
RangeType |
默认范围 / 自定义 / 每步各自 | View.RangeType |
ScaleType |
线性 / 对数 / 对称对数 | View.ScaleType |
注意 枚举值从 1 开始 (不是常见的 0),使"0 = 未设置"成为可能;脚本里 View[0].IntervalsType=2 就是"连续"。
9.4.2 范围、比例与色标
范围是怎么算的?核心在 getRange(src/post/PViewOptions.cpp 第 130 行):
cpp
void PViewOptions::getRange(PViewData *data, double &min, double &max)
{
if(rangeType == Custom) { min = customMin; max = customMax; }
else if(forceNumComponents) {
// 缓存键 = 数据指针 + stamp + 范围类型 + 强制分量数 + 分量映射(+ 时间步)
std::vector<double> key = {(double)(std::size_t)data, (double)data->getStamp(),
(double)rangeType, (double)forceNumComponents};
if(rangeType == PerTimeStep) key.push_back(timeStep);
for(i < forceNumComponents) key.push_back(componentMap[i]);
if(key != _forcedRangeKey) { // 键变了才重新逐值扫描各相关步
_forcedMin = VAL_INF; _forcedMax = -VAL_INF;
for(每个相关步) { _forcedMin = min(_forcedMin, data->getMin(step, ...));
_forcedMax = max(_forcedMax, data->getMax(step, ...)); }
_forcedRangeKey = key;
}
min = _forcedMin; max = _forcedMax;
}
else if(rangeType == PerTimeStep) { min = data->getMin(timeStep); max = data->getMax(timeStep); }
else { min = data->getMin(); max = data->getMax(); }
}
亮点是 forceNumComponents 分支里的缓存键 _forcedRangeKey :它把"数据指针 + 数据 stamp + 范围类型 + 强制分量数 + 分量映射 + 时间步"组成一个向量,只有当这个键变化时才重新 遍历所有值求 min/max。这是"数据派生的缓存"思想的典型实现------用数据的 _stamp 当缓存 失效的指纹(呼应 9.3.1)。
对数/对称对数比例的实现也很有意思(第 37--66 行):
cpp
double PViewOptions::scaleForward(double v, double min, double max) const
{
int type = getScaleType(min, max); // 对数遇 0 会自动降级为线性
if(type == Logarithmic) return log10(v);
if(type == SymmetricLogarithmic) { // 阈值内线性、两侧对数,变号场两条尾巴都可读
double s = getScaleThreshold(min, max);
return (v < 0. ? -1. : 1.) * log10(1. + fabs(v) / s);
}
return v;
}
getScaleType 会自动降级 :对"触及 0 的范围"用不了对数,就回退到线性 (if(scaleType == Logarithmic && (min <= 0. || max <= 0.)) return Linear;)。对称对数 (SymmetricLogarithmic)则对阈值为 s 的区间内线性、两侧对数,让变号场的两条尾巴 都可读 。getScaleThreshold 若未指定阈值,取 max(|min|,|max|) * 1e-4。
颜色映射是 getColor(val, min, max, forceLinear, numColors)(第 96 行):先在 ColorTable 上按比例求索引 (getScaleIndex),再取 colorTable.table[index];若给了 numColors, 则先归到离散色带的第几格,再 getColor(i, nb) 映射回 ColorTable。三者的关系是:
text
值 val ──getScaleIndex(scaleForward)──▶ 索引 i ──colorTable.table[i]──▶ RGBA
9.4.3 几何移动:offset / raise / transform / explode / genRaise
PViewOptions 决定"节点画在哪里",而数据只给原始坐标。最关键的判断是 movesNodes():
cpp
// do the options move the nodes drawn from where the data puts them?
bool movesNodes() const
{
for(int i = 0; i < 3; i++) {
if(offset[i] || raise[i]) return true;
for(int j = 0; j < 3; j++)
if(transform[i][j] != (i == j ? 1. : 0.)) return true;
}
return explode != 1. || normalRaise || useGenRaise ||
vectorType == Displacement;
}
offset[3]:整体平移;raise[3]:按场抬升;transform[3][3]:线性变换(对节点坐标);explode:炸开------把单元从公共节点处向外推(画不连续场时很有用);normalRaise:沿法向抬升(画表面位移);vectorType == Displacement:直接把矢量场当位移来移动节点(画变形网格);useGenRaise:用通用表达式抬升。
genRaiseX/Y/Z 三个字符串表达式经 createGeneralRaise() 编成 mathEvaluator,变量名为 x,y,z,v0..v8,s,t:
cpp
void PViewOptions::createGeneralRaise()
{
const char *names[] = {"x","y","z","v0".."v8","s","t"}; // 坐标、分量、参数坐标
...
genRaiseEvaluator.reset(new mathEvaluator(expressions, variables));
if(expressions.empty()) genRaiseEvaluator.reset(); // 解析失败则清空
}
s,t 是单元的参数坐标 、v0..v8 是该点的分量值------这让用户能用一句表达式精确控制 "怎么抬"。raiseEvaluator 这个小包装类(第 20 行)负责浅拷贝语义 :拷贝构造/赋值时 不复制底层 mathEvaluator(它归选项独占),避免悬垂。
9.4.4 自适应可视化的开关
cpp
int adaptVisualizationGrid, maxRecursionLevel, adaptSkinOnly;
double targetError;
这四项控制"按需细化"(9.10):是否启用自适应、最大递归层数、目标误差、以及是否只细化 表皮。配对的两个判定:
cpp
bool skinOnly() const {
return drawSkinOnly && boundary <= 0 &&
(intervalsType == Continuous || intervalsType == Discrete);
}
bool adaptsSkinOnly() const {
return adaptSkinOnly && skinOnly() && (!showElement || drawSkinEdgesOnly);
}
即"只画表皮"必须是画皮 + 不画边界 + 连续/离散着色三者同时成立;"只细化皮"还要再加 "不画单元边或只画皮的边"。
9.4.5 与脚本 View[...].Option 的映射
PViewOptions 的每个字段在选项系统(第 3 章)里都注册为 View[X].Field 形式的选项名。 脚本 View[0].IntervalsType = 2; View[0].RangeType = 2; View[0].CustomMin = 0; 直接写到 视图的 _options 上;GUI 面板显示/编辑的也是同一批字段;API 的 gmshViewOptionSetNumber(tag, "IntervalsType", 2) 同样落到这里。同一套选项,三个入口。
skipElement(int type)(第 187 行)把"这个类型的元素该不该画"变成一个 switch:
cpp
bool PViewOptions::skipElement(int type)
{
switch(type) {
case TYPE_PNT: return !drawPoints;
case TYPE_LIN: return !drawLines;
case TYPE_TRI: return !drawTriangles;
...
case TYPE_POLYH: return !drawPolyhedra;
default: return true;
}
}
默认对未知类型返回 true(跳过)------这是防御式写法:画不了的类型宁可不画。
9.5 PViewDataList:容器式(不连续)数据
9.5.1 一句话定位与适用场景
src/post/PViewDataList.h 开头的注释一句话点明:
The container for list-based datasets (for which all elements are discontinuous).
它服务于不连续(discontinuous)数据------每个元素独立携带自己的节点坐标与值,元素之间 不共享节点。典型来源是:
- 从
.pos/ 老式结果文件读进来的数据; - 大多数插件(切割面、等值面、数学运算...)的输出;
- 脚本
View命令与 APIgmshViewAdd*塞入的散乱数据。
它的优点是通用 :不需要网格、不需要拓扑、任意单元类型混装;代价是坐标冗余(共享 节点被重复存储)。Gmsh 用"按需重建拓扑"(9.5.7)来弥补。
9.5.2 扁平数组布局与命名规律
PViewDataList 的内部结构非常"扁平":按"单元类型 × 分量数"分列的多组 std::vector<double>(第 25--45 行):
cpp
int _nbSP, _nbVP, _nbTP; std::vector<double> _sp, _vp, _tp; // points:标量/矢量/张量
int _nbSL, _nbVL, _nbTL; std::vector<double> _sl, _vl, _tl; // lines
int _nbST, _nbVT, _nbTT; std::vector<double> _st, _vt, _tt; // triangles
int _nbSQ, ... std::vector<double> _sq, ...; // quadrangles
int _nbSS, ...; _ss, ...; // tetrahedra int _nbSH, ...; // hexahedra
int _nbSI, ...; // prisms int _nbSY, ...; // pyramids
int _nbSR, ...; // trihedra
int _nbT2, _nbT3; std::vector<double> _t2d, _t3d; std::vector<char> _t2c, _t3c; // 文字串
命名规律是 <类型前缀>{x,y,z 坐标数组 / 值数组 / 时间数组}:
- 第二个字母是分量数 :
S=scalar(1)、V=vector(3)、T=tensor(9); - 第一个字母是单元类型 :
P=point、L=line、T=triangle、Q=quad、S=tet、H=hex、I=prism、Y=pyramid、R=trihedron; _nbXx是元素个数计数器,与数组配对。
例如 _vt 是"三角单元、矢量(3 分量)"的扁平数组,_tq 是"四边单元、张量(9 分量)"。 数组里每个元素占 3*numNodes 个坐标 + numComp*numNodes*numSteps 个值------坐标在前,值在 后,多步的值依次接续 ,这就是 _stat(9.5.5)解析数据的依据。
9.5.3 listKinds27:27 组列表的反射表
把这 27 组手工数组用统一的表 描述出来,是 PViewDataList 最精妙的技巧(第 48--57 行):
cpp
struct listKind { // 一组列表的"元数据 + 成员指针"
const char *name; // "SP","VT",...
int type, dim, numNodes, numEdges, numComp; // 单元类型/维度/节点数/边数/分量数
std::vector<double> PViewDataList::*list; // 指向对应数组
int PViewDataList::*num; // 指向对应计数
};
static const listKind listKinds[27];
listKind 用了 C++ 的"成员指针" (std::vector<double> PViewDataList::*list 与 int PViewDataList::*num)------这是"按索引访问某个成员"的反射手段。定义表如下 (src/post/PViewDataList.cpp 第 26 行):
cpp
// 前 9 项:points / lines / triangles 各含 1/3/9 分量;其余组(quadrangles,
// tetrahedra, hexahedra, prisms, pyramids, trihedra)按同一规律排列,共 27 组
const PViewDataList::listKind PViewDataList::listKinds[27] = {
{"SP", TYPE_PNT, 0, 1, 0, 1, &PViewDataList::_sp, &PViewDataList::_nbSP},
{"VP", TYPE_PNT, 0, 1, 0, 3, &PViewDataList::_vp, &PViewDataList::_nbVP},
{"TP", TYPE_PNT, 0, 1, 0, 9, &PViewDataList::_tp, &PViewDataList::_nbTP},
{"SL", TYPE_LIN, 1, 2, 1, 1, &PViewDataList::_sl, &PViewDataList::_nbSL},
{"VL", TYPE_LIN, 1, 2, 1, 3, &PViewDataList::_vl, &PViewDataList::_nbVL},
{"TL", TYPE_LIN, 1, 2, 1, 9, &PViewDataList::_tl, &PViewDataList::_nbTL},
{"ST", TYPE_TRI, 2, 3, 3, 1, &PViewDataList::_st, &PViewDataList::_nbST},
{"VT", TYPE_TRI, 2, 3, 3, 3, &PViewDataList::_vt, &PViewDataList::_nbVT},
{"TT", TYPE_TRI, 2, 3, 3, 9, &PViewDataList::_tt, &PViewDataList::_nbTT},
{"SQ", TYPE_QUA, 2, 4, 4, 1, &PViewDataList::_sq, &PViewDataList::_nbSQ},
...
{"TR", TYPE_TRIH, 3, 4, 5, 9, &PViewDataList::_tr, &PViewDataList::_nbTR},
};
27 = 9 类单元 × 3 种分量。有了这张表,任何"按索引访问某一组"的操作都变成一行:
cpp
const listKind &k = listKinds[i];
std::vector<double> &list = this->*k.list; // 取数组
int nbe = this->*k.num; // 取计数
finalize 用它遍历所有组算统计(9.5.5);_getRawData 用它给 smooth/combine 取指针; _kind(numComp, type)(第 992 行)反向查找,incrementList(9.5.9)靠它追加元素。一张表 换来整个容器的"泛化访问"能力,这是 Gmsh 后处理代码里最值得学习的设计之一。
listKind 还携带了 dim/numEdges------因为 getDimension/getNumEdges 不必真的算,直接从 表里查;numNodes 也直接查(高阶插值时会被插值矩阵覆盖,见 9.5.6)。
9.5.4 元素与步骤的内存布局
对某一组列表,若它有 nbelm 个元素、每元素 nbnod 个节点、nbcomp 个分量、numSteps 个 时间步,则数组长度是:
text
len = nbelm * ( 3*nbnod + nbnod*nbcomp*numSteps )
└─坐标: x,y,z 各 nbnod ─┘ └─值: 每步每节点 nbcomp 个,共 numSteps 步─┘
单个元素内的字段顺序是:先 x[0..nbnod-1],再 y[0..nbnod-1],再 z[0..nbnod-1] (三个坐标分量分段存储,不是 xyzxyz... 交错 ),然后是值。这个顺序由 _stat 里的指针 算术直接体现(第 301--302 行):
cpp
double *X = &list[(std::size_t)ele * nb];
double *Y = X + nbnod, *Z = X + 2 * nbnod, *V = X + 3 * nbnod;
"分段存储坐标"的好处是:取同方向坐标时缓存友好 ,且 y = x + nbnod 这种指针平移很便宜。
9.5.5 finalize:统计、_index、并行 min/max/bbox
finalize(第 113 行)是视图装填后的"收尾",逻辑分五步:
cpp
bool PViewDataList::finalize(bool computeMinMax, const std::string &interpolationScheme)
{
_skinMasks.clear(); // (whoever knows them gives them again)
_state = newState(); // 缓存的状态号变了
_nodeIndexStatus = 0; // 列表变了:重建的拓扑不再有效
_nodeId.clear(); _nodeOffset.clear();
_bbox.reset(); _min = VAL_INF; _max = -VAL_INF;
_stat(_t2d, _t2c, 4); _stat(_t3d, _t3c, 5); // 文字串先行(步数上界)
for(auto &k : listKinds)
_stat(this->*k.list, k.numComp, this->*k.num, k.numNodes, k.type); // 逐组统计
if((int)_time.size() < _nbTimeStep) // 缺时间值时补 dummy 值
for(int i = _time.size(); i < _nbTimeStep; i++) _time.push_back(i);
for(int i = 0; i < 27; i++) // 前缀和 _index[27]
_index[i] = (i ? _index[i - 1] : 0) + this->*listKinds[i].num;
if(CTX::instance()->post.smooth && !_smoothing) smooth(); // 可选平滑
return PViewData::finalize();
}
五步各自的用意 :① 失效旧缓存(清 skin masks、换 _state、丢重建拓扑);② 文字串先行(只有 文字串的视图也能定义时间步数);③ 逐组统计(最重的一步);④ 补 dummy 时间值(老式 parsed 格式 可能不写时间);⑤ 前缀和 _index[27](把全局元素号 ele 二分到"属于哪组、组内第几个",9.5.6)。
getNumElements 直接把 27 组计数相加:
cpp
int PViewDataList::getNumElements(int step, int ent)
{
int n = 0;
for(auto &k : listKinds) n += this->*k.num;
return n;
}
getNumScalars/Vectors/Tensors 则按 numComp 过滤:
cpp
int PViewDataList::getNumVectors(int step) {
int n = 0;
for(auto &k : listKinds) if(k.numComp == 3) n += this->*k.num;
return n;
}
并行统计 _stat(第 258 行)是性能关键,注释直言"a view with millions of elements spends its time here"。它按线程数切分元素区间,每线程独立算自己那片的 bbox 与逐步 min/max, 最后串行归并(避免共享状态加锁):
cpp
int nthreads = CTX::instance()->numThreadsFor(nbelm, 100000);
int numRanges = (N + nbval - 1) / nbval; // (the last one may be partial)
std::vector<SBoundingBox3d> bbox(nthreads);
std::vector<std::vector<double> > min(nthreads), max(nthreads);
#pragma omp parallel for schedule(static, 1) num_threads(nthreads)
for(int t = 0; t < nthreads; t++) {
min[t].assign(numRanges, VAL_INF); max[t].assign(numRanges, -VAL_INF);
int tensorRep = 0; // Von-Mises: we could/should be able to choose this
for(每个线程负责的元素 ele) {
double *X = &list[(std::size_t)ele * nb]; // x,y,z 分段存储
double *Y = X + nbnod, *Z = X + 2 * nbnod, *V = X + 3 * nbnod;
for(int j = 0; j < nbnod; j++) bbox[t] += SPoint3(X[j], Y[j], Z[j]); // O(N) 增量
for(int ts = 0; ts < numRanges; ts++)
for(int j = ts*nbval; j < std::min((ts+1)*nbval, N); j += nbcomp) {
double l0 = (nbcomp == 1) ? V[j] : ComputeScalarRep(nbcomp, &V[j], tensorRep);
min[t][ts] = std::min(l0, min[t][ts]); max[t][ts] = std::max(l0, max[t][ts]); }
}
}
// 归并:_bbox += bbox[t];_min/_max 与 _timeStepMin/_timeStepMax 逐区间取 min/max
要点:每线程独立算、串行归并 ;bbox 逐元素增量(O(N));张量一律按 Von-Mises 估范围------ 注释坦承"we could/should be able to choose this",即 _stat 阶段不区分张量表示 ,而 getMin/getMax 在 tensorRep 或 forceNumComponents 非零时会重新逐值扫描:
cpp
if(forceNumComponents || tensorRep) {
double vmin = VAL_INF;
for(int ent = 0; ent < getNumEntities(step); ent++)
for(int ele = 0; ele < getNumElements(step, ent); ele++)
for(int nod = 0; nod < getNumNodes(step, ent, ele); nod++) {
double val;
getScalarValue(step, ent, ele, nod, val, tensorRep, forceNumComponents, componentMap);
vmin = std::min(vmin, val);
}
return vmin;
}
_nbTimeStep 的收敛也用到了 _stat:每个列表算自己的步数,全局取最小 ("if some elts have less steps, reduce the total number")------这保证"所有元素都到齐的步"才可显示。文字串则 相反,注释说它们"designed to degrade gracefully when some have fewer time steps",所以用 最大步数。
9.5.6 thread_local 读缓存:_lastRead4 与 _state
绘制是大规模读 操作,而列表式数据每次读都要先"定位到某元素在数组里的偏移"。PViewDataList 为此做了一套精细缓存(第 72--91 行):
cpp
// 每线程缓存"上次读到的元素":state(列表状态号,任何其它列表式视图都没有的号,
// 随 finalize 改变)、位置与数据指针;4 个槽位让一个线程同时读多个视图各留一格
struct lastElement {
std::size_t state = 0;
int ele = -1, dim = 0, numNodes = 0, numComponents = 0, numValues = 0, numEdges = 0, type = 0;
double *xyz = nullptr, *val = nullptr;
};
std::size_t _state;
static thread_local lastElement _lastRead[4];
lastElement &_last(int ele)
{ lastElement &l = _lastRead[_state & 3];
if(l.state != _state || l.ele != ele) _setLast(l, ele); return l; }
设计要点:缓存是 thread_local 的 (多线程同时读同一视图互不干扰,这正是 isThreadSafe() 为真的原因);做 4 项 (插件可能同时读多个视图、逐值交替,4 个槽各记一个不同元素);_state 编码"列表状态" ------由 newState()(第 20 行)在每次 finalize 时分配一个全局唯一递增号:
cpp
static std::size_t newState()
{
static std::atomic<std::size_t> last(0);
return ++last;
}
为什么用"状态号"而不是直接比较 _stamp? 注释说得很妙:"a number no other state of any list-based view has"------即不同视图的状态号也互不相同 ,于是同一线程读视图 A 再读视图 B, 即使槽位被复用,l.state != _state 也会命中失效。_state & 3 只是把状态号散列到 4 个槽。
_setLast是定位核心 (第 328 行):先用std::upper_bound(_index, _index+27, ele)把全局元素号二分到"属于哪一组",再取出该组列表与组内下标:
cpp
void PViewDataList::_setLast(lastElement &l, int ele)
{
l.state = _state; l.ele = ele;
// the list of the element: the first whose elements end after it
int k = std::upper_bound(_index, _index + 27, ele) - _index;
if(k >= 27) { l = lastElement(); l.state = _state; l.ele = ele; return; }
const listKind &kind = listKinds[k];
std::vector<double> &list = this->*kind.list;
int i = ele - (k ? _index[k - 1] : 0), nbnod = kind.numNodes;
auto im = _interpolation.find(kind.type); // 4 个插值矩阵:几何也高阶
if(im != _interpolation.end() && im->second.size() == 4) nbnod = im->second[2]->size1();
l.dim = kind.dim; l.numNodes = nbnod;
l.numComponents = kind.numComp; l.numEdges = kind.numEdges; l.type = kind.type;
std::size_t nb = list.size() / (this->*kind.num), before = (std::size_t)i * nb;
int numValues = kind.numComp *
((im != _interpolation.end() && im->second.size()) ? im->second[0]->size1() : nbnod);
l.numValues = numValues;
l.xyz = &list[before]; // 裸指针:生命周期由 _state 保证
l.val = &list[before + 3 * l.numNodes];
}
注意 l.xyz/l.val 是裸指针 ,指向 std::vector 的内部缓冲------它们的生命周期由 _state 保证:只要 _state 没变,向量就不该被重新分配 (finalize 会换 _state, 所以任何改动数据结构的操作都会让旧指针失效)。这也是为什么 setNode(第 502 行)里既要改 坐标、又要把 _nodeIndexStatus 归零。
有了这个缓存,所有访问器变成一行(第 478 行起):
cpp
int PViewDataList::getNode(int step, int ent, int ele, int nod, double &x, double &y, double &z)
{ lastElement &l = _last(ele);
x = l.xyz[nod]; y = l.xyz[l.numNodes + nod]; z = l.xyz[2 * l.numNodes + nod]; return 0; }
void PViewDataList::getValue(int step, int ent, int ele, int nod, int comp, double &val)
{ lastElement &l = _last(ele);
if(step >= _nbTimeStep) step = 0; // 元素步数不足时退回第 0 步
val = l.val[step * l.numValues + nod * l.numComponents + comp]; }
void PViewDataList::getNodesAndValues(int step, int ent, int ele, int numNodes,
int numComp, double **xyz, double **val)
{ lastElement &l = _last(ele);
const double *v = l.val + step * l.numValues; // 一次取全(reads millions)
for(int j = 0; j < numNodes; j++) {
xyz[j][0] = l.xyz[j]; xyz[j][1] = l.xyz[l.numNodes + j]; xyz[j][2] = l.xyz[2*l.numNodes + j];
for(int k = 0; k < numComp; k++) val[j][k] = v[j * l.numComponents + k]; } }
9.5.7 无拓扑数据如何重建节点 id
容器式数据没有拓扑 (每元素自带坐标),但后处理常需要"共享节点"的概念(如按节点画 等值线、光滑法向)。PViewDataList 的解法是一次性合并重合坐标、重建节点 id 并缓存 (src/post/PViewDataList.h 第 62--68 行):
cpp
// list-based data has no topology: node identifiers are recreated once, by
// merging the nodes that have the same coordinates, and cached here.
// _nodeId is indexed by _nodeOffset[element] + node
std::vector<unsigned int> _nodeId, _nodeOffset;
std::atomic<int> _nodeIndexStatus; // 0: not built, 1: built, -1: none
用 std::atomic<int> 做"构建状态":多线程可能同时首次访问,原子量保证只建一次。构建逻辑 (src/post/PViewDataList.cpp 第 409 行)用哈希表按精确 坐标合并:
cpp
void PViewDataList::_buildNodeIndex()
{
if(_nodeIndexStatus) return;
int numEle = getNumElements(0, 0);
if(numEle <= 0) { _nodeIndexStatus = -1; return; }
const std::size_t maxNodes = 100000000; // 内存"熔断"
std::size_t total = 0; _nodeOffset.resize(numEle + 1); _nodeOffset[0] = 0;
for(int i = 0; i < numEle; i++) {
total += getNumNodes(0, 0, i);
if(total > maxNodes) { /* 提示并放弃建拓扑 */ _nodeIndexStatus = -1; return; }
_nodeOffset[i + 1] = (unsigned int)total;
}
_nodeId.resize(total);
std::unordered_map<NodeKey, unsigned int, NodeKeyHash, NodeKeyEqual> map;
unsigned int next = 0;
for(int i = 0; i < numEle; i++)
for(int j = 0; j < getNumNodes(0, 0, i); j++) {
NodeKey k; getNode(0, 0, i, j, k.x, k.y, k.z); // 精确坐标(memcmp 比较)
auto it = map.find(k);
if(it == map.end()) { map[k] = next; _nodeId[_nodeOffset[i]+j] = next++; }
else _nodeId[_nodeOffset[i]+j] = it->second;
}
_nodeIndexStatus = 1;
}
三个要点:
- 精确坐标合并 (
memcmp比较三个 double,见NodeKeyHash/NodeKeyEqual,第 401 行)。 这意味着"看起来重合但浮点不一致"的点不会被合并------对结构化文件读取是合适的。 - 100,000,000 节点的熔断:超大列表视图宁可不重建拓扑,也不分配天文数字的内存。
- 结果被
_nodeIndexStatus缓存 ,且getNodeId用#pragma omp critical(...)保证 "第一个线程构建、其余等待":
cpp
std::size_t PViewDataList::getNodeId(int step, int ent, int ele, int nod)
{
if(!_nodeIndexStatus) {
#pragma omp critical(PViewDataList_buildNodeIndex)
_buildNodeIndex(); // (by the first thread that asks)
}
if(_nodeIndexStatus != 1) return 0;
if(ele < 0 || ele + 1 >= (int)_nodeOffset.size()) return 0;
std::size_t k = (std::size_t)_nodeOffset[ele] + nod;
if(k >= _nodeId.size()) return 0;
// 0 means "no topology", so shift the identifiers by one
return (std::size_t)_nodeId[k] + 1;
}
注意最后的 +1:因为 0 被征用作"无拓扑"的返回值,所以真实 id 都偏移 1。
9.5.8 字符串(T2/T3)的存储
2D/3D 文字标注是后处理里的小而特别的一块。_t2d/_t3d 存"记录",_t2c/_t3c 存字符 串正文 ,记录里用字符偏移指向正文(第 563 行注释):
text
3D: _t3d 是 double 列表: x,y,z,style,index, x,y,z,style,index, ...
_t3c 是 char 列表: string\0,string\0,string\0,...
Parser 格式是: T3(x,y,z,style){"str","str",...};
2D: _t2d 是 double 列表: x,y,style,index, ...
一条记录跨多个时间步 :正文里多个以 \0 分隔的字符串,第 k 个对应第 k 步。取第 step 步字符串时,从头数第 step 个 \0(_getString,第 563 行):
cpp
// _getString(dim, i, step, str, x, y, z, style):
// 从记录 i(2D: x,y,style,index;3D: x,y,z,style,index)取坐标与字符串起始 index;
// 正文字符区间是 [index, 下一条记录的 index);在此区间数到第 step 个 '\0' 即第 step 步的串
"下一条记录还不在"时 nbchar 取到正文末尾,处理得干净。_stringSpan(头文件第 105 行) 把这套"记录 → 正文字符区间"的算法抽成内联函数,供 combineSpace 复制字符串时复用。
9.5.9 编辑操作:reverseElement / smooth / setOrder2 / incrementList 家族
reverseElement(第 628 行):反转元素节点顺序(同时反向每步的值)。它先整块拷出 再反着写回,避免自覆盖:
cpp
// 先把坐标(3*numNodes)与值(numValues*numSteps)整块拷出,再反着写回,避免自覆盖
-
smooth()(第 654 行):把不连续数据光滑成 C0 ------对所有元素(点除外)的所有节点, 按"位置在eps内"分组,组内按到达顺序做在线均值 (running mean)。它先收集所有节点 指针,再用_mergePoints(xyz, lc*1e-8, num)做排序分组 ,然后按组算均值写回。注释强调 "their values are averaged in that order, as they always were"------顺序敏感 ,为兼容历史 行为。smooth()末尾把_smoothing = true; finalize(); _smoothing = false;,避免finalize里的自动平滑递归。 -
setOrder2(type)(第 969 行):一键把某类元素升到二阶 ------查BasisFactory的polynomialBasis,把它的系数/单项式直接装成插值矩阵:
cpp
// setOrder2(type):把某类元素升到二阶------按 type 映射到 MSH 二阶类型
// (LIN→MSH_LIN_3, TRI→TRI_6, QUA→QUA_9, TET→TET_10, HEX→HEX_27, PRI→PRI_18, PYR→PYR_14),
// 再取 BasisFactory::getNodalBasis 的 coefficients/monomials 装成插值矩阵
这直接连到第 7 章的 BasisFactory:后处理借用了网格层的基函数设施,不做重复实现。
- "增量式"填充家族(给插件/脚本用):
cpp
// the list of the elements of a type with numComp components, their number
// incremented: the caller appends the element to it (coordinates, then values).
// Null for a kind the lists cannot hold
std::vector<double> *incrementList(int numComp, int type, int numNodes = 0);
void appendList(int numComp, int type, int numElements, const std::vector<double> &values);
void addTime(double time) { _time.push_back(time); }
void clearList(int numComp, int type);
void clearStrings(int dim);
incrementList 的实现(第 999 行):
cpp
// incrementList(numComp, type, numNodes):把该组计数 +1 并返回数组引用(调用方随后 push
// 坐标再 push 值);多边形/多面体被排除(节点数可变,只能存在于网格式视图),返回 nullptr
std::vector<double> *PViewDataList::incrementList(int numComp, int type, int numNodes)
{ if(type == TYPE_POLYG || type == TYPE_POLYH) { /* 警告一次:跳过 */ return nullptr; }
const listKind *k = _kind(numComp, type); if(!k) return nullptr;
this->*k->num += 1; return &(this->*k->list); }
返回值就是该组数组的引用 ,调用方随后 push_back 坐标、再 push_back 值------这正是 DiscretizationError、HarmonicToTime、MathEval 等插件构造输出的标准姿势(见 9.9)。 注意多边形/多面体被明确排除:列表式数据"单元节点数必须固定",而多面体节点数可变,只能 存在于网格式视图里。
importList/importLists(第 242--245 行,头文件)是给 API 直接塞 24 组原始数组的后门:
cpp
virtual void importLists(int N[24], std::vector<double> *V[24]);
void importList(int index, int n, const std::vector<double> &v, bool finalize);
9.5.10 读写与合并
PViewDataList 自带 readPOS/writePOS/writeMSH(第 226--241 行),其中 writeMSH 的 签名和 PViewData 的虚接口一致,还有个静态重载"把多个视图写进一个 mesh":
cpp
bool readPOS(FILE *fp, double version, bool binary);
bool writePOS(const std::string &fileName, bool binary = false, bool parsed = true,
bool append = false);
virtual bool writeMSH(const std::string &fileName, double version = 2.2,
bool binary = false, bool savemesh = true, bool multipleView = false,
int partitionNum = -1, bool saveInterpolationMatrices = true,
bool forceNodeData = false, bool forceElementData = false);
static bool writeMSH(const std::string &fileName, const std::vector<PViewDataList *> &views,
double version, bool binary, bool saveMesh, bool multipleView, ...);
合并的两条路(第 743、819 行):
combineSpace:要求各视图时间步数一致 ,然后直接dVecMerge把每组数组串接、 计数相加;字符串按"记录重编索引"合并;名字合成xxx_Combine。combineTime:要求元素结构一致 (同类同数),把每个元素的坐标只保留一次(来自 参照视图),把各视图的值按序拼接;最后若所有时间值相同则清空时间(注释推测"原视图 本来就没有时间数据"),退回步号。
两者都返回 finalize() 的结果------合并后必须重新统计。
_getRawData(int idxtype, ...)(第 949 行)是两者的共同工具:给定组索引,吐出"数组指针、 计数指针、分量数、节点数",并若该类型有插值矩阵则用几何函数数覆盖节点数:
cpp
// _getRawData(i, &list, &num, &numComp, &numNodes):给 smooth/combine 取"数组指针、
// 计数指针、分量数、节点数";若有 4 个插值矩阵,节点数用几何函数数覆盖
getMemoryInMB(第 720 行)把 12 组 double 数组 + 时间数组的字节数相加,除以 1MiB------用于 "View 内存用量"统计。
9.6 PViewDataGModel:挂在网格上的数据
9.6.1 DataType:五种数据
与容器式相对的是 src/post/PViewDataGModel.h,它的数据 直接挂在模型的网格单元/节点上(共享节点、有拓扑)。它先定义五种数据类型(第 223 行):
cpp
enum DataType {
NodeData = 1, // 值在网格节点上(共享)
ElementData = 2, // 每个单元一个值(在重心)
ElementNodeData = 3, // 每单元自己的节点各有一个值(可不共享)
GaussPointData = 4, // 值在高斯点上
BeamData = 5, // 梁/杆专用
};
对应关系:
权衡对照 :列表式无拓扑、坐标冗余、不要求与网格对应、不支持多面体/高斯点/多网格 , 适合文件/插件/散乱数据;网格式有拓扑、按 tag 紧凑存储、要求严格对应、支持多面体/高斯点/ 多网格,适合求解器/按网格导出的结果。
每种类型的关键差别:NodeData 值在共享的 MVertex 上(节点 id v->getNum()); ElementData 每单元一值(重心,无节点 id);ElementNodeData 值按单元内节点存(可不共享); GaussPointData 值在高斯点(getNodeId 返回 0);BeamData 用于梁单元。
Gmsh 通过 PViewData 抽象让绘制、插件、导出对两者一视同仁(9.3)。
9.6.2 stepData:按 tag 索引的稀疏数组 + pool 分配
网格式数据的每一步是 template <class Real> class stepData(第 16 行起)。核心是按 tag 索引的数组:
cpp
std::vector<Real *> _data; // 按 tag 索引:_data[tag] 指向该节点/单元的值
std::vector<std::unique_ptr<Real[]>> _pools; // 值内存的池(见 allocate())
Real *_poolNext; std::size_t _poolLeft, _poolSize;
std::vector<int> _mult; // 每个 tag 的值组数(以 getNumComponents() 为单位)
设计哲学是用 tag 当索引 :_data[tag] 直接指向该节点/单元的值。这带来 O(1) 读取、 且不需要在 MVertex/MElement 里存任何后处理字段 ------后处理与网格完全解耦。代价是 稀疏 tag 会浪费指针槽(数组长到"最大 tag"),但换来的是"读极快"。
值的内存不一个个 new,而是从池 里切(第 133 行 allocate):
cpp
Real *allocate(std::size_t n)
{
if(n > (1 << 14)) { // 大块单独分配
_pools.emplace_back(new Real[n]); return _pools.back().get();
}
if(n > _poolLeft) { // 小池翻倍,最多 64k
_poolSize = std::max(n, std::min<std::size_t>(2 * _poolSize, 1 << 16));
_pools.emplace_back(new Real[_poolSize]);
_poolNext = _pools.back().get(); _poolLeft = _poolSize;
}
Real *d = _poolNext; _poolNext += n; _poolLeft -= n; return d;
}
池化的动机 :一个百万节点、多分量、多步的数据若逐节点分配,会有百万次分配开销与碎片; 池化后是"几次大分配 + 指针切割"。_pools 用 unique_ptr 持有,析构即释放全部。
拷贝构造(第 60 行)深拷贝------因为池的生命周期是每 step 的:
cpp
stepData(stepData<Real> &other) : _poolNext(0), _poolLeft(0), _poolSize(128)
{
...
_data.assign(other._data.size(), nullptr);
for(std::size_t i = 0; i < _data.size(); i++) {
Real *d = other._data[i];
if(d) {
std::size_t m = other.getMult(i) * _numComp;
_data[i] = allocate(m);
std::copy(d, d + m, _data[i]);
}
}
...
}
_mult[tag] 记录"这个 tag 有几个值组"(以 getNumComponents() 为单位)。对 ElementNodeData/GaussPointData,一个单元可能存多个节点/高斯点的值,mult = 节点数;对 NodeData/ElementData,mult = 1------getMult 对超出范围的索引返回 1(第 112 行),这让 "缺省即 1"的语义无需显式填充。
9.6.3 getValue 的分派与 _getValue
网格式数据的取值按类型分派(第 636、657 行):
cpp
void PViewDataGModel::getValue(int step, int ent, int ele, int idx, double &val)
{
MElement *e = _getElement(step, ent, ele);
if(_type == ElementNodeData || _type == ElementData)
val = _steps[step]->getData(e->getNum())[idx]; // 按元素 tag
else if(_type == NodeData) {
int nc = _steps[step]->getNumComponents();
val = _steps[step]->getData(_getNode(e, idx / nc)->getNum())[idx % nc]; // 按节点 tag
}
else Msg::Error("getValue(index) should not be used on this type of view");
}
真正干活的是 _getValue,返回裸指针(第 657 行):
cpp
double *PViewDataGModel::_getValue(int step, MElement *e, int nod, int comp)
{
stepData<double> *s = _steps[step];
switch(_type) {
case NodeData: return &s->getData(_getNode(e, nod)->getNum())[comp]; // 按节点 tag
case ElementNodeData:
case GaussPointData:
if(s->getMult(e->getNum()) < nod + 1) { // 值数不足:退回第一个节点并警告一次
nod = 0; /* atomic<bool> warned.exchange(true) → Msg::Warning 一次 */ }
return &s->getData(e->getNum())[s->getNumComponents() * nod + comp];
case ElementData:
default: return &s->getData(e->getNum())[comp]; // 按元素 tag
}
}
注释点明一条容错:"element-node 和高斯点数据:每元素有的值数即它的值数,超出它的节点 取它第一个节点的值" 。这是"不同单元类型有不同节点数"时的优雅降级,且用 atomic<bool> 做 只警告一次。
getValue(step, ent, ele, nod, comp, val) 与 setValue 都是一行:
cpp
void PViewDataGModel::getValue(int step, int ent, int ele, int nod, int comp, double &val)
{ val = *_getValue(step, _getElement(step, ent, ele), nod, comp); }
void PViewDataGModel::setValue(int step, int ent, int ele, int nod, int comp, double val)
{ *_getValue(step, _getElement(step, ent, ele), nod, comp) = val; }
注意 _getValue 返回可写引用 ------所以网格式数据支持 Warp 之类的原地变形。
9.6.4 getNode / getNodeId / getNumNodes:共享节点
网格式数据的坐标直接来自 MVertex(第 568 行):
cpp
int PViewDataGModel::getNode(int step, int ent, int ele, int nod, double &x, double &y, double &z)
{
MElement *e = _getElement(step, ent, ele);
MVertex *v = _getNode(e, nod);
if(_type == GaussPointData) {
std::vector<double> &p(_steps[step]->getGaussPoints(e->getTypeForMSH()));
if(p[0] == 1.e22) { x = v->x(); y = v->y(); z = v->z(); } // 哨兵:高斯点退化为单元顶点
else { // 参数坐标 → 物理坐标
double vx[8], vy[8], vz[8];
for(i < e->getNumPrimaryVertices()) 取 e->getVertex(i)->x/y/z;
x = e->interpolate(vx, p[3*nod], p[3*nod+1], p[3*nod+2], 1, 1); ... }
return 0;
}
x = v->x(); y = v->y(); z = v->z(); return v->getIndex(); // 共享节点:坐标天然一致
}
要点:
- 非高斯点 :坐标就是
MVertex坐标------共享节点天然一致; - 高斯点 :坐标由"参数坐标 × 形函数"插值而来,用
e->interpolate(...)(网格层能力);p[0] == 1.e22是哨兵值:表示"高斯点就是单元顶点"(一种退化标记)。 getNodeId(第 539 行)返回v->getNum()(MVertex的全局编号),这就是"节点 id 稳定 且共享"的实现 ;GaussPointData直接返回 0("高斯点不是网格节点"):
cpp
std::size_t PViewDataGModel::getNodeId(int step, int ent, int ele, int nod)
{ if(_type == GaussPointData) return 0; // 高斯点不是网格节点
MElement *e = _getElement(step, ent, ele); if(!e) return 0;
MVertex *v = _getNode(e, nod); return v ? v->getNum() : 0; }
_getNode 处理高阶/复合单元 (第 526 行起):普通单元取 e->getVertex(nod),复合单元 (有子单元)取 e->getChild(nod / nbV)->getVertex(nod % nbV)------这覆盖了第 7 章的 MSubElement 复合表示。
getElementInfo(第 685 行)一次取全四要素;getNodesAndValues(第 695 行)对高斯点退回 基类朴素实现,其余直接读网格:
cpp
// getNodesAndValues:GaussPointData 退回基类朴素实现;其余直接读 MVertex------
// for(j) xyz[j] = v->x/y/z,再把 numComp 个值填进 val[j][k]
9.6.5 model signature:避免每步重复遍历
网格式数据要在"模型变了(读文件/改网格)"后重新取实体列表与包围盒。为此 stepData 记一个 模型签名 (第 25 行 _modelSignature),updateModelInfo(第 91 行)只在签名变化时才 重算,并且借用同一模型上其它步已经算好的实体/bbox:
cpp
void updateModelInfo(const std::vector<stepData<Real> *> &steps,
const std::vector<std::size_t> &signature)
{
if(signature == _modelSignature) return;
for(auto s : steps) // 借用同模型上已算好的步
if(s != this && s->_model == _model && s->_modelSignature == signature)
{ _entities = s->_entities; _bbox = s->_bbox; _modelSignature = signature; return; }
fillEntities(); computeBoundingBox(); _modelSignature = signature; // 首次:真去遍历
}
注释点明了动机:"going over all the nodes for each step, or for each block of data read, is slow"。签名由 _getStep(第 892 行)构造,包含网格内容 stamp、几何 stamp 之和、实体总数、 最大节点编号:
cpp
// 签名 = 网格内容 stamp + 几何 stamp 之和 + 实体总数 + 最大节点编号(一枚廉价指纹)
std::vector<std::size_t> signature = { ctx->meshContentStamp,
ctx->geom.stamp[0]+ctx->geom.stamp[1]+ctx->geom.stamp[2]+ctx->geom.stamp[3],
m->getNumRegions()+m->getNumFaces()+m->getNumEdges()+m->getNumVertices(), m->getMaxVertexNumber() };
_steps[step]->updateModelInfo(_steps, signature);
这是一枚"廉价指纹" :只读几个整数就能判断"网格几何是否变了"。_getStep 还兜住了两种 错误(同一步换模型 / 换分量数)并明确报错。
9.6.6 addData 三个重载:把结果"挂"上去
PViewDataGModelIO.cpp 里有三个 addData 重载,服务不同来源:
cpp
// 1) 最通用:按 tag 的 map<tag, vector<double>>
// 2) 标签 + 每标签一个 vector(各自长度可不同 → 支持 element-node 数据),可 minMax=false
// 3) 标签 + 同质 vector(每标签同样多值)
bool addData(GModel *model, const std::map<int, std::vector<double> > &data, ...);
bool addData(GModel *model, const std::vector<std::size_t> &tags,
const std::vector<std::vector<double>> &data, ..., bool minMax = true);
bool addData(GModel *model, const std::vector<std::size_t> &tags,
const std::vector<double> &data, ...);
第一个的实现(src/post/PViewDataGModelIO.cpp 第 12 行) 把流程串清楚了:
cpp
bool PViewDataGModel::addData(GModel *model, const std::map<int, std::vector<double> > &data,
int step, double time, int partition, int numComp)
{
if(data.empty() || step < 0) return false;
changed();
if(numComp < 0) { numComp = 9; // 未指定:按最短的推断
for(auto it = data.begin(); it != data.end(); it++)
numComp = std::min(numComp, (int)it->second.size()); }
if(!_getStep(step, model, numComp)) return false;
_steps[step]->setTime(time);
_steps[step]->resizeData((_type == NodeData) ? model->getMaxVertexNumber() + 1
: model->getMaxElementNumber() + 1);
for(auto it = data.begin(); it != data.end(); it++) { // 按 tag 写入
int mult = it->second.size() / numComp;
double *d = _steps[step]->getData(it->first, true, mult); // allocIfNeeded=true
for(int j = 0; j < numComp * mult; j++) d[j] = it->second[j];
}
if(partition >= 0) _steps[step]->getPartitions().insert(partition);
_finalizeStep(step);
return true;
}
resizeData 把指针数组扩到"最大 tag + 1"(NodeData 用 getMaxVertexNumber(),其余用 getMaxElementNumber());getData(tag, true, mult) 第二参 allocIfNeeded=true 会在需要时分配 并清零。第三重载校验 stride % numComp,出错即 Msg::Error 拒绝。
这就是 API/脚本 gmshViewAddNodeData/gmshViewAddElementData、以及 Python/Julia 绑定 "把数组直接变成视图"的底层。
9.6.7 readInView:增量读入与"继续追加"
文件读取时,Gmsh 常有"把新读到的一步加进已有视图"的需求。readInView(第 929 行)负责 "找到可接受的现有视图,或新建一个":
cpp
// readInView(name, fileName, type, accept, read):逆序在 PView::list 里找同名且 accept()
// 同意的网格式视图,找到就复用、否则新建;read() 成功后改名/文件名(新建的挂成 PView),
// 失败时"新建的删掉、复用的保持原样"
// canAddData(type, step, numComp):类型必须一致,且该步为空或其分量数相同
两个回调分别回答"这个现有视图能不能接收(accept)"与"往它里面读(read)"。读失败时 若视图是新建的就删掉,若是复用的就保持原样 ------很稳妥。canAddData(第 951 行)是配套判定:
cpp
bool PViewDataGModel::canAddData(DataType type, int step, int numComp)
{
if(type != _type) return false;
return !hasTimeStep(step) || _steps[step]->getNumComponents() == numComp;
}
9.6.8 多网格、分区与表皮键
hasMultipleMeshes(第 964 行)判断"各步是否落在不同 GModel 上";forEachMesh(头文件第 329 行)与其配套:
cpp
// forEachMesh(f):对"各步落在不同网格"的视图,按"同一网格上连续的一段步"调用 f,
// 调用时视图只持有那一段(其余为空),以便把它当成单网格视图写出
bool forEachMesh(const std::function<bool(int)> &f);
这正是 PView::write 里"多网格视图写多个文件"的实现(9.7.1)。
分区(hasPartition,第 957 行)与表皮键(getSkinKeys,第 549 行)服务于并行/分区数据:
cpp
// getSkinKeys(step, partitionsTogether, keys):给每个实体一个"表皮键"------
// 分区实体的键归到其父实体(编号排在所有实体之后),于是按实体取表皮时,
// 同一父实体的分区不会把公共面误判为表皮
分区实体共享一个"父实体键"(编号排在所有实体之后),于是"按实体分块取表皮"时,同一父 实体的分区不会把公共面误判为表皮(呼应第 2 章的 partition/ghost 与第 6 章的并行)。
getValueByIndex(第 983 行)则提供了一个绕过 MElement 的快速取值通道,供大规模导出 使用:
cpp
// getValueByIndex(step, dataIndex, nod, comp, val):绕过 MElement 的快速取值------
// NodeData/ElementData 取 d[comp],其余取 d[numComponents*nod + comp](供大规模导出)
9.7 结果文件读写
9.7.1 write():同一入口,多格式分派
所有视图导出都经 PView::write(src/post/PViewIO.cpp 第 301 行)。 它先按扩展名解析 AUTO 格式,再分派:
cpp
// the formats of write(), as in PostProcessing.Format
enum Format {
POS_ASCII = 0, POS_BINARY = 1, POS_PARSED = 2, STL = 3, TXT = 4,
MSH = 5, MED = 6, X3D = 7, VTU = 8, AUTO = 10 // from the extension of the file
};
扩展名映射:
cpp
if(format == AUTO) {
std::string ext = SplitFileName(fileName)[2];
format = (ext == ".pos") ? (CTX::instance()->post.binary ? POS_BINARY : POS_PARSED)
: (ext == ".stl") ? STL : (ext == ".msh") ? MSH : (ext == ".med") ? MED
: (ext == ".x3d") ? X3D
: (ext == ".vtu" || ext == ".pvtu" || ext == ".pvd") ? VTU : TXT;
}
注意 .pos 默认写成 "parsed"(可被脚本解析的文本)而非裸 ASCII ------注释和枚举名 (POS_PARSED)都强调了这一点:parsed 格式还能被 Gmsh 脚本解析器回读为 View 命令。
单格式写入统一走 writeData(第 280 行):
cpp
// writeData:按 format 分派到 PViewData 的虚写函数------
// POS_ASCII/POS_BINARY/POS_PARSED → writePOS(..., binary, parsed, append)
// STL/TXT → writeSTL/writeTXT;MSH → writeMSH(版本/二进制/存网格/... 均取 CTX 设置)
// MED → writeMED;未知格式 → Msg::Error("Unknown view format %d", format)
因为 writePOS/writeMSH 等都是 PViewData 的虚函数,列表式与网格式各自实现一遍 ------同 一条 write 命令对两类数据都成立。
write 处理了三种"一个视图对应多个文件"的情况:
cpp
// write 的三条"多文件"分支:
if(format == VTU) ret = writeVTU(fileName, CTX::instance()->post.binary, {this}, &files);
else if(format == X3D) ret = writeX3D(fileName);
else if(savesAdapted()) { // 高阶视图"按自适应网格"保存:每步一个文件
std::vector<PViewDataList *> steps = getAdaptedSteps();
for(step) if(steps[step] && !writeFile(steps[step], name(step))) ret = false;
doneSaving();
}
else if(savesSeveralMeshes()) // 多网格:每块网格一个文件(forEachMesh)
ret = ((PViewDataGModel *)_data)->forEachMesh([&](int step){ return writeFile(_data, name(step)); });
else ret = writeFile(_data, fileName);
文件命名用 _%04d 后缀区分步/网格;written 输出"写出的文件 + 是否含网格"。若调用者没给 written,就自动生成回读脚本:
cpp
if(written) written->insert(written->end(), files.begin(), files.end());
else CreateReadBackScript(fileName, files);
回读脚本(read-back script) 是个很贴心的设计:把"a.pvtu + 分块"这一堆文件写成一段 .geo 脚本,下次打开该脚本即可还原这些视图。
9.7.2 .pos:Gmsh 原生后处理格式
PView::readPOS(第 18 行)是流式扫描器------它按 $ 起始的段读整个文件,遇到 $PostFormat 取版本/格式/精度,遇到 $View 就造一个 PViewDataList 并交给 readPOS:
cpp
// readPOS 的骨架(流式扫描 $ 段):
while(1) {
while(str[0] != '$') { if(!fgets(str, sizeof(str), fp) || feof(fp)) break; }
if(feof(fp)) break;
if(!strncmp(&str[1], "PostFormat", 10)) { // 版本/格式/精度
fscanf(fp, "%lf %d %d\n", &version, &format, &size);
if(version < 1.0) { /* too old */ }
if(size != sizeof(double)) { /* unknown size */ }
}
else if(!strncmp(&str[1], "View", 4)) { // 每个 $View 段一个视图
index++;
if(fileIndex < 0 || fileIndex == index) {
PViewDataList *d = new PViewDataList();
if(d->readPOS(fp, version, format ? true : false)) {
d->setFileName(fileName); d->setFileIndex(index); new PView(d);
} else delete d; // 失败即释放
}
}
do { if(!fgets(str, sizeof(str), fp) || feof(fp)) break; } while(str[0] != '$');
}
要点:版本校验 (version < 1.0 拒绝)、精度校验 (只接受 sizeof(double));format 决定 binary ;多个 $View 段 → 多个视图 (fileIndex 可只读其一);失败即 delete。
9.7.3 .msh:视图段与插值方案
MSH 文件既能存网格也能存视图。视图段由 readMSHViewData(第 123 行)读,先认 dataType 字符串(NodeData/ElementData/...)再取 PViewDataGModel::DataType。MSH 里的视图通常带 插值方案 ,由 readMSHInterpolationScheme(第 83 行)读:
cpp
bool PView::readMSHInterpolationScheme(FILE *fp)
{
std::string name = ExtractDoubleQuotedString(str, sizeof(str));
PViewData::removeInterpolationScheme(name); // 先清同名
int numTypes; if(fscanf(fp, "%d", &numTypes) != 1) return false;
for(int i = 0; i < numTypes; i++) { // 每类元素一个矩阵集
int type, numMatrices; if(fscanf(fp, "%d %d", &type, &numMatrices) != 2) return false;
for(int j = 0; j < numMatrices; j++) {
int m, n; if(fscanf(fp, "%d %d", &m, &n) != 2) return false;
fullMatrix<double> mat(m, n);
for(k,l) { double d; if(fscanf(fp, "%lf", &d) != 1) return false; mat.set(k, l, d); }
PViewData::addMatrixToInterpolationScheme(name, type, mat);
}
}
return true;
}
它读的是"一个方案名 → 若干 (元素类型, 矩阵) 对 ",存进 PViewData::_interpolationSchemes 全局表(9.3.7)。这样多个视图、乃至同一个文件的多段数据,可以共享同一套高阶插值矩阵 ------ 避免每个视图重复存矩阵。写侧的开关是 CTX::instance()->post.saveInterpolationMatrices。
9.7.4 VTK / VTU
VTU(VTK XML UnstructuredGrid)由 PView::writeVTU(src/post/PViewIO_VTU.cpp) 实现,能一次写多个视图 + 可选网格:
cpp
static bool writeVTU(const std::string &fileName, bool binary,
const std::vector<PView *> &views,
std::vector<std::pair<std::string, bool> > *written = nullptr);
它同时服务两个场景:
PView::write里写单个/单类视图({this});- 导出"ParaView 可直接打开"的
.pvtu/.pvd集合。
VTU 与列表式数据的对应是天然的:VTU 的 Points + Cells(每单元带自己的连接)本来就是 "不连续表示" ,所以列表式视图转到 VTU 几乎是一一映射;网格式视图则先取网格的节点/单元再 写。Gmsh 也用它实现自适应视图另存 (PViewData::saveAdaptedViewForVTK,9.10)。
9.7.5 MED
MED(src/post/PViewIO.cpp 第 248 行)走 PViewDataGModel,因为它 天然绑定网格:
cpp
// readMED(fileName, fileIndex):对文件里每个字段,用 readInView 把它读进
// "同名且不含本文件"的网格式视图(域分解:多文件补全同一视图);失败即报错返回 false
注意注释里的洞见:MED 把"分块/分区"视作"同一视图的多个文件" ------每个文件补上一个 字段的对应步,靠 readInView + accept 把 hasFileName 之外的数据追加进来。这正是 MED 在域分解(domain decomposition)里的典型用法。HAVE_MED 未定义时给出明确的编译提示。
9.7.6 CGNS
CGNS(PView::readCGNS,src/post/PViewIO_CGNS.cpp)接收 每 zone 的顶点表与单元表,再逐 zone 读解:
cpp
// readCGNS(vertPerZone, eltPerZone, fileName):接收"已经建好的网格对象",
// 逐 zone 把解场映射上去;另一重载 readCGNS(solFieldName, fileName, fileIndex,
// baseIndex, ...) 指定要读哪个解场
它接收已经建好的网格对象 (MVertex*/MElement*)------CGNS 的网格由主读取器先建,视图读取器 只负责把解场映射上去,这正是网格式数据的用武之地。
9.7.7 X3D / STL / TXT
- X3D (
PView::writeX3D,src/post/PViewX3D.cpp):写成可用于 网页/VRML 浏览器的 3D 场景(几何 + 颜色)。 - STL :只写三角面(
writeSTL),回读时不被认作视图 (write里 STL/TXT 会files.clear(), 即不生成回读脚本)。TXT :writeTXT把"节点坐标 + 值"写成纯文本表格,便于导入第三方工具。
9.7.8 二进制 / ASCII、流式与大文件、错误处理
综合这一节,可抽出几条后处理 I/O 的通用工程考虑:二进制/ASCII 依格式各有开关(.pos 用 format、MSH 用 mesh.binary、VTU 用 post.binary);字节序 由 swap 参数处理; 版本兼容 靠 .pos 的 version >= 1.0 与 MSH 的 mshFileVersion 分支;大文件 一律 fscanf/fgets 流式解析、不整文件读入;错误处理 是"每个 fscanf 都判返回值,失败即 fclose 返回 false";增量追加 靠 readInView;半成品 一律 delete,不挂到全局列表。
9.8 插件体系
9.8.1 两类插件与基类契约
插件(src/plugin/)是 Gmsh 最可扩展的部分,设计目标是:在不改动核心代码的前提下,给 后处理/网格增加新运算 。src/plugin/Plugin.h 开头注释定了基调:
cpp
// A plugin is a class derived from GMSH_PostPlugin (a plugin acting on a
// view) or GMSH_MeshPlugin (on the model). Besides the plugins built into Gmsh,
// plugins can be loaded from shared libraries (see PluginManager.h): such a
// library defines its plugin with GMSH_PLUGIN() below, and is built against the
// headers of the private API and the shared Gmsh library.
#define GMSH_PLUGIN_API_VERSION 2
基类 GMSH_Plugin(第 50 行)是插件契约:
cpp
class GMSH_Plugin {
public:
typedef enum { GMSH_MESH_PLUGIN, GMSH_POST_PLUGIN } GMSH_PLUGIN_TYPE;
PluginDialogBox *dialogBox; // 给 UI 用的对话框
// 数值/字符串选项,带默认值
GMSH_Plugin(const std::vector<StringXNumber> &numOptions = {},
const std::vector<StringXString> &strOptions = {});
virtual GMSH_PLUGIN_TYPE getType() const = 0;
virtual std::string getName() const = 0;
virtual std::string getShortHelp() const = 0;
virtual std::string getHelp() const = 0;
virtual std::string getAuthor() const { return "C. Geuzaine, J.-F. Remacle"; }
virtual std::string getCopyright() const { return "C. Geuzaine, J.-F. Remacle"; }
int getNbOptions() const { return (int)_numOptions.size(); }
StringXNumber *getOption(int iopt) { return &_numOptions[iopt]; }
int getNbOptionsStr() const { return (int)_strOptions.size(); }
StringXString *getOptionStr(int iopt) { return &_strOptions[iopt]; }
StringXNumber *findOption(const std::string &name); // 支持别名
StringXString *findOptionStr(const std::string &name);
void addOptionAlias(const std::string &alias, const std::string &name);
void resetOptions(); // 恢复默认值
...
virtual int run() = 0; // 运行插件
};
两类插件的分叉在于 GMSH_PostPlugin 与 GMSH_MeshPlugin:
cpp
class GMSH_MeshPlugin : public GMSH_Plugin {
public:
using GMSH_Plugin::GMSH_Plugin;
inline GMSH_PLUGIN_TYPE getType() const { return GMSH_Plugin::GMSH_MESH_PLUGIN; }
virtual int run() { return 0; }
};
GMSH_MESH_PLUGIN 作用于模型/网格(如 VoroMetal、Bubbles、MeshVolume), GMSH_POST_PLUGIN 作用于视图。后者把 run() 默认实现成"执行 execute 并返回新视图 tag":
cpp
class GMSH_PostPlugin : public GMSH_Plugin {
public:
using GMSH_Plugin::GMSH_Plugin;
inline GMSH_PLUGIN_TYPE getType() const { return GMSH_Plugin::GMSH_POST_PLUGIN; }
virtual int run()
{
PView *v = execute(nullptr);
if(v) return v->getTag();
return 0;
}
// if the returned pointer is the same as the argument, then the view is
// simply modified, else, a new view is added in the view list
virtual PView *execute(PView *) = 0;
...
};
关键约定 :execute 返回的指针若等于入参 view ,就是"原地修改该视图";否则新视图 被加入全局列表。这一条决定了脚本里 Plugin("X") 是"改现有视图"还是"建新视图"。
9.8.2 参数系统:StringXNumber / StringXString
插件参数就是一对对"名字 + 默认值"的选项对象(第 59 行)。StringXNumber 至少携带 {GMSH_FULLRC, "A", nullptr, 1., ""}(种类、名、回调、默认值、帮助),StringXString 类似 但默认值是 std::string。插件里读某个数值参数就是 option(iopt):
cpp
protected:
double &option(int iopt) { return getOption(iopt)->def; }
double option(int iopt) const { return const_cast<GMSH_Plugin *>(this)->getOption(iopt)->def; }
std::string &optionStr(int iopt) { return getOptionStr(iopt)->def; }
bool optionIntList(int iopt, std::vector<int> &list); // 逗号分隔的整数表
这正是脚本 Plugin("CutPlane").A = 1; 的底层:PluginManager::setPluginOption 找到插件、 按名找到选项、写 def:
cpp
void PluginManager::setPluginOption(const std::string &pluginName,
const std::string &option, double value)
{
GMSH_Plugin *plugin = find(pluginName);
if(!plugin) throw std::runtime_error("Unknown plugin '" + pluginName + "'");
StringXNumber *sxn = plugin->findOption(option);
if(sxn) { sxn->def = value; return; }
throw std::runtime_error("Unknown option '" + option + "' of Plugin(" + pluginName + ")");
}
optionIntList(src/plugin/Plugin.cpp 第 76 行)顺手处理 "逗号分隔整数"的解析,并对非法字符报错:
cpp
// optionIntList(iopt, list):把字符串选项按逗号解析成整数表;遇非法字符报错并返回 false
9.8.3 别名、序列化与 resetOptions
三个小设施:别名 (addOptionAlias,让旧脚本的老选项名命中新名------向后兼容)、 resetOptions (恢复构造时的默认------对话框"Reset"按钮)、serialize (把当前选项反向 生成脚本 Plugin(X).A=...; Plugin(X).Run;,是 ONELAB 交互与 GUI 操作日志的基础)。
cpp
std::string GMSH_Plugin::serialize()
// 反向生成脚本:Plugin(X).<option>=<value>; ... Plugin(X).Run;
// findOption(name):先查别名表 _aliases,再按名匹配 _numOptions/_strOptions
// resetOptions():_numOptions = _numDefaults; _strOptions = _strDefaults;
sliderOption(第 93 行)把"滑杆编辑 + 实时预览"抽象成一个函数:
cpp
// sliderOption(iopt, action, value, step, min, max):action=1/2/3 返回步长/最小/最大;
// 其余 action 为"设置并刷新预览"(option(iopt) = value; setPreview(this))
action 编码"GUI 想干什么":1/2/3 是查询,0/其它是设置。setPreview 在 HAVE_OPENGL 下 把当前插件设为全局 preview 并触发一次快速重画(第 108 行)。
9.8.4 GMSH_PostPlugin 的工具箱
后处理插件有一整套"把视图数据喂给它"的现成工具(第 153--211 行):
cpp
virtual PView *executeRemote(PView *); // 远程数据集上执行
virtual PView *getView(int index, PView *view); // index<0 用默认/最后视图
virtual PViewDataList *getDataList(PView *view, bool showError = true);
virtual PViewData *getPossiblyAdaptiveData(PView *view); // 有自适应数据就用它
int getNumCornerNodes(PViewData *data, int step, int ent, int ele) const;
void forEachNode(PViewData *data, const std::function<...> &f, int dim = -1) const;
void createListData(PViewData *data, const std::vector<PViewDataList *> &out,
const std::function<int(const PluginElement &e)> &numComp,
const std::function<bool(const PluginElement &e, int step,
std::vector<std::vector<double>> &res)> &values) const;
void forEachValue(PViewData *data, int step, const std::function<...> &f) const;
逐个看几个最有用的:
getView(第 163 行)------"index < 0 时用默认视图,否则用列表最后一个":
cpp
PView *GMSH_PostPlugin::getView(int index, PView *view)
{
if(index < 0) index = view ? view->getIndex() : PView::list.size() - 1;
if(index >= 0 && index < (int)PView::list.size()) return PView::list[index];
else { Msg::Error("View[%d] does not exist", index); return nullptr; }
}
这就是"View 选项默认 -1 → 作用于当前视图"的实现。
getDataList(第 190 行)------插件若只支持列表式数据,用它拿到强类型指针:
cpp
// getDataList(view, showError):dynamic_cast 到 PViewDataList;失败且 showError 时
// 报错"This plugin can only be run on list-based views (`.pos' files)"并返回 nullptr
getPossiblyAdaptiveData (第 176 行)------让插件看到的是"细化后的数据":
cpp
// getPossiblyAdaptiveData(view):先 adaptWhole()(若只细化了皮),再 getData(true) 取
// "细化后的数据";若视图是多步且已自适应,警告"只有当前时间步可用"(细化按当前步做)
PViewData *GMSH_PostPlugin::getPossiblyAdaptiveData(PView *view)
{ view->adaptWhole(); ... return view->getData(true); }
这里有一条重要的权衡提示 :自适应细化后,插件只能看到当前时间步(因为细化是按当前 步做的),所以多步视图会收到警告。
getNumCornerNodes(第 218 行)------决定一个元素读几个节点:
cpp
// numCorners(type):PTN/LIN/TRI/QUA/TET/PYR/PRI/HEX = 1/2/3/4/4/5/6/8,多面体=0(全节点)
// getNumCornerNodes(data, step, ent, ele):
// GaussPointData → 警告一次并返回 0(跳过)
// 高阶单元(numNodes>角点数)→ 只取角点,并警告一次"only uses the corners"
// 其余 → 返回 numNodes
它服务两条约定(头文件第 163--167 行注释):
- "插件只写一阶元素"(列表数据只会存角点);
- "插件用
shapeFunctions.h的 element 类,而它只认一阶"。
所以高阶单元只取它的角点 (角点在数组最前,注释特别强调"which come first"),并只警告 一次 (_warnedCorners)。GaussPointData 直接返回 0(跳过)。
9.8.5 回调解耦:forEachNode / forEachValue / createListData
这套接口的设计很现代:用 std::function 回调把"遍历视图"与"插件的具体计算"解耦。
forEachNode(第 247 行)------"遍历一个视图的每个节点",供"原地修改视图"的插件:
cpp
// forEachNode(data, f, dim):从第一个非空步起遍历实体/元素/节点并逐个回调;dim>=0 时只走该
// 维度的元素;GaussPointData 直接警告返回;网格式视图用 firstVisit 保证共享节点只访问一次
注意 firstVisit :对网格式视图共享节点只访问一次 (这正是"节点数据"的语义),对 列表式则每个元素各自访问。dim >= 0 时只走指定维度的元素。Warp、Transform、 SphericalRaise 这类"现场变形"插件都基于它。
forEachValue(data, step, f)(第 276 行)遍历每个"值所在的位置":节点数据里共享节点只报 一次、单元数据每单元只报一次(在重心) 。它的实现与 forEachNode 同构(外层两层循环 + 内层 逐节点 + firstVisit 去重),只是对 ElementData 先算重心再 f(ent, ele, 0, xc, yc, zc)。
createListData(第 335 行)------最强大的一件:插件只提供两个回调,它负责构造输出视图:
cpp
void GMSH_PostPlugin::createListData(
PViewData *data, const std::vector<PViewDataList *> &out,
const std::function<int(const PluginElement &e)> &numComp,
const std::function<bool(const PluginElement &e, int step,
std::vector<std::vector<double> > &res)> &values) const
{
int step0 = data->getFirstNonEmptyTimeStep();
std::vector<int> steps; // 有数据的步
for(int step = step0; step < data->getNumTimeSteps(); step++)
if(data->hasTimeStep(step)) steps.push_back(step);
std::vector<std::vector<double> > res(out.size()), all(out.size());
for(每个 ent → 每个 ele) {
if(data->skipElement(step0, ent, ele)) continue;
PluginElement e; // 填 ent/ele/numNodes(角点)/type/dim/numComp 与角点坐标 x,y,z
int nc = numComp(e); // ① 输出几个分量?0 = 跳过
if(!nc) continue;
// all the values first: an element is written whole or not at all
bool ok = true;
for(int step : steps) {
if(!values(e, step, res)) { ok = false; break; } // ② 逐步骤计算
if(res[i].size() != e.numNodes * nc) { Msg::Error(...); return; } // 框架强校验
all[i].insert(all[i].end(), res[i].begin(), res[i].end());
}
if(!ok) continue;
std::vector<double> *l = out[i]->incrementList(nc, e.type, e.numNodes);
l->insert(l->end(), e.x.begin(), e.x.end());
l->insert(l->end(), e.y.begin(), e.y.end());
l->insert(l->end(), e.z.begin(), e.z.end());
l->insert(l->end(), all[i].begin(), all[i].end());
}
for(auto o : out) for(int step : steps) o->addTime(data->getTime(step));
}
逐条拆解:
- 两个回调即插件的全部逻辑 :
numComp(e)回答"这个元素输出几个分量(1/3/9;0 跳过)",values(e, step, res)逐步骤算出res[i](numNodes*nc个值,node-after-node)。 - 先算全部值再写入 :注释"an element is written whole or not at all"------某步失败就整体丢弃, 绝不写半截。
- 多输出 / 强校验 / 带时间 :
out是vector<PViewDataList*>(可同时产出多个视图);res[i].size() != numNodes*nc立即报错并中止;最后自动带出各步getTime。
这正是 Lambda2 那样十几行就能写出的原因(9.9.6)。
9.8.6 PluginElement 与 shapeFunctions
PluginElement(第 36 行)是传给插件的"元素视图":它的角点(对高阶元素而言)。
cpp
class PluginElement { // 传给插件的"元素视图":ent/ele/type/dim/numNodes/numComp + 角点 x,y,z
mutable element *_shape = nullptr;
public:
element *shape() const; // 惰性:首次调用用 elementFactory 造 shapeFunctions.h 的元素
void getValues(PViewData *data, int step, std::vector<double> &val) const; // 一次取全角点值
};
shape() 是惰性构造 (第 315 行)------第一次调用时才用 elementFactory 造出对应类型的 shapeFunctions.h 元素:
cpp
// shape():惰性构造------首次调用用 elementFactory::create(numNodes, dim, x,y,z)
// 造出对应类型的 shapeFunctions.h 元素(只认一阶角点)
// getValues(data, step, val):val.resize(numNodes*numComp) 后双重循环取全角点、全分量的值
getValues 一次取全该元素所有角点、所有分量的值(第 326 行):
cpp
// getValues(data, step, val):val.resize(numNodes*numComp),双重循环
// for(nod) for(comp) val[numComp*nod + comp] = data->getValue(step, ent, ele, nod, comp)
shapeFunctions.h 的 element 类只处理角节点(低阶) ,服务于通用后处理------这与 numeric 的 nodalBasis 不同:后者服务高阶有限元。注释直说这一点:"the corners of a higher order element, which come first"。有了 element,插件就能做单元内插值/梯度 (Lambda2 用它 把节点速度插值成速度梯度,9.9.5)。
9.8.7 自适应插件的挂钩
当视图是自适应的时候,插件可能需要"在细化树里做取舍"。GMSH_PostPlugin 留了一组虚钩子 (第 197--210 行):
cpp
// 自适应插件挂钩(默认都不做事):
// mayKeep(sphere) :这个球内不可能保留任何东西就整块跳过(剪枝,最先问)
// keepsNothing(root, vertices) :顶点信息已够判定"整个元素都不要"时提前返回
// assignSpecificVisibility(root) :自己决定细分树里哪些子元素可见
// valuesNeeded() :是否真的需要读值(不需要则更快)
virtual void assignSpecificVisibility(adaptiveElement *root) const {}
virtual bool valuesNeeded() const { return true; }
virtual bool keepsNothing(adaptiveElement *root, const std::set<adaptiveVertex> &vertices) const
{ return false; }
virtual bool mayKeep(const float *sphere) const { return true; }
mayKeep(sphere):剪枝------若这个球(中心+半径)内不可能保留任何东西,就整块跳过, 省下读取;keepsNothing:顶点信息已足够判定"整个元素都不要"时提前返回;assignSpecificVisibility:自己决定树里哪些子元素可见;valuesNeeded():是否真的需要读值(不需要就更快)。
CutPlane::mayKeep(见 9.9.2)就是第一个钩子的典型实现------只让"离平面足够近"的细分子块 被保留。
9.8.8 PluginManager:单例注册表
src/plugin/PluginManager.h 是单例:
cpp
class PluginManager {
private:
PluginManager() {}
static PluginManager *_instance;
std::map<std::string, GMSH_Plugin *> allPlugins;
std::vector<void *> _libraries; // 加载过的共享库句柄
void add(GMSH_Plugin *p);
public:
virtual ~PluginManager();
void registerDefaultPlugins(); // 内置插件 + $GMSHPLUGINSHOME
static PluginManager *instance();
bool addPlugin(const std::string &fileName); // 从共享库加载
void addPlugins(const std::string &dirs); // 目录列表
void setPluginOption(const std::string &pluginName, const std::string &option, double value);
void setPluginOption(const std::string &pluginName, const std::string &option, const std::string &value);
std::map<std::string, GMSH_Plugin *>::iterator begin();
std::map<std::string, GMSH_Plugin *>::iterator end();
GMSH_Plugin *find(const std::string &pluginName);
int action(const std::string &pluginName, const std::string &action, void *data);
};
registerDefaultPlugins(src/plugin/PluginManager.cpp 第 163 行)把内置插件全部 new 出来:
cpp
void PluginManager::registerDefaultPlugins()
{
if(CTX::instance()->post.plugins) { // 可用 PostProcessing.Plugins=0 关闭
add(new GMSH_StreamLinesPlugin()); add(new GMSH_ParticlesPlugin());
add(new GMSH_CutGridPlugin()); add(new GMSH_IsosurfacePlugin());
add(new GMSH_CutPlanePlugin()); add(new GMSH_CutSpherePlugin());
add(new GMSH_CutBoxPlugin()); add(new GMSH_SkinPlugin());
add(new GMSH_MathEvalPlugin()); /* ... 其余内置插件同构 ... */
#if defined(HAVE_MESH) // 依赖可选模块的插件条件注册
add(new GMSH_AnalyseMeshQualityPlugin()); add(new GMSH_VoroMetalPlugin());
add(new GMSH_TetrahedralizePlugin()); add(new GMSH_MeshSizeFieldViewPlugin());
#endif
#if defined(HAVE_SOLVER)
add(new GMSH_DistancePlugin());
#endif
}
const char *home = getenv("GMSHPLUGINSHOME"); // 扫描目录里的动态库
if(home) addPlugins(home);
}
三个要点:
CTX::instance()->post.plugins开关 :可用PostProcessing.Plugins=0关掉所有插件;#if defined(HAVE_*)条件注册:依赖可选模块的插件(网格/求解器/边界层)只在编进相应 模块时才注册;$GMSHPLUGINSHOME:启动时扫描该目录的动态库。
add(name, plugin) 就是 allPlugins[p->getName()] = p;(第 161 行)------同名后到覆盖先到 由后面 addPlugin 的重复检查挡住(9.8.9)。
action(第 106 行)是脚本 Plugin("X") 的落点:
cpp
int PluginManager::action(const std::string &pluginName, const std::string &action, void *data)
{
GMSH_Plugin *plugin = find(pluginName);
if(!plugin) throw std::runtime_error("Unknown plugin '" + pluginName + "'");
if(action == "Run") { Msg::Info(...); int tag = plugin->run(); Msg::Info(...); return tag; }
else throw std::runtime_error("Unknown action '" + action + "' of Plugin(" + pluginName + ")");
}
即:Plugin("CutPlane").Run; == find("CutPlane") → run()(内部 execute)。 "未知插件/未知动作/未知选项"一律抛异常------这与很多"静默忽略"的代码不同,符合"宁可 响亮报错"的风格。
9.8.9 动态库加载与二进制 ABI 校验
外部插件从共享库加载(src/plugin/PluginManager.cpp 第 271 行)。没编出 HAVE_PLUGIN_LOADING 时给出一段很长的解释性错误(第 273 行):
cpp
#if !defined(HAVE_PLUGIN_LOADING)
Msg::Error("Cannot load plugin '%s': loading plugins requires Gmsh to be built as a "
"shared library with the private API (ENABLE_BUILD_DYNAMIC/ENABLE_BUILD_SHARED "
"and ENABLE_PRIVATE_API), on a system with dlopen", fileName.c_str());
return false;
#else
void *lib = dlopen(fileName.c_str(), RTLD_NOW | RTLD_LOCAL); // 立即解析、不污染全局符号表
if(!lib) { Msg::Error("Could not load plugin '%s' (%s)", fileName.c_str(), dlerror()); return false; }
// the functions defined by GMSH_PLUGIN() in Plugin.h
auto version = (int (*)())dlsym(lib, "GMSH_PluginApiVersion");
auto create = (GMSH_Plugin * (*)()) dlsym(lib, "GMSH_RegisterPlugin");
if(!version || !create) { // 不是 Gmsh 插件
Msg::Error("'%s' is not a Gmsh plugin (it does not define GMSH_PluginApiVersion() "
"and GMSH_RegisterPlugin(), which GMSH_PLUGIN() defines)", fileName.c_str());
dlclose(lib); return false;
}
if(version() != GMSH_PLUGIN_API_VERSION) { // 版本不匹配:提示重编
Msg::Error("Plugin '%s' was built for version %d of the plugin interface, "
"this Gmsh has version %d: rebuild it", fileName.c_str(),
version(), GMSH_PLUGIN_API_VERSION);
dlclose(lib); return false;
}
GMSH_Plugin *p = create();
if(!p) { Msg::Error("Plugin '%s' could not be created", fileName.c_str()); dlclose(lib); return false; }
std::string name = p->getName();
if(allPlugins.count(name)) { // 名字冲突:拒绝
Msg::Error("Plugin '%s' in '%s' is already defined", name.c_str(), fileName.c_str());
delete p; dlclose(lib); return false;
}
allPlugins[name] = p;
_libraries.push_back(lib); // 存句柄:防被卸载
Msg::Info("Loaded Plugin(%s) from '%s'", name.c_str(), fileName.c_str());
return true;
#endif
这就是插件 ABI 的完整实现 ,可归纳为四道闸门:能否 dlopen (RTLD_NOW | RTLD_LOCAL)→ 是否导出两个约定符号 → 版本号是否匹配 → 名字是否冲突。
只有四道全过才把插件加入 allPlugins,并把库句柄存进 _libraries。析构时先删插件对象、 再 dlclose 库 (第 88 行)------顺序不能反,因为插件对象的 vtable 和代码在库里:
cpp
PluginManager::~PluginManager()
{
// the plugins first: their code is in the libraries
for(auto it = allPlugins.begin(); it != allPlugins.end(); ++it) delete it->second;
#if defined(HAVE_PLUGIN_LOADING)
for(auto lib : _libraries) dlclose(lib);
#endif
_instance = nullptr;
}
RTLD_LOCAL + "句柄存起来不主动卸载"保证了插件对象存活期间库不被卸载。addPlugins(第 240 行)扫描目录(.so/.dylib/.dll),并按 PATH 式分隔符(Windows 为 ;,其它为 :) 切分多个目录,并对文件排序("in the same order everywhere",保证跨平台加载顺序一致)。
9.8.10 GMSH_PLUGIN 宏:约定导出符号
一个共享库插件只需在某个源文件里写一行(第 231 行):
cpp
#if defined(_WIN32)
#define GMSH_PLUGIN_EXPORT extern "C" __declspec(dllexport)
#else
#define GMSH_PLUGIN_EXPORT extern "C" __attribute__((visibility("default")))
#endif
#define GMSH_PLUGIN(className) \
GMSH_PLUGIN_EXPORT int GMSH_PluginApiVersion() \
{ \
return GMSH_PLUGIN_API_VERSION; \
} \
GMSH_PLUGIN_EXPORT GMSH_Plugin *GMSH_RegisterPlugin() \
{ \
return new className(); \
}
用法就是 GMSH_PLUGIN(MyPlugin)。extern "C" 保证符号名不被 C++ 名字修饰(mangling) 破坏,于是 dlsym(lib, "GMSH_RegisterPlugin") 能稳定命中------这正是"约定导出符号 + 函数表" 这套 ABI 能跨编译器生效的关键。GMSH_PluginApiVersion 是函数表版本化:将来接口一变, 版本号递增,旧插件自动被拒绝,避免"vtable 不匹配"的静默崩溃。
9.8.11 插件与 GUI / 脚本 / API 的暴露
插件从三个入口暴露给用户,共享同一套选项对象 :脚本 Plugin("X").Opt=val; Plugin("X").Run; (setPluginOption 写 def、action(..,"Run") 触发,Plugin("X") 即 find + action);GUI 的 PluginDialogBox 由 getNbOptions/getOption(iopt)->str 生成控件,滑杆用 optionCallback/ sliderOption、预览用 drawPreview/setPreview;API 的 gmshPluginSetNumber/gmshPluginRun 包一层 PluginManager。serialize() 把当前状态反向变成脚本------GUI 的一次点击即可回放。
9.9 代表插件逐类剖析
9.9.1 插件全景与分类
src/plugin/ 下有 60 余个插件文件,按功能可粗分为:几何切割 (CutPlane/CutBox/ CutSphere(基于 GMSH_LevelsetPlugin)、CutGrid、CutParametric)、等值与流线 (Isosurface、Levelset、StreamLines、Particles、NearToFarField)、矢量/张量运算 (Gradient/Curl/Divergence、Eigenvalues/Eigenvectors、Lambda2、ModulusPhase、 Scal2Vec/Scal2Tens、Summation、HarmonicToTime)、质量与统计 (AnalyseMeshQuality、 MeshVolume、Integrate、MinMax、Probe、DiscretizationError、MeshSizeFieldView)、 拓扑与重划分 (HomologyComputation、Tetrahedralize、Triangulate、MeshSubEntities、 ExtractElements/ExtractEdges、Remove、SpanningTree)、网格生成辅助 (VoroMetal、 Bubbles、Warp、SphericalRaise、Crack、LongitudeLatitude)、综合/桥接 (MathEval、 Annotate、Skin、GaussPoints、ModifyComponents)。
说明:早期文档里常出现
Evaluate/MaxEval这类名字。在当前源码树 里,对应功能分别由MathEval(通用数学求值)与MinMax(极值/统计)承担,并不存在名为Evaluate/MaxEval的插件文件------引用时请以实际文件为准。
9.9.2 算子型:MathEval 与 MinMax
MathEval(src/plugin/MathEval.cpp)是后处理里最"通用"的 算子:用户给若干表达式,它逐节点求值,产出新视图。构造函数声明了 7 个数值选项与 9 个 字符串表达式:
cpp
GMSH_MathEvalPlugin::GMSH_MathEvalPlugin()
: GMSH_PostPlugin(
{{GMSH_FULLRC, "TimeStep", nullptr, -1., ""}, {GMSH_FULLRC, "View", nullptr, -1., ""},
{GMSH_FULLRC, "OtherTimeStep", nullptr, -1., ""}, {GMSH_FULLRC, "OtherView", nullptr, -1., ""},
{GMSH_FULLRC, "ForceInterpolation", nullptr, 0., ""},
{GMSH_FULLRC, "PhysicalGroup", nullptr, -1., ""}, {GMSH_FULLRC, "Dimension", nullptr, -1., ""}},
{{GMSH_FULLRC, "Expression0", nullptr, "Sqrt(v0^2+v1^2+v2^2)", ""}, // 默认:速度模长
{GMSH_FULLRC, "Expression1", nullptr, "", ""}, ... {GMSH_FULLRC, "Expression8", nullptr, "", ""}})
{ }
getHelp() 把语义讲得很完整:只给 Expression0 → 标量;给到 Expression0..2 → 矢量; 否则张量 ;可用符号 v0..vn(本视图分量)、w0..wn(另一视图分量)、x,y,z(坐标)。 选输出分量数(只给 Expression0 → 标量;给到 Expression2 → 矢量;否则张量):
cpp
int numComp2 = (expr[3..8] 任一非空) ? 9 : ((expr[1] || expr[2]) ? 3 : 1);
for(空的表达式) expr[i] = "0";
execute 的核心是"mathEvaluator + 逐元素逐节点求值 + 必要时跨网格插值":
cpp
const char *names[] = {"x","y","z","v0".."v8","w0".."w8"}; // 共 21 个可用符号
mathEvaluator f(expr, variables);
if(expr.empty()) return view; // 解析失败:表达式被清空
OctreePost *octree = nullptr;
if(forceInterpolation || 两视图网格不同) octree = new OctreePost(otherView); // 靠八叉树插值
std::vector<double> *out = data2->incrementList(numComp2, type, numNodes);
if(!out) continue;
for(三个方向 nod 循环) out->push_back(x/y/z[nod]); // 先坐标
// 再逐步骤逐节点:values = {x, y, z, v0..v8, w0..w8}
if(f.eval(values, res)) for(int i = 0; i < numComp2; i++) out->push_back(res[i]);
else { failed = true; goto end; } // 出错:不要半个元素
几个工程细节:goto end 清理 (求值失败即跳到统一出口、删掉半成品视图,与 9.8.5 的 "an element is written whole or not at all"一脉相承);PhysicalGroup/Dimension 过滤 ; 跨网格插值 (网格不同或用户强制插值时,用 OctreePost 逐节点 searchScalar/searchVector/ searchTensor)。
MinMax(src/plugin/MinMax.cpp)是配套的"统计"算子:在多视图 /多步/多分量上算极小/极大,并可把结果写成标注或视图,是"Early 文档里 MaxEval 概念"的实际 承载者。
9.9.3 几何型切割:GMSH_LevelsetPlugin 与 CutPlane
切割类插件共享一个基类 GMSH_LevelsetPlugin(src/plugin/Levelset.h), 它定义了"用一个标量函数 levelset(x,y,z,val) 把元素切开"的通用骨架。CutPlane 只是实现了 这个函数 并配置了几个开关(src/plugin/CutPlane.cpp):
cpp
double GMSH_CutPlanePlugin::levelset(double x, double y, double z, double val) const
{ return option(0) * x + option(1) * y + option(2) * z + option(3); }
即平面 A*x + B*y + C*z + D = 0。mayKeep 剪枝(只保留离平面足够近的球):
cpp
bool GMSH_CutPlanePlugin::mayKeep(const float *s) const
{
double n = std::sqrt(option(0) * option(0) + option(1) * option(1) + option(2) * option(2));
return !n || std::abs(levelset(s[0], s[1], s[2], 0.)) <= n * s[3];
}
execute 就是把选项搬进基类的字段,再调基类 execute:
cpp
PView *GMSH_CutPlanePlugin::execute(PView *v)
{
int iView = (int)option(7);
_ref[0] = option(0); _ref[1] = option(1); _ref[2] = option(2); // 平面系数 A,B,C
_valueIndependent = 1; _valueView = -1; _valueTimeStep = -1;
_orientation = GMSH_LevelsetPlugin::PLANE;
_extractVolume = (int)option(4); _recurLevel = (int)option(5);
_targetError = option(6); _visible = (int)option(8);
PView *v1 = getView(iView, v); if(!v1) return v;
return GMSH_LevelsetPlugin::execute(v1); // 复用基类管线
}
配套的 optionCallback 用 sliderOption 给每个参数定滑杆范围(A/B/C 在 -1,1、D 约 在 -lc,lc):
cpp
bool GMSH_CutPlanePlugin::optionCallback(int iopt, int num, int action, double &value)
{
if(action > 0) _iview = num;
double lc = CTX::instance()->lc;
switch(iopt) { // A/B/C 在 [-1,1]、D 约在 [-lc,lc]、RecurLevel 0..10、TargetError 0..1
case 0: case 1: case 2: return sliderOption(iopt, action, value, 0.01, -1, 1);
case 3: return sliderOption(iopt, action, value, lc / 200., -lc, lc);
case 4: return sliderOption(iopt, action, value, 1., -1, 1);
case 5: return sliderOption(iopt, action, value, 1, 0, 10);
case 6: return sliderOption(iopt, action, value, 0.01, 0., 1.);
default: return false;
}
}
CutBox/CutSphere 只是换了个 levelset 函数 (盒用 6 个平面取交,球用 |p-c|²-r²),其余 逻辑全部复用;CutGrid/CutParametric 不走 levelset,而是"在目标网格/参数曲面上对源视图插值 采样"。这套"抽象一个标量函数 + 复用整个切割管线"的设计,是 Gmsh 插件复用的典范。
9.9.4 变形型:Warp 与 Transform
Warp(src/plugin/Warp.cpp)原地 移动节点坐标:加一个(可选 缩放)矢量场。选项只有 5 个,getHelp 说明"OtherView<0 时用表面法向场":
cpp
GMSH_WarpPlugin::GMSH_WarpPlugin()
: GMSH_PostPlugin({{GMSH_FULLRC, "Factor", nullptr, 1., ""},
{GMSH_FULLRC, "TimeStep", nullptr, 0., ""},
{GMSH_FULLRC, "SmoothingAngle", nullptr, 180., ""},
{GMSH_FULLRC, "View", nullptr, -1., ""}, {GMSH_FULLRC, "OtherView", nullptr, -1., ""}})
{ }
execute 演示了 forEachNode 的原地变形用法(正是 9.8.5 的 forEachNode):
cpp
PView *GMSH_WarpPlugin::execute(PView *v)
{
double factor = option(0); int TimeStep = (int)option(1);
double AngleTol = option(2); int iView = (int)option(3), otherView = (int)option(4);
PView *v1 = getView(iView, v); if(!v1) return v;
bool useNormals = (otherView < 0);
PView *v2 = useNormals ? v1 : getView(otherView, v); if(!v2) return v;
PViewData *data1 = v1->getData(), *data2 = v2->getData();
if(data1->getNumEntities() != data2->getNumEntities() ||
data1->getNumElements() != data2->getNumElements()) {
Msg::Error("Incompatible views"); return v; // 结构必须对应
}
// useNormals 时先建 smooth_normals 并对 2D 元素累加法向(略)
forEachNode(data1, [&](int step, int ent, int ele, int nod) {
double x, y, z, mult = 1., val[3] = {0., 0., 0.};
data1->getNode(step, ent, ele, nod, x, y, z);
if(useNormals) { normal(step, ent, ele, val); normals->get(x, y, z, val[0], val[1], val[2]);
if(...) data1->getScalarValue(TimeStep, ent, ele, nod, mult); }
else if(data2->getNumComponents(TimeStep, ent, ele) == 3)
for(int comp = 0; comp < 3; comp++) data2->getValue(TimeStep, ent, ele, nod, comp, val[comp]);
data1->setNode(step, ent, ele, nod, x + factor*mult*val[0],
y + factor*mult*val[1], z + factor*mult*val[2]);
}, useNormals ? 2 : -1);
if(normals) delete normals;
data1->finalize(); // 坐标变了:必须重新统计
v1->setChanged(true);
return v1; // 返回入参 = 原地修改
}
三个可学之处:
- 返回入参
v1→ "原地修改"(脚本里就是修改当前视图,而非新建); data1->finalize()→ 坐标变了必须重算 min/max/bbox;useNormals ? 2 : -1→ 用表面法向时必须只对 2D 元素操作。
Transform(src/plugin/Transform.cpp)类似,但做的是 仿射变换 (缩放/旋转/平移),并且对坐标与矢量都做变换(因为矢量随坐标系旋转)。
9.9.5 网格/频域型:HarmonicToTime 与网格类插件
HarmonicToTime(src/plugin/HarmonicToTime.cpp)把 频域结果(实部 + 虚部)重建为时域 :这是声学/电磁里很常见的后处理。getHelp 给出公式:
text
View[RealPart] * cos(p) +- View[ImaginaryPart] * sin(p)
p = 2*Pi*k/NumSteps, k = 0,...,NumSteps-1
执行时逐元素读实部/虚部,逐时间步合成:
cpp
for(int k = 0; k < nSteps; k++) {
// if frequency == 0 is requested, simply use real part
double p = frequency ? 2. * M_PI * nPeriods * k / nSteps : 0.;
for(int nod = 0; nod < numNodes; nod++) {
for(int comp = 0; comp < numComp; comp++) {
double val = vr[numComp * nod + comp] * cos(p) +
tsign * vi[numComp * nod + comp] * sin(p);
out->push_back(val);
}
}
}
它演示了"手工按元素构造输出 "的另一种姿势(相比 createListData 回调):自己 incrementList、自己 push_back 坐标、再逐 k 推值,最后 addTime;frequency == 0 时只用 实部(实用的退化分支)。
网格类插件(GMSH_MeshPlugin 派生思路):MeshSubEntities 按子实体拆网格、Smooth 光滑网格/ 数据、Remove 删冗余、DuplicateNodes 复制节点、MeshVolume 累加单元体积、AnalyseMeshQuality 统计每单元质量度量(第 7 章的 gammaShapeMeasure 等)并写成视图。
9.9.6 物理型与"回调解耦"范例:Lambda2
Lambda2(src/plugin/Lambda2.cpp)计算湍流涡识别准则 λ₂------它是 9.8.5 的 createListData 的教科书式用法:整个插件只有两个回调。
cpp
GMSH_Lambda2Plugin::GMSH_Lambda2Plugin()
: GMSH_PostPlugin({{GMSH_FULLRC, "Eigenvalue", nullptr, 2., ""},
{GMSH_FULLRC, "View", nullptr, -1., ""}})
{
}
getHelp 讲清数学:lambda(1,2,3) 是张量 S_ik S_kj + Om_ik Om_kj 的特征值, S/Om 是速度梯度的对称/反对称部分;"λ₂ 为负的区域代表涡"。
cpp
PView *GMSH_Lambda2Plugin::execute(PView *v)
{
int ev = (int)option(0); // 取第几个特征值 (1/2/3)
int iView = (int)option(1);
if(ev < 1 || ev > 3) { Msg::Error("Eigenvalue should be 1, 2 or 3"); return v; }
PView *v1 = getView(iView, v); if(!v1) return v;
PViewData *data1 = getPossiblyAdaptiveData(v1);
PView *v2 = new PView(); PViewDataList *data2 = getDataList(v2);
createListData(data1, {data2},
// ① 分量数:只有 3(矢量) 或 9(张量) 输入才有输出,均产出 1 个标量
[](const PluginElement &e) { return (e.numComp == 3 || e.numComp == 9) ? 1 : 0; },
// ② 逐元素逐步骤算 λ_ev
[&](const PluginElement &e, int step, std::vector<std::vector<double> > &res) {
std::vector<double> val; e.getValues(data1, step, val);
element *s = e.shape();
if(e.numComp == 3 && !s) return false;
for(int nod = 0; nod < e.numNodes; nod++) {
double g[3][3];
if(e.numComp == 9) for(i,j) g[i][j] = val[9*nod + 3*i + j]; // 输入即张量
else { s->getNode(nod, u, v, w); // 速度 → 梯度
for(i) s->interpolateGrad(&val[i], u, v, w, g[i], 3); }
for(m,n) { sym[m][n] = 0.5*(g[m][n] + g[n][m]);
asym[m][n] = 0.5*(g[m][n] - g[n][m]); }
for(m,n) { a[m][n] = 0.; for(l) a[m][n] += sym[m][l]*sym[l][n] + asym[m][l]*asym[l][n]; }
eigenvalue(a, lambda);
res[0].push_back(lambda[ev - 1]);
}
return true;
});
data2->setName(data1->getName() + "_Lambda2");
data2->setFileName(data1->getName() + "_Lambda2.pos");
data2->finalize();
return v2;
}
这十几行里浓缩了插件编程的全部要素 :getView 取输入、getPossiblyAdaptiveData 兼容自适应、 getDataList(v2) 取空输出;createListData 的两个回调(numComp 决定"要不要这个元素"、 values 算值);e.shape() + interpolateGrad 做单元内梯度 (shapeFunctions.h); eigenvalue(Numeric.h);命名 + finalize 收尾。
AnalyseMeshQuality、Gradient、Curl、Divergence 等一批算子插件都是同一模板的变体。 DiscretizationError(src/plugin/DiscretizationError.cpp) 则展示了"网格对几何的离散误差 ":对面上每个单元做超采样(默认 10×10),把采样点投影回 几何(closestPoint),取距离作为误差场:
cpp
// DiscretizationError 的超采样:对面上每个单元,在参数域按 quandt 取点 → pnt 到物理坐标,
// closestPoint 投影回几何,取两者距离作为该采样点的误差场值
(*itQuad)->pnt(...i, ...j, 0.0, quadDist[...].first);
GPoint closest = (*itFace)->closestPoint(*point, startEstimate);
quadDist[...].second = sqrt(dx*dx + dy*dy + dz*dz);
这正是第 4/7 章"误差估计→尺寸场→自适应网格"闭环里的"误差场来源"之一(9.10.3)。
9.10 自适应可视化与数据闭环
9.10.1 adaptiveData:细分树与误差估计
高阶/大规模数据的"画不全"问题,Gmsh 用自适应可视化 解决:把高阶单元在参考单元里递归细分 成低阶(一阶)子元素,按误差只画需要的地方。src/post/adaptiveData.h 开头注释定义了整套机制:
cpp
// Adaptive views:高阶单元通过"递归细分参考单元 + 在细分点上插值坐标与值"用一阶元素显示;
// 细分是一棵树(每视图每类元素建一次,到最大层);对每个元素在树的所有点上求场,再由误差
// 估计决定树里哪些子块被保留(adaptiveElements::init() / adapt())
核心类:
adaptiveShape(第 65 行):描述一类元素 的"参考单元如何切子块"------节点、边、子块数、 子块的点/面、对角线、一阶形函数指针;adaptiveElement(第 95 行)是树上的一个子块(8 个节点 指针 + 最多 10 个子块 +onFace[6]);adaptiveVertex(第 42 行):细分树上的一点,参考坐标用float("halves of halves: exact"), 带onFaces位掩码;PValues(第 129 行)存一次求值(1/3/9 个数),小值内联在对象里 ("a memory allocation per value made adapting a view twice as slow")。
9.10.2 adapt / adaptWhole / clip layer
PView 上的自适应接口(src/post/PView.h 第 186--229 行):
cpp
adaptiveData *initAdaptiveData();
// adapt(whole):按选项(时间步、maxRecursionLevel、targetError、自定义范围)细化;
// 默认只细化可见的"皮"(adaptSkinOnly),whole=true 时整体细化
void adapt(bool whole = false);
bool refineClipLayer(); void useClipLayer(bool use); // 只细化皮时,被剖切面切到的元素单独细化
void adaptWhole(); // 供插件/探针/API 把细化数据当整体读
void widenAdaptedRange(double &min, double &max); // 把范围加宽到体内节点值
void getAdaptiveRange(double &min, double &max);
adapt(whole):按选项细化,默认只细化可见的"皮" (adaptSkinOnly,省大量内存),whole = true时整体细化;adaptWhole():供插件/探针/API "把细化数据当整体读",且 下一次绘制又回到只细化皮 ;clip layer :只细化皮时被剖切面切到的元素单独细化(拖动 剖切面只重建这一小块);widenAdaptedRange:只细化皮时把范围加宽到体内节点值以稳住色标。
这套设计的动机与 9.9 的 mayKeep 钩子完全一致:数据可以无限大,但像素有限------只算看得见 的部分。这是"progressively refine"(渐进细化)思想在 Gmsh 里的落地。
9.10.3 从误差场到尺寸场:数据闭环(呼应第 4/7 章)
后处理与网格流程之间最重要的闭环是自适应重划:
text
求解器输出(文件/API)→ 读成 PViewDataList 或 PViewDataGModel(误差/应力/DiscretizationError)
→ View[...].* 阈值与插值 → 尺寸场(Field,第 4 章)→ 重划网格(第 7 章)
└────────────────────► 求解器再算 → 新结果文件(回到顶部)
Gmsh 提供的一条"短路"是 MeshSizeFieldView 插件(src/plugin/MeshSizeFieldView.h): 它把视图数据当尺寸场 驱动网格(MeshSizeFieldView 对应脚本里的尺寸场类型之一)。而 DiscretizationError 插件把网格-几何离散误差 写成视图------于是"几何误差大的地方细化"这条 闭环也通了。这直接对应第 4 章的 Field 系统与第 7 章的网格算法:后处理不只是终点,也是 下一轮网格化的输入。
9.10.4 保存自适应视图
高阶视图可以"按自适应网格"另存(PostProcessing.SaveAdapted),每个步落在自建网格上:
cpp
// 高阶视图在 PostProcessing.SaveAdapted 时"按自适应网格"保存:savesAdapted() 是否如此,
// getAdaptedSteps() 给出每个细化步(各是一份单步列表视图),doneSaving() 收尾;
// savesSeveralMeshes() 判断"各步落在不同网格上"
write(9.7.1)里 savesAdapted() 分支就是用它把每个细化步写成 name_0000.ext; PViewData::saveAdaptedViewForVTK(第 61 行)能临时建自适应数据、导出给 VTK、再销毁(供 ParaView 的 GmshReader 使用)。
9.11 后处理与求解器 / API 的闭环
一张全景:正方向"求解器/文件 → 视图 → 运算 → 画/查询/导出 ",反方向"视图 → 尺寸场 → 重划 → 求解器 "(9.10.3)。入口分别是:读结果 readMSHViewData/readPOS/readMED/readCGNS (落到两类数据容器);塞结果 View 命令、gmshViewAddNodeData、PView(..., GModel*, map<>) (落到 addData);运算 Plugin("X").Run/gmshPluginRun;画 PViewOptions + fillVertexArrays; 查询探针/gmshViewProbe/searchScalar(OctreePost);导出 write/gmshViewWrite;回灌 MeshSizeFieldView/Field + 重划(第 4/7 章)。这条"求解器 → 视图 → 尺寸场 → 重划 → 求解器"的 链路,是 Gmsh 区别于"纯前处理工具"的关键能力。
9.12 常见问答
Q1:为什么用列表式存储而不是"通用网格"? 列表式(PViewDataList)不要求数据与网格对应 :读进任意散乱结果、插件产出的切片/等值面、 临时点集都能表示。代价是坐标冗余。当结果本来就在网格上 时,PViewDataGModel 更省内存且 天然共享节点------两者由 PViewData 抽象统一(9.3、9.6.1)。选择标准:数据与模型网格严格 对应就用网格式,否则用列表式。
Q2:thread_local 缓存解决什么? 它解决"同一视图被多线程 + 多视图交替、逐值读取 "时的正确性与性能:每次读元素都要把全局 元素号定位到数组偏移,缓存最后读的元素可省去重复定位;thread_local 让多线程互不干扰(于是 isThreadSafe() 可为真);4 项是为了"一个线程同时读多个视图"各留一格;_state 保证数据一变 缓存立即失效(9.5.6)。
Q3:插件 ABI 如何保持稳定? 靠"约定导出符号 + 版本号 ":GMSH_PLUGIN(类名) 用 extern "C" 导出 GMSH_PluginApiVersion() 与 GMSH_RegisterPlugin();加载时 dlsym 找这两个符号,并校验 版本号等于 GMSH_PLUGIN_API_VERSION,不符就拒绝并提示重编。库句柄被保存以避免对象存活期间 库被卸载(9.8.9、9.8.10)。
Q4:后处理数据如何驱动自适应网格? 见 9.10.3:把结果/误差写成视图 → 设阈值/插值 → 用 MeshSizeFieldView(或 Field)把它变成 尺寸场 → 重划网格。DiscretizationError 提供"网格-几何误差"这一路输入。
Q5:_stamp 和 _dirty 有什么区别? _dirty 是"能不能显示"的布尔;_stamp 是"数据变过多少次"的计数。前者给绘制层,后者给 "数据派生的缓存"(八叉树/KD 树/插值矩阵/自适应数据)做失效判定(9.3.1)。
Q6:为什么 execute 返回的指针要比较是不是入参? 这是"原地修改 vs 新建视图"的判据(9.8.1)。返回入参 → 修改当前视图;返回别的 → 把新视图 加入 PView::list。Warp/Transform/Smooth(原地)与 CutPlane/MathEval(新建)代表 两类。
Q7:列表式数据没有拓扑,怎么画等值线/光滑法向? 用 getNodeId------它按坐标合并重建节点 id 并缓存 (9.5.7)。网格式数据则直接返回 MVertex::getNum()(9.6.4)。0 表示"无拓扑"。
Q8:Gauss point 数据为什么 getNodeId 返回 0? 因为高斯点不是网格节点 (可能是单元内部的参数坐标点)。画/插件需要坐标时用 getNode 的插值分支把参数坐标映射到物理坐标(9.6.4)。
Q9:为什么 .pos 默认写 "parsed" 而非裸 ASCII? 因为 parsed 文本还能被 Gmsh 脚本解析器回读成 View 命令,便于"结果即脚本"的工作流; POS_ASCII/POS_BINARY 则是纯数据流(9.7.1)。
Q10:一个视图对应多个文件是怎么回事? 两种情况:各步落在不同网格 (savesSeveralMeshes → 每块网格一个文件);高阶视图按 自适应网格保存 (每步一个自建网格)。文件用 _%04d 区分,并自动生成回读脚本(9.7.1、 9.10.4)。
9.13 后处理的性能与内存考量
贯穿全章的主题是**"惰性 + 指纹"**:统计靠 _stat 并行分片(9.5.5)、读取靠批量接口与 thread_local 缓存(9.3.5、9.5.6)、拓扑靠坐标合并 + 熔断(9.5.7)、值内存靠池化(9.6.2)、 每步遍历靠 model signature(9.6.5)、空间查询靠惰性八叉树/KD 树(9.3.2)------能不算就不算, 要算就用一枚廉价指纹(_stamp/_state/signature)判断"还能不能复用"。
9.14 术语中英对照速查
| 英文/代码 | 中文 | 说明 |
|---|---|---|
| view | 视图 | PView = 数据 + 选项 |
| list-based / discontinuous | 列表式 / 不连续 | PViewDataList |
| model-based | 网格式 | PViewDataGModel |
| time step | 时间步 | 一列结果 |
| component | 分量 | 标量 1 / 矢量 3 / 张量 9 |
| listKinds | 列表种类表 | 27 组(类型 × 分量)反射表 |
| thread_local cache | 线程局部缓存 | _lastRead[4] |
| node id | 节点标识 | 共享节点同一 id;0 = 无拓扑 |
| skin | 表皮 | 模型表面;getSkinMasks |
| skin key | 表皮键 | 分区实体归到父实体 |
| gauss point | 高斯点 | GaussPointData |
| interpolation matrices | 插值矩阵 | 高阶/曲线单元 |
| adaptive | 自适应 | adaptiveData 细分树 |
| recursion level / target error | 递归层数 / 目标误差 | maxRecursionLevel / targetError |
| octree / kdtree | 八叉树 / KD 树 | 空间查询 |
| probe | 探针 | 按坐标取值 |
| levelset | 水平集 | 切割基类 GMSH_LevelsetPlugin |
| plugin ABI | 插件二进制接口 | 导出符号 + 版本号 |
| serialize / read-back script | 序列化 / 回读脚本 | 选项 → 脚本 / 多文件导出后重建视图 |
9.15 源码索引与延伸阅读
本章涉及的文件(均在 src/post/ 或 src/plugin/ 下):视图与选项 PView.h/.cpp、 PViewOptions.h/.cpp;数据抽象与容器 PViewData.h/.cpp、PViewDataList.h/.cpp、 PViewDataGModel.h/.cpp、PViewDataGModelIO.cpp、PViewDataRemote;可视化支撑 adaptiveData.h/.cpp、OctreePost.h/.cpp、ColorTable.h/.cpp、shapeFunctions.h/.cpp、 SmoothData.h/.cpp;文件读写 PViewIO.cpp、PViewIO_VTU.cpp、PViewIO_CGNS.cpp、 PViewX3D.cpp、PViewDataListIO.cpp;插件 Plugin.h/.cpp、PluginManager.h/.cpp、 Levelset.h/.cpp 及各具体插件;闭环 MeshSizeFieldView.h/.cpp 与第 4/7/8 章的 Field、 网格算法、Homology。
9.16 本章小结
后处理与插件的设计要点:
- 视图 = 数据 + 选项 :
PView组合PViewData(纯数据)与PViewOptions(显示)。 数据变更由_dirty/_stamp广播,八叉树/KD 树/插值矩阵/自适应数据按_stamp惰性重建; 视图有唯一不可变 tag 与可变 index,别名视图共享数据。 - 两类数据容器 :
PViewDataList(不连续、按"单元类型 × 分量"分列成 27 组扁平数组,用 反射表listKinds[27]统一访问,靠坐标合并重建节点 id,用thread_local读缓存加速绘制) 与PViewDataGModel(基于模型网格、共享节点、按 tag 索引、池化分配、model signature 判 失效);两者由PViewData抽象统一为一套(step, ent, ele, nod, comp)寻址。 - 归约与批量接口 :
getScalarValue把标量/向量/张量归约为标量颜色值;getElementInfo/getNodesAndValues提供批量读取以应对"reads millions"。 - 选项体系 :
PViewOptions用一组"从 1 开始"的枚举描述画法;范围靠getRange(含_forcedRangeKey缓存键);比例支持线性/对数/对称对数;几何移动有 offset/raise/transform/ explode/genRaise;自适应开关adaptVisualizationGrid等。同一套字段同时服务 GUI/脚本/API。 - 文件读写 :
PView::write一处分派 pos/msh/vtu/med/x3d/stl/txt;流式解析、逐fscanf判断返回值、版本/精度校验、多文件与回读脚本;列表式与网格式各自实现写虚函数。 - 插件机制 = 基类 + 选项 + 共享库 :
GMSH_Plugin分网格/后处理两类,选项经StringXNumber/StringXString注入,支持别名/序列化/预览;PluginManager单例负责内置 注册与动态库加载;GMSH_PLUGIN宏 + 版本校验实现二进制插件 ABI。 - 插件编程用回调解耦 :
forEachNode/forEachValue/createListData用std::function把"遍历视图"与"插件计算"分开,插件只写numComp与values两个回调,框架负责构造 输出、校验尺寸、带出时间。 - 代表插件覆盖各类 :算子型(
MathEval/MinMax)、几何切割型(Levelset基类 +CutPlane/CutBox/CutSphere)、变形型(Warp/Transform)、网格/频域型 (HarmonicToTime/MeshSubEntities/Smooth)、物理型(Lambda2/AnalyseMeshQuality/DiscretizationError)。 - 自适应可视化 :
adaptiveData把高阶单元递归细分成一阶子块,按误差只保留需要的部分; 默认只细化可见的皮,剖切面切到的元素单独细化;据此打通"误差场 → 尺寸场 → 重划网格"的 数据闭环,让后处理成为下一轮网格化的输入。
全章回顾
一句话:Gmsh 把"结果"抽象成"视图",用两套互补的数据容器(不连续列表式与网格式)承载它, 用一套统一的选项体系去画它、用一套基于约定导出符号的插件机制去扩展它,并用自适应可视化与 尺寸场把后处理重新接回网格生成流程。 值得记住四组"对照":两种容器(PViewDataList vs PViewDataGModel)、两种扩展(内置 vs 动态库插件)、两种细化(只细化皮 vs adaptWhole)、 两个方向(结果 → 画/导出 vs 视图 → 尺寸场 → 重划)。
给实现者的检查清单
- 新增数据来源:优先落到
PViewDataGModel::addData或列表式增量填充; - 新增插件:继承
GMSH_PostPlugin,用createListData/forEachNode,末尾finalize(); - 改坐标或值:务必重新
finalize()并setChanged(true); - 用高阶单元:只依赖角点(
getNumCornerNodes已处理); - 导出多文件:让
write生成回读脚本; - 想接入自适应网格:把结果写成视图,再用
MeshSizeFieldView/Field 驱动重划。
第 10 章转向有限元求解器框架、API 生成与多语言绑定、Onelab 集成------看后处理与网格如何被更 上层的"求解器 + 参数化 + 脚本/API"体系串起来。