diff options
Diffstat (limited to 'apps/web/src/masks/freeform/definition.ts')
| -rw-r--r-- | apps/web/src/masks/freeform/definition.ts | 656 |
1 files changed, 656 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, + }, + }; +} |
