This is an automated email from the ASF dual-hosted git repository.
paleolimbot 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 3f4acd0f feat(rust/sedona-functions): Implement ST_Affine(),
ST_Rotate(), and ST_Scale() (#504)
3f4acd0f is described below
commit 3f4acd0f074e78a0bd7e2e365e8912df0710a718
Author: Hiroaki Yutani <[email protected]>
AuthorDate: Wed Jan 14 11:20:07 2026 +0900
feat(rust/sedona-functions): Implement ST_Affine(), ST_Rotate(), and
ST_Scale() (#504)
Co-authored-by: Dewey Dunnington <[email protected]>
---
Cargo.lock | 7 +
Cargo.toml | 1 +
python/sedonadb/tests/functions/test_functions.py | 276 ++++++++++
rust/sedona-functions/Cargo.toml | 1 +
rust/sedona-functions/src/lib.rs | 4 +
rust/sedona-functions/src/register.rs | 5 +
rust/sedona-functions/src/st_affine.rs | 453 ++++++++++++++++
rust/sedona-functions/src/st_affine_helpers.rs | 634 ++++++++++++++++++++++
rust/sedona-functions/src/st_rotate.rs | 261 +++++++++
rust/sedona-functions/src/st_scale.rs | 348 ++++++++++++
rust/sedona-geometry/src/transform.rs | 108 +++-
11 files changed, 2082 insertions(+), 16 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index aa9c05ec..ca89f4e9 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2853,6 +2853,12 @@ dependencies = [
"wasm-bindgen",
]
+[[package]]
+name = "glam"
+version = "0.30.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19fc433e8437a212d1b6f1e68c7824af3aed907da60afa994e7f542d18d12aa9"
+
[[package]]
name = "glob"
version = "0.3.3"
@@ -5089,6 +5095,7 @@ dependencies = [
"datafusion-common",
"datafusion-expr",
"geo-traits",
+ "glam",
"rstest",
"sedona-common",
"sedona-expr",
diff --git a/Cargo.toml b/Cargo.toml
index 9105526f..1e69ef66 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -92,6 +92,7 @@ dirs = "6.0.0"
env_logger = "0.11"
fastrand = "2.0"
futures = "0.3"
+glam = "0.30.10"
object_store = { version = "0.12.4", default-features = false }
float_next_after = "1"
num-traits = { version = "0.2", default-features = false, features = ["libm"] }
diff --git a/python/sedonadb/tests/functions/test_functions.py
b/python/sedonadb/tests/functions/test_functions.py
index ff566ccc..28f2e901 100644
--- a/python/sedonadb/tests/functions/test_functions.py
+++ b/python/sedonadb/tests/functions/test_functions.py
@@ -17,6 +17,7 @@
import pytest
import shapely
from sedonadb.testing import PostGIS, SedonaDB, geom_or_null, val_or_null
+import math
@pytest.mark.parametrize("eng", [SedonaDB, PostGIS])
@@ -189,6 +190,281 @@ def test_st_azimuth(eng, geom1, geom2, expected):
)
+# fmt: off
[email protected]("eng", [SedonaDB, PostGIS])
[email protected](
+ ("geom", "a", "b", "d", "e", "xoff", "yoff", "expected"),
+ [
+ (
+ None,
+ 1.0, 0.0,
+ 0.0, 2.0,
+ 1.0, 3.0,
+ None
+ ),
+ (
+ "POINT (1 2)",
+ None, 0.0,
+ 0.0, 2.0,
+ 1.0, 3.0,
+ None
+ ),
+ (
+ "POINT (1 2)",
+ 1.0, 0.0,
+ 0.0, 2.0,
+ 1.0, None,
+ None
+ ),
+ (
+ "POINT (1 2)",
+ 1.0, 0.0,
+ 0.0, 1.0,
+ 0.0, 0.0,
+ "POINT (1 2)"
+ ),
+ (
+ "POINT (1 2)",
+ 2.0, 0.0,
+ 0.0, 2.0,
+ 1.0, 3.0,
+ "POINT (3 7)"
+ ),
+ (
+ "LINESTRING (0 0, 1 1)",
+ 1.0, 0.0,
+ 0.0, 1.0,
+ 1.0, 2.0,
+ "LINESTRING (1 2, 2 3)"
+ ),
+ ],
+)
+def test_st_affine_2d(eng, geom, a, b, d, e, xoff, yoff, expected):
+ eng = eng.create_or_skip()
+ eng.assert_query_result(
+ "SELECT ST_Affine("
+ f"{geom_or_null(geom)}, "
+ f"{val_or_null(a)}, {val_or_null(b)}, {val_or_null(d)},
{val_or_null(e)}, "
+ f"{val_or_null(xoff)}, {val_or_null(yoff)})",
+ expected,
+ )
+# fmt: on
+
+
+# fmt: off
[email protected]("eng", [SedonaDB, PostGIS])
[email protected](
+ ("geom", "a", "b", "c", "d", "e", "f", "g", "h", "i", "xoff", "yoff",
"zoff", "expected"),
+ [
+ (
+ None,
+ 1.0, 0.0, 0.0,
+ 0.0, 2.0, 0.0,
+ 0.0, 0.0, 2.0,
+ 1.0, 3.0, 5.0,
+ None
+ ),
+ (
+ "POINT Z (1 2 3)",
+ None, 0.0, 0.0,
+ 0.0, 2.0, 0.0,
+ 0.0, 0.0, 2.0,
+ 1.0, 3.0, 5.0,
+ None
+ ),
+ (
+ "POINT Z (1 2 3)",
+ 2.0, 0.0, 0.0,
+ 0.0, 2.0, 0.0,
+ 0.0, 0.0, 2.0,
+ 1.0, 3.0, None,
+ None
+ ),
+ (
+ "POINT Z (1 2 3)",
+ 1.0, 0.0, 0.0,
+ 0.0, 1.0, 0.0,
+ 0.0, 0.0, 1.0,
+ 0.0, 0.0, 0.0,
+ "POINT Z (1 2 3)",
+ ),
+ (
+ "POINT Z (1 2 3)",
+ 2.0, 0.0, 0.0,
+ 0.0, 2.0, 0.0,
+ 0.0, 0.0, 2.0,
+ 1.0, 3.0, 5.0,
+ "POINT Z (3 7 11)",
+ ),
+ ],
+)
+def test_st_affine_3d(
+ eng, geom, a, b, c, d, e, f, g, h, i, xoff, yoff, zoff, expected
+):
+ eng = eng.create_or_skip()
+ query = (
+ "SELECT ST_Affine("
+ f"{geom_or_null(geom)}, "
+ f"{val_or_null(a)}, {val_or_null(b)}, {val_or_null(c)}, "
+ f"{val_or_null(d)}, {val_or_null(e)}, {val_or_null(f)}, "
+ f"{val_or_null(g)}, {val_or_null(h)}, {val_or_null(i)}, "
+ f"{val_or_null(xoff)}, {val_or_null(yoff)}, {val_or_null(zoff)})"
+ )
+ eng.assert_query_result(query, expected)
+# fmt: on
+
+
[email protected]("eng", [SedonaDB, PostGIS])
[email protected](
+ ("geom", "sx", "sy", "expected"),
+ [
+ (None, 1.0, 1.0, None),
+ ("POINT (1 2)", None, 1.0, None),
+ ("POINT (1 2)", 1.0, None, None),
+ ("POINT EMPTY", 1.0, 1.0, "POINT (nan nan)"),
+ ("POINT (1 2)", 1.0, 1.0, "POINT (1 2)"),
+ ("POINT (1 2)", 2.0, 3.0, "POINT (2 6)"),
+ ("LINESTRING (0 0, 1 1)", 2.0, 3.0, "LINESTRING (0 0, 2 3)"),
+ (
+ "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))",
+ 2.0,
+ 3.0,
+ "POLYGON ((0 0, 2 0, 2 3, 0 3, 0 0))",
+ ),
+ (
+ "MULTIPOINT (1 2, 3 4)",
+ 2.0,
+ 3.0,
+ "MULTIPOINT (2 6, 6 12)",
+ ),
+ (
+ "MULTILINESTRING ((0 0, 1 1), (2 2, 3 3))",
+ 2.0,
+ 3.0,
+ "MULTILINESTRING ((0 0, 2 3), (4 6, 6 9))",
+ ),
+ (
+ "MULTIPOLYGON (((0 0, 1 0, 1 1, 0 1, 0 0)))",
+ 2.0,
+ 3.0,
+ "MULTIPOLYGON (((0 0, 2 0, 2 3, 0 3, 0 0)))",
+ ),
+ (
+ "GEOMETRYCOLLECTION (POINT (1 2), LINESTRING (0 0, 1 1))",
+ 2.0,
+ 3.0,
+ "GEOMETRYCOLLECTION (POINT (2 6), LINESTRING (0 0, 2 3))",
+ ),
+ ("POINT Z (1 2 3)", 2.0, 3.0, "POINT Z (2 6 3)"),
+ ("POINT M (1 2 3)", 2.0, 3.0, "POINT M (2 6 3)"),
+ ("POINT ZM (1 2 3 4)", 2.0, 3.0, "POINT ZM (2 6 3 4)"),
+ ],
+)
+def test_st_scale_2d(eng, geom, sx, sy, expected):
+ eng = eng.create_or_skip()
+ eng.assert_query_result(
+ f"SELECT ST_Scale({geom_or_null(geom)}, {val_or_null(sx)},
{val_or_null(sy)})",
+ expected,
+ )
+
+
[email protected]("eng", [SedonaDB, PostGIS])
[email protected](
+ ("geom", "sx", "sy", "sz", "expected"),
+ [
+ (None, 1.0, 1.0, 1.0, None),
+ ("POINT Z (1 2 3)", None, 1.0, 1.0, None),
+ ("POINT Z (1 2 3)", 1.0, 1.0, None, None),
+ ("POINT EMPTY", 1.0, 1.0, 1.0, "POINT (nan nan)"),
+ ("POINT Z EMPTY", 1.0, 1.0, 1.0, "POINT Z (nan nan nan)"),
+ ("POINT Z (1 2 3)", 1.0, 1.0, 1.0, "POINT Z (1 2 3)"),
+ ("POINT Z (1 2 3)", 2.0, 3.0, 4.0, "POINT Z (2 6 12)"),
+ ("POINT ZM (1 2 3 4)", 2.0, 3.0, 4.0, "POINT ZM (2 6 12 4)"),
+ ("LINESTRING Z (0 0 0, 1 1 1)", 2.0, 3.0, 4.0, "LINESTRING Z (0 0 0, 2
3 4)"),
+ (
+ "POLYGON Z ((0 0 0, 1 0 2, 1 1 4, 0 1 2, 0 0 0))",
+ 2.0,
+ 3.0,
+ 4.0,
+ "POLYGON Z ((0 0 0, 2 0 8, 2 3 16, 0 3 8, 0 0 0))",
+ ),
+ ("POINT (1 2)", 2.0, 3.0, 4.0, "POINT (2 6)"),
+ ("POINT M (1 2 3)", 2.0, 3.0, 4.0, "POINT M (2 6 3)"),
+ ],
+)
+def test_st_scale_3d(eng, geom, sx, sy, sz, expected):
+ eng = eng.create_or_skip()
+ query = (
+ "SELECT ST_Scale("
+ f"{geom_or_null(geom)}, {val_or_null(sx)}, {val_or_null(sy)},
{val_or_null(sz)})"
+ )
+ eng.assert_query_result(query, expected)
+
+
[email protected]("eng", [SedonaDB, PostGIS])
[email protected](
+ ("geom", "angle", "expected"),
+ [
+ (None, 0, None),
+ ("POINT (1 2)", None, None),
+ ("POINT EMPTY", 0, "POINT (nan nan)"),
+ ("POINT Z EMPTY", 0, "POINT Z (nan nan nan)"),
+ ("POINT (1 2)", 0, "POINT (1 2)"),
+ ("POINT (1 2)", math.pi / 2, "POINT (-2 1)"),
+ ("POINT (1 2)", math.pi, "POINT (-1 -2)"),
+ ("POINT Z (1 2 3)", math.pi, "POINT Z (-1 -2 3)"),
+ ("POINT M (1 2 3)", math.pi, "POINT M (-1 -2 3)"),
+ ("POINT ZM (1 2 3 4)", math.pi, "POINT ZM (-1 -2 3 4)"),
+ ("LINESTRING (0 0, 1 2)", math.pi, "LINESTRING (0 0, -1 -2)"),
+ ("LINESTRING Z (0 0 0, 1 2 3)", math.pi, "LINESTRING Z (0 0 0, -1 -2
3)"),
+ (
+ "POLYGON ((0 0, 1 2, 2 3, 2 1, 0 0))",
+ math.pi,
+ "POLYGON ((0 0, -1 -2, -2 -3, -2 -1, 0 0))",
+ ),
+ (
+ "POLYGON Z ((0 0 0, 1 2 4, 2 3 4, 2 1 4, 0 0 0))",
+ math.pi,
+ "POLYGON Z ((0 0 0, -1 -2 4, -2 -3 4, -2 -1 4, 0 0 0))",
+ ),
+ ],
+)
+def test_st_rotate(eng, geom, angle, expected):
+ eng = eng.create_or_skip()
+ query = f"SELECT ST_Rotate({geom_or_null(geom)}, {val_or_null(angle)})"
+ eng.assert_query_result(query, expected, wkt_precision=1e-12)
+
+
[email protected]("eng", [SedonaDB, PostGIS])
[email protected](
+ ("geom", "angle", "expected"),
+ [
+ (None, 0, None),
+ ("POINT (1 2)", None, None),
+ ("POINT Z (1 2 3)", math.pi, "POINT Z (1 -2 -3)"),
+ ],
+)
+def test_st_rotate_x(eng, geom, angle, expected):
+ eng = eng.create_or_skip()
+ query = f"SELECT ST_RotateX({geom_or_null(geom)}, {val_or_null(angle)})"
+ eng.assert_query_result(query, expected, wkt_precision=1e-12)
+
+
[email protected]("eng", [SedonaDB, PostGIS])
[email protected](
+ ("geom", "angle", "expected"),
+ [
+ (None, 0, None),
+ ("POINT (1 2)", None, None),
+ ("POINT Z (1 2 3)", math.pi, "POINT Z (-1 2 -3)"),
+ ],
+)
+def test_st_rotate_y(eng, geom, angle, expected):
+ eng = eng.create_or_skip()
+ query = f"SELECT ST_RotateY({geom_or_null(geom)}, {val_or_null(angle)})"
+ eng.assert_query_result(query, expected, wkt_precision=1e-12)
+
+
@pytest.mark.parametrize("eng", [SedonaDB, PostGIS])
@pytest.mark.parametrize(
("geom", "expected_boundary"),
diff --git a/rust/sedona-functions/Cargo.toml b/rust/sedona-functions/Cargo.toml
index 4d0cedbe..41b26e24 100644
--- a/rust/sedona-functions/Cargo.toml
+++ b/rust/sedona-functions/Cargo.toml
@@ -52,6 +52,7 @@ sedona-schema = { workspace = true }
wkb = { workspace = true }
wkt = { workspace = true }
serde_json = { workspace = true }
+glam = { workspace = true }
[[bench]]
harness = false
diff --git a/rust/sedona-functions/src/lib.rs b/rust/sedona-functions/src/lib.rs
index bef42fff..6e38e426 100644
--- a/rust/sedona-functions/src/lib.rs
+++ b/rust/sedona-functions/src/lib.rs
@@ -23,6 +23,8 @@ mod referencing;
pub mod register;
mod sd_format;
pub mod sd_order;
+mod st_affine;
+mod st_affine_helpers;
pub mod st_analyze_agg;
mod st_area;
mod st_asbinary;
@@ -60,6 +62,8 @@ mod st_points;
mod st_pointzm;
mod st_polygonize_agg;
mod st_reverse;
+mod st_rotate;
+mod st_scale;
mod st_setsrid;
mod st_srid;
mod st_start_point;
diff --git a/rust/sedona-functions/src/register.rs
b/rust/sedona-functions/src/register.rs
index 32549b67..80dbbd82 100644
--- a/rust/sedona-functions/src/register.rs
+++ b/rust/sedona-functions/src/register.rs
@@ -63,6 +63,7 @@ pub fn default_function_set() -> FunctionSet {
crate::referencing::st_line_locate_point_udf,
crate::sd_format::sd_format_udf,
crate::sd_order::sd_order_udf,
+ crate::st_affine::st_affine_udf,
crate::st_area::st_area_udf,
crate::st_asbinary::st_asbinary_udf,
crate::st_asgeojson::st_asgeojson_udf,
@@ -102,6 +103,10 @@ pub fn default_function_set() -> FunctionSet {
crate::st_pointzm::st_pointz_udf,
crate::st_pointzm::st_pointzm_udf,
crate::st_reverse::st_reverse_udf,
+ crate::st_rotate::st_rotate_udf,
+ crate::st_rotate::st_rotate_x_udf,
+ crate::st_rotate::st_rotate_y_udf,
+ crate::st_scale::st_scale_udf,
crate::st_setsrid::st_set_crs_udf,
crate::st_setsrid::st_set_srid_udf,
crate::st_srid::st_crs_udf,
diff --git a/rust/sedona-functions/src/st_affine.rs
b/rust/sedona-functions/src/st_affine.rs
new file mode 100644
index 00000000..c831b710
--- /dev/null
+++ b/rust/sedona-functions/src/st_affine.rs
@@ -0,0 +1,453 @@
+// 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.
+use arrow_array::{builder::BinaryBuilder, Array};
+use arrow_schema::DataType;
+use datafusion_common::{error::Result, DataFusionError};
+use datafusion_expr::{
+ scalar_doc_sections::DOC_SECTION_OTHER, ColumnarValue, Documentation,
Volatility,
+};
+use sedona_expr::{
+ item_crs::ItemCrsKernel,
+ scalar_udf::{SedonaScalarKernel, SedonaScalarUDF},
+};
+use sedona_geometry::{transform::transform,
wkb_factory::WKB_MIN_PROBABLE_BYTES};
+use sedona_schema::{
+ datatypes::{SedonaType, WKB_GEOMETRY},
+ matchers::ArgMatcher,
+};
+use std::sync::Arc;
+
+use crate::{
+ executor::WkbExecutor,
+ st_affine_helpers::{self},
+};
+
+/// ST_Affine() scalar UDF
+///
+/// Native implementation for affine transformation
+pub fn st_affine_udf() -> SedonaScalarUDF {
+ SedonaScalarUDF::new(
+ "st_affine",
+ ItemCrsKernel::wrap_impl(vec![
+ Arc::new(STAffine { is_3d: true }),
+ Arc::new(STAffine { is_3d: false }),
+ ]),
+ Volatility::Immutable,
+ Some(st_affine_doc()),
+ )
+}
+
+fn st_affine_doc() -> Documentation {
+ Documentation::builder(
+ DOC_SECTION_OTHER,
+ "Apply an affine transformation to the given geometry.",
+ "ST_Affine (geom: Geometry, a: Double, b: Double, c: Double, d:
Double, e: Double, f: Double, g: Double, h: Double, i: Double, xOff: Double,
yOff: Double, zOff: Double)",
+ )
+ .with_argument("geom", "geometry: Input geometry")
+ .with_argument("a", "a component of the affine matrix")
+ .with_argument("b", "a component of the affine matrix")
+ .with_argument("c", "a component of the affine matrix")
+ .with_argument("d", "a component of the affine matrix")
+ .with_argument("e", "a component of the affine matrix")
+ .with_argument("f", "a component of the affine matrix")
+ .with_argument("g", "a component of the affine matrix")
+ .with_argument("h", "a component of the affine matrix")
+ .with_argument("i", "a component of the affine matrix")
+ .with_argument("xOff", "X offset")
+ .with_argument("yOff", "Y offset")
+ .with_argument("zOff", "Z offset")
+ .with_sql_example("SELECT ST_Affine(ST_GeomFromText('POLYGON Z ((1 0 1, 1
1 1, 2 2 2, 1 0 1))'), 1, 2, 4, 1, 1, 2, 3, 2, 5, 4, 8, 3)")
+ .build()
+}
+
+#[derive(Debug)]
+struct STAffine {
+ is_3d: bool,
+}
+
+impl SedonaScalarKernel for STAffine {
+ fn return_type(&self, args: &[SedonaType]) -> Result<Option<SedonaType>> {
+ let arg_matchers = if self.is_3d {
+ vec![
+ ArgMatcher::is_geometry(),
+ ArgMatcher::is_numeric(), // a
+ ArgMatcher::is_numeric(), // b
+ ArgMatcher::is_numeric(), // c
+ ArgMatcher::is_numeric(), // d
+ ArgMatcher::is_numeric(), // e
+ ArgMatcher::is_numeric(), // f
+ ArgMatcher::is_numeric(), // g
+ ArgMatcher::is_numeric(), // h
+ ArgMatcher::is_numeric(), // i
+ ArgMatcher::is_numeric(), // xOff
+ ArgMatcher::is_numeric(), // yOff
+ ArgMatcher::is_numeric(), // zOff
+ ]
+ } else {
+ vec![
+ ArgMatcher::is_geometry(),
+ ArgMatcher::is_numeric(), // a
+ ArgMatcher::is_numeric(), // b
+ ArgMatcher::is_numeric(), // d
+ ArgMatcher::is_numeric(), // e
+ ArgMatcher::is_numeric(), // xOff
+ ArgMatcher::is_numeric(), // yOff
+ ]
+ };
+
+ let matcher = ArgMatcher::new(arg_matchers, WKB_GEOMETRY);
+
+ matcher.match_args(args)
+ }
+
+ fn invoke_batch(
+ &self,
+ arg_types: &[SedonaType],
+ args: &[ColumnarValue],
+ ) -> Result<ColumnarValue> {
+ let executor = WkbExecutor::new(arg_types, args);
+ let mut builder = BinaryBuilder::with_capacity(
+ executor.num_iterations(),
+ WKB_MIN_PROBABLE_BYTES * executor.num_iterations(),
+ );
+
+ let array_args = args[1..]
+ .iter()
+ .map(|arg| {
+ arg.cast_to(&DataType::Float64, None)?
+ .to_array(executor.num_iterations())
+ })
+ .collect::<Result<Vec<Arc<dyn Array>>>>()?;
+
+ let mut affine_iter = if self.is_3d {
+ st_affine_helpers::DAffineIterator::new_3d(&array_args)?
+ } else {
+ st_affine_helpers::DAffineIterator::new_2d(&array_args)?
+ };
+
+ executor.execute_wkb_void(|maybe_wkb| {
+ let maybe_mat = affine_iter.next().unwrap();
+ match (maybe_wkb, maybe_mat) {
+ (Some(wkb), Some(mat)) => {
+ transform(&wkb, &mat, &mut builder)
+ .map_err(|e|
DataFusionError::Execution(e.to_string()))?;
+ builder.append_value([]);
+ }
+ _ => builder.append_null(),
+ }
+
+ Ok(())
+ })?;
+
+ executor.finish(Arc::new(builder.finish()))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use datafusion_common::ScalarValue;
+ use datafusion_expr::{ColumnarValue, ScalarUDF};
+ use rstest::rstest;
+ use sedona_schema::datatypes::{WKB_GEOMETRY_ITEM_CRS, WKB_VIEW_GEOMETRY};
+ use sedona_testing::{
+ compare::assert_array_equal, create::create_array,
create::create_scalar,
+ testers::ScalarUdfTester,
+ };
+
+ use super::*;
+
+ #[test]
+ fn udf_metadata() {
+ let st_affine_udf: ScalarUDF = st_affine_udf().into();
+ assert_eq!(st_affine_udf.name(), "st_affine");
+ assert!(st_affine_udf.documentation().is_some());
+ }
+
+ #[rstest]
+ fn udf_2d(#[values(WKB_GEOMETRY, WKB_VIEW_GEOMETRY)] sedona_type:
SedonaType) {
+ let tester_2d = ScalarUdfTester::new(
+ st_affine_udf().into(),
+ vec![
+ sedona_type.clone(),
+ SedonaType::Arrow(DataType::Float64),
+ SedonaType::Arrow(DataType::Float64),
+ SedonaType::Arrow(DataType::Float64),
+ SedonaType::Arrow(DataType::Float64),
+ SedonaType::Arrow(DataType::Float64),
+ SedonaType::Arrow(DataType::Float64),
+ ],
+ );
+ tester_2d.assert_return_type(WKB_GEOMETRY);
+
+ let points = create_array(
+ &[
+ None,
+ Some("POINT EMPTY"),
+ Some("POINT M EMPTY"),
+ Some("POINT (1 2)"),
+ Some("POINT M (1 2 3)"),
+ ],
+ &sedona_type,
+ );
+
+ // identity transformation
+
+ #[rustfmt::skip]
+ let m_identity = &[
+ Some(1.0), Some(0.0),
+ Some(0.0), Some(1.0),
+ Some(0.0), Some(0.0),
+ ];
+
+ let expected_identity = create_array(
+ &[
+ None,
+ Some("POINT EMPTY"),
+ Some("POINT M EMPTY"),
+ Some("POINT (1 2)"),
+ Some("POINT M (1 2 3)"),
+ ],
+ &WKB_GEOMETRY,
+ );
+
+ let result_identity = tester_2d
+ .invoke_arrays(prepare_args(points.clone(), m_identity))
+ .unwrap();
+ assert_array_equal(&result_identity, &expected_identity);
+
+ // scale transformation
+
+ #[rustfmt::skip]
+ let m_scale = &[
+ Some(10.0), Some(0.0),
+ Some(0.0), Some(10.0),
+ Some(0.0), Some(0.0),
+ ];
+
+ let expected_scale = create_array(
+ &[
+ None,
+ Some("POINT EMPTY"),
+ Some("POINT M EMPTY"),
+ Some("POINT (10 20)"),
+ Some("POINT M (10 20 3)"),
+ ],
+ &WKB_GEOMETRY,
+ );
+
+ let result_scale = tester_2d
+ .invoke_arrays(prepare_args(points.clone(), m_scale))
+ .unwrap();
+ assert_array_equal(&result_scale, &expected_scale);
+
+ // 2D matrix with 3D input (z/m preserved)
+ let points_3d = create_array(
+ &[
+ None,
+ Some("POINT Z EMPTY"),
+ Some("POINT ZM EMPTY"),
+ Some("POINT Z (1 2 3)"),
+ Some("POINT ZM (1 2 3 4)"),
+ ],
+ &sedona_type,
+ );
+
+ let expected_scale_3d = create_array(
+ &[
+ None,
+ Some("POINT Z EMPTY"),
+ Some("POINT ZM EMPTY"),
+ Some("POINT Z (10 20 3)"),
+ Some("POINT ZM (10 20 3 4)"),
+ ],
+ &WKB_GEOMETRY,
+ );
+
+ let result_scale_3d = tester_2d
+ .invoke_arrays(prepare_args(points_3d, m_scale))
+ .unwrap();
+ assert_array_equal(&result_scale_3d, &expected_scale_3d);
+ }
+
+ #[rstest]
+ fn udf_3d(#[values(WKB_GEOMETRY, WKB_VIEW_GEOMETRY)] sedona_type:
SedonaType) {
+ let tester_3d = ScalarUdfTester::new(
+ st_affine_udf().into(),
+ vec![
+ sedona_type.clone(),
+ SedonaType::Arrow(DataType::Float64),
+ SedonaType::Arrow(DataType::Float64),
+ SedonaType::Arrow(DataType::Float64),
+ SedonaType::Arrow(DataType::Float64),
+ SedonaType::Arrow(DataType::Float64),
+ SedonaType::Arrow(DataType::Float64),
+ SedonaType::Arrow(DataType::Float64),
+ SedonaType::Arrow(DataType::Float64),
+ SedonaType::Arrow(DataType::Float64),
+ SedonaType::Arrow(DataType::Float64),
+ SedonaType::Arrow(DataType::Float64),
+ SedonaType::Arrow(DataType::Float64),
+ ],
+ );
+ tester_3d.assert_return_type(WKB_GEOMETRY);
+
+ let points = create_array(
+ &[
+ None,
+ Some("POINT Z EMPTY"),
+ Some("POINT ZM EMPTY"),
+ Some("POINT Z (1 2 3)"),
+ Some("POINT ZM (1 2 3 4)"),
+ ],
+ &sedona_type,
+ );
+
+ // identity matrix
+ #[rustfmt::skip]
+ let m_identity = &[
+ Some(1.0), Some(0.0), Some(0.0),
+ Some(0.0), Some(1.0), Some(0.0),
+ Some(0.0), Some(0.0), Some(1.0),
+ Some(0.0), Some(0.0), Some(0.0),
+ ];
+
+ let expected_identity = create_array(
+ &[
+ None,
+ Some("POINT Z EMPTY"),
+ Some("POINT ZM EMPTY"),
+ Some("POINT Z (1 2 3)"),
+ Some("POINT ZM (1 2 3 4)"),
+ ],
+ &WKB_GEOMETRY,
+ );
+
+ let result_identity = tester_3d
+ .invoke_arrays(prepare_args(points.clone(), m_identity))
+ .unwrap();
+ assert_array_equal(&result_identity, &expected_identity);
+
+ // scale transformation
+ #[rustfmt::skip]
+ let m_scale = &[
+ Some(10.0), Some(0.0), Some(0.0),
+ Some(0.0), Some(10.0), Some(0.0),
+ Some(0.0), Some(0.0), Some(10.0),
+ Some(0.0), Some(0.0), Some(0.0),
+ ];
+
+ let expected_scale = create_array(
+ &[
+ None,
+ Some("POINT Z EMPTY"),
+ Some("POINT ZM EMPTY"),
+ Some("POINT Z (10 20 30)"),
+ Some("POINT ZM (10 20 30 4)"),
+ ],
+ &WKB_GEOMETRY,
+ );
+
+ let result_scale = tester_3d
+ .invoke_arrays(prepare_args(points, m_scale))
+ .unwrap();
+ assert_array_equal(&result_scale, &expected_scale);
+
+ // 3D matrix with 2D input (z translation ignored, m preserved)
+ let points_2d = create_array(
+ &[
+ None,
+ Some("POINT EMPTY"),
+ Some("POINT M EMPTY"),
+ Some("POINT (1 2)"),
+ Some("POINT M (1 2 3)"),
+ ],
+ &sedona_type,
+ );
+
+ #[rustfmt::skip]
+ let m_translate = &[
+ Some(1.0), Some(0.0), Some(0.0),
+ Some(0.0), Some(1.0), Some(0.0),
+ Some(0.0), Some(0.0), Some(1.0),
+ Some(10.0), Some(20.0), Some(30.0),
+ ];
+
+ let expected_translate = create_array(
+ &[
+ None,
+ Some("POINT EMPTY"),
+ Some("POINT M EMPTY"),
+ Some("POINT (11 22)"),
+ Some("POINT M (11 22 3)"),
+ ],
+ &WKB_GEOMETRY,
+ );
+
+ let result_translate = tester_3d
+ .invoke_arrays(prepare_args(points_2d, m_translate))
+ .unwrap();
+ assert_array_equal(&result_translate, &expected_translate);
+ }
+
+ fn prepare_args(wkt: Arc<dyn Array>, mat: &[Option<f64>]) -> Vec<Arc<dyn
Array>> {
+ let n = wkt.len();
+ let mut args: Vec<Arc<dyn Array>> = mat
+ .iter()
+ .map(|a| {
+ let values = vec![*a; n];
+ Arc::new(arrow_array::Float64Array::from(values)) as Arc<dyn
Array>
+ })
+ .collect();
+ args.insert(0, wkt);
+ args
+ }
+
+ #[rstest]
+ fn udf_invoke_item_crs(#[values(WKB_GEOMETRY_ITEM_CRS.clone())]
sedona_type: SedonaType) {
+ let tester = ScalarUdfTester::new(
+ st_affine_udf().into(),
+ vec![
+ sedona_type.clone(),
+ SedonaType::Arrow(DataType::Float64),
+ SedonaType::Arrow(DataType::Float64),
+ SedonaType::Arrow(DataType::Float64),
+ SedonaType::Arrow(DataType::Float64),
+ SedonaType::Arrow(DataType::Float64),
+ SedonaType::Arrow(DataType::Float64),
+ ],
+ );
+ tester.assert_return_type(sedona_type.clone());
+
+ let geom = create_scalar(Some("POINT (1 2)"), &sedona_type);
+ let args = vec![
+ ColumnarValue::Scalar(geom),
+ ColumnarValue::Scalar(ScalarValue::Float64(Some(2.0))),
+ ColumnarValue::Scalar(ScalarValue::Float64(Some(0.0))),
+ ColumnarValue::Scalar(ScalarValue::Float64(Some(0.0))),
+ ColumnarValue::Scalar(ScalarValue::Float64(Some(2.0))),
+ ColumnarValue::Scalar(ScalarValue::Float64(Some(1.0))),
+ ColumnarValue::Scalar(ScalarValue::Float64(Some(3.0))),
+ ];
+
+ let result = tester.invoke(args).unwrap();
+ if let ColumnarValue::Scalar(scalar) = result {
+ tester.assert_scalar_result_equals(scalar, "POINT (3 7)");
+ } else {
+ panic!("Expected scalar result from item CRS affine invoke");
+ }
+ }
+}
diff --git a/rust/sedona-functions/src/st_affine_helpers.rs
b/rust/sedona-functions/src/st_affine_helpers.rs
new file mode 100644
index 00000000..7de1ba94
--- /dev/null
+++ b/rust/sedona-functions/src/st_affine_helpers.rs
@@ -0,0 +1,634 @@
+// 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.
+use arrow_array::types::Float64Type;
+use arrow_array::Array;
+use arrow_array::PrimitiveArray;
+use datafusion_common::cast::as_float64_array;
+use datafusion_common::error::Result;
+use sedona_common::sedona_internal_err;
+use sedona_geometry::transform::CrsTransform;
+use std::sync::Arc;
+
+pub(crate) struct DAffine2Iterator<'a> {
+ index: usize,
+ a: &'a PrimitiveArray<Float64Type>,
+ b: &'a PrimitiveArray<Float64Type>,
+ d: &'a PrimitiveArray<Float64Type>,
+ e: &'a PrimitiveArray<Float64Type>,
+ x_offset: &'a PrimitiveArray<Float64Type>,
+ y_offset: &'a PrimitiveArray<Float64Type>,
+ no_null: bool,
+}
+
+impl<'a> DAffine2Iterator<'a> {
+ pub(crate) fn new(array_args: &'a [Arc<dyn Array>]) -> Result<Self> {
+ if array_args.len() != 6 {
+ return sedona_internal_err!("Invalid number of arguments are
passed");
+ }
+
+ let a = as_float64_array(&array_args[0])?;
+ let b = as_float64_array(&array_args[1])?;
+ let d = as_float64_array(&array_args[2])?;
+ let e = as_float64_array(&array_args[3])?;
+ let x_offset = as_float64_array(&array_args[4])?;
+ let y_offset = as_float64_array(&array_args[5])?;
+
+ Ok(Self {
+ index: 0,
+ a,
+ b,
+ d,
+ e,
+ x_offset,
+ y_offset,
+ no_null: a.null_count() == 0
+ && b.null_count() == 0
+ && d.null_count() == 0
+ && e.null_count() == 0
+ && x_offset.null_count() == 0
+ && y_offset.null_count() == 0,
+ })
+ }
+
+ fn is_null(&self, i: usize) -> bool {
+ if self.no_null {
+ return false;
+ }
+
+ self.a.is_null(i)
+ || self.b.is_null(i)
+ || self.d.is_null(i)
+ || self.e.is_null(i)
+ || self.x_offset.is_null(i)
+ || self.y_offset.is_null(i)
+ }
+}
+
+impl<'a> Iterator for DAffine2Iterator<'a> {
+ // As this needs to distinguish NULL, next() returns Some(Some(value))
+ type Item = Option<glam::DAffine2>;
+
+ fn next(&mut self) -> Option<Self::Item> {
+ let i = self.index;
+ self.index += 1;
+
+ if self.is_null(i) {
+ return Some(None);
+ }
+
+ Some(Some(glam::DAffine2 {
+ matrix2: glam::DMat2 {
+ x_axis: glam::DVec2 {
+ x: self.a.value(i),
+ y: self.b.value(i),
+ },
+ y_axis: glam::DVec2 {
+ x: self.d.value(i),
+ y: self.e.value(i),
+ },
+ },
+ translation: glam::DVec2 {
+ x: self.x_offset.value(i),
+ y: self.y_offset.value(i),
+ },
+ }))
+ }
+}
+
+pub(crate) struct DAffine3Iterator<'a> {
+ index: usize,
+ a: &'a PrimitiveArray<Float64Type>,
+ b: &'a PrimitiveArray<Float64Type>,
+ c: &'a PrimitiveArray<Float64Type>,
+ d: &'a PrimitiveArray<Float64Type>,
+ e: &'a PrimitiveArray<Float64Type>,
+ f: &'a PrimitiveArray<Float64Type>,
+ g: &'a PrimitiveArray<Float64Type>,
+ h: &'a PrimitiveArray<Float64Type>,
+ i: &'a PrimitiveArray<Float64Type>,
+ x_offset: &'a PrimitiveArray<Float64Type>,
+ y_offset: &'a PrimitiveArray<Float64Type>,
+ z_offset: &'a PrimitiveArray<Float64Type>,
+ no_null: bool,
+}
+
+impl<'a> DAffine3Iterator<'a> {
+ pub(crate) fn new(array_args: &'a [Arc<dyn Array>]) -> Result<Self> {
+ if array_args.len() != 12 {
+ return sedona_internal_err!("Invalid number of arguments are
passed");
+ }
+
+ let a = as_float64_array(&array_args[0])?;
+ let b = as_float64_array(&array_args[1])?;
+ let c = as_float64_array(&array_args[2])?;
+ let d = as_float64_array(&array_args[3])?;
+ let e = as_float64_array(&array_args[4])?;
+ let f = as_float64_array(&array_args[5])?;
+ let g = as_float64_array(&array_args[6])?;
+ let h = as_float64_array(&array_args[7])?;
+ let i = as_float64_array(&array_args[8])?;
+ let x_offset = as_float64_array(&array_args[9])?;
+ let y_offset = as_float64_array(&array_args[10])?;
+ let z_offset = as_float64_array(&array_args[11])?;
+
+ Ok(Self {
+ index: 0,
+ a,
+ b,
+ c,
+ d,
+ e,
+ f,
+ g,
+ h,
+ i,
+ x_offset,
+ y_offset,
+ z_offset,
+ no_null: a.null_count() == 0
+ && b.null_count() == 0
+ && c.null_count() == 0
+ && d.null_count() == 0
+ && e.null_count() == 0
+ && f.null_count() == 0
+ && g.null_count() == 0
+ && h.null_count() == 0
+ && i.null_count() == 0
+ && x_offset.null_count() == 0
+ && y_offset.null_count() == 0
+ && z_offset.null_count() == 0,
+ })
+ }
+
+ fn is_null(&self, i: usize) -> bool {
+ if self.no_null {
+ return false;
+ }
+
+ self.a.is_null(i)
+ || self.b.is_null(i)
+ || self.c.is_null(i)
+ || self.d.is_null(i)
+ || self.e.is_null(i)
+ || self.f.is_null(i)
+ || self.g.is_null(i)
+ || self.h.is_null(i)
+ || self.i.is_null(i)
+ || self.x_offset.is_null(i)
+ || self.y_offset.is_null(i)
+ || self.z_offset.is_null(i)
+ }
+}
+
+impl<'a> Iterator for DAffine3Iterator<'a> {
+ // As this needs to distinguish NULL, next() returns Some(Some(value))
+ type Item = Option<glam::DAffine3>;
+
+ fn next(&mut self) -> Option<Self::Item> {
+ let i = self.index;
+ self.index += 1;
+
+ if self.is_null(i) {
+ return Some(None);
+ }
+
+ Some(Some(glam::DAffine3 {
+ matrix3: glam::DMat3 {
+ x_axis: glam::DVec3 {
+ x: self.a.value(i),
+ y: self.b.value(i),
+ z: self.c.value(i),
+ },
+ y_axis: glam::DVec3 {
+ x: self.d.value(i),
+ y: self.e.value(i),
+ z: self.f.value(i),
+ },
+ z_axis: glam::DVec3 {
+ x: self.g.value(i),
+ y: self.h.value(i),
+ z: self.i.value(i),
+ },
+ },
+ translation: glam::DVec3 {
+ x: self.x_offset.value(i),
+ y: self.y_offset.value(i),
+ z: self.z_offset.value(i),
+ },
+ }))
+ }
+}
+
+pub(crate) struct DAffine2ScaleIterator<'a> {
+ index: usize,
+ x_scale: &'a PrimitiveArray<Float64Type>,
+ y_scale: &'a PrimitiveArray<Float64Type>,
+ no_null: bool,
+}
+
+impl<'a> DAffine2ScaleIterator<'a> {
+ pub(crate) fn new(array_args: &'a [Arc<dyn Array>]) -> Result<Self> {
+ if array_args.len() != 2 {
+ return sedona_internal_err!("Invalid number of arguments are
passed");
+ }
+
+ let x_scale = as_float64_array(&array_args[0])?;
+ let y_scale = as_float64_array(&array_args[1])?;
+
+ Ok(Self {
+ index: 0,
+ x_scale,
+ y_scale,
+ no_null: x_scale.null_count() == 0 && y_scale.null_count() == 0,
+ })
+ }
+
+ fn is_null(&self, i: usize) -> bool {
+ if self.no_null {
+ return false;
+ }
+
+ self.x_scale.is_null(i) || self.y_scale.is_null(i)
+ }
+}
+
+impl<'a> Iterator for DAffine2ScaleIterator<'a> {
+ // As this needs to distinguish NULL, next() returns Some(Some(value))
+ type Item = Option<glam::DAffine2>;
+
+ fn next(&mut self) -> Option<Self::Item> {
+ let i = self.index;
+ self.index += 1;
+
+ if self.is_null(i) {
+ return Some(None);
+ }
+
+ let scale = glam::DVec2::new(self.x_scale.value(i),
self.y_scale.value(i));
+ Some(Some(glam::DAffine2::from_scale(scale)))
+ }
+}
+
+pub(crate) struct DAffine3ScaleIterator<'a> {
+ index: usize,
+ x_scale: &'a PrimitiveArray<Float64Type>,
+ y_scale: &'a PrimitiveArray<Float64Type>,
+ z_scale: &'a PrimitiveArray<Float64Type>,
+ no_null: bool,
+}
+
+impl<'a> DAffine3ScaleIterator<'a> {
+ pub(crate) fn new(array_args: &'a [Arc<dyn Array>]) -> Result<Self> {
+ if array_args.len() != 3 {
+ return sedona_internal_err!("Invalid number of arguments are
passed");
+ }
+
+ let x_scale = as_float64_array(&array_args[0])?;
+ let y_scale = as_float64_array(&array_args[1])?;
+ let z_scale = as_float64_array(&array_args[2])?;
+
+ Ok(Self {
+ index: 0,
+ x_scale,
+ y_scale,
+ z_scale,
+ no_null: x_scale.null_count() == 0
+ && y_scale.null_count() == 0
+ && z_scale.null_count() == 0,
+ })
+ }
+
+ fn is_null(&self, i: usize) -> bool {
+ if self.no_null {
+ return false;
+ }
+
+ self.x_scale.is_null(i) || self.y_scale.is_null(i) ||
self.z_scale.is_null(i)
+ }
+}
+
+impl<'a> Iterator for DAffine3ScaleIterator<'a> {
+ // As this needs to distinguish NULL, next() returns Some(Some(value))
+ type Item = Option<glam::DAffine3>;
+
+ fn next(&mut self) -> Option<Self::Item> {
+ let i = self.index;
+ self.index += 1;
+
+ if self.is_null(i) {
+ return Some(None);
+ }
+
+ let scale = glam::DVec3::new(
+ self.x_scale.value(i),
+ self.y_scale.value(i),
+ self.z_scale.value(i),
+ );
+ Some(Some(glam::DAffine3::from_scale(scale)))
+ }
+}
+
+#[derive(Debug, Clone, Copy)]
+pub(crate) enum RotateAxis {
+ X,
+ Y,
+ Z,
+}
+
+pub(crate) struct DAffineRotateIterator<'a> {
+ index: usize,
+ angle: &'a PrimitiveArray<Float64Type>,
+ axis: RotateAxis,
+ no_null: bool,
+}
+
+impl<'a> DAffineRotateIterator<'a> {
+ pub(crate) fn new(angle: &'a Arc<dyn Array>, axis: RotateAxis) ->
Result<Self> {
+ let angle = as_float64_array(angle)?;
+ Ok(Self {
+ index: 0,
+ angle,
+ axis,
+ no_null: angle.null_count() == 0,
+ })
+ }
+
+ fn is_null(&self, i: usize) -> bool {
+ if self.no_null {
+ return false;
+ }
+
+ self.angle.is_null(i)
+ }
+}
+
+impl<'a> Iterator for DAffineRotateIterator<'a> {
+ // As this needs to distinguish NULL, next() returns Some(Some(value))
+ type Item = Option<glam::DAffine3>;
+
+ fn next(&mut self) -> Option<Self::Item> {
+ let i = self.index;
+ self.index += 1;
+
+ if self.is_null(i) {
+ return Some(None);
+ }
+
+ match self.axis {
+ RotateAxis::X =>
Some(Some(glam::DAffine3::from_rotation_x(self.angle.value(i)))),
+ RotateAxis::Y =>
Some(Some(glam::DAffine3::from_rotation_y(self.angle.value(i)))),
+ RotateAxis::Z =>
Some(Some(glam::DAffine3::from_rotation_z(self.angle.value(i)))),
+ }
+ }
+}
+
+pub(crate) enum DAffineIterator<'a> {
+ DAffine2(DAffine2Iterator<'a>),
+ DAffine3(DAffine3Iterator<'a>),
+ DAffine2Scale(DAffine2ScaleIterator<'a>),
+ DAffine3Scale(DAffine3ScaleIterator<'a>),
+ DAffineRotate(DAffineRotateIterator<'a>),
+}
+
+impl<'a> DAffineIterator<'a> {
+ pub(crate) fn new_2d(array_args: &'a [Arc<dyn Array>]) -> Result<Self> {
+ Ok(Self::DAffine2(DAffine2Iterator::new(array_args)?))
+ }
+
+ pub(crate) fn new_3d(array_args: &'a [Arc<dyn Array>]) -> Result<Self> {
+ Ok(Self::DAffine3(DAffine3Iterator::new(array_args)?))
+ }
+
+ pub(crate) fn from_scale_2d(array_args: &'a [Arc<dyn Array>]) ->
Result<Self> {
+ Ok(Self::DAffine2Scale(DAffine2ScaleIterator::new(array_args)?))
+ }
+
+ pub(crate) fn from_scale_3d(array_args: &'a [Arc<dyn Array>]) ->
Result<Self> {
+ Ok(Self::DAffine3Scale(DAffine3ScaleIterator::new(array_args)?))
+ }
+
+ pub(crate) fn from_angle(angle: &'a Arc<dyn Array>, axis: RotateAxis) ->
Result<Self> {
+ Ok(Self::DAffineRotate(DAffineRotateIterator::new(
+ angle, axis,
+ )?))
+ }
+}
+
+#[derive(Debug, PartialEq)]
+pub(crate) enum DAffine {
+ DAffine2(glam::DAffine2),
+ DAffine3(glam::DAffine3),
+}
+
+impl CrsTransform for DAffine {
+ fn transform_coord_3d(
+ &self,
+ coord: &mut (f64, f64, f64),
+ ) -> std::result::Result<(), sedona_geometry::error::SedonaGeometryError> {
+ match self {
+ DAffine::DAffine2(daffine2) => {
+ let transformed = daffine2.transform_point2(glam::DVec2 {
+ x: coord.0,
+ y: coord.1,
+ });
+ coord.0 = transformed.x;
+ coord.1 = transformed.y;
+ }
+ DAffine::DAffine3(daffine3) => {
+ let transformed = daffine3.transform_point3(glam::DVec3 {
+ x: coord.0,
+ y: coord.1,
+ z: coord.2,
+ });
+ coord.0 = transformed.x;
+ coord.1 = transformed.y;
+ coord.2 = transformed.z;
+ }
+ }
+
+ Ok(())
+ }
+
+ fn transform_coord(
+ &self,
+ coord: &mut (f64, f64),
+ ) -> std::result::Result<(), sedona_geometry::error::SedonaGeometryError> {
+ match self {
+ DAffine::DAffine2(daffine2) => {
+ let transformed = daffine2.transform_point2(glam::DVec2 {
+ x: coord.0,
+ y: coord.1,
+ });
+ coord.0 = transformed.x;
+ coord.1 = transformed.y;
+ }
+ DAffine::DAffine3(daffine3) => {
+ let transformed = daffine3.transform_point3(glam::DVec3 {
+ x: coord.0,
+ y: coord.1,
+ z: 0.0,
+ });
+ coord.0 = transformed.x;
+ coord.1 = transformed.y;
+ }
+ }
+
+ Ok(())
+ }
+}
+
+impl<'a> Iterator for DAffineIterator<'a> {
+ type Item = Option<DAffine>;
+
+ fn next(&mut self) -> Option<Self::Item> {
+ match self {
+ DAffineIterator::DAffine2(daffine2_iterator) => match
daffine2_iterator.next() {
+ Some(Some(a)) => Some(Some(DAffine::DAffine2(a))),
+ Some(None) => Some(None),
+ None => None,
+ },
+ DAffineIterator::DAffine3(daffine3_iterator) => match
daffine3_iterator.next() {
+ Some(Some(a)) => Some(Some(DAffine::DAffine3(a))),
+ Some(None) => Some(None),
+ None => None,
+ },
+ DAffineIterator::DAffine2Scale(daffine2_scale_iterator) => {
+ match daffine2_scale_iterator.next() {
+ Some(Some(a)) => Some(Some(DAffine::DAffine2(a))),
+ Some(None) => Some(None),
+ None => None,
+ }
+ }
+ DAffineIterator::DAffine3Scale(daffine3_scale_iterator) => {
+ match daffine3_scale_iterator.next() {
+ Some(Some(a)) => Some(Some(DAffine::DAffine3(a))),
+ Some(None) => Some(None),
+ None => None,
+ }
+ }
+ DAffineIterator::DAffineRotate(daffine_rotate_iterator) => {
+ match daffine_rotate_iterator.next() {
+ Some(Some(a)) => Some(Some(DAffine::DAffine3(a))),
+ Some(None) => Some(None),
+ None => None,
+ }
+ }
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use arrow_array::Array;
+ use arrow_array::Float64Array;
+ use std::sync::Arc;
+
+ fn float_array(values: Vec<Option<f64>>) -> Arc<dyn Array> {
+ Arc::new(Float64Array::from(values)) as Arc<dyn Array>
+ }
+
+ #[test]
+ fn daffine2_iterator_handles_nulls() {
+ let args = vec![
+ float_array(vec![Some(1.0), Some(10.0)]),
+ float_array(vec![Some(2.0), Some(20.0)]),
+ float_array(vec![Some(3.0), Some(30.0)]),
+ float_array(vec![Some(4.0), None]),
+ float_array(vec![Some(5.0), Some(50.0)]),
+ float_array(vec![Some(6.0), Some(60.0)]),
+ ];
+
+ let mut iter = DAffine2Iterator::new(&args).unwrap();
+
+ let expected_first = glam::DAffine2 {
+ matrix2: glam::DMat2 {
+ x_axis: glam::DVec2 { x: 1.0, y: 2.0 },
+ y_axis: glam::DVec2 { x: 3.0, y: 4.0 },
+ },
+ translation: glam::DVec2 { x: 5.0, y: 6.0 },
+ };
+ assert_eq!(iter.next(), Some(Some(expected_first)));
+
+ // The second case contains NULL, so the result is NULL
+ assert_eq!(iter.next(), Some(None));
+ }
+
+ #[test]
+ fn daffine3_iterator_values() {
+ let values = [
+ 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0,
+ ];
+ let args = values
+ .iter()
+ .map(|value| float_array(vec![Some(*value)]))
+ .collect::<Vec<_>>();
+
+ let mut iter = DAffine3Iterator::new(&args).unwrap();
+ let expected = glam::DAffine3 {
+ matrix3: glam::DMat3::from_cols(
+ glam::DVec3::new(1.0, 2.0, 3.0),
+ glam::DVec3::new(4.0, 5.0, 6.0),
+ glam::DVec3::new(7.0, 8.0, 9.0),
+ ),
+ translation: glam::DVec3::new(10.0, 11.0, 12.0),
+ };
+
+ assert_eq!(iter.next(), Some(Some(expected)));
+ }
+
+ #[test]
+ fn daffine_iterator_from_scale() {
+ let scale_args = vec![
+ float_array(vec![Some(2.0), None]),
+ float_array(vec![Some(3.0), Some(4.0)]),
+ ];
+ let mut iter = DAffineIterator::from_scale_2d(&scale_args).unwrap();
+
+ let expected_scale =
+ DAffine::DAffine2(glam::DAffine2::from_scale(glam::DVec2::new(2.0,
3.0)));
+ assert_eq!(iter.next(), Some(Some(expected_scale)));
+
+ // The second case contains NULL, so the result is NULL
+ assert_eq!(iter.next(), Some(None));
+ }
+
+ #[test]
+ fn daffine_iterator_from_rotate() {
+ let angle = float_array(vec![Some(0.25), None]);
+ let mut iter = DAffineIterator::from_angle(&angle,
RotateAxis::X).unwrap();
+ let expected_rotate =
DAffine::DAffine3(glam::DAffine3::from_rotation_x(0.25));
+ assert_eq!(iter.next(), Some(Some(expected_rotate)));
+
+ // The second case contains NULL, so the result is NULL
+ assert_eq!(iter.next(), Some(None));
+ }
+
+ #[test]
+ fn daffine_crs_transform_changes_coords() {
+ let mut coord_2d = (1.0, 2.0);
+ let affine_2d =
DAffine::DAffine2(glam::DAffine2::from_scale(glam::DVec2::new(2.0, 3.0)));
+ affine_2d.transform_coord(&mut coord_2d).unwrap();
+ assert_eq!(coord_2d, (2.0, 6.0));
+
+ let mut coord_3d = (1.0, 2.0, 3.0);
+ let affine_3d =
+ DAffine::DAffine3(glam::DAffine3::from_scale(glam::DVec3::new(2.0,
3.0, 4.0)));
+ affine_3d.transform_coord_3d(&mut coord_3d).unwrap();
+ assert_eq!(coord_3d, (2.0, 6.0, 12.0));
+ }
+}
diff --git a/rust/sedona-functions/src/st_rotate.rs
b/rust/sedona-functions/src/st_rotate.rs
new file mode 100644
index 00000000..c60781fc
--- /dev/null
+++ b/rust/sedona-functions/src/st_rotate.rs
@@ -0,0 +1,261 @@
+// 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.
+use arrow_array::builder::BinaryBuilder;
+use arrow_schema::DataType;
+use datafusion_common::{error::Result, DataFusionError};
+use datafusion_expr::{
+ scalar_doc_sections::DOC_SECTION_OTHER, ColumnarValue, Documentation,
Volatility,
+};
+use sedona_expr::{
+ item_crs::ItemCrsKernel,
+ scalar_udf::{SedonaScalarKernel, SedonaScalarUDF},
+};
+use sedona_geometry::{transform::transform,
wkb_factory::WKB_MIN_PROBABLE_BYTES};
+use sedona_schema::{
+ datatypes::{SedonaType, WKB_GEOMETRY},
+ matchers::ArgMatcher,
+};
+use std::sync::Arc;
+
+use crate::{
+ executor::WkbExecutor,
+ st_affine_helpers::{self, RotateAxis},
+};
+
+/// ST_Rotate() scalar UDF
+///
+/// Native implementation for rotate transformation
+pub fn st_rotate_udf() -> SedonaScalarUDF {
+ SedonaScalarUDF::new(
+ "st_rotate",
+ ItemCrsKernel::wrap_impl(vec![Arc::new(STRotate {
+ axis: RotateAxis::Z,
+ })]),
+ Volatility::Immutable,
+ Some(st_rotate_doc("")),
+ )
+}
+
+/// ST_RotateX() scalar UDF
+///
+/// Native implementation for rotate transformation
+pub fn st_rotate_x_udf() -> SedonaScalarUDF {
+ SedonaScalarUDF::new(
+ "st_rotatex",
+ ItemCrsKernel::wrap_impl(vec![Arc::new(STRotate {
+ axis: RotateAxis::X,
+ })]),
+ Volatility::Immutable,
+ Some(st_rotate_doc("X")),
+ )
+}
+
+/// ST_RotateY() scalar UDF
+///
+/// Native implementation for rotate transformation
+pub fn st_rotate_y_udf() -> SedonaScalarUDF {
+ SedonaScalarUDF::new(
+ "st_rotatey",
+ ItemCrsKernel::wrap_impl(vec![Arc::new(STRotate {
+ axis: RotateAxis::Y,
+ })]),
+ Volatility::Immutable,
+ Some(st_rotate_doc("Y")),
+ )
+}
+
+fn st_rotate_doc(axis: &str) -> Documentation {
+ let suffix = match axis {
+ "Z" => "",
+ _ => axis,
+ };
+ Documentation::builder(
+ DOC_SECTION_OTHER,
+ format!("Rotates a geometry by a specified angle in radians
counter-clockwise around the {axis}-axis "),
+ format!("ST_Rotate{suffix} (geom: Geometry, rot: Double)"),
+ )
+ .with_argument("geom", "geometry: Input geometry")
+ .with_argument("rot", "angle (in radians)")
+ .with_sql_example(
+ format!("SELECT ST_Rotate{suffix}(ST_GeomFromText('POLYGON Z ((1 0 1,
1 1 1, 2 2 2, 1 0 1))'), radians(45))"),
+ )
+ .build()
+}
+
+#[derive(Debug)]
+struct STRotate {
+ axis: RotateAxis,
+}
+
+impl SedonaScalarKernel for STRotate {
+ fn return_type(&self, args: &[SedonaType]) -> Result<Option<SedonaType>> {
+ let matcher = ArgMatcher::new(
+ vec![ArgMatcher::is_geometry(), ArgMatcher::is_numeric()],
+ WKB_GEOMETRY,
+ );
+
+ matcher.match_args(args)
+ }
+
+ fn invoke_batch(
+ &self,
+ arg_types: &[SedonaType],
+ args: &[ColumnarValue],
+ ) -> Result<ColumnarValue> {
+ let executor = WkbExecutor::new(arg_types, args);
+ let mut builder = BinaryBuilder::with_capacity(
+ executor.num_iterations(),
+ WKB_MIN_PROBABLE_BYTES * executor.num_iterations(),
+ );
+
+ let angle = args[1]
+ .cast_to(&DataType::Float64, None)?
+ .to_array(executor.num_iterations())?;
+
+ let mut affine_iter =
st_affine_helpers::DAffineIterator::from_angle(&angle, self.axis)?;
+
+ executor.execute_wkb_void(|maybe_wkb| {
+ let maybe_mat = affine_iter.next().unwrap();
+ match (maybe_wkb, maybe_mat) {
+ (Some(wkb), Some(mat)) => {
+ transform(&wkb, &mat, &mut builder)
+ .map_err(|e|
DataFusionError::Execution(e.to_string()))?;
+ builder.append_value([]);
+ }
+ _ => builder.append_null(),
+ }
+
+ Ok(())
+ })?;
+
+ executor.finish(Arc::new(builder.finish()))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use std::f64;
+
+ use arrow_array::Array;
+ use datafusion_expr::ScalarUDF;
+ use rstest::rstest;
+ use sedona_schema::datatypes::{WKB_GEOMETRY_ITEM_CRS, WKB_VIEW_GEOMETRY};
+ use sedona_testing::{
+ compare::assert_array_equal, create::create_array,
testers::ScalarUdfTester,
+ };
+
+ use super::*;
+
+ #[test]
+ fn udf_metadata() {
+ let st_rotate_udf: ScalarUDF = st_rotate_udf().into();
+ assert_eq!(st_rotate_udf.name(), "st_rotate");
+ assert!(st_rotate_udf.documentation().is_some());
+
+ let st_rotate_x_udf: ScalarUDF = st_rotate_x_udf().into();
+ assert_eq!(st_rotate_x_udf.name(), "st_rotatex");
+ assert!(st_rotate_x_udf.documentation().is_some());
+
+ let st_rotate_y_udf: ScalarUDF = st_rotate_y_udf().into();
+ assert_eq!(st_rotate_y_udf.name(), "st_rotatey");
+ assert!(st_rotate_y_udf.documentation().is_some());
+ }
+
+ #[rstest]
+ fn udf(#[values(WKB_GEOMETRY, WKB_VIEW_GEOMETRY)] sedona_type: SedonaType)
{
+ let tester_z = ScalarUdfTester::new(
+ st_rotate_udf().into(),
+ vec![sedona_type.clone(), SedonaType::Arrow(DataType::Float64)],
+ );
+ let tester_x = ScalarUdfTester::new(
+ st_rotate_x_udf().into(),
+ vec![sedona_type.clone(), SedonaType::Arrow(DataType::Float64)],
+ );
+ let tester_y = ScalarUdfTester::new(
+ st_rotate_y_udf().into(),
+ vec![sedona_type.clone(), SedonaType::Arrow(DataType::Float64)],
+ );
+ tester_z.assert_return_type(WKB_GEOMETRY);
+ tester_x.assert_return_type(WKB_GEOMETRY);
+ tester_y.assert_return_type(WKB_GEOMETRY);
+
+ let points = create_array(
+ &[
+ None,
+ Some("POINT EMPTY"),
+ Some("POINT M EMPTY"),
+ Some("POINT (1 2)"),
+ Some("POINT M (1 2 3)"),
+ Some("POINT Z (1 2 3)"),
+ ],
+ &sedona_type,
+ );
+ let expected_identity = create_array(
+ &[
+ None,
+ Some("POINT EMPTY"),
+ Some("POINT M EMPTY"),
+ Some("POINT (1 2)"),
+ Some("POINT M (1 2 3)"),
+ Some("POINT Z (1 2 3)"),
+ ],
+ &WKB_GEOMETRY,
+ );
+
+ let result_identity_z = tester_z
+ .invoke_arrays(prepare_args(points.clone(), &[Some(0.0_f64)]))
+ .unwrap();
+ assert_array_equal(&result_identity_z, &expected_identity);
+
+ let result_identity_x = tester_x
+ .invoke_arrays(prepare_args(points.clone(), &[Some(0.0_f64)]))
+ .unwrap();
+ assert_array_equal(&result_identity_x, &expected_identity);
+
+ let result_identity_y = tester_y
+ .invoke_arrays(prepare_args(points.clone(), &[Some(0.0_f64)]))
+ .unwrap();
+ assert_array_equal(&result_identity_y, &expected_identity);
+
+ // Don't test the rotated results here since it's hard to match with
the exact number.
+ }
+
+ fn prepare_args(wkt: Arc<dyn Array>, mat: &[Option<f64>]) -> Vec<Arc<dyn
Array>> {
+ let n = wkt.len();
+ let mut args: Vec<Arc<dyn Array>> = mat
+ .iter()
+ .map(|a| {
+ let values = vec![*a; n];
+ Arc::new(arrow_array::Float64Array::from(values)) as Arc<dyn
Array>
+ })
+ .collect();
+ args.insert(0, wkt);
+ args
+ }
+
+ #[rstest]
+ fn udf_invoke_item_crs(#[values(WKB_GEOMETRY_ITEM_CRS.clone())]
sedona_type: SedonaType) {
+ let tester = ScalarUdfTester::new(
+ st_rotate_udf().into(),
+ vec![sedona_type.clone(), SedonaType::Arrow(DataType::Float64)],
+ );
+ tester.assert_return_type(sedona_type.clone());
+
+ let result = tester.invoke_scalar_scalar("POINT (1 2)", 0.0).unwrap();
+ tester.assert_scalar_result_equals(result, "POINT (1 2)");
+ }
+}
diff --git a/rust/sedona-functions/src/st_scale.rs
b/rust/sedona-functions/src/st_scale.rs
new file mode 100644
index 00000000..419e0810
--- /dev/null
+++ b/rust/sedona-functions/src/st_scale.rs
@@ -0,0 +1,348 @@
+// 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.
+use arrow_array::{builder::BinaryBuilder, Array};
+use arrow_schema::DataType;
+use datafusion_common::{error::Result, DataFusionError};
+use datafusion_expr::{
+ scalar_doc_sections::DOC_SECTION_OTHER, ColumnarValue, Documentation,
Volatility,
+};
+use sedona_expr::{
+ item_crs::ItemCrsKernel,
+ scalar_udf::{SedonaScalarKernel, SedonaScalarUDF},
+};
+use sedona_geometry::{transform::transform,
wkb_factory::WKB_MIN_PROBABLE_BYTES};
+use sedona_schema::{
+ datatypes::{SedonaType, WKB_GEOMETRY},
+ matchers::ArgMatcher,
+};
+use std::sync::Arc;
+
+use crate::{
+ executor::WkbExecutor,
+ st_affine_helpers::{self},
+};
+
+/// ST_Scale() scalar UDF
+///
+/// Native implementation for scale transformation
+pub fn st_scale_udf() -> SedonaScalarUDF {
+ SedonaScalarUDF::new(
+ "st_scale",
+ ItemCrsKernel::wrap_impl(vec![
+ Arc::new(STScale { is_3d: true }),
+ Arc::new(STScale { is_3d: false }),
+ ]),
+ Volatility::Immutable,
+ Some(st_scale_doc()),
+ )
+}
+
+fn st_scale_doc() -> Documentation {
+ Documentation::builder(
+ DOC_SECTION_OTHER,
+ "Scales the geometry to a new size by multiplying the ordinates with
the corresponding scaling factors",
+ "ST_Scale (geom: Geometry, scaleX: Double, scaleY: Double, scaleZ:
Double)",
+ )
+ .with_argument("geom", "geometry: Input geometry")
+ .with_argument("scaleX", "scaling factor for X")
+ .with_argument("scaleY", "scaling factor for Y")
+ .with_argument("scaleZ", "scaling factor for Z")
+ .with_sql_example(
+ "SELECT ST_Scale(ST_GeomFromText('POLYGON Z ((1 0 1, 1 1 1, 2 2 2, 1 0
1))'), 1, 2, 3)",
+ )
+ .build()
+}
+
+#[derive(Debug)]
+struct STScale {
+ is_3d: bool,
+}
+
+impl SedonaScalarKernel for STScale {
+ fn return_type(&self, args: &[SedonaType]) -> Result<Option<SedonaType>> {
+ let arg_matchers = if self.is_3d {
+ vec![
+ ArgMatcher::is_geometry(),
+ ArgMatcher::is_numeric(),
+ ArgMatcher::is_numeric(),
+ ArgMatcher::is_numeric(),
+ ]
+ } else {
+ vec![
+ ArgMatcher::is_geometry(),
+ ArgMatcher::is_numeric(),
+ ArgMatcher::is_numeric(),
+ ]
+ };
+
+ let matcher = ArgMatcher::new(arg_matchers, WKB_GEOMETRY);
+
+ matcher.match_args(args)
+ }
+
+ fn invoke_batch(
+ &self,
+ arg_types: &[SedonaType],
+ args: &[ColumnarValue],
+ ) -> Result<ColumnarValue> {
+ let executor = WkbExecutor::new(arg_types, args);
+ let mut builder = BinaryBuilder::with_capacity(
+ executor.num_iterations(),
+ WKB_MIN_PROBABLE_BYTES * executor.num_iterations(),
+ );
+
+ let array_args = args[1..]
+ .iter()
+ .map(|arg| {
+ arg.cast_to(&DataType::Float64, None)?
+ .to_array(executor.num_iterations())
+ })
+ .collect::<Result<Vec<Arc<dyn Array>>>>()?;
+
+ let mut affine_iter = if self.is_3d {
+ st_affine_helpers::DAffineIterator::from_scale_3d(&array_args)?
+ } else {
+ st_affine_helpers::DAffineIterator::from_scale_2d(&array_args)?
+ };
+
+ executor.execute_wkb_void(|maybe_wkb| {
+ let maybe_mat = affine_iter.next().unwrap();
+ match (maybe_wkb, maybe_mat) {
+ (Some(wkb), Some(mat)) => {
+ transform(&wkb, &mat, &mut builder)
+ .map_err(|e|
DataFusionError::Execution(e.to_string()))?;
+ builder.append_value([]);
+ }
+ _ => builder.append_null(),
+ }
+
+ Ok(())
+ })?;
+
+ executor.finish(Arc::new(builder.finish()))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use arrow_array::Array;
+ use datafusion_expr::ScalarUDF;
+ use rstest::rstest;
+ use sedona_schema::datatypes::{WKB_GEOMETRY_ITEM_CRS, WKB_VIEW_GEOMETRY};
+ use sedona_testing::{
+ compare::assert_array_equal, create::create_array,
testers::ScalarUdfTester,
+ };
+
+ use super::*;
+
+ #[test]
+ fn udf_metadata() {
+ let st_scale_udf: ScalarUDF = st_scale_udf().into();
+ assert_eq!(st_scale_udf.name(), "st_scale");
+ assert!(st_scale_udf.documentation().is_some());
+ }
+
+ #[rstest]
+ fn udf(#[values(WKB_GEOMETRY, WKB_VIEW_GEOMETRY)] sedona_type: SedonaType)
{
+ let tester_2d = ScalarUdfTester::new(
+ st_scale_udf().into(),
+ vec![
+ sedona_type.clone(),
+ SedonaType::Arrow(DataType::Float64),
+ SedonaType::Arrow(DataType::Float64),
+ ],
+ );
+ tester_2d.assert_return_type(WKB_GEOMETRY);
+
+ let points_2d = create_array(
+ &[
+ None,
+ Some("POINT EMPTY"),
+ Some("POINT M EMPTY"),
+ Some("POINT (1 2)"),
+ Some("POINT M (1 2 3)"),
+ ],
+ &sedona_type,
+ );
+
+ let expected_identity_2d = create_array(
+ &[
+ None,
+ Some("POINT EMPTY"),
+ Some("POINT M EMPTY"),
+ Some("POINT (1 2)"),
+ Some("POINT M (1 2 3)"),
+ ],
+ &WKB_GEOMETRY,
+ );
+
+ let result_identity_2d = tester_2d
+ .invoke_arrays(prepare_args(points_2d.clone(), &[Some(1.0),
Some(1.0)]))
+ .unwrap();
+ assert_array_equal(&result_identity_2d, &expected_identity_2d);
+
+ let expected_scale_2d = create_array(
+ &[
+ None,
+ Some("POINT EMPTY"),
+ Some("POINT M EMPTY"),
+ Some("POINT (10 40)"),
+ Some("POINT M (10 40 3)"),
+ ],
+ &WKB_GEOMETRY,
+ );
+
+ let result_scale_2d = tester_2d
+ .invoke_arrays(prepare_args(points_2d, &[Some(10.0), Some(20.0)]))
+ .unwrap();
+ assert_array_equal(&result_scale_2d, &expected_scale_2d);
+
+ let points_3d = create_array(
+ &[
+ None,
+ Some("POINT Z EMPTY"),
+ Some("POINT ZM EMPTY"),
+ Some("POINT Z (1 2 3)"),
+ Some("POINT ZM (1 2 3 4)"),
+ ],
+ &sedona_type,
+ );
+
+ let expected_scale_2d_on_3d = create_array(
+ &[
+ None,
+ Some("POINT Z EMPTY"),
+ Some("POINT ZM EMPTY"),
+ Some("POINT Z (2 6 3)"),
+ Some("POINT ZM (2 6 3 4)"),
+ ],
+ &WKB_GEOMETRY,
+ );
+
+ let result_scale_2d_on_3d = tester_2d
+ .invoke_arrays(prepare_args(points_3d.clone(), &[Some(2.0),
Some(3.0)]))
+ .unwrap();
+ assert_array_equal(&result_scale_2d_on_3d, &expected_scale_2d_on_3d);
+
+ let tester_3d = ScalarUdfTester::new(
+ st_scale_udf().into(),
+ vec![
+ sedona_type.clone(),
+ SedonaType::Arrow(DataType::Float64),
+ SedonaType::Arrow(DataType::Float64),
+ SedonaType::Arrow(DataType::Float64),
+ ],
+ );
+ tester_3d.assert_return_type(WKB_GEOMETRY);
+
+ let expected_identity_3d = create_array(
+ &[
+ None,
+ Some("POINT Z EMPTY"),
+ Some("POINT ZM EMPTY"),
+ Some("POINT Z (1 2 3)"),
+ Some("POINT ZM (1 2 3 4)"),
+ ],
+ &WKB_GEOMETRY,
+ );
+
+ let result_identity_3d = tester_3d
+ .invoke_arrays(prepare_args(
+ points_3d.clone(),
+ &[Some(1.0), Some(1.0), Some(1.0)],
+ ))
+ .unwrap();
+ assert_array_equal(&result_identity_3d, &expected_identity_3d);
+
+ let expected_scale_3d = create_array(
+ &[
+ None,
+ Some("POINT Z EMPTY"),
+ Some("POINT ZM EMPTY"),
+ Some("POINT Z (2 6 12)"),
+ Some("POINT ZM (2 6 12 4)"),
+ ],
+ &WKB_GEOMETRY,
+ );
+
+ let result_scale_3d = tester_3d
+ .invoke_arrays(prepare_args(points_3d, &[Some(2.0), Some(3.0),
Some(4.0)]))
+ .unwrap();
+ assert_array_equal(&result_scale_3d, &expected_scale_3d);
+
+ let points_2d_for_3d = create_array(
+ &[
+ None,
+ Some("POINT EMPTY"),
+ Some("POINT M EMPTY"),
+ Some("POINT (1 2)"),
+ Some("POINT M (1 2 3)"),
+ ],
+ &sedona_type,
+ );
+
+ let expected_scale_3d_on_2d = create_array(
+ &[
+ None,
+ Some("POINT EMPTY"),
+ Some("POINT M EMPTY"),
+ Some("POINT (2 6)"),
+ Some("POINT M (2 6 3)"),
+ ],
+ &WKB_GEOMETRY,
+ );
+
+ let result_scale_3d_on_2d = tester_3d
+ .invoke_arrays(prepare_args(
+ points_2d_for_3d,
+ &[Some(2.0), Some(3.0), Some(4.0)],
+ ))
+ .unwrap();
+ assert_array_equal(&result_scale_3d_on_2d, &expected_scale_3d_on_2d);
+ }
+
+ fn prepare_args(wkt: Arc<dyn Array>, mat: &[Option<f64>]) -> Vec<Arc<dyn
Array>> {
+ let n = wkt.len();
+ let mut args: Vec<Arc<dyn Array>> = mat
+ .iter()
+ .map(|a| {
+ let values = vec![*a; n];
+ Arc::new(arrow_array::Float64Array::from(values)) as Arc<dyn
Array>
+ })
+ .collect();
+ args.insert(0, wkt);
+ args
+ }
+
+ #[rstest]
+ fn udf_invoke_item_crs(#[values(WKB_GEOMETRY_ITEM_CRS.clone())]
sedona_type: SedonaType) {
+ let tester = ScalarUdfTester::new(
+ st_scale_udf().into(),
+ vec![
+ sedona_type.clone(),
+ SedonaType::Arrow(DataType::Float64),
+ SedonaType::Arrow(DataType::Float64),
+ ],
+ );
+ tester.assert_return_type(sedona_type.clone());
+
+ let result = tester
+ .invoke_scalar_scalar_scalar("POINT (1 2)", 2, 3)
+ .unwrap();
+ tester.assert_scalar_result_equals(result, "POINT (2 6)");
+ }
+}
diff --git a/rust/sedona-geometry/src/transform.rs
b/rust/sedona-geometry/src/transform.rs
index 409bc159..9d115a66 100644
--- a/rust/sedona-geometry/src/transform.rs
+++ b/rust/sedona-geometry/src/transform.rs
@@ -58,6 +58,16 @@ pub trait CrsEngine: Debug {
/// Trait for transforming coordinates in a geometry from one CRS to another.
pub trait CrsTransform: std::fmt::Debug {
fn transform_coord(&self, coord: &mut (f64, f64)) -> Result<(),
SedonaGeometryError>;
+
+ // CrsTransform can optionally handle 3D coordinates. If this method is
not implemented,
+ // the Z coordinate is simply ignored.
+ fn transform_coord_3d(&self, coord: &mut (f64, f64, f64)) -> Result<(),
SedonaGeometryError> {
+ let mut coord_2d = (coord.0, coord.1);
+ self.transform_coord(&mut coord_2d)?;
+ coord.0 = coord_2d.0;
+ coord.1 = coord_2d.1;
+ Ok(())
+ }
}
/// A boxed trait object for dynamic dispatch of CRS transformations.
@@ -65,6 +75,10 @@ impl CrsTransform for Box<dyn CrsTransform> {
fn transform_coord(&self, coord: &mut (f64, f64)) -> Result<(),
SedonaGeometryError> {
self.as_ref().transform_coord(coord)
}
+
+ fn transform_coord_3d(&self, coord: &mut (f64, f64, f64)) -> Result<(),
SedonaGeometryError> {
+ self.as_ref().transform_coord_3d(coord)
+ }
}
/// A caching wrapper around any CRS transformation engine.
@@ -345,24 +359,26 @@ where
I: Iterator<Item = C>,
{
for coord in coords {
- let mut xy: (f64, f64) = (coord.x(), coord.y());
- trans.transform_coord(&mut xy)?;
-
match coord.dim() {
Dimensions::Xy => {
+ let mut xy: (f64, f64) = (coord.x(), coord.y());
+ trans.transform_coord(&mut xy)?;
write_wkb_coord(buf, (xy.0, xy.1))?;
}
Dimensions::Xyz => {
- write_wkb_coord(buf, (xy.0, xy.1, coord.nth_or_panic(2)))?;
+ let mut xyz: (f64, f64, f64) = (coord.x(), coord.y(),
coord.nth_or_panic(2));
+ trans.transform_coord_3d(&mut xyz)?;
+ write_wkb_coord(buf, (xyz.0, xyz.1, xyz.2))?;
}
Dimensions::Xym => {
+ let mut xy: (f64, f64) = (coord.x(), coord.y());
+ trans.transform_coord(&mut xy)?;
write_wkb_coord(buf, (xy.0, xy.1, coord.nth_or_panic(2)))?;
}
Dimensions::Xyzm => {
- write_wkb_coord(
- buf,
- (xy.0, xy.1, coord.nth_or_panic(2), coord.nth_or_panic(3)),
- )?;
+ let mut xyz: (f64, f64, f64) = (coord.x(), coord.y(),
coord.nth_or_panic(2));
+ trans.transform_coord_3d(&mut xyz)?;
+ write_wkb_coord(buf, (xyz.0, xyz.1, xyz.2,
coord.nth_or_panic(3)))?;
}
_ => {
return Err(SedonaGeometryError::Invalid(
@@ -391,6 +407,49 @@ mod test {
}
}
+ #[derive(Debug)]
+ struct Mock3DTransform {}
+ impl CrsTransform for Mock3DTransform {
+ // This transforms 2D and 3D differently for testing purposes
+ fn transform_coord(&self, coord: &mut (f64, f64)) -> Result<(),
SedonaGeometryError> {
+ coord.0 += 100.0;
+ coord.1 += 200.0;
+ Ok(())
+ }
+
+ fn transform_coord_3d(
+ &self,
+ coord: &mut (f64, f64, f64),
+ ) -> Result<(), SedonaGeometryError> {
+ coord.0 += 10.0;
+ coord.1 += 20.0;
+ coord.2 += 30.0;
+ Ok(())
+ }
+ }
+
+ fn test_transform_inner(
+ geom: impl GeometryTrait<T = f64>,
+ expected: &str,
+ mock_transform: impl CrsTransform,
+ ) {
+ let mut wkb_bytes = Vec::new();
+
+ transform(geom, &mock_transform, &mut wkb_bytes).unwrap();
+ let wkb_reader = read_wkb(&wkb_bytes).unwrap();
+ let mut wkt = String::new();
+ wkt::to_wkt::write_geometry(&mut wkt, &wkb_reader).unwrap();
+ assert_eq!(wkt, expected);
+ }
+
+ fn test_transform(geom: impl GeometryTrait<T = f64>, expected: &str) {
+ test_transform_inner(geom, expected, MockTransform {})
+ }
+
+ fn test_transform_3d(geom: impl GeometryTrait<T = f64>, expected: &str) {
+ test_transform_inner(geom, expected, Mock3DTransform {})
+ }
+
#[test]
fn test_transform_point() {
let point = geo_types::Point::new(1.0, 2.0);
@@ -525,15 +584,32 @@ mod test {
test_transform(ls_xyzm, "LINESTRING ZM(11 22 3 4,15 26 7 8)");
}
- fn test_transform(geom: impl GeometryTrait<T = f64>, expected: &str) {
- let mock_transform = MockTransform {};
- let mut wkb_bytes = Vec::new();
+ #[test]
+ fn test_transform_point_3d() {
+ let point = wkt::Wkt::from_str("POINT Z(1 2 3)").unwrap();
+ test_transform_3d(point, "POINT Z(11 22 33)");
- transform(geom, &mock_transform, &mut wkb_bytes).unwrap();
- let wkb_reader = read_wkb(&wkb_bytes).unwrap();
- let mut wkt = String::new();
- wkt::to_wkt::write_geometry(&mut wkt, &wkb_reader).unwrap();
- assert_eq!(wkt, expected);
+ let nan_point = wkt::Wkt::from_str("POINT Z EMPTY").unwrap();
+ test_transform_3d(nan_point, "POINT Z EMPTY");
+ }
+
+ #[test]
+ fn test_transform_dimensions_3d() {
+ let ls_xy_wkt = "LINESTRING(1.0 2.0, 3.0 4.0)";
+ let ls_xy: Wkt = Wkt::from_str(ls_xy_wkt).unwrap();
+ test_transform_3d(ls_xy, "LINESTRING(101 202,103 204)");
+
+ let ls_xyz_wkt = "LINESTRING Z(1.0 2.0 3.0, 4.0 5.0 6.0)";
+ let ls_xyz: Wkt = Wkt::from_str(ls_xyz_wkt).unwrap();
+ test_transform_3d(ls_xyz, "LINESTRING Z(11 22 33,14 25 36)");
+
+ let ls_xym_wkt = "LINESTRING M(1.0 2.0 3.0, 4.0 5.0 6.0)";
+ let ls_xym: Wkt = Wkt::from_str(ls_xym_wkt).unwrap();
+ test_transform_3d(ls_xym, "LINESTRING M(101 202 3,104 205 6)");
+
+ let ls_xyzm_wkt = "LINESTRING ZM(1.0 2.0 3.0 4.0, 5.0 6.0 7.0 8.0)";
+ let ls_xyzm: Wkt = Wkt::from_str(ls_xyzm_wkt).unwrap();
+ test_transform_3d(ls_xyzm, "LINESTRING ZM(11 22 33 4,15 26 37 8)");
}
/// Mock CRS engine for testing caching behavior