diff --git a/packages/element/src/ShapeCache.ts b/packages/element/src/ShapeCache.ts deleted file mode 100644 index 8f0c94324c..0000000000 --- a/packages/element/src/ShapeCache.ts +++ /dev/null @@ -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(); - - /** - * 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 = (element: T) => { - return ShapeCache.cache.get( - element, - ) as T["type"] extends keyof ElementShapes - ? ElementShapes[T["type"]] | undefined - : ElementShape | undefined; - }; - - public static set = ( - 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, - >( - 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; - }; -} diff --git a/packages/element/src/binding.ts b/packages/element/src/binding.ts index 0a7a4a68ae..16f3216616 100644 --- a/packages/element/src/binding.ts +++ b/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"; diff --git a/packages/element/src/bounds.ts b/packages/element/src/bounds.ts index 1bfb441585..2a7b3fb25c 100644 --- a/packages/element/src/bounds.ts +++ b/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, + 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: P, + bounds: Bounds, +): boolean => + p[0] > bounds[0] && p[0] < bounds[2] && p[1] > bounds[1] && p[1] < bounds[3]; diff --git a/packages/element/src/collision.ts b/packages/element/src/collision.ts index af81ff99ce..88e96a15ad 100644 --- a/packages/element/src/collision.ts +++ b/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, onlyFirst = false, ): GlobalPoint[] => { - const [lines, curves] = deconstructLinearOrFreeDrawElement( - element, - elementsMap, - ); + const [lines, curves] = deconstructLinearOrFreeDrawElement(element); const intersections = []; for (const l of lines) { diff --git a/packages/element/src/distance.ts b/packages/element/src/distance.ts index 55e9ed2bdb..ed900ecc69 100644 --- a/packages/element/src/distance.ts +++ b/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)), diff --git a/packages/element/src/elbowArrow.ts b/packages/element/src/elbowArrow.ts index 2425c350cf..0021851645 100644 --- a/packages/element/src/elbowArrow.ts +++ b/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"; diff --git a/packages/element/src/flowchart.ts b/packages/element/src/flowchart.ts index 9e5af4216e..6cffb56a83 100644 --- a/packages/element/src/flowchart.ts +++ b/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, diff --git a/packages/element/src/index.ts b/packages/element/src/index.ts index 93024f9940..9bf5214d0f 100644 --- a/packages/element/src/index.ts +++ b/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"; diff --git a/packages/element/src/linearElementEditor.ts b/packages/element/src/linearElementEditor.ts index 447d1e3682..44d365e2e5 100644 --- a/packages/element/src/linearElementEditor.ts +++ b/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(curves[index]); } return distance * zoom.value < LinearElementEditor.POINT_HANDLE_SIZE * 4; @@ -742,39 +747,42 @@ export class LinearElementEditor { static getSegmentMidPoint( element: NonDeleted, - 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(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; diff --git a/packages/element/src/mutateElement.ts b/packages/element/src/mutateElement.ts index 4b5526917f..0fc3e0bb8f 100644 --- a/packages/element/src/mutateElement.ts +++ b/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"; diff --git a/packages/element/src/renderElement.ts b/packages/element/src/renderElement.ts index 2786f3f84a..e870d977fb 100644 --- a/packages/element/src/renderElement.ts +++ b/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, diff --git a/packages/element/src/Shape.ts b/packages/element/src/shape.ts similarity index 79% rename from packages/element/src/Shape.ts rename to packages/element/src/shape.ts index 317dfbacb8..7a8cd351a1 100644 --- a/packages/element/src/Shape.ts +++ b/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(); + + /** + * 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 = (element: T) => { + return ShapeCache.cache.get( + element, + ) as T["type"] extends keyof ElementShapes + ? ElementShapes[T["type"]] | undefined + : ElementShape | undefined; + }; + + public static set = ( + 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, + >( + 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, 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 = ( + element: ExcalidrawElement, + elementsMap: ElementsMap, +): GeometricShape => { + switch (element.type) { + case "rectangle": + case "diamond": + case "frame": + case "magicframe": + case "embeddable": + case "image": + case "iframe": + case "text": + case "selection": + return getPolygonShape(element); + case "arrow": + case "line": { + const roughShape = + ShapeCache.get(element)?.[0] ?? + ShapeCache.generateElementShape(element, null)[0]; + const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap); + + return shouldTestInside(element) + ? getClosedCurveShape( + element, + roughShape, + pointFrom(element.x, element.y), + element.angle, + pointFrom(cx, cy), + ) + : getCurveShape( + roughShape, + pointFrom(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 + const ret = { + polygon: nextPolygonState, + points: updatedPoints, + }; + + return ret; +}; diff --git a/packages/element/src/shapes.ts b/packages/element/src/shapes.ts deleted file mode 100644 index 3abf7f5905..0000000000 --- a/packages/element/src/shapes.ts +++ /dev/null @@ -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 = ( - element: ExcalidrawElement, - elementsMap: ElementsMap, -): GeometricShape => { - switch (element.type) { - case "rectangle": - case "diamond": - case "frame": - case "magicframe": - case "embeddable": - case "image": - case "iframe": - case "text": - case "selection": - return getPolygonShape(element); - case "arrow": - case "line": { - const roughShape = - ShapeCache.get(element)?.[0] ?? - ShapeCache.generateElementShape(element, null)[0]; - const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap); - - return shouldTestInside(element) - ? getClosedCurveShape( - element, - roughShape, - pointFrom(element.x, element.y), - element.angle, - pointFrom(cx, cy), - ) - : getCurveShape( - roughShape, - pointFrom(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 = ( - element: ExcalidrawElement, - elementsMap: ElementsMap, -): GeometricShape | 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, - endPoint: P, -) => { - const shape = ShapeCache.generateElementShape(element, null); - if (!shape) { - return null; - } - - const ops = getCurvePathOps(shape[0]); - let currentP = pointFrom

(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

(data[0], data[1]); - const p2 = pointFrom

(data[2], data[3]); - const p3 = pointFrom

(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 =

( - 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 =

( - element: NonDeleted, - 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 =

( - element: NonDeleted, - 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 =

( - element: NonDeleted, - 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 =

( - element: NonDeleted, - 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, - 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: 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 - const ret = { - polygon: nextPolygonState, - points: updatedPoints, - }; - - return ret; -}; diff --git a/packages/element/src/textElement.ts b/packages/element/src/textElement.ts index 46b728158d..31347db240 100644 --- a/packages/element/src/textElement.ts +++ b/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] }; diff --git a/packages/element/src/utils.ts b/packages/element/src/utils.ts index 673b9ef1b0..ca31639d5c 100644 --- a/packages/element/src/utils.ts +++ b/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 = ( export function deconstructLinearOrFreeDrawElement( element: ExcalidrawLinearElement | ExcalidrawFreeDrawElement, - elementsMap: ElementsMap, ): [LineSegment[], Curve[]] { 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; +}; diff --git a/packages/element/tests/linearElementEditor.test.tsx b/packages/element/tests/linearElementEditor.test.tsx index 8618154aba..4b957022c6 100644 --- a/packages/element/tests/linearElementEditor.test.tsx +++ b/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", } `); }); diff --git a/packages/excalidraw/actions/actionLinearEditor.tsx b/packages/excalidraw/actions/actionLinearEditor.tsx index 32eba33394..28295d9395 100644 --- a/packages/excalidraw/actions/actionLinearEditor.tsx +++ b/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({ diff --git a/packages/excalidraw/actions/actionProperties.tsx b/packages/excalidraw/actions/actionProperties.tsx index 62c6bae91b..63cfe76727 100644 --- a/packages/excalidraw/actions/actionProperties.tsx +++ b/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"; diff --git a/packages/excalidraw/data/__snapshots__/transform.test.ts.snap b/packages/excalidraw/data/__snapshots__/transform.test.ts.snap index 1bc3ce9253..f00a51817d 100644 --- a/packages/excalidraw/data/__snapshots__/transform.test.ts.snap +++ b/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, "verticalAlign": "middle", "width": 120, - "x": 187.7545, + "x": 187.75450000000004, "y": 44.5, } `; diff --git a/packages/math/src/constants.ts b/packages/math/src/constants.ts new file mode 100644 index 0000000000..ce39e42682 --- /dev/null +++ b/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, +]; diff --git a/packages/math/src/curve.ts b/packages/math/src/curve.ts index 26ab690e9c..614f072504 100644 --- a/packages/math/src/curve.ts +++ b/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

( + c: Curve

, +): 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

( + c: Curve

, + 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

( + c: Curve

, + 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); +}