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"; import { isElbowArrow, isEmbeddableElement, isIframeElement, isIframeLikeElement, isLinearElement, } from "./typeChecks"; import { getCornerRadius, isPathALoop } from "./shapes"; import { headingForPointIsHorizontal } from "./heading"; import { canChangeRoundness } from "./comparisons"; import { generateFreeDrawShape } from "./renderElement"; import { getArrowheadPoints, getDiamondPoints } from "./bounds"; import type { ExcalidrawElement, NonDeletedExcalidrawElement, ExcalidrawSelectionElement, ExcalidrawLinearElement, Arrowhead, ExcalidrawRegularPolygonElement, } from "./types"; import type { Drawable, Options } from "roughjs/bin/core"; import type { RoughGenerator } from "roughjs/bin/generator"; import type { Point as RoughPoint } from "roughjs/bin/geometry"; 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, ): 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: 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 : element.backgroundColor; if (element.type === "ellipse") { options.curveFitting = 1; } return options; } case "regularPolygon": { options.fillStyle = element.fillStyle; options.fill = isTransparent(element.backgroundColor) ? undefined : element.backgroundColor; // Add any specific options for polygons if needed, otherwise just return return options; } case "line": case "freedraw": { if (isPathALoop(element.points)) { options.fillStyle = element.fillStyle; options.fill = element.backgroundColor === "transparent" ? undefined : element.backgroundColor; } return options; } case "arrow": return options; default: { throw new Error(`Unimplemented type ${element.type}`); } } }; /** * Returns the points for a regular polygon with the specified number of sides, * centered within the element's bounds. */ export const getRegularPolygonPoints = ( element: ExcalidrawElement, sides: number = 6 ): [number, number][] => { // Minimum number of sides for a polygon is 3 if (sides < 3) { sides = 3; } const width = element.width; const height = element.height; // Center of the element const cx = width / 2; const cy = height / 2; // Use the smaller dimension to ensure polygon fits within the element bounds const radius = Math.min(width, height) / 2; // Calculate points for the regular polygon const points: [number, number][] = []; // For regular polygons, we want to start from the top (angle = -π/2) // so that polygons like hexagons have a flat top const startAngle = -Math.PI / 2; for (let i = 0; i < sides; i++) { // Calculate angle for this vertex const angle = startAngle + (2 * Math.PI * i) / sides; // Calculate x and y for this vertex const x = cx + radius * Math.cos(angle); const y = cy + radius * Math.sin(angle); points.push([x, y]); } return points; }; 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 getArrowheadShapes = ( element: ExcalidrawLinearElement, shape: Drawable[], position: "start" | "end", arrowhead: Arrowhead, generator: RoughGenerator, options: Options, canvasBackgroundColor: string, ) => { const arrowheadPoints = getArrowheadPoints( element, shape, position, arrowhead, ); if (arrowheadPoints === null) { return []; } const generateCrowfootOne = ( arrowheadPoints: number[] | null, options: Options, ) => { if (arrowheadPoints === null) { return []; } const [, , x3, y3, x4, y4] = arrowheadPoints; return [generator.line(x3, y3, x4, y4, options)]; }; switch (arrowhead) { case "dot": case "circle": case "circle_outline": { const [x, y, diameter] = arrowheadPoints; // always use solid stroke for arrowhead delete options.strokeLineDash; return [ generator.circle(x, y, diameter, { ...options, fill: arrowhead === "circle_outline" ? canvasBackgroundColor : element.strokeColor, fillStyle: "solid", stroke: element.strokeColor, roughness: Math.min(0.5, options.roughness || 0), }), ]; } case "triangle": case "triangle_outline": { const [x, y, x2, y2, x3, y3] = arrowheadPoints; // always use solid stroke for arrowhead delete options.strokeLineDash; return [ generator.polygon( [ [x, y], [x2, y2], [x3, y3], [x, y], ], { ...options, fill: arrowhead === "triangle_outline" ? canvasBackgroundColor : element.strokeColor, fillStyle: "solid", roughness: Math.min(1, options.roughness || 0), }, ), ]; } case "diamond": case "diamond_outline": { const [x, y, x2, y2, x3, y3, x4, y4] = arrowheadPoints; // always use solid stroke for arrowhead delete options.strokeLineDash; return [ generator.polygon( [ [x, y], [x2, y2], [x3, y3], [x4, y4], [x, y], ], { ...options, fill: arrowhead === "diamond_outline" ? canvasBackgroundColor : element.strokeColor, fillStyle: "solid", roughness: Math.min(1, options.roughness || 0), }, ), ]; } case "crowfoot_one": return generateCrowfootOne(arrowheadPoints, options); case "bar": case "arrow": case "crowfoot_many": case "crowfoot_one_or_many": default: { const [x2, y2, x3, y3, x4, y4] = arrowheadPoints; if (element.strokeStyle === "dotted") { // for dotted arrows caps, reduce gap to make it more legible const dash = getDashArrayDotted(element.strokeWidth - 1); options.strokeLineDash = [dash[0], dash[1] - 1]; } else { // for solid/dashed, keep solid arrow cap delete options.strokeLineDash; } options.roughness = Math.min(1, options.roughness || 0); return [ generator.line(x3, y3, x2, y2, options), generator.line(x4, y4, x2, y2, options), ...(arrowhead === "crowfoot_one_or_many" ? generateCrowfootOne( getArrowheadPoints(element, shape, position, "crowfoot_one"), options, ) : []), ]; } } }; /** * Generates the roughjs shape for given element. * * Low-level. Use `ShapeCache.generateElementShape` instead. * * @private */ export const _generateElementShape = ( element: Exclude, generator: RoughGenerator, { isExporting, canvasBackgroundColor, embedsValidationStatus, }: { isExporting: boolean; canvasBackgroundColor: string; embedsValidationStatus: EmbedsValidationStatus | null; }, ): Drawable | Drawable[] | null => { 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, ), ); } else { shape = generator.rectangle( 0, 0, element.width, element.height, generateRoughOptions( modifyIframeLikeForRoughOptions( element, isExporting, embedsValidationStatus, ), false, ), ); } 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), ); } else { shape = generator.polygon( [ [topX, topY], [rightX, rightY], [bottomX, bottomY], [leftX, leftY], ], generateRoughOptions(element), ); } return shape; } case "ellipse": { const shape: ElementShapes[typeof element.type] = generator.ellipse( element.width / 2, element.height / 2, element.width, element.height, generateRoughOptions(element), ); return shape; } case "line": case "arrow": { let shape: ElementShapes[typeof element.type]; const options = generateRoughOptions(element); // 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), ), ]; } } 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.curve(points as unknown as RoughPoint[], options)]; } // 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, ); 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, ); shape.push(...shapes); } } return shape; } case "freedraw": { let shape: ElementShapes[typeof element.type]; generateFreeDrawShape(element); if (isPathALoop(element.points)) { // generate rough polygon to fill freedraw shape const simplifiedPoints = simplify( element.points as Mutable, 0.75, ); shape = generator.curve(simplifiedPoints as [number, number][], { ...generateRoughOptions(element), stroke: "none", }); } else { shape = null; } return shape; } 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; } case "regularPolygon": { let shape: ElementShapes[typeof element.type]; const points = getRegularPolygonPoints( element, (element as ExcalidrawRegularPolygonElement).sides ); if (element.roundness) { // For rounded corners, we create a path with smooth corners // using quadratic Bézier curves, similar to the diamond shape const options = generateRoughOptions(element, true); // Calculate appropriate corner radius based on element size const radius = getCornerRadius( Math.min(element.width, element.height) / 4, element ); const pathData: string[] = []; // Process each vertex to create rounded corners between edges for (let i = 0; i < points.length; i++) { const current = points[i]; const next = points[(i + 1) % points.length]; const prev = points[(i - 1 + points.length) % points.length]; // Calculate vectors to previous and next points const toPrev = [prev[0] - current[0], prev[1] - current[1]]; const toNext = [next[0] - current[0], next[1] - current[1]]; // Normalize vectors and calculate corner points const toPrevLength = Math.sqrt(toPrev[0] * toPrev[0] + toPrev[1] * toPrev[1]); const toNextLength = Math.sqrt(toNext[0] * toNext[0] + toNext[1] * toNext[1]); // Move inward from vertex toward previous point (limited by half the distance) const prevCorner = [ current[0] + (toPrev[0] / toPrevLength) * Math.min(radius, toPrevLength / 2), current[1] + (toPrev[1] / toPrevLength) * Math.min(radius, toPrevLength / 2) ]; // Move inward from vertex toward next point (limited by half the distance) const nextCorner = [ current[0] + (toNext[0] / toNextLength) * Math.min(radius, toNextLength / 2), current[1] + (toNext[1] / toNextLength) * Math.min(radius, toNextLength / 2) ]; // First point needs a move command, others need line commands if (i === 0) { pathData.push(`M ${nextCorner[0]} ${nextCorner[1]}`); } else { // Draw line to the corner coming from previous point pathData.push(`L ${prevCorner[0]} ${prevCorner[1]}`); // Draw a quadratic curve around the current vertex to the corner going to next point pathData.push(`Q ${current[0]} ${current[1]}, ${nextCorner[0]} ${nextCorner[1]}`); } } // Close the path to create a complete shape pathData.push("Z"); shape = generator.path(pathData.join(" "), options); } else { // For non-rounded corners, use the simple polygon generator shape = generator.polygon(points, generateRoughOptions(element)); } return shape; } default: { assertNever( element, `generateElementShape(): Unimplemented type ${(element as any)?.type}`, ); return null; } } }; const generateElbowArrowShape = ( points: readonly LocalPoint[], radius: number, ) => { 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(" "); };