From 3e89d2998588f8e35c524e523ba2ee853f0f6475 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Mon, 20 Apr 2026 11:31:17 +0200 Subject: refactor: restructure files to their domains, new preview overlay system, and dep graph --- apps/web/src/lib/masks/custom-path.ts | 842 ---------------------------------- 1 file changed, 842 deletions(-) delete mode 100644 apps/web/src/lib/masks/custom-path.ts (limited to 'apps/web/src/lib/masks/custom-path.ts') diff --git a/apps/web/src/lib/masks/custom-path.ts b/apps/web/src/lib/masks/custom-path.ts deleted file mode 100644 index 94a9043c..00000000 --- a/apps/web/src/lib/masks/custom-path.ts +++ /dev/null @@ -1,842 +0,0 @@ -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; - 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(" "); -} -- cgit v1.3.1