chore: Unify math types, utils and functions (#8389)

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
This commit is contained in:
Márk Tolmács 2024-09-03 00:23:38 +02:00 committed by GitHub
parent e3d1dee9d0
commit f4dd23fc31
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
98 changed files with 4291 additions and 3661 deletions

View file

21
packages/math/README.md Normal file
View file

@ -0,0 +1,21 @@
# @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
```
## API

47
packages/math/angle.ts Normal file
View file

@ -0,0 +1,47 @@
import type {
Degrees,
GlobalPoint,
LocalPoint,
PolarCoords,
Radians,
} from "./types";
import { PRECISION } from "./utils";
// TODO: Simplify with modulo and fix for angles beyond 4*Math.PI and - 4*Math.PI
export const normalizeRadians = (angle: Radians): Radians => {
if (angle < 0) {
return (angle + 2 * Math.PI) as Radians;
}
if (angle >= 2 * Math.PI) {
return (angle - 2 * Math.PI) as Radians;
}
return angle;
};
/**
* 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), Math.atan2(y, x)];
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;
}

41
packages/math/arc.test.ts Normal file
View file

@ -0,0 +1,41 @@
import { isPointOnSymmetricArc } from "./arc";
import { point } from "./point";
describe("point on arc", () => {
it("should detect point on simple arc", () => {
expect(
isPointOnSymmetricArc(
{
radius: 1,
startAngle: -Math.PI / 4,
endAngle: Math.PI / 4,
},
point(0.92291667, 0.385),
),
).toBe(true);
});
it("should not detect point outside of a simple arc", () => {
expect(
isPointOnSymmetricArc(
{
radius: 1,
startAngle: -Math.PI / 4,
endAngle: Math.PI / 4,
},
point(-0.92291667, 0.385),
),
).toBe(false);
});
it("should not detect point with good angle but incorrect radius", () => {
expect(
isPointOnSymmetricArc(
{
radius: 1,
startAngle: -Math.PI / 4,
endAngle: Math.PI / 4,
},
point(-0.5, 0.5),
),
).toBe(false);
});
});

20
packages/math/arc.ts Normal file
View file

@ -0,0 +1,20 @@
import { cartesian2Polar } from "./angle";
import type { GlobalPoint, LocalPoint, SymmetricArc } from "./types";
import { PRECISION } from "./utils";
/**
* Determines if a cartesian point lies on a symmetric arc, i.e. an arc which
* is part of a circle contour centered on 0, 0.
*/
export const isPointOnSymmetricArc = <P extends GlobalPoint | LocalPoint>(
{ radius: arcRadius, startAngle, endAngle }: SymmetricArc,
point: P,
): boolean => {
const [radius, angle] = cartesian2Polar(point);
return startAngle < endAngle
? Math.abs(radius - arcRadius) < PRECISION &&
startAngle <= angle &&
endAngle >= angle
: startAngle <= angle || endAngle >= angle;
};

223
packages/math/curve.ts Normal file
View file

@ -0,0 +1,223 @@
import { point, pointRotateRads } from "./point";
import type { Curve, GlobalPoint, LocalPoint, Radians } 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>;
}
export const curveRotate = <Point extends LocalPoint | GlobalPoint>(
curve: Curve<Point>,
angle: Radians,
origin: Point,
) => {
return curve.map((p) => pointRotateRads(p, origin, angle));
};
/**
*
* @param pointsIn
* @param curveTightness
* @returns
*/
export function curveToBezier<Point extends LocalPoint | GlobalPoint>(
pointsIn: readonly Point[],
curveTightness = 0,
): Point[] {
const len = pointsIn.length;
if (len < 3) {
throw new Error("A curve must have at least three points.");
}
const out: Point[] = [];
if (len === 3) {
out.push(
point(pointsIn[0][0], pointsIn[0][1]), // Points need to be cloned
point(pointsIn[1][0], pointsIn[1][1]), // Points need to be cloned
point(pointsIn[2][0], pointsIn[2][1]), // Points need to be cloned
point(pointsIn[2][0], pointsIn[2][1]), // Points need to be cloned
);
} else {
const points: Point[] = [];
points.push(pointsIn[0], pointsIn[0]);
for (let i = 1; i < pointsIn.length; i++) {
points.push(pointsIn[i]);
if (i === pointsIn.length - 1) {
points.push(pointsIn[i]);
}
}
const b: Point[] = [];
const s = 1 - curveTightness;
out.push(point(points[0][0], points[0][1]));
for (let i = 1; i + 2 < points.length; i++) {
const cachedVertArray = points[i];
b[0] = point(cachedVertArray[0], cachedVertArray[1]);
b[1] = point(
cachedVertArray[0] + (s * points[i + 1][0] - s * points[i - 1][0]) / 6,
cachedVertArray[1] + (s * points[i + 1][1] - s * points[i - 1][1]) / 6,
);
b[2] = point(
points[i + 1][0] + (s * points[i][0] - s * points[i + 2][0]) / 6,
points[i + 1][1] + (s * points[i][1] - s * points[i + 2][1]) / 6,
);
b[3] = point(points[i + 1][0], points[i + 1][1]);
out.push(b[1], b[2], b[3]);
}
}
return out;
}
/**
*
* @param t
* @param controlPoints
* @returns
*/
export const cubicBezierPoint = <Point extends LocalPoint | GlobalPoint>(
t: number,
controlPoints: Curve<Point>,
): Point => {
const [p0, p1, p2, p3] = controlPoints;
const x =
Math.pow(1 - t, 3) * p0[0] +
3 * Math.pow(1 - t, 2) * t * p1[0] +
3 * (1 - t) * Math.pow(t, 2) * p2[0] +
Math.pow(t, 3) * p3[0];
const y =
Math.pow(1 - t, 3) * p0[1] +
3 * Math.pow(1 - t, 2) * t * p1[1] +
3 * (1 - t) * Math.pow(t, 2) * p2[1] +
Math.pow(t, 3) * p3[1];
return point(x, y);
};
/**
*
* @param point
* @param controlPoints
* @returns
*/
export const cubicBezierDistance = <Point extends LocalPoint | GlobalPoint>(
point: Point,
controlPoints: Curve<Point>,
) => {
// Calculate the closest point on the Bezier curve to the given point
const t = findClosestParameter(point, controlPoints);
// Calculate the coordinates of the closest point on the curve
const [closestX, closestY] = cubicBezierPoint(t, controlPoints);
// Calculate the distance between the given point and the closest point on the curve
const distance = Math.sqrt(
(point[0] - closestX) ** 2 + (point[1] - closestY) ** 2,
);
return distance;
};
const solveCubic = (a: number, b: number, c: number, d: number) => {
// This function solves the cubic equation ax^3 + bx^2 + cx + d = 0
const roots: number[] = [];
const discriminant =
18 * a * b * c * d -
4 * Math.pow(b, 3) * d +
Math.pow(b, 2) * Math.pow(c, 2) -
4 * a * Math.pow(c, 3) -
27 * Math.pow(a, 2) * Math.pow(d, 2);
if (discriminant >= 0) {
const C = Math.cbrt((discriminant + Math.sqrt(discriminant)) / 2);
const D = Math.cbrt((discriminant - Math.sqrt(discriminant)) / 2);
const root1 = (-b - C - D) / (3 * a);
const root2 = (-b + (C + D) / 2) / (3 * a);
const root3 = (-b + (C + D) / 2) / (3 * a);
roots.push(root1, root2, root3);
} else {
const realPart = -b / (3 * a);
const root1 =
2 * Math.sqrt(-b / (3 * a)) * Math.cos(Math.acos(realPart) / 3);
const root2 =
2 *
Math.sqrt(-b / (3 * a)) *
Math.cos((Math.acos(realPart) + 2 * Math.PI) / 3);
const root3 =
2 *
Math.sqrt(-b / (3 * a)) *
Math.cos((Math.acos(realPart) + 4 * Math.PI) / 3);
roots.push(root1, root2, root3);
}
return roots;
};
const findClosestParameter = <Point extends LocalPoint | GlobalPoint>(
point: Point,
controlPoints: Curve<Point>,
) => {
// This function finds the parameter t that minimizes the distance between the point
// and any point on the cubic Bezier curve.
const [p0, p1, p2, p3] = controlPoints;
// Use the direct formula to find the parameter t
const a = p3[0] - 3 * p2[0] + 3 * p1[0] - p0[0];
const b = 3 * p2[0] - 6 * p1[0] + 3 * p0[0];
const c = 3 * p1[0] - 3 * p0[0];
const d = p0[0] - point[0];
const rootsX = solveCubic(a, b, c, d);
// Do the same for the y-coordinate
const e = p3[1] - 3 * p2[1] + 3 * p1[1] - p0[1];
const f = 3 * p2[1] - 6 * p1[1] + 3 * p0[1];
const g = 3 * p1[1] - 3 * p0[1];
const h = p0[1] - point[1];
const rootsY = solveCubic(e, f, g, h);
// Select the real root that is between 0 and 1 (inclusive)
const validRootsX = rootsX.filter((root) => root >= 0 && root <= 1);
const validRootsY = rootsY.filter((root) => root >= 0 && root <= 1);
if (validRootsX.length === 0 || validRootsY.length === 0) {
// No valid roots found, use the midpoint as a fallback
return 0.5;
}
// Choose the parameter t that minimizes the distance
let minDistance = Infinity;
let closestT = 0;
for (const rootX of validRootsX) {
for (const rootY of validRootsY) {
const distance = Math.sqrt(
(rootX - point[0]) ** 2 + (rootY - point[1]) ** 2,
);
if (distance < minDistance) {
minDistance = distance;
closestT = (rootX + rootY) / 2; // Use the average for a smoother result
}
}
}
return closestT;
};

View file

@ -0,0 +1,70 @@
import * as GA from "./ga";
import { point, toString, direction, offset } from "./ga";
import * as GAPoint from "./gapoints";
import * as GALine from "./galines";
import * as GATransform from "./gatransforms";
describe("geometric algebra", () => {
describe("points", () => {
it("distanceToLine", () => {
const point = GA.point(3, 3);
const line = GALine.equation(0, 1, -1);
expect(GAPoint.distanceToLine(point, line)).toEqual(2);
});
it("distanceToLine neg", () => {
const point = GA.point(-3, -3);
const line = GALine.equation(0, 1, -1);
expect(GAPoint.distanceToLine(point, line)).toEqual(-4);
});
});
describe("lines", () => {
it("through", () => {
const a = GA.point(0, 0);
const b = GA.point(2, 0);
expect(toString(GALine.through(a, b))).toEqual(
toString(GALine.equation(0, 2, 0)),
);
});
it("parallel", () => {
const point = GA.point(3, 3);
const line = GALine.equation(0, 1, -1);
const parallel = GALine.parallel(line, 2);
expect(GAPoint.distanceToLine(point, parallel)).toEqual(0);
});
});
describe("translation", () => {
it("points", () => {
const start = point(2, 2);
const move = GATransform.translation(direction(0, 1));
const end = GATransform.apply(move, start);
expect(toString(end)).toEqual(toString(point(2, 3)));
});
it("points 2", () => {
const start = point(2, 2);
const move = GATransform.translation(offset(3, 4));
const end = GATransform.apply(move, start);
expect(toString(end)).toEqual(toString(point(5, 6)));
});
it("lines", () => {
const original = GALine.through(point(2, 2), point(3, 4));
const move = GATransform.translation(offset(3, 4));
const parallel = GATransform.apply(move, original);
expect(toString(parallel)).toEqual(
toString(GALine.through(point(5, 6), point(6, 8))),
);
});
});
describe("rotation", () => {
it("points", () => {
const start = point(2, 2);
const pivot = point(1, 1);
const rotate = GATransform.rotation(pivot, Math.PI / 2);
const end = GATransform.apply(rotate, start);
expect(toString(end)).toEqual(toString(point(2, 0)));
});
});
});

317
packages/math/ga/ga.ts Normal file
View file

@ -0,0 +1,317 @@
/**
* This is a 2D Projective Geometric Algebra implementation.
*
* For wider context on geometric algebra visit see https://bivector.net.
*
* For this specific algebra see cheatsheet https://bivector.net/2DPGA.pdf.
*
* Converted from generator written by enki, with a ton of added on top.
*
* This library uses 8-vectors to represent points, directions and lines
* in 2D space.
*
* An array `[a, b, c, d, e, f, g, h]` represents a n(8)vector:
* a + b*e0 + c*e1 + d*e2 + e*e01 + f*e20 + g*e12 + h*e012
*
* See GAPoint, GALine, GADirection and GATransform modules for common
* operations.
*/
export type Point = NVector;
export type Direction = NVector;
export type Line = NVector;
export type Transform = NVector;
export const point = (x: number, y: number): Point => [0, 0, 0, 0, y, x, 1, 0];
export const origin = (): Point => [0, 0, 0, 0, 0, 0, 1, 0];
export const direction = (x: number, y: number): Direction => {
const norm = Math.hypot(x, y); // same as `inorm(direction(x, y))`
return [0, 0, 0, 0, y / norm, x / norm, 0, 0];
};
export const offset = (x: number, y: number): Direction => [
0,
0,
0,
0,
y,
x,
0,
0,
];
/// This is the "implementation" part of the library
type NVector = readonly [
number,
number,
number,
number,
number,
number,
number,
number,
];
// These are labels for what each number in an nvector represents
const NVECTOR_BASE = ["1", "e0", "e1", "e2", "e01", "e20", "e12", "e012"];
// Used to represent points, lines and transformations
export const nvector = (value: number = 0, index: number = 0): NVector => {
const result = [0, 0, 0, 0, 0, 0, 0, 0];
if (index < 0 || index > 7) {
throw new Error(`Expected \`index\` between 0 and 7, got \`${index}\``);
}
if (value !== 0) {
result[index] = value;
}
return result as unknown as NVector;
};
const STRING_EPSILON = 0.000001;
export const toString = (nvector: NVector): string => {
const result = nvector
.map((value, index) =>
Math.abs(value) > STRING_EPSILON
? value.toFixed(7).replace(/(\.|0+)$/, "") +
(index > 0 ? NVECTOR_BASE[index] : "")
: null,
)
.filter((representation) => representation != null)
.join(" + ");
return result === "" ? "0" : result;
};
// Reverse the order of the basis blades.
export const reverse = (nvector: NVector): NVector => [
nvector[0],
nvector[1],
nvector[2],
nvector[3],
-nvector[4],
-nvector[5],
-nvector[6],
-nvector[7],
];
// Poincare duality operator.
export const dual = (nvector: NVector): NVector => [
nvector[7],
nvector[6],
nvector[5],
nvector[4],
nvector[3],
nvector[2],
nvector[1],
nvector[0],
];
// Clifford Conjugation
export const conjugate = (nvector: NVector): NVector => [
nvector[0],
-nvector[1],
-nvector[2],
-nvector[3],
-nvector[4],
-nvector[5],
-nvector[6],
nvector[7],
];
// Main involution
export const involute = (nvector: NVector): NVector => [
nvector[0],
-nvector[1],
-nvector[2],
-nvector[3],
nvector[4],
nvector[5],
nvector[6],
-nvector[7],
];
// Multivector addition
export const add = (a: NVector, b: NVector | number): NVector => {
if (isNumber(b)) {
return [a[0] + b, a[1], a[2], a[3], a[4], a[5], a[6], a[7]];
}
return [
a[0] + b[0],
a[1] + b[1],
a[2] + b[2],
a[3] + b[3],
a[4] + b[4],
a[5] + b[5],
a[6] + b[6],
a[7] + b[7],
];
};
// Multivector subtraction
export const sub = (a: NVector, b: NVector | number): NVector => {
if (isNumber(b)) {
return [a[0] - b, a[1], a[2], a[3], a[4], a[5], a[6], a[7]];
}
return [
a[0] - b[0],
a[1] - b[1],
a[2] - b[2],
a[3] - b[3],
a[4] - b[4],
a[5] - b[5],
a[6] - b[6],
a[7] - b[7],
];
};
// The geometric product.
export const mul = (a: NVector, b: NVector | number): NVector => {
if (isNumber(b)) {
return [
a[0] * b,
a[1] * b,
a[2] * b,
a[3] * b,
a[4] * b,
a[5] * b,
a[6] * b,
a[7] * b,
];
}
return [
mulScalar(a, b),
b[1] * a[0] +
b[0] * a[1] -
b[4] * a[2] +
b[5] * a[3] +
b[2] * a[4] -
b[3] * a[5] -
b[7] * a[6] -
b[6] * a[7],
b[2] * a[0] + b[0] * a[2] - b[6] * a[3] + b[3] * a[6],
b[3] * a[0] + b[6] * a[2] + b[0] * a[3] - b[2] * a[6],
b[4] * a[0] +
b[2] * a[1] -
b[1] * a[2] +
b[7] * a[3] +
b[0] * a[4] +
b[6] * a[5] -
b[5] * a[6] +
b[3] * a[7],
b[5] * a[0] -
b[3] * a[1] +
b[7] * a[2] +
b[1] * a[3] -
b[6] * a[4] +
b[0] * a[5] +
b[4] * a[6] +
b[2] * a[7],
b[6] * a[0] + b[3] * a[2] - b[2] * a[3] + b[0] * a[6],
b[7] * a[0] +
b[6] * a[1] +
b[5] * a[2] +
b[4] * a[3] +
b[3] * a[4] +
b[2] * a[5] +
b[1] * a[6] +
b[0] * a[7],
];
};
export const mulScalar = (a: NVector, b: NVector): number =>
b[0] * a[0] + b[2] * a[2] + b[3] * a[3] - b[6] * a[6];
// The outer/exterior/wedge product.
export const meet = (a: NVector, b: NVector): NVector => [
b[0] * a[0],
b[1] * a[0] + b[0] * a[1],
b[2] * a[0] + b[0] * a[2],
b[3] * a[0] + b[0] * a[3],
b[4] * a[0] + b[2] * a[1] - b[1] * a[2] + b[0] * a[4],
b[5] * a[0] - b[3] * a[1] + b[1] * a[3] + b[0] * a[5],
b[6] * a[0] + b[3] * a[2] - b[2] * a[3] + b[0] * a[6],
b[7] * a[0] +
b[6] * a[1] +
b[5] * a[2] +
b[4] * a[3] +
b[3] * a[4] +
b[2] * a[5] +
b[1] * a[6],
];
// The regressive product.
export const join = (a: NVector, b: NVector): NVector => [
joinScalar(a, b),
a[1] * b[7] + a[4] * b[5] - a[5] * b[4] + a[7] * b[1],
a[2] * b[7] - a[4] * b[6] + a[6] * b[4] + a[7] * b[2],
a[3] * b[7] + a[5] * b[6] - a[6] * b[5] + a[7] * b[3],
a[4] * b[7] + a[7] * b[4],
a[5] * b[7] + a[7] * b[5],
a[6] * b[7] + a[7] * b[6],
a[7] * b[7],
];
export const joinScalar = (a: NVector, b: NVector): number =>
a[0] * b[7] +
a[1] * b[6] +
a[2] * b[5] +
a[3] * b[4] +
a[4] * b[3] +
a[5] * b[2] +
a[6] * b[1] +
a[7] * b[0];
// The inner product.
export const dot = (a: NVector, b: NVector): NVector => [
b[0] * a[0] + b[2] * a[2] + b[3] * a[3] - b[6] * a[6],
b[1] * a[0] +
b[0] * a[1] -
b[4] * a[2] +
b[5] * a[3] +
b[2] * a[4] -
b[3] * a[5] -
b[7] * a[6] -
b[6] * a[7],
b[2] * a[0] + b[0] * a[2] - b[6] * a[3] + b[3] * a[6],
b[3] * a[0] + b[6] * a[2] + b[0] * a[3] - b[2] * a[6],
b[4] * a[0] + b[7] * a[3] + b[0] * a[4] + b[3] * a[7],
b[5] * a[0] + b[7] * a[2] + b[0] * a[5] + b[2] * a[7],
b[6] * a[0] + b[0] * a[6],
b[7] * a[0] + b[0] * a[7],
];
export const norm = (a: NVector): number =>
Math.sqrt(Math.abs(a[0] * a[0] - a[2] * a[2] - a[3] * a[3] + a[6] * a[6]));
export const inorm = (a: NVector): number =>
Math.sqrt(Math.abs(a[7] * a[7] - a[5] * a[5] - a[4] * a[4] + a[1] * a[1]));
export const normalized = (a: NVector): NVector => {
const n = norm(a);
if (n === 0 || n === 1) {
return a;
}
const sign = a[6] < 0 ? -1 : 1;
return mul(a, sign / n);
};
export const inormalized = (a: NVector): NVector => {
const n = inorm(a);
if (n === 0 || n === 1) {
return a;
}
return mul(a, 1 / n);
};
const isNumber = (a: any): a is number => typeof a === "number";
export const E0: NVector = nvector(1, 1);
export const E1: NVector = nvector(1, 2);
export const E2: NVector = nvector(1, 3);
export const E01: NVector = nvector(1, 4);
export const E20: NVector = nvector(1, 5);
export const E12: NVector = nvector(1, 6);
export const E012: NVector = nvector(1, 7);
export const I = E012;

