Skip to content

gmcusaro/AAngle

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

96 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

AAngle

AAngle

AAngle is a Swift package that provides a flexible and extensible way to work with different types of angles, including degrees, radians, gradians, revolutions/turns, arc minutes, and arc seconds.

Why AAngle?

Key Design Rationale

Normalization by default (where appropriate): Addition, subtraction, and assignment operations are normalized by default so values stay in the expected circular range.

Preserve magnitude when needed: Multiplication and division (without assignment) intentionally keep unwrapped magnitudes for workflows where full rotational distance matters.

Type consistency: Binary operations (+, -) return the left-hand side type, so behavior remains predictable.

Cross-type correctness: Comparisons and arithmetic across angle units perform safe internal conversions.

Robust floating-point comparisons: Instance tolerances and sanitizedTolerance(_:) keep comparisons stable even with invalid values (NaN, infinities, negatives).

Advantages

  • Safer math defaults for circular values.
  • Fewer unit-conversion mistakes across APIs.
  • Better behavior under floating-point edge cases.
  • Clean integration with Measurement<UnitAngle>.
  • Ergonomic model code via the @AAngle property wrapper.

Features

Angle Types: Supports various angle types: Radians, Degrees, Gradians, Revolutions, ArcSeconds, and ArcMinutes.

Normalization: Built-in normalization to keep angles within a specific range (e.g., 0-360 degrees for Degrees).

Arithmetic Operations: Supports addition, subtraction, multiplication, and division, with consistent normalization behavior.

Angle Conversion: Easily convert between different angle types.

Measurement Support: Convert angle values to Swift's Measurement<UnitAngle> type for use with the Foundation framework's units.

Type-Safe Units: Uses an AAngleType enum to represent units, ensuring type safety and avoiding string-based errors.

Extensible Protocol: Designed with the AAnglable protocol to make it easy to add custom angle types.

Floating-Point Comparison Helpers: Includes sanitizedTolerance(_:), isApproximatelyEqual(to:tolerance:), and isEquivalent(to:tolerance:) for robust comparisons.

Complete Operators Support: Supports all basic arithmetic operators, comparison operators, including ==, <, <=, >, >=, as well as compound assignment operators like +=, -=, ensuring correct normalization behavior.

Trigonometry: A set of basic trigonometric functions: sine, cosine, tangent, cotangent, secant, cosecant. Triangle calculations: oppositeLeg(hypotenuse:), adjacentLeg(hypotenuse:), hypotenuse(fromOppositeLeg:), hypotenuse(fromAdjacentLeg:), oppositeLeg(fromAdjacentLeg:), adjacentLeg(fromOppositeLeg:).

Property Wrapper Support: Use @AAngle to store a concrete unit type while automatically converting assignments from any AAnglable type.

Installation

You can install the AAngle package via Swift Package Manager.

Using Xcode

  1. Go to File > Add Packages...
  2. Enter the repository URL: https://github.com/gmcusaro/AAngle.git.
  3. Choose "Up to Next Major Version" and specify 1.2.1 (or your initial version) as the starting version.
  4. Click "Add Package".

Add the following dependency to your Package.swift file:

dependencies: [
    .package(url: "https://github.com/gmcusaro/AAngle.git", from: "1.0.0")
]

Then add the product to any target that needs access to the library:

.product(name: "AAngle", package: "AAngle"),

Basic Usage

Create Angles

import AAngle

let degrees = Degrees(90)
let radians = Radians(Double.pi / 2)
let gradians = Gradians(100)
let revolutions = Revolutions(0.25)
let arcMinutes = ArcMinutes.zero
let arcSeconds = ArcSeconds(324000)

Conversions and AAngleType

import AAngle

// Angle conversions
let radians = Radians(Double.pi / 2)
let revolutions = Revolutions(0.25)
let degreesFromRadians = Degrees(radians) // Convert radians to degrees
let degreesFromRevolutions: Degrees = revolutions.convert(to: .degrees) as! Degrees // Convert to any AAnglable type

// Build angles from AAngleType
let oneTurn = AAngleType.revolutions.initAngle(1)
let fullCircleArcSeconds = AAngleType.arcSeconds.initAngle(1_296_000)

// Convert via AAngleType
let degreesFromGradians = AAngleType.degrees.initAngle(Gradians(100)) as! Degrees
let radiansFromType = AAngleType.radians.initAngle(Degrees(180)) as! Radians

Measurement and Introspection

import AAngle

let degrees = Degrees(90)
let measurement = degrees.toMeasurement()
print(measurement)            // 90.0 °
print(degrees.rawValue)       // 90.0
print(degrees.description)    // "90.0"
print(degrees.debugDescription)
// "Angle(Degrees): rawValue = 90.0, normalized = 90.0"

Property Wrapper (@AAngle)

Use @AAngle when you want a property to always store a concrete angle unit while accepting assignments from other AAnglable types.

