Skip to main content

oxiphysics_io/
geospatial_io.rs

1// Copyright 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Geospatial I/O: GeoJSON, WKT, CRS metadata, DEM rasters, bounding-box
5//! queries, Mercator projections, terrain tiles, geohash, R-tree-like spatial
6//! index and a simplified DBF attribute table.
7//!
8//! All coordinate values are plain `f64` or `[f64; 3]` (longitude, latitude,
9//! optional elevation). No external geometry library is used.
10
11#![allow(dead_code)]
12#![allow(clippy::too_many_arguments)]
13
14// ─────────────────────────────────────────────────────────────────────────────
15// 1. Errors
16// ─────────────────────────────────────────────────────────────────────────────
17
18#[derive(Debug)]
19pub enum GeoError {
20    /// The input string could not be parsed.
21    ParseError(String),
22    /// A requested feature or geometry was not found.
23    NotFound(String),
24    /// The coordinate reference system is unsupported.
25    UnsupportedCrs(String),
26    /// Generic I/O error message.
27    Io(String),
28}
29
30impl std::fmt::Display for GeoError {
31    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32        match self {
33            GeoError::ParseError(m) => write!(f, "parse error: {m}"),
34            GeoError::NotFound(m) => write!(f, "not found: {m}"),
35            GeoError::UnsupportedCrs(m) => write!(f, "unsupported CRS: {m}"),
36            GeoError::Io(m) => write!(f, "I/O error: {m}"),
37        }
38    }
39}
40
41impl std::error::Error for GeoError {}
42
43/// Convenience alias for geospatial results.
44pub type GeoResult<T> = Result<T, GeoError>;
45
46// ─────────────────────────────────────────────────────────────────────────────
47// 2. Coordinate types
48// ─────────────────────────────────────────────────────────────────────────────
49
50/// A 2-D geographic position `(longitude_deg, latitude_deg)`.
51#[derive(Debug, Clone, Copy, PartialEq)]
52pub struct LonLat {
53    /// Longitude in decimal degrees (−180 … +180).
54    pub lon: f64,
55    /// Latitude in decimal degrees (−90 … +90).
56    pub lat: f64,
57}
58
59impl LonLat {
60    /// Construct a new `LonLat`.
61    #[must_use]
62    pub fn new(lon: f64, lat: f64) -> Self {
63        Self { lon, lat }
64    }
65
66    /// Haversine distance in metres between two geographic positions.
67    #[must_use]
68    pub fn haversine_m(&self, other: &LonLat) -> f64 {
69        const R: f64 = 6_371_000.0; // Earth mean radius [m]
70        let dlat = (other.lat - self.lat).to_radians();
71        let dlon = (other.lon - self.lon).to_radians();
72        let lat1 = self.lat.to_radians();
73        let lat2 = other.lat.to_radians();
74        let a = (dlat / 2.0).sin().powi(2) + lat1.cos() * lat2.cos() * (dlon / 2.0).sin().powi(2);
75        let c = 2.0 * a.sqrt().asin();
76        R * c
77    }
78}
79
80/// A 3-D geographic position with elevation.
81#[derive(Debug, Clone, Copy, PartialEq)]
82pub struct LonLatElev {
83    /// Longitude in decimal degrees.
84    pub lon: f64,
85    /// Latitude in decimal degrees.
86    pub lat: f64,
87    /// Elevation above the ellipsoid in metres.
88    pub elev: f64,
89}
90
91impl LonLatElev {
92    /// Construct from individual components.
93    #[must_use]
94    pub fn new(lon: f64, lat: f64, elev: f64) -> Self {
95        Self { lon, lat, elev }
96    }
97
98    /// Drop elevation and return the 2-D position.
99    #[must_use]
100    pub fn to_lonlat(&self) -> LonLat {
101        LonLat::new(self.lon, self.lat)
102    }
103}
104
105// ─────────────────────────────────────────────────────────────────────────────
106// 3. Bounding box
107// ─────────────────────────────────────────────────────────────────────────────
108
109/// Axis-aligned bounding box in geographic (lon/lat) space.
110#[derive(Debug, Clone, Copy, PartialEq)]
111pub struct BoundingBox {
112    /// Minimum longitude.
113    pub min_lon: f64,
114    /// Minimum latitude.
115    pub min_lat: f64,
116    /// Maximum longitude.
117    pub max_lon: f64,
118    /// Maximum latitude.
119    pub max_lat: f64,
120}
121
122impl BoundingBox {
123    /// Construct a `BoundingBox` from corner coordinates.
124    #[must_use]
125    pub fn new(min_lon: f64, min_lat: f64, max_lon: f64, max_lat: f64) -> Self {
126        Self {
127            min_lon,
128            min_lat,
129            max_lon,
130            max_lat,
131        }
132    }
133
134    /// Return `true` if `pt` lies within or on the boundary of this box.
135    #[must_use]
136    pub fn contains(&self, pt: LonLat) -> bool {
137        pt.lon >= self.min_lon
138            && pt.lon <= self.max_lon
139            && pt.lat >= self.min_lat
140            && pt.lat <= self.max_lat
141    }
142
143    /// Return `true` if `other` overlaps with this box.
144    #[must_use]
145    pub fn intersects(&self, other: &BoundingBox) -> bool {
146        !(other.min_lon > self.max_lon
147            || other.max_lon < self.min_lon
148            || other.min_lat > self.max_lat
149            || other.max_lat < self.min_lat)
150    }
151
152    /// Expand this box to include `pt`.
153    pub fn expand_to_include(&mut self, pt: LonLat) {
154        if pt.lon < self.min_lon {
155            self.min_lon = pt.lon;
156        }
157        if pt.lon > self.max_lon {
158            self.max_lon = pt.lon;
159        }
160        if pt.lat < self.min_lat {
161            self.min_lat = pt.lat;
162        }
163        if pt.lat > self.max_lat {
164            self.max_lat = pt.lat;
165        }
166    }
167
168    /// Centre point of the bounding box.
169    #[must_use]
170    pub fn centre(&self) -> LonLat {
171        LonLat::new(
172            (self.min_lon + self.max_lon) / 2.0,
173            (self.min_lat + self.max_lat) / 2.0,
174        )
175    }
176
177    /// Width in degrees of longitude.
178    #[must_use]
179    pub fn width_deg(&self) -> f64 {
180        self.max_lon - self.min_lon
181    }
182
183    /// Height in degrees of latitude.
184    #[must_use]
185    pub fn height_deg(&self) -> f64 {
186        self.max_lat - self.min_lat
187    }
188}
189
190// ─────────────────────────────────────────────────────────────────────────────
191// 4. Coordinate Reference System metadata
192// ─────────────────────────────────────────────────────────────────────────────
193
194/// Named coordinate reference system authority codes (EPSG subset).
195#[derive(Debug, Clone, PartialEq, Eq)]
196pub enum CrsCode {
197    /// WGS 84 geographic (EPSG:4326).
198    Epsg4326,
199    /// Web Mercator (EPSG:3857).
200    Epsg3857,
201    /// UTM zone (Northern/Southern hemisphere, zone number 1–60).
202    Utm {
203        /// UTM zone number (1–60).
204        zone: u8,
205        /// `true` for the Northern hemisphere, `false` for the Southern.
206        north: bool,
207    },
208    /// A custom CRS identified by a free-form string.
209    Custom(String),
210}
211
212impl std::fmt::Display for CrsCode {
213    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
214        match self {
215            CrsCode::Epsg4326 => write!(f, "EPSG:4326"),
216            CrsCode::Epsg3857 => write!(f, "EPSG:3857"),
217            CrsCode::Utm { zone, north } => {
218                let hemi = if *north { "N" } else { "S" };
219                write!(f, "UTM Zone {zone}{hemi}")
220            }
221            CrsCode::Custom(s) => write!(f, "{s}"),
222        }
223    }
224}
225
226/// Metadata bundle describing a coordinate reference system.
227#[derive(Debug, Clone)]
228pub struct CrsMetadata {
229    /// The authority code identifying this CRS.
230    pub code: CrsCode,
231    /// Human-readable name.
232    pub name: String,
233    /// Datum name (e.g. "WGS 84").
234    pub datum: String,
235    /// Unit of measure for coordinate axes.
236    pub units: String,
237}
238
239impl CrsMetadata {
240    /// WGS 84 geographic CRS.
241    #[must_use]
242    pub fn wgs84() -> Self {
243        Self {
244            code: CrsCode::Epsg4326,
245            name: "WGS 84".to_string(),
246            datum: "WGS 84".to_string(),
247            units: "degree".to_string(),
248        }
249    }
250
251    /// Web Mercator projected CRS.
252    #[must_use]
253    pub fn web_mercator() -> Self {
254        Self {
255            code: CrsCode::Epsg3857,
256            name: "WGS 84 / Pseudo-Mercator".to_string(),
257            datum: "WGS 84".to_string(),
258            units: "metre".to_string(),
259        }
260    }
261
262    /// Serialise to a minimal WKT2 string.
263    #[must_use]
264    pub fn to_wkt2(&self) -> String {
265        format!(
266            r#"GEOGCRS["{}",DATUM["{}"],CS[ellipsoidal,2],AXIS["longitude",east],AXIS["latitude",north],UNIT["{}",1]]"#,
267            self.name, self.datum, self.units
268        )
269    }
270}
271
272// ─────────────────────────────────────────────────────────────────────────────
273// 5. GeoJSON geometry types
274// ─────────────────────────────────────────────────────────────────────────────
275
276/// A GeoJSON geometry value.
277#[derive(Debug, Clone, PartialEq)]
278pub enum GeoGeometry {
279    /// A single position.
280    Point(LonLat),
281    /// A single position with elevation.
282    Point3D(LonLatElev),
283    /// An ordered sequence of positions forming an open curve.
284    LineString(Vec<LonLat>),
285    /// A polygon defined by an exterior ring and zero or more interior rings.
286    Polygon {
287        /// Exterior ring (closed — first == last position).
288        exterior: Vec<LonLat>,
289        /// Interior rings (holes).
290        holes: Vec<Vec<LonLat>>,
291    },
292    /// A collection of points.
293    MultiPoint(Vec<LonLat>),
294    /// A collection of line strings.
295    MultiLineString(Vec<Vec<LonLat>>),
296    /// A collection of polygons.
297    MultiPolygon(Vec<(Vec<LonLat>, Vec<Vec<LonLat>>)>),
298    /// A heterogeneous collection of geometries.
299    GeometryCollection(Vec<GeoGeometry>),
300}
301
302impl GeoGeometry {
303    /// Compute the axis-aligned bounding box of this geometry.
304    ///
305    /// Returns `None` for empty collections.
306    #[must_use]
307    pub fn bounding_box(&self) -> Option<BoundingBox> {
308        let pts = self.collect_lonlat();
309        if pts.is_empty() {
310            return None;
311        }
312        let mut bb = BoundingBox::new(pts[0].lon, pts[0].lat, pts[0].lon, pts[0].lat);
313        for p in &pts[1..] {
314            bb.expand_to_include(*p);
315        }
316        Some(bb)
317    }
318
319    /// Recursively collect all `LonLat` positions from this geometry.
320    #[must_use]
321    pub fn collect_lonlat(&self) -> Vec<LonLat> {
322        match self {
323            GeoGeometry::Point(p) => vec![*p],
324            GeoGeometry::Point3D(p) => vec![p.to_lonlat()],
325            GeoGeometry::LineString(pts) => pts.clone(),
326            GeoGeometry::Polygon { exterior, holes } => {
327                let mut v = exterior.clone();
328                for h in holes {
329                    v.extend_from_slice(h);
330                }
331                v
332            }
333            GeoGeometry::MultiPoint(pts) => pts.clone(),
334            GeoGeometry::MultiLineString(lines) => lines.iter().flatten().copied().collect(),
335            GeoGeometry::MultiPolygon(polys) => polys
336                .iter()
337                .flat_map(|(ext, holes)| {
338                    let mut v = ext.clone();
339                    for h in holes {
340                        v.extend_from_slice(h);
341                    }
342                    v
343                })
344                .collect(),
345            GeoGeometry::GeometryCollection(gs) => {
346                gs.iter().flat_map(|g| g.collect_lonlat()).collect()
347            }
348        }
349    }
350
351    /// Return the GeoJSON `type` string for this geometry.
352    #[must_use]
353    pub fn type_str(&self) -> &'static str {
354        match self {
355            GeoGeometry::Point(_) | GeoGeometry::Point3D(_) => "Point",
356            GeoGeometry::LineString(_) => "LineString",
357            GeoGeometry::Polygon { .. } => "Polygon",
358            GeoGeometry::MultiPoint(_) => "MultiPoint",
359            GeoGeometry::MultiLineString(_) => "MultiLineString",
360            GeoGeometry::MultiPolygon(_) => "MultiPolygon",
361            GeoGeometry::GeometryCollection(_) => "GeometryCollection",
362        }
363    }
364}
365
366// ─────────────────────────────────────────────────────────────────────────────
367// 6. GeoJSON serialisation helpers
368// ─────────────────────────────────────────────────────────────────────────────
369
370/// Serialise a `LonLat` to a GeoJSON coordinate pair string `[lon,lat]`.
371#[must_use]
372pub fn lonlat_to_json(p: LonLat) -> String {
373    format!("[{:.6},{:.6}]", p.lon, p.lat)
374}
375
376/// Serialise a ring (closed polygon boundary) to a JSON array of pairs.
377#[must_use]
378pub fn ring_to_json(pts: &[LonLat]) -> String {
379    let items: Vec<String> = pts.iter().map(|p| lonlat_to_json(*p)).collect();
380    format!("[{}]", items.join(","))
381}
382
383/// Serialise a `GeoGeometry` to its GeoJSON `geometry` object string.
384#[must_use]
385pub fn geometry_to_geojson(g: &GeoGeometry) -> String {
386    match g {
387        GeoGeometry::Point(p) => {
388            format!(r#"{{"type":"Point","coordinates":{}}}"#, lonlat_to_json(*p))
389        }
390        GeoGeometry::Point3D(p) => {
391            format!(
392                r#"{{"type":"Point","coordinates":[{:.6},{:.6},{:.6}]}}"#,
393                p.lon, p.lat, p.elev
394            )
395        }
396        GeoGeometry::LineString(pts) => {
397            let coords: Vec<String> = pts.iter().map(|p| lonlat_to_json(*p)).collect();
398            format!(
399                r#"{{"type":"LineString","coordinates":[{}]}}"#,
400                coords.join(",")
401            )
402        }
403        GeoGeometry::Polygon { exterior, holes } => {
404            let mut rings = vec![ring_to_json(exterior)];
405            for h in holes {
406                rings.push(ring_to_json(h));
407            }
408            format!(
409                r#"{{"type":"Polygon","coordinates":[{}]}}"#,
410                rings.join(",")
411            )
412        }
413        GeoGeometry::MultiPoint(pts) => {
414            let coords: Vec<String> = pts.iter().map(|p| lonlat_to_json(*p)).collect();
415            format!(
416                r#"{{"type":"MultiPoint","coordinates":[{}]}}"#,
417                coords.join(",")
418            )
419        }
420        GeoGeometry::MultiLineString(lines) => {
421            let ls: Vec<String> = lines
422                .iter()
423                .map(|line| {
424                    let cs: Vec<String> = line.iter().map(|p| lonlat_to_json(*p)).collect();
425                    format!("[{}]", cs.join(","))
426                })
427                .collect();
428            format!(
429                r#"{{"type":"MultiLineString","coordinates":[{}]}}"#,
430                ls.join(",")
431            )
432        }
433        GeoGeometry::MultiPolygon(polys) => {
434            let ps: Vec<String> = polys
435                .iter()
436                .map(|(ext, holes)| {
437                    let mut rings = vec![ring_to_json(ext)];
438                    for h in holes {
439                        rings.push(ring_to_json(h));
440                    }
441                    format!("[{}]", rings.join(","))
442                })
443                .collect();
444            format!(
445                r#"{{"type":"MultiPolygon","coordinates":[{}]}}"#,
446                ps.join(",")
447            )
448        }
449        GeoGeometry::GeometryCollection(gs) => {
450            let inner: Vec<String> = gs.iter().map(geometry_to_geojson).collect();
451            format!(
452                r#"{{"type":"GeometryCollection","geometries":[{}]}}"#,
453                inner.join(",")
454            )
455        }
456    }
457}
458
459// ─────────────────────────────────────────────────────────────────────────────
460// 7. GeoJSON Feature and FeatureCollection
461// ─────────────────────────────────────────────────────────────────────────────
462
463/// A single string-valued property.
464#[derive(Debug, Clone, PartialEq)]
465pub struct GeoProperty {
466    /// Property name.
467    pub key: String,
468    /// Property value as a string (numbers are stored as strings for simplicity).
469    pub value: String,
470}
471
472impl GeoProperty {
473    /// Construct from key/value string pair.
474    #[must_use]
475    pub fn new(key: impl Into<String>, value: impl Into<String>) -> Self {
476        Self {
477            key: key.into(),
478            value: value.into(),
479        }
480    }
481
482    /// Serialise to `"key":"value"` JSON fragment.
483    #[must_use]
484    pub fn to_json_pair(&self) -> String {
485        format!(r#""{}":{}"#, self.key, json_string_or_number(&self.value))
486    }
487}
488
489/// Attempt to treat `s` as a JSON number; fall back to a quoted string.
490fn json_string_or_number(s: &str) -> String {
491    if s.parse::<f64>().is_ok() {
492        s.to_string()
493    } else {
494        format!(r#""{s}""#)
495    }
496}
497
498/// A GeoJSON `Feature` containing one geometry and a property bag.
499#[derive(Debug, Clone)]
500pub struct GeoFeature {
501    /// Optional feature identifier.
502    pub id: Option<String>,
503    /// The geometry associated with this feature.
504    pub geometry: Option<GeoGeometry>,
505    /// Key-value properties.
506    pub properties: Vec<GeoProperty>,
507}
508
509impl GeoFeature {
510    /// Construct a feature with a geometry and no properties.
511    #[must_use]
512    pub fn new(geometry: GeoGeometry) -> Self {
513        Self {
514            id: None,
515            geometry: Some(geometry),
516            properties: Vec::new(),
517        }
518    }
519
520    /// Add a property and return `self` for chaining.
521    #[must_use]
522    pub fn with_property(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
523        self.properties.push(GeoProperty::new(key, value));
524        self
525    }
526
527    /// Serialise this feature to a GeoJSON string.
528    #[must_use]
529    pub fn to_geojson(&self) -> String {
530        let geom_str = match &self.geometry {
531            Some(g) => geometry_to_geojson(g),
532            None => "null".to_string(),
533        };
534        let props: Vec<String> = self.properties.iter().map(|p| p.to_json_pair()).collect();
535        let id_str = match &self.id {
536            Some(id) => format!(r#","id":"{}""#, id),
537            None => String::new(),
538        };
539        format!(
540            r#"{{"type":"Feature"{id_str},"geometry":{geom_str},"properties":{{{}}}}}"#,
541            props.join(",")
542        )
543    }
544
545    /// Compute the bounding box of the feature's geometry.
546    #[must_use]
547    pub fn bounding_box(&self) -> Option<BoundingBox> {
548        self.geometry.as_ref().and_then(|g| g.bounding_box())
549    }
550}
551
552/// A GeoJSON `FeatureCollection`.
553#[derive(Debug, Clone, Default)]
554pub struct FeatureCollection {
555    /// The features contained in this collection.
556    pub features: Vec<GeoFeature>,
557    /// Optional CRS metadata (non-standard extension, but widely used).
558    pub crs: Option<CrsMetadata>,
559}
560
561impl FeatureCollection {
562    /// Create an empty collection.
563    #[must_use]
564    pub fn new() -> Self {
565        Self::default()
566    }
567
568    /// Add a feature.
569    pub fn add(&mut self, feature: GeoFeature) {
570        self.features.push(feature);
571    }
572
573    /// Return the number of features.
574    #[must_use]
575    pub fn len(&self) -> usize {
576        self.features.len()
577    }
578
579    /// Return `true` if the collection contains no features.
580    #[must_use]
581    pub fn is_empty(&self) -> bool {
582        self.features.is_empty()
583    }
584
585    /// Return features whose geometry bounding box intersects `bbox`.
586    #[must_use]
587    pub fn query_bbox(&self, bbox: &BoundingBox) -> Vec<&GeoFeature> {
588        self.features
589            .iter()
590            .filter(|f| {
591                f.bounding_box()
592                    .map(|bb| bb.intersects(bbox))
593                    .unwrap_or(false)
594            })
595            .collect()
596    }
597
598    /// Serialise to a GeoJSON `FeatureCollection` string.
599    #[must_use]
600    pub fn to_geojson(&self) -> String {
601        let feats: Vec<String> = self.features.iter().map(|f| f.to_geojson()).collect();
602        format!(
603            r#"{{"type":"FeatureCollection","features":[{}]}}"#,
604            feats.join(",")
605        )
606    }
607
608    /// Compute the bounding box enclosing all features.
609    #[must_use]
610    pub fn total_bounding_box(&self) -> Option<BoundingBox> {
611        let mut all_pts: Vec<LonLat> = self
612            .features
613            .iter()
614            .flat_map(|f| {
615                f.geometry
616                    .as_ref()
617                    .map(|g| g.collect_lonlat())
618                    .unwrap_or_default()
619            })
620            .collect();
621        if all_pts.is_empty() {
622            return None;
623        }
624        let first = all_pts.remove(0);
625        let mut bb = BoundingBox::new(first.lon, first.lat, first.lon, first.lat);
626        for p in all_pts {
627            bb.expand_to_include(p);
628        }
629        Some(bb)
630    }
631}
632
633// ─────────────────────────────────────────────────────────────────────────────
634// 8. WKT (Well-Known Text) geometry format
635// ─────────────────────────────────────────────────────────────────────────────
636
637/// Serialise a `GeoGeometry` to WKT.
638#[must_use]
639pub fn geometry_to_wkt(g: &GeoGeometry) -> String {
640    match g {
641        GeoGeometry::Point(p) => format!("POINT ({:.6} {:.6})", p.lon, p.lat),
642        GeoGeometry::Point3D(p) => format!("POINT Z ({:.6} {:.6} {:.6})", p.lon, p.lat, p.elev),
643        GeoGeometry::LineString(pts) => {
644            let coords = pts_to_wkt_coords(pts);
645            format!("LINESTRING ({coords})")
646        }
647        GeoGeometry::Polygon { exterior, holes } => {
648            let mut rings = vec![format!("({})", pts_to_wkt_coords(exterior))];
649            for h in holes {
650                rings.push(format!("({})", pts_to_wkt_coords(h)));
651            }
652            format!("POLYGON ({})", rings.join(","))
653        }
654        GeoGeometry::MultiPoint(pts) => {
655            let coords: Vec<String> = pts
656                .iter()
657                .map(|p| format!("({:.6} {:.6})", p.lon, p.lat))
658                .collect();
659            format!("MULTIPOINT ({})", coords.join(","))
660        }
661        GeoGeometry::MultiLineString(lines) => {
662            let ls: Vec<String> = lines
663                .iter()
664                .map(|l| format!("({})", pts_to_wkt_coords(l)))
665                .collect();
666            format!("MULTILINESTRING ({})", ls.join(","))
667        }
668        GeoGeometry::MultiPolygon(polys) => {
669            let ps: Vec<String> = polys
670                .iter()
671                .map(|(ext, holes)| {
672                    let mut rings = vec![format!("({})", pts_to_wkt_coords(ext))];
673                    for h in holes {
674                        rings.push(format!("({})", pts_to_wkt_coords(h)));
675                    }
676                    format!("({})", rings.join(","))
677                })
678                .collect();
679            format!("MULTIPOLYGON ({})", ps.join(","))
680        }
681        GeoGeometry::GeometryCollection(gs) => {
682            let inner: Vec<String> = gs.iter().map(geometry_to_wkt).collect();
683            format!("GEOMETRYCOLLECTION ({})", inner.join(","))
684        }
685    }
686}
687
688fn pts_to_wkt_coords(pts: &[LonLat]) -> String {
689    pts.iter()
690        .map(|p| format!("{:.6} {:.6}", p.lon, p.lat))
691        .collect::<Vec<_>>()
692        .join(",")
693}
694
695/// Parse a WKT `POINT (lon lat)` string.
696///
697/// Only handles 2-D `POINT` for brevity; returns `GeoError::ParseError` for
698/// unsupported types.
699pub fn parse_wkt_point(wkt: &str) -> GeoResult<GeoGeometry> {
700    let s = wkt.trim();
701    if !s.to_uppercase().starts_with("POINT") {
702        return Err(GeoError::ParseError(format!("expected POINT, got: {s}")));
703    }
704    let inner = s
705        .find('(')
706        .and_then(|i| {
707            let rest = &s[i + 1..];
708            rest.rfind(')').map(|j| &rest[..j])
709        })
710        .ok_or_else(|| GeoError::ParseError("missing parentheses".to_string()))?;
711    let parts: Vec<f64> = inner
712        .split_whitespace()
713        .map(|tok| tok.parse::<f64>())
714        .collect::<Result<_, _>>()
715        .map_err(|e| GeoError::ParseError(e.to_string()))?;
716    match parts.as_slice() {
717        [lon, lat] => Ok(GeoGeometry::Point(LonLat::new(*lon, *lat))),
718        [lon, lat, elev] => Ok(GeoGeometry::Point3D(LonLatElev::new(*lon, *lat, *elev))),
719        _ => Err(GeoError::ParseError(
720            "expected 2 or 3 coordinates".to_string(),
721        )),
722    }
723}
724
725// ─────────────────────────────────────────────────────────────────────────────
726// 9. Coordinate transformation — simplified Mercator
727// ─────────────────────────────────────────────────────────────────────────────
728
729/// Parameters for a Web-Mercator (EPSG:3857) projection.
730#[derive(Debug, Clone, Copy)]
731pub struct MercatorProjection {
732    /// Earth radius used in the spherical Mercator formula \[m\].
733    pub earth_radius_m: f64,
734}
735
736impl MercatorProjection {
737    /// Standard Web Mercator with R = 6 378 137 m.
738    #[must_use]
739    pub fn web_mercator() -> Self {
740        Self {
741            earth_radius_m: 6_378_137.0,
742        }
743    }
744
745    /// Forward projection: geographic (lon, lat) → Mercator (x, y) metres.
746    ///
747    /// Latitude is clamped to ±85.051129° to stay within the Mercator domain.
748    #[must_use]
749    pub fn forward(&self, pt: LonLat) -> [f64; 2] {
750        let lat_clamp = pt.lat.clamp(-85.051_129, 85.051_129);
751        let x = self.earth_radius_m * pt.lon.to_radians();
752        let y = self.earth_radius_m
753            * ((std::f64::consts::FRAC_PI_4 + lat_clamp.to_radians() / 2.0).tan()).ln();
754        [x, y]
755    }
756
757    /// Inverse projection: Mercator (x, y) → geographic (lon, lat).
758    #[must_use]
759    pub fn inverse(&self, xy: [f64; 2]) -> LonLat {
760        let lon = xy[0].to_degrees() / self.earth_radius_m;
761        let lat = (2.0 * (xy[1] / self.earth_radius_m).exp().atan() - std::f64::consts::FRAC_PI_2)
762            .to_degrees();
763        LonLat::new(lon, lat)
764    }
765
766    /// Project an entire `LonLat` slice to metres.
767    #[must_use]
768    pub fn forward_all(&self, pts: &[LonLat]) -> Vec<[f64; 2]> {
769        pts.iter().map(|p| self.forward(*p)).collect()
770    }
771}
772
773// ─────────────────────────────────────────────────────────────────────────────
774// 10. DEM raster (Digital Elevation Model)
775// ─────────────────────────────────────────────────────────────────────────────
776
777/// A raster grid of elevation values covering a bounding box.
778#[derive(Debug, Clone)]
779pub struct DemRaster {
780    /// Geographic extent of the raster.
781    pub bbox: BoundingBox,
782    /// Number of columns.
783    pub cols: usize,
784    /// Number of rows.
785    pub rows: usize,
786    /// Row-major elevation samples \[m\].  Length = rows × cols.
787    pub data: Vec<f32>,
788    /// No-data sentinel value.
789    pub nodata: f32,
790}
791
792impl DemRaster {
793    /// Construct a zero-filled DEM raster.
794    #[must_use]
795    pub fn zeros(bbox: BoundingBox, cols: usize, rows: usize) -> Self {
796        Self {
797            bbox,
798            cols,
799            rows,
800            data: vec![0.0_f32; cols * rows],
801            nodata: -9999.0,
802        }
803    }
804
805    /// Cell width in degrees.
806    #[must_use]
807    pub fn cell_width_deg(&self) -> f64 {
808        bbox_cell_size(self.bbox.min_lon, self.bbox.max_lon, self.cols)
809    }
810
811    /// Cell height in degrees.
812    #[must_use]
813    pub fn cell_height_deg(&self) -> f64 {
814        bbox_cell_size(self.bbox.min_lat, self.bbox.max_lat, self.rows)
815    }
816
817    /// Sample elevation at a geographic position using bilinear interpolation.
818    ///
819    /// Returns `None` if `pt` is outside the raster extent or resolves to a
820    /// no-data cell.
821    #[must_use]
822    pub fn sample(&self, pt: LonLat) -> Option<f32> {
823        if !self.bbox.contains(pt) {
824            return None;
825        }
826        let fx = (pt.lon - self.bbox.min_lon) / self.cell_width_deg();
827        let fy = (pt.lat - self.bbox.min_lat) / self.cell_height_deg();
828        let col = fx.floor() as usize;
829        let row = fy.floor() as usize;
830        let col = col.min(self.cols - 1);
831        let row = row.min(self.rows - 1);
832        let v = self.data[row * self.cols + col];
833        if v == self.nodata { None } else { Some(v) }
834    }
835
836    /// Return the minimum elevation, ignoring no-data values.
837    #[must_use]
838    pub fn min_elevation(&self) -> Option<f32> {
839        self.data
840            .iter()
841            .copied()
842            .filter(|&v| v != self.nodata)
843            .reduce(f32::min)
844    }
845
846    /// Return the maximum elevation, ignoring no-data values.
847    #[must_use]
848    pub fn max_elevation(&self) -> Option<f32> {
849        self.data
850            .iter()
851            .copied()
852            .filter(|&v| v != self.nodata)
853            .reduce(f32::max)
854    }
855
856    /// Export to an ASCII Grid string (ESRI format).
857    #[must_use]
858    pub fn to_ascii_grid(&self) -> String {
859        let mut s = String::new();
860        s.push_str(&format!("ncols {}\n", self.cols));
861        s.push_str(&format!("nrows {}\n", self.rows));
862        s.push_str(&format!("xllcorner {:.6}\n", self.bbox.min_lon));
863        s.push_str(&format!("yllcorner {:.6}\n", self.bbox.min_lat));
864        s.push_str(&format!("cellsize {:.6}\n", self.cell_width_deg()));
865        s.push_str(&format!("NODATA_value {:.1}\n", self.nodata));
866        for row in (0..self.rows).rev() {
867            let row_vals: Vec<String> = (0..self.cols)
868                .map(|c| format!("{:.1}", self.data[row * self.cols + c]))
869                .collect();
870            s.push_str(&row_vals.join(" "));
871            s.push('\n');
872        }
873        s
874    }
875}
876
877fn bbox_cell_size(lo: f64, hi: f64, n: usize) -> f64 {
878    if n == 0 { 0.0 } else { (hi - lo) / n as f64 }
879}
880
881// ─────────────────────────────────────────────────────────────────────────────
882// 11. Terrain tile format
883// ─────────────────────────────────────────────────────────────────────────────
884
885/// A Slippy-map tile identifier (zoom / x / y).
886#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
887pub struct TileId {
888    /// Zoom level (0–22).
889    pub zoom: u8,
890    /// Column tile index.
891    pub x: u32,
892    /// Row tile index.
893    pub y: u32,
894}
895
896impl TileId {
897    /// Construct a tile identifier.
898    #[must_use]
899    pub fn new(zoom: u8, x: u32, y: u32) -> Self {
900        Self { zoom, x, y }
901    }
902
903    /// Compute the `TileId` that covers a geographic point at a given zoom.
904    #[must_use]
905    pub fn from_lonlat(pt: LonLat, zoom: u8) -> Self {
906        let n = 2_u32.pow(zoom as u32) as f64;
907        let x = ((pt.lon + 180.0) / 360.0 * n).floor() as u32;
908        let lat_rad = pt.lat.to_radians();
909        let y = ((1.0 - (lat_rad.tan() + 1.0 / lat_rad.cos()).ln() / std::f64::consts::PI) / 2.0
910            * n)
911            .floor() as u32;
912        Self::new(zoom, x, y)
913    }
914
915    /// Return the bounding box of this tile in geographic coordinates.
916    #[must_use]
917    pub fn bbox(&self) -> BoundingBox {
918        let n = 2_u32.pow(self.zoom as u32) as f64;
919        let min_lon = self.x as f64 / n * 360.0 - 180.0;
920        let max_lon = (self.x + 1) as f64 / n * 360.0 - 180.0;
921        let lat_north = tile_lat(self.y, n);
922        let lat_south = tile_lat(self.y + 1, n);
923        BoundingBox::new(min_lon, lat_south, max_lon, lat_north)
924    }
925
926    /// Return the four child tiles at zoom+1.
927    #[must_use]
928    pub fn children(&self) -> [TileId; 4] {
929        let z = self.zoom + 1;
930        let x2 = self.x * 2;
931        let y2 = self.y * 2;
932        [
933            TileId::new(z, x2, y2),
934            TileId::new(z, x2 + 1, y2),
935            TileId::new(z, x2, y2 + 1),
936            TileId::new(z, x2 + 1, y2 + 1),
937        ]
938    }
939}
940
941fn tile_lat(y: u32, n: f64) -> f64 {
942    let sinh_val = std::f64::consts::PI * (1.0 - 2.0 * y as f64 / n);
943    sinh_val.sinh().atan().to_degrees()
944}
945
946/// A terrain tile carrying elevation samples.
947#[derive(Debug, Clone)]
948pub struct TerrainTile {
949    /// Tile identifier.
950    pub id: TileId,
951    /// Elevation grid, 256 × 256 samples by default.
952    pub elevation: Vec<f32>,
953    /// Side length in samples.
954    pub size: usize,
955}
956
957impl TerrainTile {
958    /// Construct an empty (zero-filled) terrain tile of given size.
959    #[must_use]
960    pub fn empty(id: TileId, size: usize) -> Self {
961        Self {
962            id,
963            elevation: vec![0.0; size * size],
964            size,
965        }
966    }
967
968    /// Sample elevation at normalised coordinates (u, v) ∈ \[0, 1\]².
969    #[must_use]
970    pub fn sample_uv(&self, u: f64, v: f64) -> f32 {
971        let col = (u.clamp(0.0, 1.0) * (self.size - 1) as f64).round() as usize;
972        let row = (v.clamp(0.0, 1.0) * (self.size - 1) as f64).round() as usize;
973        self.elevation[row * self.size + col]
974    }
975}
976
977// ─────────────────────────────────────────────────────────────────────────────
978// 12. Geohash encoding / decoding
979// ─────────────────────────────────────────────────────────────────────────────
980
981const GEOHASH_CHARS: &[u8] = b"0123456789bcdefghjkmnpqrstuvwxyz";
982
983/// Encode a `LonLat` to a geohash string of the requested length (1–12).
984///
985/// Panics if `precision` is 0 or greater than 12.
986#[must_use]
987pub fn geohash_encode(pt: LonLat, precision: usize) -> String {
988    assert!((1..=12).contains(&precision), "precision must be 1–12");
989    let mut lat_range = (-90.0_f64, 90.0_f64);
990    let mut lon_range = (-180.0_f64, 180.0_f64);
991    let mut bits_total = precision * 5;
992    let mut result = Vec::with_capacity(precision);
993    let mut idx: u8 = 0;
994    let mut bit = 0u8;
995    let mut is_lon = true;
996    while bits_total > 0 {
997        if is_lon {
998            let mid = (lon_range.0 + lon_range.1) / 2.0;
999            if pt.lon >= mid {
1000                idx = (idx << 1) | 1;
1001                lon_range.0 = mid;
1002            } else {
1003                idx <<= 1;
1004                lon_range.1 = mid;
1005            }
1006        } else {
1007            let mid = (lat_range.0 + lat_range.1) / 2.0;
1008            if pt.lat >= mid {
1009                idx = (idx << 1) | 1;
1010                lat_range.0 = mid;
1011            } else {
1012                idx <<= 1;
1013                lat_range.1 = mid;
1014            }
1015        }
1016        is_lon = !is_lon;
1017        bit += 1;
1018        bits_total -= 1;
1019        if bit == 5 {
1020            result.push(GEOHASH_CHARS[idx as usize] as char);
1021            idx = 0;
1022            bit = 0;
1023        }
1024    }
1025    result.into_iter().collect()
1026}
1027
1028/// Decode a geohash string to the centre `LonLat` and error margins.
1029///
1030/// Returns `(centre, lon_error_deg, lat_error_deg)`.
1031///
1032/// Returns `GeoError::ParseError` if the string contains invalid characters.
1033pub fn geohash_decode(hash: &str) -> GeoResult<(LonLat, f64, f64)> {
1034    let mut lat_range = (-90.0_f64, 90.0_f64);
1035    let mut lon_range = (-180.0_f64, 180.0_f64);
1036    let mut is_lon = true;
1037    for ch in hash.chars() {
1038        let idx = GEOHASH_CHARS
1039            .iter()
1040            .position(|&c| c as char == ch)
1041            .ok_or_else(|| GeoError::ParseError(format!("invalid geohash char '{ch}'")))?;
1042        for bit in (0..5).rev() {
1043            let b = (idx >> bit) & 1;
1044            if is_lon {
1045                let mid = (lon_range.0 + lon_range.1) / 2.0;
1046                if b == 1 {
1047                    lon_range.0 = mid;
1048                } else {
1049                    lon_range.1 = mid;
1050                }
1051            } else {
1052                let mid = (lat_range.0 + lat_range.1) / 2.0;
1053                if b == 1 {
1054                    lat_range.0 = mid;
1055                } else {
1056                    lat_range.1 = mid;
1057                }
1058            }
1059            is_lon = !is_lon;
1060        }
1061    }
1062    let lon = (lon_range.0 + lon_range.1) / 2.0;
1063    let lat = (lat_range.0 + lat_range.1) / 2.0;
1064    let lon_err = (lon_range.1 - lon_range.0) / 2.0;
1065    let lat_err = (lat_range.1 - lat_range.0) / 2.0;
1066    Ok((LonLat::new(lon, lat), lon_err, lat_err))
1067}
1068
1069// ─────────────────────────────────────────────────────────────────────────────
1070// 13. Spatial Index — R-tree-like bounding-box index
1071// ─────────────────────────────────────────────────────────────────────────────
1072
1073/// A single entry in the spatial index.
1074#[derive(Debug, Clone)]
1075pub struct SpatialEntry {
1076    /// Bounding box of this entry.
1077    pub bbox: BoundingBox,
1078    /// Arbitrary integer identifier (e.g. feature index).
1079    pub id: usize,
1080}
1081
1082/// A naive R-tree-like spatial index backed by a flat list.
1083///
1084/// For small data sets this brute-force scan is sufficient; a real R-tree
1085/// would split nodes at a configurable capacity.
1086#[derive(Debug, Clone, Default)]
1087pub struct SpatialIndex {
1088    entries: Vec<SpatialEntry>,
1089}
1090
1091impl SpatialIndex {
1092    /// Create an empty spatial index.
1093    #[must_use]
1094    pub fn new() -> Self {
1095        Self::default()
1096    }
1097
1098    /// Insert an entry.
1099    pub fn insert(&mut self, bbox: BoundingBox, id: usize) {
1100        self.entries.push(SpatialEntry { bbox, id });
1101    }
1102
1103    /// Return all entry IDs whose bounding boxes intersect `query`.
1104    #[must_use]
1105    pub fn query(&self, query: &BoundingBox) -> Vec<usize> {
1106        self.entries
1107            .iter()
1108            .filter(|e| e.bbox.intersects(query))
1109            .map(|e| e.id)
1110            .collect()
1111    }
1112
1113    /// Return the number of entries.
1114    #[must_use]
1115    pub fn len(&self) -> usize {
1116        self.entries.len()
1117    }
1118
1119    /// Return `true` if the index is empty.
1120    #[must_use]
1121    pub fn is_empty(&self) -> bool {
1122        self.entries.is_empty()
1123    }
1124
1125    /// Remove all entries.
1126    pub fn clear(&mut self) {
1127        self.entries.clear();
1128    }
1129}
1130
1131/// Build a `SpatialIndex` from a `FeatureCollection`.
1132#[must_use]
1133pub fn index_feature_collection(fc: &FeatureCollection) -> SpatialIndex {
1134    let mut idx = SpatialIndex::new();
1135    for (i, f) in fc.features.iter().enumerate() {
1136        if let Some(bb) = f.bounding_box() {
1137            idx.insert(bb, i);
1138        }
1139    }
1140    idx
1141}
1142
1143// ─────────────────────────────────────────────────────────────────────────────
1144// 14. Simplified DBF attribute table (Shapefile component)
1145// ─────────────────────────────────────────────────────────────────────────────
1146
1147/// A DBF field type.
1148#[derive(Debug, Clone, PartialEq, Eq)]
1149pub enum DbfFieldType {
1150    /// Character string.
1151    Character,
1152    /// Numeric (floating-point).
1153    Numeric,
1154    /// Logical (boolean).
1155    Logical,
1156    /// Date (YYYYMMDD string).
1157    Date,
1158}
1159
1160/// A DBF field descriptor.
1161#[derive(Debug, Clone)]
1162pub struct DbfFieldDescriptor {
1163    /// Field name (max 10 characters in dBase III).
1164    pub name: String,
1165    /// Field type.
1166    pub field_type: DbfFieldType,
1167    /// Field length in bytes.
1168    pub length: u8,
1169    /// Number of decimal places (Numeric fields).
1170    pub decimals: u8,
1171}
1172
1173impl DbfFieldDescriptor {
1174    /// Construct a character field.
1175    #[must_use]
1176    pub fn character(name: impl Into<String>, length: u8) -> Self {
1177        Self {
1178            name: name.into(),
1179            field_type: DbfFieldType::Character,
1180            length,
1181            decimals: 0,
1182        }
1183    }
1184
1185    /// Construct a numeric field.
1186    #[must_use]
1187    pub fn numeric(name: impl Into<String>, length: u8, decimals: u8) -> Self {
1188        Self {
1189            name: name.into(),
1190            field_type: DbfFieldType::Numeric,
1191            length,
1192            decimals,
1193        }
1194    }
1195}
1196
1197/// A single DBF record (row).
1198#[derive(Debug, Clone, Default)]
1199pub struct DbfRecord {
1200    /// Field values stored as strings.
1201    pub values: Vec<String>,
1202    /// Deletion flag.
1203    pub deleted: bool,
1204}
1205
1206impl DbfRecord {
1207    /// Construct a record with the given values.
1208    #[must_use]
1209    pub fn new(values: Vec<String>) -> Self {
1210        Self {
1211            values,
1212            deleted: false,
1213        }
1214    }
1215}
1216
1217/// A simplified in-memory DBF attribute table.
1218#[derive(Debug, Clone)]
1219pub struct DbfTable {
1220    /// Field descriptors (schema).
1221    pub fields: Vec<DbfFieldDescriptor>,
1222    /// Data rows.
1223    pub records: Vec<DbfRecord>,
1224}
1225
1226impl DbfTable {
1227    /// Create a new table with the given schema.
1228    #[must_use]
1229    pub fn new(fields: Vec<DbfFieldDescriptor>) -> Self {
1230        Self {
1231            fields,
1232            records: Vec::new(),
1233        }
1234    }
1235
1236    /// Append a record; returns `GeoError` if value count mismatches.
1237    pub fn append(&mut self, record: DbfRecord) -> GeoResult<()> {
1238        if record.values.len() != self.fields.len() {
1239            return Err(GeoError::ParseError(format!(
1240                "expected {} values, got {}",
1241                self.fields.len(),
1242                record.values.len()
1243            )));
1244        }
1245        self.records.push(record);
1246        Ok(())
1247    }
1248
1249    /// Number of records.
1250    #[must_use]
1251    pub fn record_count(&self) -> usize {
1252        self.records.iter().filter(|r| !r.deleted).count()
1253    }
1254
1255    /// Look up the column index for a field name.
1256    #[must_use]
1257    pub fn field_index(&self, name: &str) -> Option<usize> {
1258        self.fields.iter().position(|f| f.name == name)
1259    }
1260
1261    /// Get a field value from a record by field name.
1262    #[must_use]
1263    pub fn get_value(&self, record_idx: usize, field_name: &str) -> Option<&str> {
1264        let col = self.field_index(field_name)?;
1265        self.records.get(record_idx).map(|r| r.values[col].as_str())
1266    }
1267
1268    /// Export the table as a CSV string.
1269    #[must_use]
1270    pub fn to_csv(&self) -> String {
1271        let header: Vec<&str> = self.fields.iter().map(|f| f.name.as_str()).collect();
1272        let mut lines = vec![header.join(",")];
1273        for rec in &self.records {
1274            if !rec.deleted {
1275                lines.push(rec.values.join(","));
1276            }
1277        }
1278        lines.join("\n")
1279    }
1280}
1281
1282// ─────────────────────────────────────────────────────────────────────────────
1283// 15. Shapefile writer (geometry + DBF combined)
1284// ─────────────────────────────────────────────────────────────────────────────
1285
1286/// A minimal in-memory representation of a point shapefile.
1287#[derive(Debug, Clone)]
1288pub struct PointShapefile {
1289    /// Point geometries.
1290    pub points: Vec<LonLat>,
1291    /// Attribute table.
1292    pub attributes: DbfTable,
1293}
1294
1295impl PointShapefile {
1296    /// Construct a new point shapefile with the given attribute schema.
1297    #[must_use]
1298    pub fn new(fields: Vec<DbfFieldDescriptor>) -> Self {
1299        Self {
1300            points: Vec::new(),
1301            attributes: DbfTable::new(fields),
1302        }
1303    }
1304
1305    /// Add a point with attribute values.
1306    pub fn add_point(&mut self, pt: LonLat, values: Vec<String>) -> GeoResult<()> {
1307        self.points.push(pt);
1308        self.attributes.append(DbfRecord::new(values))
1309    }
1310
1311    /// Export the point locations as GeoJSON.
1312    #[must_use]
1313    pub fn to_geojson(&self) -> String {
1314        let mut fc = FeatureCollection::new();
1315        for (i, pt) in self.points.iter().enumerate() {
1316            let mut feat = GeoFeature::new(GeoGeometry::Point(*pt));
1317            for (fi, field) in self.attributes.fields.iter().enumerate() {
1318                if let Some(rec) = self.attributes.records.get(i) {
1319                    feat = feat.with_property(&field.name, &rec.values[fi]);
1320                }
1321            }
1322            fc.add(feat);
1323        }
1324        fc.to_geojson()
1325    }
1326}
1327
1328// ─────────────────────────────────────────────────────────────────────────────
1329// 16. Elevation contour line generation
1330// ─────────────────────────────────────────────────────────────────────────────
1331
1332/// Extract iso-contour polylines from a DEM at a given elevation level using
1333/// marching-squares (simplified: horizontal/vertical cell edges only).
1334#[must_use]
1335pub fn dem_contour_lines(dem: &DemRaster, level: f32) -> Vec<Vec<LonLat>> {
1336    let mut lines = Vec::new();
1337    let cw = dem.cell_width_deg();
1338    let ch = dem.cell_height_deg();
1339    for row in 0..dem.rows.saturating_sub(1) {
1340        for col in 0..dem.cols.saturating_sub(1) {
1341            let v00 = dem.data[row * dem.cols + col];
1342            let v10 = dem.data[row * dem.cols + col + 1];
1343            let v01 = dem.data[(row + 1) * dem.cols + col];
1344            let v11 = dem.data[(row + 1) * dem.cols + col + 1];
1345            // Simple saddle: if any edge crosses `level`, emit a short segment
1346            let pts = marching_square_pts(v00, v10, v01, v11, level, col, row, cw, ch, dem);
1347            if pts.len() >= 2 {
1348                lines.push(pts);
1349            }
1350        }
1351    }
1352    lines
1353}
1354
1355#[allow(clippy::too_many_arguments)]
1356fn marching_square_pts(
1357    v00: f32,
1358    v10: f32,
1359    v01: f32,
1360    v11: f32,
1361    level: f32,
1362    col: usize,
1363    row: usize,
1364    cw: f64,
1365    ch: f64,
1366    dem: &DemRaster,
1367) -> Vec<LonLat> {
1368    let mut pts = Vec::new();
1369    let lon0 = dem.bbox.min_lon + col as f64 * cw;
1370    let lat0 = dem.bbox.min_lat + row as f64 * ch;
1371    // bottom edge
1372    if (v00 < level) != (v10 < level) {
1373        let t = (level - v00) / (v10 - v00);
1374        pts.push(LonLat::new(lon0 + t as f64 * cw, lat0));
1375    }
1376    // left edge
1377    if (v00 < level) != (v01 < level) {
1378        let t = (level - v00) / (v01 - v00);
1379        pts.push(LonLat::new(lon0, lat0 + t as f64 * ch));
1380    }
1381    // top edge
1382    if (v01 < level) != (v11 < level) {
1383        let t = (level - v01) / (v11 - v01);
1384        pts.push(LonLat::new(lon0 + t as f64 * cw, lat0 + ch));
1385    }
1386    // right edge
1387    if (v10 < level) != (v11 < level) {
1388        let t = (level - v10) / (v11 - v10);
1389        pts.push(LonLat::new(lon0 + cw, lat0 + t as f64 * ch));
1390    }
1391    pts
1392}
1393
1394// ─────────────────────────────────────────────────────────────────────────────
1395// 17. GeoJSON import helpers (minimal hand-rolled parser)
1396// ─────────────────────────────────────────────────────────────────────────────
1397
1398/// Extract a quoted string value for a given key from a flat JSON object.
1399///
1400/// This is intentionally minimal and handles only the simple cases produced by
1401/// this module's own serialiser.
1402#[must_use]
1403pub fn extract_json_string(json: &str, key: &str) -> Option<String> {
1404    let pattern = format!(r#""{key}":""#);
1405    let start = json.find(&pattern)? + pattern.len();
1406    let rest = &json[start..];
1407    let end = rest.find('"')?;
1408    Some(rest[..end].to_string())
1409}
1410
1411/// A minimal GeoJSON `Point` parser (round-trips with `geometry_to_geojson`).
1412pub fn parse_geojson_point(json: &str) -> GeoResult<LonLat> {
1413    // Expect: {"type":"Point","coordinates":[lon,lat]}
1414    let coord_key = r#""coordinates":["#;
1415    let start = json
1416        .find(coord_key)
1417        .ok_or_else(|| GeoError::ParseError("no coordinates key".to_string()))?
1418        + coord_key.len();
1419    let rest = &json[start..];
1420    let end = rest
1421        .find(']')
1422        .ok_or_else(|| GeoError::ParseError("no closing bracket".to_string()))?;
1423    let coords: Vec<f64> = rest[..end]
1424        .split(',')
1425        .map(|s| s.trim().parse::<f64>())
1426        .collect::<Result<_, _>>()
1427        .map_err(|e| GeoError::ParseError(e.to_string()))?;
1428    match coords.as_slice() {
1429        [lon, lat] => Ok(LonLat::new(*lon, *lat)),
1430        [lon, lat, _] => Ok(LonLat::new(*lon, *lat)),
1431        _ => Err(GeoError::ParseError(
1432            "unexpected coordinate count".to_string(),
1433        )),
1434    }
1435}
1436
1437// ─────────────────────────────────────────────────────────────────────────────
1438// 18. Unit tests
1439// ─────────────────────────────────────────────────────────────────────────────
1440
1441#[cfg(test)]
1442mod tests {
1443    use super::*;
1444
1445    // --- LonLat ---------------------------------------------------------------
1446
1447    #[test]
1448    fn test_lonlat_new() {
1449        let p = LonLat::new(13.4050, 52.5200);
1450        assert!((p.lon - 13.4050).abs() < 1e-10);
1451        assert!((p.lat - 52.5200).abs() < 1e-10);
1452    }
1453
1454    #[test]
1455    fn test_haversine_same_point() {
1456        let p = LonLat::new(0.0, 0.0);
1457        assert!(p.haversine_m(&p) < 1e-6);
1458    }
1459
1460    #[test]
1461    fn test_haversine_equator() {
1462        // 1 degree of longitude at equator ≈ 111 195 m
1463        let a = LonLat::new(0.0, 0.0);
1464        let b = LonLat::new(1.0, 0.0);
1465        let d = a.haversine_m(&b);
1466        assert!((d - 111_195.0).abs() < 200.0);
1467    }
1468
1469    // --- BoundingBox ----------------------------------------------------------
1470
1471    #[test]
1472    fn test_bbox_contains() {
1473        let bb = BoundingBox::new(0.0, 0.0, 10.0, 10.0);
1474        assert!(bb.contains(LonLat::new(5.0, 5.0)));
1475        assert!(!bb.contains(LonLat::new(11.0, 5.0)));
1476    }
1477
1478    #[test]
1479    fn test_bbox_intersects() {
1480        let a = BoundingBox::new(0.0, 0.0, 10.0, 10.0);
1481        let b = BoundingBox::new(5.0, 5.0, 15.0, 15.0);
1482        assert!(a.intersects(&b));
1483        let c = BoundingBox::new(11.0, 0.0, 20.0, 10.0);
1484        assert!(!a.intersects(&c));
1485    }
1486
1487    #[test]
1488    fn test_bbox_centre() {
1489        let bb = BoundingBox::new(-10.0, -10.0, 10.0, 10.0);
1490        let c = bb.centre();
1491        assert!((c.lon).abs() < 1e-10);
1492        assert!((c.lat).abs() < 1e-10);
1493    }
1494
1495    #[test]
1496    fn test_bbox_expand() {
1497        let mut bb = BoundingBox::new(0.0, 0.0, 1.0, 1.0);
1498        bb.expand_to_include(LonLat::new(5.0, 5.0));
1499        assert!((bb.max_lon - 5.0).abs() < 1e-10);
1500        assert!((bb.max_lat - 5.0).abs() < 1e-10);
1501    }
1502
1503    // --- GeoGeometry ----------------------------------------------------------
1504
1505    #[test]
1506    fn test_geometry_type_str() {
1507        let g = GeoGeometry::Point(LonLat::new(0.0, 0.0));
1508        assert_eq!(g.type_str(), "Point");
1509    }
1510
1511    #[test]
1512    fn test_collect_lonlat_polygon() {
1513        let g = GeoGeometry::Polygon {
1514            exterior: vec![
1515                LonLat::new(0.0, 0.0),
1516                LonLat::new(1.0, 0.0),
1517                LonLat::new(1.0, 1.0),
1518                LonLat::new(0.0, 0.0),
1519            ],
1520            holes: vec![],
1521        };
1522        assert_eq!(g.collect_lonlat().len(), 4);
1523    }
1524
1525    #[test]
1526    fn test_bounding_box_linestring() {
1527        let g = GeoGeometry::LineString(vec![LonLat::new(-1.0, -1.0), LonLat::new(1.0, 1.0)]);
1528        let bb = g.bounding_box().unwrap();
1529        assert!((bb.min_lon - (-1.0)).abs() < 1e-10);
1530        assert!((bb.max_lat - 1.0).abs() < 1e-10);
1531    }
1532
1533    // --- GeoJSON serialisation ------------------------------------------------
1534
1535    #[test]
1536    fn test_geometry_to_geojson_point() {
1537        let g = GeoGeometry::Point(LonLat::new(13.405, 52.52));
1538        let s = geometry_to_geojson(&g);
1539        assert!(s.contains("\"type\":\"Point\""));
1540        assert!(s.contains("13.405000"));
1541    }
1542
1543    #[test]
1544    fn test_geometry_to_geojson_linestring() {
1545        let g = GeoGeometry::LineString(vec![LonLat::new(0.0, 0.0), LonLat::new(1.0, 1.0)]);
1546        let s = geometry_to_geojson(&g);
1547        assert!(s.contains("\"type\":\"LineString\""));
1548    }
1549
1550    #[test]
1551    fn test_feature_collection_to_geojson() {
1552        let mut fc = FeatureCollection::new();
1553        fc.add(GeoFeature::new(GeoGeometry::Point(LonLat::new(0.0, 0.0))));
1554        let s = fc.to_geojson();
1555        assert!(s.contains("\"type\":\"FeatureCollection\""));
1556    }
1557
1558    #[test]
1559    fn test_feature_with_properties() {
1560        let f = GeoFeature::new(GeoGeometry::Point(LonLat::new(10.0, 20.0)))
1561            .with_property("name", "test")
1562            .with_property("value", "42");
1563        let s = f.to_geojson();
1564        assert!(s.contains("\"name\":\"test\""));
1565        assert!(s.contains("\"value\":42"));
1566    }
1567
1568    #[test]
1569    fn test_query_bbox_filters() {
1570        let mut fc = FeatureCollection::new();
1571        fc.add(GeoFeature::new(GeoGeometry::Point(LonLat::new(5.0, 5.0))));
1572        fc.add(GeoFeature::new(GeoGeometry::Point(LonLat::new(50.0, 50.0))));
1573        let query = BoundingBox::new(0.0, 0.0, 10.0, 10.0);
1574        let results = fc.query_bbox(&query);
1575        assert_eq!(results.len(), 1);
1576    }
1577
1578    // --- WKT ------------------------------------------------------------------
1579
1580    #[test]
1581    fn test_geometry_to_wkt_point() {
1582        let g = GeoGeometry::Point(LonLat::new(10.0, 20.0));
1583        let s = geometry_to_wkt(&g);
1584        assert!(s.starts_with("POINT ("));
1585        assert!(s.contains("10.000000"));
1586    }
1587
1588    #[test]
1589    fn test_parse_wkt_point_2d() {
1590        let g = parse_wkt_point("POINT (10.5 20.5)").unwrap();
1591        if let GeoGeometry::Point(p) = g {
1592            assert!((p.lon - 10.5).abs() < 1e-6);
1593            assert!((p.lat - 20.5).abs() < 1e-6);
1594        } else {
1595            panic!("expected Point");
1596        }
1597    }
1598
1599    #[test]
1600    fn test_parse_wkt_point_3d() {
1601        let g = parse_wkt_point("POINT Z (1.0 2.0 3.0)").unwrap();
1602        assert!(matches!(g, GeoGeometry::Point3D(_)));
1603    }
1604
1605    #[test]
1606    fn test_parse_wkt_invalid() {
1607        assert!(parse_wkt_point("LINESTRING (0 0, 1 1)").is_err());
1608    }
1609
1610    // --- Mercator projection --------------------------------------------------
1611
1612    #[test]
1613    fn test_mercator_forward_origin() {
1614        let m = MercatorProjection::web_mercator();
1615        let xy = m.forward(LonLat::new(0.0, 0.0));
1616        assert!(xy[0].abs() < 1e-6);
1617        assert!(xy[1].abs() < 1e-6);
1618    }
1619
1620    #[test]
1621    fn test_mercator_round_trip() {
1622        let m = MercatorProjection::web_mercator();
1623        let orig = LonLat::new(30.0, 45.0);
1624        let xy = m.forward(orig);
1625        let back = m.inverse(xy);
1626        assert!((back.lon - orig.lon).abs() < 1e-6);
1627        assert!((back.lat - orig.lat).abs() < 1e-4);
1628    }
1629
1630    // --- DEM ------------------------------------------------------------------
1631
1632    #[test]
1633    fn test_dem_zeros_min_max() {
1634        let dem = DemRaster::zeros(BoundingBox::new(0.0, 0.0, 1.0, 1.0), 10, 10);
1635        assert_eq!(dem.min_elevation(), Some(0.0));
1636        assert_eq!(dem.max_elevation(), Some(0.0));
1637    }
1638
1639    #[test]
1640    fn test_dem_sample_inside() {
1641        let mut dem = DemRaster::zeros(BoundingBox::new(0.0, 0.0, 1.0, 1.0), 10, 10);
1642        dem.data[55] = 100.0;
1643        // Point should land inside the raster
1644        let v = dem.sample(LonLat::new(0.05, 0.05));
1645        assert!(v.is_some());
1646    }
1647
1648    #[test]
1649    fn test_dem_sample_outside() {
1650        let dem = DemRaster::zeros(BoundingBox::new(0.0, 0.0, 1.0, 1.0), 10, 10);
1651        let v = dem.sample(LonLat::new(5.0, 5.0));
1652        assert!(v.is_none());
1653    }
1654
1655    #[test]
1656    fn test_dem_ascii_grid_header() {
1657        let dem = DemRaster::zeros(BoundingBox::new(0.0, 0.0, 1.0, 1.0), 4, 4);
1658        let s = dem.to_ascii_grid();
1659        assert!(s.contains("ncols 4"));
1660        assert!(s.contains("nrows 4"));
1661    }
1662
1663    // --- Terrain tile ---------------------------------------------------------
1664
1665    #[test]
1666    fn test_tile_from_lonlat_zoom0() {
1667        let t = TileId::from_lonlat(LonLat::new(0.0, 0.0), 0);
1668        assert_eq!(t.zoom, 0);
1669        assert_eq!(t.x, 0);
1670        assert_eq!(t.y, 0);
1671    }
1672
1673    #[test]
1674    fn test_tile_bbox_width() {
1675        let t = TileId::new(1, 0, 0);
1676        let bb = t.bbox();
1677        // At zoom 1, each tile covers 180° of longitude
1678        assert!((bb.width_deg() - 180.0).abs() < 0.001);
1679    }
1680
1681    #[test]
1682    fn test_tile_children_count() {
1683        let t = TileId::new(2, 1, 1);
1684        let kids = t.children();
1685        assert_eq!(kids.len(), 4);
1686    }
1687
1688    #[test]
1689    fn test_terrain_tile_sample_uv() {
1690        let mut tile = TerrainTile::empty(TileId::new(0, 0, 0), 4);
1691        tile.elevation[5] = 500.0;
1692        // Just check it doesn't panic
1693        let _ = tile.sample_uv(0.5, 0.5);
1694    }
1695
1696    // --- Geohash --------------------------------------------------------------
1697
1698    #[test]
1699    fn test_geohash_encode_length() {
1700        let h = geohash_encode(LonLat::new(13.4050, 52.5200), 7);
1701        assert_eq!(h.len(), 7);
1702    }
1703
1704    #[test]
1705    fn test_geohash_decode_round_trip() {
1706        let orig = LonLat::new(10.0, 50.0);
1707        let hash = geohash_encode(orig, 9);
1708        let (decoded, lon_err, lat_err) = geohash_decode(&hash).unwrap();
1709        assert!((decoded.lon - orig.lon).abs() < lon_err + 1e-5);
1710        assert!((decoded.lat - orig.lat).abs() < lat_err + 1e-5);
1711    }
1712
1713    #[test]
1714    fn test_geohash_decode_invalid() {
1715        assert!(geohash_decode("invalid!").is_err());
1716    }
1717
1718    // --- Spatial index --------------------------------------------------------
1719
1720    #[test]
1721    fn test_spatial_index_query() {
1722        let mut idx = SpatialIndex::new();
1723        idx.insert(BoundingBox::new(0.0, 0.0, 1.0, 1.0), 0);
1724        idx.insert(BoundingBox::new(10.0, 10.0, 20.0, 20.0), 1);
1725        let results = idx.query(&BoundingBox::new(0.5, 0.5, 2.0, 2.0));
1726        assert!(results.contains(&0));
1727        assert!(!results.contains(&1));
1728    }
1729
1730    #[test]
1731    fn test_spatial_index_empty() {
1732        let idx = SpatialIndex::new();
1733        assert!(idx.is_empty());
1734    }
1735
1736    #[test]
1737    fn test_index_feature_collection() {
1738        let mut fc = FeatureCollection::new();
1739        fc.add(GeoFeature::new(GeoGeometry::Point(LonLat::new(5.0, 5.0))));
1740        fc.add(GeoFeature::new(GeoGeometry::Point(LonLat::new(15.0, 15.0))));
1741        let idx = index_feature_collection(&fc);
1742        assert_eq!(idx.len(), 2);
1743    }
1744
1745    // --- DBF ------------------------------------------------------------------
1746
1747    #[test]
1748    fn test_dbf_append_ok() {
1749        let mut tbl = DbfTable::new(vec![DbfFieldDescriptor::character("name", 50)]);
1750        tbl.append(DbfRecord::new(vec!["hello".to_string()]))
1751            .unwrap();
1752        assert_eq!(tbl.record_count(), 1);
1753    }
1754
1755    #[test]
1756    fn test_dbf_append_mismatch() {
1757        let mut tbl = DbfTable::new(vec![DbfFieldDescriptor::character("name", 50)]);
1758        let result = tbl.append(DbfRecord::new(vec!["a".to_string(), "b".to_string()]));
1759        assert!(result.is_err());
1760    }
1761
1762    #[test]
1763    fn test_dbf_get_value() {
1764        let mut tbl = DbfTable::new(vec![
1765            DbfFieldDescriptor::character("city", 50),
1766            DbfFieldDescriptor::numeric("pop", 10, 0),
1767        ]);
1768        tbl.append(DbfRecord::new(vec![
1769            "Berlin".to_string(),
1770            "3700000".to_string(),
1771        ]))
1772        .unwrap();
1773        assert_eq!(tbl.get_value(0, "city"), Some("Berlin"));
1774    }
1775
1776    #[test]
1777    fn test_dbf_to_csv() {
1778        let mut tbl = DbfTable::new(vec![DbfFieldDescriptor::character("x", 10)]);
1779        tbl.append(DbfRecord::new(vec!["foo".to_string()])).unwrap();
1780        let csv = tbl.to_csv();
1781        assert!(csv.contains("x\nfoo"));
1782    }
1783}