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(' ')
}