From cbeba1bc8ffaa207392989710692d9e49129352d Mon Sep 17 00:00:00 2001 From: Michael Barry Date: Mon, 25 Nov 2024 06:39:55 -0500 Subject: [PATCH] Add Visvalingam Whyatt Simplifier (#1109) --- .../benchmarks/BenchmarkSimplify.java | 84 +++++++ .../planetiler/FeatureCollector.java | 26 ++ .../collection/ArrayDoubleMinHeap.java | 229 ++++++++++++++++++ .../planetiler/collection/DoubleMinHeap.java | 81 +++++++ .../planetiler/geo/SimplifyMethod.java | 10 + .../planetiler/geo/VWSimplifier.java | 171 +++++++++++++ .../planetiler/render/FeatureRenderer.java | 13 +- .../planetiler/util/ZoomFunction.java | 9 + .../planetiler/PlanetilerTests.java | 29 ++- ...{LongMinHeapTest.java => MinHeapTest.java} | 107 +++++++- .../planetiler/geo/VWSimplifierTest.java | 116 +++++++++ 11 files changed, 866 insertions(+), 9 deletions(-) create mode 100644 planetiler-benchmarks/src/main/java/com/onthegomap/planetiler/benchmarks/BenchmarkSimplify.java create mode 100644 planetiler-core/src/main/java/com/onthegomap/planetiler/collection/ArrayDoubleMinHeap.java create mode 100644 planetiler-core/src/main/java/com/onthegomap/planetiler/collection/DoubleMinHeap.java create mode 100644 planetiler-core/src/main/java/com/onthegomap/planetiler/geo/SimplifyMethod.java create mode 100644 planetiler-core/src/main/java/com/onthegomap/planetiler/geo/VWSimplifier.java rename planetiler-core/src/test/java/com/onthegomap/planetiler/collection/{LongMinHeapTest.java => MinHeapTest.java} (80%) create mode 100644 planetiler-core/src/test/java/com/onthegomap/planetiler/geo/VWSimplifierTest.java diff --git a/planetiler-benchmarks/src/main/java/com/onthegomap/planetiler/benchmarks/BenchmarkSimplify.java b/planetiler-benchmarks/src/main/java/com/onthegomap/planetiler/benchmarks/BenchmarkSimplify.java new file mode 100644 index 0000000000..61de33563e --- /dev/null +++ b/planetiler-benchmarks/src/main/java/com/onthegomap/planetiler/benchmarks/BenchmarkSimplify.java @@ -0,0 +1,84 @@ +package com.onthegomap.planetiler.benchmarks; + +import com.onthegomap.planetiler.geo.DouglasPeuckerSimplifier; +import com.onthegomap.planetiler.geo.VWSimplifier; +import com.onthegomap.planetiler.util.Format; +import com.onthegomap.planetiler.util.FunctionThatThrows; +import java.math.BigDecimal; +import java.math.MathContext; +import java.time.Duration; +import org.locationtech.jts.geom.CoordinateXY; +import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.util.GeometricShapeFactory; + +public class BenchmarkSimplify { + private static int numLines; + + public static void main(String[] args) throws Exception { + for (int i = 0; i < 10; i++) { + time(" DP(0.1)", geom -> DouglasPeuckerSimplifier.simplify(geom, 0.1)); + time(" DP(1)", geom -> DouglasPeuckerSimplifier.simplify(geom, 1)); + time(" DP(20)", geom -> DouglasPeuckerSimplifier.simplify(geom, 20)); + time(" JTS VW(0)", geom -> org.locationtech.jts.simplify.VWSimplifier.simplify(geom, 0.01)); + time("JTS VW(0.1)", geom -> org.locationtech.jts.simplify.VWSimplifier.simplify(geom, 0.1)); + time(" JTS VW(1)", geom -> org.locationtech.jts.simplify.VWSimplifier.simplify(geom, 1)); + time(" JTS VW(20)", geom -> org.locationtech.jts.simplify.VWSimplifier.simplify(geom, 20)); + time(" VW(0)", geom -> new VWSimplifier().setTolerance(0).setWeight(0.7).transform(geom)); + time(" VW(0.1)", geom -> new VWSimplifier().setTolerance(0.1).setWeight(0.7).transform(geom)); + time(" VW(1)", geom -> new VWSimplifier().setTolerance(1).setWeight(0.7).transform(geom)); + time(" VW(20)", geom -> new VWSimplifier().setTolerance(20).setWeight(0.7).transform(geom)); + } + System.err.println(numLines); + } + + private static void time(String name, FunctionThatThrows fn) throws Exception { + System.err.println(String.join("\t", + name, + timePerSec(makeLines(2), fn), + timePerSec(makeLines(10), fn), + timePerSec(makeLines(50), fn), + timePerSec(makeLines(100), fn), + timePerSec(makeLines(10_000), fn) + )); + } + + private static String timePerSec(Geometry geometry, FunctionThatThrows fn) + throws Exception { + long start = System.nanoTime(); + long end = start + Duration.ofSeconds(1).toNanos(); + int num = 0; + boolean first = true; + for (; System.nanoTime() < end;) { + numLines += fn.apply(geometry).getNumPoints(); + if (first) { + first = false; + } + num++; + } + return Format.defaultInstance() + .numeric(Math.round(num * 1d / ((System.nanoTime() - start) * 1d / Duration.ofSeconds(1).toNanos())), true); + } + + private static String timeMillis(Geometry geometry, FunctionThatThrows fn) + throws Exception { + long start = System.nanoTime(); + long end = start + Duration.ofSeconds(1).toNanos(); + int num = 0; + for (; System.nanoTime() < end;) { + numLines += fn.apply(geometry).getNumPoints(); + 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 makeLines(int parts) { + var shapeFactory = new GeometricShapeFactory(); + shapeFactory.setNumPoints(parts); + shapeFactory.setCentre(new CoordinateXY(0, 0)); + shapeFactory.setSize(10); + return shapeFactory.createCircle(); + } +} 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 7d0a327a1b..096a1bddcc 100644 --- a/planetiler-core/src/main/java/com/onthegomap/planetiler/FeatureCollector.java +++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/FeatureCollector.java @@ -6,6 +6,7 @@ import com.onthegomap.planetiler.geo.GeoUtils; import com.onthegomap.planetiler.geo.GeometryException; import com.onthegomap.planetiler.geo.GeometryType; +import com.onthegomap.planetiler.geo.SimplifyMethod; import com.onthegomap.planetiler.reader.SourceFeature; import com.onthegomap.planetiler.reader.Struct; import com.onthegomap.planetiler.render.FeatureRenderer; @@ -502,6 +503,9 @@ public final class Feature implements WithZoomRange, WithAttrs private double pixelToleranceAtMaxZoom = config.simplifyToleranceAtMaxZoom(); private ZoomFunction pixelTolerance = null; + private SimplifyMethod defaultSimplifyMethod = SimplifyMethod.DOUGLAS_PEUCKER; + private ZoomFunction simplifyMethod = null; + private String numPointsAttr = null; private List partialOverrides = null; @@ -714,6 +718,28 @@ public double getPixelToleranceAtZoom(int zoom) { ZoomFunction.applyAsDoubleOrElse(pixelTolerance, zoom, defaultPixelTolerance); } + /** + * Sets the fallback line and polygon simplify method when not overriden by * + * {@link #setSimplifyMethodOverrides(ZoomFunction)}. + */ + public FeatureCollector.Feature setSimplifyMethod(SimplifyMethod strategy) { + defaultSimplifyMethod = strategy; + return this; + } + + /** Set simplification algorithm to use at different zoom levels. */ + public FeatureCollector.Feature setSimplifyMethodOverrides(ZoomFunction overrides) { + simplifyMethod = overrides; + return this; + } + + /** + * Returns the simplification method for lines and polygons in tile pixels at {@code zoom}. + */ + public SimplifyMethod getSimplifyMethodAtZoom(int zoom) { + return ZoomFunction.applyOrElse(simplifyMethod, zoom, defaultSimplifyMethod); + } + /** * Sets the simplification tolerance for lines and polygons in tile pixels below the maximum zoom-level of the map. *

diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/collection/ArrayDoubleMinHeap.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/collection/ArrayDoubleMinHeap.java new file mode 100644 index 0000000000..b585295f02 --- /dev/null +++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/collection/ArrayDoubleMinHeap.java @@ -0,0 +1,229 @@ +package com.onthegomap.planetiler.collection; + +import java.util.Arrays; +import java.util.function.IntBinaryOperator; + +/** + * A min-heap stored in an array where each element has 4 children. + *

+ * This is about 5-10% faster than the standard binary min-heap for the case of merging sorted lists. + *

+ * Ported from GraphHopper + * and: + *

    + *
  • modified to use {@code double} values instead of {@code float}
  • + *
  • extracted a common interface for subclass implementations
  • + *
  • modified so that each element has 4 children instead of 2 (improves performance by 5-10%)
  • + *
  • performance improvements to minimize array lookups
  • + *
+ * + * @see d-ary heap (wikipedia) + */ +class ArrayDoubleMinHeap implements DoubleMinHeap { + protected static final int NOT_PRESENT = -1; + protected final int[] posToId; + protected final int[] idToPos; + protected final double[] posToValue; + protected final int max; + protected int size; + private final IntBinaryOperator tieBreaker; + + /** + * @param elements the number of elements that can be stored in this heap. Currently the heap cannot be resized or + * shrunk/trimmed after initial creation. elements-1 is the maximum id that can be stored in this heap + */ + ArrayDoubleMinHeap(int elements, IntBinaryOperator tieBreaker) { + // we use an offset of one to make the arithmetic a bit simpler/more efficient, the 0th elements are not used! + posToId = new int[elements + 1]; + idToPos = new int[elements + 1]; + Arrays.fill(idToPos, NOT_PRESENT); + posToValue = new double[elements + 1]; + posToValue[0] = Double.NEGATIVE_INFINITY; + this.max = elements; + this.tieBreaker = tieBreaker; + } + + private static int firstChild(int index) { + return (index << 2) - 2; + } + + private static int parent(int index) { + return (index + 2) >> 2; + } + + @Override + public int size() { + return size; + } + + @Override + public boolean isEmpty() { + return size == 0; + } + + @Override + public void push(int id, double value) { + checkIdInRange(id); + if (size == max) { + throw new IllegalStateException("Cannot push anymore, the heap is already full. size: " + size); + } + if (contains(id)) { + throw new IllegalStateException("Element with id: " + id + + " was pushed already, you need to use the update method if you want to change its value"); + } + size++; + posToId[size] = id; + idToPos[id] = size; + posToValue[size] = value; + percolateUp(size); + } + + @Override + public boolean contains(int id) { + checkIdInRange(id); + return idToPos[id] != NOT_PRESENT; + } + + @Override + public void update(int id, double value) { + checkIdInRange(id); + int pos = idToPos[id]; + if (pos < 0) { + throw new IllegalStateException( + "The heap does not contain: " + id + ". Use the contains method to check this before calling update"); + } + double prev = posToValue[pos]; + posToValue[pos] = value; + int cmp = compareIdPos(value, prev, id, pos); + if (cmp > 0) { + percolateDown(pos); + } else if (cmp < 0) { + percolateUp(pos); + } + } + + @Override + public void updateHead(double value) { + posToValue[1] = value; + percolateDown(1); + } + + @Override + public int peekId() { + return posToId[1]; + } + + @Override + public double peekValue() { + return posToValue[1]; + } + + @Override + public int poll() { + int id = peekId(); + posToId[1] = posToId[size]; + posToValue[1] = posToValue[size]; + idToPos[posToId[1]] = 1; + idToPos[id] = NOT_PRESENT; + size--; + percolateDown(1); + return id; + } + + @Override + public void clear() { + for (int i = 1; i <= size; i++) { + idToPos[posToId[i]] = NOT_PRESENT; + } + size = 0; + } + + private void percolateUp(int pos) { + assert pos != 0; + if (pos == 1) { + return; + } + final int id = posToId[pos]; + final double val = posToValue[pos]; + // the finish condition (index==0) is covered here automatically because we set vals[0]=-inf + int parent; + double parentValue; + while (compareIdPos(val, parentValue = posToValue[parent = parent(pos)], id, parent) < 0) { + posToValue[pos] = parentValue; + idToPos[posToId[pos] = posToId[parent]] = pos; + pos = parent; + } + posToId[pos] = id; + posToValue[pos] = val; + idToPos[posToId[pos]] = pos; + } + + private void checkIdInRange(int id) { + if (id < 0 || id >= max) { + throw new IllegalArgumentException("Illegal id: " + id + ", legal range: [0, " + max + "["); + } + } + + private void percolateDown(int pos) { + if (size == 0) { + return; + } + assert pos > 0; + assert pos <= size; + final int id = posToId[pos]; + final double value = posToValue[pos]; + int child; + while ((child = firstChild(pos)) <= size) { + // optimization: this is a very hot code path for performance of k-way merging, + // so manually-unroll the loop over the 4 child elements to find the minimum value + int minChild = child; + double minValue = posToValue[child], childValue; + if (++child <= size) { + if (comparePosPos(childValue = posToValue[child], minValue, child, minChild) < 0) { + minChild = child; + minValue = childValue; + } + if (++child <= size) { + if (comparePosPos(childValue = posToValue[child], minValue, child, minChild) < 0) { + minChild = child; + minValue = childValue; + } + if (++child <= size && + comparePosPos(childValue = posToValue[child], minValue, child, minChild) < 0) { + minChild = child; + minValue = childValue; + } + } + } + if (compareIdPos(value, minValue, id, minChild) <= 0) { + break; + } + posToValue[pos] = minValue; + idToPos[posToId[pos] = posToId[minChild]] = pos; + pos = minChild; + } + posToId[pos] = id; + posToValue[pos] = value; + idToPos[id] = pos; + } + + private int comparePosPos(double val1, double val2, int pos1, int pos2) { + if (val1 < val2) { + return -1; + } else if (val1 == val2 && val1 != Double.NEGATIVE_INFINITY) { + return tieBreaker.applyAsInt(posToId[pos1], posToId[pos2]); + } + return 1; + } + + private int compareIdPos(double val1, double val2, int id1, int pos2) { + if (val1 < val2) { + return -1; + } else if (val1 == val2 && val1 != Double.NEGATIVE_INFINITY) { + return tieBreaker.applyAsInt(id1, posToId[pos2]); + } + return 1; + } + +} diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/collection/DoubleMinHeap.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/collection/DoubleMinHeap.java new file mode 100644 index 0000000000..b405c53f6a --- /dev/null +++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/collection/DoubleMinHeap.java @@ -0,0 +1,81 @@ +/* + * Licensed to GraphHopper GmbH under one or more contributor + * license agreements. See the NOTICE file distributed with this work for + * additional information regarding copyright ownership. + * + * GraphHopper GmbH licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except in + * compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.onthegomap.planetiler.collection; + +import java.util.function.IntBinaryOperator; + +/** + * API for min-heaps that keeps track of {@code int} keys in a range from {@code [0, size)} ordered by {@code double} + * values. + *

+ * Ported from GraphHopper + * and modified to extract a common interface for subclass implementations. + */ +public interface DoubleMinHeap { + /** + * Returns a new min-heap where each element has 4 children backed by elements in an array. + *

+ * This is slightly faster than a traditional binary min heap due to a shallower, more cache-friendly memory layout. + */ + static DoubleMinHeap newArrayHeap(int elements, IntBinaryOperator tieBreaker) { + return new ArrayDoubleMinHeap(elements, tieBreaker); + } + + int size(); + + boolean isEmpty(); + + /** + * Adds an element to the heap, the given id must not exceed the size specified in the constructor. Its illegal to + * push the same id twice (unless it was polled/removed before). To update the value of an id contained in the heap + * use the {@link #update} method. + */ + void push(int id, double value); + + /** + * @return true if the heap contains an element with the given id + */ + boolean contains(int id); + + /** + * Updates the element with the given id. The complexity of this method is O(log(N)), just like push/poll. Its illegal + * to update elements that are not contained in the heap. Use {@link #contains} to check the existence of an id. + */ + void update(int id, double value); + + /** + * Updates the weight of the head element in the heap, pushing it down and bubbling up the new min element if + * necessary. + */ + void updateHead(double value); + + /** + * @return the id of the next element to be polled, i.e. the same as calling poll() without removing the element + */ + int peekId(); + + double peekValue(); + + /** + * Extracts the element with minimum value from the heap + */ + int poll(); + + void clear(); +} diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/geo/SimplifyMethod.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/geo/SimplifyMethod.java new file mode 100644 index 0000000000..c298e12dab --- /dev/null +++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/geo/SimplifyMethod.java @@ -0,0 +1,10 @@ +package com.onthegomap.planetiler.geo; + +public enum SimplifyMethod { + RETAIN_IMPORTANT_POINTS, + RETAIN_EFFECTIVE_AREAS, + RETAIN_WEIGHTED_EFFECTIVE_AREAS; + + public static final SimplifyMethod DOUGLAS_PEUCKER = RETAIN_IMPORTANT_POINTS; + public static final SimplifyMethod VISVALINGAM_WHYATT = RETAIN_EFFECTIVE_AREAS; +} 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 new file mode 100644 index 0000000000..2972ce8f07 --- /dev/null +++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/geo/VWSimplifier.java @@ -0,0 +1,171 @@ +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; +import org.locationtech.jts.geom.Polygon; +import org.locationtech.jts.geom.util.GeometryTransformer; + +/** + * 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 { + + private double tolerance; + private double k; + + /** Sets the minimum effective triangle area created by 3 consecutive vertices in order to retain that vertex. */ + public VWSimplifier setTolerance(double tolerance) { + this.tolerance = tolerance; + return this; + } + + /** + * Apply a penalty from {@code k=0} to {@code k=1} to drop more sharp corners from the resulting geometry. + *

+ * {@code k=0} is the default to apply no penalty for corner sharpness and just drop based on effective triangle area + * at the vertex. {@code k=0.7} is the recommended setting to drop corners based on weighted effective area. + */ + public VWSimplifier setWeight(double k) { + this.k = k; + return this; + } + + @Override + public Geometry apply(Geometry geometry) { + return transform(geometry); + } + + private class Vertex { + int idx; + double x; + double y; + double area; + Vertex prev; + Vertex next; + + Vertex(int idx, CoordinateSequence seq) { + this.idx = idx; + this.x = seq.getX(idx); + this.y = seq.getY(idx); + } + + public void remove() { + if (prev != null) { + prev.next = next; + } + if (next != null) { + next.prev = prev; + } + } + + public double updateArea() { + if (prev == null || next == null) { + return area = Double.POSITIVE_INFINITY; + } + return area = weightedArea(prev.x, prev.y, x, y, next.x, next.y); + } + } + + @Override + protected CoordinateSequence transformCoordinates(CoordinateSequence coords, Geometry parent) { + boolean area = parent instanceof LinearRing; + int num = coords.size(); + if (num == 0) { + return coords; + } + + DoubleMinHeap heap = DoubleMinHeap.newArrayHeap(num, Integer::compare); + Vertex[] points = new Vertex[num]; + // TODO + // Stack intersecting = new Stack<>(); + Vertex prev = null; + for (int i = 0; i < num; i++) { + Vertex cur = new Vertex(i, coords); + points[i] = cur; + if (prev != null) { + cur.prev = prev; + prev.next = cur; + heap.push(prev.idx, prev.updateArea()); + } + prev = cur; + } + 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) { + break; + } + // TODO + // // Check that the new segment doesn’t intersect with + // // any existing segments, except for the point’s + // // immediate neighbours. + // if (intersect(heap, point.previous, point.next)) + // intersecting.push(point); + // continue + // // Reattempt to process points that previously would + // // have caused intersections when removed. + // while (i = intersecting.pop()) heap.push(i) + + point.remove(); + left--; + if (point.prev != null) { + heap.update(point.prev.idx, point.prev.updateArea()); + } + if (point.next != null) { + heap.update(point.next.idx, point.next.updateArea()); + } + } + MutableCoordinateSequence result = new MutableCoordinateSequence(); + for (Vertex point = points[0]; point != null; point = point.next) { + result.forceAddPoint(point.x, point.y); + } + 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; + } + + private 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); + } + + private static double cos(double ax, double ay, double bx, double by, double cx, double cy) { + double den = Math.hypot(bx - ax, by - ay) * Math.hypot(cx - bx, cy - by), + cos = 0; + if (den > 0) { + cos = Math.clamp((ax - bx) * (cx - bx) + (ay - by) * (cy - by) / den, -1, 1); + } + return cos; + } + + private double weight(double cos) { + return (-cos) * k + 1; + } + + private double weightedArea(double ax, double ay, double bx, double by, double cx, double cy) { + double area = triangleArea(ax, ay, bx, by, cx, cy); + return k == 0 ? area : (area * weight(cos(ax, ay, bx, by, cx, cy))); + } +} 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 95df944180..bf1acd0390 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 @@ -6,8 +6,10 @@ 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.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; @@ -194,6 +196,7 @@ private void renderLineOrPolygonGeometry(FeatureCollector.Feature feature, Geome 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); @@ -201,7 +204,15 @@ private void renderLineOrPolygonGeometry(FeatureCollector.Feature feature, Geome // 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; - Geometry geom = DouglasPeuckerSimplifier.simplify(scaled, tolerance); + // 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); + }; List> groups = GeometryCoordinateSequences.extractGroups(geom, minSize); try { sliced = TiledGeometry.sliceIntoTiles(groups, buffer, area, z, extents); diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/util/ZoomFunction.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/util/ZoomFunction.java index 36ccf622be..788766684b 100644 --- a/planetiler-core/src/main/java/com/onthegomap/planetiler/util/ZoomFunction.java +++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/util/ZoomFunction.java @@ -45,6 +45,15 @@ static int applyAsIntOrElse(ZoomFunction fn, int zoom, int def return result == null ? defaultValue : result.intValue(); } + /** Invoke a function at a zoom level and returns {@code defaultValue} if the function or result were null. */ + static T applyOrElse(ZoomFunction fn, int zoom, T defaultValue) { + if (fn == null) { + return defaultValue; + } + T result = fn.apply(zoom); + return result == null ? defaultValue : result; + } + /** * Returns a zoom function that returns the value from the next higher key in {@code thresholds} or {@code null} if * over the max key. diff --git a/planetiler-core/src/test/java/com/onthegomap/planetiler/PlanetilerTests.java b/planetiler-core/src/test/java/com/onthegomap/planetiler/PlanetilerTests.java index 7116edccbb..eb6b9ba5ce 100644 --- a/planetiler-core/src/test/java/com/onthegomap/planetiler/PlanetilerTests.java +++ b/planetiler-core/src/test/java/com/onthegomap/planetiler/PlanetilerTests.java @@ -18,6 +18,7 @@ import com.onthegomap.planetiler.files.ReadableFilesArchive; import com.onthegomap.planetiler.geo.GeoUtils; import com.onthegomap.planetiler.geo.GeometryException; +import com.onthegomap.planetiler.geo.SimplifyMethod; import com.onthegomap.planetiler.geo.TileCoord; import com.onthegomap.planetiler.geo.TileOrder; import com.onthegomap.planetiler.mbtiles.Mbtiles; @@ -430,26 +431,37 @@ void testLabelGridLimit() throws Exception { } @ParameterizedTest - @ValueSource(booleans = {false, true}) - void testLineString(boolean anyGeom) throws Exception { + @CsvSource({ + "false,RETAIN_IMPORTANT_POINTS", + "false,RETAIN_EFFECTIVE_AREAS", + "false,RETAIN_WEIGHTED_EFFECTIVE_AREAS", + "true,RETAIN_IMPORTANT_POINTS", + }) + void testLineString(boolean anyGeom, SimplifyMethod simplifyStrategy) throws Exception { double x1 = 0.5 + Z14_WIDTH / 2; double y1 = 0.5 + Z14_WIDTH / 2; double x2 = x1 + Z14_WIDTH; double y2 = y1 + Z14_WIDTH; + double ymid = (y1 + y2) / 2; + double xmid = (x1 + x2) / 2; double lat1 = GeoUtils.getWorldLat(y1); double lng1 = GeoUtils.getWorldLon(x1); + double latMid = GeoUtils.getWorldLat(ymid); + double lngMid = GeoUtils.getWorldLon(xmid); double lat2 = GeoUtils.getWorldLat(y2); double lng2 = GeoUtils.getWorldLon(x2); var results = runWithReaderFeatures( Map.of("threads", "1"), List.of( - newReaderFeature(newLineString(lng1, lat1, lng2, lat2), Map.of( + newReaderFeature(newLineString(lng1, lat1, lngMid, latMid, lng2, lat2), Map.of( "attr", "value" )) ), (in, features) -> (anyGeom ? features.anyGeometry("layer") : features.line("layer")) .setZoomRange(13, 14) + .setPixelTolerance(1) + .setSimplifyMethod(simplifyStrategy) .setBufferPixels(4) ); @@ -2553,8 +2565,13 @@ void testBoundFilters() throws Exception { assertEquals(bboxResult.tiles, polyResult.tiles); } - @Test - void testSimplePolygon() throws Exception { + @ParameterizedTest + @CsvSource({ + "RETAIN_IMPORTANT_POINTS", + "RETAIN_EFFECTIVE_AREAS", + "RETAIN_WEIGHTED_EFFECTIVE_AREAS", + }) + void testSimplePolygon(SimplifyMethod strategy) throws Exception { List points = z14PixelRectangle(0, 40); var results = runWithReaderFeatures( @@ -2565,6 +2582,8 @@ void testSimplePolygon() throws Exception { (in, features) -> features.polygon("layer") .setZoomRange(0, 14) .setBufferPixels(0) + .setPixelTolerance(1) + .setSimplifyMethod(strategy) .setMinPixelSize(10) // should only show up z14 (40) z13 (20) and z12 (10) ); diff --git a/planetiler-core/src/test/java/com/onthegomap/planetiler/collection/LongMinHeapTest.java b/planetiler-core/src/test/java/com/onthegomap/planetiler/collection/MinHeapTest.java similarity index 80% rename from planetiler-core/src/test/java/com/onthegomap/planetiler/collection/LongMinHeapTest.java rename to planetiler-core/src/test/java/com/onthegomap/planetiler/collection/MinHeapTest.java index 7b0a6c4a5a..f1d488cdcf 100644 --- a/planetiler-core/src/test/java/com/onthegomap/planetiler/collection/LongMinHeapTest.java +++ b/planetiler-core/src/test/java/com/onthegomap/planetiler/collection/MinHeapTest.java @@ -28,6 +28,7 @@ import com.carrotsearch.hppc.IntSet; import java.util.PriorityQueue; import java.util.Random; +import java.util.function.IntBinaryOperator; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; @@ -39,12 +40,112 @@ * and modified to use long instead of float values, use stable random seed for reproducibility, and to use new * implementations. */ -class LongMinHeapTest { +abstract class MinHeapTest { protected LongMinHeap heap; - void create(int capacity) { - heap = LongMinHeap.newArrayHeap(capacity, Integer::compare); + final void create(int capacity) { + create(capacity, Integer::compare); + } + + abstract void create(int capacity, IntBinaryOperator tieBreaker); + + + static class LongMinHeapTest extends MinHeapTest { + + @Override + void create(int capacity, IntBinaryOperator tieBreaker) { + heap = LongMinHeap.newArrayHeap(capacity, tieBreaker); + } + } + + static class DoubleMinHeapTest extends MinHeapTest { + + private DoubleMinHeap doubleHeap; + + @Test + void testDoubles() { + create(5); + + doubleHeap.push(4, 1.5d); + doubleHeap.push(1, 1.4d); + assertEquals(2, doubleHeap.size()); + assertEquals(1, doubleHeap.peekId()); + assertEquals(1.4d, doubleHeap.peekValue()); + assertEquals(1, doubleHeap.poll()); + assertEquals(4, doubleHeap.poll()); + assertTrue(heap.isEmpty()); + } + + @Test + void testDoublesReverse() { + create(5); + + doubleHeap.push(4, 1.4d); + doubleHeap.push(1, 1.5d); + assertEquals(2, doubleHeap.size()); + assertEquals(4, doubleHeap.peekId()); + assertEquals(1.4d, doubleHeap.peekValue()); + assertEquals(4, doubleHeap.poll()); + assertEquals(1, doubleHeap.poll()); + assertTrue(heap.isEmpty()); + } + + @Override + void create(int capacity, IntBinaryOperator tieBreaker) { + doubleHeap = DoubleMinHeap.newArrayHeap(capacity, tieBreaker); + heap = new LongMinHeap() { + @Override + public int size() { + return doubleHeap.size(); + } + + @Override + public boolean isEmpty() { + return doubleHeap.isEmpty(); + } + + @Override + public void push(int id, long value) { + doubleHeap.push(id, value); + } + + @Override + public boolean contains(int id) { + return doubleHeap.contains(id); + } + + @Override + public void update(int id, long value) { + doubleHeap.update(id, value); + } + + @Override + public void updateHead(long value) { + doubleHeap.updateHead(value); + } + + @Override + public int peekId() { + return doubleHeap.peekId(); + } + + @Override + public long peekValue() { + return (long) doubleHeap.peekValue(); + } + + @Override + public int poll() { + return doubleHeap.poll(); + } + + @Override + public void clear() { + doubleHeap.clear(); + } + }; + } } @Test 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 new file mode 100644 index 0000000000..d810c1f615 --- /dev/null +++ b/planetiler-core/src/test/java/com/onthegomap/planetiler/geo/VWSimplifierTest.java @@ -0,0 +1,116 @@ +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 org.junit.jupiter.api.Test; +import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.geom.util.AffineTransformation; + +class VWSimplifierTest { + + final int[] rotations = new int[]{0, 45, 90, 180, 270}; + + private void testSimplify(Geometry in, Geometry expected, double amount) { + for (int rotation : rotations) { + var rotate = AffineTransformation.rotationInstance(Math.PI * rotation / 180); + assertSameNormalizedFeature( + rotate.transform(expected), + new VWSimplifier().setTolerance(amount).setWeight(0).transform(rotate.transform(in)) + ); + assertSameNormalizedFeature( + rotate.transform(expected.reverse()), + new VWSimplifier().setTolerance(amount).setWeight(0).transform(rotate.transform(in.reverse())) + ); + } + } + + @Test + void testSimplify2Points() { + testSimplify(newLineString( + 0, 0, + 10, 10 + ), newLineString( + 0, 0, + 10, 10 + ), 1); + } + + @Test + void testRemoveAPoint() { + testSimplify(newLineString( + 0, 0, + 5, 0.9, + 10, 0 + ), newLineString( + 0, 0, + 10, 0 + ), 5); + } + + @Test + void testKeepAPoint() { + testSimplify(newLineString( + 0, 0, + 5, 1.1, + 10, 0 + ), newLineString( + 0, 0, + 5, 1.1, + 10, 0 + ), 5); + } + + @Test + void testPolygonLeaveAPoint() { + testSimplify( + newPolygon( + 0, 0, + 10, 10, + 9, 10, + 0, 8, + 0, 0 + ), + newPolygon( + 0, 0, + 0, 8, + 10, 10, + 0, 0 + ), + 200 + ); + } + + @Test + void testLine() { + testSimplify( + newLineString( + 0, 0, + 1, 0.1, + 2, 0, + 3, 0.1 + ), + newLineString( + 0, 0, + 1, 0.1, + 2, 0, + 3, 0.1 + ), + 0.09 + ); + testSimplify( + newLineString( + 0, 0, + 1, 0.1, + 2, 0, + 3, 0.1 + ), + newLineString( + 0, 0, + 3, 0.1 + ), + 0.11 + ); + } +}