From 3363e0e289103aded8dbe8d28868b9e2d84d2436 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Sat, 17 May 2025 21:03:15 +0200 Subject: [PATCH] Refactor shape functions and fix curved half point handle on linear --- packages/element/src/ShapeCache.ts | 95 ----- packages/element/src/binding.ts | 2 +- packages/element/src/bounds.ts | 70 ++- packages/element/src/collision.ts | 2 +- packages/element/src/elbowArrow.ts | 3 +- packages/element/src/flowchart.ts | 2 +- packages/element/src/index.ts | 4 +- packages/element/src/linearElementEditor.ts | 94 ++--- packages/element/src/mutateElement.ts | 3 +- packages/element/src/renderElement.ts | 4 +- packages/element/src/{Shape.ts => shape.ts} | 167 +++++++- packages/element/src/shapes.ts | 398 ------------------ packages/element/src/textElement.ts | 3 - packages/element/src/utils.ts | 55 ++- .../tests/linearElementEditor.test.tsx | 36 +- .../data/__snapshots__/transform.test.ts.snap | 2 +- packages/math/src/curve.ts | 50 ++- 17 files changed, 403 insertions(+), 587 deletions(-) delete mode 100644 packages/element/src/ShapeCache.ts rename packages/element/src/{Shape.ts => shape.ts} (82%) delete mode 100644 packages/element/src/shapes.ts diff --git a/packages/element/src/ShapeCache.ts b/packages/element/src/ShapeCache.ts deleted file mode 100644 index 8f0c94324..000000000 --- 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 f3a28cffd..4ca8fd260 100644 --- a/packages/element/src/binding.ts +++ b/packages/element/src/binding.ts @@ -37,6 +37,7 @@ import { getCenterForBounds, getElementBounds, doBoundsIntersect, + aabbForElement, } from "./bounds"; import { intersectElementWithLineSegment } from "./collision"; import { distanceToElement } from "./distance"; @@ -61,7 +62,6 @@ import { isTextElement, } from "./typeChecks"; -import { aabbForElement } from "./shapes"; import { updateElbowArrowPoints } from "./elbowArrow"; import type { Scene } from "./Scene"; diff --git a/packages/element/src/bounds.ts b/packages/element/src/bounds.ts index a5b91922b..f8794d364 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,7 @@ 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, getElementShape, ShapeCache } from "./shape"; import { LinearElementEditor } from "./linearElementEditor"; import { getBoundTextElement, getContainerElement } from "./textElement"; import { @@ -45,8 +45,6 @@ import { isTextElement, } from "./typeChecks"; -import { getElementShape } from "./shapes"; - import { deconstructDiamondElement, deconstructRectanguloidElement, @@ -1146,3 +1144,67 @@ export const doBoundsIntersect = ( return minX1 < maxX2 && maxX1 > minX2 && minY1 < maxY2 && maxY1 > minY2; }; + +export const pointInsideBounds =

( + p: P, + bounds: Bounds, +): boolean => + p[0] > bounds[0] && p[0] < bounds[2] && p[1] > bounds[1] && p[1] < bounds[3]; + +/** + * Get the axis-aligned bounding box for a given element + */ +export const aabbForElement = ( + element: Readonly, + 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); + 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; +}; diff --git a/packages/element/src/collision.ts b/packages/element/src/collision.ts index dce9513e4..e7b574229 100644 --- a/packages/element/src/collision.ts +++ b/packages/element/src/collision.ts @@ -33,7 +33,6 @@ import type { import type { FrameNameBounds } from "@excalidraw/excalidraw/types"; -import { isPathALoop } from "./shapes"; import { getElementBounds } from "./bounds"; import { hasBoundTextElement, @@ -47,6 +46,7 @@ import { deconstructDiamondElement, deconstructLinearOrFreeDrawElement, deconstructRectanguloidElement, + isPathALoop, } from "./utils"; import { getBoundTextElement } from "./textElement"; diff --git a/packages/element/src/elbowArrow.ts b/packages/element/src/elbowArrow.ts index 990f4e570..6218d74b4 100644 --- a/packages/element/src/elbowArrow.ts +++ b/packages/element/src/elbowArrow.ts @@ -52,9 +52,8 @@ import { type NonDeletedSceneElementsMap, } from "./types"; -import { aabbForElement, pointInsideBounds } from "./shapes"; +import { aabbForElement, pointInsideBounds, type Bounds } from "./bounds"; -import type { Bounds } from "./bounds"; import type { Heading } from "./heading"; import type { Arrowhead, diff --git a/packages/element/src/flowchart.ts b/packages/element/src/flowchart.ts index 5194e5425..36110cd35 100644 --- a/packages/element/src/flowchart.ts +++ b/packages/element/src/flowchart.ts @@ -21,7 +21,6 @@ import { import { LinearElementEditor } from "./linearElementEditor"; import { mutateElement } from "./mutateElement"; import { newArrowElement, newElement } from "./newElement"; -import { aabbForElement } from "./shapes"; import { elementsAreInFrameBounds, elementOverlapsWithFrame } from "./frame"; import { isBindableElement, @@ -38,6 +37,7 @@ import { type Ordered, type OrderedExcalidrawElement, } from "./types"; +import { aabbForElement } from "./bounds"; import type { Scene } from "./Scene"; diff --git a/packages/element/src/index.ts b/packages/element/src/index.ts index 93024f994..9bf5214d0 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 eec3fc7a0..0a8500d09 100644 --- a/packages/element/src/linearElementEditor.ts +++ b/packages/element/src/linearElementEditor.ts @@ -7,6 +7,10 @@ import { type LocalPoint, pointDistance, vectorFromPoint, + isCurve, + isLineSegment, + curveLength, + curveHalfPoint, } from "@excalidraw/math"; import { getCurvePathOps } from "@excalidraw/utils/shape"; @@ -20,9 +24,14 @@ import { tupleToCoors, } from "@excalidraw/common"; -import type { Store } from "@excalidraw/element"; +import { + deconstructLinearOrFreeDrawElement, + isPathALoop, + ShapeCache, + type Store, +} from "@excalidraw/element"; -import type { Radians } from "@excalidraw/math"; +import type { Curve, Radians } from "@excalidraw/math"; import type { AppState, @@ -55,16 +64,6 @@ import { isFixedPointBinding, } from "./typeChecks"; -import { ShapeCache } from "./ShapeCache"; - -import { - isPathALoop, - getBezierCurveLength, - getControlPointsForBezierCurve, - mapIntervalToBezierT, - getBezierXY, -} from "./shapes"; - import { getLockedLinearCursorAlignSize } from "./sizeHelpers"; import type { Scene } from "./Scene"; @@ -567,10 +566,7 @@ export class LinearElementEditor { } const segmentMidPoint = LinearElementEditor.getSegmentMidPoint( element, - points[index], - points[index + 1], index + 1, - elementsMap, ); midpoints.push(segmentMidPoint); index++; @@ -672,7 +668,14 @@ export class LinearElementEditor { let distance = pointDistance(startPoint, endPoint); if (element.points.length > 2 && element.roundness) { - distance = getBezierCurveLength(element, endPoint); + const segments = deconstructLinearOrFreeDrawElement(element); + + invariant( + segments.length >= index, + "Invalid segment index while calculating segment length", + ); + + distance = curveLength(segments[index] as Curve); } return distance * zoom.value < LinearElementEditor.POINT_HANDLE_SIZE * 4; @@ -680,39 +683,39 @@ 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 segments = deconstructLinearOrFreeDrawElement(element); + + invariant( + segments.length >= index, + "Invalid segment index while calculating mid point", + ); + + const shape = segments[index - 1]; + + switch (true) { + case isCurve(shape): + return curveHalfPoint(shape as Curve); + case isLineSegment(shape): + return pointCenter(shape[0] as GlobalPoint, shape[1] as GlobalPoint); + } + + invariant( + false, + `Invalid segment type while calculating mid point ${shape}`, + ); } static getSegmentMidPointIndex( @@ -1592,10 +1595,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 84785c31c..a5c3209ac 100644 --- a/packages/element/src/mutateElement.ts +++ b/packages/element/src/mutateElement.ts @@ -8,11 +8,10 @@ import type { Radians } from "@excalidraw/math"; import type { Mutable } from "@excalidraw/common/utility-types"; -import { ShapeCache } from "./ShapeCache"; - import { updateElbowArrowPoints } from "./elbowArrow"; import { isElbowArrow } from "./typeChecks"; +import { ShapeCache } from "./shape"; import type { ElementsMap, diff --git a/packages/element/src/renderElement.ts b/packages/element/src/renderElement.ts index 2786f3f84..b4739c55b 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 { ShapeCache } from "./ShapeCache"; +import { getCornerRadius } from "./utils"; +import { ShapeCache } from "./shape"; import type { ExcalidrawElement, diff --git a/packages/element/src/Shape.ts b/packages/element/src/shape.ts similarity index 82% rename from packages/element/src/Shape.ts rename to packages/element/src/shape.ts index 0ab0b0fb7..727e7cec5 100644 --- a/packages/element/src/Shape.ts +++ b/packages/element/src/shape.ts @@ -1,12 +1,25 @@ import { simplify } from "points-on-curve"; +import { + 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, +} from "@excalidraw/common"; import { RoughGenerator } from "roughjs/bin/generator"; @@ -14,8 +27,17 @@ import type { GlobalPoint, Radians } 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 type { GeometricShape } from "@excalidraw/utils/shape"; import { isElbowArrow, @@ -24,17 +46,20 @@ import { isIframeLikeElement, isLinearElement, } from "./typeChecks"; -import { getCornerRadius, isPathALoop } from "./shapes"; import { headingForPointIsHorizontal } from "./heading"; import { canChangeRoundness } from "./comparisons"; -import { generateFreeDrawShape } from "./renderElement"; +import { elementWithCanvasCache, generateFreeDrawShape } from "./renderElement"; import { getArrowheadPoints, getDiamondPoints, + getElementAbsoluteCoords, getElementBounds, } from "./bounds"; +import { shouldTestInside } from "./collision"; +import { getCornerRadius, isPathALoop } from "./utils"; + import type { ExcalidrawElement, NonDeletedExcalidrawElement, @@ -42,6 +67,7 @@ import type { ExcalidrawLinearElement, Arrowhead, ExcalidrawFreeDrawElement, + ElementsMap, } from "./types"; import type { Drawable, Options } from "roughjs/bin/core"; @@ -743,3 +769,134 @@ 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 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/shapes.ts b/packages/element/src/shapes.ts deleted file mode 100644 index 96542c538..000000000 --- a/packages/element/src/shapes.ts +++ /dev/null @@ -1,398 +0,0 @@ -import { - DEFAULT_ADAPTIVE_RADIUS, - DEFAULT_PROPORTIONAL_RADIUS, - LINE_CONFIRM_THRESHOLD, - ROUNDNESS, - invariant, - elementCenterPoint, -} 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 type { - ElementsMap, - ExcalidrawElement, - ExcalidrawLinearElement, - 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, - 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); - 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; -}; diff --git a/packages/element/src/textElement.ts b/packages/element/src/textElement.ts index 46b728158..31347db24 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 ccc41c11f..da20df983 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, @@ -10,15 +18,15 @@ import { } from "@excalidraw/math"; 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 { ExcalidrawDiamondElement, + ExcalidrawElement, ExcalidrawFreeDrawElement, ExcalidrawLinearElement, ExcalidrawRectanguloidElement, @@ -339,3 +347,44 @@ export function deconstructDiamondElement( return [sides, corners.flat()]; } + +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; +}; diff --git a/packages/element/tests/linearElementEditor.test.tsx b/packages/element/tests/linearElementEditor.test.tsx index 4e6553c55..f4ba1fbe8 100644 --- a/packages/element/tests/linearElementEditor.test.tsx +++ b/packages/element/tests/linearElementEditor.test.tsx @@ -346,12 +346,12 @@ describe("Test Linear Elements", () => { expect(midPointsWithRoundEdge).toMatchInlineSnapshot(` [ [ - "55.96978", - "47.44233", + "56.87500", + "48.12500", ], [ - "76.08587", - "43.29417", + "79.37500", + "48.12500", ], ] `); @@ -411,12 +411,12 @@ describe("Test Linear Elements", () => { expect(newMidPoints).toMatchInlineSnapshot(` [ [ - "105.96978", - "67.44233", + "106.87500", + "68.12500", ], [ - "126.08587", - "63.29417", + "129.37500", + "68.12500", ], ] `); @@ -727,12 +727,12 @@ describe("Test Linear Elements", () => { expect(newMidPoints).toMatchInlineSnapshot(` [ [ - "31.88408", - "23.13276", + "31.87500", + "23.12500", ], [ - "77.74793", - "44.57841", + "82.50000", + "51.25000", ], ] `); @@ -816,12 +816,12 @@ describe("Test Linear Elements", () => { expect(newMidPoints).toMatchInlineSnapshot(` [ [ - "55.96978", - "47.44233", + "56.87500", + "48.12500", ], [ - "76.08587", - "43.29417", + "79.37500", + "48.12500", ], ] `); @@ -983,8 +983,8 @@ describe("Test Linear Elements", () => { ); expect(position).toMatchInlineSnapshot(` { - "x": "85.82202", - "y": "75.63461", + "x": "90.23300", + "y": "81.68631", } `); }); diff --git a/packages/excalidraw/data/__snapshots__/transform.test.ts.snap b/packages/excalidraw/data/__snapshots__/transform.test.ts.snap index 8ff72d03a..53b45a0bd 100644 --- a/packages/excalidraw/data/__snapshots__/transform.test.ts.snap +++ b/packages/excalidraw/data/__snapshots__/transform.test.ts.snap @@ -1789,7 +1789,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/curve.ts b/packages/math/src/curve.ts index e0ae7770d..4909276e7 100644 --- a/packages/math/src/curve.ts +++ b/packages/math/src/curve.ts @@ -1,6 +1,14 @@ +import { invariant } from "@excalidraw/common"; + import type { Bounds } from "@excalidraw/element"; -import { isPoint, pointDistance, pointFrom, pointFromVector } from "./point"; +import { + isPoint, + pointCenter, + pointDistance, + pointFrom, + pointFromVector, +} from "./point"; import { rectangle, rectangleIntersectLineSegment } from "./rectangle"; import { vector, vectorNormal, vectorNormalize, vectorScale } from "./vector"; @@ -408,3 +416,43 @@ export function offsetPointsForQuadraticBezier( return offsetPoints; } + +export function curveLength

( + c: Curve

, +): number { + // Use numerical integration to approximate the curve length + const steps = 50; + let length = 0; + + // Calculate length by summing segments + let prevPoint = bezierEquation(c, 0); + for (let i = 1; i <= steps; i++) { + const t = i / steps; + const currentPoint = bezierEquation(c, t); + length += pointDistance(prevPoint, currentPoint); + prevPoint = currentPoint; + } + + return length; +} + +export function curveHalfPoint

( + c: Curve

, +): P { + const steps = 50; + const halfLength = curveLength(c) / 2; + + let length = 0; + let prevPoint = bezierEquation(c, 0); + for (let i = 1; i <= steps; i++) { + const t = i / steps; + const currentPoint = bezierEquation(c, t); + length += pointDistance(prevPoint, currentPoint); + if (length > halfLength) { + return pointCenter(prevPoint, currentPoint); + } + prevPoint = currentPoint; + } + + invariant(false, "No half point found (this should not happen)"); +}