refactor: separate elements logic into a standalone package (#9285)
Some checks failed
Auto release excalidraw next / Auto-release-excalidraw-next (push) Failing after 2m36s
Build Docker image / build-docker (push) Failing after 6s
Cancel previous runs / cancel (push) Failing after 1s
Publish Docker / publish-docker (push) Failing after 31s
New Sentry production release / sentry (push) Failing after 2m3s

This commit is contained in:
Marcel Mraz 2025-03-26 15:24:59 +01:00 committed by GitHub
parent a18f059188
commit 432a46ef9e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
372 changed files with 3466 additions and 2466 deletions

View file

@ -0,0 +1,51 @@
import { PRECISION } from "./utils";
import type {
Degrees,
GlobalPoint,
LocalPoint,
PolarCoords,
Radians,
} from "./types";
// 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),
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;
}

284
packages/math/src/curve.ts Normal file
View file

@ -0,0 +1,284 @@
import type { Bounds } from "@excalidraw/element/bounds";
import { isPoint, pointDistance, pointFrom } from "./point";
import { rectangle, rectangleIntersectLineSegment } from "./rectangle";
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 gradient(
f: (t: number, s: number) => number,
t0: number,
s0: number,
delta: number = 1e-6,
): number[] {
return [
(f(t0 + delta, s0) - f(t0 - delta, s0)) / (2 * delta),
(f(t0, s0 + delta) - f(t0, s0 - delta)) / (2 * delta),
];
}
function solve(
f: (t: number, s: number) => [number, number],
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;
}
const y0 = f(t0, s0);
const jacobian = [
gradient((t, s) => f(t, s)[0], t0, s0),
gradient((t, s) => f(t, s)[1], t0, s0),
];
const b = [[-y0[0]], [-y0[1]]];
const det =
jacobian[0][0] * jacobian[1][1] - jacobian[0][1] * jacobian[1][0];
if (det === 0) {
return null;
}
const iJ = [
[jacobian[1][1] / det, -jacobian[0][1] / det],
[-jacobian[1][0] / det, jacobian[0][0] / det],
];
const h = [
[iJ[0][0] * b[0][0] + iJ[0][1] * b[1][0]],
[iJ[1][0] * b[0][0] + iJ[1][1] * b[1][0]],
];
t0 = t0 + h[0][0];
s0 = s0 + h[1][0];
const [tErr, sErr] = f(t0, s0);
error = Math.max(Math.abs(tErr), Math.abs(sErr));
iter += 1;
}
return [t0, s0];
}
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],
);
/**
* Computes the intersection between a cubic spline and a line segment.
*/
export function curveIntersectLineSegment<
Point extends GlobalPoint | LocalPoint,
>(c: Curve<Point>, l: LineSegment<Point>): Point[] {
// Optimize by doing a cheap bounding box check first
const bounds = curveBounds(c);
if (
rectangleIntersectLineSegment(
rectangle(
pointFrom(bounds[0], bounds[1]),
pointFrom(bounds[2], bounds[3]),
),
l,
).length === 0
) {
return [];
}
const line = (s: number) =>
pointFrom<Point>(
l[0][0] + s * (l[1][0] - l[0][0]),
l[0][1] + s * (l[1][1] - l[0][1]),
);
const initial_guesses: [number, number][] = [
[0.5, 0],
[0.2, 0],
[0.8, 0],
];
const calculate = ([t0, s0]: [number, number]) => {
const solution = solve(
(t: number, s: number) => {
const bezier_point = bezierEquation(c, t);
const line_point = line(s);
return [
bezier_point[0] - line_point[0],
bezier_point[1] - line_point[1],
];
},
t0,
s0,
);
if (!solution) {
return null;
}
const [t, s] = solution;
if (t < 0 || t > 1 || s < 0 || s > 1) {
return null;
}
return bezierEquation(c, t);
};
let solution = calculate(initial_guesses[0]);
if (solution) {
return [solution];
}
solution = calculate(initial_guesses[1]);
if (solution) {
return [solution];
}
solution = calculate(initial_guesses[2]);
if (solution) {
return [solution];
}
return [];
}
/**
* Finds the closest point on the Bezier curve from another point
*
* @param x
* @param y
* @param P0
* @param P1
* @param P2
* @param P3
* @param tolerance
* @param maxLevel
* @returns
*/
export function curveClosestPoint<Point extends GlobalPoint | LocalPoint>(
c: Curve<Point>,
p: Point,
tolerance: number = 1e-3,
): Point | null {
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 solution = localMinimum(t0, t1, (t) =>
pointDistance(p, bezierEquation(c, t)),
);
if (!solution) {
return null;
}
return bezierEquation(c, solution);
}
/**
* 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,
) {
const closest = curveClosestPoint(c, p);
if (!closest) {
return 0;
}
return pointDistance(p, closest);
}
/**
* 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])
);
}
function curveBounds<Point extends GlobalPoint | LocalPoint>(
c: Curve<Point>,
): Bounds {
const [P0, P1, P2, P3] = c;
const x = [P0[0], P1[0], P2[0], P3[0]];
const y = [P0[1], P1[1], P2[1], P3[1]];
return [Math.min(...x), Math.min(...y), Math.max(...x), Math.max(...y)];
}

View 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;
}

View file

@ -0,0 +1,12 @@
export * from "./angle";
export * from "./curve";
export * from "./line";
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
View 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;
}

232
packages/math/src/point.ts Normal file
View file

@ -0,0 +1,232 @@
import { degreesToRadians } from "./angle";
import { PRECISION } from "./utils";
import { vectorFromPoint, vectorScale } from "./vector";
import type {
LocalPoint,
GlobalPoint,
Radians,
Degrees,
Vector,
} 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 {
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
? 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,
): boolean {
const abs = Math.abs;
return abs(a[0] - b[0]) < PRECISION && abs(a[1] - b[1]) < PRECISION;
}
/**
* 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>(
[x, y]: Point,
[cx, cy]: Point,
angle: Radians,
): Point {
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])
);
};

View file

@ -0,0 +1,73 @@
import { pointsEqual } from "./point";
import { lineSegment, pointOnLineSegment } from "./segment";
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 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,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;
};

View file

@ -0,0 +1,24 @@
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 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);
}

View file

@ -0,0 +1,175 @@
import { line, linesIntersectAt } from "./line";
import {
isPoint,
pointCenter,
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[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);
};
/**
* 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>): Point | null {
const candidate = linesIntersectAt(line(l[0], l[1]), line(s[0], s[1]));
if (
!candidate ||
!pointOnLineSegment(candidate, s) ||
!pointOnLineSegment(candidate, l)
) {
return null;
}
return candidate;
}

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

140
packages/math/src/types.ts Normal file
View file

@ -0,0 +1,140 @@
//
// 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";
};
/**
* 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";
};

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

145
packages/math/src/vector.ts Normal file
View file

@ -0,0 +1,145 @@
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);
if (m === 0) {
return vector(0, 0);
}
return vector(v[0] / m, v[1] / m);
};