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 f784ca447a6feef699e790205bf16424a0055ae5 Author: Edmond Chuc <[email protected]> AuthorDate: Tue Sep 29 14:10:11 2026 +1000 GH-4265: Support geographic CRS area calculations --- .../geosparql/implementation/GeographicArea.java | 267 +++++++++++++++++++++ .../geosparql/implementation/GeometryArea.java | 58 ++++- .../geosparql/implementation/GeometryWrapper.java | 20 +- .../filter_functions/AreaFFTest.java | 58 ++++- .../filter_functions/MetricAreaFFTest.java | 39 ++- .../geosparql/implementation/GeometryAreaTest.java | 203 ++++++++++++++-- 6 files changed, 588 insertions(+), 57 deletions(-) diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/GeographicArea.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/GeographicArea.java new file mode 100644 index 0000000000..315f1b604f --- /dev/null +++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/GeographicArea.java @@ -0,0 +1,267 @@ +/* + * 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.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import org.apache.sis.geometry.DirectPosition2D; +import org.apache.sis.measure.Units; +import org.apache.sis.referencing.CRS; +import org.apache.sis.referencing.CommonCRS; +import org.apache.sis.referencing.GeodeticCalculator; +import org.apache.sis.referencing.GeodeticException; +import org.apache.sis.referencing.crs.AbstractCRS; +import org.apache.sis.referencing.crs.DefaultProjectedCRS; +import org.apache.sis.referencing.cs.AxesConvention; +import org.apache.sis.referencing.operation.DefaultConversion; +import org.apache.sis.referencing.operation.transform.DefaultMathTransformFactory; +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.GeometryFactory; +import org.locationtech.jts.geom.LinearRing; +import org.locationtech.jts.geom.Polygon; +import org.opengis.geometry.DirectPosition; +import org.opengis.parameter.ParameterValueGroup; +import org.opengis.referencing.crs.CoordinateReferenceSystem; +import org.opengis.referencing.crs.GeographicCRS; +import org.opengis.referencing.cs.CartesianCS; +import org.opengis.referencing.operation.MathTransform; +import org.opengis.referencing.operation.OperationMethod; +import org.opengis.referencing.operation.TransformException; +import org.opengis.util.FactoryException; + +/** Approximates ellipsoidal polygon area in square metres without changing the input geometry. */ +final class GeographicArea { + // These limits bound the projection and boundary approximation. They are not + // presented as an absolute area-error guarantee, which also depends on geometry. + private static final double MAX_CENTER_ANGLE_DEGREES = 89.0; + private static final double MAX_CHORD_ERROR_METRES = 0.25; + private static final double MAX_SEGMENT_METRES = 50_000.0; + private static final int MAX_RECURSION_DEPTH = 24; + private static final int MAX_COORDINATES = 50_000; + + private GeographicArea() { + } + + static double calculate(GeometryWrapper geometry) { + Polygon polygon = (Polygon) geometry.getParsingGeometry(); + CoordinateReferenceSystem horizontal = CRS.getHorizontalComponent(geometry.getSrsInfo().getCrs()); + if (!(horizontal instanceof GeographicCRS sourceCrs) + || sourceCrs.getCoordinateSystem().getDimension() != 2) { + throw new UnitsConversionException("Geographic area requires a two-dimensional geographic horizontal CRS."); + } + + try { + GeographicCRS normalizedCrs = (GeographicCRS) AbstractCRS.castOrCopy(sourceCrs) + .forConvention(AxesConvention.NORMALIZED); + MathTransform toNormalized = CRS.findOperation(sourceCrs, normalizedCrs, null).getMathTransform(); + double[] center = center(polygon, toNormalized); + + DefaultMathTransformFactory factory = DefaultMathTransformFactory.provider(); + OperationMethod method = factory.getOperationMethod("Lambert Azimuthal Equal Area"); + ParameterValueGroup parameters = method.getParameters().createValue(); + parameters.parameter("latitude_of_center").setValue(center[1]); + parameters.parameter("longitude_of_center").setValue(center[0]); + DefaultConversion conversion = new DefaultConversion(Map.of("name", "Local equal-area conversion"), + method, null, parameters); + CartesianCS cartesian = (CartesianCS) CommonCRS.WGS84.universal(0, 0).getCoordinateSystem(); + DefaultProjectedCRS projectedCrs = new DefaultProjectedCRS(Map.of("name", "Local equal-area CRS"), + sourceCrs, conversion, cartesian); + MathTransform projection = CRS.findOperation(sourceCrs, projectedCrs, null).getMathTransform(); + + CoordinateBudget budget = new CoordinateBudget(); + GeodeticCalculator calculator = GeodeticCalculator.create(sourceCrs); + return projectPolygon(polygon, sourceCrs, projection, calculator, budget).getArea(); + } catch (FactoryException | TransformException | GeodeticException e) { + throw new UnitsConversionException("Geographic area could not be calculated reliably.", e); + } + } + + private static double[] center(Polygon polygon, MathTransform toNormalized) throws TransformException { + List<double[]> vertices = new ArrayList<>(); + List<Double> longitudes = new ArrayList<>(); + double[] sum = new double[3]; + double minLatitude = Double.POSITIVE_INFINITY; + double maxLatitude = Double.NEGATIVE_INFINITY; + Coordinate[] shell = polygon.getExteriorRing().getCoordinates(); + for (int i = 0; i < shell.length - 1; i++) { + double[] lonLat = transform(shell[i], toNormalized); + double lon = Math.toRadians(lonLat[0]); + double lat = Math.toRadians(lonLat[1]); + if (!Double.isFinite(lon) || !Double.isFinite(lat) || Math.abs(lat) > Math.PI / 2) { + throw new UnitsConversionException("Geographic area requires finite coordinates within latitude limits."); + } + vertices.add(new double[] {lon, lat}); + if (vertices.size() > MAX_COORDINATES) { + throw new UnitsConversionException("Geographic area exceeds the supported edge approximation limit."); + } + longitudes.add(Math.atan2(Math.sin(lon), Math.cos(lon))); + minLatitude = Math.min(minLatitude, lat); + maxLatitude = Math.max(maxLatitude, lat); + sum[0] += Math.cos(lat) * Math.cos(lon); + sum[1] += Math.cos(lat) * Math.sin(lon); + sum[2] += Math.sin(lat); + } + Collections.sort(longitudes); + double largestGap = -1; + double arcStart = 0; + for (int i = 0; i < longitudes.size(); i++) { + double current = longitudes.get(i); + double next = i + 1 < longitudes.size() ? longitudes.get(i + 1) + : longitudes.get(0) + 2 * Math.PI; + if (next - current > largestGap) { + largestGap = next - current; + arcStart = next; + } + } + double centerLon = arcStart + (2 * Math.PI - largestGap) / 2; + double centerLat = (minLatitude + maxLatitude) / 2; + double bestMinimumCosine = minimumCosine(vertices, centerLon, centerLat); + double norm = Math.hypot(Math.hypot(sum[0], sum[1]), sum[2]); + if (norm >= 1e-9) { + double meanLon = Math.atan2(sum[1], sum[0]); + double meanLat = Math.atan2(sum[2], Math.hypot(sum[0], sum[1])); + double meanMinimumCosine = minimumCosine(vertices, meanLon, meanLat); + if (meanMinimumCosine > bestMinimumCosine) { + centerLon = meanLon; + centerLat = meanLat; + bestMinimumCosine = meanMinimumCosine; + } + } + double maxAngleCosine = Math.cos(Math.toRadians(MAX_CENTER_ANGLE_DEGREES)); + if (bestMinimumCosine < maxAngleCosine) { + throw new UnitsConversionException("Geographic area extends beyond one supported local hemisphere."); + } + return new double[] {Math.toDegrees(Math.atan2(Math.sin(centerLon), Math.cos(centerLon))), + Math.toDegrees(centerLat)}; + } + + private static double minimumCosine(List<double[]> vertices, double centerLon, double centerLat) { + double minimum = 1; + for (double[] vertex : vertices) { + double lon = vertex[0]; + double lat = vertex[1]; + double cosine = Math.sin(centerLat) * Math.sin(lat) + + Math.cos(centerLat) * Math.cos(lat) * Math.cos(lon - centerLon); + minimum = Math.min(minimum, cosine); + } + return minimum; + } + + private static Polygon projectPolygon(Polygon polygon, GeographicCRS sourceCrs, + MathTransform projection, GeodeticCalculator calculator, CoordinateBudget budget) throws TransformException { + GeometryFactory factory = polygon.getFactory(); + LinearRing shell = projectRing(polygon.getExteriorRing(), sourceCrs, projection, calculator, budget); + LinearRing[] holes = new LinearRing[polygon.getNumInteriorRing()]; + for (int i = 0; i < holes.length; i++) { + holes[i] = projectRing(polygon.getInteriorRingN(i), sourceCrs, projection, calculator, budget); + } + return factory.createPolygon(shell, holes); + } + + private static LinearRing projectRing(LinearRing ring, GeographicCRS sourceCrs, + MathTransform projection, GeodeticCalculator calculator, CoordinateBudget budget) throws TransformException { + Coordinate[] source = ring.getCoordinates(); + List<Coordinate> target = new ArrayList<>(); + target.add(project(source[0], projection)); + for (int i = 1; i < source.length; i++) { + Coordinate end = project(source[i], projection); + appendGeodesic(source[i - 1], source[i], target.get(target.size() - 1), end, + sourceCrs, projection, calculator, target, budget, 0); + } + target.set(target.size() - 1, new Coordinate(target.get(0))); + return ring.getFactory().createLinearRing(target.toArray(Coordinate[]::new)); + } + + private static void appendGeodesic(Coordinate start, Coordinate end, Coordinate projectedStart, + Coordinate projectedEnd, GeographicCRS sourceCrs, MathTransform projection, + GeodeticCalculator calculator, List<Coordinate> target, CoordinateBudget budget, int depth) throws TransformException { + calculator.setStartPoint(new DirectPosition2D(sourceCrs, start.x, start.y)); + calculator.setEndPoint(new DirectPosition2D(sourceCrs, end.x, end.y)); + double length = calculator.getGeodesicDistance(); + double metres = calculator.getDistanceUnit().getConverterTo(Units.METRE).convert(length); + if (!Double.isFinite(metres)) { + throw new UnitsConversionException("Geographic area has a non-finite polygon edge."); + } + if (metres == 0) { + add(target, projectedEnd, budget); + return; + } + double azimuth = calculator.getStartingAzimuth(); + calculator.setStartingAzimuth(azimuth); + calculator.setGeodesicDistance(length / 2); + DirectPosition middle = calculator.getEndPoint(); + Coordinate midpoint = new Coordinate(middle.getOrdinate(0), middle.getOrdinate(1)); + Coordinate projectedMidpoint = project(midpoint, projection); + double chordError = distanceToSegment(projectedMidpoint, projectedStart, projectedEnd); + if (metres > MAX_SEGMENT_METRES || chordError > MAX_CHORD_ERROR_METRES) { + if (depth >= MAX_RECURSION_DEPTH) { + throw new UnitsConversionException("Geographic area exceeds the supported edge approximation limit."); + } + appendGeodesic(start, midpoint, projectedStart, projectedMidpoint, + sourceCrs, projection, calculator, target, budget, depth + 1); + appendGeodesic(midpoint, end, projectedMidpoint, projectedEnd, + sourceCrs, projection, calculator, target, budget, depth + 1); + } else { + add(target, projectedEnd, budget); + } + } + + private static void add(List<Coordinate> target, Coordinate point, CoordinateBudget budget) { + if (++budget.count > MAX_COORDINATES) { + throw new UnitsConversionException("Geographic area exceeds the supported edge approximation limit."); + } + target.add(point); + } + + private static double distanceToSegment(Coordinate point, Coordinate start, Coordinate end) { + double dx = end.x - start.x; + double dy = end.y - start.y; + double lengthSquared = dx * dx + dy * dy; + if (lengthSquared == 0) { + return point.distance(start); + } + double fraction = Math.max(0, Math.min(1, + ((point.x - start.x) * dx + (point.y - start.y) * dy) / lengthSquared)); + return Math.hypot(point.x - start.x - fraction * dx, + point.y - start.y - fraction * dy); + } + + private static Coordinate project(Coordinate point, MathTransform transform) throws TransformException { + double[] projected = transform(point, transform); + if (!Double.isFinite(projected[0]) || !Double.isFinite(projected[1])) { + throw new UnitsConversionException("Geographic area has a coordinate outside the local projection."); + } + return new Coordinate(projected[0], projected[1]); + } + + private static double[] transform(Coordinate point, MathTransform transform) throws TransformException { + double[] result = new double[2]; + transform.transform(new double[] {point.x, point.y}, 0, result, 0, 1); + return result; + } + + private static final class CoordinateBudget { + int count; + } +} diff --git a/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/GeometryArea.java b/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/GeometryArea.java index 501e9f40d8..3e0cf2e1a1 100644 --- a/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/GeometryArea.java +++ b/jena-geosparql/src/main/java/org/apache/jena/geosparql/implementation/GeometryArea.java @@ -24,20 +24,26 @@ import javax.measure.IncommensurableException; import javax.measure.Unit; import javax.measure.quantity.Area; +import org.apache.jena.geosparql.configuration.GeoSPARQLConfig; import org.apache.sis.measure.Quantities; +import org.apache.sis.measure.Units; import org.apache.sis.referencing.CRS; +import org.locationtech.jts.geom.Coordinate; import org.locationtech.jts.geom.Geometry; -import org.locationtech.jts.geom.MultiPolygon; import org.locationtech.jts.geom.Polygon; import org.opengis.referencing.crs.CoordinateReferenceSystem; +import org.opengis.referencing.crs.GeographicCRS; +import org.opengis.referencing.cs.CartesianCS; import org.opengis.referencing.cs.CoordinateSystem; /** - * Calculates planar area for polygonal geometries and converts to target area units. + * Calculates the area of a Polygon and converts to target area units. * - * <p>Area is calculated for {@link Polygon} and {@link MultiPolygon} geometries; - * empty geometries and non-polygonal types return zero. Non-empty polygonal geometries - * require a non-geographic horizontal CRS with equivalent linear units on both axes. + * <p>Empty geometries and types other than {@link Polygon} return zero, including + * collections containing polygons. Non-empty geometries require finite X/Y + * coordinates. For geographic CRSs, area is approximated on the source ellipsoid + * using a local equal-area projection. Other non-empty Polygons require a Cartesian + * horizontal CRS with equivalent linear units on both axes. */ final class GeometryArea { private GeometryArea() { @@ -46,19 +52,44 @@ final class GeometryArea { static double calculate(GeometryWrapper geometry, String targetUnitUri) { Unit<Area> targetUnit = AreaUnitsOfMeasure.getUnit(targetUnitUri); Geometry xyGeometry = geometry.getXYGeometry(); - if (!(xyGeometry instanceof Polygon || xyGeometry instanceof MultiPolygon) - || xyGeometry.isEmpty()) { + for (Coordinate coordinate : xyGeometry.getCoordinates()) { + if (!coordinate.isValid()) { + throw new UnitsConversionException("Area requires finite X/Y coordinates."); + } + } + // GeoSPARQL 1.1 requires zero for every geometry type other than Polygon. + if (!(xyGeometry instanceof Polygon polygon) || polygon.isEmpty()) { return 0.0; } - if (geometry.getSrsInfo().isGeographic()) { - throw new UnitsConversionException("Area is not supported for geographic coordinate reference systems."); + double sourceArea; + Unit<Area> sourceUnit; + if (CRS.getHorizontalComponent(geometry.getSrsInfo().getCrs()) instanceof GeographicCRS) { + if (!geometry.getSrsInfo().isSRSRecognised()) { + throw new UnitsConversionException("Area requires a recognised geographic coordinate reference system."); + } + if (!GeoSPARQLConfig.ALLOW_GEOMETRY_SRS_TRANSFORMATION) { + throw new UnitsConversionException("Geographic area requires geometry SRS transformation to be enabled."); + } + sourceArea = GeographicArea.calculate(geometry); + sourceUnit = Units.SQUARE_METRE; + } else { + sourceUnit = equivalentHorizontalAxisUnits(geometry).getUnit() + .pow(2).asType(Area.class); + sourceArea = polygon.getArea(); } - Unit<Area> sourceUnit = equivalentHorizontalAxisUnits(geometry).getUnit() - .pow(2).asType(Area.class); - return Quantities.create(xyGeometry.getArea(), sourceUnit) + double area = Quantities.create(sourceArea, sourceUnit) .to(targetUnit).getValue().doubleValue(); + if (!Double.isFinite(area)) { + throw new UnitsConversionException("Area result is not finite."); + } + return area; } + /** + * Returns the common linear unit of a two-dimensional Cartesian horizontal CRS. + * JTS calculates area from coordinate values as though the axes were orthogonal. + * This method rejects non-Cartesian axes because it does not correct for skew. + */ private static UnitsOfMeasure equivalentHorizontalAxisUnits(GeometryWrapper geometry) { CoordinateReferenceSystem horizontalCrs = CRS.getHorizontalComponent( geometry.getSrsInfo().getCrs()); @@ -68,6 +99,9 @@ final class GeometryArea { "Area requires a two-dimensional horizontal source coordinate system."); } CoordinateSystem coordinateSystem = horizontalCrs.getCoordinateSystem(); + if (!(coordinateSystem instanceof CartesianCS)) { + throw new UnitsConversionException("Area requires Cartesian horizontal source axes."); + } Unit<?> firstAxisUnit = coordinateSystem.getAxis(0).getUnit(); Unit<?> secondAxisUnit = coordinateSystem.getAxis(1).getUnit(); try { 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 411d5319e9..3e7c8b70c5 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 @@ -810,20 +810,30 @@ public class GeometryWrapper implements Serializable { return new GeometryWrapper(parsingGeo, xyGeo, srsInfo.getSrsURI(), geometryDatatypeURI, dimensionInfo); } - /** Polygon or MultiPolygon area defaulting to square metres. */ + /** Area of a {@link org.locationtech.jts.geom.Polygon}, defaulting to square metres. */ public double area() { return area(Unit_URI.SQUARE_METRE_QUDT); } /** - * Returns Polygon or MultiPolygon area in the requested area unit. + * Returns the area of a {@link org.locationtech.jts.geom.Polygon} in the requested area unit. * - * Empty and non-polygonal geometries return zero. Nonempty polygons require - * a non-geographic horizontal CRS with equivalent linear units on both axes. + * <p>Polygon holes are subtracted. Lines have zero area even when their + * coordinates close to form a ring. + * + * <p>Empty geometries and types other than Polygon return zero, including + * collections containing polygons. Geographic polygon edges follow shortest + * geodesics on the source ellipsoid; their area is approximated with a local + * equal-area projection. The geographic CRS must be recognised and the + * geometry SRS transformation setting must be enabled. Polygons too large + * for a reliable local projection produce an error. Other nonempty polygons + * require a Cartesian horizontal CRS with equivalent linear units on both axes. + * Nonempty geometries with non-finite X/Y coordinates produce an error. * * @param unitsURI URI of an explicit area unit. * @throws org.apache.jena.geosparql.implementation.registry.UnitsURIException if the unit URI is unknown. - * @throws UnitsConversionException if the source CRS or target units are unsupported. + * @throws UnitsConversionException if the source CRS or target units are unsupported, + * or the geometry's X/Y coordinates or calculated area are non-finite. */ public double area(String unitsURI) { return GeometryArea.calculate(this, unitsURI); diff --git a/jena-geosparql/src/test/java/org/apache/jena/geosparql/geof/nontopological/filter_functions/AreaFFTest.java b/jena-geosparql/src/test/java/org/apache/jena/geosparql/geof/nontopological/filter_functions/AreaFFTest.java index 267a4386e3..924e7ee60c 100644 --- a/jena-geosparql/src/test/java/org/apache/jena/geosparql/geof/nontopological/filter_functions/AreaFFTest.java +++ b/jena-geosparql/src/test/java/org/apache/jena/geosparql/geof/nontopological/filter_functions/AreaFFTest.java @@ -23,8 +23,10 @@ package org.apache.jena.geosparql.geof.nontopological.filter_functions; import static org.junit.Assert.assertEquals; 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.datatype.GMLDatatype; import org.apache.jena.geosparql.implementation.datatype.WKTDatatype; import org.apache.jena.geosparql.implementation.vocabulary.Unit_URI; import org.apache.jena.graph.Node; @@ -53,19 +55,20 @@ public class AreaFFTest { } @Test - public void nonPolygonalAndEmptyGeometriesReturnZero() { - for (String wkt : new String[] { "POINT (1 2)", PROJECTED + "POLYGON EMPTY" }) { + public void nonPolygonAndEmptyGeometriesReturnZero() { + for (String wkt : new String[] { "POINT (1 2)", PROJECTED + "POLYGON EMPTY", + PROJECTED + "MULTIPOLYGON(((0 0,2 0,2 2,0 2,0 0)))", + "GEOMETRYCOLLECTION(POLYGON((0 0,2 0,2 2,0 2,0 0)))" }) { assertEquals(NodeValue.makeDouble(0).asNode(), evaluate("'" + wkt + "'^^geo:wktLiteral", SQUARE_METRE)); } } @Test - public void geographicPolygonRaisesExpressionError() { - String wkt = "POLYGON ((0 0, 1 0, 1 1, 0 0))"; - assertNull(evaluate("'" + wkt + "'^^geo:wktLiteral", SQUARE_METRE)); - assertThrows(ExprEvalException.class, - () -> function.exec(NodeValue.makeNode(wkt, WKTDatatype.INSTANCE), - NodeValue.makeNode(NodeFactory.createURI(Unit_URI.SQUARE_METRE_QUDT)))); + public void geographicPolygonReturnsArea() { + String wkt = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))"; + Node result = evaluate("'" + wkt + "'^^geo:wktLiteral", SQUARE_METRE); + double area = ((Number) result.getLiteralValue()).doubleValue(); + assertTrue(area > 12_000_000_000.0 && area < 12_500_000_000.0); } @Test @@ -79,6 +82,22 @@ public class AreaFFTest { assertEquals(NodeValue.makeDouble(12).asNode(), evaluate(gml, SQUARE_METRE)); } + @Test + public void geographicGmlUsesDeclaredAxisOrder() { + String wkt = "'<http://www.opengis.net/def/crs/EPSG/0/4326> " + + "POLYGON((0 0,0 2,1 2,1 0,0 0))'^^geo:wktLiteral"; + String gml = """ + '<gml:Polygon xmlns:gml="http://www.opengis.net/gml/3.2" + srsName="http://www.opengis.net/def/crs/EPSG/0/4326"> + <gml:exterior><gml:LinearRing><gml:posList>0 0 0 2 1 2 1 0 0 0</gml:posList></gml:LinearRing></gml:exterior> + </gml:Polygon>'^^geo:gmlLiteral + """.replace("\n", " "); + + double expected = ((Number) evaluate(wkt, SQUARE_METRE).getLiteralValue()).doubleValue(); + double actual = ((Number) evaluate(gml, SQUARE_METRE).getLiteralValue()).doubleValue(); + assertEquals(expected, actual, expected * 1e-8); + } + @Test public void malformedOrUnboundGeometryLeavesBindUnbound() { for (String value : new String[] { "42", "'POINT (1 2)'", "<urn:geometry>", "'invalid'^^geo:wktLiteral", "?missing" }) { @@ -95,6 +114,29 @@ public class AreaFFTest { } } + @Test + public void nonFiniteCoordinatesAndAreaRaiseExpressionErrors() { + NodeValue squareMetre = NodeValue.makeNode(NodeFactory.createURI(Unit_URI.SQUARE_METRE_QUDT)); + for (String wkt : new String[] { + PROJECTED + "LINESTRING(0 0,1e309 1)", + PROJECTED + "POLYGON((0 0,1 0,1e309 1,0 1,0 0))", + PROJECTED + "POLYGON((0 0,1e200 0,1e200 1e200,0 1e200,0 0))" }) { + assertThrows(wkt, ExprEvalException.class, + () -> function.exec(NodeValue.makeNode(wkt, WKTDatatype.INSTANCE), squareMetre)); + assertNull(wkt, evaluate("'" + wkt + "'^^geo:wktLiteral", SQUARE_METRE)); + } + + String gml = """ + <gml:Polygon xmlns:gml="http://www.opengis.net/gml/3.2" + srsName="http://www.opengis.net/def/crs/EPSG/0/27700"> + <gml:exterior><gml:LinearRing><gml:posList>0 0 1 0 NaN 1 0 1 0 0</gml:posList></gml:LinearRing></gml:exterior> + </gml:Polygon> + """.replace("\n", " "); + assertThrows(ExprEvalException.class, + () -> function.exec(NodeValue.makeNode(gml, GMLDatatype.INSTANCE), squareMetre)); + assertNull(evaluate("'" + gml + "'^^geo:gmlLiteral", SQUARE_METRE)); + } + @Test public void wrongArityIsRejectedAtQueryBuild() { String geometry = "'POINT EMPTY'^^geo:wktLiteral"; diff --git a/jena-geosparql/src/test/java/org/apache/jena/geosparql/geof/nontopological/filter_functions/MetricAreaFFTest.java b/jena-geosparql/src/test/java/org/apache/jena/geosparql/geof/nontopological/filter_functions/MetricAreaFFTest.java index af9019e246..d7b73a60eb 100644 --- a/jena-geosparql/src/test/java/org/apache/jena/geosparql/geof/nontopological/filter_functions/MetricAreaFFTest.java +++ b/jena-geosparql/src/test/java/org/apache/jena/geosparql/geof/nontopological/filter_functions/MetricAreaFFTest.java @@ -23,8 +23,10 @@ package org.apache.jena.geosparql.geof.nontopological.filter_functions; import static org.junit.Assert.assertEquals; 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.datatype.GMLDatatype; import org.apache.jena.geosparql.implementation.datatype.WKTDatatype; import org.apache.jena.graph.Node; import org.apache.jena.graph.NodeFactory; @@ -50,17 +52,20 @@ public class MetricAreaFFTest { } @Test - public void nonPolygonalAndEmptyGeometriesReturnZero() { - for (String wkt : new String[] { "POINT (1 2)", PROJECTED + "POLYGON EMPTY" }) { + public void nonPolygonAndEmptyGeometriesReturnZero() { + for (String wkt : new String[] { "POINT (1 2)", PROJECTED + "POLYGON EMPTY", + PROJECTED + "MULTIPOLYGON(((0 0,2 0,2 2,0 2,0 0)))", + "GEOMETRYCOLLECTION(POLYGON((0 0,2 0,2 2,0 2,0 0)))" }) { assertEquals(NodeValue.makeDouble(0).asNode(), evaluate("'" + wkt + "'^^geo:wktLiteral")); } } @Test - public void geographicPolygonRaisesExpressionError() { - String wkt = "POLYGON ((0 0, 1 0, 1 1, 0 0))"; - assertNull(evaluate("'" + wkt + "'^^geo:wktLiteral")); - assertThrows(ExprEvalException.class, () -> exec(NodeValue.makeNode(wkt, WKTDatatype.INSTANCE))); + public void geographicPolygonReturnsSquareMetres() { + String wkt = "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))"; + Node result = evaluate("'" + wkt + "'^^geo:wktLiteral"); + double area = ((Number) result.getLiteralValue()).doubleValue(); + assertTrue(area > 12_000_000_000.0 && area < 12_500_000_000.0); } @Test @@ -89,6 +94,28 @@ public class MetricAreaFFTest { } } + @Test + public void nonFiniteCoordinatesAndAreaRaiseExpressionErrors() { + for (String wkt : new String[] { + PROJECTED + "LINESTRING(0 0,1e309 1)", + PROJECTED + "POLYGON((0 0,1 0,1e309 1,0 1,0 0))", + PROJECTED + "POLYGON((0 0,1e200 0,1e200 1e200,0 1e200,0 0))" }) { + assertThrows(wkt, ExprEvalException.class, + () -> function.exec(NodeValue.makeNode(wkt, WKTDatatype.INSTANCE))); + assertNull(wkt, evaluate("'" + wkt + "'^^geo:wktLiteral")); + } + + String gml = """ + <gml:Polygon xmlns:gml="http://www.opengis.net/gml/3.2" + srsName="http://www.opengis.net/def/crs/EPSG/0/27700"> + <gml:exterior><gml:LinearRing><gml:posList>0 0 1 0 NaN 1 0 1 0 0</gml:posList></gml:LinearRing></gml:exterior> + </gml:Polygon> + """.replace("\n", " "); + assertThrows(ExprEvalException.class, + () -> function.exec(NodeValue.makeNode(gml, GMLDatatype.INSTANCE))); + assertNull(evaluate("'" + gml + "'^^geo:gmlLiteral")); + } + @Test public void wrongArityIsRejectedAtQueryBuild() { String geometry = "'POINT EMPTY'^^geo:wktLiteral"; diff --git a/jena-geosparql/src/test/java/org/apache/jena/geosparql/implementation/GeometryAreaTest.java b/jena-geosparql/src/test/java/org/apache/jena/geosparql/implementation/GeometryAreaTest.java index c39434160d..c4c8b7f3f0 100644 --- a/jena-geosparql/src/test/java/org/apache/jena/geosparql/implementation/GeometryAreaTest.java +++ b/jena-geosparql/src/test/java/org/apache/jena/geosparql/implementation/GeometryAreaTest.java @@ -21,6 +21,7 @@ package org.apache.jena.geosparql.implementation; import org.apache.jena.geosparql.configuration.GeoSPARQLConfig; +import org.apache.jena.geosparql.implementation.datatype.GMLDatatype; import org.apache.jena.geosparql.implementation.datatype.WKTDatatype; import org.apache.jena.geosparql.implementation.registry.UnitsURIException; import org.apache.jena.geosparql.implementation.vocabulary.Unit_URI; @@ -33,10 +34,16 @@ import java.util.List; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; public class GeometryAreaTest { private static final String EPSG_32634 = "http://www.opengis.net/def/crs/EPSG/0/32634"; private static final String EPSG_2227 = "http://www.opengis.net/def/crs/EPSG/0/2227"; + private static final String EPSG_4326 = "http://www.opengis.net/def/crs/EPSG/0/4326"; + private static final String EPSG_4267 = "http://www.opengis.net/def/crs/EPSG/0/4267"; + private static final String EPSG_4275 = "http://www.opengis.net/def/crs/EPSG/0/4275"; + private static final String EPSG_4807 = "http://www.opengis.net/def/crs/EPSG/0/4807"; + private static final String EPSG_4979 = "http://www.opengis.net/def/crs/EPSG/0/4979"; private static final String UNKNOWN_UNIT = "http://example.com/unit/unknown"; @BeforeClass @@ -54,16 +61,12 @@ public class GeometryAreaTest { } @Test - public void areaAccumulatesMultiPolygonShellsAndHoles() { - GeometryWrapper multiPolygon = geometry("<" + EPSG_32634 + "> MULTIPOLYGON(" - + "((500000 4600000,500010 4600000,500010 4600010," - + "500000 4600010,500000 4600000)," - + "(500004 4600004,500006 4600004,500006 4600006," - + "500004 4600006,500004 4600004))," - + "((500020 4600000,500023 4600000,500023 4600004," - + "500020 4600004,500020 4600000)))"); + public void polygonHolesAreExcluded() { + GeometryWrapper polygon = geometry("<" + EPSG_32634 + "> POLYGON(" + + "(0 0,10 0,10 10,0 10,0 0)," + + "(4 4,6 4,6 6,4 6,4 4))"); - assertEquals(108.0, multiPolygon.area(), 0.0); + assertEquals(96.0, polygon.area(), 0.0); } @Test @@ -86,17 +89,57 @@ public class GeometryAreaTest { } @Test - public void areaIneligibleGeometryTypesReturnZeroWithoutSourceCrsCalculation() { + public void nonPolygonTypesReturnZeroWithoutSourceCrsCalculation() { for (String wkt : List.of( "POINT(1 1)", "LINESTRING(0 0,1 1)", "MULTIPOINT((0 0),(1 1))", "MULTILINESTRING((0 0,1 1),(2 2,3 3))", - "GEOMETRYCOLLECTION(POLYGON((0 0,0 1,1 1,1 0,0 0)))")) { + "MULTIPOLYGON(((0 0,2 0,2 2,0 2,0 0)))", + "GEOMETRYCOLLECTION(POINT(1 1),LINESTRING(0 0,1 1))", + "GEOMETRYCOLLECTION(POLYGON EMPTY,POINT(1 1))", + "GEOMETRYCOLLECTION(POLYGON((0 0,2 0,2 2,0 2,0 0)))")) { assertEquals(0.0, geometry(wkt).area(), 0.0); + assertEquals(0.0, geometry("<" + EPSG_32634 + "> " + wkt).area(), 0.0); } } + @Test + public void nonFiniteXYCoordinatesAreRejected() { + for (String wkt : List.of( + "LINESTRING(0 0,1e309 1)", + "<" + EPSG_32634 + "> POLYGON((0 0,1 0,1e309 1,0 1,0 0))")) { + assertThrows(UnitsConversionException.class, () -> geometry(wkt).area()); + } + + String gml = """ + <gml:Polygon xmlns:gml="http://www.opengis.net/gml/3.2" + srsName="http://www.opengis.net/def/crs/EPSG/0/32634"> + <gml:exterior><gml:LinearRing><gml:posList>0 0 1 0 NaN 1 0 1 0 0</gml:posList></gml:LinearRing></gml:exterior> + </gml:Polygon> + """; + GeometryWrapper polygon = GeometryWrapper.extract(gml, GMLDatatype.URI); + assertThrows(UnitsConversionException.class, polygon::area); + } + + @Test + public void nonFinitePlanarAreaResultIsRejected() { + GeometryWrapper polygon = geometry("<" + EPSG_32634 + "> POLYGON((" + + "0 0,1e200 0,1e200 1e200,0 1e200,0 0))"); + + assertThrows(UnitsConversionException.class, polygon::area); + } + + @Test + public void areaOverflowDuringUnitConversionIsRejected() { + GeometryWrapper polygon = geometry("<" + EPSG_32634 + "> POLYGON((" + + "0 0,2e151 0,2e151 2e151,0 2e151,0 0))"); + + assertTrue(Double.isFinite(polygon.area())); + assertThrows(UnitsConversionException.class, + () -> polygon.area(Unit_URI.SQUARE_MILLIMETRE_QUDT)); + } + @Test public void eligibleAreaRejectsDifferentHorizontalAxisScales() throws Exception { GeometryWrapper polygon = geometryWithCrs( @@ -110,6 +153,31 @@ public class GeometryAreaTest { polygon::area); } + @Test + public void eligibleAreaRejectsNonCartesianHorizontalAxes() throws Exception { + GeometryWrapper polygon = geometryWithCrs( + "<" + EPSG_32634 + "> POLYGON((0 0,1 0,1 1,0 1,0 0))", + CRS.fromWKT("ENGCRS[\"Skewed axes\"," + + "EDATUM[\"Engineering datum\"],CS[affine,2]," + + "AXIS[\"x\",east,ORDER[1],LENGTHUNIT[\"metre\",1]]," + + "AXIS[\"y\",northEast,ORDER[2],LENGTHUNIT[\"metre\",1]]]")); + + UnitsConversionException error = assertThrows(UnitsConversionException.class, polygon::area); + assertTrue(error.getMessage().contains("Cartesian")); + } + + @Test + public void cartesianEngineeringCrsStillSupportsArea() throws Exception { + GeometryWrapper polygon = geometryWithCrs( + "<" + EPSG_32634 + "> POLYGON((0 0,1 0,1 1,0 1,0 0))", + CRS.fromWKT("ENGCRS[\"Cartesian engineering grid\"," + + "EDATUM[\"Engineering datum\"],CS[Cartesian,2]," + + "AXIS[\"x\",east,ORDER[1],LENGTHUNIT[\"metre\",1]]," + + "AXIS[\"y\",north,ORDER[2],LENGTHUNIT[\"metre\",1]]]")); + + assertEquals(1.0, polygon.area(), 0.0); + } + @Test public void projectedNonMetreAreaConvertsTheSourceAreaQuantity() { GeometryWrapper polygon = geometry("<" + EPSG_2227 + "> POLYGON((" @@ -120,16 +188,6 @@ public class GeometryAreaTest { polygon.area(), 1e-12); } - @Test - public void multiPolygonAreaIsAccumulatedBeforeUnitConversion() { - GeometryWrapper multiPolygon = geometry("<" + EPSG_32634 + "> MULTIPOLYGON(" - + "((0 0,0.02 0,0.02 0.02,0 0.02,0 0))," - + "((1 0,1.02 0,1.02 0.02,1 0.02,1 0)))"); - - assertEquals(0.0000000008, - multiPolygon.area(Unit_URI.SQUARE_KILOMETRE_QUDT), 1e-20); - } - @Test public void compoundCrsUsesItsTwoDimensionalHorizontalAxisUnits() throws Exception { CoordinateReferenceSystem compoundCrs = CRS.compound( @@ -142,11 +200,104 @@ public class GeometryAreaTest { } @Test - public void eligibleGeographicAreaIsRejected() { - for (String wkt : List.of( - "POLYGON((0 0,0 1,1 1,1 0,0 0))", - "MULTIPOLYGON(((0 0,0 1,1 1,1 0,0 0)))")) { - assertThrows(UnitsConversionException.class, geometry(wkt)::area); + public void geographicAreaUsesTheDeclaredAxisOrderAndConvertsUnits() { + GeometryWrapper crs84 = geometry("POLYGON((0 0,2 0,2 1,0 1,0 0))"); + GeometryWrapper epsg4326 = geometry("<" + EPSG_4326 + "> POLYGON((" + + "0 0,0 2,1 2,1 0,0 0))"); + + double squareMetres = crs84.area(); + assertTrue(squareMetres > 24_000_000_000.0 && squareMetres < 25_000_000_000.0); + assertEquals(squareMetres, epsg4326.area(), squareMetres * 1e-8); + assertEquals(squareMetres / 1_000_000, crs84.area(Unit_URI.SQUARE_KILOMETRE_QUDT), 1e-6); + } + + @Test + public void geographicPolygonExcludesHoles() { + String square = "POLYGON((0 0,1 0,1 1,0 1,0 0))"; + GeometryWrapper polygon = geometry(square); + GeometryWrapper withHole = geometry("POLYGON((0 0,1 0,1 1,0 1,0 0)," + + "(0.25 0.25,0.25 0.75,0.75 0.75,0.75 0.25,0.25 0.25))"); + + // WGS84 geodesic reference areas from pyproj.Geod.polygon_area_perimeter. + assertEquals(12_308_778_361.469, polygon.area(), 100_000.0); + assertEquals(9_231_614_224.815, withHole.area(), 100_000.0); + } + + @Test + public void geographicAreaSupportsDatelineAndPolarPolygons() { + double dateline = geometry("POLYGON((179 10,-179 10,-179 11,179 11,179 10))").area(); + double polar = geometry("POLYGON((-90 80,0 80,90 80,180 80,-90 80))").area(); + + // WGS84 geodesic references from pyproj.Geod.polygon_area_perimeter; + // tolerances allow for projected-edge approximation. + assertEquals(24_218_606_783.655, dateline, 250_000.0); + assertEquals(2_507_270_031_169.875, polar, 2_500_000.0); + } + + @Test + public void geographicAreaUsesTheSourceDatum() { + String square = "POLYGON((0 0,1 0,1 1,0 1,0 0))"; + double wgs84 = geometry(square).area(); + double nad27 = geometry("<" + EPSG_4267 + "> " + square).area(); + + assertTrue(nad27 > 0 && Double.isFinite(nad27)); + assertTrue(Math.abs(nad27 - wgs84) > 10_000.0); + } + + @Test + public void parisPrimeMeridianAndGradUnitsMatchGreenwichDegrees() { + // NTF (Paris) uses grads and the Paris meridian; NTF uses degrees and Greenwich. + GeometryWrapper paris = geometry("<" + EPSG_4807 + "> POLYGON((" + + "48 0,48 1,49 1,49 0,48 0))"); + GeometryWrapper greenwich = geometry("<" + EPSG_4275 + "> POLYGON((" + + "43.2 2.33722917,43.2 3.23722917,44.1 3.23722917," + + "44.1 2.33722917,43.2 2.33722917))"); + + double area = greenwich.area(); + assertTrue(area > 0); + assertEquals(area, paris.area(), area * 1e-5); + } + + @Test + public void geographicThreeDimensionalCrsIgnoresHeight() { + GeometryWrapper twoDimensional = geometry("<" + EPSG_4326 + "> POLYGON((" + + "0 0,0 2,1 2,1 0,0 0))"); + GeometryWrapper threeDimensional = geometry("<" + EPSG_4979 + "> POLYGON Z ((" + + "0 0 10,0 2 20,1 2 30,1 0 40,0 0 10))"); + + assertEquals(twoDimensional.area(), threeDimensional.area(), twoDimensional.area() * 1e-8); + } + + @Test + public void compoundGeographicCrsUsesItsHorizontalComponent() throws Exception { + CoordinateReferenceSystem compoundCrs = CRS.compound( + CRS.forCode(EPSG_4326), CRS.forCode("EPSG:5703")); + GeometryWrapper polygon = geometryWithCrs("<" + EPSG_32634 + "> POLYGON Z ((" + + "0 0 5,0 1 5,1 1 5,1 0 5,0 0 5))", compoundCrs); + + assertEquals(geometry("POLYGON((0 0,1 0,1 1,0 1,0 0))").area(), + polygon.area(), 100_000.0); + } + + @Test + public void geographicAreaRejectsPolygonBeyondOneLocalHemisphere() { + GeometryWrapper wide = geometry("POLYGON((0 0,179.9 0,179.9 1,0 1,0 0))"); + + assertThrows(UnitsConversionException.class, wide::area); + } + + @Test + public void geographicAreaRejectsUnknownCrsAndDisabledTransformation() { + GeometryWrapper unknown = geometry("<http://example.com/crs/unknown> " + + "POLYGON((0 0,1 0,1 1,0 1,0 0))"); + assertThrows(UnitsConversionException.class, unknown::area); + + GeoSPARQLConfig.allowGeometrySRSTransformation(false); + try { + assertThrows(UnitsConversionException.class, + () -> geometry("POLYGON((0 0,1 0,1 1,0 1,0 0))").area()); + } finally { + GeoSPARQLConfig.allowGeometrySRSTransformation(true); } }
