Even more stable curve detection

feat/remove-ga
Mark Tolmacs 3 weeks ago
parent 16d3064a2f
commit 9da2917a87
No known key found for this signature in database

@ -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;

@ -71,7 +71,7 @@ import {
vectorRotate,
} from "../../math";
import { distanceToBindableElement } from "./distance";
import { intersectElementWithLine } from "./collision";
import { intersectElementWithLineSegment } from "./collision";
export type SuggestedBinding =
| NonDeleted<ExcalidrawBindableElement>
@ -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<GlobalPoint>(
// 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];
}
}

@ -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 = <Point extends GlobalPoint | LocalPoint>(
* @param offset
* @returns
*/
export const intersectElementWithLine = (
export const intersectElementWithLineSegment = (
element: ExcalidrawElement,
line: Line<GlobalPoint>,
line: LineSegment<GlobalPoint>,
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<GlobalPoint>,
l: LineSegment<GlobalPoint>,
offset: number,
): GlobalPoint[] => {
const r = rectangle(
@ -280,13 +281,18 @@ const intersectRectanguloidWithLine = (
const sideIntersections: GlobalPoint[] = sides
.map((s) =>
lineSegmentIntersectionPoints(line<GlobalPoint>(rotatedA, rotatedB), s),
lineSegmentIntersectionPoints(
lineSegment<GlobalPoint>(rotatedA, rotatedB),
s,
),
)
.filter((x) => x != null)
.map((j) => pointRotateRads<GlobalPoint>(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<GlobalPoint>,
l: LineSegment<GlobalPoint>,
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<GlobalPoint>(rotatedA, rotatedB), s),
lineSegmentIntersectionPoints(
lineSegment<GlobalPoint>(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<GlobalPoint>,
l: LineSegment<GlobalPoint>,
offset: number = 0,
): GlobalPoint[] => {
const center = pointFrom<GlobalPoint>(

@ -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<Point extends GlobalPoint | LocalPoint>(
return [a, b, c, d] as Curve<Point>;
}
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<Point extends GlobalPoint | LocalPoint>(
c: Curve<Point>,
l: Line<Point>,
): 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<Point>(
Math.round(c[0][0] * 1e4) / 1e4,
Math.round(c[0][1] * 1e4) / 1e4,
@ -49,68 +65,22 @@ export function curveIntersectLine<Point extends GlobalPoint | LocalPoint>(
Math.round(c[3][0] * 1e4) / 1e4,
Math.round(c[3][1] * 1e4) / 1e4,
);
const L1 = pointFrom<Point>(
Math.round(l[0][0] * 1e4) / 1e4,
Math.round(l[0][1] * 1e4) / 1e4,
);
const L2 = pointFrom<Point>(
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<Point extends GlobalPoint | LocalPoint>(
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<Point>, l: LineSegment<Point>): 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<Point>(
Math.round(c[0][0] * 1e4) / 1e4,
Math.round(c[0][1] * 1e4) / 1e4,
);
const C2 = pointFrom<Point>(
Math.round(c[1][0] * 1e4) / 1e4,
Math.round(c[1][1] * 1e4) / 1e4,
);
const C3 = pointFrom<Point>(
Math.round(c[2][0] * 1e4) / 1e4,
Math.round(c[2][1] * 1e4) / 1e4,
);
const C4 = pointFrom<Point>(
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<Point>(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<P extends GlobalPoint | LocalPoint>(
isPoint(v[3])
);
}
function curveCubicRoots<Point extends GlobalPoint | LocalPoint>(
[bx, by]: [number[], number[]],
l: [Point, Point],
) {
const L1 = pointFrom<Point>(
Math.round(l[0][0] * 1e4) / 1e4,
Math.round(l[0][1] * 1e4) / 1e4,
);
const L2 = pointFrom<Point>(
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<Point extends GlobalPoint | LocalPoint>(
c: Curve<Point>,
): 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,
];
}

@ -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);

@ -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 = <Point extends GlobalPoint | LocalPoint>(
*/
export function lineSegmentIntersectionPoints<
Point extends GlobalPoint | LocalPoint,
>(l: Line<Point>, s: LineSegment<Point>): Point | null {
const candidate = linesIntersectAt(l, line(s[0], s[1]));
if (!candidate || !pointOnLineSegment(candidate, s)) {
>(l: LineSegment<Point>, s: LineSegment<Point>): 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<P extends GlobalPoint | LocalPoint>(
l: LineSegment<P>,
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<P extends GlobalPoint | LocalPoint>(
l: Line<P>,
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;
}

@ -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<P extends GlobalPoint | LocalPoint>(
topLeft: P,
@ -39,3 +46,30 @@ export function rectangleDistanceFromPoint<
return Math.min(...sides.map((side) => distanceToLineSegment(p, side)));
}
export function rectangleIntersectLine<Point extends LocalPoint | GlobalPoint>(
r: Rectangle<Point>,
l: Line<Point>,
): 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<Point>, l: LineSegment<Point>): 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);
}

Loading…
Cancel
Save