Skip to content
Open
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
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,11 @@ Earth approximation model.

- **Lat/lng-native API** — pass latitude/longitude coordinates directly, no
framework-specific point types to convert through.
- **Header-only, dependency-free** — about 40 KB across 5 headers; nothing
- **Header-only, dependency-free** — about 44 KB across 5 headers; nothing
to build or link.
- **Spherical math** — distance, heading, offset, interpolation, area.
- **Polygon utilities** — point-in-polygon and path proximity checks.
- **Polygon utilities** — point-in-polygon, path proximity, and
Douglas–Peucker simplification.
- **Polyline encoding** — `encode`/`decode` for the Google Encoded Polyline
format.
- **Fast** — matches hand-written haversine on `distance`; especially strong
Expand Down
54 changes: 50 additions & 4 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ are internal and not part of the supported API.
(`area`, `path_length`, `contains`, `on_edge`, `on_path`) are not
marked `noexcept` because the generic `Path` contract doesn't
constrain `operator[]` / `size()` to be `noexcept`; they don't throw
themselves. `encode` and `decode` return owning containers and can
throw `std::bad_alloc` on allocation failure.
themselves. `encode`, `decode`, and `simplify` return owning
containers and can throw `std::bad_alloc` on allocation failure.
- **Include strategy.** Each subsystem has its own header:
`<geo/latlng.hpp>` (types), `<geo/spherical.hpp>` (distance, heading,
area), `<geo/poly.hpp>` (point-in-polygon, on-path), `<geo/encoding.hpp>`
Expand Down Expand Up @@ -73,8 +73,9 @@ a.approx_equal(b, 1e-5); // true (1e-5° ≈ 1 m on equator)
A series of connected coordinates in an ordered sequence.

`Path` is a template parameter accepted by `path_length`, `area`, `signed_area`,
`contains`, `on_edge`, `on_path`, and `encode`. It must be a random-access
container of `geo::LatLng` — specifically, it must support:
`contains`, `on_edge`, `on_path`, `is_closed_polygon`, `simplify`, and `encode`.
It must be a random-access container of `geo::LatLng` — specifically, it must
support:

- `path.size()` returning a size in elements
- `path[i]` returning a `LatLng` (or something convertible) for `0 ≤ i < size`
Expand Down Expand Up @@ -371,6 +372,51 @@ std::cout << geo::distance_to_segment(point, start, end);

---

### is_closed_polygon

**`geo::is_closed_polygon(const Path& poly)`** — Returns whether the path is a closed polygon: non-empty, with equal first and last points. Equality is the approximate `LatLng` comparison (`operator==`), so longitudes are compared modulo 360° — a path from `(10, 180)` ending at `(10, -180)` counts as closed. A single point counts as closed.

Returns: `bool` — `true` if the path is non-empty and its first and last points are equal.

```cpp
std::vector<geo::LatLng> poly = {
{28.06025, -82.41030}, {28.06129, -82.40945}, {28.06206, -82.40917},
};

std::cout << geo::is_closed_polygon(poly); // false
poly.push_back(poly.front());
std::cout << geo::is_closed_polygon(poly); // true
```

---

### simplify

