Skip to main content

shapefile_wasm/
geometry.rs

1//! Turns parsed GeoJSON geometries into concrete shapefile shapes.
2//!
3//! The shapefile format has no single "geometry" type — each dimensionality is a
4//! separate Rust type — so the conversion is generic over the point type and the
5//! caller picks the concrete one from the resolved [`Dimension`].
6
7use shapefile::record::multipoint::GenericMultipoint;
8use shapefile::record::polygon::GenericPolygon;
9use shapefile::record::polyline::GenericPolyline;
10use shapefile::record::traits::{GrowablePoint, HasXY, ShrinkablePoint};
11use shapefile::{Point, PointM, PointZ, PolygonRing, NO_DATA};
12
13use crate::error::{Result, ShapefileError};
14use crate::input::{Geometry, Position};
15
16/// Which ordinates end up in the output file.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum Dimension {
19    /// Plain 2D — `Point`, `Polyline`, `Polygon`, `Multipoint`.
20    Xy,
21    /// 2D plus a measure — the third GeoJSON ordinate is read as M.
22    Xym,
23    /// 3D — the third ordinate is Z, M is left as "no data".
24    Xyz,
25    /// 3D plus a measure — the fourth ordinate is M.
26    Xyzm,
27}
28
29impl Dimension {
30    pub fn label(self) -> &'static str {
31        match self {
32            Dimension::Xy => "xy",
33            Dimension::Xym => "xym",
34            Dimension::Xyz => "xyz",
35            Dimension::Xyzm => "xyzm",
36        }
37    }
38
39    pub fn parse(name: &str) -> Option<Self> {
40        match name.to_ascii_lowercase().as_str() {
41            "xy" | "2d" => Some(Dimension::Xy),
42            "xym" => Some(Dimension::Xym),
43            "xyz" | "3d" => Some(Dimension::Xyz),
44            "xyzm" | "xyzm4" | "4d" => Some(Dimension::Xyzm),
45            _ => None,
46        }
47    }
48
49    /// Picks a dimension from how many ordinates the richest coordinate carried.
50    pub fn from_arity(max_arity: usize) -> Self {
51        match max_arity {
52            0..=2 => Dimension::Xy,
53            3 => Dimension::Xyz,
54            _ => Dimension::Xyzm,
55        }
56    }
57
58    /// The suffix ESRI uses for this dimensionality, e.g. `PolylineZ`.
59    pub fn suffix(self) -> &'static str {
60        match self {
61            Dimension::Xy => "",
62            Dimension::Xym => "M",
63            Dimension::Xyz | Dimension::Xyzm => "Z",
64        }
65    }
66}
67
68/// Builds a concrete shapefile point from a GeoJSON position.
69pub trait FromPosition: Copy {
70    fn from_position(position: &Position, dimension: Dimension) -> Self;
71}
72
73impl FromPosition for Point {
74    fn from_position(position: &Position, _dimension: Dimension) -> Self {
75        Point::new(position.x, position.y)
76    }
77}
78
79impl FromPosition for PointM {
80    fn from_position(position: &Position, _dimension: Dimension) -> Self {
81        // Only ever selected for `Xym`, where the third ordinate is the measure.
82        PointM::new(position.x, position.y, position.third.unwrap_or(NO_DATA))
83    }
84}
85
86impl FromPosition for PointZ {
87    fn from_position(position: &Position, dimension: Dimension) -> Self {
88        let z = position.third.unwrap_or(0.0);
89        let m = match dimension {
90            Dimension::Xyzm => position.fourth.unwrap_or(NO_DATA),
91            _ => NO_DATA,
92        };
93        PointZ::new(position.x, position.y, z, m)
94    }
95}
96
97/// The trait soup the shapefile constructors require of a point type.
98pub trait ShapePoint:
99    FromPosition + Copy + PartialEq + HasXY + GrowablePoint + ShrinkablePoint
100{
101}
102
103impl<T> ShapePoint for T where
104    T: FromPosition + Copy + PartialEq + HasXY + GrowablePoint + ShrinkablePoint
105{
106}
107
108fn convert<P: ShapePoint>(positions: &[Position], dimension: Dimension) -> Vec<P> {
109    positions
110        .iter()
111        .map(|position| P::from_position(position, dimension))
112        .collect()
113}
114
115/// A GeoJSON geometry that should have been a point.
116pub fn to_point<P: ShapePoint>(
117    geometry: &Geometry,
118    dimension: Dimension,
119    index: usize,
120) -> Result<P> {
121    match geometry {
122        Geometry::Point(position) => Ok(P::from_position(position, dimension)),
123        other => Err(mismatch(other, "Point", index)),
124    }
125}
126
127pub fn to_multipoint<P: ShapePoint>(
128    geometry: &Geometry,
129    dimension: Dimension,
130    index: usize,
131) -> Result<GenericMultipoint<P>> {
132    // A lone Point is a legal one-element Multipoint, which is what lets mixed
133    // Point/MultiPoint input share a file.
134    let positions: Vec<Position> = match geometry {
135        Geometry::MultiPoint(positions) => positions.clone(),
136        Geometry::Point(position) => vec![*position],
137        other => return Err(mismatch(other, "Multipoint", index)),
138    };
139
140    if positions.is_empty() {
141        return Err(ShapefileError::Feature {
142            index,
143            message: "a multipoint needs at least one coordinate".into(),
144        });
145    }
146
147    Ok(GenericMultipoint::new(convert::<P>(&positions, dimension)))
148}
149
150pub fn to_polyline<P: ShapePoint>(
151    geometry: &Geometry,
152    dimension: Dimension,
153    index: usize,
154) -> Result<GenericPolyline<P>> {
155    let parts: Vec<Vec<Position>> = match geometry {
156        Geometry::LineString(positions) => vec![positions.clone()],
157        Geometry::MultiLineString(parts) => parts.clone(),
158        other => return Err(mismatch(other, "Polyline", index)),
159    };
160
161    if parts.is_empty() {
162        return Err(ShapefileError::Feature {
163            index,
164            message: "a polyline needs at least one part".into(),
165        });
166    }
167
168    // `GenericPolyline::with_parts` asserts on short parts, which in wasm means
169    // an abort with no message. Reject them here with something actionable.
170    if let Some(short) = parts.iter().find(|part| part.len() < 2) {
171        return Err(ShapefileError::Feature {
172            index,
173            message: format!(
174                "every line needs at least 2 coordinates, found one with {}",
175                short.len()
176            ),
177        });
178    }
179
180    let converted = parts
181        .iter()
182        .map(|part| convert::<P>(part, dimension))
183        .collect();
184
185    Ok(GenericPolyline::with_parts(converted))
186}
187
188pub fn to_polygon<P: ShapePoint>(
189    geometry: &Geometry,
190    dimension: Dimension,
191    index: usize,
192) -> Result<GenericPolygon<P>> {
193    // A shapefile polygon is a flat list of rings, so a MultiPolygon collapses
194    // into the same structure as a Polygon.
195    let polygons: Vec<Vec<Vec<Position>>> = match geometry {
196        Geometry::Polygon(rings) => vec![rings.clone()],
197        Geometry::MultiPolygon(polygons) => polygons.clone(),
198        other => return Err(mismatch(other, "Polygon", index)),
199    };
200
201    let mut rings: Vec<PolygonRing<P>> = Vec::new();
202    for polygon in &polygons {
203        for (ring_index, ring) in polygon.iter().enumerate() {
204            if ring.len() < 3 {
205                return Err(ShapefileError::Feature {
206                    index,
207                    message: format!(
208                        "every polygon ring needs at least 3 coordinates, found one with {}",
209                        ring.len()
210                    ),
211                });
212            }
213            let points = convert::<P>(ring, dimension);
214            // GeoJSON puts the exterior ring first; the rest are holes.
215            // `with_rings` fixes the winding order for us.
216            rings.push(if ring_index == 0 {
217                PolygonRing::Outer(points)
218            } else {
219                PolygonRing::Inner(points)
220            });
221        }
222    }
223
224    if rings.is_empty() {
225        return Err(ShapefileError::Feature {
226            index,
227            message: "a polygon needs at least one ring".into(),
228        });
229    }
230
231    Ok(GenericPolygon::with_rings(rings))
232}
233
234fn mismatch(geometry: &Geometry, expected: &str, index: usize) -> ShapefileError {
235    ShapefileError::MixedGeometry {
236        first: expected.to_string(),
237        other: geometry.family().label().to_string(),
238        index,
239    }
240}