kopia lustrzana https://github.com/onthegomap/planetiler
Add loop line merger (#1083)
rodzic
8a1b54f91f
commit
c470dc30d3
|
@ -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<Geometry, Collection<LineString>> 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<Geometry, Collection<LineString>> 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<Geometry, Collection<LineString>> 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<LineString> 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));
|
||||
}
|
||||
}
|
|
@ -4,13 +4,13 @@ import com.carrotsearch.hppc.IntArrayList;
|
|||
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 class FeatureMerge {
|
|||
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 class FeatureMerge {
|
|||
}
|
||||
}
|
||||
List<LineString> 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);
|
||||
|
|
|
@ -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 class DouglasPeuckerSimplifier {
|
|||
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<Coordinate> simplify(List<Coordinate> 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 @@ public class DouglasPeuckerSimplifier {
|
|||
return dx * dx + dy * dy;
|
||||
}
|
||||
|
||||
private void subsimplify(List<Coordinate> in, List<Coordinate> 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 @@ public class DouglasPeuckerSimplifier {
|
|||
}
|
||||
}
|
||||
|
||||
protected List<Coordinate> transformCoordinateList(List<Coordinate> 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<Coordinate> 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;
|
||||
|
|
|
@ -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.
|
||||
* <p>
|
||||
* Compared to JTS {@link LineMerger} which only connects when 2 lines meet at a single point, this utility:
|
||||
* <ul>
|
||||
* <li>snap-rounds points to a grid
|
||||
* <li>splits lines that intersect at a midpoint
|
||||
* <li>breaks small loops less than {@code loopMinLength} so only the shortest path connects both endpoints of the loop
|
||||
* <li>removes short "hair" edges less than {@code stubMinLength} coming off the side of longer segments
|
||||
* <li>simplifies linestrings, without touching the points shared between multiple lines to avoid breaking connections
|
||||
* <li>removes any duplicate edges
|
||||
* <li>at any remaining 3+ way intersections, connect pairs of edges that form the straightest path through the node
|
||||
* <li>remove any remaining edges shorter than {@code minLength}
|
||||
* </ul>
|
||||
*
|
||||
* @see <a href= "https://oliverwipfli.ch/improving-linestring-merging-in-planetiler-2024-10-30/">Improving Linestring
|
||||
* Merging in Planetiler</a>
|
||||
*/
|
||||
public class LoopLineMerger {
|
||||
private final List<LineString> input = new ArrayList<>();
|
||||
private final List<Node> 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.
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
* {@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<Coordinate> 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<Edge> edges = List.copyOf(node.getEdges());
|
||||
if (edges.size() >= 2) {
|
||||
record AngledPair(Edge a, Edge b, double angle) {}
|
||||
List<AngledPair> 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<Edge> 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<HasLoop> 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<Integer, Double> bestDistance = new HashMap<>();
|
||||
record Candidate(Node node, double length, double minTotalLength) {}
|
||||
PriorityQueue<Candidate> 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<Edge> 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<Edge> 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<Edge> 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.
|
||||
* <p>
|
||||
* Can be called more than once.
|
||||
*/
|
||||
public List<LineString> getMergedLineStrings() {
|
||||
output.clear();
|
||||
List<List<Coordinate>> 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<LineString> 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<Coordinate> 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<List<Coordinate>> edges) {
|
||||
Map<Coordinate, Node> 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<List<Coordinate>> nodeLines(List<LineString> input) {
|
||||
Map<Coordinate, Integer> nodeCounts = new HashMap<>();
|
||||
List<List<Coordinate>> coords = new ArrayList<>(input.size());
|
||||
for (var line : input) {
|
||||
var coordinateSequence = line.getCoordinateSequence();
|
||||
List<Coordinate> 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<List<Coordinate>> 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> 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<Edge> 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<Coordinate> coordinates;
|
||||
|
||||
|
||||
private Edge(Node from, Node to, List<Coordinate> 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<Coordinate> 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 + '}';
|
||||
}
|
||||
}
|
||||
}
|
|
@ -4,9 +4,13 @@ import static com.onthegomap.planetiler.TestUtils.assertSameNormalizedFeature;
|
|||
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<Coordinate> version also works...
|
||||
List<Coordinate> inList = List.of(inRot.getCoordinates());
|
||||
List<Coordinate> expList = List.of(expRot.getCoordinates());
|
||||
List<Coordinate> actual = DouglasPeuckerSimplifier.simplify(inList, amount, in instanceof Polygonal);
|
||||
assertEquals(expList, actual);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -65,8 +77,8 @@ class DouglasPeuckerSimplifierTest {
|
|||
rectangle(0, 10),
|
||||
newPolygon(
|
||||
0, 0,
|
||||
10, 10,
|
||||
10, 0,
|
||||
10, 10,
|
||||
0, 0
|
||||
),
|
||||
20
|
||||
|
|
|
@ -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<List<Coordinate>> 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<LineString> loop = List.copyOf(merged);
|
||||
List<LineString> jts = merger2.getMergedLineStrings().stream().map(LineString.class::cast).toList();
|
||||
List<LineString> missing = jts.stream().filter(l -> !loop.contains(l)).toList();
|
||||
List<LineString> 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()
|
||||
);
|
||||
}
|
||||
}
|
Plik binarny nie jest wyświetlany.
Plik binarny nie jest wyświetlany.
Plik binarny nie jest wyświetlany.
Plik binarny nie jest wyświetlany.
|
@ -109,7 +109,7 @@ class BikeRouteOverlayTest {
|
|||
.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);
|
||||
}
|
||||
|
|
Ładowanie…
Reference in New Issue