From d9ea7190ecd17edbdbc66c4e916f728454e409c1 Mon Sep 17 00:00:00 2001 From: Mark Tolmacs Date: Wed, 25 Sep 2024 18:30:53 +0200 Subject: [PATCH] First implementation of element distance functions with failing tests --- packages/excalidraw/element/binding.ts | 165 +------------------ packages/excalidraw/element/distance.ts | 210 ++++++++++++++++++++++++ packages/excalidraw/shapes.tsx | 16 +- packages/math/arc.ts | 23 ++- packages/math/polygon.ts | 26 ++- packages/math/segment.ts | 8 +- 6 files changed, 275 insertions(+), 173 deletions(-) create mode 100644 packages/excalidraw/element/distance.ts diff --git a/packages/excalidraw/element/binding.ts b/packages/excalidraw/element/binding.ts index 07783523af..45611d3c8c 100644 --- a/packages/excalidraw/element/binding.ts +++ b/packages/excalidraw/element/binding.ts @@ -25,7 +25,6 @@ import type { ExcalidrawElbowArrowElement, FixedPoint, SceneElementsMap, - ExcalidrawRectanguloidElement, } from "./types"; import type { Bounds } from "./bounds"; @@ -63,7 +62,7 @@ import { vectorToHeading, type Heading, } from "./heading"; -import type { LocalPoint, Radians } from "../../math"; +import type { LocalPoint } from "../../math"; import { segment, point, @@ -76,6 +75,7 @@ import { radians, } from "../../math"; import { segmentIntersectRectangleElement } from "../../utils/geometry/shape"; +import { distanceToBindableElement } from "./distance"; export type SuggestedBinding = | NonDeleted @@ -557,10 +557,7 @@ const calculateFocusAndGap = ( edgePoint, elementsMap, ), - gap: Math.max( - 1, - distanceToBindableElement(hoveredElement, edgePoint, elementsMap), - ), + gap: Math.max(1, distanceToBindableElement(hoveredElement, edgePoint)), }; }; @@ -736,11 +733,7 @@ const getDistanceForBinding = ( bindableElement: ExcalidrawBindableElement, elementsMap: ElementsMap, ) => { - const distance = distanceToBindableElement( - bindableElement, - point, - elementsMap, - ); + const distance = distanceToBindableElement(bindableElement, point); const bindDistance = maxBindingGap( bindableElement, bindableElement.width, @@ -781,9 +774,7 @@ export const bindPointToSnapToElementOutline = ( const isVertical = compareHeading(heading, HEADING_LEFT) || compareHeading(heading, HEADING_RIGHT); - const dist = Math.abs( - distanceToBindableElement(bindableElement, p, elementsMap), - ); + const dist = Math.abs(distanceToBindableElement(bindableElement, p)); const isInner = isVertical ? dist < bindableElement.width * -0.1 : dist < bindableElement.height * -0.1; @@ -937,7 +928,7 @@ export const snapToMid = ( ): GlobalPoint => { const { x, y, width, height, angle } = element; const center = point(x + width / 2 - 0.1, y + height / 2 - 0.1); - const nonRotated = pointRotateRads(p, center, -angle as Radians); + const nonRotated = pointRotateRads(p, center, radians(-angle)); // snap-to-center point is adaptive to element size, but we don't want to go // above and below certain px distance @@ -1123,7 +1114,7 @@ export const calculateFixedPointForElbowArrowBinding = ( const nonRotatedSnappedGlobalPoint = pointRotateRads( snappedPoint, globalMidPoint, - -hoveredElement.angle as Radians, + radians(-hoveredElement.angle), ); return { @@ -1351,148 +1342,6 @@ export const maxBindingGap = ( return Math.max(16, Math.min(0.25 * smallerDimension, 32)); }; -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, diff --git a/packages/excalidraw/element/distance.ts b/packages/excalidraw/element/distance.ts new file mode 100644 index 0000000000..d4c83c368b --- /dev/null +++ b/packages/excalidraw/element/distance.ts @@ -0,0 +1,210 @@ +import type { GlobalPoint, Segment } from "../../math"; +import { + arc, + arcDistanceFromPoint, + ellipse, + ellipseDistanceFromPoint, + ellipseSegmentInterceptPoints, + point, + pointRotateRads, + radians, + rectangle, + segment, + segmentDistanceToPoint, +} from "../../math"; +import { getCornerRadius } from "../shapes"; +import type { + ExcalidrawBindableElement, + ExcalidrawDiamondElement, + ExcalidrawEllipseElement, + ExcalidrawRectanguloidElement, +} from "./types"; + +export const distanceToBindableElement = ( + element: ExcalidrawBindableElement, + point: GlobalPoint, +): number => { + switch (element.type) { + case "rectangle": + case "image": + case "text": + case "iframe": + case "embeddable": + case "frame": + case "magicframe": + return distanceToRectangleElement(element, point); + case "diamond": + return distanceToDiamondElement(element, point); + case "ellipse": + return distanceToEllipseElement(element, point); + } +}; + +export const distanceToRectangleElement = ( + element: ExcalidrawRectanguloidElement, + p: GlobalPoint, +) => { + const center = point( + element.x + element.width / 2, + element.y + element.height / 2, + ); + const r = rectangle( + pointRotateRads( + point(element.x, element.y), + center, + radians(element.angle), + ), + pointRotateRads( + point(element.x + element.width, element.y + element.height), + center, + radians(element.angle), + ), + ); + const roundness = getCornerRadius( + Math.min(element.width, element.height), + element, + ); + const rotatedPoint = pointRotateRads(p, center, element.angle); + const sideDistances = [ + segment( + point(r[0][0] + roundness, r[0][1]), + point(r[1][0] - roundness, r[0][1]), + ), + segment( + point(r[1][0], r[0][1] + roundness), + point(r[1][0], r[1][1] - roundness), + ), + segment( + point(r[1][0] - roundness, r[1][1]), + point(r[0][0] + roundness, r[1][1]), + ), + segment( + point(r[0][0], r[1][1] - roundness), + point(r[0][0], r[0][1] + roundness), + ), + ].map((s) => segmentDistanceToPoint(rotatedPoint, s)); + const cornerDistances = + roundness > 0 + ? [ + arc( + point(r[0][0] + roundness, r[0][1] + roundness), + roundness, + radians(Math.PI), + radians((3 / 4) * Math.PI), + ), + arc( + point(r[1][0] - roundness, r[0][1] + roundness), + roundness, + radians((3 / 4) * Math.PI), + radians(0), + ), + arc( + point(r[1][0] - roundness, r[1][1] - roundness), + roundness, + radians(0), + radians((1 / 2) * Math.PI), + ), + arc( + point(r[0][0] + roundness, r[1][1] - roundness), + roundness, + radians((1 / 2) * Math.PI), + radians(Math.PI), + ), + ].map((a) => arcDistanceFromPoint(a, rotatedPoint)) + : []; + + return Math.min(...[...sideDistances, ...cornerDistances]); +}; + +const roundedCutoffSegment = ( + s: Segment, + r: number, +): Segment => { + const t = (4 * r) / Math.sqrt(2); + + return segment( + ellipseSegmentInterceptPoints(ellipse(s[0], radians(0), t, t), s)[0], + ellipseSegmentInterceptPoints(ellipse(s[1], radians(0), t, t), s)[0], + ); +}; + +const diamondArc = (left: GlobalPoint, right: GlobalPoint, r: number) => { + const c = point((left[0] + right[0]) / 2, left[1]); + + return arc( + c, + r, + radians(Math.asin((left[1] - c[1]) / r)), + radians(Math.asin((right[1] - c[1]) / r)), + ); +}; + +export const distanceToDiamondElement = ( + element: ExcalidrawDiamondElement, + p: GlobalPoint, +): number => { + const center = point( + element.x + element.width / 2, + element.y + element.height / 2, + ); + const roundness = getCornerRadius( + Math.min(element.width, element.height), + element, + ); + const rotatedPoint = pointRotateRads(p, center, element.angle); + const top = pointRotateRads( + point(element.x + element.width / 2, element.y), + center, + element.angle, + ); + const right = pointRotateRads( + point(element.x + element.width, element.y + element.height / 2), + center, + element.angle, + ); + const bottom = pointRotateRads( + point(element.x + element.width / 2, element.y + element.height), + center, + element.angle, + ); + const left = pointRotateRads( + point(element.x, element.y + element.height / 2), + center, + element.angle, + ); + const topRight = roundedCutoffSegment(segment(top, right), roundness); + const bottomRight = roundedCutoffSegment(segment(right, bottom), roundness); + const bottomLeft = roundedCutoffSegment(segment(bottom, left), roundness); + const topLeft = roundedCutoffSegment(segment(left, top), roundness); + + return Math.min( + ...[ + ...[topRight, bottomRight, bottomLeft, topLeft].map((s) => + segmentDistanceToPoint(rotatedPoint, s), + ), + ...(roundness > 0 + ? [ + diamondArc(topLeft[1], topRight[0], roundness), + diamondArc(topRight[1], bottomRight[0], roundness), + diamondArc(bottomRight[1], bottomLeft[0], roundness), + diamondArc(bottomLeft[1], topLeft[0], roundness), + ].map((a) => arcDistanceFromPoint(a, rotatedPoint)) + : []), + ], + ); +}; + +export const distanceToEllipseElement = ( + element: ExcalidrawEllipseElement, + p: GlobalPoint, +): number => { + return ellipseDistanceFromPoint( + p, + ellipse( + point(element.x + element.width / 2, element.y + element.height / 2), + element.angle, + element.width / 2, + element.height / 2, + ), + ); +}; diff --git a/packages/excalidraw/shapes.tsx b/packages/excalidraw/shapes.tsx index 21df33a088..76e7a80643 100644 --- a/packages/excalidraw/shapes.tsx +++ b/packages/excalidraw/shapes.tsx @@ -140,10 +140,10 @@ export const findShapeByKey = (key: string) => { * get the pure geometric shape of an excalidraw element * which is then used for hit detection */ -export const getElementShape = ( +export const getElementShape = ( element: ExcalidrawElement, elementsMap: ElementsMap, -): GeometricShape => { +): GeometricShape => { switch (element.type) { case "rectangle": case "diamond": @@ -163,16 +163,16 @@ export const getElementShape = ( const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap); return shouldTestInside(element) - ? getClosedCurveShape( + ? getClosedCurveShape( element, roughShape, - point(element.x, element.y), + point(element.x, element.y), element.angle, point(cx, cy), ) - : getCurveShape( + : getCurveShape( roughShape, - point(element.x, element.y), + point(element.x, element.y), element.angle, point(cx, cy), ); @@ -192,10 +192,10 @@ export const getElementShape = ( } }; -export const getBoundTextShape = ( +export const getBoundTextShape = ( element: ExcalidrawElement, elementsMap: ElementsMap, -): GeometricShape | null => { +): GeometricShape | null => { const boundTextElement = getBoundTextElement(element, elementsMap); if (boundTextElement) { diff --git a/packages/math/arc.ts b/packages/math/arc.ts index 157cf68c51..b8d53dc6df 100644 --- a/packages/math/arc.ts +++ b/packages/math/arc.ts @@ -1,6 +1,8 @@ +import { invariant } from "../excalidraw/utils"; import { cartesian2Polar, radians } from "./angle"; import { ellipse, ellipseSegmentInterceptPoints } from "./ellipse"; -import { point } from "./point"; +import { point, pointDistance } from "./point"; +import { segment } from "./segment"; import type { GenericPoint, Segment, Radians, Arc } from "./types"; import { PRECISION } from "./utils"; @@ -42,6 +44,25 @@ export function arcIncludesPoint

( : startAngle <= angle || endAngle >= angle; } +/** + * + * @param a + * @param p + */ +export function arcDistanceFromPoint( + a: Arc, + p: Point, +) { + const intersectPoint = arcSegmentInterceptPoint(a, segment(p, a.center)); + + invariant( + intersectPoint.length !== 1, + "Arc distance intersector cannot have multiple intersections", + ); + + return pointDistance(intersectPoint[0], p); +} + /** * Returns the intersection point(s) of a line segment represented by a start * point and end point and a symmetric arc. diff --git a/packages/math/polygon.ts b/packages/math/polygon.ts index fdf26027c3..3d15894a5b 100644 --- a/packages/math/polygon.ts +++ b/packages/math/polygon.ts @@ -1,6 +1,6 @@ import { pointsEqual } from "./point"; -import { segment, segmentIncludesPoint } from "./segment"; -import type { GenericPoint, Polygon } from "./types"; +import { segment, segmentIncludesPoint, segmentsIntersectAt } from "./segment"; +import type { GenericPoint, Polygon, Segment } from "./types"; import { PRECISION } from "./utils"; export function polygon(...points: Point[]) { @@ -62,3 +62,25 @@ function polygonClose(polygon: Point[]) { function polygonIsClosed(polygon: Point[]) { return pointsEqual(polygon[0], polygon[polygon.length - 1]); } + +/** + * Returns the points of intersection of a line segment, identified by exactly + * one start pointand one end point, and the polygon identified by a set of + * ponits representing a set of connected lines. + */ +export function polygonSegmentIntersectionPoints( + polygon: Readonly>, + segment: Readonly>, +): Point[] { + return polygon + .reduce((segments, current, idx, poly) => { + return idx === 0 + ? [] + : ([ + ...segments, + [poly[idx - 1] as Point, current], + ] as Segment[]); + }, [] as Segment[]) + .map((s) => segmentsIntersectAt(s, segment)) + .filter((point) => point !== null) as Point[]; +} diff --git a/packages/math/segment.ts b/packages/math/segment.ts index 204769b83c..baec9065ab 100644 --- a/packages/math/segment.ts +++ b/packages/math/segment.ts @@ -122,11 +122,11 @@ export const segmentIncludesPoint = ( }; export const segmentDistanceToPoint = ( - point: Point, - line: Segment, + p: Point, + s: Segment, ) => { - const [x, y] = point; - const [[x1, y1], [x2, y2]] = line; + const [x, y] = p; + const [[x1, y1], [x2, y2]] = s; const A = x - x1; const B = y - y1;