关于画点连线的思考

前言

点和线是平面世界的基础,而画点连线更是基本操作。但是关于画点连线的方法,以及所能带来的思考,也是蛮有意思的。

在这片文章中,我会简单分析画点连线的基本方法。

一起来画画

基本的画点连线

在这个章节,我们会创建 canvas 并且在上面获取鼠标的点的位置,并且将点放入一个数组存储,用直线连接起来。为了方便操作,我们使用 PIXI 这个库来简化操作。

具体的细节步骤如下:

  • 首先使用 PIXI 初始化应用,创建一个舞台
  • 然后获取 app.view,在 canvas 上面绑定点击事件
  • 获取点击事件的具体的坐标,在对应位置绘制圆,将其放入数组存储
  • 循环遍历数组,将两点依次连线,绘制出图形。

便可以得出以下的效果

也可以在下面的环境中测试。

See the Pen xxgVzpW by kitety (@kitety) on CodePen.

上述的 demo 基本的实现了直线连接,但是表述很生硬,就是一段一段的线段,没有任何的美感。假如我们想要的是折线图,这就已经实现目标了,但是如果我们想要的是有点美感的曲线呢?这应该怎么做呢?这个时候就该我们的贝赛尔曲线登场了。

贝赛尔曲线

维基百科中,他是这么解释的:在数学的数值分析领域中,贝塞尔曲线(英语:Bézier curve)是计算机图形学中相当重要的参数曲线。

感觉还是不理解,详细的过程可以参考这篇文章

本节不是对贝塞尔曲线做专业分析,如果有需要请参考其他网站,本文引用已经列举出来了。

二次贝赛尔曲线

大概的二次贝赛尔曲线就如下图所示:

二次贝塞尔曲线由三个点 P0P1P2 来确定,这些点也叫做控制点。我们需要三个点就可以绘制出一条二次贝赛尔曲线,在 canvas 中的对应的方法就是quadraticCurveTo这个方法。

高次贝塞尔曲线

随着控制点的个数的增加,可以有三次,四次,N 次贝赛尔曲线,再多就不过多探讨。需要提示一下,绘制三次贝塞尔曲线的方法bezierCurveTo

使用贝塞尔曲线绘制平滑曲线

要想绘制贝赛尔曲线,我们最简单的思路就是需要找准起点,控制点,终点。

最简单的就是假如我们有 A、B、C 三个点,我们需要绘制一条看似还算完美的曲线,我们应该怎么去思考呢?

我们会计算出 B 和 C 的中点 B1,以 A 为起点,B 为控制点,B1 为终点,最后将 B1、C 用直线链接。

当然这只是三个点的情况,我们如此循环往复就可以绘制足够多的点了。

代码简析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
function drawLine() {
// 绘制之前全部清除
for (let ii = 0; ii < ss.length; ii++) {
const bezier = ss[ii];
app.stage.removeChild(bezier);
}
ss = [];

let start = arr[0];
if (arr.length === 2) {
// 两个点直接连线
drawBaseLine(start.pos, arr[1].pos);
}
if (arr.length >= 3) {
for (let i = 1; i < arr.length; i++) {
let control = arr[i];
let end;
if (!arr[i + 1]) {
// 后面没有点了,将上一个贝塞尔曲线的终点和当前点连接
drawBaseLine(start.pos, arr[i].pos);
return;
} else {
// 将贝赛尔曲线终点设置为两点之间的中点
end = getMiddle(arr[i], arr[i + 1]);
}
// 绘制贝赛尔曲线
var bezier = new PIXI.Graphics();

bezier.lineStyle(4, 0x1a7af8, 1);
bezier.moveTo(start.pos.x, start.pos.y);
bezier.quadraticCurveTo(
control.pos.x,
control.pos.y,
end.pos.x,
end.pos.y
);
bezier.endFill();
start = end;
app.stage.addChild(bezier);
ss.push(bezier);
}
}
}

这样我们就可以尽量绘制出比较平滑的曲线了

也可以在下面的环境中测试。

