From 4ed862cad9882a7128a0ff5ac7f10504d05377bf Mon Sep 17 00:00:00 2001 From: Peter Johnson Date: Fri, 19 Jul 2024 16:29:39 +0200 Subject: [PATCH] feat(s1): angle --- index.ts | 1 + s1/_index.ts | 5 +++ s1/angle.ts | 87 +++++++++++++++++++++++++++++++++++++++++++ s1/angle_constants.ts | 8 ++++ s1/angle_test.ts | 81 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 182 insertions(+) create mode 100644 s1/_index.ts create mode 100644 s1/angle.ts create mode 100644 s1/angle_constants.ts create mode 100644 s1/angle_test.ts diff --git a/index.ts b/index.ts index 06507b7..bac0838 100644 --- a/index.ts +++ b/index.ts @@ -7,4 +7,5 @@ */ export * as r1 from './r1/_index' export * as r2 from './r2/_index' +export * as s1 from './s1/_index' export * as s2 from './s2/_index' diff --git a/s1/_index.ts b/s1/_index.ts new file mode 100644 index 0000000..30d8f2a --- /dev/null +++ b/s1/_index.ts @@ -0,0 +1,5 @@ +/** + * Module s1 implements types and functions for working with geometry in S¹ (circular geometry). + * @module s1 + */ +export * as angle from './angle' diff --git a/s1/angle.ts b/s1/angle.ts new file mode 100644 index 0000000..edf21bc --- /dev/null +++ b/s1/angle.ts @@ -0,0 +1,87 @@ +import { DEGREE } from './angle_constants' + +/** + * Angle represents a 1D angle. The internal representation is a double precision value in radians, so conversion to and from radians is exact. + * Conversions between E5, E6, E7, and Degrees are not always exact. + * + * For example, Degrees(3.1) is different from E6(3100000) or E7(31000000). + * + * The following conversions between degrees and radians are exact: + * + * Degree*180 == Radian*Math.PI + * Degree*(180/n) == Radian*(Math.PI/n) for n == 0..8 + * + * These identities hold when the arguments are scaled up or down by any power of 2. Some similar identities are also true, for example, + * + * Degree*60 == Radian*(Math.PI/3) + * + * But be aware that this type of identity does not hold in general. + * For example: + * + * Degree*3 != Radian*(Math.PI/60) + * + * Similarly, the conversion to radians means that (Angle(x)*Degree).Degrees() does not always equal x. + * For example: + * + * (Angle(45*n)*Degree).Degrees() == 45*n for n == 0..8 + * + * but + * + * (60*Degree).Degrees() != 60 + * + * When testing for equality, you should allow for numerical errors (ApproxEqual) + * or convert to discrete E5/E6/E7 values first. + * + * @module angle + */ +export type angle = number + +/** + * Returns the angle in radians. + */ +export const radians = (a: angle): number => a + +/** + * Returns the angle in degrees. + */ +export const degrees = (a: angle): number => a / DEGREE + +/** + * Returns the value rounded to nearest as an int32. + * This does not match C++ exactly for the case of x.5. + */ +export const round = (a: angle): number => Math.round(a) || 0 + +/** Returns an angle larger than any finite angle. */ +export const infAngle = (): angle => Infinity + +/** Reports whether this Angle is infinite. */ +export const isInf = (a: angle): boolean => a == Infinity + +/** Returns the angle in hundred thousandths of degrees. */ +export const e5 = (a: angle): number => round(degrees(a) * 1e5) + +/** Returns the angle in millionths of degrees. */ +export const e6 = (a: angle): number => round(degrees(a) * 1e6) + +/** Returns the angle in ten millionths of degrees. */ +export const e7 = (a: angle): number => round(degrees(a) * 1e7) + +/** Returns the absolute value of the angle. */ +export const abs = (a: angle): angle => Math.abs(a) + +/** Returns an equivalent angle in (-π, π]. */ +export const normalized = (a: angle): angle => { + const rad = a % (2 * Math.PI) + if (rad == -Math.PI) return Math.PI + if (rad < -Math.PI) return -rad - Math.PI + return rad +} + +/** + * Generates a human readable string. + */ +export const toString = (a: angle): string => degrees(a).toFixed(7) + +/** Reports whether the two angles are the same up to a small tolerance. */ +export const approxEqual = (a: angle, oa: angle, epsilon = 1e-15): boolean => Math.abs(a - oa) <= epsilon diff --git a/s1/angle_constants.ts b/s1/angle_constants.ts new file mode 100644 index 0000000..fc292ad --- /dev/null +++ b/s1/angle_constants.ts @@ -0,0 +1,8 @@ +import type { angle } from './angle' + +// angle units. +export const RADIAN: angle = 1 +export const DEGREE: angle = (Math.PI / 180) * RADIAN +export const E5: angle = 1e-5 * DEGREE +export const E6: angle = 1e-6 * DEGREE +export const E7: angle = 1e-7 * DEGREE diff --git a/s1/angle_test.ts b/s1/angle_test.ts new file mode 100644 index 0000000..7564365 --- /dev/null +++ b/s1/angle_test.ts @@ -0,0 +1,81 @@ +import test from 'node:test' +import { equal, ok } from 'node:assert/strict' +import * as angle from './angle' +import { DEGREE, RADIAN, E5, E6, E7 } from './angle_constants' + +test('empty', t => { + equal(0, angle.radians(0)) +}) + +test('PI radians exactly 180 degrees', t => { + equal(angle.radians(Math.PI * RADIAN), Math.PI, '(π * Radian).Radians() was %v, want π') + equal(angle.degrees(Math.PI * RADIAN), 180, '(π * Radian).Degrees() was %v, want 180') + equal(angle.radians(180 * DEGREE), Math.PI, '(180 * Degree).Radians() was %v, want π') + equal(angle.degrees(180 * DEGREE), 180, '(180 * Degree).Degrees() was %v, want 180') + + equal(angle.degrees((Math.PI / 2) * RADIAN), 90, '(π/2 * Radian).Degrees() was %v, want 90') + + // Check negative angles. + equal(angle.degrees((-Math.PI / 2) * RADIAN), -90, '(-π/2 * Radian).Degrees() was %v, want -90') + equal(angle.radians(-45 * DEGREE), -Math.PI / 4, '(-45 * Degree).Radians() was %v, want -π/4') +}) + +test('E5/E6/E7 representation', t => { + ok(Math.abs(angle.radians(-45 * DEGREE) - angle.radians(-4500000 * E5)) <= 1e-15) + equal(angle.radians(-60 * DEGREE), angle.radians(-60000000 * E6)) + equal(angle.radians(-75 * DEGREE), angle.radians(-750000000 * E7)) + + equal(-17256123, angle.e5(-172.56123 * DEGREE)) + equal(12345678, angle.e6(12.345678 * DEGREE)) + equal(-123456789, angle.e7(-12.3456789 * DEGREE)) + + equal(angle.e5(0.500000001 * 1e-5 * DEGREE), 1) + equal(angle.e6(0.500000001 * 1e-6 * DEGREE), 1) + equal(angle.e7(0.500000001 * 1e-7 * DEGREE), 1) + + equal(angle.e5(-0.500000001 * 1e-5 * DEGREE), -1) + equal(angle.e6(-0.500000001 * 1e-6 * DEGREE), -1) + equal(angle.e7(-0.500000001 * 1e-7 * DEGREE), -1) + + equal(angle.e5(0.499999999 * 1e-5 * DEGREE), 0) + equal(angle.e6(0.499999999 * 1e-6 * DEGREE), 0) + equal(angle.e7(0.499999999 * 1e-7 * DEGREE), 0) + + equal(angle.e5(-0.499999999 * 1e-5 * DEGREE), 0) + equal(angle.e6(-0.499999999 * 1e-6 * DEGREE), 0) + equal(angle.e7(-0.499999999 * 1e-7 * DEGREE), 0) +}) + +test('normalize correctly canonicalizes angles', t => { + equal(angle.normalized(360 * DEGREE), 0 * DEGREE) + equal(angle.normalized(-90 * DEGREE), -90 * DEGREE) + equal(angle.normalized(-180 * DEGREE), 180 * DEGREE) + equal(angle.normalized(180 * DEGREE), 180 * DEGREE) + equal(angle.normalized(540 * DEGREE), 180 * DEGREE) + equal(angle.normalized(-270 * DEGREE), 90 * DEGREE) +}) + +test('toString', t => { + equal(angle.toString(180 * DEGREE), '180.0000000') +}) + +test('degrees vs. radians', t => { + // This test tests the exactness of specific values between degrees and radians. + for (let k = -8; k <= 8; k++) { + equal(45 * k * DEGREE, ((k * Math.PI) / 4) * RADIAN) + equal(angle.degrees(45 * k * DEGREE), 45 * k) + } + + for (let k = 0; k < 30; k++) { + const m = 1 << k + equal((180 / m) * DEGREE, Math.PI / (1 * m)) + equal((60 / m) * DEGREE, Math.PI / (3 * m)) + equal((36 / m) * DEGREE, Math.PI / (5 * m)) + equal((20 / m) * DEGREE, Math.PI / (9 * m)) + equal((4 / m) * DEGREE, Math.PI / (45 * m)) + } + + // We also spot check a non-identity. + // @missinglink: this fails for epsilon=1e-15 + ok(angle.approxEqual(angle.degrees(60 * DEGREE), 60, 1e-14)) +})