Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions src/python/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ pybind_extension(
":r1interval_bindings",
":r2point_bindings",
":r2rect_bindings",
":s1angle_bindings",
":s1interval_bindings",
":s2point_bindings",
],
Expand Down Expand Up @@ -58,6 +59,14 @@ pybind_library(
],
)

pybind_library(
name = "s1angle_bindings",
srcs = ["s1angle_bindings.cc"],
deps = [
"//:s2",
],
)

pybind_library(
name = "s1interval_bindings",
srcs = ["s1interval_bindings.cc"],
Expand All @@ -84,6 +93,12 @@ py_test(
deps = [":s2geometry_pybind"],
)

py_test(
name = "s1angle_test",
srcs = ["s1angle_test.py"],
deps = [":s2geometry_pybind"],
)

py_test(
name = "r2point_test",
srcs = ["r2point_test.py"],
Expand Down
4 changes: 2 additions & 2 deletions src/python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,9 @@ The Python bindings follow the C++ API closely but with Pythonic conventions:
- Method names are converted to snake_case (converted from UpperCamelCase C++ function names)

**Properties vs. Methods:**
- Simple coordinate accessors are properties: `point.x`, `point.y`, `interval.lo`, `interval.hi`
- Simple accessors that return internal state (including trivial unit conversions) are properties: `point.x`, `point.y`, `interval.lo`, `interval.hi`, `angle.radians`, `angle.degrees`
- Properties are always read-only. To create a modified object, use a constructor or factory method.
- Other functions are not properties: `angle.radians()`, `angle.degrees()`, `interval.length()`
- Other functions are methods: `interval.length()`, `angle.normalized()`, `angle.sin()`

**Invalid Values:**
- Invalid inputs to constructions or functions raises `ValueError`.
Expand Down
14 changes: 12 additions & 2 deletions src/python/module.cc
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,26 @@ namespace py = pybind11;
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_s1interval(py::module& m);
void bind_s2point(py::module& m);

PYBIND11_MODULE(s2geometry_bindings, m) {
m.doc() = "S2 Geometry Python bindings using pybind11";

// Bind core geometry classes in dependency order
// Bind core geometry classes in dependency order.
// Each class must be registered before classes that use it as a
// parameter or return type.

// No dependencies
bind_r1interval(m);
bind_r2point(m);
bind_r2rect(m);
bind_s1interval(m);
bind_s2point(m);

// Deps: r1interval, r2point
bind_r2rect(m);

// Deps: s2point
bind_s1angle(m);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This says "dependency order", but eithe make it alphabetic if this has no deps, or comment what the dep is.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reorganized into dependency tranches with per-line comments (e.g. // Deps: s2point).

}
161 changes: 161 additions & 0 deletions src/python/s1angle_bindings.cc
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
#include <pybind11/pybind11.h>
#include <pybind11/operators.h>

#include <cmath>
#include <sstream>

#include "absl/strings/str_cat.h"
#include "s2/s1angle.h"
#include "s2/s2point.h"

namespace py = pybind11;

namespace {

void MaybeThrowNotNormalized(const S1Angle& angle) {
if (!angle.IsNormalized()) {
throw py::value_error(
absl::StrCat("Angle ", angle.degrees(),
" degrees is not in the normalized range (-180, 180]"));
}
}

} // namespace

void bind_s1angle(py::module& m) {
py::class_<S1Angle>(m, "S1Angle",
"Represents a one-dimensional angle.\n\n"
"The internal representation is a double-precision value in radians,\n"
"so conversion to and from radians is exact. Conversions between\n"
"degrees and radians are not always exact due to floating-point\n"
"arithmetic (e.g. from_degrees(60).degrees != 60). Use e5/e6/e7\n"
"representations for exact discrete comparisons.\n\n"
"See s2/s1angle.h for comprehensive documentation including exact\n"
"conversion guarantees and edge cases.")
// Constructors
.def(py::init<>(), "Default constructor creates a zero angle")
.def(py::init<const S2Point&, const S2Point&>(),
py::arg("x"), py::arg("y"),
"Construct the angle between two points.\n\n"
"This is also equal to the distance between the points on the\n"
"unit sphere. The points do not need to be normalized.")

// Factory methods
.def_static("from_radians", &S1Angle::Radians, py::arg("radians"),
"Construct an angle from its measure in radians.\n\n"
"This conversion is exact.")
.def_static("from_degrees", &S1Angle::Degrees, py::arg("degrees"),
"Construct an angle from its measure in degrees.\n\n"
"Note: the round-trip from_degrees(x).degrees is not\n"
"always exact. For example, from_degrees(60).degrees != 60.")
.def_static("from_e5", &S1Angle::E5, py::arg("e5"),
"Construct an angle from its E5 representation.\n\n"
"E5 is degrees multiplied by 1e5 and rounded to the\n"
"nearest integer.\n\n"
"Note: E5 does not share the exact conversion guarantees\n"
"of E6/E7. Avoid testing E5 values for exact equality\n"
"with other formats.")
.def_static("from_e6", &S1Angle::E6, py::arg("e6"),
"Construct an angle from its E6 representation.\n\n"
"E6 is degrees multiplied by 1e6 and rounded to the\n"
"nearest integer.\n\n"
"For any integer n: from_degrees(n) == from_e6(1000000 * n).")
.def_static("from_e7", &S1Angle::E7, py::arg("e7"),
"Construct an angle from its E7 representation.\n\n"
"E7 is degrees multiplied by 1e7 and rounded to the\n"
"nearest integer.\n\n"
"For any integer n: from_degrees(n) == from_e7(10000000 * n).")
.def_static("zero", &S1Angle::Zero, "Return a zero angle")
.def_static("infinity", &S1Angle::Infinity,
"Return an angle larger than any finite angle")

// Properties
.def_property_readonly("radians", &S1Angle::radians,
"The angle in radians.\n\n"
"This is the internal representation, so the conversion is exact.")
.def_property_readonly("degrees", &S1Angle::degrees,
"The angle in degrees.\n\n"
"Note: from_degrees(x).degrees is not always exactly x due to\n"
"the intermediate conversion to radians.")
.def_property_readonly("e5", [](const S1Angle& self) {
MaybeThrowNotNormalized(self);
return self.e5();
},
"The E5 representation (degrees * 1e5, rounded).\n\n"
"The angle must be in the normalized range (-180, 180] degrees.\n"
"Raises ValueError if out of range.")
.def_property_readonly("e6", [](const S1Angle& self) {
MaybeThrowNotNormalized(self);
return self.e6();
},
"The E6 representation (degrees * 1e6, rounded).\n\n"
"The angle must be in the normalized range (-180, 180] degrees.\n"
"Raises ValueError if out of range.")
.def_property_readonly("e7", [](const S1Angle& self) {
MaybeThrowNotNormalized(self);
return self.e7();
},
"The E7 representation (degrees * 1e7, rounded).\n\n"
"The angle must be in the normalized range (-180, 180] degrees.\n"
"Raises ValueError if out of range.")

// Predicates
.def("is_normalized", &S1Angle::IsNormalized,
"Return true if the angle is in the normalized range (-180, 180]")

// Geometric operations
.def("__abs__", &S1Angle::abs,
"Return the absolute value of the angle")
.def("normalized", &S1Angle::Normalized,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

might be worthwhile to expose Normalize()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IsNormalized, too

Copy link
Copy Markdown
Contributor Author

@deustis deustis Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exposed both is_normalized() and normalize()

"Return the angle normalized to the range (-180, 180] degrees")
.def("normalize", &S1Angle::Normalize,
"Normalize this angle in-place to the range (-180, 180] degrees")
.def("sin", [](const S1Angle& self) { return sin(self); },
"Return the sine of the angle")
.def("cos", [](const S1Angle& self) { return cos(self); },
"Return the cosine of the angle")
.def("tan", [](const S1Angle& self) { return tan(self); },
"Return the tangent of the angle")

// Operators
.def(py::self == py::self, "Return true if angles are exactly equal")
.def(py::self != py::self, "Return true if angles are not exactly equal")
.def(py::self < py::self, "Return true if this angle is less than other")
.def(py::self > py::self,
"Return true if this angle is greater than other")
.def(py::self <= py::self,
"Return true if this angle is less than or equal to other")
.def(py::self >= py::self,
"Return true if this angle is greater than or equal to other")
.def(-py::self, "Negate angle")
.def(py::self + py::self, "Add two angles")
.def(py::self - py::self, "Subtract two angles")
.def(py::self * double(), "Multiply angle by scalar")
.def("__rmul__", [](const S1Angle& self, double m) {
return m * self;
}, "Multiply angle by scalar (reversed operands)")
.def(py::self / double(), "Divide angle by scalar")
.def("__truediv__", [](const S1Angle& a, const S1Angle& b) -> double {
return a / b;
}, py::arg("other"), "Divide two angles, returning a scalar ratio")
.def(py::self += py::self, "In-place addition")
.def(py::self -= py::self, "In-place subtraction")
.def("__imul__", [](S1Angle& self, double m) -> S1Angle& {
return self *= m;
}, py::arg("m"), "In-place multiplication by scalar")
.def("__itruediv__", [](S1Angle& self, double m) -> S1Angle& {
return self /= m;
}, py::arg("m"), "In-place division by scalar")

// String representation
.def("__repr__", [](S1Angle a) {
std::ostringstream oss;
oss << "S1Angle(" << a << ")";
return oss.str();
})
.def("__str__", [](S1Angle a) {
std::ostringstream oss;
oss << a;
return oss.str();
});
}
Loading