summaryrefslogtreecommitdiff
path: root/apps/web/src/masks/custom-path.ts
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-04-29 18:41:15 +0200
committerMaze Winther <mazewinther@gmail.com>2026-05-02 03:27:23 +0200
commit79dcec8a2ec2f5cad9be95e5b52fbfa752fb6ac4 (patch)
tree99ff9393c4a550669ebac921c6a0535437a39413 /apps/web/src/masks/custom-path.ts
parent97f8d1968f186028e41f83b04c0897130e6c69f4 (diff)
downloadopencut-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.ts835
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(" ");
-}