From 21f061efe0cb357b61678e686aefb6f3f20f3938 Mon Sep 17 00:00:00 2001 From: Michael Barry Date: Thu, 30 May 2024 05:19:15 -0400 Subject: [PATCH] Add support for linear-referenced tags. (#900) --- .../benchmarks/BenchmarkLineSplitter.java | 74 ++++ .../planetiler/FeatureCollector.java | 401 ++++++++++++++---- .../planetiler/geo/LineSplitter.java | 100 +++++ .../planetiler/reader/SourceFeature.java | 23 + .../planetiler/render/FeatureRenderer.java | 71 ++-- .../onthegomap/planetiler/util/MapUtil.java | 31 ++ .../planetiler/util/MergingRangeMap.java | 106 +++++ .../planetiler/FeatureCollectorTest.java | 95 +++++ .../planetiler/PlanetilerTests.java | 75 ++++ .../com/onthegomap/planetiler/TestUtils.java | 18 + .../planetiler/geo/LineSplitterTest.java | 67 +++ .../render/FeatureRendererTest.java | 63 +++ .../planetiler/util/MergingRangeMapTest.java | 69 +++ 13 files changed, 1079 insertions(+), 114 deletions(-) create mode 100644 planetiler-benchmarks/src/main/java/com/onthegomap/planetiler/benchmarks/BenchmarkLineSplitter.java create mode 100644 planetiler-core/src/main/java/com/onthegomap/planetiler/geo/LineSplitter.java create mode 100644 planetiler-core/src/main/java/com/onthegomap/planetiler/util/MapUtil.java create mode 100644 planetiler-core/src/main/java/com/onthegomap/planetiler/util/MergingRangeMap.java create mode 100644 planetiler-core/src/test/java/com/onthegomap/planetiler/geo/LineSplitterTest.java create mode 100644 planetiler-core/src/test/java/com/onthegomap/planetiler/util/MergingRangeMapTest.java diff --git a/planetiler-benchmarks/src/main/java/com/onthegomap/planetiler/benchmarks/BenchmarkLineSplitter.java b/planetiler-benchmarks/src/main/java/com/onthegomap/planetiler/benchmarks/BenchmarkLineSplitter.java new file mode 100644 index 0000000000..ca8f6ca7b6 --- /dev/null +++ b/planetiler-benchmarks/src/main/java/com/onthegomap/planetiler/benchmarks/BenchmarkLineSplitter.java @@ -0,0 +1,74 @@ +package com.onthegomap.planetiler.benchmarks; + +import com.onthegomap.planetiler.geo.LineSplitter; +import java.util.concurrent.ThreadLocalRandom; +import org.locationtech.jts.util.GeometricShapeFactory; + +public class BenchmarkLineSplitter { + + public static void main(String[] args) { + for (int i = 0; i < 10; i++) { + System.err.println( + "reused:\t" + + timeReused(10, 1_000_000) + "\t" + + timeReused(100, 100_000) + "\t" + + timeReused(1_000, 10_000) + + "\t!reused\t" + + timeNotReused(10, 1_000_000) + "\t" + + timeNotReused(100, 100_000) + "\t" + + timeNotReused(1_000, 10_000) + + "\tcacheable\t" + + timeCacheable(10, 1_000_000) + "\t" + + timeCacheable(100, 100_000) + "\t" + + timeCacheable(1_000, 10_000)); + } + } + + private static long timeCacheable(int points, int iters) { + var fact = new GeometricShapeFactory(); + fact.setNumPoints(points); + fact.setWidth(10); + var shape = fact.createArc(0, Math.PI); + long start = System.currentTimeMillis(); + LineSplitter splitter = new LineSplitter(shape); + var random = ThreadLocalRandom.current(); + for (int i = 0; i < iters; i++) { + int a = random.nextInt(0, 90); + int b = random.nextInt(a + 2, 100); + splitter.get(a / 100d, b / 100d); + } + return System.currentTimeMillis() - start; + } + + private static long timeReused(int points, int iters) { + var fact = new GeometricShapeFactory(); + fact.setNumPoints(points); + fact.setWidth(10); + var shape = fact.createArc(0, Math.PI); + long start = System.currentTimeMillis(); + LineSplitter splitter = new LineSplitter(shape); + var random = ThreadLocalRandom.current(); + for (int i = 0; i < iters; i++) { + var a = random.nextDouble(0, 1); + var b = random.nextDouble(a, 1); + splitter.get(a, b); + } + return System.currentTimeMillis() - start; + } + + private static long timeNotReused(int points, int iters) { + var fact = new GeometricShapeFactory(); + fact.setNumPoints(points); + fact.setWidth(10); + var shape = fact.createArc(0, Math.PI); + long start = System.currentTimeMillis(); + var random = ThreadLocalRandom.current(); + for (int i = 0; i < iters; i++) { + LineSplitter splitter = new LineSplitter(shape); + var a = random.nextDouble(0, 1); + var b = random.nextDouble(a, 1); + splitter.get(a, b); + } + return System.currentTimeMillis() - start; + } +} 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 cd60987021..54e315381f 100644 --- a/planetiler-core/src/main/java/com/onthegomap/planetiler/FeatureCollector.java +++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/FeatureCollector.java @@ -1,5 +1,6 @@ package com.onthegomap.planetiler; +import com.google.common.collect.Range; import com.onthegomap.planetiler.collection.FeatureGroup; import com.onthegomap.planetiler.config.PlanetilerConfig; import com.onthegomap.planetiler.geo.GeoUtils; @@ -9,6 +10,8 @@ import com.onthegomap.planetiler.render.FeatureRenderer; import com.onthegomap.planetiler.stats.Stats; import com.onthegomap.planetiler.util.CacheByZoom; +import com.onthegomap.planetiler.util.MapUtil; +import com.onthegomap.planetiler.util.MergingRangeMap; import com.onthegomap.planetiler.util.ZoomFunction; import java.util.ArrayList; import java.util.Iterator; @@ -24,6 +27,7 @@ *

* For example to add a polygon feature for a lake and a center label point with its name: * {@snippet : + * FeatureCollector featureCollector; * featureCollector.polygon("water") * .setAttr("class", "lake"); * featureCollector.centroid("water_name") @@ -105,6 +109,26 @@ public Feature line(String layer) { } } + + /** + * Starts building a new partial line feature from {@code start} to {@code end} where 0 is the beginning of the line + * and 1 is the end of the line. + *

+ * If the source feature cannot be a line, logs an error and returns a feature that can be configured, but won't + * actually emit anything to the map. + * + * @param layer the output vector tile layer this feature will be written to + * @return a feature that can be configured further. + */ + public Feature partialLine(String layer, double start, double end) { + try { + return geometry(layer, source.partialLine(start, end)); + } catch (GeometryException e) { + e.log(stats, "feature_partial_line", "Error constructing partial line for " + source); + return new Feature(layer, EMPTY_GEOM, source.id()); + } + } + /** * Starts building a new polygon map feature that expects the source feature to be a polygon. *

@@ -222,6 +246,128 @@ public double getPixelSizeAtZoom(int zoom) { } } + private sealed interface OverrideCommand { + Range range(); + } + private record Minzoom(Range range, int minzoom) implements OverrideCommand {} + private record Maxzoom(Range range, int maxzoom) implements OverrideCommand {} + private record Omit(Range range) implements OverrideCommand {} + private record Attr(Range range, String key, Object value) implements OverrideCommand {} + + public interface WithZoomRange> { + + /** + * Sets the zoom range (inclusive) that this feature appears in. + *

+ * If not called, then defaults to all zoom levels. + */ + default T setZoomRange(int min, int max) { + assert min <= max; + return setMinZoom(min).setMaxZoom(max); + } + + + /** + * Sets the minimum zoom level (inclusive) that this feature appears in. + *

+ * If not called, defaults to minimum zoom-level of the map. + */ + T setMinZoom(int min); + + /** + * Sets the maximum zoom level (inclusive) that this feature appears in. + *

+ * If not called, defaults to maximum zoom-level of the map. + */ + T setMaxZoom(int max); + } + + public interface WithSelf> { + + default T self() { + return (T) this; + } + } + + public interface WithAttrs> extends WithSelf { + + /** Copies the value for {@code key} attribute from source feature to the output feature. */ + default T inheritAttrFromSource(String key) { + return setAttr(key, collector().source.getTag(key)); + } + + /** + * Sets an attribute on the output feature to either a string, number, boolean, or instance of {@link ZoomFunction} + * to change the value for {@code key} by zoom-level. + */ + T setAttr(String key, Object value); + + /** + * Sets the value for {@code key} attribute at or above {@code minzoom}. Below {@code minzoom} it will be ignored. + *

+ * Replaces all previous value that has been for {@code key} at any zoom level. To have a value that changes at + * multiple zoom level thresholds, call {@link #setAttr(String, Object)} with a manually-constructed + * {@link ZoomFunction} value. + */ + default T setAttrWithMinzoom(String key, Object value, int minzoom) { + return setAttr(key, ZoomFunction.minZoom(minzoom, value)); + } + + /** + * Sets the value for {@code key} only at zoom levels where the feature is at least {@code minPixelSize} pixels in + * size. + */ + default T setAttrWithMinSize(String key, Object value, double minPixelSize) { + return setAttrWithMinzoom(key, value, collector().getMinZoomForPixelSize(minPixelSize)); + } + + /** + * Sets the value for {@code key} so that it always shows when {@code zoom_level >= minZoomToShowAlways} but only + * shows when {@code minZoomIfBigEnough <= zoom_level < minZoomToShowAlways} when it is at least + * {@code minPixelSize} pixels in size. + *

+ * If you need more flexibility, use {@link #getMinZoomForPixelSize(double)} directly, or create a + * {@link ZoomFunction} that calculates {@link #getPixelSizeAtZoom(int)} and applies a custom threshold based on the + * zoom level. + */ + default T setAttrWithMinSize(String key, Object value, double minPixelSize, int minZoomIfBigEnough, + int minZoomToShowAlways) { + return setAttrWithMinzoom(key, value, + Math.clamp(collector().getMinZoomForPixelSize(minPixelSize), minZoomIfBigEnough, minZoomToShowAlways)); + } + + /** + * Inserts all key/value pairs in {@code attrs} into the set of attribute to emit on the output feature at or above + * {@code minzoom}. + *

+ * Replace values that have already been set. + */ + default T putAttrsWithMinzoom(Map attrs, int minzoom) { + for (var entry : attrs.entrySet()) { + setAttrWithMinzoom(entry.getKey(), entry.getValue(), minzoom); + } + return self(); + } + + /** + * Inserts all key/value pairs in {@code attrs} into the set of attribute to emit on the output feature. + *

+ * Does not touch attributes that have already been set. + *

+ * Values in {@code attrs} can either be the raw value to set, or an instance of {@link ZoomFunction} to change the + * value for that attribute by zoom level. + */ + default T putAttrs(Map attrs) { + for (var entry : attrs.entrySet()) { + setAttr(entry.getKey(), entry.getValue()); + } + return self(); + } + + /** Returns the {@link FeatureCollector} this feature came from. */ + FeatureCollector collector(); + } + /** * Creates new feature collector instances for each source feature that we encounter. */ @@ -232,6 +378,11 @@ public FeatureCollector get(SourceFeature source) { } } + private record PartialOverride(Range range, Object key, Object value) {} + + /** A fully-configured subset of this line feature with linear-scoped attributes applied to a subset of the range.. */ + public record RangeWithTags(double start, double end, Geometry geom, Map attrs) {} + /** * A builder for an output map feature that contains all the information that will be needed to render vector tile * features from the input element. @@ -239,7 +390,7 @@ public FeatureCollector get(SourceFeature source) { * Some feature attributes are set globally (like sort key), and some allow the value to change by zoom-level (like * tags). */ - public final class Feature { + public final class Feature implements WithZoomRange, WithAttrs { private static final double DEFAULT_LABEL_GRID_SIZE = 0; private static final int DEFAULT_LABEL_GRID_LIMIT = 0; @@ -260,6 +411,7 @@ public final class Feature { private boolean attrsChangeByZoom = false; private CacheByZoom> attrCache = null; + private CacheByZoom> partialRangeCache = null; private double defaultBufferPixels = 4; private ZoomFunction bufferPixelOverrides; @@ -274,6 +426,7 @@ public final class Feature { private ZoomFunction pixelTolerance = null; private String numPointsAttr = null; + private List partialOverrides = null; private Feature(String layer, Geometry geom, long id) { this.layer = layer; @@ -335,27 +488,12 @@ public Feature setSortKeyDescending(int sortKey) { return setSortKey(FeatureGroup.SORT_KEY_MAX + FeatureGroup.SORT_KEY_MIN - sortKey); } - /** - * Sets the zoom range (inclusive) that this feature appears in. - *

- * If not called, then defaults to all zoom levels. - */ - public Feature setZoomRange(int min, int max) { - assert min <= max; - return setMinZoom(min).setMaxZoom(max); - } - /** Returns the minimum zoom level (inclusive) that this feature appears in. */ public int getMinZoom() { return minzoom; } - - /** - * Sets the minimum zoom level (inclusive) that this feature appears in. - *

- * If not called, defaults to minimum zoom-level of the map. - */ + @Override public Feature setMinZoom(int min) { minzoom = Math.max(min, config.minzoom()); return this; @@ -366,11 +504,7 @@ public int getMaxZoom() { return maxzoom; } - /** - * Sets the maximum zoom level (inclusive) that this feature appears in. - *

- * If not called, defaults to maximum zoom-level of the map. - */ + @Override public Feature setMaxZoom(int max) { maxzoom = Math.min(max, config.maxzoom()); return this; @@ -695,15 +829,8 @@ public Map getAttrsAtZoom(int zoom) { return attrCache.get(zoom); } - /** Copies the value for {@code key} attribute from source feature to the output feature. */ - public Feature inheritAttrFromSource(String key) { - return setAttr(key, source.getTag(key)); - } - /** - * Sets an attribute on the output feature to either a string, number, boolean, or instance of {@link ZoomFunction} - * to change the value for {@code key} by zoom-level. - */ + @Override public Feature setAttr(String key, Object value) { if (value instanceof ZoomFunction) { attrsChangeByZoom = true; @@ -714,61 +841,7 @@ public Feature setAttr(String key, Object value) { return this; } - /** - * Sets the value for {@code key} attribute at or above {@code minzoom}. Below {@code minzoom} it will be ignored. - *

- * Replaces all previous value that has been for {@code key} at any zoom level. To have a value that changes at - * multiple zoom level thresholds, call {@link #setAttr(String, Object)} with a manually-constructed - * {@link ZoomFunction} value. - */ - public Feature setAttrWithMinzoom(String key, Object value, int minzoom) { - return setAttr(key, ZoomFunction.minZoom(minzoom, value)); - } - - /** - * Sets the value for {@code key} only at zoom levels where the feature is at least {@code minPixelSize} pixels in - * size. - */ - public Feature setAttrWithMinSize(String key, Object value, double minPixelSize) { - return setAttrWithMinzoom(key, value, getMinZoomForPixelSize(minPixelSize)); - } - - /** - * Sets the value for {@code key} so that it always shows when {@code zoom_level >= minZoomToShowAlways} but only - * shows when {@code minZoomIfBigEnough <= zoom_level < minZoomToShowAlways} when it is at least - * {@code minPixelSize} pixels in size. - *

- * If you need more flexibility, use {@link #getMinZoomForPixelSize(double)} directly, or create a - * {@link ZoomFunction} that calculates {@link #getPixelSizeAtZoom(int)} and applies a custom threshold based on the - * zoom level. - */ - public Feature setAttrWithMinSize(String key, Object value, double minPixelSize, int minZoomIfBigEnough, - int minZoomToShowAlways) { - return setAttrWithMinzoom(key, value, - Math.clamp(getMinZoomForPixelSize(minPixelSize), minZoomIfBigEnough, minZoomToShowAlways)); - } - - /** - * Inserts all key/value pairs in {@code attrs} into the set of attribute to emit on the output feature at or above - * {@code minzoom}. - *

- * Replace values that have already been set. - */ - public Feature putAttrsWithMinzoom(Map attrs, int minzoom) { - for (var entry : attrs.entrySet()) { - setAttrWithMinzoom(entry.getKey(), entry.getValue(), minzoom); - } - return this; - } - - /** - * Inserts all key/value pairs in {@code attrs} into the set of attribute to emit on the output feature. - *

- * Does not touch attributes that have already been set. - *

- * Values in {@code attrs} can either be the raw value to set, or an instance of {@link ZoomFunction} to change the - * value for that attribute by zoom level. - */ + @Override public Feature putAttrs(Map attrs) { for (Object value : attrs.values()) { if (value instanceof ZoomFunction) { @@ -780,6 +853,11 @@ public Feature putAttrs(Map attrs) { return this; } + @Override + public FeatureCollector collector() { + return FeatureCollector.this; + } + /** * Returns the attribute key that the renderer should use to store the number of points in the simplified geometry * before slicing it into tiles. @@ -816,5 +894,158 @@ public String toString() { public double getSourceFeaturePixelSizeAtZoom(int zoom) { return getPixelSizeAtZoom(zoom); } + + /** + * Returns a {@link LinearRange} that can be used to configure attributes that apply to only a portion of this line + * from {@code start} to {@code end} where 0 is the beginning of the line and 1 is the end. + *

+ * Since mapbox vector tiles can't handle this natively, the line will be broken up into multiple lines in the + * output tiles at each zoom level with the unique sets of tags on each line. Adjacent segments with the same tags + * will get merged into a single segment. + */ + public LinearRange linearRange(double start, double end) { + return linearRange(Range.closedOpen(start, end)); + } + + /** + * Returns a {@link LinearRange} that can be used to configure attributes that apply to only a portion of this line + * from {@code range.lowerBound} to {@code range.lowerBound} where 0 is the beginning of the line and 1 is the end. + *

+ * Since mapbox vector tiles can't handle this natively, the line will be broken up into multiple lines in the + * output tiles at each zoom level with the unique sets of tags on each line. Adjacent segments with the same tags + * will get merged into a single segment. + */ + public LinearRange linearRange(Range range) { + return new LinearRange(range); + } + + /** Returns true if any attributes have been configured over a subset of this line. */ + public boolean hasLinearRanges() { + return partialOverrides != null; + } + + /** Computes and returns the linear-scoped attributes of this line, and the geometry they apply to. */ + public List getLinearRangesAtZoom(int zoom) { + if (partialOverrides == null) { + return List.of(); + } + if (partialRangeCache == null) { + partialRangeCache = CacheByZoom.create(this::computeLinearRangesAtZoom); + } + return partialRangeCache.get(zoom); + } + + private List computeLinearRangesAtZoom(int zoom) { + record Partial(boolean omit, Map attrs) { + Partial withOmit(boolean newValue) { + return new Partial(newValue || omit, attrs); + } + + Partial merge(Partial other) { + return new Partial(other.omit, MapUtil.merge(attrs, other.attrs)); + } + + Partial withAttr(String key, Object value) { + return new Partial(omit, MapUtil.with(attrs, key, value)); + } + } + MergingRangeMap result = MergingRangeMap.unit(new Partial(false, attrs), Partial::merge); + for (var override : partialOverrides) { + result.update(override.range(), m -> switch (override) { + case Attr attr -> + m.withAttr(attr.key, attr.value instanceof ZoomFunction fn ? fn.apply(zoom) : attr.value); + case Maxzoom mz -> m.withOmit(mz.maxzoom < zoom); + case Minzoom mz -> m.withOmit(mz.minzoom > zoom); + case Omit ignored -> m.withOmit(true); + }); + } + var ranges = result.result(); + List rangesWithGeometries = new ArrayList<>(ranges.size()); + for (var range : ranges) { + var value = range.value(); + if (!value.omit) { + try { + rangesWithGeometries.add(new RangeWithTags( + range.start(), + range.end(), + source.partialLine(range.start(), range.end()), + value.attrs + )); + } catch (GeometryException e) { + throw new IllegalStateException(e); + } + } + } + return rangesWithGeometries; + } + + + /** + * A builder that can be used to configure linear-scoped attributes for a partial segment of a line feature. + */ + public final class LinearRange implements WithZoomRange, WithAttrs { + + private final Range range; + + private LinearRange(Range range) { + this.range = range; + } + + private LinearRange add(OverrideCommand override) { + if (partialOverrides == null) { + partialOverrides = new ArrayList<>(); + } + partialOverrides.add(override); + return this; + } + + @Override + public LinearRange setMinZoom(int min) { + return add(new Minzoom(range, min)); + } + + @Override + public LinearRange setMaxZoom(int max) { + return add(new Maxzoom(range, max)); + } + + @Override + public LinearRange setAttr(String key, Object value) { + return add(new Attr(range, key, value)); + } + + /** Exclude this segment of the line feature at all zoom levels. */ + public LinearRange omit() { + return add(new Omit(range)); + } + + /** Returns the full line {@link Feature} that this segment came from. */ + public Feature entireLine() { + return Feature.this; + } + + /** + * Returns a segment of the full parent line (not the current segment) that can be configured further. + * + * @see Feature#linearRange(double, double) + */ + public LinearRange linearRange(double start, double end) { + return entireLine().linearRange(start, end); + } + + /** + * Returns a segment of the full parent line (not the current segment) that can be configured further. + * + * @see Feature#linearRange(Range) + */ + public LinearRange linearRange(Range range) { + return entireLine().linearRange(range); + } + + @Override + public FeatureCollector collector() { + return FeatureCollector.this; + } + } } } diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/geo/LineSplitter.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/geo/LineSplitter.java new file mode 100644 index 0000000000..45f367d28e --- /dev/null +++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/geo/LineSplitter.java @@ -0,0 +1,100 @@ +package com.onthegomap.planetiler.geo; + +import java.util.Arrays; +import org.locationtech.jts.geom.CoordinateSequence; +import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.geom.LineString; + +/** + * Utility for extracting sub-ranges of a line. + *

+ * For example: + * {@snippet : + * LineSplitter splitter = new LineSplitter(line); + * LineString firstHalf = splitter.get(0, 0.5); + * LineString lastQuarter = splitter.get(0.75, 1); + * } + */ +public class LineSplitter { + + private final LineString line; + private double length = 0; + private double[] nodeLocations = null; + + public LineSplitter(Geometry geom) { + if (geom instanceof LineString linestring) { + this.line = linestring; + } else { + throw new IllegalArgumentException("Expected LineString, got " + geom.getGeometryType()); + } + } + + /** + * Returns a partial segment of this line from {@code start} to {@code end} where 0 is the beginning of the line and 1 + * is the end. + */ + public LineString get(double start, double end) { + if (start < 0d || end > 1d || end < start) { + throw new IllegalArgumentException("Invalid range: " + start + " to " + end); + } + if (start <= 0 && end >= 1) { + return line; + } + var cs = line.getCoordinateSequence(); + if (nodeLocations == null) { + nodeLocations = new double[cs.size()]; + double x1 = cs.getX(0); + double y1 = cs.getY(0); + nodeLocations[0] = 0d; + for (int i = 1; i < cs.size(); i++) { + double x2 = cs.getX(i); + double y2 = cs.getY(i); + double dx = x2 - x1; + double dy = y2 - y1; + length += Math.sqrt(dx * dx + dy * dy); + nodeLocations[i] = length; + x1 = x2; + y1 = y2; + } + } + MutableCoordinateSequence result = new MutableCoordinateSequence(); + + double startPos = start * length; + double endPos = end * length; + var first = floorIndex(startPos); + var last = lowerIndex(endPos); + addInterpolated(result, cs, first, startPos); + for (int i = first + 1; i <= last; i++) { + result.addPoint(cs.getX(i), cs.getY(i)); + } + addInterpolated(result, cs, last, endPos); + + return GeoUtils.JTS_FACTORY.createLineString(result); + } + + private int floorIndex(double length) { + int idx = Arrays.binarySearch(nodeLocations, length); + return idx < 0 ? (-idx - 2) : idx; + } + + private int lowerIndex(double length) { + int idx = Arrays.binarySearch(nodeLocations, length); + return idx < 0 ? (-idx - 2) : idx - 1; + } + + private void addInterpolated(MutableCoordinateSequence result, CoordinateSequence cs, + int startIdx, double position) { + int endIdx = startIdx + 1; + double startPos = nodeLocations[startIdx]; + double endPos = nodeLocations[endIdx]; + double x1 = cs.getX(startIdx); + double y1 = cs.getY(startIdx); + double x2 = cs.getX(endIdx); + double y2 = cs.getY(endIdx); + double ratio = (position - startPos) / (endPos - startPos); + result.addPoint( + x1 + (x2 - x1) * ratio, + y1 + (y2 - y1) * ratio + ); + } +} diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/reader/SourceFeature.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/reader/SourceFeature.java index ab1dc9e46a..4f32a398db 100644 --- a/planetiler-core/src/main/java/com/onthegomap/planetiler/reader/SourceFeature.java +++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/reader/SourceFeature.java @@ -2,6 +2,7 @@ import com.onthegomap.planetiler.geo.GeoUtils; import com.onthegomap.planetiler.geo.GeometryException; +import com.onthegomap.planetiler.geo.LineSplitter; import com.onthegomap.planetiler.reader.osm.OsmReader; import com.onthegomap.planetiler.reader.osm.OsmRelationInfo; import java.util.ArrayList; @@ -43,6 +44,7 @@ public abstract class SourceFeature implements WithTags, WithGeometryType { private double area = Double.NaN; private double length = Double.NaN; private double size = Double.NaN; + private LineSplitter lineSplitter; /** * Constructs a new input feature. @@ -192,6 +194,27 @@ public final Geometry line() throws GeometryException { return linearGeometry; } + /** + * Returns a partial line string from {@code start} to {@code end} where 0 is the beginning of the line and 1 is the + * end of the line. + * + * @throws GeometryException if an error occurs constructing the geometry, or of this feature should not be + * interpreted as a single line (multilinestrings are not allowed). + */ + public final Geometry partialLine(double start, double end) throws GeometryException { + Geometry line = line(); + if (start <= 0 && end >= 1) { + return line; + } else if (line instanceof LineString lineString) { + if (this.lineSplitter == null) { + this.lineSplitter = new LineSplitter(lineString); + } + return lineSplitter.get(start, end); + } else { + throw new GeometryException("partial_multilinestring", "cannot get partial of a multiline", true); + } + } + /** * Computes this feature as a {@link Polygon} or {@link MultiPolygon} in world web mercator coordinates. * 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 545dcad90a..422f2271c2 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 @@ -174,10 +174,8 @@ private void renderPoint(FeatureCollector.Feature feature, MultiPoint points) { private void renderLineOrPolygon(FeatureCollector.Feature feature, Geometry input) { boolean area = input instanceof Polygonal; double worldLength = (area || input.getNumGeometries() > 1) ? 0 : input.getLength(); - String numPointsAttr = feature.getNumPointsAttr(); for (int z = feature.getMaxZoom(); z >= feature.getMinZoom(); z--) { double scale = 1 << z; - double tolerance = feature.getPixelToleranceAtZoom(z) / 256d; double minSize = feature.getMinPixelSizeAtZoom(z) / 256d; if (area) { // treat minPixelSize as the edge of a square that defines minimum area for features @@ -187,40 +185,55 @@ private void renderLineOrPolygon(FeatureCollector.Feature feature, Geometry inpu continue; } - 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; - Geometry geom = DouglasPeuckerSimplifier.simplify(scaled, tolerance); - List> groups = GeometryCoordinateSequences.extractGroups(geom, minSize); - try { - sliced = TiledGeometry.sliceIntoTiles(groups, buffer, area, z, extents); - } catch (GeometryException e) { - try { - geom = GeoUtils.fixPolygon(geom); - groups = GeometryCoordinateSequences.extractGroups(geom, minSize); - sliced = TiledGeometry.sliceIntoTiles(groups, buffer, area, z, extents); - } catch (GeometryException ex) { - ex.log(stats, "slice_line_or_polygon", "Error slicing feature at z" + z + ": " + feature); - // omit from this zoom level, but maybe the next will be better - 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); } - Map attrs = feature.getAttrsAtZoom(sliced.zoomLevel()); - 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(z, feature.getId(), feature, sliced, attrs); } 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; + 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; + Geometry geom = DouglasPeuckerSimplifier.simplify(scaled, tolerance); + List> groups = GeometryCoordinateSequences.extractGroups(geom, minSize); + try { + sliced = TiledGeometry.sliceIntoTiles(groups, buffer, area, z, extents); + } catch (GeometryException e) { + try { + geom = GeoUtils.fixPolygon(geom); + groups = GeometryCoordinateSequences.extractGroups(geom, minSize); + sliced = TiledGeometry.sliceIntoTiles(groups, buffer, area, z, extents); + } catch (GeometryException ex) { + ex.log(stats, "slice_line_or_polygon", "Error slicing feature at z" + z + ": " + feature); + // omit from this zoom level, but maybe the next will be better + return; + } + } + 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(z, feature.getId(), feature, sliced, attrs); + } + private void writeTileFeatures(int zoom, long id, FeatureCollector.Feature feature, TiledGeometry sliced, Map attrs) { int emitted = 0; diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/util/MapUtil.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/util/MapUtil.java new file mode 100644 index 0000000000..487f5dd339 --- /dev/null +++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/util/MapUtil.java @@ -0,0 +1,31 @@ +package com.onthegomap.planetiler.util; + +import java.util.HashMap; +import java.util.Map; + +public class MapUtil { + private MapUtil() {} + + /** + * Returns a new map with the union of entries from {@code a} and {@code b} where conflicts take the value from + * {@code b}. + */ + public static Map merge(Map a, Map b) { + Map copy = new HashMap<>(a); + copy.putAll(b); + return copy; + } + + /** + * Returns a new map the entries of {@code a} added to {@code (k, v)}. + */ + public static Map with(Map a, K k, V v) { + Map copy = new HashMap<>(a); + if (v == null || "".equals(v)) { + copy.remove(k); + } else { + copy.put(k, v); + } + return copy; + } +} diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/util/MergingRangeMap.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/util/MergingRangeMap.java new file mode 100644 index 0000000000..236386381b --- /dev/null +++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/util/MergingRangeMap.java @@ -0,0 +1,106 @@ +package com.onthegomap.planetiler.util; + +import com.google.common.collect.Range; +import com.google.common.collect.RangeMap; +import com.google.common.collect.TreeRangeMap; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.BinaryOperator; +import java.util.function.UnaryOperator; + +/** + * A mapping from disjoint ranges to values that merges values assigned to overlapping ranges. + *

+ * This is similar {@link RangeMap} that merges overlapping values and coalesces ranges with identical values by + * default. + *

+ * For example: + * {@snippet : + * MergingRangeMap map = MergingRangeMap.unitMap(); + * map.put(0, 0.5, Map.of("key", "value")); + * // overrides value for key from [0.4, 0.5), and sets from [0.5, 1) + * map.put(0.4, 1, Map.of("key", "value2")); + * // adds key2 from [0.9, 1) + * map.put(0.9, 1, Map.of("key2", "value3")); + * // returns [ + * // Partial[start=0.0, end=0.4, value={key=value}], + * // Partial[start=0.4, end=0.9, value={key=value2}], + * // Partial[start=0.9, end=1.0, value={key2=value3, key=value2}] + * // ] + * map.result(); + * } + */ +public class MergingRangeMap { + + private final RangeMap items = TreeRangeMap.create(); + private final BinaryOperator merger; + + private MergingRangeMap(double lo, double hi, T identity, BinaryOperator merger) { + items.put(Range.closedOpen(lo, hi), identity); + this.merger = merger; + } + + /** + * Returns a new range map where values are {@link Map Maps} that get merged together when they overlap from + * {@code [0, 1)}. + */ + public static MergingRangeMap> unitMap() { + return unit(Map.of(), MapUtil::merge); + } + + /** + * Returns a new range map with {@code identity} from {@code [0, 1)} and {@code merger} as the default merging + * function for {@link #put(Range, Object)}. + */ + public static MergingRangeMap unit(T identity, BinaryOperator merger) { + return create(0, 1, identity, merger); + } + + /** + * Returns a new range map with {@code identity} from {@code [lo, hi)} and {@code merger} as the default merging + * function for {@link #put(Range, Object)}. + */ + public static MergingRangeMap create(double lo, double hi, T identity, BinaryOperator merger) { + return new MergingRangeMap<>(lo, hi, identity, merger); + } + + /** Returns the distinct set of ranges and their values where adjacent maps with identical values are merged. */ + public List> result() { + List> result = new ArrayList<>(); + for (var entry : items.asMapOfRanges().entrySet()) { + result.add(new Partial<>(entry.getKey().lowerEndpoint(), entry.getKey().upperEndpoint(), entry.getValue())); + } + return result; + } + + /** + * Change each of the distinct values over {@code range} to the result of applying {@code operator} to the existing + * value. + */ + public void update(Range range, UnaryOperator operator) { + var overlaps = new ArrayList<>(items.subRangeMap(range).asMapOfRanges().entrySet()); + for (var overlap : overlaps) { + items.putCoalescing(overlap.getKey(), operator.apply(overlap.getValue())); + } + } + + /** + * Merge {@code next} into the value associated with all ranges that overlap {@code [start, end)} using the default + * merging function. + */ + public void put(double start, double end, T next) { + put(Range.closedOpen(start, end), next); + } + + /** + * Merge {@code next} into the value associated with all ranges that overlap {@code range} using the default merging + * function. + */ + public void put(Range range, T next) { + update(range, prev -> merger.apply(prev, next)); + } + + /** Subset of the range and value that applies to it. */ + public record Partial(double start, double end, T value) {} +} diff --git a/planetiler-core/src/test/java/com/onthegomap/planetiler/FeatureCollectorTest.java b/planetiler-core/src/test/java/com/onthegomap/planetiler/FeatureCollectorTest.java index e200140258..ab889de397 100644 --- a/planetiler-core/src/test/java/com/onthegomap/planetiler/FeatureCollectorTest.java +++ b/planetiler-core/src/test/java/com/onthegomap/planetiler/FeatureCollectorTest.java @@ -653,4 +653,99 @@ void testManyAttr() { ) ), collector); } + + @Test + void testPartialLineFeature() { + var collector = factory.get(newReaderFeature(newLineString(worldToLatLon(0, 0, 1, 0)), Map.of())); + collector.partialLine("layername", 0.25, 0.5).setAttr("k1", "v1"); + collector.partialLine("layername", 0.75, 1).setAttr("k2", "v2"); + assertFeatures(14, List.of( + Map.of( + "_geom", new RoundGeometry(newLineString(0.25, 0, 0.5, 0)), + "k1", "v1", + "k2", "" + ), + Map.of( + "_geom", new RoundGeometry(newLineString(0.75, 0, 1, 0)), + "k1", "", + "k2", "v2" + ) + ), collector); + } + + @Test + void testLinearReferenceTags() { + var collector = factory.get(newReaderFeature(newLineString(worldToLatLon(0, 0, 1, 0)), Map.of())); + collector.line("layername") + .linearRange(0.1, 0.5).setAttr("k1", "v1") + .linearRange(0.3, 0.7).setAttr("k2", "v2") + .entireLine().setAttr("k3", "v3"); + + var feature = collector.iterator().next(); + assertTrue(feature.hasLinearRanges()); + assertEquals(List.of( + new FeatureCollector.RangeWithTags(0, 0.1, roundTrip(newLineString(0, 0, 0.1, 0)), Map.of( + "k3", "v3" + )), + new FeatureCollector.RangeWithTags(0.1, 0.3, roundTrip(newLineString(0.1, 0, 0.3, 0)), Map.of( + "k1", "v1", + "k3", "v3" + )), + new FeatureCollector.RangeWithTags(0.3, 0.5, roundTrip(newLineString(0.3, 0, 0.5, 0)), Map.of( + "k1", "v1", + "k2", "v2", + "k3", "v3" + )), + new FeatureCollector.RangeWithTags(0.5, 0.7, roundTrip(newLineString(0.5, 0, 0.7, 0)), Map.of( + "k2", "v2", + "k3", "v3" + )), + new FeatureCollector.RangeWithTags(0.7, 1, roundTrip(newLineString(0.7, 0, 1, 0)), Map.of( + "k3", "v3" + )) + ), feature.getLinearRangesAtZoom(14)); + } + + @Test + void testPartialMinzoom() { + var collector = factory.get(newReaderFeature(newLineString(worldToLatLon(0, 0, 1, 0)), Map.of())); + collector.line("layername") + .linearRange(0.25, 0.75).setMinZoom(14); + assertFeatures(13, List.of( + Map.of("_geom", new RoundGeometry(newLineString(0, 0, 1, 0))) + ), collector); + assertFeatures(14, List.of( + Map.of("_geom", new RoundGeometry(newLineString(0, 0, 1, 0))) + ), collector); + var feature = collector.iterator().next(); + assertTrue(feature.hasLinearRanges()); + assertEquals(List.of( + new FeatureCollector.RangeWithTags(0, 0.25, roundTrip(newLineString(0, 0, 0.25, 0)), Map.of()), + new FeatureCollector.RangeWithTags(0.75, 1, roundTrip(newLineString(0.75, 0, 1, 0)), Map.of()) + ), feature.getLinearRangesAtZoom(13)); + assertEquals(List.of( + new FeatureCollector.RangeWithTags(0, 1, roundTrip(newLineString(0, 0, 1, 0)), Map.of()) + ), feature.getLinearRangesAtZoom(14)); + } + + private static Geometry roundTrip(Geometry world) { + return GeoUtils.latLonToWorldCoords(GeoUtils.worldToLatLonCoords(world)); + } + + @Test + void testPartialOmit() { + var collector = factory.get(newReaderFeature(newLineString(worldToLatLon(0, 0, 1, 0)), Map.of())); + collector.line("layername") + .linearRange(0.25, 0.75).omit(); + var feature = collector.iterator().next(); + assertTrue(feature.hasLinearRanges()); + assertEquals(List.of( + new FeatureCollector.RangeWithTags(0, 0.25, roundTrip(newLineString(0, 0, 0.25, 0)), Map.of()), + new FeatureCollector.RangeWithTags(0.75, 1, roundTrip(newLineString(0.75, 0, 1, 0)), Map.of()) + ), feature.getLinearRangesAtZoom(13)); + assertEquals(List.of( + new FeatureCollector.RangeWithTags(0, 0.25, roundTrip(newLineString(0, 0, 0.25, 0)), Map.of()), + new FeatureCollector.RangeWithTags(0.75, 1, roundTrip(newLineString(0.75, 0, 1, 0)), Map.of()) + ), feature.getLinearRangesAtZoom(14)); + } } 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 d8d71292f3..2c19d9d2f3 100644 --- a/planetiler-core/src/test/java/com/onthegomap/planetiler/PlanetilerTests.java +++ b/planetiler-core/src/test/java/com/onthegomap/planetiler/PlanetilerTests.java @@ -474,6 +474,81 @@ void testLineString() throws Exception { ), results.tiles); } + @Test + void testPartialLine() 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 lat1 = GeoUtils.getWorldLat(y1); + double lng1 = GeoUtils.getWorldLon(x1); + 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( + "attr", "value" + )) + ), + (in, features) -> features.partialLine("layer", 0, 0.5) + .setZoomRange(13, 14) + .setBufferPixels(4) + ); + + assertSubmap(Map.of( + TileCoord.ofXYZ(Z14_TILES / 2, Z14_TILES / 2, 14), List.of( + feature(newLineString(128, 128, 256, 256), Map.of()) + ), + TileCoord.ofXYZ(Z14_TILES / 2 + 1, Z14_TILES / 2 + 1, 14), List.of( + feature(newLineString(-4, -4, 0, 0), Map.of()) + ), + TileCoord.ofXYZ(Z13_TILES / 2, Z13_TILES / 2, 13), List.of( + feature(newLineString(64, 64, 128, 128), Map.of()) + ) + ), results.tiles); + } + + @Test + void testLineWithPartialAttr() 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 lat1 = GeoUtils.getWorldLat(y1); + double lng1 = GeoUtils.getWorldLon(x1); + 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( + "attr", "value" + )) + ), + (in, features) -> features.line("layer") + .linearRange(0, 0.25).setAttrWithMinzoom("k", "v", 14) + .entireLine() + .setZoomRange(13, 14) + .setBufferPixels(4) + ); + + assertSubmap(Map.of( + TileCoord.ofXYZ(Z14_TILES / 2, Z14_TILES / 2, 14), List.of( + feature(newLineString(192, 192, 260, 260), Map.of()), + feature(newLineString(128, 128, 192, 192), Map.of("k", "v")) + ), + TileCoord.ofXYZ(Z14_TILES / 2 + 1, Z14_TILES / 2 + 1, 14), List.of( + feature(newLineString(-4, -4, 128, 128), Map.of()) + ), + TileCoord.ofXYZ(Z13_TILES / 2, Z13_TILES / 2, 13), List.of( + feature(newLineString(64, 64, 192, 192), Map.of()) + ) + ), results.tiles); + } + @Test void testLineStringDegenerateWhenUnscaled() throws Exception { double x1 = 0.5 + Z12_WIDTH / 2; diff --git a/planetiler-core/src/test/java/com/onthegomap/planetiler/TestUtils.java b/planetiler-core/src/test/java/com/onthegomap/planetiler/TestUtils.java index 0d68343e83..8e2729dca9 100644 --- a/planetiler-core/src/test/java/com/onthegomap/planetiler/TestUtils.java +++ b/planetiler-core/src/test/java/com/onthegomap/planetiler/TestUtils.java @@ -428,6 +428,24 @@ public int hashCode() { } } + public record RoundGeometry(Geometry geom) implements GeometryComparision { + + @Override + public boolean equals(Object o) { + return o instanceof GeometryComparision that && round(geom).equalsNorm(round(that.geom())); + } + + @Override + public String toString() { + return "Round{" + round(geom).norm() + '}'; + } + + @Override + public int hashCode() { + return 0; + } + } + private record ExactGeometry(Geometry geom) implements GeometryComparision { @Override diff --git a/planetiler-core/src/test/java/com/onthegomap/planetiler/geo/LineSplitterTest.java b/planetiler-core/src/test/java/com/onthegomap/planetiler/geo/LineSplitterTest.java new file mode 100644 index 0000000000..859bd5aa94 --- /dev/null +++ b/planetiler-core/src/test/java/com/onthegomap/planetiler/geo/LineSplitterTest.java @@ -0,0 +1,67 @@ +package com.onthegomap.planetiler.geo; + +import static com.onthegomap.planetiler.TestUtils.newLineString; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +class LineSplitterTest { + @ParameterizedTest + @CsvSource({ + "0,1", + "0,0.25", + "0.75, 1", + "0.25, 0.75", + }) + void testSingleSegment(double start, double end) { + var l = new LineSplitter(newLineString(0, 0, 2, 1)); + assertEquals( + newLineString(start * 2, start, end * 2, end), + l.get(start, end) + ); + } + + @Test + void testLength2() { + var l = new LineSplitter(newLineString(0, 0, 1, 2, 2, 4)); + assertEquals( + newLineString(0, 0, 0.5, 1), + l.get(0, 0.25) + ); + assertEquals( + newLineString(0.2, 0.4, 0.5, 1), + l.get(0.1, 0.25) + ); + assertEquals( + newLineString(0.5, 1, 1, 2), + l.get(0.25, 0.5) + ); + assertEquals( + newLineString(0.5, 1, 1, 2, 1.5, 3), + l.get(0.25, 0.75) + ); + assertEquals( + newLineString(1, 2, 1.5, 3), + l.get(0.5, 0.75) + ); + assertEquals( + newLineString(1.2, 2.4, 1.5, 3), + l.get(0.6, 0.75) + ); + assertEquals( + newLineString(1.5, 3, 2, 4), + l.get(0.75, 1) + ); + } + + @Test + void testInvalid() { + var l = new LineSplitter(newLineString(0, 0, 1, 2, 2, 4)); + assertThrows(IllegalArgumentException.class, () -> l.get(-0.1, 0.5)); + assertThrows(IllegalArgumentException.class, () -> l.get(0.9, 1.1)); + assertThrows(IllegalArgumentException.class, () -> l.get(0.6, 0.5)); + } +} 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 549f7569ff..315b03c4e9 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 @@ -12,6 +12,7 @@ import com.onthegomap.planetiler.config.Arguments; import com.onthegomap.planetiler.config.PlanetilerConfig; import com.onthegomap.planetiler.geo.GeoUtils; +import com.onthegomap.planetiler.geo.GeometryException; import com.onthegomap.planetiler.geo.TileCoord; import com.onthegomap.planetiler.reader.SimpleFeature; import com.onthegomap.planetiler.stats.Stats; @@ -23,8 +24,10 @@ import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; import java.util.TreeMap; import java.util.concurrent.atomic.AtomicLong; +import java.util.stream.Collectors; import java.util.stream.DoubleStream; import org.junit.jupiter.api.DynamicTest; import org.junit.jupiter.api.Test; @@ -1425,4 +1428,64 @@ void testSpiral(double dx, double dy) { actual ); } + + @Test + void testLinearRangeFeature() { + 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 + Z14_PX * 10 + ) + ).linearRange(0.5, 1).setAttr("k", "v").entireLine(); + Map> rendered = renderFeatures(feature); + assertEquals( + Set.of( + List.of(newLineString(128 + 5, 128 + 5, 128 + 10, 128 + 10), Map.of("k", "v")), + List.of(newLineString(128, 128, 128 + 5, 128 + 5), Map.of()) + ), + rendered.get(TileCoord.ofXYZ(Z14_TILES / 2, Z14_TILES / 2, 14)).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()) + ); + } + + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void testLinearRangeFeaturePartialMinzoom(boolean viaMinzoom) { + var feature = lineFeature( + newLineString( + 0.5 + Z13_WIDTH / 2, 0.5 + Z13_WIDTH / 2, + 0.5 + Z13_WIDTH / 2 + Z13_PX * 10, 0.5 + Z13_WIDTH / 2 + Z13_PX * 10 + ) + ); + if (viaMinzoom) { + feature.linearRange(0.5, 1).setMinZoom(14); + } else { + feature.linearRange(0.5, 1).omit(); + } + Map> rendered = renderFeatures(feature); + assertEquals( + Set.of( + List.of(newLineString(128, 128, 128 + 5, 128 + 5), Map.of()) + ), + rendered.get(TileCoord.ofXYZ(Z13_TILES / 2, Z13_TILES / 2, 13)).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()) + ); + } } diff --git a/planetiler-core/src/test/java/com/onthegomap/planetiler/util/MergingRangeMapTest.java b/planetiler-core/src/test/java/com/onthegomap/planetiler/util/MergingRangeMapTest.java new file mode 100644 index 0000000000..36576dbd2f --- /dev/null +++ b/planetiler-core/src/test/java/com/onthegomap/planetiler/util/MergingRangeMapTest.java @@ -0,0 +1,69 @@ +package com.onthegomap.planetiler.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class MergingRangeMapTest { + + @Test + void empty() { + var map = MergingRangeMap.unitMap(); + assertEquals( + List.of( + new MergingRangeMap.Partial<>(0, 1.0, Map.of()) + ), + map.result() + ); + } + + @Test + void testPartialOverlap() { + var map = MergingRangeMap.unitMap(); + map.put(0.25, 0.75, Map.of("b", 3, "c", 4)); + map.put(0.5, 1.0, Map.of("a", 1, "b", 2)); + assertEquals( + List.of( + new MergingRangeMap.Partial<>(0, 0.25, Map.of()), + new MergingRangeMap.Partial<>(0.25, 0.5, Map.of("b", 3, "c", 4)), + new MergingRangeMap.Partial<>(0.5, 0.75, Map.of("a", 1, "b", 2, "c", 4)), + new MergingRangeMap.Partial<>(0.75, 1.0, Map.of("a", 1, "b", 2)) + ), + map.result() + ); + } + + @Test + void testPutSingle() { + var map = MergingRangeMap.unitMap(); + map.put(0.25, 0.75, Map.of("b", 3)); + map.put(0.25, 0.75, Map.of("c", 4)); + map.put(0.5, 1.0, Map.of("a", 1)); + map.put(0.5, 1.0, Map.of("b", 2)); + assertEquals( + List.of( + new MergingRangeMap.Partial<>(0, 0.25, Map.of()), + new MergingRangeMap.Partial<>(0.25, 0.5, Map.of("b", 3, "c", 4)), + new MergingRangeMap.Partial<>(0.5, 0.75, Map.of("a", 1, "b", 2, "c", 4)), + new MergingRangeMap.Partial<>(0.75, 1.0, Map.of("a", 1, "b", 2)) + ), + map.result() + ); + } + + @Test + void testDuplicateKeys() { + var map = MergingRangeMap.unitMap(); + map.put(0.25, 0.75, Map.of("a", 1)); + map.put(0.5, 1.0, Map.of("a", 1)); + assertEquals( + List.of( + new MergingRangeMap.Partial<>(0, 0.25, Map.of()), + new MergingRangeMap.Partial<>(0.25, 1d, Map.of("a", 1)) + ), + map.result() + ); + } +}