深入 Canvas/SVG 的布尔运算(Martinez 法)

1,132 阅读15分钟

🙋🏻‍♀️ 编者按:本文作者是蚂蚁集团前端工程师阿侎,和大家深入探讨 Canvas/SVG 的布尔运算,包括环绕规则、分割、求交、线段内外性、线段注释、扩展曲线、选择结果、强制奇偶等等,欢迎查阅~

前言

支付宝 LOGO 的结构图相信很多人看过。它通过若干形状和线段,描述了整体矢量图形的逻辑构造。比如:背景是个圆角矩形;内部减去一个“支”矢量图。

这种将任意 2 个矢量图形,通过合、减、交、差的集合方法,组合成新的矢量图形的方法叫做多边形的布尔运算。 看上去和位图的 mask 遮罩很相似,但功能更强大一些。因为 mask 的作用相当于“交集”,反向 mask 或者说 clip 裁剪相当于“减集”,合集和差集则很不常见。 矢量的计算处理,涉及一定程度的几何图形学知识,也比位图 mask 要难。

环绕规则

矢量图形绘制有个基本概念,需要定义区分一个闭合区域的内部或外部,这样在渲染时只会填充内部。通过环绕数(winding number)来判断内外,也叫环绕规则或填充规则。 一个闭合区域的边的顺序分为顺时针和逆时针 2 种情况。如下图三角形内部填充红色,3 条边可以顺时针画也可以逆时针画。 image.png 从区域中任意方向引一条射线,判断射线和边的相交结果(相切不算),如果遇到逆时针的边记 +1,顺时针的边记 -1,那么最终所有次数相加的和,即环绕数,可能有 3 种情况:正数、零、负数。 通过定义不同情况便可确立填充规则,常见的是非零环绕和奇偶环绕。前者指环绕数非零为内部、其它为外部,后者指奇数为内部、偶数为外部。上面 2 个三角形的环绕数是 -1和 +1,无论哪种规则效果都是一样的。 再看下图左,按照黑色箭头的笔画方向绘制一个星形,在非零和奇偶 2 种规则下填充效果分别为图中和图右。星形正中间区域环绕数是 -2,非零认为是内部,奇偶认为是外部。 image.png 在 Canvas/SVG 中,默认是非零,可以通过传值更改:

// canvas
ctx.fill('nonzero'); // 默认非零可不传
ctx.fill('evenodd'); // 奇偶

//svg
<path fill-rule="nonzero"/> // 默认非零可不传
<path fill-rule="evenodd"/> // 奇偶

Bentley-Ottmann

Bentley-Ottmann 是一个求平面上所有线段交点的算法,属于扫描线算法的一种。 扫描线算法是指引入一条直线,可以是水平或者垂直的,从一端扫描到另一端,中间经过顶点位置时会触发事件。在这个过程中完成求交的结果。 如下图,有红色线段 a 和蓝色线段 b,能看出它们有个交点。垂直扫描线从左侧扫描到右侧,依次经历了:a 左侧开始点、b 左侧开始点、a 和 b 交点(后面会说特殊性)、b 右侧结束点、a 右侧结束点。处在扫描线范围内的线段称为活动线段。 5 个顶点位置触发 5 个事件,配合上不同的处理,便可完成整体算法(例子中交点这个顶点事件很特殊,并不是一开始就已知的,而是 a 和 b 开始事件后求得的,如果不相交就不会有)。 image.png 这个算法的好处是线段很多的情况下,时间复杂度约为 O(nlogn)。不像两两对比需要 O(n^2)。具体可以搜索或者看《Wiki》,这里不过多深入。 Bentley-Ottmann 揭示了 2 个要点:

  1. 两条相交的线段必定是临近的:扫描过程中只检测相邻的两条活动线段是否相交,大大减少了对比次数;
  2. 两条线段相交后会交换上下位置:起初 a 在 b 上面,相交后 a 在 b 下方,相邻关系发生变化。

