UNPKG

@tldraw/editor

Version:

tldraw infinite canvas SDK (editor).

493 lines (449 loc) • 12.2 kB
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 }