如何判断一个坐标点是否在三阶贝塞尔曲线附近

1,449 阅读6分钟

最近写了一个流程图相关的组件,用于工作流的配置,里面的节点之间的关系连接线用到了直线,折线和曲线。因为是流程图,就避免不了去操作连接线。

image.png

而关键的问题是我鼠标在的地方,我要知道当前鼠标的位置附近是否有连接线,所以我们要用当前的坐标去判断每根连接线,是否在连接线的可选范围内。

直线

image.png

直线其实是点到线段的最短距离问题,这个是最好解决的,一次判断就行,下面是计算方程:

/**
 * 求点到线段的距离
 * @param {number} pt 直线外的点
 * @param {number} p 直线内的点1
 * @param {number} q 直线内的点2
 * @returns {number} 距离
 */
function getDistance(pt: [number, number], p: [number, number], q: [number, number]) {
  const pqx = q[0] - p[0]
  const pqy = q[1] - p[1]
  let dx = pt[0] - p[0]
  let dy = pt[1] - p[1]
  const d = pqx * pqx + pqy * pqy   // qp线段长度的平方
  let t = pqx * dx + pqy * dy     // p pt向量 点积 pq 向量(p相当于A点,q相当于B点,pt相当于P点)
  if (d > 0) {  // 除数不能为0; 如果为零 t应该也为零。下面计算结果仍然成立。                   
    t /= d      // 此时t 相当于 上述推导中的 r。
  }
  if (t < 0) {  // 当t(r)< 0时,最短距离即为 pt点 和 p点(A点和P点)之间的距离。
    t = 0
  } else if (t > 1) { // 当t(r)> 1时,最短距离即为 pt点 和 q点(B点和P点)之间的距离。
    t = 1
  }

  // t = 0,计算 pt点 和 p点的距离; t = 1, 计算 pt点 和 q点 的距离; 否则计算 pt点 和 投影点 的距离。
  dx = p[0] + t * pqx - pt[0]
  dy = p[1] + t * pqy - pt[1]
  
  return dx * dx + dy * dy
}

我判断的范围是如果返回的值小于20,就认为是可以选中的。

折线

image.png

折线的判断和直线,折线是由一根根线段组成的,所以我们依次去判断每一条线段到鼠标坐标点的最短距离,只要满足我们设定的最短距离即可返回当前折线的选中状态。

// offsetX, offsetY 为当前鼠标位置的 x, y
for (let j = 1; j < innerPonints.length; j++) {
  pre = innerPonints[j - 1]
  cur = innerPonints[j]
  if (getDistance([offsetX, offsetY], pre, cur) < 20) {
    return points[i]
  }
}

曲线

image.png

这里的曲线是用三阶贝塞尔来绘制的。

三阶贝塞尔公式:

/**
 * @desc 获取三阶贝塞尔曲线的线上坐标
 * B(t) = P0 * (1-t)^3 + 3 * P1 * t * (1-t)^2 + 3 * P2 * t^2 * (1-t) + P3 * t^3, t ∈ [0,1]
 * @param {number} t 当前百分比
 * @param {Array} p1 起点坐标
 * @param {Array} p2 终点坐标
 * @param {Array} cp1 控制点1
 * @param {Array} cp2 控制点2
 */
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
  
  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]
}

所以我们想要求得曲线上某个固定比例上的点则可以用上面的公式,比如我们想要中心点:

// 获取三阶贝塞尔曲线的中点坐标
const getBezierCenterPoint = (points: [number, number][]) => {
  return getThreeBezierPoint(
    0.5, points[0], points[1], points[2], points[3]
  )
}

上面已知的是起止点和两个个控制点,和时间 t,可以求得想对应的 x,y 坐标。那如何获取鼠标位置到曲线的最短距离呢?

首先做这个之前,我们需要确定在流程控制中曲线的两个控制点的取点思路:

GIF.gif

红色的折线是曲线的两个起止点和两个控制点相连的线,第一个控制点相对于开始点,第二个控制点相对于终点,两个控制点的偏移量都是一样的,偏移量的大小由起点和终点两个坐标计算得出:

const coeff = 0.5 // 乘积系数
// 值取起点和终点的 x、y,取两者差值的较大值 * 系数
const p = Math.max(Math.abs(destx - startx), Math.abs(desty - starty)) * coeff

然后根据起止点的方向分别去算控制点的坐标。

const coeff = 0.5
export default function calcBezierPoints({ startDire, startx, starty, destDire, destx, desty }: WF.CalcBezierType,
  points: [number, number][]) {

  const p = Math.max(Math.abs(destx - startx), Math.abs(desty - starty)) * coeff
  // 第一个控制点
  switch (startDire) {
    case 'down':
      points.push([startx, starty + p])
      break
    case 'up':
      points.push([startx, starty - p])
      break
    case 'left':
      points.push([startx - p, starty])
      break
    case 'right':
      points.push([startx + p, starty])
      break
    // no default
  }
  // 第二个控制点
  switch (destDire) {
    case 'down':
      points.push([destx, desty + p])
      break
    case 'up':
      points.push([destx, desty - p])
      break
    case 'left':
      points.push([destx - p, desty])
      break
    case 'right':
      points.push([destx + p, desty])
      break
    // no default
  }
}
第一种: 曲线转换成折线,将问题转换成点到线段的最短距离问题

因为我们控制不同的 t 来获取不同位置的 x、y,然后已有的方法可以获取点到线段的最短距离。所以我们可以先将 t 分成 100 等份,分别求出对应位置的 x、y,然后将这 100 个坐标点组合成 99 条线段,最后再依次判断这 99 条线段到鼠标点的最短距离,只要其中有一条线段到点的距离值小于 20,满足条件就判定为该条曲线符合。

