本文涉及知识点
说明
本文所附图片,x的正方向向右,y的正方向向上,和Cad的坐标系同,右手坐标系。
DoTopOfScanbeam
通过e遍历整个活动边表。更新curr_x,即活动边与扫描线交点的横坐标。
| e.top.y==扫描线 | 局部最大值 | DoMaxima |
| e.top.y==扫描线 | 不是局部最大值 | 如果是活动边,将点增加到所属区域。更新边。水平边入队 |
简单成员
cpp
struct LocalMinima {
Vertex* vertex;
PathType polytype;
const bool is_open;
LocalMinima(Vertex* v, PathType pt, bool open) :
vertex(v), polytype(pt), is_open(open){}
};
PathType 是Subject,还是clips。
is_open:是多义线(折线)还是闭合多边形。AddOpenSubject 加的是折线,其它两个函数是多边形。
cpp
void AddSubject(const Paths64& subjects)
{
AddPaths(subjects, PathType::Subject, false);
}
void AddOpenSubject(const Paths64& open_subjects)
{
AddPaths(open_subjects, PathType::Subject, true);
}
void AddClip(const Paths64& clips)
{
AddPaths(clips, PathType::Clip, false);
}
C++代码,可以通过不同的函数,决定布尔运算的第一个参数是否是开闭对象。
IsHotEdge:是否是活动边,有所属区域。
Vertex
struct Vertex {
Point64 pt;
Vertex* next = nullptr;
Vertex* prev = nullptr;
VertexFlags flags = VertexFlags::Empty;
};
通过next指针可以逆时针访问所有端点,prev指针顺时针访问所有端点。
VertexFlags
enum class VertexFlags : uint32_t {
Empty = 0, OpenStart = 1, OpenEnd = 2, LocalMax = 4, LocalMin = 8
};
VertexFlags::LocalMax ,字面的上意思是局部最大点,即y值比前驱后继顶点的y值都大。调试的结果是局部最小值。

