diff --git a/README.md b/README.md index 998f64a..696f41a 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/api.md b/docs/api.md index ef88486..5daec17 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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: `` (types), `` (distance, heading, area), `` (point-in-polygon, on-path), `` @@ -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` @@ -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 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` — 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 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) diff --git a/include/geo/poly.hpp b/include/geo/poly.hpp index 2641afb..89f3a85 100644 --- a/include/geo/poly.hpp +++ b/include/geo/poly.hpp @@ -16,6 +16,8 @@ #include #include #include +#include +#include #include "detail/math.hpp" #include "latlng.hpp" @@ -264,4 +266,91 @@ template 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 +[[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 +[[nodiscard]] std::vector 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 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 keep(n, false); + keep.front() = true; + keep.back() = true; + + if (n > 2) { + std::vector> 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 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 diff --git a/tests/poly/is_closed_polygon.hpp b/tests/poly/is_closed_polygon.hpp new file mode 100644 index 0000000..6b01c33 --- /dev/null +++ b/tests/poly/is_closed_polygon.hpp @@ -0,0 +1,32 @@ +#include +#include + +#include + +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{})); + + // A single point is its own first and last point. + EXPECT_TRUE(is_closed_polygon(std::vector{{28.06025, -82.41030}})); + + // Open polygon from the upstream test suite. + std::vector 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 poly = {{10, 180}, {20, 90}, {10, -180}}; + EXPECT_TRUE(is_closed_polygon(poly)); +} diff --git a/tests/poly/simplify.hpp b/tests/poly/simplify.hpp new file mode 100644 index 0000000..a8e77ff --- /dev/null +++ b/tests/poly/simplify.hpp @@ -0,0 +1,119 @@ +#include +#include +#include +#include + +#include +#include +#include + +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& line, const std::vector& 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{}, 10.0).empty()); + + std::vector 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 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 line = decode(kLine); + ASSERT_EQ(line.size(), 95U); + + // Expected sizes match the upstream test suite. + const std::pair 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 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 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); +} diff --git a/tests/tests.cpp b/tests/tests.cpp index 5cef75f..ab53de2 100644 --- a/tests/tests.cpp +++ b/tests/tests.cpp @@ -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"