diff --git a/CHANGELOG.md b/CHANGELOG.md index ffb76b724..19491cff7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,8 @@ Changelog ### new features -* Allow to flexibly combine (automatic) aggregation methods (like `aggregateByGeometry(…)` or `aggregateByTimestamp()`) with each other and with `filter` or `map`/`flatMap`, regardless of the order of the applied operations ([#451]) +* allow to flexibly combine (automatic) aggregation methods (like `aggregateByGeometry(…)` or `aggregateByTimestamp()`) with each other and with `filter` or `map`/`flatMap`, regardless of the order of the applied operations ([#451]) +* add new OSHDB filters: `perimeter`, `geometry.vertices`, `geometry.outers`, `geometry.inners`, `geometry.roundness` and `geometry.squareness` ([#436]) ### bugfixes @@ -36,6 +37,7 @@ Changelog [#419]: https://github.com/GIScience/oshdb/pull/419 [#424]: https://github.com/GIScience/oshdb/pull/424 [#433]: https://github.com/GIScience/oshdb/issues/433 +[#436]: https://github.com/GIScience/oshdb/pull/436 [#438]: https://github.com/GIScience/oshdb/pull/438 [#441]: https://github.com/GIScience/oshdb/pull/441 [#443]: https://github.com/GIScience/oshdb/pull/443 @@ -45,6 +47,7 @@ Changelog [#453]: https://github.com/GIScience/oshdb/pull/453 [#454]: https://github.com/GIScience/oshdb/pull/454 [#459]: https://github.com/GIScience/oshdb/pull/459 +[#467]: https://github.com/GIScience/oshdb/pull/467 @@ -58,7 +61,6 @@ Changelog [#426]: https://github.com/GIScience/oshdb/pull/426 [#428]: https://github.com/GIScience/oshdb/pull/428 - ## 0.7.1 * fix a bug where contribution-based filters are not applied when used in an and/or operation. ([#409]) diff --git a/oshdb-filter/README.md b/oshdb-filter/README.md index a79b9c901..ff6bd2fb2 100644 --- a/oshdb-filter/README.md +++ b/oshdb-filter/README.md @@ -73,6 +73,12 @@ Filters are defined in textual form. A filter expression can be composed out of | `geometry:geom-type` | matches anything which has a geometry of the given type (_point_, _line_, _polygon_, or _other_) | `geometry:polygon` | | `area:(from..to-range)` | matches all features with an area that falls into the given range/interval given as two numbers in decimal or scientific notation separated by `..`. The values are interpreted as square meters (`m²`). The lower or upper limit of the interval may be omitted to select features having an area up to or starting from the given value, respectively. | `area:(123.4..1E6)` | | `length:(from..to-range)` | matches all features with a length that falls into the given range/interval given as two numbers in decimal or scientific notation separated by `..`. The values are interpreted as meters (`m`). The lower or upper limit of the interval may be omitted to select features having an area up to or starting from the given value, respectively. | `length:(100..)` | +| `perimeter:(from..to-range)` | matches all features with a perimeter that falls into the given range/interval given as two numbers in decimal or scientific notation separated by `..`. The values are interpreted as meters (`m`). The lower or upper limit of the interval may be omitted to select features having an area up to or starting from the given value, respectively. | `perimeter:(100..)` | +| `geometry.vertices:(from..to-range)` | matches features by the number of points they consist of (the range is given as two integers separated by `..`). | `geometry.vertices:(1..10)` | +| `geometry.outers:number` or `geometry.outers:(from..to-range)` | matches features by the number of outer rings they consist of (the range is given as two integers separated by `..`) | `geometry.outers:1` or `geometry.outers:(2..)` | +| `geometry.inners:number` or `geometry.inners:(from..to-range)` | matches features by the number of holes (inner rings) they have (the range is given as two integers separated by `..`) | `geometry.inners:0` or `geometry.inners:(1..)` | +| `geometry.roundness:(from..to-range)` | matches polygons which have a _roundness_ (or _compactness_) in the given range of values (given as two numbers in decimal or scientific notation separated by `..`). This is using the ["Polsby–Popper test" score](https://en.wikipedia.org/wiki/Polsby%E2%80%93Popper_test) where all values fall in the interval 0 to 1 and 1 represents a perfect circle. | `geometry.roundness:(0.8..)` | +| `geometry.squareness:(from..to-range)` | matches features which have a _squareness_ in the given range of values (given as two numbers in decimal or scientific notation separated by `..`). This is using the [rectilinearity measurement by Žunić and Rosin](https://www.researchgate.net/publication/221304067_A_Rectilinearity_Measurement_for_Polygons) where all values fall in the interval 0 to 1 and 1 represents a perfectly rectilinear geometry. | `geometry.squareness:(0.8..)` | | `changeset:id` | matches OSM contributions performed in the given OSM changeset. Can only be used in queries using the `OSMContributionView`. | `changeset:42` | | `changeset:(list,of,ids)` | matches OSM contributions performed in one of the given OSM changeset ids. Can only be used in queries using the `OSMContributionView`. | `changeset:(1,42,100)` | | `changeset:(from..to-range)` | matches OSM contributions performed in an OSM changeset falling into the given range of changeset ids. Can only be used in queries using the `OSMContributionView`. | `changeset:(100..200)` | diff --git a/oshdb-filter/src/main/java/org/heigit/ohsome/oshdb/filter/FilterParser.java b/oshdb-filter/src/main/java/org/heigit/ohsome/oshdb/filter/FilterParser.java index df0875107..bc5bd4cdd 100644 --- a/oshdb-filter/src/main/java/org/heigit/ohsome/oshdb/filter/FilterParser.java +++ b/oshdb-filter/src/main/java/org/heigit/ohsome/oshdb/filter/FilterParser.java @@ -49,6 +49,7 @@ public FilterParser(TagTranslator tt) { * @param allowContributorFilters if true enables filtering by contributor/user id. */ public FilterParser(TagTranslator tt, boolean allowContributorFilters) { + // todo: refactor this method into smaller chunks to make it more easily testable final Parser whitespace = Scanners.WHITESPACES.skipMany(); final Parser keystr = Patterns.regex("[a-zA-Z_0-9:-]+") @@ -96,6 +97,15 @@ public FilterParser(TagTranslator tt, boolean allowContributorFilters) { .map(ignored -> "*"); final Parser area = Patterns.string("area").toScanner("area"); final Parser length = Patterns.string("length").toScanner("length"); + final Parser perimeter = Patterns.string("perimeter").toScanner("perimeter"); + final Parser vertices = Patterns.string("geometry.vertices") + .toScanner("geometry.vertices"); + final Parser outers = Patterns.string("geometry.outers").toScanner("geometry.outers"); + final Parser inners = Patterns.string("geometry.inners").toScanner("geometry.inners"); + final Parser roundness = Patterns.string("geometry.roundness") + .toScanner("geometry.roundness"); + final Parser squareness = Patterns.string("geometry.squareness") + .toScanner("geometry.squareness"); final Parser changeset = Patterns.string("changeset").toScanner("changeset"); final Parser contributor = Patterns.string("contributor").toScanner("contributor"); @@ -194,7 +204,7 @@ public FilterParser(TagTranslator tt, boolean allowContributorFilters) { Parsers.or(point, line, polygon, other)) .map(geometryType -> new GeometryTypeFilter(geometryType, tt)); - final Parser floatingRange = Parsers.between( + final Parser positiveFloatingRange = Parsers.between( Scanners.isChar('('), Parsers.or( Parsers.sequence(floatingNumber, dotdot, floatingNumber, @@ -206,15 +216,53 @@ public FilterParser(TagTranslator tt, boolean allowContributorFilters) { ), Scanners.isChar(')') ); + final Parser positiveIntegerRange = Parsers.between( + Scanners.isChar('('), + Parsers.or( + Parsers.sequence(number, dotdot, number, + (min, ignored, max) -> new ValueRange(min, max)), + number.followedBy(dotdot).map( + min -> new ValueRange(min, Double.POSITIVE_INFINITY)), + Parsers.sequence(dotdot, number).map( + max -> new ValueRange(0, max)) + ), + Scanners.isChar(')') + ); + + // geometry filter final Parser geometryFilterArea = Parsers.sequence( - area, colon, floatingRange + area, colon, positiveFloatingRange ).map(GeometryFilterArea::new); final Parser geometryFilterLength = Parsers.sequence( - length, colon, floatingRange + length, colon, positiveFloatingRange ).map(GeometryFilterLength::new); + final Parser geometryFilterPerimeter = Parsers.sequence( + perimeter, colon, positiveFloatingRange + ).map(GeometryFilterPerimeter::new); + final Parser geometryFilterVertices = Parsers.sequence( + vertices, colon, positiveIntegerRange + ).map(GeometryFilterVertices::new); + final Parser geometryFilterOuters = Parsers.sequence( + outers, colon, Parsers.or(positiveIntegerRange, number.map(n -> new ValueRange(n, n))) + ).map(GeometryFilterOuterRings::new); + final Parser geometryFilterInners = Parsers.sequence( + inners, colon, Parsers.or(positiveIntegerRange, number.map(n -> new ValueRange(n, n))) + ).map(GeometryFilterInnerRings::new); + final Parser geometryFilterRoundness = Parsers.sequence( + roundness, colon, positiveFloatingRange + ).map(GeometryFilterRoundness::new); + final Parser geometryFilterSquareness = Parsers.sequence( + squareness, colon, positiveFloatingRange + ).map(GeometryFilterSquareness::new); final Parser geometryFilter = Parsers.or( geometryFilterArea, - geometryFilterLength); + geometryFilterLength, + geometryFilterPerimeter, + geometryFilterVertices, + geometryFilterOuters, + geometryFilterInners, + geometryFilterRoundness, + geometryFilterSquareness); // changeset id filters final Parser changesetIdFilter = Parsers.sequence( diff --git a/oshdb-filter/src/main/java/org/heigit/ohsome/oshdb/filter/GeometryFilterInnerRings.java b/oshdb-filter/src/main/java/org/heigit/ohsome/oshdb/filter/GeometryFilterInnerRings.java new file mode 100644 index 000000000..41ea4a54a --- /dev/null +++ b/oshdb-filter/src/main/java/org/heigit/ohsome/oshdb/filter/GeometryFilterInnerRings.java @@ -0,0 +1,35 @@ +package org.heigit.ohsome.oshdb.filter; + +import javax.annotation.Nonnull; +import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.geom.MultiPolygon; +import org.locationtech.jts.geom.Polygon; + +/** + * A filter which checks the number of inner rings of a multipolygon relation. + */ +public class GeometryFilterInnerRings extends GeometryFilter { + /** + * Creates a new inner rings filter object. + * + * @param range the allowed range (inclusive) of values to pass the filter + */ + public GeometryFilterInnerRings(@Nonnull ValueRange range) { + super(range, GeometryMetricEvaluator.fromLambda(GeometryFilterInnerRings::countInnerRings, + "geometry.inners")); + } + + private static int countInnerRings(Geometry geometry) { + if (geometry instanceof Polygon) { + return ((Polygon) geometry).getNumInteriorRing(); + } else if (geometry instanceof MultiPolygon) { + var counter = 0; + for (var i = 0; i < geometry.getNumGeometries(); i++) { + counter += ((Polygon) geometry.getGeometryN(i)).getNumInteriorRing(); + } + return counter; + } else { + return -1; + } + } +} diff --git a/oshdb-filter/src/main/java/org/heigit/ohsome/oshdb/filter/GeometryFilterOuterRings.java b/oshdb-filter/src/main/java/org/heigit/ohsome/oshdb/filter/GeometryFilterOuterRings.java new file mode 100644 index 000000000..1ab2d83dd --- /dev/null +++ b/oshdb-filter/src/main/java/org/heigit/ohsome/oshdb/filter/GeometryFilterOuterRings.java @@ -0,0 +1,20 @@ +package org.heigit.ohsome.oshdb.filter; + +import javax.annotation.Nonnull; +import org.locationtech.jts.geom.Polygonal; + +/** + * A filter which checks the number of outer rings of a multipolygon relation. + */ +public class GeometryFilterOuterRings extends GeometryFilter { + /** + * Creates a new outer rings filter object. + * + * @param range the allowed range (inclusive) of values to pass the filter + */ + public GeometryFilterOuterRings(@Nonnull ValueRange range) { + super(range, GeometryMetricEvaluator.fromLambda(geometry -> + geometry instanceof Polygonal ? geometry.getNumGeometries() : -1, + "geometry.outers")); + } +} diff --git a/oshdb-filter/src/main/java/org/heigit/ohsome/oshdb/filter/GeometryFilterPerimeter.java b/oshdb-filter/src/main/java/org/heigit/ohsome/oshdb/filter/GeometryFilterPerimeter.java new file mode 100644 index 000000000..a109964de --- /dev/null +++ b/oshdb-filter/src/main/java/org/heigit/ohsome/oshdb/filter/GeometryFilterPerimeter.java @@ -0,0 +1,25 @@ +package org.heigit.ohsome.oshdb.filter; + +import javax.annotation.Nonnull; +import org.heigit.ohsome.oshdb.util.geometry.Geo; +import org.locationtech.jts.geom.Polygonal; + +/** + * A filter which checks the perimeter of polygonal OSM feature geometries. + */ +public class GeometryFilterPerimeter extends GeometryFilter { + /** + * Creates a new perimeter filter object. + * + * @param range the allowed range (inclusive) of values to pass the filter + */ + public GeometryFilterPerimeter(@Nonnull ValueRange range) { + super(range, GeometryMetricEvaluator.fromLambda(geometry -> { + if (!(geometry instanceof Polygonal)) { + return 0; + } + var boundary = geometry.getBoundary(); + return Geo.lengthOf(boundary); + }, "perimeter")); + } +} diff --git a/oshdb-filter/src/main/java/org/heigit/ohsome/oshdb/filter/GeometryFilterRoundness.java b/oshdb-filter/src/main/java/org/heigit/ohsome/oshdb/filter/GeometryFilterRoundness.java new file mode 100644 index 000000000..a7dd897b7 --- /dev/null +++ b/oshdb-filter/src/main/java/org/heigit/ohsome/oshdb/filter/GeometryFilterRoundness.java @@ -0,0 +1,21 @@ +package org.heigit.ohsome.oshdb.filter; + +import javax.annotation.Nonnull; +import org.heigit.ohsome.oshdb.util.geometry.Geo; + +/** + * A filter which checks the roundness of polygonal OSM feature geometries. + * + *

