diff --git a/packages/element/src/Shape.ts b/packages/element/src/Shape.ts index 4def419574..16da46381a 100644 --- a/packages/element/src/Shape.ts +++ b/packages/element/src/Shape.ts @@ -3,8 +3,6 @@ import { simplify } from "points-on-curve"; import { pointFrom, pointDistance, type LocalPoint } from "@excalidraw/math"; import { ROUGHNESS, isTransparent, assertNever } from "@excalidraw/common"; -import type { Mutable } from "@excalidraw/common/utility-types"; - import type { EmbedsValidationStatus } from "@excalidraw/excalidraw/types"; import type { ElementShapes } from "@excalidraw/excalidraw/scene/types"; @@ -22,6 +20,8 @@ import { canChangeRoundness } from "./comparisons"; import { generateFreeDrawShape } from "./renderElement"; import { getArrowheadPoints, getDiamondPoints } from "./bounds"; +import { getFreedrawStroke } from "./freedraw"; + import type { ExcalidrawElement, NonDeletedExcalidrawElement, @@ -514,12 +514,19 @@ export const _generateElementShape = ( generateFreeDrawShape(element); if (isPathALoop(element.points)) { - // generate rough polygon to fill freedraw shape - const simplifiedPoints = simplify( - element.points as Mutable, - 0.75, - ); - shape = generator.curve(simplifiedPoints as [number, number][], { + let points; + if (element.pressureSensitivity === null) { + // legacy freedraw + points = simplify(element.points as LocalPoint[], 0.75); + } else { + // new freedraw + const stroke = getFreedrawStroke(element); + points = stroke + .slice(0, Math.floor(stroke.length / 2)) + .map((p) => pointFrom(p[0], p[1])); + } + + shape = generator.curve(points, { ...generateRoughOptions(element), stroke: "none", }); diff --git a/packages/element/src/freedraw.ts b/packages/element/src/freedraw.ts new file mode 100644 index 0000000000..168ae79ebd --- /dev/null +++ b/packages/element/src/freedraw.ts @@ -0,0 +1,208 @@ +import { LaserPointer, type Point } from "@excalidraw/laser-pointer"; + +import { round, type LocalPoint } from "@excalidraw/math"; + +import getStroke from "perfect-freehand"; + +import type { StrokeOptions } from "perfect-freehand"; + +import type { ExcalidrawFreeDrawElement } from "./types"; + +/** + * 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 | null, + 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 + + // Apply pressure sensitivity (range 0-1): + // sensitivity = 0 -> constant pressure (handled above) + // sensitivity = 1 -> full velocity-based variation + // sensitivity < 1 -> interpolate between constant and velocity-based + 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.pressureSensitivity, + ), + ]); + } else { + points = element.points.map(([x, y]: LocalPoint, i) => [ + x, + y, + element.pressures?.[i] ?? 0.5, + ]); + } + + const laser = new LaserPointer({ + size: element.strokeWidth, + streamline: 0.62, + simplify: 0.3, + sizeMapping: ({ pressure: t }) => 0.2 + t, + }); + + 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.pressureSensitivity === 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.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 !== null ? 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"); +}; diff --git a/packages/element/src/index.ts b/packages/element/src/index.ts index 93024f9940..8339b4ab50 100644 --- a/packages/element/src/index.ts +++ b/packages/element/src/index.ts @@ -91,6 +91,7 @@ export * from "./embeddable"; export * from "./flowchart"; export * from "./fractionalIndex"; export * from "./frame"; +export * from "./freedraw"; export * from "./groups"; export * from "./heading"; export * from "./image"; diff --git a/packages/element/src/renderElement.ts b/packages/element/src/renderElement.ts index b1dfe16483..ea8f303088 100644 --- a/packages/element/src/renderElement.ts +++ b/packages/element/src/renderElement.ts @@ -1,5 +1,4 @@ import rough from "roughjs/bin/rough"; -import { getStroke } from "perfect-freehand"; import { isRightAngleRads } from "@excalidraw/math"; @@ -58,6 +57,8 @@ import { getCornerRadius } from "./shapes"; import { ShapeCache } from "./ShapeCache"; +import { getFreeDrawSvgPath } from "./freedraw"; + import type { ExcalidrawElement, ExcalidrawTextElement, @@ -70,7 +71,6 @@ import type { ElementsMap, } from "./types"; -import type { StrokeOptions } from "perfect-freehand"; import type { RoughCanvas } from "roughjs/bin/canvas"; // using a stronger invert (100% vs our regular 93%) and saturate @@ -1032,61 +1032,3 @@ export function generateFreeDrawShape(element: ExcalidrawFreeDrawElement) { export function getFreeDrawPath2D(element: ExcalidrawFreeDrawElement) { return pathsCache.get(element); } - -export function 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.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 !== null ? 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 getSvgPathFromStroke(getStroke(inputPoints as number[][], options)); -} - -function 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; - -function 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"); -} diff --git a/packages/excalidraw/index.tsx b/packages/excalidraw/index.tsx index 478ecc42f0..93d2504cd1 100644 --- a/packages/excalidraw/index.tsx +++ b/packages/excalidraw/index.tsx @@ -248,7 +248,7 @@ export { loadSceneOrLibraryFromBlob, loadLibraryFromBlob, } from "./data/blob"; -export { getFreeDrawSvgPath } from "@excalidraw/element"; +export { getFreeDrawSvgPath } from "@excalidraw/element/freedraw"; export { mergeLibraryItems, getLibraryItemsHash } from "./data/library"; export { isLinearElement } from "@excalidraw/element";