Added ga_set_thread_pool(), which hands the thread cap to the Rust side, and .onLoad() calls it with getOption("geoarrowrs.thread_pool"). The parallel paths used to read that option where they needed it, by evaluating R from inside the call, which is the R API reached from a place that has no business reaching it and cannot be relied on to be R’s own thread. The cap is now carried into the parallel helpers as an argument, and the option is read on load or whenever ga_set_thread_pool() is called.
ga_sparse_knn() and ga_sparse_dwithin() take metric, one of "euclidean", "haversine", "geodesic", or "rhumb". On longitude and latitude the planar default measures degrees, which is not a distance: it ranks neighbours wrongly once rows are far apart or near a pole, and a radius in it is a different distance north to south than east to west. "geodesic" and "haversine" measure metres, and the bounding box each search grows is converted to match, generously enough to hold every candidate before the exact distance decides. geo defines those metrics between points alone, so asking for one with a polygon is an error rather than a planar number wearing a spherical name, and "euclidean" stays the default because it is the only one defined for every geometry type. It is also what Sedona, PostGIS and DuckDB’s ST_DWithin() measure, so it is what reproduces them.
Added ga_filter(), which keeps the rows of one data frame that relate to any row of another. This is ST_Filter(), and the short form of ga_join(left = FALSE) where the columns of y are not wanted and a row of x matching several rows should still appear once.
Added ga_sparse_pairs(), which expands the list a sparse predicate returns into one row per pair. Arrow’s own list_parent_indices() and list_flatten() do this for the inner case; what they cannot do is put back a row for each row of x that matched nothing, which is what left = TRUE needs, so the whole expansion happens in one pass here.
Added ga_cross_distance(), which measures every row of x against every row of y rather than walking the two in lockstep, giving one list of length(y) distances per row. The result is rectangular, so the values are written straight into one flat buffer at each row’s own offset instead of appended through a builder, and each row is a rayon task. Ten thousand points against ten thousand, a hundred million haversine distances, takes 0.6s against 35.8s for sf::st_distance() on the same machine. Where the distances are only wanted to pick a nearest row or a threshold, ga_sparse_knn() and ga_sparse_dwithin() answer that against an R-tree without forming the product at all.
Added ga_sparse_knn() and ga_knn_join(), a nearest neighbour search and the join over it. ga_sparse_knn() returns the k rows of y nearest each row of x, nearest first, each with its distance, and ga_knn_join() attaches those rows the way ga_join() attaches the ones that relate. Distance is measured between the geometries themselves rather than between their boxes, so a point joins to the polygon whose edge is nearest rather than to the one whose box is; the tree is used to bound the search and the answer is the same as comparing every pair. RTree$neighbors() still ranks by distance to the box, which is a candidate list rather than an answer.
Materialising geometries runs in parallel, in rayon jobs of at least 8192 rows, which is the dominant cost of every walk over a large array. The sparse predicates, the nearest neighbour search, both spatial indexes, and the pairwise distance and bearing functions all go through it, and the metric itself is threaded on top.
ga_collect_agg() takes sizes, the number of rows in each group, and returns one collection per group instead of one for the whole array. This is the grouped form of the aggregate: the rows have to already be sorted by the grouping, and the run lengths are what a group_by() and summarise(n = n()) over the same ordering give, so the keys come from there and only the geometry comes from here. A per group convex hull over 316k groups and 2.3M points takes about a second.
Reading geometries splits into more, smaller rayon tasks, sized against the thread pool rather than at a fixed 8192 rows. A table of polygons can run from four coordinates to hundreds of thousands, so a few large tasks left cores idle behind whichever one drew the biggest geometries.
Added ga_sparse_dwithin(), which finds the rows of y within a distance of each row of x. This is ST_DWithin(), and it is not the same as buffering x and intersecting: a buffer approximates its arcs with segments, so a row just inside the true radius can fall outside the buffer. Each box is grown by the radius before the tree is searched, so only the rows that could qualify are measured, and the radius is recycled so each row may have its own.
A point on either side of a sparse predicate is answered from where the point lies rather than from a DE-9IM matrix. A point has an empty boundary and a zero dimensional interior, so whether it is inside, on, or outside the other geometry settles the whole matrix, and a ray cast replaces building a topology graph per pair. Beyond a handful of candidates the edges are indexed by the rows they span, using geo’s interval tree, so each test consults the edges near the point rather than all of them. Point in polygon over 156k polygons and 6M points went from 232s to 3.1s. The index is only used where its winding assumption holds, since it pools every ring into one winding number and so reads a hole wound like its exterior as inside it.
The topological predicates node each geometry once rather than once per comparison, using geo’s prepared geometries. relate() builds a noded graph of both sides on every call, so a sparse predicate against a 500 vertex polygon was rebuilding that graph for every candidate row. The sparse predicates prepare each row of x before testing its candidates, and the pairwise predicates prepare y when it is a single geometry, which is the ga_intersects(geometry, one_polygon) case. ga_relate() and the pairwise predicates are threaded at the same time.
RTree takes arrays everywhere, matching KDTree. $search(geometry) replaces both the old $query() and the scalar $search(xmin, ymin, xmax, ymax), and $neighbors(geometry, k, max_distance) replaces the scalar $neighbors(x, y, ...), returning one list of rows per query row. A nearest neighbour join is now one call rather than a loop, which is what $neighbors() was missing.
Added ga_collect_agg(), which gathers a whole array into one geometry collection of length 1. Nothing is dissolved, so it is the counterpart to ga_unary_union(): two squares overlapping by half collect to area 8 and union to area 6. Both are aggregates rather than row by row operations, so they belong inside a summarise(), and neither is registered as an Arrow kernel because a kernel sees one batch at a time and would aggregate each batch separately.
ga_dist_euclidean_pairwise() measures between geometries of any type, so the distance from a point to the nearest edge of a polygon is one call and a bare WKB column needs no cast first. The spherical and ellipsoidal metrics still take points, because geo defines them between points alone. ga_dist_hausdorff_pairwise() shares the same walk.
Added ga_make_line(), which joins each point of one array to the matching point of another as a two point linestring. The end point is recycled, so one destination pairs with every origin.
Added ga_x() and ga_y(), which read the coordinates of a point array as doubles. Both coordinate encodings work, so a point stored as a struct of two columns and one stored interleaved read the same. Anything that is not a single point gives NA.
The distance and bearing functions recycle a length 1 dest, so ga_dist_euclidean_pairwise(points, ga_xy(-111.76, 34.87)) measures every point against one location. This is the rule the topological predicates already followed. A mismatch that is neither length 1 nor the full length is still an error rather than a silent truncation.
Added ga_join(), which attaches the columns of one data frame to each row of another that it relates to spatially, and returns an Arrow table. A row matching several rows is repeated once per match, unmatched rows are kept with NA unless left = FALSE, shared column names are suffixed, and the geometry comes from x. The relationship is any of the sparse predicates, so points in polygons is ga_join(sites, counties, ga_sparse_within). Both sides are taken in Arrow rather than subset in R, which is the difference between milliseconds and seconds once there are a few million rows.
Added KDTree, a k-d tree over a point array. $range(geometry) finds the points inside each geometry’s box and $within(geometry, r) the points within r of each point. Both take an array and return one list of rows per row, so a whole distance band join is one call; a single lookup is an array of one via ga_xy(). RTree cannot answer a radius query directly, which is why this exists.
Added RTree, a packed Hilbert R-tree over the bounding box of each geometry, built on the geo-index crate. $query() looks up a whole array at once and returns one list of candidate rows per element, which is the shape a spatial join needs; it accepts any GeoArrow array and reduces it to boxes itself. $search() takes a single box and $neighbors() the rows nearest a point. $new() takes node_size and a sort of "hilbert" or "str". Queries return candidates to confirm with an exact predicate.
Added ga_envelope(), which returns the axis aligned bounding box of each geometry as a GeoArrow box array. Unlike ga_bounding_rect() it passes a box array straight through instead of recomputing it, so it is safe to call before a query without paying for it twice.
Added ga_dbscan(), ga_kmeans(), and ga_outlier_scores(), which cluster over the whole array and return one value per row, so a point array of 10,000 locations gives 10,000 labels. A row that is not a single point takes no part and comes back null. They are not registered as Arrow kernels, because a kernel sees one batch at a time and would cluster each batch separately. Added ga_simplify_idx() and ga_simplify_vw_idx() alongside, which return the coordinate positions simplification keeps rather than the simplified geometry.
Added ga_as_point(), ga_as_linestring(), ga_as_polygon(), ga_as_multipoint(), ga_as_multilinestring(), and ga_as_multipolygon(), which cast a bare WKB column or any GeoArrow array to one named geometry type. Because the result type is fixed by the name rather than by the data, these are the only geometry producing casts Arrow can run inside a query, so a plain Parquet column can be turned into native GeoArrow once and computed on after that.
Added ga_from_wkb(), which reads a bare binary WKB column, the kind plain Parquet writes, into the narrowest GeoArrow type that fits it.
Added ga_xy(), which pairs two numeric vectors or float64 arrays into a GeoArrow point array. Either argument may be length 1 and is recycled, a row where either coordinate is NA gives a null point, and crs is recorded in the array metadata verbatim without reprojecting anything.
Added register_geoarrow_udfs(), which registers geoarrowrs functions as Arrow scalar kernels so they run inside dplyr verbs on a Table or Dataset rather than pulling the geometry into R. It registers for a CRS rather than for a table: one call covers every GeoArrow geometry type, and functions taking numeric or option arguments, such as ga_simplify(), ga_densify(), and ga_buffer(), are registered alongside the geometry-only ones. 101 functions in total.
Loading the package registers the Arrow kernels for CRS-less data automatically, so mutate(a = ga_unsigned_area(geometry)) works on a Table or Dataset with no setup. Data carrying a CRS still needs register_geoarrow_udfs(crs = ...). Set options(geoarrowrs.register_udfs = FALSE) to skip it and avoid loading arrow at all.
Registered Arrow kernels accept a bare binary or large_binary WKB column, the kind plain Parquet writes, so mutate(d = ga_dist_euclidean_pairwise(pickup, dropoff)) runs on a Dataset without converting the column first. A geometry result from a WKB input comes back as WKB, since the kernel cannot declare a type that depends on what the column turns out to hold.
Added ga_affine_transform(), the general form behind the other affine ops, taking six recyclable coefficients so a different transform can apply to every row.
Added ga_voronoi_cells() and ga_voronoi_edges(), returning one multipolygon of cells or multilinestring of edges per input geometry. Both take a clip mode and an optional boundary polygon to cut the diagram to a study area.
Casting between geometry types runs in parallel, in rayon jobs of at least 8192 rows. Parsing WKB is most of the cost of reading a plain Parquet geometry column, and Arrow cannot thread a user-defined function because it has to call back into R on the main thread, so the parallelism has to live inside the Rust. On ten cores a 3M row WKB to point cast goes from 2.0s to 0.32s, and a distance over 6M rows from 9.6s to 3.3s.
The parallel paths use every core by default. Set options(geoarrowrs.thread_pool = n) for a cap, which takes effect immediately rather than at load. Under R CMD check the cap defaults to two, since CRAN sets _R_CHECK_LIMIT_CORES_ and asks for no more than that; OMP_THREAD_LIMIT is honoured as a ceiling too. Setting the option yourself overrides both.
Every function is vectorized and length preserving: n geometries in, n out. The pairwise distance and bearing functions error on a length mismatch rather than truncating to the shorter side.
Numeric arguments accept a plain R numeric vector as well as an Arrow array, so ga_simplify(x, 0.01) works without wrapping the value. Length 1 is recycled.
Geometry arguments accept any GeoArrow array rather than only a mixed geometry array, so the concrete arrays the readers produce work directly.