Skip to content

Commit 1dadc54

Browse files
author
peng.li24
committed
feat: update geometry_py.h, module.cpp, and tests
1 parent ff2ef0a commit 1dadc54

4 files changed

Lines changed: 150 additions & 35 deletions

File tree

pycpp/geometry_py.h

Lines changed: 50 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
#include "../shapely/geometry/geometrycollection.h"
2323

2424
#include <array>
25+
#include <cmath>
2526
#include <cstring>
2627
#include <tuple>
2728
#include <vector>
@@ -66,6 +67,20 @@ inline void ensure_c_contiguous(const py::buffer_info& buf) {
6667
}
6768
}
6869

70+
/// Validate coordinate buffer — reject NaN/Inf before they reach GEOS.
71+
/// Throws std::invalid_argument with the index of the first offending value.
72+
template <typename T>
73+
inline void ensure_no_nan_inf(const T* data, size_t count, const char* label = "coords") {
74+
for (size_t i = 0; i < count; ++i) {
75+
if (std::isnan(data[i]))
76+
throw std::invalid_argument(
77+
std::string(label) + " contains NaN at index " + std::to_string(i));
78+
if (std::isinf(data[i]))
79+
throw std::invalid_argument(
80+
std::string(label) + " contains Inf at index " + std::to_string(i));
81+
}
82+
}
83+
6984
/// Convert any py::array coordinate buffer to std::vector<double>.
7085
/// Extracts the first 2 columns per row — safe for (N,2) and (N,≥3) arrays.
7186
/// dtype double → direct copy; float32/other → cast & widen to double.
@@ -96,6 +111,8 @@ inline std::vector<double> array_to_double_vec(const py::array& arr) {
96111
tmp[r * 2 + 1] = static_cast<double>(src[base + 1]);
97112
}
98113
}
114+
// Validate AFTER conversion: reject NaN/Inf before GEOS
115+
ensure_no_nan_inf(tmp.data(), rows * 2, "coords");
99116
return tmp;
100117
}
101118

@@ -104,45 +121,65 @@ inline std::vector<double> array_to_double_vec(const py::array& arr) {
104121
// ============================================================================
105122

106123
// -- Point: scalar (x,y) + array ([x,y]) forms --
107-
inline Point<double> point(double x, double y) { return Point<double>(x, y); }
124+
inline Point<double> point(double x, double y) {
125+
if (std::isnan(x) || std::isnan(y))
126+
throw std::invalid_argument("point coords contains NaN");
127+
if (std::isinf(x) || std::isinf(y))
128+
throw std::invalid_argument("point coords contains Inf");
129+
return Point<double>(x, y);
130+
}
108131
inline Point<float> point(float x, float y) { return Point<float>(x, y); }
109132

110133
inline Point<double> point(const py::array_t<double>& arr) {
111134
auto buf = arr.request();
112135
ensure_c_contiguous(buf);
113136
const double* p = static_cast<const double*>(buf.ptr);
137+
ensure_no_nan_inf(p, buf.size, "point coords");
114138
return Point<double>(buf.size > 0 ? p[0] : 0, buf.size > 1 ? p[1] : 0);
115139
}
116140
// float32 auto-upcast: prevent GEOS precision mismatch vs Python shapely
117141
inline Point<double> point(const py::array_t<float>& arr) {
118142
auto buf = arr.request();
119143
ensure_c_contiguous(buf);
120144
const float* p = static_cast<const float*>(buf.ptr);
121-
return Point<double>(buf.size > 0 ? static_cast<double>(p[0]) : 0,
122-
buf.size > 1 ? static_cast<double>(p[1]) : 0);
145+
double x = buf.size > 0 ? static_cast<double>(p[0]) : 0;
146+
double y = buf.size > 1 ? static_cast<double>(p[1]) : 0;
147+
if (std::isnan(x) || std::isnan(y))
148+
throw std::invalid_argument("point coords contains NaN");
149+
if (std::isinf(x) || std::isinf(y))
150+
throw std::invalid_argument("point coords contains Inf");
151+
return Point<double>(x, y);
123152
}
124153
// auto-double: accept any dtype (int, unknown), always produce Point<double>
125154
// Handles 1D [x,y] and 2D (N,≥2) arrays — takes first 2 elements.
126155
inline Point<double> point(const py::array& arr) {
127156
auto buf = arr.request();
128157
ensure_c_contiguous(buf);
129158
if (buf.size < 2) return Point<double>(0, 0);
159+
double x, y;
130160
if (arr.dtype().is(py::dtype::of<double>())) {
131161
const double* p = static_cast<const double*>(buf.ptr);
132-
return Point<double>(p[0], p[1]);
162+
x = p[0]; y = p[1];
133163
} else {
134164
// float32 or int/other → cast through pybind11 to float32, then widen
135165
auto f32 = py::cast<py::array_t<float>>(arr);
136166
auto fbuf = f32.request();
137167
const float* p = static_cast<const float*>(fbuf.ptr);
138-
return Point<double>(static_cast<double>(p[0]), static_cast<double>(p[1]));
168+
x = static_cast<double>(p[0]);
169+
y = static_cast<double>(p[1]);
139170
}
171+
if (std::isnan(x) || std::isnan(y))
172+
throw std::invalid_argument("point coords contains NaN");
173+
if (std::isinf(x) || std::isinf(y))
174+
throw std::invalid_argument("point coords contains Inf");
175+
return Point<double>(x, y);
140176
}
141177

142178
// -- LineString --
143179
inline LineString<double> linestring(const py::array_t<double>& arr) {
144180
auto buf = arr.request();
145181
ensure_c_contiguous(buf);
182+
ensure_no_nan_inf(static_cast<const double*>(buf.ptr), buf.size, "linestring coords");
146183
return LineString<double>(static_cast<const double*>(buf.ptr), buf.shape[0], buf.shape[1]);
147184
}
148185
// float32 auto-upcast: prevent GEOS precision mismatch vs Python shapely
@@ -167,13 +204,15 @@ inline LineString<double> linestring(const std::vector<std::array<double, 2>>& p
167204
flat[i * 2] = pts[i][0];
168205
flat[i * 2 + 1] = pts[i][1];
169206
}
207+
ensure_no_nan_inf(flat.data(), pts.size() * 2, "linestring coords");
170208
return LineString<double>(flat.data(), pts.size(), 2);
171209
}
172210

