From 0ffeaeaecfc77a952c3468ad81f56f1c7489177a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rk=20Tolm=C3=A1cs?= Date: Tue, 25 Feb 2025 22:52:06 +0100 Subject: [PATCH] feat: Remove GA code from binding (#9042) Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com> --- excalidraw-app/components/DebugCanvas.tsx | 34 + .../excalidraw/actions/actionProperties.tsx | 10 +- packages/excalidraw/constants.ts | 2 +- .../data/__snapshots__/transform.test.ts.snap | 32 +- packages/excalidraw/data/transform.test.ts | 7 +- packages/excalidraw/element/binding.ts | 999 +++++++----------- packages/excalidraw/element/collision.ts | 190 +++- packages/excalidraw/element/distance.ts | 123 +++ .../excalidraw/element/elbowArrow.test.tsx | 1 + packages/excalidraw/element/elbowArrow.ts | 58 +- packages/excalidraw/element/utils.ts | 355 +++++++ .../tests/__snapshots__/history.test.tsx.snap | 181 ++-- .../tests/__snapshots__/move.test.tsx.snap | 10 +- packages/excalidraw/tests/binding.test.tsx | 3 - packages/excalidraw/tests/history.test.tsx | 33 +- .../tests/linearElementEditor.test.tsx | 2 +- packages/excalidraw/tests/move.test.tsx | 2 +- packages/excalidraw/tests/resize.test.tsx | 25 +- packages/excalidraw/tests/rotate.test.tsx | 6 +- packages/excalidraw/tests/test-utils.ts | 31 - packages/excalidraw/visualdebug.ts | 56 +- packages/math/angle.ts | 5 +- packages/math/arc.test.ts | 41 - packages/math/arc.ts | 20 - packages/math/curve.test.ts | 101 ++ packages/math/curve.ts | 431 ++++---- packages/math/ellipse.test.ts | 126 +++ packages/math/ellipse.ts | 230 ++++ packages/math/ga/ga.test.ts | 70 -- packages/math/ga/ga.ts | 317 ------ packages/math/ga/gadirections.ts | 26 - packages/math/ga/galines.ts | 52 - packages/math/ga/gapoints.ts | 42 - packages/math/ga/gatransforms.ts | 41 - packages/math/index.ts | 2 +- packages/math/line.test.ts | 31 + packages/math/line.ts | 58 +- packages/math/point.ts | 49 +- packages/math/rectangle.ts | 23 + packages/math/segment.test.ts | 21 + packages/math/segment.ts | 32 +- packages/math/types.ts | 22 +- packages/math/vector.ts | 11 +- packages/utils/test-utils.ts | 33 + 44 files changed, 2112 insertions(+), 1832 deletions(-) create mode 100644 packages/excalidraw/element/distance.ts create mode 100644 packages/excalidraw/element/utils.ts delete mode 100644 packages/math/arc.test.ts delete mode 100644 packages/math/arc.ts create mode 100644 packages/math/curve.test.ts create mode 100644 packages/math/ellipse.test.ts create mode 100644 packages/math/ellipse.ts delete mode 100644 packages/math/ga/ga.test.ts delete mode 100644 packages/math/ga/ga.ts delete mode 100644 packages/math/ga/gadirections.ts delete mode 100644 packages/math/ga/galines.ts delete mode 100644 packages/math/ga/gapoints.ts delete mode 100644 packages/math/ga/gatransforms.ts create mode 100644 packages/math/line.test.ts create mode 100644 packages/math/rectangle.ts create mode 100644 packages/math/segment.test.ts create mode 100644 packages/utils/test-utils.ts diff --git a/excalidraw-app/components/DebugCanvas.tsx b/excalidraw-app/components/DebugCanvas.tsx index fe2c617fc..13b4bd6c0 100644 --- a/excalidraw-app/components/DebugCanvas.tsx +++ b/excalidraw-app/components/DebugCanvas.tsx @@ -12,11 +12,13 @@ import { TrashIcon, } from "../../packages/excalidraw/components/icons"; import { STORAGE_KEYS } from "../app_constants"; +import type { Curve } from "../../packages/math"; import { isLineSegment, type GlobalPoint, type LineSegment, } from "../../packages/math"; +import { isCurve } from "../../packages/math/curve"; const renderLine = ( context: CanvasRenderingContext2D, @@ -33,6 +35,28 @@ const renderLine = ( context.restore(); }; +const renderCubicBezier = ( + context: CanvasRenderingContext2D, + zoom: number, + [start, control1, control2, end]: Curve, + color: string, +) => { + context.save(); + context.strokeStyle = color; + context.beginPath(); + context.moveTo(start[0] * zoom, start[1] * zoom); + context.bezierCurveTo( + control1[0] * zoom, + control1[1] * zoom, + control2[0] * zoom, + control2[1] * zoom, + end[0] * zoom, + end[1] * zoom, + ); + context.stroke(); + context.restore(); +}; + const renderOrigin = (context: CanvasRenderingContext2D, zoom: number) => { context.strokeStyle = "#888"; context.save(); @@ -60,6 +84,16 @@ const render = ( el.color, ); break; + case isCurve(el.data): + renderCubicBezier( + context, + appState.zoom.value, + el.data as Curve, + el.color, + ); + break; + default: + throw new Error(`Unknown element type ${JSON.stringify(el)}`); } }); }; diff --git a/packages/excalidraw/actions/actionProperties.tsx b/packages/excalidraw/actions/actionProperties.tsx index 660d7e679..1552fc0c8 100644 --- a/packages/excalidraw/actions/actionProperties.tsx +++ b/packages/excalidraw/actions/actionProperties.tsx @@ -1633,18 +1633,16 @@ export const actionChangeArrowType = register({ const finalStartPoint = startHoveredElement ? bindPointToSnapToElementOutline( - startGlobalPoint, - endGlobalPoint, + newElement, startHoveredElement, - elementsMap, + "start", ) : startGlobalPoint; const finalEndPoint = endHoveredElement ? bindPointToSnapToElementOutline( - endGlobalPoint, - startGlobalPoint, + newElement, endHoveredElement, - elementsMap, + "end", ) : endGlobalPoint; diff --git a/packages/excalidraw/constants.ts b/packages/excalidraw/constants.ts index cb32190b2..b01dc98ee 100644 --- a/packages/excalidraw/constants.ts +++ b/packages/excalidraw/constants.ts @@ -188,7 +188,7 @@ export const DEFAULT_TRANSFORM_HANDLE_SPACING = 2; export const SIDE_RESIZING_THRESHOLD = 2 * DEFAULT_TRANSFORM_HANDLE_SPACING; // a small epsilon to make side resizing always take precedence // (avoids an increase in renders and changes to tests) -const EPSILON = 0.00001; +export const EPSILON = 0.00001; export const DEFAULT_COLLISION_THRESHOLD = 2 * SIDE_RESIZING_THRESHOLD - EPSILON; diff --git a/packages/excalidraw/data/__snapshots__/transform.test.ts.snap b/packages/excalidraw/data/__snapshots__/transform.test.ts.snap index 0f86b84eb..917f3d95e 100644 --- a/packages/excalidraw/data/__snapshots__/transform.test.ts.snap +++ b/packages/excalidraw/data/__snapshots__/transform.test.ts.snap @@ -88,9 +88,8 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s "endArrowhead": "arrow", "endBinding": { "elementId": "ellipse-1", - "fixedPoint": null, - "focus": -0.008153707962747813, - "gap": 1, + "focus": -0.007519379844961235, + "gap": 11.562288374879595, }, "fillStyle": "solid", "frameId": null, @@ -119,8 +118,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s "startArrowhead": null, "startBinding": { "elementId": "id49", - "fixedPoint": null, - "focus": -0.08139534883720931, + "focus": -0.0813953488372095, "gap": 1, }, "strokeColor": "#1864ab", @@ -146,9 +144,8 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s "endArrowhead": "arrow", "endBinding": { "elementId": "ellipse-1", - "fixedPoint": null, "focus": 0.10666666666666667, - "gap": 3.834326468444573, + "gap": 3.8343264684446097, }, "fillStyle": "solid", "frameId": null, @@ -177,9 +174,8 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s "startArrowhead": null, "startBinding": { "elementId": "diamond-1", - "fixedPoint": null, "focus": 0, - "gap": 1, + "gap": 4.545343408287929, }, "strokeColor": "#e67700", "strokeStyle": "solid", @@ -338,7 +334,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t "endArrowhead": "arrow", "endBinding": { "elementId": "text-2", - "fixedPoint": null, "focus": 0, "gap": 14, }, @@ -369,7 +364,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t "startArrowhead": null, "startBinding": { "elementId": "text-1", - "fixedPoint": null, "focus": 0, "gap": 1, }, @@ -442,8 +436,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe "endArrowhead": "arrow", "endBinding": { "elementId": "id42", - "fixedPoint": null, - "focus": 0, + "focus": -0, "gap": 1, }, "fillStyle": "solid", @@ -473,7 +466,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe "startArrowhead": null, "startBinding": { "elementId": "id41", - "fixedPoint": null, "focus": 0, "gap": 1, }, @@ -620,8 +612,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when "endArrowhead": "arrow", "endBinding": { "elementId": "id46", - "fixedPoint": null, - "focus": 0, + "focus": -0, "gap": 1, }, "fillStyle": "solid", @@ -651,7 +642,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when "startArrowhead": null, "startBinding": { "elementId": "id45", - "fixedPoint": null, "focus": 0, "gap": 1, }, @@ -1484,8 +1474,7 @@ exports[`Test Transform > should transform the elements correctly when linear el "endArrowhead": "arrow", "endBinding": { "elementId": "Alice", - "fixedPoint": null, - "focus": 0, + "focus": -0, "gap": 5.299874999999986, }, "fillStyle": "solid", @@ -1517,7 +1506,6 @@ exports[`Test Transform > should transform the elements correctly when linear el "startArrowhead": null, "startBinding": { "elementId": "Bob", - "fixedPoint": null, "focus": 0, "gap": 1, }, @@ -1549,9 +1537,8 @@ exports[`Test Transform > should transform the elements correctly when linear el "endArrowhead": "arrow", "endBinding": { "elementId": "B", - "fixedPoint": null, "focus": 0, - "gap": 1, + "gap": 14, }, "fillStyle": "solid", "frameId": null, @@ -1578,7 +1565,6 @@ exports[`Test Transform > should transform the elements correctly when linear el "startArrowhead": null, "startBinding": { "elementId": "Bob", - "fixedPoint": null, "focus": 0, "gap": 1, }, diff --git a/packages/excalidraw/data/transform.test.ts b/packages/excalidraw/data/transform.test.ts index ce0cf89f7..98a62daf7 100644 --- a/packages/excalidraw/data/transform.test.ts +++ b/packages/excalidraw/data/transform.test.ts @@ -434,7 +434,7 @@ describe("Test Transform", () => { }, endBinding: { elementId: ellipse.id, - focus: 0, + focus: -0, }, }); @@ -519,7 +519,7 @@ describe("Test Transform", () => { }, endBinding: { elementId: text3.id, - focus: 0, + focus: -0, }, }); @@ -777,8 +777,7 @@ describe("Test Transform", () => { const [arrow, rect] = excalidrawElements; expect((arrow as ExcalidrawArrowElement).endBinding).toStrictEqual({ elementId: "rect-1", - fixedPoint: null, - focus: 0, + focus: -0, gap: 14, }); expect(rect.boundElements).toStrictEqual([ diff --git a/packages/excalidraw/element/binding.ts b/packages/excalidraw/element/binding.ts index fa5b46181..332a70fcd 100644 --- a/packages/excalidraw/element/binding.ts +++ b/packages/excalidraw/element/binding.ts @@ -1,18 +1,6 @@ -import * as GA from "../../math/ga/ga"; -import * as GAPoint from "../../math/ga/gapoints"; -import * as GADirection from "../../math/ga/gadirections"; -import * as GALine from "../../math/ga/galines"; -import * as GATransform from "../../math/ga/gatransforms"; - import type { ExcalidrawBindableElement, ExcalidrawElement, - ExcalidrawRectangleElement, - ExcalidrawDiamondElement, - ExcalidrawEllipseElement, - ExcalidrawImageElement, - ExcalidrawFrameLikeElement, - ExcalidrawIframeLikeElement, NonDeleted, ExcalidrawLinearElement, PointBinding, @@ -25,11 +13,11 @@ import type { ExcalidrawElbowArrowElement, FixedPoint, SceneElementsMap, - ExcalidrawRectanguloidElement, + FixedPointBinding, } from "./types"; import type { Bounds } from "./bounds"; -import { getCenterForBounds, getElementAbsoluteCoords } from "./bounds"; +import { getCenterForBounds } from "./bounds"; import type { AppState } from "../types"; import { isPointOnShape } from "../../utils/collision"; import { @@ -41,7 +29,7 @@ import { isFixedPointBinding, isFrameLikeElement, isLinearElement, - isRectangularElement, + isRectanguloidElement, isTextElement, } from "./typeChecks"; import type { ElementUpdate } from "./mutateElement"; @@ -59,7 +47,6 @@ import { aabbForElement, getElementShape, pointInsideBounds } from "../shapes"; import { compareHeading, HEADING_DOWN, - HEADING_LEFT, HEADING_RIGHT, HEADING_UP, headingForPointFromElement, @@ -73,11 +60,18 @@ import { pointRotateRads, type GlobalPoint, vectorFromPoint, - pointFromPair, pointDistanceSq, clamp, + pointDistance, + pointFromVector, + vectorScale, + vectorNormalize, + vectorCross, + pointsEqual, + lineSegmentIntersectionPoints, } from "../../math"; -import { segmentIntersectRectangleElement } from "../../utils/geometry/shape"; +import { intersectElementWithLineSegment } from "./collision"; +import { distanceToBindableElement } from "./distance"; export type SuggestedBinding = | NonDeleted @@ -474,25 +468,31 @@ export const bindLinearElement = ( if (!isArrowElement(linearElement)) { return; } - const binding: PointBinding = { + + const binding: PointBinding | FixedPointBinding = { elementId: hoveredElement.id, - ...normalizePointBinding( - calculateFocusAndGap( - linearElement, - hoveredElement, - startOrEnd, - elementsMap, - ), - hoveredElement, - ), ...(isElbowArrow(linearElement) - ? calculateFixedPointForElbowArrowBinding( - linearElement, - hoveredElement, - startOrEnd, - elementsMap, - ) - : { fixedPoint: null }), + ? { + ...calculateFixedPointForElbowArrowBinding( + linearElement, + hoveredElement, + startOrEnd, + elementsMap, + ), + focus: 0, + gap: 0, + } + : { + ...normalizePointBinding( + calculateFocusAndGap( + linearElement, + hoveredElement, + startOrEnd, + elementsMap, + ), + hoveredElement, + ), + }), }; mutateElement(linearElement, { @@ -703,17 +703,10 @@ const calculateFocusAndGap = ( adjacentPointIndex, elementsMap, ); + return { - focus: determineFocusDistance( - hoveredElement, - adjacentPoint, - edgePoint, - elementsMap, - ), - gap: Math.max( - 1, - distanceToBindableElement(hoveredElement, edgePoint, elementsMap), - ), + focus: determineFocusDistance(hoveredElement, adjacentPoint, edgePoint), + gap: Math.max(1, distanceToBindableElement(hoveredElement, edgePoint)), }; }; @@ -882,11 +875,7 @@ const getDistanceForBinding = ( elementsMap: ElementsMap, zoom?: AppState["zoom"], ) => { - const distance = distanceToBindableElement( - bindableElement, - point, - elementsMap, - ); + const distance = distanceToBindableElement(bindableElement, point); const bindDistance = maxBindingGap( bindableElement, bindableElement.width, @@ -898,52 +887,58 @@ const getDistanceForBinding = ( }; export const bindPointToSnapToElementOutline = ( - p: Readonly, - otherPoint: Readonly, + arrow: ExcalidrawElbowArrowElement, bindableElement: ExcalidrawBindableElement | undefined, - elementsMap: ElementsMap, + startOrEnd: "start" | "end", ): GlobalPoint => { const aabb = bindableElement && aabbForElement(bindableElement); + const localP = + arrow.points[startOrEnd === "start" ? 0 : arrow.points.length - 1]; + const globalP = pointFrom( + arrow.x + localP[0], + arrow.y + localP[1], + ); + const p = isRectanguloidElement(bindableElement) + ? avoidRectangularCorner(bindableElement, globalP) + : globalP; if (bindableElement && aabb) { - // TODO: Dirty hacks until tangents are properly calculated - const heading = headingForPointFromElement(bindableElement, aabb, p); - const intersections = [ - ...(intersectElementWithLine( - bindableElement, - pointFrom(p[0], p[1] - 2 * bindableElement.height), - pointFrom(p[0], p[1] + 2 * bindableElement.height), - FIXED_BINDING_DISTANCE, - elementsMap, - ) ?? []), - ...(intersectElementWithLine( - bindableElement, - pointFrom(p[0] - 2 * bindableElement.width, p[1]), - pointFrom(p[0] + 2 * bindableElement.width, p[1]), - FIXED_BINDING_DISTANCE, - elementsMap, - ) ?? []), - ]; + const center = getCenterForBounds(aabb); - const isVertical = - compareHeading(heading, HEADING_LEFT) || - compareHeading(heading, HEADING_RIGHT); - const dist = Math.abs( - distanceToBindableElement(bindableElement, p, elementsMap), - ); - const isInner = isVertical - ? dist < bindableElement.width * -0.1 - : dist < bindableElement.height * -0.1; + const intersection = intersectElementWithLineSegment( + bindableElement, + lineSegment( + center, + pointFromVector( + vectorScale( + vectorNormalize(vectorFromPoint(p, center)), + Math.max(bindableElement.width, bindableElement.height), + ), + center, + ), + ), + )[0]; + const currentDistance = pointDistance(p, center); + const fullDistance = pointDistance(intersection, center); + const ratio = currentDistance / fullDistance; - intersections.sort((a, b) => pointDistanceSq(a, p) - pointDistanceSq(b, p)); + switch (true) { + case ratio > 0.9: + if (currentDistance - fullDistance > FIXED_BINDING_DISTANCE) { + return p; + } - return isInner - ? headingToMidBindPoint(otherPoint, bindableElement, aabb) - : intersections.filter((i) => - isVertical - ? Math.abs(p[1] - i[1]) < 0.1 - : Math.abs(p[0] - i[0]) < 0.1, - )[0] ?? p; + return pointFromVector( + vectorScale( + vectorNormalize(vectorFromPoint(p, intersection)), + ratio > 1 ? FIXED_BINDING_DISTANCE : -FIXED_BINDING_DISTANCE, + ), + intersection, + ); + + default: + return headingToMidBindPoint(p, bindableElement, aabb); + } } return p; @@ -1208,7 +1203,6 @@ const updateBoundPoint = ( bindableElement, binding.focus, adjacentPoint, - elementsMap, ); let newEdgePoint: GlobalPoint; @@ -1218,20 +1212,64 @@ const updateBoundPoint = ( if (binding.gap === 0) { newEdgePoint = focusPointAbsolute; } else { - const intersections = intersectElementWithLine( - bindableElement, - adjacentPoint, - focusPointAbsolute, - binding.gap, - elementsMap, + const edgePointAbsolute = + LinearElementEditor.getPointAtIndexGlobalCoordinates( + linearElement, + edgePointIndex, + elementsMap, + ); + + const center = pointFrom( + bindableElement.x + bindableElement.width / 2, + bindableElement.y + bindableElement.height / 2, ); - if (!intersections || intersections.length === 0) { - // This should never happen, since focusPoint should always be - // inside the element, but just in case, bail out + const interceptorLength = + pointDistance(adjacentPoint, edgePointAbsolute) + + pointDistance(adjacentPoint, center) + + Math.max(bindableElement.width, bindableElement.height) * 2; + const intersections = intersectElementWithLineSegment( + bindableElement, + lineSegment( + adjacentPoint, + pointFromVector( + vectorScale( + vectorNormalize(vectorFromPoint(focusPointAbsolute, adjacentPoint)), + interceptorLength, + ), + adjacentPoint, + ), + ), + binding.gap, + ).sort( + (g, h) => + pointDistanceSq(g, adjacentPoint) - pointDistanceSq(h, adjacentPoint), + ); + + // debugClear(); + // debugDrawPoint(intersections[0], { color: "red", permanent: true }); + // debugDrawLine( + // lineSegment( + // adjacentPoint, + // pointFromVector( + // vectorScale( + // vectorNormalize(vectorFromPoint(focusPointAbsolute, adjacentPoint)), + // interceptorLength, + // ), + // adjacentPoint, + // ), + // ), + // { permanent: true, color: "green" }, + // ); + + if (intersections.length > 1) { + // The adjacent point is outside the shape (+ gap) + newEdgePoint = intersections[0]; + } else if (intersections.length === 1) { + // The adjacent point is inside the shape (+ gap) newEdgePoint = focusPointAbsolute; } else { - // Guaranteed to intersect because focusPoint is always inside the shape - newEdgePoint = intersections[0]; + // Shouldn't happend, but just in case + newEdgePoint = edgePointAbsolute; } } @@ -1254,23 +1292,10 @@ export const calculateFixedPointForElbowArrowBinding = ( hoveredElement.x + hoveredElement.width, hoveredElement.y + hoveredElement.height, ] as Bounds; - const edgePointIndex = - startOrEnd === "start" ? 0 : linearElement.points.length - 1; - const globalPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates( - linearElement, - edgePointIndex, - elementsMap, - ); - const otherGlobalPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates( - linearElement, - edgePointIndex, - elementsMap, - ); const snappedPoint = bindPointToSnapToElementOutline( - globalPoint, - otherGlobalPoint, + linearElement, hoveredElement, - elementsMap, + startOrEnd, ); const globalMidPoint = pointFrom( bounds[0] + (bounds[2] - bounds[0]) / 2, @@ -1524,171 +1549,6 @@ export const maxBindingGap = ( ); }; -export const distanceToBindableElement = ( - element: ExcalidrawBindableElement, - point: GlobalPoint, - elementsMap: ElementsMap, -): number => { - switch (element.type) { - case "rectangle": - case "image": - case "text": - case "iframe": - case "embeddable": - case "frame": - case "magicframe": - return distanceToRectangle(element, point, elementsMap); - case "diamond": - return distanceToDiamond(element, point, elementsMap); - case "ellipse": - return distanceToEllipse(element, point, elementsMap); - } -}; - -const distanceToRectangle = ( - element: ExcalidrawRectanguloidElement, - p: GlobalPoint, - elementsMap: ElementsMap, -): number => { - const [, pointRel, hwidth, hheight] = pointRelativeToElement( - element, - p, - elementsMap, - ); - return Math.max( - GAPoint.distanceToLine(pointRel, GALine.equation(0, 1, -hheight)), - GAPoint.distanceToLine(pointRel, GALine.equation(1, 0, -hwidth)), - ); -}; - -const distanceToDiamond = ( - element: ExcalidrawDiamondElement, - point: GlobalPoint, - elementsMap: ElementsMap, -): number => { - const [, pointRel, hwidth, hheight] = pointRelativeToElement( - element, - point, - elementsMap, - ); - const side = GALine.equation(hheight, hwidth, -hheight * hwidth); - return GAPoint.distanceToLine(pointRel, side); -}; - -const distanceToEllipse = ( - element: ExcalidrawEllipseElement, - point: GlobalPoint, - elementsMap: ElementsMap, -): number => { - const [pointRel, tangent] = ellipseParamsForTest(element, point, elementsMap); - return -GALine.sign(tangent) * GAPoint.distanceToLine(pointRel, tangent); -}; - -const ellipseParamsForTest = ( - element: ExcalidrawEllipseElement, - point: GlobalPoint, - elementsMap: ElementsMap, -): [GA.Point, GA.Line] => { - const [, pointRel, hwidth, hheight] = pointRelativeToElement( - element, - point, - elementsMap, - ); - const [px, py] = GAPoint.toTuple(pointRel); - - // We're working in positive quadrant, so start with `t = 45deg`, `tx=cos(t)` - let tx = 0.707; - let ty = 0.707; - - const a = hwidth; - const b = hheight; - - // This is a numerical method to find the params tx, ty at which - // the ellipse has the closest point to the given point - [0, 1, 2, 3].forEach((_) => { - const xx = a * tx; - const yy = b * ty; - - const ex = ((a * a - b * b) * tx ** 3) / a; - const ey = ((b * b - a * a) * ty ** 3) / b; - - const rx = xx - ex; - const ry = yy - ey; - - const qx = px - ex; - const qy = py - ey; - - const r = Math.hypot(ry, rx); - const q = Math.hypot(qy, qx); - - tx = Math.min(1, Math.max(0, ((qx * r) / q + ex) / a)); - ty = Math.min(1, Math.max(0, ((qy * r) / q + ey) / b)); - const t = Math.hypot(ty, tx); - tx /= t; - ty /= t; - }); - - const closestPoint = GA.point(a * tx, b * ty); - - const tangent = GALine.orthogonalThrough(pointRel, closestPoint); - return [pointRel, tangent]; -}; - -// Returns: -// 1. the point relative to the elements (x, y) position -// 2. the point relative to the element's center with positive (x, y) -// 3. half element width -// 4. half element height -// -// Note that for linear elements the (x, y) position is not at the -// top right corner of their boundary. -// -// Rectangles, diamonds and ellipses are symmetrical over axes, -// and other elements have a rectangular boundary, -// so we only need to perform hit tests for the positive quadrant. -const pointRelativeToElement = ( - element: ExcalidrawElement, - pointTuple: GlobalPoint, - elementsMap: ElementsMap, -): [GA.Point, GA.Point, number, number] => { - const point = GAPoint.from(pointTuple); - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); - const center = coordsCenter(x1, y1, x2, y2); - // GA has angle orientation opposite to `rotate` - const rotate = GATransform.rotation(center, element.angle); - const pointRotated = GATransform.apply(rotate, point); - const pointRelToCenter = GA.sub(pointRotated, GADirection.from(center)); - const pointRelToCenterAbs = GAPoint.abs(pointRelToCenter); - const elementPos = GA.offset(element.x, element.y); - const pointRelToPos = GA.sub(pointRotated, elementPos); - const halfWidth = (x2 - x1) / 2; - const halfHeight = (y2 - y1) / 2; - return [pointRelToPos, pointRelToCenterAbs, halfWidth, halfHeight]; -}; - -const relativizationToElementCenter = ( - element: ExcalidrawElement, - elementsMap: ElementsMap, -): GA.Transform => { - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); - const center = coordsCenter(x1, y1, x2, y2); - // GA has angle orientation opposite to `rotate` - const rotate = GATransform.rotation(center, element.angle); - const translate = GA.reverse( - GATransform.translation(GADirection.from(center)), - ); - return GATransform.compose(rotate, translate); -}; - -const coordsCenter = ( - x1: number, - y1: number, - x2: number, - y2: number, -): GA.Point => { - return GA.point((x1 + x2) / 2, (y1 + y2) / 2); -}; - // The focus distance is the oriented ratio between the size of // the `element` and the "focus image" of the element on which // all focus points lie, so it's a number between -1 and 1. @@ -1700,39 +1560,146 @@ const determineFocusDistance = ( a: GlobalPoint, // Another point on the line, in absolute coordinates (closer to element) b: GlobalPoint, - elementsMap: ElementsMap, ): number => { - const relateToCenter = relativizationToElementCenter(element, elementsMap); - const aRel = GATransform.apply(relateToCenter, GAPoint.from(a)); - const bRel = GATransform.apply(relateToCenter, GAPoint.from(b)); - const line = GALine.through(aRel, bRel); - const q = element.height / element.width; - const hwidth = element.width / 2; - const hheight = element.height / 2; - const n = line[2]; - const m = line[3]; - const c = line[1]; - const mabs = Math.abs(m); - const nabs = Math.abs(n); - let ret; - switch (element.type) { - case "rectangle": - case "image": - case "text": - case "iframe": - case "embeddable": - case "frame": - case "magicframe": - ret = c / (hwidth * (nabs + q * mabs)); - break; - case "diamond": - ret = mabs < nabs ? c / (nabs * hwidth) : c / (mabs * hheight); - break; - case "ellipse": - ret = c / (hwidth * Math.sqrt(n ** 2 + q ** 2 * m ** 2)); - break; + const center = pointFrom( + element.x + element.width / 2, + element.y + element.height / 2, + ); + + if (pointsEqual(a, b)) { + return 0; } - return ret || 0; + + const rotatedA = pointRotateRads(a, center, -element.angle as Radians); + const rotatedB = pointRotateRads(b, center, -element.angle as Radians); + const sign = + Math.sign( + vectorCross( + vectorFromPoint(rotatedB, a), + vectorFromPoint(rotatedB, center), + ), + ) * -1; + const rotatedInterceptor = lineSegment( + rotatedB, + pointFromVector( + vectorScale( + vectorNormalize(vectorFromPoint(rotatedB, rotatedA)), + Math.max(element.width * 2, element.height * 2), + ), + rotatedB, + ), + ); + const axes = + element.type === "diamond" + ? [ + lineSegment( + pointFrom(element.x + element.width / 2, element.y), + pointFrom( + element.x + element.width / 2, + element.y + element.height, + ), + ), + lineSegment( + pointFrom(element.x, element.y + element.height / 2), + pointFrom( + element.x + element.width, + element.y + element.height / 2, + ), + ), + ] + : [ + lineSegment( + pointFrom(element.x, element.y), + pointFrom( + element.x + element.width, + element.y + element.height, + ), + ), + lineSegment( + pointFrom(element.x + element.width, element.y), + pointFrom(element.x, element.y + element.height), + ), + ]; + const interceptees = + element.type === "diamond" + ? [ + lineSegment( + pointFrom( + element.x + element.width / 2, + element.y - element.height, + ), + pointFrom( + element.x + element.width / 2, + element.y + element.height * 2, + ), + ), + lineSegment( + pointFrom( + element.x - element.width, + element.y + element.height / 2, + ), + pointFrom( + element.x + element.width * 2, + element.y + element.height / 2, + ), + ), + ] + : [ + lineSegment( + pointFrom( + element.x - element.width, + element.y - element.height, + ), + pointFrom( + element.x + element.width * 2, + element.y + element.height * 2, + ), + ), + lineSegment( + pointFrom( + element.x + element.width * 2, + element.y - element.height, + ), + pointFrom( + element.x - element.width, + element.y + element.height * 2, + ), + ), + ]; + + const ordered = [ + lineSegmentIntersectionPoints(rotatedInterceptor, interceptees[0]), + lineSegmentIntersectionPoints(rotatedInterceptor, interceptees[1]), + ] + .filter((p): p is GlobalPoint => p !== null) + .sort((g, h) => pointDistanceSq(g, b) - pointDistanceSq(h, b)) + .map( + (p, idx): number => + (sign * pointDistance(center, p)) / + (element.type === "diamond" + ? pointDistance(axes[idx][0], axes[idx][1]) / 2 + : Math.sqrt(element.width ** 2 + element.height ** 2) / 2), + ) + .sort((g, h) => Math.abs(g) - Math.abs(h)); + + // debugClear(); + // [ + // lineSegmentIntersectionPoints(rotatedInterceptor, interceptees[0]), + // lineSegmentIntersectionPoints(rotatedInterceptor, interceptees[1]), + // ] + // .filter((p): p is GlobalPoint => p !== null) + // .forEach((p) => debugDrawPoint(p, { color: "black", permanent: true })); + // debugDrawPoint(determineFocusPoint(element, ordered[0] ?? 0, rotatedA), { + // color: "red", + // permanent: true, + // }); + // debugDrawLine(rotatedInterceptor, { color: "green", permanent: true }); + // debugDrawLine(interceptees[0], { color: "red", permanent: true }); + // debugDrawLine(interceptees[1], { color: "red", permanent: true }); + + const signedDistanceRatio = ordered[0] ?? 0; + + return signedDistanceRatio; }; const determineFocusPoint = ( @@ -1740,317 +1707,123 @@ const determineFocusPoint = ( // The oriented, relative distance from the center of `element` of the // returned focusPoint focus: number, - adjecentPoint: GlobalPoint, - elementsMap: ElementsMap, + adjacentPoint: GlobalPoint, ): GlobalPoint => { + const center = pointFrom( + element.x + element.width / 2, + element.y + element.height / 2, + ); + if (focus === 0) { - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); - const center = coordsCenter(x1, y1, x2, y2); - return pointFromPair(GAPoint.toTuple(center)); - } - const relateToCenter = relativizationToElementCenter(element, elementsMap); - const adjecentPointRel = GATransform.apply( - relateToCenter, - GAPoint.from(adjecentPoint), - ); - const reverseRelateToCenter = GA.reverse(relateToCenter); - let point; - switch (element.type) { - case "rectangle": - case "image": - case "text": - case "diamond": - case "iframe": - case "embeddable": - case "frame": - case "magicframe": - point = findFocusPointForRectangulars(element, focus, adjecentPointRel); - break; - case "ellipse": - point = findFocusPointForEllipse(element, focus, adjecentPointRel); - break; - } - return pointFromPair( - GAPoint.toTuple(GATransform.apply(reverseRelateToCenter, point)), - ); -}; - -// Returns 2 or 0 intersection points between line going through `a` and `b` -// and the `element`, in ascending order of distance from `a`. -const intersectElementWithLine = ( - element: ExcalidrawBindableElement, - // Point on the line, in absolute coordinates - a: GlobalPoint, - // Another point on the line, in absolute coordinates - b: GlobalPoint, - // If given, the element is inflated by this value - gap: number = 0, - elementsMap: ElementsMap, -): GlobalPoint[] | undefined => { - if (isRectangularElement(element)) { - return segmentIntersectRectangleElement(element, lineSegment(a, b), gap); + return center; } - const relateToCenter = relativizationToElementCenter(element, elementsMap); - const aRel = GATransform.apply(relateToCenter, GAPoint.from(a)); - const bRel = GATransform.apply(relateToCenter, GAPoint.from(b)); - const line = GALine.through(aRel, bRel); - const reverseRelateToCenter = GA.reverse(relateToCenter); - const intersections = getSortedElementLineIntersections( - element, - line, - aRel, - gap, - ); - return intersections.map( - (point) => - pointFromPair( - GAPoint.toTuple(GATransform.apply(reverseRelateToCenter, point)), + const candidates = ( + element.type === "diamond" + ? [ + pointFrom(element.x, element.y + element.height / 2), + pointFrom(element.x + element.width / 2, element.y), + pointFrom( + element.x + element.width, + element.y + element.height / 2, + ), + pointFrom( + element.x + element.width / 2, + element.y + element.height, + ), + ] + : [ + pointFrom(element.x, element.y), + pointFrom(element.x + element.width, element.y), + pointFrom( + element.x + element.width, + element.y + element.height, + ), + pointFrom(element.x, element.y + element.height), + ] + ) + .map((p) => + pointFromVector( + vectorScale(vectorFromPoint(p, center), Math.abs(focus)), + center, ), - // pointFromArray( - // , - // ), - ); -}; + ) + .map((p) => pointRotateRads(p, center, element.angle as Radians)); -const getSortedElementLineIntersections = ( - element: ExcalidrawBindableElement, - // Relative to element center - line: GA.Line, - // Relative to element center - nearPoint: GA.Point, - gap: number = 0, -): GA.Point[] => { - let intersections: GA.Point[]; - switch (element.type) { - case "rectangle": - case "image": - case "text": - case "diamond": - case "iframe": - case "embeddable": - case "frame": - case "magicframe": - const corners = getCorners(element); - intersections = corners - .flatMap((point, i) => { - const edge: [GA.Point, GA.Point] = [point, corners[(i + 1) % 4]]; - return intersectSegment(line, offsetSegment(edge, gap)); - }) - .concat( - corners.flatMap((point) => getCircleIntersections(point, gap, line)), - ); - break; - case "ellipse": - intersections = getEllipseIntersections(element, gap, line); - break; - } - if (intersections.length < 2) { - // Ignore the "edge" case of only intersecting with a single corner - return []; - } - const sortedIntersections = intersections.sort( - (i1, i2) => - GAPoint.distance(i1, nearPoint) - GAPoint.distance(i2, nearPoint), - ); - return [ - sortedIntersections[0], - sortedIntersections[sortedIntersections.length - 1], + const selected = [ + vectorCross( + vectorFromPoint(adjacentPoint, candidates[0]), + vectorFromPoint(candidates[1], candidates[0]), + ) > 0 && // TOP + (focus > 0 + ? vectorCross( + vectorFromPoint(adjacentPoint, candidates[1]), + vectorFromPoint(candidates[2], candidates[1]), + ) < 0 + : vectorCross( + vectorFromPoint(adjacentPoint, candidates[3]), + vectorFromPoint(candidates[0], candidates[3]), + ) < 0), + vectorCross( + vectorFromPoint(adjacentPoint, candidates[1]), + vectorFromPoint(candidates[2], candidates[1]), + ) > 0 && // RIGHT + (focus > 0 + ? vectorCross( + vectorFromPoint(adjacentPoint, candidates[2]), + vectorFromPoint(candidates[3], candidates[2]), + ) < 0 + : vectorCross( + vectorFromPoint(adjacentPoint, candidates[0]), + vectorFromPoint(candidates[1], candidates[0]), + ) < 0), + vectorCross( + vectorFromPoint(adjacentPoint, candidates[2]), + vectorFromPoint(candidates[3], candidates[2]), + ) > 0 && // BOTTOM + (focus > 0 + ? vectorCross( + vectorFromPoint(adjacentPoint, candidates[3]), + vectorFromPoint(candidates[0], candidates[3]), + ) < 0 + : vectorCross( + vectorFromPoint(adjacentPoint, candidates[1]), + vectorFromPoint(candidates[2], candidates[1]), + ) < 0), + vectorCross( + vectorFromPoint(adjacentPoint, candidates[3]), + vectorFromPoint(candidates[0], candidates[3]), + ) > 0 && // LEFT + (focus > 0 + ? vectorCross( + vectorFromPoint(adjacentPoint, candidates[0]), + vectorFromPoint(candidates[1], candidates[0]), + ) < 0 + : vectorCross( + vectorFromPoint(adjacentPoint, candidates[2]), + vectorFromPoint(candidates[3], candidates[2]), + ) < 0), ]; + + const focusPoint = selected[0] + ? focus > 0 + ? candidates[1] + : candidates[0] + : selected[1] + ? focus > 0 + ? candidates[2] + : candidates[1] + : selected[2] + ? focus > 0 + ? candidates[3] + : candidates[2] + : focus > 0 + ? candidates[0] + : candidates[3]; + + return focusPoint; }; -const getCorners = ( - element: - | ExcalidrawRectangleElement - | ExcalidrawImageElement - | ExcalidrawDiamondElement - | ExcalidrawTextElement - | ExcalidrawIframeLikeElement - | ExcalidrawFrameLikeElement, - scale: number = 1, -): GA.Point[] => { - const hx = (scale * element.width) / 2; - const hy = (scale * element.height) / 2; - switch (element.type) { - case "rectangle": - case "image": - case "text": - case "iframe": - case "embeddable": - case "frame": - case "magicframe": - return [ - GA.point(hx, hy), - GA.point(hx, -hy), - GA.point(-hx, -hy), - GA.point(-hx, hy), - ]; - case "diamond": - return [ - GA.point(0, hy), - GA.point(hx, 0), - GA.point(0, -hy), - GA.point(-hx, 0), - ]; - } -}; - -// Returns intersection of `line` with `segment`, with `segment` moved by -// `gap` in its polar direction. -// If intersection coincides with second segment point returns empty array. -const intersectSegment = ( - line: GA.Line, - segment: [GA.Point, GA.Point], -): GA.Point[] => { - const [a, b] = segment; - const aDist = GAPoint.distanceToLine(a, line); - const bDist = GAPoint.distanceToLine(b, line); - if (aDist * bDist >= 0) { - // The intersection is outside segment `(a, b)` - return []; - } - return [GAPoint.intersect(line, GALine.through(a, b))]; -}; - -const offsetSegment = ( - segment: [GA.Point, GA.Point], - distance: number, -): [GA.Point, GA.Point] => { - const [a, b] = segment; - const offset = GATransform.translationOrthogonal( - GADirection.fromTo(a, b), - distance, - ); - return [GATransform.apply(offset, a), GATransform.apply(offset, b)]; -}; - -const getEllipseIntersections = ( - element: ExcalidrawEllipseElement, - gap: number, - line: GA.Line, -): GA.Point[] => { - const a = element.width / 2 + gap; - const b = element.height / 2 + gap; - const m = line[2]; - const n = line[3]; - const c = line[1]; - const squares = a * a * m * m + b * b * n * n; - const discr = squares - c * c; - if (squares === 0 || discr <= 0) { - return []; - } - const discrRoot = Math.sqrt(discr); - const xn = -a * a * m * c; - const yn = -b * b * n * c; - return [ - GA.point( - (xn + a * b * n * discrRoot) / squares, - (yn - a * b * m * discrRoot) / squares, - ), - GA.point( - (xn - a * b * n * discrRoot) / squares, - (yn + a * b * m * discrRoot) / squares, - ), - ]; -}; - -const getCircleIntersections = ( - center: GA.Point, - radius: number, - line: GA.Line, -): GA.Point[] => { - if (radius === 0) { - return GAPoint.distanceToLine(line, center) === 0 ? [center] : []; - } - const m = line[2]; - const n = line[3]; - const c = line[1]; - const [a, b] = GAPoint.toTuple(center); - const r = radius; - const squares = m * m + n * n; - const discr = r * r * squares - (m * a + n * b + c) ** 2; - if (squares === 0 || discr <= 0) { - return []; - } - const discrRoot = Math.sqrt(discr); - const xn = a * n * n - b * m * n - m * c; - const yn = b * m * m - a * m * n - n * c; - - return [ - GA.point((xn + n * discrRoot) / squares, (yn - m * discrRoot) / squares), - GA.point((xn - n * discrRoot) / squares, (yn + m * discrRoot) / squares), - ]; -}; - -// The focus point is the tangent point of the "focus image" of the -// `element`, where the tangent goes through `point`. -const findFocusPointForEllipse = ( - ellipse: ExcalidrawEllipseElement, - // Between -1 and 1 (not 0) the relative size of the "focus image" of - // the element on which the focus point lies - relativeDistance: number, - // The point for which we're trying to find the focus point, relative - // to the ellipse center. - point: GA.Point, -): GA.Point => { - const relativeDistanceAbs = Math.abs(relativeDistance); - const a = (ellipse.width * relativeDistanceAbs) / 2; - const b = (ellipse.height * relativeDistanceAbs) / 2; - - const orientation = Math.sign(relativeDistance); - const [px, pyo] = GAPoint.toTuple(point); - - // The calculation below can't handle py = 0 - const py = pyo === 0 ? 0.0001 : pyo; - - const squares = px ** 2 * b ** 2 + py ** 2 * a ** 2; - // Tangent mx + ny + 1 = 0 - const m = - (-px * b ** 2 + - orientation * py * Math.sqrt(Math.max(0, squares - a ** 2 * b ** 2))) / - squares; - - let n = (-m * px - 1) / py; - - if (n === 0) { - // if zero {-0, 0}, fall back to a same-sign value in the similar range - n = (Object.is(n, -0) ? -1 : 1) * 0.01; - } - - const x = -(a ** 2 * m) / (n ** 2 * b ** 2 + m ** 2 * a ** 2); - return GA.point(x, (-m * x - 1) / n); -}; - -const findFocusPointForRectangulars = ( - element: - | ExcalidrawRectangleElement - | ExcalidrawImageElement - | ExcalidrawDiamondElement - | ExcalidrawTextElement - | ExcalidrawIframeLikeElement - | ExcalidrawFrameLikeElement, - // Between -1 and 1 for how far away should the focus point be relative - // to the size of the element. Sign determines orientation. - relativeDistance: number, - // The point for which we're trying to find the focus point, relative - // to the element center. - point: GA.Point, -): GA.Point => { - const relativeDistanceAbs = Math.abs(relativeDistance); - const orientation = Math.sign(relativeDistance); - const corners = getCorners(element, relativeDistanceAbs); - - let maxDistance = 0; - let tangentPoint: null | GA.Point = null; - corners.forEach((corner) => { - const distance = orientation * GALine.through(point, corner)[1]; - if (distance > maxDistance) { - maxDistance = distance; - tangentPoint = corner; - } - }); - return tangentPoint!; -}; export const bindingProperties: Set = new Set([ "boundElements", "frameId", diff --git a/packages/excalidraw/element/collision.ts b/packages/excalidraw/element/collision.ts index a1593d2f6..bbd89cfa5 100644 --- a/packages/excalidraw/element/collision.ts +++ b/packages/excalidraw/element/collision.ts @@ -1,7 +1,10 @@ import type { ElementsMap, + ExcalidrawDiamondElement, ExcalidrawElement, + ExcalidrawEllipseElement, ExcalidrawRectangleElement, + ExcalidrawRectanguloidElement, } from "./types"; import { getElementBounds } from "./bounds"; import type { FrameNameBounds } from "../types"; @@ -16,8 +19,28 @@ import { isTextElement, } from "./typeChecks"; import { getBoundTextShape, isPathALoop } from "../shapes"; -import type { GlobalPoint, LocalPoint, Polygon } from "../../math"; -import { isPointWithinBounds, pointFrom } from "../../math"; +import type { + GlobalPoint, + LineSegment, + LocalPoint, + Polygon, + Radians, +} from "../../math"; +import { + curveIntersectLineSegment, + isPointWithinBounds, + line, + lineSegment, + lineSegmentIntersectionPoints, + pointFrom, + pointRotateRads, + pointsEqual, +} from "../../math"; +import { ellipse, ellipseLineIntersectionPoints } from "../../math/ellipse"; +import { + deconstructDiamondElement, + deconstructRectanguloidElement, +} from "./utils"; export const shouldTestInside = (element: ExcalidrawElement) => { if (element.type === "arrow") { @@ -121,3 +144,166 @@ export const hitElementBoundText = ( ): boolean => { return !!textShape && isPointInShape(pointFrom(x, y), textShape); }; + +/** + * Intersect a line with an element for binding test + * + * @param element + * @param line + * @param offset + * @returns + */ +export const intersectElementWithLineSegment = ( + element: ExcalidrawElement, + line: LineSegment, + offset: number = 0, +): GlobalPoint[] => { + switch (element.type) { + case "rectangle": + case "image": + case "text": + case "iframe": + case "embeddable": + case "frame": + case "magicframe": + return intersectRectanguloidWithLineSegment(element, line, offset); + case "diamond": + return intersectDiamondWithLineSegment(element, line, offset); + case "ellipse": + return intersectEllipseWithLineSegment(element, line, offset); + default: + throw new Error(`Unimplemented element type '${element.type}'`); + } +}; + +const intersectRectanguloidWithLineSegment = ( + element: ExcalidrawRectanguloidElement, + l: LineSegment, + offset: number = 0, +): GlobalPoint[] => { + const center = pointFrom( + element.x + element.width / 2, + element.y + element.height / 2, + ); + // To emulate a rotated rectangle we rotate the point in the inverse angle + // instead. It's all the same distance-wise. + const rotatedA = pointRotateRads( + l[0], + center, + -element.angle as Radians, + ); + const rotatedB = pointRotateRads( + l[1], + center, + -element.angle as Radians, + ); + + // Get the element's building components we can test against + const [sides, corners] = deconstructRectanguloidElement(element, offset); + + return ( + [ + // Test intersection against the sides, keep only the valid + // intersection points and rotate them back to scene space + ...sides + .map((s) => + lineSegmentIntersectionPoints( + lineSegment(rotatedA, rotatedB), + s, + ), + ) + .filter((x) => x != null) + .map((j) => pointRotateRads(j!, center, element.angle)), + // Test intersection against the corners which are cubic bezier curves, + // keep only the valid intersection points and rotate them back to scene + // space + ...corners + .flatMap((t) => + curveIntersectLineSegment(t, lineSegment(rotatedA, rotatedB)), + ) + .filter((i) => i != null) + .map((j) => pointRotateRads(j, center, element.angle)), + ] + // Remove duplicates + .filter( + (p, idx, points) => points.findIndex((d) => pointsEqual(p, d)) === idx, + ) + ); +}; + +/** + * + * @param element + * @param a + * @param b + * @returns + */ +const intersectDiamondWithLineSegment = ( + element: ExcalidrawDiamondElement, + l: LineSegment, + offset: number = 0, +): GlobalPoint[] => { + const center = pointFrom( + element.x + element.width / 2, + element.y + element.height / 2, + ); + + // Rotate the point to the inverse direction to simulate the rotated diamond + // points. It's all the same distance-wise. + const rotatedA = pointRotateRads(l[0], center, -element.angle as Radians); + const rotatedB = pointRotateRads(l[1], center, -element.angle as Radians); + + const [sides, curves] = deconstructDiamondElement(element, offset); + + return ( + [ + ...sides + .map((s) => + lineSegmentIntersectionPoints( + lineSegment(rotatedA, rotatedB), + s, + ), + ) + .filter((p): p is GlobalPoint => p != null) + // Rotate back intersection points + .map((p) => pointRotateRads(p!, center, element.angle)), + ...curves + .flatMap((p) => + curveIntersectLineSegment(p, lineSegment(rotatedA, rotatedB)), + ) + .filter((p) => p != null) + // Rotate back intersection points + .map((p) => pointRotateRads(p, center, element.angle)), + ] + // Remove duplicates + .filter( + (p, idx, points) => points.findIndex((d) => pointsEqual(p, d)) === idx, + ) + ); +}; + +/** + * + * @param element + * @param a + * @param b + * @returns + */ +const intersectEllipseWithLineSegment = ( + element: ExcalidrawEllipseElement, + l: LineSegment, + offset: number = 0, +): GlobalPoint[] => { + const center = pointFrom( + element.x + element.width / 2, + element.y + element.height / 2, + ); + + const rotatedA = pointRotateRads(l[0], center, -element.angle as Radians); + const rotatedB = pointRotateRads(l[1], center, -element.angle as Radians); + + return ellipseLineIntersectionPoints( + ellipse(center, element.width / 2 + offset, element.height / 2 + offset), + line(rotatedA, rotatedB), + ).map((p) => pointRotateRads(p, center, element.angle)); +}; diff --git a/packages/excalidraw/element/distance.ts b/packages/excalidraw/element/distance.ts new file mode 100644 index 000000000..d23bf7ee8 --- /dev/null +++ b/packages/excalidraw/element/distance.ts @@ -0,0 +1,123 @@ +import type { GlobalPoint, Radians } from "../../math"; +import { + curvePointDistance, + distanceToLineSegment, + pointFrom, + pointRotateRads, +} from "../../math"; +import { ellipse, ellipseDistanceFromPoint } from "../../math/ellipse"; +import type { + ExcalidrawBindableElement, + ExcalidrawDiamondElement, + ExcalidrawEllipseElement, + ExcalidrawRectanguloidElement, +} from "./types"; +import { + deconstructDiamondElement, + deconstructRectanguloidElement, +} from "./utils"; + +export const distanceToBindableElement = ( + element: ExcalidrawBindableElement, + p: GlobalPoint, +): number => { + switch (element.type) { + case "rectangle": + case "image": + case "text": + case "iframe": + case "embeddable": + case "frame": + case "magicframe": + return distanceToRectanguloidElement(element, p); + case "diamond": + return distanceToDiamondElement(element, p); + case "ellipse": + return distanceToEllipseElement(element, p); + } +}; + +/** + * Returns the distance of a point and the provided rectangular-shaped element, + * accounting for roundness and rotation + * + * @param element The rectanguloid element + * @param p The point to consider + * @returns The eucledian distance to the outline of the rectanguloid element + */ +const distanceToRectanguloidElement = ( + element: ExcalidrawRectanguloidElement, + p: GlobalPoint, +) => { + const center = pointFrom( + element.x + element.width / 2, + element.y + element.height / 2, + ); + // To emulate a rotated rectangle we rotate the point in the inverse angle + // instead. It's all the same distance-wise. + const rotatedPoint = pointRotateRads(p, center, -element.angle as Radians); + + // Get the element's building components we can test against + const [sides, corners] = deconstructRectanguloidElement(element); + + return Math.min( + ...sides.map((s) => distanceToLineSegment(rotatedPoint, s)), + ...corners + .map((a) => curvePointDistance(a, rotatedPoint)) + .filter((d): d is number => d !== null), + ); +}; + +/** + * Returns the distance of a point and the provided diamond element, accounting + * for roundness and rotation + * + * @param element The diamond element + * @param p The point to consider + * @returns The eucledian distance to the outline of the diamond + */ +const distanceToDiamondElement = ( + element: ExcalidrawDiamondElement, + p: GlobalPoint, +): number => { + const center = pointFrom( + element.x + element.width / 2, + element.y + element.height / 2, + ); + + // Rotate the point to the inverse direction to simulate the rotated diamond + // points. It's all the same distance-wise. + const rotatedPoint = pointRotateRads(p, center, -element.angle as Radians); + + const [sides, curves] = deconstructDiamondElement(element); + + return Math.min( + ...sides.map((s) => distanceToLineSegment(rotatedPoint, s)), + ...curves + .map((a) => curvePointDistance(a, rotatedPoint)) + .filter((d): d is number => d !== null), + ); +}; + +/** + * Returns the distance of a point and the provided ellipse element, accounting + * for roundness and rotation + * + * @param element The ellipse element + * @param p The point to consider + * @returns The eucledian distance to the outline of the ellipse + */ +const distanceToEllipseElement = ( + element: ExcalidrawEllipseElement, + p: GlobalPoint, +): number => { + const center = pointFrom( + element.x + element.width / 2, + element.y + element.height / 2, + ); + return ellipseDistanceFromPoint( + // Instead of rotating the ellipse, rotate the point to the inverse angle + pointRotateRads(p, center, -element.angle as Radians), + ellipse(center, element.width / 2, element.height / 2), + ); +}; diff --git a/packages/excalidraw/element/elbowArrow.test.tsx b/packages/excalidraw/element/elbowArrow.test.tsx index fd5682d28..2dd82e7c9 100644 --- a/packages/excalidraw/element/elbowArrow.test.tsx +++ b/packages/excalidraw/element/elbowArrow.test.tsx @@ -18,6 +18,7 @@ import type { import { ARROW_TYPE } from "../constants"; import type { LocalPoint } from "../../math"; import { pointFrom } from "../../math"; +import "../../utils/test-utils"; const { h } = window; diff --git a/packages/excalidraw/element/elbowArrow.ts b/packages/excalidraw/element/elbowArrow.ts index d3154cae9..b990fb93b 100644 --- a/packages/excalidraw/element/elbowArrow.ts +++ b/packages/excalidraw/element/elbowArrow.ts @@ -19,8 +19,6 @@ import { invariant, isAnyTrue, toBrandedType, tupleToCoors } from "../utils"; import type { AppState } from "../types"; import { bindPointToSnapToElementOutline, - distanceToBindableElement, - avoidRectangularCorner, FIXED_BINDING_DISTANCE, getHeadingForElbowArrowSnap, getGlobalFixedPointForBindableElement, @@ -42,7 +40,7 @@ import { headingForPoint, } from "./heading"; import { type ElementUpdate } from "./mutateElement"; -import { isBindableElement, isRectanguloidElement } from "./typeChecks"; +import { isBindableElement } from "./typeChecks"; import { type ExcalidrawElbowArrowElement, type NonDeletedSceneElementsMap, @@ -55,6 +53,7 @@ import type { FixedPointBinding, FixedSegment, } from "./types"; +import { distanceToBindableElement } from "./distance"; type GridAddress = [number, number] & { _brand: "gridaddress" }; @@ -1177,19 +1176,27 @@ const getElbowArrowData = ( ) : [startElement, endElement]; const startGlobalPoint = getGlobalPoint( + { + ...arrow, + elbowed: true, + points: nextPoints, + } as ExcalidrawElbowArrowElement, + "start", arrow.startBinding?.fixedPoint, origStartGlobalPoint, - origEndGlobalPoint, - elementsMap, startElement, hoveredStartElement, options?.isDragging, ); const endGlobalPoint = getGlobalPoint( + { + ...arrow, + elbowed: true, + points: nextPoints, + } as ExcalidrawElbowArrowElement, + "end", arrow.endBinding?.fixedPoint, origEndGlobalPoint, - origStartGlobalPoint, - elementsMap, endElement, hoveredEndElement, options?.isDragging, @@ -2133,21 +2140,20 @@ const neighborIndexToHeading = (idx: number): Heading => { }; const getGlobalPoint = ( + arrow: ExcalidrawElbowArrowElement, + startOrEnd: "start" | "end", fixedPointRatio: [number, number] | undefined | null, initialPoint: GlobalPoint, - otherPoint: GlobalPoint, - elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, boundElement?: ExcalidrawBindableElement | null, hoveredElement?: ExcalidrawBindableElement | null, isDragging?: boolean, ): GlobalPoint => { if (isDragging) { if (hoveredElement) { - const snapPoint = getSnapPoint( - initialPoint, - otherPoint, + const snapPoint = bindPointToSnapToElementOutline( + arrow, hoveredElement, - elementsMap, + startOrEnd, ); return snapToMid(hoveredElement, snapPoint); @@ -2164,29 +2170,16 @@ 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) + ? bindPointToSnapToElementOutline(arrow, boundElement, startOrEnd) : fixedGlobalPoint; } return initialPoint; }; -const getSnapPoint = ( - p: GlobalPoint, - otherPoint: GlobalPoint, - element: ExcalidrawBindableElement, - elementsMap: ElementsMap, -) => - bindPointToSnapToElementOutline( - isRectanguloidElement(element) ? avoidRectangularCorner(element, p) : p, - otherPoint, - element, - elementsMap, - ); - const getBindPointHeading = ( p: GlobalPoint, otherPoint: GlobalPoint, @@ -2201,9 +2194,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/element/utils.ts b/packages/excalidraw/element/utils.ts new file mode 100644 index 000000000..68717ac8c --- /dev/null +++ b/packages/excalidraw/element/utils.ts @@ -0,0 +1,355 @@ +import { getDiamondPoints } from "."; +import type { Curve, LineSegment } from "../../math"; +import { + curve, + lineSegment, + pointFrom, + pointFromVector, + rectangle, + vectorFromPoint, + vectorNormalize, + vectorScale, + type GlobalPoint, +} from "../../math"; +import { getCornerRadius } from "../shapes"; +import type { + ExcalidrawDiamondElement, + ExcalidrawRectanguloidElement, +} from "./types"; + +/** + * Get the building components of a rectanguloid element in the form of + * line segments and curves. + * + * @param element Target rectanguloid element + * @param offset Optional offset to expand the rectanguloid shape + * @returns Tuple of line segments (0) and curves (1) + */ +export function deconstructRectanguloidElement( + element: ExcalidrawRectanguloidElement, + offset: number = 0, +): [LineSegment[], Curve[]] { + const roundness = getCornerRadius( + Math.min(element.width, element.height), + element, + ); + + if (roundness <= 0) { + const r = rectangle( + pointFrom(element.x - offset, element.y - offset), + pointFrom( + element.x + element.width + offset, + element.y + element.height + offset, + ), + ); + + const top = lineSegment( + pointFrom(r[0][0] + roundness, r[0][1]), + pointFrom(r[1][0] - roundness, r[0][1]), + ); + const right = lineSegment( + pointFrom(r[1][0], r[0][1] + roundness), + pointFrom(r[1][0], r[1][1] - roundness), + ); + const bottom = lineSegment( + pointFrom(r[0][0] + roundness, r[1][1]), + pointFrom(r[1][0] - roundness, r[1][1]), + ); + const left = lineSegment( + pointFrom(r[0][0], r[1][1] - roundness), + pointFrom(r[0][0], r[0][1] + roundness), + ); + const sides = [top, right, bottom, left]; + + return [sides, []]; + } + + const center = pointFrom( + element.x + element.width / 2, + element.y + element.height / 2, + ); + + const r = rectangle( + pointFrom(element.x, element.y), + pointFrom(element.x + element.width, element.y + element.height), + ); + + const top = lineSegment( + pointFrom(r[0][0] + roundness, r[0][1]), + pointFrom(r[1][0] - roundness, r[0][1]), + ); + const right = lineSegment( + pointFrom(r[1][0], r[0][1] + roundness), + pointFrom(r[1][0], r[1][1] - roundness), + ); + const bottom = lineSegment( + pointFrom(r[0][0] + roundness, r[1][1]), + pointFrom(r[1][0] - roundness, r[1][1]), + ); + const left = lineSegment( + pointFrom(r[0][0], r[1][1] - roundness), + pointFrom(r[0][0], r[0][1] + roundness), + ); + + const offsets = [ + vectorScale( + vectorNormalize( + vectorFromPoint(pointFrom(r[0][0] - offset, r[0][1] - offset), center), + ), + offset, + ), // TOP LEFT + vectorScale( + vectorNormalize( + vectorFromPoint(pointFrom(r[1][0] + offset, r[0][1] - offset), center), + ), + offset, + ), //TOP RIGHT + vectorScale( + vectorNormalize( + vectorFromPoint(pointFrom(r[1][0] + offset, r[1][1] + offset), center), + ), + offset, + ), // BOTTOM RIGHT + vectorScale( + vectorNormalize( + vectorFromPoint(pointFrom(r[0][0] - offset, r[1][1] + offset), center), + ), + offset, + ), // BOTTOM LEFT + ]; + + const corners = [ + curve( + pointFromVector(offsets[0], left[1]), + pointFromVector( + offsets[0], + pointFrom( + left[1][0] + (2 / 3) * (r[0][0] - left[1][0]), + left[1][1] + (2 / 3) * (r[0][1] - left[1][1]), + ), + ), + pointFromVector( + offsets[0], + pointFrom( + top[0][0] + (2 / 3) * (r[0][0] - top[0][0]), + top[0][1] + (2 / 3) * (r[0][1] - top[0][1]), + ), + ), + pointFromVector(offsets[0], top[0]), + ), // TOP LEFT + curve( + pointFromVector(offsets[1], top[1]), + pointFromVector( + offsets[1], + pointFrom( + top[1][0] + (2 / 3) * (r[1][0] - top[1][0]), + top[1][1] + (2 / 3) * (r[0][1] - top[1][1]), + ), + ), + pointFromVector( + offsets[1], + pointFrom( + right[0][0] + (2 / 3) * (r[1][0] - right[0][0]), + right[0][1] + (2 / 3) * (r[0][1] - right[0][1]), + ), + ), + pointFromVector(offsets[1], right[0]), + ), // TOP RIGHT + curve( + pointFromVector(offsets[2], right[1]), + pointFromVector( + offsets[2], + pointFrom( + right[1][0] + (2 / 3) * (r[1][0] - right[1][0]), + right[1][1] + (2 / 3) * (r[1][1] - right[1][1]), + ), + ), + pointFromVector( + offsets[2], + pointFrom( + bottom[1][0] + (2 / 3) * (r[1][0] - bottom[1][0]), + bottom[1][1] + (2 / 3) * (r[1][1] - bottom[1][1]), + ), + ), + pointFromVector(offsets[2], bottom[1]), + ), // BOTTOM RIGHT + curve( + pointFromVector(offsets[3], bottom[0]), + pointFromVector( + offsets[3], + pointFrom( + bottom[0][0] + (2 / 3) * (r[0][0] - bottom[0][0]), + bottom[0][1] + (2 / 3) * (r[1][1] - bottom[0][1]), + ), + ), + pointFromVector( + offsets[3], + pointFrom( + left[0][0] + (2 / 3) * (r[0][0] - left[0][0]), + left[0][1] + (2 / 3) * (r[1][1] - left[0][1]), + ), + ), + pointFromVector(offsets[3], left[0]), + ), // BOTTOM LEFT + ]; + + const sides = [ + lineSegment(corners[0][3], corners[1][0]), + lineSegment(corners[1][3], corners[2][0]), + lineSegment(corners[2][3], corners[3][0]), + lineSegment(corners[3][3], corners[0][0]), + ]; + + return [sides, corners]; +} + +/** + * Get the building components of a diamond element in the form of + * line segments and curves as a tuple, in this order. + * + * @param element The element to deconstruct + * @param offset An optional offset + * @returns Tuple of line segments (0) and curves (1) + */ +export function deconstructDiamondElement( + element: ExcalidrawDiamondElement, + offset: number = 0, +): [LineSegment[], Curve[]] { + const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] = + getDiamondPoints(element); + const verticalRadius = getCornerRadius(Math.abs(topX - leftX), element); + const horizontalRadius = getCornerRadius(Math.abs(rightY - topY), element); + + if (element.roundness?.type == null) { + const [top, right, bottom, left]: GlobalPoint[] = [ + pointFrom(element.x + topX, element.y + topY - offset), + pointFrom(element.x + rightX + offset, element.y + rightY), + pointFrom(element.x + bottomX, element.y + bottomY + offset), + pointFrom(element.x + leftX - offset, element.y + leftY), + ]; + + // Create the line segment parts of the diamond + // NOTE: Horizontal and vertical seems to be flipped here + const topRight = lineSegment( + pointFrom(top[0] + verticalRadius, top[1] + horizontalRadius), + pointFrom(right[0] - verticalRadius, right[1] - horizontalRadius), + ); + const bottomRight = lineSegment( + pointFrom(right[0] - verticalRadius, right[1] + horizontalRadius), + pointFrom(bottom[0] + verticalRadius, bottom[1] - horizontalRadius), + ); + const bottomLeft = lineSegment( + pointFrom(bottom[0] - verticalRadius, bottom[1] - horizontalRadius), + pointFrom(left[0] + verticalRadius, left[1] + horizontalRadius), + ); + const topLeft = lineSegment( + pointFrom(left[0] + verticalRadius, left[1] - horizontalRadius), + pointFrom(top[0] - verticalRadius, top[1] + horizontalRadius), + ); + + return [[topRight, bottomRight, bottomLeft, topLeft], []]; + } + + const center = pointFrom( + element.x + element.width / 2, + element.y + element.height / 2, + ); + + const [top, right, bottom, left]: GlobalPoint[] = [ + pointFrom(element.x + topX, element.y + topY), + pointFrom(element.x + rightX, element.y + rightY), + pointFrom(element.x + bottomX, element.y + bottomY), + pointFrom(element.x + leftX, element.y + leftY), + ]; + + const offsets = [ + vectorScale(vectorNormalize(vectorFromPoint(right, center)), offset), // RIGHT + vectorScale(vectorNormalize(vectorFromPoint(bottom, center)), offset), // BOTTOM + vectorScale(vectorNormalize(vectorFromPoint(left, center)), offset), // LEFT + vectorScale(vectorNormalize(vectorFromPoint(top, center)), offset), // TOP + ]; + + const corners = [ + curve( + pointFromVector( + offsets[0], + pointFrom( + right[0] - verticalRadius, + right[1] - horizontalRadius, + ), + ), + pointFromVector(offsets[0], right), + pointFromVector(offsets[0], right), + pointFromVector( + offsets[0], + pointFrom( + right[0] - verticalRadius, + right[1] + horizontalRadius, + ), + ), + ), // RIGHT + curve( + pointFromVector( + offsets[1], + pointFrom( + bottom[0] + verticalRadius, + bottom[1] - horizontalRadius, + ), + ), + pointFromVector(offsets[1], bottom), + pointFromVector(offsets[1], bottom), + pointFromVector( + offsets[1], + pointFrom( + bottom[0] - verticalRadius, + bottom[1] - horizontalRadius, + ), + ), + ), // BOTTOM + curve( + pointFromVector( + offsets[2], + pointFrom( + left[0] + verticalRadius, + left[1] + horizontalRadius, + ), + ), + pointFromVector(offsets[2], left), + pointFromVector(offsets[2], left), + pointFromVector( + offsets[2], + pointFrom( + left[0] + verticalRadius, + left[1] - horizontalRadius, + ), + ), + ), // LEFT + curve( + pointFromVector( + offsets[3], + pointFrom( + top[0] - verticalRadius, + top[1] + horizontalRadius, + ), + ), + pointFromVector(offsets[3], top), + pointFromVector(offsets[3], top), + pointFromVector( + offsets[3], + pointFrom( + top[0] + verticalRadius, + top[1] + horizontalRadius, + ), + ), + ), // TOP + ]; + + const sides = [ + lineSegment(corners[0][3], corners[1][0]), + lineSegment(corners[1][3], corners[2][0]), + lineSegment(corners[2][3], corners[3][0]), + lineSegment(corners[3][3], corners[0][0]), + ]; + + return [sides, corners]; +} diff --git a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap index 9e353c06f..d8cf4ec2d 100644 --- a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap @@ -197,7 +197,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": 99, + "height": "102.35417", "id": "id172", "index": "a2", "isDeleted": false, @@ -211,8 +211,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl 0, ], [ - "98.20800", - 99, + "101.77517", + "102.35417", ], ], "roughness": 1, @@ -227,8 +227,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "type": "arrow", "updated": 1, "version": 40, - "width": "98.20800", - "x": 1, + "width": "101.77517", + "x": "0.70711", "y": 0, } `; @@ -294,24 +294,22 @@ History { "deleted": { "endBinding": { "elementId": "id171", - "fixedPoint": null, "focus": "0.00990", "gap": 1, }, - "height": "0.98017", + "height": "0.98597", "points": [ [ 0, 0, ], [ - 98, - "-0.98017", + "98.58579", + "-0.98597", ], ], "startBinding": { "elementId": "id170", - "fixedPoint": null, "focus": "0.02970", "gap": 1, }, @@ -319,24 +317,22 @@ History { "inserted": { "endBinding": { "elementId": "id171", - "fixedPoint": null, "focus": "-0.02000", "gap": 1, }, - "height": "0.00169", + "height": "0.00119", "points": [ [ 0, 0, ], [ - 98, - "0.00169", + "98.58579", + "0.00119", ], ], "startBinding": { "elementId": "id170", - "fixedPoint": null, "focus": "0.02000", "gap": 1, }, @@ -393,15 +389,15 @@ History { "focus": 0, "gap": 1, }, - "height": 99, + "height": "102.35417", "points": [ [ 0, 0, ], [ - "98.20800", - 99, + "101.77517", + "102.35417", ], ], "startBinding": null, @@ -410,28 +406,26 @@ History { "inserted": { "endBinding": { "elementId": "id171", - "fixedPoint": null, "focus": "0.00990", "gap": 1, }, - "height": "0.98161", + "height": "0.98700", "points": [ [ 0, 0, ], [ - 98, - "-0.98161", + "98.58579", + "-0.98700", ], ], "startBinding": { "elementId": "id170", - "fixedPoint": null, "focus": "0.02970", "gap": 1, }, - "y": "0.99245", + "y": "0.99465", }, }, "id175" => Delta { @@ -824,7 +818,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "type": "arrow", "updated": 1, "version": 30, - "width": 0, + "width": 50, "x": 200, "y": 0, } @@ -858,7 +852,7 @@ History { 0, ], [ - 0, + 50, 0, ], ], @@ -934,8 +928,7 @@ History { "inserted": { "endBinding": { "elementId": "id166", - "fixedPoint": null, - "focus": 0, + "focus": -0, "gap": 1, }, "points": [ @@ -944,13 +937,12 @@ History { 0, ], [ - 0, + 50, 0, ], ], "startBinding": { "elementId": "id165", - "fixedPoint": null, "focus": 0, "gap": 1, }, @@ -1246,7 +1238,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "2.61991", + "height": "2.52823", "id": "id178", "index": "Zz", "isDeleted": false, @@ -1260,8 +1252,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl 0, ], [ - "98.00000", - "-2.61991", + "98.58579", + "-2.52823", ], ], "roughness": 1, @@ -1284,9 +1276,9 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "type": "arrow", "updated": 1, "version": 11, - "width": "98.00000", - "x": "1.00000", - "y": "3.98333", + "width": "98.58579", + "x": "0.70711", + "y": "3.82861", } `; @@ -1617,7 +1609,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "2.61991", + "height": "2.52823", "id": "id181", "index": "a0", "isDeleted": false, @@ -1631,8 +1623,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl 0, ], [ - "98.00000", - "-2.61991", + "98.58579", + "-2.52823", ], ], "roughness": 1, @@ -1655,9 +1647,9 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "type": "arrow", "updated": 1, "version": 11, - "width": "98.00000", - "x": "1.00000", - "y": "3.98333", + "width": "98.58579", + "x": "0.70711", + "y": "3.82861", } `; @@ -1775,7 +1767,7 @@ History { "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "22.36242", + "height": "22.07000", "index": "a0", "isDeleted": false, "lastCommittedPoint": null, @@ -1788,8 +1780,8 @@ History { 0, ], [ - "98.00000", - "-22.36242", + "99.27949", + "-22.07000", ], ], "roughness": 1, @@ -1810,9 +1802,9 @@ History { "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", - "width": "98.00000", - "x": 1, - "y": 34, + "width": "99.27949", + "x": "0.01341", + "y": "33.34227", }, "inserted": { "isDeleted": true, @@ -2322,14 +2314,13 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "endArrowhead": "arrow", "endBinding": { "elementId": "id185", - "fixedPoint": null, - "focus": 0, + "focus": -0, "gap": 1, }, "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "408.19672", + "height": "410.63965", "id": "id186", "index": "a2", "isDeleted": false, @@ -2343,8 +2334,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl 0, ], [ - 498, - "-408.19672", + "501.24760", + "-410.63965", ], ], "roughness": 1, @@ -2354,7 +2345,6 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "startArrowhead": null, "startBinding": { "elementId": "id184", - "fixedPoint": null, "focus": 0, "gap": 1, }, @@ -2364,8 +2354,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "type": "arrow", "updated": 1, "version": 10, - "width": 498, - "x": 1, + "width": "501.24760", + "x": "0.70711", "y": 0, } `; @@ -2484,8 +2474,7 @@ History { "endArrowhead": "arrow", "endBinding": { "elementId": "id185", - "fixedPoint": null, - "focus": 0, + "focus": -0, "gap": 1, }, "fillStyle": "solid", @@ -2515,7 +2504,6 @@ History { "startArrowhead": null, "startBinding": { "elementId": "id184", - "fixedPoint": null, "focus": 0, "gap": 1, }, @@ -15122,8 +15110,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "endArrowhead": "arrow", "endBinding": { "elementId": "id58", - "fixedPoint": null, - "focus": 0, + "focus": -0, "gap": 1, }, "fillStyle": "solid", @@ -15143,7 +15130,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding 0, ], [ - "98.00000", + "98.58579", 0, ], ], @@ -15154,7 +15141,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "startArrowhead": null, "startBinding": { "elementId": "id56", - "fixedPoint": null, "focus": 0, "gap": 1, }, @@ -15164,8 +15150,8 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "type": "arrow", "updated": 1, "version": 10, - "width": "98.00000", - "x": 1, + "width": "98.58579", + "x": "0.70711", "y": 0, } `; @@ -15493,8 +15479,7 @@ History { "endArrowhead": "arrow", "endBinding": { "elementId": "id58", - "fixedPoint": null, - "focus": 0, + "focus": -0, "gap": 1, }, "fillStyle": "solid", @@ -15524,7 +15509,6 @@ History { "startArrowhead": null, "startBinding": { "elementId": "id56", - "fixedPoint": null, "focus": 0, "gap": 1, }, @@ -15821,8 +15805,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "endArrowhead": "arrow", "endBinding": { "elementId": "id52", - "fixedPoint": null, - "focus": 0, + "focus": -0, "gap": 1, }, "fillStyle": "solid", @@ -15842,7 +15825,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding 0, ], [ - "98.00000", + "98.58579", 0, ], ], @@ -15853,7 +15836,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "startArrowhead": null, "startBinding": { "elementId": "id50", - "fixedPoint": null, "focus": 0, "gap": 1, }, @@ -15863,8 +15845,8 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "type": "arrow", "updated": 1, "version": 10, - "width": "98.00000", - "x": 1, + "width": "98.58579", + "x": "0.70711", "y": 0, } `; @@ -16116,8 +16098,7 @@ History { "endArrowhead": "arrow", "endBinding": { "elementId": "id52", - "fixedPoint": null, - "focus": 0, + "focus": -0, "gap": 1, }, "fillStyle": "solid", @@ -16147,7 +16128,6 @@ History { "startArrowhead": null, "startBinding": { "elementId": "id50", - "fixedPoint": null, "focus": 0, "gap": 1, }, @@ -16444,8 +16424,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "endArrowhead": "arrow", "endBinding": { "elementId": "id64", - "fixedPoint": null, - "focus": 0, + "focus": -0, "gap": 1, }, "fillStyle": "solid", @@ -16465,7 +16444,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding 0, ], [ - "98.00000", + "98.58579", 0, ], ], @@ -16476,7 +16455,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "startArrowhead": null, "startBinding": { "elementId": "id62", - "fixedPoint": null, "focus": 0, "gap": 1, }, @@ -16486,8 +16464,8 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "type": "arrow", "updated": 1, "version": 10, - "width": "98.00000", - "x": 1, + "width": "98.58579", + "x": "0.70711", "y": 0, } `; @@ -16739,8 +16717,7 @@ History { "endArrowhead": "arrow", "endBinding": { "elementId": "id64", - "fixedPoint": null, - "focus": 0, + "focus": -0, "gap": 1, }, "fillStyle": "solid", @@ -16770,7 +16747,6 @@ History { "startArrowhead": null, "startBinding": { "elementId": "id62", - "fixedPoint": null, "focus": 0, "gap": 1, }, @@ -17065,8 +17041,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "endArrowhead": "arrow", "endBinding": { "elementId": "id70", - "fixedPoint": null, - "focus": 0, + "focus": -0, "gap": 1, }, "fillStyle": "solid", @@ -17086,7 +17061,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding 0, ], [ - "98.00000", + "98.58579", 0, ], ], @@ -17097,7 +17072,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "startArrowhead": null, "startBinding": { "elementId": "id68", - "fixedPoint": null, "focus": 0, "gap": 1, }, @@ -17107,8 +17081,8 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "type": "arrow", "updated": 1, "version": 10, - "width": "98.00000", - "x": 1, + "width": "98.58579", + "x": "0.70711", "y": 0, } `; @@ -17170,7 +17144,6 @@ History { ], "startBinding": { "elementId": "id68", - "fixedPoint": null, "focus": 0, "gap": 1, }, @@ -17431,8 +17404,7 @@ History { "endArrowhead": "arrow", "endBinding": { "elementId": "id70", - "fixedPoint": null, - "focus": 0, + "focus": -0, "gap": 1, }, "fillStyle": "solid", @@ -17462,7 +17434,6 @@ History { "startArrowhead": null, "startBinding": { "elementId": "id68", - "fixedPoint": null, "focus": 0, "gap": 1, }, @@ -17783,8 +17754,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "endArrowhead": "arrow", "endBinding": { "elementId": "id77", - "fixedPoint": null, - "focus": 0, + "focus": -0, "gap": 1, }, "fillStyle": "solid", @@ -17804,7 +17774,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding 0, ], [ - "98.00000", + "98.58579", 0, ], ], @@ -17815,7 +17785,6 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "startArrowhead": null, "startBinding": { "elementId": "id75", - "fixedPoint": null, "focus": 0, "gap": 1, }, @@ -17825,8 +17794,8 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "type": "arrow", "updated": 1, "version": 11, - "width": "98.00000", - "x": 1, + "width": "98.58579", + "x": "0.70711", "y": 0, } `; @@ -17887,8 +17856,7 @@ History { "deleted": { "endBinding": { "elementId": "id77", - "fixedPoint": null, - "focus": 0, + "focus": -0, "gap": 1, }, "points": [ @@ -17903,7 +17871,6 @@ History { ], "startBinding": { "elementId": "id75", - "fixedPoint": null, "focus": 0, "gap": 1, }, @@ -18165,8 +18132,7 @@ History { "endArrowhead": "arrow", "endBinding": { "elementId": "id77", - "fixedPoint": null, - "focus": 0, + "focus": -0, "gap": 1, }, "fillStyle": "solid", @@ -18196,7 +18162,6 @@ History { "startArrowhead": null, "startBinding": { "elementId": "id75", - "fixedPoint": null, "focus": 0, "gap": 1, }, diff --git a/packages/excalidraw/tests/__snapshots__/move.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/move.test.tsx.snap index 2e34ec6b2..eb5f14498 100644 --- a/packages/excalidraw/tests/__snapshots__/move.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/move.test.tsx.snap @@ -190,14 +190,13 @@ exports[`move element > rectangles with binding arrow 7`] = ` "endArrowhead": "arrow", "endBinding": { "elementId": "id1", - "fixedPoint": null, "focus": "-0.46667", "gap": 10, }, "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "81.47368", + "height": "84.41974", "id": "id2", "index": "a2", "isDeleted": false, @@ -211,8 +210,8 @@ exports[`move element > rectangles with binding arrow 7`] = ` 0, ], [ - 81, - "81.47368", + "83.92893", + "84.41974", ], ], "roughness": 1, @@ -223,7 +222,6 @@ exports[`move element > rectangles with binding arrow 7`] = ` "startArrowhead": null, "startBinding": { "elementId": "id0", - "fixedPoint": null, "focus": "-0.60000", "gap": 10, }, @@ -234,7 +232,7 @@ exports[`move element > rectangles with binding arrow 7`] = ` "updated": 1, "version": 11, "versionNonce": 1051383431, - "width": 81, + "width": "83.92893", "x": 110, "y": 50, } diff --git a/packages/excalidraw/tests/binding.test.tsx b/packages/excalidraw/tests/binding.test.tsx index 680cbfa85..dff4d3347 100644 --- a/packages/excalidraw/tests/binding.test.tsx +++ b/packages/excalidraw/tests/binding.test.tsx @@ -64,7 +64,6 @@ describe("element binding", () => { expect(arrow.startBinding).toEqual({ elementId: rect.id, - fixedPoint: null, focus: expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(), }); @@ -77,13 +76,11 @@ describe("element binding", () => { // Both the start and the end points should be bound expect(arrow.startBinding).toEqual({ elementId: rect.id, - fixedPoint: null, focus: expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(), }); expect(arrow.endBinding).toEqual({ elementId: rect.id, - fixedPoint: null, focus: expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(), }); diff --git a/packages/excalidraw/tests/history.test.tsx b/packages/excalidraw/tests/history.test.tsx index 5000940ca..4942fb2b3 100644 --- a/packages/excalidraw/tests/history.test.tsx +++ b/packages/excalidraw/tests/history.test.tsx @@ -9,6 +9,7 @@ import { togglePopover, getCloneByOrigId, } from "./test-utils"; +import "../../utils/test-utils"; import { Excalidraw } from "../index"; import { Keyboard, Pointer, UI } from "./helpers/ui"; import { API } from "./helpers/api"; @@ -1321,13 +1322,11 @@ describe("history", () => { expect(API.getUndoStack().length).toBe(5); expect(arrow.startBinding).toEqual({ elementId: rect1.id, - fixedPoint: null, focus: expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(), }); expect(arrow.endBinding).toEqual({ elementId: rect2.id, - fixedPoint: null, focus: expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(), }); @@ -1346,13 +1345,11 @@ describe("history", () => { expect(API.getRedoStack().length).toBe(1); expect(arrow.startBinding).toEqual({ elementId: rect1.id, - fixedPoint: null, focus: expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(), }); expect(arrow.endBinding).toEqual({ elementId: rect2.id, - fixedPoint: null, focus: expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(), }); @@ -1371,13 +1368,11 @@ describe("history", () => { expect(API.getRedoStack().length).toBe(0); expect(arrow.startBinding).toEqual({ elementId: rect1.id, - fixedPoint: null, focus: expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(), }); expect(arrow.endBinding).toEqual({ elementId: rect2.id, - fixedPoint: null, focus: expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(), }); @@ -1404,13 +1399,11 @@ describe("history", () => { expect(API.getRedoStack().length).toBe(0); expect(arrow.startBinding).toEqual({ elementId: rect1.id, - fixedPoint: null, focus: expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(), }); expect(arrow.endBinding).toEqual({ elementId: rect2.id, - fixedPoint: null, focus: expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(), }); @@ -1429,13 +1422,11 @@ describe("history", () => { expect(API.getRedoStack().length).toBe(1); expect(arrow.startBinding).toEqual({ elementId: rect1.id, - fixedPoint: null, focus: expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(), }); expect(arrow.endBinding).toEqual({ elementId: rect2.id, - fixedPoint: null, focus: expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(), }); @@ -1486,13 +1477,11 @@ describe("history", () => { id: arrow.id, startBinding: expect.objectContaining({ elementId: rect1.id, - fixedPoint: null, focus: expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(), }), endBinding: expect.objectContaining({ elementId: rect2.id, - fixedPoint: null, focus: expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(), }), @@ -1533,13 +1522,11 @@ describe("history", () => { id: arrow.id, startBinding: expect.objectContaining({ elementId: rect1.id, - fixedPoint: null, focus: expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(), }), endBinding: expect.objectContaining({ elementId: rect2.id, - fixedPoint: null, focus: expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(), }), @@ -1614,13 +1601,11 @@ describe("history", () => { id: arrow.id, startBinding: expect.objectContaining({ elementId: rect1.id, - fixedPoint: null, focus: expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(), }), endBinding: expect.objectContaining({ elementId: rect2.id, - fixedPoint: null, focus: expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(), }), @@ -1689,13 +1674,11 @@ describe("history", () => { id: arrow.id, startBinding: expect.objectContaining({ elementId: rect1.id, - fixedPoint: null, focus: expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(), }), endBinding: expect.objectContaining({ elementId: rect2.id, - fixedPoint: null, focus: expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(), }), @@ -4276,13 +4259,11 @@ describe("history", () => { id: arrowId, startBinding: expect.objectContaining({ elementId: rect1.id, - fixedPoint: null, focus: expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(), }), endBinding: expect.objectContaining({ elementId: rect2.id, - fixedPoint: null, focus: expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(), }), @@ -4347,13 +4328,11 @@ describe("history", () => { id: arrowId, startBinding: expect.objectContaining({ elementId: rect1.id, - fixedPoint: null, focus: expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(), }), endBinding: expect.objectContaining({ elementId: rect2.id, - fixedPoint: null, focus: expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(), }), @@ -4414,13 +4393,11 @@ describe("history", () => { id: arrowId, startBinding: expect.objectContaining({ elementId: rect1.id, - fixedPoint: null, focus: expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(), }), endBinding: expect.objectContaining({ elementId: rect2.id, - fixedPoint: null, focus: expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(), }), @@ -4489,14 +4466,12 @@ describe("history", () => { id: arrowId, startBinding: expect.objectContaining({ elementId: rect1.id, - fixedPoint: null, focus: expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(), }), // rebound with previous rectangle endBinding: expect.objectContaining({ elementId: rect2.id, - fixedPoint: null, focus: expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(), }), @@ -4788,14 +4763,12 @@ describe("history", () => { id: arrowId, startBinding: expect.objectContaining({ elementId: rect1.id, - fixedPoint: null, focus: 0, gap: 1, }), endBinding: expect.objectContaining({ elementId: rect2.id, - fixedPoint: null, - focus: 0, + focus: -0, gap: 1, }), isDeleted: true, @@ -4838,13 +4811,11 @@ describe("history", () => { id: arrowId, startBinding: expect.objectContaining({ elementId: rect1.id, - fixedPoint: null, focus: expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(), }), endBinding: expect.objectContaining({ elementId: rect2.id, - fixedPoint: null, focus: expect.toBeNonNaNNumber(), gap: expect.toBeNonNaNNumber(), }), diff --git a/packages/excalidraw/tests/linearElementEditor.test.tsx b/packages/excalidraw/tests/linearElementEditor.test.tsx index ef67329e0..986a63512 100644 --- a/packages/excalidraw/tests/linearElementEditor.test.tsx +++ b/packages/excalidraw/tests/linearElementEditor.test.tsx @@ -1238,7 +1238,7 @@ describe("Test Linear Elements", () => { mouse.downAt(rect.x, rect.y); mouse.moveTo(200, 0); mouse.upAt(200, 0); - expect(arrow.width).toBe(200); + expect(arrow.width).toBeCloseTo(204, 0); expect(rect.x).toBe(200); expect(rect.y).toBe(0); expect(handleBindTextResizeSpy).toHaveBeenCalledWith( diff --git a/packages/excalidraw/tests/move.test.tsx b/packages/excalidraw/tests/move.test.tsx index 5e11fe5b6..17de52247 100644 --- a/packages/excalidraw/tests/move.test.tsx +++ b/packages/excalidraw/tests/move.test.tsx @@ -123,7 +123,7 @@ describe("move element", () => { expect([rectB.x, rectB.y]).toEqual([201, 2]); expect([Math.round(arrow.x), Math.round(arrow.y)]).toEqual([110, 50]); expect([Math.round(arrow.width), Math.round(arrow.height)]).toEqual([ - 81, 81, + 84, 84, ]); h.elements.forEach((element) => expect(element).toMatchSnapshot()); diff --git a/packages/excalidraw/tests/resize.test.tsx b/packages/excalidraw/tests/resize.test.tsx index 1e2e0f4aa..eca7b7e50 100644 --- a/packages/excalidraw/tests/resize.test.tsx +++ b/packages/excalidraw/tests/resize.test.tsx @@ -181,12 +181,12 @@ describe("generic element", () => { UI.resize(rectangle, "e", [40, 0]); - expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(30); + expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(30, 0); UI.resize(rectangle, "w", [50, 0]); expect(arrow.endBinding?.elementId).toEqual(rectangle.id); - expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(80); + expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(80, 0); }); it("resizes with a label", async () => { @@ -501,12 +501,12 @@ describe("arrow element", () => { h.state, )[0] as ExcalidrawElbowArrowElement; - expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.05); + expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1); expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75); UI.resize(rectangle, "se", [-200, -150]); - expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.05); + expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1); expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75); }); @@ -529,13 +529,13 @@ describe("arrow element", () => { h.state, )[0] as ExcalidrawElbowArrowElement; - expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.05); + expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1); expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75); UI.resize([rectangle, arrow], "nw", [300, 350]); - expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(-0.144); - expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.25); + expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(-0.13); + expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.11); }); }); @@ -811,15 +811,16 @@ describe("image element", () => { UI.resize(image, "ne", [40, 0]); - expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(30); + expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo(31, 0); const imageWidth = image.width; const scale = 20 / image.height; UI.resize(image, "nw", [50, 20]); expect(arrow.endBinding?.elementId).toEqual(image.id); - expect(arrow.width + arrow.endBinding!.gap).toBeCloseTo( + expect(Math.floor(arrow.width + arrow.endBinding!.gap)).toBeCloseTo( 30 + imageWidth * scale, + 0, ); }); }); @@ -1024,7 +1025,7 @@ describe("multiple selection", () => { expect(leftBoundArrow.x).toBeCloseTo(-110); expect(leftBoundArrow.y).toBeCloseTo(50); - expect(leftBoundArrow.width).toBeCloseTo(140, 0); + expect(leftBoundArrow.width).toBeCloseTo(143, 0); expect(leftBoundArrow.height).toBeCloseTo(7, 0); expect(leftBoundArrow.angle).toEqual(0); expect(leftBoundArrow.startBinding).toBeNull(); @@ -1046,7 +1047,9 @@ describe("multiple selection", () => { expect(rightBoundArrow.endBinding?.elementId).toBe( rightArrowBinding.elementId, ); - expect(rightBoundArrow.endBinding?.focus).toBe(rightArrowBinding.focus); + expect(rightBoundArrow.endBinding?.focus).toBeCloseTo( + rightArrowBinding.focus!, + ); }); it("resizes with labeled arrows", async () => { diff --git a/packages/excalidraw/tests/rotate.test.tsx b/packages/excalidraw/tests/rotate.test.tsx index a5301cc34..367313ea9 100644 --- a/packages/excalidraw/tests/rotate.test.tsx +++ b/packages/excalidraw/tests/rotate.test.tsx @@ -32,7 +32,7 @@ test("unselected bound arrow updates when rotating its target element", async () expect(arrow.endBinding?.elementId).toEqual(rectangle.id); expect(arrow.x).toBeCloseTo(-80); expect(arrow.y).toBeCloseTo(50); - expect(arrow.width).toBeCloseTo(110.7, 1); + expect(arrow.width).toBeCloseTo(116.7, 1); expect(arrow.height).toBeCloseTo(0); }); @@ -69,8 +69,8 @@ test("unselected bound arrows update when rotating their target elements", async expect(ellipseArrow.x).toEqual(0); expect(ellipseArrow.y).toEqual(0); expect(ellipseArrow.points[0]).toEqual([0, 0]); - expect(ellipseArrow.points[1][0]).toBeCloseTo(48.5, 1); - expect(ellipseArrow.points[1][1]).toBeCloseTo(126.5, 1); + expect(ellipseArrow.points[1][0]).toBeCloseTo(48.98, 1); + expect(ellipseArrow.points[1][1]).toBeCloseTo(125.79, 1); expect(textArrow.endBinding?.elementId).toEqual(text.id); expect(textArrow.x).toEqual(360); diff --git a/packages/excalidraw/tests/test-utils.ts b/packages/excalidraw/tests/test-utils.ts index 5e395c1e8..84936f520 100644 --- a/packages/excalidraw/tests/test-utils.ts +++ b/packages/excalidraw/tests/test-utils.ts @@ -16,7 +16,6 @@ import { STORAGE_KEYS } from "../../../excalidraw-app/app_constants"; import { getSelectedElements } from "../scene/selection"; import type { ExcalidrawElement } from "../element/types"; import { UI } from "./helpers/ui"; -import { diffStringsUnified } from "jest-diff"; import ansi from "ansicolor"; import { ORIG_ID } from "../constants"; import { arrayToMap } from "../utils"; @@ -259,36 +258,6 @@ expect.extend({ pass: false, }; }, - - toCloselyEqualPoints(received, expected, precision) { - if (!Array.isArray(received) || !Array.isArray(expected)) { - throw new Error("expected and received are not point arrays"); - } - - const COMPARE = 1 / Math.pow(10, precision || 2); - const pass = received.every( - (point, idx) => - Math.abs(expected[idx]?.[0] - point[0]) < COMPARE && - Math.abs(expected[idx]?.[1] - point[1]) < COMPARE, - ); - - if (!pass) { - return { - message: () => ` The provided array of points are not close enough. - -${diffStringsUnified( - JSON.stringify(expected, undefined, 2), - JSON.stringify(received, undefined, 2), -)}`, - pass: false, - }; - } - - return { - message: () => `expected ${received} to not be close to ${expected}`, - pass: true, - }; - }, }); /** diff --git a/packages/excalidraw/visualdebug.ts b/packages/excalidraw/visualdebug.ts index 96befa731..49dacc916 100644 --- a/packages/excalidraw/visualdebug.ts +++ b/packages/excalidraw/visualdebug.ts @@ -1,3 +1,4 @@ +import type { Curve } from "../math"; import { isLineSegment, lineSegment, @@ -6,7 +7,7 @@ import { type LocalPoint, } from "../math"; import type { LineSegment } from "../utils"; -import type { BoundingBox, Bounds } from "./element/bounds"; +import type { Bounds } from "./element/bounds"; import { isBounds } from "./element/typeChecks"; // The global data holder to collect the debug operations @@ -16,17 +17,29 @@ declare global { data: DebugElement[][]; currentFrame?: number; }; - debugDrawPoint: typeof debugDrawPoint; - debugDrawLine: typeof debugDrawLine; } } export type DebugElement = { color: string; - data: LineSegment; + data: LineSegment | Curve; permanent: boolean; }; +export const debugDrawCubicBezier = ( + c: Curve, + opts?: { + color?: string; + permanent?: boolean; + }, +) => { + addToCurrentFrame({ + color: opts?.color ?? "purple", + permanent: !!opts?.permanent, + data: c, + }); +}; + export const debugDrawLine = ( segment: LineSegment | LineSegment[], opts?: { @@ -80,41 +93,6 @@ export const debugDrawPoint = ( ); }; -export const debugDrawBoundingBox = ( - box: BoundingBox | BoundingBox[], - opts?: { - color?: string; - permanent?: boolean; - }, -) => { - (Array.isArray(box) ? box : [box]).forEach((bbox) => - debugDrawLine( - [ - lineSegment( - pointFrom(bbox.minX, bbox.minY), - pointFrom(bbox.maxX, bbox.minY), - ), - lineSegment( - pointFrom(bbox.maxX, bbox.minY), - pointFrom(bbox.maxX, bbox.maxY), - ), - lineSegment( - pointFrom(bbox.maxX, bbox.maxY), - pointFrom(bbox.minX, bbox.maxY), - ), - lineSegment( - pointFrom(bbox.minX, bbox.maxY), - pointFrom(bbox.minX, bbox.minY), - ), - ], - { - color: opts?.color ?? "cyan", - permanent: opts?.permanent, - }, - ), - ); -}; - export const debugDrawBounds = ( box: Bounds | Bounds[], opts?: { diff --git a/packages/math/angle.ts b/packages/math/angle.ts index 2dc97a469..8d473cf55 100644 --- a/packages/math/angle.ts +++ b/packages/math/angle.ts @@ -26,7 +26,10 @@ export const normalizeRadians = (angle: Radians): Radians => { export const cartesian2Polar =

([ x, y, -]: P): PolarCoords => [Math.hypot(x, y), Math.atan2(y, x)]; +]: P): PolarCoords => [ + Math.hypot(x, y), + normalizeRadians(Math.atan2(y, x) as Radians), +]; export function degreesToRadians(degrees: Degrees): Radians { return ((degrees * Math.PI) / 180) as Radians; diff --git a/packages/math/arc.test.ts b/packages/math/arc.test.ts deleted file mode 100644 index adf778591..000000000 --- a/packages/math/arc.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { isPointOnSymmetricArc } from "./arc"; -import { pointFrom } from "./point"; - -describe("point on arc", () => { - it("should detect point on simple arc", () => { - expect( - isPointOnSymmetricArc( - { - radius: 1, - startAngle: -Math.PI / 4, - endAngle: Math.PI / 4, - }, - pointFrom(0.92291667, 0.385), - ), - ).toBe(true); - }); - it("should not detect point outside of a simple arc", () => { - expect( - isPointOnSymmetricArc( - { - radius: 1, - startAngle: -Math.PI / 4, - endAngle: Math.PI / 4, - }, - pointFrom(-0.92291667, 0.385), - ), - ).toBe(false); - }); - it("should not detect point with good angle but incorrect radius", () => { - expect( - isPointOnSymmetricArc( - { - radius: 1, - startAngle: -Math.PI / 4, - endAngle: Math.PI / 4, - }, - pointFrom(-0.5, 0.5), - ), - ).toBe(false); - }); -}); diff --git a/packages/math/arc.ts b/packages/math/arc.ts deleted file mode 100644 index c93830dba..000000000 --- a/packages/math/arc.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { cartesian2Polar } from "./angle"; -import type { GlobalPoint, LocalPoint, SymmetricArc } from "./types"; -import { PRECISION } from "./utils"; - -/** - * Determines if a cartesian point lies on a symmetric arc, i.e. an arc which - * is part of a circle contour centered on 0, 0. - */ -export const isPointOnSymmetricArc =

( - { radius: arcRadius, startAngle, endAngle }: SymmetricArc, - point: P, -): boolean => { - const [radius, angle] = cartesian2Polar(point); - - return startAngle < endAngle - ? Math.abs(radius - arcRadius) < PRECISION && - startAngle <= angle && - endAngle >= angle - : startAngle <= angle || endAngle >= angle; -}; diff --git a/packages/math/curve.test.ts b/packages/math/curve.test.ts new file mode 100644 index 000000000..94670d7ab --- /dev/null +++ b/packages/math/curve.test.ts @@ -0,0 +1,101 @@ +import "../utils/test-utils"; +import { + curve, + curveClosestPoint, + curveIntersectLineSegment, + curvePointDistance, +} from "./curve"; +import { pointFrom } from "./point"; +import { lineSegment } from "./segment"; + +describe("Math curve", () => { + describe("line segment intersection", () => { + it("point is found when control points are the same", () => { + const c = curve( + pointFrom(100, 0), + pointFrom(100, 100), + pointFrom(100, 100), + pointFrom(0, 100), + ); + const l = lineSegment(pointFrom(0, 0), pointFrom(200, 200)); + + expect(curveIntersectLineSegment(c, l)).toCloselyEqualPoints([ + [87.5, 87.5], + ]); + }); + + it("point is found when control points aren't the same", () => { + const c = curve( + pointFrom(100, 0), + pointFrom(100, 60), + pointFrom(60, 100), + pointFrom(0, 100), + ); + const l = lineSegment(pointFrom(0, 0), pointFrom(200, 200)); + + expect(curveIntersectLineSegment(c, l)).toCloselyEqualPoints([ + [72.5, 72.5], + ]); + }); + + it("points are found when curve is sliced at 3 points", () => { + const c = curve( + pointFrom(-50, -50), + pointFrom(10, -50), + pointFrom(10, 50), + pointFrom(50, 50), + ); + const l = lineSegment(pointFrom(0, 112.5), pointFrom(90, 0)); + + expect(curveIntersectLineSegment(c, l)).toCloselyEqualPoints([[50, 50]]); + }); + + it("can be detected where the determinant is overly precise", () => { + const c = curve( + pointFrom(41.028864759926016, 12.226249068355052), + pointFrom(41.028864759926016, 33.55958240168839), + pointFrom(30.362198093259348, 44.22624906835505), + pointFrom(9.028864759926016, 44.22624906835505), + ); + const l = lineSegment( + pointFrom(-82.30963544324186, -41.19949363038283), + + pointFrom(188.2149592542487, 134.75505940984908), + ); + + expect(curveIntersectLineSegment(c, l)).toCloselyEqualPoints([ + [34.4, 34.71], + ]); + }); + }); + + describe("point closest to other", () => { + it("point can be found", () => { + const c = curve( + pointFrom(-50, -50), + pointFrom(10, -50), + pointFrom(10, 50), + pointFrom(50, 50), + ); + const p = pointFrom(0, 0); + + expect([curveClosestPoint(c, p)]).toCloselyEqualPoints([ + [5.965462100367372, -3.04104878946646], + ]); + }); + }); + + describe("point shortest distance", () => { + it("can be determined", () => { + const c = curve( + pointFrom(-50, -50), + pointFrom(10, -50), + pointFrom(10, 50), + pointFrom(50, 50), + ); + const p = pointFrom(0, 0); + + expect(curvePointDistance(c, p)).toBeCloseTo(6.695873043213627); + }); + }); +}); diff --git a/packages/math/curve.ts b/packages/math/curve.ts index 68a885fd8..9b275ceec 100644 --- a/packages/math/curve.ts +++ b/packages/math/curve.ts @@ -1,5 +1,7 @@ -import { pointFrom, pointRotateRads } from "./point"; -import type { Curve, GlobalPoint, LocalPoint, Radians } from "./types"; +import type { Bounds } from "../excalidraw/element/bounds"; +import { isPoint, pointDistance, pointFrom } from "./point"; +import { rectangle, rectangleIntersectLineSegment } from "./rectangle"; +import type { Curve, GlobalPoint, LineSegment, LocalPoint } from "./types"; /** * @@ -18,206 +20,263 @@ export function curve( return [a, b, c, d] as Curve; } -export const curveRotate = ( - curve: Curve, - angle: Radians, - origin: Point, -) => { - return curve.map((p) => pointRotateRads(p, origin, angle)); -}; +function gradient( + f: (t: number, s: number) => number, + t0: number, + s0: number, + delta: number = 1e-6, +): number[] { + return [ + (f(t0 + delta, s0) - f(t0 - delta, s0)) / (2 * delta), + (f(t0, s0 + delta) - f(t0, s0 - delta)) / (2 * delta), + ]; +} + +function solve( + f: (t: number, s: number) => [number, number], + t0: number, + s0: number, + tolerance: number = 1e-3, + iterLimit: number = 10, +): number[] | null { + let error = Infinity; + let iter = 0; + + while (error >= tolerance) { + if (iter >= iterLimit) { + return null; + } + + const y0 = f(t0, s0); + const jacobian = [ + gradient((t, s) => f(t, s)[0], t0, s0), + gradient((t, s) => f(t, s)[1], t0, s0), + ]; + const b = [[-y0[0]], [-y0[1]]]; + const det = + jacobian[0][0] * jacobian[1][1] - jacobian[0][1] * jacobian[1][0]; + + if (det === 0) { + return null; + } + + const iJ = [ + [jacobian[1][1] / det, -jacobian[0][1] / det], + [-jacobian[1][0] / det, jacobian[0][0] / det], + ]; + const h = [ + [iJ[0][0] * b[0][0] + iJ[0][1] * b[1][0]], + [iJ[1][0] * b[0][0] + iJ[1][1] * b[1][0]], + ]; + + t0 = t0 + h[0][0]; + s0 = s0 + h[1][0]; + + const [tErr, sErr] = f(t0, s0); + error = Math.max(Math.abs(tErr), Math.abs(sErr)); + iter += 1; + } + + return [t0, s0]; +} + +const bezierEquation = ( + c: Curve, + t: number, +) => + pointFrom( + (1 - t) ** 3 * c[0][0] + + 3 * (1 - t) ** 2 * t * c[1][0] + + 3 * (1 - t) * t ** 2 * c[2][0] + + t ** 3 * c[3][0], + (1 - t) ** 3 * c[0][1] + + 3 * (1 - t) ** 2 * t * c[1][1] + + 3 * (1 - t) * t ** 2 * c[2][1] + + t ** 3 * c[3][1], + ); /** - * - * @param pointsIn - * @param curveTightness - * @returns + * Computes the intersection between a cubic spline and a line segment. */ -export function curveToBezier( - pointsIn: readonly Point[], - curveTightness = 0, -): Point[] { - const len = pointsIn.length; - if (len < 3) { - throw new Error("A curve must have at least three points."); +export function curveIntersectLineSegment< + Point extends GlobalPoint | LocalPoint, +>(c: Curve, l: LineSegment): Point[] { + // Optimize by doing a cheap bounding box check first + const bounds = curveBounds(c); + if ( + rectangleIntersectLineSegment( + rectangle( + pointFrom(bounds[0], bounds[1]), + pointFrom(bounds[2], bounds[3]), + ), + l, + ).length === 0 + ) { + return []; } - const out: Point[] = []; - if (len === 3) { - out.push( - pointFrom(pointsIn[0][0], pointsIn[0][1]), // Points need to be cloned - pointFrom(pointsIn[1][0], pointsIn[1][1]), // Points need to be cloned - pointFrom(pointsIn[2][0], pointsIn[2][1]), // Points need to be cloned - pointFrom(pointsIn[2][0], pointsIn[2][1]), // Points need to be cloned + + const line = (s: number) => + pointFrom( + l[0][0] + s * (l[1][0] - l[0][0]), + l[0][1] + s * (l[1][1] - l[0][1]), ); - } else { - const points: Point[] = []; - points.push(pointsIn[0], pointsIn[0]); - for (let i = 1; i < pointsIn.length; i++) { - points.push(pointsIn[i]); - if (i === pointsIn.length - 1) { - points.push(pointsIn[i]); - } + + const initial_guesses: [number, number][] = [ + [0.5, 0], + [0.2, 0], + [0.8, 0], + ]; + + const calculate = ([t0, s0]: [number, number]) => { + const solution = solve( + (t: number, s: number) => { + const bezier_point = bezierEquation(c, t); + const line_point = line(s); + + return [ + bezier_point[0] - line_point[0], + bezier_point[1] - line_point[1], + ]; + }, + t0, + s0, + ); + + if (!solution) { + return null; } - const b: Point[] = []; - const s = 1 - curveTightness; - out.push(pointFrom(points[0][0], points[0][1])); - for (let i = 1; i + 2 < points.length; i++) { - const cachedVertArray = points[i]; - b[0] = pointFrom(cachedVertArray[0], cachedVertArray[1]); - b[1] = pointFrom( - cachedVertArray[0] + (s * points[i + 1][0] - s * points[i - 1][0]) / 6, - cachedVertArray[1] + (s * points[i + 1][1] - s * points[i - 1][1]) / 6, - ); - b[2] = pointFrom( - points[i + 1][0] + (s * points[i][0] - s * points[i + 2][0]) / 6, - points[i + 1][1] + (s * points[i][1] - s * points[i + 2][1]) / 6, - ); - b[3] = pointFrom(points[i + 1][0], points[i + 1][1]); - out.push(b[1], b[2], b[3]); + + const [t, s] = solution; + + if (t < 0 || t > 1 || s < 0 || s > 1) { + return null; } + + return bezierEquation(c, t); + }; + + let solution = calculate(initial_guesses[0]); + if (solution) { + return [solution]; } - return out; + + solution = calculate(initial_guesses[1]); + if (solution) { + return [solution]; + } + + solution = calculate(initial_guesses[2]); + if (solution) { + return [solution]; + } + + return []; } /** + * Finds the closest point on the Bezier curve from another point * - * @param t - * @param controlPoints + * @param x + * @param y + * @param P0 + * @param P1 + * @param P2 + * @param P3 + * @param tolerance + * @param maxLevel * @returns */ -export const cubicBezierPoint = ( - t: number, - controlPoints: Curve, -): Point => { - const [p0, p1, p2, p3] = controlPoints; +export function curveClosestPoint( + c: Curve, + p: Point, + tolerance: number = 1e-3, +): Point | null { + const localMinimum = ( + min: number, + max: number, + f: (t: number) => number, + e: number = tolerance, + ) => { + let m = min; + let n = max; + let k; - const x = - Math.pow(1 - t, 3) * p0[0] + - 3 * Math.pow(1 - t, 2) * t * p1[0] + - 3 * (1 - t) * Math.pow(t, 2) * p2[0] + - Math.pow(t, 3) * p3[0]; - - const y = - Math.pow(1 - t, 3) * p0[1] + - 3 * Math.pow(1 - t, 2) * t * p1[1] + - 3 * (1 - t) * Math.pow(t, 2) * p2[1] + - Math.pow(t, 3) * p3[1]; - - return pointFrom(x, y); -}; - -/** - * - * @param point - * @param controlPoints - * @returns - */ -export const cubicBezierDistance = ( - point: Point, - controlPoints: Curve, -) => { - // Calculate the closest point on the Bezier curve to the given point - const t = findClosestParameter(point, controlPoints); - - // Calculate the coordinates of the closest point on the curve - const [closestX, closestY] = cubicBezierPoint(t, controlPoints); - - // Calculate the distance between the given point and the closest point on the curve - const distance = Math.sqrt( - (point[0] - closestX) ** 2 + (point[1] - closestY) ** 2, - ); - - return distance; -}; - -const solveCubic = (a: number, b: number, c: number, d: number) => { - // This function solves the cubic equation ax^3 + bx^2 + cx + d = 0 - const roots: number[] = []; - - const discriminant = - 18 * a * b * c * d - - 4 * Math.pow(b, 3) * d + - Math.pow(b, 2) * Math.pow(c, 2) - - 4 * a * Math.pow(c, 3) - - 27 * Math.pow(a, 2) * Math.pow(d, 2); - - if (discriminant >= 0) { - const C = Math.cbrt((discriminant + Math.sqrt(discriminant)) / 2); - const D = Math.cbrt((discriminant - Math.sqrt(discriminant)) / 2); - - const root1 = (-b - C - D) / (3 * a); - const root2 = (-b + (C + D) / 2) / (3 * a); - const root3 = (-b + (C + D) / 2) / (3 * a); - - roots.push(root1, root2, root3); - } else { - const realPart = -b / (3 * a); - - const root1 = - 2 * Math.sqrt(-b / (3 * a)) * Math.cos(Math.acos(realPart) / 3); - const root2 = - 2 * - Math.sqrt(-b / (3 * a)) * - Math.cos((Math.acos(realPart) + 2 * Math.PI) / 3); - const root3 = - 2 * - Math.sqrt(-b / (3 * a)) * - Math.cos((Math.acos(realPart) + 4 * Math.PI) / 3); - - roots.push(root1, root2, root3); - } - - return roots; -}; - -const findClosestParameter = ( - point: Point, - controlPoints: Curve, -) => { - // This function finds the parameter t that minimizes the distance between the point - // and any point on the cubic Bezier curve. - - const [p0, p1, p2, p3] = controlPoints; - - // Use the direct formula to find the parameter t - const a = p3[0] - 3 * p2[0] + 3 * p1[0] - p0[0]; - const b = 3 * p2[0] - 6 * p1[0] + 3 * p0[0]; - const c = 3 * p1[0] - 3 * p0[0]; - const d = p0[0] - point[0]; - - const rootsX = solveCubic(a, b, c, d); - - // Do the same for the y-coordinate - const e = p3[1] - 3 * p2[1] + 3 * p1[1] - p0[1]; - const f = 3 * p2[1] - 6 * p1[1] + 3 * p0[1]; - const g = 3 * p1[1] - 3 * p0[1]; - const h = p0[1] - point[1]; - - const rootsY = solveCubic(e, f, g, h); - - // Select the real root that is between 0 and 1 (inclusive) - const validRootsX = rootsX.filter((root) => root >= 0 && root <= 1); - const validRootsY = rootsY.filter((root) => root >= 0 && root <= 1); - - if (validRootsX.length === 0 || validRootsY.length === 0) { - // No valid roots found, use the midpoint as a fallback - return 0.5; - } - - // Choose the parameter t that minimizes the distance - let minDistance = Infinity; - let closestT = 0; - - for (const rootX of validRootsX) { - for (const rootY of validRootsY) { - const distance = Math.sqrt( - (rootX - point[0]) ** 2 + (rootY - point[1]) ** 2, - ); - if (distance < minDistance) { - minDistance = distance; - closestT = (rootX + rootY) / 2; // Use the average for a smoother result + while (n - m > e) { + k = (n + m) / 2; + if (f(k - e) < f(k + e)) { + n = k; + } else { + m = k; } } + + return k; + }; + + const maxSteps = 30; + let closestStep = 0; + for (let min = Infinity, step = 0; step < maxSteps; step++) { + const d = pointDistance(p, bezierEquation(c, step / maxSteps)); + if (d < min) { + min = d; + closestStep = step; + } } - return closestT; -}; + const t0 = Math.max((closestStep - 1) / maxSteps, 0); + const t1 = Math.min((closestStep + 1) / maxSteps, 1); + const solution = localMinimum(t0, t1, (t) => + pointDistance(p, bezierEquation(c, t)), + ); + + if (!solution) { + return null; + } + + return bezierEquation(c, solution); +} + +/** + * Determines the distance between a point and the closest point on the + * Bezier curve. + * + * @param c The curve to test + * @param p The point to measure from + */ +export function curvePointDistance( + c: Curve, + p: Point, +) { + const closest = curveClosestPoint(c, p); + + if (!closest) { + return 0; + } + + return pointDistance(p, closest); +} + +/** + * Determines if the parameter is a Curve + */ +export function isCurve

( + v: unknown, +): v is Curve

{ + return ( + Array.isArray(v) && + v.length === 4 && + isPoint(v[0]) && + isPoint(v[1]) && + isPoint(v[2]) && + isPoint(v[3]) + ); +} + +function curveBounds( + c: Curve, +): Bounds { + const [P0, P1, P2, P3] = c; + const x = [P0[0], P1[0], P2[0], P3[0]]; + const y = [P0[1], P1[1], P2[1], P3[1]]; + return [Math.min(...x), Math.min(...y), Math.max(...x), Math.max(...y)]; +} diff --git a/packages/math/ellipse.test.ts b/packages/math/ellipse.test.ts new file mode 100644 index 000000000..507cc5a1a --- /dev/null +++ b/packages/math/ellipse.test.ts @@ -0,0 +1,126 @@ +import { + ellipse, + ellipseSegmentInterceptPoints, + ellipseIncludesPoint, + ellipseTouchesPoint, + ellipseLineIntersectionPoints, +} from "./ellipse"; +import { line } from "./line"; +import { pointFrom } from "./point"; +import { lineSegment } from "./segment"; +import type { Ellipse, GlobalPoint } from "./types"; + +describe("point and ellipse", () => { + it("point on ellipse", () => { + const target: Ellipse = ellipse(pointFrom(1, 2), 2, 1); + [ + pointFrom(1, 3), + pointFrom(1, 1), + pointFrom(3, 2), + pointFrom(-1, 2), + ].forEach((p) => { + expect(ellipseTouchesPoint(p, target)).toBe(true); + }); + expect(ellipseTouchesPoint(pointFrom(-0.4, 2.7), target, 0.1)).toBe(true); + expect(ellipseTouchesPoint(pointFrom(-0.4, 2.71), target, 0.01)).toBe(true); + + expect(ellipseTouchesPoint(pointFrom(2.4, 2.7), target, 0.1)).toBe(true); + expect(ellipseTouchesPoint(pointFrom(2.4, 2.71), target, 0.01)).toBe(true); + + expect(ellipseTouchesPoint(pointFrom(2, 1.14), target, 0.1)).toBe(true); + expect(ellipseTouchesPoint(pointFrom(2, 1.14), target, 0.01)).toBe(true); + + expect(ellipseTouchesPoint(pointFrom(0, 1.14), target, 0.1)).toBe(true); + expect(ellipseTouchesPoint(pointFrom(0, 1.14), target, 0.01)).toBe(true); + + expect(ellipseTouchesPoint(pointFrom(0, 2.8), target)).toBe(false); + expect(ellipseTouchesPoint(pointFrom(2, 1.2), target)).toBe(false); + }); + + it("point in ellipse", () => { + const target: Ellipse = ellipse(pointFrom(0, 0), 2, 1); + [ + pointFrom(0, 1), + pointFrom(0, -1), + pointFrom(2, 0), + pointFrom(-2, 0), + ].forEach((p) => { + expect(ellipseIncludesPoint(p, target)).toBe(true); + }); + + expect(ellipseIncludesPoint(pointFrom(-1, 0.8), target)).toBe(true); + expect(ellipseIncludesPoint(pointFrom(1, -0.8), target)).toBe(true); + + // Point on outline + expect(ellipseIncludesPoint(pointFrom(2, 0), target)).toBe(true); + + expect(ellipseIncludesPoint(pointFrom(-1, 1), target)).toBe(false); + expect(ellipseIncludesPoint(pointFrom(-1.4, 0.8), target)).toBe(false); + }); +}); + +describe("segment and ellipse", () => { + it("detects outside segment", () => { + const e = ellipse(pointFrom(0, 0), 2, 2); + + expect( + ellipseSegmentInterceptPoints( + e, + lineSegment(pointFrom(-100, 0), pointFrom(-10, 0)), + ), + ).toEqual([]); + expect( + ellipseSegmentInterceptPoints( + e, + lineSegment(pointFrom(-10, 0), pointFrom(10, 0)), + ), + ).toEqual([pointFrom(-2, 0), pointFrom(2, 0)]); + expect( + ellipseSegmentInterceptPoints( + e, + lineSegment(pointFrom(-10, -2), pointFrom(10, -2)), + ), + ).toEqual([pointFrom(0, -2)]); + expect( + ellipseSegmentInterceptPoints( + e, + lineSegment(pointFrom(0, -1), pointFrom(0, 1)), + ), + ).toEqual([]); + }); +}); + +describe("line and ellipse", () => { + const e = ellipse(pointFrom(0, 0), 2, 2); + + it("detects outside line", () => { + expect( + ellipseLineIntersectionPoints( + e, + line(pointFrom(-10, -10), pointFrom(10, -10)), + ), + ).toEqual([]); + }); + it("detects line intersecting ellipse", () => { + expect( + ellipseLineIntersectionPoints( + e, + line(pointFrom(0, -1), pointFrom(0, 1)), + ), + ).toEqual([pointFrom(0, 2), pointFrom(0, -2)]); + expect( + ellipseLineIntersectionPoints( + e, + line(pointFrom(-100, 0), pointFrom(-10, 0)), + ).map(([x, y]) => pointFrom(Math.round(x), Math.round(y))), + ).toEqual([pointFrom(2, 0), pointFrom(-2, 0)]); + }); + it("detects line touching ellipse", () => { + expect( + ellipseLineIntersectionPoints( + e, + line(pointFrom(-2, -2), pointFrom(2, -2)), + ), + ).toEqual([pointFrom(0, -2)]); + }); +}); diff --git a/packages/math/ellipse.ts b/packages/math/ellipse.ts new file mode 100644 index 000000000..c32def0eb --- /dev/null +++ b/packages/math/ellipse.ts @@ -0,0 +1,230 @@ +import { + pointFrom, + pointDistance, + pointFromVector, + pointsEqual, +} from "./point"; +import type { + Ellipse, + GlobalPoint, + Line, + LineSegment, + LocalPoint, +} from "./types"; +import { PRECISION } from "./utils"; +import { + vector, + vectorAdd, + vectorDot, + vectorFromPoint, + vectorScale, +} from "./vector"; + +/** + * Construct an Ellipse object from the parameters + * + * @param center The center of the ellipse + * @param angle The slanting of the ellipse in radians + * @param halfWidth Half of the width of a non-slanted version of the ellipse + * @param halfHeight Half of the height of a non-slanted version of the ellipse + * @returns The constructed Ellipse object + */ +export function ellipse( + center: Point, + halfWidth: number, + halfHeight: number, +): Ellipse { + return { + center, + halfWidth, + halfHeight, + } as Ellipse; +} + +/** + * Determines if a point is inside or on the ellipse outline + * + * @param p The point to test + * @param ellipse The ellipse to compare against + * @returns TRUE if the point is inside or on the outline of the ellipse + */ +export const ellipseIncludesPoint = ( + p: Point, + ellipse: Ellipse, +) => { + const { center, halfWidth, halfHeight } = ellipse; + const normalizedX = (p[0] - center[0]) / halfWidth; + const normalizedY = (p[1] - center[1]) / halfHeight; + + return normalizedX * normalizedX + normalizedY * normalizedY <= 1; +}; + +/** + * Tests whether a point lies on the outline of the ellipse within a given + * tolerance + * + * @param point The point to test + * @param ellipse The ellipse to compare against + * @param threshold The distance to consider a point close enough to be "on" the outline + * @returns TRUE if the point is on the ellise outline + */ +export const ellipseTouchesPoint = ( + point: Point, + ellipse: Ellipse, + threshold = PRECISION, +) => { + return ellipseDistanceFromPoint(point, ellipse) <= threshold; +}; + +/** + * Determine the shortest euclidean distance from a point to the + * outline of the ellipse + * + * @param p The point to consider + * @param ellipse The ellipse to calculate the distance to + * @returns The eucledian distance + */ +export const ellipseDistanceFromPoint = < + Point extends GlobalPoint | LocalPoint, +>( + p: Point, + ellipse: Ellipse, +): number => { + const { halfWidth, halfHeight, center } = ellipse; + const a = halfWidth; + const b = halfHeight; + const translatedPoint = vectorAdd( + vectorFromPoint(p), + vectorScale(vectorFromPoint(center), -1), + ); + + const px = Math.abs(translatedPoint[0]); + const py = Math.abs(translatedPoint[1]); + + let tx = 0.707; + let ty = 0.707; + + for (let i = 0; i < 3; i++) { + const x = a * tx; + const y = b * ty; + + const ex = ((a * a - b * b) * tx ** 3) / a; + const ey = ((b * b - a * a) * ty ** 3) / b; + + const rx = x - ex; + const ry = y - ey; + + const qx = px - ex; + const qy = py - ey; + + const r = Math.hypot(ry, rx); + const q = Math.hypot(qy, qx); + + tx = Math.min(1, Math.max(0, ((qx * r) / q + ex) / a)); + ty = Math.min(1, Math.max(0, ((qy * r) / q + ey) / b)); + const t = Math.hypot(ty, tx); + tx /= t; + ty /= t; + } + + const [minX, minY] = [ + a * tx * Math.sign(translatedPoint[0]), + b * ty * Math.sign(translatedPoint[1]), + ]; + + return pointDistance(pointFromVector(translatedPoint), pointFrom(minX, minY)); +}; + +/** + * Calculate a maximum of two intercept points for a line going throug an + * ellipse. + */ +export function ellipseSegmentInterceptPoints< + Point extends GlobalPoint | LocalPoint, +>(e: Readonly>, s: Readonly>): Point[] { + const rx = e.halfWidth; + const ry = e.halfHeight; + + 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)); + + const a = vectorDot(dir, mDir); + const b = vectorDot(dir, mDiff); + const c = vectorDot(diff, mDiff) - 1.0; + const d = b * b - a * c; + + const intersections: Point[] = []; + + if (d > 0) { + const t_a = (-b - Math.sqrt(d)) / a; + const t_b = (-b + Math.sqrt(d)) / a; + + if (0 <= t_a && t_a <= 1) { + intersections.push( + pointFrom( + s[0][0] + (s[1][0] - s[0][0]) * t_a, + s[0][1] + (s[1][1] - s[0][1]) * t_a, + ), + ); + } + + if (0 <= t_b && t_b <= 1) { + intersections.push( + pointFrom( + s[0][0] + (s[1][0] - s[0][0]) * t_b, + s[0][1] + (s[1][1] - s[0][1]) * t_b, + ), + ); + } + } else if (d === 0) { + const t = -b / a; + if (0 <= t && t <= 1) { + intersections.push( + pointFrom( + s[0][0] + (s[1][0] - s[0][0]) * t, + s[0][1] + (s[1][1] - s[0][1]) * t, + ), + ); + } + } + + return intersections; +} + +export function ellipseLineIntersectionPoints< + Point extends GlobalPoint | LocalPoint, +>( + { center, halfWidth, halfHeight }: Ellipse, + [g, h]: Line, +): Point[] { + const [cx, cy] = center; + const x1 = g[0] - cx; + const y1 = g[1] - cy; + const x2 = h[0] - cx; + const y2 = h[1] - cy; + const a = + Math.pow(x2 - x1, 2) / Math.pow(halfWidth, 2) + + Math.pow(y2 - y1, 2) / Math.pow(halfHeight, 2); + const b = + 2 * + ((x1 * (x2 - x1)) / Math.pow(halfWidth, 2) + + (y1 * (y2 - y1)) / Math.pow(halfHeight, 2)); + const c = + Math.pow(x1, 2) / Math.pow(halfWidth, 2) + + Math.pow(y1, 2) / Math.pow(halfHeight, 2) - + 1; + const t1 = (-b + Math.sqrt(Math.pow(b, 2) - 4 * a * c)) / (2 * a); + const t2 = (-b - Math.sqrt(Math.pow(b, 2) - 4 * a * c)) / (2 * a); + const candidates = [ + pointFrom(x1 + t1 * (x2 - x1) + cx, y1 + t1 * (y2 - y1) + cy), + pointFrom(x1 + t2 * (x2 - x1) + cx, y1 + t2 * (y2 - y1) + cy), + ].filter((p) => !isNaN(p[0]) && !isNaN(p[1])); + + if (candidates.length === 2 && pointsEqual(candidates[0], candidates[1])) { + return [candidates[0]]; + } + + return candidates; +} diff --git a/packages/math/ga/ga.test.ts b/packages/math/ga/ga.test.ts deleted file mode 100644 index 767b5b65b..000000000 --- a/packages/math/ga/ga.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import * as GA from "./ga"; -import { point, toString, direction, offset } from "./ga"; -import * as GAPoint from "./gapoints"; -import * as GALine from "./galines"; -import * as GATransform from "./gatransforms"; - -describe("geometric algebra", () => { - describe("points", () => { - it("distanceToLine", () => { - const point = GA.point(3, 3); - const line = GALine.equation(0, 1, -1); - expect(GAPoint.distanceToLine(point, line)).toEqual(2); - }); - - it("distanceToLine neg", () => { - const point = GA.point(-3, -3); - const line = GALine.equation(0, 1, -1); - expect(GAPoint.distanceToLine(point, line)).toEqual(-4); - }); - }); - describe("lines", () => { - it("through", () => { - const a = GA.point(0, 0); - const b = GA.point(2, 0); - expect(toString(GALine.through(a, b))).toEqual( - toString(GALine.equation(0, 2, 0)), - ); - }); - it("parallel", () => { - const point = GA.point(3, 3); - const line = GALine.equation(0, 1, -1); - const parallel = GALine.parallel(line, 2); - expect(GAPoint.distanceToLine(point, parallel)).toEqual(0); - }); - }); - - describe("translation", () => { - it("points", () => { - const start = point(2, 2); - const move = GATransform.translation(direction(0, 1)); - const end = GATransform.apply(move, start); - expect(toString(end)).toEqual(toString(point(2, 3))); - }); - - it("points 2", () => { - const start = point(2, 2); - const move = GATransform.translation(offset(3, 4)); - const end = GATransform.apply(move, start); - expect(toString(end)).toEqual(toString(point(5, 6))); - }); - - it("lines", () => { - const original = GALine.through(point(2, 2), point(3, 4)); - const move = GATransform.translation(offset(3, 4)); - const parallel = GATransform.apply(move, original); - expect(toString(parallel)).toEqual( - toString(GALine.through(point(5, 6), point(6, 8))), - ); - }); - }); - describe("rotation", () => { - it("points", () => { - const start = point(2, 2); - const pivot = point(1, 1); - const rotate = GATransform.rotation(pivot, Math.PI / 2); - const end = GATransform.apply(rotate, start); - expect(toString(end)).toEqual(toString(point(2, 0))); - }); - }); -}); diff --git a/packages/math/ga/ga.ts b/packages/math/ga/ga.ts deleted file mode 100644 index 271aa7ae9..000000000 --- a/packages/math/ga/ga.ts +++ /dev/null @@ -1,317 +0,0 @@ -/** - * This is a 2D Projective Geometric Algebra implementation. - * - * For wider context on geometric algebra visit see https://bivector.net. - * - * For this specific algebra see cheatsheet https://bivector.net/2DPGA.pdf. - * - * Converted from generator written by enki, with a ton of added on top. - * - * This library uses 8-vectors to represent points, directions and lines - * in 2D space. - * - * An array `[a, b, c, d, e, f, g, h]` represents a n(8)vector: - * a + b*e0 + c*e1 + d*e2 + e*e01 + f*e20 + g*e12 + h*e012 - * - * See GAPoint, GALine, GADirection and GATransform modules for common - * operations. - */ - -export type Point = NVector; -export type Direction = NVector; -export type Line = NVector; -export type Transform = NVector; - -export const point = (x: number, y: number): Point => [0, 0, 0, 0, y, x, 1, 0]; - -export const origin = (): Point => [0, 0, 0, 0, 0, 0, 1, 0]; - -export const direction = (x: number, y: number): Direction => { - const norm = Math.hypot(x, y); // same as `inorm(direction(x, y))` - return [0, 0, 0, 0, y / norm, x / norm, 0, 0]; -}; - -export const offset = (x: number, y: number): Direction => [ - 0, - 0, - 0, - 0, - y, - x, - 0, - 0, -]; - -/// This is the "implementation" part of the library - -type NVector = readonly [ - number, - number, - number, - number, - number, - number, - number, - number, -]; - -// These are labels for what each number in an nvector represents -const NVECTOR_BASE = ["1", "e0", "e1", "e2", "e01", "e20", "e12", "e012"]; - -// Used to represent points, lines and transformations -export const nvector = (value: number = 0, index: number = 0): NVector => { - const result = [0, 0, 0, 0, 0, 0, 0, 0]; - if (index < 0 || index > 7) { - throw new Error(`Expected \`index\` between 0 and 7, got \`${index}\``); - } - if (value !== 0) { - result[index] = value; - } - return result as unknown as NVector; -}; - -const STRING_EPSILON = 0.000001; -export const toString = (nvector: NVector): string => { - const result = nvector - .map((value, index) => - Math.abs(value) > STRING_EPSILON - ? value.toFixed(7).replace(/(\.|0+)$/, "") + - (index > 0 ? NVECTOR_BASE[index] : "") - : null, - ) - .filter((representation) => representation != null) - .join(" + "); - return result === "" ? "0" : result; -}; - -// Reverse the order of the basis blades. -export const reverse = (nvector: NVector): NVector => [ - nvector[0], - nvector[1], - nvector[2], - nvector[3], - -nvector[4], - -nvector[5], - -nvector[6], - -nvector[7], -]; - -// Poincare duality operator. -export const dual = (nvector: NVector): NVector => [ - nvector[7], - nvector[6], - nvector[5], - nvector[4], - nvector[3], - nvector[2], - nvector[1], - nvector[0], -]; - -// Clifford Conjugation -export const conjugate = (nvector: NVector): NVector => [ - nvector[0], - -nvector[1], - -nvector[2], - -nvector[3], - -nvector[4], - -nvector[5], - -nvector[6], - nvector[7], -]; - -// Main involution -export const involute = (nvector: NVector): NVector => [ - nvector[0], - -nvector[1], - -nvector[2], - -nvector[3], - nvector[4], - nvector[5], - nvector[6], - -nvector[7], -]; - -// Multivector addition -export const add = (a: NVector, b: NVector | number): NVector => { - if (isNumber(b)) { - return [a[0] + b, a[1], a[2], a[3], a[4], a[5], a[6], a[7]]; - } - return [ - a[0] + b[0], - a[1] + b[1], - a[2] + b[2], - a[3] + b[3], - a[4] + b[4], - a[5] + b[5], - a[6] + b[6], - a[7] + b[7], - ]; -}; - -// Multivector subtraction -export const sub = (a: NVector, b: NVector | number): NVector => { - if (isNumber(b)) { - return [a[0] - b, a[1], a[2], a[3], a[4], a[5], a[6], a[7]]; - } - return [ - a[0] - b[0], - a[1] - b[1], - a[2] - b[2], - a[3] - b[3], - a[4] - b[4], - a[5] - b[5], - a[6] - b[6], - a[7] - b[7], - ]; -}; - -// The geometric product. -export const mul = (a: NVector, b: NVector | number): NVector => { - if (isNumber(b)) { - return [ - a[0] * b, - a[1] * b, - a[2] * b, - a[3] * b, - a[4] * b, - a[5] * b, - a[6] * b, - a[7] * b, - ]; - } - return [ - mulScalar(a, b), - b[1] * a[0] + - b[0] * a[1] - - b[4] * a[2] + - b[5] * a[3] + - b[2] * a[4] - - b[3] * a[5] - - b[7] * a[6] - - b[6] * a[7], - b[2] * a[0] + b[0] * a[2] - b[6] * a[3] + b[3] * a[6], - b[3] * a[0] + b[6] * a[2] + b[0] * a[3] - b[2] * a[6], - b[4] * a[0] + - b[2] * a[1] - - b[1] * a[2] + - b[7] * a[3] + - b[0] * a[4] + - b[6] * a[5] - - b[5] * a[6] + - b[3] * a[7], - b[5] * a[0] - - b[3] * a[1] + - b[7] * a[2] + - b[1] * a[3] - - b[6] * a[4] + - b[0] * a[5] + - b[4] * a[6] + - b[2] * a[7], - b[6] * a[0] + b[3] * a[2] - b[2] * a[3] + b[0] * a[6], - b[7] * a[0] + - b[6] * a[1] + - b[5] * a[2] + - b[4] * a[3] + - b[3] * a[4] + - b[2] * a[5] + - b[1] * a[6] + - b[0] * a[7], - ]; -}; - -export const mulScalar = (a: NVector, b: NVector): number => - b[0] * a[0] + b[2] * a[2] + b[3] * a[3] - b[6] * a[6]; - -// The outer/exterior/wedge product. -export const meet = (a: NVector, b: NVector): NVector => [ - b[0] * a[0], - b[1] * a[0] + b[0] * a[1], - b[2] * a[0] + b[0] * a[2], - b[3] * a[0] + b[0] * a[3], - b[4] * a[0] + b[2] * a[1] - b[1] * a[2] + b[0] * a[4], - b[5] * a[0] - b[3] * a[1] + b[1] * a[3] + b[0] * a[5], - b[6] * a[0] + b[3] * a[2] - b[2] * a[3] + b[0] * a[6], - b[7] * a[0] + - b[6] * a[1] + - b[5] * a[2] + - b[4] * a[3] + - b[3] * a[4] + - b[2] * a[5] + - b[1] * a[6], -]; - -// The regressive product. -export const join = (a: NVector, b: NVector): NVector => [ - joinScalar(a, b), - a[1] * b[7] + a[4] * b[5] - a[5] * b[4] + a[7] * b[1], - a[2] * b[7] - a[4] * b[6] + a[6] * b[4] + a[7] * b[2], - a[3] * b[7] + a[5] * b[6] - a[6] * b[5] + a[7] * b[3], - a[4] * b[7] + a[7] * b[4], - a[5] * b[7] + a[7] * b[5], - a[6] * b[7] + a[7] * b[6], - a[7] * b[7], -]; - -export const joinScalar = (a: NVector, b: NVector): number => - a[0] * b[7] + - a[1] * b[6] + - a[2] * b[5] + - a[3] * b[4] + - a[4] * b[3] + - a[5] * b[2] + - a[6] * b[1] + - a[7] * b[0]; - -// The inner product. -export const dot = (a: NVector, b: NVector): NVector => [ - b[0] * a[0] + b[2] * a[2] + b[3] * a[3] - b[6] * a[6], - b[1] * a[0] + - b[0] * a[1] - - b[4] * a[2] + - b[5] * a[3] + - b[2] * a[4] - - b[3] * a[5] - - b[7] * a[6] - - b[6] * a[7], - b[2] * a[0] + b[0] * a[2] - b[6] * a[3] + b[3] * a[6], - b[3] * a[0] + b[6] * a[2] + b[0] * a[3] - b[2] * a[6], - b[4] * a[0] + b[7] * a[3] + b[0] * a[4] + b[3] * a[7], - b[5] * a[0] + b[7] * a[2] + b[0] * a[5] + b[2] * a[7], - b[6] * a[0] + b[0] * a[6], - b[7] * a[0] + b[0] * a[7], -]; - -export const norm = (a: NVector): number => - Math.sqrt(Math.abs(a[0] * a[0] - a[2] * a[2] - a[3] * a[3] + a[6] * a[6])); - -export const inorm = (a: NVector): number => - Math.sqrt(Math.abs(a[7] * a[7] - a[5] * a[5] - a[4] * a[4] + a[1] * a[1])); - -export const normalized = (a: NVector): NVector => { - const n = norm(a); - if (n === 0 || n === 1) { - return a; - } - const sign = a[6] < 0 ? -1 : 1; - return mul(a, sign / n); -}; - -export const inormalized = (a: NVector): NVector => { - const n = inorm(a); - if (n === 0 || n === 1) { - return a; - } - return mul(a, 1 / n); -}; - -const isNumber = (a: any): a is number => typeof a === "number"; - -export const E0: NVector = nvector(1, 1); -export const E1: NVector = nvector(1, 2); -export const E2: NVector = nvector(1, 3); -export const E01: NVector = nvector(1, 4); -export const E20: NVector = nvector(1, 5); -export const E12: NVector = nvector(1, 6); -export const E012: NVector = nvector(1, 7); -export const I = E012; diff --git a/packages/math/ga/gadirections.ts b/packages/math/ga/gadirections.ts deleted file mode 100644 index 2f631fa6a..000000000 --- a/packages/math/ga/gadirections.ts +++ /dev/null @@ -1,26 +0,0 @@ -import * as GA from "./ga"; -import type { Line, Direction, Point } from "./ga"; - -/** - * A direction is stored as an array `[0, 0, 0, 0, y, x, 0, 0]` representing - * vector `(x, y)`. - */ - -export const from = (point: Point): Point => [ - 0, - 0, - 0, - 0, - point[4], - point[5], - 0, - 0, -]; - -export const fromTo = (from: Point, to: Point): Direction => - GA.inormalized([0, 0, 0, 0, to[4] - from[4], to[5] - from[5], 0, 0]); - -export const orthogonal = (direction: Direction): Direction => - GA.inormalized([0, 0, 0, 0, -direction[5], direction[4], 0, 0]); - -export const orthogonalToLine = (line: Line): Direction => GA.mul(line, GA.I); diff --git a/packages/math/ga/galines.ts b/packages/math/ga/galines.ts deleted file mode 100644 index f5058ce69..000000000 --- a/packages/math/ga/galines.ts +++ /dev/null @@ -1,52 +0,0 @@ -import * as GA from "./ga"; -import type { Line, Point } from "./ga"; - -/** - * A line is stored as an array `[0, c, a, b, 0, 0, 0, 0]` representing: - * c * e0 + a * e1 + b*e2 - * - * This maps to a standard formula `a * x + b * y + c`. - * - * `(-b, a)` corresponds to a 2D vector parallel to the line. The lines - * have a natural orientation, corresponding to that vector. - * - * The magnitude ("norm") of the line is `sqrt(a ^ 2 + b ^ 2)`. - * `c / norm(line)` is the oriented distance from line to origin. - */ - -// Returns line with direction (x, y) through origin -export const vector = (x: number, y: number): Line => - GA.normalized([0, 0, -y, x, 0, 0, 0, 0]); - -// For equation ax + by + c = 0. -export const equation = (a: number, b: number, c: number): Line => - GA.normalized([0, c, a, b, 0, 0, 0, 0]); - -export const through = (from: Point, to: Point): Line => - GA.normalized(GA.join(to, from)); - -export const orthogonal = (line: Line, point: Point): Line => - GA.dot(line, point); - -// Returns a line perpendicular to the line through `against` and `intersection` -// going through `intersection`. -export const orthogonalThrough = (against: Point, intersection: Point): Line => - orthogonal(through(against, intersection), intersection); - -export const parallel = (line: Line, distance: number): Line => { - const result = line.slice(); - result[1] -= distance; - return result as unknown as Line; -}; - -export const parallelThrough = (line: Line, point: Point): Line => - orthogonal(orthogonal(point, line), point); - -export const distance = (line1: Line, line2: Line): number => - GA.inorm(GA.meet(line1, line2)); - -export const angle = (line1: Line, line2: Line): number => - Math.acos(GA.dot(line1, line2)[0]); - -// The orientation of the line -export const sign = (line: Line): number => Math.sign(line[1]); diff --git a/packages/math/ga/gapoints.ts b/packages/math/ga/gapoints.ts deleted file mode 100644 index 909e8ffe6..000000000 --- a/packages/math/ga/gapoints.ts +++ /dev/null @@ -1,42 +0,0 @@ -import * as GA from "./ga"; -import * as GALine from "./galines"; -import type { Point, Line } from "./ga"; -import { join } from "./ga"; - -export const from = ([x, y]: readonly [number, number]): Point => [ - 0, - 0, - 0, - 0, - y, - x, - 1, - 0, -]; - -export const toTuple = (point: Point): [number, number] => [point[5], point[4]]; - -export const abs = (point: Point): Point => [ - 0, - 0, - 0, - 0, - Math.abs(point[4]), - Math.abs(point[5]), - 1, - 0, -]; - -export const intersect = (line1: Line, line2: Line): Point => - GA.normalized(GA.meet(line1, line2)); - -// Projects `point` onto the `line`. -// The returned point is the closest point on the `line` to the `point`. -export const project = (point: Point, line: Line): Point => - intersect(GALine.orthogonal(line, point), line); - -export const distance = (point1: Point, point2: Point): number => - GA.norm(join(point1, point2)); - -export const distanceToLine = (point: Point, line: Line): number => - GA.joinScalar(point, line); diff --git a/packages/math/ga/gatransforms.ts b/packages/math/ga/gatransforms.ts deleted file mode 100644 index 2301d979e..000000000 --- a/packages/math/ga/gatransforms.ts +++ /dev/null @@ -1,41 +0,0 @@ -import * as GA from "./ga"; -import type { Line, Direction, Point, Transform } from "./ga"; -import * as GADirection from "./gadirections"; - -/** - * TODO: docs - */ - -export const rotation = (pivot: Point, angle: number): Transform => - GA.add(GA.mul(pivot, Math.sin(angle / 2)), Math.cos(angle / 2)); - -export const translation = (direction: Direction): Transform => [ - 1, - 0, - 0, - 0, - -(0.5 * direction[5]), - 0.5 * direction[4], - 0, - 0, -]; - -export const translationOrthogonal = ( - direction: Direction, - distance: number, -): Transform => { - const scale = 0.5 * distance; - return [1, 0, 0, 0, scale * direction[4], scale * direction[5], 0, 0]; -}; - -export const translationAlong = (line: Line, distance: number): Transform => - GA.add(GA.mul(GADirection.orthogonalToLine(line), 0.5 * distance), 1); - -export const compose = (motor1: Transform, motor2: Transform): Transform => - GA.mul(motor2, motor1); - -export const apply = ( - motor: Transform, - nvector: Point | Direction | Line, -): Point | Direction | Line => - GA.normalized(GA.mul(GA.mul(motor, nvector), GA.reverse(motor))); diff --git a/packages/math/index.ts b/packages/math/index.ts index 05ec5158f..d00ab469d 100644 --- a/packages/math/index.ts +++ b/packages/math/index.ts @@ -1,10 +1,10 @@ -export * from "./arc"; export * from "./angle"; export * from "./curve"; export * from "./line"; export * from "./point"; export * from "./polygon"; export * from "./range"; +export * from "./rectangle"; export * from "./segment"; export * from "./triangle"; export * from "./types"; diff --git a/packages/math/line.test.ts b/packages/math/line.test.ts new file mode 100644 index 000000000..0e6bb1cc8 --- /dev/null +++ b/packages/math/line.test.ts @@ -0,0 +1,31 @@ +import { line, linesIntersectAt } from "./line"; +import { pointFrom } from "./point"; + +describe("line-line intersections", () => { + it("should correctly detect intersection at origin", () => { + expect( + linesIntersectAt( + line(pointFrom(-5, -5), pointFrom(5, 5)), + line(pointFrom(5, -5), pointFrom(-5, 5)), + ), + ).toEqual(pointFrom(0, 0)); + }); + + it("should correctly detect intersection at non-origin", () => { + expect( + linesIntersectAt( + line(pointFrom(0, 0), pointFrom(10, 10)), + line(pointFrom(10, 0), pointFrom(0, 10)), + ), + ).toEqual(pointFrom(5, 5)); + }); + + it("should correctly detect parallel lines", () => { + expect( + linesIntersectAt( + line(pointFrom(0, 0), pointFrom(0, 10)), + line(pointFrom(10, 0), pointFrom(10, 10)), + ), + ).toBe(null); + }); +}); diff --git a/packages/math/line.ts b/packages/math/line.ts index 89999baa9..bcb4f6d90 100644 --- a/packages/math/line.ts +++ b/packages/math/line.ts @@ -1,5 +1,5 @@ -import { pointCenter, pointFrom, pointRotateRads } from "./point"; -import type { GlobalPoint, Line, LocalPoint, Radians } from "./types"; +import { pointFrom } from "./point"; +import type { GlobalPoint, Line, LocalPoint } from "./types"; /** * Create a line from two points. @@ -11,54 +11,6 @@ export function line

(a: P, b: P): Line

{ return [a, b] as Line

; } -/** - * Convenient point creation from an array of two points. - * - * @param param0 The array with the two points to convert to a line - * @returns The created line - */ -export function lineFromPointPair

([a, b]: [ - P, - P, -]): Line

{ - return line(a, b); -} - -/** - * TODO - * - * @param pointArray - * @returns - */ -export function lineFromPointArray

( - pointArray: P[], -): Line

| undefined { - return pointArray.length === 2 - ? line

(pointArray[0], pointArray[1]) - : undefined; -} - -/** - * Return the coordinates resulting from rotating the given line about an - * origin by an angle in degrees note that when the origin is not given, - * the midpoint of the given line is used as the origin - * - * @param l - * @param angle - * @param origin - * @returns - */ -export const lineRotate = ( - l: Line, - angle: Radians, - origin?: Point, -): Line => { - return line( - pointRotateRads(l[0], origin || pointCenter(l[0], l[1]), angle), - pointRotateRads(l[1], origin || pointCenter(l[0], l[1]), angle), - ); -}; - /** * Determines the intersection point (unless the lines are parallel) of two * lines @@ -67,10 +19,10 @@ export const lineRotate = ( * @param b * @returns */ -export const linesIntersectAt = ( +export function linesIntersectAt( a: Line, b: Line, -): Point | null => { +): Point | null { const A1 = a[1][1] - a[0][1]; const B1 = a[0][0] - a[1][0]; const A2 = b[1][1] - b[0][1]; @@ -83,4 +35,4 @@ export const linesIntersectAt = ( } return null; -}; +} diff --git a/packages/math/point.ts b/packages/math/point.ts index 40f178efb..92df18773 100644 --- a/packages/math/point.ts +++ b/packages/math/point.ts @@ -57,24 +57,9 @@ export function pointFromPair( */ export function pointFromVector

( v: Vector, + offset: P = pointFrom(0, 0), ): P { - return v as unknown as P; -} - -/** - * Convert the coordiante object to a point. - * - * @param coords The coordinate object with x and y properties - * @returns - */ -export function pointFromCoords({ - x, - y, -}: { - x: number; - y: number; -}) { - return [x, y] as Point; + return pointFrom

(offset[0] + v[0], offset[1] + v[1]); } /** @@ -176,36 +161,6 @@ export function pointCenter

(a: P, b: P): P { return pointFrom((a[0] + b[0]) / 2, (a[1] + b[1]) / 2); } -/** - * Add together two points by their coordinates like you'd apply a translation - * to a point by a vector. - * - * @param a One point to act as a basis - * @param b The other point to act like the vector to translate by - * @returns - */ -export function pointAdd( - a: Point, - b: Point, -): Point { - return pointFrom(a[0] + b[0], a[1] + b[1]); -} - -/** - * Subtract a point from another point like you'd translate a point by an - * invese vector. - * - * @param a The point to translate - * @param b The point which will act like a vector - * @returns The resulting point - */ -export function pointSubtract( - a: Point, - b: Point, -): Point { - return pointFrom(a[0] - b[0], a[1] - b[1]); -} - /** * Calculate the distance between two points. * diff --git a/packages/math/rectangle.ts b/packages/math/rectangle.ts new file mode 100644 index 000000000..7dde15ded --- /dev/null +++ b/packages/math/rectangle.ts @@ -0,0 +1,23 @@ +import { pointFrom } from "./point"; +import { lineSegment, lineSegmentIntersectionPoints } from "./segment"; +import type { GlobalPoint, LineSegment, LocalPoint, Rectangle } from "./types"; + +export function rectangle

( + topLeft: P, + bottomRight: P, +): Rectangle

{ + return [topLeft, bottomRight] as Rectangle

; +} + +export function rectangleIntersectLineSegment< + Point extends LocalPoint | GlobalPoint, +>(r: Rectangle, l: LineSegment): Point[] { + return [ + lineSegment(r[0], pointFrom(r[1][0], r[0][1])), + lineSegment(pointFrom(r[1][0], r[0][1]), r[1]), + lineSegment(r[1], pointFrom(r[0][0], r[1][1])), + lineSegment(pointFrom(r[0][0], r[1][1]), r[0]), + ] + .map((s) => lineSegmentIntersectionPoints(l, s)) + .filter((i): i is Point => !!i); +} diff --git a/packages/math/segment.test.ts b/packages/math/segment.test.ts new file mode 100644 index 000000000..4237a3c85 --- /dev/null +++ b/packages/math/segment.test.ts @@ -0,0 +1,21 @@ +import { pointFrom } from "./point"; +import { lineSegment, lineSegmentIntersectionPoints } from "./segment"; + +describe("line-segment intersections", () => { + it("should correctly detect intersection", () => { + expect( + lineSegmentIntersectionPoints( + lineSegment(pointFrom(0, 0), pointFrom(5, 0)), + lineSegment(pointFrom(2, -2), pointFrom(3, 2)), + ), + ).toEqual(pointFrom(2.5, 0)); + }); + it("should correctly detect non-intersection", () => { + expect( + lineSegmentIntersectionPoints( + lineSegment(pointFrom(0, 0), pointFrom(5, 0)), + lineSegment(pointFrom(3, 1), pointFrom(4, 4)), + ), + ).toEqual(null); + }); +}); diff --git a/packages/math/segment.ts b/packages/math/segment.ts index 6c0c2de34..60943aacb 100644 --- a/packages/math/segment.ts +++ b/packages/math/segment.ts @@ -1,3 +1,4 @@ +import { line, linesIntersectAt } from "./line"; import { isPoint, pointCenter, @@ -27,14 +28,6 @@ export function lineSegment

( return [a, b] as LineSegment

; } -export function lineSegmentFromPointArray

( - pointArray: P[], -): LineSegment

| undefined { - return pointArray.length === 2 - ? lineSegment

(pointArray[0], pointArray[1]) - : undefined; -} - /** * * @param segment @@ -156,3 +149,26 @@ export const distanceToLineSegment = ( const dy = y - yy; return Math.sqrt(dx * dx + dy * dy); }; + +/** + * Returns the intersection point of a segment and a line + * + * @param l + * @param s + * @returns + */ +export function lineSegmentIntersectionPoints< + Point extends GlobalPoint | LocalPoint, +>(l: LineSegment, s: LineSegment): Point | null { + const candidate = linesIntersectAt(line(l[0], l[1]), line(s[0], s[1])); + + if ( + !candidate || + !pointOnLineSegment(candidate, s) || + !pointOnLineSegment(candidate, l) + ) { + return null; + } + + return candidate; +} diff --git a/packages/math/types.ts b/packages/math/types.ts index 138a44bc0..a2a575bd7 100644 --- a/packages/math/types.ts +++ b/packages/math/types.ts @@ -85,6 +85,13 @@ export type Triangle

= [ _brand: "excalimath__triangle"; }; +/** + * A rectangular shape represented by 4 points at its corners + */ +export type Rectangle

= [a: P, b: P] & { + _brand: "excalimath__rectangle"; +}; + // // Polygon // @@ -120,11 +127,14 @@ export type PolarCoords = [ ]; /** - * Angles are in radians and centered on 0, 0. Zero radians on a 1 radius circle - * corresponds to (1, 0) cartesian coordinates (point), i.e. to the "right". + An ellipse is specified by its center, angle, and its major and minor axes + but for the sake of simplicity, we've used halfWidth and halfHeight instead + in replace of semi major and semi minor axes */ -export type SymmetricArc = { - radius: number; - startAngle: number; - endAngle: number; +export type Ellipse = { + center: Point; + halfWidth: number; + halfHeight: number; +} & { + _brand: "excalimath_ellipse"; }; diff --git a/packages/math/vector.ts b/packages/math/vector.ts index d7d51b14e..246722067 100644 --- a/packages/math/vector.ts +++ b/packages/math/vector.ts @@ -137,12 +137,9 @@ export function vectorMagnitude(v: Vector) { export const vectorNormalize = (v: Vector): Vector => { const m = vectorMagnitude(v); + if (m === 0) { + return vector(0, 0); + } + return vector(v[0] / m, v[1] / m); }; - -/** - * Project the first vector onto the second vector - */ -export const vectorProjection = (a: Vector, b: Vector) => { - return vectorScale(b, vectorDot(a, b) / vectorDot(b, b)); -}; diff --git a/packages/utils/test-utils.ts b/packages/utils/test-utils.ts new file mode 100644 index 000000000..1dfd14cac --- /dev/null +++ b/packages/utils/test-utils.ts @@ -0,0 +1,33 @@ +import { diffStringsUnified } from "jest-diff"; + +expect.extend({ + toCloselyEqualPoints(received, expected, precision) { + if (!Array.isArray(received) || !Array.isArray(expected)) { + throw new Error("expected and received are not point arrays"); + } + + const COMPARE = 1 / Math.pow(10, precision || 2); + const pass = expected.every( + (point, idx) => + Math.abs(received[idx]?.[0] - point[0]) < COMPARE && + Math.abs(received[idx]?.[1] - point[1]) < COMPARE, + ); + + if (!pass) { + return { + message: () => ` The provided array of points are not close enough. + +${diffStringsUnified( + JSON.stringify(expected, undefined, 2), + JSON.stringify(received, undefined, 2), +)}`, + pass: false, + }; + } + + return { + message: () => `expected ${received} to not be close to ${expected}`, + pass: true, + }; + }, +});