diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/geof/nontopological/filter_functions/CentroidFF.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/geof/nontopological/filter_functions/CentroidFF.java new file mode 100644 index 00000000000..8ef27e81e5a --- /dev/null +++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/geof/nontopological/filter_functions/CentroidFF.java @@ -0,0 +1,44 @@ +/* + * 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.nontopological.filter_functions; + +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:centroid. */ +public class CentroidFF extends FunctionBase1 { + + @Override + public NodeValue exec(NodeValue v) { + + try { + GeometryWrapper geometry = GeometryWrapper.extract(v); + GeometryWrapper centroid = geometry.centroid(); + return centroid.asNodeValue(); + } catch (DatatypeFormatException ex) { + throw new ExprEvalException(ex.getMessage(), ex); + } + } + +} 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 daedac38ca8..1ff59b2b29b 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 @@ -46,6 +46,7 @@ import org.apache.jena.sparql.expr.NodeValue; import org.apache.sis.geometry.DirectPosition2D; import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.CoordinateXY; import org.locationtech.jts.geom.Envelope; import org.locationtech.jts.geom.Geometry; import org.locationtech.jts.geom.GeometryFactory; @@ -685,6 +686,31 @@ public GeometryWrapper convexHull() { return new GeometryWrapper(parsingGeo, xyGeo, srsInfo.getSrsURI(), geometryDatatypeURI, dimensionInfo); } + /** + * Returns the planar, two-dimensional centroid in the source SRS and datatype. + * Geographic coordinates are treated as planar coordinates, without projection. + * + * @throws DatatypeFormatException if a non-empty XY centroid cannot be + * represented as GML in the source CRS + */ + public GeometryWrapper centroid() { + Point centroid = xyGeometry.getCentroid(); + GeometryFactory factory = CustomGeometryFactory.theInstance(); + Point xyCentroid = centroid.isEmpty() + ? factory.createPoint(new CustomCoordinateSequence(CoordinateSequenceDimensions.XY)) + : factory.createPoint(new CoordinateXY(centroid.getX(), centroid.getY())); + // GML derives its coordinate dimension from the CRS. It cannot encode + // this XY result in a three-dimensional CRS without inventing Z. + if (!xyCentroid.isEmpty() + && GMLDatatype.URI.equals(geometryDatatypeURI) + && srsInfo.getCrs().getCoordinateSystem().getDimension() != 2) { + throw new DatatypeFormatException( + "A two-dimensional centroid cannot be represented as GML in the source CRS."); + } + Geometry parsingCentroid = GeometryReverse.check(xyCentroid, srsInfo); + return new GeometryWrapper(parsingCentroid, xyCentroid, getSrsURI(), geometryDatatypeURI, DimensionInfo.XY_POINT); + } + /** * * @param targetGeometry diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/function_registration/NonTopological.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/function_registration/NonTopological.java index 4797384dad9..fd893806ae2 100644 --- a/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/function_registration/NonTopological.java +++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/function_registration/NonTopological.java @@ -23,6 +23,7 @@ import org.apache.jena.geosparql.geof.nontopological.filter_functions.AsGeoJSONFF; import org.apache.jena.geosparql.geof.nontopological.filter_functions.BoundaryFF; import org.apache.jena.geosparql.geof.nontopological.filter_functions.BufferFF; +import org.apache.jena.geosparql.geof.nontopological.filter_functions.CentroidFF; import org.apache.jena.geosparql.geof.nontopological.filter_functions.ConvexHullFF; import org.apache.jena.geosparql.geof.nontopological.filter_functions.DifferenceFF; import org.apache.jena.geosparql.geof.nontopological.filter_functions.DistanceFF; @@ -52,6 +53,7 @@ public static void loadFilterFunctions(FunctionRegistry registry) { // Non Topological Filter Functions registry.put(Geof.BOUNDARY_NAME, BoundaryFF.class); registry.put(Geof.BUFFER_NAME, BufferFF.class); + registry.put(Geof.CENTROID_NAME, CentroidFF.class); registry.put(Geof.CONVEXHULL_NAME, ConvexHullFF.class); registry.put(Geof.DIFFERENCE_NAME, DifferenceFF.class); registry.put(Geof.DISTANCE_NAME, DistanceFF.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 ab13061da7c..4d42c8bca97 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 @@ -71,6 +71,7 @@ public interface Geof { public static final String BUFFER_NAME = GEOF_URI + "buffer"; public static final String DIFFERENCE_NAME = GEOF_URI + "difference"; public static final String BOUNDARY_NAME = GEOF_URI + "boundary"; + public static final String CENTROID_NAME = GEOF_URI + "centroid"; public static final String CONVEXHULL_NAME = GEOF_URI + "convexHull"; public static final String GETSRID_NAME = GEOF_URI + "getSRID"; diff --git a/jena-geosparql/src/test/java/org/apache/jena/geosparql/geof/nontopological/filter_functions/CentroidFFTest.java b/jena-geosparql/src/test/java/org/apache/jena/geosparql/geof/nontopological/filter_functions/CentroidFFTest.java new file mode 100644 index 00000000000..15600826c73 --- /dev/null +++ b/jena-geosparql/src/test/java/org/apache/jena/geosparql/geof/nontopological/filter_functions/CentroidFFTest.java @@ -0,0 +1,229 @@ +/* + * 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.nontopological.filter_functions; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import org.apache.jena.geosparql.configuration.GeoSPARQLConfig; +import org.apache.jena.geosparql.implementation.CentroidTestData; +import org.apache.jena.geosparql.implementation.GeometryWrapper; +import org.apache.jena.geosparql.implementation.datatype.GMLDatatype; +import org.apache.jena.geosparql.implementation.datatype.WKTDatatype; +import org.apache.jena.geosparql.implementation.jts.CoordinateSequenceDimensions; +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.junit.BeforeClass; +import org.junit.Test; + +public class CentroidFFTest { + @BeforeClass + public static void setup() { + GeoSPARQLConfig.setupNoIndex(); + } + + @Test + public void pointAndMultiPointUseCoordinateMean() { + assertCentroid("POINT (2 4)", 2, 4); + assertCentroid("MULTIPOINT ((0 0), (6 0), (0 6))", 2, 2); + } + + @Test + public void lineCentroidIsLengthWeighted() { + assertCentroid("LINESTRING (0 0, 8 0, 8 2)", 4.8, 0.2); + assertCentroid("MULTILINESTRING ((0 0, 8 0), (8 0, 8 2))", 4.8, 0.2); + } + + @Test + public void polygonCentroidUsesArea() { + assertCentroid("POLYGON ((0 0, 6 0, 0 6, 0 0))", 2, 2); + } + + @Test + public void polygonHolesSubtractArea() { + // Outer area 16 centred at (2,2), hole area 1 centred at (1.5,1.5). + assertCentroid("POLYGON ((0 0, 4 0, 4 4, 0 4, 0 0), (1 1, 1 2, 2 2, 2 1, 1 1))", + 30.5 / 15, 30.5 / 15); + } + + @Test + public void multiPolygonCentroidIsAreaWeighted() { + assertCentroid("MULTIPOLYGON (((0 0, 2 0, 2 2, 0 2, 0 0)), ((4 0, 8 0, 8 2, 4 2, 4 0)))", + 13.0 / 3, 1); + } + + @Test + public void mixedCollectionUsesHighestDimensionalMembers() { + assertCentroid("GEOMETRYCOLLECTION (POINT (90 90), LINESTRING (0 0, 8 0))", 4, 0); + assertCentroid("GEOMETRYCOLLECTION (POINT (90 90), POLYGON ((0 0, 6 0, 0 6, 0 0)))", 2, 2); + } + + @Test + public void resultDropsZAndMAndHasPointMetadata() { + for (String wkt : new String[] { "POINT Z (2 4 99)", "POINT M (2 4 99)", "POINT ZM (2 4 99 100)", + "LINESTRING ZM (0 4 9 8, 4 4 7 6)" }) { + assertCentroid(wkt, 2, 4); + } + } + + @Test + public void authorityAxisOrderIsRetainedInTheResult() { + String crs = " "; + GeometryWrapper result = centroid(crs + "LINESTRING (10 100, 20 120)"); + assertEquals("http://www.opengis.net/def/crs/EPSG/0/4326", result.getSrsURI()); + assertEquals(15, result.getParsingGeometry().getCoordinate().getX(), 0); + assertEquals(110, result.getParsingGeometry().getCoordinate().getY(), 0); + assertEquals(110, result.getXYGeometry().getCoordinate().getX(), 0); + assertEquals(15, result.getXYGeometry().getCoordinate().getY(), 0); + } + + @Test + public void projectedCrsIsPreserved() { + String crs = " "; + GeometryWrapper result = centroid(crs + "LINESTRING (100 200, 300 400)"); + assertEquals("http://www.opengis.net/def/crs/EPSG/0/27700", result.getSrsURI()); + assertEquals(200, result.getXYGeometry().getCoordinate().getX(), 0); + assertEquals(300, result.getXYGeometry().getCoordinate().getY(), 0); + } + + @Test + public void geographicCentroidIsPlanar() { + assertCentroid("MULTIPOINT ((170 10), (-170 10))", 0, 10); + } + + @Test + public void emptyInputsReturnAnEmptyXyPoint() { + for (String wkt : new String[] { "POINT EMPTY", "LINESTRING EMPTY", "POLYGON EMPTY", + "MULTIPOINT EMPTY", "MULTILINESTRING EMPTY", "MULTIPOLYGON EMPTY", "GEOMETRYCOLLECTION EMPTY", + "POINT Z EMPTY", "POINT M EMPTY", "POINT ZM EMPTY" }) { + GeometryWrapper result = centroid(wkt); + assertPointMetadata(result); + assertTrue(wkt, result.isEmpty()); + assertEquals("POINT EMPTY", result.asNodeValue().asNode().getLiteralLexicalForm()); + } + } + + @Test + public void gmlResultRetainsDatatypeAndAxisOrder() { + String gml = """ + ' + 10 100 20 120 + '^^geo:gmlLiteral + """.replace("\n", " "); + Node node = evaluate("geof:centroid(" + gml + ")"); + assertNotNull(node); + assertEquals("http://www.opengis.net/ont/geosparql#gmlLiteral", node.getLiteralDatatypeURI()); + GeometryWrapper result = GeometryWrapper.extract(node); + assertPointMetadata(result); + assertEquals("http://www.opengis.net/def/crs/EPSG/0/4326", result.getSrsURI()); + assertEquals(15, result.getParsingGeometry().getCoordinate().getX(), 0); + assertEquals(110, result.getParsingGeometry().getCoordinate().getY(), 0); + } + + @Test + public void threeDimensionalWktCrsRetainsCrsWithAnXyResult() { + GeometryWrapper result = centroid(" POINT Z (10 100 7)"); + assertPointMetadata(result); + assertEquals("http://www.opengis.net/def/crs/EPSG/0/4979", result.getSrsURI()); + assertEquals(10, result.getParsingGeometry().getCoordinate().getX(), 0); + assertEquals(100, result.getParsingGeometry().getCoordinate().getY(), 0); + } + + @Test + public void gmlRejectsAnXyResultInAThreeDimensionalCrs() { + NodeValue value = NodeValue.makeNode(CentroidTestData.GML_POINT_Z_EPSG_4979, GMLDatatype.INSTANCE); + assertThrows(ExprEvalException.class, () -> new CentroidFF().exec(value)); + assertNull(evaluate("geof:centroid('" + CentroidTestData.GML_POINT_Z_EPSG_4979 + "'^^geo:gmlLiteral)")); + } + + @Test + public void centroidComposesWithOtherGeometryFunctions() { + assertEquals(NodeValue.makeInteger(0).asNode(), evaluate( + "geof:dimension(geof:centroid('POLYGON ((0 0, 6 0, 0 6, 0 0))'^^geo:wktLiteral))")); + } + + @Test + public void invalidArgumentsRaiseExpressionErrors() { + CentroidFF function = new CentroidFF(); + for (NodeValue value : new NodeValue[] { NodeValue.makeInteger(42), NodeValue.makeString("POINT (1 2)"), + NodeValue.makeNode(NodeFactory.createURI("urn:geometry")), NodeValue.makeNode("invalid", WKTDatatype.INSTANCE) }) { + assertThrows(ExprEvalException.class, () -> function.exec(value)); + } + for (String value : new String[] { "42", "'POINT (1 2)'", "", "'invalid'^^geo:wktLiteral", "?missing" }) { + assertNull(evaluate("geof:centroid(" + value + ")")); + } + } + + @Test + public void wrongArityIsRejectedAtQueryBuild() { + assertThrows(QueryBuildException.class, () -> evaluate("geof:centroid()")); + assertThrows(QueryBuildException.class, + () -> evaluate("geof:centroid('POINT EMPTY'^^geo:wktLiteral, 1)")); + } + + private static void assertCentroid(String wkt, double x, double y) { + GeometryWrapper result = centroid(wkt); + assertPointMetadata(result); + assertFalse(wkt, result.isEmpty()); + assertEquals(wkt, x, result.getXYGeometry().getCoordinate().getX(), 1e-6); + assertEquals(wkt, y, result.getXYGeometry().getCoordinate().getY(), 1e-6); + } + + private static void assertPointMetadata(GeometryWrapper result) { + assertEquals("Point", result.getGeometryType()); + assertEquals(0, result.getTopologicalDimension()); + assertEquals(CoordinateSequenceDimensions.XY, result.getCoordinateSequenceDimensions()); + } + + private static GeometryWrapper centroid(String wkt) { + Node node = evaluate("geof:centroid('" + wkt + "'^^geo:wktLiteral)"); + assertNotNull(wkt, node); + assertEquals(WKTDatatype.URI, node.getLiteralDatatypeURI()); + return GeometryWrapper.extract(node); + } + + private static Node evaluate(String expression) { + String query = """ + PREFIX geof: + PREFIX geo: + SELECT ?result WHERE { BIND(%s AS ?result) } + """.formatted(expression); + try (QueryExecution execution = QueryExecution.create(query, ModelFactory.createDefaultModel())) { + ResultSet results = execution.execSelect(); + assertTrue(expression, results.hasNext()); + QuerySolution solution = results.next(); + assertFalse(expression, results.hasNext()); + return solution.contains("result") ? solution.get("result").asNode() : null; + } + } +} diff --git a/jena-geosparql/src/test/java/org/apache/jena/geosparql/implementation/CentroidTestData.java b/jena-geosparql/src/test/java/org/apache/jena/geosparql/implementation/CentroidTestData.java new file mode 100644 index 00000000000..b1305d6367a --- /dev/null +++ b/jena-geosparql/src/test/java/org/apache/jena/geosparql/implementation/CentroidTestData.java @@ -0,0 +1,31 @@ +/* + * 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; + +public final class CentroidTestData { + public static final String EMPTY_GML_POLYGON_EPSG_4979 = + ""; + public static final String GML_POINT_Z_EPSG_4979 = + "10 100 7"; + + private CentroidTestData() { + } +} diff --git a/jena-geosparql/src/test/java/org/apache/jena/geosparql/implementation/GeometryCentroidTest.java b/jena-geosparql/src/test/java/org/apache/jena/geosparql/implementation/GeometryCentroidTest.java new file mode 100644 index 00000000000..2deb7f9e248 --- /dev/null +++ b/jena-geosparql/src/test/java/org/apache/jena/geosparql/implementation/GeometryCentroidTest.java @@ -0,0 +1,87 @@ +/* + * 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 static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import org.apache.jena.datatypes.DatatypeFormatException; +import org.apache.jena.geosparql.implementation.datatype.GMLDatatype; +import org.apache.jena.geosparql.implementation.datatype.WKTDatatype; +import org.apache.jena.geosparql.implementation.jts.CoordinateSequenceDimensions; +import org.junit.Test; +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.geom.GeometryFactory; + +public class GeometryCentroidTest { + @Test + public void ordinaryJtsGeometryProducesASerializableXyPoint() { + GeometryFactory factory = new GeometryFactory(); + Geometry line = factory.createLineString(new Coordinate[] { new Coordinate(0, 0, 7), new Coordinate(4, 0, 9) }); + GeometryWrapper result = new GeometryWrapper(line, WKTDatatype.URI).centroid(); + assertEquals(CoordinateSequenceDimensions.XY, result.getCoordinateSequenceDimensions()); + assertEquals(0, result.getTopologicalDimension()); + GeometryWrapper reparsed = GeometryWrapper.extract(result.asNodeValue()); + assertEquals(2, reparsed.getXYGeometry().getCoordinate().getX(), 0); + assertEquals(0, reparsed.getXYGeometry().getCoordinate().getY(), 0); + } + + @Test + public void nestedCollectionUsesNonemptyHighestDimensionalMembers() { + GeometryFactory factory = new GeometryFactory(); + Geometry line = factory.createLineString(new Coordinate[] { new Coordinate(0, 0), new Coordinate(4, 0) }); + Geometry nested = factory.createGeometryCollection(new Geometry[] { line, factory.createPolygon() }); + Geometry collection = factory.createGeometryCollection(new Geometry[] { + factory.createPoint(new Coordinate(90, 90)), nested + }); + GeometryWrapper source = new GeometryWrapper(collection, WKTDatatype.URI); + GeometryWrapper result = source.centroid(); + assertEquals(2, result.getXYGeometry().getCoordinate().getX(), 0); + assertEquals(0, result.getXYGeometry().getCoordinate().getY(), 0); + assertEquals(2, source.getParsingGeometry().getNumGeometries()); + } + + @Test + public void ordinaryJtsEmptyGeometryProducesASerializableEmptyPoint() { + GeometryWrapper result = new GeometryWrapper(new GeometryFactory().createPolygon(), WKTDatatype.URI).centroid(); + assertEquals(CoordinateSequenceDimensions.XY, result.getCoordinateSequenceDimensions()); + assertEquals("Point", result.getGeometryType()); + assertTrue(GeometryWrapper.extract(result.asNodeValue()).isEmpty()); + } + + @Test + public void emptyGmlInAThreeDimensionalCrsProducesAnEmptyPoint() { + GeometryWrapper result = GeometryWrapper.extract(CentroidTestData.EMPTY_GML_POLYGON_EPSG_4979, GMLDatatype.URI).centroid(); + assertEquals(CoordinateSequenceDimensions.XY, result.getCoordinateSequenceDimensions()); + assertEquals("Point", result.getGeometryType()); + assertEquals(GMLDatatype.URI, result.getGeometryDatatypeURI()); + assertEquals("http://www.opengis.net/def/crs/EPSG/0/4979", result.getSrsURI()); + assertTrue(result.isEmpty()); + } + + @Test + public void gmlRejectsAnXyResultInAThreeDimensionalCrs() { + GeometryWrapper source = GeometryWrapper.extract(CentroidTestData.GML_POINT_Z_EPSG_4979, GMLDatatype.URI); + assertThrows(DatatypeFormatException.class, source::centroid); + } +}