@tldraw/editor
Version: 
tldraw infinite canvas SDK (editor).
493 lines (449 loc) • 12.2 kB
text/typescript
import { Vec, VecLike } from './Vec'
/** @public */
export function precise(A: VecLike) {
	return `${toDomPrecision(A.x)},${toDomPrecision(A.y)} `
}
/** @public */
export function average(A: VecLike, B: VecLike) {
	return `${toDomPrecision((A.x + B.x) / 2)},${toDomPrecision((A.y + B.y) / 2)} `
}
/** @public */
export const PI = Math.PI
/** @public */
export const HALF_PI = PI / 2
/** @public */
export const PI2 = PI * 2
/** @public */
export const SIN = Math.sin
/**
 * Clamp a value into a range.
 *
 * @example
 *
 * ```ts
 * const A = clamp(0, 1) // 1
 * ```
 *
 * @param n - The number to clamp.
 * @param min - The minimum value.
 * @public
 */
export function clamp(n: number, min: number): number
/**
 * Clamp a value into a range.
 *
 * @example
 *
 * ```ts
 * const A = clamp(0, 1, 10) // 1
 * const B = clamp(11, 1, 10) // 10
 * const C = clamp(5, 1, 10) // 5
 * ```
 *
 * @param n - The number to clamp.
 * @param min - The minimum value.
 * @param max - The maximum value.
 * @public
 */
export function clamp(n: number, min: number, max: number): number
export function clamp(n: number, min: number, max?: number): number {
	return Math.max(min, typeof max !== 'undefined' ? Math.min(n, max) : n)
}
/**
 * Get a number to a precision.
 *
 * @param n - The number.
 * @param precision - The precision.
 * @public
 */
export function toPrecision(n: number, precision = 10000000000) {
	if (!n) return 0
	return Math.round(n * precision) / precision
}
/**
 * Whether two numbers numbers a and b are approximately equal.
 *
 * @param a - The first point.
 * @param b - The second point.
 * @public
 */
export function approximately(a: number, b: number, precision = 0.000001) {
	return Math.abs(a - b) <= precision
}
/**
 * Whether a number is approximately less than or equal to another number.
 *
 * @param a - The first number.
 * @param b - The second number.
 * @public
 */
export function approximatelyLte(a: number, b: number, precision = 0.000001) {
	return a < b || approximately(a, b, precision)
}
/**
 * Find the approximate perimeter of an ellipse.
 *
 * @param rx - The ellipse's x radius.
 * @param ry - The ellipse's y radius.
 * @public
 */
export function perimeterOfEllipse(rx: number, ry: number): number {
	const h = Math.pow(rx - ry, 2) / Math.pow(rx + ry, 2)
	return PI * (rx + ry) * (1 + (3 * h) / (10 + Math.sqrt(4 - 3 * h)))
}
/**
 * @param a - Any angle in radians
 * @returns A number between 0 and 2 * PI
 * @public
 */
export function canonicalizeRotation(a: number) {
	a = a % PI2
	if (a < 0) {
		a = a + PI2
	} else if (a === 0) {
		// prevent negative zero
		a = 0
	}
	return a
}
/**
 * Get the clockwise angle distance between two angles.
 *
 * @param a0 - The first angle.
 * @param a1 - The second angle.
 * @public
 */
export function clockwiseAngleDist(a0: number, a1: number): number {
	a0 = canonicalizeRotation(a0)
	a1 = canonicalizeRotation(a1)
	if (a0 > a1) {
		a1 += PI2
	}
	return a1 - a0
}
/**
 * Get the counter-clockwise angle distance between two angles.
 *
 * @param a0 - The first angle.
 * @param a1 - The second angle.
 * @public
 */
export function counterClockwiseAngleDist(a0: number, a1: number): number {
	return PI2 - clockwiseAngleDist(a0, a1)
}
/**
 * Get the short angle distance between two angles.
 *
 * @param a0 - The first angle.
 * @param a1 - The second angle.
 * @public
 */
export function shortAngleDist(a0: number, a1: number): number {
	const da = (a1 - a0) % PI2
	return ((2 * da) % PI2) - da
}
/**
 * Clamp radians within 0 and 2PI
 *
 * @param r - The radian value.
 * @public
 */
export function clampRadians(r: number): number {
	return (PI2 + r) % PI2
}
/**
 * Clamp rotation to even segments.
 *
 * @param r - The rotation in radians.
 * @param segments - The number of segments.
 * @public
 */
export function snapAngle(r: number, segments: number): number {
	const seg = PI2 / segments
	let ang = (Math.floor((clampRadians(r) + seg / 2) / seg) * seg) % PI2
	if (ang < PI) ang += PI2
	if (ang > PI) ang -= PI2
	return ang
}
/**
 * Checks whether two angles are approximately at right-angles or parallel to each other
 *
 * @param a - Angle a (radians)
 * @param b - Angle b (radians)
 * @returns True iff the angles are approximately at right-angles or parallel to each other
 * @public
 */
