snapshot of https://github.com/excalidraw/excalidraw.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Tests / test (push) Failing after 15s

This commit is contained in:
upstream-mirror
2026-09-16 05:31:04 +05:00
commit 1a8d07ca6c
1286 changed files with 360144 additions and 0 deletions

View File

@@ -0,0 +1,105 @@
import "@excalidraw/utils/test-utils";
import {
bezierEquation,
curve,
curveClosestParameter,
curveIntersectLineSegment,
curvePointDistance,
} from "../src/curve";
import { pointFrom } from "../src/point";
import { lineSegment } from "../src/segment";
describe("Math curve", () => {
describe("line segment intersection", () => {
it("point is found when control points are the same", () => {
const c = curve(
pointFrom(100, 0),
pointFrom(100, 100),
pointFrom(100, 100),
pointFrom(0, 100),
);
const l = lineSegment(pointFrom(0, 0), pointFrom(200, 200));
expect(curveIntersectLineSegment(c, l)).toCloselyEqualPoints([
[87.5, 87.5],
]);
});
it("point is found when control points aren't the same", () => {
const c = curve(
pointFrom(100, 0),
pointFrom(100, 60),
pointFrom(60, 100),
pointFrom(0, 100),
);
const l = lineSegment(pointFrom(0, 0), pointFrom(200, 200));
expect(curveIntersectLineSegment(c, l)).toCloselyEqualPoints([
[72.5, 72.5],
]);
});
it("points are found when curve is sliced at 3 points", () => {
const c = curve(
pointFrom(-50, -50),
pointFrom(10, -50),
pointFrom(10, 50),
pointFrom(50, 50),
);
const l = lineSegment(pointFrom(10, -60), pointFrom(10, 60));
expect(curveIntersectLineSegment(c, l)).toCloselyEqualPoints([
[9.99, 5.05],
]);
});
it("can be detected where the determinant is overly precise", () => {
const c = curve(
pointFrom(41.028864759926016, 12.226249068355052),
pointFrom(41.028864759926016, 33.55958240168839),
pointFrom(30.362198093259348, 44.22624906835505),
pointFrom(9.028864759926016, 44.22624906835505),
);
const l = lineSegment(
pointFrom(-82.30963544324186, -41.19949363038283),
pointFrom(188.2149592542487, 134.75505940984908),
);
expect(curveIntersectLineSegment(c, l)).toCloselyEqualPoints([
[34.4, 34.71],
]);
});
});
describe("point closest to other", () => {
it("point can be found", () => {
const c = curve(
pointFrom(-50, -50),
pointFrom(10, -50),
pointFrom(10, 50),
pointFrom(50, 50),
);
const p = pointFrom(0, 0);
expect([
bezierEquation(c, curveClosestParameter(c, p, 1e-3)),
]).toCloselyEqualPoints([[5.965462100367372, -3.04104878946646]]);
});
});
describe("point shortest distance", () => {
it("can be determined", () => {
const c = curve(
pointFrom(-50, -50),
pointFrom(10, -50),
pointFrom(10, 50),
pointFrom(50, 50),
);
const p = pointFrom(0, 0);
expect(curvePointDistance(c, p)).toBeCloseTo(6.695873043213627);
});
});
});

View File

