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.
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).
- 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
@AAngleproperty wrapper.
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.
You can install the AAngle package via Swift Package Manager.
- Go to File > Add Packages...
- Enter the repository URL:
https://github.com/gmcusaro/AAngle.git. - Choose "Up to Next Major Version" and specify
1.2.1(or your initial version) as the starting version. - Click "Add Package".
Using Package.swift
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"),
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)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! Radiansimport 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"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.
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) // falseThe 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 90normalized() -> 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 450normalize(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π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) // trueUse sanitizedTolerance(_:) to make tolerance values safe before comparison logic:
- Non-finite values (
NaN,+/-infinity) fall back todefaultTolerance. - 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-5AAngle comparison APIs (==, <, <=, >=, isApproximatelyEqual, isEquivalent) use sanitized tolerance values internally.
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)AAngle provides a set of trigonometric functions for Radians type.
- 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)- 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.0This package is licensed under the Apache License. See LICENSE for more information.