Skip to content

Normalize offset/offset_origin output longitude to [-180, 180)#27

Open
gistrec wants to merge 1 commit into
masterfrom
fix/offset-lng-wrap
Open

Normalize offset/offset_origin output longitude to [-180, 180)#27
gistrec wants to merge 1 commit into
masterfrom
fix/offset-lng-wrap

Conversation

@gistrec

@gistrec gistrec commented Jun 10, 2026

Copy link
Copy Markdown
Owner

Problem

offset() and offset_origin() return the raw longitude term without wrapping, so any result that crosses the antimeridian leaves the documented LatLng range ("Longitude ranges between -180 and 180 degrees"):

geo::offset({0, 170}, 3'000'000, 90);          // lng = +196.9796, expected -163.0204
geo::offset_origin({0, -170}, 3'000'000, 90);  // lng = -196.9796, expected +163.0204
  • offset returns rad2deg(from_lng + d_lng) — each term is in [-π, π], the sum spans up to ±2π.
  • offset_origin returns deg2rad(to.lng) - atan2(...) — same issue.

Root cause: the Android SDK normalizes longitude inside the LatLng constructor, so android-maps-utils never needed to wrap these results. This port's constexpr constructor stores values as-is (fmod is not constexpr before C++23), so the normalization was silently lost in porting.

Library-internal math is unaffected (everything is periodic, operator== compares lng modulo 360), but out-of-range longitudes break interop: GeoJSON (RFC 7946), map APIs, DB range constraints, naive component comparison.

Fix

Wrap the output longitude with detail::wrap(lng, -180.0, 180.0) in both functions (same approach heading() already uses for its result). In-range results take the wrap fast path (two comparisons) and are returned bit-for-bit unchanged; only out-of-range results pay for two fmods.

call before after
offset((0,170), 3000 km, E) +196.9796100648 -163.0203899352
offset((0,-170), 3000 km, W) -196.9796100648 +163.0203899352
offset_origin((0,-170), 3000 km, E) -196.9796100648 +163.0203899352
offset((0,170), 10° arc, E) — lands exactly on the antimeridian +180.0 -180.0 (same point)
offset((10,20), 3000 km, NE) — in range 41.320625189337278 41.320625189337278 (bit-identical)

Timings

Apple M1, -O2, Google Benchmark; 10 interleaved base/fixed pairs (run order alternating inside each pair), median of per-pair deltas of per-run medians (15 reps each) — robust against drifting background load. 1000 varied calls per iteration.

benchmark base ns/call fixed ns/call delta
offset, result in range (fast path) 31.9 32.6 +0.70 ns (+2.2%)
offset, result crosses antimeridian 23.6 41.1 +17.5 ns (+75%)
offset_origin, result in range 60.4 62.5 +2.2 ns (+3.6%)
offset_origin, result crosses antimeridian 44.1 58.3 +14.2 ns (+32%)

The crossing-path cost is two fmod calls sitting on the critical path right before the return; it only applies to the (previously incorrect) out-of-range results. The headline percentage looks large because the function itself is only tens of nanoseconds.

Tests

Two new regression tests (suite 34 → 36, all pass):

  • Spherical.offset_lng_normalized — east/west crossings against analytically derived expected longitudes (1e-9 deg tolerance), plus the exact-180 landing case (lng ∈ [-180, 180) and position within 1 mm of (0, 180)).
  • Spherical.offset_origin_lng_normalized — both crossing directions, plus a round-trip back onto the destination.

Notes

Both functions returned the raw longitude sum (up to +/-360 deg) when
the result crosses the antimeridian, e.g. offset((0,170), 3000 km, east)
returned lng +196.98 instead of -163.02 - contradicting the documented
LatLng longitude range. The Android SDK normalizes inside the LatLng
constructor; this port stores values as-is, so the functions must wrap
their own output.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants