☰
半边数据结构与欧拉操作:从零实现B-rep扫掠建模内核
2026/10/22 21:36:55 网站建设 项目流程

简介:《三维CAD建模》课程作业完整实现方案,面向计算机图形学、CAD相关课程的在校学生,以及需要完成类似课设、毕设项目的开发者。项目围绕半边数据结构展开,包含五个基本欧拉操作的实现,并基于欧拉操作完成支持多个内环的二维区域扫掠建模,最终将实体模型进行真实感图形显示。代码全部测试通过,源文件共18个,以C++头文件与源文件(hpp/cpp)为主体,辅以模型数据文件、build配置、CMakeLists及README说明,整体压缩包仅102KB,结构清晰便于按模块阅读。已有96人学习下载。源码附带文档说明,下载后如遇运行问题可私聊咨询并支持远程教学;除直接用于课程作业提交外,也可作为图形学底层数据结构实验、欧拉操作与扫掠建模研究的参考,适合在此基础上继续扩展功能。

1. 图结构、欧拉流和扫掠:为什么这道课程作业是 B-rep 内核的分水岭

一份名为《三维CAD建模》课程作业的压缩包,标题里只写了三件事:定义半边数据结构、实现五个基本欧拉操作、实现扫掠操作。但这三件事恰好是许多几何内核学习者的共同分水岭——能完整跑通它们,意味着你真正理解边界表示(B-rep)的底层逻辑,而不是只会调建模 API。做这个作业时最直观的收获是:你亲手在内存里,用一组带方向的半边,构建出一个可遍历、可修改、可验证的实体模型。它适合正在做课程设计的学生,也适合想补几何内核基础、准备做参数化建模或网格编辑的从业者。我按常见做法把整个实现路径拆开,从数据结构定义讲到五个基础欧拉操作,再落到扫掠建模,最后一并说清楚调试和验证方法。

2. 先定骨架:半边数据结构的内存布局与 C++ 定义

2.1 为什么是半边而不是翼边:两条边指向一个面的代价

三维 CAD 建模里表达一个实体,常见的就是 B-rep:用面、环、边、顶点四层描述边界。要在这个结构上做欧拉操作和扫掠,核心问题是边的邻接信息怎么存。常见的做法有两种,翼边结构(Winged-Edge)和半边结构(Half-Edge)。翼边结构每条边要记录四个方向的邻接面,更新指针时牵一发动全身;半边结构把一条无向边拆成两条方向相反的有向边,也就是左边一个半边、右边一个半边,每个半边只属于一个面,邻接信息天然由 twin 指针表达。

我一般建议直接选半边,理由很明确:扫掠操作要频繁访问“边的另一侧是哪个面”,半边结构里 twin 指针一次拿到;欧拉操作里 MEF 要切开一个面,也需要快速访问面的外环半边链。如果用翼边结构,每步操作都要手工维护四条邻接边,非常容易漏。这也是大多数课程作业和开源建模库默认采用半边的根本原因。

2.2 面向扫掠的 C++ 结构体定义:从顶点到 Solid

半边结构的 C++ 定义不需要太复杂,但要把五个实体类串起来。我的做法是:Vertex只存坐标和一条出边半边;HalfEdge存起点、twin、next、prev 和所属面;Loop表示环,一条有向半边链;Face持有一个外环和一系列内环;Solid持有所有顶点、半边和面的索引列表。半边的 next/prev 按环的走向组成闭环,这是所有遍历算法的基础。

