三次贝塞尔曲线绘制波浪线

35 阅读2分钟

image.png

svg 预览 uutool.cn/svg-preview…

  const path = createWavyLinePath({ x: 257, y: 238 }, { x: 1343, y: 238 }, { waveLength: 40, waveHeight: 10 })
  
  M 257 238 C 262.7 240.85 271.3 240.85 277 238 C 282.7 235.15 291.3 235.15 297 238 C 302.7 240.85 311.3 240.85 317 238 C 322.7 235.15 331.3 235.15 337 238 C 342.7 240.85 351.3 240.85 357 238 C 362.7 235.15 371.3 235.15 377 238 C 382.7 240.85 391.3 240.85 397 238 C 402.7 235.15 411.3 235.15 417 238 C 422.7 240.85 431.3 240.85 437 238 C 442.7 235.15 451.3 235.15 457 238 C 462.7 240.85 471.3 240.85 477 238 C 482.7 235.15 491.3 235.15 497 238 C 502.7 240.85 511.3 240.85 517 238 C 522.7 235.15 531.3 235.15 537 238 C 542.7 240.85 551.3 240.85 557 238 C 562.7 235.15 571.3 235.15 577 238 C 582.7 240.85 591.3 240.85 597 238 C 602.7 235.15 611.3 235.15 617 238 C 622.7 240.85 631.3 240.85 637 238 C 642.7 235.15 651.3 235.15 657 238 C 662.7 240.85 671.3 240.85 677 238 C 682.7 235.15 691.3 235.15 697 238 C 702.7 240.85 711.3 240.85 717 238 C 722.7 235.15 731.3 235.15 737 238 C 742.7 240.85 751.3 240.85 757 238 C 762.7 235.15 771.3 235.15 777 238 C 782.7 240.85 791.3 240.85 797 238 C 802.7 235.15 811.3 235.15 817 238 C 822.7 240.85 831.3 240.85 837 238 C 842.7 235.15 851.3 235.15 857 238 C 862.7 240.85 871.3 240.85 877 238 C 882.7 235.15 891.3 235.15 897 238 C 902.7 240.85 911.3 240.85 917 238 C 922.7 235.15 931.3 235.15 937 238 C 942.7 240.85 951.3 240.85 957 238 C 962.7 235.15 971.3 235.15 977 238 C 982.7 240.85 991.3 240.85 997 238 C 1002.7 235.15 1011.3 235.15 1017 238 C 1022.7 240.85 1031.3 240.85 1037 238 C 1042.7 235.15 1051.3 235.15 1057 238 C 1062.7 240.85 1071.3 240.85 1077 238 C 1082.7 235.15 1091.3 235.15 1097 238 C 1102.7 240.85 1111.3 240.85 1117 238 C 1122.7 235.15 1131.3 235.15 1137 238 C 1142.7 240.85 1151.3 240.85 1157 238 C 1162.7 235.15 1171.3 235.15 1177 238 C 1182.7 240.85 1191.3 240.85 1197 238 C 1202.7 235.15 1211.3 235.15 1217 238 C 1222.7 240.85 1231.3 240.85 1237 238 C 1242.7 235.15 1251.3 235.15 1257 238 C 1262.7 240.85 1271.3 240.85 1277 238 C 1282.7 235.15 1291.3 235.15 1297 238 C 1302.7 240.85 1311.3 240.85 1317 238 C 1322.7 235.15 1343 238 1343 238
  
/**
 * SVG Path 工具函数
 * 使用贝塞尔曲线公式将一小段 SVG path 转换成可延长的路径
 */

interface Point {
  x: number
  y: number
}

interface BezierCurve {
  p0: Point // 起点
  p1: Point // 控制点1
  p2: Point // 控制点2
  p3: Point // 终点
}

/**
 * 解析 SVG path 字符串,提取贝塞尔曲线
 */
