Skip to content

Commit aeb8822

Browse files
authored
[GH-2611] Merge linestring splitting results to avoid extra segments (#2612)
1 parent d192cb3 commit aeb8822

File tree

3 files changed

+367
-5
lines changed

3 files changed

+367
-5
lines changed

common/src/main/java/org/apache/sedona/common/utils/GeometrySplitter.java

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,9 @@
3636
import org.locationtech.jts.geom.Polygon;
3737
import org.locationtech.jts.linearref.LinearGeometryBuilder;
3838
import org.locationtech.jts.operation.polygonize.Polygonizer;
39-
import org.slf4j.Logger;
40-
import org.slf4j.LoggerFactory;
4139

4240
/** Class to split geometry by other geometry. */
4341
public final class GeometrySplitter {
44-
static final Logger logger = LoggerFactory.getLogger(GeometrySplitter.class);
4542
private final GeometryFactory geometryFactory;
4643

4744
public GeometrySplitter(GeometryFactory geometryFactory) {
@@ -149,8 +146,27 @@ private MultiLineString splitLinesByPoints(Geometry lines, Geometry points) {
149146

150147
private MultiLineString splitLinesByLines(Geometry inputLines, Geometry blade) {
151148
Geometry diff = inputLines.difference(blade);
152-
if (diff instanceof MultiLineString) {
153-
return (MultiLineString) diff;
149+
Geometry merged =
150+
LineStringMerger.mergeDifferenceSplit(diff, inputLines, blade, geometryFactory);
151+
if (merged instanceof MultiLineString) {
152+
return (MultiLineString) merged;
153+
} else if (merged instanceof LineString) {
154+
return geometryFactory.createMultiLineString(new LineString[] {(LineString) merged});
155+
} else if (merged instanceof GeometryCollection) {
156+
List<LineString> lineStrings = new ArrayList<>();
157+
GeometryCollection gc = (GeometryCollection) merged;
158+
for (int i = 0; i < gc.getNumGeometries(); i++) {
159+
Geometry g = gc.getGeometryN(i);
160+
if (g instanceof LineString) {
161+
lineStrings.add((LineString) g);
162+
} else if (g instanceof MultiLineString) {
163+
MultiLineString mls = (MultiLineString) g;
164+
for (int j = 0; j < mls.getNumGeometries(); j++) {
165+
lineStrings.add((LineString) mls.getGeometryN(j));
166+
}
167+
}
168+
}
169+
return geometryFactory.createMultiLineString(lineStrings.toArray(new LineString[0]));
154170
} else {
155171
return geometryFactory.createMultiLineString(new LineString[] {(LineString) inputLines});
156172
}
Lines changed: 316 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,316 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
package org.apache.sedona.common.utils;
20+
21+
import java.util.ArrayList;
22+
import java.util.HashMap;
23+
import java.util.HashSet;
24+
import java.util.List;
25+
import java.util.Map;
26+
import java.util.Objects;
27+
import java.util.Set;
28+
import org.locationtech.jts.geom.Coordinate;
29+
import org.locationtech.jts.geom.CoordinateList;
30+
import org.locationtech.jts.geom.Geometry;
31+
import org.locationtech.jts.geom.GeometryFactory;
32+
import org.locationtech.jts.geom.LineString;
33+
import org.locationtech.jts.geom.util.LineStringExtracter;
34+
35+
/**
36+
* Post-processes line split results to merge adjacent segments that were only split by JTS overlay
37+
* noding, not by a real split point. This avoids extra line segments after {@code
38+
* Geometry#difference} on linework.
39+
*
40+
* <p>JTS has an internal line merge capability in LineBuilder (not yet exposed/used by the overlay
41+
* API). Once JTS supports it in future releases, we should rely on that directly. See
42+
* https://github.com/locationtech/jts/blob/1.20.0/modules/core/src/main/java/org/locationtech/jts/operation/overlayng/LineBuilder.java#L247-L261
43+
*/
44+
public final class LineStringMerger {
45+
private LineStringMerger() {}
46+
47+
@SuppressWarnings("unchecked")
48+
public static Geometry mergeDifferenceSplit(
49+
Geometry linework, Geometry originalLines, Geometry blade, GeometryFactory factory) {
50+
if (linework == null || linework.isEmpty() || !GeomUtils.geometryIsLineal(linework)) {
51+
return linework;
52+
}
53+
54+
List<Geometry> geoms = LineStringExtracter.getLines(linework);
55+
List<LineString> lines = new ArrayList<>();
56+
List<Geometry> nonLines = new ArrayList<>();
57+
extractLines(geoms, lines, nonLines);
58+
if (lines.size() <= 1) {
59+
return linework;
60+
}
61+
62+
Set<CoordKey> stopPointIndex = buildStopPointIndex(originalLines, blade);
63+
List<LineString> merged = mergeLinesAtNonStopNodes(lines, stopPointIndex, factory);
64+
65+
if (nonLines.isEmpty()) {
66+
return factory.buildGeometry(merged);
67+
} else {
68+
nonLines.addAll(merged);
69+
return factory.buildGeometry(nonLines);
70+
}
71+
}
72+
73+
@SuppressWarnings("unchecked")
74+
private static Set<CoordKey> buildStopPointIndex(Geometry originalLines, Geometry blade) {
75+
Set<CoordKey> index = new HashSet<>();
76+
if (originalLines != null && blade != null && !originalLines.isEmpty() && !blade.isEmpty()) {
77+
Coordinate[] coords = originalLines.intersection(blade).getCoordinates();
78+
for (Coordinate coord : coords) {
79+
index.add(new CoordKey(coord));
80+
}
81+
}
82+
83+
if (originalLines == null || originalLines.isEmpty()) {
84+
return index;
85+
}
86+
87+
List<Geometry> geoms = LineStringExtracter.getLines(originalLines);
88+
for (Geometry geom : geoms) {
89+
if (!(geom instanceof LineString)) {
90+
continue;
91+
}
92+
LineString line = (LineString) geom;
93+
if (line.isClosed() || line.getNumPoints() == 0) {
94+
continue;
95+
}
96+
index.add(new CoordKey(line.getCoordinateN(0)));
97+
index.add(new CoordKey(line.getCoordinateN(line.getNumPoints() - 1)));
98+
}
99+
100+
return index;
101+
}
102+
103+
private static void extractLines(
104+
List<Geometry> geoms, List<LineString> lines, List<Geometry> nonLines) {
105+
for (Geometry geom : geoms) {
106+
if (geom instanceof LineString) {
107+
lines.add((LineString) geom);
108+
} else {
109+
nonLines.add(geom);
110+
}
111+
}
112+
}
113+
114+
private static List<LineString> mergeLinesAtNonStopNodes(
115+
List<LineString> lines, Set<CoordKey> stopPointIndex, GeometryFactory factory) {
116+
if (lines.size() <= 1) {
117+
return lines;
118+
}
119+
120+
Map<CoordKey, List<EndpointRef>> endpointIndex = buildEndpointIndex(lines);
121+
boolean[] visited = new boolean[lines.size()];
122+
List<LineString> merged = new ArrayList<>();
123+
124+
for (int i = 0; i < lines.size(); i++) {
125+
if (visited[i]) {
126+
continue;
127+
}
128+
129+
LineString line = lines.get(i);
130+
Coordinate start = line.getCoordinateN(0);
131+
Coordinate end = line.getCoordinateN(line.getNumPoints() - 1);
132+
133+
CoordKey startKey = new CoordKey(start);
134+
CoordKey endKey = new CoordKey(end);
135+
136+
boolean startMergeable = isMergeableNode(startKey, endpointIndex, stopPointIndex);
137+
boolean endMergeable = isMergeableNode(endKey, endpointIndex, stopPointIndex);
138+
139+
if (!startMergeable) {
140+
merged.add(
141+
mergeTowardsDirection(i, true, lines, endpointIndex, stopPointIndex, visited, factory));
142+
continue;
143+
}
144+
if (!endMergeable) {
145+
merged.add(
146+
mergeTowardsDirection(
147+
i, false, lines, endpointIndex, stopPointIndex, visited, factory));
148+
}
149+
}
150+
151+
for (int i = 0; i < lines.size(); i++) {
152+
if (visited[i]) {
153+
continue;
154+
}
155+
merged.add(
156+
mergeTowardsDirection(i, true, lines, endpointIndex, stopPointIndex, visited, factory));
157+
}
158+
159+
return merged;
160+
}
161+
162+
private static Map<CoordKey, List<EndpointRef>> buildEndpointIndex(List<LineString> lines) {
163+
Map<CoordKey, List<EndpointRef>> index = new HashMap<>();
164+
for (int i = 0; i < lines.size(); i++) {
165+
LineString line = lines.get(i);
166+
addEndpoint(index, new CoordKey(line.getCoordinateN(0)), new EndpointRef(i));
167+
addEndpoint(
168+
index, new CoordKey(line.getCoordinateN(line.getNumPoints() - 1)), new EndpointRef(i));
169+
}
170+
return index;
171+
}
172+
173+
private static void addEndpoint(
174+
Map<CoordKey, List<EndpointRef>> index, CoordKey key, EndpointRef ref) {
175+
List<EndpointRef> refs = index.computeIfAbsent(key, k -> new ArrayList<>());
176+
refs.add(ref);
177+
}
178+
179+
private static boolean isMergeableNode(
180+
CoordKey key, Map<CoordKey, List<EndpointRef>> endpointIndex, Set<CoordKey> stopPointIndex) {
181+
if (stopPointIndex.contains(key)) {
182+
return false;
183+
}
184+
List<EndpointRef> refs = endpointIndex.get(key);
185+
return refs != null && refs.size() == 2;
186+
}
187+
188+
private static LineString mergeTowardsDirection(
189+
int startLineIndex,
190+
boolean forward,
191+
List<LineString> lines,
192+
Map<CoordKey, List<EndpointRef>> endpointIndex,
193+
Set<CoordKey> stopPointIndex,
194+
boolean[] visited,
195+
GeometryFactory factory) {
196+
LineString first = lines.get(startLineIndex);
197+
Coordinate startNode =
198+
forward ? first.getCoordinateN(0) : first.getCoordinateN(first.getNumPoints() - 1);
199+
Coordinate[] firstCoords = orientedCoordinates(first, startNode);
200+
if (firstCoords == null) {
201+
visited[startLineIndex] = true;
202+
return first;
203+
}
204+
205+
visited[startLineIndex] = true;
206+
CoordinateList coordList = new CoordinateList();
207+
coordList.add(firstCoords, false);
208+
Coordinate currentEnd = coordList.getCoordinate(coordList.size() - 1);
209+
int currentLineIndex = startLineIndex;
210+
211+
while (true) {
212+
CoordKey endKey = new CoordKey(currentEnd);
213+
if (!isMergeableNode(endKey, endpointIndex, stopPointIndex)) {
214+
break;
215+
}
216+
217+
EndpointRef nextRef = nextEndpointRef(endpointIndex.get(endKey), currentLineIndex);
218+
if (nextRef == null || visited[nextRef.lineIndex]) {
219+
break;
220+
}
221+
222+
LineString nextLine = lines.get(nextRef.lineIndex);
223+
Coordinate[] nextCoords = orientedCoordinates(nextLine, currentEnd);
224+
if (nextCoords == null) {
225+
break;
226+
}
227+
228+
for (int i = 1; i < nextCoords.length; i++) {
229+
coordList.add(nextCoords[i], false);
230+
}
231+
visited[nextRef.lineIndex] = true;
232+
currentLineIndex = nextRef.lineIndex;
233+
currentEnd = coordList.getCoordinate(coordList.size() - 1);
234+
235+
if (currentEnd.equals2D(coordList.getCoordinate(0))) {
236+
break;
237+
}
238+
}
239+
240+
return factory.createLineString(coordList.toCoordinateArray());
241+
}
242+
243+
private static EndpointRef nextEndpointRef(List<EndpointRef> refs, int currentLineIndex) {
244+
if (refs == null) {
245+
return null;
246+
}
247+
for (EndpointRef ref : refs) {
248+
if (ref.lineIndex != currentLineIndex) {
249+
return ref;
250+
}
251+
}
252+
return null;
253+
}
254+
255+
private static Coordinate[] orientedCoordinates(LineString line, Coordinate shared) {
256+
Coordinate[] coords = line.getCoordinates();
257+
if (coords.length == 0) {
258+
return null;
259+
}
260+
boolean start = coords[0].equals2D(shared);
261+
boolean end = coords[coords.length - 1].equals2D(shared);
262+
if (start && end) {
263+
return null;
264+
}
265+
if (start) {
266+
return coords;
267+
}
268+
if (end) {
269+
return reverse(coords);
270+
}
271+
return null;
272+
}
273+
274+
private static Coordinate[] reverse(Coordinate[] coords) {
275+
Coordinate[] reversed = new Coordinate[coords.length];
276+
for (int i = 0; i < coords.length; i++) {
277+
reversed[i] = coords[coords.length - 1 - i];
278+
}
279+
return reversed;
280+
}
281+
282+
private static final class EndpointRef {
283+
final int lineIndex;
284+
285+
EndpointRef(int lineIndex) {
286+
this.lineIndex = lineIndex;
287+
}
288+
}
289+
290+
private static final class CoordKey {
291+
final long xBits;
292+
final long yBits;
293+
294+
CoordKey(Coordinate c) {
295+
this.xBits = Double.doubleToLongBits(c.x);
296+
this.yBits = Double.doubleToLongBits(c.y);
297+
}
298+
299+
@Override
300+
public int hashCode() {
301+
return Objects.hash(xBits, yBits);
302+
}
303+
304+
@Override
305+
public boolean equals(Object obj) {
306+
if (this == obj) {
307+
return true;
308+
}
309+
if (!(obj instanceof CoordKey)) {
310+
return false;
311+
}
312+
CoordKey other = (CoordKey) obj;
313+
return xBits == other.xBits && yBits == other.yBits;
314+
}
315+
}
316+
}

common/src/test/java/org/apache/sedona/common/FunctionsTest.java

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -562,6 +562,36 @@ public void splitCircleInto2SemiCircles() throws ParseException {
562562
}
563563
}
564564

565+
@Test
566+
public void splitCircleExteriorRingInto2LineStrings() throws ParseException {
567+
String polygonWkt =
568+
"POLYGON ((-117.76405581088967 34.111876749328026, -117.76407506132291 34.11170068822483, "
569+
+ "-117.76413523652074 34.111531133837936, -117.76423402376724 34.11137460199335, -117.76436762657538 34.11123710803779, "
570+
+ "-117.76453091060647 34.11112393568514, -117.76471760098879 34.11103943398174, -117.76492052345083 34.11098685019075, "
571+
+ "-117.76513188000408 34.1109682050154, -117.76534354858369 34.11098421495394, -117.76554739513688 34.11103426476887, "
572+
+ "-117.76573558617179 34.11111643112786, -117.76590088976099 34.111227556508084, -117.76603695343799 34.11136337052523, "
573+
+ "-117.76613854831002 34.11151865402651, -117.76620177000793 34.11168743964393, -117.76622418874936 34.111863241103265, "
574+
+ "-117.76620494274577 34.11203930247842, -117.76614477135817 34.11220885781403, -117.7660459867224 34.11236539113964, "
575+
+ "-117.76591238492807 34.11250288688306, -117.7657491001595 34.11261606105824, -117.7655624074007 34.11270056434135, "
576+
+ "-117.76535948128496 34.112753149228745, -117.76514812035703 34.11277179485027, -117.76493644734776 34.112755784639766, "
577+
+ "-117.76473259698435 34.112705733876126, -117.76454440333869 34.11262356603611, -117.76437909873567 34.11251243886801, "
578+
+ "-117.76424303579616 34.11237662302835, -117.76414144330062 34.112221337947354, -117.76407822525644 34.11205255123353, "
579+
+ "-117.76405581088967 34.111876749328026))";
580+
String knifeWkt =
581+
"LINESTRING (-117.7640751398563 34.111535124121441, -117.76628486838135 34.112204866513046)";
582+
583+
Polygon polygon = (Polygon) Constructors.geomFromWKT(polygonWkt, 4326);
584+
Geometry knife = Constructors.geomFromWKT(knifeWkt, 4326);
585+
Geometry resultLineStrings = Functions.split(polygon.getExteriorRing(), knife);
586+
double perimeter = polygon.getExteriorRing().getLength();
587+
588+
assertEquals(2, resultLineStrings.getNumGeometries());
589+
for (int i = 0; i < resultLineStrings.getNumGeometries(); i++) {
590+
double length = resultLineStrings.getGeometryN(i).getLength();
591+
assertEquals(2.0, perimeter / length, 0.1);
592+
}
593+
}
594+
565595
@Test
566596
public void dimensionGeometry2D() {
567597
Point point = GEOMETRY_FACTORY.createPoint(new Coordinate(1, 2));

0 commit comments

Comments
 (0)