diff --git a/packages/element/src/renderElement.ts b/packages/element/src/renderElement.ts index 922bd773f1..ddf9acfb23 100644 --- a/packages/element/src/renderElement.ts +++ b/packages/element/src/renderElement.ts @@ -144,10 +144,6 @@ export interface ExcalidrawElementWithCanvas { imageCrop: ExcalidrawImageElement["crop"] | null; containingFrameOpacity: number; boundTextCanvas: HTMLCanvasElement; - /** - * When set, overrides the canvas origin placement in drawElementFromCanvas. - * Used by the incremental freedraw rendering path. Scene coordinates. - */ canvasOriginSceneX?: number; canvasOriginSceneY?: number; } @@ -390,29 +386,6 @@ const drawImagePlaceholder = ( ); }; -// ─── Freedraw predicted-point store ───────────────────────────────────────── -// Keyed by element ID. Coordinates are in element-local scene units -// (i.e. relative to element.x / element.y), matching the format of -// ExcalidrawFreeDrawElement.points. -const freedrawPredictedPoints = new Map(); - -/** - * Sets (or clears) the predicted next point for a freedraw element that is - * currently being drawn. Call from the pointer-move handler using the first - * entry returned by PointerEvent.getPredictedEvents(). Call with `null` when - * the stroke is finalised so the ghost segment is no longer rendered. - */ -export const setFreeDrawPredictedPoint = ( - elementId: string, - point: readonly [number, number] | null, -): void => { - if (point !== null) { - freedrawPredictedPoints.set(elementId, point); - } else { - freedrawPredictedPoints.delete(elementId); - } -}; - const DEFAULT_FREEDRAW_PRESSURE = 0.5; /** @@ -939,19 +912,23 @@ export const elementWithCanvasCache = new WeakMap< ExcalidrawElementWithCanvas >(); -// ─── Incremental freedraw canvas cache ──────────────────────────────────────── +// ─── Incremental freedraw canvas cache ─────────────────────────────────────── // A separate WeakMap that survives ShapeCache.delete() calls so that the raster -// accumulates new capsule segments without full regeneration on every added point. +// accumulates new capsule segments without full regeneration on every added +// point. -const FREEDRAW_CANVAS_OVERSHOOT_MIN = 200; // screen pixels — minimum extra lookahead space on each side (divided by scale at use) -const FREEDRAW_CANVAS_OVERSHOOT_FACTOR = 0.5; // allocate current_dimension * factor extra on each side +// screen pixels — minimum extra lookahead space on each side +// (divided by scale at use) +const FREEDRAW_CANVAS_OVERSHOOT_MIN = 200; + +// allocate current_dimension * factor extra on each side +const FREEDRAW_CANVAS_OVERSHOOT_FACTOR = 0.5; interface FreeDrawIncrementalCanvas { canvas: HTMLCanvasElement; lastRenderedPointCount: number; canvasOriginSceneX: number; canvasOriginSceneY: number; - /** Scene bounds guaranteed to be visible inside this canvas (without padding) */ canvasAllocX1: number; canvasAllocY1: number; canvasAllocX2: number; @@ -971,8 +948,8 @@ const freedrawIncrementalCache = new WeakMap< * cache is NOT cleared on each mutateElement call, allowing new segments to be * appended without full regeneration. * - * When element bounds grow beyond the over-allocated canvas, the existing raster - * is copied into a new larger canvas before appending the next segments. + * When element bounds grow beyond the over-allocated canvas, the existing + * raster is copied into a new larger canvas before appending the next segments */ const generateOrUpdateFreeDrawIncrementalCanvas = ( element: ExcalidrawFreeDrawElement, @@ -1100,9 +1077,6 @@ const generateOrUpdateFreeDrawIncrementalCanvas = ( canvasScale = prevInc.scale; } - // Fetch predicted point for this element (set by the pointer-move handler). - const predictedPoint = freedrawPredictedPoints.get(element.id); - // Roll back 2 points so the Catmull-Rom look-ahead tangent at the last // committed segment is redrawn correctly when a new point arrives. // Pressure smoothing is causal (one-sided) so it requires no extra rollback. @@ -1111,7 +1085,7 @@ const generateOrUpdateFreeDrawIncrementalCanvas = ( // Draw new (and possibly revised) segments plus the ghost toward the // predicted point. - if (drawFrom < element.points.length || predictedPoint !== undefined) { + if (drawFrom < element.points.length) { const ctx = canvas.getContext("2d")!; const canvasElemOffsetX = (element.x - canvasOriginSceneX) * dpr * canvasScale; diff --git a/packages/element/src/shape.ts b/packages/element/src/shape.ts index b90bf83e2d..e3f0cc28c8 100644 --- a/packages/element/src/shape.ts +++ b/packages/element/src/shape.ts @@ -1,5 +1,4 @@ import { simplify } from "points-on-curve"; -import { getStroke } from "perfect-freehand"; import { type GeometricShape, @@ -981,7 +980,7 @@ const _generateElementShape = ( ); } - // (2) stroke — one path element per capsule, mirrors canvas fill() calls + // (2) stroke shapes.push(...getFreeDrawCapsulePaths(element)); return shapes; @@ -1172,19 +1171,17 @@ export const toggleLinePolygonState = ( // freedraw shape helper // ----------------------------------------------------------------------------- -// ─── Capsule-based SVG export (mirrors renderElement.ts) ───────────────────── -// These constants and helpers must be kept in sync with renderElement.ts. const FREEDRAW_DEFAULT_PRESSURE = 0.5; const FREEDRAW_BEZIER_SUBDIVIDE_TARGET_SPACING = 3; const FREEDRAW_PRESSURE_SMOOTHING_RADIUS = 6; -/** Round to 2 dp — sub-pixel accuracy at SVG 96 dpi, much shorter strings. */ +// Round to 2 dp — sub-pixel accuracy at SVG 96 dpi const r2 = (v: number) => Math.round(v * 100) / 100; /** - * SVG path `d` string for a single tapered capsule — the SVG counterpart of - * `drawTaperedCapsule` in renderElement.ts. Uses clockwise arcs (sweep=1) - * so the geometry matches the canvas 2D `arc(..., anticlockwise=false)` calls. + * SVG path `d` string for a single tapered capsule. Uses clockwise arcs + * (sweep=1) so the geometry matches the canvas 2D + * `arc(..., anticlockwise=false)` calls. */ const freedrawTaperedCapsulePath = ( x0: number, @@ -1214,12 +1211,12 @@ const freedrawTaperedCapsulePath = ( const px = -dy / len; // perpendicular unit x const py = dx / len; // perpendicular unit y - // P0 ± perp·r0 (start / back cap tangent points) + // P0 +/- perp·r0 (start / back cap tangent points) const b0x = r2(x0 + px * r0); const b0y = r2(y0 + py * r0); const b1x = r2(x0 - px * r0); const b1y = r2(y0 - py * r0); - // P1 ± perp·r1 (end / front cap tangent points) + // P1 +/- perp·r1 (end / front cap tangent points) const f0x = r2(x1 - px * r1); const f0y = r2(y1 - py * r1); const f1x = r2(x1 + px * r1); @@ -1242,7 +1239,7 @@ const freedrawTaperedCapsulePath = ( }; /** - * Catmull-Rom tangent at points[i] — identical math to `getCatmullRomTangent` + * Catmull-Rom tangent at points[i].Identical math to `getCatmullRomTangent` * in renderElement.ts (predictedPoint is not needed for finalised strokes). */ const freedrawCatmullRomTangent = ( @@ -1296,40 +1293,49 @@ const freedrawCatmullRomTangent = ( return [tx, ty]; }; +/** + * Triangular-kernel causal weighted pressure average (backward-only window). + * When `simulatePressure` is true or pressures array is empty, returns the + * default constant pressure so the geometry mirrors constant-pressure rendering. + */ +const getFreeDrawSmoothedPressure = ( + element: ExcalidrawFreeDrawElement, + i: number, +): number => { + const { pressures } = element; + if (element.simulatePressure || pressures.length === 0) { + return FREEDRAW_DEFAULT_PRESSURE; + } + let sum = 0; + let totalWeight = 0; + for (let k = -FREEDRAW_PRESSURE_SMOOTHING_RADIUS; k <= 0; k++) { + const idx = i + k; + if (idx < 0) { + continue; + } + const p = + idx < pressures.length ? pressures[idx] : FREEDRAW_DEFAULT_PRESSURE; + const w = FREEDRAW_PRESSURE_SMOOTHING_RADIUS + 1 + k; + sum += p * w; + totalWeight += w; + } + return totalWeight > 0 ? sum / totalWeight : FREEDRAW_DEFAULT_PRESSURE; +}; + /** * Returns one SVG path `d` string per tapered-capsule sub-segment for a * freedraw element, using the same Catmull-Rom Bezier subdivision and pressure - * smoothing as the canvas renderer (`drawFreeDrawSegments` in renderElement.ts). - * Returning individual paths (rather than a single compound `d` string) avoids - * fill-rule artifacts that arise when overlapping subpaths invert the winding - * direction inside a compound path. + * smoothing as the canvas renderer. */ const getFreeDrawCapsulePaths = ( element: ExcalidrawFreeDrawElement, ): SVGPathString[] => { - const { points, pressures } = element; + const { points } = element; const N = points.length; const baseRadius = (element.strokeWidth * 1.25) / 2; - const getSmoothedPressure = (i: number): number => { - if (element.simulatePressure || pressures.length === 0) { - return FREEDRAW_DEFAULT_PRESSURE; - } - let sum = 0; - let totalWeight = 0; - for (let k = -FREEDRAW_PRESSURE_SMOOTHING_RADIUS; k <= 0; k++) { - const idx = i + k; - if (idx < 0) { - continue; - } - const p = - idx < pressures.length ? pressures[idx] : FREEDRAW_DEFAULT_PRESSURE; - const w = FREEDRAW_PRESSURE_SMOOTHING_RADIUS + 1 + k; - sum += p * w; - totalWeight += w; - } - return totalWeight > 0 ? sum / totalWeight : FREEDRAW_DEFAULT_PRESSURE; - }; + const getSmoothedPressure = (i: number): number => + getFreeDrawSmoothedPressure(element, i); const paths: SVGPathString[] = []; @@ -1357,7 +1363,7 @@ const getFreeDrawCapsulePaths = ( const t0 = freedrawCatmullRomTangent(points, i - 1); const t1 = freedrawCatmullRomTangent(points, i); - // Bezier subdivision (mirrors drawSubdividedSegment). + // Bezier subdivision. const segLen = Math.sqrt((p1[0] - p0[0]) ** 2 + (p1[1] - p0[1]) ** 2); const nSubdiv = Math.max( 1, @@ -1406,25 +1412,110 @@ const getFreeDrawCapsulePaths = ( return paths; }; +/** + * Generates an outline polygon for a freedraw element using the same + * Catmull-Rom Bezier subdivision and pressure smoothing as the canvas renderer. + * Returns `[x, y]` points in element-local coordinates that form a closed + * polygon around the stroke (left side + right side reversed), suitable for + * hit-testing and eraser intersection. + */ export const getFreedrawOutlinePoints = ( 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]]; +): [number, number][] => { + const { points } = element; + const N = points.length; + const baseRadius = (element.strokeWidth * 1.25) / 2; - return getStroke(inputPoints as number[][], { - 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: true, - }) as [number, number][]; + if (N === 0) { + return []; + } + + const radius0 = baseRadius * getFreeDrawSmoothedPressure(element, 0) * 2; + + if (N === 1) { + // Single point case + const cx = points[0][0]; + const cy = points[0][1]; + const result: [number, number][] = []; + for (let i = 0; i < 8; i++) { + const angle = (i / 8) * Math.PI * 2; + result.push([ + cx + Math.cos(angle) * radius0, + cy + Math.sin(angle) * radius0, + ]); + } + return result; + } + + const leftPoints: [number, number][] = []; + const rightPoints: [number, number][] = []; + + for (let i = 1; i < N; i++) { + const p0 = points[i - 1]; + const p1 = points[i]; + const r0 = baseRadius * getFreeDrawSmoothedPressure(element, i - 1) * 2; + const r1 = baseRadius * getFreeDrawSmoothedPressure(element, i) * 2; + + const t0 = freedrawCatmullRomTangent(points, i - 1); + const t1 = freedrawCatmullRomTangent(points, i); + + const segLen = Math.sqrt((p1[0] - p0[0]) ** 2 + (p1[1] - p0[1]) ** 2); + const nSubdiv = Math.max( + 1, + Math.ceil(segLen / FREEDRAW_BEZIER_SUBDIVIDE_TARGET_SPACING), + ); + + const cp1x = p0[0] + t0[0] / 3; + const cp1y = p0[1] + t0[1] / 3; + const cp2x = p1[0] - t1[0] / 3; + const cp2y = p1[1] - t1[1] / 3; + + // Include the start point of the first segment. + const kStart = i === 1 ? 0 : 1; + + for (let k = kStart; k <= nSubdiv; k++) { + const tParam = k / nSubdiv; + const mt = 1 - tParam; + const mt2 = mt * mt; + const t2 = tParam * tParam; + const mt3 = mt2 * mt; + const t3 = t2 * tParam; + + const x = + mt3 * p0[0] + 3 * mt2 * tParam * cp1x + 3 * mt * t2 * cp2x + t3 * p1[0]; + const y = + mt3 * p0[1] + 3 * mt2 * tParam * cp1y + 3 * mt * t2 * cp2y + t3 * p1[1]; + + // Bezier first derivative for the tangent direction. + const dtx = + 3 * + (mt2 * (cp1x - p0[0]) + + 2 * mt * tParam * (cp2x - cp1x) + + t2 * (p1[0] - cp2x)); + const dty = + 3 * + (mt2 * (cp1y - p0[1]) + + 2 * mt * tParam * (cp2y - cp1y) + + t2 * (p1[1] - cp2y)); + + const len = Math.sqrt(dtx * dtx + dty * dty); + if (len === 0) { + continue; + } + + // Perpendicular (left = +, right = −). + const px = -dty / len; + const py = dtx / len; + + const r = r0 + (r1 - r0) * tParam; + + leftPoints.push([x + px * r, y + py * r]); + rightPoints.push([x - px * r, y - py * r]); + } + } + + // Closed polygon: left side (start -> end) + right side (end -> start). + return [...leftPoints, ...rightPoints.reverse()]; }; // ----------------------------------------------------------------------------- diff --git a/packages/excalidraw/actions/actionFinalize.tsx b/packages/excalidraw/actions/actionFinalize.tsx index f967fdbf96..6b7cdf8edd 100644 --- a/packages/excalidraw/actions/actionFinalize.tsx +++ b/packages/excalidraw/actions/actionFinalize.tsx @@ -14,10 +14,7 @@ import { isLineElement, } from "@excalidraw/element"; -import { - invalidateFreeDrawIncrementalCanvas, - setFreeDrawPredictedPoint, -} from "@excalidraw/element"; +import { invalidateFreeDrawIncrementalCanvas } from "@excalidraw/element"; import { KEYS, @@ -320,7 +317,6 @@ export const actionFinalize = register({ if (element && isFreeDrawElement(element)) { invalidateFreeDrawIncrementalCanvas(element); - setFreeDrawPredictedPoint(element.id, null); } return { diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 8cdb11a44b..419b86c0c5 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -259,7 +259,6 @@ import { maybeHandleArrowPointlikeDrag, getUncroppedWidthAndHeight, getActiveTextElement, - setFreeDrawPredictedPoint, } from "@excalidraw/element"; import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math"; @@ -8837,7 +8836,7 @@ class App extends React.Component { locked: false, frameId: topLayerFrame ? topLayerFrame.id : null, points: [pointFrom(0, 0)], - pressures: simulatePressure ? [] : [event.pressure], + pressures: [event.pressure], }); this.scene.insertElement(element); @@ -10172,17 +10171,10 @@ class App extends React.Component { } if (newElement.type === "freedraw") { - // Collect all coalesced pointer positions that accumulated between - // RAF frames (important for high-frequency Apple Pencil / stylus - // input). getCoalescedEvents() returns the intermediate samples that - // would otherwise be discarded by throttleRAF, giving a dense stroke - // without extra renders. const coalescedEvents: PointerEvent[] = event.getCoalescedEvents?.() ?? []; - // The final event is always included; coalesced list may duplicate it const allEvents = coalescedEvents.length > 0 ? coalescedEvents : [event]; - const newPoints: LocalPoint[] = []; const newPressures: number[] = []; @@ -10203,32 +10195,8 @@ class App extends React.Component { } } - // Update the predicted next point for smoother stroke rendering. - // getPredictedEvents() returns samples predicted by the browser for - // the current pointer position, compensating for event-delivery - // latency (especially useful with Apple Pencil / high-frequency - // stylus input). We pass the first predicted point to the renderer - // so it can (a) use it as a Catmull-Rom look-ahead tangent and - // (b) draw a ghost segment toward the predicted position. - const predictedEvents: PointerEvent[] = - event.getPredictedEvents?.() ?? []; - if (predictedEvents.length > 0) { - const predCoords = viewportCoordsToSceneCoords( - predictedEvents[0], - this.state, - ); - setFreeDrawPredictedPoint(newElement.id, [ - predCoords.x - newElement.x, - predCoords.y - newElement.y, - ]); - } else { - setFreeDrawPredictedPoint(newElement.id, null); - } - if (newPoints.length > 0) { - const pressures = newElement.simulatePressure - ? newElement.pressures - : [...newElement.pressures, ...newPressures]; + const pressures = [...newElement.pressures, ...newPressures]; this.scene.mutateElement( newElement, @@ -10698,9 +10666,7 @@ class App extends React.Component { dx += 0.0001; } - const pressures = newElement.simulatePressure - ? [] - : [...newElement.pressures, childEvent.pressure]; + const pressures = [...newElement.pressures, childEvent.pressure]; this.scene.mutateElement(newElement, { points: [...points, pointFrom(dx, dy)], diff --git a/packages/excalidraw/package.json b/packages/excalidraw/package.json index 1d01b82bd8..8d786daf54 100644 --- a/packages/excalidraw/package.json +++ b/packages/excalidraw/package.json @@ -104,7 +104,6 @@ "lodash.throttle": "4.1.1", "nanoid": "3.3.3", "pako": "2.0.3", - "perfect-freehand": "1.2.0", "pica": "7.1.1", "png-chunk-text": "1.0.0", "png-chunks-encode": "1.0.0", @@ -138,4 +137,4 @@ "jest-diff": "29.7.0", "typescript": "5.9.3" } -} +} \ No newline at end of file diff --git a/packages/math/src/vector.ts b/packages/math/src/vector.ts index e2fbcda528..c520fce244 100644 --- a/packages/math/src/vector.ts +++ b/packages/math/src/vector.ts @@ -158,14 +158,3 @@ export const vectorNormalize = (v: Vector): Vector => { * Calculate the right-hand normal of the vector. */ export const vectorNormal = (v: Vector): Vector => vector(v[1], -v[0]); - -/** - * Scalar projection of vector v onto normal. - * - * @param v - * @param normal - * @returns - */ -export const vectorReflect = (v: Vector, normal: Vector): Vector => { - return vectorSubtract(v, vectorScale(normal, 2 * vectorDot(v, normal))); -}; diff --git a/packages/utils/package.json b/packages/utils/package.json index 2439480042..c97811e084 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -52,7 +52,6 @@ "@excalidraw/laser-pointer": "1.3.1", "browser-fs-access": "0.38.0", "pako": "2.0.3", - "perfect-freehand": "1.2.0", "png-chunk-text": "1.0.0", "png-chunks-encode": "1.0.0", "png-chunks-extract": "1.0.0", @@ -71,4 +70,4 @@ "gen:types": "rimraf types && tsc", "build:esm": "rimraf dist && node ../../scripts/buildUtils.js && yarn gen:types" } -} +} \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index dd87b52426..36d600b08a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8381,11 +8381,6 @@ pepjs@0.5.3: resolved "https://registry.yarnpkg.com/pepjs/-/pepjs-0.5.3.tgz#dc755f03d965c20e4b1bb65e42a03a97c382cfc7" integrity sha512-5yHVB9OHqKd9fr/OIsn8ss0NgThQ9buaqrEuwr9Or5YjPp6h+WTDKWZI+xZLaBGZCtODTnFtlSHNmhFsq67THg== -perfect-freehand@1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/perfect-freehand/-/perfect-freehand-1.2.0.tgz#706a0f854544f6175772440c51d3b0563eb3988a" - integrity sha512-h/0ikF1M3phW7CwpZ5MMvKnfpHficWoOEyr//KVNTxV4F6deRK1eYMtHyBKEAKFK0aXIEUK9oBvlF6PNXMDsAw== - pica@7.1.1, pica@^7.1.0: version "7.1.1" resolved "https://registry.yarnpkg.com/pica/-/pica-7.1.1.tgz#c68b42f5cfa6cc26eaec5cfa10cc0a5299ef3b7a"