View file

@ -0,0 +1,26 @@
import * as GA from "./ga";
import type { Line, Direction, Point } from "./ga";
/**
* A direction is stored as an array `[0, 0, 0, 0, y, x, 0, 0]` representing
* vector `(x, y)`.
*/
export const from = (point: Point): Point => [
0,
0,
0,
0,
point[4],
point[5],
0,
0,
];
export const fromTo = (from: Point, to: Point): Direction =>
GA.inormalized([0, 0, 0, 0, to[4] - from[4], to[5] - from[5], 0, 0]);
export const orthogonal = (direction: Direction): Direction =>
GA.inormalized([0, 0, 0, 0, -direction[5], direction[4], 0, 0]);
export const orthogonalToLine = (line: Line): Direction => GA.mul(line, GA.I);

View file

@ -0,0 +1,52 @@
import * as GA from "./ga";
import type { Line, Point } from "./ga";
/**
* A line is stored as an array `[0, c, a, b, 0, 0, 0, 0]` representing:
* c * e0 + a * e1 + b*e2
*
* This maps to a standard formula `a * x + b * y + c`.
*
* `(-b, a)` corresponds to a 2D vector parallel to the line. The lines
* have a natural orientation, corresponding to that vector.
*
* The magnitude ("norm") of the line is `sqrt(a ^ 2 + b ^ 2)`.
* `c / norm(line)` is the oriented distance from line to origin.
*/
// Returns line with direction (x, y) through origin
export const vector = (x: number, y: number): Line =>
GA.normalized([0, 0, -y, x, 0, 0, 0, 0]);
// For equation ax + by + c = 0.
export const equation = (a: number, b: number, c: number): Line =>
GA.normalized([0, c, a, b, 0, 0, 0, 0]);
export const through = (from: Point, to: Point): Line =>
GA.normalized(GA.join(to, from));
export const orthogonal = (line: Line, point: Point): Line =>
GA.dot(line, point);
// Returns a line perpendicular to the line through `against` and `intersection`
// going through `intersection`.
export const orthogonalThrough = (against: Point, intersection: Point): Line =>
orthogonal(through(against, intersection), intersection);
export const parallel = (line: Line, distance: number): Line => {
const result = line.slice();
result[1] -= distance;
return result as unknown as Line;
};
export const parallelThrough = (line: Line, point: Point): Line =>
orthogonal(orthogonal(point, line), point);
export const distance = (line1: Line, line2: Line): number =>
GA.inorm(GA.meet(line1, line2));
export const angle = (line1: Line, line2: Line): number =>
Math.acos(GA.dot(line1, line2)[0]);
// The orientation of the line
export const sign = (line: Line): number => Math.sign(line[1]);

