canvas 绘制贝塞尔曲线并切割曲线

444 阅读5分钟

绘制三阶贝塞尔曲线

初始化,创建一个贝塞尔曲线图

  • 三阶贝塞尔曲线公式: 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
  }

}

效果:

beau.gif

切割贝塞尔曲线

  • 点击曲线,进行切割

    • 找到点击点坐标 (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,哈哈)
  • 得到两个新的贝塞尔,重新绘制

  1. 解析贝塞尔曲线,求 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]
}
  1. 分割贝塞尔曲线
三阶贝塞尔曲线的控制点是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]
    ];
}

效果: qiege.gif