Martinez 以此为根据写了篇算法论文(见末尾参考),在扫描求交的同时进行线段的内外性注释,最后给出布尔运算的结果。可惜的是,它只支持直线多边形,不支持曲线多边形。 现实情况中贝塞尔曲线矢量图形更加常见,比如文章开头支付宝 LOGO 中大量的圆弧。虽然可以通过将弧线离散为很多细小的直线,但这样会产生精度问题,顶点过多也会影响性能。 笔者借鉴Vatti的方法将弧线分割为x单调性的弧线,将求交的部分用普通扫描线算法实现,再以Martinez的线段内外性注释方法完成布尔运算。这样做的原因是逻辑比较清晰简单,方便拆分解耦。如果有同时求交和注释的详细解法,请不吝赐教。 另:Martinez法强制参与运算的规则是奇偶环绕,sketch似乎就是如此,见后文。

分割

算法的第一步是检查是否有非 x 单调性的曲线,如果有则求导确定单调变化的极值点,然后分割曲线,确保 x 一定是单调增或者单调减。这么做是为了后面线段注释算法,可以先跳过。 如图,多边形右侧的非 x 单调性曲线切割为上下 2 份,原本 4 条边 4 个顶点变成 5 条边 5 个顶点: image.png

求交

普通的扫描线求交算法理解起来要简单一些,不像 Bentley-Ottmann 有诸多限制,还能包含曲线。 具体做法是先遍历求得每条线段的 bbox,即包围盒。直线比较简单,曲线需要求导求极值。可以参考:

www.iquilezles.org/www/article…

注意文中遗漏了特殊情况,当 2 次项系数为 0 时,方程降级为一元一次。 image.png 有了 bbox 后,扫描线经过的点即每个 bbox 的左侧开始和右侧结束。每扫过开始,将这条线段加入活动列表;扫过结束则剔除。处于活动列表中的线段且 bbox 有重叠的,才进行真正求交。再有交点结果的,从相交处分割线段。 image.png 如上图,3 条线段 abc 的 bbox 都用半透明底色填充供可视化观察(图左)。

  • 当扫描线扫到 b 的开始时(图中),活动列表是 a 和 b,且 a 和 b 的 bbox 重叠,求交但没有交点。
  • 当扫到 c 的开始时(图右),活动列表是 a 和 c,且 a 和 c 的 bbox 重叠,求交有交点。将 a 分割为 a1 和 a2,c 分割为 c1 和 c2(图下)。

注意,如果遇到线段重合,包括部分重合和完全重合,重合的部分只计一次,多余的要舍弃掉。但重合次数要记下来,比如 a、b 线段中间部分重合,求交后重合部分提取出来作为一条线段,次数是 2。 image.png 另外垂线是个特殊的情况,它会和扫描线重合。这时记下端点是开始,上端点是结束。在进入活动列表时要特殊注意下,不要刚进来就被剔除了。

线段内外性

这里我直接借用了 @velipso 的 blog 的例子,他画得非常漂亮!原文链接在末尾参考。 image.png 红色多边形和蓝色多边形如上图,它们重合的边(线段)有 2、3、4,次数都是 2。 Martinez 法是基于边的思考,不同于 Greiner-Hormann 基于点的思考,它最重要的是可以处理重合问题。 我们先将 2 个多边形各自查看,每条边(线段)都有两侧的概念,其中一侧是多边形内部,另外一侧是外部。 image.png 以同色实心圆圈标识内部,空心圆圈标识外部,很容易理解上图中红蓝多边形每条边的内外性。 然后再将 2 个多边形放在一起看,为每条边添加对方颜色的内外性圆圈,就得到了这样一张图: image.png 如果你有足够的洞察力,相信看到这张图之后,就能想象出算法结果:选取符合内外性要求的边组成新的多边形。 例如交集的结果是红蓝重叠的 2 个三角形,重叠的边有什么特征?一侧的红蓝圆圈都是实心的。 image.png

线段注释

