diff --git a/pom.xml b/pom.xml index 12e9558612..96ff02f20f 100755 --- a/pom.xml +++ b/pom.xml @@ -88,7 +88,7 @@ 3.0.4 1.6.3 0.7 - 1.15.0 + 1.18.2 1.67 2.0.1 42.2.20 diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/geo/GeoUtil.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/geo/GeoUtil.java index 76868b1f57..d89699c123 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/geo/GeoUtil.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/geo/GeoUtil.java @@ -18,20 +18,35 @@ package org.thingsboard.rule.engine.geo; import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonParser; +import lombok.NonNull; +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.CoordinateSequence; +import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.geom.util.GeometryFixer; import org.locationtech.spatial4j.context.SpatialContext; import org.locationtech.spatial4j.context.jts.JtsSpatialContext; import org.locationtech.spatial4j.context.jts.JtsSpatialContextFactory; import org.locationtech.spatial4j.distance.DistanceUtils; import org.locationtech.spatial4j.shape.Point; -import org.locationtech.spatial4j.shape.Shape; -import org.locationtech.spatial4j.shape.ShapeFactory; import org.locationtech.spatial4j.shape.SpatialRelation; +import org.locationtech.spatial4j.shape.jts.JtsGeometry; + +import java.util.Collection; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; public class GeoUtil { private static final SpatialContext distCtx = SpatialContext.GEO; private static final JtsSpatialContext jtsCtx; + private static final JsonParser JSON_PARSER = new JsonParser(); + static { JtsSpatialContextFactory factory = new JtsSpatialContextFactory(); factory.normWrapLongitude = true; @@ -44,43 +59,148 @@ public class GeoUtil { return unit.fromKm(distCtx.getDistCalc().distance(xLL, yLL) * DistanceUtils.DEG_TO_KM); } - public static synchronized boolean contains(String polygon, Coordinates coordinates) { - JsonArray polygonArray = new JsonParser().parse(polygon).getAsJsonArray(); - - JsonArray arrayWithCoords = polygonArray; - JsonArray innerArray = polygonArray.get(0).getAsJsonArray(); - if (!containsPrimitives(innerArray)) { - arrayWithCoords = innerArray; + public static synchronized boolean contains(@NonNull String polygonInString, @NonNull Coordinates coordinates) { + if (polygonInString.isEmpty() || polygonInString.isBlank()) { + throw new RuntimeException("Polygon string can't be empty or null!"); } - Shape shape = buildPolygonFromJsonCoords(arrayWithCoords); - Point point = jtsCtx.getShapeFactory().pointXY(coordinates.getLongitude(), coordinates.getLatitude()); - return shape.relate(point).equals(SpatialRelation.CONTAINS); + JsonArray polygonsJson = normalizePolygonsJson(JSON_PARSER.parse(polygonInString).getAsJsonArray()); + List polygons = buildPolygonsFromJson(polygonsJson); + Set holes = extractHolesFrom(polygons); + polygons.removeIf(holes::contains); + + Geometry globalGeometry = unionToGlobalGeometry(polygons, holes); + var point = jtsCtx.getShapeFactory().getGeometryFactory() + .createPoint(new Coordinate(coordinates.getLatitude(), coordinates.getLongitude())); + + return globalGeometry.contains(point); } - private static Shape buildPolygonFromJsonCoords(JsonArray coordinates) { - ShapeFactory.PolygonBuilder polygonBuilder = jtsCtx.getShapeFactory().polygon(); - boolean isFirst = true; - double firstLat = 0.0; - double firstLng = 0.0; - for (JsonElement element : coordinates) { - double lat = element.getAsJsonArray().get(0).getAsDouble(); - double lng = element.getAsJsonArray().get(1).getAsDouble(); - if (isFirst) { - firstLat = lat; - firstLng = lng; - isFirst = false; - } - polygonBuilder.pointXY(jtsCtx.getShapeFactory().normX(lng), jtsCtx.getShapeFactory().normX(lat)); + private static Geometry unionToGlobalGeometry(List polygons, Set holes) { + Geometry globalPolygon = polygons.stream().reduce(Geometry::union).orElseThrow(() -> + new RuntimeException("Error while calculating globalPolygon - the result of all polygons union is null")); + Optional globalHole = holes.stream().reduce(Geometry::union); + if (globalHole.isEmpty()) { + return globalPolygon; + } else { + return globalPolygon.difference(globalHole.get()); } - polygonBuilder.pointXY(jtsCtx.getShapeFactory().normX(firstLng), jtsCtx.getShapeFactory().normX(firstLat)); - return polygonBuilder.buildOrRect(); + } + + private static JsonArray normalizePolygonsJson(JsonArray polygonsJsonArray) { + JsonArray result = new JsonArray(); + normalizePolygonsJson(polygonsJsonArray, result); + return result; + } + + private static void normalizePolygonsJson(JsonArray polygonsJsonArray, JsonArray result) { + if (containsArrayWithPrimitives(polygonsJsonArray)) { + result.add(polygonsJsonArray); + } else { + for (JsonElement element : polygonsJsonArray) { + if (containsArrayWithPrimitives(element.getAsJsonArray())) { + result.add(element); + } else { + normalizePolygonsJson(element.getAsJsonArray(), result); + } + } + } + } + + private static Set extractHolesFrom(List polygons) { + Map> polygonsHoles = new HashMap<>(); + + for (Geometry polygon : polygons) { + List holes = polygons.stream() + .filter(another -> !another.equalsExact(polygon)) + .filter(another -> { + JtsGeometry currentGeo = jtsCtx.getShapeFactory().makeShape(polygon); + JtsGeometry anotherGeo = jtsCtx.getShapeFactory().makeShape(another); + + boolean currentContainsAnother = currentGeo + .relate(anotherGeo) + .equals(SpatialRelation.CONTAINS); + + boolean anotherWithinCurrent = anotherGeo + .relate(currentGeo) + .equals(SpatialRelation.WITHIN); + + return currentContainsAnother && anotherWithinCurrent; + }) + .collect(Collectors.toList()); + + if (!holes.isEmpty()) { + polygonsHoles.put(polygon, holes); + } + } + + return polygonsHoles.values().stream().flatMap(Collection::stream).collect(Collectors.toSet()); + } + + private static List buildPolygonsFromJson(JsonArray polygonsJsonArray) { + List polygons = new LinkedList<>(); + + for (JsonElement polygonJsonArray : polygonsJsonArray) { + polygons.add( + buildPolygonFromCoordinates(parseCoordinates(polygonJsonArray.getAsJsonArray())) + ); + } + + return polygons; + } + + private static Geometry buildPolygonFromCoordinates(List coordinates) { + if (coordinates.size() == 2) { + Coordinate a = coordinates.get(0); + Coordinate c = coordinates.get(1); + coordinates.clear(); + + Coordinate b = new Coordinate(a.x, c.y); + Coordinate d = new Coordinate(c.x, a.y); + coordinates.addAll(List.of(a, b, c, d, a)); + } + + CoordinateSequence coordinateSequence = jtsCtx + .getShapeFactory() + .getGeometryFactory() + .getCoordinateSequenceFactory() + .create(coordinates.toArray(new Coordinate[0])); + + return GeometryFixer.fix(jtsCtx.getShapeFactory().getGeometryFactory().createPolygon(coordinateSequence)); + } + + private static List parseCoordinates(JsonArray coordinatesJson) { + List result = new LinkedList<>(); + + for (JsonElement coords : coordinatesJson) { + double x = coords.getAsJsonArray().get(0).getAsDouble(); + double y = coords.getAsJsonArray().get(1).getAsDouble(); + result.add(new Coordinate(x, y)); + } + + if (result.size() >= 3) { + result.add(result.get(0)); + } + + return result; } private static boolean containsPrimitives(JsonArray array) { for (JsonElement element : array) { return element.isJsonPrimitive(); } + return false; } + + private static boolean containsArrayWithPrimitives(JsonArray array) { + for (JsonElement element : array) { + if (!containsPrimitives(element.getAsJsonArray())) { + return false; + } + } + + return true; + } + } diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/geo/TbGeoUtilTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/geo/TbGeoUtilTest.java new file mode 100644 index 0000000000..6563fcad1e --- /dev/null +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/geo/TbGeoUtilTest.java @@ -0,0 +1,163 @@ +/** + * Copyright © 2016-2021 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.rule.engine.geo; + +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.junit.MockitoJUnitRunner; + +@RunWith(MockitoJUnitRunner.class) +public class TbGeoUtilTest { + + public static final String SIMPLE_RECT = "[[51.903762928405555,23.642220786948297],[44.669801219635644,41.83345155830211]]"; + public static final String SIMPLE_RECT_WITH_HOLE_IN_CENTER = "[[[44.66980121963565,23.642220786948297],[44.66980121963565,41.83345155830211],[51.903762928405555,41.83345155830211],[51.903762928405555,23.642220786948297]],[[46.10464044504632,26.234282119122227],[50.8755868028522,26.25625220459488],[51.04164771375101,38.5595000692786],[45.99790855491869,38.75723083853248]]]"; + public static final String SAND_CLOCK = "[[47.45865912532852,25.531200822337155],[49.76760268310416,29.353995694578202],[51.42691963936519,25.355440138555966],[51.413219087617655,39.41629484105169],[49.78179072754938,33.37452133607305],[47.81395478511215,38.867042704235466]]"; + public static final String SAND_CLOCK_WITH_HOLE_IN_CENTER = "[[[51.426919639365195,25.355440138555966],[49.76760268310416,29.353995694578202],[47.45865912532852,25.531200822337155],[47.81395478511215,38.867042704235466],[49.78179072754938,33.37452133607305],[51.413219087617655,39.41629484105169]],[[49.8243299406579,30.210829028011513],[50.34591034217041,31.04569227597222],[49.56853374046142,32.53965808811239],[49.02409241675058,31.57297432731579]]]"; + public static final String SELF_INTERSECTING = "[[47.42893833699058,27.178954662008522],[51.71367987390804,37.46095466320854],[51.659197648757306,27.947907653551276],[47.41407351681856,37.46095466320854]]"; + public static final String SELF_INTERSECTING_WITH_HOLES = "[[[[47.42893833699058,27.17895466200852],[47.41407351681856,37.46095466320853],[49.63741575793274,32.47858934221699]],[[47.84342032696093,29.20045703244998],[49.124803688667576,32.38611942598416],[47.858163521459254,34.714948486085035]]],[[[51.659197648757306,27.947907653551276],[49.63741575793274,32.47858934221699],[51.71367987390804,37.46095466320853]],[[51.20718653775245,30.738363015535448],[51.317169097567344,34.58312797324915],[50.1351109330126,32.51793993882006]]]]"; + + public static final Coordinates POINT_INSIDE_SIMPLE_RECT_CENTER = new Coordinates(48.37082198780869, 32.673342414527355); + public static final Coordinates POINT_INSIDE_SIMPLE_RECT_NEAR_BORDER = new Coordinates(48.42916753187315,40.956064637716224); + public static final Coordinates POINT_OUTSIDE_SIMPLE_RECT = new Coordinates(52.94806646045028,32.91501335472649); + public static final Coordinates POINT_INSIDE_SAND_CLOCK_CENTER = new Coordinates(49.993588800145105,31.289062500000004); + public static final Coordinates POINT_INSIDE_SAND_CLOCK_NEAR_BORDER = new Coordinates(47.798651123976306,26.895045405470082); + public static final Coordinates POINT_OUTSIDE_SAND_CLOCK_1 = new Coordinates(49.553754212665936,28.03748985004787); + public static final Coordinates POINT_OUTSIDE_SAND_CLOCK_2 = new Coordinates(46.9802466961145,32.321728498980335); + public static final Coordinates POINT_INSIDE_SELF_INTERSECTING_UPPER_CENTER = new Coordinates(50.750366308834884,32.51952867922265); + public static final Coordinates POINT_INSIDE_SELF_INTERSECTING_LOWER_CENTER = new Coordinates(48.1371117277312,32.40967825185941); + public static final Coordinates POINT_INSIDE_SELF_INTERSECTING_NEAR_BORDER = new Coordinates(51.16552151942722,35.66125090181154); + public static final Coordinates POINT_OUTSIDE_SELF_INTERSECTING_1= new Coordinates(49.66777277299077,33.26651158529272); + public static final Coordinates POINT_OUTSIDE_SELF_INTERSECTING_2 = new Coordinates(47.10052840114779,32.16800731166027); + public static final Coordinates POINT_OUTSIDE_SELF_INTERSECTING_3 = new Coordinates(78.76578380252519,15.646485040786361); + + + @Test + public void testPointsInSimplePolygons() { + Assert.assertTrue("Polygon " + SIMPLE_RECT + " must contain the dot " + POINT_INSIDE_SIMPLE_RECT_CENTER, + GeoUtil.contains(SIMPLE_RECT, POINT_INSIDE_SIMPLE_RECT_CENTER) + ); + Assert.assertTrue("Polygon " + SIMPLE_RECT + " must contain the dot " + POINT_INSIDE_SIMPLE_RECT_NEAR_BORDER, + GeoUtil.contains(SIMPLE_RECT, POINT_INSIDE_SIMPLE_RECT_NEAR_BORDER) + ); + Assert.assertTrue("Polygon " + SIMPLE_RECT_WITH_HOLE_IN_CENTER + " must contain the dot " + + POINT_INSIDE_SIMPLE_RECT_NEAR_BORDER, + GeoUtil.contains(SIMPLE_RECT_WITH_HOLE_IN_CENTER, POINT_INSIDE_SIMPLE_RECT_NEAR_BORDER) + ); + + Assert.assertFalse("Polygon " + SIMPLE_RECT + " must not contain the dot " + + POINT_OUTSIDE_SIMPLE_RECT, + GeoUtil.contains(SIMPLE_RECT, POINT_OUTSIDE_SIMPLE_RECT) + ); + Assert.assertFalse("Polygon " + SIMPLE_RECT_WITH_HOLE_IN_CENTER + " must not contain the dot " + + POINT_OUTSIDE_SIMPLE_RECT, + GeoUtil.contains(SIMPLE_RECT_WITH_HOLE_IN_CENTER, POINT_OUTSIDE_SIMPLE_RECT) + ); + Assert.assertFalse("Polygon " + SIMPLE_RECT_WITH_HOLE_IN_CENTER + " must not contain the dot " + + POINT_INSIDE_SIMPLE_RECT_CENTER, + GeoUtil.contains(SIMPLE_RECT_WITH_HOLE_IN_CENTER, POINT_INSIDE_SIMPLE_RECT_CENTER) + ); + } + + @Test + public void testPointsInComplexPolygons() { + Assert.assertTrue("Polygon " + SAND_CLOCK + " must contain the dot " + POINT_INSIDE_SAND_CLOCK_CENTER, + GeoUtil.contains(SAND_CLOCK, POINT_INSIDE_SAND_CLOCK_CENTER) + ); + Assert.assertTrue("Polygon " + SAND_CLOCK + " must contain the dot " + POINT_INSIDE_SAND_CLOCK_NEAR_BORDER, + GeoUtil.contains(SAND_CLOCK, POINT_INSIDE_SAND_CLOCK_NEAR_BORDER) + ); + Assert.assertTrue("Polygon " + SAND_CLOCK_WITH_HOLE_IN_CENTER + " must contain the dot " + + POINT_INSIDE_SAND_CLOCK_NEAR_BORDER, + GeoUtil.contains(SAND_CLOCK_WITH_HOLE_IN_CENTER, POINT_INSIDE_SAND_CLOCK_NEAR_BORDER) + ); + + Assert.assertFalse("Polygon " + SAND_CLOCK + " must not contain the dot " + + POINT_OUTSIDE_SAND_CLOCK_1, + GeoUtil.contains(SAND_CLOCK, POINT_OUTSIDE_SAND_CLOCK_1) + ); + Assert.assertFalse("Polygon " + SAND_CLOCK + " must not contain the dot " + + POINT_OUTSIDE_SAND_CLOCK_2, + GeoUtil.contains(SAND_CLOCK, POINT_OUTSIDE_SAND_CLOCK_2) + ); + Assert.assertFalse("Polygon " + SAND_CLOCK_WITH_HOLE_IN_CENTER + " must not contain the dot " + + POINT_INSIDE_SAND_CLOCK_CENTER, + GeoUtil.contains(SAND_CLOCK_WITH_HOLE_IN_CENTER, POINT_INSIDE_SAND_CLOCK_CENTER) + ); + Assert.assertFalse("Polygon " + SAND_CLOCK_WITH_HOLE_IN_CENTER + " must not contain the dot " + + POINT_OUTSIDE_SAND_CLOCK_1, + GeoUtil.contains(SAND_CLOCK_WITH_HOLE_IN_CENTER, POINT_OUTSIDE_SAND_CLOCK_1) + ); + Assert.assertFalse("Polygon " + SAND_CLOCK_WITH_HOLE_IN_CENTER + " must not contain the dot " + + POINT_OUTSIDE_SAND_CLOCK_2, + GeoUtil.contains(SAND_CLOCK_WITH_HOLE_IN_CENTER, POINT_OUTSIDE_SAND_CLOCK_2) + ); + } + + @Test + public void testPointsInSelfIntersectingPolygons() { + Assert.assertTrue("Polygon " + SELF_INTERSECTING + " must contain the dot " + + POINT_INSIDE_SELF_INTERSECTING_UPPER_CENTER, + GeoUtil.contains(SELF_INTERSECTING, POINT_INSIDE_SELF_INTERSECTING_UPPER_CENTER) + ); + Assert.assertTrue("Polygon " + SELF_INTERSECTING + " must contain the dot " + + POINT_INSIDE_SELF_INTERSECTING_LOWER_CENTER, + GeoUtil.contains(SELF_INTERSECTING, POINT_INSIDE_SELF_INTERSECTING_LOWER_CENTER) + ); + Assert.assertTrue("Polygon " + SELF_INTERSECTING + " must contain the dot " + + POINT_INSIDE_SELF_INTERSECTING_NEAR_BORDER, + GeoUtil.contains(SELF_INTERSECTING, POINT_INSIDE_SELF_INTERSECTING_NEAR_BORDER) + ); + Assert.assertTrue("Polygon " + SELF_INTERSECTING_WITH_HOLES + " must contain the dot " + + POINT_INSIDE_SAND_CLOCK_NEAR_BORDER, + GeoUtil.contains(SELF_INTERSECTING_WITH_HOLES, POINT_INSIDE_SELF_INTERSECTING_NEAR_BORDER) + ); + + Assert.assertFalse("Polygon " + SELF_INTERSECTING + " must not contain the dot " + + POINT_OUTSIDE_SELF_INTERSECTING_1, + GeoUtil.contains(SELF_INTERSECTING, POINT_OUTSIDE_SELF_INTERSECTING_1) + ); + Assert.assertFalse("Polygon " + SELF_INTERSECTING + " must not contain the dot " + + POINT_OUTSIDE_SELF_INTERSECTING_2, + GeoUtil.contains(SELF_INTERSECTING, POINT_OUTSIDE_SELF_INTERSECTING_2) + ); + Assert.assertFalse("Polygon " + SELF_INTERSECTING + " must not contain the dot " + + POINT_OUTSIDE_SELF_INTERSECTING_3, + GeoUtil.contains(SELF_INTERSECTING, POINT_OUTSIDE_SELF_INTERSECTING_3) + ); + Assert.assertFalse("Polygon " + SELF_INTERSECTING_WITH_HOLES + " must not contain the dot " + + POINT_OUTSIDE_SELF_INTERSECTING_1, + GeoUtil.contains(SELF_INTERSECTING_WITH_HOLES, POINT_OUTSIDE_SELF_INTERSECTING_1) + ); + Assert.assertFalse("Polygon " + SELF_INTERSECTING_WITH_HOLES + " must not contain the dot " + + POINT_OUTSIDE_SELF_INTERSECTING_2, + GeoUtil.contains(SELF_INTERSECTING_WITH_HOLES, POINT_OUTSIDE_SELF_INTERSECTING_2) + ); + Assert.assertFalse("Polygon " + SELF_INTERSECTING_WITH_HOLES + " must not contain the dot " + + POINT_OUTSIDE_SELF_INTERSECTING_3, + GeoUtil.contains(SELF_INTERSECTING_WITH_HOLES, POINT_OUTSIDE_SELF_INTERSECTING_3) + ); + Assert.assertFalse("Polygon " + SELF_INTERSECTING_WITH_HOLES + " must not contain the dot " + + POINT_INSIDE_SELF_INTERSECTING_UPPER_CENTER, + GeoUtil.contains(SELF_INTERSECTING_WITH_HOLES, POINT_INSIDE_SELF_INTERSECTING_UPPER_CENTER) + ); + Assert.assertFalse("Polygon " + SELF_INTERSECTING_WITH_HOLES + " must not contain the dot " + + POINT_INSIDE_SELF_INTERSECTING_LOWER_CENTER, + GeoUtil.contains(SELF_INTERSECTING_WITH_HOLES, POINT_INSIDE_SELF_INTERSECTING_LOWER_CENTER) + ); + } + +}