You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@jena.apache.org by an...@apache.org on 2026/09/21 12:17:01 UTC

(jena) branch main updated (4dcfd5b950 -> 509136074c)

This is an automated email from the ASF dual-hosted git repository.

afs pushed a change to branch main
in repository https://gitbox.apache.org/repos/asf/jena.git


    from 4dcfd5b950 Bump org.seleniumhq.selenium:selenium-java from 4.48.0 to 4.49.0
     new b5ce1a9c9a GH-4221: Preserve Z/M and coordinate layouts when reversing axes
     new ccc7fe90d0 GH-4221: Preserve Z/M when converting coordinate sequences
     new 509136074c GH-4221: Add six GeoSPARQL 1.1 coordinate extrema functions

The 3 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.


Summary of changes:
 .../{IsEmptyFF.java => MaxXFF.java}                |  19 +-
 .../{IsEmptyFF.java => MaxYFF.java}                |  19 +-
 .../{IsEmptyFF.java => MaxZFF.java}                |  19 +-
 .../{IsEmptyFF.java => MinXFF.java}                |  19 +-
 .../{IsEmptyFF.java => MinYFF.java}                |  19 +-
 .../{IsEmptyFF.java => MinZFF.java}                |  19 +-
 .../geosparql/implementation/DimensionInfo.java    |   4 +-
 .../implementation/GeometryCoordinateExtrema.java  | 123 +++++++++
 .../geosparql/implementation/GeometryReverse.java  | 165 ++---------
 .../geosparql/implementation/GeometryWrapper.java  |  56 ++++
 .../function_registration/GeometryProperty.java    |  19 +-
 .../jts/CustomCoordinateSequence.java              |  18 +-
 .../jts/CustomCoordinateSequenceFactory.java       |  25 +-
 .../geosparql/implementation/vocabulary/Geof.java  |   8 +
 .../GeometryCoordinateExtremaFFTest.java           | 284 +++++++++++++++++++
 .../GeometryCoordinateExtremaTest.java             | 307 +++++++++++++++++++++
 .../implementation/GeometryReverseTest.java        | 101 +++++++
 .../implementation/GeometryWrapperFactoryTest.java |  41 +++
 .../jts/CustomCoordinateSequenceFactoryTest.java   | 129 +++++++++
 .../jts/CustomCoordinateSequenceTest.java          |  23 ++
 20 files changed, 1187 insertions(+), 230 deletions(-)
 copy jena-geosparql/src/main/java/org/apache/jena/geosparql/geof/topological/filter_functions/geometry_property/{IsEmptyFF.java => MaxXFF.java} (84%)
 copy jena-geosparql/src/main/java/org/apache/jena/geosparql/geof/topological/filter_functions/geometry_property/{IsEmptyFF.java => MaxYFF.java} (84%)
 copy jena-geosparql/src/main/java/org/apache/jena/geosparql/geof/topological/filter_functions/geometry_property/{IsEmptyFF.java => MaxZFF.java} (84%)
 copy jena-geosparql/src/main/java/org/apache/jena/geosparql/geof/topological/filter_functions/geometry_property/{IsEmptyFF.java => MinXFF.java} (84%)
 copy jena-geosparql/src/main/java/org/apache/jena/geosparql/geof/topological/filter_functions/geometry_property/{IsEmptyFF.java => MinYFF.java} (84%)
 copy jena-geosparql/src/main/java/org/apache/jena/geosparql/geof/topological/filter_functions/geometry_property/{IsEmptyFF.java => MinZFF.java} (84%)
 create mode 100644 jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/GeometryCoordinateExtrema.java
 create mode 100644 jena-geosparql/src/test/java/org/apache/jena/geosparql/geof/topological/filter_functions/geometry_property/GeometryCoordinateExtremaFFTest.java
 create mode 100644 jena-geosparql/src/test/java/org/apache/jena/geosparql/implementation/GeometryCoordinateExtremaTest.java
 create mode 100644 jena-geosparql/src/test/java/org/apache/jena/geosparql/implementation/jts/CustomCoordinateSequenceFactoryTest.java


(jena) 03/03: GH-4221: Add six GeoSPARQL 1.1 coordinate extrema functions

Posted by an...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

afs pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/jena.git

commit 509136074c4bd8b4771c08d20da4b2821fccdf37
Author: Edmond Chuc <ed...@kurrawong.ai>
AuthorDate: Mon Sep 14 16:47:13 2026 +1000

    GH-4221: Add six GeoSPARQL 1.1 coordinate extrema functions
---
 .../filter_functions/geometry_property/MaxXFF.java |  41 +++
 .../filter_functions/geometry_property/MaxYFF.java |  41 +++
 .../filter_functions/geometry_property/MaxZFF.java |  41 +++
 .../filter_functions/geometry_property/MinXFF.java |  41 +++
 .../filter_functions/geometry_property/MinYFF.java |  41 +++
 .../filter_functions/geometry_property/MinZFF.java |  41 +++
 .../implementation/GeometryCoordinateExtrema.java  | 123 +++++++++
 .../geosparql/implementation/GeometryWrapper.java  |  56 ++++
 .../function_registration/GeometryProperty.java    |  19 +-
 .../geosparql/implementation/vocabulary/Geof.java  |   8 +
 .../GeometryCoordinateExtremaFFTest.java           | 284 +++++++++++++++++++
 .../GeometryCoordinateExtremaTest.java             | 307 +++++++++++++++++++++
 12 files changed, 1040 insertions(+), 3 deletions(-)

diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/geof/topological/filter_functions/geometry_property/MaxXFF.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/geof/topological/filter_functions/geometry_property/MaxXFF.java
new file mode 100644
index 0000000000..6476d6539f
--- /dev/null
+++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/geof/topological/filter_functions/geometry_property/MaxXFF.java
@@ -0,0 +1,41 @@
+/*
+ * 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
+ *
+ *   https://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.
+ *
+ *   SPDX-License-Identifier: Apache-2.0
+ */
+package org.apache.jena.geosparql.geof.topological.filter_functions.geometry_property;
+
+import org.apache.jena.datatypes.DatatypeFormatException;
+import org.apache.jena.geosparql.implementation.GeometryWrapper;
+import org.apache.jena.sparql.expr.ExprEvalException;
+import org.apache.jena.sparql.expr.NodeValue;
+import org.apache.jena.sparql.function.FunctionBase1;
+
+/** Implements geof:maxX. */
+public class MaxXFF extends FunctionBase1 {
+
+    @Override
+    public NodeValue exec(NodeValue value) {
+        try {
+            GeometryWrapper geometry = GeometryWrapper.extract(value);
+            return NodeValue.makeDouble(geometry.getMaxX());
+        } catch (DatatypeFormatException | IllegalStateException ex) {
+            throw new ExprEvalException(ex.getMessage(), ex);
+        }
+    }
+}
diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/geof/topological/filter_functions/geometry_property/MaxYFF.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/geof/topological/filter_functions/geometry_property/MaxYFF.java
new file mode 100644
index 0000000000..81749c66ff
--- /dev/null
+++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/geof/topological/filter_functions/geometry_property/MaxYFF.java
@@ -0,0 +1,41 @@
+/*
+ * 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
+ *
+ *   https://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.
+ *
+ *   SPDX-License-Identifier: Apache-2.0
+ */
+package org.apache.jena.geosparql.geof.topological.filter_functions.geometry_property;
+
+import org.apache.jena.datatypes.DatatypeFormatException;
+import org.apache.jena.geosparql.implementation.GeometryWrapper;
+import org.apache.jena.sparql.expr.ExprEvalException;
+import org.apache.jena.sparql.expr.NodeValue;
+import org.apache.jena.sparql.function.FunctionBase1;
+
+/** Implements geof:maxY. */
+public class MaxYFF extends FunctionBase1 {
+
+    @Override
+    public NodeValue exec(NodeValue value) {
+        try {
+            GeometryWrapper geometry = GeometryWrapper.extract(value);
+            return NodeValue.makeDouble(geometry.getMaxY());
+        } catch (DatatypeFormatException | IllegalStateException ex) {
+            throw new ExprEvalException(ex.getMessage(), ex);
+        }
+    }
+}
diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/geof/topological/filter_functions/geometry_property/MaxZFF.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/geof/topological/filter_functions/geometry_property/MaxZFF.java
new file mode 100644
index 0000000000..d0fdf20aa1
--- /dev/null
+++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/geof/topological/filter_functions/geometry_property/MaxZFF.java
@@ -0,0 +1,41 @@
+/*
+ * 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
+ *
+ *   https://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.
+ *
+ *   SPDX-License-Identifier: Apache-2.0
+ */
+package org.apache.jena.geosparql.geof.topological.filter_functions.geometry_property;
+
+import org.apache.jena.datatypes.DatatypeFormatException;
+import org.apache.jena.geosparql.implementation.GeometryWrapper;
+import org.apache.jena.sparql.expr.ExprEvalException;
+import org.apache.jena.sparql.expr.NodeValue;
+import org.apache.jena.sparql.function.FunctionBase1;
+
+/** Implements geof:maxZ. */
+public class MaxZFF extends FunctionBase1 {
+
+    @Override
+    public NodeValue exec(NodeValue value) {
+        try {
+            GeometryWrapper geometry = GeometryWrapper.extract(value);
+            return NodeValue.makeDouble(geometry.getMaxZ());
+        } catch (DatatypeFormatException | IllegalStateException ex) {
+            throw new ExprEvalException(ex.getMessage(), ex);
+        }
+    }
+}
diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/geof/topological/filter_functions/geometry_property/MinXFF.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/geof/topological/filter_functions/geometry_property/MinXFF.java
new file mode 100644
index 0000000000..49dacf554f
--- /dev/null
+++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/geof/topological/filter_functions/geometry_property/MinXFF.java
@@ -0,0 +1,41 @@
+/*
+ * 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
+ *
+ *   https://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.
+ *
+ *   SPDX-License-Identifier: Apache-2.0
+ */
+package org.apache.jena.geosparql.geof.topological.filter_functions.geometry_property;
+
+import org.apache.jena.datatypes.DatatypeFormatException;
+import org.apache.jena.geosparql.implementation.GeometryWrapper;
+import org.apache.jena.sparql.expr.ExprEvalException;
+import org.apache.jena.sparql.expr.NodeValue;
+import org.apache.jena.sparql.function.FunctionBase1;
+
+/** Implements geof:minX. */
+public class MinXFF extends FunctionBase1 {
+
+    @Override
+    public NodeValue exec(NodeValue value) {
+        try {
+            GeometryWrapper geometry = GeometryWrapper.extract(value);
+            return NodeValue.makeDouble(geometry.getMinX());
+        } catch (DatatypeFormatException | IllegalStateException ex) {
+            throw new ExprEvalException(ex.getMessage(), ex);
+        }
+    }
+}
diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/geof/topological/filter_functions/geometry_property/MinYFF.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/geof/topological/filter_functions/geometry_property/MinYFF.java
new file mode 100644
index 0000000000..6be3c482d4
--- /dev/null
+++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/geof/topological/filter_functions/geometry_property/MinYFF.java
@@ -0,0 +1,41 @@
+/*
+ * 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
+ *
+ *   https://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.
+ *
+ *   SPDX-License-Identifier: Apache-2.0
+ */
+package org.apache.jena.geosparql.geof.topological.filter_functions.geometry_property;
+
+import org.apache.jena.datatypes.DatatypeFormatException;
+import org.apache.jena.geosparql.implementation.GeometryWrapper;
+import org.apache.jena.sparql.expr.ExprEvalException;
+import org.apache.jena.sparql.expr.NodeValue;
+import org.apache.jena.sparql.function.FunctionBase1;
+
+/** Implements geof:minY. */
+public class MinYFF extends FunctionBase1 {
+
+    @Override
+    public NodeValue exec(NodeValue value) {
+        try {
+            GeometryWrapper geometry = GeometryWrapper.extract(value);
+            return NodeValue.makeDouble(geometry.getMinY());
+        } catch (DatatypeFormatException | IllegalStateException ex) {
+            throw new ExprEvalException(ex.getMessage(), ex);
+        }
+    }
+}
diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/geof/topological/filter_functions/geometry_property/MinZFF.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/geof/topological/filter_functions/geometry_property/MinZFF.java
new file mode 100644
index 0000000000..5dcd13f90a
--- /dev/null
+++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/geof/topological/filter_functions/geometry_property/MinZFF.java
@@ -0,0 +1,41 @@
+/*
+ * 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
+ *
+ *   https://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.
+ *
+ *   SPDX-License-Identifier: Apache-2.0
+ */
+package org.apache.jena.geosparql.geof.topological.filter_functions.geometry_property;
+
+import org.apache.jena.datatypes.DatatypeFormatException;
+import org.apache.jena.geosparql.implementation.GeometryWrapper;
+import org.apache.jena.sparql.expr.ExprEvalException;
+import org.apache.jena.sparql.expr.NodeValue;
+import org.apache.jena.sparql.function.FunctionBase1;
+
+/** Implements geof:minZ. */
+public class MinZFF extends FunctionBase1 {
+
+    @Override
+    public NodeValue exec(NodeValue value) {
+        try {
+            GeometryWrapper geometry = GeometryWrapper.extract(value);
+            return NodeValue.makeDouble(geometry.getMinZ());
+        } catch (DatatypeFormatException | IllegalStateException ex) {
+            throw new ExprEvalException(ex.getMessage(), ex);
+        }
+    }
+}
diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/GeometryCoordinateExtrema.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/GeometryCoordinateExtrema.java
new file mode 100644
index 0000000000..035c6d751d
--- /dev/null
+++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/GeometryCoordinateExtrema.java
@@ -0,0 +1,123 @@
+/*
+ * 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
+ *
+ *   https://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.
+ *
+ *   SPDX-License-Identifier: Apache-2.0
+ */
+package org.apache.jena.geosparql.implementation;
+
+import java.util.function.DoubleBinaryOperator;
+
+import org.locationtech.jts.geom.CoordinateSequence;
+import org.locationtech.jts.geom.CoordinateSequenceFilter;
+import org.locationtech.jts.geom.Geometry;
+
+/**
+ * Finds coordinate extrema in the input geometry's SRS axis order.
+ * X and Y denote the first and second SRS dimensions, respectively.
+ */
+final class GeometryCoordinateExtrema {
+    private GeometryCoordinateExtrema() {
+    }
+
+    static double maxX(GeometryWrapper geometry) {
+        return extreme(geometry, 0, Math::max);
+    }
+
+    static double maxY(GeometryWrapper geometry) {
+        return extreme(geometry, 1, Math::max);
+    }
+
+    static double maxZ(GeometryWrapper geometry) {
+        return zExtreme(geometry, Math::max);
+    }
+
+    static double minX(GeometryWrapper geometry) {
+        return extreme(geometry, 0, Math::min);
+    }
+
+    static double minY(GeometryWrapper geometry) {
+        return extreme(geometry, 1, Math::min);
+    }
+
+    static double minZ(GeometryWrapper geometry) {
+        return zExtreme(geometry, Math::min);
+    }
+
+    private static double extreme(GeometryWrapper geometry, int ordinate,
+            DoubleBinaryOperator accumulator) {
+        return extreme(geometry.getParsingGeometry(), ordinate, accumulator, false);
+    }
+
+    private static double zExtreme(GeometryWrapper geometry, DoubleBinaryOperator accumulator) {
+        return extreme(geometry.getParsingGeometry(), 2, accumulator, true);
+    }
+
+    private static double extreme(Geometry geometry, int ordinate,
+            DoubleBinaryOperator accumulator, boolean skipNaN) {
+        ExtremaFilter filter = new ExtremaFilter(ordinate, accumulator, skipNaN);
+        geometry.apply(filter);
+        return filter.result();
+    }
+
+    private static final class ExtremaFilter implements CoordinateSequenceFilter {
+        private final int ordinate;
+        private final DoubleBinaryOperator accumulator;
+        private final boolean skipNaN;
+        private double result;
+        private boolean found;
+
+        private ExtremaFilter(int ordinate, DoubleBinaryOperator accumulator, boolean skipNaN) {
+            this.ordinate = ordinate;
+            this.accumulator = accumulator;
+            this.skipNaN = skipNaN;
+        }
+
+        @Override
+        public void filter(CoordinateSequence sequence, int index) {
+            if (skipNaN && !sequence.hasZ()) {
+                return;
+            }
+            double value = skipNaN ? sequence.getZ(index) : sequence.getOrdinate(index, ordinate);
+            if (Double.isInfinite(value) || (!skipNaN && Double.isNaN(value))) {
+                throw new IllegalStateException("Geometry has a non-finite ordinate at ordinate index " + ordinate);
+            }
+            if (skipNaN && Double.isNaN(value)) {
+                return;
+            }
+            result = found ? accumulator.applyAsDouble(result, value) : value;
+            found = true;
+        }
+
+        @Override
+        public boolean isDone() {
+            return false;
+        }
+
+        @Override
+        public boolean isGeometryChanged() {
+            return false;
+        }
+
+        private double result() {
+            if (!found) {
+                throw new IllegalStateException("Geometry has no eligible ordinate");
+            }
+            return result;
+        }
+    }
+}
diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/GeometryWrapper.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/GeometryWrapper.java
index daedac38ca..6d72d2773b 100644
--- a/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/GeometryWrapper.java
+++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/GeometryWrapper.java
@@ -387,6 +387,62 @@ public class GeometryWrapper implements Serializable {
         return parsingGeometry.getGeometryType();
     }
 
+    /**
+     * Returns the minimum ordinate of the first SRS dimension (X)
+     * across all geometry members.
+     * @throws IllegalStateException if the geometry has no coordinates or the evaluated ordinate is NaN or infinite.
+     */
+    public double getMinX() {
+        return GeometryCoordinateExtrema.minX(this);
+    }
+
+    /**
+     * Returns the minimum ordinate of the second SRS dimension (Y)
+     * across all geometry members.
+     * @throws IllegalStateException if the geometry has no coordinates or the evaluated ordinate is NaN or infinite.
+     */
+    public double getMinY() {
+        return GeometryCoordinateExtrema.minY(this);
+    }
+
+    /**
+     * Returns the minimum finite Z ordinate from the original coordinates across all geometry members.
+     * Members without a Z ordinate are skipped; M is not treated as Z.
+     * NaN values are skipped as missing Z ordinates; infinite Z values are rejected.
+     * @throws IllegalStateException if no finite Z exists or a Z ordinate is infinite.
+     */
+    public double getMinZ() {
+        return GeometryCoordinateExtrema.minZ(this);
+    }
+
+    /**
+     * Returns the maximum ordinate of the first SRS dimension (X)
+     * across all geometry members.
+     * @throws IllegalStateException if the geometry has no coordinates or the evaluated ordinate is NaN or infinite.
+     */
+    public double getMaxX() {
+        return GeometryCoordinateExtrema.maxX(this);
+    }
+
+    /**
+     * Returns the maximum ordinate of the second SRS dimension (Y)
+     * across all geometry members.
+     * @throws IllegalStateException if the geometry has no coordinates or the evaluated ordinate is NaN or infinite.
+     */
+    public double getMaxY() {
+        return GeometryCoordinateExtrema.maxY(this);
+    }
+
+    /**
+     * Returns the maximum finite Z ordinate from the original coordinates across all geometry members.
+     * Members without a Z ordinate are skipped; M is not treated as Z.
+     * NaN values are skipped as missing Z ordinates; infinite Z values are rejected.
+     * @throws IllegalStateException if no finite Z exists or a Z ordinate is infinite.
+     */
+    public double getMaxZ() {
+        return GeometryCoordinateExtrema.maxZ(this);
+    }
+
     /**
      *
      * @return GeometryDatatype of the literal.
diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/function_registration/GeometryProperty.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/function_registration/GeometryProperty.java
index c7c3fbe089..d22e772dae 100644
--- a/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/function_registration/GeometryProperty.java
+++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/function_registration/GeometryProperty.java
@@ -31,6 +31,12 @@ import org.apache.jena.geosparql.geof.topological.filter_functions.geometry_prop
 import org.apache.jena.geosparql.geof.topological.filter_functions.geometry_property.IsEmptyFF;
 import org.apache.jena.geosparql.geof.topological.filter_functions.geometry_property.IsSimpleFF;
 import org.apache.jena.geosparql.geof.topological.filter_functions.geometry_property.IsValidFF;
+import org.apache.jena.geosparql.geof.topological.filter_functions.geometry_property.MaxXFF;
+import org.apache.jena.geosparql.geof.topological.filter_functions.geometry_property.MaxYFF;
+import org.apache.jena.geosparql.geof.topological.filter_functions.geometry_property.MaxZFF;
+import org.apache.jena.geosparql.geof.topological.filter_functions.geometry_property.MinXFF;
+import org.apache.jena.geosparql.geof.topological.filter_functions.geometry_property.MinYFF;
+import org.apache.jena.geosparql.geof.topological.filter_functions.geometry_property.MinZFF;
 import org.apache.jena.geosparql.geof.topological.filter_functions.geometry_property.SpatialDimensionFF;
 import org.apache.jena.geosparql.implementation.vocabulary.Geo;
 import org.apache.jena.geosparql.implementation.vocabulary.Geof;
@@ -59,9 +65,9 @@ public class GeometryProperty {
     }
 
     /**
-     * This method loads all the Geometry property filter functions.<br>
-     * N.B. These functions are not part of the GeoSPARQL standard but have been
-     * included for convenience using GeometryLiterals.
+     * Registers {@code geof:} geometry metadata expression functions for use in
+     * {@code FILTER}, {@code BIND}, and projection expressions.
+     * Includes GeoSPARQL 1.1 geometry metadata functions and the isValid extension.
      *
      * @param registry - the FunctionRegistry to be used
      */
