diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-04-15 15:45:50 +0200 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-04-15 15:45:50 +0200 |
| commit | c46d28891fee62ca2d3132da876af6411a41d9da (patch) | |
| tree | f856f71d04c69628f26b904088d675dd298a5080 /apps/web/src/lib/masks/custom-path.ts | |
| parent | 66e4367bc4727f1d0bcf8f869529c7518812277e (diff) | |
| download | opencut-c46d28891fee62ca2d3132da876af6411a41d9da.tar.gz | |
feat: text / custom masks, fix preview pan, refactor selection
Diffstat (limited to 'apps/web/src/lib/masks/custom-path.ts')
| -rw-r--r-- | apps/web/src/lib/masks/custom-path.ts | 842 |
1 files changed, 842 insertions, 0 deletions
diff --git a/apps/web/src/lib/masks/custom-path.ts b/apps/web/src/lib/masks/custom-path.ts new file mode 100644 index 00000000..94a9043c --- /dev/null +++ b/apps/web/src/lib/masks/custom-path.ts @@ -0,0 +1,842 @@ +import type { ElementBounds } from "@/lib/preview/element-bounds"; + +export interface CustomMaskPathPoint { + id: string; + x: number; + y: number; + inX: number; + inY: number; + outX: number; + outY: number; +} + +export type CustomMaskHandlePart = "anchor" | "in" | "out"; + +function isCustomMaskPathPoint(value: unknown): value is CustomMaskPathPoint { + if (!value || typeof value !== "object") { + return false; + } + + const candidate = value as Record<string, unknown>; + return ( + typeof candidate.id === "string" && + typeof candidate.x === "number" && + typeof candidate.y === "number" && + typeof candidate.inX === "number" && + typeof candidate.inY === "number" && + typeof candidate.outX === "number" && + typeof candidate.outY === "number" + ); +} + +export function parseCustomMaskHandleId({ + handleId, +}: { + handleId: string; +}): { pointId: string; part: CustomMaskHandlePart } | null { + const match = /^point:(.+):(anchor|in|out)$/.exec(handleId); + if (!match) { + return null; + } + + return { + pointId: match[1], + part: match[2] as CustomMaskHandlePart, + }; +} + +export function parseCustomMaskSegmentHandleId({ + handleId, +}: { + handleId: string; +}): { segmentIndex: number } | null { + const match = /^segment:(\d+)$/.exec(handleId); + if (!match) { + return null; + } + + return { + segmentIndex: Number.parseInt(match[1], 10), + }; +} + +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: CanvasPoint, b: CanvasPoint): number { + const dx = a.x - b.x; + const dy = a.y - b.y; + return dx * dx + dy * dy; +} + +function lerpPoint(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(canvasPoint, 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(canvasPoint, 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(canvasPoint, 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(p0, p1, clampedT); + const p12 = lerpPoint(p1, p2, clampedT); + const p23 = lerpPoint(p2, p3, clampedT); + const p012 = lerpPoint(p01, p12, clampedT); + const p123 = lerpPoint(p12, p23, clampedT); + const splitPoint = lerpPoint(p012, p123, 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(" "); +} |
