Selaa lähdekoodia

feat: Precise hit testing (#9488)

Márk Tolmács 1 kuukausi sitten
vanhempi
commit
ca1a4f25e7
52 muutettua tiedostoa jossa 1959 lisäystä ja 2082 poistoa
  1. 5 6
      packages/common/src/utils.ts
  2. 184 3
      packages/element/src/Shape.ts
  3. 142 127
      packages/element/src/binding.ts
  4. 35 3
      packages/element/src/bounds.ts
  5. 318 132
      packages/element/src/collision.ts
  6. 2 1
      packages/element/src/cropElement.ts
  7. 37 9
      packages/element/src/distance.ts
  8. 23 56
      packages/element/src/elbowArrow.ts
  9. 5 4
      packages/element/src/flowchart.ts
  10. 2 1
      packages/element/src/shapes.ts
  11. 2 1
      packages/element/src/types.ts
  12. 313 238
      packages/element/src/utils.ts
  13. 10 2
      packages/element/tests/align.test.tsx
  14. 2 2
      packages/element/tests/binding.test.tsx
  15. 38 0
      packages/element/tests/collision.test.tsx
  16. 1 1
      packages/element/tests/linearElementEditor.test.tsx
  17. 6 6
      packages/element/tests/resize.test.tsx
  18. 0 11
      packages/element/tests/sizeHelpers.test.ts
  19. 13 61
      packages/excalidraw/actions/actionProperties.tsx
  20. 2 1
      packages/excalidraw/appState.ts
  21. 69 135
      packages/excalidraw/components/App.tsx
  22. 1 1
      packages/excalidraw/components/Stats/stats.test.tsx
  23. 1 1
      packages/excalidraw/components/hyperlink/Hyperlink.tsx
  24. 1 1
      packages/excalidraw/components/hyperlink/helpers.ts
  25. 3 3
      packages/excalidraw/data/__snapshots__/transform.test.ts.snap
  26. 1 1
      packages/excalidraw/data/transform.test.ts
  27. 40 71
      packages/excalidraw/eraser/index.ts
  28. 1 0
      packages/excalidraw/lasso/index.ts
  29. 58 26
      packages/excalidraw/lasso/utils.ts
  30. 96 160
      packages/excalidraw/renderer/helpers.ts
  31. 9 11
      packages/excalidraw/renderer/interactiveScene.ts
  32. 132 230
      packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap
  33. 4 12
      packages/excalidraw/tests/__snapshots__/dragCreate.test.tsx.snap
  34. 1 1
      packages/excalidraw/tests/__snapshots__/export.test.tsx.snap
  35. 113 223
      packages/excalidraw/tests/__snapshots__/history.test.tsx.snap
  36. 11 21
      packages/excalidraw/tests/__snapshots__/move.test.tsx.snap
  37. 1 3
      packages/excalidraw/tests/__snapshots__/multiPointCreate.test.tsx.snap
  38. 98 224
      packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap
  39. 4 12
      packages/excalidraw/tests/__snapshots__/selection.test.tsx.snap
  40. 10 10
      packages/excalidraw/tests/contextmenu.test.tsx
  41. 6 18
      packages/excalidraw/tests/data/__snapshots__/restore.test.ts.snap
  42. 22 4
      packages/excalidraw/tests/helpers/ui.ts
  43. 1 0
      packages/excalidraw/tests/lasso.test.tsx
  44. 2 2
      packages/excalidraw/tests/move.test.tsx
  45. 1 1
      packages/excalidraw/tests/rotate.test.tsx
  46. 2 4
      packages/excalidraw/wysiwyg/textWysiwyg.test.tsx
  47. 117 14
      packages/math/src/curve.ts
  48. 1 0
      packages/math/src/index.ts
  49. 12 2
      packages/math/src/vector.ts
  50. 0 135
      packages/utils/src/collision.ts
  51. 1 1
      packages/utils/tests/__snapshots__/export.test.ts.snap
  52. 0 90
      packages/utils/tests/collision.test.ts

+ 5 - 6
packages/common/src/utils.ts

@@ -1,10 +1,12 @@
 import { average, pointFrom, type GlobalPoint } from "@excalidraw/math";
 import { average, pointFrom, type GlobalPoint } from "@excalidraw/math";
+import { getCenterForBounds, getElementBounds } from "@excalidraw/element";
 
 
 import type {
 import type {
   ExcalidrawBindableElement,
   ExcalidrawBindableElement,
   FontFamilyValues,
   FontFamilyValues,
   FontString,
   FontString,
   ExcalidrawElement,
   ExcalidrawElement,
+  ElementsMap,
 } from "@excalidraw/element/types";
 } from "@excalidraw/element/types";
 
 
 import type {
 import type {
@@ -1240,16 +1242,13 @@ export const castArray = <T>(value: T | T[]): T[] =>
 
 
 export const elementCenterPoint = (
 export const elementCenterPoint = (
   element: ExcalidrawElement,
   element: ExcalidrawElement,
+  elementsMap: ElementsMap,
   xOffset: number = 0,
   xOffset: number = 0,
   yOffset: number = 0,
   yOffset: number = 0,
 ) => {
 ) => {
-  const { x, y, width, height } = element;
+  const [x, y] = getCenterForBounds(getElementBounds(element, elementsMap));
 
 
-  const centerXPoint = x + width / 2 + xOffset;
-
-  const centerYPoint = y + height / 2 + yOffset;
-
-  return pointFrom<GlobalPoint>(centerXPoint, centerYPoint);
+  return pointFrom<GlobalPoint>(x + xOffset, y + yOffset);
 };
 };
 
 
 /** hack for Array.isArray type guard not working with readonly value[] */
 /** hack for Array.isArray type guard not working with readonly value[] */

+ 184 - 3
packages/element/src/Shape.ts

@@ -1,8 +1,17 @@
 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,
+  pointRotateRads,
+} 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 { GlobalPoint } from "@excalidraw/math";
+
 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";
@@ -20,7 +29,12 @@ import { headingForPointIsHorizontal } from "./heading";
 
 
 import { canChangeRoundness } from "./comparisons";
 import { canChangeRoundness } from "./comparisons";
 import { generateFreeDrawShape } from "./renderElement";
 import { generateFreeDrawShape } from "./renderElement";
-import { getArrowheadPoints, getDiamondPoints } from "./bounds";
+import {
+  getArrowheadPoints,
+  getCenterForBounds,
+  getDiamondPoints,
+  getElementBounds,
+} from "./bounds";
 
 
 import type {
 import type {
   ExcalidrawElement,
   ExcalidrawElement,
@@ -28,10 +42,11 @@ import type {
   ExcalidrawSelectionElement,
   ExcalidrawSelectionElement,
   ExcalidrawLinearElement,
   ExcalidrawLinearElement,
   Arrowhead,
   Arrowhead,
+  ExcalidrawFreeDrawElement,
+  ElementsMap,
 } 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];
@@ -303,6 +318,172 @@ const getArrowheadShapes = (
   }
   }
 };
 };
 
 
+export const generateLinearCollisionShape = (
+  element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
+  elementsMap: ElementsMap,
+) => {
+  const generator = new RoughGenerator();
+  const options: Options = {
+    seed: element.seed,
+    disableMultiStroke: true,
+    disableMultiStrokeFill: true,
+    roughness: 0,
+    preserveVertices: true,
+  };
+  const center = getCenterForBounds(
+    getElementBounds(element, elementsMap, true),
+  );
+
+  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),
+          };
+        });
+      }
+
+      return generator
+        .curve(points as unknown as RoughPoint[], 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(),
+          };
+        });
+    }
+    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.
  * Generates the roughjs shape for given element.
  *
  *

+ 142 - 127
packages/element/src/binding.ts

@@ -27,8 +27,6 @@ import {
   PRECISION,
   PRECISION,
 } from "@excalidraw/math";
 } from "@excalidraw/math";
 
 
-import { isPointOnShape } from "@excalidraw/utils/collision";
-
 import type { LocalPoint, Radians } from "@excalidraw/math";
 import type { LocalPoint, Radians } from "@excalidraw/math";
 
 
 import type { AppState } from "@excalidraw/excalidraw/types";
 import type { AppState } from "@excalidraw/excalidraw/types";
@@ -41,7 +39,7 @@ import {
   doBoundsIntersect,
   doBoundsIntersect,
 } from "./bounds";
 } from "./bounds";
 import { intersectElementWithLineSegment } from "./collision";
 import { intersectElementWithLineSegment } from "./collision";
