diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-04-29 18:41:15 +0200 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-05-02 03:27:23 +0200 |
| commit | 79dcec8a2ec2f5cad9be95e5b52fbfa752fb6ac4 (patch) | |
| tree | 99ff9393c4a550669ebac921c6a0535437a39413 /apps/web/src/masks/custom-path.ts | |
| parent | 97f8d1968f186028e41f83b04c0897130e6c69f4 (diff) | |
| download | opencut-79dcec8a2ec2f5cad9be95e5b52fbfa752fb6ac4.tar.gz | |
refactor: split builtin and freeform masks
Made-with: Cursor
Diffstat (limited to 'apps/web/src/masks/custom-path.ts')
| -rw-r--r-- | apps/web/src/masks/custom-path.ts | 835 |
1 files changed, 0 insertions, 835 deletions
diff --git a/apps/web/src/masks/custom-path.ts b/apps/web/src/masks/custom-path.ts deleted file mode 100644 index 363bb3bd..00000000 --- a/apps/web/src/masks/custom-path.ts +++ /dev/null @@ -1,835 +0,0 @@ -import type { ElementBounds } from "@/preview/element-bounds"; - -export interface CustomMaskPathPoint { - id: string; - x: number; - y: number; - inX: number; - inY: number; - outX: number; - outY: number; -} - -function isCustomMaskPathPoint(value: unknown): value is CustomMaskPathPoint { - 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 parseCustomMaskPath({ - path, -}: { - path: string; -}): CustomMaskPathPoint[] { - if (!path) { - return []; - } - - try { - const parsed = JSON.parse(path); - return Array.isArray(parsed) ? parsed.filter(isCustomMaskPathPoint) : []; - } catch { - return []; - } -} - -export function serializeCustomMaskPath({ - points, -}: { - points: CustomMaskPathPoint[]; -}): string { - return JSON.stringify(points); -} - -export function removeCustomMaskPoints({ - points, - pointIds, -}: { - points: CustomMaskPathPoint[]; - pointIds: string[]; -}): CustomMaskPathPoint[] { - if (pointIds.length === 0) { - return points; - } - const pointIdsToRemove = new Set(pointIds); - return points.filter((point) => !pointIdsToRemove.has(point.id)); -} - -export function getCustomMaskClosedStateAfterPointRemoval({ - 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 getCustomMaskCenterCanvasPoint({ - 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 customMaskLocalPointToCanvas({ - 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 = getCustomMaskCenterCanvasPoint({ 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 customMaskCanvasPointToLocal({ - 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 = getCustomMaskCenterCanvasPoint({ 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 getCustomMaskCanvasGeometry({ - points, - centerX, - centerY, - rotation, - scale, - bounds, -}: { - points: CustomMaskPathPoint[]; - centerX: number; - centerY: number; - rotation: number; - scale: number; - bounds: ElementBounds; -}) { - const anchors = points.map((point) => ({ - id: point.id, - anchor: customMaskLocalPointToCanvas({ - point: { x: point.x, y: point.y }, - centerX, - centerY, - rotation, - scale, - bounds, - }), - inHandle: customMaskLocalPointToCanvas({ - point: { x: point.x + point.inX, y: point.y + point.inY }, - centerX, - centerY, - rotation, - scale, - bounds, - }), - outHandle: customMaskLocalPointToCanvas({ - 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 CustomMaskCanvasSegment { - 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 getCustomMaskSegmentIndices({ - points, - segmentIndex, - closed, -}: { - points: CustomMaskPathPoint[]; - segmentIndex: number; - closed: boolean; -}): { startIndex: number; endIndex: number } | null { - const segmentCount = getCustomMaskSegmentCount({ points, closed }); - if (segmentIndex < 0 || segmentIndex >= segmentCount) { - return null; - } - - return { - startIndex: segmentIndex, - endIndex: (segmentIndex + 1) % points.length, - }; -} - -export function getCustomMaskSegmentCount({ - points, - closed, -}: { - points: CustomMaskPathPoint[]; - closed: boolean; -}): number { - if (points.length < 2) { - return 0; - } - return closed ? points.length : points.length - 1; -} - -export function getCustomMaskCanvasSegments({ - points, - centerX, - centerY, - rotation, - scale, - bounds, - closed, -}: { - points: CustomMaskPathPoint[]; - centerX: number; - centerY: number; - rotation: number; - scale: number; - bounds: ElementBounds; - closed: boolean; -}): CustomMaskCanvasSegment[] { - const geometry = getCustomMaskCanvasGeometry({ - points, - centerX, - centerY, - rotation, - scale, - bounds, - }); - const segmentCount = getCustomMaskSegmentCount({ 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 findClosestPointOnCustomMaskSegment({ - points, - segmentIndex, - canvasPoint, - centerX, - centerY, - rotation, - scale, - bounds, - closed, -}: { - points: CustomMaskPathPoint[]; - segmentIndex: number; - canvasPoint: CanvasPoint; - centerX: number; - centerY: number; - rotation: number; - scale: number; - bounds: ElementBounds; - closed: boolean; -}): { t: number; point: CanvasPoint } | null { - const segment = getCustomMaskCanvasSegments({ - 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 insertPointIntoCustomMaskSegment({ - points, - segmentIndex, - pointId, - t, - closed, -}: { - points: CustomMaskPathPoint[]; - segmentIndex: number; - pointId: string; - t: number; - closed: boolean; -}): CustomMaskPathPoint[] { - const indices = getCustomMaskSegmentIndices({ - 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 getCustomMaskLocalBounds({ - points, - bounds, -}: { - points: CustomMaskPathPoint[]; - 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 recenterCustomMaskPath({ - points, - centerX, - centerY, - rotation, - scale, - bounds, -}: { - points: CustomMaskPathPoint[]; - centerX: number; - centerY: number; - rotation: number; - scale: number; - bounds: ElementBounds; -}) { - if (points.length === 0) { - return { centerX, centerY, points }; - } - - const geometry = getCustomMaskCanvasGeometry({ - 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 = customMaskCanvasPointToLocal({ - point: point.anchor, - centerX: nextCenterLocal.x, - centerY: nextCenterLocal.y, - rotation, - scale, - bounds, - }); - const inHandle = customMaskCanvasPointToLocal({ - point: point.inHandle, - centerX: nextCenterLocal.x, - centerY: nextCenterLocal.y, - rotation, - scale, - bounds, - }); - const outHandle = customMaskCanvasPointToLocal({ - 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 buildCustomMaskPath2D({ - points, - centerX, - centerY, - rotation, - scale, - bounds, - closed, -}: { - points: CustomMaskPathPoint[]; - 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 = getCustomMaskCanvasGeometry({ - 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 buildCustomMaskSvgPath({ - points, - centerX, - centerY, - rotation, - scale, - bounds, - closed, -}: { - points: CustomMaskPathPoint[]; - centerX: number; - centerY: number; - rotation: number; - scale: number; - bounds: ElementBounds; - closed: boolean; -}): string { - if (points.length === 0) { - return ""; - } - - const geometry = getCustomMaskCanvasGeometry({ - 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(" "); -} |
