Skip to main content

oxigdal_gpkg/vector/
feature.rs

1//! GeoPackage feature row and feature table types.
2
3use std::collections::HashMap;
4
5use super::types::{FieldDefinition, FieldValue, GpkgGeometry};
6
7// ─────────────────────────────────────────────────────────────────────────────
8// FeatureRow
9// ─────────────────────────────────────────────────────────────────────────────
10
11/// A single feature (row) read from a GeoPackage feature table.
12#[derive(Debug, Clone)]
13pub struct FeatureRow {
14    /// Feature identifier (primary key value).
15    pub fid: i64,
16    /// Decoded geometry, or `None` when the geometry column is NULL.
17    pub geometry: Option<GpkgGeometry>,
18    /// Non-geometry attribute values, keyed by column name.
19    pub fields: HashMap<String, FieldValue>,
20}
21
22impl FeatureRow {
23    /// Look up a field by name.
24    pub fn get_field(&self, name: &str) -> Option<&FieldValue> {
25        self.fields.get(name)
26    }
27
28    /// Convenience: return the integer value of a field, or `None`.
29    pub fn get_integer(&self, name: &str) -> Option<i64> {
30        self.fields.get(name)?.as_integer()
31    }
32
33    /// Convenience: return the real value of a field, or `None`.
34    pub fn get_real(&self, name: &str) -> Option<f64> {
35        self.fields.get(name)?.as_real()
36    }
37
38    /// Convenience: return the text value of a field, or `None`.
39    pub fn get_text(&self, name: &str) -> Option<&str> {
40        self.fields.get(name)?.as_text()
41    }
42}
43
44// ─────────────────────────────────────────────────────────────────────────────
45// FeatureTable
46// ─────────────────────────────────────────────────────────────────────────────
47
48/// An in-memory representation of a GeoPackage feature table.
49///
50/// Holds the table schema and all feature rows that have been loaded.
51#[derive(Debug, Clone)]
52pub struct FeatureTable {
53    /// Name of the feature table (matches `gpkg_contents.table_name`).
54    pub name: String,
55    /// Name of the geometry column.
56    pub geometry_column: String,
57    /// Spatial reference system ID, or `None` when unknown.
58    pub srs_id: Option<i32>,
59    /// Column definitions (excludes the geometry column and FID).
60    pub schema: Vec<FieldDefinition>,
61    /// Loaded feature rows.
62    pub features: Vec<FeatureRow>,
63}
64
65impl FeatureTable {
66    /// Create a new, empty feature table with the given name and geometry column.
67    pub fn new(name: impl Into<String>, geometry_column: impl Into<String>) -> Self {
68        Self {
69            name: name.into(),
70            geometry_column: geometry_column.into(),
71            srs_id: None,
72            schema: Vec::new(),
73            features: Vec::new(),
74        }
75    }
76
77    /// Return the number of loaded feature rows.
78    pub fn feature_count(&self) -> usize {
79        self.features.len()
80    }
81
82    /// Append a feature row to the table.
83    pub fn add_feature(&mut self, row: FeatureRow) {
84        self.features.push(row);
85    }
86
87    /// Find a feature by its FID, or return `None`.
88    pub fn get_feature(&self, fid: i64) -> Option<&FeatureRow> {
89        self.features.iter().find(|r| r.fid == fid)
90    }
91
92    /// Return the union bounding box of all feature geometries, or `None` when
93    /// there are no geometries.
94    pub fn bbox(&self) -> Option<(f64, f64, f64, f64)> {
95        let mut min_x = f64::INFINITY;
96        let mut min_y = f64::INFINITY;
97        let mut max_x = f64::NEG_INFINITY;
98        let mut max_y = f64::NEG_INFINITY;
99        let mut found = false;
100
101        for row in &self.features {
102            if let Some(geom) = &row.geometry {
103                if let Some((gx0, gy0, gx1, gy1)) = geom.bbox() {
104                    found = true;
105                    if gx0 < min_x {
106                        min_x = gx0;
107                    }
108                    if gy0 < min_y {
109                        min_y = gy0;
110                    }
111                    if gx1 > max_x {
112                        max_x = gx1;
113                    }
114                    if gy1 > max_y {
115                        max_y = gy1;
116                    }
117                }
118            }
119        }
120
121        if found {
122            Some((min_x, min_y, max_x, max_y))
123        } else {
124            None
125        }
126    }
127
128    /// Return all features whose geometry bounding box intersects the query bbox.
129    ///
130    /// Features with `None` geometry are excluded.  The check is a simple AABB
131    /// intersection test (not precise polygon intersection).
132    pub fn features_in_bbox(
133        &self,
134        min_x: f64,
135        min_y: f64,
136        max_x: f64,
137        max_y: f64,
138    ) -> Vec<&FeatureRow> {
139        self.features
140            .iter()
141            .filter(|row| {
142                if let Some(geom) = &row.geometry {
143                    if let Some((gx0, gy0, gx1, gy1)) = geom.bbox() {
144                        // AABB intersects when not separated on either axis
145                        return gx0 <= max_x && gx1 >= min_x && gy0 <= max_y && gy1 >= min_y;
146                    }
147                }
148                false
149            })
150            .collect()
151    }
152
153    /// Collect all distinct (non-Null) values for a named field across all features.
154    ///
155    /// The returned vec is deduplicated by equality.
156    pub fn distinct_values(&self, field_name: &str) -> Vec<FieldValue> {
157        let mut seen: Vec<FieldValue> = Vec::new();
158        for row in &self.features {
159            if let Some(val) = row.fields.get(field_name) {
160                if !val.is_null() && !seen.contains(val) {
161                    seen.push(val.clone());
162                }
163            }
164        }
165        seen
166    }
167
168    /// Serialise the feature table as a GeoJSON FeatureCollection string.
169    ///
170    /// Geometry `None` is encoded as `"geometry":null`.
171    pub fn to_geojson(&self) -> String {
172        let features_json: String = self
173            .features
174            .iter()
175            .map(|row| {
176                let geom_json = match &row.geometry {
177                    Some(g) => g.to_geojson_geometry(),
178                    None => "null".into(),
179                };
180                let props_json = build_properties_json(&row.fields);
181                format!(r#"{{"type":"Feature","geometry":{geom_json},"properties":{props_json}}}"#)
182            })
183            .collect::<Vec<_>>()
184            .join(",");
185
186        format!(r#"{{"type":"FeatureCollection","features":[{features_json}]}}"#)
187    }
188}
189
190// ─────────────────────────────────────────────────────────────────────────────
191// SrsInfo
192// ─────────────────────────────────────────────────────────────────────────────
193
194/// Spatial reference system metadata (from `gpkg_spatial_ref_sys`).
195#[derive(Debug, Clone, PartialEq)]
196pub struct SrsInfo {
197    /// Human-readable name for this SRS.
198    pub srs_name: String,
199    /// Numeric SRS identifier (primary key in `gpkg_spatial_ref_sys`).
200    pub srs_id: i32,
201    /// Defining organisation (e.g. `"EPSG"`).
202    pub organization: String,
203    /// Organisation-assigned CRS code.
204    pub org_coord_sys_id: i32,
205    /// WKT or PROJ definition of the SRS.
206    pub definition: String,
207    /// Optional free-text description.
208    pub description: Option<String>,
209}
210
211impl SrsInfo {
212    /// Return the standard WGS 84 geographic SRS (EPSG:4326).
213    pub fn wgs84() -> Self {
214        Self {
215            srs_name: "WGS 84".into(),
216            srs_id: 4326,
217            organization: "EPSG".into(),
218            org_coord_sys_id: 4326,
219            definition: concat!(
220                "GEOGCS[\"WGS 84\",DATUM[\"WGS_1984\",",
221                "SPHEROID[\"WGS 84\",6378137,298.257223563]],",
222                "PRIMEM[\"Greenwich\",0],",
223                "UNIT[\"degree\",0.0174532925199433]]"
224            )
225            .into(),
226            description: Some("World Geodetic System 1984".into()),
227        }
228    }
229
230    /// Return the Web Mercator (Pseudo-Mercator) projected SRS (EPSG:3857).
231    pub fn web_mercator() -> Self {
232        Self {
233            srs_name: "WGS 84 / Pseudo-Mercator".into(),
234            srs_id: 3857,
235            organization: "EPSG".into(),
236            org_coord_sys_id: 3857,
237            definition: concat!(
238                "PROJCS[\"WGS 84 / Pseudo-Mercator\",",
239                "GEOGCS[\"WGS 84\",DATUM[\"WGS_1984\",",
240                "SPHEROID[\"WGS 84\",6378137,298.257223563]],",
241                "PRIMEM[\"Greenwich\",0],",
242                "UNIT[\"degree\",0.0174532925199433]],",
243                "PROJECTION[\"Mercator_1SP\"],",
244                "PARAMETER[\"central_meridian\",0],",
245                "PARAMETER[\"scale_factor\",1],",
246                "PARAMETER[\"false_easting\",0],",
247                "PARAMETER[\"false_northing\",0],",
248                "UNIT[\"metre\",1]]"
249            )
250            .into(),
251            description: Some("Web Mercator projection used by many web mapping services".into()),
252        }
253    }
254
255    /// Return `true` when this SRS uses geographic (lat/lon) coordinates.
256    ///
257    /// Heuristic: considers `srs_id` values in the range 4000–4999 as geographic.
258    pub fn is_geographic(&self) -> bool {
259        (4000..5000).contains(&self.srs_id)
260    }
261
262    /// Return the EPSG code when the defining organisation is `"EPSG"`.
263    pub fn epsg_code(&self) -> Option<i32> {
264        if self.organization.eq_ignore_ascii_case("EPSG") {
265            Some(self.org_coord_sys_id)
266        } else {
267            None
268        }
269    }
270}
271
272// ─────────────────────────────────────────────────────────────────────────────
273// JSON helper utility
274// ─────────────────────────────────────────────────────────────────────────────
275
276/// Render a `HashMap<String, FieldValue>` as a JSON object.
277pub(super) fn build_properties_json(fields: &HashMap<String, FieldValue>) -> String {
278    if fields.is_empty() {
279        return "{}".into();
280    }
281    // Sort keys for deterministic output
282    let mut pairs: Vec<(&String, &FieldValue)> = fields.iter().collect();
283    pairs.sort_by_key(|(k, _)| k.as_str());
284    let members: String = pairs
285        .iter()
286        .map(|(k, v)| format!("{}:{}", super::types::json_string_escape(k), v.to_json()))
287        .collect::<Vec<_>>()
288        .join(",");
289    format!("{{{members}}}")
290}