diff --git a/.github/workflows/benchmarks-smoke.yml b/.github/workflows/benchmarks-smoke.yml
new file mode 100644
index 0000000..22470ef
--- /dev/null
+++ b/.github/workflows/benchmarks-smoke.yml
@@ -0,0 +1,44 @@
+# Smoke-build the dependency-free portion of the benchmarks. Triggered only
+# on PRs/pushes that actually touch benchmark sources, the root CMake, or
+# this workflow file — so doc-only PRs don't pay the CI cost. The benchmarks
+# themselves aren't run here (that would require S2 / Boost / GeographicLib
+# installs), only `bench_geo_utils` and `bench_naive` which have no external
+# deps. The intent is to catch silent breakage in `benchmarks/CMakeLists.txt`
+# or the shared `random_data.hpp` / `constants.hpp` headers.
+
+name: Benchmarks smoke-build
+
+on:
+ push:
+ branches: [master, main]
+ paths:
+ - 'benchmarks/**'
+ - 'CMakeLists.txt'
+ - '.github/workflows/benchmarks-smoke.yml'
+ pull_request:
+ branches: [master, main]
+ paths:
+ - 'benchmarks/**'
+ - 'CMakeLists.txt'
+ - '.github/workflows/benchmarks-smoke.yml'
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ smoke:
+ name: smoke-build (ubuntu)
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Configure
+ run: cmake -S . -B build-bench -DGEO_UTILS_CPP_BUILD_BENCHMARKS=ON -DGEO_UTILS_CPP_BUILD_TESTS=OFF -DGEO_UTILS_CPP_BUILD_EXAMPLES=OFF -DCMAKE_BUILD_TYPE=Release
+
+ - name: Build (dependency-free benches only)
+ run: cmake --build build-bench --config Release --target bench_geo_utils bench_naive
diff --git a/.gitignore b/.gitignore
index ce379f0..7adb8bb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,5 @@
build/
+build-*/
# Compiled Object files
*.slo
diff --git a/CMakeLists.txt b/CMakeLists.txt
index a55b6d2..c1e7d29 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -8,8 +8,9 @@ else()
endif()
include(CMakeDependentOption)
-option(GEO_UTILS_CPP_BUILD_TESTS "Build geo-utils-cpp tests" ${_geo_utils_cpp_is_top_level})
-option(GEO_UTILS_CPP_BUILD_EXAMPLES "Build geo-utils-cpp examples" ${_geo_utils_cpp_is_top_level})
+option(GEO_UTILS_CPP_BUILD_TESTS "Build geo-utils-cpp tests" ${_geo_utils_cpp_is_top_level})
+option(GEO_UTILS_CPP_BUILD_EXAMPLES "Build geo-utils-cpp examples" ${_geo_utils_cpp_is_top_level})
+option(GEO_UTILS_CPP_BUILD_BENCHMARKS "Build geo-utils-cpp benchmarks" OFF)
cmake_dependent_option(GEO_UTILS_CPP_ENABLE_COVERAGE
"Enable gcov coverage instrumentation (GCC/Clang only)" OFF
"GEO_UTILS_CPP_BUILD_TESTS" OFF)
@@ -98,6 +99,11 @@ if(GEO_UTILS_CPP_BUILD_EXAMPLES)
add_subdirectory(examples)
endif()
+# Benchmarks (opt-in: pulls Google Benchmark and optionally S2/Boost/GeographicLib)
+if(GEO_UTILS_CPP_BUILD_BENCHMARKS)
+ add_subdirectory(benchmarks)
+endif()
+
# Installation
include(GNUInstallDirs)
diff --git a/README.md b/README.md
index cd122d0..d73e6f6 100644
--- a/README.md
+++ b/README.md
@@ -38,33 +38,31 @@
-Header-only C++17 library for geographic (lat/lng) geometry (no dependencies).
+Practical latitude/longitude geometry for C++17 projects that need GPS math,
+not a full geometry framework.
-Provides utilities for distance, bearing, polygon area, point-in-polygon, and
-path proximity checks on Earth coordinates.
+Distance, heading, polygon area, point-in-polygon, and path proximity checks —
+header-only, no dependencies, no build step.
-API inspired by Google Maps geometry utilities.
-Uses spherical Earth approximation (like Google Maps).
+The API is inspired by Google Maps geometry utilities and uses the same spherical
+Earth approximation model.
## Features
-* **`geo::` spherical functions** — distance, bearing, area, interpolation
-* **`geo::` polygon functions** — point-in-polygon, path proximity, distance to segments
-
-## Why use this library?
-
-- Lightweight and header-only (no dependencies)
-- Simple API for common GPS/lat-lng calculations
-- Suitable for backend, GIS, navigation and tracking systems
-
-## When not to use
-
-- If you need high-precision geodesic calculations on an ellipsoid
-- If you need advanced spatial indexing (use S2 / CGAL instead)
+- **Lat/lng-native API** — pass latitude/longitude coordinates directly, no
+ framework-specific point types to convert through.
+- **Header-only, dependency-free** — about 36 KB across 4 headers; nothing
+ to build or link.
+- **Spherical math** — distance, heading, offset, interpolation, area.
+- **Polygon utilities** — point-in-polygon and path proximity checks.
+- **Fast** — matches hand-written haversine on `distance`; especially strong
+ on polygon `area` (see [benchmarks](docs/benchmarks.md)).
+- **Focused scope** — intentionally small API for GPS, navigation, tracking,
+ backend, and GIS workflows.
## Installation
-### FetchContent (recommended)
+### FetchContent
```cmake
include(FetchContent)
@@ -85,13 +83,6 @@ target_link_libraries(your_target PRIVATE geo::utils)
vcpkg install geo-utils-cpp
```
-Then in your `CMakeLists.txt`:
-
-```cmake
-find_package(GeoUtilsCpp 1.0.1 REQUIRED)
-target_link_libraries(your_target PRIVATE geo::utils)
-```
-
### xrepo
```sh
@@ -107,21 +98,35 @@ target("your_target")
add_packages("geo-utils-cpp")
```
-### find_package
+### Conan
-```cmake
-find_package(GeoUtilsCpp 1.0.1 REQUIRED)
-target_link_libraries(your_target PRIVATE geo::utils)
+```sh
+conan install --requires=geo-utils-cpp/1.0.1 --build=missing
```
+Conan Center support is pending
+[conan-io/conan-center-index#30152](https://github.com/conan-io/conan-center-index/pull/30152)
+
### Manual
Copy the `include/` directory into your project and add it to your include path.
-For more details see [docs/getting-started.md](docs/getting-started.md).
+### Using it from CMake
+
+With any of the above methods (vcpkg, xrepo, Conan, FetchContent, or a
+system `find_package`), wire it into your build with:
+
+```cmake
+find_package(GeoUtilsCpp 1.0.1 REQUIRED)
+target_link_libraries(your_target PRIVATE geo::utils)
+```
+
+For more details, see [docs/getting-started.md](docs/getting-started.md).
## Usage
+Distance and heading between two points:
+
```cpp
#include
@@ -139,6 +144,75 @@ int main() {
}
```
+Polygon area, point-in-polygon, path length, and path proximity:
+
+```cpp
+#include
+#include
+
+#include
+
+int main() {
+ // A small box around midtown Manhattan (vertices in CCW order).
+ std::vector midtown = {
+ {40.74, -74.01}, {40.74, -73.96}, {40.78, -73.96}, {40.78, -74.01},
+ };
+ geo::LatLng timesSquare{40.7580, -73.9855};
+
+ std::cout << "Times Square inside: "
+ << (geo::contains(timesSquare, midtown) ? "true" : "false") << "\n";
+ std::cout << "Polygon area: "
+ << geo::area(midtown) / 1e6 << " km^2\n";
+
+ // A short polyline along Broadway, and a point near it.
+ std::vector route = {
+ {40.7580, -73.9855}, // Times Square
+ {40.7680, -73.9818}, // Columbus Circle
+ {40.7780, -73.9740}, // Lincoln Center
+ };
+ geo::LatLng nearby{40.7670, -73.9820};
+
+ std::cout << "Route length: "
+ << geo::path_length(route) / 1000.0 << " km\n";
+ std::cout << "Point within 200 m of route: "
+ << (geo::on_path(nearby, route, /*geodesic=*/true, /*tolerance=*/200.0)
+ ? "true" : "false")
+ << "\n";
+}
+```
+
+## Benchmarks
+
+`geo-utils-cpp` is header-only with no runtime dependencies. Throughput on
+Apple M1 / clang 17 / `-O2 -DNDEBUG` (higher is better):
+
+| Library | `distance_between` (M pairs/s) | `area` (poly N=100, M polys/s) |
+| -------------------- | -----------------------------: | -----------------------------: |
+| **geo-utils-cpp** | **40.5** | **67.2** |
+| naive haversine | 38.3 | — |
+| S2 Geometry | 82.9 | 14.0 |
+| Boost.Geometry | 39.8 | 36.2 |
+| GeographicLib | 1.2 | 2.0 |
+
+Native types are pre-built outside the timed loop, so the table compares
+algorithmic cost rather than object-construction overhead. `geo-utils-cpp`
+matches hand-written haversine and Boost.Geometry on simple spherical operations,
+is especially strong on `area`, while S2 is faster on several operations when
+conversion from lat/lng is excluded.
+
+See [docs/benchmarks.md](docs/benchmarks.md) for full methodology, all
+operations, and when to use each library.
+
+## When not to use
+
+- If you need high-precision ellipsoidal geodesics or sub-meter accuracy, use
+ GeographicLib.
+- If polygon containment is your main hot path, especially for larger polygons,
+ consider S2 Geometry.
+- If you need many geometry types, coordinate systems, or generic geometry
+ algorithms, Boost.Geometry may be a better fit.
+- If you need spatial indexing, use S2, CGAL, or another dedicated spatial index.
+
## API Reference
See [docs/api.md](docs/api.md) for the full API reference.
diff --git a/benchmarks/CMakeLists.txt b/benchmarks/CMakeLists.txt
new file mode 100644
index 0000000..515e7b3
--- /dev/null
+++ b/benchmarks/CMakeLists.txt
@@ -0,0 +1,118 @@
+# Benchmarks for geo-utils-cpp.
+#
+# Build with:
+# cmake -B build -DGEO_UTILS_CPP_BUILD_BENCHMARKS=ON
+# cmake --build build --target bench_all
+#
+# Google Benchmark is fetched automatically. S2, Boost.Geometry, and
+# GeographicLib are looked up via find_package — missing competitors are
+# skipped with a status message instead of causing a hard error.
+
+include(FetchContent)
+
+# --- Google Benchmark -------------------------------------------------------
+
+set(BENCHMARK_ENABLE_TESTING OFF CACHE BOOL "" FORCE)
+set(BENCHMARK_ENABLE_INSTALL OFF CACHE BOOL "" FORCE)
+set(BENCHMARK_INSTALL_DOCS OFF CACHE BOOL "" FORCE)
+set(BENCHMARK_ENABLE_GTEST_TESTS OFF CACHE BOOL "" FORCE)
+
+FetchContent_Declare(
+ google_benchmark
+ URL https://github.com/google/benchmark/archive/v1.8.4.tar.gz
+ URL_HASH SHA256=3e7059b6b11fb1bbe28e33e02519398ca94c1818874ebed18e504dc6f709be45
+ DOWNLOAD_EXTRACT_TIMESTAMP ON
+)
+FetchContent_MakeAvailable(google_benchmark)
+
+# --- Shared infrastructure --------------------------------------------------
+
+add_library(geo_utils_cpp_bench_common INTERFACE)
+target_include_directories(geo_utils_cpp_bench_common INTERFACE
+ ${CMAKE_CURRENT_SOURCE_DIR}/common
+)
+target_compile_features(geo_utils_cpp_bench_common INTERFACE cxx_std_17)
+
+# Aggregator target: depend on every bench that ends up being built.
+add_custom_target(bench_all)
+
+function(_geo_utils_cpp_add_bench name source)
+ add_executable(bench_${name} ${source})
+ target_link_libraries(bench_${name} PRIVATE
+ geo::utils
+ geo_utils_cpp_bench_common
+ benchmark::benchmark_main
+ ${ARGN}
+ )
+ add_dependencies(bench_all bench_${name})
+endfunction()
+
+# --- geo-utils-cpp itself + naive haversine baseline ------------------------
+
+_geo_utils_cpp_add_bench(geo_utils speed/bench_geo_utils.cpp)
+_geo_utils_cpp_add_bench(naive speed/bench_naive.cpp)
+
+# --- Optional: S2 Geometry --------------------------------------------------
+#
+# vcpkg port name: `s2geometry`. Homebrew formula: `s2geometry`. The CMake
+# package is conventionally exported as `s2`, but case-sensitive filesystems
+# may also see `S2Config.cmake` from some installs — accept either.
+find_package(s2 NAMES s2 S2 QUIET)
+if(s2_FOUND)
+ _geo_utils_cpp_add_bench(s2 speed/bench_s2.cpp s2::s2)
+ message(STATUS "geo-utils-cpp benchmarks: S2 found — bench_s2 enabled.")
+else()
+ message(STATUS "geo-utils-cpp benchmarks: S2 not found — bench_s2 skipped. "
+ "Install via 'vcpkg install s2geometry' or 'brew install s2geometry'.")
+endif()
+
+# --- Optional: Boost.Geometry -----------------------------------------------
+#
+# Boost.Geometry is header-only. CMake 3.30 deprecates the FindBoost module in
+# favour of Boost's exported BoostConfig.cmake; we prefer CONFIG and fall back
+# to the legacy module so this works across CMake 3.14+.
+if(POLICY CMP0167)
+ cmake_policy(SET CMP0167 NEW)
+endif()
+
+find_package(Boost 1.71 QUIET CONFIG)
+if(NOT Boost_FOUND)
+ find_package(Boost 1.71 QUIET)
+endif()
+
+if(Boost_FOUND)
+ # Different Boost versions expose the header set as Boost::headers (>=1.71),
+ # Boost::boost (older), or only via Boost_INCLUDE_DIRS.
+ if(TARGET Boost::headers)
+ _geo_utils_cpp_add_bench(boost speed/bench_boost.cpp Boost::headers)
+ elseif(TARGET Boost::boost)
+ _geo_utils_cpp_add_bench(boost speed/bench_boost.cpp Boost::boost)
+ else()
+ _geo_utils_cpp_add_bench(boost speed/bench_boost.cpp)
+ target_include_directories(bench_boost PRIVATE ${Boost_INCLUDE_DIRS})
+ endif()
+ message(STATUS "geo-utils-cpp benchmarks: Boost.Geometry found — bench_boost enabled.")
+else()
+ message(STATUS "geo-utils-cpp benchmarks: Boost not found — bench_boost skipped. "
+ "Install via 'vcpkg install boost-geometry' or 'brew install boost'.")
+endif()
+
+# --- Optional: GeographicLib ------------------------------------------------
+#
+# Recent GeographicLib versions export the target `GeographicLib::GeographicLib`.
+# Older versions exposed `${GeographicLib_LIBRARIES}` instead — we accept both.
+find_package(GeographicLib QUIET)
+if(GeographicLib_FOUND)
+ if(TARGET GeographicLib::GeographicLib)
+ _geo_utils_cpp_add_bench(geographiclib speed/bench_geographiclib.cpp
+ GeographicLib::GeographicLib)
+ else()
+ _geo_utils_cpp_add_bench(geographiclib speed/bench_geographiclib.cpp
+ ${GeographicLib_LIBRARIES})
+ target_include_directories(bench_geographiclib PRIVATE ${GeographicLib_INCLUDE_DIRS})
+ endif()
+ message(STATUS "geo-utils-cpp benchmarks: GeographicLib found — bench_geographiclib enabled.")
+else()
+ message(STATUS "geo-utils-cpp benchmarks: GeographicLib not found — bench_geographiclib skipped. "
+ "Install via 'vcpkg install geographiclib' or 'brew install geographiclib'.")
+endif()
diff --git a/benchmarks/README.md b/benchmarks/README.md
new file mode 100644
index 0000000..3141474
--- /dev/null
+++ b/benchmarks/README.md
@@ -0,0 +1,82 @@
+# Benchmarks
+
+This directory measures `geo-utils-cpp` against three popular C++ geometry
+libraries on two axes:
+
+1. **Speed** — Google Benchmark micro-benchmarks for `distance`, `heading`,
+ `contains`, `area`, and `path_length`.
+2. **Disk footprint** — stripped binary size of a minimal consumer plus the
+ on-disk install size of each library.
+
+The benchmarks are **not built by default** and **not run in CI**. Set
+`-DGEO_UTILS_CPP_BUILD_BENCHMARKS=ON` to opt in.
+
+Results, methodology, and per-library trade-offs live in
+[`docs/benchmarks.md`](../docs/benchmarks.md). This file covers only the
+mechanics: installing competitors, building, and running.
+
+## Installing competitors
+
+The CMake build looks each library up via `find_package` and *skips* the
+benchmark binary for any competitor it can't find — so you only need to
+install the libraries you want to compare against.
+
+### Homebrew (macOS / Linux)
+
+```sh
+brew install s2geometry boost geographiclib
+```
+
+### vcpkg
+
+```sh
+vcpkg install s2geometry boost-geometry geographiclib
+```
+
+(Then point CMake at the vcpkg toolchain file with
+`-DCMAKE_TOOLCHAIN_FILE=$VCPKG_ROOT/scripts/buildsystems/vcpkg.cmake`.)
+
+### apt (Debian/Ubuntu)
+
+S2 is not in apt; install from source or vcpkg. The other two:
+
+```sh
+sudo apt install libboost-dev libgeographiclib-dev
+```
+
+## Building and running speed benchmarks
+
+```sh
+cmake -S . -B build-bench \
+ -DGEO_UTILS_CPP_BUILD_BENCHMARKS=ON \
+ -DCMAKE_BUILD_TYPE=Release
+cmake --build build-bench --target bench_all -j
+
+# Run any one binary:
+./build-bench/benchmarks/bench_geo_utils
+./build-bench/benchmarks/bench_naive
+./build-bench/benchmarks/bench_s2 # if S2 was found
+./build-bench/benchmarks/bench_boost # if Boost was found
+./build-bench/benchmarks/bench_geographiclib # if GeographicLib was found
+```
+
+Each binary supports the standard Google Benchmark flags. To produce a single
+combined JSON report:
+
+```sh
+for b in build-bench/benchmarks/bench_*; do
+ "$b" --benchmark_format=json --benchmark_out="$(basename $b).json"
+done
+```
+
+## Measuring disk footprint
+
+```sh
+./benchmarks/size/measure.sh
+```
+
+This builds a minimal "distance + point-in-polygon" consumer against every
+library it can find, strips the resulting binary, and reports both the
+binary size and the on-disk install size of each library. Override compiler
+or flags via `CXX=` and `CXXFLAGS=`; pass `STATIC=1` to build statically
+linked binaries instead.
diff --git a/benchmarks/common/constants.hpp b/benchmarks/common/constants.hpp
new file mode 100644
index 0000000..4d5c1be
--- /dev/null
+++ b/benchmarks/common/constants.hpp
@@ -0,0 +1,34 @@
+// Copyright 2026 Aleksandr Kovalko
+// Licensed under the Apache License, Version 2.0
+//
+// Shared compile-time constants used by every benchmark binary. Centralized
+// so a single change here updates all five competitor benchmarks and the
+// random-data generator.
+
+#pragma once
+
+#include
+
+namespace geo::bench {
+
+inline constexpr double kPi = 3.14159265358979323846;
+
+// Earth radius in meters. Matches `geo-utils-cpp`'s internal constant; we
+// reuse the same value for Boost.Geometry's haversine strategy and the naive
+// baseline so every sphere-based library normalizes to identical units.
+// S2 uses `S2Earth::RadiusMeters()` (6371010.0); that ~1 m difference
+// affects absolute results but not throughput.
+inline constexpr double kEarthRadiusMeters = 6371009.0;
+
+// Test polygon: regular n-gon centered over NYC, 5° radius. Stays well
+// inside ±80° latitude (where ellipsoidal Inverse is numerically safe) and
+// is large enough that 2°-jitter queries hit both inside/outside branches
+// of contains().
+inline constexpr double kPolyCenterLat = 40.0;
+inline constexpr double kPolyCenterLng = -74.0;
+inline constexpr double kPolyRadiusDeg = 5.0;
+
+// Query points per `contains` benchmark iteration.
+inline constexpr std::size_t kQueriesPerIteration = 1000;
+
+} // namespace geo::bench
diff --git a/benchmarks/common/random_data.hpp b/benchmarks/common/random_data.hpp
new file mode 100644
index 0000000..2be1ee3
--- /dev/null
+++ b/benchmarks/common/random_data.hpp
@@ -0,0 +1,87 @@
+// Copyright 2026 Aleksandr Kovalko
+// Licensed under the Apache License, Version 2.0
+//
+// Deterministic random data shared by every benchmark binary, so that all
+// libraries compete on identical inputs.
+
+#pragma once
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include
+
+#include "constants.hpp"
+
+namespace geo::bench {
+
+inline constexpr std::uint64_t kSeed = 0xC0FFEEull;
+
+// Returns `n` random points uniformly distributed over the globe. Latitude is
+// clamped to [-80, 80] to keep all libraries on safe ground (S2 is fine near
+// the poles, but ellipsoidal Inverse can be numerically iffy for nearly
+// antipodal pairs).
+inline std::vector random_points(std::size_t n, std::uint64_t seed = kSeed) {
+ std::mt19937_64 rng(seed);
+ std::uniform_real_distribution lat(-80.0, 80.0);
+ std::uniform_real_distribution lng(-180.0, 180.0);
+ std::vector out;
+ out.reserve(n);
+ for (std::size_t i = 0; i < n; ++i) {
+ out.emplace_back(lat(rng), lng(rng));
+ }
+ return out;
+}
+
+// Returns a regular n-gon centered at (clat, clng) with the given radius
+// (degrees). Vertices are emitted counter-clockwise when viewed with
+// latitude on the y-axis and longitude on the x-axis (the standard map
+// orientation, looking down from above), so the first vertex is due
+// north of the center and successive vertices march west — that's
+// positive signed area in our convention.
+inline std::vector regular_polygon(std::size_t n, double clat, double clng, double radius_deg) {
+ std::vector out;
+ out.reserve(n);
+ for (std::size_t i = 0; i < n; ++i) {
+ double a = 2.0 * kPi * static_cast(i) / static_cast(n);
+ out.emplace_back(clat + radius_deg * std::cos(a),
+ clng - radius_deg * std::sin(a));
+ }
+ return out;
+}
+
+// Query points clustered around the polygon center. Roughly half land
+// inside, half outside — exercises both branches of contains().
+inline std::vector queries_around(double clat, double clng, double radius_deg,
+ std::size_t n, std::uint64_t seed = kSeed + 1) {
+ std::mt19937_64 rng(seed);
+ std::uniform_real_distribution jitter(-2.0, 2.0);
+ std::vector out;
+ out.reserve(n);
+ for (std::size_t i = 0; i < n; ++i) {
+ // Clamp to valid lat/lng so callers picking large radii can't produce
+ // out-of-domain inputs that some libraries silently NaN on.
+ const double lat = std::clamp(clat + radius_deg * jitter(rng), -90.0, 90.0);
+ const double lng = std::clamp(clng + radius_deg * jitter(rng), -180.0, 180.0);
+ out.emplace_back(lat, lng);
+ }
+ return out;
+}
+
+// Sugar over `regular_polygon` and `queries_around` using the shared test
+// polygon constants from constants.hpp. Use these in benchmarks instead of
+// hard-coding (40.0, -74.0, 5.0) per-file.
+inline std::vector bench_polygon(std::size_t n) {
+ return regular_polygon(n, kPolyCenterLat, kPolyCenterLng, kPolyRadiusDeg);
+}
+
+inline std::vector bench_queries() {
+ return queries_around(kPolyCenterLat, kPolyCenterLng, kPolyRadiusDeg,
+ kQueriesPerIteration);
+}
+
+} // namespace geo::bench
diff --git a/benchmarks/size/consumer_boost.cpp b/benchmarks/size/consumer_boost.cpp
new file mode 100644
index 0000000..083c950
--- /dev/null
+++ b/benchmarks/size/consumer_boost.cpp
@@ -0,0 +1,32 @@
+// Minimal consumer: distance + point-in-polygon, Boost.Geometry.
+
+#include
+#include
+
+#include
+#include
+#include
+#include
+
+namespace bg = boost::geometry;
+using Pt = bg::model::point>;
+using Poly = bg::model::polygon;
+
+int main(int argc, char** argv) {
+ if (argc < 5) return 1;
+ char* end;
+ Pt a(std::strtod(argv[2], &end), std::strtod(argv[1], &end)); // (lng, lat)
+ Pt b(std::strtod(argv[4], &end), std::strtod(argv[3], &end));
+ Poly poly;
+ bg::append(poly.outer(), Pt{-74.1, 40.7});
+ bg::append(poly.outer(), Pt{-74.1, 40.8});
+ bg::append(poly.outer(), Pt{-74.0, 40.8});
+ bg::append(poly.outer(), Pt{-74.0, 40.7});
+ bg::append(poly.outer(), Pt{-74.1, 40.7});
+ bg::correct(poly);
+
+ bg::strategy::distance::haversine hav(6371009.0);
+ const double d = bg::distance(a, b, hav);
+ std::printf("%.1f %d\n", d, static_cast(bg::within(a, poly)));
+ return 0;
+}
diff --git a/benchmarks/size/consumer_geo_utils.cpp b/benchmarks/size/consumer_geo_utils.cpp
new file mode 100644
index 0000000..1e8684c
--- /dev/null
+++ b/benchmarks/size/consumer_geo_utils.cpp
@@ -0,0 +1,23 @@
+// Minimal consumer: distance + point-in-polygon, geo-utils-cpp.
+// Reads four doubles (lat1 lng1 lat2 lng2) from argv so the optimizer can't
+// pre-compute the answer, prints " ".
+
+#include
+#include
+#include
+
+#include
+#include
+
+int main(int argc, char** argv) {
+ if (argc < 5) return 1;
+ char* end;
+ geo::LatLng a(std::strtod(argv[1], &end), std::strtod(argv[2], &end));
+ geo::LatLng b(std::strtod(argv[3], &end), std::strtod(argv[4], &end));
+ std::vector poly = {
+ {40.7, -74.1}, {40.8, -74.1}, {40.8, -74.0}, {40.7, -74.0},
+ };
+ std::printf("%.1f %d\n", geo::distance_between(a, b),
+ static_cast(geo::contains(a, poly)));
+ return 0;
+}
diff --git a/benchmarks/size/consumer_geographiclib.cpp b/benchmarks/size/consumer_geographiclib.cpp
new file mode 100644
index 0000000..4c5fd47
--- /dev/null
+++ b/benchmarks/size/consumer_geographiclib.cpp
@@ -0,0 +1,22 @@
+// Minimal consumer: distance only, GeographicLib.
+// GeographicLib does not provide a native point-in-polygon predicate, so we
+// emit "-1" in that column to make the omission explicit (rather than
+// inflating its size by adding a hand-rolled PIP that no caller would
+// actually depend on).
+
+#include
+#include
+
+#include
+
+int main(int argc, char** argv) {
+ if (argc < 5) return 1;
+ double s12 = 0.0;
+ char* end;
+ GeographicLib::Geodesic::WGS84().Inverse(
+ std::strtod(argv[1], &end), std::strtod(argv[2], &end),
+ std::strtod(argv[3], &end), std::strtod(argv[4], &end),
+ s12);
+ std::printf("%.1f -1\n", s12);
+ return 0;
+}
diff --git a/benchmarks/size/consumer_naive.cpp b/benchmarks/size/consumer_naive.cpp
new file mode 100644
index 0000000..9e0072b
--- /dev/null
+++ b/benchmarks/size/consumer_naive.cpp
@@ -0,0 +1,60 @@
+// Minimal consumer: distance + point-in-polygon, hand-written, no library.
+// Establishes a "no-dependency floor" against which every library's overhead
+// can be measured.
+
+#include
+#include
+#include
+#include
+#include
+
+namespace {
+
+struct LL {
+ double lat;
+ double lng;
+};
+
+double distance(LL a, LL b) {
+ constexpr double kR = 6371009.0;
+ constexpr double kPi = 3.14159265358979323846;
+ const double lat1 = a.lat * kPi / 180.0;
+ const double lat2 = b.lat * kPi / 180.0;
+ const double dlat = (b.lat - a.lat) * kPi / 180.0;
+ const double dlng = (b.lng - a.lng) * kPi / 180.0;
+ const double s1 = std::sin(dlat * 0.5);
+ const double s2 = std::sin(dlng * 0.5);
+ const double h = s1 * s1 + std::cos(lat1) * std::cos(lat2) * s2 * s2;
+ return 2.0 * kR * std::asin(std::sqrt(h));
+}
+
+// Planar ray-cast — fine for a tiny bbox at ~40°N. NOT correct in general
+// for spherical polygons, but representative of "the simplest thing a
+// developer would write before reaching for a library".
+bool contains(LL p, const std::vector& poly) {
+ bool inside = false;
+ const std::size_t n = poly.size();
+ for (std::size_t i = 0, j = n - 1; i < n; j = i++) {
+ if ((poly[i].lng > p.lng) != (poly[j].lng > p.lng) &&
+ p.lat < (poly[j].lat - poly[i].lat) * (p.lng - poly[i].lng) /
+ (poly[j].lng - poly[i].lng) +
+ poly[i].lat) {
+ inside = !inside;
+ }
+ }
+ return inside;
+}
+
+} // namespace
+
+int main(int argc, char** argv) {
+ if (argc < 5) return 1;
+ char* end;
+ LL a{std::strtod(argv[1], &end), std::strtod(argv[2], &end)};
+ LL b{std::strtod(argv[3], &end), std::strtod(argv[4], &end)};
+ std::vector poly = {
+ {40.7, -74.1}, {40.8, -74.1}, {40.8, -74.0}, {40.7, -74.0},
+ };
+ std::printf("%.1f %d\n", distance(a, b), static_cast(contains(a, poly)));
+ return 0;
+}
diff --git a/benchmarks/size/consumer_s2.cpp b/benchmarks/size/consumer_s2.cpp
new file mode 100644
index 0000000..6f55c82
--- /dev/null
+++ b/benchmarks/size/consumer_s2.cpp
@@ -0,0 +1,30 @@
+// Minimal consumer: distance + point-in-polygon, S2 Geometry.
+
+#include
+#include
+#include
+#include
+
+#include
+#include
+#include
+#include
+#include
+
+int main(int argc, char** argv) {
+ if (argc < 5) return 1;
+ char* end;
+ const auto a = S2LatLng::FromDegrees(std::strtod(argv[1], &end), std::strtod(argv[2], &end)).ToPoint();
+ const auto b = S2LatLng::FromDegrees(std::strtod(argv[3], &end), std::strtod(argv[4], &end)).ToPoint();
+ std::vector verts = {
+ S2LatLng::FromDegrees(40.7, -74.1).ToPoint(),
+ S2LatLng::FromDegrees(40.8, -74.1).ToPoint(),
+ S2LatLng::FromDegrees(40.8, -74.0).ToPoint(),
+ S2LatLng::FromDegrees(40.7, -74.0).ToPoint(),
+ };
+ auto loop = std::make_unique(verts);
+ loop->Normalize();
+ const double d = S2Earth::ToMeters(S1Angle(a, b));
+ std::printf("%.1f %d\n", d, static_cast(loop->Contains(a)));
+ return 0;
+}
diff --git a/benchmarks/size/measure.sh b/benchmarks/size/measure.sh
new file mode 100755
index 0000000..51f69a4
--- /dev/null
+++ b/benchmarks/size/measure.sh
@@ -0,0 +1,170 @@
+#!/usr/bin/env bash
+# Builds a minimal consumer (distance + point-in-polygon) against each library
+# we benchmark, then reports the stripped binary size. Also reports the
+# install footprint of the library itself (Homebrew prefix size, when
+# available) so the reader can see the *deployment* cost, not just the link
+# cost.
+#
+# Override:
+# CXX=clang++ ./measure.sh
+# CXXFLAGS="-std=c++17 -O3 -DNDEBUG" ./measure.sh
+# STATIC=1 ./measure.sh # add -static (best-effort; not all toolchains)
+
+set -euo pipefail
+
+CXX="${CXX:-c++}"
+CXXFLAGS="${CXXFLAGS:--std=c++17 -O2 -DNDEBUG}"
+EXTRA_LD=""
+if [ "${STATIC:-0}" = "1" ]; then
+ EXTRA_LD="-static"
+fi
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+ROOT_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)"
+TMP="$(mktemp -d)"
+LOG_DIR="${ROOT_DIR}/build-bench/size-logs"
+# build() runs inside command substitution, so plain shell variables can't
+# propagate failure state back to the parent. Use a sentinel file instead.
+FAILURE_MARKER="${TMP}/.any_failure"
+trap 'rm -rf "${TMP}"' EXIT
+
+is_macos() { [ "$(uname)" = "Darwin" ]; }
+
+file_size() {
+ if is_macos; then stat -f '%z' "$1"; else stat -c '%s' "$1"; fi
+}
+
+human() {
+ awk -v b="$1" 'BEGIN {
+ split("B KB MB GB", u);
+ i = 1;
+ while (b >= 1024 && i < 4) { b /= 1024; i++ }
+ printf "%.1f %s", b, u[i]
+ }'
+}
+
+dir_size() {
+ if [ -z "$1" ] || [ ! -e "$1" ]; then echo 0; return; fi
+ # -L follows symlinks (Homebrew's --prefix is a symlink into Cellar/).
+ if is_macos; then
+ du -skL "$1" 2>/dev/null | awk '{print $1 * 1024}'
+ else
+ du -sbL "$1" 2>/dev/null | awk '{print $1}'
+ fi
+}
+
+row() {
+ # name, binary_size_bytes ("" if skipped), install_size_bytes ("" if N/A), notes
+ local name="$1" bin="$2" inst="$3" notes="$4"
+ local bin_str="—" inst_str="—"
+ [ -n "${bin}" ] && bin_str=$(human "${bin}")
+ [ -n "${inst}" ] && inst_str=$(human "${inst}")
+ printf " %-22s %14s %14s %s\n" "${name}" "${bin_str}" "${inst_str}" "${notes}"
+}
+
+build() {
+ # name, source, extra_flags...
+ local name="$1"; shift
+ local source="$1"; shift
+ local safe_name="${name// /_}"
+ local out="${TMP}/${safe_name}"
+ local log="${TMP}/${safe_name}.log"
+ if "${CXX}" ${CXXFLAGS} ${EXTRA_LD} "${source}" -o "${out}" "$@" 2>"${log}"; then
+ strip "${out}" 2>/dev/null || true
+ file_size "${out}"
+ else
+ # Persist the failure log so the user can diagnose after the trap
+ # cleans up TMP.
+ mkdir -p "${LOG_DIR}"
+ cp "${log}" "${LOG_DIR}/${safe_name}.log"
+ : > "${FAILURE_MARKER}"
+ echo "" # signals "skipped"
+ fi
+}
+
+brew_prefix() {
+ command -v brew >/dev/null 2>&1 && brew --prefix "$1" 2>/dev/null || true
+}
+
+echo "Stripped binary + install-footprint comparison"
+echo " CXX=${CXX}, CXXFLAGS=${CXXFLAGS}${EXTRA_LD:+ ${EXTRA_LD}}"
+echo
+printf " %-22s %14s %14s %s\n" "Library" "Binary" "Installed" "Notes"
+printf " %-22s %14s %14s %s\n" "-------" "------" "---------" "-----"
+
+# --- geo-utils-cpp (header-only, in-tree) ----------------------------------
+GU_BIN=$(build "geo-utils-cpp" "${SCRIPT_DIR}/consumer_geo_utils.cpp" \
+ -I "${ROOT_DIR}/include")
+GU_INST=$(dir_size "${ROOT_DIR}/include")
+row "geo-utils-cpp" "${GU_BIN}" "${GU_INST}" "header-only, zero deps"
+
+# --- naive (no library) ----------------------------------------------------
+NV_BIN=$(build "naive" "${SCRIPT_DIR}/consumer_naive.cpp")
+row "naive (no library)" "${NV_BIN}" "0" "hand-written haversine + planar PIP"
+
+# --- S2 Geometry -----------------------------------------------------------
+S2_FLAGS=""
+S2_PFX="$(brew_prefix s2geometry)"
+if [ -n "${S2_PFX}" ] && [ -d "${S2_PFX}" ]; then
+ S2_FLAGS="-I${S2_PFX}/include -L${S2_PFX}/lib -ls2"
+ # absl is a runtime peer; common labels:
+ ABSL_PFX="$(brew_prefix abseil)"
+ [ -n "${ABSL_PFX}" ] && S2_FLAGS="${S2_FLAGS} -I${ABSL_PFX}/include -L${ABSL_PFX}/lib"
+elif command -v pkg-config >/dev/null && pkg-config --exists s2 2>/dev/null; then
+ S2_FLAGS="$(pkg-config --cflags --libs s2)"
+fi
+if [ -n "${S2_FLAGS}" ]; then
+ S2_BIN=$(build "S2 Geometry" "${SCRIPT_DIR}/consumer_s2.cpp" ${S2_FLAGS})
+ S2_INST=$(dir_size "${S2_PFX}")
+ ABSL_INST=$(dir_size "${ABSL_PFX:-}")
+ S2_TOTAL=$((S2_INST + ABSL_INST))
+ row "S2 Geometry" "${S2_BIN}" "${S2_TOTAL}" "+ abseil dependency"
+else
+ row "S2 Geometry" "" "" "not installed (brew install s2geometry)"
+fi
+
+# --- Boost.Geometry --------------------------------------------------------
+BOOST_FLAGS=""
+BOOST_PFX="$(brew_prefix boost)"
+if [ -n "${BOOST_PFX}" ] && [ -d "${BOOST_PFX}/include/boost" ]; then
+ BOOST_FLAGS="-I${BOOST_PFX}/include"
+elif [ -d /usr/include/boost ]; then
+ BOOST_FLAGS=""
+ BOOST_PFX="/usr"
+fi
+if [ -n "${BOOST_PFX}" ] && [ -d "${BOOST_PFX}" ]; then
+ B_BIN=$(build "Boost.Geometry" "${SCRIPT_DIR}/consumer_boost.cpp" ${BOOST_FLAGS})
+ # Whole Boost is huge; report only Boost.Geometry headers as a fairer figure.
+ BG_HDRS="${BOOST_PFX}/include/boost/geometry"
+ B_INST=$(dir_size "${BG_HDRS}")
+ row "Boost.Geometry" "${B_BIN}" "${B_INST}" "headers-only subset of Boost"
+else
+ row "Boost.Geometry" "" "" "not installed (brew install boost)"
+fi
+
+# --- GeographicLib ---------------------------------------------------------
+GL_FLAGS=""
+GL_PFX="$(brew_prefix geographiclib)"
+if [ -n "${GL_PFX}" ] && [ -d "${GL_PFX}" ]; then
+ GL_FLAGS="-I${GL_PFX}/include -L${GL_PFX}/lib -lGeographicLib"
+elif command -v pkg-config >/dev/null && pkg-config --exists geographiclib 2>/dev/null; then
+ GL_FLAGS="$(pkg-config --cflags --libs geographiclib)"
+fi
+if [ -n "${GL_FLAGS}" ]; then
+ GL_BIN=$(build "GeographicLib" "${SCRIPT_DIR}/consumer_geographiclib.cpp" ${GL_FLAGS})
+ GL_INST=$(dir_size "${GL_PFX}")
+ row "GeographicLib" "${GL_BIN}" "${GL_INST}" "no native PIP — distance only"
+else
+ row "GeographicLib" "" "" "not installed (brew install geographiclib)"
+fi
+
+echo
+echo "Notes:"
+echo " - 'Binary' is the stripped consumer executable (dynamic linking)."
+echo " - 'Installed' for geo-utils-cpp is the include/ directory (everything"
+echo " you ship). For other libraries it's the package install prefix on"
+echo " Homebrew (or the geometry subset for Boost) — what the user must"
+echo " have on disk to consume the library."
+if [ -f "${FAILURE_MARKER}" ]; then
+ echo " - Some builds failed. Diagnostic logs preserved in: ${LOG_DIR}"
+fi
diff --git a/benchmarks/speed/bench_boost.cpp b/benchmarks/speed/bench_boost.cpp
new file mode 100644
index 0000000..91d7c0f
--- /dev/null
+++ b/benchmarks/speed/bench_boost.cpp
@@ -0,0 +1,148 @@
+// Copyright 2026 Aleksandr Kovalko
+// Licensed under the Apache License, Version 2.0
+//
+// Boost.Geometry equivalents using `cs::spherical_equatorial` —
+// the same sphere-based model we use, so this is a fair head-to-head.
+//
+// Conversion policy (matches bench_s2.cpp): every native point/geometry is
+// pre-built OUTSIDE the timed loop. The timed work is the library's per-call
+// computation, isolated from data-shape conversion overhead — so the numbers
+// reflect algorithmic cost, not "lat/lng-in / lat/lng-out" plumbing.
+//
+// Coordinate convention reminder: Boost.Geometry expects (lng, lat) order
+// for spherical_equatorial points.
+//
+// Strategy note for `contains`: bg::within with cs::spherical_equatorial
+// auto-selects strategy::within::spherical_winding, which traces great-circle
+// edges and classifies crossings carefully. Our own `geo::contains` defaults
+// to rhumb-line edges (geodesic=false), which is significantly cheaper.
+// The performance gap on `contains` reflects this algorithmic difference,
+// not a Boost misconfiguration.
+
+#include
+
+#include
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "random_data.hpp"
+
+namespace bg = boost::geometry;
+
+namespace {
+
+using Point = bg::model::point>;
+using Polygon = bg::model::polygon;
+using LineString = bg::model::linestring;
+
+constexpr double kEarthRadius = geo::bench::kEarthRadiusMeters;
+
+inline Point to_boost_point(const geo::LatLng& p) {
+ return Point(p.lng, p.lat); // (lng, lat) order!
+}
+
+inline Polygon to_boost_polygon(const std::vector& pts) {
+ Polygon poly;
+ auto& outer = poly.outer();
+ outer.reserve(pts.size() + 1);
+ for (const auto& p : pts) outer.emplace_back(p.lng, p.lat);
+ outer.emplace_back(pts.front().lng, pts.front().lat); // close the ring
+ bg::correct(poly); // ensure orientation matches Boost's expectation
+ return poly;
+}
+
+inline LineString to_boost_linestring(const std::vector& pts) {
+ LineString ls;
+ ls.reserve(pts.size());
+ for (const auto& p : pts) ls.emplace_back(p.lng, p.lat);
+ return ls;
+}
+
+} // namespace
+
+// --- distance --------------------------------------------------------------
+
+static void BM_Boost_DistanceBetween(benchmark::State& state) {
+ const auto pts_ll = geo::bench::random_points(2 * static_cast(state.range(0)));
+ std::vector pts;
+ pts.reserve(pts_ll.size());
+ for (const auto& p : pts_ll) pts.push_back(to_boost_point(p));
+ bg::strategy::distance::haversine haversine(kEarthRadius);
+ for (auto _ : state) {
+ for (std::size_t i = 0; i + 1 < pts.size(); i += 2) {
+ benchmark::DoNotOptimize(bg::distance(pts[i], pts[i + 1], haversine));
+ }
+ }
+ state.SetItemsProcessed(state.iterations() * state.range(0));
+}
+BENCHMARK(BM_Boost_DistanceBetween)->Arg(1000)->Arg(100000)->Repetitions(5)->ReportAggregatesOnly(true);
+
+// --- heading (azimuth) -----------------------------------------------------
+
+static void BM_Boost_Heading(benchmark::State& state) {
+ const auto pts_ll = geo::bench::random_points(2 * static_cast(state.range(0)));
+ std::vector pts;
+ pts.reserve(pts_ll.size());
+ for (const auto& p : pts_ll) pts.push_back(to_boost_point(p));
+ for (auto _ : state) {
+ for (std::size_t i = 0; i + 1 < pts.size(); i += 2) {
+ benchmark::DoNotOptimize(bg::azimuth(pts[i], pts[i + 1]));
+ }
+ }
+ state.SetItemsProcessed(state.iterations() * state.range(0));
+}
+BENCHMARK(BM_Boost_Heading)->Arg(1000)->Arg(100000)->Repetitions(5)->ReportAggregatesOnly(true);
+
+// --- contains: polygon and query points pre-converted outside the loop ----
+
+static void BM_Boost_Contains(benchmark::State& state) {
+ const auto poly_ll = geo::bench::bench_polygon(static_cast(state.range(0)));
+ const Polygon poly = to_boost_polygon(poly_ll);
+
+ const auto queries_ll = geo::bench::bench_queries();
+ std::vector queries;
+ queries.reserve(queries_ll.size());
+ for (const auto& q : queries_ll) queries.push_back(to_boost_point(q));
+
+ for (auto _ : state) {
+ for (const auto& q : queries) {
+ benchmark::DoNotOptimize(bg::within(q, poly));
+ }
+ }
+ state.SetItemsProcessed(state.iterations() * static_cast(queries.size()));
+}
+BENCHMARK(BM_Boost_Contains)->Arg(10)->Arg(100)->Arg(1000)->Repetitions(5)->ReportAggregatesOnly(true);
+
+// --- area ------------------------------------------------------------------
+
+static void BM_Boost_Area(benchmark::State& state) {
+ const auto poly_ll = geo::bench::bench_polygon(static_cast(state.range(0)));
+ const Polygon poly = to_boost_polygon(poly_ll);
+ // bg::area on a spherical CS returns area on the unit sphere (steradians);
+ // multiply by R² to get square meters.
+ const double r2 = kEarthRadius * kEarthRadius;
+ for (auto _ : state) {
+ benchmark::DoNotOptimize(bg::area(poly) * r2);
+ }
+ state.SetItemsProcessed(state.iterations() * state.range(0));
+}
+BENCHMARK(BM_Boost_Area)->Arg(10)->Arg(100)->Arg(1000)->Repetitions(5)->ReportAggregatesOnly(true);
+
+// --- path_length: LineString pre-built outside the timed loop ------------
+
+static void BM_Boost_PathLength(benchmark::State& state) {
+ const auto path_ll = geo::bench::random_points(static_cast(state.range(0)));
+ const LineString ls = to_boost_linestring(path_ll);
+ bg::strategy::distance::haversine haversine(kEarthRadius);
+ for (auto _ : state) {
+ benchmark::DoNotOptimize(bg::length(ls, haversine));
+ }
+ state.SetItemsProcessed(state.iterations() * state.range(0));
+}
+BENCHMARK(BM_Boost_PathLength)->Arg(10)->Arg(100)->Arg(1000)->Repetitions(5)->ReportAggregatesOnly(true);
diff --git a/benchmarks/speed/bench_geo_utils.cpp b/benchmarks/speed/bench_geo_utils.cpp
new file mode 100644
index 0000000..f830384
--- /dev/null
+++ b/benchmarks/speed/bench_geo_utils.cpp
@@ -0,0 +1,74 @@
+// Copyright 2026 Aleksandr Kovalko
+// Licensed under the Apache License, Version 2.0
+
+#include
+
+#include
+#include
+
+#include
+#include
+
+#include "random_data.hpp"
+
+// --- distance_between -------------------------------------------------------
+
+static void BM_GeoUtils_DistanceBetween(benchmark::State& state) {
+ const auto pts = geo::bench::random_points(2 * static_cast(state.range(0)));
+ for (auto _ : state) {
+ for (std::size_t i = 0; i + 1 < pts.size(); i += 2) {
+ benchmark::DoNotOptimize(geo::distance_between(pts[i], pts[i + 1]));
+ }
+ }
+ state.SetItemsProcessed(state.iterations() * state.range(0));
+}
+BENCHMARK(BM_GeoUtils_DistanceBetween)->Arg(1000)->Arg(100000)->Repetitions(5)->ReportAggregatesOnly(true);
+
+// --- heading ----------------------------------------------------------------
+
+static void BM_GeoUtils_Heading(benchmark::State& state) {
+ const auto pts = geo::bench::random_points(2 * static_cast(state.range(0)));
+ for (auto _ : state) {
+ for (std::size_t i = 0; i + 1 < pts.size(); i += 2) {
+ benchmark::DoNotOptimize(geo::heading(pts[i], pts[i + 1]));
+ }
+ }
+ state.SetItemsProcessed(state.iterations() * state.range(0));
+}
+BENCHMARK(BM_GeoUtils_Heading)->Arg(1000)->Arg(100000)->Repetitions(5)->ReportAggregatesOnly(true);
+
+// --- contains (point-in-polygon) -------------------------------------------
+
+static void BM_GeoUtils_Contains(benchmark::State& state) {
+ const auto poly = geo::bench::bench_polygon(static_cast(state.range(0)));
+ const auto queries = geo::bench::bench_queries();
+ for (auto _ : state) {
+ for (const auto& q : queries) {
+ benchmark::DoNotOptimize(geo::contains(q, poly));
+ }
+ }
+ state.SetItemsProcessed(state.iterations() * static_cast(queries.size()));
+}
+BENCHMARK(BM_GeoUtils_Contains)->Arg(10)->Arg(100)->Arg(1000)->Repetitions(5)->ReportAggregatesOnly(true);
+
+// --- area -------------------------------------------------------------------
+
+static void BM_GeoUtils_Area(benchmark::State& state) {
+ const auto poly = geo::bench::bench_polygon(static_cast(state.range(0)));
+ for (auto _ : state) {
+ benchmark::DoNotOptimize(geo::area(poly));
+ }
+ state.SetItemsProcessed(state.iterations() * state.range(0));
+}
+BENCHMARK(BM_GeoUtils_Area)->Arg(10)->Arg(100)->Arg(1000)->Repetitions(5)->ReportAggregatesOnly(true);
+
+// --- path_length ------------------------------------------------------------
+
+static void BM_GeoUtils_PathLength(benchmark::State& state) {
+ const auto path = geo::bench::random_points(static_cast(state.range(0)));
+ for (auto _ : state) {
+ benchmark::DoNotOptimize(geo::path_length(path));
+ }
+ state.SetItemsProcessed(state.iterations() * state.range(0));
+}
+BENCHMARK(BM_GeoUtils_PathLength)->Arg(10)->Arg(100)->Arg(1000)->Repetitions(5)->ReportAggregatesOnly(true);
diff --git a/benchmarks/speed/bench_geographiclib.cpp b/benchmarks/speed/bench_geographiclib.cpp
new file mode 100644
index 0000000..3ad64bf
--- /dev/null
+++ b/benchmarks/speed/bench_geographiclib.cpp
@@ -0,0 +1,114 @@
+// Copyright 2026 Aleksandr Kovalko
+// Licensed under the Apache License, Version 2.0
+//
+// GeographicLib uses an ellipsoid (WGS84 by default) and Karney's iterative
+// geodesic algorithm — significantly more accurate than haversine but also
+// slower. The numbers here intentionally show that gap; this is a
+// *trade-off* benchmark, not a "we are faster" benchmark.
+//
+// Conversion policy: distance/heading work directly on lat/lng (no
+// conversion at all). For area / path_length we DO build PolygonArea +
+// AddPoint inside the timed loop — unlike Boost.Geometry / S2, where the
+// "build native type" step is separable from "run algorithm", PolygonArea
+// is an incremental accumulator: AddPoint *is* the geodesic work, and
+// Compute() only adds the closing-edge contribution and returns the
+// already-accumulated value. Pre-building outside the loop would measure
+// nothing real (a cached double read).
+//
+// GeographicLib does not ship a native point-in-polygon predicate, so the
+// `contains` benchmark is omitted (this is itself information for the reader).
+
+#include
+
+#include
+#include
+
+#include
+#include
+
+#include "random_data.hpp"
+
+namespace {
+
+inline const GeographicLib::Geodesic& wgs84() {
+ return GeographicLib::Geodesic::WGS84();
+}
+
+} // namespace
+
+// --- distance --------------------------------------------------------------
+
+static void BM_GeographicLib_DistanceBetween(benchmark::State& state) {
+ const auto pts = geo::bench::random_points(2 * static_cast(state.range(0)));
+ const auto& geod = wgs84();
+ double s12 = 0.0;
+ for (auto _ : state) {
+ for (std::size_t i = 0; i + 1 < pts.size(); i += 2) {
+ geod.Inverse(pts[i].lat, pts[i].lng, pts[i + 1].lat, pts[i + 1].lng, s12);
+ benchmark::DoNotOptimize(s12);
+ }
+ }
+ state.SetItemsProcessed(state.iterations() * state.range(0));
+}
+BENCHMARK(BM_GeographicLib_DistanceBetween)->Arg(1000)->Arg(100000)->Repetitions(5)->ReportAggregatesOnly(true);
+
+// --- heading ---------------------------------------------------------------
+
+static void BM_GeographicLib_Heading(benchmark::State& state) {
+ const auto pts = geo::bench::random_points(2 * static_cast(state.range(0)));
+ const auto& geod = wgs84();
+ double s12 = 0.0;
+ double azi1 = 0.0;
+ double azi2 = 0.0;
+ for (auto _ : state) {
+ for (std::size_t i = 0; i + 1 < pts.size(); i += 2) {
+ geod.Inverse(pts[i].lat, pts[i].lng, pts[i + 1].lat, pts[i + 1].lng,
+ s12, azi1, azi2);
+ benchmark::DoNotOptimize(azi1);
+ }
+ }
+ state.SetItemsProcessed(state.iterations() * state.range(0));
+}
+BENCHMARK(BM_GeographicLib_Heading)->Arg(1000)->Arg(100000)->Repetitions(5)->ReportAggregatesOnly(true);
+
+// --- area (PolygonArea, polyline=false) -----------------------------------
+//
+// AddPoint is where the per-vertex geodesic work happens (an Inverse() call
+// per vertex); Compute() is essentially free. We measure the full pattern
+// "build + finalize" because that is what computing the area of an N-vertex
+// polygon actually costs in GeographicLib — there is no separate algorithm
+// step to time on its own.
+
+static void BM_GeographicLib_Area(benchmark::State& state) {
+ const auto poly = geo::bench::bench_polygon(static_cast(state.range(0)));
+ for (auto _ : state) {
+ GeographicLib::PolygonArea pa(wgs84(), /*polyline=*/false);
+ for (const auto& p : poly) pa.AddPoint(p.lat, p.lng);
+ double perimeter = 0.0;
+ double area = 0.0;
+ pa.Compute(/*reverse=*/false, /*sign=*/true, perimeter, area);
+ benchmark::DoNotOptimize(area);
+ }
+ state.SetItemsProcessed(state.iterations() * state.range(0));
+}
+BENCHMARK(BM_GeographicLib_Area)->Arg(10)->Arg(100)->Arg(1000)->Repetitions(5)->ReportAggregatesOnly(true);
+
+// --- path_length (PolygonArea with polyline=true) -------------------------
+
+static void BM_GeographicLib_PathLength(benchmark::State& state) {
+ const auto path = geo::bench::random_points(static_cast(state.range(0)));
+ for (auto _ : state) {
+ GeographicLib::PolygonArea pa(wgs84(), /*polyline=*/true);
+ for (const auto& p : path) pa.AddPoint(p.lat, p.lng);
+ double perimeter = 0.0;
+ double area = 0.0;
+ pa.Compute(/*reverse=*/false, /*sign=*/true, perimeter, area);
+ benchmark::DoNotOptimize(perimeter);
+ }
+ state.SetItemsProcessed(state.iterations() * state.range(0));
+}
+BENCHMARK(BM_GeographicLib_PathLength)->Arg(10)->Arg(100)->Arg(1000)->Repetitions(5)->ReportAggregatesOnly(true);
+
+// Note: contains() benchmark intentionally omitted — GeographicLib does not
+// provide a native point-in-polygon predicate. This is a real capability gap,
+// not just a benchmark omission.
diff --git a/benchmarks/speed/bench_naive.cpp b/benchmarks/speed/bench_naive.cpp
new file mode 100644
index 0000000..271f683
--- /dev/null
+++ b/benchmarks/speed/bench_naive.cpp
@@ -0,0 +1,42 @@
+// Copyright 2026 Aleksandr Kovalko
+// Licensed under the Apache License, Version 2.0
+//
+// Reference baseline: a textbook haversine implementation, no library at all.
+// Tells us how much overhead `geo-utils-cpp` adds over the simplest possible
+// hand-written code (ideally: zero — we are inlined headers).
+
+#include
+
+#include
+#include
+
+#include "random_data.hpp"
+
+namespace {
+
+constexpr double kEarthRadius = geo::bench::kEarthRadiusMeters;
+constexpr double kPi = geo::bench::kPi;
+
+inline double naive_distance(geo::LatLng a, geo::LatLng b) noexcept {
+ const double lat1 = a.lat * kPi / 180.0;
+ const double lat2 = b.lat * kPi / 180.0;
+ const double dlat = (b.lat - a.lat) * kPi / 180.0;
+ const double dlng = (b.lng - a.lng) * kPi / 180.0;
+ const double s_lat = std::sin(dlat * 0.5);
+ const double s_lng = std::sin(dlng * 0.5);
+ const double h = s_lat * s_lat + std::cos(lat1) * std::cos(lat2) * s_lng * s_lng;
+ return 2.0 * kEarthRadius * std::asin(std::sqrt(h));
+}
+
+} // namespace
+
+static void BM_Naive_DistanceBetween(benchmark::State& state) {
+ const auto pts = geo::bench::random_points(2 * static_cast(state.range(0)));
+ for (auto _ : state) {
+ for (std::size_t i = 0; i + 1 < pts.size(); i += 2) {
+ benchmark::DoNotOptimize(naive_distance(pts[i], pts[i + 1]));
+ }
+ }
+ state.SetItemsProcessed(state.iterations() * state.range(0));
+}
+BENCHMARK(BM_Naive_DistanceBetween)->Arg(1000)->Arg(100000)->Repetitions(5)->ReportAggregatesOnly(true);
diff --git a/benchmarks/speed/bench_s2.cpp b/benchmarks/speed/bench_s2.cpp
new file mode 100644
index 0000000..3c1855a
--- /dev/null
+++ b/benchmarks/speed/bench_s2.cpp
@@ -0,0 +1,106 @@
+// Copyright 2026 Aleksandr Kovalko
+// Licensed under the Apache License, Version 2.0
+//
+// S2 Geometry equivalents. S2 uses a sphere, same as us — apples-to-apples
+// comparison on accuracy.
+//
+// Conversion policy: every native point/geometry is pre-built OUTSIDE the
+// timed loop. The timed work is the library's per-call computation, isolated
+// from data-shape conversion overhead — so the numbers reflect algorithmic
+// cost, not "lat/lng-in / lat/lng-out" plumbing. (Note: this means S2's
+// well-known per-call lat/lng→S2Point cost is *not* counted here.)
+//
+// Note: S2 does not ship a public initial-bearing function, so the heading
+// benchmark is intentionally absent — this is itself information for the
+// reader.
+
+#include
+
+#include
+#include
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "random_data.hpp"
+
+namespace {
+
+inline S2Point to_s2_point(const geo::LatLng& p) {
+ return S2LatLng::FromDegrees(p.lat, p.lng).ToPoint();
+}
+
+inline std::vector to_s2_points(const std::vector& src) {
+ std::vector out;
+ out.reserve(src.size());
+ for (const auto& p : src) out.push_back(to_s2_point(p));
+ return out;
+}
+
+} // namespace
+
+// --- distance: points pre-converted outside the timed loop ----------------
+
+static void BM_S2_DistanceBetween(benchmark::State& state) {
+ const auto pts_ll = geo::bench::random_points(2 * static_cast(state.range(0)));
+ const auto pts = to_s2_points(pts_ll);
+ for (auto _ : state) {
+ for (std::size_t i = 0; i + 1 < pts.size(); i += 2) {
+ const S1Angle angle(pts[i], pts[i + 1]);
+ benchmark::DoNotOptimize(S2Earth::ToMeters(angle));
+ }
+ }
+ state.SetItemsProcessed(state.iterations() * state.range(0));
+}
+BENCHMARK(BM_S2_DistanceBetween)->Arg(1000)->Arg(100000)->Repetitions(5)->ReportAggregatesOnly(true);
+
+// --- contains: polygon and query points pre-converted outside the loop ---
+
+static void BM_S2_Contains(benchmark::State& state) {
+ const auto poly_ll = geo::bench::bench_polygon(static_cast(state.range(0)));
+ auto loop = std::make_unique(to_s2_points(poly_ll));
+ loop->Normalize(); // S2Loop expects CCW; Normalize flips if needed.
+
+ const auto queries_ll = geo::bench::bench_queries();
+ const auto queries = to_s2_points(queries_ll);
+
+ for (auto _ : state) {
+ for (const auto& q : queries) {
+ benchmark::DoNotOptimize(loop->Contains(q));
+ }
+ }
+ state.SetItemsProcessed(state.iterations() * static_cast(queries.size()));
+}
+BENCHMARK(BM_S2_Contains)->Arg(10)->Arg(100)->Arg(1000)->Repetitions(5)->ReportAggregatesOnly(true);
+
+// --- area ------------------------------------------------------------------
+
+static void BM_S2_Area(benchmark::State& state) {
+ const auto poly_ll = geo::bench::bench_polygon(static_cast(state.range(0)));
+ auto loop = std::make_unique(to_s2_points(poly_ll));
+ loop->Normalize();
+
+ const double r2 = S2Earth::RadiusMeters() * S2Earth::RadiusMeters();
+ for (auto _ : state) {
+ benchmark::DoNotOptimize(loop->GetArea() * r2);
+ }
+ state.SetItemsProcessed(state.iterations() * state.range(0));
+}
+BENCHMARK(BM_S2_Area)->Arg(10)->Arg(100)->Arg(1000)->Repetitions(5)->ReportAggregatesOnly(true);
+
+// --- path_length: S2Polyline pre-built outside the timed loop ------------
+
+static void BM_S2_PathLength(benchmark::State& state) {
+ const auto path_ll = geo::bench::random_points(static_cast(state.range(0)));
+ const S2Polyline line(to_s2_points(path_ll));
+ for (auto _ : state) {
+ benchmark::DoNotOptimize(S2Earth::ToMeters(line.GetLength()));
+ }
+ state.SetItemsProcessed(state.iterations() * state.range(0));
+}
+BENCHMARK(BM_S2_PathLength)->Arg(10)->Arg(100)->Arg(1000)->Repetitions(5)->ReportAggregatesOnly(true);
diff --git a/docs/api.md b/docs/api.md
index 77b258d..cfa67f8 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -5,15 +5,29 @@ are internal and not part of the supported API.
## Conventions
-- Coordinates are in degrees (latitude, longitude)
-- Distances are in meters
-- Angles are in degrees unless otherwise specified
-- Earth model: spherical (mean radius = 6371009 m)
-
-## Numerical notes
-
-- Results are approximate due to floating point arithmetic
-- Precision decreases near the poles and for antipodal points
+- **Units.** Coordinates are in degrees (latitude, longitude); distances
+ in meters; angles in degrees unless otherwise noted.
+- **Earth model.** Spherical, mean radius 6371009 m — the same model
+ Google Maps geometry utilities use. See [benchmarks.md](benchmarks.md)
+ for the trade-off vs ellipsoidal libraries like GeographicLib.
+- **Precision.** Floating-point arithmetic; precision degrades near the
+ poles and for antipodal pairs.
+- **Thread safety.** All functions are pure (no global mutable state, no
+ caching) and safe to call concurrently from multiple threads.
+- **Error handling.** Scalar functions (`heading`, `offset`,
+ `interpolate`, `distance_between`, `distance_to_segment`) are
+ `noexcept`. Out-of-domain inputs are not asserted: `interpolate` with
+ `fraction` outside `[0, 1]` extrapolates along the great circle,
+ `offset_origin` returns `std::nullopt` when no origin can be computed,
+ and pathological inputs may yield NaN. Container-taking functions
+ (`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.
+- **Include strategy.** Each subsystem has its own header:
+ `` (types), `` (distance, heading,
+ area), `` (point-in-polygon, on-path). The umbrella
+ `` pulls all three in for convenience.
## LatLng
@@ -25,8 +39,8 @@ Represents a geographic coordinate (latitude, longitude) in degrees.
`geo::LatLng` — a point in geographical coordinates: latitude and longitude.
-* Latitude ranges between `-90` and `90` degrees, inclusive
-* Longitude ranges between `-180` and `180` degrees, inclusive. Note: `180` and `-180` are treated as equal.
+- Latitude ranges between `-90` and `90` degrees, inclusive
+- Longitude ranges between `-180` and `180` degrees, inclusive. Note: `180` and `-180` are treated as equal.
```cpp
geo::LatLng northPole{90, 0};
@@ -72,6 +86,18 @@ std::vector aroundNorthPole = { {89, 0}, {89, 120}, {89, -120} };
std::array northPole = { {90, 0} };
```
+> **Note.** A braced-init-list cannot be passed directly to a `Path`
+> template parameter — it has no `operator[]` and no deducible type.
+> Wrap it in a container:
+>
+> ```cpp
+> // Won't compile:
+> geo::area({{0, 0}, {0, 10}, {10, 0}});
+>
+> // Wrap in a vector:
+> geo::area(std::vector{{0, 0}, {0, 10}, {10, 0}});
+> ```
+
---
## Spherical functions
@@ -86,17 +112,17 @@ Spherical geometry utilities for computing angles, distances, and areas.
**`geo::heading(const LatLng& from, const LatLng& to)`** — Returns the heading from one LatLng to another. Headings are expressed in degrees clockwise from North within the range `[-180, 180)`.
-* `from` — the starting point
-* `to` — the destination point
+- `from` — the starting point
+- `to` — the destination point
-Returns: `double` — the heading in degrees clockwise from north
+Returns: `double` — heading in degrees clockwise from north, in `[-180, 180)`.
```cpp
-geo::LatLng front{0, 0};
-geo::LatLng right{0, 90};
+geo::LatLng equator{0, 0}; // on the equator at lng=0
+geo::LatLng east{0, 90}; // 90° east along the equator
-std::cout << geo::heading(right, front); // -90
-std::cout << geo::heading(front, right); // +90
+std::cout << geo::heading(equator, east); // +90 (due east)
+std::cout << geo::heading(east, equator); // -90 (due west)
```
---
@@ -105,16 +131,16 @@ std::cout << geo::heading(front, right); // +90
**`geo::offset(const LatLng& from, double distance, double heading)`** — Returns the LatLng resulting from moving a distance from an origin in the specified heading (degrees clockwise from north).
-* `from` — the starting point
-* `distance` — the distance to travel, in meters
-* `heading` — the heading in degrees clockwise from north
+- `from` — the starting point
+- `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.
```cpp
geo::LatLng front{0, 0};
-// quarter-circumference of Earth ≈ 10,007.5 km
+// Quarter-circumference of Earth: π·R/2 ≈ 10,007.5 km (R = 6371009 m).
constexpr double quarter = 10'007'543.4;
auto up = geo::offset(front, quarter, 0); // { 90, 0}
@@ -127,19 +153,28 @@ auto right = geo::offset(front, quarter, 90); // { 0, 90}
### offset_origin
-**`geo::offset_origin(const LatLng& to, double distance, double heading)`** — Returns the origin point that, when travelling `distance` meters at `heading`, arrives at `to`. Returns `std::nullopt` when no solution exists.
+**`geo::offset_origin(const LatLng& to, double distance, double heading)`** — Returns the origin point that, when travelling `distance` meters at `heading`, arrives at `to`. Returns `std::nullopt` when no origin can be computed — including the degenerate cases at the poles.
-* `to` — the destination point
-* `distance` — the distance travelled, in meters
-* `heading` — the heading in degrees clockwise from north
+- `to` — the destination point
+- `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.
```cpp
-geo::LatLng front{0, 0};
-
-auto r0 = geo::offset_origin(front, 0, 0);
-assert(r0.has_value() && front == r0.value());
+geo::LatLng start{40.0, -74.0};
+constexpr double distance = 5'000'000.0; // 5,000 km
+constexpr double heading = 60.0; // east-northeast
+
+// Round-trip: offset_origin undoes offset.
+auto destination = geo::offset(start, distance, heading);
+auto origin = geo::offset_origin(destination, distance, heading);
+assert(origin.has_value() && start.approx_equal(origin.value(), 1e-6));
+
+// nullopt at the pole with quarter-circumference east heading — all
+// directions are degenerate there, no origin solution exists.
+auto pole = geo::offset_origin(geo::LatLng{90, 0}, 10'007'543.4, 90.0);
+assert(!pole.has_value());
```
---
@@ -148,16 +183,17 @@ assert(r0.has_value() && front == r0.value());
**`geo::interpolate(const LatLng& from, const LatLng& to, double fraction)`** — Returns the LatLng which lies the given fraction of the way between the origin and the destination (spherical linear interpolation).
-* `from` — the starting point
-* `to` — the destination point
-* `fraction` — a value in `[0, 1]`
+- `from` — the starting point
+- `to` — the destination point
+- `fraction` — typically in `[0, 1]`; values outside extrapolate along the same great-circle arc
-Returns: `LatLng`
+Returns: `LatLng` — the interpolated point on the great-circle arc from `from` to `to`.
```cpp
geo::LatLng up{90, 0};
geo::LatLng front{0, 0};
+// The arc from equator to pole spans 90°, so fraction 1/90 → 1° latitude.
assert(geo::LatLng{1, 0} == geo::interpolate(front, up, 1 / 90.0));
assert(geo::LatLng{89, 0} == geo::interpolate(front, up, 89 / 90.0));
```
@@ -166,50 +202,62 @@ assert(geo::LatLng{89, 0} == geo::interpolate(front, up, 89 / 90.0));
### angle_between
-**`geo::angle_between(const LatLng& from, const LatLng& to)`** — Returns the central angle between two points, in radians.
+**`geo::angle_between(const LatLng& from, const LatLng& to)`** — Returns the central angle (great-circle arc) between two points, in radians.
-Returns: `double`
+Returned in radians, not degrees, because the value equals the great-circle
+arc length on the unit sphere — multiply by Earth's mean radius (6371009 m)
+to get meters. This is exactly how `distance_between` is implemented.
+
+Returns: `double` — central angle in radians, in `[0, π]`.
+
+```cpp
+geo::LatLng pole{90, 0};
+geo::LatLng equator{0, 0};
+
+double angle = geo::angle_between(pole, equator); // π/2 ≈ 1.5708
+double meters = angle * 6371009.0; // ≈ 1.001e+07 (quarter-circumference)
+```
---
### distance_between
-**`geo::distance_between(const LatLng& from, const LatLng& to)`** — Returns the distance between two points, in meters.
+**`geo::distance_between(const LatLng& from, const LatLng& to)`** — Returns the great-circle distance between two points, in meters.
-Returns: `double`
+Returns: `double` — distance in meters, in `[0, π·R]` (i.e. up to half Earth's circumference).
```cpp
geo::LatLng up{90, 0};
geo::LatLng down{-90, 0};
-std::cout << geo::distance_between(up, down); // ~2.00151e+07
+std::cout << geo::distance_between(up, down); // ~20,015 km (π·R, half Earth's circumference)
```
---
### path_length
-**`geo::path_length(const Path& path)`** — Returns the length of the given path, in meters.
+**`geo::path_length(const Path& path)`** — Returns the total length of the polyline (sum of great-circle distances between consecutive points), in meters.
-Returns: `double`
+Returns: `double` — total length in meters; `0` for a path with fewer than 2 points.
```cpp
std::vector path = { {0, 0}, {90, 0}, {0, 90} };
-std::cout << geo::path_length(path); // ~20,015,087 m (pi*R)
+std::cout << geo::path_length(path); // ~20,015 km (π·R, half Earth's circumference)
```
---
### area
-**`geo::area(const Path& path)`** — Returns the area of a closed path on Earth, in square meters.
+**`geo::area(const Path& path)`** — Returns the area of a closed path on Earth, in square meters. The path is implicitly closed (last vertex connects back to the first); equivalent to `std::abs(signed_area(path))`.
-Returns: `double`
+Returns: `double` — signed area in square meters; positive for CCW, negative for CW.
```cpp
// Lune bounded by meridians 0 and 90 — one quarter of the Earth's surface.
std::vector path = { {0, 90}, {-90, 0}, {0, 0}, {90, 0}, {0, 90} };
-std::cout << geo::area(path); // ~pi*R^2 (one quarter of the total 4*pi*R^2)
+std::cout << geo::area(path); // ~1.275e+14 m² (π·R², one quarter of Earth's surface)
```
---
@@ -246,13 +294,18 @@ Utilities for computations involving polygons and polylines.
#include
```
+> **Note on `geodesic` defaults.** `contains` defaults to rhumb-line
+> edges (cheaper, fine for polygons well inside one hemisphere);
+> `on_edge` and `on_path` default to great-circle edges (more accurate,
+> especially near the poles). Pass `geodesic` explicitly when in doubt.
+
### contains
**`geo::contains(const LatLng& point, const Path& polygon, bool geodesic = false)`** — Returns whether the given point lies inside the specified polygon. The polygon is always considered closed. The South Pole is always outside.
-* `geodesic` — `true` for great circle edges, `false` for rhumb edges
+- `geodesic` — `false` (default) for rhumb-line edges, `true` for great-circle edges. See the note at the top of this section.
-Returns: `bool`
+Returns: `bool` — `true` if `point` is inside the polygon, `false` otherwise.
```cpp
std::vector aroundNorthPole = { {89, 0}, {89, 120}, {89, -120} };
@@ -265,9 +318,12 @@ std::cout << geo::contains(geo::LatLng{-90, 0}, aroundNorthPole); // false
### on_edge
-**`geo::on_edge(const LatLng& point, const Path& polygon, bool geodesic = true, double tolerance = geo::kDefaultTolerance)`** — Returns whether the given point lies on or near a polygon edge, within `tolerance` meters.
+**`geo::on_edge(const LatLng& point, const Path& polygon, bool geodesic = true, double tolerance = geo::kDefaultTolerance)`** — Returns whether the given point lies on or near a polygon edge (including the closing segment between the last and first vertices), within `tolerance` meters.
+
+- `geodesic` — `true` (default) for great-circle edges, `false` for rhumb-line edges. See the note at the top of this section.
+- `tolerance` — maximum distance in meters between `point` and the nearest edge to still count as "on"; defaults to `geo::kDefaultTolerance` (0.1 m).
-Returns: `bool`
+Returns: `bool` — `true` if `point` is within `tolerance` of any edge.
```cpp
std::vector equator = { {0, 90}, {0, 180} };
@@ -280,24 +336,35 @@ std::cout << geo::on_edge(geo::LatLng{0, 90 - 2e-6}, equator); // false
### on_path
-**`geo::on_path(const LatLng& point, const Path& polyline, bool geodesic = true, double tolerance = geo::kDefaultTolerance)`** — Returns whether the given point lies on or near a polyline, within `tolerance` meters. The closing segment between the first and last points is **not** included.
+**`geo::on_path(const LatLng& point, const Path& polyline, bool geodesic = true, double tolerance = geo::kDefaultTolerance)`** — Returns whether the given point lies on or near a polyline, within `tolerance` meters. The closing segment between the first and last points is **not** included — that's the only difference vs `on_edge`.
+
+- `geodesic` — `true` (default) for great-circle edges, `false` for rhumb-line edges. See the note at the top of this section.
+- `tolerance` — maximum distance in meters; defaults to `geo::kDefaultTolerance` (0.1 m).
-Returns: `bool`
+Returns: `bool` — `true` if `point` is within `tolerance` of any segment of the polyline.
+
+```cpp
+std::vector polyline = { {0, 0}, {0, 10}, {10, 10}, {10, 0} };
+
+std::cout << geo::on_path(geo::LatLng{0, 5}, polyline); // true — on first segment
+std::cout << geo::on_path(geo::LatLng{5, 0}, polyline); // false — closing edge (10,0)→(0,0) is excluded
+```
---
### distance_to_segment
-**`geo::distance_to_segment(const LatLng& point, const LatLng& start, const LatLng& end)`** — Returns the distance in meters from `point` to the line segment `[start, end]` on the sphere.
+**`geo::distance_to_segment(const LatLng& point, const LatLng& start, const LatLng& end)`** — Returns the distance in meters from `point` to the closest point on the line segment `[start, end]` on the sphere. If the perpendicular foot falls outside the segment, returns the distance to the nearer endpoint.
-Returns: `double`
+Returns: `double` — distance in meters, always ≥ 0.
```cpp
geo::LatLng start{28.05359, -82.41632};
geo::LatLng end{28.05310, -82.41634};
geo::LatLng point{28.05342, -82.41594};
-std::cout << geo::distance_to_segment(point, start, end); // ~37.95
+// Point lies ~38 m east-northeast of the segment
+std::cout << geo::distance_to_segment(point, start, end);
```
---
diff --git a/docs/benchmarks.md b/docs/benchmarks.md
new file mode 100644
index 0000000..55e799d
--- /dev/null
+++ b/docs/benchmarks.md
@@ -0,0 +1,248 @@
+# Benchmarks
+
+`geo-utils-cpp` is a small, dependency-free lat/lng geometry library that
+stays close to hand-written spherical math while avoiding a full geometry
+framework dependency. This page shows the benchmark results, methodology,
+and trade-offs against S2 Geometry, Boost.Geometry, and GeographicLib.
+
+For build and run instructions see [`benchmarks/README.md`](../benchmarks/README.md).
+
+## TL;DR
+
+- **Speed.** Matches Boost.Geometry's spherical strategy and hand-written
+ haversine on `distance` / `heading`; wins on `area`; loses `contains`
+ and `path_length` to S2, which pays a hidden `lat/lng → S2Point`
+ conversion in real workloads. ~30× faster than GeographicLib's WGS84
+ geodesic — but on a sphere, less accurate.
+- **Deployment footprint.** Header-only, zero deps — nothing to add to
+ your build's dependency tree, and no `.so`/`.dylib` to ship alongside
+ the binary.
+- **When each library wins.** S2 wins on `contains` and on several
+ algorithm-only speed tests, especially if your data already lives as
+ `S2Point` end-to-end. geo-utils-cpp is best-in-class on `area`, and on
+ lat/lng-input workloads where adding a geometry framework to your
+ build isn't an option. Full per-library guidance is in [Where each library is the right tool](#where-each-library-is-the-right-tool).
+
+## Methodology
+
+| Item | Value |
+| ----------------- | -------------------------------------------------- |
+| Compiler | Apple clang 17 (`-std=c++17 -O2 -DNDEBUG`) |
+| Build type | Release |
+| Benchmark harness | [Google Benchmark 1.8.4](https://github.com/google/benchmark) |
+| Host | Apple M1, 8 cores, 8 GB RAM, macOS 15.7 |
+| Random data | Mersenne-twister seeded to a fixed value, lat ∈ [-80, 80], lng ∈ [-180, 180] |
+| Library versions | s2geometry 0.14.0 · boost 1.90.0 · geographiclib 2.7 |
+
+Each library is fed identical inputs (see
+[`benchmarks/common/random_data.hpp`](../benchmarks/common/random_data.hpp)).
+Each library's **native point/geometry types are pre-built outside the timed
+loop**. The timed work is the per-call computation (`bg::distance`,
+`S2Loop::Contains`, `pa.Compute()`, etc.) — this isolates algorithmic cost
+from `lat/lng → native-type` plumbing. `geo-utils-cpp`'s API takes lat/lng
+directly, so it has nothing to pre-build; this is a real API-shape advantage
+but is not what the speed numbers below measure.
+
+### Apples-to-apples notes
+
+- **S2 vs us:** both on a sphere. Fair on accuracy. `S2Loop` stores a
+ bounding rectangle with the loop and uses it as an early-exit predicate
+ inside `Contains`; that, plus a tightly inlined edge-crossing routine,
+ is what makes its `contains` scale differently from our linear ray-cast.
+ Note: this is *not* spatial indexing — that lives in `S2ShapeIndex`,
+ which we do not construct here.
+- **Boost.Geometry vs us:** uses `cs::spherical_equatorial`. Fair.
+- **GeographicLib vs us:** uses Karney's iterative WGS84 geodesic.
+ Slower *and* more accurate. Treat as a trade-off data point, not a
+ "we are faster" claim.
+- **GeographicLib `PolygonArea` is timed differently** for `area` /
+ `path_length`. It's an incremental accumulator: `AddPoint` itself does
+ the per-vertex geodesic `Inverse()` call, and `Compute()` only
+ finalizes the closing edge. We therefore measure the full
+ `PolygonArea + N×AddPoint + Compute()` pattern inside the timed loop
+ — that's what computing the area of an N-vertex polygon actually
+ costs in GeographicLib. The other libraries pre-build native types
+ outside the loop; for GeographicLib there is no separable "pre-build"
+ step to lift.
+- **GeographicLib has no native point-in-polygon.** Real capability gap.
+- **S2 has no public initial-bearing API.** Same.
+
+## Speed results
+
+Throughput in million items per second (higher is better). All numbers from
+the host described above. Run `./build-bench/benchmarks/bench_*` locally
+to reproduce. **Bold** number = column winner, or co-winners within ~5%
+(noise-level tie); bold library name = this library (`geo-utils-cpp`).
+
+### `distance_between`
+
+| Library | N=1 000 | N=100 000 |
+| ---------------------- | -------: | --------: |
+| **geo-utils-cpp** | 40.5 | **28.2** |
+| naive haversine | 38.3 | 26.0 |
+| S2 Geometry | **82.9** | **29.1** |
+| Boost.Geometry | 39.8 | **28.8** |
+| GeographicLib | 1.25 | 1.24 |
+
+We tie naive haversine and Boost.Geometry's spherical strategy within
+noise — zero overhead from being a library. The "naive" baseline is a
+deliberately textbook haversine (recomputes `* π / 180` per call, no
+trig caching); `geo-utils-cpp`'s actual implementation is hand-optimized
+(cached `deg2rad` factors, combined `arc_hav` reductions), so "ties
+naive" really means "the optimized library version is no slower than a
+hand-rolled one-liner — overhead is zero". S2 is **2× faster at small N**
+because once the input is `S2Point` the per-pair distance reduces to a dot
+product / `acos`, cheaper than haversine; the gap closes at N=100 000 where
+all three become memory-bandwidth bound. Note: in real-world workloads
+where the input *is* lat/lng, S2 also pays a per-call lat/lng→S2Point
+conversion that isn't counted here. GeographicLib is ~25× slower (and
+substantially more accurate on long-distance pairs).
+
+### `heading`
+
+| Library | N=1 000 | N=100 000 |
+| ---------------------- | -------: | --------: |
+| **geo-utils-cpp** | **24.9** | **15.5** |
+| Boost.Geometry | 22.5 | 14.7 |
+| GeographicLib | 1.16 | 1.16 |
+| S2 Geometry | — | — |
+
+S2 has no public initial-bearing API.
+
+### `contains` (point-in-polygon)
+
+Million queries per second (1 000 query points per iteration).
+
+> **Apples-to-apples caveat for this op.** The four libraries do *not*
+> run the same algorithm here. `geo-utils-cpp` uses an O(N) ray-cast over
+> rhumb-line edges (its `geodesic=false` default — cheaper but less
+> accurate near the poles). Boost.Geometry's `bg::within` with the
+> spherical CS auto-selects `strategy::within::spherical_winding`, which
+> traces *great-circle* edges and is materially more expensive per edge.
+> S2 wins partly through algorithm (3D edge-crossing) and partly through
+> structure (a bounding-rectangle prefilter on the loop). The Boost gap
+> below therefore reflects algorithm choice as much as raw speed; see the
+> commentary after the table.
+
+| Library | poly N=10 | poly N=100 | poly N=1 000 |
+| -------------------- | --------: | ---------: | -----------: |
+| **geo-utils-cpp** | 16.2 | 2.87 | 0.329 |
+| S2 Geometry | **26.7** | **18.0** | **21.2** |
+| Boost.Geometry | 1.91 | 0.234 | 0.024 |
+| GeographicLib | — | — | — |
+
+GeographicLib has no native point-in-polygon predicate.
+
+Practical takeaway: if `contains` is the hot path and your data already
+lives as `S2Point` end-to-end, S2 is the right tool — throughput is
+roughly constant in N because of the prefilter. For lat/lng-input
+workloads we beat Boost.Geometry by ~10× while remaining header-only.
+
+### `area` (M polygons/s × vertex count)
+
+| Library | N=10 | N=100 | N=1 000 |
+| -------------------- | -------: | -------: | -------: |
+| **geo-utils-cpp** | **69.9** | **67.2** | **67.7** |
+| S2 Geometry | 16.3 | 14.0 | 13.9 |
+| Boost.Geometry | 45.0 | 36.2 | 36.6 |
+| GeographicLib | 1.75 | 2.04 | 2.07 |
+
+N = vertices per polygon.
+
+We win clearly on `area` — our spherical-triangle accumulation is a tight
+loop with no allocation; S2's `S2Loop::GetArea` does more work per
+vertex, and Boost.Geometry's strategy machinery costs ~1.8×.
+
+### `path_length` (M points/s)
+
+| Library | N=10 | N=100 | N=1 000 |
+| -------------------- | -------: | -------: | -------: |
+| **geo-utils-cpp** | 54.0 | 46.2 | 41.7 |
+| S2 Geometry | **105.1** | **96.7** | **91.6** |
+| Boost.Geometry | 48.7 | 43.5 | 40.2 |
+| GeographicLib | 1.53 | 1.27 | 1.23 |
+
+S2 wins on `path_length` algorithmically (~2×) — once the input is
+`S2Point`, segment length is a fast cartesian computation. We tie
+Boost.Geometry within noise. GeographicLib pays the ellipsoidal cost.
+Note: a lat/lng-input workload would push the S2 column down by the
+per-call conversion cost, which is not counted here.
+
+## Deployment footprint
+
+A geometry library shows up in two places: at **build time** (what your
+CMake has to find, what your container image has to install) and at
+**runtime** (what has to sit alongside the binary so it can load the
+library at startup). `geo-utils-cpp` is header-only with no deps, so
+both are zero beyond the headers themselves.
+
+The table below covers the runtime side — stripped binary size of a
+minimal "distance + point-in-polygon" consumer, dynamically linked
+against each library. Smaller is better.
+
+| Library | Stripped binary | Notes |
+| -------------------- | --------------: | ---------------------------------------------- |
+| **geo-utils-cpp** | **33 KB** | header-only — nothing else to ship |
+| naive haversine | **33 KB** | hand-written, no library |
+| S2 Geometry | 33.4 KB | dynamic linking; `libs2.dylib` required at runtime |
+| Boost.Geometry | 50.8 KB | header-only — nothing else to ship |
+| GeographicLib | 33 KB | dynamic linking; `libGeographicLib.dylib` required; distance only — no PIP |
+
+**Bold** marks the entries that ship nothing beyond the binary, not
+similarity of the size column itself.
+
+These numbers are for **dynamic linking**: the binary itself stays small
+because the library code lives in the shared object that has to be
+present at runtime alongside the binary. Static linking shifts the cost
+the other way — the binary grows, but only by the symbols the linker
+actually keeps (with `-ffunction-sections -Wl,--gc-sections` or LTO,
+unused code is pruned). For header-only libraries (`geo-utils-cpp`,
+Boost.Geometry) there's nothing to ship beyond the binary in either mode.
+
+The `benchmarks/size/measure.sh` script supports `STATIC=1` if you want
+to repeat the comparison for statically linked binaries on your own host
+— numbers will depend on which symbols your code actually pulls in.
+
+## Where each library is the right tool
+
+- **geo-utils-cpp** — lat/lng-native API, no-deps constraint, `area` is
+ hot, sphere accuracy is acceptable. Best when adding a geometry
+ framework to your build (S2 + abseil, Boost.Geometry, GeographicLib)
+ isn't an option, or when you'd otherwise be paying a
+ lat/lng→native-type conversion on every call.
+- **S2 Geometry** — `contains` / `distance` / `path_length` are the hot
+ path, and you're willing to keep data as `S2Point` end-to-end
+ (otherwise the lat/lng→S2Point conversion eats the algorithmic win).
+ Spatial indexing (`S2ShapeIndex`) available for bigger workloads.
+- **Boost.Geometry** — already-Boost project, want one library for many
+ geometry types and CSes. Ties us on most operations; loses on `area`
+ and on `contains`.
+- **GeographicLib** — sub-meter geodesic accuracy on the WGS84 ellipsoid.
+ Slower by 1–2 orders of magnitude. No PIP.
+
+## Reproducing
+
+Every benchmark is registered with `Repetitions(5)->ReportAggregatesOnly(true)`,
+so output shows `_mean` / `_median` / `_stddev` / `_cv` per data point —
+that's what the numbers in the tables above are (median rows). Use the
+standard Google Benchmark CLI flags (`--benchmark_repetitions=N`,
+`--benchmark_min_time=...`, etc.) to override locally.
+
+See [`benchmarks/README.md`](../benchmarks/README.md) for build
+prerequisites and target names.
+
+```sh
+# Speed
+cmake -S . -B build-bench \
+ -DGEO_UTILS_CPP_BUILD_BENCHMARKS=ON \
+ -DCMAKE_BUILD_TYPE=Release
+cmake --build build-bench --target bench_all -j
+for b in build-bench/benchmarks/bench_*; do "$b"; done
+
+# Deployment footprint
+./benchmarks/size/measure.sh
+```
+
+If a competitor is missing it will be skipped with a `STATUS` message during
+CMake configure (or a "not installed" line from `measure.sh`). Install only
+the competitors you care about.
diff --git a/docs/getting-started.md b/docs/getting-started.md
index c164961..ce0bc1e 100644
--- a/docs/getting-started.md
+++ b/docs/getting-started.md
@@ -1,12 +1,29 @@
-# Getting Started
+# Getting started
+
+This page walks through installing `geo-utils-cpp` and writing your first
+program. For a wider tour of the library, see the [README](../README.md);
+for full function-level documentation, see [api.md](api.md); for
+performance comparisons against S2, Boost.Geometry, and GeographicLib,
+see [benchmarks.md](benchmarks.md).
## Requirements
-- C++17 or later
+- C++17 or later (tested on C++17, C++20, and C++23 in CI)
- CMake 3.14 or later
+- A standard-conformant compiler. CI covers recent GCC, Clang, AppleClang,
+ and MSVC on Linux, macOS, and Windows — see the CI badges in the
+ [README](../README.md) for the exact matrix.
+
+The library is header-only with no runtime dependencies — there is nothing
+to link against beyond the headers themselves.
## Integration
+Pick the option that matches how your project already manages dependencies.
+If you don't have a preference, **FetchContent** is the lowest-friction
+path: no separate install step, version pinned per project, works offline
+once cached.
+
### FetchContent (recommended)
```cmake
@@ -24,15 +41,17 @@ target_link_libraries(your_target PRIVATE geo::utils)
### vcpkg
-The library is available in the official [vcpkg registry](https://github.com/microsoft/vcpkg).
+The library is available in the official
+[vcpkg registry](https://github.com/microsoft/vcpkg).
-Classic mode:
+Classic mode — install once into the vcpkg shared store:
```sh
vcpkg install geo-utils-cpp
```
-Manifest mode — add to your `vcpkg.json`:
+Manifest mode — pin the dependency per-project in `vcpkg.json` (preferred
+for new projects, since the manifest commits alongside your source):
```json
{
@@ -42,7 +61,7 @@ Manifest mode — add to your `vcpkg.json`:
}
```
-Then consume it from CMake:
+Either mode, then consume it from CMake:
```cmake
find_package(GeoUtilsCpp 1.0.1 REQUIRED)
@@ -76,18 +95,40 @@ A minimal smoke-test consumer (`tests/consumer/`) is included for both CMake
and xmake builds — see [`tests/consumer/xmake.lua`](../tests/consumer/xmake.lua)
for the xmake variant.
-### find_package
+### Conan
+
+```sh
+conan install --requires=geo-utils-cpp/1.0.1 --build=missing
+```
+
+Conan Center support is pending
+[conan-io/conan-center-index#30152](https://github.com/conan-io/conan-center-index/pull/30152);
+until that lands, install from the local recipe in the
+[conan/](https://github.com/gistrec/geo-utils-cpp/tree/master/conan)
+directory.
+
+### System install
-Install the library first, then:
+Build and install the library to a prefix on your machine, then have
+downstream projects locate it via `find_package`:
+
+```sh
+cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
+cmake --install build --prefix /usr/local
+```
```cmake
find_package(GeoUtilsCpp 1.0.1 REQUIRED)
target_link_libraries(your_target PRIVATE geo::utils)
```
+If `find_package` can't locate the library, point CMake at the install
+prefix via `-DCMAKE_PREFIX_PATH=/usr/local` (or wherever you installed it).
+
### Manual
-Copy the `include/` directory into your project and add it to your compiler's include path:
+Copy the `include/` directory into your project and add it to your
+compiler's include path:
```sh
g++ main.cpp -std=c++17 -I/path/to/geo-utils-cpp/include -o main
@@ -95,16 +136,17 @@ g++ main.cpp -std=c++17 -I/path/to/geo-utils-cpp/include -o main
## Usage
-Include the umbrella header or individual modules:
+Include the umbrella header for everything, or pull in individual modules
+for tighter compile times:
```cpp
// Everything at once
#include
// Or individual modules
-#include
-#include
-#include
+#include // types
+#include // distance, heading, offset, area
+#include // point-in-polygon, on-path
```
### Example: distance and heading between two points
@@ -126,11 +168,12 @@ int main() {
}
```
-### Example: approximate equality with custom tolerance
+### Example: round-trip with custom tolerance
-`LatLng::operator==` is an approximate comparison with tolerance `1e-12` degrees
-(≈ 0.1 nm). For comparisons at coarser scale — e.g. metre precision — pass an
-explicit tolerance to `approx_equal`:
+`LatLng::operator==` is an approximate comparison with tolerance `1e-12`
+degrees (≈ 0.1 nanometers on Earth) — fine for bit-exact equality, too
+strict to compare results of floating-point geometry. For coarser-scale
+comparisons, pass an explicit tolerance to `approx_equal`:
```cpp
#include
@@ -140,7 +183,9 @@ geo::LatLng end = geo::offset(start, 100'000.0, 90.0); // 100 km east
auto recovered = geo::offset_origin(end, 100'000.0, 90.0);
assert(recovered.has_value());
-assert(recovered->approx_equal(start, 1e-6)); // ~10 cm tolerance
+// 1e-6 degrees ≈ 11 cm on the equator — comfortably above the
+// floating-point noise of a 100 km round-trip.
+assert(recovered->approx_equal(start, 1e-6));
```
### Example: point-in-polygon
@@ -152,6 +197,7 @@ assert(recovered->approx_equal(start, 1e-6)); // ~10 cm tolerance
#include
int main() {
+ // A small box around midtown Manhattan.
std::vector polygon = {
{ 40.7650, -73.9900 },
{ 40.7650, -73.9700 },
@@ -160,22 +206,77 @@ int main() {
};
geo::LatLng timesSquare = { 40.7580, -73.9855 };
+ geo::LatLng centralPark = { 40.7829, -73.9654 };
std::cout << std::boolalpha;
- std::cout << geo::contains(timesSquare, polygon) << "\n"; // true
+ std::cout << "Times Square inside: " << geo::contains(timesSquare, polygon) << "\n"; // true
+ std::cout << "Central Park inside: " << geo::contains(centralPark, polygon) << "\n"; // false
}
```
-## Build options
+More runnable samples live in [`examples/`](../examples/) — each is a
+single `.cpp` file you can build and run standalone.
+
+### Editor integration
-- `GEO_UTILS_CPP_BUILD_TESTS` — build unit tests (default: ON if top-level, OFF otherwise)
-- `GEO_UTILS_CPP_BUILD_EXAMPLES` — build examples (default: ON if top-level, OFF otherwise)
-- `GEO_UTILS_CPP_ENABLE_COVERAGE` — gcov instrumentation, GCC/Clang only (default: OFF)
+After CMake configure, `compile_commands.json` is generated in the build
+directory. Most language servers (clangd, ccls) pick it up automatically;
+point your editor at the build directory if it doesn't.
-## Building and testing
+## Building the library itself
+
+The sections above are for *using* `geo-utils-cpp` as a dependency. If
+you're working on the library — running tests, building examples,
+measuring coverage — clone the repo and configure it as a top-level
+project:
```sh
cmake -S . -B build
cmake --build build
ctest --test-dir build --output-on-failure
```
+
+### Build options
+
+| Option | Default | Purpose |
+| --------------------------------- | ------------------------ | ------- |
+| `GEO_UTILS_CPP_BUILD_TESTS` | `ON` if top-level | Build unit tests |
+| `GEO_UTILS_CPP_BUILD_EXAMPLES` | `ON` if top-level | Build the examples in [`examples/`](../examples/) |
+| `GEO_UTILS_CPP_ENABLE_COVERAGE` | `OFF` | gcov instrumentation; GCC/Clang only |
+
+Downstream users consuming `geo-utils-cpp` as a dependency don't need to
+touch these — tests and examples are off by default when the library is
+included via `FetchContent` / `find_package`.
+
+## Troubleshooting
+
+A few issues that catch people on first use:
+
+- **`find_package(GeoUtilsCpp)` fails.** The install prefix isn't on
+ CMake's search path. Add `-DCMAKE_PREFIX_PATH=/path/to/prefix` to your
+ configure command (or set the env var).
+
+- **`FetchContent` re-downloads on every configure.** Add
+ `-DFETCHCONTENT_UPDATES_DISCONNECTED=ON` once the dependency is cached,
+ or pin to a specific `GIT_TAG` (which we already do in the snippet
+ above).
+
+- **`no matching function for call to 'area({{...}, ...})'`.** A braced
+ initializer can't be deduced as a `Path` template parameter — wrap it
+ in a `std::vector` first. See the Path notes in
+ [api.md](api.md#path).
+
+- **clangd doesn't see the headers.** Make sure CMake has generated
+ `compile_commands.json` (it does so automatically once you've
+ configured a build directory) and that your editor is pointed at it.
+
+## Next steps
+
+- [API reference](api.md) — every public function, with examples and
+ edge-case notes.
+- [Benchmarks](benchmarks.md) — how `geo-utils-cpp` compares to S2,
+ Boost.Geometry, and GeographicLib, and where each one is the right
+ tool.
+- [`examples/`](../examples/) — runnable code samples.
+- [Open an issue](https://github.com/gistrec/geo-utils-cpp/issues) if
+ something here doesn't work or doesn't make sense.