View file

@ -0,0 +1,42 @@
import * as GA from "./ga";
import * as GALine from "./galines";
import type { Point, Line } from "./ga";
import { join } from "./ga";
export const from = ([x, y]: readonly [number, number]): Point => [
0,
0,
0,
0,
y,
x,
1,
0,
];
export const toTuple = (point: Point): [number, number] => [point[5], point[4]];
export const abs = (point: Point): Point => [
0,
0,
0,
0,
Math.abs(point[4]),
Math.abs(point[5]),
1,
0,
];
export const intersect = (line1: Line, line2: Line): Point =>
GA.normalized(GA.meet(line1, line2));
// Projects `point` onto the `line`.
// The returned point is the closest point on the `line` to the `point`.
export const project = (point: Point, line: Line): Point =>
intersect(GALine.orthogonal(line, point), line);
export const distance = (point1: Point, point2: Point): number =>
GA.norm(join(point1, point2));
export const distanceToLine = (point: Point, line: Line): number =>
GA.joinScalar(point, line);

View file

@ -0,0 +1,41 @@
import * as GA from "./ga";
import type { Line, Direction, Point, Transform } from "./ga";
import * as GADirection from "./gadirections";
/**
* TODO: docs
*/
export const rotation = (pivot: Point, angle: number): Transform =>
GA.add(GA.mul(pivot, Math.sin(angle / 2)), Math.cos(angle / 2));
export const translation = (direction: Direction): Transform => [
1,
0,
0,
0,
-(0.5 * direction[5]),
0.5 * direction[4],
0,
0,
];
export const translationOrthogonal = (
direction: Direction,
distance: number,
): Transform => {
const scale = 0.5 * distance;
return [1, 0, 0, 0, scale * direction[4], scale * direction[5], 0, 0];
};
export const translationAlong = (line: Line, distance: number): Transform =>
GA.add(GA.mul(GADirection.orthogonalToLine(line), 0.5 * distance), 1);
export const compose = (motor1: Transform, motor2: Transform): Transform =>
GA.mul(motor2, motor1);
export const apply = (
motor: Transform,
nvector: Point | Direction | Line,
): Point | Direction | Line =>
GA.normalized(GA.mul(GA.mul(motor, nvector), GA.reverse(motor)));

