Files
OpenCut/apps/web/src/masks/definitions/heart.ts
T
Maze WintherandCursor ab982b888e refactor: extract decoration model types
Move animation, effect, mask, and param value shapes into model decorations so element data no longer depends on decoration domains.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-03 03:58:21 +02:00

144 lines
3.4 KiB
TypeScript

import type { RectangleMaskParams } from "@/model/decorations/mask";
import type { MaskDefinition } from "@/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,
});
},
},
};