Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -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}
```

---
Expand All @@ -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<LatLng>` — the origin, or `std::nullopt` if unreachable.
Returns: `std::optional<LatLng>` — 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};
Expand Down
11 changes: 9 additions & 2 deletions include/geo/spherical.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<LatLng> offset_origin(const LatLng& to, double distance, double heading_deg) noexcept {
double heading_rad = detail::deg2rad(heading_deg);
Expand All @@ -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));
}

/**
Expand Down
29 changes: 29 additions & 0 deletions tests/spherical/offset.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -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);
}
29 changes: 29 additions & 0 deletions tests/spherical/offset_origin.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -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);
}
}
Loading