diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/geo/Unit.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/geo/Unit.java new file mode 100644 index 0000000000..eb57c3c713 --- /dev/null +++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/geo/Unit.java @@ -0,0 +1,214 @@ +package com.onthegomap.planetiler.geo; + +import com.onthegomap.planetiler.util.ToDoubleFunctionThatThrows; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import systems.uom.common.USCustomary; + +/** Units of length and area measurement based off of constants defined in {@link USCustomary}. */ +public interface Unit { + + Pattern EXTRA_CHARS = Pattern.compile("[^a-z]+"); + Pattern TRAILING_S = Pattern.compile("s$"); + + private static Map index(T[] values) { + return Arrays.stream(values) + .flatMap(unit -> Stream.concat(unit.symbols().stream(), Stream.of(unit.unitName(), unit.toString())) + .map(label -> Map.entry(normalize(label), unit)) + .distinct()) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + } + + private static String normalize(String unit) { + String result = EXTRA_CHARS.matcher(unit.toLowerCase()).replaceAll(""); + return TRAILING_S.matcher(result).replaceAll(""); + } + + /** The {@link Base} measurement this unit is based off of. */ + Base base(); + + /** Computes the size of {@code geometry} in this unit. */ + double of(WithGeometry geometry); + + /** The aliases for this unit. */ + List symbols(); + + /** The full name for this unit. */ + String unitName(); + + /** Converts a measurement in {@link Base} units to this unit. */ + double fromBaseUnit(double base); + + /** The base units that all other units are derived from. */ + enum Base { + /** Size of a feature in "z0 tiles" where 1=the length/width/area entire world. */ + Z0_TILE( + WithGeometry::length, + WithGeometry::area), + /** Size of a feature in meters. */ + METER( + WithGeometry::lengthMeters, + WithGeometry::areaMeters); + + private final ToDoubleFunctionThatThrows area; + private final ToDoubleFunctionThatThrows length; + + Base(ToDoubleFunctionThatThrows length, ToDoubleFunctionThatThrows area) { + this.length = length; + this.area = area; + } + + public double area(WithGeometry geometry) { + return area.applyAndWrapException(geometry); + } + + public double length(WithGeometry geometry) { + return length.applyAndWrapException(geometry); + } + } + + /** Units to measure line length. */ + enum Length implements Unit { + METER(USCustomary.METER, "m"), + FOOT(USCustomary.FOOT, "ft", "feet"), + YARD(USCustomary.YARD, "yd"), + NAUTICAL_MILE(USCustomary.NAUTICAL_MILE, "nm"), + MILE(USCustomary.MILE, "mi", "miles"), + KILOMETER(Base.METER, 1e-3, List.of("km"), "Kilometer"), + + Z0_PIXEL(Base.Z0_TILE, 1d / 256, List.of("z0_px"), "Z0 Pixel"), + Z0_TILE(Base.Z0_TILE, 1d, List.of("z0_ti"), "Z0 Tile"); + + private static final Map NAMES = index(values()); + private final Base base; + private final double multiplier; + private final List symbols; + private final String name; + + Length(Base base, double multiplier, List symbols, String name) { + this.base = base; + this.multiplier = multiplier; + this.symbols = Stream.concat(symbols.stream(), Stream.of(name, name())).distinct().toList(); + this.name = name; + } + + Length(javax.measure.Unit from, String... alias) { + this(Base.METER, USCustomary.METER.getConverterTo(from).convert(1d), List.of(alias), from.getName()); + } + + public static Length from(String label) { + Length unit = NAMES.get(normalize(label)); + if (unit == null) { + throw new IllegalArgumentException("Could not find area unit for '%s'".formatted(label)); + } + return unit; + } + + @Override + public double fromBaseUnit(double i) { + return i * multiplier; + } + + @Override + public Base base() { + return base; + } + + @Override + public double of(WithGeometry geometry) { + return fromBaseUnit(base.length.applyAndWrapException(geometry)); + } + + @Override + public List symbols() { + return symbols; + } + + @Override + public String unitName() { + return name; + } + } + + /** Units to measure polygon areas. */ + enum Area implements Unit { + SQUARE_METER(Length.METER), + SQUARE_FOOT(Length.FOOT), + SQUARE_YARD(Length.YARD), + SQUARE_NAUTICAL_MILE(Length.NAUTICAL_MILE), + SQUARE_MILE(Length.MILE), + SQUARE_KILOMETER(Length.KILOMETER), + + SQUARE_Z0_PIXEL(Length.Z0_PIXEL), + SQUARE_Z0_TILE(Length.Z0_TILE), + + ARE(USCustomary.ARE, "a"), + HECTARE(USCustomary.HECTARE, "ha"), + ACRE(USCustomary.ACRE, "ac"); + + private static final Map NAMES = index(values()); + private final Base base; + private final double multiplier; + private final List symbols; + private final String name; + + Area(Base base, double multiplier, List symbols, String name) { + this.base = base; + this.multiplier = multiplier; + this.symbols = symbols; + this.name = name; + } + + Area(Length length) { + this(length.base, length.multiplier * length.multiplier, + length.symbols().stream().flatMap(symbol -> Stream.of( + "s" + symbol, + "square " + symbol, + "sq " + symbol, + symbol + "2" + )).toList(), + "Square " + length.name); + } + + Area(javax.measure.Unit area, String... symbols) { + this(Base.METER, USCustomary.ARE.getConverterTo(area).convert(0.01d), List.of(symbols), area.getName()); + } + + public static Area from(String label) { + Area unit = NAMES.get(normalize(label)); + if (unit == null) { + throw new IllegalArgumentException("Could not find area unit for '%s'".formatted(label)); + } + return unit; + } + + @Override + public double fromBaseUnit(double base) { + return base * multiplier; + } + + @Override + public Base base() { + return base; + } + + @Override + public double of(WithGeometry geometry) { + return fromBaseUnit(base.area.applyAndWrapException(geometry)); + } + + @Override + public List symbols() { + return symbols; + } + + @Override + public String unitName() { + return name; + } + } +} diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/geo/WithGeometry.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/geo/WithGeometry.java new file mode 100644 index 0000000000..6e2d669906 --- /dev/null +++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/geo/WithGeometry.java @@ -0,0 +1,344 @@ +package com.onthegomap.planetiler.geo; + +import com.onthegomap.planetiler.reader.WithGeometryType; +import org.locationtech.jts.algorithm.construct.MaximumInscribedCircle; +import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.geom.LineString; +import org.locationtech.jts.geom.Lineal; +import org.locationtech.jts.geom.MultiLineString; +import org.locationtech.jts.geom.MultiPolygon; +import org.locationtech.jts.geom.Polygon; +import org.locationtech.jts.geom.Polygonal; +import org.locationtech.jts.geom.Puntal; + +/** + * Wraps a geometry and provides cached accessor methods for applying transformations and transforming to lat/lon. + *

+ * All geometries except for {@link #latLonGeometry()} return elements in world web mercator coordinates where (0,0) is + * the northwest corner and (1,1) is the southeast corner of the planet. + */ +public abstract class WithGeometry implements WithGeometryType { + private Geometry centroid = null; + private Geometry pointOnSurface = null; + private Geometry centroidIfConvex = null; + private double innermostPointTolerance = Double.NaN; + private Geometry innermostPoint = null; + private Geometry linearGeometry = null; + private Geometry polygonGeometry = null; + private Geometry validPolygon = null; + private double area = Double.NaN; + private double length = Double.NaN; + private double areaMeters = Double.NaN; + private double lengthMeters = Double.NaN; + private LineSplitter lineSplitter; + + + /** + * Returns a geometry in world web mercator coordinates. + * + * @return the geometry in web mercator coordinates + * @throws GeometryException if an unexpected but recoverable error occurs creating this geometry that should + * be logged for debugging + * @throws GeometryException.Verbose if an expected error occurs creating this geometry that will be logged at a lower + * log level + */ + public abstract Geometry worldGeometry() throws GeometryException; + + + /** + * Returns this geometry in latitude/longitude degree coordinates. + * + * @return the latitude/longitude geometry + * @throws GeometryException if an unexpected but recoverable error occurs creating this geometry that should + * be logged for debugging + * @throws GeometryException.Verbose if an expected error occurs creating this geometry that will be logged at a lower + * log level + */ + public abstract Geometry latLonGeometry() throws GeometryException; + + + /** + * Returns and caches the result of {@link Geometry#getArea()} of this feature in world web mercator coordinates where + * {@code 1} means the area of the entire planet. + */ + public double area() throws GeometryException { + return Double.isNaN(area) ? (area = canBePolygon() ? Math.abs(polygon().getArea()) : 0) : area; + } + + /** + * Returns and caches the result of {@link Geometry#getLength()} of this feature in world web mercator coordinates + * where {@code 1} means the circumference of the entire planet or the distance from 85 degrees north to 85 degrees + * south. + */ + public double length() throws GeometryException { + return Double.isNaN(length) ? (length = + (isPoint() || canBePolygon() || canBeLine()) ? worldGeometry().getLength() : 0) : length; + } + + /** + * Returns the sqrt of {@link #area()} if polygon or {@link #length()} if a line string. + */ + public double size() throws GeometryException { + return canBePolygon() ? Math.sqrt(Math.abs(area())) : canBeLine() ? length() : 0; + } + + /** Returns the approximate area of the geometry in square meters. */ + public double areaMeters() throws GeometryException { + return Double.isNaN(areaMeters) ? (areaMeters = + (isPoint() || canBePolygon() || canBeLine()) ? GeoUtils.areaInMeters(latLonGeometry()) : 0) : areaMeters; + } + + /** Returns the approximate length of the geometry in meters. */ + public double lengthMeters() throws GeometryException { + return Double.isNaN(lengthMeters) ? (lengthMeters = + (isPoint() || canBePolygon() || canBeLine()) ? GeoUtils.lengthInMeters(latLonGeometry()) : 0) : lengthMeters; + } + + /** Returns the sqrt of {@link #areaMeters()} if polygon or {@link #lengthMeters()} if a line string. */ + public double sizeMeters() throws GeometryException { + return canBePolygon() ? Math.sqrt(Math.abs(areaMeters())) : canBeLine() ? lengthMeters() : 0; + } + + + /** Returns the length of this geometry in units of {@link Unit.Length}. */ + public double length(Unit.Length length) { + return length.of(this); + } + + /** + * Returns the length of this geometry if it is a line or the square root of the area if it is a polygon in units of + * {@link Unit.Length}. + */ + public double size(Unit.Length length) { + return canBePolygon() ? Math.sqrt(length.base().area(this)) : length.base().length(this); + } + + /** Returns the area of this geometry in units of {@link Unit.Area}. */ + public double area(Unit.Area area) { + return area.of(this); + } + + /** Returns and caches {@link Geometry#getCentroid()} of this geometry in world web mercator coordinates. */ + public final Geometry centroid() throws GeometryException { + return centroid != null ? centroid : (centroid = + canBePolygon() ? polygon().getCentroid() : + canBeLine() ? line().getCentroid() : + worldGeometry().getCentroid()); + } + + /** Returns and caches {@link Geometry#getInteriorPoint()} of this geometry in world web mercator coordinates. */ + public final Geometry pointOnSurface() throws GeometryException { + return pointOnSurface != null ? pointOnSurface : (pointOnSurface = + canBePolygon() ? polygon().getInteriorPoint() : + canBeLine() ? line().getInteriorPoint() : + worldGeometry().getInteriorPoint()); + } + + /** + * Returns {@link MaximumInscribedCircle#getCenter()} of this geometry in world web mercator coordinates. + * + * @param tolerance precision for calculating maximum inscribed circle. 0.01 means 1% of the square root of the area. + * Smaller values for a more precise tolerance become very expensive to compute. Values between + * 0.05-0.1 are a good compromise of performance vs. precision. + */ + public final Geometry innermostPoint(double tolerance) throws GeometryException { + if (canBePolygon()) { + // cache as long as the tolerance hasn't changed + if (tolerance != innermostPointTolerance || innermostPoint == null) { + innermostPoint = MaximumInscribedCircle.getCenter(polygon(), Math.sqrt(area()) * tolerance); + innermostPointTolerance = tolerance; + } + return innermostPoint; + } else if (canBeLine()) { + return lineMidpoint(); + } else { + return pointOnSurface(); + } + } + + /** + * Returns the midpoint of this line, or the longest segment if it is a multilinestring. + */ + public final Geometry lineMidpoint() throws GeometryException { + if (innermostPoint == null) { + innermostPoint = pointAlongLine(0.5); + } + return innermostPoint; + } + + /** + * Returns along this line where {@code ratio=0} is the start {@code ratio=1} is the end and {@code ratio=0.5} is the + * midpoint. + *

+ * When this is a multilinestring, the longest segment is used. + */ + public final Geometry pointAlongLine(double ratio) throws GeometryException { + if (lineSplitter == null) { + var line = line(); + lineSplitter = new LineSplitter(line instanceof MultiLineString multi ? GeoUtils.getLongestLine(multi) : line); + } + return lineSplitter.get(ratio); + } + + private Geometry computeCentroidIfConvex() throws GeometryException { + if (!canBePolygon()) { + return centroid(); + } else if (polygon() instanceof Polygon poly && + poly.getNumInteriorRing() == 0 && + GeoUtils.isConvex(poly.getExteriorRing())) { + return centroid(); + } else { // multipolygon, polygon with holes, or concave polygon + return pointOnSurface(); + } + } + + /** + * Returns and caches a point inside the geometry in world web mercator coordinates. + *

+ * If the geometry is convex, uses the faster {@link Geometry#getCentroid()} but otherwise falls back to the slower + * {@link Geometry#getInteriorPoint()}. + */ + public final Geometry centroidIfConvex() throws GeometryException { + return centroidIfConvex != null ? centroidIfConvex : (centroidIfConvex = computeCentroidIfConvex()); + } + + /** + * Computes this feature as a {@link LineString} or {@link MultiLineString} in world web mercator coordinates. + * + * @return the linestring in web mercator coordinates + * @throws GeometryException if an unexpected but recoverable error occurs creating this geometry that should + * be logged for debugging + * @throws GeometryException.Verbose if an expected error occurs creating this geometry that will be logged at a lower + * log level + */ + protected Geometry computeLine() throws GeometryException { + Geometry world = worldGeometry(); + return world instanceof Lineal ? world : GeoUtils.polygonToLineString(world); + } + + /** + * Returns this feature as a {@link LineString} or {@link MultiLineString} in world web mercator coordinates. + * + * @throws GeometryException if an error occurs constructing the geometry, or of this feature should not be + * interpreted as a line + */ + public final Geometry line() throws GeometryException { + if (!canBeLine()) { + throw new GeometryException("feature_not_line", "cannot be line", true); + } + if (linearGeometry == null) { + linearGeometry = computeLine(); + } + 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. + * + * @return the polygon in web mercator coordinates + * @throws GeometryException if an unexpected but recoverable error occurs creating this geometry that should + * be logged for debugging + * @throws GeometryException.Verbose if an expected error occurs creating this geometry that will be logged at a lower + * log level + */ + protected Geometry computePolygon() throws GeometryException { + return worldGeometry(); + } + + /** + * Returns this feature as a {@link Polygon} or {@link MultiPolygon} in world web mercator coordinates. + * + * @throws GeometryException if an error occurs constructing the geometry, or of this feature should not be + * interpreted as a line + */ + public final Geometry polygon() throws GeometryException { + if (!canBePolygon()) { + throw new GeometryException("feature_not_polygon", "cannot be polygon", true); + } + return polygonGeometry != null ? polygonGeometry : (polygonGeometry = computePolygon()); + } + + private Geometry computeValidPolygon() throws GeometryException { + Geometry polygon = polygon(); + if (!polygon.isValid()) { + polygon = GeoUtils.fixPolygon(polygon); + } + return polygon; + } + + /** + * Returns this feature as a valid {@link Polygon} or {@link MultiPolygon} in world web mercator coordinates. + *

+ * Validating and fixing invalid polygons can be expensive, so use only if necessary. Invalid polygons will also be + * fixed at render-time. + * + * @throws GeometryException if an error occurs constructing the geometry, or of this feature should not be + * interpreted as a line + */ + public final Geometry validatedPolygon() throws GeometryException { + if (!canBePolygon()) { + throw new GeometryException("feature_not_polygon", "cannot be polygon", true); + } + return validPolygon != null ? validPolygon : (validPolygon = computeValidPolygon()); + } + + /** Wraps a world web mercator geometry. */ + public static WithGeometry fromWorldGeometry(Geometry worldGeometry) { + return new FromWorld(worldGeometry); + } + + private static class FromWorld extends WithGeometry { + private final Geometry worldGeometry; + private Geometry latLonGeometry; + + FromWorld(Geometry worldGeometry) { + this.worldGeometry = worldGeometry; + } + + @Override + public Geometry worldGeometry() { + return worldGeometry; + } + + @Override + public Geometry latLonGeometry() { + return latLonGeometry != null ? latLonGeometry : (latLonGeometry = GeoUtils.worldToLatLonCoords(worldGeometry)); + } + + @Override + public boolean isPoint() { + return worldGeometry instanceof Puntal; + } + + @Override + public boolean canBePolygon() { + return worldGeometry instanceof Polygonal; + } + + @Override + public boolean canBeLine() { + return worldGeometry instanceof Lineal; + } + } +} 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 f2d51fb197..8439a80893 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 @@ -1,20 +1,11 @@ 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.geo.WithGeometry; import com.onthegomap.planetiler.reader.osm.OsmReader; import com.onthegomap.planetiler.reader.osm.OsmRelationInfo; import java.util.ArrayList; import java.util.List; import java.util.Map; -import org.locationtech.jts.algorithm.construct.MaximumInscribedCircle; -import org.locationtech.jts.geom.Geometry; -import org.locationtech.jts.geom.LineString; -import org.locationtech.jts.geom.Lineal; -import org.locationtech.jts.geom.MultiLineString; -import org.locationtech.jts.geom.MultiPolygon; -import org.locationtech.jts.geom.Polygon; /** * Base class for input features read from a data source. @@ -26,26 +17,14 @@ * All geometries except for {@link #latLonGeometry()} return elements in world web mercator coordinates where (0,0) is * the northwest corner and (1,1) is the southeast corner of the planet. */ -public abstract class SourceFeature implements WithTags, WithGeometryType, WithSource, WithSourceLayer { +public abstract class SourceFeature extends WithGeometry + implements WithTags, WithSource, WithSourceLayer { private final Map tags; private final String source; private final String sourceLayer; private final List> relationInfos; private final long id; - private Geometry centroid = null; - private Geometry pointOnSurface = null; - private Geometry centroidIfConvex = null; - private double innermostPointTolerance = Double.NaN; - private Geometry innermostPoint = null; - private Geometry linearGeometry = null; - private Geometry polygonGeometry = null; - private Geometry validPolygon = null; - private double area = Double.NaN; - private double length = Double.NaN; - private double areaMeters = Double.NaN; - private double lengthMeters = Double.NaN; - private LineSplitter lineSplitter; /** * Constructs a new input feature. @@ -72,255 +51,6 @@ public Map tags() { return tags; } - /** - * Returns this feature's geometry in latitude/longitude degree coordinates. - * - * @return the latitude/longitude geometry - * @throws GeometryException if an unexpected but recoverable error occurs creating this geometry that should - * be logged for debugging - * @throws GeometryException.Verbose if an expected error occurs creating this geometry that will be logged at a lower - * log level - */ - public abstract Geometry latLonGeometry() throws GeometryException; - - /** - * Returns this feature's geometry in world web mercator coordinates. - * - * @return the geometry in web mercator coordinates - * @throws GeometryException if an unexpected but recoverable error occurs creating this geometry that should - * be logged for debugging - * @throws GeometryException.Verbose if an expected error occurs creating this geometry that will be logged at a lower - * log level - */ - public abstract Geometry worldGeometry() throws GeometryException; - - /** Returns and caches {@link Geometry#getCentroid()} of this geometry in world web mercator coordinates. */ - public final Geometry centroid() throws GeometryException { - return centroid != null ? centroid : (centroid = - canBePolygon() ? polygon().getCentroid() : - canBeLine() ? line().getCentroid() : - worldGeometry().getCentroid()); - } - - /** Returns and caches {@link Geometry#getInteriorPoint()} of this geometry in world web mercator coordinates. */ - public final Geometry pointOnSurface() throws GeometryException { - return pointOnSurface != null ? pointOnSurface : (pointOnSurface = - canBePolygon() ? polygon().getInteriorPoint() : - canBeLine() ? line().getInteriorPoint() : - worldGeometry().getInteriorPoint()); - } - - /** - * Returns {@link MaximumInscribedCircle#getCenter()} of this geometry in world web mercator coordinates. - * - * @param tolerance precision for calculating maximum inscribed circle. 0.01 means 1% of the square root of the area. - * Smaller values for a more precise tolerance become very expensive to compute. Values between - * 0.05-0.1 are a good compromise of performance vs. precision. - */ - public final Geometry innermostPoint(double tolerance) throws GeometryException { - if (canBePolygon()) { - // cache as long as the tolerance hasn't changed - if (tolerance != innermostPointTolerance || innermostPoint == null) { - innermostPoint = MaximumInscribedCircle.getCenter(polygon(), Math.sqrt(area()) * tolerance); - innermostPointTolerance = tolerance; - } - return innermostPoint; - } else if (canBeLine()) { - return lineMidpoint(); - } else { - return pointOnSurface(); - } - } - - /** - * Returns the midpoint of this line, or the longest segment if it is a multilinestring. - */ - public final Geometry lineMidpoint() throws GeometryException { - if (innermostPoint == null) { - innermostPoint = pointAlongLine(0.5); - } - return innermostPoint; - } - - /** - * Returns along this line where {@code ratio=0} is the start {@code ratio=1} is the end and {@code ratio=0.5} is the - * midpoint. - *

- * When this is a multilinestring, the longest segment is used. - */ - public final Geometry pointAlongLine(double ratio) throws GeometryException { - if (lineSplitter == null) { - var line = line(); - lineSplitter = new LineSplitter(line instanceof MultiLineString multi ? GeoUtils.getLongestLine(multi) : line); - } - return lineSplitter.get(ratio); - } - - private Geometry computeCentroidIfConvex() throws GeometryException { - if (!canBePolygon()) { - return centroid(); - } else if (polygon() instanceof Polygon poly && - poly.getNumInteriorRing() == 0 && - GeoUtils.isConvex(poly.getExteriorRing())) { - return centroid(); - } else { // multipolygon, polygon with holes, or concave polygon - return pointOnSurface(); - } - } - - /** - * Returns and caches a point inside the geometry in world web mercator coordinates. - *

- * If the geometry is convex, uses the faster {@link Geometry#getCentroid()} but otherwise falls back to the slower - * {@link Geometry#getInteriorPoint()}. - */ - public final Geometry centroidIfConvex() throws GeometryException { - return centroidIfConvex != null ? centroidIfConvex : (centroidIfConvex = computeCentroidIfConvex()); - } - - /** - * Computes this feature as a {@link LineString} or {@link MultiLineString} in world web mercator coordinates. - * - * @return the linestring in web mercator coordinates - * @throws GeometryException if an unexpected but recoverable error occurs creating this geometry that should - * be logged for debugging - * @throws GeometryException.Verbose if an expected error occurs creating this geometry that will be logged at a lower - * log level - */ - protected Geometry computeLine() throws GeometryException { - Geometry world = worldGeometry(); - return world instanceof Lineal ? world : GeoUtils.polygonToLineString(world); - } - - /** - * Returns this feature as a {@link LineString} or {@link MultiLineString} in world web mercator coordinates. - * - * @throws GeometryException if an error occurs constructing the geometry, or of this feature should not be - * interpreted as a line - */ - public final Geometry line() throws GeometryException { - if (!canBeLine()) { - throw new GeometryException("feature_not_line", "cannot be line", true); - } - if (linearGeometry == null) { - linearGeometry = computeLine(); - } - 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. - * - * @return the polygon in web mercator coordinates - * @throws GeometryException if an unexpected but recoverable error occurs creating this geometry that should - * be logged for debugging - * @throws GeometryException.Verbose if an expected error occurs creating this geometry that will be logged at a lower - * log level - */ - protected Geometry computePolygon() throws GeometryException { - return worldGeometry(); - } - - /** - * Returns this feature as a {@link Polygon} or {@link MultiPolygon} in world web mercator coordinates. - * - * @throws GeometryException if an error occurs constructing the geometry, or of this feature should not be - * interpreted as a line - */ - public final Geometry polygon() throws GeometryException { - if (!canBePolygon()) { - throw new GeometryException("feature_not_polygon", "cannot be polygon", true); - } - return polygonGeometry != null ? polygonGeometry : (polygonGeometry = computePolygon()); - } - - private Geometry computeValidPolygon() throws GeometryException { - Geometry polygon = polygon(); - if (!polygon.isValid()) { - polygon = GeoUtils.fixPolygon(polygon); - } - return polygon; - } - - /** - * Returns this feature as a valid {@link Polygon} or {@link MultiPolygon} in world web mercator coordinates. - *

- * Validating and fixing invalid polygons can be expensive, so use only if necessary. Invalid polygons will also be - * fixed at render-time. - * - * @throws GeometryException if an error occurs constructing the geometry, or of this feature should not be - * interpreted as a line - */ - public final Geometry validatedPolygon() throws GeometryException { - if (!canBePolygon()) { - throw new GeometryException("feature_not_polygon", "cannot be polygon", true); - } - return validPolygon != null ? validPolygon : (validPolygon = computeValidPolygon()); - } - - /** - * Returns and caches the result of {@link Geometry#getArea()} of this feature in world web mercator coordinates where - * {@code 1} means the area of the entire planet. - */ - public double area() throws GeometryException { - return Double.isNaN(area) ? (area = canBePolygon() ? Math.abs(polygon().getArea()) : 0) : area; - } - - /** - * Returns and caches the result of {@link Geometry#getLength()} of this feature in world web mercator coordinates - * where {@code 1} means the circumference of the entire planet or the distance from 85 degrees north to 85 degrees - * south. - */ - public double length() throws GeometryException { - return Double.isNaN(length) ? (length = - (isPoint() || canBePolygon() || canBeLine()) ? worldGeometry().getLength() : 0) : length; - } - - /** - * Returns the sqrt of {@link #area()} if polygon or {@link #length()} if a line string. - */ - public double size() throws GeometryException { - return canBePolygon() ? Math.sqrt(Math.abs(area())) : canBeLine() ? length() : 0; - } - - /** Returns and caches the approximate area of the geometry in square meters. */ - public double areaMeters() throws GeometryException { - return Double.isNaN(areaMeters) ? (areaMeters = - (isPoint() || canBePolygon() || canBeLine()) ? GeoUtils.areaInMeters(latLonGeometry()) : 0) : areaMeters; - } - - /** Returns and caches the approximate length of the geometry in meters. */ - public double lengthMeters() throws GeometryException { - return Double.isNaN(lengthMeters) ? (lengthMeters = - (isPoint() || canBePolygon() || canBeLine()) ? GeoUtils.lengthInMeters(latLonGeometry()) : 0) : lengthMeters; - } - - /** Returns the sqrt of {@link #areaMeters()} if polygon or {@link #lengthMeters()} if a line string. */ - public double sizeMeters() throws GeometryException { - return canBePolygon() ? Math.sqrt(Math.abs(areaMeters())) : canBeLine() ? lengthMeters() : 0; - } - /** Returns the ID of the source that this feature came from. */ @Override public String getSource() { diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/util/ToDoubleFunctionThatThrows.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/util/ToDoubleFunctionThatThrows.java new file mode 100644 index 0000000000..2092944c58 --- /dev/null +++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/util/ToDoubleFunctionThatThrows.java @@ -0,0 +1,18 @@ +package com.onthegomap.planetiler.util; + +import static com.onthegomap.planetiler.util.Exceptions.throwFatalException; + +@FunctionalInterface +public interface ToDoubleFunctionThatThrows { + + @SuppressWarnings("java:S112") + double applyAsDouble(I value) throws Exception; + + default double applyAndWrapException(I value) { + try { + return applyAsDouble(value); + } catch (Exception e) { + return throwFatalException(e); + } + } +} diff --git a/planetiler-core/src/test/java/com/onthegomap/planetiler/geo/UnitTest.java b/planetiler-core/src/test/java/com/onthegomap/planetiler/geo/UnitTest.java new file mode 100644 index 0000000000..668060f2c8 --- /dev/null +++ b/planetiler-core/src/test/java/com/onthegomap/planetiler/geo/UnitTest.java @@ -0,0 +1,50 @@ +package com.onthegomap.planetiler.geo; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +class UnitTest { + @ParameterizedTest + @CsvSource({ + "METER, 1, m; meters; metre; metres", + "KILOMETER, 0.001, km; kilometers", + "FOOT, 3.28084, ft; feet; foot", + "MILE, 0.000621371, mi; mile; miles", + "NAUTICAL_MILE, 0.000539957, nm; nautical miles", + "YARD, 1.0936136964129, yd; yards; yds", + + "Z0_PIXEL, 0.00390625, z0px; z0 pixels; z0 pixel", + "Z0_TILE, 1, z0ti; z0tile; z0 tile; z0_tiles; z0_tiles; z0 tiles", + }) + void testLengthAndDerivedArea(String name, double expected, String aliases) { + Unit.Length length = Unit.Length.from(name); + Unit.Area area = Unit.Area.from("SQUARE_" + name); + assertEquals(expected, length.fromBaseUnit(1), expected / 1e5); + double expectedArea = expected * expected; + assertEquals(expected * expected, area.fromBaseUnit(1), expectedArea / 1e5); + + for (String alias : aliases.split(";")) { + assertEquals(length, Unit.Length.from(alias), alias); + assertEquals(area, Unit.Area.from("s" + alias), "s" + alias); + assertEquals(area, Unit.Area.from("sq " + alias), "sq " + alias); + assertEquals(area, Unit.Area.from("square " + alias), "square " + alias); + assertEquals(area, Unit.Area.from(alias + "2"), alias + "2"); + } + } + + @ParameterizedTest + @CsvSource({ + "ARE, 0.01, a; ares", + "HECTARE, 0.0001, ha; hectares", + "ACRE, 0.000247105, ac; acres", + }) + void testCustomArea(String name, double expected, String aliases) { + Unit.Area area = Unit.Area.valueOf(name); + assertEquals(expected, area.fromBaseUnit(1), expected / 1e5); + for (String alias : aliases.split(";")) { + assertEquals(area, Unit.Area.from(alias)); + } + } +} diff --git a/planetiler-custommap/README.md b/planetiler-custommap/README.md index b10c74ed75..aafd1072be 100644 --- a/planetiler-custommap/README.md +++ b/planetiler-custommap/README.md @@ -506,6 +506,38 @@ Additional variables, on top of the root context: - `feature.osm_user_name` - optional name of the OSM user that last modified this feature - `feature.osm_type` - type of the OSM element as a string: `"node"`, `"way"`, or `"relation"` +On the original feature or any accessor that returns a geometry, you can also use: + +- `feature.length("unit")` - length of the feature if it is a line, 0 otherwise. Allowed units: "meters"/"m", "feet" + /"ft", "yards"/"yd", "nautical miles"/"nm", "kilometer"/"km" for units relative to the size in meters, or "z0 tiles"/" + z0 ti", "z0 pixels"/"z0 px" for sizes relative to the size of the geometry when projected into a z0 web mercator tile + containing the entire world. +- `feature.area("unit")` - area of the feature if it is a polygon, 0 otherwise. Allowed units: any length unit like " + km2", "mi2", or "z0 px2" or also "acres"/"ac", "hectares"/"ha", or "ares"/"a". +- `feature.min_lat` / `feature.min_lon` / `feature.max_lat` / `feature.max_lon` - returns coordinates from the bounding + box of this geometry +- `feature.lat` / `feature.lon` - returns the coordinate of an arbitrary point on this shape (useful to get the lat/lon + of a point) +- `feature.bbox` - returns the rectangle bounding box that contains this entire shape +- `feature.centroid` - returns the weighted center point of the geometry, which may fall outside the the shape +- `feature.point_on_surface` - returns a point that is within the shape (on the line, or inside the polygon) +- `feature.validated_polygon` - if this is a polygon, fixes any self-intersections and returns the result +- `feature.centroid_if_convex` - returns point_on_surface if this is a concave polygon, or centroid if convex +- `feature.line_midpoint` - returns midpoint of this feature if it is a line +- `feature.point_along_line(amount)` - when amount=0 returns the start of the line, when amount=1 returns the end, + otherwise a point at a certain ratio along the line +- `feature.partial_line(start, end)` - returns a partial line segment from start to end where 0=the beginning of the + line and 1=the end +- `feature.innermost_point` / `feature.innermost_point(tolerance)` - returns the midpoint of a line, or + the [pole of inaccessibility](https://en.wikipedia.org/wiki/Pole_of_inaccessibility) if it is a polygon + +For example: + +```yaml +key: bbox_area_km2 +value: ${ feature.bbox.area('km2') } +``` + ##### 3. Post-Match Context Context available after a feature has matched, for example computing an attribute value. diff --git a/planetiler-custommap/src/main/java/com/onthegomap/planetiler/custommap/Contexts.java b/planetiler-custommap/src/main/java/com/onthegomap/planetiler/custommap/Contexts.java index f01a8a41f4..56055ce4b6 100644 --- a/planetiler-custommap/src/main/java/com/onthegomap/planetiler/custommap/Contexts.java +++ b/planetiler-custommap/src/main/java/com/onthegomap/planetiler/custommap/Contexts.java @@ -10,6 +10,7 @@ import com.onthegomap.planetiler.custommap.expression.ParseException; import com.onthegomap.planetiler.custommap.expression.ScriptContext; import com.onthegomap.planetiler.custommap.expression.ScriptEnvironment; +import com.onthegomap.planetiler.custommap.expression.stdlib.GeometryVal; import com.onthegomap.planetiler.expression.DataType; import com.onthegomap.planetiler.reader.SourceFeature; import com.onthegomap.planetiler.reader.WithGeometryType; @@ -362,6 +363,7 @@ public record ProcessFeature( private static final String FEATURE_OSM_USER_ID = "feature.osm_user_id"; private static final String FEATURE_OSM_USER_NAME = "feature.osm_user_name"; private static final String FEATURE_OSM_TYPE = "feature.osm_type"; + private static final String FEATURE_GEOMETRY = "feature"; public static ScriptEnvironment description(Root root) { return root.description() @@ -376,7 +378,8 @@ public static ScriptEnvironment description(Root root) { Decls.newVar(FEATURE_OSM_TIMESTAMP, Decls.Int), Decls.newVar(FEATURE_OSM_USER_ID, Decls.Int), Decls.newVar(FEATURE_OSM_USER_NAME, Decls.String), - Decls.newVar(FEATURE_OSM_TYPE, Decls.String) + Decls.newVar(FEATURE_OSM_TYPE, Decls.String), + Decls.newVar(FEATURE_GEOMETRY, GeometryVal.PROTO_TYPE) ); } @@ -388,6 +391,7 @@ public Object apply(String key) { case FEATURE_ID -> feature.id(); case FEATURE_SOURCE -> feature.getSource(); case FEATURE_SOURCE_LAYER -> wrapNullable(feature.getSourceLayer()); + case FEATURE_GEOMETRY -> new GeometryVal(feature); default -> { OsmElement elem = feature instanceof OsmSourceFeature osm ? osm.originalElement() : null; if (FEATURE_OSM_TYPE.equals(key)) { diff --git a/planetiler-custommap/src/main/java/com/onthegomap/planetiler/custommap/expression/ConfigExpressionScript.java b/planetiler-custommap/src/main/java/com/onthegomap/planetiler/custommap/expression/ConfigExpressionScript.java index f83c2c2a55..d90adea8e0 100644 --- a/planetiler-custommap/src/main/java/com/onthegomap/planetiler/custommap/expression/ConfigExpressionScript.java +++ b/planetiler-custommap/src/main/java/com/onthegomap/planetiler/custommap/expression/ConfigExpressionScript.java @@ -1,7 +1,9 @@ package com.onthegomap.planetiler.custommap.expression; import com.onthegomap.planetiler.custommap.TypeConversion; +import com.onthegomap.planetiler.custommap.expression.stdlib.GeometryVal; import com.onthegomap.planetiler.custommap.expression.stdlib.PlanetilerStdLib; +import com.onthegomap.planetiler.custommap.expression.stdlib.PlanetilerTypeRegistry; import com.onthegomap.planetiler.util.Memoized; import com.onthegomap.planetiler.util.Try; import java.util.Objects; @@ -98,7 +100,8 @@ public static ConfigExpressionScript parse( */ public static ConfigExpressionScript parse(String string, ScriptEnvironment description, Class expected) { - ScriptHost scriptHost = ScriptHost.newBuilder().build(); + var scriptHost = ScriptHost.newBuilder().registry(PlanetilerTypeRegistry.INSTANCE) + .build(); try { var scriptBuilder = scriptHost.buildScript(string).withLibraries( new StringsLib(), @@ -107,6 +110,7 @@ public static ConfigExpressionScript parse(St if (!description.declarations().isEmpty()) { scriptBuilder.withDeclarations(description.declarations()); } + scriptBuilder.withTypes(GeometryVal.PROTO_TYPE); var script = scriptBuilder.build(); return new ConfigExpressionScript<>(string, script, description, expected); diff --git a/planetiler-custommap/src/main/java/com/onthegomap/planetiler/custommap/expression/stdlib/GeometryVal.java b/planetiler-custommap/src/main/java/com/onthegomap/planetiler/custommap/expression/stdlib/GeometryVal.java new file mode 100644 index 0000000000..133e384aed --- /dev/null +++ b/planetiler-custommap/src/main/java/com/onthegomap/planetiler/custommap/expression/stdlib/GeometryVal.java @@ -0,0 +1,120 @@ +package com.onthegomap.planetiler.custommap.expression.stdlib; + +import static org.projectnessie.cel.common.types.Err.newTypeConversionError; +import static org.projectnessie.cel.common.types.Err.noSuchOverload; +import static org.projectnessie.cel.common.types.Types.boolOf; + +import com.onthegomap.planetiler.geo.GeoUtils; +import com.onthegomap.planetiler.geo.WithGeometry; +import com.onthegomap.planetiler.util.FunctionThatThrows; +import com.onthegomap.planetiler.util.ToDoubleFunctionThatThrows; +import java.util.Map; +import java.util.Objects; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.locationtech.jts.geom.Geometry; +import org.projectnessie.cel.checker.Decls; +import org.projectnessie.cel.common.types.DoubleT; +import org.projectnessie.cel.common.types.Err; +import org.projectnessie.cel.common.types.StringT; +import org.projectnessie.cel.common.types.TypeT; +import org.projectnessie.cel.common.types.ref.BaseVal; +import org.projectnessie.cel.common.types.ref.Type; +import org.projectnessie.cel.common.types.ref.Val; +import org.projectnessie.cel.common.types.traits.FieldTester; +import org.projectnessie.cel.common.types.traits.Indexer; + +/** Wrapper for a geometry that exposes utility functions to CEL expressions. */ +public class GeometryVal extends BaseVal implements Indexer, FieldTester { + public static final String NAME = "geometry"; + public static final com.google.api.expr.v1alpha1.Type PROTO_TYPE = Decls.newObjectType(NAME); + private static final Type TYPE = TypeT.newObjectTypeValue(NAME); + private final WithGeometry geometry; + private static final Map FIELDS = Stream.of( + doubleField("lat", geom -> GeoUtils.getWorldLat(geom.worldGeometry().getCoordinate().getY())), + doubleField("lon", geom -> GeoUtils.getWorldLon(geom.worldGeometry().getCoordinate().getX())), + doubleField("min_lat", geom -> geom.latLonGeometry().getEnvelopeInternal().getMinY()), + doubleField("max_lat", geom -> geom.latLonGeometry().getEnvelopeInternal().getMaxY()), + doubleField("min_lon", geom -> geom.latLonGeometry().getEnvelopeInternal().getMinX()), + doubleField("max_lon", geom -> geom.latLonGeometry().getEnvelopeInternal().getMaxX()), + geometryField("bbox", geom -> geom.worldGeometry().getEnvelope()), + geometryField("centroid", WithGeometry::centroid), + geometryField("centroid_if_convex", WithGeometry::centroidIfConvex), + geometryField("validated_polygon", WithGeometry::validatedPolygon), + geometryField("point_on_surface", WithGeometry::pointOnSurface), + geometryField("line_midpoint", WithGeometry::lineMidpoint), + geometryField("innermost_point", geom -> geom.innermostPoint(0.1)) + ).collect(Collectors.toMap(field -> field.name, Function.identity())); + + public static GeometryVal fromWorldGeom(Geometry geometry) { + return new GeometryVal(WithGeometry.fromWorldGeometry(geometry)); + } + + record Field(String name, com.google.api.expr.v1alpha1.Type type, FunctionThatThrows getter) {} + + private static Field doubleField(String name, ToDoubleFunctionThatThrows getter) { + return new Field(name, Decls.Double, geom -> DoubleT.doubleOf(getter.applyAsDouble(geom))); + } + + private static Field geometryField(String name, FunctionThatThrows getter) { + return new Field(name, PROTO_TYPE, geom -> new GeometryVal(WithGeometry.fromWorldGeometry(getter.apply(geom)))); + } + + public GeometryVal(WithGeometry geometry) { + this.geometry = geometry; + } + + public static com.google.api.expr.v1alpha1.Type fieldType(String fieldName) { + var field = FIELDS.get(fieldName); + return field == null ? null : field.type; + } + + @Override + public T convertToNative(Class typeDesc) { + return typeDesc.isInstance(geometry) ? typeDesc.cast(geometry) : null; + } + + @Override + public Val convertToType(Type typeValue) { + return newTypeConversionError(TYPE, typeValue); + } + + @Override + public Val equal(Val other) { + return boolOf(other instanceof GeometryVal val && Objects.equals(val.geometry, geometry)); + } + + @Override + public Type type() { + return TYPE; + } + + @Override + public Object value() { + return geometry; + } + + @Override + public Val isSet(Val field) { + if (!(field instanceof StringT)) { + return noSuchOverload(this, "isSet", field); + } + String fieldName = (String) field.value(); + return boolOf(FIELDS.containsKey(fieldName)); + } + + @Override + public Val get(Val index) { + if (!(index instanceof StringT)) { + return noSuchOverload(this, "get", index); + } + String fieldName = (String) index.value(); + try { + var field = FIELDS.get(fieldName); + return field.getter.apply(geometry); + } catch (Exception err) { + return Err.newErr(err, "Error getting %s", fieldName); + } + } +} diff --git a/planetiler-custommap/src/main/java/com/onthegomap/planetiler/custommap/expression/stdlib/PlanetilerStdLib.java b/planetiler-custommap/src/main/java/com/onthegomap/planetiler/custommap/expression/stdlib/PlanetilerStdLib.java index 506df133c7..df316bdb9f 100644 --- a/planetiler-custommap/src/main/java/com/onthegomap/planetiler/custommap/expression/stdlib/PlanetilerStdLib.java +++ b/planetiler-custommap/src/main/java/com/onthegomap/planetiler/custommap/expression/stdlib/PlanetilerStdLib.java @@ -3,6 +3,9 @@ import static org.projectnessie.cel.checker.Decls.newOverload; import com.google.api.expr.v1alpha1.Type; +import com.onthegomap.planetiler.geo.GeometryException; +import com.onthegomap.planetiler.geo.Unit; +import com.onthegomap.planetiler.geo.WithGeometry; import java.util.List; import java.util.Objects; import java.util.function.DoubleBinaryOperator; @@ -162,6 +165,83 @@ public PlanetilerStdLib() { Decls.newOverload("max_double", List.of(Decls.newListType(Decls.Double)), Decls.Double) ), Overload.unary("max", list -> reduceNumeric(list, Math::max, Math::max)) + ), + + new BuiltInFunction( + Decls.newFunction("area", + Decls.newInstanceOverload("area", List.of(GeometryVal.PROTO_TYPE, Decls.String), Decls.Double) + ), + Overload.binary("area", + (a, b) -> DoubleT + .doubleOf(a.convertToNative(WithGeometry.class).area(Unit.Area.from(b.convertToNative(String.class))))) + ), + + new BuiltInFunction( + Decls.newFunction("length", + Decls.newInstanceOverload("length", List.of(GeometryVal.PROTO_TYPE, Decls.String), Decls.Double) + ), + Overload.binary("length", + (a, b) -> DoubleT + .doubleOf(a.convertToNative(WithGeometry.class).length(Unit.Length.from(b.convertToNative(String.class))))) + ), + + new BuiltInFunction( + Decls.newFunction("point_along_line", + Decls.newInstanceOverload("point_along_line_double", List.of(GeometryVal.PROTO_TYPE, Decls.Double), + GeometryVal.PROTO_TYPE), + Decls.newInstanceOverload("point_along_line_int", List.of(GeometryVal.PROTO_TYPE, Decls.Int), + GeometryVal.PROTO_TYPE) + ), + Overload.binary("point_along_line", + (a, b) -> { + try { + return GeometryVal.fromWorldGeom(a.convertToNative(WithGeometry.class).pointAlongLine(b.doubleValue())); + } catch (GeometryException e) { + return Err.newErr(e, "Unable to compute point_along_line(%d)", b.doubleValue()); + } + }) + ), + + new BuiltInFunction( + Decls.newFunction("innermost_point", + Decls.newInstanceOverload("innermost_point_double", List.of(GeometryVal.PROTO_TYPE, Decls.Double), + GeometryVal.PROTO_TYPE), + Decls.newInstanceOverload("innermost_point_int", List.of(GeometryVal.PROTO_TYPE, Decls.Int), + GeometryVal.PROTO_TYPE) + ), + Overload.binary("innermost_point", + (a, b) -> { + try { + return GeometryVal.fromWorldGeom(a.convertToNative(WithGeometry.class).innermostPoint(b.doubleValue())); + } catch (GeometryException e) { + return Err.newErr(e, "Unable to compute innermost_point(%d)", b.doubleValue()); + } + }) + ), + + new BuiltInFunction( + Decls.newFunction("partial_line", + Decls.newInstanceOverload("partial_line_double_double", + List.of(GeometryVal.PROTO_TYPE, Decls.Double, Decls.Double), + GeometryVal.PROTO_TYPE), + Decls.newInstanceOverload("partial_line_double_int", List.of(GeometryVal.PROTO_TYPE, Decls.Double, Decls.Int), + GeometryVal.PROTO_TYPE), + Decls.newInstanceOverload("partial_line_int_double", List.of(GeometryVal.PROTO_TYPE, Decls.Int, Decls.Double), + GeometryVal.PROTO_TYPE), + Decls.newInstanceOverload("partial_line_int_int", List.of(GeometryVal.PROTO_TYPE, Decls.Int, Decls.Int), + GeometryVal.PROTO_TYPE) + ), + Overload.function("partial_line", + (Val[] args) -> { + Val a = args[0]; + double b = args[1].doubleValue(), c = args[2].doubleValue(); + try { + return GeometryVal + .fromWorldGeom(a.convertToNative(WithGeometry.class).partialLine(b, c)); + } catch (GeometryException e) { + return Err.newErr(e, "Unable to compute partial_line(%d, %d)", b, c); + } + }) ) )); } diff --git a/planetiler-custommap/src/main/java/com/onthegomap/planetiler/custommap/expression/stdlib/PlanetilerTypeRegistry.java b/planetiler-custommap/src/main/java/com/onthegomap/planetiler/custommap/expression/stdlib/PlanetilerTypeRegistry.java new file mode 100644 index 0000000000..70c58473d0 --- /dev/null +++ b/planetiler-custommap/src/main/java/com/onthegomap/planetiler/custommap/expression/stdlib/PlanetilerTypeRegistry.java @@ -0,0 +1,73 @@ +package com.onthegomap.planetiler.custommap.expression.stdlib; + +import static org.projectnessie.cel.common.types.Err.newErr; +import static org.projectnessie.cel.common.types.Err.unsupportedRefValConversionErr; + +import com.onthegomap.planetiler.reader.SourceFeature; +import java.util.Map; +import org.projectnessie.cel.common.types.pb.Db; +import org.projectnessie.cel.common.types.pb.DefaultTypeAdapter; +import org.projectnessie.cel.common.types.ref.FieldType; +import org.projectnessie.cel.common.types.ref.Type; +import org.projectnessie.cel.common.types.ref.TypeRegistry; +import org.projectnessie.cel.common.types.ref.Val; + +/** Registers any types that are available to CEL expressions in planetiler configs. */ +public class PlanetilerTypeRegistry implements TypeRegistry { + public static final PlanetilerTypeRegistry INSTANCE = new PlanetilerTypeRegistry(); + + @Override + public TypeRegistry copy() { + return new PlanetilerTypeRegistry(); + } + + @Override + public void register(Object t) {} + + @Override + public void registerType(Type... types) {} + + @Override + public Val nativeToValue(Object value) { + return switch (value) { + case Val val -> val; + case SourceFeature sourceFeature -> new GeometryVal(sourceFeature); + case null, default -> { + Val val = DefaultTypeAdapter.nativeToValue(Db.defaultDb, this, value); + if (val != null) { + yield val; + } + yield unsupportedRefValConversionErr(value); + } + }; + } + + @Override + public Val enumValue(String enumName) { + return newErr("unknown enum name '%s'", enumName); + } + + @Override + public Val findIdent(String identName) { + return null; + } + + @Override + public com.google.api.expr.v1alpha1.Type findType(String typeName) { + return typeName.equals(GeometryVal.NAME) ? GeometryVal.PROTO_TYPE : null; + } + + @Override + public FieldType findFieldType(String messageType, String fieldName) { + com.google.api.expr.v1alpha1.Type type = switch (messageType) { + case GeometryVal.NAME -> GeometryVal.fieldType(fieldName); + case null, default -> null; + }; + return type == null ? null : new FieldType(type, any -> false, any -> null); + } + + @Override + public Val newValue(String typeName, Map fields) { + return null; + } +} diff --git a/planetiler-custommap/src/test/java/com/onthegomap/planetiler/custommap/ConfiguredFeatureTest.java b/planetiler-custommap/src/test/java/com/onthegomap/planetiler/custommap/ConfiguredFeatureTest.java index d4585bc1a0..5182ca20f1 100644 --- a/planetiler-custommap/src/test/java/com/onthegomap/planetiler/custommap/ConfiguredFeatureTest.java +++ b/planetiler-custommap/src/test/java/com/onthegomap/planetiler/custommap/ConfiguredFeatureTest.java @@ -1518,4 +1518,91 @@ void testLeftHandSideExpressionMatchNone(String matchString) { testFeature(config, sfNoMatch, any -> { }, 1); } + + @ParameterizedTest + @CsvSource(value = { + "feature.length('z0 px'); 3.0712E-5", + "feature.length('z0 tiles'); 0.007862", + "feature.length('m'); 314283", + "feature.length('km'); 314.28", + "feature.length('nm'); 169.7", + "feature.length('ft'); 1031114", + "feature.length('yd'); 343704", + "feature.length('mi'); 195.287", + "feature.bbox.area('mi2'); 19068", + "feature.centroid.lat; 3", + "feature.centroid.lon; 2", + "feature.innermost_point.lat; 3", + "feature.innermost_point(0.01).lat; 3", + "feature.line_midpoint.lat; 3", + "feature.point_along_line(0).lat; 2", + "feature.point_along_line(1.0).lat; 4", + "feature.partial_line(0.0, 0.1).centroid.lat; 2.1", + }, delimiter = ';') + void testGeometryAttributesLine(String expression, double expected) { + var config = """ + sources: + osm: + type: osm + url: geofabrik:rhode-island + local_path: data/rhode-island.osm.pbf + layers: + - id: testLayer + features: + - source: osm + attributes: + - key: attr + value: ${%s} + """.formatted(expression); + var sfMatch = + SimpleFeature.createFakeOsmFeature(newLineString(1, 2, 3, 4), Map.of(), "osm", "layer", 1, emptyList(), + new OsmElement.Info(2, 3, 4, 5, "user")); + testFeature(config, sfMatch, + any -> assertEquals(expected, (Double) any.getAttrsAtZoom(14).get("attr"), expected / 1e3), 1); + } + + @ParameterizedTest + @CsvSource(value = { + "feature.area('z0 px2'); 1.17743E-10", + "feature.area('z0 tiles'); 7.7164E-6", + "feature.area('sm'); 1.2364E10", + "feature.area('km2'); 12363", + "feature.area('ft2'); 1.3308E11", + "feature.area('a'); 1.23637E8", + "feature.area('ac'); 3055141", + "feature.area('acres'); 3055141", + "feature.area('ha'); 1236371", + "feature.area('mi2'); 4773.7", + "feature.bbox.area('mi2'); 4773.7", + "feature.centroid.lat; 0.5", + "feature.centroid.lon; 0.5", + "feature.centroid_if_convex.lon; 0.5", + "feature.point_on_surface.lat; 0.5", + "feature.innermost_point.lat; 0.5", + "feature.validated_polygon.area('mi2'); 4773.7", + }, delimiter = ';') + void testGeometryAttributesArea(String expression, double expected) { + var config = """ + sources: + osm: + type: osm + url: geofabrik:rhode-island + local_path: data/rhode-island.osm.pbf + layers: + - id: testLayer + features: + - source: osm + attributes: + - key: attr + value: ${%s} + """.formatted(expression); + var sfMatch = + SimpleFeature.createFakeOsmFeature(rectangle(0, 1), Map.of(), "osm", "layer", 1, emptyList(), + new OsmElement.Info(2, 3, 4, 5, "user")); + testFeature(config, sfMatch, + any -> assertEquals(expected, (Double) any.getAttrsAtZoom(14).get("attr"), expected / 1e3), 1); + } + + // TODO: line_midpoint + // TODO: point_along_line(a), innermost_point(b), partial_line(a, b) }