import { simplify } from "points-on-curve"; import { getStroke } from "perfect-freehand"; import { type GeometricShape, getClosedCurveShape, getCurveShape, getEllipseShape, getFreedrawShape, getPolygonShape, } from "@excalidraw/utils/shape"; import { pointFrom, pointDistance, type LocalPoint, pointRotateRads, } from "@excalidraw/math"; import { ROUGHNESS, THEME, isTransparent, assertNever, COLOR_PALETTE, LINE_POLYGON_POINT_MERGE_DISTANCE, applyDarkModeFilter, } from "@excalidraw/common"; import { RoughGenerator } from "roughjs/bin/generator"; import type { GlobalPoint } from "@excalidraw/math"; import type { Mutable } from "@excalidraw/common/utility-types"; import type { AppState, EmbedsValidationStatus, } from "@excalidraw/excalidraw/types"; import type { ElementShape, ElementShapes, SVGPathString, } from "@excalidraw/excalidraw/scene/types"; import { elementWithCanvasCache } from "./renderElement"; import { canBecomePolygon, isElbowArrow, isEmbeddableElement, isIframeElement, isIframeLikeElement, isLinearElement, } from "./typeChecks"; import { getCornerRadius, isPathALoop } from "./utils"; import { headingForPointIsHorizontal } from "./heading"; import { canChangeRoundness } from "./comparisons"; import { elementCenterPoint, getArrowheadPoints, getDiamondPoints, getElementAbsoluteCoords, } from "./bounds"; import { shouldTestInside } from "./collision"; import type { ExcalidrawElement, NonDeletedExcalidrawElement, ExcalidrawSelectionElement, ExcalidrawLinearElement, ExcalidrawFreeDrawElement, ElementsMap, ExcalidrawLineElement, Arrowhead, } from "./types"; import type { Drawable, Options } from "roughjs/bin/core"; import type { Point as RoughPoint } from "roughjs/bin/geometry"; // Controls how handle distance scales with chord length. // At 1.0 handles are exactly h/3 (standard Hermite). Values below 1 make // short segments curvier and long segments more taut (sub-linear scaling). const CP_CHORD_POWER = 1; // At curved knots the C2 spline tangent can be tilted away from the // bisector direction, making one side of the knot tight and the other taut. // This factor [0, 1] controls how far the tangent direction is pulled toward // the bisector (the chord-bisector normal) linearly with turn sharpness. // 0 = pure C2 spline; 1 = tangent fully aligned with the bisector. const CP_ANGLE_CORRECTION = 1; export class ShapeCache { private static rg = new RoughGenerator(); private static cache = new WeakMap< ExcalidrawElement, { shape: ElementShape; theme: AppState["theme"] } >(); /** * Retrieves shape from cache if available. Use this only if shape * is optional and you have a fallback in case it's not cached. */ public static get = ( element: T, theme: AppState["theme"] | null, ) => { const cached = ShapeCache.cache.get(element); if (cached && (theme === null || cached.theme === theme)) { return cached.shape as T["type"] extends keyof ElementShapes ? ElementShapes[T["type"]] | undefined : ElementShape | undefined; } return undefined; }; public static delete = (element: ExcalidrawElement) => { ShapeCache.cache.delete(element); elementWithCanvasCache.delete(element); }; public static destroy = () => { ShapeCache.cache = new WeakMap(); }; /** * Generates & caches shape for element if not already cached, otherwise * returns cached shape. */ public static generateElementShape = < T extends Exclude, >( element: T, renderConfig: { isExporting: boolean; canvasBackgroundColor: AppState["viewBackgroundColor"]; embedsValidationStatus: EmbedsValidationStatus; theme: AppState["theme"]; } | null, ) => { // when exporting, always regenerated to guarantee the latest shape const cachedShape = renderConfig?.isExporting ? undefined : ShapeCache.get(element, renderConfig ? renderConfig.theme : null); // `null` indicates no rc shape applicable for this element type, // but it's considered a valid cache value (= do not regenerate) if (cachedShape !== undefined) { return cachedShape; } elementWithCanvasCache.delete(element); const shape = _generateElementShape( element, ShapeCache.rg, renderConfig || { isExporting: false, canvasBackgroundColor: COLOR_PALETTE.white, embedsValidationStatus: null, theme: THEME.LIGHT, }, ) as T["type"] extends keyof ElementShapes ? ElementShapes[T["type"]] : Drawable | null; if (!renderConfig?.isExporting) { ShapeCache.cache.set(element, { shape, theme: renderConfig?.theme || THEME.LIGHT, }); } return shape; }; } const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth]; const getDashArrayDotted = (strokeWidth: number) => [1.5, 6 + strokeWidth]; function adjustRoughness(element: ExcalidrawElement): number { const roughness = element.roughness; const maxSize = Math.max(element.width, element.height); const minSize = Math.min(element.width, element.height); // don't reduce roughness if if ( // both sides relatively big (minSize >= 20 && maxSize >= 50) || // is round & both sides above 15px (minSize >= 15 && !!element.roundness && canChangeRoundness(element.type)) || // relatively long linear element (isLinearElement(element) && maxSize >= 50) ) { return roughness; } return Math.min(roughness / (maxSize < 10 ? 3 : 2), 2.5); } export const generateRoughOptions = ( element: ExcalidrawElement, continuousPath = false, isDarkMode: boolean = false, ): Options => { const options: Options = { seed: element.seed, strokeLineDash: element.strokeStyle === "dashed" ? getDashArrayDashed(element.strokeWidth) : element.strokeStyle === "dotted" ? getDashArrayDotted(element.strokeWidth) : undefined, // for non-solid strokes, disable multiStroke because it tends to make // dashes/dots overlay each other disableMultiStroke: element.strokeStyle !== "solid", // for non-solid strokes, increase the width a bit to make it visually // similar to solid strokes, because we're also disabling multiStroke strokeWidth: element.strokeStyle !== "solid" ? element.strokeWidth + 0.5 : element.strokeWidth, // when increasing strokeWidth, we must explicitly set fillWeight and // 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, roughness: adjustRoughness(element), stroke: isDarkMode ? applyDarkModeFilter(element.strokeColor) : element.strokeColor, preserveVertices: continuousPath || element.roughness < ROUGHNESS.cartoonist, }; switch (element.type) { case "rectangle": case "iframe": case "embeddable": case "diamond": case "ellipse": { options.fillStyle = element.fillStyle; options.fill = isTransparent(element.backgroundColor) ? undefined : isDarkMode ? applyDarkModeFilter(element.backgroundColor) : element.backgroundColor; if (element.type === "ellipse") { options.curveFitting = 1; } return options; } case "line": case "freedraw": { if (isPathALoop(element.points)) { options.fillStyle = element.fillStyle; options.fill = element.backgroundColor === "transparent" ? undefined : isDarkMode ? applyDarkModeFilter(element.backgroundColor) : element.backgroundColor; } return options; } case "arrow": return options; default: { throw new Error(`Unimplemented type ${element.type}`); } } }; const modifyIframeLikeForRoughOptions = ( element: NonDeletedExcalidrawElement, isExporting: boolean, embedsValidationStatus: EmbedsValidationStatus | null, ) => { if ( isIframeLikeElement(element) && (isExporting || (isEmbeddableElement(element) && embedsValidationStatus?.get(element.id) !== true)) && isTransparent(element.backgroundColor) && isTransparent(element.strokeColor) ) { return { ...element, roughness: 0, backgroundColor: "#d3d3d3", fillStyle: "solid", } as const; } else if (isIframeElement(element)) { return { ...element, strokeColor: isTransparent(element.strokeColor) ? "#000000" : element.strokeColor, backgroundColor: isTransparent(element.backgroundColor) ? "#f4f4f6" : element.backgroundColor, }; } return element; }; const generateArrowheadCardinalityOne = ( generator: RoughGenerator, arrowheadPoints: number[] | null, lineOptions: Options, ) => { if (arrowheadPoints === null) { return []; } const [, , x3, y3, x4, y4] = arrowheadPoints; return [generator.line(x3, y3, x4, y4, lineOptions)]; }; const generateArrowheadLinesToTip = ( generator: RoughGenerator, arrowheadPoints: number[] | null, lineOptions: Options, ) => { if (arrowheadPoints === null) { return []; } const [x2, y2, x3, y3, x4, y4] = arrowheadPoints; return [ generator.line(x3, y3, x2, y2, lineOptions), generator.line(x4, y4, x2, y2, lineOptions), ]; }; const getArrowheadLineOptions = ( element: ExcalidrawLinearElement, options: Options, ) => { const lineOptions = { ...options }; if (element.strokeStyle === "dotted") { // for dotted arrows caps, reduce gap to make it more legible const dash = getDashArrayDotted(element.strokeWidth - 1); lineOptions.strokeLineDash = [dash[0], dash[1] - 1]; } else { // for solid/dashed, keep solid arrow cap delete lineOptions.strokeLineDash; } lineOptions.roughness = Math.min(1, lineOptions.roughness || 0); return lineOptions; }; const generateArrowheadOutlineCircle = ( generator: RoughGenerator, options: Options, strokeColor: string, arrowheadPoints: number[] | null, fill: string, diameterScale = 1, ) => { if (arrowheadPoints === null) { return []; } const [x, y, diameter] = arrowheadPoints; const circleOptions = { ...options, fill, fillStyle: "solid" as const, stroke: strokeColor, roughness: Math.min(0.5, options.roughness || 0), }; delete circleOptions.strokeLineDash; return [generator.circle(x, y, diameter * diameterScale, circleOptions)]; }; const getArrowheadShapes = ( element: ExcalidrawLinearElement, shape: Drawable[], position: "start" | "end", arrowhead: Arrowhead, generator: RoughGenerator, options: Options, canvasBackgroundColor: string, isDarkMode: boolean, ) => { if (arrowhead === null) { return []; } const strokeColor = isDarkMode ? applyDarkModeFilter(element.strokeColor) : element.strokeColor; const backgroundFillColor = isDarkMode ? applyDarkModeFilter(canvasBackgroundColor) : canvasBackgroundColor; const cardinalityOneOrManyOffset = -0.25; const cardinalityZeroCircleScale = 0.8; switch (arrowhead) { case "circle": case "circle_outline": { return generateArrowheadOutlineCircle( generator, options, strokeColor, getArrowheadPoints(element, shape, position, arrowhead), arrowhead === "circle_outline" ? backgroundFillColor : strokeColor, ); } case "triangle": case "triangle_outline": { const arrowheadPoints = getArrowheadPoints( element, shape, position, arrowhead, ); if (arrowheadPoints === null) { return []; } const [x, y, x2, y2, x3, y3] = arrowheadPoints; const triangleOptions = { ...options, fill: arrowhead === "triangle_outline" ? backgroundFillColor : strokeColor, fillStyle: "solid" as const, roughness: Math.min(1, options.roughness || 0), }; // always use solid stroke for arrowhead delete triangleOptions.strokeLineDash; return [ generator.polygon( [ [x, y], [x2, y2], [x3, y3], [x, y], ], triangleOptions, ), ]; } case "diamond": case "diamond_outline": { const arrowheadPoints = getArrowheadPoints( element, shape, position, arrowhead, ); if (arrowheadPoints === null) { return []; } const [x, y, x2, y2, x3, y3, x4, y4] = arrowheadPoints; const diamondOptions = { ...options, fill: arrowhead === "diamond_outline" ? backgroundFillColor : strokeColor, fillStyle: "solid" as const, roughness: Math.min(1, options.roughness || 0), }; // always use solid stroke for arrowhead delete diamondOptions.strokeLineDash; return [ generator.polygon( [ [x, y], [x2, y2], [x3, y3], [x4, y4], [x, y], ], diamondOptions, ), ]; } case "cardinality_one": return generateArrowheadCardinalityOne( generator, getArrowheadPoints(element, shape, position, arrowhead), getArrowheadLineOptions(element, options), ); case "cardinality_many": return generateArrowheadLinesToTip( generator, getArrowheadPoints(element, shape, position, arrowhead), getArrowheadLineOptions(element, options), ); case "cardinality_one_or_many": { const lineOptions = getArrowheadLineOptions(element, options); return [ ...generateArrowheadLinesToTip( generator, getArrowheadPoints(element, shape, position, "cardinality_many"), lineOptions, ), ...generateArrowheadCardinalityOne( generator, getArrowheadPoints( element, shape, position, "cardinality_one", cardinalityOneOrManyOffset, ), lineOptions, ), ]; } case "cardinality_exactly_one": { const lineOptions = getArrowheadLineOptions(element, options); return [ ...generateArrowheadCardinalityOne( generator, getArrowheadPoints(element, shape, position, "cardinality_one", -0.5), lineOptions, ), ...generateArrowheadCardinalityOne( generator, getArrowheadPoints(element, shape, position, "cardinality_one"), lineOptions, ), ]; } case "cardinality_zero_or_one": { const lineOptions = getArrowheadLineOptions(element, options); return [ ...generateArrowheadOutlineCircle( generator, options, strokeColor, getArrowheadPoints(element, shape, position, "circle_outline", 1.5), backgroundFillColor, cardinalityZeroCircleScale, ), ...generateArrowheadCardinalityOne( generator, getArrowheadPoints(element, shape, position, "cardinality_one", -0.5), lineOptions, ), ]; } case "cardinality_zero_or_many": { const lineOptions = getArrowheadLineOptions(element, options); return [ ...generateArrowheadLinesToTip( generator, getArrowheadPoints(element, shape, position, "cardinality_many"), lineOptions, ), ...generateArrowheadOutlineCircle( generator, options, strokeColor, getArrowheadPoints(element, shape, position, "circle_outline", 1.5), backgroundFillColor, cardinalityZeroCircleScale, ), ]; } case "bar": case "arrow": default: { return generateArrowheadLinesToTip( generator, getArrowheadPoints(element, shape, position, arrowhead), getArrowheadLineOptions(element, options), ); } } }; export const generateLinearCollisionShape = ( element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement, elementsMap: ElementsMap, ): { op: string; data: number[]; }[] => { const generator = new RoughGenerator(); const options: Options = { seed: element.seed, disableMultiStroke: true, disableMultiStrokeFill: true, roughness: 0, preserveVertices: true, }; const center = elementCenterPoint(element, elementsMap); switch (element.type) { case "line": case "arrow": { // points array can be empty in the beginning, so it is important to add // initial position to it const points = element.points.length ? element.points : [pointFrom(0, 0)]; if (isElbowArrow(element)) { return generator.path(generateElbowArrowShape(points, 16), options) .sets[0].ops; } else if (!element.roundness) { return points.map((point, idx) => { const p = pointRotateRads( pointFrom(element.x + point[0], element.y + point[1]), center, element.angle, ); return { op: idx === 0 ? "move" : "lineTo", data: pointFrom(p[0] - element.x, p[1] - element.y), }; }); } // Generate collision ops using the same bisector-based cubic Bézier // algorithm as generateRoundedSimpleArrowShape so hit-testing matches rendering. const rotateLocal = (lx: number, ly: number): LocalPoint => { const g = pointRotateRads( pointFrom(element.x + lx, element.y + ly), center, element.angle, ); return pointFrom(g[0] - element.x, g[1] - element.y); }; const collisionOps: Array<{ op: string; data: number[] | LocalPoint; }> = []; collisionOps.push({ op: "move", data: rotateLocal(points[0][0], points[0][1]), }); if (points.length === 2) { collisionOps.push({ op: "lineTo", data: rotateLocal(points[1][0], points[1][1]), }); } else { // Chord-length C2 spline. Mirrors generateRoundedSimpleArrowShape // exactly so hit-testing matches rendering. const n = points.length - 1; const h = new Float64Array(n); for (let i = 0; i < n; i++) { h[i] = Math.max( 1e-10, Math.hypot( points[i + 1][0] - points[i][0], points[i + 1][1] - points[i][1], ), ); } const mx = new Float64Array(n + 1); const my = new Float64Array(n + 1); const diag = new Float64Array(n + 1); const rhsX = new Float64Array(n + 1); const rhsY = new Float64Array(n + 1); diag[0] = 2; rhsX[0] = (3 * (points[1][0] - points[0][0])) / h[0]; rhsY[0] = (3 * (points[1][1] - points[0][1])) / h[0]; for (let i = 1; i < n; i++) { diag[i] = 2 * (h[i - 1] + h[i]); rhsX[i] = 3 * ((h[i] * (points[i][0] - points[i - 1][0])) / h[i - 1] + (h[i - 1] * (points[i + 1][0] - points[i][0])) / h[i]); rhsY[i] = 3 * ((h[i] * (points[i][1] - points[i - 1][1])) / h[i - 1] + (h[i - 1] * (points[i + 1][1] - points[i][1])) / h[i]); } diag[n] = 2; rhsX[n] = (3 * (points[n][0] - points[n - 1][0])) / h[n - 1]; rhsY[n] = (3 * (points[n][1] - points[n - 1][1])) / h[n - 1]; for (let i = 1; i <= n; i++) { const sub = i < n ? h[i] : 1; const supPrev = i === 1 ? 1 : h[i - 2]; const w = sub / diag[i - 1]; diag[i] -= w * supPrev; rhsX[i] -= w * rhsX[i - 1]; rhsY[i] -= w * rhsY[i - 1]; } mx[n] = rhsX[n] / diag[n]; my[n] = rhsY[n] / diag[n]; for (let i = n - 1; i >= 0; i--) { const sup = i === 0 ? 1 : h[i - 1]; mx[i] = (rhsX[i] - sup * mx[i + 1]) / diag[i]; my[i] = (rhsY[i] - sup * my[i + 1]) / diag[i]; } // Normalised tangent directions; handle length scales sub-linearly with chord. const mlen = new Float64Array(n + 1); for (let i = 0; i <= n; i++) { mlen[i] = Math.max(1e-10, Math.hypot(mx[i], my[i])); } // At interior knots, blend the C2 tangent direction toward the // bisector direction by a factor proportional to turn sharpness * // CP_ANGLE_CORRECTION for (let k = 1; k < n; k++) { const d1x = (points[k][0] - points[k - 1][0]) / h[k - 1]; const d1y = (points[k][1] - points[k - 1][1]) / h[k - 1]; const d2x = (points[k + 1][0] - points[k][0]) / h[k]; const d2y = (points[k + 1][1] - points[k][1]) / h[k]; const dot = d1x * d2x + d1y * d2y; const t = ((1 - dot) / 2) * CP_ANGLE_CORRECTION; if (t < 1e-6) { continue; } const bx = d1x + d2x; const by = d1y + d2y; const blen = Math.hypot(bx, by); if (blen < 1e-10) { continue; } let px = bx / blen; let py = by / blen; const tx = mx[k] / mlen[k]; const ty = my[k] / mlen[k]; if (tx * px + ty * py < 0) { px = -px; py = -py; } const blendX = tx + t * (px - tx); const blendY = ty + t * (py - ty); const blendLen = Math.max(1e-10, Math.hypot(blendX, blendY)); mx[k] = (blendX / blendLen) * mlen[k]; my[k] = (blendY / blendLen) * mlen[k]; } for (let i = 0; i < n; i++) { const cpDist = Math.pow(h[i], CP_CHORD_POWER) / 3; const cp1x = points[i][0] + (mx[i] / mlen[i]) * cpDist; const cp1y = points[i][1] + (my[i] / mlen[i]) * cpDist; const cp2x = points[i + 1][0] - (mx[i + 1] / mlen[i + 1]) * cpDist; const cp2y = points[i + 1][1] - (my[i + 1] / mlen[i + 1]) * cpDist; const rcp1 = rotateLocal(cp1x, cp1y); const rcp2 = rotateLocal(cp2x, cp2y); const rend = rotateLocal(points[i + 1][0], points[i + 1][1]); collisionOps.push({ op: "bcurveTo", data: [rcp1[0], rcp1[1], rcp2[0], rcp2[1], rend[0], rend[1]], }); } } return collisionOps; } case "freedraw": { if (element.points.length < 2) { return []; } const simplifiedPoints = simplify( element.points as Mutable, 0.75, ); return generator .curve(simplifiedPoints as [number, number][], options) .sets[0].ops.slice(0, element.points.length) .map((op, i) => { if (i === 0) { const p = pointRotateRads( pointFrom( element.x + op.data[0], element.y + op.data[1], ), center, element.angle, ); return { op: "move", data: pointFrom(p[0] - element.x, p[1] - element.y), }; } return { op: "bcurveTo", data: [ pointRotateRads( pointFrom( element.x + op.data[0], element.y + op.data[1], ), center, element.angle, ), pointRotateRads( pointFrom( element.x + op.data[2], element.y + op.data[3], ), center, element.angle, ), pointRotateRads( pointFrom( element.x + op.data[4], element.y + op.data[5], ), center, element.angle, ), ] .map((p) => pointFrom(p[0] - element.x, p[1] - element.y), ) .flat(), }; }); } } }; /** * Generates the roughjs shape for given element. * * Low-level. Use `ShapeCache.generateElementShape` instead. * * @private */ const _generateElementShape = ( element: Exclude, generator: RoughGenerator, { isExporting, canvasBackgroundColor, embedsValidationStatus, theme, }: { isExporting: boolean; canvasBackgroundColor: string; embedsValidationStatus: EmbedsValidationStatus | null; theme?: AppState["theme"]; }, ): ElementShape => { const isDarkMode = theme === THEME.DARK; switch (element.type) { case "rectangle": case "iframe": case "embeddable": { let shape: ElementShapes[typeof element.type]; // this is for rendering the stroke/bg of the embeddable, especially // when the src url is not set if (element.roundness) { const w = element.width; const h = element.height; const r = getCornerRadius(Math.min(w, h), element); shape = generator.path( `M ${r} 0 L ${w - r} 0 Q ${w} 0, ${w} ${r} L ${w} ${ h - r } Q ${w} ${h}, ${w - r} ${h} L ${r} ${h} Q 0 ${h}, 0 ${ h - r } L 0 ${r} Q 0 0, ${r} 0`, generateRoughOptions( modifyIframeLikeForRoughOptions( element, isExporting, embedsValidationStatus, ), true, isDarkMode, ), ); } else { shape = generator.rectangle( 0, 0, element.width, element.height, generateRoughOptions( modifyIframeLikeForRoughOptions( element, isExporting, embedsValidationStatus, ), false, isDarkMode, ), ); } return shape; } case "diamond": { let shape: ElementShapes[typeof element.type]; const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] = getDiamondPoints(element); if (element.roundness) { const verticalRadius = getCornerRadius(Math.abs(topX - leftX), element); const horizontalRadius = getCornerRadius( Math.abs(rightY - topY), element, ); shape = generator.path( `M ${topX + verticalRadius} ${topY + horizontalRadius} L ${ rightX - verticalRadius } ${rightY - horizontalRadius} C ${rightX} ${rightY}, ${rightX} ${rightY}, ${ rightX - verticalRadius } ${rightY + horizontalRadius} L ${bottomX + verticalRadius} ${bottomY - horizontalRadius} C ${bottomX} ${bottomY}, ${bottomX} ${bottomY}, ${ bottomX - verticalRadius } ${bottomY - horizontalRadius} L ${leftX + verticalRadius} ${leftY + horizontalRadius} C ${leftX} ${leftY}, ${leftX} ${leftY}, ${leftX + verticalRadius} ${ leftY - horizontalRadius } L ${topX - verticalRadius} ${topY + horizontalRadius} C ${topX} ${topY}, ${topX} ${topY}, ${topX + verticalRadius} ${ topY + horizontalRadius }`, generateRoughOptions(element, true, isDarkMode), ); } else { shape = generator.polygon( [ [topX, topY], [rightX, rightY], [bottomX, bottomY], [leftX, leftY], ], generateRoughOptions(element, false, isDarkMode), ); } return shape; } case "ellipse": { const shape: ElementShapes[typeof element.type] = generator.ellipse( element.width / 2, element.height / 2, element.width, element.height, generateRoughOptions(element, false, isDarkMode), ); return shape; } case "line": case "arrow": { let shape: ElementShapes[typeof element.type]; const options = generateRoughOptions(element, false, isDarkMode); // points array can be empty in the beginning, so it is important to add // initial position to it const points = element.points.length ? element.points : [pointFrom(0, 0)]; if (isElbowArrow(element)) { // NOTE (mtolmacs): Temporary fix for extremely big arrow shapes if ( !points.every( (point) => Math.abs(point[0]) <= 1e6 && Math.abs(point[1]) <= 1e6, ) ) { console.error( `Elbow arrow with extreme point positions detected. Arrow not rendered.`, element.id, JSON.stringify(points), ); shape = []; } else { shape = [ generator.path( generateElbowArrowShape(points, 16), generateRoughOptions(element, true, isDarkMode), ), ]; } } else if (!element.roundness) { // curve is always the first element // this simplifies finding the curve for an element if (options.fill) { shape = [ generator.polygon(points as unknown as RoughPoint[], options), ]; } else { shape = [ generator.linearPath(points as unknown as RoughPoint[], options), ]; } } else { shape = [ generator.path( generateRoundedSimpleArrowShape(points), generateRoughOptions(element, true, isDarkMode), ), ]; } // add lines only in arrow if (element.type === "arrow") { const { startArrowhead = null, endArrowhead = "arrow" } = element; if (startArrowhead !== null) { const shapes = getArrowheadShapes( element, shape, "start", startArrowhead, generator, options, canvasBackgroundColor, isDarkMode, ); shape.push(...shapes); } if (endArrowhead !== null) { if (endArrowhead === undefined) { // Hey, we have an old arrow here! } const shapes = getArrowheadShapes( element, shape, "end", endArrowhead, generator, options, canvasBackgroundColor, isDarkMode, ); shape.push(...shapes); } } return shape; } case "freedraw": { // oredered in terms of z-index [background, stroke] const shapes: ElementShapes[typeof element.type] = []; // (1) background fill (rc shape), optional if (isPathALoop(element.points)) { // generate rough polygon to fill freedraw shape const simplifiedPoints = simplify( element.points as Mutable, 0.75, ); shapes.push( generator.curve(simplifiedPoints as [number, number][], { ...generateRoughOptions(element, false, isDarkMode), stroke: "none", }), ); } // (2) stroke shapes.push(getFreeDrawSvgPath(element)); return shapes; } case "frame": case "magicframe": case "text": case "image": { const shape: ElementShapes[typeof element.type] = null; // we return (and cache) `null` to make sure we don't regenerate // `element.canvas` on rerenders return shape; } default: { assertNever( element, `generateElementShape(): Unimplemented type ${(element as any)?.type}`, ); return null; } } }; const generateRoundedSimpleArrowShape = ( points: readonly LocalPoint[], ): string => { if (points.length < 2) { return ""; } if (points.length === 2) { return `M ${points[0][0]} ${points[0][1]} L ${points[1][0]} ${points[1][1]}`; } // Chord-length parameterised C2 natural cubic spline (Thomas's algorithm). // // Unknowns: tangent vectors m[0..n] at each knot (n = number of segments). // Chord lengths h[i] = |K[i+1] − K[i]| act as the parameter intervals so // that tightly-spaced knots don't over-influence distant ones. // // Row 0: 2·m₀ + m₁ = 3·(K₁−K₀)/h₀ // Row i: h[i]·mᵢ₋₁ + 2·(h[i−1]+h[i])·mᵢ + h[i−1]·mᵢ₊₁ // = 3·(h[i]·(Kᵢ−Kᵢ₋₁)/h[i−1] // + h[i−1]·(Kᵢ₊₁−Kᵢ)/h[i]) 1≤i≤n−1 // Row n: mₙ₋₁ + 2·mₙ = 3·(Kₙ−Kₙ₋₁)/h[n−1] // // Bézier control points from Hermite→Bézier identity: // cp1ᵢ = Kᵢ + mᵢ · h[i] / 3 // cp2ᵢ = Kᵢ₊₁ − mᵢ₊₁ · h[i] / 3 const n = points.length - 1; // number of segments const h = new Float64Array(n); for (let i = 0; i < n; i++) { h[i] = Math.max( 1e-10, Math.hypot( points[i + 1][0] - points[i][0], points[i + 1][1] - points[i][1], ), ); } const mx = new Float64Array(n + 1); const my = new Float64Array(n + 1); const diag = new Float64Array(n + 1); const rhsX = new Float64Array(n + 1); const rhsY = new Float64Array(n + 1); // Row 0 – natural BC (zero second derivative at start) diag[0] = 2; rhsX[0] = (3 * (points[1][0] - points[0][0])) / h[0]; rhsY[0] = (3 * (points[1][1] - points[0][1])) / h[0]; // Interior rows for (let i = 1; i < n; i++) { diag[i] = 2 * (h[i - 1] + h[i]); rhsX[i] = 3 * ((h[i] * (points[i][0] - points[i - 1][0])) / h[i - 1] + (h[i - 1] * (points[i + 1][0] - points[i][0])) / h[i]); rhsY[i] = 3 * ((h[i] * (points[i][1] - points[i - 1][1])) / h[i - 1] + (h[i - 1] * (points[i + 1][1] - points[i][1])) / h[i]); } // Row n – natural BC (zero second derivative at end) diag[n] = 2; rhsX[n] = (3 * (points[n][0] - points[n - 1][0])) / h[n - 1]; rhsY[n] = (3 * (points[n][1] - points[n - 1][1])) / h[n - 1]; // Forward sweep // sub[i] = h[i] for i=1..n−1, sub[n] = 1 // sup[i] = 1 for i=0, h[i−1] for i=1..n−1 (never modified) for (let i = 1; i <= n; i++) { const sub = i < n ? h[i] : 1; const supPrev = i === 1 ? 1 : h[i - 2]; const w = sub / diag[i - 1]; diag[i] -= w * supPrev; rhsX[i] -= w * rhsX[i - 1]; rhsY[i] -= w * rhsY[i - 1]; } // Back substitution mx[n] = rhsX[n] / diag[n]; my[n] = rhsY[n] / diag[n]; for (let i = n - 1; i >= 0; i--) { const sup = i === 0 ? 1 : h[i - 1]; mx[i] = (rhsX[i] - sup * mx[i + 1]) / diag[i]; my[i] = (rhsY[i] - sup * my[i + 1]) / diag[i]; } // Normalised tangent directions; handle length scales sub-linearly with chord. const mlen = new Float64Array(n + 1); for (let i = 0; i <= n; i++) { mlen[i] = Math.max(1e-10, Math.hypot(mx[i], my[i])); } // At interior knots, blend the C2 tangent direction toward the // perpendicular-to-bisector (the perfectly symmetric tangent) by a factor // proportional to turn sharpness × CP_ANGLE_CORRECTION. // Both cp2 (incoming) and cp1 (outgoing) at the knot share the same adjusted // direction, so collinear (aligned) handles are preserved. for (let k = 1; k < n; k++) { const d1x = (points[k][0] - points[k - 1][0]) / h[k - 1]; const d1y = (points[k][1] - points[k - 1][1]) / h[k - 1]; const d2x = (points[k + 1][0] - points[k][0]) / h[k]; const d2y = (points[k + 1][1] - points[k][1]) / h[k]; const dot = d1x * d2x + d1y * d2y; // t: 0 = straight, 1 = hairpin const t = ((1 - dot) / 2) * CP_ANGLE_CORRECTION; if (t < 1e-6) { continue; } // Bisector of the two chord directions as the "normal" at the knot. // Its perpendicular is the ideal symmetric tangent direction. const bx = d1x + d2x; const by = d1y + d2y; const blen = Math.hypot(bx, by); if (blen < 1e-10) { continue; // 180° hairpin – bisector undefined, skip } // Blend target: bisector direction (pick sign aligning with current tangent) let px = bx / blen; let py = by / blen; const tx = mx[k] / mlen[k]; const ty = my[k] / mlen[k]; if (tx * px + ty * py < 0) { px = -px; py = -py; } // Linear blend of unit directions, then renormalize to preserve magnitude. const blendX = tx + t * (px - tx); const blendY = ty + t * (py - ty); const blendLen = Math.max(1e-10, Math.hypot(blendX, blendY)); mx[k] = (blendX / blendLen) * mlen[k]; my[k] = (blendY / blendLen) * mlen[k]; } const path: string[] = [`M ${points[0][0]} ${points[0][1]}`]; for (let i = 0; i < n; i++) { const cpDist = Math.pow(h[i], CP_CHORD_POWER) / 3; const cp1x = points[i][0] + (mx[i] / mlen[i]) * cpDist; const cp1y = points[i][1] + (my[i] / mlen[i]) * cpDist; const cp2x = points[i + 1][0] - (mx[i + 1] / mlen[i + 1]) * cpDist; const cp2y = points[i + 1][1] - (my[i + 1] / mlen[i + 1]) * cpDist; path.push( `C ${cp1x} ${cp1y} ${cp2x} ${cp2y} ${points[i + 1][0]} ${ points[i + 1][1] }`, ); } return path.join(" "); }; const generateElbowArrowShape = ( points: readonly LocalPoint[], radius: number, ): string => { const subpoints = [] as [number, number][]; for (let i = 1; i < points.length - 1; i += 1) { const prev = points[i - 1]; const next = points[i + 1]; const point = points[i]; const prevIsHorizontal = headingForPointIsHorizontal(point, prev); const nextIsHorizontal = headingForPointIsHorizontal(next, point); const corner = Math.min( radius, pointDistance(points[i], next) / 2, pointDistance(points[i], prev) / 2, ); if (prevIsHorizontal) { if (prev[0] < point[0]) { // LEFT subpoints.push([points[i][0] - corner, points[i][1]]); } else { // RIGHT subpoints.push([points[i][0] + corner, points[i][1]]); } } else if (prev[1] < point[1]) { // UP subpoints.push([points[i][0], points[i][1] - corner]); } else { subpoints.push([points[i][0], points[i][1] + corner]); } subpoints.push(points[i] as [number, number]); if (nextIsHorizontal) { if (next[0] < point[0]) { // LEFT subpoints.push([points[i][0] - corner, points[i][1]]); } else { // RIGHT subpoints.push([points[i][0] + corner, points[i][1]]); } } else if (next[1] < point[1]) { // UP subpoints.push([points[i][0], points[i][1] - corner]); } else { // DOWN subpoints.push([points[i][0], points[i][1] + corner]); } } const d = [`M ${points[0][0]} ${points[0][1]}`]; for (let i = 0; i < subpoints.length; i += 3) { d.push(`L ${subpoints[i][0]} ${subpoints[i][1]}`); d.push( `Q ${subpoints[i + 1][0]} ${subpoints[i + 1][1]}, ${ subpoints[i + 2][0] } ${subpoints[i + 2][1]}`, ); } d.push(`L ${points[points.length - 1][0]} ${points[points.length - 1][1]}`); return d.join(" "); }; /** * get the pure geometric shape of an excalidraw elementw * which is then used for hit detection */ export const getElementShape = ( element: ExcalidrawElement, elementsMap: ElementsMap, ): GeometricShape => { switch (element.type) { case "rectangle": case "diamond": case "frame": case "magicframe": case "embeddable": case "image": case "iframe": case "text": case "selection": return getPolygonShape(element); case "arrow": case "line": { const roughShape = ShapeCache.generateElementShape(element, null)[0]; const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap); return shouldTestInside(element) ? getClosedCurveShape( element, roughShape, pointFrom(element.x, element.y), element.angle, pointFrom(cx, cy), ) : getCurveShape( roughShape, pointFrom(element.x, element.y), element.angle, pointFrom(cx, cy), ); } case "ellipse": return getEllipseShape(element); case "freedraw": { const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap); return getFreedrawShape( element, pointFrom(cx, cy), shouldTestInside(element), ); } } }; export const toggleLinePolygonState = ( element: ExcalidrawLineElement, nextPolygonState: boolean, ): { polygon: ExcalidrawLineElement["polygon"]; points: ExcalidrawLineElement["points"]; } | null => { const updatedPoints = [...element.points]; if (nextPolygonState) { if (!canBecomePolygon(element.points)) { return null; } const firstPoint = updatedPoints[0]; const lastPoint = updatedPoints[updatedPoints.length - 1]; const distance = Math.hypot( firstPoint[0] - lastPoint[0], firstPoint[1] - lastPoint[1], ); if ( distance > LINE_POLYGON_POINT_MERGE_DISTANCE || updatedPoints.length < 4 ) { updatedPoints.push(pointFrom(firstPoint[0], firstPoint[1])); } else { updatedPoints[updatedPoints.length - 1] = pointFrom( firstPoint[0], firstPoint[1], ); } } // TODO: satisfies ElementUpdate const ret = { polygon: nextPolygonState, points: updatedPoints, }; return ret; }; // ----------------------------------------------------------------------------- // freedraw shape helper // ----------------------------------------------------------------------------- // NOTE not cached (-> for SVG export) const getFreeDrawSvgPath = (element: ExcalidrawFreeDrawElement) => { return getSvgPathFromStroke( getFreedrawOutlinePoints(element), ) as SVGPathString; }; 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]]; 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][]; }; 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 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"); }; // -----------------------------------------------------------------------------