summaryrefslogtreecommitdiff
path: root/apps/web/src/lib/masks
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/lib/masks')
-rw-r--r--apps/web/src/lib/masks/__tests__/snap.test.ts487
-rw-r--r--apps/web/src/lib/masks/custom-path.ts842
-rw-r--r--apps/web/src/lib/masks/definitions/box-like.ts313
-rw-r--r--apps/web/src/lib/masks/definitions/cinematic-bars.ts135
-rw-r--r--apps/web/src/lib/masks/definitions/custom.ts652
-rw-r--r--apps/web/src/lib/masks/definitions/diamond.ts100
-rw-r--r--apps/web/src/lib/masks/definitions/ellipse.ts75
-rw-r--r--apps/web/src/lib/masks/definitions/heart.ts142
-rw-r--r--apps/web/src/lib/masks/definitions/index.ts74
-rw-r--r--apps/web/src/lib/masks/definitions/rectangle.ts97
-rw-r--r--apps/web/src/lib/masks/definitions/split.ts382
-rw-r--r--apps/web/src/lib/masks/definitions/star.ts140
-rw-r--r--apps/web/src/lib/masks/definitions/text.ts438
-rw-r--r--apps/web/src/lib/masks/dimensions.ts2
-rw-r--r--apps/web/src/lib/masks/feather.ts2
-rw-r--r--apps/web/src/lib/masks/geometry.ts121
-rw-r--r--apps/web/src/lib/masks/handle-positions.ts497
-rw-r--r--apps/web/src/lib/masks/index.ts18
-rw-r--r--apps/web/src/lib/masks/param-update.ts26
-rw-r--r--apps/web/src/lib/masks/registry.ts122
-rw-r--r--apps/web/src/lib/masks/snap.ts336
-rw-r--r--apps/web/src/lib/masks/types.ts298
-rw-r--r--apps/web/src/lib/masks/utils.ts64
23 files changed, 0 insertions, 5363 deletions
diff --git a/apps/web/src/lib/masks/__tests__/snap.test.ts b/apps/web/src/lib/masks/__tests__/snap.test.ts
deleted file mode 100644
index e2f9cafb..00000000
--- a/apps/web/src/lib/masks/__tests__/snap.test.ts
+++ /dev/null
@@ -1,487 +0,0 @@
-import { describe, expect, test } from "bun:test";
-import {
- findClosestPointOnCustomMaskSegment,
- getCustomMaskClosedStateAfterPointRemoval,
- insertPointIntoCustomMaskSegment,
- removeCustomMaskPoints,
-} from "@/lib/masks/custom-path";
-import {
- appendPointToCustomMask,
- customMaskDefinition,
- insertPointOnCustomMaskSegment,
-} from "@/lib/masks/definitions/custom";
-import { getSplitMaskStrokeSegment } from "@/lib/masks/definitions/split";
-import { textMaskDefinition } from "@/lib/masks/definitions/text";
-import { getMaskSnapGeometry } from "@/lib/masks/geometry";
-import { snapMaskInteraction } from "@/lib/masks/snap";
-import type { ElementBounds } from "@/lib/preview/element-bounds";
-import type {
- CustomMaskParams,
- RectangleMaskParams,
- SplitMaskParams,
- TextMaskParams,
-} from "@/lib/masks/types";
-
-const bounds: ElementBounds = {
- cx: 200,
- cy: 150,
- width: 200,
- height: 100,
- rotation: 0,
-};
-
-const canvasSize = {
- width: 400,
- height: 300,
-};
-
-const snapThreshold = {
- x: 8,
- y: 8,
-};
-
-function buildSplitParams(
- overrides: Partial<SplitMaskParams> = {},
-): SplitMaskParams {
- return {
- feather: 0,
- inverted: false,
- strokeColor: "#ffffff",
- strokeWidth: 0,
- strokeAlign: "center",
- centerX: 0,
- centerY: 0,
- rotation: 0,
- ...overrides,
- };
-}
-
-function buildRectangleParams(
- overrides: Partial<RectangleMaskParams> = {},
-): RectangleMaskParams {
- return {
- feather: 0,
- inverted: false,
- strokeColor: "#ffffff",
- strokeWidth: 0,
- strokeAlign: "center",
- centerX: 0,
- centerY: 0,
- width: 0.4,
- height: 0.2,
- rotation: 0,
- scale: 1,
- ...overrides,
- };
-}
-
-function buildTextMaskParams(
- overrides: Partial<TextMaskParams> = {},
-): TextMaskParams {
- return {
- feather: 0,
- inverted: false,
- strokeColor: "#ffffff",
- strokeWidth: 0,
- strokeAlign: "center",
- content: "Mask",
- fontSize: 15,
- fontFamily: "Arial",
- fontWeight: "normal",
- fontStyle: "normal",
- textDecoration: "none",
- letterSpacing: 0,
- lineHeight: 1.2,
- centerX: 0,
- centerY: 0,
- rotation: 0,
- scale: 1,
- ...overrides,
- };
-}
-
-function buildCustomMaskParams(
- overrides: Partial<CustomMaskParams> = {},
-): CustomMaskParams {
- return {
- feather: 0,
- inverted: false,
- strokeColor: "#ffffff",
- strokeWidth: 0,
- strokeAlign: "center",
- path: [
- {
- id: "a",
- x: -0.2,
- y: -0.1,
- inX: 0,
- inY: 0,
- outX: 0,
- outY: 0,
- },
- {
- id: "b",
- x: 0.2,
- y: -0.1,
- inX: 0,
- inY: 0,
- outX: 0,
- outY: 0,
- },
- {
- id: "c",
- x: 0,
- y: 0.2,
- inX: 0,
- inY: 0,
- outX: 0,
- outY: 0,
- },
- ],
- closed: true,
- centerX: 0,
- centerY: 0,
- rotation: 0,
- scale: 1,
- ...overrides,
- };
-}
-
-function sortSegment(
- segment: [{ x: number; y: number }, { x: number; y: number }],
-): [{ x: number; y: number }, { x: number; y: number }] {
- return [...segment].sort((a, b) => (a.x === b.x ? a.y - b.y : a.x - b.x)) as [
- { x: number; y: number },
- { x: number; y: number },
- ];
-}
-
-describe("mask geometry", () => {
- test("resolves split mask center from centerX and centerY", () => {
- expect(
- getMaskSnapGeometry({
- params: buildSplitParams({
- centerX: 0.25,
- centerY: -0.5,
- rotation: 45,
- }),
- bounds,
- }),
- ).toEqual({
- position: { x: 50, y: -50 },
- size: { width: 0, height: 0 },
- rotation: 45,
- });
- });
-
- test("resolves box mask center and size from centerX and centerY", () => {
- expect(
- getMaskSnapGeometry({
- params: buildRectangleParams({
- centerX: -0.25,
- centerY: 0.5,
- width: 0.5,
- height: 0.6,
- rotation: 30,
- }),
- bounds,
- }),
- ).toEqual({
- position: { x: -50, y: 50 },
- size: { width: 100, height: 60 },
- rotation: 30,
- });
- });
-
- test("returns a vertical split stroke segment for rotation 0", () => {
- const segment = getSplitMaskStrokeSegment({
- resolvedParams: buildSplitParams(),
- width: bounds.width,
- height: bounds.height,
- });
-
- expect(segment).not.toBeNull();
- if (!segment) {
- throw new Error("Expected split stroke segment for rotation 0");
- }
- expect(sortSegment(segment)).toEqual([
- { x: bounds.width / 2, y: 0 },
- { x: bounds.width / 2, y: bounds.height },
- ]);
- });
-
- test("returns a horizontal split stroke segment for rotation 90", () => {
- const segment = getSplitMaskStrokeSegment({
- resolvedParams: buildSplitParams({ rotation: 90 }),
- width: bounds.width,
- height: bounds.height,
- });
-
- expect(segment).not.toBeNull();
- if (!segment) {
- throw new Error("Expected split stroke segment for rotation 90");
- }
- expect(sortSegment(segment)).toEqual([
- { x: 0, y: bounds.height / 2 },
- { x: bounds.width, y: bounds.height / 2 },
- ]);
- });
-});
-
-describe("mask snapping", () => {
- test("snaps split mask movement using the shared position pipeline", () => {
- const result = snapMaskInteraction({
- handleId: "position",
- startParams: buildSplitParams({
- centerX: 0.03,
- centerY: -0.04,
- }),
- proposedParams: buildSplitParams({
- centerX: 0.03,
- centerY: -0.04,
- }),
- bounds,
- canvasSize,
- snapThreshold,
- });
-
- expect(result.params.centerX).toBe(0);
- expect(result.params.centerY).toBe(0);
- expect(result.activeLines).toEqual([
- { type: "vertical", position: 0 },
- { type: "horizontal", position: 0 },
- ]);
- });
-
- test("snaps box mask movement against element center and edges", () => {
- const result = snapMaskInteraction({
- handleId: "position",
- startParams: buildRectangleParams(),
- proposedParams: buildRectangleParams({
- centerX: 0.29,
- centerY: 0.03,
- }),
- bounds,
- canvasSize,
- snapThreshold,
- });
-
- expect(result.params.centerX).toBeCloseTo(0.3);
- expect(result.params.centerY).toBe(0);
- expect(result.activeLines).toEqual([
- { type: "vertical", position: 100 },
- { type: "horizontal", position: 0 },
- ]);
- });
-
- test("snaps mask rotation through the shared rotation path", () => {
- const result = snapMaskInteraction({
- handleId: "rotation",
- startParams: buildRectangleParams(),
- proposedParams: buildRectangleParams({
- rotation: 88,
- }),
- bounds,
- canvasSize,
- snapThreshold,
- });
-
- expect(result.params.rotation).toBe(90);
- expect(result.activeLines).toEqual([]);
- });
-
- test("snaps edge resize for box masks", () => {
- const result = snapMaskInteraction({
- handleId: "right",
- startParams: buildRectangleParams(),
- proposedParams: buildRectangleParams({
- width: 0.98,
- }),
- bounds,
- canvasSize,
- snapThreshold,
- });
-
- expect(result.params.width).toBe(1);
- expect(result.activeLines).toEqual([{ type: "vertical", position: 100 }]);
- });
-
- test("snaps corner resize for box masks", () => {
- const result = snapMaskInteraction({
- handleId: "bottom-right",
- startParams: buildRectangleParams(),
- proposedParams: buildRectangleParams({
- width: 0.99,
- height: 0.495,
- }),
- bounds,
- canvasSize,
- snapThreshold,
- });
-
- expect(result.params.width).toBe(1);
- expect(result.params.height).toBe(0.5);
- expect(result.activeLines).toEqual([{ type: "vertical", position: 100 }]);
- });
-
- test("snaps text mask movement using intrinsic text bounds", () => {
- const params = buildTextMaskParams({
- centerX: 0.03,
- centerY: -0.04,
- });
- const result = textMaskDefinition.interaction.snap?.({
- handleId: "position",
- startParams: params,
- proposedParams: params,
- bounds,
- canvasSize,
- snapThreshold,
- });
-
- expect(result?.params.centerX).toBe(0);
- expect(result?.params.centerY).toBe(0);
- expect(result?.activeLines).toEqual([
- { type: "vertical", position: 0 },
- { type: "horizontal", position: 0 },
- ]);
- });
-
- test("snaps custom mask movement using path geometry bounds", () => {
- const params = buildCustomMaskParams({
- centerX: 0.03,
- centerY: -0.04,
- });
- const result = customMaskDefinition.interaction.snap?.({
- handleId: "position",
- startParams: params,
- proposedParams: params,
- bounds,
- canvasSize,
- snapThreshold,
- });
-
- expect(result?.params.centerX).toBe(0);
- expect(result?.params.centerY).toBe(0);
- expect(result?.activeLines).toEqual([
- { type: "vertical", position: 0 },
- { type: "horizontal", position: 0 },
- ]);
- });
-
- test("marks blank text masks inactive", () => {
- expect(
- textMaskDefinition.isActive?.(
- buildTextMaskParams({
- content: " ",
- }),
- ),
- ).toBe(false);
- });
-});
-
-describe("custom mask creation", () => {
- test("anchors the first point at the click position", () => {
- const params = buildCustomMaskParams({
- path: [],
- closed: false,
- });
- const next = appendPointToCustomMask({
- params,
- canvasPoint: { x: bounds.cx + 20, y: bounds.cy - 10 },
- bounds,
- });
-
- expect(next.centerX).toBeCloseTo(0.1);
- expect(next.centerY).toBeCloseTo(-0.1);
- expect(next.rotation).toBe(0);
- expect(next.scale).toBe(1);
- expect(next.path).toHaveLength(1);
- });
-});
-
-describe("custom mask point deletion", () => {
- test("removes the selected points by id", () => {
- const points = buildCustomMaskParams().path;
- const nextPoints = removeCustomMaskPoints({
- points,
- pointIds: ["b"],
- });
-
- expect(nextPoints.map((point) => point.id)).toEqual(["a", "c"]);
- });
-
- test("reopens a closed path once fewer than three points remain", () => {
- const points = buildCustomMaskParams().path;
- const nextPoints = removeCustomMaskPoints({
- points,
- pointIds: ["c"],
- });
-
- expect(
- getCustomMaskClosedStateAfterPointRemoval({
- wasClosed: true,
- remainingPointCount: nextPoints.length,
- }),
- ).toBe(false);
- });
-});
-
-describe("custom mask point insertion", () => {
- test("finds the closest point on the clicked segment", () => {
- const params = buildCustomMaskParams();
- const points = params.path;
- const closestPoint = findClosestPointOnCustomMaskSegment({
- points,
- segmentIndex: 0,
- canvasPoint: { x: bounds.cx, y: bounds.cy - 10 },
- centerX: params.centerX,
- centerY: params.centerY,
- rotation: params.rotation,
- scale: params.scale,
- bounds,
- closed: params.closed,
- });
-
- expect(closestPoint).not.toBeNull();
- expect(closestPoint?.t).toBeCloseTo(0.5, 1);
- expect(closestPoint?.point.x).toBeCloseTo(bounds.cx, 4);
- expect(closestPoint?.point.y).toBeCloseTo(bounds.cy - 10, 4);
- });
-
- test("splits a segment into two segments at the insertion point", () => {
- const points = buildCustomMaskParams().path;
- const nextPoints = insertPointIntoCustomMaskSegment({
- points,
- segmentIndex: 0,
- pointId: "new",
- t: 0.5,
- closed: true,
- });
-
- expect(nextPoints.map((point) => point.id)).toEqual(["a", "new", "b", "c"]);
- expect(nextPoints[1]).toMatchObject({
- id: "new",
- x: 0,
- y: -0.1,
- inX: 0,
- inY: 0,
- outX: 0,
- outY: 0,
- });
- });
-
- test("builds updated custom mask params for a clicked segment", () => {
- const result = insertPointOnCustomMaskSegment({
- params: buildCustomMaskParams(),
- segmentIndex: 0,
- canvasPoint: { x: bounds.cx, y: bounds.cy - 10 },
- bounds,
- pointId: "new",
- });
-
- expect(result).not.toBeNull();
- const nextPoints = result?.params.path ?? [];
- expect(nextPoints).toHaveLength(4);
- expect(nextPoints.some((point) => point.id === "new")).toBe(true);
- });
-});
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<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(" ");
-}
diff --git a/apps/web/src/lib/masks/definitions/box-like.ts b/apps/web/src/lib/masks/definitions/box-like.ts
deleted file mode 100644
index fe0f1a85..00000000
--- a/apps/web/src/lib/masks/definitions/box-like.ts
+++ /dev/null
@@ -1,313 +0,0 @@
-import {
- DEFAULT_SHAPE_MASK_SHORT_SIDE_RATIO,
- MIN_MASK_DIMENSION,
-} from "@/lib/masks/dimensions";
-import { computeFeatherUpdate } from "../param-update";
-import type {
- BaseMaskParams,
- MaskDefaultContext,
- MaskFeatures,
- MaskInteractionDefinition,
- MaskParamUpdateArgs,
- RectangleMaskParams,
-} from "@/lib/masks/types";
-import type { NumberParamDefinition, ParamDefinition } from "@/lib/params";
-import {
- getBoxMaskHandlePositions,
- getBoxMaskOverlays,
-} from "@/lib/masks/handle-positions";
-import { snapMaskInteraction } from "@/lib/masks/snap";
-
-const PERCENTAGE_DISPLAY: Pick<
- NumberParamDefinition,
- "displayMultiplier" | "step"
-> = {
- displayMultiplier: 100,
- step: 1,
-};
-
-export const BOX_LIKE_MASK_PARAMS: ParamDefinition<
- keyof RectangleMaskParams & string
->[] = [
- {
- key: "centerX",
- label: "X",
- type: "number",
- default: 0,
- min: -100,
- max: 100,
- ...PERCENTAGE_DISPLAY,
- },
- {
- key: "centerY",
- label: "Y",
- type: "number",
- default: 0,
- min: -100,
- max: 100,
- ...PERCENTAGE_DISPLAY,
- },
- {
- key: "width",
- label: "Width",
- type: "number",
- default: 0.6,
- min: 1,
- ...PERCENTAGE_DISPLAY,
- },
- {
- key: "height",
- label: "Height",
- type: "number",
- default: 0.6,
- min: 1,
- ...PERCENTAGE_DISPLAY,
- },
- {
- key: "rotation",
- label: "Rotation",
- type: "number",
- default: 0,
- min: 0,
- max: 360,
- step: 1,
- },
- {
- key: "scale",
- label: "Scale",
- type: "number",
- default: 1,
- min: 1,
- max: 500,
- ...PERCENTAGE_DISPLAY,
- },
- {
- key: "strokeAlign",
- label: "Stroke Align",
- type: "select",
- default: "center",
- options: [
- { value: "inside", label: "Inside" },
- { value: "center", label: "Center" },
- { value: "outside", label: "Outside" },
- ],
- },
-];
-
-export function getDefaultBaseMaskParams(): BaseMaskParams {
- return {
- feather: 0,
- inverted: false,
- strokeColor: "#ffffff",
- strokeWidth: 0,
- strokeAlign: "center",
- };
-}
-
-export function getStrokeOffset({
- strokeAlign,
- strokeWidth,
-}: Pick<BaseMaskParams, "strokeAlign" | "strokeWidth">): number {
- if (strokeAlign === "inside") {
- return -(strokeWidth / 2);
- }
-
- if (strokeAlign === "outside") {
- return strokeWidth / 2;
- }
-
- return 0;
-}
-
-export function getDefaultSquareMaskParams({
- elementSize,
-}: MaskDefaultContext): RectangleMaskParams {
- const absWidth = Math.abs(elementSize?.width ?? 0);
- const absHeight = Math.abs(elementSize?.height ?? 0);
- const shortSide = Math.min(absWidth, absHeight);
- const squareSide =
- shortSide > 0 ? shortSide * DEFAULT_SHAPE_MASK_SHORT_SIDE_RATIO : 0;
- const width =
- absWidth > 0 ? squareSide / absWidth : DEFAULT_SHAPE_MASK_SHORT_SIDE_RATIO;
- const height =
- absHeight > 0
- ? squareSide / absHeight
- : DEFAULT_SHAPE_MASK_SHORT_SIDE_RATIO;
-
- return {
- ...getDefaultBaseMaskParams(),
- centerX: 0,
- centerY: 0,
- width,
- height,
- rotation: 0,
- scale: 1,
- };
-}
-
-export function getBoxLikeGeometry({
- params,
- width,
- height,
-}: {
- params: RectangleMaskParams;
- width: number;
- height: number;
-}) {
- return {
- centerX: width / 2 + params.centerX * width,
- centerY: height / 2 + params.centerY * height,
- maskWidth: Math.max(params.width, MIN_MASK_DIMENSION) * width,
- maskHeight: Math.max(params.height, MIN_MASK_DIMENSION) * height,
- rotationRad: (params.rotation * Math.PI) / 180,
- };
-}
-
-export function buildBoxMaskInteraction({
- sizeMode,
- buildOverlayPath,
- showBoundingBox = true,
-}: {
- sizeMode: MaskFeatures["sizeMode"];
- buildOverlayPath?: (args: { width: number; height: number }) => string;
- showBoundingBox?: boolean;
-}): MaskInteractionDefinition<RectangleMaskParams> {
- return {
- getInteraction({ params, bounds, displayScale, scaleX, scaleY }) {
- return {
- handles: getBoxMaskHandlePositions({
- centerX: params.centerX,
- centerY: params.centerY,
- width: params.width,
- height: params.height,
- rotation: params.rotation,
- feather: params.feather,
- sizeMode,
- bounds,
- displayScale,
- }),
- overlays: getBoxMaskOverlays({
- params,
- bounds,
- pathData: buildOverlayPath?.({
- width: params.width * bounds.width * scaleX,
- height: params.height * bounds.height * scaleY,
- }),
- showBoundingBox,
- }),
- };
- },
- snap(args) {
- return snapMaskInteraction(args);
- },
- };
-}
-
-export function computeBoxMaskParamUpdate({
- handleId,
- startParams,
- deltaX,
- deltaY,
- bounds,
-}: MaskParamUpdateArgs<RectangleMaskParams>): Partial<RectangleMaskParams> {
- if (handleId === "position") {
- return {
- centerX: startParams.centerX + deltaX / bounds.width,
- centerY: startParams.centerY + deltaY / bounds.height,
- };
- }
-
- if (handleId === "rotation") {
- const currentAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI;
- const newRotation = (startParams.rotation + currentAngle) % 360;
- return { rotation: newRotation < 0 ? newRotation + 360 : newRotation };
- }
-
- if (handleId === "feather") {
- const angleRad = (startParams.rotation * Math.PI) / 180;
- return computeFeatherUpdate({
- startFeather: startParams.feather,
- deltaX,
- deltaY,
- directionX: -Math.sin(angleRad),
- directionY: Math.cos(angleRad),
- });
- }
-
- const halfWidth = startParams.width * bounds.width;
- const halfHeight = startParams.height * bounds.height;
-
- if (handleId === "right" || handleId === "left") {
- const sign = handleId === "right" ? 1 : -1;
- return {
- width: Math.max(
- MIN_MASK_DIMENSION,
- startParams.width + (sign * deltaX * 2) / bounds.width,
- ),
- };
- }
-
- if (handleId === "bottom" || handleId === "top") {
- const sign = handleId === "bottom" ? 1 : -1;
- return {
- height: Math.max(
- MIN_MASK_DIMENSION,
- startParams.height + (sign * deltaY * 2) / bounds.height,
- ),
- };
- }
-
- if (
- handleId === "top-left" ||
- handleId === "top-right" ||
- handleId === "bottom-left" ||
- handleId === "bottom-right"
- ) {
- const signX = handleId.includes("right") ? 1 : -1;
- const signY = handleId.includes("bottom") ? 1 : -1;
- const distance = Math.sqrt(
- (signX * deltaX + halfWidth) ** 2 + (signY * deltaY + halfHeight) ** 2,
- );
- const originalDistance = Math.sqrt(halfWidth ** 2 + halfHeight ** 2);
- const scale = originalDistance > 0 ? distance / originalDistance : 1;
- return {
- width: Math.max(MIN_MASK_DIMENSION, startParams.width * scale),
- height: Math.max(MIN_MASK_DIMENSION, startParams.height * scale),
- };
- }
-
- if (handleId === "scale") {
- const distance = Math.sqrt(deltaX ** 2 + deltaY ** 2);
- const originalDistance = Math.sqrt(halfWidth ** 2 + halfHeight ** 2);
- const scale = originalDistance > 0 ? 1 + distance / originalDistance : 1;
- return {
- scale: Math.max(MIN_MASK_DIMENSION, startParams.scale * scale),
- };
- }
-
- return {};
-}
-
-export function rotatePoint({
- x,
- y,
- centerX,
- centerY,
- rotationRad,
-}: {
- x: number;
- y: number;
- centerX: number;
- centerY: number;
- rotationRad: number;
-}) {
- const dx = x - centerX;
- const dy = y - centerY;
- const cos = Math.cos(rotationRad);
- const sin = Math.sin(rotationRad);
-
- return {
- x: centerX + dx * cos - dy * sin,
- y: centerY + dx * sin + dy * cos,
- };
-}
diff --git a/apps/web/src/lib/masks/definitions/cinematic-bars.ts b/apps/web/src/lib/masks/definitions/cinematic-bars.ts
deleted file mode 100644
index ef9c6e00..00000000
--- a/apps/web/src/lib/masks/definitions/cinematic-bars.ts
+++ /dev/null
@@ -1,135 +0,0 @@
-import type {
- MaskDefaultContext,
- MaskDefinition,
- RectangleMaskParams,
-} from "@/lib/masks/types";
-import {
- BOX_LIKE_MASK_PARAMS,
- buildBoxMaskInteraction,
- computeBoxMaskParamUpdate,
- getDefaultBaseMaskParams,
- getStrokeOffset,
- rotatePoint,
-} from "./box-like";
-
-function getDefaultCinematicBarsMaskParams({
- elementSize,
-}: MaskDefaultContext): RectangleMaskParams {
- const absWidth = Math.abs(elementSize?.width ?? 0);
- const absHeight = Math.abs(elementSize?.height ?? 0);
- const diagonal =
- absWidth > 0 && absHeight > 0
- ? Math.sqrt(absWidth ** 2 + absHeight ** 2)
- : 0;
- const fullSpanWidth =
- absWidth > 0 ? diagonal / absWidth : Math.SQRT2;
-
- return {
- ...getDefaultBaseMaskParams(),
- centerX: 0,
- centerY: 0,
- width: Math.max(fullSpanWidth, 1),
- height: 0.6,
- rotation: 0,
- scale: 1,
- };
-}
-
-function buildBandPath({
- centerX,
- centerY,
- halfWidth,
- halfHeight,
- rotationRad,
-}: {
- centerX: number;
- centerY: number;
- halfWidth: number;
- halfHeight: number;
- rotationRad: number;
-}): Path2D {
- const corners = [
- { x: centerX - halfWidth, y: centerY - halfHeight },
- { x: centerX + halfWidth, y: centerY - halfHeight },
- { x: centerX + halfWidth, y: centerY + halfHeight },
- { x: centerX - halfWidth, y: centerY + halfHeight },
- ].map((point) =>
- rotatePoint({
- ...point,
- centerX,
- centerY,
- rotationRad,
- }),
- );
-
- const path = new Path2D();
- path.moveTo(corners[0].x, corners[0].y);
- for (const corner of corners.slice(1)) {
- path.lineTo(corner.x, corner.y);
- }
- path.closePath();
- return path;
-}
-
-export const cinematicBarsMaskDefinition: MaskDefinition<RectangleMaskParams> = {
- type: "cinematic-bars",
- name: "Cinematic Bars",
- features: {
- hasPosition: true,
- hasRotation: true,
- sizeMode: "height-only",
- },
- params: BOX_LIKE_MASK_PARAMS,
- interaction: buildBoxMaskInteraction({
- sizeMode: "height-only",
- buildOverlayPath({ width, height }) {
- return `M 0,0 H ${width} V ${height} H 0 Z`;
- },
- }),
- buildDefault(context) {
- return {
- type: "cinematic-bars",
- params: getDefaultCinematicBarsMaskParams(context),
- };
- },
- computeParamUpdate: computeBoxMaskParamUpdate,
- renderer: {
- buildPath({ resolvedParams, width, height }) {
- const params = resolvedParams as RectangleMaskParams;
- const centerX = width / 2 + params.centerX * width;
- const centerY = height / 2 + params.centerY * height;
- const maskWidth = Math.max(params.width * width, width);
- const maskHeight = Math.max(params.height, 0.01) * height;
- const rotationRad = (params.rotation * Math.PI) / 180;
-
- return buildBandPath({
- centerX,
- centerY,
- halfWidth: maskWidth / 2,
- halfHeight: maskHeight / 2,
- rotationRad,
- });
- },
- buildStrokePath({ resolvedParams, width, height }) {
- const params = resolvedParams as RectangleMaskParams;
- const centerX = width / 2 + params.centerX * width;
- const centerY = height / 2 + params.centerY * height;
- const rotationRad = (params.rotation * Math.PI) / 180;
- const offset = getStrokeOffset({
- strokeAlign: params.strokeAlign,
- strokeWidth: params.strokeWidth,
- });
-
- return buildBandPath({
- centerX,
- centerY,
- halfWidth: Math.max((Math.max(params.width * width, width) / 2) + offset, 1),
- halfHeight: Math.max(
- (Math.max(params.height, 0.01) * height) / 2 + offset,
- 1,
- ),
- rotationRad,
- });
- },
- },
-};
diff --git a/apps/web/src/lib/masks/definitions/custom.ts b/apps/web/src/lib/masks/definitions/custom.ts
deleted file mode 100644
index 0fc69929..00000000
--- a/apps/web/src/lib/masks/definitions/custom.ts
+++ /dev/null
@@ -1,652 +0,0 @@
-import { generateUUID } from "@/utils/id";
-import type { ParamDefinition } from "@/lib/params";
-import { PEN_CURSOR } from "@/components/editor/panels/preview/cursors";
-import type { ElementBounds } from "@/lib/preview/element-bounds";
-import type {
- CustomMask,
- CustomMaskParams,
- MaskDefinition,
- MaskHandlePosition,
- MaskOverlay,
- MaskParamUpdateArgs,
-} from "@/lib/masks/types";
-import {
- buildCustomMaskPath2D,
- buildCustomMaskSvgPath,
- customMaskCanvasPointToLocal,
- findClosestPointOnCustomMaskSegment,
- getCustomMaskCanvasSegments,
- getCustomMaskCanvasGeometry,
- getCustomMaskLocalBounds,
- getCustomMaskSegmentCount,
- insertPointIntoCustomMaskSegment,
- parseCustomMaskHandleId,
- recenterCustomMaskPath,
- type CustomMaskPathPoint,
-} from "@/lib/masks/custom-path";
-import { getBoxMaskHandlePositions } from "@/lib/masks/handle-positions";
-import { computeFeatherUpdate } from "@/lib/masks/param-update";
-import {
- setMaskLocalCenter,
- toGlobalMaskSnapLines,
-} from "@/lib/masks/geometry";
-import {
- snapPosition,
- snapRotation,
- snapScale,
-} from "@/lib/preview/preview-snap";
-
-const PERCENTAGE_DISPLAY = {
- displayMultiplier: 100,
- step: 1,
-} as const;
-
-const CUSTOM_MASK_PARAMS: ParamDefinition<keyof CustomMaskParams & string>[] = [
- {
- key: "centerX",
- label: "X",
- type: "number",
- default: 0,
- min: -100,
- max: 100,
- ...PERCENTAGE_DISPLAY,
- },
- {
- key: "centerY",
- label: "Y",
- type: "number",
- default: 0,
- min: -100,
- max: 100,
- ...PERCENTAGE_DISPLAY,
- },
- {
- key: "rotation",
- label: "Rotation",
- type: "number",
- default: 0,
- min: 0,
- max: 360,
- step: 1,
- },
- {
- key: "scale",
- label: "Scale",
- type: "number",
- default: 1,
- min: 1,
- max: 500,
- ...PERCENTAGE_DISPLAY,
- },
-];
-
-function getCustomMaskDisplayHandles({
- params,
- displayScale,
- bounds,
-}: {
- params: CustomMaskParams;
- displayScale: number;
- bounds: ElementBounds;
-}): {
- handles: MaskHandlePosition[];
- overlays: MaskOverlay[];
-} {
- const points = params.path;
- const geometry = getCustomMaskCanvasGeometry({
- points,
- centerX: params.centerX,
- centerY: params.centerY,
- rotation: params.rotation,
- scale: params.scale,
- bounds,
- });
-
- const handles: MaskHandlePosition[] = [];
- const overlays: MaskOverlay[] = [];
-
- if (points.length > 0) {
- overlays.push({
- id: "path",
- type: "canvas-path",
- pathData: buildCustomMaskSvgPath({
- points,
- centerX: params.centerX,
- centerY: params.centerY,
- rotation: params.rotation,
- scale: params.scale,
- bounds,
- closed: params.closed,
- }),
- coordinateSpace: "canvas",
- });
- }
-
- if (params.closed) {
- const segmentStrokeWidth = 12;
- overlays.push(
- ...getCustomMaskCanvasSegments({
- points,
- centerX: params.centerX,
- centerY: params.centerY,
- rotation: params.rotation,
- scale: params.scale,
- bounds,
- closed: true,
- }).map((segment) => ({
- id: `segment:${segment.index}`,
- type: "canvas-path" as const,
- pathData: segment.pathData,
- coordinateSpace: "canvas" as const,
- handleId: `segment:${segment.index}`,
- cursor: PEN_CURSOR,
- strokeOpacity: 0,
- strokeWidth: segmentStrokeWidth,
- })),
- );
- }
-
- const localBounds = getCustomMaskLocalBounds({ points, bounds });
- if (params.closed && localBounds) {
- handles.push(
- ...getBoxMaskHandlePositions({
- centerX: params.centerX,
- centerY: params.centerY,
- width: (localBounds.width * params.scale) / bounds.width,
- height: (localBounds.height * params.scale) / bounds.height,
- rotation: params.rotation,
- feather: params.feather,
- sizeMode: "uniform",
- showScaleHandle: false,
- bounds,
- displayScale,
- }),
- );
- }
-
- geometry.anchors.forEach((point) => {
- handles.push({
- id: `point:${point.id}:anchor`,
- x: point.anchor.x,
- y: point.anchor.y,
- cursor: params.closed ? "move" : "pointer",
- kind: "point",
- });
- });
-
- return {
- handles,
- overlays,
- };
-}
-
-function updateCustomMaskPoint({
- points,
- pointId,
- updater,
-}: {
- points: CustomMaskPathPoint[];
- pointId: string;
- updater: (point: CustomMaskPathPoint) => CustomMaskPathPoint;
-}) {
- return points.map((point) => (point.id === pointId ? updater(point) : point));
-}
-
-function computeCustomMaskParamUpdate({
- handleId,
- startParams,
- deltaX,
- deltaY,
- startCanvasX,
- startCanvasY,
- bounds,
-}: MaskParamUpdateArgs<CustomMaskParams>): Partial<CustomMaskParams> {
- if (handleId === "position") {
- return {
- centerX: startParams.centerX + deltaX / bounds.width,
- centerY: startParams.centerY + deltaY / bounds.height,
- };
- }
-
- const pivotX = bounds.cx + startParams.centerX * bounds.width;
- const pivotY = bounds.cy + startParams.centerY * bounds.height;
-
- if (handleId === "rotation") {
- const startAngle =
- (Math.atan2(startCanvasY - pivotY, startCanvasX - pivotX) * 180) /
- Math.PI;
- const currentAngle =
- (Math.atan2(
- startCanvasY + deltaY - pivotY,
- startCanvasX + deltaX - pivotX,
- ) *
- 180) /
- Math.PI;
-
- let deltaAngle = currentAngle - startAngle;
- if (deltaAngle > 180) deltaAngle -= 360;
- if (deltaAngle < -180) deltaAngle += 360;
-
- return {
- rotation: (((startParams.rotation + deltaAngle) % 360) + 360) % 360,
- };
- }
-
- if (handleId === "feather") {
- const angleRad = (startParams.rotation * Math.PI) / 180;
- return computeFeatherUpdate({
- startFeather: startParams.feather,
- deltaX,
- deltaY,
- directionX: -Math.sin(angleRad),
- directionY: Math.cos(angleRad),
- });
- }
-
- if (handleId === "scale") {
- const startDistance = Math.hypot(
- startCanvasX - pivotX,
- startCanvasY - pivotY,
- );
- const currentDistance = Math.hypot(
- startCanvasX + deltaX - pivotX,
- startCanvasY + deltaY - pivotY,
- );
- const scaleFactor = startDistance > 0 ? currentDistance / startDistance : 1;
- return {
- scale: Math.max(0.01, startParams.scale * scaleFactor),
- };
- }
-
- const parsedHandle = parseCustomMaskHandleId({ handleId });
- if (!parsedHandle) {
- return {};
- }
-
- const points = startParams.path;
- const currentPoint = {
- x: startCanvasX + deltaX,
- y: startCanvasY + deltaY,
- };
- const localPoint = customMaskCanvasPointToLocal({
- point: currentPoint,
- centerX: startParams.centerX,
- centerY: startParams.centerY,
- rotation: startParams.rotation,
- scale: startParams.scale,
- bounds,
- });
-
- return {
- path: updateCustomMaskPoint({
- points,
- pointId: parsedHandle.pointId,
- updater: (point) => {
- if (parsedHandle.part === "anchor") {
- return {
- ...point,
- x: localPoint.x,
- y: localPoint.y,
- };
- }
-
- if (parsedHandle.part === "in") {
- return {
- ...point,
- inX: localPoint.x - point.x,
- inY: localPoint.y - point.y,
- };
- }
-
- return {
- ...point,
- outX: localPoint.x - point.x,
- outY: localPoint.y - point.y,
- };
- },
- }),
- };
-}
-
-export const customMaskDefinition: MaskDefinition<CustomMaskParams> = {
- type: "custom",
- name: "Custom",
- features: {
- hasPosition: true,
- hasRotation: true,
- sizeMode: "uniform",
- },
- params: CUSTOM_MASK_PARAMS,
- interaction: {
- getInteraction({
- params,
- bounds,
- displayScale,
- scaleX: _scaleX,
- scaleY: _scaleY,
- }) {
- return getCustomMaskDisplayHandles({ params, bounds, displayScale });
- },
- snap({
- handleId,
- proposedParams,
- startParams,
- bounds,
- canvasSize,
- snapThreshold,
- }) {
- const points = startParams.path;
- const localBounds = getCustomMaskLocalBounds({ points, bounds });
- if (!startParams.closed || !localBounds) {
- return {
- params: proposedParams,
- activeLines: [],
- };
- }
-
- const position = {
- x: proposedParams.centerX * bounds.width,
- y: proposedParams.centerY * bounds.height,
- };
-
- if (handleId === "position") {
- const { snappedPosition, activeLines } = snapPosition({
- proposedPosition: position,
- canvasSize: bounds,
- elementSize: {
- width: localBounds.width * proposedParams.scale,
- height: localBounds.height * proposedParams.scale,
- },
- rotation: proposedParams.rotation,
- snapThreshold,
- });
-
- return {
- params: {
- ...proposedParams,
- ...setMaskLocalCenter({
- center: snappedPosition,
- bounds,
- }),
- },
- activeLines: toGlobalMaskSnapLines({
- lines: activeLines,
- bounds,
- canvasSize,
- }),
- };
- }
-
- if (handleId === "rotation") {
- const { snappedRotation } = snapRotation({
- proposedRotation: proposedParams.rotation,
- });
- return {
- params: {
- ...proposedParams,
- rotation: snappedRotation,
- },
- activeLines: [],
- };
- }
-
- if (handleId === "scale") {
- const { snappedScale, activeLines } = snapScale({
- proposedScale: proposedParams.scale,
- position,
- baseWidth: localBounds.width,
- baseHeight: localBounds.height,
- rotation: proposedParams.rotation,
- canvasSize: bounds,
- snapThreshold,
- preferredEdges: {
- right: true,
- bottom: true,
- },
- });
-
- return {
- params: {
- ...proposedParams,
- scale: Math.max(0.01, snappedScale),
- },
- activeLines: toGlobalMaskSnapLines({
- lines: activeLines,
- bounds,
- canvasSize,
- }),
- };
- }
-
- return {
- params: proposedParams,
- activeLines: [],
- };
- },
- },
- buildDefault(): Omit<CustomMask, "id"> {
- return {
- type: "custom",
- params: {
- feather: 0,
- inverted: false,
- strokeColor: "#ffffff",
- strokeWidth: 0,
- strokeAlign: "center",
- path: [],
- closed: false,
- centerX: 0,
- centerY: 0,
- rotation: 0,
- scale: 1,
- },
- };
- },
- computeParamUpdate: computeCustomMaskParamUpdate,
- isActive(params) {
- return params.closed;
- },
- renderer: {
- buildPath({ resolvedParams, width, height }) {
- const params = resolvedParams as CustomMaskParams;
- const points = params.path;
- if (!params.closed) {
- return new Path2D();
- }
-
- return buildCustomMaskPath2D({
- points,
- centerX: params.centerX,
- centerY: params.centerY,
- rotation: params.rotation,
- scale: params.scale,
- bounds: {
- cx: width / 2,
- cy: height / 2,
- width,
- height,
- rotation: 0,
- },
- closed: true,
- });
- },
- renderStroke({ resolvedParams, ctx, width, height }) {
- const params = resolvedParams as CustomMaskParams;
- if (!params.closed) {
- return;
- }
-
- const points = params.path;
- const path = buildCustomMaskPath2D({
- points,
- centerX: params.centerX,
- centerY: params.centerY,
- rotation: params.rotation,
- scale: params.scale,
- bounds: {
- cx: width / 2,
- cy: height / 2,
- width,
- height,
- rotation: 0,
- },
- closed: true,
- });
-
- ctx.save();
- ctx.strokeStyle = params.strokeColor;
- ctx.lineWidth = params.strokeWidth;
- ctx.lineJoin = "round";
- ctx.lineCap = "round";
- ctx.stroke(path);
-
- if (params.strokeAlign === "inside") {
- ctx.globalCompositeOperation = "destination-in";
- ctx.fillStyle = "#ffffff";
- ctx.fill(path);
- }
-
- if (params.strokeAlign === "outside") {
- ctx.globalCompositeOperation = "destination-out";
- ctx.fillStyle = "#ffffff";
- ctx.fill(path);
- }
- ctx.restore();
- },
- },
-};
-
-export function appendPointToCustomMask({
- params,
- canvasPoint,
- bounds,
-}: {
- params: CustomMaskParams;
- canvasPoint: { x: number; y: number };
- bounds: ElementBounds;
-}): CustomMaskParams {
- const points = params.path;
-
- if (points.length === 0) {
- return {
- ...params,
- centerX:
- bounds.width === 0 ? 0 : (canvasPoint.x - bounds.cx) / bounds.width,
- centerY:
- bounds.height === 0 ? 0 : (canvasPoint.y - bounds.cy) / bounds.height,
- rotation: 0,
- scale: 1,
- path: [
- {
- id: generateUUID(),
- x: 0,
- y: 0,
- inX: 0,
- inY: 0,
- outX: 0,
- outY: 0,
- },
- ],
- };
- }
-
- const localPoint = customMaskCanvasPointToLocal({
- point: canvasPoint,
- centerX: params.centerX,
- centerY: params.centerY,
- rotation: params.rotation,
- scale: params.scale,
- bounds,
- });
- const nextPoints = [
- ...points,
- {
- id: generateUUID(),
- x: localPoint.x,
- y: localPoint.y,
- inX: 0,
- inY: 0,
- outX: 0,
- outY: 0,
- },
- ];
- const recentered = recenterCustomMaskPath({
- points: nextPoints,
- centerX: params.centerX,
- centerY: params.centerY,
- rotation: params.rotation,
- scale: params.scale,
- bounds,
- });
-
- return {
- ...params,
- centerX: recentered.centerX,
- centerY: recentered.centerY,
- path: recentered.points,
- };
-}
-
-export function insertPointOnCustomMaskSegment({
- params,
- segmentIndex,
- canvasPoint,
- bounds,
- pointId = generateUUID(),
-}: {
- params: CustomMaskParams;
- segmentIndex: number;
- canvasPoint: { x: number; y: number };
- bounds: ElementBounds;
- pointId?: string;
-}): { params: CustomMaskParams; pointId: string } | null {
- const points = params.path;
- if (getCustomMaskSegmentCount({ points, closed: params.closed }) === 0) {
- return null;
- }
-
- const closestPoint = findClosestPointOnCustomMaskSegment({
- points,
- segmentIndex,
- canvasPoint,
- centerX: params.centerX,
- centerY: params.centerY,
- rotation: params.rotation,
- scale: params.scale,
- bounds,
- closed: params.closed,
- });
- if (!closestPoint) {
- return null;
- }
-
- const nextPoints = insertPointIntoCustomMaskSegment({
- points,
- segmentIndex,
- pointId,
- t: closestPoint.t,
- closed: params.closed,
- });
- if (nextPoints.length === points.length) {
- return null;
- }
-
- const recentered = recenterCustomMaskPath({
- points: nextPoints,
- centerX: params.centerX,
- centerY: params.centerY,
- rotation: params.rotation,
- scale: params.scale,
- bounds,
- });
-
- return {
- pointId,
- params: {
- ...params,
- centerX: recentered.centerX,
- centerY: recentered.centerY,
- path: recentered.points,
- },
- };
-}
diff --git a/apps/web/src/lib/masks/definitions/diamond.ts b/apps/web/src/lib/masks/definitions/diamond.ts
deleted file mode 100644
index 56930678..00000000
--- a/apps/web/src/lib/masks/definitions/diamond.ts
+++ /dev/null
@@ -1,100 +0,0 @@
-import type { MaskDefinition, RectangleMaskParams } from "@/lib/masks/types";
-import {
- BOX_LIKE_MASK_PARAMS,
- buildBoxMaskInteraction,
- computeBoxMaskParamUpdate,
- getBoxLikeGeometry,
- getDefaultSquareMaskParams,
- getStrokeOffset,
- rotatePoint,
-} from "./box-like";
-
-function buildDiamondPath({
- centerX,
- centerY,
- halfWidth,
- halfHeight,
- rotationRad,
-}: {
- centerX: number;
- centerY: number;
- halfWidth: number;
- halfHeight: number;
- rotationRad: number;
-}): Path2D {
- const points = [
- { x: centerX, y: centerY - halfHeight },
- { x: centerX + halfWidth, y: centerY },
- { x: centerX, y: centerY + halfHeight },
- { x: centerX - halfWidth, y: centerY },
- ].map((point) =>
- rotatePoint({
- ...point,
- centerX,
- centerY,
- rotationRad,
- }),
- );
-
- const path = new Path2D();
- path.moveTo(points[0].x, points[0].y);
- for (const point of points.slice(1)) {
- path.lineTo(point.x, point.y);
- }
- path.closePath();
- return path;
-}
-
-export const diamondMaskDefinition: MaskDefinition<RectangleMaskParams> = {
- type: "diamond",
- name: "Diamond",
- features: {
- hasPosition: true,
- hasRotation: true,
- sizeMode: "width-height",
- },
- params: BOX_LIKE_MASK_PARAMS,
- interaction: buildBoxMaskInteraction({
- sizeMode: "width-height",
- buildOverlayPath({ width, height }) {
- return `M ${width / 2},0 L ${width},${height / 2} L ${width / 2},${height} L 0,${height / 2} Z`;
- },
- }),
- buildDefault(context) {
- return {
- type: "diamond",
- params: getDefaultSquareMaskParams(context),
- };
- },
- computeParamUpdate: computeBoxMaskParamUpdate,
- renderer: {
- buildPath({ resolvedParams, width, height }) {
- const params = resolvedParams as RectangleMaskParams;
- const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
- getBoxLikeGeometry({ params, width, height });
- return buildDiamondPath({
- centerX,
- centerY,
- halfWidth: maskWidth / 2,
- halfHeight: maskHeight / 2,
- rotationRad,
- });
- },
- buildStrokePath({ resolvedParams, width, height }) {
- const params = resolvedParams as RectangleMaskParams;
- const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
- getBoxLikeGeometry({ params, width, height });
- const offset = getStrokeOffset({
- strokeAlign: params.strokeAlign,
- strokeWidth: params.strokeWidth,
- });
- return buildDiamondPath({
- centerX,
- centerY,
- halfWidth: Math.max(maskWidth / 2 + offset, 1),
- halfHeight: Math.max(maskHeight / 2 + offset, 1),
- rotationRad,
- });
- },
- },
-};
diff --git a/apps/web/src/lib/masks/definitions/ellipse.ts b/apps/web/src/lib/masks/definitions/ellipse.ts
deleted file mode 100644
index 2eb85f0c..00000000
--- a/apps/web/src/lib/masks/definitions/ellipse.ts
+++ /dev/null
@@ -1,75 +0,0 @@
-import type { MaskDefinition, RectangleMaskParams } from "@/lib/masks/types";
-import {
- BOX_LIKE_MASK_PARAMS,
- buildBoxMaskInteraction,
- computeBoxMaskParamUpdate,
- getBoxLikeGeometry,
- getDefaultSquareMaskParams,
- getStrokeOffset,
-} from "./box-like";
-
-export const ellipseMaskDefinition: MaskDefinition<RectangleMaskParams> = {
- type: "ellipse",
- name: "Ellipse",
- features: {
- hasPosition: true,
- hasRotation: true,
- sizeMode: "width-height",
- },
- params: BOX_LIKE_MASK_PARAMS,
- interaction: buildBoxMaskInteraction({
- sizeMode: "width-height",
- buildOverlayPath({ width, height }) {
- const rx = Math.max((width - 1) / 2, 0);
- const ry = Math.max((height - 1) / 2, 0);
- const cx = width / 2;
- const cy = height / 2;
- return `M ${cx},${cy - ry} A ${rx},${ry} 0 1,1 ${cx},${cy + ry} A ${rx},${ry} 0 1,1 ${cx},${cy - ry} Z`;
- },
- }),
- buildDefault(context) {
- return {
- type: "ellipse",
- params: getDefaultSquareMaskParams(context),
- };
- },
- computeParamUpdate: computeBoxMaskParamUpdate,
- renderer: {
- buildPath({ resolvedParams, width, height }) {
- const params = resolvedParams as RectangleMaskParams;
- const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
- getBoxLikeGeometry({ params, width, height });
- const path = new Path2D();
- path.ellipse(
- centerX,
- centerY,
- maskWidth / 2,
- maskHeight / 2,
- rotationRad,
- 0,
- Math.PI * 2,
- );
- return path;
- },
- buildStrokePath({ resolvedParams, width, height }) {
- const params = resolvedParams as RectangleMaskParams;
- const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
- getBoxLikeGeometry({ params, width, height });
- const offset = getStrokeOffset({
- strokeAlign: params.strokeAlign,
- strokeWidth: params.strokeWidth,
- });
- const path = new Path2D();
- path.ellipse(
- centerX,
- centerY,
- Math.max(1, maskWidth / 2 + offset),
- Math.max(1, maskHeight / 2 + offset),
- rotationRad,
- 0,
- Math.PI * 2,
- );
- return path;
- },
- },
-};
diff --git a/apps/web/src/lib/masks/definitions/heart.ts b/apps/web/src/lib/masks/definitions/heart.ts
deleted file mode 100644
index 429c5d98..00000000
--- a/apps/web/src/lib/masks/definitions/heart.ts
+++ /dev/null
@@ -1,142 +0,0 @@
-import type { MaskDefinition, RectangleMaskParams } from "@/lib/masks/types";
-import {
- BOX_LIKE_MASK_PARAMS,
- buildBoxMaskInteraction,
- computeBoxMaskParamUpdate,
- getBoxLikeGeometry,
- getDefaultSquareMaskParams,
- getStrokeOffset,
- rotatePoint,
-} from "./box-like";
-
-function buildHeartPath({
- centerX,
- centerY,
- halfWidth,
- halfHeight,
- rotationRad,
-}: {
- centerX: number;
- centerY: number;
- halfWidth: number;
- halfHeight: number;
- rotationRad: number;
-}): Path2D {
- const toPoint = ({
- localX,
- localY,
- }: {
- localX: number;
- localY: number;
- }) =>
- rotatePoint({
- x: centerX + localX,
- y: centerY + localY,
- centerX,
- centerY,
- rotationRad,
- });
-
- const start = toPoint({ localX: 0, localY: -halfHeight * 0.475 });
- const rightControl1 = toPoint({
- localX: halfWidth,
- localY: -halfHeight * 1.225,
- });
- const rightControl2 = toPoint({
- localX: halfWidth,
- localY: -halfHeight * 0.125,
- });
- const bottom = toPoint({ localX: 0, localY: halfHeight * 0.725 });
- const leftControl1 = toPoint({
- localX: -halfWidth,
- localY: -halfHeight * 0.125,
- });
- const leftControl2 = toPoint({
- localX: -halfWidth,
- localY: -halfHeight * 1.225,
- });
-
- const path = new Path2D();
- path.moveTo(start.x, start.y);
- path.bezierCurveTo(
- rightControl1.x,
- rightControl1.y,
- rightControl2.x,
- rightControl2.y,
- bottom.x,
- bottom.y,
- );
- path.bezierCurveTo(
- leftControl1.x,
- leftControl1.y,
- leftControl2.x,
- leftControl2.y,
- start.x,
- start.y,
- );
- path.closePath();
- return path;
-}
-
-export const heartMaskDefinition: MaskDefinition<RectangleMaskParams> = {
- type: "heart",
- name: "Heart",
- features: {
- hasPosition: true,
- hasRotation: true,
- sizeMode: "width-height",
- },
- params: BOX_LIKE_MASK_PARAMS,
- interaction: buildBoxMaskInteraction({
- sizeMode: "width-height",
- buildOverlayPath({ width, height }) {
- const cx = width / 2;
- const cy = height / 2;
- const halfWidth = width / 2;
- const halfHeight = height / 2;
- return [
- `M ${cx},${cy - halfHeight * 0.475}`,
- `C ${cx + halfWidth},${cy - halfHeight * 1.225} ${cx + halfWidth},${cy - halfHeight * 0.125} ${cx},${cy + halfHeight * 0.725}`,
- `C ${cx - halfWidth},${cy - halfHeight * 0.125} ${cx - halfWidth},${cy - halfHeight * 1.225} ${cx},${cy - halfHeight * 0.475}`,
- "Z",
- ].join(" ");
- },
- }),
- buildDefault(context) {
- return {
- type: "heart",
- params: getDefaultSquareMaskParams(context),
- };
- },
- computeParamUpdate: computeBoxMaskParamUpdate,
- renderer: {
- buildPath({ resolvedParams, width, height }) {
- const params = resolvedParams as RectangleMaskParams;
- const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
- getBoxLikeGeometry({ params, width, height });
- return buildHeartPath({
- centerX,
- centerY,
- halfWidth: maskWidth / 2,
- halfHeight: maskHeight / 2,
- rotationRad,
- });
- },
- buildStrokePath({ resolvedParams, width, height }) {
- const params = resolvedParams as RectangleMaskParams;
- const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
- getBoxLikeGeometry({ params, width, height });
- const offset = getStrokeOffset({
- strokeAlign: params.strokeAlign,
- strokeWidth: params.strokeWidth,
- });
- return buildHeartPath({
- centerX,
- centerY,
- halfWidth: Math.max(maskWidth / 2 + offset, 1),
- halfHeight: Math.max(maskHeight / 2 + offset, 1),
- rotationRad,
- });
- },
- },
-};
diff --git a/apps/web/src/lib/masks/definitions/index.ts b/apps/web/src/lib/masks/definitions/index.ts
deleted file mode 100644
index fd00cab1..00000000
--- a/apps/web/src/lib/masks/definitions/index.ts
+++ /dev/null
@@ -1,74 +0,0 @@
-import type { BaseMaskParams, MaskDefinition } from "@/lib/masks/types";
-import { masksRegistry, type MaskIconProps } from "../registry";
-import { cinematicBarsMaskDefinition } from "./cinematic-bars";
-import { customMaskDefinition } from "./custom";
-import { diamondMaskDefinition } from "./diamond";
-import { ellipseMaskDefinition } from "./ellipse";
-import { heartMaskDefinition } from "./heart";
-import { rectangleMaskDefinition } from "./rectangle";
-import { splitMaskDefinition } from "./split";
-import { starMaskDefinition } from "./star";
-import { textMaskDefinition } from "./text";
-import {
- MinusSignIcon,
- PanelRightDashedIcon,
- SquareIcon,
- CircleIcon,
- FavouriteIcon,
- DiamondIcon,
- StarsIcon,
- TextFontIcon,
-} from "@hugeicons/core-free-icons";
-
-function registerDefaultMask<TParams extends BaseMaskParams>({
- definition,
- icon,
-}: {
- definition: MaskDefinition<TParams>;
- icon: MaskIconProps;
-}) {
- if (masksRegistry.has(definition.type)) {
- return;
- }
-
- masksRegistry.registerMask({ definition, icon });
-}
-
-export function registerDefaultMasks(): void {
- registerDefaultMask({
- definition: splitMaskDefinition,
- icon: { icon: PanelRightDashedIcon, strokeWidth: 1 },
- });
- registerDefaultMask({
- definition: cinematicBarsMaskDefinition,
- icon: { icon: MinusSignIcon },
- });
- registerDefaultMask({
- definition: rectangleMaskDefinition,
- icon: { icon: SquareIcon },
- });
- registerDefaultMask({
- definition: ellipseMaskDefinition,
- icon: { icon: CircleIcon },
- });
- registerDefaultMask({
- definition: heartMaskDefinition,
- icon: { icon: FavouriteIcon },
- });
- registerDefaultMask({
- definition: diamondMaskDefinition,
- icon: { icon: DiamondIcon },
- });
- registerDefaultMask({
- definition: starMaskDefinition,
- icon: { icon: StarsIcon },
- });
- registerDefaultMask({
- definition: textMaskDefinition,
- icon: { icon: TextFontIcon },
- });
- registerDefaultMask({
- definition: customMaskDefinition,
- icon: { icon: SquareIcon },
- });
-}
diff --git a/apps/web/src/lib/masks/definitions/rectangle.ts b/apps/web/src/lib/masks/definitions/rectangle.ts
deleted file mode 100644
index 832b8307..00000000
--- a/apps/web/src/lib/masks/definitions/rectangle.ts
+++ /dev/null
@@ -1,97 +0,0 @@
-import type { MaskDefinition, RectangleMaskParams } from "@/lib/masks/types";
-import {
- BOX_LIKE_MASK_PARAMS,
- buildBoxMaskInteraction,
- computeBoxMaskParamUpdate,
- getBoxLikeGeometry,
- getDefaultSquareMaskParams,
- getStrokeOffset,
- rotatePoint,
-} from "./box-like";
-
-function buildRectanglePath({
- centerX,
- centerY,
- halfWidth,
- halfHeight,
- rotationRad,
-}: {
- centerX: number;
- centerY: number;
- halfWidth: number;
- halfHeight: number;
- rotationRad: number;
-}): Path2D {
- const corners = [
- { x: centerX - halfWidth, y: centerY - halfHeight },
- { x: centerX + halfWidth, y: centerY - halfHeight },
- { x: centerX + halfWidth, y: centerY + halfHeight },
- { x: centerX - halfWidth, y: centerY + halfHeight },
- ].map((point) =>
- rotatePoint({
- ...point,
- centerX,
- centerY,
- rotationRad,
- }),
- );
-
- const path = new Path2D();
- path.moveTo(corners[0].x, corners[0].y);
- for (const corner of corners.slice(1)) {
- path.lineTo(corner.x, corner.y);
- }
- path.closePath();
- return path;
-}
-
-export const rectangleMaskDefinition: MaskDefinition<RectangleMaskParams> = {
- type: "rectangle",
- name: "Rectangle",
- features: {
- hasPosition: true,
- hasRotation: true,
- sizeMode: "width-height",
- },
- params: BOX_LIKE_MASK_PARAMS,
- interaction: buildBoxMaskInteraction({
- sizeMode: "width-height",
- }),
- buildDefault(context) {
- return {
- type: "rectangle",
- params: getDefaultSquareMaskParams(context),
- };
- },
- computeParamUpdate: computeBoxMaskParamUpdate,
- renderer: {
- buildPath({ resolvedParams, width, height }) {
- const params = resolvedParams as RectangleMaskParams;
- const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
- getBoxLikeGeometry({ params, width, height });
- return buildRectanglePath({
- centerX,
- centerY,
- halfWidth: maskWidth / 2,
- halfHeight: maskHeight / 2,
- rotationRad,
- });
- },
- buildStrokePath({ resolvedParams, width, height }) {
- const params = resolvedParams as RectangleMaskParams;
- const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
- getBoxLikeGeometry({ params, width, height });
- const offset = getStrokeOffset({
- strokeAlign: params.strokeAlign,
- strokeWidth: params.strokeWidth,
- });
- return buildRectanglePath({
- centerX,
- centerY,
- halfWidth: Math.max(1, maskWidth / 2 + offset),
- halfHeight: Math.max(1, maskHeight / 2 + offset),
- rotationRad,
- });
- },
- },
-};
diff --git a/apps/web/src/lib/masks/definitions/split.ts b/apps/web/src/lib/masks/definitions/split.ts
deleted file mode 100644
index c1a14d90..00000000
--- a/apps/web/src/lib/masks/definitions/split.ts
+++ /dev/null
@@ -1,382 +0,0 @@
-import { computeFeatherUpdate } from "../param-update";
-import type {
- MaskDefinition,
- MaskParamUpdateArgs,
- SplitMaskParams,
-} from "@/lib/masks/types";
-import { halfPlaneSign, lineEdgeIntersection } from "../utils";
-import {
- getLineMaskHandlePositions,
- getLineMaskOverlay,
-} from "@/lib/masks/handle-positions";
-import { snapMaskInteraction } from "@/lib/masks/snap";
-
-// cos(π/2) returns ~6e-17 in JS, not 0. Values below this threshold are snapped
-// to exactly 0 to prevent opposite-sign float noise on canvas corners that lie
-// exactly on the split line, which produces spurious midpoint vertices.
-const NORMAL_SNAP_EPSILON = 1e-10;
-
-// Guards against collinear vertices from float noise at canvas edges.
-const MIN_POLYGON_AREA_PX = 0.5;
-const INTERSECTION_EPSILON = 1e-6;
-
-function polygonArea({ vertices }: { vertices: [number, number][] }): number {
- let area = 0;
- for (let i = 0; i < vertices.length; i++) {
- const [x1, y1] = vertices[i];
- const [x2, y2] = vertices[(i + 1) % vertices.length];
- area += x1 * y2 - x2 * y1;
- }
- return Math.abs(area) * 0.5;
-}
-
-function splitLineGeometry({
- centerX,
- centerY,
- rotation,
- width,
- height,
-}: {
- centerX: number;
- centerY: number;
- rotation: number;
- width: number;
- height: number;
-}): { normalX: number; normalY: number; lineX: number; lineY: number } {
- const angleRad = (rotation * Math.PI) / 180;
- const normalX =
- Math.abs(Math.cos(angleRad)) < NORMAL_SNAP_EPSILON ? 0 : Math.cos(angleRad);
- const normalY =
- Math.abs(Math.sin(angleRad)) < NORMAL_SNAP_EPSILON ? 0 : Math.sin(angleRad);
- const lineX = width / 2 + centerX * width;
- const lineY = height / 2 + centerY * height;
- return { normalX, normalY, lineX, lineY };
-}
-
-function pointsEqual(
- a: { x: number; y: number },
- b: { x: number; y: number },
-): boolean {
- return (
- Math.abs(a.x - b.x) <= INTERSECTION_EPSILON &&
- Math.abs(a.y - b.y) <= INTERSECTION_EPSILON
- );
-}
-
-export function getSplitMaskStrokeSegment({
- resolvedParams,
- width,
- height,
-}: {
- resolvedParams: unknown;
- width: number;
- height: number;
-}): [{ x: number; y: number }, { x: number; y: number }] | null {
- const { centerX, centerY, rotation } = resolvedParams as SplitMaskParams;
- const { normalX, normalY, lineX, lineY } = splitLineGeometry({
- centerX,
- centerY,
- rotation,
- width,
- height,
- });
-
- const edges: [number, number, number, number][] = [
- [0, 0, width, 0],
- [width, 0, width, height],
- [width, height, 0, height],
- [0, height, 0, 0],
- ];
- const intersections: { x: number; y: number }[] = [];
-
- for (const [x1, y1, x2, y2] of edges) {
- const hit = lineEdgeIntersection({
- lineX,
- lineY,
- normalX,
- normalY,
- x1,
- y1,
- x2,
- y2,
- });
-
- if (!hit || intersections.some((point) => pointsEqual(point, hit))) {
- continue;
- }
-
- intersections.push(hit);
- }
-
- if (intersections.length !== 2) {
- return null;
- }
-
- return [intersections[0], intersections[1]];
-}
-
-function computeSplitMaskParamUpdate({
- handleId,
- startParams,
- deltaX,
- deltaY,
- startCanvasX,
- startCanvasY,
- bounds,
- canvasSize,
-}: MaskParamUpdateArgs<SplitMaskParams>): Partial<SplitMaskParams> {
- if (handleId === "position") {
- const rawX = startParams.centerX + deltaX / bounds.width;
- const rawY = startParams.centerY + deltaY / bounds.height;
-
- const minX = -bounds.cx / bounds.width;
- const maxX = (canvasSize.width - bounds.cx) / bounds.width;
- const minY = -bounds.cy / bounds.height;
- const maxY = (canvasSize.height - bounds.cy) / bounds.height;
-
- return {
- centerX: Math.max(minX, Math.min(maxX, rawX)),
- centerY: Math.max(minY, Math.min(maxY, rawY)),
- };
- }
-
- if (handleId === "feather") {
- const angleRad = (startParams.rotation * Math.PI) / 180;
- return computeFeatherUpdate({
- startFeather: startParams.feather,
- deltaX,
- deltaY,
- directionX: -Math.cos(angleRad),
- directionY: -Math.sin(angleRad),
- });
- }
-
- if (handleId === "rotation") {
- const pivotX = bounds.cx + startParams.centerX * bounds.width;
- const pivotY = bounds.cy + startParams.centerY * bounds.height;
- const startAngle =
- (Math.atan2(startCanvasY - pivotY, startCanvasX - pivotX) * 180) /
- Math.PI;
- const currentAngle =
- (Math.atan2(
- startCanvasY + deltaY - pivotY,
- startCanvasX + deltaX - pivotX,
- ) *
- 180) /
- Math.PI;
-
- let deltaAngle = currentAngle - startAngle;
- if (deltaAngle > 180) deltaAngle -= 360;
- if (deltaAngle < -180) deltaAngle += 360;
-
- return {
- rotation: (((startParams.rotation + deltaAngle) % 360) + 360) % 360,
- };
- }
-
- return {};
-}
-
-export const splitMaskDefinition: MaskDefinition<SplitMaskParams> = {
- type: "split",
- name: "Split",
- features: {
- hasPosition: true,
- hasRotation: true,
- sizeMode: "none",
- },
- interaction: {
- getInteraction({
- params,
- bounds,
- displayScale,
- scaleX: _scaleX,
- scaleY: _scaleY,
- }) {
- return {
- handles: getLineMaskHandlePositions({
- centerX: params.centerX,
- centerY: params.centerY,
- rotation: params.rotation,
- feather: params.feather,
- bounds,
- displayScale,
- }),
- overlays: [
- getLineMaskOverlay({
- centerX: params.centerX,
- centerY: params.centerY,
- rotation: params.rotation,
- bounds,
- }),
- ],
- };
- },
- snap(args) {
- return snapMaskInteraction(args);
- },
- },
- buildDefault() {
- return {
- type: "split",
- params: {
- feather: 0,
- inverted: false,
- strokeColor: "#ffffff",
- strokeWidth: 0,
- strokeAlign: "center",
- centerX: 0,
- centerY: 0,
- rotation: 0,
- },
- };
- },
- computeParamUpdate: computeSplitMaskParamUpdate,
- params: [
- {
- key: "centerX",
- label: "X",
- type: "number",
- default: 0,
- min: -100,
- max: 100,
- step: 1,
- displayMultiplier: 100,
- },
- {
- key: "centerY",
- label: "Y",
- type: "number",
- default: 0,
- min: -100,
- max: 100,
- step: 1,
- displayMultiplier: 100,
- },
- {
- key: "rotation",
- label: "Rotation",
- type: "number",
- default: 0,
- min: 0,
- max: 360,
- step: 1,
- },
- ],
- renderer: {
- renderMaskHandlesFeather: true,
- renderMask({ resolvedParams, ctx, width, height, feather }) {
- const { centerX, centerY, rotation } = resolvedParams as SplitMaskParams;
- const { normalX, normalY, lineX, lineY } = splitLineGeometry({
- centerX,
- centerY,
- rotation,
- width,
- height,
- });
-
- // Analytical gradient avoids JFA's two-sided distance artifact near canvas edges.
- const featherHalf = feather / 2;
- const gradient = ctx.createLinearGradient(
- lineX - normalX * featherHalf,
- lineY - normalY * featherHalf,
- lineX + normalX * featherHalf,
- lineY + normalY * featherHalf,
- );
- gradient.addColorStop(0, "rgba(255,255,255,0)");
- gradient.addColorStop(1, "white");
-
- ctx.fillStyle = gradient;
- ctx.fillRect(0, 0, width, height);
- },
-
- buildPath({ resolvedParams, width, height }) {
- const { centerX, centerY, rotation } = resolvedParams as SplitMaskParams;
- const { normalX, normalY, lineX, lineY } = splitLineGeometry({
- centerX,
- centerY,
- rotation,
- width,
- height,
- });
-
- const edges: [number, number, number, number][] = [
- [0, 0, width, 0],
- [width, 0, width, height],
- [width, height, 0, height],
- [0, height, 0, 0],
- ];
-
- const isInsideHalfPlane = (x: number, y: number) =>
- halfPlaneSign({ lineX, lineY, normalX, normalY, x, y }) >= 0;
-
- const vertices: [number, number][] = [];
- for (const [x1, y1, x2, y2] of edges) {
- const isVertex1Inside = isInsideHalfPlane(x1, y1);
- const isVertex2Inside = isInsideHalfPlane(x2, y2);
-
- if (isVertex1Inside && isVertex2Inside) {
- vertices.push([x2, y2]);
- } else if (isVertex1Inside && !isVertex2Inside) {
- const hit = lineEdgeIntersection({
- lineX,
- lineY,
- normalX,
- normalY,
- x1,
- y1,
- x2,
- y2,
- });
- if (hit) vertices.push([hit.x, hit.y]);
- } else if (!isVertex1Inside && isVertex2Inside) {
- const hit = lineEdgeIntersection({
- lineX,
- lineY,
- normalX,
- normalY,
- x1,
- y1,
- x2,
- y2,
- });
- if (hit) {
- vertices.push([hit.x, hit.y]);
- vertices.push([x2, y2]);
- }
- }
- }
-
- if (
- vertices.length < 3 ||
- polygonArea({ vertices }) < MIN_POLYGON_AREA_PX
- ) {
- return new Path2D();
- }
-
- const path = new Path2D();
- path.moveTo(vertices[0][0], vertices[0][1]);
- for (let i = 1; i < vertices.length; i++) {
- path.lineTo(vertices[i][0], vertices[i][1]);
- }
- path.closePath();
- return path;
- },
- buildStrokePath({ resolvedParams, width, height }) {
- const segment = getSplitMaskStrokeSegment({
- resolvedParams,
- width,
- height,
- });
- const path = new Path2D();
-
- if (!segment) {
- return path;
- }
-
- path.moveTo(segment[0].x, segment[0].y);
- path.lineTo(segment[1].x, segment[1].y);
- return path;
- },
- },
-};
diff --git a/apps/web/src/lib/masks/definitions/star.ts b/apps/web/src/lib/masks/definitions/star.ts
deleted file mode 100644
index d311ab80..00000000
--- a/apps/web/src/lib/masks/definitions/star.ts
+++ /dev/null
@@ -1,140 +0,0 @@
-import type { MaskDefinition, RectangleMaskParams } from "@/lib/masks/types";
-import {
- BOX_LIKE_MASK_PARAMS,
- buildBoxMaskInteraction,
- computeBoxMaskParamUpdate,
- getBoxLikeGeometry,
- getDefaultSquareMaskParams,
- getStrokeOffset,
- rotatePoint,
-} from "./box-like";
-
-const STAR_INNER_RADIUS_RATIO = 0.45;
-const STAR_VERTEX_COUNT = 10;
-
-function buildStarPath({
- centerX,
- centerY,
- halfWidth,
- halfHeight,
- rotationRad,
-}: {
- centerX: number;
- centerY: number;
- halfWidth: number;
- halfHeight: number;
- rotationRad: number;
-}): Path2D {
- const path = new Path2D();
-
- for (let index = 0; index < STAR_VERTEX_COUNT; index++) {
- const isOuterVertex = index % 2 === 0;
- const radiusX = isOuterVertex
- ? halfWidth
- : halfWidth * STAR_INNER_RADIUS_RATIO;
- const radiusY = isOuterVertex
- ? halfHeight
- : halfHeight * STAR_INNER_RADIUS_RATIO;
- const angle = (index * Math.PI) / 5 - Math.PI / 2;
- const point = rotatePoint({
- x: centerX + radiusX * Math.cos(angle),
- y: centerY + radiusY * Math.sin(angle),
- centerX,
- centerY,
- rotationRad,
- });
-
- if (index === 0) {
- path.moveTo(point.x, point.y);
- } else {
- path.lineTo(point.x, point.y);
- }
- }
-
- path.closePath();
- return path;
-}
-
-function buildOverlayStarPath({
- width,
- height,
-}: {
- width: number;
- height: number;
-}): string {
- const centerX = width / 2;
- const centerY = height / 2;
- const halfWidth = width / 2;
- const halfHeight = height / 2;
- const segments: string[] = [];
-
- for (let index = 0; index < STAR_VERTEX_COUNT; index++) {
- const isOuterVertex = index % 2 === 0;
- const radiusX = isOuterVertex
- ? halfWidth
- : halfWidth * STAR_INNER_RADIUS_RATIO;
- const radiusY = isOuterVertex
- ? halfHeight
- : halfHeight * STAR_INNER_RADIUS_RATIO;
- const angle = (index * Math.PI) / 5 - Math.PI / 2;
- const x = centerX + radiusX * Math.cos(angle);
- const y = centerY + radiusY * Math.sin(angle);
- segments.push(`${index === 0 ? "M" : "L"} ${x},${y}`);
- }
-
- return `${segments.join(" ")} Z`;
-}
-
-export const starMaskDefinition: MaskDefinition<RectangleMaskParams> = {
- type: "star",
- name: "Star",
- features: {
- hasPosition: true,
- hasRotation: true,
- sizeMode: "width-height",
- },
- params: BOX_LIKE_MASK_PARAMS,
- interaction: buildBoxMaskInteraction({
- sizeMode: "width-height",
- buildOverlayPath({ width, height }) {
- return buildOverlayStarPath({ width, height });
- },
- }),
- buildDefault(context) {
- return {
- type: "star",
- params: getDefaultSquareMaskParams(context),
- };
- },
- computeParamUpdate: computeBoxMaskParamUpdate,
- renderer: {
- buildPath({ resolvedParams, width, height }) {
- const params = resolvedParams as RectangleMaskParams;
- const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
- getBoxLikeGeometry({ params, width, height });
- return buildStarPath({
- centerX,
- centerY,
- halfWidth: maskWidth / 2,
- halfHeight: maskHeight / 2,
- rotationRad,
- });
- },
- buildStrokePath({ resolvedParams, width, height }) {
- const params = resolvedParams as RectangleMaskParams;
- const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
- getBoxLikeGeometry({ params, width, height });
- const offset = getStrokeOffset({
- strokeAlign: params.strokeAlign,
- strokeWidth: params.strokeWidth,
- });
- return buildStarPath({
- centerX,
- centerY,
- halfWidth: Math.max(maskWidth / 2 + offset, 1),
- halfHeight: Math.max(maskHeight / 2 + offset, 1),
- rotationRad,
- });
- },
- },
-};
diff --git a/apps/web/src/lib/masks/definitions/text.ts b/apps/web/src/lib/masks/definitions/text.ts
deleted file mode 100644
index b774ed28..00000000
--- a/apps/web/src/lib/masks/definitions/text.ts
+++ /dev/null
@@ -1,438 +0,0 @@
-import type { ParamDefinition } from "@/lib/params";
-import type {
- MaskDefinition,
- MaskParamUpdateArgs,
- TextMask,
- TextMaskParams,
-} from "@/lib/masks/types";
-import { DEFAULTS } from "@/lib/timeline/defaults";
-import { MIN_FONT_SIZE, MAX_FONT_SIZE } from "@/lib/text/typography";
-import {
- drawMeasuredTextLayout,
- measureTextLayout,
- strokeMeasuredTextLayout,
-} from "@/lib/text/primitives";
-import { getTextMeasurementContext } from "@/lib/text/measure-element";
-import { getTextVisualRect } from "@/lib/text/layout";
-import {
- getBoxMaskHandlePositions,
- getBoxMaskRectOverlay,
-} from "@/lib/masks/handle-positions";
-import { computeFeatherUpdate } from "@/lib/masks/param-update";
-import {
- setMaskLocalCenter,
- toGlobalMaskSnapLines,
-} from "@/lib/masks/geometry";
-import {
- snapPosition,
- snapRotation,
- snapScale,
- type ScaleEdgePreference,
-} from "@/lib/preview/preview-snap";
-
-const PERCENTAGE_DISPLAY = {
- displayMultiplier: 100,
- step: 1,
-} as const;
-
-const TEXT_MASK_ALIGNMENT = DEFAULTS.text.element.textAlign;
-
-const TEXT_MASK_PARAMS: ParamDefinition<keyof TextMaskParams & string>[] = [
- {
- key: "centerX",
- label: "X",
- type: "number",
- default: 0,
- min: -100,
- max: 100,
- ...PERCENTAGE_DISPLAY,
- },
- {
- key: "centerY",
- label: "Y",
- type: "number",
- default: 0,
- min: -100,
- max: 100,
- ...PERCENTAGE_DISPLAY,
- },
- {
- key: "fontSize",
- label: "Size",
- type: "number",
- default: DEFAULTS.text.element.fontSize,
- min: MIN_FONT_SIZE,
- max: MAX_FONT_SIZE,
- step: 1,
- },
- {
- key: "rotation",
- label: "Rotation",
- type: "number",
- default: 0,
- min: 0,
- max: 360,
- step: 1,
- },
- {
- key: "scale",
- label: "Scale",
- type: "number",
- default: 1,
- min: 1,
- max: 500,
- ...PERCENTAGE_DISPLAY,
- },
-];
-
-function measureTextMask({
- params,
- height,
-}: {
- params: TextMaskParams;
- height: number;
-}) {
- const layout = measureTextLayout({
- text: {
- content: params.content,
- fontSize: params.fontSize,
- fontFamily: params.fontFamily,
- fontWeight: params.fontWeight,
- fontStyle: params.fontStyle,
- textAlign: TEXT_MASK_ALIGNMENT,
- textDecoration: params.textDecoration,
- letterSpacing: params.letterSpacing,
- lineHeight: params.lineHeight,
- },
- canvasHeight: height,
- ctx: getTextMeasurementContext(),
- });
- const visualRect = getTextVisualRect({
- textAlign: layout.textAlign,
- block: layout.block,
- background: { enabled: false, color: "transparent" },
- fontSizeRatio: layout.fontSizeRatio,
- });
-
- return {
- layout,
- intrinsicWidth: Math.max(1, visualRect.width),
- intrinsicHeight: Math.max(1, visualRect.height),
- };
-}
-
-function getScalePreferredEdges({
- handleId,
-}: {
- handleId: string;
-}): ScaleEdgePreference | undefined {
- if (handleId !== "scale") {
- return undefined;
- }
-
- return {
- right: true,
- bottom: true,
- };
-}
-
-function computeTextMaskParamUpdate({
- handleId,
- startParams,
- deltaX,
- deltaY,
- startCanvasX,
- startCanvasY,
- bounds,
-}: MaskParamUpdateArgs<TextMaskParams>): Partial<TextMaskParams> {
- if (handleId === "position") {
- return {
- centerX: startParams.centerX + deltaX / bounds.width,
- centerY: startParams.centerY + deltaY / bounds.height,
- };
- }
-
- const pivotX = bounds.cx + startParams.centerX * bounds.width;
- const pivotY = bounds.cy + startParams.centerY * bounds.height;
-
- if (handleId === "rotation") {
- const startAngle =
- (Math.atan2(startCanvasY - pivotY, startCanvasX - pivotX) * 180) /
- Math.PI;
- const currentAngle =
- (Math.atan2(
- startCanvasY + deltaY - pivotY,
- startCanvasX + deltaX - pivotX,
- ) *
- 180) /
- Math.PI;
-
- let deltaAngle = currentAngle - startAngle;
- if (deltaAngle > 180) deltaAngle -= 360;
- if (deltaAngle < -180) deltaAngle += 360;
-
- return {
- rotation: (((startParams.rotation + deltaAngle) % 360) + 360) % 360,
- };
- }
-
- if (handleId === "feather") {
- const angleRad = (startParams.rotation * Math.PI) / 180;
- return computeFeatherUpdate({
- startFeather: startParams.feather,
- deltaX,
- deltaY,
- directionX: -Math.sin(angleRad),
- directionY: Math.cos(angleRad),
- });
- }
-
- if (handleId === "scale") {
- const startDistance = Math.hypot(
- startCanvasX - pivotX,
- startCanvasY - pivotY,
- );
- const currentDistance = Math.hypot(
- startCanvasX + deltaX - pivotX,
- startCanvasY + deltaY - pivotY,
- );
- const scaleFactor = startDistance > 0 ? currentDistance / startDistance : 1;
- return {
- scale: Math.max(0.01, startParams.scale * scaleFactor),
- };
- }
-
- return {};
-}
-
-export const textMaskDefinition: MaskDefinition<TextMaskParams> = {
- type: "text",
- name: "Text",
- features: {
- hasPosition: true,
- hasRotation: true,
- sizeMode: "uniform",
- },
- params: TEXT_MASK_PARAMS,
- interaction: {
- getInteraction({
- params,
- bounds,
- displayScale,
- scaleX: _scaleX,
- scaleY: _scaleY,
- }) {
- const { intrinsicWidth, intrinsicHeight } = measureTextMask({
- params,
- height: bounds.height,
- });
- const width = (intrinsicWidth * params.scale) / bounds.width;
- const height = (intrinsicHeight * params.scale) / bounds.height;
- return {
- handles: getBoxMaskHandlePositions({
- centerX: params.centerX,
- centerY: params.centerY,
- width,
- height,
- rotation: params.rotation,
- feather: params.feather,
- sizeMode: "uniform",
- bounds,
- displayScale,
- }),
- overlays: [
- getBoxMaskRectOverlay({
- centerX: params.centerX,
- centerY: params.centerY,
- width,
- height,
- rotation: params.rotation,
- bounds,
- }),
- ],
- };
- },
- snap({
- handleId,
- startParams,
- proposedParams,
- bounds,
- canvasSize,
- snapThreshold,
- }) {
- const { intrinsicWidth, intrinsicHeight } = measureTextMask({
- params: startParams,
- height: bounds.height,
- });
- const position = {
- x: proposedParams.centerX * bounds.width,
- y: proposedParams.centerY * bounds.height,
- };
-
- if (handleId === "position") {
- const { snappedPosition, activeLines } = snapPosition({
- proposedPosition: position,
- canvasSize: bounds,
- elementSize: {
- width: intrinsicWidth * proposedParams.scale,
- height: intrinsicHeight * proposedParams.scale,
- },
- rotation: proposedParams.rotation,
- snapThreshold,
- });
-
- return {
- params: {
- ...proposedParams,
- ...setMaskLocalCenter({
- center: snappedPosition,
- bounds,
- }),
- },
- activeLines: toGlobalMaskSnapLines({
- lines: activeLines,
- bounds,
- canvasSize,
- }),
- };
- }
-
- if (handleId === "rotation") {
- const { snappedRotation } = snapRotation({
- proposedRotation: proposedParams.rotation,
- });
- return {
- params: {
- ...proposedParams,
- rotation: snappedRotation,
- },
- activeLines: [],
- };
- }
-
- if (handleId === "scale") {
- const { snappedScale, activeLines } = snapScale({
- proposedScale: proposedParams.scale,
- position,
- baseWidth: intrinsicWidth,
- baseHeight: intrinsicHeight,
- rotation: proposedParams.rotation,
- canvasSize: bounds,
- snapThreshold,
- preferredEdges: getScalePreferredEdges({ handleId }),
- });
-
- return {
- params: {
- ...proposedParams,
- scale: Math.max(0.01, snappedScale),
- },
- activeLines: toGlobalMaskSnapLines({
- lines: activeLines,
- bounds,
- canvasSize,
- }),
- };
- }
-
- return {
- params: proposedParams,
- activeLines: [],
- };
- },
- },
- buildDefault(): Omit<TextMask, "id"> {
- return {
- type: "text",
- params: {
- feather: 0,
- inverted: false,
- strokeColor: "#ffffff",
- strokeWidth: 0,
- strokeAlign: "center",
- content: "Mask",
- fontSize: DEFAULTS.text.element.fontSize,
- fontFamily: DEFAULTS.text.element.fontFamily,
- fontWeight: DEFAULTS.text.element.fontWeight,
- fontStyle: DEFAULTS.text.element.fontStyle,
- textDecoration: DEFAULTS.text.element.textDecoration,
- letterSpacing: DEFAULTS.text.letterSpacing,
- lineHeight: DEFAULTS.text.lineHeight,
- centerX: 0,
- centerY: 0,
- rotation: 0,
- scale: 1,
- },
- };
- },
- computeParamUpdate: computeTextMaskParamUpdate,
- isActive(params) {
- return params.content.trim().length > 0;
- },
- renderer: {
- renderMask({ resolvedParams, ctx, width, height }) {
- const params = resolvedParams as TextMaskParams;
- const { layout } = measureTextMask({ params, height });
-
- ctx.save();
- ctx.translate(
- width / 2 + params.centerX * width,
- height / 2 + params.centerY * height,
- );
- ctx.scale(params.scale, params.scale);
- if (params.rotation) {
- ctx.rotate((params.rotation * Math.PI) / 180);
- }
- drawMeasuredTextLayout({
- ctx,
- layout,
- textColor: "#ffffff",
- background: null,
- });
- ctx.restore();
- },
- renderStroke({ resolvedParams, ctx, width, height }) {
- const params = resolvedParams as TextMaskParams;
- const { layout } = measureTextMask({ params, height });
-
- ctx.save();
- ctx.translate(
- width / 2 + params.centerX * width,
- height / 2 + params.centerY * height,
- );
- ctx.scale(params.scale, params.scale);
- if (params.rotation) {
- ctx.rotate((params.rotation * Math.PI) / 180);
- }
-
- strokeMeasuredTextLayout({
- ctx,
- layout,
- strokeColor: params.strokeColor,
- strokeWidth: params.strokeWidth,
- });
-
- if (params.strokeAlign === "inside") {
- ctx.globalCompositeOperation = "destination-in";
- drawMeasuredTextLayout({
- ctx,
- layout,
- textColor: "#ffffff",
- background: null,
- });
- }
-
- if (params.strokeAlign === "outside") {
- ctx.globalCompositeOperation = "destination-out";
- drawMeasuredTextLayout({
- ctx,
- layout,
- textColor: "#ffffff",
- background: null,
- });
- }
-
- ctx.restore();
- },
- },
-};
diff --git a/apps/web/src/lib/masks/dimensions.ts b/apps/web/src/lib/masks/dimensions.ts
deleted file mode 100644
index dcfcdc0c..00000000
--- a/apps/web/src/lib/masks/dimensions.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-export const DEFAULT_SHAPE_MASK_SHORT_SIDE_RATIO = 0.6;
-export const MIN_MASK_DIMENSION = 0.01;
diff --git a/apps/web/src/lib/masks/feather.ts b/apps/web/src/lib/masks/feather.ts
deleted file mode 100644
index 83e80a0c..00000000
--- a/apps/web/src/lib/masks/feather.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-export const MAX_FEATHER = 1000;
-export const FEATHER_HANDLE_SCALE = 0.11;
diff --git a/apps/web/src/lib/masks/geometry.ts b/apps/web/src/lib/masks/geometry.ts
deleted file mode 100644
index a5b05030..00000000
--- a/apps/web/src/lib/masks/geometry.ts
+++ /dev/null
@@ -1,121 +0,0 @@
-import type { ElementBounds } from "@/lib/preview/element-bounds";
-import type { SnapLine } from "@/lib/preview/preview-snap";
-import { MIN_MASK_DIMENSION } from "@/lib/masks/dimensions";
-import type { RectangleMaskParams } from "@/lib/masks/types";
-
-type CenterMaskParams = {
- centerX: number;
- centerY: number;
-};
-
-type SnapGeometryParams = CenterMaskParams & {
- rotation?: number;
- width?: number;
- height?: number;
- scale?: number;
-};
-
-export function hasCenterParams(
- params: Partial<CenterMaskParams>,
-): params is CenterMaskParams {
- return (
- typeof params.centerX === "number" && typeof params.centerY === "number"
- );
-}
-
-export function isRectangleMaskParams(
- params: SnapGeometryParams,
-): params is RectangleMaskParams {
- return (
- hasCenterParams(params) &&
- typeof params.width === "number" &&
- typeof params.height === "number" &&
- typeof params.rotation === "number" &&
- typeof params.scale === "number"
- );
-}
-
-export function getMaskLocalCenter({
- params,
- bounds,
-}: {
- params: CenterMaskParams;
- bounds: ElementBounds;
-}): { x: number; y: number } | null {
- if (!hasCenterParams(params)) return null;
-
- return {
- x: params.centerX * bounds.width,
- y: params.centerY * bounds.height,
- };
-}
-
-export function setMaskLocalCenter({
- center,
- bounds,
-}: {
- center: { x: number; y: number };
- bounds: ElementBounds;
-}): { centerX: number; centerY: number } {
- return {
- centerX: bounds.width === 0 ? 0 : center.x / bounds.width,
- centerY: bounds.height === 0 ? 0 : center.y / bounds.height,
- };
-}
-
-export function getMaskSnapGeometry({
- params,
- bounds,
-}: {
- params: SnapGeometryParams;
- bounds: ElementBounds;
-}): {
- position: { x: number; y: number };
- size: { width: number; height: number };
- rotation: number;
-} | null {
- const position = getMaskLocalCenter({ params, bounds });
- if (!position) return null;
-
- if (isRectangleMaskParams(params)) {
- return {
- position,
- size: {
- width: Math.max(params.width, MIN_MASK_DIMENSION) * bounds.width,
- height: Math.max(params.height, MIN_MASK_DIMENSION) * bounds.height,
- },
- rotation: params.rotation,
- };
- }
-
- return {
- position,
- size: { width: 0, height: 0 },
- rotation: typeof params.rotation === "number" ? params.rotation : 0,
- };
-}
-
-export function toGlobalMaskSnapLines({
- lines,
- bounds,
- canvasSize,
-}: {
- lines: SnapLine[];
- bounds: ElementBounds;
- canvasSize: { width: number; height: number };
-}): SnapLine[] {
- const centerX = bounds.cx - canvasSize.width / 2;
- const centerY = bounds.cy - canvasSize.height / 2;
-
- return lines.map((line) =>
- line.type === "vertical"
- ? {
- type: "vertical" as const,
- position: centerX + line.position,
- }
- : {
- type: "horizontal" as const,
- position: centerY + line.position,
- },
- );
-}
diff --git a/apps/web/src/lib/masks/handle-positions.ts b/apps/web/src/lib/masks/handle-positions.ts
deleted file mode 100644
index d32b8424..00000000
--- a/apps/web/src/lib/masks/handle-positions.ts
+++ /dev/null
@@ -1,497 +0,0 @@
-import { FEATHER_HANDLE_SCALE } from "@/lib/masks/feather";
-import type { ElementBounds } from "@/lib/preview/element-bounds";
-import type {
- MaskFeatures,
- MaskHandlePosition,
- MaskLineOverlay,
- MaskOverlay,
- MaskRectOverlay,
- RectangleMaskParams,
- MaskShapeOverlay,
-} from "@/lib/masks/types";
-
-const LINE_HANDLE_OFFSET_SCREEN_PX = 20;
-const BOX_HANDLE_OFFSET_SCREEN_PX = 20;
-const LINE_EXTENT_MULTIPLIER = 50;
-
-const CURSOR = {
- rotate: "crosshair",
- resizeDiagonal: "nwse-resize",
- resizeHorizontal: "ew-resize",
- resizeVertical: "ns-resize",
-} as const;
-
-/**
- * The renderer defines the split line as:
- * - normal direction: (cos(rotation), sin(rotation))
- * - line direction (parallel to cut): (-sin(rotation), cos(rotation))
- * - reference point: (centerX * width, centerY * height) from element centre
- *
- * So rotation=0 → normal points right → line runs vertically.
- */
-export function getLineMaskLinePoints({
- centerX,
- centerY,
- rotation,
- bounds,
-}: {
- centerX: number;
- centerY: number;
- rotation: number;
- bounds: ElementBounds;
-}): { start: { x: number; y: number }; end: { x: number; y: number } } {
- const angleRad = (rotation * Math.PI) / 180;
- const normalX = Math.cos(angleRad);
- const normalY = Math.sin(angleRad);
- const lineDirX = -normalY;
- const lineDirY = normalX;
-
- const cx = bounds.cx + centerX * bounds.width;
- const cy = bounds.cy + centerY * bounds.height;
-
- const extent = Math.max(bounds.width, bounds.height) * LINE_EXTENT_MULTIPLIER;
-
- return {
- start: {
- x: cx - lineDirX * extent,
- y: cy - lineDirY * extent,
- },
- end: {
- x: cx + lineDirX * extent,
- y: cy + lineDirY * extent,
- },
- };
-}
-
-export function getLineMaskOverlay({
- centerX,
- centerY,
- rotation,
- bounds,
- handleId = "position",
- cursor = "move",
-}: {
- centerX: number;
- centerY: number;
- rotation: number;
- bounds: ElementBounds;
- handleId?: string;
- cursor?: string;
-}): MaskLineOverlay {
- const { start, end } = getLineMaskLinePoints({
- centerX,
- centerY,
- rotation,
- bounds,
- });
-
- return {
- id: "line",
- type: "line",
- start,
- end,
- handleId,
- cursor,
- };
-}
-
-export function getLineMaskHandlePositions({
- centerX,
- centerY,
- rotation,
- feather,
- bounds,
- displayScale,
-}: {
- centerX: number;
- centerY: number;
- rotation: number;
- feather: number;
- bounds: ElementBounds;
- displayScale: number;
-}): MaskHandlePosition[] {
- const angleRad = (rotation * Math.PI) / 180;
- const normalX = Math.cos(angleRad);
- const normalY = Math.sin(angleRad);
-
- const cx = bounds.cx + centerX * bounds.width;
- const cy = bounds.cy + centerY * bounds.height;
-
- const iconOffsetCanvas = LINE_HANDLE_OFFSET_SCREEN_PX / displayScale;
- const featherOffset = iconOffsetCanvas + feather * FEATHER_HANDLE_SCALE;
-
- return [
- {
- id: "rotation",
- x: cx + normalX * iconOffsetCanvas,
- y: cy + normalY * iconOffsetCanvas,
- cursor: CURSOR.rotate,
- kind: "icon",
- icon: "rotate",
- },
- {
- id: "feather",
- x: cx - normalX * featherOffset,
- y: cy - normalY * featherOffset,
- cursor: CURSOR.resizeHorizontal,
- kind: "icon",
- icon: "feather",
- },
- ];
-}
-
-function rotatePoint({
- localX,
- localY,
- cx,
- cy,
- angleRad,
-}: {
- localX: number;
- localY: number;
- cx: number;
- cy: number;
- angleRad: number;
-}): { x: number; y: number } {
- const cos = Math.cos(angleRad);
- const sin = Math.sin(angleRad);
- return {
- x: cx + localX * cos - localY * sin,
- y: cy + localX * sin + localY * cos,
- };
-}
-
-export function getBoxMaskHandlePositions({
- centerX,
- centerY,
- width,
- height,
- rotation,
- feather,
- sizeMode,
- showScaleHandle = true,
- bounds,
- displayScale,
-}: {
- centerX: number;
- centerY: number;
- width: number;
- height: number;
- rotation: number;
- feather: number;
- sizeMode: MaskFeatures["sizeMode"];
- showScaleHandle?: boolean;
- bounds: ElementBounds;
- displayScale: number;
-}): MaskHandlePosition[] {
- const cx = bounds.cx + centerX * bounds.width;
- const cy = bounds.cy + centerY * bounds.height;
- const angleRad = (rotation * Math.PI) / 180;
- const halfWidth = (width * bounds.width) / 2;
- const halfHeight = (height * bounds.height) / 2;
-
- const handles: MaskHandlePosition[] = [];
- const handleOffsetCanvas = BOX_HANDLE_OFFSET_SCREEN_PX / displayScale;
-
- const rotHandle = rotatePoint({
- localX: 0,
- localY: -halfHeight - handleOffsetCanvas,
- cx,
- cy,
- angleRad,
- });
- handles.push({
- id: "rotation",
- x: rotHandle.x,
- y: rotHandle.y,
- cursor: CURSOR.rotate,
- kind: "icon",
- icon: "rotate",
- });
-
- const featherHandle = rotatePoint({
- localX: 0,
- localY: halfHeight + handleOffsetCanvas + feather * FEATHER_HANDLE_SCALE,
- cx,
- cy,
- angleRad,
- });
- handles.push({
- id: "feather",
- x: featherHandle.x,
- y: featherHandle.y,
- cursor: CURSOR.resizeVertical,
- kind: "icon",
- icon: "feather",
- });
-
- if (sizeMode === "width-height") {
- const corners = [
- { localX: -halfWidth, localY: -halfHeight, id: "top-left" },
- { localX: halfWidth, localY: -halfHeight, id: "top-right" },
- { localX: halfWidth, localY: halfHeight, id: "bottom-right" },
- { localX: -halfWidth, localY: halfHeight, id: "bottom-left" },
- ];
- for (const { localX, localY, id } of corners) {
- const point = rotatePoint({ localX, localY, cx, cy, angleRad });
- handles.push({
- id,
- x: point.x,
- y: point.y,
- cursor: CURSOR.resizeDiagonal,
- kind: "corner",
- });
- }
- const right = rotatePoint({
- localX: halfWidth,
- localY: 0,
- cx,
- cy,
- angleRad,
- });
- const left = rotatePoint({
- localX: -halfWidth,
- localY: 0,
- cx,
- cy,
- angleRad,
- });
- const bottom = rotatePoint({
- localX: 0,
- localY: halfHeight,
- cx,
- cy,
- angleRad,
- });
- handles.push({
- id: "left",
- x: left.x,
- y: left.y,
- cursor: CURSOR.resizeHorizontal,
- kind: "edge",
- edgeAxis: "horizontal",
- rotation,
- });
- handles.push({
- id: "right",
- x: right.x,
- y: right.y,
- cursor: CURSOR.resizeHorizontal,
- kind: "edge",
- edgeAxis: "horizontal",
- rotation,
- });
- handles.push({
- id: "bottom",
- x: bottom.x,
- y: bottom.y,
- cursor: CURSOR.resizeVertical,
- kind: "edge",
- edgeAxis: "vertical",
- rotation,
- });
- } else if (sizeMode === "height-only") {
- const top = rotatePoint({
- localX: 0,
- localY: -halfHeight,
- cx,
- cy,
- angleRad,
- });
- const bottom = rotatePoint({
- localX: 0,
- localY: halfHeight,
- cx,
- cy,
- angleRad,
- });
- handles.push({
- id: "top",
- x: top.x,
- y: top.y,
- cursor: CURSOR.resizeVertical,
- kind: "edge",
- edgeAxis: "vertical",
- rotation,
- });
- handles.push({
- id: "bottom",
- x: bottom.x,
- y: bottom.y,
- cursor: CURSOR.resizeVertical,
- kind: "edge",
- edgeAxis: "vertical",
- rotation,
- });
- } else if (sizeMode === "width-only") {
- const left = rotatePoint({
- localX: -halfWidth,
- localY: 0,
- cx,
- cy,
- angleRad,
- });
- const right = rotatePoint({
- localX: halfWidth,
- localY: 0,
- cx,
- cy,
- angleRad,
- });
- handles.push({
- id: "left",
- x: left.x,
- y: left.y,
- cursor: CURSOR.resizeHorizontal,
- kind: "edge",
- edgeAxis: "horizontal",
- rotation,
- });
- handles.push({
- id: "right",
- x: right.x,
- y: right.y,
- cursor: CURSOR.resizeHorizontal,
- kind: "edge",
- edgeAxis: "horizontal",
- rotation,
- });
- } else if (sizeMode === "uniform" && showScaleHandle) {
- const point = rotatePoint({
- localX: halfWidth,
- localY: halfHeight,
- cx,
- cy,
- angleRad,
- });
- handles.push({
- id: "scale",
- x: point.x,
- y: point.y,
- cursor: CURSOR.resizeDiagonal,
- kind: "corner",
- });
- }
-
- return handles;
-}
-
-export function getBoxMaskRectOverlay({
- centerX,
- centerY,
- width,
- height,
- rotation,
- bounds,
- handleId = "position",
- cursor = "move",
- dashed = false,
-}: {
- centerX: number;
- centerY: number;
- width: number;
- height: number;
- rotation: number;
- bounds: ElementBounds;
- handleId?: string;
- cursor?: string;
- dashed?: boolean;
-}): MaskRectOverlay {
- return {
- id: "bounding-box",
- type: "rect",
- center: {
- x: bounds.cx + centerX * bounds.width,
- y: bounds.cy + centerY * bounds.height,
- },
- width: width * bounds.width,
- height: height * bounds.height,
- rotation,
- handleId,
- cursor,
- dashed,
- };
-}
-
-export function getBoxMaskShapeOverlay({
- centerX,
- centerY,
- width,
- height,
- rotation,
- bounds,
- pathData,
- handleId = "position",
- cursor = "move",
-}: {
- centerX: number;
- centerY: number;
- width: number;
- height: number;
- rotation: number;
- bounds: ElementBounds;
- pathData: string;
- handleId?: string;
- cursor?: string;
-}): MaskShapeOverlay {
- return {
- id: "shape-outline",
- type: "shape",
- center: {
- x: bounds.cx + centerX * bounds.width,
- y: bounds.cy + centerY * bounds.height,
- },
- width: width * bounds.width,
- height: height * bounds.height,
- rotation,
- pathData,
- handleId,
- cursor,
- };
-}
-
-export function getBoxMaskOverlays({
- params,
- bounds,
- pathData,
- showBoundingBox = true,
-}: {
- params: Pick<
- RectangleMaskParams,
- "centerX" | "centerY" | "width" | "height" | "rotation"
- >;
- bounds: ElementBounds;
- pathData?: string;
- showBoundingBox?: boolean;
-}): MaskOverlay[] {
- const overlays: MaskOverlay[] = [];
- if (showBoundingBox) {
- overlays.push(
- getBoxMaskRectOverlay({
- centerX: params.centerX,
- centerY: params.centerY,
- width: params.width,
- height: params.height,
- rotation: params.rotation,
- bounds,
- dashed: Boolean(pathData),
- }),
- );
- }
-
- if (pathData) {
- overlays.push(
- getBoxMaskShapeOverlay({
- centerX: params.centerX,
- centerY: params.centerY,
- width: params.width,
- height: params.height,
- rotation: params.rotation,
- bounds,
- pathData,
- }),
- );
- }
-
- return overlays;
-}
diff --git a/apps/web/src/lib/masks/index.ts b/apps/web/src/lib/masks/index.ts
deleted file mode 100644
index 43871b44..00000000
--- a/apps/web/src/lib/masks/index.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-import type { Mask, MaskDefaultContext, MaskType } from "@/lib/masks/types";
-import { masksRegistry } from "./registry";
-import { generateUUID } from "@/utils/id";
-
-export { masksRegistry } from "./registry";
-export { registerDefaultMasks } from "./definitions";
-
-export function buildDefaultMaskInstance({
- maskType,
- elementSize,
-}: {
- maskType: MaskType;
- elementSize?: { width: number; height: number };
-}): Mask {
- const definition = masksRegistry.get(maskType);
- const context: MaskDefaultContext = { elementSize };
- return { ...definition.buildDefault(context), id: generateUUID() } as Mask;
-}
diff --git a/apps/web/src/lib/masks/param-update.ts b/apps/web/src/lib/masks/param-update.ts
deleted file mode 100644
index 4f8693eb..00000000
--- a/apps/web/src/lib/masks/param-update.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import { FEATHER_HANDLE_SCALE, MAX_FEATHER } from "@/lib/masks/feather";
-
-export function computeFeatherUpdate({
- startFeather,
- deltaX,
- deltaY,
- directionX,
- directionY,
-}: {
- startFeather: number;
- deltaX: number;
- deltaY: number;
- directionX: number;
- directionY: number;
-}): { feather: number } {
- const projection = deltaX * directionX + deltaY * directionY;
- return {
- feather: Math.max(
- 0,
- Math.min(
- MAX_FEATHER,
- Math.round(startFeather + projection / FEATHER_HANDLE_SCALE),
- ),
- ),
- };
-}
diff --git a/apps/web/src/lib/masks/registry.ts b/apps/web/src/lib/masks/registry.ts
deleted file mode 100644
index ae8ddd20..00000000
--- a/apps/web/src/lib/masks/registry.ts
+++ /dev/null
@@ -1,122 +0,0 @@
-import { MAX_FEATHER } from "@/lib/masks/feather";
-import type { ParamDefinition } from "@/lib/params";
-import type {
- BaseMaskParams,
- MaskDefaultContext,
- MaskDefinition,
- MaskInteractionResult,
- MaskSnapArgs,
- MaskSnapResult,
- MaskType,
-} from "@/lib/masks/types";
-import type { HugeiconsIconProps } from "@hugeicons/react";
-import { DefinitionRegistry } from "@/lib/registry";
-
-export type MaskIconProps = {
- icon: HugeiconsIconProps["icon"];
- strokeWidth?: number;
-};
-
-const BASE_MASK_PARAM_DEFINITIONS: ParamDefinition<
- keyof BaseMaskParams & string
->[] = [
- {
- key: "feather",
- label: "Feather",
- type: "number",
- default: 0,
- min: 0,
- max: MAX_FEATHER,
- step: 1,
- unit: "percent",
- },
- {
- key: "strokeWidth",
- label: "Stroke width",
- type: "number",
- default: 0,
- min: 0,
- max: 100,
- step: 1,
- },
- {
- key: "strokeColor",
- label: "Stroke color",
- type: "color",
- default: "#ffffff",
- },
-];
-
-export interface RegisteredMaskDefinition {
- type: MaskType;
- name: string;
- features: MaskDefinition<BaseMaskParams>["features"];
- params: ParamDefinition<string>[];
- renderer: MaskDefinition<BaseMaskParams>["renderer"];
- interaction: {
- getInteraction(args: {
- params: BaseMaskParams;
- bounds: Parameters<
- MaskDefinition<BaseMaskParams>["interaction"]["getInteraction"]
- >[0]["bounds"];
- displayScale: number;
- scaleX: number;
- scaleY: number;
- }): MaskInteractionResult;
- snap?(args: MaskSnapArgs<BaseMaskParams>): MaskSnapResult<BaseMaskParams>;
- };
- isActive?: (params: BaseMaskParams) => boolean;
- buildDefault(
- context: MaskDefaultContext,
- ): ReturnType<MaskDefinition<BaseMaskParams>["buildDefault"]>;
- computeParamUpdate(
- args: Parameters<MaskDefinition<BaseMaskParams>["computeParamUpdate"]>[0],
- ): ReturnType<MaskDefinition<BaseMaskParams>["computeParamUpdate"]>;
- icon: MaskIconProps;
-}
-
-export class MasksRegistry extends DefinitionRegistry<
- MaskType,
- RegisteredMaskDefinition
-> {
- constructor() {
- super("mask");
- }
-
- registerMask<TParams extends BaseMaskParams>({
- definition,
- icon,
- }: {
- definition: MaskDefinition<TParams>;
- icon: MaskIconProps;
- }): void {
- const withBaseParams: RegisteredMaskDefinition = {
- type: definition.type,
- name: definition.name,
- features: definition.features,
- params: [...definition.params, ...BASE_MASK_PARAM_DEFINITIONS],
- renderer: definition.renderer,
- interaction: {
- getInteraction(args) {
- return definition.interaction.getInteraction(args as never);
- },
- snap: definition.interaction.snap
- ? (args) => definition.interaction.snap?.(args as never) as never
- : undefined,
- },
- isActive: definition.isActive
- ? (params) => definition.isActive?.(params as TParams) ?? true
- : undefined,
- buildDefault(context) {
- return definition.buildDefault(context);
- },
- computeParamUpdate(args) {
- return definition.computeParamUpdate(args as never);
- },
- icon,
- };
- this.register(definition.type, withBaseParams);
- }
-}
-
-export const masksRegistry = new MasksRegistry();
diff --git a/apps/web/src/lib/masks/snap.ts b/apps/web/src/lib/masks/snap.ts
deleted file mode 100644
index 7396dd82..00000000
--- a/apps/web/src/lib/masks/snap.ts
+++ /dev/null
@@ -1,336 +0,0 @@
-import type { ElementBounds } from "@/lib/preview/element-bounds";
-import { MIN_MASK_DIMENSION } from "@/lib/masks/dimensions";
-import {
- snapPosition,
- snapRotation,
- snapScale,
- snapScaleAxes,
- type ScaleEdgePreference,
- type SnapLine,
-} from "@/lib/preview/preview-snap";
-import type { RectangleMaskParams, SplitMaskParams } from "@/lib/masks/types";
-import {
- isRectangleMaskParams,
- getMaskSnapGeometry,
- setMaskLocalCenter,
- toGlobalMaskSnapLines,
-} from "./geometry";
-
-type SharedMaskParams = SplitMaskParams | RectangleMaskParams;
-
-type MaskSnapResult<TParams extends SharedMaskParams> = {
- params: TParams;
- activeLines: SnapLine[];
-};
-
-const CORNER_SIZE_HANDLES = new Set([
- "top-left",
- "top-right",
- "bottom-left",
- "bottom-right",
-]);
-
-function getClampedRatio({
- next,
- base,
-}: {
- next: number;
- base: number;
-}): number {
- return (
- Math.max(next, MIN_MASK_DIMENSION) / Math.max(base, MIN_MASK_DIMENSION)
- );
-}
-
-function getPreferredEdges({
- handleId,
-}: {
- handleId: string;
-}): ScaleEdgePreference | undefined {
- const preferredEdges = {
- left:
- handleId === "left" ||
- handleId === "top-left" ||
- handleId === "bottom-left",
- right:
- handleId === "right" ||
- handleId === "top-right" ||
- handleId === "bottom-right",
- top:
- handleId === "top" || handleId === "top-left" || handleId === "top-right",
- bottom:
- handleId === "bottom" ||
- handleId === "bottom-left" ||
- handleId === "bottom-right",
- } satisfies ScaleEdgePreference;
-
- return Object.values(preferredEdges).some(Boolean)
- ? preferredEdges
- : undefined;
-}
-
-function snapMaskPosition({
- proposedParams,
- bounds,
- canvasSize,
- snapThreshold,
-}: {
- proposedParams: SharedMaskParams;
- bounds: ElementBounds;
- canvasSize: { width: number; height: number };
- snapThreshold: { x: number; y: number };
-}): MaskSnapResult<SharedMaskParams> {
- const geometry = getMaskSnapGeometry({
- params: proposedParams,
- bounds,
- });
- if (!geometry) {
- return { params: proposedParams, activeLines: [] };
- }
-
- const { snappedPosition, activeLines } = snapPosition({
- proposedPosition: geometry.position,
- canvasSize: bounds,
- elementSize: geometry.size,
- rotation: geometry.rotation,
- snapThreshold,
- });
-
- return {
- params: {
- ...proposedParams,
- ...setMaskLocalCenter({
- center: snappedPosition,
- bounds,
- }),
- },
- activeLines: toGlobalMaskSnapLines({
- lines: activeLines,
- bounds,
- canvasSize,
- }),
- };
-}
-
-function snapMaskRotation({
- proposedParams,
-}: {
- proposedParams: SharedMaskParams;
-}): MaskSnapResult<SharedMaskParams> {
- if (typeof proposedParams.rotation !== "number") {
- return { params: proposedParams, activeLines: [] };
- }
-
- const { snappedRotation } = snapRotation({
- proposedRotation: proposedParams.rotation,
- });
-
- return {
- params: {
- ...proposedParams,
- rotation: snappedRotation,
- },
- activeLines: [],
- };
-}
-
-function snapBoxMaskSize({
- handleId,
- startParams,
- proposedParams,
- bounds,
- canvasSize,
- snapThreshold,
-}: {
- handleId: string;
- startParams: SharedMaskParams;
- proposedParams: SharedMaskParams;
- bounds: ElementBounds;
- canvasSize: { width: number; height: number };
- snapThreshold: { x: number; y: number };
-}): MaskSnapResult<SharedMaskParams> {
- if (
- !isRectangleMaskParams(startParams) ||
- !isRectangleMaskParams(proposedParams)
- ) {
- return { params: proposedParams, activeLines: [] };
- }
-
- const geometry = getMaskSnapGeometry({
- params: proposedParams,
- bounds,
- });
- if (!geometry) {
- return { params: proposedParams, activeLines: [] };
- }
-
- const localCanvasSize = bounds;
- const baseWidth =
- Math.max(startParams.width, MIN_MASK_DIMENSION) * bounds.width;
- const baseHeight =
- Math.max(startParams.height, MIN_MASK_DIMENSION) * bounds.height;
- const preferredEdges = getPreferredEdges({ handleId });
-
- if (handleId === "right" || handleId === "left") {
- const proposedScaleX = getClampedRatio({
- next: proposedParams.width,
- base: startParams.width,
- });
- const { x } = snapScaleAxes({
- proposedScaleX,
- proposedScaleY: 1,
- position: geometry.position,
- baseWidth,
- baseHeight,
- rotation: proposedParams.rotation,
- canvasSize: localCanvasSize,
- snapThreshold,
- preferredEdges,
- });
-
- return {
- params: {
- ...proposedParams,
- width: Math.max(MIN_MASK_DIMENSION, startParams.width * x.snappedScale),
- },
- activeLines: toGlobalMaskSnapLines({
- lines: x.activeLines,
- bounds,
- canvasSize,
- }),
- };
- }
-
- if (handleId === "top" || handleId === "bottom") {
- const proposedScaleY = getClampedRatio({
- next: proposedParams.height,
- base: startParams.height,
- });
- const { y } = snapScaleAxes({
- proposedScaleX: 1,
- proposedScaleY,
- position: geometry.position,
- baseWidth,
- baseHeight,
- rotation: proposedParams.rotation,
- canvasSize: localCanvasSize,
- snapThreshold,
- preferredEdges,
- });
-
- return {
- params: {
- ...proposedParams,
- height: Math.max(
- MIN_MASK_DIMENSION,
- startParams.height * y.snappedScale,
- ),
- },
- activeLines: toGlobalMaskSnapLines({
- lines: y.activeLines,
- bounds,
- canvasSize,
- }),
- };
- }
-
- if (handleId === "scale") {
- const baseScale = Math.max(startParams.scale, MIN_MASK_DIMENSION);
- const proposedScale = getClampedRatio({
- next: proposedParams.scale,
- base: startParams.scale,
- });
- const { snappedScale, activeLines } = snapScale({
- proposedScale,
- position: geometry.position,
- baseWidth: baseWidth * baseScale,
- baseHeight: baseHeight * baseScale,
- rotation: proposedParams.rotation,
- canvasSize: localCanvasSize,
- snapThreshold,
- preferredEdges,
- });
-
- return {
- params: {
- ...proposedParams,
- scale: Math.max(MIN_MASK_DIMENSION, startParams.scale * snappedScale),
- },
- activeLines: toGlobalMaskSnapLines({
- lines: activeLines,
- bounds,
- canvasSize,
- }),
- };
- }
-
- if (CORNER_SIZE_HANDLES.has(handleId)) {
- const proposedScale = getClampedRatio({
- next: proposedParams.width,
- base: startParams.width,
- });
- const { snappedScale, activeLines } = snapScale({
- proposedScale,
- position: geometry.position,
- baseWidth,
- baseHeight,
- rotation: proposedParams.rotation,
- canvasSize: localCanvasSize,
- snapThreshold,
- preferredEdges,
- });
-
- return {
- params: {
- ...proposedParams,
- width: Math.max(MIN_MASK_DIMENSION, startParams.width * snappedScale),
- height: Math.max(MIN_MASK_DIMENSION, startParams.height * snappedScale),
- },
- activeLines: toGlobalMaskSnapLines({
- lines: activeLines,
- bounds,
- canvasSize,
- }),
- };
- }
-
- return { params: proposedParams, activeLines: [] };
-}
-
-export function snapMaskInteraction<TParams extends SharedMaskParams>({
- handleId,
- startParams,
- proposedParams,
- bounds,
- canvasSize,
- snapThreshold,
-}: {
- handleId: string;
- startParams: TParams;
- proposedParams: TParams;
- bounds: ElementBounds;
- canvasSize: { width: number; height: number };
- snapThreshold: { x: number; y: number };
-}): MaskSnapResult<TParams> {
- if (handleId === "position") {
- return snapMaskPosition({
- proposedParams,
- bounds,
- canvasSize,
- snapThreshold,
- }) as MaskSnapResult<TParams>;
- }
-
- if (handleId === "rotation") {
- return snapMaskRotation({ proposedParams }) as MaskSnapResult<TParams>;
- }
-
- return snapBoxMaskSize({
- handleId,
- startParams,
- proposedParams,
- bounds,
- canvasSize,
- snapThreshold,
- }) as MaskSnapResult<TParams>;
-}
diff --git a/apps/web/src/lib/masks/types.ts b/apps/web/src/lib/masks/types.ts
deleted file mode 100644
index 5a0d126d..00000000
--- a/apps/web/src/lib/masks/types.ts
+++ /dev/null
@@ -1,298 +0,0 @@
-import type { ElementBounds } from "@/lib/preview/element-bounds";
-import type { SnapLine } from "@/lib/preview/preview-snap";
-import type { ParamDefinition } from "@/lib/params";
-import type { CustomMaskPathPoint } from "@/lib/masks/custom-path";
-import type {
- TextDecoration,
- TextFontStyle,
- TextFontWeight,
-} from "@/lib/text/primitives";
-
-export type MaskType =
- | "split"
- | "cinematic-bars"
- | "rectangle"
- | "ellipse"
- | "heart"
- | "diamond"
- | "star"
- | "text"
- | "custom";
-
-export interface BaseMaskParams {
- feather: number;
- inverted: boolean;
- strokeColor: string;
- strokeWidth: number;
- strokeAlign: "inside" | "center" | "outside";
-}
-
-export interface SplitMaskParams extends BaseMaskParams {
- centerX: number;
- centerY: number;
- rotation: number;
-}
-
-export interface RectangleMaskParams extends BaseMaskParams {
- centerX: number;
- centerY: number;
- width: number;
- height: number;
- rotation: number;
- scale: number;
-}
-
-export interface TextMaskParams extends BaseMaskParams {
- content: string;
- fontSize: number;
- fontFamily: string;
- fontWeight: TextFontWeight;
- fontStyle: TextFontStyle;
- textDecoration: TextDecoration;
- letterSpacing: number;
- lineHeight: number;
- centerX: number;
- centerY: number;
- rotation: number;
- scale: number;
-}
-
-export interface CustomMaskParams extends BaseMaskParams {
- path: CustomMaskPathPoint[];
- closed: boolean;
- centerX: number;
- centerY: number;
- rotation: number;
- scale: number;
-}
-
-export interface SplitMask {
- id: string;
- type: "split";
- params: SplitMaskParams;
-}
-
-export interface CinematicBarsMask {
- id: string;
- type: "cinematic-bars";
- params: RectangleMaskParams;
-}
-
-export interface RectangleMask {
- id: string;
- type: "rectangle";
- params: RectangleMaskParams;
-}
-
-export interface EllipseMask {
- id: string;
- type: "ellipse";
- params: RectangleMaskParams;
-}
-
-export interface HeartMask {
- id: string;
- type: "heart";
- params: RectangleMaskParams;
-}
-
-export interface DiamondMask {
- id: string;
- type: "diamond";
- params: RectangleMaskParams;
-}
-
-export interface StarMask {
- id: string;
- type: "star";
- params: RectangleMaskParams;
-}
-
-export interface TextMask {
- id: string;
- type: "text";
- params: TextMaskParams;
-}
-
-export interface CustomMask {
- id: string;
- type: "custom";
- params: CustomMaskParams;
-}
-
-export type Mask =
- | SplitMask
- | CinematicBarsMask
- | RectangleMask
- | EllipseMask
- | HeartMask
- | DiamondMask
- | StarMask
- | TextMask
- | CustomMask;
-
-export interface MaskRenderer {
- buildPath?: (params: {
- resolvedParams: unknown;
- width: number;
- height: number;
- }) => Path2D;
- buildStrokePath?: (params: {
- resolvedParams: unknown;
- width: number;
- height: number;
- }) => Path2D;
- /** Renders the feathered mask directly onto ctx, bypassing JFA. */
- renderMask?: (params: {
- resolvedParams: unknown;
- ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
- width: number;
- height: number;
- feather: number;
- }) => void;
- renderMaskHandlesFeather?: boolean;
- renderStroke?: (params: {
- resolvedParams: unknown;
- ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
- width: number;
- height: number;
- }) => void;
-}
-
-export interface MaskFeatures {
- hasPosition: boolean;
- hasRotation: boolean;
- sizeMode: "none" | "uniform" | "width-height" | "height-only" | "width-only";
-}
-
-export type MaskHandleIcon = "rotate" | "feather";
-
-export type MaskHandleKind = "corner" | "edge" | "icon" | "point" | "tangent";
-
-export interface MaskHandlePosition {
- id: string;
- x: number;
- y: number;
- cursor: string;
- kind: MaskHandleKind;
- isSelected?: boolean;
- edgeAxis?: "horizontal" | "vertical";
- rotation?: number;
- icon?: MaskHandleIcon;
-}
-
-export interface MaskLineOverlay {
- id: string;
- type: "line";
- start: { x: number; y: number };
- end: { x: number; y: number };
- cursor?: string;
- handleId?: string;
-}
-
-export interface MaskRectOverlay {
- id: string;
- type: "rect";
- center: { x: number; y: number };
- width: number;
- height: number;
- rotation: number;
- dashed?: boolean;
- cursor?: string;
- handleId?: string;
-}
-
-export interface MaskShapeOverlay {
- id: string;
- type: "shape";
- center: { x: number; y: number };
- width: number;
- height: number;
- rotation: number;
- pathData: string;
- cursor?: string;
- handleId?: string;
-}
-
-export interface MaskCanvasPathOverlay {
- id: string;
- type: "canvas-path";
- pathData: string;
- coordinateSpace?: "canvas" | "overlay";
- cursor?: string;
- handleId?: string;
- strokeWidth?: number;
- strokeOpacity?: number;
-}
-
-export type MaskOverlay =
- | MaskLineOverlay
- | MaskRectOverlay
- | MaskShapeOverlay
- | MaskCanvasPathOverlay;
-
-export interface MaskDefaultContext {
- elementSize?: { width: number; height: number };
-}
-
-export interface MaskParamUpdateArgs<
- TParams extends BaseMaskParams = BaseMaskParams,
-> {
- handleId: string;
- startParams: TParams;
- deltaX: number;
- deltaY: number;
- startCanvasX: number;
- startCanvasY: number;
- bounds: ElementBounds;
- canvasSize: { width: number; height: number };
-}
-
-export interface MaskSnapArgs<TParams extends BaseMaskParams = BaseMaskParams> {
- handleId: string;
- startParams: TParams;
- proposedParams: TParams;
- bounds: ElementBounds;
- canvasSize: { width: number; height: number };
- snapThreshold: { x: number; y: number };
-}
-
-export interface MaskSnapResult<
- TParams extends BaseMaskParams = BaseMaskParams,
-> {
- params: TParams;
- activeLines: SnapLine[];
-}
-
-export interface MaskInteractionResult {
- handles: MaskHandlePosition[];
- overlays: MaskOverlay[];
-}
-
-export interface MaskInteractionDefinition<
- TParams extends BaseMaskParams = BaseMaskParams,
-> {
- getInteraction(args: {
- params: TParams;
- bounds: ElementBounds;
- displayScale: number;
- scaleX: number;
- scaleY: number;
- }): MaskInteractionResult;
- snap?(args: MaskSnapArgs<TParams>): MaskSnapResult<TParams>;
-}
-
-export interface MaskDefinition<
- TParams extends BaseMaskParams = BaseMaskParams,
-> {
- type: MaskType;
- name: string;
- features: MaskFeatures;
- params: ParamDefinition<keyof TParams & string>[];
- renderer: MaskRenderer;
- interaction: MaskInteractionDefinition<TParams>;
- /** When defined and returning false, the mask is not applied and the element renders fully visible. */
- isActive?: (params: TParams) => boolean;
- buildDefault(context: MaskDefaultContext): Omit<Mask, "id">;
- computeParamUpdate(args: MaskParamUpdateArgs<TParams>): Partial<TParams>;
-}
diff --git a/apps/web/src/lib/masks/utils.ts b/apps/web/src/lib/masks/utils.ts
deleted file mode 100644
index b1ac2c39..00000000
--- a/apps/web/src/lib/masks/utils.ts
+++ /dev/null
@@ -1,64 +0,0 @@
-const LINE_PARALLEL_EPSILON = 1e-10;
-
-export function lineEdgeIntersection({
- lineX,
- lineY,
- normalX,
- normalY,
- x1,
- y1,
- x2,
- y2,
-}: {
- lineX: number;
- lineY: number;
- normalX: number;
- normalY: number;
- x1: number;
- y1: number;
- x2: number;
- y2: number;
-}): { x: number; y: number } | null {
- const distance1 = halfPlaneSign({
- lineX,
- lineY,
- normalX,
- normalY,
- x: x1,
- y: y1,
- });
- const distance2 = halfPlaneSign({
- lineX,
- lineY,
- normalX,
- normalY,
- x: x2,
- y: y2,
- });
- const denom = distance1 - distance2;
- if (Math.abs(denom) < LINE_PARALLEL_EPSILON) return null;
- const t = distance1 / denom;
- if (t < 0 || t > 1) return null;
- return {
- x: x1 + (x2 - x1) * t,
- y: y1 + (y2 - y1) * t,
- };
-}
-
-export function halfPlaneSign({
- lineX,
- lineY,
- normalX,
- normalY,
- x,
- y,
-}: {
- lineX: number;
- lineY: number;
- normalX: number;
- normalY: number;
- x: number;
- y: number;
-}): number {
- return (x - lineX) * normalX + (y - lineY) * normalY;
-}