diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/FeatureCollector.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/FeatureCollector.java index 096a1bddcc..607491a6d1 100644 --- a/planetiler-core/src/main/java/com/onthegomap/planetiler/FeatureCollector.java +++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/FeatureCollector.java @@ -5,6 +5,7 @@ import com.onthegomap.planetiler.config.PlanetilerConfig; import com.onthegomap.planetiler.geo.GeoUtils; import com.onthegomap.planetiler.geo.GeometryException; +import com.onthegomap.planetiler.geo.GeometryPipeline; import com.onthegomap.planetiler.geo.GeometryType; import com.onthegomap.planetiler.geo.SimplifyMethod; import com.onthegomap.planetiler.reader.SourceFeature; @@ -506,6 +507,9 @@ public final class Feature implements WithZoomRange, WithAttrs private SimplifyMethod defaultSimplifyMethod = SimplifyMethod.DOUGLAS_PEUCKER; private ZoomFunction simplifyMethod = null; + private GeometryPipeline defaultGeometryPipeline = null; + private ZoomFunction geometryPipelineByZoom = null; + private String numPointsAttr = null; private List partialOverrides = null; @@ -740,6 +744,34 @@ public SimplifyMethod getSimplifyMethodAtZoom(int zoom) { return ZoomFunction.applyOrElse(simplifyMethod, zoom, defaultSimplifyMethod); } + /** + * Sets the default pipeline to apply to geometries scaled to tile coordinates right before emitting vector tile + * features. This function gets run instead of simplification, so should include any simplification if you want + * that. + */ + public Feature transformScaledGeometry(GeometryPipeline pipeline) { + this.defaultGeometryPipeline = pipeline; + return this; + } + + /** + * Sets the per-zoom geometry pipeline to apply to geometries scaled to tile coordinates right before emitting + * vector tile features. These functions get run instead of simplification, so should include any simplification if + * you want that. + */ + public Feature transformScaledGeometryByZoom(ZoomFunction overrides) { + this.geometryPipelineByZoom = overrides; + return this; + } + + /** + * Returns the geometry transform function to apply to scaled geometries at {@code zoom}, or null to not update them + * at all. + */ + public GeometryPipeline getScaledGeometryTransformAtZoom(int zoom) { + return ZoomFunction.applyOrElse(geometryPipelineByZoom, zoom, defaultGeometryPipeline); + } + /** * Sets the simplification tolerance for lines and polygons in tile pixels below the maximum zoom-level of the map. *

@@ -1091,6 +1123,10 @@ Partial withAttr(String key, Object value) { return rangesWithGeometries; } + public SourceFeature source() { + return source; + } + /** * A builder that can be used to configure linear-scoped attributes for a partial segment of a line feature. 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 4c86a45cbc..7f7e84ba09 100644 --- a/planetiler-core/src/main/java/com/onthegomap/planetiler/FeatureMerge.java +++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/FeatureMerge.java @@ -6,6 +6,7 @@ import com.onthegomap.planetiler.collection.Hppc; import com.onthegomap.planetiler.geo.GeoUtils; import com.onthegomap.planetiler.geo.GeometryException; +import com.onthegomap.planetiler.geo.GeometryPipeline; import com.onthegomap.planetiler.geo.GeometryType; import com.onthegomap.planetiler.geo.MutableCoordinateSequence; import com.onthegomap.planetiler.stats.DefaultStats; @@ -81,7 +82,7 @@ private FeatureMerge() {} */ public static List mergeLineStrings(List features, double minLength, double tolerance, double buffer, boolean resimplify) { - return mergeLineStrings(features, attrs -> minLength, tolerance, buffer, resimplify); + return mergeLineStrings(features, attrs -> minLength, tolerance, buffer, resimplify, null); } /** @@ -143,12 +144,13 @@ private static List mergeGeometries( } /** - * Merges linestrings with the same attributes as {@link #mergeLineStrings(List, Function, double, double, boolean)} - * except sets {@code resimplify=false} by default. + * Merges linestrings with the same attributes as + * {@link #mergeLineStrings(List, Function, double, double, boolean, GeometryPipeline)} except sets + * {@code resimplify=false} by default. */ public static List mergeLineStrings(List features, Function, Double> lengthLimitCalculator, double tolerance, double buffer) { - return mergeLineStrings(features, lengthLimitCalculator, tolerance, buffer, false); + return mergeLineStrings(features, lengthLimitCalculator, tolerance, buffer, false, null); } /** @@ -156,7 +158,8 @@ public static List mergeLineStrings(List * except with a dynamic length limit computed by {@code lengthLimitCalculator} for the attributes of each group. */ public static List mergeLineStrings(List features, - Function, Double> lengthLimitCalculator, double tolerance, double buffer, boolean resimplify) { + Function, Double> lengthLimitCalculator, double tolerance, double buffer, boolean resimplify, + GeometryPipeline pipeline) { List result = new ArrayList<>(features.size()); var groupedByAttrs = groupByAttrs(features, result, GeometryType.LINE); for (List groupedFeatures : groupedByAttrs) { @@ -176,7 +179,8 @@ public static List mergeLineStrings(List .setMergeStrokes(true) .setMinLength(lengthLimit) .setLoopMinLength(lengthLimit) - .setStubMinLength(0.5); + .setStubMinLength(0.5) + .setSegmentTransform(pipeline); for (VectorTile.Feature feature : groupedFeatures) { try { merger.add(feature.geometry().decode()); @@ -287,12 +291,14 @@ public static List mergeOverlappingPolygons(List mergeNearbyPolygons(List features, double minArea, - double minHoleArea, double minDist, double buffer, Stats stats) throws GeometryException { + double minHoleArea, double minDist, double buffer, Stats stats, GeometryPipeline pipeline) + throws GeometryException { List result = new ArrayList<>(features.size()); Collection> groupedByAttrs = groupByAttrs(features, result, GeometryType.POLYGON); for (List groupedFeatures : groupedByAttrs) { @@ -326,12 +332,26 @@ public static List mergeNearbyPolygons(List List sortByHilbertIndex(List geometrie public static List mergeNearbyPolygons(List features, double minArea, double minHoleArea, double minDist, double buffer) throws GeometryException { - return mergeNearbyPolygons(features, minArea, minHoleArea, minDist, buffer, DefaultStats.get()); + return mergeNearbyPolygons(features, minArea, minHoleArea, minDist, buffer, DefaultStats.get(), null); } 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 9ff3d0ef10..f2a7ce94f5 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 @@ -30,7 +30,31 @@ * accurately and performance improvement to put the results in a {@link MutableCoordinateSequence} which uses a * primitive double array instead of allocating lots of {@link Coordinate} objects. */ -public class DouglasPeuckerSimplifier { +public class DouglasPeuckerSimplifier extends GeometryTransformer implements GeometryPipeline { + + private final double sqTolerance; + + public DouglasPeuckerSimplifier(double distanceTolerance) { + this.sqTolerance = distanceTolerance * Math.abs(distanceTolerance); + } + + @Override + public Geometry apply(Geometry input) { + return simplify(input); + } + + /** + * Returns a copy of {@code geom}, simplified using Douglas Peucker Algorithm. + * + * @param geom the geometry to simplify (will not be modified) + * @return the simplified geometry + */ + public Geometry simplify(Geometry geom) { + if (geom.isEmpty() || (sqTolerance < 0.0)) { + return geom.copy(); + } + return transform(geom); + } /** * Returns a copy of {@code geom}, simplified using Douglas Peucker Algorithm. @@ -44,7 +68,7 @@ public static Geometry simplify(Geometry geom, double distanceTolerance) { return geom.copy(); } - return (new DPTransformer(distanceTolerance)).transform(geom); + return (new DouglasPeuckerSimplifier(distanceTolerance)).simplify(geom); } /** @@ -60,160 +84,154 @@ public static List simplify(List coords, double distance return List.of(); } - return (new DPTransformer(distanceTolerance)).transformCoordinateList(coords, area); + return (new DouglasPeuckerSimplifier(distanceTolerance)).transformCoordinateList(coords, area); } - private static class DPTransformer extends GeometryTransformer { - private final double sqTolerance; - - private DPTransformer(double distanceTolerance) { - this.sqTolerance = distanceTolerance * distanceTolerance; - } - - /** - * Returns the square of the number of units that (px, p1) is away from the line segment from (p1x, py1) to (p2x, - * p2y). - */ - private static double getSqSegDist(double px, double py, double p1x, double p1y, double p2x, double p2y) { + /** + * Returns the square of the number of units that (px, p1) is away from the line segment from (p1x, py1) to (p2x, + * p2y). + */ + public static double getSqSegDist(double px, double py, double p1x, double p1y, double p2x, double p2y) { - double x = p1x, - y = p1y, - dx = p2x - x, - dy = p2y - y; + double x = p1x, + y = p1y, + dx = p2x - x, + dy = p2y - y; - if (dx != 0d || dy != 0d) { + if (dx != 0d || dy != 0d) { - double t = ((px - x) * dx + (py - y) * dy) / (dx * dx + dy * dy); + double t = ((px - x) * dx + (py - y) * dy) / (dx * dx + dy * dy); - if (t > 1) { - x = p2x; - y = p2y; + if (t > 1) { + x = p2x; + y = p2y; - } else if (t > 0) { - x += dx * t; - y += dy * t; - } + } else if (t > 0) { + x += dx * t; + y += dy * t; } - - dx = px - x; - dy = py - y; - - 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++; - } + dx = px - x; + dy = py - y; - 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); - } + 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++; } - 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 - boolean force = numForcedPoints > 0; - double maxSqDist = force ? -1 : sqTolerance; - int index = -1; - double p1x = in.getX(first); - double p1y = in.getY(first); - double p2x = in.getX(last); - double p2y = in.getY(last); - - for (int i = first + 1; i < last; i++) { - double px = in.getX(i); - double py = in.getY(i); - double sqDist = getSqSegDist(px, py, p1x, p1y, p2x, p2y); - - if (sqDist > maxSqDist) { - index = i; - maxSqDist = sqDist; - } + if (force || maxSqDist > sqTolerance) { + if (index - first > 1) { + subsimplify(in, out, first, index, numForcedPoints - 1); } - - if (force || maxSqDist > sqTolerance) { - if (index - first > 1) { - subsimplify(in, out, first, index, numForcedPoints - 1); - } - out.forceAddPoint(in.getX(index), in.getY(index)); - if (last - index > 1) { - subsimplify(in, out, index, last, numForcedPoints - 2); - } + out.add(furthest); + if (last - index > 1) { + subsimplify(in, out, index, last, numForcedPoints - 2); } } + } - protected List transformCoordinateList(List coords, boolean area) { - if (coords.isEmpty()) { - return coords; + 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 + boolean force = numForcedPoints > 0; + double maxSqDist = force ? -1 : sqTolerance; + int index = -1; + double p1x = in.getX(first); + double p1y = in.getY(first); + double p2x = in.getX(last); + double p2y = in.getY(last); + + for (int i = first + 1; i < last; i++) { + double px = in.getX(i); + double py = in.getY(i); + double sqDist = getSqSegDist(px, py, p1x, p1y, p2x, p2y); + + if (sqDist > maxSqDist) { + index = i; + maxSqDist = sqDist; } - // 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; - if (coords.size() == 0) { - return coords; + if (force || maxSqDist > sqTolerance) { + if (index - first > 1) { + subsimplify(in, out, first, index, numForcedPoints - 1); + } + out.forceAddPoint(in.getX(index), in.getY(index)); + if (last - index > 1) { + subsimplify(in, out, index, last, numForcedPoints - 2); } - // make sure we include the first and last points even if they are closer than the simplification threshold - MutableCoordinateSequence result = new MutableCoordinateSequence(); - result.forceAddPoint(coords.getX(0), coords.getY(0)); - // 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.forceAddPoint(coords.getX(coords.size() - 1), coords.getY(coords.size() - 1)); - return result; } + } - @Override - protected Geometry transformPolygon(Polygon geom, Geometry parent) { - return geom.isEmpty() ? null : super.transformPolygon(geom, parent); + protected List transformCoordinateList(List coords, boolean area) { + int minPoints = area ? 4 : 2; + if (coords.size() <= minPoints) { + 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, minPoints - 2); + result.add(coords.getLast()); + return result; + } - @Override - protected Geometry transformLinearRing(LinearRing geom, Geometry parent) { - boolean removeDegenerateRings = parent instanceof Polygon; - Geometry simpResult = super.transformLinearRing(geom, parent); - if (removeDegenerateRings && !(simpResult instanceof LinearRing)) { - return null; - } - return simpResult; + @Override + protected CoordinateSequence transformCoordinates(CoordinateSequence coords, Geometry parent) { + boolean area = parent instanceof LinearRing; + int minPoints = area ? 4 : 2; + if (coords.size() <= minPoints) { + return coords; + } + // make sure we include the first and last points even if they are closer than the simplification threshold + MutableCoordinateSequence result = new MutableCoordinateSequence(); + result.forceAddPoint(coords.getX(0), coords.getY(0)); + // 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, minPoints - 2); + result.forceAddPoint(coords.getX(coords.size() - 1), coords.getY(coords.size() - 1)); + return result; + } + + @Override + protected Geometry transformPolygon(Polygon geom, Geometry parent) { + return geom.isEmpty() ? null : super.transformPolygon(geom, parent); + } + + @Override + protected Geometry transformLinearRing(LinearRing geom, Geometry parent) { + boolean removeDegenerateRings = parent instanceof Polygon; + Geometry simpResult = super.transformLinearRing(geom, parent); + if (removeDegenerateRings && !(simpResult instanceof LinearRing)) { + return null; } + return simpResult; } } diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/geo/DualMidpointSmoother.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/geo/DualMidpointSmoother.java new file mode 100644 index 0000000000..8dd256b602 --- /dev/null +++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/geo/DualMidpointSmoother.java @@ -0,0 +1,221 @@ +package com.onthegomap.planetiler.geo; + +import org.locationtech.jts.geom.CoordinateSequence; +import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.geom.LinearRing; +import org.locationtech.jts.geom.util.GeometryTransformer; + +/** + * Smooths an input geometry by interpolating 2 points at certain ratios along each edge and repeating for a set number + * of iterations. This can be thought of as slicing off of each vertex until the segments are so short it appears round. + *

+ * Instead of iterating a fixed number of iterations, you can set {@link #setMinVertexArea(double)} or + * {@link #setMinVertexOffset(double)} to stop smoothing corners when the triangle formed by 3 consecutive vertices gets + * to small. + *

+ * In order to avoid introducing too much error from the original shape when rounding corners between very long edges, + * you can set {@link #setMaxVertexArea(double)} or {@link #setMaxVertexOffset(double)} to move the new points closer to + * a vertex to limit the amount of error that is introduced. + *

+ * When the points are {@code [0.25, 0.75]} this is equivalent to + * Chaikin Smoothing. + */ +public class DualMidpointSmoother extends GeometryTransformer implements GeometryPipeline { + + private final double a; + private final double b; + private int iters = 1; + private double minVertexArea = 0; + private double minSquaredVertexOffset = 0; + private double maxVertexArea = 0; + private double maxSquaredVertexOffset = 0; + + public DualMidpointSmoother(double a, double b) { + this.a = a; + this.b = b; + } + + /** + * Returns a new smoother that does Chaikin + * Smoothing {@code iters} times on the input line. + */ + public static DualMidpointSmoother chaikin(int iters) { + return new DualMidpointSmoother(0.25, 0.75).setIters(iters); + } + + /** + * Returns a new smoother that does Chaikin + * Smoothing but instead of stopping after a fixed number of iterations, it stops when the added points would get + * dropped with {@link DouglasPeuckerSimplifier Douglas-Peucker simplification} with {@code tolerance} threshold. + */ + public static DualMidpointSmoother chaikinToTolerance(double tolerance) { + return chaikin(10).setMinVertexOffset(tolerance); + } + + /** + * Returns a new smoother that does Chaikin + * Smoothing but instead of stopping after a fixed number of iterations, it stops when the added points would get + * dropped with {@link VWSimplifier Visvalingam-Whyatt simplification} with {@code minArea} threshold. + */ + public static DualMidpointSmoother chaikinToMinArea(double minArea) { + return chaikin(10).setMinVertexArea(minArea); + } + + /** + * Sets the min distance between a vertex and the line between its neighbors below which a vertex will not be + * squashed. + *

+ * If all points are below this threshold for an entire iteration then smoothing will stop before the maximum number + * of iterations is reached. + */ + public DualMidpointSmoother setMinVertexOffset(double minVertexOffset) { + this.minSquaredVertexOffset = minVertexOffset * Math.abs(minVertexOffset); + return this; + } + + /** + * Sets the min area of the triangle created by a point and its 2 neighbors below which a vertex will not be squashed. + *

+ * If all points are below this threshold for an entire iteration then smoothing will stop before the maximum number + * of iterations is reached. + */ + public DualMidpointSmoother setMinVertexArea(double minVertexArea) { + this.minVertexArea = minVertexArea; + return this; + } + + /** + * Sets a limit on the maximum area that can be removed when removing a vertex. When the area is larger than this, the + * new points are moved closer to the vertex in order to bring the removed area down to this threshold. + *

+ * This prevents smoothing 2 long adjacent edges from introducing a large deviation from the original shape. + */ + public DualMidpointSmoother setMaxVertexArea(double maxArea) { + this.maxVertexArea = maxArea; + return this; + } + + /** + * Sets a limit on the maximum distance from the original shape that can be introduced by smoothing. When the error + * introduced by squashing a vertex will be above this threshold, the new points are moved closer to the vertex in + * order to bring the distance down to this threshold. + *

+ * This prevents smoothing 2 long adjacent edges from introducing a large deviation from the original shape. + */ + public DualMidpointSmoother setMaxVertexOffset(double maxVertexOffset) { + this.maxSquaredVertexOffset = maxVertexOffset * Math.abs(maxVertexOffset); + return this; + } + + /** Sets the number of times that smoothing runs. */ + public DualMidpointSmoother setIters(int iters) { + this.iters = iters; + return this; + } + + @Override + public Geometry apply(Geometry input) { + return transform(input); + } + + @Override + protected CoordinateSequence transformCoordinates(CoordinateSequence coords, Geometry parent) { + boolean area = parent instanceof LinearRing; + if (coords.size() <= 2) { + return coords.copy(); + } + for (int iter = 0; iter < iters; iter++) { + assert iter < iters - 1 || (minSquaredVertexOffset == 0 && minVertexArea == 0) : "reached max iterations"; + MutableCoordinateSequence result = new MutableCoordinateSequence(); + int last = coords.size() - 1; + double x1, y1; + double x2 = coords.getX(0), y2 = coords.getY(0); + double x3 = coords.getX(1), y3 = coords.getY(1); + // for lines, always add the first point but for polygons this is a placeholder that will be updated + // when last vertex is squashed + result.addPoint(x2, y2); + for (int i = 1; i < last; i++) { + x1 = x2; + y1 = y2; + x2 = x3; + y2 = y3; + x3 = coords.getX(i + 1); + y3 = coords.getY(i + 1); + squashVertex(result, x1, y1, x2, y2, x3, y3); + } + if (area) { + squashVertex(result, + coords.getX(last - 1), + coords.getY(last - 1), + coords.getX(0), + coords.getY(0), + coords.getX(1), + coords.getY(1) + ); + int idx = result.size() - 1; + result.setX(0, result.getX(idx)); + result.setY(0, result.getY(idx)); + } else { + result.addPoint(coords.getX(last), coords.getY(last)); + } + // early exit case: if no vertices were collapsed then we are done smoothing + if (coords.size() == result.size()) { + return result; + } + coords = result; + } + return coords; + } + + private void squashVertex(MutableCoordinateSequence result, double x1, double y1, double x2, double y2, double x3, + double y3) { + + if (skipVertex(x1, y1, x2, y2, x3, y3)) { + result.addPoint(x2, y2); + return; + } + + double nextB = b; + double nextA = a; + + // check the amount of error introduced by removing this vertex (either by offset or area) + // and if it is too large, then move nextA/nextB ratios closer to the vertex to limit the error + if (maxVertexArea > 0 || maxSquaredVertexOffset > 0) { + double magA = Math.hypot(x2 - x1, y2 - y1); + double magB = Math.hypot(x3 - x2, y3 - y2); + double den = magA * magB; + double aDist = magA * (1 - b); + double bDist = magB * a; + double maxDistSquared = Double.POSITIVE_INFINITY; + if (maxVertexArea > 0) { + double sin = den <= 0 ? 0 : Math.abs(((x1 - x2) * (y3 - y2)) - ((y1 - y2) * (x3 - x2))) / den; + if (sin != 0) { + maxDistSquared = 2 * maxVertexArea / sin; + } + } + if (maxSquaredVertexOffset > 0) { + double cos = den <= 0 ? 0 : Math.clamp(((x1 - x2) * (x3 - x2) + (y1 - y2) * (y3 - y2)) / den, -1, 1); + maxDistSquared = Math.min(maxDistSquared, 2 * maxSquaredVertexOffset / (1 + cos)); + } + double maxDist = Double.NaN; + if (aDist * aDist > maxDistSquared) { + nextB = 1 - (maxDist = Math.sqrt(maxDistSquared)) / magA; + } + if (bDist * bDist > maxDistSquared) { + if (Double.isNaN(maxDist)) { + maxDist = Math.sqrt(maxDistSquared); + } + nextA = maxDist / magB; + } + } + + result.addPoint(x1 + (x2 - x1) * nextB, y1 + (y2 - y1) * nextB); + result.addPoint(x2 + (x3 - x2) * nextA, y2 + (y3 - y2) * nextA); + } + + private boolean skipVertex(double x1, double y1, double x2, double y2, double x3, double y3) { + return (minVertexArea > 0 && VWSimplifier.triangleArea(x1, y1, x2, y2, x3, y3) < minVertexArea) || + (minSquaredVertexOffset > 0 && + DouglasPeuckerSimplifier.getSqSegDist(x2, y2, x1, y1, x3, y3) < minSquaredVertexOffset); + } +} diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/geo/GeometryPipeline.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/geo/GeometryPipeline.java new file mode 100644 index 0000000000..f485458fd2 --- /dev/null +++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/geo/GeometryPipeline.java @@ -0,0 +1,136 @@ +package com.onthegomap.planetiler.geo; + +import com.onthegomap.planetiler.FeatureCollector; +import com.onthegomap.planetiler.util.ZoomFunction; +import org.locationtech.jts.geom.Geometry; + +/** + * A function that transforms a {@link Geometry}. + *

+ * This can be chained and used in {@link FeatureCollector.Feature#transformScaledGeometry(GeometryPipeline)} to + * transform geometries before slicing them into tiles. + */ +@FunctionalInterface +public interface GeometryPipeline extends ZoomFunction { + GeometryPipeline NOOP = g -> g; + + Geometry apply(Geometry input); + + @Override + default GeometryPipeline apply(int zoom) { + // allow GeometryPipeline to be used where ZoomFunction is expected + return this; + } + + /** Returns a function equivalent to {@code other(this(geom))}. */ + default GeometryPipeline andThen(GeometryPipeline other) { + return input -> other.apply(apply(input)); + } + + /** Returns a function equivalent to {@code b(a(geom))} at each zoom. */ + static ZoomFunction compose(ZoomFunction a, ZoomFunction b) { + return zoom -> ZoomFunction.applyOrElse(a, zoom, NOOP).andThen(ZoomFunction.applyOrElse(b, zoom, NOOP)); + } + + /** Returns a function that applies each argument sequentially to the input geometry at each zoom. */ + @SafeVarargs + static ZoomFunction compose( + ZoomFunction a, + ZoomFunction b, + ZoomFunction c, + ZoomFunction... rest) { + return zoom -> geom -> { + geom = ZoomFunction.applyOrElse(a, zoom, NOOP).apply(geom); + geom = ZoomFunction.applyOrElse(b, zoom, NOOP).apply(geom); + geom = ZoomFunction.applyOrElse(c, zoom, NOOP).apply(geom); + if (rest != null) { + for (var fn : rest) { + geom = ZoomFunction.applyOrElse(fn, zoom, NOOP).apply(geom); + } + } + return geom; + }; + } + + /** Returns a function equivalent to {@code b(a(geom))} at each zoom. */ + static ZoomFunction compose(ZoomFunction a, GeometryPipeline b) { + return zoom -> ZoomFunction.applyOrElse(a, zoom, NOOP).andThen(b); + } + + /** Returns a function equivalent to {@code b(a(geom))} at each zoom. */ + static ZoomFunction compose(GeometryPipeline a, ZoomFunction b) { + return zoom -> a.andThen(ZoomFunction.applyOrElse(b, zoom, NOOP)); + } + + /** Returns a function equivalent to {@code b(a(geom))} at each zoom. */ + static GeometryPipeline compose(GeometryPipeline a, GeometryPipeline b) { + return a.andThen(b); + } + + + /** + * Returns a geometry pipeline that applies the normal geometry simplification to {@code feature} that would happen + * without any geometry pipeline. + */ + static ZoomFunction defaultSimplify(FeatureCollector.Feature feature) { + return zoom -> { + double tolerance = feature.getPixelToleranceAtZoom(zoom) / 256d; + SimplifyMethod simplifyMethod = feature.getSimplifyMethodAtZoom(zoom); + return geom -> switch (simplifyMethod) { + case RETAIN_IMPORTANT_POINTS -> DouglasPeuckerSimplifier.simplify(geom, tolerance); + // DP tolerance is displacement, and VW tolerance is area, so square what the user entered to convert from + // DP to VW tolerance + case RETAIN_EFFECTIVE_AREAS -> new VWSimplifier().setTolerance(tolerance * tolerance).transform(geom); + case RETAIN_WEIGHTED_EFFECTIVE_AREAS -> + new VWSimplifier().setWeight(0.7).setTolerance(tolerance * tolerance).transform(geom); + }; + }; + } + + /** + * Returns a pipeline that simplifies input geometries using the {@link VWSimplifier Visvalingam Whyatt algorithm}. + */ + static VWSimplifier simplifyVW(double tolerance) { + return new VWSimplifier().setTolerance(tolerance); + } + + /** + * Returns a pipeline that simplifies input geometries using the {@link DouglasPeuckerSimplifier Douglas Peucker + * algorithm}. + */ + static DouglasPeuckerSimplifier simplifyDP(double tolerance) { + return new DouglasPeuckerSimplifier(tolerance); + } + + /** + * Returns a pipeline that smooths an input geometry by joining the midpoint of each edge of lines or polygons in the + * order in which they are encountered. + */ + static MidpointSmoother smoothMidpoint(int iters) { + return new MidpointSmoother().setIters(iters); + } + + /** + * Returns a pipeline that smooths an input geometry by slicing off each corner {@code iters} times until you get a + * sufficiently smooth curve. + */ + static DualMidpointSmoother smoothChaikin(int iters) { + return DualMidpointSmoother.chaikin(iters); + } + + /** + * Returns a new smoother that slices off each corner, except instead of stopping at a fixed number of iterations, it + * stops when the distance between a vertex and its 2 adjacent neighbors is below {@code tolerance}. + */ + static DualMidpointSmoother smoothChaikinToTolerance(double tolerance) { + return DualMidpointSmoother.chaikinToTolerance(tolerance); + } + + /** + * Returns a new smoother that slices off each corner, except instead of stopping at a fixed number of iterations, it + * stops when the area formed between a vertex and its 2 adjacent neighbors is below {@code minArea}. + */ + static DualMidpointSmoother smoothChaikinToMinArea(double minArea) { + return DualMidpointSmoother.chaikinToMinArea(minArea); + } +} diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/geo/MidpointSmoother.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/geo/MidpointSmoother.java new file mode 100644 index 0000000000..4f6580c56c --- /dev/null +++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/geo/MidpointSmoother.java @@ -0,0 +1,69 @@ +package com.onthegomap.planetiler.geo; + +import org.locationtech.jts.geom.CoordinateSequence; +import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.geom.LinearRing; +import org.locationtech.jts.geom.util.GeometryTransformer; + +/** + * Smooths an input geometry by iteratively joining the midpoint of each edge of lines or polygons in the order in which + * they are encountered. + * + * @see github.com/wipfli/midpoint-smoothing. + */ +public class MidpointSmoother extends GeometryTransformer implements GeometryPipeline { + + private final double ratio; + private int iters = 1; + + public MidpointSmoother(double ratio) { + this.ratio = ratio; + } + + public MidpointSmoother() { + this(0.5); + } + + /** Sets the number of times that smoothing runs. */ + public MidpointSmoother setIters(int iters) { + this.iters = iters; + return this; + } + + @Override + public Geometry apply(Geometry input) { + return transform(input); + } + + @Override + protected CoordinateSequence transformCoordinates(CoordinateSequence coords, Geometry parent) { + boolean area = parent instanceof LinearRing; + if (coords.size() <= 2) { + return coords.copy(); + } + for (int iter = 0; iter < iters; iter++) { + MutableCoordinateSequence result = new MutableCoordinateSequence(); + if (!area) { + result.addPoint(coords.getX(0), coords.getY(0)); + } + int last = coords.size() - 1; + double x2 = coords.getX(0); + double y2 = coords.getY(0); + double x1, y1; + for (int i = 0; i < last; i++) { + x1 = x2; + y1 = y2; + x2 = coords.getX(i + 1); + y2 = coords.getY(i + 1); + result.addPoint(x1 + (x2 - x1) * ratio, y1 + (y2 - y1) * ratio); + } + if (area) { + result.closeRing(); + } else { + result.addPoint(coords.getX(last), coords.getY(last)); + } + coords = result; + } + return coords; + } +} diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/geo/VWSimplifier.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/geo/VWSimplifier.java index 256d13f298..199d08cf17 100644 --- a/planetiler-core/src/main/java/com/onthegomap/planetiler/geo/VWSimplifier.java +++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/geo/VWSimplifier.java @@ -1,7 +1,6 @@ package com.onthegomap.planetiler.geo; import com.onthegomap.planetiler.collection.DoubleMinHeap; -import java.util.function.Function; import org.locationtech.jts.geom.CoordinateSequence; import org.locationtech.jts.geom.Geometry; import org.locationtech.jts.geom.LinearRing; @@ -12,10 +11,11 @@ * A utility to simplify geometries using Visvalingam Whyatt simplification algorithm without any attempt to repair * geometries that become invalid due to simplification. */ -public class VWSimplifier extends GeometryTransformer implements Function { +public class VWSimplifier extends GeometryTransformer implements GeometryPipeline { private double tolerance; private double k; + private boolean keepCollapsed = false; /** Sets the minimum effective triangle area created by 3 consecutive vertices in order to retain that vertex. */ public VWSimplifier setTolerance(double tolerance) { @@ -23,6 +23,12 @@ public VWSimplifier setTolerance(double tolerance) { return this; } + /** Set to {@code true} to keep polygons with area smaller than {@code tolerance}. */ + public VWSimplifier setKeepCollapsed(boolean keepCollapsed) { + this.keepCollapsed = keepCollapsed; + return this; + } + /** * Apply a penalty from {@code k=0} to {@code k=1} to drop more sharp corners from the resulting geometry. *

@@ -73,8 +79,9 @@ public double updateArea() { @Override protected CoordinateSequence transformCoordinates(CoordinateSequence coords, Geometry parent) { boolean area = parent instanceof LinearRing; + int minPoints = keepCollapsed && area ? 4 : 2; int num = coords.size(); - if (num == 0) { + if (num <= minPoints) { return coords; } @@ -96,13 +103,12 @@ protected CoordinateSequence transformCoordinates(CoordinateSequence coords, Geo heap.push(prev.idx, prev.updateArea()); int left = num; - int min = area ? 4 : 2; while (!heap.isEmpty()) { var id = heap.poll(); Vertex point = points[id]; - if (point.area > tolerance || left <= min) { + if (point.area > tolerance || left <= minPoints) { break; } // TODO @@ -147,7 +153,7 @@ protected Geometry transformLinearRing(LinearRing geom, Geometry parent) { return simpResult; } - private static double triangleArea(double ax, double ay, double bx, double by, double cx, double cy) { + public static double triangleArea(double ax, double ay, double bx, double by, double cx, double cy) { return Math.abs(((ay - cy) * (bx - cx) + (by - cy) * (cx - ax)) / 2); } diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/render/FeatureRenderer.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/render/FeatureRenderer.java index bf1acd0390..451b01afe9 100644 --- a/planetiler-core/src/main/java/com/onthegomap/planetiler/render/FeatureRenderer.java +++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/render/FeatureRenderer.java @@ -3,13 +3,11 @@ import com.onthegomap.planetiler.FeatureCollector; import com.onthegomap.planetiler.VectorTile; import com.onthegomap.planetiler.config.PlanetilerConfig; -import com.onthegomap.planetiler.geo.DouglasPeuckerSimplifier; import com.onthegomap.planetiler.geo.GeoUtils; import com.onthegomap.planetiler.geo.GeometryException; -import com.onthegomap.planetiler.geo.SimplifyMethod; +import com.onthegomap.planetiler.geo.GeometryPipeline; import com.onthegomap.planetiler.geo.TileCoord; import com.onthegomap.planetiler.geo.TileExtents; -import com.onthegomap.planetiler.geo.VWSimplifier; import com.onthegomap.planetiler.stats.Stats; import java.io.Closeable; import java.io.IOException; @@ -24,12 +22,14 @@ import org.locationtech.jts.geom.Geometry; import org.locationtech.jts.geom.GeometryCollection; import org.locationtech.jts.geom.LineString; +import org.locationtech.jts.geom.Lineal; import org.locationtech.jts.geom.MultiLineString; import org.locationtech.jts.geom.MultiPoint; import org.locationtech.jts.geom.MultiPolygon; import org.locationtech.jts.geom.Point; import org.locationtech.jts.geom.Polygon; import org.locationtech.jts.geom.Polygonal; +import org.locationtech.jts.geom.Puntal; import org.locationtech.jts.geom.util.AffineTransformation; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -39,6 +39,7 @@ * profile (like zoom range, min pixel size, output attributes and their zoom ranges). */ public class FeatureRenderer implements Consumer, Closeable { + private static final Logger LOGGER = LoggerFactory.getLogger(FeatureRenderer.class); private final PlanetilerConfig config; private final Consumer consumer; @@ -60,22 +61,68 @@ public FeatureRenderer(PlanetilerConfig config, Consumer consum @Override public void accept(FeatureCollector.Feature feature) { - renderGeometry(feature.getGeometry(), feature); + var geometry = feature.getGeometry(); + double simpleLineLength = + geometry instanceof Lineal && geometry.getNumGeometries() == 1 ? geometry.getLength() : -1; + if (geometry.isEmpty()) { + LOGGER.warn("Empty geometry {}", feature); + return; + } + // geometries are filtered by min size after processing before they are emitted, but do cheap pre-filtering here + // to avoid processing features that won't emit anything + for (int zoom = feature.getMaxZoom(); zoom >= feature.getMinZoom(); zoom--) { + double scale = 1 << zoom; + double minSize = feature.getMinPixelSizeAtZoom(zoom); + if (feature.hasLinearRanges()) { + double length = simpleLineLength * scale * 256; + for (var range : feature.getLinearRangesAtZoom(zoom)) { + if (minSize > 0 && length * (range.end() - range.start()) > minSize) { + accept(zoom, range.geom(), range.attrs(), feature); + } + } + } else { + if (minSize > 0) { + if (geometry instanceof Puntal) { + if (!feature.source().isPoint() && feature.getSourceFeaturePixelSizeAtZoom(zoom) < minSize) { + // don't emit points if the line or polygon feature it came from was too small + continue; + } + } else if (simpleLineLength >= 0 && simpleLineLength * scale * 256 < minSize) { + // skip processing lines that are too short + continue; + } + } + accept(zoom, geometry, feature.getAttrsAtZoom(zoom), feature); + } + } } - private void renderGeometry(Geometry geom, FeatureCollector.Feature feature) { - if (geom.isEmpty()) { - LOGGER.warn("Empty geometry {}", feature); + private void accept(int zoom, Geometry geom, Map attrs, FeatureCollector.Feature feature) { + double scale = 1 << zoom; + geom = AffineTransformation.scaleInstance(scale, scale).transform(geom); + GeometryPipeline pipeline = feature.getScaledGeometryTransformAtZoom(zoom); + if (pipeline != null) { + geom = pipeline.apply(geom); + } else if (!(geom instanceof Puntal)) { + geom = GeometryPipeline.defaultSimplify(feature).apply(zoom).apply(geom); + } + + renderGeometry(zoom, geom, attrs, feature); + } + + private void renderGeometry(int zoom, Geometry geom, Map attrs, FeatureCollector.Feature feature) { + if (geom == null || geom.isEmpty()) { + // skip this feature } else if (geom instanceof Point point) { - renderPoint(feature, point.getCoordinates()); + renderPoint(zoom, attrs, feature, point.getCoordinates()); } else if (geom instanceof MultiPoint points) { - renderPoint(feature, points); + renderPoint(zoom, attrs, feature, points); } else if (geom instanceof Polygon || geom instanceof MultiPolygon || geom instanceof LineString || geom instanceof MultiLineString) { - renderLineOrPolygon(feature, geom); + renderLineOrPolygon(zoom, attrs, feature, geom); } else if (geom instanceof GeometryCollection collection) { for (int i = 0; i < collection.getNumGeometries(); i++) { - renderGeometry(collection.getGeometryN(i), feature); + renderGeometry(zoom, collection.getGeometryN(i), attrs, feature); } } else { LOGGER.warn("Unrecognized JTS geometry type for {}: {}", feature.getClass().getSimpleName(), @@ -83,54 +130,37 @@ private void renderGeometry(Geometry geom, FeatureCollector.Feature feature) { } } - private void renderPoint(FeatureCollector.Feature feature, Coordinate... origCoords) { + private void renderPoint(int zoom, Map attrs, FeatureCollector.Feature feature, + Coordinate... coords) { boolean hasLabelGrid = feature.hasLabelGrid(); - Coordinate[] coords = new Coordinate[origCoords.length]; - for (int i = 0; i < origCoords.length; i++) { - coords[i] = origCoords[i].copy(); - } - for (int zoom = feature.getMaxZoom(); zoom >= feature.getMinZoom(); zoom--) { - double minSize = feature.getMinPixelSizeAtZoom(zoom); - if (minSize > 0 && feature.getSourceFeaturePixelSizeAtZoom(zoom) < minSize) { - continue; - } - Map attrs = feature.getAttrsAtZoom(zoom); - double buffer = feature.getBufferPixelsAtZoom(zoom) / 256; - int tilesAtZoom = 1 << zoom; - // scale coordinates for this zoom - for (int i = 0; i < coords.length; i++) { - var orig = origCoords[i]; - coords[i].setX(orig.x * tilesAtZoom); - coords[i].setY(orig.y * tilesAtZoom); - } - + double buffer = feature.getBufferPixelsAtZoom(zoom) / 256; + int tilesAtZoom = 1 << zoom; - // for "label grid" point density limiting, compute the grid square that this point sits in - // only valid if not a multipoint - RenderedFeature.Group groupInfo = null; - if (hasLabelGrid && coords.length == 1) { - double labelGridTileSize = feature.getPointLabelGridPixelSizeAtZoom(zoom) / 256d; - groupInfo = labelGridTileSize < 1d / 4096d ? null : new RenderedFeature.Group( - GeoUtils.labelGridId(tilesAtZoom, labelGridTileSize, coords[0]), - feature.getPointLabelGridLimitAtZoom(zoom) - ); - } + // for "label grid" point density limiting, compute the grid square that this point sits in + // only valid if not a multipoint + RenderedFeature.Group groupInfo = null; + if (hasLabelGrid && coords.length == 1) { + double labelGridTileSize = feature.getPointLabelGridPixelSizeAtZoom(zoom) / 256d; + groupInfo = labelGridTileSize < 1d / 4096d ? null : new RenderedFeature.Group( + GeoUtils.labelGridId(tilesAtZoom, labelGridTileSize, coords[0]), + feature.getPointLabelGridLimitAtZoom(zoom) + ); + } - // compute the tile coordinate of every tile these points should show up in at the given buffer size - TileExtents.ForZoom extents = config.bounds().tileExtents().getForZoom(zoom); - TiledGeometry tiled = TiledGeometry.slicePointsIntoTiles(extents, buffer, zoom, coords); - int emitted = 0; - for (var entry : tiled.getTileData().entrySet()) { - TileCoord tile = entry.getKey(); - List> result = entry.getValue(); - Geometry geom = GeometryCoordinateSequences.reassemblePoints(result); - encodeAndEmitFeature(feature, feature.getId(), attrs, tile, geom, groupInfo, 0); - emitted++; - } - stats.emittedFeatures(zoom, feature.getLayer(), emitted); + // compute the tile coordinate of every tile these points should show up in at the given buffer size + TileExtents.ForZoom extents = config.bounds().tileExtents().getForZoom(zoom); + TiledGeometry tiled = TiledGeometry.slicePointsIntoTiles(extents, buffer, zoom, coords); + int emitted = 0; + for (var entry : tiled.getTileData().entrySet()) { + TileCoord tile = entry.getKey(); + List> result = entry.getValue(); + Geometry geom = GeometryCoordinateSequences.reassemblePoints(result); + encodeAndEmitFeature(feature, feature.getId(), attrs, tile, geom, groupInfo, 0); + emitted++; } + stats.emittedFeatures(zoom, feature.getLayer(), emitted); - stats.processedElement("point", feature.getLayer()); + stats.processedElement("point", feature.getLayer(), zoom); } private void encodeAndEmitFeature(FeatureCollector.Feature feature, long id, Map attrs, @@ -149,7 +179,7 @@ private void encodeAndEmitFeature(FeatureCollector.Feature feature, long id, Map )); } - private void renderPoint(FeatureCollector.Feature feature, MultiPoint points) { + private void renderPoint(int zoom, Map attrs, FeatureCollector.Feature feature, MultiPoint points) { /* * Attempt to encode multipoints as a single feature sharing attributes and sort-key * but if it has label grid data then need to fall back to separate features per point, @@ -157,83 +187,50 @@ private void renderPoint(FeatureCollector.Feature feature, MultiPoint points) { */ if (feature.hasLabelGrid()) { for (Coordinate coord : points.getCoordinates()) { - renderPoint(feature, coord); + renderPoint(zoom, attrs, feature, coord); } } else { - renderPoint(feature, points.getCoordinates()); + renderPoint(zoom, attrs, feature, points.getCoordinates()); } } - private void renderLineOrPolygon(FeatureCollector.Feature feature, Geometry input) { - boolean area = input instanceof Polygonal; - double worldLength = (area || input.getNumGeometries() > 1) ? 0 : input.getLength(); - for (int z = feature.getMaxZoom(); z >= feature.getMinZoom(); z--) { - double scale = 1 << z; - double minSize = feature.getMinPixelSizeAtZoom(z) / 256d; - if (area) { - // treat minPixelSize as the edge of a square that defines minimum area for features - minSize *= minSize; - } else if (worldLength > 0 && worldLength * scale < minSize) { - // skip linestring, too short - continue; - } - - if (feature.hasLinearRanges()) { - for (var range : feature.getLinearRangesAtZoom(z)) { - if (worldLength * scale * (range.end() - range.start()) >= minSize) { - renderLineOrPolygonGeometry(feature, range.geom(), range.attrs(), z, minSize, area); - } - } - } else { - renderLineOrPolygonGeometry(feature, input, feature.getAttrsAtZoom(z), z, minSize, area); - } + private void renderLineOrPolygon(int zoom, Map attrs, FeatureCollector.Feature feature, + Geometry geom) { + boolean finished = false; + boolean area = geom instanceof Polygonal; + double minSize = feature.getMinPixelSizeAtZoom(zoom) / 256d; + double buffer = feature.getBufferPixelsAtZoom(zoom) / 256; + if (area) { + // treat minPixelSize as the edge of a square that defines minimum area for features + minSize *= minSize; } - - stats.processedElement(area ? "polygon" : "line", feature.getLayer()); - } - - private void renderLineOrPolygonGeometry(FeatureCollector.Feature feature, Geometry input, Map attrs, - int z, double minSize, boolean area) { - double scale = 1 << z; - double tolerance = feature.getPixelToleranceAtZoom(z) / 256d; - SimplifyMethod simplifyMethod = feature.getSimplifyMethodAtZoom(z); - double buffer = feature.getBufferPixelsAtZoom(z) / 256; - TileExtents.ForZoom extents = config.bounds().tileExtents().getForZoom(z); - - // TODO potential optimization: iteratively simplify z+1 to get z instead of starting with original geom each time - // simplify only takes 4-5 minutes of wall time when generating the planet though, so not a big deal - Geometry scaled = AffineTransformation.scaleInstance(scale, scale).transform(input); - TiledGeometry sliced; - // TODO replace with geometry pipeline when available - Geometry geom = switch (simplifyMethod) { - case RETAIN_IMPORTANT_POINTS -> DouglasPeuckerSimplifier.simplify(scaled, tolerance); - // DP tolerance is displacement, and VW tolerance is area, so square what the user entered to convert from - // DP to VW tolerance - case RETAIN_EFFECTIVE_AREAS -> new VWSimplifier().setTolerance(tolerance * tolerance).transform(scaled); - case RETAIN_WEIGHTED_EFFECTIVE_AREAS -> - new VWSimplifier().setWeight(0.7).setTolerance(tolerance * tolerance).transform(scaled); - }; + TileExtents.ForZoom extents = config.bounds().tileExtents().getForZoom(zoom); + TiledGeometry sliced = null; List> groups = GeometryCoordinateSequences.extractGroups(geom, minSize); try { - sliced = TiledGeometry.sliceIntoTiles(groups, buffer, area, z, extents); + sliced = TiledGeometry.sliceIntoTiles(groups, buffer, area, zoom, extents); } catch (GeometryException e) { try { geom = GeoUtils.fixPolygon(geom); groups = GeometryCoordinateSequences.extractGroups(geom, minSize); - sliced = TiledGeometry.sliceIntoTiles(groups, buffer, area, z, extents); + sliced = TiledGeometry.sliceIntoTiles(groups, buffer, area, zoom, extents); } catch (GeometryException ex) { - ex.log(stats, "slice_line_or_polygon", "Error slicing feature at z" + z + ": " + feature); + ex.log(stats, "slice_line_or_polygon", "Error slicing feature at z" + zoom + ": " + feature); // omit from this zoom level, but maybe the next will be better - return; + finished = true; } } - String numPointsAttr = feature.getNumPointsAttr(); - if (numPointsAttr != null) { - // if profile wants the original number off points that the simplified but untiled geometry started with - attrs = new HashMap<>(attrs); - attrs.put(numPointsAttr, geom.getNumPoints()); + if (!finished) { + String numPointsAttr = feature.getNumPointsAttr(); + if (numPointsAttr != null) { + // if profile wants the original number off points that the simplified but untiled geometry started with + attrs = new HashMap<>(attrs); + attrs.put(numPointsAttr, geom.getNumPoints()); + } + writeTileFeatures(zoom, feature.getId(), feature, sliced, attrs); } - writeTileFeatures(z, feature.getId(), feature, sliced, attrs); + + stats.processedElement(area ? "polygon" : "line", feature.getLayer(), zoom); } private void writeTileFeatures(int zoom, long id, FeatureCollector.Feature feature, TiledGeometry sliced, diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/stats/PrometheusStats.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/stats/PrometheusStats.java index ecbff23098..16ec5575e3 100644 --- a/planetiler-core/src/main/java/com/onthegomap/planetiler/stats/PrometheusStats.java +++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/stats/PrometheusStats.java @@ -114,12 +114,12 @@ public List collect() { private final io.prometheus.client.Counter processedElements = io.prometheus.client.Counter .build(BASE + "renderer_elements_processed", "Number of source elements processed") - .labelNames("type", "layer") + .labelNames("type", "layer", "zoom") .register(registry); @Override - public void processedElement(String elemType, String layer) { - processedElements.labels(elemType, layer).inc(); + public void processedElement(String elemType, String layer, int zoom) { + processedElements.labels(elemType, layer, String.valueOf(zoom)).inc(); } private final io.prometheus.client.Counter dataErrors = io.prometheus.client.Counter diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/stats/Stats.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/stats/Stats.java index 4570c0a832..408015f183 100644 --- a/planetiler-core/src/main/java/com/onthegomap/planetiler/stats/Stats.java +++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/stats/Stats.java @@ -114,7 +114,7 @@ public ProcessTime elapsed() { void emittedFeatures(int z, String layer, int numFeatures); /** Records that an input element was processed and emitted some output features in {@code layer}. */ - void processedElement(String elemType, String layer); + void processedElement(String elemType, String layer, int zoom); /** Records that a tile has been written to the archive output where compressed size is {@code bytes}. */ void wroteTile(int zoom, int bytes); @@ -239,7 +239,7 @@ public Map dataErrors() { } @Override - public void processedElement(String elemType, String layer) {} + public void processedElement(String elemType, String layer, int zoom) {} @Override public void gauge(String name, Supplier value) {} 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 index 865c96de04..a80cf1a5a5 100644 --- a/planetiler-core/src/main/java/com/onthegomap/planetiler/util/LoopLineMerger.java +++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/util/LoopLineMerger.java @@ -2,6 +2,7 @@ import com.onthegomap.planetiler.geo.DouglasPeuckerSimplifier; import com.onthegomap.planetiler.geo.GeoUtils; +import com.onthegomap.planetiler.geo.GeometryPipeline; import java.util.ArrayList; import java.util.Comparator; import java.util.HashMap; @@ -48,6 +49,7 @@ public class LoopLineMerger { private double stubMinLength = 0.0; private double tolerance = -1.0; private boolean mergeStrokes = false; + private GeometryPipeline pipeline; /** * Sets the precision model used to snap points to a grid. @@ -105,6 +107,16 @@ public LoopLineMerger setTolerance(double tolerance) { return this; } + /** + * Sets a function that should be applied to each segment between intersections instead of simplification. + *

+ * When this is present, {@code tolerance} is ignored. + */ + public LoopLineMerger setSegmentTransform(GeometryPipeline pipeline) { + this.pipeline = pipeline; + 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. @@ -388,7 +400,7 @@ public List getMergedLineStrings() { // removeShortStubEdges does degreeTwoMerge internally } - if (tolerance >= 0.0) { + if (pipeline != null || tolerance >= 0.0) { simplify(); removeDuplicatedEdges(); degreeTwoMerge(); @@ -585,7 +597,13 @@ void remove() { } void simplify() { - coordinates = DouglasPeuckerSimplifier.simplify(coordinates, tolerance, false); + if (pipeline != null) { + coordinates = List.of( + pipeline.apply(GeoUtils.JTS_FACTORY.createLineString(coordinates.toArray(Coordinate[]::new))) + .getCoordinates()); + } else if (tolerance >= 0) { + coordinates = DouglasPeuckerSimplifier.simplify(coordinates, tolerance, false); + } if (reversed != null) { reversed.coordinates = coordinates.reversed(); } diff --git a/planetiler-core/src/test/java/com/onthegomap/planetiler/FeatureMergeTest.java b/planetiler-core/src/test/java/com/onthegomap/planetiler/FeatureMergeTest.java index e27feda284..736cfba854 100644 --- a/planetiler-core/src/test/java/com/onthegomap/planetiler/FeatureMergeTest.java +++ b/planetiler-core/src/test/java/com/onthegomap/planetiler/FeatureMergeTest.java @@ -8,6 +8,7 @@ import com.carrotsearch.hppc.IntObjectMap; import com.onthegomap.planetiler.collection.Hppc; import com.onthegomap.planetiler.geo.GeometryException; +import com.onthegomap.planetiler.geo.GeometryPipeline; import com.onthegomap.planetiler.geo.GeometryType; import com.onthegomap.planetiler.mbtiles.Mbtiles; import com.onthegomap.planetiler.stats.Stats; @@ -373,6 +374,73 @@ void mergeConnectedPolygonsWithSameAttrs() throws GeometryException { ); } + @Test + void geometryPipelineWhenMergingOverlappingPolygons() throws GeometryException { + List features = List.of( + feature(1, rectangle(10, 10, 20, 19), Map.of("a", 1)), + feature(2, rectangle(11, 10, 20, 20), Map.of("a", 1)) + ); + assertEquivalentFeatures( + List.of( + feature(1, newPolygon( + 10, 10, + 20, 10, + 20, 20, + 11, 20, + // remove this point due to simplification: 11, 19, + 10, 19, + 10, 10 + ), Map.of("a", 1)) + ), + FeatureMerge.mergeNearbyPolygons( + features, + 0, + 0, + 0, + 1, + Stats.inMemory(), + GeometryPipeline.simplifyVW(1) + ) + ); + } + + @Test + void geometryPipelineAppliedWhenMergingSinglePolygon() throws GeometryException { + List features = List.of( + feature(1, newPolygon( + 10, 10, + 20, 10, + 20, 20, + 11, 20, + 11, 19, + 10, 19, + 10, 10), Map.of("a", 1)) + ); + assertEquivalentFeatures( + List.of( + feature(1, newPolygon( + 10, 10, + 20, 10, + 20, 20, + 11, 20, + // remove this point due to simplification: 11, 19, + 10, 19, + 10, 10 + ), Map.of("a", 1)) + ), + FeatureMerge.mergeNearbyPolygons( + features, + 0, + 0, + 0, + 1, + Stats.inMemory(), + GeometryPipeline.simplifyVW(1) + ) + ); + } + + @Test void mergeMultiPolygons() throws GeometryException { assertEquivalentFeatures( 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 857a00bc04..4bde0f58ec 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 @@ -1,9 +1,6 @@ package com.onthegomap.planetiler.geo; -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 com.onthegomap.planetiler.TestUtils.*; import static org.junit.jupiter.api.Assertions.assertEquals; import java.util.List; @@ -35,6 +32,11 @@ private void testSimplify(Geometry in, Geometry expected, double amount) { } } + @Test + void testDontModifyPoint() { + testSimplify(newPoint(1, 1), newPoint(1, 1), 1); + } + @Test void testSimplify2Points() { testSimplify(newLineString( diff --git a/planetiler-core/src/test/java/com/onthegomap/planetiler/geo/DualMidpointSmootherTest.java b/planetiler-core/src/test/java/com/onthegomap/planetiler/geo/DualMidpointSmootherTest.java new file mode 100644 index 0000000000..df954f7c62 --- /dev/null +++ b/planetiler-core/src/test/java/com/onthegomap/planetiler/geo/DualMidpointSmootherTest.java @@ -0,0 +1,119 @@ +package com.onthegomap.planetiler.geo; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.onthegomap.planetiler.TestUtils; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.io.ParseException; +import org.locationtech.jts.io.WKTReader; + +class DualDualMidpointSmootherTest { + + @ParameterizedTest + @CsvSource(value = { + "POINT(1 1); POINT(1 1)", + "LINESTRING(0 0, 10 10); LINESTRING(0 0, 10 10)", + "LINESTRING(0 0, 10 0, 10 10); LINESTRING(0 0, 8 0, 10 2, 10 10)", + "LINESTRING(0 0, 10 0, 10 10, 0 10); LINESTRING(0 0, 8 0, 10 2, 10 8, 8 10, 0 10)", + "POLYGON((0 0, 10 0, 10 10, 0 0)); POLYGON((2 0, 8 0, 10 2, 10 8, 8 8, 2 2, 2 0))", + "POLYGON((0 0, 10 0, 10 10, 0 10, 0 0)); POLYGON((2 0, 8 0, 10 2, 10 8, 8 10, 2 10, 0 8, 0 2, 2 0))", + }, delimiter = ';') + void testDualMidpointSmooth(String inWKT, String outWKT) throws ParseException { + var reader = new WKTReader(); + Geometry in = reader.read(inWKT); + Geometry out = reader.read(outWKT); + assertEquals( + TestUtils.round(out), + TestUtils.round(new DualMidpointSmoother(0.2, 0.8).setIters(1).apply(in)) + ); + } + + @ParameterizedTest + @CsvSource(value = { + "POINT(1 1); POINT(1 1)", + "LINESTRING(0 0, 10 10); LINESTRING(0 0, 10 10)", + "LINESTRING(0 0, 10 0, 10 10); LINESTRING(0 0, 6.4 0, 8.4 0.4, 9.6 1.6, 10 3.6, 10 10)", + "POLYGON((0 0, 10 0, 10 10, 0 0)); POLYGON((3.2 0, 6.8 0, 8.4 0.4, 9.6 1.6, 10 3.2, 10 6.8, 9.6 8, 8.4 8, 6.8 6.8, 3.2 3.2, 2 1.6, 2 0.4, 3.2 0))", + }, delimiter = ';') + void testMultiPassSmooth(String inWKT, String outWKT) throws ParseException { + var reader = new WKTReader(); + Geometry in = reader.read(inWKT); + Geometry out = reader.read(outWKT); + assertEquals( + TestUtils.round(out), + TestUtils.round(new DualMidpointSmoother(0.2, 0.8).setIters(2).apply(in)) + ); + } + + @ParameterizedTest + @CsvSource(value = { + "POINT(1 1); POINT(1 1)", + "LINESTRING(0 0, 10 10); LINESTRING(0 0, 10 10)", + "LINESTRING(0 0, 10 0, 10 10); LINESTRING (0 0, 6.4 0, 8 0.32, 8.64 0.64, 9.36 1.36, 9.68 2, 10 3.6, 10 10)", + "POLYGON((0 0, 10 0, 10 10, 0 0)); POLYGON ((3.2 0, 6.8 0, 8.4 0.4, 9.6 1.6, 10 3.2, 10 6.8, 9.68 7.76, 9.36 8, 8.4 8, 6.8 6.8, 3.2 3.2, 2 1.6, 2 0.64, 2.24 0.32, 3.2 0))", + }, delimiter = ';') + void testSmoothToTolerance(String inWKT, String outWKT) throws ParseException { + var reader = new WKTReader(); + Geometry in = reader.read(inWKT); + Geometry out = reader.read(outWKT); + assertEquals( + TestUtils.round(out), + TestUtils.round(new DualMidpointSmoother(0.2, 0.8).setIters(200).setMinVertexOffset(0.5).apply(in)) + ); + } + + @ParameterizedTest + @CsvSource(value = { + "POINT(1 1); POINT(1 1)", + "LINESTRING(0 0, 10 10); LINESTRING(0 0, 10 10)", + "LINESTRING(0 0, 10 0, 10 10); LINESTRING (0 0, 5.12 0, 6.8 0.08, 8 0.32, 8.64 0.64, 9.36 1.36, 9.68 2, 9.92 3.2, 10 4.88, 10 10)", + "POLYGON((0 0, 10 0, 10 10, 0 0)); POLYGON ((3.92 0, 6.08 0, 7.12 0.08, 8.08 0.32, 8.64 0.64, 9.36 1.36, 9.68 1.92, 9.92 2.88, 10 3.92, 10 6.08, 9.92 7.04, 9.68 7.76, 9.36 8, 8.64 8, 8.08 7.76, 7.12 7.04, 6.08 6.08, 3.92 3.92, 2.96 2.88, 2.24 1.92, 2 1.36, 2 0.64, 2.24 0.32, 2.96 0.08, 3.92 0))", + }, delimiter = ';') + void testSmoothToMinArea(String inWKT, String outWKT) throws ParseException { + var reader = new WKTReader(); + Geometry in = reader.read(inWKT); + Geometry out = reader.read(outWKT); + assertEquals( + TestUtils.round(out), + TestUtils.round(new DualMidpointSmoother(0.2, 0.8).setIters(200).setMinVertexArea(0.5).apply(in)) + ); + } + + @ParameterizedTest + @CsvSource(value = { + "LINESTRING(0 0, 10 10); LINESTRING(0 0, 10 10)", + "LINESTRING(0 0, 10 0, 10 10); LINESTRING (0 0, 9 0, 10 1, 10 10)", + "LINESTRING(0 0, 10 0, 20 0); LINESTRING (0 0, 7.5 0, 12.5 0, 20 0)", + "LINESTRING(0 0, 10 0, 0 0); LINESTRING (0 0, 7.5 0, 0 0)", + "POLYGON((0 0, 10 0, 10 10, 0 10, 0 0)); POLYGON ((1 0, 9 0, 10 1, 10 9, 9 10, 1 10, 0 9, 0 1, 1 0))", + }, delimiter = ';') + void testSmoothWithMaxArea(String inWKT, String outWKT) throws ParseException { + var reader = new WKTReader(); + Geometry in = reader.read(inWKT); + Geometry out = reader.read(outWKT); + assertEquals( + TestUtils.round(out), + TestUtils.round(DualMidpointSmoother.chaikin(1).setMaxVertexArea(0.5).apply(in)) + ); + } + + @ParameterizedTest + @CsvSource(value = { + "LINESTRING(0 0, 10 10); LINESTRING(0 0, 10 10)", + "LINESTRING(0 0, 10 0, 10 10); LINESTRING (0 0, 9 0, 10 1, 10 10)", + "LINESTRING(0 0, 10 0, 20 0); LINESTRING (0 0, 7.5 0, 12.5 0, 20 0)", + "LINESTRING(0 0, 10 0, 10 5); LINESTRING (0 0, 9 0, 10 1, 10 5)", + "POLYGON((0 0, 10 0, 10 10, 0 10, 0 0)); POLYGON ((1 0, 9 0, 10 1, 10 9, 9 10, 1 10, 0 9, 0 1, 1 0))", + }, delimiter = ';') + void testSmoothWithMaxOffset(String inWKT, String outWKT) throws ParseException { + var reader = new WKTReader(); + Geometry in = reader.read(inWKT); + Geometry out = reader.read(outWKT); + assertEquals( + TestUtils.round(out), + TestUtils.round(DualMidpointSmoother.chaikin(1).setMaxVertexOffset(Math.sqrt(0.5)).apply(in)) + ); + } +} diff --git a/planetiler-core/src/test/java/com/onthegomap/planetiler/geo/GeometryPipelineTest.java b/planetiler-core/src/test/java/com/onthegomap/planetiler/geo/GeometryPipelineTest.java new file mode 100644 index 0000000000..33e94d203c --- /dev/null +++ b/planetiler-core/src/test/java/com/onthegomap/planetiler/geo/GeometryPipelineTest.java @@ -0,0 +1,56 @@ +package com.onthegomap.planetiler.geo; + +import static com.onthegomap.planetiler.geo.GeometryPipeline.compose; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.onthegomap.planetiler.util.ZoomFunction; +import org.junit.jupiter.api.Test; +import org.locationtech.jts.geom.CoordinateXY; +import org.locationtech.jts.geom.Point; +import org.locationtech.jts.geom.util.AffineTransformation; + +class GeometryPipelineTest { + GeometryPipeline move(int i) { + return AffineTransformation.translationInstance(i, i)::transform; + } + + private final ZoomFunction moveByZoom = this::move; + + Point point(int i) { + return GeoUtils.JTS_FACTORY.createPoint(new CoordinateXY(i, i)); + } + + @Test + void testSingle() { + assertEquals(point(2), move(1).apply(point(1))); + assertEquals(point(2), move(1).apply(3).apply(point(1))); + } + + @Test + void testSingleByZoom() { + assertEquals(point(4), moveByZoom.apply(3).apply(point(1))); + } + + @Test + void testCompose2() { + // pipeline, pipeline + assertEquals(point(4), compose(move(1), move(2)).apply(point(1))); + assertEquals(point(4), compose(move(1), move(2)).apply(2).apply(point(1))); + + // pipeline, ZoomFunction + assertEquals(point(5), compose(move(1), moveByZoom).apply(3).apply(point(1))); + + // ZoomFunction, pipeline + assertEquals(point(5), compose(moveByZoom, move(1)).apply(3).apply(point(1))); + + // ZoomFunction, ZoomFunction + assertEquals(point(7), compose(moveByZoom, moveByZoom).apply(3).apply(point(1))); + } + + @Test + void testComposeMany() { + assertEquals(point(8), compose(move(1), moveByZoom, moveByZoom).apply(3).apply(point(1))); + assertEquals(point(9), compose(move(1), moveByZoom, moveByZoom, move(1)).apply(3).apply(point(1))); + assertEquals(point(12), compose(move(1), moveByZoom, moveByZoom, move(1), moveByZoom).apply(3).apply(point(1))); + } +} diff --git a/planetiler-core/src/test/java/com/onthegomap/planetiler/geo/MidpointSmootherTest.java b/planetiler-core/src/test/java/com/onthegomap/planetiler/geo/MidpointSmootherTest.java new file mode 100644 index 0000000000..a7271c680c --- /dev/null +++ b/planetiler-core/src/test/java/com/onthegomap/planetiler/geo/MidpointSmootherTest.java @@ -0,0 +1,50 @@ +package com.onthegomap.planetiler.geo; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.onthegomap.planetiler.TestUtils; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.io.ParseException; +import org.locationtech.jts.io.WKTReader; + +class MidpointSmootherTest { + + @ParameterizedTest + @CsvSource(value = { + "POINT(1 1); POINT(1 1)", + "LINESTRING(0 0, 10 10); LINESTRING(0 0, 10 10)", + "LINESTRING(0 0, 10 0, 10 10); LINESTRING(0 0, 5 0, 10 5, 10 10)", + "LINESTRING(0 0, 10 0, 10 10, 0 10); LINESTRING(0 0, 5 0, 10 5, 5 10, 0 10)", + "POLYGON((0 0, 10 0, 10 10, 0 0)); POLYGON((5 0, 10 5, 5 5, 5 0))", + "POLYGON((0 0, 10 0, 10 10, 0 10, 0 0)); POLYGON((5 0, 10 5, 5 10, 0 5, 5 0))", + }, delimiter = ';') + void testMidpointSmooth(String inWKT, String outWKT) throws ParseException { + var reader = new WKTReader(); + Geometry in = reader.read(inWKT); + Geometry out = reader.read(outWKT); + assertEquals( + TestUtils.round(out), + TestUtils.round(new MidpointSmoother().setIters(1).apply(in)) + ); + } + + @ParameterizedTest + @CsvSource(value = { + "POINT(1 1); POINT(1 1)", + "LINESTRING(0 0, 10 10); LINESTRING(0 0, 10 10)", + "LINESTRING(0 0, 10 0, 10 10); LINESTRING(0 0, 2.5 0, 7.5 2.5, 10 7.5, 10 10)", + "POLYGON((0 0, 10 0, 10 10, 0 0)); POLYGON ((7.5 2.5, 7.5 5, 5 2.5, 7.5 2.5))", + "POLYGON((0 0, 10 0, 10 10, 0 10, 0 0)); POLYGON ((7.5 2.5, 7.5 7.5, 2.5 7.5, 2.5 2.5, 7.5 2.5))", + }, delimiter = ';') + void testMidpointSmoothTwice(String inWKT, String outWKT) throws ParseException { + var reader = new WKTReader(); + Geometry in = reader.read(inWKT); + Geometry out = reader.read(outWKT); + assertEquals( + TestUtils.round(out), + TestUtils.round(new MidpointSmoother().setIters(2).apply(in)) + ); + } +} diff --git a/planetiler-core/src/test/java/com/onthegomap/planetiler/geo/VWSimplifierTest.java b/planetiler-core/src/test/java/com/onthegomap/planetiler/geo/VWSimplifierTest.java index d810c1f615..d4a521f543 100644 --- a/planetiler-core/src/test/java/com/onthegomap/planetiler/geo/VWSimplifierTest.java +++ b/planetiler-core/src/test/java/com/onthegomap/planetiler/geo/VWSimplifierTest.java @@ -1,8 +1,6 @@ package com.onthegomap.planetiler.geo; -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.*; import org.junit.jupiter.api.Test; import org.locationtech.jts.geom.Geometry; @@ -26,6 +24,11 @@ private void testSimplify(Geometry in, Geometry expected, double amount) { } } + @Test + void testSimplifyPoint() { + testSimplify(newPoint(1, 1), newPoint(1, 1), 1); + } + @Test void testSimplify2Points() { testSimplify(newLineString( @@ -63,22 +66,26 @@ void testKeepAPoint() { } @Test - void testPolygonLeaveAPoint() { - testSimplify( - newPolygon( - 0, 0, - 10, 10, - 9, 10, - 0, 8, - 0, 0 - ), + void testPolygonLeaveAPointWhenAreaLessThenTolerance() { + Geometry in = newPolygon( + 0, 0, + 10, 10, + 9, 10, + 0, 8, + 0, 0 + ); + assertSameNormalizedFeature( + emptyGeometry(), + new VWSimplifier().setTolerance(200).setWeight(0).transform(in) + ); + assertSameNormalizedFeature( newPolygon( 0, 0, 0, 8, 10, 10, 0, 0 ), - 200 + new VWSimplifier().setTolerance(200).setKeepCollapsed(true).setWeight(0).transform(in) ); } diff --git a/planetiler-core/src/test/java/com/onthegomap/planetiler/render/FeatureRendererTest.java b/planetiler-core/src/test/java/com/onthegomap/planetiler/render/FeatureRendererTest.java index a1a3dab4dd..834991f27e 100644 --- a/planetiler-core/src/test/java/com/onthegomap/planetiler/render/FeatureRendererTest.java +++ b/planetiler-core/src/test/java/com/onthegomap/planetiler/render/FeatureRendererTest.java @@ -13,6 +13,7 @@ import com.onthegomap.planetiler.config.PlanetilerConfig; import com.onthegomap.planetiler.geo.GeoUtils; import com.onthegomap.planetiler.geo.GeometryException; +import com.onthegomap.planetiler.geo.GeometryPipeline; import com.onthegomap.planetiler.geo.TileCoord; import com.onthegomap.planetiler.reader.SimpleFeature; import com.onthegomap.planetiler.stats.Stats; @@ -71,6 +72,19 @@ private Map> renderFeatures(FeatureCollec return result; } + private Set> renderedTileFeatures(FeatureCollector.Feature feature, TileCoord coord) { + return renderFeatures(feature).get(coord).stream() + .map(RenderedFeature::vectorTileFeature) + .map(d -> { + try { + return List.of(d.geometry().decode(), d.tags()); + } catch (GeometryException e) { + throw new RuntimeException(e); + } + }) + .collect(Collectors.toSet()); + } + private static final int Z14_TILES = 1 << 14; private static final double Z14_WIDTH = 1d / Z14_TILES; private static final double Z14_PX = Z14_WIDTH / 256; @@ -1488,4 +1502,39 @@ void testLinearRangeFeaturePartialMinzoom(boolean viaMinzoom) { .collect(Collectors.toSet()) ); } + + @Test + void testGeometryPipeline() { + var feature = lineFeature( + newLineString( + 0.5 + Z14_WIDTH / 2, 0.5 + Z14_WIDTH / 2, + 0.5 + Z14_WIDTH / 2 + Z14_PX * 10, 0.5 + Z14_WIDTH / 2 + ) + ).transformScaledGeometry(Geometry::getCentroid).setAttr("k", "v"); + assertEquals( + Set.of( + List.of(newPoint(128 + 5, 128), Map.of("k", "v")) + ), + renderedTileFeatures(feature, TileCoord.ofXYZ(Z14_TILES / 2, Z14_TILES / 2, 14)) + ); + } + + @Test + void testGeometryPipelineSimplify() { + var feature = lineFeature( + newLineString( + 0.5 + Z14_WIDTH / 2, 0.5 + Z14_WIDTH / 2, + 0.5 + Z14_WIDTH / 2 + Z14_PX * 10, 0.5 + Z14_WIDTH / 2 + ) + ).transformScaledGeometry( + GeometryPipeline.simplifyVW(1).setWeight(0.9) + .andThen(GeometryPipeline.simplifyDP(1)) + ).setAttr("k", "v"); + assertEquals( + Set.of( + List.of(newLineString(128, 128, 128 + 10, 128), Map.of("k", "v")) + ), + renderedTileFeatures(feature, TileCoord.ofXYZ(Z14_TILES / 2, Z14_TILES / 2, 14)) + ); + } } diff --git a/planetiler-core/src/test/java/com/onthegomap/planetiler/stats/PrometheusStatsTest.java b/planetiler-core/src/test/java/com/onthegomap/planetiler/stats/PrometheusStatsTest.java index f5fce52a84..3e0bbe70a6 100644 --- a/planetiler-core/src/test/java/com/onthegomap/planetiler/stats/PrometheusStatsTest.java +++ b/planetiler-core/src/test/java/com/onthegomap/planetiler/stats/PrometheusStatsTest.java @@ -59,9 +59,9 @@ void testGauge() { @Test void testProcessedElement() { PrometheusStats stats = new PrometheusStats("job"); - stats.processedElement("type1", "layer1"); - stats.processedElement("type1", "layer1"); - stats.processedElement("type1", "layer2"); + stats.processedElement("type1", "layer1", 0); + stats.processedElement("type1", "layer1", 0); + stats.processedElement("type1", "layer2", 0); assertContainsStat("^planetiler_renderer_elements_processed_total\\{.*layer1.* 2", stats); assertContainsStat("^planetiler_renderer_elements_processed_total\\{.*layer2.* 1", stats); } 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 index 59fa2e2215..30670fc4f8 100644 --- a/planetiler-core/src/test/java/com/onthegomap/planetiler/util/LoopLineMergerTest.java +++ b/planetiler-core/src/test/java/com/onthegomap/planetiler/util/LoopLineMergerTest.java @@ -6,6 +6,7 @@ import com.onthegomap.planetiler.TestUtils; import com.onthegomap.planetiler.geo.GeoUtils; +import com.onthegomap.planetiler.geo.GeometryPipeline; import java.io.IOException; import java.nio.file.Files; import java.util.Arrays; @@ -470,6 +471,34 @@ void testMergeStrokesAt3WayIntersection() { ); } + @Test + void testSimplifyTolerance() { + var merger = new LoopLineMerger() + .setTolerance(1); + + merger.add(newLineString(0, 0, 5, 1)); + merger.add(newLineString(5, 1, 10, 0)); + + assertEquals( + List.of(newLineString(0, 0, 10, 0)), + merger.getMergedLineStrings() + ); + } + + @Test + void testGeometryPipeline() { + var merger = new LoopLineMerger() + .setSegmentTransform(GeometryPipeline.simplifyDP(1)); + + merger.add(newLineString(0, 0, 5, 1)); + merger.add(newLineString(5, 1, 10, 0)); + + assertEquals( + List.of(newLineString(0, 0, 10, 0)), + merger.getMergedLineStrings() + ); + } + @Test void testMergeStrokesAt4WayIntersection() { var merger = new LoopLineMerger()