function parseBezierCurves(pathString: string): BezierCurve[] {
  const curves: BezierCurve[] = []
  // 移除所有逗号,统一用空格分隔
  let path = pathString.replace(/,/g, ' ')
  // 在命令字母前后添加空格
  path = path.replace(/([MmLlHhVvCcSsQqTtAaZz])/g, ' $1 ')
  // 压缩多个空格为单个空格
  path = path.replace(/\s+/g, ' ').trim()

  const tokens = path.split(' ')
  let i = 0
  let currentPoint: Point = { x: 0, y: 0 }

  while (i < tokens.length) {
    const token = tokens[i]
    if (!token) {
      i++
      continue
    }

    const command = token.toUpperCase()
    const isRelative = token === token.toLowerCase()

    switch (command) {
      case 'M': {
        // Move to
        const x = parseFloat(tokens[++i])
        const y = parseFloat(tokens[++i])
        if (isRelative) {
          currentPoint.x += x
          currentPoint.y += y
        } else {
          currentPoint.x = x
          currentPoint.y = y
        }
        break
      }
      case 'C': {
        // Cubic Bezier curve: C x1 y1 x2 y2 x y
        const x1 = parseFloat(tokens[++i])
        const y1 = parseFloat(tokens[++i])
        const x2 = parseFloat(tokens[++i])
        const y2 = parseFloat(tokens[++i])
        const x = parseFloat(tokens[++i])
        const y = parseFloat(tokens[++i])

        let cp1x = x1
        let cp1y = y1
        let cp2x = x2
        let cp2y = y2
        let endX = x
        let endY = y

        if (isRelative) {
          cp1x += currentPoint.x
          cp1y += currentPoint.y
          cp2x += currentPoint.x
          cp2y += currentPoint.y
          endX += currentPoint.x
          endY += currentPoint.y
        }

        curves.push({
          p0: { x: currentPoint.x, y: currentPoint.y },
          p1: { x: cp1x, y: cp1y },
          p2: { x: cp2x, y: cp2y },
          p3: { x: endX, y: endY },
        })

        currentPoint = { x: endX, y: endY }
        break
      }
      case 'L': {
        // Line to - 转换为线性贝塞尔曲线(两个控制点与起点终点重合)
        const x = parseFloat(tokens[++i])
        const y = parseFloat(tokens[++i])
        let endX = x
        let endY = y

        if (isRelative) {
          endX += currentPoint.x
          endY += currentPoint.y
        }

        // 将直线转换为贝塞尔曲线(控制点与起点终点重合)
        curves.push({
          p0: { x: currentPoint.x, y: currentPoint.y },
          p1: { x: currentPoint.x, y: currentPoint.y },
          p2: { x: endX, y: endY },
          p3: { x: endX, y: endY },
        })

        currentPoint = { x: endX, y: endY }
        break
      }
      default:
        i++
        break
    }
    i++
  }

  return curves
}

/**
 * 计算贝塞尔曲线在参数 t 处的点
 * B(t) = (1-t)³P₀ + 3(1-t)²tP₁ + 3(1-t)t²P₂ + t³P₃
 */
function bezierPoint(curve: BezierCurve, t: number): Point {
  const mt = 1 - t
  const mt2 = mt * mt
  const mt3 = mt2 * mt
  const t2 = t * t
  const t3 = t2 * t

  return {
    x: mt3 * curve.p0.x + 3 * mt2 * t * curve.p1.x + 3 * mt * t2 * curve.p2.x + t3 * curve.p3.x,
    y: mt3 * curve.p0.y + 3 * mt2 * t * curve.p1.y + 3 * mt * t2 * curve.p2.y + t3 * curve.p3.y,
  }
}

/**
 * 计算贝塞尔曲线的近似长度(通过采样点)
 */
function bezierLength(curve: BezierCurve, samples: number = 20): number {
  let length = 0
  let prevPoint = curve.p0

  for (let i = 1; i <= samples; i++) {
    const t = i / samples
    const point = bezierPoint(curve, t)
    const dx = point.x - prevPoint.x
    const dy = point.y - prevPoint.y
    length += Math.sqrt(dx * dx + dy * dy)
    prevPoint = point
  }

  return length
}

/**
 * 计算所有贝塞尔曲线的总长度
 */
function getTotalLength(curves: BezierCurve[]): number {
  return curves.reduce((sum, curve) => sum + bezierLength(curve), 0)
}

/**
 * 将贝塞尔曲线转换为相对于起点的形式
 * 确保每条曲线的起点是前一条曲线的终点(连续)
 */
