From 49a6349c356c8bb10acf10c51ed622d9440e70f9 Mon Sep 17 00:00:00 2001 From: Martin Davis Date: Wed, 12 Feb 2025 09:42:58 -0800 Subject: [PATCH] Fix buffer element erosion for negative distance and remove overlay deps (#1119) --- .../jts/operation/buffer/BufferBuilder.java | 6 +- .../operation/buffer/BufferNodeFactory.java | 36 +++ .../jts/operation/buffer/MaximalEdgeRing.java | 86 ++++++ .../jts/operation/buffer/MinimalEdgeRing.java | 44 +++ .../jts/operation/buffer/PolygonBuilder.java | 276 ++++++++++++++++++ .../jts/operation/buffer/BufferTest.java | 8 + 6 files changed, 451 insertions(+), 5 deletions(-) create mode 100644 modules/core/src/main/java/org/locationtech/jts/operation/buffer/BufferNodeFactory.java create mode 100644 modules/core/src/main/java/org/locationtech/jts/operation/buffer/MaximalEdgeRing.java create mode 100644 modules/core/src/main/java/org/locationtech/jts/operation/buffer/MinimalEdgeRing.java create mode 100644 modules/core/src/main/java/org/locationtech/jts/operation/buffer/PolygonBuilder.java diff --git a/modules/core/src/main/java/org/locationtech/jts/operation/buffer/BufferBuilder.java b/modules/core/src/main/java/org/locationtech/jts/operation/buffer/BufferBuilder.java index 580de1b413..9fa34ba197 100644 --- a/modules/core/src/main/java/org/locationtech/jts/operation/buffer/BufferBuilder.java +++ b/modules/core/src/main/java/org/locationtech/jts/operation/buffer/BufferBuilder.java @@ -40,10 +40,6 @@ import org.locationtech.jts.noding.MCIndexNoder; import org.locationtech.jts.noding.Noder; import org.locationtech.jts.noding.SegmentString; -import org.locationtech.jts.operation.overlay.OverlayNodeFactory; -import org.locationtech.jts.operation.overlay.PolygonBuilder; - - /** * Builds the buffer geometry for a given input geometry and precision model. @@ -167,7 +163,7 @@ public Geometry buffer(Geometry g, double distance) boolean isNodingValidated = distance == 0.0; computeNodedEdges(bufferSegStrList, precisionModel, isNodingValidated); - graph = new PlanarGraph(new OverlayNodeFactory()); + graph = new PlanarGraph(new BufferNodeFactory()); graph.addEdges(edgeList.getEdges()); List subgraphList = createSubgraphs(graph); diff --git a/modules/core/src/main/java/org/locationtech/jts/operation/buffer/BufferNodeFactory.java b/modules/core/src/main/java/org/locationtech/jts/operation/buffer/BufferNodeFactory.java new file mode 100644 index 0000000000..4cf3d48318 --- /dev/null +++ b/modules/core/src/main/java/org/locationtech/jts/operation/buffer/BufferNodeFactory.java @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2016 Vivid Solutions. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * and Eclipse Distribution License v. 1.0 which accompanies this distribution. + * The Eclipse Public License is available at http://www.eclipse.org/legal/epl-v20.html + * and the Eclipse Distribution License is available at + * + * http://www.eclipse.org/org/documents/edl-v10.php. + */ +package org.locationtech.jts.operation.buffer; + +/** + * @version 1.7 + */ +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geomgraph.DirectedEdgeStar; +import org.locationtech.jts.geomgraph.Node; +import org.locationtech.jts.geomgraph.NodeFactory; +import org.locationtech.jts.geomgraph.PlanarGraph; + +/** + * Creates nodes for use in the {@link PlanarGraph}s constructed during + * buffer operations. + * + * @version 1.7 + */ +class BufferNodeFactory + extends NodeFactory +{ + public Node createNode(Coordinate coord) + { + return new Node(coord, new DirectedEdgeStar()); + } +} diff --git a/modules/core/src/main/java/org/locationtech/jts/operation/buffer/MaximalEdgeRing.java b/modules/core/src/main/java/org/locationtech/jts/operation/buffer/MaximalEdgeRing.java new file mode 100644 index 0000000000..c4c7caf80c --- /dev/null +++ b/modules/core/src/main/java/org/locationtech/jts/operation/buffer/MaximalEdgeRing.java @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2016 Vivid Solutions. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * and Eclipse Distribution License v. 1.0 which accompanies this distribution. + * The Eclipse Public License is available at http://www.eclipse.org/legal/epl-v20.html + * and the Eclipse Distribution License is available at + * + * http://www.eclipse.org/org/documents/edl-v10.php. + */ +package org.locationtech.jts.operation.buffer; + +import java.util.ArrayList; +import java.util.List; + +import org.locationtech.jts.geom.GeometryFactory; +import org.locationtech.jts.geomgraph.DirectedEdge; +import org.locationtech.jts.geomgraph.DirectedEdgeStar; +import org.locationtech.jts.geomgraph.EdgeRing; +import org.locationtech.jts.geomgraph.Node; + +/** + * A ring of {@link DirectedEdge}s which may contain nodes of degree > 2. + * A MaximalEdgeRing may represent two different spatial entities: + * + * If the MaximalEdgeRing represents a polygon, + * the interior of the polygon is strongly connected. + *

+ * These are the form of rings used to define polygons under some spatial data models. + * However, under the OGC SFS model, {@link MinimalEdgeRing}s are required. + * A MaximalEdgeRing can be converted to a list of MinimalEdgeRings using the + * {@link #buildMinimalRings() } method. + * + * @version 1.7 + * @see org.locationtech.jts.operation.buffer.MinimalEdgeRing + */ +class MaximalEdgeRing + extends EdgeRing +{ + + public MaximalEdgeRing(DirectedEdge start, GeometryFactory geometryFactory) { + super(start, geometryFactory); + } + + public DirectedEdge getNext(DirectedEdge de) + { + return de.getNext(); + } + public void setEdgeRing(DirectedEdge de, EdgeRing er) + { + de.setEdgeRing(er); + } + + /** + * For all nodes in this EdgeRing, + * link the DirectedEdges at the node to form minimalEdgeRings + */ + public void linkDirectedEdgesForMinimalEdgeRings() + { + DirectedEdge de = startDe; + do { + Node node = de.getNode(); + ((DirectedEdgeStar) node.getEdges()).linkMinimalDirectedEdges(this); + de = de.getNext(); + } while (de != startDe); + } + + public List buildMinimalRings() + { + List minEdgeRings = new ArrayList(); + DirectedEdge de = startDe; + do { + if (de.getMinEdgeRing() == null) { + EdgeRing minEr = new MinimalEdgeRing(de, geometryFactory); + minEdgeRings.add(minEr); + } + de = de.getNext(); + } while (de != startDe); + return minEdgeRings; + } + +} diff --git a/modules/core/src/main/java/org/locationtech/jts/operation/buffer/MinimalEdgeRing.java b/modules/core/src/main/java/org/locationtech/jts/operation/buffer/MinimalEdgeRing.java new file mode 100644 index 0000000000..bc2799bda5 --- /dev/null +++ b/modules/core/src/main/java/org/locationtech/jts/operation/buffer/MinimalEdgeRing.java @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2016 Vivid Solutions. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * and Eclipse Distribution License v. 1.0 which accompanies this distribution. + * The Eclipse Public License is available at http://www.eclipse.org/legal/epl-v20.html + * and the Eclipse Distribution License is available at + * + * http://www.eclipse.org/org/documents/edl-v10.php. + */ +package org.locationtech.jts.operation.buffer; + +import org.locationtech.jts.geom.GeometryFactory; +import org.locationtech.jts.geomgraph.DirectedEdge; +import org.locationtech.jts.geomgraph.Edge; +import org.locationtech.jts.geomgraph.EdgeRing; + +/** + * A ring of {@link Edge}s with the property that no node + * has degree greater than 2. These are the form of rings required + * to represent polygons under the OGC SFS spatial data model. + * + * @version 1.7 + * @see org.locationtech.jts.operation.buffer.MaximalEdgeRing + */ +class MinimalEdgeRing + extends EdgeRing +{ + + public MinimalEdgeRing(DirectedEdge start, GeometryFactory geometryFactory) { + super(start, geometryFactory); + } + + public DirectedEdge getNext(DirectedEdge de) + { + return de.getNextMin(); + } + public void setEdgeRing(DirectedEdge de, EdgeRing er) + { + de.setMinEdgeRing(er); + } + +} diff --git a/modules/core/src/main/java/org/locationtech/jts/operation/buffer/PolygonBuilder.java b/modules/core/src/main/java/org/locationtech/jts/operation/buffer/PolygonBuilder.java new file mode 100644 index 0000000000..8deadd87a6 --- /dev/null +++ b/modules/core/src/main/java/org/locationtech/jts/operation/buffer/PolygonBuilder.java @@ -0,0 +1,276 @@ +/* + * Copyright (c) 2016 Vivid Solutions. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License 2.0 + * and Eclipse Distribution License v. 1.0 which accompanies this distribution. + * The Eclipse Public License is available at http://www.eclipse.org/legal/epl-v20.html + * and the Eclipse Distribution License is available at + * + * http://www.eclipse.org/org/documents/edl-v10.php. + */ +package org.locationtech.jts.operation.buffer; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Iterator; +import java.util.List; + +import org.locationtech.jts.algorithm.PointLocation; +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.CoordinateArrays; +import org.locationtech.jts.geom.Envelope; +import org.locationtech.jts.geom.GeometryFactory; +import org.locationtech.jts.geom.LinearRing; +import org.locationtech.jts.geom.Polygon; +import org.locationtech.jts.geomgraph.DirectedEdge; +import org.locationtech.jts.geomgraph.EdgeRing; +import org.locationtech.jts.geomgraph.PlanarGraph; +import org.locationtech.jts.util.Assert; + +/** + * Forms {@link Polygon}s out of a graph of {@link DirectedEdge}s. + * The edges to use are marked as being in the result Area. + *

+ * + * @version 1.7 + */ +class PolygonBuilder { + + private GeometryFactory geometryFactory; + private List shellList = new ArrayList(); + + public PolygonBuilder(GeometryFactory geometryFactory) + { + this.geometryFactory = geometryFactory; + } + + /** + * Add a complete graph. + * The graph is assumed to contain one or more polygons, + * possibly with holes. + */ + public void add(PlanarGraph graph) + { + add(graph.getEdgeEnds(), graph.getNodes()); + } + + /** + * Add a set of edges and nodes, which form a graph. + * The graph is assumed to contain one or more polygons, + * possibly with holes. + */ + public void add(Collection dirEdges, Collection nodes) + { + PlanarGraph.linkResultDirectedEdges(nodes); + List maxEdgeRings = buildMaximalEdgeRings(dirEdges); + List freeHoleList = new ArrayList(); + List edgeRings = buildMinimalEdgeRings(maxEdgeRings, shellList, freeHoleList); + sortShellsAndHoles(edgeRings, shellList, freeHoleList); + placeFreeHoles(shellList, freeHoleList); + //Assert: every hole on freeHoleList has a shell assigned to it + } + + public List getPolygons() + { + List resultPolyList = computePolygons(shellList); + return resultPolyList; + } + + + /** + * for all DirectedEdges in result, form them into MaximalEdgeRings + */ + private List buildMaximalEdgeRings(Collection dirEdges) + { + List maxEdgeRings = new ArrayList(); + for (Iterator it = dirEdges.iterator(); it.hasNext(); ) { + DirectedEdge de = (DirectedEdge) it.next(); + if (de.isInResult() && de.getLabel().isArea() ) { + // if this edge has not yet been processed + if (de.getEdgeRing() == null) { + MaximalEdgeRing er = new MaximalEdgeRing(de, geometryFactory); + maxEdgeRings.add(er); + er.setInResult(); +//System.out.println("max node degree = " + er.getMaxDegree()); + } + } + } + return maxEdgeRings; + } + + private List buildMinimalEdgeRings(List maxEdgeRings, List shellList, List freeHoleList) + { + List edgeRings = new ArrayList(); + for (Iterator it = maxEdgeRings.iterator(); it.hasNext(); ) { + MaximalEdgeRing er = (MaximalEdgeRing) it.next(); + if (er.getMaxNodeDegree() > 2) { + er.linkDirectedEdgesForMinimalEdgeRings(); + List minEdgeRings = er.buildMinimalRings(); + // at this point we can go ahead and attempt to place holes, if this EdgeRing is a polygon + EdgeRing shell = findShell(minEdgeRings); + if (shell != null) { + placePolygonHoles(shell, minEdgeRings); + shellList.add(shell); + } + else { + freeHoleList.addAll(minEdgeRings); + } + } + else { + edgeRings.add(er); + } + } + return edgeRings; + } + + /** + * This method takes a list of MinimalEdgeRings derived from a MaximalEdgeRing, + * and tests whether they form a Polygon. This is the case if there is a single shell + * in the list. In this case the shell is returned. + * The other possibility is that they are a series of connected holes, in which case + * no shell is returned. + * + * @return the shell EdgeRing, if there is one + * or null, if all the rings are holes + */ + private EdgeRing findShell(List minEdgeRings) + { + int shellCount = 0; + EdgeRing shell = null; + for (Iterator it = minEdgeRings.iterator(); it.hasNext(); ) { + EdgeRing er = (MinimalEdgeRing) it.next(); + if (! er.isHole()) { + shell = er; + shellCount++; + } + } + Assert.isTrue(shellCount <= 1, "found two shells in MinimalEdgeRing list"); + return shell; + } + /** + * This method assigns the holes for a Polygon (formed from a list of + * MinimalEdgeRings) to its shell. + * Determining the holes for a MinimalEdgeRing polygon serves two purposes: + *

+ */ + private void placePolygonHoles(EdgeRing shell, List minEdgeRings) + { + for (Iterator it = minEdgeRings.iterator(); it.hasNext(); ) { + MinimalEdgeRing er = (MinimalEdgeRing) it.next(); + if (er.isHole()) { + er.setShell(shell); + } + } + } + /** + * For all rings in the input list, + * determine whether the ring is a shell or a hole + * and add it to the appropriate list. + * Due to the way the DirectedEdges were linked, + * a ring is a shell if it is oriented CW, a hole otherwise. + */ + private void sortShellsAndHoles(List edgeRings, List shellList, List freeHoleList) + { + for (Iterator it = edgeRings.iterator(); it.hasNext(); ) { + EdgeRing er = (EdgeRing) it.next(); +// er.setInResult(); + if (er.isHole() ) { + freeHoleList.add(er); + } + else { + shellList.add(er); + } + } + } + /** + * Determines finds a containing shell for all holes + * which have not yet been assigned to a shell. + * + * Holes which do not lie in any shell are (probably) an eroded element, + * so are simply discarded + */ + private void placeFreeHoles(List shellList, List freeHoleList) + { + for (Iterator it = freeHoleList.iterator(); it.hasNext(); ) { + EdgeRing hole = (EdgeRing) it.next(); + // only place this hole if it doesn't yet have a shell + if (hole.getShell() == null) { + EdgeRing shell = findEdgeRingContaining(hole, shellList); + /** + * If hole lies outside shell, discard it. + */ + if (shell != null) { + hole.setShell(shell); + } + } + } + } + + /** + * Find the innermost enclosing shell EdgeRing containing the argument EdgeRing, if any. + * The innermost enclosing ring is the smallest enclosing ring. + * The algorithm used depends on the fact that: + *
+ * ring A contains ring B if envelope(ring A) contains envelope(ring B) + *
+ * This routine is only safe to use if the chosen point of the hole + * is known to be properly contained in a shell + * (which is guaranteed to be the case if the hole does not touch its shell) + * + * @return containing EdgeRing, if there is one + * or null if no containing EdgeRing is found + */ + private static EdgeRing findEdgeRingContaining(EdgeRing testEr, List shellList) + { + LinearRing testRing = testEr.getLinearRing(); + Envelope testEnv = testRing.getEnvelopeInternal(); + Coordinate testPt = testRing.getCoordinateN(0); + + EdgeRing minShell = null; + Envelope minShellEnv = null; + for (Iterator it = shellList.iterator(); it.hasNext(); ) { + EdgeRing tryShell = (EdgeRing) it.next(); + LinearRing tryShellRing = tryShell.getLinearRing(); + Envelope tryShellEnv = tryShellRing.getEnvelopeInternal(); + // the hole envelope cannot equal the shell envelope + // (also guards against testing rings against themselves) + if (tryShellEnv.equals(testEnv)) continue; + // hole must be contained in shell + if (! tryShellEnv.contains(testEnv)) continue; + + testPt = CoordinateArrays.ptNotInList(testRing.getCoordinates(), tryShellRing.getCoordinates()); + boolean isContained = false; + if (PointLocation.isInRing(testPt, tryShellRing.getCoordinates()) ) + isContained = true; + + // check if this new containing ring is smaller than the current minimum ring + if (isContained) { + if (minShell == null + || minShellEnv.contains(tryShellEnv)) { + minShell = tryShell; + minShellEnv = minShell.getLinearRing().getEnvelopeInternal(); + } + } + } + return minShell; + } + + private List computePolygons(List shellList) + { + List resultPolyList = new ArrayList(); + // add Polygons for all shells + for (Iterator it = shellList.iterator(); it.hasNext(); ) { + EdgeRing er = (EdgeRing) it.next(); + Polygon poly = er.toPolygon(geometryFactory); + resultPolyList.add(poly); + } + return resultPolyList; + } + +} diff --git a/modules/core/src/test/java/org/locationtech/jts/operation/buffer/BufferTest.java b/modules/core/src/test/java/org/locationtech/jts/operation/buffer/BufferTest.java index 97e1ed820b..956181bcc6 100644 --- a/modules/core/src/test/java/org/locationtech/jts/operation/buffer/BufferTest.java +++ b/modules/core/src/test/java/org/locationtech/jts/operation/buffer/BufferTest.java @@ -650,6 +650,14 @@ public void testRingCCW() { "POLYGON ((0.73 0.05, 0.67 -0.13, 0.58 -0.31, 0.46 -0.46, 0.31 -0.58, 0.13 -0.67, -0.05 -0.73, -0.25 -0.75, -0.75 -0.75, -0.95 -0.73, -1.13 -0.67, -1.31 -0.58, -1.46 -0.46, -1.58 -0.31, -1.67 -0.13, -1.73 0.05, -1.75 0.25, -1.75 0.75, -1.73 0.95, -1.67 1.13, -1.58 1.31, -1.46 1.46, -1.31 1.58, -1.13 1.67, -0.95 1.73, -0.75 1.75, -0.25 1.75, -0.05 1.73, 0.13 1.67, 0.31 1.58, 0.46 1.46, 0.58 1.31, 0.67 1.13, 0.73 0.95, 0.75 0.75, 0.75 0.25, 0.73 0.05))"); } + // Checks that a skinny element polygon is eroded with no internal predicision reduction due to topo exes + // see https://github.com/libgeos/geos/issues/1182 + public void testElementErodedEx() { + String wkt = "MULTIPOLYGON (((48268.99938 -49048.29324, 44429.1 -55700.232847, 44429.1 -55107.317582, 44506.1 -54974, 44429.1 -54840, 44429.1 -51569.2, 42170.10515 -49316.27944, 48268.99938 -49048.29324)), ((43433.08324 -51823.15037, 42480.09977 -55494.96132, 42477.638798 -55504.400121, 42480.20715 -55494.547587, 42482.247919 -55485.931009, 42482.431666 -55485.976608, 43433.08324 -51823.15037)))"; + checkBuffer(wkt, -1, +"POLYGON ((48267.2218198241 -49049.37231112561, 44430.1 -55696.500291383236, 44430.1 -55107.58561879013, 44506.96594366424 -54974.50014155032, 44507.05088958367 -54974.309530288774, 44507.09442572395 -54974.10543945913, 44507.09465615313 -54973.896756903174, 44507.05157083636 -54973.69257042533, 44506.96704607295 -54973.50177203271, 44430.1 -54839.73314639927, 44430.1 -51569.2, 44430.08071956347 -51569.00457958696, 44430.02362172434 -51568.81669475566, 44429.93090822513 -51568.64359050924, 44429.80615417933 -51568.49194189856, 42172.42282165639 -49317.178566110095, 48267.2218198241 -49049.37231112561))"); + } + //=================================================== private static BufferParameters bufParamRoundMitre(double mitreLimit) {