Skip to content

Commit 7fe8e6c

Browse files
authored
pybind: Add S1Interval bindings (#534)
Add pybind11 bindings for S1Interval. Also add interface notes to the python README. (Part of a series of addressing #524.)
1 parent 297893f commit 7fe8e6c

11 files changed

Lines changed: 647 additions & 42 deletions

src/python/BUILD.bazel

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,19 @@ pybind_extension(
2626
name = "s2geometry_bindings",
2727
srcs = ["module.cc"],
2828
deps = [
29+
":s1interval_bindings",
2930
":s2point_bindings",
3031
],
3132
)
3233

34+
pybind_library(
35+
name = "s1interval_bindings",
36+
srcs = ["s1interval_bindings.cc"],
37+
deps = [
38+
"//:s2",
39+
],
40+
)
41+
3342
pybind_library(
3443
name = "s2point_bindings",
3544
srcs = ["s2point_bindings.cc"],
@@ -42,6 +51,12 @@ pybind_library(
4251
# Python Tests
4352
# ========================================
4453

54+
py_test(
55+
name = "s1interval_test",
56+
srcs = ["s1interval_test.py"],
57+
deps = [":s2geometry_pybind"],
58+
)
59+
4560
py_test(
4661
name = "s2point_test",
4762
srcs = ["s2point_test.py"],

src/python/README.md

Lines changed: 74 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -13,19 +13,9 @@ The S2 Geometry library is transitioning from SWIG-based bindings to pybind11-ba
1313

1414
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.
1515

16-
## Directory Structure
16+
## User Guide
1717

18-
```
19-
python/
20-
├── module.cc # Binding module entry point
21-
├── s2point_bindings.cc # Bindings for S2Point (add more *_bindings.cc as needed)
22-
├── s2geometry_pybind/ # Dir for Python package
23-
│ └── __init__.py # Package initialization
24-
├── s2point_test.py # Tests for S2Point (add more *_test.py as needed)
25-
└── BUILD.bazel # Build rules for bindings, library, and tests
26-
```
27-
28-
## Usage Example
18+
### Usage Example
2919

3020
```python
3121
import s2geometry_pybind as s2
@@ -36,8 +26,65 @@ sum_point = p1 + p2
3626
print(sum_point)
3727
```
3828

29+
### Interface Notes
30+
31+
The Python bindings follow the C++ API closely but with Pythonic conventions:
32+
33+
**Naming Conventions:**
34+
- Core classes exist within the top-level module; we may define submodules for utility classes.
35+
- Class names remain unchanged (e.g., `S2Point`, `S1Angle`, `R1Interval`)
36+
- Method names are converted to snake_case (converted from UpperCamelCase C++ function names)
37+
38+
**Properties vs. Methods:**
39+
- Simple coordinate accessors are properties: `point.x`, `point.y`, `interval.lo`, `interval.hi`
40+
- Properties are always read-only. To create a modified object, use a constructor or factory method.
41+
- Other functions are not properties: `angle.radians()`, `angle.degrees()`, `interval.length()`
42+
43+
**Invalid Values:**
44+
- Invalid inputs to constructions or functions raises `ValueError`.
45+
- Example: `S1Interval(0.0, 4.0)` raises `ValueError` because `4.0 > π`.
46+
- Note: In C++, these conditions trigger `ABSL_DCHECK` assertions. The bindings prevent these assertions from firing by pre-validating inputs.
47+
- Note: Python bindings check for invalid inputs and throw C++ exceptions which are caught by
48+
pybind and converted to Python exceptions. Exceptions are normally prohibited by the C++
49+
style guide, but this is the preferred approach for pybind.
50+
51+
**Documentation:**
52+
- Python docstrings provide essential information about parameters, return values, and key behaviors
53+
- For comprehensive documentation including edge cases and algorithmic details, refer to the C++ header files
54+
- The C++ documentation is the authoritative source of truth
55+
56+
**Operators:**
57+
- Standard Python operators work as expected: `+`, `-`, `*`, `==`, `!=`, `<`, `>` (for C++ classes that implement those operators)
58+
59+
**String Representations:**
60+
- `repr()` prefixes the class name and delegates to C++ `operator<<` for the value
61+
- `str()` delegates to C++ `operator<<` for a cleaner output
62+
- Example: `repr(S1Interval(0.0, 2.0))` returns `'S1Interval([0, 2])'` while `str()` returns `'[0, 2]'`
63+
64+
**Vector Inheritance:**
65+
- 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`)
66+
- The Python bindings do **not** expose this inheritance hierarchy; it is treated as an implementation detail
67+
- Instead, classes that inherit from a vector expose key functions from the `BasicVector` interface (e.g., `norm()`, `dot_prod()`, `cross_prod()`)
68+
- C++ functions that accept or return a vector object use a Python tuple (of length matching the vector dimension)
69+
- Array indexing operators (e.g., `point[0]`) are not currently supported
70+
71+
**Serialization:**
72+
- The C++ Encoder/Decoder serialization functions are not currently supported
73+
3974
## Development
4075

76+
### Directory Structure
77+
78+
```
79+
python/
80+
├── module.cc # Binding module entry point
81+
├── s2point_bindings.cc # Bindings for S2Point (add more *_bindings.cc as needed)
82+
├── s2geometry_pybind/ # Dir for Python package
83+
│ └── __init__.py # Package initialization
84+
├── s2point_test.py # Tests for S2Point (add more *_test.py as needed)
85+
└── BUILD.bazel # Build rules for bindings, library, and tests
86+
```
87+
4188
### Building with Bazel (pybind11 bindings)
4289

4390
Bazel can be used for development and testing of the new pybind11-based bindings.
@@ -82,4 +129,18 @@ To add bindings for a new class:
82129
1. Create `<classname>_bindings.cc` with pybind11 bindings
83130
2. Update `BUILD.bazel` to add a new `pybind_library` target
84131
3. Update `module.cc` to call your binding function
85-
4. Create tests in `<classname>_test.py`
132+
4. Create tests in `<classname>_test.py`
133+
134+
### Binding File Organization
135+
136+
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.
137+
138+
1. **Constructors** - Default constructors and constructors with parameters
139+
2. **Factory methods** - Static factory methods (e.g., `from_degrees`, `from_radians`, `zero`, `invalid`)
140+
3. **Properties** - Mutable and read-only properties (e.g., coordinate accessors like `x`, `y`, `lo`, `hi`)
141+
4. **Predicates** - Simple boolean state checks (e.g., `is_empty`, `is_valid`, `is_full`)
142+
5. **Geometric operations** - All other methods including conversions, computations, containment checks, set operations, normalization, and distance calculations
143+
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`
144+
7. **Operators** - Operator overloads (e.g., `==`, `+`, `*`, comparison operators)
145+
8. **String representation** - `__repr__` (which also provides `__str__`), and string conversion methods like `to_string_in_degrees`
146+
9. **Module-level functions** - Standalone functions (e.g., trigonometric functions for S1Angle)

src/python/module.cc

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,13 @@
33
namespace py = pybind11;
44

55
// Forward declarations for binding functions
6+
void bind_s1interval(py::module& m);
67
void bind_s2point(py::module& m);
78

89
PYBIND11_MODULE(s2geometry_bindings, m) {
910
m.doc() = "S2 Geometry Python bindings using pybind11";
11+
12+
// Bind core geometry classes in dependency order
13+
bind_s1interval(m);
1014
bind_s2point(m);
1115
}

src/python/s1interval_bindings.cc

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
#include <pybind11/pybind11.h>
2+
#include <pybind11/operators.h>
3+
4+
#include <sstream>
5+
6+
#include "s2/s1interval.h"
7+
8+
namespace py = pybind11;
9+
10+
namespace {
11+
12+
void MaybeThrowInvalidPoint(double p) {
13+
if (!S1Interval::IsValidPoint(p)) {
14+
throw py::value_error("Invalid S1 point: " + std::to_string(p));
15+
}
16+
}
17+
18+
} // namespace
19+
20+
void bind_s1interval(py::module& m) {
21+
py::class_<S1Interval>(m, "S1Interval")
22+
// Constructors
23+
.def(py::init<>(), "Default constructor creates an empty interval")
24+
.def(py::init([](double lo, double hi) {
25+
MaybeThrowInvalidPoint(lo);
26+
MaybeThrowInvalidPoint(hi);
27+
return S1Interval(lo, hi);
28+
}),
29+
py::arg("lo"), py::arg("hi"),
30+
"Constructor that accepts the endpoints of the interval.\n\n"
31+
"Both endpoints must be in the range -Pi to Pi inclusive.\n"
32+
"Raises ValueError if either bound is outside that range.")
33+
34+
// Static factory methods
35+
.def_static("empty", &S1Interval::Empty, "Returns the empty interval")
36+
.def_static("full", &S1Interval::Full, "Returns the full interval")
37+
.def_static("from_point", &S1Interval::FromPoint, py::arg("p"),
38+
"Constructs an interval containing a single point")
39+
.def_static("from_point_pair",
40+
[](double p1, double p2) {
41+
MaybeThrowInvalidPoint(p1);
42+
MaybeThrowInvalidPoint(p2);
43+
return S1Interval::FromPointPair(p1, p2);
44+
},
45+
py::arg("p1"), py::arg("p2"),
46+
"Constructs the minimal interval containing two points")
47+
48+
// Properties
49+
.def_property_readonly("lo", &S1Interval::lo, "Lower bound")
50+
.def_property_readonly("hi", &S1Interval::hi, "Upper bound")
51+
.def("bounds", [](const S1Interval& self) {
52+
return py::make_tuple(self.lo(), self.hi());
53+
}, "Return bounds as a tuple (lo, hi)")
54+
55+
// Predicates
56+
.def("is_full", &S1Interval::is_full,
57+
"Return true if the interval contains all points on the unit circle")
58+
.def("is_empty", &S1Interval::is_empty,
59+
"Return true if the interval is empty, i.e. it contains no points")
60+
.def("is_inverted", &S1Interval::is_inverted,
61+
"Return true if lo() > hi(). (This is true for empty intervals.)")
62+
63+
// Geometric operations
64+
.def("center", &S1Interval::GetCenter,
65+
"Return the midpoint of the interval.\n\n"
66+
"For full and empty intervals, the result is arbitrary.")
67+
.def("length", &S1Interval::GetLength,
68+
"Return the length of the interval.\n\n"
69+
"The length of an empty interval is negative.")
70+
.def("complement_center", &S1Interval::GetComplementCenter,
71+
"Return the midpoint of the complement of the interval.\n\n"
72+
"For full and empty intervals, the result is arbitrary. For a\n"
73+
"singleton interval, the result is its antipodal point on S1.")
74+
.def("contains", [](const S1Interval& self, double p) {
75+
MaybeThrowInvalidPoint(p);
76+
return self.Contains(p);
77+
}, py::arg("p"),
78+
"Return true if the interval (which is closed) contains the point 'p'")
79+
.def("interior_contains", [](const S1Interval& self, double p) {
80+
MaybeThrowInvalidPoint(p);
81+
return self.InteriorContains(p);
82+
}, py::arg("p"),
83+
"Return true if the interior of the interval contains the point 'p'")
84+
.def("contains", py::overload_cast<const S1Interval&>(
85+
&S1Interval::Contains, py::const_),
86+
py::arg("other"),
87+
"Return true if the interval contains the given interval 'y'")
88+
.def("interior_contains", py::overload_cast<const S1Interval&>(
89+
&S1Interval::InteriorContains, py::const_),
90+
py::arg("other"),
91+
"Return true if the interior of this interval contains the entire interval 'y'")
92+
.def("intersects", &S1Interval::Intersects, py::arg("other"),
93+
"Return true if the two intervals contain any points in common")
94+
.def("interior_intersects", &S1Interval::InteriorIntersects,
95+
py::arg("other"),
96+
"Return true if the interior of this interval contains any point of 'y'")
97+
.def("add_point", [](S1Interval& self, double p) {
98+
MaybeThrowInvalidPoint(p);
99+
self.AddPoint(p);
100+
}, py::arg("p"),
101+
"Expand the interval to contain the given point 'p'.\n\n"
102+
"The point should be an angle in the range [-Pi, Pi].")
103+
.def("project", [](const S1Interval& self, double p) {
104+
if (self.is_empty()) throw py::value_error("Invalid S1Interval");
105+
MaybeThrowInvalidPoint(p);
106+
return self.Project(p);
107+
}, py::arg("p"),
108+
"Return the closest point in the interval to 'p'.\n\n"
109+
"The interval must be non-empty.")
110+
.def("expanded", &S1Interval::Expanded, py::arg("margin"),
111+
"Return interval expanded on each side by 'margin' (radians).\n\n"
112+
"If 'margin' is negative, shrink the interval instead. The resulting\n"
113+
"interval may be empty or full. Any expansion of a full interval remains\n"
114+
"full, and any expansion of an empty interval remains empty.")
115+
.def("union", &S1Interval::Union, py::arg("other"),
116+
"Return the smallest interval containing this interval and 'y'")
117+
.def("intersection", &S1Interval::Intersection, py::arg("other"),
118+
"Return the smallest interval containing the intersection with 'y'.\n\n"
119+
"Note that the region of intersection may consist of two disjoint intervals.")
120+
.def("complement", &S1Interval::Complement,
121+
"Return the complement of the interior of the interval")
122+
.def("directed_hausdorff_distance",
123+
&S1Interval::GetDirectedHausdorffDistance,
124+
py::arg("other"),
125+
"Return the directed Hausdorff distance to 'y'")
126+
// Note: default value must match C++ signature in s1interval.h
127+
.def("approx_equals", &S1Interval::ApproxEquals,
128+
py::arg("other"), py::arg("max_error") = 1e-15,
129+
"Return true if approximately equal to 'y'.\n\n"
130+
"Two intervals are approximately equal if each endpoint can be moved\n"
131+
"by at most 'max_error' (radians) to match the other interval.")
132+
133+
// Operators
134+
.def(py::self == py::self,
135+
"Return true if two intervals contain the same set of points")
136+
.def(py::self != py::self,
137+
"Return true if two intervals do not contain the same set of points")
138+
139+
// String representation
140+
.def("__repr__", [](const S1Interval& i) {
141+
std::ostringstream oss;
142+
oss << "S1Interval(" << i << ")";
143+
return oss.str();
144+
})
145+
.def("__str__", [](const S1Interval& i) {
146+
std::ostringstream oss;
147+
oss << i;
148+
return oss.str();
149+
});
150+
}

0 commit comments

Comments
 (0)