@@ -73,6 +79,13 @@ public class GeometryProperty {
         registry.put(Geof.IS_SIMPLE, IsSimpleFF.class);
         registry.put(Geof.IS_EMPTY, IsEmptyFF.class);
         registry.put(Geof.IS_VALID, IsValidFF.class);
+
+        registry.put(Geof.MIN_X, MinXFF.class);
+        registry.put(Geof.MIN_Y, MinYFF.class);
+        registry.put(Geof.MIN_Z, MinZFF.class);
+        registry.put(Geof.MAX_X, MaxXFF.class);
+        registry.put(Geof.MAX_Y, MaxYFF.class);
+        registry.put(Geof.MAX_Z, MaxZFF.class);
     }
 
 }
diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/vocabulary/Geof.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/vocabulary/Geof.java
index ab13061da7..83d19e5dd8 100644
--- a/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/vocabulary/Geof.java
+++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/vocabulary/Geof.java
@@ -83,6 +83,14 @@ public interface Geof {
     public static final String IS_SIMPLE = GEOF_URI + "isSimple";
     public static final String IS_VALID = GEOF_URI + "isValid";
 
+    //GeoSPARQL 1.1 coordinate extrema:
+    public static final String MIN_X = GEOF_URI + "minX";
+    public static final String MIN_Y = GEOF_URI + "minY";
+    public static final String MIN_Z = GEOF_URI + "minZ";
+    public static final String MAX_X = GEOF_URI + "maxX";
+    public static final String MAX_Y = GEOF_URI + "maxY";
+    public static final String MAX_Z = GEOF_URI + "maxZ";
+
     //upcoming GeoSPARQL 1.1 Datatype transformation functions:
     public static final String AS_GEOJSON = GEOF_URI + "asGeoJSON";
 }
