9dceb40a4f
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
1488 lines
43 KiB
TypeScript
1488 lines
43 KiB
TypeScript
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 = <T extends ExcalidrawElement>(
|
||
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<ExcalidrawElement, ExcalidrawSelectionElement>,
|
||
>(
|
||
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<LocalPoint>(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<GlobalPoint>(element.x + point[0], element.y + point[1]),
|
||
center,
|
||
element.angle,
|
||
);
|
||
|
||
return {
|
||
op: idx === 0 ? "move" : "lineTo",
|
||
data: pointFrom<LocalPoint>(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<GlobalPoint>(
|
||
pointFrom<GlobalPoint>(element.x + lx, element.y + ly),
|
||
center,
|
||
element.angle,
|
||
);
|
||
return pointFrom<LocalPoint>(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<LocalPoint[]>,
|
||
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<GlobalPoint>(
|
||
pointFrom<GlobalPoint>(
|
||
element.x + op.data[0],
|
||
element.y + op.data[1],
|
||
),
|
||
center,
|
||
element.angle,
|
||
);
|
||
|
||
return {
|
||
op: "move",
|
||
data: pointFrom<LocalPoint>(p[0] - element.x, p[1] - element.y),
|
||
};
|
||
}
|
||
|
||
return {
|
||
op: "bcurveTo",
|
||
data: [
|
||
pointRotateRads(
|
||
pointFrom<GlobalPoint>(
|
||
element.x + op.data[0],
|
||
element.y + op.data[1],
|
||
),
|
||
center,
|
||
element.angle,
|
||
),
|
||
pointRotateRads(
|
||
pointFrom<GlobalPoint>(
|
||
element.x + op.data[2],
|
||
element.y + op.data[3],
|
||
),
|
||
center,
|
||
element.angle,
|
||
),
|
||
pointRotateRads(
|
||
pointFrom<GlobalPoint>(
|
||
element.x + op.data[4],
|
||
element.y + op.data[5],
|
||
),
|
||
center,
|
||
element.angle,
|
||
),
|
||
]
|
||
.map((p) =>
|
||
pointFrom<LocalPoint>(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<NonDeletedExcalidrawElement, ExcalidrawSelectionElement>,
|
||
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<LocalPoint>(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<LocalPoint[]>,
|
||
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 = <Point extends GlobalPoint | LocalPoint>(
|
||
element: ExcalidrawElement,
|
||
elementsMap: ElementsMap,
|
||
): GeometricShape<Point> => {
|
||
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<Point>(
|
||
element,
|
||
roughShape,
|
||
pointFrom<Point>(element.x, element.y),
|
||
element.angle,
|
||
pointFrom(cx, cy),
|
||
)
|
||
: getCurveShape<Point>(
|
||
roughShape,
|
||
pointFrom<Point>(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<ExcalidrawLineElement>
|
||
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");
|
||
};
|
||
|
||
// -----------------------------------------------------------------------------
|