struct Solid; struct Face; struct Loop; struct HalfEdge; struct Vertex { double x, y, z; // 三维坐标 HalfEdge* out = nullptr; // 从该顶点出发的一条半边,作为读取邻边入口 }; struct HalfEdge { Vertex* origin = nullptr; // 起点;终点由 twin->origin 获得 HalfEdge* twin = nullptr; // 同一条几何边的反向半边 HalfEdge* next = nullptr; // 环内的下一条半边,逆时针排列 HalfEdge* prev = nullptr; // 环内的上一条半边 Face* face = nullptr; // 该半边所在的面 }; struct Loop { HalfEdge* first = nullptr; // 环中任意一条半边,用于遍历整环 Loop* next = nullptr; // 指向下一个内环;外环不参与该链 Face* face = nullptr; }; struct Face { Loop* outer = nullptr; // 外环,必有 Loop* inner = nullptr; // 内环链表,没有孔时为 nullptr Face* next = nullptr; // 用于维护 solid 中的面链表 }; struct Solid { std::vector<Vertex*> verts; std::vector<HalfEdge*> halfEdges; std::vector<Face*> faces; };

这里最需要注意的一点:半边结构里一条几何边对应两个HalfEdge,所以halfEdges的长度必然是偶数。每次调用 MEV 或 MEF 添加一条边时,必须同时创建两个半边并互设 twin,否则后续遍历会直接出错。我把这条规则写在了文档说明的第一页,因为后面所有欧拉操作的正确性都依赖它。

2.3 环的方向约定与外环/内环:一个隐藏的坑

建完数据结构之后还要定一个约定:从实体外部看,外环半边逆时针旋转,内环(孔洞)顺时针旋转。这个方向直接决定了面法向量。用右手定则检验时,逆向的环会让法线指向实体内侧,渲染时面会变成黑色,扫掠出来的侧面也会内外颠倒。实现早期我没在意这个约定,结果用 MEF 切开一个面之后,新面的法线朝向和旧面相反,整个模型看起来像被“掏空”了一样。

在做扫掠操作之前,环的方向默认就是半边链的排列方向。实现时我加了一个断言函数,遍历每个面的外环半边,用叉积算面积法向量,朝外的才通过。这个检查在 DEBUG 模式下每次都跑,成本不高但能拦截大半的法线问题。另外,内环存储我用的是链表而不是 vector,因为扫掠一个带孔轮廓时,孔的数量不定,链表插入更灵活。

3. 五个基本欧拉操作逐个实现:MVFS 到 KEF 的代码与参数说明

3.1 MVFS 与 MEV:从空体到一条边的完整拓扑

五个基本欧拉操作,我按常见教材的集合来定:MVFS(Make Vertex Face Solid)、MEV(Make Edge Vertex)、MEF(Make Edge Face)、KEV(Kill Edge Vertex)、KEF(Kill Edge Face)。MVFS 从空状态创建一个只含一个顶点、一个退化面(外环没有边)的实体,是整个建模的起点。MEV 在当前面上添加一个新顶点,并从已有顶点连一条边过去。它们对应的欧拉示性数变化是:MVFS 使 V 和 F 各加 1,MEV 使 V 和 E 各加 1。

// MVFS:新建实体,返回创建出的 solid 和唯一顶点 void MVFS(Solid*& s, Vertex*& v) { s = new Solid(); Face* f = new Face(); Loop* loop = new Loop(); v = new Vertex(); loop->first = nullptr; // 退化面:没有任何边,但面已存在 loop->face = f; f->outer = loop; s->faces.push_back(f); s->verts.push_back(v); }

MVFS 的关键点是它的“退化”设计:一个面可以不包含任何半边,但在拓扑上已经存在。这是欧拉操作合法性的起点,后面所有操作都基于这个初始状态继续。v->out暂时为空,直到第一次 MEV 才设置。

// MEV:从已有顶点 v1 连边到新顶点 v2,并把边挂到 v1 所在的环上 void MEV(Solid* s, Vertex* v1, Vertex* v2) { HalfEdge* h1 = new HalfEdge(); HalfEdge* h2 = new HalfEdge(); h1->origin = v1; h2->origin = v2; h1->twin = h2; h2->twin = h1; v2->out = h1; // v2 是新增顶点,出边指向 h1 // 寻找 v1 所在面的外环,并将 h1 插入环链 Face* f = v1->out->face; Loop* loop = f->outer; if (loop->first == nullptr) { // 当前面退化,半边自成环 loop->first = h1; h1->next = h1->prev = h1; } else { // 插入到环的末尾:h1 的 next 指向 first,first 的 prev 指向 h1 h1->next = loop->first; h1->prev = loop->first->prev; loop->first->prev->next = h1; loop->first->prev = h1; } h1->face = f; h2->face = f; // 新边的反向半边暂时也属于同一面 s->verts.push_back(v2); s->halfEdges.push_back(h1); s->halfEdges.push_back(h2); }

MEV 最容易出错的是半边插入环链的位置:h1插入后必须保证环的连续性,即first->prev->next == h1且h1->next == first。如果没调整前驱新边的指针,环就变成链表,遍历会出现死循环。另一点是h2->face的归属,当 v1 所在的环还没有闭合时,两条半边属于同一个面是合理的。

3.2 MEF:用一条边把旧面切开成两个面

MEF 是五个操作里语义最重的:在同一个面的两个顶点之间添加一条边,把原有面一分为二。它的前提条件是 v1、v2 已经在同一个面上,并且从 v1 沿环方向走到 v2 的路径,和从 v2 走回 v1 的路径都至少包含一条边。这样新边会把环切成两个闭合环,分别作为新旧两个面的外环。

// MEF:在同一面内 v1、v2 之间加边,旧面分裂为两个面 Face* MEF(Solid* s, Vertex* v1, Vertex* v2) { HalfEdge* h1 = new HalfEdge(); HalfEdge* h2 = new HalfEdge(); h1->origin = v1; h2->origin = v2; h1->twin = h2; h2->twin = h1; v1->out = h1; // 沿当前环找 v1->v2 的路径 HalfEdge* cur = v1->out->next; while (cur->origin != v2) cur = cur->next; HalfEdge* segEnd = cur; // v2 所在的半边 HalfEdge* segStart = v1->out; // v1 所在的半边 // 新边串起两条子环:h1 接 segStart,h2 接 segEnd h1->next = segEnd; h1->prev = segStart->prev; segStart->prev->next = h1; segEnd->prev = h1; h2->next = segStart; h2->prev = segEnd->prev; segEnd->prev->next = h2; segStart->prev = h2; // 更新 face 归属:从 segEnd 开始的环都属于新面 Face* oldFace = segStart->face; Face* newFace = new Face(); Loop* newLoop = new Loop(); newLoop->first = h2; newLoop->face = newFace; newFace->outer = newLoop; h2->face = newFace; cur = h2; while (cur != h2 || cur->next != h2) { cur->face = newFace; cur = cur->next; } s->faces.push_back(newFace); s->halfEdges.push_back(h1); s->halfEdges.push_back(h2); return newFace; }

这个实现里最磨人的是环分割后的归属更新。新边把环分成两段,一段保留给旧面,另一段整体划分给新面。要注意不能简单把v1->out之后所有半边都给新面,因为segEnd可能走了一整圈才回到 v1。文档说明里我建议写一个walkLoop工具函数,用do...while遍历半边并统一设置 face 指针,这样比上面内联循环更不容易出低级错误。

3.3 KEV 与 KEF:记住它们的逆操作语义

KEV 和 KEF 分别是 MEV 和 MEF 的逆操作,但实现起来比正向更麻烦,因为它们要正确“杀死”顶点和面,同时维护环的完整性。KEV 删除一条边及其一个端点:被删除的边必须满足一端是新顶点,另一端是已有顶点,且删除后环链要重新闭合。

// KEV:删除边 e,并删除该边的 origin 顶点 void KEV(Solid* s, HalfEdge* e, Vertex* delV) { HalfEdge* twin = e->twin; HalfEdge* prev = e->prev; HalfEdge* next = e->next; prev->next = next; // 跳过被删半边,闭合环 next->prev = prev; // 如果环头被删,重设 loop->first Loop* loop = e->face->outer; if (loop->first == e || loop->first == twin) loop->first = next; // 从 solid 记录中移除顶点 auto it = std::find(s->verts.begin(), s->verts.end(), delV); if (it != s->verts.end()) s->verts.erase(it); delete e; delete twin; }

KEV 里被删除的半边不能是环中唯一的半边,否则环会变成空环,这在合法操作中不应该出现。KEF 删除一条边和一个面时,需要把两个面合并成一个,关键在于把被删面的外环半边并入保留面的环链。我的常见做法是先找到被删面环绕到保留面环的那条共用边,然后把两个环拆开重连。这里最容易踩的坑是面指针归属:被删面里的半边全部要改挂到保留面下,漏了一个就会导致后续遍历时访问悬垂 face 指针。

KEF 的代码与 MEF 相反,但核心都是环链的重连。我实现时把环链的“断开”和“重接”抽成了两个函数:detachLoop和mergeLoop,KEV 和 KEF 复用它们。这样既避免重复代码,也减少指针错乱的可能。

4. 扫掠操作:用欧拉操作把二维轮廓拉伸成三维体

4.1 扫掠的两种做法:直接构建 vs 复用欧拉操作

扫掠操作是三维 CAD 建模里最常见的建模手段之一:一个二维轮廓沿某个方向平移,扫出一个三维实体。实现时有两种路线。第一种是直接构建:复制顶面顶点、生成侧面四边面、分别封顶封底,一步到位。第二种是复用上一章实现的欧拉操作:先建底面轮廓的线框,然后用 MEV 一层层拉出侧面,最后用 MEF 把侧面闭合,再加顶面和底面。课程作业标题里把“五个基本欧拉操作”和“扫掠操作”并列,通常期望的路线是第二种——用欧拉操作验证数据结构,再用它们组合出更高层建模功能。

我两种都做过,结论是:如果只想快速出结果,直接构建更省心;但如果你想验证自己的欧拉操作实现有没有漏洞,就必须走第二种。因为扫掠过程本质上是几十次 MEV 和 MEF 的组合,任何一次环链拼接有误都会在最后检查时暴露。课程作业交的是源码和文档说明,我建议两种都保留:直接用结构体遍历写一个快速扫掠,再用欧拉操作写一个慢速但可校验的版本。

4.2 核心 sweep 函数:先复制顶点,再打侧面,最后封盖

扫掠的实现我一般按三步走。第一步,把轮廓的每个顶点复制一份并沿方向平移,生成顶面顶点;第二步,连接底面第 i 个顶点和顶面第 i 个顶点,生成竖向边,然后用两条竖边和同层的轮廓边围成侧面四边形;第三步,把底面轮廓和顶面轮廓分别闭合为环,并创建顶面。侧面四边形本质上是调用两次 MEF 实现的:先在底面相邻顶点之间加一条边切开面,再连顶部对应顶点闭合。

// 扫掠:将二维轮廓沿 dir 方向拉伸 dist 距离 Solid* sweep(const std::vector<Vertex*>& contour, const Vec3& dir, double dist) { int n = (int)contour.size(); if (n < 3) return nullptr; // 第一步:复制顶点到顶面 std::vector<Vertex*> top(n); for (int i = 0; i < n; i++) { top[i] = new Vertex(); top[i]->x = contour[i]->x + dir.x * dist; top[i]->y = contour[i]->y + dir.y * dist; top[i]->z = contour[i]->z + dir.z * dist; } // 第二步:用 MEV + MEF 构建侧面 // 从底面第一条边开始,逐个生成四边形侧面 Solid* s = nullptr; Vertex* startV = nullptr; MVFS(s, startV); // 建立初始实体 MEV(s, startV, contour[0]); // 建立第一条底边 for (int i = 1; i < n; i++) { MEV(s, contour[i - 1], contour[i]); // 底边 i MEF(s, contour[i - 1], top[i - 1]); // 连接底边起点到顶边起点 MEV(s, contour[i], top[i]); // 竖向边 MEF(s, contour[i], top[i - 1]); // 闭合侧面四边形 } // 最后闭合底面和顶面 MEF(s, contour[n - 1], contour[0]); MEF(s, top[n - 1], top[0]); return s; }

代码里的操作序列是按顺序执行的,但要注意:每一轮循环中MEF(s, contour[i - 1], top[i - 1])和MEF(s, contour[i], top[i - 1])的顺序不能互换,否则侧面四边形的对角线方向会乱,生成的自相交网格会让法线计算完全失效。每个 MEV 调用之后必须确保新顶点的out已经指向新边,否则下一次MEF找不到合法路径。

4.3 两个参数:扫掠方向向量的 normalize 和轮廓闭合检查

扫掠方法里最隐蔽的参数是方向向量的长度。很多人只传方向不传距离,函数内部默认把 dist 设为 1,结果侧面的高度由dir的模长决定。我的建议是函数签名保持dir + dist分离,dir必须是单位向量,dist控制拉伸距离。在函数开头做一次 normalize 检查,如果dir模长不为 1 就直接归一化,否则一旦调用方把方向向量写错(比如长度是 2),整个模型会被无形放大,后续装配时才发现坐标对不上,非常浪费排查时间。

轮廓的闭合检查也经常被忽略。扫掠要求输入的轮廓点序列是严格首尾相接的,也就是最后一个点后面不需要重复第一个点。但很多同学从文件读点云时,习惯把首点重复一遍,导致扫掠时多生成一个重复面。我在文档说明里定义输入约定:contour传入的是互不重复的有序顶点序列,函数内部默认contour[n-1]与contour[0]相连。如果不闭合,我在函数返回前会做一次assert,检查所有边的twin是否成对出现,不成对说明有悬空边,直接中止报错。

5. 欧拉操作与扫掠的常见问题排查:四个必踩的坑

5.1 现象:欧拉示性数对不上

做完几次欧拉操作后,用V - E + F验算,发现数值不是预期的 2。例如单个六面体应该是 8 个顶点、12 条边、6 个面,示性数为 2,但算出来是 3 或 1。原因通常是某个 MEV 或 MEF 在添加半边时忘了创建 twin,或者创建了 twin 但只 push 了一条到halfEdges列表。半边数量不成对,直接导致 E 统计错误。解决方法是写一个validateTwin()工具函数,遍历所有半边,断言he->twin->twin == he,每次操作后调用它,问题会在第一次出现时立刻暴露。

5.2 现象:遍历半边死循环

在扫掠侧面时,某个环的半边链没有闭合,导致从loop->first出发沿next遍历,永远走不回起点。最常见的原因是在 KEV 删除半边后,没有把被删半边前驱的 next 指针接好,环从中间断成链表。我排查时会在死循环加一个计数保护:每走一步加一,超过halfEdges.size() * 2就抛出异常。实际工作中这个方法已经在多套代码里救过我,因为半边链的死循环时常伪装成程序卡死,而不是明确的崩溃。

5.3 现象:扫掠结果的法线全部朝向内侧

生成的模型放进渲染器,所有面都是暗色,或者背面剔除后模型消失。原因多半是轮廓点的排列方向与扫掠方向组合下,MEF 切出来的侧面环走向反了。外环应该是逆时针,如果轮廓输入时是顺时针,扫掠后所有侧面法向量都会朝里。解决方法是输入轮廓时统一外环为逆时针,并在 sweep 函数入口处做一次环面积符号判断,如果面积为负就把点序列反转。这个预处理成本极低,但能省掉所有后续渲染排查。

5.4 现象:删除边时指针悬空导致崩溃

KEF 或 KEV 执行后,某个旧的HalfEdge*还被外部变量引用,之后访问he->face直接段错误。出现这个现象几乎都是因为删除操作后没有同步更新Solid::faces或Solid::halfEdges列表,或者只从 vector 中删除了指针却没有 delete 对象。解决方法是约定一个原则:欧拉操作是拓扑级的修改,外部所有指入被删元素的指针必须在调用前清空。我一般会写一个collectGarbage函数统一回收,并且所有遍历循环都使用当前 solid 的 vector 长度作为边界,不要缓存旧指针。

5.5 现象:带内环轮廓扫掠后丢面

当一个轮廓中间有孔时,扫掠出的侧面是两圈环,外圈和孔环之间需要一个连接桥(bridge)结构来保证实体拓扑正确。很多人直接把内环和外环分别扫掠成一个体,再把两个体布尔相加,但布尔运算是更高阶的功能,作业阶段往往还没实现。更稳妥的做法是给扫掠函数传入一个“孔列表”参数,在构建顶面和底面时,将外环内环的桥面用 MEF 手工封好。这个坑的隐蔽性很强,因为它不会立刻崩溃,只是导出的 STL 模型有裂缝,需要对 MEF 的容错机制有充分信心才敢动手。

6. 用欧拉公式做验证:一个 invariant 检查函数和调试顺序

6.1 把欧拉示性数写成单元测试

五个基本欧拉操作的每次调用都会修改 V、E、F 三个数,而它们的组合必须满足欧拉公式。对于无孔、单一连通实体,V - E + F = 2。我在作业源码里单独写了一个 invariant 检查函数,每次建模操作完成后调用一次。

// 检查当前实体的欧拉示性数是否合法 int checkEuler(Solid* s) { int V = (int)s->verts.size(); int E = (int)s->halfEdges.size() / 2; // 两条半边算一条几何边 int F = (int)s->faces.size(); int chi = V - E + F; // 单连通实体,chi 应为 2;若为其他值,打印每个面 if (chi != 2) { printf("Euler check failed: V=%d E=%d F=%d chi=%d\n", V, E, F, chi); } return chi; }

调试顺序上,我坚持从最小场景开始:先只执行 MVFS,检查 V=1、E=0、F=1;然后 MEV 两次造出一条边和一个顶点,检查 V=2、E=1、F=1;再 MEF 一次真正切开一个面,检查 V=2、E=2、F=2。每一步都调用checkEuler,而不是等到整个扫掠完成再验算。扫掠一个四边形轮廓要执行近 20 次欧拉操作,如果最后才发现示性数不对,根本定位不到是哪一步出的问题。

6.2 输出 OBJ 线框检查拓扑

欧拉公式只能判断数量对错,判断不了环链拼接是否正确。我的做法是写一个简单的 OBJ 导出函数,把半边结构的线框信息输出成可视化的.obj文件——顶点按v行输出,边按l行输出。这样可以在外部查看器里直接看到每条边有没有歪掉、有没有多余的几何。对比日志文件和拓扑图,基本能快速判断是环链断点还是面归属错乱。早期调试时这比单步调试高效得多,因为半边链的指针关系在纯文本里根本看不出来。

这套组合拳看起来简单,但价值不低:欧拉公式把数量错误限制在单个操作内,OBJ 导出把拓扑错误暴露在视觉层面,两者一先一后,几乎覆盖了实现欧拉操作和扫掠过程里百分之九十的问题。我自己的习惯是先跑完单元测试再跑扫掠样例,所有验证通过后才把模型导出看效果。这跟我早期做这块内容时的经历有关——那时候我先看渲染结果,再回头查欧拉数,结果在一堆正常面片里找一条隐藏的反向环,那才是真正的时间黑洞。希望这套验证方式能帮你在实现时少走同样的弯路,也让你交付的“源代码+文档说明”足够扎实。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询