diff --git a/packages/excalidraw/constants.ts b/packages/excalidraw/constants.ts index cb32190b22..b01dc98ee8 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/element/binding.ts b/packages/excalidraw/element/binding.ts index 8de92d64e9..8b74c98b5b 100644 --- a/packages/excalidraw/element/binding.ts +++ b/packages/excalidraw/element/binding.ts @@ -71,7 +71,7 @@ import { vectorRotate, } from "../../math"; import { distanceToBindableElement } from "./distance"; -import { intersectElementWithLine } from "./collision"; +import { intersectElementWithLineSegment } from "./collision"; export type SuggestedBinding = | NonDeleted @@ -890,17 +890,17 @@ export const bindPointToSnapToElementOutline = ( // TODO: Dirty hacks until tangents are properly calculated const heading = headingForPointFromElement(bindableElement, aabb, p); const intersections = [ - ...(intersectElementWithLine( + ...(intersectElementWithLineSegment( bindableElement, - line( + lineSegment( pointFrom(p[0], p[1] - 2 * bindableElement.height), pointFrom(p[0], p[1] + 2 * bindableElement.height), ), FIXED_BINDING_DISTANCE, ) ?? []), - ...(intersectElementWithLine( + ...(intersectElementWithLineSegment( bindableElement, - line( + lineSegment( pointFrom(p[0] - 2 * bindableElement.width, p[1]), pointFrom(p[0] + 2 * bindableElement.width, p[1]), ), @@ -1207,43 +1207,30 @@ const updateBoundPoint = ( elementsMap, ); - const intersections = intersectElementWithLine( + let intersections = intersectElementWithLineSegment( bindableElement, - line(adjacentPoint, focusPointAbsolute), + lineSegment(adjacentPoint, focusPointAbsolute), binding.gap, ); + if (!intersections || intersections.length === 0) { + intersections = intersectElementWithLineSegment( + bindableElement, + lineSegment(adjacentPoint, focusPointAbsolute), + binding.gap, + ); + } if (!intersections || intersections.length === 0) { // This should never happen, since focusPoint should always be // inside the element, but just in case, bail out - newEdgePoint = focusPointAbsolute; + // Note: Might happen with rounded elements due to FP imprecision + newEdgePoint = edgePointAbsolute; } else { // Guaranteed to intersect because focusPoint is always inside the shape - intersections.sort( (g, h) => pointDistanceSq(g!, edgePointAbsolute) - pointDistanceSq(h!, edgePointAbsolute), ); - // debugClear(); - // debugDrawPoint(edgePointAbsolute, { color: "blue", permanent: true }); - // debugDrawPoint(focusPointAbsolute, { color: "red", permanent: true }); - // debugDrawPoint( - // pointFrom( - // bindableElement.x + bindableElement.width / 2, - // bindableElement.y + bindableElement.height / 2, - // ), - // { color: "gray", permanent: true }, - // ); - // debugDrawLine( - // line( - // edgePointAbsolute, - // pointFromVector(vectorScale(tangentVector, 10), edgePointAbsolute), - // ), - // { - // color: "gray", - // permanent: true, - // }, - // ); newEdgePoint = intersections[0]; } } diff --git a/packages/excalidraw/element/collision.ts b/packages/excalidraw/element/collision.ts index 6c01cb5881..20ddd1e9c6 100644 --- a/packages/excalidraw/element/collision.ts +++ b/packages/excalidraw/element/collision.ts @@ -21,7 +21,7 @@ import { import { getBoundTextShape, getCornerRadius, isPathALoop } from "../shapes"; import type { GlobalPoint, - Line, + LineSegment, LocalPoint, Polygon, Radians, @@ -29,6 +29,7 @@ import type { import { curve, curveIntersectLine, + curveIntersectLineSegment, isPointWithinBounds, line, lineSegment, @@ -151,9 +152,9 @@ export const hitElementBoundText = ( * @param offset * @returns */ -export const intersectElementWithLine = ( +export const intersectElementWithLineSegment = ( element: ExcalidrawElement, - line: Line, + line: LineSegment, offset: number = 0, ): GlobalPoint[] => { switch (element.type) { @@ -164,19 +165,19 @@ export const intersectElementWithLine = ( case "embeddable": case "frame": case "magicframe": - return intersectRectanguloidWithLine(element, line, offset); + return intersectRectanguloidWithLineSegment(element, line, offset); case "diamond": - return intersectDiamondWithLine(element, line, offset); + return intersectDiamondWithLineSegment(element, line, offset); case "ellipse": - return intersectEllipseWithLine(element, line, offset); + return intersectEllipseWithLineSegment(element, line, offset); default: throw new Error(`Unimplemented element type '${element.type}'`); } }; -const intersectRectanguloidWithLine = ( +const intersectRectanguloidWithLineSegment = ( element: ExcalidrawRectanguloidElement, - l: Line, + l: LineSegment, offset: number, ): GlobalPoint[] => { const r = rectangle( @@ -280,13 +281,18 @@ const intersectRectanguloidWithLine = ( const sideIntersections: GlobalPoint[] = sides .map((s) => - lineSegmentIntersectionPoints(line(rotatedA, rotatedB), s), + lineSegmentIntersectionPoints( + lineSegment(rotatedA, rotatedB), + s, + ), ) .filter((x) => x != null) .map((j) => pointRotateRads(j!, center, element.angle)); const cornerIntersections: GlobalPoint[] = corners - .flatMap((t) => curveIntersectLine(t, line(rotatedA, rotatedB))) + .flatMap((t) => + curveIntersectLineSegment(t, lineSegment(rotatedA, rotatedB)), + ) .filter((i) => i != null) .map((j) => pointRotateRads(j, center, element.angle)); @@ -306,9 +312,9 @@ const intersectRectanguloidWithLine = ( * @param b * @returns */ -const intersectDiamondWithLine = ( +const intersectDiamondWithLineSegment = ( element: ExcalidrawDiamondElement, - l: Line, + l: LineSegment, offset: number = 0, ): GlobalPoint[] => { const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] = @@ -406,7 +412,10 @@ const intersectDiamondWithLine = ( const sides: GlobalPoint[] = [topRight, bottomRight, bottomLeft, topLeft] .map((s) => - lineSegmentIntersectionPoints(line(rotatedA, rotatedB), s), + lineSegmentIntersectionPoints( + lineSegment(rotatedA, rotatedB), + s, + ), ) .filter((p): p is GlobalPoint => p != null) // Rotate back intersection points @@ -433,9 +442,9 @@ const intersectDiamondWithLine = ( * @param b * @returns */ -const intersectEllipseWithLine = ( +const intersectEllipseWithLineSegment = ( element: ExcalidrawEllipseElement, - l: Line, + l: LineSegment, offset: number = 0, ): GlobalPoint[] => { const center = pointFrom( diff --git a/packages/math/curve.ts b/packages/math/curve.ts index d564daddea..3f98e83efc 100644 --- a/packages/math/curve.ts +++ b/packages/math/curve.ts @@ -1,5 +1,18 @@ +import type { Bounds } from "../excalidraw/element/bounds"; +import { isPointOnLineSegment } from "./line"; import { isPoint, pointDistance, pointFrom } from "./point"; -import type { Curve, GlobalPoint, Line, LocalPoint } from "./types"; +import { + rectangle, + rectangleIntersectLine, + rectangleIntersectLineSegment, +} from "./rectangle"; +import type { + Curve, + GlobalPoint, + Line, + LineSegment, + LocalPoint, +} from "./types"; /** * @@ -18,21 +31,24 @@ export function curve( return [a, b, c, d] as Curve; } -function bezierCoeffs(P0: number, P1: number, P2: number, P3: number) { - const Z = []; - Z[0] = -P0 + 3 * P1 + -3 * P2 + P3; - Z[1] = 3 * P0 - 6 * P1 + 3 * P2; - Z[2] = -3 * P0 + 3 * P1; - Z[3] = P0; - - return Z; -} - /*computes intersection between a cubic spline and a line segment*/ export function curveIntersectLine( c: Curve, l: Line, ): Point[] { + const bounds = curveBounds(c); + if ( + rectangleIntersectLine( + rectangle( + pointFrom(bounds[0], bounds[1]), + pointFrom(bounds[2], bounds[3]), + ), + l, + ).length === 0 + ) { + return []; + } + const C1 = pointFrom( Math.round(c[0][0] * 1e4) / 1e4, Math.round(c[0][1] * 1e4) / 1e4, @@ -49,68 +65,22 @@ export function curveIntersectLine( Math.round(c[3][0] * 1e4) / 1e4, Math.round(c[3][1] * 1e4) / 1e4, ); - const L1 = pointFrom( - Math.round(l[0][0] * 1e4) / 1e4, - Math.round(l[0][1] * 1e4) / 1e4, - ); - const L2 = pointFrom( - Math.round(l[1][0] * 1e4) / 1e4, - Math.round(l[1][1] * 1e4) / 1e4, - ); const [px, py] = [ [C1[0], C2[0], C3[0], C4[0]], [C1[1], C2[1], C3[1], C4[1]], ]; - const [lx, ly] = [ - [L1[0], L2[0]], - [L1[1], L2[1]], - ]; - const X = []; - - const A = ly[1] - ly[0]; //A=y2-y1 - const B = lx[0] - lx[1]; //B=x1-x2 - const C = lx[0] * (ly[0] - ly[1]) + ly[0] * (lx[1] - lx[0]); //C=x1*(y1-y2)+y1*(x2-x1) - const bx = bezierCoeffs(px[0], px[1], px[2], px[3]); const by = bezierCoeffs(py[0], py[1], py[2], py[3]); - - const P = []; - P[0] = A * bx[0] + B * by[0]; /*t^3*/ - P[1] = A * bx[1] + B * by[1]; /*t^2*/ - P[2] = A * bx[2] + B * by[2]; /*t*/ - P[3] = A * bx[3] + B * by[3] + C; /*1*/ - - const r = cubicRoots(P); - + const r = curveCubicRoots([bx, by], l); + const X = []; const intersections = []; - /*verify the roots are in bounds of the linear segment*/ + // verify the roots are in bounds of the linear segment for (let i = 0; i < 3; i++) { const t = r[i]; X[0] = bx[0] * t * t * t + bx[1] * t * t + bx[2] * t + bx[3]; X[1] = by[0] * t * t * t + by[1] * t * t + by[2] * t + by[3]; - // /*above is intersection point assuming infinitely long line segment, - // make sure we are also in bounds of the line*/ - // let s; - // if (lx[1] - lx[0] !== 0) { - // /*if not vertical line*/ - // s = (X[0] - lx[0]) / (lx[1] - lx[0]); - // } else { - // s = (X[1] - ly[0]) / (ly[1] - ly[0]); - // } - - /*in bounds?*/ - if ( - t < 0 || - t > 1.0 //|| - // s < 0 || - // s > 1.0 - ) { - X[0] = -100; /*move off screen*/ - X[1] = -100; - } - if (!isNaN(X[0]) && !isNaN(X[1])) { intersections.push(pointFrom(X[0], X[1])); } @@ -119,55 +89,68 @@ export function curveIntersectLine( return intersections; } -function cubicRoots([a, b, c, d]: number[]): number[] { - const A = b / a; - const B = c / a; - const C = d / a; - - //var Q, R, D, S, T, Im; - - const Q = (3 * B - Math.pow(A, 2)) / 9; - const R = (9 * A * B - 27 * C - 2 * Math.pow(A, 3)) / 54; - const D = Math.pow(Q, 3) + Math.pow(R, 2); // polynomial discriminant - - const t = []; - let Im = 0.0; - - if (D >= 0) { - // complex or duplicate roots - const S = - Math.sign(R + Math.sqrt(D)) * Math.pow(Math.abs(R + Math.sqrt(D)), 1 / 3); - const T = - Math.sign(R - Math.sqrt(D)) * Math.pow(Math.abs(R - Math.sqrt(D)), 1 / 3); +export function curveIntersectLineSegment< + Point extends GlobalPoint | LocalPoint, +>(c: Curve, l: LineSegment): Point[] { + const bounds = curveBounds(c); + if ( + rectangleIntersectLineSegment( + rectangle( + pointFrom(bounds[0], bounds[1]), + pointFrom(bounds[2], bounds[3]), + ), + l, + ).length === 0 + ) { + return []; + } - t[0] = -A / 3 + (S + T); // real root - t[1] = -A / 3 - (S + T) / 2; // real part of complex root - t[2] = -A / 3 - (S + T) / 2; // real part of complex root - Im = Math.abs((Math.sqrt(3) * (S - T)) / 2); // complex part of root pair + const C1 = pointFrom( + Math.round(c[0][0] * 1e4) / 1e4, + Math.round(c[0][1] * 1e4) / 1e4, + ); + const C2 = pointFrom( + Math.round(c[1][0] * 1e4) / 1e4, + Math.round(c[1][1] * 1e4) / 1e4, + ); + const C3 = pointFrom( + Math.round(c[2][0] * 1e4) / 1e4, + Math.round(c[2][1] * 1e4) / 1e4, + ); + const C4 = pointFrom( + Math.round(c[3][0] * 1e4) / 1e4, + Math.round(c[3][1] * 1e4) / 1e4, + ); + const [px, py] = [ + [C1[0], C2[0], C3[0], C4[0]], + [C1[1], C2[1], C3[1], C4[1]], + ]; + const bx = bezierCoeffs(px[0], px[1], px[2], px[3]); + const by = bezierCoeffs(py[0], py[1], py[2], py[3]); - /*discard complex roots*/ - if (Im !== 0) { - t[1] = -1; - t[2] = -1; - } - } // distinct real roots - else { - const th = Math.acos(R / Math.sqrt(-Math.pow(Q, 3))); + const r = curveCubicRoots([bx, by], l); + const X = []; + const intersections: Point[] = []; + // verify the roots are in bounds of the linear segment + for (let i = 0; i < 3; i++) { + const t = r[i]; - t[0] = 2 * Math.sqrt(-Q) * Math.cos(th / 3) - A / 3; - t[1] = 2 * Math.sqrt(-Q) * Math.cos((th + 2 * Math.PI) / 3) - A / 3; - t[2] = 2 * Math.sqrt(-Q) * Math.cos((th + 4 * Math.PI) / 3) - A / 3; - Im = 0.0; - } + X[0] = bx[0] * t * t * t + bx[1] * t * t + bx[2] * t + bx[3]; + X[1] = by[0] * t * t * t + by[1] * t * t + by[2] * t + by[3]; - /*discard out of spec roots*/ - for (let i = 0; i < 3; i++) { - if (t[i] < 0 || t[i] > 1.0) { - t[i] = -1; + // Above is intersection point assuming infinitely long line segment, + // make sure we are also in bounds of the line + const candidate = pointFrom(X[0], X[1]); + if ( + !isNaN(X[0]) && + !isNaN(X[1]) && + isPointOnLineSegment(l, candidate, 1e-2) + ) { + intersections.push(candidate); } } - return t.filter((t) => t !== -1); + return intersections; } /** @@ -279,3 +262,103 @@ export function isCurve

( isPoint(v[3]) ); } + +function curveCubicRoots( + [bx, by]: [number[], number[]], + l: [Point, Point], +) { + const L1 = pointFrom( + Math.round(l[0][0] * 1e4) / 1e4, + Math.round(l[0][1] * 1e4) / 1e4, + ); + const L2 = pointFrom( + Math.round(l[1][0] * 1e4) / 1e4, + Math.round(l[1][1] * 1e4) / 1e4, + ); + + const [lx, ly] = [ + [L1[0], L2[0]], + [L1[1], L2[1]], + ]; + + const A = ly[1] - ly[0]; //A=y2-y1 + const B = lx[0] - lx[1]; //B=x1-x2 + const C = lx[0] * (ly[0] - ly[1]) + ly[0] * (lx[1] - lx[0]); //C=x1*(y1-y2)+y1*(x2-x1) + + const P = []; + P[0] = A * bx[0] + B * by[0]; /*t^3*/ + P[1] = A * bx[1] + B * by[1]; /*t^2*/ + P[2] = A * bx[2] + B * by[2]; /*t*/ + P[3] = A * bx[3] + B * by[3] + C; /*1*/ + + return cubicRoots(P); +} + +function cubicRoots([a, b, c, d]: number[]): number[] { + const A = b / Math.max(a, 1e-10); + const B = c / Math.max(a, 1e-10); + const C = d / Math.max(a, 1e-10); + + //var Q, R, D, S, T, Im; + + const Q = (3 * B - Math.pow(A, 2)) / 9; + const R = (9 * A * B - 27 * C - 2 * Math.pow(A, 3)) / 54; + const D = Math.pow(Q, 3) + Math.pow(R, 2); // polynomial discriminant + + const t = []; + let Im = 0.0; + + if (D >= 0) { + // complex or duplicate roots + const S = + Math.sign(R + Math.sqrt(D)) * Math.pow(Math.abs(R + Math.sqrt(D)), 1 / 3); + const T = + Math.sign(R - Math.sqrt(D)) * Math.pow(Math.abs(R - Math.sqrt(D)), 1 / 3); + + t[0] = -A / 3 + (S + T); // real root + t[1] = -A / 3 - (S + T) / 2; // real part of complex root + t[2] = -A / 3 - (S + T) / 2; // real part of complex root + Im = Math.abs((Math.sqrt(3) * (S - T)) / 2); // complex part of root pair + + /*discard complex roots*/ + if (Im !== 0) { + t[1] = -1; + t[2] = -1; + } + } // distinct real roots + else { + const th = Math.acos(R / Math.sqrt(-Math.pow(Q, 3))); + + t[0] = 2 * Math.sqrt(-Q) * Math.cos(th / 3) - A / 3; + t[1] = 2 * Math.sqrt(-Q) * Math.cos((th + 2 * Math.PI) / 3) - A / 3; + t[2] = 2 * Math.sqrt(-Q) * Math.cos((th + 4 * Math.PI) / 3) - A / 3; + Im = 0.0; + } + + /*discard out of spec roots*/ + for (let i = 0; i < 3; i++) { + if (t[i] < 0 || t[i] > 1.0) { + t[i] = -1; + } + } + + return t.filter((t) => t !== -1); +} + +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)]; +} + +function bezierCoeffs(P0: number, P1: number, P2: number, P3: number) { + return [ + Math.max(-P0 + 3 * P1 + -3 * P2 + P3, 1e-4), + 3 * P0 - 6 * P1 + 3 * P2, + -3 * P0 + 3 * P1, + P0, + ]; +} diff --git a/packages/math/line.test.ts b/packages/math/line.test.ts index d7bd247fd5..8e398c42a1 100644 --- a/packages/math/line.test.ts +++ b/packages/math/line.test.ts @@ -35,7 +35,7 @@ describe("line-segment intersections", () => { it("should correctly detect intersection", () => { expect( lineSegmentIntersectionPoints( - line(pointFrom(0, 0), pointFrom(5, 0)), + lineSegment(pointFrom(0, 0), pointFrom(5, 0)), lineSegment(pointFrom(2, -2), pointFrom(3, 2)), ), ).toEqual(pointFrom(2.5, 0)); @@ -43,7 +43,7 @@ describe("line-segment intersections", () => { it("should correctly detect non-intersection", () => { expect( lineSegmentIntersectionPoints( - line(pointFrom(0, 0), pointFrom(5, 0)), + lineSegment(pointFrom(0, 0), pointFrom(5, 0)), lineSegment(pointFrom(3, 1), pointFrom(4, 4)), ), ).toEqual(null); diff --git a/packages/math/line.ts b/packages/math/line.ts index 34933e6d6d..af9787763f 100644 --- a/packages/math/line.ts +++ b/packages/math/line.ts @@ -1,3 +1,4 @@ +import { EPSILON } from "../excalidraw/constants"; import { pointCenter, pointFrom, pointRotateRads } from "./point"; import { pointOnLineSegment } from "./segment"; import type { @@ -7,6 +8,7 @@ import type { LocalPoint, Radians, } from "./types"; +import { vectorCross, vectorFromPoint } from "./vector"; /** * Create a line from two points. @@ -101,11 +103,44 @@ export const linesIntersectAt = ( */ export function lineSegmentIntersectionPoints< Point extends GlobalPoint | LocalPoint, ->(l: Line, s: LineSegment): Point | null { - const candidate = linesIntersectAt(l, line(s[0], s[1])); - if (!candidate || !pointOnLineSegment(candidate, s)) { +>(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; } + +export function isPointOnLineSegment

( + l: LineSegment

, + p: P, + epsilon: number = EPSILON, +) { + if (!isPointOnLine(line(l[0], l[1]), p, epsilon)) { + return false; + } + + const minX = Math.min(l[0][0], l[1][0]); + const minY = Math.min(l[0][1], l[1][1]); + const maxX = Math.max(l[0][0], l[1][0]); + const maxY = Math.max(l[0][1], l[1][1]); + + return p[0] >= minX && p[0] <= maxX && p[1] >= minY && p[1] <= maxY; +} + +export function isPointOnLine

( + l: Line

, + p: P, + epsilon: number = EPSILON, +) { + const p1 = vectorFromPoint(l[1], l[0]); + const p2 = vectorFromPoint(p, l[0]); + + const r = vectorCross(p1, p2); + return Math.abs(r) < epsilon; +} diff --git a/packages/math/rectangle.ts b/packages/math/rectangle.ts index c850b028ca..88df59f9ea 100644 --- a/packages/math/rectangle.ts +++ b/packages/math/rectangle.ts @@ -1,7 +1,14 @@ import { invariant } from "../excalidraw/utils"; +import { line, lineSegmentIntersectionPoints, linesIntersectAt } from "./line"; import { pointFrom } from "./point"; import { distanceToLineSegment, lineSegment } from "./segment"; -import type { GlobalPoint, LocalPoint, Rectangle } from "./types"; +import type { + GlobalPoint, + Line, + LineSegment, + LocalPoint, + Rectangle, +} from "./types"; export function rectangle

( topLeft: P, @@ -39,3 +46,30 @@ export function rectangleDistanceFromPoint< return Math.min(...sides.map((side) => distanceToLineSegment(p, side))); } + +export function rectangleIntersectLine( + r: Rectangle, + l: Line, +): Point[] { + return [ + line(r[0], pointFrom(r[1][0], r[0][1])), + line(pointFrom(r[1][0], r[0][1]), r[1]), + line(r[1], pointFrom(r[0][0], r[1][1])), + line(pointFrom(r[0][0], r[1][1]), r[0]), + ] + .map((s) => linesIntersectAt(l, s)) + .filter((i): i is Point => !!i); +} + +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); +}