@@ -0,0 +1,127 @@
import {
ellipse,
ellipseSegmentInterceptPoints,
ellipseIncludesPoint,
ellipseTouchesPoint,
ellipseLineIntersectionPoints,
} from "../src/ellipse";
import { line } from "../src/line";
import { pointFrom } from "../src/point";
import { lineSegment } from "../src/segment";
import type { Ellipse, GlobalPoint } from "../src/types";
describe("point and ellipse", () => {
it("point on ellipse", () => {
const target: Ellipse<GlobalPoint> = ellipse(pointFrom(1, 2), 2, 1);
[
pointFrom(1, 3),
pointFrom(1, 1),
pointFrom(3, 2),
pointFrom(-1, 2),
].forEach((p) => {
expect(ellipseTouchesPoint(p, target)).toBe(true);
});
expect(ellipseTouchesPoint(pointFrom(-0.4, 2.7), target, 0.1)).toBe(true);
expect(ellipseTouchesPoint(pointFrom(-0.4, 2.71), target, 0.01)).toBe(true);
expect(ellipseTouchesPoint(pointFrom(2.4, 2.7), target, 0.1)).toBe(true);
expect(ellipseTouchesPoint(pointFrom(2.4, 2.71), target, 0.01)).toBe(true);
expect(ellipseTouchesPoint(pointFrom(2, 1.14), target, 0.1)).toBe(true);
expect(ellipseTouchesPoint(pointFrom(2, 1.14), target, 0.01)).toBe(true);
expect(ellipseTouchesPoint(pointFrom(0, 1.14), target, 0.1)).toBe(true);
expect(ellipseTouchesPoint(pointFrom(0, 1.14), target, 0.01)).toBe(true);
expect(ellipseTouchesPoint(pointFrom(0, 2.8), target)).toBe(false);
expect(ellipseTouchesPoint(pointFrom(2, 1.2), target)).toBe(false);
});
it("point in ellipse", () => {
const target: Ellipse<GlobalPoint> = ellipse(pointFrom(0, 0), 2, 1);
[
pointFrom(0, 1),
pointFrom(0, -1),
pointFrom(2, 0),
pointFrom(-2, 0),
].forEach((p) => {
expect(ellipseIncludesPoint(p, target)).toBe(true);
});
expect(ellipseIncludesPoint(pointFrom(-1, 0.8), target)).toBe(true);
expect(ellipseIncludesPoint(pointFrom(1, -0.8), target)).toBe(true);
// Point on outline
expect(ellipseIncludesPoint(pointFrom(2, 0), target)).toBe(true);
expect(ellipseIncludesPoint(pointFrom(-1, 1), target)).toBe(false);
expect(ellipseIncludesPoint(pointFrom(-1.4, 0.8), target)).toBe(false);
});
});
describe("segment and ellipse", () => {
it("detects outside segment", () => {
const e = ellipse(pointFrom(0, 0), 2, 2);
expect(
ellipseSegmentInterceptPoints(
e,
lineSegment<GlobalPoint>(pointFrom(-100, 0), pointFrom(-10, 0)),
),
).toEqual([]);
expect(
ellipseSegmentInterceptPoints(
e,
lineSegment<GlobalPoint>(pointFrom(-10, 0), pointFrom(10, 0)),
),
).toEqual([pointFrom(-2, 0), pointFrom(2, 0)]);
expect(
ellipseSegmentInterceptPoints(
e,
lineSegment<GlobalPoint>(pointFrom(-10, -2), pointFrom(10, -2)),
),
).toEqual([pointFrom(0, -2)]);
expect(
ellipseSegmentInterceptPoints(
e,
lineSegment<GlobalPoint>(pointFrom(0, -1), pointFrom(0, 1)),
),
).toEqual([]);
});
});
describe("line and ellipse", () => {
const e = ellipse(pointFrom(0, 0), 2, 2);
it("detects outside line", () => {
expect(
ellipseLineIntersectionPoints(
e,
line<GlobalPoint>(pointFrom(-10, -10), pointFrom(10, -10)),
),
).toEqual([]);
});
it("detects line intersecting ellipse", () => {
expect(
ellipseLineIntersectionPoints(
e,
line<GlobalPoint>(pointFrom(0, -1), pointFrom(0, 1)),
),
).toEqual([pointFrom(0, 2), pointFrom(0, -2)]);
expect(
ellipseLineIntersectionPoints(
e,
line<GlobalPoint>(pointFrom(-100, 0), pointFrom(-10, 0)),
).map(([x, y]) => pointFrom(Math.round(x), Math.round(y))),
).toEqual([pointFrom(2, 0), pointFrom(-2, 0)]);
});
it("detects line touching ellipse", () => {
expect(
ellipseLineIntersectionPoints(
e,
line<GlobalPoint>(pointFrom(-2, -2), pointFrom(2, -2)),
),
).toEqual([pointFrom(0, -2)]);
});
});

View File

