Skip to content

Commit b269fa9

Browse files
feat(vortex-geo): per-row 2-D AABB compute primitive
Adds vortex_geo::bounds::aabbs_2d, decoding a native geometry column to per-row (min, max) corner pairs for row-oriented consumers, e.g. bulk-loading an in-memory R-tree in a spatial-join operator. Deliberately an internal compute primitive rather than a scalar function: no registration, no SQL name, and no columnar Rect output, since the consumer reads the boxes row by row. A null row yields None just like an empty geometry (no box: it matches nothing in a spatial join). Null rows are filtered out before decoding, honoring the decode helpers' null-free precondition, and the boxes are woven back into row order. 2-D only. Columnar consumers keep the Rect extension type, and the GeometryAabb aggregate remains the whole-column box. Signed-off-by: Nemo Yu <zyu379@wisc.edu>
1 parent 2ea51cb commit b269fa9

3 files changed

Lines changed: 264 additions & 0 deletions

File tree

vortex-geo/src/bounds.rs

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
// SPDX-FileCopyrightText: Copyright the Vortex contributors
3+
4+
//! Row-shaped bounding-box computation over native geometry columns.
5+
6+
use geo::BoundingRect;
7+
use vortex_array::ArrayRef;
8+
use vortex_array::ExecutionCtx;
9+
use vortex_error::VortexResult;
10+
11+
use crate::extension::geometries;
12+
13+
/// Per-row 2-D axis-aligned bounding boxes (AABBs) of a native geometry column, as `(min, max)`
14+
/// pairs of `[x, y]` corners.
15+
///
16+
/// The per-row counterpart of the [`GeometryAabb`](crate::aggregate_fn::GeometryAabb) aggregate,
17+
/// which unions the whole column into one box. A point yields a degenerate box with `min == max`.
18+
/// A null row, or a geometry with no extent (e.g. an empty polygon), yields `None`: either way
19+
/// the row has no box, and matches nothing in a spatial join.
20+
///
21+
/// A non-geometry column is an error. Any `z`/`m` ordinates carry no bounds, this decodes to 2-D `geo_types`.
22+
pub fn aabbs_2d(
23+
array: &ArrayRef,
24+
ctx: &mut ExecutionCtx,
25+
) -> VortexResult<impl Iterator<Item = Option<([f64; 2], [f64; 2])>> + use<>> {
26+
let len = array.len();
27+
// Drop the null rows before decoding, since a null row has no geometry to decode (`filter`
28+
// collapses an all-true mask, so an all-valid column passes through unchanged), then weave
29+
// the boxes back into row order with `None` in every null slot.
30+
let valid = array.validity()?.execute_mask(len, ctx)?;
31+
let decoded = geometries(&array.filter(valid.clone())?, ctx)?;
32+
let mut boxes = decoded.into_iter().map(|geometry| {
33+
geometry
34+
.bounding_rect()
35+
.map(|rect| ([rect.min().x, rect.min().y], [rect.max().x, rect.max().y]))
36+
});
37+
Ok((0..len).map(move |row| {
38+
if valid.value(row) {
39+
boxes.next().flatten()
40+
} else {
41+
None
42+
}
43+
}))
44+
}
45+
46+
#[cfg(test)]
47+
mod tests {
48+
use vortex_array::IntoArray;
49+
use vortex_array::VortexSessionExecute;
50+
use vortex_array::arrays::ConstantArray;
51+
use vortex_array::arrays::PrimitiveArray;
52+
use vortex_array::scalar::Scalar;
53+
use vortex_error::VortexResult;
54+
55+
use super::aabbs_2d;
56+
use crate::test_harness::linestring_column;
57+
use crate::test_harness::multilinestring_column;
58+
use crate::test_harness::multipoint_column;
59+
use crate::test_harness::multipolygon_column;
60+
use crate::test_harness::nullable_multipolygon_column;
61+
use crate::test_harness::nullable_point_column;
62+
use crate::test_harness::point_column;
63+
use crate::test_harness::polygon_column;
64+
use crate::test_harness::rect_column;
65+
66+
/// A shorthand box from its four corners.
67+
fn bounds(xmin: f64, ymin: f64, xmax: f64, ymax: f64) -> Option<([f64; 2], [f64; 2])> {
68+
Some(([xmin, ymin], [xmax, ymax]))
69+
}
70+
71+
/// A point's box is degenerate: both corners are the point itself.
72+
#[test]
73+
fn point_box_is_degenerate() -> VortexResult<()> {
74+
let session = vortex_array::array_session();
75+
let mut ctx = session.create_execution_ctx();
76+
77+
let points = point_column(vec![1.0, 3.0], vec![2.0, 4.0])?;
78+
assert_eq!(
79+
aabbs_2d(&points, &mut ctx)?.collect::<Vec<_>>(),
80+
vec![bounds(1.0, 2.0, 1.0, 2.0), bounds(3.0, 4.0, 3.0, 4.0)]
81+
);
82+
Ok(())
83+
}
84+
85+
/// A polygon's box is its extent: the min/max over every ring vertex, one box per row.
86+
#[test]
87+
fn polygon_box_is_extent() -> VortexResult<()> {
88+
let session = vortex_array::array_session();
89+
let mut ctx = session.create_execution_ctx();
90+
91+
let polygons = polygon_column(vec![
92+
vec![vec![(0.0, 0.0), (4.0, 0.0), (2.0, 5.0)]],
93+
vec![vec![(-3.0, 7.0), (-2.0, 7.0), (-2.0, 9.0), (-3.0, 9.0)]],
94+
])?;
95+
assert_eq!(
96+
aabbs_2d(&polygons, &mut ctx)?.collect::<Vec<_>>(),
97+
vec![bounds(0.0, 0.0, 4.0, 5.0), bounds(-3.0, 7.0, -2.0, 9.0)]
98+
);
99+
Ok(())
100+
}
101+
102+
/// A `Rect` row is its own bounding box.
103+
#[test]
104+
fn rect_box_is_itself() -> VortexResult<()> {
105+
let session = vortex_array::array_session();
106+
let mut ctx = session.create_execution_ctx();
107+
108+
let rects = rect_column(vec![(0.0, 0.0, 2.0, 3.0), (-1.0, -1.0, 1.0, 1.0)])?;
109+
assert_eq!(
110+
aabbs_2d(&rects, &mut ctx)?.collect::<Vec<_>>(),
111+
vec![bounds(0.0, 0.0, 2.0, 3.0), bounds(-1.0, -1.0, 1.0, 1.0)]
112+
);
113+
Ok(())
114+
}
115+
116+
/// Every multi-vertex native type over the same vertex set yields that set's box, so the
117+
/// whole type family is covered (`Point` has its own degenerate-box test above).
118+
#[test]
119+
fn covers_every_native_geometry_type() -> VortexResult<()> {
120+
let session = vortex_array::array_session();
121+
let mut ctx = session.create_execution_ctx();
122+
123+
let vertices = vec![(1.0, 2.0), (-1.0, 5.0), (3.0, 4.0)];
124+
let columns = vec![
125+
linestring_column(vec![vertices.clone()])?,
126+
multipoint_column(vec![vertices.clone()])?,
127+
polygon_column(vec![vec![vertices.clone()]])?,
128+
multilinestring_column(vec![vec![vertices.clone()]])?,
129+
multipolygon_column(vec![vec![vec![vertices]]])?,
130+
];
131+
for column in columns {
132+
assert_eq!(
133+
aabbs_2d(&column, &mut ctx)?.collect::<Vec<_>>(),
134+
vec![bounds(-1.0, 2.0, 3.0, 5.0)],
135+
"box mismatch for {}",
136+
column.dtype()
137+
);
138+
}
139+
Ok(())
140+
}
141+
142+
/// An empty geometry (here a zero-part multipolygon) has no extent and yields `None`; other
143+
/// rows keep their boxes.
144+
#[test]
145+
fn empty_geometry_has_no_box() -> VortexResult<()> {
146+
let session = vortex_array::array_session();
147+
let mut ctx = session.create_execution_ctx();
148+
149+
let multipolygons = multipolygon_column(vec![
150+
vec![vec![vec![(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)]]],
151+
vec![],
152+
])?;
153+
assert_eq!(
154+
aabbs_2d(&multipolygons, &mut ctx)?.collect::<Vec<_>>(),
155+
vec![bounds(0.0, 0.0, 1.0, 1.0), None]
156+
);
157+
Ok(())
158+
}
159+
160+
/// A null geometry row yields `None`, just like an empty geometry: no box, so it matches
161+
/// nothing in a spatial join. Valid rows keep their boxes.
162+
#[test]
163+
fn null_row_yields_none() -> VortexResult<()> {
164+
let session = vortex_array::array_session();
165+
let mut ctx = session.create_execution_ctx();
166+
167+
let points = nullable_point_column(vec![Some((1.0, 2.0)), None, Some((3.0, 4.0))])?;
168+
assert_eq!(
169+
aabbs_2d(&points, &mut ctx)?.collect::<Vec<_>>(),
170+
vec![bounds(1.0, 2.0, 1.0, 2.0), None, bounds(3.0, 4.0, 3.0, 4.0)]
171+
);
172+
Ok(())
173+
}
174+
175+
/// Valid, empty, and null rows stay positionally aligned: the valid row keeps its box, and
176+
/// the empty and null rows are both `None`.
177+
#[test]
178+
fn mixed_valid_empty_null_rows_align() -> VortexResult<()> {
179+
let session = vortex_array::array_session();
180+
let mut ctx = session.create_execution_ctx();
181+
182+
let multipolygons = nullable_multipolygon_column(vec![
183+
Some(vec![vec![vec![
184+
(0.0, 0.0),
185+
(1.0, 0.0),
186+
(1.0, 1.0),
187+
(0.0, 1.0),
188+
]]]),
189+
Some(vec![]),
190+
None,
191+
])?;
192+
assert_eq!(
193+
aabbs_2d(&multipolygons, &mut ctx)?.collect::<Vec<_>>(),
194+
vec![bounds(0.0, 0.0, 1.0, 1.0), None, None]
195+
);
196+
Ok(())
197+
}
198+
199+
/// A constant-null column carries its nulls in the array's logical validity rather than in
200+
/// materialized storage; every row is `None`.
201+
#[test]
202+
fn constant_null_column_is_all_none() -> VortexResult<()> {
203+
let session = vortex_array::array_session();
204+
let mut ctx = session.create_execution_ctx();
205+
206+
let dtype = point_column(vec![0.0], vec![0.0])?.dtype().as_nullable();
207+
let nulls = ConstantArray::new(Scalar::null(dtype), 2).into_array();
208+
assert_eq!(
209+
aabbs_2d(&nulls, &mut ctx)?.collect::<Vec<_>>(),
210+
vec![None, None]
211+
);
212+
Ok(())
213+
}
214+
215+
/// A zero-row column yields a zero-row result.
216+
#[test]
217+
fn empty_column_yields_nothing() -> VortexResult<()> {
218+
let session = vortex_array::array_session();
219+
let mut ctx = session.create_execution_ctx();
220+
221+
let points = point_column(vec![], vec![])?;
222+
assert_eq!(aabbs_2d(&points, &mut ctx)?.count(), 0);
223+
Ok(())
224+
}
225+
226+
/// A non-geometry column is an error.
227+
#[test]
228+
fn non_geometry_column_is_rejected() -> VortexResult<()> {
229+
let session = vortex_array::array_session();
230+
let mut ctx = session.create_execution_ctx();
231+
232+
let numbers = PrimitiveArray::from_iter([1.0f64, 2.0]).into_array();
233+
assert!(aabbs_2d(&numbers, &mut ctx).is_err());
234+
Ok(())
235+
}
236+
}

