use super::VectorTile;
use super::feature::{WINDING_EPSILON, parse_geom_command_stream, ring_signed_double_area};
use super::geometry_type::GeomType;
use geo_types::Coord;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ValidationIssue {
pub layer: String,
pub feature_index: usize,
pub kind: IssueKind,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum IssueKind {
OrphanInnerRing,
DegenerateRing(DegenerateReason),
UnknownGeometryType,
EmptyGeometryForType(GeomType),
MalformedCommandStream(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DegenerateReason {
TooFewVertices,
SubPixel,
Collinear,
}
#[must_use]
pub fn validate_tile(tile: &VectorTile) -> Vec<ValidationIssue> {
let mut issues = Vec::new();
for layer in &tile.layers {
for (feature_index, feature) in layer.features.iter().enumerate() {
validate_feature(&layer.name, feature_index, feature, &mut issues);
}
}
issues
}
fn validate_feature(
layer: &str,
feature_index: usize,
feature: &super::feature::VectorTileFeature,
issues: &mut Vec<ValidationIssue>,
) {
let push = |kind: IssueKind, issues: &mut Vec<ValidationIssue>| {
issues.push(ValidationIssue {
layer: layer.to_string(),
feature_index,
kind,
});
};
if feature.geom_type == GeomType::Unknown {
if !feature.geom_data.is_empty() {
push(IssueKind::UnknownGeometryType, issues);
}
return;
}
let rings = match parse_geom_command_stream(&feature.geom_data) {
Ok(rings) => rings,
Err(e) => {
push(IssueKind::MalformedCommandStream(format!("{e:#}")), issues);
return;
}
};
if rings.iter().all(Vec::is_empty) {
push(IssueKind::EmptyGeometryForType(feature.geom_type), issues);
return;
}
match feature.geom_type {
GeomType::MultiPolygon => check_polygon_rings(layer, feature_index, &rings, issues),
GeomType::MultiLineString => {
for ring in &rings {
if let Some(reason) = degeneracy_reason_for_linestring(ring) {
push(IssueKind::DegenerateRing(reason), issues);
}
}
}
GeomType::MultiPoint | GeomType::Unknown => {}
}
}
fn check_polygon_rings(
layer: &str,
feature_index: usize,
rings: &[Vec<Coord<f64>>],
issues: &mut Vec<ValidationIssue>,
) {
let mut saw_outer = false;
for ring in rings {
if let Some(reason) = degeneracy_reason(ring) {
issues.push(ValidationIssue {
layer: layer.to_string(),
feature_index,
kind: IssueKind::DegenerateRing(reason),
});
continue;
}
let area2 = ring_signed_double_area(ring);
if area2 > WINDING_EPSILON {
saw_outer = true;
} else if area2 < -WINDING_EPSILON && !saw_outer {
issues.push(ValidationIssue {
layer: layer.to_string(),
feature_index,
kind: IssueKind::OrphanInnerRing,
});
}
}
}
fn degeneracy_reason(coords: &[Coord<f64>]) -> Option<DegenerateReason> {
let n = if coords.len() >= 2 && coords.first() == coords.last() {
coords.len() - 1
} else {
coords.len()
};
if n < 3 {
return Some(DegenerateReason::TooFewVertices);
}
let coords = &coords[..n];
let mut seen = std::collections::HashSet::<(i64, i64)>::with_capacity(n);
for c in coords {
#[allow(clippy::cast_possible_truncation)]
seen.insert((c.x.round() as i64, c.y.round() as i64));
if seen.len() >= 3 {
break;
}
}
if seen.len() < 3 {
return Some(DegenerateReason::SubPixel);
}
if ring_signed_double_area(coords).abs() < WINDING_EPSILON {
return Some(DegenerateReason::Collinear);
}
None
}
fn degeneracy_reason_for_linestring(coords: &[Coord<f64>]) -> Option<DegenerateReason> {
if coords.len() < 2 {
return Some(DegenerateReason::TooFewVertices);
}
let mut seen = std::collections::HashSet::<(i64, i64)>::with_capacity(coords.len());
for c in coords {
#[allow(clippy::cast_possible_truncation)]
seen.insert((c.x.round() as i64, c.y.round() as i64));
if seen.len() >= 2 {
return None;
}
}
Some(DegenerateReason::SubPixel)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::vector_tile::VectorTileLayer;
use crate::vector_tile::feature::VectorTileFeature;
use versatiles_core::Blob;
use versatiles_core::io::{ValueWriter, ValueWriterBlob};
fn raw_polygon_feature(rings: &[Vec<(i32, i32)>]) -> VectorTileFeature {
raw_feature(GeomType::MultiPolygon, rings)
}
fn raw_feature(geom_type: GeomType, rings: &[Vec<(i32, i32)>]) -> VectorTileFeature {
let mut writer = ValueWriterBlob::new_le();
let mut prev = (0i64, 0i64);
for ring in rings {
assert!(!ring.is_empty());
let (fx, fy) = ring[0];
let (ix, iy) = (i64::from(fx), i64::from(fy));
writer.write_varint((1 << 3) | 0x1).unwrap(); writer.write_svarint(ix - prev.0).unwrap();
writer.write_svarint(iy - prev.1).unwrap();
prev = (ix, iy);
let rest = ring.len() - 1;
if rest > 0 {
writer.write_varint(((rest as u64) << 3) | 0x2).unwrap();
for &(fx, fy) in &ring[1..] {
let (ix, iy) = (i64::from(fx), i64::from(fy));
writer.write_svarint(ix - prev.0).unwrap();
writer.write_svarint(iy - prev.1).unwrap();
prev = (ix, iy);
}
}
if geom_type == GeomType::MultiPolygon {
writer.write_varint(7).unwrap(); }
}
VectorTileFeature {
id: None,
tag_ids: vec![],
geom_type,
geom_data: writer.into_blob(),
}
}
fn layer_with_features(name: &str, features: Vec<VectorTileFeature>) -> VectorTileLayer {
let mut layer = VectorTileLayer::new(name.to_string(), 4096, 1);
layer.features = features;
layer
}
fn tile_with_layer(layer: VectorTileLayer) -> VectorTile {
VectorTile::new(vec![layer])
}
#[test]
fn clean_tile_has_no_issues() {
let outer = vec![(0, 0), (4, 0), (4, 4), (0, 4)];
let feature = raw_polygon_feature(&[outer]);
let tile = tile_with_layer(layer_with_features("l", vec![feature]));
assert!(validate_tile(&tile).is_empty());
}
#[test]
fn detects_orphan_inner_ring() {
let inner_first = vec![(0, 0), (0, 4), (4, 4), (4, 0)];
let feature = raw_polygon_feature(&[inner_first]);
let tile = tile_with_layer(layer_with_features("l", vec![feature]));
let issues = validate_tile(&tile);
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].kind, IssueKind::OrphanInnerRing);
assert_eq!(issues[0].layer, "l");
assert_eq!(issues[0].feature_index, 0);
}
#[test]
fn detects_inverted_winding_landcover_pattern() {
let outer_inverted = vec![(0, 0), (0, 100), (100, 100), (100, 0)];
let inner_inverted = vec![(20, 20), (80, 20), (80, 80), (20, 80)];
let feature = raw_polygon_feature(&[outer_inverted, inner_inverted]);
let tile = tile_with_layer(layer_with_features("landcover", vec![feature]));
let issues = validate_tile(&tile);
assert_eq!(issues.len(), 1, "exactly one orphan-inner reported");
assert_eq!(issues[0].kind, IssueKind::OrphanInnerRing);
}
#[test]
fn detects_collinear_degenerate_ring() {
let collinear = vec![(0, 0), (10, 10), (20, 20)]; let feature = raw_polygon_feature(&[collinear]);
let tile = tile_with_layer(layer_with_features("l", vec![feature]));
let issues = validate_tile(&tile);
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].kind, IssueKind::DegenerateRing(DegenerateReason::Collinear));
}
#[test]
fn detects_sub_pixel_degenerate_ring() {
let mut writer = ValueWriterBlob::new_le();
writer.write_varint((1 << 3) | 0x1).unwrap();
writer.write_svarint(0).unwrap();
writer.write_svarint(0).unwrap();
writer.write_varint((2 << 3) | 0x2).unwrap();
writer.write_svarint(0).unwrap(); writer.write_svarint(0).unwrap();
writer.write_svarint(0).unwrap(); writer.write_svarint(0).unwrap();
writer.write_varint(7).unwrap(); let feature = VectorTileFeature {
id: None,
tag_ids: vec![],
geom_type: GeomType::MultiPolygon,
geom_data: writer.into_blob(),
};
let tile = tile_with_layer(layer_with_features("l", vec![feature]));
let issues = validate_tile(&tile);
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].kind, IssueKind::DegenerateRing(DegenerateReason::SubPixel));
}
#[test]
fn unknown_with_empty_data_is_not_flagged() {
let feature = VectorTileFeature {
id: None,
tag_ids: vec![],
geom_type: GeomType::Unknown,
geom_data: Blob::new_empty(),
};
let tile = tile_with_layer(layer_with_features("l", vec![feature]));
assert!(validate_tile(&tile).is_empty());
}
#[test]
fn detects_unknown_geometry_with_attached_data() {
let feature = VectorTileFeature {
id: None,
tag_ids: vec![],
geom_type: GeomType::Unknown,
geom_data: Blob::from(vec![0x09, 0x00, 0x00]),
};
let tile = tile_with_layer(layer_with_features("l", vec![feature]));
let issues = validate_tile(&tile);
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].kind, IssueKind::UnknownGeometryType);
}
#[test]
fn detects_typed_feature_with_empty_geom_data() {
for geom_type in [GeomType::MultiPoint, GeomType::MultiLineString, GeomType::MultiPolygon] {
let feature = VectorTileFeature {
id: None,
tag_ids: vec![],
geom_type,
geom_data: Blob::new_empty(),
};
let tile = tile_with_layer(layer_with_features("l", vec![feature]));
let issues = validate_tile(&tile);
assert_eq!(issues.len(), 1, "type={geom_type:?}");
assert_eq!(issues[0].kind, IssueKind::EmptyGeometryForType(geom_type));
}
}
#[test]
fn detects_malformed_command_stream() {
let feature = VectorTileFeature {
id: None,
tag_ids: vec![],
geom_type: GeomType::MultiPolygon,
geom_data: Blob::from(vec![0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]),
};
let tile = tile_with_layer(layer_with_features("l", vec![feature]));
let issues = validate_tile(&tile);
assert_eq!(issues.len(), 1);
assert!(matches!(issues[0].kind, IssueKind::MalformedCommandStream(_)));
}
#[test]
fn reports_layer_and_feature_index_correctly() {
let good = raw_polygon_feature(&[vec![(0, 0), (4, 0), (4, 4), (0, 4)]]);
let bad = raw_polygon_feature(&[vec![(0, 0), (0, 4), (4, 4), (4, 0)]]); let tile = tile_with_layer(layer_with_features("mixed", vec![good, bad]));
let issues = validate_tile(&tile);
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].layer, "mixed");
assert_eq!(issues[0].feature_index, 1);
}
}