refactor: make mask renderer dispatch explicit

Made-with: Cursor
This commit is contained in:
Maze Winther 2026-04-29 18:27:21 +02:00
parent 6ea5a156b6
commit ae1292a14b
14 changed files with 669 additions and 568 deletions

View File

@ -71,7 +71,7 @@ function buildBandPath({
return path; return path;
} }
export const cinematicBarsMaskDefinition: MaskDefinition<RectangleMaskParams> = { export const cinematicBarsMaskDefinition: MaskDefinition<"cinematic-bars"> = {
type: "cinematic-bars", type: "cinematic-bars",
name: "Cinematic Bars", name: "Cinematic Bars",
features: { features: {
@ -94,42 +94,51 @@ export const cinematicBarsMaskDefinition: MaskDefinition<RectangleMaskParams> =
}, },
computeParamUpdate: computeBoxMaskParamUpdate, computeParamUpdate: computeBoxMaskParamUpdate,
renderer: { renderer: {
buildPath({ resolvedParams, width, height }) { body: {
const params = resolvedParams as RectangleMaskParams; kind: "fillPath",
const centerX = width / 2 + params.centerX * width; buildPath({ resolvedParams, width, height }) {
const centerY = height / 2 + params.centerY * height; const params = resolvedParams;
const maskWidth = Math.max(params.width * width, width); const centerX = width / 2 + params.centerX * width;
const maskHeight = Math.max(params.height, 0.01) * height; const centerY = height / 2 + params.centerY * height;
const rotationRad = (params.rotation * Math.PI) / 180; 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({ return buildBandPath({
centerX, centerX,
centerY, centerY,
halfWidth: maskWidth / 2, halfWidth: maskWidth / 2,
halfHeight: maskHeight / 2, halfHeight: maskHeight / 2,
rotationRad, rotationRad,
}); });
},
}, },
buildStrokePath({ resolvedParams, width, height }) { stroke: {
const params = resolvedParams as RectangleMaskParams; kind: "strokeFromPath",
const centerX = width / 2 + params.centerX * width; buildStrokePath({ resolvedParams, width, height }) {
const centerY = height / 2 + params.centerY * height; const params = resolvedParams;
const rotationRad = (params.rotation * Math.PI) / 180; const centerX = width / 2 + params.centerX * width;
const offset = getStrokeOffset({ const centerY = height / 2 + params.centerY * height;
strokeAlign: params.strokeAlign, const rotationRad = (params.rotation * Math.PI) / 180;
strokeWidth: params.strokeWidth, const offset = getStrokeOffset({
}); strokeAlign: params.strokeAlign,
strokeWidth: params.strokeWidth,
});
return buildBandPath({ return buildBandPath({
centerX, centerX,
centerY, centerY,
halfWidth: Math.max((Math.max(params.width * width, width) / 2) + offset, 1), halfWidth: Math.max(
halfHeight: Math.max( Math.max(params.width * width, width) / 2 + offset,
(Math.max(params.height, 0.01) * height) / 2 + offset, 1,
1, ),
), halfHeight: Math.max(
rotationRad, (Math.max(params.height, 0.01) * height) / 2 + offset,
}); 1,
),
rotationRad,
});
},
}, },
}, },
}; };

View File