import AAngle

struct RotationModel {
    @AAngle var heading = Degrees(45)
    @AAngle(.radians) var orientation: Radians = Degrees(180)
}

var model = RotationModel()
model.heading = Radians(.pi / 2)     // Automatically converted to Degrees(90)
model.orientation = Revolutions(0.5) // Automatically converted to Radians(.pi)

Use the explicit unit argument (@AAngle(.degrees), @AAngle(.radians), etc.) when you want a runtime check that the declared wrapper unit matches storage.

Operators

The AAngle types are designed with a core principle: to represent normalized angles whenever appropriate. Normalization ensures consistency and prevents ambiguity by keeping angles within a predefined range (e.g., 0-360 degrees for Degrees, 0-2π radians for Radians). However, some operations, like multiplication and division by scalars, can produce mathematically valid results outside this standard range, and preserving these values can be important. Therefore, the operators in AAngle have specific normalization behaviors:

+, - Addition and Subtraction: These operators always produce normalized results. The resulting angle is normalized to the standard range of the left-hand side operand's type. This ensures that adding or subtracting angles always results in a value within the expected bounds. The type of the result is the same as the type of the left-hand side operand.

let degrees = Degrees(350)
let radians = Radians(Double.pi / 2) // Equivalent to 90 degrees
let sum = degrees + radians  // Result: Degrees(80)  (350 + 90 = 440, normalized to 80)
let difference = radians - degrees // Result: Radians(-4.537856055185257)

+=, -= Addition and Subtraction Assignment: These operators modify the angle in place and always normalize the result. They are equivalent to performing the corresponding arithmetic operation and then assigning the normalized value back to the original variable.

var myAngle = Degrees(350)
myAngle += 90  // myAngle is now 80 (normalized)
myAngle -= Revolutions(0.25) // myAngle is now 340 (normalized)

* Multiplication: Multiplication by a scalar (Double, Int, etc.) is not normalized. This is because multiplying an angle by a value can legitimately produce an angle outside the standard range (e.g., 2 * 180 degrees = 360 degrees, 3 * 180 degrees = 540 degrees). Preserving these values is often mathematically necessary.

let degrees = Degrees(180)
let doubled = degrees * 2  // Result: Degrees(360) (not normalized)
let tripled = degrees * 3  // Result: Degrees(540) (not normalized)

*= Multiplication Assignment: The multiplication assignment operator is normalized. Repeated multiplication, as often done, will result to value to keep within the standard range.

var degrees = Degrees(180)
degrees *= 2  // Result: Degrees(0) (normalized)

/ Division: Division by a scalar is not normalized, for the same reasons as multiplication. Dividing an angle can produce a smaller or larger angle that may or may not fall within the standard range, and preserving the unnormalized value is often desirable.

let radians = Radians(Double.pi)
let halved = radians / 2  // Result: Radians(1.570796326794966) (not normalized, equal to pi/2)

/= Division Assignment: The division assignment operator is normalized. Repeated division, as often done, will result to value to keep within the standard range.

var degrees = Degrees(10)
degrees /= 2  // Result: Degrees(90) (normalized)

==, <, <=, >, >= Comparison Operators: Comparison operators work correctly between AAnglable instances of different types. Before comparison, the right-hand side operand is converted to the type of the left-hand side operand. This ensures consistent and accurate comparisons, regardless of the original units. The comparisons use a tolerance value to account for potential floating-point inaccuracies.

let degrees = Degrees(90)
let radians = Radians(Double.pi / 2)
print(degrees == 90)      // true
print(degrees == radians) // true (comparison after converting radians to degrees)
print(degrees < Int(89))  // false
print(degrees < radians)  // false

Normalization

The AAnglable protocol provides methods for normalizing angle values, ensuring they fall within a defined range. Normalization is crucial for consistency and preventing ambiguity in angle representations. Each conforming type (e.g., Degrees, Radians) defines its own normalizationValue which is a static property of AAnglable.

normalize(): Normalizes the angle in place to the standard range defined by the conforming type's normalizationValue. For example, for Degrees, this would be the range 0 to 360 (exclusive of 360). The normalize() methods modify the existing angle.

var myAngle = Degrees(450)
myAngle.normalize() // myAngle is now 90

normalized() -> Self: Returns a new AAnglable instance containing the normalized value. The original instance is not modified. The normalized() methods create a new, normalized angle instance.

let myAngle = Degrees(450)
let normalizedAngle = myAngle.normalized() // normalizedAngle is 90, myAngle is still 450

normalize(by value: Double): Normalizes the angle in place using a custom normalization value. This is useful if you need a range other than the default. The normalize() methods modify the existing angle.

var myAngle = Radians(3 * Double.pi) // 3π
myAngle.normalize(by: Double.pi) // myAngle is now π (normalized to the range 0 to π)