第二种:将已有的三阶贝塞尔公式反推出 t

这种方法的思路是,贝塞尔的公式是根据 t 来求 x、y的,所以我们只要反推出用 x、y 的值求 t 的反推公式,然后用 x 或者 y 来求 t,来做三者的比较即可。

下面是三阶贝塞尔公式反推 t 公式:

/**
 * 已知四个控制点,及曲线中的某一个点的 x/y,反推求 t
 * @param {number} x1 起点 x/y
 * @param {number} x2 控制点1 x/y
 * @param {number} x3 控制点2 x/y
 * @param {number} x4 终点 x/y
 * @param {number} X 曲线中的某个点 x/y
 * @returns {number[]} t[]
 */
export const getBezierT = (x1: number, x2: number, x3: number, x4: number, X: number) => {
  const a = -x1 + 3 * x2 - 3 * x3 + x4
  const b = 3 * x1 - 6 * x2 + 3 * x3
  const c = -3 * x1 + 3 * x2
  const d = x1 - X

  // 盛金公式, 预先需满足, a !== 0
  // 判别式
  const A = Math.pow(b, 2) - 3 * a * c
  const B = b * c - 9 * a * d
  const C = Math.pow(c, 2) - 3 * b * d
  const delta = Math.pow(B, 2) - 4 * A * C

  let t1 = -100, t2 = -100, t3 = -100

  // 3个相同实数根
  if (A === B && A === 0) {
    t1 = -b / (3 * a)
    t2 = -c / b
    t3 = -3 * d / c
    return [t1, t2, t3]
  }

  // 1个实数根和1对共轭复数根
  if (delta > 0) {
    const v = Math.pow(B, 2) - 4 * A * C
    const xsv = v < 0 ? -1 : 1

    const m1 = A * b + 3 * a * (-B + (v * xsv) ** (1 / 2) * xsv) / 2
    const m2 = A * b + 3 * a * (-B - (v * xsv) ** (1 / 2) * xsv) / 2

    const xs1 = m1 < 0 ? -1 : 1
    const xs2 = m2 < 0 ? -1 : 1

    t1 = (-b - (m1 * xs1) ** (1 / 3) * xs1 - (m2 * xs2) ** (1 / 3) * xs2) / (3 * a)
    // 涉及虚数,可不考虑。i ** 2 = -1
  }

  // 3个实数根
  if (delta === 0) {
    const K = B / A
    t1 = -b / a + K
    t2 = t3 = -K / 2
  }

  // 3个不相等实数根
  if (delta < 0) {
    const xsA = A < 0 ? -1 : 1
    const T = (2 * A * b - 3 * a * B) / (2 * (A * xsA) ** (3 / 2) * xsA)
    const theta = Math.acos(T)

    if (A > 0 && T < 1 && T > -1) {
      t1 = (-b - 2 * A ** (1 / 2) * Math.cos(theta / 3)) / (3 * a)
      t2 = (-b + A ** (1 / 2) * (Math.cos(theta / 3) + 3 ** (1 / 2) * Math.sin(theta / 3))) / (3 * a)
      t3 = (-b + A ** (1 / 2) * (Math.cos(theta / 3) - 3 ** (1 / 2) * Math.sin(theta / 3))) / (3 * a)
    }
  }
  return [t1, t2, t3]
}

根据以上的反推公式,我们可以用 x 来求出 时间 t,也可以用 y 来求出 t,我们每一次求值都会得到是 3 个 t,根据三阶贝塞尔曲线,t 的取值范围是 0 - 1,所以我们得到值后要先判断 t 是否有效。

image.png

还有两种特殊的曲线需要处理:

  • 当曲线所有的 x 点都一样时,如果我们是用 x 来求 t,那会导致盛金公式不成立
  • 当曲线所有的 y 点都一样时,如果我们是用 y 来求 t,那也会导致盛金公式不成立

所以我们要验证两次:

1、先用 x 来求 t,再用 t 来求 y,然后比较 y 与鼠标的 offsetY 值的差值是否在可选中范围内

2、如果 x 求出来的值不满足,再用 y 来求 t,然后用 t 求出的 x 来和鼠标的 offsetX 来比较,如果满足则返回可选状态。

export const isAboveLine = (offsetX: number, offsetY: number, points: WF.LineInfo[]) => {
  // 用 x 求出对应的 t,用 t 求相应位置的 y,再比较得出的 y 与 offsetY 之间的差值
  const tsx = getBezierT(innerPonints[0][0], innerPonints[1][0], innerPonints[2][0],     innerPonints[3][0], offsetX)
  for (let x = 0; x < 3; x++) {
    if (tsx[x] <= 1 && tsx[x] >= 0) {
      const ny = getThreeBezierPoint(tsx[x], innerPonints[0], innerPonints[1], innerPonints[2], innerPonints[3])
      if (Math.abs(ny[1] - offsetY) < 8) {
        return points[i]
      }
    }
  }
  // 如果上述没有结果,则用 y 求出对应的 t,再用 t 求出对应的 x,与 offsetX 进行匹配
  const tsy = getBezierT(innerPonints[0][1], innerPonints[1][1], innerPonints[2][1], innerPonints[3][1], offsetY)
  for (let y = 0; y < 3; y++) {
    if (tsy[y] <= 1 && tsy[y] >= 0) {
      const nx = getThreeBezierPoint(tsy[y], innerPonints[0], innerPonints[1], innerPonints[2], innerPonints[3])
      if (Math.abs(nx[0] - offsetX) < 8) {
        return points[i]
      }
    }
  }
}

到这里就完成了是否可以选中曲线了。