cpp
if (curr_v->pt.y > prev_v->pt.y && going_up)
{
prev_v->flags = (prev_v->flags | VertexFlags::LocalMax);
going_up = false;
}
else if (curr_v->pt.y < prev_v->pt.y && !going_up)
{
going_up = true;
AddLocMin(locMinList, *prev_v, polytype, is_open);
}
going_up ,是否是上升状态。上升状态变下降状态,或下降状态变上升状态时,前驱节点一定时极值。
y相等时,状态不变。
同理,VertexFlags::LocalMin 实际上是局部最大值。
去掉重复点
cpp
template<typename T>
inline void StripDuplicates( Path<T>& path, bool is_closed_path)
{
path.erase(std::unique(path.begin(), path.end()), path.end());
if (is_closed_path)
while (path.size() > 1 && path.back() == path.front()) path.pop_back();
}
无需也不能排序。相邻的重复点才能删除。
符号函数
cpp
inline int TriSign(int64_t x) // returns 0, 1 or -1
{
return (x > 0) - (x < 0);
}
x>0: 1-0=1
x=0: 0-0=0
x<0: 0-1=-1
线段和扫描线的交点
cpp
inline int64_t TopX(const Active& ae, const int64_t currentY)
{
if ((currentY == ae.top.y) || (ae.top.x == ae.bot.x)) return ae.top.x;
else if (currentY == ae.bot.y) return ae.bot.x;
else return ae.bot.x + static_cast<int64_t>(nearbyint(ae.dx * (currentY - ae.bot.y)));
// nb: std::nearbyint (or std::round) substantially *improves* performance here
// as it greatly improves the likelihood of edge adjacency in ProcessIntersectList().
}
求线段ae和扫描线currentY的交点横坐标。
双向循环链表节点结构
cpp
struct OutPt {
Point64 pt; // 顶点坐标
OutPt* next = nullptr; // 逆时针下一个节点
OutPt* prev = nullptr; // 逆时针上一个节点
OutRec* outrec; // 所属的输出区域
HorzSegment* horz = nullptr; // 关联的水平段(可选)
OutPt(const Point64& pt_, OutRec* outrec_): pt(pt_), outrec(outrec_) {
next = this;
prev = this;
}
};
扫描线算法中的一条活动边
cpp
struct Active {
Point64 bot;
Point64 top;
int64_t curr_x = 0; //current (updated at every new scanline)
double dx = 0.0;
int wind_dx = 1; //1 or -1 depending on winding direction
int wind_cnt = 0;
int wind_cnt2 = 0; //winding count of the opposite polytype
OutRec* outrec = nullptr;
//AEL: 'active edge list' (Vatti's AET - active edge table)
// a linked list of all edges (from left to right) that are present
// (or 'active') within the current scanbeam (a horizontal 'beam' that
// sweeps from bottom to top over the paths in the clipping operation).
Active* prev_in_ael = nullptr;
Active* next_in_ael = nullptr;
//SEL: 'sorted edge list' (Vatti's ST - sorted table)
// linked list used when sorting edges into their new positions at the
// top of scanbeams, but also (re)used to process horizontals.
Active* prev_in_sel = nullptr;
Active* next_in_sel = nullptr;
Active* jump = nullptr;
Vertex* vertex_top = nullptr;
LocalMinima* local_min = nullptr; // the bottom of an edge 'bound' (also Vatti)
bool is_left_bound = false;
JoinWith join_with = JoinWith::NoJoin;
};
几何信息
top,bot:当前活动边的上下端点。当扫描线到达下端时激活,到达上端时移除。
curr_x :扫描线与当前活动边交点的横坐标。会随着扫描线的变化而变化。
dx:斜率的倒数。
绕数信息
wind_dx:边的方向对绕数的贡献。y大的端点是bottom,y小的是top,从bom到bot,dx小的那条边(逆时针) +1,dx大的那条边(顺时针) -1。左边界-1,右边界1。
wind_cnt:当前扫描线位置上,这条边所在类型的累积绕数。
wind_cnt2:相反多边形类型(比如 clip 和 subject)的绕数。
subject是主体对象,clip是裁剪对象,如差集就是subject 减 clip对象。
归属信息
outrec 这条边当前属于哪个输出环。
两套链表:AEL 和 SEL
AEL:Active Edge List
cpp
Active* prev_in_ael = nullptr;
Active* next_in_ael = nullptr;
按 curr_x 从左到右排序;表示当前扫描线上所有活动边;
SEL:Sorted Edge List
cpp
Active* prev_in_sel = nullptr;
Active* next_in_sel = nullptr;
用于在扫描线顶部重新排序边;
也用于处理水平边;
其它辅助字段
Active* jump = nullptr; 跳跃指针,跳跃指针,用于快速跳过一段已知顺序的边。下一个非相交活动边。
Vertex* vertex_top = nullptr;边的上端对应的顶点对象,保存该顶点的更多信息。非水平线段和top相等,水平线还要继续查看。
LocalMinima* local_min = nullptr; 这条边所属的局部极小点。局部极小点是扫描线算法的起点,从它开始激活一整串边。
bool is_left_bound = false; 标记这条边是否为某个"边束"的左边界。Vatti 中局部极小点会引出一对左右边,左边界决定多边形内部方向。极端情况下(cur_x相同,方向相同或相反),活动边的顺序。
JoinWith join_with = JoinWith::NoJoin;表示这条边在合并阶段是否需要与其它边连接,比如:用于处理复杂交点、水平边、重叠边时的连接策略。相连的边和sel有关。
SetWindCountForClosedPathEdge 核心代码
没有路径类型(subject或clip)相同的左邻e2,由其它分支处理,本代码无需考虑。
奇偶填充方式走其它分之。
cpp
//NonZero, positive, or negative filling here ...
//if e's WindCnt is in the SAME direction as its WindDx, then polygon
//filling will be on the right of 'e'.
//NB neither e2.WindCnt nor e2.WindDx should ever be 0.
if (e2->wind_cnt * e2->wind_dx < 0)
{
//opposite directions so 'e' is outside 'e2' ...
if (abs(e2->wind_cnt) > 1)
{
//outside prev poly but still inside another.
if (e2->wind_dx * e.wind_dx < 0)
//reversing direction so use the same WC
e.wind_cnt = e2->wind_cnt;
else
//otherwise keep 'reducing' the WC by 1 (ie towards 0) ...
e.wind_cnt = e2->wind_cnt + e.wind_dx;
}
else
//now outside all polys of same polytype so set own WC ...
e.wind_cnt = (IsOpen(e) ? 1 : e.wind_dx);
}
else
{
//'e' must be inside 'e2'
if (e2->wind_dx * e.wind_dx < 0)
//reversing direction so use the same WC
e.wind_cnt = e2->wind_cnt;
else
//otherwise keep 'increasing' the WC by 1 (ie away from 0) ...
e.wind_cnt = e2->wind_cnt + e.wind_dx;
}
e.wind_cnt2 = e2->wind_cnt2;
e2 = e2->next_in_ael; // ie get ready to calc WindCnt2
wind_dx 左侧-1,右侧+1,不会是0。
无论那种填充方式,缠绕数为0,是不需要保留,故活动边表的边wind_cnt不为0。
e2->wind_cnt * e2->wind_dx > 0 有两种情况,e都在e2内部。
一,e2是左侧,e2所在的path是逆时针。