vortex-geo/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ use crate::scalar_fn::distance::GeoDistance;
2626
use crate::scalar_fn::intersects::GeoIntersects;
2727

2828
pub mod aggregate_fn;
29+
pub mod bounds;
2930
pub mod extension;
3031
pub mod prune;
3132
pub mod scalar_fn;

vortex-geo/src/test_harness.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,33 @@ pub(crate) fn multipolygon_column(multipolygons: Vec<MultiPolygonRings>) -> Vort
180180
)
181181
}
182182

183+
/// A nullable `MultiPolygon` column: `None` rows are null (an empty-list placeholder in storage).
184+
pub(crate) fn nullable_multipolygon_column(
185+
multipolygons: Vec<Option<MultiPolygonRings>>,
186+
) -> VortexResult<ArrayRef> {
187+
let rows: Vec<MultiPolygonRings> = multipolygons
188+
.iter()
189+
.map(|row| row.clone().unwrap_or_default())
190+
.collect();
191+
let polygons: Vec<Vec<Vec<(f64, f64)>>> = rows.iter().flatten().cloned().collect();
192+
let mut offsets = vec![0i32];
193+
let mut len = 0usize;
194+
for row in &rows {
195+
len += row.len();
196+
offsets.push(offset(len)?);
197+
}
198+
let storage = ListArray::try_new(
199+
vertex_list_lists(&polygons)?,
200+
PrimitiveArray::from_iter(offsets).into_array(),
201+
Validity::from_iter(multipolygons.iter().map(Option::is_some)),
202+
)?
203+
.into_array();
204+
geo_column::<MultiPolygon>(
205+
storage,
206+
multipolygon_storage_dtype(Dimension::Xy, Nullability::Nullable),
207+
)
208+
}
209+
183210
/// A 2D `Rect` (`geoarrow.box`) column over `(xmin, ymin, xmax, ymax)` boxes, stored as
184211
/// `Struct<xmin, ymin, xmax, ymax>`.
185212
pub(crate) fn rect_column(boxes: Vec<(f64, f64, f64, f64)>) -> VortexResult<ArrayRef> {

0 commit comments

Comments
 (0)