kopia lustrzana https://github.com/onthegomap/planetiler
				
				
				
			Add support for linear-referenced tags. (#900)
							rodzic
							
								
									9dbd5d358e
								
							
						
					
					
						commit
						21f061efe0
					
				|  | @ -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; | ||||
|   } | ||||
| } | ||||
|  | @ -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.reader.SourceFeature; | |||
| 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 @@ import org.locationtech.jts.geom.Geometry; | |||
|  * <p> | ||||
|  * 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 class FeatureCollector implements Iterable<FeatureCollector.Feature> { | |||
|     } | ||||
|   } | ||||
| 
 | ||||
| 
 | ||||
|   /** | ||||
|    * 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. | ||||
|    * <p> | ||||
|    * 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. | ||||
|    * <p> | ||||
|  | @ -222,6 +246,128 @@ public class FeatureCollector implements Iterable<FeatureCollector.Feature> { | |||
|     } | ||||
|   } | ||||
| 
 | ||||
|   private sealed interface OverrideCommand { | ||||
|     Range<Double> range(); | ||||
|   } | ||||
|   private record Minzoom(Range<Double> range, int minzoom) implements OverrideCommand {} | ||||
|   private record Maxzoom(Range<Double> range, int maxzoom) implements OverrideCommand {} | ||||
|   private record Omit(Range<Double> range) implements OverrideCommand {} | ||||
|   private record Attr(Range<Double> range, String key, Object value) implements OverrideCommand {} | ||||
| 
 | ||||
|   public interface WithZoomRange<T extends WithZoomRange<T>> { | ||||
| 
 | ||||
|     /** | ||||
|      * Sets the zoom range (inclusive) that this feature appears in. | ||||
|      * <p> | ||||
|      * 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. | ||||
|      * <p> | ||||
|      * 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. | ||||
|      * <p> | ||||
|      * If not called, defaults to maximum zoom-level of the map. | ||||
|      */ | ||||
|     T setMaxZoom(int max); | ||||
|   } | ||||
| 
 | ||||
