3F = 2E − h:一条三角网格自检公式的适用边界
一张三角网格,最容易被忽略的检查是「数数」:三角形有几个、边有几条、边界边有几条。这三个数之间有一条固定的关系:
3F = 2E − h
其中 F 是三角形数,E 是去重后的边数,h 是只属于一个三角形的边数,也就是边界边数。
这条式子在自检里只占一行,却很有用。但它的适用范围和检验力,比「欧拉公式」这四个字听起来要窄。先把推导写清楚,再说它能查什么、查不到什么。
公式怎么来的
数边和面的关联次数。
每个三角形有 3 条边,F 个三角形一共贡献 3F 次「边被某个三角形使用」的计数。
一条内部边被两个三角形共享,贡献 2 次;一条边界边只被一个三角形共享,贡献 1 次。设边界边数为 h,总边数为 E,那么:
3F = 2 × (E − h) + 1 × h
= 2E − h
推导只用到「每条边最多被两个三角形共享」这个前提。它不要求网格是凸的,也不要求点集在一般位置。
如果网格是一个拓扑圆盘(没有洞、没有分离的部件),还可以结合欧拉公式写成顶点形式。只数三角形面时:
V − E + F = 1
把 E = V + F − 1 代进去,得到:
F = 2V − 2 − h
这两个形式是等价的。用哪个取决于你手上有什么:3F = 2E − h 只需要边表,F = 2V − 2 − h 还需要顶点数,并且默认网格是圆盘。如果网格有洞,欧拉示性数不再是 1,顶点形式要跟着改,而 3F = 2E − h 仍然成立——这点后面还会用到。
h 为什么不能换成凸包顶点数
最容易写错的地方,是把 h 当成「凸包顶点数」。
在没有共线边界点的点集上,两者恰好相等:凸包有几条边,网格就有几条边界边。所以用凸包顶点数代入,测试也能过。但只要凸包边上有一个共线点,两者就分开了。
举个具体的例子:一个正方形,四条边上各取几个点,再在内部放一个点。凸包只有 4 个拐点,但边界被这些共线点切成了 9 段。三角剖分产生的边界边是 9 条,不是 4 条。
如果公式里写 h = hull.hullPoints.length,这个算例会直接失败:
3F = 27
2E − 4 = 32
明明网格是对的,检查却报错。改成从边表里数 count === 1 的边:
const hEdges = del.edges.filter(e => e.count === 1).length;
3 * del.triangles.length === 2 * del.edges.length - hEdges
h = 9,两边都是 27,检查通过。
这个细节说明:h 的几何含义是「边界边」,不是「凸包拐点」。在只处理拐点的凸包实现里,这两个概念很容易被混为一谈,因为大多数随机点集上它们碰巧相等。
它能抓到什么
先看一组真实数字。60 个随机点,凸包 12 个顶点:
F = 106
E = 165
h = 12
3F = 318
2E − h = 330 − 12 = 318
这些数字都是整数,比较是精确相等,不需要容差。这是它比面积、角度检查更省事的地方:不会因为浮点误差在边界上摇摆。
它能抓到的典型问题:
- 一条边被三个以上三角形共享。这时
3F把这条边数了 3 次,右边只按 2 次算,等式立刻不平衡。这种非流形结构通常来自插入或翻边时的重复建边。 - 重复的三角形。重复一个内部三角形,三条边都从共享 2 次变成 3 次,等式失败;重复一个边界三角形,通常只有一两条边是边界边,
h减少 1 或 2,而左边增加 3,仍然失败。唯一的例外是整个网格只有一个三角形(三条边都是边界边),重复它会让两边同时增加 3,碰巧通过。 - 边表独立维护时的更新错误。如果边表是增量维护的,翻边后旧边没删、新边没加,
E就会和三角形对不上。这个项目最后从三角形列表重算del.edges,所以这一类错误不会出现在这里;这是实现方式的差异,不是公式的功劳。
它抓不到什么
这是这条公式最容易被高估的地方。
如果 E 和 h 都是从同一份三角形列表推出来的,那么 3F = 2E − h 在很大程度上是一个恒等式:左边数的是边-面关联次数,右边只是把这些次数按「共享两次」和「共享一次」重新分组。只要没有边被三个以上三角形共享,两边就必然相等。
一个干净的洞,它抓不到。
删掉一个内部三角形,它的三条边从「被两个三角形共享」变成「只被一个三角形共享」。于是 F 减 1,h 加 3,E 不变:
左边:3F 减 3
右边:2E − h 减 3
两边同时减 3,等式仍然成立。网格明明多了一个三角形的洞,公式却通过了。
所以,「3F = 2E − h 通过」不等于「网格没有洞」。它证明的是「边-面关联的计数方式自洽」,不是「网格覆盖了整个凸包」。
这也是为什么自检里不能只放这一条。它必须和别的检查一起用:
- 面积恒等式:三角形面积和 = 凸包面积。洞会直接减少面积,这条能抓到;
- 覆盖面:每个输入点至少属于一个三角形。它抓的是「有点没被插进去」,不是洞——删掉一个内部三角形,周围的三角形仍然覆盖着它的三个顶点;
- 如果要把洞定位得更准,再加一条「网格边界边集合 = 凸包边集合」的比对。这是下面要说的增强做法,现有自检里还没有这条。
怎么让它变成真正的检查
如果想让 3F = 2E − h 有更强的检验力,关键是让等式两边来自不同的数据来源,而不是同一份三角形列表。
几种做法:
- h 从凸包来:不是数凸包拐点,而是把凸包边(包括边上的共线点切出来的段)做成集合,和网格里
count === 1的边集合逐条比对。集合相等比数量相等更强:数量对上了,边可能不是同一批。 - V 从输入来:统计三角形实际用到的顶点,和去重后的输入点集比对。少一个点,说明它在插入时被跳过了;多一个点,说明索引错了。
- 面积从凸包来:
Σ 三角形面积与凸包面积比。这条是浮点比较,但缺口通常远大于容差,仍然很敏感。 - 边界环:把
count === 1的边按端点串成环,检查是不是恰好一个外环。洞会多出一个内环。这个检查比 Euler 计数重,但定位更直接。
在这种组合里,3F = 2E − h 的角色是「快速结构筛查」:一秒内跑完,先排除非流形和重复三角形,再把更贵的面积、边界环比对留给后面的检查。
欧拉公式不是越多越好
还有一点值得说:不要为了「看起来严谨」堆一堆恒等式。每一条恒等式都要能回答两个问题:
- 它的两边是否独立?如果两边从同一份数据推出来,它可能只是恒真式;
- 它失败时,能不能定位到具体位置?
3F = 2E − h失败只告诉你「结构计数不对」,不告诉你是哪条边;面积失败可以定位到缺口附近;边界环失败可以直接指出洞的位置。
这也是我在自检页里把判据分成「绝对 / 自洽 / 逻辑」三类的原因。3F = 2E − h 属于「逻辑」类:它检查的是结构关系,不是几何定义。它通过,不代表几何一定对;它失败,通常说明结构真的坏了。
一条可以直接用的检查顺序
如果让我补一条并重排,我会这样排:
- 3F = 2E − h:抓非流形边和重复三角形,成本最低,先跑,当快速筛查;如果边表是独立维护的,也能抓边表错误;
- 面积和 = 凸包面积:抓漏三角形、重叠、边界缺口,敏感且好定位;
- 边界边集合 = 凸包边集合:抓洞、裂缝、边界点漏插(这是要补的判据,现有自检里还没有)。
三条都过,网格不一定完美;但任何一条失败,都说明有一个具体的问题值得去查。
一条公式的价值,不在于它写出来多漂亮,而在于它的两边是否真的在互相监督。3F = 2E − h 如果只是拿三角形列表数两遍,它监督的是自己;把 h 换成凸包给的边界,把 V 换成输入点集,它才开始监督网格。
(完)
- 点赞
- 收藏
- 关注作者
评论(0)