diff --git a/src/python/BUILD.bazel b/src/python/BUILD.bazel index a0d83669..0c9621cc 100644 --- a/src/python/BUILD.bazel +++ b/src/python/BUILD.bazel @@ -30,6 +30,7 @@ pybind_extension( ":r2point_bindings", ":r2rect_bindings", ":s1angle_bindings", + ":s1chord_angle_bindings", ":s1interval_bindings", ":s2cell_id_bindings", ":s2latlng_bindings", @@ -69,6 +70,16 @@ pybind_library( ], ) +pybind_library( + name = "s1chord_angle_bindings", + srcs = ["s1chord_angle_bindings.cc"], + deps = [ + "//:s2", + "@abseil-cpp//absl/hash", + "@abseil-cpp//absl/strings", + ], +) + pybind_library( name = "s1interval_bindings", srcs = ["s1interval_bindings.cc"], @@ -119,6 +130,12 @@ py_test( deps = [":s2geometry_pybind"], ) +py_test( + name = "s1chord_angle_test", + srcs = ["s1chord_angle_test.py"], + deps = [":s2geometry_pybind"], +) + py_test( name = "r2point_test", srcs = ["r2point_test.py"], diff --git a/src/python/module.cc b/src/python/module.cc index 91ef57ad..701899ca 100644 --- a/src/python/module.cc +++ b/src/python/module.cc @@ -8,6 +8,7 @@ void bind_r1interval(py::module& m); void bind_r2point(py::module& m); 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_id(py::module& m); void bind_s2latlng(py::module& m); @@ -33,6 +34,9 @@ PYBIND11_MODULE(s2geometry_bindings, m) { // Deps: s2point bind_s1angle(m); + // Deps: s1angle, s2point + bind_s1chord_angle(m); + // Deps: s1angle, s2point bind_s2latlng(m); diff --git a/src/python/s1chord_angle_bindings.cc b/src/python/s1chord_angle_bindings.cc new file mode 100644 index 00000000..9710fce9 --- /dev/null +++ b/src/python/s1chord_angle_bindings.cc @@ -0,0 +1,190 @@ +#include +#include + +#include +#include + +#include "absl/hash/hash.h" +#include "absl/strings/str_cat.h" +#include "s2/s1angle.h" +#include "s2/s1chord_angle.h" +#include "s2/s2point.h" +#include "s2/s2pointutil.h" + +namespace py = pybind11; + +namespace { + +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(), ")")); + } +} + +void MaybeThrowIfSpecial(const S1ChordAngle& a, const char* name) { + if (a.is_special()) { + throw py::value_error( + absl::StrCat(name, " must not be a special value " + "(negative() or infinity())")); + } +} + +} // namespace + +void bind_s1chord_angle(py::module& m) { + py::class_(m, "S1ChordAngle", + "Represents the angle subtended by a chord on the unit sphere.\n\n" + "S1ChordAngle can represent angles between 0 and Pi radians. It is\n" + "more efficient than S1Angle for computing and comparing distances,\n" + "but loses some accuracy as the angle approaches Pi radians.\n\n" + "See s2/s1chord_angle.h for comprehensive documentation, including\n" + "accuracy analysis and guidance on when to prefer S1Angle.") + // Constructors + .def(py::init<>(), "Default constructor creates a zero chord angle") + .def(py::init([](const S2Point& x, const S2Point& y) { + MaybeThrowNotUnitLength(x, "x"); + MaybeThrowNotUnitLength(y, "y"); + return S1ChordAngle(x, y); + }), + py::arg("x"), py::arg("y"), + "Construct the chord angle between two unit-length points.\n\n" + "Raises ValueError if either point is not unit-length.") + .def(py::init(), py::arg("angle"), + "Construct from an S1Angle.\n\n" + "Angles outside [0, Pi] are mapped as follows:\n" + " Infinity() -> Infinity()\n" + " negative -> Negative()\n" + " > Pi -> Straight()\n" + "This conversion is relatively expensive; prefer to convert at\n" + "the boundaries of your algorithm.") + + // Factory methods + .def_static("from_radians", &S1ChordAngle::Radians, py::arg("radians"), + "Construct a chord angle from an angle in radians") + .def_static("from_degrees", &S1ChordAngle::Degrees, py::arg("degrees"), + "Construct a chord angle from an angle in degrees") + .def_static("from_e5", &S1ChordAngle::E5, py::arg("e5"), + "Construct a chord angle from the E5 representation") + .def_static("from_e6", &S1ChordAngle::E6, py::arg("e6"), + "Construct a chord angle from the E6 representation") + .def_static("from_e7", &S1ChordAngle::E7, py::arg("e7"), + "Construct a chord angle from the E7 representation") + .def_static("zero", &S1ChordAngle::Zero, "Return the zero chord angle") + .def_static("right", &S1ChordAngle::Right, + "Return a 90-degree chord angle") + .def_static("straight", &S1ChordAngle::Straight, + "Return a 180-degree chord angle (the maximum finite value)") + .def_static("infinity", &S1ChordAngle::Infinity, + "Return a chord angle larger than any finite chord angle") + .def_static("negative", &S1ChordAngle::Negative, + "Return a chord angle smaller than Zero()") + + // Properties + .def_property_readonly("radians", &S1ChordAngle::radians, + "The angle in radians.\n\n" + "Note: this performs a trigonometric conversion and should be\n" + "avoided in inner loops.") + .def_property_readonly("degrees", &S1ChordAngle::degrees, + "The angle in degrees.\n\n" + "Note: this performs a trigonometric conversion and should be\n" + "avoided in inner loops.") + .def_property_readonly("e5", [](const S1ChordAngle& self) { + MaybeThrowIfSpecial(self, "self"); + return self.e5(); + }, "The E5 representation (degrees * 1e5, rounded).\n\n" + "Raises ValueError if the angle is negative() or infinity().") + .def_property_readonly("e6", [](const S1ChordAngle& self) { + MaybeThrowIfSpecial(self, "self"); + return self.e6(); + }, "The E6 representation (degrees * 1e6, rounded).\n\n" + "Raises ValueError if the angle is negative() or infinity().") + .def_property_readonly("e7", [](const S1ChordAngle& self) { + MaybeThrowIfSpecial(self, "self"); + return self.e7(); + }, "The E7 representation (degrees * 1e7, rounded).\n\n" + "Raises ValueError if the angle is negative() or infinity().") + + // Predicates + .def("is_zero", &S1ChordAngle::is_zero, + "Return true if this is exactly zero") + .def("is_negative", &S1ChordAngle::is_negative, + "Return true if this is less than zero (e.g. Negative())") + .def("is_infinity", &S1ChordAngle::is_infinity, + "Return true if this is the Infinity() sentinel") + .def("is_special", &S1ChordAngle::is_special, + "Return true if this is Negative() or Infinity()") + .def("is_valid", &S1ChordAngle::is_valid, + "Return true if the internal representation is valid.\n\n" + "Negative() and Infinity() are both considered valid.") + + // Geometric operations + .def("to_angle", &S1ChordAngle::ToAngle, + "Convert to an S1Angle.\n\n" + "Infinity() converts to S1Angle::Infinity(); Negative() converts\n" + "to an unspecified negative S1Angle. Uses trigonometric functions\n" + "and should be avoided in inner loops.") + .def("sin", [](const S1ChordAngle& self) { + MaybeThrowIfSpecial(self, "self"); + return sin(self); + }, + "Return the sine of the chord angle.\n\n" + "More accurate and efficient than converting to S1Angle first.\n" + "Raises ValueError if the angle is negative() or infinity().") + .def("cos", [](const S1ChordAngle& self) { + MaybeThrowIfSpecial(self, "self"); + return cos(self); + }, + "Return the cosine of the chord angle.\n\n" + "More accurate and efficient than converting to S1Angle first.\n" + "Raises ValueError if the angle is negative() or infinity().") + .def("tan", [](const S1ChordAngle& self) { + MaybeThrowIfSpecial(self, "self"); + return tan(self); + }, + "Return the tangent of the chord angle.\n\n" + "More accurate and efficient than converting to S1Angle first.\n" + "Raises ValueError if the angle is negative() or infinity().") + + // Operators + .def(py::self == py::self, "Return true if chord angles are equal") + .def(py::self != py::self, "Return true if chord angles are not equal") + .def(py::self < py::self, + "Return true if this is less than other (by length2)") + .def(py::self > py::self, + "Return true if this is greater than other (by length2)") + .def(py::self <= py::self, + "Return true if this is less than or equal to other") + .def(py::self >= py::self, + "Return true if this is greater than or equal to other") + .def("__add__", [](const S1ChordAngle& a, const S1ChordAngle& b) { + MaybeThrowIfSpecial(a, "left operand"); + MaybeThrowIfSpecial(b, "right operand"); + return a + b; + }, py::is_operator(), + "Add two chord angles, clamping the result to [0, Pi].\n\n" + "Raises ValueError if either operand is Negative() or Infinity().") + .def("__sub__", [](const S1ChordAngle& a, const S1ChordAngle& b) { + MaybeThrowIfSpecial(a, "left operand"); + MaybeThrowIfSpecial(b, "right operand"); + return a - b; + }, py::is_operator(), + "Subtract two chord angles, clamping the result to [0, Pi].\n\n" + "Raises ValueError if either operand is Negative() or Infinity().") + .def("__hash__", [](const S1ChordAngle& self) { + return absl::Hash()(self.length2()); + }) + + // String representation + .def("__repr__", [](const S1ChordAngle& a) { + std::ostringstream oss; + oss << "S1ChordAngle(" << a << ")"; + return oss.str(); + }) + .def("__str__", [](const S1ChordAngle& a) { + std::ostringstream oss; + oss << a; + return oss.str(); + }); +} diff --git a/src/python/s1chord_angle_test.py b/src/python/s1chord_angle_test.py new file mode 100644 index 00000000..1183101a --- /dev/null +++ b/src/python/s1chord_angle_test.py @@ -0,0 +1,276 @@ +"""Tests for S1ChordAngle pybind11 bindings.""" + +import math +import unittest +import s2geometry_pybind as s2 + + +class TestS1ChordAngle(unittest.TestCase): + """Test cases for S1ChordAngle bindings.""" + + # Constructors + + def test_default_constructor(self): + a = s2.S1ChordAngle() + self.assertTrue(a.is_zero()) + + def test_constructor_from_two_points(self): + x = s2.S2Point(1.0, 0.0, 0.0) + y = s2.S2Point(0.0, 1.0, 0.0) + a = s2.S1ChordAngle(x, y) + self.assertAlmostEqual(a.radians, math.pi / 2) + + def test_constructor_from_same_point(self): + p = s2.S2Point(1.0, 0.0, 0.0) + a = s2.S1ChordAngle(p, p) + self.assertTrue(a.is_zero()) + + def test_constructor_from_antipodal_points(self): + x = s2.S2Point(1.0, 0.0, 0.0) + y = s2.S2Point(-1.0, 0.0, 0.0) + a = s2.S1ChordAngle(x, y) + self.assertEqual(a, s2.S1ChordAngle.straight()) + + def test_constructor_non_unit_length_raises(self): + bad = s2.S2Point(2.0, 0.0, 0.0) + ok = s2.S2Point(1.0, 0.0, 0.0) + with self.assertRaises(ValueError): + s2.S1ChordAngle(bad, ok) + with self.assertRaises(ValueError): + s2.S1ChordAngle(ok, bad) + + def test_constructor_from_s1_angle(self): + angle = s2.S1Angle.from_degrees(90.0) + a = s2.S1ChordAngle(angle) + self.assertAlmostEqual(a.radians, math.pi / 2) + + def test_constructor_from_s1_angle_negative(self): + a = s2.S1ChordAngle(s2.S1Angle.from_radians(-1.0)) + self.assertTrue(a.is_negative()) + + def test_constructor_from_s1_angle_infinity(self): + a = s2.S1ChordAngle(s2.S1Angle.infinity()) + self.assertTrue(a.is_infinity()) + + def test_constructor_from_s1_angle_larger_than_pi_clamps(self): + a = s2.S1ChordAngle(s2.S1Angle.from_radians(math.pi + 1.0)) + self.assertEqual(a, s2.S1ChordAngle.straight()) + + # Factory methods + + def test_from_radians(self): + a = s2.S1ChordAngle.from_radians(math.pi / 2) + self.assertAlmostEqual(a.radians, math.pi / 2) + + def test_from_degrees(self): + a = s2.S1ChordAngle.from_degrees(90.0) + self.assertAlmostEqual(a.degrees, 90.0) + + def test_zero(self): + a = s2.S1ChordAngle.zero() + self.assertTrue(a.is_zero()) + + def test_right(self): + a = s2.S1ChordAngle.right() + self.assertAlmostEqual(a.radians, math.pi / 2) + + def test_straight(self): + a = s2.S1ChordAngle.straight() + self.assertAlmostEqual(a.radians, math.pi) + + def test_infinity(self): + a = s2.S1ChordAngle.infinity() + self.assertTrue(a.is_infinity()) + self.assertTrue(a.is_special()) + + def test_negative(self): + a = s2.S1ChordAngle.negative() + self.assertTrue(a.is_negative()) + self.assertTrue(a.is_special()) + + # Properties + + def test_radians(self): + a = s2.S1ChordAngle.from_radians(1.0) + self.assertAlmostEqual(a.radians, 1.0) + + def test_degrees(self): + a = s2.S1ChordAngle.from_degrees(45.0) + self.assertAlmostEqual(a.degrees, 45.0) + + def test_e5_e6_e7(self): + a = s2.S1ChordAngle.from_degrees(45.0) + self.assertEqual(a.e5, 4500000) + self.assertEqual(a.e6, 45000000) + self.assertEqual(a.e7, 450000000) + + def test_e5_e6_e7_special_raises(self): + with self.assertRaises(ValueError): + _ = s2.S1ChordAngle.negative().e5 + with self.assertRaises(ValueError): + _ = s2.S1ChordAngle.infinity().e5 + with self.assertRaises(ValueError): + _ = s2.S1ChordAngle.negative().e6 + with self.assertRaises(ValueError): + _ = s2.S1ChordAngle.infinity().e6 + with self.assertRaises(ValueError): + _ = s2.S1ChordAngle.negative().e7 + with self.assertRaises(ValueError): + _ = s2.S1ChordAngle.infinity().e7 + + # Predicates + + def test_is_zero(self): + self.assertTrue(s2.S1ChordAngle.zero().is_zero()) + self.assertFalse(s2.S1ChordAngle.right().is_zero()) + + def test_is_negative(self): + self.assertTrue(s2.S1ChordAngle.negative().is_negative()) + self.assertFalse(s2.S1ChordAngle.zero().is_negative()) + + def test_is_infinity(self): + self.assertTrue(s2.S1ChordAngle.infinity().is_infinity()) + self.assertFalse(s2.S1ChordAngle.straight().is_infinity()) + + def test_is_special(self): + self.assertTrue(s2.S1ChordAngle.negative().is_special()) + self.assertTrue(s2.S1ChordAngle.infinity().is_special()) + self.assertFalse(s2.S1ChordAngle.zero().is_special()) + self.assertFalse(s2.S1ChordAngle.straight().is_special()) + + def test_is_valid(self): + self.assertTrue(s2.S1ChordAngle.zero().is_valid()) + self.assertTrue(s2.S1ChordAngle.straight().is_valid()) + self.assertTrue(s2.S1ChordAngle.negative().is_valid()) + self.assertTrue(s2.S1ChordAngle.infinity().is_valid()) + + # Geometric operations + + def test_to_angle(self): + a = s2.S1ChordAngle.from_degrees(90.0) + angle = a.to_angle() + self.assertAlmostEqual(angle.degrees, 90.0) + + def test_to_angle_special_values(self): + self.assertEqual(s2.S1ChordAngle.infinity().to_angle(), + s2.S1Angle.infinity()) + self.assertTrue(s2.S1ChordAngle.negative().to_angle().radians < 0) + + def test_sin_cos_tan(self): + a = s2.S1ChordAngle.from_degrees(30.0) + self.assertAlmostEqual(a.sin(), 0.5, places=10) + self.assertAlmostEqual(a.cos(), math.sqrt(3) / 2, places=10) + self.assertAlmostEqual(a.tan(), 1.0 / math.sqrt(3), places=10) + + def test_sin_cos_tan_special_raises(self): + with self.assertRaises(ValueError): + s2.S1ChordAngle.negative().sin() + with self.assertRaises(ValueError): + s2.S1ChordAngle.infinity().sin() + with self.assertRaises(ValueError): + s2.S1ChordAngle.negative().cos() + with self.assertRaises(ValueError): + s2.S1ChordAngle.infinity().cos() + with self.assertRaises(ValueError): + s2.S1ChordAngle.negative().tan() + with self.assertRaises(ValueError): + s2.S1ChordAngle.infinity().tan() + + # Operators + + def test_equality(self): + a = s2.S1ChordAngle.from_degrees(45.0) + b = s2.S1ChordAngle.from_degrees(45.0) + c = s2.S1ChordAngle.from_degrees(90.0) + self.assertTrue(a == b) + self.assertTrue(a != c) + + def test_comparison(self): + a = s2.S1ChordAngle.from_degrees(30.0) + b = s2.S1ChordAngle.from_degrees(60.0) + self.assertTrue(a < b) + self.assertTrue(b > a) + self.assertTrue(a <= a) + self.assertTrue(a >= a) + + def test_comparison_special_values(self): + # Negative < Zero < finite < Infinity. + neg = s2.S1ChordAngle.negative() + zero = s2.S1ChordAngle.zero() + finite = s2.S1ChordAngle.from_degrees(45.0) + inf = s2.S1ChordAngle.infinity() + self.assertLess(neg, zero) + self.assertLess(zero, finite) + self.assertLess(finite, inf) + + def test_add(self): + a = s2.S1ChordAngle.from_degrees(30.0) + b = s2.S1ChordAngle.from_degrees(60.0) + total = a + b + self.assertAlmostEqual(total.degrees, 90.0, places=5) + + def test_add_clamps_to_pi(self): + a = s2.S1ChordAngle.from_degrees(120.0) + b = s2.S1ChordAngle.from_degrees(120.0) + total = a + b + self.assertEqual(total, s2.S1ChordAngle.straight()) + + def test_add_special_raises(self): + a = s2.S1ChordAngle.from_degrees(30.0) + with self.assertRaises(ValueError): + _ = a + s2.S1ChordAngle.negative() + with self.assertRaises(ValueError): + _ = s2.S1ChordAngle.infinity() + a + + def test_sub(self): + a = s2.S1ChordAngle.from_degrees(60.0) + b = s2.S1ChordAngle.from_degrees(30.0) + diff = a - b + self.assertAlmostEqual(diff.degrees, 30.0, places=5) + + def test_sub_clamps_at_zero(self): + a = s2.S1ChordAngle.from_degrees(30.0) + b = s2.S1ChordAngle.from_degrees(60.0) + diff = a - b + self.assertTrue(diff.is_zero()) + + def test_sub_special_raises(self): + a = s2.S1ChordAngle.from_degrees(30.0) + with self.assertRaises(ValueError): + _ = a - s2.S1ChordAngle.negative() + + def test_add_assign(self): + a = s2.S1ChordAngle.from_degrees(30.0) + a += s2.S1ChordAngle.from_degrees(60.0) + self.assertAlmostEqual(a.degrees, 90.0, places=5) + + def test_sub_assign(self): + a = s2.S1ChordAngle.from_degrees(60.0) + a -= s2.S1ChordAngle.from_degrees(30.0) + self.assertAlmostEqual(a.degrees, 30.0, places=5) + + def test_hash(self): + a = s2.S1ChordAngle.from_degrees(45.0) + b = s2.S1ChordAngle.from_degrees(45.0) + self.assertEqual(hash(a), hash(b)) + s = {a, b} + self.assertEqual(len(s), 1) + + # String representation + + def test_repr(self): + a = s2.S1ChordAngle.from_degrees(90.0) + self.assertEqual(repr(a), "S1ChordAngle(90.0000000)") + + def test_str(self): + a = s2.S1ChordAngle.from_degrees(90.0) + self.assertEqual(str(a), "90.0000000") + + def test_str_negative_sentinel(self): + # negative() is a sentinel value; its string output is implementation- + # defined but should represent a negative angle. + self.assertIn("-", str(s2.S1ChordAngle.negative())) + + +if __name__ == "__main__": + unittest.main()