See the Pen eYgZjOM by kitety (@kitety) on CodePen.

上述的代码基本是实现了比较平滑的曲线,但是我们的还是会发现一些问题,就是在绘制的时候会出现画出来的线并没有经过所有点这种情况。而且参考的文章下面也有了类似的思考,一样抛出了这个问题。


看来革命尚未成功,同志仍需努力啊。

相似三角形与贝赛尔曲线

其实在处理问题的时候,我们需要将问题简单化。比如在画点连线的过程中,我们很轻易的知道,两点之间肯定是连接直线就是最佳的。在需要曲线的时候,往往是两个点以上,因此我们就先画好三个点的曲线。

切线


如图,我们为三个点分了三种情况,其实前面两种很明显都是不合理的,都出现了没有必要的弯曲,只有最后一种稍显合理。

我们围绕三种情况 B 点的位置做出切线,发现只有看 k1 和 k2 的斜率几乎一直的时候才是比较合理的曲线,也就是三点中,曲线经过中间一点的切线和另外两点所成的直线平行的时候,这样的曲线才是比较合理的。

贝塞尔切线

而贝赛尔曲线的绘制图如下

我们截取中间的某个时刻会发现,很明显,贝塞尔曲线的起点和终点(其实也是控制点),他们的切线其实就是和轨迹相切的,这也是贝赛尔曲线的绘制方法,只不过起点和终点对应的就是 t0 时刻和 1 时刻。

结论:我们绘制的曲线经过点的切线要和相邻的两点的直线平行,而贝赛尔曲线就是起点和终点平行,我们现在只需要找到其他的控制点绘制贝塞尔曲线即可。

解决问题

根据以上的思考,我们就有了后续的思路,我们分别需要在 AB 之间绘制一条贝赛尔曲线,B 为终点,切线相切;同时还要在 BC 之间绘制贝塞尔曲线,并且 B 点此时作为起点,要和上一条曲线经过 B 点的斜率一致。我们就有了上示的草稿图,并且可以得出一些相似的三角形。
在当我们有了 A、B、C 三个点的坐标之后,还有对应的相似比 S1S2P1P2 的坐标就是比较简单了。

接下来我们简单分析一下有 ABCDE 五个点的情况。我们五点中,中间的三个点是 BCD,我们都可以根据其各自的相邻两点做出对应的平行切线,并且每个点的切点上都可以找到对应两个点的控制点,根据控制点和各自的起点终点(其实也是控制点),我们可以绘制不同的红色举行的区域,分别可以用二次贝赛尔曲线绘制和三次贝赛尔曲线绘制。两点中有一个控制点的就是二次贝塞尔,比如 AB 之间;有两个控制点的就是三次贝塞尔,比如 BC 之间等。

说了这么多我们还没有落实一个数据,就是上面提到的相似比,我们用 S 表示。采用一种简单的情况来做具体的分析

代码简析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
// 数组的两个点的构成的直角三角形的[w,h](w:底边长度,h:高)
function va(arr, i, j) {
return [arr[2 * j] - arr[2 * i], arr[2 * j + 1] - arr[2 * i + 1]];
}
// 计算arr数组的i和j两个点之间的距离
function distance(arr, i, j) {
return Math.sqrt(
Math.pow(arr[2 * i] - arr[2 * j], 2) +
Math.pow(arr[2 * i + 1] - arr[2 * j + 1], 2)
);
}
function controlPoints(x1, y1, x2, y2, x3, y3) {
var t = 0.5; // 常数取得0.5
// 返回 w 和 h=>[w,h]
var v = va(arguments, 0, 2);
var d01 = distance(arguments, 0, 1);
var d12 = distance(arguments, 1, 2);
// 带入距离比例
var d012 = d01 + d12;
return [
x2 - (v[0] * t * d01) / d012,
y2 - (v[1] * t * d01) / d012,
x2 + (v[0] * t * d12) / d012,
y2 + (v[1] * t * d12) / d012,
];
}