diff --git a/jena-geosparql/src/test/java/org/apache/jena/geosparql/geof/topological/filter_functions/geometry_property/GeometryCoordinateExtremaFFTest.java b/jena-geosparql/src/test/java/org/apache/jena/geosparql/geof/topological/filter_functions/geometry_property/GeometryCoordinateExtremaFFTest.java
new file mode 100644
index 0000000000..418893b171
--- /dev/null
+++ b/jena-geosparql/src/test/java/org/apache/jena/geosparql/geof/topological/filter_functions/geometry_property/GeometryCoordinateExtremaFFTest.java
@@ -0,0 +1,284 @@
+/*
+ * 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
+ *
+ *   https://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.
+ *
+ *   SPDX-License-Identifier: Apache-2.0
+ */
+package org.apache.jena.geosparql.geof.topological.filter_functions.geometry_property;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assume.assumeFalse;
+
+import java.util.List;
+
+import org.apache.jena.geosparql.configuration.GeoSPARQLConfig;
+import org.apache.jena.geosparql.implementation.datatype.WKTDatatype;
+import org.apache.jena.graph.Node;
+import org.apache.jena.graph.NodeFactory;
+import org.apache.jena.query.QueryBuildException;
+import org.apache.jena.query.QueryExecution;
+import org.apache.jena.query.QuerySolution;
+import org.apache.jena.query.ResultSet;
+import org.apache.jena.rdf.model.ModelFactory;
+import org.apache.jena.sparql.expr.ExprEvalException;
+import org.apache.jena.sparql.expr.NodeValue;
+import org.apache.jena.sparql.function.FunctionBase1;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+
+@RunWith(Parameterized.class)
+public class GeometryCoordinateExtremaFFTest {
+    @Parameterized.Parameters(name = "function: {0}")
+    public static List<Object[]> functions() {
+        return List.of(
+            new Object[] { "minX", new MinXFF() },
+            new Object[] { "minY", new MinYFF() },
+            new Object[] { "minZ", new MinZFF() },
+            new Object[] { "maxX", new MaxXFF() },
+            new Object[] { "maxY", new MaxYFF() },
+            new Object[] { "maxZ", new MaxZFF() }
+        );
+    }
+
+    private final String name;
+    private final FunctionBase1 function;
+
+    public GeometryCoordinateExtremaFFTest(String name, FunctionBase1 function) {
+        this.name = name;
+        this.function = function;
+    }
+
+    @BeforeClass
+    public static void setup() {
+        GeoSPARQLConfig.setupNoIndex();
+    }
+
+    @Test
+    public void iriArgumentRaisesExpressionError() {
+        assertThrows(ExprEvalException.class, () -> function.exec(NodeValue.makeNode(NodeFactory.createURI("urn:not-a-literal"))));
+    }
+
+    @Test
+    public void numericArgumentRaisesExpressionError() {
+        assertThrows(ExprEvalException.class, () -> function.exec(NodeValue.makeInteger(42)));
+    }
+
+    @Test
+    public void stringWithoutGeometryDatatypeRaisesExpressionError() {
+        assertThrows(ExprEvalException.class, () -> function.exec(NodeValue.makeString("POINT (1 2)")));
+    }
+
+    @Test
+    public void malformedWktRaisesExpressionError() {
+        NodeValue malformed = NodeValue.makeNode("invalid", WKTDatatype.INSTANCE);
+        assertThrows(ExprEvalException.class, () -> function.exec(malformed));
+    }
+
+    @Test
+    public void iriArgumentLeavesBindUnbound() {
+        assertUnbound("<urn:not-a-literal>");
+    }
+
+    @Test
+    public void numericArgumentLeavesBindUnbound() {
+        assertUnbound("42");
+    }
+
+    @Test
+    public void stringWithoutGeometryDatatypeLeavesBindUnbound() {
+        assertUnbound("'POINT (1 2)'");
+    }
+
+    @Test
+    public void malformedWktLeavesBindUnbound() {
+        assertUnbound("'invalid'^^geo:wktLiteral");
+    }
+
+    @Test
+    public void unboundArgumentLeavesBindUnbound() {
+        assertUnbound("?missing");
+    }
+
+    @Test
+    public void missingArgumentIsRejectedAtQueryBuild() {
+        assertThrows(QueryBuildException.class, () -> evaluate("geof:" + name + "()"));
+    }
+
+    @Test
+    public void extraArgumentIsRejectedAtQueryBuild() {
+        assertThrows(QueryBuildException.class,
+                     () -> evaluate("geof:" + name + "('POINT EMPTY'^^geo:wktLiteral, 1)"));
+    }
+
+    @Test
+    public void extremaReturnDoubleLiterals() {
+        double expected = switch (name) {
+            case "minX" -> -9;
+            case "maxX" -> -2;
+            case "minY" -> 3;
+            case "maxY" -> 8;
+            case "minZ" -> -7;
+            case "maxZ" -> -1;
+            default -> throw new AssertionError(name);
+        };
+        assertEquals(NodeValue.makeDouble(expected).asNode(),
+                     evaluate("geof:" + name + "('LINESTRING ZM (-9 8 -7 100, -2 3 -1 -100)'^^geo:wktLiteral)"));
+    }
+
+    @Test
+    public void epsg4326LineStringExtremaFollowSrsDimensionOrder() {
+        String argument = "'<http://www.opengis.net/def/crs/EPSG/0/4326> "
+                        + "LINESTRING (10 100, 20 120)'^^geo:wktLiteral";
+        if (name.endsWith("Z")) {
+            assertUnbound(argument);
+        } else {
+            double expected = switch (name) {
+                case "minX" -> 10;
+                case "maxX" -> 20;
+                case "minY" -> 100;
+                case "maxY" -> 120;
+                default -> throw new AssertionError(name);
+            };
+            assertEquals(NodeValue.makeDouble(expected).asNode(),
+                         evaluate("geof:" + name + "(" + argument + ")"));
+        }
+    }
+
+    @Test
+    public void infiniteOrdinateRaisesExpressionErrorAndLeavesBindUnbound() {
+        for (String infinity : new String[] { "Infinity", "-Infinity" }) {
+            String coordinate = switch (name.charAt(name.length() - 1)) {
+                case 'X' -> infinity + " 1 1";
+                case 'Y' -> "1 " + infinity + " 1";
+                case 'Z' -> "1 1 " + infinity;
+                default -> throw new AssertionError(name);
+            };
+            String wkt = "LINESTRING Z (-4 -4 -4, " + coordinate + ", 9 9 9)";
+            assertThrows(wkt, ExprEvalException.class,
+                         () -> function.exec(NodeValue.makeNode(wkt, WKTDatatype.INSTANCE)));
+            assertUnbound("'" + wkt + "'^^geo:wktLiteral");
+        }
+    }
+
+    @Test
+    public void nanXYOrdinateRaisesExpressionErrorAndLeavesBindUnbound() {
+        assumeFalse("Only X/Y extrema are covered by this test", name.endsWith("Z"));
+        String coordinate = name.endsWith("Y") ? "2 NaN" : "NaN 2";
+        String wkt = "LINESTRING (1 1, " + coordinate + ", 3 3)";
+        assertThrows(wkt, ExprEvalException.class,
+                     () -> function.exec(NodeValue.makeNode(wkt, WKTDatatype.INSTANCE)));
+        assertUnbound("'" + wkt + "'^^geo:wktLiteral");
+    }
+
+    @Test
+    public void emptyGeometryRaisesExpressionErrorAndLeavesBindUnbound() {
+        assertThrows(ExprEvalException.class,
+                     () -> function.exec(NodeValue.makeNode("POINT Z EMPTY", WKTDatatype.INSTANCE)));
+        assertUnbound("'POINT Z EMPTY'^^geo:wktLiteral");
+    }
+
+    @Test
+    public void layoutsWithoutZStillHaveXYExtrema() {
+        for (String wkt : new String[] { "POINT (1 2)", "POINT M (1 2 99)" }) {
+            if (name.endsWith("Z")) {
+                assertThrows(wkt, ExprEvalException.class,
+                             () -> function.exec(NodeValue.makeNode(wkt, WKTDatatype.INSTANCE)));
+                assertUnbound("'" + wkt + "'^^geo:wktLiteral");
+            } else {
+                double expected = name.endsWith("X") ? 1 : 2;
+                assertEquals(wkt, NodeValue.makeDouble(expected).asNode(),
+                             evaluate("geof:" + name + "('" + wkt + "'^^geo:wktLiteral)"));
+            }
+        }
+    }
+
+    @Test
+    public void multiPointExtremaDistinguishZFromMeasures() {
+        for (boolean hasZ : new boolean[] { false, true }) {
+            String wkt = hasZ ? "MULTIPOINT ZM ((1 2 -4 10), (3 4 9 20))"
+                              : "MULTIPOINT M ((1 2 10), (3 4 20))";
+            if (name.endsWith("Z") && !hasZ) {
+                assertThrows(ExprEvalException.class,
+                             () -> function.exec(NodeValue.makeNode(wkt, WKTDatatype.INSTANCE)));
+                assertUnbound("'" + wkt + "'^^geo:wktLiteral");
+            } else {
+                double value = switch (name) {
+                    case "minX" -> 1;
+                    case "maxX" -> 3;
+                    case "minY" -> 2;
+                    case "maxY" -> 4;
+                    case "minZ" -> -4;
+                    case "maxZ" -> 9;
+                    default -> throw new AssertionError(name);
+                };
+                NodeValue expected = NodeValue.makeDouble(value);
+                assertEquals(expected, function.exec(NodeValue.makeNode(wkt, WKTDatatype.INSTANCE)));
+                assertEquals(expected.asNode(), evaluate("geof:" + name + "('" + wkt + "'^^geo:wktLiteral)"));
+            }
+        }
+    }
+
+    @Test
+    public void mixedCollectionExtremaDoNotDependOnMemberOrder() {
+        for (String wkt : new String[] {
+                "GEOMETRYCOLLECTION (POINT Z (1 2 3))",
+                "GEOMETRYCOLLECTION (POINT (1 2), POINT M (1 2 999), POINT Z (1 2 3))",
+                "GEOMETRYCOLLECTION (POINT Z (1 2 3), POINT M (1 2 999), POINT (1 2))" }) {
+            NodeValue expected = NodeValue.makeDouble(name.endsWith("X") ? 1 : name.endsWith("Y") ? 2 : 3);
+            assertEquals(expected, function.exec(NodeValue.makeNode(wkt, WKTDatatype.INSTANCE)));
+            assertEquals(expected.asNode(), evaluate("geof:" + name + "('" + wkt + "'^^geo:wktLiteral)"));
+        }
+    }
+
+    @Test
+    public void gmlExtremaFollowSrsDimensionOrder() {
+        String point = """
+            '<gml:Point xmlns:gml="http://www.opengis.net/gml/3.2"
+                        srsName="http://www.opengis.net/def/crs/EPSG/0/4979">
+               <gml:pos srsDimension="3">10 100 -4</gml:pos>
+             </gml:Point>'^^geo:gmlLiteral
+            """.replace("\n", " ");
+        double expected = name.endsWith("X") ? 10 : name.endsWith("Y") ? 100 : -4;
+        assertEquals(NodeValue.makeDouble(expected).asNode(), evaluate("geof:" + name + "(" + point + ")"));
+    }
+
+    private static Node evaluate(String expression) {
+        String query = """
+            PREFIX geof: <http://www.opengis.net/def/function/geosparql/>
+            PREFIX geo: <http://www.opengis.net/ont/geosparql#>
+            SELECT ?result WHERE { BIND(%s AS ?result) }
+            """.formatted(expression);
+        try (QueryExecution execution = QueryExecution.create(query, ModelFactory.createDefaultModel())) {
+            ResultSet results = execution.execSelect();
+            assertTrue("Expected one solution for " + expression, results.hasNext());
+            QuerySolution solution = results.next();
+            assertFalse("Expected only one solution for " + expression, results.hasNext());
+            return solution.contains("result") ? solution.get("result").asNode() : null;
+        }
+    }
+
+    private void assertUnbound(String argument) {
+        String expression = "geof:" + name + "(" + argument + ")";
+        assertNull(expression, evaluate(expression));
+    }
+}
diff --git a/jena-geosparql/src/test/java/org/apache/jena/geosparql/implementation/GeometryCoordinateExtremaTest.java b/jena-geosparql/src/test/java/org/apache/jena/geosparql/implementation/GeometryCoordinateExtremaTest.java
new file mode 100644
index 0000000000..865eba6ca2
--- /dev/null
+++ b/jena-geosparql/src/test/java/org/apache/jena/geosparql/implementation/GeometryCoordinateExtremaTest.java
@@ -0,0 +1,307 @@
+/*
+ * 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
+ *
+ *   https://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.
+ *
+ *   SPDX-License-Identifier: Apache-2.0
+ */
+package org.apache.jena.geosparql.implementation;
+
+import org.apache.jena.geosparql.implementation.datatype.WKTDatatype;
+import org.apache.jena.geosparql.implementation.jts.CoordinateSequenceDimensions;
+import org.apache.jena.geosparql.implementation.jts.CustomCoordinateSequence;
+import org.apache.jena.geosparql.implementation.jts.CustomGeometryFactory;
+import org.apache.jena.geosparql.implementation.vocabulary.SRS_URI;
+import org.junit.Test;
+import org.locationtech.jts.geom.Coordinate;
+import org.locationtech.jts.geom.CoordinateSequence;
+import org.locationtech.jts.geom.CoordinateXYM;
+import org.locationtech.jts.geom.Geometry;
+import org.locationtech.jts.geom.GeometryFactory;
+import org.locationtech.jts.geom.impl.CoordinateArraySequence;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertThrows;
+
+public class GeometryCoordinateExtremaTest {
+    @Test
+    public void measuredMultiPointPreservesMWithoutCreatingZ() {
+        GeometryWrapper geometry = GeometryWrapper.extract("MULTIPOINT M ((1 2 10), (3 4 20))", WKTDatatype.URI);
+        for (int i = 0; i < 2; i++) {
+            Coordinate coordinate = geometry.getParsingGeometry().getGeometryN(i).getCoordinate();
+            assertEquals(10 * (i + 1), coordinate.getM(), 0);
+            assertEquals(Double.NaN, coordinate.getZ(), 0);
+        }
+        assertThrows(IllegalStateException.class, geometry::getMinZ);
+        assertThrows(IllegalStateException.class, geometry::getMaxZ);
+    }
+
+    @Test
+    public void zExtremaIgnoreMeasuresInMixedJtsCollections() {
+        GeometryFactory factory = new GeometryFactory();
+        for (double measure : new double[] { -1000, 1000, Double.NEGATIVE_INFINITY, Double.POSITIVE_INFINITY }) {
+            Geometry xyz = factory.createPoint(new Coordinate(1, 2, 99));
+            Geometry xym = factory.createPoint(new CoordinateXYM(3, 4, measure));
+            for (Geometry[] members : new Geometry[][] { { xyz, xym }, { xym, xyz } }) {
+                Geometry mixed = factory.createGeometryCollection(members);
+                GeometryWrapper geometry = GeometryWrapperFactory.createGeometry(mixed,
+                        SRS_URI.DEFAULT_WKT_CRS84, WKTDatatype.URI);
+                assertEquals(99, geometry.getMinZ(), 0);
+                assertEquals(99, geometry.getMaxZ(), 0);
+            }
+        }
+    }
+
+    @Test
+    public void factoryHandlesEmptyCollectionsWithoutCoordinates() {
+        GeometryFactory factory = new GeometryFactory();
+        Geometry[] members = {
+            factory.createPoint(new CoordinateArraySequence(0, 3, 0)),
+            factory.createPoint(new CoordinateArraySequence(0, 3, 1))
+        };
+        for (Geometry empty : new Geometry[] {
+                factory.createGeometryCollection(),
+                factory.createGeometryCollection(new Geometry[] { factory.createGeometryCollection() }),
+                factory.createGeometryCollection(members) }) {
+            GeometryWrapper geometry = GeometryWrapperFactory.createGeometry(empty,
+                    SRS_URI.DEFAULT_WKT_CRS84, WKTDatatype.URI);
+            assertThrows(IllegalStateException.class, geometry::getMinX);
+            assertThrows(IllegalStateException.class, geometry::getMaxX);
+            assertThrows(IllegalStateException.class, geometry::getMinY);
+            assertThrows(IllegalStateException.class, geometry::getMaxY);
+            assertThrows(IllegalStateException.class, geometry::getMinZ);
+            assertThrows(IllegalStateException.class, geometry::getMaxZ);
+        }
+    }
+
+    @Test
+    public void factoryZExtremaFindLaterValuesWhenFirstZIsMissing() {
+        GeometryFactory factory = new GeometryFactory();
+        CoordinateSequence coordinates = new CoordinateArraySequence(2, 3, 0);
+        for (int i = 0; i < coordinates.size(); i++) {
+            coordinates.setOrdinate(i, 0, i);
+            coordinates.setOrdinate(i, 1, i);
+            coordinates.setOrdinate(i, 2, i == 0 ? Double.NaN : 9);
+        }
+        Geometry line = factory.createLineString(coordinates);
+        Geometry homogeneousCollection = factory.createGeometryCollection(new Geometry[] {
+                factory.createPoint(new Coordinate(0, 0, Double.NaN)), line });
+        for (Geometry source : new Geometry[] { line, homogeneousCollection }) {
+            for (String srs : new String[] { SRS_URI.DEFAULT_WKT_CRS84,
+                    "http://www.opengis.net/def/crs/EPSG/0/4979" }) {
+                GeometryWrapper geometry = GeometryWrapperFactory.createGeometry(source, srs, WKTDatatype.URI);
+                assertEquals(9, geometry.getMinZ(), 0);
+                assertEquals(9, geometry.getMaxZ(), 0);
+            }
+        }
+    }
+
+    @Test
+    public void factoryPointPreservesZInAuthorityAxisOrder() {
+        GeometryWrapper geometry = GeometryWrapperFactory.createPoint(new Coordinate(100, 10, -4),
+            "http://www.opengis.net/def/crs/EPSG/0/4979", WKTDatatype.URI);
+        assertEquals(10, geometry.getMinX(), 0);
+        assertEquals(10, geometry.getMaxX(), 0);
+        assertEquals(100, geometry.getMinY(), 0);
+        assertEquals(100, geometry.getMaxY(), 0);
+        assertEquals(-4, geometry.getMinZ(), 0);
+        assertEquals(-4, geometry.getMaxZ(), 0);
+    }
+
+    @Test
+    public void factoryGeometryPreservesZAndMeasureInAuthorityAxisOrder() {
+        CustomCoordinateSequence coordinates = new CustomCoordinateSequence(
+            CoordinateSequenceDimensions.XYZM, "100 10 -4 7");
+        Geometry point = CustomGeometryFactory.theInstance().createPoint(coordinates);
+        GeometryWrapper geometry = GeometryWrapperFactory.createGeometry(point,
+            "http://www.opengis.net/def/crs/EPSG/0/4979", WKTDatatype.URI);
+        assertEquals(10, geometry.getMinX(), 0);
+        assertEquals(10, geometry.getMaxX(), 0);
+        assertEquals(100, geometry.getMinY(), 0);
+        assertEquals(100, geometry.getMaxY(), 0);
+        assertEquals(-4, geometry.getMinZ(), 0);
+        assertEquals(-4, geometry.getMaxZ(), 0);
+        assertEquals(7, geometry.getParsingGeometry().getCoordinate().getM(), 0);
+    }
+
+    @Test
+    public void xyExtremaFollowSrsDimensionOrder() {
+        GeometryWrapper crs84Point = GeometryWrapper.extract(
+                "<http://www.opengis.net/def/crs/OGC/1.3/CRS84> POINT(10 100)",
+                WKTDatatype.URI);
+        GeometryWrapper epsg4326Point = GeometryWrapper.extract(
+                "<http://www.opengis.net/def/crs/EPSG/0/4326> POINT(10 100)",
+                WKTDatatype.URI);
+
+        assertEquals(10.0, crs84Point.getMinX(), 0.0);
+        assertEquals(10.0, crs84Point.getMaxX(), 0.0);
+        assertEquals(100.0, crs84Point.getMinY(), 0.0);
+        assertEquals(100.0, crs84Point.getMaxY(), 0.0);
+        assertEquals(10.0, epsg4326Point.getMinX(), 0.0);
+        assertEquals(10.0, epsg4326Point.getMaxX(), 0.0);
+        assertEquals(100.0, epsg4326Point.getMinY(), 0.0);
+        assertEquals(100.0, epsg4326Point.getMaxY(), 0.0);
+    }
+
+    @Test
+    public void xyExtremaRejectNaNAlongsideFiniteValues() {
+        CustomCoordinateSequence coordinatesX = new CustomCoordinateSequence(
+                CoordinateSequenceDimensions.XY, "1 1,NaN 2,3 3");
+        GeometryWrapper nanX = wrapper(
+                CustomGeometryFactory.theInstance().createLineString(coordinatesX),
+                CoordinateSequenceDimensions.XY);
+        assertThrows(IllegalStateException.class, nanX::getMinX);
+        assertThrows(IllegalStateException.class, nanX::getMaxX);
+        assertEquals(1, nanX.getMinY(), 0);
+        assertEquals(3, nanX.getMaxY(), 0);
+
+        CustomCoordinateSequence coordinatesY = new CustomCoordinateSequence(
+                CoordinateSequenceDimensions.XY, "1 1,2 NaN,3 3");
+        GeometryWrapper nanY = wrapper(
+                CustomGeometryFactory.theInstance().createLineString(coordinatesY),
+                CoordinateSequenceDimensions.XY);
+        assertThrows(IllegalStateException.class, nanY::getMinY);
+        assertThrows(IllegalStateException.class, nanY::getMaxY);
+        assertEquals(1, nanY.getMinX(), 0);
+        assertEquals(3, nanY.getMaxX(), 0);
+    }
+
+    @Test
+    public void zExtremaIgnoreMissingZSentinels() {
+        CustomCoordinateSequence coordinates = new CustomCoordinateSequence(
+                CoordinateSequenceDimensions.XYZ,
+                "0 0 NaN,1 1 -4,2 2 NaN,3 3 9");
+        GeometryWrapper geometry = wrapper(
+                CustomGeometryFactory.theInstance().createLineString(coordinates),
+                CoordinateSequenceDimensions.XYZ);
+
+        assertEquals(-4.0, geometry.getMinZ(), 0.0);
+        assertEquals(9.0, geometry.getMaxZ(), 0.0);
+    }
+
+    @Test
+    public void zExtremaFailWhenAllZValuesAreMissing() {
+        CustomCoordinateSequence coordinates = new CustomCoordinateSequence(
+                CoordinateSequenceDimensions.XYZ,
+                "0 0 NaN,1 1 NaN");
+        GeometryWrapper geometry = wrapper(
+                CustomGeometryFactory.theInstance().createLineString(coordinates),
+                CoordinateSequenceDimensions.XYZ);
+
+        assertThrows(IllegalStateException.class,
+                () -> geometry.getMinZ());
+        assertThrows(IllegalStateException.class,
+                () -> geometry.getMaxZ());
+    }
+
+    @Test
+    public void zExtremaRejectInfinityAlongsideFiniteValues() {
+        for (String infinity : new String[] { "Infinity", "-Infinity" }) {
+            CustomCoordinateSequence coordinates = new CustomCoordinateSequence(
+                CoordinateSequenceDimensions.XYZ, "0 0 -4,1 1 " + infinity + ",2 2 9");
+            GeometryWrapper geometry = wrapper(
+                CustomGeometryFactory.theInstance().createLineString(coordinates),
+                CoordinateSequenceDimensions.XYZ);
+            assertThrows(infinity, IllegalStateException.class, geometry::getMinZ);
+            assertThrows(infinity, IllegalStateException.class, geometry::getMaxZ);
+        }
+    }
+
+    @Test
+    public void zExtremaRequireZBearingSequences() {
+        CoordinateSequence coordinates = new CoordinateArraySequence(
+                new Coordinate[]{new Coordinate(1, 2, 99)}, 3, 1);
+        GeometryWrapper geometry = wrapper(
+                CustomGeometryFactory.theInstance().createPoint(coordinates),
+                CoordinateSequenceDimensions.XYM);
+
+        assertThrows(IllegalStateException.class,
+                () -> geometry.getMinZ());
+        assertThrows(IllegalStateException.class,
+                () -> geometry.getMaxZ());
+    }
+
+    @Test
+    public void allExtremaScanEveryCoordinateIncludingNegativeValues() {
+        GeometryWrapper geometry = GeometryWrapper.extract("LINESTRING ZM (-9 8 -7 100, -2 3 -1 -100)", WKTDatatype.URI);
+        assertEquals(-9, geometry.getMinX(), 0);
+        assertEquals(-2, geometry.getMaxX(), 0);
+        assertEquals(3, geometry.getMinY(), 0);
+        assertEquals(8, geometry.getMaxY(), 0);
+        assertEquals(-7, geometry.getMinZ(), 0);
+        assertEquals(-1, geometry.getMaxZ(), 0);
+    }
+
+    @Test
+    public void threeDimensionalExtremaFollowSrsDimensionOrder() {
+        GeometryWrapper geometry = GeometryWrapper.extract(
+            "<http://www.opengis.net/def/crs/EPSG/0/4979> LINESTRING Z (10 100 -4, 20 120 9)", WKTDatatype.URI);
+        assertEquals(10, geometry.getMinX(), 0);
+        assertEquals(20, geometry.getMaxX(), 0);
+        assertEquals(100, geometry.getMinY(), 0);
+        assertEquals(120, geometry.getMaxY(), 0);
+        assertEquals(-4, geometry.getMinZ(), 0);
+        assertEquals(9, geometry.getMaxZ(), 0);
+    }
+
+    @Test
+    public void nestedCollectionsIncludeAllDescendantsAndSkipEmptyMembers() {
+        var factory = CustomGeometryFactory.theInstance();
+        Geometry first = GeometryWrapper.extract("POINT Z (-9 8 -7)", WKTDatatype.URI).getParsingGeometry();
+        Geometry second = GeometryWrapper.extract("POINT Z (-2 3 -1)", WKTDatatype.URI).getParsingGeometry();
+        Geometry nested = factory.createGeometryCollection(new Geometry[] { second, factory.createPoint() });
+        GeometryWrapper geometry = wrapper(factory.createGeometryCollection(new Geometry[] { first, nested }),
+                                           CoordinateSequenceDimensions.XYZ);
+        assertEquals(-9, geometry.getMinX(), 0);
+        assertEquals(-2, geometry.getMaxX(), 0);
+        assertEquals(3, geometry.getMinY(), 0);
+        assertEquals(8, geometry.getMaxY(), 0);
+        assertEquals(-7, geometry.getMinZ(), 0);
+        assertEquals(-1, geometry.getMaxZ(), 0);
+    }
+
+    @Test
+    public void emptyGeometriesHaveNoExtrema() {
+        for (String wkt : new String[] { "POINT Z EMPTY", "LINESTRING Z EMPTY", "POLYGON Z EMPTY",
+                                         "MULTIPOINT Z EMPTY", "GEOMETRYCOLLECTION Z EMPTY" }) {
+            GeometryWrapper geometry = GeometryWrapper.extract(wkt, WKTDatatype.URI);
+            assertThrows(wkt, IllegalStateException.class, geometry::getMinX);
+            assertThrows(wkt, IllegalStateException.class, geometry::getMaxX);
+            assertThrows(wkt, IllegalStateException.class, geometry::getMinY);
+            assertThrows(wkt, IllegalStateException.class, geometry::getMaxY);
+            assertThrows(wkt, IllegalStateException.class, geometry::getMinZ);
+            assertThrows(wkt, IllegalStateException.class, geometry::getMaxZ);
+        }
+    }
+
+    @Test
+    public void polygonZExtremaIncludeInteriorRings() {
+        GeometryWrapper geometry = GeometryWrapper.extract(
+            "POLYGON Z ((0 0 1, 10 0 2, 10 10 3, 0 10 4, 0 0 1), "
+            + "(2 2 -9, 2 3 20, 3 2 6, 2 2 -9))", WKTDatatype.URI);
+        assertEquals(0, geometry.getMinX(), 0);
+        assertEquals(10, geometry.getMaxX(), 0);
+        assertEquals(0, geometry.getMinY(), 0);
+        assertEquals(10, geometry.getMaxY(), 0);
+        assertEquals(-9, geometry.getMinZ(), 0);
+        assertEquals(20, geometry.getMaxZ(), 0);
+    }
+
+    private static GeometryWrapper wrapper(Geometry geometry, CoordinateSequenceDimensions dimensions) {
+        return new GeometryWrapper(geometry, SRS_URI.DEFAULT_WKT_CRS84, WKTDatatype.URI,
+                new DimensionInfo(dimensions, geometry.getDimension()));
+    }
+}


