diff options
Diffstat (limited to 'apps/web/src/lib/masks')
| -rw-r--r-- | apps/web/src/lib/masks/__tests__/snap.test.ts | 235 | ||||
| -rw-r--r-- | apps/web/src/lib/masks/definitions/box-like.ts | 270 | ||||
| -rw-r--r-- | apps/web/src/lib/masks/definitions/ellipse.ts | 72 | ||||
| -rw-r--r-- | apps/web/src/lib/masks/definitions/index.ts | 39 | ||||
| -rw-r--r-- | apps/web/src/lib/masks/definitions/rectangle.ts | 94 | ||||
| -rw-r--r-- | apps/web/src/lib/masks/definitions/split.ts | 347 | ||||
| -rw-r--r-- | apps/web/src/lib/masks/geometry.ts | 112 | ||||
| -rw-r--r-- | apps/web/src/lib/masks/handle-positions.ts | 382 | ||||
| -rw-r--r-- | apps/web/src/lib/masks/index.ts | 18 | ||||
| -rw-r--r-- | apps/web/src/lib/masks/param-update.ts | 59 | ||||
| -rw-r--r-- | apps/web/src/lib/masks/registry.ts | 70 | ||||
| -rw-r--r-- | apps/web/src/lib/masks/shaders/jfa-distance.frag.glsl | 36 | ||||
| -rw-r--r-- | apps/web/src/lib/masks/snap.ts | 335 | ||||
| -rw-r--r-- | apps/web/src/lib/masks/types.ts | 115 | ||||
| -rw-r--r-- | apps/web/src/lib/masks/utils.ts | 64 |
15 files changed, 2248 insertions, 0 deletions
diff --git a/apps/web/src/lib/masks/__tests__/snap.test.ts b/apps/web/src/lib/masks/__tests__/snap.test.ts new file mode 100644 index 00000000..3ec96344 --- /dev/null +++ b/apps/web/src/lib/masks/__tests__/snap.test.ts @@ -0,0 +1,235 @@ +import { describe, expect, test } from "bun:test"; +import { getSplitMaskStrokeSegment } from "@/lib/masks/definitions/split"; +import { getMaskSnapGeometry } from "@/lib/masks/geometry"; +import { snapMaskInteraction } from "@/lib/masks/snap"; +import type { ElementBounds } from "@/lib/preview/element-bounds"; +import type { RectangleMaskParams, SplitMaskParams } 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, + centerX: 0, + centerY: 0, + rotation: 0, + ...overrides, + }; +} + +function buildRectangleParams( + overrides: Partial<RectangleMaskParams> = {}, +): RectangleMaskParams { + return { + feather: 0, + inverted: false, + strokeColor: "#ffffff", + strokeWidth: 0, + centerX: 0, + centerY: 0, + width: 0.4, + height: 0.2, + 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 }]); + }); +}); diff --git a/apps/web/src/lib/masks/definitions/box-like.ts b/apps/web/src/lib/masks/definitions/box-like.ts new file mode 100644 index 00000000..0436151e --- /dev/null +++ b/apps/web/src/lib/masks/definitions/box-like.ts @@ -0,0 +1,270 @@ +import { + DEFAULT_SHAPE_MASK_SHORT_SIDE_RATIO, + MIN_MASK_DIMENSION, +} from "@/constants/mask-constants"; +import { computeFeatherUpdate } from "../param-update"; +import type { + BaseMaskParams, + MaskDefaultContext, + MaskParamUpdateArgs, + RectangleMaskParams, +} from "@/lib/masks/types"; +import type { + NumberParamDefinition, + ParamDefinition, + ParamValues, +} from "@/lib/params"; + +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 computeBoxMaskParamUpdate({ + handleId, + startParams, + deltaX, + deltaY, + bounds, +}: MaskParamUpdateArgs<RectangleMaskParams>): ParamValues { + 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/ellipse.ts b/apps/web/src/lib/masks/definitions/ellipse.ts new file mode 100644 index 00000000..45d0a7c0 --- /dev/null +++ b/apps/web/src/lib/masks/definitions/ellipse.ts @@ -0,0 +1,72 @@ +import type { MaskDefinition, RectangleMaskParams } from "@/lib/masks/types"; +import { + BOX_LIKE_MASK_PARAMS, + computeBoxMaskParamUpdate, + getBoxLikeGeometry, + getDefaultSquareMaskParams, + getStrokeOffset, +} from "./box-like"; + +export const ellipseMaskDefinition: MaskDefinition<RectangleMaskParams> = { + type: "ellipse", + name: "Ellipse", + overlayShape: "box", + 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`; + }, + features: { + hasPosition: true, + hasRotation: true, + sizeMode: "width-height", + }, + params: BOX_LIKE_MASK_PARAMS, + 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/index.ts b/apps/web/src/lib/masks/definitions/index.ts new file mode 100644 index 00000000..c2affe30 --- /dev/null +++ b/apps/web/src/lib/masks/definitions/index.ts @@ -0,0 +1,39 @@ +import type { BaseMaskParams, MaskDefinition } from "@/lib/masks/types"; +import { masksRegistry, type MaskIconProps } from "../registry"; +import { ellipseMaskDefinition } from "./ellipse"; +import { rectangleMaskDefinition } from "./rectangle"; +import { splitMaskDefinition } from "./split"; +import { + PanelRightDashedIcon, + SquareIcon, + CircleIcon, +} 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: rectangleMaskDefinition, + icon: { icon: SquareIcon }, + }); + registerDefaultMask({ + definition: ellipseMaskDefinition, + icon: { icon: CircleIcon }, + }); +} diff --git a/apps/web/src/lib/masks/definitions/rectangle.ts b/apps/web/src/lib/masks/definitions/rectangle.ts new file mode 100644 index 00000000..5b7f84a9 --- /dev/null +++ b/apps/web/src/lib/masks/definitions/rectangle.ts @@ -0,0 +1,94 @@ +import type { MaskDefinition, RectangleMaskParams } from "@/lib/masks/types"; +import { + BOX_LIKE_MASK_PARAMS, + 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", + overlayShape: "box", + features: { + hasPosition: true, + hasRotation: true, + sizeMode: "width-height", + }, + params: BOX_LIKE_MASK_PARAMS, + 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 new file mode 100644 index 00000000..43f02db1 --- /dev/null +++ b/apps/web/src/lib/masks/definitions/split.ts @@ -0,0 +1,347 @@ +import { computeFeatherUpdate } from "../param-update"; +import type { ParamValues } from "@/lib/params"; +import type { + MaskDefinition, + MaskParamUpdateArgs, + SplitMaskParams, +} from "@/lib/masks/types"; +import { halfPlaneSign, lineEdgeIntersection } from "../utils"; + +// 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>): ParamValues { + 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", + overlayShape: "line", + features: { + hasPosition: true, + hasRotation: true, + sizeMode: "none", + }, + 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: { + 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/geometry.ts b/apps/web/src/lib/masks/geometry.ts new file mode 100644 index 00000000..c6d386f8 --- /dev/null +++ b/apps/web/src/lib/masks/geometry.ts @@ -0,0 +1,112 @@ +import type { ElementBounds } from "@/lib/preview/element-bounds"; +import type { SnapLine } from "@/lib/preview/preview-snap"; +import { MIN_MASK_DIMENSION } from "@/constants/mask-constants"; +import type { ParamValues } from "@/lib/params"; +import type { RectangleMaskParams } from "@/lib/masks/types"; + +export function hasCenterParams(params: ParamValues): params is ParamValues & { + centerX: number; + centerY: number; +} { + return ( + typeof params.centerX === "number" && typeof params.centerY === "number" + ); +} + +export function isRectangleMaskParams( + params: ParamValues, +): 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: ParamValues; + 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; +}): Pick<ParamValues, "centerX" | "centerY"> { + 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: ParamValues; + 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 new file mode 100644 index 00000000..753b17ce --- /dev/null +++ b/apps/web/src/lib/masks/handle-positions.ts @@ -0,0 +1,382 @@ +import { FEATHER_HANDLE_SCALE } from "@/constants/mask-constants"; +import type { ElementBounds } from "@/lib/preview/element-bounds"; +import type { + MaskFeatures, + MaskHandlePosition, + MaskLinePoints, + MaskOverlayShape, +} from "@/lib/masks/types"; +import type { ParamValues } from "@/lib/params"; + +const LINE_HANDLE_OFFSET_SCREEN_PX = 20; +const BOX_HANDLE_OFFSET_SCREEN_PX = 20; +const LINE_EXTENT_MULTIPLIER = 50; + +const CURSOR = { + rotate: "cursor-crosshair", + resizeDiagonal: "cursor-nwse-resize", + resizeHorizontal: "cursor-ew-resize", + resizeVertical: "cursor-ns-resize", +} as const; + +function getNumParam({ + params, + key, + fallback, +}: { + params: ParamValues; + key: string; + fallback: number; +}): number { + const value = params[key]; + return typeof value === "number" && !Number.isNaN(value) ? value : fallback; +} + +/** + * 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; +}): MaskLinePoints { + 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 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, + }, + { + id: "feather", + x: cx - normalX * featherOffset, + y: cy - normalY * featherOffset, + cursor: CURSOR.resizeHorizontal, + }, + ]; +} + +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, + bounds, + displayScale, +}: { + centerX: number; + centerY: number; + width: number; + height: number; + rotation: number; + feather: number; + sizeMode: MaskFeatures["sizeMode"]; + 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, + }); + + 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, + }); + + 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, + }); + } + 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, + }); + handles.push({ + id: "right", + x: right.x, + y: right.y, + cursor: CURSOR.resizeHorizontal, + }); + handles.push({ + id: "bottom", + x: bottom.x, + y: bottom.y, + cursor: CURSOR.resizeVertical, + }); + } 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, + }); + handles.push({ + id: "bottom", + x: bottom.x, + y: bottom.y, + cursor: CURSOR.resizeVertical, + }); + } 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, + }); + handles.push({ + id: "right", + x: right.x, + y: right.y, + cursor: CURSOR.resizeHorizontal, + }); + } else if (sizeMode === "uniform") { + const point = rotatePoint({ + localX: halfWidth, + localY: halfHeight, + cx, + cy, + angleRad, + }); + handles.push({ + id: "scale", + x: point.x, + y: point.y, + cursor: CURSOR.resizeDiagonal, + }); + } + + return handles; +} + +type MaskHandleResolver = (args: { + features: MaskFeatures; + params: ParamValues; + bounds: ElementBounds; + displayScale: number; +}) => MaskHandlePosition[]; + +const rectangleHandles: MaskHandleResolver = ({ + features, + params, + bounds, + displayScale, +}) => + getBoxMaskHandlePositions({ + centerX: getNumParam({ params, key: "centerX", fallback: 0 }), + centerY: getNumParam({ params, key: "centerY", fallback: 0 }), + width: getNumParam({ params, key: "width", fallback: 1 }), + height: getNumParam({ params, key: "height", fallback: 1 }), + rotation: getNumParam({ params, key: "rotation", fallback: 0 }), + feather: getNumParam({ params, key: "feather", fallback: 0 }), + sizeMode: features.sizeMode, + bounds, + displayScale, + }); + +const HANDLE_RESOLVERS: Record<MaskOverlayShape, MaskHandleResolver> = { + line: ({ params, bounds, displayScale }) => + getLineMaskHandlePositions({ + centerX: getNumParam({ params, key: "centerX", fallback: 0 }), + centerY: getNumParam({ params, key: "centerY", fallback: 0 }), + rotation: getNumParam({ params, key: "rotation", fallback: 0 }), + feather: getNumParam({ params, key: "feather", fallback: 0 }), + bounds, + displayScale, + }), + box: rectangleHandles, +}; + +export function getMaskHandlePositions({ + overlayShape, + features, + params, + bounds, + displayScale, +}: { + overlayShape: MaskOverlayShape; + features: MaskFeatures; + params: ParamValues; + bounds: ElementBounds; + displayScale: number; +}): MaskHandlePosition[] { + return HANDLE_RESOLVERS[overlayShape]({ + features, + params, + bounds, + displayScale, + }); +} diff --git a/apps/web/src/lib/masks/index.ts b/apps/web/src/lib/masks/index.ts new file mode 100644 index 00000000..bc9f20b0 --- /dev/null +++ b/apps/web/src/lib/masks/index.ts @@ -0,0 +1,18 @@ +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 new file mode 100644 index 00000000..967a4be0 --- /dev/null +++ b/apps/web/src/lib/masks/param-update.ts @@ -0,0 +1,59 @@ +import { FEATHER_HANDLE_SCALE, MAX_FEATHER } from "@/constants/mask-constants"; +import { masksRegistry } from "@/lib/masks"; +import type { ParamValues } from "@/lib/params"; +import type { + BaseMaskParams, + MaskParamUpdateArgs, + MaskType, +} from "@/lib/masks/types"; + +function compactMaskParamValues({ + params, +}: { + params: Partial<ParamValues>; +}): ParamValues { + const nextParams: ParamValues = {}; + for (const [key, value] of Object.entries(params)) { + if (value !== undefined) { + nextParams[key] = value; + } + } + return nextParams; +} + +export function computeFeatherUpdate({ + startFeather, + deltaX, + deltaY, + directionX, + directionY, +}: { + startFeather: number; + deltaX: number; + deltaY: number; + directionX: number; + directionY: number; +}): ParamValues { + const projection = deltaX * directionX + deltaY * directionY; + return { + feather: Math.max( + 0, + Math.min( + MAX_FEATHER, + Math.round(startFeather + projection / FEATHER_HANDLE_SCALE), + ), + ), + }; +} + +export function computeMaskParamUpdate({ + maskType, + ...args +}: { + maskType: MaskType; +} & MaskParamUpdateArgs<BaseMaskParams & ParamValues>): ParamValues { + const definition = masksRegistry.get(maskType); + return compactMaskParamValues({ + params: definition.computeParamUpdate(args), + }); +} diff --git a/apps/web/src/lib/masks/registry.ts b/apps/web/src/lib/masks/registry.ts new file mode 100644 index 00000000..5a98cb41 --- /dev/null +++ b/apps/web/src/lib/masks/registry.ts @@ -0,0 +1,70 @@ +import { MAX_FEATHER } from "@/constants/mask-constants"; +import type { ParamDefinition } from "@/lib/params"; +import type { BaseMaskParams, MaskDefinition, 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 type RegisteredMaskDefinition = MaskDefinition<BaseMaskParams> & { + 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 = { + ...definition, + params: [...definition.params, ...BASE_MASK_PARAM_DEFINITIONS], + icon, + }; + this.register(definition.type, withBaseParams); + } +} + +export const masksRegistry = new MasksRegistry(); diff --git a/apps/web/src/lib/masks/shaders/jfa-distance.frag.glsl b/apps/web/src/lib/masks/shaders/jfa-distance.frag.glsl new file mode 100644 index 00000000..4ba2c5a1 --- /dev/null +++ b/apps/web/src/lib/masks/shaders/jfa-distance.frag.glsl @@ -0,0 +1,36 @@ +precision mediump float; + +uniform sampler2D u_texture; +uniform sampler2D u_jfa_outside; +uniform vec2 u_resolution; +uniform float u_feather_half; + +varying vec2 v_texCoord; + +vec2 decodeSeed(vec4 encoded) { + float x = floor(encoded.r * 255.0 + 0.5) * 256.0 + floor(encoded.g * 255.0 + 0.5); + float y = floor(encoded.b * 255.0 + 0.5) * 256.0 + floor(encoded.a * 255.0 + 0.5); + return vec2(x, y); +} + +bool isNoSeed(vec4 encoded) { + return encoded.r > 0.99 && encoded.g > 0.99 && encoded.b > 0.99 && encoded.a > 0.99; +} + +void main() { + vec2 pixelCoord = floor(v_texCoord * u_resolution); + + vec4 insideEncoded = texture2D(u_texture, v_texCoord); + vec4 outsideEncoded = texture2D(u_jfa_outside, v_texCoord); + + bool hasInside = !isNoSeed(insideEncoded); + bool hasOutside = !isNoSeed(outsideEncoded); + + float distToInside = hasInside ? distance(pixelCoord, decodeSeed(insideEncoded)) : 1e5; + float distToOutside = hasOutside ? distance(pixelCoord, decodeSeed(outsideEncoded)) : 1e5; + + float signedDist = distToOutside - distToInside; + float alpha = smoothstep(-u_feather_half, u_feather_half, signedDist); + + gl_FragColor = vec4(alpha, alpha, alpha, alpha); +} diff --git a/apps/web/src/lib/masks/snap.ts b/apps/web/src/lib/masks/snap.ts new file mode 100644 index 00000000..83cc0e99 --- /dev/null +++ b/apps/web/src/lib/masks/snap.ts @@ -0,0 +1,335 @@ +import type { ElementBounds } from "@/lib/preview/element-bounds"; +import { MIN_MASK_DIMENSION } from "@/constants/mask-constants"; +import { + snapPosition, + snapRotation, + snapScale, + snapScaleAxes, + type ScaleEdgePreference, + type SnapLine, +} from "@/lib/preview/preview-snap"; +import type { ParamValues } from "@/lib/params"; +import { + isRectangleMaskParams, + getMaskSnapGeometry, + setMaskLocalCenter, + toGlobalMaskSnapLines, +} from "./geometry"; + +type MaskSnapResult = { + params: ParamValues; + 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: ParamValues; + bounds: ElementBounds; + canvasSize: { width: number; height: number }; + snapThreshold: { x: number; y: number }; +}): MaskSnapResult { + 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: ParamValues; +}): MaskSnapResult { + 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: ParamValues; + proposedParams: ParamValues; + bounds: ElementBounds; + canvasSize: { width: number; height: number }; + snapThreshold: { x: number; y: number }; +}): MaskSnapResult { + 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({ + handleId, + startParams, + proposedParams, + bounds, + canvasSize, + snapThreshold, +}: { + handleId: string; + startParams: ParamValues; + proposedParams: ParamValues; + bounds: ElementBounds; + canvasSize: { width: number; height: number }; + snapThreshold: { x: number; y: number }; +}): MaskSnapResult { + if (handleId === "position") { + return snapMaskPosition({ + proposedParams, + bounds, + canvasSize, + snapThreshold, + }); + } + + if (handleId === "rotation") { + return snapMaskRotation({ proposedParams }); + } + + return snapBoxMaskSize({ + handleId, + startParams, + proposedParams, + bounds, + canvasSize, + snapThreshold, + }); +} + diff --git a/apps/web/src/lib/masks/types.ts b/apps/web/src/lib/masks/types.ts new file mode 100644 index 00000000..569ff0ff --- /dev/null +++ b/apps/web/src/lib/masks/types.ts @@ -0,0 +1,115 @@ +import type { ElementBounds } from "@/lib/preview/element-bounds"; +import type { ParamDefinition, ParamValues } from "@/lib/params"; + +export type MaskType = "split" | "rectangle" | "ellipse"; + +export interface BaseMaskParams extends ParamValues { + 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 SplitMask { + id: string; + type: "split"; + params: SplitMaskParams; +} + +export interface RectangleMask { + id: string; + type: "rectangle"; + params: RectangleMaskParams; +} + +export interface EllipseMask { + id: string; + type: "ellipse"; + params: RectangleMaskParams; +} + +export type Mask = SplitMask | RectangleMask | EllipseMask; + +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; +} + +export type MaskOverlayShape = "line" | "box"; + +export interface MaskFeatures { + hasPosition: boolean; + hasRotation: boolean; + sizeMode: "none" | "uniform" | "width-height" | "height-only" | "width-only"; +} + +export interface MaskHandlePosition { + id: string; + x: number; + y: number; + cursor: string; +} + +export interface MaskLinePoints { + start: { x: number; y: number }; + end: { x: number; y: number }; +} + +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 MaskDefinition<TParams extends BaseMaskParams = BaseMaskParams> { + type: MaskType; + name: string; + overlayShape: MaskOverlayShape; + features: MaskFeatures; + params: ParamDefinition<keyof TParams & string>[]; + renderer: MaskRenderer; + buildOverlayPath?: (params: { width: number; height: number }) => string; + buildDefault(context: MaskDefaultContext): Omit<Mask, "id">; + computeParamUpdate(args: MaskParamUpdateArgs<TParams>): ParamValues; +} diff --git a/apps/web/src/lib/masks/utils.ts b/apps/web/src/lib/masks/utils.ts new file mode 100644 index 00000000..a9110237 --- /dev/null +++ b/apps/web/src/lib/masks/utils.ts @@ -0,0 +1,64 @@ +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; +} |
