summaryrefslogtreecommitdiff
path: root/apps/web/src/masks/freeform
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-04-29 18:41:15 +0200
committerMaze Winther <mazewinther@gmail.com>2026-05-02 03:27:23 +0200
commit79dcec8a2ec2f5cad9be95e5b52fbfa752fb6ac4 (patch)
tree99ff9393c4a550669ebac921c6a0535437a39413 /apps/web/src/masks/freeform
parent97f8d1968f186028e41f83b04c0897130e6c69f4 (diff)
downloadopencut-79dcec8a2ec2f5cad9be95e5b52fbfa752fb6ac4.tar.gz
refactor: split builtin and freeform masks
Made-with: Cursor
Diffstat (limited to 'apps/web/src/masks/freeform')
-rw-r--r--apps/web/src/masks/freeform/definition.ts656
-rw-r--r--apps/web/src/masks/freeform/path.ts835
2 files changed, 1491 insertions, 0 deletions
diff --git a/apps/web/src/masks/freeform/definition.ts b/apps/web/src/masks/freeform/definition.ts
new file mode 100644
index 00000000..b657218d
--- /dev/null
+++ b/apps/web/src/masks/freeform/definition.ts
@@ -0,0 +1,656 @@
+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 {
+ FreeformPathMask,
+ FreeformPathMaskParams,
+ MaskDefinition,
+ MaskHandlePosition,
+ MaskOverlay,
+ MaskParamUpdateArgs,
+} from "@/masks/types";
+import {
+ buildFreeformPath2D,
+ buildFreeformSvgPath,
+ freeformCanvasPointToLocal,
+ findClosestPointOnFreeformSegment,
+ getFreeformCanvasSegments,
+ getFreeformCanvasGeometry,
+ getFreeformLocalBounds,
+ getFreeformSegmentCount,
+ insertPointIntoFreeformSegment,
+ recenterFreeformPath,
+ type FreeformPathPoint,
+} from "@/masks/freeform/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 FREEFORM_PATH_MASK_PARAMS: ParamDefinition<keyof FreeformPathMaskParams & 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 getFreeformDisplayHandles({
+ params,
+ displayScale,
+ bounds,
+}: {
+ params: FreeformPathMaskParams;
+ displayScale: number;
+ bounds: ElementBounds;
+}): {
+ handles: MaskHandlePosition[];
+ overlays: MaskOverlay[];
+} {
+ const points = params.path;
+ const geometry = getFreeformCanvasGeometry({
+ 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: buildFreeformSvgPath({
+ 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(
+ ...getFreeformCanvasSegments({
+ points,
+ centerX: params.centerX,
+ centerY: params.centerY,
+ rotation: params.rotation,
+ scale: params.scale,
+ bounds,
+ closed: true,
+ }).map((segment): MaskOverlay => ({
+ id: `segment:${segment.index}`,
+ type: "canvas-path" as const,
+ pathData: segment.pathData,
+ coordinateSpace: "canvas" as const,
+ handleId: { kind: "segment", index: segment.index },
+ cursor: PEN_CURSOR,
+ strokeOpacity: 0,
+ strokeWidth: segmentStrokeWidth,
+ })),
+ );
+ }
+
+ const localBounds = getFreeformLocalBounds({ 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: { kind: "anchor", pointId: point.id },
+ x: point.anchor.x,
+ y: point.anchor.y,
+ cursor: params.closed ? "move" : "pointer",
+ kind: "point",
+ });
+ });
+
+ return {
+ handles,
+ overlays,
+ };
+}
+
+function updateFreeformPathMaskPoint({
+ points,
+ pointId,
+ updater,
+}: {
+ points: FreeformPathPoint[];
+ pointId: string;
+ updater: (point: FreeformPathPoint) => FreeformPathPoint;
+}) {
+ return points.map((point) => (point.id === pointId ? updater(point) : point));
+}
+
+function computeFreeformParamUpdate({
+ handleId,
+ startParams,
+ deltaX,
+ deltaY,
+ startCanvasX,
+ startCanvasY,
+ bounds,
+}: MaskParamUpdateArgs<FreeformPathMaskParams>): Partial<FreeformPathMaskParams> {
+ if (handleId.kind === "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.kind === "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.kind === "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.kind === "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),
+ };
+ }
+
+ if (handleId.kind !== "anchor" && handleId.kind !== "tangent") {
+ return {};
+ }
+
+ const points = startParams.path;
+ const currentPoint = {
+ x: startCanvasX + deltaX,
+ y: startCanvasY + deltaY,
+ };
+ const localPoint = freeformCanvasPointToLocal({
+ point: currentPoint,
+ centerX: startParams.centerX,
+ centerY: startParams.centerY,
+ rotation: startParams.rotation,
+ scale: startParams.scale,
+ bounds,
+ });
+
+ return {
+ path: updateFreeformPathMaskPoint({
+ points,
+ pointId: handleId.pointId,
+ updater: (point) => {
+ if (handleId.kind === "anchor") {
+ return {
+ ...point,
+ x: localPoint.x,
+ y: localPoint.y,
+ };
+ }
+
+ if (handleId.side === "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 freeformMaskDefinition: MaskDefinition<"freeform"> = {
+ type: "freeform",
+ name: "Pen tool",
+ features: {
+ hasPosition: true,
+ hasRotation: true,
+ sizeMode: "uniform",
+ },
+ params: FREEFORM_PATH_MASK_PARAMS,
+ interaction: {
+ getInteraction({
+ params,
+ bounds,
+ displayScale,
+ scaleX: _scaleX,
+ scaleY: _scaleY,
+ }) {
+ return getFreeformDisplayHandles({ params, bounds, displayScale });
+ },
+ snap({
+ handleId,
+ proposedParams,
+ startParams,
+ bounds,
+ canvasSize,
+ snapThreshold,
+ }) {
+ const points = startParams.path;
+ const localBounds = getFreeformLocalBounds({ points, bounds });
+ if (!startParams.closed || !localBounds) {
+ return {
+ params: proposedParams,
+ activeLines: [],
+ };
+ }
+
+ const position = {
+ x: proposedParams.centerX * bounds.width,
+ y: proposedParams.centerY * bounds.height,
+ };
+
+ if (handleId.kind === "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.kind === "rotation") {
+ const { snappedRotation } = snapRotation({
+ proposedRotation: proposedParams.rotation,
+ });
+ return {
+ params: {
+ ...proposedParams,
+ rotation: snappedRotation,
+ },
+ activeLines: [],
+ };
+ }
+
+ if (handleId.kind === "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<FreeformPathMask, "id"> {
+ return {
+ type: "freeform",
+ params: {
+ feather: 0,
+ inverted: false,
+ strokeColor: "#ffffff",
+ strokeWidth: 0,
+ strokeAlign: "center",
+ path: [],
+ closed: false,
+ centerX: 0,
+ centerY: 0,
+ rotation: 0,
+ scale: 1,
+ },
+ };
+ },
+ computeParamUpdate: computeFreeformParamUpdate,
+ isActive(params) {
+ return params.closed;
+ },
+ renderer: {
+ body: {
+ kind: "fillPath",
+ buildPath({ resolvedParams, width, height }) {
+ const params = resolvedParams;
+ const points = params.path;
+ if (!params.closed) {
+ return new Path2D();
+ }
+
+ return buildFreeformPath2D({
+ 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,
+ });
+ },
+ },
+ stroke: {
+ kind: "renderStroke",
+ renderStroke({ resolvedParams, ctx, width, height }) {
+ const params = resolvedParams;
+ if (!params.closed) {
+ return;
+ }
+
+ const points = params.path;
+ const path = buildFreeformPath2D({
+ 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 appendPointToFreeformPathMask({
+ params,
+ canvasPoint,
+ bounds,
+}: {
+ params: FreeformPathMaskParams;
+ canvasPoint: { x: number; y: number };
+ bounds: ElementBounds;
+}): FreeformPathMaskParams {
+ 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 = freeformCanvasPointToLocal({
+ 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 = recenterFreeformPath({
+ 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 insertPointOnFreeformSegment({
+ params,
+ segmentIndex,
+ canvasPoint,
+ bounds,
+ pointId = generateUUID(),
+}: {
+ params: FreeformPathMaskParams;
+ segmentIndex: number;
+ canvasPoint: { x: number; y: number };
+ bounds: ElementBounds;
+ pointId?: string;
+}): { params: FreeformPathMaskParams; pointId: string } | null {
+ const points = params.path;
+ if (getFreeformSegmentCount({ points, closed: params.closed }) === 0) {
+ return null;
+ }
+
+ const closestPoint = findClosestPointOnFreeformSegment({
+ 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 = insertPointIntoFreeformSegment({
+ points,
+ segmentIndex,
+ pointId,
+ t: closestPoint.t,
+ closed: params.closed,
+ });
+ if (nextPoints.length === points.length) {
+ return null;
+ }
+
+ const recentered = recenterFreeformPath({
+ 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/freeform/path.ts b/apps/web/src/masks/freeform/path.ts
new file mode 100644
index 00000000..fa6217c2
--- /dev/null
+++ b/apps/web/src/masks/freeform/path.ts
@@ -0,0 +1,835 @@
+import type { ElementBounds } from "@/preview/element-bounds";
+
+export interface FreeformPathPoint {
+ id: string;
+ x: number;
+ y: number;
+ inX: number;
+ inY: number;
+ outX: number;
+ outY: number;
+}
+
+function isFreeformPathPoint(value: unknown): value is FreeformPathPoint {
+ if (!value || typeof value !== "object") {
+ return false;
+ }
+
+ return (
+ "id" in value &&
+ typeof value.id === "string" &&
+ "x" in value &&
+ typeof value.x === "number" &&
+ "y" in value &&
+ typeof value.y === "number" &&
+ "inX" in value &&
+ typeof value.inX === "number" &&
+ "inY" in value &&
+ typeof value.inY === "number" &&
+ "outX" in value &&
+ typeof value.outX === "number" &&
+ "outY" in value &&
+ typeof value.outY === "number"
+ );
+}
+
+export function parseFreeformPath({
+ path,
+}: {
+ path: string;
+}): FreeformPathPoint[] {
+ if (!path) {
+ return [];
+ }
+
+ try {
+ const parsed = JSON.parse(path);
+ return Array.isArray(parsed) ? parsed.filter(isFreeformPathPoint) : [];
+ } catch {
+ return [];
+ }
+}
+
+export function serializeFreeformPath({
+ points,
+}: {
+ points: FreeformPathPoint[];
+}): string {
+ return JSON.stringify(points);
+}
+
+export function removeFreeformPathPoints({
+ points,
+ pointIds,
+}: {
+ points: FreeformPathPoint[];
+ pointIds: string[];
+}): FreeformPathPoint[] {
+ if (pointIds.length === 0) {
+ return points;
+ }
+ const pointIdsToRemove = new Set(pointIds);
+ return points.filter((point) => !pointIdsToRemove.has(point.id));
+}
+
+export function getFreeformPathClosedStateAfterPointRemoval({
+ 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 getFreeformCenterCanvasPoint({
+ 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 freeformLocalPointToCanvas({
+ 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 = getFreeformCenterCanvasPoint({ 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 freeformCanvasPointToLocal({
+ 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 = getFreeformCenterCanvasPoint({ 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 getFreeformCanvasGeometry({
+ points,
+ centerX,
+ centerY,
+ rotation,
+ scale,
+ bounds,
+}: {
+ points: FreeformPathPoint[];
+ centerX: number;
+ centerY: number;
+ rotation: number;
+ scale: number;
+ bounds: ElementBounds;
+}) {
+ const anchors = points.map((point) => ({
+ id: point.id,
+ anchor: freeformLocalPointToCanvas({
+ point: { x: point.x, y: point.y },
+ centerX,
+ centerY,
+ rotation,
+ scale,
+ bounds,
+ }),
+ inHandle: freeformLocalPointToCanvas({
+ point: { x: point.x + point.inX, y: point.y + point.inY },
+ centerX,
+ centerY,
+ rotation,
+ scale,
+ bounds,
+ }),
+ outHandle: freeformLocalPointToCanvas({
+ 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 FreeformCanvasSegment {
+ 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,
+ b,
+}: {
+ 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,
+ b,
+ t,
+}: {
+ 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 getFreeformSegmentIndices({
+ points,
+ segmentIndex,
+ closed,
+}: {
+ points: FreeformPathPoint[];
+ segmentIndex: number;
+ closed: boolean;
+}): { startIndex: number; endIndex: number } | null {
+ const segmentCount = getFreeformSegmentCount({ points, closed });
+ if (segmentIndex < 0 || segmentIndex >= segmentCount) {
+ return null;
+ }
+
+ return {
+ startIndex: segmentIndex,
+ endIndex: (segmentIndex + 1) % points.length,
+ };
+}
+
+export function getFreeformSegmentCount({
+ points,
+ closed,
+}: {
+ points: FreeformPathPoint[];
+ closed: boolean;
+}): number {
+ if (points.length < 2) {
+ return 0;
+ }
+ return closed ? points.length : points.length - 1;
+}
+
+export function getFreeformCanvasSegments({
+ points,
+ centerX,
+ centerY,
+ rotation,
+ scale,
+ bounds,
+ closed,
+}: {
+ points: FreeformPathPoint[];
+ centerX: number;
+ centerY: number;
+ rotation: number;
+ scale: number;
+ bounds: ElementBounds;
+ closed: boolean;
+}): FreeformCanvasSegment[] {
+ const geometry = getFreeformCanvasGeometry({
+ points,
+ centerX,
+ centerY,
+ rotation,
+ scale,
+ bounds,
+ });
+ const segmentCount = getFreeformSegmentCount({ 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 findClosestPointOnFreeformSegment({
+ points,
+ segmentIndex,
+ canvasPoint,
+ centerX,
+ centerY,
+ rotation,
+ scale,
+ bounds,
+ closed,
+}: {
+ points: FreeformPathPoint[];
+ segmentIndex: number;
+ canvasPoint: CanvasPoint;
+ centerX: number;
+ centerY: number;
+ rotation: number;
+ scale: number;
+ bounds: ElementBounds;
+ closed: boolean;
+}): { t: number; point: CanvasPoint } | null {
+ const segment = getFreeformCanvasSegments({
+ 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({
+ a: canvasPoint,
+ b: 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({ a: canvasPoint, b: 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({
+ a: canvasPoint,
+ b: 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 insertPointIntoFreeformSegment({
+ points,
+ segmentIndex,
+ pointId,
+ t,
+ closed,
+}: {
+ points: FreeformPathPoint[];
+ segmentIndex: number;
+ pointId: string;
+ t: number;
+ closed: boolean;
+}): FreeformPathPoint[] {
+ const indices = getFreeformSegmentIndices({
+ 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({ a: p0, b: p1, t: clampedT });
+ const p12 = lerpPoint({ a: p1, b: p2, t: clampedT });
+ const p23 = lerpPoint({ a: p2, b: p3, t: clampedT });
+ const p012 = lerpPoint({ a: p01, b: p12, t: clampedT });
+ const p123 = lerpPoint({ a: p12, b: p23, t: clampedT });
+ const splitPoint = lerpPoint({ a: p012, b: p123, t: 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 getFreeformLocalBounds({
+ points,
+ bounds,
+}: {
+ points: FreeformPathPoint[];
+ 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 recenterFreeformPath({
+ points,
+ centerX,
+ centerY,
+ rotation,
+ scale,
+ bounds,
+}: {
+ points: FreeformPathPoint[];
+ centerX: number;
+ centerY: number;
+ rotation: number;
+ scale: number;
+ bounds: ElementBounds;
+}) {
+ if (points.length === 0) {
+ return { centerX, centerY, points };
+ }
+
+ const geometry = getFreeformCanvasGeometry({
+ 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 = freeformCanvasPointToLocal({
+ point: point.anchor,
+ centerX: nextCenterLocal.x,
+ centerY: nextCenterLocal.y,
+ rotation,
+ scale,
+ bounds,
+ });
+ const inHandle = freeformCanvasPointToLocal({
+ point: point.inHandle,
+ centerX: nextCenterLocal.x,
+ centerY: nextCenterLocal.y,
+ rotation,
+ scale,
+ bounds,
+ });
+ const outHandle = freeformCanvasPointToLocal({
+ 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 buildFreeformPath2D({
+ points,
+ centerX,
+ centerY,
+ rotation,
+ scale,
+ bounds,
+ closed,
+}: {
+ points: FreeformPathPoint[];
+ 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 = getFreeformCanvasGeometry({
+ 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 buildFreeformSvgPath({
+ points,
+ centerX,
+ centerY,
+ rotation,
+ scale,
+ bounds,
+ closed,
+}: {
+ points: FreeformPathPoint[];
+ centerX: number;
+ centerY: number;
+ rotation: number;
+ scale: number;
+ bounds: ElementBounds;
+ closed: boolean;
+}): string {
+ if (points.length === 0) {
+ return "";
+ }
+
+ const geometry = getFreeformCanvasGeometry({
+ 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(" ");
+}