mod config;
mod heuristics;
mod reduce_lines;
mod reduce_points;
mod reduce_polygons;
mod spatial_index;
mod tile_render;
pub use config::{FeatureImportArgs, FeatureImportConfig};
pub use heuristics::auto_max_zoom;
pub use reduce_points::PointReductionStrategy;
pub use tile_render::{clip_geometry, render_tile};
#[must_use]
pub fn project_and_flatten(mut feature: crate::geo::GeoFeature) -> Vec<crate::geo::GeoFeature> {
let stub = geo_types::Geometry::Point(geo_types::Point::new(0.0, 0.0));
let original = std::mem::replace(&mut feature.geometry, stub);
feature.geometry = MercatorExt::to_mercator(original);
flatten_feature(feature)
}
use versatiles_derive::context;
use crate::arc_graph::{self, ArcGraph, FeatureArcs};
use crate::ext::{MercatorExt, coord_from_mercator};
use crate::geo::GeoFeature;
use crate::vector_tile::VectorTile;
use anyhow::{Result, bail};
use geo::BoundingRect;
use geo_types::{Coord, Geometry};
use heuristics::auto_max_zoom_projected;
use rstar::RTree;
use spatial_index::{FeatureRef, query};
use versatiles_core::{GeoBBox, WORLD_SIZE};
pub const TILE_EXTENT: u32 = 4096;
#[derive(Debug)]
struct ZoomEntry {
source_index: usize,
geometry: Geometry<f64>,
}
#[derive(Debug)]
struct ZoomLayer {
entries: Vec<ZoomEntry>,
rtree: RTree<FeatureRef>,
}
#[derive(Debug)]
pub struct FeatureImport {
config: FeatureImportConfig,
resolved_max_zoom: u8,
layers: Vec<Option<ZoomLayer>>,
bounds_mercator: [f64; 4],
property_schema: std::collections::BTreeMap<String, String>,
flattened: Vec<GeoFeature>,
}
impl FeatureImport {
#[context("importing features")]
pub fn from_features(flattened: Vec<GeoFeature>, args: FeatureImportArgs) -> Result<Self> {
let config: FeatureImportConfig = args.into();
let property_schema = collect_property_schema(&flattened);
let resolved_max_zoom = config.max_zoom.unwrap_or_else(|| auto_max_zoom_projected(&flattened));
if config.min_zoom > resolved_max_zoom {
bail!("min_zoom ({}) > max_zoom ({resolved_max_zoom})", config.min_zoom);
}
let bounds_mercator = features_bbox(&flattened).ok_or_else(|| anyhow::anyhow!("failed to compute bounds"))?;
let (arc_graph, feature_arcs): (ArcGraph, Vec<FeatureArcs>) = arc_graph::build(&flattened);
let n_slots = usize::from(resolved_max_zoom) + 1;
let mut layers: Vec<Option<ZoomLayer>> = (0..n_slots).map(|_| None).collect();
log::debug!(
"building zoom layers for zooms {}..={resolved_max_zoom} (cascading max→min)",
config.min_zoom,
);
let mut arcs: Vec<arc_graph::Arc> = arc_graph.arcs().to_vec();
let mut alive_indices: Vec<usize> = (0..flattened.len()).collect();
for z in (config.min_zoom..=resolved_max_zoom).rev() {
log::trace!("processing zoom {z}");
let m_per_px = meters_per_pixel(z);
let tol_simplify_m = arc_simplify_tolerance(&config, m_per_px);
let polygon_min_area_m2 = f64::from(config.polygon_min_area_px) * m_per_px * m_per_px;
let line_min_length_m = f64::from(config.line_min_length_px) * m_per_px;
arcs = arc_graph::simplify_arcs(&arcs, tol_simplify_m);
let reassembled: Vec<(usize, Geometry<f64>)> = alive_indices
.iter()
.map(|&i| (i, arc_graph::reassemble_geometry(&arcs, &feature_arcs[i])))
.collect();
let filtered: Vec<(usize, Geometry<f64>)> = reassembled
.into_iter()
.filter(|(_, g)| reduce_polygons::passes_min_area(g, polygon_min_area_m2))
.filter(|(_, g)| reduce_lines::passes_min_length(g, line_min_length_m))
.collect();
let reduced = match config.point_reduction {
PointReductionStrategy::None => filtered,
PointReductionStrategy::DropRate => {
let keep_ratio = f64::from(config.drop_rate_keep_ratio);
reduce_points::apply_drop_rate(filtered, keep_ratio)
}
PointReductionStrategy::MinDistance => {
let threshold_m = f64::from(config.min_distance_px) * m_per_px;
reduce_points::apply_min_distance(filtered, threshold_m)
}
};
alive_indices = reduced.iter().map(|(i, _)| *i).collect();
let entries: Vec<ZoomEntry> = reduced
.into_iter()
.filter_map(|(i, g)| {
g.bounding_rect().map(|_| ZoomEntry {
source_index: i,
geometry: g,
})
})
.collect();
let rtree = build_rtree_from_entries(&entries);
layers[usize::from(z)] = Some(ZoomLayer { entries, rtree });
}
Ok(Self {
config,
resolved_max_zoom,
layers,
bounds_mercator,
property_schema,
flattened,
})
}
#[must_use]
pub fn property_schema(&self) -> &std::collections::BTreeMap<String, String> {
&self.property_schema
}
pub fn get_tile(&self, z: u8, x: u32, y: u32) -> Result<Option<VectorTile>> {
let Some(layer) = self.layers.get(usize::from(z)).and_then(Option::as_ref) else {
return Ok(None);
};
let tile_bbox = tile_mercator_bbox(z, x, y);
let candidates: Vec<&FeatureRef> = query(&layer.rtree, tile_bbox).collect();
if candidates.is_empty() {
return Ok(None);
}
let candidate_features: Vec<GeoFeature> = candidates
.into_iter()
.map(|r| {
let entry = &layer.entries[r.index];
let template = &self.flattened[entry.source_index];
GeoFeature {
id: template.id.clone(),
geometry: entry.geometry.clone(),
properties: template.properties.clone(),
}
})
.collect();
render_tile(candidate_features, &self.config.layer_name, tile_bbox, TILE_EXTENT)
}
#[must_use]
pub fn bounds_mercator(&self) -> [f64; 4] {
self.bounds_mercator
}
pub fn bounds_geo(&self) -> Result<Option<GeoBBox>> {
let [xmin, ymin, xmax, ymax] = self.bounds_mercator;
let min = coord_from_mercator(Coord { x: xmin, y: ymin });
let max = coord_from_mercator(Coord { x: xmax, y: ymax });
let lon_min = min.x.clamp(-180.0, 180.0);
let lat_min = min.y.clamp(-90.0, 90.0);
let lon_max = max.x.clamp(-180.0, 180.0);
let lat_max = max.y.clamp(-90.0, 90.0);
Ok(Some(GeoBBox::new(lon_min, lat_min, lon_max, lat_max)?))
}
#[must_use]
pub fn config(&self) -> &FeatureImportConfig {
&self.config
}
#[must_use]
pub fn max_zoom(&self) -> u8 {
self.resolved_max_zoom
}
#[must_use]
pub fn min_zoom(&self) -> u8 {
self.config.min_zoom
}
}
fn tile_mercator_bbox(z: u8, x: u32, y: u32) -> [f64; 4] {
let tiles_per_side = f64::from(2u32.pow(u32::from(z)));
let tile_size = WORLD_SIZE / tiles_per_side;
let xmin = -WORLD_SIZE / 2.0 + f64::from(x) * tile_size;
let xmax = xmin + tile_size;
let ymax = WORLD_SIZE / 2.0 - f64::from(y) * tile_size;
let ymin = ymax - tile_size;
[xmin, ymin, xmax, ymax]
}
fn meters_per_pixel(zoom: u8) -> f64 {
let tiles_per_side = f64::from(2u32.pow(u32::from(zoom)));
let tile_size_m = WORLD_SIZE / tiles_per_side;
tile_size_m / f64::from(TILE_EXTENT)
}
fn flatten_feature(feature: GeoFeature) -> Vec<GeoFeature> {
let is_multi = matches!(
feature.geometry,
Geometry::MultiPoint(_) | Geometry::MultiLineString(_) | Geometry::MultiPolygon(_)
);
if !is_multi {
return vec![feature];
}
let GeoFeature {
id,
geometry,
properties,
} = feature;
match geometry {
Geometry::MultiPoint(mp) => mp
.0
.into_iter()
.map(|p| GeoFeature {
id: id.clone(),
geometry: Geometry::Point(p),
properties: properties.clone(),
})
.collect(),
Geometry::MultiLineString(ml) => ml
.0
.into_iter()
.map(|ls| GeoFeature {
id: id.clone(),
geometry: Geometry::LineString(ls),
properties: properties.clone(),
})
.collect(),
Geometry::MultiPolygon(mp) => mp
.0
.into_iter()
.map(|p| GeoFeature {
id: id.clone(),
geometry: Geometry::Polygon(p),
properties: properties.clone(),
})
.collect(),
_ => unreachable!("checked is_multi above"),
}
}
fn arc_simplify_tolerance(config: &FeatureImportConfig, m_per_px: f64) -> f64 {
let p = f64::from(config.polygon_simplify_px);
let l = f64::from(config.line_simplify_px);
let combined_px = match (p > 0.0, l > 0.0) {
(true, true) => p.min(l),
(true, false) => p,
(false, true) => l,
(false, false) => 0.0,
};
combined_px * m_per_px
}
fn collect_property_schema(features: &[GeoFeature]) -> std::collections::BTreeMap<String, String> {
use crate::geo::GeoValue;
fn rank(t: &str) -> u8 {
match t {
"Boolean" => 1,
"Number" => 2,
"String" => 3,
_ => 0,
}
}
let mut schema: std::collections::BTreeMap<String, String> = std::collections::BTreeMap::new();
for feature in features {
for (name, value) in feature.properties.iter() {
let new_type = match value {
GeoValue::Bool(_) => "Boolean",
GeoValue::Int(_) | GeoValue::UInt(_) | GeoValue::Float(_) | GeoValue::Double(_) => "Number",
GeoValue::String(_) => "String",
GeoValue::Null => continue,
};
schema
.entry(name.clone())
.and_modify(|existing| {
if rank(new_type) > rank(existing) {
*existing = new_type.to_string();
}
})
.or_insert_with(|| new_type.to_string());
}
}
schema
}
fn features_bbox(features: &[GeoFeature]) -> Option<[f64; 4]> {
let mut acc: Option<(f64, f64, f64, f64)> = None;
for f in features {
if let Some(rect) = f.geometry.bounding_rect() {
let (xmin, ymin, xmax, ymax) = (rect.min().x, rect.min().y, rect.max().x, rect.max().y);
acc = Some(match acc {
None => (xmin, ymin, xmax, ymax),
Some((a, b, c, d)) => (a.min(xmin), b.min(ymin), c.max(xmax), d.max(ymax)),
});
}
}
acc.map(|(a, b, c, d)| [a, b, c, d])
}
fn build_rtree_from_entries(entries: &[ZoomEntry]) -> RTree<FeatureRef> {
let refs: Vec<FeatureRef> = entries
.iter()
.enumerate()
.filter_map(|(i, e)| {
e.geometry
.bounding_rect()
.map(|r| FeatureRef::new(i, [r.min().x, r.min().y, r.max().x, r.max().y]))
})
.collect();
RTree::bulk_load(refs)
}
#[cfg(test)]
#[allow(clippy::cast_possible_truncation)]
mod tests {
use super::*;
use crate::geo::GeoValue;
use geo_types::{LineString, Point, Polygon};
fn point_feature(id: u64, name: &str, lon: f64, lat: f64) -> GeoFeature {
let mut f = GeoFeature::new(Geometry::Point(Point::new(lon, lat)));
f.set_property("name".into(), name);
f.set_id(GeoValue::from(id));
f
}
#[test]
fn imports_two_points_and_renders_world_tile() -> Result<()> {
let features = vec![
point_feature(1, "origin", 0.0, 0.0),
point_feature(2, "east", 90.0, 30.0),
];
let args = FeatureImportArgs {
max_zoom: Some(5),
..Default::default()
};
let import = FeatureImport::from_features(features.into_iter().flat_map(project_and_flatten).collect(), args)?;
assert_eq!(import.bounds_mercator().map(|b| b as i64), [0, 0, 10018754, 3503549]);
let tile = import.get_tile(0, 0, 0)?.expect("world tile is non-empty");
assert_eq!(tile.layers.len(), 1);
assert_eq!(tile.layers[0].name, "features");
assert_eq!(tile.layers[0].features.len(), 2);
Ok(())
}
#[test]
fn empty_input_yields_no_tiles() -> Result<()> {
let import = FeatureImport::from_features(Vec::new(), FeatureImportArgs::default());
assert_eq!(import.unwrap_err().to_string(), "importing features");
Ok(())
}
#[test]
fn out_of_range_zoom_returns_none() -> Result<()> {
let args = FeatureImportArgs {
max_zoom: Some(3),
..Default::default()
};
let import = FeatureImport::from_features(project_and_flatten(point_feature(1, "o", 0.0, 0.0)), args)?;
assert!(import.get_tile(10, 0, 0)?.is_none());
Ok(())
}
#[test]
fn drops_tiny_polygon_at_low_zoom() -> Result<()> {
let exterior = LineString::from(vec![
[13.40500, 52.52000],
[13.40501, 52.52000],
[13.40501, 52.52001],
[13.40500, 52.52001],
[13.40500, 52.52000],
]);
let polygon = Polygon::new(exterior, vec![]);
let feature = GeoFeature::new(Geometry::Polygon(polygon));
let args = FeatureImportArgs {
max_zoom: Some(5),
polygon_simplify_px: Some(0.0),
..Default::default()
};
let import = FeatureImport::from_features(project_and_flatten(feature), args)?;
let coord = versatiles_core::TileCoord::from_geo(13.405, 52.52, 5)?;
assert!(import.get_tile(coord.level, coord.x, coord.y)?.is_none());
Ok(())
}
#[test]
fn drops_short_line_at_low_zoom() -> Result<()> {
let line = LineString::from(vec![[13.405, 52.520], [13.406, 52.520]]);
let feature = GeoFeature::new(Geometry::LineString(line));
let args = FeatureImportArgs {
max_zoom: Some(14),
line_simplify_px: Some(0.0),
..Default::default()
};
let import = FeatureImport::from_features(project_and_flatten(feature), args)?;
assert!(import.get_tile(0, 0, 0)?.is_none());
let coord = versatiles_core::TileCoord::from_geo(13.405, 52.52, 14)?;
assert!(import.get_tile(coord.level, coord.x, coord.y)?.is_some());
Ok(())
}
#[test]
fn polygon_clipped_to_tile() -> Result<()> {
let exterior = LineString::from(vec![
[-90.0, -45.0],
[90.0, -45.0],
[90.0, 45.0],
[-90.0, 45.0],
[-90.0, -45.0],
]);
let polygon = Polygon::new(exterior, vec![]);
let mut feature = GeoFeature::new(Geometry::Polygon(polygon));
feature.set_property("kind".into(), "boundary");
let args = FeatureImportArgs {
max_zoom: Some(3),
polygon_simplify_px: Some(0.0), ..Default::default()
};
let import = FeatureImport::from_features(project_and_flatten(feature), args)?;
let tile = import.get_tile(2, 1, 1)?.expect("tile in the polygon");
assert_eq!(tile.layers.len(), 1);
assert_eq!(tile.layers[0].features.len(), 1);
Ok(())
}
#[test]
fn auto_max_zoom_for_country_scale_polygon() {
let exterior = LineString::from(vec![[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0], [0.0, 0.0]]);
let f = GeoFeature::new(Geometry::Polygon(Polygon::new(exterior, vec![])));
assert_eq!(auto_max_zoom(std::slice::from_ref(&f)), 0);
}
#[test]
fn auto_max_zoom_for_kilometer_scale_polygon() {
let exterior = LineString::from(vec![[0.0, 0.0], [0.009, 0.0], [0.009, 0.009], [0.0, 0.009], [0.0, 0.0]]);
let f = GeoFeature::new(Geometry::Polygon(Polygon::new(exterior, vec![])));
let z = auto_max_zoom(std::slice::from_ref(&f));
assert!((4..=6).contains(&z), "expected ~5, got {z}");
}
#[test]
fn auto_max_zoom_for_meter_scale_features_caps_at_14() {
let exterior = LineString::from(vec![
[0.000_001, 0.000_001],
[0.000_010, 0.000_001],
[0.000_010, 0.000_010],
[0.000_001, 0.000_010],
[0.000_001, 0.000_001],
]);
let f = GeoFeature::new(Geometry::Polygon(Polygon::new(exterior, vec![])));
assert_eq!(auto_max_zoom(std::slice::from_ref(&f)), 14);
}
#[test]
fn auto_max_zoom_for_point_only_input_defaults_to_14() {
let p1 = GeoFeature::new(Geometry::Point(Point::new(0.0, 0.0)));
let p2 = GeoFeature::new(Geometry::Point(Point::new(1.0, 1.0)));
assert_eq!(auto_max_zoom(&[p1, p2]), 14);
}
#[test]
fn auto_max_zoom_uses_median_not_mean() {
let huge = LineString::from(vec![
[-90.0, -45.0],
[90.0, -45.0],
[90.0, 45.0],
[-90.0, 45.0],
[-90.0, -45.0],
]);
let mut features: Vec<GeoFeature> = Vec::new();
features.push(GeoFeature::new(Geometry::Polygon(Polygon::new(huge, vec![]))));
for i in 0..10 {
let off = f64::from(i) * 0.001;
let small = LineString::from(vec![
[off, off],
[off + 0.0001, off],
[off + 0.0001, off + 0.0001],
[off, off + 0.0001],
[off, off],
]);
features.push(GeoFeature::new(Geometry::Polygon(Polygon::new(small, vec![]))));
}
let z = auto_max_zoom(&features);
assert!(z >= 12, "expected ≥12 (median is small), got {z}");
}
#[tokio::test]
async fn from_features_via_geojson_source() -> Result<()> {
use crate::feature_source::{FeatureSource, GeoJsonSource};
use futures::StreamExt;
let src = GeoJsonSource::new("../testdata/places.geojson");
let args = FeatureImportArgs {
layer_name: Some("places".to_string()),
max_zoom: Some(5),
polygon_simplify_px: Some(0.0),
line_simplify_px: Some(0.0),
polygon_min_area_px: Some(0.0),
line_min_length_px: Some(0.0),
..Default::default()
};
let mut stream = src.load()?;
let mut features = Vec::new();
while let Some(item) = stream.next().await {
features.extend(project_and_flatten(item?));
}
let import = FeatureImport::from_features(features, args)?;
assert_eq!(
import.bounds_mercator().map(|b| b as i64),
[1447153, 6800125, 1614132, 6927697]
);
let tile = import.get_tile(0, 0, 0)?.expect("world tile non-empty");
assert_eq!(tile.layers[0].name, "places");
assert_eq!(tile.layers[0].features.len(), 5);
Ok(())
}
}