@@ -0,0 +1,31 @@
import { line, linesIntersectAt } from "../src/line";
import { pointFrom } from "../src/point";
describe("line-line intersections", () => {
it("should correctly detect intersection at origin", () => {
expect(
linesIntersectAt(
line(pointFrom(-5, -5), pointFrom(5, 5)),
line(pointFrom(5, -5), pointFrom(-5, 5)),
),
).toEqual(pointFrom(0, 0));
});
it("should correctly detect intersection at non-origin", () => {
expect(
linesIntersectAt(
line(pointFrom(0, 0), pointFrom(10, 10)),
line(pointFrom(10, 0), pointFrom(0, 10)),
),
).toEqual(pointFrom(5, 5));
});
it("should correctly detect parallel lines", () => {
expect(
linesIntersectAt(
line(pointFrom(0, 0), pointFrom(0, 10)),
line(pointFrom(10, 0), pointFrom(10, 10)),
),
).toBe(null);
});
});

View File

@@ -0,0 +1,200 @@
import {
centroid,
elongation,
kurtosis,
orientPrincipalAxes,
principalAxes,
principalCoords,
skewness,
standardizedMoment,
} from "../src/pca";
import { pointFrom } from "../src/point";
import type { GlobalPoint } from "../src/types";
const rotate = (points: GlobalPoint[], angle: number, scale = 1) =>
points.map(([x, y]) =>
pointFrom<GlobalPoint>(
(x * Math.cos(angle) - y * Math.sin(angle)) * scale + 17,
(x * Math.sin(angle) + y * Math.cos(angle)) * scale - 4,
),
);
const horizontalSpread: GlobalPoint[] = Array.from({ length: 21 }, (_, i) =>
pointFrom<GlobalPoint>(i - 10, 0),
);
describe("centroid", () => {
it("averages the points", () => {
expect(
centroid([
pointFrom<GlobalPoint>(0, 0),
pointFrom<GlobalPoint>(4, 0),
pointFrom<GlobalPoint>(2, 6),
]),
).toEqual([2, 2]);
});
});
describe("principalAxes", () => {
it("finds the direction the points spread along", () => {
const axes = principalAxes(horizontalSpread);
expect(Math.abs(axes.major[0])).toBeCloseTo(1);
expect(axes.major[1]).toBeCloseTo(0);
expect(axes.majorVariance).toBeGreaterThan(axes.minorVariance);
});
it("tracks the points when they are rotated", () => {
const axes = principalAxes(rotate(horizontalSpread, Math.PI / 6));
// Up to the sign of the eigenvector, the major axis is the rotated x axis.
expect(Math.abs(axes.major[0])).toBeCloseTo(Math.cos(Math.PI / 6));
expect(Math.abs(axes.major[1])).toBeCloseTo(Math.sin(Math.PI / 6));
});
it("keeps the axes orthonormal", () => {
const { major, minor } = principalAxes([
...rotate(horizontalSpread, 1.1),
pointFrom<GlobalPoint>(3, 9),
]);
expect(Math.hypot(...major)).toBeCloseTo(1);
expect(Math.hypot(...minor)).toBeCloseTo(1);
expect(major[0] * minor[0] + major[1] * minor[1]).toBeCloseTo(0);
});
it("falls back to the coordinate axes for an already diagonal covariance", () => {
const axes = principalAxes([
pointFrom<GlobalPoint>(-1, 0),
pointFrom<GlobalPoint>(1, 0),
pointFrom<GlobalPoint>(0, -5),
pointFrom<GlobalPoint>(0, 5),
]);
expect(axes.major).toEqual([0, 1]);
});
});
describe("principalCoords", () => {
it("undoes translation, rotation and scale", () => {
const points = rotate(horizontalSpread, 0.7, 3);
const axes = principalAxes(points);
const coords = principalCoords(
points,
axes,
1 / Math.sqrt(axes.majorVariance),
);
// The canonical form of a horizontal spread is the same spread, whatever
// was done to it in between.
const us = coords.map(([u]) => u).sort((a, b) => a - b);
expect(coords.every(([, v]) => Math.abs(v) < 1e-9)).toBe(true);
expect(Math.abs(us[0])).toBeCloseTo(Math.abs(us[us.length - 1]));
expect(Math.abs(us[0])).toBeCloseTo(1.65, 1);
});
});
describe("orientPrincipalAxes", () => {
it("points the major axis at the dense end", () => {
// Sparse to the left of the origin, piled up to the right of it.
const lopsided: GlobalPoint[] = [
...Array.from({ length: 5 }, (_, i) =>
pointFrom<GlobalPoint>(-100 + i * 10, 0),
),
...Array.from({ length: 30 }, (_, i) => pointFrom<GlobalPoint>(i, 0)),
];
for (const angle of [0, 1, 2.5, 4]) {
const points = rotate(lopsided, angle);
const axes = orientPrincipalAxes(points, principalAxes(points));
const us = principalCoords(points, axes).map(([u]) => u);
// The dense end is at +u, i.e. the projection is skewed negative.
expect(skewness(us)).toBeLessThan(0);
}
});
it("leaves a symmetric point set alone", () => {
const axes = principalAxes(horizontalSpread);
expect(orientPrincipalAxes(horizontalSpread, axes)).toEqual(axes);
});
});
describe("elongation", () => {
it("is 0 for a straight spread and 1 for an isotropic one", () => {
expect(elongation(principalAxes(horizontalSpread))).toBeCloseTo(0);
expect(
elongation(
principalAxes(
Array.from({ length: 36 }, (_, i) =>
pointFrom<GlobalPoint>(
Math.cos((i * Math.PI) / 18),
Math.sin((i * Math.PI) / 18),
),
),
),
),
).toBeCloseTo(1);
});
it("is invariant to rotation and scale", () => {
const points = Array.from({ length: 40 }, (_, i) =>
pointFrom<GlobalPoint>(Math.cos(i / 6) * 4, Math.sin(i / 6)),
);
expect(elongation(principalAxes(rotate(points, 0.9, 7)))).toBeCloseTo(
elongation(principalAxes(points)),
);
});
});
describe("standardizedMoment", () => {
it("is 0 for a sample with no spread", () => {
expect(standardizedMoment([3, 3, 3], 3)).toBe(0);
});
it("is unchanged by shifting and scaling the sample", () => {
const sample = [1, 2, 2, 3, 9, 4];
expect(
standardizedMoment(
sample.map((v) => v * 5 + 100),
3,
),
).toBeCloseTo(standardizedMoment(sample, 3));
});
});
describe("skewness", () => {
it("is 0 for a symmetric sample", () => {
expect(skewness([-2, -1, 0, 1, 2])).toBeCloseTo(0);
});
it("is positive when the tail runs to the right of the mass", () => {
expect(skewness([1, 1, 1, 1, 1, 9])).toBeGreaterThan(0);
expect(skewness([-9, -1, -1, -1, -1, -1])).toBeLessThan(0);
});
});
describe("kurtosis", () => {
it("separates a uniform sample from a normal-ish one", () => {
const uniform = Array.from({ length: 101 }, (_, i) => i / 100);
// 1.8 for a uniform sample, 3 for a normal one.
expect(kurtosis(uniform)).toBeCloseTo(1.8, 1);
});
it("is invariant to rotation, via the principal frame", () => {
const points = Array.from({ length: 50 }, (_, i) =>
pointFrom<GlobalPoint>(i - 25, ((i % 5) - 2) * 0.3),
);
const project = (pts: GlobalPoint[]) => {
const axes = principalAxes(pts);
return kurtosis(principalCoords(pts, axes).map(([u]) => u));
};
expect(project(rotate(points, 1.3, 2))).toBeCloseTo(project(points));
});
});