export function areAnglesCompatible(a: number, b: number) {
	return a === b || approximately((a % (Math.PI / 2)) - (b % (Math.PI / 2)), 0)
}
/**
 * Convert degrees to radians.
 *
 * @param d - The degree in degrees.
 * @public
 */
export function degreesToRadians(d: number): number {
	return (d * PI) / 180
}
/**
 * Convert radians to degrees.
 *
 * @param r - The degree in radians.
 * @public
 */
export function radiansToDegrees(r: number): number {
	return (r * 180) / PI
}
/**
 * Get a point on the perimeter of a circle.
 *
 * @param center - The center of the circle.
 * @param r - The radius of the circle.
 * @param a - The angle in radians.
 * @public
 */
export function getPointOnCircle(center: VecLike, r: number, a: number) {
	return new Vec(center.x, center.y).add(Vec.FromAngle(a, r))
}
/** @public */
export function getPolygonVertices(width: number, height: number, sides: number) {
	const cx = width / 2
	const cy = height / 2
	const pointsOnPerimeter: Vec[] = []
	let minX = Infinity
	let maxX = -Infinity
	let minY = Infinity
	let maxY = -Infinity
	for (let i = 0; i < sides; i++) {
		const step = PI2 / sides
		const t = -HALF_PI + i * step
		const x = cx + cx * Math.cos(t)
		const y = cy + cy * Math.sin(t)
		if (x < minX) minX = x
		if (y < minY) minY = y
		if (x > maxX) maxX = x
		if (y > maxY) maxY = y
		pointsOnPerimeter.push(new Vec(x, y))
	}
	// Bounds of calculated points
	const w = maxX - minX
	const h = maxY - minY
	// Difference between input bounds and calculated bounds
	const dx = width - w
	const dy = height - h
	// If there's a difference, scale the points to the input bounds
	if (dx !== 0 || dy !== 0) {
		for (let i = 0; i < pointsOnPerimeter.length; i++) {
			const pt = pointsOnPerimeter[i]
			pt.x = ((pt.x - minX) / w) * width
			pt.y = ((pt.y - minY) / h) * height
		}
	}
	return pointsOnPerimeter
}
/**
 * @param a0 - The start point in the A range
 * @param a1 - The end point in the A range
 * @param b0 - The start point in the B range
 * @param b1 - The end point in the B range
 * @returns True if the ranges overlap
 * @public
 */
export function rangesOverlap(a0: number, a1: number, b0: number, b1: number): boolean {
	return a0 < b1 && b0 < a1
}
/**
 * Finds the intersection of two ranges.
 *
 * @param a0 - The start point in the A range
 * @param a1 - The end point in the A range
 * @param b0 - The start point in the B range
 * @param b1 - The end point in the B range
 * @returns The intersection of the ranges, or null if no intersection
 * @public
 */
export function rangeIntersection(
	a0: number,
	a1: number,
	b0: number,
	b1: number
): [number, number] | null {
	const min = Math.max(a0, b0)
	const max = Math.min(a1, b1)
	if (min <= max) {
		return [min, max]
	}
	return null
}
/** Helper for point in polygon */
function cross(x: VecLike, y: VecLike, z: VecLike): number {
	return (y.x - x.x) * (z.y - x.y) - (z.x - x.x) * (y.y - x.y)
}
/**
 * Get whether a point is inside of a polygon.
 *
 * ```ts
 * const result = pointInPolygon(myPoint, myPoints)
 * ```
 *
 * @public
 */
export function pointInPolygon(A: VecLike, points: VecLike[]): boolean {
	let windingNumber = 0
	let a: VecLike
	let b: VecLike
	for (let i = 0; i < points.length; i++) {
		a = points[i]
		// Point is the same as one of the corners of the polygon
		if (a.x === A.x && a.y === A.y) return true
		b = points[(i + 1) % points.length]
		// Point is on the polygon edge
		if (Vec.Dist(A, a) + Vec.Dist(A, b) === Vec.Dist(a, b)) return true
		if (a.y <= A.y) {
			if (b.y > A.y && cross(a, b, A) > 0) {
				windingNumber += 1
			}
		} else if (b.y <= A.y && cross(a, b, A) < 0) {
			windingNumber -= 1
		}
	}
	return windingNumber !== 0
}
/**
 * The DOM likes values to be fixed to 3 decimal places
 *
 * @public
 */
export function toDomPrecision(v: number) {
	return Math.round(v * 1e4) / 1e4
}
/**
 * @public
 */
