diff --git a/src/python/BUILD.bazel b/src/python/BUILD.bazel index 0d37eecc..b6b67d1e 100644 --- a/src/python/BUILD.bazel +++ b/src/python/BUILD.bazel @@ -32,6 +32,7 @@ pybind_extension( ":s1angle_bindings", ":s1chord_angle_bindings", ":s1interval_bindings", + ":s2cell_bindings", ":s2cell_id_bindings", ":s2latlng_bindings", ":s2point_bindings", @@ -123,6 +124,16 @@ pybind_library( ], ) +pybind_library( + name = "s2cell_bindings", + srcs = ["s2cell_bindings.cc"], + deps = [ + "//:s2", + "@abseil-cpp//absl/hash", + "@abseil-cpp//absl/strings", + ], +) + # ======================================== # Python Tests # ======================================== @@ -180,3 +191,9 @@ py_test( srcs = ["s2cell_id_test.py"], deps = [":s2geometry_pybind"], ) + +py_test( + name = "s2cell_test", + srcs = ["s2cell_test.py"], + deps = [":s2geometry_pybind"], +) diff --git a/src/python/README.md b/src/python/README.md index 2f2afe44..02e535e2 100644 --- a/src/python/README.md +++ b/src/python/README.md @@ -34,6 +34,7 @@ The Python bindings follow the C++ API closely but with Pythonic conventions: - Core classes exist within the top-level module; we may define submodules for utility classes. - Class names remain unchanged (e.g., `S2Point`, `S1Angle`, `R1Interval`) - Method names are converted to snake_case (converted from UpperCamelCase C++ function names) +- The `Get` prefix is dropped: C++ `GetDistance` becomes Python `distance`, `GetBoundUV` becomes `bound_uv`, etc. **Properties vs. Methods:** - Simple accessors that return internal state (including trivial unit conversions) are properties: `point.x`, `point.y`, `interval.lo`, `interval.hi`, `angle.radians`, `angle.degrees` diff --git a/src/python/module.cc b/src/python/module.cc index 701899ca..cbb0a176 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_s2cell(py::module& m); void bind_s2cell_id(py::module& m); void bind_s2latlng(py::module& m); void bind_s2point(py::module& m); @@ -42,4 +43,7 @@ PYBIND11_MODULE(s2geometry_bindings, m) { // Deps: s1angle, s2point, s2latlng, r2point, r2rect bind_s2cell_id(m); + + // Deps: r2rect, s1chord_angle, s2cell_id, s2latlng, s2point + bind_s2cell(m); } diff --git a/src/python/s2cell_bindings.cc b/src/python/s2cell_bindings.cc new file mode 100644 index 00000000..ea9cb0c7 --- /dev/null +++ b/src/python/s2cell_bindings.cc @@ -0,0 +1,252 @@ +#include +#include +#include + +#include +#include +#include + +#include "absl/hash/hash.h" +#include "absl/strings/str_cat.h" +#include "s2/s1chord_angle.h" +#include "s2/s2cell.h" +#include "s2/s2cell_id.h" +#include "s2/s2latlng.h" +#include "s2/s2point.h" + +namespace py = pybind11; + +namespace { + +// These three helpers are duplicated from s2cell_id_bindings.cc. +void MaybeThrowFaceOutOfRange(int face) { + if (face < 0 || face >= S2CellId::kNumFaces) { + throw py::value_error( + absl::StrCat("Face ", face, " out of range [0, ", + S2CellId::kNumFaces - 1, "]")); + } +} + +void MaybeThrowLevelOutOfRange(int level, int min, int max) { + if (level < min || level > max) { + throw py::value_error( + absl::StrCat("Level ", level, " out of range [", min, ", ", max, "]")); + } +} + +void MaybeThrowPositionOutOfRange(uint64_t pos) { + if (pos > S2CellId::kMaxPosition) { + throw py::value_error( + absl::StrCat("pos ", pos, " out of range [0, ", S2CellId::kMaxPosition, + "]")); + } +} + +} // namespace + +void bind_s2cell(py::module& m) { + auto cls = py::class_(m, "S2Cell", + "An S2Region representing a single cell on the sphere.\n\n" + "Unlike S2CellId (which is just a 64-bit identifier), S2Cell carries\n" + "precomputed state that allows efficient containment and intersection\n" + "tests. See s2/s2cell.h for comprehensive documentation.") + // Constructors + .def(py::init([](S2CellId id) { + // No validity check needed: all S2CellId objects reachable from + // Python are guaranteed valid by the S2CellId bindings. + return S2Cell(id); + }), + py::arg("cell_id"), + "Construct the cell corresponding to the given S2CellId.") + .def(py::init([](const S2Point& p) { + return S2Cell(p); + }), + py::arg("point"), + "Construct a leaf cell containing the given point.\n\n" + "The point does not need to be normalized.") + .def(py::init([](const S2LatLng& ll) { + // No validity check needed: all S2LatLng objects reachable from + // Python are guaranteed valid by the S2LatLng bindings. + return S2Cell(ll); + }), + py::arg("latlng"), + "Construct a leaf cell containing the given S2LatLng.") + + // Factory methods + .def_static("from_face", [](int face) { + MaybeThrowFaceOutOfRange(face); + return S2Cell::FromFace(face); + }, py::arg("face"), + "Return the cell corresponding to the given S2 cube face (0..5).\n\n" + "Raises ValueError if face is out of range.") + .def_static("from_face_pos_level", [](int face, uint64_t pos, int level) { + MaybeThrowFaceOutOfRange(face); + MaybeThrowPositionOutOfRange(pos); + MaybeThrowLevelOutOfRange(level, 0, S2CellId::kMaxLevel); + return S2Cell::FromFacePosLevel(face, pos, level); + }, + py::arg("face"), py::arg("pos"), py::arg("level"), + "Return a cell given its face, Hilbert curve position, and level.\n\n" + "Raises ValueError if face, pos, or level is out of range.") + + // Properties + .def_property_readonly("id", &S2Cell::id, + "The S2CellId this cell corresponds to") + .def_property_readonly("face", &S2Cell::face, + "Which cube face this cell belongs to (0..5)") + .def_property_readonly("level", &S2Cell::level, + "The subdivision level (0..kMaxLevel)") + .def_property_readonly("orientation", &S2Cell::orientation, + "The Hilbert curve orientation of this cell.\n\n" + "A bitmask: bit 0 (kSwapMask) indicates swapped\n" + "axes; bit 1 (kInvertMask) indicates 180-degree\n" + "rotation. Values are in [0, 3].") + + // Predicates + .def("is_leaf", &S2Cell::is_leaf, + "Return true if this is a leaf cell (level == kMaxLevel)") + + // Geometric operations + .def("size_ij", &S2Cell::GetSizeIJ, + "Return the edge length of this cell in (i,j)-space") + .def("size_st", &S2Cell::GetSizeST, + "Return the edge length of this cell in (s,t)-space") + .def("vertex", &S2Cell::GetVertex, py::arg("k"), + "Return the k-th vertex of the cell (k = 0,1,2,3) in CCW order.\n\n" + "Lower-left, lower-right, upper-right, upper-left in the UV plane.\n" + "The argument is reduced modulo 4 to the range [0..3].\n" + "The returned point is normalized.") + .def("edge", &S2Cell::GetEdge, py::arg("k"), + "Return the normalized inward-facing normal of the great circle\n" + "passing through the edge from vertex k to vertex k+1 (mod 4).\n\n" + "The argument is reduced modulo 4 to the range [0..3].") + .def("uv_coord_of_edge", &S2Cell::GetUVCoordOfEdge, py::arg("k"), + "Return either U or V for the given edge, whichever is constant\n" + "along it.\n\n" + "Boundaries 0 and 2 return V; boundaries 1 and 3 return U.\n" + "The argument is reduced modulo 4 to the range [0..3].") + .def("ij_coord_of_edge", &S2Cell::GetIJCoordOfEdge, py::arg("k"), + "Return either I or J for the given edge, whichever is constant\n" + "along it.\n\n" + "Boundaries 0 and 2 return J; boundaries 1 and 3 return I.\n" + "The argument is reduced modulo 4 to the range [0..3].") + .def("center", &S2Cell::GetCenter, + "Return the center of the cell as a normalized S2Point") + .def_static("average_area_for_level", [](int level) { + MaybeThrowLevelOutOfRange(level, 0, S2CellId::kMaxLevel); + return S2Cell::AverageArea(level); + }, py::arg("level"), + "Return the average area of cells at the given level,\n" + "in steradians.\n\n" + "Raises ValueError if level is out of range.") + .def("approx_area", &S2Cell::ApproxArea, + "Return the approximate area of this cell in steradians.\n\n" + "Accurate to within 3% for all cell sizes and within 0.1% for\n" + "cells at level 5 or higher.") + .def("exact_area", &S2Cell::ExactArea, + "Return the area of this cell as accurately as possible,\n" + "in steradians.\n\n" + "More expensive than approx_area but accurate to 6 digits\n" + "even for leaf cells.") + .def("bound_uv", &S2Cell::GetBoundUV, + "Return the bounds of this cell in (u,v)-space") + .def("distance", py::overload_cast( + &S2Cell::GetDistance, py::const_), + py::arg("point"), + "Return the distance from this cell to the given point.\n\n" + "Returns zero if the point is inside the cell.") + .def("boundary_distance", &S2Cell::GetBoundaryDistance, + py::arg("point"), + "Return the distance from the cell boundary to the given point.") + .def("max_distance", py::overload_cast( + &S2Cell::GetMaxDistance, py::const_), + py::arg("point"), + "Return the maximum distance from this cell to the given point.") + .def("distance_to_edge", + py::overload_cast( + &S2Cell::GetDistance, py::const_), + py::arg("a"), py::arg("b"), + "Return the minimum distance from this cell to the edge AB.\n\n" + "Returns zero if the edge intersects the cell interior.") + .def("max_distance_to_edge", + py::overload_cast( + &S2Cell::GetMaxDistance, py::const_), + py::arg("a"), py::arg("b"), + "Return the maximum distance from this cell to the edge AB.") + .def("distance_to_cell", + py::overload_cast(&S2Cell::GetDistance, py::const_), + py::arg("cell"), + "Return the distance from this cell to the given cell.\n\n" + "Returns zero if one cell contains the other.") + .def("max_distance_to_cell", + py::overload_cast(&S2Cell::GetMaxDistance, py::const_), + py::arg("cell"), + "Return the maximum distance from this cell to the given cell.") + .def("cell_union_bound", [](const S2Cell& self) { + std::vector cell_ids; + self.GetCellUnionBound(&cell_ids); + return cell_ids; + }, + "Return a list of S2CellIds whose union covers this cell.\n\n" + "For a single S2Cell, this always returns a list containing\n" + "just this cell's id.") + .def("contains", py::overload_cast( + &S2Cell::Contains, py::const_), + py::arg("cell"), + "Return true if this cell contains the given cell") + .def("contains_point", py::overload_cast( + &S2Cell::Contains, py::const_), + py::arg("point"), + "Return true if this cell contains the given point.\n\n" + "S2Cells are closed sets: points along an edge or vertex\n" + "belong to the adjacent cell(s) as well.\n" + "The point does not need to be normalized.") + .def("may_intersect", &S2Cell::MayIntersect, py::arg("cell"), + "Return true if this cell may intersect the given cell") + .def("subdivide", [](const S2Cell& self) { + if (self.is_leaf()) { + throw py::value_error("Leaf cell has no children"); + } + S2Cell children[4]; + self.Subdivide(children); + return py::make_tuple(children[0], children[1], + children[2], children[3]); + }, + "Return the four children of this cell as a tuple.\n\n" + "Raises ValueError if this is a leaf cell.") + + // Operators + .def(py::self == py::self, "Return true if cells are equal") + .def(py::self != py::self, "Return true if cells are not equal") + .def(py::self < py::self, "Compare cells by their cell id") + .def(py::self > py::self, "Compare cells by their cell id") + .def(py::self <= py::self, "Compare cells by their cell id") + .def(py::self >= py::self, "Compare cells by their cell id") + .def("__hash__", [](const S2Cell& self) { + return absl::HashOf(self.id()); + }) + + // String representation + .def("__repr__", [](const S2Cell& cell) { + std::ostringstream oss; + oss << "S2Cell(" << cell.id() << ")"; + return oss.str(); + }) + .def("__str__", [](const S2Cell& cell) { + std::ostringstream oss; + oss << cell.id(); + return oss.str(); + }); + + py::enum_(cls, "Boundary", py::arithmetic()) + .value("BOTTOM_EDGE", S2Cell::kBottomEdge) + .value("RIGHT_EDGE", S2Cell::kRightEdge) + .value("TOP_EDGE", S2Cell::kTopEdge) + .value("LEFT_EDGE", S2Cell::kLeftEdge) + .export_values(); + + // TODO: The following S2Cell methods are not yet bound because they depend + // on types that have not been bound yet: + // - get_cap_bound() -> S2Cap + // - get_rect_bound() -> S2LatLngRect +} diff --git a/src/python/s2cell_test.py b/src/python/s2cell_test.py new file mode 100644 index 00000000..15fed657 --- /dev/null +++ b/src/python/s2cell_test.py @@ -0,0 +1,343 @@ +"""Tests for S2Cell pybind11 bindings.""" + +import math +import unittest +import s2geometry_pybind as s2 + + +class TestS2Cell(unittest.TestCase): + """Test cases for S2Cell bindings.""" + + # Constructors + + def test_constructor_from_cell_id(self): + cell_id = s2.S2CellId.from_face(0) + cell = s2.S2Cell(cell_id) + self.assertEqual(cell.id, cell_id) + self.assertEqual(cell.face, 0) + self.assertEqual(cell.level, 0) + + def test_constructor_from_point(self): + p = s2.S2Point(1.0, 0.0, 0.0) + cell = s2.S2Cell(p) + self.assertTrue(cell.is_leaf()) + + def test_constructor_from_latlng(self): + ll = s2.S2LatLng.from_degrees(0.0, 0.0) + cell = s2.S2Cell(ll) + self.assertTrue(cell.is_leaf()) + + # Factory methods + + def test_from_face(self): + for face in range(6): + cell = s2.S2Cell.from_face(face) + self.assertEqual(cell.face, face) + self.assertEqual(cell.level, 0) + + def test_from_face_out_of_range_raises(self): + with self.assertRaises(ValueError) as cm: + s2.S2Cell.from_face(6) + self.assertEqual(str(cm.exception), "Face 6 out of range [0, 5]") + with self.assertRaises(ValueError): + s2.S2Cell.from_face(-1) + + def test_from_face_pos_level(self): + cell = s2.S2Cell.from_face_pos_level(0, 0, 0) + self.assertEqual(cell.level, 0) + self.assertEqual(cell.face, 0) + + def test_from_face_pos_level_out_of_range_raises(self): + with self.assertRaises(ValueError): + s2.S2Cell.from_face_pos_level(6, 0, 0) + with self.assertRaises(ValueError): + s2.S2Cell.from_face_pos_level(0, s2.S2CellId.MAX_POSITION + 1, 0) + with self.assertRaises(ValueError): + s2.S2Cell.from_face_pos_level(0, 0, 31) + + # Constants + + def test_boundary_constants(self): + self.assertEqual(int(s2.S2Cell.BOTTOM_EDGE), 0) + self.assertEqual(int(s2.S2Cell.RIGHT_EDGE), 1) + self.assertEqual(int(s2.S2Cell.TOP_EDGE), 2) + self.assertEqual(int(s2.S2Cell.LEFT_EDGE), 3) + + # Properties + + def test_id(self): + cell_id = s2.S2CellId.from_face(3) + cell = s2.S2Cell(cell_id) + self.assertEqual(cell.id, cell_id) + + def test_face(self): + for f in range(6): + self.assertEqual(s2.S2Cell.from_face(f).face, f) + + def test_level(self): + face = s2.S2Cell.from_face(0) + self.assertEqual(face.level, 0) + child = s2.S2Cell(face.id.child(0)) + self.assertEqual(child.level, 1) + + def test_orientation(self): + # Orientation is a bitmask in [0, 3]. + for f in range(6): + self.assertIn(s2.S2Cell.from_face(f).orientation, range(4)) + child = s2.S2Cell(s2.S2CellId.from_face(0).child(0)) + self.assertIn(child.orientation, range(4)) + + # Predicates + + def test_is_leaf(self): + face = s2.S2Cell.from_face(0) + self.assertFalse(face.is_leaf()) + leaf = s2.S2Cell(s2.S2Point(1.0, 0.0, 0.0)) + self.assertTrue(leaf.is_leaf()) + + # Geometric operations + + def test_size_ij(self): + # A face cell spans 2^kMaxLevel in (i,j). + face = s2.S2Cell.from_face(0) + self.assertEqual(face.size_ij(), 1 << s2.S2CellId.MAX_LEVEL) + + def test_size_st(self): + # A face cell spans the full [0,1] range in (s,t). + face = s2.S2Cell.from_face(0) + self.assertAlmostEqual(face.size_st(), 1.0) + + def test_vertex_count_and_normalization(self): + face = s2.S2Cell.from_face(0) + for k in range(4): + v = face.vertex(k) + # Normalized vertices should have unit norm. + self.assertAlmostEqual(v.norm(), 1.0) + + def test_vertex_mod_4(self): + face = s2.S2Cell.from_face(0) + # Argument is reduced modulo 4. + v0 = face.vertex(0) + v4 = face.vertex(4) + self.assertAlmostEqual(v0.x, v4.x) + self.assertAlmostEqual(v0.y, v4.y) + self.assertAlmostEqual(v0.z, v4.z) + + def test_edge(self): + face = s2.S2Cell.from_face(0) + for k in range(4): + e = face.edge(k) + self.assertAlmostEqual(e.norm(), 1.0) + + def test_uv_coord_of_edge(self): + face = s2.S2Cell.from_face(0) + # Face cell UV bounds are [-1, 1] x [-1, 1]. Bottom/top edges are + # constant in V; left/right constant in U. + bottom = face.uv_coord_of_edge(s2.S2Cell.Boundary.BOTTOM_EDGE) + top = face.uv_coord_of_edge(s2.S2Cell.Boundary.TOP_EDGE) + self.assertAlmostEqual(bottom, -1.0) + self.assertAlmostEqual(top, 1.0) + right = face.uv_coord_of_edge(s2.S2Cell.Boundary.RIGHT_EDGE) + left = face.uv_coord_of_edge(s2.S2Cell.Boundary.LEFT_EDGE) + self.assertAlmostEqual(right, 1.0) + self.assertAlmostEqual(left, -1.0) + + def test_ij_coord_of_edge(self): + face = s2.S2Cell.from_face(0) + # Face cell in (i,j): bottom/top edges are constant in J (0 and 2^30), + # left/right edges are constant in I (0 and 2^30). + self.assertEqual(face.ij_coord_of_edge(s2.S2Cell.Boundary.BOTTOM_EDGE), 0) + self.assertEqual(face.ij_coord_of_edge(s2.S2Cell.Boundary.TOP_EDGE), 1 << 30) + self.assertEqual(face.ij_coord_of_edge(s2.S2Cell.Boundary.LEFT_EDGE), 0) + self.assertEqual(face.ij_coord_of_edge(s2.S2Cell.Boundary.RIGHT_EDGE), 1 << 30) + + def test_center(self): + face = s2.S2Cell.from_face(0) + c = face.center() + # Face 0 center is (1, 0, 0). + self.assertAlmostEqual(c.norm(), 1.0) + self.assertAlmostEqual(c.x, 1.0, places=5) + self.assertAlmostEqual(c.y, 0.0, places=5) + self.assertAlmostEqual(c.z, 0.0, places=5) + + def test_average_area_for_level(self): + # AverageArea halves (roughly) with each subdivision by 4: area at + # level L is ~ (4*pi / 6) / 4^L. + expected_0 = 4.0 * math.pi / 6.0 + self.assertAlmostEqual( + s2.S2Cell.average_area_for_level(0), expected_0, places=5) + # Sum over all cells at level 5 should still be 4*pi. + total = s2.S2Cell.average_area_for_level(5) * 6 * (4 ** 5) + self.assertAlmostEqual(total, 4.0 * math.pi, places=5) + + def test_average_area_for_level_out_of_range_raises(self): + with self.assertRaises(ValueError): + s2.S2Cell.average_area_for_level(31) + with self.assertRaises(ValueError): + s2.S2Cell.average_area_for_level(-1) + + def test_approx_area(self): + face = s2.S2Cell.from_face(0) + expected = 4.0 * math.pi / 6.0 + # approx_area is accurate to within 3%. + self.assertAlmostEqual(face.approx_area(), expected, delta=expected * 0.03) + + def test_exact_area_sums_to_sphere(self): + # Summing exact_area over all 6 face cells should give 4*pi. + total = sum(s2.S2Cell.from_face(f).exact_area() for f in range(6)) + self.assertAlmostEqual(total, 4.0 * math.pi, places=10) + + def test_bound_uv_face(self): + face = s2.S2Cell.from_face(0) + uv = face.bound_uv() + self.assertAlmostEqual(uv.lo.x, -1.0) + self.assertAlmostEqual(uv.lo.y, -1.0) + self.assertAlmostEqual(uv.hi.x, 1.0) + self.assertAlmostEqual(uv.hi.y, 1.0) + + def test_distance_to_point_outside(self): + face = s2.S2Cell.from_face(0) + # A point on the opposite face should be at a positive distance. + far = s2.S2Point(-1.0, 0.0, 0.0) + d = face.distance(far) + self.assertGreater(d.radians, 0.0) + + def test_distance_to_point_inside(self): + face = s2.S2Cell.from_face(0) + # Center of face 0 is inside; distance should be zero. + center = face.center() + self.assertAlmostEqual(face.distance(center).radians, 0.0) + + def test_boundary_distance(self): + face = s2.S2Cell.from_face(0) + center = face.center() + # Interior point has positive boundary distance. + self.assertGreater(face.boundary_distance(center).radians, 0.0) + # A far exterior point also has positive boundary distance. + far = s2.S2Point(-1.0, 0.0, 0.0) + self.assertGreater(face.boundary_distance(far).radians, 0.0) + + def test_max_distance_to_point(self): + face = s2.S2Cell.from_face(0) + center = face.center() + # Max distance from a face cell to its own center should be positive + # (distance to the farthest corner). + self.assertGreater(face.max_distance(center).radians, 0.0) + + def test_distance_to_edge(self): + face = s2.S2Cell.from_face(0) + # An edge on the opposite face; use unit-length endpoints. + a = s2.S2Point(-1.0, 1.0, 0.0).normalize() + b = s2.S2Point(-1.0, -1.0, 0.0).normalize() + self.assertGreater(face.distance_to_edge(a, b).radians, 0.0) + + def test_max_distance_to_edge(self): + face = s2.S2Cell.from_face(0) + a = s2.S2Point(-1.0, 1.0, 0.0).normalize() + b = s2.S2Point(-1.0, -1.0, 0.0).normalize() + self.assertGreater(face.max_distance_to_edge(a, b).radians, 0.0) + + def test_distance_to_cell(self): + face0 = s2.S2Cell.from_face(0) + # Distance from a cell to itself is zero. + self.assertAlmostEqual(face0.distance_to_cell(face0).radians, 0.0) + # Face 0 (pos X) and face 3 (neg X) are opposite and non-adjacent. + face3 = s2.S2Cell.from_face(3) + self.assertGreater(face0.distance_to_cell(face3).radians, 0.0) + + def test_max_distance_to_cell(self): + face0 = s2.S2Cell.from_face(0) + face3 = s2.S2Cell.from_face(3) + self.assertGreater(face0.max_distance_to_cell(face3).radians, 0.0) + + def test_cell_union_bound(self): + cell = s2.S2Cell.from_face(0) + bound = cell.cell_union_bound() + self.assertEqual(len(bound), 1) + self.assertEqual(bound[0], cell.id) + + def test_contains_cell(self): + face = s2.S2Cell.from_face(0) + child = s2.S2Cell(face.id.child(0)) + self.assertTrue(face.contains(child)) + self.assertFalse(child.contains(face)) + + def test_contains_point(self): + face = s2.S2Cell.from_face(0) + # Face 0 center is (1, 0, 0), which is inside face 0. + self.assertTrue(face.contains_point(s2.S2Point(1.0, 0.0, 0.0))) + # A point on the opposite face is not in face 0. + self.assertFalse(face.contains_point(s2.S2Point(-1.0, 0.0, 0.0))) + + def test_may_intersect(self): + face0 = s2.S2Cell.from_face(0) + face1 = s2.S2Cell.from_face(1) + # A cell intersects itself. + self.assertTrue(face0.may_intersect(face0)) + # Distinct faces (disjoint cell id ranges) do not intersect. + self.assertFalse(face0.may_intersect(face1)) + # A cell contained within another may_intersect the outer cell. + child = s2.S2Cell(face0.id.child(0)) + self.assertTrue(face0.may_intersect(child)) + self.assertTrue(child.may_intersect(face0)) + + # Traversal + + def test_subdivide(self): + face = s2.S2Cell.from_face(0) + children = face.subdivide() + self.assertEqual(len(children), 4) + for i, child in enumerate(children): + self.assertEqual(child.level, 1) + self.assertEqual(child.face, 0) + # Subdivide should produce the same children as constructing + # from the S2CellId children. + self.assertEqual(child.id, face.id.child(i)) + + def test_subdivide_leaf_raises(self): + leaf = s2.S2Cell(s2.S2Point(1.0, 0.0, 0.0)) + with self.assertRaises(ValueError) as cm: + leaf.subdivide() + self.assertEqual(str(cm.exception), "Leaf cell has no children") + + # Operators + + def test_equality(self): + a = s2.S2Cell.from_face(0) + b = s2.S2Cell.from_face(0) + c = s2.S2Cell.from_face(1) + self.assertTrue(a == b) + self.assertTrue(a != c) + + def test_comparison(self): + a = s2.S2Cell.from_face(0) + b = s2.S2Cell.from_face(1) + self.assertTrue(a < b) + self.assertTrue(b > a) + self.assertTrue(a <= a) + self.assertTrue(a >= a) + + def test_hash(self): + a = s2.S2Cell.from_face(0) + b = s2.S2Cell.from_face(0) + self.assertEqual(hash(a), hash(b)) + s = {a, b} + self.assertEqual(len(s), 1) + + # String representation + + def test_repr(self): + cell = s2.S2Cell.from_face(0) + self.assertEqual(repr(cell), "S2Cell(0/)") + + def test_str(self): + cell = s2.S2Cell.from_face(0) + self.assertEqual(str(cell), "0/") + + def test_repr_child(self): + cell = s2.S2Cell(s2.S2CellId.from_face(3).child(0).child(2)) + self.assertEqual(repr(cell), "S2Cell(3/02)") + + +if __name__ == "__main__": + unittest.main()