2
0
Эх сурвалжийг харах

chore: Unify math types, utils and functions (#8389)

Co-authored-by: dwelle <[email protected]>
Márk Tolmács 10 сар өмнө
parent
commit
f4dd23fc31
98 өөрчлөгдсөн 4291 нэмэгдсэн , 3661 устгасан
  1. 13 5
      excalidraw-app/components/DebugCanvas.tsx
  2. 1 0
      package.json
  3. 1 1
      packages/excalidraw/actions/actionCanvas.tsx
  4. 12 11
      packages/excalidraw/actions/actionDuplicateSelection.tsx
  5. 5 4
      packages/excalidraw/actions/actionFinalize.tsx
  6. 6 4
      packages/excalidraw/actions/actionProperties.tsx
  7. 7 17
      packages/excalidraw/charts.ts
  8. 67 71
      packages/excalidraw/components/App.tsx
  9. 6 5
      packages/excalidraw/components/Stats/Angle.tsx
  10. 6 5
      packages/excalidraw/components/Stats/MultiAngle.tsx
  11. 5 4
      packages/excalidraw/components/Stats/MultiDimension.tsx
  12. 15 17
      packages/excalidraw/components/Stats/MultiPosition.tsx
  13. 7 11
      packages/excalidraw/components/Stats/Position.tsx
  14. 24 33
      packages/excalidraw/components/Stats/stats.test.tsx
  15. 9 12
      packages/excalidraw/components/Stats/utils.ts
  16. 1 1
      packages/excalidraw/components/TTDDialog/TTDDialog.tsx
  17. 9 6
      packages/excalidraw/components/hyperlink/Hyperlink.tsx
  18. 10 11
      packages/excalidraw/components/hyperlink/helpers.ts
  19. 9 17
      packages/excalidraw/data/restore.ts
  20. 3 5
      packages/excalidraw/data/transform.test.ts
  21. 4 9
      packages/excalidraw/data/transform.ts
  22. 169 141
      packages/excalidraw/element/binding.ts
  23. 5 3
      packages/excalidraw/element/bounds.test.ts
  24. 187 109
      packages/excalidraw/element/bounds.ts
  25. 21 19
      packages/excalidraw/element/collision.ts
  26. 1 1
      packages/excalidraw/element/dragElements.ts
  27. 8 9
      packages/excalidraw/element/flowchart.ts
  28. 78 46
      packages/excalidraw/element/heading.ts
  29. 200 177
      packages/excalidraw/element/linearElementEditor.ts
  30. 2 3
      packages/excalidraw/element/mutateElement.ts
  31. 3 4
      packages/excalidraw/element/newElement.test.ts
  32. 49 2
      packages/excalidraw/element/newElement.ts
  33. 127 87
      packages/excalidraw/element/resizeElements.ts
  34. 36 25
      packages/excalidraw/element/resizeTest.ts
  35. 5 10
      packages/excalidraw/element/routing.test.tsx
  36. 124 75
      packages/excalidraw/element/routing.ts
  37. 2 4
      packages/excalidraw/element/textWysiwyg.test.tsx
  38. 9 4
      packages/excalidraw/element/transformHandles.ts
  39. 3 14
      packages/excalidraw/element/typeChecks.ts
  40. 15 6
      packages/excalidraw/element/types.ts
  41. 4 4
      packages/excalidraw/frame.ts
  42. 0 99
      packages/excalidraw/math.test.ts
  43. 0 715
      packages/excalidraw/math.ts
  44. 10 6
      packages/excalidraw/points.ts
  45. 7 6
      packages/excalidraw/renderer/interactiveScene.ts
  46. 4 2
      packages/excalidraw/renderer/renderElement.ts
  47. 27 18
      packages/excalidraw/renderer/renderSnaps.ts
  48. 1 1
      packages/excalidraw/renderer/staticSvgScene.ts
  49. 23 10
      packages/excalidraw/scene/Shape.ts
  50. 1 1
      packages/excalidraw/scene/normalize.ts
  51. 315 13
      packages/excalidraw/shapes.tsx
  52. 145 132
      packages/excalidraw/snapping.ts
  53. 4 14
      packages/excalidraw/tests/binding.test.tsx
  54. 2 1
      packages/excalidraw/tests/fixtures/elementFixture.ts
  55. 26 24
      packages/excalidraw/tests/flip.test.tsx
  56. 8 7
      packages/excalidraw/tests/helpers/api.ts
  57. 17 14
      packages/excalidraw/tests/helpers/ui.ts
  58. 13 11
      packages/excalidraw/tests/history.test.tsx
  59. 89 74
      packages/excalidraw/tests/linearElementEditor.test.tsx
  60. 32 36
      packages/excalidraw/tests/resize.test.tsx
  61. 0 3
      packages/excalidraw/types.ts
  62. 1 5
      packages/excalidraw/utils.ts
  63. 50 46
      packages/excalidraw/visualdebug.ts
  64. 0 0
      packages/math/CHANGELOG.md
  65. 21 0
      packages/math/README.md
  66. 47 0
      packages/math/angle.ts
  67. 41 0
      packages/math/arc.test.ts
  68. 20 0
      packages/math/arc.ts
  69. 223 0
      packages/math/curve.ts
  70. 5 5
      packages/math/ga/ga.test.ts
  71. 0 0
      packages/math/ga/ga.ts
  72. 0 0
      packages/math/ga/gadirections.ts
  73. 0 0
      packages/math/ga/galines.ts
  74. 0 0
      packages/math/ga/gapoints.ts
  75. 0 0
      packages/math/ga/gatransforms.ts
  76. 12 0
      packages/math/index.ts
  77. 52 0
      packages/math/line.ts
  78. 61 0
      packages/math/package.json
  79. 24 0
      packages/math/point.test.ts
  80. 257 0
      packages/math/point.ts
  81. 72 0
      packages/math/polygon.ts
  82. 51 0
      packages/math/range.test.ts
  83. 82 0
      packages/math/range.ts
  84. 158 0
      packages/math/segment.ts
  85. 28 0
      packages/math/triangle.ts
  86. 130 0
      packages/math/types.ts
  87. 17 0
      packages/math/utils.ts
  88. 12 0
      packages/math/vector.test.ts
  89. 141 0
      packages/math/vector.ts
  90. 55 0
      packages/math/webpack.prod.config.js
  91. 31 24
      packages/utils/bbox.ts
  92. 87 0
      packages/utils/collision.test.ts
  93. 87 17
      packages/utils/collision.ts
  94. 77 204
      packages/utils/geometry/geometry.test.ts
  95. 0 1060
      packages/utils/geometry/geometry.ts
  96. 314 102
      packages/utils/geometry/shape.ts
  97. 35 19
      packages/utils/withinBounds.ts
  98. 108 0
      scripts/buildMath.js

+ 13 - 5
excalidraw-app/components/DebugCanvas.tsx

@@ -1,7 +1,6 @@
 import { forwardRef, useCallback, useImperativeHandle, useRef } from "react";
 import { type AppState } from "../../packages/excalidraw/types";
 import { throttleRAF } from "../../packages/excalidraw/utils";
-import type { LineSegment } from "../../packages/utils";
 import {
   bootstrapCanvas,
   getNormalizedCanvasDimensions,
@@ -13,12 +12,16 @@ import {
   TrashIcon,
 } from "../../packages/excalidraw/components/icons";
 import { STORAGE_KEYS } from "../app_constants";
-import { isLineSegment } from "../../packages/excalidraw/element/typeChecks";
+import {
+  isLineSegment,
+  type GlobalPoint,
+  type LineSegment,
+} from "../../packages/math";
 
 const renderLine = (
   context: CanvasRenderingContext2D,
   zoom: number,
-  segment: LineSegment,
+  segment: LineSegment<GlobalPoint>,
   color: string,
 ) => {
   context.save();
@@ -47,10 +50,15 @@ const render = (
   context: CanvasRenderingContext2D,
   appState: AppState,
 ) => {
-  frame.forEach((el) => {
+  frame.forEach((el: DebugElement) => {
     switch (true) {
       case isLineSegment(el.data):
-        renderLine(context, appState.zoom.value, el.data, el.color);
+        renderLine(
+          context,
+          appState.zoom.value,
+          el.data as LineSegment<GlobalPoint>,
+          el.color,
+        );
         break;
     }
   });

+ 1 - 0
package.json

@@ -6,6 +6,7 @@
     "excalidraw-app",
     "packages/excalidraw",
     "packages/utils",
+    "packages/math",
     "examples/excalidraw",
     "examples/excalidraw/*"
   ],

+ 1 - 1
packages/excalidraw/actions/actionCanvas.tsx

@@ -38,7 +38,7 @@ import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors";
 import type { SceneBounds } from "../element/bounds";
 import { setCursor } from "../cursor";
 import { StoreAction } from "../store";
-import { clamp } from "../math";
+import { clamp } from "../../math";
 
 export const actionChangeViewBackgroundColor = register({
   name: "changeViewBackgroundColor",

+ 12 - 11
packages/excalidraw/actions/actionDuplicateSelection.tsx

@@ -42,20 +42,21 @@ export const actionDuplicateSelection = register({
   perform: (elements, appState, formData, app) => {
     // duplicate selected point(s) if editing a line
     if (appState.editingLinearElement) {
-      const ret = LinearElementEditor.duplicateSelectedPoints(
-        appState,
-        app.scene.getNonDeletedElementsMap(),
-      );
+      // TODO: Invariants should be checked here instead of duplicateSelectedPoints()
+      try {
+        const newAppState = LinearElementEditor.duplicateSelectedPoints(
+          appState,
+          app.scene.getNonDeletedElementsMap(),
+        );
 
-      if (!ret) {
+        return {
+          elements,
+          appState: newAppState,
+          storeAction: StoreAction.CAPTURE,
+        };
+      } catch {
         return false;
       }
-
-      return {
-        elements,
-        appState: ret.appState,
-        storeAction: StoreAction.CAPTURE,
-      };
     }
 
     return {

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

@@ -6,7 +6,6 @@ import { done } from "../components/icons";
 import { t } from "../i18n";
 import { register } from "./register";
 import { mutateElement } from "../element/mutateElement";
-import { isPathALoop } from "../math";
 import { LinearElementEditor } from "../element/linearElementEditor";
 import {
   maybeBindLinearElement,
@@ -16,6 +15,8 @@ import { isBindingElement, isLinearElement } from "../element/typeChecks";
 import type { AppState } from "../types";
 import { resetCursor } from "../cursor";
 import { StoreAction } from "../store";
+import { point } from "../../math";
+import { isPathALoop } from "../shapes";
 
 export const actionFinalize = register({
   name: "finalize",
@@ -112,10 +113,10 @@ export const actionFinalize = register({
           const linePoints = multiPointElement.points;
           const firstPoint = linePoints[0];
           mutateElement(multiPointElement, {
-            points: linePoints.map((point, index) =>
+            points: linePoints.map((p, index) =>
               index === linePoints.length - 1
-                ? ([firstPoint[0], firstPoint[1]] as const)
-                : point,
+                ? point(firstPoint[0], firstPoint[1])
+                : p,
             ),
           });
         }

+ 6 - 4
packages/excalidraw/actions/actionProperties.tsx

@@ -1,5 +1,5 @@
 import { useEffect, useMemo, useRef, useState } from "react";
-import type { AppClassProperties, AppState, Point, Primitive } from "../types";
+import type { AppClassProperties, AppState, Primitive } from "../types";
 import type { StoreActionType } from "../store";
 import {
   DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE,
@@ -115,6 +115,8 @@ import {
 } from "../element/binding";
 import { mutateElbowArrow } from "../element/routing";
 import { LinearElementEditor } from "../element/linearElementEditor";
+import type { LocalPoint } from "../../math";
+import { point, vector } from "../../math";
 
 const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
 
@@ -1648,10 +1650,10 @@ export const actionChangeArrowType = register({
             newElement,
             elementsMap,
             [finalStartPoint, finalEndPoint].map(
-              (point) =>
-                [point[0] - newElement.x, point[1] - newElement.y] as Point,
+              (p): LocalPoint =>
+                point(p[0] - newElement.x, p[1] - newElement.y),
             ),
-            [0, 0],
+            vector(0, 0),
             {
               ...(startElement && newElement.startBinding
                 ? {

+ 7 - 17
packages/excalidraw/charts.ts

@@ -1,3 +1,5 @@
+import type { Radians } from "../math";
+import { point } from "../math";
 import {
   COLOR_PALETTE,
   DEFAULT_CHART_COLOR_INDEX,
@@ -203,7 +205,7 @@ const chartXLabels = (
         x: x + index * (BAR_WIDTH + BAR_GAP) + BAR_GAP * 2,
         y: y + BAR_GAP / 2,
         width: BAR_WIDTH,
-        angle: 5.87,
+        angle: 5.87 as Radians,
         fontSize: 16,
         textAlign: "center",
         verticalAlign: "top",
@@ -258,10 +260,7 @@ const chartLines = (
     x,
     y,
     width: chartWidth,
-    points: [
-      [0, 0],
-      [chartWidth, 0],
-    ],
+    points: [point(0, 0), point(chartWidth, 0)],
   });
 
   const yLine = newLinearElement({
@@ -272,10 +271,7 @@ const chartLines = (
     x,
     y,
     height: chartHeight,
-    points: [
-      [0, 0],
-      [0, -chartHeight],
-    ],
+    points: [point(0, 0), point(0, -chartHeight)],
   });
 
   const maxLine = newLinearElement({
@@ -288,10 +284,7 @@ const chartLines = (
     strokeStyle: "dotted",
     width: chartWidth,
     opacity: GRID_OPACITY,
-    points: [
-      [0, 0],
-      [chartWidth, 0],
-    ],
+    points: [point(0, 0), point(chartWidth, 0)],
   });
 
   return [xLine, yLine, maxLine];
@@ -448,10 +441,7 @@ const chartTypeLine = (
       height: cy,
       strokeStyle: "dotted",
       opacity: GRID_OPACITY,
-      points: [
-        [0, 0],
-        [0, cy],
-      ],
+      points: [point(0, 0), point(0, cy)],
     });
   });
 

+ 67 - 71
packages/excalidraw/components/App.tsx

@@ -210,12 +210,6 @@ import {
   isElementCompletelyInViewport,
   isElementInViewport,
 } from "../element/sizeHelpers";
-import {
-  distance2d,
-  getCornerRadius,
-  getGridPoint,
-  isPathALoop,
-} from "../math";
 import {
   calculateScrollCenter,
   getElementsWithinSelection,
@@ -230,7 +224,13 @@ import type {
   ScrollBars,
 } from "../scene/types";
 import { getStateForZoom } from "../scene/zoom";
-import { findShapeByKey, getBoundTextShape, getElementShape } from "../shapes";
+import {
+  findShapeByKey,
+  getBoundTextShape,
+  getCornerRadius,
+  getElementShape,
+  isPathALoop,
+} from "../shapes";
 import { getSelectionBoxShape } from "../../utils/geometry/shape";
 import { isPointInShape } from "../../utils/collision";
 import type {
@@ -386,6 +386,7 @@ import {
   getReferenceSnapPoints,
   SnapCache,
   isGridModeEnabled,
+  getGridPoint,
 } from "../snapping";
 import { actionWrapTextInContainer } from "../actions/actionBoundText";
 import BraveMeasureTextError from "./BraveMeasureTextError";
@@ -439,6 +440,8 @@ import {
   FlowChartNavigator,
   getLinkDirectionFromKey,
 } from "../element/flowchart";
+import type { LocalPoint, Radians } from "../../math";
+import { point, pointDistance, vector } from "../../math";
 
 const AppContext = React.createContext<AppClassProperties>(null!);
 const AppPropsContext = React.createContext<AppProps>(null!);
@@ -4844,7 +4847,7 @@ class App extends React.Component<AppProps, AppState> {
         this.getElementHitThreshold(),
       );
 
-      return isPointInShape([x, y], selectionShape);
+      return isPointInShape(point(x, y), selectionShape);
     }
 
     // take bound text element into consideration for hit collision as well
@@ -5035,7 +5038,7 @@ class App extends React.Component<AppProps, AppState> {
           containerId: shouldBindToContainer ? container?.id : undefined,
           groupIds: container?.groupIds ?? [],
           lineHeight,
-          angle: container?.angle ?? 0,
+          angle: container?.angle ?? (0 as Radians),
           frameId: topLayerFrame ? topLayerFrame.id : null,
         });
 
@@ -5203,7 +5206,7 @@ class App extends React.Component<AppProps, AppState> {
           element,
           this.scene.getNonDeletedElementsMap(),
           this.state,
-          [scenePointer.x, scenePointer.y],
+          point(scenePointer.x, scenePointer.y),
           this.device.editor.isMobile,
         )
       );
@@ -5214,11 +5217,12 @@ class App extends React.Component<AppProps, AppState> {
     event: React.PointerEvent<HTMLCanvasElement>,
     isTouchScreen: boolean,
   ) => {
-    const draggedDistance = distance2d(
-      this.lastPointerDownEvent!.clientX,
-      this.lastPointerDownEvent!.clientY,
-      this.lastPointerUpEvent!.clientX,
-      this.lastPointerUpEvent!.clientY,
+    const draggedDistance = pointDistance(
+      point(
+        this.lastPointerDownEvent!.clientX,
+        this.lastPointerDownEvent!.clientY,
+      ),
+      point(this.lastPointerUpEvent!.clientX, this.lastPointerUpEvent!.clientY),
     );
     if (
       !this.hitLinkElement ||
@@ -5237,7 +5241,7 @@ class App extends React.Component<AppProps, AppState> {
       this.hitLinkElement,
       elementsMap,
       this.state,
-      [lastPointerDownCoords.x, lastPointerDownCoords.y],
+      point(lastPointerDownCoords.x, lastPointerDownCoords.y),
       this.device.editor.isMobile,
     );
     const lastPointerUpCoords = viewportCoordsToSceneCoords(
@@ -5248,7 +5252,7 @@ class App extends React.Component<AppProps, AppState> {
       this.hitLinkElement,
       elementsMap,
       this.state,
-      [lastPointerUpCoords.x, lastPointerUpCoords.y],
+      point(lastPointerUpCoords.x, lastPointerUpCoords.y),
       this.device.editor.isMobile,
     );
     if (lastPointerDownHittingLinkIcon && lastPointerUpHittingLinkIcon) {
@@ -5497,17 +5501,18 @@ class App extends React.Component<AppProps, AppState> {
         // if we haven't yet created a temp point and we're beyond commit-zone
         // threshold, add a point
         if (
-          distance2d(
-            scenePointerX - rx,
-            scenePointerY - ry,
-            lastPoint[0],
-            lastPoint[1],
+          pointDistance(
+            point(scenePointerX - rx, scenePointerY - ry),
+            lastPoint,
           ) >= LINE_CONFIRM_THRESHOLD
         ) {
           mutateElement(
             multiElement,
             {
-              points: [...points, [scenePointerX - rx, scenePointerY - ry]],
+              points: [
+                ...points,
+                point<LocalPoint>(scenePointerX - rx, scenePointerY - ry),
+              ],
             },
             false,
           );
@@ -5519,11 +5524,9 @@ class App extends React.Component<AppProps, AppState> {
       } else if (
         points.length > 2 &&
         lastCommittedPoint &&
-        distance2d(
-          scenePointerX - rx,
-          scenePointerY - ry,
-          lastCommittedPoint[0],
-          lastCommittedPoint[1],
+        pointDistance(
+          point(scenePointerX - rx, scenePointerY - ry),
+          lastCommittedPoint,
         ) < LINE_CONFIRM_THRESHOLD
       ) {
         setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
@@ -5570,10 +5573,10 @@ class App extends React.Component<AppProps, AppState> {
             this.scene.getNonDeletedElementsMap(),
             [
               ...points.slice(0, -1),
-              [
+              point<LocalPoint>(
                 lastCommittedX + dxFromLastCommitted,
                 lastCommittedY + dyFromLastCommitted,
-              ],
+              ),
             ],
             undefined,
             undefined,
@@ -5589,10 +5592,10 @@ class App extends React.Component<AppProps, AppState> {
             {
               points: [
                 ...points.slice(0, -1),
-                [
+                point<LocalPoint>(
                   lastCommittedX + dxFromLastCommitted,
                   lastCommittedY + dyFromLastCommitted,
-                ],
+                ),
               ],
             },
             false,
@@ -5817,17 +5820,15 @@ class App extends React.Component<AppProps, AppState> {
       }
     };
 
-    const distance = distance2d(
-      pointerDownState.lastCoords.x,
-      pointerDownState.lastCoords.y,
-      scenePointer.x,
-      scenePointer.y,
+    const distance = pointDistance(
+      point(pointerDownState.lastCoords.x, pointerDownState.lastCoords.y),
+      point(scenePointer.x, scenePointer.y),
     );
     const threshold = this.getElementHitThreshold();
-    const point = { ...pointerDownState.lastCoords };
+    const p = { ...pointerDownState.lastCoords };
     let samplingInterval = 0;
     while (samplingInterval <= distance) {
-      const hitElements = this.getElementsAtPosition(point.x, point.y);
+      const hitElements = this.getElementsAtPosition(p.x, p.y);
       processElements(hitElements);
 
       // Exit since we reached current point
@@ -5839,12 +5840,10 @@ class App extends React.Component<AppProps, AppState> {
       samplingInterval = Math.min(samplingInterval + threshold, distance);
 
       const distanceRatio = samplingInterval / distance;
-      const nextX =
-        (1 - distanceRatio) * point.x + distanceRatio * scenePointer.x;
-      const nextY =
-        (1 - distanceRatio) * point.y + distanceRatio * scenePointer.y;
-      point.x = nextX;
-      point.y = nextY;
+      const nextX = (1 - distanceRatio) * p.x + distanceRatio * scenePointer.x;
+      const nextY = (1 - distanceRatio) * p.y + distanceRatio * scenePointer.y;
+      p.x = nextX;
+      p.y = nextY;
     }
 
     pointerDownState.lastCoords.x = scenePointer.x;
@@ -6325,7 +6324,7 @@ class App extends React.Component<AppProps, AppState> {
           this.hitLinkElement,
           this.scene.getNonDeletedElementsMap(),
           this.state,
-          [scenePointer.x, scenePointer.y],
+          point(scenePointer.x, scenePointer.y),
         )
       ) {
         this.handleEmbeddableCenterClick(this.hitLinkElement);
@@ -7008,7 +7007,7 @@ class App extends React.Component<AppProps, AppState> {
       simulatePressure,
       locked: false,
       frameId: topLayerFrame ? topLayerFrame.id : null,
-      points: [[0, 0]],
+      points: [point<LocalPoint>(0, 0)],
       pressures: simulatePressure ? [] : [event.pressure],
     });
 
@@ -7216,11 +7215,9 @@ class App extends React.Component<AppProps, AppState> {
       if (
         multiElement.points.length > 1 &&
         lastCommittedPoint &&
-        distance2d(
-          pointerDownState.origin.x - rx,
-          pointerDownState.origin.y - ry,
-          lastCommittedPoint[0],
-          lastCommittedPoint[1],
+        pointDistance(
+          point(pointerDownState.origin.x - rx, pointerDownState.origin.y - ry),
+          lastCommittedPoint,
         ) < LINE_CONFIRM_THRESHOLD
       ) {
         this.actionManager.executeAction(actionFinalize);
@@ -7321,7 +7318,7 @@ class App extends React.Component<AppProps, AppState> {
         };
       });
       mutateElement(element, {
-        points: [...element.points, [0, 0]],
+        points: [...element.points, point<LocalPoint>(0, 0)],
       });
       const boundElement = getHoveredElementForBinding(
         pointerDownState.origin,
@@ -7573,11 +7570,9 @@ class App extends React.Component<AppProps, AppState> {
           this.state.activeTool.type === "line")
       ) {
         if (
-          distance2d(
-            pointerCoords.x,
-            pointerCoords.y,
-            pointerDownState.origin.x,
-            pointerDownState.origin.y,
+          pointDistance(
+            point(pointerCoords.x, pointerCoords.y),
+            point(pointerDownState.origin.x, pointerDownState.origin.y),
           ) < DRAGGING_THRESHOLD
         ) {
           return;
@@ -7926,7 +7921,7 @@ class App extends React.Component<AppProps, AppState> {
             mutateElement(
               newElement,
               {
-                points: [...points, [dx, dy]],
+                points: [...points, point<LocalPoint>(dx, dy)],
                 pressures,
               },
               false,
@@ -7955,7 +7950,7 @@ class App extends React.Component<AppProps, AppState> {
             mutateElement(
               newElement,
               {
-                points: [...points, [dx, dy]],
+                points: [...points, point<LocalPoint>(dx, dy)],
               },
               false,
             );
@@ -7963,8 +7958,8 @@ class App extends React.Component<AppProps, AppState> {
             mutateElbowArrow(
               newElement,
               elementsMap,
-              [...points.slice(0, -1), [dx, dy]],
-              [0, 0],
+              [...points.slice(0, -1), point<LocalPoint>(dx, dy)],
+              vector(0, 0),
               undefined,
               {
                 isDragging: true,
@@ -7975,7 +7970,7 @@ class App extends React.Component<AppProps, AppState> {
             mutateElement(
               newElement,
               {
-                points: [...points.slice(0, -1), [dx, dy]],
+                points: [...points.slice(0, -1), point<LocalPoint>(dx, dy)],
               },
               false,
             );
@@ -8284,9 +8279,9 @@ class App extends React.Component<AppProps, AppState> {
           : [...newElement.pressures, childEvent.pressure];
 
         mutateElement(newElement, {
-          points: [...points, [dx, dy]],
+          points: [...points, point<LocalPoint>(dx, dy)],
           pressures,
-          lastCommittedPoint: [dx, dy],
+          lastCommittedPoint: point<LocalPoint>(dx, dy),
         });
 
         this.actionManager.executeAction(actionFinalize);
@@ -8333,7 +8328,10 @@ class App extends React.Component<AppProps, AppState> {
           mutateElement(newElement, {
             points: [
               ...newElement.points,
-              [pointerCoords.x - newElement.x, pointerCoords.y - newElement.y],
+              point<LocalPoint>(
+                pointerCoords.x - newElement.x,
+                pointerCoords.y - newElement.y,
+              ),
             ],
           });
           this.setState({
@@ -8643,11 +8641,9 @@ class App extends React.Component<AppProps, AppState> {
       if (isEraserActive(this.state) && pointerStart && pointerEnd) {
         this.eraserTrail.endPath();
 
-        const draggedDistance = distance2d(
-          pointerStart.clientX,
-          pointerStart.clientY,
-          pointerEnd.clientX,
-          pointerEnd.clientY,
+        const draggedDistance = pointDistance(
+          point(pointerStart.clientX, pointerStart.clientY),
+          point(pointerEnd.clientX, pointerEnd.clientY),
         );
 
         if (draggedDistance === 0) {

+ 6 - 5
packages/excalidraw/components/Stats/Angle.tsx

@@ -2,13 +2,14 @@ import { mutateElement } from "../../element/mutateElement";
 import { getBoundTextElement } from "../../element/textElement";
 import { isArrowElement, isElbowArrow } from "../../element/typeChecks";
 import type { ExcalidrawElement } from "../../element/types";
-import { degreeToRadian, radianToDegree } from "../../math";
 import { angleIcon } from "../icons";
 import DragInput from "./DragInput";
 import type { DragInputCallbackType } from "./DragInput";
 import { getStepSizedValue, isPropertyEditable, updateBindings } from "./utils";
 import type Scene from "../../scene/Scene";
 import type { AppState } from "../../types";
+import type { Degrees } from "../../../math";
+import { degreesToRadians, radiansToDegrees } from "../../../math";
 
 interface AngleProps {
   element: ExcalidrawElement;
@@ -36,7 +37,7 @@ const handleDegreeChange: DragInputCallbackType<AngleProps["property"]> = ({
     }
 
     if (nextValue !== undefined) {
-      const nextAngle = degreeToRadian(nextValue);
+      const nextAngle = degreesToRadians(nextValue as Degrees);
       mutateElement(latestElement, {
         angle: nextAngle,
       });
@@ -51,7 +52,7 @@ const handleDegreeChange: DragInputCallbackType<AngleProps["property"]> = ({
     }
 
     const originalAngleInDegrees =
-      Math.round(radianToDegree(origElement.angle) * 100) / 100;
+      Math.round(radiansToDegrees(origElement.angle) * 100) / 100;
     const changeInDegrees = Math.round(accumulatedChange);
     let nextAngleInDegrees = (originalAngleInDegrees + changeInDegrees) % 360;
     if (shouldChangeByStepSize) {
@@ -61,7 +62,7 @@ const handleDegreeChange: DragInputCallbackType<AngleProps["property"]> = ({
     nextAngleInDegrees =
       nextAngleInDegrees < 0 ? nextAngleInDegrees + 360 : nextAngleInDegrees;
 
-    const nextAngle = degreeToRadian(nextAngleInDegrees);
+    const nextAngle = degreesToRadians(nextAngleInDegrees as Degrees);
 
     mutateElement(latestElement, {
       angle: nextAngle,
@@ -80,7 +81,7 @@ const Angle = ({ element, scene, appState, property }: AngleProps) => {
     <DragInput
       label="A"
       icon={angleIcon}
-      value={Math.round((radianToDegree(element.angle) % 360) * 100) / 100}
+      value={Math.round((radiansToDegrees(element.angle) % 360) * 100) / 100}
       elements={[element]}
       dragInputCallback={handleDegreeChange}
       editable={isPropertyEditable(element, "angle")}

+ 6 - 5
packages/excalidraw/components/Stats/MultiAngle.tsx

@@ -3,13 +3,14 @@ import { getBoundTextElement } from "../../element/textElement";
 import { isArrowElement } from "../../element/typeChecks";
 import type { ExcalidrawElement } from "../../element/types";
 import { isInGroup } from "../../groups";
-import { degreeToRadian, radianToDegree } from "../../math";
 import type Scene from "../../scene/Scene";
 import { angleIcon } from "../icons";
 import DragInput from "./DragInput";
 import type { DragInputCallbackType } from "./DragInput";
 import { getStepSizedValue, isPropertyEditable } from "./utils";
 import type { AppState } from "../../types";
+import type { Degrees } from "../../../math";
+import { degreesToRadians, radiansToDegrees } from "../../../math";
 
 interface MultiAngleProps {
   elements: readonly ExcalidrawElement[];
@@ -39,7 +40,7 @@ const handleDegreeChange: DragInputCallbackType<
   );
 
   if (nextValue !== undefined) {
-    const nextAngle = degreeToRadian(nextValue);
+    const nextAngle = degreesToRadians(nextValue as Degrees);
 
     for (const element of editableLatestIndividualElements) {
       if (!element) {
@@ -71,7 +72,7 @@ const handleDegreeChange: DragInputCallbackType<
     }
     const originalElement = editableOriginalIndividualElements[i];
     const originalAngleInDegrees =
-      Math.round(radianToDegree(originalElement.angle) * 100) / 100;
+      Math.round(radiansToDegrees(originalElement.angle) * 100) / 100;
     const changeInDegrees = Math.round(accumulatedChange);
     let nextAngleInDegrees = (originalAngleInDegrees + changeInDegrees) % 360;
     if (shouldChangeByStepSize) {
@@ -81,7 +82,7 @@ const handleDegreeChange: DragInputCallbackType<
     nextAngleInDegrees =
       nextAngleInDegrees < 0 ? nextAngleInDegrees + 360 : nextAngleInDegrees;
 
-    const nextAngle = degreeToRadian(nextAngleInDegrees);
+    const nextAngle = degreesToRadians(nextAngleInDegrees as Degrees);
 
     mutateElement(
       latestElement,
@@ -109,7 +110,7 @@ const MultiAngle = ({
     (el) => !isInGroup(el) && isPropertyEditable(el, "angle"),
   );
   const angles = editableLatestIndividualElements.map(
-    (el) => Math.round((radianToDegree(el.angle) % 360) * 100) / 100,
+    (el) => Math.round((radiansToDegrees(el.angle) % 360) * 100) / 100,
   );
   const value = new Set(angles).size === 1 ? angles[0] : "Mixed";
 

+ 5 - 4
packages/excalidraw/components/Stats/MultiDimension.tsx

@@ -13,13 +13,14 @@ import type {
   NonDeletedSceneElementsMap,
 } from "../../element/types";
 import type Scene from "../../scene/Scene";
-import type { AppState, Point } from "../../types";
+import type { AppState } from "../../types";
 import DragInput from "./DragInput";
 import type { DragInputCallbackType } from "./DragInput";
 import { getAtomicUnits, getStepSizedValue, isPropertyEditable } from "./utils";
 import { getElementsInAtomicUnit, resizeElement } from "./utils";
 import type { AtomicUnit } from "./utils";
 import { MIN_WIDTH_OR_HEIGHT } from "../../constants";
+import { point, type GlobalPoint } from "../../../math";
 
 interface MultiDimensionProps {
   property: "width" | "height";
@@ -104,7 +105,7 @@ const resizeGroup = (
   nextHeight: number,
   initialHeight: number,
   aspectRatio: number,
-  anchor: Point,
+  anchor: GlobalPoint,
   property: MultiDimensionProps["property"],
   latestElements: ExcalidrawElement[],
   originalElements: ExcalidrawElement[],
@@ -181,7 +182,7 @@ const handleDimensionChange: DragInputCallbackType<
           nextHeight,
           initialHeight,
           aspectRatio,
-          [x1, y1],
+          point(x1, y1),
           property,
           latestElements,
           originalElements,
@@ -286,7 +287,7 @@ const handleDimensionChange: DragInputCallbackType<
         nextHeight,
         initialHeight,
         aspectRatio,
-        [x1, y1],
+        point(x1, y1),
         property,
         latestElements,
         originalElements,

+ 15 - 17
packages/excalidraw/components/Stats/MultiPosition.tsx

@@ -4,7 +4,6 @@ import type {
   NonDeletedExcalidrawElement,
   NonDeletedSceneElementsMap,
 } from "../../element/types";
-import { rotate } from "../../math";
 import type Scene from "../../scene/Scene";
 import StatsDragInput from "./DragInput";
 import type { DragInputCallbackType } from "./DragInput";
@@ -14,6 +13,7 @@ import { useMemo } from "react";
 import { getElementsInAtomicUnit, moveElement } from "./utils";
 import type { AtomicUnit } from "./utils";
 import type { AppState } from "../../types";
+import { point, pointRotateRads } from "../../../math";
 
 interface MultiPositionProps {
   property: "x" | "y";
@@ -43,11 +43,9 @@ const moveElements = (
       origElement.x + origElement.width / 2,
       origElement.y + origElement.height / 2,
     ];
-    const [topLeftX, topLeftY] = rotate(
-      origElement.x,
-      origElement.y,
-      cx,
-      cy,
+    const [topLeftX, topLeftY] = pointRotateRads(
+      point(origElement.x, origElement.y),
+      point(cx, cy),
       origElement.angle,
     );
 
@@ -98,11 +96,9 @@ const moveGroupTo = (
         latestElement.y + latestElement.height / 2,
       ];
 
-      const [topLeftX, topLeftY] = rotate(
-        latestElement.x,
-        latestElement.y,
-        cx,
-        cy,
+      const [topLeftX, topLeftY] = pointRotateRads(
+        point(latestElement.x, latestElement.y),
+        point(cx, cy),
         latestElement.angle,
       );
 
@@ -174,11 +170,9 @@ const handlePositionChange: DragInputCallbackType<
             origElement.x + origElement.width / 2,
             origElement.y + origElement.height / 2,
           ];
-          const [topLeftX, topLeftY] = rotate(
-            origElement.x,
-            origElement.y,
-            cx,
-            cy,
+          const [topLeftX, topLeftY] = pointRotateRads(
+            point(origElement.x, origElement.y),
+            point(cx, cy),
             origElement.angle,
           );
 
@@ -246,7 +240,11 @@ const MultiPosition = ({
         const [el] = elementsInUnit;
         const [cx, cy] = [el.x + el.width / 2, el.y + el.height / 2];
 
-        const [topLeftX, topLeftY] = rotate(el.x, el.y, cx, cy, el.angle);
+        const [topLeftX, topLeftY] = pointRotateRads(
+          point(el.x, el.y),
+          point(cx, cy),
+          el.angle,
+        );
 
         return Math.round((property === "x" ? topLeftX : topLeftY) * 100) / 100;
       }),

+ 7 - 11
packages/excalidraw/components/Stats/Position.tsx

@@ -1,10 +1,10 @@
 import type { ElementsMap, ExcalidrawElement } from "../../element/types";
-import { rotate } from "../../math";
 import StatsDragInput from "./DragInput";
 import type { DragInputCallbackType } from "./DragInput";
 import { getStepSizedValue, moveElement } from "./utils";
 import type Scene from "../../scene/Scene";
 import type { AppState } from "../../types";
+import { point, pointRotateRads } from "../../../math";
 
 interface PositionProps {
   property: "x" | "y";
@@ -32,11 +32,9 @@ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
     origElement.x + origElement.width / 2,
     origElement.y + origElement.height / 2,
   ];
-  const [topLeftX, topLeftY] = rotate(
-    origElement.x,
-    origElement.y,
-    cx,
-    cy,
+  const [topLeftX, topLeftY] = pointRotateRads(
+    point(origElement.x, origElement.y),
+    point(cx, cy),
     origElement.angle,
   );
 
@@ -94,11 +92,9 @@ const Position = ({
   scene,
   appState,
 }: PositionProps) => {
-  const [topLeftX, topLeftY] = rotate(
-    element.x,
-    element.y,
-    element.x + element.width / 2,
-    element.y + element.height / 2,
+  const [topLeftX, topLeftY] = pointRotateRads(
+    point(element.x, element.y),
+    point(element.x + element.width / 2, element.y + element.height / 2),
     element.angle,
   );
   const value =

+ 24 - 33
packages/excalidraw/components/Stats/stats.test.tsx

@@ -19,12 +19,13 @@ import type {
   ExcalidrawLinearElement,
   ExcalidrawTextElement,
 } from "../../element/types";
-import { degreeToRadian, rotate } from "../../math";
 import { getTextEditor, updateTextEditor } from "../../tests/queries/dom";
 import { getCommonBounds, isTextElement } from "../../element";
 import { API } from "../../tests/helpers/api";
 import { actionGroup } from "../../actions";
 import { isInGroup } from "../../groups";
+import type { Degrees } from "../../../math";
+import { degreesToRadians, point, pointRotateRads } from "../../../math";
 
 const { h } = window;
 const mouse = new Pointer("mouse");
@@ -46,7 +47,9 @@ const testInputProperty = (
   expect(input.value).toBe(initialValue.toString());
   UI.updateInput(input, String(nextValue));
   if (property === "angle") {
-    expect(element[property]).toBe(degreeToRadian(Number(nextValue)));
+    expect(element[property]).toBe(
+      degreesToRadians(Number(nextValue) as Degrees),
+    );
   } else if (property === "fontSize" && isTextElement(element)) {
     expect(element[property]).toBe(Number(nextValue));
   } else if (property !== "fontSize") {
@@ -260,11 +263,9 @@ describe("stats for a generic element", () => {
       rectangle.x + rectangle.width / 2,
       rectangle.y + rectangle.height / 2,
     ];
-    const [topLeftX, topLeftY] = rotate(
-      rectangle.x,
-      rectangle.y,
-      cx,
-      cy,
+    const [topLeftX, topLeftY] = pointRotateRads(
+      point(rectangle.x, rectangle.y),
+      point(cx, cy),
       rectangle.angle,
     );
 
@@ -281,11 +282,9 @@ describe("stats for a generic element", () => {
 
     testInputProperty(rectangle, "angle", "A", 0, 45);
 
-    let [newTopLeftX, newTopLeftY] = rotate(
-      rectangle.x,
-      rectangle.y,
-      cx,
-      cy,
+    let [newTopLeftX, newTopLeftY] = pointRotateRads(
+      point(rectangle.x, rectangle.y),
+      point(cx, cy),
       rectangle.angle,
     );
 
@@ -294,11 +293,9 @@ describe("stats for a generic element", () => {
 
     testInputProperty(rectangle, "angle", "A", 45, 66);
 
-    [newTopLeftX, newTopLeftY] = rotate(
-      rectangle.x,
-      rectangle.y,
-      cx,
-      cy,
+    [newTopLeftX, newTopLeftY] = pointRotateRads(
+      point(rectangle.x, rectangle.y),
+      point(cx, cy),
       rectangle.angle,
     );
     expect(newTopLeftX.toString()).not.toEqual(xInput.value);
@@ -313,11 +310,9 @@ describe("stats for a generic element", () => {
       rectangle.x + rectangle.width / 2,
       rectangle.y + rectangle.height / 2,
     ];
-    const [topLeftX, topLeftY] = rotate(
-      rectangle.x,
-      rectangle.y,
-      cx,
-      cy,
+    const [topLeftX, topLeftY] = pointRotateRads(
+      point(rectangle.x, rectangle.y),
+      point(cx, cy),
       rectangle.angle,
     );
     testInputProperty(rectangle, "width", "W", rectangle.width, 400);
@@ -325,11 +320,9 @@ describe("stats for a generic element", () => {
       rectangle.x + rectangle.width / 2,
       rectangle.y + rectangle.height / 2,
     ];
-    let [currentTopLeftX, currentTopLeftY] = rotate(
-      rectangle.x,
-      rectangle.y,
-      cx,
-      cy,
+    let [currentTopLeftX, currentTopLeftY] = pointRotateRads(
+      point(rectangle.x, rectangle.y),
+      point(cx, cy),
       rectangle.angle,
     );
     expect(currentTopLeftX).toBeCloseTo(topLeftX, 4);
@@ -340,11 +333,9 @@ describe("stats for a generic element", () => {
       rectangle.x + rectangle.width / 2,
       rectangle.y + rectangle.height / 2,
     ];
-    [currentTopLeftX, currentTopLeftY] = rotate(
-      rectangle.x,
-      rectangle.y,
-      cx,
-      cy,
+    [currentTopLeftX, currentTopLeftY] = pointRotateRads(
+      point(rectangle.x, rectangle.y),
+      point(cx, cy),
       rectangle.angle,
     );
 
@@ -642,7 +633,7 @@ describe("stats for multiple elements", () => {
 
     UI.updateInput(angle, "40");
 
-    const angleInRadian = degreeToRadian(40);
+    const angleInRadian = degreesToRadians(40 as Degrees);
     expect(rectangle?.angle).toBeCloseTo(angleInRadian, 4);
     expect(text?.angle).toBeCloseTo(angleInRadian, 4);
     expect(frame.angle).toBe(0);

+ 9 - 12
packages/excalidraw/components/Stats/utils.ts

@@ -1,3 +1,5 @@
+import type { Radians } from "../../../math";
+import { point, pointRotateRads } from "../../../math";
 import {
   bindOrUnbindLinearElements,
   updateBoundElements,
@@ -30,7 +32,6 @@ import {
   getElementsInGroup,
   isInGroup,
 } from "../../groups";
-import { rotate } from "../../math";
 import type Scene from "../../scene/Scene";
 import type { AppState } from "../../types";
 import { getFontString } from "../../utils";
@@ -229,23 +230,19 @@ export const moveElement = (
     originalElement.x + originalElement.width / 2,
     originalElement.y + originalElement.height / 2,
   ];
-  const [topLeftX, topLeftY] = rotate(
-    originalElement.x,
-    originalElement.y,
-    cx,
-    cy,
+  const [topLeftX, topLeftY] = pointRotateRads(
+    point(originalElement.x, originalElement.y),
+    point(cx, cy),
     originalElement.angle,
   );
 
   const changeInX = newTopLeftX - topLeftX;
   const changeInY = newTopLeftY - topLeftY;
 
-  const [x, y] = rotate(
-    newTopLeftX,
-    newTopLeftY,
-    cx + changeInX,
-    cy + changeInY,
-    -originalElement.angle,
+  const [x, y] = pointRotateRads(
+    point(newTopLeftX, newTopLeftY),
+    point(cx + changeInX, cy + changeInY),
+    -originalElement.angle as Radians,
   );
 
   mutateElement(

+ 1 - 1
packages/excalidraw/components/TTDDialog/TTDDialog.tsx

@@ -25,11 +25,11 @@ import type { BinaryFiles } from "../../types";
 import { ArrowRightIcon } from "../icons";
 
 import "./TTDDialog.scss";
-import { isFiniteNumber } from "../../utils";
 import { atom, useAtom } from "jotai";
 import { trackEvent } from "../../analytics";
 import { InlineIcon } from "../InlineIcon";
 import { TTDDialogSubmitShortcut } from "./TTDDialogSubmitShortcut";
+import { isFiniteNumber } from "../../../math";
 
 const MIN_PROMPT_LENGTH = 3;
 const MAX_PROMPT_LENGTH = 1000;

+ 9 - 6
packages/excalidraw/components/hyperlink/Hyperlink.tsx

@@ -1,4 +1,4 @@
-import type { AppState, ExcalidrawProps, Point, UIAppState } from "../../types";
+import type { AppState, ExcalidrawProps, UIAppState } from "../../types";
 import {
   sceneCoordsToViewportCoords,
   viewportCoordsToSceneCoords,
@@ -36,6 +36,7 @@ import { trackEvent } from "../../analytics";
 import { useAppProps, useExcalidrawAppState } from "../App";
 import { isEmbeddableElement } from "../../element/typeChecks";
 import { getLinkHandleFromCoords } from "./helpers";
+import { point, type GlobalPoint } from "../../../math";
 
 const CONTAINER_WIDTH = 320;
 const SPACE_BOTTOM = 85;
@@ -176,10 +177,12 @@ export const Hyperlink = ({
       if (timeoutId) {
         clearTimeout(timeoutId);
       }
-      const shouldHide = shouldHideLinkPopup(element, elementsMap, appState, [
-        event.clientX,
-        event.clientY,
-      ]) as boolean;
+      const shouldHide = shouldHideLinkPopup(
+        element,
+        elementsMap,
+        appState,
+        point(event.clientX, event.clientY),
+      ) as boolean;
       if (shouldHide) {
         timeoutId = window.setTimeout(() => {
           setAppState({ showHyperlinkPopup: false });
@@ -416,7 +419,7 @@ const shouldHideLinkPopup = (
   element: NonDeletedExcalidrawElement,
   elementsMap: ElementsMap,
   appState: AppState,
-  [clientX, clientY]: Point,
+  [clientX, clientY]: GlobalPoint,
 ): Boolean => {
   const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
     { clientX, clientY },

+ 10 - 11
packages/excalidraw/components/hyperlink/helpers.ts

@@ -1,3 +1,5 @@
+import type { GlobalPoint, Radians } from "../../../math";
+import { point, pointRotateRads } from "../../../math";
 import { MIME_TYPES } from "../../constants";
 import type { Bounds } from "../../element/bounds";
 import { getElementAbsoluteCoords } from "../../element/bounds";
@@ -6,9 +8,8 @@ import type {
   ElementsMap,
   NonDeletedExcalidrawElement,
 } from "../../element/types";
-import { rotate } from "../../math";
 import { DEFAULT_LINK_SIZE } from "../../renderer/renderElement";
-import type { AppState, Point, UIAppState } from "../../types";
+import type { AppState, UIAppState } from "../../types";
 
 export const EXTERNAL_LINK_IMG = document.createElement("img");
 EXTERNAL_LINK_IMG.src = `data:${MIME_TYPES.svg}, ${encodeURIComponent(
@@ -17,7 +18,7 @@ EXTERNAL_LINK_IMG.src = `data:${MIME_TYPES.svg}, ${encodeURIComponent(
 
 export const getLinkHandleFromCoords = (
   [x1, y1, x2, y2]: Bounds,
-  angle: number,
+  angle: Radians,
   appState: Pick<UIAppState, "zoom">,
 ): Bounds => {
   const size = DEFAULT_LINK_SIZE;
@@ -33,11 +34,9 @@ export const getLinkHandleFromCoords = (
   const x = x2 + dashedLineMargin - centeringOffset;
   const y = y1 - dashedLineMargin - linkMarginY + centeringOffset;
 
-  const [rotatedX, rotatedY] = rotate(
-    x + linkWidth / 2,
-    y + linkHeight / 2,
-    centerX,
-    centerY,
+  const [rotatedX, rotatedY] = pointRotateRads(
+    point(x + linkWidth / 2, y + linkHeight / 2),
+    point(centerX, centerY),
     angle,
   );
   return [
@@ -52,7 +51,7 @@ export const isPointHittingLinkIcon = (
   element: NonDeletedExcalidrawElement,
   elementsMap: ElementsMap,
   appState: AppState,
-  [x, y]: Point,
+  [x, y]: GlobalPoint,
 ) => {
   const threshold = 4 / appState.zoom.value;
   const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
@@ -73,7 +72,7 @@ export const isPointHittingLink = (
   element: NonDeletedExcalidrawElement,
   elementsMap: ElementsMap,
   appState: AppState,
-  [x, y]: Point,
+  [x, y]: GlobalPoint,
   isMobile: boolean,
 ) => {
   if (!element.link || appState.selectedElementIds[element.id]) {
@@ -86,5 +85,5 @@ export const isPointHittingLink = (
   ) {
     return true;
   }
-  return isPointHittingLinkIcon(element, elementsMap, appState, [x, y]);
+  return isPointHittingLinkIcon(element, elementsMap, appState, point(x, y));
 };

+ 9 - 17
packages/excalidraw/data/restore.ts

@@ -40,11 +40,7 @@ import {
 import { getDefaultAppState } from "../appState";
 import { LinearElementEditor } from "../element/linearElementEditor";
 import { bumpVersion } from "../element/mutateElement";
-import {
-  getUpdatedTimestamp,
-  isFiniteNumber,
-  updateActiveTool,
-} from "../utils";
+import { getUpdatedTimestamp, updateActiveTool } from "../utils";
 import { arrayToMap } from "../utils";
 import type { MarkOptional, Mutable } from "../utility-types";
 import { detectLineHeight, getContainerElement } from "../element/textElement";
@@ -58,6 +54,8 @@ import {
   getNormalizedGridStep,
   getNormalizedZoom,
 } from "../scene";
+import type { LocalPoint, Radians } from "../../math";
+import { isFiniteNumber, point } from "../../math";
 
 type RestoredAppState = Omit<
   AppState,
@@ -152,7 +150,7 @@ const restoreElementWithProperties = <
     roughness: element.roughness ?? DEFAULT_ELEMENT_PROPS.roughness,
     opacity:
       element.opacity == null ? DEFAULT_ELEMENT_PROPS.opacity : element.opacity,
-    angle: element.angle || 0,
+    angle: element.angle || (0 as Radians),
     x: extra.x ?? element.x ?? 0,
     y: extra.y ?? element.y ?? 0,
     strokeColor: element.strokeColor || DEFAULT_ELEMENT_PROPS.strokeColor,
@@ -266,10 +264,7 @@ const restoreElement = (
       let y = element.y;
       let points = // migrate old arrow model to new one
         !Array.isArray(element.points) || element.points.length < 2
-          ? [
-              [0, 0],
-              [element.width, element.height],
-            ]
+          ? [point(0, 0), point(element.width, element.height)]
           : element.points;
 
       if (points[0][0] !== 0 || points[0][1] !== 0) {
@@ -293,14 +288,11 @@ const restoreElement = (
       });
     case "arrow": {
       const { startArrowhead = null, endArrowhead = "arrow" } = element;
-      let x = element.x;
-      let y = element.y;
-      let points = // migrate old arrow model to new one
+      let x: number | undefined = element.x;
+      let y: number | undefined = element.y;
+      let points: readonly LocalPoint[] | undefined = // migrate old arrow model to new one
         !Array.isArray(element.points) || element.points.length < 2
-          ? [
-              [0, 0],
-              [element.width, element.height],
-            ]
+          ? [point(0, 0), point(element.width, element.height)]
           : element.points;
 
       if (points[0][0] !== 0 || points[0][1] !== 0) {

+ 3 - 5
packages/excalidraw/data/transform.test.ts

@@ -2,6 +2,7 @@ import { vi } from "vitest";
 import type { ExcalidrawElementSkeleton } from "./transform";
 import { convertToExcalidrawElements } from "./transform";
 import type { ExcalidrawArrowElement } from "../element/types";
+import { point } from "../../math";
 
 const opts = { regenerateIds: false };
 
@@ -911,10 +912,7 @@ describe("Test Transform", () => {
         x: 111.262,
         y: 57,
         strokeWidth: 2,
-        points: [
-          [0, 0],
-          [272.985, 0],
-        ],
+        points: [point(0, 0), point(272.985, 0)],
         label: {
           text: "How are you?",
           fontSize: 20,
@@ -937,7 +935,7 @@ describe("Test Transform", () => {
         x: 77.017,
         y: 79,
         strokeWidth: 2,
-        points: [[0, 0]],
+        points: [point(0, 0)],
         label: {
           text: "Friendship",
           fontSize: 20,

+ 4 - 9
packages/excalidraw/data/transform.ts

@@ -53,6 +53,7 @@ import { randomId } from "../random";
 import { syncInvalidIndices } from "../fractionalIndex";
 import { getLineHeight } from "../fonts";
 import { isArrowElement } from "../element/typeChecks";
+import { point, type LocalPoint } from "../../math";
 
 export type ValidLinearElement = {
   type: "arrow" | "line";
@@ -417,7 +418,7 @@ const bindLinearElementToElement = (
   const endPointIndex = linearElement.points.length - 1;
   const delta = 0.5;
 
-  const newPoints = cloneJSON(linearElement.points) as [number, number][];
+  const newPoints = cloneJSON<readonly LocalPoint[]>(linearElement.points);
 
   // left to right so shift the arrow towards right
   if (
@@ -535,10 +536,7 @@ export const convertToExcalidrawElements = (
         excalidrawElement = newLinearElement({
           width,
           height,
-          points: [
-            [0, 0],
-            [width, height],
-          ],
+          points: [point(0, 0), point(width, height)],
           ...element,
         });
 
@@ -551,10 +549,7 @@ export const convertToExcalidrawElements = (
           width,
           height,
           endArrowhead: "arrow",
-          points: [
-            [0, 0],
-            [width, height],
-          ],
+          points: [point(0, 0), point(width, height)],
           ...element,
           type: "arrow",
         });

+ 169 - 141
packages/excalidraw/element/binding.ts

@@ -1,8 +1,8 @@
-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 * as GA from "../../math/ga/ga";
+import * as GAPoint from "../../math/ga/gapoints";
+import * as GADirection from "../../math/ga/gadirections";
+import * as GALine from "../../math/ga/galines";
+import * as GATransform from "../../math/ga/gatransforms";
 
 import type {
   ExcalidrawBindableElement,
@@ -10,7 +10,6 @@ import type {
   ExcalidrawRectangleElement,
   ExcalidrawDiamondElement,
   ExcalidrawEllipseElement,
-  ExcalidrawFreeDrawElement,
   ExcalidrawImageElement,
   ExcalidrawFrameLikeElement,
   ExcalidrawIframeLikeElement,
@@ -26,11 +25,12 @@ import type {
   ExcalidrawElbowArrowElement,
   FixedPoint,
   SceneElementsMap,
+  ExcalidrawRectanguloidElement,
 } from "./types";
 
 import type { Bounds } from "./bounds";
-import { getElementAbsoluteCoords } from "./bounds";
-import type { AppState, Point } from "../types";
+import { getCenterForBounds, getElementAbsoluteCoords } from "./bounds";
+import type { AppState } from "../types";
 import { isPointOnShape } from "../../utils/collision";
 import { getElementAtPosition } from "../scene";
 import {
@@ -51,17 +51,7 @@ import { LinearElementEditor } from "./linearElementEditor";
 import { arrayToMap, tupleToCoors } from "../utils";
 import { KEYS } from "../keys";
 import { getBoundTextElement, handleBindTextResize } from "./textElement";
-import { getElementShape } from "../shapes";
-import {
-  aabbForElement,
-  clamp,
-  distanceSq2d,
-  getCenterForBounds,
-  getCenterForElement,
-  pointInsideBounds,
-  pointToVector,
-  rotatePoint,
-} from "../math";
+import { aabbForElement, getElementShape, pointInsideBounds } from "../shapes";
 import {
   compareHeading,
   HEADING_DOWN,
@@ -72,7 +62,18 @@ import {
   vectorToHeading,
   type Heading,
 } from "./heading";
-import { segmentIntersectRectangleElement } from "../../utils/geometry/geometry";
+import type { LocalPoint, Radians } from "../../math";
+import {
+  lineSegment,
+  point,
+  pointRotateRads,
+  type GlobalPoint,
+  vectorFromPoint,
+  pointFromPair,
+  pointDistanceSq,
+  clamp,
+} from "../../math";
+import { segmentIntersectRectangleElement } from "../../utils/geometry/shape";
 
 export type SuggestedBinding =
   | NonDeleted<ExcalidrawBindableElement>
@@ -649,7 +650,7 @@ export const updateBoundElements = (
         update,
       ): update is NonNullable<{
         index: number;
-        point: Point;
+        point: LocalPoint;
         isDragging?: boolean;
       }> => update !== null,
     );
@@ -695,14 +696,14 @@ const getSimultaneouslyUpdatedElementIds = (
 };
 
 export const getHeadingForElbowArrowSnap = (
-  point: Readonly<Point>,
-  otherPoint: Readonly<Point>,
+  p: Readonly<GlobalPoint>,
+  otherPoint: Readonly<GlobalPoint>,
   bindableElement: ExcalidrawBindableElement | undefined | null,
   aabb: Bounds | undefined | null,
   elementsMap: ElementsMap,
-  origPoint: Point,
+  origPoint: GlobalPoint,
 ): Heading => {
-  const otherPointHeading = vectorToHeading(pointToVector(otherPoint, point));
+  const otherPointHeading = vectorToHeading(vectorFromPoint(otherPoint, p));
 
   if (!bindableElement || !aabb) {
     return otherPointHeading;
@@ -716,17 +717,23 @@ export const getHeadingForElbowArrowSnap = (
 
   if (!distance) {
     return vectorToHeading(
-      pointToVector(point, getCenterForElement(bindableElement)),
+      vectorFromPoint(
+        p,
+        point<GlobalPoint>(
+          bindableElement.x + bindableElement.width / 2,
+          bindableElement.y + bindableElement.height / 2,
+        ),
+      ),
     );
   }
 
-  const pointHeading = headingForPointFromElement(bindableElement, aabb, point);
+  const pointHeading = headingForPointFromElement(bindableElement, aabb, p);
 
   return pointHeading;
 };
 
 const getDistanceForBinding = (
-  point: Readonly<Point>,
+  point: Readonly<GlobalPoint>,
   bindableElement: ExcalidrawBindableElement,
   elementsMap: ElementsMap,
 ) => {
@@ -745,89 +752,87 @@ const getDistanceForBinding = (
 };
 
 export const bindPointToSnapToElementOutline = (
-  point: Readonly<Point>,
-  otherPoint: Readonly<Point>,
+  p: Readonly<GlobalPoint>,
+  otherPoint: Readonly<GlobalPoint>,
   bindableElement: ExcalidrawBindableElement | undefined,
   elementsMap: ElementsMap,
-): Point => {
+): GlobalPoint => {
   const aabb = bindableElement && aabbForElement(bindableElement);
 
   if (bindableElement && aabb) {
     // TODO: Dirty hacks until tangents are properly calculated
-    const heading = headingForPointFromElement(bindableElement, aabb, point);
+    const heading = headingForPointFromElement(bindableElement, aabb, p);
     const intersections = [
-      ...intersectElementWithLine(
+      ...(intersectElementWithLine(
         bindableElement,
-        [point[0], point[1] - 2 * bindableElement.height],
-        [point[0], point[1] + 2 * bindableElement.height],
+        point(p[0], p[1] - 2 * bindableElement.height),
+        point(p[0], p[1] + 2 * bindableElement.height),
         FIXED_BINDING_DISTANCE,
         elementsMap,
-      ),
-      ...intersectElementWithLine(
+      ) ?? []),
+      ...(intersectElementWithLine(
         bindableElement,
-        [point[0] - 2 * bindableElement.width, point[1]],
-        [point[0] + 2 * bindableElement.width, point[1]],
+        point(p[0] - 2 * bindableElement.width, p[1]),
+        point(p[0] + 2 * bindableElement.width, p[1]),
         FIXED_BINDING_DISTANCE,
         elementsMap,
-      ),
+      ) ?? []),
     ];
 
     const isVertical =
       compareHeading(heading, HEADING_LEFT) ||
       compareHeading(heading, HEADING_RIGHT);
     const dist = Math.abs(
-      distanceToBindableElement(bindableElement, point, elementsMap),
+      distanceToBindableElement(bindableElement, p, elementsMap),
     );
     const isInner = isVertical
       ? dist < bindableElement.width * -0.1
       : dist < bindableElement.height * -0.1;
 
-    intersections.sort(
-      (a, b) => distanceSq2d(a, point) - distanceSq2d(b, point),
-    );
+    intersections.sort((a, b) => pointDistanceSq(a, p) - pointDistanceSq(b, p));
 
     return isInner
       ? headingToMidBindPoint(otherPoint, bindableElement, aabb)
       : intersections.filter((i) =>
           isVertical
-            ? Math.abs(point[1] - i[1]) < 0.1
-            : Math.abs(point[0] - i[0]) < 0.1,
+            ? Math.abs(p[1] - i[1]) < 0.1
+            : Math.abs(p[0] - i[0]) < 0.1,
         )[0] ?? point;
   }
 
-  return point;
+  return p;
 };
 
 const headingToMidBindPoint = (
-  point: Point,
+  p: GlobalPoint,
   bindableElement: ExcalidrawBindableElement,
   aabb: Bounds,
-): Point => {
+): GlobalPoint => {
   const center = getCenterForBounds(aabb);
-  const heading = vectorToHeading(pointToVector(point, center));
+  const heading = vectorToHeading(vectorFromPoint(p, center));
 
   switch (true) {
     case compareHeading(heading, HEADING_UP):
-      return rotatePoint(
-        [(aabb[0] + aabb[2]) / 2 + 0.1, aabb[1]],
+      return pointRotateRads(
+        point((aabb[0] + aabb[2]) / 2 + 0.1, aabb[1]),
         center,
         bindableElement.angle,
       );
     case compareHeading(heading, HEADING_RIGHT):
-      return rotatePoint(
-        [aabb[2], (aabb[1] + aabb[3]) / 2 + 0.1],
+      return pointRotateRads(
+        point(aabb[2], (aabb[1] + aabb[3]) / 2 + 0.1),
         center,
         bindableElement.angle,
       );
     case compareHeading(heading, HEADING_DOWN):
-      return rotatePoint(
-        [(aabb[0] + aabb[2]) / 2 - 0.1, aabb[3]],
+      return pointRotateRads(
+        point((aabb[0] + aabb[2]) / 2 - 0.1, aabb[3]),
         center,
         bindableElement.angle,
       );
     default:
-      return rotatePoint(
-        [aabb[0], (aabb[1] + aabb[3]) / 2 - 0.1],
+      return pointRotateRads(
+        point(aabb[0], (aabb[1] + aabb[3]) / 2 - 0.1),
         center,
         bindableElement.angle,
       );
@@ -836,22 +841,25 @@ const headingToMidBindPoint = (
 
 export const avoidRectangularCorner = (
   element: ExcalidrawBindableElement,
-  p: Point,
-): Point => {
-  const center = getCenterForElement(element);
-  const nonRotatedPoint = rotatePoint(p, center, -element.angle);
+  p: GlobalPoint,
+): GlobalPoint => {
+  const center = point<GlobalPoint>(
+    element.x + element.width / 2,
+    element.y + element.height / 2,
+  );
+  const nonRotatedPoint = pointRotateRads(p, center, -element.angle as Radians);
 
   if (nonRotatedPoint[0] < element.x && nonRotatedPoint[1] < element.y) {
     // Top left
     if (nonRotatedPoint[1] - element.y > -FIXED_BINDING_DISTANCE) {
-      return rotatePoint(
-        [element.x - FIXED_BINDING_DISTANCE, element.y],
+      return pointRotateRads<GlobalPoint>(
+        point(element.x - FIXED_BINDING_DISTANCE, element.y),
         center,
         element.angle,
       );
     }
-    return rotatePoint(
-      [element.x, element.y - FIXED_BINDING_DISTANCE],
+    return pointRotateRads(
+      point(element.x, element.y - FIXED_BINDING_DISTANCE),
       center,
       element.angle,
     );
@@ -861,14 +869,14 @@ export const avoidRectangularCorner = (
   ) {
     // Bottom left
     if (nonRotatedPoint[0] - element.x > -FIXED_BINDING_DISTANCE) {
-      return rotatePoint(
-        [element.x, element.y + element.height + FIXED_BINDING_DISTANCE],
+      return pointRotateRads(
+        point(element.x, element.y + element.height + FIXED_BINDING_DISTANCE),
         center,
         element.angle,
       );
     }
-    return rotatePoint(
-      [element.x - FIXED_BINDING_DISTANCE, element.y + element.height],
+    return pointRotateRads(
+      point(element.x - FIXED_BINDING_DISTANCE, element.y + element.height),
       center,
       element.angle,
     );
@@ -881,20 +889,20 @@ export const avoidRectangularCorner = (
       nonRotatedPoint[0] - element.x <
       element.width + FIXED_BINDING_DISTANCE
     ) {
-      return rotatePoint(
-        [
+      return pointRotateRads(
+        point(
           element.x + element.width,
           element.y + element.height + FIXED_BINDING_DISTANCE,
-        ],
+        ),
         center,
         element.angle,
       );
     }
-    return rotatePoint(
-      [
+    return pointRotateRads(
+      point(
         element.x + element.width + FIXED_BINDING_DISTANCE,
         element.y + element.height,
-      ],
+      ),
       center,
       element.angle,
     );
@@ -907,14 +915,14 @@ export const avoidRectangularCorner = (
       nonRotatedPoint[0] - element.x <
       element.width + FIXED_BINDING_DISTANCE
     ) {
-      return rotatePoint(
-        [element.x + element.width, element.y - FIXED_BINDING_DISTANCE],
+      return pointRotateRads(
+        point(element.x + element.width, element.y - FIXED_BINDING_DISTANCE),
         center,
         element.angle,
       );
     }
-    return rotatePoint(
-      [element.x + element.width + FIXED_BINDING_DISTANCE, element.y],
+    return pointRotateRads(
+      point(element.x + element.width + FIXED_BINDING_DISTANCE, element.y),
       center,
       element.angle,
     );
@@ -925,12 +933,12 @@ export const avoidRectangularCorner = (
 
 export const snapToMid = (
   element: ExcalidrawBindableElement,
-  p: Point,
+  p: GlobalPoint,
   tolerance: number = 0.05,
-): Point => {
+): GlobalPoint => {
   const { x, y, width, height, angle } = element;
-  const center = [x + width / 2 - 0.1, y + height / 2 - 0.1] as Point;
-  const nonRotated = rotatePoint(p, center, -angle);
+  const center = point<GlobalPoint>(x + width / 2 - 0.1, y + height / 2 - 0.1);
+  const nonRotated = pointRotateRads(p, center, -angle as Radians);
 
   // snap-to-center point is adaptive to element size, but we don't want to go
   // above and below certain px distance
@@ -943,22 +951,30 @@ export const snapToMid = (
     nonRotated[1] < center[1] + verticalThrehsold
   ) {
     // LEFT
-    return rotatePoint([x - FIXED_BINDING_DISTANCE, center[1]], center, angle);
+    return pointRotateRads(
+      point(x - FIXED_BINDING_DISTANCE, center[1]),
+      center,
+      angle,
+    );
   } else if (
     nonRotated[1] <= y + height / 2 &&
     nonRotated[0] > center[0] - horizontalThrehsold &&
     nonRotated[0] < center[0] + horizontalThrehsold
   ) {
     // TOP
-    return rotatePoint([center[0], y - FIXED_BINDING_DISTANCE], center, angle);
+    return pointRotateRads(
+      point(center[0], y - FIXED_BINDING_DISTANCE),
+      center,
+      angle,
+    );
   } else if (
     nonRotated[0] >= x + width / 2 &&
     nonRotated[1] > center[1] - verticalThrehsold &&
     nonRotated[1] < center[1] + verticalThrehsold
   ) {
     // RIGHT
-    return rotatePoint(
-      [x + width + FIXED_BINDING_DISTANCE, center[1]],
+    return pointRotateRads(
+      point(x + width + FIXED_BINDING_DISTANCE, center[1]),
       center,
       angle,
     );
@@ -968,8 +984,8 @@ export const snapToMid = (
     nonRotated[0] < center[0] + horizontalThrehsold
   ) {
     // DOWN
-    return rotatePoint(
-      [center[0], y + height + FIXED_BINDING_DISTANCE],
+    return pointRotateRads(
+      point(center[0], y + height + FIXED_BINDING_DISTANCE),
       center,
       angle,
     );
@@ -984,7 +1000,7 @@ const updateBoundPoint = (
   binding: PointBinding | null | undefined,
   bindableElement: ExcalidrawBindableElement,
   elementsMap: ElementsMap,
-): Point | null => {
+): LocalPoint | null => {
   if (
     binding == null ||
     // We only need to update the other end if this is a 2 point line element
@@ -1006,15 +1022,15 @@ const updateBoundPoint = (
         startOrEnd === "startBinding" ? "start" : "end",
         elementsMap,
       ).fixedPoint;
-    const globalMidPoint = [
+    const globalMidPoint = point<GlobalPoint>(
       bindableElement.x + bindableElement.width / 2,
       bindableElement.y + bindableElement.height / 2,
-    ] as Point;
-    const global = [
+    );
+    const global = point<GlobalPoint>(
       bindableElement.x + fixedPoint[0] * bindableElement.width,
       bindableElement.y + fixedPoint[1] * bindableElement.height,
-    ] as Point;
-    const rotatedGlobal = rotatePoint(
+    );
+    const rotatedGlobal = pointRotateRads(
       global,
       globalMidPoint,
       bindableElement.angle,
@@ -1040,7 +1056,7 @@ const updateBoundPoint = (
     elementsMap,
   );
 
-  let newEdgePoint: Point;
+  let newEdgePoint: GlobalPoint;
 
   // The linear element was not originally pointing inside the bound shape,
   // we can point directly at the focus point
@@ -1054,7 +1070,7 @@ const updateBoundPoint = (
       binding.gap,
       elementsMap,
     );
-    if (intersections.length === 0) {
+    if (!intersections || intersections.length === 0) {
       // This should never happen, since focusPoint should always be
       // inside the element, but just in case, bail out
       newEdgePoint = focusPointAbsolute;
@@ -1101,15 +1117,15 @@ export const calculateFixedPointForElbowArrowBinding = (
     hoveredElement,
     elementsMap,
   );
-  const globalMidPoint = [
+  const globalMidPoint = point(
     bounds[0] + (bounds[2] - bounds[0]) / 2,
     bounds[1] + (bounds[3] - bounds[1]) / 2,
-  ] as Point;
-  const nonRotatedSnappedGlobalPoint = rotatePoint(
+  );
+  const nonRotatedSnappedGlobalPoint = pointRotateRads(
     snappedPoint,
     globalMidPoint,
-    -hoveredElement.angle,
-  ) as Point;
+    -hoveredElement.angle as Radians,
+  );
 
   return {
     fixedPoint: normalizeFixedPoint([
@@ -1320,8 +1336,9 @@ export const bindingBorderTest = (
   const threshold = maxBindingGap(element, element.width, element.height);
   const shape = getElementShape(element, elementsMap);
   return (
-    isPointOnShape([x, y], shape, threshold) ||
-    (fullShape === true && pointInsideBounds([x, y], aabbForElement(element)))
+    isPointOnShape(point(x, y), shape, threshold) ||
+    (fullShape === true &&
+      pointInsideBounds(point(x, y), aabbForElement(element)))
   );
 };
 
@@ -1339,7 +1356,7 @@ export const maxBindingGap = (
 
 export const distanceToBindableElement = (
   element: ExcalidrawBindableElement,
-  point: Point,
+  point: GlobalPoint,
   elementsMap: ElementsMap,
 ): number => {
   switch (element.type) {
@@ -1359,19 +1376,13 @@ export const distanceToBindableElement = (
 };
 
 const distanceToRectangle = (
-  element:
-    | ExcalidrawRectangleElement
-    | ExcalidrawTextElement
-    | ExcalidrawFreeDrawElement
-    | ExcalidrawImageElement
-    | ExcalidrawIframeLikeElement
-    | ExcalidrawFrameLikeElement,
-  point: Point,
+  element: ExcalidrawRectanguloidElement,
+  p: GlobalPoint,
   elementsMap: ElementsMap,
 ): number => {
   const [, pointRel, hwidth, hheight] = pointRelativeToElement(
     element,
-    point,
+    p,
     elementsMap,
   );
   return Math.max(
@@ -1382,7 +1393,7 @@ const distanceToRectangle = (
 
 const distanceToDiamond = (
   element: ExcalidrawDiamondElement,
-  point: Point,
+  point: GlobalPoint,
   elementsMap: ElementsMap,
 ): number => {
   const [, pointRel, hwidth, hheight] = pointRelativeToElement(
@@ -1396,7 +1407,7 @@ const distanceToDiamond = (
 
 const distanceToEllipse = (
   element: ExcalidrawEllipseElement,
-  point: Point,
+  point: GlobalPoint,
   elementsMap: ElementsMap,
 ): number => {
   const [pointRel, tangent] = ellipseParamsForTest(element, point, elementsMap);
@@ -1405,7 +1416,7 @@ const distanceToEllipse = (
 
 const ellipseParamsForTest = (
   element: ExcalidrawEllipseElement,
-  point: Point,
+  point: GlobalPoint,
   elementsMap: ElementsMap,
 ): [GA.Point, GA.Line] => {
   const [, pointRel, hwidth, hheight] = pointRelativeToElement(
@@ -1467,7 +1478,7 @@ const ellipseParamsForTest = (
 // so we only need to perform hit tests for the positive quadrant.
 const pointRelativeToElement = (
   element: ExcalidrawElement,
-  pointTuple: Point,
+  pointTuple: GlobalPoint,
   elementsMap: ElementsMap,
 ): [GA.Point, GA.Point, number, number] => {
   const point = GAPoint.from(pointTuple);
@@ -1516,9 +1527,9 @@ const coordsCenter = (
 const determineFocusDistance = (
   element: ExcalidrawBindableElement,
   // Point on the line, in absolute coordinates
-  a: Point,
+  a: GlobalPoint,
   // Another point on the line, in absolute coordinates (closer to element)
-  b: Point,
+  b: GlobalPoint,
   elementsMap: ElementsMap,
 ): number => {
   const relateToCenter = relativizationToElementCenter(element, elementsMap);
@@ -1559,13 +1570,13 @@ const determineFocusPoint = (
   // The oriented, relative distance from the center of `element` of the
   // returned focusPoint
   focus: number,
-  adjecentPoint: Point,
+  adjecentPoint: GlobalPoint,
   elementsMap: ElementsMap,
-): Point => {
+): GlobalPoint => {
   if (focus === 0) {
     const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
     const center = coordsCenter(x1, y1, x2, y2);
-    return GAPoint.toTuple(center);
+    return pointFromPair(GAPoint.toTuple(center));
   }
   const relateToCenter = relativizationToElementCenter(element, elementsMap);
   const adjecentPointRel = GATransform.apply(
@@ -1589,7 +1600,9 @@ const determineFocusPoint = (
       point = findFocusPointForEllipse(element, focus, adjecentPointRel);
       break;
   }
-  return GAPoint.toTuple(GATransform.apply(reverseRelateToCenter, point));
+  return pointFromPair(
+    GAPoint.toTuple(GATransform.apply(reverseRelateToCenter, point)),
+  );
 };
 
 // Returns 2 or 0 intersection points between line going through `a` and `b`
@@ -1597,15 +1610,15 @@ const determineFocusPoint = (
 const intersectElementWithLine = (
   element: ExcalidrawBindableElement,
   // Point on the line, in absolute coordinates
-  a: Point,
+  a: GlobalPoint,
   // Another point on the line, in absolute coordinates
-  b: Point,
+  b: GlobalPoint,
   // If given, the element is inflated by this value
   gap: number = 0,
   elementsMap: ElementsMap,
-): Point[] => {
+): GlobalPoint[] | undefined => {
   if (isRectangularElement(element)) {
-    return segmentIntersectRectangleElement(element, [a, b], gap);
+    return segmentIntersectRectangleElement(element, lineSegment(a, b), gap);
   }
 
   const relateToCenter = relativizationToElementCenter(element, elementsMap);
@@ -1619,8 +1632,14 @@ const intersectElementWithLine = (
     aRel,
     gap,
   );
-  return intersections.map((point) =>
-    GAPoint.toTuple(GATransform.apply(reverseRelateToCenter, point)),
+  return intersections.map(
+    (point) =>
+      pointFromPair(
+        GAPoint.toTuple(GATransform.apply(reverseRelateToCenter, point)),
+      ),
+    // pointFromArray(
+    //   ,
+    // ),
   );
 };
 
@@ -2173,12 +2192,18 @@ export class BindableElement {
 export const getGlobalFixedPointForBindableElement = (
   fixedPointRatio: [number, number],
   element: ExcalidrawBindableElement,
-) => {
+): GlobalPoint => {
   const [fixedX, fixedY] = normalizeFixedPoint(fixedPointRatio);
 
-  return rotatePoint(
-    [element.x + element.width * fixedX, element.y + element.height * fixedY],
-    getCenterForElement(element),
+  return pointRotateRads(
+    point(
+      element.x + element.width * fixedX,
+      element.y + element.height * fixedY,
+    ),
+    point<GlobalPoint>(
+      element.x + element.width / 2,
+      element.y + element.height / 2,
+    ),
     element.angle,
   );
 };
@@ -2186,7 +2211,7 @@ export const getGlobalFixedPointForBindableElement = (
 const getGlobalFixedPoints = (
   arrow: ExcalidrawElbowArrowElement,
   elementsMap: ElementsMap,
-) => {
+): [GlobalPoint, GlobalPoint] => {
   const startElement =
     arrow.startBinding &&
     (elementsMap.get(arrow.startBinding.elementId) as
@@ -2197,23 +2222,26 @@ const getGlobalFixedPoints = (
     (elementsMap.get(arrow.endBinding.elementId) as
       | ExcalidrawBindableElement
       | undefined);
-  const startPoint: Point =
+  const startPoint =
     startElement && arrow.startBinding
       ? getGlobalFixedPointForBindableElement(
           arrow.startBinding.fixedPoint,
           startElement as ExcalidrawBindableElement,
         )
-      : [arrow.x + arrow.points[0][0], arrow.y + arrow.points[0][1]];
-  const endPoint: Point =
+      : point<GlobalPoint>(
+          arrow.x + arrow.points[0][0],
+          arrow.y + arrow.points[0][1],
+        );
+  const endPoint =
     endElement && arrow.endBinding
       ? getGlobalFixedPointForBindableElement(
           arrow.endBinding.fixedPoint,
           endElement as ExcalidrawBindableElement,
         )
-      : [
+      : point<GlobalPoint>(
           arrow.x + arrow.points[arrow.points.length - 1][0],
           arrow.y + arrow.points[arrow.points.length - 1][1],
-        ];
+        );
 
   return [startPoint, endPoint];
 };

+ 5 - 3
packages/excalidraw/element/bounds.test.ts

@@ -1,3 +1,5 @@
+import type { LocalPoint } from "../../math";
+import { point } from "../../math";
 import { ROUNDNESS } from "../constants";
 import { arrayToMap } from "../utils";
 import { getElementAbsoluteCoords, getElementBounds } from "./bounds";
@@ -123,9 +125,9 @@ describe("getElementBounds", () => {
         a: 0.6447741904932416,
       }),
       points: [
-        [0, 0] as [number, number],
-        [67.33984375, 92.48828125] as [number, number],
-        [-102.7890625, 52.15625] as [number, number],
+        point<LocalPoint>(0, 0),
+        point<LocalPoint>(67.33984375, 92.48828125),
+        point<LocalPoint>(-102.7890625, 52.15625),
       ],
     } as ExcalidrawLinearElement;
 

+ 187 - 109
packages/excalidraw/element/bounds.ts

@@ -7,10 +7,10 @@ import type {
   ExcalidrawTextElementWithContainer,
   ElementsMap,
 } from "./types";
-import { distance2d, rotate, rotatePoint } from "../math";
 import rough from "roughjs/bin/rough";
+import type { Point as RoughPoint } from "roughjs/bin/geometry";
 import type { Drawable, Op } from "roughjs/bin/core";
-import type { AppState, Point } from "../types";
+import type { AppState } from "../types";
 import { generateRoughOptions } from "../scene/Shape";
 import {
   isArrowElement,
@@ -22,9 +22,24 @@ import {
 import { rescalePoints } from "../points";
 import { getBoundTextElement, getContainerElement } from "./textElement";
 import { LinearElementEditor } from "./linearElementEditor";
-import type { Mutable } from "../utility-types";
 import { ShapeCache } from "../scene/ShapeCache";
-import { arrayToMap } from "../utils";
+import { arrayToMap, invariant } from "../utils";
+import type {
+  Degrees,
+  GlobalPoint,
+  LineSegment,
+  LocalPoint,
+  Radians,
+} from "../../math";
+import {
+  degreesToRadians,
+  lineSegment,
+  point,
+  pointDistance,
+  pointFromArray,
+  pointRotateRads,
+} from "../../math";
+import type { Mutable } from "../utility-types";
 
 export type RectangleBox = {
   x: number;
@@ -97,7 +112,11 @@ export class ElementBounds {
     if (isFreeDrawElement(element)) {
       const [minX, minY, maxX, maxY] = getBoundsFromPoints(
         element.points.map(([x, y]) =>
-          rotate(x, y, cx - element.x, cy - element.y, element.angle),
+          pointRotateRads(
+            point(x, y),
+            point(cx - element.x, cy - element.y),
+            element.angle,
+          ),
         ),
       );
 
@@ -110,10 +129,26 @@ export class ElementBounds {
     } else if (isLinearElement(element)) {
       bounds = getLinearElementRotatedBounds(element, cx, cy, elementsMap);
     } else if (element.type === "diamond") {
-      const [x11, y11] = rotate(cx, y1, cx, cy, element.angle);
-      const [x12, y12] = rotate(cx, y2, cx, cy, element.angle);
-      const [x22, y22] = rotate(x1, cy, cx, cy, element.angle);
-      const [x21, y21] = rotate(x2, cy, cx, cy, element.angle);
+      const [x11, y11] = pointRotateRads(
+        point(cx, y1),
+        point(cx, cy),
+        element.angle,
+      );
+      const [x12, y12] = pointRotateRads(
+        point(cx, y2),
+        point(cx, cy),
+        element.angle,
+      );
+      const [x22, y22] = pointRotateRads(
+        point(x1, cy),
+        point(cx, cy),
+        element.angle,
+      );
+      const [x21, y21] = pointRotateRads(
+        point(x2, cy),
+        point(cx, cy),
+        element.angle,
+      );
       const minX = Math.min(x11, x12, x22, x21);
       const minY = Math.min(y11, y12, y22, y21);
       const maxX = Math.max(x11, x12, x22, x21);
@@ -128,10 +163,26 @@ export class ElementBounds {
       const hh = Math.hypot(h * cos, w * sin);
       bounds = [cx - ww, cy - hh, cx + ww, cy + hh];
     } else {
-      const [x11, y11] = rotate(x1, y1, cx, cy, element.angle);
-      const [x12, y12] = rotate(x1, y2, cx, cy, element.angle);
-      const [x22, y22] = rotate(x2, y2, cx, cy, element.angle);
-      const [x21, y21] = rotate(x2, y1, cx, cy, element.angle);
+      const [x11, y11] = pointRotateRads(
+        point(x1, y1),
+        point(cx, cy),
+        element.angle,
+      );
+      const [x12, y12] = pointRotateRads(
+        point(x1, y2),
+        point(cx, cy),
+        element.angle,
+      );
+      const [x22, y22] = pointRotateRads(
+        point(x2, y2),
+        point(cx, cy),
+        element.angle,
+      );
+      const [x21, y21] = pointRotateRads(
+        point(x2, y1),
+        point(cx, cy),
+        element.angle,
+      );
       const minX = Math.min(x11, x12, x22, x21);
       const minY = Math.min(y11, y12, y22, y21);
       const maxX = Math.max(x11, x12, x22, x21);
@@ -165,18 +216,18 @@ export const getElementAbsoluteCoords = (
       ? getContainerElement(element, elementsMap)
       : null;
     if (isArrowElement(container)) {
-      const coords = LinearElementEditor.getBoundTextElementPosition(
+      const { x, y } = LinearElementEditor.getBoundTextElementPosition(
         container,
         element as ExcalidrawTextElementWithContainer,
         elementsMap,
       );
       return [
-        coords.x,
-        coords.y,
-        coords.x + element.width,
-        coords.y + element.height,
-        coords.x + element.width / 2,
-        coords.y + element.height / 2,
+        x,
+        y,
+        x + element.width,
+        y + element.height,
+        x + element.width / 2,
+        y + element.height / 2,
       ];
     }
   }
@@ -198,38 +249,40 @@ export const getElementAbsoluteCoords = (
 export const getElementLineSegments = (
   element: ExcalidrawElement,
   elementsMap: ElementsMap,
-): [Point, Point][] => {
+): LineSegment<GlobalPoint>[] => {
   const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(
     element,
     elementsMap,
   );
 
-  const center: Point = [cx, cy];
+  const center: GlobalPoint = point(cx, cy);
 
   if (isLinearElement(element) || isFreeDrawElement(element)) {
-    const segments: [Point, Point][] = [];
+    const segments: LineSegment<GlobalPoint>[] = [];
 
     let i = 0;
 
     while (i < element.points.length - 1) {
-      segments.push([
-        rotatePoint(
-          [
-            element.points[i][0] + element.x,
-            element.points[i][1] + element.y,
-          ] as Point,
-          center,
-          element.angle,
-        ),
-        rotatePoint(
-          [
-            element.points[i + 1][0] + element.x,
-            element.points[i + 1][1] + element.y,
-          ] as Point,
-          center,
-          element.angle,
+      segments.push(
+        lineSegment(
+          pointRotateRads(
+            point(
+              element.points[i][0] + element.x,
+              element.points[i][1] + element.y,
+            ),
+            center,
+            element.angle,
+          ),
+          pointRotateRads(
+            point(
+              element.points[i + 1][0] + element.x,
+              element.points[i + 1][1] + element.y,
+            ),
+            center,
+            element.angle,
+          ),
         ),
-      ]);
+      );
       i++;
     }
 
@@ -246,40 +299,40 @@ export const getElementLineSegments = (
       [cx, y2],
       [x1, cy],
       [x2, cy],
-    ] as Point[]
-  ).map((point) => rotatePoint(point, center, element.angle));
+    ] as GlobalPoint[]
+  ).map((point) => pointRotateRads(point, center, element.angle));
 
   if (element.type === "diamond") {
     return [
-      [n, w],
-      [n, e],
-      [s, w],
-      [s, e],
+      lineSegment(n, w),
+      lineSegment(n, e),
+      lineSegment(s, w),
+      lineSegment(s, e),
     ];
   }
 
   if (element.type === "ellipse") {
     return [
-      [n, w],
-      [n, e],
-      [s, w],
-      [s, e],
-      [n, w],
-      [n, e],
-      [s, w],
-      [s, e],
+      lineSegment(n, w),
+      lineSegment(n, e),
+      lineSegment(s, w),
+      lineSegment(s, e),
+      lineSegment(n, w),
+      lineSegment(n, e),
+      lineSegment(s, w),
+      lineSegment(s, e),
     ];
   }
 
   return [
-    [nw, ne],
-    [sw, se],
-    [nw, sw],
-    [ne, se],
-    [nw, e],
-    [sw, e],
-    [ne, w],
-    [se, w],
+    lineSegment(nw, ne),
+    lineSegment(sw, se),
+    lineSegment(nw, sw),
+    lineSegment(ne, se),
+    lineSegment(nw, e),
+    lineSegment(sw, e),
+    lineSegment(ne, w),
+    lineSegment(se, w),
   ];
 };
 
@@ -386,10 +439,10 @@ const solveQuadratic = (
 };
 
 const getCubicBezierCurveBound = (
-  p0: Point,
-  p1: Point,
-  p2: Point,
-  p3: Point,
+  p0: GlobalPoint,
+  p1: GlobalPoint,
+  p2: GlobalPoint,
+  p3: GlobalPoint,
 ): Bounds => {
   const solX = solveQuadratic(p0[0], p1[0], p2[0], p3[0]);
   const solY = solveQuadratic(p0[1], p1[1], p2[1], p3[1]);
@@ -415,9 +468,9 @@ const getCubicBezierCurveBound = (
 
 export const getMinMaxXYFromCurvePathOps = (
   ops: Op[],
-  transformXY?: (x: number, y: number) => [number, number],
+  transformXY?: (p: GlobalPoint) => GlobalPoint,
 ): Bounds => {
-  let currentP: Point = [0, 0];
+  let currentP: GlobalPoint = point(0, 0);
 
   const { minX, minY, maxX, maxY } = ops.reduce(
     (limits, { op, data }) => {
@@ -425,19 +478,21 @@ export const getMinMaxXYFromCurvePathOps = (
       // move, bcurveTo, lineTo, and curveTo
       if (op === "move") {
         // change starting point
-        currentP = data as unknown as Point;
+        const p: GlobalPoint | undefined = pointFromArray(data);
+        invariant(p != null, "Op data is not a point");
+        currentP = p;
         // move operation does not draw anything; so, it always
         // returns false
       } else if (op === "bcurveTo") {
-        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 _p1 = point<GlobalPoint>(data[0], data[1]);
+        const _p2 = point<GlobalPoint>(data[2], data[3]);
+        const _p3 = point<GlobalPoint>(data[4], data[5]);
 
-        const p1 = transformXY ? transformXY(..._p1) : _p1;
-        const p2 = transformXY ? transformXY(..._p2) : _p2;
-        const p3 = transformXY ? transformXY(..._p3) : _p3;
+        const p1 = transformXY ? transformXY(_p1) : _p1;
+        const p2 = transformXY ? transformXY(_p2) : _p2;
+        const p3 = transformXY ? transformXY(_p3) : _p3;
 
-        const p0 = transformXY ? transformXY(...currentP) : currentP;
+        const p0 = transformXY ? transformXY(currentP) : currentP;
         currentP = _p3;
 
         const [minX, minY, maxX, maxY] = getCubicBezierCurveBound(
@@ -507,14 +562,14 @@ export const getArrowheadSize = (arrowhead: Arrowhead): number => {
 };
 
 /** @returns number in degrees */
-export const getArrowheadAngle = (arrowhead: Arrowhead): number => {
+export const getArrowheadAngle = (arrowhead: Arrowhead): Degrees => {
   switch (arrowhead) {
     case "bar":
-      return 90;
+      return 90 as Degrees;
     case "arrow":
-      return 20;
+      return 20 as Degrees;
     default:
-      return 25;
+      return 25 as Degrees;
   }
 };
 
@@ -533,19 +588,24 @@ export const getArrowheadPoints = (
   const index = position === "start" ? 1 : ops.length - 1;
 
   const data = ops[index].data;
-  const p3 = [data[4], data[5]] as Point;
-  const p2 = [data[2], data[3]] as Point;
-  const p1 = [data[0], data[1]] as Point;
+
+  invariant(data.length === 6, "Op data length is not 6");
+
+  const p3 = point(data[4], data[5]);
+  const p2 = point(data[2], data[3]);
+  const p1 = point(data[0], data[1]);
 
   // We need to find p0 of the bezier curve.
   // It is typically the last point of the previous
   // curve; it can also be the position of moveTo operation.
   const prevOp = ops[index - 1];
-  let p0: Point = [0, 0];
+  let p0 = point(0, 0);
   if (prevOp.op === "move") {
-    p0 = prevOp.data as unknown as Point;
+    const p = pointFromArray(prevOp.data);
+    invariant(p != null, "Op data is not a point");
+    p0 = p;
   } else if (prevOp.op === "bcurveTo") {
-    p0 = [prevOp.data[4], prevOp.data[5]];
+    p0 = point(prevOp.data[4], prevOp.data[5]);
   }
 
   // B(t) = p0 * (1-t)^3 + 3p1 * t * (1-t)^2 + 3p2 * t^2 * (1-t) + p3 * t^3
@@ -610,8 +670,16 @@ export const getArrowheadPoints = (
   const angle = getArrowheadAngle(arrowhead);
 
   // Return points
-  const [x3, y3] = rotate(xs, ys, x2, y2, (-angle * Math.PI) / 180);
-  const [x4, y4] = rotate(xs, ys, x2, y2, (angle * Math.PI) / 180);
+  const [x3, y3] = pointRotateRads(
+    point(xs, ys),
+    point(x2, y2),
+    ((-angle * Math.PI) / 180) as Radians,
+  );
+  const [x4, y4] = pointRotateRads(
+    point(xs, ys),
+    point(x2, y2),
+    degreesToRadians(angle),
+  );
 
   if (arrowhead === "diamond" || arrowhead === "diamond_outline") {
     // point opposite to the arrowhead point
@@ -621,12 +689,10 @@ export const getArrowheadPoints = (
     if (position === "start") {
       const [px, py] = element.points.length > 1 ? element.points[1] : [0, 0];
 
-      [ox, oy] = rotate(
-        x2 + minSize * 2,
-        y2,
-        x2,
-        y2,
-        Math.atan2(py - y2, px - x2),
+      [ox, oy] = pointRotateRads(
+        point(x2 + minSize * 2, y2),
+        point(x2, y2),
+        Math.atan2(py - y2, px - x2) as Radians,
       );
     } else {
       const [px, py] =
@@ -634,12 +700,10 @@ export const getArrowheadPoints = (
           ? element.points[element.points.length - 2]
           : [0, 0];
 
-      [ox, oy] = rotate(
-        x2 - minSize * 2,
-        y2,
-        x2,
-        y2,
-        Math.atan2(y2 - py, x2 - px),
+      [ox, oy] = pointRotateRads(
+        point(x2 - minSize * 2, y2),
+        point(x2, y2),
+        Math.atan2(y2 - py, x2 - px) as Radians,
       );
     }
 
@@ -665,7 +729,10 @@ const generateLinearElementShape = (
     return "linearPath";
   })();
 
-  return generator[method](element.points as Mutable<Point>[], options);
+  return generator[method](
+    element.points as Mutable<LocalPoint>[] as RoughPoint[],
+    options,
+  );
 };
 
 const getLinearElementRotatedBounds = (
@@ -678,11 +745,9 @@ const getLinearElementRotatedBounds = (
 
   if (element.points.length < 2) {
     const [pointX, pointY] = element.points[0];
-    const [x, y] = rotate(
-      element.x + pointX,
-      element.y + pointY,
-      cx,
-      cy,
+    const [x, y] = pointRotateRads(
+      point(element.x + pointX, element.y + pointY),
+      point(cx, cy),
       element.angle,
     );
 
@@ -708,8 +773,12 @@ const getLinearElementRotatedBounds = (
   const cachedShape = ShapeCache.get(element)?.[0];
   const shape = cachedShape ?? generateLinearElementShape(element);
   const ops = getCurvePathOps(shape);
-  const transformXY = (x: number, y: number) =>
-    rotate(element.x + x, element.y + y, cx, cy, element.angle);
+  const transformXY = ([x, y]: GlobalPoint) =>
+    pointRotateRads<GlobalPoint>(
+      point(element.x + x, element.y + y),
+      point(cx, cy),
+      element.angle,
+    );
   const res = getMinMaxXYFromCurvePathOps(ops, transformXY);
   let coords: Bounds = [res[0], res[1], res[2], res[3]];
   if (boundTextElement) {
@@ -861,7 +930,10 @@ export const getClosestElementBounds = (
   const elementsMap = arrayToMap(elements);
   elements.forEach((element) => {
     const [x1, y1, x2, y2] = getElementBounds(element, elementsMap);
-    const distance = distance2d((x1 + x2) / 2, (y1 + y2) / 2, from.x, from.y);
+    const distance = pointDistance(
+      point((x1 + x2) / 2, (y1 + y2) / 2),
+      point(from.x, from.y),
+    );
 
     if (distance < minDistance) {
       minDistance = distance;
@@ -916,3 +988,9 @@ export const getVisibleSceneBounds = ({
     -scrollY + height / zoom.value,
   ];
 };
+
+export const getCenterForBounds = (bounds: Bounds): GlobalPoint =>
+  point(
+    bounds[0] + (bounds[2] - bounds[0]) / 2,
+    bounds[1] + (bounds[3] - bounds[1]) / 2,
+  );

+ 21 - 19
packages/excalidraw/element/collision.ts

@@ -1,14 +1,11 @@
-import { isPathALoop, isPointWithinBounds } from "../math";
-
 import type {
   ElementsMap,
   ExcalidrawElement,
   ExcalidrawRectangleElement,
 } from "./types";
-
 import { getElementBounds } from "./bounds";
 import type { FrameNameBounds } from "../types";
-import type { Polygon, GeometricShape } from "../../utils/geometry/shape";
+import type { GeometricShape } from "../../utils/geometry/shape";
 import { getPolygonShape } from "../../utils/geometry/shape";
 import { isPointInShape, isPointOnShape } from "../../utils/collision";
 import { isTransparent } from "../utils";
@@ -18,7 +15,9 @@ import {
   isImageElement,
   isTextElement,
 } from "./typeChecks";
-import { getBoundTextShape } from "../shapes";
+import { getBoundTextShape, isPathALoop } from "../shapes";
+import type { GlobalPoint, LocalPoint, Polygon } from "../../math";
+import { isPointWithinBounds, point } from "../../math";
 
 export const shouldTestInside = (element: ExcalidrawElement) => {
   if (element.type === "arrow") {
@@ -42,35 +41,36 @@ export const shouldTestInside = (element: ExcalidrawElement) => {
   return isDraggableFromInside || isImageElement(element);
 };
 
-export type HitTestArgs = {
+export type HitTestArgs<Point extends GlobalPoint | LocalPoint> = {
   x: number;
   y: number;
   element: ExcalidrawElement;
-  shape: GeometricShape;
+  shape: GeometricShape<Point>;
   threshold?: number;
   frameNameBound?: FrameNameBounds | null;
 };
 
-export const hitElementItself = ({
+export const hitElementItself = <Point extends GlobalPoint | LocalPoint>({
   x,
   y,
   element,
   shape,
   threshold = 10,
   frameNameBound = null,
-}: HitTestArgs) => {
+}: HitTestArgs<Point>) => {
   let hit = shouldTestInside(element)
     ? // Since `inShape` tests STRICTLY againt the insides of a shape
       // we would need `onShape` as well to include the "borders"
-      isPointInShape([x, y], shape) || isPointOnShape([x, y], shape, threshold)
-    : isPointOnShape([x, y], shape, threshold);
+      isPointInShape(point(x, y), shape) ||
+      isPointOnShape(point(x, y), shape, threshold)
+    : isPointOnShape(point(x, y), shape, threshold);
 
   // hit test against a frame's name
   if (!hit && frameNameBound) {
-    hit = isPointInShape([x, y], {
+    hit = isPointInShape(point(x, y), {
       type: "polygon",
       data: getPolygonShape(frameNameBound as ExcalidrawRectangleElement)
-        .data as Polygon,
+        .data as Polygon<Point>,
     });
   }
 
@@ -89,11 +89,13 @@ export const hitElementBoundingBox = (
   y1 -= tolerance;
   x2 += tolerance;
   y2 += tolerance;
-  return isPointWithinBounds([x1, y1], [x, y], [x2, y2]);
+  return isPointWithinBounds(point(x1, y1), point(x, y), point(x2, y2));
 };
 
-export const hitElementBoundingBoxOnly = (
-  hitArgs: HitTestArgs,
+export const hitElementBoundingBoxOnly = <
+  Point extends GlobalPoint | LocalPoint,
+>(
+  hitArgs: HitTestArgs<Point>,
   elementsMap: ElementsMap,
 ) => {
   return (
@@ -108,10 +110,10 @@ export const hitElementBoundingBoxOnly = (
   );
 };
 
-export const hitElementBoundText = (
+export const hitElementBoundText = <Point extends GlobalPoint | LocalPoint>(
   x: number,
   y: number,
-  textShape: GeometricShape | null,
+  textShape: GeometricShape<Point> | null,
 ): boolean => {
-  return !!textShape && isPointInShape([x, y], textShape);
+  return !!textShape && isPointInShape(point(x, y), textShape);
 };

+ 1 - 1
packages/excalidraw/element/dragElements.ts

@@ -11,7 +11,6 @@ import type {
   PointerDownState,
 } from "../types";
 import { getBoundTextElement, getMinTextElementWidth } from "./textElement";
-import { getGridPoint } from "../math";
 import type Scene from "../scene/Scene";
 import {
   isArrowElement,
@@ -21,6 +20,7 @@ import {
 } from "./typeChecks";
 import { getFontString } from "../utils";
 import { TEXT_AUTOWRAP_THRESHOLD } from "../constants";
+import { getGridPoint } from "../snapping";
 
 export const dragSelectedElements = (
   pointerDownState: PointerDownState,

+ 8 - 9
packages/excalidraw/element/flowchart.ts

@@ -10,7 +10,6 @@ import {
 import { bindLinearElement } from "./binding";
 import { LinearElementEditor } from "./linearElementEditor";
 import { newArrowElement, newElement } from "./newElement";
-import { aabbForElement } from "../math";
 import type {
   ElementsMap,
   ExcalidrawBindableElement,
@@ -20,7 +19,7 @@ import type {
   OrderedExcalidrawElement,
 } from "./types";
 import { KEYS } from "../keys";
-import type { AppState, PendingExcalidrawElements, Point } from "../types";
+import type { AppState, PendingExcalidrawElements } from "../types";
 import { mutateElement } from "./mutateElement";
 import { elementOverlapsWithFrame, elementsAreInFrameBounds } from "../frame";
 import {
@@ -30,6 +29,8 @@ import {
   isFlowchartNodeElement,
 } from "./typeChecks";
 import { invariant } from "../utils";
+import { point, type LocalPoint } from "../../math";
+import { aabbForElement } from "../shapes";
 
 type LinkDirection = "up" | "right" | "down" | "left";
 
@@ -81,13 +82,14 @@ const getNodeRelatives = (
           "not an ExcalidrawBindableElement",
         );
 
-        const edgePoint: Point =
-          type === "predecessors" ? el.points[el.points.length - 1] : [0, 0];
+        const edgePoint = (
+          type === "predecessors" ? el.points[el.points.length - 1] : [0, 0]
+        ) as Readonly<LocalPoint>;
 
         const heading = headingForPointFromElement(node, aabbForElement(node), [
           edgePoint[0] + el.x,
           edgePoint[1] + el.y,
-        ]);
+        ] as Readonly<LocalPoint>);
 
         acc.push({
           relative,
@@ -419,10 +421,7 @@ const createBindingArrow = (
     strokeColor: appState.currentItemStrokeColor,
     strokeStyle: appState.currentItemStrokeStyle,
     strokeWidth: appState.currentItemStrokeWidth,
-    points: [
-      [0, 0],
-      [endX, endY],
-    ],
+    points: [point(0, 0), point(endX, endY)],
     elbowed: true,
   });
 

+ 78 - 46
packages/excalidraw/element/heading.ts

@@ -1,12 +1,18 @@
-import { lineAngle } from "../../utils/geometry/geometry";
-import type { Point, Vector } from "../../utils/geometry/shape";
+import type {
+  LocalPoint,
+  GlobalPoint,
+  Triangle,
+  Vector,
+  Radians,
+} from "../../math";
 import {
-  getCenterForBounds,
-  PointInTriangle,
-  rotatePoint,
-  scalePointFromOrigin,
-} from "../math";
-import type { Bounds } from "./bounds";
+  point,
+  pointRotateRads,
+  pointScaleFromOrigin,
+  radiansToDegrees,
+  triangleIncludesPoint,
+} from "../../math";
+import { getCenterForBounds, type Bounds } from "./bounds";
 import type { ExcalidrawBindableElement } from "./types";
 
 export const HEADING_RIGHT = [1, 0] as Heading;
@@ -15,8 +21,13 @@ export const HEADING_LEFT = [-1, 0] as Heading;
 export const HEADING_UP = [0, -1] as Heading;
 export type Heading = [1, 0] | [0, 1] | [-1, 0] | [0, -1];
 
-export const headingForDiamond = (a: Point, b: Point) => {
-  const angle = lineAngle([a, b]);
+export const headingForDiamond = <Point extends GlobalPoint | LocalPoint>(
+  a: Point,
+  b: Point,
+) => {
+  const angle = radiansToDegrees(
+    Math.atan2(b[1] - a[1], b[0] - a[0]) as Radians,
+  );
   if (angle >= 315 || angle < 45) {
     return HEADING_UP;
   } else if (angle >= 45 && angle < 135) {
@@ -47,56 +58,58 @@ export const compareHeading = (a: Heading, b: Heading) =>
 // Gets the heading for the point by creating a bounding box around the rotated
 // close fitting bounding box, then creating 4 search cones around the center of
 // the external bbox.
-export const headingForPointFromElement = (
+export const headingForPointFromElement = <
+  Point extends GlobalPoint | LocalPoint,
+>(
   element: Readonly<ExcalidrawBindableElement>,
   aabb: Readonly<Bounds>,
-  point: Readonly<Point>,
+  p: Readonly<LocalPoint | GlobalPoint>,
 ): Heading => {
   const SEARCH_CONE_MULTIPLIER = 2;
 
   const midPoint = getCenterForBounds(aabb);
 
   if (element.type === "diamond") {
-    if (point[0] < element.x) {
+    if (p[0] < element.x) {
       return HEADING_LEFT;
-    } else if (point[1] < element.y) {
+    } else if (p[1] < element.y) {
       return HEADING_UP;
-    } else if (point[0] > element.x + element.width) {
+    } else if (p[0] > element.x + element.width) {
       return HEADING_RIGHT;
-    } else if (point[1] > element.y + element.height) {
+    } else if (p[1] > element.y + element.height) {
       return HEADING_DOWN;
     }
 
-    const top = rotatePoint(
-      scalePointFromOrigin(
-        [element.x + element.width / 2, element.y],
+    const top = pointRotateRads(
+      pointScaleFromOrigin(
+        point(element.x + element.width / 2, element.y),
         midPoint,
         SEARCH_CONE_MULTIPLIER,
       ),
       midPoint,
       element.angle,
     );
-    const right = rotatePoint(
-      scalePointFromOrigin(
-        [element.x + element.width, element.y + element.height / 2],
+    const right = pointRotateRads(
+      pointScaleFromOrigin(
+        point(element.x + element.width, element.y + element.height / 2),
         midPoint,
         SEARCH_CONE_MULTIPLIER,
       ),
       midPoint,
       element.angle,
     );
-    const bottom = rotatePoint(
-      scalePointFromOrigin(
-        [element.x + element.width / 2, element.y + element.height],
+    const bottom = pointRotateRads(
+      pointScaleFromOrigin(
+        point(element.x + element.width / 2, element.y + element.height),
         midPoint,
         SEARCH_CONE_MULTIPLIER,
       ),
       midPoint,
       element.angle,
     );
-    const left = rotatePoint(
-      scalePointFromOrigin(
-        [element.x, element.y + element.height / 2],
+    const left = pointRotateRads(
+      pointScaleFromOrigin(
+        point(element.x, element.y + element.height / 2),
         midPoint,
         SEARCH_CONE_MULTIPLIER,
       ),
@@ -104,43 +117,62 @@ export const headingForPointFromElement = (
       element.angle,
     );
 
-    if (PointInTriangle(point, top, right, midPoint)) {
+    if (triangleIncludesPoint([top, right, midPoint] as Triangle<Point>, p)) {
       return headingForDiamond(top, right);
-    } else if (PointInTriangle(point, right, bottom, midPoint)) {
+    } else if (
+      triangleIncludesPoint([right, bottom, midPoint] as Triangle<Point>, p)
+    ) {
       return headingForDiamond(right, bottom);
-    } else if (PointInTriangle(point, bottom, left, midPoint)) {
+    } else if (
+      triangleIncludesPoint([bottom, left, midPoint] as Triangle<Point>, p)
+    ) {
       return headingForDiamond(bottom, left);
     }
 
     return headingForDiamond(left, top);
   }
 
-  const topLeft = scalePointFromOrigin(
-    [aabb[0], aabb[1]],
+  const topLeft = pointScaleFromOrigin(
+    point(aabb[0], aabb[1]),
     midPoint,
     SEARCH_CONE_MULTIPLIER,
-  );
-  const topRight = scalePointFromOrigin(
-    [aabb[2], aabb[1]],
+  ) as Point;
+  const topRight = pointScaleFromOrigin(
+    point(aabb[2], aabb[1]),
     midPoint,
     SEARCH_CONE_MULTIPLIER,
-  );
-  const bottomLeft = scalePointFromOrigin(
-    [aabb[0], aabb[3]],
+  ) as Point;
+  const bottomLeft = pointScaleFromOrigin(
+    point(aabb[0], aabb[3]),
     midPoint,
     SEARCH_CONE_MULTIPLIER,
-  );
-  const bottomRight = scalePointFromOrigin(
-    [aabb[2], aabb[3]],
+  ) as Point;
+  const bottomRight = pointScaleFromOrigin(
+    point(aabb[2], aabb[3]),
     midPoint,
     SEARCH_CONE_MULTIPLIER,
-  );
+  ) as Point;
 
-  return PointInTriangle(point, topLeft, topRight, midPoint)
+  return triangleIncludesPoint(
+    [topLeft, topRight, midPoint] as Triangle<Point>,
+    p,
+  )
     ? HEADING_UP
-    : PointInTriangle(point, topRight, bottomRight, midPoint)
+    : triangleIncludesPoint(
+        [topRight, bottomRight, midPoint] as Triangle<Point>,
+        p,
+      )
     ? HEADING_RIGHT
-    : PointInTriangle(point, bottomRight, bottomLeft, midPoint)
+    : triangleIncludesPoint(
+        [bottomRight, bottomLeft, midPoint] as Triangle<Point>,
+        p,
+      )
     ? HEADING_DOWN
     : HEADING_LEFT;
 };
+
+export const flipHeading = (h: Heading): Heading =>
+  [
+    h[0] === 0 ? 0 : h[0] > 0 ? -1 : 1,
+    h[1] === 0 ? 0 : h[1] > 0 ? -1 : 1,
+  ] as Heading;

+ 200 - 177
packages/excalidraw/element/linearElementEditor.ts

@@ -11,19 +11,6 @@ import type {
   FixedPointBinding,
   SceneElementsMap,
 } from "./types";
-import {
-  distance2d,
-  rotate,
-  isPathALoop,
-  getGridPoint,
-  rotatePoint,
-  centerPoint,
-  getControlPointsForBezierCurve,
-  getBezierXY,
-  getBezierCurveLength,
-  mapIntervalToBezierT,
-  arePointsEqual,
-} from "../math";
 import { getElementAbsoluteCoords, getLockedLinearCursorAlignSize } from ".";
 import type { Bounds } from "./bounds";
 import {
@@ -32,7 +19,6 @@ import {
   getMinMaxXYFromCurvePathOps,
 } from "./bounds";
 import type {
-  Point,
   AppState,
   PointerCoords,
   InteractiveCanvasAppState,
@@ -46,7 +32,7 @@ import {
   getHoveredElementForBinding,
   isBindingEnabled,
 } from "./binding";
-import { toBrandedType, tupleToCoors } from "../utils";
+import { invariant, toBrandedType, tupleToCoors } from "../utils";
 import {
   isBindingElement,
   isElbowArrow,
@@ -60,10 +46,29 @@ import { ShapeCache } from "../scene/ShapeCache";
 import type { Store } from "../store";
 import { mutateElbowArrow } from "./routing";
 import type Scene from "../scene/Scene";
+import type { Radians } from "../../math";
+import {
+  pointCenter,
+  point,
+  pointRotateRads,
+  pointsEqual,
+  vector,
+  type GlobalPoint,
+  type LocalPoint,
+  pointDistance,
+} from "../../math";
+import {
+  getBezierCurveLength,
+  getBezierXY,
+  getControlPointsForBezierCurve,
+  isPathALoop,
+  mapIntervalToBezierT,
+} from "../shapes";
+import { getGridPoint } from "../snapping";
 
 const editorMidPointsCache: {
   version: number | null;
-  points: (Point | null)[];
+  points: (GlobalPoint | null)[];
   zoom: number | null;
 } = { version: null, points: [], zoom: null };
 export class LinearElementEditor {
@@ -80,7 +85,7 @@ export class LinearElementEditor {
     lastClickedIsEndPoint: boolean;
     origin: Readonly<{ x: number; y: number }> | null;
     segmentMidpoint: {
-      value: Point | null;
+      value: GlobalPoint | null;
       index: number | null;
       added: boolean;
     };
@@ -88,7 +93,7 @@ export class LinearElementEditor {
 
   /** whether you're dragging a point */
   public readonly isDragging: boolean;
-  public readonly lastUncommittedPoint: Point | null;
+  public readonly lastUncommittedPoint: LocalPoint | null;
   public readonly pointerOffset: Readonly<{ x: number; y: number }>;
   public readonly startBindingElement:
     | ExcalidrawBindableElement
@@ -96,13 +101,13 @@ export class LinearElementEditor {
     | "keep";
   public readonly endBindingElement: ExcalidrawBindableElement | null | "keep";
   public readonly hoverPointIndex: number;
-  public readonly segmentMidPointHoveredCoords: Point | null;
+  public readonly segmentMidPointHoveredCoords: GlobalPoint | null;
 
   constructor(element: NonDeleted<ExcalidrawLinearElement>) {
     this.elementId = element.id as string & {
       _brand: "excalidrawLinearElementId";
     };
-    if (!arePointsEqual(element.points[0], [0, 0])) {
+    if (!pointsEqual(element.points[0], point(0, 0))) {
       console.error("Linear element is not normalized", Error().stack);
     }
 
@@ -280,7 +285,7 @@ export class LinearElementEditor {
           element,
           elementsMap,
           referencePoint,
-          [scenePointerX, scenePointerY],
+          point(scenePointerX, scenePointerY),
           event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
         );
 
@@ -289,7 +294,10 @@ export class LinearElementEditor {
           [
             {
               index: selectedIndex,
-              point: [width + referencePoint[0], height + referencePoint[1]],
+              point: point(
+                width + referencePoint[0],
+                height + referencePoint[1],
+              ),
               isDragging: selectedIndex === lastClickedPoint,
             },
           ],
@@ -310,7 +318,7 @@ export class LinearElementEditor {
         LinearElementEditor.movePoints(
           element,
           selectedPointsIndices.map((pointIndex) => {
-            const newPointPosition =
+            const newPointPosition: LocalPoint =
               pointIndex === lastClickedPoint
                 ? LinearElementEditor.createPointAt(
                     element,
@@ -319,10 +327,10 @@ export class LinearElementEditor {
                     scenePointerY - linearElementEditor.pointerOffset.y,
                     event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
                   )
-                : ([
+                : point(
                     element.points[pointIndex][0] + deltaX,
                     element.points[pointIndex][1] + deltaY,
-                  ] as const);
+                  );
             return {
               index: pointIndex,
               point: newPointPosition,
@@ -515,7 +523,7 @@ export class LinearElementEditor {
     );
 
     let index = 0;
-    const midpoints: (Point | null)[] = [];
+    const midpoints: (GlobalPoint | null)[] = [];
     while (index < points.length - 1) {
       if (
         LinearElementEditor.isSegmentTooShort(
@@ -549,7 +557,7 @@ export class LinearElementEditor {
     scenePointer: { x: number; y: number },
     appState: AppState,
     elementsMap: ElementsMap,
-  ) => {
+  ): GlobalPoint | null => {
     const { elementId } = linearElementEditor;
     const element = LinearElementEditor.getElement(elementId, elementsMap);
     if (!element) {
@@ -579,11 +587,12 @@ export class LinearElementEditor {
     const existingSegmentMidpointHitCoords =
       linearElementEditor.segmentMidPointHoveredCoords;
     if (existingSegmentMidpointHitCoords) {
-      const distance = distance2d(
-        existingSegmentMidpointHitCoords[0],
-        existingSegmentMidpointHitCoords[1],
-        scenePointer.x,
-        scenePointer.y,
+      const distance = pointDistance(
+        point(
+          existingSegmentMidpointHitCoords[0],
+          existingSegmentMidpointHitCoords[1],
+        ),
+        point(scenePointer.x, scenePointer.y),
       );
       if (distance <= threshold) {
         return existingSegmentMidpointHitCoords;
@@ -594,11 +603,9 @@ export class LinearElementEditor {
       LinearElementEditor.getEditorMidPoints(element, elementsMap, appState);
     while (index < midPoints.length) {
       if (midPoints[index] !== null) {
-        const distance = distance2d(
-          midPoints[index]![0],
-          midPoints[index]![1],
-          scenePointer.x,
-          scenePointer.y,
+        const distance = pointDistance(
+          point(midPoints[index]![0], midPoints[index]![1]),
+          point(scenePointer.x, scenePointer.y),
         );
         if (distance <= threshold) {
           return midPoints[index];
@@ -612,15 +619,13 @@ export class LinearElementEditor {
 
   static isSegmentTooShort(
     element: NonDeleted<ExcalidrawLinearElement>,
-    startPoint: Point,
-    endPoint: Point,
+    startPoint: GlobalPoint | LocalPoint,
+    endPoint: GlobalPoint | LocalPoint,
     zoom: AppState["zoom"],
   ) {
-    let distance = distance2d(
-      startPoint[0],
-      startPoint[1],
-      endPoint[0],
-      endPoint[1],
+    let distance = pointDistance(
+      point(startPoint[0], startPoint[1]),
+      point(endPoint[0], endPoint[1]),
     );
     if (element.points.length > 2 && element.roundness) {
       distance = getBezierCurveLength(element, endPoint);
@@ -631,12 +636,12 @@ export class LinearElementEditor {
 
   static getSegmentMidPoint(
     element: NonDeleted<ExcalidrawLinearElement>,
-    startPoint: Point,
-    endPoint: Point,
+    startPoint: GlobalPoint,
+    endPoint: GlobalPoint,
     endPointIndex: number,
     elementsMap: ElementsMap,
-  ) {
-    let segmentMidPoint = centerPoint(startPoint, endPoint);
+  ): GlobalPoint {
+    let segmentMidPoint = pointCenter(startPoint, endPoint);
     if (element.points.length > 2 && element.roundness) {
       const controlPoints = getControlPointsForBezierCurve(
         element,
@@ -649,16 +654,15 @@ export class LinearElementEditor {
           0.5,
         );
 
-        const [tx, ty] = getBezierXY(
-          controlPoints[0],
-          controlPoints[1],
-          controlPoints[2],
-          controlPoints[3],
-          t,
-        );
         segmentMidPoint = LinearElementEditor.getPointGlobalCoordinates(
           element,
-          [tx, ty],
+          getBezierXY(
+            controlPoints[0],
+            controlPoints[1],
+            controlPoints[2],
+            controlPoints[3],
+            t,
+          ),
           elementsMap,
         );
       }
@@ -670,7 +674,7 @@ export class LinearElementEditor {
   static getSegmentMidPointIndex(
     linearElementEditor: LinearElementEditor,
     appState: AppState,
-    midPoint: Point,
+    midPoint: GlobalPoint,
     elementsMap: ElementsMap,
   ) {
     const element = LinearElementEditor.getElement(
@@ -822,11 +826,12 @@ export class LinearElementEditor {
     const cy = (y1 + y2) / 2;
     const targetPoint =
       clickedPointIndex > -1 &&
-      rotate(
-        element.x + element.points[clickedPointIndex][0],
-        element.y + element.points[clickedPointIndex][1],
-        cx,
-        cy,
+      pointRotateRads(
+        point(
+          element.x + element.points[clickedPointIndex][0],
+          element.y + element.points[clickedPointIndex][1],
+        ),
+        point(cx, cy),
         element.angle,
       );
 
@@ -865,14 +870,17 @@ export class LinearElementEditor {
     return ret;
   }
 
-  static arePointsEqual(point1: Point | null, point2: Point | null) {
+  static arePointsEqual<Point extends LocalPoint | GlobalPoint>(
+    point1: Point | null,
+    point2: Point | null,
+  ) {
     if (!point1 && !point2) {
       return true;
     }
     if (!point1 || !point2) {
       return false;
     }
-    return arePointsEqual(point1, point2);
+    return pointsEqual(point1, point2);
   }
 
   static handlePointerMove(
@@ -909,7 +917,7 @@ export class LinearElementEditor {
       };
     }
 
-    let newPoint: Point;
+    let newPoint: LocalPoint;
 
     if (shouldRotateWithDiscreteAngle(event) && points.length >= 2) {
       const lastCommittedPoint = points[points.length - 2];
@@ -918,14 +926,14 @@ export class LinearElementEditor {
         element,
         elementsMap,
         lastCommittedPoint,
-        [scenePointerX, scenePointerY],
+        point(scenePointerX, scenePointerY),
         event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
       );
 
-      newPoint = [
+      newPoint = point(
         width + lastCommittedPoint[0],
         height + lastCommittedPoint[1],
-      ];
+      );
     } else {
       newPoint = LinearElementEditor.createPointAt(
         element,
@@ -965,30 +973,36 @@ export class LinearElementEditor {
   /** scene coords */
   static getPointGlobalCoordinates(
     element: NonDeleted<ExcalidrawLinearElement>,
-    point: Point,
+    p: LocalPoint,
     elementsMap: ElementsMap,
-  ) {
+  ): GlobalPoint {
     const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
     const cx = (x1 + x2) / 2;
     const cy = (y1 + y2) / 2;
 
-    let { x, y } = element;
-    [x, y] = rotate(x + point[0], y + point[1], cx, cy, element.angle);
-    return [x, y] as const;
+    const { x, y } = element;
+    return pointRotateRads(
+      point(x + p[0], y + p[1]),
+      point(cx, cy),
+      element.angle,
+    );
   }
 
   /** scene coords */
   static getPointsGlobalCoordinates(
     element: NonDeleted<ExcalidrawLinearElement>,
     elementsMap: ElementsMap,
-  ): Point[] {
+  ): GlobalPoint[] {
     const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
     const cx = (x1 + x2) / 2;
     const cy = (y1 + y2) / 2;
-    return element.points.map((point) => {
-      let { x, y } = element;
-      [x, y] = rotate(x + point[0], y + point[1], cx, cy, element.angle);
-      return [x, y] as const;
+    return element.points.map((p) => {
+      const { x, y } = element;
+      return pointRotateRads(
+        point(x + p[0], y + p[1]),
+        point(cx, cy),
+        element.angle,
+      );
     });
   }
 
@@ -997,7 +1011,7 @@ export class LinearElementEditor {
 
     indexMaybeFromEnd: number, // -1 for last element
     elementsMap: ElementsMap,
-  ): Point {
+  ): GlobalPoint {
     const index =
       indexMaybeFromEnd < 0
         ? element.points.length + indexMaybeFromEnd
@@ -1005,35 +1019,36 @@ export class LinearElementEditor {
     const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
     const cx = (x1 + x2) / 2;
     const cy = (y1 + y2) / 2;
-
-    const point = element.points[index];
+    const p = element.points[index];
     const { x, y } = element;
-    return point
-      ? rotate(x + point[0], y + point[1], cx, cy, element.angle)
-      : rotate(x, y, cx, cy, element.angle);
+
+    return p
+      ? pointRotateRads(point(x + p[0], y + p[1]), point(cx, cy), element.angle)
+      : pointRotateRads(point(x, y), point(cx, cy), element.angle);
   }
 
   static pointFromAbsoluteCoords(
     element: NonDeleted<ExcalidrawLinearElement>,
-    absoluteCoords: Point,
+    absoluteCoords: GlobalPoint,
     elementsMap: ElementsMap,
-  ): Point {
+  ): LocalPoint {
     if (isElbowArrow(element)) {
       // No rotation for elbow arrows
-      return [absoluteCoords[0] - element.x, absoluteCoords[1] - element.y];
+      return point(
+        absoluteCoords[0] - element.x,
+        absoluteCoords[1] - element.y,
+      );
     }
 
     const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
     const cx = (x1 + x2) / 2;
     const cy = (y1 + y2) / 2;
-    const [x, y] = rotate(
-      absoluteCoords[0],
-      absoluteCoords[1],
-      cx,
-      cy,
-      -element.angle,
+    const [x, y] = pointRotateRads(
+      point(absoluteCoords[0], absoluteCoords[1]),
+      point(cx, cy),
+      -element.angle as Radians,
     );
-    return [x - element.x, y - element.y];
+    return point(x - element.x, y - element.y);
   }
 
   static getPointIndexUnderCursor(
@@ -1052,9 +1067,9 @@ export class LinearElementEditor {
     // points on the left, thus should take precedence when clicking, if they
     // overlap
     while (--idx > -1) {
-      const point = pointHandles[idx];
+      const p = pointHandles[idx];
       if (
-        distance2d(x, y, point[0], point[1]) * zoom.value <
+        pointDistance(point(x, y), point(p[0], p[1])) * zoom.value <
         // +1px to account for outline stroke
         LinearElementEditor.POINT_HANDLE_SIZE + 1
       ) {
@@ -1070,20 +1085,18 @@ export class LinearElementEditor {
     scenePointerX: number,
     scenePointerY: number,
     gridSize: NullableGridSize,
-  ): Point {
+  ): LocalPoint {
     const pointerOnGrid = getGridPoint(scenePointerX, scenePointerY, gridSize);
     const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
     const cx = (x1 + x2) / 2;
     const cy = (y1 + y2) / 2;
-    const [rotatedX, rotatedY] = rotate(
-      pointerOnGrid[0],
-      pointerOnGrid[1],
-      cx,
-      cy,
-      -element.angle,
+    const [rotatedX, rotatedY] = pointRotateRads(
+      point(pointerOnGrid[0], pointerOnGrid[1]),
+      point(cx, cy),
+      -element.angle as Radians,
     );
 
-    return [rotatedX - element.x, rotatedY - element.y];
+    return point(rotatedX - element.x, rotatedY - element.y);
   }
 
   /**
@@ -1091,15 +1104,19 @@ export class LinearElementEditor {
    * expected in various parts of the codebase. Also returns new x/y to account
    * for the potential normalization.
    */
-  static getNormalizedPoints(element: ExcalidrawLinearElement) {
+  static getNormalizedPoints(element: ExcalidrawLinearElement): {
+    points: LocalPoint[];
+    x: number;
+    y: number;
+  } {
     const { points } = element;
 
     const offsetX = points[0][0];
     const offsetY = points[0][1];
 
     return {
-      points: points.map((point) => {
-        return [point[0] - offsetX, point[1] - offsetY] as const;
+      points: points.map((p) => {
+        return point(p[0] - offsetX, p[1] - offsetY);
       }),
       x: element.x + offsetX,
       y: element.y + offsetY,
@@ -1116,17 +1133,23 @@ export class LinearElementEditor {
   static duplicateSelectedPoints(
     appState: AppState,
     elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
-  ) {
-    if (!appState.editingLinearElement) {
-      return false;
-    }
+  ): AppState {
+    invariant(
+      appState.editingLinearElement,
+      "Not currently editing a linear element",
+    );
 
     const { selectedPointsIndices, elementId } = appState.editingLinearElement;
     const element = LinearElementEditor.getElement(elementId, elementsMap);
 
-    if (!element || selectedPointsIndices === null) {
-      return false;
-    }
+    invariant(
+      element,
+      "The linear element does not exist in the provided Scene",
+    );
+    invariant(
+      selectedPointsIndices != null,
+      "There are no selected points to duplicate",
+    );
 
     const { points } = element;
 
@@ -1134,9 +1157,9 @@ export class LinearElementEditor {
 
     let pointAddedToEnd = false;
     let indexCursor = -1;
-    const nextPoints = points.reduce((acc: Point[], point, index) => {
+    const nextPoints = points.reduce((acc: LocalPoint[], p, index) => {
       ++indexCursor;
-      acc.push(point);
+      acc.push(p);
 
       const isSelected = selectedPointsIndices.includes(index);
       if (isSelected) {
@@ -1147,8 +1170,8 @@ export class LinearElementEditor {
         }
         acc.push(
           nextPoint
-            ? [(point[0] + nextPoint[0]) / 2, (point[1] + nextPoint[1]) / 2]
-            : [point[0], point[1]],
+            ? point((p[0] + nextPoint[0]) / 2, (p[1] + nextPoint[1]) / 2)
+            : point(p[0], p[1]),
         );
 
         nextSelectedIndices.push(indexCursor + 1);
@@ -1169,7 +1192,7 @@ export class LinearElementEditor {
         [
           {
             index: element.points.length - 1,
-            point: [lastPoint[0] + 30, lastPoint[1] + 30],
+            point: point(lastPoint[0] + 30, lastPoint[1] + 30),
           },
         ],
         elementsMap,
@@ -1177,12 +1200,10 @@ export class LinearElementEditor {
     }
 
     return {
-      appState: {
-        ...appState,
-        editingLinearElement: {
-          ...appState.editingLinearElement,
-          selectedPointsIndices: nextSelectedIndices,
-        },
+      ...appState,
+      editingLinearElement: {
+        ...appState.editingLinearElement,
+        selectedPointsIndices: nextSelectedIndices,
       },
     };
   }
@@ -1209,10 +1230,10 @@ export class LinearElementEditor {
       }
     }
 
-    const nextPoints = element.points.reduce((acc: Point[], point, idx) => {
+    const nextPoints = element.points.reduce((acc: LocalPoint[], p, idx) => {
       if (!pointIndices.includes(idx)) {
         acc.push(
-          !acc.length ? [0, 0] : [point[0] - offsetX, point[1] - offsetY],
+          !acc.length ? point(0, 0) : point(p[0] - offsetX, p[1] - offsetY),
         );
       }
       return acc;
@@ -1229,7 +1250,7 @@ export class LinearElementEditor {
 
   static addPoints(
     element: NonDeleted<ExcalidrawLinearElement>,
-    targetPoints: { point: Point }[],
+    targetPoints: { point: LocalPoint }[],
     elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
   ) {
     const offsetX = 0;
@@ -1247,7 +1268,7 @@ export class LinearElementEditor {
 
   static movePoints(
     element: NonDeleted<ExcalidrawLinearElement>,
-    targetPoints: { index: number; point: Point; isDragging?: boolean }[],
+    targetPoints: { index: number; point: LocalPoint; isDragging?: boolean }[],
     elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
     otherUpdates?: {
       startBinding?: PointBinding | null;
@@ -1277,11 +1298,11 @@ export class LinearElementEditor {
         selectedOriginPoint.point[1] + points[selectedOriginPoint.index][1];
     }
 
-    const nextPoints = points.map((point, idx) => {
-      const selectedPointData = targetPoints.find((p) => p.index === idx);
+    const nextPoints: LocalPoint[] = points.map((p, idx) => {
+      const selectedPointData = targetPoints.find((t) => t.index === idx);
       if (selectedPointData) {
         if (selectedPointData.index === 0) {
-          return point;
+          return p;
         }
 
         const deltaX =
@@ -1289,14 +1310,9 @@ export class LinearElementEditor {
         const deltaY =
           selectedPointData.point[1] - points[selectedPointData.index][1];
 
-        return [
-          point[0] + deltaX - offsetX,
-          point[1] + deltaY - offsetY,
-        ] as const;
+        return point(p[0] + deltaX - offsetX, p[1] + deltaY - offsetY);
       }
-      return offsetX || offsetY
-        ? ([point[0] - offsetX, point[1] - offsetY] as const)
-        : point;
+      return offsetX || offsetY ? point(p[0] - offsetX, p[1] - offsetY) : p;
     });
 
     LinearElementEditor._updatePoints(
@@ -1349,11 +1365,9 @@ export class LinearElementEditor {
     }
 
     const origin = linearElementEditor.pointerDownState.origin!;
-    const dist = distance2d(
-      origin.x,
-      origin.y,
-      pointerCoords.x,
-      pointerCoords.y,
+    const dist = pointDistance(
+      point(origin.x, origin.y),
+      point(pointerCoords.x, pointerCoords.y),
     );
     if (
       !appState.editingLinearElement &&
@@ -1418,7 +1432,7 @@ export class LinearElementEditor {
 
   private static _updatePoints(
     element: NonDeleted<ExcalidrawLinearElement>,
-    nextPoints: readonly Point[],
+    nextPoints: readonly LocalPoint[],
     offsetX: number,
     offsetY: number,
     elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
@@ -1461,7 +1475,7 @@ export class LinearElementEditor {
         element,
         mergedElementsMap,
         nextPoints,
-        [offsetX, offsetY],
+        vector(offsetX, offsetY),
         bindings,
         options,
       );
@@ -1474,7 +1488,11 @@ export class LinearElementEditor {
       const prevCenterY = (prevCoords[1] + prevCoords[3]) / 2;
       const dX = prevCenterX - nextCenterX;
       const dY = prevCenterY - nextCenterY;
-      const rotated = rotate(offsetX, offsetY, dX, dY, element.angle);
+      const rotated = pointRotateRads(
+        point(offsetX, offsetY),
+        point(dX, dY),
+        element.angle,
+      );
       mutateElement(element, {
         ...otherUpdates,
         points: nextPoints,
@@ -1487,8 +1505,8 @@ export class LinearElementEditor {
   private static _getShiftLockedDelta(
     element: NonDeleted<ExcalidrawLinearElement>,
     elementsMap: ElementsMap,
-    referencePoint: Point,
-    scenePointer: Point,
+    referencePoint: LocalPoint,
+    scenePointer: GlobalPoint,
     gridSize: NullableGridSize,
   ) {
     const referencePointCoords = LinearElementEditor.getPointGlobalCoordinates(
@@ -1517,7 +1535,11 @@ export class LinearElementEditor {
       gridY,
     );
 
-    return rotatePoint([width, height], [0, 0], -element.angle);
+    return pointRotateRads(
+      point(width, height),
+      point(0, 0),
+      -element.angle as Radians,
+    );
   }
 
   static getBoundTextElementPosition = (
@@ -1548,7 +1570,7 @@ export class LinearElementEditor {
 
       let midSegmentMidpoint = editorMidPointsCache.points[index];
       if (element.points.length === 2) {
-        midSegmentMidpoint = centerPoint(points[0], points[1]);
+        midSegmentMidpoint = pointCenter(points[0], points[1]);
       }
       if (
         !midSegmentMidpoint ||
@@ -1585,37 +1607,38 @@ export class LinearElementEditor {
       );
     const boundTextX2 = boundTextX1 + boundTextElement.width;
     const boundTextY2 = boundTextY1 + boundTextElement.height;
+    const centerPoint = point(cx, cy);
 
-    const topLeftRotatedPoint = rotatePoint([x1, y1], [cx, cy], element.angle);
-    const topRightRotatedPoint = rotatePoint([x2, y1], [cx, cy], element.angle);
-
-    const counterRotateBoundTextTopLeft = rotatePoint(
-      [boundTextX1, boundTextY1],
-
-      [cx, cy],
-
-      -element.angle,
+    const topLeftRotatedPoint = pointRotateRads(
+      point(x1, y1),
+      centerPoint,
+      element.angle,
     );
-    const counterRotateBoundTextTopRight = rotatePoint(
-      [boundTextX2, boundTextY1],
-
-      [cx, cy],
-
-      -element.angle,
+    const topRightRotatedPoint = pointRotateRads(
+      point(x2, y1),
+      centerPoint,
+      element.angle,
     );
-    const counterRotateBoundTextBottomLeft = rotatePoint(
-      [boundTextX1, boundTextY2],
 
-      [cx, cy],
-
-      -element.angle,
+    const counterRotateBoundTextTopLeft = pointRotateRads(
+      point(boundTextX1, boundTextY1),
+      centerPoint,
+      -element.angle as Radians,
     );
-    const counterRotateBoundTextBottomRight = rotatePoint(
-      [boundTextX2, boundTextY2],
-
-      [cx, cy],
-
-      -element.angle,
+    const counterRotateBoundTextTopRight = pointRotateRads(
+      point(boundTextX2, boundTextY1),
+      centerPoint,
+      -element.angle as Radians,
+    );
+    const counterRotateBoundTextBottomLeft = pointRotateRads(
+      point(boundTextX1, boundTextY2),
+      centerPoint,
+      -element.angle as Radians,
+    );
+    const counterRotateBoundTextBottomRight = pointRotateRads(
+      point(boundTextX2, boundTextY2),
+      centerPoint,
+      -element.angle as Radians,
     );
 
     if (

+ 2 - 3
packages/excalidraw/element/mutateElement.ts

@@ -2,7 +2,6 @@ import type { ExcalidrawElement } from "./types";
 import Scene from "../scene/Scene";
 import { getSizeFromPoints } from "../points";
 import { randomInteger } from "../random";
-import type { Point } from "../types";
 import { getUpdatedTimestamp } from "../utils";
 import type { Mutable } from "../utility-types";
 import { ShapeCache } from "../scene/ShapeCache";
@@ -59,8 +58,8 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
           let didChangePoints = false;
           let index = prevPoints.length;
           while (--index) {
-            const prevPoint: Point = prevPoints[index];
-            const nextPoint: Point = nextPoints[index];
+            const prevPoint = prevPoints[index];
+            const nextPoint = nextPoints[index];
             if (
               prevPoint[0] !== nextPoint[0] ||
               prevPoint[1] !== nextPoint[1]

+ 3 - 4
packages/excalidraw/element/newElement.test.ts

@@ -4,6 +4,8 @@ import { API } from "../tests/helpers/api";
 import { FONT_FAMILY, ROUNDNESS } from "../constants";
 import { isPrimitive } from "../utils";
 import type { ExcalidrawLinearElement } from "./types";
+import type { LocalPoint } from "../../math";
+import { point } from "../../math";
 
 const assertCloneObjects = (source: any, clone: any) => {
   for (const key in clone) {
@@ -36,10 +38,7 @@ describe("duplicating single elements", () => {
     element.__proto__ = { hello: "world" };
 
     mutateElement(element, {
-      points: [
-        [1, 2],
-        [3, 4],
-      ],
+      points: [point<LocalPoint>(1, 2), point<LocalPoint>(3, 4)],
     });
 
     const copy = duplicateElement(null, new Map(), element);

+ 49 - 2
packages/excalidraw/element/newElement.ts

@@ -30,7 +30,6 @@ import { bumpVersion, newElementWith } from "./mutateElement";
 import { getNewGroupIdsForDuplication } from "../groups";
 import type { AppState } from "../types";
 import { getElementAbsoluteCoords } from ".";
-import { adjustXYWithRotation } from "../math";
 import { getResizedElementAbsoluteCoords } from "./bounds";
 import {
   measureText,
@@ -48,6 +47,7 @@ import {
 } from "../constants";
 import type { MarkOptional, Merge, Mutable } from "../utility-types";
 import { getLineHeight } from "../fonts";
+import type { Radians } from "../../math";
 
 export type ElementConstructorOpts = MarkOptional<
   Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">,
@@ -88,7 +88,7 @@ const _newElementBase = <T extends ExcalidrawElement>(
     opacity = DEFAULT_ELEMENT_PROPS.opacity,
     width = 0,
     height = 0,
-    angle = 0,
+    angle = 0 as Radians,
     groupIds = [],
     frameId = null,
     index = null,
@@ -348,6 +348,53 @@ const getAdjustedDimensions = (
   };
 };
 
+const adjustXYWithRotation = (
+  sides: {
+    n?: boolean;
+    e?: boolean;
+    s?: boolean;
+    w?: boolean;
+  },
+  x: number,
+  y: number,
+  angle: number,
+  deltaX1: number,
+  deltaY1: number,
+  deltaX2: number,
+  deltaY2: number,
+): [number, number] => {
+  const cos = Math.cos(angle);
+  const sin = Math.sin(angle);
+  if (sides.e && sides.w) {
+    x += deltaX1 + deltaX2;
+  } else if (sides.e) {
+    x += deltaX1 * (1 + cos);
+    y += deltaX1 * sin;
+    x += deltaX2 * (1 - cos);
+    y += deltaX2 * -sin;
+  } else if (sides.w) {
+    x += deltaX1 * (1 - cos);
+    y += deltaX1 * -sin;
+    x += deltaX2 * (1 + cos);
+    y += deltaX2 * sin;
+  }
+
+  if (sides.n && sides.s) {
+    y += deltaY1 + deltaY2;
+  } else if (sides.n) {
+    x += deltaY1 * sin;
+    y += deltaY1 * (1 - cos);
+    x += deltaY2 * -sin;
+    y += deltaY2 * (1 + cos);
+  } else if (sides.s) {
+    x += deltaY1 * -sin;
+    y += deltaY1 * (1 + cos);
+    x += deltaY2 * sin;
+    y += deltaY2 * (1 - cos);
+  }
+  return [x, y];
+};
+
 export const refreshTextDimensions = (
   textElement: ExcalidrawTextElement,
   container: ExcalidrawTextContainer | null,

+ 127 - 87
packages/excalidraw/element/resizeElements.ts

@@ -1,7 +1,5 @@
 import { MIN_FONT_SIZE, SHIFT_LOCKING_ANGLE } from "../constants";
 import { rescalePoints } from "../points";
-
-import { rotate, centerPoint, rotatePoint } from "../math";
 import type {
   ExcalidrawLinearElement,
   ExcalidrawTextElement,
@@ -38,7 +36,7 @@ import type {
   MaybeTransformHandleType,
   TransformHandleDirection,
 } from "./transformHandles";
-import type { Point, PointerDownState } from "../types";
+import type { PointerDownState } from "../types";
 import Scene from "../scene/Scene";
 import {
   getApproxMinLineWidth,
@@ -55,16 +53,15 @@ import {
 import { LinearElementEditor } from "./linearElementEditor";
 import { isInGroup } from "../groups";
 import { mutateElbowArrow } from "./routing";
-
-export const normalizeAngle = (angle: number): number => {
-  if (angle < 0) {
-    return angle + 2 * Math.PI;
-  }
-  if (angle >= 2 * Math.PI) {
-    return angle - 2 * Math.PI;
-  }
-  return angle;
-};
+import type { GlobalPoint } from "../../math";
+import {
+  pointCenter,
+  normalizeRadians,
+  point,
+  pointFromPair,
+  pointRotateRads,
+  type Radians,
+} from "../../math";
 
 // Returns true when transform (resizing/rotation) happened
 export const transformElements = (
@@ -158,16 +155,17 @@ const rotateSingleElement = (
   const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
   const cx = (x1 + x2) / 2;
   const cy = (y1 + y2) / 2;
-  let angle: number;
+  let angle: Radians;
   if (isFrameLikeElement(element)) {
-    angle = 0;
+    angle = 0 as Radians;
   } else {
-    angle = (5 * Math.PI) / 2 + Math.atan2(pointerY - cy, pointerX - cx);
+    angle = ((5 * Math.PI) / 2 +
+      Math.atan2(pointerY - cy, pointerX - cx)) as Radians;
     if (shouldRotateWithDiscreteAngle) {
-      angle += SHIFT_LOCKING_ANGLE / 2;
-      angle -= angle % SHIFT_LOCKING_ANGLE;
+      angle = (angle + SHIFT_LOCKING_ANGLE / 2) as Radians;
+      angle = (angle - (angle % SHIFT_LOCKING_ANGLE)) as Radians;
     }
-    angle = normalizeAngle(angle);
+    angle = normalizeRadians(angle as Radians);
   }
   const boundTextElementId = getBoundTextElementId(element);
 
@@ -240,12 +238,10 @@ const resizeSingleTextElement = (
     elementsMap,
   );
   // rotation pointer with reverse angle
-  const [rotatedX, rotatedY] = rotate(
-    pointerX,
-    pointerY,
-    cx,
-    cy,
-    -element.angle,
+  const [rotatedX, rotatedY] = pointRotateRads(
+    point(pointerX, pointerY),
+    point(cx, cy),
+    -element.angle as Radians,
   );
   let scaleX = 0;
   let scaleY = 0;
@@ -279,20 +275,26 @@ const resizeSingleTextElement = (
     const startBottomRight = [x2, y2];
     const startCenter = [cx, cy];
 
-    let newTopLeft = [x1, y1] as [number, number];
+    let newTopLeft = point<GlobalPoint>(x1, y1);
     if (["n", "w", "nw"].includes(transformHandleType)) {
-      newTopLeft = [
+      newTopLeft = point<GlobalPoint>(
         startBottomRight[0] - Math.abs(nextWidth),
         startBottomRight[1] - Math.abs(nextHeight),
-      ];
+      );
     }
     if (transformHandleType === "ne") {
       const bottomLeft = [startTopLeft[0], startBottomRight[1]];
-      newTopLeft = [bottomLeft[0], bottomLeft[1] - Math.abs(nextHeight)];
+      newTopLeft = point<GlobalPoint>(
+        bottomLeft[0],
+        bottomLeft[1] - Math.abs(nextHeight),
+      );
     }
     if (transformHandleType === "sw") {
       const topRight = [startBottomRight[0], startTopLeft[1]];
-      newTopLeft = [topRight[0] - Math.abs(nextWidth), topRight[1]];
+      newTopLeft = point<GlobalPoint>(
+        topRight[0] - Math.abs(nextWidth),
+        topRight[1],
+      );
     }
 
     if (["s", "n"].includes(transformHandleType)) {
@@ -308,13 +310,17 @@ const resizeSingleTextElement = (
     }
 
     const angle = element.angle;
-    const rotatedTopLeft = rotatePoint(newTopLeft, [cx, cy], angle);
-    const newCenter: Point = [
+    const rotatedTopLeft = pointRotateRads(newTopLeft, point(cx, cy), angle);
+    const newCenter = point<GlobalPoint>(
       newTopLeft[0] + Math.abs(nextWidth) / 2,
       newTopLeft[1] + Math.abs(nextHeight) / 2,
-    ];
-    const rotatedNewCenter = rotatePoint(newCenter, [cx, cy], angle);
-    newTopLeft = rotatePoint(rotatedTopLeft, rotatedNewCenter, -angle);
+    );
+    const rotatedNewCenter = pointRotateRads(newCenter, point(cx, cy), angle);
+    newTopLeft = pointRotateRads(
+      rotatedTopLeft,
+      rotatedNewCenter,
+      -angle as Radians,
+    );
     const [nextX, nextY] = newTopLeft;
 
     mutateElement(element, {
@@ -334,14 +340,14 @@ const resizeSingleTextElement = (
       stateAtResizeStart.height,
       true,
     );
-    const startTopLeft: Point = [x1, y1];
-    const startBottomRight: Point = [x2, y2];
-    const startCenter: Point = centerPoint(startTopLeft, startBottomRight);
+    const startTopLeft = point<GlobalPoint>(x1, y1);
+    const startBottomRight = point<GlobalPoint>(x2, y2);
+    const startCenter = pointCenter(startTopLeft, startBottomRight);
 
-    const rotatedPointer = rotatePoint(
-      [pointerX, pointerY],
+    const rotatedPointer = pointRotateRads(
+      point(pointerX, pointerY),
       startCenter,
-      -stateAtResizeStart.angle,
+      -stateAtResizeStart.angle as Radians,
     );
 
     const [esx1, , esx2] = getResizedElementAbsoluteCoords(
@@ -407,13 +413,21 @@ const resizeSingleTextElement = (
 
     // adjust topLeft to new rotation point
     const angle = stateAtResizeStart.angle;
-    const rotatedTopLeft = rotatePoint(newTopLeft, startCenter, angle);
-    const newCenter: Point = [
+    const rotatedTopLeft = pointRotateRads(
+      pointFromPair(newTopLeft),
+      startCenter,
+      angle,
+    );
+    const newCenter = point(
       newTopLeft[0] + Math.abs(newBoundsWidth) / 2,
       newTopLeft[1] + Math.abs(newBoundsHeight) / 2,
-    ];
-    const rotatedNewCenter = rotatePoint(newCenter, startCenter, angle);
-    newTopLeft = rotatePoint(rotatedTopLeft, rotatedNewCenter, -angle);
+    );
+    const rotatedNewCenter = pointRotateRads(newCenter, startCenter, angle);
+    newTopLeft = pointRotateRads(
+      rotatedTopLeft,
+      rotatedNewCenter,
+      -angle as Radians,
+    );
 
     const resizedElement: Partial<ExcalidrawTextElement> = {
       width: Math.abs(newWidth),
@@ -446,15 +460,15 @@ export const resizeSingleElement = (
     stateAtResizeStart.height,
     true,
   );
-  const startTopLeft: Point = [x1, y1];
-  const startBottomRight: Point = [x2, y2];
-  const startCenter: Point = centerPoint(startTopLeft, startBottomRight);
+  const startTopLeft = point(x1, y1);
+  const startBottomRight = point(x2, y2);
+  const startCenter = pointCenter(startTopLeft, startBottomRight);
 
   // Calculate new dimensions based on cursor position
-  const rotatedPointer = rotatePoint(
-    [pointerX, pointerY],
+  const rotatedPointer = pointRotateRads(
+    point(pointerX, pointerY),
     startCenter,
-    -stateAtResizeStart.angle,
+    -stateAtResizeStart.angle as Radians,
   );
 
   // Get bounds corners rendered on screen
@@ -628,13 +642,21 @@ export const resizeSingleElement = (
 
   // adjust topLeft to new rotation point
   const angle = stateAtResizeStart.angle;
-  const rotatedTopLeft = rotatePoint(newTopLeft, startCenter, angle);
-  const newCenter: Point = [
+  const rotatedTopLeft = pointRotateRads(
+    pointFromPair(newTopLeft),
+    startCenter,
+    angle,
+  );
+  const newCenter = point(
     newTopLeft[0] + Math.abs(newBoundsWidth) / 2,
     newTopLeft[1] + Math.abs(newBoundsHeight) / 2,
-  ];
-  const rotatedNewCenter = rotatePoint(newCenter, startCenter, angle);
-  newTopLeft = rotatePoint(rotatedTopLeft, rotatedNewCenter, -angle);
+  );
+  const rotatedNewCenter = pointRotateRads(newCenter, startCenter, angle);
+  newTopLeft = pointRotateRads(
+    rotatedTopLeft,
+    rotatedNewCenter,
+    -angle as Radians,
+  );
 
   // For linear elements (x,y) are the coordinates of the first drawn point not the top-left corner
   // So we need to readjust (x,y) to be where the first point should be
@@ -793,21 +815,21 @@ export const resizeMultipleElements = (
 
   const direction = transformHandleType;
 
-  const anchorsMap: Record<TransformHandleDirection, Point> = {
-    ne: [minX, maxY],
-    se: [minX, minY],
-    sw: [maxX, minY],
-    nw: [maxX, maxY],
-    e: [minX, minY + height / 2],
-    w: [maxX, minY + height / 2],
-    n: [minX + width / 2, maxY],
-    s: [minX + width / 2, minY],
+  const anchorsMap: Record<TransformHandleDirection, GlobalPoint> = {
+    ne: point(minX, maxY),
+    se: point(minX, minY),
+    sw: point(maxX, minY),
+    nw: point(maxX, maxY),
+    e: point(minX, minY + height / 2),
+    w: point(maxX, minY + height / 2),
+    n: point(minX + width / 2, maxY),
+    s: point(minX + width / 2, minY),
   };
 
   // anchor point must be on the opposite side of the dragged selection handle
   // or be the center of the selection if shouldResizeFromCenter
-  const [anchorX, anchorY]: Point = shouldResizeFromCenter
-    ? [midX, midY]
+  const [anchorX, anchorY] = shouldResizeFromCenter
+    ? point(midX, midY)
     : anchorsMap[direction];
 
   const resizeFromCenterScale = shouldResizeFromCenter ? 2 : 1;
@@ -898,7 +920,9 @@ export const resizeMultipleElements = (
 
     const width = orig.width * scaleX;
     const height = orig.height * scaleY;
-    const angle = normalizeAngle(orig.angle * flipFactorX * flipFactorY);
+    const angle = normalizeRadians(
+      (orig.angle * flipFactorX * flipFactorY) as Radians,
+    );
 
     const isLinearOrFreeDraw = isLinearElement(orig) || isFreeDrawElement(orig);
     const offsetX = orig.x - anchorX;
@@ -1029,12 +1053,10 @@ const rotateMultipleElements = (
       const cy = (y1 + y2) / 2;
       const origAngle =
         originalElements.get(element.id)?.angle ?? element.angle;
-      const [rotatedCX, rotatedCY] = rotate(
-        cx,
-        cy,
-        centerX,
-        centerY,
-        centerAngle + origAngle - element.angle,
+      const [rotatedCX, rotatedCY] = pointRotateRads(
+        point(cx, cy),
+        point(centerX, centerY),
+        (centerAngle + origAngle - element.angle) as Radians,
       );
 
       if (isArrowElement(element) && isElbowArrow(element)) {
@@ -1046,7 +1068,7 @@ const rotateMultipleElements = (
           {
             x: element.x + (rotatedCX - cx),
             y: element.y + (rotatedCY - cy),
-            angle: normalizeAngle(centerAngle + origAngle),
+            angle: normalizeRadians((centerAngle + origAngle) as Radians),
           },
           false,
         );
@@ -1063,7 +1085,7 @@ const rotateMultipleElements = (
           {
             x: boundText.x + (rotatedCX - cx),
             y: boundText.y + (rotatedCY - cy),
-            angle: normalizeAngle(centerAngle + origAngle),
+            angle: normalizeRadians((centerAngle + origAngle) as Radians),
           },
           false,
         );
@@ -1086,25 +1108,43 @@ export const getResizeOffsetXY = (
       : getCommonBounds(selectedElements);
   const cx = (x1 + x2) / 2;
   const cy = (y1 + y2) / 2;
-  const angle = selectedElements.length === 1 ? selectedElements[0].angle : 0;
-  [x, y] = rotate(x, y, cx, cy, -angle);
+  const angle = (
+    selectedElements.length === 1 ? selectedElements[0].angle : 0
+  ) as Radians;
+  [x, y] = pointRotateRads(point(x, y), point(cx, cy), -angle as Radians);
   switch (transformHandleType) {
     case "n":
-      return rotate(x - (x1 + x2) / 2, y - y1, 0, 0, angle);
+      return pointRotateRads(
+        point(x - (x1 + x2) / 2, y - y1),
+        point(0, 0),
+        angle,
+      );
     case "s":
-      return rotate(x - (x1 + x2) / 2, y - y2, 0, 0, angle);
+      return pointRotateRads(
+        point(x - (x1 + x2) / 2, y - y2),
+        point(0, 0),
+        angle,
+      );
     case "w":
-      return rotate(x - x1, y - (y1 + y2) / 2, 0, 0, angle);
+      return pointRotateRads(
+        point(x - x1, y - (y1 + y2) / 2),
+        point(0, 0),
+        angle,
+      );
     case "e":
-      return rotate(x - x2, y - (y1 + y2) / 2, 0, 0, angle);
+      return pointRotateRads(
+        point(x - x2, y - (y1 + y2) / 2),
+        point(0, 0),
+        angle,
+      );
     case "nw":
-      return rotate(x - x1, y - y1, 0, 0, angle);
+      return pointRotateRads(point(x - x1, y - y1), point(0, 0), angle);
     case "ne":
-      return rotate(x - x2, y - y1, 0, 0, angle);
+      return pointRotateRads(point(x - x2, y - y1), point(0, 0), angle);
     case "sw":
-      return rotate(x - x1, y - y2, 0, 0, angle);
+      return pointRotateRads(point(x - x1, y - y2), point(0, 0), angle);
     case "se":
-      return rotate(x - x2, y - y2, 0, 0, angle);
+      return pointRotateRads(point(x - x2, y - y2), point(0, 0), angle);
     default:
       return [0, 0];
   }

+ 36 - 25
packages/excalidraw/element/resizeTest.ts

@@ -20,13 +20,14 @@ import type { AppState, Device, Zoom } from "../types";
 import type { Bounds } from "./bounds";
 import { getElementAbsoluteCoords } from "./bounds";
 import { SIDE_RESIZING_THRESHOLD } from "../constants";
-import {
-  angleToDegrees,
-  pointOnLine,
-  pointRotate,
-} from "../../utils/geometry/geometry";
-import type { Line, Point } from "../../utils/geometry/shape";
 import { isLinearElement } from "./typeChecks";
+import type { GlobalPoint, LineSegment, LocalPoint } from "../../math";
+import {
+  point,
+  pointOnLineSegment,
+  pointRotateRads,
+  type Radians,
+} from "../../math";
 
 const isInsideTransformHandle = (
   transformHandle: TransformHandle,
@@ -38,7 +39,7 @@ const isInsideTransformHandle = (
   y >= transformHandle[1] &&
   y <= transformHandle[1] + transformHandle[3];
 
-export const resizeTest = (
+export const resizeTest = <Point extends GlobalPoint | LocalPoint>(
   element: NonDeletedExcalidrawElement,
   elementsMap: ElementsMap,
   appState: AppState,
@@ -91,15 +92,17 @@ export const resizeTest = (
     if (!(isLinearElement(element) && element.points.length <= 2)) {
       const SPACING = SIDE_RESIZING_THRESHOLD / zoom.value;
       const sides = getSelectionBorders(
-        [x1 - SPACING, y1 - SPACING],
-        [x2 + SPACING, y2 + SPACING],
-        [cx, cy],
-        angleToDegrees(element.angle),
+        point(x1 - SPACING, y1 - SPACING),
+        point(x2 + SPACING, y2 + SPACING),
+        point(cx, cy),
+        element.angle,
       );
 
       for (const [dir, side] of Object.entries(sides)) {
         // test to see if x, y are on the line segment
-        if (pointOnLine([x, y], side as Line, SPACING)) {
+        if (
+          pointOnLineSegment(point(x, y), side as LineSegment<Point>, SPACING)
+        ) {
           return dir as TransformHandleType;
         }
       }
@@ -137,7 +140,9 @@ export const getElementWithTransformHandleType = (
   }, null as { element: NonDeletedExcalidrawElement; transformHandleType: MaybeTransformHandleType } | null);
 };
 
-export const getTransformHandleTypeFromCoords = (
+export const getTransformHandleTypeFromCoords = <
+  Point extends GlobalPoint | LocalPoint,
+>(
   [x1, y1, x2, y2]: Bounds,
   scenePointerX: number,
   scenePointerY: number,
@@ -147,7 +152,7 @@ export const getTransformHandleTypeFromCoords = (
 ): MaybeTransformHandleType => {
   const transformHandles = getTransformHandlesFromCoords(
     [x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2],
-    0,
+    0 as Radians,
     zoom,
     pointerType,
     getOmitSidesForDevice(device),
@@ -173,15 +178,21 @@ export const getTransformHandleTypeFromCoords = (
     const SPACING = SIDE_RESIZING_THRESHOLD / zoom.value;
 
     const sides = getSelectionBorders(
-      [x1 - SPACING, y1 - SPACING],
-      [x2 + SPACING, y2 + SPACING],
-      [cx, cy],
-      angleToDegrees(0),
+      point(x1 - SPACING, y1 - SPACING),
+      point(x2 + SPACING, y2 + SPACING),
+      point(cx, cy),
+      0 as Radians,
     );
 
     for (const [dir, side] of Object.entries(sides)) {
       // test to see if x, y are on the line segment
-      if (pointOnLine([scenePointerX, scenePointerY], side as Line, SPACING)) {
+      if (
+        pointOnLineSegment(
+          point(scenePointerX, scenePointerY),
+          side as LineSegment<Point>,
+          SPACING,
+        )
+      ) {
         return dir as TransformHandleType;
       }
     }
@@ -248,16 +259,16 @@ export const getCursorForResizingElement = (resizingElement: {
   return cursor ? `${cursor}-resize` : "";
 };
 
-const getSelectionBorders = (
+const getSelectionBorders = <Point extends LocalPoint | GlobalPoint>(
   [x1, y1]: Point,
   [x2, y2]: Point,
   center: Point,
-  angleInDegrees: number,
+  angle: Radians,
 ) => {
-  const topLeft = pointRotate([x1, y1], angleInDegrees, center);
-  const topRight = pointRotate([x2, y1], angleInDegrees, center);
-  const bottomLeft = pointRotate([x1, y2], angleInDegrees, center);
-  const bottomRight = pointRotate([x2, y2], angleInDegrees, center);
+  const topLeft = pointRotateRads(point(x1, y1), center, angle);
+  const topRight = pointRotateRads(point(x2, y1), center, angle);
+  const bottomLeft = pointRotateRads(point(x1, y2), center, angle);
+  const bottomRight = pointRotateRads(point(x2, y2), center, angle);
 
   return {
     n: [topLeft, topRight],

+ 5 - 10
packages/excalidraw/element/routing.test.tsx

@@ -17,6 +17,7 @@ import type {
   ExcalidrawElbowArrowElement,
 } from "./types";
 import { ARROW_TYPE } from "../constants";
+import { point } from "../../math";
 
 const { h } = window;
 
@@ -31,8 +32,8 @@ describe("elbow arrow routing", () => {
     }) as ExcalidrawElbowArrowElement;
     scene.insertElement(arrow);
     mutateElbowArrow(arrow, scene.getNonDeletedElementsMap(), [
-      [-45 - arrow.x, -100.1 - arrow.y],
-      [45 - arrow.x, 99.9 - arrow.y],
+      point(-45 - arrow.x, -100.1 - arrow.y),
+      point(45 - arrow.x, 99.9 - arrow.y),
     ]);
     expect(arrow.points).toEqual([
       [0, 0],
@@ -68,10 +69,7 @@ describe("elbow arrow routing", () => {
       y: -100.1,
       width: 90,
       height: 200,
-      points: [
-        [0, 0],
-        [90, 200],
-      ],
+      points: [point(0, 0), point(90, 200)],
     }) as ExcalidrawElbowArrowElement;
     scene.insertElement(rectangle1);
     scene.insertElement(rectangle2);
@@ -83,10 +81,7 @@ describe("elbow arrow routing", () => {
     expect(arrow.startBinding).not.toBe(null);
     expect(arrow.endBinding).not.toBe(null);
 
-    mutateElbowArrow(arrow, elementsMap, [
-      [0, 0],
-      [90, 200],
-    ]);
+    mutateElbowArrow(arrow, elementsMap, [point(0, 0), point(90, 200)]);
 
     expect(arrow.points).toEqual([
       [0, 0],

+ 124 - 75
packages/excalidraw/element/routing.ts

@@ -1,16 +1,19 @@
-import { cross } from "../../utils/geometry/geometry";
-import BinaryHeap from "../binaryheap";
+import type { Radians } from "../../math";
 import {
-  aabbForElement,
-  arePointsEqual,
-  pointInsideBounds,
-  pointToVector,
-  scalePointFromOrigin,
-  scaleVector,
-  translatePoint,
-} from "../math";
+  point,
+  pointScaleFromOrigin,
+  pointTranslate,
+  vector,
+  vectorCross,
+  vectorFromPoint,
+  vectorScale,
+  type GlobalPoint,
+  type LocalPoint,
+  type Vector,
+} from "../../math";
+import BinaryHeap from "../binaryheap";
 import { getSizeFromPoints } from "../points";
-import type { Point } from "../types";
+import { aabbForElement, pointInsideBounds } from "../shapes";
 import { isAnyTrue, toBrandedType, tupleToCoors } from "../utils";
 import {
   bindPointToSnapToElementOutline,
@@ -25,6 +28,8 @@ import {
 import type { Bounds } from "./bounds";
 import type { Heading } from "./heading";
 import {
+  compareHeading,
+  flipHeading,
   HEADING_DOWN,
   HEADING_LEFT,
   HEADING_RIGHT,
@@ -41,6 +46,8 @@ import type {
 } from "./types";
 import type { ElementsMap, ExcalidrawBindableElement } from "./types";
 
+type GridAddress = [number, number] & { _brand: "gridaddress" };
+
 type Node = {
   f: number;
   g: number;
@@ -48,8 +55,8 @@ type Node = {
   closed: boolean;
   visited: boolean;
   parent: Node | null;
-  pos: Point;
-  addr: [number, number];
+  pos: GlobalPoint;
+  addr: GridAddress;
 };
 
 type Grid = {
@@ -63,8 +70,8 @@ const BASE_PADDING = 40;
 export const mutateElbowArrow = (
   arrow: ExcalidrawElbowArrowElement,
   elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
-  nextPoints: readonly Point[],
-  offset?: Point,
+  nextPoints: readonly LocalPoint[],
+  offset?: Vector,
   otherUpdates?: {
     startBinding?: FixedPointBinding | null;
     endBinding?: FixedPointBinding | null;
@@ -75,14 +82,20 @@ export const mutateElbowArrow = (
     informMutation?: boolean;
   },
 ) => {
-  const origStartGlobalPoint = translatePoint(nextPoints[0], [
-    arrow.x + (offset ? offset[0] : 0),
-    arrow.y + (offset ? offset[1] : 0),
-  ]);
-  const origEndGlobalPoint = translatePoint(nextPoints[nextPoints.length - 1], [
-    arrow.x + (offset ? offset[0] : 0),
-    arrow.y + (offset ? offset[1] : 0),
-  ]);
+  const origStartGlobalPoint: GlobalPoint = pointTranslate(
+    pointTranslate<LocalPoint, GlobalPoint>(
+      nextPoints[0],
+      vector(arrow.x, arrow.y),
+    ),
+    offset,
+  );
+  const origEndGlobalPoint: GlobalPoint = pointTranslate(
+    pointTranslate<LocalPoint, GlobalPoint>(
+      nextPoints[nextPoints.length - 1],
+      vector(arrow.x, arrow.y),
+    ),
+    offset,
+  );
 
   const startElement =
     arrow.startBinding &&
@@ -275,7 +288,10 @@ export const mutateElbowArrow = (
   );
 
   if (path) {
-    const points = path.map((node) => [node.pos[0], node.pos[1]]) as Point[];
+    const points = path.map((node) => [
+      node.pos[0],
+      node.pos[1],
+    ]) as GlobalPoint[];
     startDongle && points.unshift(startGlobalPoint);
     endDongle && points.push(endGlobalPoint);
 
@@ -284,7 +300,7 @@ export const mutateElbowArrow = (
       {
         ...otherUpdates,
         ...normalizedArrowElementUpdate(simplifyElbowArrowPoints(points), 0, 0),
-        angle: 0,
+        angle: 0 as Radians,
       },
       options?.informMutation,
     );
@@ -363,7 +379,7 @@ const astar = (
       }
 
       // Intersect
-      const neighborHalfPoint = scalePointFromOrigin(
+      const neighborHalfPoint = pointScaleFromOrigin(
         neighbor.pos,
         current.pos,
         0.5,
@@ -380,17 +396,17 @@ const astar = (
       // We need to check if the path we have arrived at this neighbor is the shortest one we have seen yet.
       const neighborHeading = neighborIndexToHeading(i as 0 | 1 | 2 | 3);
       const previousDirection = current.parent
-        ? vectorToHeading(pointToVector(current.pos, current.parent.pos))
+        ? vectorToHeading(vectorFromPoint(current.pos, current.parent.pos))
         : startHeading;
 
       // Do not allow going in reverse
-      const reverseHeading = scaleVector(previousDirection, -1);
+      const reverseHeading = flipHeading(previousDirection);
       const neighborIsReverseRoute =
-        arePointsEqual(reverseHeading, neighborHeading) ||
-        (arePointsEqual(start.addr, neighbor.addr) &&
-          arePointsEqual(neighborHeading, startHeading)) ||
-        (arePointsEqual(end.addr, neighbor.addr) &&
-          arePointsEqual(neighborHeading, endHeading));
+        compareHeading(reverseHeading, neighborHeading) ||
+        (gridAddressesEqual(start.addr, neighbor.addr) &&
+          compareHeading(neighborHeading, startHeading)) ||
+        (gridAddressesEqual(end.addr, neighbor.addr) &&
+          compareHeading(neighborHeading, endHeading));
       if (neighborIsReverseRoute) {
         continue;
       }
@@ -444,7 +460,7 @@ const pathTo = (start: Node, node: Node) => {
   return path;
 };
 
-const m_dist = (a: Point, b: Point) =>
+const m_dist = (a: GlobalPoint | LocalPoint, b: GlobalPoint | LocalPoint) =>
   Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]);
 
 /**
@@ -541,7 +557,12 @@ const generateDynamicAABBs = (
       const cX = first[2] + (second[0] - first[2]) / 2;
       const cY = second[3] + (first[1] - second[3]) / 2;
 
-      if (cross([a[2], a[1]], [a[0], a[3]], [endCenterX, endCenterY]) > 0) {
+      if (
+        vectorCross(
+          vector(a[2] - endCenterX, a[1] - endCenterY),
+          vector(a[0] - endCenterX, a[3] - endCenterY),
+        ) > 0
+      ) {
         return [
           [first[0], first[1], cX, first[3]],
           [cX, second[1], second[2], second[3]],
@@ -557,7 +578,12 @@ const generateDynamicAABBs = (
       const cX = first[2] + (second[0] - first[2]) / 2;
       const cY = first[3] + (second[1] - first[3]) / 2;
 
-      if (cross([a[0], a[1]], [a[2], a[3]], [endCenterX, endCenterY]) > 0) {
+      if (
+        vectorCross(
+          vector(a[0] - endCenterX, a[1] - endCenterY),
+          vector(a[2] - endCenterX, a[3] - endCenterY),
+        ) > 0
+      ) {
         return [
           [first[0], first[1], first[2], cY],
           [second[0], cY, second[2], second[3]],
@@ -573,7 +599,12 @@ const generateDynamicAABBs = (
       const cX = second[2] + (first[0] - second[2]) / 2;
       const cY = first[3] + (second[1] - first[3]) / 2;
 
-      if (cross([a[2], a[1]], [a[0], a[3]], [endCenterX, endCenterY]) > 0) {
+      if (
+        vectorCross(
+          vector(a[2] - endCenterX, a[1] - endCenterY),
+          vector(a[0] - endCenterX, a[3] - endCenterY),
+        ) > 0
+      ) {
         return [
           [cX, first[1], first[2], first[3]],
           [second[0], second[1], cX, second[3]],
@@ -589,7 +620,12 @@ const generateDynamicAABBs = (
       const cX = second[2] + (first[0] - second[2]) / 2;
       const cY = second[3] + (first[1] - second[3]) / 2;
 
-      if (cross([a[0], a[1]], [a[2], a[3]], [endCenterX, endCenterY]) > 0) {
+      if (
+        vectorCross(
+          vector(a[0] - endCenterX, a[1] - endCenterY),
+          vector(a[2] - endCenterX, a[3] - endCenterY),
+        ) > 0
+      ) {
         return [
           [cX, first[1], first[2], first[3]],
           [second[0], second[1], cX, second[3]],
@@ -615,9 +651,9 @@ const generateDynamicAABBs = (
  */
 const calculateGrid = (
   aabbs: Bounds[],
-  start: Point,
+  start: GlobalPoint,
   startHeading: Heading,
-  end: Point,
+  end: GlobalPoint,
   endHeading: Heading,
   common: Bounds,
 ): Grid => {
@@ -662,8 +698,8 @@ const calculateGrid = (
           closed: false,
           visited: false,
           parent: null,
-          addr: [col, row] as [number, number],
-          pos: [x, y] as Point,
+          addr: [col, row] as GridAddress,
+          pos: [x, y] as GlobalPoint,
         }),
       ),
     ),
@@ -673,17 +709,17 @@ const calculateGrid = (
 const getDonglePosition = (
   bounds: Bounds,
   heading: Heading,
-  point: Point,
-): Point => {
+  p: GlobalPoint,
+): GlobalPoint => {
   switch (heading) {
     case HEADING_UP:
-      return [point[0], bounds[1]];
+      return point(p[0], bounds[1]);
     case HEADING_RIGHT:
-      return [bounds[2], point[1]];
+      return point(bounds[2], p[1]);
     case HEADING_DOWN:
-      return [point[0], bounds[3]];
+      return point(p[0], bounds[3]);
   }
-  return [bounds[0], point[1]];
+  return point(bounds[0], p[1]);
 };
 
 const estimateSegmentCount = (
@@ -826,7 +862,7 @@ const gridNodeFromAddr = (
 /**
  * Get node for global point on canvas (if exists)
  */
-const pointToGridNode = (point: Point, grid: Grid): Node | null => {
+const pointToGridNode = (point: GlobalPoint, grid: Grid): Node | null => {
   for (let col = 0; col < grid.col; col++) {
     for (let row = 0; row < grid.row; row++) {
       const candidate = gridNodeFromAddr([col, row], grid);
@@ -865,15 +901,24 @@ const getBindableElementForId = (
 };
 
 const normalizedArrowElementUpdate = (
-  global: Point[],
+  global: GlobalPoint[],
   externalOffsetX?: number,
   externalOffsetY?: number,
-) => {
+): {
+  points: LocalPoint[];
+  x: number;
+  y: number;
+  width: number;
+  height: number;
+} => {
   const offsetX = global[0][0];
   const offsetY = global[0][1];
 
-  const points = global.map(
-    (point) => [point[0] - offsetX, point[1] - offsetY] as const,
+  const points = global.map((p) =>
+    pointTranslate<GlobalPoint, LocalPoint>(
+      p,
+      vectorScale(vectorFromPoint(global[0]), -1),
+    ),
   );
 
   return {
@@ -885,19 +930,22 @@ const normalizedArrowElementUpdate = (
 };
 
 /// If last and current segments have the same heading, skip the middle point
-const simplifyElbowArrowPoints = (points: Point[]): Point[] =>
+const simplifyElbowArrowPoints = (points: GlobalPoint[]): GlobalPoint[] =>
   points
     .slice(2)
     .reduce(
-      (result, point) =>
-        arePointsEqual(
+      (result, p) =>
+        compareHeading(
           vectorToHeading(
-            pointToVector(result[result.length - 1], result[result.length - 2]),
+            vectorFromPoint(
+              result[result.length - 1],
+              result[result.length - 2],
+            ),
           ),
-          vectorToHeading(pointToVector(point, result[result.length - 1])),
+          vectorToHeading(vectorFromPoint(p, result[result.length - 1])),
         )
-          ? [...result.slice(0, -1), point]
-          : [...result, point],
+          ? [...result.slice(0, -1), p]
+          : [...result, p],
       [points[0] ?? [0, 0], points[1] ?? [1, 0]],
     );
 
@@ -915,13 +963,13 @@ const neighborIndexToHeading = (idx: number): Heading => {
 
 const getGlobalPoint = (
   fixedPointRatio: [number, number] | undefined | null,
-  initialPoint: Point,
-  otherPoint: Point,
+  initialPoint: GlobalPoint,
+  otherPoint: GlobalPoint,
   elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
   boundElement?: ExcalidrawBindableElement | null,
   hoveredElement?: ExcalidrawBindableElement | null,
   isDragging?: boolean,
-): Point => {
+): GlobalPoint => {
   if (isDragging) {
     if (hoveredElement) {
       const snapPoint = getSnapPoint(
@@ -956,36 +1004,34 @@ const getGlobalPoint = (
 };
 
 const getSnapPoint = (
-  point: Point,
-  otherPoint: Point,
+  p: GlobalPoint,
+  otherPoint: GlobalPoint,
   element: ExcalidrawBindableElement,
   elementsMap: ElementsMap,
 ) =>
   bindPointToSnapToElementOutline(
-    isRectanguloidElement(element)
-      ? avoidRectangularCorner(element, point)
-      : point,
+    isRectanguloidElement(element) ? avoidRectangularCorner(element, p) : p,
     otherPoint,
     element,
     elementsMap,
   );
 
 const getBindPointHeading = (
-  point: Point,
-  otherPoint: Point,
+  p: GlobalPoint,
+  otherPoint: GlobalPoint,
   elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
   hoveredElement: ExcalidrawBindableElement | null | undefined,
-  origPoint: Point,
+  origPoint: GlobalPoint,
 ) =>
   getHeadingForElbowArrowSnap(
-    point,
+    p,
     otherPoint,
     hoveredElement,
     hoveredElement &&
       aabbForElement(
         hoveredElement,
         Array(4).fill(
-          distanceToBindableElement(hoveredElement, point, elementsMap),
+          distanceToBindableElement(hoveredElement, p, elementsMap),
         ) as [number, number, number, number],
       ),
     elementsMap,
@@ -993,8 +1039,8 @@ const getBindPointHeading = (
   );
 
 const getHoveredElements = (
-  origStartGlobalPoint: Point,
-  origEndGlobalPoint: Point,
+  origStartGlobalPoint: GlobalPoint,
+  origEndGlobalPoint: GlobalPoint,
   elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
 ) => {
   // TODO: Might be a performance bottleneck and the Map type
@@ -1018,3 +1064,6 @@ const getHoveredElements = (
     ),
   ];
 };
+
+const gridAddressesEqual = (a: GridAddress, b: GridAddress): boolean =>
+  a[0] === b[0] && a[1] === b[1];

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

@@ -19,6 +19,7 @@ import type {
 import { API } from "../tests/helpers/api";
 import { getOriginalContainerHeightFromCache } from "./containerCache";
 import { getTextEditor, updateTextEditor } from "../tests/queries/dom";
+import { point } from "../../math";
 
 // Unmount ReactDOM from root
 ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
@@ -41,10 +42,7 @@ describe("textWysiwyg", () => {
         type: "line",
         width: 100,
         height: 0,
-        points: [
-          [0, 0],
-          [100, 0],
-        ],
+        points: [point(0, 0), point(100, 0)],
       });
       const textSize = 20;
       const text = API.createElement({

+ 9 - 4
packages/excalidraw/element/transformHandles.ts

@@ -7,7 +7,6 @@ import type {
 
 import type { Bounds } from "./bounds";
 import { getElementAbsoluteCoords } from "./bounds";
-import { rotate } from "../math";
 import type { Device, InteractiveCanvasAppState, Zoom } from "../types";
 import {
   isElbowArrow,
@@ -19,6 +18,8 @@ import {
   isAndroid,
   isIOS,
 } from "../constants";
+import type { Radians } from "../../math";
+import { point, pointRotateRads } from "../../math";
 
 export type TransformHandleDirection =
   | "n"
@@ -91,9 +92,13 @@ const generateTransformHandle = (
   height: number,
   cx: number,
   cy: number,
-  angle: number,
+  angle: Radians,
 ): TransformHandle => {
-  const [xx, yy] = rotate(x + width / 2, y + height / 2, cx, cy, angle);
+  const [xx, yy] = pointRotateRads(
+    point(x + width / 2, y + height / 2),
+    point(cx, cy),
+    angle,
+  );
   return [xx - width / 2, yy - height / 2, width, height];
 };
 
@@ -119,7 +124,7 @@ export const getOmitSidesForDevice = (device: Device) => {
 
 export const getTransformHandlesFromCoords = (
   [x1, y1, x2, y2, cx, cy]: [number, number, number, number, number, number],
-  angle: number,
+  angle: Radians,
   zoom: Zoom,
   pointerType: PointerType,
   omitSides: { [T in TransformHandleType]?: boolean } = {},

+ 3 - 14
packages/excalidraw/element/typeChecks.ts

@@ -1,6 +1,5 @@
-import type { LineSegment } from "../../utils";
 import { ROUNDNESS } from "../constants";
-import type { ElementOrToolType, Point } from "../types";
+import type { ElementOrToolType } from "../types";
 import type { MarkNonNullable } from "../utility-types";
 import { assertNever } from "../utils";
 import type { Bounds } from "./bounds";
@@ -191,7 +190,8 @@ export const isRectangularElement = (
       element.type === "iframe" ||
       element.type === "embeddable" ||
       element.type === "frame" ||
-      element.type === "magicframe")
+      element.type === "magicframe" ||
+      element.type === "freedraw")
   );
 };
 
@@ -325,10 +325,6 @@ export const isFixedPointBinding = (
   return binding.fixedPoint != null;
 };
 
-// TODO: Move this to @excalidraw/math
-export const isPoint = (point: unknown): point is Point =>
-  Array.isArray(point) && point.length === 2;
-
 // TODO: Move this to @excalidraw/math
 export const isBounds = (box: unknown): box is Bounds =>
   Array.isArray(box) &&
@@ -337,10 +333,3 @@ export const isBounds = (box: unknown): box is Bounds =>
   typeof box[1] === "number" &&
   typeof box[2] === "number" &&
   typeof box[3] === "number";
-
-// TODO: Move this to @excalidraw/math
-export const isLineSegment = (segment: unknown): segment is LineSegment =>
-  Array.isArray(segment) &&
-  segment.length === 2 &&
-  isPoint(segment[0]) &&
-  isPoint(segment[0]);

+ 15 - 6
packages/excalidraw/element/types.ts

@@ -1,4 +1,4 @@
-import type { Point } from "../types";
+import type { LocalPoint, Radians } from "../../math";
 import type {
   FONT_FAMILY,
   ROUNDNESS,
@@ -49,7 +49,7 @@ type _ExcalidrawElementBase = Readonly<{
   opacity: number;
   width: number;
   height: number;
-  angle: number;
+  angle: Radians;
   /** Random integer used to seed shape generation so that the roughjs shape
       doesn't differ across renders. */
   seed: number;
@@ -175,6 +175,15 @@ export type ExcalidrawFlowchartNodeElement =
   | ExcalidrawDiamondElement
   | ExcalidrawEllipseElement;
 
+export type ExcalidrawRectanguloidElement =
+  | ExcalidrawRectangleElement
+  | ExcalidrawImageElement
+  | ExcalidrawTextElement
+  | ExcalidrawFreeDrawElement
+  | ExcalidrawIframeLikeElement
+  | ExcalidrawFrameLikeElement
+  | ExcalidrawEmbeddableElement;
+
 /**
  * ExcalidrawElement should be JSON serializable and (eventually) contain
  * no computed data. The list of all ExcalidrawElements should be shareable
@@ -283,8 +292,8 @@ export type Arrowhead =
 export type ExcalidrawLinearElement = _ExcalidrawElementBase &
   Readonly<{
     type: "line" | "arrow";
-    points: readonly Point[];
-    lastCommittedPoint: Point | null;
+    points: readonly LocalPoint[];
+    lastCommittedPoint: LocalPoint | null;
     startBinding: PointBinding | null;
     endBinding: PointBinding | null;
     startArrowhead: Arrowhead | null;
@@ -309,10 +318,10 @@ export type ExcalidrawElbowArrowElement = Merge<
 export type ExcalidrawFreeDrawElement = _ExcalidrawElementBase &
   Readonly<{
     type: "freedraw";
-    points: readonly Point[];
+    points: readonly LocalPoint[];
     pressures: readonly number[];
     simulatePressure: boolean;
-    lastCommittedPoint: Point | null;
+    lastCommittedPoint: LocalPoint | null;
   }>;
 
 export type FileId = string & { _brand: "FileId" };

+ 4 - 4
packages/excalidraw/frame.ts

@@ -11,7 +11,6 @@ import type {
   NonDeleted,
   NonDeletedExcalidrawElement,
 } from "./element/types";
-import { isPointWithinBounds } from "./math";
 import {
   getBoundTextElement,
   getContainerElement,
@@ -30,6 +29,7 @@ import { getElementLineSegments } from "./element/bounds";
 import { doLineSegmentsIntersect, elementsOverlappingBBox } from "../utils/";
 import { isFrameElement, isFrameLikeElement } from "./element/typeChecks";
 import type { ReadonlySetLike } from "./utility-types";
+import { isPointWithinBounds, point } from "../math";
 
 // --------------------------- Frame State ------------------------------------
 export const bindElementsToFramesAfterDuplication = (
@@ -159,9 +159,9 @@ export const isCursorInFrame = (
   const [fx1, fy1, fx2, fy2] = getElementAbsoluteCoords(frame, elementsMap);
 
   return isPointWithinBounds(
-    [fx1, fy1],
-    [cursorCoords.x, cursorCoords.y],
-    [fx2, fy2],
+    point(fx1, fy1),
+    point(cursorCoords.x, cursorCoords.y),
+    point(fx2, fy2),
   );
 };
 

+ 0 - 99
packages/excalidraw/math.test.ts

@@ -1,99 +0,0 @@
-import {
-  isPointOnSymmetricArc,
-  rangeIntersection,
-  rangesOverlap,
-  rotate,
-} from "./math";
-
-describe("rotate", () => {
-  it("should rotate over (x2, y2) and return the rotated coordinates for (x1, y1)", () => {
-    const x1 = 10;
-    const y1 = 20;
-    const x2 = 20;
-    const y2 = 30;
-    const angle = Math.PI / 2;
-    const [rotatedX, rotatedY] = rotate(x1, y1, x2, y2, angle);
-    expect([rotatedX, rotatedY]).toEqual([30, 20]);
-    const res2 = rotate(rotatedX, rotatedY, x2, y2, -angle);
-    expect(res2).toEqual([x1, x2]);
-  });
-});
-
-describe("range overlap", () => {
-  it("should overlap when range a contains range b", () => {
-    expect(rangesOverlap([1, 4], [2, 3])).toBe(true);
-    expect(rangesOverlap([1, 4], [1, 4])).toBe(true);
-    expect(rangesOverlap([1, 4], [1, 3])).toBe(true);
-    expect(rangesOverlap([1, 4], [2, 4])).toBe(true);
-  });
-
-  it("should overlap when range b contains range a", () => {
-    expect(rangesOverlap([2, 3], [1, 4])).toBe(true);
-    expect(rangesOverlap([1, 3], [1, 4])).toBe(true);
-    expect(rangesOverlap([2, 4], [1, 4])).toBe(true);
-  });
-
-  it("should overlap when range a and b intersect", () => {
-    expect(rangesOverlap([1, 4], [2, 5])).toBe(true);
-  });
-});
-
-describe("range intersection", () => {
-  it("should intersect completely with itself", () => {
-    expect(rangeIntersection([1, 4], [1, 4])).toEqual([1, 4]);
-  });
-
-  it("should intersect irrespective of order", () => {
-    expect(rangeIntersection([1, 4], [2, 3])).toEqual([2, 3]);
-    expect(rangeIntersection([2, 3], [1, 4])).toEqual([2, 3]);
-    expect(rangeIntersection([1, 4], [3, 5])).toEqual([3, 4]);
-    expect(rangeIntersection([3, 5], [1, 4])).toEqual([3, 4]);
-  });
-
-  it("should intersect at the edge", () => {
-    expect(rangeIntersection([1, 4], [4, 5])).toEqual([4, 4]);
-  });
-
-  it("should not intersect", () => {
-    expect(rangeIntersection([1, 4], [5, 7])).toEqual(null);
-  });
-});
-
-describe("point on arc", () => {
-  it("should detect point on simple arc", () => {
-    expect(
-      isPointOnSymmetricArc(
-        {
-          radius: 1,
-          startAngle: -Math.PI / 4,
-          endAngle: Math.PI / 4,
-        },
-        [0.92291667, 0.385],
-      ),
-    ).toBe(true);
-  });
-  it("should not detect point outside of a simple arc", () => {
-    expect(
-      isPointOnSymmetricArc(
-        {
-          radius: 1,
-          startAngle: -Math.PI / 4,
-          endAngle: Math.PI / 4,
-        },
-        [-0.92291667, 0.385],
-      ),
-    ).toBe(false);
-  });
-  it("should not detect point with good angle but incorrect radius", () => {
-    expect(
-      isPointOnSymmetricArc(
-        {
-          radius: 1,
-          startAngle: -Math.PI / 4,
-          endAngle: Math.PI / 4,
-        },
-        [-0.5, 0.5],
-      ),
-    ).toBe(false);
-  });
-});

+ 0 - 715
packages/excalidraw/math.ts

@@ -1,715 +0,0 @@
-import type {
-  NormalizedZoomValue,
-  NullableGridSize,
-  Point,
-  Zoom,
-} from "./types";
-import {
-  DEFAULT_ADAPTIVE_RADIUS,
-  LINE_CONFIRM_THRESHOLD,
-  DEFAULT_PROPORTIONAL_RADIUS,
-  ROUNDNESS,
-} from "./constants";
-import type {
-  ExcalidrawElement,
-  ExcalidrawLinearElement,
-  NonDeleted,
-} from "./element/types";
-import type { Bounds } from "./element/bounds";
-import { getCurvePathOps } from "./element/bounds";
-import type { Mutable } from "./utility-types";
-import { ShapeCache } from "./scene/ShapeCache";
-import type { Vector } from "../utils/geometry/shape";
-
-export const rotate = (
-  // target point to rotate
-  x: number,
-  y: number,
-  // point to rotate against
-  cx: number,
-  cy: number,
-  angle: number,
-): [number, number] =>
-  // 𝑎′𝑥=(𝑎𝑥−𝑐𝑥)cos𝜃−(𝑎𝑦−𝑐𝑦)sin𝜃+𝑐𝑥
-  // 𝑎′𝑦=(𝑎𝑥−𝑐𝑥)sin𝜃+(𝑎𝑦−𝑐𝑦)cos𝜃+𝑐𝑦.
-  // https://math.stackexchange.com/questions/2204520/how-do-i-rotate-a-line-segment-in-a-specific-point-on-the-line
-  [
-    (x - cx) * Math.cos(angle) - (y - cy) * Math.sin(angle) + cx,
-    (x - cx) * Math.sin(angle) + (y - cy) * Math.cos(angle) + cy,
-  ];
-
-export const rotatePoint = (
-  point: Point,
-  center: Point,
-  angle: number,
-): [number, number] => rotate(point[0], point[1], center[0], center[1], angle);
-
-export const adjustXYWithRotation = (
-  sides: {
-    n?: boolean;
-    e?: boolean;
-    s?: boolean;
-    w?: boolean;
-  },
-  x: number,
-  y: number,
-  angle: number,
-  deltaX1: number,
-  deltaY1: number,
-  deltaX2: number,
-  deltaY2: number,
-): [number, number] => {
-  const cos = Math.cos(angle);
-  const sin = Math.sin(angle);
-  if (sides.e && sides.w) {
-    x += deltaX1 + deltaX2;
-  } else if (sides.e) {
-    x += deltaX1 * (1 + cos);
-    y += deltaX1 * sin;
-    x += deltaX2 * (1 - cos);
-    y += deltaX2 * -sin;
-  } else if (sides.w) {
-    x += deltaX1 * (1 - cos);
-    y += deltaX1 * -sin;
-    x += deltaX2 * (1 + cos);
-    y += deltaX2 * sin;
-  }
-
-  if (sides.n && sides.s) {
-    y += deltaY1 + deltaY2;
-  } else if (sides.n) {
-    x += deltaY1 * sin;
-    y += deltaY1 * (1 - cos);
-    x += deltaY2 * -sin;
-    y += deltaY2 * (1 + cos);
-  } else if (sides.s) {
-    x += deltaY1 * -sin;
-    y += deltaY1 * (1 + cos);
-    x += deltaY2 * sin;
-    y += deltaY2 * (1 - cos);
-  }
-  return [x, y];
-};
-
-export const getPointOnAPath = (point: Point, path: Point[]) => {
-  const [px, py] = point;
-  const [start, ...other] = path;
-  let [lastX, lastY] = start;
-  let kLine: number = 0;
-  let idx: number = 0;
-
-  // if any item in the array is true, it means that a point is
-  // on some segment of a line based path
-  const retVal = other.some(([x2, y2], i) => {
-    // we always take a line when dealing with line segments
-    const x1 = lastX;
-    const y1 = lastY;
-
-    lastX = x2;
-    lastY = y2;
-
-    // if a point is not within the domain of the line segment
-    // it is not on the line segment
-    if (px < x1 || px > x2) {
-      return false;
-    }
-
-    // check if all points lie on the same line
-    // y1 = kx1 + b, y2 = kx2 + b
-    // y2 - y1 = k(x2 - x2) -> k = (y2 - y1) / (x2 - x1)
-
-    // coefficient for the line (p0, p1)
-    const kL = (y2 - y1) / (x2 - x1);
-
-    // coefficient for the line segment (p0, point)
-    const kP1 = (py - y1) / (px - x1);
-
-    // coefficient for the line segment (point, p1)
-    const kP2 = (py - y2) / (px - x2);
-
-    // because we are basing both lines from the same starting point
-    // the only option for collinearity is having same coefficients
-
-    // using it for floating point comparisons
-    const epsilon = 0.3;
-
-    // if coefficient is more than an arbitrary epsilon,
-    // these lines are nor collinear
-    if (Math.abs(kP1 - kL) > epsilon && Math.abs(kP2 - kL) > epsilon) {
-      return false;
-    }
-
-    // store the coefficient because we are goint to need it
-    kLine = kL;
-    idx = i;
-
-    return true;
-  });
-
-  // Return a coordinate that is always on the line segment
-  if (retVal === true) {
-    return { x: point[0], y: kLine * point[0], segment: idx };
-  }
-
-  return null;
-};
-
-export const distance2d = (x1: number, y1: number, x2: number, y2: number) => {
-  const xd = x2 - x1;
-  const yd = y2 - y1;
-  return Math.hypot(xd, yd);
-};
-
-export const distanceSq2d = (p1: Point, p2: Point) => {
-  const xd = p2[0] - p1[0];
-  const yd = p2[1] - p1[1];
-  return xd * xd + yd * yd;
-};
-
-export const centerPoint = (a: Point, b: Point): Point => {
-  return [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
-};
-
-// Checks if the first and last point are close enough
-// to be considered a loop
-export const isPathALoop = (
-  points: ExcalidrawLinearElement["points"],
-  /** supply if you want the loop detection to account for current zoom */
-  zoomValue: Zoom["value"] = 1 as NormalizedZoomValue,
-): boolean => {
-  if (points.length >= 3) {
-    const [first, last] = [points[0], points[points.length - 1]];
-    const distance = distance2d(first[0], first[1], last[0], last[1]);
-
-    // Adjusting LINE_CONFIRM_THRESHOLD to current zoom so that when zoomed in
-    // really close we make the threshold smaller, and vice versa.
-    return distance <= LINE_CONFIRM_THRESHOLD / zoomValue;
-  }
-  return false;
-};
-
-// Draw a line from the point to the right till infiinty
-// Check how many lines of the polygon does this infinite line intersects with
-// If the number of intersections is odd, point is in the polygon
-export const isPointInPolygon = (
-  points: Point[],
-  x: number,
-  y: number,
-): boolean => {
-  const vertices = points.length;
-
-  // There must be at least 3 vertices in polygon
-  if (vertices < 3) {
-    return false;
-  }
-  const extreme: Point = [Number.MAX_SAFE_INTEGER, y];
-  const p: Point = [x, y];
-  let count = 0;
-  for (let i = 0; i < vertices; i++) {
-    const current = points[i];
-    const next = points[(i + 1) % vertices];
-    if (doSegmentsIntersect(current, next, p, extreme)) {
-      if (orderedColinearOrientation(current, p, next) === 0) {
-        return isPointWithinBounds(current, p, next);
-      }
-      count++;
-    }
-  }
-  // true if count is off
-  return count % 2 === 1;
-};
-
-// Returns whether `q` lies inside the segment/rectangle defined by `p` and `r`.
-// This is an approximation to "does `q` lie on a segment `pr`" check.
-export const isPointWithinBounds = (p: Point, q: Point, r: Point) => {
-  return (
-    q[0] <= Math.max(p[0], r[0]) &&
-    q[0] >= Math.min(p[0], r[0]) &&
-    q[1] <= Math.max(p[1], r[1]) &&
-    q[1] >= Math.min(p[1], r[1])
-  );
-};
-
-// For the ordered points p, q, r, return
-// 0 if p, q, r are colinear
-// 1 if Clockwise
-// 2 if counterclickwise
-const orderedColinearOrientation = (p: Point, q: Point, r: Point) => {
-  const val = (q[1] - p[1]) * (r[0] - q[0]) - (q[0] - p[0]) * (r[1] - q[1]);
-  if (val === 0) {
-    return 0;
-  }
-  return val > 0 ? 1 : 2;
-};
-
-// Check is p1q1 intersects with p2q2
-const doSegmentsIntersect = (p1: Point, q1: Point, p2: Point, q2: Point) => {
-  const o1 = orderedColinearOrientation(p1, q1, p2);
-  const o2 = orderedColinearOrientation(p1, q1, q2);
-  const o3 = orderedColinearOrientation(p2, q2, p1);
-  const o4 = orderedColinearOrientation(p2, q2, q1);
-
-  if (o1 !== o2 && o3 !== o4) {
-    return true;
-  }
-
-  // p1, q1 and p2 are colinear and p2 lies on segment p1q1
-  if (o1 === 0 && isPointWithinBounds(p1, p2, q1)) {
-    return true;
-  }
-
-  // p1, q1 and p2 are colinear and q2 lies on segment p1q1
-  if (o2 === 0 && isPointWithinBounds(p1, q2, q1)) {
-    return true;
-  }
-
-  // p2, q2 and p1 are colinear and p1 lies on segment p2q2
-  if (o3 === 0 && isPointWithinBounds(p2, p1, q2)) {
-    return true;
-  }
-
-  // p2, q2 and q1 are colinear and q1 lies on segment p2q2
-  if (o4 === 0 && isPointWithinBounds(p2, q1, q2)) {
-    return true;
-  }
-
-  return false;
-};
-
-// TODO: Rounding this point causes some shake when free drawing
-export const getGridPoint = (
-  x: number,
-  y: number,
-  gridSize: NullableGridSize,
-): [number, number] => {
-  if (gridSize) {
-    return [
-      Math.round(x / gridSize) * gridSize,
-      Math.round(y / gridSize) * gridSize,
-    ];
-  }
-  return [x, y];
-};
-
-export const getCornerRadius = (x: number, element: ExcalidrawElement) => {
-  if (
-    element.roundness?.type === ROUNDNESS.PROPORTIONAL_RADIUS ||
-    element.roundness?.type === ROUNDNESS.LEGACY
-  ) {
-    return x * DEFAULT_PROPORTIONAL_RADIUS;
-  }
-
-  if (element.roundness?.type === ROUNDNESS.ADAPTIVE_RADIUS) {
-    const fixedRadiusSize = element.roundness?.value ?? DEFAULT_ADAPTIVE_RADIUS;
-
-    const CUTOFF_SIZE = fixedRadiusSize / DEFAULT_PROPORTIONAL_RADIUS;
-
-    if (x <= CUTOFF_SIZE) {
-      return x * DEFAULT_PROPORTIONAL_RADIUS;
-    }
-
-    return fixedRadiusSize;
-  }
-
-  return 0;
-};
-
-export const getControlPointsForBezierCurve = (
-  element: NonDeleted<ExcalidrawLinearElement>,
-  endPoint: Point,
-) => {
-  const shape = ShapeCache.generateElementShape(element, null);
-  if (!shape) {
-    return null;
-  }
-
-  const ops = getCurvePathOps(shape[0]);
-  let currentP: Mutable<Point> = [0, 0];
-  let index = 0;
-  let minDistance = Infinity;
-  let controlPoints: Mutable<Point>[] | null = null;
-
-  while (index < ops.length) {
-    const { op, data } = ops[index];
-    if (op === "move") {
-      currentP = data as unknown as Mutable<Point>;
-    }
-    if (op === "bcurveTo") {
-      const p0 = currentP;
-      const p1 = [data[0], data[1]] as Mutable<Point>;
-      const p2 = [data[2], data[3]] as Mutable<Point>;
-      const p3 = [data[4], data[5]] as Mutable<Point>;
-      const distance = distance2d(p3[0], p3[1], endPoint[0], endPoint[1]);
-      if (distance < minDistance) {
-        minDistance = distance;
-        controlPoints = [p0, p1, p2, p3];
-      }
-      currentP = p3;
-    }
-    index++;
-  }
-
-  return controlPoints;
-};
-
-export const getBezierXY = (
-  p0: Point,
-  p1: Point,
-  p2: Point,
-  p3: Point,
-  t: number,
-) => {
-  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 tx = equation(t, 0);
-  const ty = equation(t, 1);
-  return [tx, ty];
-};
-
-export const getPointsInBezierCurve = (
-  element: NonDeleted<ExcalidrawLinearElement>,
-  endPoint: Point,
-) => {
-  const controlPoints: Mutable<Point>[] = getControlPointsForBezierCurve(
-    element,
-    endPoint,
-  )!;
-  if (!controlPoints) {
-    return [];
-  }
-  const pointsOnCurve: Mutable<Point>[] = [];
-  let t = 1;
-  // Take 20 points on curve for better accuracy
-  while (t > 0) {
-    const point = getBezierXY(
-      controlPoints[0],
-      controlPoints[1],
-      controlPoints[2],
-      controlPoints[3],
-      t,
-    );
-    pointsOnCurve.push([point[0], point[1]]);
-    t -= 0.05;
-  }
-  if (pointsOnCurve.length) {
-    if (arePointsEqual(pointsOnCurve.at(-1)!, endPoint)) {
-      pointsOnCurve.push([endPoint[0], endPoint[1]]);
-    }
-  }
-  return pointsOnCurve;
-};
-
-export const getBezierCurveArcLengths = (
-  element: NonDeleted<ExcalidrawLinearElement>,
-  endPoint: Point,
-) => {
-  const arcLengths: number[] = [];
-  arcLengths[0] = 0;
-  const points = getPointsInBezierCurve(element, endPoint);
-  let index = 0;
-  let distance = 0;
-  while (index < points.length - 1) {
-    const segmentDistance = distance2d(
-      points[index][0],
-      points[index][1],
-      points[index + 1][0],
-      points[index + 1][1],
-    );
-    distance += segmentDistance;
-    arcLengths.push(distance);
-    index++;
-  }
-
-  return arcLengths;
-};
-
-export const getBezierCurveLength = (
-  element: NonDeleted<ExcalidrawLinearElement>,
-  endPoint: Point,
-) => {
-  const arcLengths = getBezierCurveArcLengths(element, endPoint);
-  return arcLengths.at(-1) as number;
-};
-
-// This maps interval to actual interval t on the curve so that when t = 0.5, its actually the point at 50% of the length
-export const mapIntervalToBezierT = (
-  element: NonDeleted<ExcalidrawLinearElement>,
-  endPoint: Point,
-  interval: number, // The interval between 0 to 1 for which you want to find the point on the curve,
-) => {
-  const arcLengths = getBezierCurveArcLengths(element, endPoint);
-  const pointsCount = arcLengths.length - 1;
-  const curveLength = arcLengths.at(-1) as number;
-  const targetLength = interval * curveLength;
-  let low = 0;
-  let high = pointsCount;
-  let index = 0;
-  // Doing a binary search to find the largest length that is less than the target length
-  while (low < high) {
-    index = Math.floor(low + (high - low) / 2);
-    if (arcLengths[index] < targetLength) {
-      low = index + 1;
-    } else {
-      high = index;
-    }
-  }
-  if (arcLengths[index] > targetLength) {
-    index--;
-  }
-  if (arcLengths[index] === targetLength) {
-    return index / pointsCount;
-  }
-
-  return (
-    1 -
-    (index +
-      (targetLength - arcLengths[index]) /
-        (arcLengths[index + 1] - arcLengths[index])) /
-      pointsCount
-  );
-};
-
-export const arePointsEqual = (p1: Point, p2: Point) => {
-  return p1[0] === p2[0] && p1[1] === p2[1];
-};
-
-export const isRightAngle = (angle: number) => {
-  // if our angles were mathematically accurate, we could just check
-  //
-  //    angle % (Math.PI / 2) === 0
-  //
-  // but since we're in floating point land, we need to round.
-  //
-  // Below, after dividing by Math.PI, a multiple of 0.5 indicates a right
-  // angle, which we can check with modulo after rounding.
-  return Math.round((angle / Math.PI) * 10000) % 5000 === 0;
-};
-
-export const radianToDegree = (r: number) => {
-  return (r * 180) / Math.PI;
-};
-
-export const degreeToRadian = (d: number) => {
-  return (d / 180) * Math.PI;
-};
-
-// Given two ranges, return if the two ranges overlap with each other
-// e.g. [1, 3] overlaps with [2, 4] while [1, 3] does not overlap with [4, 5]
-export const rangesOverlap = (
-  [a0, a1]: [number, number],
-  [b0, b1]: [number, number],
-) => {
-  if (a0 <= b0) {
-    return a1 >= b0;
-  }
-
-  if (a0 >= b0) {
-    return b1 >= a0;
-  }
-
-  return false;
-};
-
-// Given two ranges,return ther intersection of the two ranges if any
-// e.g. the intersection of [1, 3] and [2, 4] is [2, 3]
-export const rangeIntersection = (
-  rangeA: [number, number],
-  rangeB: [number, number],
-): [number, number] | null => {
-  const rangeStart = Math.max(rangeA[0], rangeB[0]);
-  const rangeEnd = Math.min(rangeA[1], rangeB[1]);
-
-  if (rangeStart <= rangeEnd) {
-    return [rangeStart, rangeEnd];
-  }
-
-  return null;
-};
-
-export const isValueInRange = (value: number, min: number, max: number) => {
-  return value >= min && value <= max;
-};
-
-export const translatePoint = (p: Point, v: Vector): Point => [
-  p[0] + v[0],
-  p[1] + v[1],
-];
-
-export const scaleVector = (v: Vector, scalar: number): Vector => [
-  v[0] * scalar,
-  v[1] * scalar,
-];
-
-export const pointToVector = (p: Point, origin: Point = [0, 0]): Vector => [
-  p[0] - origin[0],
-  p[1] - origin[1],
-];
-
-export const scalePointFromOrigin = (
-  p: Point,
-  mid: Point,
-  multiplier: number,
-) => translatePoint(mid, scaleVector(pointToVector(p, mid), multiplier));
-
-const triangleSign = (p1: Point, p2: Point, p3: Point): number =>
-  (p1[0] - p3[0]) * (p2[1] - p3[1]) - (p2[0] - p3[0]) * (p1[1] - p3[1]);
-
-export const PointInTriangle = (pt: Point, v1: Point, v2: Point, v3: Point) => {
-  const d1 = triangleSign(pt, v1, v2);
-  const d2 = triangleSign(pt, v2, v3);
-  const d3 = triangleSign(pt, v3, v1);
-
-  const has_neg = d1 < 0 || d2 < 0 || d3 < 0;
-  const has_pos = d1 > 0 || d2 > 0 || d3 > 0;
-
-  return !(has_neg && has_pos);
-};
-
-export const magnitudeSq = (vector: Vector) =>
-  vector[0] * vector[0] + vector[1] * vector[1];
-
-export const magnitude = (vector: Vector) => Math.sqrt(magnitudeSq(vector));
-
-export const normalize = (vector: Vector): Vector => {
-  const m = magnitude(vector);
-
-  return [vector[0] / m, vector[1] / m];
-};
-
-export const addVectors = (
-  vec1: Readonly<Vector>,
-  vec2: Readonly<Vector>,
-): Vector => [vec1[0] + vec2[0], vec1[1] + vec2[1]];
-
-export const subtractVectors = (
-  vec1: Readonly<Vector>,
-  vec2: Readonly<Vector>,
-): Vector => [vec1[0] - vec2[0], vec1[1] - vec2[1]];
-
-export const pointInsideBounds = (p: Point, bounds: Bounds): boolean =>
-  p[0] > bounds[0] && p[0] < bounds[2] && p[1] > bounds[1] && p[1] < bounds[3];
-
-/**
- * Get the axis-aligned bounding box for a given element
- */
-export const aabbForElement = (
-  element: Readonly<ExcalidrawElement>,
-  offset?: [number, number, number, number],
-) => {
-  const bbox = {
-    minX: element.x,
-    minY: element.y,
-    maxX: element.x + element.width,
-    maxY: element.y + element.height,
-    midX: element.x + element.width / 2,
-    midY: element.y + element.height / 2,
-  };
-
-  const center = [bbox.midX, bbox.midY] as Point;
-  const [topLeftX, topLeftY] = rotatePoint(
-    [bbox.minX, bbox.minY],
-    center,
-    element.angle,
-  );
-  const [topRightX, topRightY] = rotatePoint(
-    [bbox.maxX, bbox.minY],
-    center,
-    element.angle,
-  );
-  const [bottomRightX, bottomRightY] = rotatePoint(
-    [bbox.maxX, bbox.maxY],
-    center,
-    element.angle,
-  );
-  const [bottomLeftX, bottomLeftY] = rotatePoint(
-    [bbox.minX, bbox.maxY],
-    center,
-    element.angle,
-  );
-
-  const bounds = [
-    Math.min(topLeftX, topRightX, bottomRightX, bottomLeftX),
-    Math.min(topLeftY, topRightY, bottomRightY, bottomLeftY),
-    Math.max(topLeftX, topRightX, bottomRightX, bottomLeftX),
-    Math.max(topLeftY, topRightY, bottomRightY, bottomLeftY),
-  ] as Bounds;
-
-  if (offset) {
-    const [topOffset, rightOffset, downOffset, leftOffset] = offset;
-    return [
-      bounds[0] - leftOffset,
-      bounds[1] - topOffset,
-      bounds[2] + rightOffset,
-      bounds[3] + downOffset,
-    ] as Bounds;
-  }
-
-  return bounds;
-};
-
-type PolarCoords = [number, number];
-
-/**
- * Return the polar coordinates for the given carthesian point represented by
- * (x, y) for the center point 0,0 where the first number returned is the radius,
- * the second is the angle in radians.
- */
-export const carthesian2Polar = ([x, y]: Point): PolarCoords => [
-  Math.hypot(x, y),
-  Math.atan2(y, x),
-];
-
-/**
- * Angles are in radians and centered on 0, 0. Zero radians on a 1 radius circle
- * corresponds to (1, 0) carthesian coordinates (point), i.e. to the "right".
- */
-type SymmetricArc = { radius: number; startAngle: number; endAngle: number };
-
-/**
- * Determines if a carthesian point lies on a symmetric arc, i.e. an arc which
- * is part of a circle contour centered on 0, 0.
- */
-export const isPointOnSymmetricArc = (
-  { radius: arcRadius, startAngle, endAngle }: SymmetricArc,
-  point: Point,
-): boolean => {
-  const [radius, angle] = carthesian2Polar(point);
-
-  return startAngle < endAngle
-    ? Math.abs(radius - arcRadius) < 0.0000001 &&
-        startAngle <= angle &&
-        endAngle >= angle
-    : startAngle <= angle || endAngle >= angle;
-};
-
-export const getCenterForBounds = (bounds: Bounds): Point => [
-  bounds[0] + (bounds[2] - bounds[0]) / 2,
-  bounds[1] + (bounds[3] - bounds[1]) / 2,
-];
-
-export const getCenterForElement = (element: ExcalidrawElement): Point => [
-  element.x + element.width / 2,
-  element.y + element.height / 2,
-];
-
-export const aabbsOverlapping = (a: Bounds, b: Bounds) =>
-  pointInsideBounds([a[0], a[1]], b) ||
-  pointInsideBounds([a[2], a[1]], b) ||
-  pointInsideBounds([a[2], a[3]], b) ||
-  pointInsideBounds([a[0], a[3]], b) ||
-  pointInsideBounds([b[0], b[1]], a) ||
-  pointInsideBounds([b[2], b[1]], a) ||
-  pointInsideBounds([b[2], b[3]], a) ||
-  pointInsideBounds([b[0], b[3]], a);
-
-export const clamp = (value: number, min: number, max: number) => {
-  return Math.min(Math.max(value, min), max);
-};
-
-export const round = (value: number, precision: number) => {
-  const multiplier = Math.pow(10, precision);
-  return Math.round((value + Number.EPSILON) * multiplier) / multiplier;
-};

+ 10 - 6
packages/excalidraw/points.ts

@@ -1,6 +1,8 @@
-import type { Point } from "./types";
+import { pointFromPair, type GlobalPoint, type LocalPoint } from "../math";
 
-export const getSizeFromPoints = (points: readonly Point[]) => {
+export const getSizeFromPoints = (
+  points: readonly (GlobalPoint | LocalPoint)[],
+) => {
   const xs = points.map((point) => point[0]);
   const ys = points.map((point) => point[1]);
   return {
@@ -10,7 +12,7 @@ export const getSizeFromPoints = (points: readonly Point[]) => {
 };
 
 /** @arg dimension, 0 for rescaling only x, 1 for y */
-export const rescalePoints = (
+export const rescalePoints = <Point extends GlobalPoint | LocalPoint>(
   dimension: 0 | 1,
   newSize: number,
   points: readonly Point[],
@@ -31,7 +33,7 @@ export const rescalePoints = (
     if (newCoordinate < nextMinCoordinate) {
       nextMinCoordinate = newCoordinate;
     }
-    return newPoint as unknown as Point;
+    return newPoint as Point;
   });
 
   if (!normalize) {
@@ -45,11 +47,13 @@ export const rescalePoints = (
 
   const translation = minCoordinate - nextMinCoordinate;
 
-  const nextPoints = scaledPoints.map(
-    (scaledPoint) =>
+  const nextPoints = scaledPoints.map((scaledPoint) =>
+    pointFromPair<Point>(
       scaledPoint.map((value, currentDimension) => {
         return currentDimension === dimension ? value + translation : value;
       }) as [number, number],
+    ),
   );
+
   return nextPoints;
 };

+ 7 - 6
packages/excalidraw/renderer/interactiveScene.ts

@@ -30,7 +30,7 @@ import {
   shouldShowBoundingBox,
 } from "../element/transformHandles";
 import { arrayToMap, throttleRAF } from "../utils";
-import type { InteractiveCanvasAppState, Point } from "../types";
+import type { InteractiveCanvasAppState } from "../types";
 import { DEFAULT_TRANSFORM_HANDLE_SPACING, FRAME_STYLE } from "../constants";
 
 import { renderSnaps } from "../renderer/renderSnaps";
@@ -69,7 +69,8 @@ import type {
   InteractiveSceneRenderConfig,
   RenderableElementsMap,
 } from "../scene/types";
-import { getCornerRadius } from "../math";
+import type { GlobalPoint, LocalPoint, Radians } from "../../math";
+import { getCornerRadius } from "../shapes";
 
 const renderLinearElementPointHighlight = (
   context: CanvasRenderingContext2D,
@@ -101,7 +102,7 @@ const renderLinearElementPointHighlight = (
   context.restore();
 };
 
-const highlightPoint = (
+const highlightPoint = <Point extends LocalPoint | GlobalPoint>(
   point: Point,
   context: CanvasRenderingContext2D,
   appState: InteractiveCanvasAppState,
@@ -168,7 +169,7 @@ const strokeDiamondWithRotation = (
   context.restore();
 };
 
-const renderSingleLinearPoint = (
+const renderSingleLinearPoint = <Point extends GlobalPoint | LocalPoint>(
   context: CanvasRenderingContext2D,
   appState: InteractiveCanvasAppState,
   point: Point,
@@ -499,7 +500,7 @@ const renderLinearPointHandles = (
     element,
     elementsMap,
     appState,
-  ).filter((midPoint) => midPoint !== null) as Point[];
+  ).filter((midPoint): midPoint is GlobalPoint => midPoint !== null);
 
   midPoints.forEach((segmentMidPoint) => {
     if (
@@ -931,7 +932,7 @@ const _renderInteractiveScene = ({
       context.setLineDash(initialLineDash);
       const transformHandles = getTransformHandlesFromCoords(
         [x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2],
-        0,
+        0 as Radians,
         appState.zoom,
         "mouse",
         isFrameSelected

+ 4 - 2
packages/excalidraw/renderer/renderElement.ts

@@ -27,7 +27,6 @@ import type {
   InteractiveCanvasRenderConfig,
 } from "../scene/types";
 import { distance, getFontString, isRTL } from "../utils";
-import { getCornerRadius, isRightAngle } from "../math";
 import rough from "roughjs/bin/rough";
 import type {
   AppState,
@@ -60,6 +59,8 @@ import { LinearElementEditor } from "../element/linearElementEditor";
 import { getContainingFrame } from "../frame";
 import { ShapeCache } from "../scene/ShapeCache";
 import { getVerticalOffset } from "../fonts";
+import { isRightAngleRads } from "../../math";
+import { getCornerRadius } from "../shapes";
 
 // using a stronger invert (100% vs our regular 93%) and saturate
 // as a temp hack to make images in dark theme look closer to original
@@ -907,7 +908,8 @@ export const renderElement = (
           (!element.angle ||
             // or check if angle is a right angle in which case we can still
             // disable smoothing without adversely affecting the result
-            isRightAngle(element.angle))
+            // We need less-than comparison because of FP artihmetic
+            isRightAngleRads(element.angle))
         ) {
           // Disabling smoothing makes output much sharper, especially for
           // text. Unless for non-right angles, where the aliasing is really

+ 27 - 18
packages/excalidraw/renderer/renderSnaps.ts

@@ -1,6 +1,7 @@
+import { point, type GlobalPoint, type LocalPoint } from "../../math";
 import { THEME } from "../constants";
 import type { PointSnapLine, PointerSnapLine } from "../snapping";
-import type { InteractiveCanvasAppState, Point } from "../types";
+import type { InteractiveCanvasAppState } from "../types";
 
 const SNAP_COLOR_LIGHT = "#ff6b6b";
 const SNAP_COLOR_DARK = "#ff0000";
@@ -85,7 +86,7 @@ const drawPointerSnapLine = (
   }
 };
 
-const drawCross = (
+const drawCross = <Point extends LocalPoint | GlobalPoint>(
   [x, y]: Point,
   appState: InteractiveCanvasAppState,
   context: CanvasRenderingContext2D,
@@ -106,18 +107,18 @@ const drawCross = (
   context.restore();
 };
 
-const drawLine = (
+const drawLine = <Point extends LocalPoint | GlobalPoint>(
   from: Point,
   to: Point,
   context: CanvasRenderingContext2D,
 ) => {
   context.beginPath();
-  context.lineTo(...from);
-  context.lineTo(...to);
+  context.lineTo(from[0], from[1]);
+  context.lineTo(to[0], to[1]);
   context.stroke();
 };
 
-const drawGapLine = (
+const drawGapLine = <Point extends LocalPoint | GlobalPoint>(
   from: Point,
   to: Point,
   direction: "horizontal" | "vertical",
@@ -138,24 +139,28 @@ const drawGapLine = (
     const halfPoint = [(from[0] + to[0]) / 2, from[1]];
     // (1)
     if (!appState.zenModeEnabled) {
-      drawLine([from[0], from[1] - FULL], [from[0], from[1] + FULL], context);
+      drawLine(
+        point(from[0], from[1] - FULL),
+        point(from[0], from[1] + FULL),
+        context,
+      );
     }
 
     // (3)
     drawLine(
-      [halfPoint[0] - QUARTER, halfPoint[1] - HALF],
-      [halfPoint[0] - QUARTER, halfPoint[1] + HALF],
+      point(halfPoint[0] - QUARTER, halfPoint[1] - HALF),
+      point(halfPoint[0] - QUARTER, halfPoint[1] + HALF),
       context,
     );
     drawLine(
-      [halfPoint[0] + QUARTER, halfPoint[1] - HALF],
-      [halfPoint[0] + QUARTER, halfPoint[1] + HALF],
+      point(halfPoint[0] + QUARTER, halfPoint[1] - HALF),
+      point(halfPoint[0] + QUARTER, halfPoint[1] + HALF),
       context,
     );
 
     if (!appState.zenModeEnabled) {
       // (4)
-      drawLine([to[0], to[1] - FULL], [to[0], to[1] + FULL], context);
+      drawLine(point(to[0], to[1] - FULL), point(to[0], to[1] + FULL), context);
 
       // (2)
       drawLine(from, to, context);
@@ -164,24 +169,28 @@ const drawGapLine = (
     const halfPoint = [from[0], (from[1] + to[1]) / 2];
     // (1)
     if (!appState.zenModeEnabled) {
-      drawLine([from[0] - FULL, from[1]], [from[0] + FULL, from[1]], context);
+      drawLine(
+        point(from[0] - FULL, from[1]),
+        point(from[0] + FULL, from[1]),
+        context,
+      );
     }
 
     // (3)
     drawLine(
-      [halfPoint[0] - HALF, halfPoint[1] - QUARTER],
-      [halfPoint[0] + HALF, halfPoint[1] - QUARTER],
+      point(halfPoint[0] - HALF, halfPoint[1] - QUARTER),
+      point(halfPoint[0] + HALF, halfPoint[1] - QUARTER),
       context,
     );
     drawLine(
-      [halfPoint[0] - HALF, halfPoint[1] + QUARTER],
-      [halfPoint[0] + HALF, halfPoint[1] + QUARTER],
+      point(halfPoint[0] - HALF, halfPoint[1] + QUARTER),
+      point(halfPoint[0] + HALF, halfPoint[1] + QUARTER),
       context,
     );
 
     if (!appState.zenModeEnabled) {
       // (4)
-      drawLine([to[0] - FULL, to[1]], [to[0] + FULL, to[1]], context);
+      drawLine(point(to[0] - FULL, to[1]), point(to[0] + FULL, to[1]), context);
 
       // (2)
       drawLine(from, to, context);

+ 1 - 1
packages/excalidraw/renderer/staticSvgScene.ts

@@ -30,13 +30,13 @@ import type {
   NonDeletedExcalidrawElement,
 } from "../element/types";
 import { getContainingFrame } from "../frame";
-import { getCornerRadius, isPathALoop } from "../math";
 import { ShapeCache } from "../scene/ShapeCache";
 import type { RenderableElementsMap, SVGRenderConfig } from "../scene/types";
 import type { AppState, BinaryFiles } from "../types";
 import { getFontFamilyString, isRTL, isTestEnv } from "../utils";
 import { getFreeDrawSvgPath, IMAGE_INVERT_FILTER } from "./renderElement";
 import { getVerticalOffset } from "../fonts";
+import { getCornerRadius, isPathALoop } from "../shapes";
 
 const roughSVGDrawWithPrecision = (
   rsvg: RoughSVG,

+ 23 - 10
packages/excalidraw/scene/Shape.ts

@@ -1,3 +1,4 @@
+import type { Point as RoughPoint } from "roughjs/bin/geometry";
 import type { Drawable, Options } from "roughjs/bin/core";
 import type { RoughGenerator } from "roughjs/bin/generator";
 import { getDiamondPoints, getArrowheadPoints } from "../element";
@@ -9,7 +10,6 @@ import type {
   ExcalidrawLinearElement,
   Arrowhead,
 } from "../element/types";
-import { isPathALoop, getCornerRadius, distanceSq2d } from "../math";
 import { generateFreeDrawShape } from "../renderer/renderElement";
 import { isTransparent, assertNever } from "../utils";
 import { simplify } from "points-on-curve";
@@ -23,6 +23,13 @@ import {
 } from "../element/typeChecks";
 import { canChangeRoundness } from "./comparisons";
 import type { EmbedsValidationStatus } from "../types";
+import {
+  point,
+  pointDistance,
+  type GlobalPoint,
+  type LocalPoint,
+} from "../../math";
+import { getCornerRadius, isPathALoop } from "../shapes";
 
 const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth];
 
@@ -399,12 +406,14 @@ export const _generateElementShape = (
 
       // points array can be empty in the beginning, so it is important to add
       // initial position to it
-      const points = element.points.length ? element.points : [[0, 0]];
+      const points = element.points.length
+        ? element.points
+        : [point<LocalPoint>(0, 0)];
 
       if (isElbowArrow(element)) {
         shape = [
           generator.path(
-            generateElbowArrowShape(points as [number, number][], 16),
+            generateElbowArrowShape(points, 16),
             generateRoughOptions(element, true),
           ),
         ];
@@ -412,12 +421,16 @@ export const _generateElementShape = (
         // curve is always the first element
         // this simplifies finding the curve for an element
         if (options.fill) {
-          shape = [generator.polygon(points as [number, number][], options)];
+          shape = [
+            generator.polygon(points as unknown as RoughPoint[], options),
+          ];
         } else {
-          shape = [generator.linearPath(points as [number, number][], options)];
+          shape = [
+            generator.linearPath(points as unknown as RoughPoint[], options),
+          ];
         }
       } else {
-        shape = [generator.curve(points as [number, number][], options)];
+        shape = [generator.curve(points as unknown as RoughPoint[], options)];
       }
 
       // add lines only in arrow
@@ -491,8 +504,8 @@ export const _generateElementShape = (
   }
 };
 
-const generateElbowArrowShape = (
-  points: [number, number][],
+const generateElbowArrowShape = <Point extends GlobalPoint | LocalPoint>(
+  points: readonly Point[],
   radius: number,
 ) => {
   const subpoints = [] as [number, number][];
@@ -501,8 +514,8 @@ const generateElbowArrowShape = (
     const next = points[i + 1];
     const corner = Math.min(
       radius,
-      Math.sqrt(distanceSq2d(points[i], next)) / 2,
-      Math.sqrt(distanceSq2d(points[i], prev)) / 2,
+      pointDistance(points[i], next) / 2,
+      pointDistance(points[i], prev) / 2,
     );
 
     if (prev[0] < points[i][0] && prev[1] === points[i][1]) {

+ 1 - 1
packages/excalidraw/scene/normalize.ts

@@ -1,5 +1,5 @@
+import { clamp, round } from "../../math";
 import { MAX_ZOOM, MIN_ZOOM } from "../constants";
-import { clamp, round } from "../math";
 import type { NormalizedZoomValue } from "../types";
 
 export const getNormalizedZoom = (zoom: number): NormalizedZoomValue => {

+ 315 - 13
packages/excalidraw/shapes.tsx

@@ -1,5 +1,16 @@
+import {
+  isPoint,
+  point,
+  pointDistance,
+  pointFromPair,
+  pointRotateRads,
+  pointsEqual,
+  type GlobalPoint,
+  type LocalPoint,
+} from "../math";
 import {
   getClosedCurveShape,
+  getCurvePathOps,
   getCurveShape,
   getEllipseShape,
   getFreedrawShape,
@@ -18,13 +29,27 @@ import {
   SelectionIcon,
   TextIcon,
 } from "./components/icons";
+import {
+  DEFAULT_ADAPTIVE_RADIUS,
+  DEFAULT_PROPORTIONAL_RADIUS,
+  LINE_CONFIRM_THRESHOLD,
+  ROUNDNESS,
+} from "./constants";
 import { getElementAbsoluteCoords } from "./element";
+import type { Bounds } from "./element/bounds";
 import { shouldTestInside } from "./element/collision";
 import { LinearElementEditor } from "./element/linearElementEditor";
 import { getBoundTextElement } from "./element/textElement";
-import type { ElementsMap, ExcalidrawElement } from "./element/types";
+import type {
+  ElementsMap,
+  ExcalidrawElement,
+  ExcalidrawLinearElement,
+  NonDeleted,
+} from "./element/types";
 import { KEYS } from "./keys";
 import { ShapeCache } from "./scene/ShapeCache";
+import type { NormalizedZoomValue, Zoom } from "./types";
+import { invariant } from "./utils";
 
 export const SHAPES = [
   {
@@ -116,10 +141,10 @@ export const findShapeByKey = (key: string) => {
  * get the pure geometric shape of an excalidraw element
  * which is then used for hit detection
  */
-export const getElementShape = (
+export const getElementShape = <Point extends GlobalPoint | LocalPoint>(
   element: ExcalidrawElement,
   elementsMap: ElementsMap,
-): GeometricShape => {
+): GeometricShape<Point> => {
   switch (element.type) {
     case "rectangle":
     case "diamond":
@@ -139,17 +164,19 @@ export const getElementShape = (
       const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap);
 
       return shouldTestInside(element)
-        ? getClosedCurveShape(
+        ? getClosedCurveShape<Point>(
             element,
             roughShape,
-            [element.x, element.y],
+            point<Point>(element.x, element.y),
             element.angle,
-            [cx, cy],
+            point(cx, cy),
           )
-        : getCurveShape(roughShape, [element.x, element.y], element.angle, [
-            cx,
-            cy,
-          ]);
+        : getCurveShape<Point>(
+            roughShape,
+            point<Point>(element.x, element.y),
+            element.angle,
+            point(cx, cy),
+          );
     }
 
     case "ellipse":
@@ -157,15 +184,19 @@ export const getElementShape = (
 
     case "freedraw": {
       const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap);
-      return getFreedrawShape(element, [cx, cy], shouldTestInside(element));
+      return getFreedrawShape(
+        element,
+        point(cx, cy),
+        shouldTestInside(element),
+      );
     }
   }
 };
 
-export const getBoundTextShape = (
+export const getBoundTextShape = <Point extends GlobalPoint | LocalPoint>(
   element: ExcalidrawElement,
   elementsMap: ElementsMap,
-): GeometricShape | null => {
+): GeometricShape<Point> | null => {
   const boundTextElement = getBoundTextElement(element, elementsMap);
 
   if (boundTextElement) {
@@ -189,3 +220,274 @@ export const getBoundTextShape = (
 
   return null;
 };
+
+export const getControlPointsForBezierCurve = <
+  P extends GlobalPoint | LocalPoint,
+>(
+  element: NonDeleted<ExcalidrawLinearElement>,
+  endPoint: P,
+) => {
+  const shape = ShapeCache.generateElementShape(element, null);
+  if (!shape) {
+    return null;
+  }
+
+  const ops = getCurvePathOps(shape[0]);
+  let currentP = point<P>(0, 0);
+  let index = 0;
+  let minDistance = Infinity;
+  let controlPoints: P[] | null = null;
+
+  while (index < ops.length) {
+    const { op, data } = ops[index];
+    if (op === "move") {
+      invariant(
+        isPoint(data),
+        "The returned ops is not compatible with a point",
+      );
+      currentP = pointFromPair(data);
+    }
+    if (op === "bcurveTo") {
+      const p0 = currentP;
+      const p1 = point<P>(data[0], data[1]);
+      const p2 = point<P>(data[2], data[3]);
+      const p3 = point<P>(data[4], data[5]);
+      const distance = pointDistance(p3, endPoint);
+      if (distance < minDistance) {
+        minDistance = distance;
+        controlPoints = [p0, p1, p2, p3];
+      }
+      currentP = p3;
+    }
+    index++;
+  }
+
+  return controlPoints;
+};
+
+export const getBezierXY = <P extends GlobalPoint | LocalPoint>(
+  p0: P,
+  p1: P,
+  p2: P,
+  p3: P,
+  t: number,
+): P => {
+  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 tx = equation(t, 0);
+  const ty = equation(t, 1);
+  return point(tx, ty);
+};
+
+const getPointsInBezierCurve = <P extends GlobalPoint | LocalPoint>(
+  element: NonDeleted<ExcalidrawLinearElement>,
+  endPoint: P,
+) => {
+  const controlPoints: P[] = getControlPointsForBezierCurve(element, endPoint)!;
+  if (!controlPoints) {
+    return [];
+  }
+  const pointsOnCurve: P[] = [];
+  let t = 1;
+  // Take 20 points on curve for better accuracy
+  while (t > 0) {
+    const p = getBezierXY(
+      controlPoints[0],
+      controlPoints[1],
+      controlPoints[2],
+      controlPoints[3],
+      t,
+    );
+    pointsOnCurve.push(point(p[0], p[1]));
+    t -= 0.05;
+  }
+  if (pointsOnCurve.length) {
+    if (pointsEqual(pointsOnCurve.at(-1)!, endPoint)) {
+      pointsOnCurve.push(point(endPoint[0], endPoint[1]));
+    }
+  }
+  return pointsOnCurve;
+};
+
+const getBezierCurveArcLengths = <P extends GlobalPoint | LocalPoint>(
+  element: NonDeleted<ExcalidrawLinearElement>,
+  endPoint: P,
+) => {
+  const arcLengths: number[] = [];
+  arcLengths[0] = 0;
+  const points = getPointsInBezierCurve(element, endPoint);
+  let index = 0;
+  let distance = 0;
+  while (index < points.length - 1) {
+    const segmentDistance = pointDistance(points[index], points[index + 1]);
+    distance += segmentDistance;
+    arcLengths.push(distance);
+    index++;
+  }
+
+  return arcLengths;
+};
+
+export const getBezierCurveLength = <P extends GlobalPoint | LocalPoint>(
+  element: NonDeleted<ExcalidrawLinearElement>,
+  endPoint: P,
+) => {
+  const arcLengths = getBezierCurveArcLengths(element, endPoint);
+  return arcLengths.at(-1) as number;
+};
+
+// This maps interval to actual interval t on the curve so that when t = 0.5, its actually the point at 50% of the length
+export const mapIntervalToBezierT = <P extends GlobalPoint | LocalPoint>(
+  element: NonDeleted<ExcalidrawLinearElement>,
+  endPoint: P,
+  interval: number, // The interval between 0 to 1 for which you want to find the point on the curve,
+) => {
+  const arcLengths = getBezierCurveArcLengths(element, endPoint);
+  const pointsCount = arcLengths.length - 1;
+  const curveLength = arcLengths.at(-1) as number;
+  const targetLength = interval * curveLength;
+  let low = 0;
+  let high = pointsCount;
+  let index = 0;
+  // Doing a binary search to find the largest length that is less than the target length
+  while (low < high) {
+    index = Math.floor(low + (high - low) / 2);
+    if (arcLengths[index] < targetLength) {
+      low = index + 1;
+    } else {
+      high = index;
+    }
+  }
+  if (arcLengths[index] > targetLength) {
+    index--;
+  }
+  if (arcLengths[index] === targetLength) {
+    return index / pointsCount;
+  }
+
+  return (
+    1 -
+    (index +
+      (targetLength - arcLengths[index]) /
+        (arcLengths[index + 1] - arcLengths[index])) /
+      pointsCount
+  );
+};
+
+/**
+ * Get the axis-aligned bounding box for a given element
+ */
+export const aabbForElement = (
+  element: Readonly<ExcalidrawElement>,
+  offset?: [number, number, number, number],
+) => {
+  const bbox = {
+    minX: element.x,
+    minY: element.y,
+    maxX: element.x + element.width,
+    maxY: element.y + element.height,
+    midX: element.x + element.width / 2,
+    midY: element.y + element.height / 2,
+  };
+
+  const center = point(bbox.midX, bbox.midY);
+  const [topLeftX, topLeftY] = pointRotateRads(
+    point(bbox.minX, bbox.minY),
+    center,
+    element.angle,
+  );
+  const [topRightX, topRightY] = pointRotateRads(
+    point(bbox.maxX, bbox.minY),
+    center,
+    element.angle,
+  );
+  const [bottomRightX, bottomRightY] = pointRotateRads(
+    point(bbox.maxX, bbox.maxY),
+    center,
+    element.angle,
+  );
+  const [bottomLeftX, bottomLeftY] = pointRotateRads(
+    point(bbox.minX, bbox.maxY),
+    center,
+    element.angle,
+  );
+
+  const bounds = [
+    Math.min(topLeftX, topRightX, bottomRightX, bottomLeftX),
+    Math.min(topLeftY, topRightY, bottomRightY, bottomLeftY),
+    Math.max(topLeftX, topRightX, bottomRightX, bottomLeftX),
+    Math.max(topLeftY, topRightY, bottomRightY, bottomLeftY),
+  ] as Bounds;
+
+  if (offset) {
+    const [topOffset, rightOffset, downOffset, leftOffset] = offset;
+    return [
+      bounds[0] - leftOffset,
+      bounds[1] - topOffset,
+      bounds[2] + rightOffset,
+      bounds[3] + downOffset,
+    ] as Bounds;
+  }
+
+  return bounds;
+};
+
+export const pointInsideBounds = <P extends GlobalPoint | LocalPoint>(
+  p: P,
+  bounds: Bounds,
+): boolean =>
+  p[0] > bounds[0] && p[0] < bounds[2] && p[1] > bounds[1] && p[1] < bounds[3];
+
+export const aabbsOverlapping = (a: Bounds, b: Bounds) =>
+  pointInsideBounds(point(a[0], a[1]), b) ||
+  pointInsideBounds(point(a[2], a[1]), b) ||
+  pointInsideBounds(point(a[2], a[3]), b) ||
+  pointInsideBounds(point(a[0], a[3]), b) ||
+  pointInsideBounds(point(b[0], b[1]), a) ||
+  pointInsideBounds(point(b[2], b[1]), a) ||
+  pointInsideBounds(point(b[2], b[3]), a) ||
+  pointInsideBounds(point(b[0], b[3]), a);
+
+export const getCornerRadius = (x: number, element: ExcalidrawElement) => {
+  if (
+    element.roundness?.type === ROUNDNESS.PROPORTIONAL_RADIUS ||
+    element.roundness?.type === ROUNDNESS.LEGACY
+  ) {
+    return x * DEFAULT_PROPORTIONAL_RADIUS;
+  }
+
+  if (element.roundness?.type === ROUNDNESS.ADAPTIVE_RADIUS) {
+    const fixedRadiusSize = element.roundness?.value ?? DEFAULT_ADAPTIVE_RADIUS;
+
+    const CUTOFF_SIZE = fixedRadiusSize / DEFAULT_PROPORTIONAL_RADIUS;
+
+    if (x <= CUTOFF_SIZE) {
+      return x * DEFAULT_PROPORTIONAL_RADIUS;
+    }
+
+    return fixedRadiusSize;
+  }
+
+  return 0;
+};
+
+// Checks if the first and last point are close enough
+// to be considered a loop
+export const isPathALoop = (
+  points: ExcalidrawLinearElement["points"],
+  /** supply if you want the loop detection to account for current zoom */
+  zoomValue: Zoom["value"] = 1 as NormalizedZoomValue,
+): boolean => {
+  if (points.length >= 3) {
+    const [first, last] = [points[0], points[points.length - 1]];
+    const distance = pointDistance(first, last);
+
+    // Adjusting LINE_CONFIRM_THRESHOLD to current zoom so that when zoomed in
+    // really close we make the threshold smaller, and vice versa.
+    return distance <= LINE_CONFIRM_THRESHOLD / zoomValue;
+  }
+  return false;
+};

+ 145 - 132
packages/excalidraw/snapping.ts

@@ -1,3 +1,12 @@
+import type { InclusiveRange } from "../math";
+import {
+  point,
+  pointRotateRads,
+  rangeInclusive,
+  rangeIntersection,
+  rangesOverlap,
+  type GlobalPoint,
+} from "../math";
 import { TOOL_TYPE } from "./constants";
 import type { Bounds } from "./element/bounds";
 import {
@@ -14,7 +23,6 @@ import type {
 } from "./element/types";
 import { getMaximumGroups } from "./groups";
 import { KEYS } from "./keys";
-import { rangeIntersection, rangesOverlap, rotatePoint } from "./math";
 import {
   getSelectedElements,
   getVisibleAndNonSelectedElements,
@@ -23,7 +31,7 @@ import type {
   AppClassProperties,
   AppState,
   KeyboardModifiersObject,
-  Point,
+  NullableGridSize,
 } from "./types";
 
 const SNAP_DISTANCE = 8;
@@ -42,7 +50,7 @@ type Vector2D = {
   y: number;
 };
 
-type PointPair = [Point, Point];
+type PointPair = [GlobalPoint, GlobalPoint];
 
 export type PointSnap = {
   type: "point";
@@ -62,9 +70,9 @@ export type Gap = {
   //                               ↑ end side
   startBounds: Bounds;
   endBounds: Bounds;
-  startSide: [Point, Point];
-  endSide: [Point, Point];
-  overlap: [number, number];
+  startSide: [GlobalPoint, GlobalPoint];
+  endSide: [GlobalPoint, GlobalPoint];
+  overlap: InclusiveRange;
   length: number;
 };
 
@@ -88,7 +96,7 @@ export type Snaps = Snap[];
 
 export type PointSnapLine = {
   type: "points";
-  points: Point[];
+  points: GlobalPoint[];
 };
 
 export type PointerSnapLine = {
@@ -108,14 +116,14 @@ export type SnapLine = PointSnapLine | GapSnapLine | PointerSnapLine;
 // -----------------------------------------------------------------------------
 
 export class SnapCache {
-  private static referenceSnapPoints: Point[] | null = null;
+  private static referenceSnapPoints: GlobalPoint[] | null = null;
 
   private static visibleGaps: {
     verticalGaps: Gap[];
     horizontalGaps: Gap[];
   } | null = null;
 
-  public static setReferenceSnapPoints = (snapPoints: Point[] | null) => {
+  public static setReferenceSnapPoints = (snapPoints: GlobalPoint[] | null) => {
     SnapCache.referenceSnapPoints = snapPoints;
   };
 
@@ -191,8 +199,8 @@ export const getElementsCorners = (
     omitCenter: false,
     boundingBoxCorners: false,
   },
-): Point[] => {
-  let result: Point[] = [];
+): GlobalPoint[] => {
+  let result: GlobalPoint[] = [];
 
   if (elements.length === 1) {
     const element = elements[0];
@@ -219,33 +227,53 @@ export const getElementsCorners = (
       (element.type === "diamond" || element.type === "ellipse") &&
       !boundingBoxCorners
     ) {
-      const leftMid = rotatePoint(
-        [x1, y1 + halfHeight],
-        [cx, cy],
+      const leftMid = pointRotateRads<GlobalPoint>(
+        point(x1, y1 + halfHeight),
+        point(cx, cy),
+        element.angle,
+      );
+      const topMid = pointRotateRads<GlobalPoint>(
+        point(x1 + halfWidth, y1),
+        point(cx, cy),
         element.angle,
       );
-      const topMid = rotatePoint([x1 + halfWidth, y1], [cx, cy], element.angle);
-      const rightMid = rotatePoint(
-        [x2, y1 + halfHeight],
-        [cx, cy],
+      const rightMid = pointRotateRads<GlobalPoint>(
+        point(x2, y1 + halfHeight),
+        point(cx, cy),
         element.angle,
       );
-      const bottomMid = rotatePoint(
-        [x1 + halfWidth, y2],
-        [cx, cy],
+      const bottomMid = pointRotateRads<GlobalPoint>(
+        point(x1 + halfWidth, y2),
+        point(cx, cy),
         element.angle,
       );
-      const center: Point = [cx, cy];
+      const center = point<GlobalPoint>(cx, cy);
 
       result = omitCenter
         ? [leftMid, topMid, rightMid, bottomMid]
         : [leftMid, topMid, rightMid, bottomMid, center];
     } else {
-      const topLeft = rotatePoint([x1, y1], [cx, cy], element.angle);
-      const topRight = rotatePoint([x2, y1], [cx, cy], element.angle);
-      const bottomLeft = rotatePoint([x1, y2], [cx, cy], element.angle);
-      const bottomRight = rotatePoint([x2, y2], [cx, cy], element.angle);
-      const center: Point = [cx, cy];
+      const topLeft = pointRotateRads<GlobalPoint>(
+        point(x1, y1),
+        point(cx, cy),
+        element.angle,
+      );
+      const topRight = pointRotateRads<GlobalPoint>(
+        point(x2, y1),
+        point(cx, cy),
+        element.angle,
+      );
+      const bottomLeft = pointRotateRads<GlobalPoint>(
+        point(x1, y2),
+        point(cx, cy),
+        element.angle,
+      );
+      const bottomRight = pointRotateRads<GlobalPoint>(
+        point(x2, y2),
+        point(cx, cy),
+        element.angle,
+      );
+      const center = point<GlobalPoint>(cx, cy);
 
       result = omitCenter
         ? [topLeft, topRight, bottomLeft, bottomRight]
@@ -259,18 +287,18 @@ export const getElementsCorners = (
     const width = maxX - minX;
     const height = maxY - minY;
 
-    const topLeft: Point = [minX, minY];
-    const topRight: Point = [maxX, minY];
-    const bottomLeft: Point = [minX, maxY];
-    const bottomRight: Point = [maxX, maxY];
-    const center: Point = [minX + width / 2, minY + height / 2];
+    const topLeft = point<GlobalPoint>(minX, minY);
+    const topRight = point<GlobalPoint>(maxX, minY);
+    const bottomLeft = point<GlobalPoint>(minX, maxY);
+    const bottomRight = point<GlobalPoint>(maxX, maxY);
+    const center = point<GlobalPoint>(minX + width / 2, minY + height / 2);
 
     result = omitCenter
       ? [topLeft, topRight, bottomLeft, bottomRight]
       : [topLeft, topRight, bottomLeft, bottomRight, center];
   }
 
-  return result.map((point) => [round(point[0]), round(point[1])] as Point);
+  return result.map((p) => point(round(p[0]), round(p[1])));
 };
 
 const getReferenceElements = (
@@ -339,23 +367,20 @@ export const getVisibleGaps = (
 
       if (
         startMaxX < endMinX &&
-        rangesOverlap([startMinY, startMaxY], [endMinY, endMaxY])
+        rangesOverlap(
+          rangeInclusive(startMinY, startMaxY),
+          rangeInclusive(endMinY, endMaxY),
+        )
       ) {
         horizontalGaps.push({
           startBounds,
           endBounds,
-          startSide: [
-            [startMaxX, startMinY],
-            [startMaxX, startMaxY],
-          ],
-          endSide: [
-            [endMinX, endMinY],
-            [endMinX, endMaxY],
-          ],
+          startSide: [point(startMaxX, startMinY), point(startMaxX, startMaxY)],
+          endSide: [point(endMinX, endMinY), point(endMinX, endMaxY)],
           length: endMinX - startMaxX,
           overlap: rangeIntersection(
-            [startMinY, startMaxY],
-            [endMinY, endMaxY],
+            rangeInclusive(startMinY, startMaxY),
+            rangeInclusive(endMinY, endMaxY),
           )!,
         });
       }
@@ -382,23 +407,20 @@ export const getVisibleGaps = (
 
       if (
         startMaxY < endMinY &&
-        rangesOverlap([startMinX, startMaxX], [endMinX, endMaxX])
+        rangesOverlap(
+          rangeInclusive(startMinX, startMaxX),
+          rangeInclusive(endMinX, endMaxX),
+        )
       ) {
         verticalGaps.push({
           startBounds,
           endBounds,
-          startSide: [
-            [startMinX, startMaxY],
-            [startMaxX, startMaxY],
-          ],
-          endSide: [
-            [endMinX, endMinY],
-            [endMaxX, endMinY],
-          ],
+          startSide: [point(startMinX, startMaxY), point(startMaxX, startMaxY)],
+          endSide: [point(endMinX, endMinY), point(endMaxX, endMinY)],
           length: endMinY - startMaxY,
           overlap: rangeIntersection(
-            [startMinX, startMaxX],
-            [endMinX, endMaxX],
+            rangeInclusive(startMinX, startMaxX),
+            rangeInclusive(endMinX, endMaxX),
           )!,
         });
       }
@@ -441,7 +463,7 @@ const getGapSnaps = (
     const centerY = (minY + maxY) / 2;
 
     for (const gap of horizontalGaps) {
-      if (!rangesOverlap([minY, maxY], gap.overlap)) {
+      if (!rangesOverlap(rangeInclusive(minY, maxY), gap.overlap)) {
         continue;
       }
 
@@ -510,7 +532,7 @@ const getGapSnaps = (
       }
     }
     for (const gap of verticalGaps) {
-      if (!rangesOverlap([minX, maxX], gap.overlap)) {
+      if (!rangesOverlap(rangeInclusive(minX, maxX), gap.overlap)) {
         continue;
       }
 
@@ -603,7 +625,7 @@ export const getReferenceSnapPoints = (
 
 const getPointSnaps = (
   selectedElements: ExcalidrawElement[],
-  selectionSnapPoints: Point[],
+  selectionSnapPoints: GlobalPoint[],
   app: AppClassProperties,
   event: KeyboardModifiersObject,
   nearestSnapsX: Snaps,
@@ -779,8 +801,8 @@ const round = (x: number) => {
   return Math.round(x * 10 ** decimalPlaces) / 10 ** decimalPlaces;
 };
 
-const dedupePoints = (points: Point[]): Point[] => {
-  const map = new Map<string, Point>();
+const dedupePoints = (points: GlobalPoint[]): GlobalPoint[] => {
+  const map = new Map<string, GlobalPoint>();
 
   for (const point of points) {
     const key = point.join(",");
@@ -797,8 +819,8 @@ const createPointSnapLines = (
   nearestSnapsX: Snaps,
   nearestSnapsY: Snaps,
 ): PointSnapLine[] => {
-  const snapsX = {} as { [key: string]: Point[] };
-  const snapsY = {} as { [key: string]: Point[] };
+  const snapsX = {} as { [key: string]: GlobalPoint[] };
+  const snapsY = {} as { [key: string]: GlobalPoint[] };
 
   if (nearestSnapsX.length > 0) {
     for (const snap of nearestSnapsX) {
@@ -809,8 +831,8 @@ const createPointSnapLines = (
           snapsX[key] = [];
         }
         snapsX[key].push(
-          ...snap.points.map(
-            (point) => [round(point[0]), round(point[1])] as Point,
+          ...snap.points.map((p) =>
+            point<GlobalPoint>(round(p[0]), round(p[1])),
           ),
         );
       }
@@ -826,8 +848,8 @@ const createPointSnapLines = (
           snapsY[key] = [];
         }
         snapsY[key].push(
-          ...snap.points.map(
-            (point) => [round(point[0]), round(point[1])] as Point,
+          ...snap.points.map((p) =>
+            point<GlobalPoint>(round(p[0]), round(p[1])),
           ),
         );
       }
@@ -840,8 +862,8 @@ const createPointSnapLines = (
         type: "points",
         points: dedupePoints(
           points
-            .map((point) => {
-              return [Number(key), point[1]] as Point;
+            .map((p) => {
+              return point<GlobalPoint>(Number(key), p[1]);
             })
             .sort((a, b) => a[1] - b[1]),
         ),
@@ -853,8 +875,8 @@ const createPointSnapLines = (
           type: "points",
           points: dedupePoints(
             points
-              .map((point) => {
-                return [point[0], Number(key)] as Point;
+              .map((p) => {
+                return point<GlobalPoint>(p[0], Number(key));
               })
               .sort((a, b) => a[0] - b[0]),
           ),
@@ -898,12 +920,12 @@ const createGapSnapLines = (
     const [endMinX, endMinY, endMaxX, endMaxY] = gapSnap.gap.endBounds;
 
     const verticalIntersection = rangeIntersection(
-      [minY, maxY],
+      rangeInclusive(minY, maxY),
       gapSnap.gap.overlap,
     );
 
     const horizontalGapIntersection = rangeIntersection(
-      [minX, maxX],
+      rangeInclusive(minX, maxX),
       gapSnap.gap.overlap,
     );
 
@@ -918,16 +940,16 @@ const createGapSnapLines = (
               type: "gap",
               direction: "horizontal",
               points: [
-                [gapSnap.gap.startSide[0][0], gapLineY],
-                [minX, gapLineY],
+                point(gapSnap.gap.startSide[0][0], gapLineY),
+                point(minX, gapLineY),
               ],
             },
             {
               type: "gap",
               direction: "horizontal",
               points: [
-                [maxX, gapLineY],
-                [gapSnap.gap.endSide[0][0], gapLineY],
+                point(maxX, gapLineY),
+                point(gapSnap.gap.endSide[0][0], gapLineY),
               ],
             },
           );
@@ -944,16 +966,16 @@ const createGapSnapLines = (
               type: "gap",
               direction: "vertical",
               points: [
-                [gapLineX, gapSnap.gap.startSide[0][1]],
-                [gapLineX, minY],
+                point(gapLineX, gapSnap.gap.startSide[0][1]),
+                point(gapLineX, minY),
               ],
             },
             {
               type: "gap",
               direction: "vertical",
               points: [
-                [gapLineX, maxY],
-                [gapLineX, gapSnap.gap.endSide[0][1]],
+                point(gapLineX, maxY),
+                point(gapLineX, gapSnap.gap.endSide[0][1]),
               ],
             },
           );
@@ -969,18 +991,12 @@ const createGapSnapLines = (
             {
               type: "gap",
               direction: "horizontal",
-              points: [
-                [startMaxX, gapLineY],
-                [endMinX, gapLineY],
-              ],
+              points: [point(startMaxX, gapLineY), point(endMinX, gapLineY)],
             },
             {
               type: "gap",
               direction: "horizontal",
-              points: [
-                [endMaxX, gapLineY],
-                [minX, gapLineY],
-              ],
+              points: [point(endMaxX, gapLineY), point(minX, gapLineY)],
             },
           );
         }
@@ -995,18 +1011,12 @@ const createGapSnapLines = (
             {
               type: "gap",
               direction: "horizontal",
-              points: [
-                [maxX, gapLineY],
-                [startMinX, gapLineY],
-              ],
+              points: [point(maxX, gapLineY), point(startMinX, gapLineY)],
             },
             {
               type: "gap",
               direction: "horizontal",
-              points: [
-                [startMaxX, gapLineY],
-                [endMinX, gapLineY],
-              ],
+              points: [point(startMaxX, gapLineY), point(endMinX, gapLineY)],
             },
           );
         }
@@ -1021,18 +1031,12 @@ const createGapSnapLines = (
             {
               type: "gap",
               direction: "vertical",
-              points: [
-                [gapLineX, maxY],
-                [gapLineX, startMinY],
-              ],
+              points: [point(gapLineX, maxY), point(gapLineX, startMinY)],
             },
             {
               type: "gap",
               direction: "vertical",
-              points: [
-                [gapLineX, startMaxY],
-                [gapLineX, endMinY],
-              ],
+              points: [point(gapLineX, startMaxY), point(gapLineX, endMinY)],
             },
           );
         }
@@ -1047,18 +1051,12 @@ const createGapSnapLines = (
             {
               type: "gap",
               direction: "vertical",
-              points: [
-                [gapLineX, startMaxY],
-                [gapLineX, endMinY],
-              ],
+              points: [point(gapLineX, startMaxY), point(gapLineX, endMinY)],
             },
             {
               type: "gap",
               direction: "vertical",
-              points: [
-                [gapLineX, endMaxY],
-                [gapLineX, minY],
-              ],
+              points: [point(gapLineX, endMaxY), point(gapLineX, minY)],
             },
           );
         }
@@ -1071,8 +1069,8 @@ const createGapSnapLines = (
     gapSnapLines.map((gapSnapLine) => {
       return {
         ...gapSnapLine,
-        points: gapSnapLine.points.map(
-          (point) => [round(point[0]), round(point[1])] as Point,
+        points: gapSnapLine.points.map((p) =>
+          point(round(p[0]), round(p[1])),
         ) as PointPair,
       };
     }),
@@ -1117,40 +1115,40 @@ export const snapResizingElements = (
     }
   }
 
-  const selectionSnapPoints: Point[] = [];
+  const selectionSnapPoints: GlobalPoint[] = [];
 
   if (transformHandle) {
     switch (transformHandle) {
       case "e": {
-        selectionSnapPoints.push([maxX, minY], [maxX, maxY]);
+        selectionSnapPoints.push(point(maxX, minY), point(maxX, maxY));
         break;
       }
       case "w": {
-        selectionSnapPoints.push([minX, minY], [minX, maxY]);
+        selectionSnapPoints.push(point(minX, minY), point(minX, maxY));
         break;
       }
       case "n": {
-        selectionSnapPoints.push([minX, minY], [maxX, minY]);
+        selectionSnapPoints.push(point(minX, minY), point(maxX, minY));
         break;
       }
       case "s": {
-        selectionSnapPoints.push([minX, maxY], [maxX, maxY]);
+        selectionSnapPoints.push(point(minX, maxY), point(maxX, maxY));
         break;
       }
       case "ne": {
-        selectionSnapPoints.push([maxX, minY]);
+        selectionSnapPoints.push(point(maxX, minY));
         break;
       }
       case "nw": {
-        selectionSnapPoints.push([minX, minY]);
+        selectionSnapPoints.push(point(minX, minY));
         break;
       }
       case "se": {
-        selectionSnapPoints.push([maxX, maxY]);
+        selectionSnapPoints.push(point(maxX, maxY));
         break;
       }
       case "sw": {
-        selectionSnapPoints.push([minX, maxY]);
+        selectionSnapPoints.push(point(minX, maxY));
         break;
       }
     }
@@ -1192,11 +1190,11 @@ export const snapResizingElements = (
     round(bound),
   );
 
-  const corners: Point[] = [
-    [x1, y1],
-    [x1, y2],
-    [x2, y1],
-    [x2, y2],
+  const corners: GlobalPoint[] = [
+    point(x1, y1),
+    point(x1, y2),
+    point(x2, y1),
+    point(x2, y2),
   ];
 
   getPointSnaps(
@@ -1232,8 +1230,8 @@ export const snapNewElement = (
     };
   }
 
-  const selectionSnapPoints: Point[] = [
-    [origin.x + dragOffset.x, origin.y + dragOffset.y],
+  const selectionSnapPoints: GlobalPoint[] = [
+    point(origin.x + dragOffset.x, origin.y + dragOffset.y),
   ];
 
   const snapDistance = getSnapDistance(app.state.zoom.value);
@@ -1333,7 +1331,7 @@ export const getSnapLinesAtPointer = (
 
         verticalSnapLines.push({
           type: "pointer",
-          points: [corner, [corner[0], pointer.y]],
+          points: [corner, point(corner[0], pointer.y)],
           direction: "vertical",
         });
 
@@ -1349,7 +1347,7 @@ export const getSnapLinesAtPointer = (
 
         horizontalSnapLines.push({
           type: "pointer",
-          points: [corner, [pointer.x, corner[1]]],
+          points: [corner, point(pointer.x, corner[1])],
           direction: "horizontal",
         });
 
@@ -1386,3 +1384,18 @@ export const isActiveToolNonLinearSnappable = (
     activeToolType === TOOL_TYPE.text
   );
 };
+
+// TODO: Rounding this point causes some shake when free drawing
+export const getGridPoint = (
+  x: number,
+  y: number,
+  gridSize: NullableGridSize,
+): [number, number] => {
+  if (gridSize) {
+    return [
+      Math.round(x / gridSize) * gridSize,
+      Math.round(y / gridSize) * gridSize,
+    ];
+  }
+  return [x, y];
+};

+ 4 - 14
packages/excalidraw/tests/binding.test.tsx

@@ -7,6 +7,7 @@ import { API } from "./helpers/api";
 import { KEYS } from "../keys";
 import { actionWrapTextInContainer } from "../actions/actionBoundText";
 import { arrayToMap } from "../utils";
+import { point } from "../../math";
 
 const { h } = window;
 
@@ -31,12 +32,7 @@ describe("element binding", () => {
       y: 0,
       width: 100,
       height: 1,
-      points: [
-        [0, 0],
-        [0, 0],
-        [100, 0],
-        [100, 0],
-      ],
+      points: [point(0, 0), point(0, 0), point(100, 0), point(100, 0)],
     });
     API.setElements([rect, arrow]);
     expect(arrow.startBinding).toBe(null);
@@ -314,10 +310,7 @@ describe("element binding", () => {
     const arrow1 = API.createElement({
       type: "arrow",
       id: "arrow1",
-      points: [
-        [0, 0],
-        [0, -87.45777932247563],
-      ],
+      points: [point(0, 0), point(0, -87.45777932247563)],
       startBinding: {
         elementId: "rectangle1",
         focus: 0.2,
@@ -335,10 +328,7 @@ describe("element binding", () => {
     const arrow2 = API.createElement({
       type: "arrow",
       id: "arrow2",
-      points: [
-        [0, 0],
-        [0, -87.45777932247563],
-      ],
+      points: [point(0, 0), point(0, -87.45777932247563)],
       startBinding: {
         elementId: "text1",
         focus: 0.2,

+ 2 - 1
packages/excalidraw/tests/fixtures/elementFixture.ts

@@ -1,3 +1,4 @@
+import type { Radians } from "../../../math";
 import { DEFAULT_FONT_FAMILY } from "../../constants";
 import type { ExcalidrawElement } from "../../element/types";
 
@@ -7,7 +8,7 @@ const elementBase: Omit<ExcalidrawElement, "type"> = {
   y: 237,
   width: 214,
   height: 214,
-  angle: 0,
+  angle: 0 as Radians,
   strokeColor: "#000000",
   backgroundColor: "#15aabf",
   fillStyle: "hachure",

+ 26 - 24
packages/excalidraw/tests/flip.test.tsx

@@ -28,6 +28,8 @@ import { KEYS } from "../keys";
 import { getBoundTextElementPosition } from "../element/textElement";
 import { createPasteEvent } from "../clipboard";
 import { arrayToMap, cloneJSON } from "../utils";
+import type { LocalPoint } from "../../math";
+import { point, type Radians } from "../../math";
 
 const { h } = window;
 const mouse = new Pointer("mouse");
@@ -131,7 +133,7 @@ const createLinearElementWithCurveInsideMinMaxPoints = (
     y: -2412.5069664197654,
     width: 1750.4888916015625,
     height: 410.51605224609375,
-    angle: 0,
+    angle: 0 as Radians,
     strokeColor: "#000000",
     backgroundColor: "#fa5252",
     fillStyle: "hachure",
@@ -145,9 +147,9 @@ const createLinearElementWithCurveInsideMinMaxPoints = (
     link: null,
     locked: false,
     points: [
-      [0, 0],
-      [-922.4761962890625, 300.3277587890625],
-      [828.0126953125, 410.51605224609375],
+      point<LocalPoint>(0, 0),
+      point<LocalPoint>(-922.4761962890625, 300.3277587890625),
+      point<LocalPoint>(828.0126953125, 410.51605224609375),
     ],
   });
 };
@@ -423,8 +425,8 @@ describe("arrow", () => {
   });
 
   it("flips a rotated arrow horizontally with line inside min/max points bounds", async () => {
-    const originalAngle = Math.PI / 4;
-    const expectedAngle = (7 * Math.PI) / 4;
+    const originalAngle = (Math.PI / 4) as Radians;
+    const expectedAngle = ((7 * Math.PI) / 4) as Radians;
     const line = createLinearElementWithCurveInsideMinMaxPoints("arrow");
     API.setElements([line]);
     API.setAppState({
@@ -444,8 +446,8 @@ describe("arrow", () => {
   });
 
   it("flips a rotated arrow vertically with line inside min/max points bounds", async () => {
-    const originalAngle = Math.PI / 4;
-    const expectedAngle = (7 * Math.PI) / 4;
+    const originalAngle = (Math.PI / 4) as Radians;
+    const expectedAngle = ((7 * Math.PI) / 4) as Radians;
     const line = createLinearElementWithCurveInsideMinMaxPoints("arrow");
     API.setElements([line]);
     API.setAppState({
@@ -477,8 +479,8 @@ describe("arrow", () => {
 
   //TODO: elements with curve outside minMax points have a wrong bounding box!!!
   it.skip("flips a rotated arrow horizontally with line outside min/max points bounds", async () => {
-    const originalAngle = Math.PI / 4;
-    const expectedAngle = (7 * Math.PI) / 4;
+    const originalAngle = (Math.PI / 4) as Radians;
+    const expectedAngle = ((7 * Math.PI) / 4) as Radians;
     const line = createLinearElementsWithCurveOutsideMinMaxPoints("arrow");
     API.updateElement(line, { angle: originalAngle });
     API.setElements([line]);
@@ -501,8 +503,8 @@ describe("arrow", () => {
 
   //TODO: elements with curve outside minMax points have a wrong bounding box!!!
   it.skip("flips a rotated arrow vertically with line outside min/max points bounds", async () => {
-    const originalAngle = Math.PI / 4;
-    const expectedAngle = (7 * Math.PI) / 4;
+    const originalAngle = (Math.PI / 4) as Radians;
+    const expectedAngle = ((7 * Math.PI) / 4) as Radians;
     const line = createLinearElementsWithCurveOutsideMinMaxPoints("arrow");
     API.updateElement(line, { angle: originalAngle });
     API.setElements([line]);
@@ -585,8 +587,8 @@ describe("line", () => {
 
   //TODO: elements with curve outside minMax points have a wrong bounding box
   it.skip("flips a rotated line horizontally with line outside min/max points bounds", async () => {
-    const originalAngle = Math.PI / 4;
-    const expectedAngle = (7 * Math.PI) / 4;
+    const originalAngle = (Math.PI / 4) as Radians;
+    const expectedAngle = ((7 * Math.PI) / 4) as Radians;
     const line = createLinearElementsWithCurveOutsideMinMaxPoints("line");
     API.updateElement(line, { angle: originalAngle });
     API.setElements([line]);
@@ -600,8 +602,8 @@ describe("line", () => {
 
   //TODO: elements with curve outside minMax points have a wrong bounding box
   it.skip("flips a rotated line vertically with line outside min/max points bounds", async () => {
-    const originalAngle = Math.PI / 4;
-    const expectedAngle = (7 * Math.PI) / 4;
+    const originalAngle = (Math.PI / 4) as Radians;
+    const expectedAngle = ((7 * Math.PI) / 4) as Radians;
     const line = createLinearElementsWithCurveOutsideMinMaxPoints("line");
     API.updateElement(line, { angle: originalAngle });
     API.setElements([line]);
@@ -619,8 +621,8 @@ describe("line", () => {
   });
 
   it("flips a rotated line horizontally with line inside min/max points bounds", async () => {
-    const originalAngle = Math.PI / 4;
-    const expectedAngle = (7 * Math.PI) / 4;
+    const originalAngle = (Math.PI / 4) as Radians;
+    const expectedAngle = ((7 * Math.PI) / 4) as Radians;
     const line = createLinearElementWithCurveInsideMinMaxPoints("line");
     API.setElements([line]);
     API.setAppState({
@@ -640,8 +642,8 @@ describe("line", () => {
   });
 
   it("flips a rotated line vertically with line inside min/max points bounds", async () => {
-    const originalAngle = Math.PI / 4;
-    const expectedAngle = (7 * Math.PI) / 4;
+    const originalAngle = (Math.PI / 4) as Radians;
+    const expectedAngle = ((7 * Math.PI) / 4) as Radians;
     const line = createLinearElementWithCurveInsideMinMaxPoints("line");
     API.setElements([line]);
     API.setAppState({
@@ -772,8 +774,8 @@ describe("image", () => {
   });
 
   it("flips an rotated image horizontally correctly", async () => {
-    const originalAngle = Math.PI / 4;
-    const expectedAngle = (7 * Math.PI) / 4;
+    const originalAngle = (Math.PI / 4) as Radians;
+    const expectedAngle = ((7 * Math.PI) / 4) as Radians;
     //paste image
     await createImage();
     await waitFor(() => {
@@ -790,8 +792,8 @@ describe("image", () => {
   });
 
   it("flips an rotated image vertically correctly", async () => {
-    const originalAngle = Math.PI / 4;
-    const expectedAngle = (7 * Math.PI) / 4;
+    const originalAngle = (Math.PI / 4) as Radians;
+    const expectedAngle = ((7 * Math.PI) / 4) as Radians;
     //paste image
     await createImage();
     await waitFor(() => {

+ 8 - 7
packages/excalidraw/tests/helpers/api.ts

@@ -27,7 +27,7 @@ import {
   newImageElement,
   newMagicFrameElement,
 } from "../../element/newElement";
-import type { AppState, Point } from "../../types";
+import type { AppState } from "../../types";
 import { getSelectedElements } from "../../scene/selection";
 import { isLinearElementType } from "../../element/typeChecks";
 import type { Mutable } from "../../utility-types";
@@ -36,6 +36,7 @@ import type App from "../../components/App";
 import { createTestHook } from "../../components/App";
 import type { Action } from "../../actions/types";
 import { mutateElement } from "../../element/mutateElement";
+import { point, type LocalPoint, type Radians } from "../../../math";
 
 const readFile = util.promisify(fs.readFile);
 // so that window.h is available when App.tsx is not imported as well.
@@ -171,7 +172,7 @@ export class API {
     containerId?: T extends "text"
       ? ExcalidrawTextElement["containerId"]
       : never;
-    points?: T extends "arrow" | "line" ? readonly Point[] : never;
+    points?: T extends "arrow" | "line" ? readonly LocalPoint[] : never;
     locked?: boolean;
     fileId?: T extends "image" ? string : never;
     scale?: T extends "image" ? ExcalidrawImageElement["scale"] : never;
@@ -218,7 +219,7 @@ export class API {
       y,
       frameId: rest.frameId ?? null,
       index: rest.index ?? null,
-      angle: rest.angle ?? 0,
+      angle: (rest.angle ?? 0) as Radians,
       strokeColor: rest.strokeColor ?? appState.currentItemStrokeColor,
       backgroundColor:
         rest.backgroundColor ?? appState.currentItemBackgroundColor,
@@ -293,8 +294,8 @@ export class API {
           height,
           type,
           points: rest.points ?? [
-            [0, 0],
-            [100, 100],
+            point<LocalPoint>(0, 0),
+            point<LocalPoint>(100, 100),
           ],
           elbowed: rest.elbowed ?? false,
         });
@@ -306,8 +307,8 @@ export class API {
           height,
           type,
           points: rest.points ?? [
-            [0, 0],
-            [100, 100],
+            point<LocalPoint>(0, 0),
+            point<LocalPoint>(100, 100),
           ],
         });
         break;

+ 17 - 14
packages/excalidraw/tests/helpers/ui.ts

@@ -1,4 +1,4 @@
-import type { Point, ToolType } from "../../types";
+import type { ToolType } from "../../types";
 import type {
   ExcalidrawElement,
   ExcalidrawLinearElement,
@@ -30,10 +30,11 @@ import {
   isFrameLikeElement,
 } from "../../element/typeChecks";
 import { getCommonBounds, getElementPointsCoords } from "../../element/bounds";
-import { rotatePoint } from "../../math";
 import { getTextEditor } from "../queries/dom";
 import { arrayToMap } from "../../utils";
 import { createTestHook } from "../../components/App";
+import type { GlobalPoint, LocalPoint, Radians } from "../../../math";
+import { point, pointRotateRads } from "../../../math";
 
 // so that window.h is available when App.tsx is not imported as well.
 createTestHook();
@@ -131,27 +132,29 @@ export class Keyboard {
   };
 }
 
-const getElementPointForSelection = (element: ExcalidrawElement): Point => {
+const getElementPointForSelection = (
+  element: ExcalidrawElement,
+): GlobalPoint => {
   const { x, y, width, height, angle } = element;
-  const target: Point = [
+  const target = point<GlobalPoint>(
     x +
       (isLinearElement(element) || isFreeDrawElement(element) ? 0 : width / 2),
     y,
-  ];
-  let center: Point;
+  );
+  let center: GlobalPoint;
 
   if (isLinearElement(element)) {
     const bounds = getElementPointsCoords(element, element.points);
-    center = [(bounds[0] + bounds[2]) / 2, (bounds[1] + bounds[3]) / 2];
+    center = point((bounds[0] + bounds[2]) / 2, (bounds[1] + bounds[3]) / 2);
   } else {
-    center = [x + width / 2, y + height / 2];
+    center = point(x + width / 2, y + height / 2);
   }
 
   if (isTextElement(element)) {
     return center;
   }
 
-  return rotatePoint(target, center, angle);
+  return pointRotateRads(target, center, angle);
 };
 
 export class Pointer {
@@ -328,7 +331,7 @@ const transform = (
     const isFrameSelected = elements.some(isFrameLikeElement);
     const transformHandles = getTransformHandlesFromCoords(
       [x1, y1, x2, y2, (x1 + x2) / 2, (y1 + y2) / 2],
-      0,
+      0 as Radians,
       h.state.zoom,
       "mouse",
       isFrameSelected ? OMIT_SIDES_FOR_FRAME : OMIT_SIDES_FOR_MULTIPLE_ELEMENTS,
@@ -450,7 +453,7 @@ export class UI {
       width?: number;
       height?: number;
       angle?: number;
-      points?: T extends "line" | "arrow" | "freedraw" ? Point[] : never;
+      points?: T extends "line" | "arrow" | "freedraw" ? LocalPoint[] : never;
     } = {},
   ): Element<T> & {
     /** Returns the actual, current element from the elements array, instead
@@ -459,9 +462,9 @@ export class UI {
   } {
     const width = initialWidth ?? initialHeight ?? size;
     const height = initialHeight ?? size;
-    const points: Point[] = initialPoints ?? [
-      [0, 0],
-      [width, height],
+    const points: LocalPoint[] = initialPoints ?? [
+      point(0, 0),
+      point(width, height),
     ];
 
     UI.clickTool(type);

+ 13 - 11
packages/excalidraw/tests/history.test.tsx

@@ -44,6 +44,8 @@ import { queryByText } from "@testing-library/react";
 import { HistoryEntry } from "../history";
 import { AppStateChange, ElementsChange } from "../change";
 import { Snapshot, StoreAction } from "../store";
+import type { LocalPoint, Radians } from "../../math";
+import { point } from "../../math";
 
 const { h } = window;
 
@@ -2038,9 +2040,9 @@ describe("history", () => {
             width: 178.9000000000001,
             height: 236.10000000000002,
             points: [
-              [0, 0],
-              [178.9000000000001, 0],
-              [178.9000000000001, 236.10000000000002],
+              point(0, 0),
+              point(178.9000000000001, 0),
+              point(178.9000000000001, 236.10000000000002),
             ],
             startBinding: {
               elementId: "KPrBI4g_v9qUB1XxYLgSz",
@@ -2156,12 +2158,12 @@ describe("history", () => {
         elements: [
           newElementWith(h.elements[0] as ExcalidrawLinearElement, {
             points: [
-              [0, 0],
-              [5, 5],
-              [10, 10],
-              [15, 15],
-              [20, 20],
-            ],
+              point(0, 0),
+              point(5, 5),
+              point(10, 10),
+              point(15, 15),
+              point(20, 20),
+            ] as LocalPoint[],
           }),
         ],
         storeAction: StoreAction.UPDATE,
@@ -4003,7 +4005,7 @@ describe("history", () => {
             newElementWith(h.elements[0], {
               x: 200,
               y: 200,
-              angle: 90,
+              angle: 90 as Radians,
             }),
           ],
           storeAction: StoreAction.CAPTURE,
@@ -4121,7 +4123,7 @@ describe("history", () => {
             newElementWith(h.elements[0], {
               x: 205,
               y: 205,
-              angle: 90,
+              angle: 90 as Radians,
             }),
           ],
           storeAction: StoreAction.CAPTURE,

+ 89 - 74
packages/excalidraw/tests/linearElementEditor.test.tsx

@@ -8,7 +8,6 @@ import type {
   SceneElementsMap,
 } from "../element/types";
 import { Excalidraw, mutateElement } from "../index";
-import { centerPoint } from "../math";
 import { reseed } from "../random";
 import * as StaticScene from "../renderer/staticScene";
 import * as InteractiveCanvas from "../renderer/interactiveScene";
@@ -16,7 +15,6 @@ import * as InteractiveCanvas from "../renderer/interactiveScene";
 import { Keyboard, Pointer, UI } from "./helpers/ui";
 import { screen, render, fireEvent, GlobalTestState } from "./test-utils";
 import { API } from "../tests/helpers/api";
-import type { Point } from "../types";
 import { KEYS } from "../keys";
 import { LinearElementEditor } from "../element/linearElementEditor";
 import { act, queryByTestId, queryByText } from "@testing-library/react";
@@ -29,6 +27,8 @@ import * as textElementUtils from "../element/textElement";
 import { ROUNDNESS, VERTICAL_ALIGN } from "../constants";
 import { vi } from "vitest";
 import { arrayToMap } from "../utils";
+import type { GlobalPoint } from "../../math";
+import { pointCenter, point } from "../../math";
 
 const renderInteractiveScene = vi.spyOn(
   InteractiveCanvas,
@@ -57,9 +57,9 @@ describe("Test Linear Elements", () => {
     interactiveCanvas = container.querySelector("canvas.interactive")!;
   });
 
-  const p1: Point = [20, 20];
-  const p2: Point = [60, 20];
-  const midpoint = centerPoint(p1, p2);
+  const p1 = point<GlobalPoint>(20, 20);
+  const p2 = point<GlobalPoint>(60, 20);
+  const midpoint = pointCenter<GlobalPoint>(p1, p2);
   const delta = 50;
   const mouse = new Pointer("mouse");
 
@@ -75,10 +75,7 @@ describe("Test Linear Elements", () => {
       height: 0,
       type,
       roughness,
-      points: [
-        [0, 0],
-        [p2[0] - p1[0], p2[1] - p1[1]],
-      ],
+      points: [point(0, 0), point(p2[0] - p1[0], p2[1] - p1[1])],
       roundness,
     });
     API.setElements([line]);
@@ -102,9 +99,9 @@ describe("Test Linear Elements", () => {
       type,
       roughness,
       points: [
-        [0, 0],
-        [p3[0], p3[1]],
-        [p2[0] - p1[0], p2[1] - p1[1]],
+        point(0, 0),
+        point(p3[0], p3[1]),
+        point(p2[0] - p1[0], p2[1] - p1[1]),
       ],
       roundness,
     });
@@ -129,7 +126,7 @@ describe("Test Linear Elements", () => {
     expect(h.state.editingLinearElement?.elementId).toEqual(line.id);
   };
 
-  const drag = (startPoint: Point, endPoint: Point) => {
+  const drag = (startPoint: GlobalPoint, endPoint: GlobalPoint) => {
     fireEvent.pointerDown(interactiveCanvas, {
       clientX: startPoint[0],
       clientY: startPoint[1],
@@ -144,7 +141,7 @@ describe("Test Linear Elements", () => {
     });
   };
 
-  const deletePoint = (point: Point) => {
+  const deletePoint = (point: GlobalPoint) => {
     fireEvent.pointerDown(interactiveCanvas, {
       clientX: point[0],
       clientY: point[1],
@@ -164,7 +161,7 @@ describe("Test Linear Elements", () => {
     expect(line.points.length).toEqual(2);
 
     mouse.clickAt(midpoint[0], midpoint[1]);
-    drag(midpoint, [midpoint[0] + 1, midpoint[1] + 1]);
+    drag(midpoint, point(midpoint[0] + 1, midpoint[1] + 1));
 
     expect(line.points.length).toEqual(2);
 
@@ -172,7 +169,7 @@ describe("Test Linear Elements", () => {
     expect(line.y).toBe(originalY);
     expect(line.points.length).toEqual(2);
 
-    drag(midpoint, [midpoint[0] + delta, midpoint[1] + delta]);
+    drag(midpoint, point(midpoint[0] + delta, midpoint[1] + delta));
     expect(line.x).toBe(originalX);
     expect(line.y).toBe(originalY);
     expect(line.points.length).toEqual(3);
@@ -187,7 +184,7 @@ describe("Test Linear Elements", () => {
     expect((h.elements[0] as ExcalidrawLinearElement).points.length).toEqual(2);
 
     // drag line from midpoint
-    drag(midpoint, [midpoint[0] + delta, midpoint[1] + delta]);
+    drag(midpoint, point(midpoint[0] + delta, midpoint[1] + delta));
     expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(`9`);
     expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
     expect(line.points.length).toEqual(3);
@@ -251,7 +248,7 @@ describe("Test Linear Elements", () => {
       mouse.clickAt(midpoint[0], midpoint[1]);
       expect(line.points.length).toEqual(2);
 
-      drag(midpoint, [midpoint[0] + 1, midpoint[1] + 1]);
+      drag(midpoint, point(midpoint[0] + 1, midpoint[1] + 1));
       expect(line.x).toBe(originalX);
       expect(line.y).toBe(originalY);
       expect(line.points.length).toEqual(3);
@@ -264,7 +261,7 @@ describe("Test Linear Elements", () => {
       enterLineEditingMode(line);
 
       // drag line from midpoint
-      drag(midpoint, [midpoint[0] + delta, midpoint[1] + delta]);
+      drag(midpoint, point(midpoint[0] + delta, midpoint[1] + delta));
       expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
         `12`,
       );
@@ -356,10 +353,13 @@ describe("Test Linear Elements", () => {
         h.state,
       );
 
-      const startPoint = centerPoint(points[0], midPoints[0] as Point);
+      const startPoint = pointCenter(points[0], midPoints[0]!);
       const deltaX = 50;
       const deltaY = 20;
-      const endPoint: Point = [startPoint[0] + deltaX, startPoint[1] + deltaY];
+      const endPoint = point<GlobalPoint>(
+        startPoint[0] + deltaX,
+        startPoint[1] + deltaY,
+      );
 
       // Move the element
       drag(startPoint, endPoint);
@@ -399,8 +399,8 @@ describe("Test Linear Elements", () => {
       // This is the expected midpoint for line with round edge
       // hence hardcoding it so if later some bug is introduced
       // this will fail and we can fix it
-      const firstSegmentMidpoint: Point = [55, 45];
-      const lastSegmentMidpoint: Point = [75, 40];
+      const firstSegmentMidpoint = point<GlobalPoint>(55, 45);
+      const lastSegmentMidpoint = point<GlobalPoint>(75, 40);
 
       let line: ExcalidrawLinearElement;
 
@@ -414,17 +414,20 @@ describe("Test Linear Elements", () => {
 
       it("should allow dragging lines from midpoints in between segments", async () => {
         // drag line via first segment midpoint
-        drag(firstSegmentMidpoint, [
-          firstSegmentMidpoint[0] + delta,
-          firstSegmentMidpoint[1] + delta,
-        ]);
+        drag(
+          firstSegmentMidpoint,
+          point(
+            firstSegmentMidpoint[0] + delta,
+            firstSegmentMidpoint[1] + delta,
+          ),
+        );
         expect(line.points.length).toEqual(4);
 
         // drag line from last segment midpoint
-        drag(lastSegmentMidpoint, [
-          lastSegmentMidpoint[0] + delta,
-          lastSegmentMidpoint[1] + delta,
-        ]);
+        drag(
+          lastSegmentMidpoint,
+          point(lastSegmentMidpoint[0] + delta, lastSegmentMidpoint[1] + delta),
+        );
 
         expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
           `16`,
@@ -472,10 +475,10 @@ describe("Test Linear Elements", () => {
           h.state,
         );
 
-        const hitCoords: Point = [points[0][0], points[0][1]];
+        const hitCoords = point<GlobalPoint>(points[0][0], points[0][1]);
 
         // Drag from first point
-        drag(hitCoords, [hitCoords[0] - delta, hitCoords[1] - delta]);
+        drag(hitCoords, point(hitCoords[0] - delta, hitCoords[1] - delta));
 
         expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
           `12`,
@@ -513,10 +516,10 @@ describe("Test Linear Elements", () => {
           h.state,
         );
 
-        const hitCoords: Point = [points[0][0], points[0][1]];
+        const hitCoords = point<GlobalPoint>(points[0][0], points[0][1]);
 
         // Drag from first point
-        drag(hitCoords, [hitCoords[0] + delta, hitCoords[1] + delta]);
+        drag(hitCoords, point(hitCoords[0] + delta, hitCoords[1] + delta));
 
         expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
           `12`,
@@ -551,10 +554,10 @@ describe("Test Linear Elements", () => {
         );
 
         // dragging line from last segment midpoint
-        drag(lastSegmentMidpoint, [
-          lastSegmentMidpoint[0] + 50,
-          lastSegmentMidpoint[1] + 50,
-        ]);
+        drag(
+          lastSegmentMidpoint,
+          point(lastSegmentMidpoint[0] + 50, lastSegmentMidpoint[1] + 50),
+        );
         expect(line.points.length).toEqual(4);
 
         const midPoints = LinearElementEditor.getEditorMidPoints(
@@ -586,12 +589,14 @@ describe("Test Linear Elements", () => {
       // This is the expected midpoint for line with round edge
       // hence hardcoding it so if later some bug is introduced
       // this will fail and we can fix it
-      const firstSegmentMidpoint: Point = [
-        55.9697848965255, 47.442326230998205,
-      ];
-      const lastSegmentMidpoint: Point = [
-        76.08587175006699, 43.294165939653226,
-      ];
+      const firstSegmentMidpoint = point<GlobalPoint>(
+        55.9697848965255,
+        47.442326230998205,
+      );
+      const lastSegmentMidpoint = point<GlobalPoint>(
+        76.08587175006699,
+        43.294165939653226,
+      );
       let line: ExcalidrawLinearElement;
 
       beforeEach(() => {
@@ -605,17 +610,20 @@ describe("Test Linear Elements", () => {
 
       it("should allow dragging lines from midpoints in between segments", async () => {
         // drag line from first segment midpoint
-        drag(firstSegmentMidpoint, [
-          firstSegmentMidpoint[0] + delta,
-          firstSegmentMidpoint[1] + delta,
-        ]);
+        drag(
+          firstSegmentMidpoint,
+          point(
+            firstSegmentMidpoint[0] + delta,
+            firstSegmentMidpoint[1] + delta,
+          ),
+        );
         expect(line.points.length).toEqual(4);
 
         // drag line from last segment midpoint
-        drag(lastSegmentMidpoint, [
-          lastSegmentMidpoint[0] + delta,
-          lastSegmentMidpoint[1] + delta,
-        ]);
+        drag(
+          lastSegmentMidpoint,
+          point(lastSegmentMidpoint[0] + delta, lastSegmentMidpoint[1] + delta),
+        );
         expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
           `16`,
         );
@@ -661,10 +669,10 @@ describe("Test Linear Elements", () => {
           h.state,
         );
 
-        const hitCoords: Point = [points[0][0], points[0][1]];
+        const hitCoords = point<GlobalPoint>(points[0][0], points[0][1]);
 
         // Drag from first point
-        drag(hitCoords, [hitCoords[0] - delta, hitCoords[1] - delta]);
+        drag(hitCoords, point(hitCoords[0] - delta, hitCoords[1] - delta));
 
         const newPoints = LinearElementEditor.getPointsGlobalCoordinates(
           line,
@@ -709,10 +717,10 @@ describe("Test Linear Elements", () => {
           h.state,
         );
 
-        const hitCoords: Point = [points[0][0], points[0][1]];
+        const hitCoords = point<GlobalPoint>(points[0][0], points[0][1]);
 
         // Drag from first point
-        drag(hitCoords, [hitCoords[0] + delta, hitCoords[1] + delta]);
+        drag(hitCoords, point(hitCoords[0] + delta, hitCoords[1] + delta));
 
         expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
           `12`,
@@ -741,10 +749,10 @@ describe("Test Linear Elements", () => {
       it("should update all the midpoints when a point is deleted", async () => {
         const elementsMap = arrayToMap(h.elements);
 
-        drag(lastSegmentMidpoint, [
-          lastSegmentMidpoint[0] + delta,
-          lastSegmentMidpoint[1] + delta,
-        ]);
+        drag(
+          lastSegmentMidpoint,
+          point(lastSegmentMidpoint[0] + delta, lastSegmentMidpoint[1] + delta),
+        );
         expect(line.points.length).toEqual(4);
 
         const midPoints = LinearElementEditor.getEditorMidPoints(
@@ -803,8 +811,11 @@ describe("Test Linear Elements", () => {
       API.setSelectedElements([line]);
       enterLineEditingMode(line, true);
       drag(
-        [line.points[0][0] + line.x, line.points[0][1] + line.y],
-        [dragEndPositionOffset[0] + line.x, dragEndPositionOffset[1] + line.y],
+        point(line.points[0][0] + line.x, line.points[0][1] + line.y),
+        point(
+          dragEndPositionOffset[0] + line.x,
+          dragEndPositionOffset[1] + line.y,
+        ),
       );
       expect(line.points).toMatchInlineSnapshot(`
         [
@@ -916,14 +927,18 @@ describe("Test Linear Elements", () => {
         // This is the expected midpoint for line with round edge
         // hence hardcoding it so if later some bug is introduced
         // this will fail and we can fix it
-        const firstSegmentMidpoint: Point = [
-          55.9697848965255, 47.442326230998205,
-        ];
+        const firstSegmentMidpoint = point<GlobalPoint>(
+          55.9697848965255,
+          47.442326230998205,
+        );
         // drag line from first segment midpoint
-        drag(firstSegmentMidpoint, [
-          firstSegmentMidpoint[0] + delta,
-          firstSegmentMidpoint[1] + delta,
-        ]);
+        drag(
+          firstSegmentMidpoint,
+          point(
+            firstSegmentMidpoint[0] + delta,
+            firstSegmentMidpoint[1] + delta,
+          ),
+        );
 
         const position = LinearElementEditor.getBoundTextElementPosition(
           container,
@@ -1136,7 +1151,7 @@ describe("Test Linear Elements", () => {
       );
 
       // Drag from last point
-      drag(points[1], [points[1][0] + 300, points[1][1]]);
+      drag(points[1], point(points[1][0] + 300, points[1][1]));
 
       expect({ width: container.width, height: container.height })
         .toMatchInlineSnapshot(`
@@ -1335,14 +1350,14 @@ describe("Test Linear Elements", () => {
           [
             {
               index: 0,
-              point: [line.points[0][0] + 10, line.points[0][1] + 10],
+              point: point(line.points[0][0] + 10, line.points[0][1] + 10),
             },
             {
               index: line.points.length - 1,
-              point: [
+              point: point(
                 line.points[line.points.length - 1][0] - 10,
                 line.points[line.points.length - 1][1] - 10,
-              ],
+              ),
             },
           ],
           new Map() as SceneElementsMap,

+ 32 - 36
packages/excalidraw/tests/resize.test.tsx

@@ -7,7 +7,6 @@ import type {
   ExcalidrawFreeDrawElement,
   ExcalidrawLinearElement,
 } from "../element/types";
-import type { Point } from "../types";
 import type { Bounds } from "../element/bounds";
 import { getElementPointsCoords } from "../element/bounds";
 import { Excalidraw } from "../index";
@@ -16,6 +15,8 @@ import { KEYS } from "../keys";
 import { isLinearElement } from "../element/typeChecks";
 import { LinearElementEditor } from "../element/linearElementEditor";
 import { arrayToMap } from "../utils";
+import type { LocalPoint } from "../../math";
+import { point } from "../../math";
 
 ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
 
@@ -217,18 +218,13 @@ describe("generic element", () => {
 });
 
 describe.each(["line", "freedraw"] as const)("%s element", (type) => {
-  const points: Record<typeof type, Point[]> = {
-    line: [
-      [0, 0],
-      [60, -20],
-      [20, 40],
-      [-40, 0],
-    ],
+  const points: Record<typeof type, LocalPoint[]> = {
+    line: [point(0, 0), point(60, -20), point(20, 40), point(-40, 0)],
     freedraw: [
-      [0, 0],
-      [-2.474600807561444, 41.021700699972],
-      [3.6627956000014024, 47.84174560617245],
-      [40.495224145598115, 47.15909710753482],
+      point(0, 0),
+      point(-2.474600807561444, 41.021700699972),
+      point(3.6627956000014024, 47.84174560617245),
+      point(40.495224145598115, 47.15909710753482),
     ],
   };
 
@@ -296,11 +292,11 @@ describe("arrow element", () => {
   it("resizes with a label", async () => {
     const arrow = UI.createElement("arrow", {
       points: [
-        [0, 0],
-        [40, 140],
-        [80, 60], // label's anchor
-        [180, 20],
-        [200, 120],
+        point(0, 0),
+        point(40, 140),
+        point(80, 60), // label's anchor
+        point(180, 20),
+        point(200, 120),
       ],
     });
     const label = await UI.editText(arrow, "Hello");
@@ -694,24 +690,24 @@ describe("multiple selection", () => {
       x: 60,
       y: 40,
       points: [
-        [0, 0],
-        [-40, 40],
-        [-60, 0],
-        [0, -40],
-        [40, 20],
-        [0, 40],
+        point(0, 0),
+        point(-40, 40),
+        point(-60, 0),
+        point(0, -40),
+        point(40, 20),
+        point(0, 40),
       ],
     });
     const freedraw = UI.createElement("freedraw", {
       x: 63.56072661326618,
       y: 100,
       points: [
-        [0, 0],
-        [-43.56072661326618, 18.15048126846341],
-        [-43.56072661326618, 29.041198460587566],
-        [-38.115368017204105, 42.652452795512204],
-        [-19.964886748740696, 66.24829266003775],
-        [19.056612930986716, 77.1390098521619],
+        point(0, 0),
+        point(-43.56072661326618, 18.15048126846341),
+        point(-43.56072661326618, 29.041198460587566),
+        point(-38.115368017204105, 42.652452795512204),
+        point(-19.964886748740696, 66.24829266003775),
+        point(19.056612930986716, 77.1390098521619),
       ],
     });
 
@@ -1050,13 +1046,13 @@ describe("multiple selection", () => {
       x: 60,
       y: 0,
       points: [
-        [0, 0],
-        [-40, 40],
-        [-20, 60],
-        [20, 20],
-        [40, 40],
-        [-20, 100],
-        [-60, 60],
+        point(0, 0),
+        point(-40, 40),
+        point(-20, 60),
+        point(20, 20),
+        point(40, 40),
+        point(-20, 100),
+        point(-60, 60),
       ],
     });
 

+ 0 - 3
packages/excalidraw/types.ts

@@ -24,7 +24,6 @@ import type {
   ExcalidrawNonSelectionElement,
 } from "./element/types";
 import type { Action } from "./actions/types";
-import type { Point as RoughPoint } from "roughjs/bin/geometry";
 import type { LinearElementEditor } from "./element/linearElementEditor";
 import type { SuggestedBinding } from "./element/binding";
 import type { ImportedDataState } from "./data/types";
@@ -43,8 +42,6 @@ import type { SnapLine } from "./snapping";
 import type { Merge, MaybePromise, ValueOf, MakeBrand } from "./utility-types";
 import type { StoreActionType } from "./store";
 
-export type Point = Readonly<RoughPoint>;
-
 export type SocketId = string & { _brand: "SocketId" };
 
 export type Collaborator = Readonly<{

+ 1 - 5
packages/excalidraw/utils.ts

@@ -1,3 +1,4 @@
+import { average } from "../math";
 import { COLOR_PALETTE } from "./colors";
 import type { EVENT } from "./constants";
 import {
@@ -992,10 +993,6 @@ export const isMemberOf = <T extends string>(
 
 export const cloneJSON = <T>(obj: T): T => JSON.parse(JSON.stringify(obj));
 
-export const isFiniteNumber = (value: any): value is number => {
-  return typeof value === "number" && Number.isFinite(value);
-};
-
 export const updateStable = <T extends any[] | Record<string, any>>(
   prevValue: T,
   nextValue: T,
@@ -1079,7 +1076,6 @@ export function addEventListener(
   };
 }
 
-const average = (a: number, b: number) => (a + b) / 2;
 export function getSvgPathFromStroke(points: number[][], closed = true) {
   const len = points.length;
 

+ 50 - 46
packages/excalidraw/visualdebug.ts

@@ -1,7 +1,7 @@
+import { isLineSegment, lineSegment, point, type GlobalPoint } from "../math";
 import type { LineSegment } from "../utils";
 import type { BoundingBox, Bounds } from "./element/bounds";
-import { isBounds, isLineSegment } from "./element/typeChecks";
-import type { Point } from "./types";
+import { isBounds } from "./element/typeChecks";
 
 // The global data holder to collect the debug operations
 declare global {
@@ -15,18 +15,22 @@ declare global {
 
 export type DebugElement = {
   color: string;
-  data: LineSegment;
+  data: LineSegment<GlobalPoint>;
   permanent: boolean;
 };
 
 export const debugDrawLine = (
-  segment: LineSegment | LineSegment[],
+  segment: LineSegment<GlobalPoint> | LineSegment<GlobalPoint>[],
   opts?: {
     color?: string;
     permanent?: boolean;
   },
 ) => {
-  (isLineSegment(segment) ? [segment] : segment).forEach((data) =>
+  const segments = (
+    isLineSegment(segment) ? [segment] : segment
+  ) as LineSegment<GlobalPoint>[];
+
+  segments.forEach((data) =>
     addToCurrentFrame({
       color: opts?.color ?? "red",
       data,
@@ -36,7 +40,7 @@ export const debugDrawLine = (
 };
 
 export const debugDrawPoint = (
-  point: Point,
+  p: GlobalPoint,
   opts?: {
     color?: string;
     permanent?: boolean;
@@ -47,20 +51,20 @@ export const debugDrawPoint = (
   const yOffset = opts?.fuzzy ? Math.random() * 3 : 0;
 
   debugDrawLine(
-    [
-      [point[0] + xOffset - 10, point[1] + yOffset - 10],
-      [point[0] + xOffset + 10, point[1] + yOffset + 10],
-    ],
+    lineSegment(
+      point<GlobalPoint>(p[0] + xOffset - 10, p[1] + yOffset - 10),
+      point<GlobalPoint>(p[0] + xOffset + 10, p[1] + yOffset + 10),
+    ),
     {
       color: opts?.color ?? "cyan",
       permanent: opts?.permanent,
     },
   );
   debugDrawLine(
-    [
-      [point[0] + xOffset - 10, point[1] + yOffset + 10],
-      [point[0] + xOffset + 10, point[1] + yOffset - 10],
-    ],
+    lineSegment(
+      point<GlobalPoint>(p[0] + xOffset - 10, p[1] + yOffset + 10),
+      point<GlobalPoint>(p[0] + xOffset + 10, p[1] + yOffset - 10),
+    ),
     {
       color: opts?.color ?? "cyan",
       permanent: opts?.permanent,
@@ -78,22 +82,22 @@ export const debugDrawBoundingBox = (
   (Array.isArray(box) ? box : [box]).forEach((bbox) =>
     debugDrawLine(
       [
-        [
-          [bbox.minX, bbox.minY],
-          [bbox.maxX, bbox.minY],
-        ],
-        [
-          [bbox.maxX, bbox.minY],
-          [bbox.maxX, bbox.maxY],
-        ],
-        [
-          [bbox.maxX, bbox.maxY],
-          [bbox.minX, bbox.maxY],
-        ],
-        [
-          [bbox.minX, bbox.maxY],
-          [bbox.minX, bbox.minY],
-        ],
+        lineSegment(
+          point<GlobalPoint>(bbox.minX, bbox.minY),
+          point<GlobalPoint>(bbox.maxX, bbox.minY),
+        ),
+        lineSegment(
+          point<GlobalPoint>(bbox.maxX, bbox.minY),
+          point<GlobalPoint>(bbox.maxX, bbox.maxY),
+        ),
+        lineSegment(
+          point<GlobalPoint>(bbox.maxX, bbox.maxY),
+          point<GlobalPoint>(bbox.minX, bbox.maxY),
+        ),
+        lineSegment(
+          point<GlobalPoint>(bbox.minX, bbox.maxY),
+          point<GlobalPoint>(bbox.minX, bbox.minY),
+        ),
       ],
       {
         color: opts?.color ?? "cyan",
@@ -113,22 +117,22 @@ export const debugDrawBounds = (
   (isBounds(box) ? [box] : box).forEach((bbox) =>
     debugDrawLine(
       [
-        [
-          [bbox[0], bbox[1]],
-          [bbox[2], bbox[1]],
-        ],
-        [
-          [bbox[2], bbox[1]],
-          [bbox[2], bbox[3]],
-        ],
-        [
-          [bbox[2], bbox[3]],
-          [bbox[0], bbox[3]],
-        ],
-        [
-          [bbox[0], bbox[3]],
-          [bbox[0], bbox[1]],
-        ],
+        lineSegment(
+          point<GlobalPoint>(bbox[0], bbox[1]),
+          point<GlobalPoint>(bbox[2], bbox[1]),
+        ),
+        lineSegment(
+          point<GlobalPoint>(bbox[2], bbox[1]),
+          point<GlobalPoint>(bbox[2], bbox[3]),
+        ),
+        lineSegment(
+          point<GlobalPoint>(bbox[2], bbox[3]),
+          point<GlobalPoint>(bbox[0], bbox[3]),
+        ),
+        lineSegment(
+          point<GlobalPoint>(bbox[0], bbox[3]),
+          point<GlobalPoint>(bbox[0], bbox[1]),
+        ),
       ],
       {
         color: opts?.color ?? "green",

+ 0 - 0
packages/math/CHANGELOG.md


+ 21 - 0
packages/math/README.md

@@ -0,0 +1,21 @@
+# @excalidraw/math
+
+## Install
+
+```bash
+npm install @excalidraw/math
+```
+
+If you prefer Yarn over npm, use this command to install the Excalidraw utils package:
+
+```bash
+yarn add @excalidraw/math
+```
+
+With PNPM, similarly install the package with this command:
+
+```bash
+pnpm add @excalidraw/math
+```
+
+## API

+ 47 - 0
packages/math/angle.ts

@@ -0,0 +1,47 @@
+import type {
+  Degrees,
+  GlobalPoint,
+  LocalPoint,
+  PolarCoords,
+  Radians,
+} from "./types";
+import { PRECISION } from "./utils";
+
+// TODO: Simplify with modulo and fix for angles beyond 4*Math.PI and - 4*Math.PI
+export const normalizeRadians = (angle: Radians): Radians => {
+  if (angle < 0) {
+    return (angle + 2 * Math.PI) as Radians;
+  }
+  if (angle >= 2 * Math.PI) {
+    return (angle - 2 * Math.PI) as Radians;
+  }
+  return angle;
+};
+
+/**
+ * Return the polar coordinates for the given cartesian point represented by
+ * (x, y) for the center point 0,0 where the first number returned is the radius,
+ * the second is the angle in radians.
+ */
+export const cartesian2Polar = <P extends GlobalPoint | LocalPoint>([
+  x,
+  y,
+]: P): PolarCoords => [Math.hypot(x, y), Math.atan2(y, x)];
+
+export function degreesToRadians(degrees: Degrees): Radians {
+  return ((degrees * Math.PI) / 180) as Radians;
+}
+
+export function radiansToDegrees(degrees: Radians): Degrees {
+  return ((degrees * 180) / Math.PI) as Degrees;
+}
+
+/**
+ * Determines if the provided angle is a right angle.
+ *
+ * @param rads The angle to measure
+ * @returns TRUE if the provided angle is a right angle
+ */
+export function isRightAngleRads(rads: Radians): boolean {
+  return Math.abs(Math.sin(2 * rads)) < PRECISION;
+}

+ 41 - 0
packages/math/arc.test.ts

@@ -0,0 +1,41 @@
+import { isPointOnSymmetricArc } from "./arc";
+import { point } from "./point";
+
+describe("point on arc", () => {
+  it("should detect point on simple arc", () => {
+    expect(
+      isPointOnSymmetricArc(
+        {
+          radius: 1,
+          startAngle: -Math.PI / 4,
+          endAngle: Math.PI / 4,
+        },
+        point(0.92291667, 0.385),
+      ),
+    ).toBe(true);
+  });
+  it("should not detect point outside of a simple arc", () => {
+    expect(
+      isPointOnSymmetricArc(
+        {
+          radius: 1,
+          startAngle: -Math.PI / 4,
+          endAngle: Math.PI / 4,
+        },
+        point(-0.92291667, 0.385),
+      ),
+    ).toBe(false);
+  });
+  it("should not detect point with good angle but incorrect radius", () => {
+    expect(
+      isPointOnSymmetricArc(
+        {
+          radius: 1,
+          startAngle: -Math.PI / 4,
+          endAngle: Math.PI / 4,
+        },
+        point(-0.5, 0.5),
+      ),
+    ).toBe(false);
+  });
+});

+ 20 - 0
packages/math/arc.ts

@@ -0,0 +1,20 @@
+import { cartesian2Polar } from "./angle";
+import type { GlobalPoint, LocalPoint, SymmetricArc } from "./types";
+import { PRECISION } from "./utils";
+
+/**
+ * Determines if a cartesian point lies on a symmetric arc, i.e. an arc which
+ * is part of a circle contour centered on 0, 0.
+ */
+export const isPointOnSymmetricArc = <P extends GlobalPoint | LocalPoint>(
+  { radius: arcRadius, startAngle, endAngle }: SymmetricArc,
+  point: P,
+): boolean => {
+  const [radius, angle] = cartesian2Polar(point);
+
+  return startAngle < endAngle
+    ? Math.abs(radius - arcRadius) < PRECISION &&
+        startAngle <= angle &&
+        endAngle >= angle
+    : startAngle <= angle || endAngle >= angle;
+};

+ 223 - 0
packages/math/curve.ts

@@ -0,0 +1,223 @@
+import { point, pointRotateRads } from "./point";
+import type { Curve, GlobalPoint, LocalPoint, Radians } from "./types";
+
+/**
+ *
+ * @param a
+ * @param b
+ * @param c
+ * @param d
+ * @returns
+ */
+export function curve<Point extends GlobalPoint | LocalPoint>(
+  a: Point,
+  b: Point,
+  c: Point,
+  d: Point,
+) {
+  return [a, b, c, d] as Curve<Point>;
+}
+
+export const curveRotate = <Point extends LocalPoint | GlobalPoint>(
+  curve: Curve<Point>,
+  angle: Radians,
+  origin: Point,
+) => {
+  return curve.map((p) => pointRotateRads(p, origin, angle));
+};
+
+/**
+ *
+ * @param pointsIn
+ * @param curveTightness
+ * @returns
+ */
+export function curveToBezier<Point extends LocalPoint | GlobalPoint>(
+  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(
+      point(pointsIn[0][0], pointsIn[0][1]), // Points need to be cloned
+      point(pointsIn[1][0], pointsIn[1][1]), // Points need to be cloned
+      point(pointsIn[2][0], pointsIn[2][1]), // Points need to be cloned
+      point(pointsIn[2][0], pointsIn[2][1]), // Points need to be cloned
+    );
+  } 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(point(points[0][0], points[0][1]));
+    for (let i = 1; i + 2 < points.length; i++) {
+      const cachedVertArray = points[i];
+      b[0] = point(cachedVertArray[0], cachedVertArray[1]);
+      b[1] = point(
+        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] = point(
+        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] = point(points[i + 1][0], points[i + 1][1]);
+      out.push(b[1], b[2], b[3]);
+    }
+  }
+  return out;
+}
+
+/**
+ *
+ * @param t
+ * @param controlPoints
+ * @returns
+ */
+export const cubicBezierPoint = <Point extends LocalPoint | GlobalPoint>(
+  t: number,
+  controlPoints: Curve<Point>,
+): 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 point(x, y);
+};
+
+/**
+ *
+ * @param point
+ * @param controlPoints
+ * @returns
+ */
+export const cubicBezierDistance = <Point extends LocalPoint | GlobalPoint>(
+  point: Point,
+  controlPoints: Curve<Point>,
+) => {
+  // 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;
+};
+
+const solveCubic = (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 extends LocalPoint | GlobalPoint>(
+  point: Point,
+  controlPoints: Curve<Point>,
+) => {
+  // 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 = solveCubic(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 = solveCubic(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;
+};

+ 5 - 5
packages/excalidraw/tests/geometricAlgebra.test.ts → packages/math/ga/ga.test.ts

@@ -1,8 +1,8 @@
-import * as GA from "../ga";
-import { point, toString, direction, offset } from "../ga";
-import * as GAPoint from "../gapoints";
-import * as GALine from "../galines";
-import * as GATransform from "../gatransforms";
+import * as GA from "./ga";
+import { point, toString, direction, offset } from "./ga";
+import * as GAPoint from "./gapoints";
+import * as GALine from "./galines";
+import * as GATransform from "./gatransforms";
 
 describe("geometric algebra", () => {
   describe("points", () => {

+ 0 - 0
packages/excalidraw/ga.ts → packages/math/ga/ga.ts


+ 0 - 0
packages/excalidraw/gadirections.ts → packages/math/ga/gadirections.ts


+ 0 - 0
packages/excalidraw/galines.ts → packages/math/ga/galines.ts


+ 0 - 0
packages/excalidraw/gapoints.ts → packages/math/ga/gapoints.ts


+ 0 - 0
packages/excalidraw/gatransforms.ts → packages/math/ga/gatransforms.ts


+ 12 - 0
packages/math/index.ts

@@ -0,0 +1,12 @@
+export * from "./arc";
+export * from "./angle";
+export * from "./curve";
+export * from "./line";
+export * from "./point";
+export * from "./polygon";
+export * from "./range";
+export * from "./segment";
+export * from "./triangle";
+export * from "./types";
+export * from "./vector";
+export * from "./utils";

+ 52 - 0
packages/math/line.ts

@@ -0,0 +1,52 @@
+import { pointCenter, pointRotateRads } from "./point";
+import type { GlobalPoint, Line, LocalPoint, Radians } from "./types";
+
+/**
+ * Create a line from two points.
+ *
+ * @param points The two points lying on the line
+ * @returns The line on which the points lie
+ */
+export function line<P extends GlobalPoint | LocalPoint>(a: P, b: P): Line<P> {
+  return [a, b] as Line<P>;
+}
+
+/**
+ * Convenient point creation from an array of two points.
+ *
+ * @param param0 The array with the two points to convert to a line
+ * @returns The created line
+ */
+export function lineFromPointPair<P extends GlobalPoint | LocalPoint>([a, b]: [
+  P,
+  P,
+]): Line<P> {
+  return line(a, b);
+}
+
+/**
+ * TODO
+ *
+ * @param pointArray
+ * @returns
+ */
+export function lineFromPointArray<P extends GlobalPoint | LocalPoint>(
+  pointArray: P[],
+): Line<P> | undefined {
+  return pointArray.length === 2
+    ? line<P>(pointArray[0], pointArray[1])
+    : undefined;
+}
+
+// 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 = <Point extends LocalPoint | GlobalPoint>(
+  l: Line<Point>,
+  angle: Radians,
+  origin?: Point,
+): Line<Point> => {
+  return line(
+    pointRotateRads(l[0], origin || pointCenter(l[0], l[1]), angle),
+    pointRotateRads(l[1], origin || pointCenter(l[0], l[1]), angle),
+  );
+};

+ 61 - 0
packages/math/package.json

@@ -0,0 +1,61 @@
+{
+  "name": "@excalidraw/math",
+  "version": "0.1.0",
+  "main": "./dist/prod/index.js",
+  "type": "module",
+  "module": "./dist/prod/index.js",
+  "exports": {
+    ".": {
+      "development": "./dist/dev/index.js",
+      "default": "./dist/prod/index.js"
+    }
+  },
+  "types": "./dist/utils/index.d.ts",
+  "files": [
+    "dist/*"
+  ],
+  "description": "Excalidraw math functions",
+  "publishConfig": {
+    "access": "public"
+  },
+  "license": "MIT",
+  "keywords": [
+    "excalidraw",
+    "excalidraw-math",
+    "math",
+    "vector",
+    "algebra",
+    "2d"
+  ],
+  "browserslist": {
+    "production": [
+      ">0.2%",
+      "not dead",
+      "not ie <= 11",
+      "not op_mini all",
+      "not safari < 12",
+      "not kaios <= 2.5",
+      "not edge < 79",
+      "not chrome < 70",
+      "not and_uc < 13",
+      "not samsung < 10"
+    ],
+    "development": [
+      "last 1 chrome version",
+      "last 1 firefox version",
+      "last 1 safari version"
+    ]
+  },
+  "bugs": "https://github.com/excalidraw/excalidraw/issues",
+  "repository": "https://github.com/excalidraw/excalidraw",
+  "dependencies": {
+    "@excalidraw/utils": "*"
+  },
+  "scripts": {
+    "gen:types": "rm -rf types && tsc",
+    "build:umd": "cross-env NODE_ENV=production webpack --config webpack.prod.config.js",
+    "build:esm": "rm -rf dist && node ../../scripts/buildUtils.js && yarn gen:types",
+    "build:umd:withAnalyzer": "cross-env NODE_ENV=production ANALYZER=true webpack --config webpack.prod.config.js",
+    "pack": "yarn build:umd && yarn pack"
+  }
+}

+ 24 - 0
packages/math/point.test.ts

@@ -0,0 +1,24 @@
+import { point, pointRotateRads } from "./point";
+import type { Radians } from "./types";
+
+describe("rotate", () => {
+  it("should rotate over (x2, y2) and return the rotated coordinates for (x1, y1)", () => {
+    const x1 = 10;
+    const y1 = 20;
+    const x2 = 20;
+    const y2 = 30;
+    const angle = (Math.PI / 2) as Radians;
+    const [rotatedX, rotatedY] = pointRotateRads(
+      point(x1, y1),
+      point(x2, y2),
+      angle,
+    );
+    expect([rotatedX, rotatedY]).toEqual([30, 20]);
+    const res2 = pointRotateRads(
+      point(rotatedX, rotatedY),
+      point(x2, y2),
+      -angle as Radians,
+    );
+    expect(res2).toEqual([x1, x2]);
+  });
+});

+ 257 - 0
packages/math/point.ts

@@ -0,0 +1,257 @@
+import { degreesToRadians } from "./angle";
+import type {
+  LocalPoint,
+  GlobalPoint,
+  Radians,
+  Degrees,
+  Vector,
+} from "./types";
+import { PRECISION } from "./utils";
+import { vectorFromPoint, vectorScale } from "./vector";
+
+/**
+ * Create a properly typed Point instance from the X and Y coordinates.
+ *
+ * @param x The X coordinate
+ * @param y The Y coordinate
+ * @returns The branded and created point
+ */
+export function point<Point extends GlobalPoint | LocalPoint>(
+  x: number,
+  y: number,
+): Point {
+  return [x, y] as Point;
+}
+
+/**
+ * Converts and remaps an array containing a pair of numbers to Point.
+ *
+ * @param numberArray The number array to check and to convert to Point
+ * @returns The point instance
+ */
+export function pointFromArray<Point extends GlobalPoint | LocalPoint>(
+  numberArray: number[],
+): Point | undefined {
+  return numberArray.length === 2
+    ? point<Point>(numberArray[0], numberArray[1])
+    : undefined;
+}
+
+/**
+ * Converts and remaps a pair of numbers to Point.
+ *
+ * @param pair A number pair to convert to Point
+ * @returns The point instance
+ */
+export function pointFromPair<Point extends GlobalPoint | LocalPoint>(
+  pair: [number, number],
+): Point {
+  return pair as Point;
+}
+
+/**
+ * Convert a vector to a point.
+ *
+ * @param v The vector to convert
+ * @returns The point the vector points at with origin 0,0
+ */
+export function pointFromVector<P extends GlobalPoint | LocalPoint>(
+  v: Vector,
+): P {
+  return v as unknown as P;
+}
+
+/**
+ * Checks if the provided value has the shape of a Point.
+ *
+ * @param p The value to attempt verification on
+ * @returns TRUE if the provided value has the shape of a local or global point
+ */
+export function isPoint(p: unknown): p is LocalPoint | GlobalPoint {
+  return (
+    Array.isArray(p) &&
+    p.length === 2 &&
+    typeof p[0] === "number" &&
+    !isNaN(p[0]) &&
+    typeof p[1] === "number" &&
+    !isNaN(p[1])
+  );
+}
+
+/**
+ * Compare two points coordinate-by-coordinate and if
+ * they are closer than INVERSE_PRECISION it returns TRUE.
+ *
+ * @param a Point The first point to compare
+ * @param b Point The second point to compare
+ * @returns TRUE if the points are sufficiently close to each other
+ */
+export function pointsEqual<Point extends GlobalPoint | LocalPoint>(
+  a: Point,
+  b: Point,
+): boolean {
+  const abs = Math.abs;
+  return abs(a[0] - b[0]) < PRECISION && abs(a[1] - b[1]) < PRECISION;
+}
+
+/**
+ * Roate a point by [angle] radians.
+ *
+ * @param point The point to rotate
+ * @param center The point to rotate around, the center point
+ * @param angle The radians to rotate the point by
+ * @returns The rotated point
+ */
+export function pointRotateRads<Point extends GlobalPoint | LocalPoint>(
+  [x, y]: Point,
+  [cx, cy]: Point,
+  angle: Radians,
+): Point {
+  return point(
+    (x - cx) * Math.cos(angle) - (y - cy) * Math.sin(angle) + cx,
+    (x - cx) * Math.sin(angle) + (y - cy) * Math.cos(angle) + cy,
+  );
+}
+
+/**
+ * Roate a point by [angle] degree.
+ *
+ * @param point The point to rotate
+ * @param center The point to rotate around, the center point
+ * @param angle The degree to rotate the point by
+ * @returns The rotated point
+ */
+export function pointRotateDegs<Point extends GlobalPoint | LocalPoint>(
+  point: Point,
+  center: Point,
+  angle: Degrees,
+): Point {
+  return pointRotateRads(point, center, degreesToRadians(angle));
+}
+
+/**
+ * Translate a point by a vector.
+ *
+ * WARNING: This is not for translating Excalidraw element points!
+ *          You need to account for rotation on base coordinates
+ *          on your own.
+ *          CONSIDER USING AN APPROPRIATE ELEMENT-AWARE TRANSLATE!
+ *
+ * @param p The point to apply the translation on
+ * @param v The vector to translate by
+ * @returns
+ */
+// TODO 99% of use is translating between global and local coords, which need to be formalized
+export function pointTranslate<
+  From extends GlobalPoint | LocalPoint,
+  To extends GlobalPoint | LocalPoint,
+>(p: From, v: Vector = [0, 0] as Vector): To {
+  return point(p[0] + v[0], p[1] + v[1]);
+}
+
+/**
+ * Find the center point at equal distance from both points.
+ *
+ * @param a One of the points to create the middle point for
+ * @param b The other point to create the middle point for
+ * @returns The middle point
+ */
+export function pointCenter<P extends LocalPoint | GlobalPoint>(a: P, b: P): P {
+  return point((a[0] + b[0]) / 2, (a[1] + b[1]) / 2);
+}
+
+/**
+ * Add together two points by their coordinates like you'd apply a translation
+ * to a point by a vector.
+ *
+ * @param a One point to act as a basis
+ * @param b The other point to act like the vector to translate by
+ * @returns
+ */
+export function pointAdd<Point extends LocalPoint | GlobalPoint>(
+  a: Point,
+  b: Point,
+): Point {
+  return point(a[0] + b[0], a[1] + b[1]);
+}
+
+/**
+ * Subtract a point from another point like you'd translate a point by an
+ * invese vector.
+ *
+ * @param a The point to translate
+ * @param b The point which will act like a vector
+ * @returns The resulting point
+ */
+export function pointSubtract<Point extends LocalPoint | GlobalPoint>(
+  a: Point,
+  b: Point,
+): Point {
+  return point(a[0] - b[0], a[1] - b[1]);
+}
+
+/**
+ * Calculate the distance between two points.
+ *
+ * @param a First point
+ * @param b Second point
+ * @returns The euclidean distance between the two points.
+ */
+export function pointDistance<P extends LocalPoint | GlobalPoint>(
+  a: P,
+  b: P,
+): number {
+  return Math.hypot(b[0] - a[0], b[1] - a[1]);
+}
+
+/**
+ * Calculate the squared distance between two points.
+ *
+ * Note: Use this if you only compare distances, it saves a square root.
+ *
+ * @param a First point
+ * @param b Second point
+ * @returns The euclidean distance between the two points.
+ */
+export function pointDistanceSq<P extends LocalPoint | GlobalPoint>(
+  a: P,
+  b: P,
+): number {
+  return Math.hypot(b[0] - a[0], b[1] - a[1]);
+}
+
+/**
+ * Scale a point from a given origin by the multiplier.
+ *
+ * @param p The point to scale
+ * @param mid The origin to scale from
+ * @param multiplier The scaling factor
+ * @returns
+ */
+export const pointScaleFromOrigin = <P extends GlobalPoint | LocalPoint>(
+  p: P,
+  mid: P,
+  multiplier: number,
+) => pointTranslate(mid, vectorScale(vectorFromPoint(p, mid), multiplier));
+
+/**
+ * Returns whether `q` lies inside the segment/rectangle defined by `p` and `r`.
+ * This is an approximation to "does `q` lie on a segment `pr`" check.
+ *
+ * @param p The first point to compare against
+ * @param q The actual point this function checks whether is in between
+ * @param r The other point to compare against
+ * @returns TRUE if q is indeed between p and r
+ */
+export const isPointWithinBounds = <P extends GlobalPoint | LocalPoint>(
+  p: P,
+  q: P,
+  r: P,
+) => {
+  return (
+    q[0] <= Math.max(p[0], r[0]) &&
+    q[0] >= Math.min(p[0], r[0]) &&
+    q[1] <= Math.max(p[1], r[1]) &&
+    q[1] >= Math.min(p[1], r[1])
+  );
+};

+ 72 - 0
packages/math/polygon.ts

@@ -0,0 +1,72 @@
+import { pointsEqual } from "./point";
+import { lineSegment, pointOnLineSegment } from "./segment";
+import type { GlobalPoint, LocalPoint, Polygon } from "./types";
+import { PRECISION } from "./utils";
+
+export function polygon<Point extends GlobalPoint | LocalPoint>(
+  ...points: Point[]
+) {
+  return polygonClose(points) as Polygon<Point>;
+}
+
+export function polygonFromPoints<Point extends GlobalPoint | LocalPoint>(
+  points: Point[],
+) {
+  return polygonClose(points) as Polygon<Point>;
+}
+
+export const polygonIncludesPoint = <Point extends LocalPoint | GlobalPoint>(
+  point: Point,
+  polygon: Polygon<Point>,
+) => {
+  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 extends LocalPoint | GlobalPoint>(
+  p: Point,
+  poly: Polygon<Point>,
+  threshold = PRECISION,
+) => {
+  let on = false;
+
+  for (let i = 0, l = poly.length - 1; i < l; i++) {
+    if (pointOnLineSegment(p, lineSegment(poly[i], poly[i + 1]), threshold)) {
+      on = true;
+      break;
+    }
+  }
+
+  return on;
+};
+
+function polygonClose<Point extends LocalPoint | GlobalPoint>(
+  polygon: Point[],
+) {
+  return polygonIsClosed(polygon)
+    ? polygon
+    : ([...polygon, polygon[0]] as Polygon<Point>);
+}
+
+function polygonIsClosed<Point extends LocalPoint | GlobalPoint>(
+  polygon: Point[],
+) {
+  return pointsEqual(polygon[0], polygon[polygon.length - 1]);
+}

+ 51 - 0
packages/math/range.test.ts

@@ -0,0 +1,51 @@
+import { rangeInclusive, rangeIntersection, rangesOverlap } from "./range";
+
+describe("range overlap", () => {
+  const range1_4 = rangeInclusive(1, 4);
+
+  it("should overlap when range a contains range b", () => {
+    expect(rangesOverlap(range1_4, rangeInclusive(2, 3))).toBe(true);
+    expect(rangesOverlap(range1_4, range1_4)).toBe(true);
+    expect(rangesOverlap(range1_4, rangeInclusive(1, 3))).toBe(true);
+    expect(rangesOverlap(range1_4, rangeInclusive(2, 4))).toBe(true);
+  });
+
+  it("should overlap when range b contains range a", () => {
+    expect(rangesOverlap(rangeInclusive(2, 3), range1_4)).toBe(true);
+    expect(rangesOverlap(rangeInclusive(1, 3), range1_4)).toBe(true);
+    expect(rangesOverlap(rangeInclusive(2, 4), range1_4)).toBe(true);
+  });
+
+  it("should overlap when range a and b intersect", () => {
+    expect(rangesOverlap(range1_4, rangeInclusive(2, 5))).toBe(true);
+  });
+});
+
+describe("range intersection", () => {
+  const range1_4 = rangeInclusive(1, 4);
+
+  it("should intersect completely with itself", () => {
+    expect(rangeIntersection(range1_4, range1_4)).toEqual(range1_4);
+  });
+
+  it("should intersect irrespective of order", () => {
+    expect(rangeIntersection(range1_4, rangeInclusive(2, 3))).toEqual([2, 3]);
+    expect(rangeIntersection(rangeInclusive(2, 3), range1_4)).toEqual([2, 3]);
+    expect(rangeIntersection(range1_4, rangeInclusive(3, 5))).toEqual(
+      rangeInclusive(3, 4),
+    );
+    expect(rangeIntersection(rangeInclusive(3, 5), range1_4)).toEqual(
+      rangeInclusive(3, 4),
+    );
+  });
+
+  it("should intersect at the edge", () => {
+    expect(rangeIntersection(range1_4, rangeInclusive(4, 5))).toEqual(
+      rangeInclusive(4, 4),
+    );
+  });
+
+  it("should not intersect", () => {
+    expect(rangeIntersection(range1_4, rangeInclusive(5, 7))).toEqual(null);
+  });
+});

+ 82 - 0
packages/math/range.ts

@@ -0,0 +1,82 @@
+import { toBrandedType } from "../excalidraw/utils";
+import type { InclusiveRange } from "./types";
+
+/**
+ * Create an inclusive range from the two numbers provided.
+ *
+ * @param start Start of the range
+ * @param end End of the range
+ * @returns
+ */
+export function rangeInclusive(start: number, end: number): InclusiveRange {
+  return toBrandedType<InclusiveRange>([start, end]);
+}
+
+/**
+ * Turn a number pair into an inclusive range.
+ *
+ * @param pair The number pair to convert to an inclusive range
+ * @returns The new inclusive range
+ */
+export function rangeInclusiveFromPair(pair: [start: number, end: number]) {
+  return toBrandedType<InclusiveRange>(pair);
+}
+
+/**
+ * Given two ranges, return if the two ranges overlap with each other e.g.
+ * [1, 3] overlaps with [2, 4] while [1, 3] does not overlap with [4, 5].
+ *
+ * @param param0 One of the ranges to compare
+ * @param param1 The other range to compare against
+ * @returns TRUE if the ranges overlap
+ */
+export const rangesOverlap = (
+  [a0, a1]: InclusiveRange,
+  [b0, b1]: InclusiveRange,
+): boolean => {
+  if (a0 <= b0) {
+    return a1 >= b0;
+  }
+
+  if (a0 >= b0) {
+    return b1 >= a0;
+  }
+
+  return false;
+};
+
+/**
+ * Given two ranges,return ther intersection of the two ranges if any e.g. the
+ * intersection of [1, 3] and [2, 4] is [2, 3].
+ *
+ * @param param0 The first range to compare
+ * @param param1 The second range to compare
+ * @returns The inclusive range intersection or NULL if no intersection
+ */
+export const rangeIntersection = (
+  [a0, a1]: InclusiveRange,
+  [b0, b1]: InclusiveRange,
+): InclusiveRange | null => {
+  const rangeStart = Math.max(a0, b0);
+  const rangeEnd = Math.min(a1, b1);
+
+  if (rangeStart <= rangeEnd) {
+    return toBrandedType<InclusiveRange>([rangeStart, rangeEnd]);
+  }
+
+  return null;
+};
+
+/**
+ * Determine if a value is inside a range.
+ *
+ * @param value The value to check
+ * @param range The range
+ * @returns
+ */
+export const rangeIncludesValue = (
+  value: number,
+  [min, max]: InclusiveRange,
+): boolean => {
+  return value >= min && value <= max;
+};

+ 158 - 0
packages/math/segment.ts

@@ -0,0 +1,158 @@
+import {
+  isPoint,
+  pointCenter,
+  pointFromVector,
+  pointRotateRads,
+} from "./point";
+import type { GlobalPoint, LineSegment, LocalPoint, Radians } from "./types";
+import { PRECISION } from "./utils";
+import {
+  vectorAdd,
+  vectorCross,
+  vectorFromPoint,
+  vectorScale,
+  vectorSubtract,
+} from "./vector";
+
+/**
+ * Create a line segment from two points.
+ *
+ * @param points The two points delimiting the line segment on each end
+ * @returns The line segment delineated by the points
+ */
+export function lineSegment<P extends GlobalPoint | LocalPoint>(
+  a: P,
+  b: P,
+): LineSegment<P> {
+  return [a, b] as LineSegment<P>;
+}
+
+export function lineSegmentFromPointArray<P extends GlobalPoint | LocalPoint>(
+  pointArray: P[],
+): LineSegment<P> | undefined {
+  return pointArray.length === 2
+    ? lineSegment<P>(pointArray[0], pointArray[1])
+    : undefined;
+}
+
+/**
+ *
+ * @param segment
+ * @returns
+ */
+export const isLineSegment = <Point extends GlobalPoint | LocalPoint>(
+  segment: unknown,
+): segment is LineSegment<Point> =>
+  Array.isArray(segment) &&
+  segment.length === 2 &&
+  isPoint(segment[0]) &&
+  isPoint(segment[0]);
+
+/**
+ * Return the coordinates resulting from rotating the given line about an origin by an angle in radians
+ * note that when the origin is not given, the midpoint of the given line is used as the origin.
+ *
+ * @param l
+ * @param angle
+ * @param origin
+ * @returns
+ */
+export const lineSegmentRotate = <Point extends LocalPoint | GlobalPoint>(
+  l: LineSegment<Point>,
+  angle: Radians,
+  origin?: Point,
+): LineSegment<Point> => {
+  return lineSegment(
+    pointRotateRads(l[0], origin || pointCenter(l[0], l[1]), angle),
+    pointRotateRads(l[1], origin || pointCenter(l[0], l[1]), angle),
+  );
+};
+
+/**
+ * Calculates the point two line segments with a definite start and end point
+ * intersect at.
+ */
+export const segmentsIntersectAt = <Point extends GlobalPoint | LocalPoint>(
+  a: Readonly<LineSegment<Point>>,
+  b: Readonly<LineSegment<Point>>,
+): Point | null => {
+  const a0 = vectorFromPoint(a[0]);
+  const a1 = vectorFromPoint(a[1]);
+  const b0 = vectorFromPoint(b[0]);
+  const b1 = vectorFromPoint(b[1]);
+  const r = vectorSubtract(a1, a0);
+  const s = vectorSubtract(b1, b0);
+  const denominator = vectorCross(r, s);
+
+  if (denominator === 0) {
+    return null;
+  }
+
+  const i = vectorSubtract(vectorFromPoint(b[0]), vectorFromPoint(a[0]));
+  const u = vectorCross(i, r) / denominator;
+  const t = vectorCross(i, s) / denominator;
+
+  if (u === 0) {
+    return null;
+  }
+
+  const p = vectorAdd(a0, vectorScale(r, t));
+
+  if (t >= 0 && t < 1 && u >= 0 && u < 1) {
+    return pointFromVector<Point>(p);
+  }
+
+  return null;
+};
+
+export const pointOnLineSegment = <Point extends LocalPoint | GlobalPoint>(
+  point: Point,
+  line: LineSegment<Point>,
+  threshold = PRECISION,
+) => {
+  const distance = distanceToLineSegment(point, line);
+
+  if (distance === 0) {
+    return true;
+  }
+
+  return distance < threshold;
+};
+
+export const distanceToLineSegment = <Point extends LocalPoint | GlobalPoint>(
+  point: Point,
+  line: LineSegment<Point>,
+) => {
+  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);
+};

+ 28 - 0
packages/math/triangle.ts

@@ -0,0 +1,28 @@
+import type { GlobalPoint, LocalPoint, Triangle } from "./types";
+
+// Types
+
+/**
+ * Tests if a point lies inside a triangle. This function
+ * will return FALSE if the point lies exactly on the sides
+ * of the triangle.
+ *
+ * @param triangle The triangle to test the point for
+ * @param p The point to test whether is in the triangle
+ * @returns TRUE if the point is inside of the triangle
+ */
+export function triangleIncludesPoint<P extends GlobalPoint | LocalPoint>(
+  [a, b, c]: Triangle<P>,
+  p: P,
+): boolean {
+  const triangleSign = (p1: P, p2: P, p3: P) =>
+    (p1[0] - p3[0]) * (p2[1] - p3[1]) - (p2[0] - p3[0]) * (p1[1] - p3[1]);
+  const d1 = triangleSign(p, a, b);
+  const d2 = triangleSign(p, b, c);
+  const d3 = triangleSign(p, c, a);
+
+  const has_neg = d1 < 0 || d2 < 0 || d3 < 0;
+  const has_pos = d1 > 0 || d2 > 0 || d3 > 0;
+
+  return !(has_neg && has_pos);
+}

+ 130 - 0
packages/math/types.ts

@@ -0,0 +1,130 @@
+//
+// Measurements
+//
+
+/**
+ * By definition one radian is the angle subtended at the centre
+ * of a circle by an arc that is equal in length to the radius.
+ */
+export type Radians = number & { _brand: "excalimath__radian" };
+
+/**
+ * An angle measurement of a plane angle in which one full
+ * rotation is 360 degrees.
+ */
+export type Degrees = number & { _brand: "excalimath_degree" };
+
+//
+// Range
+//
+
+/**
+ * A number range which includes the start and end numbers in the range.
+ */
+export type InclusiveRange = [number, number] & { _brand: "excalimath_degree" };
+
+//
+// Point
+//
+
+/**
+ * Represents a 2D position in world or canvas space. A
+ * global coordinate.
+ */
+export type GlobalPoint = [x: number, y: number] & {
+  _brand: "excalimath__globalpoint";
+};
+
+/**
+ * Represents a 2D position in whatever local space it's
+ * needed. A local coordinate.
+ */
+export type LocalPoint = [x: number, y: number] & {
+  _brand: "excalimath__localpoint";
+};
+
+// Line
+
+/**
+ * A line is an infinitely long object with no width, depth, or curvature.
+ */
+export type Line<P extends GlobalPoint | LocalPoint> = [p: P, q: P] & {
+  _brand: "excalimath_line";
+};
+
+/**
+ * In geometry, a line segment is a part of a straight
+ * line that is bounded by two distinct end points, and
+ * contains every point on the line that is between its endpoints.
+ */
+export type LineSegment<P extends GlobalPoint | LocalPoint> = [a: P, b: P] & {
+  _brand: "excalimath_linesegment";
+};
+
+//
+// Vector
+//
+
+/**
+ * Represents a 2D vector
+ */
+export type Vector = [u: number, v: number] & {
+  _brand: "excalimath__vector";
+};
+
+// Triangles
+
+/**
+ * A triangle represented by 3 points
+ */
+export type Triangle<P extends GlobalPoint | LocalPoint> = [
+  a: P,
+  b: P,
+  c: P,
+] & {
+  _brand: "excalimath__triangle";
+};
+
+//
+// Polygon
+//
+
+/**
+ * A polygon is a closed shape by connecting the given points
+ * rectangles and diamonds are modelled by polygons
+ */
+export type Polygon<Point extends GlobalPoint | LocalPoint> = Point[] & {
+  _brand: "excalimath_polygon";
+};
+
+//
+// Curve
+//
+
+/**
+ * Cubic bezier curve with four control points
+ */
+export type Curve<Point extends GlobalPoint | LocalPoint> = [
+  Point,
+  Point,
+  Point,
+  Point,
+] & {
+  _brand: "excalimath_curve";
+};
+
+export type PolarCoords = [
+  radius: number,
+  /** angle in radians */
+  angle: number,
+];
+
+/**
+ * Angles are in radians and centered on 0, 0. Zero radians on a 1 radius circle
+ * corresponds to (1, 0) cartesian coordinates (point), i.e. to the "right".
+ */
+export type SymmetricArc = {
+  radius: number;
+  startAngle: number;
+  endAngle: number;
+};

+ 17 - 0
packages/math/utils.ts

@@ -0,0 +1,17 @@
+export const PRECISION = 10e-5;
+
+export function clamp(value: number, min: number, max: number) {
+  return Math.min(Math.max(value, min), max);
+}
+
+export function round(value: number, precision: number) {
+  const multiplier = Math.pow(10, precision);
+
+  return Math.round((value + Number.EPSILON) * multiplier) / multiplier;
+}
+
+export const average = (a: number, b: number) => (a + b) / 2;
+
+export const isFiniteNumber = (value: any): value is number => {
+  return typeof value === "number" && Number.isFinite(value);
+};

+ 12 - 0
packages/math/vector.test.ts

@@ -0,0 +1,12 @@
+import { isVector } from ".";
+
+describe("Vector", () => {
+  test("isVector", () => {
+    expect(isVector([5, 5])).toBe(true);
+    expect(isVector([-5, -5])).toBe(true);
+    expect(isVector([5, 0.5])).toBe(true);
+    expect(isVector(null)).toBe(false);
+    expect(isVector(undefined)).toBe(false);
+    expect(isVector([5, NaN])).toBe(false);
+  });
+});

+ 141 - 0
packages/math/vector.ts

@@ -0,0 +1,141 @@
+import type { GlobalPoint, LocalPoint, Vector } from "./types";
+
+/**
+ * Create a vector from the x and y coordiante elements.
+ *
+ * @param x The X aspect of the vector
+ * @param y T Y aspect of the vector
+ * @returns The constructed vector with X and Y as the coordinates
+ */
+export function vector(
+  x: number,
+  y: number,
+  originX: number = 0,
+  originY: number = 0,
+): Vector {
+  return [x - originX, y - originY] as Vector;
+}
+
+/**
+ * Turn a point into a vector with the origin point.
+ *
+ * @param p The point to turn into a vector
+ * @param origin The origin point in a given coordiante system
+ * @returns The created vector from the point and the origin
+ */
+export function vectorFromPoint<Point extends GlobalPoint | LocalPoint>(
+  p: Point,
+  origin: Point = [0, 0] as Point,
+): Vector {
+  return vector(p[0] - origin[0], p[1] - origin[1]);
+}
+
+/**
+ * Cross product is a binary operation on two vectors in 2D space.
+ * It results in a vector that is perpendicular to both vectors.
+ *
+ * @param a One of the vectors to use for the directed area calculation
+ * @param b The other vector to use for the directed area calculation
+ * @returns The directed area value for the two vectos
+ */
+export function vectorCross(a: Vector, b: Vector): number {
+  return a[0] * b[1] - b[0] * a[1];
+}
+
+/**
+ * Dot product is defined as the sum of the products of the
+ * two vectors.
+ *
+ * @param a One of the vectors for which the sum of products is calculated
+ * @param b The other vector for which the sum of products is calculated
+ * @returns The sum of products of the two vectors
+ */
+export function vectorDot(a: Vector, b: Vector) {
+  return a[0] * b[0] + a[1] * b[1];
+}
+
+/**
+ * Determines if the value has the shape of a Vector.
+ *
+ * @param v The value to test
+ * @returns TRUE if the value has the shape and components of a Vectors
+ */
+export function isVector(v: unknown): v is Vector {
+  return (
+    Array.isArray(v) &&
+    v.length === 2 &&
+    typeof v[0] === "number" &&
+    !isNaN(v[0]) &&
+    typeof v[1] === "number" &&
+    !isNaN(v[1])
+  );
+}
+
+/**
+ * Add two vectors by adding their coordinates.
+ *
+ * @param a One of the vectors to add
+ * @param b The other vector to add
+ * @returns The sum vector of the two provided vectors
+ */
+export function vectorAdd(a: Readonly<Vector>, b: Readonly<Vector>): Vector {
+  return [a[0] + b[0], a[1] + b[1]] as Vector;
+}
+
+/**
+ * Add two vectors by adding their coordinates.
+ *
+ * @param start One of the vectors to add
+ * @param end The other vector to add
+ * @returns The sum vector of the two provided vectors
+ */
+export function vectorSubtract(
+  start: Readonly<Vector>,
+  end: Readonly<Vector>,
+): Vector {
+  return [start[0] - end[0], start[1] - end[1]] as Vector;
+}
+
+/**
+ * Scale vector by a scalar.
+ *
+ * @param v The vector to scale
+ * @param scalar The scalar to multiply the vector components with
+ * @returns The new scaled vector
+ */
+export function vectorScale(v: Vector, scalar: number): Vector {
+  return vector(v[0] * scalar, v[1] * scalar);
+}
+
+/**
+ * Calculates the sqare magnitude of a vector. Use this if you compare
+ * magnitudes as it saves you an SQRT.
+ *
+ * @param v The vector to measure
+ * @returns The scalar squared magnitude of the vector
+ */
+export function vectorMagnitudeSq(v: Vector) {
+  return v[0] * v[0] + v[1] * v[1];
+}
+
+/**
+ * Calculates the magnitude of a vector.
+ *
+ * @param v The vector to measure
+ * @returns The scalar magnitude of the vector
+ */
+export function vectorMagnitude(v: Vector) {
+  return Math.sqrt(vectorMagnitudeSq(v));
+}
+
+/**
+ * Normalize the vector (i.e. make the vector magnitue equal 1).
+ *
+ * @param v The vector to normalize
+ * @returns The new normalized vector
+ */
+export const vectorNormalize = (v: Vector): Vector => {
+  const m = vectorMagnitude(v);
+
+  return vector(v[0] / m, v[1] / m);
+};

+ 55 - 0
packages/math/webpack.prod.config.js

@@ -0,0 +1,55 @@
+const webpack = require("webpack");
+const path = require("path");
+const BundleAnalyzerPlugin =
+  require("webpack-bundle-analyzer").BundleAnalyzerPlugin;
+
+module.exports = {
+  mode: "production",
+  entry: { "excalidraw-math.min": "./index.js" },
+  output: {
+    path: path.resolve(__dirname, "dist"),
+    filename: "[name].js",
+    library: "ExcalidrawMath",
+    libraryTarget: "umd",
+  },
+  resolve: {
+    extensions: [".tsx", ".ts", ".js", ".css", ".scss"],
+  },
+  optimization: {
+    runtimeChunk: false,
+  },
+  module: {
+    rules: [
+      {
+        test: /\.(ts|tsx|js)$/,
+        use: [
+          {
+            loader: "ts-loader",
+            options: {
+              transpileOnly: true,
+              configFile: path.resolve(__dirname, "../tsconfig.prod.json"),
+            },
+          },
+          {
+            loader: "babel-loader",
+
+            options: {
+              presets: [
+                "@babel/preset-env",
+                ["@babel/preset-react", { runtime: "automatic" }],
+                "@babel/preset-typescript",
+              ],
+              plugins: [["@babel/plugin-transform-runtime"]],
+            },
+          },
+        ],
+      },
+    ],
+  },
+  plugins: [
+    new webpack.optimize.LimitChunkCountPlugin({
+      maxChunks: 1,
+    }),
+    ...(process.env.ANALYZER === "true" ? [new BundleAnalyzerPlugin()] : []),
+  ],
+};

+ 31 - 24
packages/utils/bbox.ts

@@ -1,9 +1,16 @@
+import {
+  vectorCross,
+  vectorFromPoint,
+  type GlobalPoint,
+  type LocalPoint,
+} from "../math";
 import type { Bounds } from "../excalidraw/element/bounds";
-import type { Point } from "../excalidraw/types";
 
-export type LineSegment = [Point, Point];
+export type LineSegment<P extends LocalPoint | GlobalPoint> = [P, P];
 
-export function getBBox(line: LineSegment): Bounds {
+export function getBBox<P extends LocalPoint | GlobalPoint>(
+  line: LineSegment<P>,
+): Bounds {
   return [
     Math.min(line[0][0], line[1][0]),
     Math.min(line[0][1], line[1][1]),
@@ -12,40 +19,37 @@ export function getBBox(line: LineSegment): Bounds {
   ];
 }
 
-export function crossProduct(a: Point, b: Point) {
-  return a[0] * b[1] - b[0] * a[1];
-}
-
 export function doBBoxesIntersect(a: Bounds, b: Bounds) {
   return a[0] <= b[2] && a[2] >= b[0] && a[1] <= b[3] && a[3] >= b[1];
 }
 
-export function translate(a: Point, b: Point): Point {
-  return [a[0] - b[0], a[1] - b[1]];
-}
-
 const EPSILON = 0.000001;
 
-export function isPointOnLine(l: LineSegment, p: Point) {
-  const p1 = translate(l[1], l[0]);
-  const p2 = translate(p, l[0]);
+export function isPointOnLine<P extends GlobalPoint | LocalPoint>(
+  l: LineSegment<P>,
+  p: P,
+) {
+  const p1 = vectorFromPoint(l[1], l[0]);
+  const p2 = vectorFromPoint(p, l[0]);
 
-  const r = crossProduct(p1, p2);
+  const r = vectorCross(p1, p2);
 
   return Math.abs(r) < EPSILON;
 }
 
-export function isPointRightOfLine(l: LineSegment, p: Point) {
-  const p1 = translate(l[1], l[0]);
-  const p2 = translate(p, l[0]);
+export function isPointRightOfLine<P extends GlobalPoint | LocalPoint>(
+  l: LineSegment<P>,
+  p: P,
+) {
+  const p1 = vectorFromPoint(l[1], l[0]);
+  const p2 = vectorFromPoint(p, l[0]);
 
-  return crossProduct(p1, p2) < 0;
+  return vectorCross(p1, p2) < 0;
 }
 
-export function isLineSegmentTouchingOrCrossingLine(
-  a: LineSegment,
-  b: LineSegment,
-) {
+export function isLineSegmentTouchingOrCrossingLine<
+  P extends GlobalPoint | LocalPoint,
+>(a: LineSegment<P>, b: LineSegment<P>) {
   return (
     isPointOnLine(a, b[0]) ||
     isPointOnLine(a, b[1]) ||
@@ -56,7 +60,10 @@ export function isLineSegmentTouchingOrCrossingLine(
 }
 
 // https://martin-thoma.com/how-to-check-if-two-line-segments-intersect/
-export function doLineSegmentsIntersect(a: LineSegment, b: LineSegment) {
+export function doLineSegmentsIntersect<P extends GlobalPoint | LocalPoint>(
+  a: LineSegment<P>,
+  b: LineSegment<P>,
+) {
   return (
     doBBoxesIntersect(getBBox(a), getBBox(b)) &&
     isLineSegmentTouchingOrCrossingLine(a, b) &&

+ 87 - 0
packages/utils/collision.test.ts

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

+ 87 - 17
packages/utils/collision.ts

@@ -1,20 +1,26 @@
-import type { Point, Polygon, GeometricShape } from "./geometry/shape";
+import type { Polycurve, Polyline } from "./geometry/shape";
 import {
   pointInEllipse,
-  pointInPolygon,
-  pointOnCurve,
   pointOnEllipse,
-  pointOnLine,
-  pointOnPolycurve,
+  type GeometricShape,
+} from "./geometry/shape";
+import type { Curve } from "../math";
+import {
+  lineSegment,
+  point,
+  polygonIncludesPoint,
+  pointOnLineSegment,
   pointOnPolygon,
-  pointOnPolyline,
-  close,
-} from "./geometry/geometry";
+  polygonFromPoints,
+  type GlobalPoint,
+  type LocalPoint,
+  type Polygon,
+} from "../math";
 
 // check if the given point is considered on the given shape's border
-export const isPointOnShape = (
+export const isPointOnShape = <Point extends GlobalPoint | LocalPoint>(
   point: Point,
-  shape: GeometricShape,
+  shape: GeometricShape<Point>,
   tolerance = 0,
 ) => {
   // get the distance from the given point to the given element
@@ -25,7 +31,7 @@ export const isPointOnShape = (
     case "ellipse":
       return pointOnEllipse(point, shape.data, tolerance);
     case "line":
-      return pointOnLine(point, shape.data, tolerance);
+      return pointOnLineSegment(point, shape.data, tolerance);
     case "polyline":
       return pointOnPolyline(point, shape.data, tolerance);
     case "curve":
@@ -38,10 +44,13 @@ export const isPointOnShape = (
 };
 
 // check if the given point is considered inside the element's border
-export const isPointInShape = (point: Point, shape: GeometricShape) => {
+export const isPointInShape = <Point extends GlobalPoint | LocalPoint>(
+  point: Point,
+  shape: GeometricShape<Point>,
+) => {
   switch (shape.type) {
     case "polygon":
-      return pointInPolygon(point, shape.data);
+      return polygonIncludesPoint(point, shape.data);
     case "line":
       return false;
     case "curve":
@@ -49,8 +58,8 @@ export const isPointInShape = (point: Point, shape: GeometricShape) => {
     case "ellipse":
       return pointInEllipse(point, shape.data);
     case "polyline": {
-      const polygon = close(shape.data.flat()) as Polygon;
-      return pointInPolygon(point, polygon);
+      const polygon = polygonFromPoints(shape.data.flat());
+      return polygonIncludesPoint(point, polygon);
     }
     case "polycurve": {
       return false;
@@ -61,6 +70,67 @@ export const isPointInShape = (point: Point, shape: GeometricShape) => {
 };
 
 // check if the given element is in the given bounds
-export const isPointInBounds = (point: Point, bounds: Polygon) => {
-  return pointInPolygon(point, bounds);
+export const isPointInBounds = <Point extends GlobalPoint | LocalPoint>(
+  point: Point,
+  bounds: Polygon<Point>,
+) => {
+  return polygonIncludesPoint(point, bounds);
+};
+
+const pointOnPolycurve = <Point extends LocalPoint | GlobalPoint>(
+  point: Point,
+  polycurve: Polycurve<Point>,
+  tolerance: number,
+) => {
+  return polycurve.some((curve) => pointOnCurve(point, curve, tolerance));
+};
+
+const cubicBezierEquation = <Point extends LocalPoint | GlobalPoint>(
+  curve: Curve<Point>,
+) => {
+  const [p0, p1, p2, p3] = curve;
+  // B(t) = p0 * (1-t)^3 + 3p1 * t * (1-t)^2 + 3p2 * t^2 * (1-t) + p3 * t^3
+  return (t: number, idx: number) =>
+    Math.pow(1 - t, 3) * p3[idx] +
+    3 * t * Math.pow(1 - t, 2) * p2[idx] +
+    3 * Math.pow(t, 2) * (1 - t) * p1[idx] +
+    p0[idx] * Math.pow(t, 3);
+};
+
+const polyLineFromCurve = <Point extends LocalPoint | GlobalPoint>(
+  curve: Curve<Point>,
+  segments = 10,
+): Polyline<Point> => {
+  const equation = cubicBezierEquation(curve);
+  let startingPoint = [equation(0, 0), equation(0, 1)] as Point;
+  const lineSegments: Polyline<Point> = [];
+  let t = 0;
+  const increment = 1 / segments;
+
+  for (let i = 0; i < segments; i++) {
+    t += increment;
+    if (t <= 1) {
+      const nextPoint: Point = point(equation(t, 0), equation(t, 1));
+      lineSegments.push(lineSegment(startingPoint, nextPoint));
+      startingPoint = nextPoint;
+    }
+  }
+
+  return lineSegments;
+};
+
+export const pointOnCurve = <Point extends LocalPoint | GlobalPoint>(
+  point: Point,
+  curve: Curve<Point>,
+  threshold: number,
+) => {
+  return pointOnPolyline(point, polyLineFromCurve(curve), threshold);
+};
+
+export const pointOnPolyline = <Point extends LocalPoint | GlobalPoint>(
+  point: Point,
+  polyline: Polyline<Point>,
+  threshold = 10e-5,
+) => {
+  return polyline.some((line) => pointOnLineSegment(point, line, threshold));
 };

+ 77 - 204
packages/utils/geometry/geometry.test.ts

@@ -1,249 +1,122 @@
+import type { GlobalPoint, LineSegment, Polygon, Radians } from "../../math";
 import {
-  lineIntersectsLine,
-  lineRotate,
-  pointInEllipse,
-  pointInPolygon,
-  pointLeftofLine,
-  pointOnCurve,
-  pointOnEllipse,
-  pointOnLine,
+  point,
+  lineSegment,
+  polygon,
+  pointOnLineSegment,
   pointOnPolygon,
-  pointOnPolyline,
-  pointRightofLine,
-  pointRotate,
-} from "./geometry";
-import type { Curve, Ellipse, Line, Point, Polygon, Polyline } from "./shape";
+  polygonIncludesPoint,
+  segmentsIntersectAt,
+} from "../../math";
+import { pointInEllipse, pointOnEllipse, type Ellipse } 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);
-  });
+  // const l: Line<GlobalPoint> = line(point(1, 0), point(1, 2));
 
-  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);
-  });
-});
+  // it("point on left or right of line", () => {
+  //   expect(pointLeftofLine(point(0, 1), l)).toBe(true);
+  //   expect(pointLeftofLine(point(1, 1), l)).toBe(false);
+  //   expect(pointLeftofLine(point(2, 1), l)).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],
-    ],
-  ];
+  //   expect(pointRightofLine(point(0, 1), l)).toBe(false);
+  //   expect(pointRightofLine(point(1, 1), l)).toBe(false);
+  //   expect(pointRightofLine(point(2, 1), l)).toBe(true);
+  // });
 
-  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);
-  });
+  const s: LineSegment<GlobalPoint> = lineSegment(point(1, 0), point(1, 2));
 
-  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);
-    });
+  it("point on the line", () => {
+    expect(pointOnLineSegment(point(0, 1), s)).toBe(false);
+    expect(pointOnLineSegment(point(1, 1), s, 0)).toBe(true);
+    expect(pointOnLineSegment(point(2, 1), s)).toBe(false);
   });
 });
 
 describe("point and polygon", () => {
-  const polygon: Polygon = [
-    [10, 10],
-    [50, 10],
-    [50, 50],
-    [10, 50],
-  ];
+  const poly: Polygon<GlobalPoint> = polygon(
+    point(10, 10),
+    point(50, 10),
+    point(50, 50),
+    point(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);
+    expect(pointOnPolygon(point(30, 10), poly)).toBe(true);
+    expect(pointOnPolygon(point(50, 30), poly)).toBe(true);
+    expect(pointOnPolygon(point(30, 50), poly)).toBe(true);
+    expect(pointOnPolygon(point(10, 30), poly)).toBe(true);
+    expect(pointOnPolygon(point(30, 30), poly)).toBe(false);
+    expect(pointOnPolygon(point(30, 70), poly)).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);
+    const poly: Polygon<GlobalPoint> = polygon(
+      point(0, 0),
+      point(2, 0),
+      point(2, 2),
+      point(0, 2),
+    );
+    expect(polygonIncludesPoint(point(1, 1), poly)).toBe(true);
+    expect(polygonIncludesPoint(point(3, 3), poly)).toBe(false);
   });
 });
 
 describe("point and ellipse", () => {
-  const ellipse: Ellipse = {
-    center: [0, 0],
-    angle: 0,
+  const ellipse: Ellipse<GlobalPoint> = {
+    center: point(0, 0),
+    angle: 0 as Radians,
     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);
+    [point(0, 1), point(0, -1), point(2, 0), point(-2, 0)].forEach((p) => {
+      expect(pointOnEllipse(p, 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(point(-1.4, 0.7), ellipse, 0.1)).toBe(true);
+    expect(pointOnEllipse(point(-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(point(1.4, 0.7), ellipse, 0.1)).toBe(true);
+    expect(pointOnEllipse(point(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(point(1, -0.86), ellipse, 0.1)).toBe(true);
+    expect(pointOnEllipse(point(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(point(-1, -0.86), ellipse, 0.1)).toBe(true);
+    expect(pointOnEllipse(point(-1, -0.86), ellipse, 0.01)).toBe(true);
 
-    expect(pointOnEllipse([-1, 0.8], ellipse)).toBe(false);
-    expect(pointOnEllipse([1, -0.8], ellipse)).toBe(false);
+    expect(pointOnEllipse(point(-1, 0.8), ellipse)).toBe(false);
+    expect(pointOnEllipse(point(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);
+    [point(0, 1), point(0, -1), point(2, 0), point(-2, 0)].forEach((p) => {
+      expect(pointInEllipse(p, ellipse)).toBe(true);
     });
 
-    expect(pointInEllipse([-1, 0.8], ellipse)).toBe(true);
-    expect(pointInEllipse([1, -0.8], ellipse)).toBe(true);
+    expect(pointInEllipse(point(-1, 0.8), ellipse)).toBe(true);
+    expect(pointInEllipse(point(1, -0.8), ellipse)).toBe(true);
 
-    expect(pointInEllipse([-1, 1], ellipse)).toBe(false);
-    expect(pointInEllipse([-1.4, 0.8], ellipse)).toBe(false);
+    expect(pointInEllipse(point(-1, 1), ellipse)).toBe(false);
+    expect(pointInEllipse(point(-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],
-  ];
+  const lineA: LineSegment<GlobalPoint> = lineSegment(point(1, 4), point(3, 4));
+  const lineB: LineSegment<GlobalPoint> = lineSegment(point(2, 1), point(2, 7));
+  const lineC: LineSegment<GlobalPoint> = lineSegment(point(1, 8), point(3, 8));
+  const lineD: LineSegment<GlobalPoint> = lineSegment(point(1, 8), point(3, 8));
+  const lineE: LineSegment<GlobalPoint> = lineSegment(point(1, 9), point(3, 9));
+  const lineF: LineSegment<GlobalPoint> = lineSegment(point(1, 2), point(3, 4));
+  const lineG: LineSegment<GlobalPoint> = lineSegment(point(0, 1), point(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);
+    expect(segmentsIntersectAt(lineA, lineB)).toEqual([2, 4]);
+    expect(segmentsIntersectAt(lineA, lineC)).toBe(null);
+    expect(segmentsIntersectAt(lineB, lineC)).toBe(null);
+    expect(segmentsIntersectAt(lineC, lineD)).toBe(null); // Line overlapping line is not intersection!
+    expect(segmentsIntersectAt(lineE, lineD)).toBe(null);
+    expect(segmentsIntersectAt(lineF, lineG)).toBe(null);
   });
 });

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

@@ -1,1060 +0,0 @@
-import type { ExcalidrawBindableElement } from "../../excalidraw/element/types";
-import {
-  addVectors,
-  distance2d,
-  rotatePoint,
-  scaleVector,
-  subtractVectors,
-} from "../../excalidraw/math";
-import type { LineSegment } from "../bbox";
-import { crossProduct } from "../bbox";
-import type {
-  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: Readonly<Point>,
-  b: Readonly<Point>,
-  o: Readonly<Point>,
-) => {
-  return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
-};
-
-export const dot = (
-  a: Readonly<Point>,
-  b: Readonly<Point>,
-  o: Readonly<Point>,
-) => {
-  return (a[0] - o[0]) * (b[0] - o[0]) + (a[1] - o[1]) * (b[1] - o[1]);
-};
-
-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) => {
-  const theta = (angle * 180) / Math.PI;
-
-  return theta < 0 ? 360 + theta : theta;
-};
-
-// 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
-  );
-};
-
-/**
- * Calculates the point two line segments with a definite start and end point
- * intersect at.
- */
-export const segmentsIntersectAt = (
-  a: Readonly<LineSegment>,
-  b: Readonly<LineSegment>,
-): Point | null => {
-  const r = subtractVectors(a[1], a[0]);
-  const s = subtractVectors(b[1], b[0]);
-  const denominator = crossProduct(r, s);
-
-  if (denominator === 0) {
-    return null;
-  }
-
-  const i = subtractVectors(b[0], a[0]);
-  const u = crossProduct(i, r) / denominator;
-  const t = crossProduct(i, s) / denominator;
-
-  if (u === 0) {
-    return null;
-  }
-
-  const p = addVectors(a[0], scaleVector(r, t));
-
-  if (t >= 0 && t < 1 && u >= 0 && u < 1) {
-    return p;
-  }
-
-  return null;
-};
-
-/**
- * Determine intersection of a rectangular shaped element and a
- * line segment.
- *
- * @param element The rectangular element to test against
- * @param segment The segment intersecting the element
- * @param gap Optional value to inflate the shape before testing
- * @returns An array of intersections
- */
-// TODO: Replace with final rounded rectangle code
-export const segmentIntersectRectangleElement = (
-  element: ExcalidrawBindableElement,
-  segment: LineSegment,
-  gap: number = 0,
-): Point[] => {
-  const bounds = [
-    element.x - gap,
-    element.y - gap,
-    element.x + element.width + gap,
-    element.y + element.height + gap,
-  ];
-  const center = [
-    (bounds[0] + bounds[2]) / 2,
-    (bounds[1] + bounds[3]) / 2,
-  ] as Point;
-
-  return [
-    [
-      rotatePoint([bounds[0], bounds[1]], center, element.angle),
-      rotatePoint([bounds[2], bounds[1]], center, element.angle),
-    ] as LineSegment,
-    [
-      rotatePoint([bounds[2], bounds[1]], center, element.angle),
-      rotatePoint([bounds[2], bounds[3]], center, element.angle),
-    ] as LineSegment,
-    [
-      rotatePoint([bounds[2], bounds[3]], center, element.angle),
-      rotatePoint([bounds[0], bounds[3]], center, element.angle),
-    ] as LineSegment,
-    [
-      rotatePoint([bounds[0], bounds[3]], center, element.angle),
-      rotatePoint([bounds[0], bounds[1]], center, element.angle),
-    ] as LineSegment,
-  ]
-    .map((s) => segmentsIntersectAt(segment, s))
-    .filter((i): i is Point => !!i);
-};

+ 314 - 102
packages/utils/geometry/shape.ts

@@ -12,9 +12,30 @@
  * to pure shapes
  */
 
+import type { Curve, LineSegment, Polygon, Radians } from "../../math";
+import {
+  curve,
+  lineSegment,
+  point,
+  pointDistance,
+  pointFromArray,
+  pointFromVector,
+  pointRotateRads,
+  polygon,
+  polygonFromPoints,
+  PRECISION,
+  segmentsIntersectAt,
+  vector,
+  vectorAdd,
+  vectorFromPoint,
+  vectorScale,
+  type GlobalPoint,
+  type LocalPoint,
+} from "../../math";
 import { getElementAbsoluteCoords } from "../../excalidraw/element";
 import type {
   ElementsMap,
+  ExcalidrawBindableElement,
   ExcalidrawDiamondElement,
   ExcalidrawElement,
   ExcalidrawEllipseElement,
@@ -28,67 +49,54 @@ import type {
   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];
+import { invariant } from "../../excalidraw/utils";
 
 // 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];
+export type Polyline<Point extends GlobalPoint | LocalPoint> =
+  LineSegment<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[];
+export type Polycurve<Point extends GlobalPoint | LocalPoint> = Curve<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 = {
+export type Ellipse<Point extends GlobalPoint | LocalPoint> = {
   center: Point;
-  angle: number;
+  angle: Radians;
   halfWidth: number;
   halfHeight: number;
 };
 
-export type GeometricShape =
+export type GeometricShape<Point extends GlobalPoint | LocalPoint> =
   | {
       type: "line";
-      data: Line;
+      data: LineSegment<Point>;
     }
   | {
       type: "polygon";
-      data: Polygon;
+      data: Polygon<Point>;
     }
   | {
       type: "curve";
-      data: Curve;
+      data: Curve<Point>;
     }
   | {
       type: "ellipse";
-      data: Ellipse;
+      data: Ellipse<Point>;
     }
   | {
       type: "polyline";
-      data: Polyline;
+      data: Polyline<Point>;
     }
   | {
       type: "polycurve";
-      data: Polycurve;
+      data: Polycurve<Point>;
     };
 
 type RectangularElement =
@@ -102,32 +110,32 @@ type RectangularElement =
   | ExcalidrawSelectionElement;
 
 // polygon
-export const getPolygonShape = (
+export const getPolygonShape = <Point extends GlobalPoint | LocalPoint>(
   element: RectangularElement,
-): GeometricShape => {
+): GeometricShape<Point> => {
   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];
+  const center: Point = point(cx, cy);
 
-  let data: Polygon = [];
+  let data: Polygon<Point>;
 
   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;
+    data = polygon(
+      pointRotateRads(point(cx, y), center, angle),
+      pointRotateRads(point(x + width, cy), center, angle),
+      pointRotateRads(point(cx, y + height), center, angle),
+      pointRotateRads(point(x, cy), center, angle),
+    );
   } 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;
+    data = polygon(
+      pointRotateRads(point(x, y), center, angle),
+      pointRotateRads(point(x + width, y), center, angle),
+      pointRotateRads(point(x + width, y + height), center, angle),
+      pointRotateRads(point(x, y + height), center, angle),
+    );
   }
 
   return {
@@ -137,7 +145,7 @@ export const getPolygonShape = (
 };
 
 // return the selection box for an element, possibly rotated as well
-export const getSelectionBoxShape = (
+export const getSelectionBoxShape = <Point extends GlobalPoint | LocalPoint>(
   element: ExcalidrawElement,
   elementsMap: ElementsMap,
   padding = 10,
@@ -153,29 +161,29 @@ export const getSelectionBoxShape = (
   y1 -= padding;
   y2 += padding;
 
-  const angleInDegrees = angleToDegrees(element.angle);
-  const center: Point = [cx, cy];
-  const topLeft = pointRotate([x1, y1], angleInDegrees, center);
-  const topRight = pointRotate([x2, y1], angleInDegrees, center);
-  const bottomLeft = pointRotate([x1, y2], angleInDegrees, center);
-  const bottomRight = pointRotate([x2, y2], angleInDegrees, center);
+  //const angleInDegrees = angleToDegrees(element.angle);
+  const center = point(cx, cy);
+  const topLeft = pointRotateRads(point(x1, y1), center, element.angle);
+  const topRight = pointRotateRads(point(x2, y1), center, element.angle);
+  const bottomLeft = pointRotateRads(point(x1, y2), center, element.angle);
+  const bottomRight = pointRotateRads(point(x2, y2), center, element.angle);
 
   return {
     type: "polygon",
     data: [topLeft, topRight, bottomRight, bottomLeft],
-  } as GeometricShape;
+  } as GeometricShape<Point>;
 };
 
 // ellipse
-export const getEllipseShape = (
+export const getEllipseShape = <Point extends GlobalPoint | LocalPoint>(
   element: ExcalidrawEllipseElement,
-): GeometricShape => {
+): GeometricShape<Point> => {
   const { width, height, angle, x, y } = element;
 
   return {
     type: "ellipse",
     data: {
-      center: [x + width / 2, y + height / 2],
+      center: point(x + width / 2, y + height / 2),
       angle,
       halfWidth: width / 2,
       halfHeight: height / 2,
@@ -193,32 +201,34 @@ export const getCurvePathOps = (shape: Drawable): Op[] => {
 };
 
 // linear
-export const getCurveShape = (
+export const getCurveShape = <Point extends GlobalPoint | LocalPoint>(
   roughShape: Drawable,
-  startingPoint: Point = [0, 0],
-  angleInRadian: number,
+  startingPoint: Point = point(0, 0),
+  angleInRadian: Radians,
   center: Point,
-): GeometricShape => {
-  const transform = (p: Point) =>
-    pointRotate(
-      [p[0] + startingPoint[0], p[1] + startingPoint[1]],
-      angleToDegrees(angleInRadian),
+): GeometricShape<Point> => {
+  const transform = (p: Point): Point =>
+    pointRotateRads(
+      point(p[0] + startingPoint[0], p[1] + startingPoint[1]),
       center,
+      angleInRadian,
     );
 
   const ops = getCurvePathOps(roughShape);
-  const polycurve: Polycurve = [];
-  let p0: Point = [0, 0];
+  const polycurve: Polycurve<Point> = [];
+  let p0 = point<Point>(0, 0);
 
   for (const op of ops) {
     if (op.op === "move") {
-      p0 = transform(op.data as Point);
+      const p = pointFromArray<Point>(op.data);
+      invariant(p != null, "Ops data is not a point");
+      p0 = transform(p);
     }
     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]);
+      const p1 = transform(point<Point>(op.data[0], op.data[1]));
+      const p2 = transform(point<Point>(op.data[2], op.data[3]));
+      const p3 = transform(point<Point>(op.data[4], op.data[5]));
+      polycurve.push(curve<Point>(p0, p1, p2, p3));
       p0 = p3;
     }
   }
@@ -229,61 +239,72 @@ export const getCurveShape = (
   };
 };
 
-const polylineFromPoints = (points: Point[]) => {
-  let previousPoint = points[0];
-  const polyline: Polyline = [];
+const polylineFromPoints = <Point extends GlobalPoint | LocalPoint>(
+  points: Point[],
+): Polyline<Point> => {
+  let previousPoint: Point = points[0];
+  const polyline: LineSegment<Point>[] = [];
 
   for (let i = 1; i < points.length; i++) {
     const nextPoint = points[i];
-    polyline.push([previousPoint, nextPoint]);
+    polyline.push(lineSegment<Point>(previousPoint, nextPoint));
     previousPoint = nextPoint;
   }
 
   return polyline;
 };
 
-export const getFreedrawShape = (
+export const getFreedrawShape = <Point extends GlobalPoint | LocalPoint>(
   element: ExcalidrawFreeDrawElement,
   center: Point,
   isClosed: boolean = false,
-): GeometricShape => {
-  const angle = angleToDegrees(element.angle);
+): GeometricShape<Point> => {
   const transform = (p: Point) =>
-    pointRotate(pointAdd(p, [element.x, element.y] as Point), angle, center);
+    pointRotateRads(
+      pointFromVector(
+        vectorAdd(vectorFromPoint(p), vector(element.x, element.y)),
+      ),
+      center,
+      element.angle,
+    );
 
   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,
-      };
+  return (
+    isClosed
+      ? {
+          type: "polygon",
+          data: polygonFromPoints(polyline.flat()),
+        }
+      : {
+          type: "polyline",
+          data: polyline,
+        }
+  ) as GeometricShape<Point>;
 };
 
-export const getClosedCurveShape = (
+export const getClosedCurveShape = <Point extends GlobalPoint | LocalPoint>(
   element: ExcalidrawLinearElement,
   roughShape: Drawable,
-  startingPoint: Point = [0, 0],
-  angleInRadian: number,
+  startingPoint: Point = point<Point>(0, 0),
+  angleInRadian: Radians,
   center: Point,
-): GeometricShape => {
+): GeometricShape<Point> => {
   const transform = (p: Point) =>
-    pointRotate(
-      [p[0] + startingPoint[0], p[1] + startingPoint[1]],
-      angleToDegrees(angleInRadian),
+    pointRotateRads(
+      point(p[0] + startingPoint[0], p[1] + startingPoint[1]),
       center,
+      angleInRadian,
     );
 
   if (element.roundness === null) {
     return {
       type: "polygon",
-      data: close(element.points.map((p) => transform(p as Point))),
+      data: polygonFromPoints(
+        element.points.map((p) => transform(p as Point)) as Point[],
+      ),
     };
   }
 
@@ -295,27 +316,218 @@ export const getClosedCurveShape = (
     if (operation.op === "move") {
       odd = !odd;
       if (odd) {
-        points.push([operation.data[0], operation.data[1]]);
+        points.push(point(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]]);
+        points.push(point(operation.data[0], operation.data[1]));
+        points.push(point(operation.data[2], operation.data[3]));
+        points.push(point(operation.data[4], operation.data[5]));
       }
     } else if (operation.op === "lineTo") {
       if (odd) {
-        points.push([operation.data[0], operation.data[1]]);
+        points.push(point(operation.data[0], operation.data[1]));
       }
     }
   }
 
   const polygonPoints = pointsOnBezierCurves(points, 10, 5).map((p) =>
-    transform(p),
-  );
+    transform(p as Point),
+  ) as Point[];
 
   return {
     type: "polygon",
-    data: polygonPoints,
+    data: polygonFromPoints<Point>(polygonPoints),
   };
 };
+
+/**
+ * Determine intersection of a rectangular shaped element and a
+ * line segment.
+ *
+ * @param element The rectangular element to test against
+ * @param segment The segment intersecting the element
+ * @param gap Optional value to inflate the shape before testing
+ * @returns An array of intersections
+ */
+// TODO: Replace with final rounded rectangle code
+export const segmentIntersectRectangleElement = <
+  Point extends LocalPoint | GlobalPoint,
+>(
+  element: ExcalidrawBindableElement,
+  segment: LineSegment<Point>,
+  gap: number = 0,
+): Point[] => {
+  const bounds = [
+    element.x - gap,
+    element.y - gap,
+    element.x + element.width + gap,
+    element.y + element.height + gap,
+  ];
+  const center = point(
+    (bounds[0] + bounds[2]) / 2,
+    (bounds[1] + bounds[3]) / 2,
+  );
+
+  return [
+    lineSegment(
+      pointRotateRads(point(bounds[0], bounds[1]), center, element.angle),
+      pointRotateRads(point(bounds[2], bounds[1]), center, element.angle),
+    ),
+    lineSegment(
+      pointRotateRads(point(bounds[2], bounds[1]), center, element.angle),
+      pointRotateRads(point(bounds[2], bounds[3]), center, element.angle),
+    ),
+    lineSegment(
+      pointRotateRads(point(bounds[2], bounds[3]), center, element.angle),
+      pointRotateRads(point(bounds[0], bounds[3]), center, element.angle),
+    ),
+    lineSegment(
+      pointRotateRads(point(bounds[0], bounds[3]), center, element.angle),
+      pointRotateRads(point(bounds[0], bounds[1]), center, element.angle),
+    ),
+  ]
+    .map((s) => segmentsIntersectAt(segment, s))
+    .filter((i): i is Point => !!i);
+};
+
+const distanceToEllipse = <Point extends LocalPoint | GlobalPoint>(
+  p: Point,
+  ellipse: Ellipse<Point>,
+) => {
+  const { angle, halfWidth, halfHeight, center } = ellipse;
+  const a = halfWidth;
+  const b = halfHeight;
+  const translatedPoint = vectorAdd(
+    vectorFromPoint(p),
+    vectorScale(vectorFromPoint(center), -1),
+  );
+  const [rotatedPointX, rotatedPointY] = pointRotateRads(
+    pointFromVector(translatedPoint),
+    point(0, 0),
+    -angle as Radians,
+  );
+
+  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 pointDistance(point(rotatedPointX, rotatedPointY), point(minX, minY));
+};
+
+export const pointOnEllipse = <Point extends LocalPoint | GlobalPoint>(
+  point: Point,
+  ellipse: Ellipse<Point>,
+  threshold = PRECISION,
+) => {
+  return distanceToEllipse(point, ellipse) <= threshold;
+};
+
+export const pointInEllipse = <Point extends LocalPoint | GlobalPoint>(
+  p: Point,
+  ellipse: Ellipse<Point>,
+) => {
+  const { center, angle, halfWidth, halfHeight } = ellipse;
+  const translatedPoint = vectorAdd(
+    vectorFromPoint(p),
+    vectorScale(vectorFromPoint(center), -1),
+  );
+  const [rotatedPointX, rotatedPointY] = pointRotateRads(
+    pointFromVector(translatedPoint),
+    point(0, 0),
+    -angle as Radians,
+  );
+
+  return (
+    (rotatedPointX / halfWidth) * (rotatedPointX / halfWidth) +
+      (rotatedPointY / halfHeight) * (rotatedPointY / halfHeight) <=
+    1
+  );
+};
+
+export const ellipseAxes = <Point extends LocalPoint | GlobalPoint>(
+  ellipse: Ellipse<Point>,
+) => {
+  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 = <Point extends LocalPoint | GlobalPoint>(
+  ellipse: Ellipse<Point>,
+) => {
+  const { majorAxis, minorAxis } = ellipseAxes(ellipse);
+
+  return Math.sqrt(majorAxis ** 2 - minorAxis ** 2);
+};
+
+export const ellipseExtremes = <Point extends LocalPoint | GlobalPoint>(
+  ellipse: Ellipse<Point>,
+) => {
+  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);
+  const centerVector = vectorFromPoint(center);
+
+  return [
+    vectorAdd(vector(xAtYMax, yMax), centerVector),
+    vectorAdd(vectorScale(vector(xAtYMax, yMax), -1), centerVector),
+    vectorAdd(vector(xMax, yAtXMax), centerVector),
+    vectorAdd(vector(xMax, yAtXMax), centerVector),
+  ];
+};

+ 35 - 19
packages/utils/withinBounds.ts

@@ -11,16 +11,21 @@ import {
   isLinearElement,
   isTextElement,
 } from "../excalidraw/element/typeChecks";
-import { isValueInRange, rotatePoint } from "../excalidraw/math";
-import type { Point } from "../excalidraw/types";
 import type { Bounds } from "../excalidraw/element/bounds";
 import { getElementBounds } from "../excalidraw/element/bounds";
 import { arrayToMap } from "../excalidraw/utils";
+import type { LocalPoint } from "../math";
+import {
+  rangeIncludesValue,
+  point,
+  pointRotateRads,
+  rangeInclusive,
+} from "../math";
 
 type Element = NonDeletedExcalidrawElement;
 type Elements = readonly NonDeletedExcalidrawElement[];
 
-type Points = readonly Point[];
+type Points = readonly LocalPoint[];
 
 /** @returns vertices relative to element's top-left [0,0] position  */
 const getNonLinearElementRelativePoints = (
@@ -28,20 +33,25 @@ const getNonLinearElementRelativePoints = (
     Element,
     ExcalidrawLinearElement | ExcalidrawFreeDrawElement
   >,
-): [TopLeft: Point, TopRight: Point, BottomRight: Point, BottomLeft: Point] => {
+): [
+  TopLeft: LocalPoint,
+  TopRight: LocalPoint,
+  BottomRight: LocalPoint,
+  BottomLeft: LocalPoint,
+] => {
   if (element.type === "diamond") {
     return [
-      [element.width / 2, 0],
-      [element.width, element.height / 2],
-      [element.width / 2, element.height],
-      [0, element.height / 2],
+      point(element.width / 2, 0),
+      point(element.width, element.height / 2),
+      point(element.width / 2, element.height),
+      point(0, element.height / 2),
     ];
   }
   return [
-    [0, 0],
-    [0 + element.width, 0],
-    [0 + element.width, element.height],
-    [0, element.height],
+    point(0, 0),
+    point(0 + element.width, 0),
+    point(0 + element.width, element.height),
+    point(0, element.height),
   ];
 };
 
@@ -84,10 +94,10 @@ const getRotatedBBox = (element: Element): Bounds => {
   const points = getElementRelativePoints(element);
 
   const { cx, cy } = getMinMaxPoints(points);
-  const centerPoint: Point = [cx, cy];
+  const centerPoint = point<LocalPoint>(cx, cy);
 
-  const rotatedPoints = points.map((point) =>
-    rotatePoint([point[0], point[1]], centerPoint, element.angle),
+  const rotatedPoints = points.map((p) =>
+    pointRotateRads(p, centerPoint, element.angle),
   );
   const { minX, minY, maxX, maxY } = getMinMaxPoints(rotatedPoints);
 
@@ -135,10 +145,16 @@ export const elementPartiallyOverlapsWithOrContainsBBox = (
   const elementBBox = getRotatedBBox(element);
 
   return (
-    (isValueInRange(elementBBox[0], bbox[0], bbox[2]) ||
-      isValueInRange(bbox[0], elementBBox[0], elementBBox[2])) &&
-    (isValueInRange(elementBBox[1], bbox[1], bbox[3]) ||
-      isValueInRange(bbox[1], elementBBox[1], elementBBox[3]))
+    (rangeIncludesValue(elementBBox[0], rangeInclusive(bbox[0], bbox[2])) ||
+      rangeIncludesValue(
+        bbox[0],
+        rangeInclusive(elementBBox[0], elementBBox[2]),
+      )) &&
+    (rangeIncludesValue(elementBBox[1], rangeInclusive(bbox[1], bbox[3])) ||
+      rangeIncludesValue(
+        bbox[1],
+        rangeInclusive(elementBBox[1], elementBBox[3]),
+      ))
   );
 };
 

+ 108 - 0
scripts/buildMath.js

@@ -0,0 +1,108 @@
+const fs = require("fs");
+const { build } = require("esbuild");
+
+const browserConfig = {
+  entryPoints: ["index.ts"],
+  bundle: true,
+  format: "esm",
+};
+
+// Will be used later for treeshaking
+
+// function getFiles(dir, files = []) {
+//   const fileList = fs.readdirSync(dir);
+//   for (const file of fileList) {
+//     const name = `${dir}/${file}`;
+//     if (
+//       name.includes("node_modules") ||
+//       name.includes("config") ||
+//       name.includes("package.json") ||
+//       name.includes("main.js") ||
+//       name.includes("index-node.ts") ||
+//       name.endsWith(".d.ts") ||
+//       name.endsWith(".md")
+//     ) {
+//       continue;
+//     }
+
+//     if (fs.statSync(name).isDirectory()) {
+//       getFiles(name, files);
+//     } else if (
+//       name.match(/\.(sa|sc|c)ss$/) ||
+//       name.match(/\.(woff|woff2|eot|ttf|otf)$/) ||
+//       name.match(/locales\/[^/]+\.json$/)
+//     ) {
+//       continue;
+//     } else {
+//       files.push(name);
+//     }
+//   }
+//   return files;
+// }
+const createESMBrowserBuild = async () => {
+  // Development unminified build with source maps
+  const browserDev = await build({
+    ...browserConfig,
+    outdir: "dist/browser/dev",
+    sourcemap: true,
+    metafile: true,
+    define: {
+      "import.meta.env": JSON.stringify({ DEV: true }),
+    },
+  });
+  fs.writeFileSync(
+    "meta-browser-dev.json",
+    JSON.stringify(browserDev.metafile),
+  );
+
+  // production minified build without sourcemaps
+  const browserProd = await build({
+    ...browserConfig,
+    outdir: "dist/browser/prod",
+    minify: true,
+    metafile: true,
+    define: {
+      "import.meta.env": JSON.stringify({ PROD: true }),
+    },
+  });
+  fs.writeFileSync(
+    "meta-browser-prod.json",
+    JSON.stringify(browserProd.metafile),
+  );
+};
+
+const rawConfig = {
+  entryPoints: ["index.ts"],
+  bundle: true,
+  format: "esm",
+  packages: "external",
+};
+
+const createESMRawBuild = async () => {
+  // Development unminified build with source maps
+  const rawDev = await build({
+    ...rawConfig,
+    outdir: "dist/dev",
+    sourcemap: true,
+    metafile: true,
+    define: {
+      "import.meta.env": JSON.stringify({ DEV: true }),
+    },
+  });
+  fs.writeFileSync("meta-raw-dev.json", JSON.stringify(rawDev.metafile));
+
+  // production minified build without sourcemaps
+  const rawProd = await build({
+    ...rawConfig,
+    outdir: "dist/prod",
+    minify: true,
+    metafile: true,
+    define: {
+      "import.meta.env": JSON.stringify({ PROD: true }),
+    },
+  });
+  fs.writeFileSync("meta-raw-prod.json", JSON.stringify(rawProd.metafile));
+};
+
+createESMRawBuild();
+createESMBrowserBuild();