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:
19
packages/math/README.md
Normal file
19
packages/math/README.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# @excalidraw/math
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npm install @excalidraw/math
|
||||
```
|
||||
|
||||
If you prefer Yarn over npm, use this command to install the Excalidraw utils package:
|
||||
|
||||
```bash
|
||||
yarn add @excalidraw/math
|
||||
```
|
||||
|
||||
With PNPM, similarly install the package with this command:
|
||||
|
||||
```bash
|
||||
pnpm add @excalidraw/math
|
||||
```
|
||||
3
packages/math/global.d.ts
vendored
Normal file
3
packages/math/global.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
/// <reference types="vite/client" />
|
||||
import "@excalidraw/excalidraw/global";
|
||||
import "@excalidraw/excalidraw/css";
|
||||
66
packages/math/package.json
Normal file
66
packages/math/package.json
Normal file
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"name": "@excalidraw/math",
|
||||
"version": "0.18.0",
|
||||
"type": "module",
|
||||
"types": "./dist/types/math/src/index.d.ts",
|
||||
"main": "./dist/prod/index.js",
|
||||
"module": "./dist/prod/index.js",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/types/math/src/index.d.ts",
|
||||
"development": "./dist/dev/index.js",
|
||||
"production": "./dist/prod/index.js",
|
||||
"default": "./dist/prod/index.js"
|
||||
},
|
||||
"./*": {
|
||||
"types": "./dist/types/math/src/*.d.ts",
|
||||
"development": "./dist/dev/index.js",
|
||||
"production": "./dist/prod/index.js",
|
||||
"default": "./dist/prod/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist/*"
|
||||
],
|
||||
"description": "Excalidraw math functions",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"excalidraw",
|
||||
"excalidraw-math",
|
||||
"math",
|
||||
"vector",
|
||||
"algebra",
|
||||
"2d"
|
||||
],
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not ie <= 11",
|
||||
"not op_mini all",
|
||||
"not safari < 12",
|
||||
"not kaios <= 2.5",
|
||||
"not edge < 79",
|
||||
"not chrome < 70",
|
||||
"not and_uc < 13",
|
||||
"not samsung < 10"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"bugs": "https://github.com/excalidraw/excalidraw/issues",
|
||||
"repository": "https://github.com/excalidraw/excalidraw",
|
||||
"scripts": {
|
||||
"gen:types": "rimraf types && tsc",
|
||||
"build:esm": "rimraf dist && node ../../scripts/buildBase.js && yarn gen:types"
|
||||
},
|
||||
"dependencies": {
|
||||
"@excalidraw/common": "0.18.0"
|
||||
}
|
||||
}
|
||||
77
packages/math/src/angle.ts
Normal file
77
packages/math/src/angle.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { PRECISION } from "./utils";
|
||||
|
||||
import type {
|
||||
Degrees,
|
||||
GlobalPoint,
|
||||
LocalPoint,
|
||||
PolarCoords,
|
||||
Radians,
|
||||
} from "./types";
|
||||
|
||||
export const normalizeRadians = (angle: Radians): Radians =>
|
||||
angle < 0
|
||||
? (((angle % (2 * Math.PI)) + 2 * Math.PI) as Radians)
|
||||
: ((angle % (2 * Math.PI)) as Radians);
|
||||
|
||||
/**
|
||||
* Return the polar coordinates for the given cartesian point represented by
|
||||
* (x, y) for the center point 0,0 where the first number returned is the radius,
|
||||
* the second is the angle in radians.
|
||||
*/
|
||||
export const cartesian2Polar = <P extends GlobalPoint | LocalPoint>([
|
||||
x,
|
||||
y,
|
||||
]: P): PolarCoords => [
|
||||
Math.hypot(x, y),
|
||||
normalizeRadians(Math.atan2(y, x) as Radians),
|
||||
];
|
||||
|
||||
export function degreesToRadians(degrees: Degrees): Radians {
|
||||
return ((degrees * Math.PI) / 180) as Radians;
|
||||
}
|
||||
|
||||
export function radiansToDegrees(degrees: Radians): Degrees {
|
||||
return ((degrees * 180) / Math.PI) as Degrees;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if the provided angle is a right angle.
|
||||
*
|
||||
* @param rads The angle to measure
|
||||
* @returns TRUE if the provided angle is a right angle
|
||||
*/
|
||||
export function isRightAngleRads(rads: Radians): boolean {
|
||||
return Math.abs(Math.sin(2 * rads)) < PRECISION;
|
||||
}
|
||||
|
||||
export function radiansBetweenAngles(
|
||||
a: Radians,
|
||||
min: Radians,
|
||||
max: Radians,
|
||||
): boolean {
|
||||
a = normalizeRadians(a);
|
||||
min = normalizeRadians(min);
|
||||
max = normalizeRadians(max);
|
||||
|
||||
if (min < max) {
|
||||
return a >= min && a <= max;
|
||||
}
|
||||
|
||||
// The range wraps around the 0 angle
|
||||
return a >= min || a <= max;
|
||||
}
|
||||
|
||||
export function radiansDifference(a: Radians, b: Radians): Radians {
|
||||
a = normalizeRadians(a);
|
||||
b = normalizeRadians(b);
|
||||
|
||||
let diff = a - b;
|
||||
|
||||
if (diff < -Math.PI) {
|
||||
diff = (diff + 2 * Math.PI) as Radians;
|
||||
} else if (diff > Math.PI) {
|
||||
diff = (diff - 2 * Math.PI) as Radians;
|
||||
}
|
||||
|
||||
return Math.abs(diff) as Radians;
|
||||
}
|
||||
55
packages/math/src/constants.ts
Normal file
55
packages/math/src/constants.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
// Legendre-Gauss abscissae (x values) and weights for n=24
|
||||
// Refeerence: https://pomax.github.io/bezierinfo/legendre-gauss.html
|
||||
export const LegendreGaussN24TValues = [
|
||||
-0.0640568928626056260850430826247450385909,
|
||||
0.0640568928626056260850430826247450385909,
|
||||
-0.1911188674736163091586398207570696318404,
|
||||
0.1911188674736163091586398207570696318404,
|
||||
-0.3150426796961633743867932913198102407864,
|
||||
0.3150426796961633743867932913198102407864,
|
||||
-0.4337935076260451384870842319133497124524,
|
||||
0.4337935076260451384870842319133497124524,
|
||||
-0.5454214713888395356583756172183723700107,
|
||||
0.5454214713888395356583756172183723700107,
|
||||
-0.6480936519369755692524957869107476266696,
|
||||
0.6480936519369755692524957869107476266696,
|
||||
-0.7401241915785543642438281030999784255232,
|
||||
0.7401241915785543642438281030999784255232,
|
||||
-0.8200019859739029219539498726697452080761,
|
||||
0.8200019859739029219539498726697452080761,
|
||||
-0.8864155270044010342131543419821967550873,
|
||||
0.8864155270044010342131543419821967550873,
|
||||
-0.9382745520027327585236490017087214496548,
|
||||
0.9382745520027327585236490017087214496548,
|
||||
-0.9747285559713094981983919930081690617411,
|
||||
0.9747285559713094981983919930081690617411,
|
||||
-0.9951872199970213601799974097007368118745,
|
||||
0.9951872199970213601799974097007368118745,
|
||||
];
|
||||
|
||||
export const LegendreGaussN24CValues = [
|
||||
0.1279381953467521569740561652246953718517,
|
||||
0.1279381953467521569740561652246953718517,
|
||||
0.1258374563468282961213753825111836887264,
|
||||
0.1258374563468282961213753825111836887264,
|
||||
0.121670472927803391204463153476262425607,
|
||||
0.121670472927803391204463153476262425607,
|
||||
0.1155056680537256013533444839067835598622,
|
||||
0.1155056680537256013533444839067835598622,
|
||||
0.1074442701159656347825773424466062227946,
|
||||
0.1074442701159656347825773424466062227946,
|
||||
0.0976186521041138882698806644642471544279,
|
||||
0.0976186521041138882698806644642471544279,
|
||||
0.086190161531953275917185202983742667185,
|
||||
0.086190161531953275917185202983742667185,
|
||||
0.0733464814110803057340336152531165181193,
|
||||
0.0733464814110803057340336152531165181193,
|
||||
0.0592985849154367807463677585001085845412,
|
||||
0.0592985849154367807463677585001085845412,
|
||||
0.0442774388174198061686027482113382288593,
|
||||
0.0442774388174198061686027482113382288593,
|
||||
0.0285313886289336631813078159518782864491,
|
||||
0.0285313886289336631813078159518782864491,
|
||||
0.0123412297999871995468056670700372915759,
|
||||
0.0123412297999871995468056670700372915759,
|
||||
];
|
||||
560
packages/math/src/curve.ts
Normal file
560
packages/math/src/curve.ts
Normal file
@@ -0,0 +1,560 @@
|
||||
import { isPoint, pointDistance, pointFrom, pointFromVector } from "./point";
|
||||
import { vector, vectorNormal, vectorNormalize, vectorScale } from "./vector";
|
||||
import { LegendreGaussN24CValues, LegendreGaussN24TValues } from "./constants";
|
||||
|
||||
import type { Curve, GlobalPoint, LineSegment, LocalPoint } from "./types";
|
||||
|
||||
/**
|
||||
*
|
||||
* @param a
|
||||
* @param b
|
||||
* @param c
|
||||
* @param d
|
||||
* @returns
|
||||
*/
|
||||
export function curve<Point extends GlobalPoint | LocalPoint>(
|
||||
a: Point,
|
||||
b: Point,
|
||||
c: Point,
|
||||
d: Point,
|
||||
) {
|
||||
return [a, b, c, d] as Curve<Point>;
|
||||
}
|
||||
|
||||
function solveWithAnalyticalJacobian<Point extends GlobalPoint | LocalPoint>(
|
||||
curve: Curve<Point>,
|
||||
lineSegment: LineSegment<Point>,
|
||||
t0: number,
|
||||
s0: number,
|
||||
tolerance: number = 1e-3,
|
||||
iterLimit: number = 10,
|
||||
): number[] | null {
|
||||
let error = Infinity;
|
||||
let iter = 0;
|
||||
|
||||
while (error >= tolerance) {
|
||||
if (iter >= iterLimit) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Compute bezier point at parameter t0
|
||||
const bt = 1 - t0;
|
||||
const bt2 = bt * bt;
|
||||
const bt3 = bt2 * bt;
|
||||
const t0_2 = t0 * t0;
|
||||
const t0_3 = t0_2 * t0;
|
||||
|
||||
const bezierX =
|
||||
bt3 * curve[0][0] +
|
||||
3 * bt2 * t0 * curve[1][0] +
|
||||
3 * bt * t0_2 * curve[2][0] +
|
||||
t0_3 * curve[3][0];
|
||||
const bezierY =
|
||||
bt3 * curve[0][1] +
|
||||
3 * bt2 * t0 * curve[1][1] +
|
||||
3 * bt * t0_2 * curve[2][1] +
|
||||
t0_3 * curve[3][1];
|
||||
|
||||
// Compute line point at parameter s0
|
||||
const lineX =
|
||||
lineSegment[0][0] + s0 * (lineSegment[1][0] - lineSegment[0][0]);
|
||||
const lineY =
|
||||
lineSegment[0][1] + s0 * (lineSegment[1][1] - lineSegment[0][1]);
|
||||
|
||||
// Function values
|
||||
const fx = bezierX - lineX;
|
||||
const fy = bezierY - lineY;
|
||||
|
||||
error = Math.abs(fx) + Math.abs(fy);
|
||||
|
||||
if (error < tolerance) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Analytical derivatives
|
||||
const dfx_dt =
|
||||
-3 * bt2 * curve[0][0] +
|
||||
3 * bt2 * curve[1][0] -
|
||||
6 * bt * t0 * curve[1][0] -
|
||||
3 * t0_2 * curve[2][0] +
|
||||
6 * bt * t0 * curve[2][0] +
|
||||
3 * t0_2 * curve[3][0];
|
||||
|
||||
const dfy_dt =
|
||||
-3 * bt2 * curve[0][1] +
|
||||
3 * bt2 * curve[1][1] -
|
||||
6 * bt * t0 * curve[1][1] -
|
||||
3 * t0_2 * curve[2][1] +
|
||||
6 * bt * t0 * curve[2][1] +
|
||||
3 * t0_2 * curve[3][1];
|
||||
|
||||
// Line derivatives
|
||||
const dfx_ds = -(lineSegment[1][0] - lineSegment[0][0]);
|
||||
const dfy_ds = -(lineSegment[1][1] - lineSegment[0][1]);
|
||||
|
||||
// Jacobian determinant
|
||||
const det = dfx_dt * dfy_ds - dfx_ds * dfy_dt;
|
||||
|
||||
if (Math.abs(det) < 1e-12) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Newton step
|
||||
const invDet = 1 / det;
|
||||
const dt = invDet * (dfy_ds * -fx - dfx_ds * -fy);
|
||||
const ds = invDet * (-dfy_dt * -fx + dfx_dt * -fy);
|
||||
|
||||
t0 += dt;
|
||||
s0 += ds;
|
||||
iter += 1;
|
||||
}
|
||||
|
||||
return [t0, s0];
|
||||
}
|
||||
|
||||
export const bezierEquation = <Point extends GlobalPoint | LocalPoint>(
|
||||
c: Curve<Point>,
|
||||
t: number,
|
||||
) =>
|
||||
pointFrom<Point>(
|
||||
(1 - t) ** 3 * c[0][0] +
|
||||
3 * (1 - t) ** 2 * t * c[1][0] +
|
||||
3 * (1 - t) * t ** 2 * c[2][0] +
|
||||
t ** 3 * c[3][0],
|
||||
(1 - t) ** 3 * c[0][1] +
|
||||
3 * (1 - t) ** 2 * t * c[1][1] +
|
||||
3 * (1 - t) * t ** 2 * c[2][1] +
|
||||
t ** 3 * c[3][1],
|
||||
);
|
||||
|
||||
const initial_guesses: [number, number][] = [
|
||||
[0.5, 0],
|
||||
[0.2, 0],
|
||||
[0.8, 0],
|
||||
];
|
||||
|
||||
const calculate = <Point extends GlobalPoint | LocalPoint>(
|
||||
[t0, s0]: [number, number],
|
||||
l: LineSegment<Point>,
|
||||
c: Curve<Point>,
|
||||
tolerance: number = 1e-2,
|
||||
iterLimit: number = 4,
|
||||
) => {
|
||||
const solution = solveWithAnalyticalJacobian(
|
||||
c,
|
||||
l,
|
||||
t0,
|
||||
s0,
|
||||
tolerance,
|
||||
iterLimit,
|
||||
);
|
||||
|
||||
if (!solution) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [t, s] = solution;
|
||||
|
||||
if (t < 0 || t > 1 || s < 0 || s > 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return bezierEquation(c, t);
|
||||
};
|
||||
|
||||
/**
|
||||
* Computes the intersection between a cubic spline and a line segment.
|
||||
*/
|
||||
export function curveIntersectLineSegment<
|
||||
Point extends GlobalPoint | LocalPoint,
|
||||
>(
|
||||
c: Curve<Point>,
|
||||
l: LineSegment<Point>,
|
||||
opts?: {
|
||||
tolerance?: number;
|
||||
iterLimit?: number;
|
||||
},
|
||||
): Point[] {
|
||||
let solution = calculate(
|
||||
initial_guesses[0],
|
||||
l,
|
||||
c,
|
||||
opts?.tolerance,
|
||||
opts?.iterLimit,
|
||||
);
|
||||
if (solution) {
|
||||
return [solution];
|
||||
}
|
||||
|
||||
solution = calculate(
|
||||
initial_guesses[1],
|
||||
l,
|
||||
c,
|
||||
opts?.tolerance,
|
||||
opts?.iterLimit,
|
||||
);
|
||||
if (solution) {
|
||||
return [solution];
|
||||
}
|
||||
|
||||
solution = calculate(
|
||||
initial_guesses[2],
|
||||
l,
|
||||
c,
|
||||
opts?.tolerance,
|
||||
opts?.iterLimit,
|
||||
);
|
||||
if (solution) {
|
||||
return [solution];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the parameter on the Bezier curve whose point is closest to another
|
||||
* point
|
||||
*
|
||||
* @param c The curve to test
|
||||
* @param p The point to measure from
|
||||
* @param tolerance The width of the search window under which the bisection
|
||||
* stops refining the parameter
|
||||
*/
|
||||
export function curveClosestParameter<Point extends GlobalPoint | LocalPoint>(
|
||||
c: Curve<Point>,
|
||||
p: Point,
|
||||
tolerance: number = 1e-3,
|
||||
): number {
|
||||
const localMinimum = (
|
||||
min: number,
|
||||
max: number,
|
||||
f: (t: number) => number,
|
||||
e: number = tolerance,
|
||||
) => {
|
||||
let m = min;
|
||||
let n = max;
|
||||
let k;
|
||||
|
||||
while (n - m > e) {
|
||||
k = (n + m) / 2;
|
||||
if (f(k - e) < f(k + e)) {
|
||||
n = k;
|
||||
} else {
|
||||
m = k;
|
||||
}
|
||||
}
|
||||
|
||||
return k;
|
||||
};
|
||||
|
||||
const maxSteps = 30;
|
||||
let closestStep = 0;
|
||||
for (let min = Infinity, step = 0; step <= maxSteps; step++) {
|
||||
const d = pointDistance(p, bezierEquation(c, step / maxSteps));
|
||||
if (d < min) {
|
||||
min = d;
|
||||
closestStep = step;
|
||||
}
|
||||
}
|
||||
|
||||
const t0 = Math.max((closestStep - 1) / maxSteps, 0);
|
||||
const t1 = Math.min((closestStep + 1) / maxSteps, 1);
|
||||
const param = localMinimum(t0, t1, (t) =>
|
||||
pointDistance(p, bezierEquation(c, t)),
|
||||
);
|
||||
|
||||
return param ?? closestStep / maxSteps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the closest point on the Bezier curve from another point
|
||||
*
|
||||
* @param c The curve to test
|
||||
* @param p The point to measure from
|
||||
* @param tolerance The width of the search window under which the bisection
|
||||
* stops refining the parameter
|
||||
*/
|
||||
export function curveClosestPoint<Point extends GlobalPoint | LocalPoint>(
|
||||
c: Curve<Point>,
|
||||
p: Point,
|
||||
tolerance: number = 1e-3,
|
||||
): Point {
|
||||
return bezierEquation(c, curveClosestParameter(c, p, tolerance));
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the distance between a point and the closest point on the
|
||||
* Bezier curve.
|
||||
*
|
||||
* @param c The curve to test
|
||||
* @param p The point to measure from
|
||||
*/
|
||||
export function curvePointDistance<Point extends GlobalPoint | LocalPoint>(
|
||||
c: Curve<Point>,
|
||||
p: Point,
|
||||
tolerance: number = 1e-3,
|
||||
) {
|
||||
return pointDistance(p, curveClosestPoint(c, p, tolerance));
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if the parameter is a Curve
|
||||
*/
|
||||
export function isCurve<P extends GlobalPoint | LocalPoint>(
|
||||
v: unknown,
|
||||
): v is Curve<P> {
|
||||
return (
|
||||
Array.isArray(v) &&
|
||||
v.length === 4 &&
|
||||
isPoint(v[0]) &&
|
||||
isPoint(v[1]) &&
|
||||
isPoint(v[2]) &&
|
||||
isPoint(v[3])
|
||||
);
|
||||
}
|
||||
|
||||
export function curveTangent<Point extends GlobalPoint | LocalPoint>(
|
||||
[p0, p1, p2, p3]: Curve<Point>,
|
||||
t: number,
|
||||
) {
|
||||
return vector(
|
||||
-3 * (1 - t) * (1 - t) * p0[0] +
|
||||
3 * (1 - t) * (1 - t) * p1[0] -
|
||||
6 * t * (1 - t) * p1[0] -
|
||||
3 * t * t * p2[0] +
|
||||
6 * t * (1 - t) * p2[0] +
|
||||
3 * t * t * p3[0],
|
||||
-3 * (1 - t) * (1 - t) * p0[1] +
|
||||
3 * (1 - t) * (1 - t) * p1[1] -
|
||||
6 * t * (1 - t) * p1[1] -
|
||||
3 * t * t * p2[1] +
|
||||
6 * t * (1 - t) * p2[1] +
|
||||
3 * t * t * p3[1],
|
||||
);
|
||||
}
|
||||
|
||||
export function curveCatmullRomQuadraticApproxPoints(
|
||||
points: GlobalPoint[],
|
||||
tension = 0.5,
|
||||
) {
|
||||
if (points.length < 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pointSets: [GlobalPoint, GlobalPoint][] = [];
|
||||
for (let i = 0; i < points.length - 1; i++) {
|
||||
const p0 = points[i - 1 < 0 ? 0 : i - 1];
|
||||
const p1 = points[i];
|
||||
const p2 = points[i + 1 >= points.length ? points.length - 1 : i + 1];
|
||||
const cpX = p1[0] + ((p2[0] - p0[0]) * tension) / 2;
|
||||
const cpY = p1[1] + ((p2[1] - p0[1]) * tension) / 2;
|
||||
|
||||
pointSets.push([
|
||||
pointFrom<GlobalPoint>(cpX, cpY),
|
||||
pointFrom<GlobalPoint>(p2[0], p2[1]),
|
||||
]);
|
||||
}
|
||||
|
||||
return pointSets;
|
||||
}
|
||||
|
||||
export function curveCatmullRomCubicApproxPoints<
|
||||
Point extends GlobalPoint | LocalPoint,
|
||||
>(points: Point[], tension = 0.5) {
|
||||
if (points.length < 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pointSets: Curve<Point>[] = [];
|
||||
for (let i = 0; i < points.length - 1; i++) {
|
||||
const p0 = points[i - 1 < 0 ? 0 : i - 1];
|
||||
const p1 = points[i];
|
||||
const p2 = points[i + 1 >= points.length ? points.length - 1 : i + 1];
|
||||
const p3 = points[i + 2 >= points.length ? points.length - 1 : i + 2];
|
||||
const tangent1 = [(p2[0] - p0[0]) * tension, (p2[1] - p0[1]) * tension];
|
||||
const tangent2 = [(p3[0] - p1[0]) * tension, (p3[1] - p1[1]) * tension];
|
||||
const cp1x = p1[0] + tangent1[0] / 3;
|
||||
const cp1y = p1[1] + tangent1[1] / 3;
|
||||
const cp2x = p2[0] - tangent2[0] / 3;
|
||||
const cp2y = p2[1] - tangent2[1] / 3;
|
||||
|
||||
pointSets.push(
|
||||
curve(
|
||||
pointFrom(p1[0], p1[1]),
|
||||
pointFrom(cp1x, cp1y),
|
||||
pointFrom(cp2x, cp2y),
|
||||
pointFrom(p2[0], p2[1]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return pointSets;
|
||||
}
|
||||
|
||||
export function curveOffsetPoints(
|
||||
[p0, p1, p2, p3]: Curve<GlobalPoint>,
|
||||
offset: number,
|
||||
steps = 50,
|
||||
) {
|
||||
const offsetPoints = [];
|
||||
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const t = i / steps;
|
||||
const c = curve(p0, p1, p2, p3);
|
||||
const point = bezierEquation(c, t);
|
||||
const tangent = vectorNormalize(curveTangent(c, t));
|
||||
const normal = vectorNormal(tangent);
|
||||
|
||||
offsetPoints.push(pointFromVector(vectorScale(normal, offset), point));
|
||||
}
|
||||
|
||||
return offsetPoints;
|
||||
}
|
||||
|
||||
export function offsetPointsForQuadraticBezier(
|
||||
p0: GlobalPoint,
|
||||
p1: GlobalPoint,
|
||||
p2: GlobalPoint,
|
||||
offsetDist: number,
|
||||
steps = 50,
|
||||
) {
|
||||
const offsetPoints = [];
|
||||
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const t = i / steps;
|
||||
const t1 = 1 - t;
|
||||
const point = pointFrom<GlobalPoint>(
|
||||
t1 * t1 * p0[0] + 2 * t1 * t * p1[0] + t * t * p2[0],
|
||||
t1 * t1 * p0[1] + 2 * t1 * t * p1[1] + t * t * p2[1],
|
||||
);
|
||||
const tangentX = 2 * (1 - t) * (p1[0] - p0[0]) + 2 * t * (p2[0] - p1[0]);
|
||||
const tangentY = 2 * (1 - t) * (p1[1] - p0[1]) + 2 * t * (p2[1] - p1[1]);
|
||||
const tangent = vectorNormalize(vector(tangentX, tangentY));
|
||||
const normal = vectorNormal(tangent);
|
||||
|
||||
offsetPoints.push(pointFromVector(vectorScale(normal, offsetDist), point));
|
||||
}
|
||||
|
||||
return offsetPoints;
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation based on Legendre-Gauss quadrature for more accurate arc
|
||||
* length calculation.
|
||||
*
|
||||
* Reference: https://pomax.github.io/bezierinfo/#arclength
|
||||
*
|
||||
* @param c The curve to calculate the length of
|
||||
* @returns The approximated length of the curve
|
||||
*/
|
||||
export function curveLength<P extends GlobalPoint | LocalPoint>(
|
||||
c: Curve<P>,
|
||||
): number {
|
||||
const z2 = 0.5;
|
||||
let sum = 0;
|
||||
|
||||
for (let i = 0; i < 24; i++) {
|
||||
const t = z2 * LegendreGaussN24TValues[i] + z2;
|
||||
const derivativeVector = curveTangent(c, t);
|
||||
const magnitude = Math.sqrt(
|
||||
derivativeVector[0] * derivativeVector[0] +
|
||||
derivativeVector[1] * derivativeVector[1],
|
||||
);
|
||||
sum += LegendreGaussN24CValues[i] * magnitude;
|
||||
}
|
||||
|
||||
return z2 * sum;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the curve length from t=0 to t=parameter using the same
|
||||
* Legendre-Gauss quadrature method used in curveLength
|
||||
*
|
||||
* @param c The curve to calculate the partial length for
|
||||
* @param t The parameter value (0 to 1) to calculate length up to
|
||||
* @returns The length of the curve from beginning to parameter t
|
||||
*/
|
||||
export function curveLengthAtParameter<P extends GlobalPoint | LocalPoint>(
|
||||
c: Curve<P>,
|
||||
t: number,
|
||||
): number {
|
||||
if (t <= 0) {
|
||||
return 0;
|
||||
}
|
||||
if (t >= 1) {
|
||||
return curveLength(c);
|
||||
}
|
||||
|
||||
// Scale and shift the integration interval from [0,t] to [-1,1]
|
||||
// which is what the Legendre-Gauss quadrature expects
|
||||
const z1 = t / 2;
|
||||
const z2 = t / 2;
|
||||
|
||||
let sum = 0;
|
||||
|
||||
for (let i = 0; i < 24; i++) {
|
||||
const parameter = z1 * LegendreGaussN24TValues[i] + z2;
|
||||
const derivativeVector = curveTangent(c, parameter);
|
||||
const magnitude = Math.sqrt(
|
||||
derivativeVector[0] * derivativeVector[0] +
|
||||
derivativeVector[1] * derivativeVector[1],
|
||||
);
|
||||
sum += LegendreGaussN24CValues[i] * magnitude;
|
||||
}
|
||||
|
||||
return z1 * sum; // Scale the result back to the original interval
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the point at a specific percentage of a curve's total length
|
||||
* using binary search for improved efficiency and accuracy.
|
||||
*
|
||||
* @param c The curve to calculate point on
|
||||
* @param percent A value between 0 and 1 representing the percentage of the curve's length
|
||||
* @returns The point at the specified percentage of curve length
|
||||
*/
|
||||
export function curvePointAtLength<P extends GlobalPoint | LocalPoint>(
|
||||
c: Curve<P>,
|
||||
percent: number,
|
||||
/** pass the curve's total length when already known to skip recomputing it */
|
||||
totalLength: number = curveLength(c),
|
||||
): P {
|
||||
if (percent <= 0) {
|
||||
return bezierEquation(c, 0);
|
||||
}
|
||||
|
||||
if (percent >= 1) {
|
||||
return bezierEquation(c, 1);
|
||||
}
|
||||
|
||||
const targetLength = totalLength * percent;
|
||||
|
||||
// Binary search to find parameter t where length at t equals target length
|
||||
let tMin = 0;
|
||||
let tMax = 1;
|
||||
let t = percent; // Start with a reasonable guess (t = percent)
|
||||
let currentLength = 0;
|
||||
|
||||
// Tolerance for length comparison and iteration limit to avoid infinite loops
|
||||
const tolerance = totalLength * 0.0001;
|
||||
const maxIterations = 20;
|
||||
|
||||
for (let iteration = 0; iteration < maxIterations; iteration++) {
|
||||
currentLength = curveLengthAtParameter(c, t);
|
||||
const error = Math.abs(currentLength - targetLength);
|
||||
|
||||
if (error < tolerance) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (currentLength < targetLength) {
|
||||
tMin = t;
|
||||
} else {
|
||||
tMax = t;
|
||||
}
|
||||
|
||||
t = (tMin + tMax) / 2;
|
||||
}
|
||||
|
||||
return bezierEquation(c, t);
|
||||
}
|
||||
231
packages/math/src/ellipse.ts
Normal file
231
packages/math/src/ellipse.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import {
|
||||
pointFrom,
|
||||
pointDistance,
|
||||
pointFromVector,
|
||||
pointsEqual,
|
||||
} from "./point";
|
||||
import { PRECISION } from "./utils";
|
||||
import {
|
||||
vector,
|
||||
vectorAdd,
|
||||
vectorDot,
|
||||
vectorFromPoint,
|
||||
vectorScale,
|
||||
} from "./vector";
|
||||
|
||||
import type {
|
||||
Ellipse,
|
||||
GlobalPoint,
|
||||
Line,
|
||||
LineSegment,
|
||||
LocalPoint,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
* Construct an Ellipse object from the parameters
|
||||
*
|
||||
* @param center The center of the ellipse
|
||||
* @param angle The slanting of the ellipse in radians
|
||||
* @param halfWidth Half of the width of a non-slanted version of the ellipse
|
||||
* @param halfHeight Half of the height of a non-slanted version of the ellipse
|
||||
* @returns The constructed Ellipse object
|
||||
*/
|
||||
export function ellipse<Point extends GlobalPoint | LocalPoint>(
|
||||
center: Point,
|
||||
halfWidth: number,
|
||||
halfHeight: number,
|
||||
): Ellipse<Point> {
|
||||
return {
|
||||
center,
|
||||
halfWidth,
|
||||
halfHeight,
|
||||
} as Ellipse<Point>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a point is inside or on the ellipse outline
|
||||
*
|
||||
* @param p The point to test
|
||||
* @param ellipse The ellipse to compare against
|
||||
* @returns TRUE if the point is inside or on the outline of the ellipse
|
||||
*/
|
||||
export const ellipseIncludesPoint = <Point extends GlobalPoint | LocalPoint>(
|
||||
p: Point,
|
||||
ellipse: Ellipse<Point>,
|
||||
) => {
|
||||
const { center, halfWidth, halfHeight } = ellipse;
|
||||
const normalizedX = (p[0] - center[0]) / halfWidth;
|
||||
const normalizedY = (p[1] - center[1]) / halfHeight;
|
||||
|
||||
return normalizedX * normalizedX + normalizedY * normalizedY <= 1;
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests whether a point lies on the outline of the ellipse within a given
|
||||
* tolerance
|
||||
*
|
||||
* @param point The point to test
|
||||
* @param ellipse The ellipse to compare against
|
||||
* @param threshold The distance to consider a point close enough to be "on" the outline
|
||||
* @returns TRUE if the point is on the ellise outline
|
||||
*/
|
||||
export const ellipseTouchesPoint = <Point extends GlobalPoint | LocalPoint>(
|
||||
point: Point,
|
||||
ellipse: Ellipse<Point>,
|
||||
threshold = PRECISION,
|
||||
) => {
|
||||
return ellipseDistanceFromPoint(point, ellipse) <= threshold;
|
||||
};
|
||||
|
||||
/**
|
||||
* Determine the shortest euclidean distance from a point to the
|
||||
* outline of the ellipse
|
||||
*
|
||||
* @param p The point to consider
|
||||
* @param ellipse The ellipse to calculate the distance to
|
||||
* @returns The eucledian distance
|
||||
*/
|
||||
export const ellipseDistanceFromPoint = <
|
||||
Point extends GlobalPoint | LocalPoint,
|
||||
>(
|
||||
p: Point,
|
||||
ellipse: Ellipse<Point>,
|
||||
): number => {
|
||||
const { halfWidth, halfHeight, center } = ellipse;
|
||||
const a = halfWidth;
|
||||
const b = halfHeight;
|
||||
const translatedPoint = vectorAdd(
|
||||
vectorFromPoint(p),
|
||||
vectorScale(vectorFromPoint(center), -1),
|
||||
);
|
||||
|
||||
const px = Math.abs(translatedPoint[0]);
|
||||
const py = Math.abs(translatedPoint[1]);
|
||||
|
||||
let tx = 0.707;
|
||||
let ty = 0.707;
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const x = a * tx;
|
||||
const y = b * ty;
|
||||
|
||||
const ex = ((a * a - b * b) * tx ** 3) / a;
|
||||
const ey = ((b * b - a * a) * ty ** 3) / b;
|
||||
|
||||
const rx = x - ex;
|
||||
const ry = y - ey;
|
||||
|
||||
const qx = px - ex;
|
||||
const qy = py - ey;
|
||||
|
||||
const r = Math.hypot(ry, rx);
|
||||
const q = Math.hypot(qy, qx);
|
||||
|
||||
tx = Math.min(1, Math.max(0, ((qx * r) / q + ex) / a));
|
||||
ty = Math.min(1, Math.max(0, ((qy * r) / q + ey) / b));
|
||||
const t = Math.hypot(ty, tx);
|
||||
tx /= t;
|
||||
ty /= t;
|
||||
}
|
||||
|
||||
const [minX, minY] = [
|
||||
a * tx * Math.sign(translatedPoint[0]),
|
||||
b * ty * Math.sign(translatedPoint[1]),
|
||||
];
|
||||
|
||||
return pointDistance(pointFromVector(translatedPoint), pointFrom(minX, minY));
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculate a maximum of two intercept points for a line going throug an
|
||||
* ellipse.
|
||||
*/
|
||||
export function ellipseSegmentInterceptPoints<
|
||||
Point extends GlobalPoint | LocalPoint,
|
||||
>(e: Readonly<Ellipse<Point>>, s: Readonly<LineSegment<Point>>): Point[] {
|
||||
const rx = e.halfWidth;
|
||||
const ry = e.halfHeight;
|
||||
|
||||
const dir = vectorFromPoint(s[1], s[0]);
|
||||
const diff = vector(s[0][0] - e.center[0], s[0][1] - e.center[1]);
|
||||
const mDir = vector(dir[0] / (rx * rx), dir[1] / (ry * ry));
|
||||
const mDiff = vector(diff[0] / (rx * rx), diff[1] / (ry * ry));
|
||||
|
||||
const a = vectorDot(dir, mDir);
|
||||
const b = vectorDot(dir, mDiff);
|
||||
const c = vectorDot(diff, mDiff) - 1.0;
|
||||
const d = b * b - a * c;
|
||||
|
||||
const intersections: Point[] = [];
|
||||
|
||||
if (d > 0) {
|
||||
const t_a = (-b - Math.sqrt(d)) / a;
|
||||
const t_b = (-b + Math.sqrt(d)) / a;
|
||||
|
||||
if (0 <= t_a && t_a <= 1) {
|
||||
intersections.push(
|
||||
pointFrom(
|
||||
s[0][0] + (s[1][0] - s[0][0]) * t_a,
|
||||
s[0][1] + (s[1][1] - s[0][1]) * t_a,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (0 <= t_b && t_b <= 1) {
|
||||
intersections.push(
|
||||
pointFrom(
|
||||
s[0][0] + (s[1][0] - s[0][0]) * t_b,
|
||||
s[0][1] + (s[1][1] - s[0][1]) * t_b,
|
||||
),
|
||||
);
|
||||
}
|
||||
} else if (d === 0) {
|
||||
const t = -b / a;
|
||||
if (0 <= t && t <= 1) {
|
||||
intersections.push(
|
||||
pointFrom(
|
||||
s[0][0] + (s[1][0] - s[0][0]) * t,
|
||||
s[0][1] + (s[1][1] - s[0][1]) * t,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return intersections;
|
||||
}
|
||||
|
||||
export function ellipseLineIntersectionPoints<
|
||||
Point extends GlobalPoint | LocalPoint,
|
||||
>(
|
||||
{ center, halfWidth, halfHeight }: Ellipse<Point>,
|
||||
[g, h]: Line<Point>,
|
||||
): Point[] {
|
||||
const [cx, cy] = center;
|
||||
const x1 = g[0] - cx;
|
||||
const y1 = g[1] - cy;
|
||||
const x2 = h[0] - cx;
|
||||
const y2 = h[1] - cy;
|
||||
const a =
|
||||
Math.pow(x2 - x1, 2) / Math.pow(halfWidth, 2) +
|
||||
Math.pow(y2 - y1, 2) / Math.pow(halfHeight, 2);
|
||||
const b =
|
||||
2 *
|
||||
((x1 * (x2 - x1)) / Math.pow(halfWidth, 2) +
|
||||
(y1 * (y2 - y1)) / Math.pow(halfHeight, 2));
|
||||
const c =
|
||||
Math.pow(x1, 2) / Math.pow(halfWidth, 2) +
|
||||
Math.pow(y1, 2) / Math.pow(halfHeight, 2) -
|
||||
1;
|
||||
const t1 = (-b + Math.sqrt(Math.pow(b, 2) - 4 * a * c)) / (2 * a);
|
||||
const t2 = (-b - Math.sqrt(Math.pow(b, 2) - 4 * a * c)) / (2 * a);
|
||||
const candidates = [
|
||||
pointFrom<Point>(x1 + t1 * (x2 - x1) + cx, y1 + t1 * (y2 - y1) + cy),
|
||||
pointFrom<Point>(x1 + t2 * (x2 - x1) + cx, y1 + t2 * (y2 - y1) + cy),
|
||||
].filter((p) => !isNaN(p[0]) && !isNaN(p[1]));
|
||||
|
||||
if (candidates.length === 2 && pointsEqual(candidates[0], candidates[1])) {
|
||||
return [candidates[0]];
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
14
packages/math/src/index.ts
Normal file
14
packages/math/src/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export * from "./angle";
|
||||
export * from "./curve";
|
||||
export * from "./ellipse";
|
||||
export * from "./line";
|
||||
export * from "./pca";
|
||||
export * from "./point";
|
||||
export * from "./polygon";
|
||||
export * from "./range";
|
||||
export * from "./rectangle";
|
||||
export * from "./segment";
|
||||
export * from "./triangle";
|
||||
export * from "./types";
|
||||
export * from "./vector";
|
||||
export * from "./utils";
|
||||
39
packages/math/src/line.ts
Normal file
39
packages/math/src/line.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { pointFrom } from "./point";
|
||||
|
||||
import type { GlobalPoint, Line, LocalPoint } from "./types";
|
||||
|
||||
/**
|
||||
* Create a line from two points.
|
||||
*
|
||||
* @param points The two points lying on the line
|
||||
* @returns The line on which the points lie
|
||||
*/
|
||||
export function line<P extends GlobalPoint | LocalPoint>(a: P, b: P): Line<P> {
|
||||
return [a, b] as Line<P>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the intersection point (unless the lines are parallel) of two
|
||||
* lines
|
||||
*
|
||||
* @param a
|
||||
* @param b
|
||||
* @returns
|
||||
*/
|
||||
export function linesIntersectAt<Point extends GlobalPoint | LocalPoint>(
|
||||
a: Line<Point>,
|
||||
b: Line<Point>,
|
||||
): Point | null {
|
||||
const A1 = a[1][1] - a[0][1];
|
||||
const B1 = a[0][0] - a[1][0];
|
||||
const A2 = b[1][1] - b[0][1];
|
||||
const B2 = b[0][0] - b[1][0];
|
||||
const D = A1 * B2 - A2 * B1;
|
||||
if (D !== 0) {
|
||||
const C1 = A1 * a[0][0] + B1 * a[0][1];
|
||||
const C2 = A2 * b[0][0] + B2 * b[0][1];
|
||||
return pointFrom<Point>((C1 * B2 - C2 * B1) / D, (A1 * C2 - A2 * C1) / D);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
202
packages/math/src/pca.ts
Normal file
202
packages/math/src/pca.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import { pointFrom } from "./point";
|
||||
import { vector, vectorNormal, vectorNormalize, vectorScale } from "./vector";
|
||||
|
||||
import type { GlobalPoint, LocalPoint, Vector } from "./types";
|
||||
|
||||
/**
|
||||
* The principal axes of a point set, i.e. the eigen decomposition of its
|
||||
* 2x2 covariance matrix.
|
||||
*
|
||||
* The axes are orthonormal and ordered by variance, so `major` is the
|
||||
* direction along which the points spread the most. Together with `centroid`
|
||||
* they define a canonical frame: expressing the points in it removes
|
||||
* translation and rotation, and dividing by `sqrt(majorVariance)` removes
|
||||
* scale.
|
||||
*/
|
||||
export type PrincipalAxes<Point extends GlobalPoint | LocalPoint> = {
|
||||
centroid: Point;
|
||||
/** Unit vector along the direction of largest variance. */
|
||||
major: Vector;
|
||||
/** Unit vector along the direction of smallest variance, normal to major. */
|
||||
minor: Vector;
|
||||
/** Variance along `major` (the larger eigenvalue, λ₁). */
|
||||
majorVariance: number;
|
||||
/** Variance along `minor` (the smaller eigenvalue, λ₂). */
|
||||
minorVariance: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* A point expressed in a principal axes frame: `u` along the major axis,
|
||||
* `v` along the minor axis, both relative to the centroid.
|
||||
*/
|
||||
export type PrincipalCoords = [u: number, v: number];
|
||||
|
||||
/**
|
||||
* Compute the centroid of a point set.
|
||||
*/
|
||||
export function centroid<Point extends GlobalPoint | LocalPoint>(
|
||||
points: readonly Point[],
|
||||
): Point {
|
||||
let cx = 0;
|
||||
let cy = 0;
|
||||
for (const [x, y] of points) {
|
||||
cx += x;
|
||||
cy += y;
|
||||
}
|
||||
return pointFrom<Point>(cx / points.length, cy / points.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Principal component analysis of a 2D point set.
|
||||
*
|
||||
* Computes the centroid and the eigen decomposition of the covariance matrix
|
||||
* [[μ20, μ11], [μ11, μ02]].
|
||||
*
|
||||
* @param points At least two points; fewer leaves the axes degenerate.
|
||||
* @returns The centroid, the orthonormal axes and their variances.
|
||||
*/
|
||||
export function principalAxes<Point extends GlobalPoint | LocalPoint>(
|
||||
points: readonly Point[],
|
||||
): PrincipalAxes<Point> {
|
||||
if (points.length < 2) {
|
||||
console.error("Degenerate point set: at least two points are required");
|
||||
}
|
||||
|
||||
const c = centroid(points);
|
||||
|
||||
let m20 = 0;
|
||||
let m02 = 0;
|
||||
let m11 = 0;
|
||||
for (const [x, y] of points) {
|
||||
const dx = x - c[0];
|
||||
const dy = y - c[1];
|
||||
m20 += dx * dx;
|
||||
m02 += dy * dy;
|
||||
m11 += dx * dy;
|
||||
}
|
||||
m20 /= points.length;
|
||||
m02 /= points.length;
|
||||
m11 /= points.length;
|
||||
|
||||
// Eigenvalues of [[m20, m11], [m11, m02]].
|
||||
const trace = m20 + m02;
|
||||
const diff = Math.hypot(m20 - m02, 2 * m11);
|
||||
const majorVariance = (trace + diff) / 2;
|
||||
const minorVariance = (trace - diff) / 2;
|
||||
|
||||
// Eigenvector for the larger eigenvalue. When m11 is 0 the covariance is
|
||||
// already diagonal and the axes are the coordinate axes.
|
||||
let major: Vector;
|
||||
if (Math.abs(m11) > Number.EPSILON) {
|
||||
major = vectorNormalize(vector(majorVariance - m02, m11));
|
||||
} else {
|
||||
major = m20 >= m02 ? vector(1, 0) : vector(0, 1);
|
||||
}
|
||||
|
||||
return {
|
||||
centroid: c,
|
||||
major,
|
||||
minor: vectorNormal(major),
|
||||
majorVariance,
|
||||
minorVariance,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Express points in the frame of the given principal axes, making the point
|
||||
* cloud rotation, scale and translation invariant.
|
||||
*/
|
||||
export function principalCoords<Point extends GlobalPoint | LocalPoint>(
|
||||
points: readonly Point[],
|
||||
axes: PrincipalAxes<Point>,
|
||||
scale: number = 1,
|
||||
): PrincipalCoords[] {
|
||||
const { centroid: c, major, minor } = axes;
|
||||
return points.map(([x, y]) => {
|
||||
const dx = x - c[0];
|
||||
const dy = y - c[1];
|
||||
return [
|
||||
(dx * major[0] + dy * major[1]) * scale,
|
||||
(dx * minor[0] + dy * minor[1]) * scale,
|
||||
] as PrincipalCoords;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Flip the major axis so that it points toward the denser end of the point
|
||||
* cloud, resolving the 180° sign ambiguity of the eigenvector.
|
||||
*
|
||||
* @returns The axes with `major`/`minor` flipped if needed. Variances are
|
||||
* unchanged, as they are invariant to axis sign.
|
||||
*/
|
||||
export function orientPrincipalAxes<Point extends GlobalPoint | LocalPoint>(
|
||||
points: readonly Point[],
|
||||
axes: PrincipalAxes<Point>,
|
||||
): PrincipalAxes<Point> {
|
||||
const u = principalCoords(points, axes).map(([uu]) => uu);
|
||||
if (skewness(u) <= 0) {
|
||||
return axes;
|
||||
}
|
||||
const major = vectorScale(axes.major, -1);
|
||||
return { ...axes, major, minor: vectorNormal(major) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Ratio of the minor to the major variance in [0, 1]
|
||||
*
|
||||
* @returns 0 is a perfectly straight stroke, 1 is a stroke with no preferred
|
||||
* direction.
|
||||
*/
|
||||
export function elongation<Point extends GlobalPoint | LocalPoint>(
|
||||
axes: PrincipalAxes<Point>,
|
||||
): number {
|
||||
return axes.majorVariance > 0 ? axes.minorVariance / axes.majorVariance : 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `order`-th standardized moment of a sample in a rotation and scale
|
||||
* invariant way (sandardizing by sigma).
|
||||
*
|
||||
* @returns 0 for a degenerate (zero variance) sample.
|
||||
*/
|
||||
export function standardizedMoment(
|
||||
values: readonly number[],
|
||||
order: number,
|
||||
): number {
|
||||
const n = values.length;
|
||||
let mean = 0;
|
||||
for (const v of values) {
|
||||
mean += v;
|
||||
}
|
||||
mean /= n;
|
||||
|
||||
let variance = 0;
|
||||
let moment = 0;
|
||||
for (const v of values) {
|
||||
const d = v - mean;
|
||||
variance += d * d;
|
||||
moment += d ** order;
|
||||
}
|
||||
variance /= n;
|
||||
moment /= n;
|
||||
|
||||
const sigma = Math.sqrt(variance);
|
||||
return sigma > Number.EPSILON ? moment / sigma ** order : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Third standardized moment: how lopsided a sample is.
|
||||
*
|
||||
*/
|
||||
export function skewness(values: readonly number[]): number {
|
||||
return standardizedMoment(values, 3);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fourth standardized moment: how the mass is split between the tails and the
|
||||
* middle of a sample. Not excess kurtosis, a normal sample gives 3.
|
||||
*
|
||||
*/
|
||||
export function kurtosis(values: readonly number[]): number {
|
||||
return standardizedMoment(values, 4);
|
||||
}
|
||||
264
packages/math/src/point.ts
Normal file
264
packages/math/src/point.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
import { degreesToRadians } from "./angle";
|
||||
import { isFiniteNumber, PRECISION } from "./utils";
|
||||
import { vectorFromPoint, vectorScale } from "./vector";
|
||||
|
||||
import type {
|
||||
LocalPoint,
|
||||
GlobalPoint,
|
||||
Radians,
|
||||
Degrees,
|
||||
Vector,
|
||||
GlobalCoord,
|
||||
LocalCoord,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
* Create a properly typed Point instance from the X and Y coordinates.
|
||||
*
|
||||
* @param x The X coordinate
|
||||
* @param y The Y coordinate
|
||||
* @returns The branded and created point
|
||||
*/
|
||||
export function pointFrom<Point extends GlobalPoint | LocalPoint>(
|
||||
x: number,
|
||||
y: number,
|
||||
): Point;
|
||||
// TODO remove the overload once we migrate to using Point tuples everywhere
|
||||
export function pointFrom<Coord extends GlobalCoord | LocalCoord>(
|
||||
coords: Coord,
|
||||
): Coord extends GlobalCoord ? GlobalPoint : LocalPoint;
|
||||
// TODO remove the overload once we migrate to using Point tuples everywhere
|
||||
export function pointFrom<Point extends GlobalPoint | LocalPoint>(coords: {
|
||||
x: number;
|
||||
y: number;
|
||||
}): Point;
|
||||
export function pointFrom<Point extends GlobalPoint | LocalPoint>(
|
||||
xOrCoords: number | { x: number; y: number },
|
||||
y?: number,
|
||||
): Point {
|
||||
return typeof xOrCoords === "object"
|
||||
? ([xOrCoords.x, xOrCoords.y] as Point)
|
||||
: ([xOrCoords, y!] as Point);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts and remaps an array containing a pair of numbers to Point.
|
||||
*
|
||||
* @param numberArray The number array to check and to convert to Point
|
||||
* @returns The point instance
|
||||
*/
|
||||
export function pointFromArray<Point extends GlobalPoint | LocalPoint>(
|
||||
numberArray: number[],
|
||||
): Point | undefined {
|
||||
return numberArray.length === 2
|
||||
? pointFrom<Point>(numberArray[0], numberArray[1])
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts and remaps a pair of numbers to Point.
|
||||
*
|
||||
* @param pair A number pair to convert to Point
|
||||
* @returns The point instance
|
||||
*/
|
||||
export function pointFromPair<Point extends GlobalPoint | LocalPoint>(
|
||||
pair: [number, number],
|
||||
): Point {
|
||||
return pair as Point;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a vector to a point.
|
||||
*
|
||||
* @param v The vector to convert
|
||||
* @returns The point the vector points at with origin 0,0
|
||||
*/
|
||||
export function pointFromVector<P extends GlobalPoint | LocalPoint>(
|
||||
v: Vector,
|
||||
offset: P = pointFrom(0, 0),
|
||||
): P {
|
||||
return pointFrom<P>(offset[0] + v[0], offset[1] + v[1]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the provided value has the shape of a Point.
|
||||
*
|
||||
* @param p The value to attempt verification on
|
||||
* @returns TRUE if the provided value has the shape of a local or global point
|
||||
*/
|
||||
export function isPoint(p: unknown): p is LocalPoint | GlobalPoint {
|
||||
return (
|
||||
Array.isArray(p) &&
|
||||
p.length === 2 &&
|
||||
typeof p[0] === "number" &&
|
||||
!isNaN(p[0]) &&
|
||||
typeof p[1] === "number" &&
|
||||
!isNaN(p[1])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two points coordinate-by-coordinate and if
|
||||
* they are closer than INVERSE_PRECISION it returns TRUE.
|
||||
*
|
||||
* @param a Point The first point to compare
|
||||
* @param b Point The second point to compare
|
||||
* @returns TRUE if the points are sufficiently close to each other
|
||||
*/
|
||||
export function pointsEqual<Point extends GlobalPoint | LocalPoint>(
|
||||
a: Point,
|
||||
b: Point,
|
||||
tolerance: number = PRECISION,
|
||||
): boolean {
|
||||
const abs = Math.abs;
|
||||
return abs(a[0] - b[0]) < tolerance && abs(a[1] - b[1]) < tolerance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotate a point by [angle] radians.
|
||||
*
|
||||
* @param point The point to rotate
|
||||
* @param center The point to rotate around, the center point
|
||||
* @param angle The radians to rotate the point by
|
||||
* @returns The rotated point
|
||||
*/
|
||||
export function pointRotateRads<Point extends GlobalPoint | LocalPoint>(
|
||||
point: Point,
|
||||
center: Point,
|
||||
angle: Radians,
|
||||
): Point {
|
||||
if (!angle) {
|
||||
return point;
|
||||
}
|
||||
const [x, y] = point;
|
||||
const [cx, cy] = center;
|
||||
return pointFrom(
|
||||
(x - cx) * Math.cos(angle) - (y - cy) * Math.sin(angle) + cx,
|
||||
(x - cx) * Math.sin(angle) + (y - cy) * Math.cos(angle) + cy,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotate a point by [angle] degree.
|
||||
*
|
||||
* @param point The point to rotate
|
||||
* @param center The point to rotate around, the center point
|
||||
* @param angle The degree to rotate the point by
|
||||
* @returns The rotated point
|
||||
*/
|
||||
export function pointRotateDegs<Point extends GlobalPoint | LocalPoint>(
|
||||
point: Point,
|
||||
center: Point,
|
||||
angle: Degrees,
|
||||
): Point {
|
||||
return pointRotateRads(point, center, degreesToRadians(angle));
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a point by a vector.
|
||||
*
|
||||
* WARNING: This is not for translating Excalidraw element points!
|
||||
* You need to account for rotation on base coordinates
|
||||
* on your own.
|
||||
* CONSIDER USING AN APPROPRIATE ELEMENT-AWARE TRANSLATE!
|
||||
*
|
||||
* @param p The point to apply the translation on
|
||||
* @param v The vector to translate by
|
||||
* @returns
|
||||
*/
|
||||
// TODO 99% of use is translating between global and local coords, which need to be formalized
|
||||
export function pointTranslate<
|
||||
From extends GlobalPoint | LocalPoint,
|
||||
To extends GlobalPoint | LocalPoint,
|
||||
>(p: From, v: Vector = [0, 0] as Vector): To {
|
||||
return pointFrom(p[0] + v[0], p[1] + v[1]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the center point at equal distance from both points.
|
||||
*
|
||||
* @param a One of the points to create the middle point for
|
||||
* @param b The other point to create the middle point for
|
||||
* @returns The middle point
|
||||
*/
|
||||
export function pointCenter<P extends LocalPoint | GlobalPoint>(a: P, b: P): P {
|
||||
return pointFrom((a[0] + b[0]) / 2, (a[1] + b[1]) / 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the distance between two points.
|
||||
*
|
||||
* @param a First point
|
||||
* @param b Second point
|
||||
* @returns The euclidean distance between the two points.
|
||||
*/
|
||||
export function pointDistance<P extends LocalPoint | GlobalPoint>(
|
||||
a: P,
|
||||
b: P,
|
||||
): number {
|
||||
return Math.hypot(b[0] - a[0], b[1] - a[1]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the squared distance between two points.
|
||||
*
|
||||
* Note: Use this if you only compare distances, it saves a square root.
|
||||
*
|
||||
* @param a First point
|
||||
* @param b Second point
|
||||
* @returns The euclidean distance between the two points.
|
||||
*/
|
||||
export function pointDistanceSq<P extends LocalPoint | GlobalPoint>(
|
||||
a: P,
|
||||
b: P,
|
||||
): number {
|
||||
const xDiff = b[0] - a[0];
|
||||
const yDiff = b[1] - a[1];
|
||||
|
||||
return xDiff * xDiff + yDiff * yDiff;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scale a point from a given origin by the multiplier.
|
||||
*
|
||||
* @param p The point to scale
|
||||
* @param mid The origin to scale from
|
||||
* @param multiplier The scaling factor
|
||||
* @returns
|
||||
*/
|
||||
export const pointScaleFromOrigin = <P extends GlobalPoint | LocalPoint>(
|
||||
p: P,
|
||||
mid: P,
|
||||
multiplier: number,
|
||||
) => pointTranslate(mid, vectorScale(vectorFromPoint(p, mid), multiplier));
|
||||
|
||||
/**
|
||||
* Returns whether `q` lies inside the segment/rectangle defined by `p` and `r`.
|
||||
* This is an approximation to "does `q` lie on a segment `pr`" check.
|
||||
*
|
||||
* @param p The first point to compare against
|
||||
* @param q The actual point this function checks whether is in between
|
||||
* @param r The other point to compare against
|
||||
* @returns TRUE if q is indeed between p and r
|
||||
*/
|
||||
export const isPointWithinBounds = <P extends GlobalPoint | LocalPoint>(
|
||||
p: P,
|
||||
q: P,
|
||||
r: P,
|
||||
) => {
|
||||
return (
|
||||
q[0] <= Math.max(p[0], r[0]) &&
|
||||
q[0] >= Math.min(p[0], r[0]) &&
|
||||
q[1] <= Math.max(p[1], r[1]) &&
|
||||
q[1] >= Math.min(p[1], r[1])
|
||||
);
|
||||
};
|
||||
|
||||
export const isValidPoint = (point: unknown): point is LocalPoint => {
|
||||
return (
|
||||
Array.isArray(point) &&
|
||||
point.length === 2 &&
|
||||
isFiniteNumber(point[0]) &&
|
||||
isFiniteNumber(point[1])
|
||||
);
|
||||
};
|
||||
212
packages/math/src/polygon.ts
Normal file
212
packages/math/src/polygon.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import { pointsEqual } from "./point";
|
||||
import { PRECISION } from "./utils";
|
||||
|
||||
import type { GlobalPoint, LocalPoint, Polygon } from "./types";
|
||||
|
||||
export function polygon<Point extends GlobalPoint | LocalPoint>(
|
||||
...points: Point[]
|
||||
) {
|
||||
return polygonClose(points) as Polygon<Point>;
|
||||
}
|
||||
|
||||
export function polygonFromPoints<Point extends GlobalPoint | LocalPoint>(
|
||||
points: Point[],
|
||||
) {
|
||||
return polygonClose(points) as Polygon<Point>;
|
||||
}
|
||||
|
||||
export const polygonIncludesPoint = <Point extends LocalPoint | GlobalPoint>(
|
||||
point: Point,
|
||||
polygon: Polygon<Point>,
|
||||
) => {
|
||||
const x = point[0];
|
||||
const y = point[1];
|
||||
let inside = false;
|
||||
|
||||
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
||||
const xi = polygon[i][0];
|
||||
const yi = polygon[i][1];
|
||||
const xj = polygon[j][0];
|
||||
const yj = polygon[j][1];
|
||||
|
||||
if (
|
||||
((yi > y && yj <= y) || (yi <= y && yj > y)) &&
|
||||
x < ((xj - xi) * (y - yi)) / (yj - yi) + xi
|
||||
) {
|
||||
inside = !inside;
|
||||
}
|
||||
}
|
||||
|
||||
return inside;
|
||||
};
|
||||
|
||||
export const polygonIncludesPointNonZero = <Point extends [number, number]>(
|
||||
point: Point,
|
||||
polygon: Point[],
|
||||
): boolean => {
|
||||
const [x, y] = point;
|
||||
let windingNumber = 0;
|
||||
|
||||
for (let i = 0; i < polygon.length; i++) {
|
||||
const j = (i + 1) % polygon.length;
|
||||
const [xi, yi] = polygon[i];
|
||||
const [xj, yj] = polygon[j];
|
||||
|
||||
if (yi <= y) {
|
||||
if (yj > y) {
|
||||
if ((xj - xi) * (y - yi) - (x - xi) * (yj - yi) > 0) {
|
||||
windingNumber++;
|
||||
}
|
||||
}
|
||||
} else if (yj <= y) {
|
||||
if ((xj - xi) * (y - yi) - (x - xi) * (yj - yi) < 0) {
|
||||
windingNumber--;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return windingNumber !== 0;
|
||||
};
|
||||
|
||||
function polygonClose<Point extends LocalPoint | GlobalPoint>(
|
||||
polygon: Point[],
|
||||
) {
|
||||
return polygonIsClosed(polygon)
|
||||
? polygon
|
||||
: ([...polygon, polygon[0]] as Polygon<Point>);
|
||||
}
|
||||
|
||||
export function polygonIsClosed<Point extends LocalPoint | GlobalPoint>(
|
||||
polygon: readonly Point[],
|
||||
tolerance: number = PRECISION,
|
||||
) {
|
||||
return pointsEqual(polygon[0], polygon[polygon.length - 1], tolerance);
|
||||
}
|
||||
|
||||
/**
|
||||
* The signed area of a polygon via the shoelace formula. Positive when the
|
||||
* vertices wind counter-clockwise in a y-down coordinate system.
|
||||
*
|
||||
* The polygon may be given open or closed; a closing vertex
|
||||
*/
|
||||
export function polygonSignedArea<Point extends LocalPoint | GlobalPoint>(
|
||||
polygon: readonly Point[],
|
||||
tolerance: number = PRECISION,
|
||||
): number {
|
||||
const pts = polygonIsClosed(polygon, tolerance)
|
||||
? polygon.slice(0, -1)
|
||||
: polygon;
|
||||
let sum = 0;
|
||||
for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) {
|
||||
sum += pts[j][0] * pts[i][1] - pts[i][0] * pts[j][1];
|
||||
}
|
||||
return sum / 2;
|
||||
}
|
||||
|
||||
export function polygonArea<Point extends LocalPoint | GlobalPoint>(
|
||||
polygon: readonly Point[],
|
||||
tolerance: number = PRECISION,
|
||||
): number {
|
||||
return Math.abs(polygonSignedArea(polygon, tolerance));
|
||||
}
|
||||
|
||||
/**
|
||||
* The convex hull of a point set via Andrew's monotone chain.
|
||||
*
|
||||
* @returns The hull vertices in counter-clockwise order (y-down), without a
|
||||
* repeated closing vertex.
|
||||
*/
|
||||
export function convexHull<Point extends LocalPoint | GlobalPoint>(
|
||||
points: readonly Point[],
|
||||
): Point[] {
|
||||
if (points.length < 3) {
|
||||
return [...points];
|
||||
}
|
||||
|
||||
const sorted = [...points].sort((a, b) =>
|
||||
a[0] === b[0] ? a[1] - b[1] : a[0] - b[0],
|
||||
);
|
||||
|
||||
// Cross product of OA x OB. Negative means the turn O->A->B is clockwise.
|
||||
const cross = (o: Point, a: Point, b: Point) =>
|
||||
(a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
|
||||
|
||||
const half = (pts: Point[]) => {
|
||||
const chain: Point[] = [];
|
||||
for (const p of pts) {
|
||||
while (
|
||||
chain.length >= 2 &&
|
||||
cross(chain[chain.length - 2], chain[chain.length - 1], p) <= 0
|
||||
) {
|
||||
chain.pop();
|
||||
}
|
||||
chain.push(p);
|
||||
}
|
||||
chain.pop(); // shared with the other half's first vertex
|
||||
return chain;
|
||||
};
|
||||
|
||||
const hull = [...half(sorted), ...half([...sorted].reverse())];
|
||||
|
||||
return hull.length >= 3 ? hull : [...points];
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the points of a convex polygon that only contribute a shallow turn,
|
||||
* merging each run of near-collinear points into one.
|
||||
*
|
||||
* @param polygon A convex polygon, as returned by `convexHull`.
|
||||
* @param angleThreshold Minimum accumulated turn (radians) for a vertex to be
|
||||
* kept.
|
||||
*/
|
||||
export function simplifyConvexPolygon<Point extends LocalPoint | GlobalPoint>(
|
||||
polygon: readonly Point[],
|
||||
angleThreshold: number,
|
||||
): Point[] {
|
||||
if (polygon.length < 3) {
|
||||
return [...polygon];
|
||||
}
|
||||
|
||||
const turnAt = (i: number) => {
|
||||
const prev = polygon[(i - 1 + polygon.length) % polygon.length];
|
||||
const curr = polygon[i];
|
||||
const next = polygon[(i + 1) % polygon.length];
|
||||
const inAngle = Math.atan2(curr[1] - prev[1], curr[0] - prev[0]);
|
||||
const outAngle = Math.atan2(next[1] - curr[1], next[0] - curr[0]);
|
||||
return Math.abs(normalizeTurn(outAngle - inAngle));
|
||||
};
|
||||
|
||||
// Walk the hull accumulating turn, emitting a vertex whenever enough of it
|
||||
// has built up. Starting at the sharpest vertex keeps the result stable: a
|
||||
// real corner always ends a run rather than being split across the seam.
|
||||
let start = 0;
|
||||
for (let i = 1; i < polygon.length; i++) {
|
||||
if (turnAt(i) > turnAt(start)) {
|
||||
start = i;
|
||||
}
|
||||
}
|
||||
|
||||
const simplified: Point[] = [];
|
||||
let accumulated = 0;
|
||||
for (let k = 0; k < polygon.length; k++) {
|
||||
const i = (start + k) % polygon.length;
|
||||
accumulated += turnAt(i);
|
||||
if (accumulated >= angleThreshold) {
|
||||
simplified.push(polygon[i]);
|
||||
accumulated = 0;
|
||||
}
|
||||
}
|
||||
|
||||
return simplified.length >= 3 ? simplified : [...polygon];
|
||||
}
|
||||
|
||||
function normalizeTurn(angle: number): number {
|
||||
let a = angle;
|
||||
while (a > Math.PI) {
|
||||
a -= 2 * Math.PI;
|
||||
}
|
||||
while (a < -Math.PI) {
|
||||
a += 2 * Math.PI;
|
||||
}
|
||||
return a;
|
||||
}
|
||||
83
packages/math/src/range.ts
Normal file
83
packages/math/src/range.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { toBrandedType } from "@excalidraw/common";
|
||||
|
||||
import type { InclusiveRange } from "./types";
|
||||
|
||||
/**
|
||||
* Create an inclusive range from the two numbers provided.
|
||||
*
|
||||
* @param start Start of the range
|
||||
* @param end End of the range
|
||||
* @returns
|
||||
*/
|
||||
export function rangeInclusive(start: number, end: number): InclusiveRange {
|
||||
return toBrandedType<InclusiveRange>([start, end]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn a number pair into an inclusive range.
|
||||
*
|
||||
* @param pair The number pair to convert to an inclusive range
|
||||
* @returns The new inclusive range
|
||||
*/
|
||||
export function rangeInclusiveFromPair(pair: [start: number, end: number]) {
|
||||
return toBrandedType<InclusiveRange>(pair);
|
||||
}
|
||||
|
||||
/**
|
||||
* Given two ranges, return if the two ranges overlap with each other e.g.
|
||||
* [1, 3] overlaps with [2, 4] while [1, 3] does not overlap with [4, 5].
|
||||
*
|
||||
* @param param0 One of the ranges to compare
|
||||
* @param param1 The other range to compare against
|
||||
* @returns TRUE if the ranges overlap
|
||||
*/
|
||||
export const rangesOverlap = (
|
||||
[a0, a1]: InclusiveRange,
|
||||
[b0, b1]: InclusiveRange,
|
||||
): boolean => {
|
||||
if (a0 <= b0) {
|
||||
return a1 >= b0;
|
||||
}
|
||||
|
||||
if (a0 >= b0) {
|
||||
return b1 >= a0;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Given two ranges,return ther intersection of the two ranges if any e.g. the
|
||||
* intersection of [1, 3] and [2, 4] is [2, 3].
|
||||
*
|
||||
* @param param0 The first range to compare
|
||||
* @param param1 The second range to compare
|
||||
* @returns The inclusive range intersection or NULL if no intersection
|
||||
*/
|
||||
export const rangeIntersection = (
|
||||
[a0, a1]: InclusiveRange,
|
||||
[b0, b1]: InclusiveRange,
|
||||
): InclusiveRange | null => {
|
||||
const rangeStart = Math.max(a0, b0);
|
||||
const rangeEnd = Math.min(a1, b1);
|
||||
|
||||
if (rangeStart <= rangeEnd) {
|
||||
return toBrandedType<InclusiveRange>([rangeStart, rangeEnd]);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Determine if a value is inside a range.
|
||||
*
|
||||
* @param value The value to check
|
||||
* @param range The range
|
||||
* @returns
|
||||
*/
|
||||
export const rangeIncludesValue = (
|
||||
value: number,
|
||||
[min, max]: InclusiveRange,
|
||||
): boolean => {
|
||||
return value >= min && value <= max;
|
||||
};
|
||||
39
packages/math/src/rectangle.ts
Normal file
39
packages/math/src/rectangle.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { pointFrom } from "./point";
|
||||
import { lineSegment, lineSegmentIntersectionPoints } from "./segment";
|
||||
|
||||
import type { GlobalPoint, LineSegment, LocalPoint, Rectangle } from "./types";
|
||||
|
||||
export function rectangle<P extends GlobalPoint | LocalPoint>(
|
||||
topLeft: P,
|
||||
bottomRight: P,
|
||||
): Rectangle<P> {
|
||||
return [topLeft, bottomRight] as Rectangle<P>;
|
||||
}
|
||||
|
||||
export function rectangleFromNumberSequence<
|
||||
Point extends LocalPoint | GlobalPoint,
|
||||
>(minX: number, minY: number, maxX: number, maxY: number) {
|
||||
return rectangle(pointFrom<Point>(minX, minY), pointFrom<Point>(maxX, maxY));
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
export function rectangleIntersectRectangle<
|
||||
Point extends LocalPoint | GlobalPoint,
|
||||
>(rectangle1: Rectangle<Point>, rectangle2: Rectangle<Point>): boolean {
|
||||
const [[minX1, minY1], [maxX1, maxY1]] = rectangle1;
|
||||
const [[minX2, minY2], [maxX2, maxY2]] = rectangle2;
|
||||
|
||||
return minX1 < maxX2 && maxX1 > minX2 && minY1 < maxY2 && maxY1 > minY2;
|
||||
}
|
||||
204
packages/math/src/segment.ts
Normal file
204
packages/math/src/segment.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import { line, linesIntersectAt } from "./line";
|
||||
|
||||
import {
|
||||
isPoint,
|
||||
pointCenter,
|
||||
pointDistance,
|
||||
pointFrom,
|
||||
pointFromVector,
|
||||
pointRotateRads,
|
||||
} from "./point";
|
||||
import { PRECISION } from "./utils";
|
||||
import {
|
||||
vectorAdd,
|
||||
vectorCross,
|
||||
vectorFromPoint,
|
||||
vectorScale,
|
||||
vectorSubtract,
|
||||
} from "./vector";
|
||||
|
||||
import type { GlobalPoint, LineSegment, LocalPoint, Radians } from "./types";
|
||||
|
||||
/**
|
||||
* Create a line segment from two points.
|
||||
*
|
||||
* @param points The two points delimiting the line segment on each end
|
||||
* @returns The line segment delineated by the points
|
||||
*/
|
||||
export function lineSegment<P extends GlobalPoint | LocalPoint>(
|
||||
a: P,
|
||||
b: P,
|
||||
): LineSegment<P> {
|
||||
return [a, b] as LineSegment<P>;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param segment
|
||||
* @returns
|
||||
*/
|
||||
export const isLineSegment = <Point extends GlobalPoint | LocalPoint>(
|
||||
segment: unknown,
|
||||
): segment is LineSegment<Point> =>
|
||||
Array.isArray(segment) &&
|
||||
segment.length === 2 &&
|
||||
isPoint(segment[0]) &&
|
||||
isPoint(segment[1]);
|
||||
|
||||
/**
|
||||
* Return the coordinates resulting from rotating the given line about an origin by an angle in radians
|
||||
* note that when the origin is not given, the midpoint of the given line is used as the origin.
|
||||
*
|
||||
* @param l
|
||||
* @param angle
|
||||
* @param origin
|
||||
* @returns
|
||||
*/
|
||||
export const lineSegmentRotate = <Point extends LocalPoint | GlobalPoint>(
|
||||
l: LineSegment<Point>,
|
||||
angle: Radians,
|
||||
origin?: Point,
|
||||
): LineSegment<Point> => {
|
||||
return lineSegment(
|
||||
pointRotateRads(l[0], origin || pointCenter(l[0], l[1]), angle),
|
||||
pointRotateRads(l[1], origin || pointCenter(l[0], l[1]), angle),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates the point two line segments with a definite start and end point
|
||||
* intersect at.
|
||||
*/
|
||||
export const segmentsIntersectAt = <Point extends GlobalPoint | LocalPoint>(
|
||||
a: Readonly<LineSegment<Point>>,
|
||||
b: Readonly<LineSegment<Point>>,
|
||||
): Point | null => {
|
||||
const a0 = vectorFromPoint(a[0]);
|
||||
const a1 = vectorFromPoint(a[1]);
|
||||
const b0 = vectorFromPoint(b[0]);
|
||||
const b1 = vectorFromPoint(b[1]);
|
||||
const r = vectorSubtract(a1, a0);
|
||||
const s = vectorSubtract(b1, b0);
|
||||
const denominator = vectorCross(r, s);
|
||||
|
||||
if (denominator === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const i = vectorSubtract(vectorFromPoint(b[0]), vectorFromPoint(a[0]));
|
||||
const u = vectorCross(i, r) / denominator;
|
||||
const t = vectorCross(i, s) / denominator;
|
||||
|
||||
if (u === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const p = vectorAdd(a0, vectorScale(r, t));
|
||||
|
||||
if (t >= 0 && t < 1 && u >= 0 && u < 1) {
|
||||
return pointFromVector<Point>(p);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const pointOnLineSegment = <Point extends LocalPoint | GlobalPoint>(
|
||||
point: Point,
|
||||
line: LineSegment<Point>,
|
||||
threshold = PRECISION,
|
||||
) => {
|
||||
const distance = distanceToLineSegment(point, line);
|
||||
|
||||
if (distance === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return distance < threshold;
|
||||
};
|
||||
|
||||
export const distanceToLineSegment = <Point extends LocalPoint | GlobalPoint>(
|
||||
point: Point,
|
||||
line: LineSegment<Point>,
|
||||
) => {
|
||||
return pointDistance(
|
||||
point,
|
||||
lineSegmentPointAt(line, lineSegmentClosestParameter(point, line)),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the point at parameter `t` along the segment, where `t = 0` is the
|
||||
* segment's start and `t = 1` its end. Not clamped.
|
||||
*/
|
||||
export function lineSegmentPointAt<Point extends GlobalPoint | LocalPoint>(
|
||||
line: LineSegment<Point>,
|
||||
t: number,
|
||||
): Point {
|
||||
const [[x1, y1], [x2, y2]] = line;
|
||||
|
||||
return pointFrom<Point>(x1 + t * (x2 - x1), y1 + t * (y2 - y1));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the intersection point of a segment and a line
|
||||
*
|
||||
* @param l
|
||||
* @param s
|
||||
* @returns
|
||||
*/
|
||||
export function lineSegmentIntersectionPoints<
|
||||
Point extends GlobalPoint | LocalPoint,
|
||||
>(
|
||||
l: LineSegment<Point>,
|
||||
s: LineSegment<Point>,
|
||||
threshold?: number,
|
||||
): Point | null {
|
||||
const candidate = linesIntersectAt(line(l[0], l[1]), line(s[0], s[1]));
|
||||
|
||||
if (
|
||||
!candidate ||
|
||||
!pointOnLineSegment(candidate, s, threshold) ||
|
||||
!pointOnLineSegment(candidate, l, threshold)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return candidate;
|
||||
}
|
||||
|
||||
export function lineSegmentsDistance<Point extends GlobalPoint | LocalPoint>(
|
||||
s1: LineSegment<Point>,
|
||||
s2: LineSegment<Point>,
|
||||
): number {
|
||||
if (lineSegmentIntersectionPoints(s1, s2)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.min(
|
||||
distanceToLineSegment(s1[0], s2),
|
||||
distanceToLineSegment(s1[1], s2),
|
||||
distanceToLineSegment(s2[0], s1),
|
||||
distanceToLineSegment(s2[1], s1),
|
||||
);
|
||||
}
|
||||
|
||||
export function lineSegmentClosestParameter<
|
||||
Point extends GlobalPoint | LocalPoint,
|
||||
>(point: Point, line: LineSegment<Point>): number {
|
||||
const [x, y] = point;
|
||||
const [[x1, y1], [x2, y2]] = line;
|
||||
|
||||
const A = x - x1;
|
||||
const B = y - y1;
|
||||
const C = x2 - x1;
|
||||
const D = y2 - y1;
|
||||
|
||||
const dot = A * C + B * D;
|
||||
const len_sq = C * C + D * D;
|
||||
let param = 0;
|
||||
if (len_sq !== 0) {
|
||||
param = dot / len_sq;
|
||||
}
|
||||
|
||||
return Math.max(0, Math.min(1, param));
|
||||
}
|
||||
28
packages/math/src/triangle.ts
Normal file
28
packages/math/src/triangle.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { GlobalPoint, LocalPoint, Triangle } from "./types";
|
||||
|
||||
// Types
|
||||
|
||||
/**
|
||||
* Tests if a point lies inside a triangle. This function
|
||||
* will return FALSE if the point lies exactly on the sides
|
||||
* of the triangle.
|
||||
*
|
||||
* @param triangle The triangle to test the point for
|
||||
* @param p The point to test whether is in the triangle
|
||||
* @returns TRUE if the point is inside of the triangle
|
||||
*/
|
||||
export function triangleIncludesPoint<P extends GlobalPoint | LocalPoint>(
|
||||
[a, b, c]: Triangle<P>,
|
||||
p: P,
|
||||
): boolean {
|
||||
const triangleSign = (p1: P, p2: P, p3: P) =>
|
||||
(p1[0] - p3[0]) * (p2[1] - p3[1]) - (p2[0] - p3[0]) * (p1[1] - p3[1]);
|
||||
const d1 = triangleSign(p, a, b);
|
||||
const d2 = triangleSign(p, b, c);
|
||||
const d3 = triangleSign(p, c, a);
|
||||
|
||||
const has_neg = d1 < 0 || d2 < 0 || d3 < 0;
|
||||
const has_pos = d1 > 0 || d2 > 0 || d3 > 0;
|
||||
|
||||
return !(has_neg && has_pos);
|
||||
}
|
||||
162
packages/math/src/types.ts
Normal file
162
packages/math/src/types.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
//
|
||||
// Measurements
|
||||
//
|
||||
|
||||
/**
|
||||
* By definition one radian is the angle subtended at the centre
|
||||
* of a circle by an arc that is equal in length to the radius.
|
||||
*/
|
||||
export type Radians = number & { _brand: "excalimath__radian" };
|
||||
|
||||
/**
|
||||
* An angle measurement of a plane angle in which one full
|
||||
* rotation is 360 degrees.
|
||||
*/
|
||||
export type Degrees = number & { _brand: "excalimath_degree" };
|
||||
|
||||
//
|
||||
// Range
|
||||
//
|
||||
|
||||
/**
|
||||
* A number range which includes the start and end numbers in the range.
|
||||
*/
|
||||
export type InclusiveRange = [number, number] & { _brand: "excalimath_degree" };
|
||||
|
||||
//
|
||||
// Point
|
||||
//
|
||||
|
||||
/**
|
||||
* Represents a 2D position in world/canvas/scene space. A
|
||||
* global coordinate.
|
||||
*/
|
||||
export type GlobalPoint = [x: number, y: number] & {
|
||||
_brand: "excalimath__globalpoint";
|
||||
};
|
||||
|
||||
/**
|
||||
* Represents a 2D position in world/canvas/scene space. A
|
||||
* global coordinate.
|
||||
*
|
||||
* TODO remove this once we migrate the codebase to use Point tuples everywhere
|
||||
*/
|
||||
export type GlobalCoord = { x: number; y: number } & {
|
||||
_brand: "excalimath__globalcoord";
|
||||
};
|
||||
|
||||
/**
|
||||
* Represents a 2D position in whatever local space it's
|
||||
* needed. A local coordinate.
|
||||
*/
|
||||
export type LocalPoint = [x: number, y: number] & {
|
||||
_brand: "excalimath__localpoint";
|
||||
};
|
||||
|
||||
/**
|
||||
* Represents a 2D position in whatever local space it's needed.
|
||||
* A local coordinate.
|
||||
*
|
||||
* TODO remove this once we migrate the codebase to use Point tuples everywhere
|
||||
*/
|
||||
export type LocalCoord = { x: number; y: number } & {
|
||||
_brand: "excalimath__localcoord";
|
||||
};
|
||||
|
||||
// Line
|
||||
|
||||
/**
|
||||
* A line is an infinitely long object with no width, depth, or curvature.
|
||||
*/
|
||||
export type Line<P extends GlobalPoint | LocalPoint> = [p: P, q: P] & {
|
||||
_brand: "excalimath_line";
|
||||
};
|
||||
|
||||
/**
|
||||
* In geometry, a line segment is a part of a straight
|
||||
* line that is bounded by two distinct end points, and
|
||||
* contains every point on the line that is between its endpoints.
|
||||
*/
|
||||
export type LineSegment<P extends GlobalPoint | LocalPoint> = [a: P, b: P] & {
|
||||
_brand: "excalimath_linesegment";
|
||||
};
|
||||
|
||||
//
|
||||
// Vector
|
||||
//
|
||||
|
||||
/**
|
||||
* Represents a 2D vector
|
||||
*/
|
||||
export type Vector = [u: number, v: number] & {
|
||||
_brand: "excalimath__vector";
|
||||
};
|
||||
|
||||
// Triangles
|
||||
|
||||
/**
|
||||
* A triangle represented by 3 points
|
||||
*/
|
||||
export type Triangle<P extends GlobalPoint | LocalPoint> = [
|
||||
a: P,
|
||||
b: P,
|
||||
c: P,
|
||||
] & {
|
||||
_brand: "excalimath__triangle";
|
||||
};
|
||||
|
||||
/**
|
||||
* A rectangular shape represented by 4 points at its corners
|
||||
*/
|
||||
export type Rectangle<P extends GlobalPoint | LocalPoint> = [a: P, b: P] & {
|
||||
_brand: "excalimath__rectangle";
|
||||
};
|
||||
|
||||
//
|
||||
// Polygon
|
||||
//
|
||||
|
||||
/**
|
||||
* A polygon is a closed shape by connecting the given points
|
||||
* rectangles and diamonds are modelled by polygons
|
||||
*/
|
||||
export type Polygon<Point extends GlobalPoint | LocalPoint> = Point[] & {
|
||||
_brand: "excalimath_polygon";
|
||||
};
|
||||
|
||||
//
|
||||
// Curve
|
||||
//
|
||||
|
||||
/**
|
||||
* Cubic bezier curve with four control points
|
||||
*/
|
||||
export type Curve<Point extends GlobalPoint | LocalPoint> = [
|
||||
Point,
|
||||
Point,
|
||||
Point,
|
||||
Point,
|
||||
] & {
|
||||
_brand: "excalimath_curve";
|
||||
};
|
||||
|
||||
export type PolarCoords = [
|
||||
radius: number,
|
||||
/** angle in radians */
|
||||
angle: number,
|
||||
];
|
||||
|
||||
/**
|
||||
An ellipse is specified by its center, angle, and its major and minor axes
|
||||
but for the sake of simplicity, we've used halfWidth and halfHeight instead
|
||||
in replace of semi major and semi minor axes
|
||||
*/
|
||||
export type Ellipse<Point extends GlobalPoint | LocalPoint> = {
|
||||
center: Point;
|
||||
halfWidth: number;
|
||||
halfHeight: number;
|
||||
} & {
|
||||
_brand: "excalimath_ellipse";
|
||||
};
|
||||
|
||||
export type ElementsSegmentsMap = Map<string, LineSegment<GlobalPoint>[]>;
|
||||
33
packages/math/src/utils.ts
Normal file
33
packages/math/src/utils.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
export const PRECISION = 10e-5;
|
||||
|
||||
export const clamp = (value: number, min: number, max: number) => {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
};
|
||||
|
||||
export const round = (
|
||||
value: number,
|
||||
precision: number,
|
||||
func: "round" | "floor" | "ceil" = "round",
|
||||
) => {
|
||||
const multiplier = Math.pow(10, precision);
|
||||
|
||||
return Math[func]((value + Number.EPSILON) * multiplier) / multiplier;
|
||||
};
|
||||
|
||||
export const roundToStep = (
|
||||
value: number,
|
||||
step: number,
|
||||
func: "round" | "floor" | "ceil" = "round",
|
||||
): number => {
|
||||
const factor = 1 / step;
|
||||
return Math[func](value * factor) / factor;
|
||||
};
|
||||
|
||||
export const average = (a: number, b: number) => (a + b) / 2;
|
||||
|
||||
export const isFiniteNumber = (value: any): value is number => {
|
||||
return typeof value === "number" && Number.isFinite(value);
|
||||
};
|
||||
|
||||
export const isCloseTo = (a: number, b: number, precision = PRECISION) =>
|
||||
Math.abs(a - b) < precision;
|
||||
160
packages/math/src/vector.ts
Normal file
160
packages/math/src/vector.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import type { GlobalPoint, LocalPoint, Vector } from "./types";
|
||||
|
||||
/**
|
||||
* Create a vector from the x and y coordiante elements.
|
||||
*
|
||||
* @param x The X aspect of the vector
|
||||
* @param y T Y aspect of the vector
|
||||
* @returns The constructed vector with X and Y as the coordinates
|
||||
*/
|
||||
export function vector(
|
||||
x: number,
|
||||
y: number,
|
||||
originX: number = 0,
|
||||
originY: number = 0,
|
||||
): Vector {
|
||||
return [x - originX, y - originY] as Vector;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn a point into a vector with the origin point.
|
||||
*
|
||||
* @param p The point to turn into a vector
|
||||
* @param origin The origin point in a given coordiante system
|
||||
* @param threshold The threshold to consider the vector as 'undefined'
|
||||
* @param defaultValue The default value to return if the vector is 'undefined'
|
||||
* @returns The created vector from the point and the origin or default
|
||||
*/
|
||||
export function vectorFromPoint<Point extends GlobalPoint | LocalPoint>(
|
||||
p: Point,
|
||||
origin: Point = [0, 0] as Point,
|
||||
threshold?: number,
|
||||
defaultValue: Vector = [0, 1] as Vector,
|
||||
): Vector {
|
||||
const vec = vector(p[0] - origin[0], p[1] - origin[1]);
|
||||
|
||||
if (threshold && vectorMagnitudeSq(vec) < threshold * threshold) {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
return vec;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross product is a binary operation on two vectors in 2D space.
|
||||
* It results in a vector that is perpendicular to both vectors.
|
||||
*
|
||||
* @param a One of the vectors to use for the directed area calculation
|
||||
* @param b The other vector to use for the directed area calculation
|
||||
* @returns The directed area value for the two vectos
|
||||
*/
|
||||
export function vectorCross(a: Vector, b: Vector): number {
|
||||
return a[0] * b[1] - b[0] * a[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Dot product is defined as the sum of the products of the
|
||||
* two vectors.
|
||||
*
|
||||
* @param a One of the vectors for which the sum of products is calculated
|
||||
* @param b The other vector for which the sum of products is calculated
|
||||
* @returns The sum of products of the two vectors
|
||||
*/
|
||||
export function vectorDot(a: Vector, b: Vector) {
|
||||
return a[0] * b[0] + a[1] * b[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if the value has the shape of a Vector.
|
||||
*
|
||||
* @param v The value to test
|
||||
* @returns TRUE if the value has the shape and components of a Vectors
|
||||
*/
|
||||
export function isVector(v: unknown): v is Vector {
|
||||
return (
|
||||
Array.isArray(v) &&
|
||||
v.length === 2 &&
|
||||
typeof v[0] === "number" &&
|
||||
!isNaN(v[0]) &&
|
||||
typeof v[1] === "number" &&
|
||||
!isNaN(v[1])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add two vectors by adding their coordinates.
|
||||
*
|
||||
* @param a One of the vectors to add
|
||||
* @param b The other vector to add
|
||||
* @returns The sum vector of the two provided vectors
|
||||
*/
|
||||
export function vectorAdd(a: Readonly<Vector>, b: Readonly<Vector>): Vector {
|
||||
return [a[0] + b[0], a[1] + b[1]] as Vector;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add two vectors by adding their coordinates.
|
||||
*
|
||||
* @param start One of the vectors to add
|
||||
* @param end The other vector to add
|
||||
* @returns The sum vector of the two provided vectors
|
||||
*/
|
||||
export function vectorSubtract(
|
||||
start: Readonly<Vector>,
|
||||
end: Readonly<Vector>,
|
||||
): Vector {
|
||||
return [start[0] - end[0], start[1] - end[1]] as Vector;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scale vector by a scalar.
|
||||
*
|
||||
* @param v The vector to scale
|
||||
* @param scalar The scalar to multiply the vector components with
|
||||
* @returns The new scaled vector
|
||||
*/
|
||||
export function vectorScale(v: Vector, scalar: number): Vector {
|
||||
return vector(v[0] * scalar, v[1] * scalar);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the sqare magnitude of a vector. Use this if you compare
|
||||
* magnitudes as it saves you an SQRT.
|
||||
*
|
||||
* @param v The vector to measure
|
||||
* @returns The scalar squared magnitude of the vector
|
||||
*/
|
||||
export function vectorMagnitudeSq(v: Vector) {
|
||||
return v[0] * v[0] + v[1] * v[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the magnitude of a vector.
|
||||
*
|
||||
* @param v The vector to measure
|
||||
* @returns The scalar magnitude of the vector
|
||||
*/
|
||||
export function vectorMagnitude(v: Vector) {
|
||||
return Math.sqrt(vectorMagnitudeSq(v));
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize the vector (i.e. make the vector magnitue equal 1).
|
||||
*
|
||||
* @param v The vector to normalize
|
||||
* @returns The new normalized vector
|
||||
*/
|
||||
export const vectorNormalize = (v: Vector): Vector => {
|
||||
const m = vectorMagnitude(v);
|
||||
|
||||
if (m === 0) {
|
||||
return vector(0, 0);
|
||||
}
|
||||
|
||||
return vector(v[0] / m, v[1] / m);
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculate the right-hand normal of the vector.
|
||||
*/
|
||||
export const vectorNormal = (v: Vector): Vector => vector(v[1], -v[0]);
|
||||
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);
|
||||
});
|
||||
});
|
||||
9
packages/math/tsconfig.json
Normal file
9
packages/math/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "../",
|
||||
"outDir": "./dist/types"
|
||||
},
|
||||
"include": ["src/**/*", "global.d.ts"],
|
||||
"exclude": ["**/*.test.*", "tests", "types", "examples", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user