耳切法切带洞多边形:论文能跑通,真实数据为什么崩

论文假设输入是简单多边形,真实测绘数据一条都不满足。

同一份耳切法代码,喂一个规规矩矩的五边形,每次都对;换一份从测绘数据里直接导出的地块------外环有几十处共线、环首尾重复点、两处边交叉、外加一个内环------渲染线程当场卡住,进度条停在第一帧。把循环强行打断,让它吐出已经切完的三角形,看到的是一堆面积对得上、形状全错的碎片。

问题不在公式。耳切法的数学部分不到三十行,任何一本计算几何教材都能抄全。真正会崩的地方全在论文的"假设"里:论文假设多边形是简单的(边界不自交)、强制的(没有重复点、没有共线点)、单一环的(不带洞)。这三条假设在地图数据里没有任何一条成立。

下面按代码执行的顺序拆:先看一刀切错的现场,再拆耳朵判定的两个判据、z-order 的位运算、洞桥接的选点、三级兜底的触发条件,最后看两条链表怎么维护、面积校验能查出什么、查不出什么。算法的数学只占两成,另外八成是把这些假设一条一条补回去。

一、先看一刀切错:只判凸角会切出什么

拿一个具体的凹多边形。一个 10×6 的矩形,从顶边中间挖一个 V 形凹口,凹口尖落在 (5,2),顶点按下标顺序给出:

text 复制代码
v0 (10, 0)
v1 ( 0, 0)
v2 ( 0, 6)
v3 ( 5, 2)   ← 凹口尖,唯一的凹点
v4 (10, 6)

这个环是顺时针的,面积 40(取绝对值)。v3 是凹点,其余四个都是凸点。

现在假设耳朵判定只做第一条------判凸角。从 v1 开始试:v1 (0,0) 的前驱是 v0 (10,0)、后继是 v2 (0,6),叉积算出来是 −60,是凸角,于是把 (v0, v1, v2) 这个三角形剪下来,记进结果,把 v1 从环上摘掉。

剪下来的三角形是 (10,0) --- (0,0) --- (0,6),面积 30。它有一条斜边从 (10,0) 到 (0,6),这条斜边的方程是 y = 6 − 0.6x。在 x = 5 处,斜边高度是 y = 3;而这个多边形的上边界在 x = 5 处只有 y = 2,因为那正是凹口尖。

也就是说,2 < y < 3 这一条沿着斜边的区域,根本不属于这个多边形 ,却被打进了第一个三角形里。这就是"跨边界的一刀":耳切法的每一刀都必须在多边形内部,而 v0--v2 这条弦穿过了凹口的外部空腔。

更麻烦的是剩下那片:环被摘掉 v1 之后变成 (0,6) (5,2) (10,6) (10,0),面积 10。30 + 10 = 40,面积校验完全通过。切错了,但校验说没问题。

所以第二个判据不是"锦上添花",它是唯一能拦住这一刀的东西。v3 (5,2) 落在三角形 (v0,v1,v2) 内部,pointInTriangle 返回真;再看 v3 自己的叉积是 +40,是凹点------否决,这只耳朵不成立。判定逻辑正是靠这一票否决,把"三角形里有别的点"翻译成"这条弦穿过了不属于自己的区域"。

把这个过程压成一张状态图:

