diff --git a/src/python/BUILD.bazel b/src/python/BUILD.bazel index deef4336..5d93a47c 100644 --- a/src/python/BUILD.bazel +++ b/src/python/BUILD.bazel @@ -36,6 +36,7 @@ pybind_extension( ":s2cell_bindings", ":s2cell_id_bindings", ":s2latlng_bindings", + ":s2latlng_rect_bindings", ":s2point_bindings", ], ) @@ -155,6 +156,16 @@ pybind_library( ], ) +pybind_library( + name = "s2latlng_rect_bindings", + srcs = ["s2latlng_rect_bindings.cc"], + deps = [ + "//:s2", + "@abseil-cpp//absl/hash", + "@abseil-cpp//absl/strings", + ], +) + pybind_library( name = "s2cell_bindings", srcs = ["s2cell_bindings.cc"], @@ -223,6 +234,12 @@ py_test( deps = [":s2geometry_pybind"], ) +py_test( + name = "s2latlng_rect_test", + srcs = ["s2latlng_rect_test.py"], + deps = [":s2geometry_pybind"], +) + py_test( name = "s2cell_id_test", srcs = ["s2cell_id_test.py"], diff --git a/src/python/module.cc b/src/python/module.cc index 10d53d88..c7d8f40d 100644 --- a/src/python/module.cc +++ b/src/python/module.cc @@ -14,6 +14,7 @@ void bind_s2cap(py::module& m); void bind_s2cell(py::module& m); void bind_s2cell_id(py::module& m); void bind_s2latlng(py::module& m); +void bind_s2latlng_rect(py::module& m); void bind_s2point(py::module& m); // keep-sorted end @@ -50,4 +51,7 @@ PYBIND11_MODULE(s2geometry_bindings, m) { // Deps: r2rect, s1chord_angle, s2cap, s2cell_id, s2latlng, s2point bind_s2cell(m); + + // Deps: r1interval, s1angle, s1interval, s2cell, s2cell_id, s2latlng, s2point + bind_s2latlng_rect(m); } diff --git a/src/python/s2latlng_rect_bindings.cc b/src/python/s2latlng_rect_bindings.cc new file mode 100644 index 00000000..2bad85dd --- /dev/null +++ b/src/python/s2latlng_rect_bindings.cc @@ -0,0 +1,385 @@ +#include +#include +#include + +#include +#include +#include +#include +#include + +#include "absl/hash/hash.h" +#include "absl/strings/str_cat.h" +#include "s2/r1interval.h" +#include "s2/s1angle.h" +#include "s2/s1interval.h" +#include "s2/s2cap.h" +#include "s2/s2cell.h" +#include "s2/s2cell_id.h" +#include "s2/s2latlng.h" +#include "s2/s2latlng_rect.h" +#include "s2/s2point.h" +#include "s2/s2pointutil.h" + +namespace py = pybind11; + +namespace { + +void MaybeThrowInvalidLatInterval(const R1Interval& lat) { + if (!std::isfinite(lat.lo()) || !std::isfinite(lat.hi()) || + std::fabs(lat.lo()) > M_PI_2 || std::fabs(lat.hi()) > M_PI_2) { + throw py::value_error(absl::StrCat( + "lat interval must be within [-pi/2, pi/2], got [", + lat.lo(), ", ", lat.hi(), "]")); + } +} + +void MaybeThrowEmptyMismatch(const R1Interval& lat, const S1Interval& lng) { + if (lat.is_empty() != lng.is_empty()) { + throw py::value_error( + "lat and lng intervals must both be empty or both non-empty"); + } +} + +void MaybeThrowLatNotOrdered(const S2LatLng& lo, const S2LatLng& hi) { + if (lo.lat() > hi.lat()) { + throw py::value_error(absl::StrCat( + "lo.lat() must be <= hi.lat(), got lo.lat()=", lo.lat().degrees(), + "°, hi.lat()=", hi.lat().degrees(), "°")); + } +} + +void MaybeThrowInvalidRect(const S2LatLngRect& rect, + const S2LatLng& lo, const S2LatLng& hi) { + if (!rect.is_valid()) { + throw py::value_error(absl::StrCat( + "Resulting rectangle is invalid: lo=(", lo.lat().degrees(), + "°, ", lo.lng().degrees(), "°), hi=(", hi.lat().degrees(), + "°, ", hi.lng().degrees(), "°)")); + } +} + +void MaybeThrowIfEmpty(const S2LatLngRect& rect) { + if (rect.is_empty()) { + throw py::value_error("rectangle must be non-empty"); + } +} + +void MaybeThrowNaN(S1Angle angle, const char* name) { + if (std::isnan(angle.radians())) { + throw py::value_error(absl::StrCat(name, " must not be NaN")); + } +} + +// Duplicated from s1chord_angle_bindings.cc (see also s2cell_bindings.cc). +void MaybeThrowNotUnitLength(const S2Point& p, const char* name) { + if (!S2::IsUnitLength(p)) { + throw py::value_error( + absl::StrCat(name, " must be a unit-length vector (norm=", + p.Norm(), ")")); + } +} + +} // namespace + +void bind_s2latlng_rect(py::module& m) { + py::class_(m, "S2LatLngRect", + "A closed latitude-longitude rectangle on the sphere.\n\n" + "Capable of representing the empty and full rectangles as well as\n" + "single points. Uses cylindrical topology: longitudes wrap at +/-180\n" + "degrees while latitudes are clamped to [-90, 90]. See\n" + "s2/s2latlng_rect.h for comprehensive documentation.") + + // Constructors + .def(py::init<>(), + "Construct an empty S2LatLngRect.") + .def(py::init([](const S2LatLng& lo, const S2LatLng& hi) { + MaybeThrowLatNotOrdered(lo, hi); + S2LatLngRect rect(lo, hi); + MaybeThrowInvalidRect(rect, lo, hi); + return rect; + }), + py::arg("lo"), py::arg("hi"), + "Construct a rectangle from lo and hi corner points.\n\n" + "If lo.lng() > hi.lng() the rectangle spans the 180-degree line.\n" + "lo.lat() must be <= hi.lat().\n\n" + "Raises ValueError if lo.lat() > hi.lat() or the resulting\n" + "rectangle is invalid (e.g. lo.lng == 180° and hi.lng == -180°).") + .def(py::init([](const R1Interval& lat, const S1Interval& lng) { + // Return canonical Empty() before checking bounds so that + // non-canonical empty R1Intervals (e.g. [0.5, -0.5]) don't + // produce a rect whose lo/hi differ from Empty() and break + // the == / __hash__ contract. + if (lat.is_empty() && lng.is_empty()) return S2LatLngRect::Empty(); + MaybeThrowInvalidLatInterval(lat); + MaybeThrowEmptyMismatch(lat, lng); + return S2LatLngRect(lat, lng); + }), + py::arg("lat"), py::arg("lng"), + "Construct a rectangle from latitude and longitude intervals.\n\n" + "Both intervals must be empty or both non-empty. The latitude\n" + "interval must lie within [-pi/2, pi/2] radians.\n\n" + "Raises ValueError if either condition is violated.") + + // Factory methods + .def_static("from_center_size", + [](const S2LatLng& center, S1Angle lat_size, S1Angle lng_size) { + MaybeThrowNaN(lat_size, "lat_size"); + MaybeThrowNaN(lng_size, "lng_size"); + // Sizes may exceed S2LatLng's valid range (e.g. 210°, 400°), so + // they are taken as separate angles and packed into an + // unvalidated C++ S2LatLng. + return S2LatLngRect::FromCenterSize( + center, S2LatLng(lat_size, lng_size)); + }, + py::arg("center"), py::arg("lat_size"), py::arg("lng_size"), + "Construct a rectangle of the given size centered around center.\n\n" + "The latitude interval is clamped to [-90, 90] degrees, and the\n" + "longitude interval is full if lng_size is 360 degrees or more.\n" + "from_center_size(r.center(), *r.size()) reconstructs r.\n\n" + "Raises ValueError if either size is NaN.") + .def_static("from_point", &S2LatLngRect::FromPoint, + py::arg("latlng"), + "Construct a rectangle containing a single point.") + .def_static("from_point_pair", &S2LatLngRect::FromPointPair, + py::arg("p1"), py::arg("p2"), + "Construct the minimal bounding rectangle containing two points.\n\n" + "Unlike the (lo, hi) constructor, the points may be given in\n" + "either order.") + .def_static("empty", &S2LatLngRect::Empty, + "Return an empty rectangle (contains no points).") + .def_static("full", &S2LatLngRect::Full, + "Return the full rectangle (contains all points).") + .def_static("full_lat", &S2LatLngRect::FullLat, + "Return the full allowable latitude range as an R1Interval.") + .def_static("full_lng", &S2LatLngRect::FullLng, + "Return the full allowable longitude range as an S1Interval.") + .def_static("from_latlngs", [](const std::vector& latlngs) { + // Replaces the mutable AddPoint pattern: callers pass a list and + // get back a new rectangle. S2LatLngRect() is Empty(), and + // AddPoint on an empty rect initializes it to that point. + S2LatLngRect result; + for (const S2LatLng& ll : latlngs) result.AddPoint(ll); + return result; + }, py::arg("latlngs"), + "Return the smallest rectangle containing all given S2LatLng points.\n\n" + "Returns the empty rectangle if the list is empty.") + + // Properties + .def_property_readonly("lat", &S2LatLngRect::lat, + "The latitude interval as an R1Interval (in radians).") + .def_property_readonly("lng", &S2LatLngRect::lng, + "The longitude interval as an S1Interval (in radians).") + .def_property_readonly("lat_lo", &S2LatLngRect::lat_lo, + "The minimum latitude as an S1Angle.") + .def_property_readonly("lat_hi", &S2LatLngRect::lat_hi, + "The maximum latitude as an S1Angle.") + .def_property_readonly("lng_lo", &S2LatLngRect::lng_lo, + "The minimum longitude as an S1Angle.") + .def_property_readonly("lng_hi", &S2LatLngRect::lng_hi, + "The maximum longitude as an S1Angle.") + .def_property_readonly("lo", &S2LatLngRect::lo, + "The lower-left corner as an S2LatLng.") + .def_property_readonly("hi", &S2LatLngRect::hi, + "The upper-right corner as an S2LatLng.") + + // Predicates + .def("is_empty", &S2LatLngRect::is_empty, + "Return true if the rectangle contains no points.") + .def("is_full", &S2LatLngRect::is_full, + "Return true if the rectangle contains all points.") + .def("is_point", &S2LatLngRect::is_point, + "Return true if the rectangle is a single point (lo == hi).") + .def("is_inverted", &S2LatLngRect::is_inverted, + "Return true if the longitude interval crosses the 180-degree line.") + + // Geometric operations + .def("vertex", &S2LatLngRect::GetVertex, py::arg("k"), + "Return the k-th vertex in CCW order.\n\n" + "Order: lower-left, lower-right, upper-right, upper-left.\n" + "k is reduced modulo 4, so vertex(4) == vertex(0).") + .def("center", &S2LatLngRect::GetCenter, + "Return the center in latitude-longitude space.\n\n" + "Note: this is not the center of the region on the sphere.") + .def("size", [](const S2LatLngRect& self) { + S2LatLng s = self.GetSize(); + // GetSize() returns extents that can exceed S2LatLng validity + // bounds (e.g. full().size() yields lat=180°, lng=360°), so + // expose as a pair of S1Angles rather than an S2LatLng. + return std::make_pair(s.lat(), s.lng()); + }, + "Return the (lat, lng) extents of this rectangle as a pair of S1Angles.\n\n" + "Both values are non-negative for non-empty rectangles. The values\n" + "can exceed 90° / 180° (e.g. full().size() returns (180°, 360°)),\n" + "so the result is a pair of S1Angles rather than an S2LatLng.\n" + "Empty rectangles have negative extents.") + .def("area", &S2LatLngRect::Area, + "Return the surface area of this rectangle on the unit sphere.") + .def("centroid", &S2LatLngRect::GetCentroid, + "Return the true centroid multiplied by its surface area.\n\n" + "The result is not unit length. The centroid may not be contained\n" + "by the rectangle.") + .def("contains", py::overload_cast( + &S2LatLngRect::Contains, py::const_), + py::arg("other"), + "Return true if this rectangle contains the given rectangle.") + .def("contains_latlng", py::overload_cast( + &S2LatLngRect::Contains, py::const_), + py::arg("latlng"), + "Return true if this rectangle contains the given S2LatLng.") + .def("contains_point", py::overload_cast( + &S2LatLngRect::Contains, py::const_), + py::arg("point"), + "Return true if this rectangle contains the given S2Point.\n\n" + "The point does not need to be normalized.") + .def("contains_cell", py::overload_cast( + &S2LatLngRect::Contains, py::const_), + py::arg("cell"), + "Return true if this rectangle contains the given cell.") + .def("interior_contains_point", py::overload_cast( + &S2LatLngRect::InteriorContains, py::const_), + py::arg("point"), + "Return true if the interior contains the given S2Point.\n\n" + "The point does not need to be normalized.") + .def("interior_contains_latlng", py::overload_cast( + &S2LatLngRect::InteriorContains, py::const_), + py::arg("latlng"), + "Return true if the interior contains the given S2LatLng.") + .def("interior_contains", py::overload_cast( + &S2LatLngRect::InteriorContains, py::const_), + py::arg("other"), + "Return true if the interior of this rectangle contains all\n" + "points of the given rectangle (including its boundary).") + .def("intersects", py::overload_cast( + &S2LatLngRect::Intersects, py::const_), + py::arg("other"), + "Return true if this rectangle and other have any points in common.") + .def("intersects_cell", py::overload_cast( + &S2LatLngRect::Intersects, py::const_), + py::arg("cell"), + "Return true if this rectangle intersects the given cell.\n\n" + "This is an exact test and may be expensive. Use may_intersect()\n" + "for a cheaper approximate test.") + .def("interior_intersects", &S2LatLngRect::InteriorIntersects, + py::arg("other"), + "Return true if the interior of this rectangle intersects\n" + "any point (including the boundary) of the given rectangle.") + .def("boundary_intersects", + [](const S2LatLngRect& self, const S2Point& v0, const S2Point& v1) { + MaybeThrowNotUnitLength(v0, "v0"); + MaybeThrowNotUnitLength(v1, "v1"); + return self.BoundaryIntersects(v0, v1); + }, + py::arg("v0"), py::arg("v1"), + "Return true if the boundary of this rectangle intersects\n" + "the given geodesic edge (v0, v1).\n\n" + "Raises ValueError if v0 or v1 is not unit-length.") + .def("may_intersect", &S2LatLngRect::MayIntersect, py::arg("cell"), + "Return true if this rectangle may intersect the given cell.\n\n" + "Cheap but not exact; use intersects_cell() for an exact test.") + .def("expanded", + [](const S2LatLngRect& self, S1Angle lat_margin, S1Angle lng_margin) { + MaybeThrowNaN(lat_margin, "lat_margin"); + MaybeThrowNaN(lng_margin, "lng_margin"); + // Margins may exceed S2LatLng's valid range (e.g. a 180° lat + // margin), so pack them into an unvalidated C++ S2LatLng. + return self.Expanded(S2LatLng(lat_margin, lng_margin)); + }, + py::arg("lat_margin"), py::arg("lng_margin"), + "Return this rectangle expanded by lat_margin on each latitude\n" + "side and lng_margin on each longitude side.\n\n" + "A negative margin shrinks the rectangle. Uses lat/lng space\n" + "topology (cylindrical). Use expanded_by_distance() to expand by\n" + "a spherical distance.\n\n" + "Raises ValueError if either margin is NaN.") + .def("polar_closure", &S2LatLngRect::PolarClosure, + "Return this rectangle with longitude range expanded to Full()\n" + "if it contains a pole, so that all representations of any\n" + "contained pole are included.") + .def("union", &S2LatLngRect::Union, py::arg("other"), + "Return the smallest rectangle containing the union of this\n" + "rectangle and other.") + .def("intersection", &S2LatLngRect::Intersection, py::arg("other"), + "Return the smallest rectangle containing the intersection of\n" + "this rectangle and other.\n\n" + "If the intersection consists of two disjoint rectangles, returns\n" + "a single rectangle spanning both.") + .def("expanded_by_distance", + [](const S2LatLngRect& self, S1Angle distance) { + MaybeThrowNaN(distance, "distance"); + // Clamp so S1ChordAngle(distance) never becomes Infinity(), + // which S2Cap's constructor rejects via DCHECK. + return self.ExpandedByDistance( + std::min(distance, S1Angle::Radians(M_PI))); + }, + py::arg("distance"), + "Return this rectangle expanded so that it contains all points\n" + "within the given spherical distance of its boundary.\n\n" + "A negative distance shrinks the rectangle instead. Unlike\n" + "expanded(), this method measures distances on the sphere.\n" + "Distances of 180 degrees or more (including infinity) expand a\n" + "non-empty rectangle to full.\n\n" + "Raises ValueError if distance is NaN.") + .def("distance", [](const S2LatLngRect& self, const S2LatLngRect& other) { + MaybeThrowIfEmpty(self); + MaybeThrowIfEmpty(other); + return self.GetDistance(other); + }, py::arg("other"), + "Return the minimum spherical distance to the given rectangle.\n\n" + "Raises ValueError if either rectangle is empty.") + .def("distance_latlng", + [](const S2LatLngRect& self, const S2LatLng& latlng) { + MaybeThrowIfEmpty(self); + return self.GetDistance(latlng); + }, py::arg("latlng"), + "Return the minimum spherical distance from this rectangle to\n" + "the given S2LatLng.\n\n" + "Raises ValueError if the rectangle is empty.") + .def("directed_hausdorff_distance", + py::overload_cast( + &S2LatLngRect::GetDirectedHausdorffDistance, py::const_), + py::arg("other"), + "Return the directed Hausdorff distance from this rectangle to other.\n\n" + "h(A,B) = max_{p in A} min_{q in B} d(p,q).") + .def("hausdorff_distance", &S2LatLngRect::GetHausdorffDistance, + py::arg("other"), + "Return the Hausdorff distance between this rectangle and other.\n\n" + "H(A,B) = max(h(A,B), h(B,A)).") + .def("cap_bound", &S2LatLngRect::GetCapBound, + "Return the smallest cap containing this rectangle.") + .def("rect_bound", &S2LatLngRect::GetRectBound, + "Return a bounding rectangle for this rectangle (returns self).") + .def("cell_union_bound", [](const S2LatLngRect& self) { + // GetCellUnionBound uses an output parameter; return by value instead. + std::vector cell_ids; + self.GetCellUnionBound(&cell_ids); + return cell_ids; + }, + "Return a list of S2CellIds whose union covers this rectangle.") + // Note: default value must match C++ signature in s2latlng_rect.h. + .def("approx_equals", py::overload_cast( + &S2LatLngRect::ApproxEquals, py::const_), + py::arg("other"), + py::arg("max_error") = S1Angle::Radians(1e-15), + "Return true if this rectangle is approximately equal to other.\n\n" + "Uses a uniform tolerance for both lat and lng.") + + // Operators + .def(py::self == py::self, "Return true if rectangles are equal.") + .def(py::self != py::self, "Return true if rectangles are not equal.") + .def("__hash__", [](const S2LatLngRect& self) { + return absl::HashOf(self); + }) + + // String representation + .def("__repr__", [](const S2LatLngRect& self) { + std::ostringstream oss; + oss << "S2LatLngRect(" << self << ")"; + return oss.str(); + }) + .def("__str__", [](const S2LatLngRect& self) { + std::ostringstream oss; + oss << self; + return oss.str(); + }); +} diff --git a/src/python/s2latlng_rect_test.py b/src/python/s2latlng_rect_test.py new file mode 100644 index 00000000..fec60a53 --- /dev/null +++ b/src/python/s2latlng_rect_test.py @@ -0,0 +1,595 @@ +"""Tests for S2LatLngRect pybind11 bindings.""" + +import math +import unittest +import s2geometry_pybind as s2 + + +class TestS2LatLngRect(unittest.TestCase): + """Test cases for S2LatLngRect bindings.""" + + # Constructors + + def test_default_constructor_is_empty(self): + rect = s2.S2LatLngRect() + self.assertTrue(rect.is_empty()) + self.assertFalse(rect.is_full()) + + def test_constructor_from_lo_hi(self): + lo = s2.S2LatLng.from_degrees(-10.0, -20.0) + hi = s2.S2LatLng.from_degrees(10.0, 20.0) + rect = s2.S2LatLngRect(lo, hi) + self.assertEqual(rect.lo, lo) + self.assertEqual(rect.hi, hi) + + def test_constructor_rejects_reversed_lat(self): + lo = s2.S2LatLng.from_degrees(10.0, -20.0) + hi = s2.S2LatLng.from_degrees(-10.0, 20.0) + with self.assertRaises(ValueError): + s2.S2LatLngRect(lo, hi) + + def test_constructor_from_intervals(self): + lat = s2.R1Interval(-0.5, 0.5) + lng = s2.S1Interval(-1.0, 1.0) + rect = s2.S2LatLngRect(lat, lng) + self.assertEqual(rect.lat, lat) + self.assertEqual(rect.lng, lng) + + def test_constructor_from_both_empty_intervals_is_canonical(self): + # Non-canonical empty R1Interval must produce canonical Empty(), so + # that == and __hash__ are consistent. + rect = s2.S2LatLngRect(s2.R1Interval(0.5, -0.5), s2.S1Interval.empty()) + self.assertEqual(rect, s2.S2LatLngRect.empty()) + self.assertEqual(hash(rect), hash(s2.S2LatLngRect.empty())) + + def test_constructor_rejects_lo_lng_180_hi_lng_neg180(self): + lo = s2.S2LatLng.from_degrees(0.0, 180.0) + hi = s2.S2LatLng.from_degrees(10.0, -180.0) + with self.assertRaises(ValueError): + s2.S2LatLngRect(lo, hi) + + def test_constructor_rejects_lat_out_of_range(self): + lat = s2.R1Interval(0.0, 2.0) # hi > pi/2 (~1.5708) + lng = s2.S1Interval(0.0, 1.0) + with self.assertRaises(ValueError): + s2.S2LatLngRect(lat, lng) + + def test_constructor_rejects_nan_lat(self): + lat = s2.R1Interval(float('nan'), float('nan')) + lng = s2.S1Interval(0.0, 1.0) + with self.assertRaises(ValueError): + s2.S2LatLngRect(lat, lng) + + def test_constructor_rejects_empty_mismatch(self): + lat = s2.R1Interval.empty() + lng = s2.S1Interval(0.0, 1.0) # non-empty lng with empty lat + with self.assertRaises(ValueError): + s2.S2LatLngRect(lat, lng) + + # Factory methods + + def test_from_center_size(self): + center = s2.S2LatLng.from_degrees(10.0, 20.0) + rect = s2.S2LatLngRect.from_center_size( + center, s2.S1Angle.from_degrees(4.0), s2.S1Angle.from_degrees(6.0)) + self.assertTrue(rect.contains_latlng(center)) + self.assertAlmostEqual(rect.lat_lo.degrees, 8.0) + self.assertAlmostEqual(rect.lat_hi.degrees, 12.0) + self.assertAlmostEqual(rect.lng_lo.degrees, 17.0) + self.assertAlmostEqual(rect.lng_hi.degrees, 23.0) + + def test_from_center_size_exceeds_latlng_validity_range(self): + # Example from s2latlng_rect.h: sizes beyond 180°/360° clamp to full. + center = s2.S2LatLng.from_degrees(10.0, 40.0) + rect = s2.S2LatLngRect.from_center_size( + center, s2.S1Angle.from_degrees(210.0), + s2.S1Angle.from_degrees(400.0)) + self.assertTrue(rect.is_full()) + + def test_from_center_size_round_trips_size(self): + rect = s2.S2LatLngRect(s2.S2LatLng.from_degrees(-80.0, -170.0), + s2.S2LatLng.from_degrees(70.0, 150.0)) + rebuilt = s2.S2LatLngRect.from_center_size(rect.center(), *rect.size()) + self.assertTrue(rebuilt.approx_equals(rect, s2.S1Angle.from_radians(1e-14))) + + def test_from_center_size_rejects_nan(self): + center = s2.S2LatLng.from_degrees(0.0, 0.0) + nan = s2.S1Angle.from_radians(math.nan) + one = s2.S1Angle.from_degrees(1.0) + with self.assertRaises(ValueError): + s2.S2LatLngRect.from_center_size(center, nan, one) + with self.assertRaises(ValueError): + s2.S2LatLngRect.from_center_size(center, one, nan) + + def test_from_point(self): + p = s2.S2LatLng.from_degrees(45.0, 90.0) + rect = s2.S2LatLngRect.from_point(p) + self.assertTrue(rect.is_point()) + self.assertTrue(rect.contains_latlng(p)) + + def test_from_point_pair(self): + # Ordered points. + p1 = s2.S2LatLng.from_degrees(-10.0, -20.0) + p2 = s2.S2LatLng.from_degrees(10.0, 20.0) + rect = s2.S2LatLngRect.from_point_pair(p1, p2) + self.assertTrue(rect.contains_latlng(p1)) + self.assertTrue(rect.contains_latlng(p2)) + # Reversed order must produce the same result. + self.assertEqual(s2.S2LatLngRect.from_point_pair(p2, p1), rect) + + def test_from_latlngs_empty(self): + rect = s2.S2LatLngRect.from_latlngs([]) + self.assertTrue(rect.is_empty()) + + def test_from_latlngs_single(self): + ll = s2.S2LatLng.from_degrees(10.0, 20.0) + rect = s2.S2LatLngRect.from_latlngs([ll]) + self.assertTrue(rect.contains_latlng(ll)) + self.assertTrue(rect.is_point()) + + def test_from_latlngs_multiple(self): + lls = [ + s2.S2LatLng.from_degrees(-10.0, -20.0), + s2.S2LatLng.from_degrees(10.0, 20.0), + s2.S2LatLng.from_degrees(5.0, 0.0), + ] + rect = s2.S2LatLngRect.from_latlngs(lls) + for ll in lls: + self.assertTrue(rect.contains_latlng(ll)) + + def test_empty(self): + self.assertTrue(s2.S2LatLngRect.empty().is_empty()) + self.assertFalse(s2.S2LatLngRect.empty().is_full()) + + def test_full(self): + self.assertTrue(s2.S2LatLngRect.full().is_full()) + self.assertFalse(s2.S2LatLngRect.full().is_empty()) + + def test_full_lat(self): + lat = s2.S2LatLngRect.full_lat() + self.assertAlmostEqual(lat.lo, -math.pi / 2) + self.assertAlmostEqual(lat.hi, math.pi / 2) + + def test_full_lng(self): + lng = s2.S2LatLngRect.full_lng() + self.assertTrue(lng.is_full()) + + # Properties + + def test_lat_property(self): + lo = s2.S2LatLng.from_degrees(-10.0, 0.0) + hi = s2.S2LatLng.from_degrees(10.0, 0.0) + rect = s2.S2LatLngRect(lo, hi) + self.assertAlmostEqual(rect.lat.lo, lo.lat.radians) + self.assertAlmostEqual(rect.lat.hi, hi.lat.radians) + + def test_lng_property(self): + lo = s2.S2LatLng.from_degrees(0.0, -30.0) + hi = s2.S2LatLng.from_degrees(0.0, 30.0) + rect = s2.S2LatLngRect(lo, hi) + self.assertAlmostEqual(rect.lng.lo, lo.lng.radians) + self.assertAlmostEqual(rect.lng.hi, hi.lng.radians) + + def test_lat_lo_lat_hi(self): + lo = s2.S2LatLng.from_degrees(-10.0, 0.0) + hi = s2.S2LatLng.from_degrees(20.0, 0.0) + rect = s2.S2LatLngRect(lo, hi) + self.assertAlmostEqual(rect.lat_lo.degrees, -10.0) + self.assertAlmostEqual(rect.lat_hi.degrees, 20.0) + + def test_lng_lo_lng_hi(self): + lo = s2.S2LatLng.from_degrees(0.0, -30.0) + hi = s2.S2LatLng.from_degrees(0.0, 60.0) + rect = s2.S2LatLngRect(lo, hi) + self.assertAlmostEqual(rect.lng_lo.degrees, -30.0) + self.assertAlmostEqual(rect.lng_hi.degrees, 60.0) + + def test_lo_hi(self): + lo = s2.S2LatLng.from_degrees(-10.0, -20.0) + hi = s2.S2LatLng.from_degrees(10.0, 20.0) + rect = s2.S2LatLngRect(lo, hi) + self.assertEqual(rect.lo, lo) + self.assertEqual(rect.hi, hi) + + # Predicates + + def test_is_empty(self): + self.assertTrue(s2.S2LatLngRect.empty().is_empty()) + self.assertFalse(s2.S2LatLngRect.full().is_empty()) + + def test_is_full(self): + self.assertTrue(s2.S2LatLngRect.full().is_full()) + self.assertFalse(s2.S2LatLngRect.empty().is_full()) + + def test_is_point(self): + p = s2.S2LatLng.from_degrees(45.0, 90.0) + self.assertTrue(s2.S2LatLngRect.from_point(p).is_point()) + self.assertFalse(s2.S2LatLngRect.full().is_point()) + + def test_is_inverted(self): + # A rectangle spanning the 180-degree line has lo.lng > hi.lng + lo = s2.S2LatLng.from_degrees(0.0, 170.0) + hi = s2.S2LatLng.from_degrees(10.0, -170.0) + rect = s2.S2LatLngRect(lo, hi) + self.assertTrue(rect.is_inverted()) + + def test_inverted_rect(self): + # A rect where lng_lo > lng_hi spans the antimeridian (is "inverted"). + lo = s2.S2LatLng.from_degrees(-10, 160) + hi = s2.S2LatLng.from_degrees(10, -160) + rect = s2.S2LatLngRect(lo, hi) + # A point at lng=175 is inside (between 160 and -160 via antimeridian). + self.assertTrue(rect.contains_latlng(s2.S2LatLng.from_degrees(0, 175))) + # A point at lng=0 is outside. + self.assertFalse(rect.contains_latlng(s2.S2LatLng.from_degrees(0, 0))) + + # Geometric operations + + def test_vertex(self): + lo = s2.S2LatLng.from_degrees(-10.0, -20.0) + hi = s2.S2LatLng.from_degrees(10.0, 20.0) + rect = s2.S2LatLngRect(lo, hi) + # Vertices in CCW order: lower-left, lower-right, upper-right, upper-left. + expected = [(-10.0, -20.0), (-10.0, 20.0), (10.0, 20.0), (10.0, -20.0)] + for k, (lat, lng) in enumerate(expected): + v = rect.vertex(k) + self.assertAlmostEqual(v.lat.degrees, lat) + self.assertAlmostEqual(v.lng.degrees, lng) + + def test_vertex_modulo_4(self): + rect = s2.S2LatLngRect.full() + self.assertEqual(rect.vertex(0), rect.vertex(4)) + self.assertEqual(rect.vertex(3), rect.vertex(-1)) + + def test_center(self): + lo = s2.S2LatLng.from_degrees(-10.0, -20.0) + hi = s2.S2LatLng.from_degrees(10.0, 20.0) + rect = s2.S2LatLngRect(lo, hi) + c = rect.center() + self.assertAlmostEqual(c.lat.degrees, 0.0) + self.assertAlmostEqual(c.lng.degrees, 0.0) + + def test_size(self): + lo = s2.S2LatLng.from_degrees(-10.0, -20.0) + hi = s2.S2LatLng.from_degrees(10.0, 20.0) + rect = s2.S2LatLngRect(lo, hi) + lat_ext, lng_ext = rect.size() + self.assertAlmostEqual(lat_ext.degrees, 20.0) + self.assertAlmostEqual(lng_ext.degrees, 40.0) + + def test_size_full_exceeds_latlng_validity_range(self): + # full().size() returns (180°, 360°), which is outside S2LatLng validity + # bounds — confirmed by checking the pair of S1Angles directly. + lat_ext, lng_ext = s2.S2LatLngRect.full().size() + self.assertAlmostEqual(lat_ext.degrees, 180.0) + self.assertAlmostEqual(lng_ext.degrees, 360.0) + + def test_area(self): + self.assertAlmostEqual(s2.S2LatLngRect.full().area(), 4 * math.pi) + self.assertAlmostEqual(s2.S2LatLngRect.empty().area(), 0.0) + + def test_centroid(self): + # Full sphere is symmetric; centroid (area-weighted) is the zero vector. + centroid = s2.S2LatLngRect.full().centroid() + self.assertAlmostEqual(centroid.x, 0.0) + self.assertAlmostEqual(centroid.y, 0.0) + self.assertAlmostEqual(centroid.z, 0.0) + + def test_contains_rect(self): + outer = s2.S2LatLngRect.full() + inner = s2.S2LatLngRect.from_point(s2.S2LatLng.from_degrees(0.0, 0.0)) + self.assertTrue(outer.contains(inner)) + self.assertFalse(inner.contains(outer)) + + def test_contains_latlng(self): + lo = s2.S2LatLng.from_degrees(-10.0, -20.0) + hi = s2.S2LatLng.from_degrees(10.0, 20.0) + rect = s2.S2LatLngRect(lo, hi) + self.assertTrue(rect.contains_latlng(s2.S2LatLng.from_degrees(0.0, 0.0))) + self.assertFalse(rect.contains_latlng(s2.S2LatLng.from_degrees(45.0, 0.0))) + + def test_contains_point(self): + lo = s2.S2LatLng.from_degrees(-10.0, -20.0) + hi = s2.S2LatLng.from_degrees(10.0, 20.0) + rect = s2.S2LatLngRect(lo, hi) + self.assertTrue(rect.contains_point(s2.S2Point(1.0, 0.0, 0.0))) + # Point on the equator at lng=45° is outside. + outside = s2.S2LatLng.from_degrees(0.0, 45.0).to_point() + self.assertFalse(rect.contains_point(outside)) + + def test_contains_point_accepts_non_unit_length(self): + rect = s2.S2LatLngRect(s2.S2LatLng.from_degrees(-10.0, -20.0), + s2.S2LatLng.from_degrees(10.0, 20.0)) + self.assertTrue(rect.contains_point(s2.S2Point(2.0, 0.0, 0.0))) + self.assertFalse(rect.contains_point(s2.S2Point(-2.0, 0.0, 0.0))) + + def test_contains_cell(self): + rect = s2.S2LatLngRect.full() + cell = s2.S2Cell(s2.S2CellId.from_face(0)) + self.assertTrue(rect.contains_cell(cell)) + small = s2.S2LatLngRect(s2.S2LatLng.from_degrees(0.0, 0.0), + s2.S2LatLng.from_degrees(1.0, 1.0)) + self.assertFalse(small.contains_cell(cell)) + + def test_interior_contains_point(self): + lo = s2.S2LatLng.from_degrees(-10.0, -20.0) + hi = s2.S2LatLng.from_degrees(10.0, 20.0) + rect = s2.S2LatLngRect(lo, hi) + self.assertTrue(rect.interior_contains_point(s2.S2Point(1.0, 0.0, 0.0))) + # A point on the boundary is not in the interior. + boundary = s2.S2LatLng.from_degrees(10.0, 0.0).to_point() + self.assertFalse(rect.interior_contains_point(boundary)) + + def test_interior_contains_point_accepts_non_unit_length(self): + rect = s2.S2LatLngRect(s2.S2LatLng.from_degrees(-10.0, -20.0), + s2.S2LatLng.from_degrees(10.0, 20.0)) + self.assertTrue(rect.interior_contains_point(s2.S2Point(2.0, 0.0, 0.0))) + self.assertFalse( + rect.interior_contains_point(s2.S2Point(-2.0, 0.0, 0.0))) + + def test_interior_contains_latlng(self): + lo = s2.S2LatLng.from_degrees(-10.0, -20.0) + hi = s2.S2LatLng.from_degrees(10.0, 20.0) + rect = s2.S2LatLngRect(lo, hi) + self.assertTrue( + rect.interior_contains_latlng(s2.S2LatLng.from_degrees(0.0, 0.0))) + self.assertFalse( + rect.interior_contains_latlng(s2.S2LatLng.from_degrees(10.0, 0.0))) + + def test_interior_contains_rect(self): + outer = s2.S2LatLngRect.full() + inner = s2.S2LatLngRect.from_point(s2.S2LatLng.from_degrees(0.0, 0.0)) + self.assertTrue(outer.interior_contains(inner)) + # A rect does not interior-contain itself (boundary touches boundary). + self.assertFalse(inner.interior_contains(inner)) + + def test_intersects_rect(self): + lo = s2.S2LatLng.from_degrees(-10.0, -20.0) + hi = s2.S2LatLng.from_degrees(10.0, 20.0) + rect = s2.S2LatLngRect(lo, hi) + self.assertTrue(rect.intersects(rect)) + self.assertFalse(rect.intersects(s2.S2LatLngRect.empty())) + + def test_intersects_cell(self): + rect = s2.S2LatLngRect.full() + cell = s2.S2Cell(s2.S2CellId.from_face(0)) + self.assertTrue(rect.intersects_cell(cell)) + self.assertFalse(s2.S2LatLngRect.empty().intersects_cell(cell)) + + def test_interior_intersects(self): + lo = s2.S2LatLng.from_degrees(-10.0, -20.0) + hi = s2.S2LatLng.from_degrees(10.0, 20.0) + rect = s2.S2LatLngRect(lo, hi) + self.assertTrue(rect.interior_intersects(rect)) + # A rect whose boundary only touches does not interior-intersect. + touching = s2.S2LatLngRect(s2.S2LatLng.from_degrees(10.0, -20.0), + s2.S2LatLng.from_degrees(20.0, 20.0)) + self.assertFalse(rect.interior_intersects(touching)) + + def test_boundary_intersects(self): + rect = s2.S2LatLngRect.full() + p1 = s2.S2Point(1.0, 0.0, 0.0) + p2 = s2.S2Point(0.0, 1.0, 0.0) + # Full rect has no boundary on the sphere, so this returns False. + self.assertFalse(rect.boundary_intersects(p1, p2)) + + rect2 = s2.S2LatLngRect(s2.S2LatLng.from_degrees(-10, -20), + s2.S2LatLng.from_degrees(10, 20)) + # An edge crossing the boundary: one point inside, one outside. + p_in = s2.S2LatLng.from_degrees(0, 0).to_point() + p_out = s2.S2LatLng.from_degrees(0, 45).to_point() + self.assertTrue(rect2.boundary_intersects(p_in, p_out)) + # An edge entirely inside: both points inside. + p_a = s2.S2LatLng.from_degrees(-5, -10).to_point() + p_b = s2.S2LatLng.from_degrees(5, 10).to_point() + self.assertFalse(rect2.boundary_intersects(p_a, p_b)) + + def test_boundary_intersects_rejects_non_unit_length(self): + rect = s2.S2LatLngRect(s2.S2LatLng.from_degrees(-10, -20), + s2.S2LatLng.from_degrees(10, 20)) + unit = s2.S2Point(1.0, 0.0, 0.0) + non_unit = s2.S2Point(2.0, 0.0, 0.0) + with self.assertRaises(ValueError): + rect.boundary_intersects(non_unit, unit) + with self.assertRaises(ValueError): + rect.boundary_intersects(unit, non_unit) + + def test_may_intersect(self): + rect = s2.S2LatLngRect.full() + cell = s2.S2Cell(s2.S2CellId.from_face(0)) + self.assertTrue(rect.may_intersect(cell)) + self.assertFalse(s2.S2LatLngRect.empty().may_intersect(cell)) + + def test_expanded(self): + lo = s2.S2LatLng.from_degrees(-10.0, -20.0) + hi = s2.S2LatLng.from_degrees(10.0, 20.0) + rect = s2.S2LatLngRect(lo, hi) + expanded = rect.expanded(s2.S1Angle.from_degrees(5.0), + s2.S1Angle.from_degrees(7.0)) + self.assertTrue(expanded.contains(rect)) + self.assertAlmostEqual(expanded.lat_hi.degrees, 15.0) + self.assertAlmostEqual(expanded.lng_hi.degrees, 27.0) + + def test_expanded_lat_margin_beyond_90(self): + # A 180° lat margin grows a south-pole point to the full latitude range. + south = s2.S2LatLngRect.from_point(s2.S2LatLng.from_degrees(-90.0, 0.0)) + expanded = south.expanded(s2.S1Angle.from_degrees(180.0), + s2.S1Angle.from_degrees(0.0)) + self.assertEqual(expanded.lat, s2.S2LatLngRect.full_lat()) + + def test_expanded_shrinks_full_to_empty(self): + shrunk = s2.S2LatLngRect.full().expanded(s2.S1Angle.from_degrees(-91.0), + s2.S1Angle.from_degrees(0.0)) + self.assertTrue(shrunk.is_empty()) + + def test_expanded_rejects_nan(self): + rect = s2.S2LatLngRect.full() + nan = s2.S1Angle.from_radians(math.nan) + zero = s2.S1Angle.from_degrees(0.0) + with self.assertRaises(ValueError): + rect.expanded(nan, zero) + with self.assertRaises(ValueError): + rect.expanded(zero, nan) + + def test_polar_closure(self): + self.assertTrue(s2.S2LatLngRect.full().polar_closure().is_full()) + # A rect touching the north pole with partial lng should expand to full lng. + polar = s2.S2LatLngRect(s2.S2LatLng.from_degrees(80.0, 10.0), + s2.S2LatLng.from_degrees(90.0, 20.0)) + self.assertTrue(polar.polar_closure().lng.is_full()) + # A rect not touching a pole should be unchanged. + mid = s2.S2LatLngRect(s2.S2LatLng.from_degrees(0.0, 0.0), + s2.S2LatLng.from_degrees(10.0, 10.0)) + self.assertEqual(mid.polar_closure(), mid) + + def test_union(self): + p1 = s2.S2LatLng.from_degrees(-10.0, -10.0) + p2 = s2.S2LatLng.from_degrees(10.0, 10.0) + r1 = s2.S2LatLngRect.from_point(p1) + r2 = s2.S2LatLngRect.from_point(p2) + u = r1.union(r2) + self.assertTrue(u.contains_latlng(p1)) + self.assertTrue(u.contains_latlng(p2)) + + def test_intersection(self): + lo1 = s2.S2LatLng.from_degrees(-10.0, -20.0) + hi1 = s2.S2LatLng.from_degrees(10.0, 20.0) + lo2 = s2.S2LatLng.from_degrees(0.0, 0.0) + hi2 = s2.S2LatLng.from_degrees(20.0, 30.0) + r1 = s2.S2LatLngRect(lo1, hi1) + r2 = s2.S2LatLngRect(lo2, hi2) + inter = r1.intersection(r2) + self.assertTrue(r1.contains(inter)) + self.assertTrue(r2.contains(inter)) + + def test_expanded_by_distance(self): + lo = s2.S2LatLng.from_degrees(-10.0, -20.0) + hi = s2.S2LatLng.from_degrees(10.0, 20.0) + rect = s2.S2LatLngRect(lo, hi) + distance = s2.S1Angle.from_radians(0.1) + expanded = rect.expanded_by_distance(distance) + self.assertTrue(expanded.contains(rect)) + + def test_expanded_by_distance_infinity_is_full(self): + rect = s2.S2LatLngRect.from_point(s2.S2LatLng.from_degrees(10.0, 20.0)) + self.assertTrue( + rect.expanded_by_distance(s2.S1Angle.infinity()).is_full()) + + def test_expanded_by_distance_rejects_nan(self): + with self.assertRaises(ValueError): + s2.S2LatLngRect.full().expanded_by_distance( + s2.S1Angle.from_radians(math.nan)) + + def test_distance(self): + # Two rects separated by exactly 10° in latitude; closest points are + # hi1.lat=20° and lo2.lat=30°, so distance is 10° in radians. + lo1 = s2.S2LatLng.from_degrees(10.0, 0.0) + hi1 = s2.S2LatLng.from_degrees(20.0, 10.0) + lo2 = s2.S2LatLng.from_degrees(30.0, 0.0) + hi2 = s2.S2LatLng.from_degrees(40.0, 10.0) + r1 = s2.S2LatLngRect(lo1, hi1) + r2 = s2.S2LatLngRect(lo2, hi2) + d = r1.distance(r2) + self.assertAlmostEqual(d.degrees, 10.0, places=10) + + def test_distance_rejects_empty(self): + full = s2.S2LatLngRect.full() + empty = s2.S2LatLngRect.empty() + with self.assertRaises(ValueError): + empty.distance(full) + with self.assertRaises(ValueError): + full.distance(empty) + + def test_distance_latlng(self): + lo = s2.S2LatLng.from_degrees(-10.0, -10.0) + hi = s2.S2LatLng.from_degrees(10.0, 10.0) + rect = s2.S2LatLngRect(lo, hi) + # Interior point: distance is 0. + self.assertAlmostEqual( + rect.distance_latlng(s2.S2LatLng.from_degrees(0.0, 0.0)).radians, 0.0) + # Exterior point due north: closest boundary is at lat=10°, distance=10°. + d = rect.distance_latlng(s2.S2LatLng.from_degrees(20.0, 0.0)) + self.assertAlmostEqual(d.degrees, 10.0, places=10) + + def test_distance_latlng_rejects_empty(self): + p = s2.S2LatLng.from_degrees(0.0, 0.0) + with self.assertRaises(ValueError): + s2.S2LatLngRect.empty().distance_latlng(p) + + def test_directed_hausdorff_distance(self): + lo = s2.S2LatLng.from_degrees(-10.0, -10.0) + hi = s2.S2LatLng.from_degrees(10.0, 10.0) + r2 = s2.S2LatLngRect(lo, hi) + # Every point in r2 is in the full rect, so the directed distance is 0. + d = r2.directed_hausdorff_distance(s2.S2LatLngRect.full()) + self.assertAlmostEqual(d.radians, 0.0) + + def test_hausdorff_distance(self): + lo = s2.S2LatLng.from_degrees(-10.0, -10.0) + hi = s2.S2LatLng.from_degrees(10.0, 10.0) + rect = s2.S2LatLngRect(lo, hi) + d = rect.hausdorff_distance(rect) + self.assertAlmostEqual(d.radians, 0.0) + + def test_cap_bound(self): + bound = s2.S2LatLngRect.full().cap_bound() + self.assertTrue(bound.is_full()) + + def test_rect_bound(self): + lo = s2.S2LatLng.from_degrees(-10.0, -20.0) + hi = s2.S2LatLng.from_degrees(10.0, 20.0) + rect = s2.S2LatLngRect(lo, hi) + self.assertTrue(rect.rect_bound().approx_equals(rect)) + + def test_cell_union_bound(self): + cell_ids = s2.S2LatLngRect.full().cell_union_bound() + self.assertEqual(cell_ids, [s2.S2CellId.from_face(i) for i in range(6)]) + + # Operators + + def test_equality(self): + lo = s2.S2LatLng.from_degrees(-10.0, -20.0) + hi = s2.S2LatLng.from_degrees(10.0, 20.0) + r1 = s2.S2LatLngRect(lo, hi) + r2 = s2.S2LatLngRect(lo, hi) + self.assertTrue(r1 == r2) + self.assertFalse(r1 != r2) + + def test_inequality(self): + r1 = s2.S2LatLngRect.empty() + r2 = s2.S2LatLngRect.full() + self.assertTrue(r1 != r2) + self.assertFalse(r1 == r2) + + def test_approx_equals(self): + lo = s2.S2LatLng.from_degrees(-10.0, -20.0) + hi = s2.S2LatLng.from_degrees(10.0, 20.0) + rect = s2.S2LatLngRect(lo, hi) + self.assertTrue(rect.approx_equals(rect)) + self.assertFalse(rect.approx_equals(s2.S2LatLngRect.full())) + + def test_hash(self): + lo = s2.S2LatLng.from_degrees(-10.0, -20.0) + hi = s2.S2LatLng.from_degrees(10.0, 20.0) + r1 = s2.S2LatLngRect(lo, hi) + r2 = s2.S2LatLngRect(lo, hi) + self.assertEqual(hash(r1), hash(r2)) + + # String representation + + def test_repr(self): + rect = s2.S2LatLngRect.empty() + r = repr(rect) + self.assertTrue(r.startswith("S2LatLngRect(")) + self.assertIn("Lo", r) + self.assertIn("Hi", r) + + def test_str(self): + rect = s2.S2LatLngRect.empty() + s = str(rect) + self.assertIn("Lo", s) + self.assertIn("Hi", s) + + +if __name__ == "__main__": + unittest.main()