**`geo::simplify(const Path& poly, double tolerance)`** — Simplifies the given polyline or polygon using the [Douglas–Peucker](https://en.wikipedia.org/wiki/Ramer%E2%80%93Douglas%E2%80%93Peucker_algorithm) decimation algorithm: keeps the vertices that lie farther than `tolerance` meters from the simplified shape, drops the rest. The first and last points are always kept, every returned point is one of the input points (in input order), and the input is not modified.

A closed polygon (in the `is_closed_polygon` sense) is simplified including its closing segment, so the result is a closed polygon too.

- `tolerance` — maximum distance in meters a dropped vertex may lie from the simplified path; larger values drop more points.

Returns: `std::vector<LatLng>` — the simplified path; empty only for an empty input.

> **Note.** Distances are measured with `distance_to_segment`, so its
> approximation limits apply — in particular for segments crossing the
> antimeridian. Worst-case complexity is O(n²) in the number of input
> points.

```cpp
std::vector<geo::LatLng> route = {
{28.06025, -82.41030}, {28.06129, -82.40945}, {28.06206, -82.40917},
{28.06125, -82.40850}, {28.06035, -82.40834}, {28.06038, -82.40924},
};

auto simplified = geo::simplify(route, /*tolerance=*/88.0);
std::cout << simplified.size(); // 4 — two vertices within 88 m are dropped
```

---

## Polyline encoding

Encoder and decoder for the [Encoded Polyline Algorithm Format](https://developers.google.com/maps/documentation/utilities/polylinealgorithm)
Expand Down
89 changes: 89 additions & 0 deletions include/geo/poly.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
#include <algorithm>
#include <cmath>
#include <cstddef>
#include <utility>
#include <vector>

#include "detail/math.hpp"
#include "latlng.hpp"
Expand Down Expand Up @@ -264,4 +266,91 @@ template <typename Path>
return distance_between(p, su);
}

/**
* Returns whether the path is a closed polygon: non-empty, with equal first
* and last points. Equality is the approximate LatLng comparison, with
* longitudes compared modulo 360°.
*/
template <typename Path>
[[nodiscard]] bool is_closed_polygon(const Path& poly) {
std::size_t size = poly.size();
if (size == 0) {
return false;
}
const auto& first = poly[0];
const auto& last = poly[size - 1];
return LatLng(first.lat, first.lng) == LatLng(last.lat, last.lng);
}

/**
* Simplifies the given polyline or polygon using the Douglas-Peucker
* decimation algorithm: keeps the vertices that lie farther than tolerance
* meters from the simplified shape, drops the rest. The first and last
* points are always kept, and every returned point is one of the input
* points. A closed polygon (is_closed_polygon) is simplified including its
* closing segment.
*
* Distances are measured with distance_to_segment, so its approximation
* limits apply — in particular for segments crossing the antimeridian.
* Worst-case complexity is O(n^2).
*/
template <typename Path>
[[nodiscard]] std::vector<LatLng> simplify(const Path& poly, double tolerance) {
std::size_t n = poly.size();
if (n == 0) {
return {};
}

// Work on a copy: for a closed polygon the last point is nudged slightly
// off the first one so Douglas-Peucker "sees" the closing segment
// (upstream PolyUtil trick); the output is filtered from the original
// points, so the nudge never leaks into the result.
std::vector<LatLng> working;
working.reserve(n);
for (const auto& point : poly) {
working.emplace_back(point.lat, point.lng);
}
if (is_closed_polygon(working)) {
constexpr double offset = 1e-11;
working.back() = LatLng(working.back().lat + offset, working.back().lng + offset);
}

std::vector<bool> keep(n, false);
keep.front() = true;
keep.back() = true;

if (n > 2) {
std::vector<std::pair<std::size_t, std::size_t>> stack;
stack.emplace_back(0, n - 1);
while (!stack.empty()) {
const auto [start, end] = stack.back();
stack.pop_back();

double max_dist = 0;
std::size_t max_idx = 0;
for (std::size_t i = start + 1; i < end; ++i) {
double dist = distance_to_segment(working[i], working[start], working[end]);
if (dist > max_dist) {
max_dist = dist;
max_idx = i;
}
}
if (max_dist > tolerance) {
keep[max_idx] = true;
stack.emplace_back(start, max_idx);
stack.emplace_back(max_idx, end);
}
}
}

std::vector<LatLng> result;
for (std::size_t i = 0; i < n; ++i) {
if (keep[i]) {
const auto& point = poly[i];
result.emplace_back(point.lat, point.lng);
}
}
return result;
}

} // namespace geo
32 changes: 32 additions & 0 deletions tests/poly/is_closed_polygon.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
#include <gtest/gtest.h>
#include <vector>

#include <geo/poly.hpp>

using geo::LatLng;
using geo::is_closed_polygon;

TEST(Poly, is_closed_polygon) {
// Empty path is not a polygon.
EXPECT_FALSE(is_closed_polygon(std::vector<LatLng>{}));

// A single point is its own first and last point.
EXPECT_TRUE(is_closed_polygon(std::vector<LatLng>{{28.06025, -82.41030}}));

// Open polygon from the upstream test suite.
std::vector<LatLng> poly = {
{28.06025, -82.41030}, {28.06129, -82.40945}, {28.06206, -82.40917},
{28.06125, -82.40850}, {28.06035, -82.40834},
};
EXPECT_FALSE(is_closed_polygon(poly));

// Closing the polygon by repeating the first point.
poly.push_back(poly.front());
EXPECT_TRUE(is_closed_polygon(poly));
}

TEST(Poly, is_closed_polygon_antimeridian) {
// 180 and -180 are the same meridian: longitudes compare modulo 360.
std::vector<LatLng> poly = {{10, 180}, {20, 90}, {10, -180}};
EXPECT_TRUE(is_closed_polygon(poly));
}
119 changes: 119 additions & 0 deletions tests/poly/simplify.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
#include <gtest/gtest.h>
#include <cstddef>
#include <utility>
#include <vector>

#include <geo/encoding.hpp>
#include <geo/poly.hpp>
#include <geo/spherical.hpp>

using geo::LatLng;
using geo::decode;
using geo::is_closed_polygon;
using geo::path_length;
using geo::simplify;

