Sfoglia il codice sorgente

refactor: update collision from ga to vector geometry (#7636)

* new collision api

* isPointOnShape

* removed redundant code

* new collision methods in app

* curve shape takes starting point

* clean up geometry

* curve rotation

* freedraw

* inside curve

* improve ellipse inside check

* ellipse distance func

* curve inside

* include frame name bounds

* replace previous private methods for getting elements at x,y

* arrow bound text hit detection

* keep iframes on top

* remove dependence on old collision methods from app

* remove old collision functions

* move some hit functions outside of app

* code refactor

* type

* text collision from inside

* fix context menu test

* highest z-index collision

* fix 1px away binding test

* strictly less

* remove unused imports

* lint

* 'ignore' resize flipping test

* more lint fix

* skip 'flips while resizing' test

* more test

* fix merge errors

* fix selection in resize test

* added a bit more comment

---------

Co-authored-by: dwelle <[email protected]>
Ryan Di 1 anno fa
parent
commit
bbdcd30a73

+ 4 - 14
packages/excalidraw/actions/actionFinalize.tsx

@@ -8,7 +8,6 @@ import { register } from "./register";
 import { mutateElement } from "../element/mutateElement";
 import { mutateElement } from "../element/mutateElement";
 import { isPathALoop } from "../math";
 import { isPathALoop } from "../math";
 import { LinearElementEditor } from "../element/linearElementEditor";
 import { LinearElementEditor } from "../element/linearElementEditor";
-import Scene from "../scene/Scene";
 import {
 import {
   maybeBindLinearElement,
   maybeBindLinearElement,
   bindOrUnbindLinearElement,
   bindOrUnbindLinearElement,
@@ -21,12 +20,9 @@ export const actionFinalize = register({
   name: "finalize",
   name: "finalize",
   label: "",
   label: "",
   trackEvent: false,
   trackEvent: false,
-  perform: (
-    elements,
-    appState,
-    _,
-    { interactiveCanvas, focusContainer, scene },
-  ) => {
+  perform: (elements, appState, _, app) => {
+    const { interactiveCanvas, focusContainer, scene } = app;
+
     const elementsMap = scene.getNonDeletedElementsMap();
     const elementsMap = scene.getNonDeletedElementsMap();
 
 
     if (appState.editingLinearElement) {
     if (appState.editingLinearElement) {
@@ -131,13 +127,7 @@ export const actionFinalize = register({
           -1,
           -1,
           arrayToMap(elements),
           arrayToMap(elements),
         );
         );
-        maybeBindLinearElement(
-          multiPointElement,
-          appState,
-          Scene.getScene(multiPointElement)!,
-          { x, y },
-          elementsMap,
-        );
+        maybeBindLinearElement(multiPointElement, appState, { x, y }, app);
       }
       }
     }
     }
 
 

+ 7 - 2
packages/excalidraw/actions/actionFlip.ts

@@ -7,7 +7,7 @@ import {
   NonDeletedSceneElementsMap,
   NonDeletedSceneElementsMap,
 } from "../element/types";
 } from "../element/types";
 import { resizeMultipleElements } from "../element/resizeElements";
 import { resizeMultipleElements } from "../element/resizeElements";
-import { AppState } from "../types";
+import { AppClassProperties, AppState } from "../types";
 import { arrayToMap } from "../utils";
 import { arrayToMap } from "../utils";
 import { CODES, KEYS } from "../keys";
 import { CODES, KEYS } from "../keys";
 import { getCommonBoundingBox } from "../element/bounds";
 import { getCommonBoundingBox } from "../element/bounds";
@@ -32,6 +32,7 @@ export const actionFlipHorizontal = register({
           app.scene.getNonDeletedElementsMap(),
           app.scene.getNonDeletedElementsMap(),
           appState,
           appState,
           "horizontal",
           "horizontal",
+          app,
         ),
         ),
         appState,
         appState,
         app,
         app,
@@ -56,6 +57,7 @@ export const actionFlipVertical = register({
           app.scene.getNonDeletedElementsMap(),
           app.scene.getNonDeletedElementsMap(),
           appState,
           appState,
           "vertical",
           "vertical",
+          app,
         ),
         ),
         appState,
         appState,
         app,
         app,
@@ -73,6 +75,7 @@ const flipSelectedElements = (
   elementsMap: NonDeletedSceneElementsMap,
   elementsMap: NonDeletedSceneElementsMap,
   appState: Readonly<AppState>,
   appState: Readonly<AppState>,
   flipDirection: "horizontal" | "vertical",
   flipDirection: "horizontal" | "vertical",
+  app: AppClassProperties,
 ) => {
 ) => {
   const selectedElements = getSelectedElements(
   const selectedElements = getSelectedElements(
     getNonDeletedElements(elements),
     getNonDeletedElements(elements),
@@ -89,6 +92,7 @@ const flipSelectedElements = (
     elementsMap,
     elementsMap,
     appState,
     appState,
     flipDirection,
     flipDirection,
+    app,
   );
   );
 
 
   const updatedElementsMap = arrayToMap(updatedElements);
   const updatedElementsMap = arrayToMap(updatedElements);
@@ -104,6 +108,7 @@ const flipElements = (
   elementsMap: NonDeletedSceneElementsMap,
   elementsMap: NonDeletedSceneElementsMap,
   appState: AppState,
   appState: AppState,
   flipDirection: "horizontal" | "vertical",
   flipDirection: "horizontal" | "vertical",
+  app: AppClassProperties,
 ): ExcalidrawElement[] => {
 ): ExcalidrawElement[] => {
   const { minX, minY, maxX, maxY } = getCommonBoundingBox(selectedElements);
   const { minX, minY, maxX, maxY } = getCommonBoundingBox(selectedElements);
 
 
@@ -118,7 +123,7 @@ const flipElements = (
   );
   );
 
 
   isBindingEnabled(appState)
   isBindingEnabled(appState)
-    ? bindOrUnbindSelectedElements(selectedElements, elements, elementsMap)
+    ? bindOrUnbindSelectedElements(selectedElements, app)
     : unbindLinearElements(selectedElements, elementsMap);
     : unbindLinearElements(selectedElements, elementsMap);
 
 
   return selectedElements;
   return selectedElements;

+ 263 - 123
packages/excalidraw/components/App.tsx

@@ -107,8 +107,6 @@ import {
   getResizeOffsetXY,
   getResizeOffsetXY,
   getLockedLinearCursorAlignSize,
   getLockedLinearCursorAlignSize,
   getTransformHandleTypeFromCoords,
   getTransformHandleTypeFromCoords,
-  hitTest,
-  isHittingElementBoundingBoxWithoutHittingElement,
   isInvisiblySmallElement,
   isInvisiblySmallElement,
   isNonDeletedElement,
   isNonDeletedElement,
   isTextElement,
   isTextElement,
@@ -119,6 +117,7 @@ import {
   transformElements,
   transformElements,
   updateTextElement,
   updateTextElement,
   redrawTextBoundingBox,
   redrawTextBoundingBox,
+  getElementAbsoluteCoords,
 } from "../element";
 } from "../element";
 import {
 import {
   bindOrUnbindLinearElement,
   bindOrUnbindLinearElement,
@@ -162,6 +161,7 @@ import {
   isIframeElement,
   isIframeElement,
   isIframeLikeElement,
   isIframeLikeElement,
   isMagicFrameElement,
   isMagicFrameElement,
+  isTextBindableContainer,
 } from "../element/typeChecks";
 } from "../element/typeChecks";
 import {
 import {
   ExcalidrawBindableElement,
   ExcalidrawBindableElement,
@@ -212,7 +212,6 @@ import {
 } from "../math";
 } from "../math";
 import {
 import {
   calculateScrollCenter,
   calculateScrollCenter,
-  getElementsAtPosition,
   getElementsWithinSelection,
   getElementsWithinSelection,
   getNormalizedZoom,
   getNormalizedZoom,
   getSelectedElements,
   getSelectedElements,
@@ -223,6 +222,15 @@ import Scene from "../scene/Scene";
 import { RenderInteractiveSceneCallback, ScrollBars } from "../scene/types";
 import { RenderInteractiveSceneCallback, ScrollBars } from "../scene/types";
 import { getStateForZoom } from "../scene/zoom";
 import { getStateForZoom } from "../scene/zoom";
 import { findShapeByKey } from "../shapes";
 import { findShapeByKey } from "../shapes";
+import {
+  GeometricShape,
+  getClosedCurveShape,
+  getCurveShape,
+  getEllipseShape,
+  getFreedrawShape,
+  getPolygonShape,
+} from "../../utils/geometry/shape";
+import { isPointInShape } from "../../utils/collision";
 import {
 import {
   AppClassProperties,
   AppClassProperties,
   AppProps,
   AppProps,
@@ -318,11 +326,9 @@ import {
   getContainerElement,
   getContainerElement,
   getDefaultLineHeight,
   getDefaultLineHeight,
   getLineHeightInPx,
   getLineHeightInPx,
-  getTextBindableContainerAtPosition,
   isMeasureTextSupported,
   isMeasureTextSupported,
   isValidTextContainer,
   isValidTextContainer,
 } from "../element/textElement";
 } from "../element/textElement";
-import { isHittingElementNotConsideringBoundingBox } from "../element/collision";
 import {
 import {
   showHyperlinkTooltip,
   showHyperlinkTooltip,
   hideHyperlinkToolip,
   hideHyperlinkToolip,
@@ -407,6 +413,13 @@ import { AnimatedTrail } from "../animated-trail";
 import { LaserTrails } from "../laser-trails";
 import { LaserTrails } from "../laser-trails";
 import { withBatchedUpdates, withBatchedUpdatesThrottled } from "../reactUtils";
 import { withBatchedUpdates, withBatchedUpdatesThrottled } from "../reactUtils";
 import { getRenderOpacity } from "../renderer/renderElement";
 import { getRenderOpacity } from "../renderer/renderElement";
+import {
+  hitElementBoundText,
+  hitElementBoundingBox,
+  hitElementBoundingBoxOnly,
+  hitElementItself,
+  shouldTestInside,
+} from "../element/collision";
 import { textWysiwyg } from "../element/textWysiwyg";
 import { textWysiwyg } from "../element/textWysiwyg";
 import { isOverScrollBars } from "../scene/scrollbars";
 import { isOverScrollBars } from "../scene/scrollbars";
 import {
 import {
@@ -2757,7 +2770,6 @@ class App extends React.Component<AppProps, AppState> {
       maybeBindLinearElement(
       maybeBindLinearElement(
         multiElement,
         multiElement,
         this.state,
         this.state,
-        this.scene,
         tupleToCoors(
         tupleToCoors(
           LinearElementEditor.getPointAtIndexGlobalCoordinates(
           LinearElementEditor.getPointAtIndexGlobalCoordinates(
             multiElement,
             multiElement,
@@ -2765,7 +2777,7 @@ class App extends React.Component<AppProps, AppState> {
             elementsMap,
             elementsMap,
           ),
           ),
         ),
         ),
-        elementsMap,
+        this,
       );
       );
     }
     }
     this.history.record(this.state, elements);
     this.history.record(this.state, elements);
@@ -4048,11 +4060,7 @@ class App extends React.Component<AppProps, AppState> {
       const selectedElements = this.scene.getSelectedElements(this.state);
       const selectedElements = this.scene.getSelectedElements(this.state);
       const elementsMap = this.scene.getNonDeletedElementsMap();
       const elementsMap = this.scene.getNonDeletedElementsMap();
       isBindingEnabled(this.state)
       isBindingEnabled(this.state)
-        ? bindOrUnbindSelectedElements(
-            selectedElements,
-            this.scene.getNonDeletedElements(),
-            elementsMap,
-          )
+        ? bindOrUnbindSelectedElements(selectedElements, this)
         : unbindLinearElements(selectedElements, elementsMap);
         : unbindLinearElements(selectedElements, elementsMap);
       this.setState({ suggestedBindings: [] });
       this.setState({ suggestedBindings: [] });
     }
     }
@@ -4355,12 +4363,87 @@ class App extends React.Component<AppProps, AppState> {
     return null;
     return null;
   }
   }
 
 
+  /**
+   * get the pure geometric shape of an excalidraw element
+   * which is then used for hit detection
+   */
+  public getElementShape(element: ExcalidrawElement): GeometricShape {
+    switch (element.type) {
+      case "rectangle":
+      case "diamond":
+      case "frame":
+      case "magicframe":
+      case "embeddable":
+      case "image":
+      case "iframe":
+      case "text":
+      case "selection":
+        return getPolygonShape(element);
+      case "arrow":
+      case "line": {
+        const roughShape =
+          ShapeCache.get(element)?.[0] ??
+          ShapeCache.generateElementShape(element, null)[0];
+        const [, , , , cx, cy] = getElementAbsoluteCoords(
+          element,
+          this.scene.getNonDeletedElementsMap(),
+        );
+
+        return shouldTestInside(element)
+          ? getClosedCurveShape(
+              roughShape,
+              [element.x, element.y],
+              element.angle,
+              [cx, cy],
+            )
+          : getCurveShape(roughShape, [element.x, element.y], element.angle, [
+              cx,
+              cy,
+            ]);
+      }
+
+      case "ellipse":
+        return getEllipseShape(element);
+
+      case "freedraw": {
+        const [, , , , cx, cy] = getElementAbsoluteCoords(
+          element,
+          this.scene.getNonDeletedElementsMap(),
+        );
+        return getFreedrawShape(element, [cx, cy], shouldTestInside(element));
+      }
+    }
+  }
+
+  private getBoundTextShape(element: ExcalidrawElement): GeometricShape | null {
+    const boundTextElement = getBoundTextElement(
+      element,
+      this.scene.getNonDeletedElementsMap(),
+    );
+
+    if (boundTextElement) {
+      if (element.type === "arrow") {
+        return this.getElementShape({
+          ...boundTextElement,
+          // 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,
+            boundTextElement,
+            this.scene.getNonDeletedElementsMap(),
+          ),
+        });
+      }
+      return this.getElementShape(boundTextElement);
+    }
+
+    return null;
+  }
+
   private getElementAtPosition(
   private getElementAtPosition(
     x: number,
     x: number,
     y: number,
     y: number,
     opts?: {
     opts?: {
-      /** if true, returns the first selected element (with highest z-index)
-        of all hit elements */
       preferSelected?: boolean;
       preferSelected?: boolean;
       includeBoundTextElement?: boolean;
       includeBoundTextElement?: boolean;
       includeLockedElements?: boolean;
       includeLockedElements?: boolean;
@@ -4372,6 +4455,7 @@ class App extends React.Component<AppProps, AppState> {
       opts?.includeBoundTextElement,
       opts?.includeBoundTextElement,
       opts?.includeLockedElements,
       opts?.includeLockedElements,
     );
     );
+
     if (allHitElements.length > 1) {
     if (allHitElements.length > 1) {
       if (opts?.preferSelected) {
       if (opts?.preferSelected) {
         for (let index = allHitElements.length - 1; index > -1; index--) {
         for (let index = allHitElements.length - 1; index > -1; index--) {
@@ -4382,22 +4466,20 @@ class App extends React.Component<AppProps, AppState> {
       }
       }
       const elementWithHighestZIndex =
       const elementWithHighestZIndex =
         allHitElements[allHitElements.length - 1];
         allHitElements[allHitElements.length - 1];
+
       // 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 isHittingElementBoundingBoxWithoutHittingElement(
-        elementWithHighestZIndex,
-        this.state,
-        this.frameNameBoundsCache,
-        x,
-        y,
-        this.scene.getNonDeletedElementsMap(),
+      return isPointInShape(
+        [x, y],
+        this.getElementShape(elementWithHighestZIndex),
       )
       )
-        ? allHitElements[allHitElements.length - 2]
-        : elementWithHighestZIndex;
+        ? elementWithHighestZIndex
+        : allHitElements[allHitElements.length - 2];
     }
     }
     if (allHitElements.length === 1) {
     if (allHitElements.length === 1) {
       return allHitElements[0];
       return allHitElements[0];
     }
     }
+
     return null;
     return null;
   }
   }
 
 
@@ -4407,7 +4489,11 @@ class App extends React.Component<AppProps, AppState> {
     includeBoundTextElement: boolean = false,
     includeBoundTextElement: boolean = false,
     includeLockedElements: boolean = false,
     includeLockedElements: boolean = false,
   ): NonDeleted<ExcalidrawElement>[] {
   ): NonDeleted<ExcalidrawElement>[] {
-    const elements =
+    const iframeLikes: ExcalidrawIframeElement[] = [];
+
+    const elementsMap = this.scene.getNonDeletedElementsMap();
+
+    const elements = (
       includeBoundTextElement && includeLockedElements
       includeBoundTextElement && includeLockedElements
         ? this.scene.getNonDeletedElements()
         ? this.scene.getNonDeletedElements()
         : this.scene
         : this.scene
@@ -4417,29 +4503,120 @@ class App extends React.Component<AppProps, AppState> {
                 (includeLockedElements || !element.locked) &&
                 (includeLockedElements || !element.locked) &&
                 (includeBoundTextElement ||
                 (includeBoundTextElement ||
                   !(isTextElement(element) && element.containerId)),
                   !(isTextElement(element) && element.containerId)),
-            );
+            )
+    )
+      .filter((el) => this.hitElement(x, y, el))
+      .filter((element) => {
+        // hitting a frame's element from outside the frame is not considered a hit
+        const containingFrame = getContainingFrame(element, elementsMap);
+        return containingFrame &&
+          this.state.frameRendering.enabled &&
+          this.state.frameRendering.clip
+          ? isCursorInFrame({ x, y }, containingFrame, elementsMap)
+          : true;
+      })
+      .filter((el) => {
+        // The parameter elements comes ordered from lower z-index to higher.
+        // We want to preserve that order on the returned array.
+        // Exception being embeddables which should be on top of everything else in
+        // terms of hit testing.
+        if (isIframeElement(el)) {
+          iframeLikes.push(el);
+          return false;
+        }
+        return true;
+      })
+      .concat(iframeLikes) as NonDeleted<ExcalidrawElement>[];
 
 
-    const elementsMap = this.scene.getNonDeletedElementsMap();
-    return getElementsAtPosition(elements, (element) =>
-      hitTest(
-        element,
-        this.state,
-        this.frameNameBoundsCache,
+    return elements;
+  }
+
+  private getHitThreshold() {
+    return 10 / this.state.zoom.value;
+  }
+
+  private hitElement(
+    x: number,
+    y: number,
+    element: ExcalidrawElement,
+    considerBoundingBox = true,
+  ) {
+    // if the element is selected, then hit test is done against its bounding box
+    if (
+      considerBoundingBox &&
+      this.state.selectedElementIds[element.id] &&
+      shouldShowBoundingBox([element], this.state)
+    ) {
+      return hitElementBoundingBox(
         x,
         x,
         y,
         y,
-        elementsMap,
-      ),
-    ).filter((element) => {
-      // hitting a frame's element from outside the frame is not considered a hit
-      const containingFrame = getContainingFrame(element, elementsMap);
-      return containingFrame &&
-        this.state.frameRendering.enabled &&
-        this.state.frameRendering.clip
-        ? isCursorInFrame({ x, y }, containingFrame, elementsMap)
-        : true;
+        element,
+        this.scene.getNonDeletedElementsMap(),
+        this.getHitThreshold(),
+      );
+    }
+
+    // take bound text element into consideration for hit collision as well
+    const hitBoundTextOfElement = hitElementBoundText(
+      x,
+      y,
+      this.getBoundTextShape(element),
+    );
+    if (hitBoundTextOfElement) {
+      return true;
+    }
+
+    return hitElementItself({
+      x,
+      y,
+      element,
+      shape: this.getElementShape(element),
+      threshold: this.getHitThreshold(),
+      frameNameBound: isFrameLikeElement(element)
+        ? this.frameNameBoundsCache.get(element)
+        : null,
     });
     });
   }
   }
 
 
+  private getTextBindableContainerAtPosition(x: number, y: number) {
+    const elements = this.scene.getNonDeletedElements();
+    const selectedElements = this.scene.getSelectedElements(this.state);
+    if (selectedElements.length === 1) {
+      return isTextBindableContainer(selectedElements[0], false)
+        ? selectedElements[0]
+        : null;
+    }
+    let hitElement = null;
+    // We need to do hit testing from front (end of the array) to back (beginning of the array)
+    for (let index = elements.length - 1; index >= 0; --index) {
+      if (elements[index].isDeleted) {
+        continue;
+      }
+      const [x1, y1, x2, y2] = getElementAbsoluteCoords(
+        elements[index],
+        this.scene.getNonDeletedElementsMap(),
+      );
+      if (
+        isArrowElement(elements[index]) &&
+        hitElementItself({
+          x,
+          y,
+          element: elements[index],
+          shape: this.getElementShape(elements[index]),
+          threshold: this.getHitThreshold(),
+        })
+      ) {
+        hitElement = elements[index];
+        break;
+      } else if (x1 < x && x < x2 && y1 < y && y < y2) {
+        hitElement = elements[index];
+        break;
+      }
+    }
+
+    return isTextBindableContainer(hitElement, false) ? hitElement : null;
+  }
+
   private startTextEditing = ({
   private startTextEditing = ({
     sceneX,
     sceneX,
     sceneY,
     sceneY,
@@ -4667,25 +4844,19 @@ class App extends React.Component<AppProps, AppState> {
         return;
         return;
       }
       }
 
 
-      const container = getTextBindableContainerAtPosition(
-        this.scene.getNonDeletedElements(),
-        this.state,
-        sceneX,
-        sceneY,
-        this.scene.getNonDeletedElementsMap(),
-      );
+      const container = this.getTextBindableContainerAtPosition(sceneX, sceneY);
 
 
       if (container) {
       if (container) {
         if (
         if (
           hasBoundTextElement(container) ||
           hasBoundTextElement(container) ||
           !isTransparent(container.backgroundColor) ||
           !isTransparent(container.backgroundColor) ||
-          isHittingElementNotConsideringBoundingBox(
-            container,
-            this.state,
-            this.frameNameBoundsCache,
-            [sceneX, sceneY],
-            this.scene.getNonDeletedElementsMap(),
-          )
+          hitElementItself({
+            x: sceneX,
+            y: sceneY,
+            element: container,
+            shape: this.getElementShape(container),
+            threshold: this.getHitThreshold(),
+          })
         ) {
         ) {
           const midPoint = getContainerCenter(
           const midPoint = getContainerCenter(
             container,
             container,
@@ -5281,7 +5452,7 @@ class App extends React.Component<AppProps, AppState> {
       scenePointer.x,
       scenePointer.x,
       scenePointer.y,
       scenePointer.y,
     );
     );
-    const threshold = 10 / this.state.zoom.value;
+    const threshold = this.getHitThreshold();
     const point = { ...pointerDownState.lastCoords };
     const point = { ...pointerDownState.lastCoords };
     let samplingInterval = 0;
     let samplingInterval = 0;
     while (samplingInterval <= distance) {
     while (samplingInterval <= distance) {
@@ -5346,7 +5517,6 @@ class App extends React.Component<AppProps, AppState> {
       linearElementEditor.elementId,
       linearElementEditor.elementId,
       elementsMap,
       elementsMap,
     );
     );
-    const boundTextElement = getBoundTextElement(element, elementsMap);
 
 
     if (!element) {
     if (!element) {
       return;
       return;
@@ -5355,13 +5525,12 @@ class App extends React.Component<AppProps, AppState> {
       let hoverPointIndex = -1;
       let hoverPointIndex = -1;
       let segmentMidPointHoveredCoords = null;
       let segmentMidPointHoveredCoords = null;
       if (
       if (
-        isHittingElementNotConsideringBoundingBox(
+        hitElementItself({
+          x: scenePointerX,
+          y: scenePointerY,
           element,
           element,
-          this.state,
-          this.frameNameBoundsCache,
-          [scenePointerX, scenePointerY],
-          elementsMap,
-        )
+          shape: this.getElementShape(element),
+        })
       ) {
       ) {
         hoverPointIndex = LinearElementEditor.getPointIndexUnderCursor(
         hoverPointIndex = LinearElementEditor.getPointIndexUnderCursor(
           element,
           element,
@@ -5383,29 +5552,7 @@ class App extends React.Component<AppProps, AppState> {
         } else {
         } else {
           setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
           setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
         }
         }
-      } else if (
-        shouldShowBoundingBox([element], this.state) &&
-        isHittingElementBoundingBoxWithoutHittingElement(
-          element,
-          this.state,
-          this.frameNameBoundsCache,
-          scenePointerX,
-          scenePointerY,
-          elementsMap,
-        )
-      ) {
-        setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
-      } else if (
-        boundTextElement &&
-        hitTest(
-          boundTextElement,
-          this.state,
-          this.frameNameBoundsCache,
-          scenePointerX,
-          scenePointerY,
-          this.scene.getNonDeletedElementsMap(),
-        )
-      ) {
+      } else if (this.hitElement(scenePointerX, scenePointerY, element)) {
         setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
         setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
       }
       }
 
 
@@ -6159,8 +6306,7 @@ class App extends React.Component<AppProps, AppState> {
             this.history,
             this.history,
             pointerDownState.origin,
             pointerDownState.origin,
             linearElementEditor,
             linearElementEditor,
-            this.scene.getNonDeletedElements(),
-            elementsMap,
+            this,
           );
           );
           if (ret.hitElement) {
           if (ret.hitElement) {
             pointerDownState.hit.element = ret.hitElement;
             pointerDownState.hit.element = ret.hitElement;
@@ -6383,7 +6529,7 @@ 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 = 10 / this.state.zoom.value;
+    const threshold = this.getHitThreshold();
     const [x1, y1, x2, y2] = getCommonBounds(selectedElements);
     const [x1, y1, x2, y2] = getCommonBounds(selectedElements);
     return (
     return (
       point.x > x1 - threshold &&
       point.x > x1 - threshold &&
@@ -6411,13 +6557,7 @@ class App extends React.Component<AppProps, AppState> {
     });
     });
 
 
     // FIXME
     // FIXME
-    let container = getTextBindableContainerAtPosition(
-      this.scene.getNonDeletedElements(),
-      this.state,
-      sceneX,
-      sceneY,
-      this.scene.getNonDeletedElementsMap(),
-    );
+    let container = this.getTextBindableContainerAtPosition(sceneX, sceneY);
 
 
     if (hasBoundTextElement(element)) {
     if (hasBoundTextElement(element)) {
       container = element as ExcalidrawTextContainer;
       container = element as ExcalidrawTextContainer;
@@ -6497,8 +6637,7 @@ class App extends React.Component<AppProps, AppState> {
 
 
     const boundElement = getHoveredElementForBinding(
     const boundElement = getHoveredElementForBinding(
       pointerDownState.origin,
       pointerDownState.origin,
-      this.scene.getNonDeletedElements(),
-      this.scene.getNonDeletedElementsMap(),
+      this,
     );
     );
     this.scene.addNewElement(element);
     this.scene.addNewElement(element);
     this.setState({
     this.setState({
@@ -6766,8 +6905,7 @@ class App extends React.Component<AppProps, AppState> {
       });
       });
       const boundElement = getHoveredElementForBinding(
       const boundElement = getHoveredElementForBinding(
         pointerDownState.origin,
         pointerDownState.origin,
-        this.scene.getNonDeletedElements(),
-        this.scene.getNonDeletedElementsMap(),
+        this,
       );
       );
 
 
       this.scene.addNewElement(element);
       this.scene.addNewElement(element);
@@ -7551,7 +7689,6 @@ class App extends React.Component<AppProps, AppState> {
             ? this.state.editingElement
             ? this.state.editingElement
             : null,
             : null,
         snapLines: updateStable(prevState.snapLines, []),
         snapLines: updateStable(prevState.snapLines, []),
-
         originSnapOffset: null,
         originSnapOffset: null,
       }));
       }));
 
 
@@ -7578,8 +7715,7 @@ class App extends React.Component<AppProps, AppState> {
             childEvent,
             childEvent,
             this.state.editingLinearElement,
             this.state.editingLinearElement,
             this.state,
             this.state,
-            this.scene.getNonDeletedElements(),
-            elementsMap,
+            this,
           );
           );
           if (editingLinearElement !== this.state.editingLinearElement) {
           if (editingLinearElement !== this.state.editingLinearElement) {
             this.setState({
             this.setState({
@@ -7603,8 +7739,7 @@ class App extends React.Component<AppProps, AppState> {
             childEvent,
             childEvent,
             this.state.selectedLinearElement,
             this.state.selectedLinearElement,
             this.state,
             this.state,
-            this.scene.getNonDeletedElements(),
-            elementsMap,
+            this,
           );
           );
 
 
           const { startBindingElement, endBindingElement } =
           const { startBindingElement, endBindingElement } =
@@ -7753,9 +7888,8 @@ class App extends React.Component<AppProps, AppState> {
             maybeBindLinearElement(
             maybeBindLinearElement(
               draggingElement,
               draggingElement,
               this.state,
               this.state,
-              this.scene,
               pointerCoords,
               pointerCoords,
-              elementsMap,
+              this,
             );
             );
           }
           }
           this.setState({ suggestedBindings: [], startBoundElement: null });
           this.setState({ suggestedBindings: [], startBoundElement: null });
@@ -8207,16 +8341,24 @@ class App extends React.Component<AppProps, AppState> {
       }
       }
 
 
       if (
       if (
+        // not dragged
         !pointerDownState.drag.hasOccurred &&
         !pointerDownState.drag.hasOccurred &&
+        // not resized
         !this.state.isResizing &&
         !this.state.isResizing &&
+        // only hitting the bounding box of the previous hit element
         ((hitElement &&
         ((hitElement &&
-          isHittingElementBoundingBoxWithoutHittingElement(
-            hitElement,
-            this.state,
-            this.frameNameBoundsCache,
-            pointerDownState.origin.x,
-            pointerDownState.origin.y,
-            this.scene.getNonDeletedElementsMap(),
+          hitElementBoundingBoxOnly(
+            {
+              x: pointerDownState.origin.x,
+              y: pointerDownState.origin.y,
+              element: hitElement,
+              shape: this.getElementShape(hitElement),
+              threshold: this.getHitThreshold(),
+              frameNameBound: isFrameLikeElement(hitElement)
+                ? this.frameNameBoundsCache.get(hitElement)
+                : null,
+            },
+            elementsMap,
           )) ||
           )) ||
           (!hitElement &&
           (!hitElement &&
             pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements))
             pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements))
@@ -8232,6 +8374,8 @@ class App extends React.Component<AppProps, AppState> {
             activeEmbeddable: null,
             activeEmbeddable: null,
           });
           });
         }
         }
+        // reset cursor
+        setCursor(this.interactiveCanvas, CURSOR_TYPE.AUTO);
         return;
         return;
       }
       }
 
 
@@ -8267,11 +8411,10 @@ class App extends React.Component<AppProps, AppState> {
         isBindingEnabled(this.state)
         isBindingEnabled(this.state)
           ? bindOrUnbindSelectedElements(
           ? bindOrUnbindSelectedElements(
               this.scene.getSelectedElements(this.state),
               this.scene.getSelectedElements(this.state),
-              this.scene.getNonDeletedElements(),
-              elementsMap,
+              this,
             )
             )
           : unbindLinearElements(
           : unbindLinearElements(
-              this.scene.getSelectedElements(this.state),
+              this.scene.getNonDeletedElements(),
               elementsMap,
               elementsMap,
             );
             );
       }
       }
@@ -8758,8 +8901,7 @@ class App extends React.Component<AppProps, AppState> {
   }): void => {
   }): void => {
     const hoveredBindableElement = getHoveredElementForBinding(
     const hoveredBindableElement = getHoveredElementForBinding(
       pointerCoords,
       pointerCoords,
-      this.scene.getNonDeletedElements(),
-      this.scene.getNonDeletedElementsMap(),
+      this,
     );
     );
     this.setState({
     this.setState({
       suggestedBindings:
       suggestedBindings:
@@ -8786,8 +8928,7 @@ class App extends React.Component<AppProps, AppState> {
       (acc: NonDeleted<ExcalidrawBindableElement>[], coords) => {
       (acc: NonDeleted<ExcalidrawBindableElement>[], coords) => {
         const hoveredBindableElement = getHoveredElementForBinding(
         const hoveredBindableElement = getHoveredElementForBinding(
           coords,
           coords,
-          this.scene.getNonDeletedElements(),
-          this.scene.getNonDeletedElementsMap(),
+          this,
         );
         );
         if (
         if (
           hoveredBindableElement != null &&
           hoveredBindableElement != null &&
@@ -8815,8 +8956,7 @@ class App extends React.Component<AppProps, AppState> {
     }
     }
     const suggestedBindings = getEligibleElementsForBinding(
     const suggestedBindings = getEligibleElementsForBinding(
       selectedElements,
       selectedElements,
-      this.scene.getNonDeletedElements(),
-      this.scene.getNonDeletedElementsMap(),
+      this,
     );
     );
     this.setState({ suggestedBindings });
     this.setState({ suggestedBindings });
   }
   }

+ 3 - 11
packages/excalidraw/components/hyperlink/Hyperlink.tsx

@@ -26,9 +26,9 @@ import clsx from "clsx";
 import { KEYS } from "../../keys";
 import { KEYS } from "../../keys";
 import { EVENT, HYPERLINK_TOOLTIP_DELAY } from "../../constants";
 import { EVENT, HYPERLINK_TOOLTIP_DELAY } from "../../constants";
 import { getElementAbsoluteCoords } from "../../element/bounds";
 import { getElementAbsoluteCoords } from "../../element/bounds";
-import { getTooltipDiv, updateTooltipPosition } from "../Tooltip";
+import { getTooltipDiv, updateTooltipPosition } from "../../components/Tooltip";
 import { getSelectedElements } from "../../scene";
 import { getSelectedElements } from "../../scene";
-import { isPointHittingElementBoundingBox } from "../../element/collision";
+import { hitElementBoundingBox } from "../../element/collision";
 import { isLocalLink, normalizeLink } from "../../data/url";
 import { isLocalLink, normalizeLink } from "../../data/url";
 
 
 import "./Hyperlink.scss";
 import "./Hyperlink.scss";
@@ -425,15 +425,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 (
-    isPointHittingElementBoundingBox(
-      element,
-      elementsMap,
-      [sceneX, sceneY],
-      threshold,
-      null,
-    )
-  ) {
+  if (hitElementBoundingBox(sceneX, sceneY, element, elementsMap)) {
     return false;
     return false;
   }
   }
   const [x1, y1, x2] = getElementAbsoluteCoords(element, elementsMap);
   const [x1, y1, x2] = getElementAbsoluteCoords(element, elementsMap);

+ 2 - 9
packages/excalidraw/components/hyperlink/helpers.ts

@@ -1,6 +1,6 @@
 import { MIME_TYPES } from "../../constants";
 import { MIME_TYPES } from "../../constants";
 import { Bounds, getElementAbsoluteCoords } from "../../element/bounds";
 import { Bounds, getElementAbsoluteCoords } from "../../element/bounds";
-import { isPointHittingElementBoundingBox } from "../../element/collision";
+import { hitElementBoundingBox } from "../../element/collision";
 import { ElementsMap, NonDeletedExcalidrawElement } from "../../element/types";
 import { ElementsMap, NonDeletedExcalidrawElement } from "../../element/types";
 import { rotate } from "../../math";
 import { rotate } from "../../math";
 import { DEFAULT_LINK_SIZE } from "../../renderer/renderElement";
 import { DEFAULT_LINK_SIZE } from "../../renderer/renderElement";
@@ -75,17 +75,10 @@ export const isPointHittingLink = (
   if (!element.link || appState.selectedElementIds[element.id]) {
   if (!element.link || appState.selectedElementIds[element.id]) {
     return false;
     return false;
   }
   }
-  const threshold = 4 / appState.zoom.value;
   if (
   if (
     !isMobile &&
     !isMobile &&
     appState.viewModeEnabled &&
     appState.viewModeEnabled &&
-    isPointHittingElementBoundingBox(
-      element,
-      elementsMap,
-      [x, y],
-      threshold,
-      null,
-    )
+    hitElementBoundingBox(x, y, element, elementsMap)
   ) {
   ) {
     return true;
     return true;
   }
   }

+ 616 - 80
packages/excalidraw/element/binding.ts

@@ -1,28 +1,37 @@
+import * as GA from "../ga";
+import * as GAPoint from "../gapoints";
+import * as GADirection from "../gadirections";
+import * as GALine from "../galines";
+import * as GATransform from "../gatransforms";
+
 import {
 import {
-  ExcalidrawLinearElement,
   ExcalidrawBindableElement,
   ExcalidrawBindableElement,
+  ExcalidrawElement,
+  ExcalidrawRectangleElement,
+  ExcalidrawDiamondElement,
+  ExcalidrawTextElement,
+  ExcalidrawEllipseElement,
+  ExcalidrawFreeDrawElement,
+  ExcalidrawImageElement,
+  ExcalidrawFrameLikeElement,
+  ExcalidrawIframeLikeElement,
   NonDeleted,
   NonDeleted,
-  NonDeletedExcalidrawElement,
+  ExcalidrawLinearElement,
   PointBinding,
   PointBinding,
-  ExcalidrawElement,
+  NonDeletedExcalidrawElement,
   ElementsMap,
   ElementsMap,
   NonDeletedSceneElementsMap,
   NonDeletedSceneElementsMap,
 } from "./types";
 } from "./types";
+
+import { getElementAbsoluteCoords } from "./bounds";
+import { AppClassProperties, AppState, Point } from "../types";
+import { isPointOnShape } from "../../utils/collision";
 import { getElementAtPosition } from "../scene";
 import { getElementAtPosition } from "../scene";
-import { AppState } from "../types";
 import {
 import {
   isBindableElement,
   isBindableElement,
   isBindingElement,
   isBindingElement,
   isLinearElement,
   isLinearElement,
 } from "./typeChecks";
 } from "./typeChecks";
-import {
-  bindingBorderTest,
-  distanceToBindableElement,
-  maxBindingGap,
-  determineFocusDistance,
-  intersectElementWithLine,
-  determineFocusPoint,
-} from "./collision";
 import { mutateElement } from "./mutateElement";
 import { mutateElement } from "./mutateElement";
 import Scene from "../scene/Scene";
 import Scene from "../scene/Scene";
 import { LinearElementEditor } from "./linearElementEditor";
 import { LinearElementEditor } from "./linearElementEditor";
@@ -152,29 +161,22 @@ const bindOrUnbindLinearElementEdge = (
 
 
 export const bindOrUnbindSelectedElements = (
 export const bindOrUnbindSelectedElements = (
   selectedElements: NonDeleted<ExcalidrawElement>[],
   selectedElements: NonDeleted<ExcalidrawElement>[],
-  elements: readonly ExcalidrawElement[],
-  elementsMap: NonDeletedSceneElementsMap,
+  app: AppClassProperties,
 ): void => {
 ): void => {
   selectedElements.forEach((selectedElement) => {
   selectedElements.forEach((selectedElement) => {
     if (isBindingElement(selectedElement)) {
     if (isBindingElement(selectedElement)) {
       bindOrUnbindLinearElement(
       bindOrUnbindLinearElement(
         selectedElement,
         selectedElement,
-        getElligibleElementForBindingElement(
-          selectedElement,
-          "start",
-          elements,
-          elementsMap,
-        ),
-        getElligibleElementForBindingElement(
-          selectedElement,
-          "end",
-          elements,
-          elementsMap,
-        ),
-        elementsMap,
+        getElligibleElementForBindingElement(selectedElement, "start", app),
+        getElligibleElementForBindingElement(selectedElement, "end", app),
+        app.scene.getNonDeletedElementsMap(),
       );
       );
     } else if (isBindableElement(selectedElement)) {
     } else if (isBindableElement(selectedElement)) {
-      maybeBindBindableElement(selectedElement, elementsMap);
+      maybeBindBindableElement(
+        selectedElement,
+        app.scene.getNonDeletedElementsMap(),
+        app,
+      );
     }
     }
   });
   });
 };
 };
@@ -182,40 +184,34 @@ export const bindOrUnbindSelectedElements = (
 const maybeBindBindableElement = (
 const maybeBindBindableElement = (
   bindableElement: NonDeleted<ExcalidrawBindableElement>,
   bindableElement: NonDeleted<ExcalidrawBindableElement>,
   elementsMap: NonDeletedSceneElementsMap,
   elementsMap: NonDeletedSceneElementsMap,
+  app: AppClassProperties,
 ): void => {
 ): void => {
-  getElligibleElementsForBindableElementAndWhere(
-    bindableElement,
-    elementsMap,
-  ).forEach(([linearElement, where]) =>
-    bindOrUnbindLinearElement(
-      linearElement,
-      where === "end" ? "keep" : bindableElement,
-      where === "start" ? "keep" : bindableElement,
-      elementsMap,
-    ),
+  getElligibleElementsForBindableElementAndWhere(bindableElement, app).forEach(
+    ([linearElement, where]) =>
+      bindOrUnbindLinearElement(
+        linearElement,
+        where === "end" ? "keep" : bindableElement,
+        where === "start" ? "keep" : bindableElement,
+        elementsMap,
+      ),
   );
   );
 };
 };
 
 
 export const maybeBindLinearElement = (
 export const maybeBindLinearElement = (
   linearElement: NonDeleted<ExcalidrawLinearElement>,
   linearElement: NonDeleted<ExcalidrawLinearElement>,
   appState: AppState,
   appState: AppState,
-  scene: Scene,
   pointerCoords: { x: number; y: number },
   pointerCoords: { x: number; y: number },
-  elementsMap: NonDeletedSceneElementsMap,
+  app: AppClassProperties,
 ): void => {
 ): void => {
   if (appState.startBoundElement != null) {
   if (appState.startBoundElement != null) {
     bindLinearElement(
     bindLinearElement(
       linearElement,
       linearElement,
       appState.startBoundElement,
       appState.startBoundElement,
       "start",
       "start",
-      elementsMap,
+      app.scene.getNonDeletedElementsMap(),
     );
     );
   }
   }
-  const hoveredElement = getHoveredElementForBinding(
-    pointerCoords,
-    scene.getNonDeletedElements(),
-    elementsMap,
-  );
+  const hoveredElement = getHoveredElementForBinding(pointerCoords, app);
   if (
   if (
     hoveredElement != null &&
     hoveredElement != null &&
     !isLinearElementSimpleAndAlreadyBoundOnOppositeEdge(
     !isLinearElementSimpleAndAlreadyBoundOnOppositeEdge(
@@ -224,7 +220,12 @@ export const maybeBindLinearElement = (
       "end",
       "end",
     )
     )
   ) {
   ) {
-    bindLinearElement(linearElement, hoveredElement, "end", elementsMap);
+    bindLinearElement(
+      linearElement,
+      hoveredElement,
+      "end",
+      app.scene.getNonDeletedElementsMap(),
+    );
   }
   }
 };
 };
 
 
@@ -283,7 +284,7 @@ export const isLinearElementSimpleAndAlreadyBound = (
 };
 };
 
 
 export const unbindLinearElements = (
 export const unbindLinearElements = (
-  elements: NonDeleted<ExcalidrawElement>[],
+  elements: readonly NonDeleted<ExcalidrawElement>[],
   elementsMap: NonDeletedSceneElementsMap,
   elementsMap: NonDeletedSceneElementsMap,
 ): void => {
 ): void => {
   elements.forEach((element) => {
   elements.forEach((element) => {
@@ -311,14 +312,13 @@ export const getHoveredElementForBinding = (
     x: number;
     x: number;
     y: number;
     y: number;
   },
   },
-  elements: readonly NonDeletedExcalidrawElement[],
-  elementsMap: NonDeletedSceneElementsMap,
+  app: AppClassProperties,
 ): NonDeleted<ExcalidrawBindableElement> | null => {
 ): NonDeleted<ExcalidrawBindableElement> | null => {
   const hoveredElement = getElementAtPosition(
   const hoveredElement = getElementAtPosition(
-    elements,
+    app.scene.getNonDeletedElements(),
     (element) =>
     (element) =>
       isBindableElement(element, false) &&
       isBindableElement(element, false) &&
-      bindingBorderTest(element, pointerCoords, elementsMap),
+      bindingBorderTest(element, pointerCoords, app),
   );
   );
   return hoveredElement as NonDeleted<ExcalidrawBindableElement> | null;
   return hoveredElement as NonDeleted<ExcalidrawBindableElement> | null;
 };
 };
@@ -547,23 +547,21 @@ const maybeCalculateNewGapWhenScaling = (
 // TODO: this is a bottleneck, optimise
 // TODO: this is a bottleneck, optimise
 export const getEligibleElementsForBinding = (
 export const getEligibleElementsForBinding = (
   selectedElements: NonDeleted<ExcalidrawElement>[],
   selectedElements: NonDeleted<ExcalidrawElement>[],
-  elements: readonly ExcalidrawElement[],
-  elementsMap: NonDeletedSceneElementsMap,
+  app: AppClassProperties,
 ): SuggestedBinding[] => {
 ): SuggestedBinding[] => {
   const includedElementIds = new Set(selectedElements.map(({ id }) => id));
   const includedElementIds = new Set(selectedElements.map(({ id }) => id));
   return selectedElements.flatMap((selectedElement) =>
   return selectedElements.flatMap((selectedElement) =>
     isBindingElement(selectedElement, false)
     isBindingElement(selectedElement, false)
       ? (getElligibleElementsForBindingElement(
       ? (getElligibleElementsForBindingElement(
           selectedElement as NonDeleted<ExcalidrawLinearElement>,
           selectedElement as NonDeleted<ExcalidrawLinearElement>,
-          elements,
-          elementsMap,
+          app,
         ).filter(
         ).filter(
           (element) => !includedElementIds.has(element.id),
           (element) => !includedElementIds.has(element.id),
         ) as SuggestedBinding[])
         ) as SuggestedBinding[])
       : isBindableElement(selectedElement, false)
       : isBindableElement(selectedElement, false)
       ? getElligibleElementsForBindableElementAndWhere(
       ? getElligibleElementsForBindableElementAndWhere(
           selectedElement,
           selectedElement,
-          elementsMap,
+          app,
         ).filter((binding) => !includedElementIds.has(binding[0].id))
         ).filter((binding) => !includedElementIds.has(binding[0].id))
       : [],
       : [],
   );
   );
@@ -571,22 +569,11 @@ export const getEligibleElementsForBinding = (
 
 
 const getElligibleElementsForBindingElement = (
 const getElligibleElementsForBindingElement = (
   linearElement: NonDeleted<ExcalidrawLinearElement>,
   linearElement: NonDeleted<ExcalidrawLinearElement>,
-  elements: readonly ExcalidrawElement[],
-  elementsMap: NonDeletedSceneElementsMap,
+  app: AppClassProperties,
 ): NonDeleted<ExcalidrawBindableElement>[] => {
 ): NonDeleted<ExcalidrawBindableElement>[] => {
   return [
   return [
-    getElligibleElementForBindingElement(
-      linearElement,
-      "start",
-      elements,
-      elementsMap,
-    ),
-    getElligibleElementForBindingElement(
-      linearElement,
-      "end",
-      elements,
-      elementsMap,
-    ),
+    getElligibleElementForBindingElement(linearElement, "start", app),
+    getElligibleElementForBindingElement(linearElement, "end", app),
   ].filter(
   ].filter(
     (element): element is NonDeleted<ExcalidrawBindableElement> =>
     (element): element is NonDeleted<ExcalidrawBindableElement> =>
       element != null,
       element != null,
@@ -596,13 +583,15 @@ const getElligibleElementsForBindingElement = (
 const getElligibleElementForBindingElement = (
 const getElligibleElementForBindingElement = (
   linearElement: NonDeleted<ExcalidrawLinearElement>,
   linearElement: NonDeleted<ExcalidrawLinearElement>,
   startOrEnd: "start" | "end",
   startOrEnd: "start" | "end",
-  elements: readonly ExcalidrawElement[],
-  elementsMap: NonDeletedSceneElementsMap,
+  app: AppClassProperties,
 ): NonDeleted<ExcalidrawBindableElement> | null => {
 ): NonDeleted<ExcalidrawBindableElement> | null => {
   return getHoveredElementForBinding(
   return getHoveredElementForBinding(
-    getLinearElementEdgeCoors(linearElement, startOrEnd, elementsMap),
-    elements,
-    elementsMap,
+    getLinearElementEdgeCoors(
+      linearElement,
+      startOrEnd,
+      app.scene.getNonDeletedElementsMap(),
+    ),
+    app,
   );
   );
 };
 };
 
 
@@ -623,7 +612,7 @@ const getLinearElementEdgeCoors = (
 
 
 const getElligibleElementsForBindableElementAndWhere = (
 const getElligibleElementsForBindableElementAndWhere = (
   bindableElement: NonDeleted<ExcalidrawBindableElement>,
   bindableElement: NonDeleted<ExcalidrawBindableElement>,
-  elementsMap: NonDeletedSceneElementsMap,
+  app: AppClassProperties,
 ): SuggestedPointBinding[] => {
 ): SuggestedPointBinding[] => {
   const scene = Scene.getScene(bindableElement)!;
   const scene = Scene.getScene(bindableElement)!;
   return scene
   return scene
@@ -636,13 +625,15 @@ const getElligibleElementsForBindableElementAndWhere = (
         element,
         element,
         "start",
         "start",
         bindableElement,
         bindableElement,
-        elementsMap,
+        scene.getNonDeletedElementsMap(),
+        app,
       );
       );
       const canBindEnd = isLinearElementEligibleForNewBindingByBindable(
       const canBindEnd = isLinearElementEligibleForNewBindingByBindable(
         element,
         element,
         "end",
         "end",
         bindableElement,
         bindableElement,
-        elementsMap,
+        scene.getNonDeletedElementsMap(),
+        app,
       );
       );
       if (!canBindStart && !canBindEnd) {
       if (!canBindStart && !canBindEnd) {
         return null;
         return null;
@@ -661,6 +652,7 @@ const isLinearElementEligibleForNewBindingByBindable = (
   startOrEnd: "start" | "end",
   startOrEnd: "start" | "end",
   bindableElement: NonDeleted<ExcalidrawBindableElement>,
   bindableElement: NonDeleted<ExcalidrawBindableElement>,
   elementsMap: NonDeletedSceneElementsMap,
   elementsMap: NonDeletedSceneElementsMap,
+  app: AppClassProperties,
 ): boolean => {
 ): boolean => {
   const existingBinding =
   const existingBinding =
     linearElement[startOrEnd === "start" ? "startBinding" : "endBinding"];
     linearElement[startOrEnd === "start" ? "startBinding" : "endBinding"];
@@ -674,7 +666,7 @@ const isLinearElementEligibleForNewBindingByBindable = (
     bindingBorderTest(
     bindingBorderTest(
       bindableElement,
       bindableElement,
       getLinearElementEdgeCoors(linearElement, startOrEnd, elementsMap),
       getLinearElementEdgeCoors(linearElement, startOrEnd, elementsMap),
-      elementsMap,
+      app,
     )
     )
   );
   );
 };
 };
@@ -846,3 +838,547 @@ const newBoundElementsAfterDeletion = (
   }
   }
   return boundElements.filter((ele) => !deletedElementIds.has(ele.id));
   return boundElements.filter((ele) => !deletedElementIds.has(ele.id));
 };
 };
+
+export const bindingBorderTest = (
+  element: NonDeleted<ExcalidrawBindableElement>,
+  { x, y }: { x: number; y: number },
+  app: AppClassProperties,
+): boolean => {
+  const threshold = maxBindingGap(element, element.width, element.height);
+  const shape = app.getElementShape(element);
+  return isPointOnShape([x, y], shape, threshold);
+};
+
+export const maxBindingGap = (
+  element: ExcalidrawElement,
+  elementWidth: number,
+  elementHeight: number,
+): number => {
+  // Aligns diamonds with rectangles
+  const shapeRatio = element.type === "diamond" ? 1 / Math.sqrt(2) : 1;
+  const smallerDimension = shapeRatio * Math.min(elementWidth, elementHeight);
+  // We make the bindable boundary bigger for bigger elements
+  return Math.max(16, Math.min(0.25 * smallerDimension, 32));
+};
+
+export const distanceToBindableElement = (
+  element: ExcalidrawBindableElement,
+  point: Point,
+  elementsMap: ElementsMap,
+): number => {
+  switch (element.type) {
+    case "rectangle":
+    case "image":
+    case "text":
+    case "iframe":
+    case "embeddable":
+    case "frame":
+    case "magicframe":
+      return distanceToRectangle(element, point, elementsMap);
+    case "diamond":
+      return distanceToDiamond(element, point, elementsMap);
+    case "ellipse":
+      return distanceToEllipse(element, point, elementsMap);
+  }
+};
+
+const distanceToRectangle = (
+  element:
+    | ExcalidrawRectangleElement
+    | ExcalidrawTextElement
+    | ExcalidrawFreeDrawElement
+    | ExcalidrawImageElement
+    | ExcalidrawIframeLikeElement
+    | ExcalidrawFrameLikeElement,
+  point: Point,
+  elementsMap: ElementsMap,
+): number => {
+  const [, pointRel, hwidth, hheight] = pointRelativeToElement(
+    element,
+    point,
+    elementsMap,
+  );
+  return Math.max(
+    GAPoint.distanceToLine(pointRel, GALine.equation(0, 1, -hheight)),
+    GAPoint.distanceToLine(pointRel, GALine.equation(1, 0, -hwidth)),
+  );
+};
+
+const distanceToDiamond = (
+  element: ExcalidrawDiamondElement,
+  point: Point,
+  elementsMap: ElementsMap,
+): number => {
+  const [, pointRel, hwidth, hheight] = pointRelativeToElement(
+    element,
+    point,
+    elementsMap,
+  );
+  const side = GALine.equation(hheight, hwidth, -hheight * hwidth);
+  return GAPoint.distanceToLine(pointRel, side);
+};
+
+export const distanceToEllipse = (
+  element: ExcalidrawEllipseElement,
+  point: Point,
+  elementsMap: ElementsMap,
+): number => {
+  const [pointRel, tangent] = ellipseParamsForTest(element, point, elementsMap);
+  return -GALine.sign(tangent) * GAPoint.distanceToLine(pointRel, tangent);
+};
+
+const ellipseParamsForTest = (
+  element: ExcalidrawEllipseElement,
+  point: Point,
+  elementsMap: ElementsMap,
+): [GA.Point, GA.Line] => {
+  const [, pointRel, hwidth, hheight] = pointRelativeToElement(
+    element,
+    point,
+    elementsMap,
+  );
+  const [px, py] = GAPoint.toTuple(pointRel);
+
+  // We're working in positive quadrant, so start with `t = 45deg`, `tx=cos(t)`
+  let tx = 0.707;
+  let ty = 0.707;
+
+  const a = hwidth;
+  const b = hheight;
+
+  // This is a numerical method to find the params tx, ty at which
+  // the ellipse has the closest point to the given point
+  [0, 1, 2, 3].forEach((_) => {
+    const xx = a * tx;
+    const yy = b * ty;
+
+    const ex = ((a * a - b * b) * tx ** 3) / a;
+    const ey = ((b * b - a * a) * ty ** 3) / b;
+
+    const rx = xx - ex;
+    const ry = yy - ey;
+
+    const qx = px - ex;
+    const qy = py - ey;
+
+    const r = Math.hypot(ry, rx);
+    const q = Math.hypot(qy, qx);
+
+    tx = Math.min(1, Math.max(0, ((qx * r) / q + ex) / a));
+    ty = Math.min(1, Math.max(0, ((qy * r) / q + ey) / b));
+    const t = Math.hypot(ty, tx);
+    tx /= t;
+    ty /= t;
+  });
+
+  const closestPoint = GA.point(a * tx, b * ty);
+
+  const tangent = GALine.orthogonalThrough(pointRel, closestPoint);
+  return [pointRel, tangent];
+};
+
+// Returns:
+//   1. the point relative to the elements (x, y) position
+//   2. the point relative to the element's center with positive (x, y)
+//   3. half element width
+//   4. half element height
+//
+// Note that for linear elements the (x, y) position is not at the
+// top right corner of their boundary.
+//
+// Rectangles, diamonds and ellipses are symmetrical over axes,
+// and other elements have a rectangular boundary,
+// so we only need to perform hit tests for the positive quadrant.
+const pointRelativeToElement = (
+  element: ExcalidrawElement,
+  pointTuple: Point,
+  elementsMap: ElementsMap,
+): [GA.Point, GA.Point, number, number] => {
+  const point = GAPoint.from(pointTuple);
+  const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
+  const center = coordsCenter(x1, y1, x2, y2);
+  // GA has angle orientation opposite to `rotate`
+  const rotate = GATransform.rotation(center, element.angle);
+  const pointRotated = GATransform.apply(rotate, point);
+  const pointRelToCenter = GA.sub(pointRotated, GADirection.from(center));
+  const pointRelToCenterAbs = GAPoint.abs(pointRelToCenter);
+  const elementPos = GA.offset(element.x, element.y);
+  const pointRelToPos = GA.sub(pointRotated, elementPos);
+  const halfWidth = (x2 - x1) / 2;
+  const halfHeight = (y2 - y1) / 2;
+  return [pointRelToPos, pointRelToCenterAbs, halfWidth, halfHeight];
+};
+
+const relativizationToElementCenter = (
+  element: ExcalidrawElement,
+  elementsMap: ElementsMap,
+): GA.Transform => {
+  const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
+  const center = coordsCenter(x1, y1, x2, y2);
+  // GA has angle orientation opposite to `rotate`
+  const rotate = GATransform.rotation(center, element.angle);
+  const translate = GA.reverse(
+    GATransform.translation(GADirection.from(center)),
+  );
+  return GATransform.compose(rotate, translate);
+};
+
+const coordsCenter = (
+  x1: number,
+  y1: number,
+  x2: number,
+  y2: number,
+): GA.Point => {
+  return GA.point((x1 + x2) / 2, (y1 + y2) / 2);
+};
+
+// The focus distance is the oriented ratio between the size of
+// the `element` and the "focus image" of the element on which
+// all focus points lie, so it's a number between -1 and 1.
+// The line going through `a` and `b` is a tangent to the "focus image"
+// of the element.
+export const determineFocusDistance = (
+  element: ExcalidrawBindableElement,
+  // Point on the line, in absolute coordinates
+  a: Point,
+  // Another point on the line, in absolute coordinates (closer to element)
+  b: Point,
+  elementsMap: ElementsMap,
+): number => {
+  const relateToCenter = relativizationToElementCenter(element, elementsMap);
+  const aRel = GATransform.apply(relateToCenter, GAPoint.from(a));
+  const bRel = GATransform.apply(relateToCenter, GAPoint.from(b));
+  const line = GALine.through(aRel, bRel);
+  const q = element.height / element.width;
+  const hwidth = element.width / 2;
+  const hheight = element.height / 2;
+  const n = line[2];
+  const m = line[3];
+  const c = line[1];
+  const mabs = Math.abs(m);
+  const nabs = Math.abs(n);
+  let ret;
+  switch (element.type) {
+    case "rectangle":
+    case "image":
+    case "text":
+    case "iframe":
+    case "embeddable":
+    case "frame":
+    case "magicframe":
+      ret = c / (hwidth * (nabs + q * mabs));
+      break;
+    case "diamond":
+      ret = mabs < nabs ? c / (nabs * hwidth) : c / (mabs * hheight);
+      break;
+    case "ellipse":
+      ret = c / (hwidth * Math.sqrt(n ** 2 + q ** 2 * m ** 2));
+      break;
+  }
+  return ret || 0;
+};
+
+export const determineFocusPoint = (
+  element: ExcalidrawBindableElement,
+  // The oriented, relative distance from the center of `element` of the
+  // returned focusPoint
+  focus: number,
+  adjecentPoint: Point,
+  elementsMap: ElementsMap,
+): Point => {
+  if (focus === 0) {
+    const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
+    const center = coordsCenter(x1, y1, x2, y2);
+    return GAPoint.toTuple(center);
+  }
+  const relateToCenter = relativizationToElementCenter(element, elementsMap);
+  const adjecentPointRel = GATransform.apply(
+    relateToCenter,
+    GAPoint.from(adjecentPoint),
+  );
+  const reverseRelateToCenter = GA.reverse(relateToCenter);
+  let point;
+  switch (element.type) {
+    case "rectangle":
+    case "image":
+    case "text":
+    case "diamond":
+    case "iframe":
+    case "embeddable":
+    case "frame":
+    case "magicframe":
+      point = findFocusPointForRectangulars(element, focus, adjecentPointRel);
+      break;
+    case "ellipse":
+      point = findFocusPointForEllipse(element, focus, adjecentPointRel);
+      break;
+  }
+  return GAPoint.toTuple(GATransform.apply(reverseRelateToCenter, point));
+};
+
+// Returns 2 or 0 intersection points between line going through `a` and `b`
+// and the `element`, in ascending order of distance from `a`.
+export const intersectElementWithLine = (
+  element: ExcalidrawBindableElement,
+  // Point on the line, in absolute coordinates
+  a: Point,
+  // Another point on the line, in absolute coordinates
+  b: Point,
+  // If given, the element is inflated by this value
+  gap: number = 0,
+  elementsMap: ElementsMap,
+): Point[] => {
+  const relateToCenter = relativizationToElementCenter(element, elementsMap);
+  const aRel = GATransform.apply(relateToCenter, GAPoint.from(a));
+  const bRel = GATransform.apply(relateToCenter, GAPoint.from(b));
+  const line = GALine.through(aRel, bRel);
+  const reverseRelateToCenter = GA.reverse(relateToCenter);
+  const intersections = getSortedElementLineIntersections(
+    element,
+    line,
+    aRel,
+    gap,
+  );
+  return intersections.map((point) =>
+    GAPoint.toTuple(GATransform.apply(reverseRelateToCenter, point)),
+  );
+};
+
+const getSortedElementLineIntersections = (
+  element: ExcalidrawBindableElement,
+  // Relative to element center
+  line: GA.Line,
+  // Relative to element center
+  nearPoint: GA.Point,
+  gap: number = 0,
+): GA.Point[] => {
+  let intersections: GA.Point[];
+  switch (element.type) {
+    case "rectangle":
+    case "image":
+    case "text":
+    case "diamond":
+    case "iframe":
+    case "embeddable":
+    case "frame":
+    case "magicframe":
+      const corners = getCorners(element);
+      intersections = corners
+        .flatMap((point, i) => {
+          const edge: [GA.Point, GA.Point] = [point, corners[(i + 1) % 4]];
+          return intersectSegment(line, offsetSegment(edge, gap));
+        })
+        .concat(
+          corners.flatMap((point) => getCircleIntersections(point, gap, line)),
+        );
+      break;
+    case "ellipse":
+      intersections = getEllipseIntersections(element, gap, line);
+      break;
+  }
+  if (intersections.length < 2) {
+    // Ignore the "edge" case of only intersecting with a single corner
+    return [];
+  }
+  const sortedIntersections = intersections.sort(
+    (i1, i2) =>
+      GAPoint.distance(i1, nearPoint) - GAPoint.distance(i2, nearPoint),
+  );
+  return [
+    sortedIntersections[0],
+    sortedIntersections[sortedIntersections.length - 1],
+  ];
+};
+
+const getCorners = (
+  element:
+    | ExcalidrawRectangleElement
+    | ExcalidrawImageElement
+    | ExcalidrawDiamondElement
+    | ExcalidrawTextElement
+    | ExcalidrawIframeLikeElement
+    | ExcalidrawFrameLikeElement,
+  scale: number = 1,
+): GA.Point[] => {
+  const hx = (scale * element.width) / 2;
+  const hy = (scale * element.height) / 2;
+  switch (element.type) {
+    case "rectangle":
+    case "image":
+    case "text":
+    case "iframe":
+    case "embeddable":
+    case "frame":
+    case "magicframe":
+      return [
+        GA.point(hx, hy),
+        GA.point(hx, -hy),
+        GA.point(-hx, -hy),
+        GA.point(-hx, hy),
+      ];
+    case "diamond":
+      return [
+        GA.point(0, hy),
+        GA.point(hx, 0),
+        GA.point(0, -hy),
+        GA.point(-hx, 0),
+      ];
+  }
+};
+
+// Returns intersection of `line` with `segment`, with `segment` moved by
+// `gap` in its polar direction.
+// If intersection coincides with second segment point returns empty array.
+const intersectSegment = (
+  line: GA.Line,
+  segment: [GA.Point, GA.Point],
+): GA.Point[] => {
+  const [a, b] = segment;
+  const aDist = GAPoint.distanceToLine(a, line);
+  const bDist = GAPoint.distanceToLine(b, line);
+  if (aDist * bDist >= 0) {
+    // The intersection is outside segment `(a, b)`
+    return [];
+  }
+  return [GAPoint.intersect(line, GALine.through(a, b))];
+};
+
+const offsetSegment = (
+  segment: [GA.Point, GA.Point],
+  distance: number,
+): [GA.Point, GA.Point] => {
+  const [a, b] = segment;
+  const offset = GATransform.translationOrthogonal(
+    GADirection.fromTo(a, b),
+    distance,
+  );
+  return [GATransform.apply(offset, a), GATransform.apply(offset, b)];
+};
+
+const getEllipseIntersections = (
+  element: ExcalidrawEllipseElement,
+  gap: number,
+  line: GA.Line,
+): GA.Point[] => {
+  const a = element.width / 2 + gap;
+  const b = element.height / 2 + gap;
+  const m = line[2];
+  const n = line[3];
+  const c = line[1];
+  const squares = a * a * m * m + b * b * n * n;
+  const discr = squares - c * c;
+  if (squares === 0 || discr <= 0) {
+    return [];
+  }
+  const discrRoot = Math.sqrt(discr);
+  const xn = -a * a * m * c;
+  const yn = -b * b * n * c;
+  return [
+    GA.point(
+      (xn + a * b * n * discrRoot) / squares,
+      (yn - a * b * m * discrRoot) / squares,
+    ),
+    GA.point(
+      (xn - a * b * n * discrRoot) / squares,
+      (yn + a * b * m * discrRoot) / squares,
+    ),
+  ];
+};
+
+export const getCircleIntersections = (
+  center: GA.Point,
+  radius: number,
+  line: GA.Line,
+): GA.Point[] => {
+  if (radius === 0) {
+    return GAPoint.distanceToLine(line, center) === 0 ? [center] : [];
+  }
+  const m = line[2];
+  const n = line[3];
+  const c = line[1];
+  const [a, b] = GAPoint.toTuple(center);
+  const r = radius;
+  const squares = m * m + n * n;
+  const discr = r * r * squares - (m * a + n * b + c) ** 2;
+  if (squares === 0 || discr <= 0) {
+    return [];
+  }
+  const discrRoot = Math.sqrt(discr);
+  const xn = a * n * n - b * m * n - m * c;
+  const yn = b * m * m - a * m * n - n * c;
+
+  return [
+    GA.point((xn + n * discrRoot) / squares, (yn - m * discrRoot) / squares),
+    GA.point((xn - n * discrRoot) / squares, (yn + m * discrRoot) / squares),
+  ];
+};
+
+// The focus point is the tangent point of the "focus image" of the
+// `element`, where the tangent goes through `point`.
+export const findFocusPointForEllipse = (
+  ellipse: ExcalidrawEllipseElement,
+  // Between -1 and 1 (not 0) the relative size of the "focus image" of
+  // the element on which the focus point lies
+  relativeDistance: number,
+  // The point for which we're trying to find the focus point, relative
+  // to the ellipse center.
+  point: GA.Point,
+): GA.Point => {
+  const relativeDistanceAbs = Math.abs(relativeDistance);
+  const a = (ellipse.width * relativeDistanceAbs) / 2;
+  const b = (ellipse.height * relativeDistanceAbs) / 2;
+
+  const orientation = Math.sign(relativeDistance);
+  const [px, pyo] = GAPoint.toTuple(point);
+
+  // The calculation below can't handle py = 0
+  const py = pyo === 0 ? 0.0001 : pyo;
+
+  const squares = px ** 2 * b ** 2 + py ** 2 * a ** 2;
+  // Tangent mx + ny + 1 = 0
+  const m =
+    (-px * b ** 2 +
+      orientation * py * Math.sqrt(Math.max(0, squares - a ** 2 * b ** 2))) /
+    squares;
+
+  let n = (-m * px - 1) / py;
+
+  if (n === 0) {
+    // if zero {-0, 0}, fall back to a same-sign value in the similar range
+    n = (Object.is(n, -0) ? -1 : 1) * 0.01;
+  }
+
+  const x = -(a ** 2 * m) / (n ** 2 * b ** 2 + m ** 2 * a ** 2);
+  return GA.point(x, (-m * x - 1) / n);
+};
+
+export const findFocusPointForRectangulars = (
+  element:
+    | ExcalidrawRectangleElement
+    | ExcalidrawImageElement
+    | ExcalidrawDiamondElement
+    | ExcalidrawTextElement
+    | ExcalidrawIframeLikeElement
+    | ExcalidrawFrameLikeElement,
+  // Between -1 and 1 for how far away should the focus point be relative
+  // to the size of the element. Sign determines orientation.
+  relativeDistance: number,
+  // The point for which we're trying to find the focus point, relative
+  // to the element center.
+  point: GA.Point,
+): GA.Point => {
+  const relativeDistanceAbs = Math.abs(relativeDistance);
+  const orientation = Math.sign(relativeDistance);
+  const corners = getCorners(element, relativeDistanceAbs);
+
+  let maxDistance = 0;
+  let tangentPoint: null | GA.Point = null;
+  corners.forEach((corner) => {
+    const distance = orientation * GALine.through(point, corner)[1];
+    if (distance > maxDistance) {
+      maxDistance = distance;
+      tangentPoint = corner;
+    }
+  });
+  return tangentPoint!;
+};

+ 0 - 7
packages/excalidraw/element/bounds.ts

@@ -299,13 +299,6 @@ export const getRectangleBoxAbsoluteCoords = (boxSceneCoords: RectangleBox) => {
   ];
   ];
 };
 };
 
 
-export const pointRelativeTo = (
-  element: ExcalidrawElement,
-  absoluteCoords: Point,
-): Point => {
-  return [absoluteCoords[0] - element.x, absoluteCoords[1] - element.y];
-};
-
 export const getDiamondPoints = (element: ExcalidrawElement) => {
 export const getDiamondPoints = (element: ExcalidrawElement) => {
   // Here we add +1 to avoid these numbers to be 0
   // Here we add +1 to avoid these numbers to be 0
   // otherwise rough.js will throw an error complaining about it
   // otherwise rough.js will throw an error complaining about it

+ 66 - 1125
packages/excalidraw/element/collision.ts

@@ -1,1170 +1,111 @@
-import * as GA from "../ga";
-import * as GAPoint from "../gapoints";
-import * as GADirection from "../gadirections";
-import * as GALine from "../galines";
-import * as GATransform from "../gatransforms";
+import { isPathALoop, isPointWithinBounds } from "../math";
 
 
 import {
 import {
-  distance2d,
-  rotatePoint,
-  isPathALoop,
-  isPointInPolygon,
-  rotate,
-} from "../math";
-import { pointsOnBezierCurves } from "points-on-curve";
-
-import {
-  NonDeletedExcalidrawElement,
-  ExcalidrawBindableElement,
+  ElementsMap,
   ExcalidrawElement,
   ExcalidrawElement,
   ExcalidrawRectangleElement,
   ExcalidrawRectangleElement,
-  ExcalidrawDiamondElement,
-  ExcalidrawTextElement,
-  ExcalidrawEllipseElement,
-  NonDeleted,
-  ExcalidrawFreeDrawElement,
-  ExcalidrawImageElement,
-  ExcalidrawLinearElement,
-  StrokeRoundness,
-  ExcalidrawFrameLikeElement,
-  ExcalidrawIframeLikeElement,
-  ElementsMap,
 } from "./types";
 } from "./types";
 
 
+import { getElementBounds } from "./bounds";
+import { FrameNameBounds } from "../types";
 import {
 import {
-  getElementAbsoluteCoords,
-  getCurvePathOps,
-  getRectangleBoxAbsoluteCoords,
-  RectangleBox,
-} from "./bounds";
-import { FrameNameBoundsCache, Point } from "../types";
-import { Drawable } from "roughjs/bin/core";
-import { AppState } from "../types";
+  Polygon,
+  GeometricShape,
+  getPolygonShape,
+} from "../../utils/geometry/shape";
+import { isPointInShape, isPointOnShape } from "../../utils/collision";
+import { isTransparent } from "../utils";
 import {
 import {
   hasBoundTextElement,
   hasBoundTextElement,
-  isFrameLikeElement,
   isIframeLikeElement,
   isIframeLikeElement,
   isImageElement,
   isImageElement,
+  isTextElement,
 } from "./typeChecks";
 } from "./typeChecks";
-import { isTextElement } from ".";
-import { isTransparent } from "../utils";
-import { shouldShowBoundingBox } from "./transformHandles";
-import { getBoundTextElement } from "./textElement";
-import { Mutable } from "../utility-types";
-import { ShapeCache } from "../scene/ShapeCache";
 
 
-const isElementDraggableFromInside = (
-  element: NonDeletedExcalidrawElement,
-): boolean => {
+export const shouldTestInside = (element: ExcalidrawElement) => {
   if (element.type === "arrow") {
   if (element.type === "arrow") {
     return false;
     return false;
   }
   }
 
 
-  if (element.type === "freedraw") {
-    return true;
-  }
   const isDraggableFromInside =
   const isDraggableFromInside =
     !isTransparent(element.backgroundColor) ||
     !isTransparent(element.backgroundColor) ||
     hasBoundTextElement(element) ||
     hasBoundTextElement(element) ||
-    isIframeLikeElement(element);
+    isIframeLikeElement(element) ||
+    isTextElement(element);
+
   if (element.type === "line") {
   if (element.type === "line") {
     return isDraggableFromInside && isPathALoop(element.points);
     return isDraggableFromInside && isPathALoop(element.points);
   }
   }
-  return isDraggableFromInside || isImageElement(element);
-};
-
-export const hitTest = (
-  element: NonDeletedExcalidrawElement,
-  appState: AppState,
-  frameNameBoundsCache: FrameNameBoundsCache,
-  x: number,
-  y: number,
-  elementsMap: ElementsMap,
-): boolean => {
-  // How many pixels off the shape boundary we still consider a hit
-  const threshold = 10 / appState.zoom.value;
-  const point: Point = [x, y];
-
-  if (
-    isElementSelected(appState, element) &&
-    shouldShowBoundingBox([element], appState)
-  ) {
-    return isPointHittingElementBoundingBox(
-      element,
-      elementsMap,
-      point,
-      threshold,
-      frameNameBoundsCache,
-    );
-  }
-
-  const boundTextElement = getBoundTextElement(element, elementsMap);
-  if (boundTextElement) {
-    const isHittingBoundTextElement = hitTest(
-      boundTextElement,
-      appState,
-      frameNameBoundsCache,
-      x,
-      y,
-      elementsMap,
-    );
-    if (isHittingBoundTextElement) {
-      return true;
-    }
-  }
-  return isHittingElementNotConsideringBoundingBox(
-    element,
-    appState,
-    frameNameBoundsCache,
-    point,
-    elementsMap,
-  );
-};
 
 
-export const isHittingElementBoundingBoxWithoutHittingElement = (
-  element: NonDeletedExcalidrawElement,
-  appState: AppState,
-  frameNameBoundsCache: FrameNameBoundsCache,
-  x: number,
-  y: number,
-  elementsMap: ElementsMap,
-): boolean => {
-  const threshold = 10 / appState.zoom.value;
-
-  // So that bound text element hit is considered within bounding box of container even if its outside actual bounding box of element
-  // eg for linear elements text can be outside the element bounding box
-  const boundTextElement = getBoundTextElement(element, elementsMap);
-  if (
-    boundTextElement &&
-    hitTest(boundTextElement, appState, frameNameBoundsCache, x, y, elementsMap)
-  ) {
-    return false;
+  if (element.type === "freedraw") {
+    return isDraggableFromInside && isPathALoop(element.points);
   }
   }
 
 
-  return (
-    !isHittingElementNotConsideringBoundingBox(
-      element,
-      appState,
-      frameNameBoundsCache,
-      [x, y],
-      elementsMap,
-    ) &&
-    isPointHittingElementBoundingBox(
-      element,
-      elementsMap,
-      [x, y],
-      threshold,
-      frameNameBoundsCache,
-    )
-  );
-};
-
-export const isHittingElementNotConsideringBoundingBox = (
-  element: NonDeletedExcalidrawElement,
-  appState: AppState,
-  frameNameBoundsCache: FrameNameBoundsCache | null,
-  point: Point,
-  elementsMap: ElementsMap,
-): boolean => {
-  const threshold = 10 / appState.zoom.value;
-  const check = isTextElement(element)
-    ? isStrictlyInside
-    : isElementDraggableFromInside(element)
-    ? isInsideCheck
-    : isNearCheck;
-  return hitTestPointAgainstElement({
-    element,
-    elementsMap,
-    point,
-    threshold,
-    check,
-    frameNameBoundsCache,
-  });
+  return isDraggableFromInside || isImageElement(element);
 };
 };
 
 
-const isElementSelected = (
-  appState: AppState,
-  element: NonDeleted<ExcalidrawElement>,
-) => appState.selectedElementIds[element.id];
-
-export const isPointHittingElementBoundingBox = (
-  element: NonDeleted<ExcalidrawElement>,
-  elementsMap: ElementsMap,
-  [x, y]: Point,
-  threshold: number,
-  frameNameBoundsCache: FrameNameBoundsCache | null,
-) => {
-  // frames needs be checked differently so as to be able to drag it
-  // by its frame, whether it has been selected or not
-  // this logic here is not ideal
-  // TODO: refactor it later...
-  if (isFrameLikeElement(element)) {
-    return hitTestPointAgainstElement({
-      element,
-      elementsMap,
-      point: [x, y],
-      threshold,
-      check: isInsideCheck,
-      frameNameBoundsCache,
+export type HitTestArgs = {
+  x: number;
+  y: number;
+  element: ExcalidrawElement;
+  shape: GeometricShape;
+  threshold?: number;
+  frameNameBound?: FrameNameBounds | null;
+};
+
+export const hitElementItself = ({
+  x,
+  y,
+  element,
+  shape,
+  threshold = 10,
+  frameNameBound = null,
+}: HitTestArgs) => {
+  let hit = shouldTestInside(element)
+    ? isPointInShape([x, y], shape)
+    : isPointOnShape([x, y], shape, threshold);
+
+  // hit test against a frame's name
+  if (!hit && frameNameBound) {
+    hit = isPointInShape([x, y], {
+      type: "polygon",
+      data: getPolygonShape(frameNameBound as ExcalidrawRectangleElement)
+        .data as Polygon,
     });
     });
   }
   }
 
 
-  const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
-  const elementCenterX = (x1 + x2) / 2;
-  const elementCenterY = (y1 + y2) / 2;
-  // reverse rotate to take element's angle into account.
-  const [rotatedX, rotatedY] = rotate(
-    x,
-    y,
-    elementCenterX,
-    elementCenterY,
-    -element.angle,
-  );
-
-  return (
-    rotatedX > x1 - threshold &&
-    rotatedX < x2 + threshold &&
-    rotatedY > y1 - threshold &&
-    rotatedY < y2 + threshold
-  );
-};
-
-export const bindingBorderTest = (
-  element: NonDeleted<ExcalidrawBindableElement>,
-  { x, y }: { x: number; y: number },
-  elementsMap: ElementsMap,
-): boolean => {
-  const threshold = maxBindingGap(element, element.width, element.height);
-  const check = isOutsideCheck;
-  const point: Point = [x, y];
-  return hitTestPointAgainstElement({
-    element,
-    elementsMap,
-    point,
-    threshold,
-    check,
-    frameNameBoundsCache: null,
-  });
-};
-
-export const maxBindingGap = (
-  element: ExcalidrawElement,
-  elementWidth: number,
-  elementHeight: number,
-): number => {
-  // Aligns diamonds with rectangles
-  const shapeRatio = element.type === "diamond" ? 1 / Math.sqrt(2) : 1;
-  const smallerDimension = shapeRatio * Math.min(elementWidth, elementHeight);
-  // We make the bindable boundary bigger for bigger elements
-  return Math.max(16, Math.min(0.25 * smallerDimension, 32));
-};
-
-type HitTestArgs = {
-  element: NonDeletedExcalidrawElement;
-  elementsMap: ElementsMap;
-  point: Point;
-  threshold: number;
-  check: (distance: number, threshold: number) => boolean;
-  frameNameBoundsCache: FrameNameBoundsCache | null;
-};
-
-const hitTestPointAgainstElement = (args: HitTestArgs): boolean => {
-  switch (args.element.type) {
-    case "rectangle":
-    case "iframe":
-    case "embeddable":
-    case "image":
-    case "text":
-    case "diamond":
-    case "ellipse":
-      const distance = distanceToBindableElement(
-        args.element,
-        args.point,
-        args.elementsMap,
-      );
-      return args.check(distance, args.threshold);
-    case "freedraw": {
-      if (
-        !args.check(
-          distanceToRectangle(args.element, args.point, args.elementsMap),
-          args.threshold,
-        )
-      ) {
-        return false;
-      }
-
-      return hitTestFreeDrawElement(
-        args.element,
-        args.point,
-        args.threshold,
-        args.elementsMap,
-      );
-    }
-    case "arrow":
-    case "line":
-      return hitTestLinear(args);
-    case "selection":
-      console.warn(
-        "This should not happen, we need to investigate why it does.",
-      );
-      return false;
-    case "frame":
-    case "magicframe": {
-      // check distance to frame element first
-      if (
-        args.check(
-          distanceToBindableElement(args.element, args.point, args.elementsMap),
-          args.threshold,
-        )
-      ) {
-        return true;
-      }
-
-      const frameNameBounds = args.frameNameBoundsCache?.get(args.element);
-
-      if (frameNameBounds) {
-        return args.check(
-          distanceToRectangleBox(frameNameBounds, args.point),
-          args.threshold,
-        );
-      }
-      return false;
-    }
-  }
-};
-
-export const distanceToBindableElement = (
-  element: ExcalidrawBindableElement,
-  point: Point,
-  elementsMap: ElementsMap,
-): number => {
-  switch (element.type) {
-    case "rectangle":
-    case "image":
-    case "text":
-    case "iframe":
-    case "embeddable":
-    case "frame":
-    case "magicframe":
-      return distanceToRectangle(element, point, elementsMap);
-    case "diamond":
-      return distanceToDiamond(element, point, elementsMap);
-    case "ellipse":
-      return distanceToEllipse(element, point, elementsMap);
-  }
-};
-
-const isStrictlyInside = (distance: number, threshold: number): boolean => {
-  return distance < 0;
-};
-
-const isInsideCheck = (distance: number, threshold: number): boolean => {
-  return distance < threshold;
-};
-
-const isNearCheck = (distance: number, threshold: number): boolean => {
-  return Math.abs(distance) < threshold;
-};
-
-const isOutsideCheck = (distance: number, threshold: number): boolean => {
-  return 0 <= distance && distance < threshold;
-};
-
-const distanceToRectangle = (
-  element:
-    | ExcalidrawRectangleElement
-    | ExcalidrawTextElement
-    | ExcalidrawFreeDrawElement
-    | ExcalidrawImageElement
-    | ExcalidrawIframeLikeElement
-    | ExcalidrawFrameLikeElement,
-  point: Point,
-  elementsMap: ElementsMap,
-): number => {
-  const [, pointRel, hwidth, hheight] = pointRelativeToElement(
-    element,
-    point,
-    elementsMap,
-  );
-  return Math.max(
-    GAPoint.distanceToLine(pointRel, GALine.equation(0, 1, -hheight)),
-    GAPoint.distanceToLine(pointRel, GALine.equation(1, 0, -hwidth)),
-  );
-};
-
-const distanceToRectangleBox = (box: RectangleBox, point: Point): number => {
-  const [, pointRel, hwidth, hheight] = pointRelativeToDivElement(point, box);
-  return Math.max(
-    GAPoint.distanceToLine(pointRel, GALine.equation(0, 1, -hheight)),
-    GAPoint.distanceToLine(pointRel, GALine.equation(1, 0, -hwidth)),
-  );
-};
-
-const distanceToDiamond = (
-  element: ExcalidrawDiamondElement,
-  point: Point,
-  elementsMap: ElementsMap,
-): number => {
-  const [, pointRel, hwidth, hheight] = pointRelativeToElement(
-    element,
-    point,
-    elementsMap,
-  );
-  const side = GALine.equation(hheight, hwidth, -hheight * hwidth);
-  return GAPoint.distanceToLine(pointRel, side);
-};
-
-const distanceToEllipse = (
-  element: ExcalidrawEllipseElement,
-  point: Point,
-  elementsMap: ElementsMap,
-): number => {
-  const [pointRel, tangent] = ellipseParamsForTest(element, point, elementsMap);
-  return -GALine.sign(tangent) * GAPoint.distanceToLine(pointRel, tangent);
-};
-
-const ellipseParamsForTest = (
-  element: ExcalidrawEllipseElement,
-  point: Point,
-  elementsMap: ElementsMap,
-): [GA.Point, GA.Line] => {
-  const [, pointRel, hwidth, hheight] = pointRelativeToElement(
-    element,
-    point,
-    elementsMap,
-  );
-  const [px, py] = GAPoint.toTuple(pointRel);
-
-  // We're working in positive quadrant, so start with `t = 45deg`, `tx=cos(t)`
-  let tx = 0.707;
-  let ty = 0.707;
-
-  const a = hwidth;
-  const b = hheight;
-
-  // This is a numerical method to find the params tx, ty at which
-  // the ellipse has the closest point to the given point
-  [0, 1, 2, 3].forEach((_) => {
-    const xx = a * tx;
-    const yy = b * ty;
-
-    const ex = ((a * a - b * b) * tx ** 3) / a;
-    const ey = ((b * b - a * a) * ty ** 3) / b;
-
-    const rx = xx - ex;
-    const ry = yy - ey;
-
-    const qx = px - ex;
-    const qy = py - ey;
-
-    const r = Math.hypot(ry, rx);
-    const q = Math.hypot(qy, qx);
-
-    tx = Math.min(1, Math.max(0, ((qx * r) / q + ex) / a));
-    ty = Math.min(1, Math.max(0, ((qy * r) / q + ey) / b));
-    const t = Math.hypot(ty, tx);
-    tx /= t;
-    ty /= t;
-  });
-
-  const closestPoint = GA.point(a * tx, b * ty);
-
-  const tangent = GALine.orthogonalThrough(pointRel, closestPoint);
-  return [pointRel, tangent];
-};
-
-const hitTestFreeDrawElement = (
-  element: ExcalidrawFreeDrawElement,
-  point: Point,
-  threshold: number,
-  elementsMap: ElementsMap,
-): boolean => {
-  // Check point-distance-to-line-segment for every segment in the
-  // element's points (its input points, not its outline points).
-  // This is... okay? It's plenty fast, but the GA library may
-  // have a faster option.
-
-  let x: number;
-  let y: number;
-
-  if (element.angle === 0) {
-    x = point[0] - element.x;
-    y = point[1] - element.y;
-  } else {
-    // Counter-rotate the point around center before testing
-    const [minX, minY, maxX, maxY] = getElementAbsoluteCoords(
-      element,
-      elementsMap,
-    );
-    const rotatedPoint = rotatePoint(
-      point,
-      [minX + (maxX - minX) / 2, minY + (maxY - minY) / 2],
-      -element.angle,
-    );
-    x = rotatedPoint[0] - element.x;
-    y = rotatedPoint[1] - element.y;
-  }
-
-  let [A, B] = element.points;
-  let P: readonly [number, number];
-
-  // For freedraw dots
-  if (
-    distance2d(A[0], A[1], x, y) < threshold ||
-    distance2d(B[0], B[1], x, y) < threshold
-  ) {
-    return true;
-  }
-
-  // For freedraw lines
-  for (let i = 0; i < element.points.length; i++) {
-    const delta = [B[0] - A[0], B[1] - A[1]];
-    const length = Math.hypot(delta[1], delta[0]);
-
-    const U = [delta[0] / length, delta[1] / length];
-    const C = [x - A[0], y - A[1]];
-    const d = (C[0] * U[0] + C[1] * U[1]) / Math.hypot(U[1], U[0]);
-    P = [A[0] + U[0] * d, A[1] + U[1] * d];
-
-    const da = distance2d(P[0], P[1], A[0], A[1]);
-    const db = distance2d(P[0], P[1], B[0], B[1]);
-
-    P = db < da && da > length ? B : da < db && db > length ? A : P;
-
-    if (Math.hypot(y - P[1], x - P[0]) < threshold) {
-      return true;
-    }
-
-    A = B;
-    B = element.points[i + 1];
-  }
-
-  const shape = ShapeCache.get(element);
-
-  // for filled freedraw shapes, support
-  // selecting from inside
-  if (shape && shape.sets.length) {
-    return element.fillStyle === "solid"
-      ? hitTestCurveInside(shape, x, y, "round")
-      : hitTestRoughShape(shape, x, y, threshold);
-  }
-
-  return false;
-};
-
-const hitTestLinear = (args: HitTestArgs): boolean => {
-  const { element, threshold } = args;
-  if (!ShapeCache.get(element)) {
-    return false;
-  }
-
-  const [point, pointAbs, hwidth, hheight] = pointRelativeToElement(
-    args.element,
-    args.point,
-    args.elementsMap,
-  );
-  const side1 = GALine.equation(0, 1, -hheight);
-  const side2 = GALine.equation(1, 0, -hwidth);
-  if (
-    !isInsideCheck(GAPoint.distanceToLine(pointAbs, side1), threshold) ||
-    !isInsideCheck(GAPoint.distanceToLine(pointAbs, side2), threshold)
-  ) {
-    return false;
-  }
-  const [relX, relY] = GAPoint.toTuple(point);
-
-  const shape = ShapeCache.get(element as ExcalidrawLinearElement);
-
-  if (!shape) {
-    return false;
-  }
-
-  if (args.check === isInsideCheck) {
-    const hit = shape.some((subshape) =>
-      hitTestCurveInside(
-        subshape,
-        relX,
-        relY,
-        element.roundness ? "round" : "sharp",
-      ),
-    );
-    if (hit) {
-      return true;
-    }
-  }
-
-  // hit test all "subshapes" of the linear element
-  return shape.some((subshape) =>
-    hitTestRoughShape(subshape, relX, relY, threshold),
-  );
-};
-
-// Returns:
-//   1. the point relative to the elements (x, y) position
-//   2. the point relative to the element's center with positive (x, y)
-//   3. half element width
-//   4. half element height
-//
-// Note that for linear elements the (x, y) position is not at the
-// top right corner of their boundary.
-//
-// Rectangles, diamonds and ellipses are symmetrical over axes,
-// and other elements have a rectangular boundary,
-// so we only need to perform hit tests for the positive quadrant.
-const pointRelativeToElement = (
-  element: ExcalidrawElement,
-  pointTuple: Point,
-  elementsMap: ElementsMap,
-): [GA.Point, GA.Point, number, number] => {
-  const point = GAPoint.from(pointTuple);
-  const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
-  const center = coordsCenter(x1, y1, x2, y2);
-  // GA has angle orientation opposite to `rotate`
-  const rotate = GATransform.rotation(center, element.angle);
-  const pointRotated = GATransform.apply(rotate, point);
-  const pointRelToCenter = GA.sub(pointRotated, GADirection.from(center));
-  const pointRelToCenterAbs = GAPoint.abs(pointRelToCenter);
-  const elementPos = GA.offset(element.x, element.y);
-  const pointRelToPos = GA.sub(pointRotated, elementPos);
-  const halfWidth = (x2 - x1) / 2;
-  const halfHeight = (y2 - y1) / 2;
-  return [pointRelToPos, pointRelToCenterAbs, halfWidth, halfHeight];
-};
-
-const pointRelativeToDivElement = (
-  pointTuple: Point,
-  rectangle: RectangleBox,
-): [GA.Point, GA.Point, number, number] => {
-  const point = GAPoint.from(pointTuple);
-  const [x1, y1, x2, y2] = getRectangleBoxAbsoluteCoords(rectangle);
-  const center = coordsCenter(x1, y1, x2, y2);
-  const rotate = GATransform.rotation(center, rectangle.angle);
-  const pointRotated = GATransform.apply(rotate, point);
-  const pointRelToCenter = GA.sub(pointRotated, GADirection.from(center));
-  const pointRelToCenterAbs = GAPoint.abs(pointRelToCenter);
-  const elementPos = GA.offset(rectangle.x, rectangle.y);
-  const pointRelToPos = GA.sub(pointRotated, elementPos);
-  const halfWidth = (x2 - x1) / 2;
-  const halfHeight = (y2 - y1) / 2;
-  return [pointRelToPos, pointRelToCenterAbs, halfWidth, halfHeight];
+  return hit;
 };
 };
 
 
-// Returns point in absolute coordinates
-export const pointInAbsoluteCoords = (
-  element: ExcalidrawElement,
-  elementsMap: ElementsMap,
-  // Point relative to the element position
-  point: Point,
-): Point => {
-  const [x, y] = point;
-  const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
-  const cx = (x2 - x1) / 2;
-  const cy = (y2 - y1) / 2;
-  const [rotatedX, rotatedY] = rotate(x, y, cx, cy, element.angle);
-  return [element.x + rotatedX, element.y + rotatedY];
-};
-
-const relativizationToElementCenter = (
+export const hitElementBoundingBox = (
+  x: number,
+  y: number,
   element: ExcalidrawElement,
   element: ExcalidrawElement,
   elementsMap: ElementsMap,
   elementsMap: ElementsMap,
-): GA.Transform => {
-  const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
-  const center = coordsCenter(x1, y1, x2, y2);
-  // GA has angle orientation opposite to `rotate`
-  const rotate = GATransform.rotation(center, element.angle);
-  const translate = GA.reverse(
-    GATransform.translation(GADirection.from(center)),
-  );
-  return GATransform.compose(rotate, translate);
-};
-
-const coordsCenter = (
-  x1: number,
-  y1: number,
-  x2: number,
-  y2: number,
-): GA.Point => {
-  return GA.point((x1 + x2) / 2, (y1 + y2) / 2);
-};
-
-// The focus distance is the oriented ratio between the size of
-// the `element` and the "focus image" of the element on which
-// all focus points lie, so it's a number between -1 and 1.
-// The line going through `a` and `b` is a tangent to the "focus image"
-// of the element.
-export const determineFocusDistance = (
-  element: ExcalidrawBindableElement,
-
-  // Point on the line, in absolute coordinates
-  a: Point,
-  // Another point on the line, in absolute coordinates (closer to element)
-  b: Point,
-  elementsMap: ElementsMap,
-): number => {
-  const relateToCenter = relativizationToElementCenter(element, elementsMap);
-  const aRel = GATransform.apply(relateToCenter, GAPoint.from(a));
-  const bRel = GATransform.apply(relateToCenter, GAPoint.from(b));
-  const line = GALine.through(aRel, bRel);
-  const q = element.height / element.width;
-  const hwidth = element.width / 2;
-  const hheight = element.height / 2;
-  const n = line[2];
-  const m = line[3];
-  const c = line[1];
-  const mabs = Math.abs(m);
-  const nabs = Math.abs(n);
-  let ret;
-  switch (element.type) {
-    case "rectangle":
-    case "image":
-    case "text":
-    case "iframe":
-    case "embeddable":
-    case "frame":
-    case "magicframe":
-      ret = c / (hwidth * (nabs + q * mabs));
-      break;
-    case "diamond":
-      ret = mabs < nabs ? c / (nabs * hwidth) : c / (mabs * hheight);
-      break;
-    case "ellipse":
-      ret = c / (hwidth * Math.sqrt(n ** 2 + q ** 2 * m ** 2));
-      break;
-  }
-  return ret || 0;
-};
-
-export const determineFocusPoint = (
-  element: ExcalidrawBindableElement,
-  // The oriented, relative distance from the center of `element` of the
-  // returned focusPoint
-  focus: number,
-  adjecentPoint: Point,
-  elementsMap: ElementsMap,
-): Point => {
-  if (focus === 0) {
-    const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
-    const center = coordsCenter(x1, y1, x2, y2);
-    return GAPoint.toTuple(center);
-  }
-  const relateToCenter = relativizationToElementCenter(element, elementsMap);
-  const adjecentPointRel = GATransform.apply(
-    relateToCenter,
-    GAPoint.from(adjecentPoint),
-  );
-  const reverseRelateToCenter = GA.reverse(relateToCenter);
-  let point;
-  switch (element.type) {
-    case "rectangle":
-    case "image":
-    case "text":
-    case "diamond":
-    case "iframe":
-    case "embeddable":
-    case "frame":
-    case "magicframe":
-      point = findFocusPointForRectangulars(element, focus, adjecentPointRel);
-      break;
-    case "ellipse":
-      point = findFocusPointForEllipse(element, focus, adjecentPointRel);
-      break;
-  }
-  return GAPoint.toTuple(GATransform.apply(reverseRelateToCenter, point));
-};
-
-// Returns 2 or 0 intersection points between line going through `a` and `b`
-// and the `element`, in ascending order of distance from `a`.
-export const intersectElementWithLine = (
-  element: ExcalidrawBindableElement,
-
-  // Point on the line, in absolute coordinates
-  a: Point,
-  // Another point on the line, in absolute coordinates
-  b: Point,
-  // If given, the element is inflated by this value
-  gap: number = 0,
-  elementsMap: ElementsMap,
-): Point[] => {
-  const relateToCenter = relativizationToElementCenter(element, elementsMap);
-  const aRel = GATransform.apply(relateToCenter, GAPoint.from(a));
-  const bRel = GATransform.apply(relateToCenter, GAPoint.from(b));
-  const line = GALine.through(aRel, bRel);
-  const reverseRelateToCenter = GA.reverse(relateToCenter);
-  const intersections = getSortedElementLineIntersections(
-    element,
-    line,
-    aRel,
-    gap,
-  );
-  return intersections.map((point) =>
-    GAPoint.toTuple(GATransform.apply(reverseRelateToCenter, point)),
-  );
-};
-
-const getSortedElementLineIntersections = (
-  element: ExcalidrawBindableElement,
-  // Relative to element center
-  line: GA.Line,
-  // Relative to element center
-  nearPoint: GA.Point,
-  gap: number = 0,
-): GA.Point[] => {
-  let intersections: GA.Point[];
-  switch (element.type) {
-    case "rectangle":
-    case "image":
-    case "text":
-    case "diamond":
-    case "iframe":
-    case "embeddable":
-    case "frame":
-    case "magicframe":
-      const corners = getCorners(element);
-      intersections = corners
-        .flatMap((point, i) => {
-          const edge: [GA.Point, GA.Point] = [point, corners[(i + 1) % 4]];
-          return intersectSegment(line, offsetSegment(edge, gap));
-        })
-        .concat(
-          corners.flatMap((point) => getCircleIntersections(point, gap, line)),
-        );
-      break;
-    case "ellipse":
-      intersections = getEllipseIntersections(element, gap, line);
-      break;
-  }
-  if (intersections.length < 2) {
-    // Ignore the "edge" case of only intersecting with a single corner
-    return [];
-  }
-  const sortedIntersections = intersections.sort(
-    (i1, i2) =>
-      GAPoint.distance(i1, nearPoint) - GAPoint.distance(i2, nearPoint),
-  );
-  return [
-    sortedIntersections[0],
-    sortedIntersections[sortedIntersections.length - 1],
-  ];
-};
-
-const getCorners = (
-  element:
-    | ExcalidrawRectangleElement
-    | ExcalidrawImageElement
-    | ExcalidrawDiamondElement
-    | ExcalidrawTextElement
-    | ExcalidrawIframeLikeElement
-    | ExcalidrawFrameLikeElement,
-  scale: number = 1,
-): GA.Point[] => {
-  const hx = (scale * element.width) / 2;
-  const hy = (scale * element.height) / 2;
-  switch (element.type) {
-    case "rectangle":
-    case "image":
-    case "text":
-    case "iframe":
-    case "embeddable":
-    case "frame":
-    case "magicframe":
-      return [
-        GA.point(hx, hy),
-        GA.point(hx, -hy),
-        GA.point(-hx, -hy),
-        GA.point(-hx, hy),
-      ];
-    case "diamond":
-      return [
-        GA.point(0, hy),
-        GA.point(hx, 0),
-        GA.point(0, -hy),
-        GA.point(-hx, 0),
-      ];
-  }
-};
-
-// Returns intersection of `line` with `segment`, with `segment` moved by
-// `gap` in its polar direction.
-// If intersection coincides with second segment point returns empty array.
-const intersectSegment = (
-  line: GA.Line,
-  segment: [GA.Point, GA.Point],
-): GA.Point[] => {
-  const [a, b] = segment;
-  const aDist = GAPoint.distanceToLine(a, line);
-  const bDist = GAPoint.distanceToLine(b, line);
-  if (aDist * bDist >= 0) {
-    // The intersection is outside segment `(a, b)`
-    return [];
-  }
-  return [GAPoint.intersect(line, GALine.through(a, b))];
-};
-
-const offsetSegment = (
-  segment: [GA.Point, GA.Point],
-  distance: number,
-): [GA.Point, GA.Point] => {
-  const [a, b] = segment;
-  const offset = GATransform.translationOrthogonal(
-    GADirection.fromTo(a, b),
-    distance,
-  );
-  return [GATransform.apply(offset, a), GATransform.apply(offset, b)];
-};
-
-const getEllipseIntersections = (
-  element: ExcalidrawEllipseElement,
-  gap: number,
-  line: GA.Line,
-): GA.Point[] => {
-  const a = element.width / 2 + gap;
-  const b = element.height / 2 + gap;
-  const m = line[2];
-  const n = line[3];
-  const c = line[1];
-  const squares = a * a * m * m + b * b * n * n;
-  const discr = squares - c * c;
-  if (squares === 0 || discr <= 0) {
-    return [];
-  }
-  const discrRoot = Math.sqrt(discr);
-  const xn = -a * a * m * c;
-  const yn = -b * b * n * c;
-  return [
-    GA.point(
-      (xn + a * b * n * discrRoot) / squares,
-      (yn - a * b * m * discrRoot) / squares,
-    ),
-    GA.point(
-      (xn - a * b * n * discrRoot) / squares,
-      (yn + a * b * m * discrRoot) / squares,
-    ),
-  ];
-};
-
-export const getCircleIntersections = (
-  center: GA.Point,
-  radius: number,
-  line: GA.Line,
-): GA.Point[] => {
-  if (radius === 0) {
-    return GAPoint.distanceToLine(line, center) === 0 ? [center] : [];
-  }
-  const m = line[2];
-  const n = line[3];
-  const c = line[1];
-  const [a, b] = GAPoint.toTuple(center);
-  const r = radius;
-  const squares = m * m + n * n;
-  const discr = r * r * squares - (m * a + n * b + c) ** 2;
-  if (squares === 0 || discr <= 0) {
-    return [];
-  }
-  const discrRoot = Math.sqrt(discr);
-  const xn = a * n * n - b * m * n - m * c;
-  const yn = b * m * m - a * m * n - n * c;
-
-  return [
-    GA.point((xn + n * discrRoot) / squares, (yn - m * discrRoot) / squares),
-    GA.point((xn - n * discrRoot) / squares, (yn + m * discrRoot) / squares),
-  ];
-};
-
-// The focus point is the tangent point of the "focus image" of the
-// `element`, where the tangent goes through `point`.
-export const findFocusPointForEllipse = (
-  ellipse: ExcalidrawEllipseElement,
-  // Between -1 and 1 (not 0) the relative size of the "focus image" of
-  // the element on which the focus point lies
-  relativeDistance: number,
-  // The point for which we're trying to find the focus point, relative
-  // to the ellipse center.
-  point: GA.Point,
-): GA.Point => {
-  const relativeDistanceAbs = Math.abs(relativeDistance);
-  const a = (ellipse.width * relativeDistanceAbs) / 2;
-  const b = (ellipse.height * relativeDistanceAbs) / 2;
-
-  const orientation = Math.sign(relativeDistance);
-  const [px, pyo] = GAPoint.toTuple(point);
-
-  // The calculation below can't handle py = 0
-  const py = pyo === 0 ? 0.0001 : pyo;
-
-  const squares = px ** 2 * b ** 2 + py ** 2 * a ** 2;
-  // Tangent mx + ny + 1 = 0
-  const m =
-    (-px * b ** 2 +
-      orientation * py * Math.sqrt(Math.max(0, squares - a ** 2 * b ** 2))) /
-    squares;
-
-  let n = (-m * px - 1) / py;
-
-  if (n === 0) {
-    // if zero {-0, 0}, fall back to a same-sign value in the similar range
-    n = (Object.is(n, -0) ? -1 : 1) * 0.01;
-  }
-
-  const x = -(a ** 2 * m) / (n ** 2 * b ** 2 + m ** 2 * a ** 2);
-  return GA.point(x, (-m * x - 1) / n);
-};
-
-export const findFocusPointForRectangulars = (
-  element:
-    | ExcalidrawRectangleElement
-    | ExcalidrawImageElement
-    | ExcalidrawDiamondElement
-    | ExcalidrawTextElement
-    | ExcalidrawIframeLikeElement
-    | ExcalidrawFrameLikeElement,
-  // Between -1 and 1 for how far away should the focus point be relative
-  // to the size of the element. Sign determines orientation.
-  relativeDistance: number,
-  // The point for which we're trying to find the focus point, relative
-  // to the element center.
-  point: GA.Point,
-): GA.Point => {
-  const relativeDistanceAbs = Math.abs(relativeDistance);
-  const orientation = Math.sign(relativeDistance);
-  const corners = getCorners(element, relativeDistanceAbs);
-
-  let maxDistance = 0;
-  let tangentPoint: null | GA.Point = null;
-  corners.forEach((corner) => {
-    const distance = orientation * GALine.through(point, corner)[1];
-    if (distance > maxDistance) {
-      maxDistance = distance;
-      tangentPoint = corner;
-    }
-  });
-  return tangentPoint!;
-};
-
-const pointInBezierEquation = (
-  p0: Point,
-  p1: Point,
-  p2: Point,
-  p3: Point,
-  [mx, my]: Point,
-  lineThreshold: number,
+  tolerance = 0,
 ) => {
 ) => {
-  // B(t) = p0 * (1-t)^3 + 3p1 * t * (1-t)^2 + 3p2 * t^2 * (1-t) + p3 * t^3
-  const equation = (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);
-
-  // go through t in increments of 0.01
-  let t = 0;
-  while (t <= 1.0) {
-    const tx = equation(t, 0);
-    const ty = equation(t, 1);
-
-    const diff = Math.sqrt(Math.pow(tx - mx, 2) + Math.pow(ty - my, 2));
-
-    if (diff < lineThreshold) {
-      return true;
-    }
-
-    t += 0.01;
-  }
-
-  return false;
+  let [x1, y1, x2, y2] = getElementBounds(element, elementsMap);
+  x1 -= tolerance;
+  y1 -= tolerance;
+  x2 += tolerance;
+  y2 += tolerance;
+  return isPointWithinBounds([x1, y1], [x, y], [x2, y2]);
 };
 };
 
 
-const hitTestCurveInside = (
-  drawable: Drawable,
-  x: number,
-  y: number,
-  roundness: StrokeRoundness,
+export const hitElementBoundingBoxOnly = (
+  hitArgs: HitTestArgs,
+  elementsMap: ElementsMap,
 ) => {
 ) => {
-  const ops = getCurvePathOps(drawable);
-  const points: Mutable<Point>[] = [];
-  let odd = false; // select one line out of double lines
-  for (const operation of ops) {
-    if (operation.op === "move") {
-      odd = !odd;
-      if (odd) {
-        points.push([operation.data[0], operation.data[1]]);
-      }
-    } else if (operation.op === "bcurveTo") {
-      if (odd) {
-        points.push([operation.data[0], operation.data[1]]);
-        points.push([operation.data[2], operation.data[3]]);
-        points.push([operation.data[4], operation.data[5]]);
-      }
-    } else if (operation.op === "lineTo") {
-      if (odd) {
-        points.push([operation.data[0], operation.data[1]]);
-      }
-    }
-  }
-  if (points.length >= 4) {
-    if (roundness === "sharp") {
-      return isPointInPolygon(points, x, y);
-    }
-    const polygonPoints = pointsOnBezierCurves(points, 10, 5);
-    return isPointInPolygon(polygonPoints, x, y);
-  }
-  return false;
+  return (
+    !hitElementItself(hitArgs) &&
+    hitElementBoundingBox(hitArgs.x, hitArgs.y, hitArgs.element, elementsMap)
+  );
 };
 };
 
 
-const hitTestRoughShape = (
-  drawable: Drawable,
+export const hitElementBoundText = (
   x: number,
   x: number,
   y: number,
   y: number,
-  lineThreshold: number,
+  textShape: GeometricShape | null,
 ) => {
 ) => {
-  // read operations from first opSet
-  const ops = getCurvePathOps(drawable);
-
-  // set start position as (0,0) just in case
-  // move operation does not exist (unlikely but it is worth safekeeping it)
-  let currentP: Point = [0, 0];
-
-  return ops.some(({ op, data }, idx) => {
-    // There are only four operation types:
-    // move, bcurveTo, lineTo, and curveTo
-    if (op === "move") {
-      // change starting point
-      currentP = data as unknown as Point;
-      // move operation does not draw anything; so, it always
-      // returns false
-    } else if (op === "bcurveTo") {
-      // create points from bezier curve
-      // bezier curve stores data as a flattened array of three positions
-      // [x1, y1, x2, y2, x3, y3]
-      const p1 = [data[0], data[1]] as Point;
-      const p2 = [data[2], data[3]] as Point;
-      const p3 = [data[4], data[5]] as Point;
-
-      const p0 = currentP;
-      currentP = p3;
-
-      // check if points are on the curve
-      // cubic bezier curves require four parameters
-      // the first parameter is the last stored position (p0)
-      const retVal = pointInBezierEquation(
-        p0,
-        p1,
-        p2,
-        p3,
-        [x, y],
-        lineThreshold,
-      );
-
-      // set end point of bezier curve as the new starting point for
-      // upcoming operations as each operation is based on the last drawn
-      // position of the previous operation
-      return retVal;
-    } else if (op === "lineTo") {
-      return hitTestCurveInside(drawable, x, y, "sharp");
-    } else if (op === "qcurveTo") {
-      // TODO: Implement this
-      console.warn("qcurveTo is not implemented yet");
-    }
-
-    return false;
-  });
+  return textShape && isPointInShape([x, y], textShape);
 };
 };

+ 0 - 4
packages/excalidraw/element/index.ts

@@ -29,10 +29,6 @@ export {
   getTransformHandlesFromCoords,
   getTransformHandlesFromCoords,
   getTransformHandles,
   getTransformHandles,
 } from "./transformHandles";
 } from "./transformHandles";
-export {
-  hitTest,
-  isHittingElementBoundingBoxWithoutHittingElement,
-} from "./collision";
 export {
 export {
   resizeTest,
   resizeTest,
   getCursorForResizingElement,
   getCursorForResizingElement,

+ 9 - 12
packages/excalidraw/element/linearElementEditor.ts

@@ -6,7 +6,6 @@ import {
   ExcalidrawBindableElement,
   ExcalidrawBindableElement,
   ExcalidrawTextElementWithContainer,
   ExcalidrawTextElementWithContainer,
   ElementsMap,
   ElementsMap,
-  NonDeletedExcalidrawElement,
   NonDeletedSceneElementsMap,
   NonDeletedSceneElementsMap,
 } from "./types";
 } from "./types";
 import {
 import {
@@ -34,6 +33,7 @@ import {
   AppState,
   AppState,
   PointerCoords,
   PointerCoords,
   InteractiveCanvasAppState,
   InteractiveCanvasAppState,
+  AppClassProperties,
 } from "../types";
 } from "../types";
 import { mutateElement } from "./mutateElement";
 import { mutateElement } from "./mutateElement";
 import History from "../history";
 import History from "../history";
@@ -334,9 +334,10 @@ export class LinearElementEditor {
     event: PointerEvent,
     event: PointerEvent,
     editingLinearElement: LinearElementEditor,
     editingLinearElement: LinearElementEditor,
     appState: AppState,
     appState: AppState,
-    elements: readonly NonDeletedExcalidrawElement[],
-    elementsMap: NonDeletedSceneElementsMap,
+    app: AppClassProperties,
   ): LinearElementEditor {
   ): LinearElementEditor {
+    const elementsMap = app.scene.getNonDeletedElementsMap();
+
     const { elementId, selectedPointsIndices, isDragging, pointerDownState } =
     const { elementId, selectedPointsIndices, isDragging, pointerDownState } =
       editingLinearElement;
       editingLinearElement;
     const element = LinearElementEditor.getElement(elementId, elementsMap);
     const element = LinearElementEditor.getElement(elementId, elementsMap);
@@ -380,8 +381,7 @@ export class LinearElementEditor {
                     elementsMap,
                     elementsMap,
                   ),
                   ),
                 ),
                 ),
-                elements,
-                elementsMap,
+                app,
               )
               )
             : null;
             : null;
 
 
@@ -645,13 +645,14 @@ export class LinearElementEditor {
     history: History,
     history: History,
     scenePointer: { x: number; y: number },
     scenePointer: { x: number; y: number },
     linearElementEditor: LinearElementEditor,
     linearElementEditor: LinearElementEditor,
-    elements: readonly NonDeletedExcalidrawElement[],
-    elementsMap: NonDeletedSceneElementsMap,
+    app: AppClassProperties,
   ): {
   ): {
     didAddPoint: boolean;
     didAddPoint: boolean;
     hitElement: NonDeleted<ExcalidrawElement> | null;
     hitElement: NonDeleted<ExcalidrawElement> | null;
     linearElementEditor: LinearElementEditor | null;
     linearElementEditor: LinearElementEditor | null;
   } {
   } {
+    const elementsMap = app.scene.getNonDeletedElementsMap();
+
     const ret: ReturnType<typeof LinearElementEditor["handlePointerDown"]> = {
     const ret: ReturnType<typeof LinearElementEditor["handlePointerDown"]> = {
       didAddPoint: false,
       didAddPoint: false,
       hitElement: null,
       hitElement: null,
@@ -714,11 +715,7 @@ export class LinearElementEditor {
         },
         },
         selectedPointsIndices: [element.points.length - 1],
         selectedPointsIndices: [element.points.length - 1],
         lastUncommittedPoint: null,
         lastUncommittedPoint: null,
-        endBindingElement: getHoveredElementForBinding(
-          scenePointer,
-          elements,
-          elementsMap,
-        ),
+        endBindingElement: getHoveredElementForBinding(scenePointer, app),
       };
       };
 
 
       ret.didAddPoint = true;
       ret.didAddPoint = true;

+ 1 - 50
packages/excalidraw/element/textElement.ts

@@ -26,16 +26,11 @@ import { isTextElement } from ".";
 import { isBoundToContainer, isArrowElement } from "./typeChecks";
 import { isBoundToContainer, isArrowElement } from "./typeChecks";
 import { LinearElementEditor } from "./linearElementEditor";
 import { LinearElementEditor } from "./linearElementEditor";
 import { AppState } from "../types";
 import { AppState } from "../types";
-import { isTextBindableContainer } from "./typeChecks";
-import { getElementAbsoluteCoords } from ".";
-import { getSelectedElements } from "../scene";
-import { isHittingElementNotConsideringBoundingBox } from "./collision";
-
-import { ExtractSetType, MakeBrand } from "../utility-types";
 import {
 import {
   resetOriginalContainerCache,
   resetOriginalContainerCache,
   updateOriginalContainerCache,
   updateOriginalContainerCache,
 } from "./containerCache";
 } from "./containerCache";
+import { ExtractSetType, MakeBrand } from "../utility-types";
 
 
 export const normalizeText = (text: string) => {
 export const normalizeText = (text: string) => {
   return (
   return (
@@ -771,50 +766,6 @@ export const suppportsHorizontalAlign = (
   });
   });
 };
 };
 
 
-export const getTextBindableContainerAtPosition = (
-  elements: readonly ExcalidrawElement[],
-  appState: AppState,
-  x: number,
-  y: number,
-  elementsMap: ElementsMap,
-): ExcalidrawTextContainer | null => {
-  const selectedElements = getSelectedElements(elements, appState);
-  if (selectedElements.length === 1) {
-    return isTextBindableContainer(selectedElements[0], false)
-      ? selectedElements[0]
-      : null;
-  }
-  let hitElement = null;
-  // We need to to hit testing from front (end of the array) to back (beginning of the array)
-  for (let index = elements.length - 1; index >= 0; --index) {
-    if (elements[index].isDeleted) {
-      continue;
-    }
-    const [x1, y1, x2, y2] = getElementAbsoluteCoords(
-      elements[index],
-      elementsMap,
-    );
-    if (
-      isArrowElement(elements[index]) &&
-      isHittingElementNotConsideringBoundingBox(
-        elements[index],
-        appState,
-        null,
-        [x, y],
-        elementsMap,
-      )
-    ) {
-      hitElement = elements[index];
-      break;
-    } else if (x1 < x && x < x2 && y1 < y && y < y2) {
-      hitElement = elements[index];
-      break;
-    }
-  }
-
-  return isTextBindableContainer(hitElement, false) ? hitElement : null;
-};
-
 const VALID_CONTAINER_TYPES = new Set([
 const VALID_CONTAINER_TYPES = new Set([
   "rectangle",
   "rectangle",
   "ellipse",
   "ellipse",

+ 5 - 2
packages/excalidraw/renderer/interactiveScene.ts

@@ -34,8 +34,11 @@ import { DEFAULT_TRANSFORM_HANDLE_SPACING, FRAME_STYLE } from "../constants";
 
 
 import { renderSnaps } from "../renderer/renderSnaps";
 import { renderSnaps } from "../renderer/renderSnaps";
 
 
-import { maxBindingGap } from "../element/collision";
-import { SuggestedBinding, SuggestedPointBinding } from "../element/binding";
+import {
+  maxBindingGap,
+  SuggestedBinding,
+  SuggestedPointBinding,
+} from "../element/binding";
 import { LinearElementEditor } from "../element/linearElementEditor";
 import { LinearElementEditor } from "../element/linearElementEditor";
 import {
 import {
   bootstrapCanvas,
   bootstrapCanvas,

+ 92 - 92
packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap

@@ -2294,14 +2294,14 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
   "roundness": {
   "roundness": {
     "type": 3,
     "type": 3,
   },
   },
-  "seed": 449462985,
+  "seed": 1278240551,
   "strokeColor": "#1e1e1e",
   "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeStyle": "solid",
   "strokeWidth": 2,
   "strokeWidth": 2,
   "type": "rectangle",
   "type": "rectangle",
   "updated": 1,
   "updated": 1,
   "version": 3,
   "version": 3,
-  "versionNonce": 1116226695,
+  "versionNonce": 2019559783,
   "width": 20,
   "width": 20,
   "x": -10,
   "x": -10,
   "y": 0,
   "y": 0,
@@ -2354,14 +2354,14 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
           "roundness": {
           "roundness": {
             "type": 3,
             "type": 3,
           },
           },
-          "seed": 449462985,
+          "seed": 1278240551,
           "strokeColor": "#1e1e1e",
           "strokeColor": "#1e1e1e",
           "strokeStyle": "solid",
           "strokeStyle": "solid",
           "strokeWidth": 2,
           "strokeWidth": 2,
           "type": "rectangle",
           "type": "rectangle",
           "updated": 1,
           "updated": 1,
           "version": 2,
           "version": 2,
-          "versionNonce": 2019559783,
+          "versionNonce": 453191,
           "width": 20,
           "width": 20,
           "x": -10,
           "x": -10,
           "y": 0,
           "y": 0,
@@ -2396,14 +2396,14 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
           "roundness": {
           "roundness": {
             "type": 3,
             "type": 3,
           },
           },
-          "seed": 449462985,
+          "seed": 1278240551,
           "strokeColor": "#1e1e1e",
           "strokeColor": "#1e1e1e",
           "strokeStyle": "solid",
           "strokeStyle": "solid",
           "strokeWidth": 2,
           "strokeWidth": 2,
           "type": "rectangle",
           "type": "rectangle",
           "updated": 1,
           "updated": 1,
           "version": 3,
           "version": 3,
-          "versionNonce": 1116226695,
+          "versionNonce": 2019559783,
           "width": 20,
           "width": 20,
           "x": -10,
           "x": -10,
           "y": 0,
           "y": 0,
@@ -2540,14 +2540,14 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
   "roundness": {
   "roundness": {
     "type": 3,
     "type": 3,
   },
   },
-  "seed": 449462985,
+  "seed": 1278240551,
   "strokeColor": "#1e1e1e",
   "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeStyle": "solid",
   "strokeWidth": 2,
   "strokeWidth": 2,
   "type": "rectangle",
   "type": "rectangle",
   "updated": 1,
   "updated": 1,
   "version": 2,
   "version": 2,
-  "versionNonce": 2019559783,
+  "versionNonce": 453191,
   "width": 20,
   "width": 20,
   "x": -10,
   "x": -10,
   "y": 0,
   "y": 0,
@@ -2573,14 +2573,14 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
   "roundness": {
   "roundness": {
     "type": 3,
     "type": 3,
   },
   },
-  "seed": 1116226695,
+  "seed": 2019559783,
   "strokeColor": "#1e1e1e",
   "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeStyle": "solid",
   "strokeWidth": 2,
   "strokeWidth": 2,
   "type": "rectangle",
   "type": "rectangle",
   "updated": 1,
   "updated": 1,
   "version": 2,
   "version": 2,
-  "versionNonce": 2019559783,
+  "versionNonce": 453191,
   "width": 20,
   "width": 20,
   "x": 0,
   "x": 0,
   "y": 10,
   "y": 10,
@@ -2633,14 +2633,14 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
           "roundness": {
           "roundness": {
             "type": 3,
             "type": 3,
           },
           },
-          "seed": 449462985,
+          "seed": 1278240551,
           "strokeColor": "#1e1e1e",
           "strokeColor": "#1e1e1e",
           "strokeStyle": "solid",
           "strokeStyle": "solid",
           "strokeWidth": 2,
           "strokeWidth": 2,
           "type": "rectangle",
           "type": "rectangle",
           "updated": 1,
           "updated": 1,
           "version": 2,
           "version": 2,
-          "versionNonce": 2019559783,
+          "versionNonce": 453191,
           "width": 20,
           "width": 20,
           "x": -10,
           "x": -10,
           "y": 0,
           "y": 0,
@@ -2677,14 +2677,14 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
           "roundness": {
           "roundness": {
             "type": 3,
             "type": 3,
           },
           },
-          "seed": 449462985,
+          "seed": 1278240551,
           "strokeColor": "#1e1e1e",
           "strokeColor": "#1e1e1e",
           "strokeStyle": "solid",
           "strokeStyle": "solid",
           "strokeWidth": 2,
           "strokeWidth": 2,
           "type": "rectangle",
           "type": "rectangle",
           "updated": 1,
           "updated": 1,
           "version": 2,
           "version": 2,
-          "versionNonce": 2019559783,
+          "versionNonce": 453191,
           "width": 20,
           "width": 20,
           "x": -10,
           "x": -10,
           "y": 0,
           "y": 0,
@@ -2707,14 +2707,14 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
           "roundness": {
           "roundness": {
             "type": 3,
             "type": 3,
           },
           },
-          "seed": 1116226695,
+          "seed": 2019559783,
           "strokeColor": "#1e1e1e",
           "strokeColor": "#1e1e1e",
           "strokeStyle": "solid",
           "strokeStyle": "solid",
           "strokeWidth": 2,
           "strokeWidth": 2,
           "type": "rectangle",
           "type": "rectangle",
           "updated": 1,
           "updated": 1,
           "version": 2,
           "version": 2,
-          "versionNonce": 2019559783,
+          "versionNonce": 453191,
           "width": 20,
           "width": 20,
           "x": 0,
           "x": 0,
           "y": 10,
           "y": 10,
@@ -2858,14 +2858,14 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
   "roundness": {
   "roundness": {
     "type": 3,
     "type": 3,
   },
   },
-  "seed": 449462985,
+  "seed": 1278240551,
   "strokeColor": "#1e1e1e",
   "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeStyle": "solid",
   "strokeWidth": 2,
   "strokeWidth": 2,
   "type": "rectangle",
   "type": "rectangle",
   "updated": 1,
   "updated": 1,
   "version": 3,
   "version": 3,
-  "versionNonce": 1505387817,
+  "versionNonce": 400692809,
   "width": 20,
   "width": 20,
   "x": -10,
   "x": -10,
   "y": 0,
   "y": 0,
@@ -2893,14 +2893,14 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
   "roundness": {
   "roundness": {
     "type": 3,
     "type": 3,
   },
   },
-  "seed": 1116226695,
+  "seed": 2019559783,
   "strokeColor": "#1e1e1e",
   "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeStyle": "solid",
   "strokeWidth": 2,
   "strokeWidth": 2,
   "type": "rectangle",
   "type": "rectangle",
   "updated": 1,
   "updated": 1,
   "version": 3,
   "version": 3,
-  "versionNonce": 23633383,
+  "versionNonce": 1604849351,
   "width": 20,
   "width": 20,
   "x": 20,
   "x": 20,
   "y": 30,
   "y": 30,
@@ -2953,14 +2953,14 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
           "roundness": {
           "roundness": {
             "type": 3,
             "type": 3,
           },
           },
-          "seed": 449462985,
+          "seed": 1278240551,
           "strokeColor": "#1e1e1e",
           "strokeColor": "#1e1e1e",
           "strokeStyle": "solid",
           "strokeStyle": "solid",
           "strokeWidth": 2,
           "strokeWidth": 2,
           "type": "rectangle",
           "type": "rectangle",
           "updated": 1,
           "updated": 1,
           "version": 2,
           "version": 2,
-          "versionNonce": 2019559783,
+          "versionNonce": 453191,
           "width": 20,
           "width": 20,
           "x": -10,
           "x": -10,
           "y": 0,
           "y": 0,
@@ -2997,14 +2997,14 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
           "roundness": {
           "roundness": {
             "type": 3,
             "type": 3,
           },
           },
-          "seed": 449462985,
+          "seed": 1278240551,
           "strokeColor": "#1e1e1e",
           "strokeColor": "#1e1e1e",
           "strokeStyle": "solid",
           "strokeStyle": "solid",
           "strokeWidth": 2,
           "strokeWidth": 2,
           "type": "rectangle",
           "type": "rectangle",
           "updated": 1,
           "updated": 1,
           "version": 2,
           "version": 2,
-          "versionNonce": 2019559783,
+          "versionNonce": 453191,
           "width": 20,
           "width": 20,
           "x": -10,
           "x": -10,
           "y": 0,
           "y": 0,
@@ -3027,14 +3027,14 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
           "roundness": {
           "roundness": {
             "type": 3,
             "type": 3,
           },
           },
-          "seed": 1116226695,
+          "seed": 2019559783,
           "strokeColor": "#1e1e1e",
           "strokeColor": "#1e1e1e",
           "strokeStyle": "solid",
           "strokeStyle": "solid",
           "strokeWidth": 2,
           "strokeWidth": 2,
           "type": "rectangle",
           "type": "rectangle",
           "updated": 1,
           "updated": 1,
           "version": 2,
           "version": 2,
-          "versionNonce": 238820263,
+          "versionNonce": 1116226695,
           "width": 20,
           "width": 20,
           "x": 20,
           "x": 20,
           "y": 30,
           "y": 30,
@@ -3076,14 +3076,14 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
           "roundness": {
           "roundness": {
             "type": 3,
             "type": 3,
           },
           },
-          "seed": 449462985,
+          "seed": 1278240551,
           "strokeColor": "#1e1e1e",
           "strokeColor": "#1e1e1e",
           "strokeStyle": "solid",
           "strokeStyle": "solid",
           "strokeWidth": 2,
           "strokeWidth": 2,
           "type": "rectangle",
           "type": "rectangle",
           "updated": 1,
           "updated": 1,
           "version": 3,
           "version": 3,
-          "versionNonce": 1505387817,
+          "versionNonce": 400692809,
           "width": 20,
           "width": 20,
           "x": -10,
           "x": -10,
           "y": 0,
           "y": 0,
@@ -3108,14 +3108,14 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
           "roundness": {
           "roundness": {
             "type": 3,
             "type": 3,
           },
           },
-          "seed": 1116226695,
+          "seed": 2019559783,
           "strokeColor": "#1e1e1e",
           "strokeColor": "#1e1e1e",
           "strokeStyle": "solid",
           "strokeStyle": "solid",
           "strokeWidth": 2,
           "strokeWidth": 2,
           "type": "rectangle",
           "type": "rectangle",
           "updated": 1,
           "updated": 1,
           "version": 3,
           "version": 3,
-          "versionNonce": 23633383,
+          "versionNonce": 1604849351,
           "width": 20,
           "width": 20,
           "x": 20,
           "x": 20,
           "y": 30,
           "y": 30,
@@ -3254,14 +3254,14 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
   "roundness": {
   "roundness": {
     "type": 3,
     "type": 3,
   },
   },
-  "seed": 449462985,
+  "seed": 1278240551,
   "strokeColor": "#e03131",
   "strokeColor": "#e03131",
   "strokeStyle": "dotted",
   "strokeStyle": "dotted",
   "strokeWidth": 2,
   "strokeWidth": 2,
   "type": "rectangle",
   "type": "rectangle",
   "updated": 1,
   "updated": 1,
   "version": 3,
   "version": 3,
-  "versionNonce": 640725609,
+  "versionNonce": 1315507081,
   "width": 20,
   "width": 20,
   "x": -10,
   "x": -10,
   "y": 0,
   "y": 0,
@@ -3287,14 +3287,14 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
   "roundness": {
   "roundness": {
     "type": 3,
     "type": 3,
   },
   },
-  "seed": 760410951,
+  "seed": 747212839,
   "strokeColor": "#e03131",
   "strokeColor": "#e03131",
   "strokeStyle": "dotted",
   "strokeStyle": "dotted",
   "strokeWidth": 2,
   "strokeWidth": 2,
   "type": "rectangle",
   "type": "rectangle",
   "updated": 1,
   "updated": 1,
   "version": 8,
   "version": 8,
-  "versionNonce": 1315507081,
+  "versionNonce": 1006504105,
   "width": 20,
   "width": 20,
   "x": 20,
   "x": 20,
   "y": 30,
   "y": 30,
@@ -3347,14 +3347,14 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
           "roundness": {
           "roundness": {
             "type": 3,
             "type": 3,
           },
           },
-          "seed": 449462985,
+          "seed": 1278240551,
           "strokeColor": "#1e1e1e",
           "strokeColor": "#1e1e1e",
           "strokeStyle": "solid",
           "strokeStyle": "solid",
           "strokeWidth": 2,
           "strokeWidth": 2,
           "type": "rectangle",
           "type": "rectangle",
           "updated": 1,
           "updated": 1,
           "version": 2,
           "version": 2,
-          "versionNonce": 2019559783,
+          "versionNonce": 453191,
           "width": 20,
           "width": 20,
           "x": -10,
           "x": -10,
           "y": 0,
           "y": 0,
@@ -3391,14 +3391,14 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
           "roundness": {
           "roundness": {
             "type": 3,
             "type": 3,
           },
           },
-          "seed": 449462985,
+          "seed": 1278240551,
           "strokeColor": "#1e1e1e",
           "strokeColor": "#1e1e1e",
           "strokeStyle": "solid",
           "strokeStyle": "solid",
           "strokeWidth": 2,
           "strokeWidth": 2,
           "type": "rectangle",
           "type": "rectangle",
           "updated": 1,
           "updated": 1,
           "version": 2,
           "version": 2,
-          "versionNonce": 2019559783,
+          "versionNonce": 453191,
           "width": 20,
           "width": 20,
           "x": -10,
           "x": -10,
           "y": 0,
           "y": 0,
@@ -3421,14 +3421,14 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
           "roundness": {
           "roundness": {
             "type": 3,
             "type": 3,
           },
           },
-          "seed": 1116226695,
+          "seed": 2019559783,
           "strokeColor": "#1e1e1e",
           "strokeColor": "#1e1e1e",
           "strokeStyle": "solid",
           "strokeStyle": "solid",
           "strokeWidth": 2,
           "strokeWidth": 2,
           "type": "rectangle",
           "type": "rectangle",
           "updated": 1,
           "updated": 1,
           "version": 2,
           "version": 2,
-          "versionNonce": 238820263,
+          "versionNonce": 1116226695,
           "width": 20,
           "width": 20,
           "x": 20,
           "x": 20,
           "y": 30,
           "y": 30,
@@ -3465,14 +3465,14 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
           "roundness": {
           "roundness": {
             "type": 3,
             "type": 3,
           },
           },
-          "seed": 449462985,
+          "seed": 1278240551,
           "strokeColor": "#1e1e1e",
           "strokeColor": "#1e1e1e",
           "strokeStyle": "solid",
           "strokeStyle": "solid",
           "strokeWidth": 2,
           "strokeWidth": 2,
           "type": "rectangle",
           "type": "rectangle",
           "updated": 1,
           "updated": 1,
           "version": 2,
           "version": 2,
-          "versionNonce": 2019559783,
+          "versionNonce": 453191,
           "width": 20,
           "width": 20,
           "x": -10,
           "x": -10,
           "y": 0,
           "y": 0,
@@ -3495,14 +3495,14 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
           "roundness": {
           "roundness": {
             "type": 3,
             "type": 3,
           },
           },
-          "seed": 1116226695,
+          "seed": 2019559783,
           "strokeColor": "#e03131",
           "strokeColor": "#e03131",
           "strokeStyle": "solid",
           "strokeStyle": "solid",
           "strokeWidth": 2,
           "strokeWidth": 2,
           "type": "rectangle",
           "type": "rectangle",
           "updated": 1,
           "updated": 1,
           "version": 3,
           "version": 3,
-          "versionNonce": 1604849351,
+          "versionNonce": 238820263,
           "width": 20,
           "width": 20,
           "x": 20,
           "x": 20,
           "y": 30,
           "y": 30,
@@ -3539,14 +3539,14 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
           "roundness": {
           "roundness": {
             "type": 3,
             "type": 3,
           },
           },
-          "seed": 449462985,
+          "seed": 1278240551,
           "strokeColor": "#1e1e1e",
           "strokeColor": "#1e1e1e",
           "strokeStyle": "solid",
           "strokeStyle": "solid",
           "strokeWidth": 2,
           "strokeWidth": 2,
           "type": "rectangle",
           "type": "rectangle",
           "updated": 1,
           "updated": 1,
           "version": 2,
           "version": 2,
-          "versionNonce": 2019559783,
+          "versionNonce": 453191,
           "width": 20,
           "width": 20,
           "x": -10,
           "x": -10,
           "y": 0,
           "y": 0,
@@ -3569,14 +3569,14 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
           "roundness": {
           "roundness": {
             "type": 3,
             "type": 3,
           },
           },
-          "seed": 1116226695,
+          "seed": 2019559783,
           "strokeColor": "#e03131",
           "strokeColor": "#e03131",
           "strokeStyle": "solid",
           "strokeStyle": "solid",
           "strokeWidth": 2,
           "strokeWidth": 2,
           "type": "rectangle",
           "type": "rectangle",
           "updated": 1,
           "updated": 1,
           "version": 4,
           "version": 4,
-          "versionNonce": 23633383,
+          "versionNonce": 1604849351,
           "width": 20,
           "width": 20,
           "x": 20,
           "x": 20,
           "y": 30,
           "y": 30,
@@ -3613,14 +3613,14 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
           "roundness": {
           "roundness": {
             "type": 3,
             "type": 3,
           },
           },
-          "seed": 449462985,
+          "seed": 1278240551,
           "strokeColor": "#1e1e1e",
           "strokeColor": "#1e1e1e",
           "strokeStyle": "solid",
           "strokeStyle": "solid",
           "strokeWidth": 2,
           "strokeWidth": 2,
           "type": "rectangle",
           "type": "rectangle",
           "updated": 1,
           "updated": 1,
           "version": 2,
           "version": 2,
-          "versionNonce": 2019559783,
+          "versionNonce": 453191,
           "width": 20,
           "width": 20,
           "x": -10,
           "x": -10,
           "y": 0,
           "y": 0,
@@ -3643,14 +3643,14 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
           "roundness": {
           "roundness": {
             "type": 3,
             "type": 3,
           },
           },
-          "seed": 1116226695,
+          "seed": 2019559783,
           "strokeColor": "#e03131",
           "strokeColor": "#e03131",
           "strokeStyle": "solid",
           "strokeStyle": "solid",
           "strokeWidth": 2,
           "strokeWidth": 2,
           "type": "rectangle",
           "type": "rectangle",
           "updated": 1,
           "updated": 1,
           "version": 5,
           "version": 5,
-          "versionNonce": 915032327,
+          "versionNonce": 23633383,
           "width": 20,
           "width": 20,
           "x": 20,
           "x": 20,
           "y": 30,
           "y": 30,
@@ -3687,14 +3687,14 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
           "roundness": {
           "roundness": {
             "type": 3,
             "type": 3,
           },
           },
-          "seed": 449462985,
+          "seed": 1278240551,
           "strokeColor": "#1e1e1e",
           "strokeColor": "#1e1e1e",
           "strokeStyle": "solid",
           "strokeStyle": "solid",
           "strokeWidth": 2,
           "strokeWidth": 2,
           "type": "rectangle",
           "type": "rectangle",
           "updated": 1,
           "updated": 1,
           "version": 2,
           "version": 2,
-          "versionNonce": 2019559783,
+          "versionNonce": 453191,
           "width": 20,
           "width": 20,
           "x": -10,
           "x": -10,
           "y": 0,
           "y": 0,
@@ -3717,14 +3717,14 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
           "roundness": {
           "roundness": {
             "type": 3,
             "type": 3,
           },
           },
-          "seed": 1116226695,
+          "seed": 2019559783,
           "strokeColor": "#e03131",
           "strokeColor": "#e03131",
           "strokeStyle": "dotted",
           "strokeStyle": "dotted",
           "strokeWidth": 2,
           "strokeWidth": 2,
           "type": "rectangle",
           "type": "rectangle",
           "updated": 1,
           "updated": 1,
           "version": 6,
           "version": 6,
-          "versionNonce": 747212839,
+          "versionNonce": 915032327,
           "width": 20,
           "width": 20,
           "x": 20,
           "x": 20,
           "y": 30,
           "y": 30,
@@ -3761,14 +3761,14 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
           "roundness": {
           "roundness": {
             "type": 3,
             "type": 3,
           },
           },
-          "seed": 449462985,
+          "seed": 1278240551,
           "strokeColor": "#1e1e1e",
           "strokeColor": "#1e1e1e",
           "strokeStyle": "solid",
           "strokeStyle": "solid",
           "strokeWidth": 2,
           "strokeWidth": 2,
           "type": "rectangle",
           "type": "rectangle",
           "updated": 1,
           "updated": 1,
           "version": 2,
           "version": 2,
-          "versionNonce": 2019559783,
+          "versionNonce": 453191,
           "width": 20,
           "width": 20,
           "x": -10,
           "x": -10,
           "y": 0,
           "y": 0,
@@ -3791,14 +3791,14 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
           "roundness": {
           "roundness": {
             "type": 3,
             "type": 3,
           },
           },
-          "seed": 760410951,
+          "seed": 747212839,
           "strokeColor": "#e03131",
           "strokeColor": "#e03131",
           "strokeStyle": "dotted",
           "strokeStyle": "dotted",
           "strokeWidth": 2,
           "strokeWidth": 2,
           "type": "rectangle",
           "type": "rectangle",
           "updated": 1,
           "updated": 1,
           "version": 7,
           "version": 7,
-          "versionNonce": 1006504105,
+          "versionNonce": 1723083209,
           "width": 20,
           "width": 20,
           "x": 20,
           "x": 20,
           "y": 30,
           "y": 30,
@@ -3835,14 +3835,14 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
           "roundness": {
           "roundness": {
             "type": 3,
             "type": 3,
           },
           },
-          "seed": 449462985,
+          "seed": 1278240551,
           "strokeColor": "#1e1e1e",
           "strokeColor": "#1e1e1e",
           "strokeStyle": "solid",
           "strokeStyle": "solid",
           "strokeWidth": 2,
           "strokeWidth": 2,
           "type": "rectangle",
           "type": "rectangle",
           "updated": 1,
           "updated": 1,
           "version": 2,
           "version": 2,
-          "versionNonce": 2019559783,
+          "versionNonce": 453191,
           "width": 20,
           "width": 20,
           "x": -10,
           "x": -10,
           "y": 0,
           "y": 0,
@@ -3865,14 +3865,14 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
           "roundness": {
           "roundness": {
             "type": 3,
             "type": 3,
           },
           },
-          "seed": 760410951,
+          "seed": 747212839,
           "strokeColor": "#e03131",
           "strokeColor": "#e03131",
           "strokeStyle": "dotted",
           "strokeStyle": "dotted",
           "strokeWidth": 2,
           "strokeWidth": 2,
           "type": "rectangle",
           "type": "rectangle",
           "updated": 1,
           "updated": 1,
           "version": 8,
           "version": 8,
-          "versionNonce": 1315507081,
+          "versionNonce": 1006504105,
           "width": 20,
           "width": 20,
           "x": 20,
           "x": 20,
           "y": 30,
           "y": 30,
@@ -3909,14 +3909,14 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
           "roundness": {
           "roundness": {
             "type": 3,
             "type": 3,
           },
           },
-          "seed": 449462985,
+          "seed": 1278240551,
           "strokeColor": "#e03131",
           "strokeColor": "#e03131",
           "strokeStyle": "dotted",
           "strokeStyle": "dotted",
           "strokeWidth": 2,
           "strokeWidth": 2,
           "type": "rectangle",
           "type": "rectangle",
           "updated": 1,
           "updated": 1,
           "version": 3,
           "version": 3,
-          "versionNonce": 640725609,
+          "versionNonce": 1315507081,
           "width": 20,
           "width": 20,
           "x": -10,
           "x": -10,
           "y": 0,
           "y": 0,
@@ -3939,14 +3939,14 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
           "roundness": {
           "roundness": {
             "type": 3,
             "type": 3,
           },
           },
-          "seed": 760410951,
+          "seed": 747212839,
           "strokeColor": "#e03131",
           "strokeColor": "#e03131",
           "strokeStyle": "dotted",
           "strokeStyle": "dotted",
           "strokeWidth": 2,
           "strokeWidth": 2,
           "type": "rectangle",
           "type": "rectangle",
           "updated": 1,
           "updated": 1,
           "version": 8,
           "version": 8,
-          "versionNonce": 1315507081,
+          "versionNonce": 1006504105,
           "width": 20,
           "width": 20,
           "x": 20,
           "x": 20,
           "y": 30,
           "y": 30,
@@ -4468,14 +4468,14 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
   "roundness": {
   "roundness": {
     "type": 3,
     "type": 3,
   },
   },
-  "seed": 1116226695,
+  "seed": 2019559783,
   "strokeColor": "#1e1e1e",
   "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeStyle": "solid",
   "strokeWidth": 2,
   "strokeWidth": 2,
   "type": "rectangle",
   "type": "rectangle",
   "updated": 1,
   "updated": 1,
   "version": 3,
   "version": 3,
-  "versionNonce": 1604849351,
+  "versionNonce": 238820263,
   "width": 20,
   "width": 20,
   "x": 20,
   "x": 20,
   "y": 30,
   "y": 30,
@@ -4501,14 +4501,14 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
   "roundness": {
   "roundness": {
     "type": 3,
     "type": 3,
   },
   },
-  "seed": 449462985,
+  "seed": 1278240551,
   "strokeColor": "#1e1e1e",
   "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeStyle": "solid",
   "strokeWidth": 2,
   "strokeWidth": 2,
   "type": "rectangle",
   "type": "rectangle",
   "updated": 1,
   "updated": 1,
   "version": 2,
   "version": 2,
-  "versionNonce": 2019559783,
+  "versionNonce": 453191,
   "width": 20,
   "width": 20,
   "x": -10,
   "x": -10,
   "y": 0,
   "y": 0,
@@ -4561,14 +4561,14 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
           "roundness": {
           "roundness": {
             "type": 3,
             "type": 3,
           },
           },
-          "seed": 449462985,
+          "seed": 1278240551,
           "strokeColor": "#1e1e1e",
           "strokeColor": "#1e1e1e",
           "strokeStyle": "solid",
           "strokeStyle": "solid",
           "strokeWidth": 2,
           "strokeWidth": 2,
           "type": "rectangle",
           "type": "rectangle",
           "updated": 1,
           "updated": 1,
           "version": 2,
           "version": 2,
-          "versionNonce": 2019559783,
+          "versionNonce": 453191,
           "width": 20,
           "width": 20,
           "x": -10,
           "x": -10,
           "y": 0,
           "y": 0,
@@ -4605,14 +4605,14 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
           "roundness": {
           "roundness": {
             "type": 3,
             "type": 3,
           },
           },
-          "seed": 449462985,
+          "seed": 1278240551,
           "strokeColor": "#1e1e1e",
           "strokeColor": "#1e1e1e",
           "strokeStyle": "solid",
           "strokeStyle": "solid",
           "strokeWidth": 2,
           "strokeWidth": 2,
           "type": "rectangle",
           "type": "rectangle",
           "updated": 1,
           "updated": 1,
           "version": 2,
           "version": 2,
-          "versionNonce": 2019559783,
+          "versionNonce": 453191,
           "width": 20,
           "width": 20,
           "x": -10,
           "x": -10,
           "y": 0,
           "y": 0,
@@ -4635,14 +4635,14 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
           "roundness": {
           "roundness": {
             "type": 3,
             "type": 3,
           },
           },
-          "seed": 1116226695,
+          "seed": 2019559783,
           "strokeColor": "#1e1e1e",
           "strokeColor": "#1e1e1e",
           "strokeStyle": "solid",
           "strokeStyle": "solid",
           "strokeWidth": 2,
           "strokeWidth": 2,
           "type": "rectangle",
           "type": "rectangle",
           "updated": 1,
           "updated": 1,
           "version": 2,
           "version": 2,
-          "versionNonce": 238820263,
+          "versionNonce": 1116226695,
           "width": 20,
           "width": 20,
           "x": 20,
           "x": 20,
           "y": 30,
           "y": 30,
@@ -4679,14 +4679,14 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
           "roundness": {
           "roundness": {
             "type": 3,
             "type": 3,
           },
           },
-          "seed": 1116226695,
+          "seed": 2019559783,
           "strokeColor": "#1e1e1e",
           "strokeColor": "#1e1e1e",
           "strokeStyle": "solid",
           "strokeStyle": "solid",
           "strokeWidth": 2,
           "strokeWidth": 2,
           "type": "rectangle",
           "type": "rectangle",
           "updated": 1,
           "updated": 1,
           "version": 3,
           "version": 3,
-          "versionNonce": 1604849351,
+          "versionNonce": 238820263,
           "width": 20,
           "width": 20,
           "x": 20,
           "x": 20,
           "y": 30,
           "y": 30,
@@ -4709,14 +4709,14 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
           "roundness": {
           "roundness": {
             "type": 3,
             "type": 3,
           },
           },
-          "seed": 449462985,
+          "seed": 1278240551,
           "strokeColor": "#1e1e1e",
           "strokeColor": "#1e1e1e",
           "strokeStyle": "solid",
           "strokeStyle": "solid",
           "strokeWidth": 2,
           "strokeWidth": 2,
           "type": "rectangle",
           "type": "rectangle",
           "updated": 1,
           "updated": 1,
           "version": 2,
           "version": 2,
-          "versionNonce": 2019559783,
+          "versionNonce": 453191,
           "width": 20,
           "width": 20,
           "x": -10,
           "x": -10,
           "y": 0,
           "y": 0,
@@ -6115,14 +6115,14 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
   "roundness": {
   "roundness": {
     "type": 3,
     "type": 3,
   },
   },
-  "seed": 453191,
+  "seed": 449462985,
   "strokeColor": "#1e1e1e",
   "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeStyle": "solid",
   "strokeWidth": 2,
   "strokeWidth": 2,
   "type": "rectangle",
   "type": "rectangle",
   "updated": 1,
   "updated": 1,
   "version": 2,
   "version": 2,
-  "versionNonce": 1116226695,
+  "versionNonce": 2019559783,
   "width": 10,
   "width": 10,
   "x": -10,
   "x": -10,
   "y": 0,
   "y": 0,
@@ -6148,14 +6148,14 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
   "roundness": {
   "roundness": {
     "type": 3,
     "type": 3,
   },
   },
-  "seed": 238820263,
+  "seed": 1116226695,
   "strokeColor": "#1e1e1e",
   "strokeColor": "#1e1e1e",
   "strokeStyle": "solid",
   "strokeStyle": "solid",
   "strokeWidth": 2,
   "strokeWidth": 2,
   "type": "rectangle",
   "type": "rectangle",
   "updated": 1,
   "updated": 1,
   "version": 2,
   "version": 2,
-  "versionNonce": 1604849351,
+  "versionNonce": 238820263,
   "width": 10,
   "width": 10,
   "x": 10,
   "x": 10,
   "y": 0,
   "y": 0,
@@ -6208,14 +6208,14 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
           "roundness": {
           "roundness": {
             "type": 3,
             "type": 3,
           },
           },
-          "seed": 453191,
+          "seed": 449462985,
           "strokeColor": "#1e1e1e",
           "strokeColor": "#1e1e1e",
           "strokeStyle": "solid",
           "strokeStyle": "solid",
           "strokeWidth": 2,
           "strokeWidth": 2,
           "type": "rectangle",
           "type": "rectangle",
           "updated": 1,
           "updated": 1,
           "version": 2,
           "version": 2,
-          "versionNonce": 1116226695,
+          "versionNonce": 2019559783,
           "width": 10,
           "width": 10,
           "x": -10,
           "x": -10,
           "y": 0,
           "y": 0,
@@ -6252,14 +6252,14 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
           "roundness": {
           "roundness": {
             "type": 3,
             "type": 3,
           },
           },
-          "seed": 453191,
+          "seed": 449462985,
           "strokeColor": "#1e1e1e",
           "strokeColor": "#1e1e1e",
           "strokeStyle": "solid",
           "strokeStyle": "solid",
           "strokeWidth": 2,
           "strokeWidth": 2,
           "type": "rectangle",
           "type": "rectangle",
           "updated": 1,
           "updated": 1,
           "version": 2,
           "version": 2,
-          "versionNonce": 1116226695,
+          "versionNonce": 2019559783,
           "width": 10,
           "width": 10,
           "x": -10,
           "x": -10,
           "y": 0,
           "y": 0,
@@ -6282,14 +6282,14 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
           "roundness": {
           "roundness": {
             "type": 3,
             "type": 3,
           },
           },
-          "seed": 238820263,
+          "seed": 1116226695,
           "strokeColor": "#1e1e1e",
           "strokeColor": "#1e1e1e",
           "strokeStyle": "solid",
           "strokeStyle": "solid",
           "strokeWidth": 2,
           "strokeWidth": 2,
           "type": "rectangle",
           "type": "rectangle",
           "updated": 1,
           "updated": 1,
           "version": 2,
           "version": 2,
-          "versionNonce": 1604849351,
+          "versionNonce": 238820263,
           "width": 10,
           "width": 10,
           "x": 10,
           "x": 10,
           "y": 0,
           "y": 0,

+ 9 - 2
packages/excalidraw/tests/helpers/ui.ts

@@ -287,9 +287,16 @@ const transform = (
   keyboardModifiers: KeyboardModifiers = {},
   keyboardModifiers: KeyboardModifiers = {},
 ) => {
 ) => {
   const elements = Array.isArray(element) ? element : [element];
   const elements = Array.isArray(element) ? element : [element];
-  mouse.select(elements);
+  h.setState({
+    selectedElementIds: elements.reduce(
+      (acc, e) => ({
+        ...acc,
+        [e.id]: true,
+      }),
+      {},
+    ),
+  });
   let handleCoords: TransformHandle | undefined;
   let handleCoords: TransformHandle | undefined;
-
   if (elements.length === 1) {
   if (elements.length === 1) {
     handleCoords = getTransformHandles(
     handleCoords = getTransformHandles(
       elements[0],
       elements[0],

+ 16 - 16
packages/excalidraw/tests/linearElementEditor.test.tsx

@@ -321,9 +321,9 @@ describe("Test Linear Elements", () => {
       fireEvent.click(screen.getByTitle("Round"));
       fireEvent.click(screen.getByTitle("Round"));
 
 
       expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
       expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
-        `10`,
+        `9`,
       );
       );
-      expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`8`);
+      expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
 
 
       const midPointsWithRoundEdge = LinearElementEditor.getEditorMidPoints(
       const midPointsWithRoundEdge = LinearElementEditor.getEditorMidPoints(
         h.elements[0] as ExcalidrawLinearElement,
         h.elements[0] as ExcalidrawLinearElement,
@@ -379,9 +379,9 @@ describe("Test Linear Elements", () => {
       drag(startPoint, endPoint);
       drag(startPoint, endPoint);
 
 
       expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
       expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
-        `13`,
+        `12`,
       );
       );
-      expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`9`);
+      expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`8`);
 
 
       expect([line.x, line.y]).toEqual([
       expect([line.x, line.y]).toEqual([
         points[0][0] + deltaX,
         points[0][0] + deltaX,
@@ -441,9 +441,9 @@ describe("Test Linear Elements", () => {
         ]);
         ]);
 
 
         expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
         expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
-          `17`,
+          `16`,
         );
         );
-        expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`9`);
+        expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`8`);
 
 
         expect(line.points.length).toEqual(5);
         expect(line.points.length).toEqual(5);
 
 
@@ -492,9 +492,9 @@ describe("Test Linear Elements", () => {
         drag(hitCoords, [hitCoords[0] - delta, hitCoords[1] - delta]);
         drag(hitCoords, [hitCoords[0] - delta, hitCoords[1] - delta]);
 
 
         expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
         expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
-          `13`,
+          `12`,
         );
         );
-        expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`8`);
+        expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
 
 
         const newPoints = LinearElementEditor.getPointsGlobalCoordinates(
         const newPoints = LinearElementEditor.getPointsGlobalCoordinates(
           line,
           line,
@@ -533,9 +533,9 @@ describe("Test Linear Elements", () => {
         drag(hitCoords, [hitCoords[0] + delta, hitCoords[1] + delta]);
         drag(hitCoords, [hitCoords[0] + delta, hitCoords[1] + delta]);
 
 
         expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
         expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
-          `13`,
+          `12`,
         );
         );
-        expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`8`);
+        expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
 
 
         const newPoints = LinearElementEditor.getPointsGlobalCoordinates(
         const newPoints = LinearElementEditor.getPointsGlobalCoordinates(
           line,
           line,
@@ -581,9 +581,9 @@ describe("Test Linear Elements", () => {
         deletePoint(points[2]);
         deletePoint(points[2]);
         expect(line.points.length).toEqual(3);
         expect(line.points.length).toEqual(3);
         expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
         expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
-          `19`,
+          `18`,
         );
         );
-        expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`9`);
+        expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`8`);
 
 
         const newMidPoints = LinearElementEditor.getEditorMidPoints(
         const newMidPoints = LinearElementEditor.getEditorMidPoints(
           line,
           line,
@@ -631,9 +631,9 @@ describe("Test Linear Elements", () => {
           lastSegmentMidpoint[1] + delta,
           lastSegmentMidpoint[1] + delta,
         ]);
         ]);
         expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
         expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
-          `17`,
+          `16`,
         );
         );
-        expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`9`);
+        expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`8`);
         expect(line.points.length).toEqual(5);
         expect(line.points.length).toEqual(5);
 
 
         expect((h.elements[0] as ExcalidrawLinearElement).points)
         expect((h.elements[0] as ExcalidrawLinearElement).points)
@@ -729,9 +729,9 @@ describe("Test Linear Elements", () => {
         drag(hitCoords, [hitCoords[0] + delta, hitCoords[1] + delta]);
         drag(hitCoords, [hitCoords[0] + delta, hitCoords[1] + delta]);
 
 
         expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
         expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
-          `13`,
+          `12`,
         );
         );
-        expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`8`);
+        expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
 
 
         const newPoints = LinearElementEditor.getPointsGlobalCoordinates(
         const newPoints = LinearElementEditor.getPointsGlobalCoordinates(
           line,
           line,

+ 2 - 1
packages/excalidraw/types.ts

@@ -586,6 +586,7 @@ export type AppClassProperties = {
   setOpenDialog: App["setOpenDialog"];
   setOpenDialog: App["setOpenDialog"];
   insertEmbeddableElement: App["insertEmbeddableElement"];
   insertEmbeddableElement: App["insertEmbeddableElement"];
   onMagicframeToolSelect: App["onMagicframeToolSelect"];
   onMagicframeToolSelect: App["onMagicframeToolSelect"];
+  getElementShape: App["getElementShape"];
   getName: App["getName"];
   getName: App["getName"];
 };
 };
 
 
@@ -722,7 +723,7 @@ export type Device = Readonly<{
   isTouchScreen: boolean;
   isTouchScreen: boolean;
 }>;
 }>;
 
 
-type FrameNameBounds = {
+export type FrameNameBounds = {
   x: number;
   x: number;
   y: number;
   y: number;
   width: number;
   width: number;

+ 66 - 0
packages/utils/collision.ts

@@ -0,0 +1,66 @@
+import { Point, Polygon, GeometricShape } from "./geometry/shape";
+import {
+  pointInEllipse,
+  pointInPolygon,
+  pointOnCurve,
+  pointOnEllipse,
+  pointOnLine,
+  pointOnPolycurve,
+  pointOnPolygon,
+  pointOnPolyline,
+  close,
+} from "./geometry/geometry";
+
+// check if the given point is considered on the given shape's border
+export const isPointOnShape = (
+  point: Point,
+  shape: GeometricShape,
+  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 pointOnLine(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: Point, shape: GeometricShape) => {
+  switch (shape.type) {
+    case "polygon":
+      return pointInPolygon(point, shape.data);
+    case "line":
+      return false;
+    case "curve":
+      return false;
+    case "ellipse":
+      return pointInEllipse(point, shape.data);
+    case "polyline": {
+      const polygon = close(shape.data.flat()) as Polygon;
+      return pointInPolygon(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: Point, bounds: Polygon) => {
+  return pointInPolygon(point, bounds);
+};

+ 249 - 0
packages/utils/geometry/geometry.test.ts

@@ -0,0 +1,249 @@
+import {
+  lineIntersectsLine,
+  lineRotate,
+  pointInEllipse,
+  pointInPolygon,
+  pointLeftofLine,
+  pointOnCurve,
+  pointOnEllipse,
+  pointOnLine,
+  pointOnPolygon,
+  pointOnPolyline,
+  pointRightofLine,
+  pointRotate,
+} from "./geometry";
+import { Curve, Ellipse, Line, Point, Polygon, Polyline } from "./shape";
+
+describe("point and line", () => {
+  const line: Line = [
+    [1, 0],
+    [1, 2],
+  ];
+
+  it("point on left or right of line", () => {
+    expect(pointLeftofLine([0, 1], line)).toBe(true);
+    expect(pointLeftofLine([1, 1], line)).toBe(false);
+    expect(pointLeftofLine([2, 1], line)).toBe(false);
+
+    expect(pointRightofLine([0, 1], line)).toBe(false);
+    expect(pointRightofLine([1, 1], line)).toBe(false);
+    expect(pointRightofLine([2, 1], line)).toBe(true);
+  });
+
+  it("point on the line", () => {
+    expect(pointOnLine([0, 1], line)).toBe(false);
+    expect(pointOnLine([1, 1], line, 0)).toBe(true);
+    expect(pointOnLine([2, 1], line)).toBe(false);
+  });
+});
+
+describe("point and polylines", () => {
+  const polyline: Polyline = [
+    [
+      [1, 0],
+      [1, 2],
+    ],
+    [
+      [1, 2],
+      [2, 2],
+    ],
+    [
+      [2, 2],
+      [2, 1],
+    ],
+    [
+      [2, 1],
+      [3, 1],
+    ],
+  ];
+
+  it("point on the line", () => {
+    expect(pointOnPolyline([1, 0], polyline)).toBe(true);
+    expect(pointOnPolyline([1, 2], polyline)).toBe(true);
+    expect(pointOnPolyline([2, 2], polyline)).toBe(true);
+    expect(pointOnPolyline([2, 1], polyline)).toBe(true);
+    expect(pointOnPolyline([3, 1], polyline)).toBe(true);
+
+    expect(pointOnPolyline([1, 1], polyline)).toBe(true);
+    expect(pointOnPolyline([2, 1.5], polyline)).toBe(true);
+    expect(pointOnPolyline([2.5, 1], polyline)).toBe(true);
+
+    expect(pointOnPolyline([0, 1], polyline)).toBe(false);
+    expect(pointOnPolyline([2.1, 1.5], polyline)).toBe(false);
+  });
+
+  it("point on the line with rotation", () => {
+    const truePoints = [
+      [1, 0],
+      [1, 2],
+      [2, 2],
+      [2, 1],
+      [3, 1],
+    ] as Point[];
+
+    truePoints.forEach((point) => {
+      const rotation = Math.random() * 360;
+      const rotatedPoint = pointRotate(point, rotation);
+      const rotatedPolyline: Polyline = polyline.map((line) =>
+        lineRotate(line, rotation, [0, 0]),
+      );
+      expect(pointOnPolyline(rotatedPoint, rotatedPolyline)).toBe(true);
+    });
+
+    const falsePoints = [
+      [0, 1],
+      [2.1, 1.5],
+    ] as Point[];
+
+    falsePoints.forEach((point) => {
+      const rotation = Math.random() * 360;
+      const rotatedPoint = pointRotate(point, rotation);
+      const rotatedPolyline: Polyline = polyline.map((line) =>
+        lineRotate(line, rotation, [0, 0]),
+      );
+      expect(pointOnPolyline(rotatedPoint, rotatedPolyline)).toBe(false);
+    });
+  });
+});
+
+describe("point and polygon", () => {
+  const polygon: Polygon = [
+    [10, 10],
+    [50, 10],
+    [50, 50],
+    [10, 50],
+  ];
+
+  it("point on polygon", () => {
+    expect(pointOnPolygon([30, 10], polygon)).toBe(true);
+    expect(pointOnPolygon([50, 30], polygon)).toBe(true);
+    expect(pointOnPolygon([30, 50], polygon)).toBe(true);
+    expect(pointOnPolygon([10, 30], polygon)).toBe(true);
+    expect(pointOnPolygon([30, 30], polygon)).toBe(false);
+    expect(pointOnPolygon([30, 70], polygon)).toBe(false);
+  });
+
+  it("point in polygon", () => {
+    const polygon: Polygon = [
+      [0, 0],
+      [2, 0],
+      [2, 2],
+      [0, 2],
+    ];
+    expect(pointInPolygon([1, 1], polygon)).toBe(true);
+    expect(pointInPolygon([3, 3], polygon)).toBe(false);
+  });
+});
+
+describe("point and curve", () => {
+  const curve: Curve = [
+    [1.4, 1.65],
+    [1.9, 7.9],
+    [5.9, 1.65],
+    [6.44, 4.84],
+  ];
+
+  it("point on curve", () => {
+    expect(pointOnCurve(curve[0], curve)).toBe(true);
+    expect(pointOnCurve(curve[3], curve)).toBe(true);
+
+    expect(pointOnCurve([2, 4], curve, 0.1)).toBe(true);
+    expect(pointOnCurve([4, 4.4], curve, 0.1)).toBe(true);
+    expect(pointOnCurve([5.6, 3.85], curve, 0.1)).toBe(true);
+
+    expect(pointOnCurve([5.6, 4], curve, 0.1)).toBe(false);
+    expect(pointOnCurve(curve[1], curve, 0.1)).toBe(false);
+    expect(pointOnCurve(curve[2], curve, 0.1)).toBe(false);
+  });
+});
+
+describe("point and ellipse", () => {
+  const ellipse: Ellipse = {
+    center: [0, 0],
+    angle: 0,
+    halfWidth: 2,
+    halfHeight: 1,
+  };
+
+  it("point on ellipse", () => {
+    [
+      [0, 1],
+      [0, -1],
+      [2, 0],
+      [-2, 0],
+    ].forEach((point) => {
+      expect(pointOnEllipse(point as Point, ellipse)).toBe(true);
+    });
+    expect(pointOnEllipse([-1.4, 0.7], ellipse, 0.1)).toBe(true);
+    expect(pointOnEllipse([-1.4, 0.71], ellipse, 0.01)).toBe(true);
+
+    expect(pointOnEllipse([1.4, 0.7], ellipse, 0.1)).toBe(true);
+    expect(pointOnEllipse([1.4, 0.71], ellipse, 0.01)).toBe(true);
+
+    expect(pointOnEllipse([1, -0.86], ellipse, 0.1)).toBe(true);
+    expect(pointOnEllipse([1, -0.86], ellipse, 0.01)).toBe(true);
+
+    expect(pointOnEllipse([-1, -0.86], ellipse, 0.1)).toBe(true);
+    expect(pointOnEllipse([-1, -0.86], ellipse, 0.01)).toBe(true);
+
+    expect(pointOnEllipse([-1, 0.8], ellipse)).toBe(false);
+    expect(pointOnEllipse([1, -0.8], ellipse)).toBe(false);
+  });
+
+  it("point in ellipse", () => {
+    [
+      [0, 1],
+      [0, -1],
+      [2, 0],
+      [-2, 0],
+    ].forEach((point) => {
+      expect(pointInEllipse(point as Point, ellipse)).toBe(true);
+    });
+
+    expect(pointInEllipse([-1, 0.8], ellipse)).toBe(true);
+    expect(pointInEllipse([1, -0.8], ellipse)).toBe(true);
+
+    expect(pointInEllipse([-1, 1], ellipse)).toBe(false);
+    expect(pointInEllipse([-1.4, 0.8], ellipse)).toBe(false);
+  });
+});
+
+describe("line and line", () => {
+  const lineA: Line = [
+    [1, 4],
+    [3, 4],
+  ];
+  const lineB: Line = [
+    [2, 1],
+    [2, 7],
+  ];
+  const lineC: Line = [
+    [1, 8],
+    [3, 8],
+  ];
+  const lineD: Line = [
+    [1, 8],
+    [3, 8],
+  ];
+  const lineE: Line = [
+    [1, 9],
+    [3, 9],
+  ];
+  const lineF: Line = [
+    [1, 2],
+    [3, 4],
+  ];
+  const lineG: Line = [
+    [0, 1],
+    [2, 3],
+  ];
+
+  it("intersection", () => {
+    expect(lineIntersectsLine(lineA, lineB)).toBe(true);
+    expect(lineIntersectsLine(lineA, lineC)).toBe(false);
+    expect(lineIntersectsLine(lineB, lineC)).toBe(false);
+    expect(lineIntersectsLine(lineC, lineD)).toBe(true);
+    expect(lineIntersectsLine(lineE, lineD)).toBe(false);
+    expect(lineIntersectsLine(lineF, lineG)).toBe(true);
+  });
+});

+ 956 - 0
packages/utils/geometry/geometry.ts

@@ -0,0 +1,956 @@
+import { distance2d } from "../../excalidraw/math";
+import {
+  Point,
+  Line,
+  Polygon,
+  Curve,
+  Ellipse,
+  Polycurve,
+  Polyline,
+} from "./shape";
+
+const DEFAULT_THRESHOLD = 10e-5;
+
+/**
+ * utils
+ */
+
+// the two vectors are ao and bo
+export const cross = (a: Point, b: Point, o: Point) => {
+  return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
+};
+
+export const isClosed = (polygon: Polygon) => {
+  const first = polygon[0];
+  const last = polygon[polygon.length - 1];
+  return first[0] === last[0] && first[1] === last[1];
+};
+
+export const close = (polygon: Polygon) => {
+  return isClosed(polygon) ? polygon : [...polygon, polygon[0]];
+};
+
+/**
+ * angles
+ */
+
+// convert radians to degress
+export const angleToDegrees = (angle: number) => {
+  return (angle * 180) / Math.PI;
+};
+
+// convert degrees to radians
+export const angleToRadians = (angle: number) => {
+  return (angle / 180) * Math.PI;
+};
+
+// return the angle of reflection given an angle of incidence and a surface angle in degrees
+export const angleReflect = (incidenceAngle: number, surfaceAngle: number) => {
+  const a = surfaceAngle * 2 - incidenceAngle;
+  return a >= 360 ? a - 360 : a < 0 ? a + 360 : a;
+};
+
+/**
+ * points
+ */
+
+const rotate = (point: Point, angle: number): Point => {
+  return [
+    point[0] * Math.cos(angle) - point[1] * Math.sin(angle),
+    point[0] * Math.sin(angle) + point[1] * Math.cos(angle),
+  ];
+};
+
+const isOrigin = (point: Point) => {
+  return point[0] === 0 && point[1] === 0;
+};
+
+// rotate a given point about a given origin at the given angle
+export const pointRotate = (
+  point: Point,
+  angle: number,
+  origin?: Point,
+): Point => {
+  const r = angleToRadians(angle);
+
+  if (!origin || isOrigin(origin)) {
+    return rotate(point, r);
+  }
+  return rotate(point.map((c, i) => c - origin[i]) as Point, r).map(
+    (c, i) => c + origin[i],
+  ) as Point;
+};
+
+// translate a point by an angle (in degrees) and distance
+export const pointTranslate = (point: Point, angle = 0, distance = 0) => {
+  const r = angleToRadians(angle);
+  return [
+    point[0] + distance * Math.cos(r),
+    point[1] + distance * Math.sin(r),
+  ] as Point;
+};
+
+export const pointInverse = (point: Point) => {
+  return [-point[0], -point[1]] as Point;
+};
+
+export const pointAdd = (pointA: Point, pointB: Point): Point => {
+  return [pointA[0] + pointB[0], pointA[1] + pointB[1]];
+};
+
+export const distanceToPoint = (p1: Point, p2: Point) => {
+  return distance2d(...p1, ...p2);
+};
+
+/**
+ * lines
+ */
+
+// return the angle of a line, in degrees
+export const lineAngle = (line: Line) => {
+  return angleToDegrees(
+    Math.atan2(line[1][1] - line[0][1], line[1][0] - line[0][0]),
+  );
+};
+
+// get the distance between the endpoints of a line segment
+export const lineLength = (line: Line) => {
+  return Math.sqrt(
+    Math.pow(line[1][0] - line[0][0], 2) + Math.pow(line[1][1] - line[0][1], 2),
+  );
+};
+
+// get the midpoint of a line segment
+export const lineMidpoint = (line: Line) => {
+  return [
+    (line[0][0] + line[1][0]) / 2,
+    (line[0][1] + line[1][1]) / 2,
+  ] as Point;
+};
+
+// return the coordinates resulting from rotating the given line about an origin by an angle in degrees
+// note that when the origin is not given, the midpoint of the given line is used as the origin
+export const lineRotate = (line: Line, angle: number, origin?: Point): Line => {
+  return line.map((point) =>
+    pointRotate(point, angle, origin || lineMidpoint(line)),
+  ) as Line;
+};
+
+// returns the coordinates resulting from translating a line by an angle in degrees and a distance.
+export const lineTranslate = (line: Line, angle: number, distance: number) => {
+  return line.map((point) => pointTranslate(point, angle, distance));
+};
+
+export const lineInterpolate = (line: Line, clamp = false) => {
+  const [[x1, y1], [x2, y2]] = line;
+  return (t: number) => {
+    const t0 = clamp ? (t < 0 ? 0 : t > 1 ? 1 : t) : t;
+    return [(x2 - x1) * t0 + x1, (y2 - y1) * t0 + y1] as Point;
+  };
+};
+
+/**
+ * curves
+ */
+function clone(p: Point): Point {
+  return [...p] as Point;
+}
+
+export const curveToBezier = (
+  pointsIn: readonly Point[],
+  curveTightness = 0,
+): Point[] => {
+  const len = pointsIn.length;
+  if (len < 3) {
+    throw new Error("A curve must have at least three points.");
+  }
+  const out: Point[] = [];
+  if (len === 3) {
+    out.push(
+      clone(pointsIn[0]),
+      clone(pointsIn[1]),
+      clone(pointsIn[2]),
+      clone(pointsIn[2]),
+    );
+  } else {
+    const points: Point[] = [];
+    points.push(pointsIn[0], pointsIn[0]);
+    for (let i = 1; i < pointsIn.length; i++) {
+      points.push(pointsIn[i]);
+      if (i === pointsIn.length - 1) {
+        points.push(pointsIn[i]);
+      }
+    }
+    const b: Point[] = [];
+    const s = 1 - curveTightness;
+    out.push(clone(points[0]));
+    for (let i = 1; i + 2 < points.length; i++) {
+      const cachedVertArray = points[i];
+      b[0] = [cachedVertArray[0], cachedVertArray[1]];
+      b[1] = [
+        cachedVertArray[0] + (s * points[i + 1][0] - s * points[i - 1][0]) / 6,
+        cachedVertArray[1] + (s * points[i + 1][1] - s * points[i - 1][1]) / 6,
+      ];
+      b[2] = [
+        points[i + 1][0] + (s * points[i][0] - s * points[i + 2][0]) / 6,
+        points[i + 1][1] + (s * points[i][1] - s * points[i + 2][1]) / 6,
+      ];
+      b[3] = [points[i + 1][0], points[i + 1][1]];
+      out.push(b[1], b[2], b[3]);
+    }
+  }
+  return out;
+};
+
+export const curveRotate = (curve: Curve, angle: number, origin: Point) => {
+  return curve.map((p) => pointRotate(p, angle, origin));
+};
+
+export const cubicBezierPoint = (t: number, controlPoints: Curve): Point => {
+  const [p0, p1, p2, p3] = controlPoints;
+
+  const x =
+    Math.pow(1 - t, 3) * p0[0] +
+    3 * Math.pow(1 - t, 2) * t * p1[0] +
+    3 * (1 - t) * Math.pow(t, 2) * p2[0] +
+    Math.pow(t, 3) * p3[0];
+
+  const y =
+    Math.pow(1 - t, 3) * p0[1] +
+    3 * Math.pow(1 - t, 2) * t * p1[1] +
+    3 * (1 - t) * Math.pow(t, 2) * p2[1] +
+    Math.pow(t, 3) * p3[1];
+
+  return [x, y];
+};
+
+const solveCubicEquation = (a: number, b: number, c: number, d: number) => {
+  // This function solves the cubic equation ax^3 + bx^2 + cx + d = 0
+  const roots: number[] = [];
+
+  const discriminant =
+    18 * a * b * c * d -
+    4 * Math.pow(b, 3) * d +
+    Math.pow(b, 2) * Math.pow(c, 2) -
+    4 * a * Math.pow(c, 3) -
+    27 * Math.pow(a, 2) * Math.pow(d, 2);
+
+  if (discriminant >= 0) {
+    const C = Math.cbrt((discriminant + Math.sqrt(discriminant)) / 2);
+    const D = Math.cbrt((discriminant - Math.sqrt(discriminant)) / 2);
+
+    const root1 = (-b - C - D) / (3 * a);
+    const root2 = (-b + (C + D) / 2) / (3 * a);
+    const root3 = (-b + (C + D) / 2) / (3 * a);
+
+    roots.push(root1, root2, root3);
+  } else {
+    const realPart = -b / (3 * a);
+
+    const root1 =
+      2 * Math.sqrt(-b / (3 * a)) * Math.cos(Math.acos(realPart) / 3);
+    const root2 =
+      2 *
+      Math.sqrt(-b / (3 * a)) *
+      Math.cos((Math.acos(realPart) + 2 * Math.PI) / 3);
+    const root3 =
+      2 *
+      Math.sqrt(-b / (3 * a)) *
+      Math.cos((Math.acos(realPart) + 4 * Math.PI) / 3);
+
+    roots.push(root1, root2, root3);
+  }
+
+  return roots;
+};
+
+const findClosestParameter = (point: Point, controlPoints: Curve) => {
+  // This function finds the parameter t that minimizes the distance between the point
+  // and any point on the cubic Bezier curve.
+
+  const [p0, p1, p2, p3] = controlPoints;
+
+  // Use the direct formula to find the parameter t
+  const a = p3[0] - 3 * p2[0] + 3 * p1[0] - p0[0];
+  const b = 3 * p2[0] - 6 * p1[0] + 3 * p0[0];
+  const c = 3 * p1[0] - 3 * p0[0];
+  const d = p0[0] - point[0];
+
+  const rootsX = solveCubicEquation(a, b, c, d);
+
+  // Do the same for the y-coordinate
+  const e = p3[1] - 3 * p2[1] + 3 * p1[1] - p0[1];
+  const f = 3 * p2[1] - 6 * p1[1] + 3 * p0[1];
+  const g = 3 * p1[1] - 3 * p0[1];
+  const h = p0[1] - point[1];
+
+  const rootsY = solveCubicEquation(e, f, g, h);
+
+  // Select the real root that is between 0 and 1 (inclusive)
+  const validRootsX = rootsX.filter((root) => root >= 0 && root <= 1);
+  const validRootsY = rootsY.filter((root) => root >= 0 && root <= 1);
+
+  if (validRootsX.length === 0 || validRootsY.length === 0) {
+    // No valid roots found, use the midpoint as a fallback
+    return 0.5;
+  }
+
+  // Choose the parameter t that minimizes the distance
+  let minDistance = Infinity;
+  let closestT = 0;
+
+  for (const rootX of validRootsX) {
+    for (const rootY of validRootsY) {
+      const distance = Math.sqrt(
+        (rootX - point[0]) ** 2 + (rootY - point[1]) ** 2,
+      );
+      if (distance < minDistance) {
+        minDistance = distance;
+        closestT = (rootX + rootY) / 2; // Use the average for a smoother result
+      }
+    }
+  }
+
+  return closestT;
+};
+
+export const cubicBezierDistance = (point: Point, controlPoints: Curve) => {
+  // Calculate the closest point on the Bezier curve to the given point
+  const t = findClosestParameter(point, controlPoints);
+
+  // Calculate the coordinates of the closest point on the curve
+  const [closestX, closestY] = cubicBezierPoint(t, controlPoints);
+
+  // Calculate the distance between the given point and the closest point on the curve
+  const distance = Math.sqrt(
+    (point[0] - closestX) ** 2 + (point[1] - closestY) ** 2,
+  );
+
+  return distance;
+};
+
+/**
+ * polygons
+ */
+
+export const polygonRotate = (
+  polygon: Polygon,
+  angle: number,
+  origin: Point,
+) => {
+  return polygon.map((p) => pointRotate(p, angle, origin));
+};
+
+export const polygonBounds = (polygon: Polygon) => {
+  let xMin = Infinity;
+  let xMax = -Infinity;
+  let yMin = Infinity;
+  let yMax = -Infinity;
+
+  for (let i = 0, l = polygon.length; i < l; i++) {
+    const p = polygon[i];
+    const x = p[0];
+    const y = p[1];
+
+    if (x != null && isFinite(x) && y != null && isFinite(y)) {
+      if (x < xMin) {
+        xMin = x;
+      }
+      if (x > xMax) {
+        xMax = x;
+      }
+      if (y < yMin) {
+        yMin = y;
+      }
+      if (y > yMax) {
+        yMax = y;
+      }
+    }
+  }
+
+  return [
+    [xMin, yMin],
+    [xMax, yMax],
+  ] as [Point, Point];
+};
+
+export const polygonCentroid = (vertices: Point[]) => {
+  let a = 0;
+  let x = 0;
+  let y = 0;
+  const l = vertices.length;
+
+  for (let i = 0; i < l; i++) {
+    const s = i === l - 1 ? 0 : i + 1;
+    const v0 = vertices[i];
+    const v1 = vertices[s];
+    const f = v0[0] * v1[1] - v1[0] * v0[1];
+
+    a += f;
+    x += (v0[0] + v1[0]) * f;
+    y += (v0[1] + v1[1]) * f;
+  }
+
+  const d = a * 3;
+
+  return [x / d, y / d] as Point;
+};
+
+export const polygonScale = (
+  polygon: Polygon,
+  scale: number,
+  origin?: Point,
+) => {
+  if (!origin) {
+    origin = polygonCentroid(polygon);
+  }
+
+  const p: Polygon = [];
+
+  for (let i = 0, l = polygon.length; i < l; i++) {
+    const v = polygon[i];
+    const d = lineLength([origin, v]);
+    const a = lineAngle([origin, v]);
+
+    p[i] = pointTranslate(origin, a, d * scale);
+  }
+
+  return p;
+};
+
+export const polygonScaleX = (
+  polygon: Polygon,
+  scale: number,
+  origin?: Point,
+) => {
+  if (!origin) {
+    origin = polygonCentroid(polygon);
+  }
+
+  const p: Polygon = [];
+
+  for (let i = 0, l = polygon.length; i < l; i++) {
+    const v = polygon[i];
+    const d = lineLength([origin, v]);
+    const a = lineAngle([origin, v]);
+    const t = pointTranslate(origin, a, d * scale);
+
+    p[i] = [t[0], v[1]];
+  }
+
+  return p;
+};
+
+export const polygonScaleY = (
+  polygon: Polygon,
+  scale: number,
+  origin?: Point,
+) => {
+  if (!origin) {
+    origin = polygonCentroid(polygon);
+  }
+
+  const p: Polygon = [];
+
+  for (let i = 0, l = polygon.length; i < l; i++) {
+    const v = polygon[i];
+    const d = lineLength([origin, v]);
+    const a = lineAngle([origin, v]);
+    const t = pointTranslate(origin, a, d * scale);
+
+    p[i] = [v[0], t[1]];
+  }
+
+  return p;
+};
+
+export const polygonReflectX = (polygon: Polygon, reflectFactor = 1) => {
+  const [[min], [max]] = polygonBounds(polygon);
+  const p: Point[] = [];
+
+  for (let i = 0, l = polygon.length; i < l; i++) {
+    const [x, y] = polygon[i];
+    const r: Point = [min + max - x, y];
+
+    if (reflectFactor === 0) {
+      p[i] = [x, y];
+    } else if (reflectFactor === 1) {
+      p[i] = r;
+    } else {
+      const t = lineInterpolate([[x, y], r]);
+      p[i] = t(Math.max(Math.min(reflectFactor, 1), 0));
+    }
+  }
+
+  return p;
+};
+
+export const polygonReflectY = (polygon: Polygon, reflectFactor = 1) => {
+  const [[, min], [, max]] = polygonBounds(polygon);
+  const p: Point[] = [];
+
+  for (let i = 0, l = polygon.length; i < l; i++) {
+    const [x, y] = polygon[i];
+    const r: Point = [x, min + max - y];
+
+    if (reflectFactor === 0) {
+      p[i] = [x, y];
+    } else if (reflectFactor === 1) {
+      p[i] = r;
+    } else {
+      const t = lineInterpolate([[x, y], r]);
+      p[i] = t(Math.max(Math.min(reflectFactor, 1), 0));
+    }
+  }
+
+  return p;
+};
+
+export const polygonTranslate = (
+  polygon: Polygon,
+  angle: number,
+  distance: number,
+) => {
+  return polygon.map((p) => pointTranslate(p, angle, distance));
+};
+
+/**
+ * ellipses
+ */
+
+export const ellipseAxes = (ellipse: Ellipse) => {
+  const widthGreaterThanHeight = ellipse.halfWidth > ellipse.halfHeight;
+
+  const majorAxis = widthGreaterThanHeight
+    ? ellipse.halfWidth * 2
+    : ellipse.halfHeight * 2;
+  const minorAxis = widthGreaterThanHeight
+    ? ellipse.halfHeight * 2
+    : ellipse.halfWidth * 2;
+
+  return {
+    majorAxis,
+    minorAxis,
+  };
+};
+
+export const ellipseFocusToCenter = (ellipse: Ellipse) => {
+  const { majorAxis, minorAxis } = ellipseAxes(ellipse);
+
+  return Math.sqrt(majorAxis ** 2 - minorAxis ** 2);
+};
+
+export const ellipseExtremes = (ellipse: Ellipse) => {
+  const { center, angle } = ellipse;
+  const { majorAxis, minorAxis } = ellipseAxes(ellipse);
+
+  const cos = Math.cos(angle);
+  const sin = Math.sin(angle);
+
+  const sqSum = majorAxis ** 2 + minorAxis ** 2;
+  const sqDiff = (majorAxis ** 2 - minorAxis ** 2) * Math.cos(2 * angle);
+
+  const yMax = Math.sqrt((sqSum - sqDiff) / 2);
+  const xAtYMax =
+    (yMax * sqSum * sin * cos) /
+    (majorAxis ** 2 * sin ** 2 + minorAxis ** 2 * cos ** 2);
+
+  const xMax = Math.sqrt((sqSum + sqDiff) / 2);
+  const yAtXMax =
+    (xMax * sqSum * sin * cos) /
+    (majorAxis ** 2 * cos ** 2 + minorAxis ** 2 * sin ** 2);
+
+  return [
+    pointAdd([xAtYMax, yMax], center),
+    pointAdd(pointInverse([xAtYMax, yMax]), center),
+    pointAdd([xMax, yAtXMax], center),
+    pointAdd([xMax, yAtXMax], center),
+  ];
+};
+
+export const pointRelativeToCenter = (
+  point: Point,
+  center: Point,
+  angle: number,
+): Point => {
+  const translated = pointAdd(point, pointInverse(center));
+  const rotated = pointRotate(translated, -angleToDegrees(angle));
+
+  return rotated;
+};
+
+/**
+ * relationships
+ */
+
+const topPointFirst = (line: Line) => {
+  return line[1][1] > line[0][1] ? line : [line[1], line[0]];
+};
+
+export const pointLeftofLine = (point: Point, line: Line) => {
+  const t = topPointFirst(line);
+  return cross(point, t[1], t[0]) < 0;
+};
+
+export const pointRightofLine = (point: Point, line: Line) => {
+  const t = topPointFirst(line);
+  return cross(point, t[1], t[0]) > 0;
+};
+
+export const distanceToSegment = (point: Point, line: Line) => {
+  const [x, y] = point;
+  const [[x1, y1], [x2, y2]] = line;
+
+  const A = x - x1;
+  const B = y - y1;
+  const C = x2 - x1;
+  const D = y2 - y1;
+
+  const dot = A * C + B * D;
+  const len_sq = C * C + D * D;
+  let param = -1;
+  if (len_sq !== 0) {
+    param = dot / len_sq;
+  }
+
+  let xx;
+  let yy;
+
+  if (param < 0) {
+    xx = x1;
+    yy = y1;
+  } else if (param > 1) {
+    xx = x2;
+    yy = y2;
+  } else {
+    xx = x1 + param * C;
+    yy = y1 + param * D;
+  }
+
+  const dx = x - xx;
+  const dy = y - yy;
+  return Math.sqrt(dx * dx + dy * dy);
+};
+
+export const pointOnLine = (
+  point: Point,
+  line: Line,
+  threshold = DEFAULT_THRESHOLD,
+) => {
+  const distance = distanceToSegment(point, line);
+
+  if (distance === 0) {
+    return true;
+  }
+
+  return distance < threshold;
+};
+
+export const pointOnPolyline = (
+  point: Point,
+  polyline: Polyline,
+  threshold = DEFAULT_THRESHOLD,
+) => {
+  return polyline.some((line) => pointOnLine(point, line, threshold));
+};
+
+export const lineIntersectsLine = (lineA: Line, lineB: Line) => {
+  const [[a0x, a0y], [a1x, a1y]] = lineA;
+  const [[b0x, b0y], [b1x, b1y]] = lineB;
+
+  // shared points
+  if (a0x === b0x && a0y === b0y) {
+    return true;
+  }
+  if (a1x === b1x && a1y === b1y) {
+    return true;
+  }
+
+  // point on line
+  if (pointOnLine(lineA[0], lineB) || pointOnLine(lineA[1], lineB)) {
+    return true;
+  }
+  if (pointOnLine(lineB[0], lineA) || pointOnLine(lineB[1], lineA)) {
+    return true;
+  }
+
+  const denom = (b1y - b0y) * (a1x - a0x) - (b1x - b0x) * (a1y - a0y);
+
+  if (denom === 0) {
+    return false;
+  }
+
+  const deltaY = a0y - b0y;
+  const deltaX = a0x - b0x;
+  const numer0 = (b1x - b0x) * deltaY - (b1y - b0y) * deltaX;
+  const numer1 = (a1x - a0x) * deltaY - (a1y - a0y) * deltaX;
+  const quotA = numer0 / denom;
+  const quotB = numer1 / denom;
+
+  return quotA > 0 && quotA < 1 && quotB > 0 && quotB < 1;
+};
+
+export const lineIntersectsPolygon = (line: Line, polygon: Polygon) => {
+  let intersects = false;
+  const closed = close(polygon);
+
+  for (let i = 0, l = closed.length - 1; i < l; i++) {
+    const v0 = closed[i];
+    const v1 = closed[i + 1];
+
+    if (
+      lineIntersectsLine(line, [v0, v1]) ||
+      (pointOnLine(v0, line) && pointOnLine(v1, line))
+    ) {
+      intersects = true;
+      break;
+    }
+  }
+
+  return intersects;
+};
+
+export const pointInBezierEquation = (
+  p0: Point,
+  p1: Point,
+  p2: Point,
+  p3: Point,
+  [mx, my]: Point,
+  lineThreshold: number,
+) => {
+  // B(t) = p0 * (1-t)^3 + 3p1 * t * (1-t)^2 + 3p2 * t^2 * (1-t) + p3 * t^3
+  const equation = (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 lineSegmentPoints: Point[] = [];
+  let t = 0;
+  while (t <= 1.0) {
+    const tx = equation(t, 0);
+    const ty = equation(t, 1);
+
+    const diff = Math.sqrt(Math.pow(tx - mx, 2) + Math.pow(ty - my, 2));
+
+    if (diff < lineThreshold) {
+      return true;
+    }
+
+    lineSegmentPoints.push([tx, ty]);
+
+    t += 0.1;
+  }
+
+  // check the distance from line segments to the given point
+
+  return false;
+};
+
+export const cubicBezierEquation = (curve: Curve) => {
+  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);
+};
+
+export const polyLineFromCurve = (curve: Curve, segments = 10): Polyline => {
+  const equation = cubicBezierEquation(curve);
+  let startingPoint = [equation(0, 0), equation(0, 1)] as Point;
+  const lineSegments: Polyline = [];
+  let t = 0;
+  const increment = 1 / segments;
+
+  for (let i = 0; i < segments; i++) {
+    t += increment;
+    if (t <= 1) {
+      const nextPoint: Point = [equation(t, 0), equation(t, 1)];
+      lineSegments.push([startingPoint, nextPoint]);
+      startingPoint = nextPoint;
+    }
+  }
+
+  return lineSegments;
+};
+
+export const pointOnCurve = (
+  point: Point,
+  curve: Curve,
+  threshold = DEFAULT_THRESHOLD,
+) => {
+  return pointOnPolyline(point, polyLineFromCurve(curve), threshold);
+};
+
+export const pointOnPolycurve = (
+  point: Point,
+  polycurve: Polycurve,
+  threshold = DEFAULT_THRESHOLD,
+) => {
+  return polycurve.some((curve) => pointOnCurve(point, curve, threshold));
+};
+
+export const pointInPolygon = (point: Point, polygon: Polygon) => {
+  const x = point[0];
+  const y = point[1];
+  let inside = false;
+
+  for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
+    const xi = polygon[i][0];
+    const yi = polygon[i][1];
+    const xj = polygon[j][0];
+    const yj = polygon[j][1];
+
+    if (
+      ((yi > y && yj <= y) || (yi <= y && yj > y)) &&
+      x < ((xj - xi) * (y - yi)) / (yj - yi) + xi
+    ) {
+      inside = !inside;
+    }
+  }
+
+  return inside;
+};
+
+export const pointOnPolygon = (
+  point: Point,
+  polygon: Polygon,
+  threshold = DEFAULT_THRESHOLD,
+) => {
+  let on = false;
+  const closed = close(polygon);
+
+  for (let i = 0, l = closed.length - 1; i < l; i++) {
+    if (pointOnLine(point, [closed[i], closed[i + 1]], threshold)) {
+      on = true;
+      break;
+    }
+  }
+
+  return on;
+};
+
+export const polygonInPolygon = (polygonA: Polygon, polygonB: Polygon) => {
+  let inside = true;
+  const closed = close(polygonA);
+
+  for (let i = 0, l = closed.length - 1; i < l; i++) {
+    const v0 = closed[i];
+
+    // Points test
+    if (!pointInPolygon(v0, polygonB)) {
+      inside = false;
+      break;
+    }
+
+    // Lines test
+    if (lineIntersectsPolygon([v0, closed[i + 1]], polygonB)) {
+      inside = false;
+      break;
+    }
+  }
+
+  return inside;
+};
+
+export const polygonIntersectPolygon = (
+  polygonA: Polygon,
+  polygonB: Polygon,
+) => {
+  let intersects = false;
+  let onCount = 0;
+  const closed = close(polygonA);
+
+  for (let i = 0, l = closed.length - 1; i < l; i++) {
+    const v0 = closed[i];
+    const v1 = closed[i + 1];
+
+    if (lineIntersectsPolygon([v0, v1], polygonB)) {
+      intersects = true;
+      break;
+    }
+
+    if (pointOnPolygon(v0, polygonB)) {
+      ++onCount;
+    }
+
+    if (onCount === 2) {
+      intersects = true;
+      break;
+    }
+  }
+
+  return intersects;
+};
+
+const distanceToEllipse = (point: Point, ellipse: Ellipse) => {
+  const { angle, halfWidth, halfHeight, center } = ellipse;
+  const a = halfWidth;
+  const b = halfHeight;
+  const [rotatedPointX, rotatedPointY] = pointRelativeToCenter(
+    point,
+    center,
+    angle,
+  );
+
+  const px = Math.abs(rotatedPointX);
+  const py = Math.abs(rotatedPointY);
+
+  let tx = 0.707;
+  let ty = 0.707;
+
+  for (let i = 0; i < 3; i++) {
+    const x = a * tx;
+    const y = b * ty;
+
+    const ex = ((a * a - b * b) * tx ** 3) / a;
+    const ey = ((b * b - a * a) * ty ** 3) / b;
+
+    const rx = x - ex;
+    const ry = y - ey;
+
+    const qx = px - ex;
+    const qy = py - ey;
+
+    const r = Math.hypot(ry, rx);
+    const q = Math.hypot(qy, qx);
+
+    tx = Math.min(1, Math.max(0, ((qx * r) / q + ex) / a));
+    ty = Math.min(1, Math.max(0, ((qy * r) / q + ey) / b));
+    const t = Math.hypot(ty, tx);
+    tx /= t;
+    ty /= t;
+  }
+
+  const [minX, minY] = [
+    a * tx * Math.sign(rotatedPointX),
+    b * ty * Math.sign(rotatedPointY),
+  ];
+
+  return distanceToPoint([rotatedPointX, rotatedPointY], [minX, minY]);
+};
+
+export const pointOnEllipse = (
+  point: Point,
+  ellipse: Ellipse,
+  threshold = DEFAULT_THRESHOLD,
+) => {
+  return distanceToEllipse(point, ellipse) <= threshold;
+};
+
+export const pointInEllipse = (point: Point, ellipse: Ellipse) => {
+  const { center, angle, halfWidth, halfHeight } = ellipse;
+  const [rotatedPointX, rotatedPointY] = pointRelativeToCenter(
+    point,
+    center,
+    angle,
+  );
+
+  return (
+    (rotatedPointX / halfWidth) * (rotatedPointX / halfWidth) +
+      (rotatedPointY / halfHeight) * (rotatedPointY / halfHeight) <=
+    1
+  );
+};

+ 278 - 0
packages/utils/geometry/shape.ts

@@ -0,0 +1,278 @@
+/**
+ * this file defines pure geometric shapes
+ *
+ * for instance, a cubic bezier curve is specified by its four control points and
+ * an ellipse is defined by its center, angle, semi major axis and semi minor axis
+ * (but in semi-width and semi-height so it's more relevant to Excalidraw)
+ *
+ * the idea with pure shapes is so that we can provide collision and other geoemtric methods not depending on
+ * the specifics of roughjs or elements in Excalidraw; instead, we can focus on the pure shapes themselves
+ *
+ * also included in this file are methods for converting an Excalidraw element or a Drawable from roughjs
+ * to pure shapes
+ */
+
+import {
+  ExcalidrawDiamondElement,
+  ExcalidrawEllipseElement,
+  ExcalidrawEmbeddableElement,
+  ExcalidrawFrameLikeElement,
+  ExcalidrawFreeDrawElement,
+  ExcalidrawIframeElement,
+  ExcalidrawImageElement,
+  ExcalidrawRectangleElement,
+  ExcalidrawSelectionElement,
+  ExcalidrawTextElement,
+} from "../../excalidraw/element/types";
+import { angleToDegrees, close, pointAdd, pointRotate } from "./geometry";
+import { pointsOnBezierCurves } from "points-on-curve";
+import type { Drawable, Op } from "roughjs/bin/core";
+
+// a point is specified by its coordinate (x, y)
+export type Point = [number, number];
+export type Vector = Point;
+
+// a line (segment) is defined by two endpoints
+export type Line = [Point, Point];
+
+// a polyline (made up term here) is a line consisting of other line segments
+// this corresponds to a straight line element in the editor but it could also
+// be used to model other elements
+export type Polyline = Line[];
+
+// cubic bezier curve with four control points
+export type Curve = [Point, Point, Point, Point];
+
+// a polycurve is a curve consisting of ther curves, this corresponds to a complex
+// curve on the canvas
+export type Polycurve = Curve[];
+
+// a polygon is a closed shape by connecting the given points
+// rectangles and diamonds are modelled by polygons
+export type Polygon = Point[];
+
+// an ellipse is specified by its center, angle, and its major and minor axes
+// but for the sake of simplicity, we've used halfWidth and halfHeight instead
+// in replace of semi major and semi minor axes
+export type Ellipse = {
+  center: Point;
+  angle: number;
+  halfWidth: number;
+  halfHeight: number;
+};
+
+export type GeometricShape =
+  | {
+      type: "line";
+      data: Line;
+    }
+  | {
+      type: "polygon";
+      data: Polygon;
+    }
+  | {
+      type: "curve";
+      data: Curve;
+    }
+  | {
+      type: "ellipse";
+      data: Ellipse;
+    }
+  | {
+      type: "polyline";
+      data: Polyline;
+    }
+  | {
+      type: "polycurve";
+      data: Polycurve;
+    };
+
+type RectangularElement =
+  | ExcalidrawRectangleElement
+  | ExcalidrawDiamondElement
+  | ExcalidrawFrameLikeElement
+  | ExcalidrawEmbeddableElement
+  | ExcalidrawImageElement
+  | ExcalidrawIframeElement
+  | ExcalidrawTextElement
+  | ExcalidrawSelectionElement;
+
+// polygon
+export const getPolygonShape = (
+  element: RectangularElement,
+): GeometricShape => {
+  const { angle, width, height, x, y } = element;
+  const angleInDegrees = angleToDegrees(angle);
+  const cx = x + width / 2;
+  const cy = y + height / 2;
+
+  const center: Point = [cx, cy];
+
+  let data: Polygon = [];
+
+  if (element.type === "diamond") {
+    data = [
+      pointRotate([cx, y], angleInDegrees, center),
+      pointRotate([x + width, cy], angleInDegrees, center),
+      pointRotate([cx, y + height], angleInDegrees, center),
+      pointRotate([x, cy], angleInDegrees, center),
+    ] as Polygon;
+  } else {
+    data = [
+      pointRotate([x, y], angleInDegrees, center),
+      pointRotate([x + width, y], angleInDegrees, center),
+      pointRotate([x + width, y + height], angleInDegrees, center),
+      pointRotate([x, y + height], angleInDegrees, center),
+    ] as Polygon;
+  }
+
+  return {
+    type: "polygon",
+    data,
+  };
+};
+
+// ellipse
+export const getEllipseShape = (
+  element: ExcalidrawEllipseElement,
+): GeometricShape => {
+  const { width, height, angle, x, y } = element;
+
+  return {
+    type: "ellipse",
+    data: {
+      center: [x + width / 2, y + height / 2],
+      angle,
+      halfWidth: width / 2,
+      halfHeight: height / 2,
+    },
+  };
+};
+
+export const getCurvePathOps = (shape: Drawable): Op[] => {
+  for (const set of shape.sets) {
+    if (set.type === "path") {
+      return set.ops;
+    }
+  }
+  return shape.sets[0].ops;
+};
+
+// linear
+export const getCurveShape = (
+  roughShape: Drawable,
+  startingPoint: Point = [0, 0],
+  angleInRadian: number,
+  center: Point,
+): GeometricShape => {
+  const transform = (p: Point) =>
+    pointRotate(
+      [p[0] + startingPoint[0], p[1] + startingPoint[1]],
+      angleToDegrees(angleInRadian),
+      center,
+    );
+
+  const ops = getCurvePathOps(roughShape);
+  const polycurve: Polycurve = [];
+  let p0: Point = [0, 0];
+
+  for (const op of ops) {
+    if (op.op === "move") {
+      p0 = transform(op.data as Point);
+    }
+    if (op.op === "bcurveTo") {
+      const p1: Point = transform([op.data[0], op.data[1]]);
+      const p2: Point = transform([op.data[2], op.data[3]]);
+      const p3: Point = transform([op.data[4], op.data[5]]);
+      polycurve.push([p0, p1, p2, p3]);
+      p0 = p3;
+    }
+  }
+
+  return {
+    type: "polycurve",
+    data: polycurve,
+  };
+};
+
+const polylineFromPoints = (points: Point[]) => {
+  let previousPoint = points[0];
+  const polyline: Polyline = [];
+
+  for (let i = 1; i < points.length; i++) {
+    const nextPoint = points[i];
+    polyline.push([previousPoint, nextPoint]);
+    previousPoint = nextPoint;
+  }
+
+  return polyline;
+};
+
+export const getFreedrawShape = (
+  element: ExcalidrawFreeDrawElement,
+  center: Point,
+  isClosed: boolean = false,
+): GeometricShape => {
+  const angle = angleToDegrees(element.angle);
+  const transform = (p: Point) =>
+    pointRotate(pointAdd(p, [element.x, element.y] as Point), angle, center);
+
+  const polyline = polylineFromPoints(
+    element.points.map((p) => transform(p as Point)),
+  );
+
+  return isClosed
+    ? {
+        type: "polygon",
+        data: close(polyline.flat()) as Polygon,
+      }
+    : {
+        type: "polyline",
+        data: polyline,
+      };
+};
+
+export const getClosedCurveShape = (
+  roughShape: Drawable,
+  startingPoint: Point = [0, 0],
+  angleInRadian: number,
+  center: Point,
+): GeometricShape => {
+  const ops = getCurvePathOps(roughShape);
+  const transform = (p: Point) =>
+    pointRotate(
+      [p[0] + startingPoint[0], p[1] + startingPoint[1]],
+      angleToDegrees(angleInRadian),
+      center,
+    );
+
+  const points: Point[] = [];
+  let odd = false;
+  for (const operation of ops) {
+    if (operation.op === "move") {
+      odd = !odd;
+      if (odd) {
+        points.push([operation.data[0], operation.data[1]]);
+      }
+    } else if (operation.op === "bcurveTo") {
+      if (odd) {
+        points.push([operation.data[0], operation.data[1]]);
+        points.push([operation.data[2], operation.data[3]]);
+        points.push([operation.data[4], operation.data[5]]);
+      }
+    } else if (operation.op === "lineTo") {
+      if (odd) {
+        points.push([operation.data[0], operation.data[1]]);
+      }
+    }
+  }
+
+  const polygonPoints = pointsOnBezierCurves(points, 10, 5).map((p) =>
+    transform(p),
+  );
+
+  return {
+    type: "polygon",
+    data: polygonPoints,
+  };
+};