12
packages/math/index.ts Normal file
View file

@ -0,0 +1,12 @@
export * from "./arc";
export * from "./angle";
export * from "./curve";
export * from "./line";
export * from "./point";
export * from "./polygon";
export * from "./range";
export * from "./segment";
export * from "./triangle";
export * from "./types";
export * from "./vector";
export * from "./utils";

52
packages/math/line.ts Normal file
View file

@ -0,0 +1,52 @@
import { pointCenter, pointRotateRads } from "./point";
import type { GlobalPoint, Line, LocalPoint, Radians } 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>;
}
/**
* Convenient point creation from an array of two points.
*
* @param param0 The array with the two points to convert to a line
* @returns The created line
*/
export function lineFromPointPair<P extends GlobalPoint | LocalPoint>([a, b]: [
P,
P,
]): Line<P> {
return line(a, b);
}
/**
* TODO
*
* @param pointArray
* @returns
*/
export function lineFromPointArray<P extends GlobalPoint | LocalPoint>(
pointArray: P[],
): Line<P> | undefined {
return pointArray.length === 2
? line<P>(pointArray[0], pointArray[1])
: undefined;
}
// return the coordinates resulting from rotating the given line about an origin by an angle in degrees
// note that when the origin is not given, the midpoint of the given line is used as the origin
export const lineRotate = <Point extends LocalPoint | GlobalPoint>(
l: Line<Point>,
angle: Radians,
origin?: Point,
): Line<Point> => {
return line(
pointRotateRads(l[0], origin || pointCenter(l[0], l[1]), angle),
pointRotateRads(l[1], origin || pointCenter(l[0], l[1]), angle),
);
};

