Freedraw experiment 1

This commit is contained in:
Mark Tolmacs
2025-10-22 18:29:45 +02:00
33 changed files with 1663 additions and 905 deletions

View File

@@ -5,6 +5,7 @@ import {
invariant,
rescalePoints,
sizeOf,
STROKE_WIDTH,
} from "@excalidraw/common";
import {
@@ -832,9 +833,15 @@ export const getArrowheadPoints = (
// This value is selected by minimizing a minimum size with the last segment of the arrowhead
const lengthMultiplier =
arrowhead === "diamond" || arrowhead === "diamond_outline" ? 0.25 : 0.5;
const minSize = Math.min(size, length * lengthMultiplier);
const xs = x2 - nx * minSize;
const ys = y2 - ny * minSize;
// make arrowheads bigger for thick strokes
const strokeWidthMultiplier =
element.strokeWidth >= STROKE_WIDTH.extraBold ? 1.5 : 1;
const adjustedSize =
Math.min(size, length * lengthMultiplier) * strokeWidthMultiplier;
const xs = x2 - nx * adjustedSize;
const ys = y2 - ny * adjustedSize;
if (
arrowhead === "dot" ||
@@ -883,7 +890,7 @@ export const getArrowheadPoints = (
const [px, py] = element.points.length > 1 ? element.points[1] : [0, 0];
[ox, oy] = pointRotateRads(
pointFrom(x2 + minSize * 2, y2),
pointFrom(x2 + adjustedSize * 2, y2),
pointFrom(x2, y2),
Math.atan2(py - y2, px - x2) as Radians,
);
@@ -894,7 +901,7 @@ export const getArrowheadPoints = (
: [0, 0];
[ox, oy] = pointRotateRads(
pointFrom(x2 - minSize * 2, y2),
pointFrom(x2 - adjustedSize * 2, y2),
pointFrom(x2, y2),
Math.atan2(y2 - py, x2 - px) as Radians,
);

View File

@@ -0,0 +1,278 @@
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, PointerType } from "./types";
export const STROKE_OPTIONS: Record<
PointerType | "default",
{ streamline: number; simplify: number }
> = {
default: {
streamline: 0.35,
simplify: 0.1,
},
mouse: {
streamline: 0.6,
simplify: 0.1,
},
pen: {
// for optimal performance, we use a lower streamline and simplify
streamline: 0.2,
simplify: 0.1,
},
touch: {
streamline: 0.65,
simplify: 0.1,
},
} as const;
export const getFreedrawConfig = (eventType: string | null | undefined) => {
return (
STROKE_OPTIONS[(eventType as PointerType | null) || "default"] ||
STROKE_OPTIONS.default
);
};
/**
* 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,
fixedStrokeWidth: boolean | undefined,
maxDistance = 8, // Maximum expected distance for normalization
): number => {
if (fixedStrokeWidth) {
return 1;
}
// 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);
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.freedrawOptions?.fixedStrokeWidth) {
points = element.points.map(
([x, y]: LocalPoint): [number, number, number] => [x, y, 1],
);
} else 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.freedrawOptions?.fixedStrokeWidth,
),
]);
} else {
points = element.points.map(([x, y]: LocalPoint, i) => {
const rawPressure = element.pressures?.[i] ?? 0.5;
const amplifiedPressure = Math.pow(rawPressure, 0.6);
const adjustedPressure = amplifiedPressure;
return [x, y, clamp(adjustedPressure, 0.1, 1.0)];
});
}
const streamline =
element.freedrawOptions?.streamline ?? STROKE_OPTIONS.default.streamline;
const simplify =
element.freedrawOptions?.simplify ?? STROKE_OPTIONS.default.simplify;
const laser = new LaserPointer({
size: element.strokeWidth,
streamline,
simplify,
sizeMapping: ({ pressure: t }) => {
if (element.freedrawOptions?.fixedStrokeWidth) {
return 0.6;
}
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.freedrawOptions === null) {
return _legacy_getFreeDrawSvgPath(element);
}
return _transition_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",
)} `;
};
export 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 _transition_getFreeDrawSvgPath(element: ExcalidrawFreeDrawElement) {
const inputPoints = element.simulatePressure
? element.points
: element.points.length
? element.points.map(([x, y], i) => [x, y, element.pressures[i]])
: [[0, 0, 0.5]];
// Consider changing the options for simulated pressure vs real pressure
const options: StrokeOptions = {
simulatePressure: element.simulatePressure,
size: element.strokeWidth,
thinning: 0.6,
smoothing: 0.5,
streamline: 0.5,
easing: (t) => {
if (element.freedrawOptions?.fixedStrokeWidth) {
return 0.5;
}
return Math.sin((t * Math.PI) / 2) * 0.65;
}, // https://easings.net/#easeOutSine
last: !!element.lastCommittedPoint, // LastCommittedPoint is added on pointerup
};
return _legacy_getSvgPathFromStroke(
getStroke(inputPoints as number[][], options),
);
}
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]];
// Consider changing the options for simulated pressure vs real pressure
const options: StrokeOptions = {
simulatePressure: element.simulatePressure,
size: element.strokeWidth * 4.25,
thinning: 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");
};

View File

@@ -94,6 +94,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";

View File

@@ -445,6 +445,7 @@ export const newFreeDrawElement = (
points?: ExcalidrawFreeDrawElement["points"];
simulatePressure: boolean;
pressures?: ExcalidrawFreeDrawElement["pressures"];
strokeOptions?: ExcalidrawFreeDrawElement["freedrawOptions"];
} & ElementConstructorOpts,
): NonDeleted<ExcalidrawFreeDrawElement> => {
return {
@@ -453,6 +454,11 @@ export const newFreeDrawElement = (
pressures: opts.pressures || [],
simulatePressure: opts.simulatePressure,
lastCommittedPoint: null,
freedrawOptions: opts.strokeOptions || {
fixedStrokeWidth: true,
streamline: 0.25,
simplify: 0.1,
},
};
};

View File

@@ -1,5 +1,5 @@
import rough from "roughjs/bin/rough";
import { getStroke } from "perfect-freehand";
import { getStroke, StrokeOptions } from "perfect-freehand";
import {
type GlobalPoint,
@@ -66,6 +66,8 @@ import { getCornerRadius } from "./utils";
import { ShapeCache } from "./shape";
import { getFreeDrawSvgPath } from "./freedraw";
import type {
ExcalidrawElement,
ExcalidrawTextElement,
@@ -78,7 +80,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
@@ -1046,10 +1047,6 @@ export function getFreeDrawPath2D(element: ExcalidrawFreeDrawElement) {
return pathsCache.get(element);
}
export function getFreeDrawSvgPath(element: ExcalidrawFreeDrawElement) {
return getSvgPathFromStroke(getFreedrawOutlinePoints(element));
}
export function getFreedrawOutlineAsSegments(
element: ExcalidrawFreeDrawElement,
points: readonly [number, number][],
@@ -1126,35 +1123,3 @@ export function getFreedrawOutlinePoints(element: ExcalidrawFreeDrawElement) {
return getStroke(inputPoints as number[][], options) as [number, number][];
}
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");
}

View File

@@ -21,6 +21,7 @@ import {
assertNever,
COLOR_PALETTE,
LINE_POLYGON_POINT_MERGE_DISTANCE,
STROKE_WIDTH,
} from "@excalidraw/common";
import { RoughGenerator } from "roughjs/bin/generator";
@@ -202,7 +203,7 @@ export const generateRoughOptions = (
// hachureGap because if not specified, roughjs uses strokeWidth to
// calculate them (and we don't want the fills to be modified)
fillWeight: element.strokeWidth / 2,
hachureGap: element.strokeWidth * 4,
hachureGap: Math.min(element.strokeWidth, STROKE_WIDTH.bold) * 4,
roughness: adjustRoughness(element),
stroke: element.strokeColor,
preserveVertices:
@@ -806,15 +807,21 @@ 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][], {
...generateRoughOptions(element),
stroke: "none",
});
const points =
element.freedrawOptions === null
? simplify(element.points as LocalPoint[], 0.75)
: simplify(element.points as LocalPoint[], 1.5);
shape =
element.freedrawOptions === null
? generator.curve(points, {
...generateRoughOptions(element),
stroke: "none",
})
: generator.polygon(points, {
...generateRoughOptions(element),
stroke: "none",
});
} else {
shape = null;
}

View File

@@ -380,6 +380,11 @@ export type ExcalidrawFreeDrawElement = _ExcalidrawElementBase &
pressures: readonly number[];
simulatePressure: boolean;
lastCommittedPoint: LocalPoint | null;
freedrawOptions: {
streamline?: number;
simplify?: number;
fixedStrokeWidth?: boolean;
} | null;
}>;
export type FileId = string & { _brand: "FileId" };