From aa3e1341fb77f9d964c0db7bfa0c2b1478808525 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Wed, 22 Oct 2025 14:28:32 +0200 Subject: [PATCH] feat: Simplify the freedraw element at creation or restoration --- packages/element/src/renderElement.ts | 5 +- packages/element/src/utils.ts | 22 ++++++++ packages/excalidraw/components/App.tsx | 6 +-- packages/excalidraw/data/restore.ts | 18 ++++++- packages/excalidraw/eraser/index.ts | 6 +-- packages/math/src/curve.ts | 75 ++++++++++++++++++++++++++ 6 files changed, 118 insertions(+), 14 deletions(-) diff --git a/packages/element/src/renderElement.ts b/packages/element/src/renderElement.ts index 8c17863ee0..6441d8b3dc 100644 --- a/packages/element/src/renderElement.ts +++ b/packages/element/src/renderElement.ts @@ -1052,15 +1052,14 @@ export function getFreeDrawSvgPath(element: ExcalidrawFreeDrawElement) { export function getFreedrawOutlineAsSegments( element: ExcalidrawFreeDrawElement, - points: [number, number][], - elementsMap: ElementsMap, + points: readonly [number, number][], ) { const bounds = getElementBounds( { ...element, angle: 0 as Radians, }, - elementsMap, + new Map(), ); const center = pointFrom( (bounds[0] + bounds[2]) / 2, diff --git a/packages/element/src/utils.ts b/packages/element/src/utils.ts index 44b0fe79c6..7c5a90ff48 100644 --- a/packages/element/src/utils.ts +++ b/packages/element/src/utils.ts @@ -10,6 +10,7 @@ import { curveCatmullRomCubicApproxPoints, curveOffsetPoints, lineSegment, + perpendicularDistance, pointDistance, pointFrom, pointFromArray, @@ -481,3 +482,24 @@ export const getCornerRadius = (x: number, element: ExcalidrawElement) => { return 0; }; + +export function shouldSkipFreedrawPoint( + points: readonly LocalPoint[], + dx: number, + dy: number, + epsilon: number = 0.5, +) { + const lastPoint = points.length > 0 && points[points.length - 1]; + const nextToLastPoint = points.length > 1 && points[points.length - 2]; + return ( + (lastPoint && lastPoint[0] === dx && lastPoint[1] === dy) || + // NOTE: Apply a simplification algorithm to reduce number of points + (nextToLastPoint && lastPoint + ? perpendicularDistance( + pointFrom(dx, dy), + lineSegment(nextToLastPoint, lastPoint), + ) < epsilon + : !points[0] || + pointDistance(points[0], pointFrom(dx, dy)) < epsilon) + ); +} diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 470c72081e..76ac09257e 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -239,6 +239,7 @@ import { StoreDelta, type ApplyToOptions, positionElementsOnGrid, + shouldSkipFreedrawPoint, } from "@excalidraw/element"; import type { LocalPoint, Radians } from "@excalidraw/math"; @@ -8922,10 +8923,7 @@ class App extends React.Component { const points = newElement.points; const dx = pointerCoords.x - newElement.x; const dy = pointerCoords.y - newElement.y; - - const lastPoint = points.length > 0 && points[points.length - 1]; - const discardPoint = - lastPoint && lastPoint[0] === dx && lastPoint[1] === dy; + const discardPoint = shouldSkipFreedrawPoint(points, dx, dy, 0.5); if (!discardPoint) { const pressures = newElement.simulatePressure diff --git a/packages/excalidraw/data/restore.ts b/packages/excalidraw/data/restore.ts index 34bdc8f57f..16efe3dfda 100644 --- a/packages/excalidraw/data/restore.ts +++ b/packages/excalidraw/data/restore.ts @@ -1,4 +1,8 @@ -import { isFiniteNumber, pointFrom } from "@excalidraw/math"; +import { + curveSimplifyWithRDP, + isFiniteNumber, + pointFrom, +} from "@excalidraw/math"; import { DEFAULT_FONT_FAMILY, @@ -18,7 +22,11 @@ import { normalizeLink, getLineHeight, } from "@excalidraw/common"; -import { getNonDeletedElements, isValidPolygon } from "@excalidraw/element"; +import { + getNonDeletedElements, + isFreeDrawElement, + isValidPolygon, +} from "@excalidraw/element"; import { normalizeFixedPoint } from "@excalidraw/element"; import { updateElbowArrowPoints, @@ -623,6 +631,12 @@ export const restoreElements = ( (element as Mutable).endBinding = null; } } + + if (isFreeDrawElement(element)) { + Object.assign(element, { + points: curveSimplifyWithRDP(element.points, 0.5), + }); + } } // NOTE (mtolmacs): Temporary fix for extremely large arrows diff --git a/packages/excalidraw/eraser/index.ts b/packages/excalidraw/eraser/index.ts index d587bb3811..416ebd0a17 100644 --- a/packages/excalidraw/eraser/index.ts +++ b/packages/excalidraw/eraser/index.ts @@ -238,11 +238,7 @@ const eraserTest = ( // which offers a good visual precision at various zoom levels if (isFreeDrawElement(element)) { const outlinePoints = getFreedrawOutlinePoints(element); - const strokeSegments = getFreedrawOutlineAsSegments( - element, - outlinePoints, - elementsMap, - ); + const strokeSegments = getFreedrawOutlineAsSegments(element, outlinePoints); const tolerance = Math.max(2.25, 5 / zoom); // NOTE: Visually fine-tuned approximation for (const seg of strokeSegments) { diff --git a/packages/math/src/curve.ts b/packages/math/src/curve.ts index 7be0f72245..1a440e840f 100644 --- a/packages/math/src/curve.ts +++ b/packages/math/src/curve.ts @@ -1,6 +1,7 @@ import { isPoint, pointDistance, pointFrom, pointFromVector } from "./point"; import { vector, vectorNormal, vectorNormalize, vectorScale } from "./vector"; import { LegendreGaussN24CValues, LegendreGaussN24TValues } from "./constants"; +import { lineSegment } from "./segment"; import type { Curve, GlobalPoint, LineSegment, LocalPoint } from "./types"; @@ -520,3 +521,77 @@ export function curvePointAtLength

( return bezierEquation(c, t); } + +export function perpendicularDistance( + point: Point, + [lineStart, lineEnd]: LineSegment, +): number { + const dx = lineEnd[0] - lineStart[0]; + const dy = lineEnd[1] - lineStart[1]; + const norm = dx * dx + dy * dy; + + if (norm === 0) { + return pointDistance(point, lineStart); + } + + const u = + ((point[0] - lineStart[0]) * dx + (point[1] - lineStart[1]) * dy) / norm; + + if (u < 0) { + return pointDistance(point, lineStart); + } + if (u > 1) { + return pointDistance(point, lineEnd); + } + + const projX = lineStart[0] + u * dx; + const projY = lineStart[1] + u * dy; + + return Math.sqrt( + (point[0] - projX) * (point[0] - projX) + + (point[1] - projY) * (point[1] - projY), + ); +} + +export function curveSimplifyWithRDP( + points: readonly Point[], + epsilon: number = 0.5, +): readonly Point[] { + if (points.length <= 2) { + return points; + } + + const stack: Array<{ start: number; end: number }> = [ + { start: 0, end: points.length - 1 }, + ]; + const keep = new Set(); + + keep.add(0); + keep.add(points.length - 1); + + while (stack.length > 0) { + const segment = stack.pop()!; + let maxDist = 0; + let maxIndex = -1; + + for (let i = segment.start + 1; i < segment.end; i++) { + const dist = perpendicularDistance( + points[i], + lineSegment(points[segment.start], points[segment.end]), + ); + + if (dist > maxDist) { + maxDist = dist; + maxIndex = i; + } + } + + if (maxDist > epsilon && maxIndex !== -1) { + keep.add(maxIndex); + stack.push({ start: segment.start, end: maxIndex }); + stack.push({ start: maxIndex, end: segment.end }); + } + } + + return points.filter((_, index) => keep.has(index)); +}