(jena) 01/03: GH-4221: Preserve Z/M and coordinate layouts when reversing axes

Posted by an...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

afs pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/jena.git

commit b5ce1a9c9a331eae15b8f3c0ac6150ca108dca28
Author: Edmond Chuc <ed...@kurrawong.ai>
AuthorDate: Mon Sep 14 16:45:47 2026 +1000

    GH-4221: Preserve Z/M and coordinate layouts when reversing axes
---
 .../geosparql/implementation/GeometryReverse.java  | 165 +++------------------
 .../jts/CustomCoordinateSequence.java              |   9 +-
 .../implementation/GeometryReverseTest.java        | 101 +++++++++++++
 .../jts/CustomCoordinateSequenceTest.java          |  23 +++
 4 files changed, 153 insertions(+), 145 deletions(-)

diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/GeometryReverse.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/GeometryReverse.java
index 96f384b47d..5baae92b4e 100644
--- a/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/GeometryReverse.java
+++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/GeometryReverse.java
@@ -21,13 +21,9 @@
 package org.apache.jena.geosparql.implementation;
 
 import org.apache.jena.geosparql.implementation.registry.SRSRegistry;
-import org.locationtech.jts.geom.Coordinate;
+import org.locationtech.jts.geom.CoordinateSequence;
+import org.locationtech.jts.geom.CoordinateSequenceFilter;
 import org.locationtech.jts.geom.Geometry;
