几何检查器也要被测:先让空圆性判据失败一次
写几何代码时,检查器通常比算法本身写得快:算个面积、数个数、比个距离,几十行就能跑。也正因为写得快,它很少被当成「需要测试的代码」。
一个永远返回「0 处违规」的检查器,看起来最稳定。但它和「什么都不检查」在输出上完全一样。要证明它有检验力,最直接的办法就是:喂给它一个已知错误的输入,看它能不能报出来。
一次真实的误报
5×5 网格,25 个点,间距均匀。把空圆性判据的容差临时改成固定的 1e-12,会报出 32 处违规。
如果真有 32 处违规,剖分应该已经乱套了。但网格看起来规整,每个方格被一条对角线分成两个直角三角形。把「三角形 + 点」逐对打印出来,每一对里的点都恰好落在那个三角形的外接圆上:方格的四个格点共圆,两条对角线选哪条都满足空圆条件,检查器却把圆周上的点算成了圆内点。
这不是几何错,是判据的容差错。按当前代码、把容差设成 0 复现这 32 对,每一对的距离平方都比 r² 小一点,最大 1.819×10⁻¹²,相对 1.289×10⁻¹⁶,约一个双精度 ulp;阈值放到 1e-11,全部消失。这些「违规」的深度只有 1 个 ulp 量级,完全是浮点舍入。
固定绝对容差的问题不在于「一定太大」或「一定太小」,而在于它和圆的大小没有关系:同一个 1e-12,在半径平方 1.4×10⁴ 的网格上太小,在半径平方 1 的小三角形上又可能太松。阈值要跟着被比较的量走,而不是跟着某个习惯小数走。
检查器现在怎么判
空圆性检查的核心只有几行:
const r2 = cc.r * cc.r;
const tol = r2 * 1e-6;
for (let i = 0; i < pts.length; i++) {
if (i === t[0] || i === t[1] || i === t[2]) continue;
const dx = pts[i].x - cc.cx, dy = pts[i].y - cc.cy;
if (dx * dx + dy * dy < r2 - tol) {
bad.push({ triangle: ti, point: i });
}
}
tol = r2 * 1e-6 是相对容差。半径平方 1.4×10⁴ 时,tol 约 0.014;半径平方 1 时,tol 是 1e-6。代码判的是 d² < r² − tol,也就是 d²/r² < 1 − 1e-6:只有明显进到圆内、相对深度超过百万分之一才算违规,更靠近圆周的一律放过。
这个阈值会不会太松?要看真实违规有多深。两条不同对角线造成的空圆性违规,通常是某个点明显落在另一个三角形的圆内,相对深度远大于 1e-6;只有接近共圆的情况才会落进容差带,而那种情况本来就剖分不唯一,判成「不算违规」是合理的。
负例一:故意连错对角线
容差调好,只说明误报少了。要证明检查器能报出真错,得构造一个已知错误的剖分。
拿四个点:
A(0, 0) B(10, 0) C(10, 10) D(1, 9)
以 ABC 为一个三角形,它的外接圆圆心在 (5, 5),半径平方 50。点 D 到圆心的距离平方是 (-4)² + 4² = 32,明显小于 50,所以 D 落在 ABC 的外接圆内。用对角线 AC 剖分,两侧都违规:ABC 的圆包含 D;ACD 的外接圆(圆心约 (6.125, 3.875),半径平方约 52.53)也包含 B(距离平方约 30.03)。检查器应该报 2 处。
换另一条对角线 BD,三角形变成 ABD 和 BCD。BCD 的外接圆圆心在 (6, 5),半径平方 41;点 A 到圆心的距离平方是 (-6)² + (-5)² = 61,在圆外。ABD 的外接圆圆心在 (5, 4),半径平方 41,C 的距离平方是 5² + 6² = 61,也在圆外。这一组没有任何违规。
同一个四边形,只换一条对角线,检查器必须从「2 处违规」变成「0 处」。如果它对两种剖分都返回 0,那它测的不是几何,是自己。
这个四边形是给检查器的建议负例:把它写进测试,检查器才能证明自己抓得住这类错。
负例二:删掉一个三角形
第二个负例更粗暴:拿一个正确的三角网,删掉中间一个三角形,然后看各个检查器能不能发现。
- 空圆性检查:大概率发现不了。剩下的三角形仍然满足空圆条件,少一个三角形不会让它们变得不合法;
- 面积和 = 凸包面积:能从变异后的三角形列表重新算的检查器会发现,面积直接少了一块;如果它读的是构造时缓存的总面积,就会漏掉——「检查器不能只信构造器给的汇总值」本身就是一条教训;
- 覆盖面:按「每个点至少属于一个三角形」这个定义,发现不了。被删三角形的三个顶点,通常还被周围的三角形使用着;
- 3F = 2E − h:也抓不到。这里的
h必须是边表里count === 1的边数:删掉内部三角形后,它的三条边从共享两次变成一次,h加 3、F减 1,3F和2E − h同时减 3,等式照样成立。如果换成凸包边数当h,等式会立刻失败——这其实是另一种用法:h小于凸包边数,说明网格边界和凸包边界对不上,中间大概率有洞。现有检查用的是边表统计值,所以这一步抓不到。
这个负例说明:没有哪一个检查器能抓住所有错误。空圆性管的是「三角形是否满足 Delaunay 条件」,不管「网格是否覆盖了整个凸包」;面积管覆盖,不管局部合法性。它们必须组合使用。
负例三:重复一个三角形
再做一个变异:把某个内部三角形复制一份,插进三角形列表。
- 空圆性检查:重复的三角形和原来完全一样,外接圆也一样,检查器不会报;
- 面积和:面积多算了一块,会报;
- 3F = 2E − h:三条边从共享 2 次变成 3 次,会报。唯一的例外是整个网格只有一个三角形:重复后
h从 3 掉到 0,3F和2E − h同为 6,等式就漏过去了。
同一个错误,三个检查器的反应不同。这不是坏事,正好说明它们的职责不同。测试要做的是:每个变异至少被一个检查器抓住;如果某个变异谁都没反应,说明检查器组合有盲区。
构造器和检查器用的是两套公式
还有一点值得说明:剖分内部判断「点是否在外接圆内」用的是行列式,而独立检查用的是圆心距离。两套公式在数学上等价,在浮点下不一定给出完全相同的判断。
构造器里的容差是 1e-12 * dmax^4(dmax 是点集包围盒的长边),因为那个行列式对坐标是四次的;检查器里的容差是 r² × 1e-6,因为比较的是平方距离。两个容差各自有各自的量纲和尺度,不能互相替代,也不应该指望它们在共圆附近逐点一致。
这意味着:「检查器报 0 违规」不等于「构造器一定用了正确的容差」。检查器能证明的是「在当前判据下没有发现明显违规」;要证明构造器稳,还得靠外部实现对拍、面积恒等式、退化算例这些不同来源的检查。
一个够用的检查器测试集
如果要把这套做法落到测试里,我会分成两组。
正向算例(必须零违规):
- 随机点集,多个固定种子;
- 网格,大量四点共圆;
- 正圆:自己给一组严格共圆的坐标(扰动过的圆环只能当近似共圆算例);
- 三簇,密度不均;
- 近共线,退化路径;
- 凸包边上的共线点;
- 重复点去重后的点集;
- 全共线退化(不产生三角形,单独断言退化标记)。
负向变异(每个都必须被至少一个检查器抓住):
- 四边形连错对角线(空圆性);
- 删掉一个内部三角形(面积和);
- 重复一个内部三角形(面积和、欧拉关系);
- 把某个点从三角形列表里排除(覆盖面)。
正向算例证明「不误报」,负向变异证明「不漏报」。只有正向算例的测试集,等于只测了一半。
适用范围与局限
- 相对容差
1e-6是这套实现的选择,换一套浮点策略可能需要调整;写清楚阈值,别人才知道「通过」是在什么标准下通过; - 坐标跨度极端的点集没有测;半径平方到 1e12 时,
r² × 1e-6是否仍然合适,需要重新评估; - 近共圆点集的剖分本身不唯一,检查器只能判断「是否明显违规」,不能判断「哪一条对角线才是唯一正确的」;
- 空圆性检查不检查覆盖,面积和与覆盖面检查不检查局部合法性;它们必须一起用。
检查器的价值在于它能拒绝什么
一个检查器报「通过」的次数,不能说明它有多可靠;它能拒绝哪些已知错误,才能说明它的检验力。
空圆性判据的这次误报,表面上是容差问题,实际上是「检查器没有被测试」的问题:如果一开始就有一组「必须零违规」的网格算例,32 处误报当场就会暴露;如果同时还有一个「必须报错」的连错对角线算例,容差调过头也能被发现。正向算例管误报,负向算例管漏报,缺哪一边都不行。
写检查器和写算法一样,都要先想清楚:它应该拒绝什么,然后拿一个真的该被拒绝的输入,看它会不会拒绝。
(完)
- 点赞
- 收藏
- 关注作者
评论(0)