View File

@@ -0,0 +1,25 @@
import { pointFrom, pointRotateRads } from "../src/point";
import type { Radians } from "../src/types";
describe("rotate", () => {
it("should rotate over (x2, y2) and return the rotated coordinates for (x1, y1)", () => {
const x1 = 10;
const y1 = 20;
const x2 = 20;
const y2 = 30;
const angle = (Math.PI / 2) as Radians;
const [rotatedX, rotatedY] = pointRotateRads(
pointFrom(x1, y1),
pointFrom(x2, y2),
angle,
);
expect([rotatedX, rotatedY]).toEqual([30, 20]);
const res2 = pointRotateRads(
pointFrom(rotatedX, rotatedY),
pointFrom(x2, y2),
-angle as Radians,
);
expect(res2).toEqual([x1, x2]);
});
});

View File

@@ -0,0 +1,120 @@
import { pointFrom } from "../src/point";
import {
convexHull,
polygonArea,
polygonSignedArea,
simplifyConvexPolygon,
} from "../src/polygon";
import type { GlobalPoint } from "../src/types";
const square: GlobalPoint[] = [
pointFrom(0, 0),
pointFrom(10, 0),
pointFrom(10, 10),
pointFrom(0, 10),
];
describe("polygonArea", () => {
it("measures a polygon, whichever way it winds", () => {
expect(polygonArea(square)).toBe(100);
expect(polygonArea([...square].reverse())).toBe(100);
});
it("ignores a repeated closing vertex", () => {
expect(polygonArea([...square, square[0]])).toBe(100);
});
it("reports the winding direction in the sign", () => {
expect(polygonSignedArea(square)).toBe(100);
expect(polygonSignedArea([...square].reverse())).toBe(-100);
});
});
describe("convexHull", () => {
it("drops the points inside the hull", () => {
const hull = convexHull([...square, pointFrom<GlobalPoint>(5, 5)]);
expect(hull).toHaveLength(4);
expect(hull).toEqual(expect.arrayContaining(square));
});
it("drops collinear points", () => {
expect(convexHull([...square, pointFrom<GlobalPoint>(5, 0)])).toHaveLength(
4,
);
});
it("wraps a point cloud whatever order it arrives in", () => {
const cloud: GlobalPoint[] = Array.from({ length: 30 }, (_, i) =>
pointFrom(Math.cos(i * 2.4) * 10, Math.sin(i * 2.4) * 10),
);
const hull = convexHull(cloud);
expect(polygonArea(hull)).toBeCloseTo(
polygonArea(convexHull([...cloud].reverse())),
);
// Every point is inside or on the hull it produced.
expect(polygonArea(hull)).toBeGreaterThan(250);
expect(polygonArea(hull)).toBeLessThanOrEqual(Math.PI * 100);
});
it("returns degenerate input as-is", () => {
const two = square.slice(0, 2);
expect(convexHull(two)).toEqual(two);
});
});
describe("simplifyConvexPolygon", () => {
const circle = (n: number): GlobalPoint[] =>
Array.from({ length: n }, (_, i) =>
pointFrom(
Math.cos((i * 2 * Math.PI) / n) * 100,
Math.sin((i * 2 * Math.PI) / n) * 100,
),
);
it("keeps the corners of a polygon and drops the wobble along its sides", () => {
// A square whose sides bulge slightly outward, as a hand-drawn one would.
const wobbly = convexHull([
...square,
pointFrom<GlobalPoint>(5, -0.2),
pointFrom<GlobalPoint>(10.2, 5),
pointFrom<GlobalPoint>(5, 10.2),
pointFrom<GlobalPoint>(-0.2, 5),
]);
expect(simplifyConvexPolygon(wobbly, (25 * Math.PI) / 180)).toHaveLength(4);
});
it("spreads an ellipse's turn over many corners", () => {
const corners = simplifyConvexPolygon(
convexHull(circle(64)),
(25 * Math.PI) / 180,
);
// A full turn is 360°, emitted in ~25° steps.
expect(corners.length).toBeGreaterThan(10);
expect(corners.length).toBeLessThanOrEqual(15);
});
it("does not depend on where the hull happens to start", () => {
const hull = convexHull([
...square,
pointFrom<GlobalPoint>(5, -0.2),
pointFrom<GlobalPoint>(10.2, 5),
]);
const rotated = [...hull.slice(2), ...hull.slice(0, 2)];
expect(simplifyConvexPolygon(rotated, (25 * Math.PI) / 180)).toHaveLength(
simplifyConvexPolygon(hull, (25 * Math.PI) / 180).length,
);
});
it("returns degenerate input as-is", () => {
const two = square.slice(0, 2);
expect(simplifyConvexPolygon(two, 0.4)).toEqual(two);
});
});

