Skip to content

Commit 9e1153b

Browse files
authored
Add Voronoi snapping heuristic to fix invalid diagram topology (#1174)
1 parent 7b79342 commit 9e1153b

File tree

5 files changed

+482
-42
lines changed

5 files changed

+482
-42
lines changed

modules/core/src/main/java/org/locationtech/jts/triangulate/VoronoiDiagramBuilder.java

Lines changed: 141 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright (c) 2016 Vivid Solutions.
2+
* Copyright (c) 2026 Martin Davis.
33
*
44
* All rights reserved. This program and the accompanying materials
55
* are made available under the terms of the Eclipse Public License 2.0
@@ -17,11 +17,14 @@
1717

1818
import org.locationtech.jts.geom.Coordinate;
1919
import org.locationtech.jts.geom.CoordinateArrays;
20+
import org.locationtech.jts.geom.CoordinateList;
2021
import org.locationtech.jts.geom.Envelope;
2122
import org.locationtech.jts.geom.Geometry;
2223
import org.locationtech.jts.geom.GeometryCollection;
2324
import org.locationtech.jts.geom.GeometryFactory;
25+
import org.locationtech.jts.geom.LinearRing;
2426
import org.locationtech.jts.geom.Polygon;
27+
import org.locationtech.jts.noding.snap.SnappingPointIndex;
2528
import org.locationtech.jts.triangulate.quadedge.QuadEdgeSubdivision;
2629

2730

@@ -44,7 +47,13 @@
4447
*/
4548
public class VoronoiDiagramBuilder
4649
{
47-
private Collection siteCoords;
50+
/**
51+
* A very small factor which detects short Voronoi cell segments
52+
* which might be caused by nearly-cocircular site circumcentres.
53+
*/
54+
private static final double SHORT_SEG_TOLERANCE_FACTOR = 1.0e-10;
55+
56+
private Collection siteCoords;
4857
private double tolerance = 0.0;
4958
private QuadEdgeSubdivision subdiv = null;
5059
private Envelope clipEnv = null;
@@ -160,39 +169,141 @@ public Geometry getDiagram(GeometryFactory geomFact)
160169
create();
161170
Geometry polys = subdiv.getVoronoiDiagram(geomFact);
162171

172+
//System.out.println(polys);
173+
//System.out.println( subdiv.getTriangles(true, geomFact) );
174+
163175
/*
164-
System.out.println(polys);
165-
Geometry tris = subdiv.getTriangles(true, geomFact);
166-
System.out.println(tris);
167176
if (! subdiv.isFrameDelaunay()) {
168-
throw new IllegalStateException("Triangulation frame is not Delaunay");
169-
}
177+
throw new IllegalStateException("Triangulation frame is not Delaunay");
178+
}
170179
//*/
171-
172-
//-- clip polys to diagramEnv
173-
return clipGeometryCollection(polys, diagramEnv);
174-
}
175180

181+
Geometry polysClean = clean(polys);
182+
//Geometry polysClean = polys; // TESTING ONLY
183+
184+
//-- clip cell polygons to diagram boundary
185+
return clipGeometryCollection(polysClean, diagramEnv);
186+
}
187+
176188
private static Geometry clipGeometryCollection(Geometry geom, Envelope clipEnv)
177-
{
178-
Geometry clipPoly = geom.getFactory().toGeometry(clipEnv);
179-
List clipped = new ArrayList();
180-
for (int i = 0; i < geom.getNumGeometries(); i++) {
181-
Geometry g = geom.getGeometryN(i);
182-
Geometry result = null;
183-
// don't clip unless necessary
184-
if (clipEnv.contains(g.getEnvelopeInternal()))
185-
result = g;
186-
else if (clipEnv.intersects(g.getEnvelopeInternal())) {
187-
result = clipPoly.intersection(g);
188-
// keep vertex key info
189-
result.setUserData(g.getUserData());
190-
}
189+
{
190+
Geometry clipPoly = geom.getFactory().toGeometry(clipEnv);
191+
List<Geometry> clipped = new ArrayList<Geometry>();
192+
for (int i = 0; i < geom.getNumGeometries(); i++) {
193+
Geometry g = geom.getGeometryN(i);
194+
Geometry result = null;
195+
// don't clip unless necessary
196+
if (clipEnv.contains(g.getEnvelopeInternal()))
197+
result = g;
198+
else if (clipEnv.intersects(g.getEnvelopeInternal())) {
199+
result = clipPoly.intersection(g);
200+
// keep vertex key info
201+
result.setUserData(g.getUserData());
202+
}
191203

192-
if (result != null && ! result.isEmpty()) {
193-
clipped.add(result);
194-
}
204+
if (result != null && ! result.isEmpty()) {
205+
clipped.add(result);
206+
}
207+
}
208+
return geom.getFactory().createGeometryCollection(GeometryFactory.toGeometryArray(clipped));
209+
}
210+
211+
/**
212+
* Cleans diagram polygons to fix invalid topology caused by robustness errors,
213+
*
214+
* @param polys a GeometryCollection containing the raw polygons for the diagram
215+
* @return the clean polygons
216+
*/
217+
private Geometry clean(Geometry polys) {
218+
/**
219+
* Check for a diagram polygon with a very short edge which is invalid.
220+
* This can indicate invalid diagram topology caused by nearly cocircular input points.
221+
* This is an efficient test which should not trigger on most typical datasets.
222+
*
223+
* If found, snap the polygons to fix the topology.
224+
* This is a heuristic fix, but should generally restore correct topology
225+
* with very little effect on the diagram geometry.
226+
*
227+
* See https://github.com/locationtech/jts/issues/1171
228+
*/
229+
double segmentLenTolerance = SHORT_SEG_TOLERANCE_FACTOR * diagramEnv.getDiameter();
230+
if (hasInvalidPolygonWithShortEdge(polys, segmentLenTolerance)) {
231+
//System.out.println("SNAPPING!");
232+
Geometry polysSnap = snap(polys, segmentLenTolerance);
233+
return polysSnap;
195234
}
196-
return geom.getFactory().createGeometryCollection(GeometryFactory.toGeometryArray(clipped));
197-
}
235+
return polys;
236+
}
237+
238+
/**
239+
* Tests for a polygon with a very short edge which is invalid.
240+
* This check is efficient for valid input,
241+
* since that is unlikely to contain very short edges.
242+
*
243+
* @param polys
244+
* @param segmentLenTolerance
245+
* @return true if a short edge in an invalid polygon is found
246+
*/
247+
private static boolean hasInvalidPolygonWithShortEdge(Geometry polys, double segmentLenTolerance) {
248+
for (int i = 0; i < polys.getNumGeometries(); i++) {
249+
Polygon poly = (Polygon) polys.getGeometryN(i);
250+
if (hasShortSegment(poly, segmentLenTolerance)) {
251+
if (! poly.isValid())
252+
return true;
253+
}
254+
}
255+
return false;
256+
}
257+
258+
/**
259+
* Tests if a polygon shell contains a short edge.
260+
*
261+
* @param poly a polygon
262+
* @param segmentLenTolerance the minimum segment length
263+
* @return true if the polygon has a short edge
264+
*/
265+
private static boolean hasShortSegment(Polygon poly, double segmentLenTolerance) {
266+
LinearRing ring = poly.getExteriorRing();
267+
Coordinate prev = ring.getCoordinateN(0);
268+
for (int i = 1; i < ring.getNumPoints(); i++) {
269+
Coordinate p = ring.getCoordinateN(i);
270+
if (p.distance(prev) < segmentLenTolerance)
271+
return true;
272+
prev = p;
273+
}
274+
return false;
275+
}
276+
277+
/**
278+
* Snaps the vertices of a collection of polygons to eliminate short edges.
279+
* Using a snapping map for all vertices ensures that adjacent polygons
280+
* match after snapping.
281+
*
282+
* @param polys a GeometryCollection of single-ring polygons
283+
* @param snapTolerance the snapping tolerance
284+
* @return a GeometryCollection of snapped polygons
285+
*/
286+
private static Geometry snap(Geometry polys, double snapTolerance) {
287+
SnappingPointIndex snapMap = new SnappingPointIndex(snapTolerance);
288+
List<Polygon> polysSnap = new ArrayList<Polygon>();
289+
for (int i = 0; i < polys.getNumGeometries(); i++) {
290+
Polygon polySnap = snapPolygon((Polygon) polys.getGeometryN(i), snapMap);
291+
polysSnap.add(polySnap);
292+
}
293+
GeometryFactory geomFact = polys.getFactory();
294+
return geomFact.createGeometryCollection(GeometryFactory.toGeometryArray(polysSnap));
295+
}
296+
297+
private static Polygon snapPolygon(Polygon poly, SnappingPointIndex snapMap) {
298+
CoordinateList ptsSnap = new CoordinateList();
299+
//-- voronoi polygons do not contain holes
300+
Coordinate[] pts = poly.getExteriorRing().getCoordinates();
301+
for (Coordinate pt : pts) {
302+
Coordinate snapPt = snapMap.snap(pt);
303+
ptsSnap.add(snapPt.copy(), false);
304+
}
305+
Polygon polySnap = poly.getFactory().createPolygon(ptsSnap.toCoordinateArray());
306+
return polySnap;
307+
}
308+
198309
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/*
2+
* Copyright (c) 2026 Martin Davis.
3+
*
4+
* All rights reserved. This program and the accompanying materials
5+
* are made available under the terms of the Eclipse Public License 2.0
6+
* and Eclipse Distribution License v. 1.0 which accompanies this distribution.
7+
* The Eclipse Public License is available at http://www.eclipse.org/legal/epl-v20.html
8+
* and the Eclipse Distribution License is available at
9+
*
10+
* http://www.eclipse.org/org/documents/edl-v10.php.
11+
*/
12+
package org.locationtech.jts.triangulate;
13+
14+
import org.locationtech.jts.algorithm.Orientation;
15+
import org.locationtech.jts.geom.Coordinate;
16+
import org.locationtech.jts.geom.Geometry;
17+
import org.locationtech.jts.geom.Polygon;
18+
19+
public class VoronoiChecker {
20+
21+
public static boolean isValid(Geometry voronoiDiagram) {
22+
boolean isValid = voronoiDiagram.isValid();
23+
if (! isValid) return false;
24+
25+
boolean isConvex = isConvex(voronoiDiagram);
26+
if (! isConvex) return false;
27+
28+
boolean isNonoverlapping = isNonOverlapping(voronoiDiagram);
29+
if (! isNonoverlapping) return false;
30+
31+
return true;
32+
}
33+
34+
private static boolean isConvex(Geometry voronoiDiagram) {
35+
Geometry union = voronoiDiagram.union();
36+
if (! (union instanceof Polygon)) {
37+
return false;
38+
}
39+
return isConvex((Polygon) union);
40+
}
41+
42+
private static boolean isConvex(Polygon poly) {
43+
Coordinate[] pts = poly.getCoordinates();
44+
for (int i = 0; i < pts.length - 1; i++) {
45+
int iprev = i - 1;
46+
if (iprev < 0) iprev = pts.length - 2;
47+
int inext = i + 1;
48+
//-- orientation must be CLOCKWISE or COLLINEAR
49+
boolean isConvex = Orientation.COUNTERCLOCKWISE != Orientation.index(pts[iprev], pts[i], pts[inext]);
50+
if (! isConvex)
51+
return false;
52+
}
53+
return true;
54+
}
55+
56+
private static final String INTERIOR_INTERSECTS = "T********";
57+
58+
private static boolean isNonOverlapping(Geometry result) {
59+
int n = result.getNumGeometries();
60+
for (int i1 = 0; i1 < n; i1++) {
61+
Polygon poly1 = (Polygon) result.getGeometryN(i1);
62+
for (int i2 = i1 + 1; i2 < n; i2++) {
63+
Polygon poly2 = (Polygon) result.getGeometryN(i2);
64+
boolean isOverlapping = poly1.relate(poly2, INTERIOR_INTERSECTS);
65+
if (isOverlapping)
66+
return false;
67+
}
68+
}
69+
return true;
70+
}
71+
}

0 commit comments

Comments
 (0)