在现代图形设计和数字艺术创作中,矢量图形的应用越来越广泛。矢量路径不仅仅是简单的形状,它们可以通过缠绕路径(Winding Path)和布尔运算(Boolean Operations)的组合,创建出复杂且精美的图形。在本文中,我们将简单介绍这些概念,并展示如何在 HTML5 Canvas 中应用它们。
一、矢量路径的基本概念
矢量路径是一组通过控制点和线段定义的图形路径。每个路径由一系列命令(如 M
、L
、Q
等)和坐标(如 [x, y]
)构成,用于描述从一个点到另一个点的移动方式。矢量路径的特点在于它们可以无损放大,适用于不同尺寸的输出。在HTML5 Canvas中,可以使用CanvasRenderingContext2D接口提供的一系列API来绘制矢量路径。以下是一些基本步骤和API的使用示例,用于绘制一个简单的矢量路径:
- 开始一个新路径:使用
beginPath()
方法开始一个新路径。这个方法会清除之前的路径,让你可以开始绘制一个新的。 - 移动到起点:使用
moveTo(x, y)
方法将绘图游标移动到路径的起始点。 - 绘制线段:使用
lineTo(x, y)
方法添加一个从当前位置到指定x
和y
位置的直线。 - 绘制曲线:可以使用
quadraticCurveTo(cp1x, cp1y, x, y)
绘制二次贝塞尔曲线,或使用bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y)
绘制三次贝塞尔曲线。这里的cp1x, cp1y
和cp2x, cp2y
是控制点的坐标,而x, y
是曲线的结束点。 - 闭合路径:如果需要,可以使用
closePath()
方法闭合路径。这会绘制一条从当前点回到起始点的直线。 - 绘制路径:使用
stroke()
方法绘制路径的轮廓,或使用fill()
方法填充路径内部。
二、缠绕路径(Winding Path)的概念
缠绕路径是指一个路径的绘制方式,它决定了路径内部和外部区域的划分。缠绕路径主要有以下两种规则:
const canvas = document.createElement('canvas');
canvas.height = canvas.width = 500
document.body.append(canvas)
const ctx = canvas.getContext("2d");
ctx.fillStyle = "#f00";
ctx.beginPath();
ctx.moveTo(100, 10);
ctx.lineTo(40, 180);
ctx.lineTo(190, 60);
ctx.lineTo(10,60);
ctx.lineTo(160,180);
ctx.closePath();
ctx.strokeStyle = '#000';
ctx.lineWidth = 5;
ctx.stroke()
/* ctx.fill(); //默认非零缠绕 */
/* ctx.fill('evenodd'); // 奇偶缠绕*/
- 非零缠绕规则(Non-Zero Winding Rule) :判断点是否在路径内部的依据是,观察从该点向路径外画出的射线穿过路径的次数。如果射线穿过路径的方向一致,则计算为正;如果方向相反,则计算为负。最终,如果穿过路径的次数总和不为零,则该点被认为是在路径内部。
- 奇偶规则(Even-Odd Rule) :判断点是否在路径内部的依据是,观察从该点向路径外画出的射线穿过路径的次数。如果射线穿过路径的次数为奇数,则该点被认为在路径内部;否则被认为在路径外部。
三、 闭合路径
闭合路径(Closed Path) 是指路径的起点和终点重合,形成一个完整的、封闭的形状。例如,圆形、矩形、以及大多数多边形都是闭合路径。路径闭合意味着路径沿着一定的方向最终返回到起点,不留下任何开口。
非闭合路径(Open Path) 则指路径的起点和终点不重合,即路径未形成一个封闭的形状。常见的非闭合路径包括线段、折线、或一些复杂的开放图形。这些路径的起点和终点是两个不同的点。 在canvas中,我们常常用closePath将一段路径闭合。
const path = new Path2D();
path.moveTo(0, 0);
path.lineTo(0, 100);
path.lineTo(100, 100);
ctx.stroke(path);
ctx.translate(120, 0);
path.closePath();
ctx.stroke(path);
我们沿零点向下画一条射线,在向右画一条线。很明显看出第二个使用了closepath的路径是闭合的。
如果在将末端手动绕回原点,可以看出这个两个路径看上去并无区别了。
const path = new Path2D();
path.moveTo(0, 0);
path.lineTo(0, 100);
path.lineTo(100, 100);
path.lineTo(0, 0); // 增加绕回原点的线
但注意,由于我们描边宽度是一像素的,我们将描边加大,非闭合与闭合路径的并不是完全一致的。
可以看到即使将线绕回原点变成一个封闭的结构,它与closePath表现的仍有差别。
jsfiddle.net/qpg769eL/16…
四、布尔运算
布尔运算用于合并或修改多个矢量路径,生成新的复杂形状。主要的布尔运算包括:
- 联合(Unite/Add/Combine) :将两个路径合并为一个路径,包含它们的全部区域。
- 交集(Intersect) :保留两个路径的重叠区域,丢弃非重叠的部分。
- 差集(Subtract) :从一个路径中减去另一个路径的重叠部分。
- 排除(Exclude/XOR) :保留两个路径的非重叠区域,丢弃重叠部分。
通过布尔运算,设计师可以快速组合简单形状,生成复杂的几何图形。例如,在图标设计中,常见的心形、星形等图案,通常都是通过基本形状的布尔运算生成的。
我们定义一个能够结合多种规则、闭合、布尔运算的数据结构,取开头的例子,它由一个花朵的矢量图案与4个文字形状以及两条非闭合的贝塞尔曲线组合而成。我们在画布上这样绘制ta:
将一条子路径的规则、布尔运算类型、轮廓的命令以及矢量路径定位为如下:
[A,B,C,D,...P] as number[]
[A, B, C, D, ... P]
中的每一个子数组表示一个路径,其中:
-
A:(是否开放) : 表示路径是否开放(0表示闭合路径,1表示开放路径)。
-
B:(缠绕规则) : 表示是否使用奇偶缠绕规则(0表示非零缠绕规则,1表示奇偶缠绕规则)。
-
C:(布尔运算类型) : 表示路径参与的布尔运算类型:
- 0 表示联合(Union)
- 1 表示差集(Difference)
- 2 表示交集(Intersection)
- 3 表示排除(Exclusion)
- -1 表示无运算类型(即单独的路径,不参与布尔运算)
-
D: ... P: 表示路径的绘制指令(类似于SVG的path指令),如
1
表示移动到(Move to),2
表示直线到(Line to),5
表示三次贝塞尔曲线。
完全用数字记录的好处是自由仿射变换的时候可以很方便的处理数据,也就是缩放倾斜使得描边宽度不随着变换变形。这里用做布尔运算的库使用paper.js,我们输入一个二维数组就能位置出包含多种规则运算类型的路径了。下面这个对象包含三个方法,执行Helper.drawDatas(data)就能得到一个paper.Path的结构,拿这个结构无论转svg还是直接绘制就非常轻松了。
const Helper = {
drawPathByDataInPaper(data: number[]) {
const path = new paper.Path()
let i = 0
const isOpen = data[i++]
path.fillRule = data[i++] ? 'evenodd' : 'nonzero'
i++
while (i < data.length) {
const command = data[i++]
if (command === 1) {
path.moveTo(new paper.Point(data[i++], data[i++]))
} else if (command === 5) {
path.cubicCurveTo(new paper.Point(data[i++], data[i++]), new paper.Point(data[i++], data[i++]), new paper.Point(data[i++], data[i++]))
}
}
!isOpen && path.closePath()
return path
},
performBooleanOperation(type: number, path1: paper.Path | paper.CompoundPath, path2) {
let result
switch (type) {
case 0:
result = path1.unite(path2)
break
case 1:
result = path1.subtract(path2)
break
case 2:
result = path1.intersect(path2)
break
case 3:
result = path1.exclude(path2)
break
}
return result
},
drawDatas(data: number[][]){
let path = new paper.CompoundPath({})
const map = {}
let key = ''
for(let i = 0; i < data.length; i++) {
const d = data[i]
const op = d[2]
if(op !== -1) {
key = op+'-'+i
map[key] = new paper.CompoundPath({})
}
map[key].addChild(Helper.drawPathByDataInPaper(d))
}
const keys = Object.keys(map)
keys.forEach((key) => {
const compoundPath = map[key]
const op = key.split('-')[0]
path = Helper.performBooleanOperation(+op, path, compoundPath)
})
return path
}
}