那么接下来最重要的事情就是给每条边(线段)注释内外性了。 我们仍然选用垂直扫描线算法,但要扫描 3 次:

  1. 只扫描红色多边形,计算红色边的注释;
  2. 只扫描蓝色多边形,计算蓝色边的注释;
  3. 同时扫描红蓝多边形,计算对方边的注释。

扫描前先给出几点说明:

  1. 扫描线是从左到右的,因此所有线段的左端点为开始点,右端点为结束点;
  2. 维持一个活动列表,当扫过开始点时将线段加入,扫过结束点时剔除;
  3. 活动列表中的线段,按照相同 x 值的部分比较y大小来确定上下排序;
  4. 同一时刻扫描线可能经过多个顶点(x坐标相同),按从下到上顺序触发;
  5. 开始点和结束点重合时(两条线段交点),优先结束;
  6. 线段会把所在平面分为上下两部分,这样说有点不严谨,可以想象线段延长为直线;
  7. 垂线比较特殊,记下端点为开始点,上端点为结束点,这时左边称为上,右边称为下;

最重要的来了,通过 Bentley-Ottmann 带给我们的思考,能得出以下观察(先考虑前 2 次分别扫描注释红蓝自己)。 首先,扫描过程中,处于活动列表底部的边,它的下面没有其它线段了,所以下面这侧一定是外部。如下图,线段1、2、3、4、5、6,都是底部的。结合之前注释的圆圈图,无论红色还是蓝色,下方都是空心的。 image.png 然后,当我们得知一条边的一侧的内外性后,另外一侧也就知道了。一般是相反,除非是重合线段。 下图的绘制顺序是:abcabd。根据奇偶规则,底部的边 ab 重合 2 次,因此它的上方也是空白的。由此不难发现:在奇偶规则下,重合边的两侧,根据重合次数的奇偶性,决定了是否内外性一致。 image.png 最后,活动列表中相邻的 2 条线段,如果已知下面一条线段的上方内外性,那么上面一条线段的下方必然是相等的。这条太直观了,就像公理。 image.png 完成前 2 次扫描后,可以得到上图的注释信息。注意红蓝重合的3条边,已经提前知道对方的注释了。 接下来该考虑第 3 次扫描注释对方。 普通非重合线(这里重合是指和对方多边形的重合,注意区分)的情况下,依旧是查看是否处于活动列表最底部,是的话一定在对方多边形外,上下注释对方都是空心圆圈。 如下图 1 号红色线段,是底部线段,那么两侧的蓝色都是外部空心。 image.png 普通非底部的话,查看活动列表中下方的边,如果和自己同色,取其上方的对方注释,否则取其上方的自己注释。 如上图 2 号线段,下方是 1 号线段,同色,取 1 号上方的对方蓝色空心圆圈给自己注释;而 6 号线段又是底部,两侧都是蓝色空心;7 号线段的下方是 6 号,不同色,取 6 号上方自己红色实心圆圈给自己注释。 特殊的和对方重合的 3、4、5线段,无需多余计算,因为前 2 次扫描时红色和蓝色早已各自计算好了,此时拼在一起即可。

扩展曲线

曲线的注释和直线并无区别,规则都是一样的,麻烦点在于判断曲线相邻边上下顺序。 先看下图直线。 图左是常见情况,此时可以说 a 在 b 的上方。因为之前的规则是:按照相同 x 值的部分比较y大小来确定上下排序。扫描线在经过 b 左侧开始到 a 右侧结束时,a 和 b 都处于活动列表内且 x 值有相同的一段,此时 a 的 y 值比 b 大,所以说 a 在 b 的上方。 图右是个特殊情况,a 是垂线,但 a 仍然在这部分 y 比 b 大。 image.png 再说曲线。实际曲线可以看做是分割成许多条细小的直线组成,当数量趋向于无穷时等价。这些直线首尾相连不会与其它线段相交,上下内外性也保持一致,因此还是符合已有的排序规则,看相同x部分的y大小。 image.png 还记得开始按 x 分割曲线单调性吗?如果不分割的话,就无法满足这种等价性。

选择结果

