use std::path::Path;
use crate::{Dataset, DatasetFormat, DatasetInfo, GeoTransform, OxiGdalError, Result};
#[derive(Debug, Clone, PartialEq, Default)]
pub enum VrtResolution {
#[default]
Average,
Highest,
Lowest,
User(f64),
}
#[derive(Debug, Clone, Default)]
pub struct VrtOptions {
pub resolution: VrtResolution,
pub no_data: Option<f64>,
pub separate_bands: bool,
pub srcnodata: Option<f64>,
}
pub fn build_vrt(sources: &[&Path], output_path: &Path, options: VrtOptions) -> Result<Dataset> {
if sources.is_empty() {
return Err(OxiGdalError::InvalidParameter {
parameter: "sources",
message: "at least one source file is required to build a VRT".to_string(),
});
}
let source_metas: Vec<SourceMeta> = sources
.iter()
.enumerate()
.map(|(idx, &src)| {
read_source_meta(src).map_err(|e| OxiGdalError::InvalidParameter {
parameter: "sources",
message: format!(
"failed to read metadata for source[{}] '{}': {e}",
idx,
src.display()
),
})
})
.collect::<Result<Vec<_>>>()?;
let pixel_size = resolve_pixel_size(&source_metas, &options.resolution);
let union_bbox = compute_union_bbox(&source_metas)?;
let total_width = ((union_bbox.max_x - union_bbox.min_x) / pixel_size).ceil() as u32;
let total_height = ((union_bbox.max_y - union_bbox.min_y) / pixel_size).ceil() as u32;
let band_count: u32 = source_metas.iter().map(|m| m.band_count).max().unwrap_or(1);
let xml = generate_vrt_xml(
&source_metas,
sources,
&union_bbox,
total_width,
total_height,
pixel_size,
band_count,
&options,
);
let output_str = output_path
.to_str()
.ok_or_else(|| OxiGdalError::InvalidParameter {
parameter: "output_path",
message: "output path contains non-UTF-8 characters".to_string(),
})?;
std::fs::write(output_path, &xml).map_err(|e| {
OxiGdalError::Io(oxigdal_core::error::IoError::Write {
message: format!("failed to write VRT file '{}': {e}", output_str),
})
})?;
let vrt_gt = GeoTransform::north_up(union_bbox.min_x, union_bbox.max_y, pixel_size, pixel_size);
let info = DatasetInfo {
format: DatasetFormat::Vrt,
path: Some(output_str.to_string()),
width: Some(total_width),
height: Some(total_height),
band_count,
layer_count: 0,
crs: source_metas.first().and_then(|m| m.crs.clone()),
geotransform: Some(vrt_gt),
feature_count: None,
bounds: None,
};
Ok(Dataset::from_info(output_str.to_string(), info))
}
#[derive(Debug, Clone)]
struct SourceMeta {
pixel_width: f64,
pixel_height: f64,
width: u32,
height: u32,
band_count: u32,
origin_x: f64,
origin_y: f64,
data_type_str: String,
crs: Option<String>,
}
impl SourceMeta {
fn min_x(&self) -> f64 {
self.origin_x
}
fn max_x(&self) -> f64 {
self.origin_x + self.width as f64 * self.pixel_width
}
fn min_y(&self) -> f64 {
self.origin_y - self.height as f64 * self.pixel_height
}
fn max_y(&self) -> f64 {
self.origin_y
}
}
#[derive(Debug, Clone)]
struct Bbox {
min_x: f64,
min_y: f64,
max_x: f64,
max_y: f64,
}
fn read_source_meta(path: &Path) -> Result<SourceMeta> {
let info_opt = crate::open::extract_tiff_info(path);
match info_opt {
Some(info) => {
let gt = info
.geotransform
.unwrap_or_else(|| GeoTransform::north_up(0.0, 0.0, 1.0, 1.0));
let width = info.width.unwrap_or(1);
let height = info.height.unwrap_or(1);
let pixel_height = gt.pixel_height.abs();
let data_type_str = "Float32".to_string(); Ok(SourceMeta {
pixel_width: gt.pixel_width,
pixel_height,
width,
height,
band_count: info.band_count.max(1),
origin_x: gt.origin_x,
origin_y: gt.origin_y,
data_type_str,
crs: info.crs,
})
}
None => Err(OxiGdalError::InvalidParameter {
parameter: "source",
message: format!(
"cannot read raster metadata from '{}' — only GeoTIFF sources are supported",
path.display()
),
}),
}
}
fn resolve_pixel_size(metas: &[SourceMeta], rule: &VrtResolution) -> f64 {
match rule {
VrtResolution::User(px) => *px,
VrtResolution::Average => {
let sum: f64 = metas.iter().map(|m| m.pixel_width).sum();
sum / metas.len() as f64
}
VrtResolution::Highest => metas
.iter()
.map(|m| m.pixel_width)
.fold(f64::INFINITY, f64::min),
VrtResolution::Lowest => metas.iter().map(|m| m.pixel_width).fold(0.0f64, f64::max),
}
}
fn compute_union_bbox(metas: &[SourceMeta]) -> Result<Bbox> {
let first = metas
.first()
.ok_or_else(|| OxiGdalError::InvalidParameter {
parameter: "sources",
message: "no sources to compute bbox from".to_string(),
})?;
let mut min_x = first.min_x();
let mut min_y = first.min_y();
let mut max_x = first.max_x();
let mut max_y = first.max_y();
for m in metas.iter().skip(1) {
if m.min_x() < min_x {
min_x = m.min_x();
}
if m.min_y() < min_y {
min_y = m.min_y();
}
if m.max_x() > max_x {
max_x = m.max_x();
}
if m.max_y() > max_y {
max_y = m.max_y();
}
}
Ok(Bbox {
min_x,
min_y,
max_x,
max_y,
})
}
fn gdal_dtype_str(dt_str: &str) -> &str {
match dt_str {
"UInt8" => "Byte",
"UInt16" => "UInt16",
"Int16" => "Int16",
"UInt32" => "UInt32",
"Int32" => "Int32",
"Float32" => "Float32",
"Float64" => "Float64",
other => other,
}
}
#[allow(clippy::too_many_arguments)]
fn generate_vrt_xml(
metas: &[SourceMeta],
sources: &[&Path],
bbox: &Bbox,
total_width: u32,
total_height: u32,
pixel_size: f64,
band_count: u32,
options: &VrtOptions,
) -> String {
let mut xml = String::with_capacity(4096);
xml.push_str("<VRTDataset rasterXSize=\"");
xml.push_str(&total_width.to_string());
xml.push_str("\" rasterYSize=\"");
xml.push_str(&total_height.to_string());
xml.push_str("\">\n");
xml.push_str(" <GeoTransform>");
xml.push_str(&format!(
"{:.10}, {:.10}, 0.0, {:.10}, 0.0, -{:.10}",
bbox.min_x, pixel_size, bbox.max_y, pixel_size
));
xml.push_str("</GeoTransform>\n");
if let Some(crs) = metas.first().and_then(|m| m.crs.as_ref()) {
xml.push_str(" <SRS>");
xml.push_str(crs);
xml.push_str("</SRS>\n");
}
for band_idx in 1..=band_count {
let dt_str = metas
.first()
.map(|m| gdal_dtype_str(&m.data_type_str))
.unwrap_or("Float32");
xml.push_str(" <VRTRasterBand dataType=\"");
xml.push_str(dt_str);
xml.push_str("\" band=\"");
xml.push_str(&band_idx.to_string());
xml.push_str("\">\n");
if let Some(nd) = options.no_data {
xml.push_str(" <NoDataValue>");
xml.push_str(&nd.to_string());
xml.push_str("</NoDataValue>\n");
}
for (meta, src_path) in metas.iter().zip(sources.iter()) {
let dst_off_x = ((meta.origin_x - bbox.min_x) / pixel_size).round() as i64;
let dst_off_y = ((bbox.max_y - meta.max_y()) / pixel_size).round() as i64;
let dst_w = (meta.width as f64 * meta.pixel_width / pixel_size).ceil() as u32;
let dst_h = (meta.height as f64 * meta.pixel_height / pixel_size).ceil() as u32;
let src_path_str = src_path.to_string_lossy();
xml.push_str(" <SimpleSource>\n");
xml.push_str(" <SourceFilename relativeToVRT=\"1\">");
xml.push_str(&src_path_str);
xml.push_str("</SourceFilename>\n");
xml.push_str(" <SourceBand>");
xml.push_str(&band_idx.to_string());
xml.push_str("</SourceBand>\n");
xml.push_str(" <SrcRect xOff=\"0\" yOff=\"0\" xSize=\"");
xml.push_str(&meta.width.to_string());
xml.push_str("\" ySize=\"");
xml.push_str(&meta.height.to_string());
xml.push_str("\"/>\n");
xml.push_str(" <DstRect xOff=\"");
xml.push_str(&dst_off_x.to_string());
xml.push_str("\" yOff=\"");
xml.push_str(&dst_off_y.to_string());
xml.push_str("\" xSize=\"");
xml.push_str(&dst_w.to_string());
xml.push_str("\" ySize=\"");
xml.push_str(&dst_h.to_string());
xml.push_str("\"/>\n");
if let Some(nd) = options.srcnodata {
xml.push_str(" <NODATA>");
xml.push_str(&nd.to_string());
xml.push_str("</NODATA>\n");
}
xml.push_str(" </SimpleSource>\n");
}
xml.push_str(" </VRTRasterBand>\n");
}
xml.push_str("</VRTDataset>\n");
xml
}
impl Dataset {
pub fn build_vrt(
sources: &[&Path],
output_path: &Path,
options: VrtOptions,
) -> Result<Dataset> {
build_vrt(sources, output_path, options)
}
}