diff --git a/docs/api.md b/docs/api.md index cfa67f8..ec98f28 100644 --- a/docs/api.md +++ b/docs/api.md @@ -135,7 +135,7 @@ std::cout << geo::heading(east, equator); // -90 (due west) - `distance` — the distance to travel, in meters - `heading` — the heading in degrees clockwise from north -Returns: `LatLng` — the destination point. +Returns: `LatLng` — the destination point. The longitude of the result is normalized to `[-180, 180)`. ```cpp geo::LatLng front{0, 0}; @@ -147,6 +147,9 @@ auto up = geo::offset(front, quarter, 0); // { 90, 0} auto down = geo::offset(front, quarter, 180); // { -90, 0} auto left = geo::offset(front, quarter, -90); // { 0, -90} auto right = geo::offset(front, quarter, 90); // { 0, 90} + +// Crossing the antimeridian: the longitude comes back normalized. +auto wrapped = geo::offset({0, 170}, 3'000'000, 90); // {0, -163.02}, not {0, 196.98} ``` --- @@ -159,7 +162,7 @@ auto right = geo::offset(front, quarter, 90); // { 0, 90} - `distance` — the distance travelled, in meters - `heading` — the heading in degrees clockwise from north -Returns: `std::optional` — the origin, or `std::nullopt` if unreachable. +Returns: `std::optional` — the origin, or `std::nullopt` if unreachable. The longitude of the result is normalized to `[-180, 180)`. ```cpp geo::LatLng start{40.0, -74.0}; diff --git a/include/geo/spherical.hpp b/include/geo/spherical.hpp index eb09dd1..67a5cd2 100644 --- a/include/geo/spherical.hpp +++ b/include/geo/spherical.hpp @@ -45,6 +45,7 @@ namespace geo { /** * Returns the LatLng resulting from moving a distance from an origin * in the specified heading (expressed in degrees clockwise from north). + * The longitude of the result is normalized to the range [-180, 180). */ [[nodiscard]] inline LatLng offset(const LatLng& from, double distance, double heading_deg) noexcept { distance /= detail::kEarthRadius; @@ -60,13 +61,16 @@ namespace geo { sin_distance * cos_from_lat * std::sin(heading_rad), cos_distance - sin_from_lat * sin_lat); + // from_lng and d_lng are each in [-π, π], so their raw sum spans up to + // ±2π; wrap it back into the documented LatLng longitude range. return LatLng(detail::rad2deg(std::asin(std::clamp(sin_lat, -1.0, 1.0))), - detail::rad2deg(from_lng + d_lng)); + detail::wrap(detail::rad2deg(from_lng + d_lng), -180.0, 180.0)); } /** * Returns the location of origin when provided with a destination, * meters travelled and original heading. Returns nullopt when no solution exists. + * The longitude of the result is normalized to the range [-180, 180). */ [[nodiscard]] inline std::optional offset_origin(const LatLng& to, double distance, double heading_deg) noexcept { double heading_rad = detail::deg2rad(heading_deg); @@ -91,7 +95,10 @@ namespace geo { if (from_lat_radians < -detail::kPi / 2 || from_lat_radians > detail::kPi / 2) return std::nullopt; double from_lng_radians = detail::deg2rad(to.lng) - std::atan2(n3, n1 * std::cos(from_lat_radians) - n2 * std::sin(from_lat_radians)); - return LatLng(detail::rad2deg(from_lat_radians), detail::rad2deg(from_lng_radians)); + // to.lng - atan2(...) spans up to ±2π; wrap it back into the documented + // LatLng longitude range. + return LatLng(detail::rad2deg(from_lat_radians), + detail::wrap(detail::rad2deg(from_lng_radians), -180.0, 180.0)); } /** diff --git a/tests/spherical/offset.hpp b/tests/spherical/offset.hpp index 1e1b29b..97f71ba 100644 --- a/tests/spherical/offset.hpp +++ b/tests/spherical/offset.hpp @@ -4,9 +4,12 @@ #include "../test_helpers.hpp" using geo::LatLng; +using geo::distance_between; using geo::offset; +using geo::detail::deg2rad; using geo::detail::kEarthRadius; using geo::detail::kPi; +using geo::detail::rad2deg; TEST(Spherical, offset) { LatLng up = { 90.0, 0.0 }; @@ -35,3 +38,29 @@ TEST(Spherical, offset) { // NOTE: Heading is undefined at the poles, so we do not test from up/down. } + +TEST(Spherical, offset_lng_normalized) { + // The raw result longitude (from_lng + d_lng) spans up to ±360° when the + // path crosses the antimeridian; offset must wrap it into [-180, 180) + // (the Android SDK normalized in the LatLng constructor, this port does + // not, so the function has to wrap its own output). + const double d = 3.0e6; // 3000 km ≈ 26.98° of arc at the equator + const double d_deg = rad2deg(d / kEarthRadius); + + // Eastbound across the antimeridian: raw lng ≈ +196.98 → wrapped ≈ -163.02. + LatLng east = offset(LatLng(0, 170), d, 90); + EXPECT_NEAR(east.lat, 0.0, 1e-9); + EXPECT_NEAR(east.lng, 170.0 + d_deg - 360.0, 1e-9); + + // Westbound across the antimeridian: raw lng ≈ -196.98 → wrapped ≈ +163.02. + LatLng west = offset(LatLng(0, -170), d, -90); + EXPECT_NEAR(west.lat, 0.0, 1e-9); + EXPECT_NEAR(west.lng, -170.0 - d_deg + 360.0, 1e-9); + + // Landing exactly on the antimeridian: lng must stay inside [-180, 180), + // i.e. +180 becomes -180 (the same point). + LatLng on180 = offset(LatLng(0, 170), deg2rad(10.0) * kEarthRadius, 90); + EXPECT_GE(on180.lng, -180.0); + EXPECT_LT(on180.lng, 180.0); + EXPECT_LT(distance_between(on180, LatLng(0, 180)), 1e-3); +} diff --git a/tests/spherical/offset_origin.hpp b/tests/spherical/offset_origin.hpp index 8515b2d..3b941d8 100644 --- a/tests/spherical/offset_origin.hpp +++ b/tests/spherical/offset_origin.hpp @@ -8,6 +8,7 @@ using geo::offset; using geo::offset_origin; using geo::detail::kEarthRadius; using geo::detail::kPi; +using geo::detail::rad2deg; TEST(Spherical, offset_origin) { LatLng front = { 0.0, 0.0 }; @@ -74,3 +75,31 @@ TEST(Spherical, offset_origin) { EXPECT_NEAR_LatLng(to, offset(r.value(), half_pi_R, 45.0)); } } + +TEST(Spherical, offset_origin_lng_normalized) { + // The raw origin longitude (to.lng - atan2(...)) spans up to ±360° when + // the origin lies across the antimeridian from the destination; it must + // come back wrapped into [-180, 180). + const double d = 3.0e6; // 3000 km ≈ 26.98° of arc at the equator + const double d_deg = rad2deg(d / kEarthRadius); + + // Travelling east to (0,-170): the origin lies west across the + // antimeridian, raw lng ≈ -196.98 → wrapped ≈ +163.02. + { + auto r = offset_origin(LatLng(0, -170), d, 90); + ASSERT_TRUE(r.has_value()); + EXPECT_NEAR(r->lat, 0.0, 1e-9); + EXPECT_NEAR(r->lng, -170.0 - d_deg + 360.0, 1e-9); + // And the wrapped origin still maps back onto the destination. + EXPECT_NEAR_LatLng(LatLng(0, -170), offset(r.value(), d, 90)); + } + + // Travelling west to (0,170): the origin lies east across the + // antimeridian, raw lng ≈ +196.98 → wrapped ≈ -163.02. + { + auto r = offset_origin(LatLng(0, 170), d, -90); + ASSERT_TRUE(r.has_value()); + EXPECT_NEAR(r->lat, 0.0, 1e-9); + EXPECT_NEAR(r->lng, 170.0 + d_deg - 360.0, 1e-9); + } +}