Uses the Polsby-Popper test score, see + * wikipedia for details.

+ */ +public class GeometryFilterRoundness extends GeometryFilter { + /** + * Creates a new "roundness" filter object. + * + * @param range the allowed range (inclusive) of values to pass the filter + */ + public GeometryFilterRoundness(@Nonnull ValueRange range) { + super(range, GeometryMetricEvaluator.fromLambda(Geo::roundness, "geometry.roundness")); + } +} diff --git a/oshdb-filter/src/main/java/org/heigit/ohsome/oshdb/filter/GeometryFilterSquareness.java b/oshdb-filter/src/main/java/org/heigit/ohsome/oshdb/filter/GeometryFilterSquareness.java new file mode 100644 index 000000000..237e61890 --- /dev/null +++ b/oshdb-filter/src/main/java/org/heigit/ohsome/oshdb/filter/GeometryFilterSquareness.java @@ -0,0 +1,23 @@ +package org.heigit.ohsome.oshdb.filter; + +import javax.annotation.Nonnull; +import org.heigit.ohsome.oshdb.util.geometry.Geo; + +/** + * A filter which checks the squareness of OSM feature geometries. + * + *

For the measure for the rectilinearity (or squareness) of a geometry, a methods adapted from the + * paper "A Rectilinearity Measurement for Polygons" by Joviša Žunić and Paul L. Rosin + * (DOI:10.1007/3-540-47967-8_50, https://link.springer.com/chapter/10.1007%2F3-540-47967-8_50, + * https://www.researchgate.net/publication/221304067_A_Rectilinearity_Measurement_for_Polygons) is used.

+ */ +public class GeometryFilterSquareness extends GeometryFilter { + /** + * Creates a new squareness filter object. + * + * @param range the allowed range (inclusive) of values to pass the filter + */ + public GeometryFilterSquareness(@Nonnull ValueRange range) { + super(range, GeometryMetricEvaluator.fromLambda(Geo::squareness, "squareness")); + } +} diff --git a/oshdb-filter/src/main/java/org/heigit/ohsome/oshdb/filter/GeometryFilterVertices.java b/oshdb-filter/src/main/java/org/heigit/ohsome/oshdb/filter/GeometryFilterVertices.java new file mode 100644 index 000000000..eed91e140 --- /dev/null +++ b/oshdb-filter/src/main/java/org/heigit/ohsome/oshdb/filter/GeometryFilterVertices.java @@ -0,0 +1,13 @@ +package org.heigit.ohsome.oshdb.filter; + +import javax.annotation.Nonnull; +import org.locationtech.jts.geom.Geometry; + +/** + * A filter which checks the number of vertices of OSM feature geometries. + */ +public class GeometryFilterVertices extends GeometryFilter { + public GeometryFilterVertices(@Nonnull ValueRange range) { + super(range, GeometryMetricEvaluator.fromLambda(Geometry::getNumPoints, "geometry.vertices")); + } +} diff --git a/oshdb-filter/src/main/java/org/heigit/ohsome/oshdb/filter/NegatableFilter.java b/oshdb-filter/src/main/java/org/heigit/ohsome/oshdb/filter/NegatableFilter.java index fc2d33a23..26bf54aaa 100644 --- a/oshdb-filter/src/main/java/org/heigit/ohsome/oshdb/filter/NegatableFilter.java +++ b/oshdb-filter/src/main/java/org/heigit/ohsome/oshdb/filter/NegatableFilter.java @@ -21,7 +21,7 @@ public FilterExpression negate() { throw new IllegalStateException("Invalid call of inner negate() on a negatable filter"); } - /** Inverse of {@link FilterExpression#applyOSH(OSHEntity)} */ + /** Inverse of {@link FilterExpression#applyOSH(OSHEntity)}. */ @Contract(pure = true) boolean applyOSHNegated(OSHEntity entity) { return true; @@ -32,7 +32,7 @@ public boolean applyOSM(OSMEntity entity) { return true; } - /** Inverse of {@link FilterExpression#applyOSM(OSMEntity)} */ + /** Inverse of {@link FilterExpression#applyOSM(OSMEntity)}. */ @Contract(pure = true) boolean applyOSMNegated(OSMEntity entity) { return true; diff --git a/oshdb-filter/src/test/java/org/heigit/ohsome/oshdb/filter/ApplyOSMGeometryTest.java b/oshdb-filter/src/test/java/org/heigit/ohsome/oshdb/filter/ApplyOSMGeometryTest.java index a467df1a9..68a4d2c56 100644 --- a/oshdb-filter/src/test/java/org/heigit/ohsome/oshdb/filter/ApplyOSMGeometryTest.java +++ b/oshdb-filter/src/test/java/org/heigit/ohsome/oshdb/filter/ApplyOSMGeometryTest.java @@ -3,12 +3,22 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import com.google.common.collect.Streams; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import java.util.stream.LongStream; +import java.util.stream.Stream; import org.heigit.ohsome.oshdb.OSHDBBoundingBox; import org.heigit.ohsome.oshdb.osm.OSMEntity; import org.heigit.ohsome.oshdb.util.geometry.OSHDBGeometryBuilder; +import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; 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.locationtech.jts.io.ParseException; +import org.locationtech.jts.io.WKTReader; /** * Tests the application of filters to OSM geometries. @@ -16,6 +26,12 @@ class ApplyOSMGeometryTest extends FilterTest { private final GeometryFactory gf = new GeometryFactory(); + private Polygon getBoundingBoxPolygon( + double minLon, double minLat, double maxLon, double maxLat) { + return OSHDBGeometryBuilder.getGeometry(OSHDBBoundingBox.bboxWgs84Coordinates( + minLon, minLat, maxLon, maxLat)); + } + @Test void testGeometryTypeFilterPoint() { FilterExpression expression = parser.parse("geometry:point"); @@ -105,20 +121,20 @@ void testGeometryFilterArea() { OSMEntity entity = createTestOSMEntityWay(new long[] {1, 2, 3, 4, 1}); assertFalse(expression.applyOSMGeometry(entity, // approx 0.3m² - OSHDBGeometryBuilder.getGeometry(OSHDBBoundingBox.bboxWgs84Coordinates(0, 0, 5E-6, 5E-6)) + getBoundingBoxPolygon(0, 0, 5E-6, 5E-6) )); assertTrue(expression.applyOSMGeometry(entity, // approx 1.2m² - OSHDBGeometryBuilder.getGeometry(OSHDBBoundingBox.bboxWgs84Coordinates(0, 0, 1E-5, 1E-5)) + getBoundingBoxPolygon(0, 0, 1E-5, 1E-5) )); assertFalse(expression.applyOSMGeometry(entity, // approx 4.9m² - OSHDBGeometryBuilder.getGeometry(OSHDBBoundingBox.bboxWgs84Coordinates(0, 0, 2E-5, 2E-5)) + getBoundingBoxPolygon(0, 0, 2E-5, 2E-5) )); // negated assertFalse(expression.negate().applyOSMGeometry(entity, // approx 1.2m² - OSHDBGeometryBuilder.getGeometry(OSHDBBoundingBox.bboxWgs84Coordinates(0, 0, 1E-5, 1E-5)) + getBoundingBoxPolygon(0, 0, 1E-5, 1E-5) )); assertTrue(expression.negate().applyOSMGeometry(entity, // approx 0.3m² @@ -161,4 +177,271 @@ void testGeometryFilterLength() { }) )); } + + @Test + void testGeometryFilterPerimeterTooSmall() { + FilterExpression expression = parser.parse("perimeter:(4..5)"); + OSMEntity entity = createTestOSMEntityWay(new long[]{1, 2, 3, 4, 1}); + assertFalse(expression.applyOSMGeometry(entity, + // square with approx 0.6m edge length + getBoundingBoxPolygon(0, 0, 5E-6, 5E-6) + )); + } + @Test + void testGeometryFilterPerimeterInRange() { + FilterExpression expression = parser.parse("perimeter:(4..5)"); + OSMEntity entity = createTestOSMEntityWay(new long[] {1, 2, 3, 4, 1}); + assertTrue(expression.applyOSMGeometry(entity, + // square with approx 1.1m edge length + getBoundingBoxPolygon(0, 0, 1E-5, 1E-5) + )); + } + @Test + void testGeometryFilterPerimeterTooLarge() { + FilterExpression expression = parser.parse("perimeter:(4..5)"); + OSMEntity entity = createTestOSMEntityWay(new long[] {1, 2, 3, 4, 1}); + assertFalse(expression.applyOSMGeometry(entity, + // square with approx 2.2m edge length + getBoundingBoxPolygon(0, 0, 2E-5, 2E-5) + )); + } + @Test + void testGeometryFilterPerimeterNegated() { + FilterExpression expression = parser.parse("perimeter:(4..5)"); + OSMEntity entity = createTestOSMEntityWay(new long[] {1, 2, 3, 4, 1}); + assertTrue(expression.negate().applyOSMGeometry(entity, + // square with approx 0.6m edge length + getBoundingBoxPolygon(0, 0, 5E-6, 5E-6) + )); + } + + @Test + void testGeometryFilterVerticesPoint() { + FilterExpression expression = parser.parse("geometry.vertices:(11..13)"); + assertFalse(expression.applyOSMGeometry( + createTestOSMEntityNode("natural", "tree"), + gf.createPoint(new Coordinate(0, 0)))); + } + @Test + void testGeometryFilterVerticesLine() { + FilterExpression expression = parser.parse("geometry.vertices:(11..13)"); + BiConsumer> testLineN = (n, tester) -> { + var entity = createTestOSMEntityWay(LongStream.rangeClosed(1, n).toArray()); + var coords = LongStream.rangeClosed(1, n) + .mapToObj(i -> new Coordinate(i, i)).toArray(Coordinate[]::new); + var geometry = gf.createLineString(coords); + tester.accept(expression.applyOSMGeometry(entity, geometry)); + }; + testLineN.accept(10, Assertions::assertFalse); + testLineN.accept(11, Assertions::assertTrue); + testLineN.accept(12, Assertions::assertTrue); + testLineN.accept(13, Assertions::assertTrue); + testLineN.accept(14, Assertions::assertFalse); + } + @Test + void testGeometryFilterVerticesPolygon() { + FilterExpression expression = parser.parse("geometry.vertices:(11..13)"); + BiConsumer> testPolyonN = (n, tester) -> { + var entity = createTestOSMEntityWay(LongStream.rangeClosed(1, n).toArray()); + var coords = Streams.concat( + LongStream.rangeClosed(1, n - 1).mapToObj(i -> new Coordinate(i, Math.pow(i, 2))), + Stream.of(new Coordinate(1, 1)) + ).toArray(Coordinate[]::new); + var geometry = gf.createPolygon(coords); + tester.accept(expression.applyOSMGeometry(entity, geometry)); + }; + testPolyonN.accept(10, Assertions::assertFalse); + testPolyonN.accept(11, Assertions::assertTrue); + testPolyonN.accept(12, Assertions::assertTrue); + testPolyonN.accept(13, Assertions::assertTrue); + testPolyonN.accept(14, Assertions::assertFalse); + } + @Test + void testGeometryFilterVerticesPolygonWithHole() { + FilterExpression expression = parser.parse("geometry.vertices:(11..13)"); + BiConsumer> testPolyonWithHoleN = (n, tester) -> { + var entity = createTestOSMEntityRelation("type", "multipolygon"); + n -= 5; // outer shell is a simple bbox with 5 points + var innerCoords = gf.createLinearRing(Streams.concat( + LongStream.rangeClosed(1, n - 1).mapToObj(i -> new Coordinate(i, Math.pow(i, 2))), + Stream.of(new Coordinate(1, 1)) + ).toArray(Coordinate[]::new)); + var geometry = gf.createPolygon( + OSHDBGeometryBuilder.getGeometry(OSHDBBoundingBox + .bboxWgs84Coordinates(-80, -80, 80, 80)).getExteriorRing(), + new LinearRing[] { innerCoords }); + tester.accept(expression.applyOSMGeometry(entity, geometry)); + }; + testPolyonWithHoleN.accept(10, Assertions::assertFalse); + testPolyonWithHoleN.accept(11, Assertions::assertTrue); + testPolyonWithHoleN.accept(12, Assertions::assertTrue); + testPolyonWithHoleN.accept(13, Assertions::assertTrue); + testPolyonWithHoleN.accept(14, Assertions::assertFalse); + } + @Test + void testGeometryFilterVerticesMultiPolygon() { + FilterExpression expression = parser.parse("geometry.vertices:(11..13)"); + BiConsumer> testMultiPolyonN = (n, tester) -> { + var entity = createTestOSMEntityRelation("type", "multipolygon"); + n -= 5; // outer shell 2 is a simple bbox with 5 points + var coords = Streams.concat( + LongStream.rangeClosed(1, n - 1).mapToObj(i -> new Coordinate(i, Math.pow(i, 2))), + Stream.of(new Coordinate(1, 1)) + ).toArray(Coordinate[]::new); + var geometry = gf.createMultiPolygon(new Polygon[] { + getBoundingBoxPolygon(-2, -2, -1, -1), + gf.createPolygon(coords) }); + tester.accept(expression.applyOSMGeometry(entity, geometry)); + }; + testMultiPolyonN.accept(10, Assertions::assertFalse); + testMultiPolyonN.accept(11, Assertions::assertTrue); + testMultiPolyonN.accept(12, Assertions::assertTrue); + testMultiPolyonN.accept(13, Assertions::assertTrue); + testMultiPolyonN.accept(14, Assertions::assertFalse); + } + + @Test + void testGeometryFilterOutersPoint() { + FilterExpression expression = parser.parse("geometry.outers:1"); + OSMEntity entity = createTestOSMEntityNode(); + assertFalse(expression.applyOSMGeometry(entity, gf.createPoint(new Coordinate(0, 0)))); + // range + expression = parser.parse("geometry.outers:(2..)"); + assertFalse(expression.applyOSMGeometry(entity, gf.createPoint(new Coordinate(0, 0)))); + } + @Test + void testGeometryFilterOutersLine() { + FilterExpression expression = parser.parse("geometry.outers:1"); + OSMEntity entity = createTestOSMEntityWay(new long[] {1, 2, 3}); + var geom = gf.createLineString(new Coordinate[] { + new Coordinate(0, 0), new Coordinate(1, 0), new Coordinate(1, 1) + }); + assertFalse(expression.applyOSMGeometry(entity, geom)); + // range + expression = parser.parse("geometry.outers:(2..)"); + assertFalse(expression.applyOSMGeometry(entity, geom)); + } + @Test + void testGeometryFilterOutersPolygon() { + FilterExpression expression = parser.parse("geometry.outers:1"); + OSMEntity entity = createTestOSMEntityWay(new long[] {1, 2, 3, 1}); + assertTrue(expression.applyOSMGeometry(entity, getBoundingBoxPolygon(1, 1, 2, 2))); + // range + expression = parser.parse("geometry.outers:(2..)"); + assertFalse(expression.applyOSMGeometry(entity, getBoundingBoxPolygon(1, 1, 2, 2))); + } + @Test + void testGeometryFilterOutersMultiPolygon() { + FilterExpression expression = parser.parse("geometry.outers:1"); + OSMEntity entity = createTestOSMEntityRelation("type", "multipolygon"); + assertFalse(expression.applyOSMGeometry(entity, gf.createMultiPolygon(new Polygon[] { + getBoundingBoxPolygon(1, 1, 2, 2), + getBoundingBoxPolygon(3, 3, 4, 4) + }))); + assertTrue(expression.applyOSMGeometry(entity, gf.createMultiPolygon(new Polygon[] { + getBoundingBoxPolygon(1, 1, 2, 2) + }))); + assertTrue(expression.applyOSMGeometry(entity, + getBoundingBoxPolygon(1, 1, 2, 2) + )); + // range + expression = parser.parse("geometry.outers:(2..)"); + assertTrue(expression.applyOSMGeometry(entity, gf.createMultiPolygon(new Polygon[] { + getBoundingBoxPolygon(1, 1, 2, 2), + getBoundingBoxPolygon(3, 3, 4, 4) + }))); + } + + @Test + void testGeometryFilterInnersPoint() { + FilterExpression expression = parser.parse("geometry.inners:0"); + OSMEntity entity = createTestOSMEntityNode(); + assertFalse(expression.applyOSMGeometry(entity, gf.createPoint(new Coordinate(0, 0)))); + // range + expression = parser.parse("geometry.inners:(1..)"); + assertFalse(expression.applyOSMGeometry(entity, gf.createPoint(new Coordinate(0, 0)))); + } + @Test + void testGeometryFilterInnersLine() { + FilterExpression expression = parser.parse("geometry.inners:0"); + OSMEntity entity = createTestOSMEntityWay(new long[] {1, 2, 3}); + var geom = gf.createLineString(new Coordinate[] { + new Coordinate(0, 0), new Coordinate(1, 0), new Coordinate(1, 1) + }); + assertFalse(expression.applyOSMGeometry(entity, geom)); + // range + expression = parser.parse("geometry.inners:(1..)"); + assertFalse(expression.applyOSMGeometry(entity, geom)); + } + @Test + void testGeometryFilterInnersPolygon() { + FilterExpression expression = parser.parse("geometry.inners:0"); + OSMEntity entity = createTestOSMEntityWay(new long[] {1, 2, 3, 1}); + assertTrue(expression.applyOSMGeometry(entity, getBoundingBoxPolygon(1, 1, 2, 2))); + assertFalse(expression.applyOSMGeometry(entity, gf.createPolygon( + OSHDBGeometryBuilder.getGeometry( + OSHDBBoundingBox.bboxWgs84Coordinates(0, 0, 10, 10)).getExteriorRing(), + new LinearRing[] { OSHDBGeometryBuilder.getGeometry( + OSHDBBoundingBox.bboxWgs84Coordinates(1, 1, 2, 2)).getExteriorRing() + }))); + // range + expression = parser.parse("geometry.inners:(1..)"); + assertFalse(expression.applyOSMGeometry(entity, getBoundingBoxPolygon(1, 1, 2, 2))); + } + @Test + void testGeometryFilterInnersMultiPolygon() { + FilterExpression expression = parser.parse("geometry.inners:0"); + OSMEntity entity = createTestOSMEntityRelation("type", "multipolygon"); + assertTrue(expression.applyOSMGeometry(entity, gf.createMultiPolygon(new Polygon[] { + getBoundingBoxPolygon(1, 1, 2, 2) + }))); + assertFalse(expression.applyOSMGeometry(entity, gf.createMultiPolygon(new Polygon[] { + gf.createPolygon( + OSHDBGeometryBuilder.getGeometry( + OSHDBBoundingBox.bboxWgs84Coordinates(0, 0, 10, 10)).getExteriorRing(), + new LinearRing[] { OSHDBGeometryBuilder.getGeometry( + OSHDBBoundingBox.bboxWgs84Coordinates(1, 1, 2, 2)).getExteriorRing() + }) + }))); + // range + expression = parser.parse("geometry.inners:(1..)"); + assertTrue(expression.applyOSMGeometry(entity, gf.createPolygon( + OSHDBGeometryBuilder.getGeometry( + OSHDBBoundingBox.bboxWgs84Coordinates(0, 0, 10, 10)).getExteriorRing(), + new LinearRing[] { OSHDBGeometryBuilder.getGeometry( + OSHDBBoundingBox.bboxWgs84Coordinates(1, 1, 2, 2)).getExteriorRing() + }))); + } + + private final String regular32gon = "POLYGON ((1.0000004 0, 0.9807856 0.1950904, " + + "0.9238799 0.3826836, 0.8314699 0.5555704, 0.707107 0.707107, 0.5555704 0.8314699, " + + "0.3826836 0.9238799, 0.1950904 0.9807856, 0 1.0000004, -0.1950904 0.9807856, " + + "-0.3826836 0.9238799, -0.5555704 0.8314699, -0.707107 0.707107, -0.8314699 0.5555704, " + + "-0.9238799 0.3826836, -0.9807856 0.1950904, -1.0000004 0, -0.9807856 -0.1950904, " + + "-0.9238799 -0.3826836, -0.8314699 -0.5555704, -0.707107 -0.707107, -0.5555704 -0.8314699, " + + "-0.3826836 -0.9238799, -0.1950904 -0.9807856, 0 -1.0000004, 0.1950904 -0.9807856, " + + "0.3826836 -0.9238799, 0.5555704 -0.8314699, 0.707107 -0.707107, 0.8314699 -0.5555704, " + + "0.9238799 -0.3826836, 0.9807856 -0.1950904, 1.0000004 0))"; + + @Test + void testGeometryFilterRoundness() throws ParseException { + FilterExpression expression = parser.parse("geometry.roundness:(0.8..)"); + OSMEntity entity = createTestOSMEntityWay(new long[] {}); + assertFalse(expression.applyOSMGeometry(entity, + getBoundingBoxPolygon(0, 0, 1, 1) + )); + var reader = new WKTReader(); + assertTrue(expression.applyOSMGeometry(entity, reader.read(regular32gon))); + } + + @Test + void testGeometryFilterSqareness() throws ParseException { + FilterExpression expression = parser.parse("geometry.squareness:(0.8..)"); + OSMEntity entity = createTestOSMEntityWay(new long[] {}); + assertTrue(expression.applyOSMGeometry(entity, + getBoundingBoxPolygon(0, 0, 1, 1) + )); + var reader = new WKTReader(); + assertFalse(expression.applyOSMGeometry(entity, reader.read(regular32gon))); + } } diff --git a/oshdb-filter/src/test/java/org/heigit/ohsome/oshdb/filter/ParseTest.java b/oshdb-filter/src/test/java/org/heigit/ohsome/oshdb/filter/ParseTest.java index cf667cc66..20a98cc99 100644 --- a/oshdb-filter/src/test/java/org/heigit/ohsome/oshdb/filter/ParseTest.java +++ b/oshdb-filter/src/test/java/org/heigit/ohsome/oshdb/filter/ParseTest.java @@ -338,6 +338,46 @@ void testGeometryFilterLength() { assertTrue(expression instanceof GeometryFilterLength); } + @Test + void testGeometryFilterPerimeter() { + FilterExpression expression = parser.parse("perimeter:(1..10)"); + assertTrue(expression instanceof GeometryFilterPerimeter); + } + + @Test + void testGeometryFilterVertices() { + FilterExpression expression = parser.parse("geometry.vertices:(1..10)"); + assertTrue(expression instanceof GeometryFilterVertices); + } + + @Test + void testGeometryFilterOuters() { + FilterExpression expression = parser.parse("geometry.outers:2"); + assertTrue(expression instanceof GeometryFilterOuterRings); + expression = parser.parse("geometry.outers:(1..10)"); + assertTrue(expression instanceof GeometryFilterOuterRings); + } + + @Test + void testGeometryFilterInners() { + FilterExpression expression = parser.parse("geometry.inners:0"); + assertTrue(expression instanceof GeometryFilterInnerRings); + expression = parser.parse("geometry.inners:(1..10)"); + assertTrue(expression instanceof GeometryFilterInnerRings); + } + + @Test + void testGeometryFilterRoundness() { + FilterExpression expression = parser.parse("geometry.roundness:(0.8..)"); + assertTrue(expression instanceof GeometryFilterRoundness); + } + + @Test + void testGeometryFilterSquareness() { + FilterExpression expression = parser.parse("geometry.squareness:(0.8..)"); + assertTrue(expression instanceof GeometryFilterSquareness); + } + @Test void testChangesetIdFilter() { FilterExpression expression = parser.parse("changeset:42"); diff --git a/oshdb-util/src/main/java/org/heigit/ohsome/oshdb/util/geometry/Geo.java b/oshdb-util/src/main/java/org/heigit/ohsome/oshdb/util/geometry/Geo.java index f0bae4cdc..e47146c07 100644 --- a/oshdb-util/src/main/java/org/heigit/ohsome/oshdb/util/geometry/Geo.java +++ b/oshdb-util/src/main/java/org/heigit/ohsome/oshdb/util/geometry/Geo.java @@ -1,5 +1,8 @@ package org.heigit.ohsome.oshdb.util.geometry; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; import org.heigit.ohsome.oshdb.OSHDBBoundingBox; import org.locationtech.jts.geom.Coordinate; import org.locationtech.jts.geom.Geometry; @@ -52,8 +55,11 @@ private Geo() { * @return The approximate geodesic length of the line string in meters. */ public static double lengthOf(LineString line) { + return lengthOf(line.getCoordinates()); + } + + private static double lengthOf(Coordinate[] coords) { double dist = 0.0; - Coordinate[] coords = line.getCoordinates(); if (coords.length > 1) { double prevLon = Math.toRadians(coords[0].x); @@ -300,6 +306,202 @@ private static double ringArea(LinearRing ring) { return area; } + // ====================== + // = shape calculations = + // ====================== + + /** + * Calculates the "Polsby–Popper test" score of a polygonal geometry, representing the + * roundness (or compactness) of the feature. + * + *

+ * If the shape is not polygonal, zero is returned. If a shape constitutes of multiple parts (a + * MultiPolygon geometry), the result is the weighted sum of the individual parts' scores. + * See wikipedia for info. + *

+ * + * @param geom the geometry for which to calculate the PP score of. + * @return the compactness measure of the input shape. A score of 1 indicates maximum compactness + * (i.e. a circle) + */ + public static double roundness(Geometry geom) { + if (!(geom instanceof Polygonal)) { + return 0; + } + var boundaryLength = Geo.lengthOf(geom.getBoundary()); + if (boundaryLength == 0) { + return 0; + } + return 4 * Math.PI * Geo.areaOf(geom) / (boundaryLength * boundaryLength); + } + + // ====================== + // = angle calculations = + // ====================== + + public static double bearingRadians(Coordinate from, Coordinate to) { + var x1 = from.x * Math.PI / 180; + var x2 = to.x * Math.PI / 180; + var y1 = from.y * Math.PI / 180; + var y2 = to.y * Math.PI / 180; + var y = Math.sin(x2 - x1) * Math.cos(y2); + var x = Math.cos(y1) * Math.sin(y2) + - Math.sin(y1) * Math.cos(y2) * Math.cos(x2 - x1); + return (Math.atan2(y, x) + 2 * Math.PI) % (2 * Math.PI); + } + + /** + * Returns a measure for the squareness (or rectilinearity) of a geometry. + * + *

Adapted from "A Rectilinearity Measurement for Polygons" by Joviša Žunić and Paul L. Rosin: + * DOI:10.1007/3-540-47967-8_50 + * https://link.springer.com/chapter/10.1007%2F3-540-47967-8_50 + * https://www.researchgate.net/publication/221304067_A_Rectilinearity_Measurement_for_Polygons + * + * Žunić J., Rosin P.L. (2002) A Rectilinearity Measurement for Polygons. In: Heyden A., Sparr G., + * Nielsen M., Johansen P. (eds) Computer Vision — ECCV 2002. ECCV 2002. Lecture Notes in Computer + * Science, vol 2351. Springer, Berlin, Heidelberg. https://doi.org/10.1007/3-540-47967-8_50 + *

+ * + *

Adjusted to work directly on geographic coordinates. Implementation works on a few + * assumptions: input geometries are "small"; spherical globe approximation.

+ * + * @param geom a Polygon, MultiPolygon and LineString for which the squareness is to be evaluated. + * @return returns the rectilinearity value of the input geometry, or zero if the geometry type + * isn't supported + */ + public static double squareness(Geometry geom) { + if (geom instanceof Polygon) { + return squareness(dissolvePolygonToRings((Polygon) geom)); + } else if (geom instanceof MultiPolygon) { + var multiPoly = (MultiPolygon) geom; + var rings = new ArrayList(); + for (var i = 0; i < multiPoly.getNumGeometries(); i++) { + var poly = (Polygon) geom.getGeometryN(i); + rings.addAll(dissolvePolygonToRings(poly)); + } + return squareness(rings); + } else if (geom instanceof LineString) { + return squareness(Collections.singletonList((LineString) geom)); + } else { + // other geometry types: return 0 + return 0; + } + } + + /** Helper method to dissolve a polygon to a collection of rings. */ + private static List dissolvePolygonToRings(Polygon poly) { + var rings = new ArrayList(poly.getNumInteriorRing() + 1); + rings.add(poly.getExteriorRing()); + for (var i = 0; i < poly.getNumInteriorRing(); i++) { + rings.add(poly.getInteriorRingN(i)); + } + return rings; + } + + private static double squareness(List lines) { + var minLengthL1 = Double.MAX_VALUE; + for (LineString line : lines) { + var coords = line.getCoordinates(); + for (var j = 1; j < coords.length; j++) { + var angle = bearingRadians(coords[j - 1], coords[j]); + var lengthL1 = 0.0; + for (LineString lineAgain : lines) { + lengthL1 += gridAlignedLengthL1(lineAgain, angle); + } + if (lengthL1 < minLengthL1) { + minLengthL1 = lengthL1; + } + } + } + var lengthL2 = 0.0; + for (LineString line : lines) { + lengthL2 += lengthOfL2(line.getCoordinates()); + } + if (minLengthL1 == 0) { + return 0; + } + return 4 / (4 - Math.PI) * (lengthL2 / minLengthL1 - Math.PI / 4); + } + + /** + * Intermediate value used in calculation of squareness metric, see paper referenced above. + * */ + private static double gridAlignedLengthL1(LineString line, double angle) { + var cosA = Math.cos(angle); + var sinA = Math.sin(angle); + var centroid = line.getCentroid().getCoordinate(); + var cosCentroidY = Math.cos(centroid.y * Math.PI / 180); + var inverseCosCentroidY = 1 / cosCentroidY; + var coords = line.getCoordinates(); + var modifiedCoords = new Coordinate[coords.length]; + // shift to origin + for (var i = 0; i < coords.length; i++) { + modifiedCoords[i] = new Coordinate( + (coords[i].x - centroid.x) * cosCentroidY, + coords[i].y - centroid.y + ); + } + // rotate + for (Coordinate modifiedCoord : modifiedCoords) { + var newX = modifiedCoord.x * cosA - modifiedCoord.y * sinA; + modifiedCoord.y = modifiedCoord.x * sinA + modifiedCoord.y * cosA; + modifiedCoord.x = newX; + } + // shift back to original location + for (Coordinate modifiedCoord : modifiedCoords) { + modifiedCoord.x = modifiedCoord.x * inverseCosCentroidY + centroid.x; + modifiedCoord.y += centroid.y; + } + return lengthOfL1(modifiedCoords); + } + + /** + * Intermediate value used in calculation of squareness metric, see paper referenced above + * (this uses the L1 "Manhattan" metric to calculate the length of a given linestring). + * */ + private static double lengthOfL1(Coordinate[] coords) { + var dist = 0.0; + if (coords.length > 1) { + double prevLon = Math.toRadians(coords[0].x); + double prevLat = Math.toRadians(coords[0].y); + for (var i = 1; i < coords.length; i++) { + double thisLon = Math.toRadians(coords[i].x); + double thisLat = Math.toRadians(coords[i].y); + double deltaLon = thisLon - prevLon; + double deltaLat = thisLat - prevLat; + deltaLon *= Math.cos((thisLat + prevLat) / 2); + dist += Math.abs(deltaLon) + Math.abs(deltaLat); + prevLon = thisLon; + prevLat = thisLat; + } + } + return dist; + } + + /** + * Intermediate value used in calculation of squareness metric, see paper referenced above + * (this uses the L2 "euclidean" distance metric to calculate the length of a given linestring). + * */ + private static double lengthOfL2(Coordinate[] coords) { + var dist = 0.0; + if (coords.length > 1) { + double prevLon = Math.toRadians(coords[0].x); + double prevLat = Math.toRadians(coords[0].y); + for (var i = 1; i < coords.length; i++) { + double thisLon = Math.toRadians(coords[i].x); + double thisLat = Math.toRadians(coords[i].y); + double deltaLon = thisLon - prevLon; + double deltaLat = thisLat - prevLat; + deltaLon *= Math.cos((thisLat + prevLat) / 2); + dist += Math.sqrt(deltaLon * deltaLon + deltaLat * deltaLat); + prevLon = thisLon; + prevLat = thisLat; + } + } + return dist; + } + // ===================== // = geometry clipping = // ===================== diff --git a/oshdb-util/src/test/java/org/heigit/ohsome/oshdb/util/geometry/GeoTest.java b/oshdb-util/src/test/java/org/heigit/ohsome/oshdb/util/geometry/GeoTest.java index d3200638e..c5aee3b8e 100644 --- a/oshdb-util/src/test/java/org/heigit/ohsome/oshdb/util/geometry/GeoTest.java +++ b/oshdb-util/src/test/java/org/heigit/ohsome/oshdb/util/geometry/GeoTest.java @@ -2,7 +2,9 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.locationtech.jts.geom.Coordinate; import org.locationtech.jts.geom.Geometry; @@ -14,6 +16,8 @@ import org.locationtech.jts.geom.MultiPolygon; import org.locationtech.jts.geom.Point; import org.locationtech.jts.geom.Polygon; +import org.locationtech.jts.io.ParseException; +import org.locationtech.jts.io.WKTReader; /** * Tests the {@link Geo} class. @@ -276,6 +280,295 @@ void testLengthRealFeatures() { assertEquals(1.0, Geo.lengthOf(line) / expectedResult, relativeDelta); } + @Nested + class RectilinearityTest { + final double L = 1E-4; // "size" of the test geometries + final double D = 10; // offset used for shifted test geometries + + @Test + void testSquare() { + assertEquals(1.0, Geo.squareness(gf.createPolygon(new Coordinate[]{ + new Coordinate(0, 0), + new Coordinate(L, 0), + new Coordinate(L, L), + new Coordinate(0, L), + new Coordinate(0, 0) + })), 0.01); + } + + @Test + void testSquareShiftedX() { + assertEquals(1.0, Geo.squareness(gf.createPolygon(new Coordinate[]{ + new Coordinate(D, 0), + new Coordinate(D + L, 0), + new Coordinate(D + L, L), + new Coordinate(D, L), + new Coordinate(D, 0) + })), 0.01); + } + + @Test + void testSquareShiftedY() { + assertEquals(1.0, Geo.squareness(gf.createPolygon(new Coordinate[]{ + new Coordinate(0, D), + new Coordinate(L, D), + new Coordinate(L, D + L), + new Coordinate(0, D + L), + new Coordinate(0, D) + })), 0.01); + } + + @Test + void testSquareTilted() { + assertEquals(1.0, Geo.squareness(gf.createPolygon(new Coordinate[]{ + new Coordinate(L, 0), + new Coordinate(0, L), + new Coordinate(-L, 0), + new Coordinate(0, -L), + new Coordinate(L, 0) + })), 0.01); + } + + @Test + void testSquareTiltedShiftedX() { + assertEquals(1.0, Geo.squareness(gf.createPolygon(new Coordinate[]{ + new Coordinate(D + L, 0), + new Coordinate(D, L), + new Coordinate(D - L, 0), + new Coordinate(D, L), + new Coordinate(D + L, 0) + })), 0.01); + } + + @Test + void testSquareTiltedShiftedY() { + assertEquals(1.0, Geo.squareness(gf.createPolygon(new Coordinate[]{ + new Coordinate(L, D), + new Coordinate(0, D + L), + new Coordinate(-L, D), + new Coordinate(0, D - L), + new Coordinate(L, D) + })), 0.1); + } + + @Test + void testTriangle() { + assertEquals(0.3, Geo.squareness(gf.createPolygon(new Coordinate[]{ + new Coordinate(0, 0), + new Coordinate(L, 0), + new Coordinate(L, L), + new Coordinate(0, 0) + })), 0.1); + } + + @Test + void testCircle() throws ParseException { + var reader = new WKTReader(); + assertEquals(0.0, Geo.squareness(reader.read(regular32gon)), 0.1); + } + + @Test + void testLine() { + assertEquals(1.0, Geo.squareness(gf.createLineString(new Coordinate[]{ + new Coordinate(0, 0), + new Coordinate(L, 0) + })), 0.01); + } + + @Test + void testLineSlanted() { + assertEquals(1.0, Geo.squareness(gf.createLineString(new Coordinate[]{ + new Coordinate(0, 0), + new Coordinate(L, L) + })), 0.01); + } + + @Test + void testLineRightAngle() { + assertEquals(1.0, Geo.squareness(gf.createLineString(new Coordinate[]{ + new Coordinate(0, 0), + new Coordinate(L, 0), + new Coordinate(L, L) + })), 0.01); + } + + @Test + void testLineNotRightAngle() { + assertNotEquals(1.0, Geo.squareness(gf.createLineString(new Coordinate[]{ + new Coordinate(0, 0), + new Coordinate(L, 0), + new Coordinate(0, L) + })), 0.1); + } + + @Test + void testPolygonWithAlignedHoles() { + // polygon with holes: squares aligned + assertEquals(1.0, Geo.squareness(gf.createPolygon( + gf.createLinearRing(new Coordinate[]{ + new Coordinate(0, 0), + new Coordinate(L, 0), + new Coordinate(L, L), + new Coordinate(0, L), + new Coordinate(0, 0) + }), new LinearRing[]{ + gf.createLinearRing(new Coordinate[]{ + new Coordinate(L / 3, L / 3), + new Coordinate(2 * L / 3, L / 3), + new Coordinate(2 * L / 3, 2 * L / 3), + new Coordinate(L / 3, 2 * L / 3), + new Coordinate(L / 3, L / 3) + }) + }) + ), 0.01); + } + + @Test + void testPolygonWithUnalignedHoles() { + // polygon with holes: squares not aligned + assertNotEquals(1.0, Geo.squareness(gf.createPolygon( + gf.createLinearRing(new Coordinate[]{ + new Coordinate(0, 0), + new Coordinate(L, 0), + new Coordinate(L, L), + new Coordinate(0, L), + new Coordinate(0, 0) + }), new LinearRing[]{ + gf.createLinearRing(new Coordinate[]{ + new Coordinate(L / 2 + L / 4, L / 2), + new Coordinate(L / 2, L / 2 + L / 4), + new Coordinate(L / 2 - L / 4, L / 2), + new Coordinate(L / 2, L / 2 - L / 4), + new Coordinate(L / 2 + L / 4, L / 2) + }) + }) + ), 0.01); + } + + @Test + void testMultiPolygonAligned() { + // multi polygon: two aligned squares + assertEquals(1.0, Geo.squareness(gf.createMultiPolygon(new Polygon[] { + gf.createPolygon(gf.createLinearRing(new Coordinate[]{ + new Coordinate(0, 0), + new Coordinate(L, 0), + new Coordinate(L, L), + new Coordinate(0, L), + new Coordinate(0, 0) + })), + gf.createPolygon(gf.createLinearRing(new Coordinate[]{ + new Coordinate(2 * L, 0), + new Coordinate(3 * L, 0), + new Coordinate(3 * L, L), + new Coordinate(2 * L, L), + new Coordinate(2 * L, 0) + })) + })), 0.01); + } + + @Test + void testMultiPolygonUnaligned() { + // multi polygon: two non-aligned squares + assertNotEquals(1.0, Geo.squareness(gf.createMultiPolygon(new Polygon[] { + gf.createPolygon(gf.createLinearRing(new Coordinate[]{ + new Coordinate(L, 0), + new Coordinate(0, L), + new Coordinate(-L, 0), + new Coordinate(0, -L), + new Coordinate(L, 0) + })), + gf.createPolygon(gf.createLinearRing(new Coordinate[]{ + new Coordinate(2 * L, 0), + new Coordinate(3 * L, 0), + new Coordinate(3 * L, L), + new Coordinate(2 * L, L), + new Coordinate(2 * L, 0) + })) + })), 0.01); + } + + @Test + void testLineString() { + // L-shape + assertEquals(1.0, Geo.squareness(gf.createLineString(new Coordinate[]{ + new Coordinate(0, 0), + new Coordinate(L, 0), + new Coordinate(L, L) + })), 0.01); + } + + @Test + void testLineStringShiftedX() { + // L-shape shifted X + assertEquals(1.0, Geo.squareness(gf.createLineString(new Coordinate[]{ + new Coordinate(D, 0), + new Coordinate(D + L, 0), + new Coordinate(D + L, L) + })), 0.01); + } + + @Test + void testLineStringShiftedY() { + // L-shape shifted Y + assertEquals(1.0, Geo.squareness(gf.createLineString(new Coordinate[]{ + new Coordinate(0, D), + new Coordinate(L, D), + new Coordinate(L, D + L) + })), 0.01); + } + + @Test + void testLineStringTilted() { + // L-shape tilted + assertEquals(1.0, Geo.squareness(gf.createLineString(new Coordinate[]{ + new Coordinate(L, 0), + new Coordinate(0, L), + new Coordinate(-L, 0) + })), 0.01); + } + + @Test + void testLineStringCircle() throws ParseException { + // circle + var reader = new WKTReader(); + assertEquals(0.0, Geo.squareness(reader.read(regular32gon).getBoundary()), 0.1); + } + + @Test + void testRealWorldObject() throws ParseException { + var reader = new WKTReader(); + // real world, simple + var example1 = "POLYGON ((38.3460976 49.0294022, 38.3461905 49.0293425, " + + "38.3462795 49.0294021, 38.3461866 49.0294617, 38.3460976 49.0294022))"; + assertEquals(1.0, Geo.squareness(reader.read(example1)), 0.01); + // real world, complex + var example2 = "POLYGON ((38.3437718 49.0276361, 38.3438681 49.0275809, " + + "38.3438179 49.0275432, 38.3440056 49.0274357, 38.3440234 49.0274491, " + + "38.3441311 49.0273874, 38.3439048 49.0272175, 38.343646 49.0273657, " + + "38.3436218 49.0273475, 38.3435477 49.02739, 38.3435676 49.0274049, " + + "38.3435087 49.0274387, 38.3437718 49.0276361))"; + assertEquals(1.0, Geo.squareness(reader.read(example2)), 0.01); + // real world, unsquare + var example3 = "POLYGON ((38.3452974 49.0268873, 38.3453977 49.0268312, " + + "38.3454916 49.026925, 38.3453901 49.0269725, 38.3452974 49.0268873))"; + assertNotEquals(1.0, Geo.squareness(reader.read(example3)), 0.1); + } + } + + @Test + void testCompactness() throws ParseException { + // circle + var reader = new WKTReader(); + assertEquals(1.0, Geo.roundness(reader.read(regular32gon)), 0.1); + // triangle + assertEquals(0.5, Geo.roundness(gf.createPolygon(new Coordinate[] { + new Coordinate(0, 0), + new Coordinate(1E-4, 0), + new Coordinate(1E-4, 1E-4), + new Coordinate(0, 0) + })), 0.2); + } + // real world test geometries private static final Coordinate[] featureSmall = { @@ -818,4 +1111,13 @@ void testLengthRealFeatures() { new Coordinate(-56.1608058, -84.1572730), new Coordinate(-56.1617903, -84.1590114), new Coordinate(-56.1637542, -84.1609504)}; + private static final String regular32gon = "POLYGON ((1.0000004 0, 0.9807856 0.1950904, " + + "0.9238799 0.3826836, 0.8314699 0.5555704, 0.707107 0.707107, 0.5555704 0.8314699, " + + "0.3826836 0.9238799, 0.1950904 0.9807856, 0 1.0000004, -0.1950904 0.9807856, " + + "-0.3826836 0.9238799, -0.5555704 0.8314699, -0.707107 0.707107, -0.8314699 0.5555704, " + + "-0.9238799 0.3826836, -0.9807856 0.1950904, -1.0000004 0, -0.9807856 -0.1950904, " + + "-0.9238799 -0.3826836, -0.8314699 -0.5555704, -0.707107 -0.707107, -0.5555704 -0.8314699, " + + "-0.3826836 -0.9238799, -0.1950904 -0.9807856, 0 -1.0000004, 0.1950904 -0.9807856, " + + "0.3826836 -0.9238799, 0.5555704 -0.8314699, 0.707107 -0.707107, 0.8314699 -0.5555704, " + + "0.9238799 -0.3826836, 0.9807856 -0.1950904, 1.0000004 0))"; }