namespace {

// Checks the upstream PolyUtil.simplify invariants: endpoints are preserved,
// every output point is one of the input points (in input order), and the
// simplified path is not longer than the original.
void expect_simplified(const std::vector<LatLng>& line, const std::vector<LatLng>& simplified) {
ASSERT_FALSE(simplified.empty());
EXPECT_EQ(simplified.front(), line.front());
EXPECT_EQ(simplified.back(), line.back());

std::size_t pos = 0;
for (const auto& point : simplified) {
bool found = false;
while (pos < line.size()) {
if (line[pos++] == point) {
found = true;
break;
}
}
EXPECT_TRUE(found) << point << " is not an input point (or out of order)";
}

EXPECT_LE(path_length(simplified), path_length(line));
}

} // namespace

TEST(Poly, simplify_smallPaths) {
EXPECT_TRUE(simplify(std::vector<LatLng>{}, 10.0).empty());

std::vector<LatLng> one = {{28.06025, -82.41030}};
auto simplified_one = simplify(one, 10.0);
ASSERT_EQ(simplified_one.size(), 1U);
EXPECT_EQ(simplified_one[0], one[0]);

std::vector<LatLng> two = {{28.06025, -82.41030}, {28.06035, -82.40834}};
auto simplified_two = simplify(two, 10.0);
ASSERT_EQ(simplified_two.size(), 2U);
EXPECT_EQ(simplified_two[0], two[0]);
EXPECT_EQ(simplified_two[1], two[1]);
}

TEST(Poly, simplify_line) {
// 95-point polyline from the upstream test suite.
static const char kLine[] =
"elfjD~a}uNOnFN~Em@fJv@tEMhGDjDe@hG^nF??@lA?n@IvAC`Ay@A{@DwCA{CF_EC{CEi@PBTFDJBJ?V?n@?D@?A@?@?F?F?"
"LAf@?n@@`@@T@~@FpA?fA?p@?r@?vAH`@OR@^ETFJCLD?JA^?J?P?fAC`B@d@?b@A\\@`@Ad@@\\?`@?f@?V?H?DD@DDBBDBD?"
"D?B?B@B@@@B@B@B@D?D?JAF@H@FCLADBDBDCFAN?b@Af@@x@@";
const std::vector<LatLng> line = decode(kLine);
ASSERT_EQ(line.size(), 95U);

// Expected sizes match the upstream test suite.
const std::pair<double, std::size_t> expected[] = {
{5, 20}, {10, 14}, {15, 10}, {20, 8}, {50, 6}, {500, 3}, {1000, 2},
};
for (const auto& [tolerance, size] : expected) {
auto simplified = simplify(line, tolerance);
EXPECT_EQ(simplified.size(), size) << "tolerance = " << tolerance;
expect_simplified(line, simplified);
}
}

TEST(Poly, simplify_triangle) {
// Open triangle from the upstream test suite.
std::vector<LatLng> triangle = {
{28.06025, -82.41030}, {28.06129, -82.40945}, {28.06206, -82.40917},
{28.06125, -82.40850}, {28.06035, -82.40834}, {28.06038, -82.40924},
};
ASSERT_FALSE(is_closed_polygon(triangle));

auto simplified = simplify(triangle, 88.0);
EXPECT_EQ(simplified.size(), 4U);
expect_simplified(triangle, simplified);

// Closing the triangle keeps the simplified size: the closing segment is
// included in the simplification.
triangle.push_back(triangle.front());
ASSERT_TRUE(is_closed_polygon(triangle));

auto simplified_closed = simplify(triangle, 88.0);
EXPECT_EQ(simplified_closed.size(), 4U);
expect_simplified(triangle, simplified_closed);
}

TEST(Poly, simplify_oval) {
// Oval polygon from the upstream test suite.
static const char kOval[] =
"}wgjDxw_vNuAd@}AN{A]w@_Au@kAUaA?{@Ke@@_@C]D[FULWFOLSNMTOVOXO\\I\\CX?VJXJTDTNXTVVLVJ`@FXA\\AVLZBTAT"
"BZ@ZAT?\\?VFT@XGZ";
std::vector<LatLng> oval = decode(kOval);
ASSERT_FALSE(is_closed_polygon(oval));

auto simplified = simplify(oval, 10.0);
EXPECT_EQ(simplified.size(), 13U);
expect_simplified(oval, simplified);

// Closed oval: same result.
oval.push_back(oval.front());
ASSERT_TRUE(is_closed_polygon(oval));

auto simplified_closed = simplify(oval, 10.0);
EXPECT_EQ(simplified_closed.size(), 13U);
expect_simplified(oval, simplified_closed);
}
2 changes: 2 additions & 0 deletions tests/tests.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@

#include "poly/contains.hpp"
#include "poly/distance_to_segment.hpp"
#include "poly/is_closed_polygon.hpp"
#include "poly/on_edge.hpp"
#include "poly/on_path.hpp"
#include "poly/simplify.hpp"

#include "encoding/decode.hpp"
#include "encoding/encode.hpp"
Expand Down