二,e2是右侧,e2所在path是顺时针。

e2->wind_dx * e.wind_dx < 0
如果左右侧不同,则范围完全相同,故:
cpp
e.wind_cnt = e2->wind_cnt;

左右侧相同
cpp
e.wind_cnt = e2->wind_cnt + e.wind_dx;
e右边的点比e2右边的点,多穿过e。

e在e2所在path外部
abs(e2->wind_cnt) > 1 ,说明可能在其它同类型(被裁剪对象、裁剪对象)活动边之中。
异侧时,缠绕数相同。

同侧:相差边e:

等于1,不收其它活动边影响。故
cpp
e.wind_cnt = (IsOpen(e) ? 1 : e.wind_dx);
处理交点
BuildIntersectList
csharp
if (right->curr_x < left->curr_x)
新增活动边,删除活动边已处理,curr_x 的大小发生变化,说明有交点。
ProcessIntersectList 处理交点
cpp
if (!EdgesAdjacentInAEL(*node_iter))
{
node_iter2 = node_iter + 1;
while (!EdgesAdjacentInAEL(*node_iter2)) ++node_iter2;
std::swap(*node_iter, *node_iter2);
}
找到第一个相交边在活动边表相邻的交点,并处理。最坏复杂度O(nn),平均复杂度O(n)。
如果有交点则一定有边相邻的交点

两条扫描线之间不会有增加线段,故p3所在线段至少一条和p1p2的活动边相交,不失一般性,令和p1的活动边相交。则问题变成p1p3之间是否有其它活动边。p1p3比p1p2短,不断迭代,直到两点之间没有空隙。
AdjustCurrXAndCopyToSEL
所有的currx都会更新。
OutRec
owner OutRec* 所属的父 OutRec,主要用于孔洞与外环的嵌套关系
front_edge Active* 当前 OutRec 在 AEL 中的前边界边(左侧)
back_edge Active* 当前 OutRec 在 AEL 中的后边界边(右侧)
SwapOutrecs
cpp
void SwapOutrecs(Active& e1, Active& e2)
{
OutRec* or1 = e1.outrec;
OutRec* or2 = e2.outrec;
if (or1 == or2)
{
Active* e = or1->front_edge;
or1->front_edge = or1->back_edge;
or1->back_edge = e;
return;
}
if (or1)
{
if (&e1 == or1->front_edge)
or1->front_edge = &e2;
else
or1->back_edge = &e2;
}
if (or2)
{
if (&e2 == or2->front_edge)
or2->front_edge = &e1;
else
or2->back_edge = &e1;
}
e1.outrec = or2;
e2.outrec = or1;
}
SwapOutrecs 的作用是:交换两条活动边 e1 和 e2 所归属的输出环 OutRec。
当扫描线处理到 e1、e2 的交点时,两条边会穿过对方,原来属于 e1 的区域现在归 e2,反之亦然,所以它们的 outrec 要互换。同时,OutRec 里记录的前后边界边指针也要跟着换。

