Skip to content

Commit

Permalink
feat(s1): angle
Browse files Browse the repository at this point in the history
  • Loading branch information
missinglink committed Jul 19, 2024
1 parent b4f720c commit 4ed862c
Show file tree
Hide file tree
Showing 5 changed files with 182 additions and 0 deletions.
1 change: 1 addition & 0 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
5 changes: 5 additions & 0 deletions s1/_index.ts
Original file line number Diff line number Diff line change
@@ -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'
87 changes: 87 additions & 0 deletions s1/angle.ts
Original file line number Diff line number Diff line change
@@ -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
8 changes: 8 additions & 0 deletions s1/angle_constants.ts
Original file line number Diff line number Diff line change
@@ -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
81 changes: 81 additions & 0 deletions s1/angle_test.ts
Original file line number Diff line number Diff line change
@@ -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))
})

0 comments on commit 4ed862c

Please sign in to comment.