This is an automated email from the ASF dual-hosted git repository.

jiayu pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/sedona.git


The following commit(s) were added to refs/heads/master by this push:
     new aeb8822187 [GH-2611] Merge linestring splitting results to avoid extra 
segments (#2612)
aeb8822187 is described below

commit aeb88221871a1b2f5f4604bbb36a683093c38e15
Author: Kristin Cowalcijk <[email protected]>
AuthorDate: Fri Feb 6 17:12:08 2026 +0800

    [GH-2611] Merge linestring splitting results to avoid extra segments (#2612)
---
 .../sedona/common/utils/GeometrySplitter.java      |  26 +-
 .../sedona/common/utils/LineStringMerger.java      | 316 +++++++++++++++++++++
 .../org/apache/sedona/common/FunctionsTest.java    |  30 ++
 3 files changed, 367 insertions(+), 5 deletions(-)

diff --git 
a/common/src/main/java/org/apache/sedona/common/utils/GeometrySplitter.java 
b/common/src/main/java/org/apache/sedona/common/utils/GeometrySplitter.java
index 89473c5d3c..5f663a9fd9 100644
--- a/common/src/main/java/org/apache/sedona/common/utils/GeometrySplitter.java
+++ b/common/src/main/java/org/apache/sedona/common/utils/GeometrySplitter.java
@@ -36,12 +36,9 @@ import org.locationtech.jts.geom.Point;
 import org.locationtech.jts.geom.Polygon;
 import org.locationtech.jts.linearref.LinearGeometryBuilder;
 import org.locationtech.jts.operation.polygonize.Polygonizer;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
 
 /** Class to split geometry by other geometry. */
 public final class GeometrySplitter {
-  static final Logger logger = LoggerFactory.getLogger(GeometrySplitter.class);
   private final GeometryFactory geometryFactory;
 
   public GeometrySplitter(GeometryFactory geometryFactory) {
@@ -149,8 +146,27 @@ public final class GeometrySplitter {
 
   private MultiLineString splitLinesByLines(Geometry inputLines, Geometry 
blade) {
     Geometry diff = inputLines.difference(blade);
-    if (diff instanceof MultiLineString) {
-      return (MultiLineString) diff;
+    Geometry merged =
+        LineStringMerger.mergeDifferenceSplit(diff, inputLines, blade, 
geometryFactory);
+    if (merged instanceof MultiLineString) {
+      return (MultiLineString) merged;
+    } else if (merged instanceof LineString) {
+      return geometryFactory.createMultiLineString(new LineString[] 
{(LineString) merged});
+    } else if (merged instanceof GeometryCollection) {
+      List<LineString> lineStrings = new ArrayList<>();
+      GeometryCollection gc = (GeometryCollection) merged;
+      for (int i = 0; i < gc.getNumGeometries(); i++) {
+        Geometry g = gc.getGeometryN(i);
+        if (g instanceof LineString) {
+          lineStrings.add((LineString) g);
+        } else if (g instanceof MultiLineString) {
+          MultiLineString mls = (MultiLineString) g;
+          for (int j = 0; j < mls.getNumGeometries(); j++) {
+            lineStrings.add((LineString) mls.getGeometryN(j));
+          }
+        }
+      }
+      return geometryFactory.createMultiLineString(lineStrings.toArray(new 
LineString[0]));
     } else {
       return geometryFactory.createMultiLineString(new LineString[] 
{(LineString) inputLines});
     }
diff --git 
a/common/src/main/java/org/apache/sedona/common/utils/LineStringMerger.java 
b/common/src/main/java/org/apache/sedona/common/utils/LineStringMerger.java
new file mode 100644
index 0000000000..c7bb9f2310
--- /dev/null
+++ b/common/src/main/java/org/apache/sedona/common/utils/LineStringMerger.java
@@ -0,0 +1,316 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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.apache.sedona.common.utils;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import org.locationtech.jts.geom.Coordinate;
+import org.locationtech.jts.geom.CoordinateList;
+import org.locationtech.jts.geom.Geometry;
+import org.locationtech.jts.geom.GeometryFactory;
+import org.locationtech.jts.geom.LineString;
+import org.locationtech.jts.geom.util.LineStringExtracter;
+
+/**
+ * Post-processes line split results to merge adjacent segments that were only 
split by JTS overlay
+ * noding, not by a real split point. This avoids extra line segments after 
{@code
+ * Geometry#difference} on linework.
+ *
+ * <p>JTS has an internal line merge capability in LineBuilder (not yet 
exposed/used by the overlay
+ * API). Once JTS supports it in future releases, we should rely on that 
directly. See
+ * 
https://github.com/locationtech/jts/blob/1.20.0/modules/core/src/main/java/org/locationtech/jts/operation/overlayng/LineBuilder.java#L247-L261
+ */
+public final class LineStringMerger {
+  private LineStringMerger() {}
+
+  @SuppressWarnings("unchecked")
+  public static Geometry mergeDifferenceSplit(
+      Geometry linework, Geometry originalLines, Geometry blade, 
GeometryFactory factory) {
+    if (linework == null || linework.isEmpty() || 
!GeomUtils.geometryIsLineal(linework)) {
+      return linework;
+    }
+
+    List<Geometry> geoms = LineStringExtracter.getLines(linework);
+    List<LineString> lines = new ArrayList<>();
+    List<Geometry> nonLines = new ArrayList<>();
+    extractLines(geoms, lines, nonLines);
+    if (lines.size() <= 1) {
+      return linework;
+    }
+
+    Set<CoordKey> stopPointIndex = buildStopPointIndex(originalLines, blade);
+    List<LineString> merged = mergeLinesAtNonStopNodes(lines, stopPointIndex, 
factory);
+
+    if (nonLines.isEmpty()) {
+      return factory.buildGeometry(merged);
+    } else {
+      nonLines.addAll(merged);
+      return factory.buildGeometry(nonLines);
+    }
+  }
+
+  @SuppressWarnings("unchecked")
+  private static Set<CoordKey> buildStopPointIndex(Geometry originalLines, 
Geometry blade) {
+    Set<CoordKey> index = new HashSet<>();
+    if (originalLines != null && blade != null && !originalLines.isEmpty() && 
!blade.isEmpty()) {
+      Coordinate[] coords = originalLines.intersection(blade).getCoordinates();
+      for (Coordinate coord : coords) {
+        index.add(new CoordKey(coord));
+      }
+    }
+
+    if (originalLines == null || originalLines.isEmpty()) {
+      return index;
+    }
+
+    List<Geometry> geoms = LineStringExtracter.getLines(originalLines);
+    for (Geometry geom : geoms) {
+      if (!(geom instanceof LineString)) {
+        continue;
+      }
+      LineString line = (LineString) geom;
+      if (line.isClosed() || line.getNumPoints() == 0) {
+        continue;
+      }
+      index.add(new CoordKey(line.getCoordinateN(0)));
+      index.add(new CoordKey(line.getCoordinateN(line.getNumPoints() - 1)));
+    }
+
+    return index;
+  }
+
+  private static void extractLines(
+      List<Geometry> geoms, List<LineString> lines, List<Geometry> nonLines) {
+    for (Geometry geom : geoms) {
+      if (geom instanceof LineString) {
+        lines.add((LineString) geom);
+      } else {
+        nonLines.add(geom);
+      }
+    }
+  }
+
+  private static List<LineString> mergeLinesAtNonStopNodes(
+      List<LineString> lines, Set<CoordKey> stopPointIndex, GeometryFactory 
factory) {
+    if (lines.size() <= 1) {
+      return lines;
+    }
+
+    Map<CoordKey, List<EndpointRef>> endpointIndex = buildEndpointIndex(lines);
+    boolean[] visited = new boolean[lines.size()];
+    List<LineString> merged = new ArrayList<>();
+
+    for (int i = 0; i < lines.size(); i++) {
+      if (visited[i]) {
+        continue;
+      }
+
+      LineString line = lines.get(i);
+      Coordinate start = line.getCoordinateN(0);
+      Coordinate end = line.getCoordinateN(line.getNumPoints() - 1);
+
+      CoordKey startKey = new CoordKey(start);
+      CoordKey endKey = new CoordKey(end);
+
+      boolean startMergeable = isMergeableNode(startKey, endpointIndex, 
stopPointIndex);
+      boolean endMergeable = isMergeableNode(endKey, endpointIndex, 
stopPointIndex);
+
+      if (!startMergeable) {
+        merged.add(
+            mergeTowardsDirection(i, true, lines, endpointIndex, 
stopPointIndex, visited, factory));
+        continue;
+      }
+      if (!endMergeable) {
+        merged.add(
+            mergeTowardsDirection(
+                i, false, lines, endpointIndex, stopPointIndex, visited, 
factory));
+      }
+    }
+
+    for (int i = 0; i < lines.size(); i++) {
+      if (visited[i]) {
+        continue;
+      }
+      merged.add(
+          mergeTowardsDirection(i, true, lines, endpointIndex, stopPointIndex, 
visited, factory));
+    }
+
+    return merged;
+  }
+
+  private static Map<CoordKey, List<EndpointRef>> 
buildEndpointIndex(List<LineString> lines) {
+    Map<CoordKey, List<EndpointRef>> index = new HashMap<>();
+    for (int i = 0; i < lines.size(); i++) {
+      LineString line = lines.get(i);
+      addEndpoint(index, new CoordKey(line.getCoordinateN(0)), new 
EndpointRef(i));
+      addEndpoint(
+          index, new CoordKey(line.getCoordinateN(line.getNumPoints() - 1)), 
new EndpointRef(i));
+    }
+    return index;
+  }
+
+  private static void addEndpoint(
+      Map<CoordKey, List<EndpointRef>> index, CoordKey key, EndpointRef ref) {
+    List<EndpointRef> refs = index.computeIfAbsent(key, k -> new 
ArrayList<>());
+    refs.add(ref);
+  }
+
+  private static boolean isMergeableNode(
+      CoordKey key, Map<CoordKey, List<EndpointRef>> endpointIndex, 
Set<CoordKey> stopPointIndex) {
+    if (stopPointIndex.contains(key)) {
+      return false;
+    }
+    List<EndpointRef> refs = endpointIndex.get(key);
+    return refs != null && refs.size() == 2;
+  }
+
+  private static LineString mergeTowardsDirection(
+      int startLineIndex,
+      boolean forward,
+      List<LineString> lines,
+      Map<CoordKey, List<EndpointRef>> endpointIndex,
+      Set<CoordKey> stopPointIndex,
+      boolean[] visited,
+      GeometryFactory factory) {
+    LineString first = lines.get(startLineIndex);
+    Coordinate startNode =
+        forward ? first.getCoordinateN(0) : 
first.getCoordinateN(first.getNumPoints() - 1);
+    Coordinate[] firstCoords = orientedCoordinates(first, startNode);
+    if (firstCoords == null) {
+      visited[startLineIndex] = true;
+      return first;
+    }
+
+    visited[startLineIndex] = true;
+    CoordinateList coordList = new CoordinateList();
+    coordList.add(firstCoords, false);
+    Coordinate currentEnd = coordList.getCoordinate(coordList.size() - 1);
+    int currentLineIndex = startLineIndex;
+
+    while (true) {
+      CoordKey endKey = new CoordKey(currentEnd);
+      if (!isMergeableNode(endKey, endpointIndex, stopPointIndex)) {
+        break;
+      }
+
+      EndpointRef nextRef = nextEndpointRef(endpointIndex.get(endKey), 
currentLineIndex);
+      if (nextRef == null || visited[nextRef.lineIndex]) {
+        break;
+      }
+
+      LineString nextLine = lines.get(nextRef.lineIndex);
+      Coordinate[] nextCoords = orientedCoordinates(nextLine, currentEnd);
+      if (nextCoords == null) {
+        break;
+      }
+
+      for (int i = 1; i < nextCoords.length; i++) {
+        coordList.add(nextCoords[i], false);
+      }
+      visited[nextRef.lineIndex] = true;
+      currentLineIndex = nextRef.lineIndex;
+      currentEnd = coordList.getCoordinate(coordList.size() - 1);
+
+      if (currentEnd.equals2D(coordList.getCoordinate(0))) {
+        break;
+      }
+    }
+
+    return factory.createLineString(coordList.toCoordinateArray());
+  }
+
+  private static EndpointRef nextEndpointRef(List<EndpointRef> refs, int 
currentLineIndex) {
+    if (refs == null) {
+      return null;
+    }
+    for (EndpointRef ref : refs) {
+      if (ref.lineIndex != currentLineIndex) {
+        return ref;
+      }
+    }
+    return null;
+  }
+
+  private static Coordinate[] orientedCoordinates(LineString line, Coordinate 
shared) {
+    Coordinate[] coords = line.getCoordinates();
+    if (coords.length == 0) {
+      return null;
+    }
+    boolean start = coords[0].equals2D(shared);
+    boolean end = coords[coords.length - 1].equals2D(shared);
+    if (start && end) {
+      return null;
+    }
+    if (start) {
+      return coords;
+    }
+    if (end) {
+      return reverse(coords);
+    }
+    return null;
+  }
+
+  private static Coordinate[] reverse(Coordinate[] coords) {
+    Coordinate[] reversed = new Coordinate[coords.length];
+    for (int i = 0; i < coords.length; i++) {
+      reversed[i] = coords[coords.length - 1 - i];
+    }
+    return reversed;
+  }
+
+  private static final class EndpointRef {
+    final int lineIndex;
+
+    EndpointRef(int lineIndex) {
+      this.lineIndex = lineIndex;
+    }
+  }
+
+  private static final class CoordKey {
+    final long xBits;
+    final long yBits;
+
+    CoordKey(Coordinate c) {
+      this.xBits = Double.doubleToLongBits(c.x);
+      this.yBits = Double.doubleToLongBits(c.y);
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hash(xBits, yBits);
+    }
+
+    @Override
+    public boolean equals(Object obj) {
+      if (this == obj) {
+        return true;
+      }
+      if (!(obj instanceof CoordKey)) {
+        return false;
+      }
+      CoordKey other = (CoordKey) obj;
+      return xBits == other.xBits && yBits == other.yBits;
+    }
+  }
+}
diff --git a/common/src/test/java/org/apache/sedona/common/FunctionsTest.java 
b/common/src/test/java/org/apache/sedona/common/FunctionsTest.java
index ec7fbe00fe..e379e67d6d 100644
--- a/common/src/test/java/org/apache/sedona/common/FunctionsTest.java
+++ b/common/src/test/java/org/apache/sedona/common/FunctionsTest.java
@@ -562,6 +562,36 @@ public class FunctionsTest extends TestBase {
     }
   }
 
+  @Test
+  public void splitCircleExteriorRingInto2LineStrings() throws ParseException {
+    String polygonWkt =
+        "POLYGON ((-117.76405581088967 34.111876749328026, -117.76407506132291 
34.11170068822483, "
+            + "-117.76413523652074 34.111531133837936, -117.76423402376724 
34.11137460199335, -117.76436762657538 34.11123710803779, "
+            + "-117.76453091060647 34.11112393568514, -117.76471760098879 
34.11103943398174, -117.76492052345083 34.11098685019075, "
+            + "-117.76513188000408 34.1109682050154, -117.76534354858369 
34.11098421495394, -117.76554739513688 34.11103426476887, "
+            + "-117.76573558617179 34.11111643112786, -117.76590088976099 
34.111227556508084, -117.76603695343799 34.11136337052523, "
+            + "-117.76613854831002 34.11151865402651, -117.76620177000793 
34.11168743964393, -117.76622418874936 34.111863241103265, "
+            + "-117.76620494274577 34.11203930247842, -117.76614477135817 
34.11220885781403, -117.7660459867224 34.11236539113964, "
+            + "-117.76591238492807 34.11250288688306, -117.7657491001595 
34.11261606105824, -117.7655624074007 34.11270056434135, "
+            + "-117.76535948128496 34.112753149228745, -117.76514812035703 
34.11277179485027, -117.76493644734776 34.112755784639766, "
+            + "-117.76473259698435 34.112705733876126, -117.76454440333869 
34.11262356603611, -117.76437909873567 34.11251243886801, "
+            + "-117.76424303579616 34.11237662302835, -117.76414144330062 
34.112221337947354, -117.76407822525644 34.11205255123353, "
+            + "-117.76405581088967 34.111876749328026))";
+    String knifeWkt =
+        "LINESTRING (-117.7640751398563 34.111535124121441, 
-117.76628486838135 34.112204866513046)";
+
+    Polygon polygon = (Polygon) Constructors.geomFromWKT(polygonWkt, 4326);
+    Geometry knife = Constructors.geomFromWKT(knifeWkt, 4326);
+    Geometry resultLineStrings = Functions.split(polygon.getExteriorRing(), 
knife);
+    double perimeter = polygon.getExteriorRing().getLength();
+
+    assertEquals(2, resultLineStrings.getNumGeometries());
+    for (int i = 0; i < resultLineStrings.getNumGeometries(); i++) {
+      double length = resultLineStrings.getGeometryN(i).getLength();
+      assertEquals(2.0, perimeter / length, 0.1);
+    }
+  }
+
   @Test
   public void dimensionGeometry2D() {
     Point point = GEOMETRY_FACTORY.createPoint(new Coordinate(1, 2));

Reply via email to