diff --git a/packages/excalidraw/actions/actionProperties.tsx b/packages/excalidraw/actions/actionProperties.tsx index 4026d47798..8a13c1a8ae 100644 --- a/packages/excalidraw/actions/actionProperties.tsx +++ b/packages/excalidraw/actions/actionProperties.tsx @@ -1614,7 +1614,6 @@ export const actionChangeArrowType = register({ startGlobalPoint, endGlobalPoint, startHoveredElement, - elementsMap, ) : startGlobalPoint; const finalEndPoint = endHoveredElement @@ -1622,7 +1621,6 @@ export const actionChangeArrowType = register({ endGlobalPoint, startGlobalPoint, endHoveredElement, - elementsMap, ) : endGlobalPoint; diff --git a/packages/excalidraw/data/__snapshots__/transform.test.ts.snap b/packages/excalidraw/data/__snapshots__/transform.test.ts.snap index c9ad28e8cb..3f346a9c69 100644 --- a/packages/excalidraw/data/__snapshots__/transform.test.ts.snap +++ b/packages/excalidraw/data/__snapshots__/transform.test.ts.snap @@ -89,7 +89,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s "endBinding": { "elementId": "ellipse-1", "fixedPoint": null, - "focus": -0.008153707962747813, + "focus": -0.008835048729392623, "gap": 11.562288374879595, }, "fillStyle": "solid", @@ -120,7 +120,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s "startBinding": { "elementId": "id49", "fixedPoint": null, - "focus": -0.08139534883720931, + "focus": -0.08860759493670874, "gap": 1, }, "strokeColor": "#1864ab", @@ -147,7 +147,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s "endBinding": { "elementId": "ellipse-1", "fixedPoint": null, - "focus": 0.10666666666666667, + "focus": 0.1045751633986928, "gap": 3.8343264684446097, }, "fillStyle": "solid", @@ -1485,7 +1485,7 @@ exports[`Test Transform > should transform the elements correctly when linear el "endBinding": { "elementId": "Alice", "fixedPoint": null, - "focus": 0, + "focus": 1.7573472843231123e-16, "gap": 5.299874999999986, }, "fillStyle": "solid", diff --git a/packages/excalidraw/element/binding.ts b/packages/excalidraw/element/binding.ts index 4a394a9cc6..6d8cadda96 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, @@ -28,7 +16,7 @@ import type { Bounds, } from "./types"; -import { getCenterForBounds, getElementAbsoluteCoords } from "./bounds"; +import { getCenterForBounds } from "./bounds"; import type { AppState } from "../types"; import { isPointOnShape } from "../../utils/collision"; import { getElementAtPosition } from "../scene"; @@ -41,7 +29,6 @@ import { isFixedPointBinding, isFrameLikeElement, isLinearElement, - isRectangularElement, isTextElement, } from "./typeChecks"; import type { ElementUpdate } from "./mutateElement"; @@ -69,7 +56,6 @@ import { pointRotateRads, type GlobalPoint, vectorFromPoint, - pointFromPair, pointDistanceSq, clamp, radians, @@ -78,10 +64,14 @@ import { vectorRotate, vectorNormalize, pointDistance, + line, + lineLineIntersectionPoint, + segmentIncludesPoint, } from "../../math"; -import { segmentIntersectRectangleElement } from "../../utils/geometry/shape"; import { distanceToBindableElement } from "./distance"; +import { intersectElementWithLine } from "./collision"; + export type SuggestedBinding = | NonDeleted | SuggestedPointBinding; @@ -556,12 +546,7 @@ const calculateFocusAndGap = ( elementsMap, ); return { - focus: determineFocusDistance( - hoveredElement, - adjacentPoint, - edgePoint, - elementsMap, - ), + focus: determineFocusDistance(hoveredElement, adjacentPoint, edgePoint), gap: Math.max(1, distanceToBindableElement(hoveredElement, edgePoint)), }; }; @@ -747,29 +732,26 @@ export const bindPointToSnapToElementOutline = ( p: Readonly, otherPoint: Readonly, bindableElement: ExcalidrawBindableElement | undefined, - elementsMap: ElementsMap, ): GlobalPoint => { const aabb = bindableElement && aabbForElement(bindableElement); if (bindableElement && aabb) { // TODO: Dirty hacks until tangents are properly calculated const heading = headingForPointFromElement(bindableElement, aabb, p); - const intersections = [ + const intersections: GlobalPoint[] = [ ...(intersectElementWithLine( bindableElement, point(p[0], p[1] - 2 * bindableElement.height), point(p[0], p[1] + 2 * bindableElement.height), FIXED_BINDING_DISTANCE, - elementsMap, ) ?? []), ...(intersectElementWithLine( bindableElement, point(p[0] - 2 * bindableElement.width, p[1]), point(p[0] + 2 * bindableElement.width, p[1]), FIXED_BINDING_DISTANCE, - elementsMap, ) ?? []), - ]; + ].filter((p) => p != null); const isVertical = compareHeading(heading, HEADING_LEFT) || @@ -1043,7 +1025,6 @@ const updateBoundPoint = ( bindableElement, binding.focus, adjacentPoint, - elementsMap, ); let newEdgePoint: GlobalPoint; @@ -1058,7 +1039,6 @@ const updateBoundPoint = ( adjacentPoint, focusPointAbsolute, binding.gap, - elementsMap, ); if (!intersections || intersections.length === 0) { // This should never happen, since focusPoint should always be @@ -1105,7 +1085,6 @@ export const calculateFixedPointForElbowArrowBinding = ( globalPoint, otherGlobalPoint, hoveredElement, - elementsMap, ); const globalMidPoint = point( bounds[0] + (bounds[2] - bounds[0]) / 2, @@ -1342,29 +1321,6 @@ export const maxBindingGap = ( return Math.max(16, Math.min(0.25 * smallerDimension, 32)); }; -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. @@ -1376,39 +1332,22 @@ 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 = point( + element.x + element.width / 2, + element.y + element.height / 2, + ); + const p = pointRotateRads(b, center, radians(Math.PI / 2)); + const intersection = lineLineIntersectionPoint(line(a, b), line(p, center)); + if (!intersection) { + return 0; } - return ret || 0; + + return ( + ((segmentIncludesPoint(intersection, segment(center, p)) ? 1 : -1) * + pointDistance(center, intersection!)) / + pointDistance(center, b) + ); }; const determineFocusPoint = ( @@ -1416,330 +1355,32 @@ const determineFocusPoint = ( // The oriented, relative distance from the center of `element` of the // returned focusPoint focus: number, - adjecentPoint: GlobalPoint, - elementsMap: ElementsMap, + adjacentPoint: GlobalPoint, ): GlobalPoint => { - 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 p: GA.Point; - switch (element.type) { - case "rectangle": - case "image": - case "text": - case "diamond": - case "iframe": - case "embeddable": - case "frame": - case "magicframe": - p = findFocusPointForRectanguloidElement( - element, - focus, - adjecentPointRel, - ); - break; - case "ellipse": - p = findFocusPointForEllipse(element, focus, adjecentPointRel); - break; - } - return pointFromPair( - GAPoint.toTuple(GATransform.apply(reverseRelateToCenter, p)), - ); -}; - -// 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, segment(a, b), gap); - } - - 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)), - ), - // pointFromArray( - // , - // ), - ); -}; - -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 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 findFocusPointForRectanguloidElement = ( - 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. - gaPoint: GA.Point, -): GA.Point => { - const relP = pointFromPair(GAPoint.toTuple(gaPoint)); const center = point( element.x + element.width / 2, element.y + element.height / 2, ); - const p = point(center[0] + relP[0], center[1] + relP[1]); - const ret = pointFromVector( + if (focus === 0) { + return center; + } + + return pointFromVector( vectorScale( vectorRotate( - vectorNormalize(vectorFromPoint(p, center)), + vectorNormalize(vectorFromPoint(adjacentPoint, center)), radians(Math.PI / 2), ), - Math.sign(relativeDistance) * + Math.sign(focus) * Math.min( pointDistance(point(element.x, element.y), center) * - Math.abs(relativeDistance), + Math.abs(focus), element.width / 2, element.height / 2, ), ), center, ); - - return GA.point(ret[0] - center[0], ret[1] - center[1]); }; export const bindingProperties: Set = new Set([ diff --git a/packages/excalidraw/element/bounds.ts b/packages/excalidraw/element/bounds.ts index 6fbafc4db4..0a3f667922 100644 --- a/packages/excalidraw/element/bounds.ts +++ b/packages/excalidraw/element/bounds.ts @@ -22,13 +22,18 @@ import { getBoundTextElement, getContainerElement } from "./textElement"; import { LinearElementEditor } from "./linearElementEditor"; import { ShapeCache } from "../scene/ShapeCache"; import { arrayToMap, invariant } from "../utils"; -import type { GlobalPoint, LocalPoint } from "../../math"; +import type { GlobalPoint, LocalPoint, Segment } from "../../math"; import { point, pointDistance, pointFromArray, pointRotateRads, pointRescaleFromTopLeft, + segment, + ellipseSegmentInterceptPoints, + ellipse, + arc, + radians, } from "../../math"; import type { Mutable } from "../utility-types"; import { getCurvePathOps } from "../../utils/geometry/shape"; @@ -651,3 +656,55 @@ export const getCenterForBounds = (bounds: Bounds): GlobalPoint => bounds[0] + (bounds[2] - bounds[0]) / 2, bounds[1] + (bounds[3] - bounds[1]) / 2, ); + +/** + * Shortens a segment on both ends to accomodate the arc in the rounded + * diamond shape + * + * @param s The segment to shorten + * @param r The radius to shorten by + * @returns The segment shortened on both ends by the same radius + */ +export const createDiamondSide = ( + s: Segment, + startRadius: number, + endRadius: number, +): Segment => { + return segment( + ellipseSegmentInterceptPoints( + ellipse(s[0], startRadius, startRadius), + s, + )[0] ?? s[0], + ellipseSegmentInterceptPoints(ellipse(s[1], endRadius, endRadius), s)[0] ?? + s[1], + ); +}; + +/** + * Creates an arc for the given roundness and position by taking the start + * and end positions and determining the angle points on the hypotethical + * circle with center point between start and end and raidus equals provided + * roundness. I.e. the created arc is gobal point-aware, or "rotated" in-place. + * + * @param start + * @param end + * @param r + * @returns + */ +export const createDiamondArc = ( + start: GlobalPoint, + end: GlobalPoint, + r: number, +) => { + const c = point( + (start[0] + end[0]) / 2, + (start[1] + end[1]) / 2, + ); + + return arc( + c, + r, + radians(Math.asin((start[1] - c[1]) / r)), + radians(Math.asin((end[1] - c[1]) / r)), + ); +}; diff --git a/packages/excalidraw/element/collision.ts b/packages/excalidraw/element/collision.ts index 6ccda33313..4fe446d37e 100644 --- a/packages/excalidraw/element/collision.ts +++ b/packages/excalidraw/element/collision.ts @@ -1,9 +1,16 @@ import type { ElementsMap, + ExcalidrawDiamondElement, ExcalidrawElement, + ExcalidrawEllipseElement, ExcalidrawRectangleElement, + ExcalidrawRectanguloidElement, } from "./types"; -import { getElementBounds } from "./bounds"; +import { + createDiamondArc, + createDiamondSide, + getElementBounds, +} from "./bounds"; import type { FrameNameBounds } from "../types"; import type { GeometricShape } from "../../utils/geometry/shape"; import { getPolygonShape } from "../../utils/geometry/shape"; @@ -15,9 +22,28 @@ import { isImageElement, isTextElement, } from "./typeChecks"; -import { getBoundTextShape } from "../shapes"; -import type { GlobalPoint, Polygon } from "../../math"; -import { pathIsALoop, isPointWithinBounds, point } from "../../math"; +import { + getBoundTextShape, + getCornerRadius, + getDiamondPoints, +} from "../shapes"; +import type { Arc, GlobalPoint, Polygon } from "../../math"; +import { + pathIsALoop, + isPointWithinBounds, + point, + rectangle, + pointRotateRads, + radians, + segment, + arc, + lineSegmentIntersectionPoints, + line, + arcLineInterceptPoints, + pointDistanceSq, + ellipse, + ellipseLineIntersectionPoints, +} from "../../math"; import { LINE_CONFIRM_THRESHOLD } from "../constants"; export const shouldTestInside = (element: ExcalidrawElement) => { @@ -117,3 +143,226 @@ export const hitElementBoundText = ( ): boolean => { return !!textShape && isPointInShape(scenePointer, textShape); }; + +export const intersectElementWithLine = ( + element: ExcalidrawElement, + a: GlobalPoint, + b: GlobalPoint, + offset: number, +): GlobalPoint[] => { + switch (element.type) { + case "rectangle": + case "image": + case "text": + case "iframe": + case "embeddable": + case "frame": + case "magicframe": + return intersectRectanguloidWithLine(element, a, b, offset); + case "diamond": + return intersectDiamondWithLine(element, a, b, offset); + case "ellipse": + return intersectEllipseWithLine(element, a, b, offset); + default: + throw new Error(`Unimplemented element type '${element.type}'`); + } +}; + +export const intersectRectanguloidWithLine = ( + element: ExcalidrawRectanguloidElement, + a: GlobalPoint, + b: GlobalPoint, + offset: number, +): GlobalPoint[] => { + const r = rectangle( + point(element.x - offset, element.y - offset), + point( + element.x + element.width + offset, + element.y + element.height + offset, + ), + ); + const center = point( + 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( + a, + center, + radians(-element.angle), + ); + const rotatedB = pointRotateRads( + b, + center, + radians(-element.angle), + ); + const roundness = getCornerRadius( + Math.min(element.width + 2 * offset, element.height + 2 * offset), + element, + ); + const sideIntersections: GlobalPoint[] = [ + 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) => + lineSegmentIntersectionPoints(line(rotatedA, rotatedB), s), + ) + .filter((x) => x != null) + .map((j) => pointRotateRads(j, center, element.angle)); + const cornerIntersections: GlobalPoint[] = + 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), + ), + ] + .flatMap((t) => arcLineInterceptPoints(t, line(rotatedA, rotatedB))) + .filter((i) => i != null) + .map((j) => pointRotateRads(j, center, element.angle)) + : []; + + return [...sideIntersections, ...cornerIntersections].sort( + (g, h) => pointDistanceSq(g!, b) - pointDistanceSq(h!, b), + ); +}; + +/** + * + * @param element + * @param a + * @param b + * @returns + */ +export const intersectDiamondWithLine = ( + element: ExcalidrawDiamondElement, + a: GlobalPoint, + b: GlobalPoint, + offset: number = 0, +): GlobalPoint[] => { + const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] = + getDiamondPoints(element, offset); + const center = point((topX + bottomX) / 2, (topY + bottomY) / 2); + const verticalRadius = getCornerRadius(Math.abs(topX - leftX), element); + const horizontalRadius = getCornerRadius(Math.abs(rightY - topY), element); + + // Rotate the point to the inverse direction to simulate the rotated diamond + // points. It's all the same distance-wise. + const rotatedA = pointRotateRads(a, center, radians(-element.angle)); + const rotatedB = pointRotateRads(b, center, radians(-element.angle)); + const [top, right, bottom, left]: GlobalPoint[] = [ + point(element.x + topX, element.y + topY), + point(element.x + rightX, element.y + rightY), + point(element.x + bottomX, element.y + bottomY), + point(element.x + leftX, element.y + leftY), + ]; + + const topRight = createDiamondSide( + segment(top, right), + verticalRadius, + horizontalRadius, + ); + const bottomRight = createDiamondSide( + segment(bottom, right), + verticalRadius, + horizontalRadius, + ); + const bottomLeft = createDiamondSide( + segment(bottom, left), + verticalRadius, + horizontalRadius, + ); + const topLeft = createDiamondSide( + segment(top, left), + verticalRadius, + horizontalRadius, + ); + + const arcs: Arc[] = element.roundness + ? [ + createDiamondArc(topLeft[0], topRight[0], verticalRadius), // TOP + createDiamondArc(topRight[1], bottomRight[1], horizontalRadius), // RIGHT + createDiamondArc(bottomRight[0], bottomLeft[0], verticalRadius), // BOTTOM + createDiamondArc(bottomLeft[1], topLeft[1], horizontalRadius), // LEFT + ] + : []; + + const sides: GlobalPoint[] = [topRight, bottomRight, bottomLeft, topLeft] + .map((s) => + lineSegmentIntersectionPoints(line(rotatedA, rotatedB), s), + ) + .filter((x) => x != null) + // Rotate back intersection points + .map((p) => pointRotateRads(p, center, element.angle)); + const corners = arcs + .flatMap((x) => arcLineInterceptPoints(x, line(rotatedA, rotatedB))) + .filter((x) => x != null) + // Rotate back intersection points + .map((p) => pointRotateRads(p, center, element.angle)); + + return [...sides, ...corners].sort( + (g, h) => pointDistanceSq(g!, b) - pointDistanceSq(h!, b), + ); +}; + +/** + * + * @param element + * @param a + * @param b + * @returns + */ +export const intersectEllipseWithLine = ( + element: ExcalidrawEllipseElement, + a: GlobalPoint, + b: GlobalPoint, + offset: number = 0, +): GlobalPoint[] => { + const center = point( + element.x + element.width / 2, + element.y + element.height / 2, + ); + + const rotatedA = pointRotateRads(a, center, radians(-element.angle)); + const rotatedB = pointRotateRads(b, center, radians(-element.angle)); + + 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 index 027e9ac81f..dba1beb525 100644 --- a/packages/excalidraw/element/distance.ts +++ b/packages/excalidraw/element/distance.ts @@ -1,10 +1,9 @@ -import type { GlobalPoint, Segment } from "../../math"; +import type { GlobalPoint } from "../../math"; import { arc, arcDistanceFromPoint, ellipse, ellipseDistanceFromPoint, - ellipseSegmentInterceptPoints, point, pointRotateRads, radians, @@ -13,6 +12,7 @@ import { segmentDistanceToPoint, } from "../../math"; import { getCornerRadius, getDiamondPoints } from "../shapes"; +import { createDiamondArc, createDiamondSide } from "./bounds"; import type { ExcalidrawBindableElement, ExcalidrawDiamondElement, @@ -22,7 +22,7 @@ import type { export const distanceToBindableElement = ( element: ExcalidrawBindableElement, - point: GlobalPoint, + p: GlobalPoint, ): number => { switch (element.type) { case "rectangle": @@ -32,11 +32,11 @@ export const distanceToBindableElement = ( case "embeddable": case "frame": case "magicframe": - return distanceToRectangleElement(element, point); + return distanceToRectangleElement(element, p); case "diamond": - return distanceToDiamondElement(element, point); + return distanceToDiamondElement(element, p); case "ellipse": - return distanceToEllipseElement(element, point); + return distanceToEllipseElement(element, p); } }; @@ -118,54 +118,6 @@ export const distanceToRectangleElement = ( return Math.min(...[...sideDistances, ...cornerDistances]); }; -/** - * Shortens a segment on both ends to accomodate the arc in the rounded - * diamond shape - * - * @param s The segment to shorten - * @param r The radius to shorten by - * @returns The segment shortened on both ends by the same radius - */ -const createDiamondSide = ( - s: Segment, - startRadius: number, - endRadius: number, -): Segment => { - return segment( - ellipseSegmentInterceptPoints( - ellipse(s[0], startRadius, startRadius), - s, - )[0] ?? s[0], - ellipseSegmentInterceptPoints(ellipse(s[1], endRadius, endRadius), s)[0] ?? - s[1], - ); -}; - -/** - * Creates an arc for the given roundness and position by taking the start - * and end positions and determining the angle points on the hypotethical - * circle with center point between start and end and raidus equals provided - * roundness. I.e. the created arc is gobal point-aware, or "rotated" in-place. - * - * @param start - * @param end - * @param r - * @returns - */ -const createDiamondArc = (start: GlobalPoint, end: GlobalPoint, r: number) => { - const c = point( - (start[0] + end[0]) / 2, - (start[1] + end[1]) / 2, - ); - - return arc( - c, - r, - radians(Math.asin((start[1] - c[1]) / r)), - radians(Math.asin((end[1] - c[1]) / r)), - ); -}; - /** * Returns the distance of a point and the provided diamond element, accounting * for roundness and rotation diff --git a/packages/excalidraw/element/routing.ts b/packages/excalidraw/element/routing.ts index ea6d503d1d..0ebace19c4 100644 --- a/packages/excalidraw/element/routing.ts +++ b/packages/excalidraw/element/routing.ts @@ -1043,7 +1043,6 @@ const getSnapPoint = ( isRectanguloidElement(element) ? avoidRectangularCorner(element, p) : p, otherPoint, element, - elementsMap, ); const getBindPointHeading = ( diff --git a/packages/excalidraw/shapes.tsx b/packages/excalidraw/shapes.tsx index 1330dc3df9..4403bd4111 100644 --- a/packages/excalidraw/shapes.tsx +++ b/packages/excalidraw/shapes.tsx @@ -474,7 +474,10 @@ export const getCornerRadius = (x: number, element: ExcalidrawElement) => { return 0; }; -export const getDiamondPoints = (element: ExcalidrawDiamondElement) => { +export const getDiamondPoints = ( + element: ExcalidrawDiamondElement, + offset: number = 0, +) => { // Here we add +1 to avoid these numbers to be 0 // otherwise rough.js will throw an error complaining about it const topX = Math.floor(element.width / 2) + 1; @@ -486,5 +489,14 @@ export const getDiamondPoints = (element: ExcalidrawDiamondElement) => { const leftX = 0; const leftY = rightY; - return [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY]; + return [ + topX - offset, + topY - offset, + rightX + offset, + rightY + offset, + bottomX + offset, + bottomY + offset, + leftX - offset, + leftY - offset, + ]; }; diff --git a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap index eb3972adad..0c609dfda6 100644 --- a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap @@ -194,7 +194,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": 99, + "height": "121.17708", "id": "id166", "index": "a2", "isDeleted": false, @@ -208,8 +208,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl 0, ], [ - "98.20800", - 99, + "120.20767", + "121.17708", ], ], "roughness": 1, @@ -224,7 +224,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "type": "arrow", "updated": 1, "version": 40, - "width": "98.20800", + "width": "120.20767", "x": 1, "y": 0, } @@ -292,24 +292,24 @@ History { "endBinding": { "elementId": "id165", "fixedPoint": null, - "focus": "0.00990", + "focus": "0.01000", "gap": 1, }, - "height": "1.37272", + "height": "1.78061", "points": [ [ 0, 0, ], [ - 98, - "-1.37272", + "128.08994", + "-1.78061", ], ], "startBinding": { "elementId": "id164", "fixedPoint": null, - "focus": "0.02970", + "focus": "0.03001", "gap": 1, }, }, @@ -320,15 +320,15 @@ History { "focus": "-0.02000", "gap": 1, }, - "height": "0.00473", + "height": "0.00968", "points": [ [ 0, 0, ], [ - "98.00000", - "0.00473", + 302, + "-0.00968", ], ], "startBinding": { @@ -390,15 +390,15 @@ History { "focus": 0, "gap": 1, }, - "height": 99, + "height": "121.17708", "points": [ [ 0, 0, ], [ - "98.20800", - 99, + "120.20767", + "121.17708", ], ], "startBinding": null, @@ -408,27 +408,27 @@ History { "endBinding": { "elementId": "id165", "fixedPoint": null, - "focus": "0.00990", + "focus": "0.01000", "gap": 1, }, - "height": "1.37680", + "height": "1.02669", "points": [ [ 0, 0, ], [ - "98.00000", - "-1.37680", + "128.74676", + "-1.02669", ], ], "startBinding": { "elementId": "id164", "fixedPoint": null, - "focus": "0.02970", + "focus": "0.03001", "gap": 1, }, - "y": "1.39313", + "y": "0.48108", }, }, "id169" => Delta { @@ -819,7 +819,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "updated": 1, "version": 30, "width": 0, - "x": 200, + "x": "174.50000", "y": 0, } `; @@ -1237,7 +1237,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "2.61991", + "height": "0.13739", "id": "id172", "index": "Zz", "isDeleted": false, @@ -1251,8 +1251,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl 0, ], [ - "98.00000", - "-2.61991", + "124.66911", + "0.13739", ], ], "roughness": 1, @@ -1275,9 +1275,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": "124.66911", + "x": 1, + "y": "-0.16421", } `; @@ -1605,7 +1605,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "2.61991", + "height": "0.13739", "id": "id175", "index": "a0", "isDeleted": false, @@ -1619,8 +1619,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl 0, ], [ - "98.00000", - "-2.61991", + "124.66911", + "0.13739", ], ], "roughness": 1, @@ -1643,9 +1643,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": "124.66911", + "x": 1, + "y": "-0.16421", } `; @@ -1763,7 +1763,7 @@ History { "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "22.36242", + "height": "5.44620", "index": "a0", "isDeleted": false, "lastCommittedPoint": null, @@ -1776,8 +1776,8 @@ History { 0, ], [ - "98.00000", - "-22.36242", + "188.94246", + "5.44620", ], ], "roughness": 1, @@ -1798,9 +1798,9 @@ History { "strokeStyle": "solid", "strokeWidth": 2, "type": "arrow", - "width": "98.00000", - "x": 1, - "y": 34, + "width": "188.94246", + "x": "-59.03817", + "y": "-6.02545", }, "inserted": { "isDeleted": true, @@ -2311,7 +2311,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "fillStyle": "solid", "frameId": null, "groupIds": [], - "height": "408.19672", + "height": "414.71403", "id": "id180", "index": "a2", "isDeleted": false, @@ -2325,8 +2325,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl 0, ], [ - 498, - "-408.19672", + "576.45250", + "-414.71403", ], ], "roughness": 1, @@ -2346,8 +2346,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl "type": "arrow", "updated": 1, "version": 10, - "width": 498, - "x": 1, + "width": "576.45250", + "x": "-75.50000", "y": 0, } `; @@ -14997,7 +14997,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding 0, ], [ - "98.00000", + 200, 0, ], ], @@ -15018,8 +15018,8 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "type": "arrow", "updated": 1, "version": 10, - "width": "98.00000", - "x": 1, + "width": 200, + "x": "-75.50000", "y": 0, } `; @@ -15693,7 +15693,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding 0, ], [ - "98.00000", + 200, 0, ], ], @@ -15714,8 +15714,8 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "type": "arrow", "updated": 1, "version": 10, - "width": "98.00000", - "x": 1, + "width": 200, + "x": "-75.50000", "y": 0, } `; @@ -16313,7 +16313,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding 0, ], [ - "98.00000", + 200, 0, ], ], @@ -16334,8 +16334,8 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "type": "arrow", "updated": 1, "version": 10, - "width": "98.00000", - "x": 1, + "width": 200, + "x": "-75.50000", "y": 0, } `; @@ -16931,7 +16931,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding 0, ], [ - "98.00000", + 200, 0, ], ], @@ -16952,8 +16952,8 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "type": "arrow", "updated": 1, "version": 10, - "width": "98.00000", - "x": 1, + "width": 200, + "x": "-75.50000", "y": 0, } `; @@ -17646,7 +17646,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding 0, ], [ - "98.00000", + 200, 0, ], ], @@ -17667,8 +17667,8 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding "type": "arrow", "updated": 1, "version": 11, - "width": "98.00000", - "x": 1, + "width": 200, + "x": "-75.50000", "y": 0, } `; diff --git a/packages/excalidraw/tests/__snapshots__/linearElementEditor.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/linearElementEditor.test.tsx.snap index 8ba8195197..962e5a6a84 100644 --- a/packages/excalidraw/tests/__snapshots__/linearElementEditor.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/linearElementEditor.test.tsx.snap @@ -49,9 +49,3 @@ exports[`Test Linear Elements > Test bound text element > should wrap the bound "Online whiteboard collaboration made easy" `; - -exports[`Test Linear Elements > Test bound text element > should wrap the bound text when arrow bound container moves 2`] = ` -"Online whiteboard -collaboration made -easy" -`; diff --git a/packages/excalidraw/tests/__snapshots__/move.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/move.test.tsx.snap index f8848d012d..aace8e3d9c 100644 --- a/packages/excalidraw/tests/__snapshots__/move.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/move.test.tsx.snap @@ -101,141 +101,3 @@ exports[`move element > rectangle 5`] = ` "y": 40, } `; - -exports[`move element > rectangles with binding arrow 5`] = ` -{ - "angle": 0, - "backgroundColor": "transparent", - "boundElements": [ - { - "id": "id2", - "type": "arrow", - }, - ], - "customData": undefined, - "fillStyle": "solid", - "frameId": null, - "groupIds": [], - "height": 100, - "id": "id0", - "index": "a0", - "isDeleted": false, - "link": null, - "locked": false, - "opacity": 100, - "roughness": 1, - "roundness": { - "type": 3, - }, - "seed": 1278240551, - "strokeColor": "#1e1e1e", - "strokeStyle": "solid", - "strokeWidth": 2, - "type": "rectangle", - "updated": 1, - "version": 4, - "versionNonce": 1723083209, - "width": 100, - "x": 0, - "y": 0, -} -`; - -exports[`move element > rectangles with binding arrow 6`] = ` -{ - "angle": 0, - "backgroundColor": "transparent", - "boundElements": [ - { - "id": "id2", - "type": "arrow", - }, - ], - "customData": undefined, - "fillStyle": "solid", - "frameId": null, - "groupIds": [], - "height": 300, - "id": "id1", - "index": "a1", - "isDeleted": false, - "link": null, - "locked": false, - "opacity": 100, - "roughness": 1, - "roundness": { - "type": 3, - }, - "seed": 1150084233, - "strokeColor": "#1e1e1e", - "strokeStyle": "solid", - "strokeWidth": 2, - "type": "rectangle", - "updated": 1, - "version": 7, - "versionNonce": 745419401, - "width": 300, - "x": 201, - "y": 2, -} -`; - -exports[`move element > rectangles with binding arrow 7`] = ` -{ - "angle": 0, - "backgroundColor": "transparent", - "boundElements": null, - "customData": undefined, - "elbowed": false, - "endArrowhead": "arrow", - "endBinding": { - "elementId": "id1", - "fixedPoint": null, - "focus": "-0.46667", - "gap": 10, - }, - "fillStyle": "solid", - "frameId": null, - "groupIds": [], - "height": "77.29870", - "id": "id2", - "index": "a2", - "isDeleted": false, - "lastCommittedPoint": null, - "link": null, - "locked": false, - "opacity": 100, - "points": [ - [ - 0, - 0, - ], - [ - 81, - "77.29870", - ], - ], - "roughness": 1, - "roundness": { - "type": 2, - }, - "seed": 1604849351, - "startArrowhead": null, - "startBinding": { - "elementId": "id0", - "fixedPoint": null, - "focus": "-0.60000", - "gap": 9, - }, - "strokeColor": "#1e1e1e", - "strokeStyle": "solid", - "strokeWidth": 2, - "type": "arrow", - "updated": 1, - "version": 11, - "versionNonce": 1996028265, - "width": 81, - "x": 110, - "y": 50, -} -`; diff --git a/packages/math/arc.test.ts b/packages/math/arc.test.ts index b922c815d3..972118e001 100644 --- a/packages/math/arc.test.ts +++ b/packages/math/arc.test.ts @@ -1,5 +1,11 @@ import { radians } from "./angle"; -import { arc, arcIncludesPoint, arcSegmentInterceptPoints } from "./arc"; +import { + arc, + arcIncludesPoint, + arcLineInterceptPoints, + arcSegmentInterceptPoints, +} from "./arc"; +import { line } from "./line"; import { point } from "./point"; import { segment } from "./segment"; @@ -31,7 +37,7 @@ describe("point on arc", () => { }); describe("intersection", () => { - it("should report correct interception point", () => { + it("should report correct interception point for segment", () => { expect( arcSegmentInterceptPoints( arc(point(0, 0), 1, radians(-Math.PI / 4), radians(Math.PI / 4)), @@ -40,7 +46,7 @@ describe("intersection", () => { ).toEqual([point(0.894427190999916, 0.447213595499958)]); }); - it("should report both interception points when present", () => { + it("should report both interception points when present for segment", () => { expect( arcSegmentInterceptPoints( arc(point(0, 0), 1, radians(-Math.PI / 4), radians(Math.PI / 4)), @@ -51,4 +57,25 @@ describe("intersection", () => { point(0.9, 0.4358898943540668), ]); }); + + it("should report correct interception point for line", () => { + expect( + arcLineInterceptPoints( + arc(point(0, 0), 1, radians(-Math.PI / 4), radians(Math.PI / 4)), + line(point(2, 1), point(0, 0)), + ), + ).toEqual([point(0.894427190999916, 0.447213595499958)]); + }); + + it("should report both interception points when present for line", () => { + expect( + arcLineInterceptPoints( + arc(point(0, 0), 1, radians(-Math.PI / 4), radians(Math.PI / 4)), + line(point(0.9, -2), point(0.9, 2)), + ), + ).toEqual([ + point(0.9, 0.4358898943540668), + point(0.9, -0.4358898943540668), + ]); + }); }); diff --git a/packages/math/arc.ts b/packages/math/arc.ts index 86a4f97893..016d1032e2 100644 --- a/packages/math/arc.ts +++ b/packages/math/arc.ts @@ -2,10 +2,11 @@ import { cartesian2Polar, normalizeRadians, radians } from "./angle"; import { ellipse, ellipseDistanceFromPoint, + ellipseLineIntersectionPoints, ellipseSegmentInterceptPoints, } from "./ellipse"; import { point, pointDistance } from "./point"; -import type { GenericPoint, Segment, Radians, Arc } from "./types"; +import type { GenericPoint, Segment, Radians, Arc, Line } from "./types"; import { PRECISION } from "./utils"; /** @@ -85,7 +86,7 @@ export function arcDistanceFromPoint( /** * Returns the intersection point(s) of a line segment represented by a start - * point and end point and a symmetric arc. + * point and end point and a symmetric arc */ export function arcSegmentInterceptPoints( a: Readonly>, @@ -106,3 +107,31 @@ export function arcSegmentInterceptPoints( : a.startAngle <= candidateAngle || a.endAngle >= candidateAngle; }); } + +/** + * Returns the intersection point(s) of a line segment represented by a start + * point and end point and a symmetric arc + * + * @param a + * @param l + * @returns + */ +export function arcLineInterceptPoints( + a: Readonly>, + l: Readonly>, +): Point[] { + return ellipseLineIntersectionPoints( + ellipse(a.center, a.radius, a.radius), + l, + ).filter((candidate) => { + const [candidateRadius, candidateAngle] = cartesian2Polar( + point(candidate[0] - a.center[0], candidate[1] - a.center[1]), + ); + + return a.startAngle < a.endAngle + ? Math.abs(a.radius - candidateRadius) < PRECISION && + a.startAngle <= candidateAngle && + a.endAngle >= candidateAngle + : a.startAngle <= candidateAngle || a.endAngle >= candidateAngle; + }); +} diff --git a/packages/math/ellipse.test.ts b/packages/math/ellipse.test.ts index 3a82cda73b..cf87a19705 100644 --- a/packages/math/ellipse.test.ts +++ b/packages/math/ellipse.test.ts @@ -3,7 +3,7 @@ import { ellipseSegmentInterceptPoints, ellipseIncludesPoint, ellipseTouchesPoint, - ellipseIntersectsLine, + ellipseLineIntersectionPoints, } from "./ellipse"; import { line } from "./line"; import { point } from "./point"; @@ -85,7 +85,7 @@ describe("line and ellipse", () => { it("detects outside line", () => { expect( - ellipseIntersectsLine( + ellipseLineIntersectionPoints( e, line(point(-10, -10), point(10, -10)), ), @@ -93,10 +93,10 @@ describe("line and ellipse", () => { }); it("detects line intersecting ellipse", () => { expect( - ellipseIntersectsLine(e, line(point(0, -1), point(0, 1))), + ellipseLineIntersectionPoints(e, line(point(0, -1), point(0, 1))), ).toEqual([point(0, 2), point(0, -2)]); expect( - ellipseIntersectsLine( + ellipseLineIntersectionPoints( e, line(point(-100, 0), point(-10, 0)), ).map(([x, y]) => point(Math.round(x), Math.round(y))), @@ -104,7 +104,7 @@ describe("line and ellipse", () => { }); it("detects line touching ellipse", () => { expect( - ellipseIntersectsLine(e, line(point(-2, -2), point(2, -2))), + ellipseLineIntersectionPoints(e, line(point(-2, -2), point(2, -2))), ).toEqual([point(0, -2)]); }); }); diff --git a/packages/math/ellipse.ts b/packages/math/ellipse.ts index 787faa052b..8e6b258842 100644 --- a/packages/math/ellipse.ts +++ b/packages/math/ellipse.ts @@ -181,7 +181,7 @@ export function ellipseSegmentInterceptPoints( return intersections; } -export function ellipseIntersectsLine( +export function ellipseLineIntersectionPoints( { center, halfWidth, halfHeight }: Ellipse, [g, h]: Line, ): Point[] { diff --git a/packages/math/line.test.ts b/packages/math/line.test.ts index f72617d4bb..a47eba2869 100644 --- a/packages/math/line.test.ts +++ b/packages/math/line.test.ts @@ -1,11 +1,11 @@ -import { line, lineIntersectsLine, lineIntersectsSegment } from "./line"; +import { line, lineLineIntersectionPoint, lineSegmentIntersectionPoints } from "./line"; import { point } from "./point"; import { segment } from "./segment"; describe("line-line intersections", () => { it("should correctly detect intersection at origin", () => { expect( - lineIntersectsLine( + lineLineIntersectionPoint( line(point(-5, -5), point(5, 5)), line(point(5, -5), point(-5, 5)), ), @@ -14,7 +14,7 @@ describe("line-line intersections", () => { it("should correctly detect intersection at non-origin", () => { expect( - lineIntersectsLine( + lineLineIntersectionPoint( line(point(0, 0), point(10, 10)), line(point(10, 0), point(0, 10)), ), @@ -23,7 +23,7 @@ describe("line-line intersections", () => { it("should correctly detect parallel lines", () => { expect( - lineIntersectsLine( + lineLineIntersectionPoint( line(point(0, 0), point(0, 10)), line(point(10, 0), point(10, 10)), ), @@ -34,7 +34,7 @@ describe("line-line intersections", () => { describe("line-segment intersections", () => { it("should correctly detect intersection", () => { expect( - lineIntersectsSegment( + lineSegmentIntersectionPoints( line(point(0, 0), point(5, 0)), segment(point(2, -2), point(3, 2)), ), @@ -42,7 +42,7 @@ describe("line-segment intersections", () => { }); it("should correctly detect non-intersection", () => { expect( - lineIntersectsSegment( + lineSegmentIntersectionPoints( line(point(0, 0), point(5, 0)), segment(point(3, 1), point(4, 4)), ), diff --git a/packages/math/line.ts b/packages/math/line.ts index 6dca92b2ab..0fadb64a5d 100644 --- a/packages/math/line.ts +++ b/packages/math/line.ts @@ -1,4 +1,4 @@ -import { ellipseIntersectsLine } from "./ellipse"; +import { ellipseLineIntersectionPoints } from "./ellipse"; import { point, pointCenter, pointRotateRads } from "./point"; import { segmentIncludesPoint } from "./segment"; import type { GenericPoint, Line, Radians, Segment } from "./types"; @@ -60,7 +60,7 @@ export function lineRotate( * @param b Another line to intersect * @returns The intersection point */ -export function lineIntersectsLine( +export function lineLineIntersectionPoint( [[x1, y1], [x2, y2]]: Line, [[x3, y3], [x4, y4]]: Line, ): Point | null { @@ -83,11 +83,11 @@ export function lineIntersectsLine( * @param s * @returns */ -export function lineIntersectsSegment( +export function lineSegmentIntersectionPoints( l: Line, s: Segment, ): Point | null { - const candidate = lineIntersectsLine(l, line(s[0], s[1])); + const candidate = lineLineIntersectionPoint(l, line(s[0], s[1])); if (!candidate || !segmentIncludesPoint(candidate, s)) { return null; } @@ -95,4 +95,4 @@ export function lineIntersectsSegment( return candidate; } -export const lineInterceptsEllipse = ellipseIntersectsLine; +export const lineInterceptsEllipse = ellipseLineIntersectionPoints; diff --git a/packages/math/segment.ts b/packages/math/segment.ts index ef2a5eb7a2..6e98668117 100644 --- a/packages/math/segment.ts +++ b/packages/math/segment.ts @@ -1,4 +1,4 @@ -import { lineIntersectsSegment } from "./line"; +import { lineSegmentIntersectionPoints } from "./line"; import { isPoint, pointCenter, @@ -186,4 +186,4 @@ export function segmentDistanceToPoint( * @param s * @returns */ -export const segmentIntersectsLine = lineIntersectsSegment; +export const segmentLineIntersectionPoints = lineSegmentIntersectionPoints;