ti表示第i线段向上,bi表示第i条线段向下。
在快要到达交点c之前,有三个区域。
一,t4b5。二,t5t1b6。三,t6b2.
刚刚经过交点。
一,t4b6。三,t6b5t3.三,t5b2。
AddLocalMaxPoly
一个区域处理结束。 e2拼接到e1,指针会置空。
cpp
e2.outrec->front_edge = nullptr;
e2.outrec->back_edge = nullptr;
e2.outrec->pts = nullptr;
SwapSides
翻转一个输出环 OutRec 的方向(左右边界互换),并调整其输出点链表的起始指针。
cpp
inline void SwapSides(OutRec& outrec)
{
Active* e2 = outrec.front_edge;
outrec.front_edge = outrec.back_edge;
outrec.back_edge = e2;
outrec.pts = outrec.pts->next;
}
InsertLocalMinimaIntoAEL(核心函数,建议好好体会)
对每个 bot_y 处的 LocalMinima:
├─ 创建 left_bound(下降边,wind_dx = -1)
├─ 创建 right_bound(上升边,wind_dx = +1)
├─ 必要时交换左右,使 left_bound 真正在左
├─ 只有一条边时,把它当 left_bound
├─ left_bound 插入 AEL
├─ 设置绕数,判断 contributing
├─ 有 right_bound:
│ ├─ 插入 right_bound 到 AEL
│ ├─ 若 contributing,创建新 OutRec(AddLocalMinPoly)
│ ├─ 处理 right_bound 与后续边的交点
│ └─ 水平边推入水平队列 / 普通边插入扫描线
└─ 无 right_bound 但有 contributing:
└─ 开始开放路径
└─ 处理 left_bound:水平边入队 / 普通边插入扫描线
CleanCollinear
cpp
if (IsCollinear(op2->prev->pt, op2->pt, op2->next->pt) &&
(op2->pt == op2->prev->pt ||
op2->pt == op2->next->pt || !preserve_collinear_ ||
DotProduct(op2->prev->pt, op2->pt, op2->next->pt) < 0))
条件一必须满足,条件二到五满足任意一条。
条件一:当前点、前一点、后一点共线。
条件二:当前点和前驱点重合。
条件三:当前点和后继点重合。
条件四:preserve_collinear_ 为假。
条件五:三个形成180度角,如下图。

DoSplitOp
如果不满足拆分条件,说明这个自交只是一个小凸起或退化情况,直接删除 splitOp 和 splitOp->next,相当于去掉这个小环。
FixSelfIntersects
这个不是通用的处理自交的函数,是对扫描线的结果进行处理。由于Clipper是整数坐标,故删除共线点不会产生误差,但计算交点时会四舍五入,产生误差。
数学模型:

计算机中:

故只需要判断AC是否相交,无法判断所有边。
TrimHorz
从一条水平活动边出发,沿路径向下检查所有 y 相同的顶点,把它们合并到这条水平边里。
类名、函数名翻译
| 类名函数名 | 翻译 |
|---|---|
| ReusableDataContainer64 | 可复用数据容器 |
| LocalMinima | 局部最小值 |
| Scanline | 扫描线 |
| Duplicate | 复制、创建副本 |
| Uncouple | 使分离 |
| preserve | 保留 |
| collinear | 共线的 |
| scanbeam | 扫描带,扫描束 |
| hot_edge | 条活动边如果它的 outrec 非空,并且它被 OutRec 记录为 front_edge 或 back_edge,它就是热边。 |
CheckJoinLeft 与 CheckJoinRight 解析
当两条热边在扫描线处理过程中变得非常接近、几乎共线或重叠时,判断它们是否应该合并(join),如果应该,就执行合并操作。

扩展阅读
| 计算几何为骨,排样优化为魂 |
|---|
| 作品:亲士CAD工具箱 |
| 经典文章推荐:二维排样 |
| 万物皆数学 |
| 查阅鄙人的博文,请点击博文下载学院导航 |
| 活到老,学到老。明朝中后期,大约50%的进士能当上堂官(副部及更高);能当上堂官的举人只有十余人。 |
| 子墨子言之:事无终始,无务多业。也就是我们常说的专业的人做专业的事。 |
测试环境
操作系统:win7 开发环境: VS2019 C++17
或者 操作系统:win10 开发环境: VS2022 C++17
如无特殊说明,本算法用**C++**实现。