View file

@ -0,0 +1,61 @@
{
"name": "@excalidraw/math",
"version": "0.1.0",
"main": "./dist/prod/index.js",
"type": "module",
"module": "./dist/prod/index.js",
"exports": {
".": {
"development": "./dist/dev/index.js",
"default": "./dist/prod/index.js"
}
},
"types": "./dist/utils/index.d.ts",
"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",
"dependencies": {
"@excalidraw/utils": "*"
},
"scripts": {
"gen:types": "rm -rf types && tsc",
"build:umd": "cross-env NODE_ENV=production webpack --config webpack.prod.config.js",
"build:esm": "rm -rf dist && node ../../scripts/buildUtils.js && yarn gen:types",
"build:umd:withAnalyzer": "cross-env NODE_ENV=production ANALYZER=true webpack --config webpack.prod.config.js",
"pack": "yarn build:umd && yarn pack"
}
}

View file

@ -0,0 +1,24 @@
import { point, pointRotateRads } from "./point";
import type { Radians } from "./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(
point(x1, y1),
point(x2, y2),
angle,
);
expect([rotatedX, rotatedY]).toEqual([30, 20]);
const res2 = pointRotateRads(
point(rotatedX, rotatedY),
point(x2, y2),
-angle as Radians,
);
expect(res2).toEqual([x1, x2]);
});
});