function normalizeCurves(curves: BezierCurve[]): {
  normalizedCurves: BezierCurve[]
  startPoint: Point
  endPoint: Point
  totalLength: number
} {
  if (curves.length === 0) {
    return { normalizedCurves: [], startPoint: { x: 0, y: 0 }, endPoint: { x: 0, y: 0 }, totalLength: 0 }
  }

  const startPoint = curves[0].p0
  const endPoint = curves[curves.length - 1].p3

  // 将所有曲线转换为相对于起点的坐标,并确保连续性
  const normalizedCurves: BezierCurve[] = []

  curves.forEach((curve, index) => {
    if (index === 0) {
      // 第一条曲线:起点是 (0, 0)
      normalizedCurves.push({
        p0: { x: 0, y: 0 },
        p1: { x: curve.p1.x - startPoint.x, y: curve.p1.y - startPoint.y },
        p2: { x: curve.p2.x - startPoint.x, y: curve.p2.y - startPoint.y },
        p3: { x: curve.p3.x - startPoint.x, y: curve.p3.y - startPoint.y },
      })
    } else {
      // 后续曲线:起点应该是前一条曲线的终点(相对于原始起点)
      const relativeP0 = { x: curve.p0.x - startPoint.x, y: curve.p0.y - startPoint.y }
      normalizedCurves.push({
        p0: relativeP0,
        p1: { x: curve.p1.x - startPoint.x, y: curve.p1.y - startPoint.y },
        p2: { x: curve.p2.x - startPoint.x, y: curve.p2.y - startPoint.y },
        p3: { x: curve.p3.x - startPoint.x, y: curve.p3.y - startPoint.y },
      })
    }
  })

  const totalLength = getTotalLength(normalizedCurves)

  return { normalizedCurves, startPoint, endPoint, totalLength }
}

/**
 * 变换点(旋转和平移)
 */
function transformPoint(point: Point, angle: number, origin: Point): Point {
  const cos = Math.cos(angle)
  const sin = Math.sin(angle)
  return {
    x: origin.x + point.x * cos - point.y * sin,
    y: origin.y + point.x * sin + point.y * cos,
  }
}

/**
 * 将归一化的贝塞尔曲线应用到新的起点和方向
 * @param useMoveTo 是否使用 M 命令(仅在路径开始时使用)
 */
function applyCurveToPath(
  curve: BezierCurve,
  startPoint: Point,
  angle: number,
  useMoveTo: boolean = false,
): { path: string; endPoint: Point } {
  // 对于归一化的曲线,p0 是 (0,0) 表示从 startPoint 开始
  // 所以所有点都应该相对于 startPoint 进行变换
  const p0 = transformPoint(curve.p0, angle, startPoint)
  const p1 = transformPoint(curve.p1, angle, startPoint)
  const p2 = transformPoint(curve.p2, angle, startPoint)
  const p3 = transformPoint(curve.p3, angle, startPoint)

  // 只有在明确要求时才使用 M 命令,否则使用 C 命令继续绘制
  // C 命令的所有坐标都是绝对坐标,所以直接使用变换后的坐标即可
  const path = useMoveTo
    ? `M ${p0.x} ${p0.y} C ${p1.x} ${p1.y} ${p2.x} ${p2.y} ${p3.x} ${p3.y}`
    : `C ${p1.x} ${p1.y} ${p2.x} ${p2.y} ${p3.x} ${p3.y}`

  return { path, endPoint: p3 }
}

/**
 * 使用贝塞尔曲线公式生成更长的路径
 * @param pathString 原始的 SVG path 字符串
 * @param fromX 新路径的起点 X 坐标
 * @param fromY 新路径的起点 Y 坐标
 * @param toX 新路径的终点 X 坐标
 * @param toY 新路径的终点 Y 坐标
 * @returns 新的 SVG path 字符串
 */
