diff --git a/packages/excalidraw/data/__snapshots__/transform.test.ts.snap b/packages/excalidraw/data/__snapshots__/transform.test.ts.snap index 921118eb1f..161e23ac5d 100644 --- a/packages/excalidraw/data/__snapshots__/transform.test.ts.snap +++ b/packages/excalidraw/data/__snapshots__/transform.test.ts.snap @@ -1,236 +1,5 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`Test Transform > Test arrow bindings > should bind arrows to existing shapes when start / end provided with ids 1`] = ` -{ - "angle": 0, - "backgroundColor": "#d8f5a2", - "boundElements": [ - { - "id": "id45", - "type": "arrow", - }, - { - "id": "id46", - "type": "arrow", - }, - ], - "customData": undefined, - "fillStyle": "solid", - "frameId": null, - "groupIds": [], - "height": 300, - "id": Any, - "index": "a0", - "isDeleted": false, - "link": null, - "locked": false, - "opacity": 100, - "roughness": 1, - "roundness": null, - "seed": Any, - "strokeColor": "#66a80f", - "strokeStyle": "solid", - "strokeWidth": 2, - "type": "ellipse", - "updated": 1, - "version": 4, - "versionNonce": Any, - "width": 300, - "x": 630, - "y": 316, -} -`; - -exports[`Test Transform > Test arrow bindings > should bind arrows to existing shapes when start / end provided with ids 2`] = ` -{ - "angle": 0, - "backgroundColor": "transparent", - "boundElements": [ - { - "id": "id46", - "type": "arrow", - }, - ], - "customData": undefined, - "fillStyle": "solid", - "frameId": null, - "groupIds": [], - "height": 100, - "id": Any, - "index": "a1", - "isDeleted": false, - "link": null, - "locked": false, - "opacity": 100, - "roughness": 1, - "roundness": null, - "seed": Any, - "strokeColor": "#9c36b5", - "strokeStyle": "solid", - "strokeWidth": 2, - "type": "diamond", - "updated": 1, - "version": 3, - "versionNonce": Any, - "width": 140, - "x": 96, - "y": 400, -} -`; - -exports[`Test Transform > Test arrow bindings > should bind arrows to existing shapes when start / end provided with ids 3`] = ` -{ - "angle": 0, - "backgroundColor": "transparent", - "boundElements": null, - "customData": undefined, - "elbowed": false, - "endArrowhead": "arrow", - "endBinding": { - "elementId": "ellipse-1", - "fixedPoint": null, - "focus": -0.008153707962747813, - "gap": 1, - }, - "fillStyle": "solid", - "frameId": null, - "groupIds": [], - "height": 35, - "id": Any, - "index": "a2", - "isDeleted": false, - "lastCommittedPoint": null, - "link": null, - "locked": false, - "opacity": 100, - "points": [ - [ - 0.5, - 0.5, - ], - [ - 394.5, - 34.5, - ], - ], - "roughness": 1, - "roundness": null, - "seed": Any, - "startArrowhead": null, - "startBinding": { - "elementId": "id47", - "fixedPoint": null, - "focus": -0.08139534883720931, - "gap": 1, - }, - "strokeColor": "#1864ab", - "strokeStyle": "solid", - "strokeWidth": 2, - "type": "arrow", - "updated": 1, - "version": 4, - "versionNonce": Any, - "width": 395, - "x": 247, - "y": 420, -} -`; - -exports[`Test Transform > Test arrow bindings > should bind arrows to existing shapes when start / end provided with ids 4`] = ` -{ - "angle": 0, - "backgroundColor": "transparent", - "boundElements": null, - "customData": undefined, - "elbowed": false, - "endArrowhead": "arrow", - "endBinding": { - "elementId": "ellipse-1", - "fixedPoint": null, - "focus": 0.10666666666666667, - "gap": 3.834326468444573, - }, - "fillStyle": "solid", - "frameId": null, - "groupIds": [], - "height": 0, - "id": Any, - "index": "a3", - "isDeleted": false, - "lastCommittedPoint": null, - "link": null, - "locked": false, - "opacity": 100, - "points": [ - [ - 0.5, - 0, - ], - [ - 399.5, - 0, - ], - ], - "roughness": 1, - "roundness": null, - "seed": Any, - "startArrowhead": null, - "startBinding": { - "elementId": "diamond-1", - "fixedPoint": null, - "focus": 0, - "gap": 1, - }, - "strokeColor": "#e67700", - "strokeStyle": "solid", - "strokeWidth": 2, - "type": "arrow", - "updated": 1, - "version": 4, - "versionNonce": Any, - "width": 400, - "x": 227, - "y": 450, -} -`; - -exports[`Test Transform > Test arrow bindings > should bind arrows to existing shapes when start / end provided with ids 5`] = ` -{ - "angle": 0, - "backgroundColor": "transparent", - "boundElements": [ - { - "id": "id45", - "type": "arrow", - }, - ], - "customData": undefined, - "fillStyle": "solid", - "frameId": null, - "groupIds": [], - "height": 300, - "id": Any, - "index": "a4", - "isDeleted": false, - "link": null, - "locked": false, - "opacity": 100, - "roughness": 1, - "roundness": null, - "seed": Any, - "strokeColor": "#1e1e1e", - "strokeStyle": "solid", - "strokeWidth": 2, - "type": "rectangle", - "updated": 1, - "version": 3, - "versionNonce": Any, - "width": 300, - "x": -53, - "y": 270, -} -`; - exports[`Test Transform > Test arrow bindings > should bind arrows to existing text elements when start / end provided with ids 1`] = ` { "angle": 0, @@ -1551,7 +1320,7 @@ exports[`Test Transform > should transform the elements correctly when linear el "elementId": "B", "fixedPoint": null, "focus": 0, - "gap": 1, + "gap": 77.017, }, "fillStyle": "solid", "frameId": null, diff --git a/packages/excalidraw/element/binding.ts b/packages/excalidraw/element/binding.ts index 45611d3c8c..407de4ac31 100644 --- a/packages/excalidraw/element/binding.ts +++ b/packages/excalidraw/element/binding.ts @@ -705,11 +705,7 @@ export const getHeadingForElbowArrowSnap = ( return otherPointHeading; } - const distance = getDistanceForBinding( - origPoint, - bindableElement, - elementsMap, - ); + const distance = getDistanceForBinding(origPoint, bindableElement); if (!distance) { return vectorToHeading( @@ -731,7 +727,6 @@ export const getHeadingForElbowArrowSnap = ( const getDistanceForBinding = ( point: Readonly, bindableElement: ExcalidrawBindableElement, - elementsMap: ElementsMap, ) => { const distance = distanceToBindableElement(bindableElement, point); const bindDistance = maxBindingGap( diff --git a/packages/excalidraw/element/distance.ts b/packages/excalidraw/element/distance.ts index d4c83c368b..422e591bb5 100644 --- a/packages/excalidraw/element/distance.ts +++ b/packages/excalidraw/element/distance.ts @@ -123,8 +123,8 @@ const roundedCutoffSegment = ( const t = (4 * r) / Math.sqrt(2); return segment( - ellipseSegmentInterceptPoints(ellipse(s[0], radians(0), t, t), s)[0], - ellipseSegmentInterceptPoints(ellipse(s[1], radians(0), t, t), s)[0], + ellipseSegmentInterceptPoints(ellipse(s[0], t, t), s)[0], + ellipseSegmentInterceptPoints(ellipse(s[1], t, t), s)[0], ); }; @@ -198,13 +198,12 @@ export const distanceToEllipseElement = ( element: ExcalidrawEllipseElement, p: GlobalPoint, ): number => { + const center = point( + element.x + element.width / 2, + element.y + element.height / 2, + ); return ellipseDistanceFromPoint( - p, - ellipse( - point(element.x + element.width / 2, element.y + element.height / 2), - element.angle, - element.width / 2, - element.height / 2, - ), + pointRotateRads(p, center, radians(-element.angle)), + ellipse(center, element.width / 2, element.height / 2), ); }; diff --git a/packages/excalidraw/element/routing.ts b/packages/excalidraw/element/routing.ts index 4f4501a253..93f6a49fdc 100644 --- a/packages/excalidraw/element/routing.ts +++ b/packages/excalidraw/element/routing.ts @@ -17,7 +17,6 @@ import { aabbForElement, pointInsideBounds } from "../shapes"; import { isAnyTrue, toBrandedType } from "../utils"; import { bindPointToSnapToElementOutline, - distanceToBindableElement, avoidRectangularCorner, getHoveredElementForBinding, FIXED_BINDING_DISTANCE, @@ -26,6 +25,7 @@ import { snapToMid, } from "./binding"; import type { Bounds } from "./bounds"; +import { distanceToBindableElement } from "./distance"; import type { Heading } from "./heading"; import { compareHeading, @@ -1023,7 +1023,7 @@ const getGlobalPoint = ( // NOTE: Resize scales the binding position point too, so we need to update it return Math.abs( - distanceToBindableElement(boundElement, fixedGlobalPoint, elementsMap) - + distanceToBindableElement(boundElement, fixedGlobalPoint) - FIXED_BINDING_DISTANCE, ) > 0.01 ? getSnapPoint(initialPoint, otherPoint, boundElement, elementsMap) @@ -1060,9 +1060,12 @@ const getBindPointHeading = ( hoveredElement && aabbForElement( hoveredElement, - Array(4).fill( - distanceToBindableElement(hoveredElement, p, elementsMap), - ) as [number, number, number, number], + Array(4).fill(distanceToBindableElement(hoveredElement, p)) as [ + number, + number, + number, + number, + ], ), elementsMap, origPoint, diff --git a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap index c0c995884f..b473240946 100644 --- a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap @@ -817,8 +817,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "strokeWidth": 2, "type": "arrow", "updated": 1, - "version": 28, - "width": 50, + "version": 30, + "width": 0, "x": 200, "y": 0, } @@ -852,7 +852,7 @@ History { 0, ], [ - 50, + 0, 0, ], ], @@ -938,7 +938,7 @@ History { 0, ], [ - 50, + 0, 0, ], ], diff --git a/packages/excalidraw/tests/__snapshots__/move.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/move.test.tsx.snap index 654eccfea4..75bcb6db45 100644 --- a/packages/excalidraw/tests/__snapshots__/move.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/move.test.tsx.snap @@ -225,7 +225,7 @@ exports[`move element > rectangles with binding arrow 7`] = ` "elementId": "id0", "fixedPoint": null, "focus": "-0.60000", - "gap": 10, + "gap": 9, }, "strokeColor": "#1e1e1e", "strokeStyle": "solid", diff --git a/packages/math/arc.test.ts b/packages/math/arc.test.ts index 98f5fefbbc..b922c815d3 100644 --- a/packages/math/arc.test.ts +++ b/packages/math/arc.test.ts @@ -1,5 +1,5 @@ import { radians } from "./angle"; -import { arc, arcIncludesPoint, arcSegmentInterceptPoint } from "./arc"; +import { arc, arcIncludesPoint, arcSegmentInterceptPoints } from "./arc"; import { point } from "./point"; import { segment } from "./segment"; @@ -33,7 +33,7 @@ describe("point on arc", () => { describe("intersection", () => { it("should report correct interception point", () => { expect( - arcSegmentInterceptPoint( + arcSegmentInterceptPoints( arc(point(0, 0), 1, radians(-Math.PI / 4), radians(Math.PI / 4)), segment(point(2, 1), point(0, 0)), ), @@ -42,7 +42,7 @@ describe("intersection", () => { it("should report both interception points when present", () => { expect( - arcSegmentInterceptPoint( + arcSegmentInterceptPoints( arc(point(0, 0), 1, radians(-Math.PI / 4), radians(Math.PI / 4)), segment(point(0.9, -2), point(0.9, 2)), ), diff --git a/packages/math/arc.ts b/packages/math/arc.ts index b8d53dc6df..86a4f97893 100644 --- a/packages/math/arc.ts +++ b/packages/math/arc.ts @@ -1,8 +1,10 @@ -import { invariant } from "../excalidraw/utils"; -import { cartesian2Polar, radians } from "./angle"; -import { ellipse, ellipseSegmentInterceptPoints } from "./ellipse"; +import { cartesian2Polar, normalizeRadians, radians } from "./angle"; +import { + ellipse, + ellipseDistanceFromPoint, + ellipseSegmentInterceptPoints, +} from "./ellipse"; import { point, pointDistance } from "./point"; -import { segment } from "./segment"; import type { GenericPoint, Segment, Radians, Arc } from "./types"; import { PRECISION } from "./utils"; @@ -53,26 +55,44 @@ export function arcDistanceFromPoint( a: Arc, p: Point, ) { - const intersectPoint = arcSegmentInterceptPoint(a, segment(p, a.center)); - - invariant( - intersectPoint.length !== 1, - "Arc distance intersector cannot have multiple intersections", + const theta = normalizeRadians( + radians(Math.atan2(p[0] - a.center[0], p[1] - a.center[1])), ); - return pointDistance(intersectPoint[0], p); + if (a.startAngle <= theta && a.endAngle >= theta) { + return ellipseDistanceFromPoint( + p, + ellipse(a.center, 2 * a.radius, 2 * a.radius), + ); + } + return Math.min( + pointDistance( + p, + point( + a.center[0] + a.radius + Math.cos(a.startAngle), + a.center[1] + a.radius + Math.sin(a.startAngle), + ), + ), + pointDistance( + p, + point( + a.center[0] + a.radius + Math.cos(a.endAngle), + a.center[1] + a.radius + Math.sin(a.endAngle), + ), + ), + ); } /** * Returns the intersection point(s) of a line segment represented by a start * point and end point and a symmetric arc. */ -export function arcSegmentInterceptPoint( +export function arcSegmentInterceptPoints( a: Readonly>, s: Readonly>, ): Point[] { return ellipseSegmentInterceptPoints( - ellipse(a.center, radians(0), a.radius, a.radius), + ellipse(a.center, a.radius, a.radius), s, ).filter((candidate) => { const [candidateRadius, candidateAngle] = cartesian2Polar( diff --git a/packages/math/ellipse.test.ts b/packages/math/ellipse.test.ts index ef3fc211f0..653c4c55f0 100644 --- a/packages/math/ellipse.test.ts +++ b/packages/math/ellipse.test.ts @@ -1,4 +1,3 @@ -import { radians } from "./angle"; import { ellipse, ellipseSegmentInterceptPoints, @@ -10,29 +9,29 @@ import { segment } from "./segment"; import type { Ellipse, GlobalPoint } from "./types"; describe("point and ellipse", () => { - const target: Ellipse = ellipse(point(0, 0), radians(0), 2, 1); - it("point on ellipse", () => { - [point(0, 1), point(0, -1), point(2, 0), point(-2, 0)].forEach((p) => { + const target: Ellipse = ellipse(point(1, 2), 2, 1); + [point(1, 3), point(1, 1), point(3, 2), point(-1, 2)].forEach((p) => { expect(ellipseTouchesPoint(p, target)).toBe(true); }); - expect(ellipseTouchesPoint(point(-1.4, 0.7), target, 0.1)).toBe(true); - expect(ellipseTouchesPoint(point(-1.4, 0.71), target, 0.01)).toBe(true); + expect(ellipseTouchesPoint(point(-0.4, 2.7), target, 0.1)).toBe(true); + expect(ellipseTouchesPoint(point(-0.4, 2.71), target, 0.01)).toBe(true); - expect(ellipseTouchesPoint(point(1.4, 0.7), target, 0.1)).toBe(true); - expect(ellipseTouchesPoint(point(1.4, 0.71), target, 0.01)).toBe(true); + expect(ellipseTouchesPoint(point(2.4, 2.7), target, 0.1)).toBe(true); + expect(ellipseTouchesPoint(point(2.4, 2.71), target, 0.01)).toBe(true); - expect(ellipseTouchesPoint(point(1, -0.86), target, 0.1)).toBe(true); - expect(ellipseTouchesPoint(point(1, -0.86), target, 0.01)).toBe(true); + expect(ellipseTouchesPoint(point(2, 1.14), target, 0.1)).toBe(true); + expect(ellipseTouchesPoint(point(2, 1.14), target, 0.01)).toBe(true); - expect(ellipseTouchesPoint(point(-1, -0.86), target, 0.1)).toBe(true); - expect(ellipseTouchesPoint(point(-1, -0.86), target, 0.01)).toBe(true); + expect(ellipseTouchesPoint(point(0, 1.14), target, 0.1)).toBe(true); + expect(ellipseTouchesPoint(point(0, 1.14), target, 0.01)).toBe(true); - expect(ellipseTouchesPoint(point(-1, 0.8), target)).toBe(false); - expect(ellipseTouchesPoint(point(1, -0.8), target)).toBe(false); + expect(ellipseTouchesPoint(point(0, 2.8), target)).toBe(false); + expect(ellipseTouchesPoint(point(2, 1.2), target)).toBe(false); }); it("point in ellipse", () => { + const target: Ellipse = ellipse(point(0, 0), 2, 1); [point(0, 1), point(0, -1), point(2, 0), point(-2, 0)].forEach((p) => { expect(ellipseIncludesPoint(p, target)).toBe(true); }); @@ -50,7 +49,7 @@ describe("point and ellipse", () => { describe("line and ellipse", () => { it("detects outside segment", () => { - const e = ellipse(point(0, 0), radians(0), 2, 2); + const e = ellipse(point(0, 0), 2, 2); expect( ellipseSegmentInterceptPoints( diff --git a/packages/math/ellipse.ts b/packages/math/ellipse.ts index 7127b6abc1..dff59bcc76 100644 --- a/packages/math/ellipse.ts +++ b/packages/math/ellipse.ts @@ -1,12 +1,5 @@ -import { radians } from "./angle"; -import { line } from "./line"; -import { - point, - pointDistance, - pointFromVector, - pointRotateRads, -} from "./point"; -import type { Ellipse, GenericPoint, Segment, Radians } from "./types"; +import { point, pointDistance, pointFromVector } from "./point"; +import type { Ellipse, GenericPoint, Segment } from "./types"; import { PRECISION } from "./utils"; import { vector, @@ -27,13 +20,11 @@ import { */ export function ellipse( center: Point, - angle: Radians, halfWidth: number, halfHeight: number, ): Ellipse { return { center, - angle, halfWidth, halfHeight, } as Ellipse; @@ -50,22 +41,11 @@ export const ellipseIncludesPoint = ( p: Point, ellipse: Ellipse, ) => { - const { center, angle, halfWidth, halfHeight } = ellipse; - const translatedPoint = vectorAdd( - vectorFromPoint(p), - vectorScale(vectorFromPoint(center), -1), - ); - const [rotatedPointX, rotatedPointY] = pointRotateRads( - pointFromVector(translatedPoint), - point(0, 0), - radians(-angle), - ); + const { center, halfWidth, halfHeight } = ellipse; + const normalizedX = (p[0] - center[0]) / halfWidth; + const normalizedY = (p[1] - center[1]) / halfHeight; - return ( - (rotatedPointX / halfWidth) * (rotatedPointX / halfWidth) + - (rotatedPointY / halfHeight) * (rotatedPointY / halfHeight) <= - 1 - ); + return normalizedX * normalizedX + normalizedY * normalizedY <= 1; }; /** @@ -97,21 +77,16 @@ export const ellipseDistanceFromPoint = ( p: Point, ellipse: Ellipse, ): number => { - const { angle, halfWidth, halfHeight, center } = ellipse; + const { halfWidth, halfHeight, center } = ellipse; const a = halfWidth; const b = halfHeight; const translatedPoint = vectorAdd( vectorFromPoint(p), vectorScale(vectorFromPoint(center), -1), ); - const [rotatedPointX, rotatedPointY] = pointRotateRads( - pointFromVector(translatedPoint), - point(0, 0), - radians(-angle), - ); - const px = Math.abs(rotatedPointX); - const py = Math.abs(rotatedPointY); + const px = Math.abs(translatedPoint[0]); + const py = Math.abs(translatedPoint[1]); let tx = 0.707; let ty = 0.707; @@ -140,11 +115,11 @@ export const ellipseDistanceFromPoint = ( } const [minX, minY] = [ - a * tx * Math.sign(rotatedPointX), - b * ty * Math.sign(rotatedPointY), + a * tx * Math.sign(translatedPoint[0]), + b * ty * Math.sign(translatedPoint[1]), ]; - return pointDistance(point(rotatedPointX, rotatedPointY), point(minX, minY)); + return pointDistance(pointFromVector(translatedPoint), point(minX, minY)); }; /** @@ -153,19 +128,13 @@ export const ellipseDistanceFromPoint = ( */ export function ellipseSegmentInterceptPoints( e: Readonly>, - l: Readonly>, + s: Readonly>, ): Point[] { const rx = e.halfWidth; const ry = e.halfHeight; - const nonRotatedLine = line( - pointRotateRads(l[0], e.center, radians(-e.angle)), - pointRotateRads(l[1], e.center, radians(-e.angle)), - ); - const dir = vectorFromPoint(nonRotatedLine[1], nonRotatedLine[0]); - const diff = vector( - nonRotatedLine[0][0] - e.center[0], - nonRotatedLine[0][1] - e.center[1], - ); + + const dir = vectorFromPoint(s[1], s[0]); + const diff = vector(s[0][0] - e.center[0], s[0][1] - e.center[1]); const mDir = vector(dir[0] / (rx * rx), dir[1] / (ry * ry)); const mDiff = vector(diff[0] / (rx * rx), diff[1] / (ry * ry)); @@ -183,10 +152,8 @@ export function ellipseSegmentInterceptPoints( if (0 <= t_a && t_a <= 1) { intersections.push( point( - nonRotatedLine[0][0] + - (nonRotatedLine[1][0] - nonRotatedLine[0][0]) * t_a, - nonRotatedLine[0][1] + - (nonRotatedLine[1][1] - nonRotatedLine[0][1]) * t_a, + s[0][0] + (s[1][0] - s[0][0]) * t_a, + s[0][1] + (s[1][1] - s[0][1]) * t_a, ), ); } @@ -194,10 +161,8 @@ export function ellipseSegmentInterceptPoints( if (0 <= t_b && t_b <= 1) { intersections.push( point( - nonRotatedLine[0][0] + - (nonRotatedLine[1][0] - nonRotatedLine[0][0]) * t_b, - nonRotatedLine[0][1] + - (nonRotatedLine[1][1] - nonRotatedLine[0][1]) * t_b, + s[0][0] + (s[1][0] - s[0][0]) * t_b, + s[0][1] + (s[1][1] - s[0][1]) * t_b, ), ); } @@ -206,16 +171,12 @@ export function ellipseSegmentInterceptPoints( if (0 <= t && t <= 1) { intersections.push( point( - nonRotatedLine[0][0] + - (nonRotatedLine[1][0] - nonRotatedLine[0][0]) * t, - nonRotatedLine[0][1] + - (nonRotatedLine[1][1] - nonRotatedLine[0][1]) * t, + s[0][0] + (s[1][0] - s[0][0]) * t, + s[0][1] + (s[1][1] - s[0][1]) * t, ), ); } } - return intersections.map((point) => - pointRotateRads(point, e.center, e.angle), - ); + return intersections; } diff --git a/packages/math/types.ts b/packages/math/types.ts index 4a7c73fa02..ecc42ffd23 100644 --- a/packages/math/types.ts +++ b/packages/math/types.ts @@ -137,7 +137,6 @@ export type Extent = { // in replace of semi major and semi minor axes export type Ellipse = { center: Point; - angle: Radians; halfWidth: number; halfHeight: number; } & { diff --git a/packages/utils/geometry/shape.ts b/packages/utils/geometry/shape.ts index 2d7c2bf9e1..d7e4d2c189 100644 --- a/packages/utils/geometry/shape.ts +++ b/packages/utils/geometry/shape.ts @@ -175,16 +175,11 @@ export const getSelectionBoxShape = ( export const getEllipseShape = ( element: ExcalidrawEllipseElement, ): GeometricShape => { - const { width, height, angle, x, y } = element; + const { width, height, x, y } = element; return { type: "ellipse", - data: ellipse( - point(x + width / 2, y + height / 2), - angle, - width / 2, - height / 2, - ), + data: ellipse(point(x + width / 2, y + height / 2), width / 2, height / 2), }; };