173211
// -- Polygon --
174212
inline Polygon<double> polygon(const py::array_t<double>& arr) {
175213
auto buf = arr.request();
176214
ensure_c_contiguous(buf);
215+
ensure_no_nan_inf(static_cast<const double*>(buf.ptr), buf.size, "polygon coords");
177216
return Polygon<double>(static_cast<const double*>(buf.ptr), buf.shape[0], buf.shape[1]);
178217
}
179218
// float32 auto-upcast: prevent GEOS precision mismatch vs Python shapely
@@ -192,6 +231,7 @@ inline Polygon<double> polygon(const py::array& arr) {
192231
inline LinearRing<double> linearring(const py::array_t<double>& arr) {
193232
auto buf = arr.request();
194233
ensure_c_contiguous(buf);
234+
ensure_no_nan_inf(static_cast<const double*>(buf.ptr), buf.size, "linearring coords");
195235
return LinearRing<double>(static_cast<const double*>(buf.ptr), buf.shape[0], buf.shape[1]);
196236
}
197237
inline LinearRing<double> linearring(const py::array& arr) {
@@ -204,6 +244,7 @@ inline LinearRing<double> linearring(const py::array& arr) {
204244
inline MultiPoint<double> multipoint(const py::array_t<double>& arr) {
205245
auto buf = arr.request();
206246
ensure_c_contiguous(buf);
247+
ensure_no_nan_inf(static_cast<const double*>(buf.ptr), buf.size, "multipoint coords");
207248
return MultiPoint<double>(static_cast<const double*>(buf.ptr), buf.shape[0], buf.shape[1]);
208249
}
209250
// float32 auto-upcast: prevent GEOS precision mismatch vs Python shapely
@@ -224,6 +265,7 @@ inline MultiLineString<double> multilinestring(const std::vector<py::array_t<dou
224265
for (auto& arr : arrays) {
225266
auto buf = arr.request();
226267
ensure_c_contiguous(buf);
268+
ensure_no_nan_inf(static_cast<const double*>(buf.ptr), buf.size, "multilinestring coords");
227269
mls.add_line(static_cast<const double*>(buf.ptr), buf.shape[0], buf.shape[1]);
228270
}
229271
return mls;
@@ -254,6 +296,7 @@ inline MultiPolygon<double> multipolygon(const std::vector<py::array_t<double>>&
254296
for (auto& arr : arrays) {
255297
auto buf = arr.request();
256298
ensure_c_contiguous(buf);
299+
ensure_no_nan_inf(static_cast<const double*>(buf.ptr), buf.size, "multipolygon coords");
257300
mp.add_polygon(static_cast<const double*>(buf.ptr), buf.shape[0], buf.shape[1]);
258301
}
259302
return mp;
@@ -443,9 +486,8 @@ inline std::tuple<double,double> centroid_linestring(const LineString<double>& l
443486
inline std::tuple<double,double> centroid_polygon(const Polygon<double>& p) { auto r=p.centroid(); return {r.x,r.y}; }
444487

445488
inline double project_ls_pt(const LineString<double>& l, const Point<double>& p) { return l.project(p); }
446-
inline std::tuple<double,double> interpolate_ls(const LineString<double>& l, double d) { auto r=l.interpolate(d); return {r.x,r.y}; }
447-
inline std::tuple<double,double> interpolate_ls_normalized(const LineString<double>& l, double t) {
448-
auto r = l.interpolate(t, true);
489+
inline std::tuple<double,double> interpolate_ls(const LineString<double>& l, double d, bool normalized = false) {
490+
auto r = l.interpolate(d, normalized);
449491
return {r.x, r.y};
450492
}
451493

tests/module.cpp

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -388,8 +388,8 @@ PYBIND11_MODULE(shapelycpp, m) {
388388
// Project / interpolate (from pycpp)
389389
// ======================================================================
390390
m.def("project_linestring_point", &project_ls_pt);
391-
m.def("interpolate_linestring", &interpolate_ls);
392-
m.def("interpolate_linestring_normalized", &interpolate_ls_normalized);
391+
m.def("interpolate_linestring", &interpolate_ls,
392+
py::arg("ls"), py::arg("distance"), py::arg("normalized") = false);
393393

394394
// ======================================================================
395395
// Intersection area (from pycpp)

tests/report.csv

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -230,11 +230,11 @@ ops,interpolate_linestring,unknown,f64,v_mid,PASS,0
230230
ops,interpolate_linestring,unknown,f64,h_start,PASS,0
231231
ops,interpolate_linestring,unknown,f64,h_end,PASS,0
232232
ops,interpolate_linestring,unknown,f64,v3pt,PASS,0
233-
ops,interpolate_linestring_normalized,unknown,f64,h_mid,PASS,0
234-
ops,interpolate_linestring_normalized,unknown,f64,h_start,PASS,0
235-
ops,interpolate_linestring_normalized,unknown,f64,h_end,PASS,0
236-
ops,interpolate_linestring_normalized,unknown,f64,v_mid,PASS,0
237-
ops,interpolate_linestring_normalized,unknown,f64,v3pt_end,PASS,0
233+
ops,interpolate_linestring,unknown,f64,norm_h_mid,PASS,0
234+
ops,interpolate_linestring,unknown,f64,norm_h_start,PASS,0
235+
ops,interpolate_linestring,unknown,f64,norm_h_end,PASS,0
236+
ops,interpolate_linestring,unknown,f64,norm_v_mid,PASS,0
237+
ops,interpolate_linestring,unknown,f64,norm_v3pt_end,PASS,0
238238
ops,centroid_point,unknown,f64,3-4,PASS,0
239239
ops,centroid_point,unknown,f64,neg,PASS,0
240240
ops,centroid_linestring,unknown,f64,d,PASS,0
@@ -249,3 +249,15 @@ ops,nearest_points,unknown,f64,poly_ls_touch,PASS,0
249249
ops,nearest_points,unknown,f64,poly_ls_near,PASS,0
250250
ops,nearest_points_ls_pt,unknown,f64,ls_pt_above,PASS,0
251251
ops,nearest_points_ls_pt,unknown,f64,ls_pt_endpoint,PASS,0
252+
errors,point,unknown,f64,nan,PASS,0
253+
errors,point,unknown,f64,inf,PASS,0
254+
errors,linestring,unknown,f64,nan,PASS,0
255+
errors,linestring,unknown,f64,inf,PASS,0
256+
errors,linestring,unknown,f64,ninf,PASS,0
257+
errors,polygon,unknown,f64,nan,PASS,0
258+
errors,polygon,unknown,f64,inf,PASS,0
259+
errors,linearring,unknown,f64,nan,PASS,0
260+
errors,linearring,unknown,f64,inf,PASS,0
261+
errors,multipoint,unknown,f64,nan,PASS,0
262+
errors,multilinestring,unknown,f64,nan,PASS,0
263+
errors,multipolygon,unknown,f64,nan,PASS,0

tests/test_all.py

Lines changed: 81 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,6 @@ def _compare_bit_exact(cpp_result, py_result, label=""):
147147
_OPS_NAMES = {
148148
"centroid_point", "centroid_linestring", "centroid_polygon",
149149
"project_linestring_point", "interpolate_linestring",
150-
"interpolate_linestring_normalized",
151150
"intersection_area_polygon_polygon",
152151
"polygon_exterior",
153152
"nearest_points", "nearest_points_ls_pt",
@@ -353,16 +352,12 @@ def _call_ops(api_name, cpp, *args, **kwargs):
353352
c_ls = _build_cpp_geom(cpp, *geoms[0])
354353
py_ls = _build_py_geom(*geoms[0])
355354
dist = float(extras[0]) if extras else 5.0
356-
c_result = cpp.interpolate_linestring(c_ls, dist)
357-
py_pt = py_ls.interpolate(dist)
358-
return np.array(c_result), np.array((py_pt.x, py_pt.y))
359-
360-
if api_name == "interpolate_linestring_normalized":
361-
c_ls = _build_cpp_geom(cpp, *geoms[0])
362-
py_ls = _build_py_geom(*geoms[0])
363-
t = float(extras[0]) if extras else 0.5
364-
c_result = cpp.interpolate_linestring_normalized(c_ls, t)
365-
py_pt = py_ls.interpolate(t, normalized=True)
355+
normalized = bool(kwargs.get("normalized", False))
356+
if normalized:
357+
c_result = cpp.interpolate_linestring(c_ls, dist, normalized=True)
358+
else:
359+
c_result = cpp.interpolate_linestring(c_ls, dist)
360+
py_pt = py_ls.interpolate(dist, normalized=normalized)
366361
return np.array(c_result), np.array((py_pt.x, py_pt.y))
367362

368363
if api_name == "intersection_area_polygon_polygon":
@@ -534,6 +529,19 @@ def _call_class_method(api_name, cpp, *args, **kwargs):
534529
_I_A = [(0, 0), (5, 0), (5, 5), (0, 5)]
535530
_I_B = [(3, 0), (8, 0), (8, 5), (3, 5)]
536531

532+
# ---- NaN/Inf test data presets ------------------------------------------------
533+
534+
_NAN_1 = [(float('nan'), 0), (1, 1)]
535+
_INF_1 = [(float('inf'), 0), (1, 1)]
536+
_NINF_1= [(float('-inf'), 0), (1, 1)]
537+
_NAN_PT = (float('nan'), 0)
538+
_INF_PT = (float('inf'), 0)
539+
540+
_MNAN = [(float('nan'), 0), (10, 10), (20, 5)] # multipoint nan
541+
_MLNAN = [[(float('nan'), 0), (5, 0)], [(10, 0), (15, 0)]] # multiline nan
542+
_MPNAN = [[(0, 0), (10, 0), (10, 10), (0, 10)], # multipoly nan
543+
[(float('nan'), 0), (20, 0), (20, 10), (10, 10)]]
544+
537545

538546
# ---- Group 1: factories (7 APIs) --------------------------------------------
539547

@@ -877,7 +885,7 @@ def _catalog_ops():
877885
yield _TestCase("project_linestring_point", (ls, pt), {}, "f64", tag,
878886
"scalar_eq", True, None, "ops")
879887

880-
# -- interpolate_linestring --
888+
# -- interpolate_linestring (absolute distance) --
881889
for ls, dist, tag in [
882890
(("ls", _L_H), 5.0, "h_mid"),
883891
(("ls", _L_V), 5.0, "v_mid"),
@@ -888,15 +896,15 @@ def _catalog_ops():
888896
yield _TestCase("interpolate_linestring", (ls, dist), {}, "f64", tag,
889897
"bit_exact", True, None, "ops")
890898

891-
# -- interpolate_linestring_normalized --
892-
for ls, t, py_t_expected_tag, tag in [
893-
(("ls", _L_H), 0.5, "h_mid", "h_mid"), # t=0.5 → midpoint of 10-length horizontal
894-
(("ls", _L_H), 0.0, "h_start", "h_start"), # t=0.0 → start
895-
(("ls", _L_H), 1.0, "h_end", "h_end"), # t=1.0 → end
896-
(("ls", _L_V), 0.5, "v_mid", "v_mid"), # vertical 10-length, t=0.5
897-
(("ls", _L_3), 1.0, "v3pt", "v3pt_end"), # 3-point L, t=1.0
899+
# -- interpolate_linestring (normalized) -- same API, kwargs={"normalized": True}
900+
for ls, t, tag in [
901+
(("ls", _L_H), 0.5, "norm_h_mid"),
902+
(("ls", _L_H), 0.0, "norm_h_start"),
903+
(("ls", _L_H), 1.0, "norm_h_end"),
904+
(("ls", _L_V), 0.5, "norm_v_mid"),
905+
(("ls", _L_3), 1.0, "norm_v3pt_end"),
898906
]:
899-
yield _TestCase("interpolate_linestring_normalized", (ls, t), {},
907+
yield _TestCase("interpolate_linestring", (ls, t), {"normalized": True},
900908
"f64", tag, "bit_exact", True, None, "ops")
901909

902910
# -- Centroid --
@@ -955,6 +963,37 @@ def api_catalog():
955963
yield tc._replace(group="predicates")
956964
for tc in _catalog_ops():
957965
yield tc._replace(group="ops")
966+
for tc in _catalog_errors():
967+
yield tc._replace(group="errors")
968+
969+
970+
def _catalog_errors():
971+
"""NaN/Inf rejection: every geometry factory must reject NaN/Inf coords."""
972+
# point
973+
for coords, tag in [(_NAN_PT, "nan"), (_INF_PT, "inf")]:
974+
yield _TestCase("point", (("pt", coords),), {}, "f64", tag,
975+
"expect_error", True, None, "errors")
976+
# linestring
977+
for coords, tag in [(_NAN_1, "nan"), (_INF_1, "inf"), (_NINF_1, "ninf")]:
978+
yield _TestCase("linestring", (coords,), {}, "f64", tag,
979+
"expect_error", True, None, "errors")
980+
# polygon
981+
for coords, tag in [(_NAN_1, "nan"), (_INF_1, "inf")]:
982+
yield _TestCase("polygon", (coords,), {}, "f64", tag,
983+
"expect_error", True, None, "errors")
984+
# linearring
985+
for coords, tag in [(_NAN_1, "nan"), (_INF_1, "inf")]:
986+
yield _TestCase("linearring", (coords,), {}, "f64", tag,
987+
"expect_error", True, None, "errors")
988+
# multipoint
989+
yield _TestCase("multipoint", (_MNAN,), {}, "f64", "nan",
990+
"expect_error", True, None, "errors")
991+
# multilinestring
992+
yield _TestCase("multilinestring", (_MLNAN,), {}, "f64", "nan",
993+
"expect_error", True, None, "errors")
994+
# multipolygon
995+
yield _TestCase("multipolygon", (_MPNAN,), {}, "f64", "nan",
996+
"expect_error", True, None, "errors")
958997

959998

960999
# =============================================================================
@@ -1028,6 +1067,28 @@ def test_api(tc, cpp):
10281067
tc.setup_fn(args, kwargs)
10291068
return
10301069

1070+
# Error tests: expect exception from C++
1071+
if strategy == "expect_error":
1072+
try:
1073+
call_cpp_py(api_name, cpp, *args, **kwargs)
1074+
# Should have raised — didn't
1075+
raise AssertionError(
1076+
f"[{api_name}] expected exception for NaN/Inf input, but no error raised")
1077+
except Exception as e:
1078+
# Re-raise assertion errors, accept everything else
1079+
if isinstance(e, AssertionError):
1080+
_REPORT_ROWS.append(dict(
1081+
category=tc.group, api=api_name, mode=_geos_info(cpp),
1082+
dtype=tc.dtype_label, feature=tc.category,
1083+
result="FAIL", ulp=-1))
1084+
raise
1085+
# Expected: any exception (invalid_argument, runtime_error, etc.)
1086+
_REPORT_ROWS.append(dict(
1087+
category=tc.group, api=api_name, mode=_geos_info(cpp),
1088+
dtype=tc.dtype_label, feature=tc.category,
1089+
result="PASS", ulp=0))
1090+
return
1091+
10311092
cpp_r, py_r = call_cpp_py(api_name, cpp, *args, **kwargs)
10321093
if check_py and py_r is None:
10331094
pytest.skip(f"no shapely equivalent for {api_name}")

0 commit comments

Comments
 (0)