-import org.locationtech.jts.geom.GeometryCollection;
-import org.locationtech.jts.geom.GeometryFactory;
-import org.locationtech.jts.geom.LineString;
-import org.locationtech.jts.geom.LinearRing;
-import org.locationtech.jts.geom.Polygon;
 
 /**
  *
@@ -81,149 +77,32 @@ public class GeometryReverse {
     }
 
     /**
-     * Reverses coordinate order of the supplied geometry and produces a new
-     * geometry.
+     * Swaps X and Y in a copy of the supplied geometry, preserving Z, M,
+     * coordinate layouts, and collection structure. The input is not modified.
      *
      * @param geometry
-     * @return Geometry in x,y coordinate order.
+     * @return Geometry with X and Y exchanged.
      */
     public static Geometry reverseGeometry(Geometry geometry) {
-
-        if (geometry.isEmpty()) {
-            return geometry.copy();
-        }
-
-        GeometryFactory factory = geometry.getFactory();
-        Geometry finalGeometry;
-        Coordinate[] coordinates;
-
-        String type = geometry.getGeometryType();
-
-        switch (type) {
-            case "LineString":
-                coordinates = getReversedCoordinates(geometry);
-                finalGeometry = factory.createLineString(coordinates);
-                break;
-            case "LinearRing":
-                coordinates = getReversedCoordinates(geometry);
-                finalGeometry = factory.createLinearRing(coordinates);
-                break;
-            case "MultiPoint":
-                coordinates = getReversedCoordinates(geometry);
-                finalGeometry = factory.createMultiPointFromCoords(coordinates);
-                break;
-            case "Polygon":
-                finalGeometry = reversePolygon(geometry, factory);
-                break;
-            case "Point":
-                coordinates = getReversedCoordinates(geometry);
-                finalGeometry = factory.createPoint(coordinates[0]);
-                break;
-            case "MultiPolygon":
-                Polygon[] polygons = unpackPolygons((GeometryCollection) geometry);
-                finalGeometry = factory.createMultiPolygon(polygons);
-                break;
-            case "MultiLineString":
-                LineString[] lineString = unpackLineStrings((GeometryCollection) geometry);
-                finalGeometry = factory.createMultiLineString(lineString);
-                break;
-            case "GeometryCollection":
-                Geometry[] geometries = unpackGeometryCollection((GeometryCollection) geometry);
-                finalGeometry = factory.createGeometryCollection(geometries);
-                break;
-            default:
-                finalGeometry = geometry;
-                break;
-        }
-
-        return finalGeometry;
-    }
-
-    private static Coordinate[] getReversedCoordinates(Geometry geometry) {
-
-        Coordinate[] original = geometry.getCoordinates();
-        Coordinate[] reversed = new Coordinate[original.length];
-
-        for (int i = 0; i < original.length; i++) {
-            reversed[i] = new Coordinate(original[i].y, original[i].x);
-        }
-
-        return reversed;
-
-    }
-
-    private static Polygon reversePolygon(Geometry geometry, GeometryFactory factory) {
-
-        Polygon finalGeometry;
-        Polygon polygon = (Polygon) geometry;
-        if (polygon.getNumInteriorRing() == 0) {
-            //There are no interior rings so perform the standard reversal.
-            Coordinate[] coordinates = getReversedCoordinates(geometry);
-            finalGeometry = factory.createPolygon(coordinates);
-        } else {
-
-            LineString exteriorRing = polygon.getExteriorRing();
-            Coordinate[] reversedExteriorCoordinates = getReversedCoordinates(exteriorRing);
-            LinearRing reversedExteriorRing = factory.createLinearRing(reversedExteriorCoordinates);
-
-            LinearRing[] reversedInteriorRings = new LinearRing[polygon.getNumInteriorRing()];
-            for (int i = 0; i < polygon.getNumInteriorRing(); i++) {
-                LineString interiorRing = polygon.getInteriorRingN(i);
-                Coordinate[] reversedInteriorCoordinates = getReversedCoordinates(interiorRing);
-                LinearRing reversedInteriorRing = factory.createLinearRing(reversedInteriorCoordinates);
-                reversedInteriorRings[i] = reversedInteriorRing;
+        Geometry reversed = geometry.copy();
+        reversed.apply(new CoordinateSequenceFilter() {
+            @Override
+            public void filter(CoordinateSequence sequence, int index) {
+                double x = sequence.getX(index);
+                sequence.setOrdinate(index, 0, sequence.getY(index));
+                sequence.setOrdinate(index, 1, x);
             }
 
-            finalGeometry = factory.createPolygon(reversedExteriorRing, reversedInteriorRings);
-        }
-
-        return finalGeometry;
-    }
-
-    private static Polygon[] unpackPolygons(GeometryCollection geoCollection) {
-
-        GeometryFactory factory = geoCollection.getFactory();
-
-        int count = geoCollection.getNumGeometries();
-        Polygon[] polygons = new Polygon[count];
-
-        for (int i = 0; i < count; i++) {
-            Geometry geometry = geoCollection.getGeometryN(i);
-            Polygon polygon = reversePolygon(geometry, factory);
-            polygons[i] = polygon;
-        }
-
-        return polygons;
-    }
-
-    private static LineString[] unpackLineStrings(GeometryCollection geoCollection) {
-
-        GeometryFactory factory = geoCollection.getFactory();
-
-        int count = geoCollection.getNumGeometries();
-        LineString[] lineStrings = new LineString[count];
-
-        for (int i = 0; i < count; i++) {
-            Geometry geometry = geoCollection.getGeometryN(i);
-            Coordinate[] coordinates = getReversedCoordinates(geometry);
-            LineString lineString = factory.createLineString(coordinates);
-            lineStrings[i] = lineString;
-        }
-
-        return lineStrings;
-    }
-
-    private static Geometry[] unpackGeometryCollection(GeometryCollection geoCollection) {
-
-        int count = geoCollection.getNumGeometries();
-        Geometry[] geometries = new Geometry[count];
-
-        for (int i = 0; i < count; i++) {
-            Geometry geometry = geoCollection.getGeometryN(i);
-            geometries[i] = reverseGeometry(geometry);
-        }
+            @Override
+            public boolean isDone() {
+                return false;
+            }
 
-        return geometries;
+            @Override
+            public boolean isGeometryChanged() {
+                return true;
+            }
+        });
+        return reversed;
     }
-
 }
diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/jts/CustomCoordinateSequence.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/jts/CustomCoordinateSequence.java
index 11110f746f..083e643ded 100644
--- a/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/jts/CustomCoordinateSequence.java
+++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/jts/CustomCoordinateSequence.java
@@ -238,7 +238,12 @@ public class CustomCoordinateSequence implements CoordinateSequence, Serializabl
 
     @Override
     public CustomCoordinateSequence copy() {
-        return new CustomCoordinateSequence(x, y, z, m);
+        CustomCoordinateSequence copy = new CustomCoordinateSequence(size, dimensions);
+        System.arraycopy(x, 0, copy.x, 0, size);
+        System.arraycopy(y, 0, copy.y, 0, size);
+        System.arraycopy(z, 0, copy.z, 0, size);
+        System.arraycopy(m, 0, copy.m, 0, size);
+        return copy;
     }
 
     public int getSize() {
@@ -506,7 +511,7 @@ public class CustomCoordinateSequence implements CoordinateSequence, Serializabl
     @Override
     @Deprecated
     public CustomCoordinateSequence clone() {
-        return new CustomCoordinateSequence(x, y, z, m);
+        return copy();
     }
 
     @Override
diff --git a/jena-geosparql/src/test/java/org/apache/jena/geosparql/implementation/GeometryReverseTest.java b/jena-geosparql/src/test/java/org/apache/jena/geosparql/implementation/GeometryReverseTest.java
index 1ca8c335a0..9b81edce3c 100644
--- a/jena-geosparql/src/test/java/org/apache/jena/geosparql/implementation/GeometryReverseTest.java
+++ b/jena-geosparql/src/test/java/org/apache/jena/geosparql/implementation/GeometryReverseTest.java
@@ -20,14 +20,24 @@
  */
 package org.apache.jena.geosparql.implementation;
 
+import java.util.ArrayList;
+import java.util.List;
+
+import org.apache.jena.geosparql.implementation.datatype.WKTDatatype;
+import org.apache.jena.geosparql.implementation.jts.CoordinateSequenceDimensions;
+import org.apache.jena.geosparql.implementation.jts.CustomCoordinateSequence;
+import org.apache.jena.geosparql.implementation.jts.CustomGeometryFactory;
 import org.apache.jena.geosparql.implementation.vocabulary.SRS_URI;
 import org.junit.After;
 import org.junit.AfterClass;
 import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotSame;
 import org.junit.Before;
 import org.junit.BeforeClass;
 import org.junit.Test;
 import org.locationtech.jts.geom.Geometry;
+import org.locationtech.jts.geom.CoordinateSequence;
+import org.locationtech.jts.geom.GeometryComponentFilter;
 import org.locationtech.jts.geom.LineString;
 import org.locationtech.jts.geom.MultiLineString;
 import org.locationtech.jts.geom.MultiPoint;
@@ -44,6 +54,97 @@ import org.opengis.util.FactoryException;
  */
 public class GeometryReverseTest {
 
+    @Test
+    public void reversalPreservesZAndMForStandardAndCustomSequences() throws ParseException {
+        String[] examples = {
+            "POINT Z (100 10 -4)",
+            "LINESTRING M (100 10 7, 120 20 8)",
+            "LINESTRING ZM (100 10 -4 7, 120 20 9 8)",
+            "POLYGON ZM ((0 0 1 10, 10 0 2 20, 10 10 3 30, 0 0 1 10), "
+            + "(2 1 -9 40, 3 1 8 50, 3 2 7 60, 2 1 -9 40))",
+            "MULTIPOINT Z ((100 10 -4), (120 20 9))"
+        };
+        for (String wkt : examples) {
+            assertReversalPreservesOrdinates(new WKTReader().read(wkt));
+            assertReversalPreservesOrdinates(GeometryWrapper.extract(wkt, WKTDatatype.URI).getParsingGeometry());
+        }
+    }
+
+    @Test
+    public void reversalPreservesNestedMembersAndEmptyLayouts() {
+        var factory = CustomGeometryFactory.theInstance();
+        Geometry point = GeometryWrapper.extract("POINT ZM (100 10 -4 7)", WKTDatatype.URI).getParsingGeometry();
+        Geometry empty = factory.createPoint(new CustomCoordinateSequence(0, CoordinateSequenceDimensions.XYZM));
+        Geometry nested = factory.createGeometryCollection(new Geometry[] { empty, point });
+        Geometry collection = factory.createGeometryCollection(new Geometry[] { point, nested });
+        Geometry reversed = GeometryReverse.reverseGeometry(collection);
+        assertEquals(2, reversed.getNumGeometries());
+        assertEquals(2, reversed.getGeometryN(1).getNumGeometries());
+        assertReversalPreservesOrdinates(collection);
+        assertReversalPreservesOrdinates(empty);
+    }
+
+    @Test
+    public void reversalPreservesDeclaredLayoutWithMissingZAndM() {
+        var coordinates = new CustomCoordinateSequence(CoordinateSequenceDimensions.XYZM,
+                                                       "100 10 NaN NaN,120 20 9 8");
+        assertReversalPreservesOrdinates(CustomGeometryFactory.theInstance().createLineString(coordinates));
+    }
+
+    private static void assertReversalPreservesOrdinates(Geometry original) {
+        // Capture values independently so a shallow copy cannot hide input mutation.
+        List<CoordinateSequence> source = sequences(original);
+        List<double[]> values = new ArrayList<>();
+        for (CoordinateSequence sequence : source) {
+            for (int i = 0; i < sequence.size(); i++) {
+                values.add(new double[] { sequence.getX(i), sequence.getY(i), sequence.getZ(i), sequence.getM(i) });
+            }
+        }
+        original.getEnvelopeInternal(); // Populate the envelope cache before reversal.
+        Geometry reversed = GeometryReverse.reverseGeometry(original);
+        assertNotSame(original, reversed);
+        assertEquals(original.getGeometryType(), reversed.getGeometryType());
+        List<CoordinateSequence> result = sequences(reversed);
+        assertEquals(source.size(), result.size());
+        int coordinate = 0;
+        for (int n = 0; n < source.size(); n++) {
+            CoordinateSequence before = source.get(n);
+            CoordinateSequence after = result.get(n);
+            assertNotSame(before, after);
+            assertEquals(before.getDimension(), after.getDimension());
+            assertEquals(before.getMeasures(), after.getMeasures());
+            assertEquals(before.size(), after.size());
+            for (int i = 0; i < before.size(); i++) {
+                double[] expected = values.get(coordinate++);
+                assertEquals(expected[0], before.getX(i), 0);
+                assertEquals(expected[1], before.getY(i), 0);
+                assertEquals(expected[2], before.getZ(i), 0);
+                assertEquals(expected[3], before.getM(i), 0);
+                assertEquals(expected[1], after.getX(i), 0);
+                assertEquals(expected[0], after.getY(i), 0);
+                assertEquals(expected[2], after.getZ(i), 0);
+                assertEquals(expected[3], after.getM(i), 0);
+            }
+        }
+        if (!original.isEmpty()) {
+            assertEquals(original.getEnvelopeInternal().getMinY(), reversed.getEnvelopeInternal().getMinX(), 0);
+            assertEquals(original.getEnvelopeInternal().getMaxY(), reversed.getEnvelopeInternal().getMaxX(), 0);
+            assertEquals(original.getEnvelopeInternal().getMinX(), reversed.getEnvelopeInternal().getMinY(), 0);
+            assertEquals(original.getEnvelopeInternal().getMaxX(), reversed.getEnvelopeInternal().getMaxY(), 0);
+        }
+    }
+
+    private static List<CoordinateSequence> sequences(Geometry geometry) {
+        List<CoordinateSequence> result = new ArrayList<>();
+        geometry.apply((GeometryComponentFilter) component -> {
+            if (component instanceof Point point)
+                result.add(point.getCoordinateSequence());
+            else if (component instanceof LineString line)
+                result.add(line.getCoordinateSequence());
+        });
+        return result;
+    }
+
     public GeometryReverseTest() {
     }
 
diff --git a/jena-geosparql/src/test/java/org/apache/jena/geosparql/implementation/jts/CustomCoordinateSequenceTest.java b/jena-geosparql/src/test/java/org/apache/jena/geosparql/implementation/jts/CustomCoordinateSequenceTest.java
index e0c0c927db..66e05fb521 100644
--- a/jena-geosparql/src/test/java/org/apache/jena/geosparql/implementation/jts/CustomCoordinateSequenceTest.java
+++ b/jena-geosparql/src/test/java/org/apache/jena/geosparql/implementation/jts/CustomCoordinateSequenceTest.java
@@ -27,6 +27,29 @@ import static org.junit.Assert.*;
 
 public class CustomCoordinateSequenceTest {
 
+    @Test
+    @SuppressWarnings("deprecation")
+    public void clonePreservesDeclaredLayoutAndValues() {
+        for (CustomCoordinateSequence source : new CustomCoordinateSequence[] {
+                new CustomCoordinateSequence(0, CoordinateSequenceDimensions.XYZM),
+                new CustomCoordinateSequence(CoordinateSequenceDimensions.XYZM, "100 10 NaN NaN,120 20 9 8") }) {
+            CustomCoordinateSequence clone = source.clone();
+            assertNotSame(source, clone);
+            assertEquals(CoordinateSequenceDimensions.XYZM, clone.getDimensions());
+            assertEquals(4, clone.getDimension());
+            assertEquals(1, clone.getMeasures());
+            assertEquals(source.size(), clone.size());
+            for (int i = 0; i < source.size(); i++) {
+                for (int ordinate = 0; ordinate < 4; ordinate++) {
+                    double original = source.getOrdinate(i, ordinate);
+                    assertEquals(original, clone.getOrdinate(i, ordinate), 0);
+                    clone.setOrdinate(i, ordinate, 42);
+                    assertEquals(original, source.getOrdinate(i, ordinate), 0);
+                }
+            }
+        }
+    }
+
     public CustomCoordinateSequenceTest() {
     }
 


(jena) 02/03: GH-4221: Preserve Z/M when converting coordinate sequences

Posted by an...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

afs pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/jena.git

commit ccc7fe90d067349140a9049ea2a2e7a7c9fd9874
Author: Edmond Chuc <ed...@kurrawong.ai>
AuthorDate: Mon Sep 14 16:46:31 2026 +1000

    GH-4221: Preserve Z/M when converting coordinate sequences
---
 .../geosparql/implementation/DimensionInfo.java    |   4 +-
 .../jts/CustomCoordinateSequence.java              |   9 +-
 .../jts/CustomCoordinateSequenceFactory.java       |  25 +++-
 .../implementation/GeometryWrapperFactoryTest.java |  41 +++++++
 .../jts/CustomCoordinateSequenceFactoryTest.java   | 129 +++++++++++++++++++++
 5 files changed, 204 insertions(+), 4 deletions(-)

diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/DimensionInfo.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/DimensionInfo.java
index ca1866c44f..d06566742b 100644
--- a/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/DimensionInfo.java
+++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/DimensionInfo.java
@@ -91,7 +91,9 @@ public class DimensionInfo implements Serializable {
     }
 
     public static DimensionInfo find(Coordinate coordinate, Geometry geometry) {
-        CoordinateSequenceDimensions coordDims = CoordinateSequenceDimensions.find(coordinate);
+        // Empty geometries have no coordinate; use the existing XY default.
+        CoordinateSequenceDimensions coordDims = CoordinateSequenceDimensions.find(
+                coordinate == null ? XY_COORDINATE : coordinate);
         return new DimensionInfo(coordDims, geometry.getDimension());
     }
 
diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/jts/CustomCoordinateSequence.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/jts/CustomCoordinateSequence.java
index 083e643ded..73ff6bdfbe 100644
--- a/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/jts/CustomCoordinateSequence.java
+++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/jts/CustomCoordinateSequence.java
@@ -450,7 +450,8 @@ public class CustomCoordinateSequence implements CoordinateSequence, Serializabl
             case Y:
                 return y[index];
             case Z:
-                return z[index];
+                // In XYM, ordinate 2 is M; the Z constant assumes an XYZ layout.
+                return dimensions == CoordinateSequenceDimensions.XYM ? m[index] : z[index];
             case M:
                 return m[index];
         }
@@ -473,7 +474,11 @@ public class CustomCoordinateSequence implements CoordinateSequence, Serializabl
                 y[index] = value;
                 break;
             case Z:
-                z[index] = value;
+                if (dimensions == CoordinateSequenceDimensions.XYM) {
+                    m[index] = value;
+                } else {
+                    z[index] = value;
+                }
                 break;
             case M:
                 m[index] = value;
diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/jts/CustomCoordinateSequenceFactory.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/jts/CustomCoordinateSequenceFactory.java
index 8d98dd0834..3cdf4ae636 100644
--- a/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/jts/CustomCoordinateSequenceFactory.java
+++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/jts/CustomCoordinateSequenceFactory.java
@@ -48,7 +48,21 @@ public class CustomCoordinateSequenceFactory implements CoordinateSequenceFactor
             CustomCoordinateSequence customCoordSeq = (CustomCoordinateSequence) coordSeq;
             copyCoordSeq = customCoordSeq.copy();
         } else {
-            copyCoordSeq = new CustomCoordinateSequence(coordSeq.toCoordinateArray());
+            CoordinateSequenceDimensions dimensions;
+            if (coordSeq.hasM()) {
+                dimensions = coordSeq.hasZ() ? CoordinateSequenceDimensions.XYZM : CoordinateSequenceDimensions.XYM;
+            } else {
+                dimensions = coordSeq.hasZ() ? CoordinateSequenceDimensions.XYZ : CoordinateSequenceDimensions.XY;
+            }
+            copyCoordSeq = new CustomCoordinateSequence(coordSeq.size(), dimensions);
+            for (int i = 0; i < coordSeq.size(); i++) {
+                copyCoordSeq.setOrdinate(i, CoordinateSequence.X, coordSeq.getX(i));
+                copyCoordSeq.setOrdinate(i, CoordinateSequence.Y, coordSeq.getY(i));
+                if (coordSeq.hasZ())
+                    copyCoordSeq.setOrdinate(i, CoordinateSequence.Z, coordSeq.getZ(i));
+                if (coordSeq.hasM())
+                    copyCoordSeq.setOrdinate(i, copyCoordSeq.getDimension() - copyCoordSeq.getMeasures(), coordSeq.getM(i));
+            }
         }
 
         return copyCoordSeq;
@@ -59,4 +73,13 @@ public class CustomCoordinateSequenceFactory implements CoordinateSequenceFactor
         return new CustomCoordinateSequence(size, dimension);
     }
 