View File

@@ -0,0 +1,51 @@
import { rangeInclusive, rangeIntersection, rangesOverlap } from "../src/range";
describe("range overlap", () => {
const range1_4 = rangeInclusive(1, 4);
it("should overlap when range a contains range b", () => {
expect(rangesOverlap(range1_4, rangeInclusive(2, 3))).toBe(true);
expect(rangesOverlap(range1_4, range1_4)).toBe(true);
expect(rangesOverlap(range1_4, rangeInclusive(1, 3))).toBe(true);
expect(rangesOverlap(range1_4, rangeInclusive(2, 4))).toBe(true);
});
it("should overlap when range b contains range a", () => {
expect(rangesOverlap(rangeInclusive(2, 3), range1_4)).toBe(true);
expect(rangesOverlap(rangeInclusive(1, 3), range1_4)).toBe(true);
expect(rangesOverlap(rangeInclusive(2, 4), range1_4)).toBe(true);
});
it("should overlap when range a and b intersect", () => {
expect(rangesOverlap(range1_4, rangeInclusive(2, 5))).toBe(true);
});
});
describe("range intersection", () => {
const range1_4 = rangeInclusive(1, 4);
it("should intersect completely with itself", () => {
expect(rangeIntersection(range1_4, range1_4)).toEqual(range1_4);
});
it("should intersect irrespective of order", () => {
expect(rangeIntersection(range1_4, rangeInclusive(2, 3))).toEqual([2, 3]);
expect(rangeIntersection(rangeInclusive(2, 3), range1_4)).toEqual([2, 3]);
expect(rangeIntersection(range1_4, rangeInclusive(3, 5))).toEqual(
rangeInclusive(3, 4),
);
expect(rangeIntersection(rangeInclusive(3, 5), range1_4)).toEqual(
rangeInclusive(3, 4),
);
});
it("should intersect at the edge", () => {
expect(rangeIntersection(range1_4, rangeInclusive(4, 5))).toEqual(
rangeInclusive(4, 4),
);
});
it("should not intersect", () => {
expect(rangeIntersection(range1_4, rangeInclusive(5, 7))).toEqual(null);
});
});