@ -308,7 +308,7 @@ function computeCustomMaskParamUpdate({
}; };
} }
export const customMaskDefinition: MaskDefinition<CustomMaskParams> = { export const customMaskDefinition: MaskDefinition<"custom"> = {
type: "custom", type: "custom",
name: "Custom", name: "Custom",
features: { features: {
@ -447,71 +447,77 @@ export const customMaskDefinition: MaskDefinition<CustomMaskParams> = {
return params.closed; return params.closed;
}, },
renderer: { renderer: {
buildPath({ resolvedParams, width, height }) { body: {
const params = resolvedParams as CustomMaskParams; kind: "fillPath",
const points = params.path; buildPath({ resolvedParams, width, height }) {
if (!params.closed) { const params = resolvedParams;
return new Path2D(); const points = params.path;
} if (!params.closed) {
return new Path2D();
}
return buildCustomMaskPath2D({ return buildCustomMaskPath2D({
points, points,
centerX: params.centerX, centerX: params.centerX,
centerY: params.centerY, centerY: params.centerY,
rotation: params.rotation, rotation: params.rotation,
scale: params.scale, scale: params.scale,
bounds: { bounds: {
cx: width / 2, cx: width / 2,
cy: height / 2, cy: height / 2,
width, width,
height, height,
rotation: 0, rotation: 0,
}, },
closed: true, closed: true,
}); });
},
}, },
renderStroke({ resolvedParams, ctx, width, height }) { stroke: {
const params = resolvedParams as CustomMaskParams; kind: "renderStroke",
if (!params.closed) { renderStroke({ resolvedParams, ctx, width, height }) {
return; const params = resolvedParams;
} if (!params.closed) {
return;
}
const points = params.path; const points = params.path;
const path = buildCustomMaskPath2D({ const path = buildCustomMaskPath2D({
points, points,
centerX: params.centerX, centerX: params.centerX,
centerY: params.centerY, centerY: params.centerY,
rotation: params.rotation, rotation: params.rotation,
scale: params.scale, scale: params.scale,
bounds: { bounds: {
cx: width / 2, cx: width / 2,
cy: height / 2, cy: height / 2,
width, width,
height, height,
rotation: 0, rotation: 0,
}, },
closed: true, closed: true,
}); });
ctx.save(); ctx.save();
ctx.strokeStyle = params.strokeColor; ctx.strokeStyle = params.strokeColor;
ctx.lineWidth = params.strokeWidth; ctx.lineWidth = params.strokeWidth;
ctx.lineJoin = "round"; ctx.lineJoin = "round";
ctx.lineCap = "round"; ctx.lineCap = "round";
ctx.stroke(path); ctx.stroke(path);
if (params.strokeAlign === "inside") { if (params.strokeAlign === "inside") {
ctx.globalCompositeOperation = "destination-in"; ctx.globalCompositeOperation = "destination-in";
ctx.fillStyle = "#ffffff"; ctx.fillStyle = "#ffffff";
ctx.fill(path); ctx.fill(path);
} }
if (params.strokeAlign === "outside") { if (params.strokeAlign === "outside") {
ctx.globalCompositeOperation = "destination-out"; ctx.globalCompositeOperation = "destination-out";
ctx.fillStyle = "#ffffff"; ctx.fillStyle = "#ffffff";
ctx.fill(path); ctx.fill(path);
} }
ctx.restore(); ctx.restore();
},
}, },
}, },
}; };

View File

@ -1,4 +1,4 @@
import type { MaskDefinition, RectangleMaskParams } from "@/masks/types"; import type { MaskDefinition } from "@/masks/types";
import { import {
BOX_LIKE_MASK_PARAMS, BOX_LIKE_MASK_PARAMS,
buildBoxMaskInteraction, buildBoxMaskInteraction,
@ -45,7 +45,7 @@ function buildDiamondPath({
return path; return path;
} }
export const diamondMaskDefinition: MaskDefinition<RectangleMaskParams> = { export const diamondMaskDefinition: MaskDefinition<"diamond"> = {
type: "diamond", type: "diamond",
name: "Diamond", name: "Diamond",
features: { features: {
@ -68,33 +68,39 @@ export const diamondMaskDefinition: MaskDefinition<RectangleMaskParams> = {
}, },
computeParamUpdate: computeBoxMaskParamUpdate, computeParamUpdate: computeBoxMaskParamUpdate,
renderer: { renderer: {
buildPath({ resolvedParams, width, height }) { body: {
const params = resolvedParams as RectangleMaskParams; kind: "fillPath",
const { centerX, centerY, maskWidth, maskHeight, rotationRad } = buildPath({ resolvedParams, width, height }) {
getBoxLikeGeometry({ params, width, height }); const params = resolvedParams;
return buildDiamondPath({ const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
centerX, getBoxLikeGeometry({ params, width, height });
centerY, return buildDiamondPath({
halfWidth: maskWidth / 2, centerX,
halfHeight: maskHeight / 2, centerY,
rotationRad, halfWidth: maskWidth / 2,
}); halfHeight: maskHeight / 2,
rotationRad,
});
},
}, },
buildStrokePath({ resolvedParams, width, height }) { stroke: {
const params = resolvedParams as RectangleMaskParams; kind: "strokeFromPath",
const { centerX, centerY, maskWidth, maskHeight, rotationRad } = buildStrokePath({ resolvedParams, width, height }) {
getBoxLikeGeometry({ params, width, height }); const params = resolvedParams;
const offset = getStrokeOffset({ const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
strokeAlign: params.strokeAlign, getBoxLikeGeometry({ params, width, height });
strokeWidth: params.strokeWidth, const offset = getStrokeOffset({
}); strokeAlign: params.strokeAlign,
return buildDiamondPath({ strokeWidth: params.strokeWidth,
centerX, });
centerY, return buildDiamondPath({
halfWidth: Math.max(maskWidth / 2 + offset, 1), centerX,
halfHeight: Math.max(maskHeight / 2 + offset, 1), centerY,
rotationRad, halfWidth: Math.max(maskWidth / 2 + offset, 1),
}); halfHeight: Math.max(maskHeight / 2 + offset, 1),
rotationRad,
});
},
}, },
}, },
}; };

View File

@ -1,4 +1,4 @@
import type { MaskDefinition, RectangleMaskParams } from "@/masks/types"; import type { MaskDefinition } from "@/masks/types";
import { import {
BOX_LIKE_MASK_PARAMS, BOX_LIKE_MASK_PARAMS,
buildBoxMaskInteraction, buildBoxMaskInteraction,
@ -8,7 +8,7 @@ import {
getStrokeOffset, getStrokeOffset,
} from "./box-like"; } from "./box-like";
export const ellipseMaskDefinition: MaskDefinition<RectangleMaskParams> = { export const ellipseMaskDefinition: MaskDefinition<"ellipse"> = {
type: "ellipse", type: "ellipse",
name: "Ellipse", name: "Ellipse",
features: { features: {
@ -35,41 +35,47 @@ export const ellipseMaskDefinition: MaskDefinition<RectangleMaskParams> = {
}, },
computeParamUpdate: computeBoxMaskParamUpdate, computeParamUpdate: computeBoxMaskParamUpdate,
renderer: { renderer: {
buildPath({ resolvedParams, width, height }) { body: {
const params = resolvedParams as RectangleMaskParams; kind: "fillPath",
const { centerX, centerY, maskWidth, maskHeight, rotationRad } = buildPath({ resolvedParams, width, height }) {
getBoxLikeGeometry({ params, width, height }); const params = resolvedParams;
const path = new Path2D(); const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
path.ellipse( getBoxLikeGeometry({ params, width, height });
centerX, const path = new Path2D();
centerY, path.ellipse(
maskWidth / 2, centerX,
maskHeight / 2, centerY,
rotationRad, maskWidth / 2,
0, maskHeight / 2,
Math.PI * 2, rotationRad,
); 0,
return path; Math.PI * 2,
);
return path;
},
}, },
buildStrokePath({ resolvedParams, width, height }) { stroke: {
const params = resolvedParams as RectangleMaskParams; kind: "strokeFromPath",
const { centerX, centerY, maskWidth, maskHeight, rotationRad } = buildStrokePath({ resolvedParams, width, height }) {
getBoxLikeGeometry({ params, width, height }); const params = resolvedParams;
const offset = getStrokeOffset({ const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
strokeAlign: params.strokeAlign, getBoxLikeGeometry({ params, width, height });
strokeWidth: params.strokeWidth, const offset = getStrokeOffset({
}); strokeAlign: params.strokeAlign,
const path = new Path2D(); strokeWidth: params.strokeWidth,
path.ellipse( });
centerX, const path = new Path2D();
centerY, path.ellipse(
Math.max(1, maskWidth / 2 + offset), centerX,
Math.max(1, maskHeight / 2 + offset), centerY,
rotationRad, Math.max(1, maskWidth / 2 + offset),
0, Math.max(1, maskHeight / 2 + offset),
Math.PI * 2, rotationRad,
); 0,
return path; Math.PI * 2,
);
return path;
},
}, },
}, },
}; };

View File

@ -1,4 +1,4 @@
import type { MaskDefinition, RectangleMaskParams } from "@/masks/types"; import type { MaskDefinition } from "@/masks/types";
import { import {
BOX_LIKE_MASK_PARAMS, BOX_LIKE_MASK_PARAMS,
buildBoxMaskInteraction, buildBoxMaskInteraction,
@ -78,7 +78,7 @@ function buildHeartPath({
return path; return path;
} }
export const heartMaskDefinition: MaskDefinition<RectangleMaskParams> = { export const heartMaskDefinition: MaskDefinition<"heart"> = {
type: "heart", type: "heart",
name: "Heart", name: "Heart",
features: { features: {
@ -110,33 +110,39 @@ export const heartMaskDefinition: MaskDefinition<RectangleMaskParams> = {
}, },
computeParamUpdate: computeBoxMaskParamUpdate, computeParamUpdate: computeBoxMaskParamUpdate,
renderer: { renderer: {
buildPath({ resolvedParams, width, height }) { body: {
const params = resolvedParams as RectangleMaskParams; kind: "fillPath",
const { centerX, centerY, maskWidth, maskHeight, rotationRad } = buildPath({ resolvedParams, width, height }) {
getBoxLikeGeometry({ params, width, height }); const params = resolvedParams;
return buildHeartPath({ const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
centerX, getBoxLikeGeometry({ params, width, height });
centerY, return buildHeartPath({
halfWidth: maskWidth / 2, centerX,
halfHeight: maskHeight / 2, centerY,
rotationRad, halfWidth: maskWidth / 2,
}); halfHeight: maskHeight / 2,
rotationRad,
});
},
}, },
buildStrokePath({ resolvedParams, width, height }) { stroke: {
const params = resolvedParams as RectangleMaskParams; kind: "strokeFromPath",
const { centerX, centerY, maskWidth, maskHeight, rotationRad } = buildStrokePath({ resolvedParams, width, height }) {
getBoxLikeGeometry({ params, width, height }); const params = resolvedParams;
const offset = getStrokeOffset({ const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
strokeAlign: params.strokeAlign, getBoxLikeGeometry({ params, width, height });
strokeWidth: params.strokeWidth, const offset = getStrokeOffset({
}); strokeAlign: params.strokeAlign,
return buildHeartPath({ strokeWidth: params.strokeWidth,
centerX, });
centerY, return buildHeartPath({
halfWidth: Math.max(maskWidth / 2 + offset, 1), centerX,
halfHeight: Math.max(maskHeight / 2 + offset, 1), centerY,
rotationRad, halfWidth: Math.max(maskWidth / 2 + offset, 1),
}); halfHeight: Math.max(maskHeight / 2 + offset, 1),
rotationRad,
});
},
}, },
}, },
}; };

View File

@ -1,5 +1,8 @@
import type { BaseMaskParams, MaskDefinition } from "@/masks/types"; import {
import { masksRegistry, type MaskIconProps } from "../registry"; masksRegistry,
type MaskDefinitionForRegistration,
type MaskIconProps,
} from "../registry";
import { cinematicBarsMaskDefinition } from "./cinematic-bars"; import { cinematicBarsMaskDefinition } from "./cinematic-bars";
import { customMaskDefinition } from "./custom"; import { customMaskDefinition } from "./custom";
import { diamondMaskDefinition } from "./diamond"; import { diamondMaskDefinition } from "./diamond";
@ -20,11 +23,11 @@ import {
TextFontIcon, TextFontIcon,
} from "@hugeicons/core-free-icons"; } from "@hugeicons/core-free-icons";
function registerDefaultMask<TParams extends BaseMaskParams>({ function registerDefaultMask({
definition, definition,
icon, icon,
}: { }: {
definition: MaskDefinition<TParams>; definition: MaskDefinitionForRegistration;
icon: MaskIconProps; icon: MaskIconProps;
}) { }) {
if (masksRegistry.has(definition.type)) { if (masksRegistry.has(definition.type)) {

View File

@ -1,4 +1,4 @@
import type { MaskDefinition, RectangleMaskParams } from "@/masks/types"; import type { MaskDefinition } from "@/masks/types";
import { import {
BOX_LIKE_MASK_PARAMS, BOX_LIKE_MASK_PARAMS,
buildBoxMaskInteraction, buildBoxMaskInteraction,
@ -45,7 +45,7 @@ function buildRectanglePath({
return path; return path;
} }
export const rectangleMaskDefinition: MaskDefinition<RectangleMaskParams> = { export const rectangleMaskDefinition: MaskDefinition<"rectangle"> = {
type: "rectangle", type: "rectangle",
name: "Rectangle", name: "Rectangle",
features: { features: {
@ -65,33 +65,39 @@ export const rectangleMaskDefinition: MaskDefinition<RectangleMaskParams> = {
}, },
computeParamUpdate: computeBoxMaskParamUpdate, computeParamUpdate: computeBoxMaskParamUpdate,
renderer: { renderer: {
buildPath({ resolvedParams, width, height }) { body: {
const params = resolvedParams as RectangleMaskParams; kind: "fillPath",
const { centerX, centerY, maskWidth, maskHeight, rotationRad } = buildPath({ resolvedParams, width, height }) {
getBoxLikeGeometry({ params, width, height }); const params = resolvedParams;
return buildRectanglePath({ const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
centerX, getBoxLikeGeometry({ params, width, height });
centerY, return buildRectanglePath({
halfWidth: maskWidth / 2, centerX,
halfHeight: maskHeight / 2, centerY,
rotationRad, halfWidth: maskWidth / 2,
}); halfHeight: maskHeight / 2,
rotationRad,
});
},
}, },
buildStrokePath({ resolvedParams, width, height }) { stroke: {
const params = resolvedParams as RectangleMaskParams; kind: "strokeFromPath",
const { centerX, centerY, maskWidth, maskHeight, rotationRad } = buildStrokePath({ resolvedParams, width, height }) {
getBoxLikeGeometry({ params, width, height }); const params = resolvedParams;
const offset = getStrokeOffset({ const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
strokeAlign: params.strokeAlign, getBoxLikeGeometry({ params, width, height });
strokeWidth: params.strokeWidth, const offset = getStrokeOffset({
}); strokeAlign: params.strokeAlign,
return buildRectanglePath({ strokeWidth: params.strokeWidth,
centerX, });
centerY, return buildRectanglePath({
halfWidth: Math.max(1, maskWidth / 2 + offset), centerX,
halfHeight: Math.max(1, maskHeight / 2 + offset), centerY,
rotationRad, halfWidth: Math.max(1, maskWidth / 2 + offset),
}); halfHeight: Math.max(1, maskHeight / 2 + offset),
rotationRad,
});
},
}, },
}, },
}; };

View File

@ -71,11 +71,11 @@ export function getSplitMaskStrokeSegment({
width, width,
height, height,
}: { }: {
resolvedParams: unknown; resolvedParams: SplitMaskParams;
width: number; width: number;
height: number; height: number;
}): [{ x: number; y: number }, { x: number; y: number }] | null { }): [{ x: number; y: number }, { x: number; y: number }] | null {
const { centerX, centerY, rotation } = resolvedParams as SplitMaskParams; const { centerX, centerY, rotation } = resolvedParams;
const { normalX, normalY, lineX, lineY } = splitLineGeometry({ const { normalX, normalY, lineX, lineY } = splitLineGeometry({
centerX, centerX,
centerY, centerY,
@ -180,7 +180,7 @@ function computeSplitMaskParamUpdate({
return {}; return {};
} }
export const splitMaskDefinition: MaskDefinition<SplitMaskParams> = { export const splitMaskDefinition: MaskDefinition<"split"> = {
type: "split", type: "split",
name: "Split", name: "Split",
features: { features: {
@ -267,124 +267,131 @@ export const splitMaskDefinition: MaskDefinition<SplitMaskParams> = {
}, },
], ],
renderer: { renderer: {
renderMaskHandlesFeather: true, body: {
renderMask({ resolvedParams, ctx, width, height, feather }) { kind: "drawWithFeather",
const { centerX, centerY, rotation } = resolvedParams as SplitMaskParams; drawWithFeather({ resolvedParams, ctx, width, height, feather }) {
const { normalX, normalY, lineX, lineY } = splitLineGeometry({ const { centerX, centerY, rotation } = resolvedParams;
centerX, const { normalX, normalY, lineX, lineY } = splitLineGeometry({
centerY, centerX,
rotation, centerY,
width, rotation,
height, width,
}); height,
});
// Analytical gradient avoids JFA's two-sided distance artifact near canvas edges. // Analytical gradient avoids JFA's two-sided distance artifact near canvas edges.
const featherHalf = feather / 2; const featherHalf = feather / 2;
const gradient = ctx.createLinearGradient( const gradient = ctx.createLinearGradient(
lineX - normalX * featherHalf, lineX - normalX * featherHalf,
lineY - normalY * featherHalf, lineY - normalY * featherHalf,
lineX + normalX * featherHalf, lineX + normalX * featherHalf,
lineY + normalY * featherHalf, lineY + normalY * featherHalf,
); );
gradient.addColorStop(0, "rgba(255,255,255,0)"); gradient.addColorStop(0, "rgba(255,255,255,0)");
gradient.addColorStop(1, "white"); gradient.addColorStop(1, "white");
ctx.fillStyle = gradient; ctx.fillStyle = gradient;
ctx.fillRect(0, 0, width, height); ctx.fillRect(0, 0, width, height);
}, },
buildPath({ resolvedParams, width, height }) { opaqueFastPath: {
const { centerX, centerY, rotation } = resolvedParams as SplitMaskParams; buildPath({ resolvedParams, width, height }) {
const { normalX, normalY, lineX, lineY } = splitLineGeometry({ const { centerX, centerY, rotation } = resolvedParams;
centerX, const { normalX, normalY, lineX, lineY } = splitLineGeometry({
centerY, centerX,
rotation, centerY,
width, rotation,
height, 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,
y,
}: {
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({ x: x1, y: y1 });
const isVertex2Inside = isInsideHalfPlane({ x: x2, y: 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 edges: [number, number, number, number][] = [
const hit = lineEdgeIntersection({ [0, 0, width, 0],
lineX, [width, 0, width, height],
lineY, [width, height, 0, height],
normalX, [0, height, 0, 0],
normalY, ];
x1,
y1, const isInsideHalfPlane = ({
x2, x,
y2, y,
}); }: {
if (hit) { x: number;
vertices.push([hit.x, hit.y]); y: number;
vertices.push([x2, y2]); }) => halfPlaneSign({ lineX, lineY, normalX, normalY, x, y }) >= 0;
const vertices: [number, number][] = [];
for (const [x1, y1, x2, y2] of edges) {
const isVertex1Inside = isInsideHalfPlane({ x: x1, y: y1 });
const isVertex2Inside = isInsideHalfPlane({ x: x2, y: 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 ( if (
vertices.length < 3 || vertices.length < 3 ||
polygonArea({ vertices }) < MIN_POLYGON_AREA_PX polygonArea({ vertices }) < MIN_POLYGON_AREA_PX
) { ) {
return new Path2D(); return new Path2D();
} }
const path = new Path2D(); const path = new Path2D();
path.moveTo(vertices[0][0], vertices[0][1]); path.moveTo(vertices[0][0], vertices[0][1]);
for (let i = 1; i < vertices.length; i++) { for (let i = 1; i < vertices.length; i++) {
path.lineTo(vertices[i][0], vertices[i][1]); path.lineTo(vertices[i][0], vertices[i][1]);
} }
path.closePath(); path.closePath();
return path; return path;
},
},
}, },
buildStrokePath({ resolvedParams, width, height }) { stroke: {
const segment = getSplitMaskStrokeSegment({ kind: "strokeFromPath",
resolvedParams, buildStrokePath({ resolvedParams, width, height }) {
width, const segment = getSplitMaskStrokeSegment({
height, resolvedParams,
}); width,
const path = new Path2D(); height,
});
const path = new Path2D();
if (!segment) { if (!segment) {
return path;
}
path.moveTo(segment[0].x, segment[0].y);
path.lineTo(segment[1].x, segment[1].y);
return path; return path;
} },
path.moveTo(segment[0].x, segment[0].y);
path.lineTo(segment[1].x, segment[1].y);
return path;
}, },
}, },
}; };

View File

@ -1,4 +1,4 @@
import type { MaskDefinition, RectangleMaskParams } from "@/masks/types"; import type { MaskDefinition } from "@/masks/types";
import { import {
BOX_LIKE_MASK_PARAMS, BOX_LIKE_MASK_PARAMS,
buildBoxMaskInteraction, buildBoxMaskInteraction,
@ -85,7 +85,7 @@ function buildOverlayStarPath({
return `${segments.join(" ")} Z`; return `${segments.join(" ")} Z`;
} }
export const starMaskDefinition: MaskDefinition<RectangleMaskParams> = { export const starMaskDefinition: MaskDefinition<"star"> = {
type: "star", type: "star",
name: "Star", name: "Star",
features: { features: {
@ -108,33 +108,39 @@ export const starMaskDefinition: MaskDefinition<RectangleMaskParams> = {
}, },
computeParamUpdate: computeBoxMaskParamUpdate, computeParamUpdate: computeBoxMaskParamUpdate,
renderer: { renderer: {
buildPath({ resolvedParams, width, height }) { body: {
const params = resolvedParams as RectangleMaskParams; kind: "fillPath",
const { centerX, centerY, maskWidth, maskHeight, rotationRad } = buildPath({ resolvedParams, width, height }) {
getBoxLikeGeometry({ params, width, height }); const params = resolvedParams;
return buildStarPath({ const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
centerX, getBoxLikeGeometry({ params, width, height });
centerY, return buildStarPath({
halfWidth: maskWidth / 2, centerX,
halfHeight: maskHeight / 2, centerY,
rotationRad, halfWidth: maskWidth / 2,
}); halfHeight: maskHeight / 2,
rotationRad,
});
},
}, },
buildStrokePath({ resolvedParams, width, height }) { stroke: {
const params = resolvedParams as RectangleMaskParams; kind: "strokeFromPath",
const { centerX, centerY, maskWidth, maskHeight, rotationRad } = buildStrokePath({ resolvedParams, width, height }) {
getBoxLikeGeometry({ params, width, height }); const params = resolvedParams;
const offset = getStrokeOffset({ const { centerX, centerY, maskWidth, maskHeight, rotationRad } =
strokeAlign: params.strokeAlign, getBoxLikeGeometry({ params, width, height });
strokeWidth: params.strokeWidth, const offset = getStrokeOffset({
}); strokeAlign: params.strokeAlign,
return buildStarPath({ strokeWidth: params.strokeWidth,
centerX, });
centerY, return buildStarPath({
halfWidth: Math.max(maskWidth / 2 + offset, 1), centerX,
halfHeight: Math.max(maskHeight / 2 + offset, 1), centerY,
rotationRad, halfWidth: Math.max(maskWidth / 2 + offset, 1),
}); halfHeight: Math.max(maskHeight / 2 + offset, 1),
rotationRad,
});
},
}, },
}, },
}; };

View File

@ -205,7 +205,7 @@ function computeTextMaskParamUpdate({
return {}; return {};
} }
export const textMaskDefinition: MaskDefinition<TextMaskParams> = { export const textMaskDefinition: MaskDefinition<"text"> = {
type: "text", type: "text",
name: "Text", name: "Text",
features: { features: {
@ -370,69 +370,75 @@ export const textMaskDefinition: MaskDefinition<TextMaskParams> = {
return params.content.trim().length > 0; return params.content.trim().length > 0;
}, },
renderer: { renderer: {
renderMask({ resolvedParams, ctx, width, height }) { body: {
const params = resolvedParams as TextMaskParams; kind: "drawOpaque",
const { layout } = measureTextMask({ params, height }); drawOpaque({ resolvedParams, ctx, width, height }) {
const params = resolvedParams;
const { layout } = measureTextMask({ params, height });
ctx.save(); ctx.save();
ctx.translate( ctx.translate(
width / 2 + params.centerX * width, width / 2 + params.centerX * width,
height / 2 + params.centerY * height, height / 2 + params.centerY * height,
); );
ctx.scale(params.scale, params.scale); ctx.scale(params.scale, params.scale);
if (params.rotation) { if (params.rotation) {
ctx.rotate((params.rotation * Math.PI) / 180); ctx.rotate((params.rotation * Math.PI) / 180);
} }
drawMeasuredTextLayout({ drawMeasuredTextLayout({
ctx, ctx,
layout, layout,
textColor: "#ffffff", textColor: "#ffffff",
background: null, background: null,
}); });
ctx.restore(); ctx.restore();
},
}, },
renderStroke({ resolvedParams, ctx, width, height }) { stroke: {
const params = resolvedParams as TextMaskParams; kind: "renderStroke",
const { layout } = measureTextMask({ params, height }); renderStroke({ resolvedParams, ctx, width, height }) {
const params = resolvedParams;
const { layout } = measureTextMask({ params, height });
ctx.save(); ctx.save();
ctx.translate( ctx.translate(
width / 2 + params.centerX * width, width / 2 + params.centerX * width,
height / 2 + params.centerY * height, height / 2 + params.centerY * height,
); );
ctx.scale(params.scale, params.scale); ctx.scale(params.scale, params.scale);
if (params.rotation) { if (params.rotation) {
ctx.rotate((params.rotation * Math.PI) / 180); ctx.rotate((params.rotation * Math.PI) / 180);
} }
strokeMeasuredTextLayout({ strokeMeasuredTextLayout({
ctx,
layout,
strokeColor: params.strokeColor,
strokeWidth: params.strokeWidth,
});
if (params.strokeAlign === "inside") {
ctx.globalCompositeOperation = "destination-in";
drawMeasuredTextLayout({
ctx, ctx,
layout, layout,
textColor: "#ffffff", strokeColor: params.strokeColor,
background: null, strokeWidth: params.strokeWidth,
}); });
}
if (params.strokeAlign === "outside") { if (params.strokeAlign === "inside") {
ctx.globalCompositeOperation = "destination-out"; ctx.globalCompositeOperation = "destination-in";
drawMeasuredTextLayout({ drawMeasuredTextLayout({
ctx, ctx,
layout, layout,
textColor: "#ffffff", textColor: "#ffffff",
background: null, background: null,
}); });
} }
ctx.restore(); if (params.strokeAlign === "outside") {
ctx.globalCompositeOperation = "destination-out";
drawMeasuredTextLayout({
ctx,
layout,
textColor: "#ffffff",
background: null,
});
}
ctx.restore();
},
}, },
}, },
}; };

View File

@ -1,18 +1,50 @@
import type { Mask, MaskDefaultContext, MaskType } from "@/masks/types"; import type { Mask, MaskDefaultContext, MaskType } from "@/masks/types";
import { masksRegistry } from "./registry"; import { masksRegistry } from "./registry";
import { generateUUID } from "@/utils/id"; import { generateUUID } from "@/utils/id";
export { masksRegistry } from "./registry"; export { masksRegistry } from "./registry";
export { registerDefaultMasks } from "./definitions"; export { registerDefaultMasks } from "./definitions";
export function buildDefaultMaskInstance({ type MaskWithoutId = Mask extends infer TMask
maskType, ? TMask extends Mask
elementSize, ? Omit<TMask, "id">
}: { : never
maskType: MaskType; : never;
elementSize?: { width: number; height: number };
}): Mask { function withMaskId({ mask, id }: { mask: MaskWithoutId; id: string }): Mask {
const definition = masksRegistry.get(maskType); switch (mask.type) {
const context: MaskDefaultContext = { elementSize }; case "split":
return { ...definition.buildDefault(context), id: generateUUID() } as Mask; return { ...mask, id };
} case "cinematic-bars":
return { ...mask, id };
case "rectangle":
return { ...mask, id };
case "ellipse":
return { ...mask, id };
case "heart":
return { ...mask, id };
case "diamond":
return { ...mask, id };
case "star":
return { ...mask, id };
case "text":
return { ...mask, id };
case "custom":
return { ...mask, id };
}
}
export function buildDefaultMaskInstance({
maskType,
elementSize,
}: {
maskType: MaskType;
elementSize?: { width: number; height: number };
}): Mask {
const definition = masksRegistry.get(maskType);
const context: MaskDefaultContext = { elementSize };
return withMaskId({
mask: definition.buildDefault(context),
id: generateUUID(),
});
}

View File

@ -2,11 +2,11 @@ import { MAX_FEATHER } from "@/masks/feather";
import type { ParamDefinition } from "@/params"; import type { ParamDefinition } from "@/params";
import type { import type {
BaseMaskParams, BaseMaskParams,
Mask,
MaskDefaultContext, MaskDefaultContext,
MaskDefinition, MaskDefinition,
MaskInteractionResult, MaskParamUpdateArgs,
MaskSnapArgs, MaskRenderer,
MaskSnapResult,
MaskType, MaskType,
} from "@/masks/types"; } from "@/masks/types";
import type { HugeiconsIconProps } from "@hugeicons/react"; import type { HugeiconsIconProps } from "@hugeicons/react";
@ -17,6 +17,16 @@ export type MaskIconProps = {
strokeWidth?: number; strokeWidth?: number;
}; };
type RegisteredMaskWithoutId = Mask extends infer TMask
? TMask extends Mask
? Omit<TMask, "id">
: never
: never;
export type MaskDefinitionForRegistration = {
[TType in MaskType]: MaskDefinition<TType>;
}[MaskType];
const BASE_MASK_PARAM_DEFINITIONS: ParamDefinition< const BASE_MASK_PARAM_DEFINITIONS: ParamDefinition<
keyof BaseMaskParams & string keyof BaseMaskParams & string
>[] = [ >[] = [
@ -50,28 +60,15 @@ const BASE_MASK_PARAM_DEFINITIONS: ParamDefinition<
export interface RegisteredMaskDefinition { export interface RegisteredMaskDefinition {
type: MaskType; type: MaskType;
name: string; name: string;
features: MaskDefinition<BaseMaskParams>["features"]; features: MaskDefinition["features"];
params: ParamDefinition<string>[]; params: ParamDefinition<string>[];
renderer: MaskDefinition<BaseMaskParams>["renderer"]; renderer: MaskRenderer<BaseMaskParams>;
interaction: { interaction: MaskDefinition["interaction"];
getInteraction(args: { isActive?(params: BaseMaskParams): boolean;
params: BaseMaskParams; buildDefault(context: MaskDefaultContext): RegisteredMaskWithoutId;
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( computeParamUpdate(
args: Parameters<MaskDefinition<BaseMaskParams>["computeParamUpdate"]>[0], args: MaskParamUpdateArgs<BaseMaskParams>,
): ReturnType<MaskDefinition<BaseMaskParams>["computeParamUpdate"]>; ): ReturnType<MaskDefinition["computeParamUpdate"]>;
icon: MaskIconProps; icon: MaskIconProps;
} }
@ -83,11 +80,11 @@ export class MasksRegistry extends DefinitionRegistry<
super("mask"); super("mask");
} }
registerMask<TParams extends BaseMaskParams>({ registerMask({
definition, definition,
icon, icon,
}: { }: {
definition: MaskDefinition<TParams>; definition: MaskDefinitionForRegistration;
icon: MaskIconProps; icon: MaskIconProps;
}): void { }): void {
const withBaseParams: RegisteredMaskDefinition = { const withBaseParams: RegisteredMaskDefinition = {
@ -96,23 +93,10 @@ export class MasksRegistry extends DefinitionRegistry<
features: definition.features, features: definition.features,
params: [...definition.params, ...BASE_MASK_PARAM_DEFINITIONS], params: [...definition.params, ...BASE_MASK_PARAM_DEFINITIONS],
renderer: definition.renderer, renderer: definition.renderer,
interaction: { interaction: definition.interaction,
getInteraction(args) { isActive: definition.isActive,
return definition.interaction.getInteraction(args as never); buildDefault: definition.buildDefault,
}, computeParamUpdate: definition.computeParamUpdate,
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, icon,
}; };
this.register({ this.register({

View File

@ -131,32 +131,50 @@ export type Mask =
| TextMask | TextMask
| CustomMask; | CustomMask;
export interface MaskRenderer { export type MaskByType<TType extends MaskType> = Extract<Mask, { type: TType }>;
buildPath?: (params: { export type MaskParamsByType<TType extends MaskType> =
resolvedParams: unknown; MaskByType<TType>["params"];
width: number;
height: number; type MaskPathArgs<TParams extends BaseMaskParams> = {
}) => Path2D; resolvedParams: TParams;
buildStrokePath?: (params: { width: number;
resolvedParams: unknown; height: number;
width: number; };
height: number;
}) => Path2D; type MaskDrawArgs<TParams extends BaseMaskParams> = MaskPathArgs<TParams> & {
/** Renders the feathered mask directly onto ctx, bypassing JFA. */ ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
renderMask?: (params: { };
resolvedParams: unknown;
ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D; export type MaskBody<TParams extends BaseMaskParams = BaseMaskParams> =
width: number; | {
height: number; kind: "fillPath";
feather: number; buildPath(args: MaskPathArgs<TParams>): Path2D;
}) => void; }
renderMaskHandlesFeather?: boolean; | {
renderStroke?: (params: { kind: "drawOpaque";
resolvedParams: unknown; drawOpaque(args: MaskDrawArgs<TParams>): void;
ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D; }
width: number; | {
height: number; kind: "drawWithFeather";
}) => void; drawWithFeather(args: MaskDrawArgs<TParams> & { feather: number }): void;
opaqueFastPath?: {
buildPath(args: MaskPathArgs<TParams>): Path2D;
};
};
export type MaskStroke<TParams extends BaseMaskParams = BaseMaskParams> =
| {
kind: "strokeFromPath";
buildStrokePath(args: MaskPathArgs<TParams>): Path2D;
}
| {
kind: "renderStroke";
renderStroke(args: MaskDrawArgs<TParams>): void;
};
export interface MaskRenderer<TParams extends BaseMaskParams = BaseMaskParams> {
body: MaskBody<TParams>;
stroke?: MaskStroke<TParams>;
} }
export interface MaskFeatures { export interface MaskFeatures {
@ -282,17 +300,17 @@ export interface MaskInteractionDefinition<
snap?(args: MaskSnapArgs<TParams>): MaskSnapResult<TParams>; snap?(args: MaskSnapArgs<TParams>): MaskSnapResult<TParams>;
} }
export interface MaskDefinition< export interface MaskDefinition<TType extends MaskType = MaskType> {
TParams extends BaseMaskParams = BaseMaskParams, type: TType;
> {
type: MaskType;
name: string; name: string;
features: MaskFeatures; features: MaskFeatures;
params: ParamDefinition<keyof TParams & string>[]; params: ParamDefinition<keyof MaskParamsByType<TType> & string>[];
renderer: MaskRenderer; renderer: MaskRenderer<MaskParamsByType<TType>>;
interaction: MaskInteractionDefinition<TParams>; interaction: MaskInteractionDefinition<MaskParamsByType<TType>>;
/** When defined and returning false, the mask is not applied and the element renders fully visible. */ /** When defined and returning false, the mask is not applied and the element renders fully visible. */
isActive?: (params: TParams) => boolean; isActive?(params: MaskParamsByType<TType>): boolean;
buildDefault(context: MaskDefaultContext): Omit<Mask, "id">; buildDefault(context: MaskDefaultContext): Omit<MaskByType<TType>, "id">;
computeParamUpdate(args: MaskParamUpdateArgs<TParams>): Partial<TParams>; computeParamUpdate(
args: MaskParamUpdateArgs<MaskParamsByType<TType>>,
): Partial<MaskParamsByType<TType>>;
} }

View File

@ -396,23 +396,16 @@ function buildMaskArtifacts({
return { mask: null, strokeLayer: null }; return { mask: null, strokeLayer: null };
} }
let feather = mask.params.feather; const { body } = definition.renderer;
const canRenderMaskDirectly = Boolean(definition.renderer.renderMask); const usesOpaqueFastPath =
const shouldRenderMaskDirectly = body.kind === "drawWithFeather" &&
canRenderMaskDirectly && mask.params.feather === 0 &&
(!definition.renderer.buildPath || Boolean(body.opaqueFastPath);
(mask.params.feather > 0 && const feather = body.kind === "drawWithFeather" ? 0 : mask.params.feather;
definition.renderer.renderMaskHandlesFeather));
if (
shouldRenderMaskDirectly &&
definition.renderer.renderMaskHandlesFeather
) {
feather = 0;
}
const maskTextureId = `${path}:mask`; const maskTextureId = `${path}:mask`;
const { width: canvasWidth, height: canvasHeight } = renderer; const { width: canvasWidth, height: canvasHeight } = renderer;
const maskContentHash = `mask:${mask.type}:${JSON.stringify(mask.params)}:${transformHash(transform)}:${canvasWidth}x${canvasHeight}:direct=${shouldRenderMaskDirectly}`; const maskContentHash = `mask:${mask.type}:${JSON.stringify(mask.params)}:${transformHash(transform)}:${canvasWidth}x${canvasHeight}:body=${body.kind}:fastPath=${usesOpaqueFastPath}`;
const drawMask: TextureCanvasDrawFn = (ctx) => { const drawMask: TextureCanvasDrawFn = (ctx) => {
const { canvas: elementMaskCanvas, context: elementMaskCtx } = const { canvas: elementMaskCanvas, context: elementMaskCtx } =
createCanvasSurface({ createCanvasSurface({
@ -420,24 +413,44 @@ function buildMaskArtifacts({
height: Math.round(transform.height), height: Math.round(transform.height),
}); });
if (shouldRenderMaskDirectly && definition.renderer.renderMask) { switch (body.kind) {
definition.renderer.renderMask({ case "fillPath": {
resolvedParams: mask.params, const path2d = body.buildPath({
ctx: elementMaskCtx, resolvedParams: mask.params,
width: Math.round(transform.width), width: transform.width,
height: Math.round(transform.height), height: transform.height,
feather: mask.params.feather, });
}); elementMaskCtx.fillStyle = "white";
} else if (definition.renderer.buildPath) { elementMaskCtx.fill(path2d);
const path2d = definition.renderer.buildPath({ break;
resolvedParams: mask.params, }
width: transform.width, case "drawOpaque":
height: transform.height, body.drawOpaque({
}); resolvedParams: mask.params,
elementMaskCtx.fillStyle = "white"; ctx: elementMaskCtx,
elementMaskCtx.fill(path2d); width: Math.round(transform.width),
} else { height: Math.round(transform.height),
return; });
break;
case "drawWithFeather":
if (usesOpaqueFastPath && body.opaqueFastPath) {
const path2d = body.opaqueFastPath.buildPath({
resolvedParams: mask.params,
width: transform.width,
height: transform.height,
});
elementMaskCtx.fillStyle = "white";
elementMaskCtx.fill(path2d);
} else {
body.drawWithFeather({
resolvedParams: mask.params,
ctx: elementMaskCtx,
width: Math.round(transform.width),
height: Math.round(transform.height),
feather: mask.params.feather,
});
}
break;
} }
drawTransformedCanvas({ ctx, source: elementMaskCanvas, transform }); drawTransformedCanvas({ ctx, source: elementMaskCanvas, transform });
@ -451,45 +464,38 @@ function buildMaskArtifacts({
draw: drawMask, draw: drawMask,
}); });
const hasStroke = const stroke = definition.renderer.stroke;
mask.params.strokeWidth > 0 && const hasStroke = mask.params.strokeWidth > 0 && Boolean(stroke);
(definition.renderer.renderStroke ||
definition.renderer.buildStrokePath ||
definition.renderer.buildPath);
let strokeLayer: FrameItemDescriptor | null = null; let strokeLayer: FrameItemDescriptor | null = null;
if (hasStroke) { if (hasStroke && stroke) {
const strokeTextureId = `${path}:mask-stroke`; const strokeTextureId = `${path}:mask-stroke`;
const strokeContentHash = `stroke:${mask.type}:${JSON.stringify(mask.params)}:${transformHash(transform)}:${canvasWidth}x${canvasHeight}`; const strokeContentHash = `stroke:${mask.type}:${JSON.stringify(mask.params)}:${transformHash(transform)}:${canvasWidth}x${canvasHeight}:stroke=${stroke.kind}`;
const drawStroke: TextureCanvasDrawFn = (ctx) => { const drawStroke: TextureCanvasDrawFn = (ctx) => {
const { canvas: strokeCanvas, context: strokeCtx } = createCanvasSurface({ const { canvas: strokeCanvas, context: strokeCtx } = createCanvasSurface({
width: Math.round(transform.width), width: Math.round(transform.width),
height: Math.round(transform.height), height: Math.round(transform.height),
}); });
if (definition.renderer.renderStroke) { switch (stroke.kind) {
definition.renderer.renderStroke({ case "renderStroke":
resolvedParams: mask.params, stroke.renderStroke({
ctx: strokeCtx, resolvedParams: mask.params,
width: transform.width, ctx: strokeCtx,
height: transform.height, width: transform.width,
}); height: transform.height,
} else { });
const strokePath = break;
definition.renderer.buildStrokePath?.({ case "strokeFromPath": {
const strokePath = stroke.buildStrokePath({
resolvedParams: mask.params, resolvedParams: mask.params,
width: transform.width, width: transform.width,
height: transform.height, height: transform.height,
}) ?? });
definition.renderer.buildPath?.({ strokeCtx.strokeStyle = mask.params.strokeColor;
resolvedParams: mask.params, strokeCtx.lineWidth = mask.params.strokeWidth;
width: transform.width, strokeCtx.stroke(strokePath);
height: transform.height, break;
}) ?? }
null;
if (!strokePath) return;
strokeCtx.strokeStyle = mask.params.strokeColor;
strokeCtx.lineWidth = mask.params.strokeWidth;
strokeCtx.stroke(strokePath);
} }
drawTransformedCanvas({ ctx, source: strokeCanvas, transform }); drawTransformedCanvas({ ctx, source: strokeCanvas, transform });