This is an automated email from the ASF dual-hosted git repository.
kontinuation pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/sedona-db.git
The following commit(s) were added to refs/heads/main by this push:
new edbd7084 feat(sedona-gdal): add geometry and spatial ref primitives
(#695)
edbd7084 is described below
commit edbd7084a16ef7131de16fd28a90417fcbaef71e
Author: Kristin Cowalcijk <[email protected]>
AuthorDate: Tue Mar 17 12:13:00 2026 +0800
feat(sedona-gdal): add geometry and spatial ref primitives (#695)
## Summary
- add standalone geometry, geotransform, spatial reference, and VSI wrappers
- provide the primitive GDAL wrapper layer that later dataset and raster
operation PRs build on
- keep the module surface explicit by avoiding wrapper re-export aliases
---
c/sedona-gdal/src/dyn_load.rs | 5 +
c/sedona-gdal/src/errors.rs | 16 ++
c/sedona-gdal/src/gdal_api.rs | 19 ++-
c/sedona-gdal/src/gdal_dyn_bindgen.rs | 19 +++
c/sedona-gdal/src/geo_transform.rs | 152 +++++++++++++++++
c/sedona-gdal/src/lib.rs | 4 +
c/sedona-gdal/src/spatial_ref.rs | 292 ++++++++++++++++++++++++++++++++
c/sedona-gdal/src/{lib.rs => vector.rs} | 16 +-
c/sedona-gdal/src/vector/geometry.rs | 238 ++++++++++++++++++++++++++
c/sedona-gdal/src/vsi.rs | 292 ++++++++++++++++++++++++++++++++
10 files changed, 1037 insertions(+), 16 deletions(-)
diff --git a/c/sedona-gdal/src/dyn_load.rs b/c/sedona-gdal/src/dyn_load.rs
index f85f05b0..25368510 100644
--- a/c/sedona-gdal/src/dyn_load.rs
+++ b/c/sedona-gdal/src/dyn_load.rs
@@ -119,6 +119,11 @@ fn load_all_symbols(lib: &Library, api: &mut
SedonaGdalApi) -> Result<(), GdalIn
// --- SpatialRef ---
load_fn!(lib, api, OSRNewSpatialReference);
+ load_fn!(lib, api, OSRSetFromUserInput);
+ load_fn!(lib, api, OSREPSGTreatsAsLatLong);
+ load_fn!(lib, api, OSRGetDataAxisToSRSAxisMapping);
+ load_fn!(lib, api, OSRGetAxisMappingStrategy);
+ load_fn!(lib, api, OSRSetAxisMappingStrategy);
load_fn!(lib, api, OSRDestroySpatialReference);
load_fn!(lib, api, OSRExportToPROJJSON);
load_fn!(lib, api, OSRClone);
diff --git a/c/sedona-gdal/src/errors.rs b/c/sedona-gdal/src/errors.rs
index 6f2ad1a0..f344667e 100644
--- a/c/sedona-gdal/src/errors.rs
+++ b/c/sedona-gdal/src/errors.rs
@@ -20,6 +20,7 @@
//! Original code is licensed under MIT.
use std::ffi::NulError;
+use std::num::TryFromIntError;
use thiserror::Error;
@@ -45,8 +46,23 @@ pub enum GdalError {
#[error("Bad argument: {0}")]
BadArgument(String),
+ #[error("GDAL method '{method_name}' returned a NULL pointer. Error msg:
'{msg}'")]
+ NullPointer {
+ method_name: &'static str,
+ msg: String,
+ },
+
+ #[error("OGR method '{method_name}' returned error: '{err:?}'")]
+ OgrError { err: i32, method_name: &'static str },
+
+ #[error("Unable to unlink mem file: {file_name}")]
+ UnlinkMemFile { file_name: String },
+
#[error("FFI NUL error: {0}")]
FfiNulError(#[from] NulError),
+
+ #[error(transparent)]
+ IntConversionError(#[from] TryFromIntError),
}
pub type Result<T> = std::result::Result<T, GdalError>;
diff --git a/c/sedona-gdal/src/gdal_api.rs b/c/sedona-gdal/src/gdal_api.rs
index 4b8381a6..c4baa6b1 100644
--- a/c/sedona-gdal/src/gdal_api.rs
+++ b/c/sedona-gdal/src/gdal_api.rs
@@ -96,7 +96,7 @@ impl GdalApi {
}
}
- /// Check the last CPL error and return a `GdalError`, it always returns
an error struct
+ /// Check the last CPL error and return a `GdalError::CplError`, it always
returns an error struct
/// (even when the error number is 0).
pub fn last_cpl_err(&self, default_err_class: u32) -> GdalError {
let err_no = unsafe { call_gdal_api!(self, CPLGetLastErrorNo) };
@@ -115,4 +115,21 @@ impl GdalApi {
msg: err_msg,
}
}
+
+ /// Check the last CPL error and return a `GdalError::NullPointer`, it
always returns an error struct
+ pub fn last_null_pointer_err(&self, method_name: &'static str) ->
GdalError {
+ let err_msg = unsafe {
+ let msg_ptr = call_gdal_api!(self, CPLGetLastErrorMsg);
+ if msg_ptr.is_null() {
+ String::new()
+ } else {
+ CStr::from_ptr(msg_ptr).to_string_lossy().into_owned()
+ }
+ };
+ unsafe { call_gdal_api!(self, CPLErrorReset) };
+ GdalError::NullPointer {
+ method_name,
+ msg: err_msg,
+ }
+ }
}
diff --git a/c/sedona-gdal/src/gdal_dyn_bindgen.rs
b/c/sedona-gdal/src/gdal_dyn_bindgen.rs
index 1b05a2c1..aa88059d 100644
--- a/c/sedona-gdal/src/gdal_dyn_bindgen.rs
+++ b/c/sedona-gdal/src/gdal_dyn_bindgen.rs
@@ -228,6 +228,14 @@ pub const CE_Fatal: CPLErr = 4;
pub const OGRERR_NONE: OGRErr = 0;
+// --- OSRAxisMappingStrategy type and constants ---
+
+pub type OSRAxisMappingStrategy = c_int;
+
+pub const OAMS_TRADITIONAL_GIS_ORDER: OSRAxisMappingStrategy = 0;
+pub const OAMS_AUTHORITY_COMPLIANT: OSRAxisMappingStrategy = 1;
+pub const OAMS_CUSTOM: OSRAxisMappingStrategy = 2;
+
// --- OGRwkbByteOrder constants ---
pub const wkbXDR: OGRwkbByteOrder = 0; // Big endian
@@ -360,6 +368,17 @@ pub(crate) struct SedonaGdalApi {
// --- SpatialRef ---
pub OSRNewSpatialReference:
Option<unsafe extern "C" fn(pszWKT: *const c_char) ->
OGRSpatialReferenceH>,
+ pub OSRSetFromUserInput: Option<
+ unsafe extern "C" fn(hSRS: OGRSpatialReferenceH, pszDefinition: *const
c_char) -> OGRErr,
+ >,
+ pub OSREPSGTreatsAsLatLong: Option<unsafe extern "C" fn(hSRS:
OGRSpatialReferenceH) -> c_int>,
+ pub OSRGetDataAxisToSRSAxisMapping: Option<
+ unsafe extern "C" fn(hSRS: OGRSpatialReferenceH, pnCount: *mut c_int)
-> *const c_int,
+ >,
+ pub OSRGetAxisMappingStrategy:
+ Option<unsafe extern "C" fn(hSRS: OGRSpatialReferenceH) ->
OSRAxisMappingStrategy>,
+ pub OSRSetAxisMappingStrategy:
+ Option<unsafe extern "C" fn(hSRS: OGRSpatialReferenceH, strategy:
OSRAxisMappingStrategy)>,
pub OSRDestroySpatialReference: Option<unsafe extern "C" fn(hSRS:
OGRSpatialReferenceH)>,
pub OSRExportToPROJJSON: Option<
unsafe extern "C" fn(
diff --git a/c/sedona-gdal/src/geo_transform.rs
b/c/sedona-gdal/src/geo_transform.rs
new file mode 100644
index 00000000..5504e850
--- /dev/null
+++ b/c/sedona-gdal/src/geo_transform.rs
@@ -0,0 +1,152 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Ported (and contains copied code) from georust/gdal:
+//! <https://github.com/georust/gdal/blob/v0.19.0/src/geo_transform.rs>.
+//! Original code is licensed under MIT.
+//!
+//! GeoTransform type and extension trait.
+//!
+//! The [`apply`](GeoTransformEx::apply) and [`invert`](GeoTransformEx::invert)
+//! methods are pure-Rust reimplementations of GDAL's `GDALApplyGeoTransform`
+//! and `GDALInvGeoTransform` (from `alg/gdaltransformer.cpp`). No FFI call or
+//! thread-local state is needed.
+
+use crate::errors;
+use crate::errors::GdalError;
+
+/// An affine geo-transform: six coefficients mapping pixel/line to projection
coordinates.
+///
+/// - `[0]`: x-coordinate of the upper-left corner of the upper-left pixel.
+/// - `[1]`: W-E pixel resolution (pixel width).
+/// - `[2]`: row rotation (typically zero).
+/// - `[3]`: y-coordinate of the upper-left corner of the upper-left pixel.
+/// - `[4]`: column rotation (typically zero).
+/// - `[5]`: N-S pixel resolution (pixel height, negative for North-up).
+pub type GeoTransform = [f64; 6];
+
+/// Extension methods on [`GeoTransform`].
+pub trait GeoTransformEx {
+ /// Apply the geo-transform to a pixel/line coordinate, returning (geo_x,
geo_y).
+ fn apply(&self, x: f64, y: f64) -> (f64, f64);
+
+ /// Invert this geo-transform, returning the inverse coefficients for
+ /// computing (geo_x, geo_y) -> (x, y) transformations.
+ fn invert(&self) -> errors::Result<GeoTransform>;
+}
+
+impl GeoTransformEx for GeoTransform {
+ /// Pure-Rust equivalent of GDAL's `GDALApplyGeoTransform`.
+ fn apply(&self, x: f64, y: f64) -> (f64, f64) {
+ let geo_x = self[0] + x * self[1] + y * self[2];
+ let geo_y = self[3] + x * self[4] + y * self[5];
+ (geo_x, geo_y)
+ }
+
+ /// Pure-Rust equivalent of GDAL's `GDALInvGeoTransform`.
+ fn invert(&self) -> errors::Result<GeoTransform> {
+ let gt = self;
+
+ // Fast path: no rotation/skew — avoid determinant and precision
issues.
+ if gt[2] == 0.0 && gt[4] == 0.0 && gt[1] != 0.0 && gt[5] != 0.0 {
+ return Ok([
+ -gt[0] / gt[1],
+ 1.0 / gt[1],
+ 0.0,
+ -gt[3] / gt[5],
+ 0.0,
+ 1.0 / gt[5],
+ ]);
+ }
+
+ // General case: 2x2 matrix inverse via adjugate / determinant.
+ let det = gt[1] * gt[5] - gt[2] * gt[4];
+ let magnitude = gt[1]
+ .abs()
+ .max(gt[2].abs())
+ .max(gt[4].abs().max(gt[5].abs()));
+
+ if det.abs() <= 1e-10 * magnitude * magnitude {
+ return Err(GdalError::BadArgument(
+ "Geo transform is uninvertible".to_string(),
+ ));
+ }
+
+ let inv_det = 1.0 / det;
+
+ Ok([
+ (gt[2] * gt[3] - gt[0] * gt[5]) * inv_det,
+ gt[5] * inv_det,
+ -gt[2] * inv_det,
+ (-gt[1] * gt[3] + gt[0] * gt[4]) * inv_det,
+ -gt[4] * inv_det,
+ gt[1] * inv_det,
+ ])
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_apply_no_rotation() {
+ // Origin at (100, 200), 10m pixels, north-up
+ let gt: GeoTransform = [100.0, 10.0, 0.0, 200.0, 0.0, -10.0];
+ let (x, y) = gt.apply(5.0, 3.0);
+ assert!((x - 150.0).abs() < 1e-12);
+ assert!((y - 170.0).abs() < 1e-12);
+ }
+
+ #[test]
+ fn test_apply_with_rotation() {
+ let gt: GeoTransform = [100.0, 10.0, 2.0, 200.0, 3.0, -10.0];
+ let (x, y) = gt.apply(5.0, 3.0);
+ // 100 + 5*10 + 3*2 = 156
+ assert!((x - 156.0).abs() < 1e-12);
+ // 200 + 5*3 + 3*(-10) = 185
+ assert!((y - 185.0).abs() < 1e-12);
+ }
+
+ #[test]
+ fn test_invert_no_rotation() {
+ let gt: GeoTransform = [100.0, 10.0, 0.0, 200.0, 0.0, -10.0];
+ let inv = gt.invert().unwrap();
+ // Round-trip: apply then apply inverse should recover pixel/line.
+ let (geo_x, geo_y) = gt.apply(7.0, 4.0);
+ let (px, ln) = inv.apply(geo_x, geo_y);
+ assert!((px - 7.0).abs() < 1e-10);
+ assert!((ln - 4.0).abs() < 1e-10);
+ }
+
+ #[test]
+ fn test_invert_with_rotation() {
+ let gt: GeoTransform = [100.0, 10.0, 2.0, 200.0, 3.0, -10.0];
+ let inv = gt.invert().unwrap();
+ let (geo_x, geo_y) = gt.apply(7.0, 4.0);
+ let (px, ln) = inv.apply(geo_x, geo_y);
+ assert!((px - 7.0).abs() < 1e-10);
+ assert!((ln - 4.0).abs() < 1e-10);
+ }
+
+ #[test]
+ fn test_invert_singular() {
+ // Determinant is zero: both rows are proportional.
+ let gt: GeoTransform = [0.0, 1.0, 2.0, 0.0, 2.0, 4.0];
+ assert!(gt.invert().is_err());
+ }
+}
diff --git a/c/sedona-gdal/src/lib.rs b/c/sedona-gdal/src/lib.rs
index b64a2275..0646a241 100644
--- a/c/sedona-gdal/src/lib.rs
+++ b/c/sedona-gdal/src/lib.rs
@@ -29,4 +29,8 @@ pub mod global;
// --- High-level wrappers ---
pub mod config;
pub mod cpl;
+pub mod geo_transform;
pub mod raster;
+pub mod spatial_ref;
+pub mod vector;
+pub mod vsi;
diff --git a/c/sedona-gdal/src/spatial_ref.rs b/c/sedona-gdal/src/spatial_ref.rs
new file mode 100644
index 00000000..360d457b
--- /dev/null
+++ b/c/sedona-gdal/src/spatial_ref.rs
@@ -0,0 +1,292 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Ported (and contains copied code) from georust/gdal:
+//! <https://github.com/georust/gdal/blob/v0.19.0/src/spatial_ref/srs.rs>.
+//! Original code is licensed under MIT.
+
+use std::ffi::{CStr, CString};
+use std::ptr;
+
+use crate::errors::{GdalError, Result};
+use crate::gdal_api::{call_gdal_api, GdalApi};
+use crate::gdal_dyn_bindgen::OGRERR_NONE;
+use crate::gdal_dyn_bindgen::*;
+
+/// An OGR spatial reference system.
+pub struct SpatialRef {
+ api: &'static GdalApi,
+ c_srs: OGRSpatialReferenceH,
+}
+
+// SAFETY: `SpatialRef` has unique ownership of its GDAL handle and only moves
that
+// ownership between threads. The handle is released exactly once on drop, and
this
+// wrapper does not provide shared concurrent access, so `Send` is sound while
`Sync`
+// remains intentionally unimplemented.
+unsafe impl Send for SpatialRef {}
+
+impl Drop for SpatialRef {
+ fn drop(&mut self) {
+ if !self.c_srs.is_null() {
+ unsafe { call_gdal_api!(self.api, OSRRelease, self.c_srs) };
+ }
+ }
+}
+
+impl SpatialRef {
+ /// Create a new SpatialRef from a WKT string.
+ pub fn from_wkt(api: &'static GdalApi, wkt: &str) -> Result<Self> {
+ let c_wkt = CString::new(wkt)?;
+ let c_srs = unsafe { call_gdal_api!(api, OSRNewSpatialReference,
c_wkt.as_ptr()) };
+ if c_srs.is_null() {
+ return Err(api.last_null_pointer_err("OSRNewSpatialReference"));
+ }
+ Ok(Self { api, c_srs })
+ }
+
+ /// Set spatial reference from various text formats.
+ ///
+ /// This method will examine the provided input, and try to deduce the
format,
+ /// and then use it to initialize the spatial reference system. See the
[C++ API docs][CPP]
+ /// for details on these forms.
+ ///
+ /// [CPP]:
https://gdal.org/api/ogrspatialref.html#_CPPv4N19OGRSpatialReference16SetFromUserInputEPKc
+ pub fn from_definition(api: &'static GdalApi, definition: &str) ->
Result<SpatialRef> {
+ let c_definition = CString::new(definition)?;
+ let c_obj = unsafe { call_gdal_api!(api, OSRNewSpatialReference,
ptr::null()) };
+ if c_obj.is_null() {
+ return Err(api.last_null_pointer_err("OSRNewSpatialReference"));
+ }
+ let rv = unsafe { call_gdal_api!(api, OSRSetFromUserInput, c_obj,
c_definition.as_ptr()) };
+ if rv != OGRERR_NONE {
+ unsafe { call_gdal_api!(api, OSRRelease, c_obj) };
+ return Err(GdalError::OgrError {
+ err: rv,
+ method_name: "OSRSetFromUserInput",
+ });
+ }
+ Ok(SpatialRef { api, c_srs: c_obj })
+ }
+
+ /// Create a SpatialRef by cloning a borrowed C handle via `OSRClone`.
+ ///
+ /// # Safety
+ ///
+ /// The caller must ensure `c_srs` is a valid `OGRSpatialReferenceH`.
+ pub unsafe fn from_c_srs_clone(
+ api: &'static GdalApi,
+ c_srs: OGRSpatialReferenceH,
+ ) -> Result<Self> {
+ let cloned = call_gdal_api!(api, OSRClone, c_srs);
+ if cloned.is_null() {
+ return Err(api.last_null_pointer_err("OSRClone"));
+ }
+ Ok(Self { api, c_srs: cloned })
+ }
+
+ /// Return the borrowed raw C handle.
+ ///
+ /// The returned handle is owned by `self` and must not be released or
destroyed
+ /// by the caller. It is only valid for the lifetime of `&self`.
+ pub fn c_srs(&self) -> OGRSpatialReferenceH {
+ self.c_srs
+ }
+
+ /// Returns whether EPSG defines this CRS with latitude before longitude.
+ pub fn epsg_treats_as_lat_long(&self) -> bool {
+ unsafe { call_gdal_api!(self.api, OSREPSGTreatsAsLatLong, self.c_srs)
!= 0 }
+ }
+
+ /// Returns the data-axis to SRS-axis mapping as an owned vector.
+ pub fn data_axis_to_srs_axis_mapping(&self) -> Result<Vec<i32>> {
+ let mut count: i32 = 0;
+ let ptr = unsafe {
+ call_gdal_api!(
+ self.api,
+ OSRGetDataAxisToSRSAxisMapping,
+ self.c_srs,
+ &mut count
+ )
+ };
+
+ if count < 0 {
+ return Err(GdalError::BadArgument(format!(
+ "OSRGetDataAxisToSRSAxisMapping returned negative count:
{count}"
+ )));
+ }
+
+ if count == 0 {
+ return Ok(Vec::new());
+ }
+
+ if ptr.is_null() {
+ return Err(self
+ .api
+ .last_null_pointer_err("OSRGetDataAxisToSRSAxisMapping"));
+ }
+
+ let count = usize::try_from(count)?;
+ let mapping = unsafe { std::slice::from_raw_parts(ptr, count)
}.to_vec();
+ Ok(mapping)
+ }
+
+ /// Returns the current axis mapping strategy.
+ pub fn axis_mapping_strategy(&self) -> OSRAxisMappingStrategy {
+ unsafe { call_gdal_api!(self.api, OSRGetAxisMappingStrategy,
self.c_srs) }
+ }
+
+ /// Sets the axis mapping strategy used by this spatial reference.
+ pub fn set_axis_mapping_strategy(&self, strategy: OSRAxisMappingStrategy) {
+ unsafe { call_gdal_api!(self.api, OSRSetAxisMappingStrategy,
self.c_srs, strategy) };
+ }
+
+ /// Export to PROJJSON string.
+ pub fn to_projjson(&self) -> Result<String> {
+ unsafe {
+ let mut ptr: *mut std::os::raw::c_char = ptr::null_mut();
+ let rv = call_gdal_api!(
+ self.api,
+ OSRExportToPROJJSON,
+ self.c_srs,
+ &mut ptr,
+ ptr::null()
+ );
+ if rv != OGRERR_NONE {
+ if !ptr.is_null() {
+ call_gdal_api!(self.api, VSIFree, ptr as *mut
std::ffi::c_void);
+ }
+ return Err(GdalError::OgrError {
+ err: rv,
+ method_name: "OSRExportToPROJJSON",
+ });
+ }
+ if ptr.is_null() {
+ return
Err(self.api.last_null_pointer_err("OSRExportToPROJJSON"));
+ }
+ let result = CStr::from_ptr(ptr).to_string_lossy().into_owned();
+ call_gdal_api!(self.api, VSIFree, ptr as *mut std::ffi::c_void);
+ Ok(result)
+ }
+ }
+}
+
+#[cfg(all(test, feature = "gdal-sys"))]
+mod tests {
+ use crate::errors::GdalError;
+ use crate::gdal_dyn_bindgen::{OAMS_AUTHORITY_COMPLIANT,
OAMS_TRADITIONAL_GIS_ORDER};
+ use crate::global::with_global_gdal_api;
+ use crate::spatial_ref::SpatialRef;
+
+ const WGS84_WKT: &str = r#"GEOGCS["WGS 84",DATUM["WGS_1984",SPHEROID["WGS
84",6378137,298.257223563]],PRIMEM["Greenwich",0],UNIT["degree",0.0174532925199433]]"#;
+
+ #[test]
+ fn test_from_wkt() {
+ with_global_gdal_api(|api| {
+ let srs = SpatialRef::from_wkt(api, WGS84_WKT).unwrap();
+ assert!(!srs.c_srs().is_null());
+ })
+ .unwrap();
+ }
+
+ #[test]
+ fn test_from_wkt_invalid() {
+ with_global_gdal_api(|api| {
+ let err = SpatialRef::from_wkt(api, "WGS\u{0}84");
+ assert!(matches!(err, Err(GdalError::FfiNulError(_))));
+ })
+ .unwrap();
+ }
+
+ #[test]
+ fn test_from_definition() {
+ with_global_gdal_api(|api| {
+ let srs = SpatialRef::from_definition(api, WGS84_WKT).unwrap();
+ assert!(!srs.c_srs().is_null());
+
+ let srs = SpatialRef::from_definition(api, "EPSG:4326").unwrap();
+ assert!(!srs.c_srs().is_null());
+ })
+ .unwrap();
+ }
+
+ #[test]
+ fn test_epsg_treats_as_lat_long() {
+ with_global_gdal_api(|api| {
+ let srs = SpatialRef::from_definition(api, "EPSG:4326").unwrap();
+ assert!(srs.epsg_treats_as_lat_long());
+ let srs = SpatialRef::from_definition(api, "OGC:CRS84").unwrap();
+ assert!(!srs.epsg_treats_as_lat_long());
+ })
+ .unwrap();
+ }
+
+ #[test]
+ fn test_axis_mapping_strategy_getter_setter() {
+ with_global_gdal_api(|api| {
+ let srs = SpatialRef::from_definition(api, "EPSG:4326").unwrap();
+ assert_eq!(srs.axis_mapping_strategy(), OAMS_AUTHORITY_COMPLIANT);
+ assert_eq!(srs.data_axis_to_srs_axis_mapping().unwrap(), vec![1,
2]);
+
+ // Force lon/lat order by swapping axes in the mapping, and check
that the getter reflects that.
+ srs.set_axis_mapping_strategy(OAMS_TRADITIONAL_GIS_ORDER);
+ assert_eq!(srs.axis_mapping_strategy(),
OAMS_TRADITIONAL_GIS_ORDER);
+ assert_eq!(srs.data_axis_to_srs_axis_mapping().unwrap(), vec![2,
1]);
+
+ srs.set_axis_mapping_strategy(OAMS_AUTHORITY_COMPLIANT);
+ assert_eq!(srs.axis_mapping_strategy(), OAMS_AUTHORITY_COMPLIANT);
+ assert_eq!(srs.data_axis_to_srs_axis_mapping().unwrap(), vec![1,
2]);
+
+ let srs = SpatialRef::from_definition(api, "OGC:CRS84").unwrap();
+ assert_eq!(srs.axis_mapping_strategy(), OAMS_AUTHORITY_COMPLIANT);
+ assert_eq!(srs.data_axis_to_srs_axis_mapping().unwrap(), vec![1,
2]);
+
+ // The original axis order of CRS84 is already lat/lon, so
swapping axes in the mapping should
+ // have no effect.
+ srs.set_axis_mapping_strategy(OAMS_TRADITIONAL_GIS_ORDER);
+ assert_eq!(srs.axis_mapping_strategy(),
OAMS_TRADITIONAL_GIS_ORDER);
+ assert_eq!(srs.data_axis_to_srs_axis_mapping().unwrap(), vec![1,
2]);
+
+ srs.set_axis_mapping_strategy(OAMS_AUTHORITY_COMPLIANT);
+ assert_eq!(srs.axis_mapping_strategy(), OAMS_AUTHORITY_COMPLIANT);
+ assert_eq!(srs.data_axis_to_srs_axis_mapping().unwrap(), vec![1,
2]);
+ })
+ .unwrap();
+ }
+
+ #[test]
+ fn test_to_projjson() {
+ with_global_gdal_api(|api| {
+ let srs = SpatialRef::from_wkt(api, WGS84_WKT).unwrap();
+ let projjson = srs.to_projjson().unwrap();
+ assert!(
+ projjson.contains("WGS 84"),
+ "unexpected projjson: {projjson}"
+ );
+ })
+ .unwrap();
+ }
+
+ #[test]
+ fn test_from_c_srs_clone() {
+ with_global_gdal_api(|api| {
+ let srs = SpatialRef::from_wkt(api, WGS84_WKT).unwrap();
+ let cloned = unsafe { SpatialRef::from_c_srs_clone(api,
srs.c_srs()) }.unwrap();
+ assert_eq!(srs.to_projjson().unwrap(),
cloned.to_projjson().unwrap());
+ })
+ .unwrap();
+ }
+}
diff --git a/c/sedona-gdal/src/lib.rs b/c/sedona-gdal/src/vector.rs
similarity index 76%
copy from c/sedona-gdal/src/lib.rs
copy to c/sedona-gdal/src/vector.rs
index b64a2275..10c038ed 100644
--- a/c/sedona-gdal/src/lib.rs
+++ b/c/sedona-gdal/src/vector.rs
@@ -15,18 +15,4 @@
// specific language governing permissions and limitations
// under the License.
-// --- FFI layer ---
-pub(crate) mod dyn_load;
-pub mod gdal_dyn_bindgen;
-
-// --- Error types ---
-pub mod errors;
-
-// --- Core API ---
-pub mod gdal_api;
-pub mod global;
-
-// --- High-level wrappers ---
-pub mod config;
-pub mod cpl;
-pub mod raster;
+pub mod geometry;
diff --git a/c/sedona-gdal/src/vector/geometry.rs
b/c/sedona-gdal/src/vector/geometry.rs
new file mode 100644
index 00000000..0f3aade9
--- /dev/null
+++ b/c/sedona-gdal/src/vector/geometry.rs
@@ -0,0 +1,238 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Ported (and contains copied code) from georust/gdal:
+//! <https://github.com/georust/gdal/blob/v0.19.0/src/vector/geometry.rs>.
+//! Original code is licensed under MIT.
+
+use std::ffi::CString;
+use std::ptr;
+
+use crate::errors::{GdalError, Result};
+use crate::gdal_api::{call_gdal_api, GdalApi};
+use crate::gdal_dyn_bindgen::*;
+
+pub type Envelope = OGREnvelope;
+
+/// An OGR geometry.
+pub struct Geometry {
+ api: &'static GdalApi,
+ c_geom: OGRGeometryH,
+}
+
+// SAFETY: `Geometry` has unique ownership of its GDAL handle and only
transfers that
+// ownership across threads. The handle is destroyed exactly once on drop, and
this
+// wrapper does not expose concurrent shared access, so `Send` is sound while
`Sync`
+// remains intentionally unimplemented.
+unsafe impl Send for Geometry {}
+
+impl Drop for Geometry {
+ fn drop(&mut self) {
+ if !self.c_geom.is_null() {
+ unsafe { call_gdal_api!(self.api, OGR_G_DestroyGeometry,
self.c_geom) };
+ }
+ }
+}
+
+impl Geometry {
+ /// Create a geometry from WKB bytes.
+ pub fn from_wkb(api: &'static GdalApi, wkb: &[u8]) -> Result<Self> {
+ let wkb_len: i32 = wkb.len().try_into()?;
+ let mut c_geom: OGRGeometryH = ptr::null_mut();
+ let rv = unsafe {
+ call_gdal_api!(
+ api,
+ OGR_G_CreateFromWkb,
+ wkb.as_ptr() as *const std::ffi::c_void,
+ ptr::null_mut(), // hSRS
+ &mut c_geom,
+ wkb_len
+ )
+ };
+ if rv != OGRERR_NONE {
+ if !c_geom.is_null() {
+ unsafe { call_gdal_api!(api, OGR_G_DestroyGeometry, c_geom) };
+ }
+ return Err(GdalError::OgrError {
+ err: rv,
+ method_name: "OGR_G_CreateFromWkb",
+ });
+ }
+ if c_geom.is_null() {
+ return Err(api.last_null_pointer_err("OGR_G_CreateFromWkb"));
+ }
+ Ok(Self { api, c_geom })
+ }
+
+ /// Create a geometry from WKT string.
+ pub fn from_wkt(api: &'static GdalApi, wkt: &str) -> Result<Self> {
+ let c_wkt = CString::new(wkt)?;
+ let mut wkt_ptr = c_wkt.as_ptr() as *mut std::os::raw::c_char;
+ let mut c_geom: OGRGeometryH = ptr::null_mut();
+ let rv = unsafe {
+ call_gdal_api!(
+ api,
+ OGR_G_CreateFromWkt,
+ &mut wkt_ptr,
+ ptr::null_mut(), // hSRS
+ &mut c_geom
+ )
+ };
+ if rv != OGRERR_NONE {
+ if !c_geom.is_null() {
+ unsafe { call_gdal_api!(api, OGR_G_DestroyGeometry, c_geom) };
+ }
+ return Err(GdalError::OgrError {
+ err: rv,
+ method_name: "OGR_G_CreateFromWkt",
+ });
+ }
+ if c_geom.is_null() {
+ return Err(api.last_null_pointer_err("OGR_G_CreateFromWkt"));
+ }
+ Ok(Self { api, c_geom })
+ }
+
+ /// Return the borrowed raw C geometry handle.
+ ///
+ /// The returned handle is owned by `self` and must not be destroyed by the
+ /// caller. It is only valid for the lifetime of `&self`.
+ pub fn c_geometry(&self) -> OGRGeometryH {
+ self.c_geom
+ }
+
+ /// Get the bounding envelope.
+ pub fn envelope(&self) -> Envelope {
+ let mut env = OGREnvelope {
+ MinX: 0.0,
+ MaxX: 0.0,
+ MinY: 0.0,
+ MaxY: 0.0,
+ };
+ unsafe { call_gdal_api!(self.api, OGR_G_GetEnvelope, self.c_geom, &mut
env) };
+ env
+ }
+
+ /// Export to ISO WKB.
+ pub fn wkb(&self) -> Result<Vec<u8>> {
+ let size = unsafe { call_gdal_api!(self.api, OGR_G_WkbSize,
self.c_geom) };
+ if size < 0 {
+ return Err(GdalError::BadArgument(format!(
+ "OGR_G_WkbSize returned negative size: {size}"
+ )));
+ }
+ let mut buf = vec![0u8; size as usize];
+ let rv = unsafe {
+ call_gdal_api!(
+ self.api,
+ OGR_G_ExportToIsoWkb,
+ self.c_geom,
+ wkbNDR, // little-endian
+ buf.as_mut_ptr()
+ )
+ };
+ if rv != OGRERR_NONE {
+ return Err(GdalError::OgrError {
+ err: rv,
+ method_name: "OGR_G_ExportToIsoWkb",
+ });
+ }
+ Ok(buf)
+ }
+}
+
+#[cfg(all(test, feature = "gdal-sys"))]
+mod tests {
+ use super::*;
+
+ use crate::errors::GdalError;
+ use crate::global::with_global_gdal_api;
+
+ #[test]
+ fn test_from_wkt_envelope() {
+ with_global_gdal_api(|api| {
+ let geometry = Geometry::from_wkt(api, "POINT (1 2)").unwrap();
+ let envelope = geometry.envelope();
+
+ assert_eq!(envelope.MinX, 1.0);
+ assert_eq!(envelope.MaxX, 1.0);
+ assert_eq!(envelope.MinY, 2.0);
+ assert_eq!(envelope.MaxY, 2.0);
+ })
+ .unwrap();
+ }
+
+ #[test]
+ fn test_from_wkb() {
+ with_global_gdal_api(|api| {
+ let geometry = Geometry::from_wkt(api, "POINT (1 2)").unwrap();
+ let wkb = geometry.wkb().unwrap();
+ let geometry = Geometry::from_wkb(api, &wkb).unwrap();
+ assert!(!geometry.c_geometry().is_null());
+ })
+ .unwrap();
+ }
+
+ #[test]
+ fn test_wkb_round_trip_preserves_envelope() {
+ with_global_gdal_api(|api| {
+ let geometry = Geometry::from_wkt(api, "LINESTRING (0 1, 2 3, 4
5)").unwrap();
+ let wkb = geometry.wkb().unwrap();
+ let round_tripped = Geometry::from_wkb(api, &wkb).unwrap();
+
+ assert!(!wkb.is_empty());
+
+ let envelope = geometry.envelope();
+ let round_trip_envelope = round_tripped.envelope();
+ assert_eq!(envelope.MinX, round_trip_envelope.MinX);
+ assert_eq!(envelope.MaxX, round_trip_envelope.MaxX);
+ assert_eq!(envelope.MinY, round_trip_envelope.MinY);
+ assert_eq!(envelope.MaxY, round_trip_envelope.MaxY);
+ })
+ .unwrap();
+ }
+
+ #[test]
+ fn test_from_wkt_invalid() {
+ with_global_gdal_api(|api| {
+ let error = Geometry::from_wkt(api, "POINT (").err().unwrap();
+ assert!(matches!(
+ error,
+ GdalError::OgrError {
+ method_name: "OGR_G_CreateFromWkt",
+ ..
+ }
+ ));
+ })
+ .unwrap();
+ }
+
+ #[test]
+ fn test_from_wkb_invalid() {
+ with_global_gdal_api(|api| {
+ let error = Geometry::from_wkb(api, &[0x01, 0x02,
0x03]).err().unwrap();
+ assert!(matches!(
+ error,
+ GdalError::OgrError {
+ method_name: "OGR_G_CreateFromWkb",
+ ..
+ }
+ ));
+ })
+ .unwrap();
+ }
+}
diff --git a/c/sedona-gdal/src/vsi.rs b/c/sedona-gdal/src/vsi.rs
new file mode 100644
index 00000000..80022c7c
--- /dev/null
+++ b/c/sedona-gdal/src/vsi.rs
@@ -0,0 +1,292 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Ported (and contains copied code) from georust/gdal:
+//! <https://github.com/georust/gdal/blob/v0.19.0/src/vsi.rs>.
+//! Original code is licensed under MIT.
+//!
+//! GDAL Virtual File System (VSI) wrappers.
+
+use std::ffi::CString;
+use std::ops::Deref;
+
+use crate::errors::{GdalError, Result};
+use crate::gdal_api::{call_gdal_api, GdalApi};
+
+/// An owned GDAL-allocated VSI memory buffer.
+pub struct VSIBuffer {
+ api: &'static GdalApi,
+ ptr: *mut u8,
+ len: usize,
+}
+
+// SAFETY: `VsiBuffer` uniquely owns the GDAL-allocated buffer it wraps.
Ownership may
+// move across threads, and the buffer is released exactly once on drop using
GDAL's
+// allocator.
+unsafe impl Send for VSIBuffer {}
+
+// SAFETY: `VsiBuffer` exposes only shared read-only slice access to an
immutable
+// GDAL-owned byte buffer. Concurrent reads are therefore safe, and the buffer
is
+// still released exactly once on drop using GDAL's allocator.
+unsafe impl Sync for VSIBuffer {}
+
+impl VSIBuffer {
+ pub fn len(&self) -> usize {
+ self.len
+ }
+
+ pub fn is_empty(&self) -> bool {
+ self.len == 0
+ }
+}
+
+impl AsRef<[u8]> for VSIBuffer {
+ fn as_ref(&self) -> &[u8] {
+ if self.len == 0 {
+ &[]
+ } else {
+ unsafe { std::slice::from_raw_parts(self.ptr, self.len) }
+ }
+ }
+}
+
+impl Deref for VSIBuffer {
+ type Target = [u8];
+
+ fn deref(&self) -> &Self::Target {
+ self.as_ref()
+ }
+}
+
+impl Drop for VSIBuffer {
+ fn drop(&mut self) {
+ if !self.ptr.is_null() {
+ unsafe { call_gdal_api!(self.api, VSIFree,
self.ptr.cast::<std::ffi::c_void>()) };
+ }
+ }
+}
+
+/// Creates a new VSI in-memory file from a given buffer.
+///
+/// The data is copied into GDAL-allocated memory (via `VSIMalloc`) so that
+/// GDAL can safely free it with `VSIFree` when ownership is taken, without
+/// crossing allocator boundaries back into Rust.
+pub fn create_mem_file(api: &'static GdalApi, file_name: &str, data: &[u8]) ->
Result<()> {
+ let c_file_name = CString::new(file_name)?;
+ let len = data.len();
+ let len_i64 = i64::try_from(len)?;
+
+ let gdal_buf = if len == 0 {
+ std::ptr::null_mut()
+ } else {
+ // Allocate via GDAL's allocator so GDAL can safely free it.
+ let gdal_buf = unsafe { call_gdal_api!(api, VSIMalloc, len) } as *mut
u8;
+ if gdal_buf.is_null() {
+ return Err(api.last_null_pointer_err("VSIMalloc"));
+ }
+
+ // Copy data into GDAL-allocated buffer.
+ unsafe {
+ std::ptr::copy_nonoverlapping(data.as_ptr(), gdal_buf, len);
+ }
+ gdal_buf
+ };
+
+ let handle = unsafe {
+ call_gdal_api!(
+ api,
+ VSIFileFromMemBuffer,
+ c_file_name.as_ptr(),
+ gdal_buf,
+ len_i64,
+ 1 // bTakeOwnership = true — GDAL will VSIFree gdal_buf
+ )
+ };
+
+ if handle.is_null() {
+ // GDAL did not take ownership, so we must free.
+ if !gdal_buf.is_null() {
+ unsafe { call_gdal_api!(api, VSIFree, gdal_buf as *mut
std::ffi::c_void) };
+ }
+ return Err(api.last_null_pointer_err("VSIFileFromMemBuffer"));
+ }
+
+ unsafe {
+ call_gdal_api!(api, VSIFCloseL, handle);
+ }
+
+ Ok(())
+}
+
+/// Unlink (delete) a VSI in-memory file.
+pub fn unlink_mem_file(api: &'static GdalApi, file_name: &str) -> Result<()> {
+ let c_file_name = CString::new(file_name)?;
+
+ let rv = unsafe { call_gdal_api!(api, VSIUnlink, c_file_name.as_ptr()) };
+
+ if rv != 0 {
+ return Err(GdalError::UnlinkMemFile {
+ file_name: file_name.to_string(),
+ });
+ }
+
+ Ok(())
+}
+
+/// Returns an owned GDAL-allocated buffer containing the bytes of the VSI
in-memory
+/// file, taking ownership and freeing the GDAL memory on drop.
+pub fn get_vsi_mem_file_buffer_owned(api: &'static GdalApi, file_name: &str)
-> Result<VSIBuffer> {
+ let c_file_name = CString::new(file_name)?;
+
+ let mut length: i64 = 0;
+ let bytes = unsafe {
+ call_gdal_api!(
+ api,
+ VSIGetMemFileBuffer,
+ c_file_name.as_ptr(),
+ &mut length,
+ 1 // bUnlinkAndSeize = true
+ )
+ };
+
+ if length < 0 {
+ if !bytes.is_null() {
+ unsafe { call_gdal_api!(api, VSIFree,
bytes.cast::<std::ffi::c_void>()) };
+ }
+ return Err(GdalError::BadArgument(format!(
+ "VSIGetMemFileBuffer returned negative length: {length}"
+ )));
+ }
+
+ if bytes.is_null() {
+ if length == 0 {
+ return Ok(VSIBuffer {
+ api,
+ ptr: std::ptr::null_mut(),
+ len: 0,
+ });
+ }
+ return Err(api.last_null_pointer_err("VSIGetMemFileBuffer"));
+ }
+
+ let len = usize::try_from(length)?;
+ Ok(VSIBuffer {
+ api,
+ ptr: bytes.cast::<u8>(),
+ len,
+ })
+}
+
+/// Copies the bytes of the VSI in-memory file, taking ownership and freeing
the GDAL memory.
+pub fn get_vsi_mem_file_bytes_owned(api: &'static GdalApi, file_name: &str) ->
Result<Vec<u8>> {
+ let buffer = get_vsi_mem_file_buffer_owned(api, file_name)?;
+ Ok(buffer.as_ref().to_vec())
+}
+
+#[cfg(all(test, feature = "gdal-sys"))]
+mod tests {
+ use super::*;
+ use crate::global::with_global_gdal_api;
+
+ #[test]
+ fn create_and_retrieve_mem_file() {
+ let file_name = "/vsimem/525ebf24-a030-4677-bb4e-a921741cabe0";
+
+ with_global_gdal_api(|api| {
+ create_mem_file(api, file_name, &[1_u8, 2, 3, 4]).unwrap();
+
+ let bytes = get_vsi_mem_file_bytes_owned(api, file_name).unwrap();
+
+ assert_eq!(bytes, vec![1_u8, 2, 3, 4]);
+
+ // mem file must not be there anymore
+ assert!(matches!(
+ unlink_mem_file(api, file_name).unwrap_err(),
+ GdalError::UnlinkMemFile {
+ file_name: err_file_name
+ }
+ if err_file_name == file_name
+ ));
+ })
+ .unwrap();
+ }
+
+ #[test]
+ fn create_and_retrieve_mem_file_buffer() {
+ let file_name = "/vsimem/2e3c48a5-d2ef-4f5c-896d-5467cdca9406";
+
+ with_global_gdal_api(|api| {
+ create_mem_file(api, file_name, &[1_u8, 2, 3, 4]).unwrap();
+
+ let buffer = get_vsi_mem_file_buffer_owned(api,
file_name).unwrap();
+
+ assert_eq!(buffer.len(), 4);
+ assert_eq!(buffer.as_ref(), &[1_u8, 2, 3, 4]);
+ })
+ .unwrap();
+ }
+
+ #[test]
+ fn create_and_unlink_mem_file() {
+ let file_name = "/vsimem/bbf5f1d6-c1e9-4469-a33b-02cd9173132d";
+
+ with_global_gdal_api(|api| {
+ create_mem_file(api, file_name, &[1_u8, 2, 3, 4]).unwrap();
+
+ unlink_mem_file(api, file_name).unwrap();
+ })
+ .unwrap();
+ }
+
+ #[test]
+ fn create_and_retrieve_empty_mem_file() {
+ let file_name = "/vsimem/3f9e6282-313d-4c51-81ab-f020ff2134d8";
+
+ with_global_gdal_api(|api| {
+ create_mem_file(api, file_name, &[]).unwrap();
+
+ let bytes = get_vsi_mem_file_bytes_owned(api, file_name).unwrap();
+
+ assert!(bytes.is_empty());
+ })
+ .unwrap();
+ }
+
+ #[test]
+ fn create_and_retrieve_empty_mem_file_buffer() {
+ let file_name = "/vsimem/17319db4-775b-4380-a9af-802c160dcb24";
+
+ with_global_gdal_api(|api| {
+ create_mem_file(api, file_name, &[]).unwrap();
+
+ let buffer = get_vsi_mem_file_buffer_owned(api,
file_name).unwrap();
+
+ assert!(buffer.is_empty());
+ assert_eq!(buffer.as_ref(), &[]);
+ })
+ .unwrap();
+ }
+
+ #[test]
+ fn no_mem_file() {
+ with_global_gdal_api(|api| {
+ let bytes = get_vsi_mem_file_bytes_owned(api, "foobar").unwrap();
+ assert!(bytes.is_empty());
+ })
+ .unwrap();
+ }
+}