snapshot of https://github.com/excalidraw/excalidraw.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Tests / test (push) Failing after 15s
Some checks failed
Tests / test (push) Failing after 15s
This commit is contained in:
105
packages/math/tests/curve.test.ts
Normal file
105
packages/math/tests/curve.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
127
packages/math/tests/ellipse.test.ts
Normal file
127
packages/math/tests/ellipse.test.ts
Normal 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)]);
|
||||
});
|
||||
});
|
||||
31
packages/math/tests/line.test.ts
Normal file
31
packages/math/tests/line.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
200
packages/math/tests/pca.test.ts
Normal file
200
packages/math/tests/pca.test.ts
Normal 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));
|
||||
});
|
||||
});
|
||||
25
packages/math/tests/point.test.ts
Normal file
25
packages/math/tests/point.test.ts
Normal 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]);
|
||||
});
|
||||
});
|
||||
120
packages/math/tests/polygon.test.ts
Normal file
120
packages/math/tests/polygon.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
51
packages/math/tests/range.test.ts
Normal file
51
packages/math/tests/range.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
45
packages/math/tests/segment.test.ts
Normal file
45
packages/math/tests/segment.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
12
packages/math/tests/vector.test.ts
Normal file
12
packages/math/tests/vector.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user