Skip to main content

oxigeo_server/
dataset_registry.rs

1//! Dataset registry for managing available layers
2//!
3//! This module provides a thread-safe registry for managing GDAL datasets
4//! that can be served via WMS/WMTS protocols.
5//!
6//! # CRS Transformation
7//!
8//! The registry supports bounding box transformation between coordinate reference systems.
9//! When requesting a layer's bounding box in a target CRS different from the native CRS,
10//! the registry performs:
11//!
12//! 1. Edge densification - Adding intermediate points along bbox edges for accurate transformation
13//! 2. Coordinate transformation - Using proj4rs for coordinate conversion
14//! 3. Axis order handling - Correctly handling lat/lon vs lon/lat conventions
15//!
16//! ## Supported CRS
17//!
18//! - EPSG:4326 (WGS84 - Geographic)
19//! - EPSG:3857 (Web Mercator - Projected)
20//! - All WGS84 UTM zones (EPSG:32601-32660 North, EPSG:32701-32760 South)
21//! - Common national datums (NAD83, ETRS89, GDA94, JGD2000, etc.)
22
23use crate::config::{ConfigError, LayerConfig};
24use oxigeo_core::buffer::RasterBuffer;
25use oxigeo_core::io::FileDataSource;
26use oxigeo_core::types::{GeoTransform, NoDataValue, RasterDataType};
27use oxigeo_geotiff::GeoTiffReader;
28use oxigeo_proj::{BoundingBox, Coordinate, Crs, Transformer};
29use std::collections::HashMap;
30use std::path::{Path, PathBuf};
31use std::sync::{Arc, RwLock};
32use thiserror::Error;
33use tracing::{debug, info, warn};
34
35/// Dataset wrapper for GeoTIFF files
36pub struct Dataset {
37    /// Path to the dataset file
38    path: PathBuf,
39    /// GeoTIFF reader (wrapped for thread-safety)
40    reader: RwLock<Option<GeoTiffReader<FileDataSource>>>,
41    /// Cached metadata
42    width: u64,
43    height: u64,
44    band_count: u32,
45    data_type: RasterDataType,
46    geo_transform: Option<GeoTransform>,
47    nodata: NoDataValue,
48    tile_size: Option<(u32, u32)>,
49    overview_count: usize,
50}
51
52impl Dataset {
53    /// Open a dataset from a file path
54    pub fn open<P: AsRef<Path>>(path: P) -> Result<Self, oxigeo_core::OxiGeoError> {
55        let path_buf = path.as_ref().to_path_buf();
56
57        // Open the file source
58        let source = FileDataSource::open(&path_buf)?;
59
60        // Create the GeoTIFF reader
61        let reader = GeoTiffReader::open(source)?;
62
63        // Extract metadata
64        let width = reader.width();
65        let height = reader.height();
66        let band_count = reader.band_count();
67        let data_type = reader.data_type().unwrap_or(RasterDataType::UInt8);
68        let geo_transform = reader.geo_transform().cloned();
69        let nodata = reader.nodata();
70        let tile_size = reader.tile_size();
71        let overview_count = reader.overview_count();
72
73        Ok(Self {
74            path: path_buf,
75            reader: RwLock::new(Some(reader)),
76            width,
77            height,
78            band_count,
79            data_type,
80            geo_transform,
81            nodata,
82            tile_size,
83            overview_count,
84        })
85    }
86
87    /// Get the file path
88    #[must_use]
89    pub fn path(&self) -> &Path {
90        &self.path
91    }
92
93    /// Get raster size (width, height)
94    #[must_use]
95    pub fn raster_size(&self) -> (usize, usize) {
96        (self.width as usize, self.height as usize)
97    }
98
99    /// Get raster width
100    #[must_use]
101    pub fn width(&self) -> u64 {
102        self.width
103    }
104
105    /// Get raster height
106    #[must_use]
107    pub fn height(&self) -> u64 {
108        self.height
109    }
110
111    /// Get raster band count
112    #[must_use]
113    pub fn raster_count(&self) -> usize {
114        self.band_count as usize
115    }
116
117    /// Get the data type
118    #[must_use]
119    pub fn data_type(&self) -> RasterDataType {
120        self.data_type
121    }
122
123    /// Get projection (WKT string)
124    pub fn projection(&self) -> Result<String, oxigeo_core::OxiGeoError> {
125        // Return EPSG code if available
126        if let Ok(guard) = self.reader.read()
127            && let Some(ref reader) = *guard
128            && let Some(epsg) = reader.epsg_code()
129        {
130            return Ok(format!("EPSG:{}", epsg));
131        }
132        Ok("EPSG:4326".to_string())
133    }
134
135    /// Get geotransform as array
136    pub fn geotransform(&self) -> Result<[f64; 6], oxigeo_core::OxiGeoError> {
137        if let Some(ref gt) = self.geo_transform {
138            Ok(gt.to_gdal_array())
139        } else {
140            // Default identity transform
141            Ok([0.0, 1.0, 0.0, 0.0, 0.0, -1.0])
142        }
143    }
144
145    /// Get the GeoTransform
146    #[must_use]
147    pub fn geo_transform_obj(&self) -> Option<&GeoTransform> {
148        self.geo_transform.as_ref()
149    }
150
151    /// Get NoData value
152    #[must_use]
153    pub fn nodata(&self) -> NoDataValue {
154        self.nodata
155    }
156
157    /// Get tile size if tiled
158    #[must_use]
159    pub fn tile_size(&self) -> Option<(u32, u32)> {
160        self.tile_size
161    }
162
163    /// Get number of overview levels
164    #[must_use]
165    pub fn overview_count(&self) -> usize {
166        self.overview_count
167    }
168
169    /// Get bounding box
170    #[must_use]
171    pub fn bounds(&self) -> Option<oxigeo_core::types::BoundingBox> {
172        self.geo_transform
173            .as_ref()
174            .map(|gt| gt.compute_bounds(self.width, self.height))
175    }
176
177    /// Read a tile from the dataset
178    ///
179    /// # Arguments
180    /// * `level` - Overview level (0 = full resolution)
181    /// * `tile_x` - Tile X coordinate
182    /// * `tile_y` - Tile Y coordinate
183    pub fn read_tile(
184        &self,
185        level: usize,
186        tile_x: u32,
187        tile_y: u32,
188    ) -> Result<Vec<u8>, oxigeo_core::OxiGeoError> {
189        let guard = self
190            .reader
191            .read()
192            .map_err(|_| oxigeo_core::OxiGeoError::Internal {
193                message: "Failed to acquire read lock".to_string(),
194            })?;
195
196        if let Some(ref reader) = *guard {
197            reader.read_tile(level, tile_x, tile_y)
198        } else {
199            Err(oxigeo_core::OxiGeoError::Internal {
200                message: "Reader not initialized".to_string(),
201            })
202        }
203    }
204
205    /// Pixel dimensions `(width, height)` of one resolution level.
206    ///
207    /// `level` is `0` for full resolution and `1..=`[`Self::overview_count`] for
208    /// the overviews. The values come from the level's **own** IFD, so they are
209    /// the dimensions actually stored rather than the `ceil(full / 2^level)` a
210    /// caller would otherwise have to assume — an assumption that only holds for
211    /// a strict power-of-two pyramid and silently mis-describes every overview
212    /// of a raster GDAL (or this crate's own writer) built with any other
213    /// decimation factor. See <https://github.com/cool-japan/oxigeo/issues/14>.
214    ///
215    /// # Errors
216    /// Returns an error if `level` names no overview or if the reader is closed.
217    pub fn level_size(&self, level: usize) -> Result<(u64, u64), oxigeo_core::OxiGeoError> {
218        let guard = self
219            .reader
220            .read()
221            .map_err(|_| oxigeo_core::OxiGeoError::Internal {
222                message: "Failed to acquire read lock".to_string(),
223            })?;
224
225        if let Some(ref reader) = *guard {
226            reader.level_size(level)
227        } else {
228            Err(oxigeo_core::OxiGeoError::Internal {
229                message: "Reader not initialized".to_string(),
230            })
231        }
232    }
233
234    /// Read band 0 of a tile as RasterBuffer
235    ///
236    /// Shorthand for [`Self::read_tile_band_buffer`] with `band = 0`.
237    pub fn read_tile_buffer(
238        &self,
239        level: usize,
240        tile_x: u32,
241        tile_y: u32,
242    ) -> Result<RasterBuffer, oxigeo_core::OxiGeoError> {
243        self.read_tile_band_buffer(level, 0, tile_x, tile_y)
244    }
245
246    /// Read one band of a tile as RasterBuffer
247    ///
248    /// A [`RasterBuffer`] holds one band's plane, so a multi-band tile needs a
249    /// band selector; [`Self::read_tile_buffer`] pinned it to 0. The driver
250    /// de-interleaves a chunky band and plane-selects a planar one, and takes
251    /// the block geometry from the requested level's own IFD.
252    ///
253    /// # Arguments
254    /// * `level` - Overview level (0 = full resolution)
255    /// * `band` - Zero-based band index
256    /// * `tile_x` - Block column
257    /// * `tile_y` - Block row
258    pub fn read_tile_band_buffer(
259        &self,
260        level: usize,
261        band: usize,
262        tile_x: u32,
263        tile_y: u32,
264    ) -> Result<RasterBuffer, oxigeo_core::OxiGeoError> {
265        let guard = self
266            .reader
267            .read()
268            .map_err(|_| oxigeo_core::OxiGeoError::Internal {
269                message: "Failed to acquire read lock".to_string(),
270            })?;
271
272        if let Some(ref reader) = *guard {
273            reader.read_tile_band_buffer(level, band, tile_x, tile_y)
274        } else {
275            Err(oxigeo_core::OxiGeoError::Internal {
276                message: "Reader not initialized".to_string(),
277            })
278        }
279    }
280
281    /// Read full band data
282    pub fn read_band(
283        &self,
284        level: usize,
285        band: usize,
286    ) -> Result<Vec<u8>, oxigeo_core::OxiGeoError> {
287        let guard = self
288            .reader
289            .read()
290            .map_err(|_| oxigeo_core::OxiGeoError::Internal {
291                message: "Failed to acquire read lock".to_string(),
292            })?;
293
294        if let Some(ref reader) = *guard {
295            reader.read_band(level, band)
296        } else {
297            Err(oxigeo_core::OxiGeoError::Internal {
298                message: "Reader not initialized".to_string(),
299            })
300        }
301    }
302
303    /// Read a window of the first band from the dataset as RasterBuffer
304    ///
305    /// Equivalent to [`Self::read_band_window`] with `level = 0`, `band = 0`.
306    ///
307    /// # Arguments
308    /// * `x_offset` - X offset in pixels
309    /// * `y_offset` - Y offset in pixels
310    /// * `x_size` - Width to read
311    /// * `y_size` - Height to read
312    pub fn read_window(
313        &self,
314        x_offset: u64,
315        y_offset: u64,
316        x_size: u64,
317        y_size: u64,
318    ) -> Result<RasterBuffer, oxigeo_core::OxiGeoError> {
319        self.read_band_window(0, 0, x_offset, y_offset, x_size, y_size)
320    }
321
322    /// Read a window of one band from the dataset as RasterBuffer
323    ///
324    /// The returned buffer is always `x_size` x `y_size`; the part of the
325    /// request that overhangs the raster extent is left at zero.
326    ///
327    /// This delegates to `GeoTiffReader::read_window`, which touches only the
328    /// tiles or strips overlapping the window and de-interleaves the requested
329    /// band for us. It replaces an older tile-stitching loop built on
330    /// `read_tile_buffer`, which could not represent a chunky multi-band tile
331    /// and therefore returned a silently all-zero buffer for every dataset with
332    /// more than one band. See <https://github.com/cool-japan/oxigeo/issues/14>.
333    ///
334    /// The bounds check and the clamp use the **level's own** dimensions
335    /// ([`Self::level_size`]), so `level > 0` behaves exactly as `level = 0`
336    /// does. They used to use the full-resolution dimensions — the driver
337    /// exposed no per-level size accessor — which made every overview window
338    /// that fitted the full-resolution grid but not the overview grid an error
339    /// instead of a clamp.
340    ///
341    /// # Arguments
342    /// * `level` - Overview level (0 = full resolution)
343    /// * `band` - Zero-based band index
344    /// * `x_offset` - X offset in pixels, in the level's grid
345    /// * `y_offset` - Y offset in pixels, in the level's grid
346    /// * `x_size` - Width to read
347    /// * `y_size` - Height to read
348    pub fn read_band_window(
349        &self,
350        level: usize,
351        band: usize,
352        x_offset: u64,
353        y_offset: u64,
354        x_size: u64,
355        y_size: u64,
356    ) -> Result<RasterBuffer, oxigeo_core::OxiGeoError> {
357        // The level's own extent, from its own IFD: an overview chain is not
358        // necessarily power-of-two, so `ceil(full / 2^level)` is not it.
359        let (level_width, level_height) = self.level_size(level)?;
360
361        // Validate window bounds
362        if x_offset >= level_width || y_offset >= level_height {
363            return Err(oxigeo_core::OxiGeoError::OutOfBounds {
364                message: format!(
365                    "Window offset ({}, {}) out of bounds ({}x{}) at level {}",
366                    x_offset, y_offset, level_width, level_height, level
367                ),
368            });
369        }
370
371        // Clamp window size to the level's bounds; the overhang stays zeroed.
372        let actual_x_size = x_size.min(level_width - x_offset);
373        let actual_y_size = y_size.min(level_height - y_offset);
374
375        let mut window_buffer = RasterBuffer::zeros(x_size, y_size, self.data_type);
376        if actual_x_size == 0 || actual_y_size == 0 {
377            return Ok(window_buffer);
378        }
379
380        let guard = self
381            .reader
382            .read()
383            .map_err(|_| oxigeo_core::OxiGeoError::Internal {
384                message: "Failed to acquire read lock".to_string(),
385            })?;
386        let reader = guard
387            .as_ref()
388            .ok_or_else(|| oxigeo_core::OxiGeoError::Internal {
389                message: "Reader not initialized".to_string(),
390            })?;
391
392        let bytes = reader.read_window(
393            level,
394            band,
395            x_offset,
396            y_offset,
397            actual_x_size,
398            actual_y_size,
399        )?;
400
401        let plane = RasterBuffer::new(
402            bytes,
403            actual_x_size,
404            actual_y_size,
405            self.data_type,
406            self.nodata,
407        )?;
408
409        if actual_x_size == x_size && actual_y_size == y_size {
410            return Ok(plane);
411        }
412
413        for y in 0..actual_y_size {
414            for x in 0..actual_x_size {
415                let value = plane.get_pixel(x, y)?;
416                window_buffer.set_pixel(x, y, value)?;
417            }
418        }
419
420        Ok(window_buffer)
421    }
422
423    /// Choose the coarsest resolution level that still resolves the request.
424    ///
425    /// `src_width` x `src_height` is the full-resolution pixel window the
426    /// request covers and `target_width` x `target_height` the image being
427    /// produced from it, so their ratio is how much the client is downsampling.
428    /// The chosen level is the coarsest whose own decimation factor is still no
429    /// more than 1.5x that ratio; level 0 is returned when the client wants full
430    /// resolution or upsampling, or when the dataset has no overviews.
431    ///
432    /// # Why this is not `1 << level`
433    ///
434    /// The WMS and WMTS handlers each used to assume level *n* is exactly
435    /// `2^n` coarser than full resolution. GDAL — and this crate's own writer —
436    /// routinely build chains that are not exact powers of two (a 5x3 level
437    /// under an 8x6 base is a decimation of 1.6, not 2), and rounding at every
438    /// step compounds the drift further down a chain. The factor is measured
439    /// here against each level's real dimensions instead, via
440    /// [`Self::level_size`]. See <https://github.com/cool-japan/oxigeo/issues/14>.
441    ///
442    /// A level whose size cannot be read ends the search rather than failing the
443    /// request: a coarser level is an optimisation, and level 0 always works.
444    #[must_use]
445    pub fn select_overview_level(
446        &self,
447        src_width: u64,
448        src_height: u64,
449        target_width: u64,
450        target_height: u64,
451    ) -> usize {
452        if self.overview_count == 0 {
453            return 0;
454        }
455
456        let ratio_x = if target_width > 0 {
457            src_width as f64 / target_width as f64
458        } else {
459            1.0
460        };
461        let ratio_y = if target_height > 0 {
462            src_height as f64 / target_height as f64
463        } else {
464            1.0
465        };
466        let request_ratio = ratio_x.max(ratio_y);
467        if request_ratio <= 1.0 {
468            // Full resolution or upsampling.
469            return 0;
470        }
471
472        let Ok((base_width, base_height)) = self.level_size(0) else {
473            return 0;
474        };
475        if base_width == 0 || base_height == 0 {
476            return 0;
477        }
478
479        let mut best_level = 0;
480        for level in 1..=self.overview_count {
481            let Ok((level_width, level_height)) = self.level_size(level) else {
482                break;
483            };
484            if level_width == 0 || level_height == 0 {
485                break;
486            }
487            let factor = (base_width as f64 / level_width as f64)
488                .max(base_height as f64 / level_height as f64);
489            // Allow a slight overshoot (1.5x) to avoid reading unnecessarily
490            // large data, exactly as the power-of-two version did.
491            if factor <= request_ratio * 1.5 {
492                best_level = level;
493            } else {
494                break;
495            }
496        }
497
498        best_level
499    }
500
501    /// Map a full-resolution pixel window onto `level`'s own pixel grid.
502    ///
503    /// Returns `(x, y, width, height)` in the level's coordinates, clamped to
504    /// the level's extent and never zero-sized. The scale factors come from
505    /// [`Self::level_size`], so a non-power-of-two overview lands where its
506    /// pixels actually are; with `level = 0` the window is returned unchanged
507    /// apart from the clamp.
508    ///
509    /// # Errors
510    /// Returns an error if `level` names no overview or if the reader is closed.
511    pub fn window_at_level(
512        &self,
513        level: usize,
514        x: u64,
515        y: u64,
516        width: u64,
517        height: u64,
518    ) -> Result<(u64, u64, u64, u64), oxigeo_core::OxiGeoError> {
519        let (level_width, level_height) = self.level_size(level)?;
520        if level_width == 0 || level_height == 0 {
521            return Err(oxigeo_core::OxiGeoError::OutOfBounds {
522                message: format!("Level {level} declares a zero-sized image"),
523            });
524        }
525
526        let (base_width, base_height) = self.level_size(0)?;
527        let scale_x = if base_width > 0 {
528            level_width as f64 / base_width as f64
529        } else {
530            1.0
531        };
532        let scale_y = if base_height > 0 {
533            level_height as f64 / base_height as f64
534        } else {
535            1.0
536        };
537
538        let scaled_x = ((x as f64) * scale_x).floor().max(0.0) as u64;
539        let scaled_y = ((y as f64) * scale_y).floor().max(0.0) as u64;
540        let scaled_x = scaled_x.min(level_width - 1);
541        let scaled_y = scaled_y.min(level_height - 1);
542
543        let scaled_width = (((width as f64) * scale_x).ceil().max(1.0) as u64)
544            .min(level_width - scaled_x)
545            .max(1);
546        let scaled_height = (((height as f64) * scale_y).ceil().max(1.0) as u64)
547            .min(level_height - scaled_y)
548            .max(1);
549
550        Ok((scaled_x, scaled_y, scaled_width, scaled_height))
551    }
552
553    /// Get the first band's pixel value at coordinates
554    ///
555    /// Reads a 1x1 window rather than decoding the whole containing tile and
556    /// indexing into it. The tile-based version could not represent a chunky
557    /// multi-band tile and so returned an error for every dataset with more
558    /// than one band -- the same defect that made `read_window` return zeros.
559    /// See <https://github.com/cool-japan/oxigeo/issues/14>.
560    pub fn get_pixel(&self, x: u64, y: u64) -> Result<f64, oxigeo_core::OxiGeoError> {
561        if x >= self.width || y >= self.height {
562            return Err(oxigeo_core::OxiGeoError::OutOfBounds {
563                message: format!(
564                    "Pixel ({}, {}) out of bounds ({}x{})",
565                    x, y, self.width, self.height
566                ),
567            });
568        }
569
570        self.read_band_window(0, 0, x, y, 1, 1)?.get_pixel(0, 0)
571    }
572
573    /// Get raster band info (for compatibility with old code)
574    pub fn rasterband(&self, _band: usize) -> Result<RasterBandInfo, oxigeo_core::OxiGeoError> {
575        Ok(RasterBandInfo {
576            nodata: self.nodata,
577            data_type: self.data_type,
578        })
579    }
580}
581
582/// Raster band information (compatible with old interface)
583pub struct RasterBandInfo {
584    nodata: NoDataValue,
585    data_type: RasterDataType,
586}
587
588impl RasterBandInfo {
589    /// Get nodata value
590    pub fn nodata(&self) -> Option<f64> {
591        self.nodata.as_f64()
592    }
593
594    /// Get datatype string
595    pub fn datatype(&self) -> &str {
596        match self.data_type {
597            RasterDataType::UInt8 => "UInt8",
598            RasterDataType::Int8 => "Int8",
599            RasterDataType::UInt16 => "UInt16",
600            RasterDataType::Int16 => "Int16",
601            RasterDataType::UInt32 => "UInt32",
602            RasterDataType::Int32 => "Int32",
603            RasterDataType::Float32 => "Float32",
604            RasterDataType::Float64 => "Float64",
605            RasterDataType::UInt64 => "UInt64",
606            RasterDataType::Int64 => "Int64",
607            RasterDataType::CFloat32 => "CFloat32",
608            RasterDataType::CFloat64 => "CFloat64",
609        }
610    }
611}
612
613/// Registry errors
614#[derive(Debug, Error)]
615pub enum RegistryError {
616    /// Layer not found
617    #[error("Layer not found: {0}")]
618    LayerNotFound(String),
619
620    /// Dataset open error
621    #[error("Failed to open dataset: {0}")]
622    DatasetOpen(#[from] oxigeo_core::OxiGeoError),
623
624    /// Configuration error
625    #[error("Configuration error: {0}")]
626    Config(#[from] ConfigError),
627
628    /// CRS transformation error
629    #[error("CRS transformation failed: {0}")]
630    CrsTransformation(String),
631
632    /// Invalid CRS specification
633    #[error("Invalid CRS: {0}")]
634    InvalidCrs(String),
635
636    /// Lock poisoned
637    #[error("Lock poisoned")]
638    LockPoisoned,
639}
640
641impl From<oxigeo_proj::Error> for RegistryError {
642    fn from(err: oxigeo_proj::Error) -> Self {
643        RegistryError::CrsTransformation(err.to_string())
644    }
645}
646
647/// Result type for registry operations
648pub type RegistryResult<T> = Result<T, RegistryError>;
649
650/// Information about a registered layer
651#[derive(Debug, Clone)]
652pub struct LayerInfo {
653    /// Layer name
654    pub name: String,
655
656    /// Display title
657    pub title: String,
658
659    /// Layer description
660    pub abstract_: String,
661
662    /// Configuration
663    pub config: LayerConfig,
664
665    /// Dataset metadata
666    pub metadata: DatasetMetadata,
667}
668
669/// Dataset metadata extracted from GDAL
670#[derive(Debug, Clone)]
671pub struct DatasetMetadata {
672    /// Dataset width in pixels
673    pub width: usize,
674
675    /// Dataset height in pixels
676    pub height: usize,
677
678    /// Number of bands
679    pub band_count: usize,
680
681    /// Data type name
682    pub data_type: String,
683
684    /// Spatial reference system (WKT)
685    pub srs: Option<String>,
686
687    /// Bounding box (min_x, min_y, max_x, max_y)
688    pub bbox: Option<(f64, f64, f64, f64)>,
689
690    /// Geotransform coefficients
691    pub geotransform: Option<[f64; 6]>,
692
693    /// NoData value
694    pub nodata: Option<f64>,
695}
696
697/// Thread-safe dataset registry
698pub struct DatasetRegistry {
699    /// Registered layers
700    layers: Arc<RwLock<HashMap<String, LayerInfo>>>,
701
702    /// Dataset cache (opened datasets)
703    datasets: Arc<RwLock<HashMap<String, Arc<Dataset>>>>,
704}
705
706impl DatasetRegistry {
707    /// Create a new empty registry
708    pub fn new() -> Self {
709        Self {
710            layers: Arc::new(RwLock::new(HashMap::new())),
711            datasets: Arc::new(RwLock::new(HashMap::new())),
712        }
713    }
714
715    /// Register a layer from configuration
716    pub fn register_layer(&self, config: LayerConfig) -> RegistryResult<()> {
717        info!("Registering layer: {}", config.name);
718
719        // Save the name before moving config
720        let layer_name = config.name.clone();
721
722        // Open dataset to extract metadata
723        let dataset = Self::open_dataset(&config.path)?;
724        let metadata = Self::extract_metadata(&dataset)?;
725
726        debug!(
727            "Layer {} metadata: {}x{}, {} bands",
728            layer_name, metadata.width, metadata.height, metadata.band_count
729        );
730
731        let layer_info = LayerInfo {
732            name: config.name.clone(),
733            title: config.title.clone().unwrap_or_else(|| config.name.clone()),
734            abstract_: config
735                .abstract_
736                .clone()
737                .unwrap_or_else(|| format!("Layer {}", config.name)),
738            config,
739            metadata,
740        };
741
742        // Store layer info
743        let mut layers = self
744            .layers
745            .write()
746            .map_err(|_| RegistryError::LockPoisoned)?;
747        layers.insert(layer_info.name.clone(), layer_info);
748
749        // Cache the dataset
750        let mut datasets = self
751            .datasets
752            .write()
753            .map_err(|_| RegistryError::LockPoisoned)?;
754        datasets.insert(layer_name, Arc::new(dataset));
755
756        Ok(())
757    }
758
759    /// Register multiple layers from configurations
760    pub fn register_layers(&self, configs: Vec<LayerConfig>) -> RegistryResult<()> {
761        for config in configs {
762            if !config.enabled {
763                debug!("Skipping disabled layer: {}", config.name);
764                continue;
765            }
766
767            if let Err(e) = self.register_layer(config) {
768                warn!("Failed to register layer: {}", e);
769                // Continue with other layers
770            }
771        }
772        Ok(())
773    }
774
775    /// Get layer information
776    pub fn get_layer(&self, name: &str) -> RegistryResult<LayerInfo> {
777        let layers = self
778            .layers
779            .read()
780            .map_err(|_| RegistryError::LockPoisoned)?;
781
782        layers
783            .get(name)
784            .cloned()
785            .ok_or_else(|| RegistryError::LayerNotFound(name.to_string()))
786    }
787
788    /// Get a dataset for a layer
789    pub fn get_dataset(&self, name: &str) -> RegistryResult<Arc<Dataset>> {
790        let datasets = self
791            .datasets
792            .read()
793            .map_err(|_| RegistryError::LockPoisoned)?;
794
795        datasets
796            .get(name)
797            .cloned()
798            .ok_or_else(|| RegistryError::LayerNotFound(name.to_string()))
799    }
800
801    /// List all registered layers
802    pub fn list_layers(&self) -> RegistryResult<Vec<LayerInfo>> {
803        let layers = self
804            .layers
805            .read()
806            .map_err(|_| RegistryError::LockPoisoned)?;
807
808        Ok(layers.values().cloned().collect())
809    }
810
811    /// Check if a layer exists
812    pub fn has_layer(&self, name: &str) -> bool {
813        self.layers
814            .read()
815            .map(|layers| layers.contains_key(name))
816            .unwrap_or(false)
817    }
818
819    /// Remove a layer from the registry
820    pub fn unregister_layer(&self, name: &str) -> RegistryResult<()> {
821        let mut layers = self
822            .layers
823            .write()
824            .map_err(|_| RegistryError::LockPoisoned)?;
825
826        let mut datasets = self
827            .datasets
828            .write()
829            .map_err(|_| RegistryError::LockPoisoned)?;
830
831        layers.remove(name);
832        datasets.remove(name);
833
834        info!("Unregistered layer: {}", name);
835        Ok(())
836    }
837
838    /// Get the number of registered layers
839    pub fn layer_count(&self) -> usize {
840        self.layers.read().map(|l| l.len()).unwrap_or(0)
841    }
842
843    /// Open a dataset from a path
844    fn open_dataset<P: AsRef<Path>>(path: P) -> RegistryResult<Dataset> {
845        let dataset = Dataset::open(path.as_ref())?;
846        Ok(dataset)
847    }
848
849    /// Extract metadata from a dataset
850    fn extract_metadata(dataset: &Dataset) -> RegistryResult<DatasetMetadata> {
851        let raster_size = dataset.raster_size();
852        let band_count = dataset.raster_count();
853
854        // Get spatial reference
855        let srs = dataset.projection().ok();
856
857        // Get bounding box from geotransform
858        let geotransform = dataset.geotransform().ok();
859        let bbox = geotransform.map(|gt| {
860            let width = raster_size.0 as f64;
861            let height = raster_size.1 as f64;
862
863            let min_x = gt[0];
864            let max_x = gt[0] + gt[1] * width + gt[2] * height;
865            let max_y = gt[3];
866            let min_y = gt[3] + gt[4] * width + gt[5] * height;
867
868            // Ensure proper order
869            let (min_x, max_x) = if min_x < max_x {
870                (min_x, max_x)
871            } else {
872                (max_x, min_x)
873            };
874            let (min_y, max_y) = if min_y < max_y {
875                (min_y, max_y)
876            } else {
877                (max_y, min_y)
878            };
879
880            (min_x, min_y, max_x, max_y)
881        });
882
883        // Get NoData value from first band
884        let nodata = if band_count > 0 {
885            dataset.rasterband(1).ok().and_then(|band| band.nodata())
886        } else {
887            None
888        };
889
890        // Get data type from first band
891        let data_type = if band_count > 0 {
892            dataset
893                .rasterband(1)
894                .ok()
895                .map(|band| format!("{:?}", band.datatype()))
896                .unwrap_or_else(|| "Unknown".to_string())
897        } else {
898            "Unknown".to_string()
899        };
900
901        Ok(DatasetMetadata {
902            width: raster_size.0,
903            height: raster_size.1,
904            band_count,
905            data_type,
906            srs,
907            bbox,
908            geotransform,
909            nodata,
910        })
911    }
912
913    /// Reload a layer (useful after dataset updates)
914    pub fn reload_layer(&self, name: &str) -> RegistryResult<()> {
915        let config = {
916            let layers = self
917                .layers
918                .read()
919                .map_err(|_| RegistryError::LockPoisoned)?;
920
921            layers
922                .get(name)
923                .ok_or_else(|| RegistryError::LayerNotFound(name.to_string()))?
924                .config
925                .clone()
926        };
927
928        self.unregister_layer(name)?;
929        self.register_layer(config)?;
930
931        info!("Reloaded layer: {}", name);
932        Ok(())
933    }
934
935    /// Get layer bounding box in a specific CRS
936    ///
937    /// This method transforms the native bounding box of a layer to a target CRS.
938    /// The transformation uses edge densification for accurate results, especially
939    /// when transforming between geographic and projected coordinate systems.
940    ///
941    /// # Arguments
942    ///
943    /// * `name` - The layer name
944    /// * `target_crs` - Optional target CRS specification (e.g., "EPSG:4326", "EPSG:3857")
945    ///
946    /// # Returns
947    ///
948    /// The bounding box in the target CRS as (min_x, min_y, max_x, max_y), or None
949    /// if the layer has no bounding box defined.
950    ///
951    /// # Errors
952    ///
953    /// Returns an error if:
954    /// - The layer is not found
955    /// - The source or target CRS is invalid
956    /// - The transformation fails
957    ///
958    /// # Example
959    ///
960    /// ```ignore
961    /// let registry = DatasetRegistry::new();
962    /// // Get bounding box in Web Mercator
963    /// let bbox = registry.get_layer_bbox("my_layer", Some("EPSG:3857"))?;
964    /// ```
965    pub fn get_layer_bbox(
966        &self,
967        name: &str,
968        target_crs: Option<&str>,
969    ) -> RegistryResult<Option<(f64, f64, f64, f64)>> {
970        let layer = self.get_layer(name)?;
971
972        // Return native bbox if no target CRS specified
973        let target_crs_str = match target_crs {
974            Some(crs) => crs,
975            None => return Ok(layer.metadata.bbox),
976        };
977
978        // Get the native bbox
979        let native_bbox = match layer.metadata.bbox {
980            Some(bbox) => bbox,
981            None => return Ok(None),
982        };
983
984        // Get the source CRS from layer metadata
985        let source_crs_str = layer.metadata.srs.as_deref().unwrap_or("EPSG:4326");
986
987        // Check if source and target CRS are the same
988        if Self::crs_strings_equivalent(source_crs_str, target_crs_str) {
989            return Ok(Some(native_bbox));
990        }
991
992        // Parse source and target CRS
993        let source_crs = Self::parse_crs(source_crs_str)?;
994        let target_crs = Self::parse_crs(target_crs_str)?;
995
996        // Transform the bounding box
997        let transformed =
998            Self::transform_bbox_with_densification(native_bbox, &source_crs, &target_crs)?;
999
1000        Ok(Some(transformed))
1001    }
1002
1003    /// Parse a CRS string into a Crs object
1004    ///
1005    /// Supports formats:
1006    /// - "EPSG:4326" or "epsg:4326"
1007    /// - PROJ strings ("+proj=longlat +datum=WGS84 +no_defs")
1008    /// - WKT strings
1009    fn parse_crs(crs_str: &str) -> RegistryResult<Crs> {
1010        let trimmed = crs_str.trim();
1011
1012        // Try EPSG code format
1013        if let Some(code_str) = trimmed.to_uppercase().strip_prefix("EPSG:") {
1014            let code: u32 = code_str.parse().map_err(|_| {
1015                RegistryError::InvalidCrs(format!("Invalid EPSG code: {}", code_str))
1016            })?;
1017            return Ok(Crs::from_epsg(code)?);
1018        }
1019
1020        // Try PROJ string
1021        if trimmed.starts_with("+proj=") || trimmed.contains("+proj=") {
1022            return Ok(Crs::from_proj(trimmed)?);
1023        }
1024
1025        // Try WKT
1026        if trimmed.starts_with("GEOGCS[")
1027            || trimmed.starts_with("PROJCS[")
1028            || trimmed.starts_with("GEOCCS[")
1029        {
1030            return Ok(Crs::from_wkt(trimmed)?);
1031        }
1032
1033        Err(RegistryError::InvalidCrs(format!(
1034            "Unrecognized CRS format: {}",
1035            crs_str
1036        )))
1037    }
1038
1039    /// Check if two CRS strings refer to the same CRS
1040    fn crs_strings_equivalent(crs1: &str, crs2: &str) -> bool {
1041        let norm1 = crs1.trim().to_uppercase();
1042        let norm2 = crs2.trim().to_uppercase();
1043        norm1 == norm2
1044    }
1045
1046    /// Transform a bounding box with edge densification for accurate results
1047    ///
1048    /// For accurate transformation between coordinate systems (especially between
1049    /// geographic and projected CRS), we densify the edges of the bounding box
1050    /// by adding intermediate points. This accounts for the curvature introduced
1051    /// by the projection.
1052    ///
1053    /// # Arguments
1054    ///
1055    /// * `bbox` - The bounding box as (min_x, min_y, max_x, max_y)
1056    /// * `source_crs` - Source coordinate reference system
1057    /// * `target_crs` - Target coordinate reference system
1058    ///
1059    /// # Returns
1060    ///
1061    /// The transformed bounding box as (min_x, min_y, max_x, max_y)
1062    fn transform_bbox_with_densification(
1063        bbox: (f64, f64, f64, f64),
1064        source_crs: &Crs,
1065        target_crs: &Crs,
1066    ) -> RegistryResult<(f64, f64, f64, f64)> {
1067        let (min_x, min_y, max_x, max_y) = bbox;
1068
1069        // Create transformer
1070        let transformer = Transformer::new(source_crs.clone(), target_crs.clone())?;
1071
1072        // Number of points to sample along each edge for accurate transformation
1073        // More points = more accurate but slower
1074        // 21 points per edge is a good balance (20 segments)
1075        const DENSIFY_POINTS: usize = 21;
1076
1077        // Generate densified edge points
1078        let edge_points = Self::densify_bbox_edges(min_x, min_y, max_x, max_y, DENSIFY_POINTS);
1079
1080        // Transform all edge points
1081        let transformed_points: Vec<Coordinate> = edge_points
1082            .iter()
1083            .filter_map(|coord| transformer.transform(coord).ok())
1084            .collect();
1085
1086        // Check if we got valid transformed points
1087        if transformed_points.is_empty() {
1088            return Err(RegistryError::CrsTransformation(
1089                "All points failed to transform".to_string(),
1090            ));
1091        }
1092
1093        // Find the bounding box of transformed points
1094        let mut result_min_x = f64::INFINITY;
1095        let mut result_min_y = f64::INFINITY;
1096        let mut result_max_x = f64::NEG_INFINITY;
1097        let mut result_max_y = f64::NEG_INFINITY;
1098
1099        for point in &transformed_points {
1100            if point.x.is_finite() && point.y.is_finite() {
1101                result_min_x = result_min_x.min(point.x);
1102                result_min_y = result_min_y.min(point.y);
1103                result_max_x = result_max_x.max(point.x);
1104                result_max_y = result_max_y.max(point.y);
1105            }
1106        }
1107
1108        // Verify we have valid results
1109        if !result_min_x.is_finite()
1110            || !result_min_y.is_finite()
1111            || !result_max_x.is_finite()
1112            || !result_max_y.is_finite()
1113        {
1114            return Err(RegistryError::CrsTransformation(
1115                "Transformation resulted in non-finite values".to_string(),
1116            ));
1117        }
1118
1119        Ok((result_min_x, result_min_y, result_max_x, result_max_y))
1120    }
1121
1122    /// Generate densified points along the edges of a bounding box
1123    ///
1124    /// Creates evenly spaced points along all four edges of the bbox.
1125    /// The points are ordered: bottom edge, right edge, top edge, left edge.
1126    fn densify_bbox_edges(
1127        min_x: f64,
1128        min_y: f64,
1129        max_x: f64,
1130        max_y: f64,
1131        points_per_edge: usize,
1132    ) -> Vec<Coordinate> {
1133        let mut points = Vec::with_capacity(points_per_edge * 4);
1134
1135        // Ensure at least 2 points per edge (corners)
1136        let n = points_per_edge.max(2);
1137
1138        // Bottom edge (min_y, from min_x to max_x)
1139        for i in 0..n {
1140            let t = i as f64 / (n - 1) as f64;
1141            let x = min_x + t * (max_x - min_x);
1142            points.push(Coordinate::new(x, min_y));
1143        }
1144
1145        // Right edge (max_x, from min_y to max_y)
1146        // Skip first point to avoid duplicate corner
1147        for i in 1..n {
1148            let t = i as f64 / (n - 1) as f64;
1149            let y = min_y + t * (max_y - min_y);
1150            points.push(Coordinate::new(max_x, y));
1151        }
1152
1153        // Top edge (max_y, from max_x to min_x)
1154        // Skip first point to avoid duplicate corner
1155        for i in 1..n {
1156            let t = i as f64 / (n - 1) as f64;
1157            let x = max_x - t * (max_x - min_x);
1158            points.push(Coordinate::new(x, max_y));
1159        }
1160
1161        // Left edge (min_x, from max_y to min_y)
1162        // Skip first and last points to avoid duplicate corners
1163        for i in 1..(n - 1) {
1164            let t = i as f64 / (n - 1) as f64;
1165            let y = max_y - t * (max_y - min_y);
1166            points.push(Coordinate::new(min_x, y));
1167        }
1168
1169        points
1170    }
1171
1172    /// Transform a single point from source to target CRS
1173    ///
1174    /// Convenience method for transforming individual coordinates.
1175    #[allow(dead_code)]
1176    fn transform_point(
1177        x: f64,
1178        y: f64,
1179        source_crs: &Crs,
1180        target_crs: &Crs,
1181    ) -> RegistryResult<(f64, f64)> {
1182        let transformer = Transformer::new(source_crs.clone(), target_crs.clone())?;
1183        let coord = Coordinate::new(x, y);
1184        let transformed = transformer.transform(&coord)?;
1185        Ok((transformed.x, transformed.y))
1186    }
1187
1188    /// Get layer bounding box in Web Mercator (EPSG:3857)
1189    ///
1190    /// Convenience method for getting bbox in the most common web mapping CRS.
1191    pub fn get_layer_bbox_web_mercator(
1192        &self,
1193        name: &str,
1194    ) -> RegistryResult<Option<(f64, f64, f64, f64)>> {
1195        self.get_layer_bbox(name, Some("EPSG:3857"))
1196    }
1197
1198    /// Get layer bounding box in WGS84 (EPSG:4326)
1199    ///
1200    /// Convenience method for getting bbox in geographic coordinates.
1201    pub fn get_layer_bbox_wgs84(&self, name: &str) -> RegistryResult<Option<(f64, f64, f64, f64)>> {
1202        self.get_layer_bbox(name, Some("EPSG:4326"))
1203    }
1204
1205    /// Transform bounding box using the simple 4-corner method
1206    ///
1207    /// This is faster but less accurate than densification for projections
1208    /// with significant curvature. Use for quick approximations.
1209    #[allow(dead_code)]
1210    fn transform_bbox_simple(
1211        bbox: (f64, f64, f64, f64),
1212        source_crs: &Crs,
1213        target_crs: &Crs,
1214    ) -> RegistryResult<(f64, f64, f64, f64)> {
1215        let (min_x, min_y, max_x, max_y) = bbox;
1216
1217        // Create bounding box using oxigeo_proj
1218        let source_bbox = BoundingBox::new(min_x, min_y, max_x, max_y)
1219            .map_err(|e| RegistryError::CrsTransformation(e.to_string()))?;
1220
1221        // Create transformer and transform bbox
1222        let transformer = Transformer::new(source_crs.clone(), target_crs.clone())?;
1223        let transformed = transformer.transform_bbox(&source_bbox)?;
1224
1225        Ok((
1226            transformed.min_x,
1227            transformed.min_y,
1228            transformed.max_x,
1229            transformed.max_y,
1230        ))
1231    }
1232}
1233
1234impl Default for DatasetRegistry {
1235    fn default() -> Self {
1236        Self::new()
1237    }
1238}
1239
1240impl Clone for DatasetRegistry {
1241    fn clone(&self) -> Self {
1242        Self {
1243            layers: Arc::clone(&self.layers),
1244            datasets: Arc::clone(&self.datasets),
1245        }
1246    }
1247}
1248
1249#[cfg(test)]
1250mod tests {
1251    use super::*;
1252
1253    #[test]
1254    fn test_registry_creation() {
1255        let registry = DatasetRegistry::new();
1256        assert_eq!(registry.layer_count(), 0);
1257        assert!(!registry.has_layer("test"));
1258    }
1259
1260    #[test]
1261    fn test_layer_not_found() {
1262        let registry = DatasetRegistry::new();
1263        let result = registry.get_layer("nonexistent");
1264        assert!(matches!(result, Err(RegistryError::LayerNotFound(_))));
1265    }
1266
1267    #[test]
1268    fn test_registry_clone() {
1269        let registry1 = DatasetRegistry::new();
1270        let registry2 = registry1.clone();
1271
1272        assert_eq!(registry1.layer_count(), registry2.layer_count());
1273    }
1274
1275    // CRS parsing tests
1276    #[test]
1277    fn test_parse_crs_epsg_uppercase() {
1278        let crs = DatasetRegistry::parse_crs("EPSG:4326");
1279        assert!(crs.is_ok());
1280        let crs = crs.expect("should parse");
1281        assert_eq!(crs.epsg_code(), Some(4326));
1282    }
1283
1284    #[test]
1285    fn test_parse_crs_epsg_lowercase() {
1286        let crs = DatasetRegistry::parse_crs("epsg:3857");
1287        assert!(crs.is_ok());
1288        let crs = crs.expect("should parse");
1289        assert_eq!(crs.epsg_code(), Some(3857));
1290    }
1291
1292    #[test]
1293    fn test_parse_crs_proj_string() {
1294        let crs = DatasetRegistry::parse_crs("+proj=longlat +datum=WGS84 +no_defs");
1295        assert!(crs.is_ok());
1296    }
1297
1298    #[test]
1299    fn test_parse_crs_wkt() {
1300        let wkt = r#"GEOGCS["WGS 84",DATUM["WGS_1984",SPHEROID["WGS 84",6378137,298.257223563]]]"#;
1301        let crs = DatasetRegistry::parse_crs(wkt);
1302        assert!(crs.is_ok());
1303    }
1304
1305    #[test]
1306    fn test_parse_crs_invalid() {
1307        let crs = DatasetRegistry::parse_crs("invalid crs");
1308        assert!(matches!(crs, Err(RegistryError::InvalidCrs(_))));
1309    }
1310
1311    #[test]
1312    fn test_parse_crs_invalid_epsg() {
1313        let crs = DatasetRegistry::parse_crs("EPSG:abc");
1314        assert!(matches!(crs, Err(RegistryError::InvalidCrs(_))));
1315    }
1316
1317    // CRS equivalence tests
1318    #[test]
1319    fn test_crs_strings_equivalent_same() {
1320        assert!(DatasetRegistry::crs_strings_equivalent(
1321            "EPSG:4326",
1322            "EPSG:4326"
1323        ));
1324    }
1325
1326    #[test]
1327    fn test_crs_strings_equivalent_case_insensitive() {
1328        assert!(DatasetRegistry::crs_strings_equivalent(
1329            "EPSG:4326",
1330            "epsg:4326"
1331        ));
1332        assert!(DatasetRegistry::crs_strings_equivalent(
1333            "epsg:3857",
1334            "EPSG:3857"
1335        ));
1336    }
1337
1338    #[test]
1339    fn test_crs_strings_equivalent_with_whitespace() {
1340        assert!(DatasetRegistry::crs_strings_equivalent(
1341            "  EPSG:4326  ",
1342            "EPSG:4326"
1343        ));
1344    }
1345
1346    #[test]
1347    fn test_crs_strings_not_equivalent() {
1348        assert!(!DatasetRegistry::crs_strings_equivalent(
1349            "EPSG:4326",
1350            "EPSG:3857"
1351        ));
1352    }
1353
1354    // Densification tests
1355    #[test]
1356    fn test_densify_bbox_edges_minimum() {
1357        let points = DatasetRegistry::densify_bbox_edges(0.0, 0.0, 10.0, 10.0, 2);
1358        // With 2 points per edge, we get corners only
1359        // 2 (bottom) + 1 (right, skip corner) + 1 (top, skip corner) + 0 (left, skip both corners)
1360        assert_eq!(points.len(), 4);
1361
1362        // Check corners exist
1363        assert!(
1364            points
1365                .iter()
1366                .any(|p| (p.x - 0.0).abs() < 1e-10 && (p.y - 0.0).abs() < 1e-10)
1367        );
1368        assert!(
1369            points
1370                .iter()
1371                .any(|p| (p.x - 10.0).abs() < 1e-10 && (p.y - 0.0).abs() < 1e-10)
1372        );
1373        assert!(
1374            points
1375                .iter()
1376                .any(|p| (p.x - 10.0).abs() < 1e-10 && (p.y - 10.0).abs() < 1e-10)
1377        );
1378        assert!(
1379            points
1380                .iter()
1381                .any(|p| (p.x - 0.0).abs() < 1e-10 && (p.y - 10.0).abs() < 1e-10)
1382        );
1383    }
1384
1385    #[test]
1386    fn test_densify_bbox_edges_5_points() {
1387        let points = DatasetRegistry::densify_bbox_edges(0.0, 0.0, 10.0, 10.0, 5);
1388        // 5 (bottom) + 4 (right) + 4 (top) + 3 (left) = 16
1389        assert_eq!(points.len(), 16);
1390    }
1391
1392    #[test]
1393    fn test_densify_bbox_edges_21_points() {
1394        let points = DatasetRegistry::densify_bbox_edges(-10.0, -10.0, 10.0, 10.0, 21);
1395        // 21 (bottom) + 20 (right) + 20 (top) + 19 (left) = 80
1396        assert_eq!(points.len(), 80);
1397
1398        // Check that corners are included
1399        let has_bottom_left = points
1400            .iter()
1401            .any(|p| (p.x - (-10.0)).abs() < 1e-10 && (p.y - (-10.0)).abs() < 1e-10);
1402        let has_bottom_right = points
1403            .iter()
1404            .any(|p| (p.x - 10.0).abs() < 1e-10 && (p.y - (-10.0)).abs() < 1e-10);
1405        let has_top_right = points
1406            .iter()
1407            .any(|p| (p.x - 10.0).abs() < 1e-10 && (p.y - 10.0).abs() < 1e-10);
1408        let has_top_left = points
1409            .iter()
1410            .any(|p| (p.x - (-10.0)).abs() < 1e-10 && (p.y - 10.0).abs() < 1e-10);
1411
1412        assert!(has_bottom_left, "Should have bottom-left corner");
1413        assert!(has_bottom_right, "Should have bottom-right corner");
1414        assert!(has_top_right, "Should have top-right corner");
1415        assert!(has_top_left, "Should have top-left corner");
1416    }
1417
1418    // Bbox transformation tests
1419    #[test]
1420    fn test_transform_bbox_same_crs() {
1421        let source_crs = Crs::wgs84();
1422        let target_crs = Crs::wgs84();
1423        let bbox = (0.0, 0.0, 10.0, 10.0);
1424
1425        let result =
1426            DatasetRegistry::transform_bbox_with_densification(bbox, &source_crs, &target_crs);
1427        assert!(result.is_ok());
1428
1429        let (min_x, min_y, max_x, max_y) = result.expect("should transform");
1430        assert!((min_x - 0.0).abs() < 1e-6);
1431        assert!((min_y - 0.0).abs() < 1e-6);
1432        assert!((max_x - 10.0).abs() < 1e-6);
1433        assert!((max_y - 10.0).abs() < 1e-6);
1434    }
1435
1436    #[test]
1437    fn test_transform_bbox_wgs84_to_web_mercator() {
1438        let source_crs = Crs::wgs84();
1439        let target_crs = Crs::web_mercator();
1440
1441        // Small bbox around null island
1442        let bbox = (-1.0, -1.0, 1.0, 1.0);
1443
1444        let result =
1445            DatasetRegistry::transform_bbox_with_densification(bbox, &source_crs, &target_crs);
1446        assert!(result.is_ok());
1447
1448        let (min_x, min_y, max_x, max_y) = result.expect("should transform");
1449
1450        // In Web Mercator, 1 degree at equator is approximately 111,320 meters
1451        // So bbox should be roughly centered at 0,0 and extend about 111km in each direction
1452        assert!(min_x < 0.0, "min_x should be negative");
1453        assert!(min_y < 0.0, "min_y should be negative");
1454        assert!(max_x > 0.0, "max_x should be positive");
1455        assert!(max_y > 0.0, "max_y should be positive");
1456
1457        // Rough check for Web Mercator coordinates
1458        assert!(min_x > -200_000.0, "min_x should be > -200000");
1459        assert!(max_x < 200_000.0, "max_x should be < 200000");
1460        assert!(min_y > -200_000.0, "min_y should be > -200000");
1461        assert!(max_y < 200_000.0, "max_y should be < 200000");
1462    }
1463
1464    #[test]
1465    fn test_transform_bbox_web_mercator_to_wgs84() {
1466        let source_crs = Crs::web_mercator();
1467        let target_crs = Crs::wgs84();
1468
1469        // 1 million meters from origin (roughly 9 degrees)
1470        let bbox = (-1_000_000.0, -1_000_000.0, 1_000_000.0, 1_000_000.0);
1471
1472        let result =
1473            DatasetRegistry::transform_bbox_with_densification(bbox, &source_crs, &target_crs);
1474        assert!(result.is_ok());
1475
1476        let (min_x, min_y, max_x, max_y) = result.expect("should transform");
1477
1478        // Should be roughly +-9 degrees
1479        assert!(
1480            min_x > -15.0 && min_x < -5.0,
1481            "min_x should be around -9 degrees"
1482        );
1483        assert!(
1484            max_x > 5.0 && max_x < 15.0,
1485            "max_x should be around 9 degrees"
1486        );
1487        assert!(
1488            min_y > -15.0 && min_y < -5.0,
1489            "min_y should be around -9 degrees"
1490        );
1491        assert!(
1492            max_y > 5.0 && max_y < 15.0,
1493            "max_y should be around 9 degrees"
1494        );
1495    }
1496
1497    #[test]
1498    fn test_transform_bbox_high_latitude() {
1499        let source_crs = Crs::wgs84();
1500        let target_crs = Crs::web_mercator();
1501
1502        // Northern Europe bbox (demonstrates importance of densification)
1503        let bbox = (0.0, 50.0, 10.0, 60.0);
1504
1505        let result =
1506            DatasetRegistry::transform_bbox_with_densification(bbox, &source_crs, &target_crs);
1507        assert!(result.is_ok());
1508
1509        let (_min_x, min_y, _max_x, max_y) = result.expect("should transform");
1510
1511        // Web Mercator Y values should be large positive numbers for 50-60 degrees N
1512        assert!(min_y > 6_000_000.0, "min_y should be > 6M for 50 degrees N");
1513        assert!(max_y > 8_000_000.0, "max_y should be > 8M for 60 degrees N");
1514    }
1515
1516    #[test]
1517    fn test_transform_bbox_simple_vs_densified() {
1518        let source_crs = Crs::wgs84();
1519        let target_crs = Crs::web_mercator();
1520
1521        // Large bbox where densification matters
1522        let bbox = (-20.0, 40.0, 20.0, 70.0);
1523
1524        let simple = DatasetRegistry::transform_bbox_simple(bbox, &source_crs, &target_crs);
1525        let densified =
1526            DatasetRegistry::transform_bbox_with_densification(bbox, &source_crs, &target_crs);
1527
1528        assert!(simple.is_ok());
1529        assert!(densified.is_ok());
1530
1531        // Both should produce valid results
1532        let simple = simple.expect("simple should work");
1533        let densified = densified.expect("densified should work");
1534
1535        // For Mercator projection, the results should be similar
1536        // The densified version may have slightly larger bounds due to curvature
1537        assert!(
1538            (simple.0 - densified.0).abs() < 100.0,
1539            "min_x should be similar"
1540        );
1541        assert!(
1542            (simple.2 - densified.2).abs() < 100.0,
1543            "max_x should be similar"
1544        );
1545    }
1546
1547    // Transform point test
1548    #[test]
1549    fn test_transform_point() {
1550        let source_crs = Crs::wgs84();
1551        let target_crs = Crs::web_mercator();
1552
1553        let result = DatasetRegistry::transform_point(0.0, 0.0, &source_crs, &target_crs);
1554        assert!(result.is_ok());
1555
1556        let (x, y) = result.expect("should transform");
1557        assert!((x - 0.0).abs() < 1.0, "x should be close to 0");
1558        assert!((y - 0.0).abs() < 1.0, "y should be close to 0");
1559    }
1560
1561    #[test]
1562    fn test_transform_point_london() {
1563        let source_crs = Crs::wgs84();
1564        let target_crs = Crs::web_mercator();
1565
1566        // London: -0.1276, 51.5074
1567        let result = DatasetRegistry::transform_point(-0.1276, 51.5074, &source_crs, &target_crs);
1568        assert!(result.is_ok());
1569
1570        let (x, y) = result.expect("should transform");
1571        // London in Web Mercator is approximately (-14200, 6711000)
1572        assert!(x > -20_000.0 && x < 0.0, "x should be slightly negative");
1573        assert!(
1574            y > 6_500_000.0 && y < 7_000_000.0,
1575            "y should be around 6.7M"
1576        );
1577    }
1578
1579    // UTM zone transformation tests
1580    #[test]
1581    fn test_transform_bbox_to_utm() {
1582        let source_crs = Crs::wgs84();
1583        // UTM Zone 32N (Central Europe, 6-12 degrees E)
1584        let target_crs = Crs::from_epsg(32632).expect("UTM 32N should exist");
1585
1586        // Bbox in Germany (within UTM zone 32)
1587        let bbox = (8.0, 48.0, 10.0, 50.0);
1588
1589        let result =
1590            DatasetRegistry::transform_bbox_with_densification(bbox, &source_crs, &target_crs);
1591        assert!(result.is_ok());
1592
1593        let (min_x, min_y, _max_x, _max_y) = result.expect("should transform");
1594
1595        // UTM coordinates should be in typical range
1596        // X (easting): 166,000 to 833,000 meters from false easting of 500,000
1597        // Y (northing): meters from equator
1598        assert!(
1599            min_x > 300_000.0 && min_x < 700_000.0,
1600            "easting should be in valid range"
1601        );
1602        assert!(
1603            min_y > 5_000_000.0 && min_y < 6_000_000.0,
1604            "northing should be in valid range"
1605        );
1606    }
1607
1608    // Edge case tests
1609    #[test]
1610    fn test_transform_bbox_antimeridian() {
1611        let source_crs = Crs::wgs84();
1612        let target_crs = Crs::web_mercator();
1613
1614        // Bbox near antimeridian (but not crossing)
1615        let bbox = (170.0, -10.0, 179.0, 10.0);
1616
1617        let result =
1618            DatasetRegistry::transform_bbox_with_densification(bbox, &source_crs, &target_crs);
1619        assert!(result.is_ok());
1620
1621        let (min_x, _min_y, max_x, _max_y) = result.expect("should transform");
1622        assert!(min_x > 0.0 && max_x > min_x, "should have valid x range");
1623    }
1624
1625    #[test]
1626    fn test_transform_bbox_polar_region() {
1627        let source_crs = Crs::wgs84();
1628        // Polar Stereographic North
1629        let target_crs = Crs::from_epsg(3413).expect("NSIDC Polar Stereographic should exist");
1630
1631        // Arctic region bbox
1632        let bbox = (-10.0, 70.0, 10.0, 80.0);
1633
1634        let result =
1635            DatasetRegistry::transform_bbox_with_densification(bbox, &source_crs, &target_crs);
1636        assert!(result.is_ok());
1637    }
1638
1639    // Error handling tests
1640    #[test]
1641    fn test_transform_bbox_invalid_crs() {
1642        // Try to parse a non-existent EPSG code
1643        let crs = DatasetRegistry::parse_crs("EPSG:99999");
1644        assert!(crs.is_err());
1645    }
1646}