let ss = [];
let pts = [];
function drawLine2() {
// 清楚所有的线段
for (let ii = 0; ii < ss.length; ii++) {
const bezier = ss[ii];
app.stage.removeChild(bezier);
}
ss = [];
pts = [];
// 将点转换为位置的一维数组
// [{pos:{x1,y1}},{pos:{x2,y2}}]=>[x1,y1,x2,y2]
arr
.map((i) => i.pos)
.forEach((item) => {
pts.push(item.x);
pts.push(item.y);
});

cps = []; // 控制点的位置
// 根据点的位置计算控制点的位置
for (var i = 0; i < pts.length - 2; i += 1) {
cps = cps.concat(
controlPoints(
pts[2 * i],
pts[2 * i + 1],
pts[2 * i + 2],
pts[2 * i + 3],
pts[2 * i + 4],
pts[2 * i + 5]
)
);
}

let start = arr[0];
// 两个点
if (arr.length === 2) {
drawBaseLine(start.pos, arr[1].pos);
}
// 多个点
console.log("pts", pts, "cps", cps);
let end;
let control;
let len = arr.length;
if (arr.length >= 3) {
start = arr[0];
end = arr[1];
// 开始的部分为二次贝赛尔曲线
var bezier = new PIXI.Graphics();
bezier.lineStyle(4, 0x1a7af8, 1);
bezier.moveTo(start.pos.x, start.pos.y);
bezier.quadraticCurveTo(cps[0], cps[1], pts[2], pts[3]);
bezier.endFill();
start = end;
app.stage.addChild(bezier);
ss.push(bezier);

for (var i = 2; i < len - 1; i += 1) {
// 中间部分为三次贝赛尔曲线
start = end;

var bezier = new PIXI.Graphics();
bezier.lineStyle(4, 0x1a7af8, 1);
bezier.moveTo(start.pos.x, start.pos.y);
bezier.bezierCurveTo(
cps[(2 * (i - 1) - 1) * 2],
cps[(2 * (i - 1) - 1) * 2 + 1],
cps[2 * (i - 1) * 2],
cps[2 * (i - 1) * 2 + 1],
pts[i * 2],
pts[i * 2 + 1]
);
bezier.endFill();
end = arr[i];
app.stage.addChild(bezier);
ss.push(bezier);
}

start = end;
// 结束部分为二次贝赛尔曲线
var bezier = new PIXI.Graphics();
bezier.lineStyle(4, 0x1a7af8, 1);
bezier.moveTo(start.pos.x, start.pos.y);
bezier.quadraticCurveTo(
cps[(2 * (i - 1) - 1) * 2],
cps[(2 * (i - 1) - 1) * 2 + 1],
pts[i * 2],
pts[i * 2 + 1]
);
bezier.endFill();
app.stage.addChild(bezier);
ss.push(bezier);
}
}

具体我们可以看看下面的 demo。(相思比S = t * d01 / d012)

See the Pen ZELKwyj by kitety (@kitety) on CodePen.

除此之外我还做了一下测试,分别做出相似比为 0.1、0.5、0.5 加距离乘积、0.8 的相似比对于同样的点,得出如下。

点的位置如下,可见不同的相似比画出来的曲线的效果也是不同的。

1
2
3
4
5
6
[
{ x: 100, y: 100 },
{ x: 400, y: 100 },
{ x: 400, y: 400 },
{ x: 100, y: 400 },
];

具体的效果可以参见下面的 demo,相似比过大、过小、为负数等都会有奇特的曲线出现,感兴趣的可以试试。

See the Pen dyNRmbY by kitety (@kitety) on CodePen.

可见,使用线段位置与具体常数的乘积效果最好。

结语

本篇文章简要分析了使用 canvas 绘制线条的具体方式,循序渐进的讲解了绘制线条,曲线和相似三角形曲线的处理方法,思路也是步步递进。感兴趣的可以在 demo 里面试试效果,尝试更换不同的相似比会有意想不到的效果。

本文也是借鉴了很多前人的思考成果加以思考,参考链接在文章末尾已经列出。

感谢阅读,完结撒花。

参考链接