第18章:多边形偏移算法实现
18.1 概述
本章将深入分析 ClipperOffset 的核心算法实现,包括法向量计算、各种连接类型的处理以及圆弧近似等技术细节。
18.2 GetUnitNormal 方法
计算边的单位法向量:
internal static DoublePoint GetUnitNormal(IntPoint pt1, IntPoint pt2)
{
double dx = (pt2.X - pt1.X);
double dy = (pt2.Y - pt1.Y);
if ((dx == 0) && (dy == 0))
return new DoublePoint();
double f = 1 * 1.0 / Math.Sqrt(dx * dx + dy * dy);
dx *= f;
dy *= f;
return new DoublePoint(dy, -dx);
}
18.2.1 数学原理
边方向向量: (dx, dy)
单位化: (dx/|d|, dy/|d|) 其中 |d| = √(dx² + dy²)
旋转90°得法向量: (dy/|d|, -dx/|d|)
法向量
↑
│
pt1 ─────► pt2
边方向
法向量指向边的左侧,用于确定偏移方向。
18.3 DoOffset 主方法
private void DoOffset(double delta)
{
m_destPolys = new Paths();
m_delta = delta;
// 计算圆弧参数
if (ArcTolerance <= 0.0)
m_miterLim = def_arc_tolerance;
else if (ArcTolerance > Math.Abs(delta) * def_arc_tolerance)
m_miterLim = Math.Abs(delta) * def_arc_tolerance;
else
m_miterLim = ArcTolerance;
// 计算每弧度的步数
double steps = Math.PI / Math.Acos(1 - m_miterLim / Math.Abs(delta));
m_sin = Math.Sin(two_pi / steps);
m_cos = Math.Cos(two_pi / steps);
m_StepsPerRad = steps / two_pi;
if (delta < 0.0) m_sin = -m_sin;
// 计算斜接限制
m_miterLim = MiterLimit;
if (m_miterLim < 2.0) m_miterLim = 2.0;
m_miterLim = 2.0 / (m_miterLim * m_miterLim);
// 处理每个输入多边形
for (int i = 0; i < m_polyNodes.ChildCount; i++)
{
PolyNode node = m_polyNodes.Childs[i];
m_srcPoly = node.m_polygon;
int len = m_srcPoly.Count;
if (len == 0 || (delta <= 0 && (len < 3 ||
node.m_endtype != EndType.etClosedPolygon)))
continue;
m_destPoly = new Path();
if (len == 1)
{
// 单点特殊处理
if (node.m_jointype == JoinType.jtRound)
{
// 创建圆形
double X = 1.0, Y = 0.0;
for (int j = 1; j <= steps; j++)
{
m_destPoly.Add(new IntPoint(
Round(m_srcPoly[0].X + X * delta),
Round(m_srcPoly[0].Y + Y * delta)));
double X2 = X;
X = X * m_cos - m_sin * Y;
Y = X2 * m_sin + Y * m_cos;
}
}
else
{
// 创建方形
double X = -1.0, Y = -1.0;
for (int j = 0; j < 4; ++j)
{
m_destPoly.Add(new IntPoint(
Round(m_srcPoly[0].X + X * delta),
Round(m_srcPoly[0].Y + Y * delta)));
if (X < 0) X = 1;
else if (Y < 0) Y = 1;
else X = -1;
}
}
m_destPolys.Add(m_destPoly);
continue;
}
// 构建法向量
m_normals.Clear();
m_normals.Capacity = len;
for (int j = 0; j < len - 1; j++)
m_normals.Add(GetUnitNormal(m_srcPoly[j], m_srcPoly[j + 1]));
if (node.m_endtype == EndType.etClosedLine ||
node.m_endtype == EndType.etClosedPolygon)
m_normals.Add(GetUnitNormal(m_srcPoly[len - 1], m_srcPoly[0]));
else
m_normals.Add(new DoublePoint(m_normals[len - 2]));
// 根据端点类型处理
if (node.m_endtype == EndType.etClosedPolygon)
{
int k = len - 1;
for (int j = 0; j < len; j++)
OffsetPoint(j, ref k, node.m_jointype);
m_destPolys.Add(m_destPoly);
}
else if (node.m_endtype == EndType.etClosedLine)
{
int k = len - 1;
for (int j = 0; j < len; j++)
OffsetPoint(j, ref k, node.m_jointype);
m_destPolys.Add(m_destPoly);
// 第二遍(反向)
m_destPoly = new Path();
DoublePoint n = m_normals[len - 1];
for (int j = len - 1; j > 0; j--)
m_normals[j] = new DoublePoint(-m_normals[j - 1].X, -m_normals[j - 1].Y);
m_normals[0] = new DoublePoint(-n.X, -n.Y);
k = 0;
for (int j = len - 1; j >= 0; j--)
OffsetPoint(j, ref k, node.m_jointype);
m_destPolys.Add(m_destPoly);
}
else
{
// 开放路径
// ... 处理开放端点 ...
}
}
}
18.4 OffsetPoint 方法
处理单个顶点的偏移:
internal void OffsetPoint(int j, ref int k, JoinType jointype)
{
// 计算角度的正弦值
m_sinA = (m_normals[k].X * m_normals[j].Y - m_normals[j].X * m_normals[k].Y);
if (Math.Abs(m_sinA * m_delta) < 1.0)
{
// 角度太小,检查是否共线或 180° 转向
double cosA = (m_normals[k].X * m_normals[j].X + m_normals[j].Y * m_normals[k].Y);
if (cosA > 0)
{
// 角度 < 90°
m_destPoly.Add(new IntPoint(Round(m_srcPoly[j].X + m_normals[k].X * m_delta),
Round(m_srcPoly[j].Y + m_normals[k].Y * m_delta)));
return;
}
}
else if (m_sinA > 1.0) m_sinA = 1.0;
else if (m_sinA < -1.0) m_sinA = -1.0;
if (m_sinA * m_delta < 0)
{
// 凸角(外侧)
m_destPoly.Add(new IntPoint(Round(m_srcPoly[j].X + m_normals[k].X * m_delta),
Round(m_srcPoly[j].Y + m_normals[k].Y * m_delta)));
m_destPoly.Add(m_srcPoly[j]);
m_destPoly.Add(new IntPoint(Round(m_srcPoly[j].X + m_normals[j].X * m_delta),
Round(m_srcPoly[j].Y + m_normals[j].Y * m_delta)));
}
else
{
// 凹角(内侧)
switch (jointype)
{
case JoinType.jtMiter:
DoMiter(j, k);
break;
case JoinType.jtSquare:
DoSquare(j, k);
break;
case JoinType.jtRound:
DoRound(j, k);
break;
}
}
k = j;
}
18.4.1 m_sinA 计算
m_sinA = (m_normals[k].X * m_normals[j].Y - m_normals[j].X * m_normals[k].Y);
这是两个法向量的叉积,等于 sin(θ),其中 θ 是两条边的夹角。
n[k]
↑
│
─────●─────
│
↓
n[j]
sinA > 0: 凹角(内侧需要连接)
sinA < 0: 凸角(外侧需要连接)
sinA ≈ 0: 接近共线
18.5 DoMiter 方法
斜接连接:
internal void DoMiter(int j, int k)
{
double q = m_delta / (1 + (m_normals[j].X * m_normals[k].X +
m_normals[j].Y * m_normals[k].Y));
// 检查斜接限制
double miterDistance = q / m_delta;
if (miterDistance > m_miterLim)
{
// 超出限制,改用方形连接
DoSquare(j, k);
return;
}
m_destPoly.Add(new IntPoint(
Round(m_srcPoly[j].X + (m_normals[k].X + m_normals[j].X) * q),
Round(m_srcPoly[j].Y + (m_normals[k].Y + m_normals[j].Y) * q)));
}
18.5.1 斜接点计算
两条边的偏移线相交:
偏移线1
╲
╲ ← 交点 (斜接点)
╲
原点 ●────────
╱
╱
╱
偏移线2
交点 = 原点 + (n1 + n2) * q
其中 q = delta / (1 + n1·n2) = delta / (1 + cosθ)
18.6 DoSquare 方法
方形连接:
internal void DoSquare(int j, int k)
{
double dx = Math.Tan(Math.Atan2(m_sinA,
m_normals[k].X * m_normals[j].X + m_normals[k].Y * m_normals[j].Y) / 4);
m_destPoly.Add(new IntPoint(
Round(m_srcPoly[j].X + m_delta * (m_normals[k].X - m_normals[k].Y * dx)),
Round(m_srcPoly[j].Y + m_delta * (m_normals[k].Y + m_normals[k].X * dx))));
m_destPoly.Add(new IntPoint(
Round(m_srcPoly[j].X + m_delta * (m_normals[j].X + m_normals[j].Y * dx)),
Round(m_srcPoly[j].Y + m_delta * (m_normals[j].Y - m_normals[j].X * dx))));
}
18.6.1 方形连接图解
pt1
╱
╱ ← 角平分线方向
╱
● ───────────────
╲ pt2
╲
╲
在角平分线方向创建两个点,形成 45° 斜切
18.7 DoRound 方法
圆形连接:
internal void DoRound(int j, int k)
{
double a = Math.Atan2(m_sinA,
m_normals[k].X * m_normals[j].X + m_normals[k].Y * m_normals[j].Y);
int steps = Math.Max((int)Round(m_StepsPerRad * Math.Abs(a)), 1);
double X = m_normals[k].X, Y = m_normals[k].Y, X2;
for (int i = 0; i < steps; ++i)
{
m_destPoly.Add(new IntPoint(
Round(m_srcPoly[j].X + X * m_delta),
Round(m_srcPoly[j].Y + Y * m_delta)));
X2 = X;
X = X * m_cos - m_sin * Y;
Y = X2 * m_sin + Y * m_cos;
}
m_destPoly.Add(new IntPoint(
Round(m_srcPoly[j].X + m_normals[j].X * m_delta),
Round(m_srcPoly[j].Y + m_normals[j].Y * m_delta)));
}
18.7.1 圆弧近似
使用旋转矩阵生成圆弧上的点:
[X'] [cos -sin] [X]
[Y'] = [sin cos] [Y]
每一步旋转 2π/steps 弧度
●────●────●
╱ ╲
● ●
│ │
● ●
╲ ╱
●────●────●
18.8 开放路径端点处理
// 开放路径的端点处理
int k = 0;
for (int j = 1; j < len - 1; ++j)
OffsetPoint(j, ref k, node.m_jointype);
IntPoint pt1;
int m;
// 处理末端
switch (node.m_endtype)
{
case EndType.etOpenButt:
{
m = len - 1;
pt1 = new IntPoint((cInt)Round(m_srcPoly[m].X + m_normals[m].X * m_delta),
(cInt)Round(m_srcPoly[m].Y + m_normals[m].Y * m_delta));
m_destPoly.Add(pt1);
pt1 = new IntPoint((cInt)Round(m_srcPoly[m].X - m_normals[m].X * m_delta),
(cInt)Round(m_srcPoly[m].Y - m_normals[m].Y * m_delta));
m_destPoly.Add(pt1);
}
break;
case EndType.etOpenSquare:
DoSquare(len - 1, len - 1);
break;
case EndType.etOpenRound:
DoRound(len - 1, len - 1);
break;
}
18.8.1 平头端点
// 简单地在末端添加两个点
pt1 = srcPoly[m] + normal[m] * delta; // 一侧
pt2 = srcPoly[m] - normal[m] * delta; // 另一侧
18.8.2 方头端点
// 复用 DoSquare,传入相同的索引
DoSquare(len - 1, len - 1);
18.8.3 圆头端点
// 复用 DoRound,创建半圆
DoRound(len - 1, len - 1);
18.9 圆弧步数计算
double steps = Math.PI / Math.Acos(1 - m_miterLim / Math.Abs(delta));
18.9.1 数学推导
为了使圆弧近似误差不超过 ArcTolerance:
误差 = r * (1 - cos(θ/2))
其中 r = delta, θ = 步进角度
要求: r * (1 - cos(θ/2)) ≤ tolerance
解得: θ ≤ 2 * arccos(1 - tolerance/r)
步数 = π / (θ/2) = π / arccos(1 - tolerance/r)
18.10 旋转矩阵优化
// 预计算旋转参数
m_sin = Math.Sin(two_pi / steps);
m_cos = Math.Cos(two_pi / steps);
// 在圆弧生成中使用
for (int i = 0; i < steps; ++i)
{
// 添加当前点
m_destPoly.Add(new IntPoint(
Round(m_srcPoly[j].X + X * m_delta),
Round(m_srcPoly[j].Y + Y * m_delta)));
// 旋转到下一个位置
X2 = X;
X = X * m_cos - m_sin * Y;
Y = X2 * m_sin + Y * m_cos;
}
这比每次调用三角函数更高效。
18.11 性能优化
18.11.1 法向量预计算
m_normals.Clear();
m_normals.Capacity = len;
for (int j = 0; j < len - 1; j++)
m_normals.Add(GetUnitNormal(m_srcPoly[j], m_srcPoly[j + 1]));
18.11.2 避免重复计算
// 在 OffsetPoint 中使用预计算的法向量
// 而不是每次重新计算
18.11.3 使用 Clipper 清理
// 偏移可能产生自相交,使用 Clipper 清理
Clipper clpr = new Clipper();
clpr.AddPaths(m_destPolys, PolyType.ptSubject, true);
clpr.Execute(ClipType.ctUnion, solution, ...);
18.12 本章小结
本章详细分析了 ClipperOffset 的核心算法:
- 法向量计算:
- GetUnitNormal 计算边的单位法向量
- 法向量用于确定偏移方向
- 连接类型实现:
- DoMiter:斜接连接(带限制)
- DoSquare:方形连接
- DoRound:圆弧连接
- 端点处理:
- 平头、方头、圆头
- 闭合路径的循环处理
- 圆弧近似:
- 基于 ArcTolerance 计算步数
- 使用旋转矩阵优化性能
- 后处理:
- 使用 Clipper 清理自相交
| 上一章:ClipperOffset详解 | 返回目录 | 下一章:辅助函数与工具 |