mirror of
https://github.com/excalidraw/excalidraw
synced 2025-07-25 13:58:22 +08:00
improve freedraw rendering
This commit is contained in:
@ -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<LocalPoint[]>,
|
||||
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",
|
||||
});
|
||||
|
208
packages/element/src/freedraw.ts
Normal file
208
packages/element/src/freedraw.ts
Normal file
@ -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");
|
||||
};
|
@ -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";
|
||||
|
@ -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");
|
||||
}
|
||||
|
@ -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";
|
||||
|
||||
|
Reference in New Issue
Block a user