From 9203c99eec7d3832af155d373c5c343bdac622ce Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Wed, 7 May 2025 20:04:25 +0200 Subject: [PATCH] Use roughjs to generate the line and freedraw shapes for collision --- packages/element/src/Shape.ts | 147 +++++++++++++++++++++++++++++- packages/element/src/collision.ts | 26 +++++- 2 files changed, 169 insertions(+), 4 deletions(-) diff --git a/packages/element/src/Shape.ts b/packages/element/src/Shape.ts index 4def41957..31ae391fa 100644 --- a/packages/element/src/Shape.ts +++ b/packages/element/src/Shape.ts @@ -1,8 +1,16 @@ 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 { RoughGenerator } from "roughjs/bin/generator"; + import type { Mutable } from "@excalidraw/common/utility-types"; import type { EmbedsValidationStatus } from "@excalidraw/excalidraw/types"; @@ -31,7 +39,6 @@ import type { } 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]; @@ -61,6 +68,37 @@ function adjustRoughness(element: ExcalidrawElement): number { 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 = ( element: ExcalidrawElement, 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(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(op.data[0], op.data[1]), + pointFrom(op.data[2], op.data[3]), + pointFrom(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(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, + 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. * diff --git a/packages/element/src/collision.ts b/packages/element/src/collision.ts index 7456b2a2f..d8810c5a7 100644 --- a/packages/element/src/collision.ts +++ b/packages/element/src/collision.ts @@ -4,7 +4,9 @@ import { arrayToMap, } from "@excalidraw/common"; import { + curve, curveIntersectLineSegment, + isCurve, isPointWithinBounds, lineSegment, lineSegmentIntersectionPoints, @@ -25,7 +27,7 @@ import type { GlobalPoint, LineSegment, Radians } from "@excalidraw/math"; import type { FrameNameBounds } from "@excalidraw/excalidraw/types"; import { isPathALoop } from "./shapes"; -import { getCommonBounds, getElementBounds } from "./bounds"; +import { getElementBounds } from "./bounds"; import { hasBoundTextElement, isIframeLikeElement, @@ -42,6 +44,8 @@ import { getBoundTextElement } from "./textElement"; import { LinearElementEditor } from "./linearElementEditor"; +import { generateComponentsForCollision } from "./Shape"; + import type { ElementsMap, ExcalidrawDiamondElement, @@ -50,6 +54,8 @@ import type { ExcalidrawRectanguloidElement, } from "./types"; +import { debugDrawCubicBezier } from "@excalidraw/excalidraw/visualdebug"; + export const shouldTestInside = (element: ExcalidrawElement) => { if (element.type === "arrow") { return false; @@ -101,6 +107,21 @@ export const hitElementItself = ({ : isPointOnShape(point, element, threshold) : false; + element.type === "freedraw" && + generateComponentsForCollision(element).forEach((c) => { + if (isCurve(c)) { + debugDrawCubicBezier( + curve( + pointFrom(element.x + c[0][0], element.y + c[0][1]), + pointFrom(element.x + c[1][0], element.y + c[1][1]), + pointFrom(element.x + c[2][0], element.y + c[2][1]), + pointFrom(element.x + c[3][0], element.y + c[3][1]), + ), + { color: "red" }, + ); + } + }); + // hit test against a frame's name if (!hit && frameNameBound) { const x1 = frameNameBound.x - threshold; @@ -195,7 +216,8 @@ export const intersectElementWithLineSegment = ( case "line": case "freedraw": case "arrow": - throw new Error(`Unimplemented element type '${element.type}'`); + return []; + //throw new Error(`Unimplemented element type '${element.type}'`); } };