import { LaserPointer, type Point } from "@excalidraw/laser-pointer"; import { clamp, round, type LocalPoint } from "@excalidraw/math"; import getStroke from "perfect-freehand"; import type { StrokeOptions } from "perfect-freehand"; import type { ExcalidrawFreeDrawElement } from "./types"; export const DRAWING_CONFIGS = { default: { streamline: 0.35, simplify: 0.25, }, // for optimal performance, we use a lower streamline and simplify stylus: { streamline: 0.1, simplify: 0.1, }, } as const; /** * Calculates simulated pressure based on velocity between consecutive points. * Fast movement (large distances) -> lower pressure * Slow movement (small distances) -> higher pressure */ const calculateVelocityBasedPressure = ( points: readonly LocalPoint[], index: number, pressureSensitivity: number | undefined, maxDistance = 8, // Maximum expected distance for normalization ): number => { // Handle pressure sensitivity const sensitivity = pressureSensitivity ?? 1; // Default to 1 for backwards compatibility // If sensitivity is 0, return constant pressure if (sensitivity === 0) { return 0.6; } // First point gets highest pressure // This avoid "a dot followed by a line" effect, •== when first stroke is "slow" if (index === 0) { return 1; } const [x1, y1] = points[index - 1]; const [x2, y2] = points[index]; // Calculate distance between consecutive points const distance = Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2); // Normalize distance and invert for pressure (0 = fast/low pressure, 1 = slow/high pressure) const normalizedDistance = Math.min(distance / maxDistance, 1); const basePressure = Math.max(0.1, 1 - normalizedDistance * 0.7); // Range: 0.1 to 1.0 const constantPressure = 0.5; const pressure = constantPressure + (basePressure - constantPressure) * sensitivity; return Math.max(0.1, Math.min(1.0, pressure)); }; export const getFreedrawStroke = (element: ExcalidrawFreeDrawElement) => { // Compose points as [x, y, pressure] let points: [number, number, number][]; if (element.simulatePressure) { // Simulate pressure based on velocity between consecutive points points = element.points.map(([x, y]: LocalPoint, i) => [ x, y, calculateVelocityBasedPressure( element.points, i, element.drawingConfigs?.pressureSensitivity, ), ]); } else { const sensitivity = element.drawingConfigs?.pressureSensitivity ?? 1; points = element.points.map(([x, y]: LocalPoint, i) => { if (sensitivity === 0) { return [x, y, 0.5]; } const rawPressure = element.pressures?.[i] ?? 0.5; const amplifiedPressure = Math.pow(rawPressure, 0.6); const adjustedPressure = amplifiedPressure * sensitivity; return [x, y, clamp(adjustedPressure, 0.1, 1.0)]; }); } const streamline = element.drawingConfigs?.streamline ?? DRAWING_CONFIGS.default.streamline; const simplify = element.drawingConfigs?.simplify ?? DRAWING_CONFIGS.default.simplify; const laser = new LaserPointer({ size: element.strokeWidth, streamline, simplify, sizeMapping: ({ pressure: t }) => { if (element.drawingConfigs?.pressureSensitivity === 0) { return 0.5; } if (element.simulatePressure) { return 0.2 + t * 0.6; } return 0.2 + t * 0.8; }, }); for (const pt of points) { laser.addPoint(pt); } laser.close(); return laser.getStrokeOutline(); }; /** * Generates an SVG path for a freedraw element using LaserPointer logic. * Uses actual pressure data if available, otherwise simulates pressure based on velocity. * No streamline, smoothing, or simulation is performed. */ export const getFreeDrawSvgPath = ( element: ExcalidrawFreeDrawElement, ): string => { // legacy, for backwards compatibility if (element.drawingConfigs === null) { return _legacy_getFreeDrawSvgPath(element); } return getSvgPathFromStroke(getFreedrawStroke(element)); }; const roundPoint = (A: Point): string => { return `${round(A[0], 4, "round")},${round(A[1], 4, "round")} `; }; const average = (A: Point, B: Point): string => { return `${round((A[0] + B[0]) / 2, 4, "round")},${round( (A[1] + B[1]) / 2, 4, "round", )} `; }; const getSvgPathFromStroke = (points: Point[]): string => { const len = points.length; if (len < 2) { return ""; } let a = points[0]; let b = points[1]; if (len === 2) { return `M${roundPoint(a)}L${roundPoint(b)}`; } let result = ""; for (let i = 2, max = len - 1; i < max; i++) { a = points[i]; b = points[i + 1]; result += average(a, b); } return `M${roundPoint(points[0])}Q${roundPoint(points[1])}${average( points[1], points[2], )}${points.length > 3 ? "T" : ""}${result}L${roundPoint(points[len - 1])}`; }; function _legacy_getFreeDrawSvgPath(element: ExcalidrawFreeDrawElement) { // If input points are empty (should they ever be?) return a dot const inputPoints = element.simulatePressure ? element.points : element.points.length ? element.points.map(([x, y], i) => [x, y, element.pressures[i]]) : [[0, 0, 0.5]]; const sensitivity = element.drawingConfigs?.pressureSensitivity; // Consider changing the options for simulated pressure vs real pressure const options: StrokeOptions = { simulatePressure: element.simulatePressure, // if sensitivity is not set, times 4.25 for backwards compatibility size: element.strokeWidth * (sensitivity !== null ? 1 : 4.25), // if sensitivity is not set, set thinning to 0.6 for backwards compatibility thinning: sensitivity !== undefined ? 0.5 * sensitivity : 0.6, smoothing: 0.5, streamline: 0.5, easing: (t) => Math.sin((t * Math.PI) / 2), // https://easings.net/#easeOutSine last: !!element.lastCommittedPoint, // LastCommittedPoint is added on pointerup }; return _legacy_getSvgPathFromStroke( getStroke(inputPoints as number[][], options), ); } const med = (A: number[], B: number[]) => { return [(A[0] + B[0]) / 2, (A[1] + B[1]) / 2]; }; // Trim SVG path data so number are each two decimal points. This // improves SVG exports, and prevents rendering errors on points // with long decimals. const TO_FIXED_PRECISION = /(\s?[A-Z]?,?-?[0-9]*\.[0-9]{0,2})(([0-9]|e|-)*)/g; const _legacy_getSvgPathFromStroke = (points: number[][]): string => { if (!points.length) { return ""; } const max = points.length - 1; return points .reduce( (acc, point, i, arr) => { if (i === max) { acc.push(point, med(point, arr[0]), "L", arr[0], "Z"); } else { acc.push(point, med(point, arr[i + 1])); } return acc; }, ["M", points[0], "Q"], ) .join(" ") .replace(TO_FIXED_PRECISION, "$1"); };