rustial-engine 0.0.1

Framework-agnostic 2.5D map engine for rustial
Documentation
//! Shapefile parser.
//!
//! Converts ESRI Shapefiles into the engine's internal geometry types.
//! Gated behind the `shapefile` Cargo feature flag.

use crate::geometry::*;
use rustial_math::GeoCoord;
use thiserror::Error;

/// Error type for Shapefile parsing.
#[derive(Debug, Error)]
pub enum ShapefileError {
    /// Failed to read or parse the Shapefile.
    #[error("shapefile read error: {0}")]
    Read(String),
    /// Unsupported shape type encountered.
    #[error("unsupported shape type: {0}")]
    UnsupportedShape(String),
}

/// Parse Shapefile bytes (`.shp` content) into a [`FeatureCollection`].
///
/// This reads the `.shp` binary data directly via [`shapefile::ShapeReader`].
/// DBF attribute data is not parsed -- all features will have empty
/// `properties`.
///
/// Supported shape types: Point, PolyLine, Polygon, MultiPoint,
/// PointZ, PolyLineZ, PolygonZ, MultiPointZ.
///
/// # Errors
///
/// Returns [`ShapefileError::Read`] if the binary data is malformed,
/// or [`ShapefileError::UnsupportedShape`] for shape types that cannot
/// be mapped to the engine's geometry model.
#[cfg(feature = "shapefile")]
pub fn parse_shapefile(shp_bytes: &[u8]) -> Result<FeatureCollection, ShapefileError> {
    use std::io::Cursor;

    let mut reader = shapefile::ShapeReader::new(Cursor::new(shp_bytes))
        .map_err(|e| ShapefileError::Read(e.to_string()))?;

    let mut features = Vec::new();

    for shape_result in reader.iter_shapes_as::<shapefile::Shape>() {
        let shape = shape_result.map_err(|e| ShapefileError::Read(e.to_string()))?;
        let geometry = shape_to_geometry(shape)?;
        features.push(Feature {
            geometry,
            properties: std::collections::HashMap::new(),
        });
    }

    Ok(FeatureCollection { features })
}

#[cfg(feature = "shapefile")]
fn shape_to_geometry(shape: shapefile::Shape) -> Result<Geometry, ShapefileError> {
    match shape {
        shapefile::Shape::Point(p) => Ok(Geometry::Point(Point {
            coord: GeoCoord::new(p.y, p.x, 0.0),
        })),
        shapefile::Shape::PointZ(p) => Ok(Geometry::Point(Point {
            coord: GeoCoord::new(p.y, p.x, p.z),
        })),
        shapefile::Shape::Polyline(pl) => {
            let lines: Vec<LineString> = pl
                .parts()
                .iter()
                .map(|part| LineString {
                    coords: part.iter().map(|p| GeoCoord::new(p.y, p.x, 0.0)).collect(),
                })
                .collect();
            if lines.len() == 1 {
                #[allow(clippy::unwrap_used)]
                Ok(Geometry::LineString(lines.into_iter().next().unwrap()))
            } else {
                Ok(Geometry::MultiLineString(MultiLineString { lines }))
            }
        }
        shapefile::Shape::PolylineZ(pl) => {
            let lines: Vec<LineString> = pl
                .parts()
                .iter()
                .map(|part| LineString {
                    coords: part.iter().map(|p| GeoCoord::new(p.y, p.x, p.z)).collect(),
                })
                .collect();
            if lines.len() == 1 {
                #[allow(clippy::unwrap_used)]
                Ok(Geometry::LineString(lines.into_iter().next().unwrap()))
            } else {
                Ok(Geometry::MultiLineString(MultiLineString { lines }))
            }
        }
        shapefile::Shape::Polygon(pg) => {
            let rings: Vec<Vec<GeoCoord>> = pg
                .rings()
                .iter()
                .map(|ring| match ring {
                    shapefile::PolygonRing::Outer(pts) | shapefile::PolygonRing::Inner(pts) => {
                        pts.iter().map(|p| GeoCoord::new(p.y, p.x, 0.0)).collect()
                    }
                })
                .collect();
            rings_to_polygon(rings)
        }
        shapefile::Shape::PolygonZ(pg) => {
            let rings: Vec<Vec<GeoCoord>> = pg
                .rings()
                .iter()
                .map(|ring| match ring {
                    shapefile::PolygonRing::Outer(pts) | shapefile::PolygonRing::Inner(pts) => {
                        pts.iter().map(|p| GeoCoord::new(p.y, p.x, p.z)).collect()
                    }
                })
                .collect();
            rings_to_polygon(rings)
        }
        shapefile::Shape::Multipoint(mp) => {
            let points = mp
                .points()
                .iter()
                .map(|p| Point {
                    coord: GeoCoord::new(p.y, p.x, 0.0),
                })
                .collect();
            Ok(Geometry::MultiPoint(MultiPoint { points }))
        }
        shapefile::Shape::MultipointZ(mp) => {
            let points = mp
                .points()
                .iter()
                .map(|p| Point {
                    coord: GeoCoord::new(p.y, p.x, p.z),
                })
                .collect();
            Ok(Geometry::MultiPoint(MultiPoint { points }))
        }
        shapefile::Shape::NullShape => Ok(Geometry::GeometryCollection(Vec::new())),
        _ => Err(ShapefileError::UnsupportedShape(
            "unsupported shape variant".into(),
        )),
    }
}

#[cfg(feature = "shapefile")]
fn rings_to_polygon(rings: Vec<Vec<GeoCoord>>) -> Result<Geometry, ShapefileError> {
    if rings.is_empty() {
        return Ok(Geometry::Polygon(crate::geometry::Polygon {
            exterior: Vec::new(),
            interiors: Vec::new(),
        }));
    }
    let exterior = rings[0].clone();
    let interiors = rings[1..].to_vec();
    Ok(Geometry::Polygon(crate::geometry::Polygon {
        exterior,
        interiors,
    }))
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn error_display() {
        let e = ShapefileError::Read("bad".into());
        assert!(e.to_string().contains("bad"));

        let e = ShapefileError::UnsupportedShape("Multipatch".into());
        assert!(e.to_string().contains("Multipatch"));
    }

    #[cfg(feature = "shapefile")]
    #[test]
    fn parse_invalid_bytes() {
        let result = parse_shapefile(b"not a shapefile");
        assert!(result.is_err());
    }
}