绘制三阶贝塞尔曲线
初始化,创建一个贝塞尔曲线图
-
三阶贝塞尔曲线公式: B(t) = P0 * (1-t)^3 + 3 * P1 * t * (1-t)^2 + 3 * P2 * t^2 * (1-t) + P3 * t^3, t ∈ [0,1]
- 由 p0 、p3 起点和终点(锚点),p1、p2 控制点 一起控制
-
利用 canvas中的bezierCurveTo 方法绘制贝塞尔曲线,要求我们传入三个参数,但是看公式明明是4个参数,是因为canvas落笔moveTo 是起点 p0。
-
下面是简单封装的一个 class 去绘制贝塞尔
// utils.ts
type vec2 = [number, number]
class Bezier {
private canvas: HTMLCanvasElement | null = null
private ctx: CanvasRenderingContext2D | null = null
// [P0, CP1, CP2, P3] 贝塞尔曲线的三个控制点
private _value: vec2[] = []
private _radii = 4
set size (size: [number, number]) {
if (this.canvas) {
this.canvas.width = size[0]
this.canvas.height = size[1]
}
}
get size () {
return [this.canvas!.width, this.canvas!.height]
}
set value (val) {
this._value = JSON.parse(JSON.stringify(val))
}
get value () {
return this._value
}
constructor(options: {
canvas: HTMLCanvasElement
size: [number, number]
value: vec2[]
}) {
const { canvas, size, value } = options
this.canvas = canvas
this.size = size
this.value = value
this.ctx = canvas.getContext('2d')
this.draw()
}
draw () {
const ctx = this.ctx
if (ctx) {
// 绘制之前,必须先清除上一次绘画
ctx!.clearRect(0, 0, ...this.size)
const [p0, cp1, cp2, p1] = this.value
ctx.beginPath()
ctx.strokeStyle = '#1572b5'
ctx.lineWidth = 2
ctx.moveTo(p0[0], p0[1])
ctx.bezierCurveTo(cp1[0], cp1[1], cp2[0], cp2[1], p1[0], p1[1])
ctx.stroke()
this.drawCircle(p0)
this.drawCircle(cp1)
this.drawLine(p0, cp1)
this.drawCircle(cp2)
this.drawCircle(p1)
this.drawLine(cp2, p1)
}
}
drawLine (start: vec2, end: vec2) {
const ctx = this.ctx
if (ctx) {
ctx.beginPath()
ctx.moveTo(...start)
ctx.lineTo(...end)
ctx.setLineDash([5, 5])
ctx.strokeStyle = '#e7bdb1'
ctx.stroke()
}
}
drawCircle ([x, y]: vec2) {
const ctx = this.ctx
if (ctx) {
ctx.beginPath()
ctx.arc(x, y, this._radii, 0, Math.PI * 2)
ctx.fillStyle = '#85bdc4'
ctx.fill()
}
}
}
<template>
<canvas id="canvas" ref="canvas"></canvas>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { Bezier } from './utils.ts'
const canvas = ref<HTMLCanvasElement>()
function init () {
new Bezier({
canvas: canvas.value!,
size: [600, 500],
value: [[150, 250], [300, 100], [400, 100], [450, 250]]
})
}
onMounted(init)
</script>
<style>
#canvas {
border-radius: 10px;
border: 1px solid #ddd;
}
</style>
效果如下:
修改贝塞尔曲线
-
鼠标hover 到控制点上出现小手
- 判断当先鼠标位置是否和控制点重合(因为canvas不想dom那样,可以直接给控制点绑定事件)
-
拖动控制点
-
更新控制点位置
-
重新绘制贝塞尔曲线
class Bezier {
// ...
constructor(options: {
canvas: HTMLCanvasElement
size: [number, number]
value: vec2[]
}) {
// ...
this.bindEvent()
}
// ...
// 检查鼠标是否接触到 控制点
checkPath([x, y]: vec2) {
const r = this._radii
const value = this.value
for (let i = 0; i < value.length; i++) {
const p = value[i]
const isPress = (
x >= p[0] - r
&& x <= p[0] + r
&& y >= p[1] - r
&& y <= p[1] + r
)
// 返回值 i ,就是 表示当前拖动的是哪个点
if (isPress) return i
}
return -1
}
bindEvent () {
const canvas = this.canvas
if (!canvas) return
let index = -1
const handleCanvasMousedown = (e: MouseEvent) => {
index = this.checkPath([e.offsetX, e.offsetY])
canvas.addEventListener('mouseup', handleCanvasMouseup)
}
const handleCanvasMousemove = (e: MouseEvent) => {
const x = e.offsetX
const y = e.offsetY
const value = this.value
// 这里只是想 鼠标 hover 的时候,出现小手
if (this.checkPath([x, y]) > -1) {
this.setCursor('pointer')
} else {
this.setCursor('default')
}
if (index > -1) {
value.splice(index, 1, [x, y])
// 数据更新了,需要重新 draw
this.value = value
this.draw()
}
}
const handleCanvasMouseup = () => {
index = -1
canvas.removeEventListener('mouseup', handleCanvasMouseup)
}
canvas.addEventListener('mousedown', handleCanvasMousedown)
canvas.addEventListener('mousemove', handleCanvasMousemove)
}
private setCursor(cursor: 'pointer' | 'default') {
this.canvas!.style.cursor = cursor
}
}
效果:
切割贝塞尔曲线
-
点击曲线,进行切割
- 找到点击点坐标 (x, y)
- 点击的位置,x 轴坐标肯定知道。(先不管 y,可以先求出 y,在判断 y 是否在曲线上,不在,忽略此次点击)
- 已知 x、p0、cp1、cp2、p3,求 t。(问 gpt,哈哈)
- 求得 t,已知 t、p0、cp0、cp1、p3,求 y。直接带入 三阶贝塞尔曲线,便可得到
- 求得 y,判断 y 和鼠标点击的 mouse_y 的误差小于 1 ,便认为点击在曲线上,否则忽略点击事件
- 判断得到点击在曲线上,进行切割
- 已知 t、p0、cp0、cp1、p3,通过公式得到两个新的贝塞尔曲线。(问 gpt,哈哈)
-
得到两个新的贝塞尔,重新绘制
- 解析贝塞尔曲线,求 t
这是一个反向问题,因为这涉及到求解三次方程,而且可能有多个解。在实际应用中,通常会使用数值方法(如牛顿法)来近似求解。。
export function solveCubicBezier(
x: number,
P0: [number, number],
P1: [number, number],
P2: [number, number],
P3: [number, number]
) {
const epsilon = 0.00001 // 精度,可以根据需要调整
const maxIterations = 10 // 最大迭代次数,可以根据需要调整
let t = 0.5 // 假设是 0.5
for (let i = 0; i < maxIterations; i++) {
const currentX = getThreeBezierPoint(t, P0, P1, P2, P3)[0]
const derivative = (getThreeBezierPoint(t + epsilon, P0, P1, P2, P3)[0] - currentX) / epsilon
const delta = (x - currentX) / derivative
t += delta
if (Math.abs(delta) < epsilon) {
return t
}
}
return t // 如果没有在maxIterations次迭代内找到解,返回最后的t值
}
export const getThreeBezierPoint = (
t: number,
p1: [number, number],
cp1: [number, number],
cp2: [number, number],
p2: [number, number],
): [number, number] => {
const [x1, y1] = p1
const [x2, y2] = p2
const [cx1, cy1] = cp1
const [cx2, cy2] = cp2
// B(t) = P0 * (1-t)^3 + 3 * P1 * t * (1-t)^2 + 3 * P2 * t^2 * (1-t) + P3 * t^3, t ∈ [0,1]
const x = x1 * (1 - t) * (1 - t) * (1 - t) + 3 * cx1 * t * (1 - t) * (1 - t) + 3 * cx2 * t * t * (1 - t) + x2 * t * t * t
const y = y1 * (1 - t) * (1 - t) * (1 - t) + 3 * cy1 * t * (1 - t) * (1 - t) + 3 * cy2 * t * t * (1 - t) + y2 * t * t * t
return [x, y]
}
- 分割贝塞尔曲线
三阶贝塞尔曲线的控制点是P0, P1, P2, P3。我们可以选择一个参数t(0<t<1),将原始的贝塞尔曲线切割成两个贝塞尔曲线。
首先,我们需要计算出新的控制点:
Q0 = (1-t)P0 + tP1 Q1 = (1-t)P1 + tP2 Q2 = (1-t)P2 + tP3
然后,我们再次应用这个公式,得到:
R0 = (1-t)Q0 + tQ1 R1 = (1-t)Q1 + tQ2
最后,我们再次应用这个公式,得到:
S = (1-t)R0 + tR1
所以,我们得到了两个新的贝塞尔曲线的控制点:
第一个贝塞尔曲线的控制点是:P0, Q0, R0, S
第二个贝塞尔曲线的控制点是:S, R1, Q2, P3
这样,我们就将原始的三阶贝塞尔曲线切割成了两个新的贝塞尔曲线
用代码解释:
function splitBezierCurve(P0, P1, P2, P3, t) {
// 计算新的控制点
const Q0 = {
x: P0.x + (P1.x - P0.x) * t,
y: P0.y + (P1.y - P0.y) * t
};
const Q1 = {
x: P1.x + (P2.x - P1.x) * t,
y: P1.y + (P2.y - P1.y) * t
};
const Q2 = {
x: P2.x + (P3.x - P2.x) * t,
y: P2.y + (P3.y - P2.y) * t
};
const R0 = {
x: Q0.x + (Q1.x - Q0.x) * t,
y: Q0.y + (Q1.y - Q0.y) * t
};
const R1 = {
x: Q1.x + (Q2.x - Q1.x) * t,
y: Q1.y + (Q2.y - Q1.y) * t
};
const S = {
x: R0.x + (R1.x - R0.x) * t,
y: R0.y + (R1.y - R0.y) * t
};
// 返回两个新的贝塞尔曲线的控制点
return [
[P0, Q0, R0, S],
[S, R1, Q2, P3]
];
}
效果: