diff --git a/src/python/BUILD.bazel b/src/python/BUILD.bazel index b6b67d1e..301225c8 100644 --- a/src/python/BUILD.bazel +++ b/src/python/BUILD.bazel @@ -32,6 +32,7 @@ pybind_extension( ":s1angle_bindings", ":s1chord_angle_bindings", ":s1interval_bindings", + ":s2cap_bindings", ":s2cell_bindings", ":s2cell_id_bindings", ":s2latlng_bindings", @@ -124,6 +125,15 @@ pybind_library( ], ) +pybind_library( + name = "s2cap_bindings", + srcs = ["s2cap_bindings.cc"], + deps = [ + "//:s2", + "@abseil-cpp//absl/hash", + ], +) + pybind_library( name = "s2cell_bindings", srcs = ["s2cell_bindings.cc"], @@ -186,6 +196,12 @@ py_test( deps = [":s2geometry_pybind"], ) +py_test( + name = "s2cap_test", + srcs = ["s2cap_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 cbb0a176..10d53d88 100644 --- a/src/python/module.cc +++ b/src/python/module.cc @@ -10,6 +10,7 @@ void bind_r2rect(py::module& m); void bind_s1angle(py::module& m); void bind_s1chord_angle(py::module& m); void bind_s1interval(py::module& m); +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); @@ -38,12 +39,15 @@ PYBIND11_MODULE(s2geometry_bindings, m) { // Deps: s1angle, s2point bind_s1chord_angle(m); + // Deps: s1angle, s1chord_angle, s2point + bind_s2cap(m); + // Deps: s1angle, s2point bind_s2latlng(m); // Deps: s1angle, s2point, s2latlng, r2point, r2rect bind_s2cell_id(m); - // Deps: r2rect, s1chord_angle, s2cell_id, s2latlng, s2point + // Deps: r2rect, s1chord_angle, s2cap, s2cell_id, s2latlng, s2point bind_s2cell(m); } diff --git a/src/python/s2cap_bindings.cc b/src/python/s2cap_bindings.cc new file mode 100644 index 00000000..d4c44850 --- /dev/null +++ b/src/python/s2cap_bindings.cc @@ -0,0 +1,184 @@ +#include +#include +#include + +#include +#include + +#include "absl/hash/hash.h" + +#include "s2/s1angle.h" +#include "s2/s1chord_angle.h" +#include "s2/s2cap.h" +#include "s2/s2cell.h" +#include "s2/s2cell_id.h" +#include "s2/s2point.h" + +namespace py = pybind11; + +void bind_s2cap(py::module& m) { + py::class_(m, "S2Cap", + "A disc-shaped region defined by a center and radius on the sphere.\n\n" + "The cap represents a portion of the unit sphere cut off by a plane.\n" + "The boundary is a circle; the cap is a closed set (contains its\n" + "boundary). A cap of radius Pi/2 is a hemisphere; Pi covers the full\n" + "sphere. See s2/s2cap.h for comprehensive documentation.") + + // Constructors + .def(py::init<>(), + "Construct an empty S2Cap.") + .def(py::init([](const S2Point& center, S1Angle radius) { + return S2Cap(center.Normalize(), radius); + }), + py::arg("center"), py::arg("radius"), + "Construct a cap with the given center and radius.\n\n" + "center is normalized if not already unit length. Negative radius\n" + "produces the empty cap; radius >= 180 degrees produces the full cap.") + .def(py::init([](const S2Point& center, S1ChordAngle radius) { + return S2Cap(center.Normalize(), radius); + }), + py::arg("center"), py::arg("radius"), + "Construct a cap with the given center and radius as S1ChordAngle.\n\n" + "center is normalized if not already unit length.") + + // Factory methods + .def_static("from_point", [](const S2Point& center) { + return S2Cap::FromPoint(center.Normalize()); + }, py::arg("center"), + "Return a cap containing a single point.\n\n" + "center is normalized if not already unit length.") + .def_static("from_center_height", [](const S2Point& center, double height) { + return S2Cap::FromCenterHeight(center.Normalize(), height); + }, py::arg("center"), py::arg("height"), + "Return a cap with the given center and height.\n\n" + "Height is the distance from the center point to the cutoff plane.\n" + "center is normalized if not already unit length.\n" + "A negative height yields an empty cap; height >= 2 yields a full cap.") + .def_static("from_center_area", [](const S2Point& center, double area) { + return S2Cap::FromCenterArea(center.Normalize(), area); + }, py::arg("center"), py::arg("area"), + "Return a cap with the given center and surface area in steradians.\n\n" + "The area also equals the solid angle subtended by the cap.\n" + "center is normalized if not already unit length.\n" + "A negative area yields an empty cap; area >= 4*Pi yields a full cap.") + .def_static("empty", &S2Cap::Empty, + "Return an empty cap (contains no points).") + .def_static("full", &S2Cap::Full, + "Return a full cap (contains all points).") + .def_static("from_points", [](const std::vector& points) { + if (points.empty()) return S2Cap::Empty(); + S2Cap result = S2Cap::FromPoint(points[0]); + for (size_t i = 1; i < points.size(); ++i) result.AddPoint(points[i]); + return result; + }, py::arg("points"), + "Return the smallest cap containing all given points.\n\n" + "Returns the empty cap if the list is empty.") + .def_static("from_caps", [](const std::vector& caps) { + if (caps.empty()) return S2Cap::Empty(); + S2Cap result = caps[0]; + for (size_t i = 1; i < caps.size(); ++i) result.AddCap(caps[i]); + return result; + }, py::arg("caps"), + "Return the smallest cap containing all given caps.\n\n" + "Returns the empty cap if the list is empty.") + + // Properties + .def_property_readonly("center", &S2Cap::center, + "The center of the cap as a unit-length S2Point.") + .def_property_readonly("radius", &S2Cap::radius, + "The radius as an S1ChordAngle.") + .def_property_readonly("height", &S2Cap::height, + "The height of the cap (distance from center point to cutoff plane).") + + // Geometric operations + .def("radius_angle", &S2Cap::GetRadius, + "Return the radius as an S1Angle.\n\n" + "Requires a trigonometric operation; may differ slightly from the\n" + "value passed to the S1Angle constructor.") + .def("area", &S2Cap::GetArea, + "Return the surface area of the cap in steradians.") + .def("centroid", &S2Cap::GetCentroid, + "Return the true centroid of the cap multiplied by its surface area.\n\n" + "The result lies on the ray from the origin through the cap's center.\n" + "For zero-radius caps, always returns the origin (0, 0, 0).") + + // Predicates + .def("is_empty", &S2Cap::is_empty, + "Return true if the cap contains no points.") + .def("is_full", &S2Cap::is_full, + "Return true if the cap contains all points.") + + .def("complement", &S2Cap::Complement, + "Return the complement of the interior of the cap.\n\n" + "Same boundary as this cap but no shared interior points.\n" + "Note: complement of a singleton equals complement of an empty cap.") + .def("expanded", &S2Cap::Expanded, py::arg("distance"), + "Return a cap containing all points within distance of this cap.\n\n" + "Any expansion of an empty cap is still empty.") + .def("union", &S2Cap::Union, py::arg("other"), + "Return the smallest cap enclosing this cap and other.") + + // Containment / intersection + .def("contains", py::overload_cast( + &S2Cap::Contains, py::const_), + py::arg("other"), + "Return true if this cap contains the given cap.") + .def("contains_point", py::overload_cast( + &S2Cap::Contains, py::const_), + py::arg("point"), + "Return true if this cap contains the given point.\n\n" + "point should be unit length.") + .def("contains_cell", py::overload_cast( + &S2Cap::Contains, py::const_), + py::arg("cell"), + "Return true if this cap contains the given cell.") + .def("intersects", py::overload_cast( + &S2Cap::Intersects, py::const_), + py::arg("other"), + "Return true if this cap intersects the given cap.") + .def("interior_intersects", &S2Cap::InteriorIntersects, py::arg("other"), + "Return true if the interior of this cap intersects other.\n\n" + "This relationship is not symmetric: only the interior of this cap\n" + "is tested, not the interior of other.") + .def("interior_contains_point", &S2Cap::InteriorContains, + py::arg("point"), + "Return true if the interior of this cap contains the given point.\n\n" + "point should be unit length.") + .def("may_intersect", &S2Cap::MayIntersect, py::arg("cell"), + "Return true if this cap may intersect the given cell.") + + .def("cap_bound", &S2Cap::GetCapBound, + "Return a bounding cap for this cap (returns self).") + // get_rect_bound() is deferred until S2LatLngRect is bound. + .def("cell_union_bound", [](const S2Cap& self) { + std::vector cell_ids; + self.GetCellUnionBound(&cell_ids); + return cell_ids; + }, + "Return a list of S2CellIds whose union covers this cap.") + + // Operators + .def(py::self == py::self, "Return true if caps are identical.") + .def(py::self != py::self, "Return true if caps are not identical.") + .def("approx_equals", &S2Cap::ApproxEquals, + py::arg("other"), + py::arg("max_error") = S1Angle::Radians(1e-14), + "Return true if this cap is approximately equal to other.\n\n" + "Checks that the angle between centers and the difference in chord\n" + "radii are both within max_error radians.") + .def("__hash__", [](const S2Cap& self) { + return absl::HashOf(self.center(), self.radius().length2()); + }) + + // String representation + .def("__repr__", [](const S2Cap& self) { + std::ostringstream oss; + oss << "S2Cap(" << self << ")"; + return oss.str(); + }) + .def("__str__", [](const S2Cap& self) { + std::ostringstream oss; + oss << self; + return oss.str(); + }); +} diff --git a/src/python/s2cap_test.py b/src/python/s2cap_test.py new file mode 100644 index 00000000..fc962c55 --- /dev/null +++ b/src/python/s2cap_test.py @@ -0,0 +1,299 @@ +"""Tests for S2Cap pybind11 bindings.""" + +import math +import unittest +import s2geometry_pybind as s2 + + +class TestS2Cap(unittest.TestCase): + """Test cases for S2Cap bindings.""" + + # Constructors + + def test_default_constructor_is_empty(self): + cap = s2.S2Cap() + self.assertTrue(cap.is_empty()) + self.assertFalse(cap.is_full()) + + def test_constructor_s1angle(self): + center = s2.S2Point(1.0, 0.0, 0.0) + radius = s2.S1Angle.from_radians(math.pi / 4) + cap = s2.S2Cap(center, radius) + self.assertFalse(cap.is_empty()) + self.assertFalse(cap.is_full()) + + def test_constructor_s1chord_angle(self): + center = s2.S2Point(1.0, 0.0, 0.0) + radius = s2.S1ChordAngle(s2.S1Angle.from_radians(math.pi / 4)) + cap = s2.S2Cap(center, radius) + self.assertFalse(cap.is_empty()) + + def test_constructor_negative_radius_is_empty(self): + center = s2.S2Point(1.0, 0.0, 0.0) + cap = s2.S2Cap(center, s2.S1Angle.from_radians(-1.0)) + self.assertTrue(cap.is_empty()) + + def test_constructor_pi_radius_is_full(self): + center = s2.S2Point(1.0, 0.0, 0.0) + cap = s2.S2Cap(center, s2.S1Angle.from_radians(math.pi)) + self.assertTrue(cap.is_full()) + + def test_constructor_normalizes_center(self): + p = s2.S2Point(2.0, 0.0, 0.0) # not unit length + cap = s2.S2Cap(p, s2.S1Angle.from_degrees(10.0)) + self.assertAlmostEqual(cap.center.norm(), 1.0, places=15) + + # Factory methods + + def test_from_point(self): + p = s2.S2Point(0.0, 1.0, 0.0) + cap = s2.S2Cap.from_point(p) + self.assertTrue(cap.contains_point(p)) + + def test_from_center_height(self): + center = s2.S2Point(1.0, 0.0, 0.0) + cap = s2.S2Cap.from_center_height(center, 0.5) + self.assertAlmostEqual(cap.height, 0.5) + + def test_from_center_height_negative_is_empty(self): + center = s2.S2Point(1.0, 0.0, 0.0) + self.assertTrue(s2.S2Cap.from_center_height(center, -1.0).is_empty()) + + def test_from_center_height_two_is_full(self): + center = s2.S2Point(1.0, 0.0, 0.0) + self.assertTrue(s2.S2Cap.from_center_height(center, 2.0).is_full()) + + def test_from_center_area(self): + center = s2.S2Point(1.0, 0.0, 0.0) + area = 2 * math.pi # hemisphere + cap = s2.S2Cap.from_center_area(center, area) + self.assertAlmostEqual(cap.area(), area, places=10) + + def test_empty(self): + cap = s2.S2Cap.empty() + self.assertTrue(cap.is_empty()) + self.assertFalse(cap.is_full()) + + def test_full(self): + cap = s2.S2Cap.full() + self.assertTrue(cap.is_full()) + self.assertFalse(cap.is_empty()) + + def test_from_points_empty(self): + cap = s2.S2Cap.from_points([]) + self.assertTrue(cap.is_empty()) + + def test_from_points_single(self): + p = s2.S2Point(1.0, 0.0, 0.0) + cap = s2.S2Cap.from_points([p]) + self.assertTrue(cap.contains_point(p)) + + def test_from_points_multiple(self): + p1 = s2.S2Point(1.0, 0.0, 0.0) + p2 = s2.S2Point(0.0, 1.0, 0.0) + p3 = s2.S2Point(0.0, 0.0, 1.0) + cap = s2.S2Cap.from_points([p1, p2, p3]) + self.assertTrue(cap.contains_point(p1)) + self.assertTrue(cap.contains_point(p2)) + self.assertTrue(cap.contains_point(p3)) + + def test_from_caps_empty(self): + cap = s2.S2Cap.from_caps([]) + self.assertTrue(cap.is_empty()) + + def test_from_caps_single(self): + c = s2.S2Cap(s2.S2Point(1.0, 0.0, 0.0), s2.S1Angle.from_degrees(10.0)) + result = s2.S2Cap.from_caps([c]) + self.assertTrue(result.contains_point(c.center)) + + def test_from_caps_multiple(self): + c1 = s2.S2Cap(s2.S2Point(1.0, 0.0, 0.0), s2.S1Angle.from_degrees(10.0)) + c2 = s2.S2Cap(s2.S2Point(0.0, 1.0, 0.0), s2.S1Angle.from_degrees(10.0)) + result = s2.S2Cap.from_caps([c1, c2]) + self.assertTrue(result.contains_point(c1.center)) + self.assertTrue(result.contains_point(c2.center)) + + # Properties + + def test_center(self): + center = s2.S2Point(1.0, 0.0, 0.0) + cap = s2.S2Cap.from_point(center) + self.assertEqual(cap.center, center) + + def test_radius(self): + center = s2.S2Point(1.0, 0.0, 0.0) + chord = s2.S1ChordAngle(s2.S1Angle.from_radians(1.0)) + cap = s2.S2Cap(center, chord) + self.assertEqual(cap.radius, chord) + + def test_height(self): + center = s2.S2Point(1.0, 0.0, 0.0) + cap = s2.S2Cap.from_center_height(center, 0.5) + self.assertAlmostEqual(cap.height, 0.5) + + # Predicates + + def test_is_empty(self): + self.assertTrue(s2.S2Cap.empty().is_empty()) + self.assertFalse(s2.S2Cap.full().is_empty()) + + def test_is_full(self): + self.assertTrue(s2.S2Cap.full().is_full()) + self.assertFalse(s2.S2Cap.empty().is_full()) + + # Geometric operations + + def test_radius_angle(self): + center = s2.S2Point(1.0, 0.0, 0.0) + cap = s2.S2Cap(center, s2.S1Angle.from_radians(1.0)) + self.assertAlmostEqual(cap.radius_angle().radians, 1.0, places=14) + + def test_area(self): + self.assertAlmostEqual(s2.S2Cap.full().area(), 4 * math.pi) + self.assertAlmostEqual(s2.S2Cap.empty().area(), 0.0) + + def test_centroid(self): + center = s2.S2Point(1.0, 0.0, 0.0) + cap = s2.S2Cap.from_center_height(center, 0.5) + centroid = cap.centroid() + # Centroid lies along the same axis as center. + self.assertGreater(centroid.x, 0.0) + self.assertAlmostEqual(centroid.y, 0.0) + self.assertAlmostEqual(centroid.z, 0.0) + + def test_complement_of_empty_is_full(self): + self.assertTrue(s2.S2Cap.empty().complement().is_full()) + + def test_complement_of_full_is_empty(self): + self.assertTrue(s2.S2Cap.full().complement().is_empty()) + + def test_complement_roundtrip(self): + center = s2.S2Point(1.0, 0.0, 0.0) + cap = s2.S2Cap(center, s2.S1Angle.from_radians(1.0)) + comp = cap.complement() + self.assertFalse(comp.is_empty()) + self.assertFalse(comp.is_full()) + + def test_expanded(self): + center = s2.S2Point(1.0, 0.0, 0.0) + cap = s2.S2Cap.from_point(center) + delta = s2.S1Angle.from_radians(0.1) + expanded = cap.expanded(delta) + self.assertGreater(expanded.radius_angle().radians, + cap.radius_angle().radians) + + def test_expanded_empty_stays_empty(self): + self.assertTrue( + s2.S2Cap.empty().expanded(s2.S1Angle.from_radians(1.0)).is_empty()) + + def test_union_contains_both_centers(self): + p1 = s2.S2Point(1.0, 0.0, 0.0) + p2 = s2.S2Point(math.cos(0.1), math.sin(0.1), 0.0) + cap1 = s2.S2Cap(p1, s2.S1Angle.from_radians(0.01)) + cap2 = s2.S2Cap(p2, s2.S1Angle.from_radians(0.01)) + u = cap1.union(cap2) + # Centers lie strictly inside the union radius, so contains_point is reliable. + self.assertTrue(u.contains_point(p1)) + self.assertTrue(u.contains_point(p2)) + + def test_full_contains_any_cap(self): + cap = s2.S2Cap(s2.S2Point(1.0, 0.0, 0.0), s2.S1Angle.from_radians(1.0)) + self.assertTrue(s2.S2Cap.full().contains(cap)) + + def test_empty_contained_by_any_cap(self): + cap = s2.S2Cap(s2.S2Point(1.0, 0.0, 0.0), s2.S1Angle.from_radians(1.0)) + self.assertTrue(cap.contains(s2.S2Cap.empty())) + + def test_contains_point(self): + center = s2.S2Point(1.0, 0.0, 0.0) + cap = s2.S2Cap(center, s2.S1Angle.from_radians(1.0)) + self.assertTrue(cap.contains_point(center)) + self.assertFalse(cap.contains_point(s2.S2Point(-1.0, 0.0, 0.0))) + + def test_contains_cell(self): + cell = s2.S2Cell(s2.S2CellId(s2.S2Point(1.0, 0.0, 0.0))) + self.assertTrue(s2.S2Cap.full().contains_cell(cell)) + self.assertFalse(s2.S2Cap.empty().contains_cell(cell)) + + def test_intersects(self): + cap = s2.S2Cap(s2.S2Point(1.0, 0.0, 0.0), s2.S1Angle.from_radians(1.0)) + self.assertTrue(cap.intersects(cap)) + self.assertFalse(cap.intersects(s2.S2Cap.empty())) + + def test_interior_intersects(self): + cap = s2.S2Cap(s2.S2Point(1.0, 0.0, 0.0), s2.S1Angle.from_radians(1.0)) + self.assertTrue(cap.interior_intersects(cap)) + + def test_interior_contains_point(self): + center = s2.S2Point(1.0, 0.0, 0.0) + cap = s2.S2Cap(center, s2.S1Angle.from_radians(1.0)) + self.assertTrue(cap.interior_contains_point(center)) + + def test_may_intersect(self): + cell = s2.S2Cell(s2.S2CellId(s2.S2Point(1.0, 0.0, 0.0))) + self.assertTrue(s2.S2Cap.full().may_intersect(cell)) + + def test_cap_bound(self): + center = s2.S2Point(1.0, 0.0, 0.0) + cap = s2.S2Cap(center, s2.S1Angle.from_radians(1.0)) + bound = cap.cap_bound() + self.assertTrue(bound.approx_equals(cap)) + + def test_cell_union_bound(self): + cell_ids = s2.S2Cap.full().cell_union_bound() + self.assertGreater(len(cell_ids), 0) + + # Operators + + def test_equality(self): + center = s2.S2Point(1.0, 0.0, 0.0) + cap1 = s2.S2Cap(center, s2.S1Angle.from_radians(1.0)) + cap2 = s2.S2Cap(center, s2.S1Angle.from_radians(1.0)) + self.assertTrue(cap1 == cap2) + self.assertFalse(cap1 != cap2) + + def test_inequality(self): + cap1 = s2.S2Cap.from_point(s2.S2Point(1.0, 0.0, 0.0)) + cap2 = s2.S2Cap.from_point(s2.S2Point(0.0, 1.0, 0.0)) + self.assertTrue(cap1 != cap2) + self.assertFalse(cap1 == cap2) + + def test_approx_equals(self): + cap = s2.S2Cap(s2.S2Point(1.0, 0.0, 0.0), s2.S1Angle.from_radians(1.0)) + self.assertTrue(cap.approx_equals(cap)) + + def test_approx_equals_with_max_error(self): + cap = s2.S2Cap(s2.S2Point(1.0, 0.0, 0.0), s2.S1Angle.from_radians(1.0)) + self.assertTrue(cap.approx_equals(cap, s2.S1Angle.from_radians(1e-10))) + + def test_approx_equals_false(self): + cap1 = s2.S2Cap(s2.S2Point(1.0, 0.0, 0.0), s2.S1Angle.from_radians(1.0)) + cap2 = s2.S2Cap(s2.S2Point(0.0, 1.0, 0.0), s2.S1Angle.from_radians(1.0)) + self.assertFalse(cap1.approx_equals(cap2)) + + def test_hash(self): + center = s2.S2Point(1.0, 0.0, 0.0) + cap1 = s2.S2Cap(center, s2.S1Angle.from_radians(1.0)) + cap2 = s2.S2Cap(center, s2.S1Angle.from_radians(1.0)) + self.assertEqual(hash(cap1), hash(cap2)) + s = {cap1, cap2} + self.assertEqual(len(s), 1) + + # String representation + + def test_repr(self): + cap = s2.S2Cap.empty() + r = repr(cap) + self.assertTrue(r.startswith("S2Cap(")) + self.assertIn("Center=", r) + self.assertIn("Radius=", r) + + def test_str(self): + cap = s2.S2Cap.empty() + s = str(cap) + self.assertIn("Center=", s) + self.assertIn("Radius=", s) + + +if __name__ == "__main__": + unittest.main()