Skip to main content

nodedb_spatial/
wkt.rs

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