diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-04-20 11:31:17 +0200 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-04-20 11:31:17 +0200 |
| commit | 3e89d2998588f8e35c524e523ba2ee853f0f6475 (patch) | |
| tree | 57221f96c1259b3b07e3ddf8c1adefc502b9d8eb /apps/web/src/masks/definitions/box-like.ts | |
| parent | 729d10592f4e802f91c555c309ff3ba4b119f843 (diff) | |
| download | opencut-3e89d2998588f8e35c524e523ba2ee853f0f6475.tar.gz | |
refactor: restructure files to their domains, new preview overlay system, and dep graph
Diffstat (limited to 'apps/web/src/masks/definitions/box-like.ts')
| -rw-r--r-- | apps/web/src/masks/definitions/box-like.ts | 313 |
1 files changed, 313 insertions, 0 deletions
diff --git a/apps/web/src/masks/definitions/box-like.ts b/apps/web/src/masks/definitions/box-like.ts new file mode 100644 index 00000000..7db51df7 --- /dev/null +++ b/apps/web/src/masks/definitions/box-like.ts @@ -0,0 +1,313 @@ +import { + DEFAULT_SHAPE_MASK_SHORT_SIDE_RATIO, + MIN_MASK_DIMENSION, +} from "@/masks/dimensions"; +import { computeFeatherUpdate } from "../param-update"; +import type { + BaseMaskParams, + MaskDefaultContext, + MaskFeatures, + MaskInteractionDefinition, + MaskParamUpdateArgs, + RectangleMaskParams, +} from "@/masks/types"; +import type { NumberParamDefinition, ParamDefinition } from "@/params"; +import { + getBoxMaskHandlePositions, + getBoxMaskOverlays, +} from "@/masks/handle-positions"; +import { snapMaskInteraction } from "@/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, + }; +} |
