Use roughjs to generate the line and freedraw shapes for collision

This commit is contained in:
Mark Tolmacs 2025-05-07 20:04:25 +02:00
parent 14a0cd3a97
commit cd3ca3b4ca
No known key found for this signature in database
2 changed files with 169 additions and 4 deletions

View File

@ -1,8 +1,16 @@
import { simplify } from "points-on-curve"; import { simplify } from "points-on-curve";
import { pointFrom, pointDistance, type LocalPoint } from "@excalidraw/math"; import {
pointFrom,
pointDistance,
type LocalPoint,
curve,
pointFromArray,
} from "@excalidraw/math";
import { ROUGHNESS, isTransparent, assertNever } from "@excalidraw/common"; import { ROUGHNESS, isTransparent, assertNever } from "@excalidraw/common";
import { RoughGenerator } from "roughjs/bin/generator";
import type { Mutable } from "@excalidraw/common/utility-types"; import type { Mutable } from "@excalidraw/common/utility-types";
import type { EmbedsValidationStatus } from "@excalidraw/excalidraw/types"; import type { EmbedsValidationStatus } from "@excalidraw/excalidraw/types";
@ -31,7 +39,6 @@ import type {
} from "./types"; } from "./types";
import type { Drawable, Options } from "roughjs/bin/core"; import type { Drawable, Options } from "roughjs/bin/core";
import type { RoughGenerator } from "roughjs/bin/generator";
import type { Point as RoughPoint } from "roughjs/bin/geometry"; import type { Point as RoughPoint } from "roughjs/bin/geometry";
const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth]; const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth];
@ -61,6 +68,37 @@ function adjustRoughness(element: ExcalidrawElement): number {
return Math.min(roughness / (maxSize < 10 ? 3 : 2), 2.5); return Math.min(roughness / (maxSize < 10 ? 3 : 2), 2.5);
} }
export const generateRoughOptionsForCollision = (
element: ExcalidrawElement,
): Options => {
const options: Options = {
seed: element.seed,
disableMultiStroke: true,
roughness: 0,
preserveVertices: true,
};
switch (element.type) {
case "rectangle":
case "iframe":
case "embeddable":
case "diamond":
case "ellipse": {
if (element.type === "ellipse") {
options.curveFitting = 1;
}
return options;
}
case "line":
case "freedraw":
case "arrow":
return options;
default: {
throw new Error(`Unimplemented type ${element.type}`);
}
}
};
export const generateRoughOptions = ( export const generateRoughOptions = (
element: ExcalidrawElement, element: ExcalidrawElement,
continuousPath = false, continuousPath = false,
@ -303,6 +341,111 @@ const getArrowheadShapes = (
} }
}; };
export const generateComponentsForCollision = (element: ExcalidrawElement) => {
const ops = generateRoughOpsForCollision(element) as {
op: string;
data: number[];
}[];
const components = [];
for (let idx = 0; idx < ops.length; idx += 1) {
const op = ops[idx];
const prevPoint =
ops[idx - 1] && pointFromArray<LocalPoint>(ops[idx - 1].data.slice(-2));
switch (op.op) {
case "move":
continue;
case "bcurveTo":
if (!prevPoint) {
throw new Error("prevPoint is undefined");
}
components.push(
curve(
prevPoint,
pointFrom<LocalPoint>(op.data[0], op.data[1]),
pointFrom<LocalPoint>(op.data[2], op.data[3]),
pointFrom<LocalPoint>(op.data[4], op.data[5]),
),
);
continue;
default: {
console.error("Unknown op type", op.op);
}
}
}
return components;
};
const generateRoughOpsForCollision = (element: ExcalidrawElement) => {
const generator = new RoughGenerator();
switch (element.type) {
case "line":
case "arrow": {
let shape: any;
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<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),
generateRoughOptionsForCollision(element),
).sets[0].ops;
}
} 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)
.sets[0].ops;
} else {
shape = generator.linearPath(
points as unknown as RoughPoint[],
options,
).sets[0].ops;
}
} else {
shape = generator.curve(points as unknown as RoughPoint[], options)
.sets[0].ops;
}
return shape.slice(0, element.points.length);
}
case "freedraw": {
const simplifiedPoints = simplify(
element.points as Mutable<LocalPoint[]>,
0.75,
);
return generator
.curve(
simplifiedPoints as [number, number][],
generateRoughOptionsForCollision(element),
)
.sets[0].ops.slice(0, element.points.length);
}
}
};
/** /**
* Generates the roughjs shape for given element. * Generates the roughjs shape for given element.
* *

View File

@ -4,7 +4,9 @@ import {
arrayToMap, arrayToMap,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { import {
curve,
curveIntersectLineSegment, curveIntersectLineSegment,
isCurve,
isPointWithinBounds, isPointWithinBounds,
lineSegment, lineSegment,
lineSegmentIntersectionPoints, lineSegmentIntersectionPoints,
@ -25,7 +27,7 @@ import type { GlobalPoint, LineSegment, Radians } from "@excalidraw/math";
import type { FrameNameBounds } from "@excalidraw/excalidraw/types"; import type { FrameNameBounds } from "@excalidraw/excalidraw/types";
import { isPathALoop } from "./shapes"; import { isPathALoop } from "./shapes";
import { getCommonBounds, getElementBounds } from "./bounds"; import { getElementBounds } from "./bounds";
import { import {
hasBoundTextElement, hasBoundTextElement,
isIframeLikeElement, isIframeLikeElement,
@ -42,6 +44,8 @@ import { getBoundTextElement } from "./textElement";
import { LinearElementEditor } from "./linearElementEditor"; import { LinearElementEditor } from "./linearElementEditor";
import { generateComponentsForCollision } from "./Shape";
import type { import type {
ElementsMap, ElementsMap,
ExcalidrawDiamondElement, ExcalidrawDiamondElement,
@ -50,6 +54,8 @@ import type {
ExcalidrawRectanguloidElement, ExcalidrawRectanguloidElement,
} from "./types"; } from "./types";
import { debugDrawCubicBezier } from "@excalidraw/excalidraw/visualdebug";
export const shouldTestInside = (element: ExcalidrawElement) => { export const shouldTestInside = (element: ExcalidrawElement) => {
if (element.type === "arrow") { if (element.type === "arrow") {
return false; return false;
@ -101,6 +107,21 @@ export const hitElementItself = ({
: isPointOnShape(point, element, threshold) : isPointOnShape(point, element, threshold)
: false; : false;
element.type === "freedraw" &&
generateComponentsForCollision(element).forEach((c) => {
if (isCurve(c)) {
debugDrawCubicBezier(
curve(
pointFrom<GlobalPoint>(element.x + c[0][0], element.y + c[0][1]),
pointFrom<GlobalPoint>(element.x + c[1][0], element.y + c[1][1]),
pointFrom<GlobalPoint>(element.x + c[2][0], element.y + c[2][1]),
pointFrom<GlobalPoint>(element.x + c[3][0], element.y + c[3][1]),
),
{ color: "red" },
);
}
});
// hit test against a frame's name // hit test against a frame's name
if (!hit && frameNameBound) { if (!hit && frameNameBound) {
const x1 = frameNameBound.x - threshold; const x1 = frameNameBound.x - threshold;
@ -195,7 +216,8 @@ export const intersectElementWithLineSegment = (
case "line": case "line":
case "freedraw": case "freedraw":
case "arrow": case "arrow":
throw new Error(`Unimplemented element type '${element.type}'`); return [];
//throw new Error(`Unimplemented element type '${element.type}'`);
} }
}; };