flowchart TD A[&#34;候选三元组 prev ear next&#34;] --> B{&#34;叉积小于零&#34;} B -->|&#34;否&#34;| C[&#34;凹角 直接否决&#34;] B -->|&#34;是&#34;| D[&#34;遍历环上其余顶点&#34;] D --> E{&#34;落在三角形内&#34;} E -->|&#34;否&#34;| F[&#34;这只耳朵成立&#34;] E -->|&#34;是&#34;| G{&#34;该点也是凹点&#34;} G -->|&#34;是&#34;| C G -->|&#34;否&#34;| H[&#34;放过 继续找下一个顶点&#34;] H --> D

图里那个"该点也是凹点才否决"的分支,是刻意的放宽,后面第六节会单独说。

1.1 两条判据落在代码里

kotlin 复制代码
// 判断 ear 是否有效:凸角 + 三角形内无其他顶点
fun isEar(ear: Node): Boolean {
    val a = ear.prev; val b = ear; val c = ear.next

    if (area(a, b, c) >= 0) return false   // 凹角,不可能是耳朵

    var p = ear.next.next
    while (p != ear.prev) {
        if (pointInTriangle(a, b, c, p) && area(p.prev, p, p.next) >= 0) {
            return false   // 三角形内有别的顶点
        }
        p = p.next
    }
    return true
}

area(a,b,c) 是三顶点的带符号面积 (叉积的一半),正负号判定拐向。pointInTriangle 用叉积符号判断点在三角形内。整段没有开方、没有除法、没有三角函数------耳判定是纯加减乘的比较,这是它能跑进渲染循环的前提。

p 的起点是 ear.next.next、终点是 ear.prev,正好把环上除这三点的其余顶点走一遍。循环条件写 p != ear.prev 而不是 p != ear,是为了把 ear.prev 自己排除在外------它本来就是这个三角形的顶点,测它没有意义。

1.2 两行叉积,符号就是全部信息

area 不需要开方、不需要除法、不需要法向量,只要三个点的坐标:

kotlin 复制代码
// 带符号面积的两倍:cross(q-p, r-q)。用 (r-q) 而不是 (r-p),
// 是因为 (q-p)×(q-p)=0,两项相消后结果等价,但操作数更小、少一次减法
fun area(p: Node, q: Node, r: Node): Double =
    (q.y - p.y) * (r.x - q.x) - (q.x - p.x) * (r.y - q.y)

为什么写成 (r - q) 而不是直觉上的 (r - p)?因为叉积里 (q−p) 和自己叉乘是 0,所以 cross(q−p, r−q) ≡ cross(q−p, r−p)。两种写法结果一模一样,但 r−q 里两个操作数都是相邻顶点,差值小,浮点上更稳。这类"等价但更稳"的写法在几何代码里很多,抄的时候容易抄成 r−p 然后发现偶发的符号错误。

符号约定必须和环的绕向绑死。这个实现约定凸角的 area 为负,等价于外环按顺时针组织;如果数据给的是逆时针,入口处必须先把环反过来。忘了这一步,整块地会被判成"每个顶点都是凹点",然后一个耳朵都找不到,直接掉进兜底。

绕向归一在入口就做了:

kotlin 复制代码
// 外环要求顺时针,不符合就地反转;洞传 false
var outerNode = linkedList(data, 0, outerLen, dim, true)

linkedList 建完环会算一次带符号面积,符号不对就把整个环反向重建。这一步很便宜,但省掉它,后面所有的 area 判断都是反的。

pointInTriangle 也一样,三个叉积同号即在内,不除法、不重心坐标:

kotlin 复制代码
fun pointInTriangle(ax: Double, ay: Double, bx: Double, by: Double,
                    cx: Double, cy: Double, px: Double, py: Double): Boolean =
    (cx - px) * (ay - py) >= (ax - px) * (cy - py) &&
    (ax - px) * (by - py) >= (bx - px) * (ay - py) &&
    (bx - px) * (cy - py) >= (cx - px) * (by - py)

三个式子分别是"点 p 在三条有向边的同一侧"的叉积形式。注意全部用 >=,不是 >------边界上的点算在内部。这个选择决定了后面共线点的行为:边界算内部,于是任何贴在候选三角形边上的点都会成为否决票。

1.3 共线点为什么让"在三角形内"失效

三点共线时 area == 0。0 >= 0 成立,于是这个顶点既不是耳朵(area >= 0 直接返回 false),也不是凹点(在否决逻辑里 area >= 0 又算"凹点")。它变成了环上一个既不会被剪、也不会被跳过的死点。

更隐蔽的是它对别人的影响。设环上有一串共线点 A, B, C 落在同一条边上,现在考察另一处的候选耳朵 (P, Q, R),它的某条边恰好压在这条直线上。遍历到 B 时,pointInTriangle 因为 >= 把边界算内部,返回真;再看 B 的 area 是 0,0 >= 0 成立,于是 B 被当成"三角形内的凹点",这只合法的耳朵被否掉。

于是一个共线点可以连续否掉环上好几只本来成立的耳朵。环很长的时候,围着转一圈一个都找不到------不是因为多边形真的切不动,而是因为这些零面积的死点在不停地投否决票。

这就是 filterPoints 必须在主循环之外先跑一遍的原因,它不是"数据清洗的礼貌",它是让耳判定恢复语义的前提:

kotlin 复制代码
// 过滤重复点与共线点:删到环不再变化为止
fun filterPoints(start: Node?, end: Node? = start): Node? {
    if (start == null) return start
    var p = start
    var e = end ?: start
    do {
        // 与后继重合,或三点共线 → 这个点对形状没有贡献,删掉
        if (p.next == p || (area(p.prev, p, p.next) == 0.0 && p.prev != p.next)) {
            val prev = p.prev
            removeNode(p)
            p = prev
            e = prev
            if (p == p.next) break       // 环退化成单点,收工
        } else {
            p = p.next
        }
    } while (p != e)
    return e
}

删完之后 p 要退回 prev 重来,因为删掉一个点可能让它的前驱也变成共线。这个"退一步"是必需的,否则连成一串的共线点只能删掉隔一个删一个。

判定条件与反例对照起来看更清楚:

条件 判据 反例 漏判的后果
凸角 area(prev,ear,next) < 0 V 形凹口尖点 弦穿到多边形外,切出跨界三角形
三角形内无凹点 pointInTriangle 且该点 area >= 0 第一节的 v3 (5,2) 面积守恒但形状错,校验查不出
非退化 area != 0 三点共线 剪出零面积三角形,环不减
桥端点不作耳尖 该顶点坐标不与孪生点重合 搭桥后的重复点 把桥剪断,环裂成两段

四条里只有第一条是教科书上的,后三条全是工程补的。

二、z-order:15 位、四次移位、一次交错

朴素找耳对每只候选耳朵都要扫一遍全环,整体 O(n²)。顶点上千之后,这部分会明显吃掉帧预算。加速的办法是给顶点建一个空间索引,把"三角形内找点"变成"在这附近的点里找点"。

选 z-order 而不是四叉树或者网格,理由是它不需要额外内存:每个节点上挂一个 Double 的 z 值,排一次序,再用现成的双向链表串起来就是索引。没有桶数组、没有递归、没有节点分裂。

kotlin 复制代码
// 把坐标映射到 15 位整数,再把 x、y 的二进制位交错成一个标量
fun zOrder(x0: Double, y0: Double, minX: Double, minY: Double, invSize: Double): Double {
    var x = (32767 * (x0 - minX) * invSize).toInt()
    var y = (32767 * (y0 - minY) * invSize).toInt()
    x = (x or (x shl 8)) and 0x00FF00FF
    x = (x or (x shl 4)) and 0x0F0F0F0F
    x = (x or (x shl 2)) and 0x33333333
    x = (x or (x shl 1)) and 0x55555555
    y = (y or (y shl 8)) and 0x00FF00FF
    y = (y or (y shl 4)) and 0x0F0F0F0F
    y = (y or (y shl 2)) and 0x33333333
    y = (y or (y shl 1)) and 0x55555555
    return (x or (y shl 1)).toDouble()
}

前两行是归一化。32767 * (x0 - minX) * invSize 把坐标压到 [0, 32767],其中 invSize = 1 / max(spanX, spanY) 是调用方预先算好的倒数------函数签名里传 invSize 而不是 span,就是为了把除法提到循环外面,每次只做乘法。toInt() 是截断不是四舍五入,这一点后面会带来副作用。

中间八行是位扩散。目标是把 15 位的数字摊开,让每一位之间空一格:b14 b13 ... b1 b0 变成 b14 0 b13 0 ... 0 b1 0 b0。四个掩码干的是同一件事,只是粒度不同:

  • (x | (x << 8)) & 0x00FF00FF:把低 8 位整体搬到高 8 位上,然后用掩码只保留第 0--7 位和第 16--23 位。搬过去的 8 位被放进了奇数个"8 位槽"里;
  • (x | (x << 4)) & 0x0F0F0F0F:每 8 位一组,把低 4 位搬到高 4 位,掩码留下每组里的低 4 位;
  • (x | (x << 2)) & 0x33333333:每 4 位一组,低 2 位搬高 2 位,掩码留 0011;
  • (x | (x << 1)) & 0x55555555:每 2 位一组,低 1 位搬高 1 位,掩码留 0101。

0x55555555 就是二进制的 0101 0101 ...,隔一位一个 1。做完这一步,x 的 15 个有效位全部落在偶数位上,奇数位是 0。y 走同样的流程,然后 y << 1 把 y 的位整体推到奇数位,or 上去,两个 15 位数就咬合成了一个 30 位数。

拿一组小数走一遍最直观。取 x = 0b1010(0xA)、y = 0b0110(0x6):

步骤 掩码 x y
归一化后 --- 0x000A 0x0006
搬低 8 位 0x00FF00FF 0x0A0A 0x0606
搬低 4 位 0x0F0F0F0F 0x0A0A 0x0606
搬低 2 位 0x33333333 0x2222 0x1212
搬低 1 位 0x55555555 0x4444 0x1414
交错 x or (y shl 1) --- --- 0x6C6C

前两步对 4 位输入是空操作,因为 0x0A0A 本来就没有跨 8 位、也没有跨 4 位的位。第三步开始起作用:0x0A0A | 0x2828 = 0x2A2A,与 0x3333 相与留下 0x2222,此时 x 的两个 1 分别在 bit1 和 bit5。第四步 0x2222 | 0x4444 = 0x6666,与 0x5555 相与得 0x4444,两个 1 落在 bit2 和 bit6。

验证一下:原来的 x = 0b1010,bit1 和 bit3 是 1;摊开后应该落到 bit2 和 bit6,正好是 0x4444(bit2 = 0x4,bit6 = 0x40,两位都在 4 的幂上)。y = 0b0110 的 bit1、bit2 应该落到 bit2、bit4,摊开得 0x1414,也对。

最后 y << 1 得 0x2828,与 0x4444 或起来是 0x6C6C。从 0x6C6C 反解:偶数位 bit0..bit6 是 0,1,0,1,还原成 1010 就是 10;奇数位是 0,0,1,1,还原成 0110 就是 6。完全对得上。

0x6C6C 的十进制是 27756,一个点就这样变成了一个可以比较大小的标量。空间上相邻的两个点,比如 (10,6) 和 (11,6),只差最低位,z 值也只差 1------空间近邻变成了 z 值近邻,按 z 排序之后就得到了"按位置排好序的字典"。

2.1 为什么是 15 位

32767 = 2^15 − 1。15 位 x 加 15 位 y 交错,正好占 30 位,落在 32 位整数的有效范围内,符号位不用。返回值写成 Double 是为了跟后面的区间比较保持同一类型,而 30 位整数在 double 的 53 位尾数里是精确的,不会丢位。

15 位意味着分辨率是包围盒长边的 1/32767 ,大约是万分之三。1 公里的街区,一个 z 单位约 3 厘米;100 公里的市域,一个 z 单位约 3 米。这个精度只跟多边形自己的包围盒有关,跟地图的绝对坐标无关------因为归一化是相对 minX/minY 做的,所以不存在"大坐标丢精度"的问题。这也是把归一化放在这里而不是放在投影阶段的原因。

代价是精度跟跨度成反比。跨度到省级、全国级时,一个 z 单位已经粗于要素本身的细节,多个顶点被截断到同一个整数上,z 链上出现大批同值节点,区间查找退化成长度不定的线性扫描;反过来,跨度很小的构件级数据,分辨率远细于坐标噪声,一堆本该重合的点被摊到相邻 z 上,索引变得没意义但也不出错。真正会出事的是越界:toInt() 截断后如果顶点因为浮点误差超出了包围盒,会得到 32768 或者负数,摊位之后串到相邻区间里去。所以包围盒必须严格按所有环(含洞)算,不能只算外环。

2.2 为什么是 80 倍维度

建索引不是白给的:一次排序 O(n log n)、每个节点多两个指针、还要多存一个 z 值。对几十个顶点的多边形,主循环本来就只跑几十次,这些固定开销反而更大------给几十个顶点建索引是负优化。

触发条件是 data.length > 80 * dim,dim 通常是 2,也就是顶点数超过 80 时启用(data.length 是坐标分量个数,除以 dim 才是点数)。80 这个数是从"建索引成本 vs 省下的内层循环成本"的交叉点估出来的,不是理论最优值。它只在顶点够多、内层扫描真的成为瓶颈之后才划算。

三种找耳实现放在一起对比:

实现 内层代价 适用规模 失效方式
朴素遍历全环 每只耳 O(n),整体 O(n²) 几十到一两百顶点 大环上直接超预算
z-order 单链 O(log n + k),k 为区间内点数 千级以上顶点 三角形包围盒远小于数据跨度时 k 变大
均匀网格 查询 O(1) 顶点密度均匀 密度极不均时空桶过多,内存不可控

地图数据恰好是"顶点数多、分布不均、内存敏感",z-order 是这三个约束下的折中:不额外分配、不改数据结构、精度损失可以算清楚。

三、洞桥接:候选点不等于桥

带洞多边形不能直接耳切。耳切是绕着环剪的,洞是一个悬在中间、跟外环没有任何连接的独立环,剪外环的耳朵永远碰不到它。解法是在洞和外环之间划一刀,把两个环焊成一个单环------像把一张中间掏了洞的纸,从洞边缘向外剪一刀,甜甜圈就变成 C 形。

这一刀的位置不能随便选。选错了,桥边会横穿别的边,焊出来的环直接自交,比不焊还糟。

kotlin 复制代码
// 找到洞与外环之间的桥接点(David Eberly 算法)
fun findHoleBridge(hole: Node, outer: Node): Node? {
    var p = outer
    val hx = hole.x; val hy = hole.y
    var qx = -Double.MAX_VALUE
    var m: Node? = null

    do {
        // 洞的最左点向左的射线,与外环某条边相交
        if (hy <= p.y && hy >= p.next.y && p.next.y != p.y) {
            val x = p.x + (hy - p.y) * (p.next.x - p.x) / (p.next.y - p.y)
            if (x <= hx && x > qx) {
                qx = x
                m = if (p.x < p.next.x) p else p.next
            }
        }
        p = p.next
    } while (p != outer)

    // 再在候选附近找最小角度的连接点,避免桥边与其他边相交
    return m
}

从洞的最左点 (hx, hy) 向左发一条水平射线,遍历外环所有边,找出与射线相交、且交点在外环最右侧的那一条。条件 hy <= p.y && hy >= p.next.y 要求这条边从上往下跨过射线高度------因为外环是顺时针的,这个方向才对应"射线从外部打进来"的那一侧。交点横坐标用线性插值算,x <= hx 保证交点在洞的左边,x > qx 保证取最右的那个。

几何上是这样的:

flowchart LR H[&#34;洞环最左点 H&#34;] --> RAY[&#34;向左发水平射线&#34;] RAY --> E1[&#34;外环边 e1 交点 x1&#34;] RAY --> E2[&#34;外环边 e2 交点 x2&#34;] E1 --> PICK[&#34;取横坐标更大的 x2&#34;] E2 --> PICK PICK --> CAND[&#34;候选点 M 为 e2 的靠右端点&#34;] CAND --> CHK{&#34;三角形 H x2 M 内有凹点&#34;} CHK -->|&#34;没有&#34;| BR[&#34;直接搭桥 H 连 M&#34;] CHK -->|&#34;有&#34;| ANG[&#34;选与射线夹角最小的点 M2&#34;] ANG --> BR BR --> MERGE[&#34;环上出现 H 到 M 再回到 H 的零宽通道&#34;]

但 m 只是候选点,不是桥。 函数注释写着"再在候选附近找最小角度的连接点",可这段代码里只返回了 m,没有做那一步。这是抄论文伪代码最容易漏的地方------原算法还有后半段。

为什么必须补?因为从 m 出发连到 hole 的桥边,可能正好贴着或者压着外环的另一条边。视觉上你选的是"最右边的交点",但那个交点是射线与一条边的交点,而桥要连的是边的端点,端点未必是合适的宿主:从端点出发的桥边可能一头扎进多边形的外面,也可能和另一条边重合。

补全的做法是:把 H、射线与 m 的连线、以及 m 本身围成一个三角形(通常是一个以 m 为顶点的窄三角),检查这个三角形里有没有别的凹点;如果有,就选与水平射线夹角最小的那个------夹角小意味着桥边最贴近射线,最不容易碰到别的边。

kotlin 复制代码
// m 之后还要在候选三角形里按最小夹角重选
var stop = m!!
var mx = m.x; var my = m.y
var tanMin = Double.POSITIVE_INFINITY
var p = m
do {
    // 只找落在 H 到 M 这条射线附近、且在候选三角形内的点
    if (hx >= p.x && p.x >= mx && hx != p.x &&
        pointInTriangle(if (hy < my) hx else qx, hy, mx, my,
                        if (hy < my) qx else hx, hy, p.x, p.y)) {
        val tan = abs(hy - p.y) / (hx - p.x)   // 与射线的夹角正切
        if (locallyInside(p, hole) &&
            (tan < tanMin || (tan == tanMin && p.x > m.x))) {
            m = p
            tanMin = tan
        }
    }
    p = p.next
} while (p != stop)

关键是 locallyInside(p, hole) 这一句:它检查从 p 到 hole 的线段在 p 处是否落在多边形内部。夹角再小,只要桥边从 p 出发是往外的,就一律不可用。这一条是"桥边不能与任何边相交"的最小可行判据------完整的相交检测太贵,而桥边只有两个端点需要保证局部可见,局部判一下就够。

3.1 多洞的顺序,与桥点在后续耳切里的身份

多个洞时,按每个洞的最左点 x 坐标从左到右依次桥接。

顺序不能乱。每桥接一个洞,外环就变了------洞被缝了进来,环的边界上多了一条零宽通道。后一个洞的射线如果先打,打到的可能是"原始外环",也可能打到"已经缝进来的前一个洞的边",两种情况的交点完全不同。从左往右处理,保证射线总是先碰到已经处理完的那部分边界,桥边不会横穿还没处理的洞。

搭桥具体怎么改环,是复制节点:

kotlin 复制代码
// 把两个环缝成一个:a、b 各复制一份,交叉接进对方的环
fun splitPolygon(a: Node, b: Node): Node {
    val a2 = Node(a.i, a.x, a.y)   // a 的副本,插进 b 所在环
    val b2 = Node(b.i, b.x, b.y)   // b 的副本,插进 a 所在环
    val an = a.next
    val bp = b.prev

    a.next = b
    b.prev = a
    a2.next = an
    an.prev = a2
    b2.next = a2
    a2.prev = b2
    bp.next = b2
    b2.prev = bp
    return b2
}

缝完之后,环上出现了两段方向相反的重复边:a → b 和 b2 → a2。这两条边的坐标完全一样,围出的面积恰好是 0。整个多边形从"外环 + 内环"变成了"一个自我贴合的单环",中间那条通道宽度为零。

这个零宽通道在后续耳切里是个麻烦:

  • 任何以 a 或 b 为顶点的候选三角形,内部判定都会撞上同坐标的孪生点。pointInTriangle 用 >= 把边界算内部,孪生点必被判为"在三角形内";如果它还是个凹点,这只耳朵就被否掉;
  • 由 a、b、b2 或 a、b、a2 围成的"三角形"面积是 0,area >= 0 让它永远不成立------这是好事,因为剪掉它等于把桥又剪断,环会裂回两个;
  • 孪生点在环上相隔很远,filterPoints 的"相邻重复点"判据管不到它们,只能等相邻的耳朵被剪掉时顺带消失。

所以桥接点必须被显式认出来:面积为零的三元组不是耳朵,是桥的痕迹 。工程上不需要给节点打标记,area == 0 天然就是判据;但代码里任何"面积接近零就当成退化三角形丢掉"的优化,都会在这里把桥剪断。这也是为什么判定用的是严格的 >= 0 而不是 > epsilon------一旦引入容差,桥就保不住了。

四、主循环与三级兜底

骨架先摆出来:

flowchart TD A[&#34;输入 外环与内环坐标&#34;] --> B[&#34;统一环的绕向&#34;] B --> C{&#34;还有未处理的洞&#34;} C -->|&#34;有&#34;| D[&#34;按最左点 x 排序&#34;] D --> E[&#34;向左射线求候选桥点&#34;] E --> F[&#34;按最小夹角重选桥点&#34;] F --> G[&#34;搭桥合并成单环&#34;] G --> C C -->|&#34;没有了&#34;| H{&#34;顶点数超过 80 乘 dim&#34;} H -->|&#34;超过&#34;| I[&#34;建 z-order 索引&#34;] H -->|&#34;不超过&#34;| J[&#34;朴素遍历&#34;] I --> K[&#34;主循环 找耳并剪掉&#34;] J --> K K --> L{&#34;这一轮找到耳&#34;} L -->|&#34;找到&#34;| M[&#34;记录三角形 删除该节点&#34;] M --> K L -->|&#34;绕一圈没找到&#34;| N[&#34;三级兜底 pass 加一&#34;] N --> K K --> O{&#34;环上只剩一个三角形&#34;} O -->|&#34;是&#34;| P[&#34;输出三角形索引&#34;]

主循环本身只有二十来行,全部复杂度都在那个 when (pass) 里:

kotlin 复制代码
fun earcutLinked(ear: Node?, triangles: MutableList<Int>, dim: Int,
                 minX: Double, minY: Double, invSize: Double, pass: Int) {
    if (ear == null) return
    if (pass == 0 && invSize != 0) indexCurve(ear, minX, minY, invSize)  // 建 z-order

    var stop = ear
    var prev: Node; var next: Node
    var cur = ear

    while (cur.prev != cur.next) {
        prev = cur.prev; next = cur.next
        val isEar = if (invSize != 0) isEarHashed(cur, minX, minY, invSize) else isEar(cur)

        if (isEar) {
            triangles.add(prev.i / dim)
            triangles.add(cur.i / dim)
            triangles.add(next.i / dim)
            removeNode(cur)
            cur = next.next
            stop = next.next
            continue
        }

        cur = next
        if (cur == stop) {
            // 绕了一圈找不到耳朵 → 进入兜底
            when (pass) {
                0 -> earcutLinked(filterPoints(cur, null), triangles, dim, minX, minY, invSize, 1)
                1 -> { cur = cureLocalIntersections(filterPoints(cur, null), triangles, dim); earcutLinked(cur, triangles, dim, minX, minY, invSize, 2) }
                2 -> splitEarcut(cur, triangles, dim, minX, minY, invSize)
            }
            break
        }
    }
}

几个细节值得单说。cur = next.next 而不是 next------剪掉一只耳朵后,只有新暴露出来的那个顶点有可能变成新耳朵,从它开始试比从头扫快得多。stop = next.next 同步更新,保证"绕一圈"的判据是相对于当前位置的完整一圈,而不是固定起点。

pass 是单向递增的,一旦进了第 1 层就不会回到第 0 层。这个设计避免了一个真实的坑:滤点之后环变短,如果重新跑 0 层,可能又找不到耳,又滤一次,无限循环。

三层兜底各管一类脏数据:

脏数据类型 典型来源 对应兜底 不做会怎样
相邻点重复 采点抖动、闭合环首尾多写一个点 pass 1 filterPoints 零长度边让 area 恒为 0,该顶点永不成立
三点共线 数字化时取点落在同一直线上 pass 1 filterPoints 共线点既不凸也不凹,绕过一圈环不减
局部边交叉 拓扑错误、环方向搞反 pass 2 cureLocalIntersections 交叉处两块三角形互相覆盖,切出重叠面
尖点相接的 8 字形 两个部件被错误合并成一个环 pass 3 splitEarcut 每个候选耳都被对面否决,彻底死循环
退化成点或线 面积为零的要素 入口面积检查 偏差计算除零,得到 NaN

4.1 只做滤点会怎样

造一个"8 字形":两个 4×4 的方块在 (2,2) 处尖点相接,环按 (0,0) (2,0) (2,2) (4,2) (4,4) (2,4) (2,2) (0,2) 走一圈,注意 (2,2) 出现了两次。

这个环没有任何相邻重复点((2,2) 的两个副本隔了三个顶点),也没有任何三点共线,filterPoints 一个点都删不掉,原样返回。主循环接着转:在 (2,2) 附近,任何一个候选三角形都有一半落在另一个方块里,pointInTriangle 总能找到一个落在内部的凹点。绕一圈,一个耳朵都没有。

pass 递增到 1,再滤一次,还是删不掉;递增到 2,cureLocalIntersections 找的是"两条不相邻边的真交叉",而 8 字形在 (2,2) 处是接触 不是穿越,两条边共用一个端点,判定为不相交,也修不了。只有第 3 层分裂能救。

4.2 只做修自交会怎样

反过来,只有 cureLocalIntersections 没有 filterPoints:一串锯齿状的共线点,比如一条边上来回折了十几下、每段长度都是 0.01,坐标上全部落在同一条直线上。

这些点两两之间不构成"交叉"------它们首尾相接,是相邻边。cureLocalIntersections 的判据是"两个不相邻的节点构成的三角形",相邻边永远进不了这个集合。于是修自交这一层空转,环一点没短,回到主循环继续找不到耳。

这两层缺一不可,因为它们针对的是拓扑上完全不同的两种病:一个是"边真的穿过去了",一个是"顶点堆在一起但边没穿"。

4.3 没有分裂会怎样

splitEarcut 是最后一层,也是最粗暴的一层:不再试图修,直接把多边形劈成两半,两半各自递归。

kotlin 复制代码
// 找不到任何一只耳朵时的最后手段:沿一条合法对角线一分为二
fun splitEarcut(start: Node, triangles: MutableList<Int>, dim: Int,
                minX: Double, minY: Double, invSize: Double) {
    var a = start
    do {
        var b = a.next.next          // 从隔一个点开始,保证两边各留至少一个顶点
        while (b != a.prev) {
            if (a.i != b.i && isValidDiagonal(a, b)) {
                val c = splitPolygon(a, b)        // 复制 a、b,裂成两个环
                a = filterPoints(a, a.next)
                val c2 = filterPoints(c, c.next)
                earcutLinked(a, triangles, dim, minX, minY, invSize, 0)
                earcutLinked(c2, triangles, dim, minX, minY, invSize, 0)
                return
            }
            b = b.next
        }
        a = a.next
    } while (a != start)
}

b 从 a.next.next 起步而不是 a.next,是因为 a 和它的直接邻居构成的"对角线"就是一条已有的边,切下去只会切出一个退化的两顶点环。这个细节决定了"分成两半"到底是不是两半------从隔一个点开始,两侧各至少留一个顶点,劈出来的两个环才都有得切。

对角线的合法性归结为四条:

kotlin 复制代码
fun isValidDiagonal(a: Node, b: Node): Boolean =
    a.next.i != b.i && a.prev.i != b.i &&      // 不能和直接邻居连
    !intersectsPolygon(a, b) &&                // 不能和环上任何一条边真交叉
    locallyInside(a, b) && locallyInside(b, a) // 两端出发都得朝多边形内部

只查"不相交"是不够的:一条完全在多边形外面的线段也不相交,但它切不出两块有效区域。locallyInside 补的就是这个方向性------从 a 出发的线段得先落进多边形的内角里。

8 字形在 (2,2) 处,唯一合法的对角线是横穿两个方块的那条,它把环劈成两个各自简单的子环,各自回去耳切,一次就过了。没有这一层,主循环会在 while (cur.prev != cur.next) 里转到天荒地老。

三层兜底的状态流转:

stateDiagram-v2 [*] --> S0 S0 : pass 0 正常找耳 S0 --> S1 : 绕一圈找不到耳 S1 : pass 1 过滤重复点与共线点 S1 --> S0 : 环被修短 重新找耳 S1 --> S2 : 仍然找不到 S2 : pass 2 修局部自交 S2 --> S0 : 剪掉退化三角形后重试 S2 --> S3 : 仍然找不到 S3 : pass 3 沿合法对角线分裂 S3 --> S0 : 子环各自从头再来 S0 --> [*] : 环上只剩三个点 S3 --> [*] : 找不到任何合法对角线

图里最后那条边是真实存在的出口:splitEarcut 转完两个 do-while 也没找到合法对角线时,函数直接返回,什么都不加。这种情况通常意味着输入已经不是多边形了(比如所有点共线),此时"少切几个三角形"比"卡死渲染线程"要好。

五、两条链表的不变式

耳切需要同时做三件互相矛盾的事:按拓扑顺序访问邻居、按空间顺序找附近的点、频繁删除节点。一个数组做不到,一个链表也不够。所以每个节点同时挂在两条链上:

flowchart LR subgraph MAIN[&#34;主链 prev 与 next&#34;] A1[&#34;A&#34;] --> B1[&#34;B&#34;] B1 --> C1[&#34;C&#34;] C1 --> D1[&#34;D&#34;] D1 --> A1 end subgraph ZC[&#34;z 序链 prevZ 与 nextZ&#34;] Z1[&#34;A&#34;] --> Z2[&#34;C&#34;] Z2 --> Z3[&#34;B&#34;] Z3 --> Z4[&#34;D&#34;] end B1 -.-> Z3 C1 -.-> Z2

主链 prev / next 表达拓扑 :谁是这只耳朵的前驱、谁是后继。z 链 prevZ / nextZ 表达空间 :谁在 z 值上排在前面。同一个节点 B 在两条链上有两个完全不同的前驱后继,这就是为什么删一个节点要维护四条指针。

indexCurve 负责把 z 链建起来:按 z 值排一次序(通常用归并,对已经近似有序的数据很快),然后按顺序串 nextZ。注意它不改主链------主链的拓扑是数据本来的样子,不能动。

删除是唯一的修改入口,必须一次改完:

kotlin 复制代码
fun removeNode(p: Node) {
    // 主链:让前后邻居互相接上
    p.next.prev = p.prev
    p.prev.next = p.next
    // z 链:同样的两行,一条都不能少
    if (p.prevZ != null) p.prevZ.nextZ = p.nextZ
    if (p.nextZ != null) p.nextZ.prevZ = p.prevZ
}

上半段维护拓扑,下半段维护空间。漏掉下半段会发生什么,值得写清楚:

节点从主链上消失了,但它还挂在 z 链上。后面 isEarHashed 按三角形包围盒的 z 区间去 z 链上扫点时,会扫到这个已经不存在的点。它有以下三个字段:坐标、i(原始数组下标)、以及它自己那份 prev/next------注意 prev/next 还指着它当年在主链上的邻居,而那些邻居可能已经全被剪掉了。

于是判定拿一个"幽灵"去做 pointInTriangle。如果它落在候选三角形内,这只本来合法的耳朵被误否;如果它不在,好像没影响。结果是:取决于数据给的顺序,有时对有时错。同一份数据换个起点重跑,切出来的三角形数量都可能不一样。

更糟的是幽灵会被"用到":isEarHashed 命中它之后不会再删它,因为删除只发生在剪耳朵的时候。它就一直留在 z 链上,占着那段 z 区间,让本来应该被扫到的真节点被挤在外面------区间扫描是按 z 值从小到大推进的,幽灵的 z 值卡在中间,扫描提前结束,真正的点没查到。

最终症状是重复三角形:同一块面积被切了两次,两块三角形重叠。面积偏差立刻就能发现(三角形总面积大于多边形面积),但定位极难------因为出问题的那次删除,可能发生在几千次循环之前。

所以不变式得写死:

  1. 对任意存活节点,p.next.prev == p 且 p.prev.next == p;
  2. 对任意存活节点,若 p.nextZ != null 则 p.nextZ.prevZ == p;
  3. 两条链的成员集合必须完全相同;
  4. z 链按 z 值非降序;
  5. 删除只有 removeNode 一个入口。

第 3 条是根,第 5 条是保证。任何"跳过某个节点"的写法------比如为了省一次调用,直接在循环里 p.prev.next = p.next------都只破了主链的对称性,z 链毫发无损,然后就是上面那串症状。

还有一处容易漏:filterPoints 和 splitPolygon 都会删或复制节点。filterPoints 调的是 removeNode,没问题;splitPolygon 复制节点的时候只接了主链,新的副本不在 z 链上 。所以分裂之后两个子环都从 pass = 0 重新开始,重新跑一次 indexCurve 重建 z 链。如果省掉这一步直接沿用旧的 z 链,新环有一半节点不在索引里,isEarHashed 会漏判,切出来的三角形会有重叠。

六、面积偏差:能查出漏切,查不出切错

切完怎么知道对不对。工程上的做法是算一个相对偏差:三角形面积之和应该等于多边形面积。

kotlin 复制代码
// 校验:三角剖分面积 vs 多边形面积,返回相对偏差
fun deviation(data: DoubleArray, holeIndices: IntArray?, dim: Int, triangles: List<Int>): Double {
    var polygonArea = abs(signedArea(data, 0, outerLen, dim))
    if (hasHoles) {
        for (i in holeIndices.indices) {
            polygonArea -= abs(signedArea(data, start(i), end(i), dim))  // 减去洞的面积
        }
    }
    var trianglesArea = 0.0
    for (i in triangles.indices step 3) {
        trianglesArea += abs(triangleArea(data, triangles[i], triangles[i+1], triangles[i+2]))
    }
    return if (polygonArea == 0.0 && trianglesArea == 0.0) 0.0
           else abs((trianglesArea - polygonArea) / polygonArea)
}

两处细节。洞要减 掉,而且用的是洞自身面积的绝对值------如果洞的环绕向和外环相反(通常如此),signedArea 出来是负的,不减绝对值就变成加,分母直接错。分母为零时单独返回 0,避免除零得到 NaN。

口径上,判通过看的不是"差多少平方米",而是相对偏差 :|Σ三角形 − 多边形| / 多边形面积。分子是几百个 double 的累加,误差随项数线性增长,正常数据的相对偏差会落在机器精度量级(2^-52 乘以累加项数的量级,也就是 1e-13 上下);一旦它明显跳出这个量级,比如到了 1e-6,就不是浮点噪声,而是切漏了或者切重了。所以阈值不该拍一个平方米数------同样的绝对误差,对一个小地块是灾难,对一个大行政区可以忽略。

重复点和共线点对偏差的影响是单向的 :它们贡献的三角形面积是 0,不改变分子的值,只让累加的项数变多,误差略微增加。真正会影响数值的是它们让 signedArea 的项数变多------多边形面积本身也是逐边累加的,边数翻倍,分母的误差也翻倍。所以滤点除了修正确性,也顺带压了一下数值误差。

这里必须说清楚一个盲区:面积偏差查不出"切错位置"。

第一节那个 V 形凹口的例子,切出来的两块面积是 30 和 10,加起来正好 40,偏差为 0。但第一块三角形覆盖到了凹口外面。原因在数学上很干脆:从环上摘掉一个顶点 v、补上三角形 (prev, v, next),带符号面积是恒等的------

text 复制代码
A(ring) = A(ring - v) + A(triangle(prev, v, next))

这是鞋带公式展开后逐项相消的结果,与 v 在哪、三角形是否跨界毫无关系。只要每一片的绕向一致(都取绝对值之后同号),面积校验就永远通过。

所以面积偏差的定位是:能查漏切和重切,查不出切的方向错了。它是一道便宜的兜底闸门,不是正确性证明。真要在运行时发现"切歪了",得靠别的量------比如检查输出三角形的每条边是否都落在原多边形内部,那个代价就高了,工程上一般不做,靠的是"耳判定的两条都判对"这个前提。

顺带一提,自交输入会让这个校验彻底失真。signedArea 对有自交的环做的是有符号求和,正瓣和反瓣互相抵消,polygonArea 会偏小甚至接近 0,分母失真之后,偏差比值就没有意义了。这也是为什么修自交必须在校验之前------校验只能验"切得对不对",验不了"输入本身是不是多边形"。

七、把策略换掉

如果现在重写,我可能会把三角剖分做成可替换的策略接口,用更稳健的梯形分解或约束 Delaunay 三角化作为后备,耳切法作为快速路径。但代价是引入更重的依赖和复杂度------对大多数地块数据,耳切法 + 三件套已经足够健壮。

这句取舍不是客套。耳切法的价值在于它几乎没有依赖:不需要维护边的有序集合,不需要处理约束边的插入,不需要浮点鲁棒性谓词(虽然它有精度问题,但在 15 位量化的尺度上问题被压小了)。梯形分解要在扫描线上维护事件队列和状态结构,约束 Delaunay 要处理约束边的恢复,两者的代码量都是耳切的好几倍,而且都会引入"退化输入下谓词不精确"这个更难的坑。

所以合理的分层是:耳切做默认路径,因为它对良态数据快且简单;把"输入是否良态"的判定放在入口,明显的病态数据(自交、尖点相接)直接换到更稳的算法上,而不是指望三级兜底把它们全都救回来。兜底的作用是别崩 ,不是切得漂亮------8 字形被劈成两半之后切出来的三角形,形状往往很难看,只是面积对、不重叠而已。

这两件事的边界要分清:兜底保证渲染线程不被卡死、保证不出现破洞,但不保证三角形的质量。真要质量(比如后面还要做三角网简化、要做法线插值),从一开始就该用 Delaunay。

八、小结

  • 耳朵的两个判据缺一不可:只判凸角,弦会穿到凹口外面,而这种错切面积恰好守恒,校验查不出来。
  • z-order 的四次移位是在把 15 位整数的每一位摊开、空出一格,两个摊开的数一 or 就交错成 30 位标量;> 80 * dim 才建索引,因为给几十个顶点排序是负优化。
  • 洞桥接的射线交点只是候选,真正搭桥还要在候选三角形里按最小夹角重选,并用 locallyInside 保证桥边朝内;多洞按最左点 x 从左到右处理。
  • 三级兜底对应三类完全不同的病:滤点管重复与共线,修自交管真交叉,分裂管尖点相接;漏任何一层都会掉进死循环。
  • 主链管拓扑、z 链管空间,删节点必须四条指针一起改;漏改 z 链会产生"幽灵节点",症状是偶发的重复三角形,且极难复现。

你手上那份数据,如果先跑一遍 filterPoints 再统计还剩多少个顶点,通常会少掉百分之几?

相关推荐
洞见前行18 分钟前
你的 Activity 关了,为什么还活着?Android 内存泄漏从发现、定位到线上治理
android·性能优化·leakcanary
Kapaseker2 小时前
Kotlin 又要加语法糖了:这次轮到 if-else
android·kotlin
恋猫de小郭4 小时前
Flutter 3.50 & Dart 3.14 发布,快来看看有什么更新吧
android·前端·flutter
Starry-sky(jing)1 天前
Vidda ADB 开启:R Pro 65 找不到开发者选项的处理与连接验收
android·adb·远程控制·电视·vidda
雨白1 天前
从原理到实践:使用 Kotlin KSP 仿写 ButterKnifeLite
android·kotlin
疯华整猫1 天前
把二十七个 init 从首帧前面搬走:用 Trae 把启动优化钉进 CI
android·性能优化·ai编程
灿飞1 天前
Kotlin与Java虚拟机十一:类与属性:构造顺序、backing field 与显式 backing field
java·后端·kotlin