大家好,我是前端西瓜哥。
今天我们来学习简单的平面几何算法,求直线线段的轮廓线。
需求是给两个点表达的直线线段,以及线宽,求它的轮廓线多边形。
对于直线线段,末端有三种样式:
- Butt:平端,不增加额外形状;
- Square:方形端,额外补充一个矩形,宽为线宽,高为线宽的一半;
- Round:圆形端,额外补充一个半圆,半径为线宽的一半。
本文实现算法的在线交互 Demo:
https://codepen.io/F-star/pen/PorzxLw
Butt(平端)
我们先看看平端的实现。
求线段的法向量,乘以线宽的一半,得到位移向量。然后让线段的两个点分别做两个方向的位移,得到多边形的 4 个顶点,将它们按照一定顺序连接起来得到多边形,这个多边形就是我们要求的轮廓多边形。
求法向量,其实就是计算向量 p1-p2 旋转 90 度。旋转的方向没关系,计算出的法向量有两个方向,都可以,只要点的顺序。
将一个向量旋转 90 度,可以用三角函数推导,或者直接用旋转矩阵,具体推导就不做了。
有个特殊的规律:对于向量旋转 90 度的向量,我们只需要把 x 和 y 交换位置,然后将其中一个值取反。
代码语言:javascript复制x2 = y;
y2 = -x;
或者你可以点积的角度看,互相垂直的两条向量的点积总是零。
代码语言:javascript复制// 法向量,模长为线段长度
const tan = {
x: p2.x - p1.x,
y: p2.y - p1.y,
};
// 线性插值比率 t
const t = width / 2 / Math.sqrt(tan.x * tan.x tan.y * tan.y);
// 求法向量,模长为 width /2
const normal = {
x: tan.y * t,
y: -tan.x * t,
};
最后依次算出 4 个顶点。
代码语言:javascript复制const vertexes = [
{ x: p1.x normal.x, y: p1.y normal.y },
{ x: p2.x normal.x, y: p2.y normal.y },
{ x: p2.x - normal.x, y: p2.y - normal.y },
{ x: p1.x - normal.x, y: p1.y - normal.y },
];
完整代码:
代码语言:javascript复制const outlineLineWithButtCap = (p1: Point, p2: Point, width: number) => {
const tan = {
x: p2.x - p1.x,
y: p2.y - p1.y,
};
const t = width / 2 / Math.sqrt(tan.x * tan.x tan.y * tan.y);
// 求法向量,模长为 width /2
const normal = {
x: tan.y * t,
y: -tan.x * t,
};
const vertexes = [
{ x: p1.x normal.x, y: p1.y normal.y },
{ x: p2.x normal.x, y: p2.y normal.y },
{ x: p2.x - normal.x, y: p2.y - normal.y },
{ x: p1.x - normal.x, y: p1.y - normal.y },
];
return vertexes;
};
看看渲染效果,很完美。
Square(方形端)
Square 方形端,需要额外补充一个矩形,宽为线宽,高为线宽的一半。
观察就能发现,Square 等价于让直线两端往两测延长 “线宽一半” 的长度,然后应用 butt 的算法。
代码语言:javascript复制const outlineLineWithSquareCap = (
p1: Point,
p2: Point,
width: number,
) => {
const tan = {
x: p2.x - p1.x,
y: p2.y - p1.y,
};
const t = width / 2 / Math.sqrt(tan.x * tan.x tan.y * tan.y);
// 需要位移的距离
const dx = tan.x * t;
const dy = tan.y * t;
p2 = {
x: p2.x dx,
y: p2.y dy,
};
p1 = {
x: p1.x - dx,
y: p1.y - dy,
};
// 使用 butt 的算法
return outlineLineWithButtCap(p1, p2, width);
};
渲染结果:
Round(圆形端)
最后是圆形端,需要给末端额外加半圆,圆半径为线宽的一半。
我们要求的是多边形,其实也就是在 butt 求出的 4 个顶点的基础上,再插入两个圆弧。
其实圆弧很容易确定,我们已经知道每个圆弧的两个端点,还有半径。
但麻烦的点在于我们需要用某种方式表达这个圆弧,圆弧的表达有好几种,且有点复杂,不同渲染引擎支持的圆弧表达是不一样的,这代表我们可能要在多种表达中进行转换。()
常见的圆弧表达有三种:
- 圆心、半径 、起始角、结束角、方向;
- 起点、终点、半径、优弧、方向;
- 起点、终点、凸度;
这三种表达我在之前的文章详细讲解过,感兴趣可以 前往阅读。
我们需要在里面选择一种。
这段圆弧是作为多段线的一部分,用带有起点、终点的表达会更好些,再考虑到能够无缝使用 SVG 的 Path 元素表达,最终我们选择用第二种方案:起点、终点、半径、优弧(largeArc)、方向(sweep)。
起点、终点、半径我们都已经有了,我们需要确定优弧(是否使用大的弧)和方向。
因为是半圆,所以优弧是 true 还是 false 并无所谓,它们对应的两个圆会重叠为一个圆,这里我们取 true。
虽然在计算 butt 时,法向量的方向无关紧要,但对于 round 末端效果还是有影响的。y 取反的法向量,对应的多边形的方向是顺时针,圆弧自然也需要是顺时针,所以方向(sweep)为 true。
完整代码实现:
代码语言:javascript复制const outlineLineWithRoundCap = (
p1: Point,
p2: Point,
width: number,
) => {
const buttOutline = outlineLineWithButtCap(p1, p2, width);
return [
buttOutline[0],
buttOutline[1],
{
...buttOutline[2],
radius: width / 2,
largeArc: true,
sweep: true,
},
buttOutline[3],
{
...buttOutline[0],
radius: width / 2,
largeArc: true,
sweep: true,
},
];
};
SVG 的 Path 元素的 d 属性值为:
代码语言:javascript复制// 起点
let d = `M ${outline[0].x} ${outline[0].y} `;
for (let i = 1; i < outline.length; i ) {
const seg = outline[i];
if ('radius' in seg) {
// 圆弧
d = `A ${seg.radius} ${seg.radius} 0 ${seg.largeArc ? 1 : 0} ${
seg.sweep ? 1 : 0
} ${seg.x} ${seg.y} `;
} else {
// 直线
d = `L ${seg.x} ${seg.y} `;
}
}
// 闭合
d = 'Z';
渲染结果:
三种效果对比
我们将这三种算法得到的多边形同时渲染,对比一下效果。
结尾
这次的算法还是挺简单的,总结一下,就是 求法向量,把直线的两个端点往两侧位移一下,得到一个矩形多边形,然后根据末端样式,给两边补上矩形或半圆。
末端样式是可以做自定义扩展,补上任意你想要的图形的。
比如我给某一端补上一个三角形,就变成了什么?变成了一个箭头线。
我是前端西瓜哥,关注我,学习更多平面几何知识。