import type { ElementBounds } from "@/preview/element-bounds"; export interface FreeformPathPoint { id: string; x: number; y: number; inX: number; inY: number; outX: number; outY: number; } function isFreeformPathPoint(value: unknown): value is FreeformPathPoint { if (!value || typeof value !== "object") { return false; } return ( "id" in value && typeof value.id === "string" && "x" in value && typeof value.x === "number" && "y" in value && typeof value.y === "number" && "inX" in value && typeof value.inX === "number" && "inY" in value && typeof value.inY === "number" && "outX" in value && typeof value.outX === "number" && "outY" in value && typeof value.outY === "number" ); } export function parseFreeformPath({ path, }: { path: string; }): FreeformPathPoint[] { if (!path) { return []; } try { const parsed = JSON.parse(path); return Array.isArray(parsed) ? parsed.filter(isFreeformPathPoint) : []; } catch { return []; } } export function serializeFreeformPath({ points, }: { points: FreeformPathPoint[]; }): string { return JSON.stringify(points); } export function removeFreeformPathPoints({ points, pointIds, }: { points: FreeformPathPoint[]; pointIds: string[]; }): FreeformPathPoint[] { if (pointIds.length === 0) { return points; } const pointIdsToRemove = new Set(pointIds); return points.filter((point) => !pointIdsToRemove.has(point.id)); } export function getFreeformPathClosedStateAfterPointRemoval({ wasClosed, remainingPointCount, }: { wasClosed: boolean; remainingPointCount: number; }): boolean { return wasClosed && remainingPointCount >= 3; } function rotatePoint({ x, y, rotationDegrees, }: { x: number; y: number; rotationDegrees: number; }): { x: number; y: number } { const angleRad = (rotationDegrees * Math.PI) / 180; const cos = Math.cos(angleRad); const sin = Math.sin(angleRad); return { x: x * cos - y * sin, y: x * sin + y * cos, }; } export function getFreeformCenterCanvasPoint({ centerX, centerY, bounds, }: { centerX: number; centerY: number; bounds: ElementBounds; }): { x: number; y: number } { return { x: bounds.cx + centerX * bounds.width, y: bounds.cy + centerY * bounds.height, }; } export function freeformLocalPointToCanvas({ point, centerX, centerY, rotation, scale, bounds, }: { point: { x: number; y: number }; centerX: number; centerY: number; rotation: number; scale: number; bounds: ElementBounds; }): { x: number; y: number } { const center = getFreeformCenterCanvasPoint({ centerX, centerY, bounds }); const scaledLocal = { x: point.x * bounds.width * scale, y: point.y * bounds.height * scale, }; const rotated = rotatePoint({ x: scaledLocal.x, y: scaledLocal.y, rotationDegrees: rotation, }); return { x: center.x + rotated.x, y: center.y + rotated.y, }; } export function freeformCanvasPointToLocal({ point, centerX, centerY, rotation, scale, bounds, }: { point: { x: number; y: number }; centerX: number; centerY: number; rotation: number; scale: number; bounds: ElementBounds; }): { x: number; y: number } { const center = getFreeformCenterCanvasPoint({ centerX, centerY, bounds }); const translated = { x: point.x - center.x, y: point.y - center.y, }; const rotated = rotatePoint({ x: translated.x, y: translated.y, rotationDegrees: -rotation, }); return { x: bounds.width === 0 ? 0 : rotated.x / (bounds.width * scale), y: bounds.height === 0 ? 0 : rotated.y / (bounds.height * scale), }; } export function getFreeformCanvasGeometry({ points, centerX, centerY, rotation, scale, bounds, }: { points: FreeformPathPoint[]; centerX: number; centerY: number; rotation: number; scale: number; bounds: ElementBounds; }) { const anchors = points.map((point) => ({ id: point.id, anchor: freeformLocalPointToCanvas({ point: { x: point.x, y: point.y }, centerX, centerY, rotation, scale, bounds, }), inHandle: freeformLocalPointToCanvas({ point: { x: point.x + point.inX, y: point.y + point.inY }, centerX, centerY, rotation, scale, bounds, }), outHandle: freeformLocalPointToCanvas({ point: { x: point.x + point.outX, y: point.y + point.outY }, centerX, centerY, rotation, scale, bounds, }), })); const geometryPoints = anchors.flatMap((point) => [ point.anchor, point.inHandle, point.outHandle, ]); if (geometryPoints.length === 0) { return { anchors, bounds: null, }; } const geometryBounds = getCanvasPointBounds({ points: geometryPoints, }); return { anchors, bounds: geometryBounds, }; } export interface CanvasPoint { x: number; y: number; } function getCanvasPointBounds({ points }: { points: CanvasPoint[] }): { minX: number; maxX: number; minY: number; maxY: number; width: number; height: number; centerX: number; centerY: number; } | null { if (points.length === 0) { return null; } const [firstPoint, ...restPoints] = points; let minX = firstPoint.x; let maxX = firstPoint.x; let minY = firstPoint.y; let maxY = firstPoint.y; for (const point of restPoints) { minX = Math.min(minX, point.x); maxX = Math.max(maxX, point.x); minY = Math.min(minY, point.y); maxY = Math.max(maxY, point.y); } return { minX, maxX, minY, maxY, width: Math.max(1, maxX - minX), height: Math.max(1, maxY - minY), centerX: (minX + maxX) / 2, centerY: (minY + maxY) / 2, }; } export interface FreeformCanvasSegment { index: number; startPointId: string; endPointId: string; start: CanvasPoint; startOut: CanvasPoint; endIn: CanvasPoint; end: CanvasPoint; pathData: string; } function clampUnit(value: number): number { return Math.min(1, Math.max(0, value)); } function getDistanceSquared({ a, b, }: { a: CanvasPoint; b: CanvasPoint; }): number { const dx = a.x - b.x; const dy = a.y - b.y; return dx * dx + dy * dy; } function lerpPoint({ a, b, t, }: { a: CanvasPoint; b: CanvasPoint; t: number; }): CanvasPoint { return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t, }; } function evaluateCubicBezier({ p0, p1, p2, p3, t, }: { p0: CanvasPoint; p1: CanvasPoint; p2: CanvasPoint; p3: CanvasPoint; t: number; }): CanvasPoint { const oneMinusT = 1 - t; return { x: oneMinusT ** 3 * p0.x + 3 * oneMinusT ** 2 * t * p1.x + 3 * oneMinusT * t ** 2 * p2.x + t ** 3 * p3.x, y: oneMinusT ** 3 * p0.y + 3 * oneMinusT ** 2 * t * p1.y + 3 * oneMinusT * t ** 2 * p2.y + t ** 3 * p3.y, }; } function getFreeformSegmentIndices({ points, segmentIndex, closed, }: { points: FreeformPathPoint[]; segmentIndex: number; closed: boolean; }): { startIndex: number; endIndex: number } | null { const segmentCount = getFreeformSegmentCount({ points, closed }); if (segmentIndex < 0 || segmentIndex >= segmentCount) { return null; } return { startIndex: segmentIndex, endIndex: (segmentIndex + 1) % points.length, }; } export function getFreeformSegmentCount({ points, closed, }: { points: FreeformPathPoint[]; closed: boolean; }): number { if (points.length < 2) { return 0; } return closed ? points.length : points.length - 1; } export function getFreeformCanvasSegments({ points, centerX, centerY, rotation, scale, bounds, closed, }: { points: FreeformPathPoint[]; centerX: number; centerY: number; rotation: number; scale: number; bounds: ElementBounds; closed: boolean; }): FreeformCanvasSegment[] { const geometry = getFreeformCanvasGeometry({ points, centerX, centerY, rotation, scale, bounds, }); const segmentCount = getFreeformSegmentCount({ points, closed }); return Array.from({ length: segmentCount }, (_, segmentIndex) => { const start = geometry.anchors[segmentIndex]; const end = geometry.anchors[(segmentIndex + 1) % geometry.anchors.length]; return { index: segmentIndex, startPointId: start.id, endPointId: end.id, start: start.anchor, startOut: start.outHandle, endIn: end.inHandle, end: end.anchor, pathData: `M ${start.anchor.x},${start.anchor.y} C ${start.outHandle.x},${start.outHandle.y} ${end.inHandle.x},${end.inHandle.y} ${end.anchor.x},${end.anchor.y}`, }; }); } export function findClosestPointOnFreeformSegment({ points, segmentIndex, canvasPoint, centerX, centerY, rotation, scale, bounds, closed, }: { points: FreeformPathPoint[]; segmentIndex: number; canvasPoint: CanvasPoint; centerX: number; centerY: number; rotation: number; scale: number; bounds: ElementBounds; closed: boolean; }): { t: number; point: CanvasPoint } | null { const segment = getFreeformCanvasSegments({ points, centerX, centerY, rotation, scale, bounds, closed, }).find((candidate) => candidate.index === segmentIndex); if (!segment) { return null; } const sampleCount = 24; let bestT = 0; let bestDistanceSquared = getDistanceSquared({ a: canvasPoint, b: segment.start, }); for (let step = 0; step <= sampleCount; step++) { const t = step / sampleCount; const point = evaluateCubicBezier({ p0: segment.start, p1: segment.startOut, p2: segment.endIn, p3: segment.end, t, }); const distanceSquared = getDistanceSquared({ a: canvasPoint, b: point }); if (distanceSquared < bestDistanceSquared) { bestDistanceSquared = distanceSquared; bestT = t; } } let searchStep = 1 / sampleCount; for (let iteration = 0; iteration < 8; iteration++) { const candidates = [bestT - searchStep, bestT, bestT + searchStep] .map(clampUnit) .map((t) => ({ t, point: evaluateCubicBezier({ p0: segment.start, p1: segment.startOut, p2: segment.endIn, p3: segment.end, t, }), })); for (const candidate of candidates) { const distanceSquared = getDistanceSquared({ a: canvasPoint, b: candidate.point, }); if (distanceSquared < bestDistanceSquared) { bestDistanceSquared = distanceSquared; bestT = candidate.t; } } searchStep /= 2; } const clampedT = Math.min(0.999, Math.max(0.001, bestT)); return { t: clampedT, point: evaluateCubicBezier({ p0: segment.start, p1: segment.startOut, p2: segment.endIn, p3: segment.end, t: clampedT, }), }; } export function insertPointIntoFreeformSegment({ points, segmentIndex, pointId, t, closed, }: { points: FreeformPathPoint[]; segmentIndex: number; pointId: string; t: number; closed: boolean; }): FreeformPathPoint[] { const indices = getFreeformSegmentIndices({ points, segmentIndex, closed, }); if (!indices) { return points; } const startPoint = points[indices.startIndex]; const endPoint = points[indices.endIndex]; const clampedT = Math.min(0.999, Math.max(0.001, t)); const p0 = { x: startPoint.x, y: startPoint.y }; const p1 = { x: startPoint.x + startPoint.outX, y: startPoint.y + startPoint.outY, }; const p2 = { x: endPoint.x + endPoint.inX, y: endPoint.y + endPoint.inY, }; const p3 = { x: endPoint.x, y: endPoint.y }; const p01 = lerpPoint({ a: p0, b: p1, t: clampedT }); const p12 = lerpPoint({ a: p1, b: p2, t: clampedT }); const p23 = lerpPoint({ a: p2, b: p3, t: clampedT }); const p012 = lerpPoint({ a: p01, b: p12, t: clampedT }); const p123 = lerpPoint({ a: p12, b: p23, t: clampedT }); const splitPoint = lerpPoint({ a: p012, b: p123, t: clampedT }); const nextPoints = [...points]; nextPoints[indices.startIndex] = { ...startPoint, outX: p01.x - startPoint.x, outY: p01.y - startPoint.y, }; nextPoints[indices.endIndex] = { ...endPoint, inX: p23.x - endPoint.x, inY: p23.y - endPoint.y, }; nextPoints.splice(indices.endIndex, 0, { id: pointId, x: splitPoint.x, y: splitPoint.y, inX: p012.x - splitPoint.x, inY: p012.y - splitPoint.y, outX: p123.x - splitPoint.x, outY: p123.y - splitPoint.y, }); return nextPoints; } export function getFreeformLocalBounds({ points, bounds, }: { points: FreeformPathPoint[]; bounds: ElementBounds; }) { if (points.length === 0) { return null; } const values = points.flatMap((point) => [ { x: point.x * bounds.width, y: point.y * bounds.height }, { x: (point.x + point.inX) * bounds.width, y: (point.y + point.inY) * bounds.height, }, { x: (point.x + point.outX) * bounds.width, y: (point.y + point.outY) * bounds.height, }, ]); const localBounds = getCanvasPointBounds({ points: values, }); if (!localBounds) { return null; } return { width: localBounds.width, height: localBounds.height, }; } export function recenterFreeformPath({ points, centerX, centerY, rotation, scale, bounds, }: { points: FreeformPathPoint[]; centerX: number; centerY: number; rotation: number; scale: number; bounds: ElementBounds; }) { if (points.length === 0) { return { centerX, centerY, points }; } const geometry = getFreeformCanvasGeometry({ points, centerX, centerY, rotation, scale, bounds, }); if (!geometry.bounds) { return { centerX, centerY, points }; } const nextCenterCanvas = { x: geometry.bounds.centerX, y: geometry.bounds.centerY, }; const nextCenterLocal = { x: bounds.width === 0 ? 0 : (nextCenterCanvas.x - bounds.cx) / bounds.width, y: bounds.height === 0 ? 0 : (nextCenterCanvas.y - bounds.cy) / bounds.height, }; const nextPoints = geometry.anchors.map((point) => { const anchor = freeformCanvasPointToLocal({ point: point.anchor, centerX: nextCenterLocal.x, centerY: nextCenterLocal.y, rotation, scale, bounds, }); const inHandle = freeformCanvasPointToLocal({ point: point.inHandle, centerX: nextCenterLocal.x, centerY: nextCenterLocal.y, rotation, scale, bounds, }); const outHandle = freeformCanvasPointToLocal({ point: point.outHandle, centerX: nextCenterLocal.x, centerY: nextCenterLocal.y, rotation, scale, bounds, }); return { id: point.id, x: anchor.x, y: anchor.y, inX: inHandle.x - anchor.x, inY: inHandle.y - anchor.y, outX: outHandle.x - anchor.x, outY: outHandle.y - anchor.y, }; }); return { centerX: nextCenterLocal.x, centerY: nextCenterLocal.y, points: nextPoints, }; } export function buildFreeformPath2D({ points, centerX, centerY, rotation, scale, bounds, closed, }: { points: FreeformPathPoint[]; centerX: number; centerY: number; rotation: number; scale: number; bounds: ElementBounds; closed: boolean; }): Path2D { const path = new Path2D(); if (points.length === 0) { return path; } const geometry = getFreeformCanvasGeometry({ points, centerX, centerY, rotation, scale, bounds, }); const anchors = geometry.anchors; path.moveTo(anchors[0].anchor.x, anchors[0].anchor.y); for (let index = 1; index < anchors.length; index++) { const previous = anchors[index - 1]; const current = anchors[index]; path.bezierCurveTo( previous.outHandle.x, previous.outHandle.y, current.inHandle.x, current.inHandle.y, current.anchor.x, current.anchor.y, ); } if (closed && anchors.length > 1) { const last = anchors[anchors.length - 1]; const first = anchors[0]; path.bezierCurveTo( last.outHandle.x, last.outHandle.y, first.inHandle.x, first.inHandle.y, first.anchor.x, first.anchor.y, ); path.closePath(); } return path; } export function buildFreeformSvgPath({ points, centerX, centerY, rotation, scale, bounds, closed, }: { points: FreeformPathPoint[]; centerX: number; centerY: number; rotation: number; scale: number; bounds: ElementBounds; closed: boolean; }): string { if (points.length === 0) { return ""; } const geometry = getFreeformCanvasGeometry({ points, centerX, centerY, rotation, scale, bounds, }); const anchors = geometry.anchors; const segments = [`M ${anchors[0].anchor.x},${anchors[0].anchor.y}`]; for (let index = 1; index < anchors.length; index++) { const previous = anchors[index - 1]; const current = anchors[index]; segments.push( `C ${previous.outHandle.x},${previous.outHandle.y} ${current.inHandle.x},${current.inHandle.y} ${current.anchor.x},${current.anchor.y}`, ); } if (closed && anchors.length > 1) { const last = anchors[anchors.length - 1]; const first = anchors[0]; segments.push( `C ${last.outHandle.x},${last.outHandle.y} ${first.inHandle.x},${first.inHandle.y} ${first.anchor.x},${first.anchor.y}`, ); segments.push("Z"); } return segments.join(" "); }