Răsfoiți Sursa

fix: Mid-point for rounded linears are not precisely centered (#9544)

Márk Tolmács 3 luni în urmă
părinte
comite
f0458cc216

+ 0 - 95
packages/element/src/ShapeCache.ts

@@ -1,95 +0,0 @@
-import { RoughGenerator } from "roughjs/bin/generator";
-
-import { COLOR_PALETTE } from "@excalidraw/common";
-
-import type {
-  AppState,
-  EmbedsValidationStatus,
-} from "@excalidraw/excalidraw/types";
-import type {
-  ElementShape,
-  ElementShapes,
-} from "@excalidraw/excalidraw/scene/types";
-
-import { _generateElementShape } from "./Shape";
-
-import { elementWithCanvasCache } from "./renderElement";
-
-import type { ExcalidrawElement, ExcalidrawSelectionElement } from "./types";
-
-import type { Drawable } from "roughjs/bin/core";
-
-export class ShapeCache {
-  private static rg = new RoughGenerator();
-  private static cache = new WeakMap<ExcalidrawElement, ElementShape>();
-
-  /**
-   * Retrieves shape from cache if available. Use this only if shape
-   * is optional and you have a fallback in case it's not cached.
-   */
-  public static get = <T extends ExcalidrawElement>(element: T) => {
-    return ShapeCache.cache.get(
-      element,
-    ) as T["type"] extends keyof ElementShapes
-      ? ElementShapes[T["type"]] | undefined
-      : ElementShape | undefined;
-  };
-
-  public static set = <T extends ExcalidrawElement>(
-    element: T,
-    shape: T["type"] extends keyof ElementShapes
-      ? ElementShapes[T["type"]]
-      : Drawable,
-  ) => ShapeCache.cache.set(element, shape);
-
-  public static delete = (element: ExcalidrawElement) =>
-    ShapeCache.cache.delete(element);
-
-  public static destroy = () => {
-    ShapeCache.cache = new WeakMap();
-  };
-
-  /**
-   * Generates & caches shape for element if not already cached, otherwise
-   * returns cached shape.
-   */
-  public static generateElementShape = <
-    T extends Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
-  >(
-    element: T,
-    renderConfig: {
-      isExporting: boolean;
-      canvasBackgroundColor: AppState["viewBackgroundColor"];
-      embedsValidationStatus: EmbedsValidationStatus;
-    } | null,
-  ) => {
-    // when exporting, always regenerated to guarantee the latest shape
-    const cachedShape = renderConfig?.isExporting
-      ? undefined
-      : ShapeCache.get(element);
-
-    // `null` indicates no rc shape applicable for this element type,
-    // but it's considered a valid cache value (= do not regenerate)
-    if (cachedShape !== undefined) {
-      return cachedShape;
-    }
-
-    elementWithCanvasCache.delete(element);
-
-    const shape = _generateElementShape(
-      element,
-      ShapeCache.rg,
-      renderConfig || {
-        isExporting: false,
-        canvasBackgroundColor: COLOR_PALETTE.white,
-        embedsValidationStatus: null,
-      },
-    ) as T["type"] extends keyof ElementShapes
-      ? ElementShapes[T["type"]]
-      : Drawable | null;
-
-    ShapeCache.cache.set(element, shape);
-
-    return shape;
-  };
-}

+ 1 - 1
packages/element/src/binding.ts

@@ -61,7 +61,7 @@ import {
   isTextElement,
 } from "./typeChecks";
 
-import { aabbForElement } from "./shapes";
+import { aabbForElement } from "./bounds";
 import { updateElbowArrowPoints } from "./elbowArrow";
 
 import type { Scene } from "./Scene";

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

@@ -2,6 +2,7 @@ import rough from "roughjs/bin/rough";
 
 import {
   arrayToMap,
+  elementCenterPoint,
   invariant,
   rescalePoints,
   sizeOf,
@@ -33,8 +34,8 @@ import type { AppState } from "@excalidraw/excalidraw/types";
 
 import type { Mutable } from "@excalidraw/common/utility-types";
 
-import { generateRoughOptions } from "./Shape";
-import { ShapeCache } from "./ShapeCache";
+import { generateRoughOptions } from "./shape";
+import { ShapeCache } from "./shape";
 import { LinearElementEditor } from "./linearElementEditor";
 import { getBoundTextElement, getContainerElement } from "./textElement";
 import {
@@ -45,7 +46,7 @@ import {
   isTextElement,
 } from "./typeChecks";
 
-import { getElementShape } from "./shapes";
+import { getElementShape } from "./shape";
 
 import {
   deconstructDiamondElement,
@@ -1178,3 +1179,68 @@ export const doBoundsIntersect = (
 
   return minX1 < maxX2 && maxX1 > minX2 && minY1 < maxY2 && maxY1 > minY2;
 };
+
+/**
+ * Get the axis-aligned bounding box for a given element
+ */
+export const aabbForElement = (
+  element: Readonly<ExcalidrawElement>,
+  elementsMap: ElementsMap,
+  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 = elementCenterPoint(element, elementsMap);
+  const [topLeftX, topLeftY] = pointRotateRads(
+    pointFrom(bbox.minX, bbox.minY),
+    center,
+    element.angle,
+  );
+  const [topRightX, topRightY] = pointRotateRads(
+    pointFrom(bbox.maxX, bbox.minY),
+    center,
+    element.angle,
+  );
+  const [bottomRightX, bottomRightY] = pointRotateRads(
+    pointFrom(bbox.maxX, bbox.maxY),
+    center,
+    element.angle,
+  );
+  const [bottomLeftX, bottomLeftY] = pointRotateRads(
+    pointFrom(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];

+ 3 - 12
packages/element/src/collision.ts

@@ -22,7 +22,7 @@ import type { GlobalPoint, LineSegment, Radians } from "@excalidraw/math";
 
 import type { FrameNameBounds } from "@excalidraw/excalidraw/types";
 
-import { isPathALoop } from "./shapes";
+import { isPathALoop } from "./utils";
 import {
   type Bounds,
   doBoundsIntersect,
@@ -250,25 +250,16 @@ export const intersectElementWithLineSegment = (
     case "line":
     case "freedraw":
     case "arrow":
-      return intersectLinearOrFreeDrawWithLineSegment(
-        element,
-        elementsMap,
-        line,
-        onlyFirst,
-      );
+      return intersectLinearOrFreeDrawWithLineSegment(element, line, onlyFirst);
   }
 };
 
 const intersectLinearOrFreeDrawWithLineSegment = (
   element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
-  elementsMap: ElementsMap,
   segment: LineSegment<GlobalPoint>,
   onlyFirst = false,
 ): GlobalPoint[] => {
-  const [lines, curves] = deconstructLinearOrFreeDrawElement(
-    element,
-    elementsMap,
-  );
+  const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
   const intersections = [];
 
   for (const l of lines) {

+ 2 - 6
packages/element/src/distance.ts

@@ -48,7 +48,7 @@ export const distanceToElement = (
     case "line":
     case "arrow":
     case "freedraw":
-      return distanceToLinearOrFreeDraElement(element, elementsMap, p);
+      return distanceToLinearOrFreeDraElement(element, p);
   }
 };
 
@@ -133,13 +133,9 @@ const distanceToEllipseElement = (
 
 const distanceToLinearOrFreeDraElement = (
   element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
-  elementsMap: ElementsMap,
   p: GlobalPoint,
 ) => {
-  const [lines, curves] = deconstructLinearOrFreeDrawElement(
-    element,
-    elementsMap,
-  );
+  const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
   return Math.min(
     ...lines.map((s) => distanceToLineSegment(p, s)),
     ...curves.map((a) => curvePointDistance(a, p)),

+ 1 - 1
packages/element/src/elbowArrow.ts

@@ -52,7 +52,7 @@ import {
   type NonDeletedSceneElementsMap,
 } from "./types";
 
-import { aabbForElement, pointInsideBounds } from "./shapes";
+import { aabbForElement, pointInsideBounds } from "./bounds";
 
 import type { Bounds } from "./bounds";
 import type { Heading } from "./heading";

+ 1 - 1
packages/element/src/flowchart.ts

@@ -21,7 +21,7 @@ import {
 import { LinearElementEditor } from "./linearElementEditor";
 import { mutateElement } from "./mutateElement";
 import { newArrowElement, newElement } from "./newElement";
-import { aabbForElement } from "./shapes";
+import { aabbForElement } from "./bounds";
 import { elementsAreInFrameBounds, elementOverlapsWithFrame } from "./frame";
 import {
   isBindableElement,

+ 1 - 3
packages/element/src/index.ts

@@ -102,9 +102,7 @@ export * from "./resizeElements";
 export * from "./resizeTest";
 export * from "./Scene";
 export * from "./selection";
-export * from "./Shape";
-export * from "./ShapeCache";
-export * from "./shapes";
+export * from "./shape";
 export * from "./showSelectedShapeActions";
 export * from "./sizeHelpers";
 export * from "./sortElements";

+ 51 - 46
packages/element/src/linearElementEditor.ts

@@ -7,6 +7,8 @@ import {
   type LocalPoint,
   pointDistance,
   vectorFromPoint,
+  curveLength,
+  curvePointAtLength,
 } from "@excalidraw/math";
 
 import { getCurvePathOps } from "@excalidraw/utils/shape";
@@ -20,7 +22,11 @@ import {
   tupleToCoors,
 } from "@excalidraw/common";
 
-import type { Store } from "@excalidraw/element";
+import {
+  deconstructLinearOrFreeDrawElement,
+  isPathALoop,
+  type Store,
+} from "@excalidraw/element";
 
 import type { Radians } from "@excalidraw/math";
 
@@ -55,16 +61,7 @@ import {
   isFixedPointBinding,
 } from "./typeChecks";
 
-import { ShapeCache } from "./ShapeCache";
-
-import {
-  isPathALoop,
-  getBezierCurveLength,
-  getControlPointsForBezierCurve,
-  mapIntervalToBezierT,
-  getBezierXY,
-  toggleLinePolygonState,
-} from "./shapes";
+import { ShapeCache, toggleLinePolygonState } from "./shape";
 
 import { getLockedLinearCursorAlignSize } from "./sizeHelpers";
 
@@ -629,10 +626,7 @@ export class LinearElementEditor {
       }
       const segmentMidPoint = LinearElementEditor.getSegmentMidPoint(
         element,
-        points[index],
-        points[index + 1],
         index + 1,
-        elementsMap,
       );
       midpoints.push(segmentMidPoint);
       index++;
@@ -734,7 +728,18 @@ export class LinearElementEditor {
 
     let distance = pointDistance(startPoint, endPoint);
     if (element.points.length > 2 && element.roundness) {
-      distance = getBezierCurveLength(element, endPoint);
+      const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
+
+      invariant(
+        lines.length === 0 && curves.length > 0,
+        "Only linears built out of curves are supported",
+      );
+      invariant(
+        lines.length + curves.length >= index,
+        "Invalid segment index while calculating mid point",
+      );
+
+      distance = curveLength<GlobalPoint>(curves[index]);
     }
 
     return distance * zoom.value < LinearElementEditor.POINT_HANDLE_SIZE * 4;
@@ -742,39 +747,42 @@ export class LinearElementEditor {
 
   static getSegmentMidPoint(
     element: NonDeleted<ExcalidrawLinearElement>,
-    startPoint: GlobalPoint,
-    endPoint: GlobalPoint,
-    endPointIndex: number,
-    elementsMap: ElementsMap,
+    index: number,
   ): GlobalPoint {
-    let segmentMidPoint = pointCenter(startPoint, endPoint);
-    if (element.points.length > 2 && element.roundness) {
-      const controlPoints = getControlPointsForBezierCurve(
-        element,
-        element.points[endPointIndex],
+    if (isElbowArrow(element)) {
+      invariant(
+        element.points.length >= index,
+        "Invalid segment index while calculating elbow arrow mid point",
       );
-      if (controlPoints) {
-        const t = mapIntervalToBezierT(
-          element,
-          element.points[endPointIndex],
-          0.5,
-        );
 
-        segmentMidPoint = LinearElementEditor.getPointGlobalCoordinates(
-          element,
-          getBezierXY(
-            controlPoints[0],
-            controlPoints[1],
-            controlPoints[2],
-            controlPoints[3],
-            t,
-          ),
-          elementsMap,
-        );
-      }
+      const p = pointCenter(element.points[index - 1], element.points[index]);
+
+      return pointFrom<GlobalPoint>(element.x + p[0], element.y + p[1]);
     }
 
-    return segmentMidPoint;
+    const [lines, curves] = deconstructLinearOrFreeDrawElement(element);
+
+    invariant(
+      (lines.length === 0 && curves.length > 0) ||
+        (lines.length > 0 && curves.length === 0),
+      "Only linears built out of either segments or curves are supported",
+    );
+    invariant(
+      lines.length + curves.length >= index,
+      "Invalid segment index while calculating mid point",
+    );
+
+    if (lines.length) {
+      const segment = lines[index - 1];
+      return pointCenter(segment[0], segment[1]);
+    }
+
+    if (curves.length) {
+      const segment = curves[index - 1];
+      return curvePointAtLength(segment, 0.5);
+    }
+
+    invariant(false, "Invalid segment type while calculating mid point");
   }
 
   static getSegmentMidPointIndex(
@@ -1670,10 +1678,7 @@ export class LinearElementEditor {
       const index = element.points.length / 2 - 1;
       const midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
         element,
-        points[index],
-        points[index + 1],
         index + 1,
-        elementsMap,
       );
 
       x = midSegmentMidpoint[0] - boundTextElement.width / 2;

+ 1 - 1
packages/element/src/mutateElement.ts

@@ -8,7 +8,7 @@ import type { Radians } from "@excalidraw/math";
 
 import type { Mutable } from "@excalidraw/common/utility-types";
 
-import { ShapeCache } from "./ShapeCache";
+import { ShapeCache } from "./shape";
 
 import { updateElbowArrowPoints } from "./elbowArrow";
 

+ 2 - 2
packages/element/src/renderElement.ts

@@ -54,9 +54,9 @@ import {
   isImageElement,
 } from "./typeChecks";
 import { getContainingFrame } from "./frame";
-import { getCornerRadius } from "./shapes";
+import { getCornerRadius } from "./utils";
 
-import { ShapeCache } from "./ShapeCache";
+import { ShapeCache } from "./shape";
 
 import type {
   ExcalidrawElement,

+ 219 - 8
packages/element/src/Shape.ts → packages/element/src/shape.ts

@@ -1,12 +1,27 @@
 import { simplify } from "points-on-curve";
 
+import {
+  type GeometricShape,
+  getClosedCurveShape,
+  getCurveShape,
+  getEllipseShape,
+  getFreedrawShape,
+  getPolygonShape,
+} from "@excalidraw/utils/shape";
+
 import {
   pointFrom,
   pointDistance,
   type LocalPoint,
   pointRotateRads,
 } from "@excalidraw/math";
-import { ROUGHNESS, isTransparent, assertNever } from "@excalidraw/common";
+import {
+  ROUGHNESS,
+  isTransparent,
+  assertNever,
+  COLOR_PALETTE,
+  LINE_POLYGON_POINT_MERGE_DISTANCE,
+} from "@excalidraw/common";
 
 import { RoughGenerator } from "roughjs/bin/generator";
 
@@ -14,17 +29,26 @@ import type { GlobalPoint } from "@excalidraw/math";
 
 import type { Mutable } from "@excalidraw/common/utility-types";
 
-import type { EmbedsValidationStatus } from "@excalidraw/excalidraw/types";
-import type { ElementShapes } from "@excalidraw/excalidraw/scene/types";
+import type {
+  AppState,
+  EmbedsValidationStatus,
+} from "@excalidraw/excalidraw/types";
+import type {
+  ElementShape,
+  ElementShapes,
+} from "@excalidraw/excalidraw/scene/types";
+
+import { elementWithCanvasCache } from "./renderElement";
 
 import {
+  canBecomePolygon,
   isElbowArrow,
   isEmbeddableElement,
   isIframeElement,
   isIframeLikeElement,
   isLinearElement,
 } from "./typeChecks";
-import { getCornerRadius, isPathALoop } from "./shapes";
+import { getCornerRadius, isPathALoop } from "./utils";
 import { headingForPointIsHorizontal } from "./heading";
 
 import { canChangeRoundness } from "./comparisons";
@@ -33,8 +57,9 @@ import {
   getArrowheadPoints,
   getCenterForBounds,
   getDiamondPoints,
-  getElementBounds,
+  getElementAbsoluteCoords,
 } from "./bounds";
+import { shouldTestInside } from "./collision";
 
 import type {
   ExcalidrawElement,
@@ -44,11 +69,87 @@ import type {
   Arrowhead,
   ExcalidrawFreeDrawElement,
   ElementsMap,
+  ExcalidrawLineElement,
 } from "./types";
 
 import type { Drawable, Options } from "roughjs/bin/core";
 import type { Point as RoughPoint } from "roughjs/bin/geometry";
 
+export class ShapeCache {
+  private static rg = new RoughGenerator();
+  private static cache = new WeakMap<ExcalidrawElement, ElementShape>();
+
+  /**
+   * Retrieves shape from cache if available. Use this only if shape
+   * is optional and you have a fallback in case it's not cached.
+   */
+  public static get = <T extends ExcalidrawElement>(element: T) => {
+    return ShapeCache.cache.get(
+      element,
+    ) as T["type"] extends keyof ElementShapes
+      ? ElementShapes[T["type"]] | undefined
+      : ElementShape | undefined;
+  };
+
+  public static set = <T extends ExcalidrawElement>(
+    element: T,
+    shape: T["type"] extends keyof ElementShapes
+      ? ElementShapes[T["type"]]
+      : Drawable,
+  ) => ShapeCache.cache.set(element, shape);
+
+  public static delete = (element: ExcalidrawElement) =>
+    ShapeCache.cache.delete(element);
+
+  public static destroy = () => {
+    ShapeCache.cache = new WeakMap();
+  };
+
+  /**
+   * Generates & caches shape for element if not already cached, otherwise
+   * returns cached shape.
+   */
+  public static generateElementShape = <
+    T extends Exclude<ExcalidrawElement, ExcalidrawSelectionElement>,
+  >(
+    element: T,
+    renderConfig: {
+      isExporting: boolean;
+      canvasBackgroundColor: AppState["viewBackgroundColor"];
+      embedsValidationStatus: EmbedsValidationStatus;
+    } | null,
+  ) => {
+    // when exporting, always regenerated to guarantee the latest shape
+    const cachedShape = renderConfig?.isExporting
+      ? undefined
+      : ShapeCache.get(element);
+
+    // `null` indicates no rc shape applicable for this element type,
+    // but it's considered a valid cache value (= do not regenerate)
+    if (cachedShape !== undefined) {
+      return cachedShape;
+    }
+
+    elementWithCanvasCache.delete(element);
+
+    const shape = generateElementShape(
+      element,
+      ShapeCache.rg,
+      renderConfig || {
+        isExporting: false,
+        canvasBackgroundColor: COLOR_PALETTE.white,
+        embedsValidationStatus: null,
+      },
+    ) as T["type"] extends keyof ElementShapes
+      ? ElementShapes[T["type"]]
+      : Drawable | null;
+
+    ShapeCache.cache.set(element, shape);
+
+    return shape;
+  };
+}
+
 const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth];
 
 const getDashArrayDotted = (strokeWidth: number) => [1.5, 6 + strokeWidth];
@@ -320,7 +421,6 @@ const getArrowheadShapes = (
 
 export const generateLinearCollisionShape = (
   element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
-  elementsMap: ElementsMap,
 ) => {
   const generator = new RoughGenerator();
   const options: Options = {
@@ -331,7 +431,18 @@ export const generateLinearCollisionShape = (
     preserveVertices: true,
   };
   const center = getCenterForBounds(
-    getElementBounds(element, elementsMap, true),
+    // Need a non-rotated center point
+    element.points.reduce(
+      (acc, point) => {
+        return [
+          Math.min(element.x + point[0], acc[0]),
+          Math.min(element.y + point[1], acc[1]),
+          Math.max(element.x + point[0], acc[2]),
+          Math.max(element.y + point[1], acc[3]),
+        ];
+      },
+      [Infinity, Infinity, -Infinity, -Infinity],
+    ),
   );
 
   switch (element.type) {
@@ -491,7 +602,7 @@ export const generateLinearCollisionShape = (
  *
  * @private
  */
-export const _generateElementShape = (
+const generateElementShape = (
   element: Exclude<NonDeletedExcalidrawElement, ExcalidrawSelectionElement>,
   generator: RoughGenerator,
   {
@@ -792,3 +903,103 @@ const generateElbowArrowShape = (
 
   return d.join(" ");
 };
+
+/**
+ * get the pure geometric shape of an excalidraw elementw
+ * which is then used for hit detection
+ */
+export const getElementShape = <Point extends GlobalPoint | LocalPoint>(
+  element: ExcalidrawElement,
+  elementsMap: ElementsMap,
+): GeometricShape<Point> => {
+  switch (element.type) {
+    case "rectangle":
+    case "diamond":
+    case "frame":
+    case "magicframe":
+    case "embeddable":
+    case "image":
+    case "iframe":
+    case "text":
+    case "selection":
+      return getPolygonShape(element);
+    case "arrow":
+    case "line": {
+      const roughShape =
+        ShapeCache.get(element)?.[0] ??
+        ShapeCache.generateElementShape(element, null)[0];
+      const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap);
+
+      return shouldTestInside(element)
+        ? getClosedCurveShape<Point>(
+            element,
+            roughShape,
+            pointFrom<Point>(element.x, element.y),
+            element.angle,
+            pointFrom(cx, cy),
+          )
+        : getCurveShape<Point>(
+            roughShape,
+            pointFrom<Point>(element.x, element.y),
+            element.angle,
+            pointFrom(cx, cy),
+          );
+    }
+
+    case "ellipse":
+      return getEllipseShape(element);
+
+    case "freedraw": {
+      const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap);
+      return getFreedrawShape(
+        element,
+        pointFrom(cx, cy),
+        shouldTestInside(element),
+      );
+    }
+  }
+};
+
+export const toggleLinePolygonState = (
+  element: ExcalidrawLineElement,
+  nextPolygonState: boolean,
+): {
+  polygon: ExcalidrawLineElement["polygon"];
+  points: ExcalidrawLineElement["points"];
+} | null => {
+  const updatedPoints = [...element.points];
+
+  if (nextPolygonState) {
+    if (!canBecomePolygon(element.points)) {
+      return null;
+    }
+
+    const firstPoint = updatedPoints[0];
+    const lastPoint = updatedPoints[updatedPoints.length - 1];
+
+    const distance = Math.hypot(
+      firstPoint[0] - lastPoint[0],
+      firstPoint[1] - lastPoint[1],
+    );
+
+    if (
+      distance > LINE_POLYGON_POINT_MERGE_DISTANCE ||
+      updatedPoints.length < 4
+    ) {
+      updatedPoints.push(pointFrom(firstPoint[0], firstPoint[1]));
+    } else {
+      updatedPoints[updatedPoints.length - 1] = pointFrom(
+        firstPoint[0],
+        firstPoint[1],
+      );
+    }
+  }
+
+  // TODO: satisfies ElementUpdate<ExcalidrawLineElement>
+  const ret = {
+    polygon: nextPolygonState,
+    points: updatedPoints,
+  };
+
+  return ret;
+};

+ 0 - 447
packages/element/src/shapes.ts

@@ -1,447 +0,0 @@
-import {
-  DEFAULT_ADAPTIVE_RADIUS,
-  DEFAULT_PROPORTIONAL_RADIUS,
-  LINE_CONFIRM_THRESHOLD,
-  ROUNDNESS,
-  invariant,
-  elementCenterPoint,
-  LINE_POLYGON_POINT_MERGE_DISTANCE,
-} from "@excalidraw/common";
-import {
-  isPoint,
-  pointFrom,
-  pointDistance,
-  pointFromPair,
-  pointRotateRads,
-  pointsEqual,
-  type GlobalPoint,
-  type LocalPoint,
-} from "@excalidraw/math";
-import {
-  getClosedCurveShape,
-  getCurvePathOps,
-  getCurveShape,
-  getEllipseShape,
-  getFreedrawShape,
-  getPolygonShape,
-  type GeometricShape,
-} from "@excalidraw/utils/shape";
-
-import type { NormalizedZoomValue, Zoom } from "@excalidraw/excalidraw/types";
-
-import { shouldTestInside } from "./collision";
-import { LinearElementEditor } from "./linearElementEditor";
-import { getBoundTextElement } from "./textElement";
-import { ShapeCache } from "./ShapeCache";
-
-import { getElementAbsoluteCoords, type Bounds } from "./bounds";
-
-import { canBecomePolygon } from "./typeChecks";
-
-import type {
-  ElementsMap,
-  ExcalidrawElement,
-  ExcalidrawLinearElement,
-  ExcalidrawLineElement,
-  NonDeleted,
-} from "./types";
-
-/**
- * get the pure geometric shape of an excalidraw elementw
- * which is then used for hit detection
- */
-export const getElementShape = <Point extends GlobalPoint | LocalPoint>(
-  element: ExcalidrawElement,
-  elementsMap: ElementsMap,
-): GeometricShape<Point> => {
-  switch (element.type) {
-    case "rectangle":
-    case "diamond":
-    case "frame":
-    case "magicframe":
-    case "embeddable":
-    case "image":
-    case "iframe":
-    case "text":
-    case "selection":
-      return getPolygonShape(element);
-    case "arrow":
-    case "line": {
-      const roughShape =
-        ShapeCache.get(element)?.[0] ??
-        ShapeCache.generateElementShape(element, null)[0];
-      const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap);
-
-      return shouldTestInside(element)
-        ? getClosedCurveShape<Point>(
-            element,
-            roughShape,
-            pointFrom<Point>(element.x, element.y),
-            element.angle,
-            pointFrom(cx, cy),
-          )
-        : getCurveShape<Point>(
-            roughShape,
-            pointFrom<Point>(element.x, element.y),
-            element.angle,
-            pointFrom(cx, cy),
-          );
-    }
-
-    case "ellipse":
-      return getEllipseShape(element);
-
-    case "freedraw": {
-      const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap);
-      return getFreedrawShape(
-        element,
-        pointFrom(cx, cy),
-        shouldTestInside(element),
-      );
-    }
-  }
-};
-
-export const getBoundTextShape = <Point extends GlobalPoint | LocalPoint>(
-  element: ExcalidrawElement,
-  elementsMap: ElementsMap,
-): GeometricShape<Point> | null => {
-  const boundTextElement = getBoundTextElement(element, elementsMap);
-
-  if (boundTextElement) {
-    if (element.type === "arrow") {
-      return getElementShape(
-        {
-          ...boundTextElement,
-          // arrow's bound text accurate position is not stored in the element's property
-          // but rather calculated and returned from the following static method
-          ...LinearElementEditor.getBoundTextElementPosition(
-            element,
-            boundTextElement,
-            elementsMap,
-          ),
-        },
-        elementsMap,
-      );
-    }
-    return getElementShape(boundTextElement, elementsMap);
-  }
-
-  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 = pointFrom<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 = pointFrom<P>(data[0], data[1]);
-      const p2 = pointFrom<P>(data[2], data[3]);
-      const p3 = pointFrom<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 pointFrom(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(pointFrom(p[0], p[1]));
-    t -= 0.05;
-  }
-  if (pointsOnCurve.length) {
-    if (pointsEqual(pointsOnCurve.at(-1)!, endPoint)) {
-      pointsOnCurve.push(pointFrom(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>,
-  elementsMap: ElementsMap,
-  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 = elementCenterPoint(element, elementsMap);
-  const [topLeftX, topLeftY] = pointRotateRads(
-    pointFrom(bbox.minX, bbox.minY),
-    center,
-    element.angle,
-  );
-  const [topRightX, topRightY] = pointRotateRads(
-    pointFrom(bbox.maxX, bbox.minY),
-    center,
-    element.angle,
-  );
-  const [bottomRightX, bottomRightY] = pointRotateRads(
-    pointFrom(bbox.maxX, bbox.maxY),
-    center,
-    element.angle,
-  );
-  const [bottomLeftX, bottomLeftY] = pointRotateRads(
-    pointFrom(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(pointFrom(a[0], a[1]), b) ||
-  pointInsideBounds(pointFrom(a[2], a[1]), b) ||
-  pointInsideBounds(pointFrom(a[2], a[3]), b) ||
-  pointInsideBounds(pointFrom(a[0], a[3]), b) ||
-  pointInsideBounds(pointFrom(b[0], b[1]), a) ||
-  pointInsideBounds(pointFrom(b[2], b[1]), a) ||
-  pointInsideBounds(pointFrom(b[2], b[3]), a) ||
-  pointInsideBounds(pointFrom(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;
-};
-
-export const toggleLinePolygonState = (
-  element: ExcalidrawLineElement,
-  nextPolygonState: boolean,
-): {
-  polygon: ExcalidrawLineElement["polygon"];
-  points: ExcalidrawLineElement["points"];
-} | null => {
-  const updatedPoints = [...element.points];
-
-  if (nextPolygonState) {
-    if (!canBecomePolygon(element.points)) {
-      return null;
-    }
-
-    const firstPoint = updatedPoints[0];
-    const lastPoint = updatedPoints[updatedPoints.length - 1];
-
-    const distance = Math.hypot(
-      firstPoint[0] - lastPoint[0],
-      firstPoint[1] - lastPoint[1],
-    );
-
-    if (
-      distance > LINE_POLYGON_POINT_MERGE_DISTANCE ||
-      updatedPoints.length < 4
-    ) {
-      updatedPoints.push(pointFrom(firstPoint[0], firstPoint[1]));
-    } else {
-      updatedPoints[updatedPoints.length - 1] = pointFrom(
-        firstPoint[0],
-        firstPoint[1],
-      );
-    }
-  }
-
-  // TODO: satisfies ElementUpdate<ExcalidrawLineElement>
-  const ret = {
-    polygon: nextPolygonState,
-    points: updatedPoints,
-  };
-
-  return ret;
-};

+ 0 - 3
packages/element/src/textElement.ts

@@ -326,10 +326,7 @@ export const getContainerCenter = (
   if (!midSegmentMidpoint) {
     midSegmentMidpoint = LinearElementEditor.getSegmentMidPoint(
       container,
-      points[index],
-      points[index + 1],
       index + 1,
-      elementsMap,
     );
   }
   return { x: midSegmentMidpoint[0], y: midSegmentMidpoint[1] };

+ 52 - 5
packages/element/src/utils.ts

@@ -1,8 +1,16 @@
+import {
+  DEFAULT_ADAPTIVE_RADIUS,
+  DEFAULT_PROPORTIONAL_RADIUS,
+  LINE_CONFIRM_THRESHOLD,
+  ROUNDNESS,
+} from "@excalidraw/common";
+
 import {
   curve,
   curveCatmullRomCubicApproxPoints,
   curveOffsetPoints,
   lineSegment,
+  pointDistance,
   pointFrom,
   pointFromArray,
   rectangle,
@@ -11,14 +19,13 @@ import {
 
 import type { Curve, LineSegment, LocalPoint } from "@excalidraw/math";
 
-import { getCornerRadius } from "./shapes";
+import type { NormalizedZoomValue, Zoom } from "@excalidraw/excalidraw/types";
 
 import { getDiamondPoints } from "./bounds";
 
-import { generateLinearCollisionShape } from "./Shape";
+import { generateLinearCollisionShape } from "./shape";
 
 import type {
-  ElementsMap,
   ExcalidrawDiamondElement,
   ExcalidrawElement,
   ExcalidrawFreeDrawElement,
@@ -85,7 +92,6 @@ const setElementShapesCacheEntry = <T extends ExcalidrawElement>(
 
 export function deconstructLinearOrFreeDrawElement(
   element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement,
-  elementsMap: ElementsMap,
 ): [LineSegment<GlobalPoint>[], Curve<GlobalPoint>[]] {
   const cachedShape = getElementShapesCacheEntry(element, 0);
 
@@ -93,7 +99,7 @@ export function deconstructLinearOrFreeDrawElement(
     return cachedShape;
   }
 
-  const ops = generateLinearCollisionShape(element, elementsMap) as {
+  const ops = generateLinearCollisionShape(element) as {
     op: string;
     data: number[];
   }[];
@@ -428,3 +434,44 @@ export function deconstructDiamondElement(
 
   return shape;
 }
+
+// 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;
+};
+
+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;
+};

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

@@ -423,12 +423,12 @@ describe("Test Linear Elements", () => {
       expect(midPointsWithRoundEdge).toMatchInlineSnapshot(`
         [
           [
-            "55.96978",
-            "47.44233",
+            "54.27552",
+            "46.16120",
           ],
           [
-            "76.08587",
-            "43.29417",
+            "76.95494",
+            "44.56052",
           ],
         ]
       `);
@@ -488,12 +488,12 @@ describe("Test Linear Elements", () => {
       expect(newMidPoints).toMatchInlineSnapshot(`
         [
           [
-            "105.96978",
-            "67.44233",
+            "104.27552",
+            "66.16120",
           ],
           [
-            "126.08587",
-            "63.29417",
+            "126.95494",
+            "64.56052",
           ],
         ]
       `);
@@ -804,12 +804,12 @@ describe("Test Linear Elements", () => {
         expect(newMidPoints).toMatchInlineSnapshot(`
           [
             [
-              "31.88408",
-              "23.13276",
+              "29.28349",
+              "20.91105",
             ],
             [
-              "77.74793",
-              "44.57841",
+              "78.86048",
+              "46.12277",
             ],
           ]
         `);
@@ -893,12 +893,12 @@ describe("Test Linear Elements", () => {
         expect(newMidPoints).toMatchInlineSnapshot(`
           [
             [
-              "55.96978",
-              "47.44233",
+              "54.27552",
+              "46.16120",
             ],
             [
-              "76.08587",
-              "43.29417",
+              "76.95494",
+              "44.56052",
             ],
           ]
         `);
@@ -1060,8 +1060,8 @@ describe("Test Linear Elements", () => {
         );
         expect(position).toMatchInlineSnapshot(`
           {
-            "x": "85.82202",
-            "y": "75.63461",
+            "x": "86.17305",
+            "y": "76.11251",
           }
         `);
       });

+ 4 - 3
packages/excalidraw/actions/actionLinearEditor.tsx

@@ -6,7 +6,10 @@ import {
 } from "@excalidraw/element";
 import { arrayToMap } from "@excalidraw/common";
 
-import { CaptureUpdateAction } from "@excalidraw/element";
+import {
+  toggleLinePolygonState,
+  CaptureUpdateAction,
+} from "@excalidraw/element";
 
 import type {
   ExcalidrawLinearElement,
@@ -22,8 +25,6 @@ import { ButtonIcon } from "../components/ButtonIcon";
 
 import { newElementWith } from "../../element/src/mutateElement";
 
-import { toggleLinePolygonState } from "../../element/src/shapes";
-
 import { register } from "./register";
 
 export const actionToggleLinearEditor = register({

+ 5 - 5
packages/excalidraw/actions/actionProperties.tsx

@@ -52,9 +52,11 @@ import {
 
 import { hasStrokeColor } from "@excalidraw/element";
 
-import { updateElbowArrowPoints } from "@excalidraw/element";
-
-import { CaptureUpdateAction } from "@excalidraw/element";
+import {
+  updateElbowArrowPoints,
+  CaptureUpdateAction,
+  toggleLinePolygonState,
+} from "@excalidraw/element";
 
 import type { LocalPoint } from "@excalidraw/math";
 
@@ -135,8 +137,6 @@ import {
   isSomeElementSelected,
 } from "../scene";
 
-import { toggleLinePolygonState } from "../../element/src/shapes";
-
 import { register } from "./register";
 
 import type { AppClassProperties, AppState, Primitive } from "../types";

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

@@ -1791,7 +1791,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
   "versionNonce": Any<Number>,
   "verticalAlign": "middle",
   "width": 120,
-  "x": 187.7545,
+  "x": 187.75450000000004,
   "y": 44.5,
 }
 `;

+ 57 - 0
packages/math/src/constants.ts

@@ -0,0 +1,57 @@
+export const PRECISION = 10e-5;
+
+// Legendre-Gauss abscissae (x values) and weights for n=24
+// Refeerence: https://pomax.github.io/bezierinfo/legendre-gauss.html
+export const LegendreGaussN24TValues = [
+  -0.0640568928626056260850430826247450385909,
+  0.0640568928626056260850430826247450385909,
+  -0.1911188674736163091586398207570696318404,
+  0.1911188674736163091586398207570696318404,
+  -0.3150426796961633743867932913198102407864,
+  0.3150426796961633743867932913198102407864,
+  -0.4337935076260451384870842319133497124524,
+  0.4337935076260451384870842319133497124524,
+  -0.5454214713888395356583756172183723700107,
+  0.5454214713888395356583756172183723700107,
+  -0.6480936519369755692524957869107476266696,
+  0.6480936519369755692524957869107476266696,
+  -0.7401241915785543642438281030999784255232,
+  0.7401241915785543642438281030999784255232,
+  -0.8200019859739029219539498726697452080761,
+  0.8200019859739029219539498726697452080761,
+  -0.8864155270044010342131543419821967550873,
+  0.8864155270044010342131543419821967550873,
+  -0.9382745520027327585236490017087214496548,
+  0.9382745520027327585236490017087214496548,
+  -0.9747285559713094981983919930081690617411,
+  0.9747285559713094981983919930081690617411,
+  -0.9951872199970213601799974097007368118745,
+  0.9951872199970213601799974097007368118745,
+];
+
+export const LegendreGaussN24CValues = [
+  0.1279381953467521569740561652246953718517,
+  0.1279381953467521569740561652246953718517,
+  0.1258374563468282961213753825111836887264,
+  0.1258374563468282961213753825111836887264,
+  0.121670472927803391204463153476262425607,
+  0.121670472927803391204463153476262425607,
+  0.1155056680537256013533444839067835598622,
+  0.1155056680537256013533444839067835598622,
+  0.1074442701159656347825773424466062227946,
+  0.1074442701159656347825773424466062227946,
+  0.0976186521041138882698806644642471544279,
+  0.0976186521041138882698806644642471544279,
+  0.086190161531953275917185202983742667185,
+  0.086190161531953275917185202983742667185,
+  0.0733464814110803057340336152531165181193,
+  0.0733464814110803057340336152531165181193,
+  0.0592985849154367807463677585001085845412,
+  0.0592985849154367807463677585001085845412,
+  0.0442774388174198061686027482113382288593,
+  0.0442774388174198061686027482113382288593,
+  0.0285313886289336631813078159518782864491,
+  0.0285313886289336631813078159518782864491,
+  0.0123412297999871995468056670700372915759,
+  0.0123412297999871995468056670700372915759,
+];

+ 121 - 0
packages/math/src/curve.ts

@@ -2,6 +2,7 @@ import { doBoundsIntersect, type Bounds } from "@excalidraw/element";
 
 import { isPoint, pointDistance, pointFrom, pointFromVector } from "./point";
 import { vector, vectorNormal, vectorNormalize, vectorScale } from "./vector";
+import { LegendreGaussN24CValues, LegendreGaussN24TValues } from "./constants";
 
 import type { Curve, GlobalPoint, LineSegment, LocalPoint } from "./types";
 
@@ -406,3 +407,123 @@ export function offsetPointsForQuadraticBezier(
 
   return offsetPoints;
 }
+
+/**
+ * Implementation based on Legendre-Gauss quadrature for more accurate arc
+ * length calculation.
+ *
+ * Reference: https://pomax.github.io/bezierinfo/#arclength
+ *
+ * @param c The curve to calculate the length of
+ * @returns The approximated length of the curve
+ */
+export function curveLength<P extends GlobalPoint | LocalPoint>(
+  c: Curve<P>,
+): number {
+  const z2 = 0.5;
+  let sum = 0;
+
+  for (let i = 0; i < 24; i++) {
+    const t = z2 * LegendreGaussN24TValues[i] + z2;
+    const derivativeVector = curveTangent(c, t);
+    const magnitude = Math.sqrt(
+      derivativeVector[0] * derivativeVector[0] +
+        derivativeVector[1] * derivativeVector[1],
+    );
+    sum += LegendreGaussN24CValues[i] * magnitude;
+  }
+
+  return z2 * sum;
+}
+
+/**
+ * Calculates the curve length from t=0 to t=parameter using the same
+ * Legendre-Gauss quadrature method used in curveLength
+ *
+ * @param c The curve to calculate the partial length for
+ * @param t The parameter value (0 to 1) to calculate length up to
+ * @returns The length of the curve from beginning to parameter t
+ */
+export function curveLengthAtParameter<P extends GlobalPoint | LocalPoint>(
+  c: Curve<P>,
+  t: number,
+): number {
+  if (t <= 0) {
+    return 0;
+  }
+  if (t >= 1) {
+    return curveLength(c);
+  }
+
+  // Scale and shift the integration interval from [0,t] to [-1,1]
+  // which is what the Legendre-Gauss quadrature expects
+  const z1 = t / 2;
+  const z2 = t / 2;
+
+  let sum = 0;
+
+  for (let i = 0; i < 24; i++) {
+    const parameter = z1 * LegendreGaussN24TValues[i] + z2;
+    const derivativeVector = curveTangent(c, parameter);
+    const magnitude = Math.sqrt(
+      derivativeVector[0] * derivativeVector[0] +
+        derivativeVector[1] * derivativeVector[1],
+    );
+    sum += LegendreGaussN24CValues[i] * magnitude;
+  }
+
+  return z1 * sum; // Scale the result back to the original interval
+}
+
+/**
+ * Calculates the point at a specific percentage of a curve's total length
+ * using binary search for improved efficiency and accuracy.
+ *
+ * @param c The curve to calculate point on
+ * @param percent A value between 0 and 1 representing the percentage of the curve's length
+ * @returns The point at the specified percentage of curve length
+ */
+export function curvePointAtLength<P extends GlobalPoint | LocalPoint>(
+  c: Curve<P>,
+  percent: number,
+): P {
+  if (percent <= 0) {
+    return bezierEquation(c, 0);
+  }
+
+  if (percent >= 1) {
+    return bezierEquation(c, 1);
+  }
+
+  const totalLength = curveLength(c);
+  const targetLength = totalLength * percent;
+
+  // Binary search to find parameter t where length at t equals target length
+  let tMin = 0;
+  let tMax = 1;
+  let t = percent; // Start with a reasonable guess (t = percent)
+  let currentLength = 0;
+
+  // Tolerance for length comparison and iteration limit to avoid infinite loops
+  const tolerance = totalLength * 0.0001;
+  const maxIterations = 20;
+
+  for (let iteration = 0; iteration < maxIterations; iteration++) {
+    currentLength = curveLengthAtParameter(c, t);
+    const error = Math.abs(currentLength - targetLength);
+
+    if (error < tolerance) {
+      break;
+    }
+
+    if (currentLength < targetLength) {
+      tMin = t;
+    } else {
+      tMax = t;
+    }
+
+    t = (tMin + tMax) / 2;
+  }
+
+  return bezierEquation(c, t);
+}