cartography 0.11.1

Cartography is a map rendering library for Geographic features expressed using [georust](https://georust.org/) libraries.
Documentation
//! Geometry simplification helpers for OSM layers.

use geo::BoundingRect as _;
use geo::Simplify as _;

use super::OsmFeature;
use crate::FeaturesVecLayer;

/// Return a new layer containing simplified copies of every feature in `layer`.
///
/// `epsilon` is the Douglas-Peucker tolerance in the same coordinate units as
/// the layer's projection (degrees for the default WGS-84 projection).
/// Larger values produce coarser geometry with fewer vertices.
///
/// Point features are copied unchanged — simplification only affects
/// `LineString`, `Polygon`, and their `Multi*` variants.
pub fn simplify_layer(
  layer: &FeaturesVecLayer<OsmFeature>,
  epsilon: f64,
) -> FeaturesVecLayer<OsmFeature>
{
  layer
    .features_iter()
    .map(|f| OsmFeature {
      id: f.id,
      tags: f.tags.clone(),
      geometry: simplify_geometry(&f.geometry, epsilon),
    })
    .collect()
}

fn simplify_geometry(geometry: &geo::Geometry, epsilon: f64) -> geo::Geometry
{
  match geometry
  {
    geo::Geometry::LineString(g) => geo::Geometry::LineString(g.simplify(epsilon)),
    geo::Geometry::Polygon(g) => geo::Geometry::Polygon(g.simplify(epsilon)),
    geo::Geometry::MultiLineString(g) => geo::Geometry::MultiLineString(g.simplify(epsilon)),
    geo::Geometry::MultiPolygon(g) => geo::Geometry::MultiPolygon(g.simplify(epsilon)),
    geo::Geometry::GeometryCollection(gc) => geo::Geometry::GeometryCollection(
      gc.0
        .iter()
        .map(|geom| simplify_geometry(geom, epsilon))
        .collect(),
    ),
    _ => geometry.clone(),
  }
}

/// Remove features whose bounding box is smaller than `min_degrees` in both
/// width and height. Points are always kept.
///
/// Guidance for `min_degrees` (WGS-84 degrees):
///   - z5:  0.1°  (~10 km)
///   - z8:  0.01° (~1 km)
///   - z12: 0.001° (~100 m)
pub fn filter_layer_by_min_bbox(
  layer: &FeaturesVecLayer<OsmFeature>,
  min_degrees: f64,
) -> FeaturesVecLayer<OsmFeature>
{
  layer
    .features_iter()
    .filter(|f| bbox_exceeds_threshold(&f.geometry, min_degrees))
    .cloned()
    .collect()
}

fn bbox_exceeds_threshold(geometry: &geo::Geometry, min_degrees: f64) -> bool
{
  match geometry
  {
    geo::Geometry::Point(_) => true,
    g => g
      .bounding_rect()
      .map(|r| r.width() >= min_degrees || r.height() >= min_degrees)
      .unwrap_or(false),
  }
}

#[cfg(test)]
mod tests
{
  use super::*;
  use geo::coords_iter::CoordsIter as _;

  fn make_feature(geometry: geo::Geometry) -> OsmFeature
  {
    OsmFeature {
      id: 1,
      geometry,
      tags: vec![("highway".to_owned(), "residential".to_owned())],
    }
  }

  /// A collinear line: A-B-C where B lies exactly on AC.
  /// Douglas-Peucker with any ε > 0 should collapse it to just A-C.
  fn collinear_linestring() -> geo::Geometry
  {
    geo::Geometry::LineString(geo::LineString::from(vec![
      (0.0, 0.0),
      (1.0, 0.0),
      (2.0, 0.0),
    ]))
  }

  fn simple_polygon() -> geo::Geometry
  {
    geo::Geometry::Polygon(geo::Polygon::new(
      geo::LineString::from(vec![
        (0.0, 0.0),
        (0.001, 0.0),
        (1.0, 0.0),
        (1.0, 1.0),
        (0.0, 1.0),
        (0.0, 0.0),
      ]),
      vec![],
    ))
  }

  #[test]
  fn collinear_midpoint_removed()
  {
    let layer: FeaturesVecLayer<OsmFeature> = vec![make_feature(collinear_linestring())].into();

    let simplified = simplify_layer(&layer, 0.5);

    let feat = simplified.features_iter().next().unwrap();
    assert_eq!(feat.geometry.coords_count(), 2);
  }

  #[test]
  fn polygon_vertex_count_reduced()
  {
    let layer: FeaturesVecLayer<OsmFeature> = vec![make_feature(simple_polygon())].into();

    let orig_count = layer
      .features_iter()
      .next()
      .unwrap()
      .geometry
      .coords_count();

    let simplified = simplify_layer(&layer, 0.01);

    let simp_count = simplified
      .features_iter()
      .next()
      .unwrap()
      .geometry
      .coords_count();

    assert!(
      simp_count < orig_count,
      "simplification should reduce vertex count"
    );
  }

  #[test]
  fn tags_preserved_after_simplification()
  {
    let layer: FeaturesVecLayer<OsmFeature> = vec![make_feature(collinear_linestring())].into();

    let simplified = simplify_layer(&layer, 0.5);

    let feat = simplified.features_iter().next().unwrap();
    assert_eq!(feat.tag("highway"), Some("residential"));
  }

  #[test]
  fn point_feature_unchanged()
  {
    let point = geo::Geometry::Point(geo::Point::new(1.0, 2.0));
    let layer: FeaturesVecLayer<OsmFeature> = vec![make_feature(point.clone())].into();

    let simplified = simplify_layer(&layer, 999.0);

    let feat = simplified.features_iter().next().unwrap();
    assert_eq!(feat.geometry, point);
  }

  #[test]
  fn empty_layer_produces_empty_output()
  {
    let layer: FeaturesVecLayer<OsmFeature> = vec![].into();
    let simplified = simplify_layer(&layer, 1.0);
    assert_eq!(simplified.features_iter().count(), 0);
  }

  #[test]
  fn feature_count_preserved()
  {
    let layer: FeaturesVecLayer<OsmFeature> = vec![
      make_feature(collinear_linestring()),
      make_feature(collinear_linestring()),
    ]
    .into();

    let simplified = simplify_layer(&layer, 0.5);
    assert_eq!(simplified.features_iter().count(), 2);
  }

  #[test]
  fn small_polygon_filtered_out()
  {
    let tiny = make_feature(geo::Geometry::Polygon(geo::Polygon::new(
      geo::LineString::from(vec![
        (0.0, 0.0),
        (0.001, 0.0),
        (0.001, 0.001),
        (0.0, 0.001),
        (0.0, 0.0),
      ]),
      vec![],
    )));
    let layer: FeaturesVecLayer<OsmFeature> = vec![tiny].into();
    let filtered = filter_layer_by_min_bbox(&layer, 0.1);
    assert_eq!(
      filtered.features_iter().count(),
      0,
      "sub-threshold polygon must be dropped"
    );
  }

  #[test]
  fn large_polygon_kept()
  {
    let large = make_feature(geo::Geometry::Polygon(geo::Polygon::new(
      geo::LineString::from(vec![
        (0.0, 0.0),
        (1.0, 0.0),
        (1.0, 1.0),
        (0.0, 1.0),
        (0.0, 0.0),
      ]),
      vec![],
    )));
    let layer: FeaturesVecLayer<OsmFeature> = vec![large].into();
    let filtered = filter_layer_by_min_bbox(&layer, 0.1);
    assert_eq!(
      filtered.features_iter().count(),
      1,
      "above-threshold polygon must be kept"
    );
  }

  #[test]
  fn point_always_kept_regardless_of_threshold()
  {
    let pt = make_feature(geo::Geometry::Point(geo::Point::new(1.0, 2.0)));
    let layer: FeaturesVecLayer<OsmFeature> = vec![pt].into();
    let filtered = filter_layer_by_min_bbox(&layer, 999.0);
    assert_eq!(
      filtered.features_iter().count(),
      1,
      "points must never be filtered out"
    );
  }

  #[test]
  fn mixed_layer_keeps_only_large_features()
  {
    let tiny = make_feature(geo::Geometry::Polygon(geo::Polygon::new(
      geo::LineString::from(vec![
        (0.0, 0.0),
        (0.001, 0.0),
        (0.001, 0.001),
        (0.0, 0.001),
        (0.0, 0.0),
      ]),
      vec![],
    )));
    let large = make_feature(geo::Geometry::Polygon(geo::Polygon::new(
      geo::LineString::from(vec![
        (0.0, 0.0),
        (1.0, 0.0),
        (1.0, 1.0),
        (0.0, 1.0),
        (0.0, 0.0),
      ]),
      vec![],
    )));
    let layer: FeaturesVecLayer<OsmFeature> = vec![tiny, large].into();
    let filtered = filter_layer_by_min_bbox(&layer, 0.1);
    assert_eq!(
      filtered.features_iter().count(),
      1,
      "only large polygon should survive filtering"
    );
  }

  #[test]
  fn filter_preserves_feature_ids()
  {
    let large = OsmFeature {
      id: 12345,
      geometry: geo::Geometry::Polygon(geo::Polygon::new(
        geo::LineString::from(vec![
          (0.0, 0.0),
          (1.0, 0.0),
          (1.0, 1.0),
          (0.0, 1.0),
          (0.0, 0.0),
        ]),
        vec![],
      )),
      tags: vec![],
    };
    let layer: FeaturesVecLayer<OsmFeature> = vec![large].into();
    let filtered = filter_layer_by_min_bbox(&layer, 0.1);
    let feat = filtered.features_iter().next().unwrap();
    assert_eq!(feat.id, 12345, "feature ID must be preserved");
  }

  #[test]
  fn linestring_filtered_by_width()
  {
    let narrow_line = make_feature(geo::Geometry::LineString(geo::LineString::from(vec![
      (0.0, 0.0),
      (0.05, 0.0),
    ])));
    let layer: FeaturesVecLayer<OsmFeature> = vec![narrow_line].into();
    let filtered = filter_layer_by_min_bbox(&layer, 0.1);
    assert_eq!(filtered.features_iter().count(), 0);
  }

  #[test]
  fn linestring_kept_if_height_exceeds_threshold()
  {
    let tall_line = make_feature(geo::Geometry::LineString(geo::LineString::from(vec![
      (0.0, 0.0),
      (0.05, 0.2),
    ])));
    let layer: FeaturesVecLayer<OsmFeature> = vec![tall_line].into();
    let filtered = filter_layer_by_min_bbox(&layer, 0.1);
    assert_eq!(filtered.features_iter().count(), 1);
  }

  #[test]
  fn larger_epsilon_produces_fewer_vertices()
  {
    use geo::coords_iter::CoordsIter as _;
    let coords: Vec<geo::Coord> = (0..=100)
      .map(|i| {
        let t = i as f64 / 100.0 * std::f64::consts::TAU;
        geo::coord! { x: t.cos(), y: t.sin() }
      })
      .collect();
    let layer: FeaturesVecLayer<OsmFeature> = vec![make_feature(geo::Geometry::Polygon(
      geo::Polygon::new(geo::LineString::new(coords), vec![]),
    ))]
    .into();

    let medium = simplify_layer(&layer, 0.01);
    let coarse = simplify_layer(&layer, 0.1);

    let verts = |l: &FeaturesVecLayer<OsmFeature>| -> usize {
      l.features_iter().map(|f| f.geometry.coords_count()).sum()
    };
    assert!(
      verts(&coarse) <= verts(&medium),
      "coarse (ε=0.1) must have ≤ vertices than medium (ε=0.01)"
    );
  }
}