diff --git a/packages/element/src/distance.ts b/packages/element/src/distance.ts index c94652a1aa..a0e9145834 100644 --- a/packages/element/src/distance.ts +++ b/packages/element/src/distance.ts @@ -2,6 +2,7 @@ import { curvePointDistance, distanceToLineSegment, pointRotateRads, + polygonIncludesPointNonZero, } from "@excalidraw/math"; import { ellipse, ellipseDistanceFromPoint } from "@excalidraw/math/ellipse"; @@ -47,8 +48,9 @@ export const distanceToElement = ( return distanceToEllipseElement(element, elementsMap, p); case "line": case "arrow": - case "freedraw": return distanceToLinearOrFreeDraElement(element, elementsMap, p); + case "freedraw": + return distanceToFreeDrawElement(element, elementsMap, p); } }; @@ -145,3 +147,30 @@ const distanceToLinearOrFreeDraElement = ( ...curves.map((a) => curvePointDistance(a, p)), ); }; + +/** + * Returns the distance of a point to a freedraw element. + * + * @param element The freedraw element + * @param p The point to consider + * @returns 0 if the point is within the inked area, otherwise the euclidean + * distance to the stroke outline + */ +const distanceToFreeDrawElement = ( + element: ExcalidrawFreeDrawElement, + elementsMap: ElementsMap, + p: GlobalPoint, +) => { + const [lines] = deconstructLinearOrFreeDrawElement(element, elementsMap); + + if (lines.length === 0) { + return Infinity; + } + + const polygon = lines.map((line) => line[0]); + if (polygonIncludesPointNonZero(p, polygon)) { + return 0; + } + + return Math.min(...lines.map((s) => distanceToLineSegment(p, s))); +}; diff --git a/packages/element/src/shape.ts b/packages/element/src/shape.ts index a0e5f42c69..e29fa93e1a 100644 --- a/packages/element/src/shape.ts +++ b/packages/element/src/shape.ts @@ -677,69 +677,45 @@ export const generateLinearCollisionShape = ( }); } case "freedraw": { - if (element.points.length < 2) { + const outlinePoints = getFreedrawOutlinePoints(element); + + if (outlinePoints.length < 2) { return []; } - const simplifiedPoints = simplify( - element.points as Mutable, - 0.75, - ); + const collisionOutline = + element.strokeOptions?.variability === "constant" && + CONSTANT_WIDTH_FREEDRAW.STREAMLINE > 0 + ? (simplify( + outlinePoints as Mutable, + CONSTANT_WIDTH_FREEDRAW.STREAMLINE, + ) as [number, number][]) + : outlinePoints; - 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, - ); + if (collisionOutline.length < 2) { + return []; + } - return { - op: "move", - data: pointFrom(p[0] - element.x, p[1] - element.y), - }; - } + // Close the outline polygon so its boundary never has a gap at the seam. + const [firstX, firstY] = collisionOutline[0]; + const [lastX, lastY] = collisionOutline[collisionOutline.length - 1]; + const closedOutline = + firstX === lastX && firstY === lastY + ? collisionOutline + : [...collisionOutline, collisionOutline[0]]; - 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(), - }; - }); + return closedOutline.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), + }; + }); } } }; diff --git a/packages/element/tests/collision.test.tsx b/packages/element/tests/collision.test.tsx index a44f1f7bb0..b1b7e1c65a 100644 --- a/packages/element/tests/collision.test.tsx +++ b/packages/element/tests/collision.test.tsx @@ -218,3 +218,54 @@ describe("hitElementItself cache", () => { ).toBe(true); }); }); + +describe("freedraw collision matches the rendered stroke width", () => { + // A straight, horizontal stroke centered on y === 0. + const points = Array.from({ length: 21 }, (_, i) => + pointFrom(i * 5, 0), + ); + + const createFreeDraw = (variability: "variable" | "constant") => + API.createElement({ + type: "freedraw", + x: 0, + y: 0, + strokeWidth: 10, + points, + strokeOptions: { variability, streamline: 0.5 }, + }); + + const distanceAt = ( + element: ReturnType, + x: number, + y: number, + ) => + distance.distanceToElement( + element, + arrayToMap([element]), + pointFrom(x, y), + ); + + it("treats a point on the centerline as a direct hit for both modes", () => { + expect(distanceAt(createFreeDraw("variable"), 50, 0)).toBe(0); + expect(distanceAt(createFreeDraw("constant"), 50, 0)).toBe(0); + }); + + it("hits across the body of a thick stroke, not just near the centerline", () => { + expect(distanceAt(createFreeDraw("variable"), 50, 20)).toBe(0); + }); + + it("uses a wider hit area for variable-width than for constant-width strokes", () => { + const offset = 20; + const variableDistance = distanceAt(createFreeDraw("variable"), 50, offset); + const constantDistance = distanceAt(createFreeDraw("constant"), 50, offset); + + expect(variableDistance).toBe(0); + expect(constantDistance).toBeGreaterThan(0); + expect(variableDistance).toBeLessThan(constantDistance); + }); + + it("does not hit points clearly outside even the widest stroke", () => { + expect(distanceAt(createFreeDraw("variable"), 50, 45)).toBeGreaterThan(0); + }); +});