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 | |
| 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')
25 files changed, 6819 insertions, 0 deletions
diff --git a/apps/web/src/masks/__tests__/snap.test.ts b/apps/web/src/masks/__tests__/snap.test.ts new file mode 100644 index 00000000..2f33615f --- /dev/null +++ b/apps/web/src/masks/__tests__/snap.test.ts @@ -0,0 +1,487 @@ +import { describe, expect, test } from "bun:test"; +import { + findClosestPointOnCustomMaskSegment, + getCustomMaskClosedStateAfterPointRemoval, + insertPointIntoCustomMaskSegment, + removeCustomMaskPoints, +} from "@/masks/custom-path"; +import { + appendPointToCustomMask, + customMaskDefinition, + insertPointOnCustomMaskSegment, +} from "@/masks/definitions/custom"; +import { getSplitMaskStrokeSegment } from "@/masks/definitions/split"; +import { textMaskDefinition } from "@/masks/definitions/text"; +import { getMaskSnapGeometry } from "@/masks/geometry"; +import { snapMaskInteraction } from "@/masks/snap"; +import type { ElementBounds } from "@/preview/element-bounds"; +import type { + CustomMaskParams, + RectangleMaskParams, + SplitMaskParams, + TextMaskParams, +} from "@/masks/types"; + +const bounds: ElementBounds = { + cx: 200, + cy: 150, + width: 200, + height: 100, + rotation: 0, +}; + +const canvasSize = { + width: 400, + height: 300, +}; + +const snapThreshold = { + x: 8, + y: 8, +}; + +function buildSplitParams( + overrides: Partial<SplitMaskParams> = {}, +): SplitMaskParams { + return { + feather: 0, + inverted: false, + strokeColor: "#ffffff", + strokeWidth: 0, + strokeAlign: "center", + centerX: 0, + centerY: 0, + rotation: 0, + ...overrides, + }; +} + +function buildRectangleParams( + overrides: Partial<RectangleMaskParams> = {}, +): RectangleMaskParams { + return { + feather: 0, + inverted: false, + strokeColor: "#ffffff", + strokeWidth: 0, + strokeAlign: "center", + centerX: 0, + centerY: 0, + width: 0.4, + height: 0.2, + rotation: 0, + scale: 1, + ...overrides, + }; +} + +function buildTextMaskParams( + overrides: Partial<TextMaskParams> = {}, +): TextMaskParams { + return { + feather: 0, + inverted: false, + strokeColor: "#ffffff", + strokeWidth: 0, + strokeAlign: "center", + content: "Mask", + fontSize: 15, + fontFamily: "Arial", + fontWeight: "normal", + fontStyle: "normal", + textDecoration: "none", + letterSpacing: 0, + lineHeight: 1.2, + centerX: 0, + centerY: 0, + rotation: 0, + scale: 1, + ...overrides, + }; +} + +function buildCustomMaskParams( + overrides: Partial<CustomMaskParams> = {}, +): CustomMaskParams { + return { + feather: 0, + inverted: false, + strokeColor: "#ffffff", + strokeWidth: 0, + strokeAlign: "center", + path: [ + { + id: "a", + x: -0.2, + y: -0.1, + inX: 0, + inY: 0, + outX: 0, + outY: 0, + }, + { + id: "b", + x: 0.2, + y: -0.1, + inX: 0, + inY: 0, + outX: 0, + outY: 0, + }, + { + id: "c", + x: 0, + y: 0.2, + inX: 0, + inY: 0, + outX: 0, + outY: 0, + }, + ], + closed: true, + centerX: 0, + centerY: 0, + rotation: 0, + scale: 1, + ...overrides, + }; +} + +function sortSegment( + segment: [{ x: number; y: number }, { x: number; y: number }], +): [{ x: number; y: number }, { x: number; y: number }] { + return [...segment].sort((a, b) => (a.x === b.x ? a.y - b.y : a.x - b.x)) as [ + { x: number; y: number }, + { x: number; y: number }, + ]; +} + +describe("mask geometry", () => { + test("resolves split mask center from centerX and centerY", () => { + expect( + getMaskSnapGeometry({ + params: buildSplitParams({ + centerX: 0.25, + centerY: -0.5, + rotation: 45, + }), + bounds, + }), + ).toEqual({ + position: { x: 50, y: -50 }, + size: { width: 0, height: 0 }, + rotation: 45, + }); + }); + + test("resolves box mask center and size from centerX and centerY", () => { + expect( + getMaskSnapGeometry({ + params: buildRectangleParams({ + centerX: -0.25, + centerY: 0.5, + width: 0.5, + height: 0.6, + rotation: 30, + }), + bounds, + }), + ).toEqual({ + position: { x: -50, y: 50 }, + size: { width: 100, height: 60 }, + rotation: 30, + }); + }); + + test("returns a vertical split stroke segment for rotation 0", () => { + const segment = getSplitMaskStrokeSegment({ + resolvedParams: buildSplitParams(), + width: bounds.width, + height: bounds.height, + }); + + expect(segment).not.toBeNull(); + if (!segment) { + throw new Error("Expected split stroke segment for rotation 0"); + } + expect(sortSegment(segment)).toEqual([ + { x: bounds.width / 2, y: 0 }, + { x: bounds.width / 2, y: bounds.height }, + ]); + }); + + test("returns a horizontal split stroke segment for rotation 90", () => { + const segment = getSplitMaskStrokeSegment({ + resolvedParams: buildSplitParams({ rotation: 90 }), + width: bounds.width, + height: bounds.height, + }); + + expect(segment).not.toBeNull(); + if (!segment) { + throw new Error("Expected split stroke segment for rotation 90"); + } + expect(sortSegment(segment)).toEqual([ + { x: 0, y: bounds.height / 2 }, + { x: bounds.width, y: bounds.height / 2 }, + ]); + }); +}); + +describe("mask snapping", () => { + test("snaps split mask movement using the shared position pipeline", () => { + const result = snapMaskInteraction({ + handleId: "position", + startParams: buildSplitParams({ + centerX: 0.03, + centerY: -0.04, + }), + proposedParams: buildSplitParams({ + centerX: 0.03, + centerY: -0.04, + }), + bounds, + canvasSize, + snapThreshold, + }); + + expect(result.params.centerX).toBe(0); + expect(result.params.centerY).toBe(0); + expect(result.activeLines).toEqual([ + { type: "vertical", position: 0 }, + { type: "horizontal", position: 0 }, + ]); + }); + + test("snaps box mask movement against element center and edges", () => { + const result = snapMaskInteraction({ + handleId: "position", + startParams: buildRectangleParams(), + proposedParams: buildRectangleParams({ + centerX: 0.29, + centerY: 0.03, + }), + bounds, + canvasSize, + snapThreshold, + }); + + expect(result.params.centerX).toBeCloseTo(0.3); + expect(result.params.centerY).toBe(0); + expect(result.activeLines).toEqual([ + { type: "vertical", position: 100 }, + { type: "horizontal", position: 0 }, + ]); + }); + + test("snaps mask rotation through the shared rotation path", () => { + const result = snapMaskInteraction({ + handleId: "rotation", + startParams: buildRectangleParams(), + proposedParams: buildRectangleParams({ + rotation: 88, + }), + bounds, + canvasSize, + snapThreshold, + }); + + expect(result.params.rotation).toBe(90); + expect(result.activeLines).toEqual([]); + }); + + test("snaps edge resize for box masks", () => { + const result = snapMaskInteraction({ + handleId: "right", + startParams: buildRectangleParams(), + proposedParams: buildRectangleParams({ + width: 0.98, + }), + bounds, + canvasSize, + snapThreshold, + }); + + expect(result.params.width).toBe(1); + expect(result.activeLines).toEqual([{ type: "vertical", position: 100 }]); + }); + + test("snaps corner resize for box masks", () => { + const result = snapMaskInteraction({ + handleId: "bottom-right", + startParams: buildRectangleParams(), + proposedParams: buildRectangleParams({ + width: 0.99, + height: 0.495, + }), + bounds, + canvasSize, + snapThreshold, + }); + + expect(result.params.width).toBe(1); + expect(result.params.height).toBe(0.5); + expect(result.activeLines).toEqual([{ type: "vertical", position: 100 }]); + }); + + test("snaps text mask movement using intrinsic text bounds", () => { + const params = buildTextMaskParams({ + centerX: 0.03, + centerY: -0.04, + }); + const result = textMaskDefinition.interaction.snap?.({ + handleId: "position", + startParams: params, + proposedParams: params, + bounds, + canvasSize, + snapThreshold, + }); + + expect(result?.params.centerX).toBe(0); + expect(result?.params.centerY).toBe(0); + expect(result?.activeLines).toEqual([ + { type: "vertical", position: 0 }, + { type: "horizontal", position: 0 }, + ]); + }); + + test("snaps custom mask movement using path geometry bounds", () => { + const params = buildCustomMaskParams({ + centerX: 0.03, + centerY: -0.04, + }); + const result = customMaskDefinition.interaction.snap?.({ + handleId: "position", + startParams: params, + proposedParams: params, + bounds, + canvasSize, + snapThreshold, + }); + + expect(result?.params.centerX).toBe(0); + expect(result?.params.centerY).toBe(0); + expect(result?.activeLines).toEqual([ + { type: "vertical", position: 0 }, + { type: "horizontal", position: 0 }, + ]); + }); + + test("marks blank text masks inactive", () => { + expect( + textMaskDefinition.isActive?.( + buildTextMaskParams({ + content: " ", + }), + ), + ).toBe(false); + }); +}); + +describe("custom mask creation", () => { + test("anchors the first point at the click position", () => { + const params = buildCustomMaskParams({ + path: [], + closed: false, + }); + const next = appendPointToCustomMask({ + params, + canvasPoint: { x: bounds.cx + 20, y: bounds.cy - 10 }, + bounds, + }); + + expect(next.centerX).toBeCloseTo(0.1); + expect(next.centerY).toBeCloseTo(-0.1); + expect(next.rotation).toBe(0); + expect(next.scale).toBe(1); + expect(next.path).toHaveLength(1); + }); +}); + +describe("custom mask point deletion", () => { + test("removes the selected points by id", () => { + const points = buildCustomMaskParams().path; + const nextPoints = removeCustomMaskPoints({ + points, + pointIds: ["b"], + }); + + expect(nextPoints.map((point) => point.id)).toEqual(["a", "c"]); + }); + + test("reopens a closed path once fewer than three points remain", () => { + const points = buildCustomMaskParams().path; + const nextPoints = removeCustomMaskPoints({ + points, + pointIds: ["c"], + }); + + expect( + getCustomMaskClosedStateAfterPointRemoval({ + wasClosed: true, + remainingPointCount: nextPoints.length, + }), + ).toBe(false); + }); +}); + +describe("custom mask point insertion", () => { + test("finds the closest point on the clicked segment", () => { + const params = buildCustomMaskParams(); + const points = params.path; + const closestPoint = findClosestPointOnCustomMaskSegment({ + points, + segmentIndex: 0, + canvasPoint: { x: bounds.cx, y: bounds.cy - 10 }, + centerX: params.centerX, + centerY: params.centerY, + rotation: params.rotation, + scale: params.scale, + bounds, + closed: params.closed, + }); + + expect(closestPoint).not.toBeNull(); + expect(closestPoint?.t).toBeCloseTo(0.5, 1); + expect(closestPoint?.point.x).toBeCloseTo(bounds.cx, 4); + expect(closestPoint?.point.y).toBeCloseTo(bounds.cy - 10, 4); + }); + + test("splits a segment into two segments at the insertion point", () => { + const points = buildCustomMaskParams().path; + const nextPoints = insertPointIntoCustomMaskSegment({ + points, + segmentIndex: 0, + pointId: "new", + t: 0.5, + closed: true, + }); + + expect(nextPoints.map((point) => point.id)).toEqual(["a", "new", "b", "c"]); + expect(nextPoints[1]).toMatchObject({ + id: "new", + x: 0, + y: -0.1, + inX: 0, + inY: 0, + outX: 0, + outY: 0, + }); + }); + + test("builds updated custom mask params for a clicked segment", () => { + const result = insertPointOnCustomMaskSegment({ + params: buildCustomMaskParams(), + segmentIndex: 0, + canvasPoint: { x: bounds.cx, y: bounds.cy - 10 }, + bounds, + pointId: "new", + }); + + expect(result).not.toBeNull(); + const nextPoints = result?.params.path ?? []; + expect(nextPoints).toHaveLength(4); + expect(nextPoints.some((point) => point.id === "new")).toBe(true); + }); +}); diff --git a/apps/web/src/masks/components/masks-tab.tsx b/apps/web/src/masks/components/masks-tab.tsx new file mode 100644 index 00000000..97fba3ab --- /dev/null +++ b/apps/web/src/masks/components/masks-tab.tsx @@ -0,0 +1,823 @@ +"use client"; + +import type { MaskableElement } from "@/timeline"; +import type { Mask, MaskType, TextMask } from "@/masks/types"; +import type { NumberParamDefinition, SelectParamDefinition } from "@/params"; +import { masksRegistry, buildDefaultMaskInstance } from "@/masks"; +import { useEditor } from "@/editor/use-editor"; +import { useElementPreview } from "@/timeline/hooks/use-element-preview"; +import { useMenuPreview } from "@/editor/use-menu-preview"; +import { getVisibleElementsWithBounds } from "@/preview/element-bounds"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { + ArrowExpandIcon, + Delete02Icon, + FeatherIcon, + PlusSignIcon, + RotateClockwiseIcon, + TextFontIcon, +} from "@hugeicons/core-free-icons"; +import { useMemo, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { ColorPicker } from "@/components/ui/color-picker"; +import { FontPicker } from "@/components/ui/font-picker"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { NumberField } from "@/components/ui/number-field"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { + clamp, + formatNumberForDisplay, + getFractionDigitsForStep, + snapToStep, +} from "@/utils/math"; +import { + Section, + SectionContent, + SectionField, + SectionFields, + SectionHeader, + SectionTitle, +} from "@/components/section"; +import { usePropertyDraft } from "@/components/editor/panels/properties/hooks/use-property-draft"; +import { + OcMirrorIcon, + OcShapesIcon, + OcTextHeightIcon, + OcTextWidthIcon, +} from "@/components/icons"; +import { cn } from "@/utils/ui"; + +type MasksTabProps = { + element: MaskableElement; + trackId: string; +}; + +type MaskItemProps = { + trackId: string; + elementId: string; + mask: Mask; + previewParam: (key: string) => (value: number | string | boolean) => void; + onCommit: () => void; +}; + +type EmptyViewProps = { + onAddMask: () => void; +}; + +type PreviewParamHandler = ( + key: string, +) => (value: number | string | boolean) => void; + +type RegisteredMaskDefinition = ReturnType<(typeof masksRegistry)["get"]>; + +function isTextMask(mask: Mask): mask is TextMask { + return mask.type === "text"; +} + +export function MasksTab({ element, trackId }: MasksTabProps) { + const editor = useEditor(); + const { renderElement, previewUpdates, commit } = + useElementPreview<MaskableElement>({ + trackId, + elementId: element.id, + fallback: element, + }); + const maskDefs = masksRegistry.getAll(); + const tracks = useEditor( + (e) => e.timeline.getPreviewTracks() ?? e.scenes.getActiveScene().tracks, + ); + const currentTime = useEditor((e) => e.playback.getCurrentTime()); + const mediaAssets = useEditor((e) => e.media.getAssets()); + const canvasSize = useEditor( + (e) => e.project.getActive().settings.canvasSize, + ); + const masks = element.masks ?? []; + const renderMasks = renderElement.masks ?? masks; + const hasMask = masks.length > 0; + const { onPointerLeave, onOpenChange, markCommitted } = useMenuPreview(); + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const elementBounds = useMemo(() => { + const clampedTime = Math.min( + Math.max(currentTime, element.startTime), + element.startTime + element.duration - 1, + ); + + return ( + getVisibleElementsWithBounds({ + tracks, + currentTime: clampedTime, + canvasSize, + mediaAssets, + }).find( + (item) => item.trackId === trackId && item.elementId === element.id, + )?.bounds ?? null + ); + }, [ + canvasSize, + currentTime, + element.duration, + element.id, + element.startTime, + mediaAssets, + trackId, + tracks, + ]); + + const handleDropdownOpenChange = (open: boolean) => { + if (hasMask && open) return; + setIsDropdownOpen(open); + onOpenChange(open); + }; + + const previewMask = ({ maskType }: { maskType: MaskType }) => { + editor.timeline.previewElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { + masks: [ + buildDefaultMaskInstance({ + maskType, + elementSize: elementBounds + ? { + width: elementBounds.width, + height: elementBounds.height, + } + : undefined, + }), + ], + } as Partial<MaskableElement>, + }, + ], + }); + }; + + const commitMask = ({ maskType }: { maskType: MaskType }) => { + if (editor.timeline.isPreviewActive()) { + editor.timeline.commitPreview(); + } else { + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + patch: { + masks: [ + buildDefaultMaskInstance({ + maskType, + elementSize: elementBounds + ? { + width: elementBounds.width, + height: elementBounds.height, + } + : undefined, + }), + ], + } as Partial<MaskableElement>, + }, + ], + }); + } + markCommitted(); + setIsDropdownOpen(false); + }; + + const previewMaskParam = + ({ index, key }: { index: number; key: string }) => + (value: number | string | boolean) => { + if (!renderMasks[index]) { + return; + } + + const updatedMasks = renderMasks.map((existingMask, maskIndex) => + maskIndex !== index + ? existingMask + : { + ...existingMask, + params: { + ...existingMask.params, + [key]: value, + }, + }, + ); + + previewUpdates({ masks: updatedMasks } as Partial<MaskableElement>); + }; + + return ( + <div className="flex flex-col h-full"> + <div className="border-b px-3.5 h-11 shrink-0 flex items-center justify-between gap-2"> + <SectionTitle>Masks</SectionTitle> + <DropdownMenu + open={hasMask ? false : isDropdownOpen} + onOpenChange={handleDropdownOpenChange} + > + {hasMask ? ( + <Tooltip> + <TooltipTrigger asChild> + <span className="inline-flex"> + <Button + variant="ghost" + size="icon" + disabled + aria-label="Add mask" + > + <HugeiconsIcon icon={PlusSignIcon} className="size-3.5!" /> + </Button> + </span> + </TooltipTrigger> + <TooltipContent className="max-w-56 text-balance"> + Only one mask is supported right now. If you need more, + duplicate the clip and apply a different mask to each copy. + </TooltipContent> + </Tooltip> + ) : ( + <DropdownMenuTrigger asChild> + <Button variant="ghost" size="icon" aria-label="Add mask"> + <HugeiconsIcon icon={PlusSignIcon} className="size-3.5!" /> + </Button> + </DropdownMenuTrigger> + )} + <DropdownMenuContent className="w-40" onPointerLeave={onPointerLeave}> + {maskDefs.map((definition) => ( + <DropdownMenuItem + key={definition.type} + onPointerEnter={() => + previewMask({ maskType: definition.type }) + } + onClick={() => commitMask({ maskType: definition.type })} + > + <HugeiconsIcon {...definition.icon} /> + {definition.name} + </DropdownMenuItem> + ))} + </DropdownMenuContent> + </DropdownMenu> + </div> + + {masks.length === 0 ? ( + <EmptyView onAddMask={() => setIsDropdownOpen(true)} /> + ) : ( + masks.map((mask, index) => ( + <MaskItem + key={mask.id} + trackId={trackId} + elementId={element.id} + mask={renderMasks[index] ?? mask} + previewParam={(paramKey) => + previewMaskParam({ index, key: paramKey }) + } + onCommit={commit} + /> + )) + )} + </div> + ); +} + +function MaskItem({ + trackId, + elementId, + mask, + previewParam, + onCommit, +}: MaskItemProps) { + const editor = useEditor(); + const definition = masksRegistry.get(mask.type); + + return ( + <Section sectionKey={`mask-item:${mask.id}`} showTopBorder={false}> + <SectionHeader + trailing={ + <div className="flex items-center gap-1"> + <Button + variant="ghost" + size="icon" + aria-label={`Toggle ${definition.name} mask inversion`} + onClick={() => + editor.timeline.toggleMaskInverted({ + trackId, + elementId, + maskId: mask.id, + }) + } + > + <OcMirrorIcon + className={cn(mask.params.inverted && "-scale-x-100")} + /> + </Button> + <Button + variant="ghost" + size="icon" + aria-label={`Remove ${definition.name} mask`} + onClick={() => + editor.timeline.removeMask({ + trackId, + elementId, + maskId: mask.id, + }) + } + > + <HugeiconsIcon icon={Delete02Icon} /> + </Button> + </div> + } + > + <div className="flex items-center gap-2"> + <HugeiconsIcon {...definition.icon} size={14} /> + <SectionTitle className="capitalize font-normal"> + {definition.name} + </SectionTitle> + </div> + </SectionHeader> + <SectionContent> + <MaskParamsFields + mask={mask} + definition={definition} + previewParam={previewParam} + onCommit={onCommit} + /> + </SectionContent> + </Section> + ); +} + +function MaskParamsFields({ + mask, + definition, + previewParam, + onCommit, +}: { + mask: Mask; + definition: RegisteredMaskDefinition; + previewParam: PreviewParamHandler; + onCommit: () => void; +}) { + const featherParam = getNumberParamDefinition({ + definition, + key: "feather", + }); + const strokeWidthParam = getNumberParamDefinition({ + definition, + key: "strokeWidth", + }); + const previewNumberParam = (key: string) => (value: number) => + previewParam(key)(value); + const previewStrokeColor = previewParam("strokeColor"); + const strokeAlignParam = definition.params.find( + (param): param is SelectParamDefinition<string> => + param.key === "strokeAlign" && param.type === "select", + ); + + return ( + <SectionFields> + {isTextMask(mask) ? ( + <TextMaskFields + mask={mask} + previewParam={previewParam} + onCommit={onCommit} + fontSizeParam={getNumberParamDefinition({ + definition, + key: "fontSize", + })} + /> + ) : null} + {definition.features.hasPosition && + "centerX" in mask.params && + "centerY" in mask.params && ( + <SectionField label="Position"> + <div className="flex items-center gap-2"> + <MaskNumberField + className="flex-1" + icon="X" + param={getNumberParamDefinition({ + definition, + key: "centerX", + })} + value={getMaskNumber({ + params: mask.params, + key: "centerX", + })} + onPreview={previewNumberParam("centerX")} + onCommit={onCommit} + /> + <MaskNumberField + className="flex-1" + icon="Y" + param={getNumberParamDefinition({ + definition, + key: "centerY", + })} + value={getMaskNumber({ + params: mask.params, + key: "centerY", + })} + onPreview={previewNumberParam("centerY")} + onCommit={onCommit} + /> + </div> + </SectionField> + )} + + {definition.features.sizeMode === "width-height" && + "width" in mask.params && + "height" in mask.params && ( + <SectionField label="Size"> + <div className="flex items-center gap-2"> + <MaskNumberField + className="flex-1" + icon="W" + param={getNumberParamDefinition({ + definition, + key: "width", + })} + value={getMaskNumber({ + params: mask.params, + key: "width", + })} + onPreview={previewNumberParam("width")} + onCommit={onCommit} + /> + <MaskNumberField + className="flex-1" + icon="H" + param={getNumberParamDefinition({ + definition, + key: "height", + })} + value={getMaskNumber({ + params: mask.params, + key: "height", + })} + onPreview={previewNumberParam("height")} + onCommit={onCommit} + /> + </div> + </SectionField> + )} + + {definition.features.sizeMode === "height-only" && + "height" in mask.params && ( + <SectionField label="Height"> + <MaskNumberField + icon="H" + param={getNumberParamDefinition({ + definition, + key: "height", + })} + value={getMaskNumber({ + params: mask.params, + key: "height", + })} + onPreview={previewNumberParam("height")} + onCommit={onCommit} + /> + </SectionField> + )} + + {definition.features.sizeMode === "width-only" && + "width" in mask.params && ( + <SectionField label="Width"> + <MaskNumberField + icon="W" + param={getNumberParamDefinition({ + definition, + key: "width", + })} + value={getMaskNumber({ + params: mask.params, + key: "width", + })} + onPreview={previewNumberParam("width")} + onCommit={onCommit} + /> + </SectionField> + )} + + {definition.features.sizeMode === "uniform" && "scale" in mask.params && ( + <SectionField label="Scale"> + <MaskNumberField + icon={ + isTextMask(mask) ? <HugeiconsIcon icon={ArrowExpandIcon} /> : "S" + } + param={getNumberParamDefinition({ + definition, + key: "scale", + })} + value={getMaskNumber({ + params: mask.params, + key: "scale", + })} + onPreview={previewNumberParam("scale")} + onCommit={onCommit} + /> + </SectionField> + )} + + {definition.features.hasRotation && "rotation" in mask.params && ( + <SectionField label="Rotation"> + <MaskNumberField + icon={<HugeiconsIcon icon={RotateClockwiseIcon} />} + param={getNumberParamDefinition({ + definition, + key: "rotation", + })} + value={getMaskNumber({ + params: mask.params, + key: "rotation", + })} + onPreview={previewNumberParam("rotation")} + onCommit={onCommit} + /> + </SectionField> + )} + + <SectionField label="Feather"> + <MaskNumberField + icon={<HugeiconsIcon icon={FeatherIcon} />} + param={featherParam} + value={getMaskNumber({ + params: mask.params, + key: "feather", + })} + onPreview={previewNumberParam("feather")} + onCommit={onCommit} + /> + </SectionField> + + <SectionField label="Stroke"> + <div className="flex flex-col gap-2"> + <div className="flex items-center gap-2"> + <MaskNumberField + className="flex-1" + icon="W" + param={strokeWidthParam} + value={getMaskNumber({ + params: mask.params, + key: "strokeWidth", + })} + onPreview={previewNumberParam("strokeWidth")} + onCommit={onCommit} + /> + <ColorPicker + className="" + value={mask.params.strokeColor.replace(/^#/, "").toUpperCase()} + onChange={(color) => previewStrokeColor(`#${color}`)} + onChangeEnd={(color) => { + previewStrokeColor(`#${color}`); + onCommit(); + }} + /> + </div> + {strokeAlignParam ? ( + <Select + value={mask.params.strokeAlign} + onValueChange={(value) => { + previewParam("strokeAlign")(value); + onCommit(); + }} + > + <SelectTrigger className="w-full"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + {strokeAlignParam.options.map((option) => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + ) : null} + </div> + </SectionField> + </SectionFields> + ); +} + +const LETTER_SPACING_PARAM: NumberParamDefinition = { + key: "letterSpacing", + label: "Letter spacing", + type: "number", + default: 0, + min: -100, + max: 500, + step: 1, +}; + +const LINE_HEIGHT_PARAM: NumberParamDefinition = { + key: "lineHeight", + label: "Line height", + type: "number", + default: 1.2, + min: 0.1, + max: 10, + step: 0.1, +}; + +function TextMaskFields({ + mask, + previewParam, + onCommit, + fontSizeParam, +}: { + mask: TextMask; + previewParam: PreviewParamHandler; + onCommit: () => void; + fontSizeParam: NumberParamDefinition; +}) { + const content = usePropertyDraft({ + displayValue: mask.params.content, + parse: (input) => input, + onPreview: (value) => previewParam("content")(value), + onCommit, + }); + + const previewNumberParam = (key: string) => (value: number) => + previewParam(key)(value); + + return ( + <> + <SectionField label="Content"> + <Textarea + value={content.displayValue} + className="min-h-20" + onFocus={content.onFocus} + onChange={content.onChange} + onBlur={content.onBlur} + /> + </SectionField> + <SectionField label="Font"> + <FontPicker + defaultValue={mask.params.fontFamily} + onValueChange={(value) => { + previewParam("fontFamily")(value); + onCommit(); + }} + /> + </SectionField> + <SectionField label="Size"> + <MaskNumberField + icon={<HugeiconsIcon icon={TextFontIcon} />} + param={fontSizeParam} + value={mask.params.fontSize} + onPreview={previewNumberParam("fontSize")} + onCommit={onCommit} + /> + </SectionField> + <SectionField label="Spacing"> + <div className="flex items-start gap-2"> + <MaskNumberField + className="w-1/2" + icon={<OcTextWidthIcon size={14} />} + param={LETTER_SPACING_PARAM} + value={mask.params.letterSpacing ?? 0} + onPreview={previewNumberParam("letterSpacing")} + onCommit={onCommit} + /> + <MaskNumberField + className="w-1/2" + icon={<OcTextHeightIcon size={14} />} + param={LINE_HEIGHT_PARAM} + value={mask.params.lineHeight ?? 1.2} + onPreview={previewNumberParam("lineHeight")} + onCommit={onCommit} + /> + </div> + </SectionField> + </> + ); +} + +function getNumberParamDefinition({ + definition, + key, +}: { + definition: RegisteredMaskDefinition; + key: string; +}): NumberParamDefinition { + const param = definition.params.find((candidate) => candidate.key === key); + + if (!param || param.type !== "number") { + throw new Error(`Missing number param definition for mask key "${key}"`); + } + + return param; +} + +function getMaskNumber< + TParams extends Mask["params"], + TKey extends keyof TParams & string, +>({ params, key }: { params: TParams; key: TKey }): number { + const value = params[key]; + + if (typeof value !== "number") { + throw new Error(`Expected numeric mask param for "${key}"`); + } + + return value; +} + +function MaskNumberField({ + param, + value, + onPreview, + onCommit, + icon, + className, +}: { + param: NumberParamDefinition; + value: number; + onPreview: (value: number) => void; + onCommit: () => void; + icon?: React.ReactNode; + className?: string; +}) { + const isPercent = param.unit === "percent"; + const percentMax = param.max ?? 100; + const displayMultiplier = isPercent + ? 100 / percentMax + : (param.displayMultiplier ?? 1); + const min = isPercent ? 0 : param.min; + const max = isPercent ? 100 : param.max; + const step = isPercent ? 1 : param.step; + const displayValue = value * displayMultiplier; + const maxFractionDigits = getFractionDigitsForStep({ step }); + + const clampDisplay = (nextDisplayValue: number) => + max !== undefined + ? clamp({ value: nextDisplayValue, min, max }) + : Math.max(min, nextDisplayValue); + + const previewFromDisplay = (nextDisplayValue: number) => { + onPreview( + clampDisplay(snapToStep({ value: nextDisplayValue, step })) / + displayMultiplier, + ); + }; + + const draft = usePropertyDraft({ + displayValue: formatNumberForDisplay({ + value: displayValue, + maxFractionDigits, + }), + parse: (input) => { + const parsed = parseFloat(input); + if (Number.isNaN(parsed)) return null; + return ( + clampDisplay(snapToStep({ value: parsed, step })) / displayMultiplier + ); + }, + onPreview, + onCommit, + }); + + return ( + <NumberField + className={className} + icon={icon} + value={draft.displayValue} + dragSensitivity="slow" + onFocus={draft.onFocus} + onChange={draft.onChange} + onBlur={draft.onBlur} + onScrub={previewFromDisplay} + onScrubEnd={onCommit} + /> + ); +} + +function EmptyView({ onAddMask }: EmptyViewProps) { + return ( + <div className="flex flex-col h-full items-center justify-center gap-4 text-center"> + <OcShapesIcon className="size-10 text-muted-foreground" strokeWidth={1} /> + <div className="flex flex-col gap-2"> + <h3 className="font-medium text-foreground">No masks</h3> + <p className="text-muted-foreground text-sm text-balance max-w-40"> + Add a mask to hide or reveal parts of this layer. + </p> + </div> + <Button variant="default" size="sm" onClick={onAddMask}> + Add mask + </Button> + </div> + ); +} diff --git a/apps/web/src/masks/custom-path.ts b/apps/web/src/masks/custom-path.ts new file mode 100644 index 00000000..0349df63 --- /dev/null +++ b/apps/web/src/masks/custom-path.ts @@ -0,0 +1,842 @@ +import type { ElementBounds } from "@/preview/element-bounds"; + +export interface CustomMaskPathPoint { + id: string; + x: number; + y: number; + inX: number; + inY: number; + outX: number; + outY: number; +} + +export type CustomMaskHandlePart = "anchor" | "in" | "out"; + +function isCustomMaskPathPoint(value: unknown): value is CustomMaskPathPoint { + if (!value || typeof value !== "object") { + return false; + } + + const candidate = value as Record<string, unknown>; + return ( + typeof candidate.id === "string" && + typeof candidate.x === "number" && + typeof candidate.y === "number" && + typeof candidate.inX === "number" && + typeof candidate.inY === "number" && + typeof candidate.outX === "number" && + typeof candidate.outY === "number" + ); +} + +export function parseCustomMaskHandleId({ + handleId, +}: { + handleId: string; +}): { pointId: string; part: CustomMaskHandlePart } | null { + const match = /^point:(.+):(anchor|in|out)$/.exec(handleId); + if (!match) { + return null; + } + + return { + pointId: match[1], + part: match[2] as CustomMaskHandlePart, + }; +} + +export function parseCustomMaskSegmentHandleId({ + handleId, +}: { + handleId: string; +}): { segmentIndex: number } | null { + const match = /^segment:(\d+)$/.exec(handleId); + if (!match) { + return null; + } + + return { + segmentIndex: Number.parseInt(match[1], 10), + }; +} + +export function parseCustomMaskPath({ + path, +}: { + path: string; +}): CustomMaskPathPoint[] { + if (!path) { + return []; + } + + try { + const parsed = JSON.parse(path); + return Array.isArray(parsed) ? parsed.filter(isCustomMaskPathPoint) : []; + } catch { + return []; + } +} + +export function serializeCustomMaskPath({ + points, +}: { + points: CustomMaskPathPoint[]; +}): string { + return JSON.stringify(points); +} + +export function removeCustomMaskPoints({ + points, + pointIds, +}: { + points: CustomMaskPathPoint[]; + pointIds: string[]; +}): CustomMaskPathPoint[] { + if (pointIds.length === 0) { + return points; + } + const pointIdsToRemove = new Set(pointIds); + return points.filter((point) => !pointIdsToRemove.has(point.id)); +} + +export function getCustomMaskClosedStateAfterPointRemoval({ + wasClosed, + remainingPointCount, +}: { + wasClosed: boolean; + remainingPointCount: number; +}): boolean { + return wasClosed && remainingPointCount >= 3; +} + +function rotatePoint({ + x, + y, + rotationDegrees, +}: { + x: number; + y: number; + rotationDegrees: number; +}): { x: number; y: number } { + const angleRad = (rotationDegrees * Math.PI) / 180; + const cos = Math.cos(angleRad); + const sin = Math.sin(angleRad); + return { + x: x * cos - y * sin, + y: x * sin + y * cos, + }; +} + +export function getCustomMaskCenterCanvasPoint({ + centerX, + centerY, + bounds, +}: { + centerX: number; + centerY: number; + bounds: ElementBounds; +}): { x: number; y: number } { + return { + x: bounds.cx + centerX * bounds.width, + y: bounds.cy + centerY * bounds.height, + }; +} + +export function customMaskLocalPointToCanvas({ + point, + centerX, + centerY, + rotation, + scale, + bounds, +}: { + point: { x: number; y: number }; + centerX: number; + centerY: number; + rotation: number; + scale: number; + bounds: ElementBounds; +}): { x: number; y: number } { + const center = getCustomMaskCenterCanvasPoint({ centerX, centerY, bounds }); + const scaledLocal = { + x: point.x * bounds.width * scale, + y: point.y * bounds.height * scale, + }; + const rotated = rotatePoint({ + x: scaledLocal.x, + y: scaledLocal.y, + rotationDegrees: rotation, + }); + + return { + x: center.x + rotated.x, + y: center.y + rotated.y, + }; +} + +export function customMaskCanvasPointToLocal({ + point, + centerX, + centerY, + rotation, + scale, + bounds, +}: { + point: { x: number; y: number }; + centerX: number; + centerY: number; + rotation: number; + scale: number; + bounds: ElementBounds; +}): { x: number; y: number } { + const center = getCustomMaskCenterCanvasPoint({ centerX, centerY, bounds }); + const translated = { + x: point.x - center.x, + y: point.y - center.y, + }; + const rotated = rotatePoint({ + x: translated.x, + y: translated.y, + rotationDegrees: -rotation, + }); + + return { + x: bounds.width === 0 ? 0 : rotated.x / (bounds.width * scale), + y: bounds.height === 0 ? 0 : rotated.y / (bounds.height * scale), + }; +} + +export function getCustomMaskCanvasGeometry({ + points, + centerX, + centerY, + rotation, + scale, + bounds, +}: { + points: CustomMaskPathPoint[]; + centerX: number; + centerY: number; + rotation: number; + scale: number; + bounds: ElementBounds; +}) { + const anchors = points.map((point) => ({ + id: point.id, + anchor: customMaskLocalPointToCanvas({ + point: { x: point.x, y: point.y }, + centerX, + centerY, + rotation, + scale, + bounds, + }), + inHandle: customMaskLocalPointToCanvas({ + point: { x: point.x + point.inX, y: point.y + point.inY }, + centerX, + centerY, + rotation, + scale, + bounds, + }), + outHandle: customMaskLocalPointToCanvas({ + point: { x: point.x + point.outX, y: point.y + point.outY }, + centerX, + centerY, + rotation, + scale, + bounds, + }), + })); + + const geometryPoints = anchors.flatMap((point) => [ + point.anchor, + point.inHandle, + point.outHandle, + ]); + if (geometryPoints.length === 0) { + return { + anchors, + bounds: null, + }; + } + + const geometryBounds = getCanvasPointBounds({ + points: geometryPoints, + }); + + return { + anchors, + bounds: geometryBounds, + }; +} + +export interface CanvasPoint { + x: number; + y: number; +} + +function getCanvasPointBounds({ points }: { points: CanvasPoint[] }): { + minX: number; + maxX: number; + minY: number; + maxY: number; + width: number; + height: number; + centerX: number; + centerY: number; +} | null { + if (points.length === 0) { + return null; + } + + const [firstPoint, ...restPoints] = points; + let minX = firstPoint.x; + let maxX = firstPoint.x; + let minY = firstPoint.y; + let maxY = firstPoint.y; + + for (const point of restPoints) { + minX = Math.min(minX, point.x); + maxX = Math.max(maxX, point.x); + minY = Math.min(minY, point.y); + maxY = Math.max(maxY, point.y); + } + + return { + minX, + maxX, + minY, + maxY, + width: Math.max(1, maxX - minX), + height: Math.max(1, maxY - minY), + centerX: (minX + maxX) / 2, + centerY: (minY + maxY) / 2, + }; +} + +export interface CustomMaskCanvasSegment { + index: number; + startPointId: string; + endPointId: string; + start: CanvasPoint; + startOut: CanvasPoint; + endIn: CanvasPoint; + end: CanvasPoint; + pathData: string; +} + +function clampUnit(value: number): number { + return Math.min(1, Math.max(0, value)); +} + +function getDistanceSquared(a: CanvasPoint, b: CanvasPoint): number { + const dx = a.x - b.x; + const dy = a.y - b.y; + return dx * dx + dy * dy; +} + +function lerpPoint(a: CanvasPoint, b: CanvasPoint, t: number): CanvasPoint { + return { + x: a.x + (b.x - a.x) * t, + y: a.y + (b.y - a.y) * t, + }; +} + +function evaluateCubicBezier({ + p0, + p1, + p2, + p3, + t, +}: { + p0: CanvasPoint; + p1: CanvasPoint; + p2: CanvasPoint; + p3: CanvasPoint; + t: number; +}): CanvasPoint { + const oneMinusT = 1 - t; + return { + x: + oneMinusT ** 3 * p0.x + + 3 * oneMinusT ** 2 * t * p1.x + + 3 * oneMinusT * t ** 2 * p2.x + + t ** 3 * p3.x, + y: + oneMinusT ** 3 * p0.y + + 3 * oneMinusT ** 2 * t * p1.y + + 3 * oneMinusT * t ** 2 * p2.y + + t ** 3 * p3.y, + }; +} + +function getCustomMaskSegmentIndices({ + points, + segmentIndex, + closed, +}: { + points: CustomMaskPathPoint[]; + segmentIndex: number; + closed: boolean; +}): { startIndex: number; endIndex: number } | null { + const segmentCount = getCustomMaskSegmentCount({ points, closed }); + if (segmentIndex < 0 || segmentIndex >= segmentCount) { + return null; + } + + return { + startIndex: segmentIndex, + endIndex: (segmentIndex + 1) % points.length, + }; +} + +export function getCustomMaskSegmentCount({ + points, + closed, +}: { + points: CustomMaskPathPoint[]; + closed: boolean; +}): number { + if (points.length < 2) { + return 0; + } + return closed ? points.length : points.length - 1; +} + +export function getCustomMaskCanvasSegments({ + points, + centerX, + centerY, + rotation, + scale, + bounds, + closed, +}: { + points: CustomMaskPathPoint[]; + centerX: number; + centerY: number; + rotation: number; + scale: number; + bounds: ElementBounds; + closed: boolean; +}): CustomMaskCanvasSegment[] { + const geometry = getCustomMaskCanvasGeometry({ + points, + centerX, + centerY, + rotation, + scale, + bounds, + }); + const segmentCount = getCustomMaskSegmentCount({ points, closed }); + + return Array.from({ length: segmentCount }, (_, segmentIndex) => { + const start = geometry.anchors[segmentIndex]; + const end = geometry.anchors[(segmentIndex + 1) % geometry.anchors.length]; + return { + index: segmentIndex, + startPointId: start.id, + endPointId: end.id, + start: start.anchor, + startOut: start.outHandle, + endIn: end.inHandle, + end: end.anchor, + pathData: `M ${start.anchor.x},${start.anchor.y} C ${start.outHandle.x},${start.outHandle.y} ${end.inHandle.x},${end.inHandle.y} ${end.anchor.x},${end.anchor.y}`, + }; + }); +} + +export function findClosestPointOnCustomMaskSegment({ + points, + segmentIndex, + canvasPoint, + centerX, + centerY, + rotation, + scale, + bounds, + closed, +}: { + points: CustomMaskPathPoint[]; + segmentIndex: number; + canvasPoint: CanvasPoint; + centerX: number; + centerY: number; + rotation: number; + scale: number; + bounds: ElementBounds; + closed: boolean; +}): { t: number; point: CanvasPoint } | null { + const segment = getCustomMaskCanvasSegments({ + points, + centerX, + centerY, + rotation, + scale, + bounds, + closed, + }).find((candidate) => candidate.index === segmentIndex); + if (!segment) { + return null; + } + + const sampleCount = 24; + let bestT = 0; + let bestDistanceSquared = getDistanceSquared(canvasPoint, segment.start); + + for (let step = 0; step <= sampleCount; step++) { + const t = step / sampleCount; + const point = evaluateCubicBezier({ + p0: segment.start, + p1: segment.startOut, + p2: segment.endIn, + p3: segment.end, + t, + }); + const distanceSquared = getDistanceSquared(canvasPoint, point); + if (distanceSquared < bestDistanceSquared) { + bestDistanceSquared = distanceSquared; + bestT = t; + } + } + + let searchStep = 1 / sampleCount; + for (let iteration = 0; iteration < 8; iteration++) { + const candidates = [bestT - searchStep, bestT, bestT + searchStep] + .map(clampUnit) + .map((t) => ({ + t, + point: evaluateCubicBezier({ + p0: segment.start, + p1: segment.startOut, + p2: segment.endIn, + p3: segment.end, + t, + }), + })); + for (const candidate of candidates) { + const distanceSquared = getDistanceSquared(canvasPoint, candidate.point); + if (distanceSquared < bestDistanceSquared) { + bestDistanceSquared = distanceSquared; + bestT = candidate.t; + } + } + searchStep /= 2; + } + + const clampedT = Math.min(0.999, Math.max(0.001, bestT)); + return { + t: clampedT, + point: evaluateCubicBezier({ + p0: segment.start, + p1: segment.startOut, + p2: segment.endIn, + p3: segment.end, + t: clampedT, + }), + }; +} + +export function insertPointIntoCustomMaskSegment({ + points, + segmentIndex, + pointId, + t, + closed, +}: { + points: CustomMaskPathPoint[]; + segmentIndex: number; + pointId: string; + t: number; + closed: boolean; +}): CustomMaskPathPoint[] { + const indices = getCustomMaskSegmentIndices({ + points, + segmentIndex, + closed, + }); + if (!indices) { + return points; + } + + const startPoint = points[indices.startIndex]; + const endPoint = points[indices.endIndex]; + const clampedT = Math.min(0.999, Math.max(0.001, t)); + const p0 = { x: startPoint.x, y: startPoint.y }; + const p1 = { + x: startPoint.x + startPoint.outX, + y: startPoint.y + startPoint.outY, + }; + const p2 = { + x: endPoint.x + endPoint.inX, + y: endPoint.y + endPoint.inY, + }; + const p3 = { x: endPoint.x, y: endPoint.y }; + const p01 = lerpPoint(p0, p1, clampedT); + const p12 = lerpPoint(p1, p2, clampedT); + const p23 = lerpPoint(p2, p3, clampedT); + const p012 = lerpPoint(p01, p12, clampedT); + const p123 = lerpPoint(p12, p23, clampedT); + const splitPoint = lerpPoint(p012, p123, clampedT); + + const nextPoints = [...points]; + nextPoints[indices.startIndex] = { + ...startPoint, + outX: p01.x - startPoint.x, + outY: p01.y - startPoint.y, + }; + nextPoints[indices.endIndex] = { + ...endPoint, + inX: p23.x - endPoint.x, + inY: p23.y - endPoint.y, + }; + nextPoints.splice(indices.endIndex, 0, { + id: pointId, + x: splitPoint.x, + y: splitPoint.y, + inX: p012.x - splitPoint.x, + inY: p012.y - splitPoint.y, + outX: p123.x - splitPoint.x, + outY: p123.y - splitPoint.y, + }); + return nextPoints; +} + +export function getCustomMaskLocalBounds({ + points, + bounds, +}: { + points: CustomMaskPathPoint[]; + bounds: ElementBounds; +}) { + if (points.length === 0) { + return null; + } + + const values = points.flatMap((point) => [ + { x: point.x * bounds.width, y: point.y * bounds.height }, + { + x: (point.x + point.inX) * bounds.width, + y: (point.y + point.inY) * bounds.height, + }, + { + x: (point.x + point.outX) * bounds.width, + y: (point.y + point.outY) * bounds.height, + }, + ]); + + const localBounds = getCanvasPointBounds({ + points: values, + }); + if (!localBounds) { + return null; + } + + return { + width: localBounds.width, + height: localBounds.height, + }; +} + +export function recenterCustomMaskPath({ + points, + centerX, + centerY, + rotation, + scale, + bounds, +}: { + points: CustomMaskPathPoint[]; + centerX: number; + centerY: number; + rotation: number; + scale: number; + bounds: ElementBounds; +}) { + if (points.length === 0) { + return { centerX, centerY, points }; + } + + const geometry = getCustomMaskCanvasGeometry({ + points, + centerX, + centerY, + rotation, + scale, + bounds, + }); + if (!geometry.bounds) { + return { centerX, centerY, points }; + } + + const nextCenterCanvas = { + x: geometry.bounds.centerX, + y: geometry.bounds.centerY, + }; + const nextCenterLocal = { + x: bounds.width === 0 ? 0 : (nextCenterCanvas.x - bounds.cx) / bounds.width, + y: + bounds.height === 0 + ? 0 + : (nextCenterCanvas.y - bounds.cy) / bounds.height, + }; + + const nextPoints = geometry.anchors.map((point) => { + const anchor = customMaskCanvasPointToLocal({ + point: point.anchor, + centerX: nextCenterLocal.x, + centerY: nextCenterLocal.y, + rotation, + scale, + bounds, + }); + const inHandle = customMaskCanvasPointToLocal({ + point: point.inHandle, + centerX: nextCenterLocal.x, + centerY: nextCenterLocal.y, + rotation, + scale, + bounds, + }); + const outHandle = customMaskCanvasPointToLocal({ + point: point.outHandle, + centerX: nextCenterLocal.x, + centerY: nextCenterLocal.y, + rotation, + scale, + bounds, + }); + + return { + id: point.id, + x: anchor.x, + y: anchor.y, + inX: inHandle.x - anchor.x, + inY: inHandle.y - anchor.y, + outX: outHandle.x - anchor.x, + outY: outHandle.y - anchor.y, + }; + }); + + return { + centerX: nextCenterLocal.x, + centerY: nextCenterLocal.y, + points: nextPoints, + }; +} + +export function buildCustomMaskPath2D({ + points, + centerX, + centerY, + rotation, + scale, + bounds, + closed, +}: { + points: CustomMaskPathPoint[]; + centerX: number; + centerY: number; + rotation: number; + scale: number; + bounds: ElementBounds; + closed: boolean; +}): Path2D { + const path = new Path2D(); + if (points.length === 0) { + return path; + } + + const geometry = getCustomMaskCanvasGeometry({ + points, + centerX, + centerY, + rotation, + scale, + bounds, + }); + const anchors = geometry.anchors; + path.moveTo(anchors[0].anchor.x, anchors[0].anchor.y); + + for (let index = 1; index < anchors.length; index++) { + const previous = anchors[index - 1]; + const current = anchors[index]; + path.bezierCurveTo( + previous.outHandle.x, + previous.outHandle.y, + current.inHandle.x, + current.inHandle.y, + current.anchor.x, + current.anchor.y, + ); + } + + if (closed && anchors.length > 1) { + const last = anchors[anchors.length - 1]; + const first = anchors[0]; + path.bezierCurveTo( + last.outHandle.x, + last.outHandle.y, + first.inHandle.x, + first.inHandle.y, + first.anchor.x, + first.anchor.y, + ); + path.closePath(); + } + + return path; +} + +export function buildCustomMaskSvgPath({ + points, + centerX, + centerY, + rotation, + scale, + bounds, + closed, +}: { + points: CustomMaskPathPoint[]; + centerX: number; + centerY: number; + rotation: number; + scale: number; + bounds: ElementBounds; + closed: boolean; +}): string { + if (points.length === 0) { + return ""; + } + + const geometry = getCustomMaskCanvasGeometry({ + points, + centerX, + centerY, + rotation, + scale, + bounds, + }); + const anchors = geometry.anchors; + const segments = [`M ${anchors[0].anchor.x},${anchors[0].anchor.y}`]; + + for (let index = 1; index < anchors.length; index++) { + const previous = anchors[index - 1]; + const current = anchors[index]; + segments.push( + `C ${previous.outHandle.x},${previous.outHandle.y} ${current.inHandle.x},${current.inHandle.y} ${current.anchor.x},${current.anchor.y}`, + ); + } + + if (closed && anchors.length > 1) { + const last = anchors[anchors.length - 1]; + const first = anchors[0]; + segments.push( + `C ${last.outHandle.x},${last.outHandle.y} ${first.inHandle.x},${first.inHandle.y} ${first.anchor.x},${first.anchor.y}`, + ); + segments.push("Z"); + } + + return segments.join(" "); +} 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, + }; +} diff --git a/apps/web/src/masks/definitions/cinematic-bars.ts b/apps/web/src/masks/definitions/cinematic-bars.ts new file mode 100644 index 00000000..36b31328 --- /dev/null +++ b/apps/web/src/masks/definitions/cinematic-bars.ts @@ -0,0 +1,135 @@ +import type { + MaskDefaultContext, + MaskDefinition, + RectangleMaskParams, +} from "@/masks/types"; +import { + BOX_LIKE_MASK_PARAMS, + buildBoxMaskInteraction, + computeBoxMaskParamUpdate, + getDefaultBaseMaskParams, + getStrokeOffset, + rotatePoint, +} from "./box-like"; + +function getDefaultCinematicBarsMaskParams({ + elementSize, +}: MaskDefaultContext): RectangleMaskParams { + const absWidth = Math.abs(elementSize?.width ?? 0); + const absHeight = Math.abs(elementSize?.height ?? 0); + const diagonal = + absWidth > 0 && absHeight > 0 + ? Math.sqrt(absWidth ** 2 + absHeight ** 2) + : 0; + const fullSpanWidth = + absWidth > 0 ? diagonal / absWidth : Math.SQRT2; + + return { + ...getDefaultBaseMaskParams(), + centerX: 0, + centerY: 0, + width: Math.max(fullSpanWidth, 1), + height: 0.6, + rotation: 0, + scale: 1, + }; +} + +function buildBandPath({ + centerX, + centerY, + halfWidth, + halfHeight, + rotationRad, +}: { + centerX: number; + centerY: number; + halfWidth: number; + halfHeight: number; + rotationRad: number; +}): Path2D { + const corners = [ + { x: centerX - halfWidth, y: centerY - halfHeight }, + { x: centerX + halfWidth, y: centerY - halfHeight }, + { x: centerX + halfWidth, y: centerY + halfHeight }, + { x: centerX - halfWidth, y: centerY + halfHeight }, + ].map((point) => + rotatePoint({ + ...point, + centerX, + centerY, + rotationRad, + }), + ); + + const path = new Path2D(); + path.moveTo(corners[0].x, corners[0].y); + for (const corner of corners.slice(1)) { + path.lineTo(corner.x, corner.y); + } + path.closePath(); + return path; +} + +export const cinematicBarsMaskDefinition: MaskDefinition<RectangleMaskParams> = { + type: "cinematic-bars", + name: "Cinematic Bars", + features: { + hasPosition: true, + hasRotation: true, + sizeMode: "height-only", + }, + params: BOX_LIKE_MASK_PARAMS, + interaction: buildBoxMaskInteraction({ + sizeMode: "height-only", + buildOverlayPath({ width, height }) { + return `M 0,0 H ${width} V ${height} H 0 Z`; + }, + }), + buildDefault(context) { + return { + type: "cinematic-bars", + params: getDefaultCinematicBarsMaskParams(context), + }; + }, + 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; + + 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, + }); + + 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 new file mode 100644 index 00000000..cd6e2055 --- /dev/null +++ b/apps/web/src/masks/definitions/custom.ts @@ -0,0 +1,652 @@ +import { generateUUID } from "@/utils/id"; +import type { ParamDefinition } from "@/params"; +import { PEN_CURSOR } from "@/preview/components/cursors"; +import type { ElementBounds } from "@/preview/element-bounds"; +import type { + CustomMask, + CustomMaskParams, + MaskDefinition, + MaskHandlePosition, + MaskOverlay, + MaskParamUpdateArgs, +} from "@/masks/types"; +import { + buildCustomMaskPath2D, + buildCustomMaskSvgPath, + customMaskCanvasPointToLocal, + findClosestPointOnCustomMaskSegment, + getCustomMaskCanvasSegments, + getCustomMaskCanvasGeometry, + getCustomMaskLocalBounds, + getCustomMaskSegmentCount, + insertPointIntoCustomMaskSegment, + parseCustomMaskHandleId, + recenterCustomMaskPath, + type CustomMaskPathPoint, +} from "@/masks/custom-path"; +import { getBoxMaskHandlePositions } from "@/masks/handle-positions"; +import { computeFeatherUpdate } from "@/masks/param-update"; +import { + setMaskLocalCenter, + toGlobalMaskSnapLines, +} from "@/masks/geometry"; +import { + snapPosition, + snapRotation, + snapScale, +} from "@/preview/preview-snap"; + +const PERCENTAGE_DISPLAY = { + displayMultiplier: 100, + step: 1, +} as const; + +const CUSTOM_MASK_PARAMS: ParamDefinition<keyof CustomMaskParams & 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: "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, + }, +]; + +function getCustomMaskDisplayHandles({ + params, + displayScale, + bounds, +}: { + params: CustomMaskParams; + displayScale: number; + bounds: ElementBounds; +}): { + handles: MaskHandlePosition[]; + overlays: MaskOverlay[]; +} { + const points = params.path; + const geometry = getCustomMaskCanvasGeometry({ + points, + centerX: params.centerX, + centerY: params.centerY, + rotation: params.rotation, + scale: params.scale, + bounds, + }); + + const handles: MaskHandlePosition[] = []; + const overlays: MaskOverlay[] = []; + + if (points.length > 0) { + overlays.push({ + id: "path", + type: "canvas-path", + pathData: buildCustomMaskSvgPath({ + points, + centerX: params.centerX, + centerY: params.centerY, + rotation: params.rotation, + scale: params.scale, + bounds, + closed: params.closed, + }), + coordinateSpace: "canvas", + }); + } + + if (params.closed) { + const segmentStrokeWidth = 12; + overlays.push( + ...getCustomMaskCanvasSegments({ + points, + centerX: params.centerX, + centerY: params.centerY, + rotation: params.rotation, + scale: params.scale, + bounds, + closed: true, + }).map((segment) => ({ + id: `segment:${segment.index}`, + type: "canvas-path" as const, + pathData: segment.pathData, + coordinateSpace: "canvas" as const, + handleId: `segment:${segment.index}`, + cursor: PEN_CURSOR, + strokeOpacity: 0, + strokeWidth: segmentStrokeWidth, + })), + ); + } + + const localBounds = getCustomMaskLocalBounds({ points, bounds }); + if (params.closed && localBounds) { + handles.push( + ...getBoxMaskHandlePositions({ + centerX: params.centerX, + centerY: params.centerY, + width: (localBounds.width * params.scale) / bounds.width, + height: (localBounds.height * params.scale) / bounds.height, + rotation: params.rotation, + feather: params.feather, + sizeMode: "uniform", + showScaleHandle: false, + bounds, + displayScale, + }), + ); + } + + geometry.anchors.forEach((point) => { + handles.push({ + id: `point:${point.id}:anchor`, + x: point.anchor.x, + y: point.anchor.y, + cursor: params.closed ? "move" : "pointer", + kind: "point", + }); + }); + + return { + handles, + overlays, + }; +} + +function updateCustomMaskPoint({ + points, + pointId, + updater, +}: { + points: CustomMaskPathPoint[]; + pointId: string; + updater: (point: CustomMaskPathPoint) => CustomMaskPathPoint; +}) { + return points.map((point) => (point.id === pointId ? updater(point) : point)); +} + +function computeCustomMaskParamUpdate({ + handleId, + startParams, + deltaX, + deltaY, + startCanvasX, + startCanvasY, + bounds, +}: MaskParamUpdateArgs<CustomMaskParams>): Partial<CustomMaskParams> { + if (handleId === "position") { + return { + centerX: startParams.centerX + deltaX / bounds.width, + centerY: startParams.centerY + deltaY / bounds.height, + }; + } + + const pivotX = bounds.cx + startParams.centerX * bounds.width; + const pivotY = bounds.cy + startParams.centerY * bounds.height; + + if (handleId === "rotation") { + const startAngle = + (Math.atan2(startCanvasY - pivotY, startCanvasX - pivotX) * 180) / + Math.PI; + const currentAngle = + (Math.atan2( + startCanvasY + deltaY - pivotY, + startCanvasX + deltaX - pivotX, + ) * + 180) / + Math.PI; + + let deltaAngle = currentAngle - startAngle; + if (deltaAngle > 180) deltaAngle -= 360; + if (deltaAngle < -180) deltaAngle += 360; + + return { + rotation: (((startParams.rotation + deltaAngle) % 360) + 360) % 360, + }; + } + + 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), + }); + } + + if (handleId === "scale") { + const startDistance = Math.hypot( + startCanvasX - pivotX, + startCanvasY - pivotY, + ); + const currentDistance = Math.hypot( + startCanvasX + deltaX - pivotX, + startCanvasY + deltaY - pivotY, + ); + const scaleFactor = startDistance > 0 ? currentDistance / startDistance : 1; + return { + scale: Math.max(0.01, startParams.scale * scaleFactor), + }; + } + + const parsedHandle = parseCustomMaskHandleId({ handleId }); + if (!parsedHandle) { + return {}; + } + + const points = startParams.path; + const currentPoint = { + x: startCanvasX + deltaX, + y: startCanvasY + deltaY, + }; + const localPoint = customMaskCanvasPointToLocal({ + point: currentPoint, + centerX: startParams.centerX, + centerY: startParams.centerY, + rotation: startParams.rotation, + scale: startParams.scale, + bounds, + }); + + return { + path: updateCustomMaskPoint({ + points, + pointId: parsedHandle.pointId, + updater: (point) => { + if (parsedHandle.part === "anchor") { + return { + ...point, + x: localPoint.x, + y: localPoint.y, + }; + } + + if (parsedHandle.part === "in") { + return { + ...point, + inX: localPoint.x - point.x, + inY: localPoint.y - point.y, + }; + } + + return { + ...point, + outX: localPoint.x - point.x, + outY: localPoint.y - point.y, + }; + }, + }), + }; +} + +export const customMaskDefinition: MaskDefinition<CustomMaskParams> = { + type: "custom", + name: "Custom", + features: { + hasPosition: true, + hasRotation: true, + sizeMode: "uniform", + }, + params: CUSTOM_MASK_PARAMS, + interaction: { + getInteraction({ + params, + bounds, + displayScale, + scaleX: _scaleX, + scaleY: _scaleY, + }) { + return getCustomMaskDisplayHandles({ params, bounds, displayScale }); + }, + snap({ + handleId, + proposedParams, + startParams, + bounds, + canvasSize, + snapThreshold, + }) { + const points = startParams.path; + const localBounds = getCustomMaskLocalBounds({ points, bounds }); + if (!startParams.closed || !localBounds) { + return { + params: proposedParams, + activeLines: [], + }; + } + + const position = { + x: proposedParams.centerX * bounds.width, + y: proposedParams.centerY * bounds.height, + }; + + if (handleId === "position") { + const { snappedPosition, activeLines } = snapPosition({ + proposedPosition: position, + canvasSize: bounds, + elementSize: { + width: localBounds.width * proposedParams.scale, + height: localBounds.height * proposedParams.scale, + }, + rotation: proposedParams.rotation, + snapThreshold, + }); + + return { + params: { + ...proposedParams, + ...setMaskLocalCenter({ + center: snappedPosition, + bounds, + }), + }, + activeLines: toGlobalMaskSnapLines({ + lines: activeLines, + bounds, + canvasSize, + }), + }; + } + + if (handleId === "rotation") { + const { snappedRotation } = snapRotation({ + proposedRotation: proposedParams.rotation, + }); + return { + params: { + ...proposedParams, + rotation: snappedRotation, + }, + activeLines: [], + }; + } + + if (handleId === "scale") { + const { snappedScale, activeLines } = snapScale({ + proposedScale: proposedParams.scale, + position, + baseWidth: localBounds.width, + baseHeight: localBounds.height, + rotation: proposedParams.rotation, + canvasSize: bounds, + snapThreshold, + preferredEdges: { + right: true, + bottom: true, + }, + }); + + return { + params: { + ...proposedParams, + scale: Math.max(0.01, snappedScale), + }, + activeLines: toGlobalMaskSnapLines({ + lines: activeLines, + bounds, + canvasSize, + }), + }; + } + + return { + params: proposedParams, + activeLines: [], + }; + }, + }, + buildDefault(): Omit<CustomMask, "id"> { + return { + type: "custom", + params: { + feather: 0, + inverted: false, + strokeColor: "#ffffff", + strokeWidth: 0, + strokeAlign: "center", + path: [], + closed: false, + centerX: 0, + centerY: 0, + rotation: 0, + scale: 1, + }, + }; + }, + computeParamUpdate: computeCustomMaskParamUpdate, + isActive(params) { + return params.closed; + }, + renderer: { + buildPath({ resolvedParams, width, height }) { + const params = resolvedParams as CustomMaskParams; + 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, + }); + }, + renderStroke({ resolvedParams, ctx, width, height }) { + const params = resolvedParams as CustomMaskParams; + 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, + }); + + 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 === "outside") { + ctx.globalCompositeOperation = "destination-out"; + ctx.fillStyle = "#ffffff"; + ctx.fill(path); + } + ctx.restore(); + }, + }, +}; + +export function appendPointToCustomMask({ + params, + canvasPoint, + bounds, +}: { + params: CustomMaskParams; + canvasPoint: { x: number; y: number }; + bounds: ElementBounds; +}): CustomMaskParams { + const points = params.path; + + if (points.length === 0) { + return { + ...params, + centerX: + bounds.width === 0 ? 0 : (canvasPoint.x - bounds.cx) / bounds.width, + centerY: + bounds.height === 0 ? 0 : (canvasPoint.y - bounds.cy) / bounds.height, + rotation: 0, + scale: 1, + path: [ + { + id: generateUUID(), + x: 0, + y: 0, + inX: 0, + inY: 0, + outX: 0, + outY: 0, + }, + ], + }; + } + + const localPoint = customMaskCanvasPointToLocal({ + point: canvasPoint, + centerX: params.centerX, + centerY: params.centerY, + rotation: params.rotation, + scale: params.scale, + bounds, + }); + const nextPoints = [ + ...points, + { + id: generateUUID(), + x: localPoint.x, + y: localPoint.y, + inX: 0, + inY: 0, + outX: 0, + outY: 0, + }, + ]; + const recentered = recenterCustomMaskPath({ + points: nextPoints, + centerX: params.centerX, + centerY: params.centerY, + rotation: params.rotation, + scale: params.scale, + bounds, + }); + + return { + ...params, + centerX: recentered.centerX, + centerY: recentered.centerY, + path: recentered.points, + }; +} + +export function insertPointOnCustomMaskSegment({ + params, + segmentIndex, + canvasPoint, + bounds, + pointId = generateUUID(), +}: { + params: CustomMaskParams; + segmentIndex: number; + canvasPoint: { x: number; y: number }; + bounds: ElementBounds; + pointId?: string; +}): { params: CustomMaskParams; pointId: string } | null { + const points = params.path; + if (getCustomMaskSegmentCount({ points, closed: params.closed }) === 0) { + return null; + } + + const closestPoint = findClosestPointOnCustomMaskSegment({ + points, + segmentIndex, + canvasPoint, + centerX: params.centerX, + centerY: params.centerY, + rotation: params.rotation, + scale: params.scale, + bounds, + closed: params.closed, + }); + if (!closestPoint) { + return null; + } + + const nextPoints = insertPointIntoCustomMaskSegment({ + points, + segmentIndex, + pointId, + t: closestPoint.t, + closed: params.closed, + }); + if (nextPoints.length === points.length) { + return null; + } + + const recentered = recenterCustomMaskPath({ + points: nextPoints, + centerX: params.centerX, + centerY: params.centerY, + rotation: params.rotation, + scale: params.scale, + bounds, + }); + + return { + pointId, + params: { + ...params, + centerX: recentered.centerX, + centerY: recentered.centerY, + path: recentered.points, + }, + }; +} diff --git a/apps/web/src/masks/definitions/diamond.ts b/apps/web/src/masks/definitions/diamond.ts new file mode 100644 index 00000000..037aee70 --- /dev/null +++ b/apps/web/src/masks/definitions/diamond.ts @@ -0,0 +1,100 @@ +import type { MaskDefinition, RectangleMaskParams } from "@/masks/types"; +import { + BOX_LIKE_MASK_PARAMS, + buildBoxMaskInteraction, + computeBoxMaskParamUpdate, + getBoxLikeGeometry, + getDefaultSquareMaskParams, + getStrokeOffset, + rotatePoint, +} from "./box-like"; + +function buildDiamondPath({ + centerX, + centerY, + halfWidth, + halfHeight, + rotationRad, +}: { + centerX: number; + centerY: number; + halfWidth: number; + halfHeight: number; + rotationRad: number; +}): Path2D { + const points = [ + { x: centerX, y: centerY - halfHeight }, + { x: centerX + halfWidth, y: centerY }, + { x: centerX, y: centerY + halfHeight }, + { x: centerX - halfWidth, y: centerY }, + ].map((point) => + rotatePoint({ + ...point, + centerX, + centerY, + rotationRad, + }), + ); + + const path = new Path2D(); + path.moveTo(points[0].x, points[0].y); + for (const point of points.slice(1)) { + path.lineTo(point.x, point.y); + } + path.closePath(); + return path; +} + +export const diamondMaskDefinition: MaskDefinition<RectangleMaskParams> = { + type: "diamond", + name: "Diamond", + features: { + hasPosition: true, + hasRotation: true, + sizeMode: "width-height", + }, + params: BOX_LIKE_MASK_PARAMS, + interaction: buildBoxMaskInteraction({ + sizeMode: "width-height", + buildOverlayPath({ width, height }) { + return `M ${width / 2},0 L ${width},${height / 2} L ${width / 2},${height} L 0,${height / 2} Z`; + }, + }), + buildDefault(context) { + return { + type: "diamond", + 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 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, + }); + }, + }, +}; diff --git a/apps/web/src/masks/definitions/ellipse.ts b/apps/web/src/masks/definitions/ellipse.ts new file mode 100644 index 00000000..d0567817 --- /dev/null +++ b/apps/web/src/masks/definitions/ellipse.ts @@ -0,0 +1,75 @@ +import type { MaskDefinition, RectangleMaskParams } from "@/masks/types"; +import { + BOX_LIKE_MASK_PARAMS, + buildBoxMaskInteraction, + computeBoxMaskParamUpdate, + getBoxLikeGeometry, + getDefaultSquareMaskParams, + getStrokeOffset, +} from "./box-like"; + +export const ellipseMaskDefinition: MaskDefinition<RectangleMaskParams> = { + type: "ellipse", + name: "Ellipse", + features: { + hasPosition: true, + hasRotation: true, + sizeMode: "width-height", + }, + params: BOX_LIKE_MASK_PARAMS, + interaction: buildBoxMaskInteraction({ + sizeMode: "width-height", + buildOverlayPath({ width, height }) { + const rx = Math.max((width - 1) / 2, 0); + const ry = Math.max((height - 1) / 2, 0); + const cx = width / 2; + const cy = height / 2; + return `M ${cx},${cy - ry} A ${rx},${ry} 0 1,1 ${cx},${cy + ry} A ${rx},${ry} 0 1,1 ${cx},${cy - ry} Z`; + }, + }), + buildDefault(context) { + return { + type: "ellipse", + params: getDefaultSquareMaskParams(context), + }; + }, + computeParamUpdate: computeBoxMaskParamUpdate, + renderer: { + buildPath({ resolvedParams, width, height }) { + const params = resolvedParams as RectangleMaskParams; + const { centerX, centerY, maskWidth, maskHeight, rotationRad } = + getBoxLikeGeometry({ params, width, height }); + const path = new Path2D(); + path.ellipse( + centerX, + centerY, + maskWidth / 2, + maskHeight / 2, + rotationRad, + 0, + Math.PI * 2, + ); + return path; + }, + buildStrokePath({ resolvedParams, width, height }) { + const params = resolvedParams as RectangleMaskParams; + const { centerX, centerY, maskWidth, maskHeight, rotationRad } = + getBoxLikeGeometry({ params, width, height }); + const offset = getStrokeOffset({ + strokeAlign: params.strokeAlign, + strokeWidth: params.strokeWidth, + }); + const path = new Path2D(); + path.ellipse( + centerX, + centerY, + Math.max(1, maskWidth / 2 + offset), + Math.max(1, maskHeight / 2 + offset), + rotationRad, + 0, + Math.PI * 2, + ); + return path; + }, + }, +}; diff --git a/apps/web/src/masks/definitions/heart.ts b/apps/web/src/masks/definitions/heart.ts new file mode 100644 index 00000000..d15c09e6 --- /dev/null +++ b/apps/web/src/masks/definitions/heart.ts @@ -0,0 +1,142 @@ +import type { MaskDefinition, RectangleMaskParams } 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, + }); + }, + }, +}; diff --git a/apps/web/src/masks/definitions/index.ts b/apps/web/src/masks/definitions/index.ts new file mode 100644 index 00000000..9cee1020 --- /dev/null +++ b/apps/web/src/masks/definitions/index.ts @@ -0,0 +1,74 @@ +import type { BaseMaskParams, MaskDefinition } from "@/masks/types"; +import { masksRegistry, type MaskIconProps } from "../registry"; +import { cinematicBarsMaskDefinition } from "./cinematic-bars"; +import { customMaskDefinition } from "./custom"; +import { diamondMaskDefinition } from "./diamond"; +import { ellipseMaskDefinition } from "./ellipse"; +import { heartMaskDefinition } from "./heart"; +import { rectangleMaskDefinition } from "./rectangle"; +import { splitMaskDefinition } from "./split"; +import { starMaskDefinition } from "./star"; +import { textMaskDefinition } from "./text"; +import { + MinusSignIcon, + PanelRightDashedIcon, + SquareIcon, + CircleIcon, + FavouriteIcon, + DiamondIcon, + StarsIcon, + TextFontIcon, +} from "@hugeicons/core-free-icons"; + +function registerDefaultMask<TParams extends BaseMaskParams>({ + definition, + icon, +}: { + definition: MaskDefinition<TParams>; + icon: MaskIconProps; +}) { + if (masksRegistry.has(definition.type)) { + return; + } + + masksRegistry.registerMask({ definition, icon }); +} + +export function registerDefaultMasks(): void { + registerDefaultMask({ + definition: splitMaskDefinition, + icon: { icon: PanelRightDashedIcon, strokeWidth: 1 }, + }); + registerDefaultMask({ + definition: cinematicBarsMaskDefinition, + icon: { icon: MinusSignIcon }, + }); + registerDefaultMask({ + definition: rectangleMaskDefinition, + icon: { icon: SquareIcon }, + }); + registerDefaultMask({ + definition: ellipseMaskDefinition, + icon: { icon: CircleIcon }, + }); + registerDefaultMask({ + definition: heartMaskDefinition, + icon: { icon: FavouriteIcon }, + }); + registerDefaultMask({ + definition: diamondMaskDefinition, + icon: { icon: DiamondIcon }, + }); + registerDefaultMask({ + definition: starMaskDefinition, + icon: { icon: StarsIcon }, + }); + registerDefaultMask({ + definition: textMaskDefinition, + icon: { icon: TextFontIcon }, + }); + registerDefaultMask({ + definition: customMaskDefinition, + icon: { icon: SquareIcon }, + }); +} diff --git a/apps/web/src/masks/definitions/rectangle.ts b/apps/web/src/masks/definitions/rectangle.ts new file mode 100644 index 00000000..7bf1a7fd --- /dev/null +++ b/apps/web/src/masks/definitions/rectangle.ts @@ -0,0 +1,97 @@ +import type { MaskDefinition, RectangleMaskParams } from "@/masks/types"; +import { + BOX_LIKE_MASK_PARAMS, + buildBoxMaskInteraction, + computeBoxMaskParamUpdate, + getBoxLikeGeometry, + getDefaultSquareMaskParams, + getStrokeOffset, + rotatePoint, +} from "./box-like"; + +function buildRectanglePath({ + centerX, + centerY, + halfWidth, + halfHeight, + rotationRad, +}: { + centerX: number; + centerY: number; + halfWidth: number; + halfHeight: number; + rotationRad: number; +}): Path2D { + const corners = [ + { x: centerX - halfWidth, y: centerY - halfHeight }, + { x: centerX + halfWidth, y: centerY - halfHeight }, + { x: centerX + halfWidth, y: centerY + halfHeight }, + { x: centerX - halfWidth, y: centerY + halfHeight }, + ].map((point) => + rotatePoint({ + ...point, + centerX, + centerY, + rotationRad, + }), + ); + + const path = new Path2D(); + path.moveTo(corners[0].x, corners[0].y); + for (const corner of corners.slice(1)) { + path.lineTo(corner.x, corner.y); + } + path.closePath(); + return path; +} + +export const rectangleMaskDefinition: MaskDefinition<RectangleMaskParams> = { + type: "rectangle", + name: "Rectangle", + features: { + hasPosition: true, + hasRotation: true, + sizeMode: "width-height", + }, + params: BOX_LIKE_MASK_PARAMS, + interaction: buildBoxMaskInteraction({ + sizeMode: "width-height", + }), + buildDefault(context) { + return { + type: "rectangle", + params: getDefaultSquareMaskParams(context), + }; + }, + computeParamUpdate: computeBoxMaskParamUpdate, + renderer: { + buildPath({ resolvedParams, width, height }) { + const params = resolvedParams as RectangleMaskParams; + const { centerX, centerY, maskWidth, maskHeight, rotationRad } = + getBoxLikeGeometry({ params, width, height }); + return buildRectanglePath({ + centerX, + centerY, + halfWidth: maskWidth / 2, + halfHeight: maskHeight / 2, + rotationRad, + }); + }, + buildStrokePath({ resolvedParams, width, height }) { + const params = resolvedParams as RectangleMaskParams; + const { centerX, centerY, maskWidth, maskHeight, rotationRad } = + getBoxLikeGeometry({ params, width, height }); + const offset = getStrokeOffset({ + strokeAlign: params.strokeAlign, + strokeWidth: params.strokeWidth, + }); + return buildRectanglePath({ + centerX, + centerY, + halfWidth: Math.max(1, maskWidth / 2 + offset), + halfHeight: Math.max(1, maskHeight / 2 + offset), + rotationRad, + }); + }, + }, +}; diff --git a/apps/web/src/masks/definitions/split.ts b/apps/web/src/masks/definitions/split.ts new file mode 100644 index 00000000..d50a2571 --- /dev/null +++ b/apps/web/src/masks/definitions/split.ts @@ -0,0 +1,382 @@ +import { computeFeatherUpdate } from "../param-update"; +import type { + MaskDefinition, + MaskParamUpdateArgs, + SplitMaskParams, +} from "@/masks/types"; +import { halfPlaneSign, lineEdgeIntersection } from "../utils"; +import { + getLineMaskHandlePositions, + getLineMaskOverlay, +} from "@/masks/handle-positions"; +import { snapMaskInteraction } from "@/masks/snap"; + +// cos(π/2) returns ~6e-17 in JS, not 0. Values below this threshold are snapped +// to exactly 0 to prevent opposite-sign float noise on canvas corners that lie +// exactly on the split line, which produces spurious midpoint vertices. +const NORMAL_SNAP_EPSILON = 1e-10; + +// Guards against collinear vertices from float noise at canvas edges. +const MIN_POLYGON_AREA_PX = 0.5; +const INTERSECTION_EPSILON = 1e-6; + +function polygonArea({ vertices }: { vertices: [number, number][] }): number { + let area = 0; + for (let i = 0; i < vertices.length; i++) { + const [x1, y1] = vertices[i]; + const [x2, y2] = vertices[(i + 1) % vertices.length]; + area += x1 * y2 - x2 * y1; + } + return Math.abs(area) * 0.5; +} + +function splitLineGeometry({ + centerX, + centerY, + rotation, + width, + height, +}: { + centerX: number; + centerY: number; + rotation: number; + width: number; + height: number; +}): { normalX: number; normalY: number; lineX: number; lineY: number } { + const angleRad = (rotation * Math.PI) / 180; + const normalX = + Math.abs(Math.cos(angleRad)) < NORMAL_SNAP_EPSILON ? 0 : Math.cos(angleRad); + const normalY = + Math.abs(Math.sin(angleRad)) < NORMAL_SNAP_EPSILON ? 0 : Math.sin(angleRad); + const lineX = width / 2 + centerX * width; + const lineY = height / 2 + centerY * height; + return { normalX, normalY, lineX, lineY }; +} + +function pointsEqual( + a: { x: number; y: number }, + b: { x: number; y: number }, +): boolean { + return ( + Math.abs(a.x - b.x) <= INTERSECTION_EPSILON && + Math.abs(a.y - b.y) <= INTERSECTION_EPSILON + ); +} + +export function getSplitMaskStrokeSegment({ + resolvedParams, + width, + height, +}: { + resolvedParams: unknown; + width: number; + height: number; +}): [{ x: number; y: number }, { x: number; y: number }] | null { + const { centerX, centerY, rotation } = resolvedParams as SplitMaskParams; + const { normalX, normalY, lineX, lineY } = splitLineGeometry({ + centerX, + centerY, + rotation, + width, + height, + }); + + const edges: [number, number, number, number][] = [ + [0, 0, width, 0], + [width, 0, width, height], + [width, height, 0, height], + [0, height, 0, 0], + ]; + const intersections: { x: number; y: number }[] = []; + + for (const [x1, y1, x2, y2] of edges) { + const hit = lineEdgeIntersection({ + lineX, + lineY, + normalX, + normalY, + x1, + y1, + x2, + y2, + }); + + if (!hit || intersections.some((point) => pointsEqual(point, hit))) { + continue; + } + + intersections.push(hit); + } + + if (intersections.length !== 2) { + return null; + } + + return [intersections[0], intersections[1]]; +} + +function computeSplitMaskParamUpdate({ + handleId, + startParams, + deltaX, + deltaY, + startCanvasX, + startCanvasY, + bounds, + canvasSize, +}: MaskParamUpdateArgs<SplitMaskParams>): Partial<SplitMaskParams> { + if (handleId === "position") { + const rawX = startParams.centerX + deltaX / bounds.width; + const rawY = startParams.centerY + deltaY / bounds.height; + + const minX = -bounds.cx / bounds.width; + const maxX = (canvasSize.width - bounds.cx) / bounds.width; + const minY = -bounds.cy / bounds.height; + const maxY = (canvasSize.height - bounds.cy) / bounds.height; + + return { + centerX: Math.max(minX, Math.min(maxX, rawX)), + centerY: Math.max(minY, Math.min(maxY, rawY)), + }; + } + + if (handleId === "feather") { + const angleRad = (startParams.rotation * Math.PI) / 180; + return computeFeatherUpdate({ + startFeather: startParams.feather, + deltaX, + deltaY, + directionX: -Math.cos(angleRad), + directionY: -Math.sin(angleRad), + }); + } + + if (handleId === "rotation") { + const pivotX = bounds.cx + startParams.centerX * bounds.width; + const pivotY = bounds.cy + startParams.centerY * bounds.height; + const startAngle = + (Math.atan2(startCanvasY - pivotY, startCanvasX - pivotX) * 180) / + Math.PI; + const currentAngle = + (Math.atan2( + startCanvasY + deltaY - pivotY, + startCanvasX + deltaX - pivotX, + ) * + 180) / + Math.PI; + + let deltaAngle = currentAngle - startAngle; + if (deltaAngle > 180) deltaAngle -= 360; + if (deltaAngle < -180) deltaAngle += 360; + + return { + rotation: (((startParams.rotation + deltaAngle) % 360) + 360) % 360, + }; + } + + return {}; +} + +export const splitMaskDefinition: MaskDefinition<SplitMaskParams> = { + type: "split", + name: "Split", + features: { + hasPosition: true, + hasRotation: true, + sizeMode: "none", + }, + interaction: { + getInteraction({ + params, + bounds, + displayScale, + scaleX: _scaleX, + scaleY: _scaleY, + }) { + return { + handles: getLineMaskHandlePositions({ + centerX: params.centerX, + centerY: params.centerY, + rotation: params.rotation, + feather: params.feather, + bounds, + displayScale, + }), + overlays: [ + getLineMaskOverlay({ + centerX: params.centerX, + centerY: params.centerY, + rotation: params.rotation, + bounds, + }), + ], + }; + }, + snap(args) { + return snapMaskInteraction(args); + }, + }, + buildDefault() { + return { + type: "split", + params: { + feather: 0, + inverted: false, + strokeColor: "#ffffff", + strokeWidth: 0, + strokeAlign: "center", + centerX: 0, + centerY: 0, + rotation: 0, + }, + }; + }, + computeParamUpdate: computeSplitMaskParamUpdate, + params: [ + { + key: "centerX", + label: "X", + type: "number", + default: 0, + min: -100, + max: 100, + step: 1, + displayMultiplier: 100, + }, + { + key: "centerY", + label: "Y", + type: "number", + default: 0, + min: -100, + max: 100, + step: 1, + displayMultiplier: 100, + }, + { + key: "rotation", + label: "Rotation", + type: "number", + default: 0, + min: 0, + max: 360, + step: 1, + }, + ], + renderer: { + 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, + }); + + // Analytical gradient avoids JFA's two-sided distance artifact near canvas edges. + const featherHalf = feather / 2; + const gradient = ctx.createLinearGradient( + lineX - normalX * featherHalf, + lineY - normalY * featherHalf, + lineX + normalX * featherHalf, + lineY + normalY * featherHalf, + ); + gradient.addColorStop(0, "rgba(255,255,255,0)"); + gradient.addColorStop(1, "white"); + + ctx.fillStyle = gradient; + ctx.fillRect(0, 0, width, height); + }, + + buildPath({ resolvedParams, width, height }) { + const { centerX, centerY, rotation } = resolvedParams as SplitMaskParams; + const { normalX, normalY, lineX, lineY } = splitLineGeometry({ + centerX, + centerY, + rotation, + width, + height, + }); + + const edges: [number, number, number, number][] = [ + [0, 0, width, 0], + [width, 0, width, height], + [width, height, 0, height], + [0, height, 0, 0], + ]; + + const isInsideHalfPlane = (x: number, y: number) => + halfPlaneSign({ lineX, lineY, normalX, normalY, x, y }) >= 0; + + const vertices: [number, number][] = []; + for (const [x1, y1, x2, y2] of edges) { + const isVertex1Inside = isInsideHalfPlane(x1, y1); + const isVertex2Inside = isInsideHalfPlane(x2, y2); + + if (isVertex1Inside && isVertex2Inside) { + vertices.push([x2, y2]); + } else if (isVertex1Inside && !isVertex2Inside) { + const hit = lineEdgeIntersection({ + lineX, + lineY, + normalX, + normalY, + x1, + y1, + x2, + y2, + }); + if (hit) vertices.push([hit.x, hit.y]); + } else if (!isVertex1Inside && isVertex2Inside) { + const hit = lineEdgeIntersection({ + lineX, + lineY, + normalX, + normalY, + x1, + y1, + x2, + y2, + }); + if (hit) { + vertices.push([hit.x, hit.y]); + vertices.push([x2, y2]); + } + } + } + + if ( + vertices.length < 3 || + polygonArea({ vertices }) < MIN_POLYGON_AREA_PX + ) { + return new Path2D(); + } + + const path = new Path2D(); + path.moveTo(vertices[0][0], vertices[0][1]); + for (let i = 1; i < vertices.length; i++) { + path.lineTo(vertices[i][0], vertices[i][1]); + } + path.closePath(); + return path; + }, + buildStrokePath({ resolvedParams, width, height }) { + const segment = getSplitMaskStrokeSegment({ + resolvedParams, + width, + height, + }); + const path = new Path2D(); + + if (!segment) { + return path; + } + + path.moveTo(segment[0].x, segment[0].y); + path.lineTo(segment[1].x, segment[1].y); + return path; + }, + }, +}; diff --git a/apps/web/src/masks/definitions/star.ts b/apps/web/src/masks/definitions/star.ts new file mode 100644 index 00000000..a7f8819c --- /dev/null +++ b/apps/web/src/masks/definitions/star.ts @@ -0,0 +1,140 @@ +import type { MaskDefinition, RectangleMaskParams } from "@/masks/types"; +import { + BOX_LIKE_MASK_PARAMS, + buildBoxMaskInteraction, + computeBoxMaskParamUpdate, + getBoxLikeGeometry, + getDefaultSquareMaskParams, + getStrokeOffset, + rotatePoint, +} from "./box-like"; + +const STAR_INNER_RADIUS_RATIO = 0.45; +const STAR_VERTEX_COUNT = 10; + +function buildStarPath({ + centerX, + centerY, + halfWidth, + halfHeight, + rotationRad, +}: { + centerX: number; + centerY: number; + halfWidth: number; + halfHeight: number; + rotationRad: number; +}): Path2D { + const path = new Path2D(); + + for (let index = 0; index < STAR_VERTEX_COUNT; index++) { + const isOuterVertex = index % 2 === 0; + const radiusX = isOuterVertex + ? halfWidth + : halfWidth * STAR_INNER_RADIUS_RATIO; + const radiusY = isOuterVertex + ? halfHeight + : halfHeight * STAR_INNER_RADIUS_RATIO; + const angle = (index * Math.PI) / 5 - Math.PI / 2; + const point = rotatePoint({ + x: centerX + radiusX * Math.cos(angle), + y: centerY + radiusY * Math.sin(angle), + centerX, + centerY, + rotationRad, + }); + + if (index === 0) { + path.moveTo(point.x, point.y); + } else { + path.lineTo(point.x, point.y); + } + } + + path.closePath(); + return path; +} + +function buildOverlayStarPath({ + width, + height, +}: { + width: number; + height: number; +}): string { + const centerX = width / 2; + const centerY = height / 2; + const halfWidth = width / 2; + const halfHeight = height / 2; + const segments: string[] = []; + + for (let index = 0; index < STAR_VERTEX_COUNT; index++) { + const isOuterVertex = index % 2 === 0; + const radiusX = isOuterVertex + ? halfWidth + : halfWidth * STAR_INNER_RADIUS_RATIO; + const radiusY = isOuterVertex + ? halfHeight + : halfHeight * STAR_INNER_RADIUS_RATIO; + const angle = (index * Math.PI) / 5 - Math.PI / 2; + const x = centerX + radiusX * Math.cos(angle); + const y = centerY + radiusY * Math.sin(angle); + segments.push(`${index === 0 ? "M" : "L"} ${x},${y}`); + } + + return `${segments.join(" ")} Z`; +} + +export const starMaskDefinition: MaskDefinition<RectangleMaskParams> = { + type: "star", + name: "Star", + features: { + hasPosition: true, + hasRotation: true, + sizeMode: "width-height", + }, + params: BOX_LIKE_MASK_PARAMS, + interaction: buildBoxMaskInteraction({ + sizeMode: "width-height", + buildOverlayPath({ width, height }) { + return buildOverlayStarPath({ width, height }); + }, + }), + buildDefault(context) { + return { + type: "star", + 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 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, + }); + }, + }, +}; diff --git a/apps/web/src/masks/definitions/text.ts b/apps/web/src/masks/definitions/text.ts new file mode 100644 index 00000000..c285f1c8 --- /dev/null +++ b/apps/web/src/masks/definitions/text.ts @@ -0,0 +1,438 @@ +import type { ParamDefinition } from "@/params"; +import type { + MaskDefinition, + MaskParamUpdateArgs, + TextMask, + TextMaskParams, +} from "@/masks/types"; +import { DEFAULTS } from "@/timeline/defaults"; +import { MIN_FONT_SIZE, MAX_FONT_SIZE } from "@/text/typography"; +import { + drawMeasuredTextLayout, + measureTextLayout, + strokeMeasuredTextLayout, +} from "@/text/primitives"; +import { getTextMeasurementContext } from "@/text/measure-element"; +import { getTextVisualRect } from "@/text/layout"; +import { + getBoxMaskHandlePositions, + getBoxMaskRectOverlay, +} from "@/masks/handle-positions"; +import { computeFeatherUpdate } from "@/masks/param-update"; +import { + setMaskLocalCenter, + toGlobalMaskSnapLines, +} from "@/masks/geometry"; +import { + snapPosition, + snapRotation, + snapScale, + type ScaleEdgePreference, +} from "@/preview/preview-snap"; + +const PERCENTAGE_DISPLAY = { + displayMultiplier: 100, + step: 1, +} as const; + +const TEXT_MASK_ALIGNMENT = DEFAULTS.text.element.textAlign; + +const TEXT_MASK_PARAMS: ParamDefinition<keyof TextMaskParams & 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: "fontSize", + label: "Size", + type: "number", + default: DEFAULTS.text.element.fontSize, + min: MIN_FONT_SIZE, + max: MAX_FONT_SIZE, + step: 1, + }, + { + 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, + }, +]; + +function measureTextMask({ + params, + height, +}: { + params: TextMaskParams; + height: number; +}) { + const layout = measureTextLayout({ + text: { + content: params.content, + fontSize: params.fontSize, + fontFamily: params.fontFamily, + fontWeight: params.fontWeight, + fontStyle: params.fontStyle, + textAlign: TEXT_MASK_ALIGNMENT, + textDecoration: params.textDecoration, + letterSpacing: params.letterSpacing, + lineHeight: params.lineHeight, + }, + canvasHeight: height, + ctx: getTextMeasurementContext(), + }); + const visualRect = getTextVisualRect({ + textAlign: layout.textAlign, + block: layout.block, + background: { enabled: false, color: "transparent" }, + fontSizeRatio: layout.fontSizeRatio, + }); + + return { + layout, + intrinsicWidth: Math.max(1, visualRect.width), + intrinsicHeight: Math.max(1, visualRect.height), + }; +} + +function getScalePreferredEdges({ + handleId, +}: { + handleId: string; +}): ScaleEdgePreference | undefined { + if (handleId !== "scale") { + return undefined; + } + + return { + right: true, + bottom: true, + }; +} + +function computeTextMaskParamUpdate({ + handleId, + startParams, + deltaX, + deltaY, + startCanvasX, + startCanvasY, + bounds, +}: MaskParamUpdateArgs<TextMaskParams>): Partial<TextMaskParams> { + if (handleId === "position") { + return { + centerX: startParams.centerX + deltaX / bounds.width, + centerY: startParams.centerY + deltaY / bounds.height, + }; + } + + const pivotX = bounds.cx + startParams.centerX * bounds.width; + const pivotY = bounds.cy + startParams.centerY * bounds.height; + + if (handleId === "rotation") { + const startAngle = + (Math.atan2(startCanvasY - pivotY, startCanvasX - pivotX) * 180) / + Math.PI; + const currentAngle = + (Math.atan2( + startCanvasY + deltaY - pivotY, + startCanvasX + deltaX - pivotX, + ) * + 180) / + Math.PI; + + let deltaAngle = currentAngle - startAngle; + if (deltaAngle > 180) deltaAngle -= 360; + if (deltaAngle < -180) deltaAngle += 360; + + return { + rotation: (((startParams.rotation + deltaAngle) % 360) + 360) % 360, + }; + } + + 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), + }); + } + + if (handleId === "scale") { + const startDistance = Math.hypot( + startCanvasX - pivotX, + startCanvasY - pivotY, + ); + const currentDistance = Math.hypot( + startCanvasX + deltaX - pivotX, + startCanvasY + deltaY - pivotY, + ); + const scaleFactor = startDistance > 0 ? currentDistance / startDistance : 1; + return { + scale: Math.max(0.01, startParams.scale * scaleFactor), + }; + } + + return {}; +} + +export const textMaskDefinition: MaskDefinition<TextMaskParams> = { + type: "text", + name: "Text", + features: { + hasPosition: true, + hasRotation: true, + sizeMode: "uniform", + }, + params: TEXT_MASK_PARAMS, + interaction: { + getInteraction({ + params, + bounds, + displayScale, + scaleX: _scaleX, + scaleY: _scaleY, + }) { + const { intrinsicWidth, intrinsicHeight } = measureTextMask({ + params, + height: bounds.height, + }); + const width = (intrinsicWidth * params.scale) / bounds.width; + const height = (intrinsicHeight * params.scale) / bounds.height; + return { + handles: getBoxMaskHandlePositions({ + centerX: params.centerX, + centerY: params.centerY, + width, + height, + rotation: params.rotation, + feather: params.feather, + sizeMode: "uniform", + bounds, + displayScale, + }), + overlays: [ + getBoxMaskRectOverlay({ + centerX: params.centerX, + centerY: params.centerY, + width, + height, + rotation: params.rotation, + bounds, + }), + ], + }; + }, + snap({ + handleId, + startParams, + proposedParams, + bounds, + canvasSize, + snapThreshold, + }) { + const { intrinsicWidth, intrinsicHeight } = measureTextMask({ + params: startParams, + height: bounds.height, + }); + const position = { + x: proposedParams.centerX * bounds.width, + y: proposedParams.centerY * bounds.height, + }; + + if (handleId === "position") { + const { snappedPosition, activeLines } = snapPosition({ + proposedPosition: position, + canvasSize: bounds, + elementSize: { + width: intrinsicWidth * proposedParams.scale, + height: intrinsicHeight * proposedParams.scale, + }, + rotation: proposedParams.rotation, + snapThreshold, + }); + + return { + params: { + ...proposedParams, + ...setMaskLocalCenter({ + center: snappedPosition, + bounds, + }), + }, + activeLines: toGlobalMaskSnapLines({ + lines: activeLines, + bounds, + canvasSize, + }), + }; + } + + if (handleId === "rotation") { + const { snappedRotation } = snapRotation({ + proposedRotation: proposedParams.rotation, + }); + return { + params: { + ...proposedParams, + rotation: snappedRotation, + }, + activeLines: [], + }; + } + + if (handleId === "scale") { + const { snappedScale, activeLines } = snapScale({ + proposedScale: proposedParams.scale, + position, + baseWidth: intrinsicWidth, + baseHeight: intrinsicHeight, + rotation: proposedParams.rotation, + canvasSize: bounds, + snapThreshold, + preferredEdges: getScalePreferredEdges({ handleId }), + }); + + return { + params: { + ...proposedParams, + scale: Math.max(0.01, snappedScale), + }, + activeLines: toGlobalMaskSnapLines({ + lines: activeLines, + bounds, + canvasSize, + }), + }; + } + + return { + params: proposedParams, + activeLines: [], + }; + }, + }, + buildDefault(): Omit<TextMask, "id"> { + return { + type: "text", + params: { + feather: 0, + inverted: false, + strokeColor: "#ffffff", + strokeWidth: 0, + strokeAlign: "center", + content: "Mask", + fontSize: DEFAULTS.text.element.fontSize, + fontFamily: DEFAULTS.text.element.fontFamily, + fontWeight: DEFAULTS.text.element.fontWeight, + fontStyle: DEFAULTS.text.element.fontStyle, + textDecoration: DEFAULTS.text.element.textDecoration, + letterSpacing: DEFAULTS.text.letterSpacing, + lineHeight: DEFAULTS.text.lineHeight, + centerX: 0, + centerY: 0, + rotation: 0, + scale: 1, + }, + }; + }, + computeParamUpdate: computeTextMaskParamUpdate, + isActive(params) { + return params.content.trim().length > 0; + }, + renderer: { + renderMask({ resolvedParams, ctx, width, height }) { + const params = resolvedParams as TextMaskParams; + 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(); + }, + renderStroke({ resolvedParams, ctx, width, height }) { + const params = resolvedParams as TextMaskParams; + 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); + } + + strokeMeasuredTextLayout({ + ctx, + layout, + strokeColor: params.strokeColor, + strokeWidth: params.strokeWidth, + }); + + if (params.strokeAlign === "inside") { + ctx.globalCompositeOperation = "destination-in"; + drawMeasuredTextLayout({ + ctx, + layout, + textColor: "#ffffff", + background: null, + }); + } + + if (params.strokeAlign === "outside") { + ctx.globalCompositeOperation = "destination-out"; + drawMeasuredTextLayout({ + ctx, + layout, + textColor: "#ffffff", + background: null, + }); + } + + ctx.restore(); + }, + }, +}; diff --git a/apps/web/src/masks/dimensions.ts b/apps/web/src/masks/dimensions.ts new file mode 100644 index 00000000..dcfcdc0c --- /dev/null +++ b/apps/web/src/masks/dimensions.ts @@ -0,0 +1,2 @@ +export const DEFAULT_SHAPE_MASK_SHORT_SIDE_RATIO = 0.6; +export const MIN_MASK_DIMENSION = 0.01; diff --git a/apps/web/src/masks/feather.ts b/apps/web/src/masks/feather.ts new file mode 100644 index 00000000..83e80a0c --- /dev/null +++ b/apps/web/src/masks/feather.ts @@ -0,0 +1,2 @@ +export const MAX_FEATHER = 1000; +export const FEATHER_HANDLE_SCALE = 0.11; diff --git a/apps/web/src/masks/geometry.ts b/apps/web/src/masks/geometry.ts new file mode 100644 index 00000000..007dfa49 --- /dev/null +++ b/apps/web/src/masks/geometry.ts @@ -0,0 +1,121 @@ +import type { ElementBounds } from "@/preview/element-bounds"; +import type { SnapLine } from "@/preview/preview-snap"; +import { MIN_MASK_DIMENSION } from "@/masks/dimensions"; +import type { RectangleMaskParams } from "@/masks/types"; + +type CenterMaskParams = { + centerX: number; + centerY: number; +}; + +type SnapGeometryParams = CenterMaskParams & { + rotation?: number; + width?: number; + height?: number; + scale?: number; +}; + +export function hasCenterParams( + params: Partial<CenterMaskParams>, +): params is CenterMaskParams { + return ( + typeof params.centerX === "number" && typeof params.centerY === "number" + ); +} + +export function isRectangleMaskParams( + params: SnapGeometryParams, +): params is RectangleMaskParams { + return ( + hasCenterParams(params) && + typeof params.width === "number" && + typeof params.height === "number" && + typeof params.rotation === "number" && + typeof params.scale === "number" + ); +} + +export function getMaskLocalCenter({ + params, + bounds, +}: { + params: CenterMaskParams; + bounds: ElementBounds; +}): { x: number; y: number } | null { + if (!hasCenterParams(params)) return null; + + return { + x: params.centerX * bounds.width, + y: params.centerY * bounds.height, + }; +} + +export function setMaskLocalCenter({ + center, + bounds, +}: { + center: { x: number; y: number }; + bounds: ElementBounds; +}): { centerX: number; centerY: number } { + return { + centerX: bounds.width === 0 ? 0 : center.x / bounds.width, + centerY: bounds.height === 0 ? 0 : center.y / bounds.height, + }; +} + +export function getMaskSnapGeometry({ + params, + bounds, +}: { + params: SnapGeometryParams; + bounds: ElementBounds; +}): { + position: { x: number; y: number }; + size: { width: number; height: number }; + rotation: number; +} | null { + const position = getMaskLocalCenter({ params, bounds }); + if (!position) return null; + + if (isRectangleMaskParams(params)) { + return { + position, + size: { + width: Math.max(params.width, MIN_MASK_DIMENSION) * bounds.width, + height: Math.max(params.height, MIN_MASK_DIMENSION) * bounds.height, + }, + rotation: params.rotation, + }; + } + + return { + position, + size: { width: 0, height: 0 }, + rotation: typeof params.rotation === "number" ? params.rotation : 0, + }; +} + +export function toGlobalMaskSnapLines({ + lines, + bounds, + canvasSize, +}: { + lines: SnapLine[]; + bounds: ElementBounds; + canvasSize: { width: number; height: number }; +}): SnapLine[] { + const centerX = bounds.cx - canvasSize.width / 2; + const centerY = bounds.cy - canvasSize.height / 2; + + return lines.map((line) => + line.type === "vertical" + ? { + type: "vertical" as const, + position: centerX + line.position, + } + : { + type: "horizontal" as const, + position: centerY + line.position, + }, + ); +} diff --git a/apps/web/src/masks/handle-positions.ts b/apps/web/src/masks/handle-positions.ts new file mode 100644 index 00000000..75668959 --- /dev/null +++ b/apps/web/src/masks/handle-positions.ts @@ -0,0 +1,497 @@ +import { FEATHER_HANDLE_SCALE } from "@/masks/feather"; +import type { ElementBounds } from "@/preview/element-bounds"; +import type { + MaskFeatures, + MaskHandlePosition, + MaskLineOverlay, + MaskOverlay, + MaskRectOverlay, + RectangleMaskParams, + MaskShapeOverlay, +} from "@/masks/types"; + +const LINE_HANDLE_OFFSET_SCREEN_PX = 20; +const BOX_HANDLE_OFFSET_SCREEN_PX = 20; +const LINE_EXTENT_MULTIPLIER = 50; + +const CURSOR = { + rotate: "crosshair", + resizeDiagonal: "nwse-resize", + resizeHorizontal: "ew-resize", + resizeVertical: "ns-resize", +} as const; + +/** + * The renderer defines the split line as: + * - normal direction: (cos(rotation), sin(rotation)) + * - line direction (parallel to cut): (-sin(rotation), cos(rotation)) + * - reference point: (centerX * width, centerY * height) from element centre + * + * So rotation=0 → normal points right → line runs vertically. + */ +export function getLineMaskLinePoints({ + centerX, + centerY, + rotation, + bounds, +}: { + centerX: number; + centerY: number; + rotation: number; + bounds: ElementBounds; +}): { start: { x: number; y: number }; end: { x: number; y: number } } { + const angleRad = (rotation * Math.PI) / 180; + const normalX = Math.cos(angleRad); + const normalY = Math.sin(angleRad); + const lineDirX = -normalY; + const lineDirY = normalX; + + const cx = bounds.cx + centerX * bounds.width; + const cy = bounds.cy + centerY * bounds.height; + + const extent = Math.max(bounds.width, bounds.height) * LINE_EXTENT_MULTIPLIER; + + return { + start: { + x: cx - lineDirX * extent, + y: cy - lineDirY * extent, + }, + end: { + x: cx + lineDirX * extent, + y: cy + lineDirY * extent, + }, + }; +} + +export function getLineMaskOverlay({ + centerX, + centerY, + rotation, + bounds, + handleId = "position", + cursor = "move", +}: { + centerX: number; + centerY: number; + rotation: number; + bounds: ElementBounds; + handleId?: string; + cursor?: string; +}): MaskLineOverlay { + const { start, end } = getLineMaskLinePoints({ + centerX, + centerY, + rotation, + bounds, + }); + + return { + id: "line", + type: "line", + start, + end, + handleId, + cursor, + }; +} + +export function getLineMaskHandlePositions({ + centerX, + centerY, + rotation, + feather, + bounds, + displayScale, +}: { + centerX: number; + centerY: number; + rotation: number; + feather: number; + bounds: ElementBounds; + displayScale: number; +}): MaskHandlePosition[] { + const angleRad = (rotation * Math.PI) / 180; + const normalX = Math.cos(angleRad); + const normalY = Math.sin(angleRad); + + const cx = bounds.cx + centerX * bounds.width; + const cy = bounds.cy + centerY * bounds.height; + + const iconOffsetCanvas = LINE_HANDLE_OFFSET_SCREEN_PX / displayScale; + const featherOffset = iconOffsetCanvas + feather * FEATHER_HANDLE_SCALE; + + return [ + { + id: "rotation", + x: cx + normalX * iconOffsetCanvas, + y: cy + normalY * iconOffsetCanvas, + cursor: CURSOR.rotate, + kind: "icon", + icon: "rotate", + }, + { + id: "feather", + x: cx - normalX * featherOffset, + y: cy - normalY * featherOffset, + cursor: CURSOR.resizeHorizontal, + kind: "icon", + icon: "feather", + }, + ]; +} + +function rotatePoint({ + localX, + localY, + cx, + cy, + angleRad, +}: { + localX: number; + localY: number; + cx: number; + cy: number; + angleRad: number; +}): { x: number; y: number } { + const cos = Math.cos(angleRad); + const sin = Math.sin(angleRad); + return { + x: cx + localX * cos - localY * sin, + y: cy + localX * sin + localY * cos, + }; +} + +export function getBoxMaskHandlePositions({ + centerX, + centerY, + width, + height, + rotation, + feather, + sizeMode, + showScaleHandle = true, + bounds, + displayScale, +}: { + centerX: number; + centerY: number; + width: number; + height: number; + rotation: number; + feather: number; + sizeMode: MaskFeatures["sizeMode"]; + showScaleHandle?: boolean; + bounds: ElementBounds; + displayScale: number; +}): MaskHandlePosition[] { + const cx = bounds.cx + centerX * bounds.width; + const cy = bounds.cy + centerY * bounds.height; + const angleRad = (rotation * Math.PI) / 180; + const halfWidth = (width * bounds.width) / 2; + const halfHeight = (height * bounds.height) / 2; + + const handles: MaskHandlePosition[] = []; + const handleOffsetCanvas = BOX_HANDLE_OFFSET_SCREEN_PX / displayScale; + + const rotHandle = rotatePoint({ + localX: 0, + localY: -halfHeight - handleOffsetCanvas, + cx, + cy, + angleRad, + }); + handles.push({ + id: "rotation", + x: rotHandle.x, + y: rotHandle.y, + cursor: CURSOR.rotate, + kind: "icon", + icon: "rotate", + }); + + const featherHandle = rotatePoint({ + localX: 0, + localY: halfHeight + handleOffsetCanvas + feather * FEATHER_HANDLE_SCALE, + cx, + cy, + angleRad, + }); + handles.push({ + id: "feather", + x: featherHandle.x, + y: featherHandle.y, + cursor: CURSOR.resizeVertical, + kind: "icon", + icon: "feather", + }); + + if (sizeMode === "width-height") { + const corners = [ + { localX: -halfWidth, localY: -halfHeight, id: "top-left" }, + { localX: halfWidth, localY: -halfHeight, id: "top-right" }, + { localX: halfWidth, localY: halfHeight, id: "bottom-right" }, + { localX: -halfWidth, localY: halfHeight, id: "bottom-left" }, + ]; + for (const { localX, localY, id } of corners) { + const point = rotatePoint({ localX, localY, cx, cy, angleRad }); + handles.push({ + id, + x: point.x, + y: point.y, + cursor: CURSOR.resizeDiagonal, + kind: "corner", + }); + } + const right = rotatePoint({ + localX: halfWidth, + localY: 0, + cx, + cy, + angleRad, + }); + const left = rotatePoint({ + localX: -halfWidth, + localY: 0, + cx, + cy, + angleRad, + }); + const bottom = rotatePoint({ + localX: 0, + localY: halfHeight, + cx, + cy, + angleRad, + }); + handles.push({ + id: "left", + x: left.x, + y: left.y, + cursor: CURSOR.resizeHorizontal, + kind: "edge", + edgeAxis: "horizontal", + rotation, + }); + handles.push({ + id: "right", + x: right.x, + y: right.y, + cursor: CURSOR.resizeHorizontal, + kind: "edge", + edgeAxis: "horizontal", + rotation, + }); + handles.push({ + id: "bottom", + x: bottom.x, + y: bottom.y, + cursor: CURSOR.resizeVertical, + kind: "edge", + edgeAxis: "vertical", + rotation, + }); + } else if (sizeMode === "height-only") { + const top = rotatePoint({ + localX: 0, + localY: -halfHeight, + cx, + cy, + angleRad, + }); + const bottom = rotatePoint({ + localX: 0, + localY: halfHeight, + cx, + cy, + angleRad, + }); + handles.push({ + id: "top", + x: top.x, + y: top.y, + cursor: CURSOR.resizeVertical, + kind: "edge", + edgeAxis: "vertical", + rotation, + }); + handles.push({ + id: "bottom", + x: bottom.x, + y: bottom.y, + cursor: CURSOR.resizeVertical, + kind: "edge", + edgeAxis: "vertical", + rotation, + }); + } else if (sizeMode === "width-only") { + const left = rotatePoint({ + localX: -halfWidth, + localY: 0, + cx, + cy, + angleRad, + }); + const right = rotatePoint({ + localX: halfWidth, + localY: 0, + cx, + cy, + angleRad, + }); + handles.push({ + id: "left", + x: left.x, + y: left.y, + cursor: CURSOR.resizeHorizontal, + kind: "edge", + edgeAxis: "horizontal", + rotation, + }); + handles.push({ + id: "right", + x: right.x, + y: right.y, + cursor: CURSOR.resizeHorizontal, + kind: "edge", + edgeAxis: "horizontal", + rotation, + }); + } else if (sizeMode === "uniform" && showScaleHandle) { + const point = rotatePoint({ + localX: halfWidth, + localY: halfHeight, + cx, + cy, + angleRad, + }); + handles.push({ + id: "scale", + x: point.x, + y: point.y, + cursor: CURSOR.resizeDiagonal, + kind: "corner", + }); + } + + return handles; +} + +export function getBoxMaskRectOverlay({ + centerX, + centerY, + width, + height, + rotation, + bounds, + handleId = "position", + cursor = "move", + dashed = false, +}: { + centerX: number; + centerY: number; + width: number; + height: number; + rotation: number; + bounds: ElementBounds; + handleId?: string; + cursor?: string; + dashed?: boolean; +}): MaskRectOverlay { + return { + id: "bounding-box", + type: "rect", + center: { + x: bounds.cx + centerX * bounds.width, + y: bounds.cy + centerY * bounds.height, + }, + width: width * bounds.width, + height: height * bounds.height, + rotation, + handleId, + cursor, + dashed, + }; +} + +export function getBoxMaskShapeOverlay({ + centerX, + centerY, + width, + height, + rotation, + bounds, + pathData, + handleId = "position", + cursor = "move", +}: { + centerX: number; + centerY: number; + width: number; + height: number; + rotation: number; + bounds: ElementBounds; + pathData: string; + handleId?: string; + cursor?: string; +}): MaskShapeOverlay { + return { + id: "shape-outline", + type: "shape", + center: { + x: bounds.cx + centerX * bounds.width, + y: bounds.cy + centerY * bounds.height, + }, + width: width * bounds.width, + height: height * bounds.height, + rotation, + pathData, + handleId, + cursor, + }; +} + +export function getBoxMaskOverlays({ + params, + bounds, + pathData, + showBoundingBox = true, +}: { + params: Pick< + RectangleMaskParams, + "centerX" | "centerY" | "width" | "height" | "rotation" + >; + bounds: ElementBounds; + pathData?: string; + showBoundingBox?: boolean; +}): MaskOverlay[] { + const overlays: MaskOverlay[] = []; + if (showBoundingBox) { + overlays.push( + getBoxMaskRectOverlay({ + centerX: params.centerX, + centerY: params.centerY, + width: params.width, + height: params.height, + rotation: params.rotation, + bounds, + dashed: Boolean(pathData), + }), + ); + } + + if (pathData) { + overlays.push( + getBoxMaskShapeOverlay({ + centerX: params.centerX, + centerY: params.centerY, + width: params.width, + height: params.height, + rotation: params.rotation, + bounds, + pathData, + }), + ); + } + + return overlays; +} diff --git a/apps/web/src/masks/index.ts b/apps/web/src/masks/index.ts new file mode 100644 index 00000000..3544e239 --- /dev/null +++ b/apps/web/src/masks/index.ts @@ -0,0 +1,18 @@ +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;
+}
diff --git a/apps/web/src/masks/param-update.ts b/apps/web/src/masks/param-update.ts new file mode 100644 index 00000000..e5c7f5b7 --- /dev/null +++ b/apps/web/src/masks/param-update.ts @@ -0,0 +1,26 @@ +import { FEATHER_HANDLE_SCALE, MAX_FEATHER } from "@/masks/feather"; + +export function computeFeatherUpdate({ + startFeather, + deltaX, + deltaY, + directionX, + directionY, +}: { + startFeather: number; + deltaX: number; + deltaY: number; + directionX: number; + directionY: number; +}): { feather: number } { + const projection = deltaX * directionX + deltaY * directionY; + return { + feather: Math.max( + 0, + Math.min( + MAX_FEATHER, + Math.round(startFeather + projection / FEATHER_HANDLE_SCALE), + ), + ), + }; +} diff --git a/apps/web/src/masks/registry.ts b/apps/web/src/masks/registry.ts new file mode 100644 index 00000000..00b276c8 --- /dev/null +++ b/apps/web/src/masks/registry.ts @@ -0,0 +1,122 @@ +import { MAX_FEATHER } from "@/masks/feather"; +import type { ParamDefinition } from "@/params"; +import type { + BaseMaskParams, + MaskDefaultContext, + MaskDefinition, + MaskInteractionResult, + MaskSnapArgs, + MaskSnapResult, + MaskType, +} from "@/masks/types"; +import type { HugeiconsIconProps } from "@hugeicons/react"; +import { DefinitionRegistry } from "@/params/registry"; + +export type MaskIconProps = { + icon: HugeiconsIconProps["icon"]; + strokeWidth?: number; +}; + +const BASE_MASK_PARAM_DEFINITIONS: ParamDefinition< + keyof BaseMaskParams & string +>[] = [ + { + key: "feather", + label: "Feather", + type: "number", + default: 0, + min: 0, + max: MAX_FEATHER, + step: 1, + unit: "percent", + }, + { + key: "strokeWidth", + label: "Stroke width", + type: "number", + default: 0, + min: 0, + max: 100, + step: 1, + }, + { + key: "strokeColor", + label: "Stroke color", + type: "color", + default: "#ffffff", + }, +]; + +export interface RegisteredMaskDefinition { + type: MaskType; + name: string; + features: MaskDefinition<BaseMaskParams>["features"]; + params: ParamDefinition<string>[]; + renderer: MaskDefinition<BaseMaskParams>["renderer"]; + interaction: { + getInteraction(args: { + params: BaseMaskParams; + 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( + args: Parameters<MaskDefinition<BaseMaskParams>["computeParamUpdate"]>[0], + ): ReturnType<MaskDefinition<BaseMaskParams>["computeParamUpdate"]>; + icon: MaskIconProps; +} + +export class MasksRegistry extends DefinitionRegistry< + MaskType, + RegisteredMaskDefinition +> { + constructor() { + super("mask"); + } + + registerMask<TParams extends BaseMaskParams>({ + definition, + icon, + }: { + definition: MaskDefinition<TParams>; + icon: MaskIconProps; + }): void { + const withBaseParams: RegisteredMaskDefinition = { + type: definition.type, + name: definition.name, + 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); + }, + icon, + }; + this.register(definition.type, withBaseParams); + } +} + +export const masksRegistry = new MasksRegistry(); diff --git a/apps/web/src/masks/snap.ts b/apps/web/src/masks/snap.ts new file mode 100644 index 00000000..cff62ebb --- /dev/null +++ b/apps/web/src/masks/snap.ts @@ -0,0 +1,336 @@ +import type { ElementBounds } from "@/preview/element-bounds"; +import { MIN_MASK_DIMENSION } from "@/masks/dimensions"; +import { + snapPosition, + snapRotation, + snapScale, + snapScaleAxes, + type ScaleEdgePreference, + type SnapLine, +} from "@/preview/preview-snap"; +import type { RectangleMaskParams, SplitMaskParams } from "@/masks/types"; +import { + isRectangleMaskParams, + getMaskSnapGeometry, + setMaskLocalCenter, + toGlobalMaskSnapLines, +} from "./geometry"; + +type SharedMaskParams = SplitMaskParams | RectangleMaskParams; + +type MaskSnapResult<TParams extends SharedMaskParams> = { + params: TParams; + activeLines: SnapLine[]; +}; + +const CORNER_SIZE_HANDLES = new Set([ + "top-left", + "top-right", + "bottom-left", + "bottom-right", +]); + +function getClampedRatio({ + next, + base, +}: { + next: number; + base: number; +}): number { + return ( + Math.max(next, MIN_MASK_DIMENSION) / Math.max(base, MIN_MASK_DIMENSION) + ); +} + +function getPreferredEdges({ + handleId, +}: { + handleId: string; +}): ScaleEdgePreference | undefined { + const preferredEdges = { + left: + handleId === "left" || + handleId === "top-left" || + handleId === "bottom-left", + right: + handleId === "right" || + handleId === "top-right" || + handleId === "bottom-right", + top: + handleId === "top" || handleId === "top-left" || handleId === "top-right", + bottom: + handleId === "bottom" || + handleId === "bottom-left" || + handleId === "bottom-right", + } satisfies ScaleEdgePreference; + + return Object.values(preferredEdges).some(Boolean) + ? preferredEdges + : undefined; +} + +function snapMaskPosition({ + proposedParams, + bounds, + canvasSize, + snapThreshold, +}: { + proposedParams: SharedMaskParams; + bounds: ElementBounds; + canvasSize: { width: number; height: number }; + snapThreshold: { x: number; y: number }; +}): MaskSnapResult<SharedMaskParams> { + const geometry = getMaskSnapGeometry({ + params: proposedParams, + bounds, + }); + if (!geometry) { + return { params: proposedParams, activeLines: [] }; + } + + const { snappedPosition, activeLines } = snapPosition({ + proposedPosition: geometry.position, + canvasSize: bounds, + elementSize: geometry.size, + rotation: geometry.rotation, + snapThreshold, + }); + + return { + params: { + ...proposedParams, + ...setMaskLocalCenter({ + center: snappedPosition, + bounds, + }), + }, + activeLines: toGlobalMaskSnapLines({ + lines: activeLines, + bounds, + canvasSize, + }), + }; +} + +function snapMaskRotation({ + proposedParams, +}: { + proposedParams: SharedMaskParams; +}): MaskSnapResult<SharedMaskParams> { + if (typeof proposedParams.rotation !== "number") { + return { params: proposedParams, activeLines: [] }; + } + + const { snappedRotation } = snapRotation({ + proposedRotation: proposedParams.rotation, + }); + + return { + params: { + ...proposedParams, + rotation: snappedRotation, + }, + activeLines: [], + }; +} + +function snapBoxMaskSize({ + handleId, + startParams, + proposedParams, + bounds, + canvasSize, + snapThreshold, +}: { + handleId: string; + startParams: SharedMaskParams; + proposedParams: SharedMaskParams; + bounds: ElementBounds; + canvasSize: { width: number; height: number }; + snapThreshold: { x: number; y: number }; +}): MaskSnapResult<SharedMaskParams> { + if ( + !isRectangleMaskParams(startParams) || + !isRectangleMaskParams(proposedParams) + ) { + return { params: proposedParams, activeLines: [] }; + } + + const geometry = getMaskSnapGeometry({ + params: proposedParams, + bounds, + }); + if (!geometry) { + return { params: proposedParams, activeLines: [] }; + } + + const localCanvasSize = bounds; + const baseWidth = + Math.max(startParams.width, MIN_MASK_DIMENSION) * bounds.width; + const baseHeight = + Math.max(startParams.height, MIN_MASK_DIMENSION) * bounds.height; + const preferredEdges = getPreferredEdges({ handleId }); + + if (handleId === "right" || handleId === "left") { + const proposedScaleX = getClampedRatio({ + next: proposedParams.width, + base: startParams.width, + }); + const { x } = snapScaleAxes({ + proposedScaleX, + proposedScaleY: 1, + position: geometry.position, + baseWidth, + baseHeight, + rotation: proposedParams.rotation, + canvasSize: localCanvasSize, + snapThreshold, + preferredEdges, + }); + + return { + params: { + ...proposedParams, + width: Math.max(MIN_MASK_DIMENSION, startParams.width * x.snappedScale), + }, + activeLines: toGlobalMaskSnapLines({ + lines: x.activeLines, + bounds, + canvasSize, + }), + }; + } + + if (handleId === "top" || handleId === "bottom") { + const proposedScaleY = getClampedRatio({ + next: proposedParams.height, + base: startParams.height, + }); + const { y } = snapScaleAxes({ + proposedScaleX: 1, + proposedScaleY, + position: geometry.position, + baseWidth, + baseHeight, + rotation: proposedParams.rotation, + canvasSize: localCanvasSize, + snapThreshold, + preferredEdges, + }); + + return { + params: { + ...proposedParams, + height: Math.max( + MIN_MASK_DIMENSION, + startParams.height * y.snappedScale, + ), + }, + activeLines: toGlobalMaskSnapLines({ + lines: y.activeLines, + bounds, + canvasSize, + }), + }; + } + + if (handleId === "scale") { + const baseScale = Math.max(startParams.scale, MIN_MASK_DIMENSION); + const proposedScale = getClampedRatio({ + next: proposedParams.scale, + base: startParams.scale, + }); + const { snappedScale, activeLines } = snapScale({ + proposedScale, + position: geometry.position, + baseWidth: baseWidth * baseScale, + baseHeight: baseHeight * baseScale, + rotation: proposedParams.rotation, + canvasSize: localCanvasSize, + snapThreshold, + preferredEdges, + }); + + return { + params: { + ...proposedParams, + scale: Math.max(MIN_MASK_DIMENSION, startParams.scale * snappedScale), + }, + activeLines: toGlobalMaskSnapLines({ + lines: activeLines, + bounds, + canvasSize, + }), + }; + } + + if (CORNER_SIZE_HANDLES.has(handleId)) { + const proposedScale = getClampedRatio({ + next: proposedParams.width, + base: startParams.width, + }); + const { snappedScale, activeLines } = snapScale({ + proposedScale, + position: geometry.position, + baseWidth, + baseHeight, + rotation: proposedParams.rotation, + canvasSize: localCanvasSize, + snapThreshold, + preferredEdges, + }); + + return { + params: { + ...proposedParams, + width: Math.max(MIN_MASK_DIMENSION, startParams.width * snappedScale), + height: Math.max(MIN_MASK_DIMENSION, startParams.height * snappedScale), + }, + activeLines: toGlobalMaskSnapLines({ + lines: activeLines, + bounds, + canvasSize, + }), + }; + } + + return { params: proposedParams, activeLines: [] }; +} + +export function snapMaskInteraction<TParams extends SharedMaskParams>({ + handleId, + startParams, + proposedParams, + bounds, + canvasSize, + snapThreshold, +}: { + handleId: string; + startParams: TParams; + proposedParams: TParams; + bounds: ElementBounds; + canvasSize: { width: number; height: number }; + snapThreshold: { x: number; y: number }; +}): MaskSnapResult<TParams> { + if (handleId === "position") { + return snapMaskPosition({ + proposedParams, + bounds, + canvasSize, + snapThreshold, + }) as MaskSnapResult<TParams>; + } + + if (handleId === "rotation") { + return snapMaskRotation({ proposedParams }) as MaskSnapResult<TParams>; + } + + return snapBoxMaskSize({ + handleId, + startParams, + proposedParams, + bounds, + canvasSize, + snapThreshold, + }) as MaskSnapResult<TParams>; +} diff --git a/apps/web/src/masks/types.ts b/apps/web/src/masks/types.ts new file mode 100644 index 00000000..4f6c90aa --- /dev/null +++ b/apps/web/src/masks/types.ts @@ -0,0 +1,298 @@ +import type { ElementBounds } from "@/preview/element-bounds"; +import type { SnapLine } from "@/preview/preview-snap"; +import type { ParamDefinition } from "@/params"; +import type { CustomMaskPathPoint } from "@/masks/custom-path"; +import type { + TextDecoration, + TextFontStyle, + TextFontWeight, +} from "@/text/primitives"; + +export type MaskType = + | "split" + | "cinematic-bars" + | "rectangle" + | "ellipse" + | "heart" + | "diamond" + | "star" + | "text" + | "custom"; + +export interface BaseMaskParams { + feather: number; + inverted: boolean; + strokeColor: string; + strokeWidth: number; + strokeAlign: "inside" | "center" | "outside"; +} + +export interface SplitMaskParams extends BaseMaskParams { + centerX: number; + centerY: number; + rotation: number; +} + +export interface RectangleMaskParams extends BaseMaskParams { + centerX: number; + centerY: number; + width: number; + height: number; + rotation: number; + scale: number; +} + +export interface TextMaskParams extends BaseMaskParams { + content: string; + fontSize: number; + fontFamily: string; + fontWeight: TextFontWeight; + fontStyle: TextFontStyle; + textDecoration: TextDecoration; + letterSpacing: number; + lineHeight: number; + centerX: number; + centerY: number; + rotation: number; + scale: number; +} + +export interface CustomMaskParams extends BaseMaskParams { + path: CustomMaskPathPoint[]; + closed: boolean; + centerX: number; + centerY: number; + rotation: number; + scale: number; +} + +export interface SplitMask { + id: string; + type: "split"; + params: SplitMaskParams; +} + +export interface CinematicBarsMask { + id: string; + type: "cinematic-bars"; + params: RectangleMaskParams; +} + +export interface RectangleMask { + id: string; + type: "rectangle"; + params: RectangleMaskParams; +} + +export interface EllipseMask { + id: string; + type: "ellipse"; + params: RectangleMaskParams; +} + +export interface HeartMask { + id: string; + type: "heart"; + params: RectangleMaskParams; +} + +export interface DiamondMask { + id: string; + type: "diamond"; + params: RectangleMaskParams; +} + +export interface StarMask { + id: string; + type: "star"; + params: RectangleMaskParams; +} + +export interface TextMask { + id: string; + type: "text"; + params: TextMaskParams; +} + +export interface CustomMask { + id: string; + type: "custom"; + params: CustomMaskParams; +} + +export type Mask = + | SplitMask + | CinematicBarsMask + | RectangleMask + | EllipseMask + | HeartMask + | DiamondMask + | StarMask + | 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 interface MaskFeatures { + hasPosition: boolean; + hasRotation: boolean; + sizeMode: "none" | "uniform" | "width-height" | "height-only" | "width-only"; +} + +export type MaskHandleIcon = "rotate" | "feather"; + +export type MaskHandleKind = "corner" | "edge" | "icon" | "point" | "tangent"; + +export interface MaskHandlePosition { + id: string; + x: number; + y: number; + cursor: string; + kind: MaskHandleKind; + isSelected?: boolean; + edgeAxis?: "horizontal" | "vertical"; + rotation?: number; + icon?: MaskHandleIcon; +} + +export interface MaskLineOverlay { + id: string; + type: "line"; + start: { x: number; y: number }; + end: { x: number; y: number }; + cursor?: string; + handleId?: string; +} + +export interface MaskRectOverlay { + id: string; + type: "rect"; + center: { x: number; y: number }; + width: number; + height: number; + rotation: number; + dashed?: boolean; + cursor?: string; + handleId?: string; +} + +export interface MaskShapeOverlay { + id: string; + type: "shape"; + center: { x: number; y: number }; + width: number; + height: number; + rotation: number; + pathData: string; + cursor?: string; + handleId?: string; +} + +export interface MaskCanvasPathOverlay { + id: string; + type: "canvas-path"; + pathData: string; + coordinateSpace?: "canvas" | "overlay"; + cursor?: string; + handleId?: string; + strokeWidth?: number; + strokeOpacity?: number; +} + +export type MaskOverlay = + | MaskLineOverlay + | MaskRectOverlay + | MaskShapeOverlay + | MaskCanvasPathOverlay; + +export interface MaskDefaultContext { + elementSize?: { width: number; height: number }; +} + +export interface MaskParamUpdateArgs< + TParams extends BaseMaskParams = BaseMaskParams, +> { + handleId: string; + startParams: TParams; + deltaX: number; + deltaY: number; + startCanvasX: number; + startCanvasY: number; + bounds: ElementBounds; + canvasSize: { width: number; height: number }; +} + +export interface MaskSnapArgs<TParams extends BaseMaskParams = BaseMaskParams> { + handleId: string; + startParams: TParams; + proposedParams: TParams; + bounds: ElementBounds; + canvasSize: { width: number; height: number }; + snapThreshold: { x: number; y: number }; +} + +export interface MaskSnapResult< + TParams extends BaseMaskParams = BaseMaskParams, +> { + params: TParams; + activeLines: SnapLine[]; +} + +export interface MaskInteractionResult { + handles: MaskHandlePosition[]; + overlays: MaskOverlay[]; +} + +export interface MaskInteractionDefinition< + TParams extends BaseMaskParams = BaseMaskParams, +> { + getInteraction(args: { + params: TParams; + bounds: ElementBounds; + displayScale: number; + scaleX: number; + scaleY: number; + }): MaskInteractionResult; + snap?(args: MaskSnapArgs<TParams>): MaskSnapResult<TParams>; +} + +export interface MaskDefinition< + TParams extends BaseMaskParams = BaseMaskParams, +> { + type: MaskType; + name: string; + features: MaskFeatures; + params: ParamDefinition<keyof TParams & string>[]; + renderer: MaskRenderer; + interaction: MaskInteractionDefinition<TParams>; + /** When defined and returning false, the mask is not applied and the element renders fully visible. */ + isActive?: (params: TParams) => boolean; + buildDefault(context: MaskDefaultContext): Omit<Mask, "id">; + computeParamUpdate(args: MaskParamUpdateArgs<TParams>): Partial<TParams>; +} diff --git a/apps/web/src/masks/use-mask-handles.ts b/apps/web/src/masks/use-mask-handles.ts new file mode 100644 index 00000000..b81de4f4 --- /dev/null +++ b/apps/web/src/masks/use-mask-handles.ts @@ -0,0 +1,633 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { usePreviewViewport } from "@/preview/components/preview-viewport"; +import { useEditor } from "@/editor/use-editor"; +import { useShiftKey } from "@/hooks/use-shift-key"; +import { masksRegistry } from "@/masks"; +import { + parseCustomMaskHandleId, + parseCustomMaskSegmentHandleId, +} from "@/masks/custom-path"; +import { appendPointToCustomMask } from "@/masks/definitions/custom"; +import { + getVisibleElementsWithBounds, + type ElementBounds, +} from "@/preview/element-bounds"; +import { + SNAP_THRESHOLD_SCREEN_PIXELS, + type SnapLine, +} from "@/preview/preview-snap"; +import type { SelectedMaskPointSelection } from "@/selection/editor-selection"; +import type { Mask, MaskInteractionResult } from "@/masks/types"; +import type { MaskableElement } from "@/timeline"; +import { registerCanceller } from "@/editor/cancel-interaction"; + +interface DragState { + trackId: string; + elementId: string; + handleId: string; + startCanvasX: number; + startCanvasY: number; + startParams: Mask["params"]; +} + +interface PendingSegmentInsertState { + trackId: string; + elementId: string; + maskId: string; + segmentIndex: number; + startClientX: number; + startClientY: number; + startCanvasX: number; + startCanvasY: number; + startParams: Mask["params"]; + bounds: ElementBounds; +} + +const SEGMENT_CLICK_DRAG_THRESHOLD_PX = 4; + +function isMaskSelectionForElement({ + trackId, + elementId, + maskId, + selection, +}: { + trackId: string; + elementId: string; + maskId: string; + selection: SelectedMaskPointSelection | null; +}): boolean { + if (!selection) { + return false; + } + return ( + selection.trackId === trackId && + selection.elementId === elementId && + selection.maskId === maskId + ); +} + +function replaceElementMask({ + masks, + updatedMask, +}: { + masks: MaskableElement["masks"]; + updatedMask: Mask; +}): Mask[] { + return (masks ?? []).map((mask) => + mask.id === updatedMask.id ? updatedMask : mask, + ); +} + +function withUpdatedMaskParams<TMask extends Mask>({ + mask, + params, +}: { + mask: TMask; + params: TMask["params"]; +}): TMask { + return { + ...mask, + params, + }; +} + +export function useMaskHandles({ + onSnapLinesChange, +}: { + onSnapLinesChange?: (lines: SnapLine[]) => void; +}) { + const editor = useEditor(); + const isShiftHeldRef = useShiftKey(); + const viewport = usePreviewViewport(); + const [activeHandleId, setActiveHandleId] = useState<string | null>(null); + const dragStateRef = useRef<DragState | null>(null); + const pendingSegmentInsertRef = useRef<PendingSegmentInsertState | null>( + null, + ); + const captureRef = useRef<{ element: HTMLElement; pointerId: number } | null>( + null, + ); + + const tracks = useEditor( + (e) => e.timeline.getPreviewTracks() ?? e.scenes.getActiveScene().tracks, + ); + const currentTime = useEditor((e) => e.playback.getCurrentTime()); + const mediaAssets = useEditor((e) => e.media.getAssets()); + const canvasSize = useEditor( + (e) => e.project.getActive().settings.canvasSize, + ); + const selectedElements = useEditor((e) => e.selection.getSelectedElements()); + const selectedMaskPointSelection = useEditor((e) => + e.selection.getSelectedMaskPointSelection(), + ); + + const elementsWithBounds = getVisibleElementsWithBounds({ + tracks, + currentTime, + canvasSize, + mediaAssets, + }); + + const selectedWithMask = + selectedElements.length === 1 + ? (() => { + const sel = selectedElements[0]; + const entry = elementsWithBounds.find( + (item) => + item.trackId === sel.trackId && item.elementId === sel.elementId, + ); + if (!entry) return null; + const element = entry.element as MaskableElement; + const masks = element.masks ?? []; + if (masks.length === 0) return null; + const activeMaskId = masks.some((mask) => + isMaskSelectionForElement({ + trackId: entry.trackId, + elementId: entry.elementId, + maskId: mask.id, + selection: selectedMaskPointSelection, + }), + ) + ? selectedMaskPointSelection?.maskId + : masks[0].id; + const mask = + masks.find((candidate) => candidate.id === activeMaskId) ?? + masks[0]; + return { ...entry, element, mask }; + })() + : null; + + const { handles: baseHandlePositions, overlays }: MaskInteractionResult = + selectedWithMask + ? (() => { + const def = masksRegistry.get(selectedWithMask.mask.type); + const { x: scaleX, y: scaleY } = viewport.getDisplayScale(); + const displayScale = (scaleX + scaleY) / 2; + return def.interaction.getInteraction({ + params: selectedWithMask.mask.params, + bounds: selectedWithMask.bounds, + displayScale, + scaleX, + scaleY, + }); + })() + : { handles: [], overlays: [] }; + + const selectedPointIds = new Set( + selectedWithMask && + isMaskSelectionForElement({ + trackId: selectedWithMask.trackId, + elementId: selectedWithMask.elementId, + maskId: selectedWithMask.mask.id, + selection: selectedMaskPointSelection, + }) + ? (selectedMaskPointSelection?.pointIds ?? []) + : [], + ); + + const handlePositions = baseHandlePositions.map((handle) => { + const parsedHandle = parseCustomMaskHandleId({ handleId: handle.id }); + if (!parsedHandle || parsedHandle.part !== "anchor") { + return handle; + } + return { + ...handle, + isSelected: selectedPointIds.has(parsedHandle.pointId), + }; + }); + + const customMaskPointIds = + selectedWithMask?.mask.type === "custom" + ? handlePositions + .filter((h) => h.kind === "point") + .map((h) => { + const parsedHandle = parseCustomMaskHandleId({ handleId: h.id }); + return parsedHandle?.part === "anchor" + ? parsedHandle.pointId + : null; + }) + .filter((id): id is string => id !== null) + : []; + const isCreatingCustomMask = + selectedWithMask?.mask.type === "custom" && + (!selectedWithMask.mask.params.closed || customMaskPointIds.length === 0); + + useEffect(() => { + if (!selectedMaskPointSelection) { + return; + } + if ( + !selectedWithMask || + selectedWithMask.mask.type !== "custom" || + !isMaskSelectionForElement({ + trackId: selectedWithMask.trackId, + elementId: selectedWithMask.elementId, + maskId: selectedWithMask.mask.id, + selection: selectedMaskPointSelection, + }) + ) { + editor.selection.clearMaskPointSelection(); + return; + } + + const availablePointIds = new Set(customMaskPointIds); + const nextSelectedPointIds = selectedMaskPointSelection.pointIds.filter( + (pointId) => availablePointIds.has(pointId), + ); + if ( + nextSelectedPointIds.length === selectedMaskPointSelection.pointIds.length + ) { + return; + } + if (nextSelectedPointIds.length === 0) { + editor.selection.clearMaskPointSelection(); + return; + } + + editor.selection.setSelectedMaskPoints({ + selection: { + ...selectedMaskPointSelection, + pointIds: nextSelectedPointIds, + }, + }); + }, [ + customMaskPointIds, + editor.selection, + selectedMaskPointSelection, + selectedWithMask, + ]); + + const updateCustomMaskPointSelection = useCallback( + ({ + pointId, + toggleSelection, + }: { + pointId: string; + toggleSelection: boolean; + }) => { + if (!selectedWithMask || selectedWithMask.mask.type !== "custom") { + return; + } + + const isSelectionForCurrentMask = isMaskSelectionForElement({ + trackId: selectedWithMask.trackId, + elementId: selectedWithMask.elementId, + maskId: selectedWithMask.mask.id, + selection: selectedMaskPointSelection, + }); + const currentPointIds = isSelectionForCurrentMask + ? (selectedMaskPointSelection?.pointIds ?? []) + : []; + const nextPointIds = toggleSelection + ? currentPointIds.includes(pointId) + ? currentPointIds.filter( + (currentPointId) => currentPointId !== pointId, + ) + : [...currentPointIds, pointId] + : [pointId]; + + if (nextPointIds.length === 0) { + editor.selection.clearMaskPointSelection(); + return; + } + + editor.selection.setSelectedMaskPoints({ + selection: { + trackId: selectedWithMask.trackId, + elementId: selectedWithMask.elementId, + maskId: selectedWithMask.mask.id, + pointIds: nextPointIds, + }, + }); + }, + [editor.selection, selectedMaskPointSelection, selectedWithMask], + ); + + const clearMaskHandleState = useCallback(() => { + dragStateRef.current = null; + pendingSegmentInsertRef.current = null; + setActiveHandleId(null); + onSnapLinesChange?.([]); + }, [onSnapLinesChange]); + + const releaseCapturedPointer = useCallback(() => { + const capture = captureRef.current; + if (!capture) return; + + if (capture.element.hasPointerCapture(capture.pointerId)) { + capture.element.releasePointerCapture(capture.pointerId); + } + + captureRef.current = null; + }, []); + + useEffect(() => { + if (!activeHandleId) return; + + return registerCanceller({ + fn: () => { + editor.timeline.discardPreview(); + clearMaskHandleState(); + releaseCapturedPointer(); + }, + }); + }, [ + activeHandleId, + clearMaskHandleState, + editor.timeline, + releaseCapturedPointer, + ]); + + const handlePointerDown = useCallback( + ({ event, handleId }: { event: React.PointerEvent; handleId: string }) => { + if (!selectedWithMask) return; + if (event.button !== 0) return; + event.stopPropagation(); + const parsedHandle = + selectedWithMask.mask.type === "custom" + ? parseCustomMaskHandleId({ handleId }) + : null; + const parsedSegmentHandle = + selectedWithMask.mask.type === "custom" + ? parseCustomMaskSegmentHandleId({ handleId }) + : null; + if (isCreatingCustomMask) { + const firstPointId = customMaskPointIds[0]; + if ( + firstPointId && + handleId === `point:${firstPointId}:anchor` && + customMaskPointIds.length >= 3 && + selectedWithMask.mask.type === "custom" + ) { + const updatedMask = withUpdatedMaskParams({ + mask: selectedWithMask.mask, + params: { + ...selectedWithMask.mask.params, + closed: true, + }, + }); + editor.timeline.updateElements({ + updates: [ + { + trackId: selectedWithMask.trackId, + elementId: selectedWithMask.elementId, + patch: { + masks: replaceElementMask({ + masks: selectedWithMask.element.masks, + updatedMask, + }), + } as Partial<MaskableElement>, + }, + ], + }); + } + return; + } + + const pos = viewport.screenToCanvas({ + clientX: event.clientX, + clientY: event.clientY, + }); + if (!pos) return; + + if (parsedSegmentHandle && selectedWithMask.mask.type === "custom") { + setActiveHandleId(handleId); + pendingSegmentInsertRef.current = { + trackId: selectedWithMask.trackId, + elementId: selectedWithMask.elementId, + maskId: selectedWithMask.mask.id, + segmentIndex: parsedSegmentHandle.segmentIndex, + startClientX: event.clientX, + startClientY: event.clientY, + startCanvasX: pos.x, + startCanvasY: pos.y, + startParams: { ...selectedWithMask.mask.params }, + bounds: selectedWithMask.bounds, + }; + const captureTarget = event.currentTarget as HTMLElement; + captureTarget.setPointerCapture(event.pointerId); + captureRef.current = { + element: captureTarget, + pointerId: event.pointerId, + }; + return; + } + + if (parsedHandle?.part === "anchor") { + updateCustomMaskPointSelection({ + pointId: parsedHandle.pointId, + toggleSelection: event.shiftKey, + }); + if (event.shiftKey) { + return; + } + } else if (selectedWithMask.mask.type === "custom") { + editor.selection.clearMaskPointSelection(); + } + + dragStateRef.current = { + trackId: selectedWithMask.trackId, + elementId: selectedWithMask.elementId, + handleId, + startCanvasX: pos.x, + startCanvasY: pos.y, + startParams: { ...selectedWithMask.mask.params }, + }; + setActiveHandleId(handleId); + const captureTarget = event.currentTarget as HTMLElement; + captureTarget.setPointerCapture(event.pointerId); + captureRef.current = { + element: captureTarget, + pointerId: event.pointerId, + }; + }, + [ + customMaskPointIds, + editor.selection, + editor.timeline, + isCreatingCustomMask, + selectedWithMask, + updateCustomMaskPointSelection, + viewport, + ], + ); + + const handleCanvasPointerDown = useCallback( + ({ event }: { event: React.PointerEvent }) => { + if (!selectedWithMask || !isCreatingCustomMask) { + return; + } + if (event.button !== 0) { + return; + } + if (selectedWithMask.mask.type !== "custom") { + return; + } + + event.stopPropagation(); + const pos = viewport.screenToCanvas({ + clientX: event.clientX, + clientY: event.clientY, + }); + if (!pos) { + return; + } + + const nextParams = appendPointToCustomMask({ + params: selectedWithMask.mask.params, + canvasPoint: pos, + bounds: selectedWithMask.bounds, + }); + const updatedMask = withUpdatedMaskParams({ + mask: selectedWithMask.mask, + params: nextParams, + }); + + editor.timeline.updateElements({ + updates: [ + { + trackId: selectedWithMask.trackId, + elementId: selectedWithMask.elementId, + patch: { + masks: replaceElementMask({ + masks: selectedWithMask.element.masks, + updatedMask, + }), + } as Partial<MaskableElement>, + }, + ], + }); + }, + [editor.timeline, isCreatingCustomMask, selectedWithMask, viewport], + ); + + const handlePointerMove = useCallback( + ({ event }: { event: React.PointerEvent }) => { + const pendingSegmentInsert = pendingSegmentInsertRef.current; + if (pendingSegmentInsert && !dragStateRef.current) { + const distance = Math.hypot( + event.clientX - pendingSegmentInsert.startClientX, + event.clientY - pendingSegmentInsert.startClientY, + ); + if (distance >= SEGMENT_CLICK_DRAG_THRESHOLD_PX) { + dragStateRef.current = { + trackId: pendingSegmentInsert.trackId, + elementId: pendingSegmentInsert.elementId, + handleId: "position", + startCanvasX: pendingSegmentInsert.startCanvasX, + startCanvasY: pendingSegmentInsert.startCanvasY, + startParams: pendingSegmentInsert.startParams, + }; + pendingSegmentInsertRef.current = null; + setActiveHandleId("position"); + } + } + + const drag = dragStateRef.current; + if (!drag || !selectedWithMask) return; + + const pos = viewport.screenToCanvas({ + clientX: event.clientX, + clientY: event.clientY, + }); + if (!pos) return; + + const deltaX = pos.x - drag.startCanvasX; + const deltaY = pos.y - drag.startCanvasY; + const def = masksRegistry.get(selectedWithMask.mask.type); + + const rawParams = def.computeParamUpdate({ + handleId: drag.handleId, + startParams: drag.startParams, + deltaX, + deltaY, + startCanvasX: drag.startCanvasX, + startCanvasY: drag.startCanvasY, + bounds: selectedWithMask.bounds, + canvasSize, + }); + const proposedParams = { ...drag.startParams, ...rawParams }; + + const snapThreshold = viewport.screenPixelsToLogicalThreshold({ + screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS, + }); + const { params: nextParams, activeLines } = isShiftHeldRef.current + ? { params: proposedParams, activeLines: [] as SnapLine[] } + : (def.interaction.snap?.({ + handleId: drag.handleId, + startParams: drag.startParams, + proposedParams, + bounds: selectedWithMask.bounds, + canvasSize, + snapThreshold, + }) ?? { params: proposedParams, activeLines: [] as SnapLine[] }); + + onSnapLinesChange?.(activeLines); + + const updatedMask = withUpdatedMaskParams({ + mask: selectedWithMask.mask, + params: nextParams as typeof selectedWithMask.mask.params, + }); + editor.timeline.previewElements({ + updates: [ + { + trackId: drag.trackId, + elementId: drag.elementId, + updates: { + masks: replaceElementMask({ + masks: selectedWithMask.element.masks, + updatedMask, + }), + } as Partial<MaskableElement>, + }, + ], + }); + }, + [ + selectedWithMask, + canvasSize, + editor, + isShiftHeldRef, + onSnapLinesChange, + viewport, + ], + ); + + const handlePointerUp = useCallback(() => { + const pendingSegmentInsert = pendingSegmentInsertRef.current; + if (pendingSegmentInsert && !dragStateRef.current) { + editor.timeline.insertCustomMaskPoint({ + trackId: pendingSegmentInsert.trackId, + elementId: pendingSegmentInsert.elementId, + maskId: pendingSegmentInsert.maskId, + segmentIndex: pendingSegmentInsert.segmentIndex, + canvasPoint: { + x: pendingSegmentInsert.startCanvasX, + y: pendingSegmentInsert.startCanvasY, + }, + bounds: pendingSegmentInsert.bounds, + }); + clearMaskHandleState(); + releaseCapturedPointer(); + return; + } + + if (dragStateRef.current) { + editor.timeline.commitPreview(); + clearMaskHandleState(); + } + releaseCapturedPointer(); + }, [clearMaskHandleState, editor, releaseCapturedPointer]); + + return { + selectedWithMask, + handlePositions, + overlays, + isCreatingCustomMask, + handleCanvasPointerDown, + activeHandleId, + handlePointerDown, + handlePointerMove, + handlePointerUp, + }; +} diff --git a/apps/web/src/masks/utils.ts b/apps/web/src/masks/utils.ts new file mode 100644 index 00000000..b1ac2c39 --- /dev/null +++ b/apps/web/src/masks/utils.ts @@ -0,0 +1,64 @@ +const LINE_PARALLEL_EPSILON = 1e-10;
+
+export function lineEdgeIntersection({
+ lineX,
+ lineY,
+ normalX,
+ normalY,
+ x1,
+ y1,
+ x2,
+ y2,
+}: {
+ lineX: number;
+ lineY: number;
+ normalX: number;
+ normalY: number;
+ x1: number;
+ y1: number;
+ x2: number;
+ y2: number;
+}): { x: number; y: number } | null {
+ const distance1 = halfPlaneSign({
+ lineX,
+ lineY,
+ normalX,
+ normalY,
+ x: x1,
+ y: y1,
+ });
+ const distance2 = halfPlaneSign({
+ lineX,
+ lineY,
+ normalX,
+ normalY,
+ x: x2,
+ y: y2,
+ });
+ const denom = distance1 - distance2;
+ if (Math.abs(denom) < LINE_PARALLEL_EPSILON) return null;
+ const t = distance1 / denom;
+ if (t < 0 || t > 1) return null;
+ return {
+ x: x1 + (x2 - x1) * t,
+ y: y1 + (y2 - y1) * t,
+ };
+}
+
+export function halfPlaneSign({
+ lineX,
+ lineY,
+ normalX,
+ normalY,
+ x,
+ y,
+}: {
+ lineX: number;
+ lineY: number;
+ normalX: number;
+ normalY: number;
+ x: number;
+ y: number;
+}): number {
+ return (x - lineX) * normalX + (y - lineY) * normalY;
+}
|
