Skip to main content

nodedb_spatial/
wkt.rs

1//! Well-Known Text (WKT) serialization and parsing for Geometry types.
2//!
3//! Standard interchange format used by PostGIS, ArcadeDB (Spatial4J),
4//! and most GIS tools. Format examples:
5//! - `POINT(lng lat)`
6//! - `LINESTRING(lng1 lat1, lng2 lat2)`
7//! - `POLYGON((lng1 lat1, lng2 lat2, ...), (hole1...))`
8//! - `MULTIPOINT((lng1 lat1), (lng2 lat2))`
9//! - `GEOMETRYCOLLECTION(POINT(...), LINESTRING(...))`
10
11use nodedb_types::geometry::Geometry;
12
13/// Serialize a Geometry to WKT string.
14pub fn geometry_to_wkt(geom: &Geometry) -> String {
15    match geom {
16        Geometry::Point { coordinates } => {
17            format!("POINT({} {})", coordinates[0], coordinates[1])
18        }
19        Geometry::LineString { coordinates } => {
20            format!("LINESTRING({})", coords_to_wkt(coordinates))
21        }
22        Geometry::Polygon { coordinates } => {
23            let rings: Vec<String> = coordinates
24                .iter()
25                .map(|ring| format!("({})", coords_to_wkt(ring)))
26                .collect();
27            format!("POLYGON({})", rings.join(", "))
28        }
29        Geometry::MultiPoint { coordinates } => {
30            let pts: Vec<String> = coordinates
31                .iter()
32                .map(|c| format!("({} {})", c[0], c[1]))
33                .collect();
34            format!("MULTIPOINT({})", pts.join(", "))
35        }
36        Geometry::MultiLineString { coordinates } => {
37            let lines: Vec<String> = coordinates
38                .iter()
39                .map(|ls| format!("({})", coords_to_wkt(ls)))
40                .collect();
41            format!("MULTILINESTRING({})", lines.join(", "))
42        }
43        Geometry::MultiPolygon { coordinates } => {
44            let polys: Vec<String> = coordinates
45                .iter()
46                .map(|poly| {
47                    let rings: Vec<String> = poly
48                        .iter()
49                        .map(|ring| format!("({})", coords_to_wkt(ring)))
50                        .collect();
51                    format!("({})", rings.join(", "))
52                })
53                .collect();
54            format!("MULTIPOLYGON({})", polys.join(", "))
55        }
56        Geometry::GeometryCollection { geometries } => {
57            let geoms: Vec<String> = geometries.iter().map(geometry_to_wkt).collect();
58            format!("GEOMETRYCOLLECTION({})", geoms.join(", "))
59        }
60    }
61}
62
63/// Parse a WKT string into a Geometry.
64///
65/// Returns `None` if the input is malformed.
66pub fn geometry_from_wkt(input: &str) -> Option<Geometry> {
67    let s = input.trim();
68    if let Some(rest) = strip_prefix_ci(s, "GEOMETRYCOLLECTION") {
69        parse_geometry_collection(rest.trim())
70    } else if let Some(rest) = strip_prefix_ci(s, "MULTIPOLYGON") {
71        parse_multipolygon(rest.trim())
72    } else if let Some(rest) = strip_prefix_ci(s, "MULTILINESTRING") {
73        parse_multilinestring(rest.trim())
74    } else if let Some(rest) = strip_prefix_ci(s, "MULTIPOINT") {
75        parse_multipoint(rest.trim())
76    } else if let Some(rest) = strip_prefix_ci(s, "POLYGON") {
77        parse_polygon(rest.trim())
78    } else if let Some(rest) = strip_prefix_ci(s, "LINESTRING") {
79        parse_linestring(rest.trim())
80    } else if let Some(rest) = strip_prefix_ci(s, "POINT") {
81        parse_point(rest.trim())
82    } else {
83        None
84    }
85}
86
87// ── Serialization helpers ──
88
89fn coords_to_wkt(coords: &[[f64; 2]]) -> String {
90    coords
91        .iter()
92        .map(|c| format!("{} {}", c[0], c[1]))
93        .collect::<Vec<_>>()
94        .join(", ")
95}
96
97// ── Parsing helpers ──
98
99/// Case-insensitive prefix strip.
100fn strip_prefix_ci<'a>(s: &'a str, prefix: &str) -> Option<&'a str> {
101    if s.len() >= prefix.len() && s[..prefix.len()].eq_ignore_ascii_case(prefix) {
102        Some(&s[prefix.len()..])
103    } else {
104        None
105    }
106}
107
108/// Strip outer parentheses: "(content)" → "content"
109fn strip_parens(s: &str) -> Option<&str> {
110    let s = s.trim();
111    if s.starts_with('(') && s.ends_with(')') {
112        Some(&s[1..s.len() - 1])
113    } else {
114        None
115    }
116}
117
118/// Parse "lng lat" pair.
119fn parse_coord(s: &str) -> Option<[f64; 2]> {
120    let s = s.trim();
121    let mut parts = s.split_whitespace();
122    let lng: f64 = parts.next()?.parse().ok()?;
123    let lat: f64 = parts.next()?.parse().ok()?;
124    Some([lng, lat])
125}
126
127/// Parse "lng1 lat1, lng2 lat2, ..." into coordinate vec.
128fn parse_coord_list(s: &str) -> Option<Vec<[f64; 2]>> {
129    s.split(',').map(parse_coord).collect()
130}
131
132fn parse_point(s: &str) -> Option<Geometry> {
133    let inner = strip_parens(s)?;
134    let coord = parse_coord(inner)?;
135    Some(Geometry::Point { coordinates: coord })
136}
137
138fn parse_linestring(s: &str) -> Option<Geometry> {
139    let inner = strip_parens(s)?;
140    let coords = parse_coord_list(inner)?;
141    Some(Geometry::LineString {
142        coordinates: coords,
143    })
144}
145
146fn parse_polygon(s: &str) -> Option<Geometry> {
147    let inner = strip_parens(s)?;
148    let rings = split_top_level_parens(inner)?;
149    let ring_coords: Option<Vec<Vec<[f64; 2]>>> = rings
150        .iter()
151        .map(|r| parse_coord_list(strip_parens(r.trim())?))
152        .collect();
153    Some(Geometry::Polygon {
154        coordinates: ring_coords?,
155    })
156}
157
158fn parse_multipoint(s: &str) -> Option<Geometry> {
159    let inner = strip_parens(s)?;
160    // MULTIPOINT((x y), (x y)) or MULTIPOINT(x y, x y)
161    let coords = if inner.contains('(') {
162        let parts = split_top_level_parens(inner)?;
163        parts
164            .iter()
165            .map(|p| parse_coord(strip_parens(p.trim())?))
166            .collect::<Option<Vec<_>>>()?
167    } else {
168        parse_coord_list(inner)?
169    };
170    Some(Geometry::MultiPoint {
171        coordinates: coords,
172    })
173}
174
175fn parse_multilinestring(s: &str) -> Option<Geometry> {
176    let inner = strip_parens(s)?;
177    let parts = split_top_level_parens(inner)?;
178    let lines: Option<Vec<Vec<[f64; 2]>>> = parts
179        .iter()
180        .map(|p| parse_coord_list(strip_parens(p.trim())?))
181        .collect();
182    Some(Geometry::MultiLineString {
183        coordinates: lines?,
184    })
185}
186
187fn parse_multipolygon(s: &str) -> Option<Geometry> {
188    let inner = strip_parens(s)?;
189    let poly_parts = split_top_level_parens(inner)?;
190    let polys: Option<Vec<Vec<Vec<[f64; 2]>>>> = poly_parts
191        .iter()
192        .map(|p| {
193            let rings_str = strip_parens(p.trim())?;
194            let ring_parts = split_top_level_parens(rings_str)?;
195            ring_parts
196                .iter()
197                .map(|r| parse_coord_list(strip_parens(r.trim())?))
198                .collect::<Option<Vec<_>>>()
199        })
200        .collect();
201    Some(Geometry::MultiPolygon {
202        coordinates: polys?,
203    })
204}
205
206fn parse_geometry_collection(s: &str) -> Option<Geometry> {
207    let inner = strip_parens(s)?;
208    // Split by top-level commas (not inside parentheses).
209    let parts = split_top_level_items(inner);
210    let geoms: Option<Vec<Geometry>> = parts.iter().map(|p| geometry_from_wkt(p.trim())).collect();
211    Some(Geometry::GeometryCollection { geometries: geoms? })
212}
213
214/// Split by commas at the top level of parentheses nesting.
215/// "(...), (...)" → ["(...)", "(...)"]
216fn split_top_level_parens(s: &str) -> Option<Vec<String>> {
217    let mut parts = Vec::new();
218    let mut depth = 0;
219    let mut start = 0;
220
221    for (i, ch) in s.char_indices() {
222        match ch {
223            '(' => depth += 1,
224            ')' => depth -= 1,
225            ',' if depth == 0 => {
226                parts.push(s[start..i].to_string());
227                start = i + 1;
228            }
229            _ => {}
230        }
231    }
232    if start < s.len() {
233        parts.push(s[start..].to_string());
234    }
235    if parts.is_empty() { None } else { Some(parts) }
236}
237
238/// Split geometry collection items by top-level commas, handling nested parens.
239fn split_top_level_items(s: &str) -> Vec<String> {
240    let mut parts = Vec::new();
241    let mut depth = 0;
242    let mut start = 0;
243
244    for (i, ch) in s.char_indices() {
245        match ch {
246            '(' => depth += 1,
247            ')' => depth -= 1,
248            ',' if depth == 0 => {
249                parts.push(s[start..i].to_string());
250                start = i + 1;
251            }
252            _ => {}
253        }
254    }
255    if start < s.len() {
256        parts.push(s[start..].to_string());
257    }
258    parts
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264
265    #[test]
266    fn point_roundtrip() {
267        let geom = Geometry::point(-73.9857, 40.7484);
268        let wkt = geometry_to_wkt(&geom);
269        assert!(wkt.starts_with("POINT("));
270        let parsed = geometry_from_wkt(&wkt).unwrap();
271        assert_eq!(geom, parsed);
272    }
273
274    #[test]
275    fn linestring_roundtrip() {
276        let geom = Geometry::line_string(vec![[0.0, 0.0], [1.0, 1.0], [2.0, 0.0]]);
277        let wkt = geometry_to_wkt(&geom);
278        let parsed = geometry_from_wkt(&wkt).unwrap();
279        assert_eq!(geom, parsed);
280    }
281
282    #[test]
283    fn polygon_roundtrip() {
284        let geom = Geometry::polygon(vec![vec![
285            [0.0, 0.0],
286            [10.0, 0.0],
287            [10.0, 10.0],
288            [0.0, 10.0],
289            [0.0, 0.0],
290        ]]);
291        let wkt = geometry_to_wkt(&geom);
292        assert!(wkt.starts_with("POLYGON("));
293        let parsed = geometry_from_wkt(&wkt).unwrap();
294        assert_eq!(geom, parsed);
295    }
296
297    #[test]
298    fn polygon_with_hole_roundtrip() {
299        let geom = Geometry::polygon(vec![
300            vec![
301                [0.0, 0.0],
302                [10.0, 0.0],
303                [10.0, 10.0],
304                [0.0, 10.0],
305                [0.0, 0.0],
306            ],
307            vec![[2.0, 2.0], [8.0, 2.0], [8.0, 8.0], [2.0, 8.0], [2.0, 2.0]],
308        ]);
309        let wkt = geometry_to_wkt(&geom);
310        let parsed = geometry_from_wkt(&wkt).unwrap();
311        assert_eq!(geom, parsed);
312    }
313
314    #[test]
315    fn multipoint_roundtrip() {
316        let geom = Geometry::MultiPoint {
317            coordinates: vec![[1.0, 2.0], [3.0, 4.0]],
318        };
319        let wkt = geometry_to_wkt(&geom);
320        let parsed = geometry_from_wkt(&wkt).unwrap();
321        assert_eq!(geom, parsed);
322    }
323
324    #[test]
325    fn multilinestring_roundtrip() {
326        let geom = Geometry::MultiLineString {
327            coordinates: vec![vec![[0.0, 0.0], [1.0, 1.0]], vec![[2.0, 2.0], [3.0, 3.0]]],
328        };
329        let wkt = geometry_to_wkt(&geom);
330        let parsed = geometry_from_wkt(&wkt).unwrap();
331        assert_eq!(geom, parsed);
332    }
333
334    #[test]
335    fn multipolygon_roundtrip() {
336        let geom = Geometry::MultiPolygon {
337            coordinates: vec![
338                vec![vec![[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 0.0]]],
339                vec![vec![[5.0, 5.0], [6.0, 5.0], [6.0, 6.0], [5.0, 5.0]]],
340            ],
341        };
342        let wkt = geometry_to_wkt(&geom);
343        let parsed = geometry_from_wkt(&wkt).unwrap();
344        assert_eq!(geom, parsed);
345    }
346
347    #[test]
348    fn geometry_collection_roundtrip() {
349        let geom = Geometry::GeometryCollection {
350            geometries: vec![
351                Geometry::point(1.0, 2.0),
352                Geometry::line_string(vec![[0.0, 0.0], [1.0, 1.0]]),
353            ],
354        };
355        let wkt = geometry_to_wkt(&geom);
356        assert!(wkt.starts_with("GEOMETRYCOLLECTION("));
357        let parsed = geometry_from_wkt(&wkt).unwrap();
358        assert_eq!(geom, parsed);
359    }
360
361    #[test]
362    fn case_insensitive_parse() {
363        let parsed = geometry_from_wkt("point(5 10)").unwrap();
364        assert_eq!(parsed, Geometry::point(5.0, 10.0));
365    }
366
367    #[test]
368    fn invalid_wkt_returns_none() {
369        assert!(geometry_from_wkt("").is_none());
370        assert!(geometry_from_wkt("GARBAGE(1 2)").is_none());
371        assert!(geometry_from_wkt("POINT(abc def)").is_none());
372    }
373}