From f9171e12fe660802a48e83ca7ac78f51fd7005e1 Mon Sep 17 00:00:00 2001 From: Craig Taverner Date: Thu, 3 May 2018 15:56:48 +0200 Subject: [PATCH] Procedures and user defined functions for withinPolygon --- algo/pom.xml | 29 +-- .../java/org/amanzi/spatial/algo/Within.java | 41 ++-- .../org/amanzi/spatial/algo/WithinTest.java | 19 ++ core/pom.xml | 2 +- doc/pom.xml | 2 +- .../spatial/neo4j/UserDefinedFunctions.java | 126 ++++++++++++ .../neo4j/UserDefinedFunctionsTest.java | 193 ++++++++++++++++++ pom.xml | 10 +- 8 files changed, 372 insertions(+), 50 deletions(-) create mode 100644 neo4j/src/main/java/org/amanzi/spatial/neo4j/UserDefinedFunctions.java create mode 100644 neo4j/src/test/java/org/amanzi/spatial/neo4j/UserDefinedFunctionsTest.java diff --git a/algo/pom.xml b/algo/pom.xml index 3569772..09459bf 100644 --- a/algo/pom.xml +++ b/algo/pom.xml @@ -6,7 +6,7 @@ org.amanzi spatial-3d-parent - 1.0.0 + 0.1.0 spatial-3d-algo @@ -22,33 +22,6 @@ ${project.version} - - org.neo4j - neo4j - provided - - - - net.biville.florent - neo4j-sproc-compiler - provided - true - - - - org.neo4j - neo4j-kernel - test-jar - test - - - - org.neo4j - neo4j-io - test-jar - test - - junit junit diff --git a/algo/src/main/java/org/amanzi/spatial/algo/Within.java b/algo/src/main/java/org/amanzi/spatial/algo/Within.java index 3202ca7..3df56d1 100644 --- a/algo/src/main/java/org/amanzi/spatial/algo/Within.java +++ b/algo/src/main/java/org/amanzi/spatial/algo/Within.java @@ -2,20 +2,22 @@ import org.amanzi.spatial.core.Point; import org.amanzi.spatial.core.Polygon; -import org.apache.commons.lang3.tuple.Pair; import java.util.ArrayList; -import java.util.Arrays; public class Within { public static boolean within(Polygon polygon, Point point) { + return within(polygon, point, false); + } + + public static boolean within(Polygon polygon, Point point, boolean touching) { for (Polygon.SimplePolygon shell : polygon.getShells()) { - if (!within(shell, point)) { + if (!within(shell, point, touching)) { return false; } } - for (Polygon.SimplePolygon hole : polygon.getShells()) { - if (within(hole, point)) { + for (Polygon.SimplePolygon hole : polygon.getHoles()) { + if (within(hole, point, touching)) { return false; } } @@ -23,6 +25,10 @@ public static boolean within(Polygon polygon, Point point) { } public static boolean within(Polygon.SimplePolygon shell, Point point) { + return within(shell, point, false); + } + + public static boolean within(Polygon.SimplePolygon shell, Point point, boolean touching) { int fixedDim = 0; int compareDim = 1; Point[] points = shell.getPoints(); @@ -34,8 +40,10 @@ public static boolean within(Polygon.SimplePolygon shell, Point point) { Integer compare2 = ternaryComparePointsIgnoringOneDimension(p2.getCoordinate(), point.getCoordinate(), fixedDim); if (compare1 == null || compare2 == null) { // Ignore? - } else if (compare1 * compare2 >= 0) { + } else if (compare1 * compare2 > 0) { // both on same side - ignore + } else if (compare1 * compare2 == 0 && !touching) { + // point touches one or both end points, but we are ignoring touching points } else { Integer compare = ternaryComparePointsIgnoringOneDimension(p1.getCoordinate(), p2.getCoordinate(), fixedDim); if (compare < 0) { @@ -47,22 +55,31 @@ public static boolean within(Polygon.SimplePolygon shell, Point point) { } int intersections = 0; for (Point[] side : sides) { - if (crosses(side, point, fixedDim, compareDim)) { + double crossingValue = crossingAt(side, point, fixedDim, compareDim); + if (touching && crossingValue == 0) { + return true; + } + if (crossingValue >= 0) { intersections += 1; } } return intersections % 2 == 1; } - public static boolean crosses(Point[] side, Point point, int fixedDim, int compareDim) { + static double crossingAt(Point[] side, Point point, int fixedDim, int compareDim) { double[] c = point.getCoordinate(); double[] min = new double[]{side[0].getCoordinate()[fixedDim], side[0].getCoordinate()[compareDim]}; double[] max = new double[]{side[1].getCoordinate()[fixedDim], side[1].getCoordinate()[compareDim]}; double[] diff = new double[]{max[0] - min[0], max[1] - min[1]}; - double ratio = (c[1] - min[1]) / diff[1]; - double offset = ratio * diff[0]; - double crossingValue = min[0] + offset; - return crossingValue >= c[0]; + if (diff[1] == 0) { + // touching a line that runs along the fixed dimension + return 0; + } else { + double ratio = (c[1] - min[1]) / diff[1]; + double offset = ratio * diff[0]; + double crossingValue = min[0] + offset; + return crossingValue - c[0]; + } } public static Integer ternaryComparePointsIgnoringOneDimension(double[] c1, double[] c2, int ignoreDim) { diff --git a/algo/src/test/java/org/amanzi/spatial/algo/WithinTest.java b/algo/src/test/java/org/amanzi/spatial/algo/WithinTest.java index a7c0db7..a683cdb 100644 --- a/algo/src/test/java/org/amanzi/spatial/algo/WithinTest.java +++ b/algo/src/test/java/org/amanzi/spatial/algo/WithinTest.java @@ -2,6 +2,7 @@ import org.amanzi.spatial.core.Point; import org.amanzi.spatial.core.Polygon; +import org.junit.Ignore; import org.junit.Test; import java.util.Arrays; @@ -21,6 +22,24 @@ public void shouldBeWithinSquare() { assertThat(Within.within(square, new Point(0, 20)), equalTo(false)); } + @Ignore + // TODO still some bugs with touching logic + public void shouldBeTouchingSquare() { + Polygon.SimplePolygon square = makeSquare(new double[]{-10, -10}, 20); + for (boolean touching : new boolean[]{false, true}) { + assertThat(Within.within(square, new Point(-10, -20), touching), equalTo(false)); + assertThat(Within.within(square, new Point(-10, -10), touching), equalTo(touching)); + assertThat(Within.within(square, new Point(-10, 0), touching), equalTo(touching)); + assertThat(Within.within(square, new Point(-10, 10), touching), equalTo(touching)); + assertThat(Within.within(square, new Point(-10, 20), touching), equalTo(false)); + assertThat(Within.within(square, new Point(-20, -10), touching), equalTo(false)); + assertThat(Within.within(square, new Point(-10, -10), touching), equalTo(touching)); + assertThat(Within.within(square, new Point(0, -10), touching), equalTo(touching)); + assertThat(Within.within(square, new Point(10, -10), touching), equalTo(touching)); + assertThat(Within.within(square, new Point(20, -10), touching), equalTo(false)); + } + } + private static double[] move(double[] coords, int dim, double move) { double[] moved = Arrays.copyOf(coords, coords.length); moved[dim] += move; diff --git a/core/pom.xml b/core/pom.xml index d1152d5..9ca4511 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -5,7 +5,7 @@ org.amanzi spatial-3d-parent - 1.0.0 + 0.1.0 4.0.0 diff --git a/doc/pom.xml b/doc/pom.xml index 18160f1..96c6801 100644 --- a/doc/pom.xml +++ b/doc/pom.xml @@ -5,7 +5,7 @@ spatial-3d-parent org.amanzi - 1.0.0 + 0.1.0 4.0.0 diff --git a/neo4j/src/main/java/org/amanzi/spatial/neo4j/UserDefinedFunctions.java b/neo4j/src/main/java/org/amanzi/spatial/neo4j/UserDefinedFunctions.java new file mode 100644 index 0000000..ce13bf4 --- /dev/null +++ b/neo4j/src/main/java/org/amanzi/spatial/neo4j/UserDefinedFunctions.java @@ -0,0 +1,126 @@ +package org.amanzi.spatial.neo4j; + +import org.amanzi.spatial.algo.Within; +import org.amanzi.spatial.core.Polygon; +import org.neo4j.graphdb.spatial.CRS; +import org.neo4j.graphdb.spatial.Coordinate; +import org.neo4j.graphdb.spatial.Point; +import org.neo4j.procedure.Name; +import org.neo4j.procedure.Procedure; +import org.neo4j.procedure.UserFunction; +import org.neo4j.values.storable.CoordinateReferenceSystem; + +import java.util.*; +import java.util.stream.Stream; + +public class UserDefinedFunctions { + + @Procedure("amanzi.polygon") + public Stream makePolygon(@Name("points") List points) { + if (points == null || points.size() < 3) { + throw new IllegalArgumentException("Invalid 'points', should be a list of at least 3, but was: " + (points == null ? "null" : points.size())); + } else if (points.get(0).equals(points.get(points.size() - 1))) { + return Stream.of(new PolygonResult(points)); + } else { + ArrayList polygon = new ArrayList<>(points.size() + 1); + polygon.addAll(points); + polygon.add(points.get(0)); + return Stream.of(new PolygonResult(polygon)); + } + } + + @UserFunction("amanzi.boundingBoxFor") + public Map boundingBoxFor(@Name("polygon") List polygon) { + if (polygon == null || polygon.size() < 4) { + throw new IllegalArgumentException("Invalid 'polygon', should be a list of at least 4, but was: " + (polygon == null ? "null" : polygon.size())); + } else if (!polygon.get(0).equals(polygon.get(polygon.size() - 1))) { + throw new IllegalArgumentException("Invalid 'polygon', first and last point should be the same, but were: " + polygon.get(0) + " and " + polygon.get(polygon.size() - 1)); + } else { + CRS crs = polygon.get(0).getCRS(); + double[] min = asPoint(polygon.get(0)).getCoordinate(); + double[] max = asPoint(polygon.get(0)).getCoordinate(); + for (Point p : polygon) { + double[] vertex = asPoint(p).getCoordinate(); + for (int i = 0; i < vertex.length; i++) { + if (vertex[i] < min[i]) { + min[i] = vertex[i]; + } + if (vertex[i] > max[i]) { + max[i] = vertex[i]; + } + } + } + HashMap bbox = new HashMap<>(); + bbox.put("min", asPoint(crs, min)); + bbox.put("max", asPoint(crs, max)); + return bbox; + } + } + + @UserFunction("amanzi.withinPolygon") + public boolean withinPolygon(@Name("point") Point point, @Name("polygon") List polygon, @Name(value = "touching", defaultValue = "false") boolean touching) { + if (polygon == null || polygon.size() < 4) { + throw new IllegalArgumentException("Invalid 'polygon', should be a list of at least 4, but was: " + polygon.size()); + } else if (!polygon.get(0).equals(polygon.get(polygon.size() - 1))) { + throw new IllegalArgumentException("Invalid 'polygon', first and last point should be the same, but were: " + polygon.get(0) + " and " + polygon.get(polygon.size() - 1)); + } else { + CRS polyCrs = polygon.get(0).getCRS(); + CRS pointCrs = point.getCRS(); + if (!polyCrs.equals(pointCrs)) { + throw new IllegalArgumentException("Cannot compare geometries of different CRS: " + polyCrs + " !+ " + pointCrs); + } else { + Polygon geometry = Polygon.simple(asPoints(polygon)); + return Within.within(geometry, asPoint(point), touching); + } + } + } + + private org.amanzi.spatial.core.Point[] asPoints(List polygon) { + org.amanzi.spatial.core.Point[] points = new org.amanzi.spatial.core.Point[polygon.size()]; + for (int i = 0; i < points.length; i++) { + points[i] = asPoint(polygon.get(i)); + } + return points; + } + + private org.amanzi.spatial.core.Point asPoint(Point point) { + List coordinates = point.getCoordinate().getCoordinate(); + double[] coords = new double[coordinates.size()]; + for (int i = 0; i < coords.length; i++) { + coords[i] = coordinates.get(i); + } + return new org.amanzi.spatial.core.Point(coords); + } + + private Point asPoint(CRS crs, double[] coords) { + return new Neo4jPoint(crs, new Coordinate(coords)); + } + + private class Neo4jPoint implements Point { + private final List coordinates; + private final CRS crs; + + private Neo4jPoint(CRS crs, Coordinate coordinate) { + this.crs = crs; + this.coordinates = Arrays.asList(coordinate); + } + + @Override + public List getCoordinates() { + return coordinates; + } + + @Override + public CRS getCRS() { + return crs; + } + } + + public class PolygonResult { + public List polygon; + + private PolygonResult(List points) { + this.polygon = points; + } + } +} diff --git a/neo4j/src/test/java/org/amanzi/spatial/neo4j/UserDefinedFunctionsTest.java b/neo4j/src/test/java/org/amanzi/spatial/neo4j/UserDefinedFunctionsTest.java new file mode 100644 index 0000000..5af55b1 --- /dev/null +++ b/neo4j/src/test/java/org/amanzi/spatial/neo4j/UserDefinedFunctionsTest.java @@ -0,0 +1,193 @@ +package org.amanzi.spatial.neo4j; + +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.neo4j.graphdb.*; +import org.neo4j.graphdb.spatial.Point; +import org.neo4j.helpers.collection.Iterators; +import org.neo4j.internal.kernel.api.exceptions.KernelException; +import org.neo4j.kernel.impl.proc.Procedures; +import org.neo4j.kernel.internal.GraphDatabaseAPI; +import org.neo4j.test.TestGraphDatabaseFactory; +import org.neo4j.values.storable.CoordinateReferenceSystem; +import org.neo4j.values.storable.Values; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +import static org.hamcrest.CoreMatchers.containsString; +import static org.hamcrest.CoreMatchers.equalTo; +import static org.hamcrest.CoreMatchers.instanceOf; +import static org.junit.Assert.assertThat; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +public class UserDefinedFunctionsTest { + private GraphDatabaseService db; + + @Before + public void setUp() throws KernelException { + db = new TestGraphDatabaseFactory().newImpermanentDatabase(); + registerUDFClass(db, UserDefinedFunctions.class); + } + + @After + public void tearDown() throws Exception { + db.shutdown(); + } + + @Test + public void shouldCreatePolygon() { + ArrayList points = new ArrayList<>(); + points.add(Values.pointValue(CoordinateReferenceSystem.Cartesian, 0, 0)); + points.add(Values.pointValue(CoordinateReferenceSystem.Cartesian, 1, 0)); + points.add(Values.pointValue(CoordinateReferenceSystem.Cartesian, 0, 1)); + testCall(db, "CALL amanzi.polygon($points)", map("points", points), result -> { + assertThat("Should have one polygon", result.size(), equalTo(1)); + Object record = result.values().iterator().next(); + assertThat("Should get list of points", record, instanceOf(List.class)); + List polygon = (List) record; + assertThat("Should have 4 points", polygon.size(), equalTo(4)); + assertThat("Should be closed polygon", polygon.get(0), equalTo(polygon.get(polygon.size() - 1))); + }); + } + + @Test + public void shouldFailToMakePolygonFromNullField() { + testCallFails(db, "CALL amanzi.polygon($points)", map("points", null), "Invalid 'points', should be a list of at least 3, but was: null"); + } + + @Test + public void shouldFailToMakePolygonFromEmptyField() { + testCallFails(db, "CALL amanzi.polygon($points)", map("points", new ArrayList()), "Invalid 'points', should be a list of at least 3, but was: 0"); + } + + @Test + public void shouldFailToMakePolygonFromInvalidPoints() { + ArrayList points = new ArrayList<>(); + points.add(Values.pointValue(CoordinateReferenceSystem.Cartesian, 0, 0)); + points.add(Values.pointValue(CoordinateReferenceSystem.Cartesian, 1, 0)); + testCallFails(db, "CALL amanzi.polygon($points)", map("points", points), "Invalid 'points', should be a list of at least 3, but was: 2"); + } + + public static void testCall(GraphDatabaseService db, String call, Consumer> consumer) { + testCall(db, call, null, consumer); + } + + @Test + public void shouldFindBBoxForPolygon() { + ArrayList points = new ArrayList<>(); + points.add(Values.pointValue(CoordinateReferenceSystem.WGS84, 0, 0)); + points.add(Values.pointValue(CoordinateReferenceSystem.WGS84, 10, 0)); + points.add(Values.pointValue(CoordinateReferenceSystem.WGS84, 0, 10)); + testCall(db, "CALL amanzi.polygon($points) YIELD polygon WITH amanzi.boundingBoxFor(polygon) as bbox RETURN bbox", map("points", points), result -> { + assertThat("Should have one result", result.size(), equalTo(1)); + Object record = result.values().iterator().next(); + assertThat("Should get bbox as map", record, instanceOf(Map.class)); + Map bbox = (Map) record; + assertThat("Should have min key", bbox.containsKey("min"), equalTo(true)); + assertThat("Should have max key", bbox.containsKey("max"), equalTo(true)); + assertThat("Should have correct bbox.min", bbox.get("min"), equalTo(Values.pointValue(CoordinateReferenceSystem.WGS84, 0, 0))); + assertThat("Should have correct bbox.max", bbox.get("max"), equalTo(Values.pointValue(CoordinateReferenceSystem.WGS84, 10, 10))); + }); + } + + @Test + public void shouldFindPointInPolygon() { + ArrayList points = new ArrayList<>(); + points.add(Values.pointValue(CoordinateReferenceSystem.WGS84, 0, 0)); + points.add(Values.pointValue(CoordinateReferenceSystem.WGS84, 10, 0)); + points.add(Values.pointValue(CoordinateReferenceSystem.WGS84, 0, 10)); + Point a = Values.pointValue(CoordinateReferenceSystem.WGS84, 1, 1); + Point b = Values.pointValue(CoordinateReferenceSystem.WGS84, 9, 9); + testCall(db, "CALL amanzi.polygon($points) YIELD polygon WITH polygon, amanzi.boundingBoxFor(polygon) as bbox RETURN amanzi.withinPolygon($a,polygon) as a, amanzi.withinPolygon($b,polygon) as b", map("points", points, "a", a, "b", b), result -> { + assertThat("Should get result as map", result, instanceOf(Map.class)); + Map results = (Map) result; + assertThat("Should have 'a' key", results.containsKey("a"), equalTo(true)); + assertThat("Should have 'b' key", results.containsKey("b"), equalTo(true)); + assertThat("'a' should be inside polygon", results.get("a"), equalTo(true)); + assertThat("'b' should be outside polygon", results.get("b"), equalTo(false)); + }); + } + + public static Map map(Object... values) { + Map map = new LinkedHashMap<>(); + for (int i = 0; i < values.length; i += 2) { + map.put(values[i].toString(), values[i + 1]); + } + return map; + } + + public static void testCall(GraphDatabaseService db, String call, Map params, Consumer> consumer) { + testCall(db, call, params, consumer, true); + } + + public static void testCallFails(GraphDatabaseService db, String call, Map params, String error) { + try { + testResult(db, call, params, (res) -> { + while (res.hasNext()) { + res.next(); + } + }); + fail("Expected an exception containing '" + error + "', but no exception was thrown"); + } catch (Exception e) { + assertThat(e.getMessage(), containsString(error)); + } + } + + public static void testCall(GraphDatabaseService db, String call, Map params, Consumer> consumer, boolean onlyOne) { + testResult(db, call, params, (res) -> { + if (res.hasNext()) { + Map row = res.next(); + consumer.accept(row); + } + if (onlyOne) { + Assert.assertFalse(res.hasNext()); + } + }); + } + + public static void testCallCount(GraphDatabaseService db, String call, Map params, int count) { + testResult(db, call, params, (res) -> { + int numLeft = count; + while (numLeft > 0) { + assertTrue("Expected " + count + " results but found only " + (count - numLeft), res.hasNext()); + res.next(); + numLeft--; + } + Assert.assertFalse("Expected " + count + " results but there are more", res.hasNext()); + }); + } + + public static void testResult(GraphDatabaseService db, String call, Consumer resultConsumer) { + testResult(db, call, null, resultConsumer); + } + + private static void testResult(GraphDatabaseService db, String call, Map params, Consumer resultConsumer) { + try (Transaction tx = db.beginTx()) { + Map p = (params == null) ? map() : params; + resultConsumer.accept(db.execute(call, p)); + tx.success(); + } + } + + private static void registerUDFClass(GraphDatabaseService db, Class udfClass) throws KernelException { + Procedures procedures = ((GraphDatabaseAPI) db).getDependencyResolver().resolveDependency(Procedures.class); + procedures.registerProcedure(udfClass); + procedures.registerFunction(udfClass); + } + + private long execute(String statement) { + return Iterators.count(db.execute(statement)); + } + + private long execute(String statement, Map params) { + return Iterators.count(db.execute(statement, params)); + } + +} diff --git a/pom.xml b/pom.xml index 8144597..5611ed0 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ org.amanzi spatial-3d-parent - 1.0.0 + 0.1.0 pom Spatial 3D Utilities and Algorithms for 3D Spatial Analysis @@ -13,6 +13,7 @@ core algo + neo4j doc @@ -77,13 +78,6 @@ test-jar test - - net.biville.florent - neo4j-sproc-compiler - 1.2 - provided - true -