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();
+    }
+}


Reply via email to