|   public interface WithSelf<T extends WithSelf<T>> { | ||||
| 
 | ||||
|     default T self() { | ||||
|       return (T) this; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   public interface WithAttrs<T extends WithAttrs<T>> extends WithSelf<T> { | ||||
| 
 | ||||
|     /** 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. | ||||
|      * <p> | ||||
|      * 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. | ||||
|      * <p> | ||||
|      * 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}. | ||||
|      * <p> | ||||
|      * Replace values that have already been set. | ||||
|      */ | ||||
|     default T putAttrsWithMinzoom(Map<String, Object> 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. | ||||
|      * <p> | ||||
|      * Does not touch attributes that have already been set. | ||||
|      * <p> | ||||
|      * 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<String, Object> 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 class FeatureCollector implements Iterable<FeatureCollector.Feature> { | |||
|     } | ||||
|   } | ||||
| 
 | ||||
|   private record PartialOverride(Range<Double> 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<String, Object> 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 class FeatureCollector implements Iterable<FeatureCollector.Feature> { | |||
|    * 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<Feature>, WithAttrs<Feature> { | ||||
| 
 | ||||
|     private static final double DEFAULT_LABEL_GRID_SIZE = 0; | ||||
|     private static final int DEFAULT_LABEL_GRID_LIMIT = 0; | ||||
|  | @ -260,6 +411,7 @@ public class FeatureCollector implements Iterable<FeatureCollector.Feature> { | |||
| 
 | ||||
|     private boolean attrsChangeByZoom = false; | ||||
|     private CacheByZoom<Map<String, Object>> attrCache = null; | ||||
|     private CacheByZoom<List<RangeWithTags>> partialRangeCache = null; | ||||
| 
 | ||||
|     private double defaultBufferPixels = 4; | ||||
|     private ZoomFunction<Number> bufferPixelOverrides; | ||||
|  | @ -274,6 +426,7 @@ public class FeatureCollector implements Iterable<FeatureCollector.Feature> { | |||
|     private ZoomFunction<Number> pixelTolerance = null; | ||||
| 
 | ||||
|     private String numPointsAttr = null; | ||||
|     private List<OverrideCommand> partialOverrides = null; | ||||
| 
 | ||||
|     private Feature(String layer, Geometry geom, long id) { | ||||
|       this.layer = layer; | ||||
|  | @ -335,27 +488,12 @@ public class FeatureCollector implements Iterable<FeatureCollector.Feature> { | |||
|       return setSortKey(FeatureGroup.SORT_KEY_MAX + FeatureGroup.SORT_KEY_MIN - sortKey); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Sets the zoom range (inclusive) that this feature appears in. | ||||
|      * <p> | ||||
|      * 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. | ||||
|      * <p> | ||||
|      * 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 class FeatureCollector implements Iterable<FeatureCollector.Feature> { | |||
|       return maxzoom; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Sets the maximum zoom level (inclusive) that this feature appears in. | ||||
|      * <p> | ||||
|      * 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 class FeatureCollector implements Iterable<FeatureCollector.Feature> { | |||
|       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 class FeatureCollector implements Iterable<FeatureCollector.Feature> { | |||
|       return this; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Sets the value for {@code key} attribute at or above {@code minzoom}. Below {@code minzoom} it will be ignored. | ||||
|      * <p> | ||||
|      * 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. | ||||
|      * <p> | ||||
|      * 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}. | ||||
|      * <p> | ||||
|      * Replace values that have already been set. | ||||
|      */ | ||||
|     public Feature putAttrsWithMinzoom(Map<String, Object> 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. | ||||
|      * <p> | ||||
|      * Does not touch attributes that have already been set. | ||||
|      * <p> | ||||
|      * 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<String, Object> attrs) { | ||||
|       for (Object value : attrs.values()) { | ||||
|         if (value instanceof ZoomFunction) { | ||||
|  | @ -780,6 +853,11 @@ public class FeatureCollector implements Iterable<FeatureCollector.Feature> { | |||
|       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 class FeatureCollector implements Iterable<FeatureCollector.Feature> { | |||
|     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. | ||||
|      * <p> | ||||
|      * 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. | ||||
|      * <p> | ||||
|      * 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<Double> 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<RangeWithTags> getLinearRangesAtZoom(int zoom) { | ||||
|       if (partialOverrides == null) { | ||||
|         return List.of(); | ||||
|       } | ||||
|       if (partialRangeCache == null) { | ||||
|         partialRangeCache = CacheByZoom.create(this::computeLinearRangesAtZoom); | ||||
|       } | ||||
|       return partialRangeCache.get(zoom); | ||||
|     } | ||||
| 
 | ||||
|     private List<RangeWithTags> computeLinearRangesAtZoom(int zoom) { | ||||
|       record Partial(boolean omit, Map<String, Object> 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<Partial> 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<RangeWithTags> 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<LinearRange>, WithAttrs<LinearRange> { | ||||
| 
 | ||||
|       private final Range<Double> range; | ||||
| 
 | ||||
|       private LinearRange(Range<Double> 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<Double> range) { | ||||
|         return entireLine().linearRange(range); | ||||
|       } | ||||
| 
 | ||||
|       @Override | ||||
|       public FeatureCollector collector() { | ||||
|         return FeatureCollector.this; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  |  | |||
|  | @ -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. | ||||
|  * <p> | ||||
|  * 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 | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | @ -2,6 +2,7 @@ package com.onthegomap.planetiler.reader; | |||
| 
 | ||||
| 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 abstract class SourceFeature implements WithTags, WithGeometryType { | |||
|     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. | ||||
|    * | ||||
|  |  | |||
|  | @ -174,10 +174,8 @@ public class FeatureRenderer implements Consumer<FeatureCollector.Feature>, Clos | |||
|   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 @@ public class FeatureRenderer implements Consumer<FeatureCollector.Feature>, Clos | |||
|         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<List<CoordinateSequence>> 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<String, Object> 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<String, Object> 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<List<CoordinateSequence>> 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<String, Object> attrs) { | ||||
|     int emitted = 0; | ||||
|  |  | |||
|  | @ -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 <K, V> Map<K, V> merge(Map<K, V> a, Map<K, V> b) { | ||||
|     Map<K, V> 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 <K, V> Map<K, V> with(Map<K, V> a, K k, V v) { | ||||
|     Map<K, V> copy = new HashMap<>(a); | ||||
|     if (v == null || "".equals(v)) { | ||||
|       copy.remove(k); | ||||
|     } else { | ||||
|       copy.put(k, v); | ||||
|     } | ||||
|     return copy; | ||||
|   } | ||||
| } | ||||
|  | @ -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. | ||||
|  * <p> | ||||
|  * This is similar {@link RangeMap} that merges overlapping values and coalesces ranges with identical values by | ||||
|  * default. | ||||
|  * <p> | ||||
|  * 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<T> { | ||||
| 
 | ||||
|   private final RangeMap<Double, T> items = TreeRangeMap.create(); | ||||
|   private final BinaryOperator<T> merger; | ||||
| 
 | ||||
|   private MergingRangeMap(double lo, double hi, T identity, BinaryOperator<T> 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 <K, V> MergingRangeMap<Map<K, V>> 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 <T> MergingRangeMap<T> unit(T identity, BinaryOperator<T> 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 <T> MergingRangeMap<T> create(double lo, double hi, T identity, BinaryOperator<T> 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<Partial<T>> result() { | ||||
|     List<Partial<T>> 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<Double> range, UnaryOperator<T> 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<Double> range, T next) { | ||||
|     update(range, prev -> merger.apply(prev, next)); | ||||
|   } | ||||
| 
 | ||||
|   /** Subset of the range and value that applies to it. */ | ||||
|   public record Partial<T>(double start, double end, T value) {} | ||||
| } | ||||
|  | @ -653,4 +653,99 @@ class FeatureCollectorTest { | |||
|       ) | ||||
|     ), 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", "<null>" | ||||
|       ), | ||||
|       Map.of( | ||||
|         "_geom", new RoundGeometry(newLineString(0.75, 0, 1, 0)), | ||||
|         "k1", "<null>", | ||||
|         "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)); | ||||
|   } | ||||
| } | ||||
|  |  | |||
|  | @ -474,6 +474,81 @@ class PlanetilerTests { | |||
|     ), 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; | ||||
|  |  | |||
|  | @ -428,6 +428,24 @@ public class TestUtils { | |||
|     } | ||||
|   } | ||||
| 
 | ||||
|   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 | ||||
|  |  | |||
|  | @ -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)); | ||||
|   } | ||||
| } | ||||
|  | @ -12,6 +12,7 @@ import com.onthegomap.planetiler.TestUtils; | |||
| 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.HashSet; | |||
| 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 @@ class FeatureRendererTest { | |||
|       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<TileCoord, Collection<RenderedFeature>> 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<TileCoord, Collection<RenderedFeature>> 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()) | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  |  | |||
|  | @ -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() | ||||
|     ); | ||||
|   } | ||||
| } | ||||
		Ładowanie…
	
		Reference in New Issue
	
	 Michael Barry
						Michael Barry