diff --git a/Cargo.lock b/Cargo.lock index aa9c05ec7..ca89f4e9e 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 9105526fe..1e69ef669 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 ff566cccf..28f2e9016 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 +@pytest.mark.parametrize("eng", [SedonaDB, PostGIS]) +@pytest.mark.parametrize( + ("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 +@pytest.mark.parametrize("eng", [SedonaDB, PostGIS]) +@pytest.mark.parametrize( + ("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 + + +@pytest.mark.parametrize("eng", [SedonaDB, PostGIS]) +@pytest.mark.parametrize( + ("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, + ) + + +@pytest.mark.parametrize("eng", [SedonaDB, PostGIS]) +@pytest.mark.parametrize( + ("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) + + +@pytest.mark.parametrize("eng", [SedonaDB, PostGIS]) +@pytest.mark.parametrize( + ("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) + + +@pytest.mark.parametrize("eng", [SedonaDB, PostGIS]) +@pytest.mark.parametrize( + ("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) + + +@pytest.mark.parametrize("eng", [SedonaDB, PostGIS]) +@pytest.mark.parametrize( + ("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 4d0cedbe5..41b26e24b 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 bef42fff1..6e38e4262 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 32549b675..80dbbd823 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 000000000..c831b710f --- /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> { + 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 { + 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::>>>()?; + + 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, mat: &[Option]) -> Vec> { + let n = wkt.len(); + let mut args: Vec> = mat + .iter() + .map(|a| { + let values = vec![*a; n]; + Arc::new(arrow_array::Float64Array::from(values)) as Arc + }) + .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 000000000..7de1ba945 --- /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, + b: &'a PrimitiveArray, + d: &'a PrimitiveArray, + e: &'a PrimitiveArray, + x_offset: &'a PrimitiveArray, + y_offset: &'a PrimitiveArray, + no_null: bool, +} + +impl<'a> DAffine2Iterator<'a> { + pub(crate) fn new(array_args: &'a [Arc]) -> Result { + 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; + + fn next(&mut self) -> Option { + 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, + b: &'a PrimitiveArray, + c: &'a PrimitiveArray, + d: &'a PrimitiveArray, + e: &'a PrimitiveArray, + f: &'a PrimitiveArray, + g: &'a PrimitiveArray, + h: &'a PrimitiveArray, + i: &'a PrimitiveArray, + x_offset: &'a PrimitiveArray, + y_offset: &'a PrimitiveArray, + z_offset: &'a PrimitiveArray, + no_null: bool, +} + +impl<'a> DAffine3Iterator<'a> { + pub(crate) fn new(array_args: &'a [Arc]) -> Result { + 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; + + fn next(&mut self) -> Option { + 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, + y_scale: &'a PrimitiveArray, + no_null: bool, +} + +impl<'a> DAffine2ScaleIterator<'a> { + pub(crate) fn new(array_args: &'a [Arc]) -> Result { + 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; + + fn next(&mut self) -> Option { + 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, + y_scale: &'a PrimitiveArray, + z_scale: &'a PrimitiveArray, + no_null: bool, +} + +impl<'a> DAffine3ScaleIterator<'a> { + pub(crate) fn new(array_args: &'a [Arc]) -> Result { + 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; + + fn next(&mut self) -> Option { + 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, + axis: RotateAxis, + no_null: bool, +} + +impl<'a> DAffineRotateIterator<'a> { + pub(crate) fn new(angle: &'a Arc, axis: RotateAxis) -> Result { + 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; + + fn next(&mut self) -> Option { + 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]) -> Result { + Ok(Self::DAffine2(DAffine2Iterator::new(array_args)?)) + } + + pub(crate) fn new_3d(array_args: &'a [Arc]) -> Result { + Ok(Self::DAffine3(DAffine3Iterator::new(array_args)?)) + } + + pub(crate) fn from_scale_2d(array_args: &'a [Arc]) -> Result { + Ok(Self::DAffine2Scale(DAffine2ScaleIterator::new(array_args)?)) + } + + pub(crate) fn from_scale_3d(array_args: &'a [Arc]) -> Result { + Ok(Self::DAffine3Scale(DAffine3ScaleIterator::new(array_args)?)) + } + + pub(crate) fn from_angle(angle: &'a Arc, axis: RotateAxis) -> Result { + 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; + + fn next(&mut self) -> Option { + 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>) -> Arc { + Arc::new(Float64Array::from(values)) as Arc + } + + #[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::>(); + + 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 000000000..c60781fca --- /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> { + 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 { + 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, mat: &[Option]) -> Vec> { + let n = wkt.len(); + let mut args: Vec> = mat + .iter() + .map(|a| { + let values = vec![*a; n]; + Arc::new(arrow_array::Float64Array::from(values)) as Arc + }) + .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 000000000..419e08100 --- /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> { + 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 { + 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::>>>()?; + + 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, mat: &[Option]) -> Vec> { + let n = wkt.len(); + let mut args: Vec> = mat + .iter() + .map(|a| { + let values = vec![*a; n]; + Arc::new(arrow_array::Float64Array::from(values)) as Arc + }) + .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 409bc159a..9d115a666 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 { 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, { 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, + 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, expected: &str) { + test_transform_inner(geom, expected, MockTransform {}) + } + + fn test_transform_3d(geom: impl GeometryTrait, 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, 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