注释结束之后,每条边就有 4 个圆圈:上红、下红、上蓝、下蓝。每个圆圈有实心/空心 2 种可能,所以就有 16 种变化。用 1 来代表实心,0 代表空心,16 种变化可以写成一个[0, 15]的二进制数字。

上红上蓝下红下蓝是否保留
1/01/01/01/0

还是以交集举例,保留的边是一侧都是红蓝实心,那么就可以组成这样一个矩阵:

/* 上红 上蓝 下红 下蓝 结果
 *   0   0    1   1 = 3
 *   0   1    1   1 = 7
 *   1   0    1   1 = 11
 *   1   1    0   0 = 12
 *   1   1    0   1 = 13
 *   1   1    1   0 = 14
 */
const INTERSECT = [
  0, 0, 0, 1,
  0, 0, 0, 1,
  0, 0, 0, 1,
  1, 1, 1, 0,
];

很漂亮的一个对称矩阵。但有意排除了 1111=15 这种情况,因为两侧都是红蓝实心的话,这种边是毫无意义的,也不会出现。 其它几种情况的矩阵:

const INTERSECT = [
  0, 0, 0, 1,
  0, 0, 0, 1,
  0, 0, 0, 1,
  1, 1, 1, 0,
], UNION = [
  0, 1, 1, 1,
  1, 0, 0, 0,
  1, 0, 0, 0,
  1, 0, 0, 0,
], SUBTRACT = [
  0, 0, 1, 0,
  0, 0, 1, 0,
  1, 1, 0, 1,
  0, 0, 1, 0,
], SUBTRACT_REV = [
  0, 1, 0, 0,
  1, 0, 1, 1,
  0, 1, 0, 0,
  0, 1, 0, 0,
], XOR = [
  0, 1, 1, 0,
  1, 0, 0, 1,
  1, 0, 0, 1,
  0, 1, 1, 0,
];

选择最终保留的边(线段)后,把它们链接起来就行。从任意一条线段开始,循环遍历所有线段。

  1. 如果是开始线段,将其作为一条新链(chain);
  2. 后面的线段尝试能否和已有 chain 连上,如果可以则加入 chain,不能则视作情况 1;
  3. 每条 chain 加入新边后,要检查能否和其它 chain 连接起来,再检查能否闭合形成一个多边形子区域。

到最后会发现,剩下的边刚刚好能形成若干个闭合区域,这就是我们想要的结果。 因为选择边的任意性,最终多边形边的顺序和组合可能也多种多样。如下图左红蓝多边形的异或操作,可能连成隔开的 2 个子区域,也可能连成重叠的样子(奇偶环绕)。 image.png

强制奇偶

可以看到,Martinez 法有个前提条件,就是强制奇偶规则,这样运算过程中重合的线段部分,才能根据重合次数得出两侧的内外性是一致还是相反。 像下图这个 abcabd 的多边形,奇偶规则是左边的样子,非零则是右边。 image.png 然而无论什么规则,在 Sketch 中,参与布尔运算后结果都是奇偶情况下的,因此很可能 Sketch 也使用了类似的解法。下图的交集都是空: image.png 另外运算的结果 Sketch 则同时支持了奇偶和非零两种规则。 image.png 上图这个例子中右边 2 个连接结果,奇偶是一定正确的,非零则必须要求红色和蓝色轮廓的时钟序是相反的,否则绘制结果会是全集的样子。 如何确保连接出相反的时钟序,我并没有很好的理论证明,只是大概有如下猜测:

  1. 优先从处于对方内部的边开始循环(对方两侧填充性都是实心);
  2. 当新加入的边有多条 chain 可选时,优先选择和自己不同色的;
  3. 如此便会形成包含情况(图右),用鞋带定理求得包含的 2 个多边形的时钟序,进行相反操作。

如有错误,敬请指正。

参考

A new algorithm for computing Boolean operations on polygons》F. Martinez 2008

The method of finding points of intersection of two cubic bezier curves using the sylvester matrix》BI Barbara

Polygon-clipping-pt2》@velipso