-import { distanceToBindableElement } from "./distance";
+import { distanceToElement } from "./distance";
 import {
 import {
   headingForPointFromElement,
   headingForPointFromElement,
   headingIsHorizontal,
   headingIsHorizontal,
@@ -63,7 +61,7 @@ import {
   isTextElement,
   isTextElement,
 } from "./typeChecks";
 } from "./typeChecks";
 
 
-import { aabbForElement, getElementShape, pointInsideBounds } from "./shapes";
+import { aabbForElement } from "./shapes";
 import { updateElbowArrowPoints } from "./elbowArrow";
 import { updateElbowArrowPoints } from "./elbowArrow";
 
 
 import type { Scene } from "./Scene";
 import type { Scene } from "./Scene";
@@ -109,7 +107,6 @@ export const isBindingEnabled = (appState: AppState): boolean => {
 
 
 export const FIXED_BINDING_DISTANCE = 5;
 export const FIXED_BINDING_DISTANCE = 5;
 export const BINDING_HIGHLIGHT_THICKNESS = 10;
 export const BINDING_HIGHLIGHT_THICKNESS = 10;
-export const BINDING_HIGHLIGHT_OFFSET = 4;
 
 
 const getNonDeletedElements = (
 const getNonDeletedElements = (
   scene: Scene,
   scene: Scene,
@@ -131,6 +128,7 @@ export const bindOrUnbindLinearElement = (
   endBindingElement: ExcalidrawBindableElement | null | "keep",
   endBindingElement: ExcalidrawBindableElement | null | "keep",
   scene: Scene,
   scene: Scene,
 ): void => {
 ): void => {
+  const elementsMap = scene.getNonDeletedElementsMap();
   const boundToElementIds: Set<ExcalidrawBindableElement["id"]> = new Set();
   const boundToElementIds: Set<ExcalidrawBindableElement["id"]> = new Set();
   const unboundFromElementIds: Set<ExcalidrawBindableElement["id"]> = new Set();
   const unboundFromElementIds: Set<ExcalidrawBindableElement["id"]> = new Set();
   bindOrUnbindLinearElementEdge(
   bindOrUnbindLinearElementEdge(
@@ -141,6 +139,7 @@ export const bindOrUnbindLinearElement = (
     boundToElementIds,
     boundToElementIds,
     unboundFromElementIds,
     unboundFromElementIds,
     scene,
     scene,
+    elementsMap,
   );
   );
   bindOrUnbindLinearElementEdge(
   bindOrUnbindLinearElementEdge(
     linearElement,
     linearElement,
@@ -150,6 +149,7 @@ export const bindOrUnbindLinearElement = (
     boundToElementIds,
     boundToElementIds,
     unboundFromElementIds,
     unboundFromElementIds,
     scene,
     scene,
+    elementsMap,
   );
   );
 
 
   const onlyUnbound = Array.from(unboundFromElementIds).filter(
   const onlyUnbound = Array.from(unboundFromElementIds).filter(
@@ -176,6 +176,7 @@ const bindOrUnbindLinearElementEdge = (
   // Is mutated
   // Is mutated
   unboundFromElementIds: Set<ExcalidrawBindableElement["id"]>,
   unboundFromElementIds: Set<ExcalidrawBindableElement["id"]>,
   scene: Scene,
   scene: Scene,
+  elementsMap: ElementsMap,
 ): void => {
 ): void => {
   // "keep" is for method chaining convenience, a "no-op", so just bail out
   // "keep" is for method chaining convenience, a "no-op", so just bail out
   if (bindableElement === "keep") {
   if (bindableElement === "keep") {
@@ -216,43 +217,29 @@ const bindOrUnbindLinearElementEdge = (
   }
   }
 };
 };
 
 
-const getOriginalBindingIfStillCloseOfLinearElementEdge = (
-  linearElement: NonDeleted<ExcalidrawLinearElement>,
-  edge: "start" | "end",
-  elementsMap: NonDeletedSceneElementsMap,
-  zoom?: AppState["zoom"],
-): NonDeleted<ExcalidrawElement> | null => {
-  const coors = getLinearElementEdgeCoors(linearElement, edge, elementsMap);
-  const elementId =
-    edge === "start"
-      ? linearElement.startBinding?.elementId
-      : linearElement.endBinding?.elementId;
-  if (elementId) {
-    const element = elementsMap.get(elementId);
-    if (
-      isBindableElement(element) &&
-      bindingBorderTest(element, coors, elementsMap, zoom)
-    ) {
-      return element;
-    }
-  }
-
-  return null;
-};
-
 const getOriginalBindingsIfStillCloseToArrowEnds = (
 const getOriginalBindingsIfStillCloseToArrowEnds = (
   linearElement: NonDeleted<ExcalidrawLinearElement>,
   linearElement: NonDeleted<ExcalidrawLinearElement>,
   elementsMap: NonDeletedSceneElementsMap,
   elementsMap: NonDeletedSceneElementsMap,
   zoom?: AppState["zoom"],
   zoom?: AppState["zoom"],
 ): (NonDeleted<ExcalidrawElement> | null)[] =>
 ): (NonDeleted<ExcalidrawElement> | null)[] =>
-  ["start", "end"].map((edge) =>
-    getOriginalBindingIfStillCloseOfLinearElementEdge(
-      linearElement,
-      edge as "start" | "end",
-      elementsMap,
-      zoom,
-    ),
-  );
+  (["start", "end"] as const).map((edge) => {
+    const coors = getLinearElementEdgeCoors(linearElement, edge, elementsMap);
+    const elementId =
+      edge === "start"
+        ? linearElement.startBinding?.elementId
+        : linearElement.endBinding?.elementId;
+    if (elementId) {
+      const element = elementsMap.get(elementId);
+      if (
+        isBindableElement(element) &&
+        bindingBorderTest(element, coors, elementsMap, zoom)
+      ) {
+        return element;
+      }
+    }
+
+    return null;
+  });
 
 
 const getBindingStrategyForDraggingArrowEndpoints = (
 const getBindingStrategyForDraggingArrowEndpoints = (
   selectedElement: NonDeleted<ExcalidrawLinearElement>,
   selectedElement: NonDeleted<ExcalidrawLinearElement>,
@@ -268,7 +255,7 @@ const getBindingStrategyForDraggingArrowEndpoints = (
   const endDragged = draggingPoints.findIndex((i) => i === endIdx) > -1;
   const endDragged = draggingPoints.findIndex((i) => i === endIdx) > -1;
   const start = startDragged
   const start = startDragged
     ? isBindingEnabled
     ? isBindingEnabled
-      ? getElligibleElementForBindingElement(
+      ? getEligibleElementForBindingElement(
           selectedElement,
           selectedElement,
           "start",
           "start",
           elementsMap,
           elementsMap,
@@ -279,7 +266,7 @@ const getBindingStrategyForDraggingArrowEndpoints = (
     : "keep";
     : "keep";
   const end = endDragged
   const end = endDragged
     ? isBindingEnabled
     ? isBindingEnabled
-      ? getElligibleElementForBindingElement(
+      ? getEligibleElementForBindingElement(
           selectedElement,
           selectedElement,
           "end",
           "end",
           elementsMap,
           elementsMap,
@@ -311,7 +298,7 @@ const getBindingStrategyForDraggingArrowOrJoints = (
   );
   );
   const start = startIsClose
   const start = startIsClose
     ? isBindingEnabled
     ? isBindingEnabled
-      ? getElligibleElementForBindingElement(
+      ? getEligibleElementForBindingElement(
           selectedElement,
           selectedElement,
           "start",
           "start",
           elementsMap,
           elementsMap,
@@ -322,7 +309,7 @@ const getBindingStrategyForDraggingArrowOrJoints = (
     : null;
     : null;
   const end = endIsClose
   const end = endIsClose
     ? isBindingEnabled
     ? isBindingEnabled
-      ? getElligibleElementForBindingElement(
+      ? getEligibleElementForBindingElement(
           selectedElement,
           selectedElement,
           "end",
           "end",
           elementsMap,
           elementsMap,
@@ -441,22 +428,13 @@ export const maybeBindLinearElement = (
 const normalizePointBinding = (
 const normalizePointBinding = (
   binding: { focus: number; gap: number },
   binding: { focus: number; gap: number },
   hoveredElement: ExcalidrawBindableElement,
   hoveredElement: ExcalidrawBindableElement,
-) => {
-  let gap = binding.gap;
-  const maxGap = maxBindingGap(
-    hoveredElement,
-    hoveredElement.width,
-    hoveredElement.height,
-  );
-
-  if (gap > maxGap) {
-    gap = BINDING_HIGHLIGHT_THICKNESS + BINDING_HIGHLIGHT_OFFSET;
-  }
-  return {
-    ...binding,
-    gap,
-  };
-};
+) => ({
+  ...binding,
+  gap: Math.min(
+    binding.gap,
+    maxBindingGap(hoveredElement, hoveredElement.width, hoveredElement.height),
+  ),
+});
 
 
 export const bindLinearElement = (
 export const bindLinearElement = (
   linearElement: NonDeleted<ExcalidrawLinearElement>,
   linearElement: NonDeleted<ExcalidrawLinearElement>,
@@ -488,6 +466,7 @@ export const bindLinearElement = (
         linearElement,
         linearElement,
         hoveredElement,
         hoveredElement,
         startOrEnd,
         startOrEnd,
+        scene.getNonDeletedElementsMap(),
       ),
       ),
     };
     };
   }
   }
@@ -703,8 +682,13 @@ const calculateFocusAndGap = (
   );
   );
 
 
   return {
   return {
-    focus: determineFocusDistance(hoveredElement, adjacentPoint, edgePoint),
-    gap: Math.max(1, distanceToBindableElement(hoveredElement, edgePoint)),
+    focus: determineFocusDistance(
+      hoveredElement,
+      elementsMap,
+      adjacentPoint,
+      edgePoint,
+    ),
+    gap: Math.max(1, distanceToElement(hoveredElement, elementsMap, edgePoint)),
   };
   };
 };
 };
 
 
@@ -874,6 +858,7 @@ export const getHeadingForElbowArrowSnap = (
   bindableElement: ExcalidrawBindableElement | undefined | null,
   bindableElement: ExcalidrawBindableElement | undefined | null,
   aabb: Bounds | undefined | null,
   aabb: Bounds | undefined | null,
   origPoint: GlobalPoint,
   origPoint: GlobalPoint,
+  elementsMap: ElementsMap,
   zoom?: AppState["zoom"],
   zoom?: AppState["zoom"],
 ): Heading => {
 ): Heading => {
   const otherPointHeading = vectorToHeading(vectorFromPoint(otherPoint, p));
   const otherPointHeading = vectorToHeading(vectorFromPoint(otherPoint, p));
@@ -882,11 +867,16 @@ export const getHeadingForElbowArrowSnap = (
     return otherPointHeading;
     return otherPointHeading;
   }
   }
 
 
-  const distance = getDistanceForBinding(origPoint, bindableElement, zoom);
+  const distance = getDistanceForBinding(
+    origPoint,
+    bindableElement,
+    elementsMap,
+    zoom,
+  );
 
 
   if (!distance) {
   if (!distance) {
     return vectorToHeading(
     return vectorToHeading(
-      vectorFromPoint(p, elementCenterPoint(bindableElement)),
+      vectorFromPoint(p, elementCenterPoint(bindableElement, elementsMap)),
     );
     );
   }
   }
 
 
@@ -896,9 +886,10 @@ export const getHeadingForElbowArrowSnap = (
 const getDistanceForBinding = (
 const getDistanceForBinding = (
   point: Readonly<GlobalPoint>,
   point: Readonly<GlobalPoint>,
   bindableElement: ExcalidrawBindableElement,
   bindableElement: ExcalidrawBindableElement,
+  elementsMap: ElementsMap,
   zoom?: AppState["zoom"],
   zoom?: AppState["zoom"],
 ) => {
 ) => {
-  const distance = distanceToBindableElement(bindableElement, point);
+  const distance = distanceToElement(bindableElement, elementsMap, point);
   const bindDistance = maxBindingGap(
   const bindDistance = maxBindingGap(
     bindableElement,
     bindableElement,
     bindableElement.width,
     bindableElement.width,
@@ -913,12 +904,13 @@ export const bindPointToSnapToElementOutline = (
   arrow: ExcalidrawElbowArrowElement,
   arrow: ExcalidrawElbowArrowElement,
   bindableElement: ExcalidrawBindableElement,
   bindableElement: ExcalidrawBindableElement,
   startOrEnd: "start" | "end",
   startOrEnd: "start" | "end",
+  elementsMap: ElementsMap,
 ): GlobalPoint => {
 ): GlobalPoint => {
   if (isDevEnv() || isTestEnv()) {
   if (isDevEnv() || isTestEnv()) {
     invariant(arrow.points.length > 1, "Arrow should have at least 2 points");
     invariant(arrow.points.length > 1, "Arrow should have at least 2 points");
   }
   }
 
 
-  const aabb = aabbForElement(bindableElement);
+  const aabb = aabbForElement(bindableElement, elementsMap);
   const localP =
   const localP =
     arrow.points[startOrEnd === "start" ? 0 : arrow.points.length - 1];
     arrow.points[startOrEnd === "start" ? 0 : arrow.points.length - 1];
   const globalP = pointFrom<GlobalPoint>(
   const globalP = pointFrom<GlobalPoint>(
@@ -926,7 +918,7 @@ export const bindPointToSnapToElementOutline = (
     arrow.y + localP[1],
     arrow.y + localP[1],
   );
   );
   const edgePoint = isRectanguloidElement(bindableElement)
   const edgePoint = isRectanguloidElement(bindableElement)
-    ? avoidRectangularCorner(bindableElement, globalP)
+    ? avoidRectangularCorner(bindableElement, elementsMap, globalP)
     : globalP;
     : globalP;
   const elbowed = isElbowArrow(arrow);
   const elbowed = isElbowArrow(arrow);
   const center = getCenterForBounds(aabb);
   const center = getCenterForBounds(aabb);
@@ -945,26 +937,31 @@ export const bindPointToSnapToElementOutline = (
     const isHorizontal = headingIsHorizontal(
     const isHorizontal = headingIsHorizontal(
       headingForPointFromElement(bindableElement, aabb, globalP),
       headingForPointFromElement(bindableElement, aabb, globalP),
     );
     );
+    const snapPoint = snapToMid(bindableElement, elementsMap, edgePoint);
     const otherPoint = pointFrom<GlobalPoint>(
     const otherPoint = pointFrom<GlobalPoint>(
-      isHorizontal ? center[0] : edgePoint[0],
-      !isHorizontal ? center[1] : edgePoint[1],
+      isHorizontal ? center[0] : snapPoint[0],
+      !isHorizontal ? center[1] : snapPoint[1],
     );
     );
-    intersection = intersectElementWithLineSegment(
-      bindableElement,
-      lineSegment(
-        otherPoint,
-        pointFromVector(
-          vectorScale(
-            vectorNormalize(vectorFromPoint(edgePoint, otherPoint)),
-            Math.max(bindableElement.width, bindableElement.height) * 2,
-          ),
-          otherPoint,
+    const intersector = lineSegment(
+      otherPoint,
+      pointFromVector(
+        vectorScale(
+          vectorNormalize(vectorFromPoint(snapPoint, otherPoint)),
+          Math.max(bindableElement.width, bindableElement.height) * 2,
         ),
         ),
+        otherPoint,
       ),
       ),
-    )[0];
+    );
+    intersection = intersectElementWithLineSegment(
+      bindableElement,
+      elementsMap,
+      intersector,
+      FIXED_BINDING_DISTANCE,
+    ).sort(pointDistanceSq)[0];
   } else {
   } else {
     intersection = intersectElementWithLineSegment(
     intersection = intersectElementWithLineSegment(
       bindableElement,
       bindableElement,
+      elementsMap,
       lineSegment(
       lineSegment(
         adjacentPoint,
         adjacentPoint,
         pointFromVector(
         pointFromVector(
@@ -991,31 +988,15 @@ export const bindPointToSnapToElementOutline = (
     return edgePoint;
     return edgePoint;
   }
   }
 
 
-  if (elbowed) {
-    const scalar =
-      pointDistanceSq(edgePoint, center) -
-        pointDistanceSq(intersection, center) >
-      0
-        ? FIXED_BINDING_DISTANCE
-        : -FIXED_BINDING_DISTANCE;
-
-    return pointFromVector(
-      vectorScale(
-        vectorNormalize(vectorFromPoint(edgePoint, intersection)),
-        scalar,
-      ),
-      intersection,
-    );
-  }
-
-  return edgePoint;
+  return elbowed ? intersection : edgePoint;
 };
 };
 
 
 export const avoidRectangularCorner = (
 export const avoidRectangularCorner = (
   element: ExcalidrawBindableElement,
   element: ExcalidrawBindableElement,
+  elementsMap: ElementsMap,
   p: GlobalPoint,
   p: GlobalPoint,
 ): GlobalPoint => {
 ): GlobalPoint => {
-  const center = elementCenterPoint(element);
+  const center = elementCenterPoint(element, elementsMap);
   const nonRotatedPoint = pointRotateRads(p, center, -element.angle as Radians);
   const nonRotatedPoint = pointRotateRads(p, center, -element.angle as Radians);
 
 
   if (nonRotatedPoint[0] < element.x && nonRotatedPoint[1] < element.y) {
   if (nonRotatedPoint[0] < element.x && nonRotatedPoint[1] < element.y) {
@@ -1108,35 +1089,34 @@ export const avoidRectangularCorner = (
 
 
 export const snapToMid = (
 export const snapToMid = (
   element: ExcalidrawBindableElement,
   element: ExcalidrawBindableElement,
+  elementsMap: ElementsMap,
   p: GlobalPoint,
   p: GlobalPoint,
   tolerance: number = 0.05,
   tolerance: number = 0.05,
 ): GlobalPoint => {
 ): GlobalPoint => {
   const { x, y, width, height, angle } = element;
   const { x, y, width, height, angle } = element;
-
-  const center = elementCenterPoint(element, -0.1, -0.1);
-
+  const center = elementCenterPoint(element, elementsMap, -0.1, -0.1);
   const nonRotated = pointRotateRads(p, center, -angle as Radians);
   const nonRotated = pointRotateRads(p, center, -angle as Radians);
 
 
   // snap-to-center point is adaptive to element size, but we don't want to go
   // snap-to-center point is adaptive to element size, but we don't want to go
   // above and below certain px distance
   // above and below certain px distance
-  const verticalThrehsold = clamp(tolerance * height, 5, 80);
-  const horizontalThrehsold = clamp(tolerance * width, 5, 80);
+  const verticalThreshold = clamp(tolerance * height, 5, 80);
+  const horizontalThreshold = clamp(tolerance * width, 5, 80);
 
 
   if (
   if (
     nonRotated[0] <= x + width / 2 &&
     nonRotated[0] <= x + width / 2 &&
-    nonRotated[1] > center[1] - verticalThrehsold &&
-    nonRotated[1] < center[1] + verticalThrehsold
+    nonRotated[1] > center[1] - verticalThreshold &&
+    nonRotated[1] < center[1] + verticalThreshold
   ) {
   ) {
     // LEFT
     // LEFT
-    return pointRotateRads(
+    return pointRotateRads<GlobalPoint>(
       pointFrom(x - FIXED_BINDING_DISTANCE, center[1]),
       pointFrom(x - FIXED_BINDING_DISTANCE, center[1]),
       center,
       center,
       angle,
       angle,
     );
     );
   } else if (
   } else if (
     nonRotated[1] <= y + height / 2 &&
     nonRotated[1] <= y + height / 2 &&
-    nonRotated[0] > center[0] - horizontalThrehsold &&
-    nonRotated[0] < center[0] + horizontalThrehsold
+    nonRotated[0] > center[0] - horizontalThreshold &&
+    nonRotated[0] < center[0] + horizontalThreshold
   ) {
   ) {
     // TOP
     // TOP
     return pointRotateRads(
     return pointRotateRads(
@@ -1146,8 +1126,8 @@ export const snapToMid = (
     );
     );
   } else if (
   } else if (
     nonRotated[0] >= x + width / 2 &&
     nonRotated[0] >= x + width / 2 &&
-    nonRotated[1] > center[1] - verticalThrehsold &&
-    nonRotated[1] < center[1] + verticalThrehsold
+    nonRotated[1] > center[1] - verticalThreshold &&
+    nonRotated[1] < center[1] + verticalThreshold
   ) {
   ) {
     // RIGHT
     // RIGHT
     return pointRotateRads(
     return pointRotateRads(
@@ -1157,8 +1137,8 @@ export const snapToMid = (
     );
     );
   } else if (
   } else if (
     nonRotated[1] >= y + height / 2 &&
     nonRotated[1] >= y + height / 2 &&
-    nonRotated[0] > center[0] - horizontalThrehsold &&
-    nonRotated[0] < center[0] + horizontalThrehsold
+    nonRotated[0] > center[0] - horizontalThreshold &&
+    nonRotated[0] < center[0] + horizontalThreshold
   ) {
   ) {
     // DOWN
     // DOWN
     return pointRotateRads(
     return pointRotateRads(
@@ -1167,7 +1147,7 @@ export const snapToMid = (
       angle,
       angle,
     );
     );
   } else if (element.type === "diamond") {
   } else if (element.type === "diamond") {
-    const distance = FIXED_BINDING_DISTANCE - 1;
+    const distance = FIXED_BINDING_DISTANCE;
     const topLeft = pointFrom<GlobalPoint>(
     const topLeft = pointFrom<GlobalPoint>(
       x + width / 4 - distance,
       x + width / 4 - distance,
       y + height / 4 - distance,
       y + height / 4 - distance,
@@ -1184,27 +1164,28 @@ export const snapToMid = (
       x + (3 * width) / 4 + distance,
       x + (3 * width) / 4 + distance,
       y + (3 * height) / 4 + distance,
       y + (3 * height) / 4 + distance,
     );
     );
+
     if (
     if (
       pointDistance(topLeft, nonRotated) <
       pointDistance(topLeft, nonRotated) <
-      Math.max(horizontalThrehsold, verticalThrehsold)
+      Math.max(horizontalThreshold, verticalThreshold)
     ) {
     ) {
       return pointRotateRads(topLeft, center, angle);
       return pointRotateRads(topLeft, center, angle);
     }
     }
     if (
     if (
       pointDistance(topRight, nonRotated) <
       pointDistance(topRight, nonRotated) <
-      Math.max(horizontalThrehsold, verticalThrehsold)
+      Math.max(horizontalThreshold, verticalThreshold)
     ) {
     ) {
       return pointRotateRads(topRight, center, angle);
       return pointRotateRads(topRight, center, angle);
     }
     }
     if (
     if (
       pointDistance(bottomLeft, nonRotated) <
       pointDistance(bottomLeft, nonRotated) <
-      Math.max(horizontalThrehsold, verticalThrehsold)
+      Math.max(horizontalThreshold, verticalThreshold)
     ) {
     ) {
       return pointRotateRads(bottomLeft, center, angle);
       return pointRotateRads(bottomLeft, center, angle);
     }
     }
     if (
     if (
       pointDistance(bottomRight, nonRotated) <
       pointDistance(bottomRight, nonRotated) <
-      Math.max(horizontalThrehsold, verticalThrehsold)
+      Math.max(horizontalThreshold, verticalThreshold)
     ) {
     ) {
       return pointRotateRads(bottomRight, center, angle);
       return pointRotateRads(bottomRight, center, angle);
     }
     }
@@ -1239,8 +1220,9 @@ const updateBoundPoint = (
         linearElement,
         linearElement,
         bindableElement,
         bindableElement,
         startOrEnd === "startBinding" ? "start" : "end",
         startOrEnd === "startBinding" ? "start" : "end",
+        elementsMap,
       ).fixedPoint;
       ).fixedPoint;
-    const globalMidPoint = elementCenterPoint(bindableElement);
+    const globalMidPoint = elementCenterPoint(bindableElement, elementsMap);
     const global = pointFrom<GlobalPoint>(
     const global = pointFrom<GlobalPoint>(
       bindableElement.x + fixedPoint[0] * bindableElement.width,
       bindableElement.x + fixedPoint[0] * bindableElement.width,
       bindableElement.y + fixedPoint[1] * bindableElement.height,
       bindableElement.y + fixedPoint[1] * bindableElement.height,
@@ -1266,6 +1248,7 @@ const updateBoundPoint = (
   );
   );
   const focusPointAbsolute = determineFocusPoint(
   const focusPointAbsolute = determineFocusPoint(
     bindableElement,
     bindableElement,
+    elementsMap,
     binding.focus,
     binding.focus,
     adjacentPoint,
     adjacentPoint,
   );
   );
@@ -1284,7 +1267,7 @@ const updateBoundPoint = (
         elementsMap,
         elementsMap,
       );
       );
 
 
-    const center = elementCenterPoint(bindableElement);
+    const center = elementCenterPoint(bindableElement, elementsMap);
     const interceptorLength =
     const interceptorLength =
       pointDistance(adjacentPoint, edgePointAbsolute) +
       pointDistance(adjacentPoint, edgePointAbsolute) +
       pointDistance(adjacentPoint, center) +
       pointDistance(adjacentPoint, center) +
@@ -1292,6 +1275,7 @@ const updateBoundPoint = (
     const intersections = [
     const intersections = [
       ...intersectElementWithLineSegment(
       ...intersectElementWithLineSegment(
         bindableElement,
         bindableElement,
+        elementsMap,
         lineSegment<GlobalPoint>(
         lineSegment<GlobalPoint>(
           adjacentPoint,
           adjacentPoint,
           pointFromVector(
           pointFromVector(
@@ -1342,6 +1326,7 @@ export const calculateFixedPointForElbowArrowBinding = (
   linearElement: NonDeleted<ExcalidrawElbowArrowElement>,
   linearElement: NonDeleted<ExcalidrawElbowArrowElement>,
   hoveredElement: ExcalidrawBindableElement,
   hoveredElement: ExcalidrawBindableElement,
   startOrEnd: "start" | "end",
   startOrEnd: "start" | "end",
+  elementsMap: ElementsMap,
 ): { fixedPoint: FixedPoint } => {
 ): { fixedPoint: FixedPoint } => {
   const bounds = [
   const bounds = [
     hoveredElement.x,
     hoveredElement.x,
@@ -1353,6 +1338,7 @@ export const calculateFixedPointForElbowArrowBinding = (
     linearElement,
     linearElement,
     hoveredElement,
     hoveredElement,
     startOrEnd,
     startOrEnd,
+    elementsMap,
   );
   );
   const globalMidPoint = pointFrom(
   const globalMidPoint = pointFrom(
     bounds[0] + (bounds[2] - bounds[0]) / 2,
     bounds[0] + (bounds[2] - bounds[0]) / 2,
@@ -1396,7 +1382,7 @@ const maybeCalculateNewGapWhenScaling = (
   return { ...currentBinding, gap: newGap };
   return { ...currentBinding, gap: newGap };
 };
 };
 
 
-const getElligibleElementForBindingElement = (
+const getEligibleElementForBindingElement = (
   linearElement: NonDeleted<ExcalidrawLinearElement>,
   linearElement: NonDeleted<ExcalidrawLinearElement>,
   startOrEnd: "start" | "end",
   startOrEnd: "start" | "end",
   elementsMap: NonDeletedSceneElementsMap,
   elementsMap: NonDeletedSceneElementsMap,
@@ -1548,14 +1534,38 @@ export const bindingBorderTest = (
   zoom?: AppState["zoom"],
   zoom?: AppState["zoom"],
   fullShape?: boolean,
   fullShape?: boolean,
 ): boolean => {
 ): boolean => {
+  const p = pointFrom<GlobalPoint>(x, y);
   const threshold = maxBindingGap(element, element.width, element.height, zoom);
   const threshold = maxBindingGap(element, element.width, element.height, zoom);
+  const shouldTestInside =
+    // disable fullshape snapping for frame elements so we
+    // can bind to frame children
+    (fullShape || !isBindingFallthroughEnabled(element)) &&
+    !isFrameLikeElement(element);
+
+  // PERF: Run a cheap test to see if the binding element
+  // is even close to the element
+  const bounds = [
+    x - threshold,
+    y - threshold,
+    x + threshold,
+    y + threshold,
+  ] as Bounds;
+  const elementBounds = getElementBounds(element, elementsMap);
+  if (!doBoundsIntersect(bounds, elementBounds)) {
+    return false;
+  }
 
 
-  const shape = getElementShape(element, elementsMap);
-  return (
-    isPointOnShape(pointFrom(x, y), shape, threshold) ||
-    (fullShape === true &&
-      pointInsideBounds(pointFrom(x, y), aabbForElement(element)))
+  // Do the intersection test against the element since it's close enough
+  const intersections = intersectElementWithLineSegment(
+    element,
+    elementsMap,
+    lineSegment(elementCenterPoint(element, elementsMap), p),
   );
   );
+  const distance = distanceToElement(element, elementsMap, p);
+
+  return shouldTestInside
+    ? intersections.length === 0 || distance <= threshold
+    : intersections.length > 0 && distance <= threshold;
 };
 };
 
 
 export const maxBindingGap = (
 export const maxBindingGap = (
@@ -1575,7 +1585,7 @@ export const maxBindingGap = (
     // bigger bindable boundary for bigger elements
     // bigger bindable boundary for bigger elements
     Math.min(0.25 * smallerDimension, 32),
     Math.min(0.25 * smallerDimension, 32),
     // keep in sync with the zoomed highlight
     // keep in sync with the zoomed highlight
-    BINDING_HIGHLIGHT_THICKNESS / zoomValue + BINDING_HIGHLIGHT_OFFSET,
+    BINDING_HIGHLIGHT_THICKNESS / zoomValue + FIXED_BINDING_DISTANCE,
   );
   );
 };
 };
 
 
@@ -1586,12 +1596,13 @@ export const maxBindingGap = (
 // of the element.
 // of the element.
 const determineFocusDistance = (
 const determineFocusDistance = (
   element: ExcalidrawBindableElement,
   element: ExcalidrawBindableElement,
+  elementsMap: ElementsMap,
   // Point on the line, in absolute coordinates
   // Point on the line, in absolute coordinates
   a: GlobalPoint,
   a: GlobalPoint,
   // Another point on the line, in absolute coordinates (closer to element)
   // Another point on the line, in absolute coordinates (closer to element)
   b: GlobalPoint,
   b: GlobalPoint,
 ): number => {
 ): number => {
-  const center = elementCenterPoint(element);
+  const center = elementCenterPoint(element, elementsMap);
 
 
   if (pointsEqual(a, b)) {
   if (pointsEqual(a, b)) {
     return 0;
     return 0;
@@ -1716,12 +1727,13 @@ const determineFocusDistance = (
 
 
 const determineFocusPoint = (
 const determineFocusPoint = (
   element: ExcalidrawBindableElement,
   element: ExcalidrawBindableElement,
+  elementsMap: ElementsMap,
   // The oriented, relative distance from the center of `element` of the
   // The oriented, relative distance from the center of `element` of the
   // returned focusPoint
   // returned focusPoint
   focus: number,
   focus: number,
   adjacentPoint: GlobalPoint,
   adjacentPoint: GlobalPoint,
 ): GlobalPoint => {
 ): GlobalPoint => {
-  const center = elementCenterPoint(element);
+  const center = elementCenterPoint(element, elementsMap);
 
 
   if (focus === 0) {
   if (focus === 0) {
     return center;
     return center;
@@ -2144,6 +2156,7 @@ export class BindableElement {
 export const getGlobalFixedPointForBindableElement = (
 export const getGlobalFixedPointForBindableElement = (
   fixedPointRatio: [number, number],
   fixedPointRatio: [number, number],
   element: ExcalidrawBindableElement,
   element: ExcalidrawBindableElement,
+  elementsMap: ElementsMap,
 ): GlobalPoint => {
 ): GlobalPoint => {
   const [fixedX, fixedY] = normalizeFixedPoint(fixedPointRatio);
   const [fixedX, fixedY] = normalizeFixedPoint(fixedPointRatio);
 
 
@@ -2152,7 +2165,7 @@ export const getGlobalFixedPointForBindableElement = (
       element.x + element.width * fixedX,
       element.x + element.width * fixedX,
       element.y + element.height * fixedY,
       element.y + element.height * fixedY,
     ),
     ),
-    elementCenterPoint(element),
+    elementCenterPoint(element, elementsMap),
     element.angle,
     element.angle,
   );
   );
 };
 };
@@ -2176,6 +2189,7 @@ export const getGlobalFixedPoints = (
       ? getGlobalFixedPointForBindableElement(
       ? getGlobalFixedPointForBindableElement(
           arrow.startBinding.fixedPoint,
           arrow.startBinding.fixedPoint,
           startElement as ExcalidrawBindableElement,
           startElement as ExcalidrawBindableElement,
+          elementsMap,
         )
         )
       : pointFrom<GlobalPoint>(
       : pointFrom<GlobalPoint>(
           arrow.x + arrow.points[0][0],
           arrow.x + arrow.points[0][0],
@@ -2186,6 +2200,7 @@ export const getGlobalFixedPoints = (
       ? getGlobalFixedPointForBindableElement(
       ? getGlobalFixedPointForBindableElement(
           arrow.endBinding.fixedPoint,
           arrow.endBinding.fixedPoint,
           endElement as ExcalidrawBindableElement,
           endElement as ExcalidrawBindableElement,
+          elementsMap,
         )
         )
       : pointFrom<GlobalPoint>(
       : pointFrom<GlobalPoint>(
           arrow.x + arrow.points[arrow.points.length - 1][0],
           arrow.x + arrow.points[arrow.points.length - 1][0],

+ 35 - 3
packages/element/src/bounds.ts

@@ -102,9 +102,23 @@ export class ElementBounds {
       version: ExcalidrawElement["version"];
       version: ExcalidrawElement["version"];
     }
     }
   >();
   >();
+  private static nonRotatedBoundsCache = new WeakMap<
+    ExcalidrawElement,
+    {
+      bounds: Bounds;
+      version: ExcalidrawElement["version"];
+    }
+  >();
 
 
-  static getBounds(element: ExcalidrawElement, elementsMap: ElementsMap) {
-    const cachedBounds = ElementBounds.boundsCache.get(element);
+  static getBounds(
+    element: ExcalidrawElement,
+    elementsMap: ElementsMap,
+    nonRotated: boolean = false,
+  ) {
+    const cachedBounds =
+      nonRotated && element.angle !== 0
+        ? ElementBounds.nonRotatedBoundsCache.get(element)
+        : ElementBounds.boundsCache.get(element);
 
 
     if (
     if (
       cachedBounds?.version &&
       cachedBounds?.version &&
@@ -115,6 +129,23 @@ export class ElementBounds {
     ) {
     ) {
       return cachedBounds.bounds;
       return cachedBounds.bounds;
     }
     }
+
+    if (nonRotated && element.angle !== 0) {
+      const nonRotatedBounds = ElementBounds.calculateBounds(
+        {
+          ...element,
+          angle: 0 as Radians,
+        },
+        elementsMap,
+      );
+      ElementBounds.nonRotatedBoundsCache.set(element, {
+        version: element.version,
+        bounds: nonRotatedBounds,
+      });
+
+      return nonRotatedBounds;
+    }
+
     const bounds = ElementBounds.calculateBounds(element, elementsMap);
     const bounds = ElementBounds.calculateBounds(element, elementsMap);
 
 
     ElementBounds.boundsCache.set(element, {
     ElementBounds.boundsCache.set(element, {
@@ -939,8 +970,9 @@ const getLinearElementRotatedBounds = (
 export const getElementBounds = (
 export const getElementBounds = (
   element: ExcalidrawElement,
   element: ExcalidrawElement,
   elementsMap: ElementsMap,
   elementsMap: ElementsMap,
+  nonRotated: boolean = false,
 ): Bounds => {
 ): Bounds => {
-  return ElementBounds.getBounds(element, elementsMap);
+  return ElementBounds.getBounds(element, elementsMap, nonRotated);
 };
 };
 
 
 export const getCommonBounds = (
 export const getCommonBounds = (

+ 318 - 132
packages/element/src/collision.ts

@@ -2,51 +2,60 @@ import { isTransparent, elementCenterPoint } from "@excalidraw/common";
 import {
 import {
   curveIntersectLineSegment,
   curveIntersectLineSegment,
   isPointWithinBounds,
   isPointWithinBounds,
-  line,
   lineSegment,
   lineSegment,
   lineSegmentIntersectionPoints,
   lineSegmentIntersectionPoints,
   pointFrom,
   pointFrom,
+  pointFromVector,
   pointRotateRads,
   pointRotateRads,
   pointsEqual,
   pointsEqual,
+  vectorFromPoint,
+  vectorNormalize,
+  vectorScale,
 } from "@excalidraw/math";
 } from "@excalidraw/math";
 
 
 import {
 import {
   ellipse,
   ellipse,
-  ellipseLineIntersectionPoints,
+  ellipseSegmentInterceptPoints,
 } from "@excalidraw/math/ellipse";
 } from "@excalidraw/math/ellipse";
 
 
-import { isPointInShape, isPointOnShape } from "@excalidraw/utils/collision";
-import { type GeometricShape, getPolygonShape } from "@excalidraw/utils/shape";
-
-import type {
-  GlobalPoint,
-  LineSegment,
-  LocalPoint,
-  Polygon,
-  Radians,
-} from "@excalidraw/math";
+import type { GlobalPoint, LineSegment, Radians } from "@excalidraw/math";
 
 
 import type { FrameNameBounds } from "@excalidraw/excalidraw/types";
 import type { FrameNameBounds } from "@excalidraw/excalidraw/types";
 
 
-import { getBoundTextShape, isPathALoop } from "./shapes";
-import { getElementBounds } from "./bounds";
+import { isPathALoop } from "./shapes";
+import {
+  type Bounds,
+  doBoundsIntersect,
+  getCenterForBounds,
+  getElementBounds,
+} from "./bounds";
 import {
 import {
   hasBoundTextElement,
   hasBoundTextElement,
+  isFreeDrawElement,
   isIframeLikeElement,
   isIframeLikeElement,
   isImageElement,
   isImageElement,
+  isLinearElement,
   isTextElement,
   isTextElement,
 } from "./typeChecks";
 } from "./typeChecks";
 import {
 import {
   deconstructDiamondElement,
   deconstructDiamondElement,
+  deconstructLinearOrFreeDrawElement,
   deconstructRectanguloidElement,
   deconstructRectanguloidElement,
 } from "./utils";
 } from "./utils";
 
 
+import { getBoundTextElement } from "./textElement";
+
+import { LinearElementEditor } from "./linearElementEditor";
+
+import { distanceToElement } from "./distance";
+
 import type {
 import type {
   ElementsMap,
   ElementsMap,
   ExcalidrawDiamondElement,
   ExcalidrawDiamondElement,
   ExcalidrawElement,
   ExcalidrawElement,
   ExcalidrawEllipseElement,
   ExcalidrawEllipseElement,
-  ExcalidrawRectangleElement,
+  ExcalidrawFreeDrawElement,
+  ExcalidrawLinearElement,
   ExcalidrawRectanguloidElement,
   ExcalidrawRectanguloidElement,
 } from "./types";
 } from "./types";
 
 
@@ -72,45 +81,64 @@ export const shouldTestInside = (element: ExcalidrawElement) => {
   return isDraggableFromInside || isImageElement(element);
   return isDraggableFromInside || isImageElement(element);
 };
 };
 
 
-export type HitTestArgs<Point extends GlobalPoint | LocalPoint> = {
-  x: number;
-  y: number;
+export type HitTestArgs = {
+  point: GlobalPoint;
   element: ExcalidrawElement;
   element: ExcalidrawElement;
-  shape: GeometricShape<Point>;
-  threshold?: number;
+  threshold: number;
+  elementsMap: ElementsMap;
   frameNameBound?: FrameNameBounds | null;
   frameNameBound?: FrameNameBounds | null;
 };
 };
 
 
-export const hitElementItself = <Point extends GlobalPoint | LocalPoint>({
-  x,
-  y,
+export const hitElementItself = ({
+  point,
   element,
   element,
-  shape,
-  threshold = 10,
+  threshold,
+  elementsMap,
   frameNameBound = null,
   frameNameBound = null,
-}: HitTestArgs<Point>) => {
-  let hit = shouldTestInside(element)
+}: HitTestArgs) => {
+  // Hit test against a frame's name
+  const hitFrameName = frameNameBound
+    ? isPointWithinBounds(
+        pointFrom(frameNameBound.x - threshold, frameNameBound.y - threshold),
+        point,
+        pointFrom(
+          frameNameBound.x + frameNameBound.width + threshold,
+          frameNameBound.y + frameNameBound.height + threshold,
+        ),
+      )
+    : false;
+
+  // Hit test against the extended, rotated bounding box of the element first
+  const bounds = getElementBounds(element, elementsMap, true);
+  const hitBounds = isPointWithinBounds(
+    pointFrom(bounds[0] - threshold, bounds[1] - threshold),
+    pointRotateRads(
+      point,
+      getCenterForBounds(bounds),
+      -element.angle as Radians,
+    ),
+    pointFrom(bounds[2] + threshold, bounds[3] + threshold),
+  );
+
+  // PERF: Bail out early if the point is not even in the
+  // rotated bounding box or not hitting the frame name (saves 99%)
+  if (!hitBounds && !hitFrameName) {
+    return false;
+  }
+
+  // Do the precise (and relatively costly) hit test
+  const hitElement = shouldTestInside(element)
     ? // Since `inShape` tests STRICTLY againt the insides of a shape
     ? // Since `inShape` tests STRICTLY againt the insides of a shape
       // we would need `onShape` as well to include the "borders"
       // we would need `onShape` as well to include the "borders"
-      isPointInShape(pointFrom(x, y), shape) ||
-      isPointOnShape(pointFrom(x, y), shape, threshold)
-    : isPointOnShape(pointFrom(x, y), shape, threshold);
-
-  // hit test against a frame's name
-  if (!hit && frameNameBound) {
-    hit = isPointInShape(pointFrom(x, y), {
-      type: "polygon",
-      data: getPolygonShape(frameNameBound as ExcalidrawRectangleElement)
-        .data as Polygon<Point>,
-    });
-  }
+      isPointInElement(point, element, elementsMap) ||
+      isPointOnElementOutline(point, element, elementsMap, threshold)
+    : isPointOnElementOutline(point, element, elementsMap, threshold);
 
 
-  return hit;
+  return hitElement || hitFrameName;
 };
 };
 
 
 export const hitElementBoundingBox = (
 export const hitElementBoundingBox = (
-  x: number,
-  y: number,
+  point: GlobalPoint,
   element: ExcalidrawElement,
   element: ExcalidrawElement,
   elementsMap: ElementsMap,
   elementsMap: ElementsMap,
   tolerance = 0,
   tolerance = 0,
@@ -120,37 +148,42 @@ export const hitElementBoundingBox = (
   y1 -= tolerance;
   y1 -= tolerance;
   x2 += tolerance;
   x2 += tolerance;
   y2 += tolerance;
   y2 += tolerance;
-  return isPointWithinBounds(
-    pointFrom(x1, y1),
-    pointFrom(x, y),
-    pointFrom(x2, y2),
-  );
+  return isPointWithinBounds(pointFrom(x1, y1), point, pointFrom(x2, y2));
 };
 };
 
 
-export const hitElementBoundingBoxOnly = <
-  Point extends GlobalPoint | LocalPoint,
->(
-  hitArgs: HitTestArgs<Point>,
+export const hitElementBoundingBoxOnly = (
+  hitArgs: HitTestArgs,
   elementsMap: ElementsMap,
   elementsMap: ElementsMap,
-) => {
-  return (
-    !hitElementItself(hitArgs) &&
-    // bound text is considered part of the element (even if it's outside the bounding box)
-    !hitElementBoundText(
-      hitArgs.x,
-      hitArgs.y,
-      getBoundTextShape(hitArgs.element, elementsMap),
-    ) &&
-    hitElementBoundingBox(hitArgs.x, hitArgs.y, hitArgs.element, elementsMap)
-  );
-};
+) =>
+  !hitElementItself(hitArgs) &&
+  // bound text is considered part of the element (even if it's outside the bounding box)
+  !hitElementBoundText(hitArgs.point, hitArgs.element, elementsMap) &&
+  hitElementBoundingBox(hitArgs.point, hitArgs.element, elementsMap);
 
 
-export const hitElementBoundText = <Point extends GlobalPoint | LocalPoint>(
-  x: number,
-  y: number,
-  textShape: GeometricShape<Point> | null,
+export const hitElementBoundText = (
+  point: GlobalPoint,
+  element: ExcalidrawElement,
+  elementsMap: ElementsMap,
 ): boolean => {
 ): boolean => {
-  return !!textShape && isPointInShape(pointFrom(x, y), textShape);
+  const boundTextElementCandidate = getBoundTextElement(element, elementsMap);
+
+  if (!boundTextElementCandidate) {
+    return false;
+  }
+  const boundTextElement = isLinearElement(element)
+    ? {
+        ...boundTextElementCandidate,
+        // arrow's bound text accurate position is not stored in the element's property
+        // but rather calculated and returned from the following static method
+        ...LinearElementEditor.getBoundTextElementPosition(
+          element,
+          boundTextElementCandidate,
+          elementsMap,
+        ),
+      }
+    : boundTextElementCandidate;
+
+  return isPointInElement(point, boundTextElement, elementsMap);
 };
 };
 
 
 /**
 /**
@@ -163,9 +196,26 @@ export const hitElementBoundText = <Point extends GlobalPoint | LocalPoint>(
  */
  */
 export const intersectElementWithLineSegment = (
 export const intersectElementWithLineSegment = (
   element: ExcalidrawElement,
   element: ExcalidrawElement,
+  elementsMap: ElementsMap,
   line: LineSegment<GlobalPoint>,
   line: LineSegment<GlobalPoint>,
   offset: number = 0,
   offset: number = 0,
+  onlyFirst = false,
 ): GlobalPoint[] => {
 ): GlobalPoint[] => {
+  // First check if the line intersects the element's axis-aligned bounding box
+  // as it is much faster than checking intersection against the element's shape
+  const intersectorBounds = [
+    Math.min(line[0][0] - offset, line[1][0] - offset),
+    Math.min(line[0][1] - offset, line[1][1] - offset),
+    Math.max(line[0][0] + offset, line[1][0] + offset),
+    Math.max(line[0][1] + offset, line[1][1] + offset),
+  ] as Bounds;
+  const elementBounds = getElementBounds(element, elementsMap);
+
+  if (!doBoundsIntersect(intersectorBounds, elementBounds)) {
+    return [];
+  }
+
+  // Do the actual intersection test against the element's shape
   switch (element.type) {
   switch (element.type) {
     case "rectangle":
     case "rectangle":
     case "image":
     case "image":
@@ -173,23 +223,88 @@ export const intersectElementWithLineSegment = (
     case "iframe":
     case "iframe":
     case "embeddable":
     case "embeddable":
     case "frame":
     case "frame":
+    case "selection":
     case "magicframe":
     case "magicframe":
-      return intersectRectanguloidWithLineSegment(element, line, offset);
+      return intersectRectanguloidWithLineSegment(
+        element,
+        elementsMap,
+        line,
+        offset,
+        onlyFirst,
+      );
     case "diamond":
     case "diamond":
-      return intersectDiamondWithLineSegment(element, line, offset);
+      return intersectDiamondWithLineSegment(
+        element,
+        elementsMap,
+        line,
+        offset,
+        onlyFirst,
+      );
     case "ellipse":
     case "ellipse":
-      return intersectEllipseWithLineSegment(element, line, offset);
-    default:
-      throw new Error(`Unimplemented element type '${element.type}'`);
+      return intersectEllipseWithLineSegment(
+        element,
+        elementsMap,
+        line,
+        offset,
+      );
+    case "line":
+    case "freedraw":
+    case "arrow":
+      return intersectLinearOrFreeDrawWithLineSegment(
+        element,
+        elementsMap,
+        line,
+        onlyFirst,
+      );
   }
   }
 };
 };
 
 
+const intersectLinearOrFreeDrawWithLineSegment = (
+  element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
+  elementsMap: ElementsMap,
+  segment: LineSegment<GlobalPoint>,
+  onlyFirst = false,
+): GlobalPoint[] => {
+  const [lines, curves] = deconstructLinearOrFreeDrawElement(
+    element,
+    elementsMap,
+  );
+  const intersections = [];
+
+  for (const l of lines) {
+    const intersection = lineSegmentIntersectionPoints(l, segment);
+    if (intersection) {
+      intersections.push(intersection);
+
+      if (onlyFirst) {
+        return intersections;
+      }
+    }
+  }
+
+  for (const c of curves) {
+    const hits = curveIntersectLineSegment(c, segment);
+
+    if (hits.length > 0) {
+      intersections.push(...hits);
+
+      if (onlyFirst) {
+        return intersections;
+      }
+    }
+  }
+
+  return intersections;
+};
+
 const intersectRectanguloidWithLineSegment = (
 const intersectRectanguloidWithLineSegment = (
   element: ExcalidrawRectanguloidElement,
   element: ExcalidrawRectanguloidElement,
+  elementsMap: ElementsMap,
   l: LineSegment<GlobalPoint>,
   l: LineSegment<GlobalPoint>,
   offset: number = 0,
   offset: number = 0,
+  onlyFirst = false,
 ): GlobalPoint[] => {
 ): GlobalPoint[] => {
-  const center = elementCenterPoint(element);
+  const center = elementCenterPoint(element, elementsMap);
   // To emulate a rotated rectangle we rotate the point in the inverse angle
   // To emulate a rotated rectangle we rotate the point in the inverse angle
   // instead. It's all the same distance-wise.
   // instead. It's all the same distance-wise.
   const rotatedA = pointRotateRads<GlobalPoint>(
   const rotatedA = pointRotateRads<GlobalPoint>(
@@ -206,34 +321,37 @@ const intersectRectanguloidWithLineSegment = (
   // Get the element's building components we can test against
   // Get the element's building components we can test against
   const [sides, corners] = deconstructRectanguloidElement(element, offset);
   const [sides, corners] = deconstructRectanguloidElement(element, offset);
 
 
-  return (
-    // Test intersection against the sides, keep only the valid
-    // intersection points and rotate them back to scene space
-    sides
-      .map((s) =>
-        lineSegmentIntersectionPoints(
-          lineSegment<GlobalPoint>(rotatedA, rotatedB),
-          s,
-        ),
-      )
-      .filter((x) => x != null)
-      .map((j) => pointRotateRads<GlobalPoint>(j!, center, element.angle))
-      // Test intersection against the corners which are cubic bezier curves,
-      // keep only the valid intersection points and rotate them back to scene
-      // space
-      .concat(
-        corners
-          .flatMap((t) =>
-            curveIntersectLineSegment(t, lineSegment(rotatedA, rotatedB)),
-          )
-          .filter((i) => i != null)
-          .map((j) => pointRotateRads(j, center, element.angle)),
-      )
-      // Remove duplicates
-      .filter(
-        (p, idx, points) => points.findIndex((d) => pointsEqual(p, d)) === idx,
-      )
-  );
+  const intersections: GlobalPoint[] = [];
+
+  for (const s of sides) {
+    const intersection = lineSegmentIntersectionPoints(
+      lineSegment(rotatedA, rotatedB),
+      s,
+    );
+    if (intersection) {
+      intersections.push(pointRotateRads(intersection, center, element.angle));
+
+      if (onlyFirst) {
+        return intersections;
+      }
+    }
+  }
+
+  for (const t of corners) {
+    const hits = curveIntersectLineSegment(t, lineSegment(rotatedA, rotatedB));
+
+    if (hits.length > 0) {
+      for (const j of hits) {
+        intersections.push(pointRotateRads(j, center, element.angle));
+      }
+
+      if (onlyFirst) {
+        return intersections;
+      }
+    }
+  }
+
+  return intersections;
 };
 };
 
 
 /**
 /**
@@ -245,43 +363,51 @@ const intersectRectanguloidWithLineSegment = (
  */
  */
 const intersectDiamondWithLineSegment = (
 const intersectDiamondWithLineSegment = (
   element: ExcalidrawDiamondElement,
   element: ExcalidrawDiamondElement,
+  elementsMap: ElementsMap,
   l: LineSegment<GlobalPoint>,
   l: LineSegment<GlobalPoint>,
   offset: number = 0,
   offset: number = 0,
+  onlyFirst = false,
 ): GlobalPoint[] => {
 ): GlobalPoint[] => {
-  const center = elementCenterPoint(element);
+  const center = elementCenterPoint(element, elementsMap);
 
 
   // Rotate the point to the inverse direction to simulate the rotated diamond
   // Rotate the point to the inverse direction to simulate the rotated diamond
   // points. It's all the same distance-wise.
   // points. It's all the same distance-wise.
   const rotatedA = pointRotateRads(l[0], center, -element.angle as Radians);
   const rotatedA = pointRotateRads(l[0], center, -element.angle as Radians);
   const rotatedB = pointRotateRads(l[1], center, -element.angle as Radians);
   const rotatedB = pointRotateRads(l[1], center, -element.angle as Radians);
 
 
-  const [sides, curves] = deconstructDiamondElement(element, offset);
+  const [sides, corners] = deconstructDiamondElement(element, offset);
 
 
-  return (
-    sides
-      .map((s) =>
-        lineSegmentIntersectionPoints(
-          lineSegment<GlobalPoint>(rotatedA, rotatedB),
-          s,
-        ),
-      )
-      .filter((p): p is GlobalPoint => p != null)
-      // Rotate back intersection points
-      .map((p) => pointRotateRads<GlobalPoint>(p!, center, element.angle))
-      .concat(
-        curves
-          .flatMap((p) =>
-            curveIntersectLineSegment(p, lineSegment(rotatedA, rotatedB)),
-          )
-          .filter((p) => p != null)
-          // Rotate back intersection points
-          .map((p) => pointRotateRads(p, center, element.angle)),
-      )
-      // Remove duplicates
-      .filter(
-        (p, idx, points) => points.findIndex((d) => pointsEqual(p, d)) === idx,
-      )
-  );
+  const intersections: GlobalPoint[] = [];
+
+  for (const s of sides) {
+    const intersection = lineSegmentIntersectionPoints(
+      lineSegment(rotatedA, rotatedB),
+      s,
+    );
+    if (intersection) {
+      intersections.push(pointRotateRads(intersection, center, element.angle));
+
+      if (onlyFirst) {
+        return intersections;
+      }
+    }
+  }
+
+  for (const t of corners) {
+    const hits = curveIntersectLineSegment(t, lineSegment(rotatedA, rotatedB));
+
+    if (hits.length > 0) {
+      for (const j of hits) {
+        intersections.push(pointRotateRads(j, center, element.angle));
+      }
+
+      if (onlyFirst) {
+        return intersections;
+      }
+    }
+  }
+
+  return intersections;
 };
 };
 
 
 /**
 /**
@@ -293,16 +419,76 @@ const intersectDiamondWithLineSegment = (
  */
  */
 const intersectEllipseWithLineSegment = (
 const intersectEllipseWithLineSegment = (
   element: ExcalidrawEllipseElement,
   element: ExcalidrawEllipseElement,
+  elementsMap: ElementsMap,
   l: LineSegment<GlobalPoint>,
   l: LineSegment<GlobalPoint>,
   offset: number = 0,
   offset: number = 0,
 ): GlobalPoint[] => {
 ): GlobalPoint[] => {
-  const center = elementCenterPoint(element);
+  const center = elementCenterPoint(element, elementsMap);
 
 
   const rotatedA = pointRotateRads(l[0], center, -element.angle as Radians);
   const rotatedA = pointRotateRads(l[0], center, -element.angle as Radians);
   const rotatedB = pointRotateRads(l[1], center, -element.angle as Radians);
   const rotatedB = pointRotateRads(l[1], center, -element.angle as Radians);
 
 
-  return ellipseLineIntersectionPoints(
+  return ellipseSegmentInterceptPoints(
     ellipse(center, element.width / 2 + offset, element.height / 2 + offset),
     ellipse(center, element.width / 2 + offset, element.height / 2 + offset),
-    line(rotatedA, rotatedB),
+    lineSegment(rotatedA, rotatedB),
   ).map((p) => pointRotateRads(p, center, element.angle));
   ).map((p) => pointRotateRads(p, center, element.angle));
 };
 };
+
+/**
+ * Check if the given point is considered on the given shape's border
+ *
+ * @param point
+ * @param element
+ * @param tolerance
+ * @returns
+ */
+const isPointOnElementOutline = (
+  point: GlobalPoint,
+  element: ExcalidrawElement,
+  elementsMap: ElementsMap,
+  tolerance = 1,
+) => distanceToElement(element, elementsMap, point) <= tolerance;
+
+/**
+ * Check if the given point is considered inside the element's border
+ *
+ * @param point
+ * @param element
+ * @returns
+ */
+export const isPointInElement = (
+  point: GlobalPoint,
+  element: ExcalidrawElement,
+  elementsMap: ElementsMap,
+) => {
+  if (
+    (isLinearElement(element) || isFreeDrawElement(element)) &&
+    !isPathALoop(element.points)
+  ) {
+    // There isn't any "inside" for a non-looping path
+    return false;
+  }
+
+  const [x1, y1, x2, y2] = getElementBounds(element, elementsMap);
+
+  if (!isPointWithinBounds(pointFrom(x1, y1), point, pointFrom(x2, y2))) {
+    return false;
+  }
+
+  const center = pointFrom<GlobalPoint>((x1 + x2) / 2, (y1 + y2) / 2);
+  const otherPoint = pointFromVector(
+    vectorScale(
+      vectorNormalize(vectorFromPoint(point, center, 0.1)),
+      Math.max(element.width, element.height) * 2,
+    ),
+    center,
+  );
+  const intersector = lineSegment(point, otherPoint);
+  const intersections = intersectElementWithLineSegment(
+    element,
+    elementsMap,
+    intersector,
+  ).filter((p, pos, arr) => arr.findIndex((q) => pointsEqual(q, p)) === pos);
+
+  return intersections.length % 2 === 1;
+};

+ 2 - 1
packages/element/src/cropElement.ts

@@ -34,6 +34,7 @@ export const MINIMAL_CROP_SIZE = 10;
 
 
 export const cropElement = (
 export const cropElement = (
   element: ExcalidrawImageElement,
   element: ExcalidrawImageElement,
+  elementsMap: ElementsMap,
   transformHandle: TransformHandleType,
   transformHandle: TransformHandleType,
   naturalWidth: number,
   naturalWidth: number,
   naturalHeight: number,
   naturalHeight: number,
@@ -63,7 +64,7 @@ export const cropElement = (
 
 
   const rotatedPointer = pointRotateRads(
   const rotatedPointer = pointRotateRads(
     pointFrom(pointerX, pointerY),
     pointFrom(pointerX, pointerY),
-    elementCenterPoint(element),
+    elementCenterPoint(element, elementsMap),
     -element.angle as Radians,
     -element.angle as Radians,
   );
   );
 
 

+ 37 - 9
packages/element/src/distance.ts

@@ -12,21 +12,27 @@ import type { GlobalPoint, Radians } from "@excalidraw/math";
 
 
 import {
 import {
   deconstructDiamondElement,
   deconstructDiamondElement,
+  deconstructLinearOrFreeDrawElement,
   deconstructRectanguloidElement,
   deconstructRectanguloidElement,
 } from "./utils";
 } from "./utils";
 
 
 import type {
 import type {
-  ExcalidrawBindableElement,
+  ElementsMap,
   ExcalidrawDiamondElement,
   ExcalidrawDiamondElement,
+  ExcalidrawElement,
   ExcalidrawEllipseElement,
   ExcalidrawEllipseElement,
+  ExcalidrawFreeDrawElement,
+  ExcalidrawLinearElement,
   ExcalidrawRectanguloidElement,
   ExcalidrawRectanguloidElement,
 } from "./types";
 } from "./types";
 
 
-export const distanceToBindableElement = (
-  element: ExcalidrawBindableElement,
+export const distanceToElement = (
+  element: ExcalidrawElement,
+  elementsMap: ElementsMap,
   p: GlobalPoint,
   p: GlobalPoint,
 ): number => {
 ): number => {
   switch (element.type) {
   switch (element.type) {
+    case "selection":
     case "rectangle":
     case "rectangle":
     case "image":
     case "image":
     case "text":
     case "text":
@@ -34,11 +40,15 @@ export const distanceToBindableElement = (
     case "embeddable":
     case "embeddable":
     case "frame":
     case "frame":
     case "magicframe":
     case "magicframe":
-      return distanceToRectanguloidElement(element, p);
+      return distanceToRectanguloidElement(element, elementsMap, p);
     case "diamond":
     case "diamond":
-      return distanceToDiamondElement(element, p);
+      return distanceToDiamondElement(element, elementsMap, p);
     case "ellipse":
     case "ellipse":
-      return distanceToEllipseElement(element, p);
+      return distanceToEllipseElement(element, elementsMap, p);
+    case "line":
+    case "arrow":
+    case "freedraw":
+      return distanceToLinearOrFreeDraElement(element, elementsMap, p);
   }
   }
 };
 };
 
 
@@ -52,9 +62,10 @@ export const distanceToBindableElement = (
  */
  */
 const distanceToRectanguloidElement = (
 const distanceToRectanguloidElement = (
   element: ExcalidrawRectanguloidElement,
   element: ExcalidrawRectanguloidElement,
+  elementsMap: ElementsMap,
   p: GlobalPoint,
   p: GlobalPoint,
 ) => {
 ) => {
-  const center = elementCenterPoint(element);
+  const center = elementCenterPoint(element, elementsMap);
   // To emulate a rotated rectangle we rotate the point in the inverse angle
   // To emulate a rotated rectangle we rotate the point in the inverse angle
   // instead. It's all the same distance-wise.
   // instead. It's all the same distance-wise.
   const rotatedPoint = pointRotateRads(p, center, -element.angle as Radians);
   const rotatedPoint = pointRotateRads(p, center, -element.angle as Radians);
@@ -80,9 +91,10 @@ const distanceToRectanguloidElement = (
  */
  */
 const distanceToDiamondElement = (
 const distanceToDiamondElement = (
   element: ExcalidrawDiamondElement,
   element: ExcalidrawDiamondElement,
+  elementsMap: ElementsMap,
   p: GlobalPoint,
   p: GlobalPoint,
 ): number => {
 ): number => {
-  const center = elementCenterPoint(element);
+  const center = elementCenterPoint(element, elementsMap);
 
 
   // Rotate the point to the inverse direction to simulate the rotated diamond
   // Rotate the point to the inverse direction to simulate the rotated diamond
   // points. It's all the same distance-wise.
   // points. It's all the same distance-wise.
@@ -108,12 +120,28 @@ const distanceToDiamondElement = (
  */
  */
 const distanceToEllipseElement = (
 const distanceToEllipseElement = (
   element: ExcalidrawEllipseElement,
   element: ExcalidrawEllipseElement,
+  elementsMap: ElementsMap,
   p: GlobalPoint,
   p: GlobalPoint,
 ): number => {
 ): number => {
-  const center = elementCenterPoint(element);
+  const center = elementCenterPoint(element, elementsMap);
   return ellipseDistanceFromPoint(
   return ellipseDistanceFromPoint(
     // Instead of rotating the ellipse, rotate the point to the inverse angle
     // Instead of rotating the ellipse, rotate the point to the inverse angle
     pointRotateRads(p, center, -element.angle as Radians),
     pointRotateRads(p, center, -element.angle as Radians),
     ellipse(center, element.width / 2, element.height / 2),
     ellipse(center, element.width / 2, element.height / 2),
   );
   );
 };
 };
+
+const distanceToLinearOrFreeDraElement = (
+  element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
+  elementsMap: ElementsMap,
+  p: GlobalPoint,
+) => {
+  const [lines, curves] = deconstructLinearOrFreeDrawElement(
+    element,
+    elementsMap,
+  );
+  return Math.min(
+    ...lines.map((s) => distanceToLineSegment(p, s)),
+    ...curves.map((a) => curvePointDistance(a, p)),
+  );
+};

+ 23 - 56
packages/element/src/elbowArrow.ts

@@ -29,10 +29,9 @@ import {
   FIXED_BINDING_DISTANCE,
   FIXED_BINDING_DISTANCE,
   getHeadingForElbowArrowSnap,
   getHeadingForElbowArrowSnap,
   getGlobalFixedPointForBindableElement,
   getGlobalFixedPointForBindableElement,
-  snapToMid,
   getHoveredElementForBinding,
   getHoveredElementForBinding,
 } from "./binding";
 } from "./binding";
-import { distanceToBindableElement } from "./distance";
+import { distanceToElement } from "./distance";
 import {
 import {
   compareHeading,
   compareHeading,
   flipHeading,
   flipHeading,
@@ -898,50 +897,6 @@ export const updateElbowArrowPoints = (
     return { points: updates.points ?? arrow.points };
     return { points: updates.points ?? arrow.points };
   }
   }
 
 
-  // NOTE (mtolmacs): This is a temporary check to ensure that the incoming elbow
-  // arrow size is valid. This check will be removed once the issue is identified
-  if (
-    arrow.x < -MAX_POS ||
-    arrow.x > MAX_POS ||
-    arrow.y < -MAX_POS ||
-    arrow.y > MAX_POS ||
-    arrow.x + (updates?.points?.[updates?.points?.length - 1]?.[0] ?? 0) <
-      -MAX_POS ||
-    arrow.x + (updates?.points?.[updates?.points?.length - 1]?.[0] ?? 0) >
-      MAX_POS ||
-    arrow.y + (updates?.points?.[updates?.points?.length - 1]?.[1] ?? 0) <
-      -MAX_POS ||
-    arrow.y + (updates?.points?.[updates?.points?.length - 1]?.[1] ?? 0) >
-      MAX_POS ||
-    arrow.x + (arrow?.points?.[arrow?.points?.length - 1]?.[0] ?? 0) <
-      -MAX_POS ||
-    arrow.x + (arrow?.points?.[arrow?.points?.length - 1]?.[0] ?? 0) >
-      MAX_POS ||
-    arrow.y + (arrow?.points?.[arrow?.points?.length - 1]?.[1] ?? 0) <
-      -MAX_POS ||
-    arrow.y + (arrow?.points?.[arrow?.points?.length - 1]?.[1] ?? 0) > MAX_POS
-  ) {
-    console.error(
-      "Elbow arrow (or update) is outside reasonable bounds (> 1e6)",
-      {
-        arrow,
-        updates,
-      },
-    );
-  }
-  // @ts-ignore See above note
-  arrow.x = clamp(arrow.x, -MAX_POS, MAX_POS);
-  // @ts-ignore See above note
-  arrow.y = clamp(arrow.y, -MAX_POS, MAX_POS);
-  if (updates.points) {
-    updates.points = updates.points.map(([x, y]) =>
-      pointFrom<LocalPoint>(
-        clamp(x, -MAX_POS, MAX_POS),
-        clamp(y, -MAX_POS, MAX_POS),
-      ),
-    );
-  }
-
   if (!import.meta.env.PROD) {
   if (!import.meta.env.PROD) {
     invariant(
     invariant(
       !updates.points || updates.points.length >= 2,
       !updates.points || updates.points.length >= 2,
@@ -1273,6 +1228,7 @@ const getElbowArrowData = (
     arrow.startBinding?.fixedPoint,
     arrow.startBinding?.fixedPoint,
     origStartGlobalPoint,
     origStartGlobalPoint,
     hoveredStartElement,
     hoveredStartElement,
+    elementsMap,
     options?.isDragging,
     options?.isDragging,
   );
   );
   const endGlobalPoint = getGlobalPoint(
   const endGlobalPoint = getGlobalPoint(
@@ -1286,6 +1242,7 @@ const getElbowArrowData = (
     arrow.endBinding?.fixedPoint,
     arrow.endBinding?.fixedPoint,
     origEndGlobalPoint,
     origEndGlobalPoint,
     hoveredEndElement,
     hoveredEndElement,
+    elementsMap,
     options?.isDragging,
     options?.isDragging,
   );
   );
   const startHeading = getBindPointHeading(
   const startHeading = getBindPointHeading(
@@ -1293,12 +1250,14 @@ const getElbowArrowData = (
     endGlobalPoint,
     endGlobalPoint,
     hoveredStartElement,
     hoveredStartElement,
     origStartGlobalPoint,
     origStartGlobalPoint,
+    elementsMap,
   );
   );
   const endHeading = getBindPointHeading(
   const endHeading = getBindPointHeading(
     endGlobalPoint,
     endGlobalPoint,
     startGlobalPoint,
     startGlobalPoint,
     hoveredEndElement,
     hoveredEndElement,
     origEndGlobalPoint,
     origEndGlobalPoint,
+    elementsMap,
   );
   );
   const startPointBounds = [
   const startPointBounds = [
     startGlobalPoint[0] - 2,
     startGlobalPoint[0] - 2,
@@ -1315,6 +1274,7 @@ const getElbowArrowData = (
   const startElementBounds = hoveredStartElement
   const startElementBounds = hoveredStartElement
     ? aabbForElement(
     ? aabbForElement(
         hoveredStartElement,
         hoveredStartElement,
+        elementsMap,
         offsetFromHeading(
         offsetFromHeading(
           startHeading,
           startHeading,
           arrow.startArrowhead
           arrow.startArrowhead
@@ -1327,6 +1287,7 @@ const getElbowArrowData = (
   const endElementBounds = hoveredEndElement
   const endElementBounds = hoveredEndElement
     ? aabbForElement(
     ? aabbForElement(
         hoveredEndElement,
         hoveredEndElement,
+        elementsMap,
         offsetFromHeading(
         offsetFromHeading(
           endHeading,
           endHeading,
           arrow.endArrowhead
           arrow.endArrowhead
@@ -1342,6 +1303,7 @@ const getElbowArrowData = (
       hoveredEndElement
       hoveredEndElement
         ? aabbForElement(
         ? aabbForElement(
             hoveredEndElement,
             hoveredEndElement,
+            elementsMap,
             offsetFromHeading(endHeading, BASE_PADDING, BASE_PADDING),
             offsetFromHeading(endHeading, BASE_PADDING, BASE_PADDING),
           )
           )
         : endPointBounds,
         : endPointBounds,
@@ -1351,6 +1313,7 @@ const getElbowArrowData = (
       hoveredStartElement
       hoveredStartElement
         ? aabbForElement(
         ? aabbForElement(
             hoveredStartElement,
             hoveredStartElement,
+            elementsMap,
             offsetFromHeading(startHeading, BASE_PADDING, BASE_PADDING),
             offsetFromHeading(startHeading, BASE_PADDING, BASE_PADDING),
           )
           )
         : startPointBounds,
         : startPointBounds,
@@ -1397,8 +1360,8 @@ const getElbowArrowData = (
           BASE_PADDING,
           BASE_PADDING,
         ),
         ),
     boundsOverlap,
     boundsOverlap,
-    hoveredStartElement && aabbForElement(hoveredStartElement),
-    hoveredEndElement && aabbForElement(hoveredEndElement),
+    hoveredStartElement && aabbForElement(hoveredStartElement, elementsMap),
+    hoveredEndElement && aabbForElement(hoveredEndElement, elementsMap),
   );
   );
   const startDonglePosition = getDonglePosition(
   const startDonglePosition = getDonglePosition(
     dynamicAABBs[0],
     dynamicAABBs[0],
@@ -2229,34 +2192,35 @@ const getGlobalPoint = (
   fixedPointRatio: [number, number] | undefined | null,
   fixedPointRatio: [number, number] | undefined | null,
   initialPoint: GlobalPoint,
   initialPoint: GlobalPoint,
   element?: ExcalidrawBindableElement | null,
   element?: ExcalidrawBindableElement | null,
+  elementsMap?: ElementsMap,
   isDragging?: boolean,
   isDragging?: boolean,
 ): GlobalPoint => {
 ): GlobalPoint => {
   if (isDragging) {
   if (isDragging) {
-    if (element) {
-      const snapPoint = bindPointToSnapToElementOutline(
+    if (element && elementsMap) {
+      return bindPointToSnapToElementOutline(
         arrow,
         arrow,
         element,
         element,
         startOrEnd,
         startOrEnd,
+        elementsMap,
       );
       );
-
-      return snapToMid(element, snapPoint);
     }
     }
 
 
     return initialPoint;
     return initialPoint;
   }
   }
 
 
-  if (element) {
+  if (element && elementsMap) {
     const fixedGlobalPoint = getGlobalFixedPointForBindableElement(
     const fixedGlobalPoint = getGlobalFixedPointForBindableElement(
       fixedPointRatio || [0, 0],
       fixedPointRatio || [0, 0],
       element,
       element,
+      elementsMap,
     );
     );
 
 
     // NOTE: Resize scales the binding position point too, so we need to update it
     // NOTE: Resize scales the binding position point too, so we need to update it
     return Math.abs(
     return Math.abs(
-      distanceToBindableElement(element, fixedGlobalPoint) -
+      distanceToElement(element, elementsMap, fixedGlobalPoint) -
         FIXED_BINDING_DISTANCE,
         FIXED_BINDING_DISTANCE,
     ) > 0.01
     ) > 0.01
-      ? bindPointToSnapToElementOutline(arrow, element, startOrEnd)
+      ? bindPointToSnapToElementOutline(arrow, element, startOrEnd, elementsMap)
       : fixedGlobalPoint;
       : fixedGlobalPoint;
   }
   }
 
 
@@ -2268,6 +2232,7 @@ const getBindPointHeading = (
   otherPoint: GlobalPoint,
   otherPoint: GlobalPoint,
   hoveredElement: ExcalidrawBindableElement | null | undefined,
   hoveredElement: ExcalidrawBindableElement | null | undefined,
   origPoint: GlobalPoint,
   origPoint: GlobalPoint,
+  elementsMap: ElementsMap,
 ): Heading =>
 ): Heading =>
   getHeadingForElbowArrowSnap(
   getHeadingForElbowArrowSnap(
     p,
     p,
@@ -2276,7 +2241,8 @@ const getBindPointHeading = (
     hoveredElement &&
     hoveredElement &&
       aabbForElement(
       aabbForElement(
         hoveredElement,
         hoveredElement,
-        Array(4).fill(distanceToBindableElement(hoveredElement, p)) as [
+        elementsMap,
+        Array(4).fill(distanceToElement(hoveredElement, elementsMap, p)) as [
           number,
           number,
           number,
           number,
           number,
           number,
@@ -2284,6 +2250,7 @@ const getBindPointHeading = (
         ],
         ],
       ),
       ),
     origPoint,
     origPoint,
+    elementsMap,
   );
   );
 
 
 const getHoveredElement = (
 const getHoveredElement = (

+ 5 - 4
packages/element/src/flowchart.ts

@@ -95,10 +95,11 @@ const getNodeRelatives = (
           type === "predecessors" ? el.points[el.points.length - 1] : [0, 0]
           type === "predecessors" ? el.points[el.points.length - 1] : [0, 0]
         ) as Readonly<LocalPoint>;
         ) as Readonly<LocalPoint>;
 
 
-        const heading = headingForPointFromElement(node, aabbForElement(node), [
-          edgePoint[0] + el.x,
-          edgePoint[1] + el.y,
-        ] as Readonly<GlobalPoint>);
+        const heading = headingForPointFromElement(
+          node,
+          aabbForElement(node, elementsMap),
+          [edgePoint[0] + el.x, edgePoint[1] + el.y] as Readonly<GlobalPoint>,
+        );
 
 
         acc.push({
         acc.push({
           relative,
           relative,

+ 2 - 1
packages/element/src/shapes.ts

@@ -291,6 +291,7 @@ export const mapIntervalToBezierT = <P extends GlobalPoint | LocalPoint>(
  */
  */
 export const aabbForElement = (
 export const aabbForElement = (
   element: Readonly<ExcalidrawElement>,
   element: Readonly<ExcalidrawElement>,
+  elementsMap: ElementsMap,
   offset?: [number, number, number, number],
   offset?: [number, number, number, number],
 ) => {
 ) => {
   const bbox = {
   const bbox = {
@@ -302,7 +303,7 @@ export const aabbForElement = (
     midY: element.y + element.height / 2,
     midY: element.y + element.height / 2,
   };
   };
 
 
-  const center = elementCenterPoint(element);
+  const center = elementCenterPoint(element, elementsMap);
   const [topLeftX, topLeftY] = pointRotateRads(
   const [topLeftX, topLeftY] = pointRotateRads(
     pointFrom(bbox.minX, bbox.minY),
     pointFrom(bbox.minX, bbox.minY),
     center,
     center,

+ 2 - 1
packages/element/src/types.ts

@@ -195,7 +195,8 @@ export type ExcalidrawRectanguloidElement =
   | ExcalidrawFreeDrawElement
   | ExcalidrawFreeDrawElement
   | ExcalidrawIframeLikeElement
   | ExcalidrawIframeLikeElement
   | ExcalidrawFrameLikeElement
   | ExcalidrawFrameLikeElement
-  | ExcalidrawEmbeddableElement;
+  | ExcalidrawEmbeddableElement
+  | ExcalidrawSelectionElement;
 
 
 /**
 /**
  * ExcalidrawElement should be JSON serializable and (eventually) contain
  * ExcalidrawElement should be JSON serializable and (eventually) contain

+ 313 - 238
packages/element/src/utils.ts

@@ -1,28 +1,168 @@
 import {
 import {
   curve,
   curve,
+  curveCatmullRomCubicApproxPoints,
+  curveOffsetPoints,
   lineSegment,
   lineSegment,
   pointFrom,
   pointFrom,
-  pointFromVector,
+  pointFromArray,
   rectangle,
   rectangle,
-  vectorFromPoint,
-  vectorNormalize,
-  vectorScale,
   type GlobalPoint,
   type GlobalPoint,
 } from "@excalidraw/math";
 } from "@excalidraw/math";
 
 
-import { elementCenterPoint } from "@excalidraw/common";
-
-import type { Curve, LineSegment } from "@excalidraw/math";
+import type { Curve, LineSegment, LocalPoint } from "@excalidraw/math";
 
 
 import { getCornerRadius } from "./shapes";
 import { getCornerRadius } from "./shapes";
 
 
 import { getDiamondPoints } from "./bounds";
 import { getDiamondPoints } from "./bounds";
 
 
+import { generateLinearCollisionShape } from "./Shape";
+
 import type {
 import type {
+  ElementsMap,
   ExcalidrawDiamondElement,
   ExcalidrawDiamondElement,
+  ExcalidrawElement,
+  ExcalidrawFreeDrawElement,
+  ExcalidrawLinearElement,
   ExcalidrawRectanguloidElement,
   ExcalidrawRectanguloidElement,
 } from "./types";
 } from "./types";
 
 
+type ElementShape = [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]];
+
+const ElementShapesCache = new WeakMap<
+  ExcalidrawElement,
+  { version: ExcalidrawElement["version"]; shapes: Map<number, ElementShape> }
+>();
+
+const getElementShapesCacheEntry = <T extends ExcalidrawElement>(
+  element: T,
+  offset: number,
+): ElementShape | undefined => {
+  const record = ElementShapesCache.get(element);
+
+  if (!record) {
+    return undefined;
+  }
+
+  const { version, shapes } = record;
+
+  if (version !== element.version) {
+    ElementShapesCache.delete(element);
+    return undefined;
+  }
+
+  return shapes.get(offset);
+};
+
+const setElementShapesCacheEntry = <T extends ExcalidrawElement>(
+  element: T,
+  shape: ElementShape,
+  offset: number,
+) => {
+  const record = ElementShapesCache.get(element);
+
+  if (!record) {
+    ElementShapesCache.set(element, {
+      version: element.version,
+      shapes: new Map([[offset, shape]]),
+    });
+
+    return;
+  }
+
+  const { version, shapes } = record;
+
+  if (version !== element.version) {
+    ElementShapesCache.set(element, {
+      version: element.version,
+      shapes: new Map([[offset, shape]]),
+    });
+
+    return;
+  }
+
+  shapes.set(offset, shape);
+};
+
+export function deconstructLinearOrFreeDrawElement(
+  element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
+  elementsMap: ElementsMap,
+): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
+  const cachedShape = getElementShapesCacheEntry(element, 0);
+
+  if (cachedShape) {
+    return cachedShape;
+  }
+
+  const ops = generateLinearCollisionShape(element, elementsMap) as {
+    op: string;
+    data: number[];
+  }[];
+  const lines = [];
+  const curves = [];
+
+  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 "lineTo":
+        if (!prevPoint) {
+          throw new Error("prevPoint is undefined");
+        }
+
+        lines.push(
+          lineSegment<GlobalPoint>(
+            pointFrom<GlobalPoint>(
+              element.x + prevPoint[0],
+              element.y + prevPoint[1],
+            ),
+            pointFrom<GlobalPoint>(
+              element.x + op.data[0],
+              element.y + op.data[1],
+            ),
+          ),
+        );
+        continue;
+      case "bcurveTo":
+        if (!prevPoint) {
+          throw new Error("prevPoint is undefined");
+        }
+
+        curves.push(
+          curve<GlobalPoint>(
+            pointFrom<GlobalPoint>(
+              element.x + prevPoint[0],
+              element.y + prevPoint[1],
+            ),
+            pointFrom<GlobalPoint>(
+              element.x + op.data[0],
+              element.y + op.data[1],
+            ),
+            pointFrom<GlobalPoint>(
+              element.x + op.data[2],
+              element.y + op.data[3],
+            ),
+            pointFrom<GlobalPoint>(
+              element.x + op.data[4],
+              element.y + op.data[5],
+            ),
+          ),
+        );
+        continue;
+      default: {
+        console.error("Unknown op type", op.op);
+      }
+    }
+  }
+
+  const shape = [lines, curves] as ElementShape;
+  setElementShapesCacheEntry(element, shape, 0);
+
+  return shape;
+}
+
 /**
 /**
  * Get the building components of a rectanguloid element in the form of
  * Get the building components of a rectanguloid element in the form of
  * line segments and curves.
  * line segments and curves.
@@ -35,175 +175,132 @@ export function deconstructRectanguloidElement(
   element: ExcalidrawRectanguloidElement,
   element: ExcalidrawRectanguloidElement,
   offset: number = 0,
   offset: number = 0,
 ): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
 ): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
-  const roundness = getCornerRadius(
+  const cachedShape = getElementShapesCacheEntry(element, offset);
+
+  if (cachedShape) {
+    return cachedShape;
+  }
+
+  let radius = getCornerRadius(
     Math.min(element.width, element.height),
     Math.min(element.width, element.height),
     element,
     element,
   );
   );
 
 
-  if (roundness <= 0) {
-    const r = rectangle(
-      pointFrom(element.x - offset, element.y - offset),
-      pointFrom(
-        element.x + element.width + offset,
-        element.y + element.height + offset,
-      ),
-    );
-
-    const top = lineSegment<GlobalPoint>(
-      pointFrom<GlobalPoint>(r[0][0] + roundness, r[0][1]),
-      pointFrom<GlobalPoint>(r[1][0] - roundness, r[0][1]),
-    );
-    const right = lineSegment<GlobalPoint>(
-      pointFrom<GlobalPoint>(r[1][0], r[0][1] + roundness),
-      pointFrom<GlobalPoint>(r[1][0], r[1][1] - roundness),
-    );
-    const bottom = lineSegment<GlobalPoint>(
-      pointFrom<GlobalPoint>(r[0][0] + roundness, r[1][1]),
-      pointFrom<GlobalPoint>(r[1][0] - roundness, r[1][1]),
-    );
-    const left = lineSegment<GlobalPoint>(
-      pointFrom<GlobalPoint>(r[0][0], r[1][1] - roundness),
-      pointFrom<GlobalPoint>(r[0][0], r[0][1] + roundness),
-    );
-    const sides = [top, right, bottom, left];
-
-    return [sides, []];
+  if (radius === 0) {
+    radius = 0.01;
   }
   }
 
 
-  const center = elementCenterPoint(element);
-
   const r = rectangle(
   const r = rectangle(
     pointFrom(element.x, element.y),
     pointFrom(element.x, element.y),
     pointFrom(element.x + element.width, element.y + element.height),
     pointFrom(element.x + element.width, element.y + element.height),
   );
   );
 
 
   const top = lineSegment<GlobalPoint>(
   const top = lineSegment<GlobalPoint>(
-    pointFrom<GlobalPoint>(r[0][0] + roundness, r[0][1]),
-    pointFrom<GlobalPoint>(r[1][0] - roundness, r[0][1]),
+    pointFrom<GlobalPoint>(r[0][0] + radius, r[0][1]),
+    pointFrom<GlobalPoint>(r[1][0] - radius, r[0][1]),
   );
   );
   const right = lineSegment<GlobalPoint>(
   const right = lineSegment<GlobalPoint>(
-    pointFrom<GlobalPoint>(r[1][0], r[0][1] + roundness),
-    pointFrom<GlobalPoint>(r[1][0], r[1][1] - roundness),
+    pointFrom<GlobalPoint>(r[1][0], r[0][1] + radius),
+    pointFrom<GlobalPoint>(r[1][0], r[1][1] - radius),
   );
   );
   const bottom = lineSegment<GlobalPoint>(
   const bottom = lineSegment<GlobalPoint>(
-    pointFrom<GlobalPoint>(r[0][0] + roundness, r[1][1]),
-    pointFrom<GlobalPoint>(r[1][0] - roundness, r[1][1]),
+    pointFrom<GlobalPoint>(r[0][0] + radius, r[1][1]),
+    pointFrom<GlobalPoint>(r[1][0] - radius, r[1][1]),
   );
   );
   const left = lineSegment<GlobalPoint>(
   const left = lineSegment<GlobalPoint>(
-    pointFrom<GlobalPoint>(r[0][0], r[1][1] - roundness),
-    pointFrom<GlobalPoint>(r[0][0], r[0][1] + roundness),
+    pointFrom<GlobalPoint>(r[0][0], r[1][1] - radius),
+    pointFrom<GlobalPoint>(r[0][0], r[0][1] + radius),
   );
   );
 
 
-  const offsets = [
-    vectorScale(
-      vectorNormalize(
-        vectorFromPoint(pointFrom(r[0][0] - offset, r[0][1] - offset), center),
-      ),
-      offset,
-    ), // TOP LEFT
-    vectorScale(
-      vectorNormalize(
-        vectorFromPoint(pointFrom(r[1][0] + offset, r[0][1] - offset), center),
-      ),
-      offset,
-    ), //TOP RIGHT
-    vectorScale(
-      vectorNormalize(
-        vectorFromPoint(pointFrom(r[1][0] + offset, r[1][1] + offset), center),
-      ),
-      offset,
-    ), // BOTTOM RIGHT
-    vectorScale(
-      vectorNormalize(
-        vectorFromPoint(pointFrom(r[0][0] - offset, r[1][1] + offset), center),
-      ),
-      offset,
-    ), // BOTTOM LEFT
-  ];
-
-  const corners = [
+  const baseCorners = [
     curve(
     curve(
-      pointFromVector(offsets[0], left[1]),
-      pointFromVector(
-        offsets[0],
-        pointFrom<GlobalPoint>(
-          left[1][0] + (2 / 3) * (r[0][0] - left[1][0]),
-          left[1][1] + (2 / 3) * (r[0][1] - left[1][1]),
-        ),
+      left[1],
+      pointFrom<GlobalPoint>(
+        left[1][0] + (2 / 3) * (r[0][0] - left[1][0]),
+        left[1][1] + (2 / 3) * (r[0][1] - left[1][1]),
       ),
       ),
-      pointFromVector(
-        offsets[0],
-        pointFrom<GlobalPoint>(
-          top[0][0] + (2 / 3) * (r[0][0] - top[0][0]),
-          top[0][1] + (2 / 3) * (r[0][1] - top[0][1]),
-        ),
+      pointFrom<GlobalPoint>(
+        top[0][0] + (2 / 3) * (r[0][0] - top[0][0]),
+        top[0][1] + (2 / 3) * (r[0][1] - top[0][1]),
       ),
       ),
-      pointFromVector(offsets[0], top[0]),
+      top[0],
     ), // TOP LEFT
     ), // TOP LEFT
     curve(
     curve(
-      pointFromVector(offsets[1], top[1]),
-      pointFromVector(
-        offsets[1],
-        pointFrom<GlobalPoint>(
-          top[1][0] + (2 / 3) * (r[1][0] - top[1][0]),
-          top[1][1] + (2 / 3) * (r[0][1] - top[1][1]),
-        ),
+      top[1],
+      pointFrom<GlobalPoint>(
+        top[1][0] + (2 / 3) * (r[1][0] - top[1][0]),
+        top[1][1] + (2 / 3) * (r[0][1] - top[1][1]),
       ),
       ),
-      pointFromVector(
-        offsets[1],
-        pointFrom<GlobalPoint>(
-          right[0][0] + (2 / 3) * (r[1][0] - right[0][0]),
-          right[0][1] + (2 / 3) * (r[0][1] - right[0][1]),
-        ),
+      pointFrom<GlobalPoint>(
+        right[0][0] + (2 / 3) * (r[1][0] - right[0][0]),
+        right[0][1] + (2 / 3) * (r[0][1] - right[0][1]),
       ),
       ),
-      pointFromVector(offsets[1], right[0]),
+      right[0],
     ), // TOP RIGHT
     ), // TOP RIGHT
     curve(
     curve(
-      pointFromVector(offsets[2], right[1]),
-      pointFromVector(
-        offsets[2],
-        pointFrom<GlobalPoint>(
-          right[1][0] + (2 / 3) * (r[1][0] - right[1][0]),
-          right[1][1] + (2 / 3) * (r[1][1] - right[1][1]),
-        ),
+      right[1],
+      pointFrom<GlobalPoint>(
+        right[1][0] + (2 / 3) * (r[1][0] - right[1][0]),
+        right[1][1] + (2 / 3) * (r[1][1] - right[1][1]),
       ),
       ),
-      pointFromVector(
-        offsets[2],
-        pointFrom<GlobalPoint>(
-          bottom[1][0] + (2 / 3) * (r[1][0] - bottom[1][0]),
-          bottom[1][1] + (2 / 3) * (r[1][1] - bottom[1][1]),
-        ),
+      pointFrom<GlobalPoint>(
+        bottom[1][0] + (2 / 3) * (r[1][0] - bottom[1][0]),
+        bottom[1][1] + (2 / 3) * (r[1][1] - bottom[1][1]),
       ),
       ),
-      pointFromVector(offsets[2], bottom[1]),
+      bottom[1],
     ), // BOTTOM RIGHT
     ), // BOTTOM RIGHT
     curve(
     curve(
-      pointFromVector(offsets[3], bottom[0]),
-      pointFromVector(
-        offsets[3],
-        pointFrom<GlobalPoint>(
-          bottom[0][0] + (2 / 3) * (r[0][0] - bottom[0][0]),
-          bottom[0][1] + (2 / 3) * (r[1][1] - bottom[0][1]),
-        ),
+      bottom[0],
+      pointFrom<GlobalPoint>(
+        bottom[0][0] + (2 / 3) * (r[0][0] - bottom[0][0]),
+        bottom[0][1] + (2 / 3) * (r[1][1] - bottom[0][1]),
       ),
       ),
-      pointFromVector(
-        offsets[3],
-        pointFrom<GlobalPoint>(
-          left[0][0] + (2 / 3) * (r[0][0] - left[0][0]),
-          left[0][1] + (2 / 3) * (r[1][1] - left[0][1]),
-        ),
+      pointFrom<GlobalPoint>(
+        left[0][0] + (2 / 3) * (r[0][0] - left[0][0]),
+        left[0][1] + (2 / 3) * (r[1][1] - left[0][1]),
       ),
       ),
-      pointFromVector(offsets[3], left[0]),
+      left[0],
     ), // BOTTOM LEFT
     ), // BOTTOM LEFT
   ];
   ];
 
 
+  const corners =
+    offset > 0
+      ? baseCorners.map(
+          (corner) =>
+            curveCatmullRomCubicApproxPoints(
+              curveOffsetPoints(corner, offset),
+            )!,
+        )
+      : [
+          [baseCorners[0]],
+          [baseCorners[1]],
+          [baseCorners[2]],
+          [baseCorners[3]],
+        ];
+
   const sides = [
   const sides = [
-    lineSegment<GlobalPoint>(corners[0][3], corners[1][0]),
-    lineSegment<GlobalPoint>(corners[1][3], corners[2][0]),
-    lineSegment<GlobalPoint>(corners[2][3], corners[3][0]),
-    lineSegment<GlobalPoint>(corners[3][3], corners[0][0]),
+    lineSegment<GlobalPoint>(
+      corners[0][corners[0].length - 1][3],
+      corners[1][0][0],
+    ),
+    lineSegment<GlobalPoint>(
+      corners[1][corners[1].length - 1][3],
+      corners[2][0][0],
+    ),
+    lineSegment<GlobalPoint>(
+      corners[2][corners[2].length - 1][3],
+      corners[3][0][0],
+    ),
+    lineSegment<GlobalPoint>(
+      corners[3][corners[3].length - 1][3],
+      corners[0][0][0],
+    ),
   ];
   ];
+  const shape = [sides, corners.flat()] as ElementShape;
+
+  setElementShapesCacheEntry(element, shape, offset);
 
 
-  return [sides, corners];
+  return shape;
 }
 }
 
 
 /**
 /**
@@ -218,42 +315,20 @@ export function deconstructDiamondElement(
   element: ExcalidrawDiamondElement,
   element: ExcalidrawDiamondElement,
   offset: number = 0,
   offset: number = 0,
 ): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
 ): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
-  const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] =
-    getDiamondPoints(element);
-  const verticalRadius = getCornerRadius(Math.abs(topX - leftX), element);
-  const horizontalRadius = getCornerRadius(Math.abs(rightY - topY), element);
-
-  if (element.roundness?.type == null) {
-    const [top, right, bottom, left]: GlobalPoint[] = [
-      pointFrom(element.x + topX, element.y + topY - offset),
-      pointFrom(element.x + rightX + offset, element.y + rightY),
-      pointFrom(element.x + bottomX, element.y + bottomY + offset),
-      pointFrom(element.x + leftX - offset, element.y + leftY),
-    ];
-
-    // Create the line segment parts of the diamond
-    // NOTE: Horizontal and vertical seems to be flipped here
-    const topRight = lineSegment<GlobalPoint>(
-      pointFrom(top[0] + verticalRadius, top[1] + horizontalRadius),
-      pointFrom(right[0] - verticalRadius, right[1] - horizontalRadius),
-    );
-    const bottomRight = lineSegment<GlobalPoint>(
-      pointFrom(right[0] - verticalRadius, right[1] + horizontalRadius),
-      pointFrom(bottom[0] + verticalRadius, bottom[1] - horizontalRadius),
-    );
-    const bottomLeft = lineSegment<GlobalPoint>(
-      pointFrom(bottom[0] - verticalRadius, bottom[1] - horizontalRadius),
-      pointFrom(left[0] + verticalRadius, left[1] + horizontalRadius),
-    );
-    const topLeft = lineSegment<GlobalPoint>(
-      pointFrom(left[0] + verticalRadius, left[1] - horizontalRadius),
-      pointFrom(top[0] - verticalRadius, top[1] + horizontalRadius),
-    );
-
-    return [[topRight, bottomRight, bottomLeft, topLeft], []];
+  const cachedShape = getElementShapesCacheEntry(element, offset);
+
+  if (cachedShape) {
+    return cachedShape;
   }
   }
 
 
-  const center = elementCenterPoint(element);
+  const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] =
+    getDiamondPoints(element);
+  const verticalRadius = element.roundness
+    ? getCornerRadius(Math.abs(topX - leftX), element)
+    : (topX - leftX) * 0.01;
+  const horizontalRadius = element.roundness
+    ? getCornerRadius(Math.abs(rightY - topY), element)
+    : (rightY - topY) * 0.01;
 
 
   const [top, right, bottom, left]: GlobalPoint[] = [
   const [top, right, bottom, left]: GlobalPoint[] = [
     pointFrom(element.x + topX, element.y + topY),
     pointFrom(element.x + topX, element.y + topY),
@@ -262,94 +337,94 @@ export function deconstructDiamondElement(
     pointFrom(element.x + leftX, element.y + leftY),
     pointFrom(element.x + leftX, element.y + leftY),
   ];
   ];
 
 
-  const offsets = [
-    vectorScale(vectorNormalize(vectorFromPoint(right, center)), offset), // RIGHT
-    vectorScale(vectorNormalize(vectorFromPoint(bottom, center)), offset), // BOTTOM
-    vectorScale(vectorNormalize(vectorFromPoint(left, center)), offset), // LEFT
-    vectorScale(vectorNormalize(vectorFromPoint(top, center)), offset), // TOP
-  ];
-
-  const corners = [
+  const baseCorners = [
     curve(
     curve(
-      pointFromVector(
-        offsets[0],
-        pointFrom<GlobalPoint>(
-          right[0] - verticalRadius,
-          right[1] - horizontalRadius,
-        ),
+      pointFrom<GlobalPoint>(
+        right[0] - verticalRadius,
+        right[1] - horizontalRadius,
       ),
       ),
-      pointFromVector(offsets[0], right),
-      pointFromVector(offsets[0], right),
-      pointFromVector(
-        offsets[0],
-        pointFrom<GlobalPoint>(
-          right[0] - verticalRadius,
-          right[1] + horizontalRadius,
-        ),
+      right,
+      right,
+      pointFrom<GlobalPoint>(
+        right[0] - verticalRadius,
+        right[1] + horizontalRadius,
       ),
       ),
     ), // RIGHT
     ), // RIGHT
     curve(
     curve(
-      pointFromVector(
-        offsets[1],
-        pointFrom<GlobalPoint>(
-          bottom[0] + verticalRadius,
-          bottom[1] - horizontalRadius,
-        ),
+      pointFrom<GlobalPoint>(
+        bottom[0] + verticalRadius,
+        bottom[1] - horizontalRadius,
       ),
       ),
-      pointFromVector(offsets[1], bottom),
-      pointFromVector(offsets[1], bottom),
-      pointFromVector(
-        offsets[1],
-        pointFrom<GlobalPoint>(
-          bottom[0] - verticalRadius,
-          bottom[1] - horizontalRadius,
-        ),
+      bottom,
+      bottom,
+      pointFrom<GlobalPoint>(
+        bottom[0] - verticalRadius,
+        bottom[1] - horizontalRadius,
       ),
       ),
     ), // BOTTOM
     ), // BOTTOM
     curve(
     curve(
-      pointFromVector(
-        offsets[2],
-        pointFrom<GlobalPoint>(
-          left[0] + verticalRadius,
-          left[1] + horizontalRadius,
-        ),
+      pointFrom<GlobalPoint>(
+        left[0] + verticalRadius,
+        left[1] + horizontalRadius,
       ),
       ),
-      pointFromVector(offsets[2], left),
-      pointFromVector(offsets[2], left),
-      pointFromVector(
-        offsets[2],
-        pointFrom<GlobalPoint>(
-          left[0] + verticalRadius,
-          left[1] - horizontalRadius,
-        ),
+      left,
+      left,
+      pointFrom<GlobalPoint>(
+        left[0] + verticalRadius,
+        left[1] - horizontalRadius,
       ),
       ),
     ), // LEFT
     ), // LEFT
     curve(
     curve(
-      pointFromVector(
-        offsets[3],
-        pointFrom<GlobalPoint>(
-          top[0] - verticalRadius,
-          top[1] + horizontalRadius,
-        ),
+      pointFrom<GlobalPoint>(
+        top[0] - verticalRadius,
+        top[1] + horizontalRadius,
       ),
       ),
-      pointFromVector(offsets[3], top),
-      pointFromVector(offsets[3], top),
-      pointFromVector(
-        offsets[3],
-        pointFrom<GlobalPoint>(
-          top[0] + verticalRadius,
-          top[1] + horizontalRadius,
-        ),
+      top,
+      top,
+      pointFrom<GlobalPoint>(
+        top[0] + verticalRadius,
+        top[1] + horizontalRadius,
       ),
       ),
     ), // TOP
     ), // TOP
   ];
   ];
 
 
+  const corners =
+    offset > 0
+      ? baseCorners.map(
+          (corner) =>
+            curveCatmullRomCubicApproxPoints(
+              curveOffsetPoints(corner, offset),
+            )!,
+        )
+      : [
+          [baseCorners[0]],
+          [baseCorners[1]],
+          [baseCorners[2]],
+          [baseCorners[3]],
+        ];
+
   const sides = [
   const sides = [
-    lineSegment<GlobalPoint>(corners[0][3], corners[1][0]),
-    lineSegment<GlobalPoint>(corners[1][3], corners[2][0]),
-    lineSegment<GlobalPoint>(corners[2][3], corners[3][0]),
-    lineSegment<GlobalPoint>(corners[3][3], corners[0][0]),
+    lineSegment<GlobalPoint>(
+      corners[0][corners[0].length - 1][3],
+      corners[1][0][0],
+    ),
+    lineSegment<GlobalPoint>(
+      corners[1][corners[1].length - 1][3],
+      corners[2][0][0],
+    ),
+    lineSegment<GlobalPoint>(
+      corners[2][corners[2].length - 1][3],
+      corners[3][0][0],
+    ),
+    lineSegment<GlobalPoint>(
+      corners[3][corners[3].length - 1][3],
+      corners[0][0][0],
+    ),
   ];
   ];
 
 
-  return [sides, corners];
+  const shape = [sides, corners.flat()] as ElementShape;
+
+  setElementShapesCacheEntry(element, shape, offset);
+
+  return shape;
 }
 }

+ 10 - 2
packages/element/tests/align.test.tsx

@@ -35,6 +35,7 @@ const createAndSelectTwoRectangles = () => {
   // The second rectangle is already reselected because it was the last element created
   // The second rectangle is already reselected because it was the last element created
   mouse.reset();
   mouse.reset();
   Keyboard.withModifierKeys({ shift: true }, () => {
   Keyboard.withModifierKeys({ shift: true }, () => {
+    mouse.moveTo(10, 0);
     mouse.click();
     mouse.click();
   });
   });
 };
 };
@@ -52,6 +53,7 @@ const createAndSelectTwoRectanglesWithDifferentSizes = () => {
   // The second rectangle is already reselected because it was the last element created
   // The second rectangle is already reselected because it was the last element created
   mouse.reset();
   mouse.reset();
   Keyboard.withModifierKeys({ shift: true }, () => {
   Keyboard.withModifierKeys({ shift: true }, () => {
+    mouse.moveTo(10, 0);
     mouse.click();
     mouse.click();
   });
   });
 };
 };
@@ -202,6 +204,7 @@ describe("aligning", () => {
     // The second rectangle is already reselected because it was the last element created
     // The second rectangle is already reselected because it was the last element created
     mouse.reset();
     mouse.reset();
     Keyboard.withModifierKeys({ shift: true }, () => {
     Keyboard.withModifierKeys({ shift: true }, () => {
+      mouse.moveTo(10, 0);
       mouse.click();
       mouse.click();
     });
     });
 
 
@@ -215,6 +218,7 @@ describe("aligning", () => {
     // Add the created group to the current selection
     // Add the created group to the current selection
     mouse.restorePosition(0, 0);
     mouse.restorePosition(0, 0);
     Keyboard.withModifierKeys({ shift: true }, () => {
     Keyboard.withModifierKeys({ shift: true }, () => {
+      mouse.moveTo(10, 0);
       mouse.click();
       mouse.click();
     });
     });
   };
   };
@@ -316,6 +320,7 @@ describe("aligning", () => {
     // The second rectangle is already selected because it was the last element created
     // The second rectangle is already selected because it was the last element created
     mouse.reset();
     mouse.reset();
     Keyboard.withModifierKeys({ shift: true }, () => {
     Keyboard.withModifierKeys({ shift: true }, () => {
+      mouse.moveTo(10, 0);
       mouse.click();
       mouse.click();
     });
     });
 
 
@@ -330,7 +335,7 @@ describe("aligning", () => {
     mouse.down();
     mouse.down();
     mouse.up(100, 100);
     mouse.up(100, 100);
 
 
-    mouse.restorePosition(200, 200);
+    mouse.restorePosition(210, 200);
     Keyboard.withModifierKeys({ shift: true }, () => {
     Keyboard.withModifierKeys({ shift: true }, () => {
       mouse.click();
       mouse.click();
     });
     });
@@ -341,6 +346,7 @@ describe("aligning", () => {
     // The second group is already selected because it was the last group created
     // The second group is already selected because it was the last group created
     mouse.reset();
     mouse.reset();
     Keyboard.withModifierKeys({ shift: true }, () => {
     Keyboard.withModifierKeys({ shift: true }, () => {
+      mouse.moveTo(10, 0);
       mouse.click();
       mouse.click();
     });
     });
   };
   };
@@ -454,6 +460,7 @@ describe("aligning", () => {
     // The second rectangle is already reselected because it was the last element created
     // The second rectangle is already reselected because it was the last element created
     mouse.reset();
     mouse.reset();
     Keyboard.withModifierKeys({ shift: true }, () => {
     Keyboard.withModifierKeys({ shift: true }, () => {
+      mouse.moveTo(10, 0);
       mouse.click();
       mouse.click();
     });
     });
 
 
@@ -466,7 +473,7 @@ describe("aligning", () => {
     mouse.up(100, 100);
     mouse.up(100, 100);
 
 
     // Add group to current selection
     // Add group to current selection
-    mouse.restorePosition(0, 0);
+    mouse.restorePosition(10, 0);
     Keyboard.withModifierKeys({ shift: true }, () => {
     Keyboard.withModifierKeys({ shift: true }, () => {
       mouse.click();
       mouse.click();
     });
     });
@@ -482,6 +489,7 @@ describe("aligning", () => {
     // Select the nested group, the rectangle is already selected
     // Select the nested group, the rectangle is already selected
     mouse.reset();
     mouse.reset();
     Keyboard.withModifierKeys({ shift: true }, () => {
     Keyboard.withModifierKeys({ shift: true }, () => {
+      mouse.moveTo(10, 0);
       mouse.click();
       mouse.click();
     });
     });
   };
   };

+ 2 - 2
packages/element/tests/binding.test.tsx

@@ -172,12 +172,12 @@ describe("element binding", () => {
     const arrow = UI.createElement("arrow", {
     const arrow = UI.createElement("arrow", {
       x: 0,
       x: 0,
       y: 0,
       y: 0,
-      size: 50,
+      size: 49,
     });
     });
 
 
     expect(arrow.endBinding).toBe(null);
     expect(arrow.endBinding).toBe(null);
 
 
-    mouse.downAt(50, 50);
+    mouse.downAt(49, 49);
     mouse.moveTo(51, 0);
     mouse.moveTo(51, 0);
     mouse.up(0, 0);
     mouse.up(0, 0);
 
 

+ 38 - 0
packages/element/tests/collision.test.tsx

@@ -0,0 +1,38 @@
+import { type GlobalPoint, type LocalPoint, pointFrom } from "@excalidraw/math";
+import { Excalidraw } from "@excalidraw/excalidraw";
+import { UI } from "@excalidraw/excalidraw/tests/helpers/ui";
+import "@excalidraw/utils/test-utils";
+import { render } from "@excalidraw/excalidraw/tests/test-utils";
+
+import { hitElementItself } from "../src/collision";
+
+describe("check rotated elements can be hit:", () => {
+  beforeEach(async () => {
+    localStorage.clear();
+    await render(<Excalidraw handleKeyboardGlobally={true} />);
+  });
+
+  it("arrow", () => {
+    UI.createElement("arrow", {
+      x: 0,
+      y: 0,
+      width: 124,
+      height: 302,
+      angle: 1.8700426423973724,
+      points: [
+        [0, 0],
+        [120, -198],
+        [-4, -302],
+      ] as LocalPoint[],
+    });
+    //const p = [120, -211];
+    //const p = [0, 13];
+    const hit = hitElementItself({
+      point: pointFrom<GlobalPoint>(87, -68),
+      element: window.h.elements[0],
+      threshold: 10,
+      elementsMap: window.h.scene.getNonDeletedElementsMap(),
+    });
+    expect(hit).toBe(true);
+  });
+});

+ 1 - 1
packages/element/tests/linearElementEditor.test.tsx

@@ -1262,7 +1262,7 @@ describe("Test Linear Elements", () => {
       mouse.downAt(rect.x, rect.y);
       mouse.downAt(rect.x, rect.y);
       mouse.moveTo(200, 0);
       mouse.moveTo(200, 0);
       mouse.upAt(200, 0);
       mouse.upAt(200, 0);
-      expect(arrow.width).toBeCloseTo(204, 0);
+      expect(arrow.width).toBeCloseTo(200, 0);
       expect(rect.x).toBe(200);
       expect(rect.x).toBe(200);
       expect(rect.y).toBe(0);
       expect(rect.y).toBe(0);
       expect(handleBindTextResizeSpy).toHaveBeenCalledWith(
       expect(handleBindTextResizeSpy).toHaveBeenCalledWith(

+ 6 - 6
packages/element/tests/resize.test.tsx

@@ -510,12 +510,12 @@ describe("arrow element", () => {
       h.state,
       h.state,
     )[0] as ExcalidrawElbowArrowElement;
     )[0] as ExcalidrawElbowArrowElement;
 
 
-    expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1);
+    expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.05);
     expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75);
     expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75);
 
 
     UI.resize(rectangle, "se", [-200, -150]);
     UI.resize(rectangle, "se", [-200, -150]);
 
 
-    expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1);
+    expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.05);
     expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75);
     expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75);
   });
   });
 
 
@@ -538,11 +538,11 @@ describe("arrow element", () => {
       h.state,
       h.state,
     )[0] as ExcalidrawElbowArrowElement;
     )[0] as ExcalidrawElbowArrowElement;
 
 
-    expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1);
+    expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.05);
     expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75);
     expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75);
 
 
     UI.resize([rectangle, arrow], "nw", [300, 350]);
     UI.resize([rectangle, arrow], "nw", [300, 350]);
-    expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(0);
+    expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(-0.05);
     expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.25);
     expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.25);
   });
   });
 });
 });
@@ -819,7 +819,7 @@ describe("image element", () => {
 
 
     UI.resize(image, "ne", [40, 0]);
     UI.resize(image, "ne", [40, 0]);
 
 
-    expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(31, 0);
+    expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(30, 0);
 
 
     const imageWidth = image.width;
     const imageWidth = image.width;
     const scale = 20 / image.height;
     const scale = 20 / image.height;
@@ -1033,7 +1033,7 @@ describe("multiple selection", () => {
 
 
     expect(leftBoundArrow.x).toBeCloseTo(-110);
     expect(leftBoundArrow.x).toBeCloseTo(-110);
     expect(leftBoundArrow.y).toBeCloseTo(50);
     expect(leftBoundArrow.y).toBeCloseTo(50);
-    expect(leftBoundArrow.width).toBeCloseTo(143, 0);
+    expect(leftBoundArrow.width).toBeCloseTo(140, 0);
     expect(leftBoundArrow.height).toBeCloseTo(7, 0);
     expect(leftBoundArrow.height).toBeCloseTo(7, 0);
     expect(leftBoundArrow.angle).toEqual(0);
     expect(leftBoundArrow.angle).toEqual(0);
     expect(leftBoundArrow.startBinding).toBeNull();
     expect(leftBoundArrow.startBinding).toBeNull();

+ 0 - 11
packages/element/tests/sizeHelpers.test.ts

@@ -1,7 +1,5 @@
 import { vi } from "vitest";
 import { vi } from "vitest";
 
 
-import * as constants from "@excalidraw/common";
-
 import { getPerfectElementSize } from "../src/sizeHelpers";
 import { getPerfectElementSize } from "../src/sizeHelpers";
 
 
 const EPSILON_DIGITS = 3;
 const EPSILON_DIGITS = 3;
@@ -57,13 +55,4 @@ describe("getPerfectElementSize", () => {
     expect(width).toBeCloseTo(0, EPSILON_DIGITS);
     expect(width).toBeCloseTo(0, EPSILON_DIGITS);
     expect(height).toBeCloseTo(0, EPSILON_DIGITS);
     expect(height).toBeCloseTo(0, EPSILON_DIGITS);
   });
   });
-
-  describe("should respond to SHIFT_LOCKING_ANGLE constant", () => {
-    it("should have only 2 locking angles per section if SHIFT_LOCKING_ANGLE = 45 deg (Math.PI/4)", () => {
-      (constants as any).SHIFT_LOCKING_ANGLE = Math.PI / 4;
-      const { height, width } = getPerfectElementSize("arrow", 120, 185);
-      expect(width).toBeCloseTo(120, EPSILON_DIGITS);
-      expect(height).toBeCloseTo(120, EPSILON_DIGITS);
-    });
-  });
 });
 });

+ 13 - 61
packages/excalidraw/actions/actionProperties.tsx

@@ -18,7 +18,6 @@ import {
   arrayToMap,
   arrayToMap,
   getFontFamilyString,
   getFontFamilyString,
   getShortcutKey,
   getShortcutKey,
-  tupleToCoors,
   getLineHeight,
   getLineHeight,
   isTransparent,
   isTransparent,
   reduceToCommonValue,
   reduceToCommonValue,
@@ -28,9 +27,7 @@ import { canBecomePolygon, getNonDeletedElements } from "@excalidraw/element";
 
 
 import {
 import {
   bindLinearElement,
   bindLinearElement,
-  bindPointToSnapToElementOutline,
   calculateFixedPointForElbowArrowBinding,
   calculateFixedPointForElbowArrowBinding,
-  getHoveredElementForBinding,
   updateBoundElements,
   updateBoundElements,
 } from "@excalidraw/element";
 } from "@excalidraw/element";
 
 
@@ -1661,63 +1658,16 @@ export const actionChangeArrowType = register({
             -1,
             -1,
             elementsMap,
             elementsMap,
           );
           );
-        const startHoveredElement =
-          !newElement.startBinding &&
-          getHoveredElementForBinding(
-            tupleToCoors(startGlobalPoint),
-            elements,
-            elementsMap,
-            appState.zoom,
-            false,
-            true,
-          );
-        const endHoveredElement =
-          !newElement.endBinding &&
-          getHoveredElementForBinding(
-            tupleToCoors(endGlobalPoint),
-            elements,
-            elementsMap,
-            appState.zoom,
-            false,
-            true,
-          );
-        const startElement = startHoveredElement
-          ? startHoveredElement
-          : newElement.startBinding &&
-            (elementsMap.get(
-              newElement.startBinding.elementId,
-            ) as ExcalidrawBindableElement);
-        const endElement = endHoveredElement
-          ? endHoveredElement
-          : newElement.endBinding &&
-            (elementsMap.get(
-              newElement.endBinding.elementId,
-            ) as ExcalidrawBindableElement);
-
-        const finalStartPoint = startHoveredElement
-          ? bindPointToSnapToElementOutline(
-              newElement,
-              startHoveredElement,
-              "start",
-            )
-          : startGlobalPoint;
-        const finalEndPoint = endHoveredElement
-          ? bindPointToSnapToElementOutline(
-              newElement,
-              endHoveredElement,
-              "end",
-            )
-          : endGlobalPoint;
-
-        startHoveredElement &&
-          bindLinearElement(
-            newElement,
-            startHoveredElement,
-            "start",
-            app.scene,
-          );
-        endHoveredElement &&
-          bindLinearElement(newElement, endHoveredElement, "end", app.scene);
+        const startElement =
+          newElement.startBinding &&
+          (elementsMap.get(
+            newElement.startBinding.elementId,
+          ) as ExcalidrawBindableElement);
+        const endElement =
+          newElement.endBinding &&
+          (elementsMap.get(
+            newElement.endBinding.elementId,
+          ) as ExcalidrawBindableElement);
 
 
         const startBinding =
         const startBinding =
           startElement && newElement.startBinding
           startElement && newElement.startBinding
@@ -1728,6 +1678,7 @@ export const actionChangeArrowType = register({
                   newElement,
                   newElement,
                   startElement,
                   startElement,
                   "start",
                   "start",
+                  elementsMap,
                 ),
                 ),
               }
               }
             : null;
             : null;
@@ -1740,6 +1691,7 @@ export const actionChangeArrowType = register({
                   newElement,
                   newElement,
                   endElement,
                   endElement,
                   "end",
                   "end",
+                  elementsMap,
                 ),
                 ),
               }
               }
             : null;
             : null;
@@ -1749,7 +1701,7 @@ export const actionChangeArrowType = register({
           startBinding,
           startBinding,
           endBinding,
           endBinding,
           ...updateElbowArrowPoints(newElement, elementsMap, {
           ...updateElbowArrowPoints(newElement, elementsMap, {
-            points: [finalStartPoint, finalEndPoint].map(
+            points: [startGlobalPoint, endGlobalPoint].map(
               (p): LocalPoint =>
               (p): LocalPoint =>
                 pointFrom(p[0] - newElement.x, p[1] - newElement.y),
                 pointFrom(p[0] - newElement.x, p[1] - newElement.y),
             ),
             ),

+ 2 - 1
packages/excalidraw/appState.ts

@@ -10,6 +10,7 @@ import {
   STATS_PANELS,
   STATS_PANELS,
   THEME,
   THEME,
   DEFAULT_GRID_STEP,
   DEFAULT_GRID_STEP,
+  isTestEnv,
 } from "@excalidraw/common";
 } from "@excalidraw/common";
 
 
 import type { AppState, NormalizedZoomValue } from "./types";
 import type { AppState, NormalizedZoomValue } from "./types";
@@ -36,7 +37,7 @@ export const getDefaultAppState = (): Omit<
     currentItemRoughness: DEFAULT_ELEMENT_PROPS.roughness,
     currentItemRoughness: DEFAULT_ELEMENT_PROPS.roughness,
     currentItemStartArrowhead: null,
     currentItemStartArrowhead: null,
     currentItemStrokeColor: DEFAULT_ELEMENT_PROPS.strokeColor,
     currentItemStrokeColor: DEFAULT_ELEMENT_PROPS.strokeColor,
-    currentItemRoundness: "round",
+    currentItemRoundness: isTestEnv() ? "sharp" : "round",
     currentItemArrowType: ARROW_TYPE.round,
     currentItemArrowType: ARROW_TYPE.round,
     currentItemStrokeStyle: DEFAULT_ELEMENT_PROPS.strokeStyle,
     currentItemStrokeStyle: DEFAULT_ELEMENT_PROPS.strokeStyle,
     currentItemStrokeWidth: DEFAULT_ELEMENT_PROPS.strokeWidth,
     currentItemStrokeWidth: DEFAULT_ELEMENT_PROPS.strokeWidth,

+ 69 - 135
packages/excalidraw/components/App.tsx

@@ -17,8 +17,6 @@ import {
   vectorDot,
   vectorDot,
   vectorNormalize,
   vectorNormalize,
 } from "@excalidraw/math";
 } from "@excalidraw/math";
-import { isPointInShape } from "@excalidraw/utils/collision";
-import { getSelectionBoxShape } from "@excalidraw/utils/shape";
 
 
 import {
 import {
   COLOR_PALETTE,
   COLOR_PALETTE,
@@ -104,9 +102,9 @@ import {
   Emitter,
   Emitter,
 } from "@excalidraw/common";
 } from "@excalidraw/common";
 
 
-import { getCommonBounds, getElementAbsoluteCoords } from "@excalidraw/element";
-
 import {
 import {
+  getCommonBounds,
+  getElementAbsoluteCoords,
   bindOrUnbindLinearElements,
   bindOrUnbindLinearElements,
   fixBindingsAfterDeletion,
   fixBindingsAfterDeletion,
   getHoveredElementForBinding,
   getHoveredElementForBinding,
@@ -115,13 +113,8 @@ import {
   shouldEnableBindingForPointerEvent,
   shouldEnableBindingForPointerEvent,
   updateBoundElements,
   updateBoundElements,
   getSuggestedBindingsForArrows,
   getSuggestedBindingsForArrows,
-} from "@excalidraw/element";
-
-import { LinearElementEditor } from "@excalidraw/element";
-
-import { newElementWith } from "@excalidraw/element";
-
-import {
+  LinearElementEditor,
+  newElementWith,
   newFrameElement,
   newFrameElement,
   newFreeDrawElement,
   newFreeDrawElement,
   newEmbeddableElement,
   newEmbeddableElement,
@@ -133,11 +126,8 @@ import {
   newLinearElement,
   newLinearElement,
   newTextElement,
   newTextElement,
   refreshTextDimensions,
   refreshTextDimensions,
-} from "@excalidraw/element";
-
-import { deepCopyElement, duplicateElements } from "@excalidraw/element";
-
-import {
+  deepCopyElement,
+  duplicateElements,
   hasBoundTextElement,
   hasBoundTextElement,
   isArrowElement,
   isArrowElement,
   isBindingElement,
   isBindingElement,
@@ -158,48 +148,27 @@ import {
   isFlowchartNodeElement,
   isFlowchartNodeElement,
   isBindableElement,
   isBindableElement,
   isTextElement,
   isTextElement,
-} from "@excalidraw/element";
-
-import {
   getLockedLinearCursorAlignSize,
   getLockedLinearCursorAlignSize,
   getNormalizedDimensions,
   getNormalizedDimensions,
   isElementCompletelyInViewport,
   isElementCompletelyInViewport,
   isElementInViewport,
   isElementInViewport,
   isInvisiblySmallElement,
   isInvisiblySmallElement,
-} from "@excalidraw/element";
-
-import {
-  getBoundTextShape,
   getCornerRadius,
   getCornerRadius,
-  getElementShape,
   isPathALoop,
   isPathALoop,
-} from "@excalidraw/element";
-
-import {
   createSrcDoc,
   createSrcDoc,
   embeddableURLValidator,
   embeddableURLValidator,
   maybeParseEmbedSrc,
   maybeParseEmbedSrc,
   getEmbedLink,
   getEmbedLink,
-} from "@excalidraw/element";
-
-import {
   getInitializedImageElements,
   getInitializedImageElements,
   loadHTMLImageElement,
   loadHTMLImageElement,
   normalizeSVG,
   normalizeSVG,
   updateImageCache as _updateImageCache,
   updateImageCache as _updateImageCache,
-} from "@excalidraw/element";
-
-import {
   getBoundTextElement,
   getBoundTextElement,
   getContainerCenter,
   getContainerCenter,
   getContainerElement,
   getContainerElement,
   isValidTextContainer,
   isValidTextContainer,
   redrawTextBoundingBox,
   redrawTextBoundingBox,
-} from "@excalidraw/element";
-
-import { shouldShowBoundingBox } from "@excalidraw/element";
-
-import {
+  shouldShowBoundingBox,
   getFrameChildren,
   getFrameChildren,
   isCursorInFrame,
   isCursorInFrame,
   addElementsToFrame,
   addElementsToFrame,
@@ -214,29 +183,17 @@ import {
   getFrameLikeTitle,
   getFrameLikeTitle,
   getElementsOverlappingFrame,
   getElementsOverlappingFrame,
   filterElementsEligibleAsFrameChildren,
   filterElementsEligibleAsFrameChildren,
-} from "@excalidraw/element";
-
-import {
   hitElementBoundText,
   hitElementBoundText,
   hitElementBoundingBoxOnly,
   hitElementBoundingBoxOnly,
   hitElementItself,
   hitElementItself,
-} from "@excalidraw/element";
-
-import { getVisibleSceneBounds } from "@excalidraw/element";
-
-import {
+  getVisibleSceneBounds,
   FlowChartCreator,
   FlowChartCreator,
   FlowChartNavigator,
   FlowChartNavigator,
   getLinkDirectionFromKey,
   getLinkDirectionFromKey,
-} from "@excalidraw/element";
-
-import { cropElement } from "@excalidraw/element";
-
-import { wrapText } from "@excalidraw/element";
-
-import { isElementLink, parseElementLinkFromURL } from "@excalidraw/element";
-
-import {
+  cropElement,
+  wrapText,
+  isElementLink,
+  parseElementLinkFromURL,
   isMeasureTextSupported,
   isMeasureTextSupported,
   normalizeText,
   normalizeText,
   measureText,
   measureText,
@@ -244,13 +201,8 @@ import {
   getApproxMinLineWidth,
   getApproxMinLineWidth,
   getApproxMinLineHeight,
   getApproxMinLineHeight,
   getMinTextElementWidth,
   getMinTextElementWidth,
-} from "@excalidraw/element";
-
-import { ShapeCache } from "@excalidraw/element";
-
-import { getRenderOpacity } from "@excalidraw/element";
-
-import {
+  ShapeCache,
+  getRenderOpacity,
   editGroupForSelectedElement,
   editGroupForSelectedElement,
   getElementsInGroup,
   getElementsInGroup,
   getSelectedGroupIdForElement,
   getSelectedGroupIdForElement,
@@ -258,42 +210,28 @@ import {
   isElementInGroup,
   isElementInGroup,
   isSelectedViaGroup,
   isSelectedViaGroup,
   selectGroupsForSelectedElements,
   selectGroupsForSelectedElements,
-} from "@excalidraw/element";
-
-import { syncInvalidIndices, syncMovedIndices } from "@excalidraw/element";
-
-import {
+  syncInvalidIndices,
+  syncMovedIndices,
   excludeElementsInFramesFromSelection,
   excludeElementsInFramesFromSelection,
   getSelectionStateForElements,
   getSelectionStateForElements,
   makeNextSelectedElementIds,
   makeNextSelectedElementIds,
-} from "@excalidraw/element";
-
-import {
   getResizeOffsetXY,
   getResizeOffsetXY,
   getResizeArrowDirection,
   getResizeArrowDirection,
   transformElements,
   transformElements,
-} from "@excalidraw/element";
-
-import {
   getCursorForResizingElement,
   getCursorForResizingElement,
   getElementWithTransformHandleType,
   getElementWithTransformHandleType,
   getTransformHandleTypeFromCoords,
   getTransformHandleTypeFromCoords,
-} from "@excalidraw/element";
-
-import {
   dragNewElement,
   dragNewElement,
   dragSelectedElements,
   dragSelectedElements,
   getDragOffsetXY,
   getDragOffsetXY,
+  isNonDeletedElement,
+  Scene,
+  Store,
+  CaptureUpdateAction,
+  type ElementUpdate,
+  hitElementBoundingBox,
 } from "@excalidraw/element";
 } from "@excalidraw/element";
 
 
-import { isNonDeletedElement } from "@excalidraw/element";
-
-import { Scene } from "@excalidraw/element";
-
-import { Store, CaptureUpdateAction } from "@excalidraw/element";
-
-import type { ElementUpdate } from "@excalidraw/element";
-
 import type { LocalPoint, Radians } from "@excalidraw/math";
 import type { LocalPoint, Radians } from "@excalidraw/math";
 
 
 import type {
 import type {
@@ -5095,6 +5033,7 @@ class App extends React.Component<AppProps, AppState> {
     return null;
     return null;
   }
   }
 
 
+  // NOTE: Hot path for hit testing, so avoid unnecessary computations
   private getElementAtPosition(
   private getElementAtPosition(
     x: number,
     x: number,
     y: number,
     y: number,
@@ -5134,16 +5073,12 @@ class App extends React.Component<AppProps, AppState> {
       // If we're hitting element with highest z-index only on its bounding box
       // If we're hitting element with highest z-index only on its bounding box
       // while also hitting other element figure, the latter should be considered.
       // while also hitting other element figure, the latter should be considered.
       return hitElementItself({
       return hitElementItself({
-        x,
-        y,
+        point: pointFrom(x, y),
         element: elementWithHighestZIndex,
         element: elementWithHighestZIndex,
-        shape: getElementShape(
-          elementWithHighestZIndex,
-          this.scene.getNonDeletedElementsMap(),
-        ),
         // when overlapping, we would like to be more precise
         // when overlapping, we would like to be more precise
         // this also avoids the need to update past tests
         // this also avoids the need to update past tests
-        threshold: this.getElementHitThreshold() / 2,
+        threshold: this.getElementHitThreshold(elementWithHighestZIndex) / 2,
+        elementsMap: this.scene.getNonDeletedElementsMap(),
         frameNameBound: isFrameLikeElement(elementWithHighestZIndex)
         frameNameBound: isFrameLikeElement(elementWithHighestZIndex)
           ? this.frameNameBoundsCache.get(elementWithHighestZIndex)
           ? this.frameNameBoundsCache.get(elementWithHighestZIndex)
           : null,
           : null,
@@ -5158,6 +5093,7 @@ class App extends React.Component<AppProps, AppState> {
     return null;
     return null;
   }
   }
 
 
+  // NOTE: Hot path for hit testing, so avoid unnecessary computations
   private getElementsAtPosition(
   private getElementsAtPosition(
     x: number,
     x: number,
     y: number,
     y: number,
@@ -5208,8 +5144,14 @@ class App extends React.Component<AppProps, AppState> {
     return elements;
     return elements;
   }
   }
 
 
-  getElementHitThreshold() {
-    return DEFAULT_COLLISION_THRESHOLD / this.state.zoom.value;
+  getElementHitThreshold(element: ExcalidrawElement) {
+    return Math.max(
+      element.strokeWidth / 2 + 0.1,
+      // NOTE: Here be dragons. Do not go under the 0.63 multiplier unless you're
+      // willing to test extensively. The hit testing starts to become unreliable
+      // due to FP imprecision under 0.63 in high zoom levels.
+      0.85 * (DEFAULT_COLLISION_THRESHOLD / this.state.zoom.value),
+    );
   }
   }
 
 
   private hitElement(
   private hitElement(
@@ -5224,35 +5166,35 @@ class App extends React.Component<AppProps, AppState> {
       this.state.selectedElementIds[element.id] &&
       this.state.selectedElementIds[element.id] &&
       shouldShowBoundingBox([element], this.state)
       shouldShowBoundingBox([element], this.state)
     ) {
     ) {
-      const selectionShape = getSelectionBoxShape(
-        element,
-        this.scene.getNonDeletedElementsMap(),
-        isImageElement(element) ? 0 : this.getElementHitThreshold(),
-      );
-
       // if hitting the bounding box, return early
       // if hitting the bounding box, return early
       // but if not, we should check for other cases as well (e.g. frame name)
       // but if not, we should check for other cases as well (e.g. frame name)
-      if (isPointInShape(pointFrom(x, y), selectionShape)) {
+      if (
+        hitElementBoundingBox(
+          pointFrom(x, y),
+          element,
+          this.scene.getNonDeletedElementsMap(),
+          this.getElementHitThreshold(element),
+        )
+      ) {
         return true;
         return true;
       }
       }
     }
     }
 
 
     // take bound text element into consideration for hit collision as well
     // take bound text element into consideration for hit collision as well
     const hitBoundTextOfElement = hitElementBoundText(
     const hitBoundTextOfElement = hitElementBoundText(
-      x,
-      y,
-      getBoundTextShape(element, this.scene.getNonDeletedElementsMap()),
+      pointFrom(x, y),
+      element,
+      this.scene.getNonDeletedElementsMap(),
     );
     );
     if (hitBoundTextOfElement) {
     if (hitBoundTextOfElement) {
       return true;
       return true;
     }
     }
 
 
     return hitElementItself({
     return hitElementItself({
-      x,
-      y,
+      point: pointFrom(x, y),
       element,
       element,
-      shape: getElementShape(element, this.scene.getNonDeletedElementsMap()),
-      threshold: this.getElementHitThreshold(),
+      threshold: this.getElementHitThreshold(element),
+      elementsMap: this.scene.getNonDeletedElementsMap(),
       frameNameBound: isFrameLikeElement(element)
       frameNameBound: isFrameLikeElement(element)
         ? this.frameNameBoundsCache.get(element)
         ? this.frameNameBoundsCache.get(element)
         : null,
         : null,
@@ -5280,14 +5222,10 @@ class App extends React.Component<AppProps, AppState> {
       if (
       if (
         isArrowElement(elements[index]) &&
         isArrowElement(elements[index]) &&
         hitElementItself({
         hitElementItself({
-          x,
-          y,
+          point: pointFrom(x, y),
           element: elements[index],
           element: elements[index],
-          shape: getElementShape(
-            elements[index],
-            this.scene.getNonDeletedElementsMap(),
-          ),
-          threshold: this.getElementHitThreshold(),
+          elementsMap: this.scene.getNonDeletedElementsMap(),
+          threshold: this.getElementHitThreshold(elements[index]),
         })
         })
       ) {
       ) {
         hitElement = elements[index];
         hitElement = elements[index];
@@ -5632,14 +5570,10 @@ class App extends React.Component<AppProps, AppState> {
           hasBoundTextElement(container) ||
           hasBoundTextElement(container) ||
           !isTransparent(container.backgroundColor) ||
           !isTransparent(container.backgroundColor) ||
           hitElementItself({
           hitElementItself({
-            x: sceneX,
-            y: sceneY,
+            point: pointFrom(sceneX, sceneY),
             element: container,
             element: container,
-            shape: getElementShape(
-              container,
-              this.scene.getNonDeletedElementsMap(),
-            ),
-            threshold: this.getElementHitThreshold(),
+            elementsMap: this.scene.getNonDeletedElementsMap(),
+            threshold: this.getElementHitThreshold(container),
           })
           })
         ) {
         ) {
           const midPoint = getContainerCenter(
           const midPoint = getContainerCenter(
@@ -6329,13 +6263,10 @@ class App extends React.Component<AppProps, AppState> {
       let segmentMidPointHoveredCoords = null;
       let segmentMidPointHoveredCoords = null;
       if (
       if (
         hitElementItself({
         hitElementItself({
-          x: scenePointerX,
-          y: scenePointerY,
+          point: pointFrom(scenePointerX, scenePointerY),
           element,
           element,
-          shape: getElementShape(
-            element,
-            this.scene.getNonDeletedElementsMap(),
-          ),
+          elementsMap,
+          threshold: this.getElementHitThreshold(element),
         })
         })
       ) {
       ) {
         hoverPointIndex = LinearElementEditor.getPointIndexUnderCursor(
         hoverPointIndex = LinearElementEditor.getPointIndexUnderCursor(
@@ -7505,7 +7436,10 @@ class App extends React.Component<AppProps, AppState> {
     }
     }
 
 
     // How many pixels off the shape boundary we still consider a hit
     // How many pixels off the shape boundary we still consider a hit
-    const threshold = this.getElementHitThreshold();
+    const threshold = Math.max(
+      DEFAULT_COLLISION_THRESHOLD / this.state.zoom.value,
+      1,
+    );
     const [x1, y1, x2, y2] = getCommonBounds(selectedElements);
     const [x1, y1, x2, y2] = getCommonBounds(selectedElements);
     return (
     return (
       point.x > x1 - threshold &&
       point.x > x1 - threshold &&
@@ -9768,14 +9702,13 @@ class App extends React.Component<AppProps, AppState> {
         ((hitElement &&
         ((hitElement &&
           hitElementBoundingBoxOnly(
           hitElementBoundingBoxOnly(
             {
             {
-              x: pointerDownState.origin.x,
-              y: pointerDownState.origin.y,
-              element: hitElement,
-              shape: getElementShape(
-                hitElement,
-                this.scene.getNonDeletedElementsMap(),
+              point: pointFrom(
+                pointerDownState.origin.x,
+                pointerDownState.origin.y,
               ),
               ),
-              threshold: this.getElementHitThreshold(),
+              element: hitElement,
+              elementsMap,
+              threshold: this.getElementHitThreshold(hitElement),
               frameNameBound: isFrameLikeElement(hitElement)
               frameNameBound: isFrameLikeElement(hitElement)
                 ? this.frameNameBoundsCache.get(hitElement)
                 ? this.frameNameBoundsCache.get(hitElement)
                 : null,
                 : null,
@@ -10882,6 +10815,7 @@ class App extends React.Component<AppProps, AppState> {
           croppingElement,
           croppingElement,
           cropElement(
           cropElement(
             croppingElement,
             croppingElement,
+            this.scene.getNonDeletedElementsMap(),
             transformHandleType,
             transformHandleType,
             image.naturalWidth,
             image.naturalWidth,
             image.naturalHeight,
             image.naturalHeight,

+ 1 - 1
packages/excalidraw/components/Stats/stats.test.tsx

@@ -133,7 +133,6 @@ describe("binding with linear elements", () => {
     const inputX = UI.queryStatsProperty("X")?.querySelector(
     const inputX = UI.queryStatsProperty("X")?.querySelector(
       ".drag-input",
       ".drag-input",
     ) as HTMLInputElement;
     ) as HTMLInputElement;
-
     expect(linear.startBinding).not.toBe(null);
     expect(linear.startBinding).not.toBe(null);
     expect(inputX).not.toBeNull();
     expect(inputX).not.toBeNull();
     UI.updateInput(inputX, String("204"));
     UI.updateInput(inputX, String("204"));
@@ -657,6 +656,7 @@ describe("stats for multiple elements", () => {
 
 
       mouse.reset();
       mouse.reset();
       Keyboard.withModifierKeys({ shift: true }, () => {
       Keyboard.withModifierKeys({ shift: true }, () => {
+        mouse.moveTo(10, 0);
         mouse.click();
         mouse.click();
       });
       });
 
 

+ 1 - 1
packages/excalidraw/components/hyperlink/Hyperlink.tsx

@@ -463,7 +463,7 @@ const shouldHideLinkPopup = (
 
 
   const threshold = 15 / appState.zoom.value;
   const threshold = 15 / appState.zoom.value;
   // hitbox to prevent hiding when hovered in element bounding box
   // hitbox to prevent hiding when hovered in element bounding box
-  if (hitElementBoundingBox(sceneX, sceneY, element, elementsMap)) {
+  if (hitElementBoundingBox(pointFrom(sceneX, sceneY), element, elementsMap)) {
     return false;
     return false;
   }
   }
   const [x1, y1, x2] = getElementAbsoluteCoords(element, elementsMap);
   const [x1, y1, x2] = getElementAbsoluteCoords(element, elementsMap);

+ 1 - 1
packages/excalidraw/components/hyperlink/helpers.ts

@@ -92,7 +92,7 @@ export const isPointHittingLink = (
   if (
   if (
     !isMobile &&
     !isMobile &&
     appState.viewModeEnabled &&
     appState.viewModeEnabled &&
-    hitElementBoundingBox(x, y, element, elementsMap)
+    hitElementBoundingBox(pointFrom(x, y), element, elementsMap)
   ) {
   ) {
     return true;
     return true;
   }
   }

+ 3 - 3
packages/excalidraw/data/__snapshots__/transform.test.ts.snap

@@ -175,7 +175,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
   "startBinding": {
   "startBinding": {
     "elementId": "diamond-1",
     "elementId": "diamond-1",
     "focus": 0,
     "focus": 0,
-    "gap": 4.545343408287929,
+    "gap": 4.535423522449215,
   },
   },
   "strokeColor": "#e67700",
   "strokeColor": "#e67700",
   "strokeStyle": "solid",
   "strokeStyle": "solid",
@@ -335,7 +335,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
   "endBinding": {
   "endBinding": {
     "elementId": "text-2",
     "elementId": "text-2",
     "focus": 0,
     "focus": 0,
-    "gap": 14,
+    "gap": 16,
   },
   },
   "fillStyle": "solid",
   "fillStyle": "solid",
   "frameId": null,
   "frameId": null,
@@ -1540,7 +1540,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
   "endBinding": {
   "endBinding": {
     "elementId": "B",
     "elementId": "B",
     "focus": 0,
     "focus": 0,
-    "gap": 14,
+    "gap": 32,
   },
   },
   "fillStyle": "solid",
   "fillStyle": "solid",
   "frameId": null,
   "frameId": null,

+ 1 - 1
packages/excalidraw/data/transform.test.ts

@@ -781,7 +781,7 @@ describe("Test Transform", () => {
       expect((arrow as ExcalidrawArrowElement).endBinding).toStrictEqual({
       expect((arrow as ExcalidrawArrowElement).endBinding).toStrictEqual({
         elementId: "rect-1",
         elementId: "rect-1",
         focus: -0,
         focus: -0,
-        gap: 14,
+        gap: 25,
       });
       });
       expect(rect.boundElements).toStrictEqual([
       expect(rect.boundElements).toStrictEqual([
         {
         {

+ 40 - 71
packages/excalidraw/eraser/index.ts

@@ -1,25 +1,19 @@
 import { arrayToMap, easeOut, THEME } from "@excalidraw/common";
 import { arrayToMap, easeOut, THEME } from "@excalidraw/common";
-import { getElementLineSegments } from "@excalidraw/element";
 import {
 import {
-  lineSegment,
-  lineSegmentIntersectionPoints,
-  pointFrom,
-} from "@excalidraw/math";
+  computeBoundTextPosition,
+  getBoundTextElement,
+  intersectElementWithLineSegment,
+  isPointInElement,
+} from "@excalidraw/element";
+import { lineSegment, pointFrom } from "@excalidraw/math";
 
 
 import { getElementsInGroup } from "@excalidraw/element";
 import { getElementsInGroup } from "@excalidraw/element";
 
 
-import { getElementShape } from "@excalidraw/element";
 import { shouldTestInside } from "@excalidraw/element";
 import { shouldTestInside } from "@excalidraw/element";
-import { isPointInShape } from "@excalidraw/utils/collision";
 import { hasBoundTextElement, isBoundToContainer } from "@excalidraw/element";
 import { hasBoundTextElement, isBoundToContainer } from "@excalidraw/element";
 import { getBoundTextElementId } from "@excalidraw/element";
 import { getBoundTextElementId } from "@excalidraw/element";
 
 
-import type { GeometricShape } from "@excalidraw/utils/shape";
-import type {
-  ElementsSegmentsMap,
-  GlobalPoint,
-  LineSegment,
-} from "@excalidraw/math/types";
+import type { GlobalPoint, LineSegment } from "@excalidraw/math/types";
 import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types";
 import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types";
 
 
 import { AnimatedTrail } from "../animated-trail";
 import { AnimatedTrail } from "../animated-trail";
@@ -28,15 +22,9 @@ import type { AnimationFrameHandler } from "../animation-frame-handler";
 
 
 import type App from "../components/App";
 import type App from "../components/App";
 
 
-// just enough to form a segment; this is sufficient for eraser
-const POINTS_ON_TRAIL = 2;
-
 export class EraserTrail extends AnimatedTrail {
 export class EraserTrail extends AnimatedTrail {
   private elementsToErase: Set<ExcalidrawElement["id"]> = new Set();
   private elementsToErase: Set<ExcalidrawElement["id"]> = new Set();
   private groupsToErase: Set<ExcalidrawElement["id"]> = new Set();
   private groupsToErase: Set<ExcalidrawElement["id"]> = new Set();
-  private segmentsCache: Map<string, LineSegment<GlobalPoint>[]> = new Map();
-  private geometricShapesCache: Map<string, GeometricShape<GlobalPoint>> =
-    new Map();
 
 
   constructor(animationFrameHandler: AnimationFrameHandler, app: App) {
   constructor(animationFrameHandler: AnimationFrameHandler, app: App) {
     super(animationFrameHandler, app, {
     super(animationFrameHandler, app, {
@@ -79,14 +67,21 @@ export class EraserTrail extends AnimatedTrail {
   }
   }
 
 
   private updateElementsToBeErased(restoreToErase?: boolean) {
   private updateElementsToBeErased(restoreToErase?: boolean) {
-    let eraserPath: GlobalPoint[] =
+    const eraserPath: GlobalPoint[] =
       super
       super
         .getCurrentTrail()
         .getCurrentTrail()
         ?.originalPoints?.map((p) => pointFrom<GlobalPoint>(p[0], p[1])) || [];
         ?.originalPoints?.map((p) => pointFrom<GlobalPoint>(p[0], p[1])) || [];
 
 
+    if (eraserPath.length < 2) {
+      return [];
+    }
+
     // for efficiency and avoid unnecessary calculations,
     // for efficiency and avoid unnecessary calculations,
     // take only POINTS_ON_TRAIL points to form some number of segments
     // take only POINTS_ON_TRAIL points to form some number of segments
-    eraserPath = eraserPath?.slice(eraserPath.length - POINTS_ON_TRAIL);
+    const pathSegment = lineSegment<GlobalPoint>(
+      eraserPath[eraserPath.length - 1],
+      eraserPath[eraserPath.length - 2],
+    );
 
 
     const candidateElements = this.app.visibleElements.filter(
     const candidateElements = this.app.visibleElements.filter(
       (el) => !el.locked,
       (el) => !el.locked,
@@ -94,28 +89,13 @@ export class EraserTrail extends AnimatedTrail {
 
 
     const candidateElementsMap = arrayToMap(candidateElements);
     const candidateElementsMap = arrayToMap(candidateElements);
 
 
-    const pathSegments = eraserPath.reduce((acc, point, index) => {
-      if (index === 0) {
-        return acc;
-      }
-      acc.push(lineSegment(eraserPath[index - 1], point));
-      return acc;
-    }, [] as LineSegment<GlobalPoint>[]);
-
-    if (pathSegments.length === 0) {
-      return [];
-    }
-
     for (const element of candidateElements) {
     for (const element of candidateElements) {
       // restore only if already added to the to-be-erased set
       // restore only if already added to the to-be-erased set
       if (restoreToErase && this.elementsToErase.has(element.id)) {
       if (restoreToErase && this.elementsToErase.has(element.id)) {
         const intersects = eraserTest(
         const intersects = eraserTest(
-          pathSegments,
+          pathSegment,
           element,
           element,
-          this.segmentsCache,
-          this.geometricShapesCache,
           candidateElementsMap,
           candidateElementsMap,
-          this.app,
         );
         );
 
 
         if (intersects) {
         if (intersects) {
@@ -148,12 +128,9 @@ export class EraserTrail extends AnimatedTrail {
         }
         }
       } else if (!restoreToErase && !this.elementsToErase.has(element.id)) {
       } else if (!restoreToErase && !this.elementsToErase.has(element.id)) {
         const intersects = eraserTest(
         const intersects = eraserTest(
-          pathSegments,
+          pathSegment,
           element,
           element,
-          this.segmentsCache,
-          this.geometricShapesCache,
           candidateElementsMap,
           candidateElementsMap,
-          this.app,
         );
         );
 
 
         if (intersects) {
         if (intersects) {
@@ -196,45 +173,37 @@ export class EraserTrail extends AnimatedTrail {
     super.clearTrails();
     super.clearTrails();
     this.elementsToErase.clear();
     this.elementsToErase.clear();
     this.groupsToErase.clear();
     this.groupsToErase.clear();
-    this.segmentsCache.clear();
   }
   }
 }
 }
 
 
 const eraserTest = (
 const eraserTest = (
-  pathSegments: LineSegment<GlobalPoint>[],
+  pathSegment: LineSegment<GlobalPoint>,
   element: ExcalidrawElement,
   element: ExcalidrawElement,
-  elementsSegments: ElementsSegmentsMap,
-  shapesCache: Map<string, GeometricShape<GlobalPoint>>,
   elementsMap: ElementsMap,
   elementsMap: ElementsMap,
-  app: App,
 ): boolean => {
 ): boolean => {
-  let shape = shapesCache.get(element.id);
-
-  if (!shape) {
-    shape = getElementShape<GlobalPoint>(element, elementsMap);
-    shapesCache.set(element.id, shape);
-  }
-
-  const lastPoint = pathSegments[pathSegments.length - 1][1];
-  if (shouldTestInside(element) && isPointInShape(lastPoint, shape)) {
+  const lastPoint = pathSegment[1];
+  if (
+    shouldTestInside(element) &&
+    isPointInElement(lastPoint, element, elementsMap)
+  ) {
     return true;
     return true;
   }
   }
 
 
-  let elementSegments = elementsSegments.get(element.id);
-
-  if (!elementSegments) {
-    elementSegments = getElementLineSegments(element, elementsMap);
-    elementsSegments.set(element.id, elementSegments);
-  }
-
-  return pathSegments.some((pathSegment) =>
-    elementSegments?.some(
-      (elementSegment) =>
-        lineSegmentIntersectionPoints(
-          pathSegment,
-          elementSegment,
-          app.getElementHitThreshold(),
-        ) !== null,
-    ),
+  const boundTextElement = getBoundTextElement(element, elementsMap);
+
+  return (
+    intersectElementWithLineSegment(element, elementsMap, pathSegment, 0, true)
+      .length > 0 ||
+    (!!boundTextElement &&
+      intersectElementWithLineSegment(
+        {
+          ...boundTextElement,
+          ...computeBoundTextPosition(element, boundTextElement, elementsMap),
+        },
+        elementsMap,
+        pathSegment,
+        0,
+        true,
+      ).length > 0)
   );
   );
 };
 };

+ 1 - 0
packages/excalidraw/lasso/index.ts

@@ -199,6 +199,7 @@ export class LassoTrail extends AnimatedTrail {
       const { selectedElementIds } = getLassoSelectedElementIds({
       const { selectedElementIds } = getLassoSelectedElementIds({
         lassoPath,
         lassoPath,
         elements: this.app.visibleElements,
         elements: this.app.visibleElements,
+        elementsMap: this.app.scene.getNonDeletedElementsMap(),
         elementsSegments: this.elementsSegments,
         elementsSegments: this.elementsSegments,
         intersectedElements: this.intersectedElements,
         intersectedElements: this.intersectedElements,
         enclosedElements: this.enclosedElements,
         enclosedElements: this.enclosedElements,

+ 58 - 26
packages/excalidraw/lasso/utils.ts

@@ -3,20 +3,25 @@ import { simplify } from "points-on-curve";
 import {
 import {
   polygonFromPoints,
   polygonFromPoints,
   lineSegment,
   lineSegment,
-  lineSegmentIntersectionPoints,
   polygonIncludesPointNonZero,
   polygonIncludesPointNonZero,
 } from "@excalidraw/math";
 } from "@excalidraw/math";
 
 
-import type {
-  ElementsSegmentsMap,
-  GlobalPoint,
-  LineSegment,
-} from "@excalidraw/math/types";
-import type { ExcalidrawElement } from "@excalidraw/element/types";
+import {
+  type Bounds,
+  computeBoundTextPosition,
+  doBoundsIntersect,
+  getBoundTextElement,
+  getElementBounds,
+  intersectElementWithLineSegment,
+} from "@excalidraw/element";
+
+import type { ElementsSegmentsMap, GlobalPoint } from "@excalidraw/math/types";
+import type { ElementsMap, ExcalidrawElement } from "@excalidraw/element/types";
 
 
 export const getLassoSelectedElementIds = (input: {
 export const getLassoSelectedElementIds = (input: {
   lassoPath: GlobalPoint[];
   lassoPath: GlobalPoint[];
   elements: readonly ExcalidrawElement[];
   elements: readonly ExcalidrawElement[];
+  elementsMap: ElementsMap;
   elementsSegments: ElementsSegmentsMap;
   elementsSegments: ElementsSegmentsMap;
   intersectedElements: Set<ExcalidrawElement["id"]>;
   intersectedElements: Set<ExcalidrawElement["id"]>;
   enclosedElements: Set<ExcalidrawElement["id"]>;
   enclosedElements: Set<ExcalidrawElement["id"]>;
@@ -27,6 +32,7 @@ export const getLassoSelectedElementIds = (input: {
   const {
   const {
     lassoPath,
     lassoPath,
     elements,
     elements,
+    elementsMap,
     elementsSegments,
     elementsSegments,
     intersectedElements,
     intersectedElements,
     enclosedElements,
     enclosedElements,
@@ -40,8 +46,26 @@ export const getLassoSelectedElementIds = (input: {
   const unlockedElements = elements.filter((el) => !el.locked);
   const unlockedElements = elements.filter((el) => !el.locked);
   // as the path might not enclose a shape anymore, clear before checking
   // as the path might not enclose a shape anymore, clear before checking
   enclosedElements.clear();
   enclosedElements.clear();
+  intersectedElements.clear();
+  const lassoBounds = lassoPath.reduce(
+    (acc, item) => {
+      return [
+        Math.min(acc[0], item[0]),
+        Math.min(acc[1], item[1]),
+        Math.max(acc[2], item[0]),
+        Math.max(acc[3], item[1]),
+      ];
+    },
+    [Infinity, Infinity, -Infinity, -Infinity],
+  ) as Bounds;
   for (const element of unlockedElements) {
   for (const element of unlockedElements) {
+    // First check if the lasso segment intersects the element's axis-aligned
+    // bounding box as it is much faster than checking intersection against
+    // the element's shape
+    const elementBounds = getElementBounds(element, elementsMap);
+
     if (
     if (
+      doBoundsIntersect(lassoBounds, elementBounds) &&
       !intersectedElements.has(element.id) &&
       !intersectedElements.has(element.id) &&
       !enclosedElements.has(element.id)
       !enclosedElements.has(element.id)
     ) {
     ) {
@@ -49,7 +73,7 @@ export const getLassoSelectedElementIds = (input: {
       if (enclosed) {
       if (enclosed) {
         enclosedElements.add(element.id);
         enclosedElements.add(element.id);
       } else {
       } else {
-        const intersects = intersectionTest(path, element, elementsSegments);
+        const intersects = intersectionTest(path, element, elementsMap);
         if (intersects) {
         if (intersects) {
           intersectedElements.add(element.id);
           intersectedElements.add(element.id);
         }
         }
@@ -85,26 +109,34 @@ const enclosureTest = (
 const intersectionTest = (
 const intersectionTest = (
   lassoPath: GlobalPoint[],
   lassoPath: GlobalPoint[],
   element: ExcalidrawElement,
   element: ExcalidrawElement,
-  elementsSegments: ElementsSegmentsMap,
+  elementsMap: ElementsMap,
 ): boolean => {
 ): boolean => {
-  const elementSegments = elementsSegments.get(element.id);
-  if (!elementSegments) {
-    return false;
-  }
+  const lassoSegments = lassoPath
+    .slice(1)
+    .map((point: GlobalPoint, index) => lineSegment(lassoPath[index], point))
+    .concat([lineSegment(lassoPath[lassoPath.length - 1], lassoPath[0])]);
 
 
-  const lassoSegments = lassoPath.reduce((acc, point, index) => {
-    if (index === 0) {
-      return acc;
-    }
-    acc.push(lineSegment(lassoPath[index - 1], point));
-    return acc;
-  }, [] as LineSegment<GlobalPoint>[]);
+  const boundTextElement = getBoundTextElement(element, elementsMap);
 
 
-  return lassoSegments.some((lassoSegment) =>
-    elementSegments.some(
-      (elementSegment) =>
-        // introduce a bit of tolerance to account for roughness and simplification of paths
-        lineSegmentIntersectionPoints(lassoSegment, elementSegment, 1) !== null,
-    ),
+  return lassoSegments.some(
+    (lassoSegment) =>
+      intersectElementWithLineSegment(
+        element,
+        elementsMap,
+        lassoSegment,
+        0,
+        true,
+      ).length > 0 ||
+      (!!boundTextElement &&
+        intersectElementWithLineSegment(
+          {
+            ...boundTextElement,
+            ...computeBoundTextPosition(element, boundTextElement, elementsMap),
+          },
+          elementsMap,
+          lassoSegment,
+          0,
+          true,
+        ).length > 0),
   );
   );
 };
 };

+ 96 - 160
packages/excalidraw/renderer/helpers.ts

@@ -5,17 +5,14 @@ import { getDiamondPoints } from "@excalidraw/element";
 import { getCornerRadius } from "@excalidraw/element";
 import { getCornerRadius } from "@excalidraw/element";
 
 
 import {
 import {
-  bezierEquation,
   curve,
   curve,
-  curveTangent,
+  curveCatmullRomCubicApproxPoints,
+  curveCatmullRomQuadraticApproxPoints,
+  curveOffsetPoints,
   type GlobalPoint,
   type GlobalPoint,
+  offsetPointsForQuadraticBezier,
   pointFrom,
   pointFrom,
-  pointFromVector,
   pointRotateRads,
   pointRotateRads,
-  vector,
-  vectorNormal,
-  vectorNormalize,
-  vectorScale,
 } from "@excalidraw/math";
 } from "@excalidraw/math";
 
 
 import type {
 import type {
@@ -102,25 +99,14 @@ export const bootstrapCanvas = ({
 function drawCatmullRomQuadraticApprox(
 function drawCatmullRomQuadraticApprox(
   ctx: CanvasRenderingContext2D,
   ctx: CanvasRenderingContext2D,
   points: GlobalPoint[],
   points: GlobalPoint[],
-  segments = 20,
+  tension = 0.5,
 ) {
 ) {
-  ctx.lineTo(points[0][0], points[0][1]);
+  const pointSets = curveCatmullRomQuadraticApproxPoints(points, tension);
+  if (pointSets) {
+    for (let i = 0; i < pointSets.length - 1; i++) {
+      const [[cpX, cpY], [p2X, p2Y]] = pointSets[i];
 
 
-  for (let i = 0; i < points.length - 1; i++) {
-    const p0 = points[i - 1 < 0 ? 0 : i - 1];
-    const p1 = points[i];
-    const p2 = points[i + 1 >= points.length ? points.length - 1 : i + 1];
-
-    for (let t = 0; t <= 1; t += 1 / segments) {
-      const t2 = t * t;
-
-      const x =
-        (1 - t) * (1 - t) * p0[0] + 2 * (1 - t) * t * p1[0] + t2 * p2[0];
-
-      const y =
-        (1 - t) * (1 - t) * p0[1] + 2 * (1 - t) * t * p1[1] + t2 * p2[1];
-
-      ctx.lineTo(x, y);
+      ctx.quadraticCurveTo(cpX, cpY, p2X, p2Y);
     }
     }
   }
   }
 }
 }
@@ -128,35 +114,13 @@ function drawCatmullRomQuadraticApprox(
 function drawCatmullRomCubicApprox(
 function drawCatmullRomCubicApprox(
   ctx: CanvasRenderingContext2D,
   ctx: CanvasRenderingContext2D,
   points: GlobalPoint[],
   points: GlobalPoint[],
-  segments = 20,
+  tension = 0.5,
 ) {
 ) {
-  ctx.lineTo(points[0][0], points[0][1]);
-
-  for (let i = 0; i < points.length - 1; i++) {
-    const p0 = points[i - 1 < 0 ? 0 : i - 1];
-    const p1 = points[i];
-    const p2 = points[i + 1 >= points.length ? points.length - 1 : i + 1];
-    const p3 = points[i + 2 >= points.length ? points.length - 1 : i + 2];
-
-    for (let t = 0; t <= 1; t += 1 / segments) {
-      const t2 = t * t;
-      const t3 = t2 * t;
-
-      const x =
-        0.5 *
-        (2 * p1[0] +
-          (-p0[0] + p2[0]) * t +
-          (2 * p0[0] - 5 * p1[0] + 4 * p2[0] - p3[0]) * t2 +
-          (-p0[0] + 3 * p1[0] - 3 * p2[0] + p3[0]) * t3);
-
-      const y =
-        0.5 *
-        (2 * p1[1] +
-          (-p0[1] + p2[1]) * t +
-          (2 * p0[1] - 5 * p1[1] + 4 * p2[1] - p3[1]) * t2 +
-          (-p0[1] + 3 * p1[1] - 3 * p2[1] + p3[1]) * t3);
-
-      ctx.lineTo(x, y);
+  const pointSets = curveCatmullRomCubicApproxPoints(points, tension);
+  if (pointSets) {
+    for (let i = 0; i < pointSets.length; i++) {
+      const [[cp1x, cp1y], [cp2x, cp2y], [x, y]] = pointSets[i];
+      ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y);
     }
     }
   }
   }
 }
 }
@@ -168,7 +132,10 @@ export const drawHighlightForRectWithRotation = (
 ) => {
 ) => {
   const [x, y] = pointRotateRads(
   const [x, y] = pointRotateRads(
     pointFrom<GlobalPoint>(element.x, element.y),
     pointFrom<GlobalPoint>(element.x, element.y),
-    elementCenterPoint(element),
+    elementCenterPoint(
+      element,
+      window.h.app.scene.getElementsMapIncludingDeleted(),
+    ),
     element.angle,
     element.angle,
   );
   );
 
 
@@ -187,25 +154,25 @@ export const drawHighlightForRectWithRotation = (
   context.beginPath();
   context.beginPath();
 
 
   {
   {
-    const topLeftApprox = offsetQuadraticBezier(
+    const topLeftApprox = offsetPointsForQuadraticBezier(
       pointFrom(0, 0 + radius),
       pointFrom(0, 0 + radius),
       pointFrom(0, 0),
       pointFrom(0, 0),
       pointFrom(0 + radius, 0),
       pointFrom(0 + radius, 0),
       padding,
       padding,
     );
     );
-    const topRightApprox = offsetQuadraticBezier(
+    const topRightApprox = offsetPointsForQuadraticBezier(
       pointFrom(element.width - radius, 0),
       pointFrom(element.width - radius, 0),
       pointFrom(element.width, 0),
       pointFrom(element.width, 0),
       pointFrom(element.width, radius),
       pointFrom(element.width, radius),
       padding,
       padding,
     );
     );
-    const bottomRightApprox = offsetQuadraticBezier(
+    const bottomRightApprox = offsetPointsForQuadraticBezier(
       pointFrom(element.width, element.height - radius),
       pointFrom(element.width, element.height - radius),
       pointFrom(element.width, element.height),
       pointFrom(element.width, element.height),
       pointFrom(element.width - radius, element.height),
       pointFrom(element.width - radius, element.height),
       padding,
       padding,
     );
     );
-    const bottomLeftApprox = offsetQuadraticBezier(
+    const bottomLeftApprox = offsetPointsForQuadraticBezier(
       pointFrom(radius, element.height),
       pointFrom(radius, element.height),
       pointFrom(0, element.height),
       pointFrom(0, element.height),
       pointFrom(0, element.height - radius),
       pointFrom(0, element.height - radius),
@@ -230,25 +197,25 @@ export const drawHighlightForRectWithRotation = (
   // mask" on a filled shape for the diamond highlight, because stroking creates
   // mask" on a filled shape for the diamond highlight, because stroking creates
   // sharp inset edges on line joins < 90 degrees.
   // sharp inset edges on line joins < 90 degrees.
   {
   {
-    const topLeftApprox = offsetQuadraticBezier(
+    const topLeftApprox = offsetPointsForQuadraticBezier(
       pointFrom(0 + radius, 0),
       pointFrom(0 + radius, 0),
       pointFrom(0, 0),
       pointFrom(0, 0),
       pointFrom(0, 0 + radius),
       pointFrom(0, 0 + radius),
       -FIXED_BINDING_DISTANCE,
       -FIXED_BINDING_DISTANCE,
     );
     );
-    const topRightApprox = offsetQuadraticBezier(
+    const topRightApprox = offsetPointsForQuadraticBezier(
       pointFrom(element.width, radius),
       pointFrom(element.width, radius),
       pointFrom(element.width, 0),
       pointFrom(element.width, 0),
       pointFrom(element.width - radius, 0),
       pointFrom(element.width - radius, 0),
       -FIXED_BINDING_DISTANCE,
       -FIXED_BINDING_DISTANCE,
     );
     );
-    const bottomRightApprox = offsetQuadraticBezier(
+    const bottomRightApprox = offsetPointsForQuadraticBezier(
       pointFrom(element.width - radius, element.height),
       pointFrom(element.width - radius, element.height),
       pointFrom(element.width, element.height),
       pointFrom(element.width, element.height),
       pointFrom(element.width, element.height - radius),
       pointFrom(element.width, element.height - radius),
       -FIXED_BINDING_DISTANCE,
       -FIXED_BINDING_DISTANCE,
     );
     );
-    const bottomLeftApprox = offsetQuadraticBezier(
+    const bottomLeftApprox = offsetPointsForQuadraticBezier(
       pointFrom(0, element.height - radius),
       pointFrom(0, element.height - radius),
       pointFrom(0, element.height),
       pointFrom(0, element.height),
       pointFrom(radius, element.height),
       pointFrom(radius, element.height),
@@ -325,7 +292,10 @@ export const drawHighlightForDiamondWithRotation = (
 ) => {
 ) => {
   const [x, y] = pointRotateRads(
   const [x, y] = pointRotateRads(
     pointFrom<GlobalPoint>(element.x, element.y),
     pointFrom<GlobalPoint>(element.x, element.y),
-    elementCenterPoint(element),
+    elementCenterPoint(
+      element,
+      window.h.app.scene.getElementsMapIncludingDeleted(),
+    ),
     element.angle,
     element.angle,
   );
   );
   context.save();
   context.save();
@@ -343,32 +313,40 @@ export const drawHighlightForDiamondWithRotation = (
     const horizontalRadius = element.roundness
     const horizontalRadius = element.roundness
       ? getCornerRadius(Math.abs(rightY - topY), element)
       ? getCornerRadius(Math.abs(rightY - topY), element)
       : (rightY - topY) * 0.01;
       : (rightY - topY) * 0.01;
-    const topApprox = offsetCubicBezier(
-      pointFrom(topX - verticalRadius, topY + horizontalRadius),
-      pointFrom(topX, topY),
-      pointFrom(topX, topY),
-      pointFrom(topX + verticalRadius, topY + horizontalRadius),
+    const topApprox = curveOffsetPoints(
+      curve(
+        pointFrom(topX - verticalRadius, topY + horizontalRadius),
+        pointFrom(topX, topY),
+        pointFrom(topX, topY),
+        pointFrom(topX + verticalRadius, topY + horizontalRadius),
+      ),
       padding,
       padding,
     );
     );
-    const rightApprox = offsetCubicBezier(
-      pointFrom(rightX - verticalRadius, rightY - horizontalRadius),
-      pointFrom(rightX, rightY),
-      pointFrom(rightX, rightY),
-      pointFrom(rightX - verticalRadius, rightY + horizontalRadius),
+    const rightApprox = curveOffsetPoints(
+      curve(
+        pointFrom(rightX - verticalRadius, rightY - horizontalRadius),
+        pointFrom(rightX, rightY),
+        pointFrom(rightX, rightY),
+        pointFrom(rightX - verticalRadius, rightY + horizontalRadius),
+      ),
       padding,
       padding,
     );
     );
-    const bottomApprox = offsetCubicBezier(
-      pointFrom(bottomX + verticalRadius, bottomY - horizontalRadius),
-      pointFrom(bottomX, bottomY),
-      pointFrom(bottomX, bottomY),
-      pointFrom(bottomX - verticalRadius, bottomY - horizontalRadius),
+    const bottomApprox = curveOffsetPoints(
+      curve(
+        pointFrom(bottomX + verticalRadius, bottomY - horizontalRadius),
+        pointFrom(bottomX, bottomY),
+        pointFrom(bottomX, bottomY),
+        pointFrom(bottomX - verticalRadius, bottomY - horizontalRadius),
+      ),
       padding,
       padding,
     );
     );
-    const leftApprox = offsetCubicBezier(
-      pointFrom(leftX + verticalRadius, leftY + horizontalRadius),
-      pointFrom(leftX, leftY),
-      pointFrom(leftX, leftY),
-      pointFrom(leftX + verticalRadius, leftY - horizontalRadius),
+    const leftApprox = curveOffsetPoints(
+      curve(
+        pointFrom(leftX + verticalRadius, leftY + horizontalRadius),
+        pointFrom(leftX, leftY),
+        pointFrom(leftX, leftY),
+        pointFrom(leftX + verticalRadius, leftY - horizontalRadius),
+      ),
       padding,
       padding,
     );
     );
 
 
@@ -376,13 +354,13 @@ export const drawHighlightForDiamondWithRotation = (
       topApprox[topApprox.length - 1][0],
       topApprox[topApprox.length - 1][0],
       topApprox[topApprox.length - 1][1],
       topApprox[topApprox.length - 1][1],
     );
     );
-    context.lineTo(rightApprox[0][0], rightApprox[0][1]);
+    context.lineTo(rightApprox[1][0], rightApprox[1][1]);
     drawCatmullRomCubicApprox(context, rightApprox);
     drawCatmullRomCubicApprox(context, rightApprox);
-    context.lineTo(bottomApprox[0][0], bottomApprox[0][1]);
+    context.lineTo(bottomApprox[1][0], bottomApprox[1][1]);
     drawCatmullRomCubicApprox(context, bottomApprox);
     drawCatmullRomCubicApprox(context, bottomApprox);
-    context.lineTo(leftApprox[0][0], leftApprox[0][1]);
+    context.lineTo(leftApprox[1][0], leftApprox[1][1]);
     drawCatmullRomCubicApprox(context, leftApprox);
     drawCatmullRomCubicApprox(context, leftApprox);
-    context.lineTo(topApprox[0][0], topApprox[0][1]);
+    context.lineTo(topApprox[1][0], topApprox[1][1]);
     drawCatmullRomCubicApprox(context, topApprox);
     drawCatmullRomCubicApprox(context, topApprox);
   }
   }
 
 
@@ -398,32 +376,40 @@ export const drawHighlightForDiamondWithRotation = (
     const horizontalRadius = element.roundness
     const horizontalRadius = element.roundness
       ? getCornerRadius(Math.abs(rightY - topY), element)
       ? getCornerRadius(Math.abs(rightY - topY), element)
       : (rightY - topY) * 0.01;
       : (rightY - topY) * 0.01;
-    const topApprox = offsetCubicBezier(
-      pointFrom(topX + verticalRadius, topY + horizontalRadius),
-      pointFrom(topX, topY),
-      pointFrom(topX, topY),
-      pointFrom(topX - verticalRadius, topY + horizontalRadius),
+    const topApprox = curveOffsetPoints(
+      curve(
+        pointFrom(topX + verticalRadius, topY + horizontalRadius),
+        pointFrom(topX, topY),
+        pointFrom(topX, topY),
+        pointFrom(topX - verticalRadius, topY + horizontalRadius),
+      ),
       -FIXED_BINDING_DISTANCE,
       -FIXED_BINDING_DISTANCE,
     );
     );
-    const rightApprox = offsetCubicBezier(
-      pointFrom(rightX - verticalRadius, rightY + horizontalRadius),
-      pointFrom(rightX, rightY),
-      pointFrom(rightX, rightY),
-      pointFrom(rightX - verticalRadius, rightY - horizontalRadius),
+    const rightApprox = curveOffsetPoints(
+      curve(
+        pointFrom(rightX - verticalRadius, rightY + horizontalRadius),
+        pointFrom(rightX, rightY),
+        pointFrom(rightX, rightY),
+        pointFrom(rightX - verticalRadius, rightY - horizontalRadius),
+      ),
       -FIXED_BINDING_DISTANCE,
       -FIXED_BINDING_DISTANCE,
     );
     );
-    const bottomApprox = offsetCubicBezier(
-      pointFrom(bottomX - verticalRadius, bottomY - horizontalRadius),
-      pointFrom(bottomX, bottomY),
-      pointFrom(bottomX, bottomY),
-      pointFrom(bottomX + verticalRadius, bottomY - horizontalRadius),
+    const bottomApprox = curveOffsetPoints(
+      curve(
+        pointFrom(bottomX - verticalRadius, bottomY - horizontalRadius),
+        pointFrom(bottomX, bottomY),
+        pointFrom(bottomX, bottomY),
+        pointFrom(bottomX + verticalRadius, bottomY - horizontalRadius),
+      ),
       -FIXED_BINDING_DISTANCE,
       -FIXED_BINDING_DISTANCE,
     );
     );
-    const leftApprox = offsetCubicBezier(
-      pointFrom(leftX + verticalRadius, leftY - horizontalRadius),
-      pointFrom(leftX, leftY),
-      pointFrom(leftX, leftY),
-      pointFrom(leftX + verticalRadius, leftY + horizontalRadius),
+    const leftApprox = curveOffsetPoints(
+      curve(
+        pointFrom(leftX + verticalRadius, leftY - horizontalRadius),
+        pointFrom(leftX, leftY),
+        pointFrom(leftX, leftY),
+        pointFrom(leftX + verticalRadius, leftY + horizontalRadius),
+      ),
       -FIXED_BINDING_DISTANCE,
       -FIXED_BINDING_DISTANCE,
     );
     );
 
 
@@ -431,66 +417,16 @@ export const drawHighlightForDiamondWithRotation = (
       topApprox[topApprox.length - 1][0],
       topApprox[topApprox.length - 1][0],
       topApprox[topApprox.length - 1][1],
       topApprox[topApprox.length - 1][1],
     );
     );
-    context.lineTo(leftApprox[0][0], leftApprox[0][1]);
+    context.lineTo(leftApprox[1][0], leftApprox[1][1]);
     drawCatmullRomCubicApprox(context, leftApprox);
     drawCatmullRomCubicApprox(context, leftApprox);
-    context.lineTo(bottomApprox[0][0], bottomApprox[0][1]);
+    context.lineTo(bottomApprox[1][0], bottomApprox[1][1]);
     drawCatmullRomCubicApprox(context, bottomApprox);
     drawCatmullRomCubicApprox(context, bottomApprox);
-    context.lineTo(rightApprox[0][0], rightApprox[0][1]);
+    context.lineTo(rightApprox[1][0], rightApprox[1][1]);
     drawCatmullRomCubicApprox(context, rightApprox);
     drawCatmullRomCubicApprox(context, rightApprox);
-    context.lineTo(topApprox[0][0], topApprox[0][1]);
+    context.lineTo(topApprox[1][0], topApprox[1][1]);
     drawCatmullRomCubicApprox(context, topApprox);
     drawCatmullRomCubicApprox(context, topApprox);
   }
   }
   context.closePath();
   context.closePath();
   context.fill();
   context.fill();
   context.restore();
   context.restore();
 };
 };
-
-function offsetCubicBezier(
-  p0: GlobalPoint,
-  p1: GlobalPoint,
-  p2: GlobalPoint,
-  p3: GlobalPoint,
-  offsetDist: number,
-  steps = 20,
-) {
-  const offsetPoints = [];
-
-  for (let i = 0; i <= steps; i++) {
-    const t = i / steps;
-    const c = curve(p0, p1, p2, p3);
-    const point = bezierEquation(c, t);
-    const tangent = vectorNormalize(curveTangent(c, t));
-    const normal = vectorNormal(tangent);
-
-    offsetPoints.push(pointFromVector(vectorScale(normal, offsetDist), point));
-  }
-
-  return offsetPoints;
-}
-
-function offsetQuadraticBezier(
-  p0: GlobalPoint,
-  p1: GlobalPoint,
-  p2: GlobalPoint,
-  offsetDist: number,
-  steps = 20,
-) {
-  const offsetPoints = [];
-
-  for (let i = 0; i <= steps; i++) {
-    const t = i / steps;
-    const t1 = 1 - t;
-    const point = pointFrom<GlobalPoint>(
-      t1 * t1 * p0[0] + 2 * t1 * t * p1[0] + t * t * p2[0],
-      t1 * t1 * p0[1] + 2 * t1 * t * p1[1] + t * t * p2[1],
-    );
-    const tangentX = 2 * (1 - t) * (p1[0] - p0[0]) + 2 * t * (p2[0] - p1[0]);
-    const tangentY = 2 * (1 - t) * (p1[1] - p0[1]) + 2 * t * (p2[1] - p1[1]);
-    const tangent = vectorNormalize(vector(tangentX, tangentY));
-    const normal = vectorNormal(tangent);
-
-    offsetPoints.push(pointFromVector(vectorScale(normal, offsetDist), point));
-  }
-
-  return offsetPoints;
-}

+ 9 - 11
packages/excalidraw/renderer/interactiveScene.ts

@@ -193,16 +193,10 @@ const renderBindingHighlightForBindableElement = (
   elementsMap: ElementsMap,
   elementsMap: ElementsMap,
   zoom: InteractiveCanvasAppState["zoom"],
   zoom: InteractiveCanvasAppState["zoom"],
 ) => {
 ) => {
-  const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
-  const width = x2 - x1;
-  const height = y2 - y1;
+  const padding = maxBindingGap(element, element.width, element.height, zoom);
 
 
-  context.strokeStyle = "rgba(0,0,0,.05)";
   context.fillStyle = "rgba(0,0,0,.05)";
   context.fillStyle = "rgba(0,0,0,.05)";
 
 
-  // To ensure the binding highlight doesn't overlap the element itself
-  const padding = maxBindingGap(element, element.width, element.height, zoom);
-
   switch (element.type) {
   switch (element.type) {
     case "rectangle":
     case "rectangle":
     case "text":
     case "text":
@@ -216,10 +210,13 @@ const renderBindingHighlightForBindableElement = (
     case "diamond":
     case "diamond":
       drawHighlightForDiamondWithRotation(context, padding, element);
       drawHighlightForDiamondWithRotation(context, padding, element);
       break;
       break;
-    case "ellipse":
-      context.lineWidth =
-        maxBindingGap(element, element.width, element.height, zoom) -
-        FIXED_BINDING_DISTANCE;
+    case "ellipse": {
+      const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
+      const width = x2 - x1;
+      const height = y2 - y1;
+
+      context.strokeStyle = "rgba(0,0,0,.05)";
+      context.lineWidth = padding - FIXED_BINDING_DISTANCE;
 
 
       strokeEllipseWithRotation(
       strokeEllipseWithRotation(
         context,
         context,
@@ -230,6 +227,7 @@ const renderBindingHighlightForBindableElement = (
         element.angle,
         element.angle,
       );
       );
       break;
       break;
+    }
   }
   }
 };
 };
 
 

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 132 - 230
packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap


+ 4 - 12
packages/excalidraw/tests/__snapshots__/dragCreate.test.tsx.snap

@@ -71,9 +71,7 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
   "locked": false,
   "locked": false,
   "opacity": 100,
   "opacity": 100,
   "roughness": 1,
   "roughness": 1,
-  "roundness": {
-    "type": 2,
-  },
+  "roundness": null,
   "seed": 1278240551,
   "seed": 1278240551,
   "strokeColor": "#1e1e1e",
   "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeStyle": "solid",
@@ -107,9 +105,7 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
   "locked": false,
   "locked": false,
   "opacity": 100,
   "opacity": 100,
   "roughness": 1,
   "roughness": 1,
-  "roundness": {
-    "type": 2,
-  },
+  "roundness": null,
   "seed": 1278240551,
   "seed": 1278240551,
   "strokeColor": "#1e1e1e",
   "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeStyle": "solid",
@@ -155,9 +151,7 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
   ],
   ],
   "polygon": false,
   "polygon": false,
   "roughness": 1,
   "roughness": 1,
-  "roundness": {
-    "type": 2,
-  },
+  "roundness": null,
   "seed": 1278240551,
   "seed": 1278240551,
   "startArrowhead": null,
   "startArrowhead": null,
   "startBinding": null,
   "startBinding": null,
@@ -193,9 +187,7 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
   "locked": false,
   "locked": false,
   "opacity": 100,
   "opacity": 100,
   "roughness": 1,
   "roughness": 1,
-  "roundness": {
-    "type": 3,
-  },
+  "roundness": null,
   "seed": 1278240551,
   "seed": 1278240551,
   "strokeColor": "#1e1e1e",
   "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeStyle": "solid",

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 1 - 1
packages/excalidraw/tests/__snapshots__/export.test.tsx.snap


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 113 - 223
packages/excalidraw/tests/__snapshots__/history.test.tsx.snap


+ 11 - 21
packages/excalidraw/tests/__snapshots__/move.test.tsx.snap

@@ -17,9 +17,7 @@ exports[`duplicate element on move when ALT is clicked > rectangle 5`] = `
   "locked": false,
   "locked": false,
   "opacity": 100,
   "opacity": 100,
   "roughness": 1,
   "roughness": 1,
-  "roundness": {
-    "type": 3,
-  },
+  "roundness": null,
   "seed": 1278240551,
   "seed": 1278240551,
   "strokeColor": "#1e1e1e",
   "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeStyle": "solid",
@@ -51,9 +49,7 @@ exports[`duplicate element on move when ALT is clicked > rectangle 6`] = `
   "locked": false,
   "locked": false,
   "opacity": 100,
   "opacity": 100,
   "roughness": 1,
   "roughness": 1,
-  "roundness": {
-    "type": 3,
-  },
+  "roundness": null,
   "seed": 1604849351,
   "seed": 1604849351,
   "strokeColor": "#1e1e1e",
   "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeStyle": "solid",
@@ -85,9 +81,7 @@ exports[`move element > rectangle 5`] = `
   "locked": false,
   "locked": false,
   "opacity": 100,
   "opacity": 100,
   "roughness": 1,
   "roughness": 1,
-  "roundness": {
-    "type": 3,
-  },
+  "roundness": null,
   "seed": 1278240551,
   "seed": 1278240551,
   "strokeColor": "#1e1e1e",
   "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeStyle": "solid",
@@ -124,9 +118,7 @@ exports[`move element > rectangles with binding arrow 5`] = `
   "locked": false,
   "locked": false,
   "opacity": 100,
   "opacity": 100,
   "roughness": 1,
   "roughness": 1,
-  "roundness": {
-    "type": 3,
-  },
+  "roundness": null,
   "seed": 1278240551,
   "seed": 1278240551,
   "strokeColor": "#1e1e1e",
   "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeStyle": "solid",
@@ -163,9 +155,7 @@ exports[`move element > rectangles with binding arrow 6`] = `
   "locked": false,
   "locked": false,
   "opacity": 100,
   "opacity": 100,
   "roughness": 1,
   "roughness": 1,
-  "roundness": {
-    "type": 3,
-  },
+  "roundness": null,
   "seed": 1150084233,
   "seed": 1150084233,
   "strokeColor": "#1e1e1e",
   "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeStyle": "solid",
@@ -196,7 +186,7 @@ exports[`move element > rectangles with binding arrow 7`] = `
   "fillStyle": "solid",
   "fillStyle": "solid",
   "frameId": null,
   "frameId": null,
   "groupIds": [],
   "groupIds": [],
-  "height": "87.29887",
+  "height": "81.40630",
   "id": "id6",
   "id": "id6",
   "index": "a2",
   "index": "a2",
   "isDeleted": false,
   "isDeleted": false,
@@ -210,8 +200,8 @@ exports[`move element > rectangles with binding arrow 7`] = `
       0,
       0,
     ],
     ],
     [
     [
-      "86.85786",
-      "87.29887",
+      "81.00000",
+      "81.40630",
     ],
     ],
   ],
   ],
   "roughness": 1,
   "roughness": 1,
@@ -232,8 +222,8 @@ exports[`move element > rectangles with binding arrow 7`] = `
   "updated": 1,
   "updated": 1,
   "version": 11,
   "version": 11,
   "versionNonce": 1996028265,
   "versionNonce": 1996028265,
-  "width": "86.85786",
-  "x": "107.07107",
-  "y": "47.07107",
+  "width": "81.00000",
+  "x": "110.00000",
+  "y": 50,
 }
 }
 `;
 `;

+ 1 - 3
packages/excalidraw/tests/__snapshots__/multiPointCreate.test.tsx.snap

@@ -95,9 +95,7 @@ exports[`multi point mode in linear elements > line 3`] = `
   ],
   ],
   "polygon": false,
   "polygon": false,
   "roughness": 1,
   "roughness": 1,
-  "roundness": {
-    "type": 2,
-  },
+  "roundness": null,
   "seed": 1278240551,
   "seed": 1278240551,
   "startArrowhead": null,
   "startArrowhead": null,
   "startBinding": null,
   "startBinding": null,

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 98 - 224
packages/excalidraw/tests/__snapshots__/regressionTests.test.tsx.snap


+ 4 - 12
packages/excalidraw/tests/__snapshots__/selection.test.tsx.snap

@@ -81,9 +81,7 @@ exports[`select single element on the scene > arrow escape 1`] = `
   ],
   ],
   "polygon": false,
   "polygon": false,
   "roughness": 1,
   "roughness": 1,
-  "roundness": {
-    "type": 2,
-  },
+  "roundness": null,
   "seed": 1278240551,
   "seed": 1278240551,
   "startArrowhead": null,
   "startArrowhead": null,
   "startBinding": null,
   "startBinding": null,
@@ -117,9 +115,7 @@ exports[`select single element on the scene > diamond 1`] = `
   "locked": false,
   "locked": false,
   "opacity": 100,
   "opacity": 100,
   "roughness": 1,
   "roughness": 1,
-  "roundness": {
-    "type": 2,
-  },
+  "roundness": null,
   "seed": 1278240551,
   "seed": 1278240551,
   "strokeColor": "#1e1e1e",
   "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeStyle": "solid",
@@ -151,9 +147,7 @@ exports[`select single element on the scene > ellipse 1`] = `
   "locked": false,
   "locked": false,
   "opacity": 100,
   "opacity": 100,
   "roughness": 1,
   "roughness": 1,
-  "roundness": {
-    "type": 2,
-  },
+  "roundness": null,
   "seed": 1278240551,
   "seed": 1278240551,
   "strokeColor": "#1e1e1e",
   "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeStyle": "solid",
@@ -185,9 +179,7 @@ exports[`select single element on the scene > rectangle 1`] = `
   "locked": false,
   "locked": false,
   "opacity": 100,
   "opacity": 100,
   "roughness": 1,
   "roughness": 1,
-  "roundness": {
-    "type": 3,
-  },
+  "roundness": null,
   "seed": 1278240551,
   "seed": 1278240551,
   "strokeColor": "#1e1e1e",
   "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeStyle": "solid",

+ 10 - 10
packages/excalidraw/tests/contextmenu.test.tsx

@@ -110,8 +110,8 @@ describe("contextMenu element", () => {
 
 
   it("shows context menu for element", () => {
   it("shows context menu for element", () => {
     UI.clickTool("rectangle");
     UI.clickTool("rectangle");
-    mouse.down(10, 10);
-    mouse.up(20, 20);
+    mouse.down(0, 0);
+    mouse.up(10, 10);
 
 
     fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
     fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
       button: 2,
       button: 2,
@@ -304,8 +304,8 @@ describe("contextMenu element", () => {
 
 
   it("selecting 'Copy styles' in context menu copies styles", () => {
   it("selecting 'Copy styles' in context menu copies styles", () => {
     UI.clickTool("rectangle");
     UI.clickTool("rectangle");
-    mouse.down(10, 10);
-    mouse.up(20, 20);
+    mouse.down(0, 0);
+    mouse.up(10, 10);
 
 
     fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
     fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
       button: 2,
       button: 2,
@@ -389,8 +389,8 @@ describe("contextMenu element", () => {
 
 
   it("selecting 'Delete' in context menu deletes element", () => {
   it("selecting 'Delete' in context menu deletes element", () => {
     UI.clickTool("rectangle");
     UI.clickTool("rectangle");
-    mouse.down(10, 10);
-    mouse.up(20, 20);
+    mouse.down(0, 0);
+    mouse.up(10, 10);
 
 
     fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
     fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
       button: 2,
       button: 2,
@@ -405,8 +405,8 @@ describe("contextMenu element", () => {
 
 
   it("selecting 'Add to library' in context menu adds element to library", async () => {
   it("selecting 'Add to library' in context menu adds element to library", async () => {
     UI.clickTool("rectangle");
     UI.clickTool("rectangle");
-    mouse.down(10, 10);
-    mouse.up(20, 20);
+    mouse.down(0, 0);
+    mouse.up(10, 10);
 
 
     fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
     fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
       button: 2,
       button: 2,
@@ -424,8 +424,8 @@ describe("contextMenu element", () => {
 
 
   it("selecting 'Duplicate' in context menu duplicates element", () => {
   it("selecting 'Duplicate' in context menu duplicates element", () => {
     UI.clickTool("rectangle");
     UI.clickTool("rectangle");
-    mouse.down(10, 10);
-    mouse.up(20, 20);
+    mouse.down(0, 0);
+    mouse.up(10, 10);
 
 
     fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
     fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
       button: 2,
       button: 2,

+ 6 - 18
packages/excalidraw/tests/data/__snapshots__/restore.test.ts.snap

@@ -31,9 +31,7 @@ exports[`restoreElements > should restore arrow element correctly 1`] = `
     ],
     ],
   ],
   ],
   "roughness": 1,
   "roughness": 1,
-  "roundness": {
-    "type": 2,
-  },
+  "roundness": null,
   "seed": Any<Number>,
   "seed": Any<Number>,
   "startArrowhead": null,
   "startArrowhead": null,
   "startBinding": null,
   "startBinding": null,
@@ -193,9 +191,7 @@ exports[`restoreElements > should restore freedraw element correctly 1`] = `
   ],
   ],
   "pressures": [],
   "pressures": [],
   "roughness": 1,
   "roughness": 1,
-  "roundness": {
-    "type": 3,
-  },
+  "roundness": null,
   "seed": Any<Number>,
   "seed": Any<Number>,
   "simulatePressure": true,
   "simulatePressure": true,
   "strokeColor": "#1e1e1e",
   "strokeColor": "#1e1e1e",
@@ -242,9 +238,7 @@ exports[`restoreElements > should restore line and draw elements correctly 1`] =
   ],
   ],
   "polygon": false,
   "polygon": false,
   "roughness": 1,
   "roughness": 1,
-  "roundness": {
-    "type": 2,
-  },
+  "roundness": null,
   "seed": Any<Number>,
   "seed": Any<Number>,
   "startArrowhead": null,
   "startArrowhead": null,
   "startBinding": null,
   "startBinding": null,
@@ -292,9 +286,7 @@ exports[`restoreElements > should restore line and draw elements correctly 2`] =
   ],
   ],
   "polygon": false,
   "polygon": false,
   "roughness": 1,
   "roughness": 1,
-  "roundness": {
-    "type": 2,
-  },
+  "roundness": null,
   "seed": Any<Number>,
   "seed": Any<Number>,
   "startArrowhead": null,
   "startArrowhead": null,
   "startBinding": null,
   "startBinding": null,
@@ -334,9 +326,7 @@ exports[`restoreElements > should restore text element correctly passing value f
   "opacity": 100,
   "opacity": 100,
   "originalText": "text",
   "originalText": "text",
   "roughness": 1,
   "roughness": 1,
-  "roundness": {
-    "type": 3,
-  },
+  "roundness": null,
   "seed": Any<Number>,
   "seed": Any<Number>,
   "strokeColor": "#1e1e1e",
   "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeStyle": "solid",
@@ -378,9 +368,7 @@ exports[`restoreElements > should restore text element correctly with unknown fo
   "opacity": 100,
   "opacity": 100,
   "originalText": "",
   "originalText": "",
   "roughness": 1,
   "roughness": 1,
-  "roundness": {
-    "type": 3,
-  },
+  "roundness": null,
   "seed": Any<Number>,
   "seed": Any<Number>,
   "strokeColor": "#1e1e1e",
   "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeStyle": "solid",

+ 22 - 4
packages/excalidraw/tests/helpers/ui.ts

@@ -32,6 +32,7 @@ import type {
   ExcalidrawTextContainer,
   ExcalidrawTextContainer,
   ExcalidrawTextElementWithContainer,
   ExcalidrawTextElementWithContainer,
   ExcalidrawImageElement,
   ExcalidrawImageElement,
+  ElementsMap,
 } from "@excalidraw/element/types";
 } from "@excalidraw/element/types";
 
 
 import { createTestHook } from "../../components/App";
 import { createTestHook } from "../../components/App";
@@ -146,6 +147,7 @@ export class Keyboard {
 
 
 const getElementPointForSelection = (
 const getElementPointForSelection = (
   element: ExcalidrawElement,
   element: ExcalidrawElement,
+  elementsMap: ElementsMap,
 ): GlobalPoint => {
 ): GlobalPoint => {
   const { x, y, width, angle } = element;
   const { x, y, width, angle } = element;
   const target = pointFrom<GlobalPoint>(
   const target = pointFrom<GlobalPoint>(
@@ -162,7 +164,7 @@ const getElementPointForSelection = (
       (bounds[1] + bounds[3]) / 2,
       (bounds[1] + bounds[3]) / 2,
     );
     );
   } else {
   } else {
-    center = elementCenterPoint(element);
+    center = elementCenterPoint(element, elementsMap);
   }
   }
 
 
   if (isTextElement(element)) {
   if (isTextElement(element)) {
@@ -299,7 +301,12 @@ export class Pointer {
       elements = Array.isArray(elements) ? elements : [elements];
       elements = Array.isArray(elements) ? elements : [elements];
       elements.forEach((element) => {
       elements.forEach((element) => {
         this.reset();
         this.reset();
-        this.click(...getElementPointForSelection(element));
+        this.click(
+          ...getElementPointForSelection(
+            element,
+            h.app.scene.getElementsMapIncludingDeleted(),
+          ),
+        );
       });
       });
     });
     });
 
 
@@ -308,13 +315,23 @@ export class Pointer {
 
 
   clickOn(element: ExcalidrawElement) {
   clickOn(element: ExcalidrawElement) {
     this.reset();
     this.reset();
-    this.click(...getElementPointForSelection(element));
+    this.click(
+      ...getElementPointForSelection(
+        element,
+        h.app.scene.getElementsMapIncludingDeleted(),
+      ),
+    );
     this.reset();
     this.reset();
   }
   }
 
 
   doubleClickOn(element: ExcalidrawElement) {
   doubleClickOn(element: ExcalidrawElement) {
     this.reset();
     this.reset();
-    this.doubleClick(...getElementPointForSelection(element));
+    this.doubleClick(
+      ...getElementPointForSelection(
+        element,
+        h.app.scene.getElementsMapIncludingDeleted(),
+      ),
+    );
     this.reset();
     this.reset();
   }
   }
 }
 }
@@ -598,6 +615,7 @@ export class UI {
 
 
     const mutations = cropElement(
     const mutations = cropElement(
       element,
       element,
+      h.scene.getNonDeletedElementsMap(),
       handle,
       handle,
       naturalWidth,
       naturalWidth,
       naturalHeight,
       naturalHeight,

+ 1 - 0
packages/excalidraw/tests/lasso.test.tsx

@@ -70,6 +70,7 @@ const updatePath = (startPoint: GlobalPoint, points: LocalPoint[]) => {
           ?.originalPoints?.map((p) => pointFrom<GlobalPoint>(p[0], p[1])) ??
           ?.originalPoints?.map((p) => pointFrom<GlobalPoint>(p[0], p[1])) ??
         [],
         [],
       elements: h.elements,
       elements: h.elements,
+      elementsMap: h.scene.getNonDeletedElementsMap(),
       elementsSegments,
       elementsSegments,
       intersectedElements: new Set(),
       intersectedElements: new Set(),
       enclosedElements: new Set(),
       enclosedElements: new Set(),

+ 2 - 2
packages/excalidraw/tests/move.test.tsx

@@ -124,8 +124,8 @@ describe("move element", () => {
     expect(h.state.selectedElementIds[rectB.id]).toBeTruthy();
     expect(h.state.selectedElementIds[rectB.id]).toBeTruthy();
     expect([rectA.x, rectA.y]).toEqual([0, 0]);
     expect([rectA.x, rectA.y]).toEqual([0, 0]);
     expect([rectB.x, rectB.y]).toEqual([201, 2]);
     expect([rectB.x, rectB.y]).toEqual([201, 2]);
-    expect([[arrow.x, arrow.y]]).toCloselyEqualPoints([[107.07, 47.07]]);
-    expect([[arrow.width, arrow.height]]).toCloselyEqualPoints([[86.86, 87.3]]);
+    expect([[arrow.x, arrow.y]]).toCloselyEqualPoints([[110, 50]]);
+    expect([[arrow.width, arrow.height]]).toCloselyEqualPoints([[81, 81.4]]);
 
 
     h.elements.forEach((element) => expect(element).toMatchSnapshot());
     h.elements.forEach((element) => expect(element).toMatchSnapshot());
   });
   });

+ 1 - 1
packages/excalidraw/tests/rotate.test.tsx

@@ -35,7 +35,7 @@ test("unselected bound arrow updates when rotating its target element", async ()
   expect(arrow.endBinding?.elementId).toEqual(rectangle.id);
   expect(arrow.endBinding?.elementId).toEqual(rectangle.id);
   expect(arrow.x).toBeCloseTo(-80);
   expect(arrow.x).toBeCloseTo(-80);
   expect(arrow.y).toBeCloseTo(50);
   expect(arrow.y).toBeCloseTo(50);
-  expect(arrow.width).toBeCloseTo(116.7, 1);
+  expect(arrow.width).toBeCloseTo(110.7, 1);
   expect(arrow.height).toBeCloseTo(0);
   expect(arrow.height).toBeCloseTo(0);
 });
 });
 
 

+ 2 - 4
packages/excalidraw/wysiwyg/textWysiwyg.test.tsx

@@ -682,7 +682,7 @@ describe("textWysiwyg", () => {
       expect(diamond.height).toBe(70);
       expect(diamond.height).toBe(70);
     });
     });
 
 
-    it("should bind text to container when double clicked on center of transparent container", async () => {
+    it("should bind text to container when double clicked inside of the transparent container", async () => {
       const rectangle = API.createElement({
       const rectangle = API.createElement({
         type: "rectangle",
         type: "rectangle",
         x: 10,
         x: 10,
@@ -1500,9 +1500,7 @@ describe("textWysiwyg", () => {
           locked: false,
           locked: false,
           opacity: 100,
           opacity: 100,
           roughness: 1,
           roughness: 1,
-          roundness: {
-            type: 3,
-          },
+          roundness: null,
           strokeColor: "#1e1e1e",
           strokeColor: "#1e1e1e",
           strokeStyle: "solid",
           strokeStyle: "solid",
           strokeWidth: 2,
           strokeWidth: 2,

+ 117 - 14
packages/math/src/curve.ts

@@ -1,8 +1,7 @@
-import type { Bounds } from "@excalidraw/element";
+import { doBoundsIntersect, type Bounds } from "@excalidraw/element";
 
 
-import { isPoint, pointDistance, pointFrom } from "./point";
-import { rectangle, rectangleIntersectLineSegment } from "./rectangle";
-import { vector } from "./vector";
+import { isPoint, pointDistance, pointFrom, pointFromVector } from "./point";
+import { vector, vectorNormal, vectorNormalize, vectorScale } from "./vector";
 
 
 import type { Curve, GlobalPoint, LineSegment, LocalPoint } from "./types";
 import type { Curve, GlobalPoint, LineSegment, LocalPoint } from "./types";
 
 
@@ -105,16 +104,15 @@ export function curveIntersectLineSegment<
   Point extends GlobalPoint | LocalPoint,
   Point extends GlobalPoint | LocalPoint,
 >(c: Curve<Point>, l: LineSegment<Point>): Point[] {
 >(c: Curve<Point>, l: LineSegment<Point>): Point[] {
   // Optimize by doing a cheap bounding box check first
   // Optimize by doing a cheap bounding box check first
-  const bounds = curveBounds(c);
-  if (
-    rectangleIntersectLineSegment(
-      rectangle(
-        pointFrom(bounds[0], bounds[1]),
-        pointFrom(bounds[2], bounds[3]),
-      ),
-      l,
-    ).length === 0
-  ) {
+  const b1 = curveBounds(c);
+  const b2 = [
+    Math.min(l[0][0], l[1][0]),
+    Math.min(l[0][1], l[1][1]),
+    Math.max(l[0][0], l[1][0]),
+    Math.max(l[0][1], l[1][1]),
+  ] as Bounds;
+
+  if (!doBoundsIntersect(b1, b2)) {
     return [];
     return [];
   }
   }
 
 
@@ -303,3 +301,108 @@ function curveBounds<Point extends GlobalPoint | LocalPoint>(
   const y = [P0[1], P1[1], P2[1], P3[1]];
   const y = [P0[1], P1[1], P2[1], P3[1]];
   return [Math.min(...x), Math.min(...y), Math.max(...x), Math.max(...y)];
   return [Math.min(...x), Math.min(...y), Math.max(...x), Math.max(...y)];
 }
 }
+
+export function curveCatmullRomQuadraticApproxPoints(
+  points: GlobalPoint[],
+  tension = 0.5,
+) {
+  if (points.length < 2) {
+    return;
+  }
+
+  const pointSets: [GlobalPoint, GlobalPoint][] = [];
+  for (let i = 0; i < points.length - 1; i++) {
+    const p0 = points[i - 1 < 0 ? 0 : i - 1];
+    const p1 = points[i];
+    const p2 = points[i + 1 >= points.length ? points.length - 1 : i + 1];
+    const cpX = p1[0] + ((p2[0] - p0[0]) * tension) / 2;
+    const cpY = p1[1] + ((p2[1] - p0[1]) * tension) / 2;
+
+    pointSets.push([
+      pointFrom<GlobalPoint>(cpX, cpY),
+      pointFrom<GlobalPoint>(p2[0], p2[1]),
+    ]);
+  }
+
+  return pointSets;
+}
+
+export function curveCatmullRomCubicApproxPoints<
+  Point extends GlobalPoint | LocalPoint,
+>(points: Point[], tension = 0.5) {
+  if (points.length < 2) {
+    return;
+  }
+
+  const pointSets: Curve<Point>[] = [];
+  for (let i = 0; i < points.length - 1; i++) {
+    const p0 = points[i - 1 < 0 ? 0 : i - 1];
+    const p1 = points[i];
+    const p2 = points[i + 1 >= points.length ? points.length - 1 : i + 1];
+    const p3 = points[i + 2 >= points.length ? points.length - 1 : i + 2];
+    const tangent1 = [(p2[0] - p0[0]) * tension, (p2[1] - p0[1]) * tension];
+    const tangent2 = [(p3[0] - p1[0]) * tension, (p3[1] - p1[1]) * tension];
+    const cp1x = p1[0] + tangent1[0] / 3;
+    const cp1y = p1[1] + tangent1[1] / 3;
+    const cp2x = p2[0] - tangent2[0] / 3;
+    const cp2y = p2[1] - tangent2[1] / 3;
+
+    pointSets.push(
+      curve(
+        pointFrom(p1[0], p1[1]),
+        pointFrom(cp1x, cp1y),
+        pointFrom(cp2x, cp2y),
+        pointFrom(p2[0], p2[1]),
+      ),
+    );
+  }
+
+  return pointSets;
+}
+
+export function curveOffsetPoints(
+  [p0, p1, p2, p3]: Curve<GlobalPoint>,
+  offset: number,
+  steps = 50,
+) {
+  const offsetPoints = [];
+
+  for (let i = 0; i <= steps; i++) {
+    const t = i / steps;
+    const c = curve(p0, p1, p2, p3);
+    const point = bezierEquation(c, t);
+    const tangent = vectorNormalize(curveTangent(c, t));
+    const normal = vectorNormal(tangent);
+
+    offsetPoints.push(pointFromVector(vectorScale(normal, offset), point));
+  }
+
+  return offsetPoints;
+}
+
+export function offsetPointsForQuadraticBezier(
+  p0: GlobalPoint,
+  p1: GlobalPoint,
+  p2: GlobalPoint,
+  offsetDist: number,
+  steps = 50,
+) {
+  const offsetPoints = [];
+
+  for (let i = 0; i <= steps; i++) {
+    const t = i / steps;
+    const t1 = 1 - t;
+    const point = pointFrom<GlobalPoint>(
+      t1 * t1 * p0[0] + 2 * t1 * t * p1[0] + t * t * p2[0],
+      t1 * t1 * p0[1] + 2 * t1 * t * p1[1] + t * t * p2[1],
+    );
+    const tangentX = 2 * (1 - t) * (p1[0] - p0[0]) + 2 * t * (p2[0] - p1[0]);
+    const tangentY = 2 * (1 - t) * (p1[1] - p0[1]) + 2 * t * (p2[1] - p1[1]);
+    const tangent = vectorNormalize(vector(tangentX, tangentY));
+    const normal = vectorNormal(tangent);
+
+    offsetPoints.push(pointFromVector(vectorScale(normal, offsetDist), point));
+  }
+
+  return offsetPoints;
+}

+ 1 - 0
packages/math/src/index.ts

@@ -1,5 +1,6 @@
 export * from "./angle";
 export * from "./angle";
 export * from "./curve";
 export * from "./curve";
+export * from "./ellipse";
 export * from "./line";
 export * from "./line";
 export * from "./point";
 export * from "./point";
 export * from "./polygon";
 export * from "./polygon";

+ 12 - 2
packages/math/src/vector.ts

@@ -21,13 +21,23 @@ export function vector(
  *
  *
  * @param p The point to turn into a vector
  * @param p The point to turn into a vector
  * @param origin The origin point in a given coordiante system
  * @param origin The origin point in a given coordiante system
- * @returns The created vector from the point and the origin
+ * @param threshold The threshold to consider the vector as 'undefined'
+ * @param defaultValue The default value to return if the vector is 'undefined'
+ * @returns The created vector from the point and the origin or default
  */
  */
 export function vectorFromPoint<Point extends GlobalPoint | LocalPoint>(
 export function vectorFromPoint<Point extends GlobalPoint | LocalPoint>(
   p: Point,
   p: Point,
   origin: Point = [0, 0] as Point,
   origin: Point = [0, 0] as Point,
+  threshold?: number,
+  defaultValue: Vector = [0, 1] as Vector,
 ): Vector {
 ): Vector {
-  return vector(p[0] - origin[0], p[1] - origin[1]);
+  const vec = vector(p[0] - origin[0], p[1] - origin[1]);
+
+  if (threshold && vectorMagnitudeSq(vec) < threshold * threshold) {
+    return defaultValue;
+  }
+
+  return vec;
 }
 }
 
 
 /**
 /**

+ 0 - 135
packages/utils/src/collision.ts

@@ -1,135 +0,0 @@
-import {
-  lineSegment,
-  pointFrom,
-  polygonIncludesPoint,
-  pointOnLineSegment,
-  pointOnPolygon,
-  polygonFromPoints,
-  type GlobalPoint,
-  type LocalPoint,
-  type Polygon,
-} from "@excalidraw/math";
-
-import type { Curve } from "@excalidraw/math";
-
-import { pointInEllipse, pointOnEllipse } from "./shape";
-
-import type { Polycurve, Polyline, GeometricShape } from "./shape";
-
-// check if the given point is considered on the given shape's border
-export const isPointOnShape = <Point extends GlobalPoint | LocalPoint>(
-  point: Point,
-  shape: GeometricShape<Point>,
-  tolerance = 0,
-) => {
-  // get the distance from the given point to the given element
-  // check if the distance is within the given epsilon range
-  switch (shape.type) {
-    case "polygon":
-      return pointOnPolygon(point, shape.data, tolerance);
-    case "ellipse":
-      return pointOnEllipse(point, shape.data, tolerance);
-    case "line":
-      return pointOnLineSegment(point, shape.data, tolerance);
-    case "polyline":
-      return pointOnPolyline(point, shape.data, tolerance);
-    case "curve":
-      return pointOnCurve(point, shape.data, tolerance);
-    case "polycurve":
-      return pointOnPolycurve(point, shape.data, tolerance);
-    default:
-      throw Error(`shape ${shape} is not implemented`);
-  }
-};
-
-// check if the given point is considered inside the element's border
-export const isPointInShape = <Point extends GlobalPoint | LocalPoint>(
-  point: Point,
-  shape: GeometricShape<Point>,
-) => {
-  switch (shape.type) {
-    case "polygon":
-      return polygonIncludesPoint(point, shape.data);
-    case "line":
-      return false;
-    case "curve":
-      return false;
-    case "ellipse":
-      return pointInEllipse(point, shape.data);
-    case "polyline": {
-      const polygon = polygonFromPoints(shape.data.flat());
-      return polygonIncludesPoint(point, polygon);
-    }
-    case "polycurve": {
-      return false;
-    }
-    default:
-      throw Error(`shape ${shape} is not implemented`);
-  }
-};
-
-// check if the given element is in the given bounds
-export const isPointInBounds = <Point extends GlobalPoint | LocalPoint>(
-  point: Point,
-  bounds: Polygon<Point>,
-) => {
-  return polygonIncludesPoint(point, bounds);
-};
-
-const pointOnPolycurve = <Point extends LocalPoint | GlobalPoint>(
-  point: Point,
-  polycurve: Polycurve<Point>,
-  tolerance: number,
-) => {
-  return polycurve.some((curve) => pointOnCurve(point, curve, tolerance));
-};
-
-const cubicBezierEquation = <Point extends LocalPoint | GlobalPoint>(
-  curve: Curve<Point>,
-) => {
-  const [p0, p1, p2, p3] = curve;
-  // B(t) = p0 * (1-t)^3 + 3p1 * t * (1-t)^2 + 3p2 * t^2 * (1-t) + p3 * t^3
-  return (t: number, idx: number) =>
-    Math.pow(1 - t, 3) * p3[idx] +
-    3 * t * Math.pow(1 - t, 2) * p2[idx] +
-    3 * Math.pow(t, 2) * (1 - t) * p1[idx] +
-    p0[idx] * Math.pow(t, 3);
-};
-
-const polyLineFromCurve = <Point extends LocalPoint | GlobalPoint>(
-  curve: Curve<Point>,
-  segments = 10,
-): Polyline<Point> => {
-  const equation = cubicBezierEquation(curve);
-  let startingPoint = [equation(0, 0), equation(0, 1)] as Point;
-  const lineSegments: Polyline<Point> = [];
-  let t = 0;
-  const increment = 1 / segments;
-
-  for (let i = 0; i < segments; i++) {
-    t += increment;
-    if (t <= 1) {
-      const nextPoint: Point = pointFrom(equation(t, 0), equation(t, 1));
-      lineSegments.push(lineSegment(startingPoint, nextPoint));
-      startingPoint = nextPoint;
-    }
-  }
-
-  return lineSegments;
-};
-
-export const pointOnCurve = <Point extends LocalPoint | GlobalPoint>(
-  point: Point,
-  curve: Curve<Point>,
-  threshold: number,
-) => {
-  return pointOnPolyline(point, polyLineFromCurve(curve), threshold);
-};
-
-export const pointOnPolyline = <Point extends LocalPoint | GlobalPoint>(
-  point: Point,
-  polyline: Polyline<Point>,
-  threshold = 10e-5,
-) => {
-  return polyline.some((line) => pointOnLineSegment(point, line, threshold));
-};

+ 1 - 1
packages/utils/tests/__snapshots__/export.test.ts.snap

@@ -24,7 +24,7 @@ exports[`exportToSvg > with default arguments 1`] = `
   "currentItemFontSize": 20,
   "currentItemFontSize": 20,
   "currentItemOpacity": 100,
   "currentItemOpacity": 100,
   "currentItemRoughness": 1,
   "currentItemRoughness": 1,
-  "currentItemRoundness": "round",
+  "currentItemRoundness": "sharp",
   "currentItemStartArrowhead": null,
   "currentItemStartArrowhead": null,
   "currentItemStrokeColor": "#1e1e1e",
   "currentItemStrokeColor": "#1e1e1e",
   "currentItemStrokeStyle": "solid",
   "currentItemStrokeStyle": "solid",

+ 0 - 90
packages/utils/tests/collision.test.ts

@@ -1,90 +0,0 @@
-import {
-  curve,
-  degreesToRadians,
-  lineSegment,
-  lineSegmentRotate,
-  pointFrom,
-  pointRotateDegs,
-} from "@excalidraw/math";
-
-import type { Curve, Degrees, GlobalPoint } from "@excalidraw/math";
-
-import { pointOnCurve, pointOnPolyline } from "../src/collision";
-
-import type { Polyline } from "../src/shape";
-
-describe("point and curve", () => {
-  const c: Curve<GlobalPoint> = curve(
-    pointFrom(1.4, 1.65),
-    pointFrom(1.9, 7.9),
-    pointFrom(5.9, 1.65),
-    pointFrom(6.44, 4.84),
-  );
-
-  it("point on curve", () => {
-    expect(pointOnCurve(c[0], c, 10e-5)).toBe(true);
-    expect(pointOnCurve(c[3], c, 10e-5)).toBe(true);
-
-    expect(pointOnCurve(pointFrom(2, 4), c, 0.1)).toBe(true);
-    expect(pointOnCurve(pointFrom(4, 4.4), c, 0.1)).toBe(true);
-    expect(pointOnCurve(pointFrom(5.6, 3.85), c, 0.1)).toBe(true);
-
-    expect(pointOnCurve(pointFrom(5.6, 4), c, 0.1)).toBe(false);
-    expect(pointOnCurve(c[1], c, 0.1)).toBe(false);
-    expect(pointOnCurve(c[2], c, 0.1)).toBe(false);
-  });
-});
-
-describe("point and polylines", () => {
-  const polyline: Polyline<GlobalPoint> = [
-    lineSegment(pointFrom(1, 0), pointFrom(1, 2)),
-    lineSegment(pointFrom(1, 2), pointFrom(2, 2)),
-    lineSegment(pointFrom(2, 2), pointFrom(2, 1)),
-    lineSegment(pointFrom(2, 1), pointFrom(3, 1)),
-  ];
-
-  it("point on the line", () => {
-    expect(pointOnPolyline(pointFrom(1, 0), polyline)).toBe(true);
-    expect(pointOnPolyline(pointFrom(1, 2), polyline)).toBe(true);
-    expect(pointOnPolyline(pointFrom(2, 2), polyline)).toBe(true);
-    expect(pointOnPolyline(pointFrom(2, 1), polyline)).toBe(true);
-    expect(pointOnPolyline(pointFrom(3, 1), polyline)).toBe(true);
-
-    expect(pointOnPolyline(pointFrom(1, 1), polyline)).toBe(true);
-    expect(pointOnPolyline(pointFrom(2, 1.5), polyline)).toBe(true);
-    expect(pointOnPolyline(pointFrom(2.5, 1), polyline)).toBe(true);
-
-    expect(pointOnPolyline(pointFrom(0, 1), polyline)).toBe(false);
-    expect(pointOnPolyline(pointFrom(2.1, 1.5), polyline)).toBe(false);
-  });
-
-  it("point on the line with rotation", () => {
-    const truePoints = [
-      pointFrom(1, 0),
-      pointFrom(1, 2),
-      pointFrom(2, 2),
-      pointFrom(2, 1),
-      pointFrom(3, 1),
-    ];
-
-    truePoints.forEach((p) => {
-      const rotation = (Math.random() * 360) as Degrees;
-      const rotatedPoint = pointRotateDegs(p, pointFrom(0, 0), rotation);
-      const rotatedPolyline = polyline.map((line) =>
-        lineSegmentRotate(line, degreesToRadians(rotation), pointFrom(0, 0)),
-      );
-      expect(pointOnPolyline(rotatedPoint, rotatedPolyline)).toBe(true);
-    });
-
-    const falsePoints = [pointFrom(0, 1), pointFrom(2.1, 1.5)];
-
-    falsePoints.forEach((p) => {
-      const rotation = (Math.random() * 360) as Degrees;
-      const rotatedPoint = pointRotateDegs(p, pointFrom(0, 0), rotation);
-      const rotatedPolyline = polyline.map((line) =>
-        lineSegmentRotate(line, degreesToRadians(rotation), pointFrom(0, 0)),
-      );
-      expect(pointOnPolyline(rotatedPoint, rotatedPolyline)).toBe(false);
-    });
-  });
-});

Kaikkia tiedostoja ei voida näyttää, sillä liian monta tiedostoa muuttui tässä diffissä