export function repeatSvgPath(pathString: string, fromX: number, fromY: number, toX: number, toY: number): string {
  // 解析原始路径,提取贝塞尔曲线
  const curves = parseBezierCurves(pathString)
  if (curves.length === 0) {
    return ''
  }

  // 归一化曲线(转换为相对于起点的形式)
  const { normalizedCurves, totalLength } = normalizeCurves(curves)

  // 计算目标长度和角度
  const targetLength = Math.sqrt((toX - fromX) ** 2 + (toY - fromY) ** 2)
  const angle = Math.atan2(toY - fromY, toX - fromX)

  // 计算需要重复的次数
  const repeatCount = Math.ceil(targetLength / totalLength)

  const pathParts: string[] = []
  let currentX = fromX
  let currentY = fromY

  for (let i = 0; i < repeatCount; i++) {
    // 计算当前段的长度(最后一段可能不完整)
    const remainingLength = targetLength - i * totalLength
    const segmentLength = i === repeatCount - 1 ? remainingLength : totalLength
    const scale = segmentLength / totalLength

    // 对每个归一化的曲线应用变换
    normalizedCurves.forEach((curve, curveIndex) => {
      // 缩放曲线
      const scaledCurve: BezierCurve = {
        p0: { x: curve.p0.x * scale, y: curve.p0.y * scale },
        p1: { x: curve.p1.x * scale, y: curve.p1.y * scale },
        p2: { x: curve.p2.x * scale, y: curve.p2.y * scale },
        p3: { x: curve.p3.x * scale, y: curve.p3.y * scale },
      }

      // 对于第一条曲线,起点应该是 currentX, currentY(即前一段的终点或初始起点)
      // 对于后续曲线,起点应该是前一条曲线的终点(已经在 currentX, currentY 中)
      const curveStartPoint = { x: currentX, y: currentY }

      // 调整曲线:确保所有曲线的起点都是相对于 curveStartPoint 的
      // 对于归一化的曲线,第一条曲线的 p0 是 (0,0),后续曲线的 p0 是相对于原始起点的偏移
      let adjustedCurve = scaledCurve

      if (curveIndex === 0) {
        // 第一条曲线:p0 应该是 (0,0),表示从 curveStartPoint 开始
        if (scaledCurve.p0.x !== 0 || scaledCurve.p0.y !== 0) {
          const offsetX = scaledCurve.p0.x
          const offsetY = scaledCurve.p0.y
          adjustedCurve = {
            p0: { x: 0, y: 0 },
            p1: { x: scaledCurve.p1.x - offsetX, y: scaledCurve.p1.y - offsetY },
            p2: { x: scaledCurve.p2.x - offsetX, y: scaledCurve.p2.y - offsetY },
            p3: { x: scaledCurve.p3.x - offsetX, y: scaledCurve.p3.y - offsetY },
          }
        }
      } else {
        // 后续曲线:p0 是相对于原始起点的偏移,需要调整为相对于前一条曲线的终点
        // 前一条曲线的终点是 normalizedCurves[curveIndex - 1].p3
        const prevCurveEnd = normalizedCurves[curveIndex - 1].p3
        const offsetX = scaledCurve.p0.x - prevCurveEnd.x * scale
        const offsetY = scaledCurve.p0.y - prevCurveEnd.y * scale
        adjustedCurve = {
          p0: { x: 0, y: 0 }, // 相对于当前起点(前一条曲线的终点)
          p1: { x: scaledCurve.p1.x - offsetX, y: scaledCurve.p1.y - offsetY },
          p2: { x: scaledCurve.p2.x - offsetX, y: scaledCurve.p2.y - offsetY },
          p3: { x: scaledCurve.p3.x - offsetX, y: scaledCurve.p3.y - offsetY },
        }
      }

      // 只有在第一段的第一条曲线时才使用 M 命令,其他情况都使用 C 命令继续绘制
      const useMoveTo = i === 0 && curveIndex === 0

      const { path, endPoint } = applyCurveToPath(adjustedCurve, curveStartPoint, angle, useMoveTo)

      pathParts.push(path)

      currentX = endPoint.x
      currentY = endPoint.y
    })
  }

  return pathParts.join(' ')
}

/**
 * 获取路径的起点和终点
 */
export function getPathEndpoints(pathString: string): { start: Point; end: Point } | null {
  const curves = parseBezierCurves(pathString)
  if (curves.length === 0) {
    return null
  }

  return {
    start: curves[0].p0,
    end: curves[curves.length - 1].p3,
  }
}

/**
 * 计算两点之间的角度(弧度)
 */
function calculateAngle(p1: Point, p2: Point): number {
  return Math.atan2(p2.y - p1.y, p2.x - p1.x)
}

/**
 * 生成波浪线的 SVG Path d 属性
 *
 * @param startPoint 起点坐标
 * @param endPoint 终点坐标
 * @param options 配置选项
 * @param options.waveLength 波形长度(每个波形的长度),默认 40
 * @param options.waveHeight 波形高度(波峰到波谷的距离),默认 10
 * @param options.curveSquaring 曲线平滑度(0-1),控制波形实际高度与给定高度的关系,默认 0.57
 * @returns SVG Path 的 d 属性字符串
 *
 * @example
 * ```ts
 * const path = createWavyLinePath(
 *   { x: 0, y: 50 },
 *   { x: 200, y: 50 },
 *   { waveLength: 40, waveHeight: 10 }
 * )
 * // 返回: "M 0 50 C ..."
 * ```
 */
