【计算几何】Clipper库的ClipperBase类代码赏析

本文涉及知识点

数学 几何

说明

本文所附图片,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++**实现。

相关推荐
Luhui_Dev3 天前
数学问题在 Agent 实践中的难点
人工智能·数学·agent
闻缺陷则喜何志丹4 天前
【计算几何】对踵点对 旋转卡壳 直径
数学·计算几何·直径·对踵点对·旋转卡壳
HIT_Weston4 天前
14、【数学】【线性代数】one-hot 与基向量:分类目标为什么能当概率分布
线性代数·数学
Luhui Dev10 天前
大角几何 3D 渲染 API 上线:让立体题图进入自动化生产流程
人工智能·数学·luhuidev
闻缺陷则喜何志丹10 天前
【计算几何】Clipper的偏移
数学·偏移·计算几何·裁剪·clipper
HIT_Weston12 天前
7、【数学】【基础】复数是什么:从解方程到旋转
数学·基础
铸人12 天前
再论勾股定理成立的条件-28
数学·几何学·复数·物理
HIT_Weston13 天前
5、【数学】【基础】归一化的几何意义:球面、超平面与信息去哪了
数学·基础
email4u15 天前
当 AI 开始解决数学难题
人工智能·数学