0

We have the data type Angle in SwiftUI. This can store a value in degrees or radians from -infinity to +infinity.

I'd like to convert the angle to a value between 0 to 360. E.g. via using a method such as wrap around mod:

func normalize(_ angle: Angle) -> Angle {
  wrapAroundMod(angle.degrees, 360.0)
}

However, wrapAroundMod isn't a built-in function, and wouldn't support floating point numbers. I'm struggling to come up with an elegant solution that doesn't have a bunch of branching logic.

Is there an elegant approach to normalizing an Angle?

Asperi
  • 228,894
  • 20
  • 464
  • 690
Senseful
  • 86,719
  • 67
  • 308
  • 465

2 Answers2

1

Elegant is in the eye of the beholder...

You can accomplish this without using a branch by using two applications of truncatingRemainder(dividingBy:):

extension Angle {
    /// Returns an Angle in the range `0° ..< 360°`
    func normalized() -> Angle {
        let degrees = (self.degrees.truncatingRemainder(dividingBy: 360) + 360)
                      .truncatingRemainder(dividingBy: 360)

        return Angle(degrees: degrees)
    }
}
vacawama
  • 150,663
  • 30
  • 266
  • 294
  • Is this any better than a branch? Performance? I don't know the implementation of `truncatingRemainder(dividingBy:)` but I assume it would be more expensive than just a branch. – George Sep 01 '21 at 23:20
  • @George This is definitely not an expensive operation. – Leo Dabus Sep 02 '21 at 00:07
  • @LeoDabus Expensive was probably the wrong word, but is it actually better than `if degrees < 0 { degrees = degrees + 360 }`? I really don't know, I'm curious. I assume the difference might just be negligible for this task? – George Sep 02 '21 at 00:13
  • 2
    @George Yes I think both approaches would work just fine. I would prefer to work directly on radians as most methods rely on radians and the natural measure for dividing a circle is in radians. – Leo Dabus Sep 02 '21 at 00:18
  • 2
    @vacawama no need to use a var. Btw the radians version would look like `var normalized: Self { .init(radians: (radians.truncatingRemainder(dividingBy: 2 * .pi) + 2 * .pi).truncatingRemainder(dividingBy: 2 * .pi)) }` – Leo Dabus Sep 02 '21 at 01:06
0

The equivalent of % for floating point numbers is truncatingRemainder, and it handles negative values pretty well!

You can use this function to normalize an angle:

extension Angle {
  /// Returns an Angle in the range `0° ..< 360°`
  func normalized() -> Angle {
    var degrees = self.degrees.truncatingRemainder(dividingBy: 360)
    if degrees < 0 {
      degrees = degrees + 360
    }
    return Angle(degrees: degrees)
  }
}

If you want to normalize it in the range -180° ..< 180°, consider adding this extension too:

extension Angle {
  /// Returns an Angle in the range `-180° ..< 180°` by mapping `180° ..< 360°` to `-180° ..< 0°`
  func normalizedDelta() -> Angle {
    var normalized = normalized()
    if normalized >= Angle(degrees: 180) {
      normalized = normalized - Angle(degrees: 360)
    }
    return normalized
  }
}

These functions have the properties:

Angle(degrees: -1081).normalized().degrees == 359
Angle(degrees: -721).normalized().degrees == 359
Angle(degrees: -361).normalized().degrees == 359
Angle(degrees: -1).normalized().degrees == 359
Angle(degrees: 0).normalized().degrees == 0
Angle(degrees: 359.9).normalized().degrees == 359.9
Angle(degrees: 360).normalized().degrees == 0
Angle(degrees: 361).normalized().degrees == 1
Angle(degrees: 720).normalized().degrees == 0
Angle(degrees: 721).normalized().degrees == 1
Angle(degrees: 1081).normalized().degrees == 1

Angle(degrees: -1081).normalizedDelta().degrees // == -1
Angle(degrees: -721).normalizedDelta().degrees // == -1
Angle(degrees: -361).normalizedDelta().degrees // == -1
Angle(degrees: -1).normalizedDelta().degrees // == -1
Angle(degrees: 0).normalizedDelta().degrees == 0
Angle(degrees: 359.9).normalizedDelta().degrees // == -0.1
Angle(degrees: 360).normalizedDelta().degrees == 0
Angle(degrees: 361).normalizedDelta().degrees == 1
Angle(degrees: 720).normalizedDelta().degrees == 0
Angle(degrees: 721).normalizedDelta().degrees == 1
Angle(degrees: 1081).normalizedDelta().degrees == 1
Senseful
  • 86,719
  • 67
  • 308
  • 465
  • I think you would use this `.normalized()` in saving data or kind of this works or math works, because there is big deference for SwiftUI between 0 and 360 or 720 degrees. In animation and in View render they are not same. – ios coder Sep 01 '21 at 22:32
  • `degrees += 360` and `normalized -= Angle(degrees: 360)` – Leo Dabus Sep 02 '21 at 00:02