View File

@@ -0,0 +1,45 @@
import { pointFrom } from "../src/point";
import {
lineSegment,
lineSegmentIntersectionPoints,
isLineSegment,
} from "../src/segment";
describe("line-segment intersections", () => {
it("should correctly detect intersection", () => {
expect(
lineSegmentIntersectionPoints(
lineSegment(pointFrom(0, 0), pointFrom(5, 0)),
lineSegment(pointFrom(2, -2), pointFrom(3, 2)),
),
).toEqual(pointFrom(2.5, 0));
});
it("should correctly detect non-intersection", () => {
expect(
lineSegmentIntersectionPoints(
lineSegment(pointFrom(0, 0), pointFrom(5, 0)),
lineSegment(pointFrom(3, 1), pointFrom(4, 4)),
),
).toEqual(null);
});
});
describe("isLineSegment validation", () => {
it("should return true for a valid segment", () => {
expect(
isLineSegment([
[0, 0],
[1, 1],
]),
).toBe(true);
});
it("should return false if second element is not a point", () => {
const invalidSegment = [[0, 0], "not-a-point"] as any;
expect(isLineSegment(invalidSegment)).toBe(false);
});
it("should return false for wrong length", () => {
expect(isLineSegment([[0, 0]])).toBe(false);
});
});

View File

@@ -0,0 +1,12 @@
import { isVector } from "../src/vector";
describe("Vector", () => {
test("isVector", () => {
expect(isVector([5, 5])).toBe(true);
expect(isVector([-5, -5])).toBe(true);
expect(isVector([5, 0.5])).toBe(true);
expect(isVector(null)).toBe(false);
expect(isVector(undefined)).toBe(false);
expect(isVector([5, NaN])).toBe(false);
});
});