diff --git a/apps/web/src/masks/definitions/cinematic-bars.ts b/apps/web/src/masks/definitions/cinematic-bars.ts index 36b31328..68717c02 100644 --- a/apps/web/src/masks/definitions/cinematic-bars.ts +++ b/apps/web/src/masks/definitions/cinematic-bars.ts @@ -71,7 +71,7 @@ function buildBandPath({ return path; } -export const cinematicBarsMaskDefinition: MaskDefinition = { +export const cinematicBarsMaskDefinition: MaskDefinition<"cinematic-bars"> = { type: "cinematic-bars", name: "Cinematic Bars", features: { @@ -94,42 +94,51 @@ export const cinematicBarsMaskDefinition: MaskDefinition = }, computeParamUpdate: computeBoxMaskParamUpdate, renderer: { - buildPath({ resolvedParams, width, height }) { - const params = resolvedParams as RectangleMaskParams; - const centerX = width / 2 + params.centerX * width; - const centerY = height / 2 + params.centerY * height; - const maskWidth = Math.max(params.width * width, width); - const maskHeight = Math.max(params.height, 0.01) * height; - const rotationRad = (params.rotation * Math.PI) / 180; + body: { + kind: "fillPath", + buildPath({ resolvedParams, width, height }) { + const params = resolvedParams; + const centerX = width / 2 + params.centerX * width; + const centerY = height / 2 + params.centerY * height; + const maskWidth = Math.max(params.width * width, width); + const maskHeight = Math.max(params.height, 0.01) * height; + const rotationRad = (params.rotation * Math.PI) / 180; - return buildBandPath({ - centerX, - centerY, - halfWidth: maskWidth / 2, - halfHeight: maskHeight / 2, - rotationRad, - }); + return buildBandPath({ + centerX, + centerY, + halfWidth: maskWidth / 2, + halfHeight: maskHeight / 2, + rotationRad, + }); + }, }, - buildStrokePath({ resolvedParams, width, height }) { - const params = resolvedParams as RectangleMaskParams; - const centerX = width / 2 + params.centerX * width; - const centerY = height / 2 + params.centerY * height; - const rotationRad = (params.rotation * Math.PI) / 180; - const offset = getStrokeOffset({ - strokeAlign: params.strokeAlign, - strokeWidth: params.strokeWidth, - }); + stroke: { + kind: "strokeFromPath", + buildStrokePath({ resolvedParams, width, height }) { + const params = resolvedParams; + const centerX = width / 2 + params.centerX * width; + const centerY = height / 2 + params.centerY * height; + const rotationRad = (params.rotation * Math.PI) / 180; + const offset = getStrokeOffset({ + strokeAlign: params.strokeAlign, + strokeWidth: params.strokeWidth, + }); - return buildBandPath({ - centerX, - centerY, - halfWidth: Math.max((Math.max(params.width * width, width) / 2) + offset, 1), - halfHeight: Math.max( - (Math.max(params.height, 0.01) * height) / 2 + offset, - 1, - ), - rotationRad, - }); + return buildBandPath({ + centerX, + centerY, + halfWidth: Math.max( + Math.max(params.width * width, width) / 2 + offset, + 1, + ), + halfHeight: Math.max( + (Math.max(params.height, 0.01) * height) / 2 + offset, + 1, + ), + rotationRad, + }); + }, }, }, }; diff --git a/apps/web/src/masks/definitions/custom.ts b/apps/web/src/masks/definitions/custom.ts index cd6e2055..6e0f7af6 100644 --- a/apps/web/src/masks/definitions/custom.ts +++ b/apps/web/src/masks/definitions/custom.ts @@ -308,7 +308,7 @@ function computeCustomMaskParamUpdate({ }; } -export const customMaskDefinition: MaskDefinition = { +export const customMaskDefinition: MaskDefinition<"custom"> = { type: "custom", name: "Custom", features: { @@ -447,71 +447,77 @@ export const customMaskDefinition: MaskDefinition = { return params.closed; }, renderer: { - buildPath({ resolvedParams, width, height }) { - const params = resolvedParams as CustomMaskParams; - const points = params.path; - if (!params.closed) { - return new Path2D(); - } + body: { + kind: "fillPath", + buildPath({ resolvedParams, width, height }) { + const params = resolvedParams; + const points = params.path; + if (!params.closed) { + return new Path2D(); + } - return buildCustomMaskPath2D({ - points, - centerX: params.centerX, - centerY: params.centerY, - rotation: params.rotation, - scale: params.scale, - bounds: { - cx: width / 2, - cy: height / 2, - width, - height, - rotation: 0, - }, - closed: true, - }); + return buildCustomMaskPath2D({ + points, + centerX: params.centerX, + centerY: params.centerY, + rotation: params.rotation, + scale: params.scale, + bounds: { + cx: width / 2, + cy: height / 2, + width, + height, + rotation: 0, + }, + closed: true, + }); + }, }, - renderStroke({ resolvedParams, ctx, width, height }) { - const params = resolvedParams as CustomMaskParams; - if (!params.closed) { - return; - } + stroke: { + kind: "renderStroke", + renderStroke({ resolvedParams, ctx, width, height }) { + const params = resolvedParams; + if (!params.closed) { + return; + } - const points = params.path; - const path = buildCustomMaskPath2D({ - points, - centerX: params.centerX, - centerY: params.centerY, - rotation: params.rotation, - scale: params.scale, - bounds: { - cx: width / 2, - cy: height / 2, - width, - height, - rotation: 0, - }, - closed: true, - }); + const points = params.path; + const path = buildCustomMaskPath2D({ + points, + centerX: params.centerX, + centerY: params.centerY, + rotation: params.rotation, + scale: params.scale, + bounds: { + cx: width / 2, + cy: height / 2, + width, + height, + rotation: 0, + }, + closed: true, + }); - ctx.save(); - ctx.strokeStyle = params.strokeColor; - ctx.lineWidth = params.strokeWidth; - ctx.lineJoin = "round"; - ctx.lineCap = "round"; - ctx.stroke(path); + ctx.save(); + ctx.strokeStyle = params.strokeColor; + ctx.lineWidth = params.strokeWidth; + ctx.lineJoin = "round"; + ctx.lineCap = "round"; + ctx.stroke(path); - if (params.strokeAlign === "inside") { - ctx.globalCompositeOperation = "destination-in"; - ctx.fillStyle = "#ffffff"; - ctx.fill(path); - } + if (params.strokeAlign === "inside") { + ctx.globalCompositeOperation = "destination-in"; + ctx.fillStyle = "#ffffff"; + ctx.fill(path); + } - if (params.strokeAlign === "outside") { - ctx.globalCompositeOperation = "destination-out"; - ctx.fillStyle = "#ffffff"; - ctx.fill(path); - } - ctx.restore(); + if (params.strokeAlign === "outside") { + ctx.globalCompositeOperation = "destination-out"; + ctx.fillStyle = "#ffffff"; + ctx.fill(path); + } + ctx.restore(); + }, }, }, }; diff --git a/apps/web/src/masks/definitions/diamond.ts b/apps/web/src/masks/definitions/diamond.ts index 037aee70..fe96dbe9 100644 --- a/apps/web/src/masks/definitions/diamond.ts +++ b/apps/web/src/masks/definitions/diamond.ts @@ -1,4 +1,4 @@ -import type { MaskDefinition, RectangleMaskParams } from "@/masks/types"; +import type { MaskDefinition } from "@/masks/types"; import { BOX_LIKE_MASK_PARAMS, buildBoxMaskInteraction, @@ -45,7 +45,7 @@ function buildDiamondPath({ return path; } -export const diamondMaskDefinition: MaskDefinition = { +export const diamondMaskDefinition: MaskDefinition<"diamond"> = { type: "diamond", name: "Diamond", features: { @@ -68,33 +68,39 @@ export const diamondMaskDefinition: MaskDefinition = { }, computeParamUpdate: computeBoxMaskParamUpdate, renderer: { - buildPath({ resolvedParams, width, height }) { - const params = resolvedParams as RectangleMaskParams; - const { centerX, centerY, maskWidth, maskHeight, rotationRad } = - getBoxLikeGeometry({ params, width, height }); - return buildDiamondPath({ - centerX, - centerY, - halfWidth: maskWidth / 2, - halfHeight: maskHeight / 2, - rotationRad, - }); + body: { + kind: "fillPath", + buildPath({ resolvedParams, width, height }) { + const params = resolvedParams; + const { centerX, centerY, maskWidth, maskHeight, rotationRad } = + getBoxLikeGeometry({ params, width, height }); + return buildDiamondPath({ + centerX, + centerY, + halfWidth: maskWidth / 2, + halfHeight: maskHeight / 2, + rotationRad, + }); + }, }, - buildStrokePath({ resolvedParams, width, height }) { - const params = resolvedParams as RectangleMaskParams; - const { centerX, centerY, maskWidth, maskHeight, rotationRad } = - getBoxLikeGeometry({ params, width, height }); - const offset = getStrokeOffset({ - strokeAlign: params.strokeAlign, - strokeWidth: params.strokeWidth, - }); - return buildDiamondPath({ - centerX, - centerY, - halfWidth: Math.max(maskWidth / 2 + offset, 1), - halfHeight: Math.max(maskHeight / 2 + offset, 1), - rotationRad, - }); + stroke: { + kind: "strokeFromPath", + buildStrokePath({ resolvedParams, width, height }) { + const params = resolvedParams; + const { centerX, centerY, maskWidth, maskHeight, rotationRad } = + getBoxLikeGeometry({ params, width, height }); + const offset = getStrokeOffset({ + strokeAlign: params.strokeAlign, + strokeWidth: params.strokeWidth, + }); + return buildDiamondPath({ + centerX, + centerY, + halfWidth: Math.max(maskWidth / 2 + offset, 1), + halfHeight: Math.max(maskHeight / 2 + offset, 1), + rotationRad, + }); + }, }, }, }; diff --git a/apps/web/src/masks/definitions/ellipse.ts b/apps/web/src/masks/definitions/ellipse.ts index d0567817..f57d7444 100644 --- a/apps/web/src/masks/definitions/ellipse.ts +++ b/apps/web/src/masks/definitions/ellipse.ts @@ -1,4 +1,4 @@ -import type { MaskDefinition, RectangleMaskParams } from "@/masks/types"; +import type { MaskDefinition } from "@/masks/types"; import { BOX_LIKE_MASK_PARAMS, buildBoxMaskInteraction, @@ -8,7 +8,7 @@ import { getStrokeOffset, } from "./box-like"; -export const ellipseMaskDefinition: MaskDefinition = { +export const ellipseMaskDefinition: MaskDefinition<"ellipse"> = { type: "ellipse", name: "Ellipse", features: { @@ -35,41 +35,47 @@ export const ellipseMaskDefinition: MaskDefinition = { }, 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; + body: { + kind: "fillPath", + buildPath({ resolvedParams, width, height }) { + const params = resolvedParams; + 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; + stroke: { + kind: "strokeFromPath", + buildStrokePath({ resolvedParams, width, height }) { + const params = resolvedParams; + 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/masks/definitions/heart.ts b/apps/web/src/masks/definitions/heart.ts index d15c09e6..ec546f18 100644 --- a/apps/web/src/masks/definitions/heart.ts +++ b/apps/web/src/masks/definitions/heart.ts @@ -1,4 +1,4 @@ -import type { MaskDefinition, RectangleMaskParams } from "@/masks/types"; +import type { MaskDefinition } from "@/masks/types"; import { BOX_LIKE_MASK_PARAMS, buildBoxMaskInteraction, @@ -78,7 +78,7 @@ function buildHeartPath({ return path; } -export const heartMaskDefinition: MaskDefinition = { +export const heartMaskDefinition: MaskDefinition<"heart"> = { type: "heart", name: "Heart", features: { @@ -110,33 +110,39 @@ export const heartMaskDefinition: MaskDefinition = { }, 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, - }); + body: { + kind: "fillPath", + buildPath({ resolvedParams, width, height }) { + const params = resolvedParams; + 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, - }); + stroke: { + kind: "strokeFromPath", + buildStrokePath({ resolvedParams, width, height }) { + const params = resolvedParams; + const { centerX, centerY, maskWidth, maskHeight, rotationRad } = + getBoxLikeGeometry({ params, width, height }); + const offset = getStrokeOffset({ + strokeAlign: params.strokeAlign, + strokeWidth: params.strokeWidth, + }); + return buildHeartPath({ + centerX, + centerY, + halfWidth: Math.max(maskWidth / 2 + offset, 1), + halfHeight: Math.max(maskHeight / 2 + offset, 1), + rotationRad, + }); + }, }, }, }; diff --git a/apps/web/src/masks/definitions/index.ts b/apps/web/src/masks/definitions/index.ts index 9cee1020..a5aec404 100644 --- a/apps/web/src/masks/definitions/index.ts +++ b/apps/web/src/masks/definitions/index.ts @@ -1,5 +1,8 @@ -import type { BaseMaskParams, MaskDefinition } from "@/masks/types"; -import { masksRegistry, type MaskIconProps } from "../registry"; +import { + masksRegistry, + type MaskDefinitionForRegistration, + type MaskIconProps, +} from "../registry"; import { cinematicBarsMaskDefinition } from "./cinematic-bars"; import { customMaskDefinition } from "./custom"; import { diamondMaskDefinition } from "./diamond"; @@ -20,11 +23,11 @@ import { TextFontIcon, } from "@hugeicons/core-free-icons"; -function registerDefaultMask({ +function registerDefaultMask({ definition, icon, }: { - definition: MaskDefinition; + definition: MaskDefinitionForRegistration; icon: MaskIconProps; }) { if (masksRegistry.has(definition.type)) { diff --git a/apps/web/src/masks/definitions/rectangle.ts b/apps/web/src/masks/definitions/rectangle.ts index 7bf1a7fd..db7a4578 100644 --- a/apps/web/src/masks/definitions/rectangle.ts +++ b/apps/web/src/masks/definitions/rectangle.ts @@ -1,4 +1,4 @@ -import type { MaskDefinition, RectangleMaskParams } from "@/masks/types"; +import type { MaskDefinition } from "@/masks/types"; import { BOX_LIKE_MASK_PARAMS, buildBoxMaskInteraction, @@ -45,7 +45,7 @@ function buildRectanglePath({ return path; } -export const rectangleMaskDefinition: MaskDefinition = { +export const rectangleMaskDefinition: MaskDefinition<"rectangle"> = { type: "rectangle", name: "Rectangle", features: { @@ -65,33 +65,39 @@ export const rectangleMaskDefinition: MaskDefinition = { }, 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, - }); + body: { + kind: "fillPath", + buildPath({ resolvedParams, width, height }) { + const params = resolvedParams; + 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, - }); + stroke: { + kind: "strokeFromPath", + buildStrokePath({ resolvedParams, width, height }) { + const params = resolvedParams; + 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/masks/definitions/split.ts b/apps/web/src/masks/definitions/split.ts index ddd4e452..de67b876 100644 --- a/apps/web/src/masks/definitions/split.ts +++ b/apps/web/src/masks/definitions/split.ts @@ -71,11 +71,11 @@ export function getSplitMaskStrokeSegment({ width, height, }: { - resolvedParams: unknown; + resolvedParams: SplitMaskParams; width: number; height: number; }): [{ 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({ centerX, centerY, @@ -180,7 +180,7 @@ function computeSplitMaskParamUpdate({ return {}; } -export const splitMaskDefinition: MaskDefinition = { +export const splitMaskDefinition: MaskDefinition<"split"> = { type: "split", name: "Split", features: { @@ -267,124 +267,131 @@ export const splitMaskDefinition: MaskDefinition = { }, ], renderer: { - renderMaskHandlesFeather: true, - renderMask({ resolvedParams, ctx, width, height, feather }) { - const { centerX, centerY, rotation } = resolvedParams as SplitMaskParams; - const { normalX, normalY, lineX, lineY } = splitLineGeometry({ - centerX, - centerY, - rotation, - width, - height, - }); + body: { + kind: "drawWithFeather", + drawWithFeather({ resolvedParams, ctx, width, height, feather }) { + const { centerX, centerY, rotation } = resolvedParams; + 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"); + // 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); - }, + 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, - 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, + opaqueFastPath: { + buildPath({ resolvedParams, width, height }) { + const { centerX, centerY, rotation } = resolvedParams; + const { normalX, normalY, lineX, lineY } = splitLineGeometry({ + centerX, + centerY, + rotation, + width, + height, }); - 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]); + + 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 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(); - } + 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; + 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(); + stroke: { + kind: "strokeFromPath", + buildStrokePath({ resolvedParams, width, height }) { + const segment = getSplitMaskStrokeSegment({ + resolvedParams, + width, + 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; - } - - path.moveTo(segment[0].x, segment[0].y); - path.lineTo(segment[1].x, segment[1].y); - return path; + }, }, }, }; diff --git a/apps/web/src/masks/definitions/star.ts b/apps/web/src/masks/definitions/star.ts index a7f8819c..2a1a9b7e 100644 --- a/apps/web/src/masks/definitions/star.ts +++ b/apps/web/src/masks/definitions/star.ts @@ -1,4 +1,4 @@ -import type { MaskDefinition, RectangleMaskParams } from "@/masks/types"; +import type { MaskDefinition } from "@/masks/types"; import { BOX_LIKE_MASK_PARAMS, buildBoxMaskInteraction, @@ -85,7 +85,7 @@ function buildOverlayStarPath({ return `${segments.join(" ")} Z`; } -export const starMaskDefinition: MaskDefinition = { +export const starMaskDefinition: MaskDefinition<"star"> = { type: "star", name: "Star", features: { @@ -108,33 +108,39 @@ export const starMaskDefinition: MaskDefinition = { }, computeParamUpdate: computeBoxMaskParamUpdate, renderer: { - buildPath({ resolvedParams, width, height }) { - const params = resolvedParams as RectangleMaskParams; - const { centerX, centerY, maskWidth, maskHeight, rotationRad } = - getBoxLikeGeometry({ params, width, height }); - return buildStarPath({ - centerX, - centerY, - halfWidth: maskWidth / 2, - halfHeight: maskHeight / 2, - rotationRad, - }); + body: { + kind: "fillPath", + buildPath({ resolvedParams, width, height }) { + const params = resolvedParams; + const { centerX, centerY, maskWidth, maskHeight, rotationRad } = + getBoxLikeGeometry({ params, width, height }); + return buildStarPath({ + centerX, + centerY, + halfWidth: maskWidth / 2, + halfHeight: maskHeight / 2, + rotationRad, + }); + }, }, - buildStrokePath({ resolvedParams, width, height }) { - const params = resolvedParams as RectangleMaskParams; - const { centerX, centerY, maskWidth, maskHeight, rotationRad } = - getBoxLikeGeometry({ params, width, height }); - const offset = getStrokeOffset({ - strokeAlign: params.strokeAlign, - strokeWidth: params.strokeWidth, - }); - return buildStarPath({ - centerX, - centerY, - halfWidth: Math.max(maskWidth / 2 + offset, 1), - halfHeight: Math.max(maskHeight / 2 + offset, 1), - rotationRad, - }); + stroke: { + kind: "strokeFromPath", + buildStrokePath({ resolvedParams, width, height }) { + const params = resolvedParams; + const { centerX, centerY, maskWidth, maskHeight, rotationRad } = + getBoxLikeGeometry({ params, width, height }); + const offset = getStrokeOffset({ + strokeAlign: params.strokeAlign, + strokeWidth: params.strokeWidth, + }); + return buildStarPath({ + centerX, + centerY, + halfWidth: Math.max(maskWidth / 2 + offset, 1), + halfHeight: Math.max(maskHeight / 2 + offset, 1), + rotationRad, + }); + }, }, }, }; diff --git a/apps/web/src/masks/definitions/text.ts b/apps/web/src/masks/definitions/text.ts index c65564d1..aa786e78 100644 --- a/apps/web/src/masks/definitions/text.ts +++ b/apps/web/src/masks/definitions/text.ts @@ -205,7 +205,7 @@ function computeTextMaskParamUpdate({ return {}; } -export const textMaskDefinition: MaskDefinition = { +export const textMaskDefinition: MaskDefinition<"text"> = { type: "text", name: "Text", features: { @@ -370,69 +370,75 @@ export const textMaskDefinition: MaskDefinition = { return params.content.trim().length > 0; }, renderer: { - renderMask({ resolvedParams, ctx, width, height }) { - const params = resolvedParams as TextMaskParams; - const { layout } = measureTextMask({ params, height }); + body: { + kind: "drawOpaque", + drawOpaque({ resolvedParams, ctx, width, height }) { + const params = resolvedParams; + const { layout } = measureTextMask({ params, height }); - ctx.save(); - ctx.translate( - width / 2 + params.centerX * width, - height / 2 + params.centerY * height, - ); - ctx.scale(params.scale, params.scale); - if (params.rotation) { - ctx.rotate((params.rotation * Math.PI) / 180); - } - drawMeasuredTextLayout({ - ctx, - layout, - textColor: "#ffffff", - background: null, - }); - ctx.restore(); + ctx.save(); + ctx.translate( + width / 2 + params.centerX * width, + height / 2 + params.centerY * height, + ); + ctx.scale(params.scale, params.scale); + if (params.rotation) { + ctx.rotate((params.rotation * Math.PI) / 180); + } + drawMeasuredTextLayout({ + ctx, + layout, + textColor: "#ffffff", + background: null, + }); + ctx.restore(); + }, }, - renderStroke({ resolvedParams, ctx, width, height }) { - const params = resolvedParams as TextMaskParams; - const { layout } = measureTextMask({ params, height }); + stroke: { + kind: "renderStroke", + renderStroke({ resolvedParams, ctx, width, height }) { + const params = resolvedParams; + const { layout } = measureTextMask({ params, height }); - ctx.save(); - ctx.translate( - width / 2 + params.centerX * width, - height / 2 + params.centerY * height, - ); - ctx.scale(params.scale, params.scale); - if (params.rotation) { - ctx.rotate((params.rotation * Math.PI) / 180); - } + ctx.save(); + ctx.translate( + width / 2 + params.centerX * width, + height / 2 + params.centerY * height, + ); + ctx.scale(params.scale, params.scale); + if (params.rotation) { + ctx.rotate((params.rotation * Math.PI) / 180); + } - strokeMeasuredTextLayout({ - ctx, - layout, - strokeColor: params.strokeColor, - strokeWidth: params.strokeWidth, - }); - - if (params.strokeAlign === "inside") { - ctx.globalCompositeOperation = "destination-in"; - drawMeasuredTextLayout({ + strokeMeasuredTextLayout({ ctx, layout, - textColor: "#ffffff", - background: null, + strokeColor: params.strokeColor, + strokeWidth: params.strokeWidth, }); - } - if (params.strokeAlign === "outside") { - ctx.globalCompositeOperation = "destination-out"; - drawMeasuredTextLayout({ - ctx, - layout, - textColor: "#ffffff", - background: null, - }); - } + if (params.strokeAlign === "inside") { + ctx.globalCompositeOperation = "destination-in"; + drawMeasuredTextLayout({ + ctx, + layout, + textColor: "#ffffff", + background: null, + }); + } - ctx.restore(); + if (params.strokeAlign === "outside") { + ctx.globalCompositeOperation = "destination-out"; + drawMeasuredTextLayout({ + ctx, + layout, + textColor: "#ffffff", + background: null, + }); + } + + ctx.restore(); + }, }, }, }; diff --git a/apps/web/src/masks/index.ts b/apps/web/src/masks/index.ts index 3544e239..df7649e1 100644 --- a/apps/web/src/masks/index.ts +++ b/apps/web/src/masks/index.ts @@ -1,18 +1,50 @@ -import type { Mask, MaskDefaultContext, MaskType } from "@/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; -} +import type { Mask, MaskDefaultContext, MaskType } from "@/masks/types"; +import { masksRegistry } from "./registry"; +import { generateUUID } from "@/utils/id"; + +export { masksRegistry } from "./registry"; +export { registerDefaultMasks } from "./definitions"; + +type MaskWithoutId = Mask extends infer TMask + ? TMask extends Mask + ? Omit + : never + : never; + +function withMaskId({ mask, id }: { mask: MaskWithoutId; id: string }): Mask { + switch (mask.type) { + case "split": + 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(), + }); +} diff --git a/apps/web/src/masks/registry.ts b/apps/web/src/masks/registry.ts index 0bcf1cf0..64ab2f60 100644 --- a/apps/web/src/masks/registry.ts +++ b/apps/web/src/masks/registry.ts @@ -2,11 +2,11 @@ import { MAX_FEATHER } from "@/masks/feather"; import type { ParamDefinition } from "@/params"; import type { BaseMaskParams, + Mask, MaskDefaultContext, MaskDefinition, - MaskInteractionResult, - MaskSnapArgs, - MaskSnapResult, + MaskParamUpdateArgs, + MaskRenderer, MaskType, } from "@/masks/types"; import type { HugeiconsIconProps } from "@hugeicons/react"; @@ -17,6 +17,16 @@ export type MaskIconProps = { strokeWidth?: number; }; +type RegisteredMaskWithoutId = Mask extends infer TMask + ? TMask extends Mask + ? Omit + : never + : never; + +export type MaskDefinitionForRegistration = { + [TType in MaskType]: MaskDefinition; +}[MaskType]; + const BASE_MASK_PARAM_DEFINITIONS: ParamDefinition< keyof BaseMaskParams & string >[] = [ @@ -50,28 +60,15 @@ const BASE_MASK_PARAM_DEFINITIONS: ParamDefinition< export interface RegisteredMaskDefinition { type: MaskType; name: string; - features: MaskDefinition["features"]; + features: MaskDefinition["features"]; params: ParamDefinition[]; - renderer: MaskDefinition["renderer"]; - interaction: { - getInteraction(args: { - params: BaseMaskParams; - bounds: Parameters< - MaskDefinition["interaction"]["getInteraction"] - >[0]["bounds"]; - displayScale: number; - scaleX: number; - scaleY: number; - }): MaskInteractionResult; - snap?(args: MaskSnapArgs): MaskSnapResult; - }; - isActive?: (params: BaseMaskParams) => boolean; - buildDefault( - context: MaskDefaultContext, - ): ReturnType["buildDefault"]>; + renderer: MaskRenderer; + interaction: MaskDefinition["interaction"]; + isActive?(params: BaseMaskParams): boolean; + buildDefault(context: MaskDefaultContext): RegisteredMaskWithoutId; computeParamUpdate( - args: Parameters["computeParamUpdate"]>[0], - ): ReturnType["computeParamUpdate"]>; + args: MaskParamUpdateArgs, + ): ReturnType; icon: MaskIconProps; } @@ -83,11 +80,11 @@ export class MasksRegistry extends DefinitionRegistry< super("mask"); } - registerMask({ + registerMask({ definition, icon, }: { - definition: MaskDefinition; + definition: MaskDefinitionForRegistration; icon: MaskIconProps; }): void { const withBaseParams: RegisteredMaskDefinition = { @@ -96,23 +93,10 @@ export class MasksRegistry extends DefinitionRegistry< features: definition.features, params: [...definition.params, ...BASE_MASK_PARAM_DEFINITIONS], renderer: definition.renderer, - interaction: { - getInteraction(args) { - return definition.interaction.getInteraction(args as never); - }, - snap: definition.interaction.snap - ? (args) => definition.interaction.snap?.(args as never) as never - : undefined, - }, - isActive: definition.isActive - ? (params) => definition.isActive?.(params as TParams) ?? true - : undefined, - buildDefault(context) { - return definition.buildDefault(context); - }, - computeParamUpdate(args) { - return definition.computeParamUpdate(args as never); - }, + interaction: definition.interaction, + isActive: definition.isActive, + buildDefault: definition.buildDefault, + computeParamUpdate: definition.computeParamUpdate, icon, }; this.register({ diff --git a/apps/web/src/masks/types.ts b/apps/web/src/masks/types.ts index 4f6c90aa..d35ca889 100644 --- a/apps/web/src/masks/types.ts +++ b/apps/web/src/masks/types.ts @@ -131,32 +131,50 @@ export type Mask = | TextMask | CustomMask; -export interface MaskRenderer { - buildPath?: (params: { - resolvedParams: unknown; - width: number; - height: number; - }) => Path2D; - buildStrokePath?: (params: { - resolvedParams: unknown; - width: number; - height: number; - }) => Path2D; - /** Renders the feathered mask directly onto ctx, bypassing JFA. */ - renderMask?: (params: { - resolvedParams: unknown; - ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D; - width: number; - height: number; - feather: number; - }) => void; - renderMaskHandlesFeather?: boolean; - renderStroke?: (params: { - resolvedParams: unknown; - ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D; - width: number; - height: number; - }) => void; +export type MaskByType = Extract; +export type MaskParamsByType = + MaskByType["params"]; + +type MaskPathArgs = { + resolvedParams: TParams; + width: number; + height: number; +}; + +type MaskDrawArgs = MaskPathArgs & { + ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D; +}; + +export type MaskBody = + | { + kind: "fillPath"; + buildPath(args: MaskPathArgs): Path2D; + } + | { + kind: "drawOpaque"; + drawOpaque(args: MaskDrawArgs): void; + } + | { + kind: "drawWithFeather"; + drawWithFeather(args: MaskDrawArgs & { feather: number }): void; + opaqueFastPath?: { + buildPath(args: MaskPathArgs): Path2D; + }; + }; + +export type MaskStroke = + | { + kind: "strokeFromPath"; + buildStrokePath(args: MaskPathArgs): Path2D; + } + | { + kind: "renderStroke"; + renderStroke(args: MaskDrawArgs): void; + }; + +export interface MaskRenderer { + body: MaskBody; + stroke?: MaskStroke; } export interface MaskFeatures { @@ -282,17 +300,17 @@ export interface MaskInteractionDefinition< snap?(args: MaskSnapArgs): MaskSnapResult; } -export interface MaskDefinition< - TParams extends BaseMaskParams = BaseMaskParams, -> { - type: MaskType; +export interface MaskDefinition { + type: TType; name: string; features: MaskFeatures; - params: ParamDefinition[]; - renderer: MaskRenderer; - interaction: MaskInteractionDefinition; + params: ParamDefinition & string>[]; + renderer: MaskRenderer>; + interaction: MaskInteractionDefinition>; /** When defined and returning false, the mask is not applied and the element renders fully visible. */ - isActive?: (params: TParams) => boolean; - buildDefault(context: MaskDefaultContext): Omit; - computeParamUpdate(args: MaskParamUpdateArgs): Partial; + isActive?(params: MaskParamsByType): boolean; + buildDefault(context: MaskDefaultContext): Omit, "id">; + computeParamUpdate( + args: MaskParamUpdateArgs>, + ): Partial>; } diff --git a/apps/web/src/services/renderer/compositor/frame-descriptor.ts b/apps/web/src/services/renderer/compositor/frame-descriptor.ts index 1bc316ef..eeb0633c 100644 --- a/apps/web/src/services/renderer/compositor/frame-descriptor.ts +++ b/apps/web/src/services/renderer/compositor/frame-descriptor.ts @@ -396,23 +396,16 @@ function buildMaskArtifacts({ return { mask: null, strokeLayer: null }; } - let feather = mask.params.feather; - const canRenderMaskDirectly = Boolean(definition.renderer.renderMask); - const shouldRenderMaskDirectly = - canRenderMaskDirectly && - (!definition.renderer.buildPath || - (mask.params.feather > 0 && - definition.renderer.renderMaskHandlesFeather)); - if ( - shouldRenderMaskDirectly && - definition.renderer.renderMaskHandlesFeather - ) { - feather = 0; - } + const { body } = definition.renderer; + const usesOpaqueFastPath = + body.kind === "drawWithFeather" && + mask.params.feather === 0 && + Boolean(body.opaqueFastPath); + const feather = body.kind === "drawWithFeather" ? 0 : mask.params.feather; const maskTextureId = `${path}:mask`; 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 { canvas: elementMaskCanvas, context: elementMaskCtx } = createCanvasSurface({ @@ -420,24 +413,44 @@ function buildMaskArtifacts({ height: Math.round(transform.height), }); - if (shouldRenderMaskDirectly && definition.renderer.renderMask) { - definition.renderer.renderMask({ - resolvedParams: mask.params, - ctx: elementMaskCtx, - width: Math.round(transform.width), - height: Math.round(transform.height), - feather: mask.params.feather, - }); - } else if (definition.renderer.buildPath) { - const path2d = definition.renderer.buildPath({ - resolvedParams: mask.params, - width: transform.width, - height: transform.height, - }); - elementMaskCtx.fillStyle = "white"; - elementMaskCtx.fill(path2d); - } else { - return; + switch (body.kind) { + case "fillPath": { + const path2d = body.buildPath({ + resolvedParams: mask.params, + width: transform.width, + height: transform.height, + }); + elementMaskCtx.fillStyle = "white"; + elementMaskCtx.fill(path2d); + break; + } + case "drawOpaque": + body.drawOpaque({ + resolvedParams: mask.params, + ctx: elementMaskCtx, + width: Math.round(transform.width), + height: Math.round(transform.height), + }); + 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 }); @@ -451,45 +464,38 @@ function buildMaskArtifacts({ draw: drawMask, }); - const hasStroke = - mask.params.strokeWidth > 0 && - (definition.renderer.renderStroke || - definition.renderer.buildStrokePath || - definition.renderer.buildPath); + const stroke = definition.renderer.stroke; + const hasStroke = mask.params.strokeWidth > 0 && Boolean(stroke); let strokeLayer: FrameItemDescriptor | null = null; - if (hasStroke) { + if (hasStroke && 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 { canvas: strokeCanvas, context: strokeCtx } = createCanvasSurface({ width: Math.round(transform.width), height: Math.round(transform.height), }); - if (definition.renderer.renderStroke) { - definition.renderer.renderStroke({ - resolvedParams: mask.params, - ctx: strokeCtx, - width: transform.width, - height: transform.height, - }); - } else { - const strokePath = - definition.renderer.buildStrokePath?.({ + switch (stroke.kind) { + case "renderStroke": + stroke.renderStroke({ + resolvedParams: mask.params, + ctx: strokeCtx, + width: transform.width, + height: transform.height, + }); + break; + case "strokeFromPath": { + const strokePath = stroke.buildStrokePath({ resolvedParams: mask.params, width: transform.width, height: transform.height, - }) ?? - definition.renderer.buildPath?.({ - resolvedParams: mask.params, - width: transform.width, - height: transform.height, - }) ?? - null; - if (!strokePath) return; - strokeCtx.strokeStyle = mask.params.strokeColor; - strokeCtx.lineWidth = mask.params.strokeWidth; - strokeCtx.stroke(strokePath); + }); + strokeCtx.strokeStyle = mask.params.strokeColor; + strokeCtx.lineWidth = mask.params.strokeWidth; + strokeCtx.stroke(strokePath); + break; + } } drawTransformedCanvas({ ctx, source: strokeCanvas, transform });