257
packages/math/point.ts Normal file
View file

@ -0,0 +1,257 @@
import { degreesToRadians } from "./angle";
import type {
LocalPoint,
GlobalPoint,
Radians,
Degrees,
Vector,
} from "./types";
import { PRECISION } from "./utils";
import { vectorFromPoint, vectorScale } from "./vector";
/**
* 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 point<Point extends GlobalPoint | LocalPoint>(
x: number,
y: number,
): Point {
return [x, 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
? point<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,
): P {
return v as unknown as P;
}
/**
* 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,
): boolean {
const abs = Math.abs;
return abs(a[0] - b[0]) < PRECISION && abs(a[1] - b[1]) < PRECISION;
}
/**
* Roate 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>(
[x, y]: Point,
[cx, cy]: Point,
angle: Radians,
): Point {
return point(
(x - cx) * Math.cos(angle) - (y - cy) * Math.sin(angle) + cx,
(x - cx) * Math.sin(angle) + (y - cy) * Math.cos(angle) + cy,
);
}
/**
* Roate 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 point(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 point((a[0] + b[0]) / 2, (a[1] + b[1]) / 2);
}
/**
* Add together two points by their coordinates like you'd apply a translation
* to a point by a vector.
*
* @param a One point to act as a basis
* @param b The other point to act like the vector to translate by
* @returns
*/
export function pointAdd<Point extends LocalPoint | GlobalPoint>(
a: Point,
b: Point,
): Point {
return point(a[0] + b[0], a[1] + b[1]);
}
/**
* Subtract a point from another point like you'd translate a point by an
* invese vector.
*
* @param a The point to translate
* @param b The point which will act like a vector
* @returns The resulting point
*/
export function pointSubtract<Point extends LocalPoint | GlobalPoint>(
a: Point,
b: Point,
): Point {
return point(a[0] - b[0], a[1] - b[1]);
}
/**
* 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 {
return Math.hypot(b[0] - a[0], b[1] - a[1]);
}
/**
* 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])
);
};

72
packages/math/polygon.ts Normal file
View file

@ -0,0 +1,72 @@
import { pointsEqual } from "./point";
import { lineSegment, pointOnLineSegment } from "./segment";
import type { GlobalPoint, LocalPoint, Polygon } from "./types";
import { PRECISION } from "./utils";
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 pointOnPolygon = <Point extends LocalPoint | GlobalPoint>(
p: Point,
poly: Polygon<Point>,
threshold = PRECISION,
) => {
let on = false;
for (let i = 0, l = poly.length - 1; i < l; i++) {
if (pointOnLineSegment(p, lineSegment(poly[i], poly[i + 1]), threshold)) {
on = true;
break;
}
}
return on;
};
function polygonClose<Point extends LocalPoint | GlobalPoint>(
polygon: Point[],
) {
return polygonIsClosed(polygon)
? polygon
: ([...polygon, polygon[0]] as Polygon<Point>);
}
function polygonIsClosed<Point extends LocalPoint | GlobalPoint>(
polygon: Point[],
) {
return pointsEqual(polygon[0], polygon[polygon.length - 1]);
}

View file

@ -0,0 +1,51 @@
import { rangeInclusive, rangeIntersection, rangesOverlap } from "./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);
});
});

82
packages/math/range.ts Normal file
View file

@ -0,0 +1,82 @@
import { toBrandedType } from "../excalidraw/utils";
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;
};

158
packages/math/segment.ts Normal file
View file

@ -0,0 +1,158 @@
import {
isPoint,
pointCenter,
pointFromVector,
pointRotateRads,
} from "./point";
import type { GlobalPoint, LineSegment, LocalPoint, Radians } from "./types";
import { PRECISION } from "./utils";
import {
vectorAdd,
vectorCross,
vectorFromPoint,
vectorScale,
vectorSubtract,
} from "./vector";
/**
* 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>;
}
export function lineSegmentFromPointArray<P extends GlobalPoint | LocalPoint>(
pointArray: P[],
): LineSegment<P> | undefined {
return pointArray.length === 2
? lineSegment<P>(pointArray[0], pointArray[1])
: undefined;
}
/**
*
* @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[0]);
/**
* 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>,
) => {
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 = -1;
if (len_sq !== 0) {
param = dot / len_sq;
}
let xx;
let yy;
if (param < 0) {
xx = x1;
yy = y1;
} else if (param > 1) {
xx = x2;
yy = y2;
} else {
xx = x1 + param * C;
yy = y1 + param * D;
}
const dx = x - xx;
const dy = y - yy;
return Math.sqrt(dx * dx + dy * dy);
};

28
packages/math/triangle.ts Normal file
View 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);
}

130
packages/math/types.ts Normal file
View file

@ -0,0 +1,130 @@
//
// 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 or canvas space. A
* global coordinate.
*/
export type GlobalPoint = [x: number, y: number] & {
_brand: "excalimath__globalpoint";
};
/**
* Represents a 2D position in whatever local space it's
* needed. A local coordinate.
*/
export type LocalPoint = [x: number, y: number] & {
_brand: "excalimath__localpoint";
};
// 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";
};
//
// 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,
];
/**
* Angles are in radians and centered on 0, 0. Zero radians on a 1 radius circle
* corresponds to (1, 0) cartesian coordinates (point), i.e. to the "right".
*/
export type SymmetricArc = {
radius: number;
startAngle: number;
endAngle: number;
};

