原文地址: https://sean.cm/a/polygon-clipping-pt1
Greiner-Hormann裁剪算法无法处理重合线。 所以我研究并写了另一篇适用于所有多边形的文章。
在此处阅读后续内容:多边形裁剪(第 2 部分)
问题
首先, 让我们定义问题,
假设您有两个多边形,每个多边形都以 2D 形式存在
var poly1 = [ // red
[ 181, 270 ],
[ 85, 418 ],
[ 171, 477 ],
[ 491, 365 ],
[ 218, 381 ],
[ 458, 260 ]
];
var poly2 = [ // blue
[ 474, 488 ],
[ 659, 363 ],
[ 255, 283 ],
[ 56, 340 ],
[ 284, 488 ],
[ 371, 342 ]
];
奇偶规则
多边形遵循奇偶规则来确定一个点是否被视为区域“内部”。
基本规则是想象您正在用一条水平线上从左到右扫描。每次越过边缘时,都会在外部和内部之间切换。
那么:给定这两个多边形,我们如何计算不同的布尔运算?
基本理念
首先,让我们定义一些基本的规则.
顺时针vs逆时针(Forward vs. Backward Movement)
如果我们坐在多边形的任何一点上,我们总是可以向前一个点或者后一个点移动
顺时针只是意味着沿箭头方向移动,逆时针则相反
插入点
在处理过程中,我们需要在多边形中插入点。只要我们对如何插入它们很聪明,它就不会改变多边形的形状:
交叉点
识别和分类交叉点是算法中的魔法.
如果您考虑一下,我们将执行的每个操作(交集、联合、差异)都会产生一个包含多边形之间所有交点的多边形
我们不关心同一多边形内的交叉点.
另外:如果你想象我们正沿着一个多边形行走并遇到一个十字路口,我们有3个选择.
1. 保持在同一个多边形上(这是毫无意义的)
2. 切换多边形,开始顺时针移动
3. 切换多边形,并开始逆时针移动
因此,如果我们能够智能地选择在每个交叉路口的方向,我们就可以追踪到正确的结果形状.
交叉点示例
想象一下, 我们想象一下,我们正在追踪两个多边形合并union的结果.
在每个交叉点,我们都希望朝着最终形状继续增长的方向移动.
我们可以这样做:
我们也可以在相反的方向得到相同的结果:
关于所有四个决定,我们可以说哪些是正确的?
在每个交叉点,我们总是朝着远离我们离开的多边形的方向前进.
因此,例如,如果我们沿着 Blue 行驶,然后遇到一个十字路口,我们应该继续沿着 Red 向远离Blue的方向行使.
会有什么不同?这是Red-Blue(从Red中减去Blue区域):
而在另一个方向:
对此我们能说什么?
当从红色切换到蓝色时,我们进入红色。当从蓝色切换到红色时,我们远离红色.
所以我们有两个基本的决定:
1. 当从红色切换到蓝色时,我们是进入还是远离红色?
2.当从蓝色切换到红色时,我们是进入还是远离蓝色?
对于联合(union)来说, 答案总是离开. 但是对于Red-Blue(Red减Blue)
,我们想要进入红色, 远离蓝色。如果你玩玩,你会注意到交叉(intersection )意味着总是进入你要离开的
这给了我们下边的表
Operator | Into Red? | Into Blue? |
---|---|---|
联合(Union) | False | False |
Red减Blue(Red - Blue) | True | False |
Blue减Red(Blue - Red) | False | True |
交叉(Intersection) | True | True |
交叉入口/ 交叉出口
我们不知道如何进入或离开——我们只知道沿着多边形顺时针,逆时针移动。我们如何把两者同意起来.
如果我们在一个交点的两边取两个点,并测试它们是否在另一个多边形内,我们可以保证一个点在外面,一个点在里面:
如果第一个点在外面,那么我们可以认为这条线是通过交点进入多边形的。如果第一个点在内,则该线通过交点离开多边形.
所以,我们真的只需要将每个交叉点标记为交叉入口/ 交叉出口
当我们沿着一条路径行驶时,每个路口都会切换我们是在里面还是外面。它必须.
因此,我们只需要计算第一个点是否在另一个多边形内部。如果是,那么第一个交叉点是一个交叉出口——否则第一个交叉点是一个交叉入口.
而且由于路径上的每个交叉点都在entry和exit之间切换,我们不必继续测试点是在内部还是外部(这很昂贵).
表现
最后,重要的是要认识到交叉点是相对于多边形的交叉入口或交叉出口.
这意味着每个交叉点有四种可能性.
白色代表进入,黑色代表退出。左半球为红色,右半球为蓝色
实际上,对于每个交点,我们将在每个多边形中插入一个点。所以每个交点会有两个点,一个存储在每个多边形中。每个点都会跟踪它是进入还是退出.
现在我们准备好代码啦.
步骤1. 将多边形转换为链表
双链表对于这个算法来说是一个有用的多边形表示,因为我们将同时插入点和遍历。通过使用双链表,我们不必担心插入会破坏遍历.
我们还需要跟踪一个点是否是一个交点,所以我们可以从false
这里初始化它开始:
function UpgradePolygon(p){
// converts a list of points into a double linked list
var root = null;
for (var i = 0; i < p.length; i++){
var node = {
point: p[i],
intersection: false,
next: null,
prev: null
};
if (root === null){
// root just points to itself:
// +-> (root) <-+
// | |
// +------------+
node.next = node;
node.prev = node;
root = node;
}
else{
// change this:
// ...-- (prev) <--------------> (root) --...
// to this:
// ...-- (prev) <--> (node) <--> (root) --...
var prev = root.prev;
prev.next = node;
node.prev = prev;
node.next = root;
root.prev = node;
}
}
return root;
}
步骤2. 计算并插入交叉点
接下来,我们需要遍历每个边组合,看看它们是否相交。如果它们确实彼此相交,那么我们需要在多边形中插入交点.
线交点
首先,我们需要一个辅助函数来计算两条线的交点:
function LinesIntersect(a0, a1, b0, b1){
var adx = a1[0] - a0[0];
var ady = a1[1] - a0[1];
var bdx = b1[0] - b0[0];
var bdy = b1[1] - b0[1];
var axb = adx * bdy - ady * bdx;
var ret = {
cross: axb,
alongA: Infinity,
alongB: Infinity,
point: [Infinity, Infinity]
};
if (axb === 0)
return ret;
var dx = a0[0] - b0[0];
var dy = a0[1] - b0[1];
ret.alongA = (bdx * dy - bdy * dx) / axb;
ret.alongB = (adx * dy - ady * dx) / axb;
ret.point = [
a0[0] + ret.alongA * adx,
a0[1] + ret.alongA * ady
];
return ret;
}
它计算两条线的交点,并返回每条线上的交点“沿”多远。因此,例如,如果alongA
是0.75
,那么交集发生在从a0
到 的75% 处a1.
这些值是重要的,因为他们可能是负数或大于1,因此,如果两条线实际相交,我们需要测试alongA
和alongB
0和1(不含)之间.
下一个非交点
由于我们将在我们的链表中插入交点,所以有一个帮助函数来查找下一个非交点.
function NextNonIntersection(node){
do{
node = node.next;
} while (node.intersection);
return node;
}
每个边组合(Edge Pair)
现在我们可以编写迭代每个边组合的代码:
var root1 = UpgradePolygon(poly1);
var root2 = UpgradePolygon(poly2);
var here1 = root1;
var here2 = root2;
do{
do{
//
// TODO: test intersection between:
// here1 -> NextNonIntersection(here1) and
// here2 -> NextNonIntersection(here2)
//
here2 = NextNonIntersection(here2);
} while (here2 !== root2);
here1 = NextNonIntersection(here1);
} while (here1 !== root1);
交叉点测试
给定两个节点,我们可以测试交集:
var next1 = NextNonIntersection(here1);
var next2 = NextNonIntersection(here2);
var i = LinesIntersect(
here1.point, next1.point,
here2.point, next2.point
);
if (i.alongA > 0 && i.alongA < 1 &&
i.alongB > 0 && i.alongB < 1){
//
// TODO: insert intersection points in both polygons at
// the correct location, referencing each other
//
}
插入交叉点
最后,如果两条边相交,那么我们要在两个非交点之间插入我们的交叉点.
为了将它插入正确的位置,我们必须跟踪alongA
和alongB
值以确保如果两个交点在同一条边上,它们以正确的顺序插入.
我们将要创建两个节点,一个用于每个多边形——但这些节点应该相互指向,以便我们稍后在遇到交叉点时可以在多边形之间“跳跃”
var node1 = {
point: i.point,
intersection: true,
next: null,
prev: null,
dist: i.alongA,
friend: null
};
var node2 = {
point: i.point,
intersection: true,
next: null,
prev: null,
dist: i.alongB,
friend: null
};
// point the nodes at each other
node1.friend = node2;
node2.friend = node1;
var inext, iprev;
// find insertion between here1 and next1, based on dist
inext = here1.next;
while (inext !== next1 && inext.dist < node1.dist)
inext = inext.next;
iprev = inext.prev;
// insert node1 between iprev and inext
inext.prev = node1;
node1.next = inext;
node1.prev = iprev;
iprev.next = node1;
// find insertion between here2 and next2, based on dist
inext = here2.next;
while (inext !== next2 && inext.dist < node2.dist)
inext = inext.next;
iprev = inext.prev;
// insert node2 between iprev and inext
inext.prev = node2;
node2.next = inext;
node2.prev = iprev;
iprev.next = node2;
步骤3. 计算交叉入口/交叉出口
我们知道交叉口在进入和退出之间交替。但是第一个交叉点是什么?是入口还是出口.
简单:如果多边形的第一个点在另一个多边形内,那么第一个交点必须是出口.
但是,计算一个点是否在多边形内部实际上有点复杂.
点在多边形内
function PointInPolygon(point, root){
var odd = false;
var x = point[0];
var y = point[1];
var here = root;
do {
var next = here.next;
var hx = here.point[0];
var hy = here.point[1];
var nx = next.point[0];
var ny = next.point[1];
if (((hy < y && ny >= y) || (hy >= y && ny < y)) &&
(hx <= x || nx <= x) &&
(hx + (y - hy) / (ny - hy) * (nx - hx) < x)){
odd = !odd;
}
here = next;
} while (here !== root);
return odd;
}
PointInPolygon
通过计算水平线相交的边数来工作。水平线从(-Infinity, y)
到(x, y)
。它只关心交叉点的数量是奇数还是偶数。它基于光线投射。
交替进入/退出
现在我们可以轻松计算出一个交叉点是入口还是出口:
function CalculateEntryExit(root, isEntry){
var here = root;
do{
if (here.intersection){
here.isEntry = isEntry;
isEntry = !isEntry;
}
here = here.next;
} while (here !== root);
}
var is1in2 = PointInPolygon(root1.point, root2);
var is2in1 = PointInPolygon(root2.point, root1);
CalculateEntryExit(root1, !is1in2);
CalculateEntryExit(root2, !is2in1);
步骤4. 生成结果
我们已经走了很长一段路!这是我们到目前为止所拥有的.
我们已经计算并插入了交点,并将它们标记为每个多边形的入口或出口.
现在是有趣的部分!
从哪里开始
我们从哪里开始追踪结果?我们不能只选择一个随机点,因为有些点实际上可以从结果中完全删除.
由于所有操作都包括每个交集,我们应该从寻找未处理的交集开始.
我们添加到最终结果中的每个交点,我们都标记为已处理.
然后,我们只是继续跟踪,直到我们不再有任何交集需要处理.
var result = [];
var isect = root1;
var into = [intoBlue, intoRed]; // explained below
while (true){
do{
if (isect.intersection && !isect.processed)
break;
isect = isect.next;
} while (isect !== root1);
if (isect === root1)
break;
//
// TODO: process isect
//
}
转向哪个方向
最后,我们来到了症结所在:
当我们遇到十字路口时,我们怎么知道该往哪个方向转弯?
让我们来推理一下:
Is Entry? | Move Into? | Move Forward? |
---|---|---|
True | True | True |
True | False | False |
False | True | False |
False | False | True |
因此,如果 ,我们应该继续前进isEntry === intoPoly
由于我们所在的多边形来回切换,我们只需通过将intoBlue
和存储intoRed
在into
列表中来使我们的决策动态化,并将 其curpoly用作索引.
var curpoly = 0;
var clipped = [];
var here = isect;
do{
// mark intersection as processed
here.processed = true;
here.friend.processed = true;
var moveForward = here.isEntry === into[curpoly];
do{
clipped.push(here.point);
if (moveForward)
here = here.next;
else
here = here.prev;
} while (!here.intersection);
// we've hit the next intersection so switch polygons
here = here.friend;
curpoly = 1 - curpoly;
} while (!here.processed);
result.push(clipped);
没有交叉点
如果没有交叉点?
我们的结果集将是空的……这可能是正确的,也可能是错误的——这取决于操作.
一个简单的检查就足以修复它:
if (result.length <= 0){
if (is1in2 === intoBlue)
result.push(poly1);
if (is2in1 === intoRed)
result.push(poly2);
}
演示
您可以拖动每个多边形的点,并通过单击按钮切换操作。
附录:限制
抱歉,这个算法有一个严重的局限性:
您不能拥有完美重叠的点或边.
如果你仔细想想,这是有道理的:整个算法都是基于交叉点的思想.
如果点或边直接重叠,那么您就不会得到那种好的跳跃效果.
最初的论文建议稍微“扰乱”点,这样线条就不会完全重叠。我最初认为这是一个小调整,不会有有问题.
但是,我错了.
扰动点会破坏数据——因此可能很重要的源数据的属性(例如,平滑边缘)变得无效.
幸运的是我研究了另一种处理一切的算法,并写了一篇后续文章
此处阅读后续内容:多边形裁剪(第 2 部分)