diff options
Diffstat (limited to 'apps/web/src/masks/freeform/path.ts')
| -rw-r--r-- | apps/web/src/masks/freeform/path.ts | 835 |
1 files changed, 835 insertions, 0 deletions
diff --git a/apps/web/src/masks/freeform/path.ts b/apps/web/src/masks/freeform/path.ts new file mode 100644 index 00000000..fa6217c2 --- /dev/null +++ b/apps/web/src/masks/freeform/path.ts @@ -0,0 +1,835 @@ +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(" "); +} |