normalized(by value: Double) -> Self: Returns a new AAnglable instance, normalized using the provided custom normalization value. The original instance is not modified. The normalized() methods create a new, normalized angle instance.

let myAngle = Radians(3 * Double.pi) // 3π
let normalizedAngle = myAngle.normalized(by: Double.pi) // normalizedAngle is π, myAngle is still 3π

Tolerance

In AAnglable, the default tolerance value is 1e-12. Each angle instance can override tolerance when you need looser or stricter floating-point comparisons.

print(Degrees.defaultTolerance) // 1e-12

var deg1 = Degrees(89.999999999999) // tolerance starts at Degrees.defaultTolerance (1e-12)
var deg2 = Degrees(89.99999)        // tolerance starts at Degrees.defaultTolerance (1e-12)
print(deg1 == deg2)                 // false

deg1.tolerance = 1e-5               // Set custom tolerance for deg1
print(deg1.tolerance)               // 1e-5 (different from default 1e-12)
print(deg1 == deg2)                 // true

Sanitized Tolerance

Use sanitizedTolerance(_:) to make tolerance values safe before comparison logic:

  • Non-finite values (NaN, +/-infinity) fall back to defaultTolerance.
  • Negative values fall back to defaultTolerance.
  • Very small positive values are clamped up to defaultTolerance.
print(Degrees.sanitizedTolerance(.nan))       // 1e-12
print(Degrees.sanitizedTolerance(-1))         // 1e-12
print(Degrees.sanitizedTolerance(1e-15))      // 1e-12
print(Degrees.sanitizedTolerance(1e-5))       // 1e-5

AAngle comparison APIs (==, <, <=, >=, isApproximatelyEqual, isEquivalent) use sanitized tolerance values internally.

Comparison Helpers

Use isApproximatelyEqual(to:tolerance:) for raw-value closeness checks, and isEquivalent(to:tolerance:) for circular equivalence checks (values that differ by full turns).

let a = Degrees(0)
let b = Degrees(360)
let c = Degrees(0.0000000000005)

print(a.isApproximatelyEqual(to: c)) // true (within tolerance)
print(a.isEquivalent(to: b))          // true (same direction on a circle)

Trigonometry

AAngle provides a set of trigonometric functions for Radians type.

Basic Trigonometric Functions

  • Sine: Returns the sine of the angle.
  • Cosine: Returns the cosine of the angle.
  • Tangent: Returns the tangent of the angle.
  • Cotangent: Returns the cotangent of the angle (1 / tangent). Returns nil if the tangent is zero.
  • Secant: Returns the secant of the angle (1 / cosine). Returns nil if the cosine is zero.
  • Cosecant: Returns the cosecant of the angle (1 / sine). Returns nil if the sine is zero.
import AAngle

let radians = Radians(Degrees(45))

// Basic trigonometric functions
print(radians.sine)      // 0.7071067811865475
print(radians.cosine)    // 0.7071067811865476
print(radians.tangent)   // 0.9999999999999999
print(radians.cotangent) // Optional(1.0000000000000002)
print(radians.secant)    // Optional(1.414213562373095)
print(radians.cosecant)  // Optional(1.414213562373095)

Triangle Calculations

  • Opposite Leg oppositeLeg(hypotenuse:) Computes the length of the opposite leg of a right triangle given the hypotenuse.
  • Adjacent Leg adjacentLeg(hypotenuse:) Computes the length of the adjacent leg of a right triangle given the hypotenuse.
  • Hypotenuse hypotenuse(fromOppositeLeg:) Computes the hypotenuse of a right triangle given the opposite leg.
  • Hypotenuse hypotenuse(fromAdjacentLeg:) Computes the hypotenuse of a right triangle given the adjacent leg.
  • Opposite Leg oppositeLeg(fromAdjacentLeg:) Computes the opposite leg of a right triangle given the adjacent leg.
  • Adjacent Leg adjacentLeg(fromOppositeLeg:) Computes the adjacent leg of a right triangle given the opposite leg.
import AAngle

let radians = Radians(Double.pi / 4) // 45 degrees

// Triangle calculations
let hypotenuse = 10.0
print(radians.oppositeLeg(hypotenuse: hypotenuse)) // 7.0710678118654755
print(radians.adjacentLeg(hypotenuse: hypotenuse)) // 7.071067811865476
print(radians.hypotenuse(fromOppositeLeg: 5.0))    // 7.0710678118654755
print(radians.hypotenuse(fromAdjacentLeg: 5.0))    // 7.071067811865476
print(radians.oppositeLeg(fromAdjacentLeg: 5.0))   // 5.0
print(radians.adjacentLeg(fromOppositeLeg: 5.0))   // 5.0

License

This package is licensed under the Apache License. See LICENSE for more information.

About

A Swift package that provides a flexible and extensible way to work with different types of angles.

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages