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    /// Read a tile as RasterBuffer
206    pub fn read_tile_buffer(
207        &self,
208        level: usize,
209        tile_x: u32,
210        tile_y: u32,
211    ) -> Result<RasterBuffer, oxigeo_core::OxiGeoError> {
212        let guard = self
213            .reader
214            .read()
215            .map_err(|_| oxigeo_core::OxiGeoError::Internal {
216                message: "Failed to acquire read lock".to_string(),
217            })?;
218
219        if let Some(ref reader) = *guard {
220            reader.read_tile_buffer(level, tile_x, tile_y)
221        } else {
222            Err(oxigeo_core::OxiGeoError::Internal {
223                message: "Reader not initialized".to_string(),
224            })
225        }
226    }
227
228    /// Read full band data
229    pub fn read_band(
230        &self,
231        level: usize,
232        band: usize,
233    ) -> Result<Vec<u8>, oxigeo_core::OxiGeoError> {
234        let guard = self
235            .reader
236            .read()
237            .map_err(|_| oxigeo_core::OxiGeoError::Internal {
238                message: "Failed to acquire read lock".to_string(),
239            })?;
240
241        if let Some(ref reader) = *guard {
242            reader.read_band(level, band)
243        } else {
244            Err(oxigeo_core::OxiGeoError::Internal {
245                message: "Reader not initialized".to_string(),
246            })
247        }
248    }
249
250    /// Read a window of data from the dataset as RasterBuffer
251    ///
252    /// # Arguments
253    /// * `x_offset` - X offset in pixels
254    /// * `y_offset` - Y offset in pixels
255    /// * `x_size` - Width to read
256    /// * `y_size` - Height to read
257    pub fn read_window(
258        &self,
259        x_offset: u64,
260        y_offset: u64,
261        x_size: u64,
262        y_size: u64,
263    ) -> Result<RasterBuffer, oxigeo_core::OxiGeoError> {
264        // Validate window bounds
265        if x_offset >= self.width || y_offset >= self.height {
266            return Err(oxigeo_core::OxiGeoError::OutOfBounds {
267                message: format!(
268                    "Window offset ({}, {}) out of bounds ({}x{})",
269                    x_offset, y_offset, self.width, self.height
270                ),
271            });
272        }
273
274        // Clamp window size to dataset bounds
275        let actual_x_size = x_size.min(self.width - x_offset);
276        let actual_y_size = y_size.min(self.height - y_offset);
277
278        // Create output buffer with requested size
279        let mut window_buffer = RasterBuffer::zeros(x_size, y_size, self.data_type);
280
281        // Get tile dimensions (default to 256x256 if not tiled)
282        let (tile_w, tile_h) = self.tile_size.unwrap_or((256, 256));
283        let tile_w = tile_w as u64;
284        let tile_h = tile_h as u64;
285
286        // Calculate tile range that intersects with the requested window
287        let start_tile_x = x_offset / tile_w;
288        let start_tile_y = y_offset / tile_h;
289        let end_tile_x = (x_offset + actual_x_size).div_ceil(tile_w);
290        let end_tile_y = (y_offset + actual_y_size).div_ceil(tile_h);
291
292        // Read only the tiles that intersect with the window
293        for tile_y in start_tile_y..end_tile_y {
294            for tile_x in start_tile_x..end_tile_x {
295                // Calculate tile boundaries in dataset coordinates
296                let tile_pixel_x = tile_x * tile_w;
297                let tile_pixel_y = tile_y * tile_h;
298
299                // Read the tile
300                let tile_buffer = match self.read_tile_buffer(0, tile_x as u32, tile_y as u32) {
301                    Ok(buf) => buf,
302                    Err(e) => {
303                        // If tile read fails, skip it (may be outside bounds or missing)
304                        debug!("Failed to read tile ({}, {}): {}", tile_x, tile_y, e);
305                        continue;
306                    }
307                };
308
309                let tile_width = tile_buffer.width();
310                let tile_height = tile_buffer.height();
311
312                // Calculate the intersection between tile and requested window
313                let win_min_x = x_offset;
314                let win_min_y = y_offset;
315                let win_max_x = x_offset + actual_x_size;
316                let win_max_y = y_offset + actual_y_size;
317
318                let tile_max_x = (tile_pixel_x + tile_width).min(self.width);
319                let tile_max_y = (tile_pixel_y + tile_height).min(self.height);
320
321                let intersect_min_x = win_min_x.max(tile_pixel_x);
322                let intersect_min_y = win_min_y.max(tile_pixel_y);
323                let intersect_max_x = win_max_x.min(tile_max_x);
324                let intersect_max_y = win_max_y.min(tile_max_y);
325
326                // Copy pixels from tile to window buffer
327                for src_y in intersect_min_y..intersect_max_y {
328                    for src_x in intersect_min_x..intersect_max_x {
329                        // Calculate position in tile coordinates
330                        let tile_local_x = src_x - tile_pixel_x;
331                        let tile_local_y = src_y - tile_pixel_y;
332
333                        // Calculate position in window coordinates
334                        let win_local_x = src_x - x_offset;
335                        let win_local_y = src_y - y_offset;
336
337                        // Read pixel from tile and write to window buffer
338                        if let Ok(value) = tile_buffer.get_pixel(tile_local_x, tile_local_y) {
339                            let _ = window_buffer.set_pixel(win_local_x, win_local_y, value);
340                        }
341                    }
342                }
343            }
344        }
345
346        Ok(window_buffer)
347    }
348
349    /// Get pixel value at coordinates
350    pub fn get_pixel(&self, x: u64, y: u64) -> Result<f64, oxigeo_core::OxiGeoError> {
351        if x >= self.width || y >= self.height {
352            return Err(oxigeo_core::OxiGeoError::OutOfBounds {
353                message: format!(
354                    "Pixel ({}, {}) out of bounds ({}x{})",
355                    x, y, self.width, self.height
356                ),
357            });
358        }
359
360        // Determine which tile contains this pixel
361        let (tile_w, tile_h) = self.tile_size.unwrap_or((256, 256));
362        let tile_x = x / tile_w as u64;
363        let tile_y = y / tile_h as u64;
364        let local_x = x % tile_w as u64;
365        let local_y = y % tile_h as u64;
366
367        // Read the tile
368        let tile_buffer = self.read_tile_buffer(0, tile_x as u32, tile_y as u32)?;
369
370        // Get the pixel value
371        tile_buffer.get_pixel(local_x, local_y)
372    }
373
374    /// Get raster band info (for compatibility with old code)
375    pub fn rasterband(&self, _band: usize) -> Result<RasterBandInfo, oxigeo_core::OxiGeoError> {
376        Ok(RasterBandInfo {
377            nodata: self.nodata,
378            data_type: self.data_type,
379        })
380    }
381}
382
383/// Raster band information (compatible with old interface)
384pub struct RasterBandInfo {
385    nodata: NoDataValue,
386    data_type: RasterDataType,
387}
388
389impl RasterBandInfo {
390    /// Get nodata value
391    pub fn nodata(&self) -> Option<f64> {
392        self.nodata.as_f64()
393    }
394
395    /// Get datatype string
396    pub fn datatype(&self) -> &str {
397        match self.data_type {
398            RasterDataType::UInt8 => "UInt8",
399            RasterDataType::Int8 => "Int8",
400            RasterDataType::UInt16 => "UInt16",
401            RasterDataType::Int16 => "Int16",
402            RasterDataType::UInt32 => "UInt32",
403            RasterDataType::Int32 => "Int32",
404            RasterDataType::Float32 => "Float32",
405            RasterDataType::Float64 => "Float64",
406            RasterDataType::UInt64 => "UInt64",
407            RasterDataType::Int64 => "Int64",
408            RasterDataType::CFloat32 => "CFloat32",
409            RasterDataType::CFloat64 => "CFloat64",
410        }
411    }
412}
413
414/// Registry errors
415#[derive(Debug, Error)]
416pub enum RegistryError {
417    /// Layer not found
418    #[error("Layer not found: {0}")]
419    LayerNotFound(String),
420
421    /// Dataset open error
422    #[error("Failed to open dataset: {0}")]
423    DatasetOpen(#[from] oxigeo_core::OxiGeoError),
424
425    /// Configuration error
426    #[error("Configuration error: {0}")]
427    Config(#[from] ConfigError),
428
429    /// CRS transformation error
430    #[error("CRS transformation failed: {0}")]
431    CrsTransformation(String),
432
433    /// Invalid CRS specification
434    #[error("Invalid CRS: {0}")]
435    InvalidCrs(String),
436
437    /// Lock poisoned
438    #[error("Lock poisoned")]
439    LockPoisoned,
440}
441
442impl From<oxigeo_proj::Error> for RegistryError {
443    fn from(err: oxigeo_proj::Error) -> Self {
444        RegistryError::CrsTransformation(err.to_string())
445    }
446}
447
448/// Result type for registry operations
449pub type RegistryResult<T> = Result<T, RegistryError>;
450
451/// Information about a registered layer
452#[derive(Debug, Clone)]
453pub struct LayerInfo {
454    /// Layer name
455    pub name: String,
456
457    /// Display title
458    pub title: String,
459
460    /// Layer description
461    pub abstract_: String,
462
463    /// Configuration
464    pub config: LayerConfig,
465
466    /// Dataset metadata
467    pub metadata: DatasetMetadata,
468}
469
470/// Dataset metadata extracted from GDAL
471#[derive(Debug, Clone)]
472pub struct DatasetMetadata {
473    /// Dataset width in pixels
474    pub width: usize,
475
476    /// Dataset height in pixels
477    pub height: usize,
478
479    /// Number of bands
480    pub band_count: usize,
481
482    /// Data type name
483    pub data_type: String,
484
485    /// Spatial reference system (WKT)
486    pub srs: Option<String>,
487
488    /// Bounding box (min_x, min_y, max_x, max_y)
489    pub bbox: Option<(f64, f64, f64, f64)>,
490
491    /// Geotransform coefficients
492    pub geotransform: Option<[f64; 6]>,
493
494    /// NoData value
495    pub nodata: Option<f64>,
496}
497
498/// Thread-safe dataset registry
499pub struct DatasetRegistry {
500    /// Registered layers
501    layers: Arc<RwLock<HashMap<String, LayerInfo>>>,
502
503    /// Dataset cache (opened datasets)
504    datasets: Arc<RwLock<HashMap<String, Arc<Dataset>>>>,
505}
506
507impl DatasetRegistry {
508    /// Create a new empty registry
509    pub fn new() -> Self {
510        Self {
511            layers: Arc::new(RwLock::new(HashMap::new())),
512            datasets: Arc::new(RwLock::new(HashMap::new())),
513        }
514    }
515
516    /// Register a layer from configuration
517    pub fn register_layer(&self, config: LayerConfig) -> RegistryResult<()> {
518        info!("Registering layer: {}", config.name);
519
520        // Save the name before moving config
521        let layer_name = config.name.clone();
522
523        // Open dataset to extract metadata
524        let dataset = Self::open_dataset(&config.path)?;
525        let metadata = Self::extract_metadata(&dataset)?;
526
527        debug!(
528            "Layer {} metadata: {}x{}, {} bands",
529            layer_name, metadata.width, metadata.height, metadata.band_count
530        );
531
532        let layer_info = LayerInfo {
533            name: config.name.clone(),
534            title: config.title.clone().unwrap_or_else(|| config.name.clone()),
535            abstract_: config
536                .abstract_
537                .clone()
538                .unwrap_or_else(|| format!("Layer {}", config.name)),
539            config,
540            metadata,
541        };
542
543        // Store layer info
544        let mut layers = self
545            .layers
546            .write()
547            .map_err(|_| RegistryError::LockPoisoned)?;
548        layers.insert(layer_info.name.clone(), layer_info);
549
550        // Cache the dataset
551        let mut datasets = self
552            .datasets
553            .write()
554            .map_err(|_| RegistryError::LockPoisoned)?;
555        datasets.insert(layer_name, Arc::new(dataset));
556
557        Ok(())
558    }
559
560    /// Register multiple layers from configurations
561    pub fn register_layers(&self, configs: Vec<LayerConfig>) -> RegistryResult<()> {
562        for config in configs {
563            if !config.enabled {
564                debug!("Skipping disabled layer: {}", config.name);
565                continue;
566            }
567
568            if let Err(e) = self.register_layer(config) {
569                warn!("Failed to register layer: {}", e);
570                // Continue with other layers
571            }
572        }
573        Ok(())
574    }
575
576    /// Get layer information
577    pub fn get_layer(&self, name: &str) -> RegistryResult<LayerInfo> {
578        let layers = self
579            .layers
580            .read()
581            .map_err(|_| RegistryError::LockPoisoned)?;
582
583        layers
584            .get(name)
585            .cloned()
586            .ok_or_else(|| RegistryError::LayerNotFound(name.to_string()))
587    }
588
589    /// Get a dataset for a layer
590    pub fn get_dataset(&self, name: &str) -> RegistryResult<Arc<Dataset>> {
591        let datasets = self
592            .datasets
593            .read()
594            .map_err(|_| RegistryError::LockPoisoned)?;
595
596        datasets
597            .get(name)
598            .cloned()
599            .ok_or_else(|| RegistryError::LayerNotFound(name.to_string()))
600    }
601
602    /// List all registered layers
603    pub fn list_layers(&self) -> RegistryResult<Vec<LayerInfo>> {
604        let layers = self
605            .layers
606            .read()
607            .map_err(|_| RegistryError::LockPoisoned)?;
608
609        Ok(layers.values().cloned().collect())
610    }
611
612    /// Check if a layer exists
613    pub fn has_layer(&self, name: &str) -> bool {
614        self.layers
615            .read()
616            .map(|layers| layers.contains_key(name))
617            .unwrap_or(false)
618    }
619
620    /// Remove a layer from the registry
621    pub fn unregister_layer(&self, name: &str) -> RegistryResult<()> {
622        let mut layers = self
623            .layers
624            .write()
625            .map_err(|_| RegistryError::LockPoisoned)?;
626
627        let mut datasets = self
628            .datasets
629            .write()
630            .map_err(|_| RegistryError::LockPoisoned)?;
631
632        layers.remove(name);
633        datasets.remove(name);
634
635        info!("Unregistered layer: {}", name);
636        Ok(())
637    }
638
639    /// Get the number of registered layers
640    pub fn layer_count(&self) -> usize {
641        self.layers.read().map(|l| l.len()).unwrap_or(0)
642    }
643
644    /// Open a dataset from a path
645    fn open_dataset<P: AsRef<Path>>(path: P) -> RegistryResult<Dataset> {
646        let dataset = Dataset::open(path.as_ref())?;
647        Ok(dataset)
648    }
649
650    /// Extract metadata from a dataset
651    fn extract_metadata(dataset: &Dataset) -> RegistryResult<DatasetMetadata> {
652        let raster_size = dataset.raster_size();
653        let band_count = dataset.raster_count();
654
655        // Get spatial reference
656        let srs = dataset.projection().ok();
657
658        // Get bounding box from geotransform
659        let geotransform = dataset.geotransform().ok();
660        let bbox = geotransform.map(|gt| {
661            let width = raster_size.0 as f64;
662            let height = raster_size.1 as f64;
663
664            let min_x = gt[0];
665            let max_x = gt[0] + gt[1] * width + gt[2] * height;
666            let max_y = gt[3];
667            let min_y = gt[3] + gt[4] * width + gt[5] * height;
668
669            // Ensure proper order
670            let (min_x, max_x) = if min_x < max_x {
671                (min_x, max_x)
672            } else {
673                (max_x, min_x)
674            };
675            let (min_y, max_y) = if min_y < max_y {
676                (min_y, max_y)
677            } else {
678                (max_y, min_y)
679            };
680
681            (min_x, min_y, max_x, max_y)
682        });
683
684        // Get NoData value from first band
685        let nodata = if band_count > 0 {
686            dataset.rasterband(1).ok().and_then(|band| band.nodata())
687        } else {
688            None
689        };
690
691        // Get data type from first band
692        let data_type = if band_count > 0 {
693            dataset
694                .rasterband(1)
695                .ok()
696                .map(|band| format!("{:?}", band.datatype()))
697                .unwrap_or_else(|| "Unknown".to_string())
698        } else {
699            "Unknown".to_string()
700        };
701
702        Ok(DatasetMetadata {
703            width: raster_size.0,
704            height: raster_size.1,
705            band_count,
706            data_type,
707            srs,
708            bbox,
709            geotransform,
710            nodata,
711        })
712    }
713
714    /// Reload a layer (useful after dataset updates)
715    pub fn reload_layer(&self, name: &str) -> RegistryResult<()> {
716        let config = {
717            let layers = self
718                .layers
719                .read()
720                .map_err(|_| RegistryError::LockPoisoned)?;
721
722            layers
723                .get(name)
724                .ok_or_else(|| RegistryError::LayerNotFound(name.to_string()))?
725                .config
726                .clone()
727        };
728
729        self.unregister_layer(name)?;
730        self.register_layer(config)?;
731
732        info!("Reloaded layer: {}", name);
733        Ok(())
734    }
735
736    /// Get layer bounding box in a specific CRS
737    ///
738    /// This method transforms the native bounding box of a layer to a target CRS.
739    /// The transformation uses edge densification for accurate results, especially
740    /// when transforming between geographic and projected coordinate systems.
741    ///
742    /// # Arguments
743    ///
744    /// * `name` - The layer name
745    /// * `target_crs` - Optional target CRS specification (e.g., "EPSG:4326", "EPSG:3857")
746    ///
747    /// # Returns
748    ///
749    /// The bounding box in the target CRS as (min_x, min_y, max_x, max_y), or None
750    /// if the layer has no bounding box defined.
751    ///
752    /// # Errors
753    ///
754    /// Returns an error if:
755    /// - The layer is not found
756    /// - The source or target CRS is invalid
757    /// - The transformation fails
758    ///
759    /// # Example
760    ///
761    /// ```ignore
762    /// let registry = DatasetRegistry::new();
763    /// // Get bounding box in Web Mercator
764    /// let bbox = registry.get_layer_bbox("my_layer", Some("EPSG:3857"))?;
765    /// ```
766    pub fn get_layer_bbox(
767        &self,
768        name: &str,
769        target_crs: Option<&str>,
770    ) -> RegistryResult<Option<(f64, f64, f64, f64)>> {
771        let layer = self.get_layer(name)?;
772
773        // Return native bbox if no target CRS specified
774        let target_crs_str = match target_crs {
775            Some(crs) => crs,
776            None => return Ok(layer.metadata.bbox),
777        };
778
779        // Get the native bbox
780        let native_bbox = match layer.metadata.bbox {
781            Some(bbox) => bbox,
782            None => return Ok(None),
783        };
784
785        // Get the source CRS from layer metadata
786        let source_crs_str = layer.metadata.srs.as_deref().unwrap_or("EPSG:4326");
787
788        // Check if source and target CRS are the same
789        if Self::crs_strings_equivalent(source_crs_str, target_crs_str) {
790            return Ok(Some(native_bbox));
791        }
792
793        // Parse source and target CRS
794        let source_crs = Self::parse_crs(source_crs_str)?;
795        let target_crs = Self::parse_crs(target_crs_str)?;
796
797        // Transform the bounding box
798        let transformed =
799            Self::transform_bbox_with_densification(native_bbox, &source_crs, &target_crs)?;
800
801        Ok(Some(transformed))
802    }
803
804    /// Parse a CRS string into a Crs object
805    ///
806    /// Supports formats:
807    /// - "EPSG:4326" or "epsg:4326"
808    /// - PROJ strings ("+proj=longlat +datum=WGS84 +no_defs")
809    /// - WKT strings
810    fn parse_crs(crs_str: &str) -> RegistryResult<Crs> {
811        let trimmed = crs_str.trim();
812
813        // Try EPSG code format
814        if let Some(code_str) = trimmed.to_uppercase().strip_prefix("EPSG:") {
815            let code: u32 = code_str.parse().map_err(|_| {
816                RegistryError::InvalidCrs(format!("Invalid EPSG code: {}", code_str))
817            })?;
818            return Ok(Crs::from_epsg(code)?);
819        }
820
821        // Try PROJ string
822        if trimmed.starts_with("+proj=") || trimmed.contains("+proj=") {
823            return Ok(Crs::from_proj(trimmed)?);
824        }
825
826        // Try WKT
827        if trimmed.starts_with("GEOGCS[")
828            || trimmed.starts_with("PROJCS[")
829            || trimmed.starts_with("GEOCCS[")
830        {
831            return Ok(Crs::from_wkt(trimmed)?);
832        }
833
834        Err(RegistryError::InvalidCrs(format!(
835            "Unrecognized CRS format: {}",
836            crs_str
837        )))
838    }
839
840    /// Check if two CRS strings refer to the same CRS
841    fn crs_strings_equivalent(crs1: &str, crs2: &str) -> bool {
842        let norm1 = crs1.trim().to_uppercase();
843        let norm2 = crs2.trim().to_uppercase();
844        norm1 == norm2
845    }
846
847    /// Transform a bounding box with edge densification for accurate results
848    ///
849    /// For accurate transformation between coordinate systems (especially between
850    /// geographic and projected CRS), we densify the edges of the bounding box
851    /// by adding intermediate points. This accounts for the curvature introduced
852    /// by the projection.
853    ///
854    /// # Arguments
855    ///
856    /// * `bbox` - The bounding box as (min_x, min_y, max_x, max_y)
857    /// * `source_crs` - Source coordinate reference system
858    /// * `target_crs` - Target coordinate reference system
859    ///
860    /// # Returns
861    ///
862    /// The transformed bounding box as (min_x, min_y, max_x, max_y)
863    fn transform_bbox_with_densification(
864        bbox: (f64, f64, f64, f64),
865        source_crs: &Crs,
866        target_crs: &Crs,
867    ) -> RegistryResult<(f64, f64, f64, f64)> {
868        let (min_x, min_y, max_x, max_y) = bbox;
869
870        // Create transformer
871        let transformer = Transformer::new(source_crs.clone(), target_crs.clone())?;
872
873        // Number of points to sample along each edge for accurate transformation
874        // More points = more accurate but slower
875        // 21 points per edge is a good balance (20 segments)
876        const DENSIFY_POINTS: usize = 21;
877
878        // Generate densified edge points
879        let edge_points = Self::densify_bbox_edges(min_x, min_y, max_x, max_y, DENSIFY_POINTS);
880
881        // Transform all edge points
882        let transformed_points: Vec<Coordinate> = edge_points
883            .iter()
884            .filter_map(|coord| transformer.transform(coord).ok())
885            .collect();
886
887        // Check if we got valid transformed points
888        if transformed_points.is_empty() {
889            return Err(RegistryError::CrsTransformation(
890                "All points failed to transform".to_string(),
891            ));
892        }
893
894        // Find the bounding box of transformed points
895        let mut result_min_x = f64::INFINITY;
896        let mut result_min_y = f64::INFINITY;
897        let mut result_max_x = f64::NEG_INFINITY;
898        let mut result_max_y = f64::NEG_INFINITY;
899
900        for point in &transformed_points {
901            if point.x.is_finite() && point.y.is_finite() {
902                result_min_x = result_min_x.min(point.x);
903                result_min_y = result_min_y.min(point.y);
904                result_max_x = result_max_x.max(point.x);
905                result_max_y = result_max_y.max(point.y);
906            }
907        }
908
909        // Verify we have valid results
910        if !result_min_x.is_finite()
911            || !result_min_y.is_finite()
912            || !result_max_x.is_finite()
913            || !result_max_y.is_finite()
914        {
915            return Err(RegistryError::CrsTransformation(
916                "Transformation resulted in non-finite values".to_string(),
917            ));
918        }
919
920        Ok((result_min_x, result_min_y, result_max_x, result_max_y))
921    }
922
923    /// Generate densified points along the edges of a bounding box
924    ///
925    /// Creates evenly spaced points along all four edges of the bbox.
926    /// The points are ordered: bottom edge, right edge, top edge, left edge.
927    fn densify_bbox_edges(
928        min_x: f64,
929        min_y: f64,
930        max_x: f64,
931        max_y: f64,
932        points_per_edge: usize,
933    ) -> Vec<Coordinate> {
934        let mut points = Vec::with_capacity(points_per_edge * 4);
935
936        // Ensure at least 2 points per edge (corners)
937        let n = points_per_edge.max(2);
938
939        // Bottom edge (min_y, from min_x to max_x)
940        for i in 0..n {
941            let t = i as f64 / (n - 1) as f64;
942            let x = min_x + t * (max_x - min_x);
943            points.push(Coordinate::new(x, min_y));
944        }
945
946        // Right edge (max_x, from min_y to max_y)
947        // Skip first point to avoid duplicate corner
948        for i in 1..n {
949            let t = i as f64 / (n - 1) as f64;
950            let y = min_y + t * (max_y - min_y);
951            points.push(Coordinate::new(max_x, y));
952        }
953
954        // Top edge (max_y, from max_x to min_x)
955        // Skip first point to avoid duplicate corner
956        for i in 1..n {
957            let t = i as f64 / (n - 1) as f64;
958            let x = max_x - t * (max_x - min_x);
959            points.push(Coordinate::new(x, max_y));
960        }
961
962        // Left edge (min_x, from max_y to min_y)
963        // Skip first and last points to avoid duplicate corners
964        for i in 1..(n - 1) {
965            let t = i as f64 / (n - 1) as f64;
966            let y = max_y - t * (max_y - min_y);
967            points.push(Coordinate::new(min_x, y));
968        }
969
970        points
971    }
972
973    /// Transform a single point from source to target CRS
974    ///
975    /// Convenience method for transforming individual coordinates.
976    #[allow(dead_code)]
977    fn transform_point(
978        x: f64,
979        y: f64,
980        source_crs: &Crs,
981        target_crs: &Crs,
982    ) -> RegistryResult<(f64, f64)> {
983        let transformer = Transformer::new(source_crs.clone(), target_crs.clone())?;
984        let coord = Coordinate::new(x, y);
985        let transformed = transformer.transform(&coord)?;
986        Ok((transformed.x, transformed.y))
987    }
988
989    /// Get layer bounding box in Web Mercator (EPSG:3857)
990    ///
991    /// Convenience method for getting bbox in the most common web mapping CRS.
992    pub fn get_layer_bbox_web_mercator(
993        &self,
994        name: &str,
995    ) -> RegistryResult<Option<(f64, f64, f64, f64)>> {
996        self.get_layer_bbox(name, Some("EPSG:3857"))
997    }
998
999    /// Get layer bounding box in WGS84 (EPSG:4326)
1000    ///
1001    /// Convenience method for getting bbox in geographic coordinates.
1002    pub fn get_layer_bbox_wgs84(&self, name: &str) -> RegistryResult<Option<(f64, f64, f64, f64)>> {
1003        self.get_layer_bbox(name, Some("EPSG:4326"))
1004    }
1005
1006    /// Transform bounding box using the simple 4-corner method
1007    ///
1008    /// This is faster but less accurate than densification for projections
1009    /// with significant curvature. Use for quick approximations.
1010    #[allow(dead_code)]
1011    fn transform_bbox_simple(
1012        bbox: (f64, f64, f64, f64),
1013        source_crs: &Crs,
1014        target_crs: &Crs,
1015    ) -> RegistryResult<(f64, f64, f64, f64)> {
1016        let (min_x, min_y, max_x, max_y) = bbox;
1017
1018        // Create bounding box using oxigeo_proj
1019        let source_bbox = BoundingBox::new(min_x, min_y, max_x, max_y)
1020            .map_err(|e| RegistryError::CrsTransformation(e.to_string()))?;
1021
1022        // Create transformer and transform bbox
1023        let transformer = Transformer::new(source_crs.clone(), target_crs.clone())?;
1024        let transformed = transformer.transform_bbox(&source_bbox)?;
1025
1026        Ok((
1027            transformed.min_x,
1028            transformed.min_y,
1029            transformed.max_x,
1030            transformed.max_y,
1031        ))
1032    }
1033}
1034
1035impl Default for DatasetRegistry {
1036    fn default() -> Self {
1037        Self::new()
1038    }
1039}
1040
1041impl Clone for DatasetRegistry {
1042    fn clone(&self) -> Self {
1043        Self {
1044            layers: Arc::clone(&self.layers),
1045            datasets: Arc::clone(&self.datasets),
1046        }
1047    }
1048}
1049
1050#[cfg(test)]
1051mod tests {
1052    use super::*;
1053
1054    #[test]
1055    fn test_registry_creation() {
1056        let registry = DatasetRegistry::new();
1057        assert_eq!(registry.layer_count(), 0);
1058        assert!(!registry.has_layer("test"));
1059    }
1060
1061    #[test]
1062    fn test_layer_not_found() {
1063        let registry = DatasetRegistry::new();
1064        let result = registry.get_layer("nonexistent");
1065        assert!(matches!(result, Err(RegistryError::LayerNotFound(_))));
1066    }
1067
1068    #[test]
1069    fn test_registry_clone() {
1070        let registry1 = DatasetRegistry::new();
1071        let registry2 = registry1.clone();
1072
1073        assert_eq!(registry1.layer_count(), registry2.layer_count());
1074    }
1075
1076    // CRS parsing tests
1077    #[test]
1078    fn test_parse_crs_epsg_uppercase() {
1079        let crs = DatasetRegistry::parse_crs("EPSG:4326");
1080        assert!(crs.is_ok());
1081        let crs = crs.expect("should parse");
1082        assert_eq!(crs.epsg_code(), Some(4326));
1083    }
1084
1085    #[test]
1086    fn test_parse_crs_epsg_lowercase() {
1087        let crs = DatasetRegistry::parse_crs("epsg:3857");
1088        assert!(crs.is_ok());
1089        let crs = crs.expect("should parse");
1090        assert_eq!(crs.epsg_code(), Some(3857));
1091    }
1092
1093    #[test]
1094    fn test_parse_crs_proj_string() {
1095        let crs = DatasetRegistry::parse_crs("+proj=longlat +datum=WGS84 +no_defs");
1096        assert!(crs.is_ok());
1097    }
1098
1099    #[test]
1100    fn test_parse_crs_wkt() {
1101        let wkt = r#"GEOGCS["WGS 84",DATUM["WGS_1984",SPHEROID["WGS 84",6378137,298.257223563]]]"#;
1102        let crs = DatasetRegistry::parse_crs(wkt);
1103        assert!(crs.is_ok());
1104    }
1105
1106    #[test]
1107    fn test_parse_crs_invalid() {
1108        let crs = DatasetRegistry::parse_crs("invalid crs");
1109        assert!(matches!(crs, Err(RegistryError::InvalidCrs(_))));
1110    }
1111
1112    #[test]
1113    fn test_parse_crs_invalid_epsg() {
1114        let crs = DatasetRegistry::parse_crs("EPSG:abc");
1115        assert!(matches!(crs, Err(RegistryError::InvalidCrs(_))));
1116    }
1117
1118    // CRS equivalence tests
1119    #[test]
1120    fn test_crs_strings_equivalent_same() {
1121        assert!(DatasetRegistry::crs_strings_equivalent(
1122            "EPSG:4326",
1123            "EPSG:4326"
1124        ));
1125    }
1126
1127    #[test]
1128    fn test_crs_strings_equivalent_case_insensitive() {
1129        assert!(DatasetRegistry::crs_strings_equivalent(
1130            "EPSG:4326",
1131            "epsg:4326"
1132        ));
1133        assert!(DatasetRegistry::crs_strings_equivalent(
1134            "epsg:3857",
1135            "EPSG:3857"
1136        ));
1137    }
1138
1139    #[test]
1140    fn test_crs_strings_equivalent_with_whitespace() {
1141        assert!(DatasetRegistry::crs_strings_equivalent(
1142            "  EPSG:4326  ",
1143            "EPSG:4326"
1144        ));
1145    }
1146
1147    #[test]
1148    fn test_crs_strings_not_equivalent() {
1149        assert!(!DatasetRegistry::crs_strings_equivalent(
1150            "EPSG:4326",
1151            "EPSG:3857"
1152        ));
1153    }
1154
1155    // Densification tests
1156    #[test]
1157    fn test_densify_bbox_edges_minimum() {
1158        let points = DatasetRegistry::densify_bbox_edges(0.0, 0.0, 10.0, 10.0, 2);
1159        // With 2 points per edge, we get corners only
1160        // 2 (bottom) + 1 (right, skip corner) + 1 (top, skip corner) + 0 (left, skip both corners)
1161        assert_eq!(points.len(), 4);
1162
1163        // Check corners exist
1164        assert!(
1165            points
1166                .iter()
1167                .any(|p| (p.x - 0.0).abs() < 1e-10 && (p.y - 0.0).abs() < 1e-10)
1168        );
1169        assert!(
1170            points
1171                .iter()
1172                .any(|p| (p.x - 10.0).abs() < 1e-10 && (p.y - 0.0).abs() < 1e-10)
1173        );
1174        assert!(
1175            points
1176                .iter()
1177                .any(|p| (p.x - 10.0).abs() < 1e-10 && (p.y - 10.0).abs() < 1e-10)
1178        );
1179        assert!(
1180            points
1181                .iter()
1182                .any(|p| (p.x - 0.0).abs() < 1e-10 && (p.y - 10.0).abs() < 1e-10)
1183        );
1184    }
1185
1186    #[test]
1187    fn test_densify_bbox_edges_5_points() {
1188        let points = DatasetRegistry::densify_bbox_edges(0.0, 0.0, 10.0, 10.0, 5);
1189        // 5 (bottom) + 4 (right) + 4 (top) + 3 (left) = 16
1190        assert_eq!(points.len(), 16);
1191    }
1192
1193    #[test]
1194    fn test_densify_bbox_edges_21_points() {
1195        let points = DatasetRegistry::densify_bbox_edges(-10.0, -10.0, 10.0, 10.0, 21);
1196        // 21 (bottom) + 20 (right) + 20 (top) + 19 (left) = 80
1197        assert_eq!(points.len(), 80);
1198
1199        // Check that corners are included
1200        let has_bottom_left = points
1201            .iter()
1202            .any(|p| (p.x - (-10.0)).abs() < 1e-10 && (p.y - (-10.0)).abs() < 1e-10);
1203        let has_bottom_right = points
1204            .iter()
1205            .any(|p| (p.x - 10.0).abs() < 1e-10 && (p.y - (-10.0)).abs() < 1e-10);
1206        let has_top_right = points
1207            .iter()
1208            .any(|p| (p.x - 10.0).abs() < 1e-10 && (p.y - 10.0).abs() < 1e-10);
1209        let has_top_left = points
1210            .iter()
1211            .any(|p| (p.x - (-10.0)).abs() < 1e-10 && (p.y - 10.0).abs() < 1e-10);
1212
1213        assert!(has_bottom_left, "Should have bottom-left corner");
1214        assert!(has_bottom_right, "Should have bottom-right corner");
1215        assert!(has_top_right, "Should have top-right corner");
1216        assert!(has_top_left, "Should have top-left corner");
1217    }
1218
1219    // Bbox transformation tests
1220    #[test]
1221    fn test_transform_bbox_same_crs() {
1222        let source_crs = Crs::wgs84();
1223        let target_crs = Crs::wgs84();
1224        let bbox = (0.0, 0.0, 10.0, 10.0);
1225
1226        let result =
1227            DatasetRegistry::transform_bbox_with_densification(bbox, &source_crs, &target_crs);
1228        assert!(result.is_ok());
1229
1230        let (min_x, min_y, max_x, max_y) = result.expect("should transform");
1231        assert!((min_x - 0.0).abs() < 1e-6);
1232        assert!((min_y - 0.0).abs() < 1e-6);
1233        assert!((max_x - 10.0).abs() < 1e-6);
1234        assert!((max_y - 10.0).abs() < 1e-6);
1235    }
1236
1237    #[test]
1238    fn test_transform_bbox_wgs84_to_web_mercator() {
1239        let source_crs = Crs::wgs84();
1240        let target_crs = Crs::web_mercator();
1241
1242        // Small bbox around null island
1243        let bbox = (-1.0, -1.0, 1.0, 1.0);
1244
1245        let result =
1246            DatasetRegistry::transform_bbox_with_densification(bbox, &source_crs, &target_crs);
1247        assert!(result.is_ok());
1248
1249        let (min_x, min_y, max_x, max_y) = result.expect("should transform");
1250
1251        // In Web Mercator, 1 degree at equator is approximately 111,320 meters
1252        // So bbox should be roughly centered at 0,0 and extend about 111km in each direction
1253        assert!(min_x < 0.0, "min_x should be negative");
1254        assert!(min_y < 0.0, "min_y should be negative");
1255        assert!(max_x > 0.0, "max_x should be positive");
1256        assert!(max_y > 0.0, "max_y should be positive");
1257
1258        // Rough check for Web Mercator coordinates
1259        assert!(min_x > -200_000.0, "min_x should be > -200000");
1260        assert!(max_x < 200_000.0, "max_x should be < 200000");
1261        assert!(min_y > -200_000.0, "min_y should be > -200000");
1262        assert!(max_y < 200_000.0, "max_y should be < 200000");
1263    }
1264
1265    #[test]
1266    fn test_transform_bbox_web_mercator_to_wgs84() {
1267        let source_crs = Crs::web_mercator();
1268        let target_crs = Crs::wgs84();
1269
1270        // 1 million meters from origin (roughly 9 degrees)
1271        let bbox = (-1_000_000.0, -1_000_000.0, 1_000_000.0, 1_000_000.0);
1272
1273        let result =
1274            DatasetRegistry::transform_bbox_with_densification(bbox, &source_crs, &target_crs);
1275        assert!(result.is_ok());
1276
1277        let (min_x, min_y, max_x, max_y) = result.expect("should transform");
1278
1279        // Should be roughly +-9 degrees
1280        assert!(
1281            min_x > -15.0 && min_x < -5.0,
1282            "min_x should be around -9 degrees"
1283        );
1284        assert!(
1285            max_x > 5.0 && max_x < 15.0,
1286            "max_x should be around 9 degrees"
1287        );
1288        assert!(
1289            min_y > -15.0 && min_y < -5.0,
1290            "min_y should be around -9 degrees"
1291        );
1292        assert!(
1293            max_y > 5.0 && max_y < 15.0,
1294            "max_y should be around 9 degrees"
1295        );
1296    }
1297
1298    #[test]
1299    fn test_transform_bbox_high_latitude() {
1300        let source_crs = Crs::wgs84();
1301        let target_crs = Crs::web_mercator();
1302
1303        // Northern Europe bbox (demonstrates importance of densification)
1304        let bbox = (0.0, 50.0, 10.0, 60.0);
1305
1306        let result =
1307            DatasetRegistry::transform_bbox_with_densification(bbox, &source_crs, &target_crs);
1308        assert!(result.is_ok());
1309
1310        let (_min_x, min_y, _max_x, max_y) = result.expect("should transform");
1311
1312        // Web Mercator Y values should be large positive numbers for 50-60 degrees N
1313        assert!(min_y > 6_000_000.0, "min_y should be > 6M for 50 degrees N");
1314        assert!(max_y > 8_000_000.0, "max_y should be > 8M for 60 degrees N");
1315    }
1316
1317    #[test]
1318    fn test_transform_bbox_simple_vs_densified() {
1319        let source_crs = Crs::wgs84();
1320        let target_crs = Crs::web_mercator();
1321
1322        // Large bbox where densification matters
1323        let bbox = (-20.0, 40.0, 20.0, 70.0);
1324
1325        let simple = DatasetRegistry::transform_bbox_simple(bbox, &source_crs, &target_crs);
1326        let densified =
1327            DatasetRegistry::transform_bbox_with_densification(bbox, &source_crs, &target_crs);
1328
1329        assert!(simple.is_ok());
1330        assert!(densified.is_ok());
1331
1332        // Both should produce valid results
1333        let simple = simple.expect("simple should work");
1334        let densified = densified.expect("densified should work");
1335
1336        // For Mercator projection, the results should be similar
1337        // The densified version may have slightly larger bounds due to curvature
1338        assert!(
1339            (simple.0 - densified.0).abs() < 100.0,
1340            "min_x should be similar"
1341        );
1342        assert!(
1343            (simple.2 - densified.2).abs() < 100.0,
1344            "max_x should be similar"
1345        );
1346    }
1347
1348    // Transform point test
1349    #[test]
1350    fn test_transform_point() {
1351        let source_crs = Crs::wgs84();
1352        let target_crs = Crs::web_mercator();
1353
1354        let result = DatasetRegistry::transform_point(0.0, 0.0, &source_crs, &target_crs);
1355        assert!(result.is_ok());
1356
1357        let (x, y) = result.expect("should transform");
1358        assert!((x - 0.0).abs() < 1.0, "x should be close to 0");
1359        assert!((y - 0.0).abs() < 1.0, "y should be close to 0");
1360    }
1361
1362    #[test]
1363    fn test_transform_point_london() {
1364        let source_crs = Crs::wgs84();
1365        let target_crs = Crs::web_mercator();
1366
1367        // London: -0.1276, 51.5074
1368        let result = DatasetRegistry::transform_point(-0.1276, 51.5074, &source_crs, &target_crs);
1369        assert!(result.is_ok());
1370
1371        let (x, y) = result.expect("should transform");
1372        // London in Web Mercator is approximately (-14200, 6711000)
1373        assert!(x > -20_000.0 && x < 0.0, "x should be slightly negative");
1374        assert!(
1375            y > 6_500_000.0 && y < 7_000_000.0,
1376            "y should be around 6.7M"
1377        );
1378    }
1379
1380    // UTM zone transformation tests
1381    #[test]
1382    fn test_transform_bbox_to_utm() {
1383        let source_crs = Crs::wgs84();
1384        // UTM Zone 32N (Central Europe, 6-12 degrees E)
1385        let target_crs = Crs::from_epsg(32632).expect("UTM 32N should exist");
1386
1387        // Bbox in Germany (within UTM zone 32)
1388        let bbox = (8.0, 48.0, 10.0, 50.0);
1389
1390        let result =
1391            DatasetRegistry::transform_bbox_with_densification(bbox, &source_crs, &target_crs);
1392        assert!(result.is_ok());
1393
1394        let (min_x, min_y, _max_x, _max_y) = result.expect("should transform");
1395
1396        // UTM coordinates should be in typical range
1397        // X (easting): 166,000 to 833,000 meters from false easting of 500,000
1398        // Y (northing): meters from equator
1399        assert!(
1400            min_x > 300_000.0 && min_x < 700_000.0,
1401            "easting should be in valid range"
1402        );
1403        assert!(
1404            min_y > 5_000_000.0 && min_y < 6_000_000.0,
1405            "northing should be in valid range"
1406        );
1407    }
1408
1409    // Edge case tests
1410    #[test]
1411    fn test_transform_bbox_antimeridian() {
1412        let source_crs = Crs::wgs84();
1413        let target_crs = Crs::web_mercator();
1414
1415        // Bbox near antimeridian (but not crossing)
1416        let bbox = (170.0, -10.0, 179.0, 10.0);
1417
1418        let result =
1419            DatasetRegistry::transform_bbox_with_densification(bbox, &source_crs, &target_crs);
1420        assert!(result.is_ok());
1421
1422        let (min_x, _min_y, max_x, _max_y) = result.expect("should transform");
1423        assert!(min_x > 0.0 && max_x > min_x, "should have valid x range");
1424    }
1425
1426    #[test]
1427    fn test_transform_bbox_polar_region() {
1428        let source_crs = Crs::wgs84();
1429        // Polar Stereographic North
1430        let target_crs = Crs::from_epsg(3413).expect("NSIDC Polar Stereographic should exist");
1431
1432        // Arctic region bbox
1433        let bbox = (-10.0, 70.0, 10.0, 80.0);
1434
1435        let result =
1436            DatasetRegistry::transform_bbox_with_densification(bbox, &source_crs, &target_crs);
1437        assert!(result.is_ok());
1438    }
1439
1440    // Error handling tests
1441    #[test]
1442    fn test_transform_bbox_invalid_crs() {
1443        // Try to parse a non-existent EPSG code
1444        let crs = DatasetRegistry::parse_crs("EPSG:99999");
1445        assert!(crs.is_err());
1446    }
1447}