+    @Override
+    public CoordinateSequence create(int size, int dimension, int measures) {
+        // Match JTS allocation limits: two or three spatial ordinates and at most one measure.
+        int spatialDimension = Math.max(2, Math.min(3, dimension - measures));
+        int measureDimension = Math.min(1, measures);
+        return new CustomCoordinateSequence(size, CustomCoordinateSequence.findCoordinateSequenceDimensions(
+                spatialDimension + measureDimension, spatialDimension));
+    }
+
 }
diff --git a/jena-geosparql/src/test/java/org/apache/jena/geosparql/implementation/GeometryWrapperFactoryTest.java b/jena-geosparql/src/test/java/org/apache/jena/geosparql/implementation/GeometryWrapperFactoryTest.java
index 8dc419e318..2bde86bea7 100644
--- a/jena-geosparql/src/test/java/org/apache/jena/geosparql/implementation/GeometryWrapperFactoryTest.java
+++ b/jena-geosparql/src/test/java/org/apache/jena/geosparql/implementation/GeometryWrapperFactoryTest.java
@@ -23,6 +23,7 @@ package org.apache.jena.geosparql.implementation;
 import java.util.Arrays;
 import java.util.List;
 import org.apache.jena.geosparql.implementation.datatype.WKTDatatype;
+import org.apache.jena.geosparql.implementation.jts.CoordinateSequenceDimensions;
 import org.apache.jena.geosparql.implementation.vocabulary.SRS_URI;
 import org.junit.After;
 import org.junit.AfterClass;
@@ -31,6 +32,9 @@ import org.junit.Before;
 import org.junit.BeforeClass;
 import org.junit.Test;
 import org.locationtech.jts.geom.Coordinate;
+import org.locationtech.jts.geom.Geometry;
+import org.locationtech.jts.geom.GeometryFactory;
+import org.locationtech.jts.geom.impl.CoordinateArraySequence;
 
 /**
  *
@@ -38,6 +42,43 @@ import org.locationtech.jts.geom.Coordinate;
  */
 public class GeometryWrapperFactoryTest {
 
+    @Test
+    public void factoryHandlesEmptyGeometriesWithoutCoordinates() {
+        GeometryFactory factory = new GeometryFactory();
+        Geometry[] members = {
+            factory.createPoint(new CoordinateArraySequence(0, 3, 0)),
+            factory.createPoint(new CoordinateArraySequence(0, 3, 1))
+        };
+        for (Geometry empty : new Geometry[] {
+                factory.createPoint(),
+                factory.createLineString(),
+                factory.createPolygon(),
+                factory.createGeometryCollection(),
+                factory.createGeometryCollection(new Geometry[] { factory.createGeometryCollection() }),
+                factory.createGeometryCollection(members) }) {
+            GeometryWrapper geometry = GeometryWrapperFactory.createGeometry(empty,
+                    SRS_URI.DEFAULT_WKT_CRS84, WKTDatatype.URI);
+            assertEquals(true, geometry.getParsingGeometry().isEmpty());
+            assertEquals(CoordinateSequenceDimensions.XY, geometry.getCoordinateSequenceDimensions());
+            assertEquals(2, geometry.getCoordinateDimension());
+            assertEquals(2, geometry.getSpatialDimension());
+        }
+    }
+
+    @Test
+    public void factoryKeepsOrdinaryJts2DGeometriesXY() {
+        GeometryFactory factory = new GeometryFactory();
+        for (Geometry source : new Geometry[] {
+                factory.createPoint(new Coordinate(1, 2)),
+                factory.createLineString(new Coordinate[] { new Coordinate(1, 2), new Coordinate(3, 4) }) }) {
+            GeometryWrapper geometry = GeometryWrapperFactory.createGeometry(source,
+                    SRS_URI.DEFAULT_WKT_CRS84, WKTDatatype.URI);
+            assertEquals(CoordinateSequenceDimensions.XY, geometry.getCoordinateSequenceDimensions());
+            assertEquals(2, geometry.getCoordinateDimension());
+            assertEquals(2, geometry.getSpatialDimension());
+        }
+    }
+
     public GeometryWrapperFactoryTest() {
     }
 
diff --git a/jena-geosparql/src/test/java/org/apache/jena/geosparql/implementation/jts/CustomCoordinateSequenceFactoryTest.java b/jena-geosparql/src/test/java/org/apache/jena/geosparql/implementation/jts/CustomCoordinateSequenceFactoryTest.java
new file mode 100644
index 0000000000..c1e63c3835
--- /dev/null
+++ b/jena-geosparql/src/test/java/org/apache/jena/geosparql/implementation/jts/CustomCoordinateSequenceFactoryTest.java
@@ -0,0 +1,129 @@
+/*
+ * 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
+ *
+ *   https://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.
+ *
+ *   SPDX-License-Identifier: Apache-2.0
+ */
+package org.apache.jena.geosparql.implementation.jts;
+
+import org.junit.Test;
+import org.locationtech.jts.geom.CoordinateSequence;
+import org.locationtech.jts.geom.CoordinateSequences;
+import org.locationtech.jts.geom.impl.CoordinateArraySequence;
+import org.locationtech.jts.geom.impl.CoordinateArraySequenceFactory;
+
+import static org.junit.Assert.*;
+
+public class CustomCoordinateSequenceFactoryTest {
+    @Test
+    public void allocationPreservesMeasuresAndMatchesJtsLayoutLimits() {
+        for (int size : new int[] { 0, 2 }) {
+            for (int[] layout : new int[][] { { 2, 0 }, { 3, 0 }, { 3, 1 }, { 4, 1 },
+                    { 1, 0 }, { 4, 0 }, { 5, 2 } }) {
+                CoordinateSequence expected = CoordinateArraySequenceFactory.instance().create(size, layout[0], layout[1]);
+                CoordinateSequence actual = new CustomCoordinateSequenceFactory().create(size, layout[0], layout[1]);
+                assertLayout(expected, actual);
+                if (size > 0 && actual.hasM()) {
+                    actual.setOrdinate(0, actual.getDimension() - actual.getMeasures(), 8);
+                    assertEquals(8, actual.getM(0), 0);
+                    assertTrue(Double.isNaN(actual.getZ(0)));
+                }
+            }
+        }
+    }
+
+    @Test
+    public void conversionPreservesLayoutsValuesAndIndependence() {
+        for (int[] layout : new int[][] { { 2, 0 }, { 3, 0 }, { 3, 1 }, { 4, 1 } }) {
+            CoordinateSequence source = new CoordinateArraySequence(2, layout[0], layout[1]);
+            for (int i = 0; i < source.size(); i++) {
+                source.setOrdinate(i, 0, 100 + i);
+                source.setOrdinate(i, 1, 10 + i);
+                if (source.hasZ())
+                    source.setOrdinate(i, 2, i == 0 ? Double.NaN : 9);
+                if (source.hasM())
+                    source.setOrdinate(i, source.getDimension() - 1, i == 0 ? Double.NaN : 8);
+            }
+            CoordinateSequence converted = new CustomCoordinateSequenceFactory().create(source);
+            assertLayout(source, converted);
+            for (int i = 0; i < source.size(); i++) {
+                assertEquals(source.getX(i), converted.getX(i), 0);
+                assertEquals(source.getY(i), converted.getY(i), 0);
+                assertEquals(source.getZ(i), converted.getZ(i), 0);
+                assertEquals(source.getM(i), converted.getM(i), 0);
+            }
+            converted.setOrdinate(1, CoordinateSequence.X, -1);
+            converted.setOrdinate(1, CoordinateSequence.Y, -1);
+            assertEquals(101, source.getX(1), 0);
+            assertEquals(11, source.getY(1), 0);
+            if (source.hasZ()) {
+                converted.setOrdinate(1, CoordinateSequence.Z, -1);
+                assertEquals(9, source.getZ(1), 0);
+            }
+            if (source.hasM()) {
+                converted.setOrdinate(1, converted.getDimension() - converted.getMeasures(), -1);
+                assertEquals(-1, converted.getM(1), 0);
+                assertEquals(8, source.getM(1), 0);
+            }
+        }
+    }
+
+    @Test
+    public void convertedSequencesSupportGenericOrdinateCopying() {
+        for (int[] layout : new int[][] { { 2, 0 }, { 3, 0 }, { 3, 1 }, { 4, 1 } }) {
+            CoordinateSequence source = new CoordinateArraySequence(2, layout[0], layout[1]);
+            for (int i = 0; i < source.size(); i++) {
+                for (int ordinate = 0; ordinate < source.getDimension(); ordinate++) {
+                    source.setOrdinate(i, ordinate, i == 0 && ordinate > 1 ? Double.NaN : 10 * i + ordinate);
+                }
+            }
+            CoordinateSequence converted = new CustomCoordinateSequenceFactory().create(source);
+            CoordinateSequence standardCopy = new CoordinateArraySequence(2, layout[0], layout[1]);
+            CoordinateSequences.copy(converted, 0, standardCopy, 0, source.size());
+            CoordinateSequence customCopy = new CustomCoordinateSequenceFactory().create(
+                    new CoordinateArraySequence(2, layout[0], layout[1]));
+            CoordinateSequences.copy(source, 0, customCopy, 0, source.size());
+            for (CoordinateSequence result : new CoordinateSequence[] { converted, standardCopy, customCopy }) {
+                assertLayout(source, result);
+                for (int i = 0; i < source.size(); i++) {
+                    for (int ordinate = 0; ordinate < source.getDimension(); ordinate++) {
+                        assertEquals(source.getOrdinate(i, ordinate), result.getOrdinate(i, ordinate), 0);
+                    }
+                    assertEquals(source.getZ(i), result.getZ(i), 0);
+                    assertEquals(source.getM(i), result.getM(i), 0);
+                }
+            }
+        }
+    }
+
+    @Test
+    public void conversionPreservesEmptyLayouts() {
+        for (int[] layout : new int[][] { { 2, 0 }, { 3, 0 }, { 3, 1 }, { 4, 1 } }) {
+            CoordinateSequence source = new CoordinateArraySequence(0, layout[0], layout[1]);
+            assertLayout(source, new CustomCoordinateSequenceFactory().create(source));
+        }
+    }
+
+    private static void assertLayout(CoordinateSequence source, CoordinateSequence converted) {
+        assertNotSame(source, converted);
+        assertEquals(source.size(), converted.size());
+        assertEquals(source.getDimension(), converted.getDimension());
+        assertEquals(source.getMeasures(), converted.getMeasures());
+        assertEquals(source.hasZ(), converted.hasZ());
+        assertEquals(source.hasM(), converted.hasM());
+    }
+}