export function toFixed(v: number) {
	return Math.round(v * 1e2) / 1e2
}
/**
 * Check if a float is safe to use. ie: Not too big or small.
 * @public
 */
export const isSafeFloat = (n: number) => {
	return Math.abs(n) < Number.MAX_SAFE_INTEGER
}
/**
 * Get the angle of a point on an arc.
 * @param fromAngle - The angle from center to arc's start point (A) on the circle
 * @param toAngle - The angle from center to arc's end point (B) on the circle
 * @param direction - The direction of the arc (1 = counter-clockwise, -1 = clockwise)
 * @returns The distance in radians between the two angles according to the direction
 * @public
 */
export function angleDistance(fromAngle: number, toAngle: number, direction: number) {
	const dist =
		direction < 0
			? clockwiseAngleDist(fromAngle, toAngle)
			: counterClockwiseAngleDist(fromAngle, toAngle)
	return dist
}
/**
 * Returns the t value of the point on the arc.
 *
 * @param mAB - The measure of the arc from A to B, negative if counter-clockwise
 * @param A - The angle from center to arc's start point (A) on the circle
 * @param B - The angle from center to arc's end point (B) on the circle
 * @param P - The angle on the circle (P) to find the t value for
 *
 * @returns The t value of the point on the arc, with 0 being the start and 1 being the end
 *
 * @public
 */
export function getPointInArcT(mAB: number, A: number, B: number, P: number): number {
	let mAP: number
	if (Math.abs(mAB) > PI) {
		mAP = shortAngleDist(A, P)
		const mPB = shortAngleDist(P, B)
		if (Math.abs(mAP) < Math.abs(mPB)) {
			return mAP / mAB
		} else {
			return (mAB - mPB) / mAB
		}
	} else {
		mAP = shortAngleDist(A, P)
		const t = mAP / mAB
		// If the arc is something like -2.8 to 2.2, then we'll get a weird bug
		// where the measurement to the center is negative but measure to points
		// near the end are positive
		if (Math.sign(mAP) !== Math.sign(mAB)) {
			return Math.abs(t) > 0.5 ? 1 : 0
		}
		return t
	}
}
/**
 * Get the measure of an arc.
 *
 * @param A - The angle from center to arc's start point (A) on the circle
 * @param B - The angle from center to arc's end point (B) on the circle
 * @param sweepFlag - 1 if the arc is clockwise, 0 if counter-clockwise
 * @param largeArcFlag - 1 if the arc is greater than 180 degrees, 0 if less than 180 degrees
 *
 * @returns The measure of the arc, negative if counter-clockwise
 *
 * @public
 */
export function getArcMeasure(A: number, B: number, sweepFlag: number, largeArcFlag: number) {
	const m = ((2 * ((B - A) % PI2)) % PI2) - ((B - A) % PI2)
	if (!largeArcFlag) return m
	return (PI2 - Math.abs(m)) * (sweepFlag ? 1 : -1)
}
/**
 * Get the center of a circle from three points.
 *
 * @param a - The first point
 * @param b - The second point
 * @param c - The third point
 *
 * @returns The center of the circle or null if the points are collinear
 *
 * @public
 */
export function centerOfCircleFromThreePoints(a: VecLike, b: VecLike, c: VecLike) {
	const u = -2 * (a.x * (b.y - c.y) - a.y * (b.x - c.x) + b.x * c.y - c.x * b.y)
	const x =
		((a.x * a.x + a.y * a.y) * (c.y - b.y) +
			(b.x * b.x + b.y * b.y) * (a.y - c.y) +
			(c.x * c.x + c.y * c.y) * (b.y - a.y)) /
		u
	const y =
		((a.x * a.x + a.y * a.y) * (b.x - c.x) +
			(b.x * b.x + b.y * b.y) * (c.x - a.x) +
			(c.x * c.x + c.y * c.y) * (a.x - b.x)) /
		u
	if (!Number.isFinite(x) || !Number.isFinite(y)) {
		return null
	}
	return new Vec(x, y)
}
/** @public */
export function getPointsOnArc(
	startPoint: VecLike,
	endPoint: VecLike,
	center: VecLike | null,
	radius: number,
	numPoints: number
): Vec[] {
	if (center === null) {
		return [Vec.From(startPoint), Vec.From(endPoint)]
	}
	const results: Vec[] = []
	const startAngle = Vec.Angle(center, startPoint)
	const endAngle = Vec.Angle(center, endPoint)
	const l = clockwiseAngleDist(startAngle, endAngle)
	for (let i = 0; i < numPoints; i++) {
		const t = i / (numPoints - 1)
		const angle = startAngle + l * t
		const point = getPointOnCircle(center, radius, angle)
		results.push(point)
	}
	return results
}