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