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
18 changes: 18 additions & 0 deletions src/python/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,26 @@ py_library(
pybind_extension(
name = "s2geometry_bindings",
srcs = ["module.cc"],
copts = ["-DNDEBUG"],
Copy link
Member

Choose a reason for hiding this comment

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

I'm not sure about this. Definitely needs a comment.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Agree, let's align on this thread and then I'll update this:
#534 (comment)

deps = [
":s1interval_bindings",
":s2point_bindings",
],
)

pybind_library(
name = "s1interval_bindings",
srcs = ["s1interval_bindings.cc"],
copts = ["-DNDEBUG"],
deps = [
"//:s2",
],
)

pybind_library(
name = "s2point_bindings",
srcs = ["s2point_bindings.cc"],
copts = ["-DNDEBUG"],
deps = [
"//:s2",
],
Expand All @@ -42,6 +54,12 @@ pybind_library(
# Python Tests
# ========================================

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

py_test(
name = "s2point_test",
srcs = ["s2point_test.py"],
Expand Down
87 changes: 74 additions & 13 deletions src/python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,19 +13,9 @@ The S2 Geometry library is transitioning from SWIG-based bindings to pybind11-ba

Once the pybind11 bindings are feature-complete and stable, the SWIG bindings will be deprecated and the pybind11 package will be renamed to `s2geometry` to become the primary Python API.

## Directory Structure
## User Guide

```
python/
├── module.cc # Binding module entry point
├── s2point_bindings.cc # Bindings for S2Point (add more *_bindings.cc as needed)
├── s2geometry_pybind/ # Dir for Python package
│ └── __init__.py # Package initialization
├── s2point_test.py # Tests for S2Point (add more *_test.py as needed)
└── BUILD.bazel # Build rules for bindings, library, and tests
```

## Usage Example
### Usage Example

```python
import s2geometry_pybind as s2
Expand All @@ -36,8 +26,65 @@ sum_point = p1 + p2
print(sum_point)
```

### Interface Notes

The Python bindings follow the C++ API closely but with Pythonic conventions:

**Naming 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)

**Properties vs. Methods:**
- Simple coordinate accessors are properties: `point.x`, `point.y`, `interval.lo`, `interval.hi`
- If the C++ class has a trivial set_foo method for the property, then the Python property is mutable (otherwise it is a read-only property).
- Other functions are not properties: `angle.radians()`, `angle.degrees()`, `interval.length()`

**Invalid/Unnormalized Values:**
- Some constructors accept invalid values or unnormalized values.
- Examples:
- `S1Interval(0.0, 4.0)` with `4.0 > π` creates interval where `is_valid()` is `False`
- `S2Point(3.0, 4.0, 0.0)` creates an unnormalized point outside of the unit sphere; use `normalize()` to normalize.
- In **C++** invalid values will typically trigger (`ABSL_DCHECK`) when compiled with debug options.
- In **Python bindings** debug assertions (`ABSL_DCHECK`) are disabled, matching the production optimized C++ behavior.
Copy link
Member

Choose a reason for hiding this comment

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

Hmm. Why? Isn't this an ODR violation waiting to happen?

Copy link
Contributor Author

@deustis deustis Jan 28, 2026

Choose a reason for hiding this comment

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

The challenge is that the C++ implementation uses debug assertions which is perhaps the source of the "ODR violation". That leaves us two choices for the Python interface: adhere to the dbg behavior or the opt behavior.

The current PR does the latter, meaning anywhere the opt C++ code accepts invalid inputs the Python does the same (as well as exposing an is_valid if that is available in C++).

Alternatively, we could follow the dbg behavior where the user would expect the Python code to raise exceptions. The challenge is that the ABSL debug checks abort the process and aren't "catchable" in the Python bindings. So we would need to either change the C++ assertions, or reproduce the validation in the Pybind implementation itself.

From the C++ perspective, would you say that the debug assertions are enough of a deterrent that we can say the opt behavior is the de-facto interface?

In that case I agree we should probably lean into the cbg behavior. We might also consider switching from ABSL_DCHECK to ABSL_CHECK in the C++ code? I'm guessing we can't make larger interface changes to the C++ code such as raising exceptions or returning Status messages.

So that leaves us with trying to reproduce the validity checks in the Python bindings themselves. This is doable but it would be non-trivial For example, for S1Interval we would to refactor this validity function to be able to call it before calling the various C++ function that would fail with assertions:
https://github.com/google/s2geometry/blob/master/src/s2/s1interval.h#L247

inline bool S1Interval::is_valid() const {
  return (std::fabs(lo()) <= M_PI && std::fabs(hi()) <= M_PI &&
          !(lo() == -M_PI && hi() != M_PI) &&
          !(hi() == -M_PI && lo() != M_PI));
}

Copy link
Member

Choose a reason for hiding this comment

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

That leaves us two choices for the Python interface: adhere to the dbg behavior or the opt behavior.

Why can't the user choose by setting the compilation mode?

From the C++ perspective, would you say that the debug assertions are enough of a deterrent that we can say the opt behavior is the de-facto interface?

It's the same interface for dbg and opt. The interface is that there are preconditions and if they are violated, anything could happen. This is fine for C++ but is not very Pythonic.

Now I see that S1Interval is a bit odd in that it DCHECKs in the constructor rather just than when you try to use it. I think we could add S1Interval::FromBounds(lo, hi) -> optional<S1Interval> or StatusOr<S1Interval> (not sure how much the Status would add, but maybe good for consistency). We could also move the DCHECK out of the constructor.

The Python constructor could call this and throw ValueError, then we deal with only valid intervals. Maybe the setters need to go to preserve "always valid".

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The interface is that there are preconditions and if they are violated, anything could happen.

😆 -- IMHO, not so great for C++ either.

This is fine for C++ but is not very Pythonic.

Agreed! Let's go with exceptions for the Python interface then? I'd also rather avoid differences in behavior depending on compilation mode.

Now I see that S1Interval is a bit odd in that it DCHECKs in the constructor

Thanks for the suggestions. I'll need to circle back to this next week but something along those lines should work.


**Documentation:**
- Python docstrings provide essential information about parameters, return values, and key behaviors
- For comprehensive documentation including edge cases and algorithmic details, refer to the C++ header files
- The C++ documentation is the authoritative source of truth

**Operators:**
- Standard Python operators work as expected: `+`, `-`, `*`, `==`, `!=`, `<`, `>` (for C++ classes that implement those operators)

**String Representations:**
- `repr()` prefixes the class name and delegates to C++ `operator<<` for the value
- `str()` delegates to C++ `operator<<` for a cleaner output
- Example: `repr(S1Interval(0.0, 2.0))` returns `'S1Interval([0, 2])'` while `str()` returns `'[0, 2]'`

**Vector Inheritance:**
- In C++, various geometry classes inherit from or expose vector types (e.g., `S2Point` inherits from `Vector3_d`, `R2Point` is a type alias for `Vector2_d`, `R1Interval` returns bounds as `Vector2_d`)
- The Python bindings do **not** expose this inheritance hierarchy; it is treated as an implementation detail
- Instead, classes that inherit from a vector expose key functions from the `BasicVector` interface (e.g., `norm()`, `dot_prod()`, `cross_prod()`)
- C++ functions that accept or return a vector object use a Python tuple (of length matching the vector dimension)
- Array indexing operators (e.g., `point[0]`) are not currently supported

**Serialization:**
- The C++ Encoder/Decoder serialization functions are not currently supported

## Development

### Directory Structure

```
python/
├── module.cc # Binding module entry point
├── s2point_bindings.cc # Bindings for S2Point (add more *_bindings.cc as needed)
├── s2geometry_pybind/ # Dir for Python package
│ └── __init__.py # Package initialization
├── s2point_test.py # Tests for S2Point (add more *_test.py as needed)
└── BUILD.bazel # Build rules for bindings, library, and tests
```

### Building with Bazel (pybind11 bindings)

Bazel can be used for development and testing of the new pybind11-based bindings.
Expand Down Expand Up @@ -82,4 +129,18 @@ To add bindings for a new class:
1. Create `<classname>_bindings.cc` with pybind11 bindings
2. Update `BUILD.bazel` to add a new `pybind_library` target
3. Update `module.cc` to call your binding function
4. Create tests in `<classname>_test.py`
4. Create tests in `<classname>_test.py`

### Binding File Organization

Use the following sections to organize functions within the bindings files and tests. Secondarily, follow the order in which functions are declared in the C++ headers.

1. **Constructors** - Default constructors and constructors with parameters
2. **Factory methods** - Static factory methods (e.g., `from_degrees`, `from_radians`, `zero`, `invalid`)
3. **Properties** - Mutable and read-only properties (e.g., coordinate accessors like `x`, `y`, `lo`, `hi`)
4. **Predicates** - Simple boolean state checks (e.g., `is_empty`, `is_valid`, `is_full`)
5. **Geometric operations** - All other methods including conversions, computations, containment checks, set operations, normalization, and distance calculations
6. **Vector operations** - Methods from the Vector base class (e.g., `norm`, `norm2`, `normalize`, `dot_prod`, `cross_prod`, `angle`). Only applicable to classes that inherit from `util/math/vector.h`
7. **Operators** - Operator overloads (e.g., `==`, `+`, `*`, comparison operators)
8. **String representation** - `__repr__` (which also provides `__str__`), and string conversion methods like `to_string_in_degrees`
9. **Module-level functions** - Standalone functions (e.g., trigonometric functions for S1Angle)
4 changes: 4 additions & 0 deletions src/python/module.cc
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,13 @@
namespace py = pybind11;

// Forward declarations for binding functions
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_s1interval(m);
bind_s2point(m);
}
119 changes: 119 additions & 0 deletions src/python/s1interval_bindings.cc
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
#include <pybind11/pybind11.h>
#include <pybind11/operators.h>

#include <sstream>

#include "s2/s1interval.h"

namespace py = pybind11;

void bind_s1interval(py::module& m) {
py::class_<S1Interval>(m, "S1Interval")
// Constructors
.def(py::init<>(), "Default constructor creates an empty interval")
.def(py::init<double, double>(),
py::arg("lo"), py::arg("hi"),
"Constructor that accepts the endpoints of the interval.\n\n"
"Both endpoints must be in the range -Pi to Pi inclusive.")

// Static factory methods
.def_static("empty", &S1Interval::Empty, "Returns the empty interval")
.def_static("full", &S1Interval::Full, "Returns the full interval")
.def_static("from_point", &S1Interval::FromPoint, py::arg("p"),
"Constructs an interval containing a single point")
.def_static("from_point_pair", &S1Interval::FromPointPair,
py::arg("p1"), py::arg("p2"),
"Constructs the minimal interval containing two points")

// Properties
.def_property("lo", &S1Interval::lo, &S1Interval::set_lo, "Lower bound")
.def_property("hi", &S1Interval::hi, &S1Interval::set_hi, "Upper bound")
.def("bounds", [](const S1Interval& self) {
return py::make_tuple(self.lo(), self.hi());
}, "Return bounds as a tuple (lo, hi)")

// Predicates
.def("is_valid", &S1Interval::is_valid,
"An interval is valid if neither bound exceeds Pi in absolute value")
.def("is_full", &S1Interval::is_full,
"Return true if the interval contains all points on the unit circle")
.def("is_empty", &S1Interval::is_empty,
"Return true if the interval is empty, i.e. it contains no points")
.def("is_inverted", &S1Interval::is_inverted,
"Return true if lo() > hi(). (This is true for empty intervals.)")

// Geometric operations
.def("center", &S1Interval::GetCenter,
"Return the midpoint of the interval.\n\n"
"For full and empty intervals, the result is arbitrary.")
.def("length", &S1Interval::GetLength,
"Return the length of the interval.\n\n"
"The length of an empty interval is negative.")
.def("complement_center", &S1Interval::GetComplementCenter,
"Return the midpoint of the complement of the interval.\n\n"
"For full and empty intervals, the result is arbitrary. For a\n"
"singleton interval, the result is its antipodal point on S1.")
.def("contains", py::overload_cast<double>(&S1Interval::Contains, py::const_),
py::arg("p"),
"Return true if the interval (which is closed) contains the point 'p'")
.def("interior_contains", py::overload_cast<double>(
&S1Interval::InteriorContains, py::const_),
py::arg("p"), "Return true if the interior of the interval contains the point 'p'")
.def("contains", py::overload_cast<const S1Interval&>(
&S1Interval::Contains, py::const_),
py::arg("other"),
"Return true if the interval contains the given interval 'y'")
.def("interior_contains", py::overload_cast<const S1Interval&>(
&S1Interval::InteriorContains, py::const_),
py::arg("other"),
"Return true if the interior of this interval contains the entire interval 'y'")
.def("intersects", &S1Interval::Intersects, py::arg("other"),
"Return true if the two intervals contain any points in common")
.def("interior_intersects", &S1Interval::InteriorIntersects,
py::arg("other"),
"Return true if the interior of this interval contains any point of 'y'")
.def("add_point", &S1Interval::AddPoint, py::arg("p"),
"Expand the interval to contain the given point 'p'.\n\n"
"The point should be an angle in the range [-Pi, Pi].")
.def("project", &S1Interval::Project, py::arg("p"),
"Return the closest point in the interval to 'p'.\n\n"
"The interval must be non-empty.")
.def("expanded", &S1Interval::Expanded, py::arg("margin"),
"Return interval expanded on each side by 'margin' (radians).\n\n"
"If 'margin' is negative, shrink the interval instead. The resulting\n"
"interval may be empty or full. Any expansion of a full interval remains\n"
"full, and any expansion of an empty interval remains empty.")
.def("union", &S1Interval::Union, py::arg("other"),
"Return the smallest interval containing this interval and 'y'")
.def("intersection", &S1Interval::Intersection, py::arg("other"),
"Return the smallest interval containing the intersection with 'y'.\n\n"
"Note that the region of intersection may consist of two disjoint intervals.")
.def("complement", &S1Interval::Complement,
"Return the complement of the interior of the interval")
.def("directed_hausdorff_distance",
&S1Interval::GetDirectedHausdorffDistance,
py::arg("other"),
"Return the directed Hausdorff distance to 'y'")
// Note: default value must match C++ signature in s1interval.h
.def("approx_equals", &S1Interval::ApproxEquals,
py::arg("other"), py::arg("max_error") = 1e-15,
"Return true if approximately equal to 'y'.\n\n"
"Two intervals are approximately equal if each endpoint can be moved\n"
"by at most 'max_error' (radians) to match the other interval.")

// Operators
.def(py::self == py::self, "Return true if two intervals contain the same set of points")
.def(py::self != py::self, "Return true if two intervals do not contain the same set of points")

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