Skip to main content

oxigdal_shapefile/
reader.rs

1//! Shapefile reader - coordinates reading from .shp, .dbf, and .shx files
2//!
3//! This module provides a high-level interface for reading Shapefiles,
4//! combining geometry from .shp, attributes from .dbf, and spatial index from .shx.
5
6use crate::dbf::{DbfReader, FieldDescriptor};
7use crate::error::{Result, ShapefileError};
8use crate::shp::{Shape, ShapefileHeader, ShpReader};
9use crate::shx::{IndexEntry, ShxReader};
10use oxigdal_core::vector::{
11    Coordinate, Feature, FieldValue, Geometry, LineString as CoreLineString,
12    MultiLineString as CoreMultiLineString, MultiPoint as CoreMultiPoint, Point as CorePoint,
13    Polygon as CorePolygon,
14};
15use std::collections::HashMap;
16use std::fs::File;
17use std::io::BufReader;
18use std::path::{Path, PathBuf};
19
20/// A complete Shapefile feature (geometry + attributes)
21#[derive(Debug, Clone)]
22pub struct ShapefileFeature {
23    /// Record number (1-based)
24    pub record_number: i32,
25    /// Geometry
26    pub geometry: Option<Geometry>,
27    /// Attributes (field name -> value)
28    pub attributes: HashMap<String, FieldValue>,
29}
30
31impl ShapefileFeature {
32    /// Creates a new Shapefile feature
33    pub fn new(
34        record_number: i32,
35        geometry: Option<Geometry>,
36        attributes: HashMap<String, FieldValue>,
37    ) -> Self {
38        Self {
39            record_number,
40            geometry,
41            attributes,
42        }
43    }
44
45    /// Converts to an OxiGDAL Feature
46    pub fn to_oxigdal_feature(&self) -> Result<Feature> {
47        let geometry = self
48            .geometry
49            .clone()
50            .ok_or_else(|| ShapefileError::invalid_geometry("feature has no geometry"))?;
51
52        let mut feature = Feature::new(geometry);
53
54        // Convert attributes
55        for (key, value) in &self.attributes {
56            feature.set_property(key, value.clone());
57        }
58
59        Ok(feature)
60    }
61}
62
63/// Shapefile reader that coordinates .shp, .dbf, and optionally .shx files
64pub struct ShapefileReader {
65    /// Base path (without extension)
66    base_path: PathBuf,
67    /// .shp file header
68    header: ShapefileHeader,
69    /// Field descriptors from .dbf
70    field_descriptors: Vec<FieldDescriptor>,
71    /// Index entries from .shx (if available)
72    index_entries: Option<Vec<IndexEntry>>,
73    /// CRS as WKT string from .prj file (if present)
74    pub crs: Option<String>,
75    /// Character encoding from .cpg file (if present)
76    pub encoding: Option<String>,
77}
78
79impl ShapefileReader {
80    /// Opens a Shapefile from a base path (without extension)
81    ///
82    /// Reads the .shp, .dbf, and optionally .shx, .prj, and .cpg files.
83    pub fn open<P: AsRef<Path>>(base_path: P) -> Result<Self> {
84        let base_path = base_path.as_ref();
85
86        // Construct file paths
87        let shp_path = Self::with_extension(base_path, "shp");
88        let dbf_path = Self::with_extension(base_path, "dbf");
89        let shx_path = Self::with_extension(base_path, "shx");
90        let prj_path = Self::with_extension(base_path, "prj");
91        let cpg_path = Self::with_extension(base_path, "cpg");
92
93        // Open .shp file
94        let shp_file = File::open(&shp_path).map_err(|_| ShapefileError::MissingFile {
95            file_type: ".shp".to_string(),
96        })?;
97        let shp_reader = BufReader::new(shp_file);
98        let shp_reader = ShpReader::new(shp_reader)?;
99        let header = shp_reader.header().clone();
100
101        // Open .dbf file
102        let dbf_file = File::open(&dbf_path).map_err(|_| ShapefileError::MissingFile {
103            file_type: ".dbf".to_string(),
104        })?;
105        let dbf_reader = BufReader::new(dbf_file);
106        let dbf_reader = DbfReader::new(dbf_reader)?;
107        let field_descriptors = dbf_reader.field_descriptors().to_vec();
108
109        // Open .shx file (optional)
110        let index_entries = if shx_path.exists() {
111            let shx_file = File::open(&shx_path).ok();
112            if let Some(file) = shx_file {
113                let shx_reader = BufReader::new(file);
114                let mut shx_reader = ShxReader::new(shx_reader)?;
115                Some(shx_reader.read_all_entries()?)
116            } else {
117                None
118            }
119        } else {
120            None
121        };
122
123        // Read .prj file (optional) — contains CRS as WKT string
124        let crs = if prj_path.exists() {
125            std::fs::read_to_string(&prj_path)
126                .ok()
127                .map(|s| s.trim().to_string())
128                .filter(|s| !s.is_empty())
129        } else {
130            None
131        };
132
133        // Read .cpg file (optional) — contains character encoding name
134        // NOTE: Encoding information is stored but DBF string fields currently use
135        // String::from_utf8_lossy as a fallback for non-UTF-8 data. Full encoding
136        // transcoding via encoding_rs is a follow-up item.
137        let encoding = if cpg_path.exists() {
138            std::fs::read_to_string(&cpg_path)
139                .ok()
140                .map(|s| s.trim().to_string())
141                .filter(|s| !s.is_empty())
142        } else {
143            None
144        };
145
146        Ok(Self {
147            base_path: base_path.to_path_buf(),
148            header,
149            field_descriptors,
150            index_entries,
151            crs,
152            encoding,
153        })
154    }
155
156    /// Returns the Shapefile header
157    pub fn header(&self) -> &ShapefileHeader {
158        &self.header
159    }
160
161    /// Returns the field descriptors
162    pub fn field_descriptors(&self) -> &[FieldDescriptor] {
163        &self.field_descriptors
164    }
165
166    /// Returns the index entries (if .shx was loaded)
167    pub fn index_entries(&self) -> Option<&[IndexEntry]> {
168        self.index_entries.as_deref()
169    }
170
171    /// Returns the CRS as a WKT string, if a .prj file was present
172    pub fn crs(&self) -> Option<&str> {
173        self.crs.as_deref()
174    }
175
176    /// Returns the character encoding name from the .cpg file, if present
177    ///
178    /// Common values: `"UTF-8"`, `"CP1252"`, `"ISO-8859-1"`.
179    /// NOTE: Non-UTF-8 encodings are not yet transcoded; DBF fields use
180    /// `String::from_utf8_lossy` as a fallback.
181    pub fn encoding(&self) -> Option<&str> {
182        self.encoding.as_deref()
183    }
184
185    /// Returns features whose bounding box intersects the given query bbox.
186    ///
187    /// Reads all shape records from `.shp` and `.dbf`, then filters to those
188    /// whose bounding box overlaps `[min_x, min_y, max_x, max_y]` (inclusive).
189    /// Point shapes use the point coordinate as a degenerate bbox.
190    /// Null shapes are excluded.
191    ///
192    /// For large shapefiles this reads all geometry upfront; a full R-tree
193    /// spatial index backed by lazy `.shx` seeks is a follow-up item.
194    pub fn features_in_bbox(
195        &mut self,
196        min_x: f64,
197        min_y: f64,
198        max_x: f64,
199        max_y: f64,
200    ) -> Result<Vec<ShapefileFeature>> {
201        let all_features = self.read_features()?;
202
203        let filtered = all_features
204            .into_iter()
205            .filter(|feature| {
206                let Some(ref geom) = feature.geometry else {
207                    return false;
208                };
209                if let Some((fx_min, fy_min, fx_max, fy_max)) = Self::geometry_bbox(geom) {
210                    // Standard AABB intersection test (inclusive on edges)
211                    !(fx_max < min_x || fx_min > max_x || fy_max < min_y || fy_min > max_y)
212                } else {
213                    false
214                }
215            })
216            .collect();
217
218        Ok(filtered)
219    }
220
221    /// Extracts a 2-D bounding box `(x_min, y_min, x_max, y_max)` from a geometry.
222    ///
223    /// Delegates to the `Geometry::bounds()` method defined in `oxigdal-core`,
224    /// which returns `None` for degenerate or empty geometries.
225    fn geometry_bbox(geom: &Geometry) -> Option<(f64, f64, f64, f64)> {
226        geom.bounds()
227    }
228
229    /// Returns a streaming iterator over the Shapefile's features.
230    ///
231    /// Unlike [`read_features`], which loads everything into memory, this opens
232    /// fresh buffered readers and reads one SHP record + one DBF record per
233    /// [`Iterator::next`] call.  Memory usage is therefore O(1) with respect
234    /// to the number of features.
235    ///
236    /// # Errors
237    ///
238    /// Returns an error if the `.shp` or `.dbf` files cannot be opened, or if
239    /// the header/field-descriptor section cannot be read.  Individual record
240    /// errors are surfaced as `Err` items from the iterator.
241    ///
242    /// [`read_features`]: ShapefileReader::read_features
243    pub fn iter_features(&self) -> Result<FeatureIter<'_>> {
244        let shp_path = Self::with_extension(&self.base_path, "shp");
245        let dbf_path = Self::with_extension(&self.base_path, "dbf");
246
247        let shp_file = File::open(&shp_path)?;
248        let shp_reader = BufReader::new(shp_file);
249        let shp_reader = ShpReader::new(shp_reader)?;
250
251        let dbf_file = File::open(&dbf_path)?;
252        let dbf_reader = BufReader::new(dbf_file);
253        let dbf_reader = DbfReader::new(dbf_reader)?;
254
255        Ok(FeatureIter {
256            shp_reader,
257            dbf_reader,
258            field_descriptors: &self.field_descriptors,
259            done: false,
260        })
261    }
262
263    /// Reads features that satisfy an arbitrary predicate closure.
264    ///
265    /// This is a convenience wrapper over [`ShapefileReader::read_features`] that filters the
266    /// result set in-place without a second allocation.  The predicate receives
267    /// a shared reference to each [`ShapefileFeature`] and returns `true` for
268    /// features that should be included in the output.
269    ///
270    /// For structured attribute comparisons prefer
271    /// [`read_features_filtered`](ShapefileReader::read_features_filtered),
272    /// which accepts a [`crate::filter::FieldFilter`] directly.
273    ///
274    /// # Errors
275    ///
276    /// Propagates any I/O or parse errors from the underlying read.
277    pub fn read_features_where<F>(&self, predicate: F) -> Result<Vec<ShapefileFeature>>
278    where
279        F: Fn(&ShapefileFeature) -> bool,
280    {
281        let all = self.read_features()?;
282        Ok(all.into_iter().filter(|f| predicate(f)).collect())
283    }
284
285    /// Reads features that match a structured [`crate::filter::FieldFilter`].
286    ///
287    /// This is a thin convenience method over
288    /// [`read_features_where`](ShapefileReader::read_features_where) that
289    /// accepts a [`crate::filter::FieldFilter`] directly.
290    ///
291    /// # Errors
292    ///
293    /// Propagates any I/O or parse errors from the underlying read.
294    pub fn read_features_filtered(
295        &self,
296        filter: &crate::filter::FieldFilter,
297    ) -> Result<Vec<ShapefileFeature>> {
298        self.read_features_where(|f| filter.matches(f))
299    }
300
301    /// Reads all features from the Shapefile
302    pub fn read_features(&self) -> Result<Vec<ShapefileFeature>> {
303        // Open files
304        let shp_path = Self::with_extension(&self.base_path, "shp");
305        let dbf_path = Self::with_extension(&self.base_path, "dbf");
306
307        let shp_file = File::open(&shp_path)?;
308        let shp_reader = BufReader::new(shp_file);
309        let mut shp_reader = ShpReader::new(shp_reader)?;
310
311        let dbf_file = File::open(&dbf_path)?;
312        let dbf_reader = BufReader::new(dbf_file);
313        let mut dbf_reader = DbfReader::new(dbf_reader)?;
314
315        // Read all shape records
316        let shape_records = shp_reader.read_all_records()?;
317
318        // Read all DBF records
319        let dbf_records = dbf_reader.read_all_records()?;
320
321        // Verify record counts match
322        if shape_records.len() != dbf_records.len() {
323            return Err(ShapefileError::RecordMismatch {
324                shp_count: shape_records.len(),
325                dbf_count: dbf_records.len(),
326            });
327        }
328
329        // Combine into features
330        let mut features = Vec::with_capacity(shape_records.len());
331        for (shape_record, dbf_record) in shape_records.iter().zip(dbf_records.iter()) {
332            let geometry = Self::shape_to_geometry(&shape_record.shape)?;
333
334            // Convert DBF record to attributes
335            let attributes = Self::dbf_to_attributes(dbf_record, &self.field_descriptors);
336
337            features.push(ShapefileFeature::new(
338                shape_record.record_number,
339                geometry,
340                attributes,
341            ));
342        }
343
344        Ok(features)
345    }
346
347    /// Converts a Shape to an OxiGDAL Geometry
348    fn shape_to_geometry(shape: &Shape) -> Result<Option<Geometry>> {
349        match shape {
350            Shape::Null => Ok(None),
351            Shape::Point(point) => {
352                let oxigdal_point = CorePoint::new(point.x, point.y);
353                Ok(Some(Geometry::Point(oxigdal_point)))
354            }
355            Shape::PointZ(point) => {
356                use oxigdal_core::vector::Coordinate;
357                let coord = if let Some(m) = point.m {
358                    Coordinate::new_3dm(point.x, point.y, point.z, m)
359                } else {
360                    Coordinate::new_3d(point.x, point.y, point.z)
361                };
362                Ok(Some(Geometry::Point(CorePoint::from_coord(coord))))
363            }
364            Shape::PointM(point) => {
365                use oxigdal_core::vector::Coordinate;
366                let coord = Coordinate::new_2dm(point.x, point.y, point.m);
367                Ok(Some(Geometry::Point(CorePoint::from_coord(coord))))
368            }
369            Shape::PolyLine(multi_part) => {
370                if multi_part.parts.len() == 1 {
371                    // Single part - convert to LineString
372                    let coords: Vec<Coordinate> = multi_part
373                        .points
374                        .iter()
375                        .map(|p| Coordinate::new_2d(p.x, p.y))
376                        .collect();
377
378                    if coords.len() < 2 {
379                        return Ok(None);
380                    }
381
382                    let linestring = CoreLineString::new(coords).map_err(|e| {
383                        ShapefileError::invalid_geometry(format!("Invalid LineString: {}", e))
384                    })?;
385                    Ok(Some(Geometry::LineString(linestring)))
386                } else {
387                    // Multiple parts - convert to MultiLineString
388                    let mut linestrings = Vec::new();
389
390                    for i in 0..multi_part.parts.len() {
391                        let start_idx = multi_part.parts[i] as usize;
392                        let end_idx = if i + 1 < multi_part.parts.len() {
393                            multi_part.parts[i + 1] as usize
394                        } else {
395                            multi_part.points.len()
396                        };
397
398                        let coords: Vec<Coordinate> = multi_part.points[start_idx..end_idx]
399                            .iter()
400                            .map(|p| Coordinate::new_2d(p.x, p.y))
401                            .collect();
402
403                        if coords.len() >= 2 {
404                            if let Ok(linestring) = CoreLineString::new(coords) {
405                                linestrings.push(linestring);
406                            }
407                        }
408                    }
409
410                    if linestrings.is_empty() {
411                        Ok(None)
412                    } else {
413                        Ok(Some(Geometry::MultiLineString(CoreMultiLineString::new(
414                            linestrings,
415                        ))))
416                    }
417                }
418            }
419            Shape::Polygon(multi_part) => {
420                if multi_part.parts.is_empty() {
421                    return Ok(None);
422                }
423
424                // First part is exterior ring
425                let exterior_start = multi_part.parts[0] as usize;
426                let exterior_end = if multi_part.parts.len() > 1 {
427                    multi_part.parts[1] as usize
428                } else {
429                    multi_part.points.len()
430                };
431
432                let exterior_coords: Vec<Coordinate> = multi_part.points
433                    [exterior_start..exterior_end]
434                    .iter()
435                    .map(|p| Coordinate::new_2d(p.x, p.y))
436                    .collect();
437
438                if exterior_coords.len() < 4 {
439                    return Ok(None);
440                }
441
442                let exterior = CoreLineString::new(exterior_coords).map_err(|e| {
443                    ShapefileError::invalid_geometry(format!("Invalid exterior ring: {}", e))
444                })?;
445
446                // Remaining parts are interior rings (holes)
447                let mut interiors = Vec::new();
448                for i in 1..multi_part.parts.len() {
449                    let start_idx = multi_part.parts[i] as usize;
450                    let end_idx = if i + 1 < multi_part.parts.len() {
451                        multi_part.parts[i + 1] as usize
452                    } else {
453                        multi_part.points.len()
454                    };
455
456                    let interior_coords: Vec<Coordinate> = multi_part.points[start_idx..end_idx]
457                        .iter()
458                        .map(|p| Coordinate::new_2d(p.x, p.y))
459                        .collect();
460
461                    if interior_coords.len() >= 4 {
462                        if let Ok(interior) = CoreLineString::new(interior_coords) {
463                            interiors.push(interior);
464                        }
465                    }
466                }
467
468                let polygon = CorePolygon::new(exterior, interiors).map_err(|e| {
469                    ShapefileError::invalid_geometry(format!("Invalid polygon: {}", e))
470                })?;
471
472                Ok(Some(Geometry::Polygon(polygon)))
473            }
474            Shape::MultiPoint(multi_part) => {
475                let points: Vec<CorePoint> = multi_part
476                    .points
477                    .iter()
478                    .map(|p| CorePoint::new(p.x, p.y))
479                    .collect();
480
481                if points.is_empty() {
482                    Ok(None)
483                } else {
484                    Ok(Some(Geometry::MultiPoint(CoreMultiPoint::new(points))))
485                }
486            }
487            // Z variants: reconstruct with Z (and optionally M) coordinates
488            Shape::PolyLineZ(shape_z) => Self::multipart_z_to_linestring_geometry(
489                &shape_z.base,
490                &shape_z.z_values,
491                shape_z.m_values.as_deref(),
492            ),
493            Shape::PolygonZ(shape_z) => Self::multipart_z_to_polygon_geometry(
494                &shape_z.base,
495                &shape_z.z_values,
496                shape_z.m_values.as_deref(),
497            ),
498            Shape::MultiPointZ(shape_z) => Self::multipart_z_to_multipoint_geometry(
499                &shape_z.base,
500                &shape_z.z_values,
501                shape_z.m_values.as_deref(),
502            ),
503            // M variants: reconstruct with M coordinates
504            Shape::PolyLineM(shape_m) => {
505                Self::multipart_m_to_linestring_geometry(&shape_m.base, &shape_m.m_values)
506            }
507            Shape::PolygonM(shape_m) => {
508                Self::multipart_m_to_polygon_geometry(&shape_m.base, &shape_m.m_values)
509            }
510            Shape::MultiPointM(shape_m) => {
511                Self::multipart_m_to_multipoint_geometry(&shape_m.base, &shape_m.m_values)
512            }
513            // MultiPatch: expose as a MultiPolygon using ring parts (outer/inner ring types)
514            // or fall back to a point collection of the vertices.
515            Shape::MultiPatch(mp_shape) => {
516                // Represent patch vertices as a MultiPoint carrying Z coordinates.
517                use oxigdal_core::vector::Coordinate;
518                let points: Vec<CorePoint> = mp_shape
519                    .base
520                    .points
521                    .iter()
522                    .zip(mp_shape.z_values.iter())
523                    .map(|(p, z)| CorePoint::from_coord(Coordinate::new_3d(p.x, p.y, *z)))
524                    .collect();
525
526                if points.is_empty() {
527                    Ok(None)
528                } else {
529                    Ok(Some(Geometry::MultiPoint(CoreMultiPoint::new(points))))
530                }
531            }
532        }
533    }
534
535    /// Converts a multi-part Z shape into a LineString or MultiLineString Geometry
536    /// with Z (and optionally M) coordinates preserved.
537    fn multipart_z_to_linestring_geometry(
538        base: &crate::shp::MultiPartShape,
539        z_values: &[f64],
540        m_values: Option<&[f64]>,
541    ) -> Result<Option<Geometry>> {
542        use oxigdal_core::vector::Coordinate;
543
544        let make_coord = |i: usize, p: &crate::shp::shapes::Point| -> Coordinate {
545            let z = z_values.get(i).copied().unwrap_or(0.0);
546            if let Some(mv) = m_values {
547                Coordinate::new_3dm(p.x, p.y, z, mv.get(i).copied().unwrap_or(0.0))
548            } else {
549                Coordinate::new_3d(p.x, p.y, z)
550            }
551        };
552
553        if base.parts.len() == 1 {
554            let coords: Vec<Coordinate> = base
555                .points
556                .iter()
557                .enumerate()
558                .map(|(i, p)| make_coord(i, p))
559                .collect();
560            if coords.len() < 2 {
561                return Ok(None);
562            }
563            let linestring = CoreLineString::new(coords).map_err(|e| {
564                ShapefileError::invalid_geometry(format!("Invalid LineString: {}", e))
565            })?;
566            Ok(Some(Geometry::LineString(linestring)))
567        } else {
568            let mut linestrings = Vec::new();
569            for i in 0..base.parts.len() {
570                let start = base.parts[i] as usize;
571                let end = if i + 1 < base.parts.len() {
572                    base.parts[i + 1] as usize
573                } else {
574                    base.points.len()
575                };
576                let coords: Vec<Coordinate> = base.points[start..end]
577                    .iter()
578                    .enumerate()
579                    .map(|(j, p)| make_coord(start + j, p))
580                    .collect();
581                if coords.len() >= 2 {
582                    if let Ok(ls) = CoreLineString::new(coords) {
583                        linestrings.push(ls);
584                    }
585                }
586            }
587            if linestrings.is_empty() {
588                Ok(None)
589            } else {
590                Ok(Some(Geometry::MultiLineString(CoreMultiLineString::new(
591                    linestrings,
592                ))))
593            }
594        }
595    }
596
597    /// Converts a multi-part Z shape into a Polygon Geometry with Z coordinates.
598    fn multipart_z_to_polygon_geometry(
599        base: &crate::shp::MultiPartShape,
600        z_values: &[f64],
601        m_values: Option<&[f64]>,
602    ) -> Result<Option<Geometry>> {
603        use oxigdal_core::vector::Coordinate;
604
605        if base.parts.is_empty() {
606            return Ok(None);
607        }
608
609        let make_coord = |i: usize, p: &crate::shp::shapes::Point| -> Coordinate {
610            let z = z_values.get(i).copied().unwrap_or(0.0);
611            if let Some(mv) = m_values {
612                Coordinate::new_3dm(p.x, p.y, z, mv.get(i).copied().unwrap_or(0.0))
613            } else {
614                Coordinate::new_3d(p.x, p.y, z)
615            }
616        };
617
618        let ext_start = base.parts[0] as usize;
619        let ext_end = if base.parts.len() > 1 {
620            base.parts[1] as usize
621        } else {
622            base.points.len()
623        };
624        let ext_coords: Vec<Coordinate> = base.points[ext_start..ext_end]
625            .iter()
626            .enumerate()
627            .map(|(j, p)| make_coord(ext_start + j, p))
628            .collect();
629        if ext_coords.len() < 4 {
630            return Ok(None);
631        }
632        let exterior = CoreLineString::new(ext_coords).map_err(|e| {
633            ShapefileError::invalid_geometry(format!("Invalid exterior Z ring: {}", e))
634        })?;
635
636        let mut interiors = Vec::new();
637        for i in 1..base.parts.len() {
638            let start = base.parts[i] as usize;
639            let end = if i + 1 < base.parts.len() {
640                base.parts[i + 1] as usize
641            } else {
642                base.points.len()
643            };
644            let coords: Vec<Coordinate> = base.points[start..end]
645                .iter()
646                .enumerate()
647                .map(|(j, p)| make_coord(start + j, p))
648                .collect();
649            if coords.len() >= 4 {
650                if let Ok(ring) = CoreLineString::new(coords) {
651                    interiors.push(ring);
652                }
653            }
654        }
655        let polygon = CorePolygon::new(exterior, interiors)
656            .map_err(|e| ShapefileError::invalid_geometry(format!("Invalid polygon Z: {}", e)))?;
657        Ok(Some(Geometry::Polygon(polygon)))
658    }
659
660    /// Converts a multi-part Z shape into a MultiPoint Geometry with Z coordinates.
661    fn multipart_z_to_multipoint_geometry(
662        base: &crate::shp::MultiPartShape,
663        z_values: &[f64],
664        m_values: Option<&[f64]>,
665    ) -> Result<Option<Geometry>> {
666        use oxigdal_core::vector::Coordinate;
667        let points: Vec<CorePoint> = base
668            .points
669            .iter()
670            .enumerate()
671            .map(|(i, p)| {
672                let z = z_values.get(i).copied().unwrap_or(0.0);
673                let coord = if let Some(mv) = m_values {
674                    Coordinate::new_3dm(p.x, p.y, z, mv.get(i).copied().unwrap_or(0.0))
675                } else {
676                    Coordinate::new_3d(p.x, p.y, z)
677                };
678                CorePoint::from_coord(coord)
679            })
680            .collect();
681        if points.is_empty() {
682            Ok(None)
683        } else {
684            Ok(Some(Geometry::MultiPoint(CoreMultiPoint::new(points))))
685        }
686    }
687
688    /// Converts a multi-part M shape into a LineString/MultiLineString with M.
689    fn multipart_m_to_linestring_geometry(
690        base: &crate::shp::MultiPartShape,
691        m_values: &[f64],
692    ) -> Result<Option<Geometry>> {
693        use oxigdal_core::vector::Coordinate;
694
695        let make_coord = |i: usize, p: &crate::shp::shapes::Point| -> Coordinate {
696            Coordinate::new_2dm(p.x, p.y, m_values.get(i).copied().unwrap_or(0.0))
697        };
698
699        if base.parts.len() == 1 {
700            let coords: Vec<Coordinate> = base
701                .points
702                .iter()
703                .enumerate()
704                .map(|(i, p)| make_coord(i, p))
705                .collect();
706            if coords.len() < 2 {
707                return Ok(None);
708            }
709            let linestring = CoreLineString::new(coords).map_err(|e| {
710                ShapefileError::invalid_geometry(format!("Invalid LineStringM: {}", e))
711            })?;
712            Ok(Some(Geometry::LineString(linestring)))
713        } else {
714            let mut linestrings = Vec::new();
715            for i in 0..base.parts.len() {
716                let start = base.parts[i] as usize;
717                let end = if i + 1 < base.parts.len() {
718                    base.parts[i + 1] as usize
719                } else {
720                    base.points.len()
721                };
722                let coords: Vec<Coordinate> = base.points[start..end]
723                    .iter()
724                    .enumerate()
725                    .map(|(j, p)| make_coord(start + j, p))
726                    .collect();
727                if coords.len() >= 2 {
728                    if let Ok(ls) = CoreLineString::new(coords) {
729                        linestrings.push(ls);
730                    }
731                }
732            }
733            if linestrings.is_empty() {
734                Ok(None)
735            } else {
736                Ok(Some(Geometry::MultiLineString(CoreMultiLineString::new(
737                    linestrings,
738                ))))
739            }
740        }
741    }
742
743    /// Converts a multi-part M shape into a Polygon with M.
744    fn multipart_m_to_polygon_geometry(
745        base: &crate::shp::MultiPartShape,
746        m_values: &[f64],
747    ) -> Result<Option<Geometry>> {
748        use oxigdal_core::vector::Coordinate;
749
750        if base.parts.is_empty() {
751            return Ok(None);
752        }
753
754        let make_coord = |i: usize, p: &crate::shp::shapes::Point| -> Coordinate {
755            Coordinate::new_2dm(p.x, p.y, m_values.get(i).copied().unwrap_or(0.0))
756        };
757
758        let ext_start = base.parts[0] as usize;
759        let ext_end = if base.parts.len() > 1 {
760            base.parts[1] as usize
761        } else {
762            base.points.len()
763        };
764        let ext_coords: Vec<Coordinate> = base.points[ext_start..ext_end]
765            .iter()
766            .enumerate()
767            .map(|(j, p)| make_coord(ext_start + j, p))
768            .collect();
769        if ext_coords.len() < 4 {
770            return Ok(None);
771        }
772        let exterior = CoreLineString::new(ext_coords).map_err(|e| {
773            ShapefileError::invalid_geometry(format!("Invalid exterior M ring: {}", e))
774        })?;
775
776        let mut interiors = Vec::new();
777        for i in 1..base.parts.len() {
778            let start = base.parts[i] as usize;
779            let end = if i + 1 < base.parts.len() {
780                base.parts[i + 1] as usize
781            } else {
782                base.points.len()
783            };
784            let coords: Vec<Coordinate> = base.points[start..end]
785                .iter()
786                .enumerate()
787                .map(|(j, p)| make_coord(start + j, p))
788                .collect();
789            if coords.len() >= 4 {
790                if let Ok(ring) = CoreLineString::new(coords) {
791                    interiors.push(ring);
792                }
793            }
794        }
795        let polygon = CorePolygon::new(exterior, interiors)
796            .map_err(|e| ShapefileError::invalid_geometry(format!("Invalid polygon M: {}", e)))?;
797        Ok(Some(Geometry::Polygon(polygon)))
798    }
799
800    /// Converts a multi-part M shape into a MultiPoint with M.
801    fn multipart_m_to_multipoint_geometry(
802        base: &crate::shp::MultiPartShape,
803        m_values: &[f64],
804    ) -> Result<Option<Geometry>> {
805        use oxigdal_core::vector::Coordinate;
806        let points: Vec<CorePoint> = base
807            .points
808            .iter()
809            .enumerate()
810            .map(|(i, p)| {
811                CorePoint::from_coord(Coordinate::new_2dm(
812                    p.x,
813                    p.y,
814                    m_values.get(i).copied().unwrap_or(0.0),
815                ))
816            })
817            .collect();
818        if points.is_empty() {
819            Ok(None)
820        } else {
821            Ok(Some(Geometry::MultiPoint(CoreMultiPoint::new(points))))
822        }
823    }
824
825    /// Converts a DBF record to FieldValue attributes
826    fn dbf_to_attributes(
827        dbf_record: &crate::dbf::DbfRecord,
828        field_descriptors: &[FieldDescriptor],
829    ) -> HashMap<String, FieldValue> {
830        let mut attributes = HashMap::new();
831
832        for (field, value) in field_descriptors.iter().zip(&dbf_record.values) {
833            let property_value = match value {
834                crate::dbf::FieldValue::String(s) => FieldValue::String(s.clone()),
835                crate::dbf::FieldValue::Integer(i) => FieldValue::Integer(*i),
836                crate::dbf::FieldValue::Float(f) => FieldValue::Float(*f),
837                crate::dbf::FieldValue::Boolean(b) => FieldValue::Bool(*b),
838                crate::dbf::FieldValue::Date(d) => FieldValue::String(d.clone()),
839                crate::dbf::FieldValue::Null => FieldValue::Null,
840            };
841
842            attributes.insert(field.name.clone(), property_value);
843        }
844
845        attributes
846    }
847
848    /// Converts a DBF record to FieldValue attributes (static version for FeatureIter)
849    pub(crate) fn dbf_to_attributes_pub(
850        dbf_record: &crate::dbf::DbfRecord,
851        field_descriptors: &[FieldDescriptor],
852    ) -> HashMap<String, FieldValue> {
853        Self::dbf_to_attributes(dbf_record, field_descriptors)
854    }
855
856    /// Converts a Shape to an OxiGDAL Geometry (public version for FeatureIter)
857    pub(crate) fn shape_to_geometry_pub(shape: &Shape) -> Result<Option<Geometry>> {
858        Self::shape_to_geometry(shape)
859    }
860
861    /// Helper to add extension to base path
862    fn with_extension<P: AsRef<Path>>(base_path: P, ext: &str) -> PathBuf {
863        let base = base_path.as_ref();
864
865        // If base already has an extension, replace it
866        if base.extension().is_some() {
867            base.with_extension(ext)
868        } else {
869            // Otherwise, add the extension
870            let mut path = base.to_path_buf();
871            path.set_extension(ext);
872            path
873        }
874    }
875}
876
877// ─── Streaming feature iterator ──────────────────────────────────────────────
878
879/// A lazy, streaming iterator over the features in a Shapefile.
880///
881/// Created by [`ShapefileReader::iter_features`].  Reads one SHP record and one
882/// DBF record per call to [`Iterator::next`], keeping memory usage at O(1) with
883/// respect to the number of features.
884///
885/// The iterator is exhausted as soon as either the `.shp` or the `.dbf` reader
886/// returns `None`, or a record-level I/O error occurs (which is surfaced as an
887/// `Err` item).
888pub struct FeatureIter<'a> {
889    shp_reader: ShpReader<BufReader<File>>,
890    dbf_reader: DbfReader<BufReader<File>>,
891    /// Reference to the field descriptors owned by the parent `ShapefileReader`.
892    field_descriptors: &'a [FieldDescriptor],
893    /// Set to `true` after the first `None` or error to make the iterator fused.
894    done: bool,
895}
896
897impl<'a> Iterator for FeatureIter<'a> {
898    type Item = Result<ShapefileFeature>;
899
900    fn next(&mut self) -> Option<Self::Item> {
901        if self.done {
902            return None;
903        }
904
905        // Read one SHP record
906        let shp_record = match self.shp_reader.read_record() {
907            Ok(Some(r)) => r,
908            Ok(None) => {
909                self.done = true;
910                return None;
911            }
912            Err(e) => {
913                self.done = true;
914                return Some(Err(e));
915            }
916        };
917
918        // Read one DBF record
919        let dbf_record = match self.dbf_reader.read_record() {
920            Ok(Some(r)) => r,
921            Ok(None) => {
922                self.done = true;
923                return None;
924            }
925            Err(e) => {
926                self.done = true;
927                return Some(Err(e));
928            }
929        };
930
931        // Convert shape to geometry
932        let geometry = match ShapefileReader::shape_to_geometry_pub(&shp_record.shape) {
933            Ok(g) => g,
934            Err(e) => {
935                self.done = true;
936                return Some(Err(e));
937            }
938        };
939
940        // Convert DBF record to attributes
941        let attributes =
942            ShapefileReader::dbf_to_attributes_pub(&dbf_record, self.field_descriptors);
943
944        Some(Ok(ShapefileFeature::new(
945            shp_record.record_number,
946            geometry,
947            attributes,
948        )))
949    }
950}
951
952#[cfg(test)]
953mod tests {
954    use super::*;
955
956    #[test]
957    fn test_path_extension_helper() {
958        let base = std::env::temp_dir().join("oxigdal_shapefile_test");
959        let expected_shp = std::env::temp_dir().join("oxigdal_shapefile_test.shp");
960        assert_eq!(ShapefileReader::with_extension(&base, "shp"), expected_shp);
961
962        let base_shp = std::env::temp_dir().join("oxigdal_shapefile_test.shp");
963        let expected_dbf = std::env::temp_dir().join("oxigdal_shapefile_test.dbf");
964        assert_eq!(
965            ShapefileReader::with_extension(&base_shp, "dbf"),
966            expected_dbf
967        );
968    }
969
970    #[test]
971    fn test_shapefile_feature_creation() {
972        let mut attributes = HashMap::new();
973        attributes.insert("name".to_string(), FieldValue::String("Test".to_string()));
974        attributes.insert("value".to_string(), FieldValue::Integer(42));
975
976        let geometry = Some(Geometry::Point(CorePoint::new(10.0, 20.0)));
977
978        let feature = ShapefileFeature::new(1, geometry, attributes);
979        assert_eq!(feature.record_number, 1);
980        assert!(feature.geometry.is_some());
981        assert_eq!(feature.attributes.len(), 2);
982    }
983}