#![allow(dead_code)]
use super::{geometry_type::GeomType, layer::VectorTileLayer};
use crate::ext::validate;
use crate::geo::{GeoFeature, GeoProperties, GeoValue};
use anyhow::{Context, Result, bail, ensure};
use byteorder::LE;
use geo_types::{
Coord, Geometry, GeometryCollection, LineString, MultiLineString, MultiPoint, MultiPolygon, Point, Polygon,
};
use versatiles_core::{
Blob,
io::{ValueReader, ValueReaderSlice, ValueWriter, ValueWriterBlob},
utils::float_to_int,
};
#[derive(Clone, Debug, PartialEq)]
pub struct VectorTileFeature {
pub id: Option<u64>,
pub tag_ids: Vec<u32>,
pub geom_type: GeomType,
pub geom_data: Blob,
}
impl Default for VectorTileFeature {
fn default() -> Self {
VectorTileFeature {
id: None,
tag_ids: Vec::new(),
geom_type: GeomType::Unknown,
geom_data: Blob::new_empty(),
}
}
}
pub(super) fn ring_signed_double_area(coords: &[Coord<f64>]) -> f64 {
let n = coords.len();
if n < 3 {
return 0.0;
}
let mut sum = 0.0;
let mut prev = coords[n - 1];
for &p in coords {
sum += (prev.x - p.x) * (p.y + prev.y);
prev = p;
}
sum
}
pub(super) const WINDING_EPSILON: f64 = 1e-14;
pub(crate) fn normalize_polygon_winding(poly: Polygon<f64>) -> Polygon<f64> {
let (mut exterior, interiors) = poly.into_inner();
if ring_signed_double_area(&exterior.0) < -WINDING_EPSILON {
exterior.0.reverse();
}
let interiors = interiors
.into_iter()
.map(|mut interior| {
if ring_signed_double_area(&interior.0) > WINDING_EPSILON {
interior.0.reverse();
}
interior
})
.collect();
Polygon::new(exterior, interiors)
}
pub(crate) fn normalize_multipolygon_winding(mp: MultiPolygon<f64>) -> MultiPolygon<f64> {
MultiPolygon(mp.0.into_iter().map(normalize_polygon_winding).collect())
}
pub(super) fn ring_is_degenerate(coords: &[Coord<f64>]) -> bool {
let n = if coords.len() >= 2 && coords.first() == coords.last() {
coords.len() - 1
} else {
coords.len()
};
if n < 3 {
return true;
}
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 true;
}
ring_signed_double_area(coords).abs() < WINDING_EPSILON
}
pub(super) fn parse_geom_command_stream(geom_data: &Blob) -> Result<Vec<Vec<Coord<f64>>>> {
let mut reader = ValueReaderSlice::new_le(geom_data.as_slice());
let mut lines: Vec<Vec<Coord<f64>>> = Vec::new();
let mut line: Vec<Coord<f64>> = Vec::new();
let mut x = 0i64;
let mut y = 0i64;
while reader.has_remaining()? {
let value = reader
.read_varint()
.context("Failed to read varint for geometry command")?;
let command = value & 0x7;
let count = value >> 3;
match command {
1 | 2 => {
for _ in 0..count {
if command == 1 && !line.is_empty() {
lines.push(line);
line = Vec::new();
}
x += reader.read_svarint().context("Failed to read x coordinate")?;
y += reader.read_svarint().context("Failed to read y coordinate")?;
#[allow(clippy::cast_precision_loss)]
line.push(Coord {
x: x as f64,
y: y as f64,
});
}
}
7 => {
ensure!(!line.is_empty(), "ClosePath command found on an empty linestring");
line.push(line[0]);
}
_ => bail!("Unknown command {command}"),
}
}
if !line.is_empty() {
lines.push(line);
}
Ok(lines)
}
impl VectorTileFeature {
pub fn read(reader: &mut dyn ValueReader<'_, LE>) -> Result<VectorTileFeature> {
let mut f = VectorTileFeature::default();
while reader.has_remaining()? {
match reader.read_pbf_key().context("Failed to read PBF key")? {
(1, 0) => f.id = Some(reader.read_varint().context("Failed to read feature ID")?),
(2, 2) => f.tag_ids = reader.read_pbf_packed_uint32().context("Failed to read tag IDs")?,
(3, 0) => f.geom_type = GeomType::from(reader.read_varint().context("Failed to read geometry type")?),
(4, 2) => f.geom_data = reader.read_pbf_blob().context("Failed to read geometry data")?,
(f, w) => bail!("Unexpected combination of field number ({f}) and wire type ({w})"),
}
}
Ok(f)
}
pub fn to_blob(&self) -> Result<Blob> {
let mut writer = ValueWriterBlob::new_le();
if let Some(id) = self.id {
writer
.write_pbf_key(1, 0)
.context("Failed to write PBF key for feature ID")?;
writer.write_varint(id).context("Failed to write feature ID")?;
}
if !self.tag_ids.is_empty() {
writer
.write_pbf_key(2, 2)
.context("Failed to write PBF key for tag IDs")?;
writer
.write_pbf_packed_uint32(&self.tag_ids)
.context("Failed to write tag IDs")?;
}
writer
.write_pbf_key(3, 0)
.context("Failed to write PBF key for geometry type")?;
writer
.write_varint(self.geom_type.as_u64())
.context("Failed to write geometry type")?;
writer
.write_pbf_key(4, 2)
.context("Failed to write PBF key for geometry data")?;
writer
.write_pbf_blob(&self.geom_data)
.context("Failed to write geometry data")?;
Ok(writer.into_blob())
}
#[must_use]
pub fn count_geometry_points(&self) -> usize {
let mut reader = ValueReaderSlice::new_le(self.geom_data.as_slice());
let mut total = 0usize;
while reader.has_remaining().unwrap_or(false) {
let Ok(value) = reader.read_varint() else { break };
let command = value & 0x7;
let count = value >> 3;
match command {
1 | 2 => {
for _ in 0..count {
if reader.read_svarint().is_err() || reader.read_svarint().is_err() {
return total;
}
total += 1;
}
}
7 => {}
_ => break,
}
}
total
}
pub fn to_geometry(&self) -> Result<Geometry<f64>> {
let coordinates = parse_geom_command_stream(&self.geom_data)?;
match self.geom_type {
GeomType::Unknown => Ok(Geometry::GeometryCollection(GeometryCollection(vec![]))),
GeomType::MultiPoint => {
if coordinates.is_empty() {
return Ok(Geometry::MultiPoint(MultiPoint(vec![])));
}
let points = coordinates
.into_iter()
.map(|mut entry| {
ensure!(entry.len() == 1, "(Multi)Point entries must have exactly one entry");
Ok(Point(entry.pop().expect("ensured len == 1 above")))
})
.collect::<Result<Vec<Point<f64>>>>()?;
Ok(Geometry::MultiPoint(MultiPoint(points)))
}
GeomType::MultiLineString => {
if coordinates.is_empty() {
return Ok(Geometry::MultiLineString(MultiLineString(vec![])));
}
let lines = coordinates.into_iter().map(LineString::new).collect::<Vec<_>>();
let g = Geometry::MultiLineString(MultiLineString(lines));
validate(&g).context("Invalid MultiLineString")?;
Ok(g)
}
GeomType::MultiPolygon => {
if coordinates.is_empty() {
return Ok(Geometry::MultiPolygon(MultiPolygon(vec![])));
}
let mut current_polygon: Vec<LineString<f64>> = Vec::new();
let mut polygons: Vec<Polygon<f64>> = Vec::new();
let mut bad_winding_warned = false;
let push_polygon = |rings: Vec<LineString<f64>>, polygons: &mut Vec<Polygon<f64>>| {
if let Some((exterior, interiors)) = rings.split_first() {
polygons.push(Polygon::new(exterior.clone(), interiors.to_vec()));
}
};
for ring_coords in coordinates {
let area2 = ring_signed_double_area(&ring_coords);
ensure!(ring_coords.len() >= 4, "polygon ring must have at least 4 points");
ensure!(ring_coords.first() == ring_coords.last(), "polygon ring must be closed");
let ring = LineString::new(ring_coords);
if area2 > 1e-14 {
if !current_polygon.is_empty() {
push_polygon(std::mem::take(&mut current_polygon), &mut polygons);
}
current_polygon.push(ring);
} else if area2 < -1e-14 {
if current_polygon.is_empty() {
if !bad_winding_warned {
log::warn!(
"Dropping orphan inner ring with no preceding outer — \
likely indicates inverted polygon winding in source MVT \
(violates MVT 2.1 §4.3.3.3)"
);
bad_winding_warned = true;
}
} else {
current_polygon.push(ring);
}
} else {
log::debug!("Skipping polygon ring with zero area (collinear or degenerate vertices)");
}
}
if !current_polygon.is_empty() {
push_polygon(current_polygon, &mut polygons);
}
Ok(Geometry::MultiPolygon(MultiPolygon(polygons)))
}
}
}
pub fn decode_properties(&self, layer: &VectorTileLayer) -> Result<GeoProperties> {
layer.decode_tag_ids(&self.tag_ids)
}
pub fn to_feature(&self, layer: &VectorTileLayer) -> Result<GeoFeature> {
let mut feature = GeoFeature::new(self.to_geometry().context("Failed to convert to geometry")?);
if let Some(id) = self.id {
feature.set_id(GeoValue::from(id));
}
feature.properties = layer.decode_tag_ids(&self.tag_ids)?;
Ok(feature)
}
pub fn to_geometry_lenient(&self) -> Result<Geometry<f64>> {
if self.geom_type == GeomType::MultiPolygon {
self.decode_polygon_lenient()
} else {
self.to_geometry()
}
}
pub fn to_feature_lenient(&self, layer: &VectorTileLayer) -> Result<GeoFeature> {
let mut feature = GeoFeature::new(
self
.to_geometry_lenient()
.context("Failed to convert to geometry (lenient)")?,
);
if let Some(id) = self.id {
feature.set_id(GeoValue::from(id));
}
feature.properties = layer.decode_tag_ids(&self.tag_ids)?;
Ok(feature)
}
fn decode_polygon_lenient(&self) -> Result<Geometry<f64>> {
let rings = parse_geom_command_stream(&self.geom_data)?;
if rings.is_empty() {
return Ok(Geometry::MultiPolygon(MultiPolygon(vec![])));
}
let areas: Vec<f64> = rings.iter().map(|r| ring_signed_double_area(r)).collect();
let total_pos: f64 = areas.iter().filter(|&&a| a > WINDING_EPSILON).sum();
let total_neg: f64 = areas.iter().filter(|&&a| a < -WINDING_EPSILON).map(|a| -a).sum();
let outer_is_positive = total_pos >= total_neg;
let mut current: Vec<LineString<f64>> = Vec::new();
let mut polygons: Vec<Polygon<f64>> = Vec::new();
let push = |rings: Vec<LineString<f64>>, polygons: &mut Vec<Polygon<f64>>| {
if let Some((exterior, interiors)) = rings.split_first() {
polygons.push(Polygon::new(exterior.clone(), interiors.to_vec()));
}
};
for (mut ring_coords, area2) in rings.into_iter().zip(areas.iter().copied()) {
if area2.abs() <= WINDING_EPSILON {
continue;
}
ensure!(ring_coords.len() >= 4, "polygon ring must have at least 4 points");
ensure!(ring_coords.first() == ring_coords.last(), "polygon ring must be closed");
if !outer_is_positive {
ring_coords.reverse();
}
let is_outer = if outer_is_positive { area2 > 0.0 } else { area2 < 0.0 };
let ring = LineString::new(ring_coords);
if is_outer {
if !current.is_empty() {
push(std::mem::take(&mut current), &mut polygons);
}
current.push(ring);
} else if !current.is_empty() {
current.push(ring);
}
}
if !current.is_empty() {
push(current, &mut polygons);
}
Ok(Geometry::MultiPolygon(MultiPolygon(polygons)))
}
#[allow(clippy::too_many_lines)]
pub fn from_geometry(id: Option<u64>, tag_ids: Vec<u32>, geometry: Geometry<f64>) -> Result<VectorTileFeature> {
fn write_coord(writer: &mut ValueWriterBlob<LE>, coord0: &mut (i64, i64), coord: Coord<f64>) -> Result<()> {
let x = float_to_int(coord.x)?;
let y = float_to_int(coord.y)?;
writer.write_svarint(x - coord0.0)?;
writer.write_svarint(y - coord0.1)?;
coord0.0 = x;
coord0.1 = y;
Ok(())
}
fn write_points(points: MultiPoint<f64>) -> Result<Blob> {
let mut writer = ValueWriterBlob::new_le();
if points.0.is_empty() {
return Ok(writer.into_blob());
}
let point0 = &mut (0i64, 0i64);
writer.write_varint(((points.0.len() as u64) << 3) | 0x1)?;
for point in points.0 {
write_coord(&mut writer, point0, point.0)?;
}
Ok(writer.into_blob())
}
fn write_line_strings(line_strings: MultiLineString<f64>) -> Result<Blob> {
let mut writer = ValueWriterBlob::new_le();
let point0 = &mut (0i64, 0i64);
for line_string in line_strings.0 {
let coords = line_string.0;
let Some((first, rest)) = coords.split_first() else {
continue;
};
writer.write_varint((1 << 3) | 0x1)?; write_coord(&mut writer, point0, *first)?;
if !rest.is_empty() {
writer.write_varint(((rest.len() as u64) << 3) | 0x2)?; for &point in rest {
write_coord(&mut writer, point0, point)?;
}
}
}
Ok(writer.into_blob())
}
fn write_ring(writer: &mut ValueWriterBlob<LE>, point0: &mut (i64, i64), ring: &LineString<f64>) -> Result<()> {
let coords = &ring.0;
let trim_to = if coords.len() >= 2 && coords.first() == coords.last() {
coords.len() - 1
} else {
coords.len()
};
if trim_to < 3 {
return Ok(());
}
let coords = &coords[..trim_to];
let (first, rest) = coords.split_first().expect("trim_to >= 3");
writer.write_varint((1 << 3) | 0x1)?; write_coord(writer, point0, *first)?;
if !rest.is_empty() {
writer.write_varint(((rest.len() as u64) << 3) | 0x2)?; for &point in rest {
write_coord(writer, point0, point)?;
}
}
writer.write_varint(7)?; Ok(())
}
fn write_polygons(polygons: MultiPolygon<f64>) -> Result<Blob> {
let mut writer = ValueWriterBlob::new_le();
let point0 = &mut (0i64, 0i64);
let polygons = normalize_multipolygon_winding(polygons);
for polygon in polygons.0 {
let (exterior, interiors) = polygon.into_inner();
if ring_is_degenerate(&exterior.0) {
continue;
}
write_ring(&mut writer, point0, &exterior)?;
for interior in interiors {
if ring_is_degenerate(&interior.0) {
continue;
}
write_ring(&mut writer, point0, &interior)?;
}
}
Ok(writer.into_blob())
}
let (mut geom_type, geom_data) = match geometry {
Geometry::Point(p) => (GeomType::MultiPoint, write_points(MultiPoint(vec![p]))?),
Geometry::MultiPoint(mp) => (GeomType::MultiPoint, write_points(mp)?),
Geometry::LineString(ls) => (
GeomType::MultiLineString,
write_line_strings(MultiLineString(vec![ls]))?,
),
Geometry::MultiLineString(ml) => (GeomType::MultiLineString, write_line_strings(ml)?),
Geometry::Polygon(p) => (GeomType::MultiPolygon, write_polygons(MultiPolygon(vec![p]))?),
Geometry::MultiPolygon(mp) => (GeomType::MultiPolygon, write_polygons(mp)?),
Geometry::Line(_) => bail!("MVT encoding of Line is not supported"),
Geometry::Rect(_) => bail!("MVT encoding of Rect is not supported"),
Geometry::Triangle(_) => bail!("MVT encoding of Triangle is not supported"),
Geometry::GeometryCollection(gc) if gc.0.is_empty() => (GeomType::Unknown, Blob::new_empty()),
Geometry::GeometryCollection(_) => bail!("MVT encoding of non-empty GeometryCollection is not supported"),
};
if geom_data.is_empty() {
geom_type = GeomType::Unknown;
}
Ok(VectorTileFeature {
id,
tag_ids,
geom_type,
geom_data,
})
}
#[cfg(test)]
pub fn new_example() -> Self {
VectorTileFeature::from_geometry(Some(3), vec![1, 2], crate::geo::example_geometry()).unwrap()
}
}
#[cfg(test)]
mod tests {
use super::*;
use geo_types::{LineString, MultiLineString, MultiPoint, MultiPolygon, Point, Polygon};
fn ls_from(pts: &[[i32; 2]]) -> LineString<f64> {
LineString::from(
pts.iter()
.map(|p| [f64::from(p[0]), f64::from(p[1])])
.collect::<Vec<_>>(),
)
}
fn polygon_from(rings: &[Vec<[i32; 2]>]) -> Polygon<f64> {
let mut iter = rings.iter().map(|ring| ls_from(ring));
let exterior = iter.next().expect("polygon has exterior");
let interiors = iter.collect();
Polygon::new(exterior, interiors)
}
fn round_trip_feature(geometry: Geometry<f64>) -> Result<()> {
let vector_tile_feature = VectorTileFeature::from_geometry(None, vec![], geometry.clone())?;
let decoded = vector_tile_feature.to_geometry()?;
assert_eq!(canonical_multi(geometry), decoded);
Ok(())
}
fn canonical_multi(g: Geometry<f64>) -> Geometry<f64> {
match g {
Geometry::Point(p) => Geometry::MultiPoint(MultiPoint(vec![p])),
Geometry::LineString(ls) => Geometry::MultiLineString(MultiLineString(vec![ls])),
Geometry::Polygon(p) => Geometry::MultiPolygon(MultiPolygon(vec![p])),
other => other,
}
}
#[test]
fn point_geometry_round_trip() -> Result<()> {
round_trip_feature(Geometry::Point(Point::new(1.0, 2.0)))
}
#[test]
fn line_string_geometry_round_trip() -> Result<()> {
round_trip_feature(Geometry::LineString(ls_from(&[[0, 1], [0, 3]])))
}
#[test]
fn polygon_geometry_round_trip() -> Result<()> {
let p = polygon_from(&[
vec![[0, 0], [3, 0], [3, 3], [0, 3], [0, 0]],
vec![[1, 1], [1, 2], [2, 2], [1, 1]],
]);
round_trip_feature(Geometry::Polygon(p))
}
#[test]
fn multi_point_geometry_round_trip() -> Result<()> {
let mp = MultiPoint(vec![Point::new(2.0, 3.0), Point::new(4.0, 5.0)]);
round_trip_feature(Geometry::MultiPoint(mp))
}
#[test]
fn multi_line_string_geometry_round_trip() -> Result<()> {
let ml = MultiLineString(vec![
ls_from(&[[0, 0], [1, 1], [2, 0]]),
ls_from(&[[0, 2], [1, 1], [2, 2]]),
]);
round_trip_feature(Geometry::MultiLineString(ml))
}
#[test]
fn multi_polygon_geometry_round_trip() -> Result<()> {
let mp = MultiPolygon(vec![
polygon_from(&[
vec![[0, 0], [3, 0], [3, 3], [0, 3], [0, 0]],
vec![[1, 1], [1, 2], [2, 2], [1, 1]],
]),
polygon_from(&[
vec![[4, 0], [7, 0], [7, 3], [4, 3], [4, 0]],
vec![[5, 1], [5, 2], [6, 2], [5, 1]],
]),
]);
round_trip_feature(Geometry::MultiPolygon(mp))
}
#[test]
fn rejects_unsupported_variants() {
let l = geo_types::Line::new(Coord { x: 0.0, y: 0.0 }, Coord { x: 1.0, y: 1.0 });
assert!(VectorTileFeature::from_geometry(None, vec![], Geometry::Line(l)).is_err());
}
#[test]
fn to_blob_always_emits_geometry_field() -> Result<()> {
use versatiles_core::io::ValueReaderSlice;
let cases: Vec<Geometry<f64>> = vec![
Geometry::Point(Point::new(1.0, 2.0)),
Geometry::MultiPoint(MultiPoint(vec![])),
Geometry::MultiLineString(MultiLineString(vec![])),
Geometry::MultiPolygon(MultiPolygon(vec![])),
];
for g in cases {
let label = format!("{g:?}");
let feat = VectorTileFeature::from_geometry(None, vec![], g)?;
let blob = feat.to_blob()?;
let mut reader = ValueReaderSlice::new_le(blob.as_slice());
let decoded = VectorTileFeature::read(&mut reader).expect(&label);
assert_eq!(
decoded.geom_data.as_slice(),
feat.geom_data.as_slice(),
"geom_data round-trip mismatch for {label}"
);
}
Ok(())
}
#[test]
fn from_geometry_downgrades_empty_to_unknown() -> Result<()> {
let cases: Vec<Geometry<f64>> = vec![
Geometry::MultiPoint(MultiPoint(vec![])),
Geometry::LineString(LineString::new(vec![])),
Geometry::MultiLineString(MultiLineString(vec![])),
Geometry::MultiPolygon(MultiPolygon(vec![])),
];
for g in cases {
let label = format!("{g:?}");
let feat = VectorTileFeature::from_geometry(None, vec![], g)?;
assert_eq!(feat.geom_type, GeomType::Unknown, "{label}");
assert!(feat.geom_data.is_empty(), "{label}");
}
Ok(())
}
#[test]
fn from_geometry_downgrades_all_degenerate_polygon_to_unknown() -> Result<()> {
let collinear = polygon_from(&[vec![[0, 0], [1, 1], [2, 2], [0, 0]]]);
let feat = VectorTileFeature::from_geometry(None, vec![], Geometry::Polygon(collinear))?;
assert_eq!(feat.geom_type, GeomType::Unknown);
assert!(feat.geom_data.is_empty());
Ok(())
}
fn coords(pts: &[(f64, f64)]) -> Vec<Coord<f64>> {
pts.iter().map(|&(x, y)| Coord { x, y }).collect()
}
#[test]
fn ring_signed_double_area_ccw_is_positive() {
let ring = coords(&[(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0), (0.0, 0.0)]);
assert!((ring_signed_double_area(&ring) - 2.0).abs() < 1e-12);
}
#[test]
fn ring_signed_double_area_cw_is_negative() {
let ring = coords(&[(0.0, 0.0), (0.0, 1.0), (1.0, 1.0), (1.0, 0.0), (0.0, 0.0)]);
assert!((ring_signed_double_area(&ring) - (-2.0)).abs() < 1e-12);
}
#[test]
fn ring_signed_double_area_degenerate_returns_zero() {
assert!(ring_signed_double_area(&[]).abs() < 1e-12);
assert!(ring_signed_double_area(&coords(&[(0.0, 0.0)])).abs() < 1e-12);
assert!(ring_signed_double_area(&coords(&[(0.0, 0.0), (1.0, 1.0)])).abs() < 1e-12);
}
#[test]
fn ring_signed_double_area_collinear_is_zero() {
let ring = coords(&[(0.0, 0.0), (1.0, 1.0), (2.0, 2.0), (0.0, 0.0)]);
assert!(ring_signed_double_area(&ring).abs() < 1e-12);
}
#[cfg(test)]
fn raw_polygon_feature(rings: &[Vec<(i32, i32)>]) -> VectorTileFeature {
use versatiles_core::io::{ValueWriter, ValueWriterBlob};
let mut writer = ValueWriterBlob::new_le();
let mut prev = (0i64, 0i64);
for ring in rings {
assert!(ring.len() >= 3, "ring needs at least 3 vertices");
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;
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);
}
writer.write_varint(7).unwrap(); }
VectorTileFeature {
id: None,
tag_ids: vec![],
geom_type: GeomType::MultiPolygon,
geom_data: writer.into_blob(),
}
}
#[test]
fn orphan_inner_rings_decode_to_empty_multipolygon() -> Result<()> {
let inner_a = vec![(0, 0), (0, 100), (100, 100), (100, 0), (0, 0)]; let inner_b = vec![(200, 200), (200, 300), (300, 300), (300, 200), (200, 200)];
let feature = raw_polygon_feature(&[inner_a, inner_b]);
let geom = feature.to_geometry()?;
match geom {
Geometry::MultiPolygon(mp) => {
assert!(
mp.0.is_empty(),
"orphan inner rings must be dropped; got {} polygon(s)",
mp.0.len()
);
}
other => panic!("expected MultiPolygon, got {other:?}"),
}
Ok(())
}
#[test]
fn zero_area_rings_decode_to_empty_multipolygon() -> Result<()> {
let collinear = vec![(0, 0), (10, 10), (20, 20), (0, 0)];
let feature = raw_polygon_feature(&[collinear]);
let geom = feature.to_geometry()?;
match geom {
Geometry::MultiPolygon(mp) => assert!(mp.0.is_empty()),
other => panic!("expected MultiPolygon, got {other:?}"),
}
Ok(())
}
#[test]
fn lenient_decode_recovers_inverted_winding_polygon_with_hole() -> Result<()> {
let outer_ccw = vec![(0, 0), (0, 100), (100, 100), (100, 0)]; let inner_cw = vec![(20, 20), (60, 20), (60, 60), (20, 60)]; let feature = raw_polygon_feature(&[outer_ccw, inner_cw]);
let strict = feature.to_geometry()?;
match strict {
Geometry::MultiPolygon(mp) => {
assert_eq!(mp.0.len(), 1, "strict: one (wrong) polygon");
assert!(mp.0[0].interiors().is_empty(), "strict: hole lost");
}
other => panic!("expected MultiPolygon, got {other:?}"),
}
let lenient = feature.to_geometry_lenient()?;
match lenient {
Geometry::MultiPolygon(mp) => {
assert_eq!(mp.0.len(), 1, "lenient: one polygon");
assert_eq!(mp.0[0].interiors().len(), 1, "lenient: hole preserved");
assert!(
ring_signed_double_area(&mp.0[0].exterior().0) > 0.0,
"lenient: exterior must be normalised to positive area"
);
assert!(
ring_signed_double_area(&mp.0[0].interiors()[0].0) < 0.0,
"lenient: interior must be normalised to negative area"
);
}
other => panic!("expected MultiPolygon, got {other:?}"),
}
Ok(())
}
#[test]
fn lenient_decode_preserves_standard_winding() -> Result<()> {
let outer_cw = vec![(0, 0), (100, 0), (100, 100), (0, 100)]; let inner_ccw = vec![(20, 20), (20, 60), (60, 60), (60, 20)]; let feature = raw_polygon_feature(&[outer_cw, inner_ccw]);
let geom = feature.to_geometry_lenient()?;
match geom {
Geometry::MultiPolygon(mp) => {
assert_eq!(mp.0.len(), 1);
assert_eq!(mp.0[0].interiors().len(), 1);
}
other => panic!("expected MultiPolygon, got {other:?}"),
}
Ok(())
}
#[test]
fn lenient_decode_empty_when_all_rings_degenerate() -> Result<()> {
let collinear = vec![(0, 0), (5, 5), (10, 10), (0, 0)];
let feature = raw_polygon_feature(&[collinear]);
let geom = feature.to_geometry_lenient()?;
match geom {
Geometry::MultiPolygon(mp) => assert!(mp.0.is_empty()),
other => panic!("expected MultiPolygon, got {other:?}"),
}
Ok(())
}
fn ring_pts(ls: &LineString<f64>) -> Vec<(f64, f64)> {
ls.0.iter().map(|c| (c.x, c.y)).collect()
}
#[test]
fn normalize_polygon_winding_noop_for_correct_input() {
let outer = ls_from(&[[0, 0], [4, 0], [4, 4], [0, 4], [0, 0]]);
let inner = ls_from(&[[1, 1], [1, 3], [3, 3], [3, 1], [1, 1]]);
let original = Polygon::new(outer.clone(), vec![inner.clone()]);
let normalized = normalize_polygon_winding(original);
assert_eq!(ring_pts(normalized.exterior()), ring_pts(&outer));
assert_eq!(normalized.interiors().len(), 1);
assert_eq!(ring_pts(&normalized.interiors()[0]), ring_pts(&inner));
}
#[test]
fn normalize_polygon_winding_reverses_inverted_outer() {
let outer_inverted = ls_from(&[[0, 0], [0, 4], [4, 4], [4, 0], [0, 0]]); let inner = ls_from(&[[1, 1], [1, 3], [3, 3], [3, 1], [1, 1]]); let original = Polygon::new(outer_inverted.clone(), vec![inner.clone()]);
let normalized = normalize_polygon_winding(original);
assert!(ring_signed_double_area(&normalized.exterior().0) > 0.0);
let mut reversed = ring_pts(&outer_inverted);
reversed.reverse();
assert_eq!(ring_pts(normalized.exterior()), reversed);
assert_eq!(ring_pts(&normalized.interiors()[0]), ring_pts(&inner));
}
#[test]
fn normalize_polygon_winding_reverses_inverted_inner() {
let outer = ls_from(&[[0, 0], [4, 0], [4, 4], [0, 4], [0, 0]]); let inner_inverted = ls_from(&[[1, 1], [3, 1], [3, 3], [1, 3], [1, 1]]); let original = Polygon::new(outer.clone(), vec![inner_inverted.clone()]);
let normalized = normalize_polygon_winding(original);
assert_eq!(ring_pts(normalized.exterior()), ring_pts(&outer));
assert!(ring_signed_double_area(&normalized.interiors()[0].0) < 0.0);
let mut reversed = ring_pts(&inner_inverted);
reversed.reverse();
assert_eq!(ring_pts(&normalized.interiors()[0]), reversed);
}
#[test]
fn normalize_polygon_winding_leaves_degenerate_alone() {
let outer_collinear = ls_from(&[[0, 0], [1, 1], [2, 2], [0, 0]]);
let original = Polygon::new(outer_collinear.clone(), vec![]);
let normalized = normalize_polygon_winding(original);
assert_eq!(ring_pts(normalized.exterior()), ring_pts(&outer_collinear));
}
#[test]
fn from_geometry_normalises_inverted_winding() -> Result<()> {
let outer_inverted = ls_from(&[[0, 0], [0, 4], [4, 4], [4, 0], [0, 0]]); let inner_inverted = ls_from(&[[1, 1], [3, 1], [3, 3], [1, 3], [1, 1]]); let bad = Polygon::new(outer_inverted, vec![inner_inverted]);
let feature = VectorTileFeature::from_geometry(None, vec![], Geometry::Polygon(bad))?;
let decoded = feature.to_geometry()?;
match decoded {
Geometry::MultiPolygon(mp) => {
assert_eq!(mp.0.len(), 1, "exactly one polygon survives the round-trip");
assert_eq!(
mp.0[0].interiors().len(),
1,
"hole must survive — was lost pre-fix because of inverted winding"
);
assert!(ring_signed_double_area(&mp.0[0].exterior().0) > 0.0);
assert!(ring_signed_double_area(&mp.0[0].interiors()[0].0) < 0.0);
}
other => panic!("expected MultiPolygon, got {other:?}"),
}
Ok(())
}
#[test]
fn ring_is_degenerate_too_few_vertices() {
assert!(ring_is_degenerate(&coords(&[(0.0, 0.0), (1.0, 0.0), (0.0, 0.0)]))); assert!(ring_is_degenerate(&coords(&[(0.0, 0.0), (1.0, 0.0)])));
assert!(ring_is_degenerate(&[]));
}
#[test]
fn ring_is_degenerate_collinear() {
assert!(ring_is_degenerate(&coords(&[
(0.0, 0.0),
(1.0, 1.0),
(2.0, 2.0),
(0.0, 0.0),
])));
}
#[test]
fn ring_is_degenerate_subpixel_collapses_to_grid_point() {
assert!(ring_is_degenerate(&coords(&[
(0.0, 0.0),
(0.1, 0.1),
(-0.2, 0.2),
(0.0, 0.0),
])));
}
#[test]
fn ring_is_degenerate_normal_triangle_is_fine() {
assert!(!ring_is_degenerate(&coords(&[
(0.0, 0.0),
(4.0, 0.0),
(0.0, 4.0),
(0.0, 0.0),
])));
}
#[test]
fn encoder_drops_polygon_with_degenerate_exterior() -> Result<()> {
let degenerate_exterior = ls_from(&[[0, 0], [1, 1], [2, 2], [0, 0]]);
let valid_hole = ls_from(&[[3, 3], [3, 5], [5, 5], [5, 3], [3, 3]]);
let poly = Polygon::new(degenerate_exterior, vec![valid_hole]);
let feature = VectorTileFeature::from_geometry(None, vec![], Geometry::Polygon(poly))?;
assert_eq!(feature.geom_type, GeomType::Unknown);
assert!(feature.geom_data.is_empty());
Ok(())
}
#[test]
fn encoder_drops_degenerate_interior_but_keeps_exterior() -> Result<()> {
let exterior = ls_from(&[[0, 0], [10, 0], [10, 10], [0, 10], [0, 0]]); let degenerate_hole = ls_from(&[[3, 3], [4, 4], [5, 5], [3, 3]]); let valid_hole = ls_from(&[[6, 6], [6, 8], [8, 8], [8, 6], [6, 6]]); let poly = Polygon::new(exterior, vec![degenerate_hole, valid_hole]);
let feature = VectorTileFeature::from_geometry(None, vec![], Geometry::Polygon(poly))?;
let decoded = feature.to_geometry()?;
match decoded {
Geometry::MultiPolygon(mp) => {
assert_eq!(mp.0.len(), 1, "polygon survives");
assert_eq!(
mp.0[0].interiors().len(),
1,
"only the valid hole survives — got {}",
mp.0[0].interiors().len()
);
}
other => panic!("expected MultiPolygon, got {other:?}"),
}
Ok(())
}
#[test]
fn encoder_drops_degenerate_polygons_from_multipolygon() -> Result<()> {
let valid = Polygon::new(ls_from(&[[0, 0], [4, 0], [4, 4], [0, 4], [0, 0]]), vec![]);
let bad = Polygon::new(ls_from(&[[10, 10], [11, 11], [12, 12], [10, 10]]), vec![]);
let mp = MultiPolygon(vec![valid, bad]);
let feature = VectorTileFeature::from_geometry(None, vec![], Geometry::MultiPolygon(mp))?;
let decoded = feature.to_geometry()?;
match decoded {
Geometry::MultiPolygon(mp) => {
assert_eq!(mp.0.len(), 1, "only the valid polygon survives");
}
other => panic!("expected MultiPolygon, got {other:?}"),
}
Ok(())
}
#[test]
fn normalize_multipolygon_winding_decides_per_polygon() {
let p1 = Polygon::new(ls_from(&[[0, 0], [4, 0], [4, 4], [0, 4], [0, 0]]), vec![]);
let p2 = Polygon::new(
ls_from(&[[10, 10], [10, 14], [14, 14], [14, 10], [10, 10]]),
vec![ls_from(&[[11, 11], [13, 11], [13, 13], [11, 13], [11, 11]])],
);
let mp = MultiPolygon(vec![p1.clone(), p2]);
let normalized = normalize_multipolygon_winding(mp);
assert_eq!(ring_pts(normalized.0[0].exterior()), ring_pts(p1.exterior()));
assert!(ring_signed_double_area(&normalized.0[1].exterior().0) > 0.0);
assert!(ring_signed_double_area(&normalized.0[1].interiors()[0].0) < 0.0);
}
}