export function createWavyLinePath(
  startPoint: Point,
  endPoint: Point,
  options: {
    waveLength?: number
    waveHeight?: number
    curveSquaring?: number
  } = {},
): string {
  const { waveLength = 40, waveHeight = 10, curveSquaring = 0.57 } = options

  const p1 = startPoint
  const p2 = endPoint

  // 计算两点之间的距离
  const distance = Math.sqrt((p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2)

  if (distance === 0) {
    // 如果起点和终点相同,返回一个简单的点
    return `M ${p1.x} ${p1.y}`
  }

  // 计算线段的角度(倾斜角)
  const angle = calculateAngle(p1, p2)

  // 计算需要多少个波形
  const howManyWaves = distance / waveLength
  // 实际波形间隔(确保波形均匀分布)
  const waveInterval = distance / howManyWaves

  // 计算最大控制点长度(当波形是三角形时的长度)
  // a = waveInterval / 4, b = waveHeight / 2
  // c = sqrt(a^2 + b^2)
  const maxBcpLength = Math.sqrt((waveInterval / 4.0) ** 2 + (waveHeight / 2.0) ** 2)

  // 计算实际控制点长度
  const bcpLength = maxBcpLength * curveSquaring

  // 计算摆角(控制点与线段的夹角)
  const bcpInclination = calculateAngle({ x: 0, y: 0 }, { x: waveInterval / 4.0, y: waveHeight / 2.0 })

  // 生成所有控制点和锚点
  const wigglePoints: Array<{ bcpOut: Point; bcpIn: Point; anchor: Point }> = []
  let prevFlexPt = p1
  let polarity = 1 // 用于交替波形方向

  // 生成波形点(每个波形需要两个贝塞尔曲线段)
  for (let waveIndex = 0; waveIndex < Math.ceil(howManyWaves * 2); waveIndex++) {
    // 计算输出控制点(控制前一段曲线的结束)
    const bcpOutAngle = angle + bcpInclination * polarity
    const bcpOut: Point = {
      x: prevFlexPt.x + Math.cos(bcpOutAngle) * bcpLength,
      y: prevFlexPt.y + Math.sin(bcpOutAngle) * bcpLength,
    }

    // 计算当前锚点(波形上的点)
    const flexPt: Point = {
      x: prevFlexPt.x + Math.cos(angle) * (waveInterval / 2.0),
      y: prevFlexPt.y + Math.sin(angle) * (waveInterval / 2.0),
    }

    // 计算输入控制点(控制当前段曲线的开始)
    const bcpInAngle = angle + (Math.PI - bcpInclination) * polarity
    const bcpIn: Point = {
      x: flexPt.x + Math.cos(bcpInAngle) * bcpLength,
      y: flexPt.y + Math.sin(bcpInAngle) * bcpLength,
    }

    // 确保不超过终点
    const distanceToEnd = Math.sqrt((flexPt.x - p2.x) ** 2 + (flexPt.y - p2.y) ** 2)
    if (distanceToEnd < waveInterval / 2.0) {
      // 如果接近终点,直接使用终点作为锚点
      wigglePoints.push({
        bcpOut,
        bcpIn: p2,
        anchor: p2,
      })
      break
    }

    wigglePoints.push({ bcpOut, bcpIn, anchor: flexPt })

    // 交替波形方向
    polarity *= -1
    prevFlexPt = flexPt
  }

  if (wigglePoints.length === 0) {
    // 如果没有生成任何点,直接返回直线
    return `M ${p1.x} ${p1.y} L ${p2.x} ${p2.y}`
  }

  // 构建 SVG Path 字符串
  const pathParts: string[] = []

  // 移动到起点
  pathParts.push(`M ${p1.x} ${p1.y}`)

  // 添加所有贝塞尔曲线段
  for (let i = 0; i < wigglePoints.length; i++) {
    const { bcpOut, bcpIn, anchor } = wigglePoints[i]
    pathParts.push(`C ${bcpOut.x} ${bcpOut.y} ${bcpIn.x} ${bcpIn.y} ${anchor.x} ${anchor.y}`)
  }

  // 确保最后到达终点(如果最后一个锚点不是终点)
  const lastAnchor = wigglePoints[wigglePoints.length - 1].anchor
  const distanceToEnd = Math.sqrt((lastAnchor.x - p2.x) ** 2 + (lastAnchor.y - p2.y) ** 2)
  if (distanceToEnd > 0.1) {
    // 如果最后一个锚点不是终点,添加一条直线到终点
    pathParts.push(`L ${p2.x} ${p2.y}`)
  }

  return pathParts.join(' ')
}