diff --git a/planetiler-benchmarks/src/main/java/com/onthegomap/planetiler/benchmarks/BenchmarkLineMerge.java b/planetiler-benchmarks/src/main/java/com/onthegomap/planetiler/benchmarks/BenchmarkLineMerge.java new file mode 100644 index 0000000000..05196735cb --- /dev/null +++ b/planetiler-benchmarks/src/main/java/com/onthegomap/planetiler/benchmarks/BenchmarkLineMerge.java @@ -0,0 +1,114 @@ +package com.onthegomap.planetiler.benchmarks; + +import com.onthegomap.planetiler.geo.GeoUtils; +import com.onthegomap.planetiler.util.Format; +import com.onthegomap.planetiler.util.FunctionThatThrows; +import com.onthegomap.planetiler.util.Gzip; +import com.onthegomap.planetiler.util.LoopLineMerger; +import java.io.IOException; +import java.math.BigDecimal; +import java.math.MathContext; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.CoordinateXY; +import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.geom.GeometryFactory; +import org.locationtech.jts.geom.LineString; +import org.locationtech.jts.io.ParseException; +import org.locationtech.jts.io.WKBReader; +import org.locationtech.jts.operation.linemerge.LineMerger; + +public class BenchmarkLineMerge { + private static int numLines; + + public static void main(String[] args) throws Exception { + for (int i = 0; i < 10; i++) { + time(" JTS", geom -> { + var lm = new LineMerger(); + lm.add(geom); + return lm.getMergedLineStrings(); + }); + time(" loop(0)", geom -> loopMerger(0).add(geom).getMergedLineStrings()); + time(" loop(0.1)", geom -> loopMerger(0.1).add(geom).getMergedLineStrings()); + time("loop(20.0)", geom -> loopMerger(20).add(geom).getMergedLineStrings()); + } + System.err.println(numLines); + } + + private static LoopLineMerger loopMerger(double minLength) { + var lm = new LoopLineMerger(); + lm.setMinLength(minLength); + lm.setStubMinLength(minLength); + lm.setLoopMinLength(minLength); + lm.setTolerance(1); + lm.setMergeStrokes(true); + return lm; + } + + private static void time(String name, FunctionThatThrows> fn) throws Exception { + System.err.println(String.join("\t", + name, + timeMillis(read("mergelines_200433_lines.wkb.gz"), fn), + timeMillis(read("mergelines_239823_lines.wkb.gz"), fn), + "(/s):", + timePerSec(read("mergelines_1759_point_line.wkb.gz"), fn), + timePerSec(makeLines(50, 2), fn), + timePerSec(makeLines(10, 10), fn), + timePerSec(makeLines(2, 50), fn) + )); + } + + private static String timePerSec(Geometry geometry, FunctionThatThrows> fn) + throws Exception { + long start = System.nanoTime(); + long end = start + Duration.ofSeconds(1).toNanos(); + int num = 0; + for (; System.nanoTime() < end;) { + numLines += fn.apply(geometry).size(); + num++; + } + return Format.defaultInstance() + .numeric(Math.round(num * 1d / ((System.nanoTime() - start) * 1d / Duration.ofSeconds(1).toNanos())), true); + } + + private static String timeMillis(Geometry geometry, FunctionThatThrows> fn) + throws Exception { + long start = System.nanoTime(); + long end = start + Duration.ofSeconds(1).toNanos(); + int num = 0; + for (; System.nanoTime() < end;) { + numLines += fn.apply(geometry).size(); + num++; + } + // equivalent of toPrecision(3) + long nanosPer = (System.nanoTime() - start) / num; + var bd = new BigDecimal(nanosPer, new MathContext(3)); + return Format.padRight(Duration.ofNanos(bd.longValue()).toString().replace("PT", ""), 6); + } + + + private static Geometry read(String fileName) throws IOException, ParseException { + var path = Path.of("planetiler-core", "src", "test", "resources", "mergelines", fileName); + byte[] bytes = Gzip.gunzip(Files.readAllBytes(path)); + return new WKBReader().read(bytes); + } + + private static Geometry makeLines(int lines, int parts) { + List result = new ArrayList<>(); + double idx = 0; + for (int i = 0; i < lines; i++) { + Coordinate[] coords = new Coordinate[parts]; + for (int j = 0; j < parts; j++) { + coords[j] = new CoordinateXY(idx, idx); + idx += 0.5; + } + result.add(GeoUtils.JTS_FACTORY.createLineString(coords)); + } + return new GeometryFactory().createMultiLineString(result.toArray(LineString[]::new)); + } +} diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/FeatureMerge.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/FeatureMerge.java index 012ddd0e06..4c86a45cbc 100644 --- a/planetiler-core/src/main/java/com/onthegomap/planetiler/FeatureMerge.java +++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/FeatureMerge.java @@ -4,13 +4,13 @@ import com.carrotsearch.hppc.IntObjectMap; import com.carrotsearch.hppc.IntStack; import com.onthegomap.planetiler.collection.Hppc; -import com.onthegomap.planetiler.geo.DouglasPeuckerSimplifier; import com.onthegomap.planetiler.geo.GeoUtils; import com.onthegomap.planetiler.geo.GeometryException; import com.onthegomap.planetiler.geo.GeometryType; import com.onthegomap.planetiler.geo.MutableCoordinateSequence; import com.onthegomap.planetiler.stats.DefaultStats; import com.onthegomap.planetiler.stats.Stats; +import com.onthegomap.planetiler.util.LoopLineMerger; import java.util.ArrayList; import java.util.BitSet; import java.util.Collection; @@ -171,7 +171,12 @@ public static List mergeLineStrings(List if (groupedFeatures.size() == 1 && buffer == 0d && lengthLimit == 0 && (!resimplify || tolerance == 0)) { result.add(feature1); } else { - LineMerger merger = new LineMerger(); + LoopLineMerger merger = new LoopLineMerger() + .setTolerance(tolerance) + .setMergeStrokes(true) + .setMinLength(lengthLimit) + .setLoopMinLength(lengthLimit) + .setStubMinLength(0.5); for (VectorTile.Feature feature : groupedFeatures) { try { merger.add(feature.geometry().decode()); @@ -180,24 +185,14 @@ public static List mergeLineStrings(List } } List outputSegments = new ArrayList<>(); - for (Object merged : merger.getMergedLineStrings()) { - if (merged instanceof LineString line && line.getLength() >= lengthLimit) { - // re-simplify since some endpoints of merged segments may be unnecessary - if (line.getNumPoints() > 2 && tolerance >= 0) { - Geometry simplified = DouglasPeuckerSimplifier.simplify(line, tolerance); - if (simplified instanceof LineString simpleLineString) { - line = simpleLineString; - } else { - LOGGER.warn("line string merge simplify emitted {}", simplified.getGeometryType()); - } - } - if (buffer >= 0) { - removeDetailOutsideTile(line, buffer, outputSegments); - } else { - outputSegments.add(line); - } + for (var line : merger.getMergedLineStrings()) { + if (buffer >= 0) { + removeDetailOutsideTile(line, buffer, outputSegments); + } else { + outputSegments.add(line); } } + if (!outputSegments.isEmpty()) { outputSegments = sortByHilbertIndex(outputSegments); Geometry newGeometry = GeoUtils.combineLineStrings(outputSegments); diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/geo/DouglasPeuckerSimplifier.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/geo/DouglasPeuckerSimplifier.java index 6ccb2bca5e..9ff3d0ef10 100644 --- a/planetiler-core/src/main/java/com/onthegomap/planetiler/geo/DouglasPeuckerSimplifier.java +++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/geo/DouglasPeuckerSimplifier.java @@ -11,6 +11,8 @@ */ package com.onthegomap.planetiler.geo; +import java.util.ArrayList; +import java.util.List; import org.locationtech.jts.geom.Coordinate; import org.locationtech.jts.geom.CoordinateSequence; import org.locationtech.jts.geom.Geometry; @@ -45,6 +47,22 @@ public static Geometry simplify(Geometry geom, double distanceTolerance) { return (new DPTransformer(distanceTolerance)).transform(geom); } + /** + * Returns a copy of {@code coords}, simplified using Douglas Peucker Algorithm. + * + * @param coords the coordinate list to simplify + * @param distanceTolerance the threshold below which we discard points + * @param area true if this is a polygon to retain at least 4 points to avoid collapse + * @return the simplified coordinate list + */ + public static List simplify(List coords, double distanceTolerance, boolean area) { + if (coords.isEmpty()) { + return List.of(); + } + + return (new DPTransformer(distanceTolerance)).transformCoordinateList(coords, area); + } + private static class DPTransformer extends GeometryTransformer { private final double sqTolerance; @@ -84,6 +102,42 @@ private static double getSqSegDist(double px, double py, double p1x, double p1y, return dx * dx + dy * dy; } + private void subsimplify(List in, List out, int first, int last, int numForcedPoints) { + // numForcePoints lets us keep some points even if they are below simplification threshold + boolean force = numForcedPoints > 0; + double maxSqDist = force ? -1 : sqTolerance; + int index = -1; + Coordinate p1 = in.get(first); + Coordinate p2 = in.get(last); + double p1x = p1.x; + double p1y = p1.y; + double p2x = p2.x; + double p2y = p2.y; + + int i = first + 1; + Coordinate furthest = null; + for (Coordinate coord : in.subList(first + 1, last)) { + double sqDist = getSqSegDist(coord.x, coord.y, p1x, p1y, p2x, p2y); + + if (sqDist > maxSqDist) { + index = i; + furthest = coord; + maxSqDist = sqDist; + } + i++; + } + + if (force || maxSqDist > sqTolerance) { + if (index - first > 1) { + subsimplify(in, out, first, index, numForcedPoints - 1); + } + out.add(furthest); + if (last - index > 1) { + subsimplify(in, out, index, last, numForcedPoints - 2); + } + } + } + private void subsimplify(CoordinateSequence in, MutableCoordinateSequence out, int first, int last, int numForcedPoints) { // numForcePoints lets us keep some points even if they are below simplification threshold @@ -117,6 +171,20 @@ private void subsimplify(CoordinateSequence in, MutableCoordinateSequence out, i } } + protected List transformCoordinateList(List coords, boolean area) { + if (coords.isEmpty()) { + return coords; + } + // make sure we include the first and last points even if they are closer than the simplification threshold + List result = new ArrayList<>(); + result.add(coords.getFirst()); + // for polygons, additionally keep at least 2 intermediate points even if they are below simplification threshold + // to avoid collapse. + subsimplify(coords, result, 0, coords.size() - 1, area ? 2 : 0); + result.add(coords.getLast()); + return result; + } + @Override protected CoordinateSequence transformCoordinates(CoordinateSequence coords, Geometry parent) { boolean area = parent instanceof LinearRing; diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/util/LoopLineMerger.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/util/LoopLineMerger.java new file mode 100644 index 0000000000..865c96de04 --- /dev/null +++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/util/LoopLineMerger.java @@ -0,0 +1,609 @@ +package com.onthegomap.planetiler.util; + +import com.onthegomap.planetiler.geo.DouglasPeuckerSimplifier; +import com.onthegomap.planetiler.geo.GeoUtils; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.PriorityQueue; +import org.locationtech.jts.algorithm.Angle; +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.CoordinateXY; +import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.geom.GeometryComponentFilter; +import org.locationtech.jts.geom.GeometryFactory; +import org.locationtech.jts.geom.LineString; +import org.locationtech.jts.geom.PrecisionModel; +import org.locationtech.jts.operation.linemerge.LineMerger; + +/** + * A utility class for merging, simplifying, and connecting linestrings and removing small loops. + *

+ * Compared to JTS {@link LineMerger} which only connects when 2 lines meet at a single point, this utility: + *

    + *
  • snap-rounds points to a grid + *
  • splits lines that intersect at a midpoint + *
  • breaks small loops less than {@code loopMinLength} so only the shortest path connects both endpoints of the loop + *
  • removes short "hair" edges less than {@code stubMinLength} coming off the side of longer segments + *
  • simplifies linestrings, without touching the points shared between multiple lines to avoid breaking connections + *
  • removes any duplicate edges + *
  • at any remaining 3+ way intersections, connect pairs of edges that form the straightest path through the node + *
  • remove any remaining edges shorter than {@code minLength} + *
+ * + * @see Improving Linestring + * Merging in Planetiler + */ +public class LoopLineMerger { + private final List input = new ArrayList<>(); + private final List output = new ArrayList<>(); + private int numNodes = 0; + private int numEdges = 0; + private PrecisionModel precisionModel = new PrecisionModel(GeoUtils.TILE_PRECISION); + private GeometryFactory factory = new GeometryFactory(precisionModel); + private double minLength = 0.0; + private double loopMinLength = 0.0; + private double stubMinLength = 0.0; + private double tolerance = -1.0; + private boolean mergeStrokes = false; + + /** + * Sets the precision model used to snap points to a grid. + *

+ * Use {@link PrecisionModel#FLOATING} to not snap points at all, or {@code new PrecisionModel(4)} to snap to a 0.25px + * grid. + */ + public LoopLineMerger setPrecisionModel(PrecisionModel precisionModel) { + this.precisionModel = precisionModel; + factory = new GeometryFactory(precisionModel); + return this; + } + + /** + * Sets the minimum length for retaining linestrings in the resulting geometry. + *

+ * Linestrings shorter than this value will be removed. {@code minLength <= 0} disables min length removal. + */ + public LoopLineMerger setMinLength(double minLength) { + this.minLength = minLength; + return this; + } + + /** + * Sets the minimum loop length for breaking loops in the merged geometry. + *

+ * Loops that are shorter than loopMinLength are broken up so that only the shortest path between loop endpoints + * remains. This should be {@code >= minLength}. {@code loopMinLength <= 0} disables loop removal. + */ + public LoopLineMerger setLoopMinLength(double loopMinLength) { + this.loopMinLength = loopMinLength; + return this; + } + + /** + * Sets the minimum length of stubs to be removed during processing. + *

+ * Stubs are short "hair" line segments that hang off of a longer linestring without connecting to anything else. + * {@code stubMinLength <= 0} disables stub removal. + */ + public LoopLineMerger setStubMinLength(double stubMinLength) { + this.stubMinLength = stubMinLength; + return this; + } + + /** + * Sets the tolerance for simplifying linestrings during processing. Lines are simplified between endpoints to avoid + * breaking intersections. + *

+ * {@code tolerance = 0} still removes collinear points, so you need to set {@code tolerance <= 0} to disable + * simplification. + */ + public LoopLineMerger setTolerance(double tolerance) { + this.tolerance = tolerance; + return this; + } + + /** + * Enables or disables stroke merging. Stroke merging connects the straightest pairs of linestrings at junctions with + * 3 or more attached linestrings based on the angle between them. + */ + public LoopLineMerger setMergeStrokes(boolean mergeStrokes) { + this.mergeStrokes = mergeStrokes; + return this; + } + + /** + * Adds a geometry to the merger. Only linestrings from the input geometry are considered. + */ + public LoopLineMerger add(Geometry geometry) { + geometry.apply((GeometryComponentFilter) component -> { + if (component instanceof LineString lineString) { + input.add(lineString); + } + }); + return this; + } + + private void degreeTwoMerge() { + for (var node : output) { + degreeTwoMerge(node); + } + output.removeIf(node -> node.getEdges().isEmpty()); + assert valid(); + } + + private boolean valid() { + // when run from a unit test, ensure some basic conditions always hold... + for (var node : output) { + for (var edge : node.getEdges()) { + assert edge.isLoop() || edge.to.getEdges().contains(edge.reversed) : edge.to + " does not contain " + + edge.reversed; + for (var other : node.getEdges()) { + if (edge != other) { + assert edge != other.reversed : "node contained edge and its reverse " + node; + assert !edge.coordinates.equals(other.coordinates) : "duplicate edges " + edge + " and " + other; + } + } + } + assert node.getEdges().size() != 2 || node.getEdges().stream().anyMatch(Edge::isLoop) : "degree 2 node found " + + node; + } + return true; + } + + private Edge degreeTwoMerge(Node node) { + if (node.getEdges().size() == 2) { + Edge a = node.getEdges().getFirst(); + Edge b = node.getEdges().get(1); + // if one side is a loop, degree is actually > 2 + if (!a.isLoop() && !b.isLoop()) { + return mergeTwoEdges(node, a, b); + } + } + return null; + } + + private Edge mergeTwoEdges(Node node, Edge edge1, Edge edge2) { + // attempt to preserve segment directions from the original line + // when: A << N -- B then output C reversed from B to A + // when: A >> N -- B then output C from A to B + Edge a = edge1.main ? edge2 : edge1; + Edge b = edge1.main ? edge1 : edge2; + node.getEdges().remove(a); + node.getEdges().remove(b); + List coordinates = new ArrayList<>(); + coordinates.addAll(a.coordinates.reversed()); + coordinates.addAll(b.coordinates.subList(1, b.coordinates.size())); + Edge c = new Edge(a.to, b.to, coordinates, a.length + b.length); + a.to.removeEdge(a.reversed); + b.to.removeEdge(b.reversed); + a.to.addEdge(c); + if (a.to != b.to) { + b.to.addEdge(c.reversed); + } + return c; + } + + private void strokeMerge() { + for (var node : output) { + List edges = List.copyOf(node.getEdges()); + if (edges.size() >= 2) { + record AngledPair(Edge a, Edge b, double angle) {} + List angledPairs = new ArrayList<>(); + for (var i = 0; i < edges.size(); ++i) { + var edgei = edges.get(i); + for (var j = i + 1; j < edges.size(); ++j) { + var edgej = edges.get(j); + if (edgei != edgej.reversed) { + double angle = edgei.angleTo(edgej); + angledPairs.add(new AngledPair(edgei, edgej, angle)); + } + } + } + angledPairs.sort(Comparator.comparingDouble(angledPair -> angledPair.angle)); + List merged = new ArrayList<>(); + for (var angledPair : angledPairs.reversed()) { + if (merged.contains(angledPair.a) || merged.contains(angledPair.b)) { + continue; + } + mergeTwoEdges(angledPair.a.from, angledPair.a, angledPair.b); + merged.add(angledPair.a); + merged.add(angledPair.b); + } + } + } + } + + private void breakLoops() { + for (var node : output) { + if (node.getEdges().size() <= 1) { + continue; + } + for (var current : List.copyOf(node.getEdges())) { + record HasLoop(Edge edge, double distance) {} + List loops = new ArrayList<>(); + if (!node.getEdges().contains(current)) { + continue; + } + for (var other : node.getEdges()) { + double distance = other.length + + shortestDistanceAStar(other.to, current.to, current.from, loopMinLength - other.length); + if (distance <= loopMinLength) { + loops.add(new HasLoop(other, distance)); + } + } + if (loops.size() > 1) { + HasLoop min = loops.stream().min(Comparator.comparingDouble(HasLoop::distance)).get(); + for (var loop : loops) { + if (loop != min) { + loop.edge.remove(); + } + } + } + } + } + } + + private double shortestDistanceAStar(Node start, Node end, Node exclude, double maxLength) { + Map bestDistance = new HashMap<>(); + record Candidate(Node node, double length, double minTotalLength) {} + PriorityQueue frontier = new PriorityQueue<>(Comparator.comparingDouble(Candidate::minTotalLength)); + if (exclude != start) { + frontier.offer(new Candidate(start, 0, start.distance(end))); + } + while (!frontier.isEmpty()) { + Candidate candidate = frontier.poll(); + Node current = candidate.node; + if (current == end) { + return candidate.length; + } + + for (var edge : current.getEdges()) { + var neighbor = edge.to; + if (neighbor != exclude) { + double newDist = candidate.length + edge.length; + double prev = bestDistance.getOrDefault(neighbor.id, Double.POSITIVE_INFINITY); + if (newDist < prev) { + bestDistance.put(neighbor.id, newDist); + double minTotalLength = newDist + neighbor.distance(end); + if (minTotalLength <= maxLength) { + frontier.offer(new Candidate(neighbor, newDist, minTotalLength)); + } + } + } + } + } + return Double.POSITIVE_INFINITY; + } + + private void removeShortStubEdges() { + PriorityQueue toCheck = new PriorityQueue<>(Comparator.comparingDouble(Edge::length)); + for (var node : output) { + for (var edge : node.getEdges()) { + if (isShortStubEdge(edge)) { + toCheck.offer(edge); + } + } + } + while (!toCheck.isEmpty()) { + var edge = toCheck.poll(); + if (edge.removed) { + continue; + } + if (isShortStubEdge(edge)) { + edge.remove(); + } + if (degreeTwoMerge(edge.from) instanceof Edge merged) { + toCheck.offer(merged); + } + if (edge.from.getEdges().size() == 1) { + var other = edge.from.getEdges().getFirst(); + if (isShortStubEdge(other)) { + toCheck.offer(other); + } + } + if (edge.from != edge.to) { + if (degreeTwoMerge(edge.to) instanceof Edge merged) { + toCheck.offer(merged); + } + if (edge.to.getEdges().size() == 1) { + var other = edge.to.getEdges().getFirst(); + if (isShortStubEdge(other)) { + toCheck.offer(other); + } + } + } + } + } + + private boolean isShortStubEdge(Edge edge) { + return edge != null && !edge.removed && edge.length < stubMinLength && + (edge.from.getEdges().size() == 1 || edge.to.getEdges().size() == 1 || edge.isLoop()); + } + + private void removeShortEdges() { + for (var node : output) { + for (var edge : List.copyOf(node.getEdges())) { + if (edge.length < minLength) { + edge.remove(); + } + } + } + } + + private void simplify() { + List toRemove = new ArrayList<>(); + for (var node : output) { + for (var edge : node.getEdges()) { + if (edge.main) { + edge.simplify(); + if (edge.isCollapsed()) { + toRemove.add(edge); + } + } + } + } + toRemove.forEach(Edge::remove); + } + + private void removeDuplicatedEdges() { + for (var node : output) { + List toRemove = new ArrayList<>(); + for (var i = 0; i < node.getEdges().size(); ++i) { + Edge a = node.getEdges().get(i); + for (var j = i + 1; j < node.getEdges().size(); ++j) { + Edge b = node.getEdges().get(j); + if (b.to == a.to && a.coordinates.equals(b.coordinates)) { + toRemove.add(b); + } + } + } + for (var edge : toRemove) { + edge.remove(); + } + } + } + + /** + * Processes the added geometries and returns the merged linestrings. + *

+ * Can be called more than once. + */ + public List getMergedLineStrings() { + output.clear(); + List> edges = nodeLines(input); + buildNodes(edges); + + degreeTwoMerge(); + + if (loopMinLength > 0.0) { + breakLoops(); + degreeTwoMerge(); + } + + if (stubMinLength > 0.0) { + removeShortStubEdges(); + // removeShortStubEdges does degreeTwoMerge internally + } + + if (tolerance >= 0.0) { + simplify(); + removeDuplicatedEdges(); + degreeTwoMerge(); + } + + if (mergeStrokes) { + strokeMerge(); + degreeTwoMerge(); + } + + if (minLength > 0) { + removeShortEdges(); + } + + List result = new ArrayList<>(); + + for (var node : output) { + for (var edge : node.getEdges()) { + if (edge.main) { + result.add(factory.createLineString(edge.coordinates.toArray(Coordinate[]::new))); + } + } + } + + return result; + } + + private static double length(List edge) { + Coordinate last = null; + double length = 0; + for (Coordinate coord : edge) { + if (last != null) { + length += last.distance(coord); + } + last = coord; + } + return length; + } + + private void buildNodes(List> edges) { + Map nodes = new HashMap<>(); + for (var coordinateSequence : edges) { + Coordinate first = coordinateSequence.getFirst(); + Node firstNode = nodes.get(first); + if (firstNode == null) { + firstNode = new Node(first); + nodes.put(first, firstNode); + output.add(firstNode); + } + + Coordinate last = coordinateSequence.getLast(); + Node lastNode = nodes.get(last); + if (lastNode == null) { + lastNode = new Node(last); + nodes.put(last, lastNode); + output.add(lastNode); + } + + double length = length(coordinateSequence); + + Edge edge = new Edge(firstNode, lastNode, coordinateSequence, length); + + firstNode.addEdge(edge); + if (firstNode != lastNode) { + lastNode.addEdge(edge.reversed); + } + } + } + + private List> nodeLines(List input) { + Map nodeCounts = new HashMap<>(); + List> coords = new ArrayList<>(input.size()); + for (var line : input) { + var coordinateSequence = line.getCoordinateSequence(); + List snapped = new ArrayList<>(); + Coordinate last = null; + for (int i = 0; i < coordinateSequence.size(); i++) { + Coordinate current = new CoordinateXY(coordinateSequence.getX(i), coordinateSequence.getY(i)); + precisionModel.makePrecise(current); + if (last == null || !last.equals(current)) { + snapped.add(current); + nodeCounts.merge(current, 1, Integer::sum); + } + last = current; + } + if (snapped.size() >= 2) { + coords.add(snapped); + } + } + + List> result = new ArrayList<>(input.size()); + for (var coordinateSequence : coords) { + int start = 0; + for (int i = 0; i < coordinateSequence.size(); i++) { + Coordinate coordinate = coordinateSequence.get(i); + if (i > 0 && i < coordinateSequence.size() - 1 && nodeCounts.get(coordinate) > 1) { + result.add(coordinateSequence.subList(start, i + 1)); + start = i; + } + } + if (start < coordinateSequence.size()) { + var sublist = start == 0 ? coordinateSequence : coordinateSequence.subList(start, coordinateSequence.size()); + result.add(sublist); + } + } + return result; + } + + private class Node { + final int id = numNodes++; + final List edge = new ArrayList<>(); + Coordinate coordinate; + + Node(Coordinate coordinate) { + this.coordinate = coordinate; + } + + void addEdge(Edge edge) { + for (Edge other : this.edge) { + if (other.coordinates.equals(edge.coordinates)) { + return; + } + } + this.edge.add(edge); + } + + List getEdges() { + return edge; + } + + void removeEdge(Edge edge) { + this.edge.remove(edge); + } + + @Override + public String toString() { + return "Node{" + id + ": " + edge + '}'; + } + + double distance(Node end) { + return coordinate.distance(end.coordinate); + } + } + + private class Edge { + + final int id; + final Node from; + final Node to; + final double length; + final boolean main; + boolean removed; + + Edge reversed; + List coordinates; + + + private Edge(Node from, Node to, List coordinateSequence, double length) { + this(numEdges, from, to, length, coordinateSequence, true, null); + reversed = new Edge(numEdges, to, from, length, coordinateSequence.reversed(), false, this); + numEdges++; + } + + private Edge(int id, Node from, Node to, double length, List coordinates, boolean main, Edge reversed) { + this.id = id; + this.from = from; + this.to = to; + this.length = length; + this.coordinates = coordinates; + this.main = main; + this.reversed = reversed; + } + + void remove() { + if (!removed) { + from.removeEdge(this); + to.removeEdge(reversed); + removed = true; + } + } + + double angleTo(Edge other) { + assert from.equals(other.from); + assert coordinates.size() >= 2; + + double angle = Angle.angle(coordinates.get(0), coordinates.get(1)); + double angleOther = Angle.angle(other.coordinates.get(0), other.coordinates.get(1)); + + return Math.abs(Angle.normalize(angle - angleOther)); + } + + double length() { + return length; + } + + void simplify() { + coordinates = DouglasPeuckerSimplifier.simplify(coordinates, tolerance, false); + if (reversed != null) { + reversed.coordinates = coordinates.reversed(); + } + } + + boolean isCollapsed() { + return coordinates.size() < 2 || + (coordinates.size() == 2 && coordinates.getFirst().equals(coordinates.getLast())); + } + + boolean isLoop() { + return from == to; + } + + @Override + public String toString() { + return "Edge{" + from.id + "->" + to.id + (main ? "" : "(R)") + ": [" + coordinates.getFirst() + ".." + + coordinates.getLast() + "], length=" + length + '}'; + } + } +} diff --git a/planetiler-core/src/test/java/com/onthegomap/planetiler/geo/DouglasPeuckerSimplifierTest.java b/planetiler-core/src/test/java/com/onthegomap/planetiler/geo/DouglasPeuckerSimplifierTest.java index e2a18899c9..857a00bc04 100644 --- a/planetiler-core/src/test/java/com/onthegomap/planetiler/geo/DouglasPeuckerSimplifierTest.java +++ b/planetiler-core/src/test/java/com/onthegomap/planetiler/geo/DouglasPeuckerSimplifierTest.java @@ -4,9 +4,13 @@ import static com.onthegomap.planetiler.TestUtils.newLineString; import static com.onthegomap.planetiler.TestUtils.newPolygon; import static com.onthegomap.planetiler.TestUtils.rectangle; +import static org.junit.jupiter.api.Assertions.assertEquals; +import java.util.List; import org.junit.jupiter.api.Test; +import org.locationtech.jts.geom.Coordinate; import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.geom.Polygonal; import org.locationtech.jts.geom.util.AffineTransformation; class DouglasPeuckerSimplifierTest { @@ -16,10 +20,18 @@ class DouglasPeuckerSimplifierTest { private void testSimplify(Geometry in, Geometry expected, double amount) { for (int rotation : rotations) { var rotate = AffineTransformation.rotationInstance(Math.PI * rotation / 180); + var expRot = rotate.transform(expected); + var inRot = rotate.transform(in); assertSameNormalizedFeature( - rotate.transform(expected), - DouglasPeuckerSimplifier.simplify(rotate.transform(in), amount) + expRot, + DouglasPeuckerSimplifier.simplify(inRot, amount) ); + + // ensure the List version also works... + List inList = List.of(inRot.getCoordinates()); + List expList = List.of(expRot.getCoordinates()); + List actual = DouglasPeuckerSimplifier.simplify(inList, amount, in instanceof Polygonal); + assertEquals(expList, actual); } } @@ -65,8 +77,8 @@ void testPolygonLeaveAPoint() { rectangle(0, 10), newPolygon( 0, 0, - 10, 10, 10, 0, + 10, 10, 0, 0 ), 20 diff --git a/planetiler-core/src/test/java/com/onthegomap/planetiler/util/LoopLineMergerTest.java b/planetiler-core/src/test/java/com/onthegomap/planetiler/util/LoopLineMergerTest.java new file mode 100644 index 0000000000..59fa2e2215 --- /dev/null +++ b/planetiler-core/src/test/java/com/onthegomap/planetiler/util/LoopLineMergerTest.java @@ -0,0 +1,518 @@ +package com.onthegomap.planetiler.util; + +import static com.onthegomap.planetiler.TestUtils.newLineString; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.onthegomap.planetiler.TestUtils; +import com.onthegomap.planetiler.geo.GeoUtils; +import java.io.IOException; +import java.nio.file.Files; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.geom.LineString; +import org.locationtech.jts.io.ParseException; +import org.locationtech.jts.io.WKBReader; +import org.locationtech.jts.operation.linemerge.LineMerger; + +class LoopLineMergerTest { + + @Test + void testMergeTouchingLinestrings() { + var merger = new LoopLineMerger() + .setMinLength(-1) + .setStubMinLength(-1) + .setTolerance(-1) + .setLoopMinLength(-1); + + merger.add(newLineString(10, 10, 20, 20)); + merger.add(newLineString(20, 20, 30, 30)); + assertEquals( + List.of(newLineString(10, 10, 20, 20, 30, 30)), + merger.getMergedLineStrings() + ); + } + + @Test + void testKeepTwoSeparateLinestring() { + var merger = new LoopLineMerger() + .setMinLength(-1) + .setLoopMinLength(-1); + + merger.add(newLineString(10, 10, 20, 20)); + merger.add(newLineString(30, 30, 40, 40)); + assertEquals( + List.of( + newLineString(10, 10, 20, 20), + newLineString(30, 30, 40, 40) + ), + merger.getMergedLineStrings() + ); + } + + @Test + void testDoesNotOvercountAlreadyAddedLines() { + var merger = new LoopLineMerger() + .setMinLength(-1) + .setTolerance(-1) + .setStubMinLength(-1) + .setLoopMinLength(-1); + + merger.add(newLineString(10, 10, 20, 20)); + merger.add(newLineString(20, 20, 30, 30)); + merger.add(newLineString(20, 20, 30, 30)); + assertEquals( + List.of(newLineString(10, 10, 20, 20, 30, 30)), + merger.getMergedLineStrings() + ); + } + + @Test + void testSplitLinestringsBeforeMerging() { + var merger = new LoopLineMerger() + .setMinLength(-1) + .setLoopMinLength(-1) + .setStubMinLength(-1) + .setTolerance(-1); + + merger.add(newLineString(10, 10, 20, 20, 30, 30)); + merger.add(newLineString(20, 20, 30, 30, 40, 40)); + assertEquals( + List.of(newLineString(10, 10, 20, 20, 30, 30, 40, 40)), + merger.getMergedLineStrings() + ); + } + + @Test + void testProgressiveStubRemoval() { + var merger = new LoopLineMerger() + .setMinLength(-1) + .setStubMinLength(4) + .setLoopMinLength(-1) + .setTolerance(-1); + + merger.add(newLineString(0, 0, 5, 0)); // stub length 5 + merger.add(newLineString(5, 0, 6, 0)); // mid piece + merger.add(newLineString(6, 0, 8, 0)); // stub length 2 + merger.add(newLineString(5, 0, 5, 1)); // stub length 1 + merger.add(newLineString(6, 0, 6, 1)); // stub length 1 + + assertEquals( + List.of(newLineString(0, 0, 5, 0, 6, 0, 8, 0)), + merger.getMergedLineStrings() + ); + } + + @Test + void testRoundCoordinatesBeforeMerging() { + var merger = new LoopLineMerger() + .setMinLength(-1) + .setLoopMinLength(-1) + .setStubMinLength(-1) + .setTolerance(-1); + + merger.add(newLineString(10.00043983098, 10, 20, 20)); + merger.add(newLineString(20, 20, 30, 30)); + assertEquals( + List.of(newLineString(10, 10, 20, 20, 30, 30)), + merger.getMergedLineStrings() + ); + } + + @Test + void testRemoveSmallLoops() { + var merger = new LoopLineMerger() + .setMinLength(-1) + .setStubMinLength(-1) + .setTolerance(-1) + .setLoopMinLength(100); + + merger.add(newLineString( + 10, 10, + 20, 10, + 30, 10, + 30, 20, + 40, 20 + )); + merger.add(newLineString( + 20, 10, + 30, 20 + )); + assertEquals( + List.of( + newLineString( + 10, 10, + 20, 10, + 30, 20, + 40, 20 + ) + ), + merger.getMergedLineStrings() + ); + } + + @Test + void testRemoveSelfClosingLoops() { + // Note that self-closing loops are considered stubs. + // They are removed by stubMinLength, not loopMinLength... + var merger = new LoopLineMerger() + .setMinLength(-1) + .setTolerance(-1) + .setStubMinLength(5) + .setLoopMinLength(-1); + + merger.add(newLineString( + 1, -10, + 1, 1, + 1, 2, + 0, 2, + 0, 1, + 1, 1, + 10, 1)); + assertEquals( + List.of(newLineString(1, -10, 1, 1, 10, 1)), + merger.getMergedLineStrings() + ); + } + + @Test + void testDoNotRemoveLargeLoops() { + var merger = new LoopLineMerger() + .setMinLength(-1) + .setLoopMinLength(0.001); + + merger.add(newLineString( + 10, 10, + 20, 10, + 30, 10, + 30, 20, + 40, 20 + )); + merger.add(newLineString( + 20, 10, + 30, 20 + )); + assertEquals( + List.of( + newLineString( + 10, 10, + 20, 10 + ), + newLineString( + 20, 10, + 30, 10, + 30, 20 + ), + newLineString( + 20, 10, + 30, 20 + ), + newLineString( + 30, 20, + 40, 20 + ) + ), + merger.getMergedLineStrings() + ); + } + + @Test + void testRemoveShortLine() { + var merger = new LoopLineMerger() + .setMinLength(10) + .setStubMinLength(-1) + .setTolerance(-1) + .setLoopMinLength(-1); + + merger.add(newLineString(10, 10, 11, 11)); + merger.add(newLineString(20, 20, 30, 30)); + merger.add(newLineString(30, 30, 40, 40)); + assertEquals( + List.of(newLineString(20, 20, 30, 30, 40, 40)), + merger.getMergedLineStrings() + ); + } + + @Test + void testRemovesShortStubsTheNonStubsThatAreTooShort() { + var merger = new LoopLineMerger() + .setMinLength(0) + .setLoopMinLength(-1) + .setStubMinLength(15) + .setTolerance(-1); + + merger.add(newLineString(0, 0, 20, 0)); + merger.add(newLineString(20, 0, 30, 0)); + merger.add(newLineString(30, 0, 50, 0)); + merger.add(newLineString(20, 0, 20, 10)); + merger.add(newLineString(30, 0, 30, 10)); + + assertEquals( + List.of(newLineString(0, 0, 20, 0, 30, 0, 50, 0)), + merger.getMergedLineStrings() + ); + } + + @Test + void testMergeCarriagewaysWithOneSplitShorterThanLoopMinLength() { + var merger = new LoopLineMerger() + .setMinLength(20) + .setMergeStrokes(true) + .setLoopMinLength(20); + + merger.add(newLineString(0, 0, 10, 0, 20, 0, 30, 0)); + merger.add(newLineString(30, 0, 20, 0, 15, 1, 10, 0, 0, 0)); + + assertEquals( + List.of(newLineString(0, 0, 10, 0, 20, 0, 30, 0)), + merger.getMergedLineStrings() + ); + } + + @Test + void testMergeCarriagewaysWithOneSplitLongerThanLoopMinLength() { + var merger = new LoopLineMerger() + .setMinLength(5) + .setMergeStrokes(true) + .setLoopMinLength(5); + + merger.add(newLineString(0, 0, 10, 0, 20, 0, 30, 0)); + merger.add(newLineString(30, 0, 20, 0, 15, 1, 10, 0, 0, 0)); + + assertEquals( + // ideally loop merging should connect long line strings and represent loops as separate segments off of the edges + List.of(newLineString(0, 0, 10, 0, 20, 0, 30, 0), newLineString(20, 0, 15, 1, 10, 0)), + merger.getMergedLineStrings() + ); + } + + @Test + void testMergeCarriagewaysWithTwoSplits() { + var merger = new LoopLineMerger() + .setMinLength(20) + .setMergeStrokes(true) + .setLoopMinLength(20); + + merger.add(newLineString(0, 0, 10, 0, 20, 0, 30, 0, 40, 0)); + merger.add(newLineString(40, 0, 30, 0, 25, 5, 20, 0, 15, 5, 10, 0, 0, 0)); + + assertEquals( + List.of(newLineString(0, 0, 10, 0, 20, 0, 30, 0, 40, 0)), + merger.getMergedLineStrings() + ); + } + + @Test + void testMergeLoopAttachedToStub() { + var merger = new LoopLineMerger() + .setMinLength(10) + .setLoopMinLength(10) + .setStubMinLength(10) + .setTolerance(-1); + + merger.add(newLineString(-20, 0, 0, 0, 20, 0)); + merger.add(newLineString(0, 0, 0, 1)); + merger.add(newLineString(0, 1, 1, 2, 1, 1, 0, 1)); + + assertEquals( + List.of(newLineString(-20, 0, 0, 0, 20, 0)), + merger.getMergedLineStrings() + ); + } + + @Test + void testRealWorldHarkingen() { + var merger = new LoopLineMerger() + .setMinLength(4 * 0.0625) + .setLoopMinLength(8 * 0.0625); + + merger.add( + newLineString(99.185791015625, 109.83056640625, 99.202392578125, 109.8193359375, 99.21337890625, 109.810302734375, + 99.222412109375, 109.8017578125, 99.229736328125, 109.793701171875, 99.241943359375, 109.779541015625)); + merger.add(newLineString(98.9931640625, 109.863525390625, 99.005126953125, 109.862060546875, 99.01708984375, + 109.86083984375, 99.028564453125, 109.85986328125, 99.040283203125, 109.859375, 99.0712890625, 109.85791015625, + 99.08203125, 109.857421875, 99.093017578125, 109.856689453125, 99.104248046875, 109.855712890625, 99.115478515625, + 109.8544921875, 99.12646484375, 109.852783203125, 99.1376953125, 109.850341796875, 99.1474609375, 109.84765625, + 99.15673828125, 109.844482421875, 99.166748046875, 109.84033203125, 99.175537109375, 109.836181640625, + 99.185791015625, 109.83056640625)); + merger.add(newLineString(99.162841796875, 109.812744140625, 99.0966796875, 109.824462890625, 99.055419921875, + 109.832275390625, 99.008544921875, 109.842041015625, 98.967529296875, 109.8525390625, 98.8818359375, + 109.875244140625)); + merger.add(newLineString(98.879150390625, 109.885498046875, 98.94091796875, 109.86572265625, 98.968017578125, + 109.859130859375, 99.017578125, 109.847412109375, 99.056396484375, 109.83984375, 99.09814453125, 109.831298828125, + 99.163330078125, 109.81982421875)); + var merged = merger.getMergedLineStrings(); + + assertEquals( + 1, + merged.size() + ); + } + + @ParameterizedTest + @CsvSource({ + "mergelines_1759_point_line.wkb.gz,0,false,3", + "mergelines_1759_point_line.wkb.gz,1,false,2", + "mergelines_1759_point_line.wkb.gz,1,true,2", + + "mergelines_200433_lines.wkb.gz,0,false,9103", + "mergelines_200433_lines.wkb.gz,0.1,false,8834", + "mergelines_200433_lines.wkb.gz,1,false,861", + "mergelines_200433_lines.wkb.gz,1,true,508", + + "mergelines_239823_lines.wkb.gz,0,false,6188", + "mergelines_239823_lines.wkb.gz,0.1,false,5941", + "mergelines_239823_lines.wkb.gz,1,false,826", + "mergelines_239823_lines.wkb.gz,1,true,681", + + "i90.wkb.gz,0,false,17", + "i90.wkb.gz,1,false,18", + "i90.wkb.gz,20,false,4", + "i90.wkb.gz,30,false,1", + }) + void testOnRealWorldData(String file, double minLengths, boolean simplify, int expected) + throws IOException, ParseException { + Geometry geom = new WKBReader(GeoUtils.JTS_FACTORY).read( + Gzip.gunzip(Files.readAllBytes(TestUtils.pathToResource("mergelines").resolve(file)))); + var merger = new LoopLineMerger(); + merger.setMinLength(minLengths); + merger.setLoopMinLength(minLengths); + merger.setStubMinLength(minLengths); + merger.setMergeStrokes(true); + merger.setTolerance(simplify ? 1 : -1); + merger.add(geom); + var merged = merger.getMergedLineStrings(); + Set> lines = new HashSet<>(); + var merger2 = new LineMerger(); + for (var line : merged) { + merger2.add(line); + assertTrue(lines.add(Arrays.asList(line.getCoordinates())), "contained duplicate: " + line); + if (minLengths > 0 && !simplify) { // simplification can make an edge < min length + assertTrue(line.getLength() >= minLengths, "line < " + minLengths + ": " + line); + } + } + // ensure there are no more opportunities for simplification found by JTS: + List loop = List.copyOf(merged); + List jts = merger2.getMergedLineStrings().stream().map(LineString.class::cast).toList(); + List missing = jts.stream().filter(l -> !loop.contains(l)).toList(); + List extra = loop.stream().filter(l -> !jts.contains(l)).toList(); + assertEquals(List.of(), missing, "missing edges"); + assertEquals(List.of(), extra, "extra edges"); + assertEquals(merged.size(), merger2.getMergedLineStrings().size()); + assertEquals(expected, merged.size()); + } + + @Test + void testMergeStrokesAt3WayIntersectionWithLoop() { + var merger = new LoopLineMerger() + .setMinLength(1) + .setLoopMinLength(1) + .setStubMinLength(1) + .setMergeStrokes(true); + + merger.add(newLineString(-5, 0, 0, 0)); + merger.add(newLineString(0, 0, 5, 0, 5, 5, 0, 5, 0, 0)); + + assertEquals( + List.of( + newLineString(-5, 0, 0, 0, 5, 0, 5, 5, 0, 5, 0, 0) + ), + merger.getMergedLineStrings() + ); + } + + @Test + void testMergeStrokesAt3WayIntersectionWithLoop2() { + var merger = new LoopLineMerger() + .setMinLength(1) + .setLoopMinLength(1) + .setStubMinLength(1) + .setMergeStrokes(true); + + merger.add(newLineString(-5, 0, 0, 0)); + merger.add(newLineString(0, 0, 0, -1, 5, 0, 5, 5, 0, 5, 0, 0)); + + assertEquals( + List.of( + newLineString( + -5, 0, 0, 0, 0, -1, 5, 0, 5, 5, 0, 5, 0, 0 + ) + ), + merger.getMergedLineStrings() + ); + } + + @Test + void testMergeStrokesAt3WayIntersection() { + var merger = new LoopLineMerger() + .setMinLength(1) + .setLoopMinLength(1) + .setStubMinLength(1) + .setMergeStrokes(true); + + merger.add(newLineString(-5, 0, 0, 0)); + merger.add(newLineString(0, 0, 5, 0)); + merger.add(newLineString(0, 0, 0, 5)); + + assertEquals( + List.of( + newLineString(-5, 0, 0, 0, 5, 0), + newLineString(0, 0, 0, 5) + ), + merger.getMergedLineStrings() + ); + } + + @Test + void testMergeStrokesAt4WayIntersection() { + var merger = new LoopLineMerger() + .setMinLength(1) + .setLoopMinLength(1) + .setStubMinLength(1) + .setMergeStrokes(true); + + merger.add(newLineString(-5, 0, 0, 0)); + merger.add(newLineString(0, 0, 5, 0)); + merger.add(newLineString(0, 0, 0, 5)); + merger.add(newLineString(0, 0, 0, -5)); + + assertEquals( + List.of( + newLineString(-5, 0, 0, 0, 5, 0), + newLineString(0, -5, 0, 0, 0, 5) + ), + merger.getMergedLineStrings() + ); + } + + @Test + void testMergeStrokesAt5WayIntersection() { + var merger = new LoopLineMerger() + .setMinLength(1) + .setLoopMinLength(1) + .setStubMinLength(1) + .setMergeStrokes(true); + + merger.add(newLineString(-5, 0, 0, 0)); + merger.add(newLineString(0, 0, 5, 0)); + merger.add(newLineString(0, 0, 0, 5)); + merger.add(newLineString(0, 0, 0, -5)); + merger.add(newLineString(0, 0, 5, 5)); + + assertEquals( + List.of( + newLineString(-5, 0, 0, 0, 5, 0), + newLineString(0, 0, 5, 5), + newLineString(0, -5, 0, 0, 0, 5) + ), + merger.getMergedLineStrings() + ); + } +} diff --git a/planetiler-core/src/test/resources/mergelines/i90.wkb.gz b/planetiler-core/src/test/resources/mergelines/i90.wkb.gz new file mode 100644 index 0000000000..10671bbb1e Binary files /dev/null and b/planetiler-core/src/test/resources/mergelines/i90.wkb.gz differ diff --git a/planetiler-core/src/test/resources/mergelines/mergelines_1759_point_line.wkb.gz b/planetiler-core/src/test/resources/mergelines/mergelines_1759_point_line.wkb.gz new file mode 100644 index 0000000000..8813e1c180 Binary files /dev/null and b/planetiler-core/src/test/resources/mergelines/mergelines_1759_point_line.wkb.gz differ diff --git a/planetiler-core/src/test/resources/mergelines/mergelines_200433_lines.wkb.gz b/planetiler-core/src/test/resources/mergelines/mergelines_200433_lines.wkb.gz new file mode 100644 index 0000000000..eb0ee1fd58 Binary files /dev/null and b/planetiler-core/src/test/resources/mergelines/mergelines_200433_lines.wkb.gz differ diff --git a/planetiler-core/src/test/resources/mergelines/mergelines_239823_lines.wkb.gz b/planetiler-core/src/test/resources/mergelines/mergelines_239823_lines.wkb.gz new file mode 100644 index 0000000000..5a863f85c5 Binary files /dev/null and b/planetiler-core/src/test/resources/mergelines/mergelines_239823_lines.wkb.gz differ diff --git a/planetiler-examples/src/test/java/com/onthegomap/planetiler/examples/BikeRouteOverlayTest.java b/planetiler-examples/src/test/java/com/onthegomap/planetiler/examples/BikeRouteOverlayTest.java index e2e311b818..77b2eff33e 100644 --- a/planetiler-examples/src/test/java/com/onthegomap/planetiler/examples/BikeRouteOverlayTest.java +++ b/planetiler-examples/src/test/java/com/onthegomap/planetiler/examples/BikeRouteOverlayTest.java @@ -109,7 +109,7 @@ void integrationTest(@TempDir Path tmpDir) throws Exception { .assertNumFeatures(mbtiles, "bicycle-route-international", 14, Map.of( "name", "EuroVelo 8 - Mediterranean Route - part Monaco", "ref", "EV8" - ), GeoUtils.WORLD_LAT_LON_BOUNDS, 25, LineString.class); + ), GeoUtils.WORLD_LAT_LON_BOUNDS, 13, LineString.class); TestUtils.assertTileDuplicates(mbtiles, 0); }