17
packages/math/utils.ts Normal file
View file

@ -0,0 +1,17 @@
export const PRECISION = 10e-5;
export function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max);
}
export function round(value: number, precision: number) {
const multiplier = Math.pow(10, precision);
return Math.round((value + Number.EPSILON) * multiplier) / multiplier;
}
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);
};

View file

@ -0,0 +1,12 @@
import { isVector } from ".";
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);
});
});

141
packages/math/vector.ts Normal file
View file

@ -0,0 +1,141 @@
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
* @returns The created vector from the point and the origin
*/
export function vectorFromPoint<Point extends GlobalPoint | LocalPoint>(
p: Point,
origin: Point = [0, 0] as Point,
): Vector {
return vector(p[0] - origin[0], p[1] - origin[1]);
}
/**
* 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);
return vector(v[0] / m, v[1] / m);
};

View file

@ -0,0 +1,55 @@
const webpack = require("webpack");
const path = require("path");
const BundleAnalyzerPlugin =
require("webpack-bundle-analyzer").BundleAnalyzerPlugin;
module.exports = {
mode: "production",
entry: { "excalidraw-math.min": "./index.js" },
output: {
path: path.resolve(__dirname, "dist"),
filename: "[name].js",
library: "ExcalidrawMath",
libraryTarget: "umd",
},
resolve: {
extensions: [".tsx", ".ts", ".js", ".css", ".scss"],
},
optimization: {
runtimeChunk: false,
},
module: {
rules: [
{
test: /\.(ts|tsx|js)$/,
use: [
{
loader: "ts-loader",
options: {
transpileOnly: true,
configFile: path.resolve(__dirname, "../tsconfig.prod.json"),
},
},
{
loader: "babel-loader",
options: {
presets: [
"@babel/preset-env",
["@babel/preset-react", { runtime: "automatic" }],
"@babel/preset-typescript",
],
plugins: [["@babel/plugin-transform-runtime"]],
},
},
],
},
],
},
plugins: [
new webpack.optimize.LimitChunkCountPlugin({
maxChunks: 1,
}),
...(process.env.ANALYZER === "true" ? [new BundleAnalyzerPlugin()] : []),
],
};